spectoflow 0.27.2 → 0.29.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/lib/brain.js ADDED
@@ -0,0 +1,257 @@
1
+ 'use strict';
2
+ /*
3
+ * The second brain — what spectoflow has learned about the user, shared by all their projects.
4
+ * One markdown file, ~/.spectoflow/brain.md (never inside a project, never copied), read and written
5
+ * by the dashboard page, the `spectoflow mcp` server and the `::spectoflow learn` run line. Zero
6
+ * dependency.
7
+ *
8
+ * # Second brain
9
+ * ## Profile
10
+ * - Scrum master and full-stack developer <!-- id:b7k2 by:agent at:2026-09-16 -->
11
+ * ## To confirm
12
+ * - [preferences] Prefers pnpm over npm <!-- id:b7k6 by:agent at:2026-09-16 -->
13
+ *
14
+ * The file is the user's too: every line spectoflow doesn't change is written back byte for byte —
15
+ * titles, blank lines, comments, code blocks, unknown sections and their order, hand-written entries.
16
+ * Writers in different processes (hub, MCP servers, runs) take a lock file, then write-then-rename.
17
+ */
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const crypto = require('crypto');
21
+ const globalConfig = require('./global-config');
22
+
23
+ const CATEGORIES = ['profile', 'preferences', 'workflow', 'avoid'];
24
+ const HEADINGS = { profile: 'Profile', preferences: 'Preferences', workflow: 'Working style', avoid: 'Avoid' };
25
+ const PENDING = 'To confirm';
26
+ const MAX_TEXT = 500;
27
+ const FALLBACK_CATEGORY = 'preferences';
28
+
29
+ function brainPath() { return path.join(globalConfig.homeDir(), 'brain.md'); }
30
+
31
+ const normCategory = (c) => (CATEGORIES.includes(String(c || '').trim().toLowerCase()) ? String(c).trim().toLowerCase() : FALLBACK_CATEGORY);
32
+ // One line, no HTML-comment delimiters (they would break the metadata), capped.
33
+ function cleanText(t) {
34
+ return String(t == null ? '' : t).replace(/<!--|-->/g, '').replace(/\s+/g, ' ').trim().slice(0, MAX_TEXT);
35
+ }
36
+ const newId = () => 'b' + Date.now().toString(36) + crypto.randomBytes(3).toString('hex');
37
+ // A hand-written line has no id: derive a stable one. `n` tells identical lines of a category apart.
38
+ const derivedId = (category, text, n) => 'h' + crypto.createHash('sha1').update(`${category}\n${text}\n${n}`).digest('hex').slice(0, 10);
39
+ const today = () => new Date().toISOString().slice(0, 10);
40
+ const isBlank = (it) => it.raw !== undefined && !it.raw.trim();
41
+
42
+ function headingKind(title) {
43
+ const t = title.trim().toLowerCase();
44
+ if (t === PENDING.toLowerCase()) return { kind: 'pending' };
45
+ const cat = CATEGORIES.find((c) => c === t || HEADINGS[c].toLowerCase() === t);
46
+ return cat ? { kind: 'category', id: cat } : { kind: 'unknown' };
47
+ }
48
+
49
+ const ENTRY_RE = /^-\s+(?:\[([\w-]+)\]\s+)?(.*?)\s*(?:<!--\s*(.*?)\s*-->)?\s*$/;
50
+ function parseMeta(s) {
51
+ const out = {};
52
+ for (const m of String(s || '').matchAll(/(\w+):(\S+)/g)) out[m[1]] = m[2];
53
+ return out;
54
+ }
55
+
56
+ // → { title, blocks: [{ kind: preamble|category|pending|unknown, id?, heading?, items: [...] }] }
57
+ // item = { raw } (a line kept verbatim) or an entry { id, category, text, by, at, status, line }.
58
+ function parse(text) {
59
+ const lines = String(text || '').split(/\r?\n/);
60
+ while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
61
+ const model = { title: null, eol: /\r\n/.test(String(text || '')) ? '\r\n' : '\n', blocks: [{ kind: 'preamble', items: [] }] };
62
+ let block = model.blocks[0], inFence = false;
63
+ const seen = new Map();
64
+ for (const line of lines) {
65
+ if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; block.items.push({ raw: line }); continue; }
66
+ if (inFence) { block.items.push({ raw: line }); continue; }
67
+ if (model.title === null && model.blocks.length === 1 && block.items.every(isBlank) && /^#\s+/.test(line)) { model.title = line; continue; }
68
+ const h = line.match(/^##\s+(.*)$/);
69
+ if (h) {
70
+ block = { ...headingKind(h[1]), heading: line, items: [] };
71
+ model.blocks.push(block);
72
+ continue;
73
+ }
74
+ const m = (block.kind === 'category' || block.kind === 'pending') && line.match(ENTRY_RE);
75
+ const meta = m ? parseMeta(m[3]) : null;
76
+ // A trailing comment is never part of the fact (a user's own comment stays hidden: the line itself is
77
+ // written back verbatim while the entry is untouched; editing the entry replaces the line).
78
+ const body = m ? m[2] : '';
79
+ if (m && cleanText(body)) {
80
+ const pending = block.kind === 'pending';
81
+ const category = pending ? normCategory(m[1]) : block.id;
82
+ const entryText = cleanText(pending || !m[1] ? body : `[${m[1]}] ${body}`);
83
+ let id = meta.id;
84
+ if (!id) { const k = `${category}\n${entryText}`; const n = seen.get(k) || 0; seen.set(k, n + 1); id = derivedId(category, entryText, n); }
85
+ block.items.push({ id, category, text: entryText, by: meta.by || 'user', at: meta.at || null, status: pending ? 'pending' : 'confirmed', line });
86
+ } else {
87
+ block.items.push({ raw: line });
88
+ }
89
+ }
90
+ return model;
91
+ }
92
+
93
+ function entryLine(e) {
94
+ const meta = `id:${e.id} by:${e.by}${e.at ? ' at:' + e.at : ''}`;
95
+ return `- ${e.status === 'pending' ? `[${e.category}] ` : ''}${e.text} <!-- ${meta} -->`;
96
+ }
97
+ function serialize(model) {
98
+ const out = [model.title || '# Second brain'];
99
+ for (const b of model.blocks) {
100
+ if (b.kind === 'pending' && !b.items.some((it) => it.raw === undefined) && b.items.every(isBlank)) continue;
101
+ if (b.kind !== 'preamble') {
102
+ if (out[out.length - 1].trim()) out.push(''); // a section always follows a blank line
103
+ out.push(b.heading || `## ${b.kind === 'pending' ? PENDING : HEADINGS[b.id]}`);
104
+ }
105
+ for (const it of b.items) out.push(it.raw !== undefined ? it.raw : (it.line && !it.dirty ? it.line : entryLine(it)));
106
+ }
107
+ while (out.length > 1 && !out[out.length - 1].trim()) out.pop();
108
+ const eol = model.eol || '\n';
109
+ return out.join(eol) + eol;
110
+ }
111
+
112
+ function emptyModel() {
113
+ return { title: null, blocks: [{ kind: 'preamble', items: [] }, ...CATEGORIES.map((id) => ({ kind: 'category', id, heading: null, items: [] }))] };
114
+ }
115
+ function load(file = brainPath()) {
116
+ let text = '';
117
+ try { text = fs.readFileSync(file, 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
118
+ return text.trim() ? parse(text) : emptyModel();
119
+ }
120
+ function save(model, file = brainPath()) {
121
+ fs.mkdirSync(path.dirname(file), { recursive: true });
122
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
123
+ fs.writeFileSync(tmp, serialize(model));
124
+ fs.renameSync(tmp, file);
125
+ }
126
+
127
+ // Cross-process lock: the hub, any number of `spectoflow mcp` processes and runs may write in the
128
+ // same instant. A lock older than 10s is a crashed writer's and is taken over.
129
+ const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
130
+ function withLock(file, fn) {
131
+ fs.mkdirSync(path.dirname(file), { recursive: true });
132
+ const lock = `${file}.lock`, deadline = Date.now() + 3000;
133
+ for (;;) {
134
+ try { fs.writeFileSync(lock, String(process.pid), { flag: 'wx' }); break; }
135
+ catch (e) {
136
+ if (e.code !== 'EEXIST') throw e;
137
+ try { if (Date.now() - fs.statSync(lock).mtimeMs > 10000) { fs.unlinkSync(lock); continue; } } catch (_) {}
138
+ if (Date.now() > deadline) throw Object.assign(new Error('The second brain is busy, try again.'), { status: 503 });
139
+ sleepSync(15);
140
+ }
141
+ }
142
+ try { return fn(); } finally { try { fs.unlinkSync(lock); } catch (_) {} }
143
+ }
144
+ // Read, change, save — under the lock. `fn` returns { result, changed }.
145
+ function mutate(file, fn) {
146
+ return withLock(file, () => {
147
+ const model = load(file);
148
+ const { result, changed } = fn(model);
149
+ if (changed) save(model, file);
150
+ return result;
151
+ });
152
+ }
153
+
154
+ const entriesOf = (model) => model.blocks.flatMap((b) => b.items.filter((it) => it.raw === undefined));
155
+ function find(model, id) {
156
+ for (const b of model.blocks) {
157
+ const i = b.items.findIndex((it) => it.raw === undefined && it.id === id);
158
+ if (i >= 0) return { block: b, i, entry: b.items[i] };
159
+ }
160
+ return null;
161
+ }
162
+ // Append before the block's trailing blank lines, so the spacing before the next heading stays.
163
+ function append(block, entry) {
164
+ let i = block.items.length;
165
+ while (i > 0 && isBlank(block.items[i - 1])) i--;
166
+ block.items.splice(i, 0, entry);
167
+ }
168
+ function blockFor(model, status, category) {
169
+ const want = status === 'pending' ? (b) => b.kind === 'pending' : (b) => b.kind === 'category' && b.id === category;
170
+ let b = model.blocks.find(want);
171
+ if (!b) {
172
+ b = status === 'pending' ? { kind: 'pending', heading: null, items: [] } : { kind: 'category', id: category, heading: null, items: [] };
173
+ const at = status === 'pending' ? -1 : model.blocks.findIndex((x) => x.kind === 'pending');
174
+ if (at < 0) model.blocks.push(b); else model.blocks.splice(at, 0, b);
175
+ }
176
+ return b;
177
+ }
178
+ const notFound = () => Object.assign(new Error('Entry not found.'), { status: 404 });
179
+ const emptyText = () => Object.assign(new Error('Text is required.'), { status: 400 });
180
+
181
+ // { entries (confirmed, in category order), pending, autoAdd }
182
+ function read(file = brainPath()) {
183
+ const all = entriesOf(load(file));
184
+ return {
185
+ entries: CATEGORIES.flatMap((c) => all.filter((e) => e.status === 'confirmed' && e.category === c)),
186
+ pending: all.filter((e) => e.status === 'pending'),
187
+ autoAdd: autoAdd(),
188
+ };
189
+ }
190
+
191
+ // Adds one fact. status 'confirmed' or 'pending'. A case-insensitive duplicate of any entry is not
192
+ // added again: → { duplicate: true, entry: <the existing one> }.
193
+ function add({ category, text, by = 'user', status = 'confirmed' }, file = brainPath()) {
194
+ const t = cleanText(text);
195
+ if (!t) throw emptyText();
196
+ return mutate(file, (model) => {
197
+ const existing = entriesOf(model).find((e) => e.text.toLowerCase() === t.toLowerCase());
198
+ if (existing) return { result: { duplicate: true, entry: existing }, changed: false };
199
+ const entry = { id: newId(), category: normCategory(category), text: t, by, at: today(), status: status === 'pending' ? 'pending' : 'confirmed' };
200
+ append(blockFor(model, entry.status, entry.category), entry);
201
+ return { result: { duplicate: false, entry }, changed: true };
202
+ });
203
+ }
204
+
205
+ // What the agent learned through MCP: confirmed right away, or "to confirm", per brain.autoAdd.
206
+ function learn({ category, text }, file = brainPath()) {
207
+ return add({ category, text, by: 'agent', status: autoAdd() ? 'confirmed' : 'pending' }, file);
208
+ }
209
+
210
+ function update(id, { text, category } = {}, file = brainPath()) {
211
+ const t = text === undefined ? undefined : cleanText(text);
212
+ if (text !== undefined && !t) throw emptyText();
213
+ return mutate(file, (model) => {
214
+ const hit = find(model, id); if (!hit) throw notFound();
215
+ const e = hit.entry;
216
+ if (t !== undefined) e.text = t;
217
+ if (category !== undefined) {
218
+ const c = normCategory(category);
219
+ if (c !== e.category && e.status === 'confirmed') { hit.block.items.splice(hit.i, 1); append(blockFor(model, 'confirmed', c), e); }
220
+ e.category = c;
221
+ }
222
+ e.dirty = true;
223
+ return { result: e, changed: true };
224
+ });
225
+ }
226
+
227
+ function remove(id, file = brainPath()) {
228
+ return mutate(file, (model) => {
229
+ const hit = find(model, id); if (!hit) throw notFound();
230
+ hit.block.items.splice(hit.i, 1);
231
+ return { result: { ok: true }, changed: true };
232
+ });
233
+ }
234
+
235
+ function confirm(id, file = brainPath()) {
236
+ return mutate(file, (model) => {
237
+ const hit = find(model, id); if (!hit) throw notFound();
238
+ if (hit.entry.status !== 'pending') return { result: hit.entry, changed: false };
239
+ hit.block.items.splice(hit.i, 1);
240
+ hit.entry.status = 'confirmed'; hit.entry.dirty = true;
241
+ append(blockFor(model, 'confirmed', hit.entry.category), hit.entry);
242
+ return { result: hit.entry, changed: true };
243
+ });
244
+ }
245
+
246
+ // Confirmed entries as markdown, grouped by category — what an agent is given.
247
+ function renderForAgent(file = brainPath()) {
248
+ const { entries } = read(file);
249
+ return CATEGORIES.map((c) => {
250
+ const items = entries.filter((e) => e.category === c);
251
+ return items.length ? `## ${HEADINGS[c]}\n${items.map((e) => `- ${e.text}`).join('\n')}` : '';
252
+ }).filter(Boolean).join('\n\n');
253
+ }
254
+
255
+ function autoAdd() { return globalConfig.get('brain.autoAdd').value !== false; }
256
+
257
+ module.exports = { CATEGORIES, HEADINGS, MAX_TEXT, brainPath, parse, serialize, read, add, learn, update, remove, confirm, renderForAgent, autoAdd };
@@ -20,6 +20,26 @@ function body(req) { return new Promise((r) => { let b = ''; req.on('data', (c)
20
20
 
21
21
  const { ROUTES, findRoute } = require('./routes');
22
22
 
23
+ // Is this request really the user, on this machine, in a dashboard tab of this hub? The hub is reachable
24
+ // from the local network; a tunnel or reverse proxy on the same machine (ngrok, Caddy) arrives from
25
+ // loopback; and a web page can rebind its own domain to 127.0.0.1 (DNS rebinding) or post cross-site. So
26
+ // all three must hold: a loopback socket, a local Host, and — when the browser sends one — an Origin that is
27
+ // this very host. Anything else is treated like a request through the online relay: it can't touch the
28
+ // second brain (ops refuse it) and a run it starts can't write into it.
29
+ const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
30
+ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
31
+ const isLoopback = (addr) => !!addr && (LOOPBACK.has(addr) || addr.startsWith('127.') || addr.startsWith('::ffff:127.'));
32
+ function isLocalRequest(req) {
33
+ if (!isLoopback(req.socket && req.socket.remoteAddress)) return false;
34
+ const host = String(req.headers.host || '');
35
+ let hostname;
36
+ try { hostname = new URL(`http://${host}`).hostname; } catch { return false; }
37
+ if (!LOCAL_HOSTNAMES.has(hostname)) return false;
38
+ const origin = req.headers.origin;
39
+ if (origin === undefined) return true;
40
+ try { return new URL(origin).host === host; } catch { return false; }
41
+ }
42
+
23
43
  function createHandlers(root) {
24
44
  async function handleApi(req, res, u, emit) {
25
45
  const p = u.pathname;
@@ -28,7 +48,7 @@ function createHandlers(root) {
28
48
  const [, , opName, args] = route;
29
49
  const b = req.method === 'GET' ? {} : await body(req);
30
50
  try {
31
- const result = await ops[opName](root, args(u, b, p), { emit });
51
+ const result = await ops[opName](root, args(u, b, p), { emit, remote: !isLocalRequest(req) });
32
52
  sendJSON(res, 200, result);
33
53
  } catch (e) {
34
54
  if (e instanceof OpError) sendJSON(res, e.status, { error: e.message });
@@ -50,4 +70,4 @@ function createHandlers(root) {
50
70
  };
51
71
  }
52
72
 
53
- module.exports = { createHandlers, ROUTES };
73
+ module.exports = { createHandlers, ROUTES, isLoopback, isLocalRequest };
@@ -23,6 +23,7 @@ const os = require('os');
23
23
  const path = require('path');
24
24
  const registry = require('../registry');
25
25
  const workspace = require('../workspace');
26
+ const brain = require('../brain');
26
27
  const { createHandlers } = require('./handlers');
27
28
  const store = require('../store');
28
29
  const { createConnector } = require('./connector');
@@ -74,7 +75,7 @@ async function execOp(localId, op, args) {
74
75
  const proj = getProject(localId);
75
76
  if (!proj) throw new OpError(404, projectErrorMessage(localId));
76
77
  if (!Object.prototype.hasOwnProperty.call(ops, op)) throw new OpError(404, `Unknown operation "${op}".`);
77
- return ops[op](proj.root, args || {}, { emit: proj.emit });
78
+ return ops[op](proj.root, args || {}, { emit: proj.emit, remote: true });
78
79
  }
79
80
  function startConnector() {
80
81
  if (connector) { connector.stop(); connector = null; }
@@ -338,4 +339,23 @@ const server = http.createServer(async (req, res) => {
338
339
  } catch (e) { sendJSON(res, 500, { error: String(e && e.message || e) }); }
339
340
  });
340
341
 
341
- server.listen(PORT, () => { writeLock(); console.log(`spectoflow · hub → http://localhost:${PORT}${migrated.movedRegistry ? ' (moved your project list into the workspace)' : ''}`); startConnector(); });
342
+ // The second brain (~/.spectoflow/brain.md) is written by the page, by any agent's `spectoflow mcp`
343
+ // server process, and by `::spectoflow learn` run lines. Tell every open LOCAL tab it changed — never
344
+ // the connector: the brain is not a project's and stays off the relay, even as a content-free event.
345
+ function watchBrain() {
346
+ const file = brain.brainPath();
347
+ try { fs.mkdirSync(path.dirname(file), { recursive: true }); } catch (_) { return; }
348
+ let timer = null;
349
+ try {
350
+ fs.watch(path.dirname(file), (_ev, name) => {
351
+ if (name && name !== path.basename(file)) return;
352
+ clearTimeout(timer);
353
+ timer = setTimeout(() => {
354
+ const line = 'data: ' + JSON.stringify({ type: 'brain' }) + '\n\n';
355
+ for (const proj of projects.values()) for (const res of proj.clients) res.write(line);
356
+ }, 100);
357
+ });
358
+ } catch (_) {}
359
+ }
360
+
361
+ server.listen(PORT, () => { watchBrain(); writeLock(); console.log(`spectoflow · hub → http://localhost:${PORT}${migrated.movedRegistry ? ' (moved your project list into the workspace)' : ''}`); startConnector(); });
@@ -17,6 +17,9 @@ const { runMeetingGenerate, todayLocal } = require('./meeting');
17
17
  const orchestrator = require('./orchestrator');
18
18
  const adapters = require('../adapters');
19
19
  const detect = require('../detect');
20
+ const brain = require('../brain');
21
+ const brainSetup = require('../brain-setup');
22
+ const globalConfig = require('../global-config');
20
23
 
21
24
  const PKG_VERSION = require('../../package.json').version;
22
25
 
@@ -55,7 +58,7 @@ const KANBAN_STATUSES = ['todo', 'in_progress', 'to_validate', 'to_analyze', 'do
55
58
  // registered 'notes'; Task 3 registers 'meeting' — the Daily meeting tab — into it), mirroring
56
59
  // app.js's ROUTES array exactly — the one server-side source of truth for "what's a real native tab
57
60
  // id".
58
- const NATIVE_TABS = ['board', 'chat', 'requests', 'attention', 'backlog', 'workflow', 'team', 'files', 'notes', 'meeting', 'info', 'docs', 'personalize'];
61
+ const NATIVE_TABS = ['board', 'chat', 'requests', 'attention', 'backlog', 'workflow', 'team', 'files', 'notes', 'meeting', 'info', 'docs', 'brain', 'personalize'];
59
62
  function writeConfig(root, patch, detectOpts) {
60
63
  const cp = path.join(root, '.spectoflow', 'config.json');
61
64
  const cfg = JSON.parse(fs.readFileSync(cp, 'utf8'));
@@ -138,6 +141,15 @@ function writeConfig(root, patch, detectOpts) {
138
141
  const filesResult = (r) => { if (r.error) bad(r.error); return r; };
139
142
  const changed = (ctx, result) => { ctx.emit({ type: 'change' }); return result; };
140
143
 
144
+ // The second brain is the user's own and lives outside every project: never reachable through the
145
+ // online relay. relay.js already refuses these ops (absent from its OP_PERMISSIONS); ctx.remote — set
146
+ // for ops arriving through the connector, or from another machine on the network — is the second lock. No emit here: the hub
147
+ // watches ~/.spectoflow/brain.md and tells local tabs only, whoever wrote it (page, MCP, run line).
148
+ function brainOp(ctx, fn) {
149
+ if (ctx && ctx.remote) throw new OpError(404, 'Unknown operation.');
150
+ try { return fn(); } catch (e) { if (e.status && !(e instanceof OpError)) throw new OpError(e.status, e.message); throw e; }
151
+ }
152
+
141
153
  const ops = {
142
154
  'project.read': async (root) => {
143
155
  const p = store.readProject(root);
@@ -190,7 +202,7 @@ const ops = {
190
202
 
191
203
  'run.start': async (root, { prompt, agent, display }, ctx) => {
192
204
  text(prompt, 'Empty request.');
193
- const r = startRun(root, { prompt, agent, display }, ctx.emit);
205
+ const r = startRun(root, { prompt, agent, display, learn: !ctx.remote }, ctx.emit);
194
206
  if (r.error) bad(r.error);
195
207
  return { runId: r.runId };
196
208
  },
@@ -213,7 +225,7 @@ const ops = {
213
225
  const active = store.readRuntime(root).orchestration;
214
226
  if (active && ['running', 'awaiting_approval'].includes(active.status)) throw new OpError(409, 'An orchestration is already active.');
215
227
  const mode = store.readConfig(root).mode || 'semi';
216
- orchestrator.runOrchestration({ root, request: req, mode, runStep: orchestrator.defaultRunStep, confirm: orchestrator.defaultConfirm }, ctx.emit)
228
+ orchestrator.runOrchestration({ root, request: req, mode, runStep: orchestrator.defaultRunStep, confirm: orchestrator.defaultConfirm, learn: !ctx.remote }, ctx.emit)
217
229
  .catch((e) => ctx.emit({ type: 'message', message: { role: 'orchestrator', kind: 'status', text: 'orchestration error: ' + e.message } }));
218
230
  const o = store.readRuntime(root).orchestration;
219
231
  return { orchestrationId: o && o.id };
@@ -248,6 +260,16 @@ const ops = {
248
260
  store.writeRuntime(root, rt);
249
261
  return changed(ctx, { item: it });
250
262
  },
263
+ 'brain.read': async (_root, _args, ctx) => brainOp(ctx, () => ({ ...brain.read(), path: brain.brainPath(), agents: brainSetup.status() })),
264
+ 'brain.add': async (_root, { category, text: body }, ctx) => brainOp(ctx, () => brain.add({ category, text: body, by: 'user' })),
265
+ 'brain.update': async (_root, { id, patch }, ctx) => brainOp(ctx, () => ({ entry: brain.update(id, patch || {}) })),
266
+ 'brain.remove': async (_root, { id }, ctx) => brainOp(ctx, () => brain.remove(id)),
267
+ 'brain.confirm': async (_root, { id }, ctx) => brainOp(ctx, () => ({ entry: brain.confirm(id) })),
268
+ 'brain.settings': async (_root, { autoAdd }, ctx) => brainOp(ctx, () => {
269
+ if (typeof autoAdd !== 'boolean') bad('autoAdd must be true or false.');
270
+ return { autoAdd: globalConfig.set('brain.autoAdd', autoAdd) };
271
+ }),
272
+
251
273
  'attention.remove': async (root, { id }, ctx) => {
252
274
  const rt = store.readRuntime(root); rt.attention = (rt.attention || []).filter((x) => x.id !== id); store.writeRuntime(root, rt);
253
275
  return changed(ctx, { ok: true });
@@ -25,7 +25,7 @@ function post(root, role, kind, text, emit) {
25
25
  emit({ type: 'message', message: m });
26
26
  }
27
27
 
28
- async function runOrchestration({ root, request, mode, runStep, confirm, resume }, emit) {
28
+ async function runOrchestration({ root, request, mode, runStep, confirm, resume, learn = true }, emit) {
29
29
  const enabled = store.readWorkflow(root).filter((s) => s.enabled);
30
30
  let o, startAt = 0;
31
31
  if (resume) {
@@ -62,7 +62,7 @@ async function runOrchestration({ root, request, mode, runStep, confirm, resume
62
62
 
63
63
  st.status = 'running'; saveState(root, o, emit);
64
64
  post(root, 'orchestrator', 'status', `→ ${step.name} (${r.agent})`, emit);
65
- const exit = await runStep({ root, step, agent: r.agent, skill: r.skill, request }, emit);
65
+ const exit = await runStep({ root, step, agent: r.agent, skill: r.skill, request, learn }, emit);
66
66
  if (exit !== 0) { st.status = 'failed'; o.status = 'failed'; saveState(root, o, emit); post(root, 'orchestrator', 'status', `⚠ ${step.name} failed (exit ${exit})`, emit); return o; }
67
67
  st.status = 'done'; saveState(root, o, emit);
68
68
  }
@@ -83,13 +83,13 @@ function buildPrompt({ step, agent, skill, request }) {
83
83
  ].join('\n');
84
84
  }
85
85
 
86
- function defaultRunStep({ root, step, agent, skill, request }, emit) {
86
+ function defaultRunStep({ root, step, agent, skill, request, learn = true }, emit) {
87
87
  return new Promise((resolve) => {
88
88
  const prompt = buildPrompt({ step, agent, skill, request });
89
89
  const tool = store.readConfig(root).agent;
90
90
  // logPrompt:false — the orchestrator already posts a clean "→ step (agent)" line; the raw
91
91
  // priming prompt would otherwise show as a noisy user bubble.
92
- const r = startRun(root, { prompt, agent: tool, logPrompt: false }, (e) => { emit(e); if (e.type === 'run-end') resolve(e.code); });
92
+ const r = startRun(root, { prompt, agent: tool, logPrompt: false, learn }, (e) => { emit(e); if (e.type === 'run-end') resolve(e.code); });
93
93
  if (r.error) { emit({ type: 'message', message: { role: 'orchestrator', kind: 'status', text: r.error } }); resolve(1); }
94
94
  });
95
95
  }