@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,110 @@
|
|
|
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
|
+
import type { LLMCompleteFn } from "../types.js";
|
|
9
|
+
import type { IMemoryStorage } from "../storage/adapter.js";
|
|
10
|
+
import type { ContextNode } from "./context-graph.js";
|
|
11
|
+
export interface NarrativeNode {
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
summary: string;
|
|
15
|
+
topics: string[];
|
|
16
|
+
/** IDs of ContextNodes that are spokes of this hub. */
|
|
17
|
+
spokeNodeIds: string[];
|
|
18
|
+
/**
|
|
19
|
+
* F-106: Union of all spoke ContextNode topics — broader than `topics[]` (intersection).
|
|
20
|
+
* Indexed by BM25F spoke field so individual session vocabulary is searchable even when
|
|
21
|
+
* it does not appear in the hub intersection topics. Backward-compatible (defaults to []).
|
|
22
|
+
*/
|
|
23
|
+
spokeTopics?: string[];
|
|
24
|
+
createdAt: number;
|
|
25
|
+
updatedAt: number;
|
|
26
|
+
/** Set by hub archiving when the node is moved to archive storage. */
|
|
27
|
+
_archived?: boolean;
|
|
28
|
+
/** FIX-03: Unix ms timestamp of the earliest spoke ContextNode. */
|
|
29
|
+
earliestSpokeAt?: number;
|
|
30
|
+
/** FIX-03: Unix ms timestamp of the most recent spoke ContextNode. */
|
|
31
|
+
latestSpokeAt?: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Code-aware tokenizer: splits on whitespace, hyphens, underscores, camelCase
|
|
35
|
+
* boundaries, and path separators. Filters to length ≥ 4 and removes stop words.
|
|
36
|
+
* Exported for use in read-path.ts scanOrphanNodes.
|
|
37
|
+
*/
|
|
38
|
+
export declare function tokenizeBM25(text: string): string[];
|
|
39
|
+
/** Session-scoped cache for all active NarrativeNodes (AC-61-02). */
|
|
40
|
+
export declare class NarrativeCache {
|
|
41
|
+
private hubs;
|
|
42
|
+
private _bm25;
|
|
43
|
+
/** Load all records from disk into cache. O(N) — acceptable at startup. */
|
|
44
|
+
build(nodes: NarrativeNode[]): void;
|
|
45
|
+
get(id: string): NarrativeNode | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* All active hubs, newest first.
|
|
48
|
+
* FIX-06: sort key is latestSpokeAt (actual session activity), falling back to updatedAt.
|
|
49
|
+
*/
|
|
50
|
+
all(): NarrativeNode[];
|
|
51
|
+
set(node: NarrativeNode): void;
|
|
52
|
+
update(id: string, patch: Partial<NarrativeNode>): void;
|
|
53
|
+
remove(id: string): void;
|
|
54
|
+
get size(): number;
|
|
55
|
+
/**
|
|
56
|
+
* Build the BM25F index from current hubs + spoke ContextNodes for supplementary field.
|
|
57
|
+
* Called once at session_start after build(). O(N × T) — fast at ≤500 hubs.
|
|
58
|
+
*/
|
|
59
|
+
buildIndex(allContextNodes: ContextNode[]): void;
|
|
60
|
+
/**
|
|
61
|
+
* Rank hubs by composite BM25F score for the given query string.
|
|
62
|
+
* Returns hubs with score > 0, sorted descending. Top 5 returned.
|
|
63
|
+
*
|
|
64
|
+
* Composite score = BM25F × temporal decay × cwd affinity (FIX-01, FIX-03).
|
|
65
|
+
* Returns empty array when index not built — caller should fall back to full-text scan.
|
|
66
|
+
*/
|
|
67
|
+
rankByQuery(topic: string, cwd?: string): NarrativeNode[];
|
|
68
|
+
/**
|
|
69
|
+
* FIX-04: Return hubs for system prompt injection filtered by cwd relevance.
|
|
70
|
+
* Primary: hubs whose spokes come from the current cwd.
|
|
71
|
+
* Fallback: 3 most recently active hubs when no cwd match exists.
|
|
72
|
+
* Hard cap: never more than `max` hubs injected.
|
|
73
|
+
*/
|
|
74
|
+
getHubsForContext(cwd?: string, max?: number): NarrativeNode[];
|
|
75
|
+
}
|
|
76
|
+
export declare function createNarrativeNode(storage: IMemoryStorage, cache: NarrativeCache, node: NarrativeNode): Promise<void>;
|
|
77
|
+
export declare function updateNarrativeNode(storage: IMemoryStorage, cache: NarrativeCache, id: string, patch: Partial<NarrativeNode>): Promise<void>;
|
|
78
|
+
export declare function loadAllNarrativeNodes(storage: IMemoryStorage): Promise<NarrativeNode[]>;
|
|
79
|
+
/**
|
|
80
|
+
* Build the system prompt append block for the narrative layer (F-62).
|
|
81
|
+
* FIX-04: accepts pre-filtered hubs and total hub count for "N of M" header.
|
|
82
|
+
* Summaries truncated to 150 chars. Hub coverage range included when available (FIX-03).
|
|
83
|
+
*/
|
|
84
|
+
export declare function buildNarrativeSystemPromptBlock(hubs: NarrativeNode[], totalCount?: number): string;
|
|
85
|
+
/**
|
|
86
|
+
* Revises the most recently active hub's summary without full compaction.
|
|
87
|
+
* Called every ~20 agent turns to keep the narrative warm during long sessions.
|
|
88
|
+
* Best-effort: errors are swallowed by the caller.
|
|
89
|
+
*/
|
|
90
|
+
export declare function runNarrativeTick(storage: IMemoryStorage, cache: NarrativeCache, complete: LLMCompleteFn): Promise<void>;
|
|
91
|
+
/**
|
|
92
|
+
* Scans active hubs and archives any where every spoke ContextNode has
|
|
93
|
+
* `allEntitiesArchived: true`. These hubs consume system prompt token budget
|
|
94
|
+
* without providing usable recall.
|
|
95
|
+
*
|
|
96
|
+
* Returns count of hubs archived. No LLM calls. Safe at session_start and shutdown.
|
|
97
|
+
*/
|
|
98
|
+
export declare function archiveDeadHubs(storage: IMemoryStorage, cache: NarrativeCache): Promise<number>;
|
|
99
|
+
/**
|
|
100
|
+
* Called after a new ContextNode is written (F-57/F-58).
|
|
101
|
+
* Runs: spoke assignment → optional hub creation → optional hub revision → LRU enforcement.
|
|
102
|
+
* All operations are best-effort — errors are caught and logged.
|
|
103
|
+
*/
|
|
104
|
+
export declare function updateNarrativeLayer(params: {
|
|
105
|
+
newNode: ContextNode;
|
|
106
|
+
storage: IMemoryStorage;
|
|
107
|
+
cache: NarrativeCache;
|
|
108
|
+
complete: LLMCompleteFn;
|
|
109
|
+
}): Promise<void>;
|
|
110
|
+
//# sourceMappingURL=narrative.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"narrative.d.ts","sourceRoot":"","sources":["../../src/core/narrative.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Narrative layer — NarrativeNode schema, CRUD, and hub construction (F-61, F-63, F-64).\n *\n * NarrativeNodes are hub nodes in the three-layer memory architecture.\n * They aggregate ContextNode spoke references and summarise high-level\n * project themes. Maximum 20 active hubs; excess archived to archive/narratives/.\n */\n\nimport type { LLMCompleteFn } from \"../types.js\";\nimport type { IMemoryStorage } from \"../storage/adapter.js\";\nimport type { ContextNode } from \"./context-graph.js\";\n\n// ---------------------------------------------------------------------------\n// F-61: NarrativeNode type\n// ---------------------------------------------------------------------------\n\nexport interface NarrativeNode {\n\tid: string;\n\ttitle: string;\n\tsummary: string;\n\ttopics: string[];\n\t/** IDs of ContextNodes that are spokes of this hub. */\n\tspokeNodeIds: string[];\n\t/**\n\t * F-106: Union of all spoke ContextNode topics — broader than `topics[]` (intersection).\n\t * Indexed by BM25F spoke field so individual session vocabulary is searchable even when\n\t * it does not appear in the hub intersection topics. Backward-compatible (defaults to []).\n\t */\n\tspokeTopics?: string[];\n\tcreatedAt: number;\n\tupdatedAt: number;\n\t/** Set by hub archiving when the node is moved to archive storage. */\n\t_archived?: boolean;\n\t/** FIX-03: Unix ms timestamp of the earliest spoke ContextNode. */\n\tearliestSpokeAt?: number;\n\t/** FIX-03: Unix ms timestamp of the most recent spoke ContextNode. */\n\tlatestSpokeAt?: number;\n}\n\n// ---------------------------------------------------------------------------\n// FIX-02: Generic directory basenames — not injected as identity topics\n// ---------------------------------------------------------------------------\n\nconst GENERIC_DIRS = new Set([\"src\", \"app\", \"lib\", \"dist\", \"packages\", \"build\", \"out\", \"tmp\"]);\n\n// ---------------------------------------------------------------------------\n// FIX-01: BM25F tokenizer and index types\n// ---------------------------------------------------------------------------\n\nconst BM25_K1 = 1.2;\nconst BM25_B = 0.75;\n\n/** Field weights for BM25F scoring. */\nconst FIELD_WEIGHTS = {\n\ttopics: 3.0,\n\ttitle: 2.0,\n\tsummary: 1.0,\n\tspoke: 1.5,\n} as const;\n\nconst STOP_WORDS = new Set([\n\t\"with\", \"from\", \"this\", \"that\", \"have\", \"been\", \"will\", \"into\", \"also\",\n\t\"each\", \"their\", \"when\", \"then\", \"than\", \"they\", \"what\", \"some\",\n]);\n\n/**\n * Code-aware tokenizer: splits on whitespace, hyphens, underscores, camelCase\n * boundaries, and path separators. Filters to length ≥ 4 and removes stop words.\n * Exported for use in read-path.ts scanOrphanNodes.\n */\nexport function tokenizeBM25(text: string): string[] {\n\t// Expand camelCase: \"AuthService\" → \"Auth Service\"\n\tconst expanded = text\n\t\t.replace(/([a-z])([A-Z])/g, \"$1 $2\")\n\t\t.replace(/([A-Z]+)([A-Z][a-z])/g, \"$1 $2\");\n\n\t// Split on whitespace, hyphens, underscores, path separators, dots\n\tconst raw = expanded.split(/[\\s\\-_/\\\\.]+/);\n\n\treturn raw\n\t\t.map((t) => t.toLowerCase())\n\t\t.filter((t) => t.length >= 4 && !STOP_WORDS.has(t));\n}\n\ninterface BM25FieldFreq {\n\ttopics: Map<string, number>;\n\ttitle: Map<string, number>;\n\tsummary: Map<string, number>;\n\tspoke: Map<string, number>;\n}\n\ninterface BM25HubIndex {\n\thubId: string;\n\tfields: BM25FieldFreq;\n\tfieldLengths: { topics: number; title: number; summary: number; spoke: number };\n\t/** cwd basenames from spoke ContextNodes for cwd-affinity scoring */\n\tcwdBasenames: Set<string>;\n}\n\ninterface BM25Index {\n\thubs: Map<string, BM25HubIndex>;\n\t/** Token → hub count (document frequency) */\n\tdf: Map<string, number>;\n\t/** Average field token lengths across corpus */\n\tavgLengths: { topics: number; title: number; summary: number; spoke: number };\n\ttotalHubs: number;\n}\n\nfunction buildFreqMap(tokens: string[]): Map<string, number> {\n\tconst m = new Map<string, number>();\n\tfor (const t of tokens) m.set(t, (m.get(t) ?? 0) + 1);\n\treturn m;\n}\n\n// ---------------------------------------------------------------------------\n// F-61: In-memory narrative cache (FIX-01 BM25F index; FIX-06 sort; FIX-04 context filter)\n// ---------------------------------------------------------------------------\n\n/** Session-scoped cache for all active NarrativeNodes (AC-61-02). */\nexport class NarrativeCache {\n\tprivate hubs: Map<string, NarrativeNode> = new Map();\n\tprivate _bm25: BM25Index | null = null;\n\n\t/** Load all records from disk into cache. O(N) — acceptable at startup. */\n\tbuild(nodes: NarrativeNode[]): void {\n\t\tthis.hubs.clear();\n\t\tthis._bm25 = null;\n\t\tfor (const node of nodes) {\n\t\t\tthis.hubs.set(node.id, node);\n\t\t}\n\t}\n\n\tget(id: string): NarrativeNode | undefined {\n\t\treturn this.hubs.get(id);\n\t}\n\n\t/**\n\t * All active hubs, newest first.\n\t * FIX-06: sort key is latestSpokeAt (actual session activity), falling back to updatedAt.\n\t */\n\tall(): NarrativeNode[] {\n\t\treturn [...this.hubs.values()].sort(\n\t\t\t(a, b) => (b.latestSpokeAt ?? b.updatedAt) - (a.latestSpokeAt ?? a.updatedAt),\n\t\t);\n\t}\n\n\tset(node: NarrativeNode): void {\n\t\tthis.hubs.set(node.id, node);\n\t\tthis._bm25 = null;\n\t}\n\n\tupdate(id: string, patch: Partial<NarrativeNode>): void {\n\t\tconst node = this.hubs.get(id);\n\t\tif (!node) return;\n\t\tthis.hubs.set(id, { ...node, ...patch });\n\t\tthis._bm25 = null;\n\t}\n\n\tremove(id: string): void {\n\t\tthis.hubs.delete(id);\n\t\tthis._bm25 = null;\n\t}\n\n\tget size(): number {\n\t\treturn this.hubs.size;\n\t}\n\n\t// ---------------------------------------------------------------------------\n\t// FIX-01: BM25F index construction\n\t// ---------------------------------------------------------------------------\n\n\t/**\n\t * Build the BM25F index from current hubs + spoke ContextNodes for supplementary field.\n\t * Called once at session_start after build(). O(N × T) — fast at ≤500 hubs.\n\t */\n\tbuildIndex(allContextNodes: ContextNode[]): void {\n\t\tconst contextMap = new Map(allContextNodes.map((n) => [n.id, n]));\n\t\tconst hubEntries: BM25HubIndex[] = [];\n\n\t\tfor (const hub of this.hubs.values()) {\n\t\t\tconst topicsTokens = tokenizeBM25(hub.topics.join(\" \"));\n\t\t\tconst titleTokens = tokenizeBM25(hub.title);\n\t\t\tconst summaryTokens = tokenizeBM25(hub.summary);\n\n\t\t\t// Spoke supplementary field: topics union + searchTerms + filePaths + modules from spoke nodes.\n\t\t\t// F-106: Seed from persisted spokeTopics union first (efficient), then fall through to spoke\n\t\t\t// node scan for searchTerms/filePaths/modules not yet covered by spokeTopics.\n\t\t\t// F-108: include searchTerms — query-side vocabulary generated at write time.\n\t\t\tconst spokeTexts: string[] = hub.spokeTopics?.length ? [...hub.spokeTopics] : [];\n\t\t\tconst cwdBasenames = new Set<string>();\n\t\t\tfor (const spokeId of hub.spokeNodeIds) {\n\t\t\t\tconst node = contextMap.get(spokeId);\n\t\t\t\tif (!node) continue;\n\t\t\t\t// Only add topics if spokeTopics not yet populated (old hubs without F-106 field)\n\t\t\t\tif (!hub.spokeTopics?.length && node.topics?.length) spokeTexts.push(...node.topics);\n\t\t\t\tif (node.searchTerms?.length) spokeTexts.push(...node.searchTerms);\n\t\t\t\tif (node.relevantFilePaths?.length) spokeTexts.push(...node.relevantFilePaths);\n\t\t\t\tif (node.relevantModules?.length) spokeTexts.push(...node.relevantModules);\n\t\t\t\tconst basename = node.workingDirectory?.split(\"/\").pop()?.toLowerCase();\n\t\t\t\tif (basename && basename.length > 2) cwdBasenames.add(basename);\n\t\t\t}\n\t\t\tconst spokeTokens = tokenizeBM25(spokeTexts.join(\" \"));\n\n\t\t\thubEntries.push({\n\t\t\t\thubId: hub.id,\n\t\t\t\tfields: {\n\t\t\t\t\ttopics: buildFreqMap(topicsTokens),\n\t\t\t\t\ttitle: buildFreqMap(titleTokens),\n\t\t\t\t\tsummary: buildFreqMap(summaryTokens),\n\t\t\t\t\tspoke: buildFreqMap(spokeTokens),\n\t\t\t\t},\n\t\t\t\tfieldLengths: {\n\t\t\t\t\ttopics: topicsTokens.length,\n\t\t\t\t\ttitle: titleTokens.length,\n\t\t\t\t\tsummary: summaryTokens.length,\n\t\t\t\t\tspoke: spokeTokens.length,\n\t\t\t\t},\n\t\t\t\tcwdBasenames,\n\t\t\t});\n\t\t}\n\n\t\t// Compute IDF: document frequency per token across all hubs\n\t\tconst df = new Map<string, number>();\n\t\tfor (const entry of hubEntries) {\n\t\t\tconst allTokensInHub = new Set([\n\t\t\t\t...entry.fields.topics.keys(),\n\t\t\t\t...entry.fields.title.keys(),\n\t\t\t\t...entry.fields.summary.keys(),\n\t\t\t\t...entry.fields.spoke.keys(),\n\t\t\t]);\n\t\t\tfor (const token of allTokensInHub) {\n\t\t\t\tdf.set(token, (df.get(token) ?? 0) + 1);\n\t\t\t}\n\t\t}\n\n\t\t// Average field lengths for length normalisation\n\t\tconst N = hubEntries.length;\n\t\tconst avgLengths = N === 0\n\t\t\t? { topics: 1, title: 1, summary: 1, spoke: 1 }\n\t\t\t: {\n\t\t\t\ttopics: hubEntries.reduce((s, e) => s + e.fieldLengths.topics, 0) / N,\n\t\t\t\ttitle: hubEntries.reduce((s, e) => s + e.fieldLengths.title, 0) / N,\n\t\t\t\tsummary: hubEntries.reduce((s, e) => s + e.fieldLengths.summary, 0) / N,\n\t\t\t\tspoke: hubEntries.reduce((s, e) => s + e.fieldLengths.spoke, 0) / N,\n\t\t\t};\n\n\t\tthis._bm25 = { hubs: new Map(hubEntries.map((e) => [e.hubId, e])), df, avgLengths, totalHubs: N };\n\t}\n\n\t/**\n\t * Rank hubs by composite BM25F score for the given query string.\n\t * Returns hubs with score > 0, sorted descending. Top 5 returned.\n\t *\n\t * Composite score = BM25F × temporal decay × cwd affinity (FIX-01, FIX-03).\n\t * Returns empty array when index not built — caller should fall back to full-text scan.\n\t */\n\trankByQuery(topic: string, cwd?: string): NarrativeNode[] {\n\t\tconst index = this._bm25;\n\t\tif (!index || index.totalHubs === 0) return [];\n\n\t\tlet queryTokens = tokenizeBM25(topic);\n\t\t// AC-FIX-01-06: short/symbol query — keep original lowercased as single token\n\t\tif (queryTokens.length === 0) queryTokens = [topic.toLowerCase().slice(0, 20).trim()].filter(Boolean);\n\t\tif (queryTokens.length === 0) return [];\n\n\t\tconst cwdBasename = cwd?.split(\"/\").pop()?.toLowerCase();\n\n\t\tconst scores = new Map<string, number>();\n\n\t\tfor (const [hubId, entry] of index.hubs) {\n\t\t\tlet bm25Score = 0;\n\n\t\t\tfor (const token of queryTokens) {\n\t\t\t\tconst df = index.df.get(token) ?? 0;\n\t\t\t\tif (df === 0) continue;\n\n\t\t\t\tconst idf = Math.log((index.totalHubs - df + 0.5) / (df + 0.5) + 1);\n\n\t\t\t\tlet weightedFreq = 0;\n\t\t\t\tconst fields: Array<[keyof BM25FieldFreq, keyof typeof FIELD_WEIGHTS]> = [\n\t\t\t\t\t[\"topics\", \"topics\"],\n\t\t\t\t\t[\"title\", \"title\"],\n\t\t\t\t\t[\"summary\", \"summary\"],\n\t\t\t\t\t[\"spoke\", \"spoke\"],\n\t\t\t\t];\n\t\t\t\tfor (const [field, weightKey] of fields) {\n\t\t\t\t\tconst freq = entry.fields[field].get(token) ?? 0;\n\t\t\t\t\tif (freq === 0) continue;\n\t\t\t\t\tconst avgLen = index.avgLengths[field] || 1;\n\t\t\t\t\tconst len = entry.fieldLengths[field];\n\t\t\t\t\tconst normFreq = freq / (1 - BM25_B + BM25_B * (len / avgLen));\n\t\t\t\t\tweightedFreq += FIELD_WEIGHTS[weightKey] * normFreq;\n\t\t\t\t}\n\n\t\t\t\tbm25Score += idf * (weightedFreq / (weightedFreq + BM25_K1));\n\t\t\t}\n\n\t\t\tif (bm25Score <= 0) continue;\n\n\t\t\t// Exact phrase bonus: 2+ consecutive query tokens adjacent in title or topics\n\t\t\tif (queryTokens.length >= 2) {\n\t\t\t\tconst hub = this.hubs.get(hubId);\n\t\t\t\tif (hub) {\n\t\t\t\t\tconst titleTokens = tokenizeBM25(hub.title);\n\t\t\t\t\tconst topicsFlat = tokenizeBM25(hub.topics.join(\" \"));\n\t\t\t\t\touter: for (let i = 0; i < queryTokens.length - 1; i++) {\n\t\t\t\t\t\tconst t1 = queryTokens[i];\n\t\t\t\t\t\tconst t2 = queryTokens[i + 1];\n\t\t\t\t\t\tfor (const toks of [titleTokens, topicsFlat]) {\n\t\t\t\t\t\t\tfor (let j = 0; j < toks.length - 1; j++) {\n\t\t\t\t\t\t\t\tif (toks[j] === t1 && toks[j + 1] === t2) {\n\t\t\t\t\t\t\t\t\tbm25Score += BM25_K1;\n\t\t\t\t\t\t\t\t\tbreak outer;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Temporal decay: e^(-0.005 × daysSinceUpdate) — uses latestSpokeAt per FIX-03\n\t\t\tconst hub = this.hubs.get(hubId);\n\t\t\tif (!hub) continue;\n\t\t\tconst anchorTs = hub.latestSpokeAt ?? hub.updatedAt;\n\t\t\tconst daysSince = (Date.now() - anchorTs) / 86_400_000;\n\t\t\tlet finalScore = bm25Score * Math.exp(-0.005 * daysSince);\n\n\t\t\t// cwd affinity: 1.5× boost when spoke nodes share current project basename\n\t\t\tif (cwdBasename && !GENERIC_DIRS.has(cwdBasename) && entry.cwdBasenames.has(cwdBasename)) {\n\t\t\t\tfinalScore *= 1.5;\n\t\t\t}\n\n\t\t\tscores.set(hubId, finalScore);\n\t\t}\n\n\t\treturn [...scores.entries()]\n\t\t\t.sort((a, b) => b[1] - a[1])\n\t\t\t.slice(0, 5)\n\t\t\t.map(([id]) => this.hubs.get(id)!)\n\t\t\t.filter(Boolean);\n\t}\n\n\t/**\n\t * FIX-04: Return hubs for system prompt injection filtered by cwd relevance.\n\t * Primary: hubs whose spokes come from the current cwd.\n\t * Fallback: 3 most recently active hubs when no cwd match exists.\n\t * Hard cap: never more than `max` hubs injected.\n\t */\n\tgetHubsForContext(cwd?: string, max = 5): NarrativeNode[] {\n\t\tconst all = this.all();\n\t\tif (!cwd || all.length === 0) return all.slice(0, max);\n\n\t\tconst cwdBasename = cwd.split(\"/\").pop()?.toLowerCase();\n\t\tif (!cwdBasename || GENERIC_DIRS.has(cwdBasename)) return all.slice(0, max);\n\n\t\tconst matched: NarrativeNode[] = [];\n\t\tfor (const hub of all) {\n\t\t\tconst entry = this._bm25?.hubs.get(hub.id);\n\t\t\tif (entry?.cwdBasenames.has(cwdBasename)) {\n\t\t\t\tmatched.push(hub);\n\t\t\t}\n\t\t}\n\n\t\tif (matched.length > 0) return matched.slice(0, max);\n\n\t\t// Fallback: 3 most recently active hubs as orientation context\n\t\treturn all.slice(0, Math.min(3, max));\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// F-61: CRUD over layer3-narrative.jsonl\n// ---------------------------------------------------------------------------\n\nexport async function createNarrativeNode(\n\tstorage: IMemoryStorage,\n\tcache: NarrativeCache,\n\tnode: NarrativeNode,\n): Promise<void> {\n\tawait storage.append<NarrativeNode>(\"narratives\", node);\n\tcache.set(node);\n}\n\nexport async function updateNarrativeNode(\n\tstorage: IMemoryStorage,\n\tcache: NarrativeCache,\n\tid: string,\n\tpatch: Partial<NarrativeNode>,\n): Promise<void> {\n\tawait storage.update<NarrativeNode>(\"narratives\", id, patch);\n\tcache.update(id, patch);\n}\n\nexport async function loadAllNarrativeNodes(storage: IMemoryStorage): Promise<NarrativeNode[]> {\n\tconst all = await storage.readAll<NarrativeNode>(\"narratives\");\n\treturn all.filter((n) => !n._archived);\n}\n\n// ---------------------------------------------------------------------------\n// F-62: Narrative injection content (FIX-04: cwd filter, 5-hub cap, 150-char truncation)\n// ---------------------------------------------------------------------------\n\nconst ACTIVATION_PROTOCOL =\n\t\"When the user references past work, a specific file, decision, or change not present \" +\n\t\"in the current session context — do not answer from assumption. Call \" +\n\t\"memory_entity_lookup(name, type) or memory_query(topic) before responding.\";\n\n/**\n * Build the system prompt append block for the narrative layer (F-62).\n * FIX-04: accepts pre-filtered hubs and total hub count for \"N of M\" header.\n * Summaries truncated to 150 chars. Hub coverage range included when available (FIX-03).\n */\nexport function buildNarrativeSystemPromptBlock(hubs: NarrativeNode[], totalCount?: number): string {\n\tconst lines: string[] = [];\n\tconst total = totalCount ?? hubs.length;\n\n\tif (hubs.length === 0) {\n\t\tlines.push(\"[MEMORY: Project Narrative]\", \"\");\n\t\tlines.push(\"[MEMORY: No narrative index yet — will build over time]\");\n\t} else {\n\t\tlines.push(`[MEMORY: Project Narrative — showing ${hubs.length} of ${total} hubs for current project]`, \"\");\n\t\tfor (const hub of hubs) {\n\t\t\tconst truncatedSummary = hub.summary.length > 150\n\t\t\t\t? hub.summary.slice(0, 150) + \"...\"\n\t\t\t\t: hub.summary;\n\t\t\tconst coverage = hub.earliestSpokeAt && hub.latestSpokeAt\n\t\t\t\t? ` | coverage: ${new Date(hub.earliestSpokeAt).toISOString().split(\"T\")[0]} → ${new Date(hub.latestSpokeAt).toISOString().split(\"T\")[0]}`\n\t\t\t\t: \"\";\n\t\t\tlines.push(`Hub: ${hub.title}`);\n\t\t\tlines.push(` Topics: ${hub.topics.join(\", \")}`);\n\t\t\tlines.push(` Summary: ${truncatedSummary}`);\n\t\t\tlines.push(` Sessions: ${hub.spokeNodeIds.length} entries${coverage}`);\n\t\t\tlines.push(\"\");\n\t\t}\n\t}\n\n\tlines.push(ACTIVATION_PROTOCOL);\n\treturn lines.join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// F-63: Spoke assignment (FIX-05: BM25F pre-filter; FIX-03: latestSpokeAt update)\n// ---------------------------------------------------------------------------\n\nconst SPOKE_SYSTEM_PROMPT =\n\t\"You are a memory routing assistant. Given a new session summary and a list of narrative hubs, \" +\n\t\"determine if the summary belongs to exactly one hub. Respond with JSON only.\";\n\nfunction buildSpokeAssignmentPrompt(newNode: ContextNode, hubs: NarrativeNode[]) {\n\tconst hubText = hubs\n\t\t.map((h, i) => `[${i + 1}] ID: ${h.id}\\nTitle: ${h.title}\\nTopics: ${h.topics.join(\", \")}\\nSummary: ${h.summary}`)\n\t\t.join(\"\\n\\n\");\n\n\treturn [\n\t\t{\n\t\t\trole: \"user\" as const,\n\t\t\ttimestamp: Date.now(),\n\t\t\tcontent: `New session summary:\nUser: ${newNode.userContext}\nAgent: ${newNode.agentContext}\nTopics: ${newNode.topics.join(\", \")}\n\nNarrative hubs:\n${hubText}\n\nDoes this summary belong to exactly one of the hubs above? If yes, return its ID. If no, return null.\nRespond with JSON only: { \"hubId\": \"<id>\" | null }`,\n\t\t},\n\t];\n}\n\nasync function assignSpoke(\n\tnewNode: ContextNode,\n\thubs: NarrativeNode[],\n\tstorage: IMemoryStorage,\n\tcache: NarrativeCache,\n\tcomplete: LLMCompleteFn,\n): Promise<string | null> {\n\tif (hubs.length === 0) return null;\n\n\t// FIX-05: Pre-filter hub candidates using BM25F — pass only plausible candidates to LLM.\n\t// Falls back to full hub list when the BM25 index is not yet built (e.g. first session start).\n\tconst queryText = `${newNode.topics.join(\" \")} ${newNode.userContext} ${newNode.agentContext}`;\n\tconst ranked = cache.rankByQuery(queryText);\n\t// When the index isn't built, rankByQuery returns [] — fall back to all hubs (capped at 5)\n\tconst candidates = ranked.length > 0 ? ranked.slice(0, 5) : hubs.slice(0, 5);\n\n\t// AC-FIX-05-03: If BM25F returned results but none scored above zero, skip LLM entirely.\n\t// When falling back to all hubs, always proceed to LLM.\n\tif (ranked.length > 0 && candidates.length === 0) return null;\n\n\tconst messages = buildSpokeAssignmentPrompt(newNode, candidates);\n\tlet raw: string;\n\ttry {\n\t\tconst response = await complete(messages, { systemPrompt: SPOKE_SYSTEM_PROMPT });\n\t\tconst textBlock = Array.isArray(response.content)\n\t\t\t? response.content.find((b) => (b as { type: string }).type === \"text\")\n\t\t\t: null;\n\t\traw = textBlock ? (textBlock as { text: string }).text : \"\";\n\t} catch {\n\t\treturn null;\n\t}\n\n\tconst cleaned = raw.replace(/^```(?:json)?\\n?/m, \"\").replace(/\\n?```$/m, \"\").trim();\n\tlet hubId: string | null = null;\n\ttry {\n\t\tconst parsed = JSON.parse(cleaned) as { hubId?: string | null };\n\t\thubId = typeof parsed.hubId === \"string\" ? parsed.hubId : null;\n\t} catch {\n\t\treturn null;\n\t}\n\n\t// Validate the returned hub ID is from the candidate list (not the full hub list)\n\tif (!hubId || !candidates.find((h) => h.id === hubId)) return null;\n\n\tconst hub = cache.get(hubId);\n\tif (!hub) return null;\n\tconst updatedSpokes = [...hub.spokeNodeIds, newNode.id];\n\n\t// FIX-03: Maintain earliestSpokeAt / latestSpokeAt on spoke add\n\tconst nodeTs = newNode.timestamp ?? Date.now();\n\tconst earliestSpokeAt = hub.earliestSpokeAt !== undefined\n\t\t? Math.min(hub.earliestSpokeAt, nodeTs)\n\t\t: nodeTs;\n\tconst latestSpokeAt = hub.latestSpokeAt !== undefined\n\t\t? Math.max(hub.latestSpokeAt, nodeTs)\n\t\t: nodeTs;\n\n\t// F-106: Maintain spokeTopics union — append new spoke's topics, deduplicated\n\tconst updatedSpokeTopics = [...new Set([...(hub.spokeTopics ?? []), ...newNode.topics])];\n\n\tawait updateNarrativeNode(storage, cache, hubId, {\n\t\tspokeNodeIds: updatedSpokes,\n\t\tspokeTopics: updatedSpokeTopics,\n\t\tupdatedAt: Date.now(),\n\t\tearliestSpokeAt,\n\t\tlatestSpokeAt,\n\t});\n\n\treturn hubId;\n}\n\n// ---------------------------------------------------------------------------\n// F-63: Hub creation (FIX-02: cwd basename topic; FIX-03: spoke timestamps)\n// ---------------------------------------------------------------------------\n\nconst HUB_CREATION_SYSTEM_PROMPT =\n\t\"You are a memory organisation assistant. Create a narrative hub title and summary that captures \" +\n\t\"a theme common to the provided session summaries. Respond with JSON only.\";\n\nfunction commonTopics(nodes: ContextNode[]): string[] {\n\tconst topicCounts = new Map<string, number>();\n\tfor (const node of nodes) {\n\t\tfor (const topic of node.topics) {\n\t\t\ttopicCounts.set(topic, (topicCounts.get(topic) ?? 0) + 1);\n\t\t}\n\t}\n\treturn [...topicCounts.entries()].filter(([, count]) => count >= 2).map(([topic]) => topic);\n}\n\nfunction existingHubCoversTopics(hubs: NarrativeNode[], topics: string[]): boolean {\n\tconst topicSet = new Set(topics);\n\tfor (const hub of hubs) {\n\t\tconst matches = hub.topics.filter((t) => topicSet.has(t));\n\t\tif (matches.length >= 2) return true;\n\t}\n\treturn false;\n}\n\n/**\n * FIX-02: Extract the dominant working directory basename from a set of context nodes.\n * Returns null when no clear majority basename exists or basename is generic.\n */\nfunction extractDominantBasename(nodes: ContextNode[]): string | null {\n\tconst basenames = nodes\n\t\t.map((n) => n.workingDirectory?.split(\"/\").pop()?.toLowerCase())\n\t\t.filter((b): b is string => !!b && b.length > 2 && !GENERIC_DIRS.has(b));\n\n\tif (basenames.length === 0) return null;\n\n\tconst counts = new Map<string, number>();\n\tfor (const b of basenames) counts.set(b, (counts.get(b) ?? 0) + 1);\n\tconst dominant = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];\n\n\t// Only inject if dominant appears on ≥50% of nodes\n\treturn dominant && dominant[1] >= nodes.length / 2 ? dominant[0] : null;\n}\n\nasync function tryCreateHub(\n\tcandidateNodes: ContextNode[],\n\tstorage: IMemoryStorage,\n\tcache: NarrativeCache,\n\tcomplete: LLMCompleteFn,\n): Promise<NarrativeNode | null> {\n\tconst allNodes = await storage.readAll<ContextNode>(\"context\");\n\tconst hubs = cache.all();\n\n\tconst unassignedNodes = allNodes.filter(\n\t\t(n) => !hubs.some((h) => h.spokeNodeIds.includes(n.id)),\n\t);\n\tif (unassignedNodes.length < 3) return null;\n\n\tconst shared = commonTopics(unassignedNodes);\n\tif (shared.length < 2) return null;\n\tif (existingHubCoversTopics(hubs, shared)) return null;\n\n\t// FIX-02: Inject dominant cwd basename as an identity topic\n\tconst dominantBasename = extractDominantBasename(unassignedNodes);\n\tconst allTopics = dominantBasename ? [...new Set([...shared, dominantBasename])] : shared;\n\n\tconst nodeText = unassignedNodes\n\t\t.slice(0, 5)\n\t\t.map((n) => `User: ${n.userContext}\\nAgent: ${n.agentContext}`)\n\t\t.join(\"\\n\\n\");\n\n\tconst messages = [\n\t\t{\n\t\t\trole: \"user\" as const,\n\t\t\ttimestamp: Date.now(),\n\t\t\tcontent: `Create a narrative hub for these session summaries that share common topics: ${allTopics.join(\", \")}\n\nSession summaries:\n${nodeText}\n\nRespond with JSON only:\n{ \"title\": \"<short hub title>\", \"summary\": \"<2-3 sentence hub summary>\" }`,\n\t\t},\n\t];\n\n\tlet raw: string;\n\ttry {\n\t\tconst response = await complete(messages, { systemPrompt: HUB_CREATION_SYSTEM_PROMPT });\n\t\tconst textBlock = Array.isArray(response.content)\n\t\t\t? response.content.find((b) => (b as { type: string }).type === \"text\")\n\t\t\t: null;\n\t\traw = textBlock ? (textBlock as { text: string }).text : \"\";\n\t} catch {\n\t\treturn null;\n\t}\n\n\tconst cleaned = raw.replace(/^```(?:json)?\\n?/m, \"\").replace(/\\n?```$/m, \"\").trim();\n\tlet title: string | null = null;\n\tlet summary: string | null = null;\n\ttry {\n\t\tconst parsed = JSON.parse(cleaned) as { title?: string; summary?: string };\n\t\ttitle = typeof parsed.title === \"string\" ? parsed.title : null;\n\t\tsummary = typeof parsed.summary === \"string\" ? parsed.summary : null;\n\t} catch {\n\t\treturn null;\n\t}\n\n\tif (!title || !summary) return null;\n\n\t// FIX-03: Set spoke timestamp range from the unassigned nodes\n\tconst spokeTimestamps = unassignedNodes.map((n) => n.timestamp ?? 0).filter((t) => t > 0);\n\tconst earliestSpokeAt = spokeTimestamps.length > 0 ? Math.min(...spokeTimestamps) : undefined;\n\tconst latestSpokeAt = spokeTimestamps.length > 0 ? Math.max(...spokeTimestamps) : undefined;\n\n\tconst now = Date.now();\n\t// F-106: Initialize spokeTopics as union of all candidate node topics\n\tconst spokeTopics = [...new Set(unassignedNodes.flatMap((n) => n.topics))];\n\n\tconst hub: NarrativeNode = {\n\t\tid: globalThis.crypto.randomUUID(),\n\t\ttitle,\n\t\tsummary,\n\t\ttopics: allTopics,\n\t\tspokeTopics,\n\t\tspokeNodeIds: unassignedNodes.map((n) => n.id),\n\t\tcreatedAt: now,\n\t\tupdatedAt: now,\n\t\tearliestSpokeAt,\n\t\tlatestSpokeAt,\n\t};\n\n\tawait createNarrativeNode(storage, cache, hub);\n\treturn hub;\n}\n\n// ---------------------------------------------------------------------------\n// F-63: Hub summary revision (FIX-07: founding + recent spokes for revision context)\n// ---------------------------------------------------------------------------\n\nconst HUB_REVISION_SYSTEM_PROMPT =\n\t\"You are a memory summarisation assistant. Revise the hub summary to incorporate new session data. Respond with JSON only.\";\n\nasync function tryReviseHub(\n\thub: NarrativeNode,\n\tnewSpokesAdded: number,\n\tstorage: IMemoryStorage,\n\tcache: NarrativeCache,\n\tcomplete: LLMCompleteFn,\n): Promise<void> {\n\tif (newSpokesAdded < 5) return;\n\n\tconst allContextNodes = await storage.readAll<ContextNode>(\"context\");\n\n\t// FIX-07: First 2 spokes (founding identity anchor) + last 3 (recent activity), deduplicated.\n\t// For hubs with ≤5 spokes, uses all spokes.\n\tconst allSpokes = hub.spokeNodeIds\n\t\t.map((id) => allContextNodes.find((n) => n.id === id))\n\t\t.filter((n): n is ContextNode => n !== undefined);\n\n\tconst founding = allSpokes.slice(0, 2);\n\tconst recent = allSpokes.slice(-3);\n\tconst spokeNodes = [...new Map([...founding, ...recent].map((n) => [n.id, n])).values()];\n\n\tconst nodeText = spokeNodes\n\t\t.map((n) => `User: ${n.userContext}\\nAgent: ${n.agentContext}`)\n\t\t.join(\"\\n\\n\");\n\n\tconst messages = [\n\t\t{\n\t\t\trole: \"user\" as const,\n\t\t\ttimestamp: Date.now(),\n\t\t\tcontent: `Update the hub summary to reflect recent activity while preserving the founding context.\n\nHub: \"${hub.title}\"\nCurrent summary: ${hub.summary}\nTopics: ${hub.topics.join(\", \")}\n\nSessions (founding + recent):\n${nodeText}\n\nRespond with JSON only: { \"summary\": \"<revised 2-3 sentence summary>\" }`,\n\t\t},\n\t];\n\n\ttry {\n\t\tconst response = await complete(messages, { systemPrompt: HUB_REVISION_SYSTEM_PROMPT });\n\t\tconst textBlock = Array.isArray(response.content)\n\t\t\t? response.content.find((b) => (b as { type: string }).type === \"text\")\n\t\t\t: null;\n\t\tconst raw = textBlock ? (textBlock as { text: string }).text : \"\";\n\t\tconst cleaned = raw.replace(/^```(?:json)?\\n?/m, \"\").replace(/\\n?```$/m, \"\").trim();\n\t\tconst parsed = JSON.parse(cleaned) as { summary?: string };\n\t\tif (typeof parsed.summary === \"string\") {\n\t\t\tawait updateNarrativeNode(storage, cache, hub.id, {\n\t\t\t\tsummary: parsed.summary,\n\t\t\t\tupdatedAt: Date.now(),\n\t\t\t});\n\t\t}\n\t} catch {\n\t\t// Non-fatal\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// F-64: 20-hub limit + LRU archival (FIX-06: latestSpokeAt for eviction sort)\n// ---------------------------------------------------------------------------\n\nconst MAX_HUBS = 20;\nconst MAX_NARRATIVE_TOKENS = 8_000;\nconst CHARS_PER_TOKEN = 4;\n\nfunction estimateNarrativeTokens(hubs: NarrativeNode[]): number {\n\tconst totalChars = hubs.reduce(\n\t\t(sum, h) => sum + h.title.length + h.summary.length + h.topics.join(\", \").length,\n\t\t0,\n\t);\n\treturn Math.ceil(totalChars / CHARS_PER_TOKEN);\n}\n\nasync function enforceHubLimit(\n\tstorage: IMemoryStorage,\n\tcache: NarrativeCache,\n): Promise<void> {\n\t// FIX-06: Sort by latestSpokeAt (true session activity), fallback to updatedAt\n\tconst hubs = cache.all().sort(\n\t\t(a, b) => (b.latestSpokeAt ?? b.updatedAt) - (a.latestSpokeAt ?? a.updatedAt),\n\t);\n\tconst tokenEstimate = estimateNarrativeTokens(hubs);\n\tconst overTokens = tokenEstimate > MAX_NARRATIVE_TOKENS;\n\n\tconst target = overTokens ? MAX_HUBS - 1 : MAX_HUBS;\n\tif (hubs.length <= target) return;\n\n\t// Archive least-recently-active hubs (tail of the sorted list)\n\tconst toArchive = hubs.slice(target);\n\tfor (const hub of toArchive) {\n\t\tawait storage.archive<NarrativeNode>(\"narratives-archive\", hub);\n\t\tawait storage.update<NarrativeNode>(\"narratives\", hub.id, { _archived: true });\n\t\tcache.remove(hub.id);\n\n\t\tif (overTokens) {\n\t\t\tconsole.warn(`[memory] Narrative token budget exceeded — archiving hub \"${hub.title}\"`);\n\t\t}\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// F-97: Lightweight narrative tick — mid-session hub summary refresh\n// ---------------------------------------------------------------------------\n\n/**\n * Revises the most recently active hub's summary without full compaction.\n * Called every ~20 agent turns to keep the narrative warm during long sessions.\n * Best-effort: errors are swallowed by the caller.\n */\nexport async function runNarrativeTick(\n\tstorage: IMemoryStorage,\n\tcache: NarrativeCache,\n\tcomplete: LLMCompleteFn,\n): Promise<void> {\n\tconst hubs = cache.all(); // sorted by latestSpokeAt ?? updatedAt\n\tif (hubs.length === 0) return;\n\tawait tryReviseHub(hubs[0], 5, storage, cache, complete);\n}\n\n// ---------------------------------------------------------------------------\n// F-96 + F-107: Eager hub formation — stub hub for single-node orphan prevention\n// ---------------------------------------------------------------------------\n\nconst STUB_HUB_SYSTEM_PROMPT =\n\t\"You are a memory summarisation assistant. Write a 2-sentence summary of this coding session. Respond with JSON only.\";\n\n/**\n * Builds a stub NarrativeNode from a single ContextNode.\n * F-107: Makes one LLM call to generate a richer summary so the BM25F index\n * has more surface area from the first session. Falls back to raw concatenation\n * on any failure — stub creation never throws.\n * FIX-02: Injects working directory basename as identity topic when meaningful.\n * FIX-03: Sets earliestSpokeAt / latestSpokeAt from node timestamp.\n */\nasync function buildStubHub(\n\tnode: ContextNode,\n\tcomplete: LLMCompleteFn,\n): Promise<NarrativeNode> {\n\tconst now = Date.now();\n\tconst title =\n\t\tnode.topics.length > 0\n\t\t\t? node.topics.slice(0, 2).join(\", \")\n\t\t\t: node.userContext.slice(0, 60).trimEnd();\n\n\t// FIX-02: Inject cwd basename as identity topic when non-generic\n\tconst cwdBasename = node.workingDirectory?.split(\"/\").pop()?.toLowerCase();\n\tconst injectBasename =\n\t\tcwdBasename && cwdBasename.length > 2 && !GENERIC_DIRS.has(cwdBasename)\n\t\t\t? cwdBasename\n\t\t\t: null;\n\tconst topics = injectBasename\n\t\t? [...new Set([...node.topics, injectBasename])]\n\t\t: node.topics;\n\n\t// F-107: LLM-generated summary — falls back to raw concat on any failure\n\tlet summary = `${node.userContext} — ${node.agentContext}`.slice(0, 300);\n\ttry {\n\t\tconst messages = [{\n\t\t\trole: \"user\" as const,\n\t\t\ttimestamp: now,\n\t\t\tcontent: `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>\" }`,\n\t\t}];\n\t\tconst response = await complete(messages, { systemPrompt: STUB_HUB_SYSTEM_PROMPT });\n\t\tconst textBlock = Array.isArray(response.content)\n\t\t\t? response.content.find((b) => (b as { type: string }).type === \"text\")\n\t\t\t: null;\n\t\tconst raw = textBlock ? (textBlock as { text: string }).text : \"\";\n\t\tconst cleaned = raw.replace(/^```(?:json)?\\n?/m, \"\").replace(/\\n?```$/m, \"\").trim();\n\t\tconst parsed = JSON.parse(cleaned) as { summary?: string };\n\t\tif (typeof parsed.summary === \"string\" && parsed.summary.length >= 20) {\n\t\t\tsummary = parsed.summary;\n\t\t}\n\t} catch {\n\t\t// Non-fatal — raw concatenation fallback already set above\n\t}\n\n\t// FIX-03: Spoke timestamps from the node\n\tconst nodeTs = node.timestamp ?? now;\n\n\treturn {\n\t\tid: globalThis.crypto.randomUUID(),\n\t\ttitle,\n\t\tsummary,\n\t\ttopics,\n\t\t// F-106: spokeTopics initialized from the single founding node's topics\n\t\tspokeTopics: [...topics],\n\t\tspokeNodeIds: [node.id],\n\t\tcreatedAt: now,\n\t\tupdatedAt: now,\n\t\tearliestSpokeAt: nodeTs,\n\t\tlatestSpokeAt: nodeTs,\n\t};\n}\n// ---------------------------------------------------------------------------\n// F-98: Dead hub detection — archive hubs whose all spokes have archived entities\n// ---------------------------------------------------------------------------\n\n/**\n * Scans active hubs and archives any where every spoke ContextNode has\n * `allEntitiesArchived: true`. These hubs consume system prompt token budget\n * without providing usable recall.\n *\n * Returns count of hubs archived. No LLM calls. Safe at session_start and shutdown.\n */\nexport async function archiveDeadHubs(\n\tstorage: IMemoryStorage,\n\tcache: NarrativeCache,\n): Promise<number> {\n\tconst allContextNodes = await storage.readAll<ContextNode>(\"context\");\n\tconst contextNodeMap = new Map(allContextNodes.map((n) => [n.id, n]));\n\n\tconst hubs = cache.all();\n\tlet archivedCount = 0;\n\n\tfor (const hub of hubs) {\n\t\tif (hub.spokeNodeIds.length === 0) continue;\n\n\t\tconst allSpokesArchivedOrMissing = hub.spokeNodeIds.every((id) => {\n\t\t\tconst node = contextNodeMap.get(id);\n\t\t\treturn !node || node.allEntitiesArchived === true;\n\t\t});\n\n\t\tif (allSpokesArchivedOrMissing) {\n\t\t\tawait storage.archive<NarrativeNode>(\"narratives-archive\", hub);\n\t\t\tawait storage.update<NarrativeNode>(\"narratives\", hub.id, { _archived: true });\n\t\t\tcache.remove(hub.id);\n\t\t\tarchivedCount++;\n\t\t}\n\t}\n\n\treturn archivedCount;\n}\n\n// ---------------------------------------------------------------------------\n// Main entry point: post-compaction narrative construction\n// ---------------------------------------------------------------------------\n\n/**\n * Called after a new ContextNode is written (F-57/F-58).\n * Runs: spoke assignment → optional hub creation → optional hub revision → LRU enforcement.\n * All operations are best-effort — errors are caught and logged.\n */\nexport async function updateNarrativeLayer(params: {\n\tnewNode: ContextNode;\n\tstorage: IMemoryStorage;\n\tcache: NarrativeCache;\n\tcomplete: LLMCompleteFn;\n}): Promise<void> {\n\tconst { newNode, storage, cache, complete } = params;\n\tconst hubs = cache.all();\n\n\ttry {\n\t\t// F-63: Spoke assignment (FIX-05: BM25F pre-filter inside assignSpoke)\n\t\tconst assignedHubId = await assignSpoke(newNode, hubs, storage, cache, complete);\n\n\t\t// Revision trigger: revise hub summary every 5 spokes\n\t\tif (assignedHubId) {\n\t\t\tconst hub = cache.get(assignedHubId);\n\t\t\tif (hub && hub.spokeNodeIds.length % 5 === 0) {\n\t\t\t\tawait tryReviseHub(hub, 5, storage, cache, complete);\n\t\t\t}\n\t\t}\n\n\t\t// F-63: Hub creation check (≥3 unassigned nodes with ≥2 common topics)\n\t\tconst createdHub = await tryCreateHub([newNode], storage, cache, complete);\n\n\t\t// F-96 + F-107: Eager hub formation — create stub immediately to eliminate orphans\n\t\tif (!assignedHubId && !createdHub) {\n\t\t\tconst stub = await buildStubHub(newNode, complete);\n\t\t\tawait createNarrativeNode(storage, cache, stub);\n\t\t}\n\n\t\t// F-64: Enforce hub limit\n\t\tawait enforceHubLimit(storage, cache);\n\t} catch (err) {\n\t\tconsole.error(\"[memory] narrative update error:\", err instanceof Error ? err.message : String(err));\n\t}\n}\n"]}
|