spectoflow 0.31.1 → 0.33.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 +41 -3
- package/bin/spectoflow.js +5 -0
- package/lib/adapters.js +15 -0
- package/lib/brain.js +16 -247
- package/lib/dashboard/handlers.js +1 -0
- package/lib/dashboard/hub-server.js +1 -1
- package/lib/dashboard/meeting.js +2 -1
- package/lib/dashboard/ops.js +37 -2
- package/lib/dashboard/public/app.js +140 -60
- package/lib/dashboard/public/i18n.js +24 -6
- package/lib/dashboard/public/index.html +33 -14
- package/lib/dashboard/public/styles.css +11 -0
- package/lib/dashboard/routes.js +11 -1
- package/lib/dashboard/runner.js +35 -6
- package/lib/dashboard/summarize.js +2 -1
- package/lib/mcp-server.js +2 -1
- package/lib/memory-store.js +268 -0
- package/lib/project-memory.js +34 -0
- package/lib/store.js +6 -2
- package/package.json +1 -1
- package/templates/SPECTOFLOW.md +31 -0
- package/templates/config.json +1 -0
package/README.md
CHANGED
|
@@ -24,6 +24,17 @@ An **agent-agnostic** spec-driven development framework with a **real-time local
|
|
|
24
24
|
You speak in plain language; the framework classifies your intent and runs the right workflow. No
|
|
25
25
|
ceremonial command to start.
|
|
26
26
|
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install -g spectoflow
|
|
31
|
+
cd my-project && spectoflow init # fits the workflow to your project, detects your agents
|
|
32
|
+
spectoflow dashboard # → http://localhost:4319
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Then just tell your coding agent what you want to build. Optional, once per machine:
|
|
36
|
+
`spectoflow brain setup` so your agents remember you across projects.
|
|
37
|
+
|
|
27
38
|
**Works with whichever coding agent you have.** `init` auto-detects what's installed; the dashboard's
|
|
28
39
|
topbar always shows the **active agent**, front and center, with a switcher — pick another and it's
|
|
29
40
|
verified as genuinely installed before activating (a red **"No agent found"** if none is), never
|
|
@@ -296,8 +307,9 @@ off by default):
|
|
|
296
307
|
- **Info** — a project-at-a-glance summary.
|
|
297
308
|
- **Documentation** — the live supported-agents table (your own install status + links) plus the CLI
|
|
298
309
|
command reference.
|
|
299
|
-
- **Second brain** — what spectoflow has learned about you, shared by all your projects
|
|
300
|
-
|
|
310
|
+
- **Second brain** — two sections: **You** (what spectoflow has learned about you, shared by all your projects,
|
|
311
|
+
local only) and **This project** (the project memory, committed). Read, add, fix, confirm, move a fact to the
|
|
312
|
+
other one (see [Second brain](#second-brain)).
|
|
301
313
|
- **Personalize** — autonomy mode, language, design, the active agent, **navigation tabs**, **slash
|
|
302
314
|
commands**, and **Extend spectoflow** (see *Customize* below).
|
|
303
315
|
|
|
@@ -401,11 +413,37 @@ agents then read and grow your second brain through that server: nothing is copi
|
|
|
401
413
|
`::spectoflow learn category=… msg=…` line instead. Those always wait in *To confirm*, whatever the setting:
|
|
402
414
|
that output also carries command output and file contents, so a line hidden in a repository can't slip
|
|
403
415
|
in unseen.
|
|
404
|
-
- **Private:** the
|
|
416
|
+
- **Private:** the *You* section and its API answer this machine only — not the online dashboard
|
|
405
417
|
(`server/` refuses them for everyone, the project owner included), and not other machines or websites.
|
|
406
418
|
A run started from either of those can't write into it, and a learned fact never goes into a project's chat
|
|
407
419
|
log.
|
|
408
420
|
|
|
421
|
+
### Project memory
|
|
422
|
+
|
|
423
|
+
Some facts aren't about you but about **one project**: its conventions, what breaks, its vocabulary, its
|
|
424
|
+
constraints. Those go in `.spectoflow/memory.md`, **committed with the code**, so your team and their agents
|
|
425
|
+
share them — and your other projects don't inherit them.
|
|
426
|
+
|
|
427
|
+
```markdown
|
|
428
|
+
# Project memory
|
|
429
|
+
|
|
430
|
+
## Conventions
|
|
431
|
+
- Tests run with `npm test -- --runInBand` (shared DB fixtures)
|
|
432
|
+
|
|
433
|
+
## Glossary
|
|
434
|
+
- "Ticket" means a customer support case, never a Jira issue
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
- **Four categories:** Conventions, Pitfalls, Glossary, Constraints.
|
|
438
|
+
- **The agent reads and writes the file directly** — no setup. About you → second brain; about the project →
|
|
439
|
+
this file. Never anything personal in it.
|
|
440
|
+
- **Facts the agent adds land directly by default.** To review them first, untick *Add what the agent learns
|
|
441
|
+
directly* in the *This project* section: that sets `memoryAutoAdd: false` in `.spectoflow/config.json`, for
|
|
442
|
+
the whole team.
|
|
443
|
+
- **Wrong memory?** *Move to…* on any fact sends it to the other one, in the category you pick (local dashboard
|
|
444
|
+
only).
|
|
445
|
+
- Created on the first fact; `spectoflow update` never touches it.
|
|
446
|
+
|
|
409
447
|
## A workflow that fits the project
|
|
410
448
|
|
|
411
449
|
The workflow (`.spectoflow/workflow.md`) isn't one-size-fits-all anymore.
|
package/bin/spectoflow.js
CHANGED
|
@@ -517,6 +517,11 @@ async function startDashboard() {
|
|
|
517
517
|
const boardUrl = (p) => (entry ? `http://localhost:${p}/p/${entry.id}/board` : `http://localhost:${p}/`);
|
|
518
518
|
const info = workspace.readLock();
|
|
519
519
|
if (info && info.port && await probeDashboard(info.port)) {
|
|
520
|
+
// A hub started by an older spectoflow keeps running that old code: replace it (D77).
|
|
521
|
+
if (info.version !== VERSION) {
|
|
522
|
+
console.log(`${c.cy('↻')} the running hub is ${info.version ? 'spectoflow v' + info.version : 'an older spectoflow'} — restarting it on v${VERSION}`);
|
|
523
|
+
return restartDashboard();
|
|
524
|
+
}
|
|
520
525
|
console.log(`${c.g('●')} hub already running → ${c.bold(boardUrl(info.port))}`);
|
|
521
526
|
await printOnlineLine(info.port, true);
|
|
522
527
|
return printDashboardCommands();
|
package/lib/adapters.js
CHANGED
|
@@ -30,6 +30,11 @@ learn one (a stated preference, a correction of how you work, their role), recor
|
|
|
30
30
|
\`::spectoflow learn category=<profile|preferences|workflow|avoid> msg=<fact>\` if that tool is unavailable. Never
|
|
31
31
|
secrets or sensitive data.
|
|
32
32
|
|
|
33
|
+
**Project memory.** Facts about *this project* (conventions, pitfalls, domain vocabulary, constraints) live in
|
|
34
|
+
\`.spectoflow/memory.md\`, committed — read it at session start and apply it. When you learn one, add a line under
|
|
35
|
+
its section (or under \`## To confirm\` as \`- [category] fact\` when \`.spectoflow/config.json\` → \`memoryAutoAdd\` is
|
|
36
|
+
\`false\`). About the user → second brain; about the project → this file. Never anything personal there.
|
|
37
|
+
|
|
33
38
|
**Workflow.** If a request needs a step that is disabled in \`.spectoflow/workflow.md\` (e.g. writing code while
|
|
34
39
|
*Develop* is off): enable it yourself and say so when \`.spectoflow/config.json\` → \`workflowAutoEnable\` is \`true\`;
|
|
35
40
|
otherwise ask first. Never disable a step without the user.
|
|
@@ -53,6 +58,11 @@ learn one (a stated preference, a correction of how you work, their role), recor
|
|
|
53
58
|
\`::spectoflow learn category=<profile|preferences|workflow|avoid> msg=<fact>\` if that tool is unavailable. Never
|
|
54
59
|
secrets or sensitive data.
|
|
55
60
|
|
|
61
|
+
**Project memory.** Facts about *this project* (conventions, pitfalls, domain vocabulary, constraints) live in
|
|
62
|
+
\`.spectoflow/memory.md\`, committed — read it at session start and apply it. When you learn one, add a line under
|
|
63
|
+
its section (or under \`## To confirm\` as \`- [category] fact\` when \`.spectoflow/config.json\` → \`memoryAutoAdd\` is
|
|
64
|
+
\`false\`). About the user → second brain; about the project → this file. Never anything personal there.
|
|
65
|
+
|
|
56
66
|
**Workflow.** If a request needs a step that is disabled in \`.spectoflow/workflow.md\` (e.g. writing code while
|
|
57
67
|
*Develop* is off): enable it yourself and say so when \`.spectoflow/config.json\` → \`workflowAutoEnable\` is \`true\`;
|
|
58
68
|
otherwise ask first. Never disable a step without the user.
|
|
@@ -73,6 +83,11 @@ learn one (a stated preference, a correction of how you work, their role), recor
|
|
|
73
83
|
\`::spectoflow learn category=<profile|preferences|workflow|avoid> msg=<fact>\` if that tool is unavailable. Never
|
|
74
84
|
secrets or sensitive data.
|
|
75
85
|
|
|
86
|
+
**Project memory.** Facts about *this project* (conventions, pitfalls, domain vocabulary, constraints) live in
|
|
87
|
+
\`.spectoflow/memory.md\`, committed — read it at session start and apply it. When you learn one, add a line under
|
|
88
|
+
its section (or under \`## To confirm\` as \`- [category] fact\` when \`.spectoflow/config.json\` → \`memoryAutoAdd\` is
|
|
89
|
+
\`false\`). About the user → second brain; about the project → this file. Never anything personal there.
|
|
90
|
+
|
|
76
91
|
**Workflow.** If a request needs a step that is disabled in \`.spectoflow/workflow.md\` (e.g. writing code while
|
|
77
92
|
*Develop* is off): enable it yourself and say so when \`.spectoflow/config.json\` → \`workflowAutoEnable\` is \`true\`;
|
|
78
93
|
otherwise ask first. Never disable a step without the user.
|
package/lib/brain.js
CHANGED
|
@@ -2,256 +2,25 @@
|
|
|
2
2
|
/*
|
|
3
3
|
* The second brain — what spectoflow has learned about the user, shared by all their projects.
|
|
4
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.
|
|
6
|
-
*
|
|
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.
|
|
5
|
+
* by the dashboard page, the `spectoflow mcp` server and the `::spectoflow learn` run line. Facts about
|
|
6
|
+
* one project go to the project memory instead (lib/project-memory.js). The file format, its fidelity
|
|
7
|
+
* and its lock live in lib/memory-store.js.
|
|
17
8
|
*/
|
|
18
|
-
const fs = require('fs');
|
|
19
9
|
const path = require('path');
|
|
20
|
-
const crypto = require('crypto');
|
|
21
10
|
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';
|
|
11
|
+
const { createMemoryStore } = require('./memory-store');
|
|
28
12
|
|
|
29
13
|
function brainPath() { return path.join(globalConfig.homeDir(), 'brain.md'); }
|
|
30
14
|
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 };
|
|
15
|
+
const store = createMemoryStore({
|
|
16
|
+
name: 'second brain',
|
|
17
|
+
title: 'Second brain',
|
|
18
|
+
categories: ['profile', 'preferences', 'workflow', 'avoid'],
|
|
19
|
+
headings: { profile: 'Profile', preferences: 'Preferences', workflow: 'Working style', avoid: 'Avoid' },
|
|
20
|
+
fallback: 'preferences',
|
|
21
|
+
idPrefix: 'b',
|
|
22
|
+
defaultFile: brainPath,
|
|
23
|
+
autoAdd: () => globalConfig.get('brain.autoAdd').value !== false,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
module.exports = { ...store, brainPath };
|
|
@@ -68,6 +68,7 @@ function createHandlers(root) {
|
|
|
68
68
|
// A process restart loses any in-flight orchestration; clear a stale 'running'/'awaiting_approval'
|
|
69
69
|
// so the 409 guard in orchestrate.start can't wedge forever. Not a resume — just un-wedging.
|
|
70
70
|
try { orchestrator.reconcileOnBoot(root); } catch (_) {}
|
|
71
|
+
try { require('./runner').reconcileRunsOnBoot(root); } catch (_) {}
|
|
71
72
|
}
|
|
72
73
|
return {
|
|
73
74
|
handleApi,
|
|
@@ -278,7 +278,7 @@ function serveStatic(reqPath, req, res, root) {
|
|
|
278
278
|
const PROJECT_PREFIX = /^\/p\/([0-9a-f]{6})(\/.*)?$/;
|
|
279
279
|
|
|
280
280
|
const LOCK = workspace.lockPath();
|
|
281
|
-
function writeLock(){ try{ fs.mkdirSync(path.dirname(LOCK),{recursive:true}); fs.writeFileSync(LOCK, JSON.stringify({ pid:process.pid, port:PORT, url:`http://localhost:${PORT}`, startedAt:new Date().toISOString() })+'\n'); }catch{} }
|
|
281
|
+
function writeLock(){ try{ fs.mkdirSync(path.dirname(LOCK),{recursive:true}); fs.writeFileSync(LOCK, JSON.stringify({ pid:process.pid, port:PORT, url:`http://localhost:${PORT}`, version:VERSION, startedAt:new Date().toISOString() })+'\n'); }catch{} }
|
|
282
282
|
function clearLock(){ try{ const l=JSON.parse(fs.readFileSync(LOCK,'utf8')); if(l.pid===process.pid) fs.unlinkSync(LOCK); }catch{} }
|
|
283
283
|
process.on('exit', clearLock);
|
|
284
284
|
['SIGINT','SIGTERM'].forEach((s)=> process.on(s, ()=>{ if (connector) connector.stop(); clearLock(); process.exit(0); }));
|
package/lib/dashboard/meeting.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
const { spawn } = require('child_process');
|
|
11
11
|
const store = require('../store');
|
|
12
12
|
const files = require('./files');
|
|
13
|
-
const { resolveRunnerCommand } = require('./runner');
|
|
13
|
+
const { resolveRunnerCommand, trackChild } = require('./runner');
|
|
14
14
|
const runnerTrust = require('../runner-trust');
|
|
15
15
|
const { formatLog } = require('./summarize'); // reused as-is rather than reimplemented — see report
|
|
16
16
|
|
|
@@ -106,6 +106,7 @@ function runMeetingGenerate(root, { agent, date } = {}, emit) {
|
|
|
106
106
|
let out = '';
|
|
107
107
|
child.stdout && child.stdout.on('data', (d) => { out += d.toString(); });
|
|
108
108
|
child.stderr && child.stderr.on('data', (d) => { out += d.toString(); });
|
|
109
|
+
trackChild(root, child);
|
|
109
110
|
child.on('close', (code) => {
|
|
110
111
|
const text = out.trim() || (code === 0 ? '(no output)' : `meeting generate failed (exit ${code})`);
|
|
111
112
|
files.writeFile(root, meetingPath(day), text);
|
package/lib/dashboard/ops.js
CHANGED
|
@@ -11,13 +11,14 @@ const fs = require('fs');
|
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const store = require('../store');
|
|
13
13
|
const files = require('./files');
|
|
14
|
-
const { startRun } = require('./runner');
|
|
14
|
+
const { startRun, stopRuns } = require('./runner');
|
|
15
15
|
const { runSummarize } = require('./summarize');
|
|
16
16
|
const { runMeetingGenerate, todayLocal } = require('./meeting');
|
|
17
17
|
const orchestrator = require('./orchestrator');
|
|
18
18
|
const adapters = require('../adapters');
|
|
19
19
|
const detect = require('../detect');
|
|
20
20
|
const brain = require('../brain');
|
|
21
|
+
const projectMemory = require('../project-memory');
|
|
21
22
|
const brainSetup = require('../brain-setup');
|
|
22
23
|
const globalConfig = require('../global-config');
|
|
23
24
|
const workflowDetect = require('../workflow-detect');
|
|
@@ -74,6 +75,7 @@ function writeConfig(root, patch, detectOpts) {
|
|
|
74
75
|
if (typeof patch.activeTab === 'string' && patch.activeTab.trim()) cfg.activeTab = patch.activeTab.trim();
|
|
75
76
|
if (typeof patch.chatOpen === 'boolean') cfg.chatOpen = patch.chatOpen;
|
|
76
77
|
if (typeof patch.workflowAutoEnable === 'boolean') cfg.workflowAutoEnable = patch.workflowAutoEnable;
|
|
78
|
+
if (typeof patch.memoryAutoAdd === 'boolean') cfg.memoryAutoAdd = patch.memoryAutoAdd;
|
|
77
79
|
// kanbanColumns: reject the whole patch (leave the current value untouched) rather than silently
|
|
78
80
|
// filtering out bad entries — an invalid/unknown status id here means the client sent something it
|
|
79
81
|
// shouldn't have, and a real product-safety rule (never persist zero visible columns) applies too.
|
|
@@ -150,8 +152,12 @@ const changed = (ctx, result) => { ctx.emit({ type: 'change' }); return result;
|
|
|
150
152
|
// watches ~/.spectoflow/brain.md and tells local tabs only, whoever wrote it (page, MCP, run line).
|
|
151
153
|
function brainOp(ctx, fn) {
|
|
152
154
|
if (ctx && ctx.remote) throw new OpError(404, 'Unknown operation.');
|
|
153
|
-
|
|
155
|
+
return storeOp(fn);
|
|
154
156
|
}
|
|
157
|
+
const storeOp = (fn) => { try { return fn(); } catch (e) { if (e.status && !(e instanceof OpError)) throw new OpError(e.status, e.message); throw e; } };
|
|
158
|
+
// The project memory is the project's (.spectoflow/memory.md, committed): gated like any project read/write,
|
|
159
|
+
// online included. Only `memory.move` touches the personal brain, so only it is local.
|
|
160
|
+
const memOp = (root, ctx, fn) => { const r = storeOp(() => fn(projectMemory.fileFor(root))); ctx.emit({ type: 'change' }); return r; };
|
|
155
161
|
|
|
156
162
|
const ops = {
|
|
157
163
|
'project.read': async (root) => {
|
|
@@ -165,6 +171,7 @@ const ops = {
|
|
|
165
171
|
// for which .spectoflow/meetings/<date>.md "today" resolves to.
|
|
166
172
|
p.todayDate = todayLocal();
|
|
167
173
|
p.untrustedRunners = runnerTrust.untrusted(root, p.config);
|
|
174
|
+
p.kitVersion = PKG_VERSION;
|
|
168
175
|
return p;
|
|
169
176
|
},
|
|
170
177
|
'agentfile.read': async (root, { path: rel }) => readAgentFile(root, rel),
|
|
@@ -234,6 +241,14 @@ const ops = {
|
|
|
234
241
|
if (r.error) bad(r.error);
|
|
235
242
|
return { runId: r.runId };
|
|
236
243
|
},
|
|
244
|
+
// Bring the project's framework files up to the installed spectoflow (same as `spectoflow update`). Local
|
|
245
|
+
// only: it writes framework files on the owner's machine (D77).
|
|
246
|
+
'project.update': async (root, _args, ctx) => {
|
|
247
|
+
if (ctx && ctx.remote) throw new OpError(404, 'Unknown operation.');
|
|
248
|
+
const r = require('../update').runUpdate({ projectRoot: root, templatesDir: path.join(__dirname, '..', '..', 'templates'), version: PKG_VERSION });
|
|
249
|
+
return changed(ctx, { fromVersion: r.fromVersion, toVersion: r.toVersion, refreshed: r.refreshed.length + r.created.length + r.forced.length + r.removed.length, review: r.newSidecar });
|
|
250
|
+
},
|
|
251
|
+
'run.stop': async (root, _args, ctx) => changed(ctx, { stopped: stopRuns(root) }),
|
|
237
252
|
'chat.summarize': async (root, { agent }, ctx) => {
|
|
238
253
|
const r = runSummarize(root, { agent }, ctx.emit);
|
|
239
254
|
if (r.error) bad(r.error);
|
|
@@ -298,6 +313,26 @@ const ops = {
|
|
|
298
313
|
return { autoAdd: globalConfig.set('brain.autoAdd', autoAdd) };
|
|
299
314
|
}),
|
|
300
315
|
|
|
316
|
+
'memory.read': async (root) => storeOp(() => ({ ...projectMemory.read(projectMemory.fileFor(root)), path: projectMemory.REL })),
|
|
317
|
+
'memory.add': async (root, { category, text: body }, ctx) => memOp(root, ctx, (f) => projectMemory.add({ category, text: body, by: 'user' }, f)),
|
|
318
|
+
'memory.update': async (root, { id, patch }, ctx) => memOp(root, ctx, (f) => ({ entry: projectMemory.update(id, patch || {}, f) })),
|
|
319
|
+
'memory.remove': async (root, { id }, ctx) => memOp(root, ctx, (f) => projectMemory.remove(id, f)),
|
|
320
|
+
'memory.confirm': async (root, { id }, ctx) => memOp(root, ctx, (f) => ({ entry: projectMemory.confirm(id, f) })),
|
|
321
|
+
// The agent picked the wrong memory: move one entry to the other, under the category the user chose. Added
|
|
322
|
+
// to the target first, removed from the source after — a failure never loses the fact. Local only: one of
|
|
323
|
+
// the two files is always the user's personal brain.
|
|
324
|
+
'memory.move': async (root, { from, id, category }, ctx) => brainOp(ctx, () => {
|
|
325
|
+
if (from !== 'user' && from !== 'project') bad('from must be "user" or "project".');
|
|
326
|
+
const pf = projectMemory.fileFor(root);
|
|
327
|
+
const [src, srcFile, dst, dstFile] = from === 'user' ? [brain, undefined, projectMemory, pf] : [projectMemory, pf, brain, undefined];
|
|
328
|
+
const entry = src.get(id, srcFile);
|
|
329
|
+
if (!dst.CATEGORIES.includes(category)) bad(`Unknown category: ${category}.`);
|
|
330
|
+
const added = dst.add({ category, text: entry.text, by: entry.by }, dstFile);
|
|
331
|
+
src.remove(id, srcFile);
|
|
332
|
+
ctx.emit({ type: 'change' });
|
|
333
|
+
return added;
|
|
334
|
+
}),
|
|
335
|
+
|
|
301
336
|
'attention.remove': async (root, { id }, ctx) => {
|
|
302
337
|
const rt = store.readRuntime(root); rt.attention = (rt.attention || []).filter((x) => x.id !== id); store.writeRuntime(root, rt);
|
|
303
338
|
return changed(ctx, { ok: true });
|