From efd6209bc8b93302f9bcaa58302c59621d7b7a73 Mon Sep 17 00:00:00 2001 From: sasheenmusic Date: Thu, 29 Jan 2026 10:34:27 -0800 Subject: [PATCH 1/3] feat(tools): add session_compact tool for agent-invoked context compaction Adds a new core tool that allows agents to proactively compact their own session context without user intervention. This enables agents to self-manage their context window when nearing limits. The tool: - Uses the existing compactEmbeddedPiSession infrastructure - Supports optional custom instructions for focused compaction - Returns token usage before/after for transparency - Integrates with existing compaction count tracking Use case: Agents can monitor context usage via session_status and invoke session_compact when approaching thresholds (e.g., 60%+ context), then read their memory/compactions/ files to restore state. Closes: Agent self-management of context window --- src/agents/moltbot-tools.ts | 6 + src/agents/tools/session-compact-tool.ts | 166 +++++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 src/agents/tools/session-compact-tool.ts diff --git a/src/agents/moltbot-tools.ts b/src/agents/moltbot-tools.ts index c10a55190..59340d1b4 100644 --- a/src/agents/moltbot-tools.ts +++ b/src/agents/moltbot-tools.ts @@ -11,6 +11,7 @@ import { createGatewayTool } from "./tools/gateway-tool.js"; import { createImageTool } from "./tools/image-tool.js"; import { createMessageTool } from "./tools/message-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 { createSessionsHistoryTool } from "./tools/sessions-history-tool.js"; import { createSessionsListTool } from "./tools/sessions-list-tool.js"; @@ -134,6 +135,11 @@ export function createMoltbotTools(options?: { agentSessionKey: options?.agentSessionKey, config: options?.config, }), + createSessionCompactTool({ + agentSessionKey: options?.agentSessionKey, + config: options?.config, + workspaceDir: options?.workspaceDir, + }), ...(webSearchTool ? [webSearchTool] : []), ...(webFetchTool ? [webFetchTool] : []), ...(imageTool ? [imageTool] : []), diff --git a/src/agents/tools/session-compact-tool.ts b/src/agents/tools/session-compact-tool.ts new file mode 100644 index 000000000..36b2a90a3 --- /dev/null +++ b/src/agents/tools/session-compact-tool.ts @@ -0,0 +1,166 @@ +import { Type } from "@sinclair/typebox"; +import { + abortEmbeddedPiRun, + compactEmbeddedPiSession, + isEmbeddedPiRunActive, + waitForEmbeddedPiRunEnd, +} from "../../agents/pi-embedded.js"; +import { resolveAgentDir } from "../../agents/agent-scope.js"; +import { loadConfig } from "../../config/config.js"; +import { + loadSessionStore, + resolveSessionFilePath, + resolveStorePath, + updateSessionStore, +} 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 { readStringParam } from "./common.js"; +import { + resolveInternalSessionKey, + resolveMainSessionAlias, + createAgentToAgentPolicy, +} 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')", + }), + ), +}); + +interface SessionCompactToolOpts { + config?: ReturnType; + agentSessionKey?: string; + workspaceDir?: string; + thinkLevel?: string; +} + +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. After compaction, read your latest compaction file from memory/compactions/ to restore state.", + parameters: SessionCompactToolSchema, + execute: async (_toolCallId, args) => { + const params = args as { instructions?: string }; + 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" }, + }; + } + + const sessionId = entry.sessionId; + + // Abort any active run before compacting + if (isEmbeddedPiRunActive(sessionId)) { + abortEmbeddedPiRun(sessionId); + await waitForEmbeddedPiRunEnd(sessionId, 15_000); + } + + const configured = resolveDefaultModelForAgent({ cfg, agentId }); + const workspaceDir = opts?.workspaceDir ?? resolveAgentDir(cfg, agentId); + + const result = await compactEmbeddedPiSession({ + 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, + }); + } + + const tokensAfterCompaction = result.result?.tokensAfter; + const totalTokens = + tokensAfterCompaction ?? + entry.totalTokens ?? + (entry.inputTokens ?? 0) + (entry.outputTokens ?? 0); + const contextSummary = formatContextUsageShort( + totalTokens > 0 ? totalTokens : null, + entry.contextTokens ?? null, + ); + + const reason = result.reason?.trim(); + const line = reason + ? `${compactLabel}: ${reason} • ${contextSummary}` + : `${compactLabel} • ${contextSummary}`; + + return { + content: [ + { + type: "text", + text: `🧹 ${line}\n\nNext: Read your latest file from memory/compactions/ to restore context state.`, + }, + ], + details: { + ok: result.ok, + compacted: result.compacted, + tokensBefore: result.result?.tokensBefore, + tokensAfter: result.result?.tokensAfter, + reason: result.reason, + }, + }; + }, + }; +} From 30262f9ffc1c74ab6bdfece0968f15a40f533116 Mon Sep 17 00:00:00 2001 From: sasheenmusic Date: Thu, 29 Jan 2026 10:50:02 -0800 Subject: [PATCH 2/3] feat(session_compact): add threshold check and auto-save compaction file Improvements: 1. threshold parameter - skip compaction if context below threshold % 2. Auto-saves compaction file to memory/compactions/ with timestamp 3. Returns file path in response so agent knows where to read This enables fully autonomous context management without manual steps. --- src/agents/tools/session-compact-tool.ts | 143 ++++++++++++++++++++--- 1 file changed, 130 insertions(+), 13 deletions(-) diff --git a/src/agents/tools/session-compact-tool.ts b/src/agents/tools/session-compact-tool.ts index 36b2a90a3..029622afa 100644 --- a/src/agents/tools/session-compact-tool.ts +++ b/src/agents/tools/session-compact-tool.ts @@ -1,4 +1,6 @@ import { Type } from "@sinclair/typebox"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { abortEmbeddedPiRun, compactEmbeddedPiSession, @@ -11,18 +13,12 @@ import { loadSessionStore, resolveSessionFilePath, resolveStorePath, - updateSessionStore, } 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 { readStringParam } from "./common.js"; -import { - resolveInternalSessionKey, - resolveMainSessionAlias, - createAgentToAgentPolicy, -} from "./sessions-helpers.js"; +import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js"; import { resolveDefaultModelForAgent } from "../../agents/model-selection.js"; const SessionCompactToolSchema = Type.Object({ @@ -32,6 +28,14 @@ const SessionCompactToolSchema = Type.Object({ "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 { @@ -41,15 +45,77 @@ interface SessionCompactToolOpts { 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 + + +## Key Decisions + + +## Next Steps + +`; + + 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. After compaction, read your latest compaction file from memory/compactions/ to restore state.", + "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 }; + const params = args as { instructions?: string; threshold?: number }; const cfg = opts?.config ?? loadConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); @@ -77,6 +143,31 @@ export function createSessionCompactTool(opts?: SessionCompactToolOpts): AnyAgen }; } + // 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; // Abort any active run before compacting @@ -131,26 +222,49 @@ export function createSessionCompactTool(opts?: SessionCompactToolOpts): AnyAgen }); } + // Calculate context percentages for the compaction file const tokensAfterCompaction = result.result?.tokensAfter; - const totalTokens = + 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( - totalTokens > 0 ? totalTokens : null, + newTotalTokens > 0 ? newTotalTokens : null, entry.contextTokens ?? null, ); const reason = result.reason?.trim(); - const line = reason + 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: `🧹 ${line}\n\nNext: Read your latest file from memory/compactions/ to restore context state.`, + text: `🧹 ${statusLine}${fileNote}`, }, ], details: { @@ -158,6 +272,9 @@ export function createSessionCompactTool(opts?: SessionCompactToolOpts): AnyAgen compacted: result.compacted, tokensBefore: result.result?.tokensBefore, tokensAfter: result.result?.tokensAfter, + contextBefore: currentContextPercent, + contextAfter: contextAfterPercent, + compactionFile: compactionFilePath, reason: result.reason, }, }; From ec8ddeb5ac30c1648cb58a0b2329b3914e2f2709 Mon Sep 17 00:00:00 2001 From: sasheenmusic Date: Thu, 29 Jan 2026 14:31:56 -0800 Subject: [PATCH 3/3] fix(session_compact): use direct compaction when called from active run - Add compactEmbeddedPiSessionDirect for synchronous compaction - Detect active run and use direct method to avoid self-abort deadlock - Fixes transcript repair error when session_compact called mid-session --- LOCAL_STATE.md | 40 ++++ config/redacted/.gitkeep | 1 + config/redacted/moltbot.redacted.json | 197 ++++++++++++++++ package.json | 5 +- scripts/local/export-local-state.mjs | 285 +++++++++++++++++++++++ scripts/local/import-clawd.mjs | 141 +++++++++++ src/agents/pi-embedded-runner.ts | 5 +- src/agents/pi-embedded.ts | 1 + src/agents/session-tool-result-guard.ts | 18 ++ src/agents/tools/session-compact-tool.ts | 15 +- src/plugins/tools.ts | 25 +- src/telegram/bot-message-context.ts | 50 +++- src/telegram/bot.test.ts | 53 +++++ 13 files changed, 811 insertions(+), 25 deletions(-) create mode 100644 LOCAL_STATE.md create mode 100644 config/redacted/.gitkeep create mode 100644 config/redacted/moltbot.redacted.json create mode 100644 scripts/local/export-local-state.mjs create mode 100644 scripts/local/import-clawd.mjs diff --git a/LOCAL_STATE.md b/LOCAL_STATE.md new file mode 100644 index 000000000..a0aae95af --- /dev/null +++ b/LOCAL_STATE.md @@ -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 +``` + diff --git a/config/redacted/.gitkeep b/config/redacted/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/config/redacted/.gitkeep @@ -0,0 +1 @@ + diff --git a/config/redacted/moltbot.redacted.json b/config/redacted/moltbot.redacted.json new file mode 100644 index 000000000..b92505e29 --- /dev/null +++ b/config/redacted/moltbot.redacted.json @@ -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": [ + "" + ] + } + }, + "agents": { + "defaults": { + "model": { + "primary": "anthropic/claude-opus-4-5", + "fallbacks": [ + "" + ] + }, + "models": { + "anthropic/claude-opus-4-5": { + "alias": "opus" + } + }, + "workspace": "/Users/conradsasinski/clawd", + "memorySearch": { + "sources": [ + "" + ], + "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": [ + "" + ] + }, + "tools": { + "allow": [ + "" + ], + "web": { + "search": { + "enabled": true, + "apiKey": "" + }, + "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": "", + "replyToMode": "off", + "groupPolicy": "allowlist", + "streamMode": "off" + } + }, + "talk": { + "apiKey": "" + }, + "gateway": { + "port": 18789, + "mode": "local", + "bind": "loopback", + "auth": { + "mode": "token", + "token": "" + }, + "tailscale": { + "mode": "off", + "resetOnExit": false + } + }, + "skills": { + "load": { + "watch": true, + "watchDebounceMs": 500 + }, + "install": { + "nodeManager": "npm" + }, + "entries": { + "sag": { + "apiKey": "" + }, + "cronometer-logger": { + "enabled": true, + "config": { + "mode": "day-only", + "diaryGroup": "disabled", + "defaultDate": "today" + } + } + } + }, + "plugins": { + "entries": { + "telegram": { + "enabled": true + } + } + } +} diff --git a/package.json b/package.json index 4d38edf18..cd6ca542a 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,10 @@ "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/MoltbotProtocol/GatewayModels.swift", "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": [], "author": "", diff --git a/scripts/local/export-local-state.mjs b/scripts/local/export-local-state.mjs new file mode 100644 index 000000000..05284bac0 --- /dev/null +++ b/scripts/local/export-local-state.mjs @@ -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: : + if (/^\d+:[A-Za-z0-9_-]{20,}$/.test(trimmed)) return ""; + // Discord bot token-ish (very loose) or other opaque tokens + if (trimmed.length >= 24 && /^[A-Za-z0-9._-]+$/.test(trimmed)) return ""; + // Local absolute paths are usually personal; keep only basename. + if (path.isAbsolute(trimmed) || trimmed.startsWith("~/")) { + return ``; + } + } + return ""; +} + +function redactObject(obj) { + if (Array.isArray(obj)) { + // Lists often contain ids/handles; keep shape but hide contents. + return obj.length > 0 ? [""] : []; + } + 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 ? [""] : []; + 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[""] = 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 Source state dir (default: ~/.moltbot)", + " --out Output dir (default: ./.local/moltbot/state)", + " --redact-out 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(); + diff --git a/scripts/local/import-clawd.mjs b/scripts/local/import-clawd.mjs new file mode 100644 index 000000000..bcde931e8 --- /dev/null +++ b/scripts/local/import-clawd.mjs @@ -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 Source folder (default: ~/clawd)", + " --out 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(); + diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index bdebd0005..e3fbd4820 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -1,5 +1,8 @@ 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 { applyGoogleTurnOrderingFix } from "./pi-embedded-runner/google.js"; diff --git a/src/agents/pi-embedded.ts b/src/agents/pi-embedded.ts index 81e99feec..e0f411541 100644 --- a/src/agents/pi-embedded.ts +++ b/src/agents/pi-embedded.ts @@ -7,6 +7,7 @@ export type { export { abortEmbeddedPiRun, compactEmbeddedPiSession, + compactEmbeddedPiSessionDirect, isEmbeddedPiRunActive, isEmbeddedPiRunStreaming, queueEmbeddedPiMessage, diff --git a/src/agents/session-tool-result-guard.ts b/src/agents/session-tool-result-guard.ts index feb6b854c..14ebac95b 100644 --- a/src/agents/session-tool-result-guard.ts +++ b/src/agents/session-tool-result-guard.ts @@ -3,6 +3,9 @@ import type { SessionManager } from "@mariozechner/pi-coding-agent"; import { makeMissingToolResult } from "./session-transcript-repair.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 }; @@ -69,8 +72,16 @@ export function installSessionToolResultGuard( const flushPendingToolResults = () => { 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) { 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 }); originalAppend( persistToolResult(synthetic, { @@ -90,7 +101,11 @@ export function installSessionToolResultGuard( if (role === "toolResult") { const id = extractToolResultId(message as Extract); const toolName = id ? pending.get(id) : undefined; + const wasPending = id ? pending.has(id) : false; if (id) pending.delete(id); + log.debug( + `Tool result received: ${toolName ?? "unknown"}(${id}) - wasPending=${wasPending}, remainingPending=${pending.size}`, + ); return originalAppend( persistToolResult(message, { toolCallId: id ?? undefined, @@ -128,6 +143,9 @@ export function installSessionToolResultGuard( if (toolCalls.length > 0) { for (const call of toolCalls) { pending.set(call.id, call.name); + log.debug( + `Tool call added to pending: ${call.name ?? "unknown"}(${call.id}) - totalPending=${pending.size}`, + ); } } diff --git a/src/agents/tools/session-compact-tool.ts b/src/agents/tools/session-compact-tool.ts index 029622afa..ce559b8bc 100644 --- a/src/agents/tools/session-compact-tool.ts +++ b/src/agents/tools/session-compact-tool.ts @@ -2,10 +2,9 @@ import { Type } from "@sinclair/typebox"; import * as fs from "node:fs"; import * as path from "node:path"; import { - abortEmbeddedPiRun, compactEmbeddedPiSession, + compactEmbeddedPiSessionDirect, isEmbeddedPiRunActive, - waitForEmbeddedPiRunEnd, } from "../../agents/pi-embedded.js"; import { resolveAgentDir } from "../../agents/agent-scope.js"; import { loadConfig } from "../../config/config.js"; @@ -170,16 +169,16 @@ export function createSessionCompactTool(opts?: SessionCompactToolOpts): AnyAgen const sessionId = entry.sessionId; - // Abort any active run before compacting - if (isEmbeddedPiRunActive(sessionId)) { - abortEmbeddedPiRun(sessionId); - await waitForEmbeddedPiRunEnd(sessionId, 15_000); - } + // 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 result = await compactEmbeddedPiSession({ + const compactFn = runIsActive ? compactEmbeddedPiSessionDirect : compactEmbeddedPiSession; + const result = await compactFn({ sessionId, sessionKey, messageChannel: entry.lastChannel ?? entry.channel ?? "unknown", diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index 09e4af8bc..8f1dd0f9b 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -12,6 +12,7 @@ type PluginToolMeta = { }; const pluginToolMeta = new WeakMap(); +const loggedConflicts = new Set(); export function getPluginToolMeta(tool: AnyAgentTool): PluginToolMeta | undefined { return pluginToolMeta.get(tool); @@ -61,7 +62,11 @@ export function resolvePluginTools(params: { const pluginIdKey = normalizeToolName(entry.pluginId); if (existingNormalized.has(pluginIdKey)) { const message = `plugin id conflicts with core tool name (${entry.pluginId})`; - log.error(message); + const key = `plugin-id:${pluginIdKey}`; + if (!loggedConflicts.has(key)) { + loggedConflicts.add(key); + log.error(message); + } registry.diagnostics.push({ level: "error", pluginId: entry.pluginId, @@ -94,13 +99,17 @@ export function resolvePluginTools(params: { for (const tool of list) { if (nameSet.has(tool.name) || existing.has(tool.name)) { const message = `plugin tool name conflict (${entry.pluginId}): ${tool.name}`; - log.error(message); - registry.diagnostics.push({ - level: "error", - pluginId: entry.pluginId, - source: entry.source, - message, - }); + const key = `tool-name:${normalizeToolName(entry.pluginId)}:${normalizeToolName(tool.name)}`; + if (!loggedConflicts.has(key)) { + loggedConflicts.add(key); + log.warn(message); + registry.diagnostics.push({ + level: "warn", + pluginId: entry.pluginId, + source: entry.source, + message, + }); + } continue; } nameSet.add(tool.name); diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index abd06cdef..f22910253 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -19,12 +19,12 @@ import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; import { buildMentionRegexes, matchesMentionWithExplicit } from "../auto-reply/reply/mentions.js"; import { formatLocationText, toLocationContext } from "../channels/location.js"; import { recordInboundSession } from "../channels/session.js"; -import { formatCliCommand } from "../cli/command-format.js"; import { readSessionUpdatedAt, resolveStorePath } from "../config/sessions.js"; import type { MoltbotConfig } from "../config/config.js"; import type { DmPolicy, TelegramGroupConfig, TelegramTopicConfig } from "../config/types.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; +import { buildPairingReply } from "../pairing/pairing-messages.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../routing/session-key.js"; import { shouldAckReaction as shouldAckReactionGate } from "../channels/ack-reactions.js"; @@ -81,6 +81,19 @@ type ResolveTelegramGroupConfig = ( messageThreadId?: number, ) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig }; +const PAIRING_NUDGE_COOLDOWN_MS = 5 * 60 * 1000; +const pairingNudgeSentAtByChatId = new Map(); + +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: { chatId: string | number; agentId?: string; @@ -224,6 +237,8 @@ export const buildTelegramMessageContext = async ({ if (dmPolicy === "disabled") return null; if (dmPolicy !== "open") { + const isStartCommand = /^\/start(?:\s|$)/i.test((msg.text ?? msg.caption ?? "").trim()); + const nowMs = Date.now(); const candidate = String(chatId); const senderUsername = msg.from?.username ?? ""; const allowMatch = resolveSenderAllowMatch({ @@ -254,7 +269,13 @@ export const buildTelegramMessageContext = async ({ firstName: from?.first_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( { chatId: candidate, @@ -266,6 +287,23 @@ export const buildTelegramMessageContext = async ({ }, "telegram pairing request", ); + await withTelegramApiErrorLogging({ + operation: "sendMessage", + fn: () => + bot.api.sendMessage( + chatId, + [ + buildPairingReply({ + channel: "telegram", + idLine: `Your Telegram user id: ${telegramUserId}`, + code, + }), + "", + "Tip: send /start to show this again.", + ].join("\n"), + ), + }); + } else if (!code && shouldReply) { await withTelegramApiErrorLogging({ operation: "sendMessage", fn: () => @@ -274,12 +312,10 @@ export const buildTelegramMessageContext = async ({ [ "Moltbot: access not configured.", "", - `Your Telegram user id: ${telegramUserId}`, + "Pairing requests are temporarily rate-limited.", "", - `Pairing code: ${code}`, - "", - "Ask the bot owner to approve with:", - formatCliCommand("moltbot pairing approve telegram "), + "Ask the bot owner to run:", + "moltbot pairing list telegram", ].join("\n"), ), }); diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index c075174fb..7780dc895 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -634,6 +634,59 @@ describe("createTelegramBot", () => { 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; + 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) => Promise; + + 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 () => { onSpy.mockReset(); sendChatActionSpy.mockReset();