diff --git a/src/agents/pi.ts b/src/agents/pi.ts index 92c2cef..42bc453 100644 --- a/src/agents/pi.ts +++ b/src/agents/pi.ts @@ -331,6 +331,50 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => { return enqueue(threadId, () => doPrompt(threadId, formatMessage(message))); }, + async promptWithModel(threadId: string, message: AgentMessage, modelId: string): Promise { + return enqueue(threadId, async () => { + const entry = await getOrCreate(threadId); + const currentModel = entry.session.model; + + // Resolve the target model (format: "provider/model-id") + let targetModel; + const [provider, ...rest] = modelId.split("/"); + const id = rest.join("/"); + if (provider && id) { + targetModel = modelRegistry.find(provider, id); + } + + if (!targetModel) { + console.warn(`[pi-agent] flush model "${modelId}" not found, using default`); + return doPrompt(threadId, formatMessage(message)); + } + + // Verify auth is available for the target model + if (!modelRegistry.hasConfiguredAuth(targetModel)) { + console.warn(`[pi-agent] no auth for flush model "${modelId}", using default`); + return doPrompt(threadId, formatMessage(message)); + } + + // Swap model in-memory only (no persistence to settings.json or session log). + // This avoids a crash-window where settings could be left on the flush model. + const agentState = (entry.session as any).agent?.state; + if (!agentState) { + console.warn(`[pi-agent] cannot access agent state for model swap, using default`); + return doPrompt(threadId, formatMessage(message)); + } + + agentState.model = targetModel; + console.log(`[pi-agent] switched to flush model (in-memory): ${modelId}`); + + try { + return await doPrompt(threadId, formatMessage(message)); + } finally { + // Restore original model (in-memory only) — even if undefined + agentState.model = currentModel; + } + }); + }, + promptStream(threadId: string, message: AgentMessage): AsyncIterable { const text = formatMessage(message); // Return an async iterable that is single-use by design. @@ -465,6 +509,49 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => { }); }, + async compactWithModel(threadId: string, modelId: string): Promise<{ tokensBefore: number; tokensAfter: number | null } | null> { + return enqueue(threadId, async () => { + const entry = sessions.get(threadId); + if (!entry) return null; + + const agentState = (entry.session as any).agent?.state; + let currentModel: any; + let modelSwapped = false; + + // Resolve and swap model for compact + if (!agentState) { + console.warn(`[pi-agent] cannot access agent state for compact model swap, using default`); + } else { + const [provider, ...rest] = modelId.split("/"); + const id = rest.join("/"); + const targetModel = (provider && id) ? modelRegistry.find(provider, id) : null; + if (!targetModel) { + console.warn(`[pi-agent] compact model "${modelId}" not found, using default`); + } else if (!modelRegistry.hasConfiguredAuth(targetModel)) { + console.warn(`[pi-agent] no auth for compact model "${modelId}", using default`); + } else { + currentModel = agentState.model; + agentState.model = targetModel; + modelSwapped = true; + console.log(`[pi-agent] compact using model (in-memory): ${modelId}`); + } + } + + try { + const result = await entry.session.compact(); + const usage = entry.session.getContextUsage(); + return { + tokensBefore: result.tokensBefore, + tokensAfter: usage?.tokens ?? null, + }; + } finally { + if (modelSwapped) { + agentState.model = currentModel; + } + } + }); + }, + async abort(threadId: string): Promise { const entry = sessions.get(threadId); if (entry) { diff --git a/src/gateway.ts b/src/gateway.ts index 3578900..dc33476 100644 --- a/src/gateway.ts +++ b/src/gateway.ts @@ -20,8 +20,10 @@ import { formatSchedule, formatRunCounts, jobEnabledIcon } from "./cron/format"; import { BOT_COMMANDS } from "./commands"; import { prepareMemoryForTurn, finalizeMemoryForTurn, flushMemoryThenCompact, determineMemoryMode } from "./memory/lifecycle"; import { maxPressure } from "./memory/policy"; -import type { PressureLevel } from "./memory/types"; +import type { PressureLevel, CompactResult } from "./memory/types"; +import { READ_ONLY_TOOLS } from "./memory/types"; import { readPendingPairing, completePendingPairing, isStartForNonce } from "./pairing"; +import { createProgressMessage } from "./telegram-progress"; /** Match a Telegram command, handling optional @botname suffix */ /** Bot username for command suffix validation (set during gateway init) */ @@ -493,7 +495,7 @@ export class Gateway { threadLocks.set(agentThreadId, lockPromise); if (prevLock) await prevLock; - await thread.post("📝 Saving memory and compacting..."); + const progress = await createProgressMessage(thread, "📝 Saving memory and compacting..."); const stopTyping = startTypingLoop(thread); try { const agentCwd = (agent.getInfo?.()?.cwd as string) ?? process.cwd(); @@ -502,23 +504,28 @@ export class Gateway { if (this.config.memory?.enabled === false) { const result = await agent.compact(agentThreadId); if (!result) { - await thread.post("⚠️ No active session to compact. Send a message first."); + await progress.update("⚠️ No active session to compact. Send a message first."); } else { const beforeK = (result.tokensBefore / 1000).toFixed(1); - await thread.post(`✅ Compaction complete\n\nCompacted ${beforeK}K tokens down to a summary.\nContext usage will update after your next message.`); + await progress.update(`✅ Compaction complete\n\nCompacted ${beforeK}K tokens down to a summary.\nContext usage will update after your next message.`); } } else { - const result = await flushMemoryThenCompact(agentThreadId, agent, memoryRoot, "manual", this.config.memory); + const result = await flushMemoryThenCompact( + agentThreadId, agent, memoryRoot, "manual", this.config.memory, + (step) => progress.update(step), + ); if (!result) { - await thread.post("⚠️ No active session to compact. Send a message first."); + await progress.update("⚠️ No active session to compact. Send a message first."); } else { const beforeK = (result.tokensBefore / 1000).toFixed(1); - await thread.post(`✅ Memory saved & compacted\n\nCompacted ${beforeK}K tokens down to a summary.\nContext usage will update after your next message.`); + const timing = result.timing; + const timingLine = timing ? `\nTiming: flush ${(timing.flushMs / 1000).toFixed(1)}s, compact ${(timing.compactMs / 1000).toFixed(1)}s, total ${(timing.totalMs / 1000).toFixed(1)}s\nModel: ${timing.model}` : ""; + await progress.update(`✅ Memory saved & compacted\n\nCompacted ${beforeK}K tokens down to a summary.\nContext usage will update after your next message.${timingLine}`); } } } catch (err) { const msg = err instanceof Error ? err.message : String(err); - await thread.post(`⚠️ Compaction failed: ${msg.slice(0, 200)}`); + await progress.update(`⚠️ Compaction failed: ${msg.slice(0, 200)}`); } finally { stopTyping(); releaseLock!(); @@ -687,17 +694,20 @@ export class Gateway { const stopTyping = startTypingLoop(thread); try { + let turnUsedTools = false; if (agent.promptStream) { const ac = new AbortController(); abortControllers.set(agentThreadId, ac); try { - await this.handleStreaming(thread, agent.promptStream(agentThreadId, agentMessage), verboseThreads.has(agentThreadId), ac.signal); + const streamResult = await this.handleStreaming(thread, agent.promptStream(agentThreadId, agentMessage), verboseThreads.has(agentThreadId), ac.signal); + turnUsedTools = streamResult.usedTools; } finally { abortControllers.delete(agentThreadId); } } else { - // Fallback: non-streaming prompt + // Fallback: non-streaming prompt (assume tools may have been used) const reply = await agent.prompt(agentThreadId, agentMessage); + turnUsedTools = true; if (reply.text) { await this.postWithFallback(thread, reply.text); } @@ -705,9 +715,10 @@ export class Gateway { // ── Memory: post-turn finalize + pressure check ─── try { + if (memoryPrepared) memoryPrepared.turnUsedTools = turnUsedTools; const pressure = await finalizeMemoryForTurn( agentThreadId, - memoryPrepared?.beforeDigest ?? null, + memoryPrepared ?? { message: agentMessage, beforeDigest: null, injected: false }, agent, memoryRoot, this.config.memory, ); // Use higher severity between pending compact and current pressure @@ -907,11 +918,17 @@ export class Gateway { // Hard or emergency: flush + compact try { - await thread.post(`📝 ${pressure === "emergency" ? "⚠️ Context nearly full! " : ""}Saving memory and compacting...`); - const result = await flushMemoryThenCompact(agentThreadId, agent, memoryRoot, pressure, this.config.memory); + const prefix = pressure === "emergency" ? "⚠️ Context nearly full! " : ""; + const progress = await createProgressMessage(thread, `📝 ${prefix}Saving memory and compacting...`); + const result = await flushMemoryThenCompact( + agentThreadId, agent, memoryRoot, pressure, this.config.memory, + (step) => progress.update(step), + ); if (result) { const beforeK = (result.tokensBefore / 1000).toFixed(1); - await thread.post(`✅ Auto-compacted: ${beforeK}K tokens → summary.`); + const timing = result.timing; + const timingLine = timing ? ` (${(timing.totalMs / 1000).toFixed(1)}s: flush ${(timing.flushMs / 1000).toFixed(1)}s + compact ${(timing.compactMs / 1000).toFixed(1)}s)` : ""; + await progress.update(`✅ Auto-compacted: ${beforeK}K tokens → summary.${timingLine}`); } } catch (err) { console.error(`[roundhouse] ${pressure} compact error:`, (err as Error).message); @@ -927,8 +944,9 @@ export class Gateway { * - Tool starts/ends are sent as compact status messages. * - Turn boundaries trigger a new message for the next turn's text. */ - private async handleStreaming(thread: any, stream: AsyncIterable, verbose: boolean, signal?: AbortSignal) { + private async handleStreaming(thread: any, stream: AsyncIterable, verbose: boolean, signal?: AbortSignal): Promise<{ usedTools: boolean }> { let activeTools = new Map(); // toolCallId -> toolName + let usedFileModifyingTools = false; // Per-turn streaming state — each turn gets a fresh iterable + promise let currentPush: ((text: string) => void) | null = null; @@ -1032,6 +1050,7 @@ export class Gateway { case "tool_start": { activeTools.set(event.toolCallId, event.toolName); + if (!READ_ONLY_TOOLS.has(event.toolName)) usedFileModifyingTools = true; if (verbose) { try { await thread.post(`${toolIcon(event.toolName)} Running \`${event.toolName}\`…`); @@ -1102,6 +1121,8 @@ export class Gateway { if (currentPromise) { await flushCurrentStream(); } + + return { usedTools: usedFileModifyingTools }; } /** Post text with markdown, falling back to plain text */ diff --git a/src/memory/lifecycle.ts b/src/memory/lifecycle.ts index fc4c5e8..8f1c797 100644 --- a/src/memory/lifecycle.ts +++ b/src/memory/lifecycle.ts @@ -9,13 +9,16 @@ */ import type { AgentAdapter, AgentMessage } from "../types"; -import type { MemoryConfig, MemoryMode, PreparedTurn, PressureLevel, ThreadMemoryState } from "./types"; +import type { MemoryConfig, MemoryFileSet, MemoryMode, MemorySnapshot, PreparedTurn, PressureLevel, ThreadMemoryState, CompactResult } from "./types"; import { resolveMemoryFiles, readMemorySnapshot, formatDate } from "./files"; import { loadThreadMemoryState, saveThreadMemoryState } from "./state"; import { shouldInjectMemory, classifyContextPressure, isSoftFlushOnCooldown } from "./policy"; import { buildMemoryInjection, injectMemoryIntoMessage } from "./inject"; import { buildFlushPrompt } from "./prompts"; import { bootstrapMemoryFiles } from "./bootstrap"; +import { appendFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { homedir } from "node:os"; // ── Memory mode detection ──────────────────────────── @@ -52,9 +55,7 @@ export async function prepareMemoryForTurn( const mode = getMode(agent); - // Complement mode: no injection, just track digest for finalize - // Unknown mode: also skip — we can't inject correctly before knowing if agent has memory extension - // (mode is detected during session creation, which happens inside promptStream) + // Complement mode: no injection, no digest tracking needed (finalize skips complement) if (mode === "complement" || mode === "unknown") { return { message, beforeDigest: null, injected: false }; } @@ -91,10 +92,10 @@ export async function prepareMemoryForTurn( await saveThreadMemoryState(threadId, state); console.log(`[memory] injected into ${threadId} (reason: ${decision.reason}, ${snapshot.entries.length} files, digest: ${snapshot.digest})`); - return { message: injectedMessage, beforeDigest: snapshot.digest, injected: true, pendingCompact: pendingCompactLevel }; + return { message: injectedMessage, beforeDigest: snapshot.digest, injected: true, pendingCompact: pendingCompactLevel, fileSet, snapshot }; } - return { message, beforeDigest: snapshot.digest, injected: false, pendingCompact: pendingCompactLevel }; + return { message, beforeDigest: snapshot.digest, injected: false, pendingCompact: pendingCompactLevel, fileSet, snapshot }; } catch (err) { console.error(`[memory] prepareMemoryForTurn error:`, (err as Error).message); return { message, beforeDigest: null, injected: false }; @@ -109,11 +110,14 @@ export async function prepareMemoryForTurn( * In Full mode: check if agent wrote memory files (update digest). * Both modes: check context pressure for proactive compaction. * + * Uses cached fileSet from PreparedTurn to avoid re-resolving files. + * Only re-reads files if the turn included tool calls that could have modified them. + * * Returns the pressure level for the gateway to act on. */ export async function finalizeMemoryForTurn( threadId: string, - beforeDigest: string | null, + prepared: PreparedTurn, agent: AgentAdapter, rootDir: string, config?: MemoryConfig, @@ -121,21 +125,25 @@ export async function finalizeMemoryForTurn( if (config?.enabled === false) return "none"; const mode = getMode(agent); + const beforeDigest = prepared.beforeDigest; // In Full mode: check if agent modified memory files if (mode !== "complement" && beforeDigest) { - try { - const fileSet = resolveMemoryFiles(rootDir, config); - const snapshot = await readMemorySnapshot(fileSet, config?.inject?.maxBytes); - if (snapshot.digest !== beforeDigest) { - const state = await loadThreadMemoryState(threadId); - state.lastInjectedDigest = snapshot.digest; - state.lastKnownDigest = snapshot.digest; - await saveThreadMemoryState(threadId, state); - console.log(`[memory] agent updated memory files (new digest: ${snapshot.digest})`); + // Skip expensive re-read if no file-modifying tools ran during this turn + if (prepared.turnUsedTools !== false) { + try { + const fileSet = prepared.fileSet ?? resolveMemoryFiles(rootDir, config); + const snapshot = await readMemorySnapshot(fileSet, config?.inject?.maxBytes); + if (snapshot.digest !== beforeDigest) { + const state = await loadThreadMemoryState(threadId); + state.lastInjectedDigest = snapshot.digest; + state.lastKnownDigest = snapshot.digest; + await saveThreadMemoryState(threadId, state); + console.log(`[memory] agent updated memory files (new digest: ${snapshot.digest})`); + } + } catch (err) { + console.error(`[memory] finalizeMemoryForTurn digest check error:`, (err as Error).message); } - } catch (err) { - console.error(`[memory] finalizeMemoryForTurn digest check error:`, (err as Error).message); } } @@ -167,6 +175,8 @@ export async function finalizeMemoryForTurn( * 2. Compact the session * 3. Mark force re-inject for Full mode * + * Uses a cheaper model for flush turns if config.compact.flushModel is set. + * * Returns compaction result or null if nothing to compact. */ export async function flushMemoryThenCompact( @@ -175,8 +185,21 @@ export async function flushMemoryThenCompact( rootDir: string, level: "soft" | "hard" | "emergency" | "manual", config?: MemoryConfig, -): Promise<{ tokensBefore: number; tokensAfter: number | null } | null> { + onProgress?: (step: string) => void | Promise, +): Promise { const mode = getMode(agent); + // Default to Sonnet for flush turns (faster). Set to null to use conversation model. + const DEFAULT_FLUSH_MODEL = "amazon-bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0"; + const flushModel = config?.compact?.flushModel === null ? undefined : (config?.compact?.flushModel ?? DEFAULT_FLUSH_MODEL); + + /** Send flush prompt, preferring flushModel if available */ + async function sendFlush(text: string): Promise { + if (flushModel && agent.promptWithModel) { + await agent.promptWithModel(threadId, { text }, flushModel); + } else { + await agent.prompt(threadId, { text }); + } + } // Soft flush: just prompt to save, don't compact if (level === "soft") { @@ -188,10 +211,10 @@ export async function flushMemoryThenCompact( try { const flushText = buildFlushPrompt(mode === "unknown" ? "full" : mode, "soft"); - await agent.prompt(threadId, { text: flushText }); + await sendFlush(flushText); state.lastSoftFlushAt = new Date().toISOString(); await saveThreadMemoryState(threadId, state); - console.log(`[memory] soft flush completed for ${threadId}`); + console.log(`[memory] soft flush completed for ${threadId}${flushModel ? ` (model: ${flushModel})` : ""}`); } catch (err) { console.error(`[memory] soft flush failed for ${threadId}:`, (err as Error).message); } @@ -202,16 +225,24 @@ export async function flushMemoryThenCompact( if (!agent.compact) return null; const effectiveLevel = level === "manual" ? "hard" : level; + const t0 = Date.now(); try { // Step 1: flush const flushText = buildFlushPrompt(mode === "unknown" ? "full" : mode, effectiveLevel); - console.log(`[memory] flushing memory for ${threadId} (level: ${level})`); - await agent.prompt(threadId, { text: flushText }); - - // Step 2: compact - console.log(`[memory] compacting ${threadId}`); - const result = await agent.compact(threadId); + console.log(`[memory] flushing memory for ${threadId} (level: ${level}${flushModel ? `, model: ${flushModel}` : ""})`); + await onProgress?.("💭 Flushing memory..."); + await sendFlush(flushText); + const flushMs = Date.now() - t0; + + // Step 2: compact (use flush model if compactWithModel is available) + console.log(`[memory] compacting ${threadId} (flush took ${flushMs}ms)`); + await onProgress?.(`✂️ Compacting context... (flush took ${(flushMs / 1000).toFixed(1)}s)`); + const t1 = Date.now(); + const result = flushModel && agent.compactWithModel + ? await agent.compactWithModel(threadId, flushModel) + : await agent.compact!(threadId); + const compactMs = Date.now() - t1; if (!result) return null; // Step 3: mark force re-inject (Full mode only) @@ -223,8 +254,27 @@ export async function flushMemoryThenCompact( await saveThreadMemoryState(threadId, state); } - console.log(`[memory] flush+compact done for ${threadId}: ${result.tokensBefore} → ${result.tokensAfter ?? "?"} tokens`); - return result; + const totalMs = Date.now() - t0; + const timing = { flushMs, compactMs, totalMs, model: flushModel ?? "default" }; + console.log(`[memory] flush+compact done for ${threadId}: ${result.tokensBefore} → ${result.tokensAfter ?? "?"} tokens | flush=${flushMs}ms compact=${compactMs}ms total=${totalMs}ms model=${timing.model}`); + + // Persist timing log for debugging (async, fire-and-forget) + const logDir = join(homedir(), ".roundhouse", "logs"); + mkdir(logDir, { recursive: true }) + .then(() => { + const entry = JSON.stringify({ + ts: new Date().toISOString(), + threadId, + level, + tokensBefore: result.tokensBefore, + tokensAfter: result.tokensAfter, + ...timing, + }); + return appendFile(join(logDir, "compact-timing.jsonl"), entry + "\n"); + }) + .catch((err) => console.warn(`[memory] timing log write failed:`, (err as Error).message)); + + return { ...result, timing }; } catch (err) { console.error(`[memory] flush+compact failed for ${threadId}:`, (err as Error).message); // Mark pending so we retry on next turn diff --git a/src/memory/types.ts b/src/memory/types.ts index 36b5ca4..3dfc561 100644 --- a/src/memory/types.ts +++ b/src/memory/types.ts @@ -40,6 +40,8 @@ export interface MemoryConfig { emergencyThresholdTokens?: number; /** Min time between soft flushes in ms (default: 600000 = 10min) */ cooldownMs?: number; + /** Model ID for flush turns (default: "amazon-bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0" — fast, matches Sonnet quality for structured writes) */ + flushModel?: string | null; }; } @@ -87,4 +89,40 @@ export interface PreparedTurn { injected: boolean; /** Pending compact level from a previously interrupted flush */ pendingCompact?: "soft" | "hard" | "emergency"; + /** Cached snapshot from pre-turn read (avoids re-reading in finalize) */ + snapshot?: MemorySnapshot; + /** Resolved file set (avoids re-resolving in finalize) */ + fileSet?: MemoryFileSet; + /** Set by caller after turn: whether agent used file-modifying tools (write/edit/bash) */ + turnUsedTools?: boolean; +} + +// ── Tool classification ────────────────────────────── + +/** + * Tools known to be read-only (cannot modify files on disk). + * Any tool NOT in this set is assumed to potentially modify files, + * triggering a memory digest re-read after the turn. + */ +export const READ_ONLY_TOOLS: ReadonlySet = new Set([ + "read", + "grep", + "find", + "ls", + "glob", +]); + +// ── Compact timing ─────────────────────────────── + +export interface CompactTiming { + flushMs: number; + compactMs: number; + totalMs: number; + model: string; +} + +export interface CompactResult { + tokensBefore: number; + tokensAfter: number | null; + timing?: CompactTiming; } diff --git a/src/telegram-progress.ts b/src/telegram-progress.ts new file mode 100644 index 0000000..ccaa1c2 --- /dev/null +++ b/src/telegram-progress.ts @@ -0,0 +1,76 @@ +/** + * telegram-progress.ts — Editable progress messages for long-running operations + */ + +/** Parse Telegram chat_id and optional message_thread_id from a Chat SDK thread ID */ +function parseTelegramThreadId(threadId: string): { chatId: string; messageThreadId?: number } { + const parts = threadId.split(":"); + const chatId = parts[1]; + const topicPart = parts[2]; + const result: { chatId: string; messageThreadId?: number } = { chatId }; + if (topicPart) { + const parsed = parseInt(topicPart, 10); + if (Number.isFinite(parsed)) result.messageThreadId = parsed; + } + return result; +} + +export interface ProgressMessage { + /** Update the message text (edits in place) */ + update(text: string): Promise; +} + +/** + * Send an initial message and return a handle to edit it in-place. + * Falls back to no-op if the thread isn't Telegram or the send fails. + */ +export async function createProgressMessage(thread: any, initialText: string): Promise { + const isTelegram = + typeof thread?.adapter?.telegramFetch === "function" && + typeof thread?.id === "string" && + thread.id.startsWith("telegram:"); + + if (!isTelegram) { + // Non-Telegram: just post once, updates are no-ops + await thread.post(initialText); + return { update: async () => {} }; + } + + const { chatId, messageThreadId } = parseTelegramThreadId(thread.id); + const basePayload = { + chat_id: chatId, + ...(messageThreadId !== undefined && { message_thread_id: messageThreadId }), + disable_web_page_preview: true, + }; + + let messageId: number | null = null; + let lastText = ""; + + try { + const result = await thread.adapter.telegramFetch("sendMessage", { + ...basePayload, + text: initialText, + }); + messageId = result.message_id; + lastText = initialText; + } catch { + // Fallback: use thread.post (can't edit later) + await thread.post(initialText); + } + + return { + async update(text: string) { + if (!messageId || text === lastText) return; + try { + await thread.adapter.telegramFetch("editMessageText", { + ...basePayload, + message_id: messageId, + text, + }); + lastText = text; + } catch { + // Edit failed (rate limit, message deleted, etc.) — skip silently + } + }, + }; +} diff --git a/src/types.ts b/src/types.ts index 324724b..a1e9c97 100644 --- a/src/types.ts +++ b/src/types.ts @@ -52,6 +52,12 @@ export interface AgentAdapter { /** Send a user message and return the full assistant response */ prompt(threadId: string, message: AgentMessage): Promise; + /** + * Send a prompt using a specific model (for maintenance turns like memory flush). + * Falls back to prompt() if not implemented or model unavailable. + */ + promptWithModel?(threadId: string, message: AgentMessage, modelId: string): Promise; + /** * Send a user message and stream back events in real time. * Falls back to prompt() if not implemented. @@ -63,6 +69,8 @@ export interface AgentAdapter { /** Compact the session context for a thread */ compact?(threadId: string): Promise<{ tokensBefore: number; tokensAfter: number | null } | null>; + /** Compact with a specific model (avoids restoring to default between flush and compact) */ + compactWithModel?(threadId: string, modelId: string): Promise<{ tokensBefore: number; tokensAfter: number | null } | null>; /** Abort the current agent run for a thread */ abort?(threadId: string): Promise; diff --git a/test/memory.test.ts b/test/memory.test.ts index c142414..7e6bd94 100644 --- a/test/memory.test.ts +++ b/test/memory.test.ts @@ -213,3 +213,46 @@ describe("buildFlushPrompt", () => { expect(result).not.toContain("Context is filling"); }); }); + +// ── READ_ONLY_TOOLS ───────────────────────────────── + +import { READ_ONLY_TOOLS } from "../src/memory/types"; +import type { CompactResult, CompactTiming } from "../src/memory/types"; + +describe("READ_ONLY_TOOLS", () => { + it("contains expected read-only tools", () => { + expect(READ_ONLY_TOOLS.has("read")).toBe(true); + expect(READ_ONLY_TOOLS.has("grep")).toBe(true); + expect(READ_ONLY_TOOLS.has("find")).toBe(true); + expect(READ_ONLY_TOOLS.has("ls")).toBe(true); + expect(READ_ONLY_TOOLS.has("glob")).toBe(true); + }); + + it("does NOT contain file-modifying tools", () => { + expect(READ_ONLY_TOOLS.has("write")).toBe(false); + expect(READ_ONLY_TOOLS.has("edit")).toBe(false); + expect(READ_ONLY_TOOLS.has("bash")).toBe(false); + expect(READ_ONLY_TOOLS.has("multi_edit")).toBe(false); + }); + + it("does NOT contain unknown/extension tools (safe default: assume writing)", () => { + expect(READ_ONLY_TOOLS.has("my_custom_tool")).toBe(false); + expect(READ_ONLY_TOOLS.has("")).toBe(false); + }); +}); + +// ── CompactResult type ─────────────────────────────── + +describe("CompactResult", () => { + it("allows result without timing (backwards compat)", () => { + const result: CompactResult = { tokensBefore: 80000, tokensAfter: 5000 }; + expect(result.timing).toBeUndefined(); + }); + + it("allows result with timing", () => { + const timing: CompactTiming = { flushMs: 3000, compactMs: 5000, totalMs: 8000, model: "haiku" }; + const result: CompactResult = { tokensBefore: 80000, tokensAfter: 5000, timing }; + expect(result.timing!.flushMs).toBe(3000); + expect(result.timing!.model).toBe("haiku"); + }); +});