alvin-bot 4.9.4 → 4.11.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/CHANGELOG.md +154 -0
- package/dist/handlers/async-agent-chunk-handler.js +33 -0
- package/dist/handlers/commands.js +6 -1
- package/dist/handlers/message.js +44 -11
- package/dist/handlers/platform-message.js +4 -4
- package/dist/index.js +20 -1
- package/dist/paths.js +18 -1
- package/dist/providers/claude-sdk-provider.js +43 -0
- package/dist/services/async-agent-parser.js +152 -0
- package/dist/services/async-agent-watcher.js +206 -0
- package/dist/services/compaction.js +13 -0
- package/dist/services/memory-extractor.js +178 -0
- package/dist/services/memory-layers.js +147 -0
- package/dist/services/memory.js +15 -8
- package/dist/services/personality.js +100 -18
- package/dist/services/session-persistence.js +159 -0
- package/dist/services/session.js +30 -0
- package/package.json +2 -2
- package/test/async-agent-chunk-flow.test.ts +131 -0
- package/test/async-agent-parser.test.ts +322 -0
- package/test/async-agent-watcher.test.ts +229 -0
- package/test/memory-extractor.test.ts +151 -0
- package/test/memory-layers.test.ts +169 -0
- package/test/memory-sdk-injection.test.ts +146 -0
- package/test/memory-stress-restart.test.ts +336 -0
- package/test/session-persistence.test.ts +192 -0
- package/test/system-prompt-background-hint.test.ts +48 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the async-agent watcher (Fix #17 Stage 2).
|
|
3
|
+
*
|
|
4
|
+
* Two responsibilities, both pure (the file read in parseOutputFileStatus
|
|
5
|
+
* is pure-by-input — same path returns the same shape at that moment in
|
|
6
|
+
* time, no mutation, no side effects):
|
|
7
|
+
*
|
|
8
|
+
* 1. Parse the SDK's plain-text "Async agent launched successfully" tool
|
|
9
|
+
* result into a structured AsyncLaunchedInfo.
|
|
10
|
+
* 2. Read the tail of an outputFile JSONL stream and decide whether the
|
|
11
|
+
* sub-agent is still running, completed, or failed.
|
|
12
|
+
*
|
|
13
|
+
* Format details captured live from @anthropic-ai/claude-agent-sdk@0.2.97
|
|
14
|
+
* on 2026-04-13. See docs/superpowers/specs/sdk-async-agent-outputfile-format.md
|
|
15
|
+
* for the full investigation notes — the SDK's .d.ts shape DOES NOT match
|
|
16
|
+
* what the runtime actually emits, which is why the contract is pinned by
|
|
17
|
+
* tests against real fixtures.
|
|
18
|
+
*/
|
|
19
|
+
import { promises as fs } from "fs";
|
|
20
|
+
// ── Tool-result text parser ──────────────────────────────────────────
|
|
21
|
+
/**
|
|
22
|
+
* Parse the plain-text SDK tool-result content for an `Agent` call with
|
|
23
|
+
* `run_in_background: true`. The format is documented in the spec doc
|
|
24
|
+
* — it's NOT JSON, and the field is `output_file` (snake_case).
|
|
25
|
+
*
|
|
26
|
+
* Accepts:
|
|
27
|
+
* - the raw text string
|
|
28
|
+
* - an Anthropic SDK content array `[{type: "text", text: "..."}]`
|
|
29
|
+
* - null/undefined/non-string → returns null
|
|
30
|
+
*/
|
|
31
|
+
export function parseAsyncLaunchedToolResult(raw) {
|
|
32
|
+
// Normalize to a string
|
|
33
|
+
let text;
|
|
34
|
+
if (raw == null)
|
|
35
|
+
return null;
|
|
36
|
+
if (typeof raw === "string") {
|
|
37
|
+
text = raw;
|
|
38
|
+
}
|
|
39
|
+
else if (Array.isArray(raw)) {
|
|
40
|
+
// SDK content blocks shape
|
|
41
|
+
text = raw
|
|
42
|
+
.map((b) => (b && typeof b === "object" && "text" in b ? String(b.text) : ""))
|
|
43
|
+
.join("");
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
if (!text || text.length === 0)
|
|
49
|
+
return null;
|
|
50
|
+
// Quick gate: avoid expensive matching on non-async tool results
|
|
51
|
+
if (!text.includes("Async agent launched successfully"))
|
|
52
|
+
return null;
|
|
53
|
+
// agentId line: "agentId: <id> (...)" — capture everything up to first space/paren
|
|
54
|
+
const agentMatch = text.match(/agentId:\s*(\S+)/);
|
|
55
|
+
if (!agentMatch)
|
|
56
|
+
return null;
|
|
57
|
+
const agentId = agentMatch[1].trim();
|
|
58
|
+
if (!agentId)
|
|
59
|
+
return null;
|
|
60
|
+
// output_file line: "output_file: <path>" — path may contain spaces, capture
|
|
61
|
+
// until end of line (the path is always on its own line in real output).
|
|
62
|
+
const outFileMatch = text.match(/output_file:\s*(.+?)\s*(?:\n|$)/);
|
|
63
|
+
if (!outFileMatch)
|
|
64
|
+
return null;
|
|
65
|
+
const outputFile = outFileMatch[1].trim();
|
|
66
|
+
if (!outputFile)
|
|
67
|
+
return null;
|
|
68
|
+
return { agentId, outputFile };
|
|
69
|
+
}
|
|
70
|
+
const DEFAULT_TAIL_BYTES = 64 * 1024;
|
|
71
|
+
/**
|
|
72
|
+
* Read the tail of an SDK background-agent outputFile and decide what
|
|
73
|
+
* state the sub-agent is in. See spec doc for the JSONL format. We only
|
|
74
|
+
* read the last `maxTailBytes` of the file because long-running agents
|
|
75
|
+
* (SEO audits etc.) can produce hundreds of KB of intermediate JSONL.
|
|
76
|
+
*/
|
|
77
|
+
export async function parseOutputFileStatus(path, opts = {}) {
|
|
78
|
+
const maxTailBytes = opts.maxTailBytes ?? DEFAULT_TAIL_BYTES;
|
|
79
|
+
let stat;
|
|
80
|
+
try {
|
|
81
|
+
stat = await fs.stat(path);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return { state: "missing" };
|
|
85
|
+
}
|
|
86
|
+
if (stat.size === 0) {
|
|
87
|
+
// Empty file is functionally the same as missing — we keep polling.
|
|
88
|
+
return { state: "missing" };
|
|
89
|
+
}
|
|
90
|
+
// Tail-read the last maxTailBytes
|
|
91
|
+
let buf;
|
|
92
|
+
let fh;
|
|
93
|
+
try {
|
|
94
|
+
fh = await fs.open(path, "r");
|
|
95
|
+
const readSize = Math.min(stat.size, maxTailBytes);
|
|
96
|
+
buf = Buffer.alloc(readSize);
|
|
97
|
+
await fh.read(buf, 0, readSize, stat.size - readSize);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return { state: "missing" };
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
try {
|
|
104
|
+
await fh?.close();
|
|
105
|
+
}
|
|
106
|
+
catch { /* ignore */ }
|
|
107
|
+
}
|
|
108
|
+
const text = buf.toString("utf-8");
|
|
109
|
+
// Split into lines. If we tail-read into the middle of a line (size >
|
|
110
|
+
// maxTailBytes), drop the first line because it's almost certainly
|
|
111
|
+
// truncated. The trailing line is dropped if there's no newline — it's
|
|
112
|
+
// the line being written right now.
|
|
113
|
+
const lines = text.split("\n");
|
|
114
|
+
const tailIsMidLine = stat.size > maxTailBytes;
|
|
115
|
+
const headIncomplete = tailIsMidLine ? 1 : 0;
|
|
116
|
+
const trailIncomplete = text.endsWith("\n") ? 0 : 1;
|
|
117
|
+
const usable = lines
|
|
118
|
+
.slice(headIncomplete, lines.length - (trailIncomplete > 0 ? trailIncomplete : 0))
|
|
119
|
+
.filter((l) => l.length > 0);
|
|
120
|
+
// Walk backwards to find the most-recent assistant message with end_turn
|
|
121
|
+
for (let i = usable.length - 1; i >= 0; i--) {
|
|
122
|
+
let parsed;
|
|
123
|
+
try {
|
|
124
|
+
parsed = JSON.parse(usable[i]);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Garbage line — skip
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (parsed.type === "assistant" &&
|
|
131
|
+
parsed.message?.stop_reason === "end_turn" &&
|
|
132
|
+
Array.isArray(parsed.message.content)) {
|
|
133
|
+
const finalText = parsed.message.content
|
|
134
|
+
.filter((c) => c?.type === "text" && typeof c.text === "string")
|
|
135
|
+
.map((c) => c.text)
|
|
136
|
+
.join("\n\n");
|
|
137
|
+
const usage = parsed.message.usage;
|
|
138
|
+
return {
|
|
139
|
+
state: "completed",
|
|
140
|
+
output: finalText,
|
|
141
|
+
tokensUsed: usage
|
|
142
|
+
? {
|
|
143
|
+
input: usage.input_tokens ?? 0,
|
|
144
|
+
output: usage.output_tokens ?? 0,
|
|
145
|
+
}
|
|
146
|
+
: undefined,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// No completion marker found — still running.
|
|
151
|
+
return { state: "running", size: stat.size };
|
|
152
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async Sub-Agent Watcher (Fix #17 Stage 2)
|
|
3
|
+
*
|
|
4
|
+
* Tracks pending background sub-agents that Claude launched with
|
|
5
|
+
* `run_in_background: true`. Polls each agent's outputFile every
|
|
6
|
+
* POLL_INTERVAL_MS, detects completion (success/failure/timeout),
|
|
7
|
+
* and delivers the final result as a separate Telegram message via
|
|
8
|
+
* the existing subagent-delivery.ts pipeline.
|
|
9
|
+
*
|
|
10
|
+
* Persistence: pending agents survive bot restarts via
|
|
11
|
+
* ~/.alvin-bot/state/async-agents.json. On boot, startWatcher() loads
|
|
12
|
+
* the file and resumes polling — same catchup pattern as the v4.9.0
|
|
13
|
+
* cron scheduler.
|
|
14
|
+
*
|
|
15
|
+
* Why this exists: Claude's Agent tool defaults to synchronous, which
|
|
16
|
+
* blocks the main Telegram session for 10+ minutes during long audits.
|
|
17
|
+
* Stage 1 of the fix tells Claude to use run_in_background; Stage 2
|
|
18
|
+
* (this file) catches the resulting outputFile and delivers the result
|
|
19
|
+
* when ready, so the user can keep chatting while the agent works.
|
|
20
|
+
*
|
|
21
|
+
* See docs/superpowers/plans/2026-04-13-async-subagents.md for the
|
|
22
|
+
* full plan and docs/superpowers/specs/sdk-async-agent-outputfile-format.md
|
|
23
|
+
* for the JSONL format details.
|
|
24
|
+
*/
|
|
25
|
+
import fs from "fs";
|
|
26
|
+
import { dirname } from "path";
|
|
27
|
+
import { parseOutputFileStatus } from "./async-agent-parser.js";
|
|
28
|
+
import { ASYNC_AGENTS_STATE_FILE } from "../paths.js";
|
|
29
|
+
/** How often the polling loop runs against each pending agent. */
|
|
30
|
+
const POLL_INTERVAL_MS = 15_000;
|
|
31
|
+
/** Hard ceiling per agent — 12h. After this, give up and deliver
|
|
32
|
+
* a timeout banner. SEO audits historically take ~13 min, so 12h
|
|
33
|
+
* is absurdly generous and protects against state-file growth. */
|
|
34
|
+
const MAX_AGENT_AGE_MS = 12 * 60 * 60 * 1000;
|
|
35
|
+
// ── Module state ──────────────────────────────────────────────────
|
|
36
|
+
const pending = new Map();
|
|
37
|
+
let pollTimer = null;
|
|
38
|
+
let started = false;
|
|
39
|
+
// ── Persistence ───────────────────────────────────────────────────
|
|
40
|
+
function loadFromDisk() {
|
|
41
|
+
try {
|
|
42
|
+
const raw = fs.readFileSync(ASYNC_AGENTS_STATE_FILE, "utf-8");
|
|
43
|
+
const arr = JSON.parse(raw);
|
|
44
|
+
if (!Array.isArray(arr))
|
|
45
|
+
return;
|
|
46
|
+
for (const entry of arr) {
|
|
47
|
+
if (typeof entry?.agentId === "string" && typeof entry?.outputFile === "string") {
|
|
48
|
+
pending.set(entry.agentId, entry);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// No state file yet — fresh start. Not an error.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function saveToDisk() {
|
|
57
|
+
try {
|
|
58
|
+
fs.mkdirSync(dirname(ASYNC_AGENTS_STATE_FILE), { recursive: true });
|
|
59
|
+
fs.writeFileSync(ASYNC_AGENTS_STATE_FILE, JSON.stringify([...pending.values()], null, 2), "utf-8");
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
console.error("[async-watcher] failed to persist state:", err);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// ── Public API ────────────────────────────────────────────────────
|
|
66
|
+
/**
|
|
67
|
+
* Register a new async agent that Claude just launched. Persists
|
|
68
|
+
* immediately so a crash right after registration still delivers
|
|
69
|
+
* the result on the next boot.
|
|
70
|
+
*/
|
|
71
|
+
export function registerPendingAgent(input) {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const entry = {
|
|
74
|
+
agentId: input.agentId,
|
|
75
|
+
outputFile: input.outputFile,
|
|
76
|
+
description: input.description,
|
|
77
|
+
prompt: input.prompt,
|
|
78
|
+
chatId: input.chatId,
|
|
79
|
+
userId: input.userId,
|
|
80
|
+
startedAt: now,
|
|
81
|
+
lastCheckedAt: 0,
|
|
82
|
+
giveUpAt: input.giveUpAt ?? now + MAX_AGENT_AGE_MS,
|
|
83
|
+
toolUseId: input.toolUseId,
|
|
84
|
+
};
|
|
85
|
+
pending.set(input.agentId, entry);
|
|
86
|
+
saveToDisk();
|
|
87
|
+
}
|
|
88
|
+
/** Returns a snapshot of in-memory pending agents (for /subagents + diagnostics). */
|
|
89
|
+
export function listPendingAgents() {
|
|
90
|
+
return [...pending.values()];
|
|
91
|
+
}
|
|
92
|
+
/** Start the polling loop. Idempotent. Loads any persisted state from disk. */
|
|
93
|
+
export function startWatcher() {
|
|
94
|
+
if (started)
|
|
95
|
+
return;
|
|
96
|
+
started = true;
|
|
97
|
+
loadFromDisk();
|
|
98
|
+
pollTimer = setInterval(() => {
|
|
99
|
+
pollOnce().catch((err) => console.error("[async-watcher] poll cycle failed:", err));
|
|
100
|
+
}, POLL_INTERVAL_MS);
|
|
101
|
+
console.log(`⏳ Async-agent watcher started (${pending.size} pending, ${POLL_INTERVAL_MS / 1000}s interval)`);
|
|
102
|
+
}
|
|
103
|
+
/** Stop the polling loop. Idempotent. */
|
|
104
|
+
export function stopWatcher() {
|
|
105
|
+
if (pollTimer)
|
|
106
|
+
clearInterval(pollTimer);
|
|
107
|
+
pollTimer = null;
|
|
108
|
+
started = false;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Run one poll cycle: check every pending agent, deliver the completed
|
|
112
|
+
* ones, drop them from the in-memory + on-disk state. Exported for
|
|
113
|
+
* tests; production uses the setInterval from startWatcher().
|
|
114
|
+
*/
|
|
115
|
+
export async function pollOnce() {
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
const toRemove = [];
|
|
118
|
+
for (const entry of pending.values()) {
|
|
119
|
+
entry.lastCheckedAt = now;
|
|
120
|
+
// Timeout check first — if the agent is past its giveUpAt, give up
|
|
121
|
+
// regardless of whether the file shows progress.
|
|
122
|
+
if (now >= entry.giveUpAt) {
|
|
123
|
+
await deliverAsFailure(entry, "timeout", "Agent ran longer than 12h — giving up");
|
|
124
|
+
toRemove.push(entry.agentId);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const status = await parseOutputFileStatus(entry.outputFile);
|
|
128
|
+
if (status.state === "completed") {
|
|
129
|
+
await deliverAsCompleted(entry, status.output, status.tokensUsed);
|
|
130
|
+
toRemove.push(entry.agentId);
|
|
131
|
+
}
|
|
132
|
+
else if (status.state === "failed") {
|
|
133
|
+
await deliverAsFailure(entry, "error", status.error);
|
|
134
|
+
toRemove.push(entry.agentId);
|
|
135
|
+
}
|
|
136
|
+
// running / missing → keep polling next cycle
|
|
137
|
+
}
|
|
138
|
+
if (toRemove.length > 0) {
|
|
139
|
+
for (const id of toRemove)
|
|
140
|
+
pending.delete(id);
|
|
141
|
+
saveToDisk();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// ── Delivery helpers ──────────────────────────────────────────────
|
|
145
|
+
async function deliverAsCompleted(entry, output, tokensUsed) {
|
|
146
|
+
const { deliverSubAgentResult } = await import("./subagent-delivery.js");
|
|
147
|
+
const info = {
|
|
148
|
+
id: entry.agentId,
|
|
149
|
+
name: entry.description,
|
|
150
|
+
status: "completed",
|
|
151
|
+
startedAt: entry.startedAt,
|
|
152
|
+
source: "cron", // Reuse cron banner format — fits async background agents.
|
|
153
|
+
depth: 0,
|
|
154
|
+
parentChatId: entry.chatId,
|
|
155
|
+
};
|
|
156
|
+
const result = {
|
|
157
|
+
id: entry.agentId,
|
|
158
|
+
name: entry.description,
|
|
159
|
+
status: "completed",
|
|
160
|
+
output,
|
|
161
|
+
tokensUsed: tokensUsed ?? { input: 0, output: 0 },
|
|
162
|
+
duration: Date.now() - entry.startedAt,
|
|
163
|
+
};
|
|
164
|
+
try {
|
|
165
|
+
await deliverSubAgentResult(info, result);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
console.error(`[async-watcher] delivery failed for ${entry.agentId}:`, err);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function deliverAsFailure(entry, status, error) {
|
|
172
|
+
const { deliverSubAgentResult } = await import("./subagent-delivery.js");
|
|
173
|
+
const info = {
|
|
174
|
+
id: entry.agentId,
|
|
175
|
+
name: entry.description,
|
|
176
|
+
status,
|
|
177
|
+
startedAt: entry.startedAt,
|
|
178
|
+
source: "cron",
|
|
179
|
+
depth: 0,
|
|
180
|
+
parentChatId: entry.chatId,
|
|
181
|
+
};
|
|
182
|
+
const result = {
|
|
183
|
+
id: entry.agentId,
|
|
184
|
+
name: entry.description,
|
|
185
|
+
status,
|
|
186
|
+
output: "",
|
|
187
|
+
tokensUsed: { input: 0, output: 0 },
|
|
188
|
+
duration: Date.now() - entry.startedAt,
|
|
189
|
+
error,
|
|
190
|
+
};
|
|
191
|
+
try {
|
|
192
|
+
await deliverSubAgentResult(info, result);
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
console.error(`[async-watcher] failure delivery failed for ${entry.agentId}:`, err);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// ── Test helpers ──────────────────────────────────────────────────
|
|
199
|
+
/** Test-only: drop in-memory state. Doesn't touch disk. */
|
|
200
|
+
export function __resetForTest() {
|
|
201
|
+
pending.clear();
|
|
202
|
+
if (pollTimer)
|
|
203
|
+
clearInterval(pollTimer);
|
|
204
|
+
pollTimer = null;
|
|
205
|
+
started = false;
|
|
206
|
+
}
|
|
@@ -65,6 +65,19 @@ export async function compactSession(session) {
|
|
|
65
65
|
catch (err) {
|
|
66
66
|
console.error("Compaction: failed to flush to memory:", err);
|
|
67
67
|
}
|
|
68
|
+
// v4.11.0 P1 #5 — Auto-extract structured facts from the archived chunk
|
|
69
|
+
// and persist them to MEMORY.md. Experimental feature, opt-out via
|
|
70
|
+
// MEMORY_EXTRACTION_DISABLED=1. Safe wrapper — never throws.
|
|
71
|
+
try {
|
|
72
|
+
const { extractAndStoreFacts } = await import("./memory-extractor.js");
|
|
73
|
+
const result = await extractAndStoreFacts(summaryInput);
|
|
74
|
+
if (result.factsStored > 0) {
|
|
75
|
+
console.log(`🧠 memory-extractor: stored ${result.factsStored} new fact(s) in MEMORY.md`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
console.warn("memory-extractor failed (non-fatal):", err instanceof Error ? err.message : err);
|
|
80
|
+
}
|
|
68
81
|
// Try AI-powered summary
|
|
69
82
|
let summaryText = null;
|
|
70
83
|
try {
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Extractor (v4.11.0, experimental)
|
|
3
|
+
*
|
|
4
|
+
* When the compaction service archives old conversation chunks, it normally
|
|
5
|
+
* dumps prose into the daily log. This extractor adds a structured pass that
|
|
6
|
+
* pulls user_facts, preferences, and decisions out of the chunk and appends
|
|
7
|
+
* them to MEMORY.md (de-duplicated by exact-string match).
|
|
8
|
+
*
|
|
9
|
+
* Pattern inspired by Mem0's auto-extraction. Designed to be safe:
|
|
10
|
+
* - Opt-out via MEMORY_EXTRACTION_DISABLED=1
|
|
11
|
+
* - Uses the active provider with effort=low
|
|
12
|
+
* - Failures are swallowed; compaction continues regardless
|
|
13
|
+
* - Dedup is exact-string only (no embedding-based semantic dedup yet)
|
|
14
|
+
*/
|
|
15
|
+
import fs from "fs";
|
|
16
|
+
import { dirname } from "path";
|
|
17
|
+
import { MEMORY_FILE } from "../paths.js";
|
|
18
|
+
const EMPTY_FACTS = {
|
|
19
|
+
user_facts: [],
|
|
20
|
+
preferences: [],
|
|
21
|
+
decisions: [],
|
|
22
|
+
};
|
|
23
|
+
const EXTRACTION_PROMPT = `Extract structured facts from this conversation chunk. Return ONLY a JSON object with these keys:
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
"user_facts": ["concrete facts about the user that should persist forever"],
|
|
27
|
+
"preferences": ["communication style or workflow preferences the user expressed"],
|
|
28
|
+
"decisions": ["explicit decisions made (e.g., 'use X instead of Y')"]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
Rules:
|
|
32
|
+
- Each entry must be ONE short, declarative sentence (max 100 chars).
|
|
33
|
+
- Skip transient conversation details (questions, todos, ephemeral state).
|
|
34
|
+
- Skip facts that are obvious from context (e.g., "user asked a question").
|
|
35
|
+
- Empty arrays are fine — don't invent facts.
|
|
36
|
+
- Output ONLY the JSON, no commentary.
|
|
37
|
+
|
|
38
|
+
Conversation chunk:
|
|
39
|
+
`;
|
|
40
|
+
/**
|
|
41
|
+
* Parse the JSON output from the AI extractor. Tolerates markdown code-fence
|
|
42
|
+
* wrapping and surrounding prose. Returns empty arrays on any parse failure.
|
|
43
|
+
*/
|
|
44
|
+
export function parseExtractedFacts(text) {
|
|
45
|
+
if (!text || typeof text !== "string")
|
|
46
|
+
return { ...EMPTY_FACTS };
|
|
47
|
+
// Strip markdown code fences if present
|
|
48
|
+
let cleaned = text.trim();
|
|
49
|
+
const fenceMatch = cleaned.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```\s*$/);
|
|
50
|
+
if (fenceMatch)
|
|
51
|
+
cleaned = fenceMatch[1].trim();
|
|
52
|
+
// Try to find the first { ... } block if there's surrounding prose
|
|
53
|
+
const braceMatch = cleaned.match(/\{[\s\S]*\}/);
|
|
54
|
+
if (braceMatch)
|
|
55
|
+
cleaned = braceMatch[0];
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(cleaned);
|
|
58
|
+
return {
|
|
59
|
+
user_facts: Array.isArray(parsed.user_facts)
|
|
60
|
+
? parsed.user_facts.filter((s) => typeof s === "string")
|
|
61
|
+
: [],
|
|
62
|
+
preferences: Array.isArray(parsed.preferences)
|
|
63
|
+
? parsed.preferences.filter((s) => typeof s === "string")
|
|
64
|
+
: [],
|
|
65
|
+
decisions: Array.isArray(parsed.decisions)
|
|
66
|
+
? parsed.decisions.filter((s) => typeof s === "string")
|
|
67
|
+
: [],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return { ...EMPTY_FACTS };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Append extracted facts to MEMORY.md under structured headers, deduplicated
|
|
76
|
+
* by exact-string match against existing content.
|
|
77
|
+
*/
|
|
78
|
+
export async function appendFactsToMemoryFile(facts) {
|
|
79
|
+
const total = facts.user_facts.length + facts.preferences.length + facts.decisions.length;
|
|
80
|
+
if (total === 0)
|
|
81
|
+
return 0;
|
|
82
|
+
// Read existing content for dedup
|
|
83
|
+
let existing = "";
|
|
84
|
+
try {
|
|
85
|
+
existing = fs.readFileSync(MEMORY_FILE, "utf-8");
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// File doesn't exist yet — that's fine, mkdir parent
|
|
89
|
+
fs.mkdirSync(dirname(MEMORY_FILE), { recursive: true });
|
|
90
|
+
}
|
|
91
|
+
const isDuplicate = (line) => existing.includes(line);
|
|
92
|
+
const newLines = [];
|
|
93
|
+
const todayIso = new Date().toISOString().slice(0, 10);
|
|
94
|
+
const sectionHeader = `\n\n## Auto-extracted (${todayIso})\n`;
|
|
95
|
+
let stored = 0;
|
|
96
|
+
if (facts.user_facts.length > 0) {
|
|
97
|
+
const newOnes = facts.user_facts.filter(f => !isDuplicate(f));
|
|
98
|
+
if (newOnes.length > 0) {
|
|
99
|
+
newLines.push("\n### User Facts");
|
|
100
|
+
for (const f of newOnes) {
|
|
101
|
+
newLines.push(`- ${f}`);
|
|
102
|
+
stored++;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (facts.preferences.length > 0) {
|
|
107
|
+
const newOnes = facts.preferences.filter(p => !isDuplicate(p));
|
|
108
|
+
if (newOnes.length > 0) {
|
|
109
|
+
newLines.push("\n### Preferences");
|
|
110
|
+
for (const p of newOnes) {
|
|
111
|
+
newLines.push(`- ${p}`);
|
|
112
|
+
stored++;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (facts.decisions.length > 0) {
|
|
117
|
+
const newOnes = facts.decisions.filter(d => !isDuplicate(d));
|
|
118
|
+
if (newOnes.length > 0) {
|
|
119
|
+
newLines.push("\n### Decisions");
|
|
120
|
+
for (const d of newOnes) {
|
|
121
|
+
newLines.push(`- ${d}`);
|
|
122
|
+
stored++;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (stored > 0) {
|
|
127
|
+
const block = sectionHeader + newLines.join("\n") + "\n";
|
|
128
|
+
fs.appendFileSync(MEMORY_FILE, block, "utf-8");
|
|
129
|
+
}
|
|
130
|
+
return stored;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Extract facts from a conversation chunk and store them in MEMORY.md.
|
|
134
|
+
* Safe wrapper — never throws, always returns an ExtractionResult.
|
|
135
|
+
*/
|
|
136
|
+
export async function extractAndStoreFacts(conversationText) {
|
|
137
|
+
if (process.env.MEMORY_EXTRACTION_DISABLED === "1") {
|
|
138
|
+
return { disabled: true, factsStored: 0 };
|
|
139
|
+
}
|
|
140
|
+
if (!conversationText || conversationText.trim().length < 50) {
|
|
141
|
+
return { disabled: false, factsStored: 0 };
|
|
142
|
+
}
|
|
143
|
+
let extractedText = "";
|
|
144
|
+
try {
|
|
145
|
+
// Lazy-import the registry so test environments without an engine init
|
|
146
|
+
// don't crash on module load.
|
|
147
|
+
const { getRegistry } = await import("../engine.js");
|
|
148
|
+
const registry = getRegistry();
|
|
149
|
+
const opts = {
|
|
150
|
+
prompt: EXTRACTION_PROMPT + conversationText.slice(0, 8000),
|
|
151
|
+
systemPrompt: "You are a fact extractor. Output only valid JSON, no commentary.",
|
|
152
|
+
effort: "low",
|
|
153
|
+
};
|
|
154
|
+
for await (const chunk of registry.queryWithFallback(opts)) {
|
|
155
|
+
if (chunk.type === "text" && chunk.text) {
|
|
156
|
+
extractedText = chunk.text;
|
|
157
|
+
}
|
|
158
|
+
if (chunk.type === "error") {
|
|
159
|
+
// Provider failed — silent fallback
|
|
160
|
+
return { disabled: false, factsStored: 0 };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return { disabled: false, factsStored: 0 };
|
|
166
|
+
}
|
|
167
|
+
if (!extractedText)
|
|
168
|
+
return { disabled: false, factsStored: 0 };
|
|
169
|
+
const facts = parseExtractedFacts(extractedText);
|
|
170
|
+
let stored = 0;
|
|
171
|
+
try {
|
|
172
|
+
stored = await appendFactsToMemoryFile(facts);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// appendFactsToMemoryFile failed — non-fatal
|
|
176
|
+
}
|
|
177
|
+
return { disabled: false, factsStored: stored };
|
|
178
|
+
}
|