@chiligpt/memory 0.1.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 (50) hide show
  1. package/README.md +30 -0
  2. package/dist/core/backfill.d.ts +78 -0
  3. package/dist/core/backfill.d.ts.map +1 -0
  4. package/dist/core/backfill.js +155 -0
  5. package/dist/core/context-graph.d.ts +73 -0
  6. package/dist/core/context-graph.d.ts.map +1 -0
  7. package/dist/core/context-graph.js +363 -0
  8. package/dist/core/entity-index.d.ts +33 -0
  9. package/dist/core/entity-index.d.ts.map +1 -0
  10. package/dist/core/entity-index.js +59 -0
  11. package/dist/core/entity.d.ts +56 -0
  12. package/dist/core/entity.d.ts.map +1 -0
  13. package/dist/core/entity.js +65 -0
  14. package/dist/core/narrative.d.ts +110 -0
  15. package/dist/core/narrative.d.ts.map +1 -0
  16. package/dist/core/narrative.js +765 -0
  17. package/dist/core/read-path.d.ts +53 -0
  18. package/dist/core/read-path.d.ts.map +1 -0
  19. package/dist/core/read-path.js +592 -0
  20. package/dist/core/session-tree-index.d.ts +69 -0
  21. package/dist/core/session-tree-index.d.ts.map +1 -0
  22. package/dist/core/session-tree-index.js +131 -0
  23. package/dist/index.d.ts +16 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +15 -0
  26. package/dist/service.d.ts +105 -0
  27. package/dist/service.d.ts.map +1 -0
  28. package/dist/service.js +465 -0
  29. package/dist/storage/adapter.d.ts +61 -0
  30. package/dist/storage/adapter.d.ts.map +1 -0
  31. package/dist/storage/adapter.js +14 -0
  32. package/dist/storage/file-utils.d.ts +7 -0
  33. package/dist/storage/file-utils.d.ts.map +1 -0
  34. package/dist/storage/file-utils.js +37 -0
  35. package/dist/storage/in-memory-entry.d.ts +5 -0
  36. package/dist/storage/in-memory-entry.d.ts.map +1 -0
  37. package/dist/storage/in-memory-entry.js +5 -0
  38. package/dist/storage/in-memory.d.ts +37 -0
  39. package/dist/storage/in-memory.d.ts.map +1 -0
  40. package/dist/storage/in-memory.js +54 -0
  41. package/dist/storage/jsonl-entry.d.ts +6 -0
  42. package/dist/storage/jsonl-entry.d.ts.map +1 -0
  43. package/dist/storage/jsonl-entry.js +6 -0
  44. package/dist/storage/jsonl.d.ts +32 -0
  45. package/dist/storage/jsonl.d.ts.map +1 -0
  46. package/dist/storage/jsonl.js +171 -0
  47. package/dist/types.d.ts +66 -0
  48. package/dist/types.d.ts.map +1 -0
  49. package/dist/types.js +9 -0
  50. package/package.json +46 -0
@@ -0,0 +1,765 @@
1
+ /**
2
+ * Narrative layer — NarrativeNode schema, CRUD, and hub construction (F-61, F-63, F-64).
3
+ *
4
+ * NarrativeNodes are hub nodes in the three-layer memory architecture.
5
+ * They aggregate ContextNode spoke references and summarise high-level
6
+ * project themes. Maximum 20 active hubs; excess archived to archive/narratives/.
7
+ */
8
+ // ---------------------------------------------------------------------------
9
+ // FIX-02: Generic directory basenames — not injected as identity topics
10
+ // ---------------------------------------------------------------------------
11
+ const GENERIC_DIRS = new Set(["src", "app", "lib", "dist", "packages", "build", "out", "tmp"]);
12
+ // ---------------------------------------------------------------------------
13
+ // FIX-01: BM25F tokenizer and index types
14
+ // ---------------------------------------------------------------------------
15
+ const BM25_K1 = 1.2;
16
+ const BM25_B = 0.75;
17
+ /** Field weights for BM25F scoring. */
18
+ const FIELD_WEIGHTS = {
19
+ topics: 3.0,
20
+ title: 2.0,
21
+ summary: 1.0,
22
+ spoke: 1.5,
23
+ };
24
+ const STOP_WORDS = new Set([
25
+ "with", "from", "this", "that", "have", "been", "will", "into", "also",
26
+ "each", "their", "when", "then", "than", "they", "what", "some",
27
+ ]);
28
+ /**
29
+ * Code-aware tokenizer: splits on whitespace, hyphens, underscores, camelCase
30
+ * boundaries, and path separators. Filters to length ≥ 4 and removes stop words.
31
+ * Exported for use in read-path.ts scanOrphanNodes.
32
+ */
33
+ export function tokenizeBM25(text) {
34
+ // Expand camelCase: "AuthService" → "Auth Service"
35
+ const expanded = text
36
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
37
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2");
38
+ // Split on whitespace, hyphens, underscores, path separators, dots
39
+ const raw = expanded.split(/[\s\-_/\\.]+/);
40
+ return raw
41
+ .map((t) => t.toLowerCase())
42
+ .filter((t) => t.length >= 4 && !STOP_WORDS.has(t));
43
+ }
44
+ function buildFreqMap(tokens) {
45
+ const m = new Map();
46
+ for (const t of tokens)
47
+ m.set(t, (m.get(t) ?? 0) + 1);
48
+ return m;
49
+ }
50
+ // ---------------------------------------------------------------------------
51
+ // F-61: In-memory narrative cache (FIX-01 BM25F index; FIX-06 sort; FIX-04 context filter)
52
+ // ---------------------------------------------------------------------------
53
+ /** Session-scoped cache for all active NarrativeNodes (AC-61-02). */
54
+ export class NarrativeCache {
55
+ hubs = new Map();
56
+ _bm25 = null;
57
+ /** Load all records from disk into cache. O(N) — acceptable at startup. */
58
+ build(nodes) {
59
+ this.hubs.clear();
60
+ this._bm25 = null;
61
+ for (const node of nodes) {
62
+ this.hubs.set(node.id, node);
63
+ }
64
+ }
65
+ get(id) {
66
+ return this.hubs.get(id);
67
+ }
68
+ /**
69
+ * All active hubs, newest first.
70
+ * FIX-06: sort key is latestSpokeAt (actual session activity), falling back to updatedAt.
71
+ */
72
+ all() {
73
+ return [...this.hubs.values()].sort((a, b) => (b.latestSpokeAt ?? b.updatedAt) - (a.latestSpokeAt ?? a.updatedAt));
74
+ }
75
+ set(node) {
76
+ this.hubs.set(node.id, node);
77
+ this._bm25 = null;
78
+ }
79
+ update(id, patch) {
80
+ const node = this.hubs.get(id);
81
+ if (!node)
82
+ return;
83
+ this.hubs.set(id, { ...node, ...patch });
84
+ this._bm25 = null;
85
+ }
86
+ remove(id) {
87
+ this.hubs.delete(id);
88
+ this._bm25 = null;
89
+ }
90
+ get size() {
91
+ return this.hubs.size;
92
+ }
93
+ // ---------------------------------------------------------------------------
94
+ // FIX-01: BM25F index construction
95
+ // ---------------------------------------------------------------------------
96
+ /**
97
+ * Build the BM25F index from current hubs + spoke ContextNodes for supplementary field.
98
+ * Called once at session_start after build(). O(N × T) — fast at ≤500 hubs.
99
+ */
100
+ buildIndex(allContextNodes) {
101
+ const contextMap = new Map(allContextNodes.map((n) => [n.id, n]));
102
+ const hubEntries = [];
103
+ for (const hub of this.hubs.values()) {
104
+ const topicsTokens = tokenizeBM25(hub.topics.join(" "));
105
+ const titleTokens = tokenizeBM25(hub.title);
106
+ const summaryTokens = tokenizeBM25(hub.summary);
107
+ // Spoke supplementary field: topics union + searchTerms + filePaths + modules from spoke nodes.
108
+ // F-106: Seed from persisted spokeTopics union first (efficient), then fall through to spoke
109
+ // node scan for searchTerms/filePaths/modules not yet covered by spokeTopics.
110
+ // F-108: include searchTerms — query-side vocabulary generated at write time.
111
+ const spokeTexts = hub.spokeTopics?.length ? [...hub.spokeTopics] : [];
112
+ const cwdBasenames = new Set();
113
+ for (const spokeId of hub.spokeNodeIds) {
114
+ const node = contextMap.get(spokeId);
115
+ if (!node)
116
+ continue;
117
+ // Only add topics if spokeTopics not yet populated (old hubs without F-106 field)
118
+ if (!hub.spokeTopics?.length && node.topics?.length)
119
+ spokeTexts.push(...node.topics);
120
+ if (node.searchTerms?.length)
121
+ spokeTexts.push(...node.searchTerms);
122
+ if (node.relevantFilePaths?.length)
123
+ spokeTexts.push(...node.relevantFilePaths);
124
+ if (node.relevantModules?.length)
125
+ spokeTexts.push(...node.relevantModules);
126
+ const basename = node.workingDirectory?.split("/").pop()?.toLowerCase();
127
+ if (basename && basename.length > 2)
128
+ cwdBasenames.add(basename);
129
+ }
130
+ const spokeTokens = tokenizeBM25(spokeTexts.join(" "));
131
+ hubEntries.push({
132
+ hubId: hub.id,
133
+ fields: {
134
+ topics: buildFreqMap(topicsTokens),
135
+ title: buildFreqMap(titleTokens),
136
+ summary: buildFreqMap(summaryTokens),
137
+ spoke: buildFreqMap(spokeTokens),
138
+ },
139
+ fieldLengths: {
140
+ topics: topicsTokens.length,
141
+ title: titleTokens.length,
142
+ summary: summaryTokens.length,
143
+ spoke: spokeTokens.length,
144
+ },
145
+ cwdBasenames,
146
+ });
147
+ }
148
+ // Compute IDF: document frequency per token across all hubs
149
+ const df = new Map();
150
+ for (const entry of hubEntries) {
151
+ const allTokensInHub = new Set([
152
+ ...entry.fields.topics.keys(),
153
+ ...entry.fields.title.keys(),
154
+ ...entry.fields.summary.keys(),
155
+ ...entry.fields.spoke.keys(),
156
+ ]);
157
+ for (const token of allTokensInHub) {
158
+ df.set(token, (df.get(token) ?? 0) + 1);
159
+ }
160
+ }
161
+ // Average field lengths for length normalisation
162
+ const N = hubEntries.length;
163
+ const avgLengths = N === 0
164
+ ? { topics: 1, title: 1, summary: 1, spoke: 1 }
165
+ : {
166
+ topics: hubEntries.reduce((s, e) => s + e.fieldLengths.topics, 0) / N,
167
+ title: hubEntries.reduce((s, e) => s + e.fieldLengths.title, 0) / N,
168
+ summary: hubEntries.reduce((s, e) => s + e.fieldLengths.summary, 0) / N,
169
+ spoke: hubEntries.reduce((s, e) => s + e.fieldLengths.spoke, 0) / N,
170
+ };
171
+ this._bm25 = { hubs: new Map(hubEntries.map((e) => [e.hubId, e])), df, avgLengths, totalHubs: N };
172
+ }
173
+ /**
174
+ * Rank hubs by composite BM25F score for the given query string.
175
+ * Returns hubs with score > 0, sorted descending. Top 5 returned.
176
+ *
177
+ * Composite score = BM25F × temporal decay × cwd affinity (FIX-01, FIX-03).
178
+ * Returns empty array when index not built — caller should fall back to full-text scan.
179
+ */
180
+ rankByQuery(topic, cwd) {
181
+ const index = this._bm25;
182
+ if (!index || index.totalHubs === 0)
183
+ return [];
184
+ let queryTokens = tokenizeBM25(topic);
185
+ // AC-FIX-01-06: short/symbol query — keep original lowercased as single token
186
+ if (queryTokens.length === 0)
187
+ queryTokens = [topic.toLowerCase().slice(0, 20).trim()].filter(Boolean);
188
+ if (queryTokens.length === 0)
189
+ return [];
190
+ const cwdBasename = cwd?.split("/").pop()?.toLowerCase();
191
+ const scores = new Map();
192
+ for (const [hubId, entry] of index.hubs) {
193
+ let bm25Score = 0;
194
+ for (const token of queryTokens) {
195
+ const df = index.df.get(token) ?? 0;
196
+ if (df === 0)
197
+ continue;
198
+ const idf = Math.log((index.totalHubs - df + 0.5) / (df + 0.5) + 1);
199
+ let weightedFreq = 0;
200
+ const fields = [
201
+ ["topics", "topics"],
202
+ ["title", "title"],
203
+ ["summary", "summary"],
204
+ ["spoke", "spoke"],
205
+ ];
206
+ for (const [field, weightKey] of fields) {
207
+ const freq = entry.fields[field].get(token) ?? 0;
208
+ if (freq === 0)
209
+ continue;
210
+ const avgLen = index.avgLengths[field] || 1;
211
+ const len = entry.fieldLengths[field];
212
+ const normFreq = freq / (1 - BM25_B + BM25_B * (len / avgLen));
213
+ weightedFreq += FIELD_WEIGHTS[weightKey] * normFreq;
214
+ }
215
+ bm25Score += idf * (weightedFreq / (weightedFreq + BM25_K1));
216
+ }
217
+ if (bm25Score <= 0)
218
+ continue;
219
+ // Exact phrase bonus: 2+ consecutive query tokens adjacent in title or topics
220
+ if (queryTokens.length >= 2) {
221
+ const hub = this.hubs.get(hubId);
222
+ if (hub) {
223
+ const titleTokens = tokenizeBM25(hub.title);
224
+ const topicsFlat = tokenizeBM25(hub.topics.join(" "));
225
+ outer: for (let i = 0; i < queryTokens.length - 1; i++) {
226
+ const t1 = queryTokens[i];
227
+ const t2 = queryTokens[i + 1];
228
+ for (const toks of [titleTokens, topicsFlat]) {
229
+ for (let j = 0; j < toks.length - 1; j++) {
230
+ if (toks[j] === t1 && toks[j + 1] === t2) {
231
+ bm25Score += BM25_K1;
232
+ break outer;
233
+ }
234
+ }
235
+ }
236
+ }
237
+ }
238
+ }
239
+ // Temporal decay: e^(-0.005 × daysSinceUpdate) — uses latestSpokeAt per FIX-03
240
+ const hub = this.hubs.get(hubId);
241
+ if (!hub)
242
+ continue;
243
+ const anchorTs = hub.latestSpokeAt ?? hub.updatedAt;
244
+ const daysSince = (Date.now() - anchorTs) / 86_400_000;
245
+ let finalScore = bm25Score * Math.exp(-0.005 * daysSince);
246
+ // cwd affinity: 1.5× boost when spoke nodes share current project basename
247
+ if (cwdBasename && !GENERIC_DIRS.has(cwdBasename) && entry.cwdBasenames.has(cwdBasename)) {
248
+ finalScore *= 1.5;
249
+ }
250
+ scores.set(hubId, finalScore);
251
+ }
252
+ return [...scores.entries()]
253
+ .sort((a, b) => b[1] - a[1])
254
+ .slice(0, 5)
255
+ .map(([id]) => this.hubs.get(id))
256
+ .filter(Boolean);
257
+ }
258
+ /**
259
+ * FIX-04: Return hubs for system prompt injection filtered by cwd relevance.
260
+ * Primary: hubs whose spokes come from the current cwd.
261
+ * Fallback: 3 most recently active hubs when no cwd match exists.
262
+ * Hard cap: never more than `max` hubs injected.
263
+ */
264
+ getHubsForContext(cwd, max = 5) {
265
+ const all = this.all();
266
+ if (!cwd || all.length === 0)
267
+ return all.slice(0, max);
268
+ const cwdBasename = cwd.split("/").pop()?.toLowerCase();
269
+ if (!cwdBasename || GENERIC_DIRS.has(cwdBasename))
270
+ return all.slice(0, max);
271
+ const matched = [];
272
+ for (const hub of all) {
273
+ const entry = this._bm25?.hubs.get(hub.id);
274
+ if (entry?.cwdBasenames.has(cwdBasename)) {
275
+ matched.push(hub);
276
+ }
277
+ }
278
+ if (matched.length > 0)
279
+ return matched.slice(0, max);
280
+ // Fallback: 3 most recently active hubs as orientation context
281
+ return all.slice(0, Math.min(3, max));
282
+ }
283
+ }
284
+ // ---------------------------------------------------------------------------
285
+ // F-61: CRUD over layer3-narrative.jsonl
286
+ // ---------------------------------------------------------------------------
287
+ export async function createNarrativeNode(storage, cache, node) {
288
+ await storage.append("narratives", node);
289
+ cache.set(node);
290
+ }
291
+ export async function updateNarrativeNode(storage, cache, id, patch) {
292
+ await storage.update("narratives", id, patch);
293
+ cache.update(id, patch);
294
+ }
295
+ export async function loadAllNarrativeNodes(storage) {
296
+ const all = await storage.readAll("narratives");
297
+ return all.filter((n) => !n._archived);
298
+ }
299
+ // ---------------------------------------------------------------------------
300
+ // F-62: Narrative injection content (FIX-04: cwd filter, 5-hub cap, 150-char truncation)
301
+ // ---------------------------------------------------------------------------
302
+ const ACTIVATION_PROTOCOL = "When the user references past work, a specific file, decision, or change not present " +
303
+ "in the current session context — do not answer from assumption. Call " +
304
+ "memory_entity_lookup(name, type) or memory_query(topic) before responding.";
305
+ /**
306
+ * Build the system prompt append block for the narrative layer (F-62).
307
+ * FIX-04: accepts pre-filtered hubs and total hub count for "N of M" header.
308
+ * Summaries truncated to 150 chars. Hub coverage range included when available (FIX-03).
309
+ */
310
+ export function buildNarrativeSystemPromptBlock(hubs, totalCount) {
311
+ const lines = [];
312
+ const total = totalCount ?? hubs.length;
313
+ if (hubs.length === 0) {
314
+ lines.push("[MEMORY: Project Narrative]", "");
315
+ lines.push("[MEMORY: No narrative index yet — will build over time]");
316
+ }
317
+ else {
318
+ lines.push(`[MEMORY: Project Narrative — showing ${hubs.length} of ${total} hubs for current project]`, "");
319
+ for (const hub of hubs) {
320
+ const truncatedSummary = hub.summary.length > 150
321
+ ? hub.summary.slice(0, 150) + "..."
322
+ : hub.summary;
323
+ const coverage = hub.earliestSpokeAt && hub.latestSpokeAt
324
+ ? ` | coverage: ${new Date(hub.earliestSpokeAt).toISOString().split("T")[0]} → ${new Date(hub.latestSpokeAt).toISOString().split("T")[0]}`
325
+ : "";
326
+ lines.push(`Hub: ${hub.title}`);
327
+ lines.push(` Topics: ${hub.topics.join(", ")}`);
328
+ lines.push(` Summary: ${truncatedSummary}`);
329
+ lines.push(` Sessions: ${hub.spokeNodeIds.length} entries${coverage}`);
330
+ lines.push("");
331
+ }
332
+ }
333
+ lines.push(ACTIVATION_PROTOCOL);
334
+ return lines.join("\n");
335
+ }
336
+ // ---------------------------------------------------------------------------
337
+ // F-63: Spoke assignment (FIX-05: BM25F pre-filter; FIX-03: latestSpokeAt update)
338
+ // ---------------------------------------------------------------------------
339
+ const SPOKE_SYSTEM_PROMPT = "You are a memory routing assistant. Given a new session summary and a list of narrative hubs, " +
340
+ "determine if the summary belongs to exactly one hub. Respond with JSON only.";
341
+ function buildSpokeAssignmentPrompt(newNode, hubs) {
342
+ const hubText = hubs
343
+ .map((h, i) => `[${i + 1}] ID: ${h.id}\nTitle: ${h.title}\nTopics: ${h.topics.join(", ")}\nSummary: ${h.summary}`)
344
+ .join("\n\n");
345
+ return [
346
+ {
347
+ role: "user",
348
+ timestamp: Date.now(),
349
+ content: `New session summary:
350
+ User: ${newNode.userContext}
351
+ Agent: ${newNode.agentContext}
352
+ Topics: ${newNode.topics.join(", ")}
353
+
354
+ Narrative hubs:
355
+ ${hubText}
356
+
357
+ Does this summary belong to exactly one of the hubs above? If yes, return its ID. If no, return null.
358
+ Respond with JSON only: { "hubId": "<id>" | null }`,
359
+ },
360
+ ];
361
+ }
362
+ async function assignSpoke(newNode, hubs, storage, cache, complete) {
363
+ if (hubs.length === 0)
364
+ return null;
365
+ // FIX-05: Pre-filter hub candidates using BM25F — pass only plausible candidates to LLM.
366
+ // Falls back to full hub list when the BM25 index is not yet built (e.g. first session start).
367
+ const queryText = `${newNode.topics.join(" ")} ${newNode.userContext} ${newNode.agentContext}`;
368
+ const ranked = cache.rankByQuery(queryText);
369
+ // When the index isn't built, rankByQuery returns [] — fall back to all hubs (capped at 5)
370
+ const candidates = ranked.length > 0 ? ranked.slice(0, 5) : hubs.slice(0, 5);
371
+ // AC-FIX-05-03: If BM25F returned results but none scored above zero, skip LLM entirely.
372
+ // When falling back to all hubs, always proceed to LLM.
373
+ if (ranked.length > 0 && candidates.length === 0)
374
+ return null;
375
+ const messages = buildSpokeAssignmentPrompt(newNode, candidates);
376
+ let raw;
377
+ try {
378
+ const response = await complete(messages, { systemPrompt: SPOKE_SYSTEM_PROMPT });
379
+ const textBlock = Array.isArray(response.content)
380
+ ? response.content.find((b) => b.type === "text")
381
+ : null;
382
+ raw = textBlock ? textBlock.text : "";
383
+ }
384
+ catch {
385
+ return null;
386
+ }
387
+ const cleaned = raw.replace(/^```(?:json)?\n?/m, "").replace(/\n?```$/m, "").trim();
388
+ let hubId = null;
389
+ try {
390
+ const parsed = JSON.parse(cleaned);
391
+ hubId = typeof parsed.hubId === "string" ? parsed.hubId : null;
392
+ }
393
+ catch {
394
+ return null;
395
+ }
396
+ // Validate the returned hub ID is from the candidate list (not the full hub list)
397
+ if (!hubId || !candidates.find((h) => h.id === hubId))
398
+ return null;
399
+ const hub = cache.get(hubId);
400
+ if (!hub)
401
+ return null;
402
+ const updatedSpokes = [...hub.spokeNodeIds, newNode.id];
403
+ // FIX-03: Maintain earliestSpokeAt / latestSpokeAt on spoke add
404
+ const nodeTs = newNode.timestamp ?? Date.now();
405
+ const earliestSpokeAt = hub.earliestSpokeAt !== undefined
406
+ ? Math.min(hub.earliestSpokeAt, nodeTs)
407
+ : nodeTs;
408
+ const latestSpokeAt = hub.latestSpokeAt !== undefined
409
+ ? Math.max(hub.latestSpokeAt, nodeTs)
410
+ : nodeTs;
411
+ // F-106: Maintain spokeTopics union — append new spoke's topics, deduplicated
412
+ const updatedSpokeTopics = [...new Set([...(hub.spokeTopics ?? []), ...newNode.topics])];
413
+ await updateNarrativeNode(storage, cache, hubId, {
414
+ spokeNodeIds: updatedSpokes,
415
+ spokeTopics: updatedSpokeTopics,
416
+ updatedAt: Date.now(),
417
+ earliestSpokeAt,
418
+ latestSpokeAt,
419
+ });
420
+ return hubId;
421
+ }
422
+ // ---------------------------------------------------------------------------
423
+ // F-63: Hub creation (FIX-02: cwd basename topic; FIX-03: spoke timestamps)
424
+ // ---------------------------------------------------------------------------
425
+ const HUB_CREATION_SYSTEM_PROMPT = "You are a memory organisation assistant. Create a narrative hub title and summary that captures " +
426
+ "a theme common to the provided session summaries. Respond with JSON only.";
427
+ function commonTopics(nodes) {
428
+ const topicCounts = new Map();
429
+ for (const node of nodes) {
430
+ for (const topic of node.topics) {
431
+ topicCounts.set(topic, (topicCounts.get(topic) ?? 0) + 1);
432
+ }
433
+ }
434
+ return [...topicCounts.entries()].filter(([, count]) => count >= 2).map(([topic]) => topic);
435
+ }
436
+ function existingHubCoversTopics(hubs, topics) {
437
+ const topicSet = new Set(topics);
438
+ for (const hub of hubs) {
439
+ const matches = hub.topics.filter((t) => topicSet.has(t));
440
+ if (matches.length >= 2)
441
+ return true;
442
+ }
443
+ return false;
444
+ }
445
+ /**
446
+ * FIX-02: Extract the dominant working directory basename from a set of context nodes.
447
+ * Returns null when no clear majority basename exists or basename is generic.
448
+ */
449
+ function extractDominantBasename(nodes) {
450
+ const basenames = nodes
451
+ .map((n) => n.workingDirectory?.split("/").pop()?.toLowerCase())
452
+ .filter((b) => !!b && b.length > 2 && !GENERIC_DIRS.has(b));
453
+ if (basenames.length === 0)
454
+ return null;
455
+ const counts = new Map();
456
+ for (const b of basenames)
457
+ counts.set(b, (counts.get(b) ?? 0) + 1);
458
+ const dominant = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
459
+ // Only inject if dominant appears on ≥50% of nodes
460
+ return dominant && dominant[1] >= nodes.length / 2 ? dominant[0] : null;
461
+ }
462
+ async function tryCreateHub(candidateNodes, storage, cache, complete) {
463
+ const allNodes = await storage.readAll("context");
464
+ const hubs = cache.all();
465
+ const unassignedNodes = allNodes.filter((n) => !hubs.some((h) => h.spokeNodeIds.includes(n.id)));
466
+ if (unassignedNodes.length < 3)
467
+ return null;
468
+ const shared = commonTopics(unassignedNodes);
469
+ if (shared.length < 2)
470
+ return null;
471
+ if (existingHubCoversTopics(hubs, shared))
472
+ return null;
473
+ // FIX-02: Inject dominant cwd basename as an identity topic
474
+ const dominantBasename = extractDominantBasename(unassignedNodes);
475
+ const allTopics = dominantBasename ? [...new Set([...shared, dominantBasename])] : shared;
476
+ const nodeText = unassignedNodes
477
+ .slice(0, 5)
478
+ .map((n) => `User: ${n.userContext}\nAgent: ${n.agentContext}`)
479
+ .join("\n\n");
480
+ const messages = [
481
+ {
482
+ role: "user",
483
+ timestamp: Date.now(),
484
+ content: `Create a narrative hub for these session summaries that share common topics: ${allTopics.join(", ")}
485
+
486
+ Session summaries:
487
+ ${nodeText}
488
+
489
+ Respond with JSON only:
490
+ { "title": "<short hub title>", "summary": "<2-3 sentence hub summary>" }`,
491
+ },
492
+ ];
493
+ let raw;
494
+ try {
495
+ const response = await complete(messages, { systemPrompt: HUB_CREATION_SYSTEM_PROMPT });
496
+ const textBlock = Array.isArray(response.content)
497
+ ? response.content.find((b) => b.type === "text")
498
+ : null;
499
+ raw = textBlock ? textBlock.text : "";
500
+ }
501
+ catch {
502
+ return null;
503
+ }
504
+ const cleaned = raw.replace(/^```(?:json)?\n?/m, "").replace(/\n?```$/m, "").trim();
505
+ let title = null;
506
+ let summary = null;
507
+ try {
508
+ const parsed = JSON.parse(cleaned);
509
+ title = typeof parsed.title === "string" ? parsed.title : null;
510
+ summary = typeof parsed.summary === "string" ? parsed.summary : null;
511
+ }
512
+ catch {
513
+ return null;
514
+ }
515
+ if (!title || !summary)
516
+ return null;
517
+ // FIX-03: Set spoke timestamp range from the unassigned nodes
518
+ const spokeTimestamps = unassignedNodes.map((n) => n.timestamp ?? 0).filter((t) => t > 0);
519
+ const earliestSpokeAt = spokeTimestamps.length > 0 ? Math.min(...spokeTimestamps) : undefined;
520
+ const latestSpokeAt = spokeTimestamps.length > 0 ? Math.max(...spokeTimestamps) : undefined;
521
+ const now = Date.now();
522
+ // F-106: Initialize spokeTopics as union of all candidate node topics
523
+ const spokeTopics = [...new Set(unassignedNodes.flatMap((n) => n.topics))];
524
+ const hub = {
525
+ id: globalThis.crypto.randomUUID(),
526
+ title,
527
+ summary,
528
+ topics: allTopics,
529
+ spokeTopics,
530
+ spokeNodeIds: unassignedNodes.map((n) => n.id),
531
+ createdAt: now,
532
+ updatedAt: now,
533
+ earliestSpokeAt,
534
+ latestSpokeAt,
535
+ };
536
+ await createNarrativeNode(storage, cache, hub);
537
+ return hub;
538
+ }
539
+ // ---------------------------------------------------------------------------
540
+ // F-63: Hub summary revision (FIX-07: founding + recent spokes for revision context)
541
+ // ---------------------------------------------------------------------------
542
+ const HUB_REVISION_SYSTEM_PROMPT = "You are a memory summarisation assistant. Revise the hub summary to incorporate new session data. Respond with JSON only.";
543
+ async function tryReviseHub(hub, newSpokesAdded, storage, cache, complete) {
544
+ if (newSpokesAdded < 5)
545
+ return;
546
+ const allContextNodes = await storage.readAll("context");
547
+ // FIX-07: First 2 spokes (founding identity anchor) + last 3 (recent activity), deduplicated.
548
+ // For hubs with ≤5 spokes, uses all spokes.
549
+ const allSpokes = hub.spokeNodeIds
550
+ .map((id) => allContextNodes.find((n) => n.id === id))
551
+ .filter((n) => n !== undefined);
552
+ const founding = allSpokes.slice(0, 2);
553
+ const recent = allSpokes.slice(-3);
554
+ const spokeNodes = [...new Map([...founding, ...recent].map((n) => [n.id, n])).values()];
555
+ const nodeText = spokeNodes
556
+ .map((n) => `User: ${n.userContext}\nAgent: ${n.agentContext}`)
557
+ .join("\n\n");
558
+ const messages = [
559
+ {
560
+ role: "user",
561
+ timestamp: Date.now(),
562
+ content: `Update the hub summary to reflect recent activity while preserving the founding context.
563
+
564
+ Hub: "${hub.title}"
565
+ Current summary: ${hub.summary}
566
+ Topics: ${hub.topics.join(", ")}
567
+
568
+ Sessions (founding + recent):
569
+ ${nodeText}
570
+
571
+ Respond with JSON only: { "summary": "<revised 2-3 sentence summary>" }`,
572
+ },
573
+ ];
574
+ try {
575
+ const response = await complete(messages, { systemPrompt: HUB_REVISION_SYSTEM_PROMPT });
576
+ const textBlock = Array.isArray(response.content)
577
+ ? response.content.find((b) => b.type === "text")
578
+ : null;
579
+ const raw = textBlock ? textBlock.text : "";
580
+ const cleaned = raw.replace(/^```(?:json)?\n?/m, "").replace(/\n?```$/m, "").trim();
581
+ const parsed = JSON.parse(cleaned);
582
+ if (typeof parsed.summary === "string") {
583
+ await updateNarrativeNode(storage, cache, hub.id, {
584
+ summary: parsed.summary,
585
+ updatedAt: Date.now(),
586
+ });
587
+ }
588
+ }
589
+ catch {
590
+ // Non-fatal
591
+ }
592
+ }
593
+ // ---------------------------------------------------------------------------
594
+ // F-64: 20-hub limit + LRU archival (FIX-06: latestSpokeAt for eviction sort)
595
+ // ---------------------------------------------------------------------------
596
+ const MAX_HUBS = 20;
597
+ const MAX_NARRATIVE_TOKENS = 8_000;
598
+ const CHARS_PER_TOKEN = 4;
599
+ function estimateNarrativeTokens(hubs) {
600
+ const totalChars = hubs.reduce((sum, h) => sum + h.title.length + h.summary.length + h.topics.join(", ").length, 0);
601
+ return Math.ceil(totalChars / CHARS_PER_TOKEN);
602
+ }
603
+ async function enforceHubLimit(storage, cache) {
604
+ // FIX-06: Sort by latestSpokeAt (true session activity), fallback to updatedAt
605
+ const hubs = cache.all().sort((a, b) => (b.latestSpokeAt ?? b.updatedAt) - (a.latestSpokeAt ?? a.updatedAt));
606
+ const tokenEstimate = estimateNarrativeTokens(hubs);
607
+ const overTokens = tokenEstimate > MAX_NARRATIVE_TOKENS;
608
+ const target = overTokens ? MAX_HUBS - 1 : MAX_HUBS;
609
+ if (hubs.length <= target)
610
+ return;
611
+ // Archive least-recently-active hubs (tail of the sorted list)
612
+ const toArchive = hubs.slice(target);
613
+ for (const hub of toArchive) {
614
+ await storage.archive("narratives-archive", hub);
615
+ await storage.update("narratives", hub.id, { _archived: true });
616
+ cache.remove(hub.id);
617
+ if (overTokens) {
618
+ console.warn(`[memory] Narrative token budget exceeded — archiving hub "${hub.title}"`);
619
+ }
620
+ }
621
+ }
622
+ // ---------------------------------------------------------------------------
623
+ // F-97: Lightweight narrative tick — mid-session hub summary refresh
624
+ // ---------------------------------------------------------------------------
625
+ /**
626
+ * Revises the most recently active hub's summary without full compaction.
627
+ * Called every ~20 agent turns to keep the narrative warm during long sessions.
628
+ * Best-effort: errors are swallowed by the caller.
629
+ */
630
+ export async function runNarrativeTick(storage, cache, complete) {
631
+ const hubs = cache.all(); // sorted by latestSpokeAt ?? updatedAt
632
+ if (hubs.length === 0)
633
+ return;
634
+ await tryReviseHub(hubs[0], 5, storage, cache, complete);
635
+ }
636
+ // ---------------------------------------------------------------------------
637
+ // F-96 + F-107: Eager hub formation — stub hub for single-node orphan prevention
638
+ // ---------------------------------------------------------------------------
639
+ const STUB_HUB_SYSTEM_PROMPT = "You are a memory summarisation assistant. Write a 2-sentence summary of this coding session. Respond with JSON only.";
640
+ /**
641
+ * Builds a stub NarrativeNode from a single ContextNode.
642
+ * F-107: Makes one LLM call to generate a richer summary so the BM25F index
643
+ * has more surface area from the first session. Falls back to raw concatenation
644
+ * on any failure — stub creation never throws.
645
+ * FIX-02: Injects working directory basename as identity topic when meaningful.
646
+ * FIX-03: Sets earliestSpokeAt / latestSpokeAt from node timestamp.
647
+ */
648
+ async function buildStubHub(node, complete) {
649
+ const now = Date.now();
650
+ const title = node.topics.length > 0
651
+ ? node.topics.slice(0, 2).join(", ")
652
+ : node.userContext.slice(0, 60).trimEnd();
653
+ // FIX-02: Inject cwd basename as identity topic when non-generic
654
+ const cwdBasename = node.workingDirectory?.split("/").pop()?.toLowerCase();
655
+ const injectBasename = cwdBasename && cwdBasename.length > 2 && !GENERIC_DIRS.has(cwdBasename)
656
+ ? cwdBasename
657
+ : null;
658
+ const topics = injectBasename
659
+ ? [...new Set([...node.topics, injectBasename])]
660
+ : node.topics;
661
+ // F-107: LLM-generated summary — falls back to raw concat on any failure
662
+ let summary = `${node.userContext} — ${node.agentContext}`.slice(0, 300);
663
+ try {
664
+ const messages = [{
665
+ role: "user",
666
+ timestamp: now,
667
+ content: `Write a 2-sentence summary of this coding session.\n\nUser: ${node.userContext}\nAgent: ${node.agentContext}\nTopics: ${topics.join(", ")}\n\nRespond with JSON only: { "summary": "<2 sentences>" }`,
668
+ }];
669
+ const response = await complete(messages, { systemPrompt: STUB_HUB_SYSTEM_PROMPT });
670
+ const textBlock = Array.isArray(response.content)
671
+ ? response.content.find((b) => b.type === "text")
672
+ : null;
673
+ const raw = textBlock ? textBlock.text : "";
674
+ const cleaned = raw.replace(/^```(?:json)?\n?/m, "").replace(/\n?```$/m, "").trim();
675
+ const parsed = JSON.parse(cleaned);
676
+ if (typeof parsed.summary === "string" && parsed.summary.length >= 20) {
677
+ summary = parsed.summary;
678
+ }
679
+ }
680
+ catch {
681
+ // Non-fatal — raw concatenation fallback already set above
682
+ }
683
+ // FIX-03: Spoke timestamps from the node
684
+ const nodeTs = node.timestamp ?? now;
685
+ return {
686
+ id: globalThis.crypto.randomUUID(),
687
+ title,
688
+ summary,
689
+ topics,
690
+ // F-106: spokeTopics initialized from the single founding node's topics
691
+ spokeTopics: [...topics],
692
+ spokeNodeIds: [node.id],
693
+ createdAt: now,
694
+ updatedAt: now,
695
+ earliestSpokeAt: nodeTs,
696
+ latestSpokeAt: nodeTs,
697
+ };
698
+ }
699
+ // ---------------------------------------------------------------------------
700
+ // F-98: Dead hub detection — archive hubs whose all spokes have archived entities
701
+ // ---------------------------------------------------------------------------
702
+ /**
703
+ * Scans active hubs and archives any where every spoke ContextNode has
704
+ * `allEntitiesArchived: true`. These hubs consume system prompt token budget
705
+ * without providing usable recall.
706
+ *
707
+ * Returns count of hubs archived. No LLM calls. Safe at session_start and shutdown.
708
+ */
709
+ export async function archiveDeadHubs(storage, cache) {
710
+ const allContextNodes = await storage.readAll("context");
711
+ const contextNodeMap = new Map(allContextNodes.map((n) => [n.id, n]));
712
+ const hubs = cache.all();
713
+ let archivedCount = 0;
714
+ for (const hub of hubs) {
715
+ if (hub.spokeNodeIds.length === 0)
716
+ continue;
717
+ const allSpokesArchivedOrMissing = hub.spokeNodeIds.every((id) => {
718
+ const node = contextNodeMap.get(id);
719
+ return !node || node.allEntitiesArchived === true;
720
+ });
721
+ if (allSpokesArchivedOrMissing) {
722
+ await storage.archive("narratives-archive", hub);
723
+ await storage.update("narratives", hub.id, { _archived: true });
724
+ cache.remove(hub.id);
725
+ archivedCount++;
726
+ }
727
+ }
728
+ return archivedCount;
729
+ }
730
+ // ---------------------------------------------------------------------------
731
+ // Main entry point: post-compaction narrative construction
732
+ // ---------------------------------------------------------------------------
733
+ /**
734
+ * Called after a new ContextNode is written (F-57/F-58).
735
+ * Runs: spoke assignment → optional hub creation → optional hub revision → LRU enforcement.
736
+ * All operations are best-effort — errors are caught and logged.
737
+ */
738
+ export async function updateNarrativeLayer(params) {
739
+ const { newNode, storage, cache, complete } = params;
740
+ const hubs = cache.all();
741
+ try {
742
+ // F-63: Spoke assignment (FIX-05: BM25F pre-filter inside assignSpoke)
743
+ const assignedHubId = await assignSpoke(newNode, hubs, storage, cache, complete);
744
+ // Revision trigger: revise hub summary every 5 spokes
745
+ if (assignedHubId) {
746
+ const hub = cache.get(assignedHubId);
747
+ if (hub && hub.spokeNodeIds.length % 5 === 0) {
748
+ await tryReviseHub(hub, 5, storage, cache, complete);
749
+ }
750
+ }
751
+ // F-63: Hub creation check (≥3 unassigned nodes with ≥2 common topics)
752
+ const createdHub = await tryCreateHub([newNode], storage, cache, complete);
753
+ // F-96 + F-107: Eager hub formation — create stub immediately to eliminate orphans
754
+ if (!assignedHubId && !createdHub) {
755
+ const stub = await buildStubHub(newNode, complete);
756
+ await createNarrativeNode(storage, cache, stub);
757
+ }
758
+ // F-64: Enforce hub limit
759
+ await enforceHubLimit(storage, cache);
760
+ }
761
+ catch (err) {
762
+ console.error("[memory] narrative update error:", err instanceof Error ? err.message : String(err));
763
+ }
764
+ }
765
+ //# sourceMappingURL=narrative.js.map