total-recall-brain 3.32.0 → 3.32.1

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.
@@ -88,6 +88,11 @@ const COMMANDS = {
88
88
  brain: 'brain.mjs',
89
89
  config: 'config.mjs',
90
90
  skill: 'skill.mjs',
91
+ 'project-id': 'project-id.mjs',
92
+ api: 'api.mjs',
93
+ brief: 'brief.mjs',
94
+ group: 'group.mjs',
95
+ groups: 'group.mjs',
91
96
  secret: 'secret.mjs',
92
97
  secrets: 'secret.mjs',
93
98
  help: 'help.mjs',
@@ -167,6 +172,19 @@ async function main() {
167
172
 
168
173
  const handlerFile = COMMANDS[command];
169
174
  if (!handlerFile) {
175
+ // Any HTTP API route group without a dedicated verb: `total-recall <group> <action> …`
176
+ try {
177
+ const { loadApiRoutes } = await import('../src/core/api-routes.mjs');
178
+ if ((await loadApiRoutes()).some((r) => r.group === command)) {
179
+ const { default: apiCli } = await import('../src/cli/api.mjs');
180
+ await apiCli(args);
181
+ process.exit(process.exitCode ?? 0);
182
+ }
183
+ } catch (err) {
184
+ console.error(`Error: ${err.message}`);
185
+ process.exit(1);
186
+ }
187
+
170
188
  // Check if it is a dynamic integration command configured in the VFS
171
189
  try {
172
190
  const { agentDir, resolveBrainLayer } = await import('../src/core/config.mjs');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.32.0",
3
+ "version": "3.32.1",
4
4
  "description": "Personal AI system — database-free, SSSS-powered intelligence engine deployable to any POSIX host.",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -64,7 +64,7 @@
64
64
  "homepage": "https://github.com/gregiteen/total-recall#readme",
65
65
  "dependencies": {
66
66
  "@simplewebauthn/server": "^13.3.2",
67
- "@ssss/cli": "github:gregiteen/ssss#v0.10.0",
67
+ "@ssss/cli": "github:gregiteen/ssss#v0.10.1",
68
68
  "bcrypt": "^6.0.0",
69
69
  "better-sqlite3": "^12.11.1",
70
70
  "cookie-parser": "^1.4.7",
@@ -0,0 +1,133 @@
1
+ /**
2
+ * total-recall api — every HTTP API route as a CLI verb.
3
+ *
4
+ * total-recall api list [group] [--json]
5
+ * total-recall api <group> <action> [positional params…] [--data '<json>'] [--query k=v]… [--json]
6
+ * total-recall api call <METHOD> </api/path> [--data '<json>'] [--query k=v]…
7
+ *
8
+ * Also reachable as `total-recall <group> <action>` for any group that has no
9
+ * dedicated verb. Talks to the brain server named in config/brain.json.
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { loadApiRoutes } from '../core/api-routes.mjs';
14
+ import { detectProjectBrain, getGlobalBrainDir } from './agent-dir.mjs';
15
+
16
+ function brainConnection() {
17
+ const dirs = [detectProjectBrain(process.cwd())?.brainDir, getGlobalBrainDir()].filter(Boolean);
18
+ for (const d of dirs) {
19
+ try {
20
+ const c = JSON.parse(fs.readFileSync(path.join(d, 'config', 'brain.json'), 'utf8'));
21
+ if (c.url) return { url: process.env.TR_BRAIN || c.url, token: process.env.TR_PAT || c.token };
22
+ } catch {
23
+ /* try next */
24
+ }
25
+ }
26
+ return { url: process.env.TR_BRAIN || 'http://localhost:3000', token: process.env.TR_PAT };
27
+ }
28
+
29
+ function parse(argv) {
30
+ const out = { pos: [], query: {}, data: undefined, json: false, help: false };
31
+ for (let i = 0; i < argv.length; i++) {
32
+ const a = argv[i];
33
+ if (a === '--json') out.json = true;
34
+ else if (a === '--help' || a === '-h') out.help = true;
35
+ else if (a === '--data') out.data = argv[++i];
36
+ else if (a === '--query') {
37
+ const [k, ...v] = String(argv[++i] ?? '').split('=');
38
+ if (k) out.query[k] = v.join('=');
39
+ } else out.pos.push(a);
40
+ }
41
+ return out;
42
+ }
43
+
44
+ async function call(method, routePath, { query, data }) {
45
+ const { url, token } = brainConnection();
46
+ const qs = new URLSearchParams(query).toString();
47
+ const res = await fetch(`${url.replace(/\/$/, '')}${routePath}${qs ? `?${qs}` : ''}`, {
48
+ method,
49
+ headers: {
50
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
51
+ ...(data !== undefined ? { 'Content-Type': 'application/json' } : {}),
52
+ },
53
+ body: data !== undefined ? data : undefined,
54
+ });
55
+ const text = await res.text();
56
+ let body;
57
+ try {
58
+ body = JSON.parse(text);
59
+ } catch {
60
+ body = text;
61
+ }
62
+ return { status: res.status, ok: res.ok, body };
63
+ }
64
+
65
+ function printRoutes(routes) {
66
+ const width = Math.max(...routes.map((r) => `${r.group} ${r.action}`.length), 10);
67
+ for (const r of routes) {
68
+ const params = r.params.map((p) => ` <${p}>`).join('');
69
+ console.log(` ${`${r.group} ${r.action}${params}`.padEnd(width + 12)} ${r.method.padEnd(6)} ${r.path}`);
70
+ }
71
+ }
72
+
73
+ export default async function apiCli(argv = []) {
74
+ const opts = parse(argv[0] === 'api' ? argv.slice(1) : argv);
75
+ const routes = await loadApiRoutes();
76
+ const [first, second, ...rest] = opts.pos;
77
+
78
+ if (opts.help || !first || first === 'list') {
79
+ const group = first === 'list' ? second : null;
80
+ const shown = group ? routes.filter((r) => r.group === group) : routes;
81
+ if (opts.json) return console.log(JSON.stringify(shown, null, 2));
82
+ if (!group && !opts.help && first !== 'list') {
83
+ console.log('Usage: total-recall api list [group] | api <group> <action> [params] [--data json] [--query k=v] | api call <METHOD> <path>\n');
84
+ }
85
+ if (!shown.length) return console.log(` No routes for group "${group}".`);
86
+ if (!group) {
87
+ const groups = [...new Set(routes.map((r) => r.group))];
88
+ console.log(` ${routes.length} routes in ${groups.length} groups: ${groups.join(', ')}\n`);
89
+ if (!opts.help && first !== 'list') return;
90
+ }
91
+ return printRoutes(shown);
92
+ }
93
+
94
+ if (first === 'call') {
95
+ const [method, routePath] = [String(second || '').toUpperCase(), rest[0]];
96
+ if (!method || !routePath) {
97
+ console.error('Usage: total-recall api call <METHOD> </api/path> [--data json] [--query k=v]');
98
+ process.exit(1);
99
+ }
100
+ return finish(await call(method, routePath, opts), opts);
101
+ }
102
+
103
+ const group = first;
104
+ const groupRoutes = routes.filter((r) => r.group === group);
105
+ if (!groupRoutes.length) {
106
+ console.error(`Unknown API group "${group}". Run: total-recall api list`);
107
+ process.exit(1);
108
+ }
109
+ if (!second) return printRoutes(groupRoutes);
110
+ const route = groupRoutes.find((r) => r.action === second);
111
+ if (!route) {
112
+ console.error(`No "${second}" action in ${group}. Actions:`);
113
+ printRoutes(groupRoutes);
114
+ process.exit(1);
115
+ }
116
+ if (rest.length < route.params.length) {
117
+ console.error(`Missing parameter(s): ${route.params.slice(rest.length).map((p) => `<${p}>`).join(' ')}`);
118
+ process.exit(1);
119
+ }
120
+ let p = route.path;
121
+ route.params.forEach((name, i) => {
122
+ p = p.replace(`:${name}`, encodeURIComponent(rest[i]));
123
+ });
124
+ return finish(await call(route.method, p, opts), opts);
125
+ }
126
+
127
+ function finish(r, opts) {
128
+ console.log(typeof r.body === 'string' ? r.body : JSON.stringify(r.body, null, opts.json ? 0 : 2));
129
+ if (!r.ok) {
130
+ console.error(`HTTP ${r.status}`);
131
+ process.exitCode = 1;
132
+ }
133
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * total-recall brief
3
+ * Run first in every session (the global /start does): read-only brief of time, repo state, project id/groups, rules, commands, secrets health (counts only), tasks, trackers, and this repo's own start skill to follow next.
4
+ * Built-in (ships with the package); was a machine-local composable command.
5
+ */
6
+ export default async function (args = []) {
7
+ // total-recall brief [--json] [--mesh]
8
+ // Session-start brief for the repo you are in: time, repo state, project identity and groups, rules, commands, secrets health (counts only), tasks, trackers, and this repo's own start skill.
9
+ const { spawn, spawnSync } = await import('node:child_process');
10
+ const fs = await import('node:fs');
11
+ const path = await import('node:path');
12
+
13
+ const argl = Array.isArray(args) ? args : [];
14
+ const asJson = argl.includes('--json');
15
+ const withMesh = argl.includes('--mesh');
16
+ if (argl.includes('--help') || argl.includes('-h')) {
17
+ console.log(`Usage: total-recall brief [--json] [--mesh]
18
+
19
+ Read-only brief for the start of a session in the current repo. Composes:
20
+ project-id, group, command list, secret catalog/rotation-due/shared/tracking-health
21
+ (counts only, never values), task list, mesh status (--mesh adds mesh ping), git,
22
+ the compiled rules line, docs/projects trackers, openwiki, and the repo's own
23
+ .agent/skills/start/SKILL.md, which you follow next when it exists.`);
24
+ return;
25
+ }
26
+
27
+ // Run a Total Recall verb (async, bounded). Output is parsed for counts only.
28
+ const tr = (verbArgs, ms = 45000) => new Promise((resolve) => {
29
+ const child = spawn(process.execPath, [process.argv[1], ...verbArgs], { cwd: process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] });
30
+ let out = ''; let err = '';
31
+ child.stdout.on('data', (d) => { out += d; });
32
+ child.stderr.on('data', (d) => { err += d; });
33
+ const timer = setTimeout(() => { child.kill('SIGKILL'); resolve({ code: null, out, err, timedOut: true }); }, ms);
34
+ child.on('close', (code) => { clearTimeout(timer); resolve({ code, out, err, timedOut: false }); });
35
+ });
36
+ const jsonLine = (text) => {
37
+ for (const line of text.split('\n').reverse()) {
38
+ const t = line.trim();
39
+ if ((t.startsWith('{') || t.startsWith('[')) && !t.includes('"subsystem"')) { try { return JSON.parse(t); } catch { /* next */ } }
40
+ }
41
+ try { return JSON.parse(text.slice(text.indexOf('{'))); } catch { return null; }
42
+ };
43
+ const num = (text, key) => { const m = text.match(new RegExp(`${key}=(\\d+)`)); return m ? Number(m[1]) : null; };
44
+ const git = (...a) => { const r = spawnSync('git', a, { cwd: process.cwd(), encoding: 'utf8' }); return r.status === 0 ? r.stdout.replace(/\r?\n$/, '') : null; };
45
+
46
+ const now = new Date();
47
+ const root = git('rev-parse', '--show-toplevel') || process.cwd();
48
+ const rel = (p) => path.relative(root, p) || '.';
49
+
50
+ const [pid, groupsRes, cmds, catalog, due, shared, tracking, tasks, secretList, mesh, ping] = await Promise.all([
51
+ tr(['project-id', '--json']),
52
+ tr(['group', 'list', '--json']),
53
+ tr(['command', 'list', '--json']),
54
+ tr(['secret', 'catalog']),
55
+ tr(['secret', 'rotation-due']),
56
+ tr(['secret', 'shared']),
57
+ tr(['secret', 'tracking-health']),
58
+ tr(['task', 'list']),
59
+ tr(['secret', 'list']),
60
+ tr(['mesh', 'status', '--json']),
61
+ withMesh ? tr(['mesh', 'ping', '--json'], 60000) : Promise.resolve(null),
62
+ ]);
63
+
64
+ // Project identity + the groups that contain it (recursively).
65
+ const project = jsonLine(pid.out);
66
+ const groups = [];
67
+ const groupList = jsonLine(groupsRes.out) || [];
68
+ if (project?.project_id && Array.isArray(groupList) && groupList.length) {
69
+ const members = await Promise.all(groupList.map((g) => tr(['group', 'members', g.id, '--recursive', '--json'])));
70
+ groupList.forEach((g, i) => {
71
+ const list = jsonLine(members[i].out) || [];
72
+ if (Array.isArray(list) && list.some((m) => m.id === project.project_id)) groups.push({ id: g.id, name: g.name });
73
+ });
74
+ }
75
+
76
+ // Repo state.
77
+ const status = git('status', '--porcelain');
78
+ const repo = {
79
+ root,
80
+ branch: git('rev-parse', '--abbrev-ref', 'HEAD'),
81
+ dirty: status === null ? null : status.split('\n').filter(Boolean).length,
82
+ last_commit: git('log', '-1', '--format=%h %s (%cr)'),
83
+ upstream: git('rev-list', '--left-right', '--count', '@{upstream}...HEAD'),
84
+ };
85
+ if (repo.upstream) { const [behind, ahead] = repo.upstream.split(/\s+/).map(Number); repo.upstream = { ahead, behind, note: 'as of last fetch' }; }
86
+
87
+ // Compiled rules (whatever the IDE surface says is active).
88
+ let rules = null;
89
+ for (const f of ['CLAUDE.md', 'AGENTS.md', 'INSTRUCTIONS.md']) {
90
+ const p = path.join(root, f);
91
+ if (!fs.existsSync(p)) continue;
92
+ const m = fs.readFileSync(p, 'utf8').match(/Active Rules: (\d+) invariants, (\d+) preferences, (\d+) corrections/);
93
+ if (m) { rules = { file: f, invariants: +m[1], preferences: +m[2], corrections: +m[3], compiled: fs.statSync(p).mtime.toISOString() }; break; }
94
+ }
95
+
96
+ // Trackers, skills, openwiki, repo start skill.
97
+ const dirList = (p) => { try { return fs.readdirSync(p, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort(); } catch { return []; } };
98
+ const trackers = { in_progress: dirList(path.join(root, 'docs/projects/in-progress')), planned: dirList(path.join(root, 'docs/projects/planned')) };
99
+ const skillsDir = path.join(root, '.agent/skills');
100
+ const skills = dirList(skillsDir).filter((s) => fs.existsSync(path.join(skillsDir, s, 'SKILL.md')));
101
+ // A repo start skill is the repo's own; a deployed copy of the global one (repo_scoped: false) is not.
102
+ const startSkill = ['.agent/skills/start/SKILL.md', '.claude/skills/start/SKILL.md'].map((p) => path.join(root, p))
103
+ .find((p) => { try { return !/^repo_scoped:\s*false/m.test(fs.readFileSync(p, 'utf8').split('\n---')[0]); } catch { return false; } }) || null;
104
+ // OpenWiki: the repo's own wiki (openwiki/) first, then the brain copy.
105
+ const openwiki = ['openwiki', '.agent/skills/total-recall/openwiki', '.agent/openwiki'].map((d) => path.join(root, d)).filter((d) => fs.existsSync(d)).map((d) => {
106
+ const pages = fs.readdirSync(d).filter((f) => f.endsWith('.md'));
107
+ const entry = ['quickstart.md', 'README.md'].find((f) => pages.includes(f)) || pages[0] || null;
108
+ const newest = pages.reduce((t, f) => Math.max(t, fs.statSync(path.join(d, f)).mtimeMs), 0);
109
+ return { dir: rel(d), pages: pages.length, entry: entry ? rel(path.join(d, entry)) : null, updated: newest ? new Date(newest).toISOString() : null };
110
+ }).filter((w) => w.pages > 0);
111
+
112
+ // Secrets: counts and exit codes only — never values or masked fragments.
113
+ const secrets = {
114
+ keys: num(catalog.out, 'keys'),
115
+ providers: num(catalog.out, 'providers'),
116
+ rotation_due: /No secrets overdue/.test(due.out) ? 0 : (due.code === 0 ? num(catalog.out, 'rotate_due') : (num(due.out, 'due') ?? 'check')),
117
+ shared_values: shared.code === 0 ? 0 : (num(shared.out, 'error_groups') ?? num(shared.out, 'groups') ?? 'check'),
118
+ untracked: tracking.code === 0 ? 0 : (num(tracking.out, 'errors') ?? 'check'),
119
+ };
120
+ const cmdList = jsonLine(cmds.out) || [];
121
+ const taskLines = tasks.out.split('\n');
122
+ const taskCounts = { pending: taskLines.filter((l) => /^\s*\[pending\]/.test(l)).length, in_progress: taskLines.filter((l) => /^\s*\[in_progress\]/.test(l)).length };
123
+ const meshStatus = jsonLine(mesh.out);
124
+ const pingData = ping ? jsonLine(ping.out) : null;
125
+
126
+ // Domain: what this repo can reach and what runs on its own. Names only.
127
+ const os = await import('node:os');
128
+ const providers = [...new Set(secretList.out.split('\n').map((l) => l.match(/^\s*•\s+\S+\s+len=\s*\d+\s+(\S+)/)?.[1]).filter(Boolean))].sort();
129
+ const integrations = [];
130
+ for (const [scope, dir] of [['project', path.join(root, '.agent/skills/total-recall/integrations')], ['global', path.join(os.homedir(), '.agent/skills/total-recall/integrations')]]) {
131
+ try { for (const f of fs.readdirSync(dir)) if (f.endsWith('.md')) integrations.push(`${f.slice(0, -3)} (${scope})`); } catch { /* none */ }
132
+ }
133
+ const repoName = path.basename(root);
134
+ const automations = [];
135
+ try {
136
+ const la = path.join(os.homedir(), 'Library/LaunchAgents');
137
+ for (const f of fs.readdirSync(la)) {
138
+ if (!f.endsWith('.plist')) continue;
139
+ const body = fs.readFileSync(path.join(la, f), 'utf8');
140
+ if (body.includes(root + '/') || body.includes(root + '<') || f.includes(repoName)) automations.push(`launchd ${f.replace(/\.plist$/, '')}`);
141
+ }
142
+ } catch { /* not macOS */ }
143
+ const cron = spawnSync('crontab', ['-l'], { encoding: 'utf8' });
144
+ if (cron.status === 0) cron.stdout.split('\n').filter((l) => l.trim() && !l.startsWith('#') && l.includes(root)).forEach((l) => automations.push(`cron ${l.trim().split(/\s+/).slice(0, 5).join(' ')}`));
145
+ let scripts = [];
146
+ try { scripts = Object.keys(JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).scripts || {}); } catch { /* none */ }
147
+ const launchers = fs.readdirSync(root).filter((f) => { try { const st = fs.statSync(path.join(root, f)); return st.isFile() && (st.mode & 0o111) && !f.includes('.'); } catch { return false; } });
148
+ const domain = { providers, integrations, automations, launchers, npm_scripts: scripts };
149
+
150
+ const warnings = [];
151
+ if (!project?.project_id) warnings.push('No project brain here (total-recall init) — memory, secrets and rules are global-only.');
152
+ if (secrets.rotation_due && secrets.rotation_due !== 0) warnings.push(`Secrets overdue for rotation: ${secrets.rotation_due} (total-recall secret rotation-due).`);
153
+ if (secrets.shared_values && secrets.shared_values !== 0) warnings.push(`Credential values reused across keys/apps: ${secrets.shared_values} group(s) (total-recall secret shared).`);
154
+ if (secrets.untracked && secrets.untracked !== 0) warnings.push(`Secrets without usage/account tracking: ${secrets.untracked} (total-recall secret tracking-health).`);
155
+ if (rules && (now - new Date(rules.compiled)) > 7 * 86400000) warnings.push(`Instruction surfaces last compiled ${rules.compiled.slice(0, 10)} — run total-recall compile.`);
156
+ for (const w of openwiki) if (w.updated && (now - new Date(w.updated)) > 30 * 86400000) warnings.push(`OpenWiki ${w.dir} last updated ${w.updated.slice(0, 10)} — may be stale.`);
157
+ if (meshStatus && meshStatus.configured === false) warnings.push(`Mesh not configured: ${meshStatus.reason || 'unknown'}.`);
158
+ for (const [name, r] of Object.entries({ project_id: pid, catalog, tasks, mesh })) if (r?.timedOut) warnings.push(`${name} check timed out.`);
159
+
160
+ const brief = {
161
+ generated: { local: now.toString(), utc: now.toISOString(), timezone: Intl.DateTimeFormat().resolvedOptions().timeZone },
162
+ project: project ? { id: project.project_id, name: project.name, groups } : null,
163
+ repo, rules, skills, repo_start_skill: startSkill ? rel(startSkill) : null, openwiki, trackers,
164
+ domain,
165
+ commands: cmdList.map((c) => ({ name: c.name, scope: c.scope, risk: c.risk })),
166
+ secrets, tasks: taskCounts,
167
+ mesh: { configured: meshStatus?.configured ?? null, ping: pingData },
168
+ warnings,
169
+ next: startSkill
170
+ ? [`Read and follow ${rel(startSkill)} — this repo's own start steps.`]
171
+ : [`This repo has no start skill of its own: read ${openwiki[0]?.entry || 'the openwiki'}${skills.includes('repo-expert') ? ' and .agent/skills/repo-expert/SKILL.md' : ''} before changing code.`],
172
+ };
173
+
174
+ if (asJson) { console.log(JSON.stringify(brief, null, 2)); return; }
175
+
176
+ const L = [];
177
+ L.push(`# Brief — ${project?.name || path.basename(root)}`);
178
+ L.push(`Time: ${now.toLocaleString()} ${brief.generated.timezone} (UTC ${brief.generated.utc})`);
179
+ L.push(`Project id: ${project?.project_id || 'none'}${groups.length ? ` groups: ${groups.map((g) => g.name).join(', ')}` : ''}`);
180
+ L.push(`Repo: ${repo.branch || '?'} dirty files: ${repo.dirty ?? '?'} last: ${repo.last_commit || '?'}${repo.upstream ? ` ahead ${repo.upstream.ahead}/behind ${repo.upstream.behind} (last fetch)` : ''}`);
181
+ L.push(`Rules: ${rules ? `${rules.invariants} invariants, ${rules.preferences} preferences, ${rules.corrections} corrections (${rules.file}, compiled ${rules.compiled.slice(0, 16).replace('T', ' ')})` : 'no compiled surface'}`);
182
+ L.push(`Skills (${skills.length}): ${skills.join(', ') || 'none'}`);
183
+ L.push(`Commands (${cmdList.length}): ${cmdList.map((c) => c.name + (c.risk && !['read', 'write'].includes(c.risk) ? `[${c.risk}]` : '')).join(', ') || 'none'}`);
184
+ L.push(`Secrets: ${secrets.keys ?? '?'} keys / ${secrets.providers ?? '?'} providers; rotation due ${secrets.rotation_due}; shared ${secrets.shared_values}; untracked ${secrets.untracked}`);
185
+ L.push(`Providers with keys (${providers.length}): ${providers.join(', ') || 'none'}`);
186
+ L.push(`Integrations: ${integrations.join(', ') || 'none'} | automations: ${automations.join(', ') || 'none'}`);
187
+ L.push(`Entry points: launchers ${launchers.map((f) => './' + f).join(' ') || 'none'}; npm scripts ${scripts.length}${scripts.length ? ` (${scripts.slice(0, 12).join(', ')}${scripts.length > 12 ? ', …' : ''})` : ''}`);
188
+ L.push(`Tasks: ${taskCounts.pending} pending, ${taskCounts.in_progress} in progress`);
189
+ L.push(`Mesh: ${meshStatus?.configured ? 'configured' : 'not configured'}${pingData ? ` — ping: ${JSON.stringify(pingData).slice(0, 300)}` : ' (add --mesh to ping nodes)'}`);
190
+ L.push(`Trackers in progress: ${trackers.in_progress.join(', ') || 'none'}${trackers.planned.length ? ` | planned: ${trackers.planned.join(', ')}` : ''}`);
191
+ L.push(`OpenWiki: ${openwiki.length ? openwiki.map((w) => `${w.entry} (${w.pages} pages, updated ${w.updated.slice(0, 10)})`).join('; ') : 'none'}`);
192
+ if (warnings.length) { L.push('', 'Warnings:'); warnings.forEach((w) => L.push(`- ${w}`)); }
193
+ L.push('', 'Next:'); brief.next.forEach((n) => L.push(`- ${n}`));
194
+ console.log(L.join('\n'));
195
+ }
@@ -21,7 +21,12 @@ const KEY_MAP = {
21
21
 
22
22
  'daily_cap_usd': { file: 'budget', path: 'budget.daily_cap_usd', type: 'number' },
23
23
  'weekly_cap_usd': { file: 'budget', path: 'budget.weekly_cap_usd', type: 'number' },
24
- 'budget_enabled': { file: 'budget', path: 'budget.enabled', type: 'boolean' }
24
+ 'budget_enabled': { file: 'budget', path: 'budget.enabled', type: 'boolean' },
25
+
26
+ // Size of each rules section in the compiled instruction files (characters).
27
+ 'rules_invariants_budget_chars': { file: 'surface', path: 'rules.invariants_budget_chars', type: 'number' },
28
+ 'rules_preferences_budget_chars': { file: 'surface', path: 'rules.preferences_budget_chars', type: 'number' },
29
+ 'rules_corrections_budget_chars': { file: 'surface', path: 'rules.corrections_budget_chars', type: 'number' },
25
30
  };
26
31
 
27
32
  function resolveKeyInfo(key) {
@@ -33,7 +38,7 @@ function resolveKeyInfo(key) {
33
38
 
34
39
  // Fallback / dynamic inference
35
40
  const isBudget = key.startsWith('budget.') || key === 'budget';
36
- const file = isBudget ? 'budget' : 'security';
41
+ const file = isBudget ? 'budget' : key.startsWith('rules.') ? 'surface' : 'security';
37
42
  const targetPath = key;
38
43
 
39
44
  let type = 'string';
@@ -131,6 +136,9 @@ export default async function configCommand(args) {
131
136
  daily_cap_usd Cost-control daily cap (number)
132
137
  weekly_cap_usd Cost-control weekly cap (number)
133
138
  budget_enabled Enable/disable cost limit checks (true | false)
139
+ rules_invariants_budget_chars Characters for the invariants section of CLAUDE.md/AGENTS.md (number; default 9000)
140
+ rules_preferences_budget_chars Same for preferences (default 4000)
141
+ rules_corrections_budget_chars Same for corrections (default 8000)
134
142
  allowed_origins CORS origins (e.g. "http://localhost:5173,http://localhost:3000")
135
143
 
136
144
  Examples:
@@ -145,12 +153,21 @@ export default async function configCommand(args) {
145
153
  const configDir = path.join(brainDir, 'config');
146
154
  const securityPath = path.join(configDir, 'security.yml');
147
155
  const budgetPath = path.join(configDir, 'budget.yml');
156
+ const surfacePath = path.join(configDir, 'surface.yml');
148
157
  const layerLabel = layer === 'project' ? '[project]' : layer === 'global' ? '[global]' : '[auto]';
149
158
 
150
159
  // Load configs safely
151
160
  let securityConfig = {};
152
161
  let budgetConfig = {};
162
+ let surfaceConfig = {};
163
+ const objFor = (file) => (file === 'budget' ? budgetConfig : file === 'surface' ? surfaceConfig : securityConfig);
164
+ const pathFor = (file) => (file === 'budget' ? budgetPath : file === 'surface' ? surfacePath : securityPath);
153
165
 
166
+ if (fs.existsSync(surfacePath)) {
167
+ try {
168
+ surfaceConfig = yaml.parse(fs.readFileSync(surfacePath, 'utf8')) || {};
169
+ } catch {}
170
+ }
154
171
  if (fs.existsSync(securityPath)) {
155
172
  try {
156
173
  securityConfig = yaml.parse(fs.readFileSync(securityPath, 'utf8')) || {};
@@ -166,7 +183,7 @@ export default async function configCommand(args) {
166
183
  const key = remainingArgs[1];
167
184
  if (key) {
168
185
  const info = resolveKeyInfo(key);
169
- const targetObj = info.file === 'budget' ? budgetConfig : securityConfig;
186
+ const targetObj = objFor(info.file);
170
187
  const value = getNestedProp(targetObj, info.path);
171
188
 
172
189
  if (args.includes('--json') || args.includes('-j')) {
@@ -178,7 +195,7 @@ export default async function configCommand(args) {
178
195
  // List all known keys
179
196
  const allSettings = {};
180
197
  for (const [flatKey, info] of Object.entries(KEY_MAP)) {
181
- const targetObj = info.file === 'budget' ? budgetConfig : securityConfig;
198
+ const targetObj = objFor(info.file);
182
199
  const val = getNestedProp(targetObj, info.path);
183
200
  allSettings[flatKey] = val !== undefined ? val : null;
184
201
  }
@@ -193,7 +210,8 @@ export default async function configCommand(args) {
193
210
  }
194
211
  console.log('\n 📍 Storage locations:');
195
212
  console.log(` - Security: ${securityPath}`);
196
- console.log(` - Budget: ${budgetPath}\n`);
213
+ console.log(` - Budget: ${budgetPath}`);
214
+ console.log(` - Surface: ${surfacePath}\n`);
197
215
  }
198
216
  }
199
217
  return;
@@ -214,8 +232,8 @@ export default async function configCommand(args) {
214
232
  }
215
233
 
216
234
  const info = resolveKeyInfo(key);
217
- const targetObj = info.file === 'budget' ? budgetConfig : securityConfig;
218
- const targetPath = info.file === 'budget' ? budgetPath : securityPath;
235
+ const targetObj = objFor(info.file);
236
+ const targetPath = pathFor(info.file);
219
237
 
220
238
  try {
221
239
  const parsedValue = castAndValidate(rawValue, info.type, info.values);
@@ -0,0 +1,151 @@
1
+ /**
2
+ * total-recall group
3
+ * Manage groups of projects and groups of groups (create, add, remove, list, show, tree, members --recursive, delete).
4
+ * Built-in (ships with the package); was a machine-local composable command.
5
+ */
6
+ export default async function (args = []) {
7
+ // total-recall group <create|add|remove|list|tree|members|delete|show> …
8
+ // Groups of projects and groups of groups. Each group is an SSSS document
9
+ // (type: project_group) in the global brain: ~/.agent/skills/total-recall/groups/<uuid>.md
10
+ // Members are projects (random-UUID project ids) or other groups. Nesting is
11
+ // cycle-checked; a project or group may belong to several groups.
12
+ const fs = await import('node:fs');
13
+ const path = await import('node:path');
14
+ const os = await import('node:os');
15
+ const { randomUUID } = await import('node:crypto');
16
+ const { spawnSync } = await import('node:child_process');
17
+ const { createRequire } = await import('node:module');
18
+ const YAML = (await import('yaml')).default;
19
+
20
+ const argl = Array.isArray(args) ? args : [];
21
+ const asJson = argl.includes('--json');
22
+ const recursive = argl.includes('--recursive') || argl.includes('-r');
23
+ const pos = argl.filter((a) => !a.startsWith('-'));
24
+ const [sub, ...rest] = pos;
25
+ const DIR = path.join(os.homedir(), '.agent', 'skills', 'total-recall', 'groups');
26
+ fs.mkdirSync(DIR, { recursive: true });
27
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
28
+ const die = (m) => { process.exitCode = 1; if (asJson) console.log(JSON.stringify({ ok: false, error: m })); else console.error('✖ ' + m); };
29
+ const help = () => console.log(`Usage: total-recall group <command> [--json]
30
+
31
+ create <name> [--description "<text>"] New group (random UUID id)
32
+ add <group> <member…> Member = repo path (→ its project id), project UUID, or group name/UUID
33
+ remove <group> <member…> Remove members
34
+ list All groups
35
+ show <group> One group with direct members
36
+ tree [<group>] Nested view (all roots if omitted)
37
+ members <group> [--recursive] Direct members, or every project reached through nested groups
38
+ delete <group> Delete a group (refused while other groups contain it)
39
+
40
+ Groups are SSSS project_group documents in ${DIR}. Nesting is cycle-checked.`);
41
+
42
+ const load = () => fs.readdirSync(DIR).filter((f) => f.endsWith('.md')).map((f) => {
43
+ const raw = fs.readFileSync(path.join(DIR, f), 'utf8');
44
+ const m = raw.match(/^---\n([\s\S]*?)\n---/);
45
+ return m ? YAML.parse(m[1]) : null;
46
+ }).filter(Boolean);
47
+ const save = (g) => {
48
+ g.timestamp = new Date().toISOString();
49
+ const body = `---\n${YAML.stringify(g).trim()}\n---\n\n# ${g.title}\n\n${g.description || 'Project group.'}\n`;
50
+ const file = path.join(DIR, `${g.id}.md`); const tmp = `${file}.tmp-${process.pid}`;
51
+ fs.writeFileSync(tmp, body, { mode: 0o644 }); fs.renameSync(tmp, file);
52
+ };
53
+ const groups = load();
54
+ const byId = new Map(groups.map((g) => [g.id, g]));
55
+ const find = (ref) => groups.find((g) => g.id === ref) || groups.filter((g) => g.title === ref).at(0) || null;
56
+ const findAll = (ref) => groups.filter((g) => g.id === ref || g.title === ref);
57
+ const one = (ref) => { const hits = findAll(ref); if (hits.length > 1) { die(`"${ref}" matches ${hits.length} groups — use the id`); return null; } if (!hits.length) { die(`no group "${ref}"`); return null; } return hits[0]; };
58
+
59
+ const projectFromPath = (p) => {
60
+ const abs = path.resolve(p);
61
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) return null;
62
+ const r = spawnSync(process.execPath, [process.argv[1], 'project-id', '--json'], { cwd: abs, encoding: 'utf8' });
63
+ const line = (r.stdout || '').split('\n').find((l) => l.startsWith('{"project_id"'));
64
+ if (r.status !== 0 || !line) return null;
65
+ const j = JSON.parse(line);
66
+ return { type: 'project', id: j.project_id, name: j.name, path: path.dirname(path.dirname(path.dirname(path.dirname(path.dirname(j.config))))) };
67
+ };
68
+ const resolveMember = (ref) => {
69
+ const g = find(ref); if (g) return { type: 'group', id: g.id, name: g.title };
70
+ if (UUID.test(ref)) {
71
+ // a bare UUID that is not a group: a project id (name/path unknown until added by path)
72
+ for (const x of groups) for (const m of x.members || []) if (m.type === 'project' && m.id === ref) return { ...m };
73
+ return { type: 'project', id: ref };
74
+ }
75
+ return projectFromPath(ref);
76
+ };
77
+ // does `from` (a group id) reach `target` (a group id) through nesting?
78
+ const reaches = (from, target, seen = new Set()) => {
79
+ if (from === target) return true; if (seen.has(from)) return false; seen.add(from);
80
+ return (byId.get(from)?.members || []).some((m) => m.type === 'group' && reaches(m.id, target, seen));
81
+ };
82
+ const flatten = (g, seen = new Set()) => {
83
+ const out = new Map(); if (seen.has(g.id)) return out; seen.add(g.id);
84
+ for (const m of g.members || []) {
85
+ if (m.type === 'project') out.set(m.id, m);
86
+ else if (byId.has(m.id)) for (const [k, v] of flatten(byId.get(m.id), seen)) out.set(k, v);
87
+ }
88
+ return out;
89
+ };
90
+ const label = (m) => m.type === 'group' ? `▸ ${byId.get(m.id)?.title || m.name || m.id} (${m.id})` : `• ${m.name || '(project)'} ${m.id}${m.path ? ' ' + m.path : ''}`;
91
+ const printTree = (g, depth = 0, seen = new Set()) => {
92
+ console.log(`${' '.repeat(depth)}▸ ${g.title} (${g.id})`);
93
+ if (seen.has(g.id)) return; seen.add(g.id);
94
+ for (const m of g.members || []) {
95
+ if (m.type === 'group' && byId.has(m.id)) printTree(byId.get(m.id), depth + 1, seen);
96
+ else console.log(`${' '.repeat(depth + 1)}${label(m)}`);
97
+ }
98
+ };
99
+
100
+ if (!sub || sub === 'help' || argl.includes('--help') || argl.includes('-h')) { help(); return; }
101
+
102
+ if (sub === 'create') {
103
+ const name = rest.join(' ').trim(); if (!name) return die('group create <name>');
104
+ const di = argl.indexOf('--description');
105
+ const g = { type: 'project_group', id: randomUUID(), title: name, description: di >= 0 ? argl[di + 1] : '', members: [] };
106
+ save(g); return asJson ? console.log(JSON.stringify({ ok: true, group: g })) : console.log(`✔ group "${name}" ${g.id}`);
107
+ }
108
+ if (sub === 'add' || sub === 'remove') {
109
+ const g = one(rest[0]); if (!g) return; const refs = rest.slice(1); if (!refs.length) return die(`group ${sub} <group> <member…>`);
110
+ const results = [];
111
+ for (const ref of refs) {
112
+ const m = resolveMember(ref);
113
+ if (!m) { results.push({ ref, ok: false, error: 'not a group, project UUID, or repo path with a project brain' }); continue; }
114
+ if (sub === 'add') {
115
+ if (m.type === 'group' && reaches(m.id, g.id)) { results.push({ ref, ok: false, error: `cycle: "${m.name}" already contains "${g.title}"` }); continue; }
116
+ if ((g.members || []).some((x) => x.id === m.id)) { results.push({ ref, ok: true, note: 'already a member' }); continue; }
117
+ g.members = [...(g.members || []), m]; results.push({ ref, ok: true, added: m });
118
+ } else {
119
+ const before = (g.members || []).length; g.members = (g.members || []).filter((x) => x.id !== m.id);
120
+ results.push({ ref, ok: before !== g.members.length, removed: before !== g.members.length });
121
+ }
122
+ }
123
+ save(g);
124
+ if (results.some((r) => !r.ok)) process.exitCode = 1;
125
+ return asJson ? console.log(JSON.stringify({ ok: !process.exitCode, group: g.id, results })) : results.forEach((r) => console.log(`${r.ok ? '✔' : '✖'} ${r.ref}${r.error ? ' — ' + r.error : r.note ? ' — ' + r.note : ''}`));
126
+ }
127
+ if (sub === 'list') {
128
+ const rows = groups.map((g) => ({ id: g.id, name: g.title, members: (g.members || []).length, projects: flatten(g).size }));
129
+ return asJson ? console.log(JSON.stringify(rows)) : (rows.length ? rows.forEach((r) => console.log(`▸ ${r.name} ${r.id} (${r.members} direct, ${r.projects} projects)`)) : console.log('No groups yet — total-recall group create <name>'));
130
+ }
131
+ if (sub === 'show') { const g = one(rest[0]); if (!g) return; return asJson ? console.log(JSON.stringify(g)) : (console.log(`▸ ${g.title} ${g.id}`), (g.members || []).forEach((m) => console.log(' ' + label(m)))); }
132
+ if (sub === 'tree') {
133
+ if (rest[0]) { const g = one(rest[0]); if (!g) return; return asJson ? console.log(JSON.stringify(g)) : printTree(g); }
134
+ const nested = new Set(groups.flatMap((g) => (g.members || []).filter((m) => m.type === 'group').map((m) => m.id)));
135
+ const roots = groups.filter((g) => !nested.has(g.id));
136
+ return asJson ? console.log(JSON.stringify(roots.map((g) => g.id))) : roots.forEach((g) => printTree(g));
137
+ }
138
+ if (sub === 'members') {
139
+ const g = one(rest[0]); if (!g) return;
140
+ const list = recursive ? [...flatten(g).values()] : (g.members || []);
141
+ return asJson ? console.log(JSON.stringify(list)) : list.forEach((m) => console.log(label(m)));
142
+ }
143
+ if (sub === 'delete') {
144
+ const g = one(rest[0]); if (!g) return;
145
+ const parents = groups.filter((x) => (x.members || []).some((m) => m.type === 'group' && m.id === g.id));
146
+ if (parents.length) return die(`"${g.title}" is inside ${parents.map((p) => p.title).join(', ')} — remove it there first`);
147
+ fs.unlinkSync(path.join(DIR, `${g.id}.md`));
148
+ return asJson ? console.log(JSON.stringify({ ok: true, deleted: g.id })) : console.log(`✔ deleted "${g.title}"`);
149
+ }
150
+ die(`unknown subcommand "${sub}" — total-recall group --help`);
151
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * total-recall project-id [--json] | --all
3
+ *
4
+ * Prints this project's stable random-UUID id, creating it on first use.
5
+ * --all gives every registered project an id and reconciles the registry.
6
+ */
7
+ import path from 'node:path';
8
+ import { detectProjectBrain } from './agent-dir.mjs';
9
+ import { getGlobalBrainDir } from './agent-dir.mjs';
10
+ import { ensureProjectId, repairProjectIds } from '../core/project-id.mjs';
11
+
12
+ export default async function projectIdCli(argv = []) {
13
+ const args = argv.filter((a) => a !== 'project-id');
14
+ if (args.includes('--help') || args.includes('-h')) {
15
+ console.log(
16
+ 'Usage: total-recall project-id [--json]\n' +
17
+ ' total-recall project-id --all [--json]\n\n' +
18
+ "Prints this repo's random-UUID project id (created on first use, stored in\n" +
19
+ 'config/brain.json). --all ensures every registered project has one and syncs the registry.',
20
+ );
21
+ return;
22
+ }
23
+ const json = args.includes('--json');
24
+ if (args.includes('--all')) {
25
+ const r = repairProjectIds(getGlobalBrainDir());
26
+ if (json) console.log(JSON.stringify(r));
27
+ else {
28
+ console.log(` ${r.total} project(s): ${r.created} id(s) created, ${r.synced} registry row(s) synced, ${r.missing_brain} without a brain on disk`);
29
+ }
30
+ return;
31
+ }
32
+ const project = detectProjectBrain(process.cwd());
33
+ if (!project) {
34
+ console.error(`No project brain found above ${process.cwd()}`);
35
+ process.exit(1);
36
+ }
37
+ const { project_id, created } = ensureProjectId(project.brainDir);
38
+ if (json) {
39
+ console.log(JSON.stringify({ project_id, name: path.basename(project.projectRoot).trim(), created, path: project.projectRoot }));
40
+ } else {
41
+ console.log(project_id);
42
+ }
43
+ }
@@ -57,8 +57,9 @@ import {
57
57
  deployKeyToRemotes,
58
58
  } from '../core/secrets-remote-deploy.mjs';
59
59
  import { listProviders, getProvider } from '../core/provider-catalog.mjs';
60
- import { resolveBrainDir, parseLayerFlag } from './agent-dir.mjs';
60
+ import { resolveBrainDir, parseLayerFlag, detectProjectBrain } from './agent-dir.mjs';
61
61
  import fs from 'node:fs';
62
+ import { ensureProjectId } from '../core/project-id.mjs';
62
63
  import path from 'node:path';
63
64
  import { execFileSync } from 'node:child_process';
64
65
  import os from 'node:os';
@@ -233,6 +234,7 @@ function printHelp() {
233
234
  --docs <url> API docs URL
234
235
  --rotate-days <n> Auto-rotation interval
235
236
  --auto-rotate Enable auto-rotate flag
237
+ --tags <a,b,c> Optional tags saying what the key is and what uses it
236
238
  --notes <text>
237
239
  --label <text>
238
240
  --project <path>
@@ -304,6 +306,7 @@ function parseArgs(args) {
304
306
  rotate_days: null,
305
307
  auto_rotate: false,
306
308
  notes: null,
309
+ tags: null,
307
310
  label: null,
308
311
  project: null,
309
312
  headscale_url: null,
@@ -390,6 +393,9 @@ function parseArgs(args) {
390
393
  case '--provider':
391
394
  out.provider = args[++i];
392
395
  break;
396
+ case '--tags':
397
+ out.tags = args[++i];
398
+ break;
393
399
  case '--scope':
394
400
  out.scope = args[++i];
395
401
  break;
@@ -573,6 +579,16 @@ function parseArgs(args) {
573
579
  return out;
574
580
  }
575
581
 
582
+ /** Id of the project the command runs in (created on first use), or null outside a project. */
583
+ function currentProjectId() {
584
+ try {
585
+ const project = detectProjectBrain(process.cwd());
586
+ return project ? ensureProjectId(project.brainDir).project_id : null;
587
+ } catch {
588
+ return null;
589
+ }
590
+ }
591
+
576
592
  export default async function secretCli(argv) {
577
593
  const { layer, remainingArgs } = parseLayerFlag(argv);
578
594
  const opts = parseArgs(remainingArgs);
@@ -708,6 +724,8 @@ export default async function secretCli(argv) {
708
724
  process.exit(1);
709
725
  }
710
726
  await setSecret(brainDir, opts.key, opts.value, {
727
+ tags: opts.tags != null ? opts.tags : undefined,
728
+ project_id: currentProjectId(),
711
729
  provider: opts.provider || inferProvider(opts.key),
712
730
  scope: opts.scope,
713
731
  repos: opts.repos.length ? opts.repos : undefined,
@@ -955,6 +973,7 @@ export default async function secretCli(argv) {
955
973
  if (opts.rotate_days != null) patch.rotate_every_days = opts.rotate_days;
956
974
  if (opts.auto_rotate) patch.auto_rotate = true;
957
975
  if (opts.notes) patch.notes = opts.notes;
976
+ if (opts.tags != null) patch.tags = opts.tags;
958
977
  if (opts.label) patch.label = opts.label;
959
978
  if (opts.project) patch.project_path = opts.project;
960
979
  if (opts.provider) patch.provider = opts.provider;
@@ -1008,7 +1027,21 @@ export default async function secretCli(argv) {
1008
1027
  console.error('Usage: total-recall secret get <key>');
1009
1028
  process.exit(1);
1010
1029
  }
1011
- const r = await getSecret(brainDir, opts.key);
1030
+ const project_id = currentProjectId();
1031
+ let r = await getSecret(brainDir, opts.key, { project_id });
1032
+ if (!r.found && layer === 'auto') {
1033
+ // Credentials live in the global store (SSOT). From inside a project
1034
+ // the auto layer resolves to the project store, so fall back to the
1035
+ // global one, but only for keys bound to this repo or unbound.
1036
+ const { globalBrainDir } = await import('../core/config.mjs');
1037
+ const { secretMatchesTarget } = await import('../core/secrets-env-export.mjs');
1038
+ if (globalBrainDir && globalBrainDir !== brainDir) {
1039
+ const meta = (await listSecretsMeta(globalBrainDir)).find((k) => k.key === opts.key);
1040
+ if (meta && secretMatchesTarget(meta, { projectPath: process.cwd(), includeGlobal: true })) {
1041
+ r = await getSecret(globalBrainDir, opts.key, { project_id });
1042
+ }
1043
+ }
1044
+ }
1012
1045
  if (!r.found) {
1013
1046
  console.error(` Secret not found: ${opts.key}`);
1014
1047
  process.exit(1);
@@ -1313,7 +1346,7 @@ export default async function secretCli(argv) {
1313
1346
  }
1314
1347
  console.log(`\n Secret audit (last ${events.length})\n`);
1315
1348
  for (const e of events) {
1316
- console.log(` ${e.ts} ${e.action.padEnd(8)} ${e.key} ${e.actor || ''}`);
1349
+ console.log(` ${e.ts} ${e.action.padEnd(8)} ${e.key} ${e.actor || ''}${e.project_id ? ` project=${e.project_id}` : ''}`);
1317
1350
  }
1318
1351
  console.log('');
1319
1352
  return;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Route table of the Total Recall HTTP API, read from the Express routers
3
+ * themselves so a CLI verb exists for every route without a hand-kept list.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const ROUTES_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../server/routes');
10
+
11
+ /** Drop structured info log lines emitted by the modules the routes import, so CLI output stays parseable. */
12
+ export function silenceInfoLogs() {
13
+ for (const stream of [process.stdout, process.stderr]) {
14
+ const write = stream.write.bind(stream);
15
+ stream.write = (chunk, ...rest) =>
16
+ typeof chunk === 'string' && /^\{"timestamp":"[^"]+","level":"(info|debug)"/.test(chunk)
17
+ ? true
18
+ : write(chunk, ...rest);
19
+ }
20
+ }
21
+
22
+ function isRouter(x) {
23
+ return typeof x === 'function' && Array.isArray(x.stack);
24
+ }
25
+
26
+ /** Verb name for a route: the static path after /api/<group>, params dropped. */
27
+ export function actionFor(method, routePath, group) {
28
+ const segs = routePath.replace(/^\/api\//, '').split('/').filter(Boolean);
29
+ if (segs[0] === group) segs.shift();
30
+ const staticSegs = segs.filter((s) => !s.startsWith(':') && s !== '*');
31
+ const name = staticSegs.join('-');
32
+ if (name) return name;
33
+ return { GET: 'list', POST: 'create', PUT: 'update', PATCH: 'patch', DELETE: 'delete' }[method] || method.toLowerCase();
34
+ }
35
+
36
+ /**
37
+ * @returns {Promise<Array<{group:string, method:string, path:string, action:string, params:string[]}>>}
38
+ */
39
+ export async function loadApiRoutes() {
40
+ silenceInfoLogs();
41
+ const out = [];
42
+ const files = fs
43
+ .readdirSync(ROUTES_DIR)
44
+ .filter((f) => f.endsWith('.mjs') && !f.endsWith('.spec.mjs') && !f.startsWith('_'))
45
+ .sort();
46
+ for (const file of files) {
47
+ const group = file.replace(/\.mjs$/, '');
48
+ let mod;
49
+ // Route modules log startup lines on import; keep CLI output clean.
50
+ const { log, info } = console;
51
+ console.log = console.info = () => {};
52
+ try {
53
+ mod = await import(path.join(ROUTES_DIR, file));
54
+ } catch {
55
+ continue;
56
+ } finally {
57
+ console.log = log;
58
+ console.info = info;
59
+ }
60
+ const routers = Object.values(mod).filter(isRouter);
61
+ const seen = new Set();
62
+ for (const router of routers) {
63
+ for (const layer of router.stack) {
64
+ if (!layer.route) continue;
65
+ const paths = Array.isArray(layer.route.path) ? layer.route.path : [layer.route.path];
66
+ for (const p of paths) {
67
+ if (typeof p !== 'string') continue;
68
+ for (const method of Object.keys(layer.route.methods).map((m) => m.toUpperCase())) {
69
+ const key = `${method} ${p}`;
70
+ if (seen.has(key)) continue;
71
+ seen.add(key);
72
+ out.push({
73
+ group,
74
+ method,
75
+ path: p,
76
+ action: actionFor(method, p, group),
77
+ params: [...p.matchAll(/:([A-Za-z0-9_]+)/g)].map((m) => m[1]),
78
+ });
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
84
+ // Make (group, action) unique: suffix the method on collisions.
85
+ const count = new Map();
86
+ for (const r of out) count.set(`${r.group}/${r.action}`, (count.get(`${r.group}/${r.action}`) || 0) + 1);
87
+ const used = new Map();
88
+ for (const r of out) {
89
+ const k = `${r.group}/${r.action}`;
90
+ if (count.get(k) > 1) {
91
+ let name = `${r.action}-${r.method.toLowerCase()}`;
92
+ const n = (used.get(`${r.group}/${name}`) || 0) + 1;
93
+ used.set(`${r.group}/${name}`, n);
94
+ if (n > 1) name = `${name}-${n}`;
95
+ r.action = name;
96
+ }
97
+ }
98
+ return out;
99
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Stable per-project identity: a random UUID kept in the project brain's
3
+ * config/brain.json (`project_id`) and mirrored into the project registry.
4
+ * Never derived from names or paths.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { randomUUID } from 'node:crypto';
9
+
10
+ function brainJsonPath(brainDir) {
11
+ return path.join(brainDir, 'config', 'brain.json');
12
+ }
13
+
14
+ function readJson(file, fallback) {
15
+ try {
16
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
17
+ } catch {
18
+ return fallback;
19
+ }
20
+ }
21
+
22
+ function writeJsonAtomic(file, data) {
23
+ fs.mkdirSync(path.dirname(file), { recursive: true });
24
+ const tmp = `${file}.tmp-${process.pid}`;
25
+ fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
26
+ fs.renameSync(tmp, file);
27
+ }
28
+
29
+ /** The project id stored for a project brain, or null. */
30
+ export function readProjectId(brainDir) {
31
+ return readJson(brainJsonPath(brainDir), {}).project_id || null;
32
+ }
33
+
34
+ /**
35
+ * Return the project's id, creating a random UUID on first use.
36
+ * @returns {{ project_id: string, created: boolean }}
37
+ */
38
+ export function ensureProjectId(brainDir) {
39
+ const file = brainJsonPath(brainDir);
40
+ const cfg = readJson(file, {});
41
+ if (cfg.project_id) return { project_id: cfg.project_id, created: false };
42
+ cfg.project_id = randomUUID();
43
+ writeJsonAtomic(file, cfg);
44
+ return { project_id: cfg.project_id, created: true };
45
+ }
46
+
47
+ /**
48
+ * Give every registered project an id and make the registry agree with each
49
+ * project's brain.json (the brain.json id wins).
50
+ * @returns {{ total: number, created: number, synced: number, missing_brain: number }}
51
+ */
52
+ export function repairProjectIds(globalBrainDir) {
53
+ const registryPath = path.join(globalBrainDir, 'config', 'project-registry.json');
54
+ const list = readJson(registryPath, []);
55
+ const out = { total: 0, created: 0, synced: 0, missing_brain: 0 };
56
+ if (!Array.isArray(list)) return out;
57
+ for (const row of list) {
58
+ out.total++;
59
+ if (!row?.brainDir || !fs.existsSync(row.brainDir)) {
60
+ out.missing_brain++;
61
+ continue;
62
+ }
63
+ const { project_id, created } = ensureProjectId(row.brainDir);
64
+ if (created) out.created++;
65
+ if (row.project_id !== project_id) {
66
+ row.project_id = project_id;
67
+ out.synced++;
68
+ }
69
+ }
70
+ if (out.synced) writeJsonAtomic(registryPath, list);
71
+ return out;
72
+ }
@@ -464,7 +464,7 @@ function matchesOnTokenBoundary(k, stem) {
464
464
  export function providerForKeyName(keyName) {
465
465
  // Strip packaging prefixes so DEVELOPER_BRAVE_SEARCH_API_KEY → BRAVE_SEARCH_API_KEY
466
466
  let k = String(keyName || '').toUpperCase();
467
- k = k.replace(/^(DEVELOPER_|VITE_|NEXT_PUBLIC_|PUBLIC_|NUXT_PUBLIC_|PORTFOLIO_|ULTRACHAT_)+/g, '');
467
+ k = k.replace(/^(DEVELOPER_|VITE_|NEXT_PUBLIC_|PUBLIC_|NUXT_PUBLIC_)+/g, '');
468
468
 
469
469
  // Tier 1 — exact pattern match.
470
470
  for (const p of PROVIDER_CATALOG) {
@@ -67,7 +67,8 @@ export function mergeEnvManagedBlock(existing, managedBody) {
67
67
  export function projectSlugFromPath(projectPath) {
68
68
  if (!projectPath) return null;
69
69
  const abs = path.resolve(projectPath);
70
- return path.basename(abs);
70
+ // Bindings are stored trimmed, so a directory name with stray whitespace must match.
71
+ return path.basename(abs).trim();
71
72
  }
72
73
 
73
74
  /**
@@ -107,9 +108,7 @@ export function secretMatchesTarget(metaRow, target = {}) {
107
108
  // Explicit key list mode
108
109
  if (target.keys?.length) return true;
109
110
 
110
- // Bound to this repo by slug or path (policy: at most one product repo per key)
111
- // Multi-repo keys are invalid — do not project them until fixed
112
- if (repos.length > 1) return false;
111
+ // Bound to this repo by slug or path (a key may be bound to several repos)
113
112
  if (slug && repos.some((r) => r === slug || r === '*' || r.endsWith(`/${slug}`))) {
114
113
  return true;
115
114
  }
@@ -319,15 +319,14 @@ function appendAudit(brainDir, event) {
319
319
  }
320
320
 
321
321
  /**
322
- * Parse repos field. Policy: 0 (developer/tooling) or exactly 1 product repo.
323
- * Never allow multi-repo binding — each key is unique to one repo or to developer scope.
322
+ * Parse repos field. A credential may be bound to zero (developer/tooling),
323
+ * one, or several repos; uniqueness per repo is not required.
324
324
  *
325
325
  * @param {string|string[]|null|undefined} v
326
- * @param {{ strict?: boolean }} opts - strict throws if >1; false collapses for read-only display
326
+ * @param {object} [_opts] - accepted for compatibility; there is no limit
327
327
  * @returns {string[]}
328
328
  */
329
- export function normalizeReposBinding(v, opts = {}) {
330
- const strict = opts.strict !== false;
329
+ export function normalizeReposBinding(v, _opts = {}) {
331
330
  let list = [];
332
331
  if (Array.isArray(v)) {
333
332
  list = [...new Set(v.map(String).map((s) => s.trim()).filter(Boolean))];
@@ -341,18 +340,21 @@ export function normalizeReposBinding(v, opts = {}) {
341
340
  ),
342
341
  ];
343
342
  }
344
- if (strict && list.length > 1) {
345
- throw new Error(`A credential can be bound to at most ONE repo. Got ${list.length}: ${list.join(', ')}`);
346
- }
347
343
  return list;
348
344
  }
349
345
 
350
346
  /**
351
- * True when meta has multi-repo violation (legacy data).
347
+ * Multi-repo binding is allowed, so this is never a violation. Kept because
348
+ * callers still read the field.
352
349
  */
353
- export function isMultiRepoViolation(meta = {}) {
354
- const repos = Array.isArray(meta.repos) ? meta.repos.filter(Boolean) : [];
355
- return repos.length > 1;
350
+ export function isMultiRepoViolation(_meta = {}) {
351
+ return false;
352
+ }
353
+
354
+ /** Lowercase, de-duplicated tag list from an array or comma/space separated string. */
355
+ export function normalizeTags(v) {
356
+ const raw = Array.isArray(v) ? v : String(v ?? '').split(/[,;\s]+/);
357
+ return [...new Set(raw.map((t) => String(t).trim().toLowerCase()).filter(Boolean))];
356
358
  }
357
359
 
358
360
  /**
@@ -370,8 +372,9 @@ export function mergeSecretMeta(prev = {}, patch = {}) {
370
372
  assign('scope', (v) => (v === 'project' ? 'project' : 'global'));
371
373
  assign('provider', (v) => (v ? String(v).toLowerCase() : null));
372
374
  assign('label', (v) => (v == null ? null : String(v)));
373
- // Writes only — at most one product repo; empty = Developer secrets
374
- assign('repos', (v) => normalizeReposBinding(v, { strict: true }));
375
+ // empty = Developer secrets
376
+ assign('repos', (v) => normalizeReposBinding(v));
377
+ assign('tags', normalizeTags);
375
378
  assign('subscription_tier', (v) => (v == null || v === '' ? null : String(v)));
376
379
  assign('monthly_cost_usd', (v) => (v == null || v === '' ? null : Number(v)));
377
380
  assign('monthly_cap_usd', (v) => (v == null || v === '' ? null : Number(v)));
@@ -478,19 +481,13 @@ export function buildSharedValueIndex(rows = []) {
478
481
  const multiKey = keys.length > 1;
479
482
  // Error unless every member is operator-waived
480
483
  const allOk = members.every((m) => m.shared_value_ok === true);
481
- let severity = 'error';
482
- let error = null;
483
- if (allOk) {
484
- severity = 'ok';
485
- error = null;
486
- } else if (multiApp) {
487
- severity = 'error';
488
- error = `SHARED CREDENTIAL across apps/repos [${apps.join(', ')}] as keys [${keys.join(', ')}]. Issue a unique API key per app and re-bind.`;
489
- } else {
490
- // Same app/developer but duplicated under multiple secret names — still force unique
491
- severity = 'error';
492
- error = `SHARED CREDENTIAL value stored under ${keys.length} secret names [${keys.join(', ')}] (app=${apps[0]}). Use one key name or issue distinct values per purpose.`;
493
- }
484
+ // Sharing a value across keys or repos is allowed; it is reported for
485
+ // information only and never counts as unhealthy.
486
+ void allOk;
487
+ const severity = 'info';
488
+ const error = multiApp
489
+ ? `Shared value across [${apps.join(', ')}] as keys [${keys.join(', ')}] (allowed).`
490
+ : `Same value stored under ${keys.length} secret names [${keys.join(', ')}] (allowed).`;
494
491
 
495
492
  const group = {
496
493
  fingerprint: fp.slice(0, 12),
@@ -582,8 +579,9 @@ export async function listSecretsMeta(brainDir) {
582
579
  label: m.label || null,
583
580
  repos,
584
581
  repo: repos.length === 1 ? repos[0] : null,
585
- multi_repo_error: isMultiRepoViolation(m),
586
- binding_error: isMultiRepoViolation(m) ? `Legacy data error: Bound to ${repos.length} repos. Must be resolved to 1 or 0.` : null,
582
+ multi_repo_error: false,
583
+ binding_error: null,
584
+ tags: Array.isArray(m.tags) ? m.tags : [],
587
585
  project_path: m.project_path || null,
588
586
  subscription_tier: m.subscription_tier || null,
589
587
  monthly_cost_usd: m.monthly_cost_usd ?? null,
@@ -838,6 +836,7 @@ export async function setSecret(brainDir, key, value, opts = {}) {
838
836
  scope: opts.scope || prev.scope || 'global',
839
837
  provider: opts.provider || prev.provider || null,
840
838
  repos: opts.repos !== undefined ? opts.repos : prev.repos,
839
+ tags: opts.tags !== undefined ? opts.tags : prev.tags,
841
840
  subscription_tier: opts.subscription_tier !== undefined ? opts.subscription_tier : prev.subscription_tier,
842
841
  monthly_cost_usd: opts.monthly_cost_usd !== undefined ? opts.monthly_cost_usd : prev.monthly_cost_usd,
843
842
  monthly_cap_usd: opts.monthly_cap_usd !== undefined ? opts.monthly_cap_usd : prev.monthly_cap_usd,
@@ -860,6 +859,7 @@ export async function setSecret(brainDir, key, value, opts = {}) {
860
859
  scope: secrets[META_KEY].keys[key].scope,
861
860
  provider: secrets[META_KEY].keys[key].provider,
862
861
  actor: opts.actor || 'cli',
862
+ project_id: opts.project_id || null,
863
863
  });
864
864
 
865
865
  // First-time set only: optionally queue product-level API research (not raw key-name scraping).
@@ -907,6 +907,7 @@ export async function getSecret(brainDir, key, opts = {}) {
907
907
  action: opts.action || 'get',
908
908
  key,
909
909
  actor: opts.actor || 'cli',
910
+ project_id: opts.project_id || null,
910
911
  });
911
912
  return { found: true, key, value: secrets[key] };
912
913
  }
@@ -16,7 +16,7 @@ import {
16
16
  /**
17
17
  * Research Source Adapters — Real Service Integrations
18
18
  *
19
- * Env vars (matching UltraChat conventions — already set in your environment):
19
+ * Env vars (read from the environment or the secret store):
20
20
  * BRAVE_SEARCH_API_KEY — Brave Search API (primary web search)
21
21
  * SERPER_API_KEY — Serper.dev (fallback web search)
22
22
  * GITHUB_TOKEN — GitHub REST API (higher rate limits)
@@ -278,7 +278,7 @@ export async function braveSearch(query, config, count = 5) {
278
278
 
279
279
  /**
280
280
  * Search the web using Serper.dev API (fallback when Brave unavailable).
281
- * Uses SERPER_API_KEY — already defined in UltraChat .env.local.
281
+ * Uses SERPER_API_KEY from the environment or the secret store.
282
282
  * https://serper.dev/
283
283
  *
284
284
  * @param {string} query
@@ -13,6 +13,7 @@ import {
13
13
  import { assemblePluginContexts } from './plugin-context.mjs';
14
14
  import { selectResearchBriefs, formatResearchBriefs } from './research-surface.mjs';
15
15
  import { loadQueue } from './research-queue.mjs';
16
+ import yaml from 'yaml';
16
17
  import { brainDir as globalBrainDir, globalAgentDir } from './config.mjs';
17
18
  import { listSurfaceCommands, buildCommandsSection, surfaceInputsHash } from './command-surface.mjs';
18
19
  import { fileURLToPath } from 'url';
@@ -355,6 +356,31 @@ export function buildResearchSection(nodes = [], { projectRoot, queueItems, rese
355
356
  }
356
357
  }
357
358
 
359
+ const DEFAULT_RULE_BUDGET = { invariants: 9000, preferences: 4000, corrections: 8000 };
360
+
361
+ /** Rule-section budgets (characters) from surface.yml files and the environment. */
362
+ export function readRuleBudgets(skillsDir) {
363
+ const out = {};
364
+ const dirs = [skillsDir ? path.join(skillsDir, 'total-recall') : null, globalBrainDir].filter(Boolean);
365
+ for (const dir of dirs.reverse()) {
366
+ try {
367
+ const cfg = yaml.parse(fs.readFileSync(path.join(dir, 'config', 'surface.yml'), 'utf8')) || {};
368
+ for (const k of Object.keys(DEFAULT_RULE_BUDGET)) {
369
+ const v = Number(cfg?.rules?.[`${k}_budget_chars`]);
370
+ if (Number.isFinite(v) && v > 0) out[k] = v;
371
+ }
372
+ } catch {
373
+ /* no surface.yml in this brain */
374
+ }
375
+ }
376
+ try {
377
+ Object.assign(out, JSON.parse(process.env.TR_RULE_BUDGET_CHARS || '{}'));
378
+ } catch {
379
+ /* ignore a malformed override */
380
+ }
381
+ return out;
382
+ }
383
+
358
384
  export async function buildRulesBlock(skillsDir, nodes = [], { consumer = 'ide', derivedDir, force = false, vaultDir, projectRoot } = {}) {
359
385
  // 1. Filter expired rules. Compilation must remain a pure projection step;
360
386
  // archival is handled by explicit memory operations.
@@ -401,7 +427,7 @@ export async function buildRulesBlock(skillsDir, nodes = [], { consumer = 'ide',
401
427
  const rawCorrections = nodes.filter(n => n.category === 'anti-patterns' && n.status === 'active' && !isExpired(n) && isImportant(n) && isRelevantToRepo(n));
402
428
 
403
429
  // Deduplicate nodes by normalized content key and rank by priority/importance
404
- const deduplicateAndRankNodes = (list, maxCount = 15) => {
430
+ const deduplicateAndRankNodes = (list) => {
405
431
  const seen = new Map();
406
432
  const deduped = [];
407
433
 
@@ -427,40 +453,62 @@ export async function buildRulesBlock(skillsDir, nodes = [], { consumer = 'ide',
427
453
  deduped.push(n);
428
454
  }
429
455
  }
430
- const kept = deduped.slice(0, maxCount);
431
- kept.omitted = deduped.length - kept.length;
432
- return kept;
456
+ return deduped;
433
457
  };
434
458
 
435
- const invariants = deduplicateAndRankNodes(rawInvariants, 15);
436
- const preferences = deduplicateAndRankNodes(rawPreferences, 10);
437
- const corrections = deduplicateAndRankNodes(rawCorrections, 15);
438
- const omittedNote = (list, label, category) => (list.omitted > 0
439
- ? `\n\n_${list.omitted} more ${label} not shown (section cap). Search them with \`npx total-recall recall "<topic>" --category ${category}\`._`
440
- : '');
441
-
442
- const formatNodes = async (list) => {
443
- const formatted = [];
459
+ const invariants = deduplicateAndRankNodes(rawInvariants);
460
+ const preferences = deduplicateAndRankNodes(rawPreferences);
461
+ const corrections = deduplicateAndRankNodes(rawCorrections);
462
+
463
+ // Rules are never dropped. Each section has a character budget: rules that
464
+ // must always be seen (absolute priority or MUST / MUST NOT) are always
465
+ // written in full; the rest are written in full while the budget lasts and
466
+ // then as one-line entries (title and slug). Budgets can be raised with
467
+ // TR_RULE_BUDGET_CHARS='{"invariants":15000,"preferences":6000,"corrections":12000}'.
468
+ // Precedence: TR_RULE_BUDGET_CHARS, then this brain's config/surface.yml,
469
+ // then the global brain's, then the defaults.
470
+ const budgetCfg = readRuleBudgets(skillsDir);
471
+ const RULE_BUDGET = { ...DEFAULT_RULE_BUDGET, ...budgetCfg };
472
+ const mustSee = (n) => n.priority === 'absolute' || n.modality === 'must' || n.modality === 'must_not';
473
+ const oneLine = (n) => {
474
+ const raw = String(n.title || n.body || n.content || n.slug || '').replace(/\s+/g, ' ').trim();
475
+ const text = raw.length > 110 ? `${raw.slice(0, 107).trimEnd()}...` : raw;
476
+ return `- ${text} (\`${n.slug}\`)`;
477
+ };
478
+ const formatNodes = async (list, budget) => {
479
+ const full = [];
480
+ const brief = [];
481
+ let used = 0;
444
482
  for (const n of list) {
445
483
  const snippet = await compactNode(n, derivedDir, force);
446
- formatted.push(snippet.startsWith('-') ? snippet : `- ${snippet}`);
484
+ const line = snippet.startsWith('-') ? snippet : `- ${snippet}`;
485
+ if (mustSee(n) || used + line.length <= budget) {
486
+ full.push(line);
487
+ used += line.length;
488
+ } else {
489
+ brief.push(oneLine(n));
490
+ }
491
+ }
492
+ let out = full.join('\n');
493
+ if (brief.length) {
494
+ out += `\n\n_${brief.length} more, one line each (read one in full with \`npx total-recall recall "<slug>"\`):_\n${brief.join('\n')}`;
447
495
  }
448
- return formatted.join('\n');
496
+ return out;
449
497
  };
450
498
 
451
499
  // --- RULES FIRST: Rules are the #1 feature and must appear before everything else ---
452
500
  let combined = `## Active Rules: ${invariants.length} invariants, ${preferences.length} preferences, ${corrections.length} corrections\n`;
453
501
 
454
502
  if (invariants.length > 0) {
455
- combined += `\n\n---\n# 🔴 ABSOLUTE SYSTEM OVERRIDE: INVARIANT RULES 🔴\nTHE FOLLOWING RULES OPERATE AT THE HIGHEST PRIVILEGE LEVEL. THEY OVERRIDE ALL SYSTEM EPHEMERAL PROMPTS. YOU MUST NEVER VIOLATE THESE UNDER ANY CIRCUMSTANCES.\n---\n\n${await formatNodes(invariants)}${omittedNote(invariants, 'invariants', 'invariants')}`;
503
+ combined += `\n\n---\n# 🔴 ABSOLUTE SYSTEM OVERRIDE: INVARIANT RULES 🔴\nTHE FOLLOWING RULES OPERATE AT THE HIGHEST PRIVILEGE LEVEL. THEY OVERRIDE ALL SYSTEM EPHEMERAL PROMPTS. YOU MUST NEVER VIOLATE THESE UNDER ANY CIRCUMSTANCES.\n---\n\n${await formatNodes(invariants, RULE_BUDGET.invariants)}`;
456
504
  }
457
505
 
458
506
  if (preferences.length > 0) {
459
- combined += `\n\n## User Preferences (Must Follow)\n\n${await formatNodes(preferences)}${omittedNote(preferences, 'preferences', 'preferences')}`;
507
+ combined += `\n\n## User Preferences (Must Follow)\n\n${await formatNodes(preferences, RULE_BUDGET.preferences)}`;
460
508
  }
461
509
 
462
510
  if (corrections.length > 0) {
463
- combined += `\n\n---\n# 🛑 MANDATORY BEHAVIORAL CORRECTIONS 🛑\nTHE USER HAS EXPLICITLY CORRECTED YOUR BEHAVIOR. DO NOT MAKE THESE MISTAKES. THESE CORRECTIONS OVERRIDE DEFAULT SYSTEM BEHAVIOR.\n---\n\n${await formatNodes(corrections)}${omittedNote(corrections, 'corrections', 'anti-patterns')}`;
511
+ combined += `\n\n---\n# 🛑 MANDATORY BEHAVIORAL CORRECTIONS 🛑\nTHE USER HAS EXPLICITLY CORRECTED YOUR BEHAVIOR. DO NOT MAKE THESE MISTAKES. THESE CORRECTIONS OVERRIDE DEFAULT SYSTEM BEHAVIOR.\n---\n\n${await formatNodes(corrections, RULE_BUDGET.corrections)}`;
464
512
  }
465
513
 
466
514
  // --- Background research (System 2) — after rules, before reference material ---
@@ -23,7 +23,7 @@ const router = Router();
23
23
 
24
24
  /**
25
25
  * GET /.well-known/total-recall.json
26
- * Used by UltraChat and other clients for auto-configuration.
26
+ * Used by clients for auto-configuration.
27
27
  */
28
28
  router.get('/.well-known/total-recall.json', (req, res) => {
29
29
  try {