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/README.md +58 -7
- package/bin/spectoflow.js +53 -3
- package/lib/adapters.js +59 -27
- package/lib/brain-setup.js +165 -0
- package/lib/brain.js +257 -0
- package/lib/dashboard/handlers.js +22 -2
- package/lib/dashboard/hub-server.js +22 -2
- package/lib/dashboard/ops.js +25 -3
- package/lib/dashboard/orchestrator.js +4 -4
- package/lib/dashboard/public/app.js +169 -7
- package/lib/dashboard/public/i18n.js +19 -13
- package/lib/dashboard/public/icons.js +2 -0
- package/lib/dashboard/public/index.html +21 -2
- 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 +6 -2
- package/lib/mcp-server.js +115 -0
- package/lib/mcp.js +13 -10
- package/lib/update.js +42 -3
- package/package.json +1 -1
- package/templates/README.md +8 -4
- package/templates/{AGENTS.md → SPECTOFLOW.md} +22 -2
- package/templates/capabilities.md +1 -1
- package/templates/config.json +1 -0
- package/templates/skills/clarify/SKILL.md +1 -1
- package/templates/skills/generate-agent/SKILL.md +1 -1
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
|
-
|
|
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(); });
|
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
|
}
|