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,450 @@
1
+ import { PeonMemoryStore } from "./memory-store.js";
2
+ import { loadPeonConfig } from "./config.js";
3
+ import { createQualityReport } from "./quality.js";
4
+ import { extractDomainEntitiesViaModel } from "./entity-extraction.js";
5
+ export class PeonMemoryProcessor {
6
+ config;
7
+ modelClient;
8
+ constructor(options = {}) {
9
+ this.config = options.config ?? loadPeonConfig();
10
+ this.modelClient = options.modelClient ?? new OpenRouterMemoryModelClient();
11
+ }
12
+ async processMemory(input) {
13
+ const store = await PeonMemoryStore.open({
14
+ projectPath: input.projectPath,
15
+ memoryDirName: this.config.memoryDirName
16
+ });
17
+ const priorState = await store.readProcessingState();
18
+ // Consolidate only NEW experience (the delta), aware of EXISTING beliefs.
19
+ const { text: deltaMemory, lastEventId, capped } = await store.readRawMemoryDelta(priorState.lastProcessedEventId);
20
+ const existingMemory = formatExistingMemory(await store.listMemoryRecords());
21
+ const fullRawChars = (await store.readRawMemory(Number.MAX_SAFE_INTEGER)).length;
22
+ const reason = input.reason ?? "manual";
23
+ const modelResult = input.aiResult
24
+ ? {
25
+ content: JSON.stringify(input.aiResult),
26
+ model: "manual-ai-result",
27
+ estimatedTokens: 0
28
+ }
29
+ : await this.modelClient.processMemory({ rawMemory: deltaMemory, existingMemory, config: this.config, reason });
30
+ const processed = parseProcessedMemory(modelResult.content);
31
+ // Model-grade DOMAIN entity extraction (people/papers/methods/datasets) over the new beliefs —
32
+ // OUTSIDE the lock (it's a network call). Merged with the deterministic resolver in apply.
33
+ // No-ops (empty map) when AI is off / no key, so tests + offline keep the deterministic path.
34
+ const beliefContents = [
35
+ ...processed.decisions, ...processed.preferences, ...processed.openQuestions,
36
+ ...processed.artifacts, ...processed.timeline, ...(processed.memories ?? []).map((m) => m.content)
37
+ ].filter((c) => typeof c === "string" && c.trim().length > 0);
38
+ const modelEntities = await extractDomainEntitiesViaModel([...new Set(beliefContents)].map((c) => ({ key: c.trim(), content: c })), { config: this.config });
39
+ // Whole apply→merge→persist runs as ONE serialized transaction so an overlapping
40
+ // consolidation (turn-end vs session-end vs heartbeat) can't lost-update the brain.
41
+ // The LLM calls above are intentionally OUTSIDE the lock — only the write section serializes.
42
+ const { applyStats, merged } = await store.runExclusive(async () => {
43
+ const applyStats = await store.applyProcessedMemory(processed, { reason }, modelEntities);
44
+ const quality = createQualityReport(await store.listMemoryRecords());
45
+ // Collapse near-duplicate active beliefs (e.g. a supersede replacement and a
46
+ // paraphrase the model also dropped into decisions[]) into a single truth.
47
+ const { records: curated, merged } = await store.mergeSimilarActiveRecords(quality.records);
48
+ await store.replaceMemoryRecords(curated);
49
+ // Persist the POST-merge report — the pre-merge one overcounts and references merged-away ids.
50
+ await store.writeQualityReport(merged > 0 ? createQualityReport(curated) : quality);
51
+ return { applyStats, merged };
52
+ });
53
+ const stats = {
54
+ operationsEmitted: (processed.operations ?? []).length,
55
+ superseded: applyStats.superseded,
56
+ obsoleted: applyStats.obsoleted,
57
+ recordsAdded: applyStats.added,
58
+ merged
59
+ };
60
+ await store.writeProcessingState({
61
+ ...(await store.readProcessingState()),
62
+ lastStatus: "processed",
63
+ lastTrigger: reason,
64
+ lastReason: reason,
65
+ lastProcessedAt: new Date().toISOString(),
66
+ // When the delta was capped we consumed only a chunk — advance the EVENT cursor to the chunk
67
+ // boundary (below) but DON'T advance the char-gate, so the next trigger keeps draining the
68
+ // backlog instead of deciding "nothing new".
69
+ lastProcessedRawChars: capped ? (priorState.lastProcessedRawChars ?? 0) : fullRawChars,
70
+ lastRawChars: fullRawChars,
71
+ lastProcessedEventId: lastEventId ?? priorState.lastProcessedEventId,
72
+ lastModel: modelResult.model,
73
+ lastEstimatedTokens: modelResult.estimatedTokens,
74
+ lastOperationsEmitted: stats.operationsEmitted,
75
+ lastSuperseded: stats.superseded,
76
+ lastObsoleted: stats.obsoleted,
77
+ lastMerged: stats.merged
78
+ });
79
+ return {
80
+ status: "processed",
81
+ model: modelResult.model,
82
+ estimatedTokens: modelResult.estimatedTokens,
83
+ applied: processed,
84
+ stats,
85
+ capped
86
+ };
87
+ }
88
+ async maybeProcessMemory(input) {
89
+ const store = await PeonMemoryStore.open({
90
+ projectPath: input.projectPath,
91
+ memoryDirName: this.config.memoryDirName
92
+ });
93
+ const rawMemory = await store.readRawMemory(Number.MAX_SAFE_INTEGER);
94
+ const state = await store.readProcessingState();
95
+ const decision = decideProcessing({
96
+ rawChars: rawMemory.length,
97
+ lastProcessedRawChars: state.lastProcessedRawChars ?? 0,
98
+ flushMinChars: this.config.flushMinChars,
99
+ trigger: input.trigger,
100
+ force: input.force ?? false,
101
+ aiMode: this.config.aiMode,
102
+ hasApiKey: Boolean(this.config.openRouterApiKey),
103
+ hasManualAiResult: Boolean(input.aiResult)
104
+ });
105
+ if (decision.action === "skip") {
106
+ await store.writeProcessingState({
107
+ ...state,
108
+ lastStatus: state.lastProcessedRawChars ? state.lastStatus : "skipped",
109
+ lastTrigger: input.trigger,
110
+ lastReason: state.lastProcessedRawChars ? state.lastReason : decision.reason,
111
+ lastRawChars: rawMemory.length,
112
+ lastSkippedAt: new Date().toISOString(),
113
+ lastSkipReason: decision.reason
114
+ });
115
+ return { status: "skipped", decision };
116
+ }
117
+ const result = await this.processMemory({
118
+ projectPath: input.projectPath,
119
+ reason: `auto:${input.trigger}:${decision.reason}`,
120
+ aiResult: input.aiResult
121
+ });
122
+ await store.writeProcessingState({
123
+ ...(await store.readProcessingState()),
124
+ lastStatus: "processed",
125
+ lastTrigger: input.trigger,
126
+ lastReason: decision.reason,
127
+ lastProcessedAt: new Date().toISOString(),
128
+ // Don't slam the char-gate shut if only a capped chunk was consumed — the event cursor
129
+ // (written by processMemory) advanced, but the remaining backlog must still re-trigger.
130
+ lastProcessedRawChars: result.capped ? (state.lastProcessedRawChars ?? 0) : rawMemory.length,
131
+ lastRawChars: rawMemory.length,
132
+ lastModel: result.model,
133
+ lastEstimatedTokens: result.estimatedTokens
134
+ });
135
+ return {
136
+ status: "processed",
137
+ decision,
138
+ result
139
+ };
140
+ }
141
+ }
142
+ export function decideProcessing(input) {
143
+ const newChars = Math.max(0, input.rawChars - input.lastProcessedRawChars);
144
+ const base = {
145
+ trigger: input.trigger,
146
+ rawChars: input.rawChars,
147
+ newChars,
148
+ flushMinChars: input.flushMinChars,
149
+ estimatedTokens: estimateTokensByChars(newChars)
150
+ };
151
+ if (input.rawChars === 0) {
152
+ return { ...base, action: "skip", reason: "empty_memory" };
153
+ }
154
+ if (!input.force && newChars < input.flushMinChars) {
155
+ return { ...base, action: "skip", reason: "below_threshold" };
156
+ }
157
+ if (input.aiMode === "off" && !input.hasManualAiResult) {
158
+ return { ...base, action: "skip", reason: "ai_disabled" };
159
+ }
160
+ if (!input.hasApiKey && !input.hasManualAiResult) {
161
+ return { ...base, action: "skip", reason: "missing_api_key" };
162
+ }
163
+ return { ...base, action: "process", reason: input.force ? "forced" : "threshold_reached" };
164
+ }
165
+ export class OpenRouterMemoryModelClient {
166
+ async processMemory(input) {
167
+ if (input.config.aiMode === "off") {
168
+ throw new Error("Peon AI processing is disabled by PEON_AI_MODE=off.");
169
+ }
170
+ if (!input.config.llmApiKey && input.config.provider !== "ollama") {
171
+ throw new Error("An LLM API key is required for Peon AI processing (PEON_API_KEY / OPENROUTER_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY).");
172
+ }
173
+ if (!input.rawMemory.trim()) {
174
+ return {
175
+ content: JSON.stringify(emptyProcessedMemory("No new session activity to consolidate.")),
176
+ model: input.config.processingModel,
177
+ estimatedTokens: 0
178
+ };
179
+ }
180
+ const systemPrompt = buildSystemPrompt();
181
+ const userBlocks = [`Processing reason: ${input.reason}`];
182
+ if (input.existingMemory && input.existingMemory.trim()) {
183
+ userBlocks.push("", "## Existing durable memory — reference these exact ids in any operation:", input.existingMemory.trim());
184
+ }
185
+ userBlocks.push("", "## New session log (the delta to consolidate):", input.rawMemory);
186
+ const userPrompt = userBlocks.join("\n");
187
+ if (input.config.provider === "anthropic")
188
+ return anthropicProcess(input, systemPrompt, userPrompt);
189
+ const response = await fetch(input.config.llmBaseUrl.replace(/\/$/, "") + "/chat/completions", {
190
+ method: "POST",
191
+ headers: {
192
+ Authorization: `Bearer ${input.config.llmApiKey ?? ""}`,
193
+ "Content-Type": "application/json"
194
+ },
195
+ body: JSON.stringify({
196
+ model: input.config.processingModel,
197
+ messages: [
198
+ { role: "system", content: systemPrompt },
199
+ { role: "user", content: userPrompt }
200
+ ],
201
+ temperature: 0.1,
202
+ // Reserve explicit output room. Without this, OpenRouter applies the provider's default
203
+ // completion cap, which — paired with the delta cap on the input side — keeps the JSON
204
+ // reply from truncating mid-object. Env-tunable for very large brains.
205
+ max_tokens: Number(process.env.PEON_CONSOLIDATION_MAX_TOKENS) || 8192
206
+ })
207
+ });
208
+ if (!response.ok) {
209
+ const body = await response.text().catch(() => "");
210
+ throw new Error(`OpenRouter memory processing failed with ${response.status}${body ? `: ${body}` : ""}`);
211
+ }
212
+ const json = (await response.json());
213
+ const content = json.choices?.[0]?.message?.content;
214
+ if (!content)
215
+ throw new Error("OpenRouter memory processing response did not include content.");
216
+ return {
217
+ content,
218
+ model: input.config.processingModel,
219
+ estimatedTokens: estimateTokens(systemPrompt) + estimateTokens(userPrompt) + estimateTokens(content)
220
+ };
221
+ }
222
+ }
223
+ export function parseProcessedMemory(content) {
224
+ const jsonText = extractJson(content);
225
+ // Throws on genuinely-unparseable output ON PURPOSE: the caller (processMemory) lets it
226
+ // propagate so the delta cursor is NOT advanced past data that was never consolidated — the
227
+ // batch is retried next run instead of being silently lost. The 500 that this used to surface
228
+ // to the SessionEnd hook is absorbed at the daemon boundary (runAutomaticProcessing logs an
229
+ // auto_process_fail and returns a failed result instead of re-throwing).
230
+ const parsed = JSON.parse(jsonText);
231
+ // Cross-cutting knowledge the model flagged becomes scope-"global" fact records,
232
+ // so it gets lifted into global memory after consolidation.
233
+ const globalRecords = stringArray(parsed.global).map((content) => ({
234
+ type: "fact",
235
+ content,
236
+ scope: "global"
237
+ }));
238
+ return {
239
+ summary: typeof parsed.summary === "string" ? parsed.summary : "",
240
+ decisions: stringArray(parsed.decisions),
241
+ preferences: stringArray(parsed.preferences),
242
+ openQuestions: stringArray(parsed.openQuestions),
243
+ artifacts: stringArray(parsed.artifacts),
244
+ timeline: stringArray(parsed.timeline),
245
+ memories: [...memoryRecordInputs(parsed.memories), ...globalRecords],
246
+ operations: operationInputs(parsed.operations)
247
+ };
248
+ }
249
+ /** Render the active durable memory the model reconciles against (id | type | content). */
250
+ function formatExistingMemory(records, limit = 40) {
251
+ const active = records.filter((record) => record.status === "active");
252
+ if (active.length === 0)
253
+ return "";
254
+ return active
255
+ .slice()
256
+ .sort((left, right) => right.score.importance - left.score.importance)
257
+ .slice(0, limit)
258
+ .map((record) => `${record.id} | ${record.type} | ${record.content}`)
259
+ .join("\n");
260
+ }
261
+ /** Sanitize consolidation operations; drop anything malformed (degrade to add-only). */
262
+ function operationInputs(value) {
263
+ if (!Array.isArray(value))
264
+ return [];
265
+ return value.flatMap((item) => {
266
+ if (!item || typeof item !== "object")
267
+ return [];
268
+ const op = item;
269
+ if (typeof op.targetId !== "string" || !op.targetId.trim())
270
+ return [];
271
+ const reason = typeof op.reason === "string" ? op.reason : undefined;
272
+ if (op.op === "obsolete") {
273
+ return [{ op: "obsolete", targetId: op.targetId, reason }];
274
+ }
275
+ if (op.op === "supersede") {
276
+ const [replacement] = memoryRecordInputs([op.replacement]);
277
+ if (!replacement)
278
+ return [];
279
+ return [{ op: "supersede", targetId: op.targetId, reason, replacement }];
280
+ }
281
+ return [];
282
+ });
283
+ }
284
+ function isJsonObject(candidate) {
285
+ try {
286
+ return JSON.parse(candidate) !== null && typeof JSON.parse(candidate) === "object";
287
+ }
288
+ catch {
289
+ return false;
290
+ }
291
+ }
292
+ function extractJson(content) {
293
+ const trimmed = content.trim();
294
+ // Models wrap the JSON in a ```json fence (or, in the wild, a ''' triple-single-quote fence),
295
+ // sometimes with prose around it, sometimes after a worked EXAMPLE block, and a fence can even
296
+ // appear inside a string value. So: collect every fenced block and return the LAST one whose body
297
+ // actually parses as a JSON object — never blindly grab the first fence (that lazily captured an
298
+ // example or a ``` inside a string and turned previously-working input into a parse failure).
299
+ // Accept both fence markers because some models emit '''json instead of ```json, which used to
300
+ // fall through to the brace-slice fallback and 500 when an EXAMPLE block was also present.
301
+ const fences = [...trimmed.matchAll(/(?:```|''')(?:json)?\s*([\s\S]*?)(?:```|''')/gi)].map((m) => m[1].trim());
302
+ for (let i = fences.length - 1; i >= 0; i--) {
303
+ if (isJsonObject(fences[i]))
304
+ return fences[i];
305
+ }
306
+ // No parseable fenced block: fall back to the outermost brace slice. This recovers both a bare
307
+ // JSON object surrounded by prose AND an object whose own string values contain literal ```.
308
+ const firstBrace = trimmed.indexOf("{");
309
+ const lastBrace = trimmed.lastIndexOf("}");
310
+ if (firstBrace >= 0 && lastBrace > firstBrace)
311
+ return trimmed.slice(firstBrace, lastBrace + 1);
312
+ return trimmed;
313
+ }
314
+ function stringArray(value) {
315
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
316
+ }
317
+ function memoryRecordInputs(value) {
318
+ if (!Array.isArray(value))
319
+ return [];
320
+ return value.flatMap((item) => {
321
+ if (!item || typeof item !== "object")
322
+ return [];
323
+ const input = item;
324
+ if (!isMemoryType(input.type) || typeof input.content !== "string" || input.content.trim().length === 0)
325
+ return [];
326
+ return [
327
+ {
328
+ type: input.type,
329
+ content: input.content,
330
+ scope: input.scope === "global" || input.scope === "session" || input.scope === "project" ? input.scope : undefined,
331
+ importance: typeof input.importance === "number" ? input.importance : undefined,
332
+ confidence: typeof input.confidence === "number" ? input.confidence : undefined,
333
+ entities: Array.isArray(input.entities) ? input.entities.filter((entity) => typeof entity === "string") : undefined,
334
+ status: isMemoryStatus(input.status) ? input.status : undefined
335
+ }
336
+ ];
337
+ });
338
+ }
339
+ function isMemoryType(value) {
340
+ return (value === "summary" ||
341
+ value === "decision" ||
342
+ value === "preference" ||
343
+ value === "open_question" ||
344
+ value === "artifact" ||
345
+ value === "timeline" ||
346
+ value === "fact");
347
+ }
348
+ function isMemoryStatus(value) {
349
+ return value === "active" || value === "stale" || value === "conflicted" || value === "superseded" || value === "archived";
350
+ }
351
+ function estimateTokens(text) {
352
+ return Math.max(1, Math.ceil(text.length / 4));
353
+ }
354
+ function estimateTokensByChars(chars) {
355
+ return Math.max(0, Math.ceil(chars / 4));
356
+ }
357
+ function emptyProcessedMemory(summary) {
358
+ return {
359
+ summary,
360
+ decisions: [],
361
+ preferences: [],
362
+ openQuestions: [],
363
+ artifacts: [],
364
+ timeline: []
365
+ };
366
+ }
367
+ /** Anthropic Messages API adapter — same contract as the OpenAI-compatible path. */
368
+ async function anthropicProcess(input, systemPrompt, userPrompt) {
369
+ const response = await fetch(input.config.llmBaseUrl.replace(/\/$/, "") + "/v1/messages", {
370
+ method: "POST",
371
+ headers: {
372
+ "x-api-key": input.config.llmApiKey ?? "",
373
+ "anthropic-version": "2023-06-01",
374
+ "Content-Type": "application/json"
375
+ },
376
+ body: JSON.stringify({
377
+ model: input.config.processingModel,
378
+ max_tokens: Number(process.env.PEON_CONSOLIDATION_MAX_TOKENS) || 8192,
379
+ system: systemPrompt,
380
+ messages: [{ role: "user", content: userPrompt }]
381
+ })
382
+ });
383
+ if (!response.ok) {
384
+ const body = await response.text().catch(() => "");
385
+ throw new Error(`Anthropic memory processing failed with ${response.status}${body ? `: ${body}` : ""}`);
386
+ }
387
+ const json = (await response.json());
388
+ const content = (json.content ?? []).map((c) => c.text ?? "").join("");
389
+ const estimatedTokens = (json.usage?.input_tokens ?? 0) + (json.usage?.output_tokens ?? 0);
390
+ return { content, model: input.config.processingModel, estimatedTokens };
391
+ }
392
+ function buildSystemPrompt() {
393
+ return `You are Peon, a local-first memory processor for AI coding and research sessions.
394
+
395
+ Your job: read a raw session log and extract ONLY durable, high-signal memory worth preserving across future sessions.
396
+
397
+ ## Output Format
398
+ Return ONLY a valid JSON object with exactly these keys:
399
+
400
+ {
401
+ "summary": "2-4 sentence narrative of what happened and what was learned. Must be useful to a future AI reading this cold.",
402
+ "decisions": ["array of firm decisions made — what was chosen and why, in one sentence each"],
403
+ "preferences": ["user workflow preferences, style choices, tooling preferences discovered"],
404
+ "openQuestions": ["unresolved questions, blockers, things still being figured out"],
405
+ "artifacts": ["important files created/modified — include path and purpose, e.g. 'peon-mcp/src/daemon.ts: background HTTP daemon for memory operations'"],
406
+ "timeline": ["key events in chronological order: what was attempted, what succeeded, what failed"],
407
+ "global": ["cross-cutting knowledge that applies to ALL of the user's projects, not just this one — see rules below"],
408
+ "operations": []
409
+ }
410
+
411
+ ## Rules
412
+ THE SIGNAL TEST governs every item: before emitting ANY belief, ask "will this still matter in a month, in a different session?" If no, drop it. A short empty result is better than noise.
413
+
414
+ - decisions: a durable CHOICE that shapes future work — an architecture/approach/tool selection, or a confirmed result/metric (e.g. "DTS-SQL hits 60.31% on BIRD"). Include rationale when present. NOT a one-time setup/operational ACTION — cloning a repo, installing deps, creating a directory, downloading weights, submitting/monitoring a job, confirming a job is pending, adding a warning filter. Those are ephemeral; EXCLUDE them.
415
+ - preferences: a STABLE working style the user will carry into FUTURE sessions ("prefers TDD", "wants terse updates", "always local-first"). NOT a one-off instruction for this task, and NOT a transient setting. If it only applies to this single session, drop it.
416
+ - openQuestions: genuinely unresolved AND still open at the END of this log, with enough context to resume. If the log later answers or abandons it, do NOT emit it.
417
+ - artifacts: files CENTRAL to the project's future (source modules, configs, key datasets/outputs) with "path: one-line purpose". Skip temp files, logs, scratch, and files merely read.
418
+ - timeline: 3-8 entries, non-obvious turning points only (a bug's root cause, an approach abandoned and why, an emergent decision). Skip routine steps.
419
+ - summary: 2-4 factual sentences for a reader with ZERO context — name the project, the goal, and the key outcome/state. No filler, no "the user asked".
420
+ - global: knowledge reusable across ALL the user's projects — compute environment (clusters, GPUs, hostnames, scratch paths), accounts/services, reusable references (dataset/doc locations), durable facts about the user and their tooling. EXCLUDE this project's OWN internals (its code, files, architecture, decisions) even when they sound general. Each item self-contained and usable cold in an UNRELATED project. Else [].
421
+
422
+ ## Hard constraints
423
+ - Output ONLY the JSON object — no markdown fences, no prose before or after it.
424
+ - Do NOT invent facts. A category with nothing durable → [].
425
+ - DEDUP: never emit two items that state the same fact in different words — keep exactly one.
426
+ - FIDELITY: preserve concrete specifics VERBATIM — exact numbers/metrics, proper names, enumerated steps, file/command/flag names. Do NOT generalize them away: keep "AskData hits 74% on BIRD; step 4 rewrites the NL to hide the predicate via GROUP BY", never "a method performs well". The specifics are what make a belief actionable; a vague gist is near-useless.
427
+ - Concise on the GIST, complete on the SPECIFICS: decisions/preferences ≤ 300 chars (use the room for the specifics above, not filler), timeline entries ≤ 150 chars.
428
+
429
+ ## Integrative Consolidation (reconcile, don't just append)
430
+ You may be shown an "Existing durable memory" block: lines of "id | type | content". These are beliefs already recorded. For each new belief in the session log, decide whether it is brand-new or whether it CHANGES an existing record, and use the "operations" array to reconcile:
431
+
432
+ - If a new belief REPLACES or PARTIALLY CONTRADICTS an existing record, emit a "supersede" op referencing that record's EXACT id, and put the full reconciled current truth in "replacement".
433
+ CRITICAL: the replacement IS the new record. Do NOT also repeat that belief in "decisions"/"preferences"/"memories" — that creates a duplicate. Each belief goes in exactly ONE place: an add channel OR a supersede replacement, never both.
434
+ { "op": "supersede", "targetId": "<exact id from existing memory>", "reason": "why it changed", "replacement": { "type": "decision", "content": "the new current truth" } }
435
+ - If an existing belief is simply no longer true and has no successor, emit "obsolete":
436
+ { "op": "obsolete", "targetId": "<exact id>", "reason": "why it's no longer true" }
437
+ - Only reference ids that appear VERBATIM in the existing memory block. NEVER invent an id. If you are unsure whether something supersedes an existing record, prefer a plain add (leave operations empty for it).
438
+ - If nothing in existing memory changed, return "operations": [].
439
+
440
+ ### Worked example
441
+ Existing durable memory:
442
+ mem_decision_4f1a9c22 | decision | Use OpenRouter for everything.
443
+ New session log says: the team is moving embeddings to a local Ollama model.
444
+ Correct output includes:
445
+ "operations": [
446
+ { "op": "supersede", "targetId": "mem_decision_4f1a9c22", "reason": "embeddings moved to local Ollama",
447
+ "replacement": { "type": "decision", "content": "OpenRouter for chat and processing; embeddings on local Ollama (supersedes the original all-OpenRouter decision)." } }
448
+ ]
449
+ and does NOT repeat that decision in the "decisions" array.`;
450
+ }
@@ -0,0 +1,86 @@
1
+ import type { MemoryRecord } from "./types.js";
2
+ export interface DuplicateMemoryRecord {
3
+ duplicateId: string;
4
+ keptId: string;
5
+ key: string;
6
+ }
7
+ export interface DeduplicateMemoryRecordsResult {
8
+ records: MemoryRecord[];
9
+ duplicates: DuplicateMemoryRecord[];
10
+ }
11
+ export interface MemoryConflict {
12
+ entity: string;
13
+ leftId: string;
14
+ rightId: string;
15
+ reason: string;
16
+ }
17
+ export interface StaleMemoryOptions {
18
+ now?: Date;
19
+ staleAfterDays?: number;
20
+ }
21
+ export interface MarkStaleMemoryRecordsResult {
22
+ records: MemoryRecord[];
23
+ staleIds: string[];
24
+ }
25
+ export interface PromoteMemoryRecordsOptions {
26
+ repeatedContent?: string[];
27
+ importantTerms?: string[];
28
+ repeatThreshold?: number;
29
+ repeatBoost?: number;
30
+ importantBoost?: number;
31
+ }
32
+ export interface PromotedMemoryRecord {
33
+ id: string;
34
+ reason: "repeated" | "important";
35
+ importance: number;
36
+ }
37
+ export interface PromoteMemoryRecordsResult {
38
+ records: MemoryRecord[];
39
+ promoted: PromotedMemoryRecord[];
40
+ }
41
+ export interface QualityReportOptions extends StaleMemoryOptions, PromoteMemoryRecordsOptions {
42
+ }
43
+ export interface MemoryQualityReport {
44
+ inputCount: number;
45
+ outputCount: number;
46
+ records: MemoryRecord[];
47
+ duplicates: DuplicateMemoryRecord[];
48
+ conflicts: MemoryConflict[];
49
+ staleIds: string[];
50
+ promotedIds: string[];
51
+ }
52
+ export interface MemoryQualityAuditSummary {
53
+ inputCount: number;
54
+ outputCount: number;
55
+ removedDuplicateCount: number;
56
+ conflictCount: number;
57
+ staleCount: number;
58
+ promotedCount: number;
59
+ changedCount: number;
60
+ unchangedCount: number;
61
+ removedIds: string[];
62
+ updatedIds: string[];
63
+ retainedIds: string[];
64
+ }
65
+ export interface ApplyMemoryQualityReportResult {
66
+ records: MemoryRecord[];
67
+ audit: MemoryQualityAuditSummary;
68
+ }
69
+ export interface SerializedMemoryQualityReport {
70
+ inputCount: number;
71
+ outputCount: number;
72
+ records: MemoryRecord[];
73
+ duplicates: DuplicateMemoryRecord[];
74
+ conflicts: MemoryConflict[];
75
+ staleIds: string[];
76
+ promotedIds: string[];
77
+ audit: MemoryQualityAuditSummary;
78
+ }
79
+ export declare function deduplicateMemoryRecords(records: MemoryRecord[]): DeduplicateMemoryRecordsResult;
80
+ export declare function detectMemoryConflicts(records: MemoryRecord[]): MemoryConflict[];
81
+ export declare function markStaleMemoryRecords(records: MemoryRecord[], options?: StaleMemoryOptions): MarkStaleMemoryRecordsResult;
82
+ export declare function promoteMemoryRecords(records: MemoryRecord[], options?: PromoteMemoryRecordsOptions): PromoteMemoryRecordsResult;
83
+ export declare function applyMemoryQualityReport(records: MemoryRecord[], report: MemoryQualityReport): ApplyMemoryQualityReportResult;
84
+ export declare function summarizeMemoryQualityReport(report: MemoryQualityReport, records?: MemoryRecord[]): MemoryQualityAuditSummary;
85
+ export declare function serializeMemoryQualityReport(report: MemoryQualityReport): SerializedMemoryQualityReport;
86
+ export declare function createQualityReport(records: MemoryRecord[], options?: QualityReportOptions): MemoryQualityReport;