spectoflow 0.32.0 → 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 +30 -3
- package/lib/adapters.js +15 -0
- package/lib/brain.js +16 -247
- package/lib/dashboard/ops.js +27 -1
- package/lib/dashboard/public/app.js +96 -58
- package/lib/dashboard/public/i18n.js +6 -6
- package/lib/dashboard/public/index.html +30 -12
- package/lib/dashboard/public/styles.css +7 -0
- package/lib/dashboard/routes.js +8 -0
- package/lib/mcp-server.js +2 -1
- package/lib/memory-store.js +268 -0
- package/lib/project-memory.js +34 -0
- package/package.json +1 -1
- package/templates/SPECTOFLOW.md +31 -0
- package/templates/config.json +1 -0
package/README.md
CHANGED
|
@@ -307,8 +307,9 @@ off by default):
|
|
|
307
307
|
- **Info** — a project-at-a-glance summary.
|
|
308
308
|
- **Documentation** — the live supported-agents table (your own install status + links) plus the CLI
|
|
309
309
|
command reference.
|
|
310
|
-
- **Second brain** — what spectoflow has learned about you, shared by all your projects
|
|
311
|
-
|
|
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)).
|
|
312
313
|
- **Personalize** — autonomy mode, language, design, the active agent, **navigation tabs**, **slash
|
|
313
314
|
commands**, and **Extend spectoflow** (see *Customize* below).
|
|
314
315
|
|
|
@@ -412,11 +413,37 @@ agents then read and grow your second brain through that server: nothing is copi
|
|
|
412
413
|
`::spectoflow learn category=… msg=…` line instead. Those always wait in *To confirm*, whatever the setting:
|
|
413
414
|
that output also carries command output and file contents, so a line hidden in a repository can't slip
|
|
414
415
|
in unseen.
|
|
415
|
-
- **Private:** the
|
|
416
|
+
- **Private:** the *You* section and its API answer this machine only — not the online dashboard
|
|
416
417
|
(`server/` refuses them for everyone, the project owner included), and not other machines or websites.
|
|
417
418
|
A run started from either of those can't write into it, and a learned fact never goes into a project's chat
|
|
418
419
|
log.
|
|
419
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
|
+
|
|
420
447
|
## A workflow that fits the project
|
|
421
448
|
|
|
422
449
|
The workflow (`.spectoflow/workflow.md`) isn't one-size-fits-all anymore.
|
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 };
|
package/lib/dashboard/ops.js
CHANGED
|
@@ -18,6 +18,7 @@ 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) => {
|
|
@@ -307,6 +313,26 @@ const ops = {
|
|
|
307
313
|
return { autoAdd: globalConfig.set('brain.autoAdd', autoAdd) };
|
|
308
314
|
}),
|
|
309
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
|
+
|
|
310
336
|
'attention.remove': async (root, { id }, ctx) => {
|
|
311
337
|
const rt = store.readRuntime(root); rt.attention = (rt.attention || []).filter((x) => x.id !== id); store.writeRuntime(root, rt);
|
|
312
338
|
return changed(ctx, { ok: true });
|