monomind 2.7.6 → 2.7.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "monomind",
3
- "version": "2.7.6",
3
+ "version": "2.7.7",
4
4
  "description": "Open-source CLI extension for Claude Code. Adds an MCP server with a codebase knowledge graph, persistent memory, multi-agent coordination, and reusable slash commands. MIT licensed, runs locally, no data leaves your machine.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -63,7 +63,7 @@
63
63
  "dependencies": {
64
64
  "@anthropic-ai/claude-agent-sdk": "^0.3.207",
65
65
  "@monoes/monobrowse": "^1.0.6",
66
- "@monoes/monodesign": "^1.2.0",
66
+ "@monoes/monodesign": "^1.2.1",
67
67
  "@monoes/monograph": "^1.5.4",
68
68
  "@noble/ed25519": "^2.1.0",
69
69
  "mammoth": "^1.12.0",
@@ -76,9 +76,9 @@
76
76
  "optionalDependencies": {
77
77
  "@huggingface/transformers": "^3.8.1",
78
78
  "@monoes/hooks": "^1.0.0",
79
- "@monoes/mcp": "^1.0.0",
79
+ "@monoes/mcp": "^1.0.1",
80
80
  "@monoes/memory": "^1.0.10",
81
- "@monoes/routing": "^1.0.0",
81
+ "@monoes/routing": "^1.0.1",
82
82
  "monofence-ai": "*",
83
83
  "sql.js": "^1.14.1"
84
84
  },
@@ -1112,7 +1112,8 @@ applyLegacyDeferredAcceptsOnStartup();
1112
1112
  restorePendingEventsFromStore();
1113
1113
  manualApply.pruneStaleEvidence();
1114
1114
  const portArg = args.find(a => a.startsWith('--port='));
1115
- state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
1115
+ const explicitPort = portArg ? parseInt(portArg.split('=')[1], 10) : null;
1116
+ state.port = explicitPort ?? await findOpenPort();
1116
1117
  // Annotation screenshots live in the project root so the agent's Read tool
1117
1118
  // doesn't trip a per-file permission prompt. Sessioned by token so concurrent
1118
1119
  // projects (or quick restarts) don't collide.
@@ -1123,7 +1124,43 @@ state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-'));
1123
1124
  const { detectScript, liveScriptParts } = loadBrowserScripts();
1124
1125
  httpServer = http.createServer(createRequestHandler({ detectScript, liveScriptParts }));
1125
1126
 
1127
+ // findOpenPort() probes a port, closes the probe socket, and only then does the
1128
+ // real server bind it. Another process scanning the same range can take the
1129
+ // port inside that gap, which surfaced as an intermittent
1130
+ // "EADDRINUSE 127.0.0.1:8405" — about one run in five of the test suite, where
1131
+ // several server-starting test files run in parallel.
1132
+ //
1133
+ // Retry on the next port when we picked it ourselves. An explicit --port is
1134
+ // never silently moved: the caller asked for that port, so a conflict there
1135
+ // must be an error they can see.
1136
+ const MAX_PORT_RETRIES = 25;
1137
+ let portRetries = 0;
1138
+
1139
+ httpServer.on('error', (err) => {
1140
+ if (err?.code !== 'EADDRINUSE') throw err;
1141
+
1142
+ if (explicitPort !== null) {
1143
+ console.error(`\nPort ${state.port} is already in use.`);
1144
+ console.error('Another live server may be running — stop it, or pass a different --port.');
1145
+ process.exit(1);
1146
+ }
1147
+
1148
+ if (portRetries >= MAX_PORT_RETRIES) {
1149
+ console.error(`\nCould not find a free port after ${MAX_PORT_RETRIES} attempts (last tried ${state.port}).`);
1150
+ process.exit(1);
1151
+ }
1152
+
1153
+ portRetries++;
1154
+ state.port++;
1155
+ httpServer.listen(state.port, '127.0.0.1');
1156
+ });
1157
+
1126
1158
  httpServer.listen(state.port, '127.0.0.1', () => {
1159
+ // Trust the address the OS actually bound over the one we asked for — after
1160
+ // a retry above, state.port and the bound port must not drift apart.
1161
+ const boundAddr = httpServer.address();
1162
+ if (boundAddr && typeof boundAddr === 'object') state.port = boundAddr.port;
1163
+
1127
1164
  writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token });
1128
1165
  const url = `http://localhost:${state.port}`;
1129
1166
  console.log(`\nMonodesign live server running on ${url}`);
@@ -180,6 +180,36 @@ export const healthCommand = {
180
180
  { command: 'monomind agent health -i agent-001 -d', description: 'Detailed health for specific agent' },
181
181
  ],
182
182
  action: async (ctx) => {
183
+ // --watch was declared (and documented as "refresh every 5s") but never
184
+ // read, so the command rendered once and exited. Mirrors the watch loop in
185
+ // commands/status.ts.
186
+ if (ctx.flags.watch) {
187
+ return watchAgentHealth(ctx);
188
+ }
189
+ return renderAgentHealth(ctx);
190
+ },
191
+ };
192
+ const WATCH_INTERVAL_MS = 5000;
193
+ async function watchAgentHealth(ctx) {
194
+ const refresh = async () => {
195
+ process.stdout.write('\x1b[2J\x1b[H');
196
+ output.writeln(output.dim(`Last updated: ${new Date().toLocaleTimeString()} — refreshing every 5s. Press Ctrl+C to exit.`));
197
+ await renderAgentHealth(ctx);
198
+ };
199
+ await refresh();
200
+ const intervalId = setInterval(() => { void refresh(); }, WATCH_INTERVAL_MS);
201
+ // `once` so repeated invocations don't accumulate SIGINT handlers.
202
+ return new Promise((resolve) => {
203
+ process.once('SIGINT', () => {
204
+ clearInterval(intervalId);
205
+ output.writeln();
206
+ output.printInfo('Watch mode stopped');
207
+ resolve({ success: true });
208
+ });
209
+ });
210
+ }
211
+ async function renderAgentHealth(ctx) {
212
+ {
183
213
  const agentId = ctx.args[0] || ctx.flags.id;
184
214
  const detailed = ctx.flags.detailed;
185
215
  try {
@@ -242,6 +272,6 @@ export const healthCommand = {
242
272
  output.printError(error instanceof MCPClientError ? `Health check error: ${error.message}` : `Unexpected error: ${String(error)}`);
243
273
  return { success: false, exitCode: 1 };
244
274
  }
245
- },
246
- };
275
+ }
276
+ }
247
277
  //# sourceMappingURL=agent-ops.js.map
@@ -53,6 +53,35 @@ const runSubcommand = {
53
53
  const wf = await readWorkflow(filePath).catch(e => { output.printError(e.message); return null; });
54
54
  if (!wf)
55
55
  return { success: false, exitCode: 1 };
56
+ // --items feeds the run's input set. Previously the flag was parsed and
57
+ // then dropped, so every run silently used the single empty default item.
58
+ let items;
59
+ const itemsFlag = ctx.flags.items;
60
+ if (itemsFlag) {
61
+ const itemsPath = isAbsolute(itemsFlag) ? itemsFlag : resolve(ctx.cwd, itemsFlag);
62
+ let parsed;
63
+ try {
64
+ const { readFile } = await import('fs/promises');
65
+ parsed = JSON.parse(await readFile(itemsPath, 'utf8'));
66
+ }
67
+ catch (e) {
68
+ output.printError(`Cannot read items file ${itemsPath}: ${e.message}`);
69
+ return { success: false, exitCode: 1 };
70
+ }
71
+ if (!Array.isArray(parsed)) {
72
+ output.printError(`Items file ${itemsPath} must contain a JSON array`);
73
+ return { success: false, exitCode: 1 };
74
+ }
75
+ // Accept both the engine's `{ data: {...} }` envelope and a bare array
76
+ // of objects, which is the shape people naturally write by hand.
77
+ items = parsed.map((entry) => {
78
+ const rec = entry;
79
+ return rec && typeof rec === 'object' && 'data' in rec && typeof rec.data === 'object' && rec.data !== null
80
+ ? { data: rec.data }
81
+ : { data: (rec ?? {}) };
82
+ });
83
+ output.printInfo(`Loaded ${items.length} input item(s) from ${itemsFlag}`);
84
+ }
56
85
  const port = ctx.flags.port ?? 4243;
57
86
  const dashboard = getDashboardServer(port);
58
87
  if (!ctx.flags['no-dashboard']) {
@@ -65,6 +94,7 @@ const runSubcommand = {
65
94
  spinner.start();
66
95
  const record = await runWorkflow(wf, {
67
96
  onEvent: (ev) => dashboard.broadcast(ev),
97
+ ...(items ? { items } : {}),
68
98
  });
69
99
  if (record.status === 'completed') {
70
100
  spinner.succeed(`Done — ${record.itemsProcessed} items in ${((record.completedAt - record.startedAt) / 1000).toFixed(1)}s`);
@@ -200,7 +200,7 @@ export const notifyCommand = {
200
200
  options: [
201
201
  { name: 'message', short: 'm', type: 'string', description: 'Notification message', required: true },
202
202
  { name: 'level', short: 'l', type: 'string', description: 'Level: info, warn, error', default: 'info' },
203
- { name: 'channel', short: 'c', type: 'string', description: 'Notification channel', default: 'console' },
203
+ { name: 'channel', short: 'c', type: 'string', description: 'Notification channel (only "console" is implemented)', default: 'console' },
204
204
  ],
205
205
  examples: [
206
206
  { command: 'monomind hooks notify -m "Build complete"', description: 'Send info notification' },
@@ -213,6 +213,14 @@ export const notifyCommand = {
213
213
  output.printError('Message is required: --message "your message"');
214
214
  return { success: false, exitCode: 1 };
215
215
  }
216
+ // Console is the only delivery mechanism that exists. Accepting
217
+ // `--channel slack` and then printing to the console anyway would tell the
218
+ // user their message went somewhere it did not.
219
+ const channel = ctx.flags.channel || 'console';
220
+ if (channel !== 'console') {
221
+ output.writeln(output.warning(`Channel "${channel}" is not implemented — delivering to console. ` +
222
+ `Only "console" is supported today.`));
223
+ }
216
224
  const timestamp = new Date().toISOString();
217
225
  if (level === 'error') {
218
226
  output.printError(`[${timestamp}] ${message}`);
@@ -229,7 +237,7 @@ export const notifyCommand = {
229
237
  await storeEntry({ key: `notify-${Date.now()}`, value: `[${level}] ${message}`, namespace: 'notifications' });
230
238
  }
231
239
  catch { /* memory not available */ }
232
- return { success: true, data: { timestamp, level, message } };
240
+ return { success: true, data: { timestamp, level, message, channel: 'console' } };
233
241
  }
234
242
  };
235
243
  //# sourceMappingURL=hooks-extended-commands.js.map
@@ -297,7 +297,10 @@ export const searchCommand = {
297
297
  output.writeln();
298
298
  }
299
299
  }
300
- output.printInfo(`Searching: "${query}" (${searchType})`);
300
+ // Requested type only — the method that ACTUALLY ran is printed after the
301
+ // search, from searchResult.searchMethod. Labelling this line "(semantic)"
302
+ // used to claim a vector search that may never have happened.
303
+ output.printInfo(`Searching: "${query}" (requested: ${searchType})`);
301
304
  output.writeln();
302
305
  // Use direct sql.js search with vector similarity
303
306
  try {
@@ -318,11 +321,39 @@ export const searchCommand = {
318
321
  namespace: r.namespace,
319
322
  preview: r.content
320
323
  }));
324
+ const actualMethod = searchResult.searchMethod ?? 'unknown';
325
+ const fallbackReason = searchResult.fallbackReason;
321
326
  if (ctx.flags.format === 'json') {
322
- output.printJson({ query, searchType, results, searchTime: `${searchResult.searchTime}ms` });
327
+ output.printJson({
328
+ query,
329
+ searchType,
330
+ searchMethod: actualMethod,
331
+ ...(fallbackReason ? { fallbackReason } : {}),
332
+ results,
333
+ searchTime: `${searchResult.searchTime}ms`,
334
+ });
323
335
  return { success: true, data: results };
324
336
  }
325
- // Performance stats
337
+ // Performance stats — method first, so a keyword fallback is never hidden
338
+ // behind a "(semantic)" header.
339
+ const REASON_TEXT = {
340
+ 'no-embedding-model': 'embedding model unavailable',
341
+ 'embedding-failed': 'embedding generation failed',
342
+ 'no-semantic-matches': 'vector search returned no matches',
343
+ };
344
+ if (actualMethod === 'semantic') {
345
+ output.writeln(output.dim(' Method: semantic (vector similarity)'));
346
+ }
347
+ else if (actualMethod === 'hybrid') {
348
+ output.writeln(output.dim(' Method: hybrid (per-entry cosine, keyword overlap where no vector exists)'));
349
+ }
350
+ else if (actualMethod === 'unknown') {
351
+ output.writeln(output.dim(' Method: unknown'));
352
+ }
353
+ else {
354
+ const why = fallbackReason ? REASON_TEXT[fallbackReason] ?? fallbackReason : undefined;
355
+ output.printWarning(`Method: ${actualMethod}${why ? ` — ${why}` : ''}. Scores are token-overlap fractions, not vector similarity.`);
356
+ }
326
357
  output.writeln(output.dim(` Search time: ${searchResult.searchTime}ms`));
327
358
  output.writeln();
328
359
  if (results.length === 0) {
@@ -178,17 +178,44 @@ export const reportAction = async (ctx, name) => {
178
178
  }
179
179
  return { success: true };
180
180
  };
181
+ /** Read questions.json. A MISSING file legitimately means "no questions" → [].
182
+ * Any other failure (unreadable, malformed — e.g. a partial daemon write) THROWS:
183
+ * answerAction rewrites this file from what this returns, so silently coercing a
184
+ * failed read to [] would atomically replace every recorded question with one. */
181
185
  const readQuestions = (cwd, name) => {
186
+ const path = join(cwd, ORG_DIR, name, 'questions.json');
187
+ let raw;
182
188
  try {
183
- return JSON.parse(readFileSync(join(cwd, ORG_DIR, name, 'questions.json'), 'utf8')).questions ?? [];
189
+ raw = readFileSync(path, 'utf8');
184
190
  }
185
- catch {
186
- return [];
191
+ catch (err) {
192
+ if (err.code === 'ENOENT')
193
+ return [];
194
+ throw new Error(`cannot read ${path}: ${err instanceof Error ? err.message : String(err)}`);
195
+ }
196
+ let parsed;
197
+ try {
198
+ parsed = JSON.parse(raw);
199
+ }
200
+ catch (err) {
201
+ throw new Error(`${path} is not valid JSON (${err instanceof Error ? err.message : String(err)})`);
187
202
  }
203
+ if (parsed?.questions === undefined || parsed.questions === null)
204
+ return [];
205
+ if (!Array.isArray(parsed.questions))
206
+ throw new Error(`${path}: "questions" is not an array`);
207
+ return parsed.questions;
188
208
  };
189
209
  /** `org questions <name> [--all]` — list pending (or all) ask_human questions. */
190
210
  export const questionsAction = async (ctx, name) => {
191
- const all = readQuestions(ctx.cwd, name);
211
+ let all;
212
+ try {
213
+ all = readQuestions(ctx.cwd, name);
214
+ }
215
+ catch (err) {
216
+ log(output.error(`Cannot read questions for org ${name}: ${err instanceof Error ? err.message : String(err)}`));
217
+ return { success: false, message: 'questions.json unreadable' };
218
+ }
192
219
  const shown = ctx.flags['all'] === true ? all : all.filter(q => q.answer === null);
193
220
  if (!shown.length) {
194
221
  log(output.info(all.length ? `No pending questions for org ${name} (${all.length} answered — use --all).` : `No questions recorded for org ${name}.`));
@@ -212,7 +239,14 @@ export const answerAction = async (ctx, name) => {
212
239
  const answer = ctx.args.slice(2).join(' ').trim();
213
240
  if (!questionId || !answer)
214
241
  return { success: false, message: `usage: monomind org answer ${name} <question-id> "answer text"` };
215
- const questions = readQuestions(ctx.cwd, name);
242
+ let questions;
243
+ try {
244
+ questions = readQuestions(ctx.cwd, name);
245
+ }
246
+ catch (err) {
247
+ log(output.error(`Cannot read questions for org ${name}: ${err instanceof Error ? err.message : String(err)}`));
248
+ return { success: false, message: 'questions.json unreadable — answer not recorded' };
249
+ }
216
250
  const q = questions.find(x => x.questionId === questionId);
217
251
  if (!q) {
218
252
  log(output.error(`Question "${questionId}" not found for org ${name} — list with: monomind org questions ${name}`));
@@ -246,7 +280,17 @@ export const answerAction = async (ctx, name) => {
246
280
  // snapshot can be up to 10s stale (live-delivery timeout), and rewriting
247
281
  // from it would delete questions the daemon appended meanwhile and revert
248
282
  // answers it recorded (atomic rename prevents torn writes, not lost updates).
249
- const fresh = readQuestions(ctx.cwd, name);
283
+ // A FAILED re-read must abort the write: rewriting from [] would atomically
284
+ // rename a single-question file over every other recorded question.
285
+ let fresh;
286
+ try {
287
+ fresh = readQuestions(ctx.cwd, name);
288
+ }
289
+ catch (err) {
290
+ log(output.error(`Refusing to rewrite questions.json — ${err instanceof Error ? err.message : String(err)}`));
291
+ log(output.warning(`The answer was NOT recorded. Fix or restore ${join(ctx.cwd, ORG_DIR, name, 'questions.json')}, then retry.`));
292
+ return { success: false, message: 'questions.json unreadable — answer not recorded' };
293
+ }
250
294
  const freshQ = fresh.find(x => x.questionId === questionId);
251
295
  if (freshQ && freshQ.answer !== null) {
252
296
  return { success: false, message: `question "${questionId}" was answered while this command was running` };
@@ -521,12 +521,24 @@ const bottleneckCommand = {
521
521
  ],
522
522
  examples: [
523
523
  { command: 'monomind performance bottleneck', description: 'Find bottlenecks' },
524
- { command: 'monomind performance bottleneck -d full', description: 'Full analysis' },
524
+ { command: 'monomind performance bottleneck -c network', description: 'Only the Network component' },
525
525
  ],
526
526
  action: async (ctx) => {
527
527
  output.writeln();
528
528
  output.writeln(output.bold('Bottleneck Analysis'));
529
529
  output.writeln(output.dim('─'.repeat(50)));
530
+ // Components this command actually knows how to inspect. Used both to
531
+ // validate --component and to tell the user what they could have asked for.
532
+ const ANALYZED_COMPONENTS = ['Runtime', 'Vector Search', 'Traces', 'Memory DB', 'Network'];
533
+ // Only a single depth of analysis is implemented. Accepting `--depth full`
534
+ // and quietly running the quick analysis discards the user's request with
535
+ // no indication it was ignored, so say so instead.
536
+ const depthRaw = ctx.flags.depth || 'quick';
537
+ if (depthRaw !== 'quick') {
538
+ output.writeln(output.warning(`--depth ${depthRaw} is not implemented — running the "quick" analysis. ` +
539
+ `Only "quick" is supported today.`));
540
+ }
541
+ const componentFilter = ctx.flags.component?.trim();
530
542
  const spinner = output.createSpinner({ text: 'Analyzing system...', spinner: 'dots' });
531
543
  spinner.start();
532
544
  const fs = await import('node:fs');
@@ -575,10 +587,24 @@ const bottleneckCommand = {
575
587
  if (!network.batching) {
576
588
  findings.push({ component: 'Network', bottleneck: 'No request batching', severity: output.info('Low'), solution: 'Run: monomind performance optimize --apply -t latency' });
577
589
  }
578
- if (findings.length === 0) {
579
- findings.push({ component: 'System', bottleneck: 'No bottlenecks detected', severity: output.success('None'), solution: 'System is performing well' });
590
+ let results = findings;
591
+ if (componentFilter) {
592
+ const known = ANALYZED_COMPONENTS.find(c => c.toLowerCase() === componentFilter.toLowerCase())
593
+ ?? ANALYZED_COMPONENTS.find(c => c.toLowerCase().includes(componentFilter.toLowerCase()));
594
+ if (!known) {
595
+ spinner.fail(`Unknown component "${componentFilter}"`);
596
+ output.writeln(output.warning(`Analyzed components: ${ANALYZED_COMPONENTS.join(', ')}.`));
597
+ return { success: false, message: `Unknown component: ${componentFilter}`, exitCode: 1 };
598
+ }
599
+ results = findings.filter(f => f.component === known);
600
+ if (results.length === 0) {
601
+ results = [{ component: known, bottleneck: 'No bottlenecks detected', severity: output.success('None'), solution: `${known} is performing well` }];
602
+ }
603
+ }
604
+ else if (results.length === 0) {
605
+ results = [{ component: 'System', bottleneck: 'No bottlenecks detected', severity: output.success('None'), solution: 'System is performing well' }];
580
606
  }
581
- spinner.succeed(`Analysis complete — ${findings.length} finding(s)`);
607
+ spinner.succeed(`Analysis complete — ${results.length} finding(s)`);
582
608
  output.writeln();
583
609
  output.printTable({
584
610
  columns: [
@@ -587,7 +613,7 @@ const bottleneckCommand = {
587
613
  { key: 'severity', header: 'Severity', width: 12 },
588
614
  { key: 'solution', header: 'Solution', width: 50 },
589
615
  ],
590
- data: findings,
616
+ data: results,
591
617
  });
592
618
  return { success: true };
593
619
  },
@@ -11,10 +11,11 @@ export const auditCommand = {
11
11
  options: [
12
12
  { name: 'action', short: 'a', type: 'string', description: 'Action: list (only supported value — log/export/clear are not implemented)', default: 'list' },
13
13
  { name: 'limit', short: 'l', type: 'number', description: 'Number of entries to show', default: '20' },
14
- { name: 'filter', short: 'f', type: 'string', description: 'Filter by event type' },
14
+ { name: 'filter', short: 'f', type: 'string', description: 'Filter by event type (substring, case-insensitive)' },
15
15
  ],
16
16
  examples: [
17
17
  { command: 'monomind security audit --action list', description: 'List audit logs' },
18
+ { command: 'monomind security audit --filter SWARM', description: 'Only swarm activity events' },
18
19
  ],
19
20
  action: async (ctx) => {
20
21
  const requestedAction = ctx.flags.action || 'list';
@@ -54,7 +55,21 @@ export const auditCommand = {
54
55
  const now = new Date().toISOString().replace('T', ' ').substring(0, 19);
55
56
  auditEntries.push({ timestamp: now, event: 'AUDIT_RUN', user: 'cli', status: output.success('Success') });
56
57
  auditEntries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
57
- if (auditEntries.length === 0) {
58
+ // --filter was previously parsed and discarded, so every invocation
59
+ // returned the full log regardless of what the user asked for.
60
+ const eventFilter = ctx.flags.filter?.trim();
61
+ let visibleEntries = auditEntries;
62
+ if (eventFilter) {
63
+ const needle = eventFilter.toLowerCase();
64
+ visibleEntries = auditEntries.filter(e => e.event.toLowerCase().includes(needle));
65
+ if (visibleEntries.length === 0) {
66
+ const seen = [...new Set(auditEntries.map(e => e.event))].sort();
67
+ output.writeln(output.warning(`No audit events match "${eventFilter}".`));
68
+ output.writeln(output.dim(`Event types present: ${seen.join(', ') || 'none'}`));
69
+ return { success: true, data: { entries: [], filter: eventFilter } };
70
+ }
71
+ }
72
+ if (visibleEntries.length === 0) {
58
73
  output.writeln(output.dim('No audit events found. Initialize a project first: monomind init'));
59
74
  }
60
75
  else {
@@ -65,7 +80,7 @@ export const auditCommand = {
65
80
  { key: 'user', header: 'User', width: 15 },
66
81
  { key: 'status', header: 'Status', width: 12 },
67
82
  ],
68
- data: auditEntries.slice(0, parseInt(ctx.flags.limit || '20', 10)),
83
+ data: visibleEntries.slice(0, parseInt(ctx.flags.limit || '20', 10)),
69
84
  });
70
85
  }
71
86
  return { success: true };
@@ -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;
@@ -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' | 'embedding-failed' | 'no-semantic-matches';
67
71
  error?: string;
68
72
  } | null>;
69
73
  export declare function bridgeListEntries(options: {
@@ -406,7 +406,11 @@ export async function bridgeSearchEntries(options) {
406
406
  const startTime = Date.now();
407
407
  let results = [];
408
408
  let searchMethod = 'keyword';
409
+ // Reported to callers so "(semantic)" can never be printed over keyword hits.
410
+ let fallbackReason = _embedder && queryStr.length > 0 ? undefined : 'no-embedding-model';
411
+ let semanticAttempted = false;
409
412
  if (_embedder && queryStr.length > 0) {
413
+ semanticAttempted = true;
410
414
  try {
411
415
  const queryEmbedding = await _embedder(queryStr);
412
416
  const searchResults = await backend.search(queryEmbedding, {
@@ -431,8 +435,14 @@ export async function bridgeSearchEntries(options) {
431
435
  };
432
436
  }).sort((a, b) => b.score - a.score);
433
437
  searchMethod = 'semantic';
438
+ fallbackReason = undefined;
439
+ }
440
+ catch (e) {
441
+ // fall through to keyword search — but never claim this was semantic
442
+ fallbackReason = 'embedding-failed';
443
+ if (process.env.DEBUG || process.env.MONOMIND_DEBUG)
444
+ console.error('[memory-bridge] semantic search failed — falling back to keyword matching:', e);
434
445
  }
435
- catch { /* fall through to keyword search */ }
436
446
  }
437
447
  // Keyword fallback — scan all entries in namespace (not just first 100)
438
448
  // to avoid missing documents that were ingested later in the batch.
@@ -463,14 +473,22 @@ export async function bridgeSearchEntries(options) {
463
473
  id: e.id,
464
474
  key: e.key,
465
475
  content: e.content || '',
466
- score: Math.min(0.9, 0.3 + score * 0.6),
476
+ // Raw token-overlap fraction, NOT rescaled to look like a cosine.
477
+ // The old `min(0.9, 0.3 + score*0.6)` floor/ceiling made a weak
478
+ // keyword hit outrank a genuine cosine match (0.90 vs 0.63 for the
479
+ // same entry) and fed cosine-calibrated gates (memory-kg dedup)
480
+ // scores that never came from a vector.
481
+ score,
467
482
  namespace: e.namespace,
468
483
  provenance: `keyword:${score.toFixed(2)}`,
469
484
  tags: e.tags ?? [],
470
485
  _createdAt: e.createdAt || 0,
471
486
  }));
472
487
  }
473
- searchMethod = 'keyword';
488
+ // The vector path ran and simply matched nothing — still not semantic.
489
+ searchMethod = semanticAttempted ? 'keyword-fallback' : 'keyword';
490
+ if (semanticAttempted && !fallbackReason)
491
+ fallbackReason = 'no-semantic-matches';
474
492
  }
475
493
  // Filter stale entries based on automem config — skip for knowledge
476
494
  // namespaces (documents should remain searchable indefinitely)
@@ -494,6 +512,7 @@ export async function bridgeSearchEntries(options) {
494
512
  results,
495
513
  searchTime: Date.now() - startTime,
496
514
  searchMethod,
515
+ ...(searchMethod === 'semantic' ? {} : { fallbackReason }),
497
516
  };
498
517
  }
499
518
  catch {
@@ -25,6 +25,10 @@ export declare function searchEntries(options: {
25
25
  namespace: string;
26
26
  }[];
27
27
  searchTime: number;
28
+ /** What actually ran — propagated from the bridge so callers can report the
29
+ * real method instead of echoing the requested one. */
30
+ searchMethod?: 'semantic' | 'keyword' | 'keyword-fallback' | 'hybrid';
31
+ fallbackReason?: string;
28
32
  error?: string;
29
33
  }>;
30
34
  /**
@@ -66,7 +66,8 @@ export async function searchEntries(options) {
66
66
  return {
67
67
  success: true,
68
68
  results: filtered,
69
- searchTime: Date.now() - startTime
69
+ searchTime: Date.now() - startTime,
70
+ searchMethod: 'semantic'
70
71
  };
71
72
  }
72
73
  // Fall back to brute-force SQLite search
@@ -127,7 +128,9 @@ export async function searchEntries(options) {
127
128
  return {
128
129
  success: true,
129
130
  results: results.slice(0, limit),
130
- searchTime: Date.now() - startTime
131
+ searchTime: Date.now() - startTime,
132
+ // Per-row: cosine when the entry had a vector, keyword overlap otherwise.
133
+ searchMethod: 'hybrid'
131
134
  };
132
135
  }
133
136
  catch (error) {
@@ -1,3 +1,18 @@
1
+ /**
2
+ * Pick the argv prefix used to spawn the embedding worker.
3
+ *
4
+ * dist/: `embed-worker.js` exists → `node embed-worker.js <task>`, byte-identical
5
+ * to the original behaviour, no extra flags, no extra resolution.
6
+ *
7
+ * src/ (tsx / vitest): only `embed-worker.ts` exists. Node's
8
+ * `--experimental-strip-types` cannot run it — the worker imports
9
+ * `./embedder.js`, and type-stripping does no `.js`→`.ts` extension rewriting,
10
+ * so it dies with ERR_MODULE_NOT_FOUND. tsx does rewrite, so the source path
11
+ * runs through tsx's CLI (a devDependency, never needed by dist/).
12
+ *
13
+ * Exported for tests.
14
+ */
15
+ export declare function resolveWorkerArgv(): string[];
1
16
  type RouteResult = any;
2
17
  export interface ConfiguredRouteLayer {
3
18
  route: (taskDescription: string) => Promise<RouteResult>;
@@ -18,9 +18,44 @@
18
18
  // loading onnxruntime in-process deterministically SIGSEGVs. The model lives
19
19
  // ONLY in embed-worker.ts, reached via the spawn below.
20
20
  import { spawn } from 'child_process';
21
+ import { existsSync } from 'fs';
22
+ import { createRequire } from 'module';
21
23
  import { fileURLToPath } from 'url';
22
24
  import { dirname, join } from 'path';
23
- const WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), 'embed-worker.js');
25
+ const WORKER_DIR = dirname(fileURLToPath(import.meta.url));
26
+ /** Compiled worker — the only path production (dist/) ever takes. */
27
+ const COMPILED_WORKER_PATH = join(WORKER_DIR, 'embed-worker.js');
28
+ /** TypeScript sibling — only present when running from src/ (tsx, vitest). */
29
+ const SOURCE_WORKER_PATH = join(WORKER_DIR, 'embed-worker.ts');
30
+ /**
31
+ * Pick the argv prefix used to spawn the embedding worker.
32
+ *
33
+ * dist/: `embed-worker.js` exists → `node embed-worker.js <task>`, byte-identical
34
+ * to the original behaviour, no extra flags, no extra resolution.
35
+ *
36
+ * src/ (tsx / vitest): only `embed-worker.ts` exists. Node's
37
+ * `--experimental-strip-types` cannot run it — the worker imports
38
+ * `./embedder.js`, and type-stripping does no `.js`→`.ts` extension rewriting,
39
+ * so it dies with ERR_MODULE_NOT_FOUND. tsx does rewrite, so the source path
40
+ * runs through tsx's CLI (a devDependency, never needed by dist/).
41
+ *
42
+ * Exported for tests.
43
+ */
44
+ export function resolveWorkerArgv() {
45
+ if (existsSync(COMPILED_WORKER_PATH))
46
+ return [COMPILED_WORKER_PATH];
47
+ if (existsSync(SOURCE_WORKER_PATH)) {
48
+ let tsxCli;
49
+ try {
50
+ tsxCli = createRequire(import.meta.url).resolve('tsx/cli');
51
+ }
52
+ catch {
53
+ throw new Error('embed worker: running from source but tsx is not installed (cannot execute embed-worker.ts)');
54
+ }
55
+ return [tsxCli, SOURCE_WORKER_PATH];
56
+ }
57
+ throw new Error(`embed worker not found at ${COMPILED_WORKER_PATH}`);
58
+ }
24
59
  /** Generous: the first-ever run computes + caches ~500 utterance embeddings. */
25
60
  const WORKER_TIMEOUT_MS = 90_000;
26
61
  /** Cap worker stdout so a runaway child can't grow parent memory unbounded. */
@@ -37,8 +72,15 @@ const RESULT_MARKER = '__ROUTE_RESULT__';
37
72
  function runWorker(task) {
38
73
  // Cap task length before passing as argv to prevent OOM/DoS via oversized args.
39
74
  const safeTask = task.length > MAX_TASK_LENGTH ? task.slice(0, MAX_TASK_LENGTH) : task;
75
+ let workerArgv;
76
+ try {
77
+ workerArgv = resolveWorkerArgv();
78
+ }
79
+ catch (err) {
80
+ return Promise.reject(err instanceof Error ? err : new Error(String(err)));
81
+ }
40
82
  return new Promise((resolve, reject) => {
41
- const child = spawn(process.execPath, [WORKER_PATH, safeTask], {
83
+ const child = spawn(process.execPath, [...workerArgv, safeTask], {
42
84
  stdio: ['ignore', 'pipe', 'pipe'],
43
85
  windowsHide: true,
44
86
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monoes/monomindcli",
3
- "version": "2.7.6",
3
+ "version": "2.7.7",
4
4
  "type": "module",
5
5
  "description": "CLI engine for Monomind \u2014 an open-source MCP server that extends Claude Code with a codebase knowledge graph (tree-sitter + SQLite), persistent memory, multi-agent task coordination, and session hooks. MIT licensed, fully local.",
6
6
  "main": "dist/src/index.js",
@@ -92,13 +92,14 @@
92
92
  "publish:all": "./scripts/publish.sh"
93
93
  },
94
94
  "devDependencies": {
95
+ "tsx": "^4.21.0",
95
96
  "typescript": "^7.0.2",
96
97
  "vitest": "^4.1.4"
97
98
  },
98
99
  "dependencies": {
99
100
  "@anthropic-ai/claude-agent-sdk": "^0.3.207",
100
101
  "@monoes/monobrowse": "^1.0.6",
101
- "@monoes/monodesign": "^1.2.0",
102
+ "@monoes/monodesign": "^1.2.1",
102
103
  "@monoes/monograph": "^1.5.4",
103
104
  "@noble/ed25519": "^2.1.0",
104
105
  "mammoth": "^1.12.0",
@@ -110,9 +111,9 @@
110
111
  "optionalDependencies": {
111
112
  "@huggingface/transformers": "^3.8.1",
112
113
  "@monoes/hooks": "^1.0.0",
113
- "@monoes/mcp": "^1.0.0",
114
+ "@monoes/mcp": "^1.0.1",
114
115
  "@monoes/memory": "^1.0.10",
115
- "@monoes/routing": "^1.0.0",
116
+ "@monoes/routing": "^1.0.1",
116
117
  "monofence-ai": "*",
117
118
  "sql.js": "^1.14.1"
118
119
  },