total-recall-brain 3.26.0 → 3.28.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/bin/total-recall.mjs +31 -1
- package/docs/ARCHITECTURE.md +76 -9
- package/frontend/dist/assets/index-U7o-qU99.js +5742 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/src/App.tsx +39 -1
- package/frontend/src/api/index.ts +1 -0
- package/frontend/src/api/plugins.ts +167 -0
- package/frontend/src/pages/ChatPage.tsx +44 -17
- package/frontend/src/pages/IntegrationsPage.tsx +7 -0
- package/frontend/src/pages/PluginsPage.spec.tsx +17 -0
- package/frontend/src/pages/PluginsPage.tsx +1331 -0
- package/package.json +1 -1
- package/scaffold/.agent/plugins/.gitkeep +0 -0
- package/scaffold/.agent/plugins/code-quality/README.md +16 -0
- package/scaffold/.agent/plugins/code-quality/cli.mjs +15 -0
- package/scaffold/.agent/plugins/code-quality/plugin.json +34 -0
- package/scaffold/.agent/plugins/git-sentinel/README.md +16 -0
- package/scaffold/.agent/plugins/git-sentinel/cli.mjs +86 -0
- package/scaffold/.agent/plugins/git-sentinel/plugin.json +34 -0
- package/scaffold/.agent/plugins/meta-harness/README.md +16 -0
- package/scaffold/.agent/plugins/meta-harness/cli.mjs +34 -0
- package/scaffold/.agent/plugins/meta-harness/plugin.json +34 -0
- package/scaffold/.agent/plugins/system-monitor/README.md +16 -0
- package/scaffold/.agent/plugins/system-monitor/cli.mjs +79 -0
- package/scaffold/.agent/plugins/system-monitor/generator.mjs +20 -0
- package/scaffold/.agent/plugins/system-monitor/plugin.json +38 -0
- package/scaffold/.agent/skills/meta-harness/SKILL.md +113 -0
- package/scaffold/.agent/skills/total-recall/references/cli-reference.md +23 -0
- package/src/cli/agent.mjs +173 -0
- package/src/cli/connect.mjs +89 -4
- package/src/cli/deploy-ui.mjs +6 -0
- package/src/cli/harness.mjs +135 -0
- package/src/cli/ingest-openwiki.mjs +71 -2
- package/src/cli/init.mjs +1 -0
- package/src/cli/lint.mjs +47 -3
- package/src/cli/mesh.mjs +286 -0
- package/src/cli/plugin/create.mjs +186 -0
- package/src/cli/plugin/index.mjs +139 -0
- package/src/cli/plugin/install.mjs +141 -0
- package/src/cli/plugin/list.mjs +83 -0
- package/src/cli/plugin/remove.mjs +51 -0
- package/src/cli/plugin/search.mjs +37 -0
- package/src/cli/skill-projection.mjs +14 -0
- package/src/cli/wizard.html +9 -0
- package/src/core/agent-manager.mjs +290 -0
- package/src/core/cache-prune.mjs +341 -0
- package/src/core/context-cache.mjs +130 -0
- package/src/core/daemon-loop.mjs +47 -1
- package/src/core/evolving-context.mjs +171 -0
- package/src/core/mesh-activity.mjs +140 -0
- package/src/core/mesh.mjs +174 -1
- package/src/core/meta-harness.mjs +251 -0
- package/src/core/plugin-context.mjs +206 -0
- package/src/core/plugin-loader.mjs +210 -0
- package/src/core/runtime.mjs +65 -13
- package/src/core/surface.mjs +32 -10
- package/src/server/api.mjs +18 -2
- package/src/server/index.mjs +8 -2
- package/src/server/rest.mjs +4 -0
- package/src/server/routes/integrations.mjs +5 -1
- package/src/server/routes/mesh-presence.mjs +143 -0
- package/src/server/routes/models.mjs +42 -30
- package/src/server/routes/plugins.mjs +517 -0
- package/src/server/tools.mjs +266 -0
- package/frontend/dist/assets/index-Bl3I11B0.js +0 -5742
package/src/cli/mesh.mjs
CHANGED
|
@@ -41,12 +41,20 @@ function printHelp() {
|
|
|
41
41
|
Usage: total-recall mesh <command> [options]
|
|
42
42
|
|
|
43
43
|
status Control-server reachability and credential state
|
|
44
|
+
doctor Probe reachability & compute capabilities across all nodes
|
|
44
45
|
nodes List mesh nodes and how to reach each one
|
|
45
46
|
ssh <node> [cmd…] Open a session using the node's recorded access
|
|
47
|
+
exec <node> <cmd…> Execute a command on a node non-interactively (--json)
|
|
46
48
|
access <node> Show resolved access for a node
|
|
47
49
|
access <node> --user <u> [--port <n>] [--host <h>] [--identity <path>]
|
|
48
50
|
Record how to reach a node
|
|
49
51
|
access import Propose access from ~/.ssh/config (--apply to save)
|
|
52
|
+
ping [node] Measure round-trip latency to a node (or all nodes) (--json)
|
|
53
|
+
leader Show cluster leader election status and current leader (--json)
|
|
54
|
+
enroll [options] Enroll this node on the mesh control server
|
|
55
|
+
--status show current enrollment state
|
|
56
|
+
--server control-server URL
|
|
57
|
+
--force re-enroll even if already connected
|
|
50
58
|
preauthkey Mint an enrollment key for 'tailscale up --authkey'
|
|
51
59
|
--reusable usable by more than one node
|
|
52
60
|
--ephemeral node is removed when it goes offline
|
|
@@ -124,6 +132,91 @@ export default async function meshCli(argv = []) {
|
|
|
124
132
|
return;
|
|
125
133
|
}
|
|
126
134
|
|
|
135
|
+
if (command === 'doctor' || command === 'diagnostics') {
|
|
136
|
+
console.log('\n🩺 Total Recall — Mesh Diagnostics & Compute Capability Audit\n');
|
|
137
|
+
const enriched = listEnrichedMeshNodes(vaultRoot);
|
|
138
|
+
const isJson = args.includes('--json');
|
|
139
|
+
|
|
140
|
+
const stripAnsi = (s) => String(s || '').replace(/\x1b\[[0-9;]*m/g, '');
|
|
141
|
+
const pad = (s, l) => String(s || '') + ' '.repeat(Math.max(0, l - stripAnsi(s).length));
|
|
142
|
+
const probeNodes = enriched.filter(n => n.ip);
|
|
143
|
+
const results = [];
|
|
144
|
+
|
|
145
|
+
for (const node of probeNodes) {
|
|
146
|
+
const resolved = resolveNodeAccess(node);
|
|
147
|
+
const nodeInfo = {
|
|
148
|
+
hostname: node.hostname,
|
|
149
|
+
ip: node.ip,
|
|
150
|
+
self: !!node.self,
|
|
151
|
+
online: !!node.online,
|
|
152
|
+
sshConfigured: resolved.complete,
|
|
153
|
+
sshTarget: resolved.target,
|
|
154
|
+
runtimes: [],
|
|
155
|
+
harnesses: [],
|
|
156
|
+
reachable: false,
|
|
157
|
+
error: null
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
if (node.self) {
|
|
161
|
+
try {
|
|
162
|
+
const { detectHarnesses } = await import('../core/meta-harness.mjs');
|
|
163
|
+
nodeInfo.harnesses = detectHarnesses().filter(h => h.available).map(h => h.id);
|
|
164
|
+
} catch {}
|
|
165
|
+
nodeInfo.runtimes = ['node', 'git'];
|
|
166
|
+
nodeInfo.reachable = true;
|
|
167
|
+
} else if (resolved.complete) {
|
|
168
|
+
try {
|
|
169
|
+
const probeCmd = 'echo "agy=$(which agy 2>/dev/null) claude=$(which claude 2>/dev/null) codex=$(which codex 2>/dev/null) gemini=$(which gemini 2>/dev/null) ollama=$(which ollama 2>/dev/null) docker=$(which docker 2>/dev/null) node=$(which node 2>/dev/null) git=$(which git 2>/dev/null)"';
|
|
170
|
+
const { execMeshCommand } = await import('../core/mesh.mjs');
|
|
171
|
+
const res = await execMeshCommand(node.hostname, probeCmd, { vaultRoot, timeoutMs: 4000 });
|
|
172
|
+
nodeInfo.reachable = res.success;
|
|
173
|
+
if (res.success) {
|
|
174
|
+
const parts = res.stdout.split(/\s+/);
|
|
175
|
+
for (const part of parts) {
|
|
176
|
+
const [k, v] = part.split('=');
|
|
177
|
+
if (v && v.trim()) {
|
|
178
|
+
if (['agy', 'claude', 'codex', 'gemini', 'ollama'].includes(k)) {
|
|
179
|
+
nodeInfo.harnesses.push(k);
|
|
180
|
+
} else {
|
|
181
|
+
nodeInfo.runtimes.push(k);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
nodeInfo.error = res.stderr || 'Probe failed';
|
|
187
|
+
}
|
|
188
|
+
} catch (err) {
|
|
189
|
+
nodeInfo.reachable = false;
|
|
190
|
+
nodeInfo.error = err.message;
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
nodeInfo.reachable = false;
|
|
194
|
+
nodeInfo.error = 'No recorded SSH login';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
results.push(nodeInfo);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (isJson) {
|
|
201
|
+
console.log(JSON.stringify(results, null, 2));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
console.log('┌──────────────────────────────────────────┬────────────────┬──────────────────┬──────────────────┬──────────────────────────────────────┬──────────────────────────┐');
|
|
206
|
+
console.log(`│ ${pad('Node', 40)} │ ${pad('Mesh IP', 14)} │ ${pad('Role', 16)} │ ${pad('Mesh SSH', 16)} │ ${pad('Harnesses', 36)} │ ${pad('Runtimes', 24)} │`);
|
|
207
|
+
console.log('├──────────────────────────────────────────┼────────────────┼──────────────────┼──────────────────┼──────────────────────────────────────┼──────────────────────────┤');
|
|
208
|
+
|
|
209
|
+
for (const r of results) {
|
|
210
|
+
const roleStr = r.self ? '\x1b[36mLocal (Self)\x1b[0m' : (r.online ? '\x1b[32mOnline Peer\x1b[0m' : '\x1b[90mOffline\x1b[0m');
|
|
211
|
+
const sshStr = r.self ? '—' : (r.reachable ? '\x1b[32mReachable ✅\x1b[0m' : '\x1b[31mUnreachable ❌\x1b[0m');
|
|
212
|
+
const hStr = r.harnesses.length ? r.harnesses.join(', ') : '—';
|
|
213
|
+
const runStr = r.runtimes.length ? r.runtimes.join(', ') : '—';
|
|
214
|
+
console.log(`│ ${pad(r.hostname, 40)} │ ${pad(r.ip, 14)} │ ${pad(roleStr, 16)} │ ${pad(sshStr, 16)} │ ${pad(hStr, 36)} │ ${pad(runStr, 24)} │`);
|
|
215
|
+
}
|
|
216
|
+
console.log('└──────────────────────────────────────────┴────────────────┴──────────────────┴──────────────────┴──────────────────────────────────────┴──────────────────────────┘\n');
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
127
220
|
if (command === 'nodes') {
|
|
128
221
|
// The control server is the authoritative registry, but local discovery
|
|
129
222
|
// plus the vault can already answer "what is on my mesh and how do I
|
|
@@ -226,6 +319,33 @@ export default async function meshCli(argv = []) {
|
|
|
226
319
|
return;
|
|
227
320
|
}
|
|
228
321
|
|
|
322
|
+
if (command === 'exec') {
|
|
323
|
+
const target = args.shift();
|
|
324
|
+
if (!target || !args.length) {
|
|
325
|
+
fail('`exec` requires a node name and a command.', 'Example: total-recall mesh exec macmini uptime');
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const jsonMode = args.includes('--json');
|
|
329
|
+
const filteredArgs = args.filter((a) => a !== '--json');
|
|
330
|
+
const cmd = filteredArgs.join(' ');
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
const { execMeshCommand } = await import('../core/mesh.mjs');
|
|
334
|
+
const res = await execMeshCommand(target, cmd, { vaultRoot });
|
|
335
|
+
if (jsonMode) {
|
|
336
|
+
console.log(JSON.stringify(res, null, 2));
|
|
337
|
+
} else {
|
|
338
|
+
if (res.stdout) console.log(res.stdout);
|
|
339
|
+
if (res.stderr) console.error(res.stderr);
|
|
340
|
+
}
|
|
341
|
+
process.exitCode = res.exitCode;
|
|
342
|
+
} catch (err) {
|
|
343
|
+
fail(`Execution failed: ${err.message}`);
|
|
344
|
+
}
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
|
|
229
349
|
if (command === 'access') {
|
|
230
350
|
const target = args.shift();
|
|
231
351
|
if (!target) {
|
|
@@ -329,6 +449,172 @@ export default async function meshCli(argv = []) {
|
|
|
329
449
|
return;
|
|
330
450
|
}
|
|
331
451
|
|
|
452
|
+
if (command === 'leader') {
|
|
453
|
+
const { getLeaderInfo, isLeader } = await import('../core/leader-election.mjs');
|
|
454
|
+
const leaderInfo = await getLeaderInfo();
|
|
455
|
+
const thisIsLeader = await isLeader();
|
|
456
|
+
if (args.includes('--json')) {
|
|
457
|
+
console.log(JSON.stringify({ leader: leaderInfo, is_current_node_leader: thisIsLeader }, null, 2));
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (!leaderInfo) {
|
|
461
|
+
console.log('No leader elected (no mesh nodes online).');
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
console.log(`👑 Current Cluster Leader: ${leaderInfo.hostname} (${leaderInfo.ip})`);
|
|
465
|
+
console.log(` Strategy: ${leaderInfo.strategy}`);
|
|
466
|
+
console.log(` Local node: ${thisIsLeader ? 'LEADER (this node runs cluster workloads)' : 'FOLLOWER'}`);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (command === 'ping' || command === 'latency') {
|
|
471
|
+
const target = args.find((a) => !a.startsWith('-'));
|
|
472
|
+
const isJson = args.includes('--json');
|
|
473
|
+
const { getMeshPeers, findMeshNode } = await import('../core/mesh.mjs');
|
|
474
|
+
const { throttledFetch } = await import('../core/throttled-fetch.mjs');
|
|
475
|
+
|
|
476
|
+
const port = Number(process.env.TR_SERVER_PORT || process.env.PORT || 3000);
|
|
477
|
+
|
|
478
|
+
if (target) {
|
|
479
|
+
const node = findMeshNode(target, vaultRoot);
|
|
480
|
+
if (!node) {
|
|
481
|
+
fail(`No mesh node matches "${target}".`);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (node.self) {
|
|
485
|
+
if (isJson) {
|
|
486
|
+
console.log(JSON.stringify({ node: node.hostname, ip: node.ip, latency_ms: 0, self: true, reachable: true }, null, 2));
|
|
487
|
+
} else {
|
|
488
|
+
console.log(`📍 ${node.hostname} (${node.ip}) is this local node (0 ms).`);
|
|
489
|
+
}
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
if (!node.ip || !node.online) {
|
|
493
|
+
if (isJson) {
|
|
494
|
+
console.log(JSON.stringify({ node: node.hostname, ip: node.ip, latency_ms: null, reachable: false, status: 'offline' }, null, 2));
|
|
495
|
+
} else {
|
|
496
|
+
console.log(`⚠️ ${node.hostname} (${node.ip || 'no IP'}) is offline.`);
|
|
497
|
+
}
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const start = Date.now();
|
|
502
|
+
try {
|
|
503
|
+
const res = await throttledFetch(`http://${node.ip}:${port}/health`, {}, 5000);
|
|
504
|
+
const ms = Date.now() - start;
|
|
505
|
+
const ok = res.ok || res.status === 200;
|
|
506
|
+
if (isJson) {
|
|
507
|
+
console.log(JSON.stringify({ node: node.hostname, ip: node.ip, latency_ms: ok ? ms : null, reachable: ok, status: res.status }, null, 2));
|
|
508
|
+
} else {
|
|
509
|
+
console.log(`✅ ${node.hostname} (${node.ip}): ${ms} ms (status: ${res.status})`);
|
|
510
|
+
}
|
|
511
|
+
} catch (err) {
|
|
512
|
+
const { spawnSync } = await import('node:child_process');
|
|
513
|
+
const pingResult = spawnSync('ping', ['-c', '1', '-W', '2', node.ip], { encoding: 'utf8', timeout: 3000 });
|
|
514
|
+
const ms = Date.now() - start;
|
|
515
|
+
if (pingResult.status === 0) {
|
|
516
|
+
if (isJson) {
|
|
517
|
+
console.log(JSON.stringify({ node: node.hostname, ip: node.ip, latency_ms: ms, reachable: true, method: 'icmp' }, null, 2));
|
|
518
|
+
} else {
|
|
519
|
+
console.log(`✅ ${node.hostname} (${node.ip}): ${ms} ms (ICMP)`);
|
|
520
|
+
}
|
|
521
|
+
} else {
|
|
522
|
+
if (isJson) {
|
|
523
|
+
console.log(JSON.stringify({ node: node.hostname, ip: node.ip, latency_ms: null, reachable: false, error: err.message }, null, 2));
|
|
524
|
+
} else {
|
|
525
|
+
console.log(`❌ ${node.hostname} (${node.ip}): unreachable (${err.message})`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const peers = getMeshPeers({ includeSelf: true });
|
|
533
|
+
const results = [];
|
|
534
|
+
for (const peer of peers) {
|
|
535
|
+
if (peer.self) {
|
|
536
|
+
results.push({ hostname: peer.hostname, ip: peer.ip, latency_ms: 0, status: 'self' });
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (!peer.ip || !peer.online) {
|
|
540
|
+
results.push({ hostname: peer.hostname, ip: peer.ip, latency_ms: null, status: 'offline' });
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
const start = Date.now();
|
|
544
|
+
try {
|
|
545
|
+
const res = await throttledFetch(`http://${peer.ip}:${port}/health`, {}, 4000);
|
|
546
|
+
const ms = Date.now() - start;
|
|
547
|
+
const ok = res.ok || res.status === 200;
|
|
548
|
+
results.push({ hostname: peer.hostname, ip: peer.ip, latency_ms: ok ? ms : null, status: ok ? 'online' : 'error' });
|
|
549
|
+
} catch {
|
|
550
|
+
const { spawnSync } = await import('node:child_process');
|
|
551
|
+
const pingResult = spawnSync('ping', ['-c', '1', '-W', '2', peer.ip], { encoding: 'utf8', timeout: 2000 });
|
|
552
|
+
const ms = Date.now() - start;
|
|
553
|
+
results.push({ hostname: peer.hostname, ip: peer.ip, latency_ms: pingResult.status === 0 ? ms : null, status: pingResult.status === 0 ? 'icmp' : 'unreachable' });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (isJson) {
|
|
558
|
+
console.log(JSON.stringify(results, null, 2));
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
console.log('┌──────────────────────────────────────────┬────────────────┬──────────────┬──────────────┐');
|
|
563
|
+
console.log('│ Node │ Mesh IP │ Latency │ Status │');
|
|
564
|
+
console.log('├──────────────────────────────────────────┼────────────────┼──────────────┼──────────────┤');
|
|
565
|
+
for (const r of results) {
|
|
566
|
+
const lat = r.latency_ms != null ? `${r.latency_ms} ms` : '—';
|
|
567
|
+
console.log(`│ ${String(r.hostname || '').padEnd(40)} │ ${String(r.ip || '-').padEnd(14)} │ ${lat.padEnd(12)} │ ${r.status.padEnd(12)} │`);
|
|
568
|
+
}
|
|
569
|
+
console.log('└──────────────────────────────────────────┴────────────────┴──────────────┴──────────────┘');
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (command === 'enroll') {
|
|
574
|
+
const { getEnrollmentStatus, enrollThisNode } = await import('../core/mesh-enroll.mjs');
|
|
575
|
+
if (args.includes('--status')) {
|
|
576
|
+
const status = await getEnrollmentStatus({ brainDir });
|
|
577
|
+
console.log(JSON.stringify(status, null, 2));
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const readOption = (flag) => {
|
|
582
|
+
const index = args.indexOf(flag);
|
|
583
|
+
return index === -1 ? null : args[index + 1] || null;
|
|
584
|
+
};
|
|
585
|
+
const server = readOption('--server');
|
|
586
|
+
const user = readOption('--user');
|
|
587
|
+
const force = args.includes('--force');
|
|
588
|
+
|
|
589
|
+
console.log('Initiating mesh enrollment...');
|
|
590
|
+
const result = await enrollThisNode({
|
|
591
|
+
brainDir,
|
|
592
|
+
loginServer: server,
|
|
593
|
+
user,
|
|
594
|
+
force,
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
if (args.includes('--json')) {
|
|
598
|
+
console.log(JSON.stringify(result, null, 2));
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (result.ok) {
|
|
603
|
+
if (result.reason === 'already-enrolled') {
|
|
604
|
+
console.log(`✅ Already enrolled on mesh (${result.state}). Use --force to re-enroll.`);
|
|
605
|
+
} else {
|
|
606
|
+
console.log(`✅ Successfully enrolled on mesh (${result.state}).`);
|
|
607
|
+
}
|
|
608
|
+
} else if (result.interactiveUrl) {
|
|
609
|
+
console.log(`⚠️ Interactive authorization required.`);
|
|
610
|
+
console.log(` Open this URL in your browser to approve:`);
|
|
611
|
+
console.log(` ${result.interactiveUrl}`);
|
|
612
|
+
} else {
|
|
613
|
+
fail(result.reason || 'Enrollment failed', result.hint);
|
|
614
|
+
}
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
332
618
|
if (command !== 'policy') {
|
|
333
619
|
fail(`Unknown mesh command: ${command}`, 'Run `total-recall mesh --help`.');
|
|
334
620
|
return;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { validatePluginManifest } from "../../core/plugin-loader.mjs";
|
|
5
|
+
|
|
6
|
+
function toTitleCase(kebab) {
|
|
7
|
+
return kebab
|
|
8
|
+
.split("-")
|
|
9
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
10
|
+
.join(" ");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function createPlugin(args = []) {
|
|
14
|
+
const isGlobal = args.includes("--global") || args.includes("-g");
|
|
15
|
+
const withCli = args.includes("--with-cli");
|
|
16
|
+
const withGenerator = args.includes("--with-generator");
|
|
17
|
+
|
|
18
|
+
let name = null;
|
|
19
|
+
let description = null;
|
|
20
|
+
let category = null;
|
|
21
|
+
|
|
22
|
+
const cleanArgs = [];
|
|
23
|
+
for (let i = 0; i < args.length; i++) {
|
|
24
|
+
const arg = args[i];
|
|
25
|
+
if (arg === "--global" || arg === "-g" || arg === "--with-cli" || arg === "--with-generator") {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (arg === "--name" && args[i + 1]) {
|
|
29
|
+
name = args[i + 1];
|
|
30
|
+
i++;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (arg === "--description" && args[i + 1]) {
|
|
34
|
+
description = args[i + 1];
|
|
35
|
+
i++;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (arg === "--category" && args[i + 1]) {
|
|
39
|
+
category = args[i + 1];
|
|
40
|
+
i++;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
cleanArgs.push(arg);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const id = cleanArgs[0];
|
|
47
|
+
if (!id) {
|
|
48
|
+
console.error("❌ Error: Missing plugin id.");
|
|
49
|
+
console.error(" Usage: total-recall plugin create <id> [--name <name>] [--description <desc>] [--category <cat>] [--with-cli] [--with-generator] [--global]\n");
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ID_PATTERN = /^[a-z][a-z0-9-]{1,63}$/;
|
|
54
|
+
if (!ID_PATTERN.test(id)) {
|
|
55
|
+
console.error(`❌ Error: Invalid plugin id "${id}". Must be lowercase kebab-case (^[a-z][a-z0-9-]{1,63}$)`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const pluginsBaseDir = isGlobal
|
|
60
|
+
? path.join(os.homedir(), ".agent", "plugins")
|
|
61
|
+
: path.join(process.cwd(), ".agent", "plugins");
|
|
62
|
+
|
|
63
|
+
const pluginDir = path.join(pluginsBaseDir, id);
|
|
64
|
+
if (fs.existsSync(pluginDir)) {
|
|
65
|
+
console.error(`❌ Error: Plugin directory already exists at ${pluginDir}`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fs.mkdirSync(pluginDir, { recursive: true });
|
|
70
|
+
|
|
71
|
+
const pluginName = name || toTitleCase(id);
|
|
72
|
+
const pluginDesc = description || `${pluginName} extension for Total Recall AI OS`;
|
|
73
|
+
|
|
74
|
+
const manifest = {
|
|
75
|
+
$schema: "https://github.com/total-recall/total-recall/blob/main/metadata.plugin.schema.json",
|
|
76
|
+
id,
|
|
77
|
+
name: pluginName,
|
|
78
|
+
version: "1.0.0",
|
|
79
|
+
description: pluginDesc,
|
|
80
|
+
author: "Total Recall Ecosystem",
|
|
81
|
+
license: "MIT"
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (category) {
|
|
85
|
+
manifest.ssss_schemas = {
|
|
86
|
+
categories: [
|
|
87
|
+
{
|
|
88
|
+
name: category,
|
|
89
|
+
description: `Custom ${category} memory nodes for ${pluginName}`,
|
|
90
|
+
node_type: "memory"
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (withCli) {
|
|
97
|
+
manifest.cli = {
|
|
98
|
+
command: id,
|
|
99
|
+
handler: "./cli.mjs",
|
|
100
|
+
subcommands: [
|
|
101
|
+
{ name: "status", description: `Show ${pluginName} status` }
|
|
102
|
+
]
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const cliContent = `#!/usr/bin/env node
|
|
106
|
+
export async function run(argv = []) {
|
|
107
|
+
const args = Array.isArray(argv) ? argv.slice(2) : [];
|
|
108
|
+
const sub = args[0] || "status";
|
|
109
|
+
console.log(\`⚡ \${sub.toUpperCase()} from plugin: ${id}\`);
|
|
110
|
+
console.log("Plugin is active and responding to CLI dispatch.");
|
|
111
|
+
}
|
|
112
|
+
export default run;
|
|
113
|
+
`;
|
|
114
|
+
fs.writeFileSync(path.join(pluginDir, "cli.mjs"), cliContent, "utf8");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (withGenerator) {
|
|
118
|
+
manifest.compile = {
|
|
119
|
+
generator: "./generator.mjs",
|
|
120
|
+
auto: true
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const generatorContent = `/**
|
|
124
|
+
* Context generator for ${pluginName}.
|
|
125
|
+
* Invoked during evolving context compilation.
|
|
126
|
+
*/
|
|
127
|
+
export async function generateContext({ projectRoot, nodes = [] }) {
|
|
128
|
+
return \`#### ${pluginName} Context\n- Status: Operational\n- Active nodes: \${nodes.length}\n\`;
|
|
129
|
+
}
|
|
130
|
+
export default generateContext;
|
|
131
|
+
`;
|
|
132
|
+
fs.writeFileSync(path.join(pluginDir, "generator.mjs"), generatorContent, "utf8");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
manifest.tasks = [
|
|
136
|
+
{
|
|
137
|
+
intent: `Periodic health verification for ${id}`,
|
|
138
|
+
schedule: "0 * * * *"
|
|
139
|
+
}
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
const validation = validatePluginManifest(manifest);
|
|
143
|
+
if (!validation.valid) {
|
|
144
|
+
console.error("❌ Validation failed for generated manifest:", validation.errors);
|
|
145
|
+
fs.rmSync(pluginDir, { recursive: true, force: true });
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
fs.writeFileSync(
|
|
150
|
+
path.join(pluginDir, "plugin.json"),
|
|
151
|
+
JSON.stringify(manifest, null, 2) + "\n",
|
|
152
|
+
"utf8"
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const lines = [
|
|
156
|
+
`# ${pluginName}`,
|
|
157
|
+
"",
|
|
158
|
+
`> ${pluginDesc}`,
|
|
159
|
+
"",
|
|
160
|
+
"## Installation",
|
|
161
|
+
"",
|
|
162
|
+
"```bash",
|
|
163
|
+
`npx total-recall plugin install ./.agent/plugins/${id} --link`,
|
|
164
|
+
"```",
|
|
165
|
+
"",
|
|
166
|
+
"## Features",
|
|
167
|
+
"",
|
|
168
|
+
`- **Identifier**: \`${id}\``,
|
|
169
|
+
"- **Version**: 1.0.0"
|
|
170
|
+
];
|
|
171
|
+
if (category) lines.push(`- **SSSS Category**: \`${category}\``);
|
|
172
|
+
if (withCli) lines.push(`- **CLI Command**: \`npx total-recall ${id}\``);
|
|
173
|
+
lines.push("");
|
|
174
|
+
|
|
175
|
+
fs.writeFileSync(path.join(pluginDir, "README.md"), lines.join("\n"), "utf8");
|
|
176
|
+
|
|
177
|
+
console.log(`\n🎉 Successfully scaffolded plugin "${pluginName}"!`);
|
|
178
|
+
console.log(` Location: \x1b[36m${pluginDir}\x1b[0m`);
|
|
179
|
+
console.log(` Manifest: \x1b[33m${path.join(pluginDir, "plugin.json")}\x1b[0m`);
|
|
180
|
+
if (withCli) {
|
|
181
|
+
console.log(` CLI Handler: \x1b[32m${path.join(pluginDir, "cli.mjs")}\x1b[0m`);
|
|
182
|
+
}
|
|
183
|
+
console.log(`\nNext steps:`);
|
|
184
|
+
console.log(` Inspect: \x1b[32mnpx total-recall plugin info ${id}\x1b[0m`);
|
|
185
|
+
console.log(` List: \x1b[32mnpx total-recall plugin list\x1b[0m\n`);
|
|
186
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { listPlugins } from './list.mjs';
|
|
2
|
+
import { installPlugin } from './install.mjs';
|
|
3
|
+
import { removePlugin } from './remove.mjs';
|
|
4
|
+
import { searchCatalog } from './search.mjs';
|
|
5
|
+
import { createPlugin } from './create.mjs';
|
|
6
|
+
import { getPlugin } from '../../core/plugin-loader.mjs';
|
|
7
|
+
|
|
8
|
+
function printHelp() {
|
|
9
|
+
console.log(`
|
|
10
|
+
🔌 Total Recall — Plugin Management System
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
npx total-recall plugin <command> [options]
|
|
14
|
+
|
|
15
|
+
Commands:
|
|
16
|
+
search [query] Search plugin catalog (--json for pipelines)
|
|
17
|
+
catalog Browse the full catalog
|
|
18
|
+
list List all installed plugins (project & global)
|
|
19
|
+
create <id> Scaffold a new plugin manifest and directory
|
|
20
|
+
--name <name> Human-readable plugin name
|
|
21
|
+
--description <desc> Plugin purpose & capability
|
|
22
|
+
--category <cat> Declare an SSSS memory category
|
|
23
|
+
--with-cli Generate CLI command handler
|
|
24
|
+
--with-generator Generate context generator
|
|
25
|
+
--global, -g Create in ~/.agent/plugins/
|
|
26
|
+
install <path|git-url> Install or link a plugin
|
|
27
|
+
--link, -l Symlink local directory instead of copying
|
|
28
|
+
--global, -g Install to ~/.agent/plugins/
|
|
29
|
+
remove <id> Uninstall or unlink an installed plugin
|
|
30
|
+
info <id> Inspect detailed manifest for an installed plugin
|
|
31
|
+
|
|
32
|
+
Examples:
|
|
33
|
+
npx total-recall plugin list
|
|
34
|
+
npx total-recall plugin create system-monitor --with-cli --category system-metrics
|
|
35
|
+
npx total-recall plugin install ./path/to/my-plugin --link
|
|
36
|
+
npx total-recall plugin install https://github.com/org/scientific-frontiers.git
|
|
37
|
+
npx total-recall plugin info scientific-frontiers
|
|
38
|
+
npx total-recall plugin remove scientific-frontiers
|
|
39
|
+
`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function run(argv = []) {
|
|
43
|
+
let args = argv;
|
|
44
|
+
if (Array.isArray(args) && args[0]?.endsWith('node')) {
|
|
45
|
+
args = args.slice(2);
|
|
46
|
+
}
|
|
47
|
+
if (args[0] === 'plugin' || args[0] === 'plugins') {
|
|
48
|
+
args = args.slice(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const command = args[0];
|
|
52
|
+
const rest = args.slice(1);
|
|
53
|
+
|
|
54
|
+
if (command === 'search' || command === 'catalog' || command === 'find') {
|
|
55
|
+
await searchCatalog(rest);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (command === 'create' || command === 'new' || command === 'init') {
|
|
60
|
+
await createPlugin(rest);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!command || command === 'list') {
|
|
65
|
+
await listPlugins(rest);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (command === 'install' || command === 'add') {
|
|
70
|
+
await installPlugin(rest);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (command === 'remove' || command === 'rm' || command === 'uninstall') {
|
|
75
|
+
await removePlugin(rest);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (command === 'info' || command === 'show') {
|
|
80
|
+
const id = rest[0];
|
|
81
|
+
if (!id) {
|
|
82
|
+
console.error('❌ Error: Missing plugin ID. Usage: total-recall plugin info <id>');
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
const plugin = getPlugin(id, process.cwd());
|
|
86
|
+
if (!plugin) {
|
|
87
|
+
console.error(`❌ Plugin '${id}' not found.`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log(`\n🔌 Plugin: ${plugin.manifest.name || plugin.id}`);
|
|
92
|
+
console.log(` ID: ${plugin.id}`);
|
|
93
|
+
console.log(` Version: v${plugin.manifest.version || '0.0.0'}`);
|
|
94
|
+
console.log(` Description: ${plugin.manifest.description || '—'}`);
|
|
95
|
+
console.log(` Directory: ${plugin.dir}`);
|
|
96
|
+
console.log(` Valid: ${plugin.valid ? '✅ Yes' : '❌ No'}`);
|
|
97
|
+
|
|
98
|
+
if (plugin.manifest.cli?.command) {
|
|
99
|
+
console.log(` CLI Command: total-recall ${plugin.manifest.cli.command}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const cats = plugin.manifest.ssss_schemas?.categories || [];
|
|
103
|
+
if (cats.length > 0) {
|
|
104
|
+
console.log(` SSSS Categories:`);
|
|
105
|
+
for (const c of cats) {
|
|
106
|
+
console.log(` - ${c.name} (${c.node_type || 'memory'}): ${c.description || ''}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const tasks = plugin.manifest.tasks || [];
|
|
111
|
+
if (tasks.length > 0) {
|
|
112
|
+
console.log(` Background Tasks:`);
|
|
113
|
+
for (const t of tasks) {
|
|
114
|
+
console.log(` - [${t.schedule}] ${t.intent}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const hooks = plugin.manifest.hooks || {};
|
|
119
|
+
if (Object.keys(hooks).length > 0) {
|
|
120
|
+
console.log(` Lifecycle Hooks:`);
|
|
121
|
+
for (const [h, script] of Object.entries(hooks)) {
|
|
122
|
+
console.log(` - ${h}: ${script}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
console.log();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (command === '--help' || command === '-h' || command === 'help') {
|
|
130
|
+
printHelp();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.error(`❌ Unknown plugin command: '${command}'`);
|
|
135
|
+
printHelp();
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export default run;
|