@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,53 @@
1
+ /**
2
+ * Memory read path — F-65 (session start retrieval), F-66 (per-turn injection),
3
+ * F-69 (active query tools: memory_entity_lookup, memory_query, memory_recall).
4
+ */
5
+ import type { LLMCompleteFn } from "../types.js";
6
+ import type { IMemoryStorage } from "../storage/adapter.js";
7
+ import type { EntityType } from "./entity.js";
8
+ import type { EntityIndex } from "./entity-index.js";
9
+ import type { NarrativeCache } from "./narrative.js";
10
+ /**
11
+ * Retrieve context nodes relevant to the current session (AC-65-01, AC-65-02, AC-65-03).
12
+ * Called once at before_agent_start on the first agent turn.
13
+ */
14
+ export declare function buildSessionStartInjection(params: {
15
+ storage: IMemoryStorage;
16
+ entityIndex: EntityIndex;
17
+ narrativeCache: NarrativeCache;
18
+ cwd: string;
19
+ sessionPrompt?: string;
20
+ }): Promise<string | null>;
21
+ /**
22
+ * Build per-turn entity-driven injection block (AC-66-01, AC-66-02, AC-66-03).
23
+ */
24
+ export declare function buildPerTurnInjection(params: {
25
+ userMessage: string;
26
+ storage: IMemoryStorage;
27
+ entityIndex: EntityIndex;
28
+ }): Promise<string | null>;
29
+ /**
30
+ * memory_entity_lookup: O(1) entity lookup + context node retrieval (AC-69-01, AC-69-03).
31
+ * Falls back to searching all entity types when the specified type yields no match.
32
+ */
33
+ export declare function memoryEntityLookup(name: string, type: EntityType, storage: IMemoryStorage, entityIndex: EntityIndex): Promise<string>;
34
+ /**
35
+ * memory_query: BM25F-ranked hub matching + spoke context retrieval (AC-69-02, AC-69-03).
36
+ * FIX-01: rankHubsByQuery replaces binary matchHubsByTopic.
37
+ * F-93: orphan scan as safety-net lane.
38
+ * F-94: coverage signal in header.
39
+ * F-109: optional LLM query expansion before BM25F.
40
+ */
41
+ export declare function memoryQuery(topic: string, storage: IMemoryStorage, entityIndex: EntityIndex, narrativeCache: NarrativeCache, cwd?: string, complete?: LLMCompleteFn): Promise<string>;
42
+ /**
43
+ * memory_recall: unified topic → context → Layer 1 session turns (one tool call).
44
+ * FIX-01: BM25F ranked hub matching replaces binary matchHubsByTopic.
45
+ * FIX-03: Optional fromDate/toDate date-range filtering on spoke ContextNodes.
46
+ *
47
+ * 1. Ranks narrative hubs by BM25F score for topic.
48
+ * 2. Loads context nodes from matching hubs (optionally filtered by date range).
49
+ * 3. Resolves and reads session JSONL for the best matching context node.
50
+ * Returns context summary + session turns with [YYYY-MM-DD HH:mm] timestamps.
51
+ */
52
+ export declare function memoryRecall(topic: string, storage: IMemoryStorage, entityIndex: EntityIndex, narrativeCache: NarrativeCache, maxTurns?: number, cwd?: string, fromDate?: Date, toDate?: Date, complete?: LLMCompleteFn): Promise<string>;
53
+ //# sourceMappingURL=read-path.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"read-path.d.ts","sourceRoot":"","sources":["../../src/core/read-path.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAC5D,OAAO,KAAK,EAAc,UAAU,EAAE,MAAM,aAAa,CAAC;AAI1D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,KAAK,EAAE,cAAc,EAAiB,MAAM,gBAAgB,CAAC;AAiLpE;;;GAGG;AACH,wBAAsB,0BAA0B,CAAC,MAAM,EAAE;IACxD,OAAO,EAAE,cAAc,CAAC;IACxB,WAAW,EAAE,WAAW,CAAC;IACzB,cAAc,EAAE,cAAc,CAAC;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,aAAa,CAAC,EAAE,MAAM,CAAC;CACvB,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAgDzB;AA8BD;;GAEG;AACH,wBAAsB,qBAAqB,CAAC,MAAM,EAAE;IACnD,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,cAAc,CAAC;IACxB,WAAW,EAAE,WAAW,CAAC;CACzB,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAqEzB;AAQD;;;GAGG;AACH,wBAAsB,kBAAkB,CACvC,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,UAAU,EAChB,OAAO,EAAE,cAAc,EACvB,WAAW,EAAE,WAAW,GACtB,OAAO,CAAC,MAAM,CAAC,CAqEjB;AAED;;;;;;GAMG;AACH,wBAAsB,WAAW,CAChC,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,cAAc,EACvB,WAAW,EAAE,WAAW,EACxB,cAAc,EAAE,cAAc,EAC9B,GAAG,CAAC,EAAE,MAAM,EACZ,QAAQ,CAAC,EAAE,aAAa,GACtB,OAAO,CAAC,MAAM,CAAC,CAqFjB;AAqDD;;;;;;;;;GASG;AACH,wBAAsB,YAAY,CACjC,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,cAAc,EACvB,WAAW,EAAE,WAAW,EACxB,cAAc,EAAE,cAAc,EAC9B,QAAQ,SAAK,EACb,GAAG,CAAC,EAAE,MAAM,EACZ,QAAQ,CAAC,EAAE,IAAI,EACf,MAAM,CAAC,EAAE,IAAI,EACb,QAAQ,CAAC,EAAE,aAAa,GACtB,OAAO,CAAC,MAAM,CAAC,CAkIjB","sourcesContent":["/**\n * Memory read path — F-65 (session start retrieval), F-66 (per-turn injection),\n * F-69 (active query tools: memory_entity_lookup, memory_query, memory_recall).\n */\n\nimport type { LLMCompleteFn } from \"../types.js\";\nimport type { IMemoryStorage } from \"../storage/adapter.js\";\nimport type { EntityNode, EntityType } from \"./entity.js\";\nimport { validityScore } from \"./entity.js\";\nimport type { ContextNode } from \"./context-graph.js\";\nimport { loadContextNodeWithSupersession, loadAllContextNodes, projectHealth, updateContextNode } from \"./context-graph.js\";\nimport type { EntityIndex } from \"./entity-index.js\";\nimport type { NarrativeCache, NarrativeNode } from \"./narrative.js\";\nimport { tokenizeBM25 } from \"./narrative.js\";\nimport { loadSessionTree, resolveSessionFile } from \"./session-tree-index.js\";\nimport { readJsonlFile } from \"../storage/file-utils.js\";\n\n// ---------------------------------------------------------------------------\n// Injection formatting\n// ---------------------------------------------------------------------------\n\nconst CHARS_PER_TOKEN_APPROX = 4;\n\nfunction validityLabel(score: number): string {\n\tif (score > 0.7) return \"trusted\";\n\tif (score >= 0.3) return \"uncertain\";\n\tif (score >= 0) return \"degraded\";\n\treturn \"deprecated\";\n}\n\nfunction formatContextNodeBlock(\n\tnode: ContextNode,\n\tsuperseded: ContextNode | null,\n\tentityIndex: EntityIndex,\n\tnow: number,\n): string {\n\tconst date = new Date(node.timestamp).toISOString().split(\"T\")[0];\n\tconst health = projectHealth(node, entityIndex, now);\n\tconst entityLines: string[] = [];\n\n\tfor (const id of node.entityPointerIds) {\n\t\tconst entity = entityIndex.getById(id);\n\t\tif (!entity) continue;\n\t\tconst score = validityScore(entity.eventMatrix, entity.type, now);\n\t\tconst daysAgo = Math.floor((now - entity.lastReferencedAt) / 86_400_000);\n\t\tentityLines.push(\n\t\t\t`[ENTITY: ${entity.canonicalName} | ${entity.type} | validity: ${score.toFixed(2)} (${validityLabel(score)}) | ref: ${daysAgo}d ago]`,\n\t\t);\n\t}\n\n\tconst stalenessWarning =\n\t\thealth.degradedCount > 0 || health.deprecatedCount > 0\n\t\t\t? ` [STALE: ${health.degradedCount} degraded, ${health.deprecatedCount} deprecated entities]`\n\t\t\t: \"\";\n\n\tconst lines = [\n\t\t`[RETRIEVED CONTEXT: ${node.sessionId}, ${date}]${stalenessWarning}`,\n\t\t`User: ${node.userContext}`,\n\t\t`Agent: ${node.agentContext}`,\n\t];\n\n\tif (entityLines.length > 0) {\n\t\tlines.push(...entityLines);\n\t}\n\n\tif (superseded) {\n\t\tlines.push(`[SUPERSEDES: ${superseded.sessionId}, ${new Date(superseded.timestamp).toISOString().split(\"T\")[0]}]`);\n\t\tlines.push(` User: ${superseded.userContext}`);\n\t}\n\n\treturn lines.join(\"\\n\");\n}\n\nfunction estimateTokens(text: string): number {\n\treturn Math.ceil(text.length / CHARS_PER_TOKEN_APPROX);\n}\n\n// ---------------------------------------------------------------------------\n// F-109: Query expansion via LLM before BM25F\n// ---------------------------------------------------------------------------\n\nconst QUERY_EXPANSION_SYSTEM_PROMPT =\n\t\"You are a search query assistant. Rewrite the given query as alternative phrasings. Respond with JSON only.\";\n\n/**\n * Expands a user query into up to 5 alternative phrasings using one LLM call.\n * Returns the original query deduped with expansions. Best-effort — falls back\n * to the original query on any failure.\n */\nasync function expandQuery(\n\ttopic: string,\n\tcomplete: LLMCompleteFn,\n): Promise<string[]> {\n\ttry {\n\t\tconst messages = [{\n\t\t\trole: \"user\" as const,\n\t\t\ttimestamp: Date.now(),\n\t\t\tcontent: `Rewrite this search query as 4 alternative phrasings a developer might use to find the same past conversation.\\n\\nQuery: \"${topic}\"\\n\\nRespond with JSON only: { \"terms\": [\"<alt1>\", \"<alt2>\", \"<alt3>\", \"<alt4>\"] }`,\n\t\t}];\n\t\tconst response = await complete(messages, { systemPrompt: QUERY_EXPANSION_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 { terms?: unknown };\n\t\tif (Array.isArray(parsed.terms)) {\n\t\t\tconst terms = parsed.terms.filter((t): t is string => typeof t === \"string\" && t.length > 0);\n\t\t\treturn [...new Set([topic, ...terms])].slice(0, 5);\n\t\t}\n\t} catch {\n\t\t// Non-fatal — original query used as sole term\n\t}\n\treturn [topic];\n}\n\n// ---------------------------------------------------------------------------\n// FIX-01: rankHubsByQuery — BM25F ranked retrieval replacing matchHubsByTopic\n// ---------------------------------------------------------------------------\n\n/**\n * Returns hubs ranked by BM25F score for the query (via NarrativeCache.rankByQuery).\n * Falls back to full-text substring scan when the BM25 index is not yet built\n * (cold start, stub hubs before first buildIndex call).\n */\nfunction rankHubsByQuery(\n\ttopic: string,\n\tnarrativeCache: NarrativeCache,\n\tcwd?: string,\n): NarrativeNode[] {\n\tconst ranked = narrativeCache.rankByQuery(topic, cwd);\n\tif (ranked.length > 0) return ranked;\n\n\t// Fallback: basic full-text substring scan for cold-start safety\n\tconst q = topic.toLowerCase();\n\treturn narrativeCache.all().filter(\n\t\t(h) =>\n\t\t\th.topics.some((t) => t.toLowerCase().includes(q) || q.includes(t.toLowerCase())) ||\n\t\t\th.title.toLowerCase().includes(q) ||\n\t\t\th.summary.toLowerCase().includes(q),\n\t);\n}\n\n// ---------------------------------------------------------------------------\n// F-93: Orphan context node scanner (FIX-01: uses tokenizeBM25)\n// ---------------------------------------------------------------------------\n\ninterface OrphanScanResult {\n\t/** Orphan nodes whose topics match the query, sorted newest-first, capped at 5. */\n\torphanMatches: ContextNode[];\n\t/** Total count of unassigned context nodes (for F-94 coverage signal). */\n\ttotalOrphans: number;\n}\n\n/**\n * Finds ContextNodes not assigned to any hub and filters by topic overlap.\n * FIX-01: uses same code-aware tokenizer as BM25F ranking for consistency.\n */\nasync function scanOrphanNodes(\n\ttopic: string,\n\tstorage: IMemoryStorage,\n\tnarrativeCache: NarrativeCache,\n): Promise<OrphanScanResult> {\n\tconst queryTokens = tokenizeBM25(topic);\n\tconst q = topic.toLowerCase();\n\tconst allSpokeIds = new Set(narrativeCache.all().flatMap((h) => h.spokeNodeIds));\n\tconst allNodes = await loadAllContextNodes(storage);\n\tconst orphanNodes = allNodes.filter((n) => !allSpokeIds.has(n.id));\n\n\tconst orphanMatches = orphanNodes\n\t\t.filter((n) => {\n\t\t\tif (queryTokens.length > 0) {\n\t\t\t\t// Token-based overlap match\n\t\t\t\tconst nodeTokens = new Set(tokenizeBM25(n.topics.join(\" \")));\n\t\t\t\treturn queryTokens.some((t) => nodeTokens.has(t));\n\t\t\t}\n\t\t\t// Fallback: substring match for very short / symbol queries\n\t\t\treturn n.topics.some((t) => t.toLowerCase().includes(q) || q.includes(t.toLowerCase()));\n\t\t})\n\t\t.sort((a, b) => b.timestamp - a.timestamp)\n\t\t.slice(0, 5);\n\n\treturn { orphanMatches, totalOrphans: orphanNodes.length };\n}\n\n// ---------------------------------------------------------------------------\n// F-65: Session start context retrieval\n// ---------------------------------------------------------------------------\n\n/**\n * Retrieve context nodes relevant to the current session (AC-65-01, AC-65-02, AC-65-03).\n * Called once at before_agent_start on the first agent turn.\n */\nexport async function buildSessionStartInjection(params: {\n\tstorage: IMemoryStorage;\n\tentityIndex: EntityIndex;\n\tnarrativeCache: NarrativeCache;\n\tcwd: string;\n\tsessionPrompt?: string;\n}): Promise<string | null> {\n\tconst { storage, entityIndex, narrativeCache, cwd, sessionPrompt } = params;\n\tconst now = Date.now();\n\n\tconst hubs = narrativeCache.all();\n\tif (hubs.length === 0) return null;\n\n\t// F-105: Use BM25F rankByQuery instead of naive substring match so semantically\n\t// relevant hubs surface even when the session prompt shares no exact topic keywords.\n\tconst querySignal = [sessionPrompt ?? \"\", cwd.split(\"/\").pop() ?? \"\"].filter(Boolean).join(\" \");\n\tlet matchingHubs = querySignal.trim()\n\t\t? rankHubsByQuery(querySignal, narrativeCache, cwd)\n\t\t: [];\n\n\t// Cold-start heuristic: fall back to most recently active hub when BM25F returns nothing\n\tif (matchingHubs.length === 0 && hubs.length > 0) {\n\t\tmatchingHubs = [hubs[0]]; // already sorted by latestSpokeAt ?? updatedAt\n\t}\n\n\tconst blocks: string[] = [];\n\tconst seenContextIds = new Set<string>();\n\n\tfor (const hub of matchingHubs) {\n\t\t// Get recent spokes (up to 3 per hub)\n\t\tfor (const spokeId of hub.spokeNodeIds.slice(-3).reverse()) {\n\t\t\tif (seenContextIds.has(spokeId)) continue;\n\t\t\tseenContextIds.add(spokeId);\n\n\t\t\t// AC-65-03: always use loadContextNodeWithSupersession\n\t\t\tconst [node, superseded] = await loadContextNodeWithSupersession(storage, spokeId);\n\t\t\tif (!node) continue;\n\n\t\t\t// Update lastReferencedAt on entities (AC-69-03 pattern)\n\t\t\tfor (const id of node.entityPointerIds) {\n\t\t\t\tconst entity = entityIndex.getById(id);\n\t\t\t\tif (entity) {\n\t\t\t\t\tentityIndex.update(id, { lastReferencedAt: now });\n\t\t\t\t\tawait storage.update<EntityNode>(\"entities\", id, { lastReferencedAt: now });\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tblocks.push(formatContextNodeBlock(node, superseded, entityIndex, now));\n\t\t}\n\t}\n\n\tif (blocks.length === 0) return null;\n\n\treturn `[MEMORY: Session Start Context]\\n\\n${blocks.join(\"\\n\\n\")}`;\n}\n\n// ---------------------------------------------------------------------------\n// F-66: Per-turn entity-driven injection\n// ---------------------------------------------------------------------------\n\n// Regex patterns for entity mention extraction (no LLM — AC-66-03)\nconst FILENAME_PATTERN = /(?:^|\\s)([\\w./-]+\\.\\w{1,6})(?:\\s|$)/gm;\nconst CAMEL_CASE_PATTERN = /\\b([A-Z][a-z]+(?:[A-Z][a-z]+)+)\\b/g;\nconst QUOTED_PATTERN = /[\"'`]([^\"'`\\n]{2,50})[\"'`]/g;\n\nfunction extractEntityMentions(text: string): string[] {\n\tconst mentions = new Set<string>();\n\n\tlet m: RegExpExecArray | null;\n\n\tconst fn = new RegExp(FILENAME_PATTERN.source, FILENAME_PATTERN.flags);\n\twhile ((m = fn.exec(text)) !== null) mentions.add(m[1]);\n\n\tconst cc = new RegExp(CAMEL_CASE_PATTERN.source, CAMEL_CASE_PATTERN.flags);\n\twhile ((m = cc.exec(text)) !== null) mentions.add(m[1]);\n\n\tconst qt = new RegExp(QUOTED_PATTERN.source, QUOTED_PATTERN.flags);\n\twhile ((m = qt.exec(text)) !== null) mentions.add(m[1]);\n\n\treturn [...mentions];\n}\n\nconst MAX_PER_TURN_TOKENS = 1_500;\n\n/**\n * Build per-turn entity-driven injection block (AC-66-01, AC-66-02, AC-66-03).\n */\nexport async function buildPerTurnInjection(params: {\n\tuserMessage: string;\n\tstorage: IMemoryStorage;\n\tentityIndex: EntityIndex;\n}): Promise<string | null> {\n\tconst { userMessage, storage, entityIndex } = params;\n\tconst now = Date.now();\n\n\tconst mentions = extractEntityMentions(userMessage);\n\tif (mentions.length === 0) return null;\n\n\t// Find all context nodes referencing the mentioned entities\n\tconst contextNodeIds = new Set<string>();\n\tconst healthMap = new Map<string, number>(); // contextNodeId -> meanValidity\n\n\tfor (const mention of mentions) {\n\t\t// Try all entity types for this mention name\n\t\tfor (const type of [\"file\", \"module\", \"concept\", \"decision\", \"person\", \"url\"] as EntityType[]) {\n\t\t\tconst entity = entityIndex.get(mention, type);\n\t\t\tif (!entity) continue;\n\n\t\t\t// Update lastReferencedAt\n\t\t\tentityIndex.update(entity.id, { lastReferencedAt: now });\n\t\t\tawait storage.update<EntityNode>(\"entities\", entity.id, { lastReferencedAt: now });\n\n\t\t\t// Find context nodes referencing this entity\n\t\t\tconst allNodes = await loadAllContextNodes(storage);\n\t\t\tfor (const node of allNodes) {\n\t\t\t\tif (node.entityPointerIds.includes(entity.id)) {\n\t\t\t\t\tcontextNodeIds.add(node.id);\n\t\t\t\t\tif (!healthMap.has(node.id)) {\n\t\t\t\t\t\tconst h = projectHealth(node, entityIndex, now);\n\t\t\t\t\t\thealthMap.set(node.id, h.meanValidity);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif (contextNodeIds.size === 0) return null;\n\n\t// Sort by meanValidity descending\n\tconst sortedIds = [...contextNodeIds].sort((a, b) => (healthMap.get(b) ?? 0) - (healthMap.get(a) ?? 0));\n\n\tconst blocks: string[] = [];\n\tlet totalTokens = 0;\n\tlet truncated = false;\n\n\tfor (const id of sortedIds) {\n\t\tconst [node, superseded] = await loadContextNodeWithSupersession(storage, id);\n\t\tif (!node) continue;\n\n\t\tconst block = formatContextNodeBlock(node, superseded, entityIndex, now);\n\t\tconst blockTokens = estimateTokens(block);\n\n\t\tif (totalTokens + blockTokens > MAX_PER_TURN_TOKENS) {\n\t\t\ttruncated = true;\n\t\t\tbreak;\n\t\t}\n\n\t\tblocks.push(block);\n\t\ttotalTokens += blockTokens;\n\t}\n\n\tif (blocks.length === 0) return null;\n\n\tlet result = `[MEMORY: Entity Context]\\n\\n${blocks.join(\"\\n\\n\")}`;\n\tif (truncated) {\n\t\tresult += \"\\n\\n[MEMORY: Additional context truncated — token budget reached]\";\n\t\tconsole.warn(\"[memory] Per-turn injection truncated to fit token budget\");\n\t}\n\n\treturn result;\n}\n\n// ---------------------------------------------------------------------------\n// F-69: Active query tools\n// ---------------------------------------------------------------------------\n\nconst MAX_QUERY_TOKENS = 2_000;\n\n/**\n * memory_entity_lookup: O(1) entity lookup + context node retrieval (AC-69-01, AC-69-03).\n * Falls back to searching all entity types when the specified type yields no match.\n */\nexport async function memoryEntityLookup(\n\tname: string,\n\ttype: EntityType,\n\tstorage: IMemoryStorage,\n\tentityIndex: EntityIndex,\n): Promise<string> {\n\tconst now = Date.now();\n\tlet entity = entityIndex.get(name, type);\n\n\t// Type mismatch fallback: the agent may call with a different type than what was\n\t// extracted (e.g. \"python\" as \"concept\" when it was indexed as \"module\").\n\tif (!entity) {\n\t\tconst allTypes: EntityType[] = [\"file\", \"module\", \"concept\", \"decision\", \"person\", \"url\"];\n\t\tfor (const t of allTypes) {\n\t\t\tif (t === type) continue;\n\t\t\tconst candidate = entityIndex.get(name, t);\n\t\t\tif (candidate) {\n\t\t\t\tentity = candidate;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t}\n\n\tif (!entity) {\n\t\treturn `[MEMORY: No record of entity \"${name}\" (${type}). May predate memory system or not yet indexed.]`;\n\t}\n\n\t// AC-69-03: Update lastReferencedAt\n\tentityIndex.update(entity.id, { lastReferencedAt: now });\n\tawait storage.update<EntityNode>(\"entities\", entity.id, { lastReferencedAt: now });\n\n\tconst score = validityScore(entity.eventMatrix, entity.type, now);\n\tconst lines = [\n\t\t`[MEMORY: Entity — ${entity.canonicalName} | ${entity.type}]`,\n\t\t`Validity: ${score.toFixed(2)} (${validityLabel(score)})`,\n\t\t\"\",\n\t];\n\n\t// Retrieve all context nodes referencing this entity, newest first\n\tconst allNodes = (await loadAllContextNodes(storage))\n\t\t.filter((n) => n.entityPointerIds.includes(entity.id))\n\t\t.sort((a, b) => b.timestamp - a.timestamp);\n\n\tconst blocks: string[] = [];\n\tlet totalTokens = estimateTokens(lines.join(\"\\n\"));\n\n\tfor (const node of allNodes) {\n\t\t// AC-69-03: Update lastReferencedAt on context node entities\n\t\tfor (const id of node.entityPointerIds) {\n\t\t\tconst e = entityIndex.getById(id);\n\t\t\tif (e) {\n\t\t\t\tentityIndex.update(id, { lastReferencedAt: now });\n\t\t\t\tawait storage.update<EntityNode>(\"entities\", id, { lastReferencedAt: now });\n\t\t\t}\n\t\t}\n\n\t\tconst [loaded, superseded] = await loadContextNodeWithSupersession(storage, node.id);\n\t\tif (!loaded) continue;\n\n\t\tconst block = formatContextNodeBlock(loaded, superseded, entityIndex, now);\n\t\tconst blockTokens = estimateTokens(block);\n\t\tif (totalTokens + blockTokens > MAX_QUERY_TOKENS) break;\n\n\t\tblocks.push(block);\n\t\ttotalTokens += blockTokens;\n\t}\n\n\tif (blocks.length > 0) {\n\t\tlines.push(...blocks);\n\t} else {\n\t\tlines.push(\"No context nodes referencing this entity.\");\n\t}\n\n\treturn lines.join(\"\\n\");\n}\n\n/**\n * memory_query: BM25F-ranked hub matching + spoke context retrieval (AC-69-02, AC-69-03).\n * FIX-01: rankHubsByQuery replaces binary matchHubsByTopic.\n * F-93: orphan scan as safety-net lane.\n * F-94: coverage signal in header.\n * F-109: optional LLM query expansion before BM25F.\n */\nexport async function memoryQuery(\n\ttopic: string,\n\tstorage: IMemoryStorage,\n\tentityIndex: EntityIndex,\n\tnarrativeCache: NarrativeCache,\n\tcwd?: string,\n\tcomplete?: LLMCompleteFn,\n): Promise<string> {\n\tconst now = Date.now();\n\n\t// F-109: Expand query into alternative phrasings before BM25F scoring\n\tconst queryTerms = complete ? await expandQuery(topic, complete) : [topic];\n\n\t// FIX-01: BM25F ranked hub matching — union results across all expanded terms, max score wins\n\tconst allHubs = narrativeCache.all();\n\tconst hubScoreMap = new Map<string, NarrativeNode>();\n\tfor (const term of queryTerms) {\n\t\tfor (const hub of rankHubsByQuery(term, narrativeCache, cwd)) {\n\t\t\tif (!hubScoreMap.has(hub.id)) hubScoreMap.set(hub.id, hub);\n\t\t}\n\t}\n\tconst matchingHubs = [...hubScoreMap.values()];\n\tconst hubContextIds = [...new Set(matchingHubs.flatMap((h) => h.spokeNodeIds))];\n\n\t// F-93: Scan orphan context nodes as a safety-net lane (use original topic for orphan scan)\n\tconst { orphanMatches, totalOrphans } = await scanOrphanNodes(topic, storage, narrativeCache);\n\n\t// F-94: Coverage confidence signal\n\tconst header = `[MEMORY: Query — \"${topic}\" | searched: ${allHubs.length} hubs, ${totalOrphans} orphan nodes | hub matches: ${matchingHubs.length}, orphan matches: ${orphanMatches.length}]`;\n\tconst blocks: string[] = [header, \"\"];\n\tlet totalTokens = estimateTokens(header);\n\tlet truncated = false;\n\n\t// Hub-matched spoke nodes\n\tfor (const id of hubContextIds) {\n\t\tconst [node, superseded] = await loadContextNodeWithSupersession(storage, id);\n\t\tif (!node) continue;\n\n\t\t// AC-69-03: Update entity lastReferencedAt\n\t\tfor (const entityId of node.entityPointerIds) {\n\t\t\tconst entity = entityIndex.getById(entityId);\n\t\t\tif (entity) {\n\t\t\t\tentityIndex.update(entityId, { lastReferencedAt: now });\n\t\t\t\tawait storage.update<EntityNode>(\"entities\", entityId, { lastReferencedAt: now });\n\t\t\t}\n\t\t}\n\n\t\tconst block = formatContextNodeBlock(node, superseded, entityIndex, now);\n\t\tconst blockTokens = estimateTokens(block);\n\t\tif (totalTokens + blockTokens > MAX_QUERY_TOKENS) {\n\t\t\ttruncated = true;\n\t\t\tbreak;\n\t\t}\n\t\tblocks.push(block);\n\t\ttotalTokens += blockTokens;\n\t}\n\n\t// F-93: Orphan matches in a separate labeled section\n\tif (orphanMatches.length > 0 && !truncated) {\n\t\tblocks.push(\"[Orphan Matches]\");\n\t\tfor (const node of orphanMatches) {\n\t\t\tconst [loaded, superseded] = await loadContextNodeWithSupersession(storage, node.id);\n\t\t\tif (!loaded) continue;\n\n\t\t\tfor (const entityId of loaded.entityPointerIds) {\n\t\t\t\tconst entity = entityIndex.getById(entityId);\n\t\t\t\tif (entity) {\n\t\t\t\t\tentityIndex.update(entityId, { lastReferencedAt: now });\n\t\t\t\t\tawait storage.update<EntityNode>(\"entities\", entityId, { lastReferencedAt: now });\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst block = formatContextNodeBlock(loaded, superseded, entityIndex, now);\n\t\t\tconst blockTokens = estimateTokens(block);\n\t\t\tif (totalTokens + blockTokens > MAX_QUERY_TOKENS) {\n\t\t\t\ttruncated = true;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tblocks.push(block);\n\t\t\ttotalTokens += blockTokens;\n\t\t}\n\t}\n\n\tif (truncated) {\n\t\tblocks.push(\"\\n[MEMORY: Additional results truncated — token budget reached]\");\n\t}\n\n\tif (blocks.length <= 2) {\n\t\tblocks.push(\"No matching context found.\");\n\t}\n\n\treturn blocks.join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// F-69: memory_recall — unified topic → context node → session turns in one hop\n// ---------------------------------------------------------------------------\n\nconst MAX_RECALL_TOKENS = 4_000;\n\n/** Minimal shape needed for session turn rendering. FIX-03: timestamp field included. */\ninterface RawSessionEntry {\n\ttype: string;\n\trole?: string;\n\tcontent?: string | Array<{ type: string; text?: string }>;\n\t/** FIX-03: Unix ms timestamp for turn-level temporal grounding. */\n\ttimestamp?: number;\n}\n\nfunction extractTurnText(content: RawSessionEntry[\"content\"]): string {\n\tif (typeof content === \"string\") return content.trim();\n\tif (Array.isArray(content)) {\n\t\treturn content\n\t\t\t.filter((b) => b.type === \"text\" && typeof b.text === \"string\")\n\t\t\t.map((b) => b.text!)\n\t\t\t.join(\" \")\n\t\t\t.trim();\n\t}\n\treturn \"\";\n}\n\n/**\n * Read session turns from a JSONL file.\n * FIX-03: Each turn prefixed with [YYYY-MM-DD HH:mm] when the entry has a timestamp.\n */\nfunction readSessionTurns(sessionFile: string, maxTurns: number): string[] {\n\tconst records = readJsonlFile<RawSessionEntry>(sessionFile);\n\tconst turns: string[] = [];\n\n\tfor (const entry of records) {\n\t\tif (entry.type !== \"message\") continue;\n\t\tif (!entry.role || (entry.role !== \"user\" && entry.role !== \"assistant\")) continue;\n\t\tconst text = extractTurnText(entry.content);\n\t\tif (!text) continue;\n\t\tconst label = entry.role === \"user\" ? \"User\" : \"Agent\";\n\t\tconst ts = entry.timestamp\n\t\t\t? `[${new Date(entry.timestamp).toISOString().slice(0, 16).replace(\"T\", \" \")}] `\n\t\t\t: \"\";\n\t\tturns.push(`${ts}${label}: ${text}`);\n\t\tif (turns.length >= maxTurns) break;\n\t}\n\n\treturn turns;\n}\n\n/**\n * memory_recall: unified topic → context → Layer 1 session turns (one tool call).\n * FIX-01: BM25F ranked hub matching replaces binary matchHubsByTopic.\n * FIX-03: Optional fromDate/toDate date-range filtering on spoke ContextNodes.\n *\n * 1. Ranks narrative hubs by BM25F score for topic.\n * 2. Loads context nodes from matching hubs (optionally filtered by date range).\n * 3. Resolves and reads session JSONL for the best matching context node.\n * Returns context summary + session turns with [YYYY-MM-DD HH:mm] timestamps.\n */\nexport async function memoryRecall(\n\ttopic: string,\n\tstorage: IMemoryStorage,\n\tentityIndex: EntityIndex,\n\tnarrativeCache: NarrativeCache,\n\tmaxTurns = 30,\n\tcwd?: string,\n\tfromDate?: Date,\n\ttoDate?: Date,\n\tcomplete?: LLMCompleteFn,\n): Promise<string> {\n\tconst now = Date.now();\n\n\t// Step 1: BM25F hub ranking with optional F-109 query expansion\n\tconst queryTerms = complete ? await expandQuery(topic, complete) : [topic];\n\tconst allHubs = narrativeCache.all();\n\tconst hubScoreMap = new Map<string, NarrativeNode>();\n\tfor (const term of queryTerms) {\n\t\tfor (const hub of rankHubsByQuery(term, narrativeCache, cwd)) {\n\t\t\tif (!hubScoreMap.has(hub.id)) hubScoreMap.set(hub.id, hub);\n\t\t}\n\t}\n\tconst matchingHubs = [...hubScoreMap.values()];\n\tlet hubContextIds = [...new Set(matchingHubs.flatMap((h) => h.spokeNodeIds))];\n\n\t// F-93: Scan orphan nodes as safety-net lane (use original topic)\n\tconst { orphanMatches, totalOrphans } = await scanOrphanNodes(topic, storage, narrativeCache);\n\n\t// F-94: Coverage confidence signal\n\tconst header = `[MEMORY: Recall — \"${topic}\" | searched: ${allHubs.length} hubs, ${totalOrphans} orphan nodes | hub matches: ${matchingHubs.length}, orphan matches: ${orphanMatches.length}]`;\n\n\t// FIX-03: Date range filter on spoke ContextNodes\n\tif (fromDate || toDate) {\n\t\tconst allNodes = await loadAllContextNodes(storage);\n\t\tconst nodeMap = new Map(allNodes.map((n) => [n.id, n]));\n\t\thubContextIds = hubContextIds.filter((id) => {\n\t\t\tconst node = nodeMap.get(id);\n\t\t\tif (!node) return false;\n\t\t\tif (fromDate && node.timestamp < fromDate.getTime()) return false;\n\t\t\tif (toDate && node.timestamp > toDate.getTime()) return false;\n\t\t\treturn true;\n\t\t});\n\t\t// Also filter orphan matches by date range\n\t\tconst filteredOrphans = orphanMatches.filter((n) => {\n\t\t\tif (fromDate && n.timestamp < fromDate.getTime()) return false;\n\t\t\tif (toDate && n.timestamp > toDate.getTime()) return false;\n\t\t\treturn true;\n\t\t});\n\t\tif (hubContextIds.length === 0 && filteredOrphans.length === 0) {\n\t\t\treturn `${header}\\n\\nNo matching context found in the specified date range.`;\n\t\t}\n\t}\n\n\tif (hubContextIds.length === 0 && orphanMatches.length === 0) {\n\t\treturn `${header}\\n\\nNo matching context found.`;\n\t}\n\n\t// Step 2: Load context nodes from hub spokes, then orphan matches\n\tconst contextBlocks: string[] = [];\n\tlet resolvedSessionFile: string | null = null;\n\tlet resolvedSessionId: string | null = null;\n\tlet totalTokens = estimateTokens(header);\n\n\tasync function loadAndAppend(id: string): Promise<boolean> {\n\t\tconst [node, superseded] = await loadContextNodeWithSupersession(storage, id);\n\t\tif (!node) return false;\n\n\t\tfor (const entityId of node.entityPointerIds) {\n\t\t\tconst entity = entityIndex.getById(entityId);\n\t\t\tif (entity) {\n\t\t\t\tentityIndex.update(entityId, { lastReferencedAt: now });\n\t\t\t\tawait storage.update<EntityNode>(\"entities\", entityId, { lastReferencedAt: now });\n\t\t\t}\n\t\t}\n\n\t\tconst block = formatContextNodeBlock(node, superseded, entityIndex, now);\n\t\tconst blockTokens = estimateTokens(block);\n\t\tif (totalTokens + blockTokens > MAX_RECALL_TOKENS) return false;\n\n\t\tcontextBlocks.push(block);\n\t\ttotalTokens += blockTokens;\n\n\t\t// Capture the first (best) node's session file for Layer 1 retrieval\n\t\tif (resolvedSessionFile === null) {\n\t\t\tresolvedSessionId = node.sessionId;\n\t\t\tif (node.sessionFile) {\n\t\t\t\tresolvedSessionFile = node.sessionFile;\n\t\t\t} else {\n\t\t\t\tconst treeIndex = await loadSessionTree(storage);\n\t\t\t\tresolvedSessionFile = resolveSessionFile(node.sessionId, treeIndex);\n\t\t\t}\n\t\t}\n\n\t\treturn true;\n\t}\n\n\t// Hub-matched spokes first\n\tfor (const id of hubContextIds) {\n\t\tconst ok = await loadAndAppend(id);\n\t\tif (!ok) break;\n\t}\n\n\t// F-93: Orphan matches — append under separate label if budget allows\n\tif (orphanMatches.length > 0 && totalTokens < MAX_RECALL_TOKENS) {\n\t\tcontextBlocks.push(\"[Orphan Matches]\");\n\t\tfor (const node of orphanMatches) {\n\t\t\tconst ok = await loadAndAppend(node.id);\n\t\t\tif (!ok) break;\n\t\t}\n\t}\n\n\tif (contextBlocks.length === 0 || (contextBlocks.length === 1 && contextBlocks[0] === \"[Orphan Matches]\")) {\n\t\treturn `${header}\\n\\nNo matching context found.`;\n\t}\n\n\t// Step 3: Read session turns from Layer 1 (FIX-03: turns include timestamps)\n\tconst lines: string[] = [header, \"\", ...contextBlocks];\n\n\tif (resolvedSessionFile) {\n\t\tconst turns = readSessionTurns(resolvedSessionFile, maxTurns);\n\t\tif (turns.length > 0) {\n\t\t\tconst date = (() => {\n\t\t\t\tconst m = contextBlocks[0]?.match(/\\[RETRIEVED CONTEXT: [^,]+, (\\d{4}-\\d{2}-\\d{2})\\]/);\n\t\t\t\treturn m?.[1] ?? \"\";\n\t\t\t})();\n\t\t\tconst turnHeader = date\n\t\t\t\t? `[SESSION TURNS: ${resolvedSessionId}, ${date}]`\n\t\t\t\t: `[SESSION TURNS: ${resolvedSessionId ?? \"unknown\"}]`;\n\t\t\tlines.push(\"\", turnHeader, ...turns);\n\t\t} else {\n\t\t\tlines.push(\"\", `[SESSION TURNS: No message entries found in session file]`);\n\t\t}\n\t} else {\n\t\tlines.push(\n\t\t\t\"\",\n\t\t\t`[SESSION TURNS: Not available — sessionFile absent from context node and not found in session tree index]`,\n\t\t);\n\t}\n\n\treturn lines.join(\"\\n\");\n}\n"]}