@ijfw/memory-server 1.3.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 (106) hide show
  1. package/bin/ijfw +27 -0
  2. package/bin/ijfw-dashboard +180 -0
  3. package/bin/ijfw-dispatch-plan +41 -0
  4. package/bin/ijfw-memorize +273 -0
  5. package/bin/ijfw-memory +51 -0
  6. package/fixtures/demo-target.js +28 -0
  7. package/package.json +53 -0
  8. package/src/api-client.js +190 -0
  9. package/src/audit-roster.js +315 -0
  10. package/src/caps.js +37 -0
  11. package/src/cold-scan-runner.mjs +37 -0
  12. package/src/compute/edges.js +155 -0
  13. package/src/compute/extract.js +560 -0
  14. package/src/compute/fts5.js +420 -0
  15. package/src/compute/graph-auto-index.js +191 -0
  16. package/src/compute/graph-lock.js +114 -0
  17. package/src/compute/index.js +18 -0
  18. package/src/compute/migration-runner.js +116 -0
  19. package/src/compute/migrations/001-initial.js +23 -0
  20. package/src/compute/migrations/002-porter-stemming-source.js +139 -0
  21. package/src/compute/migrations/003-tier-semantic.js +69 -0
  22. package/src/compute/migrations/004-kg-tables.js +83 -0
  23. package/src/compute/migrations/005-stale-candidate.js +72 -0
  24. package/src/compute/python-resolver.js +106 -0
  25. package/src/compute/runner-vm.js +185 -0
  26. package/src/compute/runner.js +416 -0
  27. package/src/compute/sandbox-detect.js +122 -0
  28. package/src/compute/sandbox-linux.js +164 -0
  29. package/src/compute/sandbox-macos.js +167 -0
  30. package/src/compute/sandbox-windows.js +63 -0
  31. package/src/compute/schema.sql +118 -0
  32. package/src/compute/staleness.js +239 -0
  33. package/src/compute/synonyms.js +367 -0
  34. package/src/compute/traverse.js +180 -0
  35. package/src/cost/aggregator.js +229 -0
  36. package/src/cost/pricing.js +134 -0
  37. package/src/cost/readers/claude.js +179 -0
  38. package/src/cost/readers/codex.js +131 -0
  39. package/src/cost/readers/gemini.js +111 -0
  40. package/src/cost/savings.js +243 -0
  41. package/src/cross-dispatcher.js +437 -0
  42. package/src/cross-orchestrator-cli.js +1885 -0
  43. package/src/cross-orchestrator.js +598 -0
  44. package/src/cross-project-search.js +114 -0
  45. package/src/dashboard-client.html +1180 -0
  46. package/src/dashboard-server.js +895 -0
  47. package/src/design-companion.js +81 -0
  48. package/src/dispatch/colon-syntax.js +732 -0
  49. package/src/dispatch-planner.js +235 -0
  50. package/src/dream/cooldown.js +105 -0
  51. package/src/dream/runner.mjs +373 -0
  52. package/src/dream/staleness-wiring.js +195 -0
  53. package/src/feedback-detector.js +57 -0
  54. package/src/hero-line.js +115 -0
  55. package/src/importers/claude-mem.js +152 -0
  56. package/src/importers/cli.js +311 -0
  57. package/src/importers/common.js +84 -0
  58. package/src/importers/discover.js +235 -0
  59. package/src/importers/rtk.js +107 -0
  60. package/src/intent-router.js +221 -0
  61. package/src/lib/atomic-io.js +201 -0
  62. package/src/lib/cache.js +33 -0
  63. package/src/lib/npm-view.js +104 -0
  64. package/src/lib/status-card.js +95 -0
  65. package/src/lib/token.js +85 -0
  66. package/src/memory/fts5.js +349 -0
  67. package/src/memory/migration-runner.js +116 -0
  68. package/src/memory/migrations/001-fts5-init.js +26 -0
  69. package/src/memory/migrations/002-tier-semantic.js +60 -0
  70. package/src/memory/migrations/003-stale-candidate.js +60 -0
  71. package/src/memory/reader.js +300 -0
  72. package/src/memory/recall-counter.js +76 -0
  73. package/src/memory/schema.sql +79 -0
  74. package/src/memory/search.js +431 -0
  75. package/src/memory/staleness.js +237 -0
  76. package/src/memory/tier-promotion.js +377 -0
  77. package/src/memory/tokenize.js +63 -0
  78. package/src/project-type-detector.js +866 -0
  79. package/src/prompt-check.js +171 -0
  80. package/src/ralph-allowlist.js +88 -0
  81. package/src/receipts.js +129 -0
  82. package/src/redactor.js +107 -0
  83. package/src/sandbox.js +275 -0
  84. package/src/sanitizer.js +69 -0
  85. package/src/scan-resume.js +167 -0
  86. package/src/schema.js +82 -0
  87. package/src/search-bm25.js +108 -0
  88. package/src/server.js +1414 -0
  89. package/src/swarm-config.js +80 -0
  90. package/src/trident/dispatch.js +211 -0
  91. package/src/trident/lens-health.js +253 -0
  92. package/src/update-apply.js +79 -0
  93. package/src/update-check.js +136 -0
  94. package/src/vectors.js +178 -0
  95. package/templates/design/bento-grid.md +84 -0
  96. package/templates/design/brutalist-luxe.md +82 -0
  97. package/templates/design/cinematic-dark.md +82 -0
  98. package/templates/design/data-dense-dashboard.md +88 -0
  99. package/templates/design/editorial-warm.md +81 -0
  100. package/templates/design/glassmorphic.md +84 -0
  101. package/templates/design/magazine-editorial.md +84 -0
  102. package/templates/design/maximalist-vibrant.md +85 -0
  103. package/templates/design/neo-swiss-tech.md +85 -0
  104. package/templates/design/swiss-minimal.md +80 -0
  105. package/templates/design/terminal-native.md +83 -0
  106. package/templates/design/warm-organic.md +84 -0
@@ -0,0 +1,732 @@
1
+ // IJFW v1.3.0 Alpha -- W1B colon-syntax dispatcher.
2
+ //
3
+ // V3-B1: zero new MCP tools. Sub-commands ride existing tool surfaces via
4
+ // "<namespace>:<command> [args]" prefix. Two consumers:
5
+ //
6
+ // 1. ijfw_run dispatch:
7
+ // - compute:python "..." -> runCompute(language='python', script=args)
8
+ // - compute:js "..." -> runCompute(language='js', script=args)
9
+ // - index:<source> ... -> safeWrite(raw) on per-project FTS5 db
10
+ // - detect:project_type -> Phase 3 wired (sync detect + cache; --bg spawns runner)
11
+ //
12
+ // 2. ijfw_memory_search dispatch:
13
+ // - compute:<query> -> search(raw_fts, query, k=10)
14
+ // - anything else -> null (caller falls through to legacy logic)
15
+ //
16
+ // Discipline:
17
+ // - All user-facing strings positive-framed; no "AI" / "artificial intelligence" // copy-lint:allow
18
+ // in user copy (covered by scripts/copy-lint.sh).
19
+ // - Env reads kept inside dispatchRun so the parser stays pure.
20
+ // - Tool count remains 10 -- this file is invoked from within existing tool
21
+ // handlers in src/server.js; tools/list does not change.
22
+
23
+ import {
24
+ openDb,
25
+ safeWrite,
26
+ search,
27
+ closeDb,
28
+ IntegrityError,
29
+ SchemaVersionError,
30
+ ComputeDbError,
31
+ } from '../compute/index.js';
32
+ import { runCompute } from '../compute/runner.js';
33
+ import { expandQuery } from '../compute/synonyms.js';
34
+ import { detect, writeProjectType } from '../project-type-detector.js';
35
+ import { extractEntities } from '../compute/extract.js';
36
+ import { writeEdges } from '../compute/edges.js';
37
+ import { bfsTraverse, bfsRelated, resolveNode } from '../compute/traverse.js';
38
+ import { acquireGraphWriteLock } from '../compute/graph-lock.js';
39
+ import { spawn } from 'child_process';
40
+ import { fileURLToPath } from 'url';
41
+ import { dirname, join } from 'path';
42
+
43
+ // Recognised namespaces -- gates dispatchRun against typos.
44
+ const RUN_NAMESPACES = new Set(['compute', 'index', 'detect', 'graph']);
45
+ const SEARCH_NAMESPACES = new Set(['compute', 'graph']);
46
+
47
+ // --- Parser ----------------------------------------------------------------
48
+
49
+ /**
50
+ * parseColonCommand(input) -> { namespace, command, args } | null
51
+ *
52
+ * Recognises "<word>:<rest>" where <word> is [a-z_][a-z0-9_]* (ASCII, lower).
53
+ * Returns null for any other shape so callers can fall through to legacy
54
+ * handlers without speculating about intent.
55
+ *
56
+ * Splitting rules:
57
+ * - First ':' separates namespace from the remainder.
58
+ * - In the remainder, the first whitespace run separates command from args.
59
+ * - If the args body starts and ends with a single matching pair of quotes
60
+ * ("..." or '...'), strip them so callers receive the raw script body.
61
+ * - Trailing whitespace on args is stripped; internal whitespace preserved.
62
+ */
63
+ export function parseColonCommand(input) {
64
+ if (typeof input !== 'string') return null;
65
+ const s = input.trim();
66
+ if (s.length === 0) return null;
67
+
68
+ const colon = s.indexOf(':');
69
+ if (colon <= 0) return null;
70
+
71
+ const namespace = s.slice(0, colon);
72
+ if (!/^[a-z_][a-z0-9_]*$/.test(namespace)) return null;
73
+
74
+ const remainder = s.slice(colon + 1);
75
+ // Empty remainder -> command present but blank; treat as malformed.
76
+ if (remainder.length === 0) return null;
77
+
78
+ // Split command from args at first whitespace run.
79
+ const wsMatch = remainder.match(/\s+/);
80
+ let command;
81
+ let args;
82
+ if (!wsMatch) {
83
+ command = remainder;
84
+ args = '';
85
+ } else {
86
+ const idx = wsMatch.index;
87
+ command = remainder.slice(0, idx);
88
+ args = remainder.slice(idx + wsMatch[0].length);
89
+ }
90
+
91
+ args = stripMatchingQuotes(args.replace(/\s+$/, ''));
92
+
93
+ return { namespace, command, args };
94
+ }
95
+
96
+ function stripMatchingQuotes(s) {
97
+ if (s.length < 2) return s;
98
+ const first = s.charCodeAt(0);
99
+ const last = s.charCodeAt(s.length - 1);
100
+ // 0x22 = ", 0x27 = '
101
+ if ((first === 0x22 || first === 0x27) && first === last) {
102
+ return s.slice(1, -1);
103
+ }
104
+ return s;
105
+ }
106
+
107
+ // --- ijfw_run dispatch -----------------------------------------------------
108
+
109
+ /**
110
+ * dispatchRun(parsed, ctx) -> Promise<{ ok, ... }>
111
+ *
112
+ * ctx may carry { projectRoot, sessionId } -- both have safe defaults.
113
+ * Returns a plain JSON-serialisable object so the server.js handler can wrap
114
+ * it for MCP transport.
115
+ *
116
+ * Returns null if the namespace is not one this dispatcher owns; callers
117
+ * should treat null as "fall through to legacy ijfw_run".
118
+ */
119
+ export async function dispatchRun(parsed, ctx = {}) {
120
+ if (!parsed || typeof parsed !== 'object') return null;
121
+ if (!RUN_NAMESPACES.has(parsed.namespace)) return null;
122
+
123
+ const projectRoot = String(ctx.projectRoot || process.env.IJFW_PROJECT_DIR || process.cwd());
124
+ const sessionId = String(ctx.sessionId || process.env.IJFW_SESSION_ID || 'unknown');
125
+ // C9.6: provenance pointer (file path / observation kind / skill name).
126
+ // Optional -- callers that don't supply it leave raw.source NULL.
127
+ const provenance = ctx.source != null ? String(ctx.source) : null;
128
+
129
+ if (parsed.namespace === 'compute') {
130
+ return dispatchCompute(parsed, { projectRoot, sessionId, provenance });
131
+ }
132
+ if (parsed.namespace === 'index') {
133
+ return dispatchIndex(parsed, { projectRoot, sessionId, provenance });
134
+ }
135
+ if (parsed.namespace === 'detect') {
136
+ return dispatchDetect(parsed, { projectRoot, sessionId });
137
+ }
138
+ if (parsed.namespace === 'graph') {
139
+ return dispatchGraph(parsed, { projectRoot, sessionId });
140
+ }
141
+
142
+ return {
143
+ ok: false,
144
+ error: 'Unknown ijfw_run sub-command. Supported: compute:python, compute:js, index:<source>, detect:project_type, graph:traverse',
145
+ };
146
+ }
147
+
148
+ async function dispatchCompute(parsed, { projectRoot, sessionId /*, provenance unused for compute runs */ }) {
149
+ const cmd = parsed.command;
150
+ if (cmd !== 'js' && cmd !== 'python') {
151
+ return {
152
+ ok: false,
153
+ error: `Unknown compute language "${cmd}". Supported: compute:js, compute:python.`,
154
+ };
155
+ }
156
+ const script = parsed.args;
157
+ if (!script) {
158
+ return { ok: false, error: `compute:${cmd} requires a script body.` };
159
+ }
160
+
161
+ const vmOnly = parseBoolEnv(process.env.IJFW_COMPUTE_VM_ONLY);
162
+ const allowNet = parseBoolEnv(process.env.IJFW_COMPUTE_NET);
163
+ const timeoutMs = parseIntEnv(process.env.IJFW_COMPUTE_TIMEOUT_MS);
164
+
165
+ try {
166
+ const result = await runCompute({
167
+ language: cmd,
168
+ script,
169
+ projectRoot,
170
+ timeoutMs,
171
+ allowNet,
172
+ vmOnly,
173
+ sessionId,
174
+ });
175
+ // L1: audit-log compute runs that opted into the host network. Forensic
176
+ // questions ("which run hit the network and when") become a single FTS5
177
+ // query instead of a per-session log scrape.
178
+ if (allowNet) {
179
+ try { await logNetAllowed({ projectRoot, sessionId, cmd, script, result }); }
180
+ catch { /* audit log is best-effort; never block the run on it */ }
181
+ }
182
+ return {
183
+ ok: result.exitCode === 0 && !result.timedOut,
184
+ stdout: result.stdout,
185
+ stderr: result.stderr,
186
+ exitCode: result.exitCode,
187
+ durationMs: result.durationMs,
188
+ timedOut: result.timedOut,
189
+ truncated: result.truncated,
190
+ sandbox: result.sandbox,
191
+ };
192
+ } catch (err) {
193
+ return {
194
+ ok: false,
195
+ error: `compute:${cmd} did not complete: ${err && err.message ? err.message : String(err)}`,
196
+ code: err && err.code ? err.code : null,
197
+ };
198
+ }
199
+ }
200
+
201
+ // L1 helper: write an audit_finding row when allowNet=true. Body is a
202
+ // compact JSON envelope -- script preview (first 240 chars) + result
203
+ // summary (exit, duration, truncation, sandbox kind).
204
+ async function logNetAllowed({ projectRoot, sessionId, cmd, script, result }) {
205
+ let db;
206
+ try {
207
+ db = await openDb(projectRoot);
208
+ const preview = String(script || '').slice(0, 240);
209
+ const body = JSON.stringify({
210
+ lang: cmd,
211
+ preview,
212
+ exit: result.exitCode,
213
+ duration_ms: result.durationMs,
214
+ timed_out: !!result.timedOut,
215
+ truncated: !!result.truncated,
216
+ sandbox_kind: result.sandbox && result.sandbox.kind,
217
+ sandbox_degraded: !!(result.sandbox && result.sandbox.degraded),
218
+ });
219
+ safeWrite(db, 'raw', {
220
+ source_kind: 'audit_finding',
221
+ session_id: sessionId,
222
+ project_root: projectRoot,
223
+ event_type: 'compute_net_allowed',
224
+ body,
225
+ ts: Date.now(),
226
+ });
227
+ } finally {
228
+ closeDb(db);
229
+ }
230
+ }
231
+
232
+ async function dispatchIndex(parsed, { projectRoot, sessionId, provenance }) {
233
+ const source = parsed.command;
234
+ if (!source) {
235
+ return { ok: false, error: 'index:<source> requires a source label after the colon.' };
236
+ }
237
+ // index:<kind> [--source=<provenance>] body...
238
+ // The --source= flag attaches a provenance pointer to the row (C9.6).
239
+ // When omitted, raw.source stays NULL. Inline-flag form keeps backward
240
+ // compatibility with `index:<kind> body` callers.
241
+ const { provenanceFlag, body } = extractProvenanceFlag(parsed.args);
242
+ if (!body) {
243
+ return { ok: false, error: `index:${source} requires content to index.` };
244
+ }
245
+ const provenancePointer = provenanceFlag != null ? provenanceFlag
246
+ : provenance != null ? provenance
247
+ : null;
248
+
249
+ let db;
250
+ try {
251
+ db = await openDb(projectRoot);
252
+ const row = {
253
+ source_kind: mapSourceKind(source),
254
+ session_id: sessionId,
255
+ project_root: projectRoot,
256
+ event_type: 'output',
257
+ body,
258
+ ts: Date.now(),
259
+ };
260
+ if (provenancePointer != null) row.source = provenancePointer;
261
+ const inserted = safeWrite(db, 'raw', row);
262
+ return {
263
+ ok: true,
264
+ source,
265
+ provenance: provenancePointer,
266
+ session_id: sessionId,
267
+ id: inserted && inserted.id != null ? inserted.id : null,
268
+ bytes: Buffer.byteLength(body, 'utf8'),
269
+ };
270
+ } catch (err) {
271
+ const code = err instanceof IntegrityError ? 'INTEGRITY'
272
+ : err instanceof SchemaVersionError ? 'SCHEMA_VERSION'
273
+ : err instanceof ComputeDbError ? 'COMPUTE_DB'
274
+ : null;
275
+ return {
276
+ ok: false,
277
+ error: `index:${source} did not complete: ${err && err.message ? err.message : String(err)}`,
278
+ code,
279
+ };
280
+ } finally {
281
+ closeDb(db);
282
+ }
283
+ }
284
+
285
+ async function dispatchDetect(parsed, { projectRoot, sessionId }) {
286
+ const cmd = parsed.command;
287
+ if (cmd !== 'project_type') {
288
+ return {
289
+ ok: false,
290
+ error: `Unknown detect sub-command "${cmd}". Supported: detect:project_type.`,
291
+ };
292
+ }
293
+
294
+ // Parse args -- recognised flags: --bg (fire-and-forget background scan),
295
+ // --no-c9 (force file-extension fallback), --max-files=N (test override).
296
+ const args = String(parsed.args || '').trim();
297
+ const tokens = args.length ? args.split(/\s+/) : [];
298
+ const flags = { bg: false, c9Available: true, maxFiles: null };
299
+ for (let i = 0; i < tokens.length; i++) {
300
+ const t = tokens[i];
301
+ if (t === '--bg' || t === '--background') flags.bg = true;
302
+ else if (t === '--no-c9') flags.c9Available = false;
303
+ else if (t === '--max-files') flags.maxFiles = Number(tokens[++i]);
304
+ else if (t.startsWith('--max-files=')) flags.maxFiles = Number(t.slice('--max-files='.length));
305
+ }
306
+
307
+ // V3-F3: --bg path spawns a detached child runner so the dispatcher
308
+ // returns immediately. Result lands in <project>/.ijfw/project.type async.
309
+ if (flags.bg) {
310
+ try {
311
+ const __filename = fileURLToPath(import.meta.url);
312
+ const runner = join(dirname(__filename), '..', 'cold-scan-runner.mjs');
313
+ const childArgs = [runner, '--project-root', projectRoot];
314
+ if (!flags.c9Available) childArgs.push('--no-c9');
315
+ if (flags.maxFiles) childArgs.push('--max-files', String(flags.maxFiles));
316
+ const child = spawn(process.execPath, childArgs, {
317
+ detached: true,
318
+ stdio: 'ignore',
319
+ env: { ...process.env, IJFW_SESSION_ID: sessionId || '' },
320
+ });
321
+ child.unref();
322
+ return { ok: true, mode: 'bg', spawned: true, pid: child.pid, projectRoot };
323
+ } catch (err) {
324
+ return {
325
+ ok: false,
326
+ error: `detect:project_type --bg did not spawn: ${err && err.message ? err.message : String(err)}`,
327
+ };
328
+ }
329
+ }
330
+
331
+ // Foreground sync path -- returns the full result + writes the cached
332
+ // .ijfw/project.type so subsequent reads short-circuit. P3-M1: cache
333
+ // even when scan_incomplete=true so /ijfw doctor + post-mortem grep can
334
+ // see the partial signal -- loadProjectType() returns null on cached
335
+ // scan_incomplete=true so consumers don't silently trust a partial walk.
336
+ try {
337
+ const result = detect(projectRoot, {
338
+ c9Available: flags.c9Available,
339
+ maxFiles: flags.maxFiles || undefined,
340
+ sessionId,
341
+ });
342
+ try { writeProjectType(projectRoot, result); } catch { /* best-effort cache */ }
343
+ return { ok: true, mode: 'sync', result };
344
+ } catch (err) {
345
+ return {
346
+ ok: false,
347
+ error: `detect:project_type did not complete: ${err && err.message ? err.message : String(err)}`,
348
+ };
349
+ }
350
+ }
351
+
352
+ // --- ijfw_run graph:* dispatch --------------------------------------------
353
+
354
+ // dispatchGraph(parsed, ctx) -> { ok, ... }
355
+ //
356
+ // Sub-commands:
357
+ // graph:traverse -- BFS from a start node id (or kind+name).
358
+ // args: JSON or "<kind>:<name> [depth=N] [edge_kinds=k1,k2]"
359
+ // graph:index -- extract entities from `args` body and write to graph.
360
+ // (Internal helper used by dream cycle / index pipeline.)
361
+ async function dispatchGraph(parsed, { projectRoot, sessionId }) {
362
+ const cmd = parsed.command;
363
+ if (cmd === 'traverse') return dispatchGraphTraverse(parsed, { projectRoot });
364
+ if (cmd === 'index') return dispatchGraphIndex(parsed, { projectRoot, sessionId });
365
+ return {
366
+ ok: false,
367
+ error: `Unknown graph sub-command "${cmd}". Supported: graph:traverse, graph:index.`,
368
+ };
369
+ }
370
+
371
+ // graph:traverse args formats:
372
+ // 1. JSON object: '{"start_node":"file:src/X","depth":2,"edge_kinds":["co_occurs"]}'
373
+ // Also accepts {"kind":"file","name":"src/X","depth":2,...}
374
+ // 2. Plain "<kind>:<name>" -- defaults depth=2, edge_kinds=['co_occurs']
375
+ async function dispatchGraphTraverse(parsed, { projectRoot }) {
376
+ const raw = String(parsed.args || '').trim();
377
+ if (!raw) return { ok: false, error: 'graph:traverse requires args.' };
378
+
379
+ let kind = null, name = null;
380
+ let depth = 2;
381
+ let edgeKinds = ['co_occurs'];
382
+ let weightThreshold = 0.5;
383
+ let startNodeId = null;
384
+
385
+ if (raw.startsWith('{')) {
386
+ let parsedJson;
387
+ try { parsedJson = JSON.parse(raw); }
388
+ catch (err) {
389
+ return { ok: false, error: `graph:traverse JSON parse failed: ${err.message}` };
390
+ }
391
+ if (parsedJson.start_node && typeof parsedJson.start_node === 'string') {
392
+ const colon = parsedJson.start_node.indexOf(':');
393
+ if (colon > 0) {
394
+ kind = parsedJson.start_node.slice(0, colon);
395
+ name = parsedJson.start_node.slice(colon + 1);
396
+ } else {
397
+ name = parsedJson.start_node;
398
+ }
399
+ }
400
+ if (parsedJson.kind) kind = String(parsedJson.kind);
401
+ if (parsedJson.name) name = String(parsedJson.name);
402
+ if (Number.isFinite(parsedJson.start_node_id)) startNodeId = Number(parsedJson.start_node_id);
403
+ if (Number.isFinite(parsedJson.depth)) depth = Number(parsedJson.depth);
404
+ if (Array.isArray(parsedJson.edge_kinds)) edgeKinds = parsedJson.edge_kinds.map(String);
405
+ if (Number.isFinite(parsedJson.weight_threshold)) weightThreshold = Number(parsedJson.weight_threshold);
406
+ } else {
407
+ // Plain "<kind>:<name>" + optional flags.
408
+ const colon = raw.indexOf(':');
409
+ if (colon > 0) {
410
+ kind = raw.slice(0, colon).split(/\s/)[0];
411
+ const rest = raw.slice(colon + 1);
412
+ // Pull off depth=N / edge_kinds=k1,k2 flags.
413
+ const flagRe = /(depth|edge_kinds|weight_threshold)=(\S+)/g;
414
+ let nameRest = rest;
415
+ for (const m of rest.matchAll(flagRe)) {
416
+ if (m[1] === 'depth') depth = Number(m[2]);
417
+ else if (m[1] === 'edge_kinds') edgeKinds = m[2].split(',').filter(Boolean);
418
+ else if (m[1] === 'weight_threshold') weightThreshold = Number(m[2]);
419
+ nameRest = nameRest.replace(m[0], '');
420
+ }
421
+ name = nameRest.trim();
422
+ } else {
423
+ name = raw;
424
+ }
425
+ }
426
+
427
+ let db;
428
+ try {
429
+ db = await openDb(projectRoot);
430
+ let resolvedId = startNodeId;
431
+ if (!resolvedId && kind && name) {
432
+ const node = resolveNode(db, kind, name);
433
+ if (!node) {
434
+ return { ok: false, error: `graph:traverse no node matching ${kind}:${name}.` };
435
+ }
436
+ resolvedId = node.id;
437
+ }
438
+ if (!resolvedId && name) {
439
+ // Try across all kinds for "name" alone.
440
+ const row = db.prepare(`SELECT id FROM kg_nodes WHERE name = ? LIMIT 1`).get(name);
441
+ if (!row) return { ok: false, error: `graph:traverse no node matching name=${name}.` };
442
+ resolvedId = Number(row.id);
443
+ }
444
+ if (!resolvedId) {
445
+ return { ok: false, error: 'graph:traverse requires start_node, kind+name, or start_node_id.' };
446
+ }
447
+ const result = bfsTraverse(db, resolvedId, depth, edgeKinds, { weightThreshold });
448
+ return {
449
+ ok: true,
450
+ start_node_id: resolvedId,
451
+ depth,
452
+ edge_kinds: edgeKinds,
453
+ weight_threshold: weightThreshold,
454
+ ...result,
455
+ };
456
+ } catch (err) {
457
+ return {
458
+ ok: false,
459
+ error: `graph:traverse did not complete: ${err && err.message ? err.message : String(err)}`,
460
+ code: err && err.code ? err.code : null,
461
+ };
462
+ } finally {
463
+ closeDb(db);
464
+ }
465
+ }
466
+
467
+ // graph:index args: body text (free-form). Extracts entities, writes
468
+ // kg_nodes + kg_edges. Acquires .graph-write.lock for the write phase.
469
+ async function dispatchGraphIndex(parsed, { projectRoot, sessionId }) {
470
+ const body = String(parsed.args || '').trim();
471
+ if (!body) return { ok: false, error: 'graph:index requires a body to index.' };
472
+
473
+ const entities = extractEntities(body, { minMentions: 1 });
474
+ if (entities.length === 0) {
475
+ return { ok: true, entities_extracted: 0, edges_added: 0, edges_updated: 0 };
476
+ }
477
+
478
+ let db;
479
+ let lock;
480
+ try {
481
+ db = await openDb(projectRoot);
482
+ lock = acquireGraphWriteLock(projectRoot);
483
+ const tx = db.txn(() => {
484
+ const result = writeEdges(db, sessionId || null, entities);
485
+ return result;
486
+ });
487
+ const result = tx();
488
+ return {
489
+ ok: true,
490
+ entities_extracted: entities.length,
491
+ nodes_upserted: result.nodes.length,
492
+ edges_added: result.edgesAdded,
493
+ edges_updated: result.edgesUpdated,
494
+ redacted_skipped: result.redactedSkipped,
495
+ };
496
+ } catch (err) {
497
+ return {
498
+ ok: false,
499
+ error: `graph:index did not complete: ${err && err.message ? err.message : String(err)}`,
500
+ code: err && err.code ? err.code : null,
501
+ };
502
+ } finally {
503
+ if (lock) lock.released();
504
+ closeDb(db);
505
+ }
506
+ }
507
+
508
+ // --- ijfw_memory_search dispatch ------------------------------------------
509
+
510
+ /**
511
+ * dispatchSearch(parsed, ctx) -> { ok, hits } | null
512
+ *
513
+ * - compute:<query> -> opens FTS5 db, returns top-k rows from raw_fts.
514
+ * - any other namespace -> returns null so the caller falls through to the
515
+ * existing memory-search handler.
516
+ */
517
+ export async function dispatchSearch(parsed, ctx = {}) {
518
+ if (!parsed || typeof parsed !== 'object') return null;
519
+ if (!SEARCH_NAMESPACES.has(parsed.namespace)) return null;
520
+
521
+ if (parsed.namespace === 'graph') {
522
+ return dispatchSearchGraph(parsed, ctx);
523
+ }
524
+
525
+ if (parsed.namespace === 'compute') {
526
+ const projectRoot = String(ctx.projectRoot || process.env.IJFW_PROJECT_DIR || process.cwd());
527
+ // FTS5 query is the command + args glued back together so phrase queries
528
+ // like `compute:foo bar` work without forcing the caller to quote.
529
+ const queryParts = [parsed.command, parsed.args].filter(Boolean);
530
+ const rawQuery = queryParts.join(' ').trim();
531
+ if (!rawQuery) {
532
+ return { ok: false, error: 'compute:<query> requires a query body.' };
533
+ }
534
+
535
+ // C9.6: optional `--session=<id>` flag scopes search to a single
536
+ // session. Stripped from the query before FTS5 sees it.
537
+ const sessionFilter = extractSessionFilter(rawQuery);
538
+ const queryAfterFilter = sessionFilter.remaining;
539
+
540
+ // C9.5: synonym expansion, default-on. Honours per-call ctx.synonym
541
+ // override (treated as IJFW_SYNONYM_EXPAND env value), then process
542
+ // env. `synonym_matches` is reported back to the caller so users can
543
+ // see what fired and disable expansion if precision matters more.
544
+ const envOverride = ctx.synonym !== undefined ? String(ctx.synonym) : undefined;
545
+ const expansion = expandQuery(queryAfterFilter, { env: envOverride });
546
+ const finalQuery = expansion.expanded;
547
+
548
+ const k = parseIntEnv(ctx.limit, 10);
549
+ let db;
550
+ try {
551
+ db = await openDb(projectRoot);
552
+ const ftsHits = search(db, 'raw', finalQuery, k);
553
+ // C9.6: surface source + session_id on every hit. Apply session
554
+ // filter post-FTS (cheaper than rebuilding the SQL for one column).
555
+ let hits = ftsHits.map(h => ({
556
+ ...h,
557
+ source: h.source != null ? h.source : null,
558
+ session_id: h.session_id != null ? h.session_id : null,
559
+ }));
560
+ if (sessionFilter.sessionId) {
561
+ hits = hits.filter(h => h.session_id === sessionFilter.sessionId);
562
+ }
563
+ return {
564
+ ok: true,
565
+ hits,
566
+ synonym_matches: expansion.synonym_matches,
567
+ synonym_applied: expansion.applied,
568
+ query: finalQuery,
569
+ session_filter: sessionFilter.sessionId || null,
570
+ };
571
+ } catch (err) {
572
+ const code = err instanceof SchemaVersionError ? 'SCHEMA_VERSION'
573
+ : err instanceof ComputeDbError ? 'COMPUTE_DB'
574
+ : null;
575
+ return {
576
+ ok: false,
577
+ error: `compute:${rawQuery} did not complete: ${err && err.message ? err.message : String(err)}`,
578
+ code,
579
+ };
580
+ } finally {
581
+ closeDb(db);
582
+ }
583
+ }
584
+
585
+ return null;
586
+ }
587
+
588
+ // dispatchSearchGraph: graph:related <query>
589
+ //
590
+ // Resolves a query string to a kg_nodes entry (exact name match, then
591
+ // case-insensitive substring), runs BFS, and returns merged FTS5 + graph
592
+ // hits. The FTS5 hits use the same query unchanged so the caller's
593
+ // "search across memory" promise still holds; graph results are tagged
594
+ // with `source: 'graph'` so the caller can render them differently.
595
+ async function dispatchSearchGraph(parsed, ctx) {
596
+ const cmd = parsed.command;
597
+ if (cmd !== 'related') {
598
+ return { ok: false, error: `Unknown graph search sub-command "${cmd}". Supported: graph:related.` };
599
+ }
600
+ const projectRoot = String(ctx.projectRoot || process.env.IJFW_PROJECT_DIR || process.cwd());
601
+ const query = String(parsed.args || '').trim();
602
+ if (!query) return { ok: false, error: 'graph:related requires a query.' };
603
+
604
+ const k = parseIntEnv(ctx.limit, 10);
605
+ let db;
606
+ try {
607
+ db = await openDb(projectRoot);
608
+
609
+ // FTS5 hits over raw_fts -- same surface as compute:<query>.
610
+ let ftsHits = [];
611
+ try {
612
+ ftsHits = search(db, 'raw', query, k).map(h => ({
613
+ ...h,
614
+ source_type: 'fts5',
615
+ }));
616
+ } catch { /* FTS5 may fail on syntax; degrade to graph-only. */ }
617
+
618
+ // Graph hits -- BFS related.
619
+ const graphResult = bfsRelated(db, query);
620
+ const graphHits = graphResult.nodes.map(n => ({
621
+ id: n.id,
622
+ kind: n.kind,
623
+ name: n.name,
624
+ first_seen: n.first_seen,
625
+ last_seen: n.last_seen,
626
+ redacted: !!n.redacted,
627
+ source_type: 'graph',
628
+ }));
629
+
630
+ return {
631
+ ok: true,
632
+ query,
633
+ resolved: graphResult.resolved || null,
634
+ fts_hits: ftsHits,
635
+ graph_hits: graphHits,
636
+ graph_edges: graphResult.edges,
637
+ graph_traversal_path: graphResult.traversal_path,
638
+ };
639
+ } catch (err) {
640
+ const code = err instanceof SchemaVersionError ? 'SCHEMA_VERSION'
641
+ : err instanceof ComputeDbError ? 'COMPUTE_DB'
642
+ : null;
643
+ return {
644
+ ok: false,
645
+ error: `graph:related did not complete: ${err && err.message ? err.message : String(err)}`,
646
+ code,
647
+ };
648
+ } finally {
649
+ closeDb(db);
650
+ }
651
+ }
652
+
653
+ // --- helpers ---------------------------------------------------------------
654
+
655
+ function parseBoolEnv(v) {
656
+ if (v === undefined || v === null || v === '') return false;
657
+ const s = String(v).toLowerCase();
658
+ return s === '1' || s === 'true' || s === 'yes' || s === 'on';
659
+ }
660
+
661
+ function parseIntEnv(v, fallback) {
662
+ if (v === undefined || v === null || v === '') return fallback;
663
+ const n = parseInt(v, 10);
664
+ return Number.isFinite(n) && n > 0 ? n : fallback;
665
+ }
666
+
667
+ // Map a colloquial source label into the schema's source_kind enum.
668
+ // Unknown labels collapse to 'tool_result' -- the catch-all for caller-side
669
+ // indexing per schema.sql.
670
+ function mapSourceKind(source) {
671
+ switch (source) {
672
+ case 'compute_output':
673
+ case 'compute':
674
+ return 'compute_output';
675
+ case 'memory_dump':
676
+ case 'memory':
677
+ return 'memory_dump';
678
+ case 'audit_finding':
679
+ case 'audit':
680
+ return 'audit_finding';
681
+ case 'tool_result':
682
+ case 'source':
683
+ default:
684
+ return 'tool_result';
685
+ }
686
+ }
687
+
688
+ // Pull a leading `--source=<value>` flag from an args body for index:*.
689
+ // The flag must be the first whitespace-delimited token; everything after
690
+ // the first whitespace run becomes the indexed body. Both quoted and bare
691
+ // values are supported. Returns { provenanceFlag, body }.
692
+ function extractProvenanceFlag(args) {
693
+ if (typeof args !== 'string') return { provenanceFlag: null, body: '' };
694
+ const s = args.replace(/^\s+/, '');
695
+ if (!s.startsWith('--source=')) {
696
+ return { provenanceFlag: null, body: args };
697
+ }
698
+ // Find the end of the flag value -- first whitespace not inside quotes.
699
+ let i = '--source='.length;
700
+ let value = '';
701
+ if (s[i] === '"' || s[i] === "'") {
702
+ const q = s[i];
703
+ i++;
704
+ while (i < s.length && s[i] !== q) { value += s[i]; i++; }
705
+ if (i < s.length) i++; // skip closing quote
706
+ } else {
707
+ while (i < s.length && !/\s/.test(s[i])) { value += s[i]; i++; }
708
+ }
709
+ // Skip whitespace, the rest is body.
710
+ while (i < s.length && /\s/.test(s[i])) i++;
711
+ return { provenanceFlag: value || null, body: s.slice(i) };
712
+ }
713
+
714
+ // Pull a leading or trailing `--session=<id>` filter from a query string.
715
+ // Strips the flag before passing the query to FTS5 -- otherwise FTS5
716
+ // would try to match `--session=...` literally. Returns { sessionId, remaining }.
717
+ function extractSessionFilter(query) {
718
+ if (typeof query !== 'string') return { sessionId: null, remaining: '' };
719
+ // Match `--session=<value>` where value is unquoted up to whitespace,
720
+ // or quoted up to the matching quote. Anchored to word boundaries so
721
+ // we don't strip a literal `--session=...` inside a quoted phrase.
722
+ const re = /(?:^|\s)--session=("([^"]*)"|'([^']*)'|(\S+))(?=\s|$)/;
723
+ const m = query.match(re);
724
+ if (!m) return { sessionId: null, remaining: query };
725
+ const sessionId = m[2] != null ? m[2] : m[3] != null ? m[3] : m[4];
726
+ const before = query.slice(0, m.index);
727
+ const after = query.slice(m.index + m[0].length);
728
+ const remaining = (before + ' ' + after).replace(/\s+/g, ' ').trim();
729
+ return { sessionId, remaining };
730
+ }
731
+
732
+ export const __test = { stripMatchingQuotes, mapSourceKind, extractProvenanceFlag, extractSessionFilter };