spectoflow 0.28.0 → 0.30.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.
@@ -20,6 +20,33 @@ 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
+ // allowNavigation: the hub's own gate lets a top-level GET navigation from another site through (a link to
33
+ // the dashboard clicked elsewhere) — the linking page can't read what it opens. Ops never allow it.
34
+ function isLocalRequest(req, { allowNavigation = false } = {}) {
35
+ if (!isLoopback(req.socket && req.socket.remoteAddress)) return false;
36
+ const host = String(req.headers.host || '');
37
+ let hostname;
38
+ try { hostname = new URL(`http://${host}`).hostname; } catch { return false; }
39
+ if (!LOCAL_HOSTNAMES.has(hostname)) return false;
40
+ const site = req.headers['sec-fetch-site'];
41
+ if (site === 'cross-site' || site === 'same-site') {
42
+ const navigation = req.headers['sec-fetch-mode'] === 'navigate' && (req.method === 'GET' || req.method === 'HEAD');
43
+ if (!(allowNavigation && navigation)) return false;
44
+ }
45
+ const origin = req.headers.origin;
46
+ if (origin === undefined) return true;
47
+ try { return new URL(origin).host === host; } catch { return false; }
48
+ }
49
+
23
50
  function createHandlers(root) {
24
51
  async function handleApi(req, res, u, emit) {
25
52
  const p = u.pathname;
@@ -28,7 +55,7 @@ function createHandlers(root) {
28
55
  const [, , opName, args] = route;
29
56
  const b = req.method === 'GET' ? {} : await body(req);
30
57
  try {
31
- const result = await ops[opName](root, args(u, b, p), { emit });
58
+ const result = await ops[opName](root, args(u, b, p), { emit, remote: !isLocalRequest(req) });
32
59
  sendJSON(res, 200, result);
33
60
  } catch (e) {
34
61
  if (e instanceof OpError) sendJSON(res, e.status, { error: e.message });
@@ -50,4 +77,4 @@ function createHandlers(root) {
50
77
  };
51
78
  }
52
79
 
53
- module.exports = { createHandlers, ROUTES };
80
+ 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; }
@@ -282,7 +283,16 @@ function clearLock(){ try{ const l=JSON.parse(fs.readFileSync(LOCK,'utf8')); if(
282
283
  process.on('exit', clearLock);
283
284
  ['SIGINT','SIGTERM'].forEach((s)=> process.on(s, ()=>{ if (connector) connector.stop(); clearLock(); process.exit(0); }));
284
285
 
286
+ const { isLocalRequest } = require('./handlers');
285
287
  const server = http.createServer(async (req, res) => {
288
+ // This machine only (D74). The hub runs agents and writes project files: it listens on the loopback
289
+ // interfaces only, and still refuses a request whose Host isn't local (DNS rebinding, a tunnel or proxy
290
+ // on this machine) or that another site sent (cross-site fetch/POST). Reaching a project from elsewhere
291
+ // is what the online dashboard (server/) is for.
292
+ if (!isLocalRequest(req, { allowNavigation: true })) {
293
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
294
+ return res.end(`spectoflow: this dashboard only answers this machine — open http://localhost:${PORT}`);
295
+ }
286
296
  const u = new URL(req.url, `http://localhost:${PORT}`);
287
297
  const p = u.pathname;
288
298
  try {
@@ -338,4 +348,27 @@ const server = http.createServer(async (req, res) => {
338
348
  } catch (e) { sendJSON(res, 500, { error: String(e && e.message || e) }); }
339
349
  });
340
350
 
341
- server.listen(PORT, () => { writeLock(); console.log(`spectoflow · hub → http://localhost:${PORT}${migrated.movedRegistry ? ' (moved your project list into the workspace)' : ''}`); startConnector(); });
351
+ // The second brain (~/.spectoflow/brain.md) is written by the page, by any agent's `spectoflow mcp`
352
+ // server process, and by `::spectoflow learn` run lines. Tell every open LOCAL tab it changed — never
353
+ // the connector: the brain is not a project's and stays off the relay, even as a content-free event.
354
+ function watchBrain() {
355
+ const file = brain.brainPath();
356
+ try { fs.mkdirSync(path.dirname(file), { recursive: true }); } catch (_) { return; }
357
+ let timer = null;
358
+ try {
359
+ fs.watch(path.dirname(file), (_ev, name) => {
360
+ if (name && name !== path.basename(file)) return;
361
+ clearTimeout(timer);
362
+ timer = setTimeout(() => {
363
+ const line = 'data: ' + JSON.stringify({ type: 'brain' }) + '\n\n';
364
+ for (const proj of projects.values()) for (const res of proj.clients) res.write(line);
365
+ }, 100);
366
+ });
367
+ } catch (_) {}
368
+ }
369
+
370
+ // IPv6 loopback too, best effort: `localhost` may resolve to ::1 first. No ::1 on this machine → IPv4 only.
371
+ const server6 = http.createServer((req, res) => server.emit('request', req, res));
372
+ server6.on('error', () => {});
373
+ server.on('listening', () => server6.listen(PORT, '::1'));
374
+ server.listen(PORT, '127.0.0.1', () => { 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
  }
@@ -13,14 +13,14 @@ let filter = { status: 'all', q: '' }; // board filter state — client-side onl
13
13
  // old localStorage-absent fallback used, then is reconciled from P.config the first time it loads
14
14
  // (see syncSettingsFromServer(), called once from load()) — after that they're local state mutated
15
15
  // only by user actions, exactly like the old localStorage-backed vars were.
16
- // The 13 native tab ids, mirroring ops.js's NATIVE_TABS exactly — the client's own source of truth
16
+ // The 14 native tab ids, mirroring ops.js's NATIVE_TABS exactly — the client's own source of truth
17
17
  // for "what's a real native tab id" (used to validate P.config.navTabs before trusting it, same
18
18
  // defensive stance as ROUTES further down). Task 1 shipped the original 11, all default-enabled; Task
19
19
  // 2 (Sous-projet C) registered 'notes' — the Bloc note scratchpad — as the first tab that must start
20
20
  // OFF by default (an opt-in feature, per the user's explicit request); Task 3 registers 'meeting' —
21
21
  // the Daily meeting tab — the same way, so DEFAULT_OFF_TABS exists rather than a blanket "every
22
22
  // native tab defaults to enabled" the way Task 1 assumed.
23
- const NATIVE_TABS = ['board', 'chat', 'requests', 'attention', 'backlog', 'workflow', 'team', 'files', 'notes', 'meeting', 'info', 'docs', 'personalize'];
23
+ const NATIVE_TABS = ['board', 'chat', 'requests', 'attention', 'backlog', 'workflow', 'team', 'files', 'notes', 'meeting', 'info', 'docs', 'brain', 'personalize'];
24
24
  const DEFAULT_OFF_TABS = new Set(['notes', 'meeting']);
25
25
  function defaultNavTabs() { return NATIVE_TABS.map((id) => ({ id, enabled: !DEFAULT_OFF_TABS.has(id) })); }
26
26
  let boardView = 'list'; // 'list' | 'kanban'
@@ -72,7 +72,11 @@ window.fetch=(url,opts)=>{
72
72
  if(OFFLINE&&method!=='GET'&&String(url).startsWith('/api/')) return Promise.resolve(new Response(JSON.stringify({error:t('offline.readonly')}),{status:503,headers:{'Content-Type':'application/json'}}));
73
73
  return _fetch(url,opts);
74
74
  };
75
+ // Served by the online relay? Its project.read always carries `online`; the local hub's never does.
76
+ // Online, the Second brain tab is hidden: the brain is the machine owner's, never a project's.
77
+ let REMOTE=false;
75
78
  function setOffline(p){
79
+ REMOTE=typeof p.online==='boolean';
76
80
  OFFLINE=p.online===false;
77
81
  document.body.classList.toggle('is-offline',OFFLINE);
78
82
  const bar=$('#offlineBar'); if(!bar) return;
@@ -157,14 +161,14 @@ function applyNavTabs() {
157
161
  list.forEach((entry) => {
158
162
  const btn = nav.querySelector('.tab[data-tab="' + entry.id + '"]');
159
163
  if (!btn) return;
160
- btn.hidden = !entry.enabled;
164
+ btn.hidden = !entry.enabled || (entry.id === 'brain' && REMOTE);
161
165
  nav.insertBefore(btn, anchor);
162
166
  });
163
167
  // If the currently active NATIVE tab just became disabled (e.g. the user disabled the tab they're
164
168
  // viewing, or another viewer did and this tab just reloaded), navigate to a sensible fallback
165
169
  // instead of leaving a hidden/disabled panel showing.
166
170
  const activeEntry = list.find((e) => e.id === activeTab);
167
- if (activeEntry && !activeEntry.enabled) {
171
+ if (activeEntry && (!activeEntry.enabled || (activeEntry.id === 'brain' && REMOTE))) {
168
172
  const fallback = list.find((e) => e.id === 'board' && e.enabled) || list.find((e) => e.enabled);
169
173
  if (fallback) navigateTab(fallback.id);
170
174
  }
@@ -295,7 +299,9 @@ const runtimeTests=(id)=> (P.runtime&&P.runtime.tests&&P.runtime.tests[id])||nul
295
299
  async function load(){
296
300
  const r = await fetch(withProject('/api/project')); P = await r.json();
297
301
  syncSettingsFromServer();
302
+ REMOTE=typeof P.online==='boolean'; // known before the first paint: the brain tab is hidden online
298
303
  render(); setOffline(P);
304
+ if(!REMOTE && !brainData) loadBrain();
299
305
  if(openTaskId) openDrawer(openTaskId,true);
300
306
  }
301
307
  // Coalesce bursts of SSE 'change'/'message' events into one reload so the board doesn't
@@ -308,6 +314,7 @@ function connect(){
308
314
  es.onmessage = (ev)=>{
309
315
  let m; try{ m=JSON.parse(ev.data); }catch{ return; }
310
316
  if(m.type==='change'||m.type==='message') return scheduleLoad(); // messages live from runtime.messages
317
+ if(m.type==='brain') return loadBrain(); // ~/.spectoflow/brain.md changed (page, MCP, run line)
311
318
  if(m.type==='run-start'||m.type==='run-end') { chatState.forEach(st=>{ st.rawBlock=null; }); sseBusy=(m.type==='run-start'); updateChatBusyUI(); return; }
312
319
  if(m.type==='run-line') return appendRaw(m.chunk); // raw output is ephemeral (not logged)
313
320
  };
@@ -534,7 +541,7 @@ function render(){
534
541
  kanbanExpanded.clear(); // a full SSE-driven reload always collapses any "show more" a viewer had open
535
542
  renderOverview(); renderBoard(); renderBacklog(); renderWorkflow(); renderTeam();
536
543
  renderChatLog($('#chatLog')); renderChatLog($('#chatTabLog'));
537
- renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderDocs(); renderSettings(); renderFiles(); renderNotes(); renderMeeting(); applySideHidden(); updateChatBusyUI();
544
+ renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderDocs(); renderSettings(); renderFiles(); renderNotes(); renderMeeting(); renderBrain(); applySideHidden(); updateChatBusyUI();
538
545
  renderCustomDashboards(); // adds/removes nav tabs + panels before applyActiveTab() below reads them
539
546
  applyNavTabs(); // hide/reorder native tabs per config.navTabs (may itself navigate away from a just-disabled active tab)
540
547
  applyActiveTab(); // re-apply the current tab so an SSE-driven re-render never resets to Board
@@ -1425,7 +1432,7 @@ async function czSubmit(kind,description,agent){
1425
1432
  // A custom dashboard (Customize page) gets its own tab id "custom:<id>" and its own URL shape
1426
1433
  // /custom/<id> — kept out of ROUTES (a fixed list) since the set of custom ids is dynamic; recognized
1427
1434
  // by a dedicated branch in tabFromPath()/navigateTab() instead.
1428
- const ROUTES=['board','requests','attention','backlog','workflow','team','files','notes','meeting','chat','info','docs','personalize'];
1435
+ const ROUTES=['board','requests','attention','backlog','workflow','team','files','notes','meeting','chat','info','docs','brain','personalize'];
1429
1436
  // the tab used to be named/routed "settings" — old bookmarks and any localStorage value saved
1430
1437
  // under that name still land on the Personalize tab instead of a blank panel.
1431
1438
  function normalizeTab(t){ return t==='settings'?'personalize':t; }
@@ -1451,6 +1458,7 @@ function navigateTab(tabId,push){
1451
1458
  // was scrolled to last (only on an actual switch INTO the tab — applyActiveTab() alone runs on
1452
1459
  // every SSE render tick too, and re-scrolling/re-focusing there would fight the user's typing).
1453
1460
  if(tabId==='chat') setTimeout(()=>{ scrollChat($('#chatTabLog')); $('#tabRunPrompt').focus(); },60);
1461
+ if(tabId==='brain'){ renderBrain(); if(!brainData) loadBrain(); }
1454
1462
  if(tabId==='files') renderFiles(); // the tree is fetched lazily — only load it on an actual switch in
1455
1463
  }
1456
1464
  function closeNav(){ document.body.classList.remove('nav-open'); const nt=$('#navToggle'); if(nt) nt.setAttribute('aria-expanded','false'); }
@@ -2027,6 +2035,159 @@ function notesSetStatus(state){
2027
2035
  } else { tip.textContent=t('files.saveError'); tip.className='note-status is-error'; }
2028
2036
  }
2029
2037
 
2038
+ // ---- Second brain ------------------------------------------------------------------------------
2039
+ // What spectoflow has learned about the user: ~/.spectoflow/brain.md, shared by all their projects —
2040
+ // NOT this project's, so it is fetched on its own (/api/brain), never part of /api/project. The hub
2041
+ // pushes a 'brain' SSE event whenever the file changes, whoever wrote it (this page, an agent through
2042
+ // `spectoflow mcp`, or a `::spectoflow learn` run line).
2043
+ const BRAIN_CATEGORIES=['profile','preferences','workflow','avoid'];
2044
+ const BRAIN_SOFT_LIMIT=60;
2045
+ let brainData=null, brainError=null, brainActionError=null;
2046
+ async function loadBrain(){
2047
+ if(REMOTE) return;
2048
+ try{
2049
+ const r=await fetch(withProject('/api/brain'));
2050
+ const d=await r.json().catch(()=>({}));
2051
+ if(!r.ok) throw new Error(d.error||'error');
2052
+ brainData=d; brainError=null;
2053
+ }catch(err){ brainError=err.message||'error'; }
2054
+ renderBrain();
2055
+ }
2056
+ async function brainCall(method,url,body){
2057
+ flash();
2058
+ const r=await fetch(withProject(url),{method,headers:{'Content-Type':'application/json'},body:body===undefined?undefined:JSON.stringify(body)});
2059
+ const d=await r.json().catch(()=>({}));
2060
+ if(!r.ok) throw new Error(d.error||t('brain.error'));
2061
+ return d;
2062
+ }
2063
+ // A re-render (SSE tick, another tab's write) must never wipe what the user is typing.
2064
+ function brainIsEditing(){
2065
+ const a=document.activeElement;
2066
+ return !!(a && (a.tagName==='INPUT'||a.tagName==='TEXTAREA') && a.closest('#brainGrid, #brainPending'));
2067
+ }
2068
+ function renderBrain(){
2069
+ const badge=$('#brainBadge');
2070
+ const pendingN=brainData ? brainData.pending.length : 0;
2071
+ if(badge){ badge.textContent=pendingN; badge.hidden=pendingN===0; }
2072
+ if(activeTab!=='brain' || brainIsEditing()) return;
2073
+ const grid=$('#brainGrid'); if(!grid) return;
2074
+ if(brainError && !brainData){ grid.innerHTML=''; grid.append(el('div','empty',t('brain.error'))); return; }
2075
+ if(!brainData){ grid.innerHTML=''; grid.append(el('div','empty',t('drawer.loading'))); return; }
2076
+ const d=brainData;
2077
+ $('#brainCount').textContent=d.entries.length;
2078
+ const auto=$('#brainAutoAdd'); auto.checked=!!d.autoAdd;
2079
+ $('#brainAutoHint').textContent=d.autoAdd ? t('brain.autoAddOn') : t('brain.autoAddOff');
2080
+ renderBrainAgents(d.agents||[]);
2081
+ const notice=$('#brainNotice');
2082
+ const noticeText=brainActionError || (d.entries.length>BRAIN_SOFT_LIMIT ? t('brain.tooMany',{n:d.entries.length}) : '');
2083
+ notice.hidden=!noticeText; notice.textContent=noticeText;
2084
+ notice.classList.toggle('is-error',!!brainActionError);
2085
+ renderBrainPending(d.pending);
2086
+ grid.innerHTML='';
2087
+ if(!d.entries.length && !d.pending.length) grid.append(el('div','brain-empty',t('brain.empty')));
2088
+ BRAIN_CATEGORIES.forEach(cat=> grid.append(brainCard(cat, d.entries.filter(e=>e.category===cat))));
2089
+ $('#brainPath').textContent=t('brain.path',{path:d.path||'~/.spectoflow/brain.md'});
2090
+ }
2091
+ function renderBrainAgents(agents){
2092
+ const box=$('#brainAgents'); box.innerHTML='';
2093
+ if(!agents.length){ box.append(el('span','brain-agents-none',t('brain.noAgents'))); return; }
2094
+ box.append(el('span','brain-agents-label',t('brain.agents')));
2095
+ agents.forEach(a=>{
2096
+ const chip=el('span','brain-agent'+(a.wired?' is-wired':''), (a.wired?'● ':'○ ')+a.label);
2097
+ chip.title=a.wired?t('brain.agentWired'):t('brain.agentNotWired');
2098
+ box.append(chip);
2099
+ });
2100
+ if(agents.some(a=>!a.wired)){
2101
+ const hint=el('span','brain-agents-hint'); const parts=t('brain.setupHint').split('{cmd}');
2102
+ hint.append(parts[0]||'', el('code',null,'spectoflow brain setup'), parts[1]||'');
2103
+ box.append(hint);
2104
+ }
2105
+ }
2106
+ function brainMeta(e){
2107
+ const who=e.by==='agent' ? t('brain.byAgent') : t('brain.byYou');
2108
+ return e.at ? `${who} · ${e.at}` : who;
2109
+ }
2110
+ function renderBrainPending(pending){
2111
+ const box=$('#brainPending'); box.innerHTML='';
2112
+ box.hidden=!pending.length; if(!pending.length) return;
2113
+ const head=el('div','brain-pending-head');
2114
+ head.append(el('h3','brain-pending-title',t('brain.toConfirm')));
2115
+ const all=el('button','btn primary',t('brain.confirmAll'));
2116
+ all.addEventListener('click',async()=>{ all.disabled=true; try{ for(const e of pending) await brainCall('POST',`/api/brain/${encodeURIComponent(e.id)}/confirm`,{}); }catch(err){} loadBrain(); });
2117
+ head.append(all); box.append(head);
2118
+ pending.forEach(e=>{
2119
+ const row=el('div','brain-row is-pending');
2120
+ row.append(el('span','brain-cat-chip',t('brain.cat.'+e.category)));
2121
+ const txt=el('div','brain-text',e.text); row.append(txt);
2122
+ row.append(el('div','brain-meta',brainMeta(e)));
2123
+ const acts=el('div','brain-actions');
2124
+ const ok=el('button','btn primary',t('brain.confirm')); ok.addEventListener('click',()=>brainAct(()=>brainCall('POST',`/api/brain/${encodeURIComponent(e.id)}/confirm`,{})));
2125
+ const ed=el('button','btn',t('action.edit')); ed.addEventListener('click',()=>brainEdit(e,txt));
2126
+ const no=el('button','btn danger',t('brain.reject')); no.addEventListener('click',()=>brainAct(()=>brainCall('DELETE',`/api/brain/${encodeURIComponent(e.id)}`)));
2127
+ acts.append(ok,ed,no); row.append(acts);
2128
+ box.append(row);
2129
+ });
2130
+ }
2131
+ function brainCard(cat, entries){
2132
+ const card=el('div','brain-card');
2133
+ const head=el('div','brain-card-head');
2134
+ head.append(el('h3','brain-card-title',t('brain.cat.'+cat)), el('span','count',String(entries.length)));
2135
+ card.append(head, el('p','brain-card-hint',t('brain.catHint.'+cat)));
2136
+ const list=el('div','brain-list');
2137
+ if(!entries.length) list.append(el('div','empty brain-card-empty',t('brain.catEmpty')));
2138
+ entries.forEach(e=>{
2139
+ const row=el('div','brain-row'+(e.by==='agent'?' from-agent':''));
2140
+ const txt=el('div','brain-text',e.text); row.append(txt);
2141
+ const foot=el('div','brain-row-foot');
2142
+ foot.append(el('span','brain-meta',brainMeta(e)));
2143
+ const acts=el('span','brain-actions');
2144
+ const ed=el('button','btn btn-xs',t('action.edit')); ed.addEventListener('click',()=>brainEdit(e,txt));
2145
+ const del=el('button','btn btn-xs danger',t('action.delete')); del.addEventListener('click',()=>brainAct(()=>brainCall('DELETE',`/api/brain/${encodeURIComponent(e.id)}`)));
2146
+ acts.append(ed,del); foot.append(acts); row.append(foot);
2147
+ list.append(row);
2148
+ });
2149
+ card.append(list);
2150
+ const add=el('form','brain-add');
2151
+ const input=el('input','brain-add-input'); input.type='text'; input.maxLength=500; input.placeholder=t('brain.addPlaceholder');
2152
+ const btn=el('button','btn',t('action.add')); btn.type='submit';
2153
+ const err=el('div','brain-add-error'); err.hidden=true;
2154
+ add.append(input,btn,err);
2155
+ add.addEventListener('submit',async(ev)=>{
2156
+ ev.preventDefault();
2157
+ const text=input.value.trim(); if(!text){ input.focus(); return; }
2158
+ btn.disabled=true; err.hidden=true;
2159
+ try{
2160
+ const r=await brainCall('POST','/api/brain',{category:cat,text});
2161
+ input.value=''; input.blur();
2162
+ if(r.duplicate){ err.textContent=t('brain.duplicate'); err.hidden=false; }
2163
+ }catch(e){ err.textContent=e.message; err.hidden=false; }
2164
+ btn.disabled=false; loadBrain();
2165
+ });
2166
+ card.append(add);
2167
+ return card;
2168
+ }
2169
+ async function brainAct(fn){
2170
+ try{ await fn(); brainActionError=null; }catch(err){ brainActionError=err.message; }
2171
+ loadBrain();
2172
+ }
2173
+ // Inline edit, same interaction as the Attention tab: blur or Ctrl/Cmd+Enter saves, Escape cancels.
2174
+ function brainEdit(e, txtNode){
2175
+ const ta=el('textarea','brain-edit'); ta.value=e.text; ta.maxLength=500; txtNode.replaceWith(ta); ta.focus();
2176
+ let done=false;
2177
+ const finish=async(save)=>{
2178
+ if(done) return; done=true;
2179
+ const v=ta.value.trim();
2180
+ ta.blur();
2181
+ if(save && v && v!==e.text) await brainAct(()=>brainCall('PATCH',`/api/brain/${encodeURIComponent(e.id)}`,{text:v}));
2182
+ else renderBrain();
2183
+ };
2184
+ ta.addEventListener('blur',()=>finish(true));
2185
+ ta.addEventListener('keydown',ev=>{
2186
+ if((ev.metaKey||ev.ctrlKey)&&ev.key==='Enter'){ ev.preventDefault(); finish(true); }
2187
+ if(ev.key==='Escape'){ ev.preventDefault(); finish(false); }
2188
+ });
2189
+ }
2190
+
2030
2191
  // ---- Daily meeting (Sous-projet C, Task 3) ------------------------------------------------------
2031
2192
  // One dated Markdown note per day, .spectoflow/meetings/<date>.md. MANUAL editing reuses the exact
2032
2193
  // same files.read/files.write ops + filesCodeEditor() component as Bloc note above (700ms debounced
@@ -2397,6 +2558,7 @@ $('#blAddCancel').addEventListener('click', closeBacklogAddForm);
2397
2558
  $('#blAddSubmit').addEventListener('click', submitBacklogAdd);
2398
2559
  $('#blAddTitle').addEventListener('keydown', e=>{ if(e.key==='Enter') submitBacklogAdd(); });
2399
2560
  // attention tab: add a note + filter chips
2561
+ $('#brainAutoAdd').addEventListener('change',(e)=>brainAct(()=>brainCall('POST','/api/brain/settings',{autoAdd:e.target.checked})));
2400
2562
  $('#attnAddBtn').addEventListener('click',()=>{ const t=$('#attnInput'); const v=t.value.trim(); if(v){ addAttn(v); t.value=''; } });
2401
2563
  $('#attnInput').addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){ const v=e.target.value.trim(); if(v){ addAttn(v); e.target.value=''; } } });
2402
2564
  $$('.attn-filters .fchip').forEach(b=> b.addEventListener('click',()=>{ attnFilter=b.dataset.attn; renderAttention(); }));