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.
- package/.claude/guidance/shipped/moflo-agent-rules.md +12 -0
- package/.claude/guidance/shipped/moflo-memory-protocol.md +30 -0
- package/.claude/guidance/shipped/moflo-subagents.md +4 -0
- package/.claude/helpers/gate.cjs +3 -3
- package/.claude/helpers/subagent-bootstrap.json +1 -1
- package/.claude/helpers/subagent-start.cjs +1 -1
- package/.claude/skills/eldar/SKILL.md +8 -0
- package/bin/gate.cjs +3 -3
- package/bin/index-guidance.mjs +41 -52
- package/bin/migrations/purge-doc-entries.mjs +53 -0
- package/bin/migrations/strip-context-preambles.mjs +97 -0
- package/bin/semantic-search.mjs +4 -1
- package/bin/session-start-launcher.mjs +2 -0
- package/dist/src/cli/commands/doctor-checks-memory-access.js +179 -0
- package/dist/src/cli/commands/memory.js +41 -52
- package/dist/src/cli/init/claudemd-generator.js +4 -0
- package/dist/src/cli/mcp-tools/memory-tools.js +169 -31
- package/dist/src/cli/memory/bridge-entries.js +6 -2
- package/dist/src/cli/memory/memory-initializer.js +17 -11
- package/dist/src/cli/services/subagent-bootstrap.js +1 -1
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
//
|
|
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([
|
|
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.
|
|
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
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
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
|
-
|
|
1662
|
-
|
|
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
|
-
|
|
1693
|
-
|
|
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
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
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
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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:
|
|
291
|
-
threshold: { type: 'number', description: 'Minimum similarity threshold 0-1 (default: 0.
|
|
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
|
-
|
|
301
|
-
|
|
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.
|
|
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
|
-
|
|
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',
|