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.
- package/bin/total-recall.mjs +18 -0
- package/package.json +2 -2
- package/src/cli/api.mjs +133 -0
- package/src/cli/brief.mjs +195 -0
- package/src/cli/config.mjs +25 -7
- package/src/cli/group.mjs +151 -0
- package/src/cli/project-id.mjs +43 -0
- package/src/cli/secret.mjs +36 -3
- package/src/core/api-routes.mjs +99 -0
- package/src/core/project-id.mjs +72 -0
- package/src/core/provider-catalog.mjs +1 -1
- package/src/core/secrets-env-export.mjs +3 -4
- package/src/core/secrets-store.mjs +30 -29
- package/src/core/source-adapters.mjs +2 -2
- package/src/core/surface.mjs +66 -18
- package/src/server/routes/help.mjs +1 -1
package/bin/total-recall.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
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",
|
package/src/cli/api.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/cli/config.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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}
|
|
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
|
|
218
|
-
const targetPath = info.file
|
|
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
|
+
}
|
package/src/cli/secret.mjs
CHANGED
|
@@ -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
|
|
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_
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
323
|
-
*
|
|
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 {
|
|
326
|
+
* @param {object} [_opts] - accepted for compatibility; there is no limit
|
|
327
327
|
* @returns {string[]}
|
|
328
328
|
*/
|
|
329
|
-
export function normalizeReposBinding(v,
|
|
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
|
-
*
|
|
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(
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
//
|
|
374
|
-
assign('repos', (v) => normalizeReposBinding(v
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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:
|
|
586
|
-
binding_error:
|
|
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 (
|
|
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
|
|
281
|
+
* Uses SERPER_API_KEY from the environment or the secret store.
|
|
282
282
|
* https://serper.dev/
|
|
283
283
|
*
|
|
284
284
|
* @param {string} query
|
package/src/core/surface.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
431
|
-
kept.omitted = deduped.length - kept.length;
|
|
432
|
-
return kept;
|
|
456
|
+
return deduped;
|
|
433
457
|
};
|
|
434
458
|
|
|
435
|
-
const invariants = deduplicateAndRankNodes(rawInvariants
|
|
436
|
-
const preferences = deduplicateAndRankNodes(rawPreferences
|
|
437
|
-
const corrections = deduplicateAndRankNodes(rawCorrections
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
26
|
+
* Used by clients for auto-configuration.
|
|
27
27
|
*/
|
|
28
28
|
router.get('/.well-known/total-recall.json', (req, res) => {
|
|
29
29
|
try {
|