monomind 2.7.6 → 2.7.8

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 (63) hide show
  1. package/package.json +5 -4
  2. package/packages/@monomind/cli/.claude/agents/core/coder.md +9 -0
  3. package/packages/@monomind/cli/.claude/agents/core/coordinator.md +62 -0
  4. package/packages/@monomind/cli/.claude/agents/core/planner.md +9 -0
  5. package/packages/@monomind/cli/.claude/agents/core/reviewer.md +9 -0
  6. package/packages/@monomind/cli/.claude/agents/core/tester.md +8 -0
  7. package/packages/@monomind/cli/.claude/helpers/handlers/gates-handler.cjs +180 -47
  8. package/packages/@monomind/cli/.claude/helpers/handlers/route-handler.cjs +32 -3
  9. package/packages/@monomind/cli/.claude/helpers/hook-handler.cjs +55 -3
  10. package/packages/@monomind/cli/.claude/helpers/intelligence.cjs +3 -1
  11. package/packages/@monomind/cli/.claude/helpers/statusline.cjs +27 -3
  12. package/packages/@monomind/cli/.claude/helpers/utils/monograph.cjs +104 -18
  13. package/packages/@monomind/cli/.claude/settings.json +1 -1
  14. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-server.mjs +38 -1
  15. package/packages/@monomind/cli/dist/src/browser/dashboard/server.js +6 -1
  16. package/packages/@monomind/cli/dist/src/capabilities/index.d.ts +0 -1
  17. package/packages/@monomind/cli/dist/src/capabilities/index.js +8 -1
  18. package/packages/@monomind/cli/dist/src/commands/agent-lifecycle.js +5 -1
  19. package/packages/@monomind/cli/dist/src/commands/agent-ops.js +32 -2
  20. package/packages/@monomind/cli/dist/src/commands/browse-workflow.js +30 -0
  21. package/packages/@monomind/cli/dist/src/commands/doctor-project-checks.js +8 -1
  22. package/packages/@monomind/cli/dist/src/commands/guidance.js +8 -2
  23. package/packages/@monomind/cli/dist/src/commands/hooks-extended-commands.js +10 -2
  24. package/packages/@monomind/cli/dist/src/commands/init.js +18 -4
  25. package/packages/@monomind/cli/dist/src/commands/memory-crud.js +40 -3
  26. package/packages/@monomind/cli/dist/src/commands/org-observe.js +67 -13
  27. package/packages/@monomind/cli/dist/src/commands/org.d.ts +11 -0
  28. package/packages/@monomind/cli/dist/src/commands/org.js +162 -15
  29. package/packages/@monomind/cli/dist/src/commands/performance.js +31 -5
  30. package/packages/@monomind/cli/dist/src/commands/security-misc.js +18 -3
  31. package/packages/@monomind/cli/dist/src/commands/security-scan.d.ts +30 -1
  32. package/packages/@monomind/cli/dist/src/commands/security-scan.js +182 -69
  33. package/packages/@monomind/cli/dist/src/commands/swarm.js +41 -14
  34. package/packages/@monomind/cli/dist/src/consensus/audit-writer.js +11 -10
  35. package/packages/@monomind/cli/dist/src/init/executor.js +138 -22
  36. package/packages/@monomind/cli/dist/src/init/settings-generator.js +4 -1
  37. package/packages/@monomind/cli/dist/src/knowledge/document-pipeline.d.ts +17 -0
  38. package/packages/@monomind/cli/dist/src/knowledge/document-pipeline.js +62 -3
  39. package/packages/@monomind/cli/dist/src/mcp-client.js +78 -0
  40. package/packages/@monomind/cli/dist/src/mcp-tools/embeddings-tools.js +16 -8
  41. package/packages/@monomind/cli/dist/src/mcp-tools/knowledge-tools.js +27 -13
  42. package/packages/@monomind/cli/dist/src/memory/memory-bridge.d.ts +5 -1
  43. package/packages/@monomind/cli/dist/src/memory/memory-bridge.js +26 -3
  44. package/packages/@monomind/cli/dist/src/memory/memory-read.d.ts +9 -0
  45. package/packages/@monomind/cli/dist/src/memory/memory-read.js +13 -2
  46. package/packages/@monomind/cli/dist/src/monovector/diff-classifier.js +25 -6
  47. package/packages/@monomind/cli/dist/src/orgrt/daemon.d.ts +21 -1
  48. package/packages/@monomind/cli/dist/src/orgrt/daemon.js +82 -11
  49. package/packages/@monomind/cli/dist/src/orgrt/inbox.js +53 -20
  50. package/packages/@monomind/cli/dist/src/parser.d.ts +4 -2
  51. package/packages/@monomind/cli/dist/src/parser.js +61 -25
  52. package/packages/@monomind/cli/dist/src/routing/route-layer-factory.d.ts +15 -0
  53. package/packages/@monomind/cli/dist/src/routing/route-layer-factory.js +44 -2
  54. package/packages/@monomind/cli/dist/src/services/config-file-manager.d.ts +10 -2
  55. package/packages/@monomind/cli/dist/src/services/config-file-manager.js +10 -2
  56. package/packages/@monomind/cli/dist/src/ui/collector.mjs +43 -3
  57. package/packages/@monomind/cli/dist/src/ui/dashboard.html +27 -8
  58. package/packages/@monomind/cli/dist/src/ui/server.mjs +144 -14
  59. package/packages/@monomind/cli/package.json +7 -6
  60. package/packages/@monomind/cli/dist/src/capabilities/watcher.d.ts +0 -18
  61. package/packages/@monomind/cli/dist/src/capabilities/watcher.js +0 -107
  62. package/packages/@monomind/cli/dist/src/config-adapter.d.ts +0 -16
  63. package/packages/@monomind/cli/dist/src/config-adapter.js +0 -220
@@ -410,17 +410,37 @@ try { await buildAsync(${JSON.stringify(targetDir)}); } finally {
410
410
  * Non-fatal: best-effort health check and auto-install.
411
411
  */
412
412
  async function runDoctorFix(targetDir, result) {
413
+ // Run the doctor THIS binary ships, in-process.
414
+ //
415
+ // This used to be `execSync('npx monomind@latest doctor --install')`, which
416
+ // was wrong in three ways at once: it downloaded and ran a DIFFERENT version
417
+ // than the one the user deliberately invoked (so `monomind@2.7.0 init` was
418
+ // finished by whatever `latest` happened to be), it silently required network
419
+ // — on a machine without it, the 120s timeout elapsed and init reported
420
+ // "skipped" with no explanation — and `stdio: 'ignore'` discarded everything
421
+ // it said, so a failed health check looked identical to a passing one.
413
422
  try {
414
- const { execSync } = await import('child_process');
415
- execSync('npx monomind@latest doctor --install', {
423
+ const { doctorCommand } = await import('../commands/doctor.js');
424
+ if (!doctorCommand.action) {
425
+ result.skipped.push('doctor: auto-fix unavailable (run: monomind doctor --install)');
426
+ return;
427
+ }
428
+ const res = await doctorCommand.action({
429
+ args: [],
430
+ flags: { install: true },
416
431
  cwd: targetDir,
417
- stdio: 'ignore',
418
- timeout: 120000,
419
432
  });
420
- result.created.files.push('doctor --install (health check + auto-fix)');
433
+ // Report what actually happened rather than asserting success either way.
434
+ if (res && res.success === false) {
435
+ result.skipped.push('doctor: reported issues (run: monomind doctor for details)');
436
+ }
437
+ else {
438
+ result.created.files.push('doctor --install (health check + auto-fix)');
439
+ }
421
440
  }
422
- catch {
423
- result.skipped.push('doctor: auto-fix skipped (run: monomind doctor --install)');
441
+ catch (err) {
442
+ const detail = err instanceof Error ? err.message : String(err);
443
+ result.skipped.push(`doctor: auto-fix failed (${detail}) — run: monomind doctor --install`);
424
444
  }
425
445
  }
426
446
  /**
@@ -953,6 +973,73 @@ async function writeMCPConfig(targetDir, options, result) {
953
973
  atomicWriteFile(mcpPath, content);
954
974
  result.created.files.push('.mcp.json');
955
975
  }
976
+ /**
977
+ * Provenance manifest for generated .claude content.
978
+ *
979
+ * init used to "clean stale" entries by deleting every name under
980
+ * .claude/{skills,commands,agents} that was absent from the current version's
981
+ * SKILLS_MAP/COMMANDS_MAP/AGENTS_MAP. Every user-authored command and skill is
982
+ * absent from those maps, so that pass deleted user content on the very first
983
+ * run — unrecoverable data loss.
984
+ *
985
+ * The manifest records exactly which entries *this tool* wrote, so the stale
986
+ * sweep can be restricted to those. Anything init did not write is never
987
+ * removed. Projects initialised by an older version have no manifest, so their
988
+ * first run under the fix deletes nothing and seeds the manifest instead;
989
+ * stale generated content may survive one extra run, which is the correct
990
+ * trade (preserving stale generated content is recoverable, deleting user
991
+ * content is not).
992
+ */
993
+ const INIT_MANIFEST_REL = path.join('.monomind', 'init-manifest.json');
994
+ /**
995
+ * Read the provenance manifest. Returns null when absent or unreadable —
996
+ * callers must treat that as "provenance unknown", i.e. delete nothing.
997
+ */
998
+ function readInitManifest(targetDir) {
999
+ const manifestPath = path.join(targetDir, INIT_MANIFEST_REL);
1000
+ try {
1001
+ if (!fs.existsSync(manifestPath))
1002
+ return null;
1003
+ const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
1004
+ if (!parsed || typeof parsed !== 'object')
1005
+ return null;
1006
+ return {
1007
+ version: typeof parsed.version === 'number' ? parsed.version : 1,
1008
+ skills: Array.isArray(parsed.skills) ? parsed.skills.filter((s) => typeof s === 'string') : [],
1009
+ commands: Array.isArray(parsed.commands) ? parsed.commands.filter((s) => typeof s === 'string') : [],
1010
+ agents: Array.isArray(parsed.agents) ? parsed.agents.filter((s) => typeof s === 'string') : [],
1011
+ };
1012
+ }
1013
+ catch {
1014
+ return null;
1015
+ }
1016
+ }
1017
+ /**
1018
+ * Names init previously generated in one section. Empty set when no manifest
1019
+ * exists — which makes the stale sweep a no-op rather than a delete-everything.
1020
+ */
1021
+ function previouslyGenerated(targetDir, section) {
1022
+ return new Set(readInitManifest(targetDir)?.[section] ?? []);
1023
+ }
1024
+ /**
1025
+ * Record the entries init just wrote for one section, merging into any
1026
+ * existing manifest so a partial run (e.g. --only-claude, or a section whose
1027
+ * source dir was missing) never drops provenance for the other sections.
1028
+ */
1029
+ function recordGenerated(targetDir, section, entries) {
1030
+ const manifestPath = path.join(targetDir, INIT_MANIFEST_REL);
1031
+ const existing = readInitManifest(targetDir);
1032
+ const manifest = existing ?? { version: 1, skills: [], commands: [], agents: [] };
1033
+ manifest.version = 1;
1034
+ manifest[section] = [...new Set(entries)].sort();
1035
+ try {
1036
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
1037
+ atomicWriteFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
1038
+ }
1039
+ catch {
1040
+ // Non-fatal: without a manifest the next run simply deletes nothing.
1041
+ }
1042
+ }
956
1043
  /**
957
1044
  * Copy skills from source
958
1045
  */
@@ -992,11 +1079,14 @@ async function copySkills(targetDir, options, result) {
992
1079
  skillsToCopy.push(...fs.readdirSync(sourceSkillsDir).filter(n => n.startsWith(prefix) &&
993
1080
  fs.existsSync(path.join(sourceSkillsDir, n, 'SKILL.md'))));
994
1081
  }
995
- // Remove stale skill directories no longer in the current version's map
1082
+ // Remove stale skill directories that a PREVIOUS init generated and this
1083
+ // version no longer ships. Entries init never wrote (user-authored skills,
1084
+ // skills installed by other tools) are left untouched — see readInitManifest.
996
1085
  const knownSkills = new Set([...new Set(skillsToCopy)]);
1086
+ const priorSkills = previouslyGenerated(targetDir, 'skills');
997
1087
  if (fs.existsSync(targetSkillsDir)) {
998
1088
  for (const existing of fs.readdirSync(targetSkillsDir)) {
999
- if (!knownSkills.has(existing)) {
1089
+ if (!knownSkills.has(existing) && priorSkills.has(existing)) {
1000
1090
  const stalePath = path.join(targetSkillsDir, existing);
1001
1091
  fs.rmSync(stalePath, { recursive: true, force: true });
1002
1092
  result.created.files.push(`[cleaned] .claude/skills/${existing} (stale)`);
@@ -1004,14 +1094,19 @@ async function copySkills(targetDir, options, result) {
1004
1094
  }
1005
1095
  }
1006
1096
  // Always copy/overwrite skills (never skip — ensures new version content lands)
1097
+ const writtenSkills = [];
1007
1098
  for (const skillName of knownSkills) {
1008
1099
  const sourcePath = path.join(sourceSkillsDir, skillName);
1009
1100
  const targetPath = path.join(targetSkillsDir, skillName);
1010
1101
  if (fs.existsSync(sourcePath)) {
1011
- if (fs.existsSync(targetPath)) {
1012
- fs.rmSync(targetPath, { recursive: true, force: true });
1013
- }
1102
+ // Deliberately NOT rmSync'd first. copyDirRecursive overwrites every
1103
+ // file it ships, so wiping the directory adds nothing except destroying
1104
+ // anything the user put inside it — notes beside a shipped skill, an
1105
+ // extra command in a shipped folder. `init --force` did exactly that.
1106
+ // The cost of not wiping is that a file removed from a newer version
1107
+ // lingers; the cost of wiping is silent data loss, which is worse.
1014
1108
  copyDirRecursive(sourcePath, targetPath);
1109
+ writtenSkills.push(skillName);
1015
1110
  result.created.files.push(`.claude/skills/${skillName}`);
1016
1111
  result.summary.skillsCount++;
1017
1112
  }
@@ -1022,6 +1117,12 @@ async function copySkills(targetDir, options, result) {
1022
1117
  result.errors.push(`Skill '${skillName}' listed in SKILLS_MAP has no source directory at ${sourcePath} — skipped`);
1023
1118
  }
1024
1119
  }
1120
+ // Record provenance so the next run can distinguish "we wrote this" from
1121
+ // "the user wrote this". Keep entries this run did not re-write but that a
1122
+ // previous run generated and that still exist, so provenance is not lost
1123
+ // when a section is partially skipped.
1124
+ const retainedSkills = [...priorSkills].filter(n => !writtenSkills.includes(n) && fs.existsSync(path.join(targetSkillsDir, n)));
1125
+ recordGenerated(targetDir, 'skills', [...writtenSkills, ...retainedSkills]);
1025
1126
  }
1026
1127
  /**
1027
1128
  * Copy commands from source
@@ -1084,11 +1185,13 @@ async function copyCommands(targetDir, options, result) {
1084
1185
  result.errors.push('Could not find source commands directory');
1085
1186
  return;
1086
1187
  }
1087
- // Remove stale command files/directories no longer in the current version's map
1188
+ // Remove stale command files/directories that a PREVIOUS init generated and
1189
+ // this version no longer ships. User-authored commands are never touched.
1088
1190
  const knownCommands = new Set([...new Set(commandsToCopy)]);
1191
+ const priorCommands = previouslyGenerated(targetDir, 'commands');
1089
1192
  if (fs.existsSync(targetCommandsDir)) {
1090
1193
  for (const existing of fs.readdirSync(targetCommandsDir)) {
1091
- if (!knownCommands.has(existing)) {
1194
+ if (!knownCommands.has(existing) && priorCommands.has(existing)) {
1092
1195
  const stalePath = path.join(targetCommandsDir, existing);
1093
1196
  fs.rmSync(stalePath, { recursive: true, force: true });
1094
1197
  result.created.files.push(`[cleaned] .claude/commands/${existing} (stale)`);
@@ -1096,23 +1199,27 @@ async function copyCommands(targetDir, options, result) {
1096
1199
  }
1097
1200
  }
1098
1201
  // Always copy/overwrite commands (never skip — ensures new version content lands)
1202
+ const writtenCommands = [];
1099
1203
  for (const cmdName of knownCommands) {
1100
1204
  const sourcePath = path.join(sourceCommandsDir, cmdName);
1101
1205
  const targetPath = path.join(targetCommandsDir, cmdName);
1102
1206
  if (fs.existsSync(sourcePath)) {
1103
- if (fs.existsSync(targetPath)) {
1104
- fs.rmSync(targetPath, { recursive: true, force: true });
1105
- }
1207
+ // No pre-copy rmSync — see the note in copySkills. Both branches below
1208
+ // overwrite what they ship, so wiping first only destroys files the user
1209
+ // added inside a shipped command directory.
1106
1210
  if (fs.statSync(sourcePath).isDirectory()) {
1107
1211
  copyDirRecursive(sourcePath, targetPath);
1108
1212
  }
1109
1213
  else {
1110
1214
  fs.copyFileSync(sourcePath, targetPath);
1111
1215
  }
1216
+ writtenCommands.push(cmdName);
1112
1217
  result.created.files.push(`.claude/commands/${cmdName}`);
1113
1218
  result.summary.commandsCount++;
1114
1219
  }
1115
1220
  }
1221
+ const retainedCommands = [...priorCommands].filter(n => !writtenCommands.includes(n) && fs.existsSync(path.join(targetCommandsDir, n)));
1222
+ recordGenerated(targetDir, 'commands', [...writtenCommands, ...retainedCommands]);
1116
1223
  }
1117
1224
  /**
1118
1225
  * Copy agents from source
@@ -1147,11 +1254,13 @@ async function copyAgents(targetDir, options, result) {
1147
1254
  result.errors.push('Could not find source agents directory');
1148
1255
  return;
1149
1256
  }
1150
- // Remove stale agent category directories no longer in the current version's map
1257
+ // Remove stale agent category directories that a PREVIOUS init generated and
1258
+ // this version no longer ships. User-authored agent dirs are never touched.
1151
1259
  const knownAgents = new Set([...new Set(agentsToCopy)]);
1260
+ const priorAgents = previouslyGenerated(targetDir, 'agents');
1152
1261
  if (fs.existsSync(targetAgentsDir)) {
1153
1262
  for (const existing of fs.readdirSync(targetAgentsDir)) {
1154
- if (!knownAgents.has(existing)) {
1263
+ if (!knownAgents.has(existing) && priorAgents.has(existing)) {
1155
1264
  const stalePath = path.join(targetAgentsDir, existing);
1156
1265
  fs.rmSync(stalePath, { recursive: true, force: true });
1157
1266
  result.created.files.push(`[cleaned] .claude/agents/${existing} (stale)`);
@@ -1159,20 +1268,27 @@ async function copyAgents(targetDir, options, result) {
1159
1268
  }
1160
1269
  }
1161
1270
  // Always copy/overwrite agents (never skip — ensures new version content lands)
1271
+ const writtenAgents = [];
1162
1272
  for (const agentCategory of knownAgents) {
1163
1273
  const sourcePath = path.join(sourceAgentsDir, agentCategory);
1164
1274
  const targetPath = path.join(targetAgentsDir, agentCategory);
1165
1275
  if (fs.existsSync(sourcePath)) {
1166
- if (fs.existsSync(targetPath)) {
1167
- fs.rmSync(targetPath, { recursive: true, force: true });
1168
- }
1276
+ // Deliberately NOT rmSync'd first. copyDirRecursive overwrites every
1277
+ // file it ships, so wiping the directory adds nothing except destroying
1278
+ // anything the user put inside it — notes beside a shipped skill, an
1279
+ // extra command in a shipped folder. `init --force` did exactly that.
1280
+ // The cost of not wiping is that a file removed from a newer version
1281
+ // lingers; the cost of wiping is silent data loss, which is worse.
1169
1282
  copyDirRecursive(sourcePath, targetPath);
1170
1283
  // Count agent files (.md only — .yaml agents were migrated to .md)
1171
1284
  const mdFiles = countFiles(sourcePath, '.md');
1172
1285
  result.summary.agentsCount += mdFiles;
1286
+ writtenAgents.push(agentCategory);
1173
1287
  result.created.files.push(`.claude/agents/${agentCategory}`);
1174
1288
  }
1175
1289
  }
1290
+ const retainedAgents = [...priorAgents].filter(n => !writtenAgents.includes(n) && fs.existsSync(path.join(targetAgentsDir, n)));
1291
+ recordGenerated(targetDir, 'agents', [...writtenAgents, ...retainedAgents]);
1176
1292
  }
1177
1293
  /**
1178
1294
  * Find source helpers directory.
@@ -226,7 +226,10 @@ function generateHooksConfig(config, graphify = true) {
226
226
  ],
227
227
  },
228
228
  {
229
- matcher: 'Write|Edit|MultiEdit',
229
+ // NotebookEdit is listed explicitly: its content field (`new_source`)
230
+ // is scanned by the same secrets gate as Write/Edit/MultiEdit, so it
231
+ // must not depend on `Edit` happening to substring-match.
232
+ matcher: 'Write|Edit|MultiEdit|NotebookEdit',
230
233
  hooks: [
231
234
  {
232
235
  // Was 'pre-edit' — not a registered hook-handler.cjs dispatch
@@ -26,6 +26,10 @@ export interface KnowledgeExcerpt {
26
26
  similarity: number;
27
27
  chunkIndex: number;
28
28
  scope: string;
29
+ /** True when this chunk belongs to a document version that has since been
30
+ * re-ingested (its contentHash is no longer the file's current one). Only
31
+ * ever set when the caller opted into `includeSuperseded`. */
32
+ superseded?: boolean;
29
33
  }
30
34
  export interface DocumentMeta {
31
35
  filePath: string;
@@ -40,6 +44,16 @@ export declare function ingestDirectory(dirPath: string, scope?: string, opts?:
40
44
  rootDir?: string;
41
45
  onProgress?: (file: string, done: number, total: number) => void;
42
46
  }): Promise<BatchIngestResult>;
47
+ /** Content hashes of the documents currently indexed under `rootDir`. */
48
+ export declare function liveContentHashes(rootDir: string): Set<string>;
49
+ /**
50
+ * True when `key` is a document chunk whose version is no longer current.
51
+ * Non-`doc:` keys are never superseded, and an empty `live` set means the
52
+ * metadata log is missing/unreadable — in that case nothing is filtered,
53
+ * because "no metadata" must not read as "everything is stale".
54
+ */
55
+ export declare function isSupersededKey(key: string, live: Set<string>): boolean;
56
+ export declare function supersededOverfetchLimit(limit: number, live: Set<string>): number;
43
57
  export declare function searchKnowledge(query: string, opts?: {
44
58
  scope?: string;
45
59
  limit?: number;
@@ -47,6 +61,9 @@ export declare function searchKnowledge(query: string, opts?: {
47
61
  rootDir?: string;
48
62
  /** which store(s): project-only, global-only, or both (default). */
49
63
  store?: 'project' | 'global' | 'all';
64
+ /** Return chunks from superseded document versions too, flagged
65
+ * `superseded: true`. Default false — see the note above `liveContentHashes`. */
66
+ includeSuperseded?: boolean;
50
67
  }): Promise<KnowledgeExcerpt[]>;
51
68
  export declare function listDocuments(rootDir?: string, scope?: string): DocumentMeta[];
52
69
  export declare function removeDocument(filePath: string, scope?: string, rootDir?: string): Promise<void>;
@@ -358,6 +358,52 @@ export async function ingestDirectory(dirPath, scope = 'shared', opts) {
358
358
  /** Small additive boost so project knowledge wins ties against the global
359
359
  * brain — local context is more likely to be what the user means. */
360
360
  const PROJECT_SCOPE_BOOST = 0.05;
361
+ // ── Superseded-version filtering ───────────────────────────────────
362
+ //
363
+ // Chunk keys are `doc:<contentHash>:<chunkIndex>`. Re-ingesting a changed file
364
+ // produces a NEW contentHash, so its chunks land under new keys — the previous
365
+ // version's rows are never touched (`removeDocument` only tombstones metadata;
366
+ // the bridge exposes no delete-by-prefix). The store therefore accumulates every
367
+ // version a document has ever had, and all of them stay searchable.
368
+ //
369
+ // Measured on this repo's own store (2026-07-26): 9,067 `doc:`-keyed rows in
370
+ // `knowledge:shared` spanning 798 distinct content hashes, of which only 139
371
+ // are current — 8,542 rows (94.2%) are orphaned older versions.
372
+ //
373
+ // Nothing is deleted here. The current-hash set from doc-metadata.jsonl is used
374
+ // to decide what search RETURNS; `includeSuperseded` puts the old versions back
375
+ // (flagged `superseded: true`) for anyone who wants document history.
376
+ /** Content hashes of the documents currently indexed under `rootDir`. */
377
+ export function liveContentHashes(rootDir) {
378
+ const live = new Set();
379
+ for (const m of readMetadata(rootDir))
380
+ if (m.contentHash)
381
+ live.add(m.contentHash);
382
+ return live;
383
+ }
384
+ /**
385
+ * True when `key` is a document chunk whose version is no longer current.
386
+ * Non-`doc:` keys are never superseded, and an empty `live` set means the
387
+ * metadata log is missing/unreadable — in that case nothing is filtered,
388
+ * because "no metadata" must not read as "everything is stale".
389
+ */
390
+ export function isSupersededKey(key, live) {
391
+ if (!key || !key.startsWith('doc:'))
392
+ return false;
393
+ if (live.size === 0)
394
+ return false;
395
+ return !live.has(key.split(':')[1] ?? '');
396
+ }
397
+ /** How many rows to ask the backend for per requested result when superseded
398
+ * filtering is active — most rows in a long-lived store are old versions, so
399
+ * a 1:1 fetch would return an almost-empty page. */
400
+ const SUPERSEDED_OVERFETCH = 20;
401
+ const SUPERSEDED_OVERFETCH_CAP = 300;
402
+ export function supersededOverfetchLimit(limit, live) {
403
+ if (live.size === 0)
404
+ return limit;
405
+ return Math.min(Math.max(limit * SUPERSEDED_OVERFETCH, limit), SUPERSEDED_OVERFETCH_CAP);
406
+ }
361
407
  export async function searchKnowledge(query, opts) {
362
408
  const bridge = await getBridge();
363
409
  if (!bridge)
@@ -373,17 +419,28 @@ export async function searchKnowledge(query, opts) {
373
419
  if (store !== 'project') {
374
420
  targets.push({ ns: namespace('global'), dbPath: GLOBAL_BRAIN_SENTINEL, root: globalBrainRoot(), label: 'global', boost: 0 });
375
421
  }
422
+ const includeSuperseded = opts?.includeSuperseded === true;
376
423
  const perTarget = await Promise.all(targets.map(async (t) => {
424
+ const meta = readMetadata(t.root);
425
+ const live = new Set();
426
+ for (const m of meta)
427
+ if (m.contentHash)
428
+ live.add(m.contentHash);
429
+ // Old versions dominate a long-lived store, so a 1:1 fetch would come back
430
+ // nearly empty once they are filtered out. Over-fetch, then trim.
431
+ const fetchLimit = includeSuperseded ? limit : supersededOverfetchLimit(limit, live);
377
432
  const result = await bridge.bridgeSearchEntries({
378
- query, namespace: t.ns, limit, threshold: minScore, dbPath: t.dbPath,
433
+ query, namespace: t.ns, limit: fetchLimit, threshold: minScore, dbPath: t.dbPath,
379
434
  }).catch(() => null);
380
435
  if (!result?.success || !result.results.length)
381
436
  return [];
382
- const meta = readMetadata(t.root);
383
437
  const hashToFile = new Map();
384
438
  for (const m of meta)
385
439
  hashToFile.set(m.contentHash, m.filePath);
386
- return result.results.map((r) => {
440
+ const kept = includeSuperseded
441
+ ? result.results
442
+ : result.results.filter((r) => !isSupersededKey(String(r.key ?? ''), live));
443
+ return kept.slice(0, limit).map((r) => {
387
444
  const parts = r.key.startsWith('doc:') ? r.key.split(':') : [];
388
445
  const hash = parts[1] ?? '';
389
446
  const idx = parseInt(parts[2] ?? '0', 10);
@@ -391,6 +448,7 @@ export async function searchKnowledge(query, opts) {
391
448
  // hash→file map can misattribute when two documents share identical
392
449
  // content, and goes empty when a re-ingested file's hash changed.
393
450
  const srcTag = (r.tags ?? []).find((tag) => tag.startsWith('src:'));
451
+ const superseded = includeSuperseded && isSupersededKey(String(r.key ?? ''), live);
394
452
  return {
395
453
  id: r.id,
396
454
  filePath: srcTag ? srcTag.slice(4) : hashToFile.get(hash) ?? '',
@@ -398,6 +456,7 @@ export async function searchKnowledge(query, opts) {
398
456
  similarity: r.score + t.boost,
399
457
  chunkIndex: isNaN(idx) ? 0 : idx,
400
458
  scope: t.label,
459
+ ...(superseded ? { superseded: true } : {}),
401
460
  };
402
461
  });
403
462
  }));
@@ -110,6 +110,83 @@ export class MCPClientError extends Error {
110
110
  this.name = 'MCPClientError';
111
111
  }
112
112
  }
113
+ /**
114
+ * Runtime JSON-Schema type name for a JS value, or undefined for values we do
115
+ * not model (functions, symbols, bigint). `null` is reported as 'null' so a
116
+ * declared `type: 'object'` does not silently accept it.
117
+ *
118
+ * Note the two JS/JSON mismatches this has to paper over: arrays are objects
119
+ * in JS but a distinct type in JSON Schema, and JSON has no integer type —
120
+ * `integer` is a *number* with a constraint, so `3` satisfies both `number`
121
+ * and `integer` while `3.5` satisfies only `number`.
122
+ */
123
+ function jsonTypeOf(value) {
124
+ if (value === null)
125
+ return 'null';
126
+ if (Array.isArray(value))
127
+ return 'array';
128
+ switch (typeof value) {
129
+ case 'string': return 'string';
130
+ case 'boolean': return 'boolean';
131
+ case 'number': return Number.isInteger(value) ? 'integer' : 'number';
132
+ case 'object': return 'object';
133
+ default: return undefined;
134
+ }
135
+ }
136
+ function matchesDeclaredType(declared, actual) {
137
+ if (declared === actual)
138
+ return true;
139
+ // Every integer is a valid number; the reverse is not true.
140
+ if (declared === 'number' && actual === 'integer')
141
+ return true;
142
+ return false;
143
+ }
144
+ /**
145
+ * Check present arguments against the `type` each property declares in the
146
+ * tool's inputSchema and WARN on a mismatch — deliberately non-fatal for now.
147
+ *
148
+ * `required` is already hard-enforced above; `type` is not, because nothing
149
+ * ever checked it, so a tool declaring `{type: 'string'}` has always been free
150
+ * to receive a number and reach its handler. Turning that into a throw without
151
+ * knowing how many real callers violate their own schemas would break working
152
+ * code, so this logs first: run the suite, count the warnings, then decide.
153
+ *
154
+ * Absent properties are ignored — that is `required`'s job, not this one's.
155
+ * Explicit null is also ignored here for the same reason (the required check
156
+ * already rejects it for required params, and an optional param set to null is
157
+ * an "unset" idiom, not a type error).
158
+ */
159
+ function warnOnTypeMismatch(toolName, schema, input) {
160
+ const properties = schema?.properties;
161
+ if (!properties || typeof properties !== 'object')
162
+ return;
163
+ for (const [key, value] of Object.entries(input)) {
164
+ if (value === undefined || value === null)
165
+ continue;
166
+ const prop = properties[key];
167
+ if (!prop || typeof prop !== 'object')
168
+ continue;
169
+ const declared = prop.type;
170
+ // Union types (`type: ['string','number']`) pass if any branch matches.
171
+ const declaredList = typeof declared === 'string'
172
+ ? [declared]
173
+ : Array.isArray(declared) && declared.every(t => typeof t === 'string')
174
+ ? declared
175
+ : undefined;
176
+ if (!declaredList || declaredList.length === 0)
177
+ continue;
178
+ const actual = jsonTypeOf(value);
179
+ if (!actual)
180
+ continue;
181
+ if (declaredList.some(d => matchesDeclaredType(d, actual)))
182
+ continue;
183
+ // Report `integer` as `number` — the distinction is an artefact of how we
184
+ // classify JS numbers, not something the caller passed.
185
+ const reported = actual === 'integer' ? 'number' : actual;
186
+ console.error(`[mcp] tool '${toolName}' param '${key}': schema declares ` +
187
+ `${declaredList.join('|')}, got ${reported}`);
188
+ }
189
+ }
113
190
  /**
114
191
  * Call an MCP tool by name with input parameters
115
192
  */
@@ -142,6 +219,7 @@ export async function callMCPTool(toolName, input = {}, context) {
142
219
  throw new MCPClientError(`MCP tool '${toolName}' missing required parameter${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`, toolName);
143
220
  }
144
221
  }
222
+ warnOnTypeMismatch(toolName, tool.inputSchema, input);
145
223
  try {
146
224
  const result = await tool.handler(input, context);
147
225
  return result;
@@ -544,9 +544,13 @@ export const allEmbeddingsTools = [
544
544
  message: 'Embedding substrate initialized',
545
545
  };
546
546
  case 'drift':
547
- // Get real drift metrics if available
547
+ // Get real drift metrics if available.
548
+ // initializeIntelligence() MUST run first: getIntelligenceStats()
549
+ // reads module singletons (sonaCoordinator / reasoningBank) that are
550
+ // null until init, so a populated store otherwise reports 0 patterns.
548
551
  try {
549
- const { getIntelligenceStats } = await import('../memory/intelligence.js');
552
+ const { getIntelligenceStats, initializeIntelligence } = await import('../memory/intelligence.js');
553
+ await initializeIntelligence();
550
554
  const stats = getIntelligenceStats();
551
555
  return {
552
556
  success: true,
@@ -564,17 +568,20 @@ export const allEmbeddingsTools = [
564
568
  : 'No patterns stored yet - drift detection inactive',
565
569
  };
566
570
  }
567
- catch {
571
+ catch (e) {
572
+ // Failing to read the store is NOT a drift report of zero.
568
573
  return {
569
- success: true,
574
+ success: false,
570
575
  action: 'drift',
576
+ error: `Intelligence store unavailable — drift status unknown: ${e.message}`,
571
577
  status: { semanticDrift: { enabled: false, reason: 'Intelligence module unavailable' } },
572
578
  };
573
579
  }
574
580
  case 'consolidate':
575
- // Get real consolidation metrics
581
+ // Get real consolidation metrics — same init requirement as 'drift'.
576
582
  try {
577
- const { getIntelligenceStats } = await import('../memory/intelligence.js');
583
+ const { getIntelligenceStats, initializeIntelligence } = await import('../memory/intelligence.js');
584
+ await initializeIntelligence();
578
585
  const stats = getIntelligenceStats();
579
586
  return {
580
587
  success: true,
@@ -590,10 +597,11 @@ export const allEmbeddingsTools = [
590
597
  message: `ReasoningBank: ${stats.reasoningBankSize} patterns, ${stats.trajectoriesRecorded} trajectories`,
591
598
  };
592
599
  }
593
- catch {
600
+ catch (e) {
594
601
  return {
595
- success: true,
602
+ success: false,
596
603
  action: 'consolidate',
604
+ error: `Intelligence store unavailable — consolidation status unknown: ${e.message}`,
597
605
  status: { memoryPhysics: { enabled: false, reason: 'Intelligence module unavailable' } },
598
606
  };
599
607
  }
@@ -82,36 +82,50 @@ const knowledgeSearch = {
82
82
  limit: { type: 'number', description: 'Max results (default: 10)' },
83
83
  minScore: { type: 'number', description: 'Minimum similarity threshold (default: 0.3)' },
84
84
  surfaces: { type: 'array', items: { type: 'string' }, description: "Override routing: any of 'chunks','kg','rules','memory'" },
85
+ includeSuperseded: { type: 'boolean', description: 'Also return chunks from older, re-ingested versions of a document (flagged superseded). Default false.' },
85
86
  },
86
87
  required: ['query'],
87
88
  },
88
89
  handler: async (input) => {
89
90
  const { searchKnowledge } = await import('../knowledge/document-pipeline.js');
90
- const { routeQuery, rrfFuse } = await import('../memory/query-router.js');
91
+ const { routeQuery, rrfFuse, recordRouteOverride } = await import('../memory/query-router.js');
91
92
  try {
92
93
  const query = String(input.query);
93
94
  const limit = input.limit ? Number(input.limit) : 10;
94
95
  const route = routeQuery(query);
95
- const surfaces = Array.isArray(input.surfaces) && input.surfaces.length
96
+ const explicitSurfaces = Array.isArray(input.surfaces) && input.surfaces.length
96
97
  ? input.surfaces
97
- : (route.confident ? route.surfaces : ['chunks', ...route.surfaces.filter(s => s !== 'chunks')]);
98
+ : null;
99
+ const surfaces = explicitSurfaces
100
+ ?? (route.confident ? route.surfaces : ['chunks', ...route.surfaces.filter(s => s !== 'chunks')]);
101
+ const chunkOpts = {
102
+ scope: input.scope ? String(input.scope) : undefined,
103
+ limit,
104
+ minScore: input.minScore ? Number(input.minScore) : undefined,
105
+ includeSuperseded: input.includeSuperseded === true,
106
+ };
98
107
  const bridge = await import('../memory/memory-bridge.js');
99
108
  const kg = await import('../memory/memory-kg.js');
100
109
  const [excerpts, graph, rules, memories] = await Promise.all([
101
- surfaces.includes('chunks')
102
- ? searchKnowledge(query, {
103
- scope: input.scope ? String(input.scope) : undefined,
104
- limit,
105
- minScore: input.minScore ? Number(input.minScore) : undefined,
106
- })
107
- : [],
110
+ surfaces.includes('chunks') ? searchKnowledge(query, chunkOpts) : [],
108
111
  surfaces.includes('kg') ? kg.kgSearch({ query, limit: 6 }) : null,
109
112
  surfaces.includes('rules') ? bridge.bridgeSearchEntries({ query, namespace: 'rules', limit: 3, threshold: 0.35 }) : null,
110
113
  surfaces.includes('memory') ? bridge.bridgeSearchEntries({ query, namespace: 'patterns', limit: 3 }) : null,
111
114
  ]);
115
+ // Confident non-chunk routing against an empty surface (e.g. a project
116
+ // with no KG yet) must not read as "no knowledge" — fall back to chunks.
117
+ // Same rule the CLI `doc search` path applies; without it agents got
118
+ // "no results" where the CLI returned document excerpts.
119
+ let fellBack = false;
120
+ let chunkExcerpts = excerpts;
121
+ if (!explicitSurfaces && !chunkExcerpts.length && !(graph?.triplets?.length) && !(rules?.results?.length) && !(memories?.results?.length) && !surfaces.includes('chunks')) {
122
+ fellBack = true;
123
+ recordRouteOverride(surfaces[0], 'chunks');
124
+ chunkExcerpts = await searchKnowledge(query, chunkOpts);
125
+ }
112
126
  // Rank-fuse heterogeneous lists (raw scores aren't comparable).
113
127
  const fused = rrfFuse([
114
- excerpts.map(e => ({ id: e.id || `${e.filePath}#${e.chunkIndex}`, kind: 'excerpt', ...e })),
128
+ chunkExcerpts.map(e => ({ id: e.id || `${e.filePath}#${e.chunkIndex}`, kind: 'excerpt', ...e })),
115
129
  (graph?.triplets ?? []).map((t, i) => ({ id: `kg:${i}:${t.source}|${t.relation}|${t.target}`, kind: 'triplet', ...t })),
116
130
  (rules?.results ?? []).map(r => ({ id: r.id, kind: 'rule', key: r.key, text: r.content, importance: 0.7 })),
117
131
  (memories?.results ?? []).map(r => ({ id: r.id, kind: 'memory', key: r.key, text: r.content })),
@@ -122,10 +136,10 @@ const knowledgeSearch = {
122
136
  text: JSON.stringify({
123
137
  success: true,
124
138
  count: fused.length,
125
- routing: { surfaces, confident: route.confident },
139
+ routing: { surfaces, confident: route.confident, fellBackToChunks: fellBack },
126
140
  results: fused,
127
141
  // Back-compat: excerpt-only view for existing consumers.
128
- excerpts,
142
+ excerpts: chunkExcerpts,
129
143
  }),
130
144
  }],
131
145
  };
@@ -63,7 +63,11 @@ export declare function bridgeSearchEntries(options: {
63
63
  tags?: string[];
64
64
  }[];
65
65
  searchTime: number;
66
- searchMethod?: string;
66
+ /** What actually ran, never what was requested. 'keyword-fallback' means the
67
+ * vector path was attempted and did not produce the results. */
68
+ searchMethod?: 'semantic' | 'keyword' | 'keyword-fallback';
69
+ /** Why the vector path did not serve these results (absent when it did). */
70
+ fallbackReason?: 'no-embedding-model' | 'empty-query' | 'embedding-failed' | 'no-semantic-matches';
67
71
  error?: string;
68
72
  } | null>;
69
73
  export declare function bridgeListEntries(options: {