clawmem 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/AGENTS.md +660 -0
  2. package/CLAUDE.md +660 -0
  3. package/LICENSE +21 -0
  4. package/README.md +993 -0
  5. package/SKILL.md +717 -0
  6. package/bin/clawmem +75 -0
  7. package/package.json +72 -0
  8. package/src/amem.ts +797 -0
  9. package/src/beads.ts +263 -0
  10. package/src/clawmem.ts +1849 -0
  11. package/src/collections.ts +405 -0
  12. package/src/config.ts +178 -0
  13. package/src/consolidation.ts +123 -0
  14. package/src/directory-context.ts +248 -0
  15. package/src/errors.ts +41 -0
  16. package/src/formatter.ts +427 -0
  17. package/src/graph-traversal.ts +247 -0
  18. package/src/hooks/context-surfacing.ts +317 -0
  19. package/src/hooks/curator-nudge.ts +89 -0
  20. package/src/hooks/decision-extractor.ts +639 -0
  21. package/src/hooks/feedback-loop.ts +214 -0
  22. package/src/hooks/handoff-generator.ts +345 -0
  23. package/src/hooks/postcompact-inject.ts +226 -0
  24. package/src/hooks/precompact-extract.ts +314 -0
  25. package/src/hooks/pretool-inject.ts +79 -0
  26. package/src/hooks/session-bootstrap.ts +324 -0
  27. package/src/hooks/staleness-check.ts +130 -0
  28. package/src/hooks.ts +367 -0
  29. package/src/indexer.ts +327 -0
  30. package/src/intent.ts +294 -0
  31. package/src/limits.ts +26 -0
  32. package/src/llm.ts +1175 -0
  33. package/src/mcp.ts +2138 -0
  34. package/src/memory.ts +336 -0
  35. package/src/mmr.ts +93 -0
  36. package/src/observer.ts +269 -0
  37. package/src/openclaw/engine.ts +283 -0
  38. package/src/openclaw/index.ts +221 -0
  39. package/src/openclaw/plugin.json +83 -0
  40. package/src/openclaw/shell.ts +207 -0
  41. package/src/openclaw/tools.ts +304 -0
  42. package/src/profile.ts +346 -0
  43. package/src/promptguard.ts +218 -0
  44. package/src/retrieval-gate.ts +106 -0
  45. package/src/search-utils.ts +127 -0
  46. package/src/server.ts +783 -0
  47. package/src/splitter.ts +325 -0
  48. package/src/store.ts +4062 -0
  49. package/src/validation.ts +67 -0
  50. package/src/watcher.ts +58 -0
@@ -0,0 +1,427 @@
1
+ /**
2
+ * formatter.ts - Output formatting utilities for QMD
3
+ *
4
+ * Provides methods to format search results and documents into various output formats:
5
+ * JSON, CSV, XML, Markdown, files list, and CLI (colored terminal output).
6
+ */
7
+
8
+ import { extractSnippet } from "./store.ts";
9
+ import type { SearchResult, MultiGetResult, DocumentResult } from "./store.ts";
10
+
11
+ // =============================================================================
12
+ // Types
13
+ // =============================================================================
14
+
15
+ // Re-export store types for convenience
16
+ export type { SearchResult, MultiGetResult, DocumentResult };
17
+
18
+ // Flattened type for formatter convenience (extracts info from MultiGetResult)
19
+ export type MultiGetFile = {
20
+ filepath: string;
21
+ displayPath: string;
22
+ title: string;
23
+ body: string;
24
+ context?: string | null;
25
+ skipped: false;
26
+ } | {
27
+ filepath: string;
28
+ displayPath: string;
29
+ title: string;
30
+ body: string;
31
+ context?: string | null;
32
+ skipped: true;
33
+ skipReason: string;
34
+ };
35
+
36
+ export type OutputFormat = "cli" | "csv" | "md" | "xml" | "files" | "json";
37
+
38
+ export type FormatOptions = {
39
+ full?: boolean; // Show full document content instead of snippet
40
+ query?: string; // Query for snippet extraction and highlighting
41
+ useColor?: boolean; // Enable terminal colors (default: false for non-CLI)
42
+ lineNumbers?: boolean;// Add line numbers to output
43
+ };
44
+
45
+ // =============================================================================
46
+ // Helper Functions
47
+ // =============================================================================
48
+
49
+ /**
50
+ * Add line numbers to text content.
51
+ * Each line becomes: "{lineNum}: {content}"
52
+ * @param text The text to add line numbers to
53
+ * @param startLine Optional starting line number (default: 1)
54
+ */
55
+ export function addLineNumbers(text: string, startLine: number = 1): string {
56
+ const lines = text.split('\n');
57
+ return lines.map((line, i) => `${startLine + i}: ${line}`).join('\n');
58
+ }
59
+
60
+ /**
61
+ * Extract short docid from a full hash (first 6 characters).
62
+ */
63
+ export function getDocid(hash: string): string {
64
+ return hash.slice(0, 6);
65
+ }
66
+
67
+ // =============================================================================
68
+ // Escape Helpers
69
+ // =============================================================================
70
+
71
+ export function escapeCSV(value: string | null | number): string {
72
+ if (value === null || value === undefined) return "";
73
+ const str = String(value);
74
+ if (str.includes(",") || str.includes('"') || str.includes("\n")) {
75
+ return `"${str.replace(/"/g, '""')}"`;
76
+ }
77
+ return str;
78
+ }
79
+
80
+ export function escapeXml(str: string): string {
81
+ return str
82
+ .replace(/&/g, "&")
83
+ .replace(/</g, "&lt;")
84
+ .replace(/>/g, "&gt;")
85
+ .replace(/"/g, "&quot;")
86
+ .replace(/'/g, "&apos;");
87
+ }
88
+
89
+ // =============================================================================
90
+ // Search Results Formatters
91
+ // =============================================================================
92
+
93
+ /**
94
+ * Format search results as JSON
95
+ */
96
+ export function searchResultsToJson(
97
+ results: SearchResult[],
98
+ opts: FormatOptions = {}
99
+ ): string {
100
+ const query = opts.query || "";
101
+ const output = results.map(row => {
102
+ const bodyStr = row.body || "";
103
+ let body = opts.full ? bodyStr : undefined;
104
+ let snippet = !opts.full ? extractSnippet(bodyStr, query, 300, row.chunkPos).snippet : undefined;
105
+
106
+ if (opts.lineNumbers) {
107
+ if (body) body = addLineNumbers(body);
108
+ if (snippet) snippet = addLineNumbers(snippet);
109
+ }
110
+
111
+ return {
112
+ docid: `#${row.docid}`,
113
+ score: Math.round(row.score * 100) / 100,
114
+ file: row.displayPath,
115
+ title: row.title,
116
+ ...(row.context && { context: row.context }),
117
+ ...(body && { body }),
118
+ ...(snippet && { snippet }),
119
+ };
120
+ });
121
+ return JSON.stringify(output, null, 2);
122
+ }
123
+
124
+ /**
125
+ * Format search results as CSV
126
+ */
127
+ export function searchResultsToCsv(
128
+ results: SearchResult[],
129
+ opts: FormatOptions = {}
130
+ ): string {
131
+ const query = opts.query || "";
132
+ const header = "docid,score,file,title,context,line,snippet";
133
+ const rows = results.map(row => {
134
+ const bodyStr = row.body || "";
135
+ const { line, snippet } = extractSnippet(bodyStr, query, 500, row.chunkPos);
136
+ let content = opts.full ? bodyStr : snippet;
137
+ if (opts.lineNumbers && content) {
138
+ content = addLineNumbers(content);
139
+ }
140
+ return [
141
+ `#${row.docid}`,
142
+ row.score.toFixed(4),
143
+ escapeCSV(row.displayPath),
144
+ escapeCSV(row.title),
145
+ escapeCSV(row.context || ""),
146
+ line,
147
+ escapeCSV(content),
148
+ ].join(",");
149
+ });
150
+ return [header, ...rows].join("\n");
151
+ }
152
+
153
+ /**
154
+ * Format search results as simple files list (docid,score,filepath,context)
155
+ */
156
+ export function searchResultsToFiles(results: SearchResult[]): string {
157
+ return results.map(row => {
158
+ const ctx = row.context ? `,"${row.context.replace(/"/g, '""')}"` : "";
159
+ return `#${row.docid},${row.score.toFixed(2)},${row.displayPath}${ctx}`;
160
+ }).join("\n");
161
+ }
162
+
163
+ /**
164
+ * Format search results as Markdown
165
+ */
166
+ export function searchResultsToMarkdown(
167
+ results: SearchResult[],
168
+ opts: FormatOptions = {}
169
+ ): string {
170
+ const query = opts.query || "";
171
+ return results.map(row => {
172
+ const heading = row.title || row.displayPath;
173
+ const bodyStr = row.body || "";
174
+ let content: string;
175
+ if (opts.full) {
176
+ content = bodyStr;
177
+ } else {
178
+ content = extractSnippet(bodyStr, query, 500, row.chunkPos).snippet;
179
+ }
180
+ if (opts.lineNumbers) {
181
+ content = addLineNumbers(content);
182
+ }
183
+ return `---\n# ${heading}\n\n**docid:** \`#${row.docid}\`\n\n${content}\n`;
184
+ }).join("\n");
185
+ }
186
+
187
+ /**
188
+ * Format search results as XML
189
+ */
190
+ export function searchResultsToXml(
191
+ results: SearchResult[],
192
+ opts: FormatOptions = {}
193
+ ): string {
194
+ const query = opts.query || "";
195
+ const items = results.map(row => {
196
+ const titleAttr = row.title ? ` title="${escapeXml(row.title)}"` : "";
197
+ const bodyStr = row.body || "";
198
+ let content = opts.full ? bodyStr : extractSnippet(bodyStr, query, 500, row.chunkPos).snippet;
199
+ if (opts.lineNumbers) {
200
+ content = addLineNumbers(content);
201
+ }
202
+ return `<file docid="#${row.docid}" name="${escapeXml(row.displayPath)}"${titleAttr}>\n${escapeXml(content)}\n</file>`;
203
+ });
204
+ return items.join("\n\n");
205
+ }
206
+
207
+ /**
208
+ * Format search results for MCP (simpler CSV format with pre-extracted snippets)
209
+ */
210
+ export function searchResultsToMcpCsv(
211
+ results: { docid: string; file: string; title: string; score: number; context: string | null; snippet: string }[]
212
+ ): string {
213
+ const header = "docid,file,title,score,context,snippet";
214
+ const rows = results.map(r =>
215
+ [`#${r.docid}`, r.file, r.title, r.score, r.context || "", r.snippet].map(escapeCSV).join(",")
216
+ );
217
+ return [header, ...rows].join("\n");
218
+ }
219
+
220
+ // =============================================================================
221
+ // Document Formatters (for multi-get using MultiGetFile from store)
222
+ // =============================================================================
223
+
224
+ /**
225
+ * Format documents as JSON
226
+ */
227
+ export function documentsToJson(results: MultiGetFile[]): string {
228
+ const output = results.map(r => ({
229
+ file: r.displayPath,
230
+ title: r.title,
231
+ ...(r.context && { context: r.context }),
232
+ ...(r.skipped ? { skipped: true, reason: r.skipReason } : { body: r.body }),
233
+ }));
234
+ return JSON.stringify(output, null, 2);
235
+ }
236
+
237
+ /**
238
+ * Format documents as CSV
239
+ */
240
+ export function documentsToCsv(results: MultiGetFile[]): string {
241
+ const header = "file,title,context,skipped,body";
242
+ const rows = results.map(r =>
243
+ [
244
+ r.displayPath,
245
+ r.title,
246
+ r.context || "",
247
+ r.skipped ? "true" : "false",
248
+ r.skipped ? (r.skipReason || "") : r.body
249
+ ].map(escapeCSV).join(",")
250
+ );
251
+ return [header, ...rows].join("\n");
252
+ }
253
+
254
+ /**
255
+ * Format documents as files list
256
+ */
257
+ export function documentsToFiles(results: MultiGetFile[]): string {
258
+ return results.map(r => {
259
+ const ctx = r.context ? `,"${r.context.replace(/"/g, '""')}"` : "";
260
+ const status = r.skipped ? ",[SKIPPED]" : "";
261
+ return `${r.displayPath}${ctx}${status}`;
262
+ }).join("\n");
263
+ }
264
+
265
+ /**
266
+ * Format documents as Markdown
267
+ */
268
+ export function documentsToMarkdown(results: MultiGetFile[]): string {
269
+ return results.map(r => {
270
+ let md = `## ${r.displayPath}\n\n`;
271
+ if (r.title && r.title !== r.displayPath) md += `**Title:** ${r.title}\n\n`;
272
+ if (r.context) md += `**Context:** ${r.context}\n\n`;
273
+ if (r.skipped) {
274
+ md += `> ${r.skipReason}\n`;
275
+ } else {
276
+ md += "```\n" + r.body + "\n```\n";
277
+ }
278
+ return md;
279
+ }).join("\n");
280
+ }
281
+
282
+ /**
283
+ * Format documents as XML
284
+ */
285
+ export function documentsToXml(results: MultiGetFile[]): string {
286
+ const items = results.map(r => {
287
+ let xml = " <document>\n";
288
+ xml += ` <file>${escapeXml(r.displayPath)}</file>\n`;
289
+ xml += ` <title>${escapeXml(r.title)}</title>\n`;
290
+ if (r.context) xml += ` <context>${escapeXml(r.context)}</context>\n`;
291
+ if (r.skipped) {
292
+ xml += ` <skipped>true</skipped>\n`;
293
+ xml += ` <reason>${escapeXml(r.skipReason || "")}</reason>\n`;
294
+ } else {
295
+ xml += ` <body>${escapeXml(r.body)}</body>\n`;
296
+ }
297
+ xml += " </document>";
298
+ return xml;
299
+ });
300
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<documents>\n${items.join("\n")}\n</documents>`;
301
+ }
302
+
303
+ // =============================================================================
304
+ // Single Document Formatters
305
+ // =============================================================================
306
+
307
+ /**
308
+ * Format a single DocumentResult as JSON
309
+ */
310
+ export function documentToJson(doc: DocumentResult): string {
311
+ return JSON.stringify({
312
+ file: doc.displayPath,
313
+ title: doc.title,
314
+ ...(doc.context && { context: doc.context }),
315
+ hash: doc.hash,
316
+ modifiedAt: doc.modifiedAt,
317
+ bodyLength: doc.bodyLength,
318
+ ...(doc.body !== undefined && { body: doc.body }),
319
+ }, null, 2);
320
+ }
321
+
322
+ /**
323
+ * Format a single DocumentResult as Markdown
324
+ */
325
+ export function documentToMarkdown(doc: DocumentResult): string {
326
+ let md = `# ${doc.title || doc.displayPath}\n\n`;
327
+ if (doc.context) md += `**Context:** ${doc.context}\n\n`;
328
+ md += `**File:** ${doc.displayPath}\n`;
329
+ md += `**Modified:** ${doc.modifiedAt}\n\n`;
330
+ if (doc.body !== undefined) {
331
+ md += "---\n\n" + doc.body + "\n";
332
+ }
333
+ return md;
334
+ }
335
+
336
+ /**
337
+ * Format a single DocumentResult as XML
338
+ */
339
+ export function documentToXml(doc: DocumentResult): string {
340
+ let xml = `<?xml version="1.0" encoding="UTF-8"?>\n<document>\n`;
341
+ xml += ` <file>${escapeXml(doc.displayPath)}</file>\n`;
342
+ xml += ` <title>${escapeXml(doc.title)}</title>\n`;
343
+ if (doc.context) xml += ` <context>${escapeXml(doc.context)}</context>\n`;
344
+ xml += ` <hash>${escapeXml(doc.hash)}</hash>\n`;
345
+ xml += ` <modifiedAt>${escapeXml(doc.modifiedAt)}</modifiedAt>\n`;
346
+ xml += ` <bodyLength>${doc.bodyLength}</bodyLength>\n`;
347
+ if (doc.body !== undefined) {
348
+ xml += ` <body>${escapeXml(doc.body)}</body>\n`;
349
+ }
350
+ xml += `</document>`;
351
+ return xml;
352
+ }
353
+
354
+ /**
355
+ * Format a single document to the specified format
356
+ */
357
+ export function formatDocument(doc: DocumentResult, format: OutputFormat): string {
358
+ switch (format) {
359
+ case "json":
360
+ return documentToJson(doc);
361
+ case "md":
362
+ return documentToMarkdown(doc);
363
+ case "xml":
364
+ return documentToXml(doc);
365
+ default:
366
+ // Default to markdown for CLI and other formats
367
+ return documentToMarkdown(doc);
368
+ }
369
+ }
370
+
371
+ // =============================================================================
372
+ // Universal Format Function
373
+ // =============================================================================
374
+
375
+ /**
376
+ * Format search results to the specified output format
377
+ */
378
+ export function formatSearchResults(
379
+ results: SearchResult[],
380
+ format: OutputFormat,
381
+ opts: FormatOptions = {}
382
+ ): string {
383
+ switch (format) {
384
+ case "json":
385
+ return searchResultsToJson(results, opts);
386
+ case "csv":
387
+ return searchResultsToCsv(results, opts);
388
+ case "files":
389
+ return searchResultsToFiles(results);
390
+ case "md":
391
+ return searchResultsToMarkdown(results, opts);
392
+ case "xml":
393
+ return searchResultsToXml(results, opts);
394
+ case "cli":
395
+ // CLI format should be handled separately with colors
396
+ // Return a simple text version as fallback
397
+ return searchResultsToMarkdown(results, opts);
398
+ default:
399
+ return searchResultsToJson(results, opts);
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Format documents to the specified output format
405
+ */
406
+ export function formatDocuments(
407
+ results: MultiGetFile[],
408
+ format: OutputFormat
409
+ ): string {
410
+ switch (format) {
411
+ case "json":
412
+ return documentsToJson(results);
413
+ case "csv":
414
+ return documentsToCsv(results);
415
+ case "files":
416
+ return documentsToFiles(results);
417
+ case "md":
418
+ return documentsToMarkdown(results);
419
+ case "xml":
420
+ return documentsToXml(results);
421
+ case "cli":
422
+ // CLI format should be handled separately with colors
423
+ return documentsToMarkdown(results);
424
+ default:
425
+ return documentsToJson(results);
426
+ }
427
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * MAGMA Adaptive Graph Traversal
3
+ *
4
+ * Beam search over multi-graph memory structure with intent-aware routing.
5
+ * Reference: MAGMA paper (arXiv:2501.XXXXX, Jan 2026)
6
+ */
7
+
8
+ import type { Database } from "bun:sqlite";
9
+ import type { IntentType } from "./intent.ts";
10
+ import { getIntentWeights } from "./intent.ts";
11
+
12
+ // =============================================================================
13
+ // Types
14
+ // =============================================================================
15
+
16
+ export interface TraversalOptions {
17
+ maxDepth: number; // 2-3 hops
18
+ beamWidth: number; // 5-10 nodes per level
19
+ budget: number; // Max total nodes (20-50)
20
+ intent: IntentType;
21
+ queryEmbedding: number[];
22
+ }
23
+
24
+ export interface TraversalNode {
25
+ docId: number;
26
+ path: string;
27
+ score: number;
28
+ hops: number;
29
+ viaRelation?: string;
30
+ }
31
+
32
+ // =============================================================================
33
+ // Helpers
34
+ // =============================================================================
35
+
36
+ /**
37
+ * Calculate cosine similarity between two vectors.
38
+ */
39
+ function cosineSimilarity(a: number[] | Float32Array, b: number[] | Float32Array): number {
40
+ if (a.length !== b.length) return 0;
41
+
42
+ let dotProduct = 0;
43
+ let normA = 0;
44
+ let normB = 0;
45
+
46
+ for (let i = 0; i < a.length; i++) {
47
+ dotProduct += a[i]! * b[i]!;
48
+ normA += a[i]! * a[i]!;
49
+ normB += b[i]! * b[i]!;
50
+ }
51
+
52
+ const magnitude = Math.sqrt(normA) * Math.sqrt(normB);
53
+ return magnitude === 0 ? 0 : dotProduct / magnitude;
54
+ }
55
+
56
+ /**
57
+ * Get document embedding from vectors_vec table.
58
+ */
59
+ function getDocEmbedding(db: Database, docId: number): Float32Array {
60
+ // Get the hash for this document
61
+ const doc = db.prepare(`
62
+ SELECT hash FROM documents WHERE id = ?
63
+ `).get(docId) as { hash: string } | undefined;
64
+
65
+ if (!doc) return new Float32Array(0);
66
+
67
+ // Get embedding for seq=0 (whole document)
68
+ const row = db.prepare(`
69
+ SELECT embedding FROM vectors_vec WHERE hash_seq = ?
70
+ `).get(`${doc.hash}_0`) as { embedding: Float32Array } | undefined;
71
+
72
+ return row?.embedding || new Float32Array(0);
73
+ }
74
+
75
+ /**
76
+ * Get document path from ID.
77
+ */
78
+ function getDocPath(db: Database, docId: number): string {
79
+ const row = db.prepare(`
80
+ SELECT collection, path FROM documents WHERE id = ?
81
+ `).get(docId) as { collection: string; path: string } | undefined;
82
+
83
+ return row ? `${row.collection}/${row.path}` : '';
84
+ }
85
+
86
+ // =============================================================================
87
+ // Adaptive Traversal
88
+ // =============================================================================
89
+
90
+ /**
91
+ * Get document ID from hash.
92
+ */
93
+ function getDocIdFromHash(db: Database, hash: string): number | null {
94
+ const row = db.prepare(`
95
+ SELECT id FROM documents WHERE hash = ? AND active = 1 LIMIT 1
96
+ `).get(hash) as { id: number } | undefined;
97
+ return row?.id || null;
98
+ }
99
+
100
+ /**
101
+ * Perform intent-aware beam search over memory graph.
102
+ *
103
+ * Algorithm:
104
+ * 1. Start from anchor documents (top BM25/vector results)
105
+ * 2. Expand frontier by following edges weighted by intent
106
+ * 3. Score each new node: parent_score * decay + transition_score
107
+ * 4. Keep top-k nodes per level (beam search)
108
+ * 5. Stop at maxDepth or budget
109
+ */
110
+ export function adaptiveTraversal(
111
+ db: Database,
112
+ anchors: { hash: string; score: number }[],
113
+ options: TraversalOptions
114
+ ): TraversalNode[] {
115
+ // Convert hashes to IDs
116
+ const anchorNodes: { docId: number; score: number }[] = [];
117
+ for (const anchor of anchors) {
118
+ const docId = getDocIdFromHash(db, anchor.hash);
119
+ if (docId !== null) {
120
+ anchorNodes.push({ docId, score: anchor.score });
121
+ }
122
+ }
123
+ const { maxDepth, beamWidth, budget, intent, queryEmbedding } = options;
124
+
125
+ // Intent-specific weights for structural alignment
126
+ const weights = getIntentWeights(intent);
127
+
128
+ const visited = new Map<number, TraversalNode>();
129
+ let currentFrontier: TraversalNode[] = anchorNodes.map(a => ({
130
+ docId: a.docId,
131
+ path: getDocPath(db, a.docId),
132
+ score: a.score,
133
+ hops: 0,
134
+ }));
135
+
136
+ // Add anchors to visited set
137
+ for (const node of currentFrontier) {
138
+ visited.set(node.docId, node);
139
+ }
140
+
141
+ // Beam search expansion
142
+ for (let depth = 1; depth <= maxDepth; depth++) {
143
+ const candidates: TraversalNode[] = [];
144
+
145
+ for (const u of currentFrontier) {
146
+ // Get all neighbors via any relation type
147
+ const neighbors = db.prepare(`
148
+ SELECT target_id as docId, relation_type, weight
149
+ FROM memory_relations
150
+ WHERE source_id = ?
151
+
152
+ UNION
153
+
154
+ SELECT source_id as docId, relation_type, weight
155
+ FROM memory_relations
156
+ WHERE target_id = ? AND relation_type IN ('semantic', 'entity')
157
+ `).all(u.docId, u.docId) as { docId: number; relation_type: string; weight: number }[];
158
+
159
+ for (const neighbor of neighbors) {
160
+ if (visited.has(neighbor.docId)) continue;
161
+
162
+ // Get neighbor embedding for semantic affinity
163
+ const neighborVec = getDocEmbedding(db, neighbor.docId);
164
+ const semanticAffinity = neighborVec.length > 0
165
+ ? cosineSimilarity(queryEmbedding, neighborVec)
166
+ : 0;
167
+
168
+ // Calculate transition score: λ1·structure + λ2·semantic
169
+ const λ1 = 0.6;
170
+ const λ2 = 0.4;
171
+ const structureScore = weights[neighbor.relation_type as keyof typeof weights] || 1.0;
172
+ const transitionScore = Math.exp(λ1 * structureScore + λ2 * semanticAffinity);
173
+
174
+ // Apply decay and accumulate
175
+ const γ = 0.9;
176
+ const newScore = u.score * γ + transitionScore * neighbor.weight;
177
+
178
+ candidates.push({
179
+ docId: neighbor.docId,
180
+ path: getDocPath(db, neighbor.docId),
181
+ score: newScore,
182
+ hops: depth,
183
+ viaRelation: neighbor.relation_type,
184
+ });
185
+ }
186
+ }
187
+
188
+ // Take top-k by score (beam search)
189
+ candidates.sort((a, b) => b.score - a.score);
190
+ currentFrontier = candidates.slice(0, beamWidth);
191
+
192
+ for (const node of currentFrontier) {
193
+ visited.set(node.docId, node);
194
+ }
195
+
196
+ // Budget check
197
+ if (visited.size >= budget) break;
198
+ }
199
+
200
+ // Convert to sorted array
201
+ return Array.from(visited.values()).sort((a, b) => b.score - a.score);
202
+ }
203
+
204
+ /**
205
+ * Merge graph traversal results with original search results.
206
+ * Returns results with both hash and score for re-integration.
207
+ */
208
+ export function mergeTraversalResults(
209
+ db: Database,
210
+ originalResults: { hash: string; score: number }[],
211
+ traversedNodes: TraversalNode[]
212
+ ): { hash: string; score: number }[] {
213
+ const merged = new Map<string, number>();
214
+
215
+ // Add original results
216
+ for (const r of originalResults) {
217
+ merged.set(r.hash, r.score);
218
+ }
219
+
220
+ // Normalize traversal scores to [0, 1] before merging (traversal uses exp() which is unbounded)
221
+ const maxTraversalScore = traversedNodes.length > 0
222
+ ? Math.max(...traversedNodes.map(n => n.score))
223
+ : 1;
224
+ const normalizer = maxTraversalScore > 0 ? 1 / maxTraversalScore : 1;
225
+
226
+ // Merge traversed nodes (boost scores slightly for multi-hop discoveries)
227
+ for (const node of traversedNodes) {
228
+ // Get hash from doc ID
229
+ const doc = db.prepare(`SELECT hash FROM documents WHERE id = ?`).get(node.docId) as { hash: string } | undefined;
230
+ if (!doc) continue;
231
+
232
+ const normalizedScore = node.score * normalizer;
233
+ const existing = merged.get(doc.hash);
234
+ if (existing !== undefined) {
235
+ // Document found via both direct search and traversal - boost it
236
+ merged.set(doc.hash, Math.max(existing, normalizedScore * 1.1));
237
+ } else {
238
+ // New document discovered via traversal
239
+ merged.set(doc.hash, normalizedScore * 0.8); // Slight penalty for indirect hits
240
+ }
241
+ }
242
+
243
+ // Convert back to array and sort
244
+ return Array.from(merged.entries())
245
+ .map(([hash, score]) => ({ hash, score }))
246
+ .sort((a, b) => b.score - a.score);
247
+ }