total-recall-brain 3.36.0 → 3.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/frontend/dist/assets/index-CpvEw3iA.js +5786 -0
- package/frontend/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/cli/agent.mjs +59 -18
- package/src/cli/plugin/create.mjs +112 -4
- package/src/cli/secret.mjs +28 -1
- package/src/core/agent-manager.mjs +47 -6
- package/src/core/cache-prune.mjs +60 -2
- package/src/core/meta-harness.mjs +93 -6
- package/src/core/plugin-loader.mjs +6 -5
- package/frontend/dist/assets/index-DZ4ypNEl.js +0 -5786
package/frontend/dist/index.html
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
18
18
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
19
19
|
<title>Total Recall — Portable Memory</title>
|
|
20
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
+
<script type="module" crossorigin src="/assets/index-CpvEw3iA.js"></script>
|
|
21
21
|
<link rel="stylesheet" crossorigin href="/assets/index-C7_Xpk27.css">
|
|
22
22
|
</head>
|
|
23
23
|
<body>
|
package/package.json
CHANGED
package/src/cli/agent.mjs
CHANGED
|
@@ -23,6 +23,14 @@ Options:
|
|
|
23
23
|
--detach Spawn process detached in background (default: true)
|
|
24
24
|
--name <label> Assign a human-readable name to the agent session
|
|
25
25
|
--tail <lines> Number of log lines to inspect (default: 50)
|
|
26
|
+
--cwd <dir> Working directory for the agent (on the target node with --node)
|
|
27
|
+
--tools <list> Built-in tools for the harness: default (all), none, or e.g. "Bash,Read,WebFetch"
|
|
28
|
+
(alias --allow-tools; --no-tools = none). Spawns default to all tools.
|
|
29
|
+
--setting-sources <list> Settings layers the harness loads (e.g. local, or user,project)
|
|
30
|
+
|
|
31
|
+
Auth:
|
|
32
|
+
Claude Code reads CLAUDE_CODE_OAUTH_TOKEN (from \`claude setup-token\`) from the environment,
|
|
33
|
+
else from the Total Recall secret store, so SSH/launchd sessions without a login keychain work.
|
|
26
34
|
|
|
27
35
|
Available Harnesses:
|
|
28
36
|
agy, claude, codex, gemini
|
|
@@ -31,11 +39,53 @@ Examples:
|
|
|
31
39
|
npx total-recall agent list
|
|
32
40
|
npx total-recall agent spawn claude "Refactor src/cli/harness.mjs error handling"
|
|
33
41
|
npx total-recall agent spawn agy "Crawl recent preprints on quantum shuttling" --name "Quantum Scout"
|
|
42
|
+
npx total-recall agent spawn claude "Run the lead search workflow" --node build-box --cwd '~/code/app'
|
|
34
43
|
npx total-recall agent logs agent-claude-xyz --tail 100
|
|
35
44
|
npx total-recall agent kill agent-claude-xyz
|
|
36
45
|
`);
|
|
37
46
|
}
|
|
38
47
|
|
|
48
|
+
const SPAWN_VALUE_FLAGS = {
|
|
49
|
+
'--node': 'targetNode',
|
|
50
|
+
'-n': 'targetNode',
|
|
51
|
+
'--name': 'name',
|
|
52
|
+
'--cwd': 'cwd',
|
|
53
|
+
'--tools': 'tools',
|
|
54
|
+
'--allow-tools': 'tools',
|
|
55
|
+
'--setting-sources': 'settingSources',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse `agent spawn` arguments. Option values are consumed with their flag, so
|
|
60
|
+
* they never leak into the task text; `--tools ""` is a real (empty) value.
|
|
61
|
+
*/
|
|
62
|
+
export function parseSpawnArgs(rest) {
|
|
63
|
+
const out = { detach: true, isJson: false, name: null, targetNode: null, cwd: undefined, tools: undefined, settingSources: undefined };
|
|
64
|
+
const positional = [];
|
|
65
|
+
for (let i = 0; i < rest.length; i++) {
|
|
66
|
+
const arg = rest[i];
|
|
67
|
+
const eq = arg.startsWith('--') ? arg.indexOf('=') : -1;
|
|
68
|
+
const flag = eq > 0 ? arg.slice(0, eq) : arg;
|
|
69
|
+
if (SPAWN_VALUE_FLAGS[flag]) {
|
|
70
|
+
let value;
|
|
71
|
+
if (eq > 0) value = arg.slice(eq + 1);
|
|
72
|
+
else {
|
|
73
|
+
if (i + 1 >= rest.length) throw new Error(`${flag} requires a value`);
|
|
74
|
+
value = rest[++i];
|
|
75
|
+
}
|
|
76
|
+
out[SPAWN_VALUE_FLAGS[flag]] = value;
|
|
77
|
+
} else if (arg === '--json') out.isJson = true;
|
|
78
|
+
else if (arg === '--no-detach') out.detach = false;
|
|
79
|
+
else if (arg === '--detach') out.detach = true;
|
|
80
|
+
else if (arg === '--no-tools') out.tools = 'none';
|
|
81
|
+
else if (arg.startsWith('--')) throw new Error(`Unknown option for agent spawn: ${arg}`);
|
|
82
|
+
else positional.push(arg);
|
|
83
|
+
}
|
|
84
|
+
out.harness = positional[0];
|
|
85
|
+
out.task = positional.slice(1).join(' ').trim();
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
39
89
|
export async function run(argv) {
|
|
40
90
|
let args = argv;
|
|
41
91
|
if (Array.isArray(args) && args[0]?.endsWith('node')) {
|
|
@@ -79,34 +129,25 @@ export async function run(argv) {
|
|
|
79
129
|
}
|
|
80
130
|
|
|
81
131
|
if (command === 'spawn') {
|
|
82
|
-
let
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
} else {
|
|
89
|
-
filteredRest.push(rest[i]);
|
|
90
|
-
}
|
|
132
|
+
let parsed;
|
|
133
|
+
try {
|
|
134
|
+
parsed = parseSpawnArgs(rest);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.error(`Error: ${err.message}`);
|
|
137
|
+
process.exit(1);
|
|
91
138
|
}
|
|
92
|
-
|
|
93
|
-
const harness = filteredRest[0];
|
|
94
|
-
const task = filteredRest.filter(r => !r.startsWith('--')).slice(1).join(' ').trim();
|
|
95
|
-
const detach = !filteredRest.includes('--no-detach');
|
|
96
|
-
const isJson = filteredRest.includes('--json');
|
|
97
|
-
const nameIdx = filteredRest.indexOf('--name');
|
|
98
|
-
const name = nameIdx !== -1 ? filteredRest[nameIdx + 1] : null;
|
|
139
|
+
const { harness, task, detach, isJson, name, targetNode, cwd, tools, settingSources } = parsed;
|
|
99
140
|
|
|
100
141
|
if (!harness || !task) {
|
|
101
142
|
console.error('Error: Please specify harness ID and task prompt.');
|
|
102
|
-
console.error('Usage: npx total-recall agent spawn <harness> "<task>" [--name "..."] [--node <node>]');
|
|
143
|
+
console.error('Usage: npx total-recall agent spawn <harness> "<task>" [--name "..."] [--node <node>] [--cwd <dir>] [--tools <list|none|default>]');
|
|
103
144
|
process.exit(1);
|
|
104
145
|
}
|
|
105
146
|
|
|
106
147
|
const nodeStr = targetNode ? ` on mesh node \x1b[1;33m${targetNode}\x1b[0m` : '';
|
|
107
148
|
if (!isJson) console.log(`\n🚀 Spawning agent on harness: \x1b[1;36m${harness}\x1b[0m${nodeStr}...`);
|
|
108
149
|
try {
|
|
109
|
-
const record = await spawnAgent(harness, task, { detach, name, node: targetNode });
|
|
150
|
+
const record = await spawnAgent(harness, task, { detach, name, node: targetNode, cwd, tools, settingSources });
|
|
110
151
|
if (isJson) {
|
|
111
152
|
console.log(JSON.stringify(record, null, 2));
|
|
112
153
|
return;
|
|
@@ -13,11 +13,104 @@ function toTitleCase(kebab) {
|
|
|
13
13
|
.join(" ");
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** CLI token subcommands every API-key plugin starts with. */
|
|
17
|
+
function tokenCliBlock(apiKeys) {
|
|
18
|
+
return `import { spawnSync } from "node:child_process";
|
|
19
|
+
|
|
20
|
+
// The service API key lives in the Total Recall secrets store, never argv or logs.
|
|
21
|
+
const TOKEN_KEY = ${JSON.stringify(apiKeys[0])};
|
|
22
|
+
const TR = process.env.TOTAL_RECALL_BIN || "total-recall";
|
|
23
|
+
|
|
24
|
+
function tokenValue() {
|
|
25
|
+
if (process.env[TOKEN_KEY]) return process.env[TOKEN_KEY].trim();
|
|
26
|
+
const run = spawnSync(TR, ["secret", "get", TOKEN_KEY], { encoding: "utf8" });
|
|
27
|
+
if (run.status !== 0) return "";
|
|
28
|
+
const lines = String(run.stdout || "").split("\\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("{"));
|
|
29
|
+
return lines.length ? lines[lines.length - 1] : "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function readStdin() {
|
|
33
|
+
const chunks = [];
|
|
34
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
35
|
+
return Buffer.concat(chunks).toString("utf8").trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// TODO(plugin author): call a cheap authenticated endpoint here and return
|
|
39
|
+
// { valid: true } or { valid: false, message }. Never save a key that fails it.
|
|
40
|
+
async function verifyToken(_value) {
|
|
41
|
+
return { valid: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function tokenCommand(args) {
|
|
45
|
+
const [action = "status"] = args;
|
|
46
|
+
const json = args.includes("--json");
|
|
47
|
+
if (action === "status") {
|
|
48
|
+
const status = { present: Boolean(tokenValue()), secretKey: TOKEN_KEY };
|
|
49
|
+
console.log(json ? JSON.stringify(status) : \`API key: \${status.present ? "present" : "missing"} (\${TOKEN_KEY})\`);
|
|
50
|
+
if (!status.present) process.exitCode = 1;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (action === "set" && args.includes("--stdin")) {
|
|
54
|
+
const value = await readStdin();
|
|
55
|
+
if (!value) { console.error("No key received on stdin."); process.exitCode = 2; return; }
|
|
56
|
+
const check = await verifyToken(value);
|
|
57
|
+
if (!check.valid) { console.error(\`Key rejected, not saved: \${check.message || "verification failed"}\`); process.exitCode = 1; return; }
|
|
58
|
+
const saved = spawnSync(TR, ["secret", "set", TOKEN_KEY, "--stdin"], { input: value, encoding: "utf8" });
|
|
59
|
+
if (saved.status !== 0) { console.error("Could not save the key to the secrets store."); process.exitCode = 1; return; }
|
|
60
|
+
console.log(json ? JSON.stringify({ saved: true, valid: true }) : "API key verified and saved.");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
console.error("usage: token <status|set --stdin>");
|
|
64
|
+
process.exitCode = 2;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** UI panel with the paste-your-key form every API-key plugin starts with. */
|
|
71
|
+
function tokenPanelSource(id, panelClass, pluginName, apiKeys) {
|
|
72
|
+
return `// Paste-your-key panel. Emits \`save-token\`; the host pipes it to
|
|
73
|
+
// \`total-recall ${id} token set --stdin\` (stored in secrets, never shown again).
|
|
74
|
+
class ${panelClass} extends HTMLElement {
|
|
75
|
+
set view(value) { this._view = value; this.render(); }
|
|
76
|
+
get view() { return this._view; }
|
|
77
|
+
connectedCallback() { this.render(); }
|
|
78
|
+
render() {
|
|
79
|
+
const root = this.shadowRoot || this.attachShadow({ mode: 'open' });
|
|
80
|
+
root.replaceChildren();
|
|
81
|
+
const present = Boolean(this._view && this._view.present);
|
|
82
|
+
const style = document.createElement('style');
|
|
83
|
+
style.textContent = ':host { display:block; color:var(--color-text); background:var(--color-surface); border:1px solid var(--color-border); border-radius:var(--radius-md); padding:var(--spacing-md); } .row { display:flex; gap:8px; margin-top:8px; } input { flex:1; padding:6px 8px; }';
|
|
84
|
+
const title = document.createElement('h3');
|
|
85
|
+
title.textContent = ${JSON.stringify(pluginName + ' API key')};
|
|
86
|
+
const state = document.createElement('p');
|
|
87
|
+
state.textContent = present ? 'A key is saved. Paste a new one to replace it.' : 'No key yet. Paste it to start using this plugin.';
|
|
88
|
+
const form = document.createElement('form');
|
|
89
|
+
form.className = 'row';
|
|
90
|
+
const input = document.createElement('input');
|
|
91
|
+
input.type = 'password'; input.autocomplete = 'off'; input.required = true;
|
|
92
|
+
input.placeholder = ${JSON.stringify(apiKeys[0])};
|
|
93
|
+
input.setAttribute('aria-label', ${JSON.stringify(pluginName + ' API key')});
|
|
94
|
+
const button = document.createElement('button');
|
|
95
|
+
button.type = 'submit'; button.textContent = 'Save and test';
|
|
96
|
+
form.append(input, button);
|
|
97
|
+
form.addEventListener('submit', (event) => {
|
|
98
|
+
event.preventDefault();
|
|
99
|
+
this.dispatchEvent(new CustomEvent('save-token', { detail: { key: ${JSON.stringify(apiKeys[0])}, token: input.value.trim() }, bubbles: true, composed: true }));
|
|
100
|
+
input.value = '';
|
|
101
|
+
});
|
|
102
|
+
root.append(style, title, state, form);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
customElements.define('${id}-panel', ${panelClass});
|
|
106
|
+
`;
|
|
107
|
+
}
|
|
108
|
+
|
|
16
109
|
export async function createPlugin(args = []) {
|
|
17
110
|
const isGlobal = args.includes("--global") || args.includes("-g");
|
|
18
|
-
|
|
111
|
+
let withCli = args.includes("--with-cli");
|
|
19
112
|
const withGenerator = args.includes("--with-generator");
|
|
20
|
-
|
|
113
|
+
let withUi = args.includes("--with-ui");
|
|
21
114
|
const withTask = args.includes("--with-task");
|
|
22
115
|
const createGithub = args.includes("--github");
|
|
23
116
|
const publicRepo = args.includes("--public");
|
|
@@ -32,6 +125,7 @@ export async function createPlugin(args = []) {
|
|
|
32
125
|
let taskSchedule = null;
|
|
33
126
|
let taskIntent = null;
|
|
34
127
|
const useCases = [];
|
|
128
|
+
const apiKeys = [];
|
|
35
129
|
|
|
36
130
|
const cleanArgs = [];
|
|
37
131
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -39,6 +133,7 @@ export async function createPlugin(args = []) {
|
|
|
39
133
|
if (["--global", "-g", "--with-cli", "--with-generator", "--with-ui", "--with-task", "--with-skill", "--capability", "--github", "--public", "--no-install"].includes(arg)) {
|
|
40
134
|
continue;
|
|
41
135
|
}
|
|
136
|
+
if (arg === "--api-key" && args[i + 1]) { apiKeys.push(args[++i]); continue; }
|
|
42
137
|
if (arg === "--repo-dir" && args[i + 1]) { repoDirArg = args[++i]; continue; }
|
|
43
138
|
if (arg === "--name" && args[i + 1]) {
|
|
44
139
|
name = args[i + 1];
|
|
@@ -152,6 +247,14 @@ export async function createPlugin(args = []) {
|
|
|
152
247
|
};
|
|
153
248
|
if (useCases.length > 0) manifest.use_cases = useCases;
|
|
154
249
|
|
|
250
|
+
// House rule: a plugin for a service that needs an API key ships a UI to paste
|
|
251
|
+
// that key and a CLI to save it, so it works the moment the key is pasted.
|
|
252
|
+
if (apiKeys.length) {
|
|
253
|
+
withCli = true;
|
|
254
|
+
withUi = true;
|
|
255
|
+
manifest.secrets = apiKeys.map((key) => ({ key, description: `${pluginName} service API key (paste it in the plugin UI)`, required: true }));
|
|
256
|
+
}
|
|
257
|
+
|
|
155
258
|
if (category) {
|
|
156
259
|
manifest.ssss_schemas = {
|
|
157
260
|
categories: [
|
|
@@ -183,7 +286,7 @@ export async function createPlugin(args = []) {
|
|
|
183
286
|
*/
|
|
184
287
|
// Scaffold: report honestly what exists. Add each real subcommand to
|
|
185
288
|
// IMPLEMENTED and to plugin.json "cli.subcommands" as it is built.
|
|
186
|
-
const IMPLEMENTED = {};
|
|
289
|
+
${apiKeys.length ? tokenCliBlock(apiKeys) : ''}const IMPLEMENTED = {};${apiKeys.length ? '\nIMPLEMENTED.token = tokenCommand;' : ''}
|
|
187
290
|
|
|
188
291
|
export async function run(argv = []) {
|
|
189
292
|
const args = Array.isArray(argv) ? argv.slice(3) : [];
|
|
@@ -221,7 +324,11 @@ export default run;
|
|
|
221
324
|
const panelClass = `${id.split('-').map((part) => part[0].toUpperCase() + part.slice(1)).join('')}Panel`;
|
|
222
325
|
fs.mkdirSync(path.join(pluginDir, 'ui'), { recursive: true });
|
|
223
326
|
fs.writeFileSync(path.join(pluginDir, 'ui', 'DESIGN.md'), '---\ncolors:\n text: "#20242a"\n surface: "#ffffff"\n border: "#dce2e8"\nspacing:\n md: 12px\nrounded:\n md: 8px\n---\n\n# Design tokens\n\nReplace these defaults with the host design tokens.\n');
|
|
224
|
-
|
|
327
|
+
if (apiKeys.length) {
|
|
328
|
+
manifest.ui.elements[0].events = ['save-token'];
|
|
329
|
+
manifest.ui.elements[0].props = { view: { type: 'object', description: `Printed by \`total-recall ${id} token status --json\`: { present, secretKey }` } };
|
|
330
|
+
}
|
|
331
|
+
fs.writeFileSync(path.join(pluginDir, 'ui', 'panel.js'), apiKeys.length ? tokenPanelSource(id, panelClass, pluginName, apiKeys) : `class ${panelClass} extends HTMLElement {\n connectedCallback() {\n const root = this.shadowRoot || this.attachShadow({ mode: 'open' });\n root.replaceChildren();\n const style = document.createElement('style');\n style.textContent = ':host { display:block; color:var(--color-text); background:var(--color-surface); border:1px solid var(--color-border); border-radius:var(--radius-md); padding:var(--spacing-md); }';\n const text = document.createElement('p');\n text.textContent = ${JSON.stringify(`${pluginName} panel scaffold. Add the actual capability before sharing.`)};\n root.append(style, text);\n }\n}\ncustomElements.define('${id}-panel', ${panelClass});\n`);
|
|
225
332
|
}
|
|
226
333
|
|
|
227
334
|
if (isCapability) {
|
|
@@ -348,6 +455,7 @@ export default generateContext;
|
|
|
348
455
|
if (withCli) lines.push(`- **CLI Command**: \`npx total-recall ${id}\``);
|
|
349
456
|
if (withTask) lines.push(`- **Scheduled task**: \`${taskCommand}\` at \`${taskSchedule}\` (selected node; implement the command and choose a node before use)`);
|
|
350
457
|
if (withUi) lines.push('- **UI**: `ui/panel.js` custom element with DESIGN.md tokens');
|
|
458
|
+
if (apiKeys.length) lines.push('- **API key**: paste it in the plugin UI (`save-token` event) or pipe it to `npx total-recall ' + id + ' token set --stdin`; stored as `' + apiKeys[0] + '` in the secrets store');
|
|
351
459
|
lines.push("");
|
|
352
460
|
|
|
353
461
|
fs.writeFileSync(path.join(pluginDir, "README.md"), lines.join("\n"), "utf8");
|
package/src/cli/secret.mjs
CHANGED
|
@@ -81,10 +81,34 @@ import { agentDir as configuredAgentDir, getActiveBrains } from '../core/config.
|
|
|
81
81
|
// pasted/piped value normally carries), not all whitespace, so a value that
|
|
82
82
|
// legitimately starts or ends with a space round-trips intact.
|
|
83
83
|
function readStdinValue() {
|
|
84
|
+
// A terminal never sends EOF on Enter, so readFileSync(0) would wait for a
|
|
85
|
+
// Ctrl-D on an empty line. Read one line (Enter ends it) with echo off.
|
|
86
|
+
if (process.stdin.isTTY) return readTtyLine();
|
|
84
87
|
const raw = fs.readFileSync(0, 'utf8');
|
|
85
88
|
return raw.endsWith('\n') ? raw.slice(0, -1) : raw;
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
function readTtyLine() {
|
|
92
|
+
const stty = (arg) => { try { execFileSync('stty', [arg], { stdio: ['inherit', 'ignore', 'ignore'] }); } catch { /* not a tty */ } };
|
|
93
|
+
process.stderr.write('Paste the value and press Enter: ');
|
|
94
|
+
stty('-echo');
|
|
95
|
+
let line = '';
|
|
96
|
+
try {
|
|
97
|
+
const chunk = Buffer.alloc(4096);
|
|
98
|
+
for (;;) {
|
|
99
|
+
let n;
|
|
100
|
+
try { n = fs.readSync(0, chunk, 0, chunk.length, null); } catch (err) { if (err.code === 'EAGAIN') continue; throw err; }
|
|
101
|
+
if (n === 0) break;
|
|
102
|
+
line += chunk.toString('utf8', 0, n);
|
|
103
|
+
if (/[\r\n]/.test(line)) break;
|
|
104
|
+
}
|
|
105
|
+
} finally {
|
|
106
|
+
stty('echo');
|
|
107
|
+
process.stderr.write('\n');
|
|
108
|
+
}
|
|
109
|
+
return line.split(/[\r\n]/)[0];
|
|
110
|
+
}
|
|
111
|
+
|
|
88
112
|
/**
|
|
89
113
|
* Read the OS clipboard. Keeps a pasted credential inside this process rather
|
|
90
114
|
* than routing it through argv, shell history, or an agent transcript.
|
|
@@ -133,13 +157,16 @@ function readSecretValue(label) {
|
|
|
133
157
|
input.resume();
|
|
134
158
|
input.setEncoding('utf8');
|
|
135
159
|
let buf = '';
|
|
136
|
-
|
|
160
|
+
// A paste arrives as one chunk (often with the Enter in it): walk it character by character.
|
|
161
|
+
const onData = (chunk) => { for (const ch of chunk) { if (onChar(ch)) return; } };
|
|
162
|
+
const onChar = (ch) => {
|
|
137
163
|
if (ch === '\n' || ch === '\r' || ch === '\u0004') {
|
|
138
164
|
input.setRawMode(false);
|
|
139
165
|
input.pause();
|
|
140
166
|
input.removeListener('data', onData);
|
|
141
167
|
process.stdout.write('\n');
|
|
142
168
|
resolve(buf.trim());
|
|
169
|
+
return true;
|
|
143
170
|
} else if (ch === '\u0003') {
|
|
144
171
|
input.setRawMode(false);
|
|
145
172
|
process.stdout.write('\n');
|
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
|
-
import { HARNESS_SPECS,
|
|
5
|
+
import { HARNESS_SPECS, buildHarnessArgs, normalizeToolSelection, resolveHarnessEnv, shellQuote } from './meta-harness.mjs';
|
|
6
6
|
import { findBinaryInPath } from './runtime.mjs';
|
|
7
7
|
import { logger } from './logger.mjs';
|
|
8
8
|
|
|
@@ -81,14 +81,45 @@ export function listAgents() {
|
|
|
81
81
|
return updated;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** Tool selection as recorded in the agent registry. */
|
|
85
|
+
function describeTools(tools) {
|
|
86
|
+
const selection = normalizeToolSelection(tools);
|
|
87
|
+
return selection === null ? 'default' : selection || 'none';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The `total-recall agent spawn` command line that recreates a spawn on another
|
|
92
|
+
* node. Every value is shell-quoted: the prompt, name and paths are caller data.
|
|
93
|
+
*/
|
|
94
|
+
export function buildRemoteSpawnCommand(harnessId, taskPrompt, options = {}) {
|
|
95
|
+
const parts = ['total-recall', 'agent', 'spawn', harnessId, taskPrompt];
|
|
96
|
+
if (options.name) parts.push('--name', options.name);
|
|
97
|
+
if (options.cwd) parts.push('--cwd', options.cwd);
|
|
98
|
+
if (options.tools !== undefined && options.tools !== null) {
|
|
99
|
+
parts.push('--tools', Array.isArray(options.tools) ? options.tools.join(',') : String(options.tools));
|
|
100
|
+
}
|
|
101
|
+
if (options.settingSources) parts.push('--setting-sources', options.settingSources);
|
|
102
|
+
if (options.detach === false) parts.push('--no-detach');
|
|
103
|
+
parts.push('--json');
|
|
104
|
+
return parts.map(shellQuote).join(' ');
|
|
105
|
+
}
|
|
106
|
+
|
|
84
107
|
/**
|
|
85
108
|
* Spawn an agent harness headlessly or in background detach mode.
|
|
109
|
+
*
|
|
110
|
+
* A spawn runs a workflow, so the harness keeps its full built-in tool set and
|
|
111
|
+
* normal settings unless the caller narrows them:
|
|
112
|
+
* options.tools 'none' | 'default' | 'Bash,Read' | ['Bash', 'Read']
|
|
113
|
+
* options.settingSources e.g. 'local' or 'user,project'
|
|
114
|
+
* options.cwd working directory (on the target node for --node)
|
|
86
115
|
*/
|
|
87
116
|
export async function spawnAgent(harnessId, taskPrompt, options = {}) {
|
|
88
117
|
const spec = HARNESS_SPECS[harnessId];
|
|
89
118
|
if (!spec) {
|
|
90
119
|
throw new Error(`Unknown harness ID "${harnessId}". Available: ${Object.keys(HARNESS_SPECS).join(', ')}`);
|
|
91
120
|
}
|
|
121
|
+
// Validate the tool/settings selection before touching any node.
|
|
122
|
+
const harnessArgs = buildHarnessArgs(spec, { tools: options.tools, settingSources: options.settingSources });
|
|
92
123
|
|
|
93
124
|
// Remote mesh execution branch
|
|
94
125
|
if (options.node) {
|
|
@@ -102,9 +133,10 @@ export async function spawnAgent(harnessId, taskPrompt, options = {}) {
|
|
|
102
133
|
|
|
103
134
|
if (!isSelf) {
|
|
104
135
|
const id = `agent-${harnessId}-${Date.now().toString(36)}`;
|
|
105
|
-
const remoteCmd =
|
|
136
|
+
const remoteCmd = buildRemoteSpawnCommand(harnessId, taskPrompt, options);
|
|
106
137
|
const execResult = await execMeshCommand(options.node, remoteCmd, {
|
|
107
138
|
vaultRoot: options.vaultRoot,
|
|
139
|
+
timeoutMs: options.timeoutMs,
|
|
108
140
|
});
|
|
109
141
|
|
|
110
142
|
let remoteRecord = null;
|
|
@@ -118,7 +150,8 @@ export async function spawnAgent(harnessId, taskPrompt, options = {}) {
|
|
|
118
150
|
id: remoteRecord?.id || id,
|
|
119
151
|
node: options.node,
|
|
120
152
|
remote: true,
|
|
121
|
-
status: execResult.success ? 'running' : 'failed',
|
|
153
|
+
status: execResult.success ? (remoteRecord?.status || 'running') : 'failed',
|
|
154
|
+
...(execResult.success ? {} : { error: (execResult.stderr || execResult.stdout || '').slice(-2000) }),
|
|
122
155
|
startedAt: remoteRecord?.startedAt || new Date().toISOString(),
|
|
123
156
|
task: taskPrompt,
|
|
124
157
|
harness: harnessId,
|
|
@@ -136,20 +169,27 @@ export async function spawnAgent(harnessId, taskPrompt, options = {}) {
|
|
|
136
169
|
throw new Error(`Binary "${spec.binary}" for harness "${spec.name}" not found on PATH.`);
|
|
137
170
|
}
|
|
138
171
|
|
|
172
|
+
// A remote caller sends `~/…` unexpanded; it means this node's home.
|
|
173
|
+
const requestedCwd = options.cwd && /^~(?=$|\/)/.test(options.cwd) ? path.join(os.homedir(), options.cwd.slice(1)) : options.cwd;
|
|
174
|
+
const cwd = requestedCwd ? path.resolve(requestedCwd) : process.cwd();
|
|
175
|
+
if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) {
|
|
176
|
+
throw new Error(`Working directory does not exist: ${cwd}`);
|
|
177
|
+
}
|
|
178
|
+
const env = await resolveHarnessEnv(spec, process.env, options);
|
|
179
|
+
|
|
139
180
|
const id = `agent-${harnessId}-${Date.now().toString(36)}`;
|
|
140
181
|
const logDir = resolveLogDir();
|
|
141
182
|
const logFile = path.join(logDir, `${id}.log`);
|
|
142
183
|
const logFd = fs.openSync(logFile, 'a');
|
|
143
184
|
|
|
144
|
-
const args = [...
|
|
145
|
-
const cwd = options.cwd || process.cwd();
|
|
185
|
+
const args = [...harnessArgs, taskPrompt];
|
|
146
186
|
|
|
147
187
|
const child = spawn(binPath, args, {
|
|
148
188
|
cwd,
|
|
149
189
|
detached: options.detach !== false,
|
|
150
190
|
stdio: options.detach !== false ? ['ignore', logFd, logFd] : 'pipe',
|
|
151
191
|
env: {
|
|
152
|
-
...
|
|
192
|
+
...env,
|
|
153
193
|
TR_AGENT_ID: id,
|
|
154
194
|
TR_HARNESS: harnessId
|
|
155
195
|
}
|
|
@@ -163,6 +203,7 @@ export async function spawnAgent(harnessId, taskPrompt, options = {}) {
|
|
|
163
203
|
pid: child.pid,
|
|
164
204
|
task: taskPrompt,
|
|
165
205
|
cwd,
|
|
206
|
+
tools: describeTools(options.tools),
|
|
166
207
|
logFile,
|
|
167
208
|
startedAt: new Date().toISOString(),
|
|
168
209
|
status: 'running',
|
package/src/core/cache-prune.mjs
CHANGED
|
@@ -154,6 +154,43 @@ export const DEFAULT_POLICIES = [
|
|
|
154
154
|
},
|
|
155
155
|
];
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Logs written next to the brain rather than inside it. The installed
|
|
159
|
+
* LaunchAgents and `auto-pull.sh` send daemon/server/auto-pull output to
|
|
160
|
+
* `<agent home>/logs` (normally `~/.agent/logs`), which the brain policies above
|
|
161
|
+
* never saw: one node's `daemon.log` there reached 9 GB with the disk at 95%.
|
|
162
|
+
* Same rule as the brain log: truncate in place, never unlink.
|
|
163
|
+
*
|
|
164
|
+
* @type {Policy[]}
|
|
165
|
+
*/
|
|
166
|
+
export const AGENT_HOME_POLICIES = [
|
|
167
|
+
{
|
|
168
|
+
id: 'agent-logs',
|
|
169
|
+
dir: 'logs',
|
|
170
|
+
match: /\.log$/,
|
|
171
|
+
mode: 'size',
|
|
172
|
+
limit: 25 * 1024 * 1024,
|
|
173
|
+
keepBytes: 5 * 1024 * 1024,
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: 'agent-system-logs',
|
|
177
|
+
dir: 'logs',
|
|
178
|
+
match: /^system-\d{4}-\d{2}-\d{2}\.jsonl$/,
|
|
179
|
+
mode: 'age',
|
|
180
|
+
limit: 14,
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The agent home that holds a conventionally placed brain
|
|
186
|
+
* (`<agent home>/skills/total-recall`), or null for any other layout.
|
|
187
|
+
*/
|
|
188
|
+
export function agentHomeFor(brainDir) {
|
|
189
|
+
const abs = path.resolve(brainDir);
|
|
190
|
+
if (path.basename(abs) !== 'total-recall' || path.basename(path.dirname(abs)) !== 'skills') return null;
|
|
191
|
+
return path.dirname(path.dirname(abs));
|
|
192
|
+
}
|
|
193
|
+
|
|
157
194
|
/** Is this path inside a protected directory? */
|
|
158
195
|
export function isProtected(brainDir, target) {
|
|
159
196
|
const root = path.resolve(brainDir);
|
|
@@ -219,10 +256,31 @@ function truncateKeepingTail(file, keepBytes) {
|
|
|
219
256
|
/**
|
|
220
257
|
* Apply every policy.
|
|
221
258
|
*
|
|
222
|
-
*
|
|
259
|
+
* `agentDir` (derived from a conventionally placed brain) also gets the
|
|
260
|
+
* AGENT_HOME_POLICIES; pass `agentDir: null` to skip it.
|
|
261
|
+
*
|
|
262
|
+
* @param {{brainDir: string, policies?: Policy[], agentDir?: string|null, agentPolicies?: Policy[], dryRun?: boolean, now?: number}} opts
|
|
223
263
|
* @returns {{freed_bytes: number, removed: number, dry_run: boolean, results: object[]}}
|
|
224
264
|
*/
|
|
225
|
-
export function pruneCaches({
|
|
265
|
+
export function pruneCaches({
|
|
266
|
+
brainDir,
|
|
267
|
+
policies = DEFAULT_POLICIES,
|
|
268
|
+
agentDir = agentHomeFor(brainDir),
|
|
269
|
+
agentPolicies = AGENT_HOME_POLICIES,
|
|
270
|
+
dryRun = false,
|
|
271
|
+
now = Date.now(),
|
|
272
|
+
} = {}) {
|
|
273
|
+
const report = runPolicies(brainDir, policies, { dryRun, now });
|
|
274
|
+
if (agentDir && path.resolve(agentDir) !== path.resolve(brainDir) && agentPolicies?.length) {
|
|
275
|
+
const extra = runPolicies(agentDir, agentPolicies, { dryRun, now });
|
|
276
|
+
report.freed_bytes += extra.freed_bytes;
|
|
277
|
+
report.removed += extra.removed;
|
|
278
|
+
report.results.push(...extra.results.map((r) => ({ ...r, root: 'agent-home' })));
|
|
279
|
+
}
|
|
280
|
+
return report;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function runPolicies(brainDir, policies, { dryRun, now }) {
|
|
226
284
|
const results = [];
|
|
227
285
|
let freed = 0;
|
|
228
286
|
let removed = 0;
|