@chiligpt/memory 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/dist/core/backfill.d.ts +78 -0
- package/dist/core/backfill.d.ts.map +1 -0
- package/dist/core/backfill.js +155 -0
- package/dist/core/context-graph.d.ts +73 -0
- package/dist/core/context-graph.d.ts.map +1 -0
- package/dist/core/context-graph.js +363 -0
- package/dist/core/entity-index.d.ts +33 -0
- package/dist/core/entity-index.d.ts.map +1 -0
- package/dist/core/entity-index.js +59 -0
- package/dist/core/entity.d.ts +56 -0
- package/dist/core/entity.d.ts.map +1 -0
- package/dist/core/entity.js +65 -0
- package/dist/core/narrative.d.ts +110 -0
- package/dist/core/narrative.d.ts.map +1 -0
- package/dist/core/narrative.js +765 -0
- package/dist/core/read-path.d.ts +53 -0
- package/dist/core/read-path.d.ts.map +1 -0
- package/dist/core/read-path.js +592 -0
- package/dist/core/session-tree-index.d.ts +69 -0
- package/dist/core/session-tree-index.d.ts.map +1 -0
- package/dist/core/session-tree-index.js +131 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/service.d.ts +105 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +465 -0
- package/dist/storage/adapter.d.ts +61 -0
- package/dist/storage/adapter.d.ts.map +1 -0
- package/dist/storage/adapter.js +14 -0
- package/dist/storage/file-utils.d.ts +7 -0
- package/dist/storage/file-utils.d.ts.map +1 -0
- package/dist/storage/file-utils.js +37 -0
- package/dist/storage/in-memory-entry.d.ts +5 -0
- package/dist/storage/in-memory-entry.d.ts.map +1 -0
- package/dist/storage/in-memory-entry.js +5 -0
- package/dist/storage/in-memory.d.ts +37 -0
- package/dist/storage/in-memory.d.ts.map +1 -0
- package/dist/storage/in-memory.js +54 -0
- package/dist/storage/jsonl-entry.d.ts +6 -0
- package/dist/storage/jsonl-entry.d.ts.map +1 -0
- package/dist/storage/jsonl-entry.js +6 -0
- package/dist/storage/jsonl.d.ts +32 -0
- package/dist/storage/jsonl.d.ts.map +1 -0
- package/dist/storage/jsonl.js +171 -0
- package/dist/types.d.ts +66 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/package.json +46 -0
|
@@ -0,0 +1,592 @@
|
|
|
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 { validityScore } from "./entity.js";
|
|
6
|
+
import { loadContextNodeWithSupersession, loadAllContextNodes, projectHealth } from "./context-graph.js";
|
|
7
|
+
import { tokenizeBM25 } from "./narrative.js";
|
|
8
|
+
import { loadSessionTree, resolveSessionFile } from "./session-tree-index.js";
|
|
9
|
+
import { readJsonlFile } from "../storage/file-utils.js";
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Injection formatting
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
const CHARS_PER_TOKEN_APPROX = 4;
|
|
14
|
+
function validityLabel(score) {
|
|
15
|
+
if (score > 0.7)
|
|
16
|
+
return "trusted";
|
|
17
|
+
if (score >= 0.3)
|
|
18
|
+
return "uncertain";
|
|
19
|
+
if (score >= 0)
|
|
20
|
+
return "degraded";
|
|
21
|
+
return "deprecated";
|
|
22
|
+
}
|
|
23
|
+
function formatContextNodeBlock(node, superseded, entityIndex, now) {
|
|
24
|
+
const date = new Date(node.timestamp).toISOString().split("T")[0];
|
|
25
|
+
const health = projectHealth(node, entityIndex, now);
|
|
26
|
+
const entityLines = [];
|
|
27
|
+
for (const id of node.entityPointerIds) {
|
|
28
|
+
const entity = entityIndex.getById(id);
|
|
29
|
+
if (!entity)
|
|
30
|
+
continue;
|
|
31
|
+
const score = validityScore(entity.eventMatrix, entity.type, now);
|
|
32
|
+
const daysAgo = Math.floor((now - entity.lastReferencedAt) / 86_400_000);
|
|
33
|
+
entityLines.push(`[ENTITY: ${entity.canonicalName} | ${entity.type} | validity: ${score.toFixed(2)} (${validityLabel(score)}) | ref: ${daysAgo}d ago]`);
|
|
34
|
+
}
|
|
35
|
+
const stalenessWarning = health.degradedCount > 0 || health.deprecatedCount > 0
|
|
36
|
+
? ` [STALE: ${health.degradedCount} degraded, ${health.deprecatedCount} deprecated entities]`
|
|
37
|
+
: "";
|
|
38
|
+
const lines = [
|
|
39
|
+
`[RETRIEVED CONTEXT: ${node.sessionId}, ${date}]${stalenessWarning}`,
|
|
40
|
+
`User: ${node.userContext}`,
|
|
41
|
+
`Agent: ${node.agentContext}`,
|
|
42
|
+
];
|
|
43
|
+
if (entityLines.length > 0) {
|
|
44
|
+
lines.push(...entityLines);
|
|
45
|
+
}
|
|
46
|
+
if (superseded) {
|
|
47
|
+
lines.push(`[SUPERSEDES: ${superseded.sessionId}, ${new Date(superseded.timestamp).toISOString().split("T")[0]}]`);
|
|
48
|
+
lines.push(` User: ${superseded.userContext}`);
|
|
49
|
+
}
|
|
50
|
+
return lines.join("\n");
|
|
51
|
+
}
|
|
52
|
+
function estimateTokens(text) {
|
|
53
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN_APPROX);
|
|
54
|
+
}
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// F-109: Query expansion via LLM before BM25F
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
const QUERY_EXPANSION_SYSTEM_PROMPT = "You are a search query assistant. Rewrite the given query as alternative phrasings. Respond with JSON only.";
|
|
59
|
+
/**
|
|
60
|
+
* Expands a user query into up to 5 alternative phrasings using one LLM call.
|
|
61
|
+
* Returns the original query deduped with expansions. Best-effort — falls back
|
|
62
|
+
* to the original query on any failure.
|
|
63
|
+
*/
|
|
64
|
+
async function expandQuery(topic, complete) {
|
|
65
|
+
try {
|
|
66
|
+
const messages = [{
|
|
67
|
+
role: "user",
|
|
68
|
+
timestamp: Date.now(),
|
|
69
|
+
content: `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>"] }`,
|
|
70
|
+
}];
|
|
71
|
+
const response = await complete(messages, { systemPrompt: QUERY_EXPANSION_SYSTEM_PROMPT });
|
|
72
|
+
const textBlock = Array.isArray(response.content)
|
|
73
|
+
? response.content.find((b) => b.type === "text")
|
|
74
|
+
: null;
|
|
75
|
+
const raw = textBlock ? textBlock.text : "";
|
|
76
|
+
const cleaned = raw.replace(/^```(?:json)?\n?/m, "").replace(/\n?```$/m, "").trim();
|
|
77
|
+
const parsed = JSON.parse(cleaned);
|
|
78
|
+
if (Array.isArray(parsed.terms)) {
|
|
79
|
+
const terms = parsed.terms.filter((t) => typeof t === "string" && t.length > 0);
|
|
80
|
+
return [...new Set([topic, ...terms])].slice(0, 5);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Non-fatal — original query used as sole term
|
|
85
|
+
}
|
|
86
|
+
return [topic];
|
|
87
|
+
}
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// FIX-01: rankHubsByQuery — BM25F ranked retrieval replacing matchHubsByTopic
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
/**
|
|
92
|
+
* Returns hubs ranked by BM25F score for the query (via NarrativeCache.rankByQuery).
|
|
93
|
+
* Falls back to full-text substring scan when the BM25 index is not yet built
|
|
94
|
+
* (cold start, stub hubs before first buildIndex call).
|
|
95
|
+
*/
|
|
96
|
+
function rankHubsByQuery(topic, narrativeCache, cwd) {
|
|
97
|
+
const ranked = narrativeCache.rankByQuery(topic, cwd);
|
|
98
|
+
if (ranked.length > 0)
|
|
99
|
+
return ranked;
|
|
100
|
+
// Fallback: basic full-text substring scan for cold-start safety
|
|
101
|
+
const q = topic.toLowerCase();
|
|
102
|
+
return narrativeCache.all().filter((h) => h.topics.some((t) => t.toLowerCase().includes(q) || q.includes(t.toLowerCase())) ||
|
|
103
|
+
h.title.toLowerCase().includes(q) ||
|
|
104
|
+
h.summary.toLowerCase().includes(q));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Finds ContextNodes not assigned to any hub and filters by topic overlap.
|
|
108
|
+
* FIX-01: uses same code-aware tokenizer as BM25F ranking for consistency.
|
|
109
|
+
*/
|
|
110
|
+
async function scanOrphanNodes(topic, storage, narrativeCache) {
|
|
111
|
+
const queryTokens = tokenizeBM25(topic);
|
|
112
|
+
const q = topic.toLowerCase();
|
|
113
|
+
const allSpokeIds = new Set(narrativeCache.all().flatMap((h) => h.spokeNodeIds));
|
|
114
|
+
const allNodes = await loadAllContextNodes(storage);
|
|
115
|
+
const orphanNodes = allNodes.filter((n) => !allSpokeIds.has(n.id));
|
|
116
|
+
const orphanMatches = orphanNodes
|
|
117
|
+
.filter((n) => {
|
|
118
|
+
if (queryTokens.length > 0) {
|
|
119
|
+
// Token-based overlap match
|
|
120
|
+
const nodeTokens = new Set(tokenizeBM25(n.topics.join(" ")));
|
|
121
|
+
return queryTokens.some((t) => nodeTokens.has(t));
|
|
122
|
+
}
|
|
123
|
+
// Fallback: substring match for very short / symbol queries
|
|
124
|
+
return n.topics.some((t) => t.toLowerCase().includes(q) || q.includes(t.toLowerCase()));
|
|
125
|
+
})
|
|
126
|
+
.sort((a, b) => b.timestamp - a.timestamp)
|
|
127
|
+
.slice(0, 5);
|
|
128
|
+
return { orphanMatches, totalOrphans: orphanNodes.length };
|
|
129
|
+
}
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// F-65: Session start context retrieval
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
/**
|
|
134
|
+
* Retrieve context nodes relevant to the current session (AC-65-01, AC-65-02, AC-65-03).
|
|
135
|
+
* Called once at before_agent_start on the first agent turn.
|
|
136
|
+
*/
|
|
137
|
+
export async function buildSessionStartInjection(params) {
|
|
138
|
+
const { storage, entityIndex, narrativeCache, cwd, sessionPrompt } = params;
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
const hubs = narrativeCache.all();
|
|
141
|
+
if (hubs.length === 0)
|
|
142
|
+
return null;
|
|
143
|
+
// F-105: Use BM25F rankByQuery instead of naive substring match so semantically
|
|
144
|
+
// relevant hubs surface even when the session prompt shares no exact topic keywords.
|
|
145
|
+
const querySignal = [sessionPrompt ?? "", cwd.split("/").pop() ?? ""].filter(Boolean).join(" ");
|
|
146
|
+
let matchingHubs = querySignal.trim()
|
|
147
|
+
? rankHubsByQuery(querySignal, narrativeCache, cwd)
|
|
148
|
+
: [];
|
|
149
|
+
// Cold-start heuristic: fall back to most recently active hub when BM25F returns nothing
|
|
150
|
+
if (matchingHubs.length === 0 && hubs.length > 0) {
|
|
151
|
+
matchingHubs = [hubs[0]]; // already sorted by latestSpokeAt ?? updatedAt
|
|
152
|
+
}
|
|
153
|
+
const blocks = [];
|
|
154
|
+
const seenContextIds = new Set();
|
|
155
|
+
for (const hub of matchingHubs) {
|
|
156
|
+
// Get recent spokes (up to 3 per hub)
|
|
157
|
+
for (const spokeId of hub.spokeNodeIds.slice(-3).reverse()) {
|
|
158
|
+
if (seenContextIds.has(spokeId))
|
|
159
|
+
continue;
|
|
160
|
+
seenContextIds.add(spokeId);
|
|
161
|
+
// AC-65-03: always use loadContextNodeWithSupersession
|
|
162
|
+
const [node, superseded] = await loadContextNodeWithSupersession(storage, spokeId);
|
|
163
|
+
if (!node)
|
|
164
|
+
continue;
|
|
165
|
+
// Update lastReferencedAt on entities (AC-69-03 pattern)
|
|
166
|
+
for (const id of node.entityPointerIds) {
|
|
167
|
+
const entity = entityIndex.getById(id);
|
|
168
|
+
if (entity) {
|
|
169
|
+
entityIndex.update(id, { lastReferencedAt: now });
|
|
170
|
+
await storage.update("entities", id, { lastReferencedAt: now });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
blocks.push(formatContextNodeBlock(node, superseded, entityIndex, now));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (blocks.length === 0)
|
|
177
|
+
return null;
|
|
178
|
+
return `[MEMORY: Session Start Context]\n\n${blocks.join("\n\n")}`;
|
|
179
|
+
}
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// F-66: Per-turn entity-driven injection
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Regex patterns for entity mention extraction (no LLM — AC-66-03)
|
|
184
|
+
const FILENAME_PATTERN = /(?:^|\s)([\w./-]+\.\w{1,6})(?:\s|$)/gm;
|
|
185
|
+
const CAMEL_CASE_PATTERN = /\b([A-Z][a-z]+(?:[A-Z][a-z]+)+)\b/g;
|
|
186
|
+
const QUOTED_PATTERN = /["'`]([^"'`\n]{2,50})["'`]/g;
|
|
187
|
+
function extractEntityMentions(text) {
|
|
188
|
+
const mentions = new Set();
|
|
189
|
+
let m;
|
|
190
|
+
const fn = new RegExp(FILENAME_PATTERN.source, FILENAME_PATTERN.flags);
|
|
191
|
+
while ((m = fn.exec(text)) !== null)
|
|
192
|
+
mentions.add(m[1]);
|
|
193
|
+
const cc = new RegExp(CAMEL_CASE_PATTERN.source, CAMEL_CASE_PATTERN.flags);
|
|
194
|
+
while ((m = cc.exec(text)) !== null)
|
|
195
|
+
mentions.add(m[1]);
|
|
196
|
+
const qt = new RegExp(QUOTED_PATTERN.source, QUOTED_PATTERN.flags);
|
|
197
|
+
while ((m = qt.exec(text)) !== null)
|
|
198
|
+
mentions.add(m[1]);
|
|
199
|
+
return [...mentions];
|
|
200
|
+
}
|
|
201
|
+
const MAX_PER_TURN_TOKENS = 1_500;
|
|
202
|
+
/**
|
|
203
|
+
* Build per-turn entity-driven injection block (AC-66-01, AC-66-02, AC-66-03).
|
|
204
|
+
*/
|
|
205
|
+
export async function buildPerTurnInjection(params) {
|
|
206
|
+
const { userMessage, storage, entityIndex } = params;
|
|
207
|
+
const now = Date.now();
|
|
208
|
+
const mentions = extractEntityMentions(userMessage);
|
|
209
|
+
if (mentions.length === 0)
|
|
210
|
+
return null;
|
|
211
|
+
// Find all context nodes referencing the mentioned entities
|
|
212
|
+
const contextNodeIds = new Set();
|
|
213
|
+
const healthMap = new Map(); // contextNodeId -> meanValidity
|
|
214
|
+
for (const mention of mentions) {
|
|
215
|
+
// Try all entity types for this mention name
|
|
216
|
+
for (const type of ["file", "module", "concept", "decision", "person", "url"]) {
|
|
217
|
+
const entity = entityIndex.get(mention, type);
|
|
218
|
+
if (!entity)
|
|
219
|
+
continue;
|
|
220
|
+
// Update lastReferencedAt
|
|
221
|
+
entityIndex.update(entity.id, { lastReferencedAt: now });
|
|
222
|
+
await storage.update("entities", entity.id, { lastReferencedAt: now });
|
|
223
|
+
// Find context nodes referencing this entity
|
|
224
|
+
const allNodes = await loadAllContextNodes(storage);
|
|
225
|
+
for (const node of allNodes) {
|
|
226
|
+
if (node.entityPointerIds.includes(entity.id)) {
|
|
227
|
+
contextNodeIds.add(node.id);
|
|
228
|
+
if (!healthMap.has(node.id)) {
|
|
229
|
+
const h = projectHealth(node, entityIndex, now);
|
|
230
|
+
healthMap.set(node.id, h.meanValidity);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (contextNodeIds.size === 0)
|
|
237
|
+
return null;
|
|
238
|
+
// Sort by meanValidity descending
|
|
239
|
+
const sortedIds = [...contextNodeIds].sort((a, b) => (healthMap.get(b) ?? 0) - (healthMap.get(a) ?? 0));
|
|
240
|
+
const blocks = [];
|
|
241
|
+
let totalTokens = 0;
|
|
242
|
+
let truncated = false;
|
|
243
|
+
for (const id of sortedIds) {
|
|
244
|
+
const [node, superseded] = await loadContextNodeWithSupersession(storage, id);
|
|
245
|
+
if (!node)
|
|
246
|
+
continue;
|
|
247
|
+
const block = formatContextNodeBlock(node, superseded, entityIndex, now);
|
|
248
|
+
const blockTokens = estimateTokens(block);
|
|
249
|
+
if (totalTokens + blockTokens > MAX_PER_TURN_TOKENS) {
|
|
250
|
+
truncated = true;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
blocks.push(block);
|
|
254
|
+
totalTokens += blockTokens;
|
|
255
|
+
}
|
|
256
|
+
if (blocks.length === 0)
|
|
257
|
+
return null;
|
|
258
|
+
let result = `[MEMORY: Entity Context]\n\n${blocks.join("\n\n")}`;
|
|
259
|
+
if (truncated) {
|
|
260
|
+
result += "\n\n[MEMORY: Additional context truncated — token budget reached]";
|
|
261
|
+
console.warn("[memory] Per-turn injection truncated to fit token budget");
|
|
262
|
+
}
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// F-69: Active query tools
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
const MAX_QUERY_TOKENS = 2_000;
|
|
269
|
+
/**
|
|
270
|
+
* memory_entity_lookup: O(1) entity lookup + context node retrieval (AC-69-01, AC-69-03).
|
|
271
|
+
* Falls back to searching all entity types when the specified type yields no match.
|
|
272
|
+
*/
|
|
273
|
+
export async function memoryEntityLookup(name, type, storage, entityIndex) {
|
|
274
|
+
const now = Date.now();
|
|
275
|
+
let entity = entityIndex.get(name, type);
|
|
276
|
+
// Type mismatch fallback: the agent may call with a different type than what was
|
|
277
|
+
// extracted (e.g. "python" as "concept" when it was indexed as "module").
|
|
278
|
+
if (!entity) {
|
|
279
|
+
const allTypes = ["file", "module", "concept", "decision", "person", "url"];
|
|
280
|
+
for (const t of allTypes) {
|
|
281
|
+
if (t === type)
|
|
282
|
+
continue;
|
|
283
|
+
const candidate = entityIndex.get(name, t);
|
|
284
|
+
if (candidate) {
|
|
285
|
+
entity = candidate;
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (!entity) {
|
|
291
|
+
return `[MEMORY: No record of entity "${name}" (${type}). May predate memory system or not yet indexed.]`;
|
|
292
|
+
}
|
|
293
|
+
// AC-69-03: Update lastReferencedAt
|
|
294
|
+
entityIndex.update(entity.id, { lastReferencedAt: now });
|
|
295
|
+
await storage.update("entities", entity.id, { lastReferencedAt: now });
|
|
296
|
+
const score = validityScore(entity.eventMatrix, entity.type, now);
|
|
297
|
+
const lines = [
|
|
298
|
+
`[MEMORY: Entity — ${entity.canonicalName} | ${entity.type}]`,
|
|
299
|
+
`Validity: ${score.toFixed(2)} (${validityLabel(score)})`,
|
|
300
|
+
"",
|
|
301
|
+
];
|
|
302
|
+
// Retrieve all context nodes referencing this entity, newest first
|
|
303
|
+
const allNodes = (await loadAllContextNodes(storage))
|
|
304
|
+
.filter((n) => n.entityPointerIds.includes(entity.id))
|
|
305
|
+
.sort((a, b) => b.timestamp - a.timestamp);
|
|
306
|
+
const blocks = [];
|
|
307
|
+
let totalTokens = estimateTokens(lines.join("\n"));
|
|
308
|
+
for (const node of allNodes) {
|
|
309
|
+
// AC-69-03: Update lastReferencedAt on context node entities
|
|
310
|
+
for (const id of node.entityPointerIds) {
|
|
311
|
+
const e = entityIndex.getById(id);
|
|
312
|
+
if (e) {
|
|
313
|
+
entityIndex.update(id, { lastReferencedAt: now });
|
|
314
|
+
await storage.update("entities", id, { lastReferencedAt: now });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const [loaded, superseded] = await loadContextNodeWithSupersession(storage, node.id);
|
|
318
|
+
if (!loaded)
|
|
319
|
+
continue;
|
|
320
|
+
const block = formatContextNodeBlock(loaded, superseded, entityIndex, now);
|
|
321
|
+
const blockTokens = estimateTokens(block);
|
|
322
|
+
if (totalTokens + blockTokens > MAX_QUERY_TOKENS)
|
|
323
|
+
break;
|
|
324
|
+
blocks.push(block);
|
|
325
|
+
totalTokens += blockTokens;
|
|
326
|
+
}
|
|
327
|
+
if (blocks.length > 0) {
|
|
328
|
+
lines.push(...blocks);
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
lines.push("No context nodes referencing this entity.");
|
|
332
|
+
}
|
|
333
|
+
return lines.join("\n");
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* memory_query: BM25F-ranked hub matching + spoke context retrieval (AC-69-02, AC-69-03).
|
|
337
|
+
* FIX-01: rankHubsByQuery replaces binary matchHubsByTopic.
|
|
338
|
+
* F-93: orphan scan as safety-net lane.
|
|
339
|
+
* F-94: coverage signal in header.
|
|
340
|
+
* F-109: optional LLM query expansion before BM25F.
|
|
341
|
+
*/
|
|
342
|
+
export async function memoryQuery(topic, storage, entityIndex, narrativeCache, cwd, complete) {
|
|
343
|
+
const now = Date.now();
|
|
344
|
+
// F-109: Expand query into alternative phrasings before BM25F scoring
|
|
345
|
+
const queryTerms = complete ? await expandQuery(topic, complete) : [topic];
|
|
346
|
+
// FIX-01: BM25F ranked hub matching — union results across all expanded terms, max score wins
|
|
347
|
+
const allHubs = narrativeCache.all();
|
|
348
|
+
const hubScoreMap = new Map();
|
|
349
|
+
for (const term of queryTerms) {
|
|
350
|
+
for (const hub of rankHubsByQuery(term, narrativeCache, cwd)) {
|
|
351
|
+
if (!hubScoreMap.has(hub.id))
|
|
352
|
+
hubScoreMap.set(hub.id, hub);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const matchingHubs = [...hubScoreMap.values()];
|
|
356
|
+
const hubContextIds = [...new Set(matchingHubs.flatMap((h) => h.spokeNodeIds))];
|
|
357
|
+
// F-93: Scan orphan context nodes as a safety-net lane (use original topic for orphan scan)
|
|
358
|
+
const { orphanMatches, totalOrphans } = await scanOrphanNodes(topic, storage, narrativeCache);
|
|
359
|
+
// F-94: Coverage confidence signal
|
|
360
|
+
const header = `[MEMORY: Query — "${topic}" | searched: ${allHubs.length} hubs, ${totalOrphans} orphan nodes | hub matches: ${matchingHubs.length}, orphan matches: ${orphanMatches.length}]`;
|
|
361
|
+
const blocks = [header, ""];
|
|
362
|
+
let totalTokens = estimateTokens(header);
|
|
363
|
+
let truncated = false;
|
|
364
|
+
// Hub-matched spoke nodes
|
|
365
|
+
for (const id of hubContextIds) {
|
|
366
|
+
const [node, superseded] = await loadContextNodeWithSupersession(storage, id);
|
|
367
|
+
if (!node)
|
|
368
|
+
continue;
|
|
369
|
+
// AC-69-03: Update entity lastReferencedAt
|
|
370
|
+
for (const entityId of node.entityPointerIds) {
|
|
371
|
+
const entity = entityIndex.getById(entityId);
|
|
372
|
+
if (entity) {
|
|
373
|
+
entityIndex.update(entityId, { lastReferencedAt: now });
|
|
374
|
+
await storage.update("entities", entityId, { lastReferencedAt: now });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const block = formatContextNodeBlock(node, superseded, entityIndex, now);
|
|
378
|
+
const blockTokens = estimateTokens(block);
|
|
379
|
+
if (totalTokens + blockTokens > MAX_QUERY_TOKENS) {
|
|
380
|
+
truncated = true;
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
blocks.push(block);
|
|
384
|
+
totalTokens += blockTokens;
|
|
385
|
+
}
|
|
386
|
+
// F-93: Orphan matches in a separate labeled section
|
|
387
|
+
if (orphanMatches.length > 0 && !truncated) {
|
|
388
|
+
blocks.push("[Orphan Matches]");
|
|
389
|
+
for (const node of orphanMatches) {
|
|
390
|
+
const [loaded, superseded] = await loadContextNodeWithSupersession(storage, node.id);
|
|
391
|
+
if (!loaded)
|
|
392
|
+
continue;
|
|
393
|
+
for (const entityId of loaded.entityPointerIds) {
|
|
394
|
+
const entity = entityIndex.getById(entityId);
|
|
395
|
+
if (entity) {
|
|
396
|
+
entityIndex.update(entityId, { lastReferencedAt: now });
|
|
397
|
+
await storage.update("entities", entityId, { lastReferencedAt: now });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const block = formatContextNodeBlock(loaded, superseded, entityIndex, now);
|
|
401
|
+
const blockTokens = estimateTokens(block);
|
|
402
|
+
if (totalTokens + blockTokens > MAX_QUERY_TOKENS) {
|
|
403
|
+
truncated = true;
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
blocks.push(block);
|
|
407
|
+
totalTokens += blockTokens;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (truncated) {
|
|
411
|
+
blocks.push("\n[MEMORY: Additional results truncated — token budget reached]");
|
|
412
|
+
}
|
|
413
|
+
if (blocks.length <= 2) {
|
|
414
|
+
blocks.push("No matching context found.");
|
|
415
|
+
}
|
|
416
|
+
return blocks.join("\n");
|
|
417
|
+
}
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
// F-69: memory_recall — unified topic → context node → session turns in one hop
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
const MAX_RECALL_TOKENS = 4_000;
|
|
422
|
+
function extractTurnText(content) {
|
|
423
|
+
if (typeof content === "string")
|
|
424
|
+
return content.trim();
|
|
425
|
+
if (Array.isArray(content)) {
|
|
426
|
+
return content
|
|
427
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
428
|
+
.map((b) => b.text)
|
|
429
|
+
.join(" ")
|
|
430
|
+
.trim();
|
|
431
|
+
}
|
|
432
|
+
return "";
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Read session turns from a JSONL file.
|
|
436
|
+
* FIX-03: Each turn prefixed with [YYYY-MM-DD HH:mm] when the entry has a timestamp.
|
|
437
|
+
*/
|
|
438
|
+
function readSessionTurns(sessionFile, maxTurns) {
|
|
439
|
+
const records = readJsonlFile(sessionFile);
|
|
440
|
+
const turns = [];
|
|
441
|
+
for (const entry of records) {
|
|
442
|
+
if (entry.type !== "message")
|
|
443
|
+
continue;
|
|
444
|
+
if (!entry.role || (entry.role !== "user" && entry.role !== "assistant"))
|
|
445
|
+
continue;
|
|
446
|
+
const text = extractTurnText(entry.content);
|
|
447
|
+
if (!text)
|
|
448
|
+
continue;
|
|
449
|
+
const label = entry.role === "user" ? "User" : "Agent";
|
|
450
|
+
const ts = entry.timestamp
|
|
451
|
+
? `[${new Date(entry.timestamp).toISOString().slice(0, 16).replace("T", " ")}] `
|
|
452
|
+
: "";
|
|
453
|
+
turns.push(`${ts}${label}: ${text}`);
|
|
454
|
+
if (turns.length >= maxTurns)
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
return turns;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* memory_recall: unified topic → context → Layer 1 session turns (one tool call).
|
|
461
|
+
* FIX-01: BM25F ranked hub matching replaces binary matchHubsByTopic.
|
|
462
|
+
* FIX-03: Optional fromDate/toDate date-range filtering on spoke ContextNodes.
|
|
463
|
+
*
|
|
464
|
+
* 1. Ranks narrative hubs by BM25F score for topic.
|
|
465
|
+
* 2. Loads context nodes from matching hubs (optionally filtered by date range).
|
|
466
|
+
* 3. Resolves and reads session JSONL for the best matching context node.
|
|
467
|
+
* Returns context summary + session turns with [YYYY-MM-DD HH:mm] timestamps.
|
|
468
|
+
*/
|
|
469
|
+
export async function memoryRecall(topic, storage, entityIndex, narrativeCache, maxTurns = 30, cwd, fromDate, toDate, complete) {
|
|
470
|
+
const now = Date.now();
|
|
471
|
+
// Step 1: BM25F hub ranking with optional F-109 query expansion
|
|
472
|
+
const queryTerms = complete ? await expandQuery(topic, complete) : [topic];
|
|
473
|
+
const allHubs = narrativeCache.all();
|
|
474
|
+
const hubScoreMap = new Map();
|
|
475
|
+
for (const term of queryTerms) {
|
|
476
|
+
for (const hub of rankHubsByQuery(term, narrativeCache, cwd)) {
|
|
477
|
+
if (!hubScoreMap.has(hub.id))
|
|
478
|
+
hubScoreMap.set(hub.id, hub);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const matchingHubs = [...hubScoreMap.values()];
|
|
482
|
+
let hubContextIds = [...new Set(matchingHubs.flatMap((h) => h.spokeNodeIds))];
|
|
483
|
+
// F-93: Scan orphan nodes as safety-net lane (use original topic)
|
|
484
|
+
const { orphanMatches, totalOrphans } = await scanOrphanNodes(topic, storage, narrativeCache);
|
|
485
|
+
// F-94: Coverage confidence signal
|
|
486
|
+
const header = `[MEMORY: Recall — "${topic}" | searched: ${allHubs.length} hubs, ${totalOrphans} orphan nodes | hub matches: ${matchingHubs.length}, orphan matches: ${orphanMatches.length}]`;
|
|
487
|
+
// FIX-03: Date range filter on spoke ContextNodes
|
|
488
|
+
if (fromDate || toDate) {
|
|
489
|
+
const allNodes = await loadAllContextNodes(storage);
|
|
490
|
+
const nodeMap = new Map(allNodes.map((n) => [n.id, n]));
|
|
491
|
+
hubContextIds = hubContextIds.filter((id) => {
|
|
492
|
+
const node = nodeMap.get(id);
|
|
493
|
+
if (!node)
|
|
494
|
+
return false;
|
|
495
|
+
if (fromDate && node.timestamp < fromDate.getTime())
|
|
496
|
+
return false;
|
|
497
|
+
if (toDate && node.timestamp > toDate.getTime())
|
|
498
|
+
return false;
|
|
499
|
+
return true;
|
|
500
|
+
});
|
|
501
|
+
// Also filter orphan matches by date range
|
|
502
|
+
const filteredOrphans = orphanMatches.filter((n) => {
|
|
503
|
+
if (fromDate && n.timestamp < fromDate.getTime())
|
|
504
|
+
return false;
|
|
505
|
+
if (toDate && n.timestamp > toDate.getTime())
|
|
506
|
+
return false;
|
|
507
|
+
return true;
|
|
508
|
+
});
|
|
509
|
+
if (hubContextIds.length === 0 && filteredOrphans.length === 0) {
|
|
510
|
+
return `${header}\n\nNo matching context found in the specified date range.`;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (hubContextIds.length === 0 && orphanMatches.length === 0) {
|
|
514
|
+
return `${header}\n\nNo matching context found.`;
|
|
515
|
+
}
|
|
516
|
+
// Step 2: Load context nodes from hub spokes, then orphan matches
|
|
517
|
+
const contextBlocks = [];
|
|
518
|
+
let resolvedSessionFile = null;
|
|
519
|
+
let resolvedSessionId = null;
|
|
520
|
+
let totalTokens = estimateTokens(header);
|
|
521
|
+
async function loadAndAppend(id) {
|
|
522
|
+
const [node, superseded] = await loadContextNodeWithSupersession(storage, id);
|
|
523
|
+
if (!node)
|
|
524
|
+
return false;
|
|
525
|
+
for (const entityId of node.entityPointerIds) {
|
|
526
|
+
const entity = entityIndex.getById(entityId);
|
|
527
|
+
if (entity) {
|
|
528
|
+
entityIndex.update(entityId, { lastReferencedAt: now });
|
|
529
|
+
await storage.update("entities", entityId, { lastReferencedAt: now });
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
const block = formatContextNodeBlock(node, superseded, entityIndex, now);
|
|
533
|
+
const blockTokens = estimateTokens(block);
|
|
534
|
+
if (totalTokens + blockTokens > MAX_RECALL_TOKENS)
|
|
535
|
+
return false;
|
|
536
|
+
contextBlocks.push(block);
|
|
537
|
+
totalTokens += blockTokens;
|
|
538
|
+
// Capture the first (best) node's session file for Layer 1 retrieval
|
|
539
|
+
if (resolvedSessionFile === null) {
|
|
540
|
+
resolvedSessionId = node.sessionId;
|
|
541
|
+
if (node.sessionFile) {
|
|
542
|
+
resolvedSessionFile = node.sessionFile;
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
const treeIndex = await loadSessionTree(storage);
|
|
546
|
+
resolvedSessionFile = resolveSessionFile(node.sessionId, treeIndex);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return true;
|
|
550
|
+
}
|
|
551
|
+
// Hub-matched spokes first
|
|
552
|
+
for (const id of hubContextIds) {
|
|
553
|
+
const ok = await loadAndAppend(id);
|
|
554
|
+
if (!ok)
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
// F-93: Orphan matches — append under separate label if budget allows
|
|
558
|
+
if (orphanMatches.length > 0 && totalTokens < MAX_RECALL_TOKENS) {
|
|
559
|
+
contextBlocks.push("[Orphan Matches]");
|
|
560
|
+
for (const node of orphanMatches) {
|
|
561
|
+
const ok = await loadAndAppend(node.id);
|
|
562
|
+
if (!ok)
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (contextBlocks.length === 0 || (contextBlocks.length === 1 && contextBlocks[0] === "[Orphan Matches]")) {
|
|
567
|
+
return `${header}\n\nNo matching context found.`;
|
|
568
|
+
}
|
|
569
|
+
// Step 3: Read session turns from Layer 1 (FIX-03: turns include timestamps)
|
|
570
|
+
const lines = [header, "", ...contextBlocks];
|
|
571
|
+
if (resolvedSessionFile) {
|
|
572
|
+
const turns = readSessionTurns(resolvedSessionFile, maxTurns);
|
|
573
|
+
if (turns.length > 0) {
|
|
574
|
+
const date = (() => {
|
|
575
|
+
const m = contextBlocks[0]?.match(/\[RETRIEVED CONTEXT: [^,]+, (\d{4}-\d{2}-\d{2})\]/);
|
|
576
|
+
return m?.[1] ?? "";
|
|
577
|
+
})();
|
|
578
|
+
const turnHeader = date
|
|
579
|
+
? `[SESSION TURNS: ${resolvedSessionId}, ${date}]`
|
|
580
|
+
: `[SESSION TURNS: ${resolvedSessionId ?? "unknown"}]`;
|
|
581
|
+
lines.push("", turnHeader, ...turns);
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
lines.push("", `[SESSION TURNS: No message entries found in session file]`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
lines.push("", `[SESSION TURNS: Not available — sessionFile absent from context node and not found in session tree index]`);
|
|
589
|
+
}
|
|
590
|
+
return lines.join("\n");
|
|
591
|
+
}
|
|
592
|
+
//# sourceMappingURL=read-path.js.map
|
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
import type { IMemoryStorage } from "../storage/adapter.js";
|
|
11
|
+
export interface SessionTreeEntry {
|
|
12
|
+
/** Unique record ID (UUID). */
|
|
13
|
+
id: string;
|
|
14
|
+
/** The session ID for this entry. */
|
|
15
|
+
sessionId: string;
|
|
16
|
+
/** Absolute path to the session JSONL file. */
|
|
17
|
+
sessionFile: string;
|
|
18
|
+
/** Parent session JSONL file path (null for root sessions). */
|
|
19
|
+
parentSessionFile: string | null;
|
|
20
|
+
/** Working directory when the session started. */
|
|
21
|
+
cwd: string;
|
|
22
|
+
/** Unix timestamp (ms) when session_start fired. */
|
|
23
|
+
timestamp: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Record the current session in the persistent tree index.
|
|
27
|
+
* Called once per session_start. Append-only — entries are never modified.
|
|
28
|
+
*/
|
|
29
|
+
export declare function recordSessionStart(params: {
|
|
30
|
+
storage: IMemoryStorage;
|
|
31
|
+
sessionId: string;
|
|
32
|
+
sessionFile: string;
|
|
33
|
+
parentSessionFile: string | null;
|
|
34
|
+
cwd: string;
|
|
35
|
+
}): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Load all entries from the session tree index.
|
|
38
|
+
* Returns an empty array if the collection is empty.
|
|
39
|
+
*/
|
|
40
|
+
export declare function loadSessionTree(storage: IMemoryStorage): Promise<SessionTreeEntry[]>;
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a session ID to its JSONL file path using the persistent index.
|
|
43
|
+
* O(N) linear scan — acceptable at expected session counts (<1,000).
|
|
44
|
+
* Returns null if the session ID is not recorded in the index.
|
|
45
|
+
*/
|
|
46
|
+
export declare function resolveSessionFile(sessionId: string, index: SessionTreeEntry[]): string | null;
|
|
47
|
+
/**
|
|
48
|
+
* Walk parentSessionFile references back to the root.
|
|
49
|
+
* Returns the full ancestor chain for the given session file path,
|
|
50
|
+
* oldest entry first (root → ... → target).
|
|
51
|
+
* The entry for the target session itself is included as the last element.
|
|
52
|
+
*/
|
|
53
|
+
export declare function getAncestorChain(sessionFile: string, index: SessionTreeEntry[]): SessionTreeEntry[];
|
|
54
|
+
export interface TreeRenderRow {
|
|
55
|
+
entry: SessionTreeEntry;
|
|
56
|
+
depth: number;
|
|
57
|
+
hasContextNode: boolean;
|
|
58
|
+
isCurrent: boolean;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Build a depth-annotated render list for all sessions in the index,
|
|
62
|
+
* sorted by timestamp within each depth level.
|
|
63
|
+
*/
|
|
64
|
+
export declare function buildTreeRenderRows(index: SessionTreeEntry[], currentSessionId: string, contextNodeSessionIds: Set<string>): TreeRenderRow[];
|
|
65
|
+
/**
|
|
66
|
+
* Render tree rows as a compact ASCII string for display.
|
|
67
|
+
*/
|
|
68
|
+
export declare function renderSessionTree(rows: TreeRenderRow[]): string;
|
|
69
|
+
//# sourceMappingURL=session-tree-index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-tree-index.d.ts","sourceRoot":"","sources":["../../src/core/session-tree-index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAM5D,MAAM,WAAW,gBAAgB;IAChC,+BAA+B;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,qCAAqC;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,+CAA+C;IAC/C,WAAW,EAAE,MAAM,CAAC;IACpB,+DAA+D;IAC/D,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,kDAAkD;IAClD,GAAG,EAAE,MAAM,CAAC;IACZ,oDAAoD;IACpD,SAAS,EAAE,MAAM,CAAC;CAClB;AAMD;;;GAGG;AACH,wBAAsB,kBAAkB,CAAC,MAAM,EAAE;IAChD,OAAO,EAAE,cAAc,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,GAAG,EAAE,MAAM,CAAC;CACZ,GAAG,OAAO,CAAC,IAAI,CAAC,CAWhB;AAMD;;;GAGG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAE1F;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,EAAE,GAAG,MAAM,GAAG,IAAI,CAM9F;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,EAAE,GAAG,gBAAgB,EAAE,CAkBnG;AAMD,MAAM,WAAW,aAAa;IAC7B,KAAK,EAAE,gBAAgB,CAAC;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,cAAc,EAAE,OAAO,CAAC;IACxB,SAAS,EAAE,OAAO,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAClC,KAAK,EAAE,gBAAgB,EAAE,EACzB,gBAAgB,EAAE,MAAM,EACxB,qBAAqB,EAAE,GAAG,CAAC,MAAM,CAAC,GAChC,aAAa,EAAE,CAiCjB;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,aAAa,EAAE,GAAG,MAAM,CAe/D","sourcesContent":["/**\n * Session Tree Index — F-74 (persistent Layer 0 registry) and F-76 (traversal API).\n *\n * Records session tree topology across process invocations so the memory system\n * can resolve ContextNode sessionIds to JSONL file paths without the live\n * SessionManager being present.\n *\n * Adapted for @chili/memory: uses IMemoryStorage instead of direct file paths.\n */\n\nimport type { IMemoryStorage } from \"../storage/adapter.js\";\n\n// ---------------------------------------------------------------------------\n// F-74: SessionTreeEntry schema\n// ---------------------------------------------------------------------------\n\nexport interface SessionTreeEntry {\n\t/** Unique record ID (UUID). */\n\tid: string;\n\t/** The session ID for this entry. */\n\tsessionId: string;\n\t/** Absolute path to the session JSONL file. */\n\tsessionFile: string;\n\t/** Parent session JSONL file path (null for root sessions). */\n\tparentSessionFile: string | null;\n\t/** Working directory when the session started. */\n\tcwd: string;\n\t/** Unix timestamp (ms) when session_start fired. */\n\ttimestamp: number;\n}\n\n// ---------------------------------------------------------------------------\n// F-74: Write — append one entry at session_start\n// ---------------------------------------------------------------------------\n\n/**\n * Record the current session in the persistent tree index.\n * Called once per session_start. Append-only — entries are never modified.\n */\nexport async function recordSessionStart(params: {\n\tstorage: IMemoryStorage;\n\tsessionId: string;\n\tsessionFile: string;\n\tparentSessionFile: string | null;\n\tcwd: string;\n}): Promise<void> {\n\tconst { storage, sessionId, sessionFile, parentSessionFile, cwd } = params;\n\tconst entry: SessionTreeEntry = {\n\t\tid: globalThis.crypto.randomUUID(),\n\t\tsessionId,\n\t\tsessionFile,\n\t\tparentSessionFile,\n\t\tcwd,\n\t\ttimestamp: Date.now(),\n\t};\n\tawait storage.append<SessionTreeEntry>(\"session-tree\", entry);\n}\n\n// ---------------------------------------------------------------------------\n// F-76: Read — traversal API\n// ---------------------------------------------------------------------------\n\n/**\n * Load all entries from the session tree index.\n * Returns an empty array if the collection is empty.\n */\nexport async function loadSessionTree(storage: IMemoryStorage): Promise<SessionTreeEntry[]> {\n\treturn storage.readAll<SessionTreeEntry>(\"session-tree\");\n}\n\n/**\n * Resolve a session ID to its JSONL file path using the persistent index.\n * O(N) linear scan — acceptable at expected session counts (<1,000).\n * Returns null if the session ID is not recorded in the index.\n */\nexport function resolveSessionFile(sessionId: string, index: SessionTreeEntry[]): string | null {\n\t// Use the most recent entry for this sessionId (last wins, handles re-recordings)\n\tfor (let i = index.length - 1; i >= 0; i--) {\n\t\tif (index[i].sessionId === sessionId) return index[i].sessionFile;\n\t}\n\treturn null;\n}\n\n/**\n * Walk parentSessionFile references back to the root.\n * Returns the full ancestor chain for the given session file path,\n * oldest entry first (root → ... → target).\n * The entry for the target session itself is included as the last element.\n */\nexport function getAncestorChain(sessionFile: string, index: SessionTreeEntry[]): SessionTreeEntry[] {\n\t// Build a map from sessionFile → entry for O(1) parent lookups\n\tconst byFile = new Map<string, SessionTreeEntry>();\n\tfor (const entry of index) {\n\t\tbyFile.set(entry.sessionFile, entry);\n\t}\n\n\tconst chain: SessionTreeEntry[] = [];\n\tlet current = byFile.get(sessionFile);\n\n\t// Walk up the parent chain\n\twhile (current) {\n\t\tchain.unshift(current); // prepend so result is oldest-first\n\t\tif (!current.parentSessionFile) break;\n\t\tcurrent = byFile.get(current.parentSessionFile);\n\t}\n\n\treturn chain;\n}\n\n// ---------------------------------------------------------------------------\n// F-77: Tree rendering helpers (used by /memory tree command)\n// ---------------------------------------------------------------------------\n\nexport interface TreeRenderRow {\n\tentry: SessionTreeEntry;\n\tdepth: number;\n\thasContextNode: boolean;\n\tisCurrent: boolean;\n}\n\n/**\n * Build a depth-annotated render list for all sessions in the index,\n * sorted by timestamp within each depth level.\n */\nexport function buildTreeRenderRows(\n\tindex: SessionTreeEntry[],\n\tcurrentSessionId: string,\n\tcontextNodeSessionIds: Set<string>,\n): TreeRenderRow[] {\n\tif (index.length === 0) return [];\n\n\t// Deduplicate: keep only the most recent entry per sessionId\n\tconst latestBySessionId = new Map<string, SessionTreeEntry>();\n\tfor (const entry of index) {\n\t\tlatestBySessionId.set(entry.sessionId, entry);\n\t}\n\tconst entries = [...latestBySessionId.values()].sort((a, b) => a.timestamp - b.timestamp);\n\n\t// Build parent lookup by sessionFile\n\tconst byFile = new Map<string, SessionTreeEntry>();\n\tfor (const e of entries) byFile.set(e.sessionFile, e);\n\n\t// Compute depth for each entry\n\tfunction depth(entry: SessionTreeEntry): number {\n\t\tlet d = 0;\n\t\tlet cur = entry;\n\t\twhile (cur.parentSessionFile) {\n\t\t\tconst parent = byFile.get(cur.parentSessionFile);\n\t\t\tif (!parent) break;\n\t\t\td++;\n\t\t\tcur = parent;\n\t\t}\n\t\treturn d;\n\t}\n\n\treturn entries.map((entry) => ({\n\t\tentry,\n\t\tdepth: depth(entry),\n\t\thasContextNode: contextNodeSessionIds.has(entry.sessionId),\n\t\tisCurrent: entry.sessionId === currentSessionId,\n\t}));\n}\n\n/**\n * Render tree rows as a compact ASCII string for display.\n */\nexport function renderSessionTree(rows: TreeRenderRow[]): string {\n\tif (rows.length === 0) return \"[memory tree] No sessions recorded yet.\";\n\n\tconst lines: string[] = [\"[memory tree] Cross-session tree (oldest → newest):\", \"\"];\n\tfor (const row of rows) {\n\t\tconst indent = \" \".repeat(row.depth);\n\t\tconst date = new Date(row.entry.timestamp).toISOString().split(\"T\")[0];\n\t\tconst cwd = row.entry.cwd.replace(/^.*\\/([^/]+)$/, \"$1\"); // basename only\n\t\tconst indexMark = row.hasContextNode ? \"\" : \" [no index]\";\n\t\tconst currentMark = row.isCurrent ? \" →\" : \"\";\n\t\tlines.push(`${indent}${row.entry.sessionId.slice(0, 8)} | ${date} | ${cwd}${indexMark}${currentMark}`);\n\t}\n\tlines.push(\"\");\n\tlines.push(`Total: ${rows.length} session(s)`);\n\treturn lines.join(\"\\n\");\n}\n"]}
|