@inceptionstack/roundhouse 0.3.27 → 0.3.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inceptionstack/roundhouse",
3
- "version": "0.3.27",
3
+ "version": "0.3.28",
4
4
  "type": "module",
5
5
  "description": "Multi-platform chat gateway that routes messages through a configured AI agent",
6
6
  "license": "MIT",
package/src/agents/pi.ts CHANGED
@@ -331,6 +331,50 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
331
331
  return enqueue(threadId, () => doPrompt(threadId, formatMessage(message)));
332
332
  },
333
333
 
334
+ async promptWithModel(threadId: string, message: AgentMessage, modelId: string): Promise<AgentResponse> {
335
+ return enqueue(threadId, async () => {
336
+ const entry = await getOrCreate(threadId);
337
+ const currentModel = entry.session.model;
338
+
339
+ // Resolve the target model (format: "provider/model-id")
340
+ let targetModel;
341
+ const [provider, ...rest] = modelId.split("/");
342
+ const id = rest.join("/");
343
+ if (provider && id) {
344
+ targetModel = modelRegistry.find(provider, id);
345
+ }
346
+
347
+ if (!targetModel) {
348
+ console.warn(`[pi-agent] flush model "${modelId}" not found, using default`);
349
+ return doPrompt(threadId, formatMessage(message));
350
+ }
351
+
352
+ // Verify auth is available for the target model
353
+ if (!modelRegistry.hasConfiguredAuth(targetModel)) {
354
+ console.warn(`[pi-agent] no auth for flush model "${modelId}", using default`);
355
+ return doPrompt(threadId, formatMessage(message));
356
+ }
357
+
358
+ // Swap model in-memory only (no persistence to settings.json or session log).
359
+ // This avoids a crash-window where settings could be left on the flush model.
360
+ const agentState = (entry.session as any).agent?.state;
361
+ if (!agentState) {
362
+ console.warn(`[pi-agent] cannot access agent state for model swap, using default`);
363
+ return doPrompt(threadId, formatMessage(message));
364
+ }
365
+
366
+ agentState.model = targetModel;
367
+ console.log(`[pi-agent] switched to flush model (in-memory): ${modelId}`);
368
+
369
+ try {
370
+ return await doPrompt(threadId, formatMessage(message));
371
+ } finally {
372
+ // Restore original model (in-memory only) — even if undefined
373
+ agentState.model = currentModel;
374
+ }
375
+ });
376
+ },
377
+
334
378
  promptStream(threadId: string, message: AgentMessage): AsyncIterable<AgentStreamEvent> {
335
379
  const text = formatMessage(message);
336
380
  // Return an async iterable that is single-use by design.
@@ -465,6 +509,49 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
465
509
  });
466
510
  },
467
511
 
512
+ async compactWithModel(threadId: string, modelId: string): Promise<{ tokensBefore: number; tokensAfter: number | null } | null> {
513
+ return enqueue(threadId, async () => {
514
+ const entry = sessions.get(threadId);
515
+ if (!entry) return null;
516
+
517
+ const agentState = (entry.session as any).agent?.state;
518
+ let currentModel: any;
519
+ let modelSwapped = false;
520
+
521
+ // Resolve and swap model for compact
522
+ if (!agentState) {
523
+ console.warn(`[pi-agent] cannot access agent state for compact model swap, using default`);
524
+ } else {
525
+ const [provider, ...rest] = modelId.split("/");
526
+ const id = rest.join("/");
527
+ const targetModel = (provider && id) ? modelRegistry.find(provider, id) : null;
528
+ if (!targetModel) {
529
+ console.warn(`[pi-agent] compact model "${modelId}" not found, using default`);
530
+ } else if (!modelRegistry.hasConfiguredAuth(targetModel)) {
531
+ console.warn(`[pi-agent] no auth for compact model "${modelId}", using default`);
532
+ } else {
533
+ currentModel = agentState.model;
534
+ agentState.model = targetModel;
535
+ modelSwapped = true;
536
+ console.log(`[pi-agent] compact using model (in-memory): ${modelId}`);
537
+ }
538
+ }
539
+
540
+ try {
541
+ const result = await entry.session.compact();
542
+ const usage = entry.session.getContextUsage();
543
+ return {
544
+ tokensBefore: result.tokensBefore,
545
+ tokensAfter: usage?.tokens ?? null,
546
+ };
547
+ } finally {
548
+ if (modelSwapped) {
549
+ agentState.model = currentModel;
550
+ }
551
+ }
552
+ });
553
+ },
554
+
468
555
  async abort(threadId: string): Promise<void> {
469
556
  const entry = sessions.get(threadId);
470
557
  if (entry) {
package/src/gateway.ts CHANGED
@@ -20,8 +20,10 @@ import { formatSchedule, formatRunCounts, jobEnabledIcon } from "./cron/format";
20
20
  import { BOT_COMMANDS } from "./commands";
21
21
  import { prepareMemoryForTurn, finalizeMemoryForTurn, flushMemoryThenCompact, determineMemoryMode } from "./memory/lifecycle";
22
22
  import { maxPressure } from "./memory/policy";
23
- import type { PressureLevel } from "./memory/types";
23
+ import type { PressureLevel, CompactResult } from "./memory/types";
24
+ import { READ_ONLY_TOOLS } from "./memory/types";
24
25
  import { readPendingPairing, completePendingPairing, isStartForNonce } from "./pairing";
26
+ import { createProgressMessage } from "./telegram-progress";
25
27
 
26
28
  /** Match a Telegram command, handling optional @botname suffix */
27
29
  /** Bot username for command suffix validation (set during gateway init) */
@@ -493,7 +495,7 @@ export class Gateway {
493
495
  threadLocks.set(agentThreadId, lockPromise);
494
496
  if (prevLock) await prevLock;
495
497
 
496
- await thread.post("📝 Saving memory and compacting...");
498
+ const progress = await createProgressMessage(thread, "📝 Saving memory and compacting...");
497
499
  const stopTyping = startTypingLoop(thread);
498
500
  try {
499
501
  const agentCwd = (agent.getInfo?.()?.cwd as string) ?? process.cwd();
@@ -502,23 +504,28 @@ export class Gateway {
502
504
  if (this.config.memory?.enabled === false) {
503
505
  const result = await agent.compact(agentThreadId);
504
506
  if (!result) {
505
- await thread.post("⚠️ No active session to compact. Send a message first.");
507
+ await progress.update("⚠️ No active session to compact. Send a message first.");
506
508
  } else {
507
509
  const beforeK = (result.tokensBefore / 1000).toFixed(1);
508
- await thread.post(`✅ Compaction complete\n\nCompacted ${beforeK}K tokens down to a summary.\nContext usage will update after your next message.`);
510
+ await progress.update(`✅ Compaction complete\n\nCompacted ${beforeK}K tokens down to a summary.\nContext usage will update after your next message.`);
509
511
  }
510
512
  } else {
511
- const result = await flushMemoryThenCompact(agentThreadId, agent, memoryRoot, "manual", this.config.memory);
513
+ const result = await flushMemoryThenCompact(
514
+ agentThreadId, agent, memoryRoot, "manual", this.config.memory,
515
+ (step) => progress.update(step),
516
+ );
512
517
  if (!result) {
513
- await thread.post("⚠️ No active session to compact. Send a message first.");
518
+ await progress.update("⚠️ No active session to compact. Send a message first.");
514
519
  } else {
515
520
  const beforeK = (result.tokensBefore / 1000).toFixed(1);
516
- await thread.post(`✅ Memory saved & compacted\n\nCompacted ${beforeK}K tokens down to a summary.\nContext usage will update after your next message.`);
521
+ const timing = result.timing;
522
+ 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}` : "";
523
+ await progress.update(`✅ Memory saved & compacted\n\nCompacted ${beforeK}K tokens down to a summary.\nContext usage will update after your next message.${timingLine}`);
517
524
  }
518
525
  }
519
526
  } catch (err) {
520
527
  const msg = err instanceof Error ? err.message : String(err);
521
- await thread.post(`⚠️ Compaction failed: ${msg.slice(0, 200)}`);
528
+ await progress.update(`⚠️ Compaction failed: ${msg.slice(0, 200)}`);
522
529
  } finally {
523
530
  stopTyping();
524
531
  releaseLock!();
@@ -687,17 +694,20 @@ export class Gateway {
687
694
  const stopTyping = startTypingLoop(thread);
688
695
 
689
696
  try {
697
+ let turnUsedTools = false;
690
698
  if (agent.promptStream) {
691
699
  const ac = new AbortController();
692
700
  abortControllers.set(agentThreadId, ac);
693
701
  try {
694
- await this.handleStreaming(thread, agent.promptStream(agentThreadId, agentMessage), verboseThreads.has(agentThreadId), ac.signal);
702
+ const streamResult = await this.handleStreaming(thread, agent.promptStream(agentThreadId, agentMessage), verboseThreads.has(agentThreadId), ac.signal);
703
+ turnUsedTools = streamResult.usedTools;
695
704
  } finally {
696
705
  abortControllers.delete(agentThreadId);
697
706
  }
698
707
  } else {
699
- // Fallback: non-streaming prompt
708
+ // Fallback: non-streaming prompt (assume tools may have been used)
700
709
  const reply = await agent.prompt(agentThreadId, agentMessage);
710
+ turnUsedTools = true;
701
711
  if (reply.text) {
702
712
  await this.postWithFallback(thread, reply.text);
703
713
  }
@@ -705,9 +715,10 @@ export class Gateway {
705
715
 
706
716
  // ── Memory: post-turn finalize + pressure check ───
707
717
  try {
718
+ if (memoryPrepared) memoryPrepared.turnUsedTools = turnUsedTools;
708
719
  const pressure = await finalizeMemoryForTurn(
709
720
  agentThreadId,
710
- memoryPrepared?.beforeDigest ?? null,
721
+ memoryPrepared ?? { message: agentMessage, beforeDigest: null, injected: false },
711
722
  agent, memoryRoot, this.config.memory,
712
723
  );
713
724
  // Use higher severity between pending compact and current pressure
@@ -907,11 +918,17 @@ export class Gateway {
907
918
 
908
919
  // Hard or emergency: flush + compact
909
920
  try {
910
- await thread.post(`📝 ${pressure === "emergency" ? "⚠️ Context nearly full! " : ""}Saving memory and compacting...`);
911
- const result = await flushMemoryThenCompact(agentThreadId, agent, memoryRoot, pressure, this.config.memory);
921
+ const prefix = pressure === "emergency" ? "⚠️ Context nearly full! " : "";
922
+ const progress = await createProgressMessage(thread, `📝 ${prefix}Saving memory and compacting...`);
923
+ const result = await flushMemoryThenCompact(
924
+ agentThreadId, agent, memoryRoot, pressure, this.config.memory,
925
+ (step) => progress.update(step),
926
+ );
912
927
  if (result) {
913
928
  const beforeK = (result.tokensBefore / 1000).toFixed(1);
914
- await thread.post(`✅ Auto-compacted: ${beforeK}K tokens → summary.`);
929
+ const timing = result.timing;
930
+ const timingLine = timing ? ` (${(timing.totalMs / 1000).toFixed(1)}s: flush ${(timing.flushMs / 1000).toFixed(1)}s + compact ${(timing.compactMs / 1000).toFixed(1)}s)` : "";
931
+ await progress.update(`✅ Auto-compacted: ${beforeK}K tokens → summary.${timingLine}`);
915
932
  }
916
933
  } catch (err) {
917
934
  console.error(`[roundhouse] ${pressure} compact error:`, (err as Error).message);
@@ -927,8 +944,9 @@ export class Gateway {
927
944
  * - Tool starts/ends are sent as compact status messages.
928
945
  * - Turn boundaries trigger a new message for the next turn's text.
929
946
  */
930
- private async handleStreaming(thread: any, stream: AsyncIterable<AgentStreamEvent>, verbose: boolean, signal?: AbortSignal) {
947
+ private async handleStreaming(thread: any, stream: AsyncIterable<AgentStreamEvent>, verbose: boolean, signal?: AbortSignal): Promise<{ usedTools: boolean }> {
931
948
  let activeTools = new Map<string, string>(); // toolCallId -> toolName
949
+ let usedFileModifyingTools = false;
932
950
 
933
951
  // Per-turn streaming state — each turn gets a fresh iterable + promise
934
952
  let currentPush: ((text: string) => void) | null = null;
@@ -1032,6 +1050,7 @@ export class Gateway {
1032
1050
 
1033
1051
  case "tool_start": {
1034
1052
  activeTools.set(event.toolCallId, event.toolName);
1053
+ if (!READ_ONLY_TOOLS.has(event.toolName)) usedFileModifyingTools = true;
1035
1054
  if (verbose) {
1036
1055
  try {
1037
1056
  await thread.post(`${toolIcon(event.toolName)} Running \`${event.toolName}\`…`);
@@ -1102,6 +1121,8 @@ export class Gateway {
1102
1121
  if (currentPromise) {
1103
1122
  await flushCurrentStream();
1104
1123
  }
1124
+
1125
+ return { usedTools: usedFileModifyingTools };
1105
1126
  }
1106
1127
 
1107
1128
  /** Post text with markdown, falling back to plain text */
@@ -9,13 +9,16 @@
9
9
  */
10
10
 
11
11
  import type { AgentAdapter, AgentMessage } from "../types";
12
- import type { MemoryConfig, MemoryMode, PreparedTurn, PressureLevel, ThreadMemoryState } from "./types";
12
+ import type { MemoryConfig, MemoryFileSet, MemoryMode, MemorySnapshot, PreparedTurn, PressureLevel, ThreadMemoryState, CompactResult } from "./types";
13
13
  import { resolveMemoryFiles, readMemorySnapshot, formatDate } from "./files";
14
14
  import { loadThreadMemoryState, saveThreadMemoryState } from "./state";
15
15
  import { shouldInjectMemory, classifyContextPressure, isSoftFlushOnCooldown } from "./policy";
16
16
  import { buildMemoryInjection, injectMemoryIntoMessage } from "./inject";
17
17
  import { buildFlushPrompt } from "./prompts";
18
18
  import { bootstrapMemoryFiles } from "./bootstrap";
19
+ import { appendFile, mkdir } from "node:fs/promises";
20
+ import { join } from "node:path";
21
+ import { homedir } from "node:os";
19
22
 
20
23
  // ── Memory mode detection ────────────────────────────
21
24
 
@@ -52,9 +55,7 @@ export async function prepareMemoryForTurn(
52
55
 
53
56
  const mode = getMode(agent);
54
57
 
55
- // Complement mode: no injection, just track digest for finalize
56
- // Unknown mode: also skip — we can't inject correctly before knowing if agent has memory extension
57
- // (mode is detected during session creation, which happens inside promptStream)
58
+ // Complement mode: no injection, no digest tracking needed (finalize skips complement)
58
59
  if (mode === "complement" || mode === "unknown") {
59
60
  return { message, beforeDigest: null, injected: false };
60
61
  }
@@ -91,10 +92,10 @@ export async function prepareMemoryForTurn(
91
92
  await saveThreadMemoryState(threadId, state);
92
93
 
93
94
  console.log(`[memory] injected into ${threadId} (reason: ${decision.reason}, ${snapshot.entries.length} files, digest: ${snapshot.digest})`);
94
- return { message: injectedMessage, beforeDigest: snapshot.digest, injected: true, pendingCompact: pendingCompactLevel };
95
+ return { message: injectedMessage, beforeDigest: snapshot.digest, injected: true, pendingCompact: pendingCompactLevel, fileSet, snapshot };
95
96
  }
96
97
 
97
- return { message, beforeDigest: snapshot.digest, injected: false, pendingCompact: pendingCompactLevel };
98
+ return { message, beforeDigest: snapshot.digest, injected: false, pendingCompact: pendingCompactLevel, fileSet, snapshot };
98
99
  } catch (err) {
99
100
  console.error(`[memory] prepareMemoryForTurn error:`, (err as Error).message);
100
101
  return { message, beforeDigest: null, injected: false };
@@ -109,11 +110,14 @@ export async function prepareMemoryForTurn(
109
110
  * In Full mode: check if agent wrote memory files (update digest).
110
111
  * Both modes: check context pressure for proactive compaction.
111
112
  *
113
+ * Uses cached fileSet from PreparedTurn to avoid re-resolving files.
114
+ * Only re-reads files if the turn included tool calls that could have modified them.
115
+ *
112
116
  * Returns the pressure level for the gateway to act on.
113
117
  */
114
118
  export async function finalizeMemoryForTurn(
115
119
  threadId: string,
116
- beforeDigest: string | null,
120
+ prepared: PreparedTurn,
117
121
  agent: AgentAdapter,
118
122
  rootDir: string,
119
123
  config?: MemoryConfig,
@@ -121,21 +125,25 @@ export async function finalizeMemoryForTurn(
121
125
  if (config?.enabled === false) return "none";
122
126
 
123
127
  const mode = getMode(agent);
128
+ const beforeDigest = prepared.beforeDigest;
124
129
 
125
130
  // In Full mode: check if agent modified memory files
126
131
  if (mode !== "complement" && beforeDigest) {
127
- try {
128
- const fileSet = resolveMemoryFiles(rootDir, config);
129
- const snapshot = await readMemorySnapshot(fileSet, config?.inject?.maxBytes);
130
- if (snapshot.digest !== beforeDigest) {
131
- const state = await loadThreadMemoryState(threadId);
132
- state.lastInjectedDigest = snapshot.digest;
133
- state.lastKnownDigest = snapshot.digest;
134
- await saveThreadMemoryState(threadId, state);
135
- console.log(`[memory] agent updated memory files (new digest: ${snapshot.digest})`);
132
+ // Skip expensive re-read if no file-modifying tools ran during this turn
133
+ if (prepared.turnUsedTools !== false) {
134
+ try {
135
+ const fileSet = prepared.fileSet ?? resolveMemoryFiles(rootDir, config);
136
+ const snapshot = await readMemorySnapshot(fileSet, config?.inject?.maxBytes);
137
+ if (snapshot.digest !== beforeDigest) {
138
+ const state = await loadThreadMemoryState(threadId);
139
+ state.lastInjectedDigest = snapshot.digest;
140
+ state.lastKnownDigest = snapshot.digest;
141
+ await saveThreadMemoryState(threadId, state);
142
+ console.log(`[memory] agent updated memory files (new digest: ${snapshot.digest})`);
143
+ }
144
+ } catch (err) {
145
+ console.error(`[memory] finalizeMemoryForTurn digest check error:`, (err as Error).message);
136
146
  }
137
- } catch (err) {
138
- console.error(`[memory] finalizeMemoryForTurn digest check error:`, (err as Error).message);
139
147
  }
140
148
  }
141
149
 
@@ -167,6 +175,8 @@ export async function finalizeMemoryForTurn(
167
175
  * 2. Compact the session
168
176
  * 3. Mark force re-inject for Full mode
169
177
  *
178
+ * Uses a cheaper model for flush turns if config.compact.flushModel is set.
179
+ *
170
180
  * Returns compaction result or null if nothing to compact.
171
181
  */
172
182
  export async function flushMemoryThenCompact(
@@ -175,8 +185,21 @@ export async function flushMemoryThenCompact(
175
185
  rootDir: string,
176
186
  level: "soft" | "hard" | "emergency" | "manual",
177
187
  config?: MemoryConfig,
178
- ): Promise<{ tokensBefore: number; tokensAfter: number | null } | null> {
188
+ onProgress?: (step: string) => void | Promise<void>,
189
+ ): Promise<CompactResult | null> {
179
190
  const mode = getMode(agent);
191
+ // Default to Sonnet for flush turns (faster). Set to null to use conversation model.
192
+ const DEFAULT_FLUSH_MODEL = "amazon-bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0";
193
+ const flushModel = config?.compact?.flushModel === null ? undefined : (config?.compact?.flushModel ?? DEFAULT_FLUSH_MODEL);
194
+
195
+ /** Send flush prompt, preferring flushModel if available */
196
+ async function sendFlush(text: string): Promise<void> {
197
+ if (flushModel && agent.promptWithModel) {
198
+ await agent.promptWithModel(threadId, { text }, flushModel);
199
+ } else {
200
+ await agent.prompt(threadId, { text });
201
+ }
202
+ }
180
203
 
181
204
  // Soft flush: just prompt to save, don't compact
182
205
  if (level === "soft") {
@@ -188,10 +211,10 @@ export async function flushMemoryThenCompact(
188
211
 
189
212
  try {
190
213
  const flushText = buildFlushPrompt(mode === "unknown" ? "full" : mode, "soft");
191
- await agent.prompt(threadId, { text: flushText });
214
+ await sendFlush(flushText);
192
215
  state.lastSoftFlushAt = new Date().toISOString();
193
216
  await saveThreadMemoryState(threadId, state);
194
- console.log(`[memory] soft flush completed for ${threadId}`);
217
+ console.log(`[memory] soft flush completed for ${threadId}${flushModel ? ` (model: ${flushModel})` : ""}`);
195
218
  } catch (err) {
196
219
  console.error(`[memory] soft flush failed for ${threadId}:`, (err as Error).message);
197
220
  }
@@ -202,16 +225,24 @@ export async function flushMemoryThenCompact(
202
225
  if (!agent.compact) return null;
203
226
 
204
227
  const effectiveLevel = level === "manual" ? "hard" : level;
228
+ const t0 = Date.now();
205
229
 
206
230
  try {
207
231
  // Step 1: flush
208
232
  const flushText = buildFlushPrompt(mode === "unknown" ? "full" : mode, effectiveLevel);
209
- console.log(`[memory] flushing memory for ${threadId} (level: ${level})`);
210
- await agent.prompt(threadId, { text: flushText });
211
-
212
- // Step 2: compact
213
- console.log(`[memory] compacting ${threadId}`);
214
- const result = await agent.compact(threadId);
233
+ console.log(`[memory] flushing memory for ${threadId} (level: ${level}${flushModel ? `, model: ${flushModel}` : ""})`);
234
+ await onProgress?.("💭 Flushing memory...");
235
+ await sendFlush(flushText);
236
+ const flushMs = Date.now() - t0;
237
+
238
+ // Step 2: compact (use flush model if compactWithModel is available)
239
+ console.log(`[memory] compacting ${threadId} (flush took ${flushMs}ms)`);
240
+ await onProgress?.(`✂️ Compacting context... (flush took ${(flushMs / 1000).toFixed(1)}s)`);
241
+ const t1 = Date.now();
242
+ const result = flushModel && agent.compactWithModel
243
+ ? await agent.compactWithModel(threadId, flushModel)
244
+ : await agent.compact!(threadId);
245
+ const compactMs = Date.now() - t1;
215
246
  if (!result) return null;
216
247
 
217
248
  // Step 3: mark force re-inject (Full mode only)
@@ -223,8 +254,27 @@ export async function flushMemoryThenCompact(
223
254
  await saveThreadMemoryState(threadId, state);
224
255
  }
225
256
 
226
- console.log(`[memory] flush+compact done for ${threadId}: ${result.tokensBefore} ${result.tokensAfter ?? "?"} tokens`);
227
- return result;
257
+ const totalMs = Date.now() - t0;
258
+ const timing = { flushMs, compactMs, totalMs, model: flushModel ?? "default" };
259
+ 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}`);
260
+
261
+ // Persist timing log for debugging (async, fire-and-forget)
262
+ const logDir = join(homedir(), ".roundhouse", "logs");
263
+ mkdir(logDir, { recursive: true })
264
+ .then(() => {
265
+ const entry = JSON.stringify({
266
+ ts: new Date().toISOString(),
267
+ threadId,
268
+ level,
269
+ tokensBefore: result.tokensBefore,
270
+ tokensAfter: result.tokensAfter,
271
+ ...timing,
272
+ });
273
+ return appendFile(join(logDir, "compact-timing.jsonl"), entry + "\n");
274
+ })
275
+ .catch((err) => console.warn(`[memory] timing log write failed:`, (err as Error).message));
276
+
277
+ return { ...result, timing };
228
278
  } catch (err) {
229
279
  console.error(`[memory] flush+compact failed for ${threadId}:`, (err as Error).message);
230
280
  // Mark pending so we retry on next turn
@@ -40,6 +40,8 @@ export interface MemoryConfig {
40
40
  emergencyThresholdTokens?: number;
41
41
  /** Min time between soft flushes in ms (default: 600000 = 10min) */
42
42
  cooldownMs?: number;
43
+ /** Model ID for flush turns (default: "amazon-bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0" — fast, matches Sonnet quality for structured writes) */
44
+ flushModel?: string | null;
43
45
  };
44
46
  }
45
47
 
@@ -87,4 +89,40 @@ export interface PreparedTurn {
87
89
  injected: boolean;
88
90
  /** Pending compact level from a previously interrupted flush */
89
91
  pendingCompact?: "soft" | "hard" | "emergency";
92
+ /** Cached snapshot from pre-turn read (avoids re-reading in finalize) */
93
+ snapshot?: MemorySnapshot;
94
+ /** Resolved file set (avoids re-resolving in finalize) */
95
+ fileSet?: MemoryFileSet;
96
+ /** Set by caller after turn: whether agent used file-modifying tools (write/edit/bash) */
97
+ turnUsedTools?: boolean;
98
+ }
99
+
100
+ // ── Tool classification ──────────────────────────────
101
+
102
+ /**
103
+ * Tools known to be read-only (cannot modify files on disk).
104
+ * Any tool NOT in this set is assumed to potentially modify files,
105
+ * triggering a memory digest re-read after the turn.
106
+ */
107
+ export const READ_ONLY_TOOLS: ReadonlySet<string> = new Set([
108
+ "read",
109
+ "grep",
110
+ "find",
111
+ "ls",
112
+ "glob",
113
+ ]);
114
+
115
+ // ── Compact timing ───────────────────────────────
116
+
117
+ export interface CompactTiming {
118
+ flushMs: number;
119
+ compactMs: number;
120
+ totalMs: number;
121
+ model: string;
122
+ }
123
+
124
+ export interface CompactResult {
125
+ tokensBefore: number;
126
+ tokensAfter: number | null;
127
+ timing?: CompactTiming;
90
128
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * telegram-progress.ts — Editable progress messages for long-running operations
3
+ */
4
+
5
+ /** Parse Telegram chat_id and optional message_thread_id from a Chat SDK thread ID */
6
+ function parseTelegramThreadId(threadId: string): { chatId: string; messageThreadId?: number } {
7
+ const parts = threadId.split(":");
8
+ const chatId = parts[1];
9
+ const topicPart = parts[2];
10
+ const result: { chatId: string; messageThreadId?: number } = { chatId };
11
+ if (topicPart) {
12
+ const parsed = parseInt(topicPart, 10);
13
+ if (Number.isFinite(parsed)) result.messageThreadId = parsed;
14
+ }
15
+ return result;
16
+ }
17
+
18
+ export interface ProgressMessage {
19
+ /** Update the message text (edits in place) */
20
+ update(text: string): Promise<void>;
21
+ }
22
+
23
+ /**
24
+ * Send an initial message and return a handle to edit it in-place.
25
+ * Falls back to no-op if the thread isn't Telegram or the send fails.
26
+ */
27
+ export async function createProgressMessage(thread: any, initialText: string): Promise<ProgressMessage> {
28
+ const isTelegram =
29
+ typeof thread?.adapter?.telegramFetch === "function" &&
30
+ typeof thread?.id === "string" &&
31
+ thread.id.startsWith("telegram:");
32
+
33
+ if (!isTelegram) {
34
+ // Non-Telegram: just post once, updates are no-ops
35
+ await thread.post(initialText);
36
+ return { update: async () => {} };
37
+ }
38
+
39
+ const { chatId, messageThreadId } = parseTelegramThreadId(thread.id);
40
+ const basePayload = {
41
+ chat_id: chatId,
42
+ ...(messageThreadId !== undefined && { message_thread_id: messageThreadId }),
43
+ disable_web_page_preview: true,
44
+ };
45
+
46
+ let messageId: number | null = null;
47
+ let lastText = "";
48
+
49
+ try {
50
+ const result = await thread.adapter.telegramFetch("sendMessage", {
51
+ ...basePayload,
52
+ text: initialText,
53
+ });
54
+ messageId = result.message_id;
55
+ lastText = initialText;
56
+ } catch {
57
+ // Fallback: use thread.post (can't edit later)
58
+ await thread.post(initialText);
59
+ }
60
+
61
+ return {
62
+ async update(text: string) {
63
+ if (!messageId || text === lastText) return;
64
+ try {
65
+ await thread.adapter.telegramFetch("editMessageText", {
66
+ ...basePayload,
67
+ message_id: messageId,
68
+ text,
69
+ });
70
+ lastText = text;
71
+ } catch {
72
+ // Edit failed (rate limit, message deleted, etc.) — skip silently
73
+ }
74
+ },
75
+ };
76
+ }
package/src/types.ts CHANGED
@@ -52,6 +52,12 @@ export interface AgentAdapter {
52
52
  /** Send a user message and return the full assistant response */
53
53
  prompt(threadId: string, message: AgentMessage): Promise<AgentResponse>;
54
54
 
55
+ /**
56
+ * Send a prompt using a specific model (for maintenance turns like memory flush).
57
+ * Falls back to prompt() if not implemented or model unavailable.
58
+ */
59
+ promptWithModel?(threadId: string, message: AgentMessage, modelId: string): Promise<AgentResponse>;
60
+
55
61
  /**
56
62
  * Send a user message and stream back events in real time.
57
63
  * Falls back to prompt() if not implemented.
@@ -63,6 +69,8 @@ export interface AgentAdapter {
63
69
 
64
70
  /** Compact the session context for a thread */
65
71
  compact?(threadId: string): Promise<{ tokensBefore: number; tokensAfter: number | null } | null>;
72
+ /** Compact with a specific model (avoids restoring to default between flush and compact) */
73
+ compactWithModel?(threadId: string, modelId: string): Promise<{ tokensBefore: number; tokensAfter: number | null } | null>;
66
74
 
67
75
  /** Abort the current agent run for a thread */
68
76
  abort?(threadId: string): Promise<void>;