monomind 2.3.4 → 2.5.0

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/README.md CHANGED
@@ -223,6 +223,8 @@ monomind doc list # what's indexed
223
223
  monomind doc export # portable OKF bundle — move your brain between machines
224
224
  ```
225
225
 
226
+ **And it follows you across projects.** Ingest a path from *outside* the current project (`monomind doc ingest ~/notes`, or add `--global`) and it lands in your personal global brain at `~/.monomind/global-brain` — searchable from every project on the machine. All retrieval (CLI search, per-prompt injection, the dashboard) merges both stores automatically, with project knowledge winning ties and global hits labeled `[global]`. `doc export --global` moves your whole brain between machines as an OKF bundle — still no cloud, ever.
227
+
226
228
  Retrieval quality is a tested invariant, not a hope: a golden-set eval (paraphrase queries against notes written in different vocabulary) runs in CI with an 80% recall bar.
227
229
 
228
230
  > **Privacy note:** the embedding model (~90MB) is fetched once from HuggingFace's CDN when your first document is indexed, then cached locally forever. That download is the only outbound request the Second Brain ever makes — your documents and queries never leave your machine. Offline at first index? Search degrades gracefully to keyword matching and `monomind doctor` tells you how to warm up later.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "monomind",
3
- "version": "2.3.4",
3
+ "version": "2.5.0",
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",
@@ -368,7 +368,10 @@ module.exports = {
368
368
  if (sbResp.ok) {
369
369
  var sbData = await sbResp.json();
370
370
  if (sbData && Array.isArray(sbData.results) && sbData.results.length > 0) {
371
- sbHits = sbData.results.map(function(r) { return { key: r.key, value: r.content, score: r.score, metadata: {} }; });
371
+ sbHits = sbData.results.map(function(r) {
372
+ var src = (r.tags || []).find(function(t) { return t.indexOf('src:') === 0; });
373
+ return { key: r.key, value: r.content, score: r.score, global: !!r.global, metadata: src ? { filePath: src.slice(4) } : {} };
374
+ });
372
375
  sbMethod = sbData.method === 'semantic' ? 'semantic' : 'keyword';
373
376
  }
374
377
  }
@@ -415,7 +418,7 @@ module.exports = {
415
418
  var sbH = sbHits[sbI];
416
419
  var sbSrc = (sbH.metadata && sbH.metadata.filePath) ? String(sbH.metadata.filePath).split('/').slice(-2).join('/') : sbH.key;
417
420
  var sbText = String(sbH.value || '').replace(/\s+/g, ' ').slice(0, 240);
418
- sbLines.push(' • [' + sbSrc + '] ' + sbText);
421
+ sbLines.push(' • [' + sbSrc + (sbH.global ? ' · global' : '') + '] ' + sbText);
419
422
  }
420
423
  sbLines.push(' (deeper lookup: mcp__monomind__knowledge_search or `monomind doc search -q "..."`)');
421
424
  console.log(sbLines.join('\n'));
@@ -223,6 +223,8 @@ monomind doc list # what's indexed
223
223
  monomind doc export # portable OKF bundle — move your brain between machines
224
224
  ```
225
225
 
226
+ **And it follows you across projects.** Ingest a path from *outside* the current project (`monomind doc ingest ~/notes`, or add `--global`) and it lands in your personal global brain at `~/.monomind/global-brain` — searchable from every project on the machine. All retrieval (CLI search, per-prompt injection, the dashboard) merges both stores automatically, with project knowledge winning ties and global hits labeled `[global]`. `doc export --global` moves your whole brain between machines as an OKF bundle — still no cloud, ever.
227
+
226
228
  Retrieval quality is a tested invariant, not a hope: a golden-set eval (paraphrase queries against notes written in different vocabulary) runs in CI with an 80% recall bar.
227
229
 
228
230
  > **Privacy note:** the embedding model (~90MB) is fetched once from HuggingFace's CDN when your first document is indexed, then cached locally forever. That download is the only outbound request the Second Brain ever makes — your documents and queries never leave your machine. Offline at first index? Search degrades gracefully to keyword matching and `monomind doctor` tells you how to warm up later.
@@ -3,22 +3,41 @@
3
3
  */
4
4
  import * as path from 'node:path';
5
5
  import { output } from '../output.js';
6
+ import { getGlobalBrainDir } from '../memory/memory-bridge.js';
6
7
  const ingestCommand = {
7
8
  name: 'ingest',
8
9
  description: 'Ingest documents into the knowledge base',
9
10
  options: [
10
- { name: 'scope', short: 's', description: 'Knowledge scope (default: shared)', type: 'string', default: 'shared' },
11
+ // No `default` on scope: the auto-global routing below must be able to
12
+ // tell "user typed --scope shared" apart from "user typed nothing" — a
13
+ // parser-injected default makes them indistinguishable.
14
+ { name: 'scope', short: 's', description: 'Knowledge scope (default: shared; auto-routes to global for paths outside the project)', type: 'string' },
15
+ { name: 'global', short: 'g', description: 'Ingest into the personal cross-project global brain (~/.monomind/global-brain)', type: 'boolean' },
11
16
  ],
12
17
  examples: [
13
18
  { command: 'monomind doc ingest ./docs', description: 'Ingest all docs in a directory' },
19
+ { command: 'monomind doc ingest ~/notes --global', description: 'Ingest into the global brain (auto-detected for paths outside the project)' },
14
20
  { command: 'monomind doc ingest report.pdf', description: 'Ingest a single file' },
15
21
  ],
16
22
  action: async (ctx) => {
17
23
  const target = ctx.args[0] || '.';
18
- const scope = String(ctx.flags.scope || 'shared');
19
24
  const { ingestDocument, ingestDirectory } = await import('../knowledge/document-pipeline.js');
20
25
  const fs = await import('node:fs');
21
26
  const resolved = path.resolve(target);
27
+ // Zero-decision routing: an explicit --global wins; otherwise paths OUTSIDE
28
+ // the current project belong to the personal brain (a project store keyed
29
+ // to this cwd would never see them again from another project).
30
+ let scope = String(ctx.flags.scope || 'shared');
31
+ if (ctx.flags.global === true) {
32
+ scope = 'global';
33
+ }
34
+ else if (!ctx.flags.scope) {
35
+ const relToCwd = path.relative(process.cwd(), resolved);
36
+ if (relToCwd.startsWith('..') || path.isAbsolute(relToCwd)) {
37
+ scope = 'global';
38
+ output.writeln(output.dim(` ${target} is outside this project — ingesting into the global brain (use --scope shared to force project scope)`));
39
+ }
40
+ }
22
41
  const spinner = output.createSpinner({ text: 'Indexing documents...' });
23
42
  spinner.start();
24
43
  try {
@@ -68,9 +87,11 @@ const searchDocCommand = {
68
87
  { name: 'limit', short: 'l', description: 'Max results (default: 10)', type: 'number', default: 10 },
69
88
  { name: 'scope', short: 's', description: 'Knowledge scope (default: shared)', type: 'string', default: 'shared' },
70
89
  { name: 'min-score', description: 'Minimum similarity (default: 0.3)', type: 'number', default: 0.3 },
90
+ { name: 'store', description: 'Which store(s): project | global | all (default: all — project results win ties)', type: 'string', default: 'all' },
71
91
  ],
72
92
  examples: [
73
- { command: 'monomind doc search -q "authentication flow"', description: 'Search for auth docs' },
93
+ { command: 'monomind doc search -q "authentication flow"', description: 'Search project + global brain' },
94
+ { command: 'monomind doc search -q "pricing notes" --store global', description: 'Search only the personal global brain' },
74
95
  ],
75
96
  action: async (ctx) => {
76
97
  const query = String(ctx.flags.query || ctx.args[0] || '');
@@ -79,10 +100,12 @@ const searchDocCommand = {
79
100
  return { success: false, exitCode: 1 };
80
101
  }
81
102
  const { searchKnowledge } = await import('../knowledge/document-pipeline.js');
103
+ const storeFlag = String(ctx.flags.store || 'all');
82
104
  const excerpts = await searchKnowledge(query, {
83
105
  scope: String(ctx.flags.scope || 'shared'),
84
106
  limit: Number(ctx.flags.limit || 10),
85
107
  minScore: Number(ctx.flags['min-score'] || 0.3),
108
+ store: storeFlag === 'project' || storeFlag === 'global' ? storeFlag : 'all',
86
109
  });
87
110
  if (!excerpts.length) {
88
111
  output.writeln(output.dim('No results found.'));
@@ -92,7 +115,8 @@ const searchDocCommand = {
92
115
  output.writeln();
93
116
  for (let i = 0; i < excerpts.length; i++) {
94
117
  const ex = excerpts[i];
95
- output.writeln(`${output.highlight(`${i + 1}.`)} ${output.dim(`(${ex.similarity.toFixed(3)})`)} ${ex.filePath || 'unknown'}`);
118
+ const origin = ex.scope === 'global' ? ` ${output.dim('[global]')}` : '';
119
+ output.writeln(`${output.highlight(`${i + 1}.`)} ${output.dim(`(${ex.similarity.toFixed(3)})`)} ${ex.filePath || 'unknown'}${origin}`);
96
120
  const preview = ex.text.length > 200 ? ex.text.slice(0, 200) + '...' : ex.text;
97
121
  output.writeln(` ${output.dim(preview)}`);
98
122
  output.writeln();
@@ -105,11 +129,13 @@ const listDocCommand = {
105
129
  description: 'List indexed documents',
106
130
  options: [
107
131
  { name: 'scope', short: 's', description: 'Knowledge scope', type: 'string' },
132
+ { name: 'global', short: 'g', description: 'List the personal cross-project global brain', type: 'boolean' },
108
133
  ],
109
134
  action: async (ctx) => {
110
135
  const { listDocuments } = await import('../knowledge/document-pipeline.js');
111
- const scope = ctx.flags.scope ? String(ctx.flags.scope) : undefined;
112
- const docs = listDocuments(process.cwd(), scope);
136
+ const isGlobal = ctx.flags.global === true;
137
+ const scope = isGlobal ? 'global' : ctx.flags.scope ? String(ctx.flags.scope) : undefined;
138
+ const docs = listDocuments(isGlobal ? getGlobalBrainDir() : process.cwd(), scope);
113
139
  if (!docs.length) {
114
140
  output.writeln(output.dim('No documents indexed. Run: monomind doc ingest <path>'));
115
141
  return { success: true, data: [] };
@@ -133,15 +159,17 @@ const exportDocCommand = {
133
159
  options: [
134
160
  { name: 'output', short: 'o', description: 'Output directory', type: 'string', default: '.monomind/knowledge-export' },
135
161
  { name: 'scope', short: 's', description: 'Knowledge scope (default: shared)', type: 'string', default: 'shared' },
162
+ { name: 'global', short: 'g', description: 'Export the personal cross-project global brain (portable between machines)', type: 'boolean' },
136
163
  ],
137
164
  action: async (ctx) => {
138
165
  const { exportToOKF } = await import('../knowledge/document-pipeline.js');
139
166
  const outDir = path.resolve(String(ctx.flags.output || '.monomind/knowledge-export'));
140
- const scope = String(ctx.flags.scope || 'shared');
167
+ const isGlobal = ctx.flags.global === true;
168
+ const scope = isGlobal ? 'global' : String(ctx.flags.scope || 'shared');
141
169
  const spinner = output.createSpinner({ text: 'Exporting to OKF...' });
142
170
  spinner.start();
143
171
  try {
144
- const result = await exportToOKF(outDir, process.cwd(), scope);
172
+ const result = await exportToOKF(outDir, isGlobal ? getGlobalBrainDir() : process.cwd(), scope);
145
173
  spinner.succeed(`Exported ${result.exported} documents to ${result.outputDir}`);
146
174
  return { success: true, data: result };
147
175
  }
@@ -220,6 +220,13 @@ export class CLI {
220
220
  this.output.printDebug(`Completed in ${Date.now() - startTime}ms`);
221
221
  }
222
222
  if (result && !result.success) {
223
+ // Always surface the failure reason: many actions return
224
+ // { success: false, message } without logging, and exiting 1 with
225
+ // zero output left users guessing (swarm finding #12). Actions that
226
+ // already printed a rich error produce one extra terse summary line
227
+ // — acceptable; silence is not.
228
+ if (result.message)
229
+ this.output.printError(result.message);
223
230
  process.exit(result.exitCode || 1);
224
231
  }
225
232
  }
@@ -399,12 +399,15 @@ If the \`documents\` capability is active (check \`.monomind/capabilities.json\`
399
399
 
400
400
  **CLI access:**
401
401
  \`\`\`bash
402
- monomind doc search -q "your query" # Semantic search
403
- monomind doc list # List indexed docs
404
- monomind doc ingest ./path # Ingest new documents
405
- monomind doc export # Export as OKF bundle
402
+ monomind doc search -q "your query" # Semantic search (project + global brain merged)
403
+ monomind doc search -q "..." --store global # Personal global brain only
404
+ monomind doc list # List indexed docs (--global for the global brain)
405
+ monomind doc ingest ./path # Ingest new documents (paths outside the project auto-route to the global brain)
406
+ monomind doc export # Export as OKF bundle (--global to move your brain between machines)
406
407
  \`\`\`
407
408
 
409
+ **Global brain:** the user has a personal cross-project knowledge store at \`~/.monomind/global-brain\`. All searches (knowledge_search, doc search, per-prompt injection) automatically merge it with project knowledge — project results win ties, global hits are labeled \`[global]\`. Cite the label so the user knows which brain answered.
410
+
408
411
  **Re-indexing** happens automatically on session start (unchanged files are skipped via content hash).`;
409
412
  }
410
413
  function graphifySection() {
@@ -43,6 +43,8 @@ export declare function searchKnowledge(query: string, opts?: {
43
43
  limit?: number;
44
44
  minScore?: number;
45
45
  rootDir?: string;
46
+ /** which store(s): project-only, global-only, or both (default). */
47
+ store?: 'project' | 'global' | 'all';
46
48
  }): Promise<KnowledgeExcerpt[]>;
47
49
  export declare function listDocuments(rootDir?: string, scope?: string): DocumentMeta[];
48
50
  export declare function removeDocument(filePath: string, scope?: string, rootDir?: string): Promise<void>;
@@ -7,6 +7,14 @@
7
7
  * @module v1/cli/memory-bridge
8
8
  */
9
9
  export declare function safeParseEmbedding(raw: string | null | undefined): number[] | null;
10
+ /** The personal, cross-project knowledge store. Deliberately a SIBLING of
11
+ * ~/.monomind/projects (never inside it) so per-project pruning heuristics
12
+ * (`cleanup --data`) can never touch it. Env-overridable for tests and for
13
+ * users who keep their brain on a synced/external location. Resolved lazily
14
+ * so the override works regardless of import order. */
15
+ export declare function getGlobalBrainDir(): string;
16
+ /** Sentinel callers pass as dbPath to address the global brain. */
17
+ export declare const GLOBAL_BRAIN = "@global";
10
18
  /** Resolve the real on-disk LanceDB path for a given custom path (or the default). */
11
19
  export declare function bridgeGetDbPath(customPath?: string): string;
12
20
  export declare function bridgeStoreEntry(options: {
@@ -46,6 +54,7 @@ export declare function bridgeSearchEntries(options: {
46
54
  score: number;
47
55
  namespace: string;
48
56
  provenance?: string;
57
+ tags?: string[];
49
58
  }[];
50
59
  searchTime: number;
51
60
  searchMethod?: string;
@@ -70,22 +70,37 @@ function realOrResolved(p) {
70
70
  return p;
71
71
  }
72
72
  }
73
+ /** The personal, cross-project knowledge store. Deliberately a SIBLING of
74
+ * ~/.monomind/projects (never inside it) so per-project pruning heuristics
75
+ * (`cleanup --data`) can never touch it. Env-overridable for tests and for
76
+ * users who keep their brain on a synced/external location. Resolved lazily
77
+ * so the override works regardless of import order. */
78
+ export function getGlobalBrainDir() {
79
+ return process.env.MONOMIND_GLOBAL_BRAIN_DIR || path.join(os.homedir(), '.monomind', 'global-brain');
80
+ }
81
+ /** Sentinel callers pass as dbPath to address the global brain. */
82
+ export const GLOBAL_BRAIN = '@global';
73
83
  function getDbPath(customPath) {
74
84
  const defaultDir = path.join(projectDataDir(), 'lancedb');
75
85
  if (!customPath || customPath === ':memory:')
76
86
  return defaultDir;
87
+ if (customPath === GLOBAL_BRAIN)
88
+ return getGlobalBrainDir();
77
89
  // Treat legacy .db paths (and the legacy .swarm dir) as a signal to use the default
78
90
  if (customPath.endsWith('.db'))
79
91
  return defaultDir;
80
92
  const resolved = realOrResolved(path.resolve(customPath));
81
93
  // Guard against path traversal from MCP inputs: only allow paths inside the
82
- // project or inside the per-project home data dir.
94
+ // project, the per-project home data dir, or the global brain.
83
95
  const relCwd = path.relative(realOrResolved(process.cwd()), resolved);
84
96
  const relHome = path.relative(realOrResolved(projectDataDir()), resolved);
97
+ const relGlobal = path.relative(realOrResolved(getGlobalBrainDir()), resolved);
85
98
  if (!relCwd.startsWith('..') && !path.isAbsolute(relCwd))
86
99
  return resolved;
87
100
  if (!relHome.startsWith('..') && !path.isAbsolute(relHome))
88
101
  return resolved;
102
+ if (!relGlobal.startsWith('..') && !path.isAbsolute(relGlobal))
103
+ return resolved;
89
104
  return defaultDir;
90
105
  }
91
106
  /** Resolve the real on-disk LanceDB path for a given custom path (or the default). */
@@ -114,13 +129,10 @@ function getAutomemConfig() {
114
129
  function generateId(prefix) {
115
130
  return `${prefix}_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`;
116
131
  }
117
- // ===== Lazy singleton LanceDB backend =====
118
- let backendPromise = null;
119
- let backendInstance = null;
120
- let bridgeAvailable = null;
132
+ const backendSlots = new Map();
121
133
  let _embedder = null;
134
+ let _embedderPromise = null;
122
135
  const MAX_INIT_ATTEMPTS = 3;
123
- let initAttempts = 0;
124
136
  /** Flush after mutations: the sql.js fallback backend is in-memory WASM and
125
137
  * only reaches disk via persist(); the CLI process is short-lived, so waiting
126
138
  * for an auto-persist interval would lose writes. No-op on better-sqlite3. */
@@ -130,59 +142,73 @@ async function flushBackend(backend) {
130
142
  }
131
143
  catch { /* best effort */ }
132
144
  }
145
+ async function loadEmbedder() {
146
+ if (_embedder)
147
+ return;
148
+ if (!_embedderPromise) {
149
+ _embedderPromise = (async () => {
150
+ try {
151
+ const hf = await import('@huggingface/transformers');
152
+ // revision must be a git ref — 'main' is the HF default; 'default' 404s and
153
+ // silently killed embeddings (every search degraded to keyword matching)
154
+ // dtype pinned explicitly: transformers.js logs a "dtype not specified"
155
+ // warning to the console on every load otherwise (leaks into CLI output).
156
+ const extractor = await hf.pipeline('feature-extraction', BRIDGE_EMBEDDING_MODEL, { revision: 'main', dtype: 'fp32' });
157
+ _embedder = async (text) => {
158
+ const output = await extractor(text, { pooling: 'mean', normalize: true });
159
+ return new Float32Array(output.data);
160
+ };
161
+ }
162
+ catch (e) {
163
+ _embedderPromise = null; // allow retry (e.g. first call offline)
164
+ if (process.env.DEBUG || process.env.MONOMIND_DEBUG)
165
+ console.error('[memory-bridge] embedding model failed to load — store and search without vectors:', e);
166
+ }
167
+ })();
168
+ }
169
+ await _embedderPromise;
170
+ }
133
171
  async function getBackend(dbPath) {
134
- if (bridgeAvailable === false)
172
+ const dir = getDbPath(dbPath);
173
+ let slot = backendSlots.get(dir);
174
+ if (!slot) {
175
+ slot = { promise: null, instance: null, available: null, attempts: 0 };
176
+ backendSlots.set(dir, slot);
177
+ }
178
+ if (slot.available === false)
135
179
  return null;
136
- if (initAttempts >= MAX_INIT_ATTEMPTS) {
137
- bridgeAvailable = false;
180
+ if (slot.attempts >= MAX_INIT_ATTEMPTS) {
181
+ slot.available = false;
138
182
  return null;
139
183
  }
140
- if (backendInstance)
141
- return backendInstance;
142
- if (!backendPromise) {
143
- backendPromise = (async () => {
184
+ if (slot.instance)
185
+ return slot.instance;
186
+ if (!slot.promise) {
187
+ slot.promise = (async () => {
144
188
  try {
145
189
  const mod = await import('@monoes/memory');
146
- // Try to create embedding generator from HuggingFace transformers
147
- let embeddingGenerator;
148
- try {
149
- const hf = await import('@huggingface/transformers');
150
- // revision must be a git ref — 'main' is the HF default; 'default' 404s and
151
- // silently killed embeddings (every search degraded to keyword matching)
152
- // dtype pinned explicitly: transformers.js logs a "dtype not specified"
153
- // warning to the console on every load otherwise (leaks into CLI output).
154
- const extractor = await hf.pipeline('feature-extraction', BRIDGE_EMBEDDING_MODEL, { revision: 'main', dtype: 'fp32' });
155
- embeddingGenerator = async (text) => {
156
- const output = await extractor(text, { pooling: 'mean', normalize: true });
157
- return new Float32Array(output.data);
158
- };
159
- _embedder = embeddingGenerator;
160
- }
161
- catch (e) {
162
- if (process.env.DEBUG || process.env.MONOMIND_DEBUG)
163
- console.error('[memory-bridge] embedding model failed to load — store and search without vectors:', e);
164
- }
190
+ await loadEmbedder();
165
191
  // Local SQLite engine (LanceDB replaced 2026-07): better-sqlite3 when its
166
192
  // native binding loads, sql.js (pure WASM) otherwise — both persist text
167
193
  // AND embeddings, so vectors are always recomputable/derivable data.
168
- // getDbPath keeps its traversal guard and directory semantics; the SQLite
169
- // file lives inside that directory.
170
- const dir = getDbPath(dbPath);
171
194
  fs.mkdirSync(dir, { recursive: true });
172
195
  // Origin marker: records which project this data dir belongs to, so
173
196
  // `monomind cleanup --data` can verifiably prune dirs whose project
174
- // no longer exists (the dir-name hash is one-way). Best-effort.
175
- try {
176
- const originFile = path.join(projectDataDir(), 'origin.json');
177
- fs.writeFileSync(originFile, JSON.stringify({ path: path.resolve(process.cwd()), updatedAt: new Date().toISOString() }) + '\n', 'utf-8');
197
+ // no longer exists (the dir-name hash is one-way). Best-effort; never
198
+ // written for the global brain (it has no single origin project).
199
+ if (dir !== getGlobalBrainDir()) {
200
+ try {
201
+ const originFile = path.join(projectDataDir(), 'origin.json');
202
+ fs.writeFileSync(originFile, JSON.stringify({ path: path.resolve(process.cwd()), updatedAt: new Date().toISOString() }) + '\n', 'utf-8');
203
+ }
204
+ catch { /* non-fatal */ }
178
205
  }
179
- catch { /* non-fatal */ }
180
206
  const cfg = {
181
207
  databasePath: path.join(dir, 'memory.db'),
182
208
  walMode: true,
183
209
  optimize: true,
184
210
  defaultNamespace: 'default',
185
- embeddingGenerator,
211
+ embeddingGenerator: _embedder ?? undefined,
186
212
  };
187
213
  const origLog = console.log;
188
214
  console.log = (...args) => {
@@ -207,20 +233,20 @@ async function getBackend(dbPath) {
207
233
  finally {
208
234
  console.log = origLog;
209
235
  }
210
- backendInstance = backend;
211
- bridgeAvailable = true;
236
+ slot.instance = backend;
237
+ slot.available = true;
212
238
  return backend;
213
239
  }
214
240
  catch {
215
- initAttempts++;
216
- backendPromise = null;
217
- if (initAttempts >= MAX_INIT_ATTEMPTS)
218
- bridgeAvailable = false;
241
+ slot.attempts++;
242
+ slot.promise = null;
243
+ if (slot.attempts >= MAX_INIT_ATTEMPTS)
244
+ slot.available = false;
219
245
  return null;
220
246
  }
221
247
  })();
222
248
  }
223
- return backendPromise;
249
+ return slot.promise;
224
250
  }
225
251
  // ===== Core CRUD =====
226
252
  export async function bridgeStoreEntry(options) {
@@ -332,6 +358,7 @@ export async function bridgeSearchEntries(options) {
332
358
  score: r.score,
333
359
  namespace: r.entry.namespace,
334
360
  provenance: `semantic:${r.score.toFixed(3)}`,
361
+ tags: r.entry.tags ?? [],
335
362
  _createdAt: r.entry.createdAt || 0,
336
363
  }));
337
364
  searchMethod = 'semantic';
@@ -370,6 +397,7 @@ export async function bridgeSearchEntries(options) {
370
397
  score: Math.min(0.9, 0.3 + score * 0.6),
371
398
  namespace: e.namespace,
372
399
  provenance: `keyword:${score.toFixed(2)}`,
400
+ tags: e.tags ?? [],
373
401
  _createdAt: e.createdAt || 0,
374
402
  }));
375
403
  }
@@ -598,17 +626,17 @@ export async function getControllerRegistry(dbPath) {
598
626
  return getBackend(dbPath);
599
627
  }
600
628
  export async function shutdownBridge() {
601
- if (backendInstance) {
602
- try {
603
- await backendInstance.shutdown();
629
+ for (const slot of backendSlots.values()) {
630
+ if (slot.instance) {
631
+ try {
632
+ await slot.instance.shutdown();
633
+ }
634
+ catch { /* ignore */ }
604
635
  }
605
- catch { /* ignore */ }
606
636
  }
607
- backendInstance = null;
608
- backendPromise = null;
609
- bridgeAvailable = null;
637
+ backendSlots.clear();
610
638
  _embedder = null;
611
- initAttempts = 0;
639
+ _embedderPromise = null;
612
640
  }
613
641
  // ===== Pattern store =====
614
642
  export async function bridgeStorePattern(options) {
@@ -113,6 +113,14 @@ export declare class OrgDaemon {
113
113
  * memory bridge (semantic when the local model is available, tokenized
114
114
  * keyword otherwise). Failures return a message, never throw into the tool. */
115
115
  private recallOrgMemory;
116
+ /** knowledge_search implementation for org agents: the user's Second Brain
117
+ * (this project's documents + the personal global brain), merged with the
118
+ * same project-first ranking every other surface uses. Failures return a
119
+ * message, never throw into the tool call. */
120
+ searchProjectKnowledge(query: string): Promise<{
121
+ text: string;
122
+ hits: number;
123
+ }>;
116
124
  /** Persist the run's outcome into cross-run org memory so org_recall (and
117
125
  * future runs) can find it by meaning, not just recency. Best-effort. */
118
126
  private storeRunMemory;
@@ -88,6 +88,11 @@ export class OrgDaemon {
88
88
  bus.emit({ type: 'status', from: r, reason: 'org-recall', msg: `recall: ${q.slice(0, 80)}`, data: { hits: answer.hits } });
89
89
  return answer.text;
90
90
  },
91
+ searchKnowledge: async (r, q) => {
92
+ const answer = await this.searchProjectKnowledge(q);
93
+ bus.emit({ type: 'status', from: r, reason: 'knowledge-search', msg: `knowledge: ${q.slice(0, 80)}`, data: { hits: answer.hits } });
94
+ return answer.text;
95
+ },
91
96
  queryFn: this.opts.queryFn,
92
97
  };
93
98
  // Supervised session: transient crashes (provider blips, network) restart
@@ -502,6 +507,23 @@ export class OrgDaemon {
502
507
  return { text: `org memory unavailable (${err instanceof Error ? err.message : 'error'})`, hits: 0 };
503
508
  }
504
509
  }
510
+ /** knowledge_search implementation for org agents: the user's Second Brain
511
+ * (this project's documents + the personal global brain), merged with the
512
+ * same project-first ranking every other surface uses. Failures return a
513
+ * message, never throw into the tool call. */
514
+ async searchProjectKnowledge(query) {
515
+ try {
516
+ const { searchKnowledge } = await import('../knowledge/document-pipeline.js');
517
+ const excerpts = await searchKnowledge(query, { rootDir: this.root, limit: 5, store: 'all' });
518
+ if (!excerpts.length)
519
+ return { text: 'No matching documents in the Second Brain for that query.', hits: 0 };
520
+ const text = excerpts.map((e, i) => `${i + 1}. [${e.filePath || 'unknown'}${e.scope === 'global' ? ' · global' : ''}] (${e.similarity.toFixed(2)})\n${e.text.slice(0, 800)}`).join('\n\n');
521
+ return { text, hits: excerpts.length };
522
+ }
523
+ catch (err) {
524
+ return { text: `knowledge search unavailable (${err instanceof Error ? err.message : 'error'})`, hits: 0 };
525
+ }
526
+ }
505
527
  /** Persist the run's outcome into cross-run org memory so org_recall (and
506
528
  * future runs) can find it by meaning, not just recency. Best-effort. */
507
529
  async storeRunMemory(name, def, run, summary) {
@@ -17,6 +17,8 @@ export interface SessionOpts {
17
17
  onComplete?: (role: string, outcome: 'achieved' | 'partial' | 'failed', summary: string) => void;
18
18
  /** Search the org's accumulated cross-run memory (memory_namespace). */
19
19
  recall?: (role: string, query: string) => Promise<string>;
20
+ /** Search the user's Second Brain (project documents + personal global brain). */
21
+ searchKnowledge?: (role: string, query: string) => Promise<string>;
20
22
  def?: OrgDef;
21
23
  maxTurns?: number;
22
24
  queryFn?: typeof query;
@@ -15,6 +15,7 @@ export function buildRolePrompt(role, def, roster) {
15
15
  `Roster: ${roster.join(', ')}. Address another org's agent as "<org-name>:<role-id>".`,
16
16
  `If you need a human decision, call ask_human with your question, then end your turn — you'll receive the human's answer as a new message when it arrives. Do not call ask_human for anything you can resolve yourself.`,
17
17
  `Before starting substantial work, call org_recall to check what previous runs already learned or delivered — do not redo finished work.`,
18
+ `The user's documents (notes, handbooks, specs) are searchable with knowledge_search — ground your work in them instead of guessing; results labeled [global] come from the user's personal cross-project brain.`,
18
19
  `When you receive a message, act on it, then org_send your result to the requester.`,
19
20
  isCoordinator
20
21
  ? `When the org's goal for this run is achieved (or clearly can't be), call org_complete exactly once with the outcome and a concise summary of what was done — it is recorded in the org's run history and briefed to the next run. Then end your turn.`
@@ -56,6 +57,10 @@ async function runOneSession(opts) {
56
57
  name: 'org',
57
58
  version: '1.0.0',
58
59
  tools: [
60
+ ...(opts.searchKnowledge ? [tool('knowledge_search', 'Semantic search over the user\'s Second Brain: this project\'s indexed documents plus their personal cross-project global brain. Use to ground work in the user\'s actual notes, handbooks, and documents.', { query: z.string() }, async (args) => {
61
+ const text = await opts.searchKnowledge(role.id, args.query);
62
+ return { content: [{ type: 'text', text }] };
63
+ })] : []),
59
64
  ...(opts.recall ? [tool('org_recall', 'Search this org\'s accumulated memory from previous runs (outcomes, decisions, learnings). Use before starting work that may already have been done.', { query: z.string() }, async (args) => {
60
65
  const text = await opts.recall(role.id, args.query);
61
66
  return { content: [{ type: 'text', text }] };
@@ -1917,6 +1917,11 @@ select.pb-cfg-inp { padding: 4px 7px; cursor: pointer; }
1917
1917
  <div class="filter-bar" style="margin-bottom:6px">
1918
1918
  <input class="filter-input" id="sb-search-input" type="text" placeholder="Ask your Second Brain — semantic search by meaning…"
1919
1919
  onkeydown="if(event.key==='Enter')secondBrainSearch()">
1920
+ <select id="sb-search-scope" class="btn" title="Which brain to search" style="padding:3px 6px">
1921
+ <option value="all" selected>project + global</option>
1922
+ <option value="project">project only</option>
1923
+ <option value="global">global only</option>
1924
+ </select>
1920
1925
  <button class="btn" onclick="secondBrainSearch()" title="Semantic search (warm local model)">⌕ Search</button>
1921
1926
  </div>
1922
1927
  <div id="sb-search-results" style="display:none;margin-bottom:12px"></div>
@@ -13609,10 +13614,11 @@ async function secondBrainSearch() {
13609
13614
  box.style.display = 'block';
13610
13615
  box.innerHTML = '<div class="loading-txt">Searching…</div>';
13611
13616
  try {
13617
+ const scopeSel = document.getElementById('sb-search-scope');
13612
13618
  const r = await fetch('/api/knowledge/search', {
13613
13619
  method: 'POST',
13614
13620
  headers: { 'Content-Type': 'application/json' },
13615
- body: JSON.stringify({ query: q, limit: 5 }),
13621
+ body: JSON.stringify({ query: q, limit: 5, scope: scopeSel ? scopeSel.value : 'all' }),
13616
13622
  });
13617
13623
  if (!r.ok) throw new Error('HTTP ' + r.status);
13618
13624
  const data = await r.json();
@@ -13625,8 +13631,11 @@ async function secondBrainSearch() {
13625
13631
  '<div style="font-size:10px;letter-spacing:0.07em;text-transform:uppercase;color:var(--text-xs);margin-bottom:6px">' +
13626
13632
  results.length + ' result(s) · ' + esc(data.method || '') + '</div>' +
13627
13633
  results.map(function (res) {
13634
+ var srcTag = (res.tags || []).find(function (t) { return t.indexOf('src:') === 0; });
13635
+ var label = srcTag ? srcTag.slice(4).split('/').slice(-2).join('/') : (res.key || '');
13628
13636
  return '<div style="border:1px solid var(--border);border-radius:8px;padding:8px 10px;margin-bottom:6px">' +
13629
- '<div style="font-size:10px;color:var(--text-lo);margin-bottom:3px">' + esc(res.key || '') +
13637
+ '<div style="font-size:10px;color:var(--text-lo);margin-bottom:3px">' + esc(label) +
13638
+ (res.global ? ' <span style="color:var(--accent-dim,#888);border:1px solid var(--border);border-radius:4px;padding:0 4px">global</span>' : '') +
13630
13639
  ' <span style="color:var(--accent)">' + (typeof res.score === 'number' ? res.score.toFixed(2) : '') + '</span></div>' +
13631
13640
  '<div style="font-size:12px;white-space:pre-wrap;max-height:120px;overflow:hidden">' + esc(String(res.content || '').slice(0, 600)) + '</div>' +
13632
13641
  '</div>';
@@ -1035,6 +1035,41 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
1035
1035
  .catch(() => { /* warm-up is best-effort */ });
1036
1036
  }, 3000);
1037
1037
  if (_warmTimer.unref) _warmTimer.unref();
1038
+
1039
+ // ── Second Brain live ingestion ──────────────────────────────────
1040
+ // This server is the one long-lived local process AND holds the warm
1041
+ // embedding model — so it watches for document changes and ingests
1042
+ // in-process within seconds, instead of waiting for the next session
1043
+ // start. Best-effort: recursive fs.watch is unsupported on some
1044
+ // platforms/volumes; the session-start reindex remains the backstop.
1045
+ try {
1046
+ const _sbDocExts = new Set(['.md', '.txt', '.pdf', '.docx']);
1047
+ const _sbSkip = /(^|\/)(node_modules|\.git|dist|\.monomind|\.claude|\.next|__pycache__|\.venv|vendor)(\/|$)/;
1048
+ const _sbPending = new Map(); // file -> debounce timer
1049
+ const _sbRoot = path.resolve(projectDir || process.cwd());
1050
+ const _sbWatcher = fs.watch(_sbRoot, { recursive: true }, (_evt, rel) => {
1051
+ try {
1052
+ if (!rel) return;
1053
+ const relStr = String(rel);
1054
+ if (_sbSkip.test(relStr) || relStr.startsWith('.')) return;
1055
+ if (!_sbDocExts.has(path.extname(relStr).toLowerCase())) return;
1056
+ const full = path.join(_sbRoot, relStr);
1057
+ clearTimeout(_sbPending.get(full));
1058
+ _sbPending.set(full, setTimeout(async () => {
1059
+ _sbPending.delete(full);
1060
+ try {
1061
+ if (!fs.existsSync(full)) return; // deleted — session-start reindex handles removal
1062
+ const pipeline = await import('../knowledge/document-pipeline.js');
1063
+ const r = await pipeline.ingestDocument(full, 'shared', _sbRoot);
1064
+ if (r.chunksIndexed > 0 && !r.skipped) {
1065
+ console.log(`[knowledge] live-ingested ${path.basename(full)} (${r.chunksIndexed} chunks)`);
1066
+ }
1067
+ } catch (_) { /* single-file ingest failure never matters here */ }
1068
+ }, 5000));
1069
+ } catch (_) { /* watcher callback must never throw */ }
1070
+ });
1071
+ activeWatchers.push(_sbWatcher);
1072
+ } catch (_) { /* recursive watch unavailable — session-start reindex covers it */ }
1038
1073
  }
1039
1074
  } catch (_) { /* non-fatal */ }
1040
1075
 
@@ -5950,11 +5985,21 @@ new Sigma(g,document.getElementById('g'),{renderEdgeLabels:false,labelColor:{col
5950
5985
  res.end('{"error":"knowledge bridge unavailable"}');
5951
5986
  return;
5952
5987
  }
5953
- const out = await bridge.bridgeSearchEntries({ query, namespace, limit });
5988
+ // scope: project | global | all (default all) project results get a
5989
+ // small tie boost; global hits are flagged so callers can show origin.
5990
+ const scope = payload.scope === 'project' || payload.scope === 'global' ? payload.scope : 'all';
5991
+ const [proj, glob] = await Promise.all([
5992
+ scope !== 'global' ? bridge.bridgeSearchEntries({ query, namespace, limit }).catch(() => null) : null,
5993
+ scope !== 'project' ? bridge.bridgeSearchEntries({ query, namespace: 'knowledge:global', limit, dbPath: '@global' }).catch(() => null) : null,
5994
+ ]);
5995
+ const merged = [
5996
+ ...(proj?.results || []).map(r => ({ key: r.key, content: String(r.content || '').slice(0, 2000), score: r.score + 0.05, global: false, tags: r.tags })),
5997
+ ...(glob?.results || []).map(r => ({ key: r.key, content: String(r.content || '').slice(0, 2000), score: r.score, global: true, tags: r.tags })),
5998
+ ].sort((a, b) => b.score - a.score).slice(0, limit);
5954
5999
  res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
5955
6000
  res.end(JSON.stringify({
5956
- method: out?.searchMethod || 'none',
5957
- results: (out?.results || []).map(r => ({ key: r.key, content: String(r.content || '').slice(0, 2000), score: r.score })),
6001
+ method: proj?.searchMethod || glob?.searchMethod || 'none',
6002
+ results: merged,
5958
6003
  }));
5959
6004
  } catch (err) {
5960
6005
  res.writeHead(500, { 'Content-Type': 'application/json' });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monoes/monomindcli",
3
- "version": "2.3.4",
3
+ "version": "2.5.0",
4
4
  "type": "module",
5
5
  "description": "CLI engine for Monomind — 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",