Merge fdc749a74e into da71eaebd2
This commit is contained in:
commit
52325fa68e
40
LOCAL_STATE.md
Normal file
40
LOCAL_STATE.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# Local state + config export (dev-only)
|
||||||
|
|
||||||
|
Moltbot stores runtime state under your home directory (by default `~/.moltbot`, with legacy `~/.clawdbot` often pointing to the same place).
|
||||||
|
|
||||||
|
This repo intentionally does **not** track your real local config, pairing stores, tokens, or other secrets. Instead, it provides a script that copies local state into a gitignored folder and optionally writes a **redacted** snapshot that is safe to commit.
|
||||||
|
|
||||||
|
## Export local state into this repo
|
||||||
|
|
||||||
|
From the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/local/export-local-state.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
- `.local/moltbot/state/` (gitignored): a local backup of your state/config files
|
||||||
|
- `config/redacted/moltbot.redacted.json` (tracked): a redacted snapshot for reference/review
|
||||||
|
|
||||||
|
### Optional flags
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/local/export-local-state.mjs --include-agents --include-memory --include-logs
|
||||||
|
```
|
||||||
|
|
||||||
|
Those folders can be large.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- The export script intentionally skips OAuth credential files like `oauth.json`.
|
||||||
|
- Always review `config/redacted/moltbot.redacted.json` before committing.
|
||||||
|
- Never commit real tokens, secrets, phone numbers, or personal identifiers.
|
||||||
|
|
||||||
|
## Optional: import a local notes folder
|
||||||
|
|
||||||
|
If you keep local operator notes in a folder like `~/clawd/`, you can copy it into this repo under `.local/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/local/import-clawd.mjs
|
||||||
|
```
|
||||||
|
|
||||||
193
MERGE-STRATEGY-v2026.1.29.md
Normal file
193
MERGE-STRATEGY-v2026.1.29.md
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
# Merge Strategy: moltbot-fork → openclaw v2026.1.29
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
- **Branch**: `feat/session-compact-tool`
|
||||||
|
- **Base commit**: `c9fe062824cabdf919cfbedc1b915375b5e684d1`
|
||||||
|
- **Target tag**: `v2026.1.29` (`77e703c69b07a236c2f0962bd195e03aae1b8da0`)
|
||||||
|
- **Our commits** (3 total):
|
||||||
|
1. `efd6209bc` - feat(tools): add session_compact tool for agent-invoked context compaction
|
||||||
|
2. `30262f9ff` - feat(session_compact): add threshold check and auto-save compaction file
|
||||||
|
3. `ec8ddeb5a` - fix(session_compact): use direct compaction when called from active run
|
||||||
|
|
||||||
|
## Files Analysis
|
||||||
|
|
||||||
|
### ⚠️ HIGH-RISK CONFLICTS (changed both sides)
|
||||||
|
| File | Our Changes | Risk |
|
||||||
|
|------|-------------|------|
|
||||||
|
| `package.json` | Our custom scripts/deps | **HIGH** - version bumps both sides |
|
||||||
|
| `src/plugins/tools.ts` | Added session_compact registration | **HIGH** - tool registration changes |
|
||||||
|
| `src/telegram/bot-message-context.ts` | Our custom modifications | **MEDIUM** |
|
||||||
|
| `src/telegram/bot.test.ts` | Test updates | **LOW** (just tests) |
|
||||||
|
|
||||||
|
### ✅ SAFE FILES (only our changes)
|
||||||
|
These files are new or only modified by us - no upstream changes:
|
||||||
|
- `src/agents/tools/session-compact-tool.ts` (NEW - 282 lines)
|
||||||
|
- `src/agents/session-tool-result-guard.ts` (our hook additions)
|
||||||
|
- `src/agents/pi-embedded-runner.ts` (minor modifications)
|
||||||
|
- `src/agents/pi-embedded.ts` (1-line addition)
|
||||||
|
- `src/agents/moltbot-tools.ts` (renamed from openclaw-tools.ts)
|
||||||
|
|
||||||
|
### 📁 NEW FILES (will be preserved)
|
||||||
|
- `LOCAL_STATE.md`
|
||||||
|
- `config/redacted/.gitkeep`
|
||||||
|
- `config/redacted/moltbot.redacted.json`
|
||||||
|
- `scripts/local/export-local-state.mjs`
|
||||||
|
- `scripts/local/import-clawd.mjs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Approach: **MERGE** (not rebase)
|
||||||
|
|
||||||
|
### Why Merge over Rebase
|
||||||
|
1. **Preserves history** - Our 3 commits stay intact
|
||||||
|
2. **Easier conflict resolution** - Single merge commit to fix conflicts
|
||||||
|
3. **Safer rollback** - Can easily revert the merge commit if something breaks
|
||||||
|
4. **Works with pushed branches** - Our `feat/session-compact-tool` is already on `fork` remote
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step-by-Step Procedure
|
||||||
|
|
||||||
|
### Phase 1: Backup
|
||||||
|
```bash
|
||||||
|
cd ~/moltbot-fork
|
||||||
|
|
||||||
|
# Create backup branch
|
||||||
|
git branch backup/pre-merge-v2026.1.29 feat/session-compact-tool
|
||||||
|
|
||||||
|
# Also tag current state
|
||||||
|
git tag pre-merge-snapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: Fetch & Prepare
|
||||||
|
```bash
|
||||||
|
# Ensure we have latest tags
|
||||||
|
git fetch origin --tags
|
||||||
|
|
||||||
|
# Verify target exists
|
||||||
|
git rev-parse v2026.1.29
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: Merge
|
||||||
|
```bash
|
||||||
|
# Make sure we're on our feature branch
|
||||||
|
git checkout feat/session-compact-tool
|
||||||
|
|
||||||
|
# Merge the tag (creates merge commit)
|
||||||
|
git merge v2026.1.29 --no-edit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 4: Resolve Conflicts
|
||||||
|
When conflicts occur, resolve in this order:
|
||||||
|
|
||||||
|
#### 1. `package.json`
|
||||||
|
- Keep upstream version number (`v2026.1.29` or its version)
|
||||||
|
- Preserve any of our custom `scripts.local.*` entries
|
||||||
|
- Accept upstream dependency versions
|
||||||
|
```bash
|
||||||
|
# After resolving:
|
||||||
|
pnpm install # regenerate lockfile
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. `src/plugins/tools.ts`
|
||||||
|
- Keep ALL upstream changes to the file
|
||||||
|
- Re-add our session_compact tool import and registration
|
||||||
|
- Look for the `tools` array and add:
|
||||||
|
```typescript
|
||||||
|
import { sessionCompactTool } from '../agents/tools/session-compact-tool.js';
|
||||||
|
// ... in the tools array:
|
||||||
|
sessionCompactTool,
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. `src/telegram/bot-message-context.ts`
|
||||||
|
- Carefully merge - likely both changes can coexist
|
||||||
|
- Test Telegram functionality after
|
||||||
|
|
||||||
|
#### 4. `src/telegram/bot.test.ts`
|
||||||
|
- Accept upstream test changes
|
||||||
|
- Verify our functionality still works
|
||||||
|
|
||||||
|
### Phase 5: Verify
|
||||||
|
```bash
|
||||||
|
# Build
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
pnpm test
|
||||||
|
|
||||||
|
# Quick smoke test
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 6: Complete
|
||||||
|
```bash
|
||||||
|
# Add resolved files
|
||||||
|
git add .
|
||||||
|
|
||||||
|
# Complete merge
|
||||||
|
git commit
|
||||||
|
|
||||||
|
# Push to fork
|
||||||
|
git push fork feat/session-compact-tool
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback Strategy
|
||||||
|
|
||||||
|
### If merge goes wrong BEFORE commit:
|
||||||
|
```bash
|
||||||
|
git merge --abort
|
||||||
|
```
|
||||||
|
|
||||||
|
### If merge was committed but broken:
|
||||||
|
```bash
|
||||||
|
git reset --hard backup/pre-merge-v2026.1.29
|
||||||
|
```
|
||||||
|
|
||||||
|
### If merge was pushed and needs revert:
|
||||||
|
```bash
|
||||||
|
git revert -m 1 HEAD # revert the merge commit
|
||||||
|
git push fork feat/session-compact-tool
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-Merge Checklist
|
||||||
|
|
||||||
|
- [ ] `pnpm build` succeeds
|
||||||
|
- [ ] `pnpm test` passes (or known failures documented)
|
||||||
|
- [ ] Session compact tool works (`/compact` command)
|
||||||
|
- [ ] Telegram bot starts without errors
|
||||||
|
- [ ] Gateway starts without errors
|
||||||
|
- [ ] Our custom scripts still work:
|
||||||
|
- [ ] `pnpm run local:export`
|
||||||
|
- [ ] `pnpm run local:import`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternative: Cherry-Pick (if merge is too messy)
|
||||||
|
|
||||||
|
If the merge produces too many conflicts (>10 files), consider:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start fresh from v2026.1.29
|
||||||
|
git checkout -b feat/session-compact-tool-rebased v2026.1.29
|
||||||
|
|
||||||
|
# Cherry-pick our commits one by one
|
||||||
|
git cherry-pick efd6209bc # initial session_compact
|
||||||
|
git cherry-pick 30262f9ff # threshold + auto-save
|
||||||
|
git cherry-pick ec8ddeb5a # direct compaction fix
|
||||||
|
|
||||||
|
# This will also have conflicts but scoped to our changes only
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The large diff count (~1000+ files) is mostly due to brand renaming (openclaw→moltbot) in upstream
|
||||||
|
- Our core feature (session_compact tool) is isolated in 4-5 files
|
||||||
|
- The merge should complete in under 30 minutes for an experienced dev
|
||||||
|
|
||||||
|
Generated: 2025-01-29
|
||||||
1
config/redacted/.gitkeep
Normal file
1
config/redacted/.gitkeep
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
197
config/redacted/moltbot.redacted.json
Normal file
197
config/redacted/moltbot.redacted.json
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"lastTouchedVersion": "2026.1.27-beta.1",
|
||||||
|
"lastTouchedAt": "2026-01-29T18:27:46.225Z"
|
||||||
|
},
|
||||||
|
"wizard": {
|
||||||
|
"lastRunAt": "2026-01-28T03:25:58.516Z",
|
||||||
|
"lastRunVersion": "2026.1.24-3",
|
||||||
|
"lastRunCommand": "configure",
|
||||||
|
"lastRunMode": "local"
|
||||||
|
},
|
||||||
|
"browser": {
|
||||||
|
"enabled": true,
|
||||||
|
"remoteCdpTimeoutMs": 0,
|
||||||
|
"remoteCdpHandshakeTimeoutMs": 60000
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"profiles": {
|
||||||
|
"openai-codex:codex-cli": {
|
||||||
|
"provider": "openai-codex",
|
||||||
|
"mode": "oauth"
|
||||||
|
},
|
||||||
|
"anthropic:claude-cli": {
|
||||||
|
"provider": "anthropic",
|
||||||
|
"mode": "oauth"
|
||||||
|
},
|
||||||
|
"openai:manual": {
|
||||||
|
"provider": "openai",
|
||||||
|
"mode": "token"
|
||||||
|
},
|
||||||
|
"zai:default": {
|
||||||
|
"provider": "zai",
|
||||||
|
"mode": "api_key"
|
||||||
|
},
|
||||||
|
"anthropic:default": {
|
||||||
|
"provider": "anthropic",
|
||||||
|
"mode": "token"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"anthropic": [
|
||||||
|
"<redacted>"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": {
|
||||||
|
"primary": "anthropic/claude-opus-4-5",
|
||||||
|
"fallbacks": [
|
||||||
|
"<redacted>"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"anthropic/claude-opus-4-5": {
|
||||||
|
"alias": "opus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workspace": "/Users/conradsasinski/clawd",
|
||||||
|
"memorySearch": {
|
||||||
|
"sources": [
|
||||||
|
"<redacted>"
|
||||||
|
],
|
||||||
|
"experimental": {
|
||||||
|
"sessionMemory": true
|
||||||
|
},
|
||||||
|
"provider": "openai",
|
||||||
|
"fallback": "openai",
|
||||||
|
"model": "text-embedding-3-small",
|
||||||
|
"sync": {
|
||||||
|
"watch": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compaction": {
|
||||||
|
"memoryFlush": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"thinkingDefault": "medium",
|
||||||
|
"elevatedDefault": "full",
|
||||||
|
"maxConcurrent": 4,
|
||||||
|
"subagents": {
|
||||||
|
"maxConcurrent": 8
|
||||||
|
},
|
||||||
|
"sandbox": {
|
||||||
|
"mode": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"list": [
|
||||||
|
"<redacted>"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"allow": [
|
||||||
|
"<redacted>"
|
||||||
|
],
|
||||||
|
"web": {
|
||||||
|
"search": {
|
||||||
|
"enabled": true,
|
||||||
|
"apiKey": "<redacted>"
|
||||||
|
},
|
||||||
|
"fetch": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agentToAgent": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"elevated": {
|
||||||
|
"enabled": true,
|
||||||
|
"allowFrom": []
|
||||||
|
},
|
||||||
|
"exec": {
|
||||||
|
"host": "gateway",
|
||||||
|
"security": "full",
|
||||||
|
"ask": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"inbound": {
|
||||||
|
"byChannel": {
|
||||||
|
"telegram": 2000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ackReactionScope": "group-mentions"
|
||||||
|
},
|
||||||
|
"commands": {
|
||||||
|
"native": "auto",
|
||||||
|
"nativeSkills": "auto",
|
||||||
|
"restart": true
|
||||||
|
},
|
||||||
|
"hooks": {
|
||||||
|
"internal": {
|
||||||
|
"enabled": true,
|
||||||
|
"entries": {
|
||||||
|
"session-memory": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"telegram": {
|
||||||
|
"enabled": true,
|
||||||
|
"dmPolicy": "pairing",
|
||||||
|
"botToken": "<redacted>",
|
||||||
|
"replyToMode": "off",
|
||||||
|
"groupPolicy": "allowlist",
|
||||||
|
"streamMode": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"talk": {
|
||||||
|
"apiKey": "<redacted>"
|
||||||
|
},
|
||||||
|
"gateway": {
|
||||||
|
"port": 18789,
|
||||||
|
"mode": "local",
|
||||||
|
"bind": "loopback",
|
||||||
|
"auth": {
|
||||||
|
"mode": "token",
|
||||||
|
"token": "<redacted>"
|
||||||
|
},
|
||||||
|
"tailscale": {
|
||||||
|
"mode": "off",
|
||||||
|
"resetOnExit": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skills": {
|
||||||
|
"load": {
|
||||||
|
"watch": true,
|
||||||
|
"watchDebounceMs": 500
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"nodeManager": "npm"
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"sag": {
|
||||||
|
"apiKey": "<redacted>"
|
||||||
|
},
|
||||||
|
"cronometer-logger": {
|
||||||
|
"enabled": true,
|
||||||
|
"config": {
|
||||||
|
"mode": "day-only",
|
||||||
|
"diaryGroup": "disabled",
|
||||||
|
"defaultDate": "today"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"entries": {
|
||||||
|
"telegram": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -144,7 +144,10 @@
|
|||||||
"protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts",
|
"protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts",
|
||||||
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/OpenClawProtocol/GatewayModels.swift",
|
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/OpenClawProtocol/GatewayModels.swift",
|
||||||
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
||||||
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500"
|
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
|
||||||
|
"local:export-state": "node scripts/local/export-local-state.mjs",
|
||||||
|
"local:export-state:full": "node scripts/local/export-local-state.mjs --include-agents --include-memory --include-logs",
|
||||||
|
"local:import-clawd": "node scripts/local/import-clawd.mjs"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
285
scripts/local/export-local-state.mjs
Normal file
285
scripts/local/export-local-state.mjs
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = {
|
||||||
|
source: null,
|
||||||
|
out: null,
|
||||||
|
redactOut: null,
|
||||||
|
includeAgents: false,
|
||||||
|
includeLogs: false,
|
||||||
|
includeMemory: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const cur = argv[i];
|
||||||
|
if (cur === "--source") args.source = argv[++i] ?? null;
|
||||||
|
else if (cur === "--out") args.out = argv[++i] ?? null;
|
||||||
|
else if (cur === "--redact-out") args.redactOut = argv[++i] ?? null;
|
||||||
|
else if (cur === "--include-agents") args.includeAgents = true;
|
||||||
|
else if (cur === "--include-logs") args.includeLogs = true;
|
||||||
|
else if (cur === "--include-memory") args.includeMemory = true;
|
||||||
|
else if (cur === "--help" || cur === "-h") {
|
||||||
|
args.help = true;
|
||||||
|
} else if (cur?.startsWith("--")) {
|
||||||
|
throw new Error(`Unknown flag: ${cur}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandUserPath(input) {
|
||||||
|
const trimmed = String(input ?? "").trim();
|
||||||
|
if (!trimmed) return trimmed;
|
||||||
|
if (trimmed.startsWith("~")) {
|
||||||
|
return path.resolve(trimmed.replace(/^~(?=$|[\\/])/, os.homedir()));
|
||||||
|
}
|
||||||
|
return path.resolve(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(p) {
|
||||||
|
try {
|
||||||
|
await fs.access(p);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDir(dir) {
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyFileIfExists(from, to) {
|
||||||
|
if (!(await pathExists(from))) return false;
|
||||||
|
await ensureDir(path.dirname(to));
|
||||||
|
await fs.copyFile(from, to);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyDirIfExists(fromDir, toDir, { filter } = {}) {
|
||||||
|
if (!(await pathExists(fromDir))) return { copied: 0, skipped: 0 };
|
||||||
|
await ensureDir(toDir);
|
||||||
|
|
||||||
|
let copied = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
const entries = await fs.readdir(fromDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const src = path.join(fromDir, entry.name);
|
||||||
|
const dst = path.join(toDir, entry.name);
|
||||||
|
if (filter && !filter({ name: entry.name, src, isDir: entry.isDirectory() })) {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const res = await copyDirIfExists(src, dst, { filter });
|
||||||
|
copied += res.copied;
|
||||||
|
skipped += res.skipped;
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
await ensureDir(path.dirname(dst));
|
||||||
|
await fs.copyFile(src, dst);
|
||||||
|
copied += 1;
|
||||||
|
} else {
|
||||||
|
skipped += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { copied, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRedactKey(key) {
|
||||||
|
const k = String(key).toLowerCase();
|
||||||
|
return (
|
||||||
|
k.includes("token") ||
|
||||||
|
k.includes("secret") ||
|
||||||
|
k.includes("password") ||
|
||||||
|
k.includes("apikey") ||
|
||||||
|
k.includes("api_key") ||
|
||||||
|
k.endsWith("key")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactValue(value) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
// Telegram bot token format: <digits>:<base64url-ish>
|
||||||
|
if (/^\d+:[A-Za-z0-9_-]{20,}$/.test(trimmed)) return "<redacted>";
|
||||||
|
// Discord bot token-ish (very loose) or other opaque tokens
|
||||||
|
if (trimmed.length >= 24 && /^[A-Za-z0-9._-]+$/.test(trimmed)) return "<redacted>";
|
||||||
|
// Local absolute paths are usually personal; keep only basename.
|
||||||
|
if (path.isAbsolute(trimmed) || trimmed.startsWith("~/")) {
|
||||||
|
return `<path:${path.basename(trimmed)}>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "<redacted>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactObject(obj) {
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
// Lists often contain ids/handles; keep shape but hide contents.
|
||||||
|
return obj.length > 0 ? ["<redacted>"] : [];
|
||||||
|
}
|
||||||
|
if (!obj || typeof obj !== "object") return obj;
|
||||||
|
|
||||||
|
const out = {};
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
if (shouldRedactKey(key)) {
|
||||||
|
out[key] = redactValue(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known id-heavy fields: keep presence but hide.
|
||||||
|
if (key === "allowFrom" || key === "groupAllowFrom") {
|
||||||
|
out[key] = Array.isArray(value) && value.length > 0 ? ["<redacted>"] : [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "groups" && value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
const v = value;
|
||||||
|
const keep = {};
|
||||||
|
if (Object.prototype.hasOwnProperty.call(v, "*")) keep["*"] = v["*"];
|
||||||
|
const exampleKey = Object.keys(v).find((k) => k !== "*");
|
||||||
|
if (exampleKey) keep["<redacted>"] = v[exampleKey];
|
||||||
|
out[key] = keep;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
out[key] = redactObject(value);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonFile(filePath) {
|
||||||
|
const raw = await fs.readFile(filePath, "utf-8");
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
const json5 = await import("json5");
|
||||||
|
return json5.default.parse(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJsonFile(filePath, value) {
|
||||||
|
await ensureDir(path.dirname(filePath));
|
||||||
|
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatList(items) {
|
||||||
|
return items.length ? items.map((v) => `- ${v}`).join("\n") : "- (none)";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
if (args.help) {
|
||||||
|
process.stdout.write(
|
||||||
|
[
|
||||||
|
"Usage: node scripts/local/export-local-state.mjs [flags]",
|
||||||
|
"",
|
||||||
|
"Flags:",
|
||||||
|
" --source <dir> Source state dir (default: ~/.moltbot)",
|
||||||
|
" --out <dir> Output dir (default: ./.local/moltbot/state)",
|
||||||
|
" --redact-out <dir> Write redacted snapshots here (default: ./config/redacted)",
|
||||||
|
" --include-agents Copy ~/.moltbot/agents (can be large)",
|
||||||
|
" --include-memory Copy ~/.moltbot/memory (can be large)",
|
||||||
|
" --include-logs Copy ~/.moltbot/logs (can be large)",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = expandUserPath(args.source ?? "~/.moltbot");
|
||||||
|
const outDir = expandUserPath(args.out ?? path.join(process.cwd(), ".local", "moltbot", "state"));
|
||||||
|
const redactOutDir = expandUserPath(
|
||||||
|
args.redactOut ?? path.join(process.cwd(), "config", "redacted"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!(await pathExists(source))) {
|
||||||
|
throw new Error(`Source state dir not found: ${source}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureDir(outDir);
|
||||||
|
await ensureDir(redactOutDir);
|
||||||
|
|
||||||
|
const copied = [];
|
||||||
|
const missing = [];
|
||||||
|
|
||||||
|
const configPath = path.join(source, "moltbot.json");
|
||||||
|
if (await copyFileIfExists(configPath, path.join(outDir, "moltbot.json"))) copied.push("moltbot.json");
|
||||||
|
else missing.push("moltbot.json");
|
||||||
|
|
||||||
|
// Backups (helpful for diffing/migrations)
|
||||||
|
const stateEntries = await fs.readdir(source, { withFileTypes: true });
|
||||||
|
for (const entry of stateEntries) {
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
if (!/^moltbot\.json\.bak(\.|$)/.test(entry.name) && !/^clawdbot\.json\.bak(\.|$)/.test(entry.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await copyFileIfExists(path.join(source, entry.name), path.join(outDir, entry.name));
|
||||||
|
copied.push(entry.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credentials: copy pairing + allowFrom stores only (avoid oauth.json).
|
||||||
|
const credsSrc = path.join(source, "credentials");
|
||||||
|
await copyDirIfExists(credsSrc, path.join(outDir, "credentials"), {
|
||||||
|
filter: ({ name, isDir }) => {
|
||||||
|
if (isDir) return true;
|
||||||
|
return name.endsWith("-allowFrom.json") || name.endsWith("-pairing.json");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (await pathExists(credsSrc)) copied.push("credentials/*(-allowFrom|-pairing).json");
|
||||||
|
|
||||||
|
// Telegram update offsets
|
||||||
|
const tgSrc = path.join(source, "telegram");
|
||||||
|
await copyDirIfExists(tgSrc, path.join(outDir, "telegram"), {
|
||||||
|
filter: ({ name, isDir }) => isDir || name.startsWith("update-offset-"),
|
||||||
|
});
|
||||||
|
if (await pathExists(tgSrc)) copied.push("telegram/update-offset-*.json");
|
||||||
|
|
||||||
|
// Optional large dirs
|
||||||
|
if (args.includeAgents) {
|
||||||
|
await copyDirIfExists(path.join(source, "agents"), path.join(outDir, "agents"));
|
||||||
|
copied.push("agents/**");
|
||||||
|
}
|
||||||
|
if (args.includeMemory) {
|
||||||
|
await copyDirIfExists(path.join(source, "memory"), path.join(outDir, "memory"));
|
||||||
|
copied.push("memory/**");
|
||||||
|
}
|
||||||
|
if (args.includeLogs) {
|
||||||
|
await copyDirIfExists(path.join(source, "logs"), path.join(outDir, "logs"));
|
||||||
|
copied.push("logs/**");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redacted snapshot of config for version control.
|
||||||
|
if (await pathExists(configPath)) {
|
||||||
|
const cfg = await readJsonFile(configPath);
|
||||||
|
const redacted = redactObject(cfg);
|
||||||
|
await writeJsonFile(path.join(redactOutDir, "moltbot.redacted.json"), redacted);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(
|
||||||
|
[
|
||||||
|
"Export complete.",
|
||||||
|
"",
|
||||||
|
`Source: ${source}`,
|
||||||
|
`Out: ${outDir}`,
|
||||||
|
`Redact: ${redactOutDir}`,
|
||||||
|
"",
|
||||||
|
"Copied:",
|
||||||
|
formatList(copied),
|
||||||
|
"",
|
||||||
|
"Missing:",
|
||||||
|
formatList(missing),
|
||||||
|
"",
|
||||||
|
"Notes:",
|
||||||
|
"- Out dir is under .local/ and is gitignored by default.",
|
||||||
|
"- Redacted config snapshot is safe to commit; validate before pushing.",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
|
|
||||||
141
scripts/local/import-clawd.mjs
Normal file
141
scripts/local/import-clawd.mjs
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = {
|
||||||
|
source: null,
|
||||||
|
out: null,
|
||||||
|
includeMemory: true,
|
||||||
|
};
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const cur = argv[i];
|
||||||
|
if (cur === "--source") args.source = argv[++i] ?? null;
|
||||||
|
else if (cur === "--out") args.out = argv[++i] ?? null;
|
||||||
|
else if (cur === "--no-memory") args.includeMemory = false;
|
||||||
|
else if (cur === "--help" || cur === "-h") args.help = true;
|
||||||
|
else if (cur?.startsWith("--")) throw new Error(`Unknown flag: ${cur}`);
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandUserPath(input) {
|
||||||
|
const trimmed = String(input ?? "").trim();
|
||||||
|
if (!trimmed) return trimmed;
|
||||||
|
if (trimmed.startsWith("~")) {
|
||||||
|
return path.resolve(trimmed.replace(/^~(?=$|[\\/])/, os.homedir()));
|
||||||
|
}
|
||||||
|
return path.resolve(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(p) {
|
||||||
|
try {
|
||||||
|
await fs.access(p);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDir(dir) {
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyFileIfExists(from, to) {
|
||||||
|
if (!(await pathExists(from))) return false;
|
||||||
|
await ensureDir(path.dirname(to));
|
||||||
|
await fs.copyFile(from, to);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyDir(fromDir, toDir) {
|
||||||
|
await ensureDir(toDir);
|
||||||
|
const entries = await fs.readdir(fromDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const src = path.join(fromDir, entry.name);
|
||||||
|
const dst = path.join(toDir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await copyDir(src, dst);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
await ensureDir(path.dirname(dst));
|
||||||
|
await fs.copyFile(src, dst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatList(items) {
|
||||||
|
return items.length ? items.map((v) => `- ${v}`).join("\n") : "- (none)";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
if (args.help) {
|
||||||
|
process.stdout.write(
|
||||||
|
[
|
||||||
|
"Usage: node scripts/local/import-clawd.mjs [flags]",
|
||||||
|
"",
|
||||||
|
"Flags:",
|
||||||
|
" --source <dir> Source folder (default: ~/clawd)",
|
||||||
|
" --out <dir> Output folder (default: ./.local/clawd)",
|
||||||
|
" --no-memory Do not copy memory/ (can be large)",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = expandUserPath(args.source ?? "~/clawd");
|
||||||
|
const outDir = expandUserPath(args.out ?? path.join(process.cwd(), ".local", "clawd"));
|
||||||
|
|
||||||
|
if (!(await pathExists(source))) {
|
||||||
|
throw new Error(`Source folder not found: ${source}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const copied = [];
|
||||||
|
const missing = [];
|
||||||
|
const topFiles = [
|
||||||
|
"AGENTS.md",
|
||||||
|
"HEARTBEAT.md",
|
||||||
|
"SOUL.md",
|
||||||
|
"TOOLS.md",
|
||||||
|
"USER.md",
|
||||||
|
"IDENTITY.md",
|
||||||
|
"MEMORY.md",
|
||||||
|
];
|
||||||
|
for (const name of topFiles) {
|
||||||
|
const ok = await copyFileIfExists(path.join(source, name), path.join(outDir, name));
|
||||||
|
(ok ? copied : missing).push(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.includeMemory) {
|
||||||
|
const mem = path.join(source, "memory");
|
||||||
|
if (await pathExists(mem)) {
|
||||||
|
await copyDir(mem, path.join(outDir, "memory"));
|
||||||
|
copied.push("memory/**");
|
||||||
|
} else {
|
||||||
|
missing.push("memory/**");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(
|
||||||
|
[
|
||||||
|
"Import complete.",
|
||||||
|
"",
|
||||||
|
`Source: ${source}`,
|
||||||
|
`Out: ${outDir}`,
|
||||||
|
"",
|
||||||
|
"Copied:",
|
||||||
|
formatList(copied),
|
||||||
|
"",
|
||||||
|
"Missing:",
|
||||||
|
formatList(missing),
|
||||||
|
"",
|
||||||
|
"Notes:",
|
||||||
|
"- Out dir is under .local/ and is gitignored by default.",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
|
|
||||||
@ -11,6 +11,7 @@ import { createGatewayTool } from "./tools/gateway-tool.js";
|
|||||||
import { createImageTool } from "./tools/image-tool.js";
|
import { createImageTool } from "./tools/image-tool.js";
|
||||||
import { createMessageTool } from "./tools/message-tool.js";
|
import { createMessageTool } from "./tools/message-tool.js";
|
||||||
import { createNodesTool } from "./tools/nodes-tool.js";
|
import { createNodesTool } from "./tools/nodes-tool.js";
|
||||||
|
import { createSessionCompactTool } from "./tools/session-compact-tool.js";
|
||||||
import { createSessionStatusTool } from "./tools/session-status-tool.js";
|
import { createSessionStatusTool } from "./tools/session-status-tool.js";
|
||||||
import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
|
import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
|
||||||
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
|
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
|
||||||
@ -134,6 +135,11 @@ export function createOpenClawTools(options?: {
|
|||||||
agentSessionKey: options?.agentSessionKey,
|
agentSessionKey: options?.agentSessionKey,
|
||||||
config: options?.config,
|
config: options?.config,
|
||||||
}),
|
}),
|
||||||
|
createSessionCompactTool({
|
||||||
|
agentSessionKey: options?.agentSessionKey,
|
||||||
|
config: options?.config,
|
||||||
|
workspaceDir: options?.workspaceDir,
|
||||||
|
}),
|
||||||
...(webSearchTool ? [webSearchTool] : []),
|
...(webSearchTool ? [webSearchTool] : []),
|
||||||
...(webFetchTool ? [webFetchTool] : []),
|
...(webFetchTool ? [webFetchTool] : []),
|
||||||
...(imageTool ? [imageTool] : []),
|
...(imageTool ? [imageTool] : []),
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
export type { MessagingToolSend } from "./pi-embedded-messaging.js";
|
export type { MessagingToolSend } from "./pi-embedded-messaging.js";
|
||||||
export { compactEmbeddedPiSession } from "./pi-embedded-runner/compact.js";
|
export {
|
||||||
|
compactEmbeddedPiSession,
|
||||||
|
compactEmbeddedPiSessionDirect,
|
||||||
|
} from "./pi-embedded-runner/compact.js";
|
||||||
export { applyExtraParamsToAgent, resolveExtraParams } from "./pi-embedded-runner/extra-params.js";
|
export { applyExtraParamsToAgent, resolveExtraParams } from "./pi-embedded-runner/extra-params.js";
|
||||||
|
|
||||||
export { applyGoogleTurnOrderingFix } from "./pi-embedded-runner/google.js";
|
export { applyGoogleTurnOrderingFix } from "./pi-embedded-runner/google.js";
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export type {
|
|||||||
export {
|
export {
|
||||||
abortEmbeddedPiRun,
|
abortEmbeddedPiRun,
|
||||||
compactEmbeddedPiSession,
|
compactEmbeddedPiSession,
|
||||||
|
compactEmbeddedPiSessionDirect,
|
||||||
isEmbeddedPiRunActive,
|
isEmbeddedPiRunActive,
|
||||||
isEmbeddedPiRunStreaming,
|
isEmbeddedPiRunStreaming,
|
||||||
queueEmbeddedPiMessage,
|
queueEmbeddedPiMessage,
|
||||||
|
|||||||
@ -3,6 +3,9 @@ import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
|||||||
|
|
||||||
import { makeMissingToolResult } from "./session-transcript-repair.js";
|
import { makeMissingToolResult } from "./session-transcript-repair.js";
|
||||||
import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js";
|
import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js";
|
||||||
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
|
|
||||||
|
const log = createSubsystemLogger("session-tool-result-guard");
|
||||||
|
|
||||||
type ToolCall = { id: string; name?: string };
|
type ToolCall = { id: string; name?: string };
|
||||||
|
|
||||||
@ -69,8 +72,16 @@ export function installSessionToolResultGuard(
|
|||||||
|
|
||||||
const flushPendingToolResults = () => {
|
const flushPendingToolResults = () => {
|
||||||
if (pending.size === 0) return;
|
if (pending.size === 0) return;
|
||||||
|
log.warn(
|
||||||
|
`flushPendingToolResults called with ${pending.size} pending tool calls: ${Array.from(
|
||||||
|
pending.entries(),
|
||||||
|
)
|
||||||
|
.map(([id, name]) => `${name ?? "unknown"}(${id})`)
|
||||||
|
.join(", ")}`,
|
||||||
|
);
|
||||||
if (allowSyntheticToolResults) {
|
if (allowSyntheticToolResults) {
|
||||||
for (const [id, name] of pending.entries()) {
|
for (const [id, name] of pending.entries()) {
|
||||||
|
log.warn(`Creating synthetic error result for tool call: ${name ?? "unknown"}(${id})`);
|
||||||
const synthetic = makeMissingToolResult({ toolCallId: id, toolName: name });
|
const synthetic = makeMissingToolResult({ toolCallId: id, toolName: name });
|
||||||
originalAppend(
|
originalAppend(
|
||||||
persistToolResult(synthetic, {
|
persistToolResult(synthetic, {
|
||||||
@ -90,7 +101,11 @@ export function installSessionToolResultGuard(
|
|||||||
if (role === "toolResult") {
|
if (role === "toolResult") {
|
||||||
const id = extractToolResultId(message as Extract<AgentMessage, { role: "toolResult" }>);
|
const id = extractToolResultId(message as Extract<AgentMessage, { role: "toolResult" }>);
|
||||||
const toolName = id ? pending.get(id) : undefined;
|
const toolName = id ? pending.get(id) : undefined;
|
||||||
|
const wasPending = id ? pending.has(id) : false;
|
||||||
if (id) pending.delete(id);
|
if (id) pending.delete(id);
|
||||||
|
log.debug(
|
||||||
|
`Tool result received: ${toolName ?? "unknown"}(${id}) - wasPending=${wasPending}, remainingPending=${pending.size}`,
|
||||||
|
);
|
||||||
return originalAppend(
|
return originalAppend(
|
||||||
persistToolResult(message, {
|
persistToolResult(message, {
|
||||||
toolCallId: id ?? undefined,
|
toolCallId: id ?? undefined,
|
||||||
@ -128,6 +143,9 @@ export function installSessionToolResultGuard(
|
|||||||
if (toolCalls.length > 0) {
|
if (toolCalls.length > 0) {
|
||||||
for (const call of toolCalls) {
|
for (const call of toolCalls) {
|
||||||
pending.set(call.id, call.name);
|
pending.set(call.id, call.name);
|
||||||
|
log.debug(
|
||||||
|
`Tool call added to pending: ${call.name ?? "unknown"}(${call.id}) - totalPending=${pending.size}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
282
src/agents/tools/session-compact-tool.ts
Normal file
282
src/agents/tools/session-compact-tool.ts
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import {
|
||||||
|
compactEmbeddedPiSession,
|
||||||
|
compactEmbeddedPiSessionDirect,
|
||||||
|
isEmbeddedPiRunActive,
|
||||||
|
} from "../../agents/pi-embedded.js";
|
||||||
|
import { resolveAgentDir } from "../../agents/agent-scope.js";
|
||||||
|
import { loadConfig } from "../../config/config.js";
|
||||||
|
import {
|
||||||
|
loadSessionStore,
|
||||||
|
resolveSessionFilePath,
|
||||||
|
resolveStorePath,
|
||||||
|
} from "../../config/sessions.js";
|
||||||
|
import { resolveAgentIdFromSessionKey, DEFAULT_AGENT_ID } from "../../routing/session-key.js";
|
||||||
|
import { formatContextUsageShort, formatTokenCount } from "../../auto-reply/status.js";
|
||||||
|
import { incrementCompactionCount } from "../../auto-reply/reply/session-updates.js";
|
||||||
|
import type { AnyAgentTool } from "./common.js";
|
||||||
|
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
|
||||||
|
import { resolveDefaultModelForAgent } from "../../agents/model-selection.js";
|
||||||
|
|
||||||
|
const SessionCompactToolSchema = Type.Object({
|
||||||
|
instructions: Type.Optional(
|
||||||
|
Type.String({
|
||||||
|
description:
|
||||||
|
"Optional instructions for what to focus on during compaction (e.g., 'Focus on decisions and open tasks')",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
threshold: Type.Optional(
|
||||||
|
Type.Number({
|
||||||
|
description:
|
||||||
|
"Only compact if context usage exceeds this percentage (default: 0, meaning always compact). Set to 60 to skip compaction when context is below 60%.",
|
||||||
|
minimum: 0,
|
||||||
|
maximum: 100,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface SessionCompactToolOpts {
|
||||||
|
config?: ReturnType<typeof loadConfig>;
|
||||||
|
agentSessionKey?: string;
|
||||||
|
workspaceDir?: string;
|
||||||
|
thinkLevel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(now.getDate()).padStart(2, "0");
|
||||||
|
const hours = String(now.getHours()).padStart(2, "0");
|
||||||
|
const minutes = String(now.getMinutes()).padStart(2, "0");
|
||||||
|
const seconds = String(now.getSeconds()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}-${hours}${minutes}${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCompactionFile(params: {
|
||||||
|
workspaceDir: string;
|
||||||
|
tokensBefore?: number;
|
||||||
|
tokensAfter?: number;
|
||||||
|
contextBefore?: number;
|
||||||
|
contextAfter?: number;
|
||||||
|
instructions?: string;
|
||||||
|
}): string | null {
|
||||||
|
try {
|
||||||
|
const compactionsDir = path.join(params.workspaceDir, "memory", "compactions");
|
||||||
|
if (!fs.existsSync(compactionsDir)) {
|
||||||
|
fs.mkdirSync(compactionsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = formatTimestamp();
|
||||||
|
const filename = `${timestamp}.md`;
|
||||||
|
const filepath = path.join(compactionsDir, filename);
|
||||||
|
|
||||||
|
const content = `# Context Compaction - ${new Date().toLocaleString("en-US", {
|
||||||
|
timeZone: "America/Los_Angeles",
|
||||||
|
dateStyle: "full",
|
||||||
|
timeStyle: "short",
|
||||||
|
})}
|
||||||
|
|
||||||
|
## Compaction Summary
|
||||||
|
- **Tokens before:** ${params.tokensBefore ? formatTokenCount(params.tokensBefore) : "unknown"}
|
||||||
|
- **Tokens after:** ${params.tokensAfter ? formatTokenCount(params.tokensAfter) : "unknown"}
|
||||||
|
- **Context before:** ${params.contextBefore ? `${params.contextBefore}%` : "unknown"}
|
||||||
|
- **Context after:** ${params.contextAfter ? `${params.contextAfter}%` : "unknown"}
|
||||||
|
${params.instructions ? `- **Focus:** ${params.instructions}` : ""}
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
Read this file after compaction to restore context. Add your working state below.
|
||||||
|
|
||||||
|
## Active Task
|
||||||
|
<!-- What were you working on? -->
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
<!-- Important decisions made this session -->
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
<!-- What needs to happen next? -->
|
||||||
|
`;
|
||||||
|
|
||||||
|
fs.writeFileSync(filepath, content, "utf-8");
|
||||||
|
return filepath;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSessionCompactTool(opts?: SessionCompactToolOpts): AnyAgentTool {
|
||||||
|
return {
|
||||||
|
label: "Session Compact",
|
||||||
|
name: "session_compact",
|
||||||
|
description:
|
||||||
|
"Compact the current session's context to free up token space. Use when context is above 60% to proactively manage memory. The compaction summarizes older conversation history while preserving recent messages. Automatically saves a compaction file to memory/compactions/ and returns the path.",
|
||||||
|
parameters: SessionCompactToolSchema,
|
||||||
|
execute: async (_toolCallId, args) => {
|
||||||
|
const params = args as { instructions?: string; threshold?: number };
|
||||||
|
const cfg = opts?.config ?? loadConfig();
|
||||||
|
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
||||||
|
|
||||||
|
const sessionKey = opts?.agentSessionKey;
|
||||||
|
if (!sessionKey) {
|
||||||
|
throw new Error("sessionKey required for compaction");
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentId = resolveAgentIdFromSessionKey(sessionKey) || DEFAULT_AGENT_ID;
|
||||||
|
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
|
||||||
|
// Resolve the session entry
|
||||||
|
const internalKey = resolveInternalSessionKey({
|
||||||
|
key: sessionKey,
|
||||||
|
alias,
|
||||||
|
mainKey,
|
||||||
|
});
|
||||||
|
const entry = store[sessionKey] ?? store[internalKey];
|
||||||
|
|
||||||
|
if (!entry?.sessionId) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "⚙️ Compaction unavailable (missing session id)." }],
|
||||||
|
details: { ok: false, reason: "no sessionId" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check threshold - skip if context is below threshold
|
||||||
|
const threshold = params.threshold ?? 0;
|
||||||
|
const contextTokens = entry.contextTokens ?? 200_000; // Default to 200k if unknown
|
||||||
|
const totalTokens = entry.totalTokens ?? (entry.inputTokens ?? 0) + (entry.outputTokens ?? 0);
|
||||||
|
const currentContextPercent =
|
||||||
|
contextTokens > 0 ? Math.round((totalTokens / contextTokens) * 100) : 0;
|
||||||
|
|
||||||
|
if (threshold > 0 && currentContextPercent < threshold) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `⏭️ Compaction skipped: context at ${currentContextPercent}% is below ${threshold}% threshold.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
ok: true,
|
||||||
|
compacted: false,
|
||||||
|
skipped: true,
|
||||||
|
reason: `context ${currentContextPercent}% < threshold ${threshold}%`,
|
||||||
|
currentContextPercent,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = entry.sessionId;
|
||||||
|
|
||||||
|
// If called from within an active run, use direct compaction to avoid
|
||||||
|
// aborting ourselves (which would prevent the tool result from being saved).
|
||||||
|
// Otherwise, use queued compaction for external callers.
|
||||||
|
const runIsActive = isEmbeddedPiRunActive(sessionId);
|
||||||
|
|
||||||
|
const configured = resolveDefaultModelForAgent({ cfg, agentId });
|
||||||
|
const workspaceDir = opts?.workspaceDir ?? resolveAgentDir(cfg, agentId);
|
||||||
|
|
||||||
|
const compactFn = runIsActive ? compactEmbeddedPiSessionDirect : compactEmbeddedPiSession;
|
||||||
|
const result = await compactFn({
|
||||||
|
sessionId,
|
||||||
|
sessionKey,
|
||||||
|
messageChannel: entry.lastChannel ?? entry.channel ?? "unknown",
|
||||||
|
groupId: entry.groupId,
|
||||||
|
groupChannel: entry.groupChannel,
|
||||||
|
groupSpace: entry.space,
|
||||||
|
spawnedBy: entry.spawnedBy,
|
||||||
|
sessionFile: resolveSessionFilePath(sessionId, entry),
|
||||||
|
workspaceDir,
|
||||||
|
config: cfg,
|
||||||
|
skillsSnapshot: entry.skillsSnapshot,
|
||||||
|
provider: entry.providerOverride ?? configured.provider,
|
||||||
|
model: entry.modelOverride ?? configured.model,
|
||||||
|
thinkLevel: (opts?.thinkLevel ?? cfg.agents?.defaults?.thinkingDefault ?? "medium") as any,
|
||||||
|
bashElevated: {
|
||||||
|
enabled: false,
|
||||||
|
allowed: false,
|
||||||
|
defaultLevel: "off",
|
||||||
|
},
|
||||||
|
customInstructions: params.instructions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const compactLabel = result.ok
|
||||||
|
? result.compacted
|
||||||
|
? result.result?.tokensBefore != null && result.result?.tokensAfter != null
|
||||||
|
? `Compacted (${formatTokenCount(result.result.tokensBefore)} → ${formatTokenCount(result.result.tokensAfter)})`
|
||||||
|
: result.result?.tokensBefore
|
||||||
|
? `Compacted (${formatTokenCount(result.result.tokensBefore)} before)`
|
||||||
|
: "Compacted"
|
||||||
|
: "Compaction skipped"
|
||||||
|
: "Compaction failed";
|
||||||
|
|
||||||
|
if (result.ok && result.compacted) {
|
||||||
|
await incrementCompactionCount({
|
||||||
|
sessionEntry: entry,
|
||||||
|
sessionStore: store,
|
||||||
|
sessionKey,
|
||||||
|
storePath,
|
||||||
|
tokensAfter: result.result?.tokensAfter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate context percentages for the compaction file
|
||||||
|
const tokensAfterCompaction = result.result?.tokensAfter;
|
||||||
|
const contextAfterPercent =
|
||||||
|
contextTokens > 0 && tokensAfterCompaction
|
||||||
|
? Math.round((tokensAfterCompaction / contextTokens) * 100)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Auto-save compaction file
|
||||||
|
let compactionFilePath: string | null = null;
|
||||||
|
if (result.ok && result.compacted && workspaceDir) {
|
||||||
|
compactionFilePath = writeCompactionFile({
|
||||||
|
workspaceDir,
|
||||||
|
tokensBefore: result.result?.tokensBefore,
|
||||||
|
tokensAfter: result.result?.tokensAfter,
|
||||||
|
contextBefore: currentContextPercent,
|
||||||
|
contextAfter: contextAfterPercent,
|
||||||
|
instructions: params.instructions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTotalTokens =
|
||||||
|
tokensAfterCompaction ??
|
||||||
|
entry.totalTokens ??
|
||||||
|
(entry.inputTokens ?? 0) + (entry.outputTokens ?? 0);
|
||||||
|
const contextSummary = formatContextUsageShort(
|
||||||
|
newTotalTokens > 0 ? newTotalTokens : null,
|
||||||
|
entry.contextTokens ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const reason = result.reason?.trim();
|
||||||
|
const statusLine = reason
|
||||||
|
? `${compactLabel}: ${reason} • ${contextSummary}`
|
||||||
|
: `${compactLabel} • ${contextSummary}`;
|
||||||
|
|
||||||
|
const fileNote = compactionFilePath
|
||||||
|
? `\n\n📁 Compaction file saved: \`${compactionFilePath}\`\nRead this file to restore your working context.`
|
||||||
|
: "\n\nNext: Read your latest file from memory/compactions/ to restore context state.";
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `🧹 ${statusLine}${fileNote}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
ok: result.ok,
|
||||||
|
compacted: result.compacted,
|
||||||
|
tokensBefore: result.result?.tokensBefore,
|
||||||
|
tokensAfter: result.result?.tokensAfter,
|
||||||
|
contextBefore: currentContextPercent,
|
||||||
|
contextAfter: contextAfterPercent,
|
||||||
|
compactionFile: compactionFilePath,
|
||||||
|
reason: result.reason,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ type PluginToolMeta = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const pluginToolMeta = new WeakMap<AnyAgentTool, PluginToolMeta>();
|
const pluginToolMeta = new WeakMap<AnyAgentTool, PluginToolMeta>();
|
||||||
|
const loggedConflicts = new Set<string>();
|
||||||
|
|
||||||
export function getPluginToolMeta(tool: AnyAgentTool): PluginToolMeta | undefined {
|
export function getPluginToolMeta(tool: AnyAgentTool): PluginToolMeta | undefined {
|
||||||
return pluginToolMeta.get(tool);
|
return pluginToolMeta.get(tool);
|
||||||
@ -61,7 +62,11 @@ export function resolvePluginTools(params: {
|
|||||||
const pluginIdKey = normalizeToolName(entry.pluginId);
|
const pluginIdKey = normalizeToolName(entry.pluginId);
|
||||||
if (existingNormalized.has(pluginIdKey)) {
|
if (existingNormalized.has(pluginIdKey)) {
|
||||||
const message = `plugin id conflicts with core tool name (${entry.pluginId})`;
|
const message = `plugin id conflicts with core tool name (${entry.pluginId})`;
|
||||||
|
const key = `plugin-id:${pluginIdKey}`;
|
||||||
|
if (!loggedConflicts.has(key)) {
|
||||||
|
loggedConflicts.add(key);
|
||||||
log.error(message);
|
log.error(message);
|
||||||
|
}
|
||||||
registry.diagnostics.push({
|
registry.diagnostics.push({
|
||||||
level: "error",
|
level: "error",
|
||||||
pluginId: entry.pluginId,
|
pluginId: entry.pluginId,
|
||||||
@ -94,13 +99,17 @@ export function resolvePluginTools(params: {
|
|||||||
for (const tool of list) {
|
for (const tool of list) {
|
||||||
if (nameSet.has(tool.name) || existing.has(tool.name)) {
|
if (nameSet.has(tool.name) || existing.has(tool.name)) {
|
||||||
const message = `plugin tool name conflict (${entry.pluginId}): ${tool.name}`;
|
const message = `plugin tool name conflict (${entry.pluginId}): ${tool.name}`;
|
||||||
log.error(message);
|
const key = `tool-name:${normalizeToolName(entry.pluginId)}:${normalizeToolName(tool.name)}`;
|
||||||
|
if (!loggedConflicts.has(key)) {
|
||||||
|
loggedConflicts.add(key);
|
||||||
|
log.warn(message);
|
||||||
registry.diagnostics.push({
|
registry.diagnostics.push({
|
||||||
level: "error",
|
level: "warn",
|
||||||
pluginId: entry.pluginId,
|
pluginId: entry.pluginId,
|
||||||
source: entry.source,
|
source: entry.source,
|
||||||
message,
|
message,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
nameSet.add(tool.name);
|
nameSet.add(tool.name);
|
||||||
|
|||||||
@ -19,12 +19,12 @@ import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
|
|||||||
import { buildMentionRegexes, matchesMentionWithExplicit } from "../auto-reply/reply/mentions.js";
|
import { buildMentionRegexes, matchesMentionWithExplicit } from "../auto-reply/reply/mentions.js";
|
||||||
import { formatLocationText, toLocationContext } from "../channels/location.js";
|
import { formatLocationText, toLocationContext } from "../channels/location.js";
|
||||||
import { recordInboundSession } from "../channels/session.js";
|
import { recordInboundSession } from "../channels/session.js";
|
||||||
import { formatCliCommand } from "../cli/command-format.js";
|
|
||||||
import { readSessionUpdatedAt, resolveStorePath } from "../config/sessions.js";
|
import { readSessionUpdatedAt, resolveStorePath } from "../config/sessions.js";
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import type { DmPolicy, TelegramGroupConfig, TelegramTopicConfig } from "../config/types.js";
|
import type { DmPolicy, TelegramGroupConfig, TelegramTopicConfig } from "../config/types.js";
|
||||||
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
||||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||||
|
import { buildPairingReply } from "../pairing/pairing-messages.js";
|
||||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||||
import { resolveThreadSessionKeys } from "../routing/session-key.js";
|
import { resolveThreadSessionKeys } from "../routing/session-key.js";
|
||||||
import { shouldAckReaction as shouldAckReactionGate } from "../channels/ack-reactions.js";
|
import { shouldAckReaction as shouldAckReactionGate } from "../channels/ack-reactions.js";
|
||||||
@ -81,6 +81,19 @@ type ResolveTelegramGroupConfig = (
|
|||||||
messageThreadId?: number,
|
messageThreadId?: number,
|
||||||
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
|
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
|
||||||
|
|
||||||
|
const PAIRING_NUDGE_COOLDOWN_MS = 5 * 60 * 1000;
|
||||||
|
const pairingNudgeSentAtByChatId = new Map<string, number>();
|
||||||
|
|
||||||
|
function shouldSendPairingNudge(chatId: string, nowMs: number): boolean {
|
||||||
|
const lastMs = pairingNudgeSentAtByChatId.get(chatId);
|
||||||
|
if (lastMs == null) return true;
|
||||||
|
return nowMs - lastMs >= PAIRING_NUDGE_COOLDOWN_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordPairingNudge(chatId: string, nowMs: number): void {
|
||||||
|
pairingNudgeSentAtByChatId.set(chatId, nowMs);
|
||||||
|
}
|
||||||
|
|
||||||
type ResolveGroupActivation = (params: {
|
type ResolveGroupActivation = (params: {
|
||||||
chatId: string | number;
|
chatId: string | number;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
@ -224,6 +237,8 @@ export const buildTelegramMessageContext = async ({
|
|||||||
if (dmPolicy === "disabled") return null;
|
if (dmPolicy === "disabled") return null;
|
||||||
|
|
||||||
if (dmPolicy !== "open") {
|
if (dmPolicy !== "open") {
|
||||||
|
const isStartCommand = /^\/start(?:\s|$)/i.test((msg.text ?? msg.caption ?? "").trim());
|
||||||
|
const nowMs = Date.now();
|
||||||
const candidate = String(chatId);
|
const candidate = String(chatId);
|
||||||
const senderUsername = msg.from?.username ?? "";
|
const senderUsername = msg.from?.username ?? "";
|
||||||
const allowMatch = resolveSenderAllowMatch({
|
const allowMatch = resolveSenderAllowMatch({
|
||||||
@ -254,7 +269,13 @@ export const buildTelegramMessageContext = async ({
|
|||||||
firstName: from?.first_name,
|
firstName: from?.first_name,
|
||||||
lastName: from?.last_name,
|
lastName: from?.last_name,
|
||||||
});
|
});
|
||||||
if (created) {
|
const shouldReply =
|
||||||
|
created || isStartCommand || (code ? shouldSendPairingNudge(candidate, nowMs) : true);
|
||||||
|
if (shouldReply) {
|
||||||
|
recordPairingNudge(candidate, nowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code && shouldReply) {
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
chatId: candidate,
|
chatId: candidate,
|
||||||
@ -272,14 +293,29 @@ export const buildTelegramMessageContext = async ({
|
|||||||
bot.api.sendMessage(
|
bot.api.sendMessage(
|
||||||
chatId,
|
chatId,
|
||||||
[
|
[
|
||||||
"OpenClaw: access not configured.",
|
buildPairingReply({
|
||||||
|
channel: "telegram",
|
||||||
|
idLine: `Your Telegram user id: ${telegramUserId}`,
|
||||||
|
code,
|
||||||
|
}),
|
||||||
"",
|
"",
|
||||||
`Your Telegram user id: ${telegramUserId}`,
|
"Tip: send /start to show this again.",
|
||||||
|
].join("\n"),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else if (!code && shouldReply) {
|
||||||
|
await withTelegramApiErrorLogging({
|
||||||
|
operation: "sendMessage",
|
||||||
|
fn: () =>
|
||||||
|
bot.api.sendMessage(
|
||||||
|
chatId,
|
||||||
|
[
|
||||||
|
"Moltbot: access not configured.",
|
||||||
"",
|
"",
|
||||||
`Pairing code: ${code}`,
|
"Pairing requests are temporarily rate-limited.",
|
||||||
"",
|
"",
|
||||||
"Ask the bot owner to approve with:",
|
"Ask the bot owner to run:",
|
||||||
formatCliCommand("openclaw pairing approve telegram <code>"),
|
"moltbot pairing list telegram",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -634,6 +634,59 @@ describe("createTelegramBot", () => {
|
|||||||
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
|
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resends pairing info after a cooldown so Telegram never appears silent", async () => {
|
||||||
|
onSpy.mockReset();
|
||||||
|
sendMessageSpy.mockReset();
|
||||||
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
replySpy.mockReset();
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const base = new Date("2025-01-09T00:00:00Z");
|
||||||
|
vi.setSystemTime(base);
|
||||||
|
|
||||||
|
loadConfig.mockReturnValue({
|
||||||
|
channels: { telegram: { dmPolicy: "pairing" } },
|
||||||
|
});
|
||||||
|
readTelegramAllowFromStore.mockResolvedValue([]);
|
||||||
|
upsertTelegramPairingRequest
|
||||||
|
.mockResolvedValueOnce({ code: "PAIRME12", created: true })
|
||||||
|
.mockResolvedValue({ code: "PAIRME12", created: false });
|
||||||
|
|
||||||
|
createTelegramBot({ token: "tok" });
|
||||||
|
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
chat: { id: 1234, type: "private" },
|
||||||
|
text: "hello",
|
||||||
|
date: 1736380800,
|
||||||
|
from: { id: 999, username: "random" },
|
||||||
|
};
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
message,
|
||||||
|
me: { username: "moltbot_bot" },
|
||||||
|
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||||
|
});
|
||||||
|
await handler({
|
||||||
|
message: { ...message, text: "hello again" },
|
||||||
|
me: { username: "moltbot_bot" },
|
||||||
|
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||||
|
});
|
||||||
|
vi.setSystemTime(new Date(base.getTime() + 6 * 60 * 1000));
|
||||||
|
await handler({
|
||||||
|
message: { ...message, text: "hello after cooldown" },
|
||||||
|
me: { username: "moltbot_bot" },
|
||||||
|
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replySpy).not.toHaveBeenCalled();
|
||||||
|
expect(sendMessageSpy).toHaveBeenCalledTimes(2);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("triggers typing cue via onReplyStart", async () => {
|
it("triggers typing cue via onReplyStart", async () => {
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
sendChatActionSpy.mockReset();
|
sendChatActionSpy.mockReset();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user