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.
- package/README.md +56 -1
- package/bin/spectoflow.js +48 -0
- package/lib/adapters.js +15 -0
- package/lib/brain-setup.js +165 -0
- package/lib/brain.js +257 -0
- package/lib/dashboard/handlers.js +29 -2
- package/lib/dashboard/hub-server.js +35 -2
- package/lib/dashboard/ops.js +25 -3
- package/lib/dashboard/orchestrator.js +4 -4
- package/lib/dashboard/public/app.js +168 -6
- package/lib/dashboard/public/i18n.js +12 -6
- package/lib/dashboard/public/icons.js +2 -0
- package/lib/dashboard/public/index.html +19 -0
- package/lib/dashboard/public/styles.css +46 -0
- package/lib/dashboard/routes.js +7 -0
- package/lib/dashboard/runner.js +26 -2
- package/lib/global-config.js +4 -3
- package/lib/init.js +3 -0
- package/lib/mcp-server.js +115 -0
- package/lib/mcp.js +13 -10
- package/package.json +1 -1
- package/templates/README.md +4 -0
- package/templates/SPECTOFLOW.md +20 -0
- package/templates/config.json +1 -0
|
@@ -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
|
-
|
|
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(); });
|
package/lib/dashboard/ops.js
CHANGED
|
@@ -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
|
|
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(); }));
|