spectoflow 0.28.0 → 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 +50 -1
- package/bin/spectoflow.js +48 -0
- package/lib/adapters.js +15 -0
- package/lib/brain-setup.js +165 -0
- package/lib/brain.js +257 -0
- package/lib/dashboard/handlers.js +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 +168 -6
- package/lib/dashboard/public/i18n.js +12 -6
- package/lib/dashboard/public/icons.js +2 -0
- package/lib/dashboard/public/index.html +19 -0
- package/lib/dashboard/public/styles.css +46 -0
- package/lib/dashboard/routes.js +7 -0
- package/lib/dashboard/runner.js +26 -2
- package/lib/global-config.js +4 -3
- package/lib/init.js +3 -0
- package/lib/mcp-server.js +115 -0
- package/lib/mcp.js +13 -10
- package/package.json +1 -1
- package/templates/README.md +4 -0
- package/templates/SPECTOFLOW.md +20 -0
- package/templates/config.json +1 -0
package/README.md
CHANGED
|
@@ -91,6 +91,9 @@ spectoflow dashboard init [--path=<dir>] create/move the dashboard workspa
|
|
|
91
91
|
spectoflow dashboard validate <file> check a custom-view JSON against the block schema
|
|
92
92
|
spectoflow config [get|set <key> [<value>]] global defaults + dashboard URL/path (~/.spectoflow/config.json)
|
|
93
93
|
|
|
94
|
+
spectoflow brain your second brain: entries, and which agents can reach it
|
|
95
|
+
spectoflow brain setup [--dry-run] connect your installed agents to it (once per machine)
|
|
96
|
+
|
|
94
97
|
spectoflow skill create "..." | --auto generate a project skill
|
|
95
98
|
spectoflow agent create "..." | --auto generate a project agent
|
|
96
99
|
|
|
@@ -256,7 +259,7 @@ it grows one extra, optional connection outward:
|
|
|
256
259
|
| `spectoflow dashboard logout` | your machine | unlink the machine entirely |
|
|
257
260
|
|
|
258
261
|
The header bar always shows the brand, the **active agent**, autonomy mode, language, a global-progress
|
|
259
|
-
meter, a sync dot, and a **Run** quick-action.
|
|
262
|
+
meter, a sync dot, and a **Run** quick-action. Fourteen tabs — and **which ones you see, and in what
|
|
260
263
|
order, is up to you** (Personalize → *Navigation tabs*: enable / disable / reorder; two of them ship
|
|
261
264
|
off by default):
|
|
262
265
|
|
|
@@ -279,6 +282,8 @@ off by default):
|
|
|
279
282
|
- **Info** — a project-at-a-glance summary.
|
|
280
283
|
- **Documentation** — the live supported-agents table (your own install status + links) plus the CLI
|
|
281
284
|
command reference.
|
|
285
|
+
- **Second brain** — what spectoflow has learned about you, shared by all your projects: read, add, fix,
|
|
286
|
+
confirm (see [Second brain](#second-brain)). Local only.
|
|
282
287
|
- **Personalize** — autonomy mode, language, design, the active agent, **navigation tabs**, **slash
|
|
283
288
|
commands**, and **Extend spectoflow** (see *Customize* below).
|
|
284
289
|
|
|
@@ -343,6 +348,50 @@ spectoflow agent create "owns accessibility review" # or: --auto
|
|
|
343
348
|
spectoflow dashboard create "a KPI overview for support" # or: --auto
|
|
344
349
|
```
|
|
345
350
|
|
|
351
|
+
## Second brain
|
|
352
|
+
|
|
353
|
+
spectoflow learns about **you** as you work, and remembers it across all your projects: your role, your
|
|
354
|
+
preferences, how you like to work, what to avoid. Every agent session starts with it, whatever the agent.
|
|
355
|
+
|
|
356
|
+
```
|
|
357
|
+
you work, in any project, with any agent
|
|
358
|
+
│ "commit messages in English, always"
|
|
359
|
+
▼
|
|
360
|
+
the agent records it ── brain_learn ──► ~/.spectoflow/brain.md (one file, yours, never in a repo)
|
|
361
|
+
│
|
|
362
|
+
┌──────────────────────────────────────────┼─────────────────────────────┐
|
|
363
|
+
▼ ▼ ▼
|
|
364
|
+
next session, any project: dashboard → Second brain tab: you can edit the file
|
|
365
|
+
the agent starts with it read, add, fix, confirm by hand, too
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
**One-time setup, per machine:**
|
|
369
|
+
|
|
370
|
+
```bash
|
|
371
|
+
spectoflow brain setup --dry-run # see what it would change
|
|
372
|
+
spectoflow brain setup # connect every coding agent installed on this machine
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
It registers a small MCP server, `spectoflow mcp`, in each installed agent's **user-level** config (Claude
|
|
376
|
+
Code, Codex, Cursor, Gemini, OpenCode, Kiro, Antigravity, Copilot, Amazon Q, Droid, Auggie, Kimi; Goose
|
|
377
|
+
gets a snippet to paste). It never touches an existing entry and never rewrites a file it can't parse. The
|
|
378
|
+
agents then read and grow your second brain through that server: nothing is copied into your projects.
|
|
379
|
+
|
|
380
|
+
- **Four categories:** Profile, Preferences, Working style, Avoid.
|
|
381
|
+
- **Facts an agent records through MCP are added directly by default.** To review them first, untick *Add what the agent learns
|
|
382
|
+
directly* on the page, or run `spectoflow config set brain.autoAdd false`: new facts then wait in
|
|
383
|
+
*To confirm*.
|
|
384
|
+
- **What gets recorded:** durable facts only. Never secrets, credentials or sensitive personal data, never
|
|
385
|
+
a one-off instruction. The rule is written in every agent's instructions and in the MCP tools themselves.
|
|
386
|
+
- **Runs launched from the dashboard:** non-interactive agents often refuse MCP tools, so the agent prints a
|
|
387
|
+
`::spectoflow learn category=… msg=…` line instead. Those always wait in *To confirm*, whatever the setting:
|
|
388
|
+
that output also carries command output and file contents, so a line hidden in a repository can't slip
|
|
389
|
+
in unseen.
|
|
390
|
+
- **Private:** the Second brain tab and its API answer this machine only — not the online dashboard
|
|
391
|
+
(`server/` refuses them for everyone, the project owner included), and not other machines on your network.
|
|
392
|
+
A run started from either of those can't write into it, and a learned fact never goes into a project's chat
|
|
393
|
+
log.
|
|
394
|
+
|
|
346
395
|
## Agents vs skills
|
|
347
396
|
|
|
348
397
|
Agents (`.spectoflow/agents/`) are **stable team personas** (Product Manager, Developer, QA Engineer…).
|
package/bin/spectoflow.js
CHANGED
|
@@ -374,6 +374,40 @@ function configCmd() {
|
|
|
374
374
|
} catch (e) { console.log(`${c.y('!')} ${e.message}`); process.exitCode = 1; }
|
|
375
375
|
}
|
|
376
376
|
|
|
377
|
+
// ---- brain: the user's second brain (~/.spectoflow/brain.md), shared by every project ----
|
|
378
|
+
function brainCmd() {
|
|
379
|
+
const brain = require('../lib/brain');
|
|
380
|
+
const brainSetup = require('../lib/brain-setup');
|
|
381
|
+
const tilde = (p) => (p.startsWith(os.homedir()) ? '~' + p.slice(os.homedir().length) : p);
|
|
382
|
+
if (argv[1] === 'setup') {
|
|
383
|
+
const dryRun = argv.includes('--dry-run');
|
|
384
|
+
const rows = brainSetup.setup({ binPath: __filename, dryRun });
|
|
385
|
+
console.log(wordmark());
|
|
386
|
+
console.log(` ${c.bold('spectoflow brain setup')}${dryRun ? c.dim(' (dry-run)') : ''} ${c.dim('registers the spectoflow MCP server in each installed agent (user level, once per machine)')}\n`);
|
|
387
|
+
if (!rows.length) { console.log(` ${c.y('!')} No supported coding agent found on PATH.\n`); return; }
|
|
388
|
+
const w = Math.max(...rows.map((r) => r.label.length));
|
|
389
|
+
const sym = { created: c.cy('+'), added: c.g('✓'), exists: c.dim('·'), skipped: c.y('!'), manual: c.y('!'), failed: c.y('✗') };
|
|
390
|
+
for (const r of rows) {
|
|
391
|
+
console.log(` ${sym[r.status] || '?'} ${r.label.padEnd(w)} ${r.status.padEnd(8)} ${c.dim(r.via || tilde(r.file))}`);
|
|
392
|
+
if (r.status === 'skipped') console.log(c.dim(` couldn't parse this file (comments?) — left untouched; add a "spectoflow" server running: spectoflow mcp`));
|
|
393
|
+
if (r.status === 'manual') console.log(c.dim(r.id === 'goose' ? ` YAML isn't edited automatically — add this to ${tilde(r.file)}:\n` : ` this file already mentions spectoflow in a form we can't read safely — check it, and add this if it's missing:\n`) + r.manual.split('\n').map((l) => ' ' + l).join('\n'));
|
|
394
|
+
if (r.status === 'failed') console.log(c.dim(` ${r.detail || 'failed'}${r.manual ? `\n run it yourself: ${r.manual}` : ''}`));
|
|
395
|
+
}
|
|
396
|
+
const changed = rows.filter((r) => ['created', 'added'].includes(r.status)).length;
|
|
397
|
+
console.log(`\n ${dryRun ? c.dim('(dry-run — nothing was written)') : changed ? c.g(`✓ ${changed} agent(s) wired`) + c.dim(' — start a new agent session to load it') : c.dim('Nothing to change')}\n`);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const r = brain.read();
|
|
401
|
+
console.log(wordmark());
|
|
402
|
+
console.log(` ${c.bold('Second brain')} ${c.dim(tilde(brain.brainPath()))}`);
|
|
403
|
+
console.log(` ${r.entries.length} entr${r.entries.length === 1 ? 'y' : 'ies'}${r.pending.length ? c.y(` · ${r.pending.length} to confirm`) : ''} ${c.dim('learned facts: ' + (r.autoAdd ? 'added directly' : 'wait for your confirmation') + ' (spectoflow config set brain.autoAdd true|false)')}\n`);
|
|
404
|
+
const agents = brainSetup.status();
|
|
405
|
+
if (!agents.length) console.log(` ${c.y('!')} No supported coding agent found on PATH.`);
|
|
406
|
+
agents.forEach((a) => console.log(` ${a.wired ? c.g('●') : c.dim('○')} ${a.label}${a.wired ? '' : c.dim(' not wired')}`));
|
|
407
|
+
if (agents.some((a) => !a.wired)) console.log(`\n ${c.dim('wire them:')} ${c.g('spectoflow brain setup')} ${c.dim('[--dry-run]')}`);
|
|
408
|
+
console.log('');
|
|
409
|
+
}
|
|
410
|
+
|
|
377
411
|
// ---- Customize: `spectoflow skill/agent/dashboard create` — the CLI mirror of the dashboard's
|
|
378
412
|
// Settings → Customize UI. Both surfaces build the same natural-language prompt (customize-prompts.js)
|
|
379
413
|
// and post it through the same pipeline (runner.js's startRun — the function /api/run itself calls),
|
|
@@ -589,6 +623,10 @@ ${c.bold('Dashboard')}
|
|
|
589
623
|
${c.g('dashboard login')} ${c.dim('--url=<u> --token=<t>')} connect this machine to an online dashboard ${c.dim('(logout · publish · unpublish)')}
|
|
590
624
|
${c.g('projects')} ${c.dim('[remove <id>]')} list every project seen so far
|
|
591
625
|
|
|
626
|
+
${c.bold('Second brain')} ${c.dim('— what spectoflow learns about you, shared by all your projects')}
|
|
627
|
+
${c.g('brain')} what it holds, and which agents can reach it
|
|
628
|
+
${c.g('brain setup')} ${c.dim('[--dry-run]')} register the spectoflow MCP server in your installed agents
|
|
629
|
+
|
|
592
630
|
${c.bold('Customize')} ${c.dim('— same as Settings → Customize, from the terminal')}
|
|
593
631
|
${c.g('skill create')} ${c.dim('"<description>" | --auto')} generate a project skill
|
|
594
632
|
${c.g('agent create')} ${c.dim('"<description>" | --auto')} generate a project agent
|
|
@@ -662,6 +700,14 @@ const HELP = {
|
|
|
662
700
|
agents: `${c.bold('spectoflow agents')}\n List the stable team personas (name · capability · role).`,
|
|
663
701
|
skills: `${c.bold('spectoflow skills')}\n List the evolving procedures (name · capability · what it does).`,
|
|
664
702
|
workflow: `${c.bold('spectoflow workflow')}\n Show the pipeline steps, marking which are enabled (●) or disabled (○).`,
|
|
703
|
+
brain: `${c.bold('spectoflow brain')} ${c.dim('[setup [--dry-run]]')}\n
|
|
704
|
+
Your second brain — what spectoflow has learned about you (profile, preferences, working style,
|
|
705
|
+
things to avoid), in ${c.dim('~/.spectoflow/brain.md')}, shared by all your projects and editable in the
|
|
706
|
+
dashboard's ${c.bold('Second brain')} tab. Agents read and grow it through the ${c.g('spectoflow mcp')} server.
|
|
707
|
+
${c.g('brain')} show it: entries, entries to confirm, and which installed agents are wired
|
|
708
|
+
${c.g('brain setup')} register the MCP server in each installed agent's USER-level config (once
|
|
709
|
+
per machine; never touches an existing entry; Goose gets a snippet to paste)
|
|
710
|
+
Learned facts are added directly by default: ${c.g('spectoflow config set brain.autoAdd false')} to confirm them first.`,
|
|
665
711
|
stop: `${c.bold('spectoflow stop')}\n Stop the running dashboard (alias for ${c.g('spectoflow dashboard stop')}).`,
|
|
666
712
|
config: `${c.bold('spectoflow config')} ${c.dim('[get <key> | set <key> <value>]')}\n
|
|
667
713
|
Global settings that apply to every project on this machine, stored in ${c.dim('~/.spectoflow/config.json')}:
|
|
@@ -677,6 +723,8 @@ const fns = {
|
|
|
677
723
|
init, update, dashboard, stop: stopDashboard, status, list: listAll, help, version,
|
|
678
724
|
projects: projectsCmd,
|
|
679
725
|
config: configCmd,
|
|
726
|
+
mcp: () => require('../lib/mcp-server').serve({ version: VERSION }),
|
|
727
|
+
brain: brainCmd,
|
|
680
728
|
agents: () => { console.log(wordmark()); printAgents(false); },
|
|
681
729
|
skills: () => { console.log(wordmark()); printSkills(false); },
|
|
682
730
|
workflow: () => { console.log(wordmark()); printWorkflow(false); },
|
package/lib/adapters.js
CHANGED
|
@@ -25,6 +25,11 @@ instructions (intent router, workflow, standing rules).
|
|
|
25
25
|
reflect it back and ask **one targeted question at a time** (each with a recommendation) until the need
|
|
26
26
|
is clear — then execute. See the Clarify reflex in \`.spectoflow/SPECTOFLOW.md\`.
|
|
27
27
|
|
|
28
|
+
**Second brain.** Durable facts about the user come from the \`spectoflow\` MCP server — apply them. When you
|
|
29
|
+
learn one (a stated preference, a correction of how you work, their role), record it with \`brain_learn\`, or print
|
|
30
|
+
\`::spectoflow learn category=<profile|preferences|workflow|avoid> msg=<fact>\` if that tool is unavailable. Never
|
|
31
|
+
secrets or sensitive data.
|
|
32
|
+
|
|
28
33
|
- Command: \`/spectoflow\` (\`init\` / \`status\` / or just a request).
|
|
29
34
|
- Dashboard: \`spectoflow dashboard\` → http://localhost:4319
|
|
30
35
|
- Artifacts are markdown in \`specs/\` and \`plans/\`; volatile state in \`.spectoflow/runtime.json\`.
|
|
@@ -38,6 +43,11 @@ instructions. Artifacts are markdown in \`specs/\` and \`plans/\`; the workflow
|
|
|
38
43
|
**Be an expert analyst, not an order-taker.** When a request is ambiguous, **clarify before acting**:
|
|
39
44
|
reflect it back and ask **one targeted question at a time** (each with a recommendation) until the need
|
|
40
45
|
is clear — then execute. See the Clarify reflex in \`.spectoflow/SPECTOFLOW.md\`.
|
|
46
|
+
|
|
47
|
+
**Second brain.** Durable facts about the user come from the \`spectoflow\` MCP server — apply them. When you
|
|
48
|
+
learn one (a stated preference, a correction of how you work, their role), record it with \`brain_learn\`, or print
|
|
49
|
+
\`::spectoflow learn category=<profile|preferences|workflow|avoid> msg=<fact>\` if that tool is unavailable. Never
|
|
50
|
+
secrets or sensitive data.
|
|
41
51
|
`;
|
|
42
52
|
|
|
43
53
|
const GEMINI_MD = `# GEMINI.md — spectoflow
|
|
@@ -49,6 +59,11 @@ instructions (intent router, workflow, standing rules). Artifacts are markdown i
|
|
|
49
59
|
**Be an expert analyst, not an order-taker.** When a request is ambiguous, **clarify before acting**:
|
|
50
60
|
reflect it back and ask **one targeted question at a time** (each with a recommendation) until the need
|
|
51
61
|
is clear — then execute. See the Clarify reflex in \`.spectoflow/SPECTOFLOW.md\`.
|
|
62
|
+
|
|
63
|
+
**Second brain.** Durable facts about the user come from the \`spectoflow\` MCP server — apply them. When you
|
|
64
|
+
learn one (a stated preference, a correction of how you work, their role), record it with \`brain_learn\`, or print
|
|
65
|
+
\`::spectoflow learn category=<profile|preferences|workflow|avoid> msg=<fact>\` if that tool is unavailable. Never
|
|
66
|
+
secrets or sensitive data.
|
|
52
67
|
`;
|
|
53
68
|
|
|
54
69
|
const SLASH_CMD = `---
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* `spectoflow brain setup` — registers the `spectoflow mcp` server (lib/mcp-server.js) in the
|
|
4
|
+
* USER-LEVEL MCP config of every coding agent installed on this machine, once per machine. User level
|
|
5
|
+
* because the second brain is personal, and because project-level MCP config is unreliable or absent
|
|
6
|
+
* for several agents (trusted-projects-only in Codex, ignored by Antigravity, missing in Goose/Kimi).
|
|
7
|
+
*
|
|
8
|
+
* Paths and entry shapes come from each agent's own docs (research pass 2026-09-16, see
|
|
9
|
+
* docs/second-brain-design.md). Same rules as lib/mcp.js: an existing entry is never touched, a file
|
|
10
|
+
* that can't be parsed is never rewritten. Claude Code is the one agent wired through its own CLI —
|
|
11
|
+
* it rewrites ~/.claude.json constantly, so editing that file directly could lose the write.
|
|
12
|
+
*/
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { spawnSync } = require('child_process');
|
|
17
|
+
const { REGISTRY } = require('./adapters');
|
|
18
|
+
const detect = require('./detect');
|
|
19
|
+
const { mergeMcpServer } = require('./mcp');
|
|
20
|
+
|
|
21
|
+
const NAME = 'spectoflow';
|
|
22
|
+
|
|
23
|
+
// `spectoflow mcp` when the `spectoflow` on PATH is this very install; otherwise node + this bin's
|
|
24
|
+
// absolute path (running from a clone, or another version on PATH). Always node + path on Windows: there
|
|
25
|
+
// `spectoflow` is an npm .cmd shim, which MCP hosts that spawn without a shell can't start.
|
|
26
|
+
function serverCommand({ binPath, env = process.env, platform = process.platform } = {}) {
|
|
27
|
+
const found = findOnPath('spectoflow', env, platform);
|
|
28
|
+
let same = false;
|
|
29
|
+
if (found && platform !== 'win32') { try { same = fs.realpathSync(found) === fs.realpathSync(binPath); } catch { same = false; } }
|
|
30
|
+
const cmd = same ? { command: 'spectoflow', args: ['mcp'] } : { command: process.execPath, args: [binPath, 'mcp'] };
|
|
31
|
+
if (env.SPECTOFLOW_HOME) cmd.env = { SPECTOFLOW_HOME: env.SPECTOFLOW_HOME };
|
|
32
|
+
return cmd;
|
|
33
|
+
}
|
|
34
|
+
function findOnPath(bin, env, platform) {
|
|
35
|
+
const exts = platform === 'win32' ? ['', ...(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)] : [''];
|
|
36
|
+
for (const d of (env.PATH || env.Path || '').split(path.delimiter).filter(Boolean)) {
|
|
37
|
+
for (const e of exts) { const fp = path.join(d, bin + e); if (fs.existsSync(fp)) return fp; }
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const home = () => os.homedir();
|
|
43
|
+
const appData = () => process.env.APPDATA || path.join(home(), 'AppData', 'Roaming');
|
|
44
|
+
|
|
45
|
+
// id → how to wire it. `file` is resolved lazily (tests point HOME elsewhere).
|
|
46
|
+
const TARGETS = {
|
|
47
|
+
claude: { kind: 'claude-cli', file: () => path.join(process.env.CLAUDE_CONFIG_DIR || home(), '.claude.json') },
|
|
48
|
+
codex: { kind: 'toml', file: () => path.join(process.env.CODEX_HOME || path.join(home(), '.codex'), 'config.toml') },
|
|
49
|
+
cursor: { kind: 'json', file: () => path.join(home(), '.cursor', 'mcp.json') },
|
|
50
|
+
gemini: { kind: 'json', file: () => path.join(home(), '.gemini', 'settings.json') },
|
|
51
|
+
opencode: { kind: 'opencode', file: () => path.join(home(), '.config', 'opencode', 'opencode.json') },
|
|
52
|
+
kiro: { kind: 'json', file: () => path.join(home(), '.kiro', 'settings', 'mcp.json') },
|
|
53
|
+
antigravity: { kind: 'json', file: () => path.join(home(), '.gemini', 'config', 'mcp_config.json') },
|
|
54
|
+
copilot: { kind: 'json', extra: { type: 'local', tools: ['*'] }, file: () => path.join(process.env.COPILOT_HOME || path.join(home(), '.copilot'), 'mcp-config.json') },
|
|
55
|
+
'amazon-q': { kind: 'json', file: () => path.join(home(), '.aws', 'amazonq', 'mcp.json') },
|
|
56
|
+
droid: { kind: 'json', file: () => path.join(home(), '.factory', 'mcp.json') },
|
|
57
|
+
auggie: { kind: 'json', file: () => path.join(home(), '.augment', 'settings.json') },
|
|
58
|
+
goose: { kind: 'yaml-snippet', file: () => (process.platform === 'win32' ? path.join(appData(), 'Block', 'goose', 'config', 'config.yaml') : path.join(home(), '.config', 'goose', 'config.yaml')) },
|
|
59
|
+
kimi: { kind: 'json', file: () => path.join(home(), '.kimi', 'mcp.json') },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const tomlString = (s) => JSON.stringify(String(s));
|
|
63
|
+
function tomlBlock(cmd) {
|
|
64
|
+
const lines = [`[mcp_servers.${NAME}]`, `command = ${tomlString(cmd.command)}`, `args = [${cmd.args.map(tomlString).join(', ')}]`];
|
|
65
|
+
if (cmd.env) lines.push(`env = { ${Object.entries(cmd.env).map(([k, v]) => `${k} = ${tomlString(v)}`).join(', ')} }`);
|
|
66
|
+
return lines.join('\n') + '\n';
|
|
67
|
+
}
|
|
68
|
+
function yamlBlock(cmd) {
|
|
69
|
+
const q = (s) => JSON.stringify(String(s));
|
|
70
|
+
const lines = ['extensions:', ` ${NAME}:`, ' type: stdio', ` name: ${NAME}`, ' enabled: true', ` cmd: ${q(cmd.command)}`, ` args: [${cmd.args.map(q).join(', ')}]`, ' timeout: 300'];
|
|
71
|
+
if (cmd.env) lines.push(` envs: { ${Object.entries(cmd.env).map(([k, v]) => `${k}: ${q(v)}`).join(', ')} }`);
|
|
72
|
+
return lines.join('\n') + '\n';
|
|
73
|
+
}
|
|
74
|
+
const readText = (fp) => { try { return fs.readFileSync(fp, 'utf8'); } catch { return null; } };
|
|
75
|
+
|
|
76
|
+
// Is a `spectoflow` MCP server declared in this Codex TOML, in any of the forms TOML allows:
|
|
77
|
+
// [mcp_servers.spectoflow] (bare or quoted key, trailing comment), a dotted key, or a key inside [mcp_servers].
|
|
78
|
+
function tomlHasServer(text) {
|
|
79
|
+
const key = `(["']?)${NAME}\\1`;
|
|
80
|
+
if (new RegExp(`^\\s*\\[\\s*mcp_servers\\s*\\.\\s*${key}\\s*(\\.[^\\]]*)?\\]\\s*(#.*)?$`, 'm').test(text)) return true;
|
|
81
|
+
if (new RegExp(`^\\s*mcp_servers\\s*\\.\\s*${key}\\s*[.=]`, 'm').test(text)) return true;
|
|
82
|
+
let inTable = false;
|
|
83
|
+
for (const line of text.split(/\r?\n/)) {
|
|
84
|
+
const h = line.match(/^\s*\[\s*([^\]]+?)\s*\]/);
|
|
85
|
+
if (h) { inTable = h[1].replace(/\s+/g, '') === 'mcp_servers'; continue; }
|
|
86
|
+
if (inTable && new RegExp(`^\\s*${key}\\s*[.=]`).test(line)) return true;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Is the spectoflow server already registered for this agent? (true/false; null = can't tell)
|
|
92
|
+
function isWired(id) {
|
|
93
|
+
const t = TARGETS[id]; if (!t) return null;
|
|
94
|
+
const fp = t.file(), text = readText(fp);
|
|
95
|
+
if (text === null) return false;
|
|
96
|
+
if (t.kind === 'toml') return tomlHasServer(text);
|
|
97
|
+
if (t.kind === 'yaml-snippet') return /^extensions:\s*$/m.test(text) && new RegExp(`^ {2}${NAME}:\\s*$`, 'm').test(text);
|
|
98
|
+
try {
|
|
99
|
+
const doc = JSON.parse(text);
|
|
100
|
+
const map = t.kind === 'opencode' ? doc.mcp : doc.mcpServers;
|
|
101
|
+
return !!(map && typeof map === 'object' && Object.prototype.hasOwnProperty.call(map, NAME));
|
|
102
|
+
} catch { return null; }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function wireOne(id, cmd, { dryRun, run }) {
|
|
106
|
+
const t = TARGETS[id], fp = t.file();
|
|
107
|
+
if (t.kind === 'claude-cli') {
|
|
108
|
+
if (isWired(id)) return { status: 'exists', file: fp };
|
|
109
|
+
// Name before --env: --env takes several values and would swallow the name otherwise.
|
|
110
|
+
const args = ['mcp', 'add', '--scope', 'user', NAME, ...Object.entries(cmd.env || {}).flatMap(([k, v]) => ['--env', `${k}=${v}`]), '--', cmd.command, ...cmd.args];
|
|
111
|
+
if (dryRun) return { status: 'added', file: fp, via: `claude ${args.join(' ')}` };
|
|
112
|
+
const r = run('claude', args);
|
|
113
|
+
const out = `${r.stderr || ''}${r.stdout || ''}`.trim();
|
|
114
|
+
if (r.status === 0) return { status: 'added', file: fp, via: 'claude mcp add' };
|
|
115
|
+
if (/already exists/i.test(out)) return { status: 'exists', file: fp };
|
|
116
|
+
return { status: 'failed', file: fp, detail: out || (r.error && r.error.message) || 'failed', manual: `claude ${args.join(' ')}` };
|
|
117
|
+
}
|
|
118
|
+
if (t.kind === 'json') return { status: mergeMcpServer(fp, NAME, { ...(t.extra || {}), command: cmd.command, args: cmd.args, ...(cmd.env ? { env: cmd.env } : {}) }, { dryRun }), file: fp };
|
|
119
|
+
if (t.kind === 'opencode') return { status: mergeMcpServer(fp, NAME, { type: 'local', command: [cmd.command, ...cmd.args], enabled: true, ...(cmd.env ? { environment: cmd.env } : {}) }, { key: 'mcp', dryRun }), file: fp };
|
|
120
|
+
if (t.kind === 'toml') {
|
|
121
|
+
const text = readText(fp);
|
|
122
|
+
if (text !== null && isWired(id)) return { status: 'exists', file: fp };
|
|
123
|
+
// Any other mention of spectoflow is a form we can't classify: appending could declare the table twice,
|
|
124
|
+
// and a duplicate key makes Codex refuse its whole config. Hand the user the block instead.
|
|
125
|
+
if (text !== null && text.includes(NAME)) return { status: 'manual', file: fp, manual: tomlBlock(cmd) };
|
|
126
|
+
// `mcp_servers = { … }` (an inline table) can't be extended by a [mcp_servers.x] header either.
|
|
127
|
+
if (text !== null && /^\s*mcp_servers\s*=/m.test(text)) return { status: 'manual', file: fp, manual: tomlBlock(cmd) };
|
|
128
|
+
if (!dryRun) {
|
|
129
|
+
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
130
|
+
fs.writeFileSync(fp, text === null ? tomlBlock(cmd) : text.replace(/\s*$/, '') + '\n\n' + tomlBlock(cmd));
|
|
131
|
+
}
|
|
132
|
+
return { status: text === null ? 'created' : 'added', file: fp };
|
|
133
|
+
}
|
|
134
|
+
// Goose: YAML can't be edited safely without a parser — hand the user the exact block instead.
|
|
135
|
+
if (isWired(id)) return { status: 'exists', file: fp };
|
|
136
|
+
return { status: 'manual', file: fp, manual: yamlBlock(cmd) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// No shell on POSIX. On Windows `claude` is a .cmd shim, which needs cmd.exe — with every argument quoted,
|
|
140
|
+
// or a path like C:\\Program Files\\nodejs\\node.exe would be split.
|
|
141
|
+
function defaultRun(bin, args) {
|
|
142
|
+
if (process.platform !== 'win32') return spawnSync(bin, args, { encoding: 'utf8', windowsHide: true });
|
|
143
|
+
const q = (a) => `"${String(a).replace(/"/g, '""')}"`;
|
|
144
|
+
return spawnSync('cmd.exe', ['/d', '/s', '/c', `"${[bin, ...args].map(q).join(' ')}"`], { encoding: 'utf8', windowsHide: true, windowsVerbatimArguments: true });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// → [{ id, label, status: created|added|exists|skipped|manual|failed, file, manual?, detail? }]
|
|
148
|
+
// for every agent installed on this machine. `agents` overrides detection (tests, explicit choice).
|
|
149
|
+
function setup({ binPath, dryRun = false, agents, run = defaultRun, env = process.env } = {}) {
|
|
150
|
+
const cmd = serverCommand({ binPath, env });
|
|
151
|
+
const ids = agents || REGISTRY.filter((a) => TARGETS[a.id] && detect.binOnPath(a.detect.bin, { env })).map((a) => a.id);
|
|
152
|
+
return ids.filter((id) => TARGETS[id]).map((id) => {
|
|
153
|
+
const label = (REGISTRY.find((a) => a.id === id) || {}).label || id;
|
|
154
|
+
try { return { id, label, ...wireOne(id, cmd, { dryRun, run }) }; }
|
|
155
|
+
catch (e) { return { id, label, status: 'failed', file: TARGETS[id].file(), detail: e.message }; }
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// For the dashboard page: each installed agent and whether the server is registered for it.
|
|
160
|
+
function status({ env = process.env } = {}) {
|
|
161
|
+
return REGISTRY.filter((a) => TARGETS[a.id] && detect.binOnPath(a.detect.bin, { env }))
|
|
162
|
+
.map((a) => ({ id: a.id, label: a.label, wired: isWired(a.id) === true }));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = { setup, status, isWired, serverCommand, TARGETS, NAME };
|
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 };
|