moflo 4.9.36 → 4.10.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 (79) hide show
  1. package/.claude/guidance/shipped/moflo-agent-rules.md +12 -0
  2. package/.claude/guidance/shipped/moflo-memory-protocol.md +34 -0
  3. package/.claude/guidance/shipped/moflo-memorydb-maintenance.md +22 -11
  4. package/.claude/guidance/shipped/moflo-root-cause-discipline.md +47 -0
  5. package/.claude/guidance/shipped/moflo-subagents.md +4 -0
  6. package/.claude/helpers/gate.cjs +3 -3
  7. package/.claude/helpers/statusline.cjs +69 -33
  8. package/.claude/helpers/subagent-bootstrap.json +1 -1
  9. package/.claude/helpers/subagent-start.cjs +1 -1
  10. package/.claude/skills/eldar/SKILL.md +8 -0
  11. package/bin/build-embeddings.mjs +6 -20
  12. package/bin/cli.js +5 -0
  13. package/bin/gate.cjs +3 -3
  14. package/bin/generate-code-map.mjs +4 -24
  15. package/bin/hooks.mjs +3 -12
  16. package/bin/index-all.mjs +3 -13
  17. package/bin/index-guidance.mjs +59 -119
  18. package/bin/index-patterns.mjs +6 -24
  19. package/bin/index-tests.mjs +4 -23
  20. package/bin/lib/db-repair.mjs +4 -25
  21. package/bin/lib/get-backend.mjs +306 -0
  22. package/bin/lib/incremental-write.mjs +27 -7
  23. package/bin/lib/moflo-paths.mjs +64 -4
  24. package/bin/lib/suppress-sqlite-warning.mjs +57 -0
  25. package/bin/migrations/knowledge-purge.mjs +7 -8
  26. package/bin/migrations/knowledge-to-learnings.mjs +7 -9
  27. package/bin/migrations/purge-doc-entries.mjs +52 -0
  28. package/bin/migrations/strip-context-preambles.mjs +95 -0
  29. package/bin/run-migrations.mjs +1 -10
  30. package/bin/semantic-search.mjs +11 -19
  31. package/bin/session-start-launcher.mjs +102 -100
  32. package/bin/simplify-classify.cjs +38 -17
  33. package/dist/src/cli/commands/daemon.js +38 -11
  34. package/dist/src/cli/commands/doctor-checks-coverage-truth.js +136 -0
  35. package/dist/src/cli/commands/doctor-checks-memory-access.js +244 -5
  36. package/dist/src/cli/commands/doctor-checks-memory.js +13 -18
  37. package/dist/src/cli/commands/doctor-checks-version-skew.js +94 -0
  38. package/dist/src/cli/commands/doctor-checks-writers-audit.js +170 -0
  39. package/dist/src/cli/commands/doctor-embedding-hygiene.js +3 -15
  40. package/dist/src/cli/commands/doctor-fixes.js +30 -0
  41. package/dist/src/cli/commands/doctor-registry.js +14 -0
  42. package/dist/src/cli/commands/doctor.js +1 -1
  43. package/dist/src/cli/commands/embeddings.js +17 -22
  44. package/dist/src/cli/commands/memory.js +54 -75
  45. package/dist/src/cli/embeddings/persistent-cache.js +44 -83
  46. package/dist/src/cli/init/claudemd-generator.js +4 -0
  47. package/dist/src/cli/init/moflo-init.js +40 -0
  48. package/dist/src/cli/mcp-tools/memory-tools.js +177 -32
  49. package/dist/src/cli/memory/bridge-core.js +256 -30
  50. package/dist/src/cli/memory/bridge-entries.js +76 -8
  51. package/dist/src/cli/memory/controller-registry.js +7 -2
  52. package/dist/src/cli/memory/controllers/batch-operations.js +5 -1
  53. package/dist/src/cli/memory/controllers/hierarchical-memory.js +7 -2
  54. package/dist/src/cli/memory/controllers/mutation-guard.js +22 -2
  55. package/dist/src/cli/memory/daemon-backend.js +400 -0
  56. package/dist/src/cli/memory/daemon-write-client.js +192 -15
  57. package/dist/src/cli/memory/database-provider.js +57 -40
  58. package/dist/src/cli/memory/hnsw-persistence.js +6 -8
  59. package/dist/src/cli/memory/index.js +0 -1
  60. package/dist/src/cli/memory/memory-bridge.js +40 -8
  61. package/dist/src/cli/memory/memory-initializer.js +286 -220
  62. package/dist/src/cli/memory/rvf-migration.js +25 -11
  63. package/dist/src/cli/memory/sqlite-backend.js +573 -0
  64. package/dist/src/cli/memory/suppress-sqlite-warning.js +49 -0
  65. package/dist/src/cli/services/cherry-pick-learnings.js +32 -21
  66. package/dist/src/cli/services/daemon-dashboard.js +13 -1
  67. package/dist/src/cli/services/daemon-lock.js +58 -1
  68. package/dist/src/cli/services/daemon-memory-rpc.js +245 -10
  69. package/dist/src/cli/services/embeddings-migration.js +9 -12
  70. package/dist/src/cli/services/ephemeral-namespace-purge.js +6 -11
  71. package/dist/src/cli/services/learning-service.js +12 -20
  72. package/dist/src/cli/services/project-root.js +69 -9
  73. package/dist/src/cli/services/soft-delete-purge.js +6 -11
  74. package/dist/src/cli/services/sqljs-migration-store.js +4 -1
  75. package/dist/src/cli/services/subagent-bootstrap.js +1 -1
  76. package/dist/src/cli/shared/events/event-store.js +26 -55
  77. package/dist/src/cli/version.js +1 -1
  78. package/package.json +2 -4
  79. package/dist/src/cli/memory/sqljs-backend.js +0 -643
@@ -1,28 +1,31 @@
1
1
  /**
2
- * SQLite-backed Persistent Cache for Embeddings (sql.js)
2
+ * SQLite-backed Persistent Cache for Embeddings (node:sqlite)
3
3
  *
4
4
  * Features:
5
- * - Cross-platform support (pure JavaScript/WASM, no native compilation)
6
- * - Disk persistence across sessions
5
+ * - Built-in node:sqlite (Node 22+) no native compile, no WASM
6
+ * - Disk persistence via WAL — writes are incremental, no whole-file dumps
7
7
  * - LRU eviction with configurable max size
8
8
  * - Automatic schema creation
9
9
  * - TTL support for cache entries
10
10
  * - Lazy initialization (no startup cost if not used)
11
+ *
12
+ * Phase 5 (#1084) migrated this from sql.js to node:sqlite via the unified
13
+ * `openDaemonDatabase` factory. The sql.js whole-file-export pattern was the
14
+ * source of the multi-writer clobber class fixed in epic #1078.
11
15
  */
12
- import { existsSync, mkdirSync, readFileSync } from 'fs';
16
+ import { existsSync, mkdirSync, statSync } from 'fs';
13
17
  import { dirname } from 'path';
14
- import { atomicWriteFileSync } from '../shared/utils/atomic-file-write.js';
18
+ import { openDaemonDatabase } from '../memory/daemon-backend.js';
15
19
  /**
16
- * SQLite-backed persistent embedding cache using sql.js (pure JS/WASM)
20
+ * SQLite-backed persistent embedding cache using node:sqlite via the
21
+ * unified daemon-backend factory.
17
22
  */
18
23
  export class PersistentEmbeddingCache {
19
24
  db = null;
20
- SQL = null;
21
25
  initialized = false;
22
26
  dirty = false;
23
27
  hits = 0;
24
28
  misses = 0;
25
- autoSaveTimer = null;
26
29
  dbPath;
27
30
  maxSize;
28
31
  ttlMs;
@@ -31,33 +34,29 @@ export class PersistentEmbeddingCache {
31
34
  this.dbPath = config.dbPath;
32
35
  this.maxSize = config.maxSize ?? 10000;
33
36
  this.ttlMs = config.ttlMs ?? 7 * 24 * 60 * 60 * 1000; // 7 days
34
- this.autoSaveInterval = config.autoSaveInterval ?? 30000; // 30 seconds
37
+ // Kept for API compatibility; node:sqlite WAL persists incrementally so
38
+ // there's no auto-save timer to drive any more.
39
+ this.autoSaveInterval = config.autoSaveInterval ?? 30000;
35
40
  }
36
41
  /**
37
- * Lazily initialize database connection
42
+ * Lazily initialize database connection.
43
+ *
44
+ * Phase 5 (#1084): swapped the sql.js readFileSync + new SQL.Database
45
+ * round-trip for openDaemonDatabase(dbPath). WAL writes incrementally so
46
+ * the auto-save timer + saveToFile() that used to live here are gone.
38
47
  */
39
48
  async ensureInitialized() {
40
49
  if (this.initialized)
41
50
  return;
42
51
  try {
43
- // Dynamically import sql.js
44
- const initSqlJs = (await import('sql.js')).default;
45
- // Initialize sql.js (loads WASM)
46
- this.SQL = await initSqlJs();
47
- // Ensure directory exists
52
+ // Ensure directory exists (openDaemonDatabase also does this, but the
53
+ // dbExisted probe below needs the path to be stable first).
48
54
  const dir = dirname(this.dbPath);
49
55
  if (!existsSync(dir)) {
50
56
  mkdirSync(dir, { recursive: true });
51
57
  }
52
- // Load existing database or create new
53
58
  const dbExisted = existsSync(this.dbPath);
54
- if (dbExisted) {
55
- const fileBuffer = readFileSync(this.dbPath);
56
- this.db = new this.SQL.Database(fileBuffer);
57
- }
58
- else {
59
- this.db = new this.SQL.Database();
60
- }
59
+ this.db = openDaemonDatabase(this.dbPath);
61
60
  // Create schema
62
61
  this.db.run(`
63
62
  CREATE TABLE IF NOT EXISTS embeddings (
@@ -88,53 +87,16 @@ export class PersistentEmbeddingCache {
88
87
  }
89
88
  // Clean expired entries on startup
90
89
  this.cleanExpired();
91
- // Save after initialization to persist schema
92
- this.saveToFile();
93
- // Start auto-save timer
94
- this.startAutoSave();
95
90
  this.initialized = true;
96
91
  }
97
92
  catch (error) {
98
- // If sql.js not available, fall back gracefully
99
- console.warn('[persistent-cache] sql.js not available, cache disabled:', error instanceof Error ? error.message : error);
93
+ // node:sqlite is built into Node 22+, so failure here is a real fault
94
+ // (corrupt DB, permission error, etc.) rather than missing dep. Surface
95
+ // and disable the cache so the embedding pipeline keeps working.
96
+ console.warn('[persistent-cache] disabled:', error instanceof Error ? error.message : error);
100
97
  this.initialized = true; // Mark as initialized to prevent retry
101
98
  }
102
99
  }
103
- /**
104
- * Start auto-save timer
105
- */
106
- startAutoSave() {
107
- if (this.autoSaveTimer)
108
- return;
109
- this.autoSaveTimer = setInterval(() => {
110
- if (this.dirty && this.db) {
111
- this.saveToFile();
112
- }
113
- }, this.autoSaveInterval);
114
- }
115
- /**
116
- * Stop auto-save timer
117
- */
118
- stopAutoSave() {
119
- if (this.autoSaveTimer) {
120
- clearInterval(this.autoSaveTimer);
121
- this.autoSaveTimer = null;
122
- }
123
- }
124
- /**
125
- * Save database to file
126
- */
127
- saveToFile() {
128
- if (!this.db)
129
- return;
130
- try {
131
- atomicWriteFileSync(this.dbPath, this.db.export());
132
- this.dirty = false;
133
- }
134
- catch (error) {
135
- console.error('[persistent-cache] Save error:', error);
136
- }
137
- }
138
100
  /**
139
101
  * Generate cache key from text
140
102
  */
@@ -284,11 +246,12 @@ export class PersistentEmbeddingCache {
284
246
  if (this.db) {
285
247
  const result = this.db.exec('SELECT COUNT(*) as count FROM embeddings');
286
248
  stats.size = result[0]?.values[0]?.[0] ?? 0;
287
- // Get file size if exists
249
+ // Get file size if exists. node:sqlite leaves the file on disk via WAL
250
+ // so statSync is enough — no whole-file read needed (sql.js used to
251
+ // readFileSync the entire DB to compute size).
288
252
  if (existsSync(this.dbPath)) {
289
253
  try {
290
- const buffer = readFileSync(this.dbPath);
291
- stats.dbSizeBytes = buffer.length;
254
+ stats.dbSizeBytes = statSync(this.dbPath).size;
292
255
  }
293
256
  catch {
294
257
  // Ignore
@@ -298,51 +261,49 @@ export class PersistentEmbeddingCache {
298
261
  return stats;
299
262
  }
300
263
  /**
301
- * Clear all cached entries
264
+ * Clear all cached entries. WAL persists the DELETE incrementally so
265
+ * there's no explicit flush — Phase 5 (#1084) removed the sql.js
266
+ * whole-file save here.
302
267
  */
303
268
  async clear() {
304
269
  await this.ensureInitialized();
305
270
  if (!this.db)
306
271
  return;
307
272
  this.db.run('DELETE FROM embeddings');
308
- this.dirty = true;
309
273
  this.hits = 0;
310
274
  this.misses = 0;
311
- this.saveToFile();
275
+ this.dirty = false;
312
276
  }
313
277
  /**
314
- * Force save to disk
278
+ * Force save to disk. node:sqlite + WAL persists each `db.run` immediately,
279
+ * so flush is a no-op kept for API compatibility.
315
280
  */
316
281
  async flush() {
317
282
  await this.ensureInitialized();
318
- if (this.db && this.dirty) {
319
- this.saveToFile();
320
- }
283
+ this.dirty = false;
321
284
  }
322
285
  /**
323
286
  * Close database connection
324
287
  */
325
288
  async close() {
326
- this.stopAutoSave();
327
289
  if (this.db) {
328
- // Save before closing
329
- if (this.dirty) {
330
- this.saveToFile();
331
- }
332
290
  this.db.close();
333
291
  this.db = null;
334
- this.SQL = null;
335
292
  this.initialized = false;
336
293
  }
337
294
  }
338
295
  }
339
296
  /**
340
- * Check if persistent cache is available (sql.js installed)
297
+ * Check if persistent cache is available. node:sqlite is built into Node 22+
298
+ * (moflo's minimum) so this always succeeds; kept for API compatibility.
299
+ *
300
+ * Loads the warning-suppression side-effect BEFORE the probe import so the
301
+ * once-per-process ExperimentalWarning doesn't leak to stderr (#1098).
341
302
  */
342
303
  export async function isPersistentCacheAvailable() {
343
304
  try {
344
- const initSqlJs = (await import('sql.js')).default;
345
- await initSqlJs();
305
+ await import('../memory/suppress-sqlite-warning.js');
306
+ await import('node:sqlite');
346
307
  return true;
347
308
  }
348
309
  catch {
@@ -31,6 +31,10 @@ function mofloSection() {
31
31
 
32
32
  Your first tool call MUST be \`mcp__moflo__memory_search\` — before any Glob/Grep/Read. Search \`guidance\`, \`patterns\`, and \`learnings\` every prompt; add \`code-map\` when navigating code, \`tests\` when looking for test inventory or coverage. When the user says "remember this", call \`mcp__moflo__memory_store\` with namespace \`learnings\`.
33
33
 
34
+ ### Traverse chunks, don't bulk-retrieve
35
+
36
+ Search results carry a compact \`navigation\` crumb (parentDoc, prev/next, chunkTitle). For adjacent/sibling/hierarchical context use \`mcp__moflo__memory_get_neighbors\`; for full chunk content use \`mcp__moflo__memory_retrieve\`; \`Read\` the source doc only via \`parentPath\` when truly needed. Full protocol: \`.claude/guidance/moflo-memory-protocol.md\`.
37
+
34
38
  ### Auto-enforced gates
35
39
 
36
40
  - **TaskCreate-first**: Call \`TaskCreate\` before spawning the Agent tool
@@ -480,11 +480,51 @@ function syncScripts(root, force) {
480
480
  copied++;
481
481
  }
482
482
  }
483
+ // Sync bin/lib/ and bin/migrations/ recursively. The top-level scripts
484
+ // import `./lib/moflo-resolve.mjs` etc., so omitting these subtrees leaves
485
+ // every synced script unable to load (#1090). The upgrade path in
486
+ // executor.ts and the post-install bootstrap both sync these trees — init
487
+ // had drifted out of step.
488
+ copied += syncTree(path.join(binDir, 'lib'), path.join(scriptsDir, 'lib'), force);
489
+ copied += syncTree(path.join(binDir, 'migrations'), path.join(scriptsDir, 'migrations'), force);
483
490
  if (copied === 0) {
484
491
  return { name: '.claude/scripts/', status: 'skipped', detail: 'Scripts already up to date' };
485
492
  }
486
493
  return { name: '.claude/scripts/', status: 'updated', detail: `${copied} scripts synced from moflo` };
487
494
  }
495
+ function syncTree(srcRoot, destRoot, force) {
496
+ if (!fs.existsSync(srcRoot))
497
+ return 0;
498
+ let entries;
499
+ try {
500
+ entries = fs.readdirSync(srcRoot, { recursive: true, withFileTypes: true });
501
+ }
502
+ catch {
503
+ return 0;
504
+ }
505
+ let copied = 0;
506
+ for (const entry of entries) {
507
+ if (!entry.isFile())
508
+ continue;
509
+ const parent = entry.parentPath
510
+ ?? entry.path
511
+ ?? srcRoot;
512
+ const absSrc = path.join(parent, entry.name);
513
+ const rel = path.relative(srcRoot, absSrc).split(path.sep).join('/');
514
+ const absDest = path.join(destRoot, rel);
515
+ try {
516
+ fs.mkdirSync(path.dirname(absDest), { recursive: true });
517
+ if (!fs.existsSync(absDest) || force || isStale(absSrc, absDest)) {
518
+ fs.copyFileSync(absSrc, absDest);
519
+ copied++;
520
+ }
521
+ }
522
+ catch {
523
+ // Non-fatal — skip individual file on error
524
+ }
525
+ }
526
+ return copied;
527
+ }
488
528
  function isStale(srcPath, destPath) {
489
529
  try {
490
530
  return fs.statSync(srcPath).mtimeMs > fs.statSync(destPath).mtimeMs;
@@ -54,6 +54,63 @@ function notifyMemoryGate() {
54
54
  const MAX_KEY_LENGTH = 1024;
55
55
  const MAX_VALUE_SIZE = 1024 * 1024; // 1MB
56
56
  const MAX_QUERY_LENGTH = 4096;
57
+ function parseNavigation(metadataJson, mode) {
58
+ if (!metadataJson)
59
+ return null;
60
+ let meta;
61
+ try {
62
+ meta = JSON.parse(metadataJson);
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ if (!meta || typeof meta !== 'object')
68
+ return null;
69
+ // Discriminator: only `type === 'chunk'` entries carry the nav fields.
70
+ if (meta.type !== 'chunk')
71
+ return null;
72
+ if (mode === 'compact') {
73
+ return {
74
+ parentDoc: meta.parentDoc,
75
+ prevChunk: meta.prevChunk ?? null,
76
+ nextChunk: meta.nextChunk ?? null,
77
+ chunkTitle: meta.chunkTitle,
78
+ };
79
+ }
80
+ return {
81
+ parentDoc: meta.parentDoc,
82
+ parentPath: meta.parentPath,
83
+ prevChunk: meta.prevChunk ?? null,
84
+ nextChunk: meta.nextChunk ?? null,
85
+ siblings: meta.siblings,
86
+ chunkIndex: meta.chunkIndex,
87
+ totalChunks: meta.totalChunks,
88
+ hierarchicalParent: meta.hierarchicalParent ?? null,
89
+ hierarchicalChildren: meta.hierarchicalChildren ?? null,
90
+ chunkTitle: meta.chunkTitle,
91
+ headerLevel: meta.headerLevel,
92
+ };
93
+ }
94
+ function shapeRetrievedEntry(entry) {
95
+ let value = entry.content;
96
+ try {
97
+ value = JSON.parse(entry.content);
98
+ }
99
+ catch { /* keep string */ }
100
+ return {
101
+ key: entry.key,
102
+ namespace: entry.namespace,
103
+ value,
104
+ tags: entry.tags,
105
+ storedAt: entry.createdAt,
106
+ updatedAt: entry.updatedAt,
107
+ accessCount: entry.accessCount,
108
+ hasEmbedding: entry.hasEmbedding,
109
+ navigation: parseNavigation(entry.metadata, 'full'),
110
+ found: true,
111
+ backend: 'sql.js + HNSW',
112
+ };
113
+ }
57
114
  function validateMemoryInput(key, value, query) {
58
115
  if (key && key.length > MAX_KEY_LENGTH) {
59
116
  throw new Error(`Key exceeds maximum length of ${MAX_KEY_LENGTH} characters`);
@@ -154,7 +211,7 @@ async function ensureInitialized() {
154
211
  export const memoryTools = [
155
212
  {
156
213
  name: 'memory_store',
157
- description: 'Store a value in memory with vector embedding for semantic search (sql.js + HNSW backend). Upserts by default — pass upsert:false to fail on duplicate keys.',
214
+ description: 'Store a value in memory with vector embedding for semantic search (sql.js + HNSW backend). Upserts by default — pass upsert:false to fail on duplicate keys. Optional `metadata` lets chunk-row producers set the navigation fields (parentDoc, prevChunk, nextChunk, siblings, …) that `memory_get_neighbors` reads.',
158
215
  category: 'memory',
159
216
  inputSchema: {
160
217
  type: 'object',
@@ -169,6 +226,11 @@ export const memoryTools = [
169
226
  },
170
227
  ttl: { type: 'number', description: 'Time-to-live in seconds (optional)' },
171
228
  upsert: { type: 'boolean', description: 'If false, fail on duplicate keys instead of replacing (default: true)' },
229
+ metadata: {
230
+ type: 'object',
231
+ additionalProperties: true,
232
+ description: 'Optional per-row metadata persisted to the `metadata` TEXT column. For chunk entries, include `type: "chunk"` plus the navigation fields (parentDoc, parentPath, chunkIndex, totalChunks, prevChunk, nextChunk, siblings, hierarchicalParent, hierarchicalChildren, chunkTitle, headerLevel) so `memory_get_neighbors` can traverse. Capped at 64KB serialised.',
233
+ },
172
234
  },
173
235
  required: ['key', 'value'],
174
236
  },
@@ -180,6 +242,7 @@ export const memoryTools = [
180
242
  const value = typeof input.value === 'string' ? input.value : JSON.stringify(input.value);
181
243
  const tags = input.tags || [];
182
244
  const ttl = input.ttl;
245
+ const metadata = input.metadata;
183
246
  // #962: default upsert=true — silent UNIQUE-constraint failures on update
184
247
  // were dropping schedule cancels and similar updates on the floor.
185
248
  const upsert = input.upsert === false ? false : true;
@@ -193,6 +256,7 @@ export const memoryTools = [
193
256
  generateEmbeddingFlag: true,
194
257
  tags,
195
258
  ttl,
259
+ metadata,
196
260
  upsert,
197
261
  });
198
262
  const duration = performance.now() - startTime;
@@ -220,7 +284,7 @@ export const memoryTools = [
220
284
  },
221
285
  {
222
286
  name: 'memory_retrieve',
223
- description: 'Retrieve a value from memory by key',
287
+ description: 'Retrieve the full value for a SPECIFIC key. For chunk entries, prefer `memory_get_neighbors` for traversal — bulk-retrieving search hits is a protocol violation. The returned `navigation` object lets you keep traversing. See `.claude/guidance/moflo-memory-protocol.md`.',
224
288
  category: 'memory',
225
289
  inputSchema: {
226
290
  type: 'object',
@@ -238,27 +302,9 @@ export const memoryTools = [
238
302
  try {
239
303
  const result = await getEntry({ key, namespace });
240
304
  if (result.found && result.entry) {
241
- // Try to parse JSON value
242
- let value = result.entry.content;
243
- try {
244
- value = JSON.parse(result.entry.content);
245
- }
246
- catch {
247
- // Keep as string
248
- }
249
305
  notifyMemoryGate();
250
- return {
251
- key,
252
- namespace,
253
- value,
254
- tags: result.entry.tags,
255
- storedAt: result.entry.createdAt,
256
- updatedAt: result.entry.updatedAt,
257
- accessCount: result.entry.accessCount,
258
- hasEmbedding: result.entry.hasEmbedding,
259
- found: true,
260
- backend: 'sql.js + HNSW',
261
- };
306
+ // #1053 S1: surface RAG navigation for chunked guidance entries.
307
+ return shapeRetrievedEntry(result.entry);
262
308
  }
263
309
  return {
264
310
  key,
@@ -280,15 +326,15 @@ export const memoryTools = [
280
326
  },
281
327
  {
282
328
  name: 'memory_search',
283
- description: 'Semantic vector search using HNSW index (150x-12,500x faster than keyword search)',
329
+ description: 'Semantic vector search using HNSW index (150x-12,500x faster than keyword search). When a result has a non-null `navigation` crumb, you MUST traverse via `memory_get_neighbors` — bulk `memory_retrieve` per hit is a protocol violation. See `.claude/guidance/moflo-memory-protocol.md`.',
284
330
  category: 'memory',
285
331
  inputSchema: {
286
332
  type: 'object',
287
333
  properties: {
288
334
  query: { type: 'string', description: 'Search query (semantic similarity)' },
289
335
  namespace: { type: 'string', description: 'Namespace to search (default: all namespaces)' },
290
- limit: { type: 'number', description: 'Maximum results (default: 10)' },
291
- threshold: { type: 'number', description: 'Minimum similarity threshold 0-1 (default: 0.3)' },
336
+ limit: { type: 'number', description: 'Maximum results (default: 8)' },
337
+ threshold: { type: 'number', description: 'Minimum similarity threshold 0-1 (default: 0.5)' },
292
338
  },
293
339
  required: ['query'],
294
340
  },
@@ -297,13 +343,13 @@ export const memoryTools = [
297
343
  const { searchEntries } = await getMemoryFunctions();
298
344
  const query = input.query;
299
345
  const namespace = input.namespace || 'all';
300
- const limit = input.limit || 10;
301
- // Falsiness check would coerce a caller-supplied 0 to 0.3 and silently
346
+ // #1053 S6: tighter defaults — fewer hits, higher relevance bar.
347
+ const limit = input.limit || 8;
348
+ // Falsiness check would coerce a caller-supplied 0 to default and silently
302
349
  // filter low-similarity matches; use a typeof guard so explicit zero
303
350
  // means "no threshold" (#837).
304
- const threshold = typeof input.threshold === 'number' ? input.threshold : 0.3;
351
+ const threshold = typeof input.threshold === 'number' ? input.threshold : 0.5;
305
352
  validateMemoryInput(undefined, undefined, query);
306
- const startTime = performance.now();
307
353
  try {
308
354
  const result = await searchEntries({
309
355
  query,
@@ -311,7 +357,6 @@ export const memoryTools = [
311
357
  limit,
312
358
  threshold,
313
359
  });
314
- const duration = performance.now() - startTime;
315
360
  // Parse JSON values in results
316
361
  const results = result.results.map(r => {
317
362
  let value = r.content;
@@ -321,19 +366,27 @@ export const memoryTools = [
321
366
  catch {
322
367
  // Keep as string
323
368
  }
369
+ // #1053 S1: compact RAG navigation crumb per result.
370
+ // Compact subset is small enough to always include — keeps the
371
+ // result envelope navigable without ballooning per-hit size.
372
+ const navigation = parseNavigation(r.metadata, 'compact');
324
373
  return {
325
374
  key: r.key,
326
375
  namespace: r.namespace,
327
376
  value,
328
- similarity: r.score,
377
+ // #1053 S6: 2dp keeps signal, drops noise (8-decimal floats add ~6
378
+ // bytes per hit and don't help any caller).
379
+ similarity: Math.round(r.score * 100) / 100,
380
+ navigation,
329
381
  };
330
382
  });
331
383
  notifyMemoryGate();
384
+ // #1053 S6: searchTime dropped from MCP envelope (CLI keeps it for
385
+ // human reading); `backend` retained — doctor reads it (#1053 epic).
332
386
  return {
333
387
  query,
334
388
  results,
335
389
  total: results.length,
336
- searchTime: `${duration.toFixed(2)}ms`,
337
390
  backend: 'HNSW + sql.js',
338
391
  };
339
392
  }
@@ -347,6 +400,98 @@ export const memoryTools = [
347
400
  }
348
401
  },
349
402
  },
403
+ {
404
+ name: 'memory_get_neighbors',
405
+ description: 'Traverse the chunk graph in one call: fetch the requested neighbors (prev/next/siblings/parent/children) of a chunk key. Returns success:false if the source is not a chunk.',
406
+ category: 'memory',
407
+ inputSchema: {
408
+ type: 'object',
409
+ properties: {
410
+ key: { type: 'string', description: 'Source chunk key (must be a chunk-* entry)' },
411
+ namespace: { type: 'string', description: 'Namespace (default: "default")' },
412
+ include: {
413
+ type: 'array',
414
+ items: { type: 'string', enum: ['prev', 'next', 'siblings', 'parent', 'children'] },
415
+ description: "Which neighbors to fetch. Default: ['prev','next']. parent/children = hierarchical (h2→h3) chunk neighbors; siblings = same-doc chunk peers.",
416
+ },
417
+ },
418
+ required: ['key'],
419
+ },
420
+ handler: async (input) => {
421
+ await ensureInitialized();
422
+ const { getEntry } = await getMemoryFunctions();
423
+ const key = input.key;
424
+ const namespace = input.namespace || 'default';
425
+ const includeRaw = input.include;
426
+ const include = Array.isArray(includeRaw) && includeRaw.length > 0 ? includeRaw : ['prev', 'next'];
427
+ validateMemoryInput(key);
428
+ try {
429
+ const sourceResult = await getEntry({ key, namespace });
430
+ if (!sourceResult.found || !sourceResult.entry) {
431
+ return {
432
+ success: false,
433
+ key,
434
+ namespace,
435
+ error: `Source key '${key}' not found in namespace '${namespace}'`,
436
+ };
437
+ }
438
+ const sourceMeta = sourceResult.entry.metadata;
439
+ const nav = parseNavigation(sourceMeta, 'full');
440
+ if (!nav) {
441
+ return {
442
+ success: false,
443
+ key,
444
+ namespace,
445
+ error: `Source key '${key}' has no chunk metadata; only chunk-* entries are navigable`,
446
+ };
447
+ }
448
+ // Resolve requested neighbor keys, dedup, exclude the source key itself.
449
+ const neighborKeys = new Set();
450
+ const addIfChunkKey = (k) => {
451
+ if (k && k !== key)
452
+ neighborKeys.add(k);
453
+ };
454
+ for (const inc of include) {
455
+ if (inc === 'prev')
456
+ addIfChunkKey(nav.prevChunk);
457
+ else if (inc === 'next')
458
+ addIfChunkKey(nav.nextChunk);
459
+ else if (inc === 'siblings')
460
+ (nav.siblings ?? []).forEach(addIfChunkKey);
461
+ else if (inc === 'parent')
462
+ addIfChunkKey(nav.hierarchicalParent);
463
+ else if (inc === 'children')
464
+ (nav.hierarchicalChildren ?? []).forEach(addIfChunkKey);
465
+ }
466
+ // Parallel fetch — one round-trip from the caller's perspective.
467
+ // Missing neighbors (deleted/renamed) are silently skipped rather
468
+ // than failing the whole call; the response.total reflects what
469
+ // we actually returned.
470
+ const fetched = await Promise.all(Array.from(neighborKeys).map(async (k) => {
471
+ const res = await getEntry({ key: k, namespace });
472
+ return res.found && res.entry ? shapeRetrievedEntry(res.entry) : null;
473
+ }));
474
+ notifyMemoryGate();
475
+ const neighbors = fetched.filter((e) => e !== null);
476
+ return {
477
+ success: true,
478
+ source: { key, namespace },
479
+ include,
480
+ neighbors,
481
+ total: neighbors.length,
482
+ backend: 'sql.js + HNSW',
483
+ };
484
+ }
485
+ catch (error) {
486
+ return {
487
+ success: false,
488
+ key,
489
+ namespace,
490
+ error: error instanceof Error ? error.message : 'Unknown error',
491
+ };
492
+ }
493
+ },
494
+ },
350
495
  {
351
496
  name: 'memory_delete',
352
497
  description: 'Delete a memory entry by key',