@inceptionstack/roundhouse 0.2.2 → 0.3.0
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/README.md +321 -9
- package/architecture.md +77 -8
- package/package.json +3 -1
- package/src/agents/pi.ts +433 -26
- package/src/agents/registry.ts +8 -0
- package/src/cli/cli.ts +384 -189
- package/src/cli/cron.ts +296 -0
- package/src/cli/doctor/checks/agent.ts +68 -0
- package/src/cli/doctor/checks/config.ts +88 -0
- package/src/cli/doctor/checks/credentials.ts +62 -0
- package/src/cli/doctor/checks/disk.ts +69 -0
- package/src/cli/doctor/checks/stt.ts +76 -0
- package/src/cli/doctor/checks/system.ts +86 -0
- package/src/cli/doctor/checks/systemd.ts +76 -0
- package/src/cli/doctor/output.ts +58 -0
- package/src/cli/doctor/runner.ts +142 -0
- package/src/cli/doctor/shell.ts +33 -0
- package/src/cli/doctor/types.ts +44 -0
- package/src/cli/doctor.ts +48 -0
- package/src/cli/setup-telegram.ts +148 -0
- package/src/cli/setup.ts +936 -0
- package/src/commands.ts +23 -0
- package/src/config.ts +188 -0
- package/src/cron/constants.ts +54 -0
- package/src/cron/durations.ts +33 -0
- package/src/cron/format.ts +139 -0
- package/src/cron/helpers.ts +30 -0
- package/src/cron/runner.ts +148 -0
- package/src/cron/schedule.ts +101 -0
- package/src/cron/scheduler.ts +295 -0
- package/src/cron/store.ts +125 -0
- package/src/cron/template.ts +89 -0
- package/src/cron/types.ts +76 -0
- package/src/gateway.ts +927 -18
- package/src/index.ts +1 -58
- package/src/memory/bootstrap.ts +98 -0
- package/src/memory/files.ts +100 -0
- package/src/memory/inject.ts +41 -0
- package/src/memory/lifecycle.ts +245 -0
- package/src/memory/policy.ts +122 -0
- package/src/memory/prompts.ts +42 -0
- package/src/memory/state.ts +43 -0
- package/src/memory/types.ts +90 -0
- package/src/notify/telegram.ts +48 -0
- package/src/types.ts +68 -1
- package/src/util.ts +28 -2
- package/src/voice/providers/whisper.ts +339 -0
- package/src/voice/stt-service.ts +284 -0
- 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
|
+
}
|