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 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: read, add, fix,
311
- confirm (see [Second brain](#second-brain)). Local only.
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 Second brain tab and its API answer this machine only — not the online dashboard
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. 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.
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 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 };
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 };
@@ -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
- try { return fn(); } catch (e) { if (e.status && !(e instanceof OpError)) throw new OpError(e.status, e.message); throw e; }
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 });