peon-mem 1.0.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.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +301 -0
  3. package/bin/peon-mem.mjs +273 -0
  4. package/dist/brain.d.ts +72 -0
  5. package/dist/brain.js +224 -0
  6. package/dist/compression.d.ts +9 -0
  7. package/dist/compression.js +37 -0
  8. package/dist/config.d.ts +22 -0
  9. package/dist/config.js +99 -0
  10. package/dist/daemon-cli.d.ts +2 -0
  11. package/dist/daemon-cli.js +54 -0
  12. package/dist/daemon.d.ts +23 -0
  13. package/dist/daemon.js +1078 -0
  14. package/dist/embedding-store.d.ts +43 -0
  15. package/dist/embedding-store.js +169 -0
  16. package/dist/embeddings.d.ts +93 -0
  17. package/dist/embeddings.js +345 -0
  18. package/dist/entities.d.ts +61 -0
  19. package/dist/entities.js +191 -0
  20. package/dist/entity-extraction.d.ts +33 -0
  21. package/dist/entity-extraction.js +75 -0
  22. package/dist/eval-metrics.d.ts +27 -0
  23. package/dist/eval-metrics.js +50 -0
  24. package/dist/evaluation.d.ts +58 -0
  25. package/dist/evaluation.js +244 -0
  26. package/dist/global-extraction.d.ts +15 -0
  27. package/dist/global-extraction.js +61 -0
  28. package/dist/global-memory.d.ts +43 -0
  29. package/dist/global-memory.js +306 -0
  30. package/dist/global-promotion.d.ts +25 -0
  31. package/dist/global-promotion.js +29 -0
  32. package/dist/hyde.d.ts +31 -0
  33. package/dist/hyde.js +46 -0
  34. package/dist/index.d.ts +2 -0
  35. package/dist/index.js +246 -0
  36. package/dist/injection.d.ts +38 -0
  37. package/dist/injection.js +133 -0
  38. package/dist/logger.d.ts +17 -0
  39. package/dist/logger.js +63 -0
  40. package/dist/memory-mutations.d.ts +24 -0
  41. package/dist/memory-mutations.js +57 -0
  42. package/dist/memory-store.d.ts +194 -0
  43. package/dist/memory-store.js +1205 -0
  44. package/dist/monitor.d.ts +13 -0
  45. package/dist/monitor.js +977 -0
  46. package/dist/overview.d.ts +73 -0
  47. package/dist/overview.js +104 -0
  48. package/dist/processor.d.ts +90 -0
  49. package/dist/processor.js +450 -0
  50. package/dist/quality.d.ts +86 -0
  51. package/dist/quality.js +338 -0
  52. package/dist/recuration.d.ts +13 -0
  53. package/dist/recuration.js +65 -0
  54. package/dist/reranker.d.ts +34 -0
  55. package/dist/reranker.js +89 -0
  56. package/dist/retrieval.d.ts +106 -0
  57. package/dist/retrieval.js +392 -0
  58. package/dist/session-index.d.ts +34 -0
  59. package/dist/session-index.js +87 -0
  60. package/dist/temporal.d.ts +20 -0
  61. package/dist/temporal.js +62 -0
  62. package/dist/token-ab-monitor.d.ts +1 -0
  63. package/dist/token-ab-monitor.js +7 -0
  64. package/dist/tools.d.ts +232 -0
  65. package/dist/tools.js +546 -0
  66. package/dist/types.d.ts +169 -0
  67. package/dist/types.js +1 -0
  68. package/docs/assets/neural-universe.png +0 -0
  69. package/package.json +57 -0
  70. package/scripts/claude-peon-hook.mjs +522 -0
  71. package/scripts/codex-peon-hook.mjs +4 -0
  72. package/scripts/eval-retrieval-labeled.mjs +135 -0
  73. package/scripts/eval-retrieval.mjs +96 -0
  74. package/scripts/evaluate-peon.mjs +47 -0
  75. package/scripts/install-peon-stl.mjs +82 -0
  76. package/scripts/install-peon.mjs +318 -0
  77. package/scripts/lib/eval-ledger.mjs +104 -0
  78. package/scripts/lib/stl-classify.mjs +44 -0
  79. package/scripts/longmemeval-eval.mjs +144 -0
  80. package/scripts/peon-report.mjs +155 -0
  81. package/scripts/peon-stl.mjs +506 -0
  82. package/scripts/token-ab-monitor.html +235 -0
@@ -0,0 +1,1205 @@
1
+ import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { basename, join } from "node:path";
4
+ import { loadPeonConfig } from "./config.js";
5
+ import { EmbeddingStore } from "./embedding-store.js";
6
+ import { cosineSimilarity, createEmbeddingClient } from "./embeddings.js";
7
+ import { applyDelete, applyMerge, applyPin, applyUpdate } from "./memory-mutations.js";
8
+ import { runSleepCycle } from "./brain.js";
9
+ import { readdir, rm } from "node:fs/promises";
10
+ import { rankMemoryRecords as rankWithRetrieval, computeGraphActivation, diversifyByMMR } from "./retrieval.js";
11
+ import { currentAsOf, changesBetween } from "./temporal.js";
12
+ import { inferCanonicalEntities, buildEntityRegistry, canonicalizeEntity } from "./entities.js";
13
+ import { redactSecrets } from "./injection.js";
14
+ /**
15
+ * Per-project write serialization. The daemon is one process but opens MORE THAN ONE store
16
+ * instance for the same project (the cached store for record/getContext + a fresh one inside
17
+ * the consolidation processor), and overlapping triggers (turn-end, SubagentStop, session-end,
18
+ * heartbeat) can run read-modify-write on the same memories.jsonl concurrently. Without a lock,
19
+ * the later full-file overwrite silently drops the earlier run's supersede flips and adds.
20
+ * This module-level map keys a promise-chain mutex by the resolved memory dir, so it serializes
21
+ * ACROSS instances within the process. Not reentrant — only the outermost public mutator locks.
22
+ */
23
+ const projectWriteLocks = new Map();
24
+ function withProjectWriteLock(key, fn) {
25
+ const prev = projectWriteLocks.get(key) ?? Promise.resolve();
26
+ const run = prev.then(fn, fn); // run regardless of how the previous holder settled
27
+ projectWriteLocks.set(key, run.then(() => undefined, () => undefined));
28
+ return run;
29
+ }
30
+ /** Crash-safe write: stage to a sibling .tmp then atomically rename over the target. */
31
+ async function atomicWrite(path, content) {
32
+ const tmp = `${path}.tmp`;
33
+ await writeFile(tmp, content, "utf8");
34
+ await rename(tmp, path);
35
+ }
36
+ export class PeonMemoryStore {
37
+ projectPath;
38
+ memoryDir;
39
+ embeddingClient;
40
+ sessions = new Map();
41
+ embeddingStore;
42
+ constructor(projectPath, memoryDir, embeddingClient) {
43
+ this.projectPath = projectPath;
44
+ this.memoryDir = memoryDir;
45
+ this.embeddingClient = embeddingClient;
46
+ }
47
+ static async open(options) {
48
+ // Path-traversal guard: never open a store at a path containing ".." segments — a daemon
49
+ // caller must not be able to escape to an arbitrary location and create/overwrite a brain.
50
+ if (/(?:^|[/\\])\.\.(?:[/\\]|$)/.test(options.projectPath)) {
51
+ throw new Error(`Unsafe projectPath (contains "..") : ${options.projectPath}`);
52
+ }
53
+ const config = options.config ?? loadPeonConfig();
54
+ const memoryDir = join(options.projectPath, options.memoryDirName ?? config.memoryDirName ?? ".peon");
55
+ const embeddingClient = options.embeddingClient !== undefined ? options.embeddingClient : createEmbeddingClient({ config });
56
+ const store = new PeonMemoryStore(options.projectPath, memoryDir, embeddingClient);
57
+ await store.ensureLayout();
58
+ store.embeddingStore = await EmbeddingStore.open(memoryDir);
59
+ return store;
60
+ }
61
+ async startSession(input) {
62
+ const session = {
63
+ id: crypto.randomUUID(),
64
+ projectPath: this.projectPath,
65
+ client: input.client,
66
+ cwd: input.cwd,
67
+ startedAt: new Date().toISOString()
68
+ };
69
+ this.sessions.set(session.id, session);
70
+ await this.appendJsonl("raw/events.jsonl", {
71
+ id: crypto.randomUUID(),
72
+ sessionId: session.id,
73
+ type: "session_started",
74
+ content: `Session started for ${input.client}`,
75
+ createdAt: session.startedAt
76
+ });
77
+ return session;
78
+ }
79
+ /**
80
+ * Rehydrate a session into memory if it isn't already known. Called by the
81
+ * tools layer after resolving a sessionId from the durable session index, so
82
+ * record/end operations succeed even after a daemon restart. Idempotent.
83
+ */
84
+ ensureSession(session) {
85
+ if (!this.sessions.has(session.id)) {
86
+ this.sessions.set(session.id, session);
87
+ }
88
+ }
89
+ async recordMessage(input) {
90
+ const event = await this.record({
91
+ sessionId: input.sessionId,
92
+ type: "message",
93
+ role: input.role,
94
+ content: input.content
95
+ });
96
+ await this.appendJsonl("raw/messages.jsonl", event);
97
+ return event;
98
+ }
99
+ async recordEvent(input) {
100
+ const event = await this.record(input);
101
+ await this.appendJsonl("raw/events.jsonl", event);
102
+ if (event.type === "tool_use") {
103
+ await this.appendJsonl("raw/tool-calls.jsonl", event);
104
+ }
105
+ await this.updateBrain(event);
106
+ return event;
107
+ }
108
+ async endSession(input) {
109
+ const session = this.requireSession(input.sessionId);
110
+ const ended = { ...session, endedAt: new Date().toISOString() };
111
+ this.sessions.set(ended.id, ended);
112
+ await this.appendJsonl("raw/events.jsonl", {
113
+ id: crypto.randomUUID(),
114
+ sessionId: ended.id,
115
+ type: "session_ended",
116
+ content: "Session ended",
117
+ createdAt: ended.endedAt
118
+ });
119
+ await this.writeSessionSummary(ended);
120
+ // Do NOT rebuild project-summary.md here — the AI processor owns that file.
121
+ // Overwriting it with raw in-memory events would clobber any AI-generated summary.
122
+ return ended;
123
+ }
124
+ async getContext(input = {}) {
125
+ const maxChars = normalizeContextBudget(input.maxChars);
126
+ // Query-FOCUSED injection: every section is built from beliefs the ranker judged
127
+ // relevant to THIS prompt (RRF + semantic + recency + reinforcement), then diversified
128
+ // by MMR — instead of dumping whole brain .md files and text-trimming them. So when the
129
+ // user asks about X, the injected decisions/preferences/etc. are about X, not the entire
130
+ // brain. Irrelevant sections come back empty and drop out, keeping the block small.
131
+ // NOTE: expandGraph is intentionally OFF here. The labeled eval harness (eval-metrics +
132
+ // scripts/eval-retrieval-labeled.mjs) showed fusing entity-graph activation into the ranking
133
+ // does NOT improve top-K injection relevance (neutral at low weight, −2.9% Recall@10 at 0.5):
134
+ // shared-entity association ≠ question-relevance, so it trades away relevant direct hits. The
135
+ // graph stays an opt-in capability (rankRecords({expandGraph}) / expandByEntityGraph) + the
136
+ // entity registry/graph.json structure — not a tax on every injection.
137
+ const ranked = diversifyByMMR(await this.rankRecords(input.query, { limit: 40 }));
138
+ const active = ranked.filter((item) => item.record.status === "active");
139
+ // The active beliefs DIRECTLY recalled for this prompt — reinforcement fuel. Exclude
140
+ // graph-neighbour beliefs (pulled in by association, not matched by the query) so they
141
+ // aren't reinforced as if the user's prompt recalled them.
142
+ const recalledIds = active
143
+ .filter((item) => !item.explanation.startsWith("linked via entity"))
144
+ .slice(0, 12)
145
+ .map((item) => item.record.id);
146
+ // Episodic layer ON by default: the consolidated belief layer is a lossy gist, so without
147
+ // this a question like "what were the professor's 3 ideas" gets the summary, not the verbatim
148
+ // answer that's sitting in the recorded session. Opt OUT with includeEpisodes:false.
149
+ const episodes = input.includeEpisodes !== false && (input.query ?? "").trim().length > 0
150
+ ? formatEpisodes(await this.rankEpisodes(input.query, { limit: 6 }), Math.floor(maxChars * 0.35))
151
+ : "";
152
+ // HYBRID per-section source: prefer query-ranked belief RECORDS of that type (the consolidated
153
+ // brain — already relevance-ranked, so the section is about what was asked). Fall back to the
154
+ // real-time .md file when no such beliefs exist yet (pre-consolidation, or types written live by
155
+ // recordEvent before the AI distills them). This keeps injection query-focused once the brain is
156
+ // built, without losing the immediate real-time view.
157
+ const ofType = (...types) => active.filter((item) => types.includes(item.record.type));
158
+ const section = async (records, file, title, frac) => {
159
+ const budget = Math.floor(maxChars * frac);
160
+ if (records.length > 0)
161
+ return formatContextRecords(records, budget, title);
162
+ return compactMemoryText(await this.readBrainFile(file), { maxChars: budget, query: input.query, title });
163
+ };
164
+ const summarySource = await this.readBrainFile("project-summary.md");
165
+ const sections = {
166
+ summary: compactMemoryText(summarySource, { maxChars: Math.floor(maxChars * 0.18), query: input.query, title: "Project Summary" }),
167
+ // The headline "most relevant overall" beliefs, with scores — kept in the original format.
168
+ memories: compactMemoryText(formatRankedMemoryRecords(active), { maxChars: Math.floor(maxChars * 0.22), query: input.query, title: "Structured Memory" }),
169
+ decisions: await section(ofType("decision"), "decisions.md", "Decisions", 0.14),
170
+ preferences: await section(ofType("preference"), "preferences.md", "Preferences", 0.12),
171
+ openQuestions: await section(ofType("open_question"), "open-questions.md", "Open Questions", 0.10),
172
+ artifacts: await section(ofType("artifact"), "artifacts.md", "Artifacts", 0.10),
173
+ timeline: await section(ofType("timeline"), "timeline.md", "Timeline", 0.12)
174
+ };
175
+ // `compacted` reflects whether the RAW brain material exceeded the budget (so callers know
176
+ // the view was trimmed) — measured from sources, not the already-budgeted section output.
177
+ const rawSize = summarySource.length +
178
+ formatRankedMemoryRecords(active).length +
179
+ (await Promise.all(["decisions.md", "preferences.md", "open-questions.md", "artifacts.md", "timeline.md"].map((f) => this.readBrainFile(f))))
180
+ .reduce((n, s) => n + s.length, 0);
181
+ const originalChars = rawSize;
182
+ // Redact secrets at the injection boundary — getContext is the hook's injection path and
183
+ // (unlike buildContextInjection) was emitting belief/episode text unredacted.
184
+ const safeSections = Object.fromEntries(Object.entries(sections).map(([k, v]) => [k, redactSecrets(v)]));
185
+ // Headline: the first GENUINELY query-matching belief, hoisted so the injection LEADS with it.
186
+ // Gate on real match signal (query-term / file / matched-entity / a real semantic hit) — NOT
187
+ // recency/reinforcement — so a recently-touched but irrelevant belief can't be paraded as "most
188
+ // relevant" (a banner is only useful if it's trustworthy). Skip graph-neighbours and the no-query
189
+ // startup path. If nothing genuinely matches, emit no banner rather than a misleading one.
190
+ const genuinelyMatches = (item) => item.reasons.some((r) => r.kind === "query_term" ||
191
+ r.kind === "file" ||
192
+ (r.kind === "entity" && r.label !== "linked via entity graph") ||
193
+ (r.kind === "semantic" && r.score >= 0.4));
194
+ const headlineRecord = (input.query ?? "").trim().length > 0
195
+ ? active.find((item) => !item.explanation.startsWith("linked via entity") && genuinelyMatches(item))?.record
196
+ : undefined;
197
+ const headline = headlineRecord ? redactSecrets(`[${headlineRecord.type}] ${headlineRecord.content}`) : undefined;
198
+ return {
199
+ ...safeSections,
200
+ ...(episodes ? { episodes: redactSecrets(episodes) } : {}),
201
+ meta: {
202
+ compacted: originalChars > maxChars,
203
+ maxChars
204
+ },
205
+ recalledIds,
206
+ ...(headline ? { headline } : {})
207
+ };
208
+ }
209
+ async inspectBrain(input = {}) {
210
+ const context = await this.getContext(input);
211
+ // FULL record set — inspection/counts must see the whole brain, not a ranked top-K slice
212
+ // (that bug made /overview report ~50 beliefs for a 1400+ belief brain). The query-relevant
213
+ // CONTENT still comes from `context` / `injectionPreview`, which stay ranked and compact.
214
+ const records = await this.listMemoryRecords();
215
+ return {
216
+ projectPath: this.projectPath,
217
+ query: input.query,
218
+ records,
219
+ graph: await this.readMemoryGraph(),
220
+ injectionPreview: formatInjectionPreview(context),
221
+ context
222
+ };
223
+ }
224
+ async listMemoryRecords() {
225
+ return this.readMemoryRecords();
226
+ }
227
+ /** Serialize a read-modify-write transaction against this project's brain (see projectWriteLocks). */
228
+ withWriteLock(fn) {
229
+ return withProjectWriteLock(this.memoryDir, fn);
230
+ }
231
+ /**
232
+ * Run a multi-step read-modify-write as ONE serialized critical section against this project's
233
+ * brain — even across separate store instances in the process. Callers must NOT invoke other
234
+ * locking mutators inside `fn` (the lock is not reentrant); use the lock-free internals
235
+ * (applyProcessedMemory, mergeSimilarActiveRecords, replaceMemoryRecords) directly.
236
+ */
237
+ runExclusive(fn) {
238
+ return this.withWriteLock(fn);
239
+ }
240
+ async replaceMemoryRecords(records) {
241
+ await atomicWrite(join(this.memoryDir, "brain", "memories.jsonl"), records.map((record) => JSON.stringify(record)).join("\n") + (records.length > 0 ? "\n" : ""));
242
+ await atomicWrite(join(this.memoryDir, "brain", "graph.json"), `${JSON.stringify(buildMemoryGraph(basename(this.projectPath), records), null, 2)}\n`);
243
+ // Canonical entity registry — derived from records (observable, like graph.json), atomic.
244
+ const { entities } = buildEntityRegistry(records.flatMap((record) => record.entities));
245
+ await atomicWrite(join(this.memoryDir, "brain", "entities.jsonl"), entities.map((entity) => JSON.stringify(entity)).join("\n") + (entities.length > 0 ? "\n" : ""));
246
+ // Keep vector embeddings in lock-step with the structured records.
247
+ await this.embeddingStore?.sync(records, this.embeddingClient);
248
+ }
249
+ /** Edit a belief in place (content, scores, status, or pin). Returns the updated record, or null if unknown. */
250
+ async updateMemoryRecord(id, patch) {
251
+ return this.withWriteLock(async () => {
252
+ const records = await this.readMemoryRecords();
253
+ if (!records.some((record) => record.id === id))
254
+ return null;
255
+ const next = applyUpdate(records, id, patch, new Date().toISOString());
256
+ await this.replaceMemoryRecords(next);
257
+ return next.find((record) => record.id === id) ?? null;
258
+ });
259
+ }
260
+ /** Delete a belief outright. Returns true if a record was removed. */
261
+ async deleteMemoryRecord(id) {
262
+ return this.withWriteLock(async () => {
263
+ const records = await this.readMemoryRecords();
264
+ const next = applyDelete(records, id);
265
+ if (next.length === records.length)
266
+ return false;
267
+ await this.replaceMemoryRecords(next);
268
+ return true;
269
+ });
270
+ }
271
+ /** Pin/unpin a belief. Returns the updated record, or null if unknown. */
272
+ async setMemoryRecordPinned(id, pinned) {
273
+ return this.withWriteLock(async () => {
274
+ const records = await this.readMemoryRecords();
275
+ if (!records.some((record) => record.id === id))
276
+ return null;
277
+ const next = applyPin(records, id, pinned, new Date().toISOString());
278
+ await this.replaceMemoryRecords(next);
279
+ return next.find((record) => record.id === id) ?? null;
280
+ });
281
+ }
282
+ /** Fold one belief into another. Returns the surviving record, or null if either id is unknown. */
283
+ async mergeMemoryRecords(keepId, dropId) {
284
+ return this.withWriteLock(async () => {
285
+ const records = await this.readMemoryRecords();
286
+ const next = applyMerge(records, keepId, dropId, new Date().toISOString());
287
+ if (next.length === records.length && keepId !== dropId)
288
+ return null;
289
+ await this.replaceMemoryRecords(next);
290
+ return next.find((record) => record.id === keepId) ?? null;
291
+ });
292
+ }
293
+ /**
294
+ * Run one autonomous brain pass (the "sleep cycle"): snapshot a backup, then
295
+ * reinforce / resolve conflicts / merge duplicates / compress topic clusters.
296
+ * Every change is recoverable from the snapshot. Returns the actions taken.
297
+ */
298
+ async runBrainPass(options = {}) {
299
+ return this.withWriteLock(async () => {
300
+ const records = await this.readMemoryRecords();
301
+ if (records.length === 0)
302
+ return [];
303
+ const now = new Date().toISOString();
304
+ await this.snapshotBackup(records, now);
305
+ const { records: curated, actions } = await runSleepCycle(records, {
306
+ recalledIds: options.recalledIds,
307
+ now,
308
+ summarize: options.summarize,
309
+ minClusterSize: options.minClusterSize,
310
+ makeSummaryId: (entity) => `mem_summary_${stableMemoryId("summary", entity).slice(-12)}`
311
+ });
312
+ if (actions.length === 0) {
313
+ // Reinforcement-only strength tweaks still matter; persist them quietly.
314
+ await this.replaceMemoryRecords(curated);
315
+ return [];
316
+ }
317
+ await this.replaceMemoryRecords(curated);
318
+ await this.appendJsonl("brain/brain-actions.jsonl", { at: now, actions });
319
+ return actions;
320
+ });
321
+ }
322
+ /** Archive a set of beliefs (recoverable) after snapshotting a backup. Returns how many were archived. */
323
+ async archiveRecords(ids, reason) {
324
+ if (ids.length === 0)
325
+ return 0;
326
+ return this.withWriteLock(async () => {
327
+ const records = await this.readMemoryRecords();
328
+ const idSet = new Set(ids);
329
+ const now = new Date().toISOString();
330
+ await this.snapshotBackup(records, now);
331
+ let archived = 0;
332
+ const next = records.map((record) => {
333
+ if (idSet.has(record.id) && record.status === "active" && !record.pinned) {
334
+ archived += 1;
335
+ return { ...record, status: "archived", updatedAt: now, source: { ...record.source, reason } };
336
+ }
337
+ return record;
338
+ });
339
+ if (archived > 0)
340
+ await this.replaceMemoryRecords(next);
341
+ return archived;
342
+ });
343
+ }
344
+ /** Recent autonomous actions the brain took — powers the cockpit "what the brain did" feed. */
345
+ async readBrainActions(limit = 50) {
346
+ const rows = await this.readJsonl("brain/brain-actions.jsonl");
347
+ return rows.slice(-limit).reverse();
348
+ }
349
+ async snapshotBackup(records, now) {
350
+ const dir = join(this.memoryDir, "brain", "backups");
351
+ await mkdir(dir, { recursive: true });
352
+ const stamp = now.replace(/[:.]/g, "-");
353
+ await writeFile(join(dir, `memories-${stamp}.jsonl`), records.map((r) => JSON.stringify(r)).join("\n") + "\n", "utf8");
354
+ // Keep only the most recent 20 snapshots.
355
+ const files = (await readdir(dir).catch(() => [])).filter((f) => f.startsWith("memories-")).sort();
356
+ for (const stale of files.slice(0, Math.max(0, files.length - 20))) {
357
+ await rm(join(dir, stale), { force: true }).catch(() => undefined);
358
+ }
359
+ }
360
+ /** Restore the project's beliefs from the most recent backup snapshot. Returns true if restored. */
361
+ async restoreLatestBackup() {
362
+ // Serialize against consolidation/merge — a restore is a read-modify-write of the brain and
363
+ // must not race the heartbeat (otherwise a concurrent consolidation can clobber the restore).
364
+ return this.withWriteLock(async () => {
365
+ const dir = join(this.memoryDir, "brain", "backups");
366
+ const files = (await readdir(dir).catch(() => [])).filter((f) => f.startsWith("memories-")).sort();
367
+ const latest = files.at(-1);
368
+ if (!latest)
369
+ return false;
370
+ const raw = await readFile(join(dir, latest), "utf8").catch(() => "");
371
+ const records = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).flatMap((l) => {
372
+ try {
373
+ return [JSON.parse(l)];
374
+ }
375
+ catch {
376
+ return [];
377
+ }
378
+ });
379
+ if (records.length === 0)
380
+ return false;
381
+ await this.replaceMemoryRecords(records);
382
+ return true;
383
+ });
384
+ }
385
+ /**
386
+ * Rank memory records for a query using hybrid lexical + semantic retrieval.
387
+ * The single retrieval entry point: embeds the query (if embeddings are on),
388
+ * loads stored vectors, and blends cosine similarity into the lexical score.
389
+ */
390
+ /** Time-travel: the beliefs that were current as of `at`. */
391
+ async currentAsOf(at) {
392
+ return currentAsOf(await this.readMemoryRecords(), at);
393
+ }
394
+ /** Time-travel: the changelog (added / superseded / retired) over [from, to]. */
395
+ async changesBetween(from, to) {
396
+ return changesBetween(await this.readMemoryRecords(), from, to);
397
+ }
398
+ /**
399
+ * EPISODIC retrieval — rank the raw conversational turns (not the consolidated beliefs) by
400
+ * relevance to a query. Consolidation is lossy by design: it distills experience into durable
401
+ * beliefs and drops episodic specifics ("the GPS was not functioning" becomes "interested in
402
+ * GPS features"). For questions that hinge on those specifics, retrieving over the raw record
403
+ * recovers the detail the belief layer compressed away. This is the high-recall episodic layer
404
+ * that complements the high-precision belief layer; callers can blend both. Lexical-ranked
405
+ * (raw turns carry no precomputed embeddings) and read-only — it never mutates the store.
406
+ */
407
+ async rankEpisodes(query, options = {}) {
408
+ const [messages, events] = await Promise.all([
409
+ this.readJsonl("raw/messages.jsonl"),
410
+ this.readJsonl("raw/events.jsonl")
411
+ ]);
412
+ // Tool-call dumps (Bash/Edit/Write payloads) are huge and low-signal for "what was discussed";
413
+ // rank over substantive conversational turns instead so verbatim answers surface, not noise.
414
+ const TOOL_TYPES = new Set(["tool_use", "tool_result"]);
415
+ const substantive = [...messages, ...events].filter((e) => typeof e.content === "string" && e.content.trim().length > 0 && !TOOL_TYPES.has(String(e.type)));
416
+ // LEXICAL PREFILTER — bound the per-prompt cost. rankWithRetrieval below ranks episodes with
417
+ // NO semantic input (lexical-only), so an episode can only score if its text lexically matches
418
+ // a query token. Restricting the expensive map + inferEntities + rank to lexically-matching
419
+ // entries therefore drops NOTHING the ranker would have surfaced, while turning an O(all session
420
+ // history) scan on every prompt into O(matches). With no query we keep the most recent slice.
421
+ const tokens = (query ?? "").toLowerCase().match(/[a-z0-9]{3,}/g) ?? [];
422
+ const EXPENSIVE_CAP = 500;
423
+ let candidates = substantive;
424
+ if (tokens.length > 0) {
425
+ candidates = substantive.filter((e) => {
426
+ const c = String(e.content).toLowerCase();
427
+ return tokens.some((t) => c.includes(t));
428
+ });
429
+ }
430
+ if (candidates.length > EXPENSIVE_CAP) {
431
+ // A near-stopword token can still match a huge slice; keep the most RECENT matches so a
432
+ // pathological query can't reintroduce the unbounded cost.
433
+ candidates = candidates
434
+ .slice()
435
+ .sort((a, b) => String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? "")))
436
+ .slice(0, EXPENSIVE_CAP);
437
+ }
438
+ const episodes = candidates
439
+ .map((e) => {
440
+ const content = String(e.content);
441
+ const when = String(e.createdAt ?? "");
442
+ const role = typeof e.role === "string" ? e.role : undefined;
443
+ return {
444
+ id: String(e.id ?? `${when}:${content.slice(0, 24)}`),
445
+ type: "timeline",
446
+ content: role ? `${role}: ${content}` : content,
447
+ normalized: content.toLowerCase(),
448
+ scope: "project",
449
+ status: "active",
450
+ score: { importance: 0.5, confidence: 0.6 },
451
+ source: { kind: "manual" },
452
+ entities: inferEntities(content),
453
+ createdAt: when,
454
+ updatedAt: when
455
+ };
456
+ });
457
+ if (episodes.length === 0)
458
+ return [];
459
+ return rankWithRetrieval(episodes, query, { limit: options.limit ?? 20 });
460
+ }
461
+ async rankRecords(query, options = {}) {
462
+ const records = await this.readMemoryRecords();
463
+ const semantic = await this.buildSemanticInput(query, records);
464
+ const limit = options.limit ?? 50;
465
+ const direct = rankWithRetrieval(records, query, { limit, semantic });
466
+ if (!options.expandGraph || direct.length === 0)
467
+ return direct;
468
+ // FUSED associative recall: spread activation from the direct hits through the entity graph,
469
+ // then RE-RANK with that activation as a (damped) signal — so a strongly-associated belief can
470
+ // enter the top-K and displace a weak direct hit, instead of being appended out of the window.
471
+ const graphActivation = computeGraphActivation(direct, records);
472
+ if (graphActivation.size === 0)
473
+ return direct;
474
+ return rankWithRetrieval(records, query, { limit, semantic, graphActivation });
475
+ }
476
+ /**
477
+ * Rank records WITHOUT mutating anything — uses only embeddings already on disk
478
+ * (no sync, no recompute, no writes). For read-only cross-project recall, where
479
+ * we must never modify another project's brain. An optional precomputed query
480
+ * vector lets the caller embed the query once and reuse it across many projects.
481
+ */
482
+ async rankRecordsReadonly(query, options = {}) {
483
+ const records = await this.readMemoryRecords();
484
+ let semantic;
485
+ if (query && query.trim() && this.embeddingStore && records.length > 0) {
486
+ try {
487
+ const vectorById = await this.embeddingStore.vectorById();
488
+ let queryVector = options.queryVector;
489
+ if ((!queryVector || queryVector.length === 0) && this.embeddingClient) {
490
+ [queryVector] = await this.embeddingClient.embed([query]);
491
+ }
492
+ if (queryVector && queryVector.length > 0 && vectorById.size > 0) {
493
+ semantic = { queryVector, vectorById };
494
+ }
495
+ }
496
+ catch {
497
+ // lexical-only on any embedding failure
498
+ }
499
+ }
500
+ return rankWithRetrieval(records, query, { limit: options.limit ?? 50, semantic });
501
+ }
502
+ async buildSemanticInput(query, records) {
503
+ if (!query || !query.trim() || !this.embeddingClient || !this.embeddingStore || records.length === 0) {
504
+ return undefined;
505
+ }
506
+ try {
507
+ // READ-ONLY on the prompt hot path: use vectors already on disk (mtime-cached) — never
508
+ // sync/recompute/rewrite the multi-MB sidecar here. Backfill happens on the WRITE path
509
+ // (replaceMemoryRecords → sync, under the write lock), so vectors stay current after any
510
+ // mutation; records added since the last write degrade to lexical until the next consolidation.
511
+ const vectorById = await this.embeddingStore.vectorById();
512
+ if (vectorById.size === 0)
513
+ return undefined;
514
+ const [queryVector] = await this.embeddingClient.embed([query]);
515
+ if (!queryVector || queryVector.length === 0)
516
+ return undefined;
517
+ return { queryVector, vectorById };
518
+ }
519
+ catch {
520
+ // Embedding failures degrade gracefully to lexical-only retrieval.
521
+ return undefined;
522
+ }
523
+ }
524
+ async writeQualityReport(report) {
525
+ await writeFile(join(this.memoryDir, "brain", "quality-report.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8");
526
+ }
527
+ async readRawMemory(maxChars = 12000) {
528
+ const [messages, events] = await Promise.all([
529
+ this.readJsonl("raw/messages.jsonl"),
530
+ this.readJsonl("raw/events.jsonl")
531
+ ]);
532
+ const formatted = [...messages, ...events]
533
+ .sort((left, right) => String(left.createdAt ?? "").localeCompare(String(right.createdAt ?? "")))
534
+ .map((entry) => {
535
+ const role = entry.role ? `${entry.role}: ` : "";
536
+ return `[${entry.createdAt ?? "unknown"}] ${entry.type ?? "memory"} ${role}${entry.content ?? ""}`;
537
+ })
538
+ .join("\n");
539
+ return formatted.length > maxChars ? formatted.slice(-maxChars) : formatted;
540
+ }
541
+ /**
542
+ * Read only the raw events that arrived AFTER `afterEventId` (the delta cursor),
543
+ * so consolidation processes new experience instead of re-reading a window.
544
+ * Falls back to the full sliding window when the cursor is unset or no longer
545
+ * present (e.g. logs rotated) — never silently skips events. The delta is capped
546
+ * to `maxChars` (default 60k, env PEON_CONSOLIDATION_MAX_DELTA_CHARS); `lastEventId`
547
+ * is the last INCLUDED event (the next cursor) and `capped` is true when the delta
548
+ * was cut short — the caller must then keep the char-gate open so the rest drains.
549
+ */
550
+ async readRawMemoryDelta(afterEventId, maxChars) {
551
+ // Bound how much delta is handed to the model in one consolidation. A huge backlog (big brain +
552
+ // a fat session) overflowed the completion window and truncated the JSON reply mid-object, which
553
+ // never parses — so the batch failed and (correctly, cursor unmoved) retried the SAME oversized
554
+ // delta forever: a permanent stall. Consume a bounded chunk, advance the cursor only to the last
555
+ // INCLUDED event, and let the next trigger drain the rest.
556
+ const cap = maxChars ?? (Number(process.env.PEON_CONSOLIDATION_MAX_DELTA_CHARS) || 60000);
557
+ const [messages, events] = await Promise.all([
558
+ this.readJsonl("raw/messages.jsonl"),
559
+ this.readJsonl("raw/events.jsonl")
560
+ ]);
561
+ const sorted = [...messages, ...events].sort((left, right) => String(left.createdAt ?? "").localeCompare(String(right.createdAt ?? "")));
562
+ let slice = sorted;
563
+ if (afterEventId) {
564
+ const index = sorted.findIndex((entry) => entry.id === afterEventId);
565
+ // Cursor present → everything after it. Cursor lost (log rotated/compacted) → fall back to
566
+ // the full window rather than silently skipping; the char cap below still bounds it.
567
+ slice = index >= 0 ? sorted.slice(index + 1) : sorted;
568
+ }
569
+ const lines = [];
570
+ let total = 0;
571
+ let boundaryId = afterEventId;
572
+ let capped = false;
573
+ for (const entry of slice) {
574
+ const role = entry.role ? `${entry.role}: ` : "";
575
+ let line = `[${entry.createdAt ?? "unknown"}] ${entry.type ?? "memory"} ${role}${entry.content ?? ""}`;
576
+ // A single oversized event (e.g. a giant tool output) is truncated so we always make progress.
577
+ if (line.length > cap)
578
+ line = line.slice(0, cap);
579
+ if (lines.length > 0 && total + line.length + 1 > cap) {
580
+ capped = true;
581
+ break;
582
+ }
583
+ lines.push(line);
584
+ total += line.length + 1;
585
+ boundaryId = entry.id ?? boundaryId;
586
+ }
587
+ return { text: lines.join("\n"), lastEventId: boundaryId, capped };
588
+ }
589
+ async applyProcessedMemory(memory, source = {}, modelEntities) {
590
+ const summary = [
591
+ "# Project Summary",
592
+ "",
593
+ `Project: ${basename(this.projectPath)}`,
594
+ "",
595
+ "## AI Summary",
596
+ memory.summary || "No AI summary produced.",
597
+ ""
598
+ ];
599
+ await writeFile(join(this.memoryDir, "brain", "project-summary.md"), `${summary.join("\n")}\n`, "utf8");
600
+ await this.appendList("brain/decisions.md", memory.decisions);
601
+ await this.appendList("brain/preferences.md", memory.preferences);
602
+ await this.appendList("brain/open-questions.md", memory.openQuestions);
603
+ await this.appendList("brain/artifacts.md", memory.artifacts);
604
+ await this.appendList("brain/timeline.md", memory.timeline);
605
+ return this.applyStructuredMemory(memory, source, modelEntities);
606
+ }
607
+ /**
608
+ * Merge near-duplicate ACTIVE records by embedding similarity. Models sometimes
609
+ * record the same belief twice (e.g. a supersede replacement AND a paraphrase in
610
+ * decisions[]); lexical dedup misses these because the wording differs. With real
611
+ * (API) embeddings this catches the paraphrase and keeps a single current truth.
612
+ * No-op when embeddings are unavailable. supersededBy links to a merged-away id
613
+ * are re-pointed at the surviving record so history stays intact.
614
+ */
615
+ async mergeSimilarActiveRecords(records, threshold = 0.9) {
616
+ if (!this.embeddingClient || !this.embeddingStore)
617
+ return { records, merged: 0 };
618
+ let vectorById;
619
+ try {
620
+ vectorById = (await this.embeddingStore.sync(records, this.embeddingClient)).vectorById;
621
+ }
622
+ catch {
623
+ return { records, merged: 0 };
624
+ }
625
+ if (vectorById.size === 0)
626
+ return { records, merged: 0 };
627
+ const active = records.filter((record) => record.status === "active");
628
+ const passthrough = records.filter((record) => record.status !== "active");
629
+ const kept = [];
630
+ const retired = []; // merged-away losers, kept recoverable (not destroyed)
631
+ const remap = new Map();
632
+ const mergeNow = new Date().toISOString();
633
+ let merged = 0;
634
+ for (const record of active) {
635
+ const vec = vectorById.get(record.id);
636
+ let matchIndex = -1;
637
+ if (vec) {
638
+ for (let i = 0; i < kept.length; i += 1) {
639
+ if (kept[i].type !== record.type)
640
+ continue;
641
+ const other = vectorById.get(kept[i].id);
642
+ if (other && cosineSimilarity(vec, other) >= threshold) {
643
+ matchIndex = i;
644
+ break;
645
+ }
646
+ }
647
+ }
648
+ if (matchIndex === -1) {
649
+ kept.push(record);
650
+ continue;
651
+ }
652
+ const other = kept[matchIndex];
653
+ const canonical = recordStrength(record) > recordStrength(other) ? record : other;
654
+ const loser = canonical === record ? other : record;
655
+ kept[matchIndex] = {
656
+ ...canonical,
657
+ score: {
658
+ importance: Math.max(record.score.importance, other.score.importance),
659
+ confidence: Math.max(record.score.confidence, other.score.confidence)
660
+ },
661
+ entities: unique([...record.entities, ...other.entities]),
662
+ updatedAt: record.updatedAt > other.updatedAt ? record.updatedAt : other.updatedAt
663
+ };
664
+ remap.set(loser.id, canonical.id);
665
+ // Recoverable-loser rule: don't destroy the merged-away belief — retire it as superseded,
666
+ // linked to the survivor. It leaves active recall but its content stays recoverable and
667
+ // history stays intact (a still-true minority phrasing is never silently lost).
668
+ retired.push({ ...loser, status: "superseded", supersededBy: canonical.id, updatedAt: mergeNow });
669
+ merged += 1;
670
+ }
671
+ // Resolve remap to a FIXED POINT: in a multi-level chain (A→B then B→C) a one-hop remap would
672
+ // leave A pointing at the now-merged-away B. Follow the chain (cycle-guarded) to the survivor.
673
+ const resolveRemap = (id) => {
674
+ let cur = id;
675
+ const seen = new Set();
676
+ while (remap.has(cur) && !seen.has(cur)) {
677
+ seen.add(cur);
678
+ cur = remap.get(cur);
679
+ }
680
+ return cur;
681
+ };
682
+ const fixed = [...passthrough, ...retired].map((record) => record.supersededBy && remap.has(record.supersededBy)
683
+ ? { ...record, supersededBy: resolveRemap(record.supersededBy) }
684
+ : record);
685
+ return { records: [...kept, ...fixed], merged };
686
+ }
687
+ async readProcessingState() {
688
+ const raw = await readFile(join(this.memoryDir, "brain", "processing-state.json"), "utf8").catch(() => "");
689
+ if (!raw.trim())
690
+ return {};
691
+ try {
692
+ return JSON.parse(raw);
693
+ }
694
+ catch {
695
+ return {};
696
+ }
697
+ }
698
+ async writeProcessingState(state) {
699
+ await writeFile(join(this.memoryDir, "brain", "processing-state.json"), `${JSON.stringify(state, null, 2)}\n`, "utf8");
700
+ }
701
+ async ensureLayout() {
702
+ // A brand-new brain is born ROOTED: the `.peon/root` marker makes it a self-contained CHILD
703
+ // brain under Peon's global (parent) brain, so the topmost-climb rule can never swallow a new
704
+ // project into an ancestor catch-all again (the old "Documents became the main brain" failure).
705
+ const fresh = !existsSync(join(this.memoryDir, "brain", "memories.jsonl"));
706
+ await mkdir(join(this.memoryDir, "raw"), { recursive: true });
707
+ await mkdir(join(this.memoryDir, "brain"), { recursive: true });
708
+ await mkdir(join(this.memoryDir, "sessions"), { recursive: true });
709
+ await Promise.all([
710
+ this.ensureFile("raw/events.jsonl", ""),
711
+ this.ensureFile("raw/messages.jsonl", ""),
712
+ this.ensureFile("raw/tool-calls.jsonl", ""),
713
+ this.ensureFile("brain/project-summary.md", `# Project Summary\n\nProject: ${basename(this.projectPath)}\n`),
714
+ this.ensureFile("brain/decisions.md", "# Decisions\n"),
715
+ this.ensureFile("brain/preferences.md", "# Preferences\n"),
716
+ this.ensureFile("brain/open-questions.md", "# Open Questions\n"),
717
+ this.ensureFile("brain/artifacts.md", "# Artifacts\n"),
718
+ this.ensureFile("brain/timeline.md", "# Timeline\n"),
719
+ this.ensureFile("brain/memories.jsonl", ""),
720
+ this.ensureFile("brain/graph.json", JSON.stringify(emptyGraph(), null, 2) + "\n"),
721
+ this.ensureFile("brain/processing-state.json", "{}\n")
722
+ ]);
723
+ if (fresh) {
724
+ await writeFile(join(this.memoryDir, "root"), "brain boundary - child brain of the Peon global brain\n", "utf8").catch(() => undefined);
725
+ }
726
+ }
727
+ async ensureFile(relativePath, content) {
728
+ const path = join(this.memoryDir, relativePath);
729
+ try {
730
+ await readFile(path, "utf8");
731
+ }
732
+ catch {
733
+ await writeFile(path, content, "utf8");
734
+ }
735
+ }
736
+ requireSession(sessionId) {
737
+ const session = this.sessions.get(sessionId);
738
+ if (!session)
739
+ throw new Error(`Unknown Peon session: ${sessionId}`);
740
+ return session;
741
+ }
742
+ async record(input) {
743
+ this.requireSession(input.sessionId);
744
+ const event = {
745
+ id: crypto.randomUUID(),
746
+ sessionId: input.sessionId,
747
+ type: input.type,
748
+ content: input.content,
749
+ role: input.role,
750
+ createdAt: new Date().toISOString()
751
+ };
752
+ await this.appendTimeline(event);
753
+ return event;
754
+ }
755
+ async updateBrain(event) {
756
+ if (event.type === "decision") {
757
+ await this.appendMarkdown("brain/decisions.md", `- ${event.content}\n`);
758
+ return;
759
+ }
760
+ if (event.type === "preference") {
761
+ await this.appendMarkdown("brain/preferences.md", `- ${event.content}\n`);
762
+ return;
763
+ }
764
+ if (event.type === "open_question") {
765
+ await this.appendMarkdown("brain/open-questions.md", `- ${event.content}\n`);
766
+ }
767
+ }
768
+ async appendTimeline(event) {
769
+ await this.appendMarkdown("brain/timeline.md", `- ${event.createdAt} [${event.type}] ${event.content}\n`);
770
+ }
771
+ async writeSessionSummary(session) {
772
+ // Read this session's events from disk (not an in-memory buffer) so the
773
+ // summary is correct even when the session was rehydrated after a restart.
774
+ const [messages, events] = await Promise.all([
775
+ this.readJsonl("raw/messages.jsonl"),
776
+ this.readJsonl("raw/events.jsonl")
777
+ ]);
778
+ const sessionEvents = [...messages, ...events]
779
+ .filter((entry) => entry.sessionId === session.id)
780
+ .sort((left, right) => String(left.createdAt ?? "").localeCompare(String(right.createdAt ?? "")));
781
+ const lines = [
782
+ `# Session ${session.id}`,
783
+ "",
784
+ `Client: ${session.client}`,
785
+ `Started: ${session.startedAt}`,
786
+ `Ended: ${session.endedAt ?? ""}`,
787
+ "",
788
+ "## Events",
789
+ ...sessionEvents.map((entry) => `- [${entry.type ?? "event"}] ${entry.content ?? ""}`)
790
+ ];
791
+ await writeFile(join(this.memoryDir, "sessions", `${session.id}.md`), `${lines.join("\n")}\n`, "utf8");
792
+ }
793
+ async readBrainFile(filename) {
794
+ return readFile(join(this.memoryDir, "brain", filename), "utf8");
795
+ }
796
+ async applyStructuredMemory(memory, source, modelEntities) {
797
+ const modelEntitiesFor = (content) => modelEntities?.get(content.trim()) ?? [];
798
+ const existing = await this.readMemoryRecords();
799
+ const existingByKey = new Map(existing.map((record) => [memoryKey(record.type, record.content), record]));
800
+ const byId = new Map(existing.map((record) => [record.id, record]));
801
+ const now = new Date().toISOString();
802
+ let superseded = 0;
803
+ let obsoleted = 0;
804
+ let added = 0;
805
+ // Reconciliation pre-pass: apply supersede/obsolete operations against existing
806
+ // records BEFORE the add loop. A supersede flips the old record to "superseded"
807
+ // and pushes its replacement onto the add channel, so the new belief flows
808
+ // through the same upsert path (no duplicate add code). Operations are already
809
+ // validated by parseProcessedMemory; here we guard existence, idempotency, and
810
+ // self-supersession, and silently drop anything unresolved (never throw).
811
+ const additions = [...processedMemoryToRecords(memory)];
812
+ for (const operation of memory.operations ?? []) {
813
+ const target = byId.get(operation.targetId);
814
+ if (!target)
815
+ continue; // unknown / hallucinated id → drop (degrade to add-only)
816
+ if (target.status === "superseded")
817
+ continue; // idempotency: already settled
818
+ if (operation.op === "obsolete") {
819
+ target.status = "superseded";
820
+ target.supersededBy = undefined;
821
+ target.updatedAt = now;
822
+ obsoleted += 1;
823
+ continue;
824
+ }
825
+ // op === "supersede"
826
+ const replacement = operation.replacement;
827
+ if (!replacement || typeof replacement.content !== "string" || !replacement.content.trim())
828
+ continue;
829
+ const replacementId = stableMemoryId(replacement.type, replacement.content);
830
+ if (replacementId === target.id)
831
+ continue; // self-supersede → no-op
832
+ target.status = "superseded";
833
+ target.supersededBy = replacementId;
834
+ target.updatedAt = now;
835
+ superseded += 1;
836
+ additions.push(replacement);
837
+ }
838
+ for (const input of additions) {
839
+ const key = memoryKey(input.type, input.content);
840
+ const current = existingByKey.get(key);
841
+ if (current) {
842
+ current.updatedAt = now;
843
+ current.score = mergeScore(current.score, scoreMemory(input));
844
+ current.entities = unique([...current.entities, ...inferEntities(input.content, [...(input.entities ?? []), ...modelEntitiesFor(input.content)])]);
845
+ // Re-affirmation revives a retired belief: an explicit re-add of content
846
+ // that was previously superseded/stale/conflicted means it is current
847
+ // again, so bring it back to active and drop any stale supersede link.
848
+ if (current.status !== "active") {
849
+ current.status = "active";
850
+ current.supersededBy = undefined;
851
+ }
852
+ existingByKey.set(key, current);
853
+ continue;
854
+ }
855
+ const score = scoreMemory(input);
856
+ existingByKey.set(key, {
857
+ id: stableMemoryId(input.type, input.content),
858
+ type: input.type,
859
+ content: input.content.trim(),
860
+ normalized: normalizeMemory(input.content),
861
+ scope: input.scope ?? "project",
862
+ status: input.status ?? "active",
863
+ score,
864
+ source: {
865
+ kind: "ai_processing",
866
+ reason: source.reason
867
+ },
868
+ entities: inferEntities(input.content, [...(input.entities ?? []), ...modelEntitiesFor(input.content)]),
869
+ provenance: deriveProvenance(input.content, now),
870
+ createdAt: now,
871
+ updatedAt: now
872
+ });
873
+ added += 1;
874
+ }
875
+ const records = Array.from(existingByKey.values()).sort((left, right) => left.type === right.type ? left.content.localeCompare(right.content) : left.type.localeCompare(right.type));
876
+ await this.replaceMemoryRecords(records);
877
+ return { superseded, obsoleted, added };
878
+ }
879
+ async readMemoryRecords() {
880
+ return (await this.readJsonl("brain/memories.jsonl")).flatMap((value) => {
881
+ if (isMemoryRecord(value))
882
+ return [value];
883
+ return [];
884
+ });
885
+ }
886
+ async readMemoryGraph() {
887
+ const raw = await readFile(join(this.memoryDir, "brain", "graph.json"), "utf8").catch(() => "");
888
+ if (!raw.trim())
889
+ return emptyGraph();
890
+ try {
891
+ return JSON.parse(raw);
892
+ }
893
+ catch {
894
+ return emptyGraph();
895
+ }
896
+ }
897
+ async appendJsonl(relativePath, value) {
898
+ await this.appendMarkdown(relativePath, `${JSON.stringify(value)}\n`);
899
+ }
900
+ async appendMarkdown(relativePath, content) {
901
+ await appendFile(join(this.memoryDir, relativePath), content, "utf8");
902
+ }
903
+ async appendList(relativePath, items) {
904
+ if (items.length === 0)
905
+ return;
906
+ await this.appendMarkdown(relativePath, items.map((item) => `- ${item}`).join("\n") + "\n");
907
+ }
908
+ async readJsonl(relativePath) {
909
+ const raw = await readFile(join(this.memoryDir, relativePath), "utf8").catch(() => "");
910
+ return raw
911
+ .split(/\r?\n/)
912
+ .map((line) => line.trim())
913
+ .filter(Boolean)
914
+ .flatMap((line) => {
915
+ try {
916
+ return [JSON.parse(line)];
917
+ }
918
+ catch {
919
+ return [];
920
+ }
921
+ });
922
+ }
923
+ }
924
+ function normalizeContextBudget(maxChars) {
925
+ if (maxChars === undefined || !Number.isFinite(maxChars))
926
+ return 24000;
927
+ return Math.min(Math.max(Math.floor(maxChars), 4000), 50000);
928
+ }
929
+ function compactMemoryText(text, options) {
930
+ if (text.length <= options.maxChars)
931
+ return text;
932
+ const lines = text
933
+ .split(/\r?\n/)
934
+ .map((line) => line.trimEnd())
935
+ .filter((line) => line.trim().length > 0)
936
+ .filter(isUsefulContextLine);
937
+ const header = lines.slice(0, Math.min(6, lines.length));
938
+ const terms = contextTerms(options.query);
939
+ const matches = terms.length > 0
940
+ ? lines.filter((line) => {
941
+ const normalized = line.toLowerCase();
942
+ return terms.some((term) => normalized.includes(term));
943
+ })
944
+ : [];
945
+ const recent = lines.slice(-40);
946
+ const selected = uniqueLines([
947
+ ...header,
948
+ `## Compacted ${options.title}`,
949
+ ...(matches.length > 0 ? ["### Query Matches", ...matches.slice(-24)] : []),
950
+ "### Recent Entries",
951
+ ...recent
952
+ ]);
953
+ const compacted = selected.join("\n");
954
+ if (compacted.length <= options.maxChars) {
955
+ return `${compacted}\n`;
956
+ }
957
+ const half = Math.floor(options.maxChars * 0.45);
958
+ return `${compacted.slice(0, half)}\n\n[...compacted...]\n\n${compacted.slice(-half)}\n`;
959
+ }
960
+ function contextTerms(query) {
961
+ if (!query)
962
+ return [];
963
+ return query
964
+ .toLowerCase()
965
+ .split(/[^a-z0-9_.-]+/)
966
+ .filter((term) => term.length >= 3)
967
+ .slice(0, 12);
968
+ }
969
+ function isUsefulContextLine(line) {
970
+ return ![
971
+ "mcp__peon__get_context",
972
+ "mcp__peon__start_session",
973
+ '"query":"select:mcp__peon',
974
+ "Output: Error: result",
975
+ "total_deferred_tools"
976
+ ].some((needle) => line.includes(needle));
977
+ }
978
+ function uniqueLines(lines) {
979
+ const seen = new Set();
980
+ return lines.filter((line) => {
981
+ if (seen.has(line))
982
+ return false;
983
+ seen.add(line);
984
+ return true;
985
+ });
986
+ }
987
+ function processedMemoryToRecords(memory) {
988
+ return [
989
+ ...(memory.summary.trim() ? [{ type: "summary", content: memory.summary }] : []),
990
+ ...memory.decisions.map((content) => ({ type: "decision", content })),
991
+ ...memory.preferences.map((content) => ({ type: "preference", content })),
992
+ ...memory.openQuestions.map((content) => ({ type: "open_question", content })),
993
+ ...memory.artifacts.map((content) => ({ type: "artifact", content })),
994
+ ...memory.timeline.map((content) => ({ type: "timeline", content })),
995
+ ...(memory.memories ?? [])
996
+ ].filter((record) => record.content.trim().length > 0);
997
+ }
998
+ /**
999
+ * Render a query-ranked slice of beliefs into a compact, titled section that fits `maxChars`.
1000
+ * Used to build query-FOCUSED context sections (one per belief type) instead of dumping whole
1001
+ * brain .md files. Emits content-only bullets (no importance/confidence noise) to save tokens;
1002
+ * stops as soon as the next bullet would exceed the budget. Empty input → empty string, so an
1003
+ * irrelevant section drops out of the injection entirely.
1004
+ */
1005
+ function formatContextRecords(ranked, maxChars, title) {
1006
+ if (ranked.length === 0 || maxChars <= 0)
1007
+ return "";
1008
+ const header = `# ${title}\n`;
1009
+ let out = header;
1010
+ for (const { record } of ranked) {
1011
+ const line = `- ${record.content}\n`;
1012
+ if (out.length + line.length > maxChars)
1013
+ break;
1014
+ out += line;
1015
+ }
1016
+ return out === header ? "" : out;
1017
+ }
1018
+ /**
1019
+ * Format top episodes as WHOLE turns up to a char budget (truncating only the last). Unlike the
1020
+ * belief sections we deliberately do NOT run compactMemoryText here — episodes are verbatim
1021
+ * answers (e.g. "the professor's 3 ideas"); fragmenting them defeats the purpose. Better to show
1022
+ * a few complete turns than slivers of many.
1023
+ */
1024
+ function formatEpisodes(ranked, maxChars) {
1025
+ if (ranked.length === 0 || maxChars <= 0)
1026
+ return "";
1027
+ let out = "# Episodic Recall\n";
1028
+ for (const { record } of ranked) {
1029
+ if (out.length >= maxChars)
1030
+ break;
1031
+ const remaining = maxChars - out.length;
1032
+ const body = record.content.length > remaining ? `${record.content.slice(0, remaining - 1)}…` : record.content;
1033
+ out += `- [${record.createdAt}] ${body}\n`;
1034
+ }
1035
+ return out === "# Episodic Recall\n" ? "" : out;
1036
+ }
1037
+ function formatRankedMemoryRecords(ranked) {
1038
+ if (ranked.length === 0)
1039
+ return "";
1040
+ return [
1041
+ "# Structured Memory",
1042
+ ...ranked.map(({ record }) => {
1043
+ const score = `importance=${record.score.importance.toFixed(2)} confidence=${record.score.confidence.toFixed(2)}`;
1044
+ const entities = record.entities.length > 0 ? ` entities=${record.entities.join(",")}` : "";
1045
+ return `- [${record.type}] ${record.content} (${score} status=${record.status}${entities})`;
1046
+ })
1047
+ ].join("\n") + "\n";
1048
+ }
1049
+ function formatInjectionPreview(context) {
1050
+ return [
1051
+ "Peon Relevant Memory",
1052
+ context.summary.trim(),
1053
+ context.memories.trim(),
1054
+ context.decisions.trim(),
1055
+ context.preferences.trim(),
1056
+ context.openQuestions.trim(),
1057
+ context.artifacts.trim(),
1058
+ context.timeline.trim()
1059
+ ]
1060
+ .filter(Boolean)
1061
+ .join("\n\n")
1062
+ .slice(0, 6000);
1063
+ }
1064
+ function recordStrength(record) {
1065
+ return record.score.importance + record.score.confidence;
1066
+ }
1067
+ function scoreMemory(input) {
1068
+ const baseImportance = {
1069
+ summary: 0.75,
1070
+ decision: 0.9,
1071
+ preference: 0.75,
1072
+ open_question: 0.65,
1073
+ artifact: 0.8,
1074
+ timeline: 0.55,
1075
+ fact: 0.7
1076
+ };
1077
+ return {
1078
+ importance: clamp(input.importance ?? baseImportance[input.type] ?? 0.6),
1079
+ confidence: clamp(input.confidence ?? 0.82)
1080
+ };
1081
+ }
1082
+ function mergeScore(left, right) {
1083
+ return {
1084
+ importance: clamp(Math.max(left.importance, right.importance)),
1085
+ confidence: clamp(Math.max(left.confidence, right.confidence))
1086
+ };
1087
+ }
1088
+ function clamp(value) {
1089
+ return Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0));
1090
+ }
1091
+ function memoryKey(type, content) {
1092
+ return `${type}:${normalizeMemory(content)}`;
1093
+ }
1094
+ function normalizeMemory(content) {
1095
+ return content
1096
+ .toLowerCase()
1097
+ .replace(/[`"'.,;:!?()[\]{}]/g, "")
1098
+ .replace(/\s+/g, " ")
1099
+ .trim();
1100
+ }
1101
+ function stableMemoryId(type, content) {
1102
+ return `mem_${type}_${fnv1a(memoryKey(type, content))}`;
1103
+ }
1104
+ /**
1105
+ * The id a record gets for a given (type, content) — content-derived and stable.
1106
+ * Exported so a supersede operation's `targetId` can be computed deterministically
1107
+ * (e.g. in tests) without first reading the record back.
1108
+ */
1109
+ export function memoryRecordId(type, content) {
1110
+ return stableMemoryId(type, content);
1111
+ }
1112
+ function nodeId(type, label) {
1113
+ return `node_${type}_${fnv1a(`${type}:${normalizeMemory(label)}`)}`;
1114
+ }
1115
+ function fnv1a(value) {
1116
+ let hash = 0x811c9dc5;
1117
+ for (let index = 0; index < value.length; index += 1) {
1118
+ hash ^= value.charCodeAt(index);
1119
+ hash = Math.imul(hash, 0x01000193);
1120
+ }
1121
+ return (hash >>> 0).toString(16).padStart(8, "0");
1122
+ }
1123
+ /** Canonical entities mentioned in content (deterministic resolver — see entities.ts). */
1124
+ function inferEntities(content, extra = []) {
1125
+ return inferCanonicalEntities(content, extra);
1126
+ }
1127
+ /**
1128
+ * Source pointer for a belief so the agent can fetch GROUND TRUTH for exact specifics
1129
+ * (the "professor's email" lesson — beliefs are a lossy gist; the source has the detail).
1130
+ * Prefers an explicit external ref in the content (URL > file), else falls back to the
1131
+ * episodic time anchor (query the raw layer around capturedAt for the verbatim turns).
1132
+ */
1133
+ function deriveProvenance(content, capturedAt) {
1134
+ const url = content.match(/https?:\/\/[^\s)]+/);
1135
+ if (url)
1136
+ return { kind: "url", ref: url[0], capturedAt };
1137
+ const file = content.match(/[\w./-]+\.(?:ts|tsx|js|jsx|py|md|json|ipynb|pdf|sql|sh|yaml|yml)\b/);
1138
+ if (file) {
1139
+ // Canonicalize so the ref isn't a phantom ("2/peon-mcp/...") or an absolute path.
1140
+ const canonical = canonicalizeEntity(file[0]);
1141
+ if (canonical)
1142
+ return { kind: "file", ref: canonical.key, capturedAt };
1143
+ }
1144
+ return { kind: "episodic", ref: capturedAt, capturedAt };
1145
+ }
1146
+ function buildMemoryGraph(projectName, records) {
1147
+ const projectNode = { id: nodeId("project", projectName), type: "project", label: projectName };
1148
+ const nodes = new Map([[projectNode.id, projectNode]]);
1149
+ const edges = new Map();
1150
+ // Canonical entity registry: collapses alias forms (daemon.ts → src/daemon.ts) and tags
1151
+ // each entity's namespace so one file is ONE node and traversal can weight code vs domain.
1152
+ const { canonical } = buildEntityRegistry(records.flatMap((record) => record.entities));
1153
+ const registryNamespace = new Map();
1154
+ for (const record of records) {
1155
+ for (const raw of record.entities) {
1156
+ const c = canonicalizeEntity(raw);
1157
+ if (c)
1158
+ registryNamespace.set(canonical.get(raw) ?? c.key, c.namespace);
1159
+ }
1160
+ }
1161
+ for (const record of records) {
1162
+ const memoryNode = { id: nodeId(record.type, record.content), type: record.type, label: record.content };
1163
+ nodes.set(memoryNode.id, memoryNode);
1164
+ const projectEdgeType = record.type === "artifact" ? "produced" : "contains";
1165
+ edges.set(`${projectNode.id}:${memoryNode.id}:${projectEdgeType}`, {
1166
+ from: projectNode.id,
1167
+ to: memoryNode.id,
1168
+ type: projectEdgeType
1169
+ });
1170
+ for (const raw of unique(record.entities.map((e) => canonical.get(e) ?? e))) {
1171
+ const entityNode = { id: nodeId("entity", raw), type: "entity", label: raw, namespace: registryNamespace.get(raw) ?? "code" };
1172
+ nodes.set(entityNode.id, entityNode);
1173
+ edges.set(`${memoryNode.id}:${entityNode.id}:mentions`, {
1174
+ from: memoryNode.id,
1175
+ to: entityNode.id,
1176
+ type: "mentions"
1177
+ });
1178
+ }
1179
+ }
1180
+ return {
1181
+ nodes: Array.from(nodes.values()).sort((left, right) => left.id.localeCompare(right.id)),
1182
+ edges: Array.from(edges.values()).sort((left, right) => `${left.from}:${left.to}:${left.type}`.localeCompare(`${right.from}:${right.to}:${right.type}`))
1183
+ };
1184
+ }
1185
+ function emptyGraph() {
1186
+ return { nodes: [], edges: [] };
1187
+ }
1188
+ function isMemoryRecord(value) {
1189
+ if (!value || typeof value !== "object")
1190
+ return false;
1191
+ const record = value;
1192
+ return (typeof record.id === "string" &&
1193
+ typeof record.type === "string" &&
1194
+ typeof record.content === "string" &&
1195
+ typeof record.normalized === "string" &&
1196
+ typeof record.scope === "string" &&
1197
+ typeof record.status === "string" &&
1198
+ typeof record.createdAt === "string" &&
1199
+ typeof record.updatedAt === "string" &&
1200
+ typeof record.score === "object" &&
1201
+ record.score !== null);
1202
+ }
1203
+ function unique(values) {
1204
+ return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
1205
+ }