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.
- package/package.json +5 -4
- package/packages/@monomind/cli/.claude/agents/core/coder.md +9 -0
- package/packages/@monomind/cli/.claude/agents/core/coordinator.md +62 -0
- package/packages/@monomind/cli/.claude/agents/core/planner.md +9 -0
- package/packages/@monomind/cli/.claude/agents/core/reviewer.md +9 -0
- package/packages/@monomind/cli/.claude/agents/core/tester.md +8 -0
- package/packages/@monomind/cli/.claude/helpers/handlers/gates-handler.cjs +180 -47
- package/packages/@monomind/cli/.claude/helpers/handlers/route-handler.cjs +32 -3
- package/packages/@monomind/cli/.claude/helpers/hook-handler.cjs +55 -3
- package/packages/@monomind/cli/.claude/helpers/intelligence.cjs +3 -1
- package/packages/@monomind/cli/.claude/helpers/statusline.cjs +27 -3
- package/packages/@monomind/cli/.claude/helpers/utils/monograph.cjs +104 -18
- package/packages/@monomind/cli/.claude/settings.json +1 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-server.mjs +38 -1
- package/packages/@monomind/cli/dist/src/browser/dashboard/server.js +6 -1
- package/packages/@monomind/cli/dist/src/capabilities/index.d.ts +0 -1
- package/packages/@monomind/cli/dist/src/capabilities/index.js +8 -1
- package/packages/@monomind/cli/dist/src/commands/agent-lifecycle.js +5 -1
- package/packages/@monomind/cli/dist/src/commands/agent-ops.js +32 -2
- package/packages/@monomind/cli/dist/src/commands/browse-workflow.js +30 -0
- package/packages/@monomind/cli/dist/src/commands/doctor-project-checks.js +8 -1
- package/packages/@monomind/cli/dist/src/commands/guidance.js +8 -2
- package/packages/@monomind/cli/dist/src/commands/hooks-extended-commands.js +10 -2
- package/packages/@monomind/cli/dist/src/commands/init.js +18 -4
- package/packages/@monomind/cli/dist/src/commands/memory-crud.js +40 -3
- package/packages/@monomind/cli/dist/src/commands/org-observe.js +67 -13
- package/packages/@monomind/cli/dist/src/commands/org.d.ts +11 -0
- package/packages/@monomind/cli/dist/src/commands/org.js +162 -15
- package/packages/@monomind/cli/dist/src/commands/performance.js +31 -5
- package/packages/@monomind/cli/dist/src/commands/security-misc.js +18 -3
- package/packages/@monomind/cli/dist/src/commands/security-scan.d.ts +30 -1
- package/packages/@monomind/cli/dist/src/commands/security-scan.js +182 -69
- package/packages/@monomind/cli/dist/src/commands/swarm.js +41 -14
- package/packages/@monomind/cli/dist/src/consensus/audit-writer.js +11 -10
- package/packages/@monomind/cli/dist/src/init/executor.js +138 -22
- package/packages/@monomind/cli/dist/src/init/settings-generator.js +4 -1
- package/packages/@monomind/cli/dist/src/knowledge/document-pipeline.d.ts +17 -0
- package/packages/@monomind/cli/dist/src/knowledge/document-pipeline.js +62 -3
- package/packages/@monomind/cli/dist/src/mcp-client.js +78 -0
- package/packages/@monomind/cli/dist/src/mcp-tools/embeddings-tools.js +16 -8
- package/packages/@monomind/cli/dist/src/mcp-tools/knowledge-tools.js +27 -13
- package/packages/@monomind/cli/dist/src/memory/memory-bridge.d.ts +5 -1
- package/packages/@monomind/cli/dist/src/memory/memory-bridge.js +26 -3
- package/packages/@monomind/cli/dist/src/memory/memory-read.d.ts +9 -0
- package/packages/@monomind/cli/dist/src/memory/memory-read.js +13 -2
- package/packages/@monomind/cli/dist/src/monovector/diff-classifier.js +25 -6
- package/packages/@monomind/cli/dist/src/orgrt/daemon.d.ts +21 -1
- package/packages/@monomind/cli/dist/src/orgrt/daemon.js +82 -11
- package/packages/@monomind/cli/dist/src/orgrt/inbox.js +53 -20
- package/packages/@monomind/cli/dist/src/parser.d.ts +4 -2
- package/packages/@monomind/cli/dist/src/parser.js +61 -25
- package/packages/@monomind/cli/dist/src/routing/route-layer-factory.d.ts +15 -0
- package/packages/@monomind/cli/dist/src/routing/route-layer-factory.js +44 -2
- package/packages/@monomind/cli/dist/src/services/config-file-manager.d.ts +10 -2
- package/packages/@monomind/cli/dist/src/services/config-file-manager.js +10 -2
- package/packages/@monomind/cli/dist/src/ui/collector.mjs +43 -3
- package/packages/@monomind/cli/dist/src/ui/dashboard.html +27 -8
- package/packages/@monomind/cli/dist/src/ui/server.mjs +144 -14
- package/packages/@monomind/cli/package.json +7 -6
- package/packages/@monomind/cli/dist/src/capabilities/watcher.d.ts +0 -18
- package/packages/@monomind/cli/dist/src/capabilities/watcher.js +0 -107
- package/packages/@monomind/cli/dist/src/config-adapter.d.ts +0 -16
- 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 {
|
|
415
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1012
|
-
|
|
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
|
|
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
|
-
|
|
1104
|
-
|
|
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
|
|
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
|
-
|
|
1167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
96
|
+
const explicitSurfaces = Array.isArray(input.surfaces) && input.surfaces.length
|
|
96
97
|
? input.surfaces
|
|
97
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|