monomind 2.3.3 → 2.4.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.
Files changed (45) hide show
  1. package/README.md +37 -4
  2. package/package.json +1 -1
  3. package/packages/@monomind/cli/.claude/commands/mastermind/{approve.md → approvev1.md} +12 -9
  4. package/packages/@monomind/cli/.claude/commands/mastermind/help.md +18 -0
  5. package/packages/@monomind/cli/.claude/commands/mastermind/master.md +3 -3
  6. package/packages/@monomind/cli/.claude/commands/mastermind/runorg.md +19 -107
  7. package/packages/@monomind/cli/.claude/commands/mastermind/runorgv1.md +159 -0
  8. package/packages/@monomind/cli/.claude/helpers/handlers/route-handler.cjs +50 -4
  9. package/packages/@monomind/cli/.claude/helpers/handlers/session-restore-handler.cjs +25 -18
  10. package/packages/@monomind/cli/.claude/skills/mastermind-skills/agents.md +5 -1
  11. package/packages/@monomind/cli/.claude/skills/mastermind-skills/{approve.md → approvev1.md} +8 -5
  12. package/packages/@monomind/cli/.claude/skills/mastermind-skills/env.md +7 -2
  13. package/packages/@monomind/cli/.claude/skills/mastermind-skills/{heartbeat.md → heartbeatv1.md} +7 -4
  14. package/packages/@monomind/cli/.claude/skills/mastermind-skills/inbox.md +14 -2
  15. package/packages/@monomind/cli/.claude/skills/mastermind-skills/instance.md +3 -1
  16. package/packages/@monomind/cli/.claude/skills/mastermind-skills/org-settings.md +4 -1
  17. package/packages/@monomind/cli/.claude/skills/mastermind-skills/orgs.md +14 -13
  18. package/packages/@monomind/cli/.claude/skills/mastermind-skills/orgstatus.md +19 -8
  19. package/packages/@monomind/cli/.claude/skills/mastermind-skills/projects.md +3 -0
  20. package/packages/@monomind/cli/.claude/skills/mastermind-skills/runorg.md +37 -725
  21. package/packages/@monomind/cli/.claude/skills/mastermind-skills/runorgv1.md +731 -0
  22. package/packages/@monomind/cli/.claude/skills/mastermind-skills/stoporg.md +10 -3
  23. package/packages/@monomind/cli/.claude/skills/mastermind-skills/tasks.md +4 -1
  24. package/packages/@monomind/cli/README.md +37 -4
  25. package/packages/@monomind/cli/dist/src/commands/cleanup.js +30 -8
  26. package/packages/@monomind/cli/dist/src/commands/doc.js +36 -8
  27. package/packages/@monomind/cli/dist/src/commands/doctor-project-checks.js +23 -10
  28. package/packages/@monomind/cli/dist/src/commands/org-observe.js +30 -26
  29. package/packages/@monomind/cli/dist/src/commands/org.js +52 -2
  30. package/packages/@monomind/cli/dist/src/index.js +7 -0
  31. package/packages/@monomind/cli/dist/src/knowledge/document-pipeline.d.ts +2 -0
  32. package/packages/@monomind/cli/dist/src/knowledge/document-pipeline.js +148 -45
  33. package/packages/@monomind/cli/dist/src/memory/memory-bridge.d.ts +9 -0
  34. package/packages/@monomind/cli/dist/src/memory/memory-bridge.js +115 -68
  35. package/packages/@monomind/cli/dist/src/orgrt/daemon.js +45 -9
  36. package/packages/@monomind/cli/dist/src/orgrt/mailbox.d.ts +23 -0
  37. package/packages/@monomind/cli/dist/src/orgrt/mailbox.js +33 -1
  38. package/packages/@monomind/cli/dist/src/orgrt/migrate.d.ts +26 -0
  39. package/packages/@monomind/cli/dist/src/orgrt/migrate.js +111 -0
  40. package/packages/@monomind/cli/dist/src/orgrt/reporting.js +8 -1
  41. package/packages/@monomind/cli/dist/src/orgrt/session.js +8 -2
  42. package/packages/@monomind/cli/dist/src/ui/dashboard.html +44 -0
  43. package/packages/@monomind/cli/dist/src/ui/orgs.html +2 -2
  44. package/packages/@monomind/cli/dist/src/ui/server.mjs +25 -4
  45. package/packages/@monomind/cli/package.json +2 -2
@@ -63,6 +63,12 @@ export class OrgDaemon {
63
63
  const running = { def, run, bus, agents: new Map(), busEvents: () => [...collected] };
64
64
  this.orgs.set(name, running);
65
65
  const perRoleBudget = Math.floor((def.run_config.budget_tokens ?? 1_000_000) / def.roles.length);
66
+ // Single boss-selection rule for kickoff AND org_complete gating — the
67
+ // session layer previously keyed the tool on reports_to===null while the
68
+ // kickoff went to (type==='boss' || reports_to===null || roles[0]), so a
69
+ // fallback-selected boss could be told to call org_complete without having
70
+ // the tool.
71
+ const bossRole = def.roles.find(r => r.type === 'boss' || r.reports_to === null) ?? def.roles[0];
66
72
  for (const role of def.roles) {
67
73
  const mailbox = new Mailbox();
68
74
  const policy = new PolicyEngine(role.id, { maxTokens: perRoleBudget, ...(role.policy ?? {}) }, bus, cwd);
@@ -72,9 +78,11 @@ export class OrgDaemon {
72
78
  maxTurns: def.run_config.max_turns_per_message,
73
79
  deliver: (from, to, subject, body) => this.deliver(name, from, to, subject, body),
74
80
  askHuman: (r, question) => this.askHuman(name, r, question),
75
- onComplete: (r, outcome, summary) => {
76
- bus.emit({ type: 'status', from: r, reason: 'org-complete', msg: `run outcome: ${outcome}`, data: { outcome, summary } });
77
- },
81
+ onComplete: role.id === bossRole.id
82
+ ? (r, outcome, summary) => {
83
+ bus.emit({ type: 'status', from: r, reason: 'org-complete', msg: `run outcome: ${outcome}`, data: { outcome, summary } });
84
+ }
85
+ : undefined,
78
86
  recall: async (r, q) => {
79
87
  const answer = await this.recallOrgMemory(name, def, q);
80
88
  bus.emit({ type: 'status', from: r, reason: 'org-recall', msg: `recall: ${q.slice(0, 80)}`, data: { hits: answer.hits } });
@@ -98,10 +106,18 @@ export class OrgDaemon {
98
106
  return;
99
107
  }
100
108
  catch (err) {
109
+ // Drop the crashed session's stale waker immediately: a push()
110
+ // during the backoff window must queue for the NEXT session, not
111
+ // wake the dead generator to swallow it.
112
+ mailbox.detach();
101
113
  const message = err instanceof Error ? err.message : String(err);
102
114
  const crash = () => {
103
115
  runtime.status = 'crashed';
104
116
  runtime.error = message;
117
+ // Close the mailbox so deliver()/receiveRemote() report a real
118
+ // error instead of pushing into a queue no session will read
119
+ // (and returning a false "delivered" receipt to the sender).
120
+ mailbox.close();
105
121
  bus.emit({
106
122
  type: 'audit', from: role.id,
107
123
  msg: `agent "${role.id}" crashed: ${message}`,
@@ -127,7 +143,7 @@ export class OrgDaemon {
127
143
  })();
128
144
  running.agents.set(role.id, runtime);
129
145
  }
130
- const boss = def.roles.find(r => r.type === 'boss' || r.reports_to === null) ?? def.roles[0];
146
+ const boss = bossRole;
131
147
  // Cross-run memory: brief the coordinator on the previous run so scheduled
132
148
  // orgs accumulate instead of starting cold every interval.
133
149
  const prev = readHistory(this.root, name).at(-1);
@@ -195,6 +211,10 @@ export class OrgDaemon {
195
211
  return `ERROR: unknown recipient "${toQualified}" (known: ${[...(targetOrg?.agents.keys() ?? this.orgs.keys())].join(', ')})`;
196
212
  }
197
213
  const targetAgent = targetOrg.agents.get(targetRole);
214
+ if (targetAgent.status === 'crashed') {
215
+ src?.bus.emit({ type: 'audit', from: fromRole, to: toQualified, msg: `undeliverable: ${subject}`, reason: 'recipient crashed (retry budget exhausted)' });
216
+ return `ERROR: recipient "${toQualified}" crashed and will not recover this run — message not delivered (${targetAgent.error ?? 'unknown error'})`;
217
+ }
198
218
  if (targetAgent.mailbox.isClosed) {
199
219
  // The org is mid-shutdown: mailboxes close before the org is removed
200
220
  // from `this.orgs`, so a message can arrive in that window. push() would
@@ -262,6 +282,8 @@ export class OrgDaemon {
262
282
  const agent = org.agents.get(toRole);
263
283
  if (!agent)
264
284
  return { ok: false, error: `role "${toRole}" not found in org "${toOrg}"` };
285
+ if (agent.status === 'crashed')
286
+ return { ok: false, error: `role "${toRole}" in org "${toOrg}" crashed and will not recover this run` };
265
287
  if (agent.mailbox.isClosed)
266
288
  return { ok: false, error: `role "${toRole}" in org "${toOrg}" is shutting down` };
267
289
  org.bus.emit({ type: 'xorg', from: fromQualified, to: `${toOrg}:${toRole}`, subject, msg: body });
@@ -364,6 +386,11 @@ export class OrgDaemon {
364
386
  // is already gone and no-ops instead of re-running the whole shutdown and
365
387
  // double-emitting 'org stopped' (duplicate org:complete/session:complete).
366
388
  this.orgs.delete(name);
389
+ // Capture THIS run's forwarder now: an autoWake-restart of the same org
390
+ // during the long tail below (agent wait, flush, history write) would
391
+ // register a NEW forwarder under the same name — settling/unsubscribing
392
+ // that one would sever the new run's dashboard stream.
393
+ const forwarder = this.forwarders.get(name);
367
394
  this.leases.get(name)?.stop();
368
395
  this.leases.delete(name);
369
396
  for (const a of org.agents.values())
@@ -402,14 +429,23 @@ export class OrgDaemon {
402
429
  // the "org stopped" event above triggers the forwarder's final org:complete /
403
430
  // session:complete POST — without waiting for it here, the CLI process can exit
404
431
  // (and kill the in-flight fetch) before that last event reaches the dashboard,
405
- // leaving the run stuck showing "running" forever.
406
- const forwarder = this.forwarders.get(name);
432
+ // leaving the run stuck showing "running" forever. Bounded: a stalled
433
+ // dashboard must not hang org shutdown indefinitely.
407
434
  if (forwarder) {
408
- await forwarder.settle();
435
+ await Promise.race([
436
+ forwarder.settle(),
437
+ new Promise(r => { const t = setTimeout(r, 5_000); t.unref?.(); }),
438
+ ]);
409
439
  forwarder.unsubscribe();
410
- this.forwarders.delete(name);
440
+ // Only remove from the map if it's still OURS — an autoWake-restart may
441
+ // have registered the new run's forwarder under this name meanwhile.
442
+ if (this.forwarders.get(name) === forwarder)
443
+ this.forwarders.delete(name);
411
444
  }
412
- this.persistState(name, 'stopped', org.run);
445
+ // Same guard for runtime.json: if a new run started during shutdown, its
446
+ // 'running' record must not be overwritten with this old run's 'stopped'.
447
+ if (!this.orgs.has(name))
448
+ this.persistState(name, 'stopped', org.run);
413
449
  }
414
450
  async stopAll() {
415
451
  await Promise.all([...this.orgs.keys()].map(n => this.stopOrg(n)));
@@ -17,9 +17,32 @@ export declare class Mailbox {
17
17
  private queue;
18
18
  private wake;
19
19
  private closed;
20
+ /** Bumped when a new stream() starts; stale generators see the mismatch and exit. */
21
+ private generation;
20
22
  push(text: string): void;
21
23
  close(): void;
22
24
  get isClosed(): boolean;
25
+ /**
26
+ * Detach the current waker WITHOUT resolving it. Called between sessions
27
+ * (maxTurns restart, crash backoff): the dead session's generator may still
28
+ * be parked on `wake` inside an abandoned next() — if a push() during that
29
+ * window resolved it, the stale generator would shift() the message and
30
+ * yield it into a promise nobody reads (silent loss, after deliver()
31
+ * already returned a "delivered" receipt). With the waker dropped, such a
32
+ * push only queues; the replacement session's stream() drains it. The
33
+ * parked stale generator is never resumed and becomes garbage with its
34
+ * session.
35
+ */
36
+ detach(): void;
37
+ /**
38
+ * One live generator at a time: each stream() call bumps `generation`, and
39
+ * a stale generator that ever resumes exits immediately without touching
40
+ * the queue. Values are shift()ed at yield time — once the consumer's
41
+ * next() resolves with a message it counts as delivered (matching SDK
42
+ * behavior: a session may consume a message and end without ever resuming
43
+ * the generator; redelivering would duplicate work and can livelock the
44
+ * restart loop).
45
+ */
23
46
  stream(sessionId?: string): AsyncGenerator<OrgUserMessage>;
24
47
  }
25
48
  //# sourceMappingURL=mailbox.d.ts.map
@@ -7,6 +7,8 @@ export class Mailbox {
7
7
  queue = [];
8
8
  wake = null;
9
9
  closed = false;
10
+ /** Bumped when a new stream() starts; stale generators see the mismatch and exit. */
11
+ generation = 0;
10
12
  push(text) {
11
13
  if (this.closed)
12
14
  return;
@@ -20,19 +22,49 @@ export class Mailbox {
20
22
  this.wake = null;
21
23
  }
22
24
  get isClosed() { return this.closed; }
25
+ /**
26
+ * Detach the current waker WITHOUT resolving it. Called between sessions
27
+ * (maxTurns restart, crash backoff): the dead session's generator may still
28
+ * be parked on `wake` inside an abandoned next() — if a push() during that
29
+ * window resolved it, the stale generator would shift() the message and
30
+ * yield it into a promise nobody reads (silent loss, after deliver()
31
+ * already returned a "delivered" receipt). With the waker dropped, such a
32
+ * push only queues; the replacement session's stream() drains it. The
33
+ * parked stale generator is never resumed and becomes garbage with its
34
+ * session.
35
+ */
36
+ detach() { this.wake = null; }
37
+ /**
38
+ * One live generator at a time: each stream() call bumps `generation`, and
39
+ * a stale generator that ever resumes exits immediately without touching
40
+ * the queue. Values are shift()ed at yield time — once the consumer's
41
+ * next() resolves with a message it counts as delivered (matching SDK
42
+ * behavior: a session may consume a message and end without ever resuming
43
+ * the generator; redelivering would duplicate work and can livelock the
44
+ * restart loop).
45
+ */
23
46
  async *stream(sessionId = '') {
47
+ const gen = ++this.generation;
48
+ // Drop (never resolve) any stale waker — see detach().
49
+ this.wake = null;
24
50
  while (true) {
25
51
  while (this.queue.length > 0) {
52
+ if (gen !== this.generation)
53
+ return; // superseded — leave the queue for the live generator
26
54
  yield {
27
55
  type: 'user',
28
56
  message: { role: 'user', content: this.queue.shift() },
29
57
  parent_tool_use_id: null,
30
58
  session_id: sessionId,
31
59
  };
60
+ if (gen !== this.generation)
61
+ return;
32
62
  }
33
- if (this.closed)
63
+ if (this.closed || gen !== this.generation)
34
64
  return;
35
65
  await new Promise(r => { this.wake = r; });
66
+ if (gen !== this.generation)
67
+ return;
36
68
  }
37
69
  }
38
70
  }
@@ -0,0 +1,26 @@
1
+ import { type OrgDef } from './types.js';
2
+ /** The structural invariants the runtime assumes but the Zod schema can't
3
+ * express: unique role ids, exactly one root (reports_to: null), every
4
+ * non-root's reports_to resolving to another role (and not itself), and a
5
+ * parseable schedule. Shared by `org validate` (validateAction) and
6
+ * `migrateOrgFile`, which refuses to write a migrated config that still
7
+ * fails these checks. */
8
+ export declare function checkOrgStructure(def: Pick<OrgDef, 'roles' | 'schedule'>): string[];
9
+ export declare function migrateOrgConfig(raw: Record<string, unknown>): {
10
+ def: Record<string, unknown>;
11
+ dropped: string[];
12
+ notes: string[];
13
+ };
14
+ /** Reads `cfgPath`, migrates it, and (unless already v2) backs up the
15
+ * original to `backupPath` and overwrites `cfgPath` with the migrated def.
16
+ * Throws on an unmigratable config (schema failure inside migrateOrgConfig,
17
+ * or a structural violation caught here) — caller decides how to report it.
18
+ * Refuses to write when the migrated def fails checkOrgStructure, so a v1
19
+ * config with e.g. all-null reports_to fails cleanly instead of silently
20
+ * writing a config the runtime can't start. */
21
+ export declare function migrateOrgFile(cfgPath: string, backupPath: string): {
22
+ status: 'migrated' | 'already-v2';
23
+ dropped: string[];
24
+ notes: string[];
25
+ };
26
+ //# sourceMappingURL=migrate.d.ts.map
@@ -0,0 +1,111 @@
1
+ // packages/@monomind/cli/src/orgrt/migrate.ts
2
+ /** v1 → v2 org config conversion. Pure transform + validation, plus the file
3
+ * IO orchestration for the `org migrate` subcommand — kept out of org.ts to
4
+ * stay under the 500-line file cap; org.ts's migrateAction is a thin wrapper
5
+ * that only does the name-validation / isOrgRunning guard and delegates here. */
6
+ import { existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
7
+ import { OrgDefSchema } from './types.js';
8
+ import { parseSchedule } from './scheduler.js';
9
+ const V1_TOP_LEVEL_KEYS = [
10
+ 'topology', 'consensus', 'strategy', 'maxAgents', 'communication',
11
+ 'board_id', 'todo_col_id', 'doing_col_id', 'done_col_id', 'loop',
12
+ 'version', 'channels', 'differentiation', 'outputDir', 'runBehavior', 'created',
13
+ ];
14
+ const V1_ROLE_KEYS = ['agent_type', 'delegates_to', 'board_id'];
15
+ /** The structural invariants the runtime assumes but the Zod schema can't
16
+ * express: unique role ids, exactly one root (reports_to: null), every
17
+ * non-root's reports_to resolving to another role (and not itself), and a
18
+ * parseable schedule. Shared by `org validate` (validateAction) and
19
+ * `migrateOrgFile`, which refuses to write a migrated config that still
20
+ * fails these checks. */
21
+ export function checkOrgStructure(def) {
22
+ const errors = [];
23
+ const ids = def.roles.map(r => r.id);
24
+ const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
25
+ if (dupes.length)
26
+ errors.push(`duplicate role id(s): ${[...new Set(dupes)].join(', ')}`);
27
+ const roots = def.roles.filter(r => r.reports_to === null);
28
+ if (roots.length === 0)
29
+ errors.push('no root role — exactly one role must have reports_to: null');
30
+ if (roots.length > 1)
31
+ errors.push(`multiple root roles (${roots.map(r => r.id).join(', ')}) — exactly one may have reports_to: null`);
32
+ for (const r of def.roles) {
33
+ if (r.reports_to !== null && !ids.includes(r.reports_to))
34
+ errors.push(`role "${r.id}": reports_to "${r.reports_to}" matches no role id`);
35
+ if (r.reports_to === r.id)
36
+ errors.push(`role "${r.id}" reports to itself`);
37
+ }
38
+ if (def.schedule != null && parseSchedule(def.schedule) === null)
39
+ errors.push(`schedule "${def.schedule}" is not parseable — use "<N>s", "<N>m", or "<N>h"`);
40
+ return errors;
41
+ }
42
+ export function migrateOrgConfig(raw) {
43
+ const def = { ...raw };
44
+ const dropped = [];
45
+ const notes = [];
46
+ // v1 loop → v2 schedule ("<N>m"); only when no v2 schedule already set
47
+ const loop = def['loop'];
48
+ if (loop && typeof loop.poll_interval_minutes === 'number' && def['schedule'] == null) {
49
+ def['schedule'] = `${loop.poll_interval_minutes}m`;
50
+ notes.push(`loop.poll_interval_minutes=${loop.poll_interval_minutes} → schedule "${def['schedule']}"`);
51
+ }
52
+ for (const k of V1_TOP_LEVEL_KEYS) {
53
+ if (k in def) {
54
+ delete def[k];
55
+ dropped.push(k);
56
+ }
57
+ }
58
+ if (Array.isArray(def['roles'])) {
59
+ const roleKeysDropped = new Set();
60
+ def['roles'] = def['roles'].map(role => {
61
+ const r = { ...role };
62
+ if (typeof r['agent_type'] === 'string') {
63
+ if (r['type'] == null || r['type'] === 'specialist') {
64
+ r['type'] = r['agent_type'];
65
+ notes.push(`role ${String(r['id'])}: agent_type → type`);
66
+ }
67
+ }
68
+ for (const k of V1_ROLE_KEYS) {
69
+ if (k in r) {
70
+ delete r[k];
71
+ roleKeysDropped.add(`roles[].${k}`);
72
+ }
73
+ }
74
+ return r;
75
+ });
76
+ dropped.push(...roleKeysDropped);
77
+ }
78
+ if (def['schedule'] === undefined)
79
+ def['schedule'] = null;
80
+ if (typeof def['status'] !== 'string')
81
+ def['status'] = 'stopped';
82
+ // Throws ZodError on an unmigratable config — caller surfaces it.
83
+ OrgDefSchema.parse(def);
84
+ return { def, dropped, notes };
85
+ }
86
+ /** Reads `cfgPath`, migrates it, and (unless already v2) backs up the
87
+ * original to `backupPath` and overwrites `cfgPath` with the migrated def.
88
+ * Throws on an unmigratable config (schema failure inside migrateOrgConfig,
89
+ * or a structural violation caught here) — caller decides how to report it.
90
+ * Refuses to write when the migrated def fails checkOrgStructure, so a v1
91
+ * config with e.g. all-null reports_to fails cleanly instead of silently
92
+ * writing a config the runtime can't start. */
93
+ export function migrateOrgFile(cfgPath, backupPath) {
94
+ const raw = JSON.parse(readFileSync(cfgPath, 'utf8'));
95
+ const result = migrateOrgConfig(raw);
96
+ const def = result.def;
97
+ const structuralErrors = checkOrgStructure(def);
98
+ if (structuralErrors.length) {
99
+ throw new Error(`migrated config still fails structural validation: ${structuralErrors.join('; ')}`);
100
+ }
101
+ if (result.dropped.length === 0 && result.notes.length === 0) {
102
+ return { status: 'already-v2', dropped: [], notes: [] };
103
+ }
104
+ if (!existsSync(backupPath))
105
+ writeFileSync(backupPath, JSON.stringify(raw, null, 2));
106
+ const tmpPath = `${cfgPath}.tmp`;
107
+ writeFileSync(tmpPath, JSON.stringify(result.def, null, 2));
108
+ renameSync(tmpPath, cfgPath);
109
+ return { status: 'migrated', dropped: result.dropped, notes: result.notes };
110
+ }
111
+ //# sourceMappingURL=migrate.js.map
@@ -123,5 +123,12 @@ export function formatEvent(e) {
123
123
  default: return `${t} ▪ ${from} ${e.type}: ${trim(e.msg ?? '')}`;
124
124
  }
125
125
  }
126
- const trim = (s, n = 120) => !s ? '' : s.length > n ? `${s.slice(0, n - 1)}…` : s.replace(/\n/g, ' ');
126
+ // Newlines stripped BEFORE truncation the truncated branch previously kept
127
+ // them, so long multi-line messages broke the one-line-per-event log format.
128
+ const trim = (s, n = 120) => {
129
+ if (!s)
130
+ return '';
131
+ const oneLine = s.replace(/\s*\n\s*/g, ' ');
132
+ return oneLine.length > n ? `${oneLine.slice(0, n - 1)}…` : oneLine;
133
+ };
127
134
  //# sourceMappingURL=reporting.js.map
@@ -39,6 +39,10 @@ export async function runAgentSession(opts) {
39
39
  // would skip that drain entirely.
40
40
  while (true) {
41
41
  await runOneSession(opts);
42
+ // The dead session's generator may still hold the waker — drop it so a
43
+ // push() before the next stream() starts only queues instead of being
44
+ // consumed by the abandoned generator (silent message loss).
45
+ mailbox.detach();
42
46
  if (mailbox.isClosed)
43
47
  return;
44
48
  opts.bus.emit({ type: 'status', from: opts.role.id, msg: 'session restarting (turn limit reached, mailbox still open)' });
@@ -48,7 +52,6 @@ export async function runAgentSession(opts) {
48
52
  async function runOneSession(opts) {
49
53
  const { org, role, bus, policy, mailbox, cwd, deliver } = opts;
50
54
  const queryFn = opts.queryFn ?? query;
51
- const isCoordinator = role.reports_to == null;
52
55
  const orgServer = createSdkMcpServer({
53
56
  name: 'org',
54
57
  version: '1.0.0',
@@ -57,7 +60,10 @@ async function runOneSession(opts) {
57
60
  const text = await opts.recall(role.id, args.query);
58
61
  return { content: [{ type: 'text', text }] };
59
62
  })] : []),
60
- ...(isCoordinator && opts.onComplete ? [tool('org_complete', 'Record the outcome of this run. Call exactly once, when the goal is achieved or clearly cannot be. The outcome and summary are persisted to the org run history and briefed to the next run.', { outcome: z.enum(['achieved', 'partial', 'failed']), summary: z.string() }, async (args) => {
63
+ // Gate purely on onComplete: the daemon passes it only to the role its
64
+ // boss-selection rule picked, so tool availability always matches the
65
+ // kickoff instruction (reports_to may be non-null for a fallback boss).
66
+ ...(opts.onComplete ? [tool('org_complete', 'Record the outcome of this run. Call exactly once, when the goal is achieved or clearly cannot be. The outcome and summary are persisted to the org run history and briefed to the next run.', { outcome: z.enum(['achieved', 'partial', 'failed']), summary: z.string() }, async (args) => {
61
67
  opts.onComplete(role.id, args.outcome, args.summary);
62
68
  return { content: [{ type: 'text', text: `outcome "${args.outcome}" recorded` }] };
63
69
  })] : []),
@@ -1914,6 +1914,12 @@ select.pb-cfg-inp { padding: 4px 7px; cursor: pointer; }
1914
1914
  <button class="btn chunk-build-btn" id="btn-build-docs" onclick="buildKnowledgeDocs()" title="Index .md/.mdx/.txt/.rst files">⊕ Build Docs</button>
1915
1915
  <span class="chunk-build-status" id="chunk-build-status"></span>
1916
1916
  </div>
1917
+ <div class="filter-bar" style="margin-bottom:6px">
1918
+ <input class="filter-input" id="sb-search-input" type="text" placeholder="Ask your Second Brain — semantic search by meaning…"
1919
+ onkeydown="if(event.key==='Enter')secondBrainSearch()">
1920
+ <button class="btn" onclick="secondBrainSearch()" title="Semantic search (warm local model)">⌕ Search</button>
1921
+ </div>
1922
+ <div id="sb-search-results" style="display:none;margin-bottom:12px"></div>
1917
1923
  <div class="filter-bar">
1918
1924
  <input class="filter-input" type="text" placeholder="Filter chunks…" oninput="filterChunks(this.value)">
1919
1925
  <button class="btn" onclick="_chunksLoaded=false;loadChunksTab()" title="Refresh">↺</button>
@@ -13592,6 +13598,44 @@ async function cleanSwarmData() {
13592
13598
  // ── chunks memory tab ──────────────────────────────────────
13593
13599
  let _chunks = [], _chunksLoaded = false, _editChunkId = null;
13594
13600
 
13601
+ // Second Brain semantic search — hits the warm /api/knowledge/search endpoint
13602
+ // (the server holds the local embedding model hot; ~60ms per query).
13603
+ async function secondBrainSearch() {
13604
+ const input = document.getElementById('sb-search-input');
13605
+ const box = document.getElementById('sb-search-results');
13606
+ if (!input || !box) return;
13607
+ const q = input.value.trim();
13608
+ if (!q) { box.style.display = 'none'; return; }
13609
+ box.style.display = 'block';
13610
+ box.innerHTML = '<div class="loading-txt">Searching…</div>';
13611
+ try {
13612
+ const r = await fetch('/api/knowledge/search', {
13613
+ method: 'POST',
13614
+ headers: { 'Content-Type': 'application/json' },
13615
+ body: JSON.stringify({ query: q, limit: 5 }),
13616
+ });
13617
+ if (!r.ok) throw new Error('HTTP ' + r.status);
13618
+ const data = await r.json();
13619
+ const results = data.results || [];
13620
+ if (!results.length) {
13621
+ box.innerHTML = '<div class="empty" style="padding:10px">No matches (' + esc(data.method || '?') + '). Ingest documents with: <code>monomind doc ingest ./path</code></div>';
13622
+ return;
13623
+ }
13624
+ box.innerHTML =
13625
+ '<div style="font-size:10px;letter-spacing:0.07em;text-transform:uppercase;color:var(--text-xs);margin-bottom:6px">' +
13626
+ results.length + ' result(s) · ' + esc(data.method || '') + '</div>' +
13627
+ results.map(function (res) {
13628
+ 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 || '') +
13630
+ ' <span style="color:var(--accent)">' + (typeof res.score === 'number' ? res.score.toFixed(2) : '') + '</span></div>' +
13631
+ '<div style="font-size:12px;white-space:pre-wrap;max-height:120px;overflow:hidden">' + esc(String(res.content || '').slice(0, 600)) + '</div>' +
13632
+ '</div>';
13633
+ }).join('');
13634
+ } catch (e) {
13635
+ box.innerHTML = '<div class="empty" style="padding:10px">Search failed: ' + esc(e.message) + '</div>';
13636
+ }
13637
+ }
13638
+
13595
13639
  async function loadChunksTab() {
13596
13640
  if (_chunksLoaded) return;
13597
13641
  const grid = document.getElementById('chunks-grid');
@@ -1782,12 +1782,12 @@ async function loadRunHistory() {
1782
1782
  ${runs.slice(0, 10).map(h => `
1783
1783
  <div style="font-size:9px;padding:5px 0;border-bottom:1px solid oklch(50% 0 0 / 0.1);display:flex;gap:10px;align-items:baseline">
1784
1784
  <span style="color:var(--dim);white-space:nowrap">${esc(h.run || '?')}</span>
1785
- <span style="color:var(--muted);white-space:nowrap">${h.durationMs != null ? Math.round(h.durationMs / 1000) + 's' : '—'} · ${h.totalTokens || 0} tok</span>
1785
+ <span style="color:var(--muted);white-space:nowrap">${Number.isFinite(Number(h.durationMs)) && h.durationMs != null ? Math.round(Number(h.durationMs) / 1000) + 's' : '—'} · ${Number(h.totalTokens) || 0} tok</span>
1786
1786
  ${h.outcome
1787
1787
  ? `<span style="color:${outcomeColor(h.outcome.status)}">${esc(h.outcome.status.toUpperCase())}</span>
1788
1788
  <span style="color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(h.outcome.summary || '')}</span>`
1789
1789
  : `<span style="color:var(--dim)">NO OUTCOME</span>`}
1790
- ${h.crashes && h.crashes.length ? `<span style="color:var(--red)">✗ ${h.crashes.length} crash</span>` : ''}
1790
+ ${Array.isArray(h.crashes) && h.crashes.length ? `<span style="color:var(--red)">✗ ${Number(h.crashes.length)} crash</span>` : ''}
1791
1791
  </div>`).join('')}
1792
1792
  </div>`;
1793
1793
  } catch (_) { el.innerHTML = ''; }
@@ -5920,8 +5920,19 @@ new Sigma(g,document.getElementById('g'),{renderEdgeLabels:false,labelColor:{col
5920
5920
  // when this endpoint is cold/absent. Fully local — model + store on disk.
5921
5921
  if (req.method === 'POST' && url === '/api/knowledge/search') {
5922
5922
  let body = '';
5923
- req.on('data', c => { body += c; });
5923
+ let overLimit = false;
5924
+ req.on('data', c => {
5925
+ if (overLimit) return;
5926
+ body += c;
5927
+ if (body.length > 64 * 1024) { // queries are prompts, not documents
5928
+ overLimit = true;
5929
+ res.writeHead(413, { 'Content-Type': 'application/json' });
5930
+ res.end('{"error":"request body too large"}');
5931
+ req.destroy();
5932
+ }
5933
+ });
5924
5934
  req.on('end', async () => {
5935
+ if (overLimit) return;
5925
5936
  try {
5926
5937
  const payload = JSON.parse(body || '{}');
5927
5938
  const query = String(payload.query || '').slice(0, 2000);
@@ -5939,11 +5950,21 @@ new Sigma(g,document.getElementById('g'),{renderEdgeLabels:false,labelColor:{col
5939
5950
  res.end('{"error":"knowledge bridge unavailable"}');
5940
5951
  return;
5941
5952
  }
5942
- const out = await bridge.bridgeSearchEntries({ query, namespace, limit });
5953
+ // scope: project | global | all (default all) project results get a
5954
+ // small tie boost; global hits are flagged so callers can show origin.
5955
+ const scope = payload.scope === 'project' || payload.scope === 'global' ? payload.scope : 'all';
5956
+ const [proj, glob] = await Promise.all([
5957
+ scope !== 'global' ? bridge.bridgeSearchEntries({ query, namespace, limit }).catch(() => null) : null,
5958
+ scope !== 'project' ? bridge.bridgeSearchEntries({ query, namespace: 'knowledge:global', limit, dbPath: '@global' }).catch(() => null) : null,
5959
+ ]);
5960
+ const merged = [
5961
+ ...(proj?.results || []).map(r => ({ key: r.key, content: String(r.content || '').slice(0, 2000), score: r.score + 0.05, global: false, tags: r.tags })),
5962
+ ...(glob?.results || []).map(r => ({ key: r.key, content: String(r.content || '').slice(0, 2000), score: r.score, global: true, tags: r.tags })),
5963
+ ].sort((a, b) => b.score - a.score).slice(0, limit);
5943
5964
  res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
5944
5965
  res.end(JSON.stringify({
5945
- method: out?.searchMethod || 'none',
5946
- results: (out?.results || []).map(r => ({ key: r.key, content: String(r.content || '').slice(0, 2000), score: r.score })),
5966
+ method: proj?.searchMethod || glob?.searchMethod || 'none',
5967
+ results: merged,
5947
5968
  }));
5948
5969
  } catch (err) {
5949
5970
  res.writeHead(500, { 'Content-Type': 'application/json' });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monoes/monomindcli",
3
- "version": "2.3.3",
3
+ "version": "2.4.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",
@@ -105,7 +105,7 @@
105
105
  },
106
106
  "optionalDependencies": {
107
107
  "@huggingface/transformers": "^3.8.1",
108
- "@monoes/memory": "^1.0.7",
108
+ "@monoes/memory": "^1.0.8",
109
109
  "@monomind/hooks": "*",
110
110
  "@monomind/mcp": "*",
111
111
  "@monomind/routing": "*",