moflo 4.9.36 → 4.9.37

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.
@@ -22,7 +22,10 @@
22
22
  * Cleanup runs even on assertion failure so a fail doesn't leave orphaned
23
23
  * agents/hive workers.
24
24
  */
25
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
25
26
  import { errorDetail } from '../shared/utils/error-detail.js';
27
+ import { mofloImport } from '../services/moflo-require.js';
28
+ import { memoryDbPath } from '../services/moflo-paths.js';
26
29
  import { loadToolArrays, getTool, pushDetail, summarizeFunctional, } from './doctor-checks-functional-shared.js';
27
30
  const MEMORY_ACCESS_CHECK = 'Memory Access Functional';
28
31
  const MEMORY_ACCESS_FAIL_FIX = 'Run `flo doctor --json` for per-subcheck details. Common fixes: ensure fastembed installed (memory_store.hasEmbedding=false), explicit threshold:0 honored (#837), or rebuild HNSW index (`flo memory rebuild-index`)';
@@ -160,6 +163,164 @@ async function safeDelete(memoryTools, key, namespace) {
160
163
  }
161
164
  catch { /* best-effort */ }
162
165
  }
166
+ /**
167
+ * #1053 S2: probe `memory_get_neighbors` round-trip.
168
+ *
169
+ * Same #798 protected-functionality posture as the swarm/agent/task probes:
170
+ * if a future refactor stubs the handler to literals (or unwires it from
171
+ * the metadata-passthrough plumbing in S1), this probe fails BEFORE the
172
+ * stub ships to consumers.
173
+ *
174
+ * Three chunks are stored via memory_store, then their `metadata` columns
175
+ * are written directly via sql.js to inject chunk-shaped nav fields
176
+ * (memory_store always sets metadata to '{}', so the chunker write path is
177
+ * the only producer in steady state — bypass it here for an in-process
178
+ * probe). The middle chunk's neighbors are then fetched and verified to
179
+ * carry navigation back, proving S1 + S2 + the metadata column passthrough
180
+ * survive end-to-end.
181
+ */
182
+ async function probeMemoryGetNeighbors(memoryTools, details) {
183
+ const tool = getTool(memoryTools, 'memory_get_neighbors');
184
+ if (!tool?.handler) {
185
+ details.push({
186
+ id: 'neighbors.registered',
187
+ mcpTool: 'memory_get_neighbors',
188
+ status: 'fail',
189
+ observed: { registered: false },
190
+ expected: 'memory_get_neighbors registered in MCP tool surface (#1053 S2)',
191
+ message: 'memory_get_neighbors is not registered — has the tool been removed or its name changed?',
192
+ });
193
+ return null;
194
+ }
195
+ details.push({
196
+ id: 'neighbors.registered',
197
+ mcpTool: 'memory_get_neighbors',
198
+ status: 'pass',
199
+ observed: { registered: true },
200
+ expected: 'memory_get_neighbors registered',
201
+ });
202
+ const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
203
+ const namespace = `doctor-neighbors-${stamp}`;
204
+ const prefix = `chunk-doctor-neighbors-${stamp}`;
205
+ const chunkKeys = [`${prefix}-0`, `${prefix}-1`, `${prefix}-2`];
206
+ const middleKey = chunkKeys[1];
207
+ // Seed three chunks via DIRECT sql.js write (skipping memory_store).
208
+ // Going through memory_store would warm the bridge's TieredCache with
209
+ // metadata='{}', and a follow-up direct UPDATE would race the bridge's
210
+ // writeback (sql.js dump-on-flush hazard, see
211
+ // feedback_sqljs_writeback_clobber.md). Writing straight to disk before
212
+ // the bridge ever sees these keys means memory_get_neighbors → getEntry
213
+ // hits fresh disk state and our injected metadata is what comes back.
214
+ const dbPath = memoryDbPath(process.cwd());
215
+ if (!existsSync(dbPath)) {
216
+ details.push({
217
+ id: 'neighbors.seed',
218
+ mcpTool: 'memory_get_neighbors',
219
+ status: 'warn',
220
+ observed: { dbPath, exists: false },
221
+ expected: 'memory.db present so the neighbors probe can seed chunks',
222
+ message: 'memory.db missing — skip the neighbors probe (run `flo memory init` first)',
223
+ });
224
+ return { key: middleKey, namespace, chunkKeys };
225
+ }
226
+ try {
227
+ const initSqlJs = (await mofloImport('sql.js')).default;
228
+ const SQL = await initSqlJs();
229
+ const db = new SQL.Database(readFileSync(dbPath));
230
+ const insert = db.prepare(`INSERT OR REPLACE INTO memory_entries
231
+ (id, key, namespace, content, type, tags, metadata, created_at, updated_at, status)
232
+ VALUES (?, ?, ?, ?, 'semantic', '[]', ?, ?, ?, 'active')`);
233
+ const now = Date.now();
234
+ for (let i = 0; i < chunkKeys.length; i++) {
235
+ const meta = {
236
+ type: 'chunk',
237
+ parentDoc: 'doc-doctor-neighbors',
238
+ parentPath: '/doctor-neighbors.md',
239
+ chunkIndex: i,
240
+ totalChunks: chunkKeys.length,
241
+ prevChunk: i > 0 ? chunkKeys[i - 1] : null,
242
+ nextChunk: i < chunkKeys.length - 1 ? chunkKeys[i + 1] : null,
243
+ siblings: chunkKeys,
244
+ hierarchicalParent: null,
245
+ hierarchicalChildren: null,
246
+ chunkTitle: `Doctor Probe Chunk ${i}`,
247
+ headerLevel: 2,
248
+ docContentHash: stamp,
249
+ };
250
+ insert.run([
251
+ `memprobe-neighbors-${stamp}-${i}`,
252
+ chunkKeys[i],
253
+ namespace,
254
+ `chunk body ${i}`,
255
+ JSON.stringify(meta),
256
+ now, now,
257
+ ]);
258
+ }
259
+ insert.free();
260
+ writeFileSync(dbPath, Buffer.from(db.export()));
261
+ db.close();
262
+ }
263
+ catch (err) {
264
+ const msg = errorDetail(err, { firstLineOnly: true });
265
+ details.push({
266
+ id: 'neighbors.seed',
267
+ mcpTool: 'memory_get_neighbors',
268
+ status: 'fail',
269
+ observed: { error: msg },
270
+ expected: 'three chunk rows seeded with chunk-shaped metadata',
271
+ message: `seed failed: ${msg}`,
272
+ });
273
+ return { key: middleKey, namespace, chunkKeys };
274
+ }
275
+ // 3. The probe itself — fetch prev + next of the middle chunk.
276
+ let result;
277
+ try {
278
+ result = (await invokeOrThrow(memoryTools, 'memory_get_neighbors', {
279
+ key: middleKey,
280
+ namespace,
281
+ }));
282
+ }
283
+ catch (err) {
284
+ const msg = errorDetail(err, { firstLineOnly: true });
285
+ details.push({
286
+ id: 'neighbors.roundtrip',
287
+ mcpTool: 'memory_get_neighbors',
288
+ status: 'fail',
289
+ observed: { error: msg },
290
+ expected: 'memory_get_neighbors returns success=true with prev + next',
291
+ message: `handler threw: ${msg}`,
292
+ });
293
+ return { key: middleKey, namespace, chunkKeys };
294
+ }
295
+ const failReason = assertNeighbors(result, [chunkKeys[0], chunkKeys[2]]);
296
+ pushDetail(details, {
297
+ id: 'neighbors.roundtrip',
298
+ mcpTool: 'memory_get_neighbors',
299
+ expected: `success=true, total=2, neighbors include ${chunkKeys[0]} + ${chunkKeys[2]} with navigation`,
300
+ }, failReason ? result : { total: result.total, neighborKeys: result.neighbors?.map(n => n.key) }, failReason);
301
+ return { key: middleKey, namespace, chunkKeys };
302
+ }
303
+ function assertNeighbors(result, expectedKeys) {
304
+ if (!result?.success) {
305
+ return `success=${result?.success} (error: ${result?.error ?? 'unknown'}) — handler did not return success`;
306
+ }
307
+ if (result.total !== expectedKeys.length) {
308
+ return `expected total=${expectedKeys.length}, got ${result.total} — neighbors traversal returned wrong count`;
309
+ }
310
+ const got = (result.neighbors ?? []).map(n => n.key).sort();
311
+ const want = [...expectedKeys].sort();
312
+ if (JSON.stringify(got) !== JSON.stringify(want)) {
313
+ return `expected neighbor keys ${JSON.stringify(want)}, got ${JSON.stringify(got)} — wrong neighbors returned`;
314
+ }
315
+ // Every neighbor must carry navigation (S1 metadata passthrough). A stub
316
+ // that returns shaped envelopes but null nav would pass the count check;
317
+ // this catches it.
318
+ const missingNav = (result.neighbors ?? []).filter(n => !n.navigation);
319
+ if (missingNav.length > 0) {
320
+ return `${missingNav.length} neighbor(s) returned with navigation=null — S1 metadata passthrough may be broken`;
321
+ }
322
+ return null;
323
+ }
163
324
  export async function checkMemoryAccessFunctional() {
164
325
  const details = [];
165
326
  const mods = await loadToolArrays({
@@ -182,6 +343,24 @@ export async function checkMemoryAccessFunctional() {
182
343
  let spawnedAgentId;
183
344
  let hiveInitialized = false;
184
345
  try {
346
+ // ── Probe 0: memory_get_neighbors (#1053 S2) ──────────────────────────
347
+ // MUST RUN FIRST — before any memory_store call warms the bridge's
348
+ // in-memory sql.js snapshot. The probe injects chunk metadata via a
349
+ // direct file write to .moflo/moflo.db; once the bridge has loaded the
350
+ // DB into memory and done its first persist, external file writes are
351
+ // clobbered (sql.js dump-on-flush hazard, see
352
+ // feedback_sqljs_writeback_clobber.md). Running this probe first means
353
+ // the bridge instantiates AFTER our seed lands — its initial disk read
354
+ // sees our rows.
355
+ {
356
+ const probeResult = await probeMemoryGetNeighbors(memoryTools, details);
357
+ if (probeResult) {
358
+ const { namespace, chunkKeys } = probeResult;
359
+ for (const key of chunkKeys) {
360
+ cleanups.push(() => safeDelete(memoryTools, key, namespace));
361
+ }
362
+ }
363
+ }
185
364
  // ── Probe 1: subagent context ─────────────────────────────────────────
186
365
  // The "subagent" path is what Claude's Task tool ends up calling: direct
187
366
  // MCP tools with no surrounding coordinator state. Failures here indicate
@@ -242,7 +242,7 @@ const searchCommand = {
242
242
  name: 'threshold',
243
243
  description: 'Similarity threshold (0-1)',
244
244
  type: 'number',
245
- default: 0.7
245
+ default: 0.5
246
246
  },
247
247
  {
248
248
  name: 'type',
@@ -268,7 +268,8 @@ const searchCommand = {
268
268
  const query = ctx.flags.query || ctx.args[0];
269
269
  const namespace = ctx.flags.namespace || 'all';
270
270
  const limit = ctx.flags.limit || 10;
271
- const threshold = ctx.flags.threshold || 0.3;
271
+ // #1053 S6: align with MCP default — was 0.3 here vs 0.7 in option block.
272
+ const threshold = ctx.flags.threshold || 0.5;
272
273
  const searchType = ctx.flags.type || 'semantic';
273
274
  const buildHnsw = ctx.flags.buildHnsw;
274
275
  if (!query) {
@@ -1612,16 +1613,16 @@ const indexGuidanceCommand = {
1612
1613
  try {
1613
1614
  const content = fs.readFileSync(filePath, 'utf-8');
1614
1615
  const contentHash_ = hashContent(content);
1615
- // Check if content changed
1616
+ // #1053 S4: doc-* retired — read docContentHash off chunk-0 instead.
1616
1617
  if (!forceReindex) {
1617
1618
  const stmt = db.prepare('SELECT metadata FROM memory_entries WHERE key = ? AND namespace = ?');
1618
- stmt.bind([docKey, NAMESPACE]);
1619
+ stmt.bind([`${chunkPrefix}-0`, NAMESPACE]);
1619
1620
  const entry = stmt.step() ? stmt.getAsObject() : null;
1620
1621
  stmt.free();
1621
1622
  if (entry?.metadata) {
1622
1623
  try {
1623
1624
  const meta = JSON.parse(entry.metadata);
1624
- if (meta.contentHash === contentHash_) {
1625
+ if (meta.docContentHash === contentHash_) {
1625
1626
  return { docKey, status: 'unchanged', chunks: 0 };
1626
1627
  }
1627
1628
  }
@@ -1630,19 +1631,12 @@ const indexGuidanceCommand = {
1630
1631
  }
1631
1632
  const stats = fs.statSync(filePath);
1632
1633
  const relativePath = filePath.replace(cwd, '').replace(/\\/g, '/');
1633
- // Delete old chunks
1634
+ // Delete old chunks. Also delete any legacy doc-* row (#1053 S4).
1634
1635
  db.run(`DELETE FROM memory_entries WHERE namespace = ? AND key LIKE ?`, [NAMESPACE, `${chunkPrefix}%`]);
1635
- // Store full document
1636
- const docMetadata = {
1637
- type: 'document',
1638
- filePath: relativePath,
1639
- fileSize: stats.size,
1640
- lastModified: stats.mtime.toISOString(),
1641
- contentHash: contentHash_,
1642
- indexedAt: new Date().toISOString(),
1643
- ragVersion: '2.0',
1644
- };
1645
- batchStoreEntry(db, docKey, NAMESPACE, content, docMetadata, [keyPrefix, 'document']);
1636
+ db.run(`DELETE FROM memory_entries WHERE namespace = ? AND key = ?`, [NAMESPACE, docKey]);
1637
+ // #1053 S4: doc-* entries no longer written. parentDoc on chunks
1638
+ // remains as an identifier label; callers Read parentPath when
1639
+ // they need the source file (see shipped/moflo-memory-protocol.md).
1646
1640
  // Chunk content
1647
1641
  const chunks = chunkMarkdown(content, fileName);
1648
1642
  if (chunks.length === 0) {
@@ -1650,26 +1644,24 @@ const indexGuidanceCommand = {
1650
1644
  }
1651
1645
  const hierarchy = buildHierarchy(chunks, chunkPrefix);
1652
1646
  const siblings = chunks.map((_, i) => `${chunkPrefix}-${i}`);
1653
- // Update doc with children refs
1654
- const docChildrenMeta = { ...docMetadata, children: siblings, chunkCount: chunks.length };
1655
- batchStoreEntry(db, docKey, NAMESPACE, content, docChildrenMeta, [keyPrefix, 'document']);
1656
1647
  for (let i = 0; i < chunks.length; i++) {
1657
1648
  const chunk = chunks[i];
1658
1649
  const chunkKey = `${chunkPrefix}-${i}`;
1659
1650
  const prevChunk = i > 0 ? `${chunkPrefix}-${i - 1}` : null;
1660
1651
  const nextChunk = i < chunks.length - 1 ? `${chunkPrefix}-${i + 1}` : null;
1661
- const contextBefore = i > 0
1662
- ? extractOverlapContext(chunks[i - 1].content, overlapPercent, 'end')
1663
- : null;
1664
- const contextAfter = i < chunks.length - 1
1665
- ? extractOverlapContext(chunks[i + 1].content, overlapPercent, 'start')
1666
- : null;
1652
+ // #1053 S5: dropped prev/next preamble wrapping. Traversal happens
1653
+ // via memory_get_neighbors now (S2).
1667
1654
  const hierInfo = hierarchy[chunkKey];
1668
1655
  const chunkMetadata = {
1669
1656
  type: 'chunk',
1670
1657
  ragVersion: '2.0',
1658
+ // #1053 S4: parentDoc is an identifier label (target row no
1659
+ // longer exists); use parentPath for the actual source file.
1660
+ // docContentHash on every chunk lets the skip-if-unchanged
1661
+ // check read it off chunk-0.
1671
1662
  parentDoc: docKey,
1672
1663
  parentPath: relativePath,
1664
+ docContentHash: contentHash_,
1673
1665
  chunkIndex: i,
1674
1666
  totalChunks: chunks.length,
1675
1667
  prevChunk,
@@ -1682,21 +1674,12 @@ const indexGuidanceCommand = {
1682
1674
  headerLine: chunk.headerLine,
1683
1675
  isPart: chunk.isPart || false,
1684
1676
  partNum: chunk.partNum || null,
1685
- contextOverlapPercent: overlapPercent,
1686
- hasContextBefore: !!contextBefore,
1687
- hasContextAfter: !!contextAfter,
1688
1677
  contentLength: chunk.content.length,
1689
1678
  contentHash: hashContent(chunk.content),
1690
1679
  indexedAt: new Date().toISOString(),
1691
1680
  };
1692
- let searchableContent = `# ${chunk.title}\n\n`;
1693
- if (contextBefore) {
1694
- searchableContent += `[Context from previous section:]\n${contextBefore}\n\n---\n\n`;
1695
- }
1696
- searchableContent += chunk.content;
1697
- if (contextAfter) {
1698
- searchableContent += `\n\n---\n\n[Context from next section:]\n${contextAfter}`;
1699
- }
1681
+ // #1053 S5: title heading + chunk body. No prev/next preamble.
1682
+ const searchableContent = `# ${chunk.title}\n\n${chunk.content}`;
1700
1683
  batchStoreEntry(db, chunkKey, NAMESPACE, searchableContent, chunkMetadata, [keyPrefix, 'chunk', `level-${chunk.level}`, chunk.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')]);
1701
1684
  }
1702
1685
  return { docKey, status: 'indexed', chunks: chunks.length };
@@ -1753,22 +1736,28 @@ const indexGuidanceCommand = {
1753
1736
  errors++;
1754
1737
  }
1755
1738
  }
1756
- // Clean stale entries for deleted files
1757
- const docsStmt = db.prepare(`SELECT DISTINCT key FROM memory_entries WHERE namespace = ? AND key LIKE 'doc-%'`);
1758
- docsStmt.bind([NAMESPACE]);
1759
- const docs = [];
1760
- while (docsStmt.step())
1761
- docs.push(docsStmt.getAsObject());
1762
- docsStmt.free();
1763
- for (const { key } of docs) {
1764
- if (!key.startsWith('doc-guidance-'))
1765
- continue;
1766
- const checkPath = pathModule.resolve(cwd, '.claude/guidance', key.replace('doc-guidance-', '') + '.md');
1739
+ // #1053 S4: Clean stale chunks for deleted files.
1740
+ // doc-* markers are gone derive prefixes from chunk keys directly.
1741
+ // Chunk key shape: chunk-guidance-<filename>-<index>; group by stripping
1742
+ // the trailing -<index>.
1743
+ const chunksStmt = db.prepare(`SELECT DISTINCT key FROM memory_entries WHERE namespace = ? AND key LIKE 'chunk-guidance-%'`);
1744
+ chunksStmt.bind([NAMESPACE]);
1745
+ const seenPrefixes = new Set();
1746
+ while (chunksStmt.step()) {
1747
+ const { key } = chunksStmt.getAsObject();
1748
+ const prefix = key.replace(/-\d+$/, '');
1749
+ seenPrefixes.add(prefix);
1750
+ }
1751
+ chunksStmt.free();
1752
+ for (const prefix of seenPrefixes) {
1753
+ const filename = prefix.replace('chunk-guidance-', '') + '.md';
1754
+ const checkPath = pathModule.resolve(cwd, '.claude/guidance', filename);
1767
1755
  if (!fs.existsSync(checkPath)) {
1768
- const cp = key.replace('doc-', 'chunk-');
1769
- db.run(`DELETE FROM memory_entries WHERE namespace = ? AND key LIKE ?`, [NAMESPACE, `${cp}%`]);
1770
- db.run(`DELETE FROM memory_entries WHERE namespace = ? AND key = ?`, [NAMESPACE, key]);
1771
- output.writeln(output.dim(` Removed stale: ${key}`));
1756
+ db.run(`DELETE FROM memory_entries WHERE namespace = ? AND key LIKE ?`, [NAMESPACE, `${prefix}-%`]);
1757
+ // Also sweep any legacy doc-* row for this prefix (one-time tidy).
1758
+ const legacyDocKey = prefix.replace('chunk-', 'doc-');
1759
+ db.run(`DELETE FROM memory_entries WHERE namespace = ? AND key = ?`, [NAMESPACE, legacyDocKey]);
1760
+ output.writeln(output.dim(` Removed stale: ${prefix}-* (file ${filename} not found)`));
1772
1761
  }
1773
1762
  }
1774
1763
  }
@@ -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
@@ -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`);
@@ -220,7 +277,7 @@ export const memoryTools = [
220
277
  },
221
278
  {
222
279
  name: 'memory_retrieve',
223
- description: 'Retrieve a value from memory by key',
280
+ description: 'Retrieve a value from memory by key. Chunk entries also return a full `navigation` object — use it with memory_get_neighbors for traversal instead of bulk-retrieving every search hit.',
224
281
  category: 'memory',
225
282
  inputSchema: {
226
283
  type: 'object',
@@ -238,27 +295,9 @@ export const memoryTools = [
238
295
  try {
239
296
  const result = await getEntry({ key, namespace });
240
297
  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
298
  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
- };
299
+ // #1053 S1: surface RAG navigation for chunked guidance entries.
300
+ return shapeRetrievedEntry(result.entry);
262
301
  }
263
302
  return {
264
303
  key,
@@ -280,15 +319,15 @@ export const memoryTools = [
280
319
  },
281
320
  {
282
321
  name: 'memory_search',
283
- description: 'Semantic vector search using HNSW index (150x-12,500x faster than keyword search)',
322
+ description: 'Semantic vector search using HNSW index (150x-12,500x faster than keyword search). Results include a compact `navigation` crumb on chunk hits — traverse via memory_get_neighbors rather than retrieving every hit.',
284
323
  category: 'memory',
285
324
  inputSchema: {
286
325
  type: 'object',
287
326
  properties: {
288
327
  query: { type: 'string', description: 'Search query (semantic similarity)' },
289
328
  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)' },
329
+ limit: { type: 'number', description: 'Maximum results (default: 8)' },
330
+ threshold: { type: 'number', description: 'Minimum similarity threshold 0-1 (default: 0.5)' },
292
331
  },
293
332
  required: ['query'],
294
333
  },
@@ -297,13 +336,13 @@ export const memoryTools = [
297
336
  const { searchEntries } = await getMemoryFunctions();
298
337
  const query = input.query;
299
338
  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
339
+ // #1053 S6: tighter defaults — fewer hits, higher relevance bar.
340
+ const limit = input.limit || 8;
341
+ // Falsiness check would coerce a caller-supplied 0 to default and silently
302
342
  // filter low-similarity matches; use a typeof guard so explicit zero
303
343
  // means "no threshold" (#837).
304
- const threshold = typeof input.threshold === 'number' ? input.threshold : 0.3;
344
+ const threshold = typeof input.threshold === 'number' ? input.threshold : 0.5;
305
345
  validateMemoryInput(undefined, undefined, query);
306
- const startTime = performance.now();
307
346
  try {
308
347
  const result = await searchEntries({
309
348
  query,
@@ -311,7 +350,6 @@ export const memoryTools = [
311
350
  limit,
312
351
  threshold,
313
352
  });
314
- const duration = performance.now() - startTime;
315
353
  // Parse JSON values in results
316
354
  const results = result.results.map(r => {
317
355
  let value = r.content;
@@ -321,19 +359,27 @@ export const memoryTools = [
321
359
  catch {
322
360
  // Keep as string
323
361
  }
362
+ // #1053 S1: compact RAG navigation crumb per result.
363
+ // Compact subset is small enough to always include — keeps the
364
+ // result envelope navigable without ballooning per-hit size.
365
+ const navigation = parseNavigation(r.metadata, 'compact');
324
366
  return {
325
367
  key: r.key,
326
368
  namespace: r.namespace,
327
369
  value,
328
- similarity: r.score,
370
+ // #1053 S6: 2dp keeps signal, drops noise (8-decimal floats add ~6
371
+ // bytes per hit and don't help any caller).
372
+ similarity: Math.round(r.score * 100) / 100,
373
+ navigation,
329
374
  };
330
375
  });
331
376
  notifyMemoryGate();
377
+ // #1053 S6: searchTime dropped from MCP envelope (CLI keeps it for
378
+ // human reading); `backend` retained — doctor reads it (#1053 epic).
332
379
  return {
333
380
  query,
334
381
  results,
335
382
  total: results.length,
336
- searchTime: `${duration.toFixed(2)}ms`,
337
383
  backend: 'HNSW + sql.js',
338
384
  };
339
385
  }
@@ -347,6 +393,98 @@ export const memoryTools = [
347
393
  }
348
394
  },
349
395
  },
396
+ {
397
+ name: 'memory_get_neighbors',
398
+ 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.',
399
+ category: 'memory',
400
+ inputSchema: {
401
+ type: 'object',
402
+ properties: {
403
+ key: { type: 'string', description: 'Source chunk key (must be a chunk-* entry)' },
404
+ namespace: { type: 'string', description: 'Namespace (default: "default")' },
405
+ include: {
406
+ type: 'array',
407
+ items: { type: 'string', enum: ['prev', 'next', 'siblings', 'parent', 'children'] },
408
+ description: "Which neighbors to fetch. Default: ['prev','next']. parent/children = hierarchical (h2→h3) chunk neighbors; siblings = same-doc chunk peers.",
409
+ },
410
+ },
411
+ required: ['key'],
412
+ },
413
+ handler: async (input) => {
414
+ await ensureInitialized();
415
+ const { getEntry } = await getMemoryFunctions();
416
+ const key = input.key;
417
+ const namespace = input.namespace || 'default';
418
+ const includeRaw = input.include;
419
+ const include = Array.isArray(includeRaw) && includeRaw.length > 0 ? includeRaw : ['prev', 'next'];
420
+ validateMemoryInput(key);
421
+ try {
422
+ const sourceResult = await getEntry({ key, namespace });
423
+ if (!sourceResult.found || !sourceResult.entry) {
424
+ return {
425
+ success: false,
426
+ key,
427
+ namespace,
428
+ error: `Source key '${key}' not found in namespace '${namespace}'`,
429
+ };
430
+ }
431
+ const sourceMeta = sourceResult.entry.metadata;
432
+ const nav = parseNavigation(sourceMeta, 'full');
433
+ if (!nav) {
434
+ return {
435
+ success: false,
436
+ key,
437
+ namespace,
438
+ error: `Source key '${key}' has no chunk metadata; only chunk-* entries are navigable`,
439
+ };
440
+ }
441
+ // Resolve requested neighbor keys, dedup, exclude the source key itself.
442
+ const neighborKeys = new Set();
443
+ const addIfChunkKey = (k) => {
444
+ if (k && k !== key)
445
+ neighborKeys.add(k);
446
+ };
447
+ for (const inc of include) {
448
+ if (inc === 'prev')
449
+ addIfChunkKey(nav.prevChunk);
450
+ else if (inc === 'next')
451
+ addIfChunkKey(nav.nextChunk);
452
+ else if (inc === 'siblings')
453
+ (nav.siblings ?? []).forEach(addIfChunkKey);
454
+ else if (inc === 'parent')
455
+ addIfChunkKey(nav.hierarchicalParent);
456
+ else if (inc === 'children')
457
+ (nav.hierarchicalChildren ?? []).forEach(addIfChunkKey);
458
+ }
459
+ // Parallel fetch — one round-trip from the caller's perspective.
460
+ // Missing neighbors (deleted/renamed) are silently skipped rather
461
+ // than failing the whole call; the response.total reflects what
462
+ // we actually returned.
463
+ const fetched = await Promise.all(Array.from(neighborKeys).map(async (k) => {
464
+ const res = await getEntry({ key: k, namespace });
465
+ return res.found && res.entry ? shapeRetrievedEntry(res.entry) : null;
466
+ }));
467
+ notifyMemoryGate();
468
+ const neighbors = fetched.filter((e) => e !== null);
469
+ return {
470
+ success: true,
471
+ source: { key, namespace },
472
+ include,
473
+ neighbors,
474
+ total: neighbors.length,
475
+ backend: 'sql.js + HNSW',
476
+ };
477
+ }
478
+ catch (error) {
479
+ return {
480
+ success: false,
481
+ key,
482
+ namespace,
483
+ error: error instanceof Error ? error.message : 'Unknown error',
484
+ };
485
+ }
486
+ },
487
+ },
350
488
  {
351
489
  name: 'memory_delete',
352
490
  description: 'Delete a memory entry by key',