@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,363 @@
1
+ /**
2
+ * Context graph — ContextNode write path (F-57) and entity coupling (F-58).
3
+ *
4
+ * Hooks into `session_before_compact` (blocking). An LLM call extracts
5
+ * userContext, agentContext, topics[], and entities[] from branch entries.
6
+ * Writes a ContextNode and coupled EntityNodes to storage.
7
+ *
8
+ * Adapted for @chili/memory: uses IMemoryStorage (collection names) instead of
9
+ * StorageLayout (file paths), LLMCompleteFn instead of @chili/ai types, and
10
+ * MemoryBranchEntry instead of chili-internal SessionEntry.
11
+ */
12
+ import { createEntity, nameHash, updateEntity, validityScore } from "./entity.js";
13
+ function extractText(content) {
14
+ if (typeof content === "string")
15
+ return content;
16
+ if (Array.isArray(content)) {
17
+ return content
18
+ .filter((b) => b.type === "text")
19
+ .map((b) => b.text)
20
+ .join(" ");
21
+ }
22
+ return "";
23
+ }
24
+ /** Serialise branch entries to a readable transcript for the LLM. */
25
+ function formatBranchEntries(entries) {
26
+ const lines = [];
27
+ for (const entry of entries) {
28
+ if (entry.type === "message" && entry.message) {
29
+ const msg = entry.message;
30
+ if (msg.role === "user") {
31
+ const text = extractText(msg.content).trim();
32
+ if (text)
33
+ lines.push(`User: ${text}`);
34
+ }
35
+ else if (msg.role === "assistant") {
36
+ const text = extractText(msg.content).trim();
37
+ if (text)
38
+ lines.push(`Assistant: ${text.slice(0, 500)}`);
39
+ }
40
+ }
41
+ }
42
+ return lines.join("\n") || "(no messages)";
43
+ }
44
+ const EXTRACTION_SYSTEM_PROMPT = `You are a memory extraction assistant for a coding agent session.
45
+ Analyze the provided conversation transcript and extract structured metadata.
46
+ Respond ONLY with valid JSON matching the schema — no markdown, no explanation.`;
47
+ function buildExtractionPrompt(transcript, cwd) {
48
+ return [
49
+ {
50
+ role: "user",
51
+ timestamp: Date.now(),
52
+ content: `Extract metadata from this coding session transcript.
53
+ Working directory: ${cwd}
54
+
55
+ TRANSCRIPT:
56
+ ${transcript}
57
+
58
+ Respond with JSON only:
59
+ {
60
+ "userContext": "<1-3 sentences: what the user was asking or working on>",
61
+ "agentContext": "<1-3 sentences: what the agent did in response>",
62
+ "topics": ["<topic1>", "<topic2>"],
63
+ "entities": [
64
+ { "name": "<canonical name>", "type": "<file|person|concept|module|decision|url>" }
65
+ ],
66
+ "relevantFilePaths": ["<file path mentioned in agent work>"],
67
+ "relevantModules": ["<package or module name referenced>"],
68
+ "searchTerms": ["<query 1>", "<query 2>"],
69
+ "entityEvents": [
70
+ { "name": "<entity canonical name>", "type": "<entity type>", "event": "<renamed|deprecated|replaced>", "newName": "<new name if renamed>" }
71
+ ]
72
+ }
73
+
74
+ Rules:
75
+ - userContext: from the user's perspective — their goal/question
76
+ - agentContext: from the agent's perspective — actions taken
77
+ - topics: 2-5 short keyword phrases for categorization
78
+ - entities: named things that appeared — files, people, modules, decisions, URLs, and concepts (including programming languages, frameworks, algorithms, data structures, and named techniques)
79
+ - relevantFilePaths: actual file paths the agent read/edited/created (empty if none)
80
+ - relevantModules: npm packages or internal module names mentioned (empty if none)
81
+ - searchTerms: 3-5 natural-language queries someone would type to find this session later — use varied vocabulary, not just technical terms (e.g. "how we fixed the memory search bug", "storage adapter interface design")
82
+ - entityEvents: observable state changes — renames, deprecations, replacements (empty if none)
83
+ - Keep all strings concise (under 200 chars each)`,
84
+ },
85
+ ];
86
+ }
87
+ function parseExtractionResponse(response) {
88
+ const textBlock = Array.isArray(response.content)
89
+ ? response.content.find((b) => b.type === "text")
90
+ : null;
91
+ const raw = textBlock ? textBlock.text : "";
92
+ // Strip markdown code fences if present
93
+ const cleaned = raw.replace(/^```(?:json)?\n?/m, "").replace(/\n?```$/m, "").trim();
94
+ try {
95
+ const parsed = JSON.parse(cleaned);
96
+ const validTypes = ["file", "person", "concept", "module", "decision", "url"];
97
+ return {
98
+ userContext: typeof parsed.userContext === "string" ? parsed.userContext : "(no context)",
99
+ agentContext: typeof parsed.agentContext === "string" ? parsed.agentContext : "(no context)",
100
+ topics: Array.isArray(parsed.topics) ? parsed.topics.filter((t) => typeof t === "string") : [],
101
+ entities: Array.isArray(parsed.entities)
102
+ ? parsed.entities.filter((e) => typeof e?.name === "string" && validTypes.includes(e?.type))
103
+ : [],
104
+ relevantFilePaths: Array.isArray(parsed.relevantFilePaths)
105
+ ? parsed.relevantFilePaths.filter((p) => typeof p === "string")
106
+ : [],
107
+ relevantModules: Array.isArray(parsed.relevantModules)
108
+ ? parsed.relevantModules.filter((m) => typeof m === "string")
109
+ : [],
110
+ searchTerms: Array.isArray(parsed.searchTerms)
111
+ ? parsed.searchTerms.filter((s) => typeof s === "string")
112
+ : [],
113
+ entityEvents: Array.isArray(parsed.entityEvents)
114
+ ? parsed.entityEvents.filter((e) => typeof e?.name === "string" &&
115
+ validTypes.includes(e?.type) &&
116
+ ["renamed", "deprecated", "replaced"].includes(e?.event))
117
+ : [],
118
+ };
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ }
124
+ // ---------------------------------------------------------------------------
125
+ // Entity coupling (F-58)
126
+ // ---------------------------------------------------------------------------
127
+ const VALID_ENTITY_TYPES = new Set(["file", "person", "concept", "module", "decision", "url"]);
128
+ async function coupleEntities(entities, entityIndex, storage, sessionOrdinal, now) {
129
+ const entityPointerIds = [];
130
+ for (const { name, type } of entities) {
131
+ if (!VALID_ENTITY_TYPES.has(type))
132
+ continue;
133
+ const existing = entityIndex.get(name, type);
134
+ if (existing) {
135
+ // Update lastReferencedAt in memory and on disk
136
+ const updated = { ...existing, lastReferencedAt: now };
137
+ entityIndex.update(existing.id, { lastReferencedAt: now });
138
+ await updateEntity(storage, existing.id, { lastReferencedAt: now });
139
+ entityPointerIds.push(updated.id);
140
+ }
141
+ else {
142
+ // Create new EntityNode with confirmed event (eventType=1)
143
+ const hash = nameHash(name);
144
+ const node = {
145
+ id: globalThis.crypto.randomUUID(),
146
+ type,
147
+ canonicalName: name,
148
+ currentNameHash: hash,
149
+ eventMatrix: [[1, now, 0, hash, sessionOrdinal]],
150
+ lastReferencedAt: now,
151
+ edges: [],
152
+ };
153
+ await createEntity(storage, node);
154
+ entityIndex.set(node);
155
+ entityPointerIds.push(node.id);
156
+ }
157
+ }
158
+ return entityPointerIds;
159
+ }
160
+ // ---------------------------------------------------------------------------
161
+ // ContextNode CRUD
162
+ // ---------------------------------------------------------------------------
163
+ export async function loadAllContextNodes(storage) {
164
+ return storage.readAll("context");
165
+ }
166
+ export async function updateContextNode(storage, id, patch) {
167
+ await storage.update("context", id, patch);
168
+ }
169
+ export async function loadContextNodeWithSupersession(storage, id) {
170
+ const all = await loadAllContextNodes(storage);
171
+ const node = all.find((n) => n.id === id) ?? null;
172
+ if (!node)
173
+ return [null, null];
174
+ const neighbourId = node.supersededBy ?? node.supersedes;
175
+ const neighbour = neighbourId ? (all.find((n) => n.id === neighbourId) ?? null) : null;
176
+ return [node, neighbour];
177
+ }
178
+ // ---------------------------------------------------------------------------
179
+ // F-67: Entity event updates from agent observations
180
+ // ---------------------------------------------------------------------------
181
+ const EVENT_TYPE_MAP = {
182
+ renamed: 2,
183
+ deprecated: 3,
184
+ replaced: 4,
185
+ };
186
+ async function applyEntityEvents(events, entityIndex, storage, sessionOrdinal, now) {
187
+ for (const evt of events) {
188
+ // AC-67-02: Skip entities not in index — no creation in this path
189
+ const existing = entityIndex.get(evt.name, evt.type);
190
+ if (!existing)
191
+ continue;
192
+ const eventType = EVENT_TYPE_MAP[evt.event];
193
+ const newHash = evt.newName ? nameHash(evt.newName) : existing.currentNameHash;
194
+ const vector = [eventType, now, existing.currentNameHash, newHash, sessionOrdinal];
195
+ const updatedMatrix = [...existing.eventMatrix, vector];
196
+ const patch = { eventMatrix: updatedMatrix };
197
+ // Update currentNameHash on rename
198
+ if (evt.event === "renamed" && evt.newName) {
199
+ patch.currentNameHash = newHash;
200
+ }
201
+ await updateEntity(storage, existing.id, patch);
202
+ entityIndex.update(existing.id, patch);
203
+ }
204
+ }
205
+ // ---------------------------------------------------------------------------
206
+ // Main: write ContextNode from compaction event (F-57 + F-58 + F-67 + F-68)
207
+ // ---------------------------------------------------------------------------
208
+ export async function writeContextNodeFromCompaction(params) {
209
+ const { branchEntries, sessionId, sessionFile, storage, entityIndex, complete, sessionOrdinal = 0, cwd = "" } = params;
210
+ if (branchEntries.length === 0)
211
+ return null;
212
+ const transcript = formatBranchEntries(branchEntries);
213
+ const messages = buildExtractionPrompt(transcript, cwd);
214
+ let extraction = null;
215
+ try {
216
+ const response = await complete(messages, { systemPrompt: EXTRACTION_SYSTEM_PROMPT });
217
+ extraction = parseExtractionResponse(response);
218
+ }
219
+ catch (err) {
220
+ console.error("[memory] LLM extraction failed:", err instanceof Error ? err.message : String(err));
221
+ return null;
222
+ }
223
+ if (!extraction)
224
+ return null;
225
+ const now = Date.now();
226
+ // F-58: Entity coupling — write entity nodes and get their IDs
227
+ const entityPointerIds = await coupleEntities(extraction.entities, entityIndex, storage, sessionOrdinal, now);
228
+ // F-67: Apply entity event updates (AC-67-01, AC-67-02)
229
+ if (extraction.entityEvents.length > 0) {
230
+ await applyEntityEvents(extraction.entityEvents, entityIndex, storage, sessionOrdinal, now);
231
+ }
232
+ // F-57 + F-68 + F-75: Write ContextNode with extended staleness fields and session file path
233
+ const node = {
234
+ id: globalThis.crypto.randomUUID(),
235
+ sessionId,
236
+ sessionIds: [sessionId],
237
+ timestamp: now,
238
+ userContext: extraction.userContext,
239
+ agentContext: extraction.agentContext,
240
+ topics: extraction.topics,
241
+ entityPointerIds,
242
+ // F-68: Extended staleness-evidence fields (AC-68-01, AC-68-02)
243
+ workingDirectory: cwd || undefined,
244
+ relevantFilePaths: extraction.relevantFilePaths,
245
+ relevantModules: extraction.relevantModules,
246
+ // F-108: Search-oriented query terms for BM25F recall widening
247
+ searchTerms: extraction.searchTerms,
248
+ // F-75: Self-contained Layer 1 file path (AC-75-01)
249
+ sessionFile: sessionFile || undefined,
250
+ };
251
+ await storage.append("context", node);
252
+ // F-59: Supersession detection (second LLM call — best-effort, non-blocking)
253
+ try {
254
+ await detectSupersession(node, storage, sessionId, complete);
255
+ }
256
+ catch {
257
+ // Non-fatal
258
+ }
259
+ return node;
260
+ }
261
+ // ---------------------------------------------------------------------------
262
+ // F-59: Supersession detection (second LLM call after F-58)
263
+ // ---------------------------------------------------------------------------
264
+ const SUPERSESSION_SYSTEM_PROMPT = `You are a memory deduplication assistant for a coding agent.
265
+ You will be shown a new context summary and a list of previous summaries from the same session.
266
+ Determine if the new summary substantially revisits or supersedes exactly one of the previous ones.
267
+
268
+ Return ONLY the ID of the superseded summary, or null if nothing is superseded.
269
+ A summary is superseded if the new one covers the same ground with updated or corrected information.
270
+ Do NOT mark as superseded if both summaries are simultaneously valid (different scope, different files, different problems).
271
+
272
+ Respond ONLY with valid JSON — no markdown, no explanation.`;
273
+ function buildSupersessionPrompt(newNode, candidates) {
274
+ const candidateText = candidates
275
+ .map((c, i) => `[${i + 1}] ID: ${c.id}\nUser: ${c.userContext}\nAgent: ${c.agentContext}`)
276
+ .join("\n\n");
277
+ return [
278
+ {
279
+ role: "user",
280
+ timestamp: Date.now(),
281
+ content: `New summary:
282
+ User: ${newNode.userContext}
283
+ Agent: ${newNode.agentContext}
284
+
285
+ Previous summaries from this session:
286
+ ${candidateText}
287
+
288
+ Does the new summary substantially revisit or supersede exactly one of the previous summaries?
289
+ If yes, return the ID of the superseded summary. If no, return null.
290
+
291
+ Respond with JSON only:
292
+ { "supersededId": "<id>" | null }`,
293
+ },
294
+ ];
295
+ }
296
+ async function detectSupersession(newNode, storage, sessionId, complete) {
297
+ // AC-59-03: Only consider nodes from the current session (parent-chain scoping)
298
+ const all = await loadAllContextNodes(storage);
299
+ const candidates = all.filter((n) => n.id !== newNode.id && n.sessionIds.includes(sessionId) && !n.supersededBy);
300
+ if (candidates.length === 0)
301
+ return;
302
+ const messages = buildSupersessionPrompt(newNode, candidates);
303
+ let raw;
304
+ try {
305
+ const response = await complete(messages, { systemPrompt: SUPERSESSION_SYSTEM_PROMPT });
306
+ const textBlock = Array.isArray(response.content)
307
+ ? response.content.find((b) => b.type === "text")
308
+ : null;
309
+ raw = textBlock ? textBlock.text : "";
310
+ }
311
+ catch {
312
+ return; // Non-fatal — supersession is best-effort
313
+ }
314
+ const cleaned = raw.replace(/^```(?:json)?\n?/m, "").replace(/\n?```$/m, "").trim();
315
+ let supersededId = null;
316
+ try {
317
+ const parsed = JSON.parse(cleaned);
318
+ supersededId = typeof parsed.supersededId === "string" ? parsed.supersededId : null;
319
+ }
320
+ catch {
321
+ return;
322
+ }
323
+ if (!supersededId)
324
+ return;
325
+ // Verify the ID is actually a candidate (guard against hallucinated IDs)
326
+ const superseded = candidates.find((n) => n.id === supersededId);
327
+ if (!superseded)
328
+ return;
329
+ // AC-59-01: Update both sides of the supersession pair
330
+ const now = Date.now();
331
+ await updateContextNode(storage, superseded.id, { supersededBy: newNode.id, supersededAt: now });
332
+ await updateContextNode(storage, newNode.id, { supersedes: superseded.id });
333
+ }
334
+ export function projectHealth(node, entityIndex, now) {
335
+ if (node.entityPointerIds.length === 0) {
336
+ return { meanValidity: 1, minValidity: 1, degradedCount: 0, deprecatedCount: 0 };
337
+ }
338
+ let sum = 0;
339
+ let min = Number.POSITIVE_INFINITY;
340
+ let degradedCount = 0;
341
+ let deprecatedCount = 0;
342
+ for (const id of node.entityPointerIds) {
343
+ const entity = entityIndex.getById(id);
344
+ if (!entity)
345
+ continue;
346
+ const score = validityScore(entity.eventMatrix, entity.type, now);
347
+ sum += score;
348
+ if (score < min)
349
+ min = score;
350
+ if (score < 0.3)
351
+ degradedCount++;
352
+ if (score < 0)
353
+ deprecatedCount++;
354
+ }
355
+ const count = node.entityPointerIds.length;
356
+ return {
357
+ meanValidity: sum / count,
358
+ minValidity: min === Number.POSITIVE_INFINITY ? 1 : min,
359
+ degradedCount,
360
+ deprecatedCount,
361
+ };
362
+ }
363
+ //# sourceMappingURL=context-graph.js.map
@@ -0,0 +1,33 @@
1
+ /**
2
+ * O(1) in-memory entity lookup index (F-55).
3
+ *
4
+ * Primary key: `${canonicalName}:${type}` — for name+type lookups.
5
+ * Secondary key: entity UUID — for ID-based lookups (used in health projection).
6
+ *
7
+ * Both maps are kept in sync. Built once at session_start from
8
+ * layer2-entity.jsonl (O(N)). Updated in-place on every create or update.
9
+ */
10
+ import type { EntityNode, EntityType } from "./entity.js";
11
+ export type EntityIndexKey = `${string}:${EntityType}`;
12
+ export declare class EntityIndex {
13
+ private nameMap;
14
+ private idMap;
15
+ /**
16
+ * Populate the index from all records read from disk.
17
+ * Called once during session_start (O(N) — acceptable).
18
+ */
19
+ build(nodes: EntityNode[]): void;
20
+ /** O(1) lookup by canonical name and type (AC-55-01). */
21
+ get(canonicalName: string, type: EntityType): EntityNode | undefined;
22
+ /** O(1) lookup by entity UUID (used by projectHealth). */
23
+ getById(id: string): EntityNode | undefined;
24
+ /** Register a new entity in the index without a full rebuild (AC-55-02). */
25
+ set(node: EntityNode): void;
26
+ /** Update an existing entity's in-memory state in-place. */
27
+ update(id: string, patch: Partial<EntityNode>): void;
28
+ /** Iterate all nodes (used by orphan detection). */
29
+ values(): IterableIterator<EntityNode>;
30
+ /** Number of entities currently indexed. */
31
+ get size(): number;
32
+ }
33
+ //# sourceMappingURL=entity-index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entity-index.d.ts","sourceRoot":"","sources":["../../src/core/entity-index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE1D,MAAM,MAAM,cAAc,GAAG,GAAG,MAAM,IAAI,UAAU,EAAE,CAAC;AAMvD,qBAAa,WAAW;IACvB,OAAO,CAAC,OAAO,CAA8C;IAC7D,OAAO,CAAC,KAAK,CAAsC;IAEnD;;;OAGG;IACH,KAAK,CAAC,KAAK,EAAE,UAAU,EAAE,GAAG,IAAI,CAO/B;IAED,yDAAyD;IACzD,GAAG,CAAC,aAAa,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,GAAG,UAAU,GAAG,SAAS,CAEnE;IAED,0DAA0D;IAC1D,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS,CAE1C;IAED,4EAA4E;IAC5E,GAAG,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,CAG1B;IAED,4DAA4D;IAC5D,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,CAAC,UAAU,CAAC,GAAG,IAAI,CAMnD;IAED,oDAAoD;IACpD,MAAM,IAAI,gBAAgB,CAAC,UAAU,CAAC,CAErC;IAED,4CAA4C;IAC5C,IAAI,IAAI,IAAI,MAAM,CAEjB;CACD","sourcesContent":["/**\n * O(1) in-memory entity lookup index (F-55).\n *\n * Primary key: `${canonicalName}:${type}` — for name+type lookups.\n * Secondary key: entity UUID — for ID-based lookups (used in health projection).\n *\n * Both maps are kept in sync. Built once at session_start from\n * layer2-entity.jsonl (O(N)). Updated in-place on every create or update.\n */\n\nimport type { EntityNode, EntityType } from \"./entity.js\";\n\nexport type EntityIndexKey = `${string}:${EntityType}`;\n\nfunction indexKey(canonicalName: string, type: EntityType): EntityIndexKey {\n\treturn `${canonicalName}:${type}` as EntityIndexKey;\n}\n\nexport class EntityIndex {\n\tprivate nameMap: Map<EntityIndexKey, EntityNode> = new Map();\n\tprivate idMap: Map<string, EntityNode> = new Map();\n\n\t/**\n\t * Populate the index from all records read from disk.\n\t * Called once during session_start (O(N) — acceptable).\n\t */\n\tbuild(nodes: EntityNode[]): void {\n\t\tthis.nameMap.clear();\n\t\tthis.idMap.clear();\n\t\tfor (const node of nodes) {\n\t\t\tthis.nameMap.set(indexKey(node.canonicalName, node.type), node);\n\t\t\tthis.idMap.set(node.id, node);\n\t\t}\n\t}\n\n\t/** O(1) lookup by canonical name and type (AC-55-01). */\n\tget(canonicalName: string, type: EntityType): EntityNode | undefined {\n\t\treturn this.nameMap.get(indexKey(canonicalName, type));\n\t}\n\n\t/** O(1) lookup by entity UUID (used by projectHealth). */\n\tgetById(id: string): EntityNode | undefined {\n\t\treturn this.idMap.get(id);\n\t}\n\n\t/** Register a new entity in the index without a full rebuild (AC-55-02). */\n\tset(node: EntityNode): void {\n\t\tthis.nameMap.set(indexKey(node.canonicalName, node.type), node);\n\t\tthis.idMap.set(node.id, node);\n\t}\n\n\t/** Update an existing entity's in-memory state in-place. */\n\tupdate(id: string, patch: Partial<EntityNode>): void {\n\t\tconst node = this.idMap.get(id);\n\t\tif (!node) return;\n\t\tconst updated = { ...node, ...patch };\n\t\tthis.nameMap.set(indexKey(node.canonicalName, node.type), updated);\n\t\tthis.idMap.set(id, updated);\n\t}\n\n\t/** Iterate all nodes (used by orphan detection). */\n\tvalues(): IterableIterator<EntityNode> {\n\t\treturn this.idMap.values();\n\t}\n\n\t/** Number of entities currently indexed. */\n\tget size(): number {\n\t\treturn this.idMap.size;\n\t}\n}\n"]}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * O(1) in-memory entity lookup index (F-55).
3
+ *
4
+ * Primary key: `${canonicalName}:${type}` — for name+type lookups.
5
+ * Secondary key: entity UUID — for ID-based lookups (used in health projection).
6
+ *
7
+ * Both maps are kept in sync. Built once at session_start from
8
+ * layer2-entity.jsonl (O(N)). Updated in-place on every create or update.
9
+ */
10
+ function indexKey(canonicalName, type) {
11
+ return `${canonicalName}:${type}`;
12
+ }
13
+ export class EntityIndex {
14
+ nameMap = new Map();
15
+ idMap = new Map();
16
+ /**
17
+ * Populate the index from all records read from disk.
18
+ * Called once during session_start (O(N) — acceptable).
19
+ */
20
+ build(nodes) {
21
+ this.nameMap.clear();
22
+ this.idMap.clear();
23
+ for (const node of nodes) {
24
+ this.nameMap.set(indexKey(node.canonicalName, node.type), node);
25
+ this.idMap.set(node.id, node);
26
+ }
27
+ }
28
+ /** O(1) lookup by canonical name and type (AC-55-01). */
29
+ get(canonicalName, type) {
30
+ return this.nameMap.get(indexKey(canonicalName, type));
31
+ }
32
+ /** O(1) lookup by entity UUID (used by projectHealth). */
33
+ getById(id) {
34
+ return this.idMap.get(id);
35
+ }
36
+ /** Register a new entity in the index without a full rebuild (AC-55-02). */
37
+ set(node) {
38
+ this.nameMap.set(indexKey(node.canonicalName, node.type), node);
39
+ this.idMap.set(node.id, node);
40
+ }
41
+ /** Update an existing entity's in-memory state in-place. */
42
+ update(id, patch) {
43
+ const node = this.idMap.get(id);
44
+ if (!node)
45
+ return;
46
+ const updated = { ...node, ...patch };
47
+ this.nameMap.set(indexKey(node.canonicalName, node.type), updated);
48
+ this.idMap.set(id, updated);
49
+ }
50
+ /** Iterate all nodes (used by orphan detection). */
51
+ values() {
52
+ return this.idMap.values();
53
+ }
54
+ /** Number of entities currently indexed. */
55
+ get size() {
56
+ return this.idMap.size;
57
+ }
58
+ }
59
+ //# sourceMappingURL=entity-index.js.map
@@ -0,0 +1,56 @@
1
+ /**
2
+ * EntityNode schema, EventVector encoding, and validity scoring (F-54).
3
+ *
4
+ * Validity uses exponential decay so that recent confirmed events raise
5
+ * a score while stale or deprecated ones lower it — no binary threshold.
6
+ */
7
+ export type EntityType = "file" | "person" | "concept" | "module" | "decision" | "url";
8
+ /**
9
+ * Fixed-width tuple: [eventType, timestamp, prevNameHash, newNameHash, sessionOrdinal]
10
+ *
11
+ * eventType encoding:
12
+ * 1 = confirmed (+1.0 contribution)
13
+ * 2 = renamed (+0.5 contribution)
14
+ * 3 = deprecated (-1.0 contribution)
15
+ * 4 = replaced (-0.5 contribution)
16
+ */
17
+ export type EventVector = [
18
+ eventType: 1 | 2 | 3 | 4,
19
+ timestamp: number,
20
+ prevNameHash: number,
21
+ newNameHash: number,
22
+ sessionOrdinal: number
23
+ ];
24
+ export interface EntityEdge {
25
+ predicate: "replaced_by" | "alias_of" | "derived_from";
26
+ targetId: string;
27
+ }
28
+ export interface EntityNode {
29
+ id: string;
30
+ type: EntityType;
31
+ canonicalName: string;
32
+ currentNameHash: number;
33
+ eventMatrix: EventVector[];
34
+ lastReferencedAt: number;
35
+ edges: EntityEdge[];
36
+ /** Set by orphan detection when the entity is moved to archive storage. */
37
+ _archived?: boolean;
38
+ }
39
+ /**
40
+ * Compute validity score for an entity's event matrix at a given time.
41
+ *
42
+ * score = Σ CONTRIBUTION[eventType] × exp(−λ × age)
43
+ *
44
+ * Validity gradient:
45
+ * > 0.7 → trusted
46
+ * 0.3–0.7 → uncertain
47
+ * 0–0.3 → degraded
48
+ * < 0 → deprecated signal dominates
49
+ */
50
+ export declare function validityScore(matrix: EventVector[], type: EntityType, now: number): number;
51
+ import type { IMemoryStorage } from "../storage/adapter.js";
52
+ export declare function createEntity(storage: IMemoryStorage, node: EntityNode): Promise<void>;
53
+ export declare function updateEntity(storage: IMemoryStorage, id: string, patch: Partial<EntityNode>): Promise<void>;
54
+ export declare function loadAllEntities(storage: IMemoryStorage): Promise<EntityNode[]>;
55
+ export declare function nameHash(name: string): number;
56
+ //# sourceMappingURL=entity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entity.d.ts","sourceRoot":"","sources":["../../src/core/entity.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAMH,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,GAAG,QAAQ,GAAG,UAAU,GAAG,KAAK,CAAC;AAEvF;;;;;;;;GAQG;AACH,MAAM,MAAM,WAAW,GAAG;IACzB,SAAS,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC;IACxB,SAAS,EAAE,MAAM;IACjB,YAAY,EAAE,MAAM;IACpB,WAAW,EAAE,MAAM;IACnB,cAAc,EAAE,MAAM;CACtB,CAAC;AAEF,MAAM,WAAW,UAAU;IAC1B,SAAS,EAAE,aAAa,GAAG,UAAU,GAAG,cAAc,CAAC;IACvD,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,UAAU;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,UAAU,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,WAAW,EAAE,CAAC;IAC3B,gBAAgB,EAAE,MAAM,CAAC;IACzB,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,2EAA2E;IAC3E,SAAS,CAAC,EAAE,OAAO,CAAC;CACpB;AAyBD;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,WAAW,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAQ1F;AAOD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAE5D,wBAAsB,YAAY,CAAC,OAAO,EAAE,cAAc,EAAE,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAE3F;AAED,wBAAsB,YAAY,CAAC,OAAO,EAAE,cAAc,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,CAAC,UAAU,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAEjH;AAED,wBAAsB,eAAe,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,CAEpF;AAMD,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAM7C","sourcesContent":["/**\n * EntityNode schema, EventVector encoding, and validity scoring (F-54).\n *\n * Validity uses exponential decay so that recent confirmed events raise\n * a score while stale or deprecated ones lower it — no binary threshold.\n */\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport type EntityType = \"file\" | \"person\" | \"concept\" | \"module\" | \"decision\" | \"url\";\n\n/**\n * Fixed-width tuple: [eventType, timestamp, prevNameHash, newNameHash, sessionOrdinal]\n *\n * eventType encoding:\n * 1 = confirmed (+1.0 contribution)\n * 2 = renamed (+0.5 contribution)\n * 3 = deprecated (-1.0 contribution)\n * 4 = replaced (-0.5 contribution)\n */\nexport type EventVector = [\n\teventType: 1 | 2 | 3 | 4,\n\ttimestamp: number,\n\tprevNameHash: number,\n\tnewNameHash: number,\n\tsessionOrdinal: number,\n];\n\nexport interface EntityEdge {\n\tpredicate: \"replaced_by\" | \"alias_of\" | \"derived_from\";\n\ttargetId: string;\n}\n\nexport interface EntityNode {\n\tid: string;\n\ttype: EntityType;\n\tcanonicalName: string;\n\tcurrentNameHash: number;\n\teventMatrix: EventVector[];\n\tlastReferencedAt: number;\n\tedges: EntityEdge[];\n\t/** Set by orphan detection when the entity is moved to archive storage. */\n\t_archived?: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Validity scoring\n// ---------------------------------------------------------------------------\n\n/** TTL expressed as λ for exponential decay: ln(2) / half-life-in-ms */\nconst MS_PER_DAY = 86_400_000;\n\nconst LAMBDA: Record<EntityType, number> = {\n\tfile: Math.LN2 / (7 * MS_PER_DAY),\n\tmodule: Math.LN2 / (14 * MS_PER_DAY),\n\turl: Math.LN2 / (14 * MS_PER_DAY),\n\tperson: Math.LN2 / (30 * MS_PER_DAY),\n\tconcept: Math.LN2 / (90 * MS_PER_DAY),\n\tdecision: Math.LN2 / (90 * MS_PER_DAY),\n};\n\nconst CONTRIBUTION: Record<1 | 2 | 3 | 4, number> = {\n\t1: +1.0,\n\t2: +0.5,\n\t3: -1.0,\n\t4: -0.5,\n};\n\n/**\n * Compute validity score for an entity's event matrix at a given time.\n *\n * score = Σ CONTRIBUTION[eventType] × exp(−λ × age)\n *\n * Validity gradient:\n * > 0.7 → trusted\n * 0.3–0.7 → uncertain\n * 0–0.3 → degraded\n * < 0 → deprecated signal dominates\n */\nexport function validityScore(matrix: EventVector[], type: EntityType, now: number): number {\n\tconst λ = LAMBDA[type];\n\tlet score = 0;\n\tfor (const [eventType, timestamp] of matrix) {\n\t\tconst age = now - timestamp;\n\t\tscore += CONTRIBUTION[eventType as 1 | 2 | 3 | 4] * Math.exp(-λ * age);\n\t}\n\treturn score;\n}\n\n// ---------------------------------------------------------------------------\n// CRUD helpers (over IMemoryStorage — F-53 storage primitives)\n// Adapted for @chili/memory: uses IMemoryStorage instead of direct file paths.\n// ---------------------------------------------------------------------------\n\nimport type { IMemoryStorage } from \"../storage/adapter.js\";\n\nexport async function createEntity(storage: IMemoryStorage, node: EntityNode): Promise<void> {\n\tawait storage.append<EntityNode>(\"entities\", node);\n}\n\nexport async function updateEntity(storage: IMemoryStorage, id: string, patch: Partial<EntityNode>): Promise<void> {\n\tawait storage.update<EntityNode>(\"entities\", id, patch);\n}\n\nexport async function loadAllEntities(storage: IMemoryStorage): Promise<EntityNode[]> {\n\treturn storage.readAll<EntityNode>(\"entities\");\n}\n\n// ---------------------------------------------------------------------------\n// Simple djb2 name hash (deterministic, collision-tolerant for audit trail)\n// ---------------------------------------------------------------------------\n\nexport function nameHash(name: string): number {\n\tlet hash = 5381;\n\tfor (let i = 0; i < name.length; i++) {\n\t\thash = ((hash << 5) + hash + name.charCodeAt(i)) >>> 0;\n\t}\n\treturn hash;\n}\n"]}
@@ -0,0 +1,65 @@
1
+ /**
2
+ * EntityNode schema, EventVector encoding, and validity scoring (F-54).
3
+ *
4
+ * Validity uses exponential decay so that recent confirmed events raise
5
+ * a score while stale or deprecated ones lower it — no binary threshold.
6
+ */
7
+ // ---------------------------------------------------------------------------
8
+ // Validity scoring
9
+ // ---------------------------------------------------------------------------
10
+ /** TTL expressed as λ for exponential decay: ln(2) / half-life-in-ms */
11
+ const MS_PER_DAY = 86_400_000;
12
+ const LAMBDA = {
13
+ file: Math.LN2 / (7 * MS_PER_DAY),
14
+ module: Math.LN2 / (14 * MS_PER_DAY),
15
+ url: Math.LN2 / (14 * MS_PER_DAY),
16
+ person: Math.LN2 / (30 * MS_PER_DAY),
17
+ concept: Math.LN2 / (90 * MS_PER_DAY),
18
+ decision: Math.LN2 / (90 * MS_PER_DAY),
19
+ };
20
+ const CONTRIBUTION = {
21
+ 1: +1.0,
22
+ 2: +0.5,
23
+ 3: -1.0,
24
+ 4: -0.5,
25
+ };
26
+ /**
27
+ * Compute validity score for an entity's event matrix at a given time.
28
+ *
29
+ * score = Σ CONTRIBUTION[eventType] × exp(−λ × age)
30
+ *
31
+ * Validity gradient:
32
+ * > 0.7 → trusted
33
+ * 0.3–0.7 → uncertain
34
+ * 0–0.3 → degraded
35
+ * < 0 → deprecated signal dominates
36
+ */
37
+ export function validityScore(matrix, type, now) {
38
+ const λ = LAMBDA[type];
39
+ let score = 0;
40
+ for (const [eventType, timestamp] of matrix) {
41
+ const age = now - timestamp;
42
+ score += CONTRIBUTION[eventType] * Math.exp(-λ * age);
43
+ }
44
+ return score;
45
+ }
46
+ export async function createEntity(storage, node) {
47
+ await storage.append("entities", node);
48
+ }
49
+ export async function updateEntity(storage, id, patch) {
50
+ await storage.update("entities", id, patch);
51
+ }
52
+ export async function loadAllEntities(storage) {
53
+ return storage.readAll("entities");
54
+ }
55
+ // ---------------------------------------------------------------------------
56
+ // Simple djb2 name hash (deterministic, collision-tolerant for audit trail)
57
+ // ---------------------------------------------------------------------------
58
+ export function nameHash(name) {
59
+ let hash = 5381;
60
+ for (let i = 0; i < name.length; i++) {
61
+ hash = ((hash << 5) + hash + name.charCodeAt(i)) >>> 0;
62
+ }
63
+ return hash;
64
+ }
65
+ //# sourceMappingURL=entity.js.map