@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,131 @@
1
+ /**
2
+ * Session Tree Index — F-74 (persistent Layer 0 registry) and F-76 (traversal API).
3
+ *
4
+ * Records session tree topology across process invocations so the memory system
5
+ * can resolve ContextNode sessionIds to JSONL file paths without the live
6
+ * SessionManager being present.
7
+ *
8
+ * Adapted for @chili/memory: uses IMemoryStorage instead of direct file paths.
9
+ */
10
+ // ---------------------------------------------------------------------------
11
+ // F-74: Write — append one entry at session_start
12
+ // ---------------------------------------------------------------------------
13
+ /**
14
+ * Record the current session in the persistent tree index.
15
+ * Called once per session_start. Append-only — entries are never modified.
16
+ */
17
+ export async function recordSessionStart(params) {
18
+ const { storage, sessionId, sessionFile, parentSessionFile, cwd } = params;
19
+ const entry = {
20
+ id: globalThis.crypto.randomUUID(),
21
+ sessionId,
22
+ sessionFile,
23
+ parentSessionFile,
24
+ cwd,
25
+ timestamp: Date.now(),
26
+ };
27
+ await storage.append("session-tree", entry);
28
+ }
29
+ // ---------------------------------------------------------------------------
30
+ // F-76: Read — traversal API
31
+ // ---------------------------------------------------------------------------
32
+ /**
33
+ * Load all entries from the session tree index.
34
+ * Returns an empty array if the collection is empty.
35
+ */
36
+ export async function loadSessionTree(storage) {
37
+ return storage.readAll("session-tree");
38
+ }
39
+ /**
40
+ * Resolve a session ID to its JSONL file path using the persistent index.
41
+ * O(N) linear scan — acceptable at expected session counts (<1,000).
42
+ * Returns null if the session ID is not recorded in the index.
43
+ */
44
+ export function resolveSessionFile(sessionId, index) {
45
+ // Use the most recent entry for this sessionId (last wins, handles re-recordings)
46
+ for (let i = index.length - 1; i >= 0; i--) {
47
+ if (index[i].sessionId === sessionId)
48
+ return index[i].sessionFile;
49
+ }
50
+ return null;
51
+ }
52
+ /**
53
+ * Walk parentSessionFile references back to the root.
54
+ * Returns the full ancestor chain for the given session file path,
55
+ * oldest entry first (root → ... → target).
56
+ * The entry for the target session itself is included as the last element.
57
+ */
58
+ export function getAncestorChain(sessionFile, index) {
59
+ // Build a map from sessionFile → entry for O(1) parent lookups
60
+ const byFile = new Map();
61
+ for (const entry of index) {
62
+ byFile.set(entry.sessionFile, entry);
63
+ }
64
+ const chain = [];
65
+ let current = byFile.get(sessionFile);
66
+ // Walk up the parent chain
67
+ while (current) {
68
+ chain.unshift(current); // prepend so result is oldest-first
69
+ if (!current.parentSessionFile)
70
+ break;
71
+ current = byFile.get(current.parentSessionFile);
72
+ }
73
+ return chain;
74
+ }
75
+ /**
76
+ * Build a depth-annotated render list for all sessions in the index,
77
+ * sorted by timestamp within each depth level.
78
+ */
79
+ export function buildTreeRenderRows(index, currentSessionId, contextNodeSessionIds) {
80
+ if (index.length === 0)
81
+ return [];
82
+ // Deduplicate: keep only the most recent entry per sessionId
83
+ const latestBySessionId = new Map();
84
+ for (const entry of index) {
85
+ latestBySessionId.set(entry.sessionId, entry);
86
+ }
87
+ const entries = [...latestBySessionId.values()].sort((a, b) => a.timestamp - b.timestamp);
88
+ // Build parent lookup by sessionFile
89
+ const byFile = new Map();
90
+ for (const e of entries)
91
+ byFile.set(e.sessionFile, e);
92
+ // Compute depth for each entry
93
+ function depth(entry) {
94
+ let d = 0;
95
+ let cur = entry;
96
+ while (cur.parentSessionFile) {
97
+ const parent = byFile.get(cur.parentSessionFile);
98
+ if (!parent)
99
+ break;
100
+ d++;
101
+ cur = parent;
102
+ }
103
+ return d;
104
+ }
105
+ return entries.map((entry) => ({
106
+ entry,
107
+ depth: depth(entry),
108
+ hasContextNode: contextNodeSessionIds.has(entry.sessionId),
109
+ isCurrent: entry.sessionId === currentSessionId,
110
+ }));
111
+ }
112
+ /**
113
+ * Render tree rows as a compact ASCII string for display.
114
+ */
115
+ export function renderSessionTree(rows) {
116
+ if (rows.length === 0)
117
+ return "[memory tree] No sessions recorded yet.";
118
+ const lines = ["[memory tree] Cross-session tree (oldest → newest):", ""];
119
+ for (const row of rows) {
120
+ const indent = " ".repeat(row.depth);
121
+ const date = new Date(row.entry.timestamp).toISOString().split("T")[0];
122
+ const cwd = row.entry.cwd.replace(/^.*\/([^/]+)$/, "$1"); // basename only
123
+ const indexMark = row.hasContextNode ? "" : " [no index]";
124
+ const currentMark = row.isCurrent ? " →" : "";
125
+ lines.push(`${indent}${row.entry.sessionId.slice(0, 8)} | ${date} | ${cwd}${indexMark}${currentMark}`);
126
+ }
127
+ lines.push("");
128
+ lines.push(`Total: ${rows.length} session(s)`);
129
+ return lines.join("\n");
130
+ }
131
+ //# sourceMappingURL=session-tree-index.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @chili/memory — Persistent agent memory system.
3
+ *
4
+ * Storage-adapter-agnostic and LLM-provider-agnostic. Consumers provide:
5
+ * - An IMemoryStorage implementation (JsonlMemoryStorage, InMemoryStorage, or custom)
6
+ * - An LLMCompleteFn callback (any provider that accepts messages and returns text)
7
+ *
8
+ * Public surface grows as features F-111–F-115 land.
9
+ */
10
+ export type { MemoryBranchEntry, LLMMessage, LLMResponse, LLMCompleteFn, } from "./types.js";
11
+ export type { IMemoryStorage, MemoryCollection, MemoryArchiveCollection, } from "./storage/adapter.js";
12
+ export { JsonlMemoryStorage } from "./storage/jsonl.js";
13
+ export { InMemoryStorage } from "./storage/in-memory.js";
14
+ export { MemoryService } from "./service.js";
15
+ export type { MemoryServiceConfig, BackfillResult, RecallOptions, MemoryStats } from "./service.js";
16
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,YAAY,EACX,iBAAiB,EACjB,UAAU,EACV,WAAW,EACX,aAAa,GACb,MAAM,YAAY,CAAC;AAGpB,YAAY,EACX,cAAc,EACd,gBAAgB,EAChB,uBAAuB,GACvB,MAAM,sBAAsB,CAAC;AAG9B,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AACxD,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,YAAY,EAAE,mBAAmB,EAAE,cAAc,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC","sourcesContent":["/**\n * @chili/memory — Persistent agent memory system.\n *\n * Storage-adapter-agnostic and LLM-provider-agnostic. Consumers provide:\n * - An IMemoryStorage implementation (JsonlMemoryStorage, InMemoryStorage, or custom)\n * - An LLMCompleteFn callback (any provider that accepts messages and returns text)\n *\n * Public surface grows as features F-111–F-115 land.\n */\n\n// Core boundary types\nexport type {\n\tMemoryBranchEntry,\n\tLLMMessage,\n\tLLMResponse,\n\tLLMCompleteFn,\n} from \"./types.js\";\n\n// Storage adapter interface and collection names\nexport type {\n\tIMemoryStorage,\n\tMemoryCollection,\n\tMemoryArchiveCollection,\n} from \"./storage/adapter.js\";\n\n// Storage implementations\nexport { JsonlMemoryStorage } from \"./storage/jsonl.js\";\nexport { InMemoryStorage } from \"./storage/in-memory.js\";\n\n// MemoryService — the public API class\nexport { MemoryService } from \"./service.js\";\nexport type { MemoryServiceConfig, BackfillResult, RecallOptions, MemoryStats } from \"./service.js\";\n"]}
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @chili/memory — Persistent agent memory system.
3
+ *
4
+ * Storage-adapter-agnostic and LLM-provider-agnostic. Consumers provide:
5
+ * - An IMemoryStorage implementation (JsonlMemoryStorage, InMemoryStorage, or custom)
6
+ * - An LLMCompleteFn callback (any provider that accepts messages and returns text)
7
+ *
8
+ * Public surface grows as features F-111–F-115 land.
9
+ */
10
+ // Storage implementations
11
+ export { JsonlMemoryStorage } from "./storage/jsonl.js";
12
+ export { InMemoryStorage } from "./storage/in-memory.js";
13
+ // MemoryService — the public API class
14
+ export { MemoryService } from "./service.js";
15
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,105 @@
1
+ /**
2
+ * MemoryService — the public API class for @chili/memory.
3
+ *
4
+ * Wraps the session-scoped state previously held as a module-level singleton
5
+ * in the chili memory extension. Consumers (chili adapter, web app) instantiate
6
+ * one MemoryService per session and call lifecycle methods from their event handlers.
7
+ *
8
+ * Adapted for @chili/memory: no chili-internal imports. Uses IMemoryStorage,
9
+ * LLMCompleteFn, and MemoryBranchEntry from the package's own types.
10
+ */
11
+ import type { LLMCompleteFn, MemoryBranchEntry } from "./types.js";
12
+ import type { IMemoryStorage } from "./storage/adapter.js";
13
+ import type { EntityType } from "./core/entity.js";
14
+ export interface MemoryServiceConfig {
15
+ storage: IMemoryStorage;
16
+ complete: LLMCompleteFn;
17
+ cwd: string;
18
+ sessionId: string;
19
+ sessionFile?: string;
20
+ parentSessionFile?: string | null;
21
+ }
22
+ export interface BackfillResult {
23
+ processed: number;
24
+ skipped: number;
25
+ errors: number;
26
+ total: number;
27
+ }
28
+ export interface RecallOptions {
29
+ max_turns?: number;
30
+ from_date?: string;
31
+ to_date?: string;
32
+ }
33
+ export interface MemoryStats {
34
+ hubs: number;
35
+ entities: number;
36
+ contextNodes: number;
37
+ }
38
+ export declare class MemoryService {
39
+ private readonly storage;
40
+ private readonly complete;
41
+ private readonly cwd;
42
+ private readonly sessionId;
43
+ private readonly sessionFile;
44
+ private readonly parentSessionFile;
45
+ private entityIndex;
46
+ private narrativeCache;
47
+ private sessionStartInjectionDone;
48
+ private memoryEnabled;
49
+ private backfillDone;
50
+ private turnCount;
51
+ private initialized;
52
+ constructor(config: MemoryServiceConfig);
53
+ /**
54
+ * Initialise storage, build indices, and return the narrative system prompt block.
55
+ * Call once at session_start before any other method.
56
+ * Returns { systemPromptAppend } for inclusion in the session system prompt.
57
+ */
58
+ init(): Promise<{
59
+ systemPromptAppend: string;
60
+ }>;
61
+ /**
62
+ * Called before each agent turn (before_agent_start).
63
+ * Returns a system prompt injection string, or null if nothing to inject.
64
+ */
65
+ onBeforeTurn(prompt?: string): Promise<string | null>;
66
+ /**
67
+ * Called after each agent turn (agent_end).
68
+ * Runs retroactive backfill on first turn, fires-and-forgets.
69
+ */
70
+ onAgentEnd(): Promise<void>;
71
+ /**
72
+ * Called when a compaction event fires (session_before_compact).
73
+ * Writes a ContextNode and updates the narrative layer.
74
+ */
75
+ onCompaction(entries: MemoryBranchEntry[], sessionId: string, sessionFile: string, cwd: string): Promise<void>;
76
+ /**
77
+ * Called at session shutdown (session_shutdown).
78
+ * Performs session-close flush, dead hub archival, and orphan detection.
79
+ */
80
+ onShutdown(): Promise<void>;
81
+ /**
82
+ * Run retroactive backfill indexing for all unindexed sessions.
83
+ */
84
+ runBackfill(): Promise<BackfillResult>;
85
+ query(topic: string): Promise<string>;
86
+ entityLookup(name: string, type: EntityType): Promise<string>;
87
+ recall(topic: string, options?: RecallOptions): Promise<string>;
88
+ enable(): void;
89
+ disable(): void;
90
+ get isEnabled(): boolean;
91
+ get stats(): MemoryStats;
92
+ renderTree(currentSessionId: string): Promise<string>;
93
+ /**
94
+ * Count of unindexed sessions. Returned from init() as a hint to the caller
95
+ * (the chili adapter can then notify the user).
96
+ */
97
+ countUnindexed(): Promise<number>;
98
+ /**
99
+ * Write SKILL.md files to the extension storage dir and return their paths.
100
+ * Called once per session from the resources_discover event handler.
101
+ */
102
+ getSkillPaths(storageDir: string): string[];
103
+ private runOrphanDetection;
104
+ }
105
+ //# sourceMappingURL=service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AACnE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AA+BnD,MAAM,WAAW,mBAAmB;IACnC,OAAO,EAAE,cAAc,CAAC;IACxB,QAAQ,EAAE,aAAa,CAAC;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iBAAiB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAED,MAAM,WAAW,cAAc;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,aAAa;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,WAAW;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;CACrB;AAcD,qBAAa,aAAa;IACzB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAiB;IACzC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAgB;IACzC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAC7B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAgB;IAGlD,OAAO,CAAC,WAAW,CAAkC;IACrD,OAAO,CAAC,cAAc,CAAwC;IAC9D,OAAO,CAAC,yBAAyB,CAAS;IAC1C,OAAO,CAAC,aAAa,CAAQ;IAC7B,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,SAAS,CAAK;IACtB,OAAO,CAAC,WAAW,CAAS;IAE5B,YAAY,MAAM,EAAE,mBAAmB,EAOtC;IAMD;;;;OAIG;IACG,IAAI,IAAI,OAAO,CAAC;QAAE,kBAAkB,EAAE,MAAM,CAAA;KAAE,CAAC,CAkDpD;IAED;;;OAGG;IACG,YAAY,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAqC1D;IAED;;;OAGG;IACG,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAkBhC;IAED;;;OAGG;IACG,YAAY,CACjB,OAAO,EAAE,iBAAiB,EAAE,EAC5B,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,EACnB,GAAG,EAAE,MAAM,GACT,OAAO,CAAC,IAAI,CAAC,CAqBf;IAED;;;OAGG;IACG,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CA2ChC;IAED;;OAEG;IACG,WAAW,IAAI,OAAO,CAAC,cAAc,CAAC,CAS3C;IAMK,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAU1C;IAEK,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAGlE;IAEK,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,GAAE,aAAkB,GAAG,OAAO,CAAC,MAAM,CAAC,CAexE;IAMD,MAAM,IAAI,IAAI,CAEb;IAED,OAAO,IAAI,IAAI,CAEd;IAED,IAAI,SAAS,IAAI,OAAO,CAEvB;IAED,IAAI,KAAK,IAAI,WAAW,CAMvB;IAMK,UAAU,CAAC,gBAAgB,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAM1D;IAED;;;OAGG;IACG,cAAc,IAAI,OAAO,CAAC,MAAM,CAAC,CAOtC;IAMD;;;OAGG;IACH,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE,CAe1C;YAMa,kBAAkB;CAwBhC","sourcesContent":["/**\n * MemoryService — the public API class for @chili/memory.\n *\n * Wraps the session-scoped state previously held as a module-level singleton\n * in the chili memory extension. Consumers (chili adapter, web app) instantiate\n * one MemoryService per session and call lifecycle methods from their event handlers.\n *\n * Adapted for @chili/memory: no chili-internal imports. Uses IMemoryStorage,\n * LLMCompleteFn, and MemoryBranchEntry from the package's own types.\n */\n\nimport { mkdirSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport type { LLMCompleteFn, MemoryBranchEntry } from \"./types.js\";\nimport type { IMemoryStorage } from \"./storage/adapter.js\";\nimport type { EntityType } from \"./core/entity.js\";\nimport { loadAllEntities, updateEntity } from \"./core/entity.js\";\nimport { EntityIndex } from \"./core/entity-index.js\";\nimport { writeContextNodeFromCompaction, loadAllContextNodes, updateContextNode } from \"./core/context-graph.js\";\nimport {\n\tNarrativeCache,\n\tarchiveDeadHubs,\n\tbuildNarrativeSystemPromptBlock,\n\tloadAllNarrativeNodes,\n\trunNarrativeTick,\n\tupdateNarrativeLayer,\n} from \"./core/narrative.js\";\nimport {\n\tbuildSessionStartInjection,\n\tbuildPerTurnInjection,\n\tmemoryEntityLookup,\n\tmemoryQuery,\n\tmemoryRecall,\n} from \"./core/read-path.js\";\nimport {\n\trecordSessionStart,\n\tloadSessionTree,\n\tbuildTreeRenderRows,\n\trenderSessionTree,\n} from \"./core/session-tree-index.js\";\nimport { findUnindexedSessions, readSessionForBackfill, runBackfillIndexing } from \"./core/backfill.js\";\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\nexport interface MemoryServiceConfig {\n\tstorage: IMemoryStorage;\n\tcomplete: LLMCompleteFn;\n\tcwd: string;\n\tsessionId: string;\n\tsessionFile?: string;\n\tparentSessionFile?: string | null;\n}\n\nexport interface BackfillResult {\n\tprocessed: number;\n\tskipped: number;\n\terrors: number;\n\ttotal: number;\n}\n\nexport interface RecallOptions {\n\tmax_turns?: number;\n\tfrom_date?: string;\n\tto_date?: string;\n}\n\nexport interface MemoryStats {\n\thubs: number;\n\tentities: number;\n\tcontextNodes: number;\n}\n\n// ---------------------------------------------------------------------------\n// Orphan detection helpers — adapted for IMemoryStorage\n// ---------------------------------------------------------------------------\n\nconst ONE_YEAR_MS = 365 * 86_400_000;\n\ninterface ContextNodeRef { id: string; entityPointerIds: string[]; allEntitiesArchived?: boolean }\n\n// ---------------------------------------------------------------------------\n// MemoryService\n// ---------------------------------------------------------------------------\n\nexport class MemoryService {\n\tprivate readonly storage: IMemoryStorage;\n\tprivate readonly complete: LLMCompleteFn;\n\tprivate readonly cwd: string;\n\tprivate readonly sessionId: string;\n\tprivate readonly sessionFile: string;\n\tprivate readonly parentSessionFile: string | null;\n\n\t// Session-scoped mutable state (set during init())\n\tprivate entityIndex: EntityIndex = new EntityIndex();\n\tprivate narrativeCache: NarrativeCache = new NarrativeCache();\n\tprivate sessionStartInjectionDone = false;\n\tprivate memoryEnabled = true;\n\tprivate backfillDone = false;\n\tprivate turnCount = 0;\n\tprivate initialized = false;\n\n\tconstructor(config: MemoryServiceConfig) {\n\t\tthis.storage = config.storage;\n\t\tthis.complete = config.complete;\n\t\tthis.cwd = config.cwd;\n\t\tthis.sessionId = config.sessionId;\n\t\tthis.sessionFile = config.sessionFile ?? \"\";\n\t\tthis.parentSessionFile = config.parentSessionFile ?? null;\n\t}\n\n\t// ---------------------------------------------------------------------------\n\t// Lifecycle — called by the adapter from event hooks\n\t// ---------------------------------------------------------------------------\n\n\t/**\n\t * Initialise storage, build indices, and return the narrative system prompt block.\n\t * Call once at session_start before any other method.\n\t * Returns { systemPromptAppend } for inclusion in the session system prompt.\n\t */\n\tasync init(): Promise<{ systemPromptAppend: string }> {\n\t\t// Initialise storage (create dirs, touch files)\n\t\tawait this.storage.init();\n\n\t\t// Build O(1) entity index from disk (O(N) startup)\n\t\tconst allEntities = (await loadAllEntities(this.storage)).filter((n) => !n._archived);\n\t\tthis.entityIndex.build(allEntities);\n\n\t\t// Load all NarrativeNodes into cache\n\t\tconst allNarratives = await loadAllNarrativeNodes(this.storage);\n\t\tthis.narrativeCache.build(allNarratives);\n\n\t\t// Build BM25F index from hubs + spoke context nodes\n\t\ttry {\n\t\t\tconst allContextNodes = await loadAllContextNodes(this.storage);\n\t\t\tthis.narrativeCache.buildIndex(allContextNodes);\n\t\t} catch (err) {\n\t\t\tconsole.error(\"[memory] BM25F index build error:\", err instanceof Error ? err.message : String(err));\n\t\t}\n\n\t\t// Record session in persistent tree index (F-74)\n\t\ttry {\n\t\t\tawait recordSessionStart({\n\t\t\t\tstorage: this.storage,\n\t\t\t\tsessionId: this.sessionId,\n\t\t\t\tsessionFile: this.sessionFile,\n\t\t\t\tparentSessionFile: this.parentSessionFile,\n\t\t\t\tcwd: this.cwd,\n\t\t\t});\n\t\t} catch (err) {\n\t\t\tconsole.error(\"[memory] session tree index error:\", err instanceof Error ? err.message : String(err));\n\t\t}\n\n\t\t// Archive dead hubs at session start (F-98)\n\t\ttry {\n\t\t\tconst deadCount = await archiveDeadHubs(this.storage, this.narrativeCache);\n\t\t\tif (deadCount > 0) {\n\t\t\t\tconsole.log(`[memory] Archived ${deadCount} dead hub(s) at session start`);\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tconsole.error(\"[memory] dead hub detection error:\", err instanceof Error ? err.message : String(err));\n\t\t}\n\n\t\tthis.initialized = true;\n\n\t\t// Build and return the narrative system prompt block (F-62)\n\t\tconst relevantHubs = this.narrativeCache.getHubsForContext(this.cwd, 5);\n\t\tconst totalHubCount = this.narrativeCache.size;\n\t\tconst narrativeBlock = buildNarrativeSystemPromptBlock(relevantHubs, totalHubCount);\n\t\treturn { systemPromptAppend: narrativeBlock };\n\t}\n\n\t/**\n\t * Called before each agent turn (before_agent_start).\n\t * Returns a system prompt injection string, or null if nothing to inject.\n\t */\n\tasync onBeforeTurn(prompt?: string): Promise<string | null> {\n\t\tif (!this.initialized || !this.memoryEnabled) return null;\n\n\t\tconst injections: string[] = [];\n\n\t\t// F-97: Narrative tick every 20 turns (fire-and-forget)\n\t\tthis.turnCount++;\n\t\tif (this.turnCount > 1 && this.turnCount % 20 === 0) {\n\t\t\trunNarrativeTick(this.storage, this.narrativeCache, this.complete).catch((err) =>\n\t\t\t\tconsole.error(\"[memory] narrative tick error:\", err instanceof Error ? err.message : String(err)),\n\t\t\t);\n\t\t}\n\n\t\t// F-65: Session start context retrieval (once per session)\n\t\tif (!this.sessionStartInjectionDone) {\n\t\t\tthis.sessionStartInjectionDone = true;\n\t\t\tconst sessionBlock = await buildSessionStartInjection({\n\t\t\t\tstorage: this.storage,\n\t\t\t\tentityIndex: this.entityIndex,\n\t\t\t\tnarrativeCache: this.narrativeCache,\n\t\t\t\tcwd: this.cwd,\n\t\t\t\tsessionPrompt: prompt,\n\t\t\t});\n\t\t\tif (sessionBlock) injections.push(sessionBlock);\n\t\t}\n\n\t\t// F-66: Per-turn entity-driven injection\n\t\tif (prompt) {\n\t\t\tconst perTurnBlock = await buildPerTurnInjection({\n\t\t\t\tuserMessage: prompt,\n\t\t\t\tstorage: this.storage,\n\t\t\t\tentityIndex: this.entityIndex,\n\t\t\t});\n\t\t\tif (perTurnBlock) injections.push(perTurnBlock);\n\t\t}\n\n\t\treturn injections.length > 0 ? injections.join(\"\\n\\n\") : null;\n\t}\n\n\t/**\n\t * Called after each agent turn (agent_end).\n\t * Runs retroactive backfill on first turn, fires-and-forgets.\n\t */\n\tasync onAgentEnd(): Promise<void> {\n\t\tif (!this.initialized || this.backfillDone) return;\n\t\tthis.backfillDone = true;\n\n\t\ttry {\n\t\t\tconst result = await runBackfillIndexing({\n\t\t\t\tstorage: this.storage,\n\t\t\t\tentityIndex: this.entityIndex,\n\t\t\t\tnarrativeCache: this.narrativeCache,\n\t\t\t\tcomplete: this.complete,\n\t\t\t\tcurrentSessionId: this.sessionId,\n\t\t\t});\n\t\t\tif (result.processed > 0) {\n\t\t\t\tconsole.log(`[memory] Indexed ${result.processed} past session(s) into memory.`);\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tconsole.error(\"[memory] backfill error:\", err instanceof Error ? err.message : String(err));\n\t\t}\n\t}\n\n\t/**\n\t * Called when a compaction event fires (session_before_compact).\n\t * Writes a ContextNode and updates the narrative layer.\n\t */\n\tasync onCompaction(\n\t\tentries: MemoryBranchEntry[],\n\t\tsessionId: string,\n\t\tsessionFile: string,\n\t\tcwd: string,\n\t): Promise<void> {\n\t\tif (!this.initialized) return;\n\n\t\tconst newNode = await writeContextNodeFromCompaction({\n\t\t\tbranchEntries: entries,\n\t\t\tsessionId,\n\t\t\tsessionFile,\n\t\t\tstorage: this.storage,\n\t\t\tentityIndex: this.entityIndex,\n\t\t\tcomplete: this.complete,\n\t\t\tcwd,\n\t\t});\n\n\t\tif (newNode) {\n\t\t\tawait updateNarrativeLayer({\n\t\t\t\tnewNode,\n\t\t\t\tstorage: this.storage,\n\t\t\t\tcache: this.narrativeCache,\n\t\t\t\tcomplete: this.complete,\n\t\t\t});\n\t\t}\n\t}\n\n\t/**\n\t * Called at session shutdown (session_shutdown).\n\t * Performs session-close flush, dead hub archival, and orphan detection.\n\t */\n\tasync onShutdown(): Promise<void> {\n\t\tif (!this.initialized) return;\n\n\t\t// F-73: Session-close flush — index uncompacted session entries\n\t\ttry {\n\t\t\tif (this.sessionFile) {\n\t\t\t\tconst sessionData = readSessionForBackfill(this.sessionFile);\n\t\t\t\tif (sessionData && sessionData.entries.length > 0) {\n\t\t\t\t\tconst newNode = await writeContextNodeFromCompaction({\n\t\t\t\t\t\tbranchEntries: sessionData.entries,\n\t\t\t\t\t\tsessionId: sessionData.sessionId,\n\t\t\t\t\t\tsessionFile: this.sessionFile,\n\t\t\t\t\t\tstorage: this.storage,\n\t\t\t\t\t\tentityIndex: this.entityIndex,\n\t\t\t\t\t\tcomplete: this.complete,\n\t\t\t\t\t\tcwd: sessionData.cwd,\n\t\t\t\t\t});\n\t\t\t\t\tif (newNode) {\n\t\t\t\t\t\tawait updateNarrativeLayer({\n\t\t\t\t\t\t\tnewNode,\n\t\t\t\t\t\t\tstorage: this.storage,\n\t\t\t\t\t\t\tcache: this.narrativeCache,\n\t\t\t\t\t\t\tcomplete: this.complete,\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tconsole.error(\"[memory] session flush error:\", err instanceof Error ? err.message : String(err));\n\t\t} finally {\n\t\t\t// F-98: Archive dead hubs\n\t\t\ttry {\n\t\t\t\tawait archiveDeadHubs(this.storage, this.narrativeCache);\n\t\t\t} catch (err) {\n\t\t\t\tconsole.error(\"[memory] dead hub detection error:\", err instanceof Error ? err.message : String(err));\n\t\t\t}\n\t\t\t// F-56: Orphan detection\n\t\t\ttry {\n\t\t\t\tawait this.runOrphanDetection();\n\t\t\t} catch (err) {\n\t\t\t\tconsole.error(\"[memory] orphan detection error:\", err instanceof Error ? err.message : String(err));\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Run retroactive backfill indexing for all unindexed sessions.\n\t */\n\tasync runBackfill(): Promise<BackfillResult> {\n\t\tif (!this.initialized) return { processed: 0, skipped: 0, errors: 0, total: 0 };\n\t\treturn runBackfillIndexing({\n\t\t\tstorage: this.storage,\n\t\t\tentityIndex: this.entityIndex,\n\t\t\tnarrativeCache: this.narrativeCache,\n\t\t\tcomplete: this.complete,\n\t\t\tcurrentSessionId: this.sessionId,\n\t\t});\n\t}\n\n\t// ---------------------------------------------------------------------------\n\t// Query methods — called by tool execute callbacks and web consumers\n\t// ---------------------------------------------------------------------------\n\n\tasync query(topic: string): Promise<string> {\n\t\tif (!this.memoryEnabled) return \"[Memory system disabled for this session]\";\n\t\treturn memoryQuery(\n\t\t\ttopic,\n\t\t\tthis.storage,\n\t\t\tthis.entityIndex,\n\t\t\tthis.narrativeCache,\n\t\t\tthis.cwd,\n\t\t\tthis.complete,\n\t\t);\n\t}\n\n\tasync entityLookup(name: string, type: EntityType): Promise<string> {\n\t\tif (!this.memoryEnabled) return \"[Memory system disabled for this session]\";\n\t\treturn memoryEntityLookup(name, type, this.storage, this.entityIndex);\n\t}\n\n\tasync recall(topic: string, options: RecallOptions = {}): Promise<string> {\n\t\tif (!this.memoryEnabled) return \"[Memory system disabled for this session]\";\n\t\tconst fromDate = options.from_date ? new Date(options.from_date) : undefined;\n\t\tconst toDate = options.to_date ? new Date(options.to_date) : undefined;\n\t\treturn memoryRecall(\n\t\t\ttopic,\n\t\t\tthis.storage,\n\t\t\tthis.entityIndex,\n\t\t\tthis.narrativeCache,\n\t\t\toptions.max_turns ?? 30,\n\t\t\tthis.cwd,\n\t\t\tfromDate,\n\t\t\ttoDate,\n\t\t\tthis.complete,\n\t\t);\n\t}\n\n\t// ---------------------------------------------------------------------------\n\t// Toggle and status\n\t// ---------------------------------------------------------------------------\n\n\tenable(): void {\n\t\tthis.memoryEnabled = true;\n\t}\n\n\tdisable(): void {\n\t\tthis.memoryEnabled = false;\n\t}\n\n\tget isEnabled(): boolean {\n\t\treturn this.memoryEnabled;\n\t}\n\n\tget stats(): MemoryStats {\n\t\treturn {\n\t\t\thubs: this.narrativeCache.size,\n\t\t\tentities: this.entityIndex.size,\n\t\t\tcontextNodes: 0, // populated lazily — callers can call query for count\n\t\t};\n\t}\n\n\t// ---------------------------------------------------------------------------\n\t// Session tree view (used by /memory tree)\n\t// ---------------------------------------------------------------------------\n\n\tasync renderTree(currentSessionId: string): Promise<string> {\n\t\tconst index = await loadSessionTree(this.storage);\n\t\tconst contextNodes = await this.storage.readAll<{ sessionId: string }>(\"context\");\n\t\tconst contextNodeSessions = new Set(contextNodes.map((n) => n.sessionId));\n\t\tconst rows = buildTreeRenderRows(index, currentSessionId, contextNodeSessions);\n\t\treturn renderSessionTree(rows);\n\t}\n\n\t/**\n\t * Count of unindexed sessions. Returned from init() as a hint to the caller\n\t * (the chili adapter can then notify the user).\n\t */\n\tasync countUnindexed(): Promise<number> {\n\t\ttry {\n\t\t\tconst unindexed = await findUnindexedSessions(this.storage, this.sessionId);\n\t\t\treturn unindexed.length;\n\t\t} catch {\n\t\t\treturn 0;\n\t\t}\n\t}\n\n\t// ---------------------------------------------------------------------------\n\t// Skill file management (called from resources_discover)\n\t// ---------------------------------------------------------------------------\n\n\t/**\n\t * Write SKILL.md files to the extension storage dir and return their paths.\n\t * Called once per session from the resources_discover event handler.\n\t */\n\tgetSkillPaths(storageDir: string): string[] {\n\t\tconst skillsBase = join(storageDir, \"skills\");\n\t\tconst memoryQueryDir = join(skillsBase, \"memory-query\");\n\t\tconst memoryLookupDir = join(skillsBase, \"memory-lookup\");\n\t\tconst memoryRecallDir = join(skillsBase, \"memory-recall\");\n\n\t\tmkdirSync(memoryQueryDir, { recursive: true });\n\t\tmkdirSync(memoryLookupDir, { recursive: true });\n\t\tmkdirSync(memoryRecallDir, { recursive: true });\n\n\t\twriteFileSync(join(memoryQueryDir, \"SKILL.md\"), SKILL_MEMORY_QUERY, \"utf-8\");\n\t\twriteFileSync(join(memoryLookupDir, \"SKILL.md\"), SKILL_MEMORY_LOOKUP, \"utf-8\");\n\t\twriteFileSync(join(memoryRecallDir, \"SKILL.md\"), SKILL_MEMORY_RECALL, \"utf-8\");\n\n\t\treturn [memoryQueryDir, memoryLookupDir, memoryRecallDir];\n\t}\n\n\t// ---------------------------------------------------------------------------\n\t// Private helpers\n\t// ---------------------------------------------------------------------------\n\n\tprivate async runOrphanDetection(): Promise<void> {\n\t\tconst now = Date.now();\n\t\tconst archivedIds = new Set<string>();\n\n\t\tfor (const node of this.entityIndex.values()) {\n\t\t\tif (now - node.lastReferencedAt > ONE_YEAR_MS) {\n\t\t\t\tawait this.storage.archive(\"entities-archive\", node);\n\t\t\t\tawait updateEntity(this.storage, node.id, { _archived: true });\n\t\t\t\tarchivedIds.add(node.id);\n\t\t\t}\n\t\t}\n\n\t\tif (archivedIds.size === 0) return;\n\n\t\tconst contextNodes = await this.storage.readAll<ContextNodeRef>(\"context\");\n\t\tfor (const ctx of contextNodes) {\n\t\t\tif (!ctx.entityPointerIds?.length) continue;\n\t\t\tif (ctx.allEntitiesArchived) continue;\n\t\t\tconst allArchived = ctx.entityPointerIds.every((id) => archivedIds.has(id));\n\t\t\tif (allArchived) {\n\t\t\t\tawait updateContextNode(this.storage, ctx.id, { allEntitiesArchived: true });\n\t\t\t}\n\t\t}\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Skill content strings (F-70, F-71, F-81/F-82)\n// ---------------------------------------------------------------------------\n\nconst SKILL_MEMORY_QUERY = `---\nname: memory-query\ndescription: Broad memory context recall — loads narrative hub summaries, all linked context and entity nodes, with optional session-level detail. Use when user wants full context on a topic area.\n---\n\n# Memory Query Protocol\n\nUse this skill when the user asks about past work, ongoing themes, or broad topic areas stored in memory.\n\n## Three-Step Traversal Protocol\n\n### Step 1 — Narrative Match\nCall \\`memory_query(topic)\\` with the user's topic. The tool matches narrative hub topics (case-insensitive containment) and returns hub summaries with linked context node summaries. Report all matching hubs and their summaries to orient the user.\n\n### Step 2 — Context Node Load\nThe \\`memory_query\\` response includes context node summaries for each matched hub. For each context node:\n- Check the health annotation: nodes marked \\`[STALE: N degraded, M deprecated entities]\\` should be flagged.\n- Note the session ID, date, and working directory fields for provenance.\n- Both sides of a supersession pair are always surfaced — present both; never present only the superseding node.\n\n### Step 3 — Entity Investigation (on-demand only)\n**Do not automatically call \\`memory_entity_lookup\\` for every entity in the result.** Only call it when:\n- The user explicitly asks about the health, history, or validity of a specific named entity.\n- An entity appears in a stale context node and the user wants to know if it is still relevant.\n\nWhen called, \\`memory_entity_lookup(name, type)\\` returns:\n- Current validity score and label (trusted / uncertain / degraded / deprecated).\n- All context nodes that reference the entity, newest first.\n\nExplicitly warn the user when any looked-up entity has validity < 0.3 (degraded or deprecated), citing the entity name and score.\n\n### Layer 1 Traversal (on-demand only)\n**Do not access Layer 1 automatically.** When the user explicitly requests session-level detail or conversation excerpts, use \\`memory_recall(topic)\\` — it resolves the session file internally and returns conversation turns without requiring manual tree reads or bash greps.\n\n### Response Format\nPresent results in this order:\n1. Hub summary (title, topics, summary, session count).\n2. Context node summaries, newest first (date, user context, agent context, entity health table).\n3. Entity detail, only if explicitly requested via Step 3.\n4. Session conversation turns, only if explicitly requested (use \\`memory_recall\\`).\n`;\n\nconst SKILL_MEMORY_LOOKUP = `---\nname: memory-lookup\ndescription: Pinpoint session-level memory retrieval — returns the actual conversation turns from the best matching session. Use when user asks about a specific past exchange or decision.\n---\n\n# Memory Lookup Protocol\n\nUse this skill when the user asks about a specific past conversation, decision, or exchange — not just a topic area. This skill always retrieves Layer 1 session turns. Use \\`/memory-query\\` when the user wants breadth-first topic recall without seeing the raw conversation.\n\n## Two-Step Retrieval Protocol\n\n### Step 1 — Recall\nCall \\`memory_recall(topic)\\` with the user's topic. This single tool returns:\n- Matching narrative hub context (session ID, date, user context, agent context, entity health).\n- The actual conversation turns from the matched session JSONL.\n\nThe tool resolves the session file path internally — do **not** manually grep for session paths or read \\`layer0-session-tree.jsonl\\`. If the tool reports \"Session file not found\", present the context node summary as the best available record.\n\nIf the user provides a session ID directly (not a topic), call \\`memory_query(sessionId)\\` first to locate the context node, then call \\`memory_recall\\` with one of the context node's topics.\n\n### Step 2 — Entity Lookup (only when explicitly needed)\nCall \\`memory_entity_lookup(name, type)\\` **only** when the user explicitly asks about a specific named artifact — a file path, function name, module, person, or architectural decision. Do **not** call it for topic phrases like \"gardening\", \"python\", or \"auth flow\" — those are topics for \\`memory_recall\\`, not entities.\n\nWhen in doubt: if the user said a noun that could be a filename or proper name, use entity lookup. If they described a subject area or past conversation, use \\`memory_recall\\`.\n\n## Supersession\nContext nodes marked \\`[SUPERSEDES: <sessionId>]\\` indicate the retrieved session updates an earlier one. Always surface both sides of a supersession pair so the user can see the evolution.\n`;\n\nconst SKILL_MEMORY_RECALL = `---\nname: memory-recall\ndescription: Unified session recall tool — retrieves context node summary and conversation turns in one call. Agent-callable via memory_recall(topic). No manual file resolution needed.\n---\n\n# memory_recall Tool Reference\n\n\\`memory_recall\\` is an agent tool (not a user slash command). It is the primary retrieval primitive for \\`/memory-lookup\\`.\n\n## What it does\nCombines narrative hub matching, context node load, and Layer 1 JSONL reading into a single tool call. Returns the context node summary (session ID, date, entities, health) plus the actual conversation turns from the matched session.\n\n## When to use\n- User asks about a specific past conversation or decision → call \\`memory_recall(topic)\\`\n- Do NOT manually grep for session paths or read \\`layer0-session-tree.jsonl\\` — the tool resolves these internally via the \\`sessionFile\\` field (F-75) or session tree index fallback.\n\n## Parameters\n- \\`topic\\` (string) — the subject area or keyword to match against narrative hubs\n- \\`max_turns\\` (number, optional) — cap on conversation turns returned (default 30, max 200)\n\n## Session file resolution order\n1. Uses \\`sessionFile\\` field on the context node directly (F-75 — present on all modern nodes).\n2. Falls back to session tree index lookup when \\`sessionFile\\` is absent (pre-F-75 nodes).\n3. Reports \"Not available\" if both fail — presents context node summary as best available record.\n`;\n"]}