@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.
- package/README.md +30 -0
- package/dist/core/backfill.d.ts +78 -0
- package/dist/core/backfill.d.ts.map +1 -0
- package/dist/core/backfill.js +155 -0
- package/dist/core/context-graph.d.ts +73 -0
- package/dist/core/context-graph.d.ts.map +1 -0
- package/dist/core/context-graph.js +363 -0
- package/dist/core/entity-index.d.ts +33 -0
- package/dist/core/entity-index.d.ts.map +1 -0
- package/dist/core/entity-index.js +59 -0
- package/dist/core/entity.d.ts +56 -0
- package/dist/core/entity.d.ts.map +1 -0
- package/dist/core/entity.js +65 -0
- package/dist/core/narrative.d.ts +110 -0
- package/dist/core/narrative.d.ts.map +1 -0
- package/dist/core/narrative.js +765 -0
- package/dist/core/read-path.d.ts +53 -0
- package/dist/core/read-path.d.ts.map +1 -0
- package/dist/core/read-path.js +592 -0
- package/dist/core/session-tree-index.d.ts +69 -0
- package/dist/core/session-tree-index.d.ts.map +1 -0
- package/dist/core/session-tree-index.js +131 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/service.d.ts +105 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +465 -0
- package/dist/storage/adapter.d.ts +61 -0
- package/dist/storage/adapter.d.ts.map +1 -0
- package/dist/storage/adapter.js +14 -0
- package/dist/storage/file-utils.d.ts +7 -0
- package/dist/storage/file-utils.d.ts.map +1 -0
- package/dist/storage/file-utils.js +37 -0
- package/dist/storage/in-memory-entry.d.ts +5 -0
- package/dist/storage/in-memory-entry.d.ts.map +1 -0
- package/dist/storage/in-memory-entry.js +5 -0
- package/dist/storage/in-memory.d.ts +37 -0
- package/dist/storage/in-memory.d.ts.map +1 -0
- package/dist/storage/in-memory.js +54 -0
- package/dist/storage/jsonl-entry.d.ts +6 -0
- package/dist/storage/jsonl-entry.d.ts.map +1 -0
- package/dist/storage/jsonl-entry.js +6 -0
- package/dist/storage/jsonl.d.ts +32 -0
- package/dist/storage/jsonl.d.ts.map +1 -0
- package/dist/storage/jsonl.js +171 -0
- package/dist/types.d.ts +66 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- 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
|