@inceptionstack/roundhouse 0.3.27 → 0.3.29
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/commands.ts +1 -0
- package/src/gateway.ts +78 -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/commands.ts
CHANGED
|
@@ -16,6 +16,7 @@ export const BOT_COMMANDS: BotCommand[] = [
|
|
|
16
16
|
{ command: "verbose", description: "Toggle verbose tool output" },
|
|
17
17
|
{ command: "stop", description: "Stop the current agent run" },
|
|
18
18
|
{ command: "restart", description: "Restart agent process" },
|
|
19
|
+
{ command: "update", description: "Update roundhouse and restart" },
|
|
19
20
|
{ command: "status", description: "Show system status" },
|
|
20
21
|
{ command: "doctor", description: "Run diagnostics" },
|
|
21
22
|
{ command: "crons", description: "List scheduled cron jobs" },
|
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) */
|
|
@@ -476,6 +478,48 @@ export class Gateway {
|
|
|
476
478
|
return;
|
|
477
479
|
}
|
|
478
480
|
|
|
481
|
+
// Handle /update command — update roundhouse then restart
|
|
482
|
+
if (isCommand(userText.trim(), "/update")) {
|
|
483
|
+
if (allowedUsers.length === 0 && allowedUserIds.length === 0) {
|
|
484
|
+
await thread.post("⚠️ /update requires an allowlist to be configured.");
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
console.log(`[roundhouse] /update requested by @${authorName} in thread=${thread.id}`);
|
|
488
|
+
const progress = await createProgressMessage(thread, "📦 Checking for updates...");
|
|
489
|
+
try {
|
|
490
|
+
const { execSync } = await import("child_process");
|
|
491
|
+
// Get current version
|
|
492
|
+
const pkg = await import("../package.json", { with: { type: "json" } });
|
|
493
|
+
const currentVersion = pkg.default?.version ?? "unknown";
|
|
494
|
+
// Check latest version on npm
|
|
495
|
+
const latestVersion = execSync("npm view @inceptionstack/roundhouse version 2>/dev/null", {
|
|
496
|
+
timeout: 30_000,
|
|
497
|
+
encoding: "utf8",
|
|
498
|
+
}).trim();
|
|
499
|
+
if (latestVersion === currentVersion) {
|
|
500
|
+
await progress.update(`✅ Already on latest (v${currentVersion})`);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
await progress.update(`📦 Updating v${currentVersion} → v${latestVersion}...`);
|
|
504
|
+
execSync("npm install -g @inceptionstack/roundhouse@latest 2>&1", {
|
|
505
|
+
timeout: 120_000,
|
|
506
|
+
encoding: "utf8",
|
|
507
|
+
});
|
|
508
|
+
await progress.update(`✅ Updated v${currentVersion} → v${latestVersion}. Restarting...`);
|
|
509
|
+
console.log(`[roundhouse] updated ${currentVersion} -> ${latestVersion}, restarting`);
|
|
510
|
+
// Exit so systemd restarts with new code
|
|
511
|
+
setTimeout(async () => {
|
|
512
|
+
try { await this.stop(); } catch (e) { console.error("[roundhouse] stop error:", e); }
|
|
513
|
+
process.exit(75);
|
|
514
|
+
}, 1500);
|
|
515
|
+
} catch (err) {
|
|
516
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
517
|
+
await progress.update(`⚠️ Update failed: ${msg.slice(0, 200)}`);
|
|
518
|
+
console.error(`[roundhouse] /update failed:`, msg);
|
|
519
|
+
}
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
479
523
|
// Handle /compact command — flush memory then compact session context
|
|
480
524
|
// Routed through the per-thread lock to prevent concurrent agent access
|
|
481
525
|
if (isCommand(userText.trim(), "/compact")) {
|
|
@@ -493,7 +537,7 @@ export class Gateway {
|
|
|
493
537
|
threadLocks.set(agentThreadId, lockPromise);
|
|
494
538
|
if (prevLock) await prevLock;
|
|
495
539
|
|
|
496
|
-
await thread
|
|
540
|
+
const progress = await createProgressMessage(thread, "📝 Saving memory and compacting...");
|
|
497
541
|
const stopTyping = startTypingLoop(thread);
|
|
498
542
|
try {
|
|
499
543
|
const agentCwd = (agent.getInfo?.()?.cwd as string) ?? process.cwd();
|
|
@@ -502,23 +546,28 @@ export class Gateway {
|
|
|
502
546
|
if (this.config.memory?.enabled === false) {
|
|
503
547
|
const result = await agent.compact(agentThreadId);
|
|
504
548
|
if (!result) {
|
|
505
|
-
await
|
|
549
|
+
await progress.update("⚠️ No active session to compact. Send a message first.");
|
|
506
550
|
} else {
|
|
507
551
|
const beforeK = (result.tokensBefore / 1000).toFixed(1);
|
|
508
|
-
await
|
|
552
|
+
await progress.update(`✅ Compaction complete\n\nCompacted ${beforeK}K tokens down to a summary.\nContext usage will update after your next message.`);
|
|
509
553
|
}
|
|
510
554
|
} else {
|
|
511
|
-
const result = await flushMemoryThenCompact(
|
|
555
|
+
const result = await flushMemoryThenCompact(
|
|
556
|
+
agentThreadId, agent, memoryRoot, "manual", this.config.memory,
|
|
557
|
+
(step) => progress.update(step),
|
|
558
|
+
);
|
|
512
559
|
if (!result) {
|
|
513
|
-
await
|
|
560
|
+
await progress.update("⚠️ No active session to compact. Send a message first.");
|
|
514
561
|
} else {
|
|
515
562
|
const beforeK = (result.tokensBefore / 1000).toFixed(1);
|
|
516
|
-
|
|
563
|
+
const timing = result.timing;
|
|
564
|
+
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}` : "";
|
|
565
|
+
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
566
|
}
|
|
518
567
|
}
|
|
519
568
|
} catch (err) {
|
|
520
569
|
const msg = err instanceof Error ? err.message : String(err);
|
|
521
|
-
await
|
|
570
|
+
await progress.update(`⚠️ Compaction failed: ${msg.slice(0, 200)}`);
|
|
522
571
|
} finally {
|
|
523
572
|
stopTyping();
|
|
524
573
|
releaseLock!();
|
|
@@ -687,17 +736,20 @@ export class Gateway {
|
|
|
687
736
|
const stopTyping = startTypingLoop(thread);
|
|
688
737
|
|
|
689
738
|
try {
|
|
739
|
+
let turnUsedTools = false;
|
|
690
740
|
if (agent.promptStream) {
|
|
691
741
|
const ac = new AbortController();
|
|
692
742
|
abortControllers.set(agentThreadId, ac);
|
|
693
743
|
try {
|
|
694
|
-
await this.handleStreaming(thread, agent.promptStream(agentThreadId, agentMessage), verboseThreads.has(agentThreadId), ac.signal);
|
|
744
|
+
const streamResult = await this.handleStreaming(thread, agent.promptStream(agentThreadId, agentMessage), verboseThreads.has(agentThreadId), ac.signal);
|
|
745
|
+
turnUsedTools = streamResult.usedTools;
|
|
695
746
|
} finally {
|
|
696
747
|
abortControllers.delete(agentThreadId);
|
|
697
748
|
}
|
|
698
749
|
} else {
|
|
699
|
-
// Fallback: non-streaming prompt
|
|
750
|
+
// Fallback: non-streaming prompt (assume tools may have been used)
|
|
700
751
|
const reply = await agent.prompt(agentThreadId, agentMessage);
|
|
752
|
+
turnUsedTools = true;
|
|
701
753
|
if (reply.text) {
|
|
702
754
|
await this.postWithFallback(thread, reply.text);
|
|
703
755
|
}
|
|
@@ -705,9 +757,10 @@ export class Gateway {
|
|
|
705
757
|
|
|
706
758
|
// ── Memory: post-turn finalize + pressure check ───
|
|
707
759
|
try {
|
|
760
|
+
if (memoryPrepared) memoryPrepared.turnUsedTools = turnUsedTools;
|
|
708
761
|
const pressure = await finalizeMemoryForTurn(
|
|
709
762
|
agentThreadId,
|
|
710
|
-
memoryPrepared
|
|
763
|
+
memoryPrepared ?? { message: agentMessage, beforeDigest: null, injected: false },
|
|
711
764
|
agent, memoryRoot, this.config.memory,
|
|
712
765
|
);
|
|
713
766
|
// Use higher severity between pending compact and current pressure
|
|
@@ -907,11 +960,17 @@ export class Gateway {
|
|
|
907
960
|
|
|
908
961
|
// Hard or emergency: flush + compact
|
|
909
962
|
try {
|
|
910
|
-
|
|
911
|
-
const
|
|
963
|
+
const prefix = pressure === "emergency" ? "⚠️ Context nearly full! " : "";
|
|
964
|
+
const progress = await createProgressMessage(thread, `📝 ${prefix}Saving memory and compacting...`);
|
|
965
|
+
const result = await flushMemoryThenCompact(
|
|
966
|
+
agentThreadId, agent, memoryRoot, pressure, this.config.memory,
|
|
967
|
+
(step) => progress.update(step),
|
|
968
|
+
);
|
|
912
969
|
if (result) {
|
|
913
970
|
const beforeK = (result.tokensBefore / 1000).toFixed(1);
|
|
914
|
-
|
|
971
|
+
const timing = result.timing;
|
|
972
|
+
const timingLine = timing ? ` (${(timing.totalMs / 1000).toFixed(1)}s: flush ${(timing.flushMs / 1000).toFixed(1)}s + compact ${(timing.compactMs / 1000).toFixed(1)}s)` : "";
|
|
973
|
+
await progress.update(`✅ Auto-compacted: ${beforeK}K tokens → summary.${timingLine}`);
|
|
915
974
|
}
|
|
916
975
|
} catch (err) {
|
|
917
976
|
console.error(`[roundhouse] ${pressure} compact error:`, (err as Error).message);
|
|
@@ -927,8 +986,9 @@ export class Gateway {
|
|
|
927
986
|
* - Tool starts/ends are sent as compact status messages.
|
|
928
987
|
* - Turn boundaries trigger a new message for the next turn's text.
|
|
929
988
|
*/
|
|
930
|
-
private async handleStreaming(thread: any, stream: AsyncIterable<AgentStreamEvent>, verbose: boolean, signal?: AbortSignal) {
|
|
989
|
+
private async handleStreaming(thread: any, stream: AsyncIterable<AgentStreamEvent>, verbose: boolean, signal?: AbortSignal): Promise<{ usedTools: boolean }> {
|
|
931
990
|
let activeTools = new Map<string, string>(); // toolCallId -> toolName
|
|
991
|
+
let usedFileModifyingTools = false;
|
|
932
992
|
|
|
933
993
|
// Per-turn streaming state — each turn gets a fresh iterable + promise
|
|
934
994
|
let currentPush: ((text: string) => void) | null = null;
|
|
@@ -1032,6 +1092,7 @@ export class Gateway {
|
|
|
1032
1092
|
|
|
1033
1093
|
case "tool_start": {
|
|
1034
1094
|
activeTools.set(event.toolCallId, event.toolName);
|
|
1095
|
+
if (!READ_ONLY_TOOLS.has(event.toolName)) usedFileModifyingTools = true;
|
|
1035
1096
|
if (verbose) {
|
|
1036
1097
|
try {
|
|
1037
1098
|
await thread.post(`${toolIcon(event.toolName)} Running \`${event.toolName}\`…`);
|
|
@@ -1102,6 +1163,8 @@ export class Gateway {
|
|
|
1102
1163
|
if (currentPromise) {
|
|
1103
1164
|
await flushCurrentStream();
|
|
1104
1165
|
}
|
|
1166
|
+
|
|
1167
|
+
return { usedTools: usedFileModifyingTools };
|
|
1105
1168
|
}
|
|
1106
1169
|
|
|
1107
1170
|
/** 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>;
|