@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 +1 -1
- package/src/agents/pi.ts +87 -0
- package/src/gateway.ts +36 -15
- package/src/memory/lifecycle.ts +79 -29
- package/src/memory/types.ts +38 -0
- package/src/telegram-progress.ts +76 -0
- package/src/types.ts +8 -0
package/package.json
CHANGED
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
911
|
-
const
|
|
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
|
-
|
|
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 */
|
package/src/memory/lifecycle.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
package/src/memory/types.ts
CHANGED
|
@@ -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>;
|