agent-working-memory 0.17.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.md +321 -319
  2. package/dist/adapters/common.d.ts +34 -0
  3. package/dist/adapters/common.d.ts.map +1 -1
  4. package/dist/adapters/common.js +528 -446
  5. package/dist/adapters/common.js.map +1 -1
  6. package/dist/api/routes.d.ts.map +1 -1
  7. package/dist/api/routes.js +19 -0
  8. package/dist/api/routes.js.map +1 -1
  9. package/dist/cli/migrate.d.ts.map +1 -1
  10. package/dist/cli/migrate.js +31 -29
  11. package/dist/cli/migrate.js.map +1 -1
  12. package/dist/core/asked-value.d.ts +37 -0
  13. package/dist/core/asked-value.d.ts.map +1 -0
  14. package/dist/core/asked-value.js +77 -0
  15. package/dist/core/asked-value.js.map +1 -0
  16. package/dist/core/conflicts.d.ts +89 -0
  17. package/dist/core/conflicts.d.ts.map +1 -0
  18. package/dist/core/conflicts.js +267 -0
  19. package/dist/core/conflicts.js.map +1 -0
  20. package/dist/core/embeddings.d.ts +14 -2
  21. package/dist/core/embeddings.d.ts.map +1 -1
  22. package/dist/core/embeddings.js +32 -10
  23. package/dist/core/embeddings.js.map +1 -1
  24. package/dist/core/format-recall.d.ts +21 -1
  25. package/dist/core/format-recall.d.ts.map +1 -1
  26. package/dist/core/format-recall.js +60 -5
  27. package/dist/core/format-recall.js.map +1 -1
  28. package/dist/core/ml-worker.d.ts +24 -1
  29. package/dist/core/ml-worker.d.ts.map +1 -1
  30. package/dist/core/ml-worker.js +165 -4
  31. package/dist/core/ml-worker.js.map +1 -1
  32. package/dist/core/model-daemon-client.d.ts +77 -0
  33. package/dist/core/model-daemon-client.d.ts.map +1 -0
  34. package/dist/core/model-daemon-client.js +383 -0
  35. package/dist/core/model-daemon-client.js.map +1 -0
  36. package/dist/core/model-daemon-server.d.ts +71 -0
  37. package/dist/core/model-daemon-server.d.ts.map +1 -0
  38. package/dist/core/model-daemon-server.js +363 -0
  39. package/dist/core/model-daemon-server.js.map +1 -0
  40. package/dist/core/model-daemon.d.ts +137 -0
  41. package/dist/core/model-daemon.d.ts.map +1 -0
  42. package/dist/core/model-daemon.js +231 -0
  43. package/dist/core/model-daemon.js.map +1 -0
  44. package/dist/core/query-expander.d.ts +5 -0
  45. package/dist/core/query-expander.d.ts.map +1 -1
  46. package/dist/core/query-expander.js +3 -2
  47. package/dist/core/query-expander.js.map +1 -1
  48. package/dist/core/recall-config.d.ts +1 -1
  49. package/dist/core/recall-config.d.ts.map +1 -1
  50. package/dist/core/recall-config.js +5 -0
  51. package/dist/core/recall-config.js.map +1 -1
  52. package/dist/core/rerank-window.d.ts +7 -2
  53. package/dist/core/rerank-window.d.ts.map +1 -1
  54. package/dist/core/rerank-window.js +9 -4
  55. package/dist/core/rerank-window.js.map +1 -1
  56. package/dist/core/rerank2.d.ts +52 -2
  57. package/dist/core/rerank2.d.ts.map +1 -1
  58. package/dist/core/rerank2.js +60 -3
  59. package/dist/core/rerank2.js.map +1 -1
  60. package/dist/core/reranker.d.ts +19 -0
  61. package/dist/core/reranker.d.ts.map +1 -1
  62. package/dist/core/reranker.js +76 -12
  63. package/dist/core/reranker.js.map +1 -1
  64. package/dist/core/trust.d.ts +32 -0
  65. package/dist/core/trust.d.ts.map +1 -0
  66. package/dist/core/trust.js +66 -0
  67. package/dist/core/trust.js.map +1 -0
  68. package/dist/core/validity.d.ts +21 -0
  69. package/dist/core/validity.d.ts.map +1 -0
  70. package/dist/core/validity.js +41 -0
  71. package/dist/core/validity.js.map +1 -0
  72. package/dist/core/whoami.d.ts +3 -0
  73. package/dist/core/whoami.d.ts.map +1 -1
  74. package/dist/core/whoami.js +14 -0
  75. package/dist/core/whoami.js.map +1 -1
  76. package/dist/core/write-pipeline.d.ts +48 -0
  77. package/dist/core/write-pipeline.d.ts.map +1 -1
  78. package/dist/core/write-pipeline.js +80 -3
  79. package/dist/core/write-pipeline.js.map +1 -1
  80. package/dist/engine/activation.d.ts.map +1 -1
  81. package/dist/engine/activation.js +27 -4
  82. package/dist/engine/activation.js.map +1 -1
  83. package/dist/engine/consolidation.d.ts.map +1 -1
  84. package/dist/engine/consolidation.js +53 -4
  85. package/dist/engine/consolidation.js.map +1 -1
  86. package/dist/hooks/prime-query.d.ts +57 -0
  87. package/dist/hooks/prime-query.d.ts.map +1 -0
  88. package/dist/hooks/prime-query.js +388 -0
  89. package/dist/hooks/prime-query.js.map +1 -0
  90. package/dist/hooks/prime.d.ts +8 -0
  91. package/dist/hooks/prime.d.ts.map +1 -1
  92. package/dist/hooks/prime.js +21 -2
  93. package/dist/hooks/prime.js.map +1 -1
  94. package/dist/hooks/sidecar.d.ts +7 -0
  95. package/dist/hooks/sidecar.d.ts.map +1 -1
  96. package/dist/hooks/sidecar.js +10 -2
  97. package/dist/hooks/sidecar.js.map +1 -1
  98. package/dist/index.js +11 -3
  99. package/dist/index.js.map +1 -1
  100. package/dist/mcp.js +148 -103
  101. package/dist/mcp.js.map +1 -1
  102. package/dist/model-daemon.d.ts +8 -0
  103. package/dist/model-daemon.d.ts.map +1 -0
  104. package/dist/model-daemon.js +45 -0
  105. package/dist/model-daemon.js.map +1 -0
  106. package/dist/storage/factory.d.ts.map +1 -1
  107. package/dist/storage/factory.js +5 -0
  108. package/dist/storage/factory.js.map +1 -1
  109. package/dist/storage/pglite-schema.d.ts.map +1 -1
  110. package/dist/storage/pglite-schema.js +176 -170
  111. package/dist/storage/pglite-schema.js.map +1 -1
  112. package/dist/storage/pglite.d.ts +5 -0
  113. package/dist/storage/pglite.d.ts.map +1 -1
  114. package/dist/storage/pglite.js +165 -153
  115. package/dist/storage/pglite.js.map +1 -1
  116. package/dist/storage/postgres.d.ts +5 -0
  117. package/dist/storage/postgres.d.ts.map +1 -1
  118. package/dist/storage/postgres.js +165 -153
  119. package/dist/storage/postgres.js.map +1 -1
  120. package/dist/storage/sqlite.d.ts +22 -0
  121. package/dist/storage/sqlite.d.ts.map +1 -1
  122. package/dist/storage/sqlite.js +400 -363
  123. package/dist/storage/sqlite.js.map +1 -1
  124. package/dist/types/engram.d.ts +33 -0
  125. package/dist/types/engram.d.ts.map +1 -1
  126. package/dist/types/engram.js.map +1 -1
  127. package/package.json +1 -1
  128. package/plugin/.claude-plugin/plugin.json +1 -1
  129. package/plugin/README.md +1 -1
  130. package/plugin/bin/awm-mcp-launcher.cjs +2 -2
  131. package/plugin/skills/awm-memory/SKILL.md +11 -2
  132. package/src/adapters/common.ts +881 -802
  133. package/src/api/routes.ts +20 -0
  134. package/src/cli/migrate.ts +309 -307
  135. package/src/core/asked-value.ts +74 -0
  136. package/src/core/conflicts.ts +280 -0
  137. package/src/core/embeddings.ts +35 -10
  138. package/src/core/format-recall.ts +83 -25
  139. package/src/core/ml-worker.ts +458 -281
  140. package/src/core/model-daemon-client.ts +374 -0
  141. package/src/core/model-daemon-server.ts +344 -0
  142. package/src/core/model-daemon.ts +243 -0
  143. package/src/core/query-expander.ts +127 -126
  144. package/src/core/recall-config.ts +5 -0
  145. package/src/core/rerank-window.ts +164 -159
  146. package/src/core/rerank2.ts +143 -82
  147. package/src/core/reranker.ts +78 -11
  148. package/src/core/trust.ts +65 -0
  149. package/src/core/validity.ts +47 -0
  150. package/src/core/whoami.ts +136 -120
  151. package/src/core/write-pipeline.ts +119 -3
  152. package/src/engine/activation.ts +27 -4
  153. package/src/engine/consolidation.ts +950 -899
  154. package/src/hooks/prime-query.ts +375 -0
  155. package/src/hooks/prime.ts +25 -2
  156. package/src/hooks/sidecar.ts +13 -3
  157. package/src/index.ts +255 -248
  158. package/src/mcp.ts +1521 -1475
  159. package/src/model-daemon.ts +48 -0
  160. package/src/storage/factory.ts +25 -19
  161. package/src/storage/pglite-schema.ts +199 -193
  162. package/src/storage/pglite.ts +1513 -1498
  163. package/src/storage/postgres.ts +1613 -1598
  164. package/src/storage/sqlite.ts +2056 -2019
  165. package/src/types/engram.ts +30 -0
@@ -1,899 +1,950 @@
1
- // Copyright 2026 Robert Winter / Complete Ideas
2
- // SPDX-License-Identifier: Apache-2.0
3
- /**
4
- * Sleep Cycle — offline memory consolidation.
5
- *
6
- * Models the brain's consolidation during sleep:
7
- * 1. Replay — find clusters of semantically similar memories
8
- * 2. Strengthen — reinforce edges within clusters + access-weighted boost
9
- * 3. Bridge — create cross-cluster shortcuts between related topic areas
10
- * 4. Decay — weaken unused edges, prune dead ones
11
- * 5. Homeostasis — normalize outgoing edge weights to prevent hub explosion
12
- * 6. Forget — archive/delete memories that were never retrieved (age-gated)
13
- * 7. Sweep — promote or discard uncertain (staging) memories
14
- *
15
- * No artificial "summary nodes" are created. Instead, the associative
16
- * graph gets denser where knowledge overlaps and sparser where it doesn't.
17
- * The beam search graph walk in activation.ts naturally propagates through
18
- * these strengthened pathways.
19
- *
20
- * Run between sessions or on a timer (e.g., every few hours).
21
- */
22
-
23
- import { cosineSimilarity } from '../core/embeddings.js';
24
- import { strengthenAssociation, decayAssociation } from '../core/hebbian.js';
25
- import type { Engram } from '../types/index.js';
26
- import type { IEngramStore as EngramStore } from '../storage/store.js';
27
- import type { ConnectionEngine } from './connections.js';
28
- import { setConsolidationActive } from '../core/write-telemetry.js';
29
-
30
- /** Cosine similarity for initial candidate detection (single-link entry gate) */
31
- const SIMILARITY_THRESHOLD = 0.65;
32
-
33
- /** Minimum pairwise cosine for cluster diameter enforcement.
34
- * Prevents chaining: a candidate must be this similar to ALL cluster members. */
35
- const MIN_PAIRWISE_COS = 0.50;
36
-
37
- /** Lower threshold for cross-cluster bridge edges */
38
- const BRIDGE_THRESHOLD = 0.25;
39
-
40
- /** Minimum edge weight to form a new connection during replay */
41
- const INITIAL_EDGE_WEIGHT = 0.3;
42
-
43
- /** Boost factor for strengthening existing edges between cluster members */
44
- const CONSOLIDATION_SIGNAL = 0.5;
45
-
46
- /** Max new edges to create per sleep cycle (prevent graph explosion) */
47
- const MAX_NEW_EDGES_PER_CYCLE = 50;
48
-
49
- /** Max bridge edges per cycle (cross-cluster shortcuts) */
50
- const MAX_BRIDGE_EDGES_PER_CYCLE = 20;
51
-
52
- /** Edge weight below which we prune during decay */
53
- const PRUNE_THRESHOLD = 0.01;
54
-
55
- /** Target total outgoing edge weight per node (homeostasis) */
56
- const HOMEOSTASIS_TARGET = 10.0;
57
-
58
- /** Grace period before forgetting curve starts (days) */
59
- const FORGET_GRACE_DAYS = 7;
60
-
61
- /** Consolidation cycles before 0-access memories get archived */
62
- const FORGET_CYCLE_THRESHOLD = 5;
63
-
64
- /** Percentile of edge count distribution used for forgetting protection (0-1) */
65
- const EDGE_PROTECTION_PERCENTILE = 0.25;
66
-
67
- /** Age at which never-retrieved memories get archived (days) */
68
- const FORGET_ARCHIVE_DAYS = 30;
69
-
70
- /** Age at which archived, never-retrieved, unconnected memories get deleted (days) */
71
- const FORGET_DELETE_DAYS = 90;
72
-
73
- /** Cosine similarity above which two low-confidence memories are considered redundant.
74
- * MiniLM-L6 paraphrases typically score 0.75-0.88 cosine; 0.85 misses most of them.
75
- * 0.75 catches paraphrases while keeping precision above 0.60 for unrelated facts. */
76
- const REDUNDANCY_THRESHOLD = 0.75;
77
-
78
- /** Max redundant memories to prune per cycle (gradual, not sudden).
79
- * Raised from 10 to 25 — the eval harness runs multiple cycles anyway,
80
- * but faster convergence reduces consolidation time for larger pools. */
81
- const MAX_REDUNDANCY_PRUNE_PER_CYCLE = 25;
82
-
83
- /** Max confidence drift per consolidation cycle (prevents runaway) */
84
- const CONFIDENCE_DRIFT_CAP = 0.03;
85
-
86
- /**
87
- * Content fade — Paper 1 (PLOS Comp Biology, storage degradation).
88
- * Memories whose content has not been recalled in a long time get coarsened:
89
- * the content string is trimmed to FADE_KEEP_CHARS with a "[faded]" marker,
90
- * while concept, tags, and embedding are preserved.
91
- *
92
- * Rationale: human memory degrades the detailed surface but retains the gist
93
- * and the cue-association pathway. The faded engram still surfaces by tag/concept
94
- * BM25 and by vector similarity (embedding preserved), but the full content body
95
- * is gone — which is the correct behavior for a memory that has not been useful
96
- * enough to rehearse in a long time.
97
- *
98
- * Fade comes BEFORE archive in the lifecycle (active → fading → archived → deleted),
99
- * applied only to engrams that have been accessed at least once. Engrams that have
100
- * never been accessed go straight to archive via the standard forgetting path —
101
- * they have no surface worth preserving.
102
- *
103
- * Env: AWM_FADE_DAYS_SINCE_ACCESS (default 45),
104
- * AWM_FADE_KEEP_CHARS (default 150),
105
- * AWM_FADE_MIN_CONTENT_LEN (default 250),
106
- * AWM_FADE_MAX_PER_CYCLE (default 25).
107
- */
108
- const FADE_DAYS_SINCE_ACCESS = Number(process.env.AWM_FADE_DAYS_SINCE_ACCESS ?? 45);
109
- const FADE_KEEP_CHARS = Number(process.env.AWM_FADE_KEEP_CHARS ?? 150);
110
- const FADE_MIN_CONTENT_LEN = Number(process.env.AWM_FADE_MIN_CONTENT_LEN ?? 250);
111
- const FADE_MAX_PER_CYCLE = Number(process.env.AWM_FADE_MAX_PER_CYCLE ?? 25);
112
- const FADE_PROTECTED_ACCESS_COUNT = 10;
113
- const FADE_MARKER = '… [faded]';
114
- const FADE_PROTECTED_CLASSES = new Set(['canonical', 'structural']);
115
-
116
- /** Days without recall before confidence starts drifting down */
117
- const CONFIDENCE_NEGLECT_DAYS = 30;
118
-
119
- export interface ConsolidationResult {
120
- clustersFound: number;
121
- edgesStrengthened: number;
122
- edgesCreated: number;
123
- bridgesCreated: number;
124
- edgesDecayed: number;
125
- edgesPruned: number;
126
- edgesNormalized: number;
127
- memoriesForgotten: number;
128
- memoriesArchived: number;
129
- memoriesFaded: number;
130
- redundancyPruned: number;
131
- confidenceAdjusted: number;
132
- stagingPromoted: number;
133
- stagingDiscarded: number;
134
- engramsProcessed: number;
135
- synthesesCreated: number;
136
- }
137
-
138
- const MAX_SYNTHESES_PER_CYCLE = 5;
139
- const MIN_CLUSTER_SIZE_FOR_SYNTHESIS = 3;
140
-
141
- /** Shared stopwords for synthesis keyword extraction */
142
- const SYNTH_STOPWORDS = new Set(['the', 'is', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'for',
143
- 'on', 'with', 'that', 'this', 'it', 'was', 'are', 'be', 'has', 'had', 'but', 'not', 'from',
144
- 'by', 'as', 'at', 'i', 'you', 'we', 'my', 'your', 'can', 'will', 'do', 'did', 'if', 'user',
145
- 'assistant', 'would', 'like', 'just', 'also', 'about', 'really', 'think', 'know', 'want',
146
- 'here', 'there', 'some', 'more', 'very', 'been', 'have', 'what', 'when', 'how', 'they']);
147
-
148
- export class ConsolidationEngine {
149
- private store: EngramStore;
150
- private connectionEngine: ConnectionEngine | null;
151
-
152
- /**
153
- * Optional `connectionEngine` is drained at the start of each
154
- * consolidation pass (v0.8.2). Connection discovery is no longer
155
- * triggered per write — the work batches into the existing sleep cycle
156
- * so writes return in ~50 ms instead of being pegged by an inline
157
- * embed + rerank cycle (~200-500 ms/write under load).
158
- */
159
- constructor(store: EngramStore, connectionEngine?: ConnectionEngine) {
160
- this.store = store;
161
- this.connectionEngine = connectionEngine ?? null;
162
- }
163
-
164
- /**
165
- * Run a full sleep cycle for an agent.
166
- *
167
- * Phase 0: Connection drain — discover associations for engrams enqueued
168
- * by writes since the last consolidation (v0.8.2)
169
- * Phase 1: Replay — find clusters of semantically similar memories
170
- * Phase 2: Strengthen — reinforce edges within clusters (access-weighted)
171
- * Phase 3: Bridge — create cross-cluster shortcuts
172
- * Phase 4: Decay — weaken unused edges, prune dead ones
173
- * Phase 5: Homeostasis — normalize outgoing edge weights per node
174
- * Phase 5.5: Content fade — coarsen un-recalled engrams (Paper 1)
175
- * Phase 6: Forget — archive/delete memories never retrieved (age-gated)
176
- * Phase 6.5: Redundancy prune
177
- * Phase 6.7: Confidence drift — adjust confidence based on structural signals
178
- * Phase 7: Sweep — check staging buffer for resonance
179
- */
180
- async consolidate(agentId: string): Promise<ConsolidationResult> {
181
- // D1 telemetry (2026-07-30): flag the cycle so slow-write reports can
182
- // attribute stalls to in-process consolidation. try/finally guarantees
183
- // the flag clears even when a phase throws.
184
- setConsolidationActive(true, agentId);
185
- const tCycleStart = performance.now();
186
- try {
187
- return await this.consolidateInner(agentId);
188
- } finally {
189
- setConsolidationActive(false);
190
- const ms = Math.round(performance.now() - tCycleStart);
191
- if (ms > 5000) {
192
- process.stderr.write(`[awm] consolidation cycle for ${agentId} took ${ms}ms\n`);
193
- }
194
- }
195
- }
196
-
197
- private async consolidateInner(agentId: string): Promise<ConsolidationResult> {
198
- // --- Phase 0: Connection drain ---
199
- // Discover associations for engrams enqueued by writes since the last
200
- // consolidation. Per-write inline discovery was removed in v0.8.2
201
- // because each findConnections() call runs a full activation cycle
202
- // (embed + rerank, ~200-500 ms event-loop block per write).
203
- if (this.connectionEngine) {
204
- try { await this.connectionEngine.processQueue(); } catch { /* best-effort */ }
205
- }
206
- const result: ConsolidationResult = {
207
- clustersFound: 0,
208
- edgesStrengthened: 0,
209
- edgesCreated: 0,
210
- bridgesCreated: 0,
211
- edgesDecayed: 0,
212
- edgesPruned: 0,
213
- edgesNormalized: 0,
214
- memoriesForgotten: 0,
215
- memoriesArchived: 0,
216
- memoriesFaded: 0,
217
- redundancyPruned: 0,
218
- confidenceAdjusted: 0,
219
- stagingPromoted: 0,
220
- stagingDiscarded: 0,
221
- engramsProcessed: 0,
222
- synthesesCreated: 0,
223
- };
224
-
225
- // --- Phase 1: Replay ---
226
- // Get all active engrams, backfill missing embeddings
227
- const allActive = await this.store.getEngramsByAgent(agentId, 'active');
228
- const needsEmbedding = allActive.filter(e => !e.embedding || e.embedding.length === 0);
229
- if (needsEmbedding.length > 0) {
230
- try {
231
- const { embedBatch, getModelId } = await import('../core/embeddings.js');
232
- const BATCH_SIZE = 32;
233
- const modelId = getModelId?.() ?? 'unknown';
234
- for (let b = 0; b < needsEmbedding.length; b += BATCH_SIZE) {
235
- const batch = needsEmbedding.slice(b, b + BATCH_SIZE);
236
- try {
237
- const texts = batch.map(e => `${e.concept} ${e.content}`);
238
- const vecs = await embedBatch(texts);
239
- for (let j = 0; j < batch.length; j++) {
240
- await this.store.updateEmbedding(batch[j].id, vecs[j], modelId);
241
- batch[j].embedding = vecs[j];
242
- }
243
- } catch { /* batch failed, skip — non-fatal */ }
244
- }
245
- if (needsEmbedding.length > 0) {
246
- console.log(`[consolidation] Backfilled ${needsEmbedding.filter(e => e.embedding?.length).length}/${needsEmbedding.length} embeddings (model: ${modelId})`);
247
- }
248
- } catch { /* embeddings module unavailable */ }
249
- }
250
- const engrams = allActive.filter(e => e.embedding && e.embedding.length > 0);
251
-
252
- result.engramsProcessed = engrams.length;
253
- // Clustering requires at least two engrams to form pairs. Per-engram phases
254
- // (fade, forget, confidence drift, staging sweep) still run for singletons —
255
- // they don't need a graph.
256
- const clusters = engrams.length >= 2 ? this.findClusters(engrams) : [];
257
- result.clustersFound = clusters.length;
258
-
259
- // --- Phase 2: Strengthen (access-weighted) ---
260
- // Memories that are retrieved more often get stronger consolidation.
261
- // This mirrors how the brain preferentially consolidates practiced memories.
262
- let newEdges = 0;
263
- for (const cluster of clusters) {
264
- for (let i = 0; i < cluster.length; i++) {
265
- for (let j = i + 1; j < cluster.length; j++) {
266
- const a = cluster[i];
267
- const b = cluster[j];
268
-
269
- // Access-weighted signal: more retrieved = stronger consolidation
270
- const accessFactor = Math.min(
271
- 1.0,
272
- 0.3 + 0.7 * Math.log1p(a.accessCount + b.accessCount) / Math.log1p(20),
273
- );
274
-
275
- const existing = await this.store.getAssociation(a.id, b.id);
276
- if (existing) {
277
- const newWeight = strengthenAssociation(
278
- existing.weight, CONSOLIDATION_SIGNAL * accessFactor, 0.25,
279
- );
280
- await this.store.upsertAssociation(
281
- a.id, b.id, newWeight, existing.type, existing.confidence,
282
- );
283
- result.edgesStrengthened++;
284
- } else if (newEdges < MAX_NEW_EDGES_PER_CYCLE) {
285
- await this.store.upsertAssociation(
286
- a.id, b.id, INITIAL_EDGE_WEIGHT * accessFactor, 'connection',
287
- );
288
- newEdges++;
289
- result.edgesCreated++;
290
- }
291
- }
292
- }
293
- }
294
-
295
- // --- Phase 2.5: Two types of synthesis ---
296
- //
297
- // Type A: SESSION SYNTHESIS (perfect recall)
298
- // Groups by shared metadata tags (sid=, proj=, topic=).
299
- // Summarizes what happened in a conversation/project session.
300
- // Helps find specific facts by providing a topical anchor.
301
- //
302
- // Type B: PATTERN SYNTHESIS (novel recall)
303
- // Uses the existing vector-similarity clusters.
304
- // Finds structural patterns across disparate topics.
305
- // "Debugging X by Y" + "Resolving A by B" → pattern: "conflict → ordering"
306
- // Lower confidence — these are speculative connections, not facts.
307
-
308
- let synthCount = 0;
309
-
310
- // --- Type A: Session synthesis (tag-based grouping) ---
311
- // Group engrams by shared session/project tags, NOT vector similarity
312
- const tagGroups = new Map<string, Engram[]>();
313
- for (const e of engrams) {
314
- if (e.tags.includes('synth=true')) continue; // Skip existing syntheses
315
- for (const tag of e.tags) {
316
- if (tag.startsWith('sid=') || tag.startsWith('proj=') || tag.startsWith('topic=')) {
317
- const group = tagGroups.get(tag) ?? [];
318
- group.push(e);
319
- tagGroups.set(tag, group);
320
- }
321
- }
322
- }
323
-
324
- for (const [tag, group] of tagGroups) {
325
- if (group.length < MIN_CLUSTER_SIZE_FOR_SYNTHESIS) continue;
326
- if (synthCount >= MAX_SYNTHESES_PER_CYCLE) break;
327
-
328
- // Check if a synthesis for this tag already exists
329
- const existing = engrams.find(e =>
330
- e.tags.includes('synth=true') && e.tags.includes(tag)
331
- );
332
- if (existing) continue;
333
-
334
- // Extract key terms from this group
335
- const wordCounts = new Map<string, number>();
336
- for (const e of group) {
337
- const words = e.content.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/);
338
- for (const w of words) {
339
- if (w.length > 3 && !SYNTH_STOPWORDS.has(w)) {
340
- wordCounts.set(w, (wordCounts.get(w) ?? 0) + 1);
341
- }
342
- }
343
- }
344
- const keyTerms = [...wordCounts.entries()]
345
- .sort((a, b) => b[1] - a[1])
346
- .slice(0, 15)
347
- .map(([word]) => word);
348
-
349
- // Extract unique concepts (deduplicated)
350
- const concepts = [...new Set(group.map(e => e.concept))];
351
-
352
- const synthContent = [
353
- `Session summary (${tag}, ${group.length} turns).`,
354
- `Key topics: ${keyTerms.slice(0, 8).join(', ')}.`,
355
- `Discussed: ${keyTerms.slice(8).join(', ')}.`,
356
- ].join(' ');
357
-
358
- const synthEngram = await this.store.createEngram({
359
- agentId,
360
- concept: `session: ${tag} (${keyTerms.slice(0, 3).join(', ')})`,
361
- content: synthContent,
362
- tags: [tag, 'synth=true', 'synth-type=session'],
363
- salience: 0.6,
364
- confidence: 0.55,
365
- memoryType: 'semantic',
366
- });
367
-
368
- // Set embedding to group centroid
369
- const centroid = this.computeCentroid(group);
370
- if (centroid.length > 0) {
371
- await this.store.updateEmbedding(synthEngram.id, centroid);
372
- }
373
-
374
- // Link to sources
375
- for (const source of group.slice(0, 10)) { // Cap links to prevent explosion
376
- await this.store.upsertAssociation(synthEngram.id, source.id, 0.4, 'causal');
377
- }
378
-
379
- synthCount++;
380
- result.synthesesCreated++;
381
- }
382
-
383
- // --- Type B: Pattern synthesis (vector-similarity clusters, speculative) ---
384
- // Only create these for clusters where members come from DIFFERENT sessions/projects.
385
- // This finds cross-domain patterns: "debugging technique A" + "architecture pattern B"
386
- for (const cluster of clusters) {
387
- if (cluster.length < MIN_CLUSTER_SIZE_FOR_SYNTHESIS) continue;
388
- if (synthCount >= MAX_SYNTHESES_PER_CYCLE) break;
389
- if (cluster.some(e => e.tags.includes('synth=true'))) continue;
390
-
391
- // Only create pattern synthesis if cluster spans multiple sessions
392
- const sessionTags = new Set<string>();
393
- for (const e of cluster) {
394
- for (const tag of e.tags) {
395
- if (tag.startsWith('sid=') || tag.startsWith('proj=')) sessionTags.add(tag);
396
- }
397
- }
398
- if (sessionTags.size < 2) continue; // Same session → skip (Type A handles it)
399
-
400
- const wordCounts = new Map<string, number>();
401
- for (const e of cluster) {
402
- const words = e.content.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/);
403
- for (const w of words) {
404
- if (w.length > 3 && !SYNTH_STOPWORDS.has(w)) {
405
- wordCounts.set(w, (wordCounts.get(w) ?? 0) + 1);
406
- }
407
- }
408
- }
409
- const keyTerms = [...wordCounts.entries()]
410
- .sort((a, b) => b[1] - a[1])
411
- .slice(0, 10)
412
- .map(([word]) => word);
413
-
414
- const synthContent = [
415
- `Pattern across ${sessionTags.size} sessions (${cluster.length} memories).`,
416
- `Common themes: ${keyTerms.join(', ')}.`,
417
- ].join(' ');
418
-
419
- const synthEngram = await this.store.createEngram({
420
- agentId,
421
- concept: `pattern: ${keyTerms.slice(0, 3).join(', ')}`,
422
- content: synthContent,
423
- tags: [...sessionTags, 'synth=true', 'synth-type=pattern'],
424
- salience: 0.5, // Lower — speculative
425
- confidence: 0.4, // Lower — these are hypotheses not facts
426
- memoryType: 'semantic',
427
- });
428
-
429
- const centroid = this.computeCentroid(cluster);
430
- if (centroid.length > 0) {
431
- await this.store.updateEmbedding(synthEngram.id, centroid);
432
- }
433
-
434
- for (const source of cluster.slice(0, 8)) {
435
- await this.store.upsertAssociation(synthEngram.id, source.id, 0.3, 'bridge');
436
- }
437
-
438
- synthCount++;
439
- result.synthesesCreated++;
440
- }
441
-
442
- // --- Phase 3: Direct cross-cluster bridging ---
443
- // Find the closest pair of memories between each cluster pair and bridge them.
444
- if (clusters.length >= 2) {
445
- const MIN_BRIDGE_SIM = 0.15;
446
- let bridges = 0;
447
- for (let i = 0; i < clusters.length && bridges < MAX_BRIDGE_EDGES_PER_CYCLE; i++) {
448
- for (let j = i + 1; j < clusters.length && bridges < MAX_BRIDGE_EDGES_PER_CYCLE; j++) {
449
- let bestSim = -1;
450
- let bestA: Engram | null = null;
451
- let bestB: Engram | null = null;
452
- for (const a of clusters[i]) {
453
- if (!a.embedding) continue;
454
- for (const b of clusters[j]) {
455
- if (!b.embedding) continue;
456
- const s = cosineSimilarity(a.embedding, b.embedding);
457
- if (s > bestSim) { bestSim = s; bestA = a; bestB = b; }
458
- }
459
- }
460
- if (bestA && bestB && bestSim > MIN_BRIDGE_SIM) {
461
- const existing = await this.store.getAssociation(bestA.id, bestB.id);
462
- if (!existing) {
463
- await this.store.upsertAssociation(bestA.id, bestB.id, bestSim, 'bridge');
464
- await this.store.upsertAssociation(bestB.id, bestA.id, bestSim, 'bridge');
465
- bridges++;
466
- result.bridgesCreated++;
467
- }
468
- }
469
- }
470
- }
471
- }
472
-
473
- // --- Phase 4: Decay (confidence-modulated) ---
474
- // High-confidence edges decay slower. This means edges between memories
475
- // that received positive feedback are more durable — just like how
476
- // practiced memories are more resistant to forgetting in the brain.
477
- // Base half-life: 7 days. High-confidence (0.8+) gets up to 30 days.
478
- const engramMap = new Map(engrams.map(e => [e.id, e]));
479
- const associations = await this.store.getAllAssociations(agentId);
480
- for (const assoc of associations) {
481
- const daysSince =
482
- (Date.now() - assoc.lastActivated.getTime()) / (1000 * 60 * 60 * 24);
483
- if (daysSince < 0.5) continue;
484
-
485
- const fromEngram = engramMap.get(assoc.fromEngramId);
486
- const toEngram = engramMap.get(assoc.toEngramId);
487
- const fromConf = fromEngram?.confidence ?? 0.5;
488
- const toConf = toEngram?.confidence ?? 0.5;
489
- const maxConf = Math.max(fromConf, toConf);
490
- const maxAccess = Math.max(fromEngram?.accessCount ?? 0, toEngram?.accessCount ?? 0);
491
- const accessBoost = Math.min(2.0, 1.0 + 0.5 * Math.log1p(maxAccess));
492
- const halfLifeDays = Math.min(
493
- 7 * (1 + 2 * Math.max(0, (maxConf - 0.5) / 0.5)) * accessBoost,
494
- 42 // Hard cap: 6 weeks max
495
- );
496
-
497
- const newWeight = decayAssociation(assoc.weight, daysSince, halfLifeDays);
498
- if (newWeight < PRUNE_THRESHOLD) {
499
- await this.store.deleteAssociation(assoc.id);
500
- result.edgesPruned++;
501
- } else if (Math.abs(newWeight - assoc.weight) > 0.001) {
502
- await this.store.upsertAssociation(
503
- assoc.fromEngramId, assoc.toEngramId,
504
- newWeight, assoc.type, assoc.confidence,
505
- );
506
- result.edgesDecayed++;
507
- }
508
- }
509
-
510
- // --- Phase 5: Synaptic homeostasis ---
511
- // Normalize total outgoing edge weight per node to prevent hub explosion.
512
- // Nodes with many strong edges get scaled down so relative weights stay meaningful.
513
- const engramIds = new Set(engrams.map(e => e.id));
514
- for (const id of engramIds) {
515
- const outgoing = await this.store.getOutgoingAssociations(id);
516
- const totalWeight = outgoing.reduce((sum, a) => sum + a.weight, 0);
517
- if (totalWeight > HOMEOSTASIS_TARGET) {
518
- const scale = HOMEOSTASIS_TARGET / totalWeight;
519
- for (const edge of outgoing) {
520
- const newWeight = edge.weight * scale;
521
- if (newWeight < PRUNE_THRESHOLD) {
522
- await this.store.deleteAssociation(edge.id);
523
- result.edgesPruned++;
524
- } else {
525
- await this.store.upsertAssociation(
526
- edge.fromEngramId, edge.toEngramId,
527
- newWeight, edge.type, edge.confidence,
528
- );
529
- }
530
- }
531
- result.edgesNormalized++;
532
- }
533
- }
534
-
535
- // --- Phase 5.5: Content fade (Paper 1: storage degradation) ---
536
- // Coarsens engrams that have been accessed before but have gone stale.
537
- // Trims content surface area while preserving concept, tags, and embedding so
538
- // the engram still participates in BM25 + vector recall — just with less
539
- // body to score against. This models how human memory loses surface detail
540
- // while retaining cue-association pathways.
541
- //
542
- // Fade applies only to accessed engrams (the never-accessed ones go straight
543
- // to archive via Phase 6). Heavily-used (accessCount >= FADE_PROTECTED_ACCESS_COUNT),
544
- // canonical, structural, short-content, and UNFINISHED TASK engrams are excluded.
545
- let fadeCount = 0;
546
- for (const engram of engrams) {
547
- if (fadeCount >= FADE_MAX_PER_CYCLE) break;
548
- if (engram.accessCount === 0) continue;
549
- if (engram.accessCount >= FADE_PROTECTED_ACCESS_COUNT) continue;
550
- if (FADE_PROTECTED_CLASSES.has(engram.memoryClass)) continue;
551
- // An unfinished task is a COMMITMENT, not a stale observation, and its body is
552
- // where the acceptance criteria, source meeting, file paths and blocking reason
553
- // live — the concept is 3-10 words by schema, so fading a task leaves a title
554
- // nobody can action. Measured 2026-10-10 on the live store before this guard
555
- // existed: 19 task bodies had already been cut to 150 chars, and `blocked` tasks
556
- // were hit at 29% (5/17) against 1.2% (3/243) for `open` — ~24x — precisely
557
- // BECAUSE a blocked task accrues idle days by definition while it waits on
558
- // someone else, which is also when its body matters most. Only 1 of the 19 was
559
- // recoverable from any backup. `done` tasks still fade: the work is finished and
560
- // the gist suffices. memory_task_add sets salience 0.9 but never memoryClass, so
561
- // FADE_PROTECTED_CLASSES above never covers a task.
562
- if (engram.taskStatus && engram.taskStatus !== 'done') continue;
563
- if (engram.retracted) continue;
564
- if (engram.content.length < FADE_MIN_CONTENT_LEN) continue;
565
- if (engram.content.endsWith(FADE_MARKER)) continue; // Already faded
566
- const daysSinceAccess = (Date.now() - engram.lastAccessed.getTime()) / (1000 * 60 * 60 * 24);
567
- if (daysSinceAccess < FADE_DAYS_SINCE_ACCESS) continue;
568
-
569
- const trimmed = engram.content.slice(0, FADE_KEEP_CHARS).trimEnd() + FADE_MARKER;
570
- await this.store.updateContent(engram.id, trimmed);
571
- await this.store.updateStage(engram.id, 'fading');
572
- engram.content = trimmed;
573
- engram.stage = 'fading';
574
- result.memoriesFaded++;
575
- fadeCount++;
576
- }
577
-
578
- // --- Phase 6: Forgetting (age-gated) ---
579
- // Models how human memory actually works:
580
- // - New memories get a grace period (too new to judge)
581
- // - Retrieval acts as rehearsal — resets the forgetting clock
582
- // - Well-connected memories persist (edges = integration into knowledge)
583
- // - Old, isolated, unretrieved memories fade to archive (not deleted)
584
- // - Archived memories can still be recovered via deep search
585
- // - Only truly orphaned, ancient memories get deleted
586
- //
587
- // Key insight: outdated memories still have value as historical context.
588
- // "We used to use X" helps explain why we now use Y.
589
- // Compute edge count percentile for relative protection threshold.
590
- // With avg 12 edges/node, an absolute threshold of 3 protects everything.
591
- // Use 25th percentile so "weakly connected" is relative to actual graph density.
592
- const edgeCounts = await Promise.all(engrams.map(async e => await this.store.countAssociationsFor(e.id)));
593
- edgeCounts.sort((a, b) => a - b);
594
- const percentileIdx = Math.floor(edgeCounts.length * EDGE_PROTECTION_PERCENTILE);
595
- const baseEdgeThreshold = edgeCounts.length > 0 ? edgeCounts[percentileIdx] : 3;
596
-
597
- // Get consolidation cycle count for cycle-based archiving
598
- const cycleCount = await this.store.getConsolidationCycleCount(agentId);
599
-
600
- for (const engram of engrams) {
601
- // Skip engrams that just transitioned to 'fading' in Phase 5.5 this cycle.
602
- // Fade is intended as an intermediate stage between active and archived —
603
- // the engram should live in 'fading' for at least one more recall window
604
- // before being eligible for archival. Without this guard, a same-cycle
605
- // fade-then-archive sequence collapses the active pool to zero (observed
606
- // in the stress test scale phase around cycle 90).
607
- if (engram.stage !== 'active') continue;
608
-
609
- const ageDays = (Date.now() - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
610
- if (ageDays < FORGET_GRACE_DAYS) continue; // Grace period — too new to judge
611
-
612
- const edgeCount = await this.store.countAssociationsFor(engram.id);
613
-
614
- // Use relative threshold (percentile-based) instead of absolute.
615
- // High-confidence memories need fewer edges to survive.
616
- const confReduction = engram.confidence > 0.7
617
- ? Math.min(0.6, (engram.confidence - 0.7) * 2)
618
- : 0;
619
- const edgeProtectionThreshold = Math.max(1, Math.round(baseEdgeThreshold * (1 - confReduction)));
620
- if (edgeCount > edgeProtectionThreshold) continue;
621
-
622
- // Cycle-based archive: 0-access memories archived after N cycles
623
- // regardless of age. Handles small pools where time thresholds are too generous.
624
- if (engram.accessCount === 0 && cycleCount >= FORGET_CYCLE_THRESHOLD) {
625
- await this.store.updateStage(engram.id, 'archived');
626
- result.memoriesArchived++;
627
- continue;
628
- }
629
-
630
- // Compute effective forgetting threshold based on memory strength signals.
631
- // Rehearsal (access + feedback) extends protection but NEVER makes immortal.
632
- // Models a sharp 20-year senior dev: confirmed knowledge persists for months/years.
633
- // - Base: FORGET_ARCHIVE_DAYS (30 days)
634
- // - Access extends by log-scaled factor: 5 accesses ≈ 2x, 10 ≈ 2.5x
635
- // - Confidence modulates up to 4x (0.5→1x, 0.7→2.6x, 0.8→3.4x, 1.0→4x)
636
- // - Hard cap: 12x base (360 days) — even the sharpest memory fades after a year
637
- const accessFactor = 1 + Math.log1p(engram.accessCount) * 0.6;
638
- const confFactor = 1 + 3 * Math.max(0, (engram.confidence - 0.5) / 0.5);
639
- const effectiveArchiveDays = Math.min(
640
- FORGET_ARCHIVE_DAYS * accessFactor * confFactor,
641
- FORGET_ARCHIVE_DAYS * 12, // Hard cap: 12x base (360 days)
642
- );
643
-
644
- const daysSinceAccess = (Date.now() - engram.lastAccessed.getTime()) / (1000 * 60 * 60 * 24);
645
-
646
- if (engram.accessCount === 0 && ageDays > FORGET_ARCHIVE_DAYS) {
647
- // Never retrieved, old, weakly connected → archive
648
- await this.store.updateStage(engram.id, 'archived');
649
- result.memoriesArchived++;
650
- } else if (engram.accessCount > 0 && daysSinceAccess > effectiveArchiveDays) {
651
- // Accessed before but not recently enough given its strength — archive
652
- await this.store.updateStage(engram.id, 'archived');
653
- result.memoriesArchived++;
654
- }
655
- }
656
-
657
- // Check archived memories for deletion — only truly orphaned ancient ones
658
- const archived = await this.store.getEngramsByAgent(agentId, 'archived');
659
- for (const engram of archived) {
660
- const ageDays = (Date.now() - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
661
- const edgeCount = await this.store.countAssociationsFor(engram.id);
662
-
663
- if (engram.accessCount === 0 && ageDays > FORGET_DELETE_DAYS && edgeCount === 0) {
664
- // Very old, never accessed, completely isolated → truly forgotten
665
- await this.store.deleteEngram(engram.id);
666
- result.memoriesForgotten++;
667
- }
668
- // Otherwise: stay archived — still searchable, just not in active recall
669
- }
670
-
671
- // --- Phase 6.5: Redundancy pruning ---
672
- // A senior dev doesn't store 30 nearly-identical memories. When multiple
673
- // low-confidence memories are semantically redundant (cosine > 0.85), keep
674
- // only the one with highest accessCount + confidence and archive the rest.
675
- // This naturally defeats volume-based attacks (narcissistic interference,
676
- // spam) while improving signal-to-noise ratio for linked memories.
677
- // High-confidence memories (feedback-confirmed) are never pruned — they
678
- // represent verified knowledge worth keeping even if similar.
679
- // Only consider memories that are both low-confidence AND rarely accessed.
680
- // Memories retrieved 3+ times have proven useful — they stay even if similar
681
- // to others. This prevents pruning seed memories that match bulk templates.
682
- const lowConfEngrams = engrams.filter(e =>
683
- e.confidence < 0.6 && e.accessCount < 3 && e.embedding && e.embedding.length > 0);
684
- const pruned = new Set<string>();
685
- let redundancyCount = 0;
686
-
687
- // Sort by quality: highest accessCount + confidence first (survivors)
688
- const sortedLow = [...lowConfEngrams].sort((a, b) =>
689
- (b.accessCount + b.confidence * 10) - (a.accessCount + a.confidence * 10));
690
-
691
- for (let i = 0; i < sortedLow.length && redundancyCount < MAX_REDUNDANCY_PRUNE_PER_CYCLE; i++) {
692
- if (pruned.has(sortedLow[i].id)) continue;
693
- for (let j = i + 1; j < sortedLow.length && redundancyCount < MAX_REDUNDANCY_PRUNE_PER_CYCLE; j++) {
694
- if (pruned.has(sortedLow[j].id)) continue;
695
- if (!sortedLow[i].embedding || !sortedLow[j].embedding) continue;
696
-
697
- const sim = cosineSimilarity(sortedLow[i].embedding!, sortedLow[j].embedding!);
698
- if (sim >= REDUNDANCY_THRESHOLD) {
699
- const survivorId = sortedLow[i].id;
700
- const prunedId = sortedLow[j].id;
701
-
702
- // Transfer associations from pruned memory to survivor
703
- const prunedEdges = await this.store.getAssociationsFor(prunedId);
704
- for (const edge of prunedEdges) {
705
- const peerId = edge.fromEngramId === prunedId ? edge.toEngramId : edge.fromEngramId;
706
- if (peerId === survivorId) continue; // Skip self-loops
707
- await this.store.upsertAssociation(survivorId, peerId, edge.weight, edge.type, edge.confidence);
708
- }
709
-
710
- // Merge tags from pruned to survivor
711
- const survivor = sortedLow[i];
712
- const prunedMem = sortedLow[j];
713
- const mergedTags = [...new Set([...survivor.tags, ...prunedMem.tags])];
714
- if (mergedTags.length > survivor.tags.length) {
715
- await this.store.updateTags(survivorId, mergedTags);
716
- }
717
-
718
- // Archive the lower-quality duplicate
719
- await this.store.updateStage(prunedId, 'archived');
720
- pruned.add(prunedId);
721
- redundancyCount++;
722
- }
723
- }
724
- }
725
- result.redundancyPruned = redundancyCount;
726
-
727
- // --- Phase 6.7: Confidence drift ---
728
- // Adjust confidence based on structural signals that emerge from the graph.
729
- // This makes confidence evolve over time without explicit feedback calls.
730
- //
731
- // Three signals:
732
- // 1. Well-clustered memories (appeared in 1+ clusters) get a small boost
733
- // — they're integrated into the knowledge graph, likely valuable.
734
- // 2. Isolated memories (0 edges after consolidation) get a small penalty
735
- // — nothing connects to them, possibly noise.
736
- // 3. Neglected memories (not recalled in 30+ days) drift toward 0.3
737
- // — if the system never needs them, they're probably not important.
738
- //
739
- // All adjustments are capped at ±0.03 per cycle to prevent runaway.
740
- // Confidence is floored at 0.15 (never reaches 0 — retraction handles that).
741
- // Confidence is capped at 0.85 (only explicit feedback can push above).
742
- const clusteredIds = new Set<string>();
743
- for (const cluster of clusters) {
744
- for (const e of cluster) clusteredIds.add(e.id);
745
- }
746
-
747
- for (const engram of engrams) {
748
- let drift = 0;
749
- const edgeCount = await this.store.countAssociationsFor(engram.id);
750
- const daysSinceAccess = (Date.now() - engram.lastAccessed.getTime()) / (1000 * 60 * 60 * 24);
751
-
752
- // Signal 1: Cluster membership → small boost
753
- if (clusteredIds.has(engram.id)) {
754
- drift += 0.01;
755
- }
756
-
757
- // Signal 2: Zero edges → small penalty
758
- if (edgeCount === 0) {
759
- drift -= 0.02;
760
- }
761
-
762
- // Signal 3: Long neglect → drift toward 0.3
763
- if (daysSinceAccess > CONFIDENCE_NEGLECT_DAYS && engram.confidence > 0.3) {
764
- drift -= 0.01;
765
- }
766
-
767
- // Apply with cap
768
- if (Math.abs(drift) > 0.001) {
769
- drift = Math.max(-CONFIDENCE_DRIFT_CAP, Math.min(CONFIDENCE_DRIFT_CAP, drift));
770
- const newConf = Math.max(0.15, Math.min(0.85, engram.confidence + drift));
771
- if (Math.abs(newConf - engram.confidence) > 0.001) {
772
- await this.store.updateConfidence(engram.id, newConf);
773
- result.confidenceAdjusted++;
774
- }
775
- }
776
- }
777
-
778
- // --- Phase 7: Sweep staging ---
779
- const staging = (await this.store.getEngramsByAgent(agentId, 'staging'))
780
- .filter(e => e.embedding && e.embedding.length > 0);
781
-
782
- for (const staged of staging) {
783
- const ageMs = Date.now() - staged.createdAt.getTime();
784
-
785
- // Check if this staging memory resonates with any active memory
786
- let maxSim = 0;
787
- for (const active of engrams) {
788
- if (!active.embedding || !staged.embedding) continue;
789
- const sim = cosineSimilarity(staged.embedding, active.embedding);
790
- if (sim > maxSim) maxSim = sim;
791
- }
792
-
793
- if (maxSim >= 0.6) {
794
- // Resonates — promote to active with low confidence (barely made it)
795
- await this.store.updateStage(staged.id, 'active');
796
- await this.store.updateConfidence(staged.id, 0.40);
797
- result.stagingPromoted++;
798
- } else if (ageMs > 24 * 60 * 60 * 1000) {
799
- // Over 24h and no resonance — discard
800
- await this.store.deleteEngram(staged.id);
801
- result.stagingDiscarded++;
802
- }
803
- // Otherwise: leave in staging, maybe next cycle
804
- }
805
-
806
- return result;
807
- }
808
-
809
- /**
810
- * Find clusters of semantically similar memories.
811
- * Greedy agglomerative — each memory belongs to at most one cluster.
812
- * Clusters of size 2+ are returned (pairs count — they link).
813
- */
814
- /**
815
- * Diameter-enforced greedy clustering.
816
- * Single-link entry (cosine ≥ SIMILARITY_THRESHOLD to any member)
817
- * + complete-link diameter (cosine ≥ MIN_PAIRWISE_COS to ALL members).
818
- * Prevents chaining where physics→biophysics→cooking = 1 cluster.
819
- */
820
- private findClusters(engrams: Engram[]): Engram[][] {
821
- const n = engrams.length;
822
- if (n < 2) return [];
823
-
824
- // Precompute pairwise cosine matrix
825
- const sim: number[][] = Array.from({ length: n }, () => Array(n).fill(0));
826
- for (let i = 0; i < n; i++) {
827
- sim[i][i] = 1;
828
- for (let j = i + 1; j < n; j++) {
829
- if (!engrams[i].embedding || !engrams[j].embedding) continue;
830
- const c = cosineSimilarity(engrams[i].embedding!, engrams[j].embedding!);
831
- sim[i][j] = c;
832
- sim[j][i] = c;
833
- }
834
- }
835
-
836
- const unassigned = new Set<number>(Array.from({ length: n }, (_, i) => i));
837
- const clusters: Engram[][] = [];
838
-
839
- const sortedIdxs = Array.from({ length: n }, (_, i) => i)
840
- .sort((a, b) => engrams[b].accessCount - engrams[a].accessCount);
841
-
842
- for (const seedIdx of sortedIdxs) {
843
- if (!unassigned.has(seedIdx)) continue;
844
- unassigned.delete(seedIdx);
845
-
846
- const clusterIdxs: number[] = [seedIdx];
847
- let added = true;
848
-
849
- while (added) {
850
- added = false;
851
- for (const candIdx of Array.from(unassigned)) {
852
- let links = false;
853
- for (const m of clusterIdxs) {
854
- if (sim[candIdx][m] >= SIMILARITY_THRESHOLD) { links = true; break; }
855
- }
856
- if (!links) continue;
857
-
858
- let passesAll = true;
859
- for (const m of clusterIdxs) {
860
- if (sim[candIdx][m] < MIN_PAIRWISE_COS) { passesAll = false; break; }
861
- }
862
- if (!passesAll) continue;
863
-
864
- clusterIdxs.push(candIdx);
865
- unassigned.delete(candIdx);
866
- added = true;
867
- }
868
- }
869
-
870
- if (clusterIdxs.length >= 2) {
871
- clusters.push(clusterIdxs.map(i => engrams[i]));
872
- } else {
873
- unassigned.add(seedIdx);
874
- }
875
- }
876
-
877
- return clusters;
878
- }
879
-
880
- /**
881
- * Compute the centroid (average embedding) of a cluster.
882
- */
883
- private computeCentroid(cluster: Engram[]): number[] {
884
- const withEmbed = cluster.filter(e => e.embedding && e.embedding.length > 0);
885
- if (withEmbed.length === 0) return [];
886
-
887
- const dim = withEmbed[0].embedding!.length;
888
- const centroid = new Array<number>(dim).fill(0);
889
- for (const e of withEmbed) {
890
- for (let i = 0; i < dim; i++) {
891
- centroid[i] += e.embedding![i];
892
- }
893
- }
894
- for (let i = 0; i < dim; i++) {
895
- centroid[i] /= withEmbed.length;
896
- }
897
- return centroid;
898
- }
899
- }
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Sleep Cycle — offline memory consolidation.
5
+ *
6
+ * Models the brain's consolidation during sleep:
7
+ * 1. Replay — find clusters of semantically similar memories
8
+ * 2. Strengthen — reinforce edges within clusters + access-weighted boost
9
+ * 3. Bridge — create cross-cluster shortcuts between related topic areas
10
+ * 4. Decay — weaken unused edges, prune dead ones
11
+ * 5. Homeostasis — normalize outgoing edge weights to prevent hub explosion
12
+ * 6. Forget — archive/delete memories that were never retrieved (age-gated)
13
+ * 7. Sweep — promote or discard uncertain (staging) memories
14
+ *
15
+ * No artificial "summary nodes" are created. Instead, the associative
16
+ * graph gets denser where knowledge overlaps and sparser where it doesn't.
17
+ * The beam search graph walk in activation.ts naturally propagates through
18
+ * these strengthened pathways.
19
+ *
20
+ * Run between sessions or on a timer (e.g., every few hours).
21
+ */
22
+
23
+ import { cosineSimilarity } from '../core/embeddings.js';
24
+ import { strengthenAssociation, decayAssociation } from '../core/hebbian.js';
25
+ import type { Engram } from '../types/index.js';
26
+ import type { IEngramStore as EngramStore } from '../storage/store.js';
27
+ import type { ConnectionEngine } from './connections.js';
28
+ import { setConsolidationActive } from '../core/write-telemetry.js';
29
+
30
+ /** Cosine similarity for initial candidate detection (single-link entry gate) */
31
+ const SIMILARITY_THRESHOLD = 0.65;
32
+
33
+ /** Minimum pairwise cosine for cluster diameter enforcement.
34
+ * Prevents chaining: a candidate must be this similar to ALL cluster members. */
35
+ const MIN_PAIRWISE_COS = 0.50;
36
+
37
+ /** Lower threshold for cross-cluster bridge edges */
38
+ const BRIDGE_THRESHOLD = 0.25;
39
+
40
+ /** Minimum edge weight to form a new connection during replay */
41
+ const INITIAL_EDGE_WEIGHT = 0.3;
42
+
43
+ /** Boost factor for strengthening existing edges between cluster members */
44
+ const CONSOLIDATION_SIGNAL = 0.5;
45
+
46
+ /** Max new edges to create per sleep cycle (prevent graph explosion) */
47
+ const MAX_NEW_EDGES_PER_CYCLE = 50;
48
+
49
+ /** Max bridge edges per cycle (cross-cluster shortcuts) */
50
+ const MAX_BRIDGE_EDGES_PER_CYCLE = 20;
51
+
52
+ /** Edge weight below which we prune during decay */
53
+ const PRUNE_THRESHOLD = 0.01;
54
+
55
+ /** Target total outgoing edge weight per node (homeostasis) */
56
+ const HOMEOSTASIS_TARGET = 10.0;
57
+
58
+ /** Grace period before forgetting curve starts (days) */
59
+ const FORGET_GRACE_DAYS = 7;
60
+
61
+ /** Consolidation cycles before 0-access memories get archived */
62
+ const FORGET_CYCLE_THRESHOLD = 5;
63
+
64
+ /** Percentile of edge count distribution used for forgetting protection (0-1) */
65
+ const EDGE_PROTECTION_PERCENTILE = 0.25;
66
+
67
+ /** Age at which never-retrieved memories get archived (days) */
68
+ const FORGET_ARCHIVE_DAYS = 30;
69
+
70
+ /** Age at which archived, never-retrieved, unconnected memories get deleted (days) */
71
+ const FORGET_DELETE_DAYS = 90;
72
+
73
+ /** Cosine similarity above which two low-confidence memories are considered redundant.
74
+ * MiniLM-L6 paraphrases typically score 0.75-0.88 cosine; 0.85 misses most of them.
75
+ * 0.75 catches paraphrases while keeping precision above 0.60 for unrelated facts. */
76
+ const REDUNDANCY_THRESHOLD = 0.75;
77
+
78
+ /** Max redundant memories to prune per cycle (gradual, not sudden).
79
+ * Raised from 10 to 25 — the eval harness runs multiple cycles anyway,
80
+ * but faster convergence reduces consolidation time for larger pools. */
81
+ const MAX_REDUNDANCY_PRUNE_PER_CYCLE = 25;
82
+
83
+ /** Max confidence drift per consolidation cycle (prevents runaway) */
84
+ const CONFIDENCE_DRIFT_CAP = 0.03;
85
+
86
+ /**
87
+ * Content fade — Paper 1 (PLOS Comp Biology, storage degradation).
88
+ * Memories whose content has not been recalled in a long time get coarsened:
89
+ * the content string is trimmed to FADE_KEEP_CHARS with a "[faded]" marker,
90
+ * while concept, tags, and embedding are preserved.
91
+ *
92
+ * Rationale: human memory degrades the detailed surface but retains the gist
93
+ * and the cue-association pathway. The faded engram still surfaces by tag/concept
94
+ * BM25 and by vector similarity (embedding preserved), but the full content body
95
+ * is gone — which is the correct behavior for a memory that has not been useful
96
+ * enough to rehearse in a long time.
97
+ *
98
+ * Fade comes BEFORE archive in the lifecycle (active → fading → archived → deleted),
99
+ * applied only to engrams that have been accessed at least once. Engrams that have
100
+ * never been accessed go straight to archive via the standard forgetting path —
101
+ * they have no surface worth preserving.
102
+ *
103
+ * Env: AWM_FADE_DAYS_SINCE_ACCESS (default 45),
104
+ * AWM_FADE_KEEP_CHARS (default 150),
105
+ * AWM_FADE_MIN_CONTENT_LEN (default 250),
106
+ * AWM_FADE_MAX_PER_CYCLE (default 25).
107
+ */
108
+ const FADE_DAYS_SINCE_ACCESS = Number(process.env.AWM_FADE_DAYS_SINCE_ACCESS ?? 45);
109
+ const FADE_KEEP_CHARS = Number(process.env.AWM_FADE_KEEP_CHARS ?? 150);
110
+ const FADE_MIN_CONTENT_LEN = Number(process.env.AWM_FADE_MIN_CONTENT_LEN ?? 250);
111
+ const FADE_MAX_PER_CYCLE = Number(process.env.AWM_FADE_MAX_PER_CYCLE ?? 25);
112
+ const FADE_PROTECTED_ACCESS_COUNT = 10;
113
+ const FADE_MARKER = '… [faded]';
114
+ const FADE_PROTECTED_CLASSES = new Set(['canonical', 'structural']);
115
+
116
+ /**
117
+ * Archive floor for fade-protected classes — one year.
118
+ *
119
+ * FADE_PROTECTED_CLASSES was only ever consulted by Phase 5.5 above. Phase 6 below
120
+ * archives on DISUSE and measured disuse as `accessCount === 0`, which is wrong for a
121
+ * source-of-truth class for one specific reason: **a rule, requirement or decision that
122
+ * has not come up YET has accessCount 0 by construction.** Never-needed-so-far and
123
+ * never-needed are not the same thing, and only the second is a reason to file something
124
+ * away. Two of the three archive branches below test accessCount with no strength term at
125
+ * all, so neither the access/confidence clock nor its 12x cap ever applied to them.
126
+ *
127
+ * Measured on the live store 2026-10-12 (`work`+`personal`, retracted=0), before this
128
+ * floor existed: 4,360 canonical engrams archived against 2,058 active, and **2,891 of
129
+ * the archived (66%) had accessCount 0** — i.e. two thirds were taken by the branches no
130
+ * strength factor reaches. Of 1,758 archived `procedural` engrams, 1,160 were zero-access.
131
+ *
132
+ * Deliberately a floor and not an exemption. `if (FADE_PROTECTED_CLASSES.has(...)) continue`
133
+ * would be smaller and is the wrong change twice over: it contradicts this block's own
134
+ * invariant that rehearsal "extends protection but NEVER makes immortal", and it would hold
135
+ * 3,928 more engrams in a 5,314-engram active pool — a 74% increase in what every recall
136
+ * scores against, which is a latency change masquerading as a correctness fix. A year of
137
+ * grace instead of a month costs nothing and keeps the eventual archive real.
138
+ *
139
+ * Env: AWM_PROTECTED_ARCHIVE_DAYS (default 365).
140
+ */
141
+ const PROTECTED_ARCHIVE_DAYS = (() => {
142
+ // NaN would make every `ageDays > floor` false and protected classes immortal; an
143
+ // empty string would be 0 and switch the floor off. Either falls back to the default.
144
+ const v = Number(process.env.AWM_PROTECTED_ARCHIVE_DAYS?.trim() || NaN);
145
+ return Number.isFinite(v) && v >= 0 ? v : 365;
146
+ })();
147
+
148
+ /** Days without recall before confidence starts drifting down */
149
+ const CONFIDENCE_NEGLECT_DAYS = 30;
150
+
151
+ export interface ConsolidationResult {
152
+ clustersFound: number;
153
+ edgesStrengthened: number;
154
+ edgesCreated: number;
155
+ bridgesCreated: number;
156
+ edgesDecayed: number;
157
+ edgesPruned: number;
158
+ edgesNormalized: number;
159
+ memoriesForgotten: number;
160
+ memoriesArchived: number;
161
+ memoriesFaded: number;
162
+ redundancyPruned: number;
163
+ confidenceAdjusted: number;
164
+ stagingPromoted: number;
165
+ stagingDiscarded: number;
166
+ engramsProcessed: number;
167
+ synthesesCreated: number;
168
+ }
169
+
170
+ const MAX_SYNTHESES_PER_CYCLE = 5;
171
+ const MIN_CLUSTER_SIZE_FOR_SYNTHESIS = 3;
172
+
173
+ /** Shared stopwords for synthesis keyword extraction */
174
+ const SYNTH_STOPWORDS = new Set(['the', 'is', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'for',
175
+ 'on', 'with', 'that', 'this', 'it', 'was', 'are', 'be', 'has', 'had', 'but', 'not', 'from',
176
+ 'by', 'as', 'at', 'i', 'you', 'we', 'my', 'your', 'can', 'will', 'do', 'did', 'if', 'user',
177
+ 'assistant', 'would', 'like', 'just', 'also', 'about', 'really', 'think', 'know', 'want',
178
+ 'here', 'there', 'some', 'more', 'very', 'been', 'have', 'what', 'when', 'how', 'they']);
179
+
180
+ export class ConsolidationEngine {
181
+ private store: EngramStore;
182
+ private connectionEngine: ConnectionEngine | null;
183
+
184
+ /**
185
+ * Optional `connectionEngine` is drained at the start of each
186
+ * consolidation pass (v0.8.2). Connection discovery is no longer
187
+ * triggered per write — the work batches into the existing sleep cycle
188
+ * so writes return in ~50 ms instead of being pegged by an inline
189
+ * embed + rerank cycle (~200-500 ms/write under load).
190
+ */
191
+ constructor(store: EngramStore, connectionEngine?: ConnectionEngine) {
192
+ this.store = store;
193
+ this.connectionEngine = connectionEngine ?? null;
194
+ }
195
+
196
+ /**
197
+ * Run a full sleep cycle for an agent.
198
+ *
199
+ * Phase 0: Connection drain — discover associations for engrams enqueued
200
+ * by writes since the last consolidation (v0.8.2)
201
+ * Phase 1: Replay — find clusters of semantically similar memories
202
+ * Phase 2: Strengthen — reinforce edges within clusters (access-weighted)
203
+ * Phase 3: Bridge — create cross-cluster shortcuts
204
+ * Phase 4: Decay — weaken unused edges, prune dead ones
205
+ * Phase 5: Homeostasis — normalize outgoing edge weights per node
206
+ * Phase 5.5: Content fade — coarsen un-recalled engrams (Paper 1)
207
+ * Phase 6: Forget — archive/delete memories never retrieved (age-gated)
208
+ * Phase 6.5: Redundancy prune
209
+ * Phase 6.7: Confidence drift — adjust confidence based on structural signals
210
+ * Phase 7: Sweep — check staging buffer for resonance
211
+ */
212
+ async consolidate(agentId: string): Promise<ConsolidationResult> {
213
+ // D1 telemetry (2026-07-30): flag the cycle so slow-write reports can
214
+ // attribute stalls to in-process consolidation. try/finally guarantees
215
+ // the flag clears even when a phase throws.
216
+ setConsolidationActive(true, agentId);
217
+ const tCycleStart = performance.now();
218
+ try {
219
+ return await this.consolidateInner(agentId);
220
+ } finally {
221
+ setConsolidationActive(false);
222
+ const ms = Math.round(performance.now() - tCycleStart);
223
+ if (ms > 5000) {
224
+ process.stderr.write(`[awm] consolidation cycle for ${agentId} took ${ms}ms\n`);
225
+ }
226
+ }
227
+ }
228
+
229
+ private async consolidateInner(agentId: string): Promise<ConsolidationResult> {
230
+ // --- Phase 0: Connection drain ---
231
+ // Discover associations for engrams enqueued by writes since the last
232
+ // consolidation. Per-write inline discovery was removed in v0.8.2
233
+ // because each findConnections() call runs a full activation cycle
234
+ // (embed + rerank, ~200-500 ms event-loop block per write).
235
+ if (this.connectionEngine) {
236
+ try { await this.connectionEngine.processQueue(); } catch { /* best-effort */ }
237
+ }
238
+ const result: ConsolidationResult = {
239
+ clustersFound: 0,
240
+ edgesStrengthened: 0,
241
+ edgesCreated: 0,
242
+ bridgesCreated: 0,
243
+ edgesDecayed: 0,
244
+ edgesPruned: 0,
245
+ edgesNormalized: 0,
246
+ memoriesForgotten: 0,
247
+ memoriesArchived: 0,
248
+ memoriesFaded: 0,
249
+ redundancyPruned: 0,
250
+ confidenceAdjusted: 0,
251
+ stagingPromoted: 0,
252
+ stagingDiscarded: 0,
253
+ engramsProcessed: 0,
254
+ synthesesCreated: 0,
255
+ };
256
+
257
+ // --- Phase 1: Replay ---
258
+ // Get all active engrams, backfill missing embeddings
259
+ const allActive = await this.store.getEngramsByAgent(agentId, 'active');
260
+ const needsEmbedding = allActive.filter(e => !e.embedding || e.embedding.length === 0);
261
+ if (needsEmbedding.length > 0) {
262
+ try {
263
+ const { embedBatch, getModelId } = await import('../core/embeddings.js');
264
+ const BATCH_SIZE = 32;
265
+ const modelId = getModelId?.() ?? 'unknown';
266
+ for (let b = 0; b < needsEmbedding.length; b += BATCH_SIZE) {
267
+ const batch = needsEmbedding.slice(b, b + BATCH_SIZE);
268
+ try {
269
+ const texts = batch.map(e => `${e.concept} ${e.content}`);
270
+ const vecs = await embedBatch(texts, { lane: 'background' });
271
+ for (let j = 0; j < batch.length; j++) {
272
+ await this.store.updateEmbedding(batch[j].id, vecs[j], modelId);
273
+ batch[j].embedding = vecs[j];
274
+ }
275
+ } catch { /* batch failed, skip — non-fatal */ }
276
+ }
277
+ if (needsEmbedding.length > 0) {
278
+ console.log(`[consolidation] Backfilled ${needsEmbedding.filter(e => e.embedding?.length).length}/${needsEmbedding.length} embeddings (model: ${modelId})`);
279
+ }
280
+ } catch { /* embeddings module unavailable */ }
281
+ }
282
+ const engrams = allActive.filter(e => e.embedding && e.embedding.length > 0);
283
+
284
+ result.engramsProcessed = engrams.length;
285
+ // Clustering requires at least two engrams to form pairs. Per-engram phases
286
+ // (fade, forget, confidence drift, staging sweep) still run for singletons —
287
+ // they don't need a graph.
288
+ const clusters = engrams.length >= 2 ? this.findClusters(engrams) : [];
289
+ result.clustersFound = clusters.length;
290
+
291
+ // --- Phase 2: Strengthen (access-weighted) ---
292
+ // Memories that are retrieved more often get stronger consolidation.
293
+ // This mirrors how the brain preferentially consolidates practiced memories.
294
+ let newEdges = 0;
295
+ for (const cluster of clusters) {
296
+ for (let i = 0; i < cluster.length; i++) {
297
+ for (let j = i + 1; j < cluster.length; j++) {
298
+ const a = cluster[i];
299
+ const b = cluster[j];
300
+
301
+ // Access-weighted signal: more retrieved = stronger consolidation
302
+ const accessFactor = Math.min(
303
+ 1.0,
304
+ 0.3 + 0.7 * Math.log1p(a.accessCount + b.accessCount) / Math.log1p(20),
305
+ );
306
+
307
+ const existing = await this.store.getAssociation(a.id, b.id);
308
+ if (existing) {
309
+ const newWeight = strengthenAssociation(
310
+ existing.weight, CONSOLIDATION_SIGNAL * accessFactor, 0.25,
311
+ );
312
+ await this.store.upsertAssociation(
313
+ a.id, b.id, newWeight, existing.type, existing.confidence,
314
+ );
315
+ result.edgesStrengthened++;
316
+ } else if (newEdges < MAX_NEW_EDGES_PER_CYCLE) {
317
+ await this.store.upsertAssociation(
318
+ a.id, b.id, INITIAL_EDGE_WEIGHT * accessFactor, 'connection',
319
+ );
320
+ newEdges++;
321
+ result.edgesCreated++;
322
+ }
323
+ }
324
+ }
325
+ }
326
+
327
+ // --- Phase 2.5: Two types of synthesis ---
328
+ //
329
+ // Type A: SESSION SYNTHESIS (perfect recall)
330
+ // Groups by shared metadata tags (sid=, proj=, topic=).
331
+ // Summarizes what happened in a conversation/project session.
332
+ // Helps find specific facts by providing a topical anchor.
333
+ //
334
+ // Type B: PATTERN SYNTHESIS (novel recall)
335
+ // Uses the existing vector-similarity clusters.
336
+ // Finds structural patterns across disparate topics.
337
+ // "Debugging X by Y" + "Resolving A by B" → pattern: "conflict → ordering"
338
+ // Lower confidence — these are speculative connections, not facts.
339
+
340
+ let synthCount = 0;
341
+
342
+ // --- Type A: Session synthesis (tag-based grouping) ---
343
+ // Group engrams by shared session/project tags, NOT vector similarity
344
+ const tagGroups = new Map<string, Engram[]>();
345
+ for (const e of engrams) {
346
+ if (e.tags.includes('synth=true')) continue; // Skip existing syntheses
347
+ for (const tag of e.tags) {
348
+ if (tag.startsWith('sid=') || tag.startsWith('proj=') || tag.startsWith('topic=')) {
349
+ const group = tagGroups.get(tag) ?? [];
350
+ group.push(e);
351
+ tagGroups.set(tag, group);
352
+ }
353
+ }
354
+ }
355
+
356
+ for (const [tag, group] of tagGroups) {
357
+ if (group.length < MIN_CLUSTER_SIZE_FOR_SYNTHESIS) continue;
358
+ if (synthCount >= MAX_SYNTHESES_PER_CYCLE) break;
359
+
360
+ // Check if a synthesis for this tag already exists
361
+ const existing = engrams.find(e =>
362
+ e.tags.includes('synth=true') && e.tags.includes(tag)
363
+ );
364
+ if (existing) continue;
365
+
366
+ // Extract key terms from this group
367
+ const wordCounts = new Map<string, number>();
368
+ for (const e of group) {
369
+ const words = e.content.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/);
370
+ for (const w of words) {
371
+ if (w.length > 3 && !SYNTH_STOPWORDS.has(w)) {
372
+ wordCounts.set(w, (wordCounts.get(w) ?? 0) + 1);
373
+ }
374
+ }
375
+ }
376
+ const keyTerms = [...wordCounts.entries()]
377
+ .sort((a, b) => b[1] - a[1])
378
+ .slice(0, 15)
379
+ .map(([word]) => word);
380
+
381
+ // Extract unique concepts (deduplicated)
382
+ const concepts = [...new Set(group.map(e => e.concept))];
383
+
384
+ const synthContent = [
385
+ `Session summary (${tag}, ${group.length} turns).`,
386
+ `Key topics: ${keyTerms.slice(0, 8).join(', ')}.`,
387
+ `Discussed: ${keyTerms.slice(8).join(', ')}.`,
388
+ ].join(' ');
389
+
390
+ const synthEngram = await this.store.createEngram({
391
+ agentId,
392
+ concept: `session: ${tag} (${keyTerms.slice(0, 3).join(', ')})`,
393
+ content: synthContent,
394
+ tags: [tag, 'synth=true', 'synth-type=session'],
395
+ salience: 0.6,
396
+ confidence: 0.55,
397
+ memoryType: 'semantic',
398
+ });
399
+
400
+ // Set embedding to group centroid
401
+ const centroid = this.computeCentroid(group);
402
+ if (centroid.length > 0) {
403
+ await this.store.updateEmbedding(synthEngram.id, centroid);
404
+ }
405
+
406
+ // Link to sources
407
+ for (const source of group.slice(0, 10)) { // Cap links to prevent explosion
408
+ await this.store.upsertAssociation(synthEngram.id, source.id, 0.4, 'causal');
409
+ }
410
+
411
+ synthCount++;
412
+ result.synthesesCreated++;
413
+ }
414
+
415
+ // --- Type B: Pattern synthesis (vector-similarity clusters, speculative) ---
416
+ // Only create these for clusters where members come from DIFFERENT sessions/projects.
417
+ // This finds cross-domain patterns: "debugging technique A" + "architecture pattern B"
418
+ for (const cluster of clusters) {
419
+ if (cluster.length < MIN_CLUSTER_SIZE_FOR_SYNTHESIS) continue;
420
+ if (synthCount >= MAX_SYNTHESES_PER_CYCLE) break;
421
+ if (cluster.some(e => e.tags.includes('synth=true'))) continue;
422
+
423
+ // Only create pattern synthesis if cluster spans multiple sessions
424
+ const sessionTags = new Set<string>();
425
+ for (const e of cluster) {
426
+ for (const tag of e.tags) {
427
+ if (tag.startsWith('sid=') || tag.startsWith('proj=')) sessionTags.add(tag);
428
+ }
429
+ }
430
+ if (sessionTags.size < 2) continue; // Same session → skip (Type A handles it)
431
+
432
+ const wordCounts = new Map<string, number>();
433
+ for (const e of cluster) {
434
+ const words = e.content.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/);
435
+ for (const w of words) {
436
+ if (w.length > 3 && !SYNTH_STOPWORDS.has(w)) {
437
+ wordCounts.set(w, (wordCounts.get(w) ?? 0) + 1);
438
+ }
439
+ }
440
+ }
441
+ const keyTerms = [...wordCounts.entries()]
442
+ .sort((a, b) => b[1] - a[1])
443
+ .slice(0, 10)
444
+ .map(([word]) => word);
445
+
446
+ const synthContent = [
447
+ `Pattern across ${sessionTags.size} sessions (${cluster.length} memories).`,
448
+ `Common themes: ${keyTerms.join(', ')}.`,
449
+ ].join(' ');
450
+
451
+ const synthEngram = await this.store.createEngram({
452
+ agentId,
453
+ concept: `pattern: ${keyTerms.slice(0, 3).join(', ')}`,
454
+ content: synthContent,
455
+ tags: [...sessionTags, 'synth=true', 'synth-type=pattern'],
456
+ salience: 0.5, // Lower — speculative
457
+ confidence: 0.4, // Lower — these are hypotheses not facts
458
+ memoryType: 'semantic',
459
+ });
460
+
461
+ const centroid = this.computeCentroid(cluster);
462
+ if (centroid.length > 0) {
463
+ await this.store.updateEmbedding(synthEngram.id, centroid);
464
+ }
465
+
466
+ for (const source of cluster.slice(0, 8)) {
467
+ await this.store.upsertAssociation(synthEngram.id, source.id, 0.3, 'bridge');
468
+ }
469
+
470
+ synthCount++;
471
+ result.synthesesCreated++;
472
+ }
473
+
474
+ // --- Phase 3: Direct cross-cluster bridging ---
475
+ // Find the closest pair of memories between each cluster pair and bridge them.
476
+ if (clusters.length >= 2) {
477
+ const MIN_BRIDGE_SIM = 0.15;
478
+ let bridges = 0;
479
+ for (let i = 0; i < clusters.length && bridges < MAX_BRIDGE_EDGES_PER_CYCLE; i++) {
480
+ for (let j = i + 1; j < clusters.length && bridges < MAX_BRIDGE_EDGES_PER_CYCLE; j++) {
481
+ let bestSim = -1;
482
+ let bestA: Engram | null = null;
483
+ let bestB: Engram | null = null;
484
+ for (const a of clusters[i]) {
485
+ if (!a.embedding) continue;
486
+ for (const b of clusters[j]) {
487
+ if (!b.embedding) continue;
488
+ const s = cosineSimilarity(a.embedding, b.embedding);
489
+ if (s > bestSim) { bestSim = s; bestA = a; bestB = b; }
490
+ }
491
+ }
492
+ if (bestA && bestB && bestSim > MIN_BRIDGE_SIM) {
493
+ const existing = await this.store.getAssociation(bestA.id, bestB.id);
494
+ if (!existing) {
495
+ await this.store.upsertAssociation(bestA.id, bestB.id, bestSim, 'bridge');
496
+ await this.store.upsertAssociation(bestB.id, bestA.id, bestSim, 'bridge');
497
+ bridges++;
498
+ result.bridgesCreated++;
499
+ }
500
+ }
501
+ }
502
+ }
503
+ }
504
+
505
+ // --- Phase 4: Decay (confidence-modulated) ---
506
+ // High-confidence edges decay slower. This means edges between memories
507
+ // that received positive feedback are more durable — just like how
508
+ // practiced memories are more resistant to forgetting in the brain.
509
+ // Base half-life: 7 days. High-confidence (0.8+) gets up to 30 days.
510
+ const engramMap = new Map(engrams.map(e => [e.id, e]));
511
+ const associations = await this.store.getAllAssociations(agentId);
512
+ for (const assoc of associations) {
513
+ const daysSince =
514
+ (Date.now() - assoc.lastActivated.getTime()) / (1000 * 60 * 60 * 24);
515
+ if (daysSince < 0.5) continue;
516
+
517
+ const fromEngram = engramMap.get(assoc.fromEngramId);
518
+ const toEngram = engramMap.get(assoc.toEngramId);
519
+ const fromConf = fromEngram?.confidence ?? 0.5;
520
+ const toConf = toEngram?.confidence ?? 0.5;
521
+ const maxConf = Math.max(fromConf, toConf);
522
+ const maxAccess = Math.max(fromEngram?.accessCount ?? 0, toEngram?.accessCount ?? 0);
523
+ const accessBoost = Math.min(2.0, 1.0 + 0.5 * Math.log1p(maxAccess));
524
+ const halfLifeDays = Math.min(
525
+ 7 * (1 + 2 * Math.max(0, (maxConf - 0.5) / 0.5)) * accessBoost,
526
+ 42 // Hard cap: 6 weeks max
527
+ );
528
+
529
+ const newWeight = decayAssociation(assoc.weight, daysSince, halfLifeDays);
530
+ if (newWeight < PRUNE_THRESHOLD) {
531
+ await this.store.deleteAssociation(assoc.id);
532
+ result.edgesPruned++;
533
+ } else if (Math.abs(newWeight - assoc.weight) > 0.001) {
534
+ await this.store.upsertAssociation(
535
+ assoc.fromEngramId, assoc.toEngramId,
536
+ newWeight, assoc.type, assoc.confidence,
537
+ );
538
+ result.edgesDecayed++;
539
+ }
540
+ }
541
+
542
+ // --- Phase 5: Synaptic homeostasis ---
543
+ // Normalize total outgoing edge weight per node to prevent hub explosion.
544
+ // Nodes with many strong edges get scaled down so relative weights stay meaningful.
545
+ const engramIds = new Set(engrams.map(e => e.id));
546
+ for (const id of engramIds) {
547
+ const outgoing = await this.store.getOutgoingAssociations(id);
548
+ const totalWeight = outgoing.reduce((sum, a) => sum + a.weight, 0);
549
+ if (totalWeight > HOMEOSTASIS_TARGET) {
550
+ const scale = HOMEOSTASIS_TARGET / totalWeight;
551
+ for (const edge of outgoing) {
552
+ const newWeight = edge.weight * scale;
553
+ if (newWeight < PRUNE_THRESHOLD) {
554
+ await this.store.deleteAssociation(edge.id);
555
+ result.edgesPruned++;
556
+ } else {
557
+ await this.store.upsertAssociation(
558
+ edge.fromEngramId, edge.toEngramId,
559
+ newWeight, edge.type, edge.confidence,
560
+ );
561
+ }
562
+ }
563
+ result.edgesNormalized++;
564
+ }
565
+ }
566
+
567
+ // --- Phase 5.5: Content fade (Paper 1: storage degradation) ---
568
+ // Coarsens engrams that have been accessed before but have gone stale.
569
+ // Trims content surface area while preserving concept, tags, and embedding so
570
+ // the engram still participates in BM25 + vector recall — just with less
571
+ // body to score against. This models how human memory loses surface detail
572
+ // while retaining cue-association pathways.
573
+ //
574
+ // Fade applies only to accessed engrams (the never-accessed ones go straight
575
+ // to archive via Phase 6). Heavily-used (accessCount >= FADE_PROTECTED_ACCESS_COUNT),
576
+ // canonical, structural, short-content, and UNFINISHED TASK engrams are excluded.
577
+ let fadeCount = 0;
578
+ for (const engram of engrams) {
579
+ if (fadeCount >= FADE_MAX_PER_CYCLE) break;
580
+ if (engram.accessCount === 0) continue;
581
+ if (engram.accessCount >= FADE_PROTECTED_ACCESS_COUNT) continue;
582
+ if (FADE_PROTECTED_CLASSES.has(engram.memoryClass)) continue;
583
+ // An unfinished task is a COMMITMENT, not a stale observation, and its body is
584
+ // where the acceptance criteria, source meeting, file paths and blocking reason
585
+ // live — the concept is 3-10 words by schema, so fading a task leaves a title
586
+ // nobody can action. Measured 2026-10-10 on the live store before this guard
587
+ // existed: 19 task bodies had already been cut to 150 chars, and `blocked` tasks
588
+ // were hit at 29% (5/17) against 1.2% (3/243) for `open` — ~24x — precisely
589
+ // BECAUSE a blocked task accrues idle days by definition while it waits on
590
+ // someone else, which is also when its body matters most. Only 1 of the 19 was
591
+ // recoverable from any backup. `done` tasks still fade: the work is finished and
592
+ // the gist suffices. memory_task_add sets salience 0.9 but never memoryClass, so
593
+ // FADE_PROTECTED_CLASSES above never covers a task.
594
+ if (engram.taskStatus && engram.taskStatus !== 'done') continue;
595
+ if (engram.retracted) continue;
596
+ if (engram.content.length < FADE_MIN_CONTENT_LEN) continue;
597
+ if (engram.content.endsWith(FADE_MARKER)) continue; // Already faded
598
+ const daysSinceAccess = (Date.now() - engram.lastAccessed.getTime()) / (1000 * 60 * 60 * 24);
599
+ if (daysSinceAccess < FADE_DAYS_SINCE_ACCESS) continue;
600
+
601
+ const trimmed = engram.content.slice(0, FADE_KEEP_CHARS).trimEnd() + FADE_MARKER;
602
+ await this.store.updateContent(engram.id, trimmed);
603
+ await this.store.updateStage(engram.id, 'fading');
604
+ engram.content = trimmed;
605
+ engram.stage = 'fading';
606
+ result.memoriesFaded++;
607
+ fadeCount++;
608
+ }
609
+
610
+ // --- Phase 6: Forgetting (age-gated) ---
611
+ // Models how human memory actually works:
612
+ // - New memories get a grace period (too new to judge)
613
+ // - Retrieval acts as rehearsal — resets the forgetting clock
614
+ // - Well-connected memories persist (edges = integration into knowledge)
615
+ // - Old, isolated, unretrieved memories fade to archive (not deleted)
616
+ // - Archived memories can still be recovered via deep search
617
+ // - Only truly orphaned, ancient memories get deleted
618
+ //
619
+ // Key insight: outdated memories still have value as historical context.
620
+ // "We used to use X" helps explain why we now use Y.
621
+ // Compute edge count percentile for relative protection threshold.
622
+ // With avg 12 edges/node, an absolute threshold of 3 protects everything.
623
+ // Use 25th percentile so "weakly connected" is relative to actual graph density.
624
+ const edgeCounts = await Promise.all(engrams.map(async e => await this.store.countAssociationsFor(e.id)));
625
+ edgeCounts.sort((a, b) => a - b);
626
+ const percentileIdx = Math.floor(edgeCounts.length * EDGE_PROTECTION_PERCENTILE);
627
+ const baseEdgeThreshold = edgeCounts.length > 0 ? edgeCounts[percentileIdx] : 3;
628
+
629
+ // Get consolidation cycle count for cycle-based archiving
630
+ const cycleCount = await this.store.getConsolidationCycleCount(agentId);
631
+
632
+ for (const engram of engrams) {
633
+ // Skip engrams that just transitioned to 'fading' in Phase 5.5 this cycle.
634
+ // Fade is intended as an intermediate stage between active and archived —
635
+ // the engram should live in 'fading' for at least one more recall window
636
+ // before being eligible for archival. Without this guard, a same-cycle
637
+ // fade-then-archive sequence collapses the active pool to zero (observed
638
+ // in the stress test scale phase around cycle 90).
639
+ if (engram.stage !== 'active') continue;
640
+
641
+ const ageDays = (Date.now() - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
642
+ if (ageDays < FORGET_GRACE_DAYS) continue; // Grace period — too new to judge
643
+
644
+ // Source-of-truth classes get a one-year floor on every branch below, because
645
+ // "not recalled yet" is the normal state of a rule nobody has needed yet.
646
+ // See PROTECTED_ARCHIVE_DAYS for the measurement this came from.
647
+ // A superseded engram is not the source of truth any more: its successor is, so it
648
+ // gets no floor and leaves the active pool on the ordinary clock.
649
+ const protectedFloorDays = FADE_PROTECTED_CLASSES.has(engram.memoryClass) && !engram.supersededBy
650
+ ? PROTECTED_ARCHIVE_DAYS
651
+ : 0;
652
+
653
+ const edgeCount = await this.store.countAssociationsFor(engram.id);
654
+
655
+ // Use relative threshold (percentile-based) instead of absolute.
656
+ // High-confidence memories need fewer edges to survive.
657
+ const confReduction = engram.confidence > 0.7
658
+ ? Math.min(0.6, (engram.confidence - 0.7) * 2)
659
+ : 0;
660
+ const edgeProtectionThreshold = Math.max(1, Math.round(baseEdgeThreshold * (1 - confReduction)));
661
+ if (edgeCount > edgeProtectionThreshold) continue;
662
+
663
+ // Cycle-based archive: 0-access memories archived after N cycles
664
+ // regardless of age. Handles small pools where time thresholds are too generous.
665
+ if (engram.accessCount === 0 && cycleCount >= FORGET_CYCLE_THRESHOLD
666
+ && ageDays > protectedFloorDays) {
667
+ await this.store.updateStage(engram.id, 'archived');
668
+ result.memoriesArchived++;
669
+ continue;
670
+ }
671
+
672
+ // Compute effective forgetting threshold based on memory strength signals.
673
+ // Rehearsal (access + feedback) extends protection but NEVER makes immortal.
674
+ // Models a sharp 20-year senior dev: confirmed knowledge persists for months/years.
675
+ // - Base: FORGET_ARCHIVE_DAYS (30 days)
676
+ // - Access extends by log-scaled factor: 5 accesses ≈ 2x, 10 ≈ 2.5x
677
+ // - Confidence modulates up to 4x (0.5→1x, 0.7→2.6x, 0.8→3.4x, 1.0→4x)
678
+ // - Hard cap: 12x base (360 days) — even the sharpest memory fades after a year
679
+ const accessFactor = 1 + Math.log1p(engram.accessCount) * 0.6;
680
+ const confFactor = 1 + 3 * Math.max(0, (engram.confidence - 0.5) / 0.5);
681
+ const effectiveArchiveDays = Math.min(
682
+ FORGET_ARCHIVE_DAYS * accessFactor * confFactor,
683
+ FORGET_ARCHIVE_DAYS * 12, // Hard cap: 12x base (360 days)
684
+ );
685
+
686
+ const daysSinceAccess = (Date.now() - engram.lastAccessed.getTime()) / (1000 * 60 * 60 * 24);
687
+
688
+ if (engram.accessCount === 0 && ageDays > Math.max(FORGET_ARCHIVE_DAYS, protectedFloorDays)) {
689
+ // Never retrieved, old, weakly connected → archive
690
+ await this.store.updateStage(engram.id, 'archived');
691
+ result.memoriesArchived++;
692
+ } else if (engram.accessCount > 0
693
+ && daysSinceAccess > Math.max(effectiveArchiveDays, protectedFloorDays)) {
694
+ // Accessed before but not recently enough given its strength — archive
695
+ await this.store.updateStage(engram.id, 'archived');
696
+ result.memoriesArchived++;
697
+ }
698
+ }
699
+
700
+ // Check archived memories for deletion — only truly orphaned ancient ones
701
+ const archived = await this.store.getEngramsByAgent(agentId, 'archived');
702
+ for (const engram of archived) {
703
+ const ageDays = (Date.now() - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
704
+ const edgeCount = await this.store.countAssociationsFor(engram.id);
705
+
706
+ // A fade-protected class is never deleted. This branch is the only irreversible
707
+ // step in the whole cycle, and it ran on the same `accessCount === 0` reading of
708
+ // disuse that PROTECTED_ARCHIVE_DAYS exists to correct — so a canonical decision
709
+ // nobody had needed yet was eligible for permanent deletion 90 days after an
710
+ // archive it should not have had. Archiving such an engram is recoverable;
711
+ // deleting it is not, so the floor is an exemption here rather than a longer clock.
712
+ if (FADE_PROTECTED_CLASSES.has(engram.memoryClass)) continue;
713
+
714
+ if (engram.accessCount === 0 && ageDays > FORGET_DELETE_DAYS && edgeCount === 0) {
715
+ // Very old, never accessed, completely isolated → truly forgotten
716
+ await this.store.deleteEngram(engram.id);
717
+ result.memoriesForgotten++;
718
+ }
719
+ // Otherwise: stay archived — still searchable, just not in active recall
720
+ }
721
+
722
+ // --- Phase 6.5: Redundancy pruning ---
723
+ // A senior dev doesn't store 30 nearly-identical memories. When multiple
724
+ // low-confidence memories are semantically redundant (cosine > 0.85), keep
725
+ // only the one with highest accessCount + confidence and archive the rest.
726
+ // This naturally defeats volume-based attacks (narcissistic interference,
727
+ // spam) while improving signal-to-noise ratio for linked memories.
728
+ // High-confidence memories (feedback-confirmed) are never pruned — they
729
+ // represent verified knowledge worth keeping even if similar.
730
+ // Only consider memories that are both low-confidence AND rarely accessed.
731
+ // Memories retrieved 3+ times have proven useful — they stay even if similar
732
+ // to others. This prevents pruning seed memories that match bulk templates.
733
+ const lowConfEngrams = engrams.filter(e =>
734
+ e.confidence < 0.6 && e.accessCount < 3 && e.embedding && e.embedding.length > 0);
735
+ const pruned = new Set<string>();
736
+ let redundancyCount = 0;
737
+
738
+ // Sort by quality: highest accessCount + confidence first (survivors)
739
+ const sortedLow = [...lowConfEngrams].sort((a, b) =>
740
+ (b.accessCount + b.confidence * 10) - (a.accessCount + a.confidence * 10));
741
+
742
+ for (let i = 0; i < sortedLow.length && redundancyCount < MAX_REDUNDANCY_PRUNE_PER_CYCLE; i++) {
743
+ if (pruned.has(sortedLow[i].id)) continue;
744
+ for (let j = i + 1; j < sortedLow.length && redundancyCount < MAX_REDUNDANCY_PRUNE_PER_CYCLE; j++) {
745
+ if (pruned.has(sortedLow[j].id)) continue;
746
+ if (!sortedLow[i].embedding || !sortedLow[j].embedding) continue;
747
+
748
+ const sim = cosineSimilarity(sortedLow[i].embedding!, sortedLow[j].embedding!);
749
+ if (sim >= REDUNDANCY_THRESHOLD) {
750
+ const survivorId = sortedLow[i].id;
751
+ const prunedId = sortedLow[j].id;
752
+
753
+ // Transfer associations from pruned memory to survivor
754
+ const prunedEdges = await this.store.getAssociationsFor(prunedId);
755
+ for (const edge of prunedEdges) {
756
+ const peerId = edge.fromEngramId === prunedId ? edge.toEngramId : edge.fromEngramId;
757
+ if (peerId === survivorId) continue; // Skip self-loops
758
+ await this.store.upsertAssociation(survivorId, peerId, edge.weight, edge.type, edge.confidence);
759
+ }
760
+
761
+ // Merge tags from pruned to survivor
762
+ const survivor = sortedLow[i];
763
+ const prunedMem = sortedLow[j];
764
+ const mergedTags = [...new Set([...survivor.tags, ...prunedMem.tags])];
765
+ if (mergedTags.length > survivor.tags.length) {
766
+ await this.store.updateTags(survivorId, mergedTags);
767
+ }
768
+
769
+ // Archive the lower-quality duplicate
770
+ await this.store.updateStage(prunedId, 'archived');
771
+ pruned.add(prunedId);
772
+ redundancyCount++;
773
+ }
774
+ }
775
+ }
776
+ result.redundancyPruned = redundancyCount;
777
+
778
+ // --- Phase 6.7: Confidence drift ---
779
+ // Adjust confidence based on structural signals that emerge from the graph.
780
+ // This makes confidence evolve over time without explicit feedback calls.
781
+ //
782
+ // Three signals:
783
+ // 1. Well-clustered memories (appeared in 1+ clusters) get a small boost
784
+ // — they're integrated into the knowledge graph, likely valuable.
785
+ // 2. Isolated memories (0 edges after consolidation) get a small penalty
786
+ // — nothing connects to them, possibly noise.
787
+ // 3. Neglected memories (not recalled in 30+ days) drift toward 0.3
788
+ // — if the system never needs them, they're probably not important.
789
+ //
790
+ // All adjustments are capped at ±0.03 per cycle to prevent runaway.
791
+ // Confidence is floored at 0.15 (never reaches 0 — retraction handles that).
792
+ // Confidence is capped at 0.85 (only explicit feedback can push above).
793
+ const clusteredIds = new Set<string>();
794
+ for (const cluster of clusters) {
795
+ for (const e of cluster) clusteredIds.add(e.id);
796
+ }
797
+
798
+ for (const engram of engrams) {
799
+ let drift = 0;
800
+ const edgeCount = await this.store.countAssociationsFor(engram.id);
801
+ const daysSinceAccess = (Date.now() - engram.lastAccessed.getTime()) / (1000 * 60 * 60 * 24);
802
+
803
+ // Signal 1: Cluster membership → small boost
804
+ if (clusteredIds.has(engram.id)) {
805
+ drift += 0.01;
806
+ }
807
+
808
+ // Signal 2: Zero edges → small penalty
809
+ if (edgeCount === 0) {
810
+ drift -= 0.02;
811
+ }
812
+
813
+ // Signal 3: Long neglect → drift toward 0.3
814
+ if (daysSinceAccess > CONFIDENCE_NEGLECT_DAYS && engram.confidence > 0.3) {
815
+ drift -= 0.01;
816
+ }
817
+
818
+ // Apply with cap
819
+ if (Math.abs(drift) > 0.001) {
820
+ drift = Math.max(-CONFIDENCE_DRIFT_CAP, Math.min(CONFIDENCE_DRIFT_CAP, drift));
821
+ const newConf = Math.max(0.15, Math.min(0.85, engram.confidence + drift));
822
+ if (Math.abs(newConf - engram.confidence) > 0.001) {
823
+ await this.store.updateConfidence(engram.id, newConf);
824
+ result.confidenceAdjusted++;
825
+ }
826
+ }
827
+ }
828
+
829
+ // --- Phase 7: Sweep staging ---
830
+ const staging = (await this.store.getEngramsByAgent(agentId, 'staging'))
831
+ .filter(e => e.embedding && e.embedding.length > 0);
832
+
833
+ for (const staged of staging) {
834
+ const ageMs = Date.now() - staged.createdAt.getTime();
835
+
836
+ // Check if this staging memory resonates with any active memory
837
+ let maxSim = 0;
838
+ for (const active of engrams) {
839
+ if (!active.embedding || !staged.embedding) continue;
840
+ const sim = cosineSimilarity(staged.embedding, active.embedding);
841
+ if (sim > maxSim) maxSim = sim;
842
+ }
843
+
844
+ if (maxSim >= 0.6) {
845
+ // Resonates — promote to active with low confidence (barely made it)
846
+ await this.store.updateStage(staged.id, 'active');
847
+ await this.store.updateConfidence(staged.id, 0.40);
848
+ result.stagingPromoted++;
849
+ } else if (ageMs > 24 * 60 * 60 * 1000) {
850
+ // Over 24h and no resonance — discard
851
+ await this.store.deleteEngram(staged.id);
852
+ result.stagingDiscarded++;
853
+ }
854
+ // Otherwise: leave in staging, maybe next cycle
855
+ }
856
+
857
+ return result;
858
+ }
859
+
860
+ /**
861
+ * Find clusters of semantically similar memories.
862
+ * Greedy agglomerative — each memory belongs to at most one cluster.
863
+ * Clusters of size 2+ are returned (pairs count — they link).
864
+ */
865
+ /**
866
+ * Diameter-enforced greedy clustering.
867
+ * Single-link entry (cosine ≥ SIMILARITY_THRESHOLD to any member)
868
+ * + complete-link diameter (cosine ≥ MIN_PAIRWISE_COS to ALL members).
869
+ * Prevents chaining where physics→biophysics→cooking = 1 cluster.
870
+ */
871
+ private findClusters(engrams: Engram[]): Engram[][] {
872
+ const n = engrams.length;
873
+ if (n < 2) return [];
874
+
875
+ // Precompute pairwise cosine matrix
876
+ const sim: number[][] = Array.from({ length: n }, () => Array(n).fill(0));
877
+ for (let i = 0; i < n; i++) {
878
+ sim[i][i] = 1;
879
+ for (let j = i + 1; j < n; j++) {
880
+ if (!engrams[i].embedding || !engrams[j].embedding) continue;
881
+ const c = cosineSimilarity(engrams[i].embedding!, engrams[j].embedding!);
882
+ sim[i][j] = c;
883
+ sim[j][i] = c;
884
+ }
885
+ }
886
+
887
+ const unassigned = new Set<number>(Array.from({ length: n }, (_, i) => i));
888
+ const clusters: Engram[][] = [];
889
+
890
+ const sortedIdxs = Array.from({ length: n }, (_, i) => i)
891
+ .sort((a, b) => engrams[b].accessCount - engrams[a].accessCount);
892
+
893
+ for (const seedIdx of sortedIdxs) {
894
+ if (!unassigned.has(seedIdx)) continue;
895
+ unassigned.delete(seedIdx);
896
+
897
+ const clusterIdxs: number[] = [seedIdx];
898
+ let added = true;
899
+
900
+ while (added) {
901
+ added = false;
902
+ for (const candIdx of Array.from(unassigned)) {
903
+ let links = false;
904
+ for (const m of clusterIdxs) {
905
+ if (sim[candIdx][m] >= SIMILARITY_THRESHOLD) { links = true; break; }
906
+ }
907
+ if (!links) continue;
908
+
909
+ let passesAll = true;
910
+ for (const m of clusterIdxs) {
911
+ if (sim[candIdx][m] < MIN_PAIRWISE_COS) { passesAll = false; break; }
912
+ }
913
+ if (!passesAll) continue;
914
+
915
+ clusterIdxs.push(candIdx);
916
+ unassigned.delete(candIdx);
917
+ added = true;
918
+ }
919
+ }
920
+
921
+ if (clusterIdxs.length >= 2) {
922
+ clusters.push(clusterIdxs.map(i => engrams[i]));
923
+ } else {
924
+ unassigned.add(seedIdx);
925
+ }
926
+ }
927
+
928
+ return clusters;
929
+ }
930
+
931
+ /**
932
+ * Compute the centroid (average embedding) of a cluster.
933
+ */
934
+ private computeCentroid(cluster: Engram[]): number[] {
935
+ const withEmbed = cluster.filter(e => e.embedding && e.embedding.length > 0);
936
+ if (withEmbed.length === 0) return [];
937
+
938
+ const dim = withEmbed[0].embedding!.length;
939
+ const centroid = new Array<number>(dim).fill(0);
940
+ for (const e of withEmbed) {
941
+ for (let i = 0; i < dim; i++) {
942
+ centroid[i] += e.embedding![i];
943
+ }
944
+ }
945
+ for (let i = 0; i < dim; i++) {
946
+ centroid[i] /= withEmbed.length;
947
+ }
948
+ return centroid;
949
+ }
950
+ }