@inceptionstack/roundhouse 0.2.2 → 0.3.1

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.
Files changed (49) hide show
  1. package/README.md +321 -9
  2. package/architecture.md +77 -8
  3. package/package.json +9 -6
  4. package/src/agents/pi.ts +433 -26
  5. package/src/agents/registry.ts +8 -0
  6. package/src/cli/cli.ts +384 -189
  7. package/src/cli/cron.ts +296 -0
  8. package/src/cli/doctor/checks/agent.ts +68 -0
  9. package/src/cli/doctor/checks/config.ts +88 -0
  10. package/src/cli/doctor/checks/credentials.ts +62 -0
  11. package/src/cli/doctor/checks/disk.ts +69 -0
  12. package/src/cli/doctor/checks/stt.ts +76 -0
  13. package/src/cli/doctor/checks/system.ts +86 -0
  14. package/src/cli/doctor/checks/systemd.ts +76 -0
  15. package/src/cli/doctor/output.ts +58 -0
  16. package/src/cli/doctor/runner.ts +142 -0
  17. package/src/cli/doctor/shell.ts +33 -0
  18. package/src/cli/doctor/types.ts +44 -0
  19. package/src/cli/doctor.ts +48 -0
  20. package/src/cli/setup-telegram.ts +148 -0
  21. package/src/cli/setup.ts +936 -0
  22. package/src/commands.ts +23 -0
  23. package/src/config.ts +188 -0
  24. package/src/cron/constants.ts +54 -0
  25. package/src/cron/durations.ts +33 -0
  26. package/src/cron/format.ts +139 -0
  27. package/src/cron/helpers.ts +30 -0
  28. package/src/cron/runner.ts +148 -0
  29. package/src/cron/schedule.ts +101 -0
  30. package/src/cron/scheduler.ts +295 -0
  31. package/src/cron/store.ts +125 -0
  32. package/src/cron/template.ts +89 -0
  33. package/src/cron/types.ts +76 -0
  34. package/src/gateway.ts +927 -18
  35. package/src/index.ts +1 -58
  36. package/src/memory/bootstrap.ts +98 -0
  37. package/src/memory/files.ts +100 -0
  38. package/src/memory/inject.ts +41 -0
  39. package/src/memory/lifecycle.ts +245 -0
  40. package/src/memory/policy.ts +122 -0
  41. package/src/memory/prompts.ts +42 -0
  42. package/src/memory/state.ts +43 -0
  43. package/src/memory/types.ts +90 -0
  44. package/src/notify/telegram.ts +48 -0
  45. package/src/types.ts +68 -1
  46. package/src/util.ts +28 -2
  47. package/src/voice/providers/whisper.ts +339 -0
  48. package/src/voice/stt-service.ts +284 -0
  49. package/src/voice/types.ts +63 -0
package/src/index.ts CHANGED
@@ -8,13 +8,10 @@
8
8
  * TELEGRAM_BOT_TOKEN=... npm start -- --config ./my-config.json
9
9
  */
10
10
 
11
- import { readFile } from "node:fs/promises";
12
- import { resolve } from "node:path";
13
-
14
- import type { GatewayConfig } from "./types";
15
11
  import { getAgentFactory } from "./agents/registry";
16
12
  import { SingleAgentRouter } from "./router";
17
13
  import { Gateway } from "./gateway";
14
+ import { loadConfig } from "./config";
18
15
 
19
16
  // ── Crash protection ─────────────────────────────────
20
17
  process.on("uncaughtException", (err) => {
@@ -24,60 +21,6 @@ process.on("unhandledRejection", (reason) => {
24
21
  console.error("[roundhouse] unhandledRejection:", reason);
25
22
  });
26
23
 
27
- // ── Default config ───────────────────────────────────
28
- const DEFAULT_CONFIG: GatewayConfig = {
29
- agent: {
30
- type: "pi",
31
- cwd: process.cwd(),
32
- },
33
- chat: {
34
- botUsername: process.env.BOT_USERNAME ?? "roundhouse_bot",
35
- allowedUsers: process.env.ALLOWED_USERS
36
- ? process.env.ALLOWED_USERS.split(",").map((u) => u.trim())
37
- : [],
38
- adapters: {
39
- telegram: { mode: "polling" },
40
- },
41
- },
42
- };
43
-
44
- async function loadConfig(): Promise<GatewayConfig> {
45
- // Check for ROUNDHOUSE_CONFIG env var (set by CLI/daemon)
46
- const envConfig = process.env.ROUNDHOUSE_CONFIG;
47
- if (envConfig) {
48
- try {
49
- const raw = await readFile(resolve(envConfig), "utf8");
50
- console.log(`[roundhouse] loaded config from ${envConfig}`);
51
- return JSON.parse(raw) as GatewayConfig;
52
- } catch {
53
- // Fall through to other methods
54
- }
55
- }
56
-
57
- // Check for --config flag
58
- const configIdx = process.argv.indexOf("--config");
59
- if (configIdx !== -1 && process.argv[configIdx + 1]) {
60
- const configPath = resolve(process.argv[configIdx + 1]);
61
- console.log(`[roundhouse] loading config from ${configPath}`);
62
- const raw = await readFile(configPath, "utf8");
63
- return JSON.parse(raw) as GatewayConfig;
64
- }
65
-
66
- // Try gateway.config.json in cwd
67
- try {
68
- const raw = await readFile(
69
- resolve(process.cwd(), "gateway.config.json"),
70
- "utf8"
71
- );
72
- console.log("[roundhouse] loaded gateway.config.json");
73
- return JSON.parse(raw) as GatewayConfig;
74
- } catch {
75
- // Fall back to defaults + env vars
76
- console.log("[roundhouse] using default config + env vars");
77
- return DEFAULT_CONFIG;
78
- }
79
- }
80
-
81
24
  async function main() {
82
25
  const config = await loadConfig();
83
26
 
@@ -0,0 +1,98 @@
1
+ /**
2
+ * memory/bootstrap.ts — Create default memory file templates
3
+ *
4
+ * Creates MEMORY.md, memory-rules.md, and daily/ directory if they don't exist.
5
+ * Mode-aware: writes different memory-rules.md for Full vs Complement mode.
6
+ */
7
+
8
+ import { writeFile, mkdir } from "node:fs/promises";
9
+ import { resolve } from "node:path";
10
+ import { fileExists } from "../config";
11
+ import type { MemoryConfig, MemoryMode } from "./types";
12
+
13
+ const DEFAULT_MEMORY_MD = `# Memory
14
+
15
+ Durable facts, preferences, decisions, and stable project context.
16
+ Keep under 100 lines. Prefer editing existing entries over appending duplicates.
17
+ `;
18
+
19
+ const RULES_FULL = `# Memory Rules
20
+
21
+ You have no built-in memory extension. Roundhouse manages your memory via workspace files.
22
+
23
+ ## Always-injected files
24
+ - ~/MEMORY.md — durable facts, preferences, decisions, project context
25
+ - Today's daily front page — headlines + leads + article links
26
+ - This file (memory-rules.md)
27
+
28
+ ## MEMORY.md
29
+ - Keep under 100 lines
30
+ - Store user preferences, project conventions, architecture decisions
31
+ - Edit existing entries rather than appending duplicates
32
+ - When the user corrects you or states a preference, ALWAYS write it here
33
+
34
+ ## Daily front pages
35
+ - Keep under 2K tokens
36
+ - Headlines + leads + relative links to articles
37
+ - No long logs, transcripts, or command output
38
+
39
+ ## Articles
40
+ - Full details in daily/YYYY-MM-DD/articles/
41
+ - New article per new durable topic; append for continuing work
42
+ - Agent reads articles on demand with file tools
43
+ `;
44
+
45
+ const RULES_COMPLEMENT = `# Memory Rules
46
+
47
+ You have a memory extension installed that handles facts, preferences, and corrections.
48
+ Use memory_remember for discrete facts. Use memory_search to recall them.
49
+
50
+ Roundhouse manages narrative context separately:
51
+
52
+ ## Always-injected files
53
+ - ~/MEMORY.md — high-level project context, architecture decisions, active investigations
54
+ - Today's daily front page — headlines + leads + article links
55
+ - This file (memory-rules.md)
56
+
57
+ ## MEMORY.md
58
+ - NOT for individual preferences (those go in memory_remember)
59
+ - For ongoing project/architecture context that doesn't fit key-value storage
60
+ - Keep under 100 lines
61
+
62
+ ## Daily front pages
63
+ - Keep under 2K tokens
64
+ - Headlines + leads + relative links to articles
65
+
66
+ ## Articles
67
+ - Full details in daily/YYYY-MM-DD/articles/
68
+ - Agent reads on demand with file tools
69
+ `;
70
+
71
+ /**
72
+ * Ensure memory directory structure and default files exist.
73
+ * Does not overwrite existing files.
74
+ */
75
+ export async function bootstrapMemoryFiles(rootDir: string, mode: MemoryMode, config?: MemoryConfig): Promise<void> {
76
+ const mainFile = config?.mainFile ?? "MEMORY.md";
77
+ const dailyDir = config?.dailyDir ?? "daily";
78
+
79
+ // Ensure directories
80
+ await mkdir(resolve(rootDir, dailyDir), { recursive: true });
81
+
82
+ // Create MEMORY.md if missing
83
+ const memoryPath = resolve(rootDir, mainFile);
84
+ if (!await fileExists(memoryPath)) {
85
+ await writeFile(memoryPath, DEFAULT_MEMORY_MD);
86
+ console.log(`[memory] created ${memoryPath}`);
87
+ }
88
+
89
+ // Create/update memory-rules.md based on mode
90
+ const rulesPath = resolve(rootDir, "memory-rules.md");
91
+ const rulesContent = mode === "complement" ? RULES_COMPLEMENT : RULES_FULL;
92
+
93
+ // Only write if doesn't exist or mode changed (check first line)
94
+ if (!await fileExists(rulesPath)) {
95
+ await writeFile(rulesPath, rulesContent);
96
+ console.log(`[memory] created ${rulesPath} (mode: ${mode})`);
97
+ }
98
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * memory/files.ts — Read memory files and compute digests
3
+ */
4
+
5
+ import { readFile } from "node:fs/promises";
6
+ import { createHash } from "node:crypto";
7
+ import { resolve } from "node:path";
8
+ import type { MemoryConfig, MemoryFileSet, MemorySnapshot } from "./types";
9
+
10
+ const DEFAULTS = {
11
+ mainFile: "MEMORY.md",
12
+ dailyDir: "daily",
13
+ rulesFile: "memory-rules.md",
14
+ };
15
+
16
+ /** Format a date as YYYY-MM-DD */
17
+ export function formatDate(d: Date): string {
18
+ return d.toISOString().slice(0, 10);
19
+ }
20
+
21
+ /** Get today and recent dates */
22
+ function getRecentDates(recentDays: number): string[] {
23
+ const dates: string[] = [];
24
+ const now = new Date();
25
+ dates.push(formatDate(now));
26
+ for (let i = 1; i <= recentDays; i++) {
27
+ const d = new Date(now);
28
+ d.setDate(d.getDate() - i);
29
+ dates.push(formatDate(d));
30
+ }
31
+ return dates;
32
+ }
33
+
34
+ /** Resolve which memory files to load based on config */
35
+ export function resolveMemoryFiles(rootDir: string, config?: MemoryConfig): MemoryFileSet {
36
+ const mainFile = config?.mainFile ?? DEFAULTS.mainFile;
37
+ const dailyDir = config?.dailyDir ?? DEFAULTS.dailyDir;
38
+ const includeToday = config?.inject?.includeToday ?? true;
39
+ const recentDays = config?.inject?.includeRecentDays ?? 1;
40
+
41
+ const files: MemoryFileSet["files"] = [];
42
+
43
+ // Always include MEMORY.md
44
+ files.push({ label: mainFile, path: resolve(rootDir, mainFile) });
45
+
46
+ // Always include memory-rules.md
47
+ files.push({ label: DEFAULTS.rulesFile, path: resolve(rootDir, DEFAULTS.rulesFile) });
48
+
49
+ // Daily notes
50
+ if (includeToday) {
51
+ const dates = getRecentDates(recentDays);
52
+ for (const date of dates) {
53
+ const dailyPath = resolve(rootDir, dailyDir, `${date}.md`);
54
+ files.push({ label: `${dailyDir}/${date}.md`, path: dailyPath });
55
+ }
56
+ }
57
+
58
+ return { files };
59
+ }
60
+
61
+ /** Read memory files, skip missing ones, return snapshot with digest */
62
+ export async function readMemorySnapshot(fileSet: MemoryFileSet, maxBytes?: number): Promise<MemorySnapshot> {
63
+ const entries: MemorySnapshot["entries"] = [];
64
+ let totalBytes = 0;
65
+ const limit = maxBytes ?? 48_000;
66
+
67
+ for (const file of fileSet.files) {
68
+ try {
69
+ const content = await readFile(file.path, "utf8");
70
+ if (totalBytes + content.length > limit) {
71
+ // Truncate to fit budget
72
+ const remaining = limit - totalBytes;
73
+ if (remaining > 100) {
74
+ entries.push({ label: file.label, content: content.slice(0, remaining) + "\n\n(truncated)" });
75
+ totalBytes = limit;
76
+ }
77
+ break;
78
+ }
79
+ entries.push({ label: file.label, content });
80
+ totalBytes += content.length;
81
+ } catch {
82
+ // File doesn't exist — skip silently
83
+ }
84
+ }
85
+
86
+ const digest = hashEntries(entries);
87
+ return { entries, digest };
88
+ }
89
+
90
+ /** Compute a fast hash of memory entries */
91
+ function hashEntries(entries: MemorySnapshot["entries"]): string {
92
+ const h = createHash("sha256");
93
+ for (const e of entries) {
94
+ h.update(e.label);
95
+ h.update("\0");
96
+ h.update(e.content);
97
+ h.update("\0");
98
+ }
99
+ return h.digest("hex").slice(0, 16);
100
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * memory/inject.ts — Build memory injection blocks for agent messages
3
+ */
4
+
5
+ import type { MemorySnapshot } from "./types";
6
+ import type { AgentMessage } from "../types";
7
+
8
+ /**
9
+ * Build a memory injection text block from a snapshot.
10
+ * Includes version/date so the agent knows it supersedes prior blocks.
11
+ */
12
+ export function buildMemoryInjection(snapshot: MemorySnapshot, reason: string): string {
13
+ if (snapshot.entries.length === 0) return "";
14
+
15
+ const date = new Date().toISOString().slice(0, 19) + "Z";
16
+ const sections = snapshot.entries.map(
17
+ (e) => `## ${e.label}\n${e.content}`
18
+ ).join("\n\n");
19
+
20
+ return [
21
+ `<roundhouse_memory v="${snapshot.digest}" date="${date}" reason="${reason}">`,
22
+ `This is your current workspace memory. It supersedes any prior roundhouse_memory blocks.`,
23
+ ``,
24
+ sections,
25
+ `</roundhouse_memory>`,
26
+ ].join("\n");
27
+ }
28
+
29
+ /**
30
+ * Prepend memory injection to a user message.
31
+ * Returns a new AgentMessage with memory block + original text.
32
+ */
33
+ export function injectMemoryIntoMessage(message: AgentMessage, injection: string): AgentMessage {
34
+ if (!injection) return message;
35
+
36
+ const combinedText = message.text
37
+ ? `${injection}\n\n${message.text}`
38
+ : injection;
39
+
40
+ return { ...message, text: combinedText };
41
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * memory/lifecycle.ts — Memory lifecycle for gateway turns
3
+ *
4
+ * Two modes:
5
+ * - Full: inject memory, track digest, flush before compact
6
+ * - Complement: only flush before compact (agent extension handles memory)
7
+ *
8
+ * Both modes share proactive compaction logic.
9
+ */
10
+
11
+ import type { AgentAdapter, AgentMessage } from "../types";
12
+ import type { MemoryConfig, MemoryMode, PreparedTurn, PressureLevel, ThreadMemoryState } from "./types";
13
+ import { resolveMemoryFiles, readMemorySnapshot, formatDate } from "./files";
14
+ import { loadThreadMemoryState, saveThreadMemoryState } from "./state";
15
+ import { shouldInjectMemory, classifyContextPressure, isSoftFlushOnCooldown } from "./policy";
16
+ import { buildMemoryInjection, injectMemoryIntoMessage } from "./inject";
17
+ import { buildFlushPrompt } from "./prompts";
18
+ import { bootstrapMemoryFiles } from "./bootstrap";
19
+
20
+ // ── Memory mode detection ────────────────────────────
21
+
22
+ /**
23
+ * Determine memory mode from agent info.
24
+ * Returns "unknown" if agent info isn't available yet (before first session).
25
+ */
26
+ export function determineMemoryMode(agentInfo: Record<string, unknown>): MemoryMode {
27
+ const has = agentInfo.hasMemoryExtension;
28
+ if (has === true) return "complement";
29
+ if (has === false) return "full";
30
+ return "unknown";
31
+ }
32
+
33
+ // ── Pre-turn: prepare memory ─────────────────────────
34
+
35
+ /**
36
+ * Prepare memory for a turn. Called before sending prompt to agent.
37
+ *
38
+ * In Full mode: may inject memory into the message.
39
+ * In Complement mode: passes message through unchanged.
40
+ * In Unknown mode: defaults to Full behavior.
41
+ */
42
+ export async function prepareMemoryForTurn(
43
+ threadId: string,
44
+ message: AgentMessage,
45
+ agent: AgentAdapter,
46
+ rootDir: string,
47
+ config?: MemoryConfig,
48
+ ): Promise<PreparedTurn> {
49
+ if (config?.enabled === false) {
50
+ return { message, beforeDigest: null, injected: false };
51
+ }
52
+
53
+ const mode = getMode(agent);
54
+
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
+ if (mode === "complement" || mode === "unknown") {
59
+ return { message, beforeDigest: null, injected: false };
60
+ }
61
+
62
+ // Full mode: inject if needed
63
+ try {
64
+ // Bootstrap memory files on first use
65
+ await bootstrapMemoryFiles(rootDir, "full", config);
66
+
67
+ const fileSet = resolveMemoryFiles(rootDir, config);
68
+ const snapshot = await readMemorySnapshot(fileSet, config?.inject?.maxBytes);
69
+ const state = await loadThreadMemoryState(threadId);
70
+
71
+ // Check pending compact from interrupted flush — surface to gateway
72
+ let pendingCompactLevel: PreparedTurn["pendingCompact"];
73
+ if (state.pendingCompact) {
74
+ pendingCompactLevel = state.pendingCompact;
75
+ state.pendingCompact = undefined;
76
+ await saveThreadMemoryState(threadId, state);
77
+ console.log(`[memory] pending compact (${pendingCompactLevel}) cleared for ${threadId} — gateway will retry`);
78
+ }
79
+
80
+ const decision = shouldInjectMemory(state, snapshot.digest);
81
+
82
+ if (decision.inject) {
83
+ const injection = buildMemoryInjection(snapshot, decision.reason);
84
+ const injectedMessage = injectMemoryIntoMessage(message, injection);
85
+
86
+ // Update state
87
+ state.lastInjectedDigest = snapshot.digest;
88
+ state.lastInjectedAt = new Date().toISOString();
89
+ state.lastSeenLocalDate = formatDate(new Date());
90
+ state.forceInjectReason = undefined;
91
+ await saveThreadMemoryState(threadId, state);
92
+
93
+ 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
+ }
96
+
97
+ return { message, beforeDigest: snapshot.digest, injected: false, pendingCompact: pendingCompactLevel };
98
+ } catch (err) {
99
+ console.error(`[memory] prepareMemoryForTurn error:`, (err as Error).message);
100
+ return { message, beforeDigest: null, injected: false };
101
+ }
102
+ }
103
+
104
+ // ── Post-turn: finalize and check pressure ───────────
105
+
106
+ /**
107
+ * Finalize memory after a turn. Called after agent response.
108
+ *
109
+ * In Full mode: check if agent wrote memory files (update digest).
110
+ * Both modes: check context pressure for proactive compaction.
111
+ *
112
+ * Returns the pressure level for the gateway to act on.
113
+ */
114
+ export async function finalizeMemoryForTurn(
115
+ threadId: string,
116
+ beforeDigest: string | null,
117
+ agent: AgentAdapter,
118
+ rootDir: string,
119
+ config?: MemoryConfig,
120
+ ): Promise<PressureLevel> {
121
+ if (config?.enabled === false) return "none";
122
+
123
+ const mode = getMode(agent);
124
+
125
+ // In Full mode: check if agent modified memory files
126
+ 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})`);
136
+ }
137
+ } catch (err) {
138
+ console.error(`[memory] finalizeMemoryForTurn digest check error:`, (err as Error).message);
139
+ }
140
+ }
141
+
142
+ // Check context pressure (both modes)
143
+ if (config?.compact?.enabled === false) return "none";
144
+
145
+ try {
146
+ const info = agent.getInfo?.(threadId) ?? {};
147
+ const pressure = classifyContextPressure(
148
+ {
149
+ contextTokens: typeof info.contextTokens === "number" ? info.contextTokens : null,
150
+ contextWindow: typeof info.contextWindow === "number" ? info.contextWindow : null,
151
+ contextPercent: typeof info.contextPercent === "number" ? info.contextPercent : null,
152
+ },
153
+ config?.compact,
154
+ );
155
+ return pressure;
156
+ } catch {
157
+ return "none";
158
+ }
159
+ }
160
+
161
+ // ── Flush + compact (atomic operation) ───────────────
162
+
163
+ /**
164
+ * Flush memory then compact. Used for proactive compaction and /compact command.
165
+ *
166
+ * 1. Send maintenance prompt (agent saves important context)
167
+ * 2. Compact the session
168
+ * 3. Mark force re-inject for Full mode
169
+ *
170
+ * Returns compaction result or null if nothing to compact.
171
+ */
172
+ export async function flushMemoryThenCompact(
173
+ threadId: string,
174
+ agent: AgentAdapter,
175
+ rootDir: string,
176
+ level: "soft" | "hard" | "emergency" | "manual",
177
+ config?: MemoryConfig,
178
+ ): Promise<{ tokensBefore: number; tokensAfter: number | null } | null> {
179
+ const mode = getMode(agent);
180
+
181
+ // Soft flush: just prompt to save, don't compact
182
+ if (level === "soft") {
183
+ const state = await loadThreadMemoryState(threadId);
184
+ if (isSoftFlushOnCooldown(state, config?.compact)) {
185
+ console.log(`[memory] soft flush skipped for ${threadId} — cooldown`);
186
+ return null;
187
+ }
188
+
189
+ try {
190
+ const flushText = buildFlushPrompt(mode === "unknown" ? "full" : mode, "soft");
191
+ await agent.prompt(threadId, { text: flushText });
192
+ state.lastSoftFlushAt = new Date().toISOString();
193
+ await saveThreadMemoryState(threadId, state);
194
+ console.log(`[memory] soft flush completed for ${threadId}`);
195
+ } catch (err) {
196
+ console.error(`[memory] soft flush failed for ${threadId}:`, (err as Error).message);
197
+ }
198
+ return null;
199
+ }
200
+
201
+ // Hard/emergency/manual: flush then compact
202
+ if (!agent.compact) return null;
203
+
204
+ const effectiveLevel = level === "manual" ? "hard" : level;
205
+
206
+ try {
207
+ // Step 1: flush
208
+ 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);
215
+ if (!result) return null;
216
+
217
+ // Step 3: mark force re-inject (Full mode only)
218
+ if (mode !== "complement") {
219
+ const state = await loadThreadMemoryState(threadId);
220
+ state.forceInjectReason = "after-compact";
221
+ state.lastCompactAt = new Date().toISOString();
222
+ state.pendingCompact = undefined;
223
+ await saveThreadMemoryState(threadId, state);
224
+ }
225
+
226
+ console.log(`[memory] flush+compact done for ${threadId}: ${result.tokensBefore} → ${result.tokensAfter ?? "?"} tokens`);
227
+ return result;
228
+ } catch (err) {
229
+ console.error(`[memory] flush+compact failed for ${threadId}:`, (err as Error).message);
230
+ // Mark pending so we retry on next turn
231
+ try {
232
+ const state = await loadThreadMemoryState(threadId);
233
+ state.pendingCompact = effectiveLevel;
234
+ await saveThreadMemoryState(threadId, state);
235
+ } catch {}
236
+ return null;
237
+ }
238
+ }
239
+
240
+ // ── Helper ───────────────────────────────────────────
241
+
242
+ function getMode(agent: AgentAdapter): MemoryMode {
243
+ const info = agent.getInfo?.() ?? {};
244
+ return determineMemoryMode(info);
245
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * memory/policy.ts — Memory injection and compaction policy decisions
3
+ *
4
+ * Pure functions — no side effects, easy to test.
5
+ */
6
+
7
+ import type { MemoryConfig, ThreadMemoryState, PressureLevel } from "./types";
8
+ import { formatDate } from "./files";
9
+
10
+ // ── Defaults ─────────────────────────────────────────
11
+
12
+ const DEFAULT_SOFT_PERCENT = 0.45;
13
+ const DEFAULT_SOFT_TOKENS = 180_000;
14
+ const DEFAULT_HARD_PERCENT = 0.50;
15
+ const DEFAULT_HARD_TOKENS = 200_000;
16
+ const DEFAULT_EMERGENCY_THRESHOLD = 32_768;
17
+ const DEFAULT_COOLDOWN_MS = 10 * 60_000; // 10 minutes
18
+
19
+ // ── Injection policy ─────────────────────────────────
20
+
21
+ export interface InjectionDecision {
22
+ inject: boolean;
23
+ reason: string;
24
+ }
25
+
26
+ /**
27
+ * Decide whether to inject memory into this turn.
28
+ * Called ONLY in Full mode (when agent has no memory extension).
29
+ */
30
+ export function shouldInjectMemory(
31
+ state: ThreadMemoryState,
32
+ currentDigest: string,
33
+ now: Date = new Date(),
34
+ ): InjectionDecision {
35
+ // Force flag (after compact, new session, manual)
36
+ if (state.forceInjectReason) {
37
+ return { inject: true, reason: state.forceInjectReason };
38
+ }
39
+
40
+ // No previous injection — first time for this thread
41
+ if (!state.lastInjectedDigest) {
42
+ return { inject: true, reason: "first-injection" };
43
+ }
44
+
45
+ // Memory files changed (cron wrote, another thread wrote, user edited)
46
+ if (currentDigest !== state.lastInjectedDigest) {
47
+ return { inject: true, reason: "changed" };
48
+ }
49
+
50
+ // Date boundary — new daily note
51
+ const today = formatDate(now);
52
+ if (state.lastSeenLocalDate && state.lastSeenLocalDate !== today) {
53
+ return { inject: true, reason: "date-boundary" };
54
+ }
55
+
56
+ return { inject: false, reason: "unchanged" };
57
+ }
58
+
59
+ // ── Context pressure ─────────────────────────────────
60
+
61
+ export interface ContextInfo {
62
+ contextTokens: number | null;
63
+ contextWindow: number | null;
64
+ contextPercent: number | null;
65
+ }
66
+
67
+ /**
68
+ * Classify context pressure level.
69
+ * Used in BOTH modes (complement and full) for proactive compaction.
70
+ */
71
+ export function classifyContextPressure(
72
+ info: ContextInfo,
73
+ config?: MemoryConfig["compact"],
74
+ ): PressureLevel {
75
+ const tokens = info.contextTokens;
76
+ const window = info.contextWindow;
77
+ const percent = info.contextPercent;
78
+
79
+ // Can't classify without data
80
+ if (tokens == null || window == null) return "none";
81
+
82
+ const remaining = window - tokens;
83
+ const emergencyThreshold = config?.emergencyThresholdTokens ?? DEFAULT_EMERGENCY_THRESHOLD;
84
+
85
+ // Emergency: running out of room
86
+ if (remaining <= emergencyThreshold) return "emergency";
87
+
88
+ const pctDecimal = percent != null ? percent / 100 : tokens / window;
89
+
90
+ // Hard threshold
91
+ const hardPct = config?.hardPercent ?? DEFAULT_HARD_PERCENT;
92
+ const hardTok = config?.hardTokens ?? DEFAULT_HARD_TOKENS;
93
+ if (pctDecimal >= hardPct || tokens >= hardTok) return "hard";
94
+
95
+ // Soft threshold
96
+ const softPct = config?.softPercent ?? DEFAULT_SOFT_PERCENT;
97
+ const softTok = config?.softTokens ?? DEFAULT_SOFT_TOKENS;
98
+ if (pctDecimal >= softPct || tokens >= softTok) return "soft";
99
+
100
+ return "none";
101
+ }
102
+
103
+ /**
104
+ * Check whether a soft flush should be skipped due to cooldown.
105
+ */
106
+ export function isSoftFlushOnCooldown(state: ThreadMemoryState, config?: MemoryConfig["compact"]): boolean {
107
+ if (!state.lastSoftFlushAt) return false;
108
+ const cooldownMs = config?.cooldownMs ?? DEFAULT_COOLDOWN_MS;
109
+ const elapsed = Date.now() - new Date(state.lastSoftFlushAt).getTime();
110
+ return elapsed < cooldownMs;
111
+ }
112
+
113
+ // ── Pressure comparison ───────────────────────────────────
114
+
115
+ const PRESSURE_SEVERITY: Record<PressureLevel, number> = { none: 0, soft: 1, hard: 2, emergency: 3 };
116
+
117
+ /** Return the higher-severity pressure level. */
118
+ export function maxPressure(a: PressureLevel | undefined, b: PressureLevel): PressureLevel {
119
+ const sa = PRESSURE_SEVERITY[a ?? "none"] ?? 0;
120
+ const sb = PRESSURE_SEVERITY[b] ?? 0;
121
+ return sa > sb ? (a ?? "none") : b;
122
+ }