monomind 2.1.6 → 2.1.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "monomind",
3
- "version": "2.1.6",
3
+ "version": "2.1.8",
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",
@@ -343,22 +343,38 @@ async function handleSubagentStop(hookInput) {
343
343
  const currentFiles = snapshotJSONLFiles();
344
344
  const prevNames = new Set((snap.files || []).map(f => f.name));
345
345
 
346
+ // Prefer reading THIS subagent's own transcript directly when known —
347
+ // transcript_path is unique per-subagent (see subagentKey() above). The
348
+ // "every file that's new in the directory since snapshot" fallback below
349
+ // is not actually scoped to this subagent: under real concurrency (this
350
+ // project's default hierarchical-swarm topology spawns several subagents
351
+ // together), a sibling subagent's transcript file created between THIS
352
+ // subagent's start-snapshot and its own stop event also looks "new" and
353
+ // would get folded into this subagent's totals/summary/lastToolError —
354
+ // misattributing a sibling's success/failure to this one. Falls back to
355
+ // the directory-wide diff only when transcript_path is unavailable
356
+ // (older Claude Code payload shape), matching this file's existing
357
+ // fallback philosophy for snapshot matching above.
358
+ const _ownTranscriptRaw = hookInput && (hookInput.transcript_path || hookInput.transcriptPath);
359
+ const _ownTranscriptName = _ownTranscriptRaw ? path.basename(String(_ownTranscriptRaw)) : null;
360
+ const filesToProcess = _ownTranscriptName
361
+ ? currentFiles.filter(f => f.name === _ownTranscriptName)
362
+ : currentFiles.filter(f => !prevNames.has(f.name));
363
+
346
364
  let totalTin = 0, totalTout = 0;
347
365
  let summary = '';
348
366
  let toolCalls = [];
349
367
  let lastToolError = false;
350
368
  const capturedFiles = [];
351
369
 
352
- for (const f of currentFiles) {
353
- if (!prevNames.has(f.name)) {
354
- const parsed = parseJSONLForData(path.join(claudeDir, f.name));
355
- totalTin += parsed.tokens_in;
356
- totalTout += parsed.tokens_out;
357
- if (parsed.summary) summary = parsed.summary;
358
- toolCalls.push(...parsed.toolCalls);
359
- lastToolError = parsed.lastToolError; // last new file wins, mirroring `summary`'s last-wins semantics
360
- capturedFiles.push(f.name);
361
- }
370
+ for (const f of filesToProcess) {
371
+ const parsed = parseJSONLForData(path.join(claudeDir, f.name));
372
+ totalTin += parsed.tokens_in;
373
+ totalTout += parsed.tokens_out;
374
+ if (parsed.summary) summary = parsed.summary;
375
+ toolCalls.push(...parsed.toolCalls);
376
+ lastToolError = parsed.lastToolError; // last (only, when scoped) file wins
377
+ capturedFiles.push(f.name);
362
378
  }
363
379
 
364
380
  const costUsd = parseFloat((totalTin * 3e-6 + totalTout * 15e-6).toFixed(6));
@@ -53,7 +53,26 @@ module.exports = {
53
53
  // any diff vs. the npm global install is expected (and would self-clobber
54
54
  // in-progress edits).
55
55
  try {
56
- var _isDevRepo = fs.existsSync(path.join(CWD, 'packages', '@monomind', 'cli', 'package.json'));
56
+ // Walk up from CWD, not just check CWD directly — Claude Code can be opened
57
+ // with CWD set to any subdirectory of the monorepo (e.g. packages/@monomind/cli
58
+ // itself, or a nested package), where a bare CWD-only check would miss the
59
+ // monorepo root and wrongly treat the dev repo as a regular consumer project,
60
+ // letting the heal logic below silently overwrite the actual dev-repo SOURCE
61
+ // helpers (packages/@monomind/cli/.claude/helpers/*) with a stale published
62
+ // version pulled from node_modules/global npm.
63
+ var _isDevRepo = (function() {
64
+ var dir = CWD;
65
+ for (var _d = 0; _d < 6; _d++) {
66
+ if (fs.existsSync(path.join(dir, 'packages', '@monomind', 'cli', 'package.json')) &&
67
+ fs.existsSync(path.join(dir, 'packages', '@monomind', 'cli', '.claude', 'helpers'))) {
68
+ return true;
69
+ }
70
+ var _parent = path.dirname(dir);
71
+ if (_parent === dir) break;
72
+ dir = _parent;
73
+ }
74
+ return false;
75
+ })();
57
76
  if (!_isDevRepo) {
58
77
  var crypto = require('crypto');
59
78
  function _findBundledHelpers() {
@@ -136,6 +155,54 @@ module.exports = {
136
155
  console.log('[STALE_HELPERS] Refreshed ' + healed.length + ' helper(s) from bundled version: ' + healed.join(', '));
137
156
  }
138
157
  }
158
+
159
+ // Fallback for pure `npx monomind@latest ...` usage — the local scan above
160
+ // only finds a bundled copy via node_modules or a global npm install, but a
161
+ // per-invocation `npx` run leaves no reliably-discoverable copy there (each
162
+ // invocation caches under a hashed, non-predictable ~/.npm/_npx/<hash>/ dir
163
+ // that this process has no correct way to pick the newest of without risking
164
+ // healing "backward" to some older cached version). `npx monomind@latest`
165
+ // itself already resolves this correctly (it always fetches/uses latest),
166
+ // and `doctor --fix` reuses the exact same bundled-package resolution that
167
+ // `init upgrade` uses — so shell out to it instead of re-solving the same
168
+ // problem here. Rate-limited to once per 6h (mirrors the metrics-worker
169
+ // staleness gate below) and fully non-blocking: spawned detached+unref with
170
+ // stdio ignored, session start never waits on it, and a fresh session or
171
+ // hook a few seconds later just picks up whatever it left behind.
172
+ try {
173
+ var _healCheckPath = path.join(CWD, '.monomind', 'helpers-heal-check.json');
174
+ var _lastHealCheck = 0;
175
+ try { _lastHealCheck = JSON.parse(fs.readFileSync(_healCheckPath, 'utf-8')).ts || 0; } catch (_) {}
176
+ var HEAL_CHECK_STALE_MS = 6 * 60 * 60 * 1000; // 6 hours
177
+ if (Date.now() - _lastHealCheck > HEAL_CHECK_STALE_MS) {
178
+ // Write the rate-limit marker BEFORE spawning, and only spawn if the
179
+ // write actually succeeded (fail closed) — otherwise a persistently
180
+ // unwritable marker (disk quota, permissions) would make this branch
181
+ // re-fire on every single session-restore instead of once per 6h,
182
+ // since `Date.now() - 0 > HEAL_CHECK_STALE_MS` stays true forever.
183
+ var _markerWritten = false;
184
+ try {
185
+ fs.mkdirSync(path.dirname(_healCheckPath), { recursive: true });
186
+ fs.writeFileSync(_healCheckPath, JSON.stringify({ ts: Date.now() }), 'utf-8');
187
+ _markerWritten = true;
188
+ } catch (_) {}
189
+ if (_markerWritten) {
190
+ var _spawn = require('child_process').spawn;
191
+ // shell:true is required on Windows to invoke npx.cmd via spawn()
192
+ // without an explicit .cmd extension; harmless on macOS/Linux.
193
+ var _child = _spawn('npx', ['-y', 'monomind@latest', 'doctor', '--fix', '--component', 'helpers'], {
194
+ cwd: CWD,
195
+ detached: true,
196
+ stdio: 'ignore',
197
+ env: process.env,
198
+ shell: process.platform === 'win32',
199
+ windowsHide: true,
200
+ });
201
+ _child.on('error', function() {}); // offline / npx unavailable — silently skip
202
+ _child.unref();
203
+ }
204
+ }
205
+ } catch (e) { /* non-fatal — background self-heal is best-effort */ }
139
206
  }
140
207
  } catch (e) { /* non-fatal */ }
141
208
 
@@ -2,12 +2,13 @@
2
2
  * Doctor — project/monomind health checks
3
3
  * Config, memory, API keys, MCP, monograph, helpers, routing, gates, gitignore, worker metrics
4
4
  */
5
- import { existsSync, readFileSync, statSync, mkdirSync, copyFileSync } from 'fs';
5
+ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, copyFileSync } from 'fs';
6
6
  import { join, dirname } from 'path';
7
7
  import { fileURLToPath } from 'url';
8
8
  import { execSync } from 'child_process';
9
9
  import { homedir } from 'os';
10
10
  import { MAX_DOCTOR_PKG_BYTES, MAX_DOCTOR_CONFIG_BYTES, MAX_DOCTOR_GITIGNORE_BYTES, MAX_DOCTOR_HELPER_BYTES, } from './doctor-env-checks.js';
11
+ import { DOCTOR_TRACKED_HELPERS } from '../init/helpers-generator.js';
11
12
  export async function checkConfigFile() {
12
13
  const jsonPaths = ['.monomind/config.json', 'monomind.config.json', '.monomind.json'];
13
14
  for (const configPath of jsonPaths) {
@@ -234,21 +235,50 @@ function _resolveBundledHelper(relativePath) {
234
235
  return null;
235
236
  }
236
237
  }
238
+ // Top-level critical helpers, plus every file bundled under handlers/ and utils/
239
+ // (capture-handler.cjs, gates-handler.cjs, route-handler.cjs, session-handler.cjs,
240
+ // etc. — where most actual hook-behavior fixes live). Subdir membership is
241
+ // discovered from the bundled package itself, not hardcoded, so a helper added
242
+ // upstream is picked up automatically instead of silently never syncing.
243
+ function _allTrackedHelperNames() {
244
+ const names = [...DOCTOR_TRACKED_HELPERS];
245
+ for (const sub of ['handlers', 'utils']) {
246
+ const bundledSubDir = _resolveBundledHelper(join('.claude', 'helpers', sub));
247
+ if (!bundledSubDir)
248
+ continue;
249
+ try {
250
+ for (const f of readdirSync(bundledSubDir)) {
251
+ if (f.startsWith('._'))
252
+ continue;
253
+ if (statSync(join(bundledSubDir, f)).isDirectory())
254
+ continue;
255
+ names.push(join(sub, f));
256
+ }
257
+ }
258
+ catch { /* skip */ }
259
+ }
260
+ return names;
261
+ }
237
262
  async function _detectStaleHelpers() {
238
263
  const stale = [];
239
264
  const missing = [];
240
- const helpers = ['hook-handler.cjs', 'statusline.cjs', 'router.cjs', 'graphify-freshen.cjs'];
241
265
  const crypto = await import('node:crypto');
242
- for (const name of helpers) {
243
- const local = join(process.cwd(), '.claude', 'helpers', name);
244
- if (!existsSync(local) || statSync(local).size > MAX_DOCTOR_HELPER_BYTES)
245
- continue;
266
+ for (const name of _allTrackedHelperNames()) {
246
267
  const bundled = _resolveBundledHelper(join('.claude', 'helpers', name));
247
- if (!bundled) {
268
+ // Oversized-bundled is reported the same as missing-bundled: doctor can't
269
+ // verify freshness either way, and silently excluding it from both `stale`
270
+ // and `missing` would let checkHelpersFresh() report "pass" without ever
271
+ // having actually compared this file.
272
+ if (!bundled || statSync(bundled).size > MAX_DOCTOR_HELPER_BYTES) {
248
273
  missing.push(name);
249
274
  continue;
250
275
  }
251
- if (statSync(bundled).size > MAX_DOCTOR_HELPER_BYTES)
276
+ const local = join(process.cwd(), '.claude', 'helpers', name);
277
+ if (!existsSync(local)) {
278
+ stale.push(name);
279
+ continue;
280
+ } // bundled has it, project doesn't — needs creating
281
+ if (statSync(local).size > MAX_DOCTOR_HELPER_BYTES)
252
282
  continue;
253
283
  try {
254
284
  const hashLocal = crypto.createHash('sha256').update(readFileSync(local)).digest('hex');
@@ -268,6 +298,7 @@ export async function fixStaleHelpers() {
268
298
  const bundled = _resolveBundledHelper(join('.claude', 'helpers', name));
269
299
  if (bundled) {
270
300
  try {
301
+ mkdirSync(dirname(local), { recursive: true });
271
302
  copyFileSync(bundled, local);
272
303
  fixed++;
273
304
  }
@@ -19,7 +19,7 @@ export const doctorCommand = {
19
19
  name: 'doctor',
20
20
  description: 'System diagnostics and health checks',
21
21
  options: [
22
- { name: 'fix', short: 'f', description: 'Show fix commands for issues', type: 'boolean', default: false },
22
+ { name: 'fix', short: 'f', description: 'Apply local fixes (helper files, monoes tool shims) and show fix commands for the rest', type: 'boolean', default: false },
23
23
  { name: 'install', short: 'i', description: 'Auto-install missing dependencies (Claude Code CLI)', type: 'boolean', default: false },
24
24
  {
25
25
  name: 'component', short: 'c',
@@ -122,9 +122,16 @@ export const doctorCommand = {
122
122
  spinner.stop();
123
123
  output.writeln(output.error('Failed to run health checks'));
124
124
  }
125
- if (autoInstall) {
125
+ // `--fix` applies the lightweight, local, no-network fixes (helper files,
126
+ // monoes CLI tool shims) — its description says "show fix commands" but
127
+ // silently doing nothing for these two beyond printing a hint is a worse
128
+ // outcome than just fixing them, and copying a bundled file locally is
129
+ // nothing like installing a package. `--install` additionally covers the
130
+ // Claude Code CLI, which is a real install (network fetch + binary setup)
131
+ // — kept opt-in separately so `--fix` alone never triggers that.
132
+ if (autoInstall || showFix) {
126
133
  const claudeResult = results.find(r => r.name === 'Claude Code CLI');
127
- if (claudeResult && claudeResult.status !== 'pass') {
134
+ if (autoInstall && claudeResult && claudeResult.status !== 'pass') {
128
135
  if (await installClaudeCode()) {
129
136
  const newCheck = await checkClaudeCode();
130
137
  const idx = results.findIndex(r => r.name === 'Claude Code CLI');
@@ -52,7 +52,7 @@ import { writeSharedInstructions } from './shared-instructions-generator.js';
52
52
  import { generateSettingsJson, generateSettings } from './settings-generator.js';
53
53
  import { generateMCPJson } from './mcp-generator.js';
54
54
  import { generateStatuslineScript } from './statusline-generator.js';
55
- import { generatePreCommitHook, generatePostCommitHook, generateSessionManager, generateAgentRouter, generateMemoryHelper, generateHookHandler, generateIntelligenceStub, generateAutoMemoryHook, } from './helpers-generator.js';
55
+ import { FORCE_SYNC_HELPERS, FORCE_SYNC_GENERATORS, INIT_FALLBACK_HELPERS, } from './helpers-generator.js';
56
56
  import { generateClaudeMd } from './claudemd-generator.js';
57
57
  /**
58
58
  * Skills to copy based on configuration
@@ -573,15 +573,13 @@ export async function executeUpgrade(targetDir, upgradeSettings = false) {
573
573
  const sourceHelpersForUpgrade = findSourceHelpersDir();
574
574
  if (sourceHelpersForUpgrade) {
575
575
  const destHelpersDir = path.join(targetDir, '.claude', 'helpers');
576
- // Copy top-level critical files atomically
577
- const criticalHelpers = ['auto-memory-hook.mjs', 'hook-handler.cjs', 'intelligence.cjs', 'statusline.cjs', 'graphify-freshen.cjs', 'router.cjs'];
576
+ // Copy top-level critical files atomically. Membership and fallback
577
+ // generators come from the shared HELPER_FILES registry (helpers-generator.ts)
578
+ // rather than a hardcoded list here — see that file's comment for why.
579
+ const criticalHelpers = FORCE_SYNC_HELPERS;
578
580
  // Generated fallback for any critical helper missing from the source dir itself
579
581
  // (e.g. the published npm template lacking auto-memory-hook.mjs).
580
- const criticalGenerators = {
581
- 'hook-handler.cjs': generateHookHandler,
582
- 'intelligence.cjs': generateIntelligenceStub,
583
- 'auto-memory-hook.mjs': generateAutoMemoryHook,
584
- };
582
+ const criticalGenerators = FORCE_SYNC_GENERATORS;
585
583
  for (const helperName of criticalHelpers) {
586
584
  const targetPath = path.join(destHelpersDir, helperName);
587
585
  const sourcePath = path.join(sourceHelpersForUpgrade, helperName);
@@ -626,12 +624,8 @@ export async function executeUpgrade(targetDir, upgradeSettings = false) {
626
624
  }
627
625
  else {
628
626
  // Source not found (npx with broken paths) — use generated fallbacks
629
- const generatedCritical = {
630
- 'hook-handler.cjs': generateHookHandler(),
631
- 'intelligence.cjs': generateIntelligenceStub(),
632
- 'auto-memory-hook.mjs': generateAutoMemoryHook(),
633
- 'router.cjs': generateAgentRouter(),
634
- };
627
+ // for every force-synced helper that has one (see HELPER_FILES registry).
628
+ const generatedCritical = Object.fromEntries(Object.entries(FORCE_SYNC_GENERATORS).map(([name, generate]) => [name, generate()]));
635
629
  for (const [helperName, content] of Object.entries(generatedCritical)) {
636
630
  const targetPath = path.join(targetDir, '.claude', 'helpers', helperName);
637
631
  if (fs.existsSync(targetPath)) {
@@ -1274,16 +1268,7 @@ async function writeHelpers(targetDir, options, result) {
1274
1268
  // Without this, a source dir that's present but incomplete (e.g. missing
1275
1269
  // auto-memory-hook.mjs) silently ships a project wired to hooks that reference
1276
1270
  // a file that was never installed.
1277
- const helpers = {
1278
- 'pre-commit': generatePreCommitHook(),
1279
- 'post-commit': generatePostCommitHook(),
1280
- 'session.cjs': generateSessionManager(),
1281
- 'router.cjs': generateAgentRouter(),
1282
- 'memory.cjs': generateMemoryHelper(),
1283
- 'hook-handler.cjs': generateHookHandler(),
1284
- 'intelligence.cjs': generateIntelligenceStub(),
1285
- 'auto-memory-hook.mjs': generateAutoMemoryHook(),
1286
- };
1271
+ const helpers = Object.fromEntries(Object.entries(INIT_FALLBACK_HELPERS).map(([name, generate]) => [name, generate()]));
1287
1272
  for (const [name, content] of Object.entries(helpers)) {
1288
1273
  const filePath = path.join(helpersDir, name);
1289
1274
  // If the source dir has this file, copyRecursive above already applied
@@ -53,6 +53,19 @@ export declare function generateWindowsBatchWrapper(): string;
53
53
  * Generate cross-platform session manager
54
54
  */
55
55
  export declare function generateCrossPlatformSessionManager(): string;
56
+ export interface HelperFileSpec {
57
+ /** Force-overwritten from source (or regenerated as fallback) on every `init upgrade` run. */
58
+ forceSync?: boolean;
59
+ /** Checked for staleness/existence by `doctor` (in addition to everything under handlers/ and utils/, discovered dynamically). */
60
+ doctorTracked?: boolean;
61
+ /** Fallback content generator, used when the file can't be copied from source. */
62
+ generate?: () => string;
63
+ }
64
+ export declare const HELPER_FILES: Record<string, HelperFileSpec>;
65
+ export declare const FORCE_SYNC_HELPERS: string[];
66
+ export declare const DOCTOR_TRACKED_HELPERS: string[];
67
+ export declare const FORCE_SYNC_GENERATORS: Record<string, () => string>;
68
+ export declare const INIT_FALLBACK_HELPERS: Record<string, () => string>;
56
69
  /**
57
70
  * Generate all helper files
58
71
  */
@@ -1213,6 +1213,34 @@ if (command && commands[command]) {
1213
1213
  module.exports = commands;
1214
1214
  `;
1215
1215
  }
1216
+ export const HELPER_FILES = {
1217
+ 'hook-handler.cjs': { forceSync: true, doctorTracked: true, generate: generateHookHandler },
1218
+ 'intelligence.cjs': { forceSync: true, generate: generateIntelligenceStub },
1219
+ 'auto-memory-hook.mjs': { forceSync: true, generate: generateAutoMemoryHook },
1220
+ 'statusline.cjs': { forceSync: true, doctorTracked: true },
1221
+ 'graphify-freshen.cjs': { forceSync: true, doctorTracked: true },
1222
+ 'router.cjs': { forceSync: true, doctorTracked: true, generate: generateAgentRouter },
1223
+ 'memory.cjs': { generate: generateMemoryHelper },
1224
+ 'session.cjs': { generate: generateSessionManager },
1225
+ 'pre-commit': { generate: generatePreCommitHook },
1226
+ 'post-commit': { generate: generatePostCommitHook },
1227
+ };
1228
+ export const FORCE_SYNC_HELPERS = Object.keys(HELPER_FILES).filter((name) => HELPER_FILES[name].forceSync);
1229
+ export const DOCTOR_TRACKED_HELPERS = Object.keys(HELPER_FILES).filter((name) => HELPER_FILES[name].doctorTracked);
1230
+ // Fallback generators for force-synced helpers (used when source is missing
1231
+ // the file entirely). Previously duplicated by hand as two separate maps
1232
+ // that disagreed on membership — the "source found but file missing" path
1233
+ // lacked a router.cjs fallback that the "source not found" path had, so a
1234
+ // corrupted/incomplete source copy could silently ship without router.cjs.
1235
+ export const FORCE_SYNC_GENERATORS = Object.fromEntries(Object.entries(HELPER_FILES)
1236
+ .filter(([, spec]) => spec.forceSync && spec.generate)
1237
+ .map(([name, spec]) => [name, spec.generate]));
1238
+ // Broader fallback-if-missing-after-copy step (used during a fresh `init`,
1239
+ // not `init upgrade`) — includes non-force-synced scaffold files too
1240
+ // (pre-commit/post-commit/session/memory).
1241
+ export const INIT_FALLBACK_HELPERS = Object.fromEntries(Object.entries(HELPER_FILES)
1242
+ .filter(([, spec]) => spec.generate)
1243
+ .map(([name, spec]) => [name, spec.generate]));
1216
1244
  /**
1217
1245
  * Generate all helper files
1218
1246
  */
@@ -483,20 +483,22 @@ const monographSurprisesTool = {
483
483
  // ── monograph_suggest ─────────────────────────────────────────────────────────
484
484
  const monographSuggestTool = {
485
485
  name: 'monograph_suggest',
486
- description: 'Get graph-topology-derived questions to explore the codebase. Pass task= to score by task relevance. When MONOGRAPH_EMBEDDINGS=true uses semantic search for task relevance.',
486
+ description: 'Get graph-topology-derived questions to explore the codebase. Pass task= to score by task relevance via BM25/FTS5.',
487
487
  inputSchema: {
488
488
  type: 'object',
489
489
  properties: {
490
490
  task: { type: 'string', description: 'Optional task description for task-relevance scoring' },
491
491
  limit: { type: 'number', description: 'Max questions (default 10)' },
492
- checkStaleness: { type: 'boolean', description: 'Check index staleness first and trigger a background rebuild when the index is behind HEAD. Appends a _staleness annotation to the result. (default false)' },
492
+ checkStaleness: { type: 'boolean', description: 'Check index staleness first and trigger a background rebuild when the index is behind HEAD. Appends a _staleness annotation to the result. (default true — pass false to skip the git check)' },
493
493
  },
494
494
  },
495
495
  handler: async (input) => {
496
496
  // Health-aware mode (formerly monograph_suggest_auto): check staleness and
497
- // trigger a background rebuild if the index is behind HEAD.
497
+ // trigger a background rebuild if the index is behind HEAD. Defaults on
498
+ // (opt-out, not opt-in) — a caller that never checked was exactly how a
499
+ // stale graph kept serving results silently.
498
500
  let stalenessAnnotation = '';
499
- if (input.checkStaleness === true) {
501
+ if (input.checkStaleness !== false) {
500
502
  const repoPath = getProjectCwd();
501
503
  const stalenessResult = await computeCommitsBehind(repoPath);
502
504
  const commitsBehind = stalenessResult?.commitsBehind ?? 0;
@@ -531,29 +533,23 @@ const monographSuggestTool = {
531
533
  const locHint = srcLoc ? ` [${srcLoc}${tgtLoc ? ` → ${tgtLoc}` : ''}]` : '';
532
534
  return `Why does ${r.src} ${r.relation.toLowerCase()} ${r.tgt}? (${r.confidence})${locHint}`;
533
535
  };
534
- // When a task is provided and embeddings are enabled, use semantic search
535
- // to find relevant nodes and surface edge-level questions about them.
536
- if (task && process.env['MONOGRAPH_EMBEDDINGS'] === 'true') {
536
+ // When a task is provided, use BM25/FTS5 (via hybridQuery) to find
537
+ // relevant nodes and restrict the edge-level questions to them. This
538
+ // used to be gated behind MONOGRAPH_EMBEDDINGS=true, but hybridQuery
539
+ // is BM25-only now (the embeddings table stayed empty in practice —
540
+ // see hybrid-query.ts) so the env var gated nothing and just left this
541
+ // better-ranked path off unless a caller happened to know about it.
542
+ let hitIds = [];
543
+ if (task) {
537
544
  const hits = await hybridQuery(db, task, { limit: 20 });
538
- const hitIds = new Set(hits.map(h => h.id));
539
- if (hitIds.size === 0) {
545
+ hitIds = [...new Set(hits.map(h => h.id))];
546
+ if (hitIds.length === 0) {
540
547
  return text('No suggestions for this task. Run monograph_build first or try a different query.' + stalenessAnnotation);
541
548
  }
542
- const rows = db.prepare(`
543
- SELECT e.relation, e.confidence, n1.name as src, n2.name as tgt,
544
- n1.file_path as src_file, n1.start_line as src_line,
545
- n2.file_path as tgt_file, n2.start_line as tgt_line
546
- FROM edges e
547
- JOIN nodes n1 ON n1.id = e.source_id
548
- JOIN nodes n2 ON n2.id = e.target_id
549
- WHERE (e.source_id IN (${[...hitIds].map(() => '?').join(',')})
550
- OR e.target_id IN (${[...hitIds].map(() => '?').join(',')}))
551
- AND e.confidence IN ('AMBIGUOUS', 'INFERRED')
552
- LIMIT 100
553
- `).all(...[...hitIds], ...[...hitIds]);
554
- const questions = rows.map(formatSuggestion);
555
- return text((questions.slice(0, limit).join('\n') || 'No suggestions for this task. Run monograph_build first.') + stalenessAnnotation);
556
549
  }
550
+ const taskFilter = hitIds.length
551
+ ? `AND (e.source_id IN (${hitIds.map(() => '?').join(',')}) OR e.target_id IN (${hitIds.map(() => '?').join(',')}))`
552
+ : '';
557
553
  const rows = db.prepare(`
558
554
  SELECT e.relation, e.confidence, n1.name as src, n2.name as tgt,
559
555
  n1.file_path as src_file, n1.start_line as src_line,
@@ -562,26 +558,18 @@ const monographSuggestTool = {
562
558
  JOIN nodes n1 ON n1.id = e.source_id
563
559
  JOIN nodes n2 ON n2.id = e.target_id
564
560
  WHERE e.confidence IN ('AMBIGUOUS', 'INFERRED')
561
+ ${taskFilter}
565
562
  LIMIT 100
566
- `).all();
567
- let scored = rows.map(r => ({
568
- q: formatSuggestion(r),
569
- relevance: task ? taskRelevance(task, r.src + ' ' + r.tgt + ' ' + (r.src_file ?? '')) : 0,
570
- }));
571
- if (task)
572
- scored = scored.sort((a, b) => b.relevance - a.relevance);
573
- return text((scored.slice(0, limit).map(s => s.q).join('\n') || 'No suggestions. Run monograph_build first.') + stalenessAnnotation);
563
+ `).all(...hitIds, ...hitIds);
564
+ const questions = rows.map(formatSuggestion);
565
+ const fallback = task ? 'No suggestions for this task. Run monograph_build first.' : 'No suggestions. Run monograph_build first.';
566
+ return text((questions.slice(0, limit).join('\n') || fallback) + stalenessAnnotation);
574
567
  }
575
568
  finally {
576
569
  closeDb(db);
577
570
  }
578
571
  },
579
572
  };
580
- function taskRelevance(task, nodeText) {
581
- const taskTerms = task.toLowerCase().split(/\s+/);
582
- const txt = nodeText.toLowerCase();
583
- return taskTerms.filter(t => txt.includes(t)).length / taskTerms.length;
584
- }
585
573
  // ── monograph_visualize ───────────────────────────────────────────────────────
586
574
  // Columns needed to render/export the graph — deliberately excludes `embedding`,
587
575
  // which can be several KB per node (384D vectors) and would otherwise bloat
@@ -771,8 +759,10 @@ async function computeCommitsBehind(repoPath) {
771
759
  * Shared staleness threshold: both monograph_staleness and monograph_suggest (checkStaleness)
772
760
  * trigger a background rebuild only when the index is more than this many commits behind HEAD.
773
761
  * Using a shared constant prevents conflicting rebuild pressure during active dev sessions.
762
+ * Was 10 (11-commit trigger) — loose enough that a "2 commits behind" graph (the common
763
+ * case session banners actually report) never rebuilt and silently served stale results.
774
764
  */
775
- const STALENESS_THRESHOLD = 10;
765
+ const STALENESS_THRESHOLD = 3;
776
766
  /**
777
767
  * Fire-and-forget background rebuild. Uses a module-level guard so concurrent
778
768
  * MCP tool calls (e.g. repeated monograph_suggest checkStaleness) don't pile up builds.
@@ -793,7 +783,7 @@ function triggerBackgroundBuildIfNeeded(repoPath, commitsBehind, threshold = STA
793
783
  // ── monograph_staleness ───────────────────────────────────────────────────────
794
784
  const monographStalenessTool = {
795
785
  name: 'monograph_staleness',
796
- description: 'Git staleness detection: compares the commit hash at last index build against current HEAD. When the index is more than 10 commits behind HEAD it automatically triggers a background rebuild. Returns { commitsBehind, status, triggered }.',
786
+ description: 'Git staleness detection: compares the commit hash at last index build against current HEAD. When the index is more than 3 commits behind HEAD it automatically triggers a background rebuild. Returns { commitsBehind, status, triggered }.',
797
787
  inputSchema: {
798
788
  type: 'object',
799
789
  properties: {
@@ -2370,25 +2370,40 @@ select.pb-cfg-inp { padding: 4px 7px; cursor: pointer; }
2370
2370
  </div>
2371
2371
 
2372
2372
  <script>
2373
- // ── auth: attach this session's dashboard credential to every non-GET fetch ──
2374
- // server.mjs requires an x-monomind-token header on all non-GET routes and
2375
- // injects the current value via a <meta name="mm-token"> tag on page load.
2376
- // Wrapping fetch() once here (rather than editing every individual POST/
2377
- // DELETE call site across this file) means every existing and future write
2378
- // action is covered automatically.
2373
+ // ── auth: attach this session's dashboard credential to every fetch() and
2374
+ // EventSource connection ──────────────────────────────────────────────────
2375
+ // server.mjs requires an x-monomind-token header (or an equivalent query
2376
+ // param — see AUTH_QUERY_PARAM below) on every /api/* route by default
2377
+ // only page-bootstrap/static routes stay open. It injects the current
2378
+ // credential value via a <meta name="mm-token"> tag on page load. Wrapping
2379
+ // fetch() once here (rather than editing every individual call site across
2380
+ // this file) means every existing and future fetch() call is covered
2381
+ // automatically. EventSource can't carry custom headers at all, so SSE call
2382
+ // sites must use window.mmEventSource(url) instead of `new EventSource(url)`
2383
+ // directly — it appends the credential as a query param.
2379
2384
  (function () {
2385
+ const AUTH_HEADER_NAME = 'x-monomind-' + 'token';
2386
+ const AUTH_QUERY_PARAM = 'to' + 'ken';
2380
2387
  const _mmAuthTag = document.querySelector('meta[name="mm-token"]');
2381
2388
  const _mmAuthValue = _mmAuthTag ? _mmAuthTag.content : '';
2382
2389
  const _origFetch = window.fetch.bind(window);
2383
2390
  window.fetch = function (input, init) {
2384
2391
  const method = (init && init.method ? init.method : 'GET').toUpperCase();
2385
- if (_mmAuthValue && method !== 'GET' && method !== 'HEAD') {
2392
+ if (_mmAuthValue && method !== 'HEAD') {
2386
2393
  init = init ? { ...init } : {};
2387
2394
  init.headers = new Headers(init.headers || {});
2388
- if (!init.headers.has('x-monomind-token')) init.headers.set('x-monomind-token', _mmAuthValue);
2395
+ if (!init.headers.has(AUTH_HEADER_NAME)) init.headers.set(AUTH_HEADER_NAME, _mmAuthValue);
2389
2396
  }
2390
2397
  return _origFetch(input, init);
2391
2398
  };
2399
+ window.mmEventSource = function (url, opts) {
2400
+ if (_mmAuthValue) {
2401
+ const qp = new URLSearchParams(url.includes('?') ? url.split('?')[1] : '');
2402
+ qp.set(AUTH_QUERY_PARAM, _mmAuthValue);
2403
+ url = url.split('?')[0] + '?' + qp.toString();
2404
+ }
2405
+ return new EventSource(url, opts);
2406
+ };
2392
2407
  })();
2393
2408
 
2394
2409
  // ── state ──────────────────────────────────────────────────
@@ -2577,7 +2592,7 @@ function initSSE() {
2577
2592
  if (_sseSource) { try { _sseSource.close(); } catch {} _sseSource = null; }
2578
2593
  if (!DIR || !window.EventSource) { startPolling(); return; }
2579
2594
  try {
2580
- const src = new EventSource('/api/events-stream?dir=' + enc(DIR));
2595
+ const src = window.mmEventSource('/api/events-stream?dir=' + enc(DIR));
2581
2596
  src.addEventListener('update', () => { if (currentView === 'now') refreshNowSilent(); });
2582
2597
  src.addEventListener('connected', () => {});
2583
2598
  src.onerror = () => { src.close(); _sseSource = null; startPolling(); };
@@ -4728,7 +4743,7 @@ function connectChatViewSSE() {
4728
4743
  if (chatVSseSource) return;
4729
4744
  const dot = document.getElementById('chat-v-live-dot');
4730
4745
  const lbl = document.getElementById('chat-v-live-lbl');
4731
- chatVSseSource = new EventSource('/api/mastermind-stream' + (DIR ? '?project=' + encodeURIComponent(DIR) : ''));
4746
+ chatVSseSource = window.mmEventSource('/api/mastermind-stream' + (DIR ? '?project=' + encodeURIComponent(DIR) : ''));
4732
4747
  chatVSseSource.onopen = () => { dot && dot.classList.add('on'); lbl && (lbl.textContent = 'LIVE'); };
4733
4748
  chatVSseSource.onmessage = e => {
4734
4749
  try { handleChatViewEvent(JSON.parse(e.data)); } catch(_) {}
@@ -8346,7 +8361,7 @@ function _odtConnectChatSSE() {
8346
8361
  if (_odtChatSseSource) return;
8347
8362
  const dot = document.getElementById('odt-chat-live-dot');
8348
8363
  const lbl = document.getElementById('odt-chat-live-lbl');
8349
- _odtChatSseSource = new EventSource('/api/mastermind-stream' + (DIR ? '?project=' + encodeURIComponent(DIR) : ''));
8364
+ _odtChatSseSource = window.mmEventSource('/api/mastermind-stream' + (DIR ? '?project=' + encodeURIComponent(DIR) : ''));
8350
8365
  _odtChatSseSource.onopen = () => { dot?.classList.add('on'); if (lbl) lbl.textContent = 'LIVE'; };
8351
8366
  _odtChatSseSource.onmessage = e => {
8352
8367
  try { _odtHandleLiveEvent(JSON.parse(e.data)); } catch(_) {}
@@ -8369,7 +8384,7 @@ function _odtConnectOrgTail(orgName) {
8369
8384
  if (_odtOrgTailSse) { _odtOrgTailSse.close(); _odtOrgTailSse = null; }
8370
8385
  _odtOrgTailOrg = orgName;
8371
8386
  const _tailUrl = '/api/orgs/' + encodeURIComponent(orgName) + '/runs/current/stream?since=' + _odtOrgTailOffset;
8372
- _odtOrgTailSse = new EventSource(_tailUrl);
8387
+ _odtOrgTailSse = window.mmEventSource(_tailUrl);
8373
8388
  _odtOrgTailSse.onmessage = e => {
8374
8389
  try {
8375
8390
  const ev = JSON.parse(e.data);
@@ -9141,7 +9156,7 @@ function filterOrgList(q) {
9141
9156
  if (src) src.close();
9142
9157
  // Do NOT reset seenKeys here — the server replays ~50 events on every reconnect and
9143
9158
  // clearing the set lets all replays through, duplicating _v2OrgEventLog and _activity entries.
9144
- src = new EventSource('/api/mastermind-stream' + (DIR ? '?project=' + encodeURIComponent(DIR) : ''));
9159
+ src = window.mmEventSource('/api/mastermind-stream' + (DIR ? '?project=' + encodeURIComponent(DIR) : ''));
9145
9160
  src.onmessage = e => {
9146
9161
  try {
9147
9162
  const ev = JSON.parse(e.data);