total-recall-brain 3.28.2 → 3.30.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 +24 -1
- package/docs/ARCHITECTURE.md +26 -18
- package/extension/background.js +259 -84
- package/extension/content-script.css +7 -13
- package/extension/content-script.js +252 -477
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-32.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/icons/mark.svg +6 -0
- package/extension/lib/brain-client.js +160 -73
- package/extension/lib/tokens.css +187 -0
- package/extension/lib/ui-helpers.js +229 -0
- package/extension/lib/ui-helpers.spec.mjs +94 -0
- package/extension/manifest.json +20 -5
- package/extension/options/options.css +64 -218
- package/extension/options/options.html +91 -43
- package/extension/options/options.js +208 -85
- package/extension/sidepanel/sidepanel.css +268 -970
- package/extension/sidepanel/sidepanel.html +200 -166
- package/extension/sidepanel/sidepanel.js +788 -942
- package/frontend/dist/assets/index-DGMNZi_1.js +5742 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/src/api/plugins.ts +124 -92
- package/frontend/src/components/ResearchAgendaTab.spec.tsx +11 -0
- package/frontend/src/components/ResearchAgendaTab.tsx +19 -14
- package/frontend/src/pages/PluginsPage.spec.tsx +87 -16
- package/frontend/src/pages/PluginsPage.tsx +454 -1218
- package/package.json +2 -1
- package/plugins/code-quality/README.md +25 -0
- package/plugins/code-quality/cli.mjs +25 -0
- package/plugins/code-quality/plugin.json +17 -0
- package/plugins/git-sentinel/README.md +27 -0
- package/{scaffold/.agent/plugins → plugins}/git-sentinel/cli.mjs +8 -5
- package/plugins/git-sentinel/plugin.json +18 -0
- package/plugins/system-monitor/README.md +26 -0
- package/{scaffold/.agent/plugins → plugins}/system-monitor/cli.mjs +5 -4
- package/{scaffold/.agent/plugins → plugins}/system-monitor/generator.mjs +4 -5
- package/plugins/system-monitor/plugin.json +29 -0
- package/scaffold/.agent/skills/meta-harness/SKILL.md +4 -4
- package/scaffold/.agent/skills/plugins/SKILL.md +53 -0
- package/scaffold/.agent/skills/total-recall/scripts/brain-state.json +1 -0
- package/src/cli/agent.mjs +1 -1
- package/src/cli/app/index.mjs +493 -0
- package/src/cli/brain.mjs +33 -3
- package/src/cli/command.mjs +80 -41
- package/src/cli/harness.mjs +1 -1
- package/src/cli/init.mjs +3 -1
- package/src/cli/mesh.mjs +118 -12
- package/src/cli/plugin/create.mjs +144 -25
- package/src/cli/plugin/index.mjs +83 -53
- package/src/cli/plugin/install.mjs +10 -127
- package/src/cli/plugin/list.mjs +44 -44
- package/src/cli/plugin/remove.mjs +23 -30
- package/src/cli/plugin/scaffold-cmd.mjs +30 -0
- package/src/cli/plugin/search.mjs +108 -23
- package/src/cli/rebuild.mjs +37 -1
- package/src/cli/recall.mjs +17 -2
- package/src/cli/research.mjs +4 -6
- package/src/cli/share.mjs +2 -0
- package/src/core/app-deploy/apply.mjs +386 -0
- package/src/core/app-deploy/design-tokens.mjs +217 -0
- package/src/core/app-deploy/plan.mjs +249 -0
- package/src/core/app-deploy/resolve.mjs +295 -0
- package/src/core/app-deploy/source.mjs +337 -0
- package/src/core/app-deploy/standalone.mjs +248 -0
- package/src/core/app-deploy/upgrade.mjs +256 -0
- package/src/core/app-deploy/verify.mjs +213 -0
- package/src/core/bound-hosts.mjs +1 -1
- package/src/core/brain-registry.mjs +146 -0
- package/src/core/brain-state.json +1 -0
- package/src/core/daemon-loop.mjs +30 -26
- package/src/core/dream.mjs +5 -17
- package/src/core/fact-seeker.mjs +53 -368
- package/src/core/lan-discovery.mjs +3 -3
- package/src/core/mesh-access-sync.mjs +134 -0
- package/src/core/mesh-access.mjs +18 -3
- package/src/core/mesh-discover.mjs +173 -0
- package/src/core/mesh.mjs +131 -32
- package/src/core/optimizer.mjs +4 -41
- package/src/core/plugin-bundle.mjs +167 -0
- package/src/core/plugin-context.mjs +8 -13
- package/src/core/plugin-loader.mjs +272 -68
- package/src/core/plugin-peers.mjs +163 -0
- package/src/core/plugin-public.mjs +85 -0
- package/src/core/plugin-runner-child.mjs +24 -0
- package/src/core/plugin-runner.mjs +102 -0
- package/src/core/plugin-store.mjs +384 -0
- package/src/core/plugin-tasks.mjs +212 -0
- package/src/core/post-mortem.mjs +40 -9
- package/src/core/research-gate.mjs +182 -0
- package/src/core/research-queue.mjs +47 -7
- package/src/core/research-surface.mjs +211 -0
- package/src/core/research.mjs +25 -32
- package/src/core/scheduler.mjs +67 -153
- package/src/core/schema.mjs +129 -0
- package/src/core/search.mjs +12 -1
- package/src/core/secret-integration-research.mjs +12 -8
- package/src/core/secrets-store.mjs +32 -2
- package/src/core/session-watcher.mjs +6 -0
- package/src/core/surface.mjs +64 -3
- package/src/core/task-executors.mjs +8 -45
- package/src/core/vault-cache.mjs +70 -4
- package/src/server/api.mjs +21 -0
- package/src/server/rest.mjs +2 -0
- package/src/server/route-manifest.json +21 -5
- package/src/server/routes/collab.mjs +38 -11
- package/src/server/routes/extension.mjs +3 -2
- package/src/server/routes/memory.mjs +24 -2
- package/src/server/routes/mesh.mjs +7 -7
- package/src/server/routes/plugins-mesh.mjs +49 -0
- package/src/server/routes/plugins.mjs +91 -426
- package/src/server/routes/research.mjs +5 -2
- package/src/server/routes/share.mjs +2 -0
- package/src/server/tools.mjs +29 -2
- package/extension/icons/README.md +0 -27
- package/extension/lib/preconfigured.js +0 -5
- package/extension/popup/popup.css +0 -227
- package/extension/popup/popup.html +0 -50
- package/extension/popup/popup.js +0 -175
- package/frontend/dist/assets/index-U7o-qU99.js +0 -5742
- package/scaffold/.agent/plugins/.gitkeep +0 -0
- package/scaffold/.agent/plugins/code-quality/README.md +0 -16
- package/scaffold/.agent/plugins/code-quality/cli.mjs +0 -15
- package/scaffold/.agent/plugins/code-quality/plugin.json +0 -34
- package/scaffold/.agent/plugins/git-sentinel/README.md +0 -16
- package/scaffold/.agent/plugins/git-sentinel/plugin.json +0 -34
- package/scaffold/.agent/plugins/meta-harness/README.md +0 -16
- package/scaffold/.agent/plugins/meta-harness/cli.mjs +0 -34
- package/scaffold/.agent/plugins/meta-harness/plugin.json +0 -34
- package/scaffold/.agent/plugins/system-monitor/README.md +0 -16
- package/scaffold/.agent/plugins/system-monitor/plugin.json +0 -38
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin bundles — the unit a node sends to a mesh peer.
|
|
3
|
+
*
|
|
4
|
+
* A bundle is plain JSON (no tar dependency) carrying every regular file of a
|
|
5
|
+
* plugin, base64-encoded, plus a SHA-256 content hash computed over a canonical
|
|
6
|
+
* ordering. The receiver recomputes the hash from the bytes it actually wrote
|
|
7
|
+
* and refuses the install on any mismatch, so a truncated or altered transfer
|
|
8
|
+
* can never become an installed plugin.
|
|
9
|
+
*/
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
export const BUNDLE_FORMAT = 'tr-plugin-bundle/1';
|
|
15
|
+
export const MAX_BUNDLE_FILES = 200;
|
|
16
|
+
export const MAX_BUNDLE_BYTES = 5 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules']);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* List a plugin's files in canonical order.
|
|
22
|
+
* Dotfiles, `.git/`, `node_modules/` and symlinks are excluded: they are either
|
|
23
|
+
* local state or a way to smuggle a path outside the plugin.
|
|
24
|
+
* @returns {Array<{ path: string, abs: string, size: number, mode: number }>}
|
|
25
|
+
*/
|
|
26
|
+
export function listPluginFiles(pluginDir) {
|
|
27
|
+
const root = fs.realpathSync(pluginDir);
|
|
28
|
+
const out = [];
|
|
29
|
+
const walk = (dir) => {
|
|
30
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
31
|
+
if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue;
|
|
32
|
+
const abs = path.join(dir, entry.name);
|
|
33
|
+
if (entry.isSymbolicLink()) continue;
|
|
34
|
+
if (entry.isDirectory()) {
|
|
35
|
+
walk(abs);
|
|
36
|
+
} else if (entry.isFile()) {
|
|
37
|
+
const stat = fs.statSync(abs);
|
|
38
|
+
out.push({
|
|
39
|
+
path: path.relative(root, abs).split(path.sep).join('/'),
|
|
40
|
+
abs,
|
|
41
|
+
size: stat.size,
|
|
42
|
+
mode: stat.mode & 0o777
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
walk(root);
|
|
48
|
+
out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function hashEntries(entries) {
|
|
53
|
+
const h = crypto.createHash('sha256');
|
|
54
|
+
for (const e of entries) {
|
|
55
|
+
h.update(e.path);
|
|
56
|
+
h.update('\0');
|
|
57
|
+
h.update(String(e.data.length));
|
|
58
|
+
h.update('\0');
|
|
59
|
+
h.update(e.data);
|
|
60
|
+
}
|
|
61
|
+
return h.digest('hex');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** SHA-256 over a plugin directory's canonical file list. */
|
|
65
|
+
export function hashPluginDir(pluginDir) {
|
|
66
|
+
const files = listPluginFiles(pluginDir);
|
|
67
|
+
return {
|
|
68
|
+
sha256: hashEntries(files.map((f) => ({ path: f.path, data: fs.readFileSync(f.abs) }))),
|
|
69
|
+
file_count: files.length,
|
|
70
|
+
size_bytes: files.reduce((n, f) => n + f.size, 0)
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Pack a plugin directory into a transferable bundle. */
|
|
75
|
+
export function packPlugin(pluginDir, manifest) {
|
|
76
|
+
const files = listPluginFiles(pluginDir);
|
|
77
|
+
if (files.length > MAX_BUNDLE_FILES) {
|
|
78
|
+
throw new Error(`Plugin has ${files.length} files; the limit is ${MAX_BUNDLE_FILES}`);
|
|
79
|
+
}
|
|
80
|
+
const total = files.reduce((n, f) => n + f.size, 0);
|
|
81
|
+
if (total > MAX_BUNDLE_BYTES) {
|
|
82
|
+
throw new Error(`Plugin is ${total} bytes; the limit is ${MAX_BUNDLE_BYTES}`);
|
|
83
|
+
}
|
|
84
|
+
const entries = files.map((f) => ({ path: f.path, mode: f.mode, data: fs.readFileSync(f.abs) }));
|
|
85
|
+
return {
|
|
86
|
+
format: BUNDLE_FORMAT,
|
|
87
|
+
id: manifest.id,
|
|
88
|
+
version: manifest.version,
|
|
89
|
+
sha256: hashEntries(entries),
|
|
90
|
+
files: entries.map((e) => ({ path: e.path, mode: e.mode, data_b64: e.data.toString('base64') }))
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function assertSafeRelativePath(p) {
|
|
95
|
+
if (typeof p !== 'string' || p.length === 0 || p.length > 512) throw new Error('Bundle file path is empty or too long');
|
|
96
|
+
if (p.includes('\\') || p.includes('\0')) throw new Error(`Bundle file path '${p}' contains a forbidden character`);
|
|
97
|
+
if (p.startsWith('/') || /^[A-Za-z]:/.test(p)) throw new Error(`Bundle file path '${p}' is absolute`);
|
|
98
|
+
const parts = p.split('/');
|
|
99
|
+
if (parts.some((s) => s === '' || s === '.' || s === '..' || s.startsWith('.'))) {
|
|
100
|
+
throw new Error(`Bundle file path '${p}' is not a plain relative path`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Decode and check a bundle without touching the filesystem.
|
|
106
|
+
* @returns {{ id: string, version: string, sha256: string, entries: Array<{path:string, mode:number, data:Buffer}> }}
|
|
107
|
+
*/
|
|
108
|
+
export function decodeBundle(bundle, { expectedSha256 } = {}) {
|
|
109
|
+
if (!bundle || typeof bundle !== 'object' || bundle.format !== BUNDLE_FORMAT) {
|
|
110
|
+
throw new Error('Not a Total Recall plugin bundle');
|
|
111
|
+
}
|
|
112
|
+
if (!Array.isArray(bundle.files) || bundle.files.length === 0) throw new Error('Bundle contains no files');
|
|
113
|
+
if (bundle.files.length > MAX_BUNDLE_FILES) throw new Error('Bundle exceeds the file limit');
|
|
114
|
+
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
let total = 0;
|
|
117
|
+
const entries = bundle.files.map((f) => {
|
|
118
|
+
assertSafeRelativePath(f?.path);
|
|
119
|
+
if (seen.has(f.path)) throw new Error(`Bundle lists '${f.path}' twice`);
|
|
120
|
+
seen.add(f.path);
|
|
121
|
+
const data = Buffer.from(String(f.data_b64 || ''), 'base64');
|
|
122
|
+
total += data.length;
|
|
123
|
+
if (total > MAX_BUNDLE_BYTES) throw new Error('Bundle exceeds the size limit');
|
|
124
|
+
return { path: f.path, mode: Number.isInteger(f.mode) ? f.mode & 0o755 : 0o644, data };
|
|
125
|
+
});
|
|
126
|
+
entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
127
|
+
|
|
128
|
+
if (!seen.has('plugin.json')) throw new Error('Bundle has no plugin.json');
|
|
129
|
+
|
|
130
|
+
const sha256 = hashEntries(entries);
|
|
131
|
+
if (sha256 !== bundle.sha256) {
|
|
132
|
+
throw new Error(`Bundle content hash mismatch (declared ${bundle.sha256}, actual ${sha256})`);
|
|
133
|
+
}
|
|
134
|
+
if (expectedSha256 && sha256 !== expectedSha256) {
|
|
135
|
+
throw new Error(`Bundle content hash ${sha256} does not match the advertised ${expectedSha256}`);
|
|
136
|
+
}
|
|
137
|
+
return { id: bundle.id, version: bundle.version, sha256, entries };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Write decoded entries into `<pluginsDir>/<id>` atomically: files go to a
|
|
142
|
+
* dot-prefixed staging dir (invisible to discovery) that is renamed into place.
|
|
143
|
+
*/
|
|
144
|
+
export function writeEntriesAtomic(pluginsDir, id, entries) {
|
|
145
|
+
fs.mkdirSync(pluginsDir, { recursive: true });
|
|
146
|
+
const dest = path.join(pluginsDir, id);
|
|
147
|
+
if (fs.existsSync(dest)) throw new Error(`Plugin '${id}' is already installed at ${dest}`);
|
|
148
|
+
const staging = path.join(pluginsDir, `.staging-${id}-${crypto.randomBytes(4).toString('hex')}`);
|
|
149
|
+
try {
|
|
150
|
+
for (const e of entries) {
|
|
151
|
+
const target = path.join(staging, ...e.path.split('/'));
|
|
152
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
153
|
+
fs.writeFileSync(target, e.data, { mode: e.mode });
|
|
154
|
+
}
|
|
155
|
+
fs.renameSync(staging, dest);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
return dest;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Copy a local plugin directory into `pluginsDir` atomically (same exclusions as bundles). */
|
|
164
|
+
export function copyPluginAtomic(sourceDir, pluginsDir, id) {
|
|
165
|
+
const entries = listPluginFiles(sourceDir).map((f) => ({ path: f.path, mode: f.mode, data: fs.readFileSync(f.abs) }));
|
|
166
|
+
return writeEntriesAtomic(pluginsDir, id, entries);
|
|
167
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
-
import {
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { discoverPlugins, getPluginWatchPaths, getPlugin, getPluginCategories, validatePluginManifest, resolveProjectRoot } from './plugin-loader.mjs';
|
|
5
6
|
|
|
6
7
|
export { discoverPlugins, getPluginWatchPaths, getPlugin, getPluginCategories, validatePluginManifest };
|
|
7
8
|
|
|
@@ -10,16 +11,7 @@ export { discoverPlugins, getPluginWatchPaths, getPlugin, getPluginCategories, v
|
|
|
10
11
|
* Supports plugin-directed compilation via custom generators or standard SSSS category aggregation.
|
|
11
12
|
*/
|
|
12
13
|
export async function assemblePluginContexts({ projectRoot = process.cwd(), vaultDir, nodes = [], derivedDir } = {}) {
|
|
13
|
-
|
|
14
|
-
if (vaultDir && (!resolvedProjectRoot || resolvedProjectRoot === process.cwd())) {
|
|
15
|
-
try {
|
|
16
|
-
const parent = path.resolve(vaultDir, '..', '..', '..');
|
|
17
|
-
if (fs.existsSync(path.join(parent, '.agent'))) {
|
|
18
|
-
resolvedProjectRoot = parent;
|
|
19
|
-
}
|
|
20
|
-
} catch {}
|
|
21
|
-
}
|
|
22
|
-
|
|
14
|
+
const resolvedProjectRoot = resolveProjectRoot(projectRoot, vaultDir);
|
|
23
15
|
const plugins = discoverPlugins(resolvedProjectRoot);
|
|
24
16
|
if (plugins.length === 0) return '';
|
|
25
17
|
|
|
@@ -32,14 +24,17 @@ export async function assemblePluginContexts({ projectRoot = process.cwd(), vaul
|
|
|
32
24
|
// 1. Check if plugin directs compilation via a custom generator
|
|
33
25
|
if (manifest.compile?.generator) {
|
|
34
26
|
const generatorRel = manifest.compile.generator;
|
|
35
|
-
|
|
27
|
+
let generatorPath = path.isAbsolute(generatorRel)
|
|
36
28
|
? generatorRel
|
|
37
29
|
: path.resolve(dir, generatorRel);
|
|
38
30
|
|
|
39
31
|
if (!fs.existsSync(generatorPath)) generatorPath = path.resolve(resolvedProjectRoot, generatorRel);
|
|
40
32
|
if (fs.existsSync(generatorPath)) {
|
|
41
33
|
try {
|
|
42
|
-
|
|
34
|
+
// Keyed by mtime so an edited generator is picked up without a restart.
|
|
35
|
+
const url = pathToFileURL(generatorPath);
|
|
36
|
+
url.searchParams.set('mtime', String(fs.statSync(generatorPath).mtimeMs));
|
|
37
|
+
const mod = await import(url.href);
|
|
43
38
|
const fn = mod.generateContext || mod.default;
|
|
44
39
|
if (typeof fn === 'function') {
|
|
45
40
|
const generated = await fn({
|
|
@@ -1,9 +1,68 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
4
5
|
|
|
5
6
|
const ID_PATTERN = /^[a-z][a-z0-9-]{1,63}$/;
|
|
6
7
|
const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
|
|
8
|
+
const USE_CASE_PATTERN = /^[a-z][a-z0-9-]{1,47}$/;
|
|
9
|
+
const CRON_FIELD_PATTERN = /^[\d*,/-]+$/;
|
|
10
|
+
|
|
11
|
+
/** Root of the installed total-recall package (holds `plugins/` with the bundled sources). */
|
|
12
|
+
export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Where plugins live. Everything a brain owns sits under `skills/total-recall/`
|
|
16
|
+
* — `.agent/` itself holds only `skills/` and `secrets.enc`.
|
|
17
|
+
*/
|
|
18
|
+
export function projectPluginsDir(projectRoot = process.cwd()) {
|
|
19
|
+
return path.join(projectRoot, '.agent', 'skills', 'total-recall', 'plugins');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function globalPluginsDir() {
|
|
23
|
+
const agentDir = process.env.AGENT_DIR || process.env._TR_TEST_AGENT_DIR || path.join(os.homedir(), '.agent');
|
|
24
|
+
return path.join(agentDir, 'skills', 'total-recall', 'plugins');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function bundledPluginsDir() {
|
|
28
|
+
return path.join(PACKAGE_ROOT, 'plugins');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The vault that owns records for plugins installed in `pluginsDir` (`<brain>/plugins` → `<brain>/memory-vault`). */
|
|
32
|
+
export function vaultForPluginsDir(pluginsDir) {
|
|
33
|
+
return path.join(path.dirname(pluginsDir), 'memory-vault');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The project a vault belongs to. A brain vault sits at
|
|
38
|
+
* `<project>/.agent/skills/total-recall/memory-vault`, four levels below the
|
|
39
|
+
* project root. Only used when the caller did not pass an explicit root.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveProjectRoot(projectRoot, vaultDir) {
|
|
42
|
+
if (!vaultDir || (projectRoot && projectRoot !== process.cwd())) return projectRoot || process.cwd();
|
|
43
|
+
const parent = path.resolve(vaultDir, '..', '..', '..', '..');
|
|
44
|
+
return fs.existsSync(path.join(parent, '.agent')) ? parent : (projectRoot || process.cwd());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** True when a 5-field cron expression is syntactically plausible. Semantics are checked by plugin-tasks. */
|
|
48
|
+
export function isCronExpression(expr) {
|
|
49
|
+
if (typeof expr !== 'string') return false;
|
|
50
|
+
const fields = expr.trim().split(/\s+/);
|
|
51
|
+
return fields.length === 5 && fields.every((f) => CRON_FIELD_PATTERN.test(f));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validates that a path is relative, normalized, and cannot traverse above its root.
|
|
56
|
+
* Rejects absolute paths, null bytes, and any use of parent directory traversal ('..').
|
|
57
|
+
*/
|
|
58
|
+
export function isSafeRelativePath(p) {
|
|
59
|
+
if (typeof p !== 'string' || !p.trim()) return false;
|
|
60
|
+
if (p.includes('\0')) return false;
|
|
61
|
+
if (path.isAbsolute(p) || p.startsWith('/') || p.startsWith('\\')) return false;
|
|
62
|
+
const normalized = path.normalize(p);
|
|
63
|
+
if (normalized === '..' || normalized.startsWith('..' + path.sep) || normalized.includes(path.sep + '..' + path.sep) || normalized.endsWith(path.sep + '..')) return false;
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
7
66
|
|
|
8
67
|
/**
|
|
9
68
|
* Validates a plugin manifest object against Total Recall plugin standards.
|
|
@@ -37,6 +96,18 @@ export function validatePluginManifest(manifest) {
|
|
|
37
96
|
errors.push("Missing required field 'description' (minimum 5 characters)");
|
|
38
97
|
}
|
|
39
98
|
|
|
99
|
+
if (manifest.use_cases !== undefined) {
|
|
100
|
+
if (!Array.isArray(manifest.use_cases)) {
|
|
101
|
+
errors.push("'use_cases' must be an array of kebab-case strings");
|
|
102
|
+
} else {
|
|
103
|
+
for (const u of manifest.use_cases) {
|
|
104
|
+
if (typeof u !== 'string' || !USE_CASE_PATTERN.test(u)) {
|
|
105
|
+
errors.push(`Invalid use case '${u}': must be kebab-case`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
40
111
|
if (manifest.ssss_schemas?.categories) {
|
|
41
112
|
if (!Array.isArray(manifest.ssss_schemas.categories)) {
|
|
42
113
|
errors.push("'ssss_schemas.categories' must be an array");
|
|
@@ -54,8 +125,137 @@ export function validatePluginManifest(manifest) {
|
|
|
54
125
|
errors.push("'tasks' must be an array");
|
|
55
126
|
} else {
|
|
56
127
|
for (const t of manifest.tasks) {
|
|
57
|
-
if (!t.intent || !t.schedule) {
|
|
128
|
+
if (!t || !t.intent || !t.schedule) {
|
|
58
129
|
errors.push("Each task must define 'intent' and 'schedule'");
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!isCronExpression(t.schedule)) {
|
|
133
|
+
errors.push(`Task '${t.intent}' has an invalid schedule '${t.schedule}': expected 5-field cron`);
|
|
134
|
+
}
|
|
135
|
+
// A task is a plugin CLI subcommand run on a schedule. Without a
|
|
136
|
+
// command and a handler there is nothing to run, and a task that is
|
|
137
|
+
// displayed but never executes is worse than no task.
|
|
138
|
+
if (!t.command || typeof t.command !== 'string') {
|
|
139
|
+
errors.push(`Task '${t.intent}' must define 'command' (the plugin CLI subcommand to run)`);
|
|
140
|
+
}
|
|
141
|
+
if (!manifest.cli?.handler) {
|
|
142
|
+
errors.push(`Task '${t.intent}' requires 'cli.handler' to run its command`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (manifest.cli?.handler && !isSafeRelativePath(manifest.cli.handler)) {
|
|
149
|
+
errors.push("cli.handler must be a safe relative path without directory traversal");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (manifest.compile?.generator && !isSafeRelativePath(manifest.compile.generator)) {
|
|
153
|
+
errors.push("compile.generator must be a safe relative path without directory traversal");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (manifest.deploy !== undefined) {
|
|
157
|
+
if (typeof manifest.deploy !== 'object' || manifest.deploy === null) {
|
|
158
|
+
errors.push("'deploy' must be an object");
|
|
159
|
+
} else {
|
|
160
|
+
if (manifest.deploy.targets !== undefined) {
|
|
161
|
+
if (!Array.isArray(manifest.deploy.targets) || manifest.deploy.targets.length === 0) {
|
|
162
|
+
errors.push("'deploy.targets' must be a non-empty array of target names");
|
|
163
|
+
} else {
|
|
164
|
+
for (const t of manifest.deploy.targets) {
|
|
165
|
+
if (typeof t !== 'string' || !t.trim()) {
|
|
166
|
+
errors.push(`Invalid deploy target '${t}': must be a non-empty string`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (manifest.deploy.required_ssss_version !== undefined) {
|
|
172
|
+
if (typeof manifest.deploy.required_ssss_version !== 'string' || !manifest.deploy.required_ssss_version.trim()) {
|
|
173
|
+
errors.push("'deploy.required_ssss_version' must be a non-empty string");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (manifest.deploy.access_grants !== undefined) {
|
|
177
|
+
if (!Array.isArray(manifest.deploy.access_grants)) {
|
|
178
|
+
errors.push("'deploy.access_grants' must be an array of permission strings");
|
|
179
|
+
} else {
|
|
180
|
+
const GRANT_PATTERN = /^[a-z0-9_-]+(:[a-z0-9_*-]+)*$/;
|
|
181
|
+
for (const g of manifest.deploy.access_grants) {
|
|
182
|
+
if (typeof g !== 'string' || !GRANT_PATTERN.test(g)) {
|
|
183
|
+
errors.push(`Invalid access grant '${g}': must follow format domain:action (e.g. ssss:vault:read)`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (manifest.deploy.adapter !== undefined) {
|
|
189
|
+
if (typeof manifest.deploy.adapter !== 'string' || !manifest.deploy.adapter.trim()) {
|
|
190
|
+
errors.push("'deploy.adapter' must be a non-empty string");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (manifest.deploy.resources !== undefined) {
|
|
194
|
+
if (typeof manifest.deploy.resources !== 'object' || manifest.deploy.resources === null) {
|
|
195
|
+
errors.push("'deploy.resources' must be an object");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (manifest.skills !== undefined) {
|
|
202
|
+
if (!Array.isArray(manifest.skills)) {
|
|
203
|
+
errors.push("'skills' must be an array");
|
|
204
|
+
} else {
|
|
205
|
+
for (const s of manifest.skills) {
|
|
206
|
+
if (!s || typeof s !== 'object') {
|
|
207
|
+
errors.push("Each entry in 'skills' must be an object");
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (!s.id || typeof s.id !== 'string') {
|
|
211
|
+
errors.push("Each skill entry must have a string 'id'");
|
|
212
|
+
}
|
|
213
|
+
if (!s.path || !isSafeRelativePath(s.path)) {
|
|
214
|
+
errors.push(`Skill '${s.id || 'unknown'}' has invalid or unsafe 'path': must be safe relative path`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (manifest.commands !== undefined) {
|
|
221
|
+
if (!Array.isArray(manifest.commands)) {
|
|
222
|
+
errors.push("'commands' must be an array");
|
|
223
|
+
} else {
|
|
224
|
+
for (const c of manifest.commands) {
|
|
225
|
+
if (!c || typeof c !== 'object') {
|
|
226
|
+
errors.push("Each entry in 'commands' must be an object");
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (!c.name || typeof c.name !== 'string') {
|
|
230
|
+
errors.push("Each command entry must have a string 'name'");
|
|
231
|
+
}
|
|
232
|
+
if (!c.handler || !isSafeRelativePath(c.handler)) {
|
|
233
|
+
errors.push(`Command '${c.name || 'unknown'}' has invalid or unsafe 'handler': must be safe relative path`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (manifest.ui !== undefined) {
|
|
240
|
+
if (typeof manifest.ui !== 'object' || manifest.ui === null) {
|
|
241
|
+
errors.push("'ui' must be an object");
|
|
242
|
+
} else {
|
|
243
|
+
if (manifest.ui.design_tokens !== undefined && !isSafeRelativePath(manifest.ui.design_tokens)) {
|
|
244
|
+
errors.push("'ui.design_tokens' must be a safe relative path");
|
|
245
|
+
}
|
|
246
|
+
if (manifest.ui.elements !== undefined && !Array.isArray(manifest.ui.elements)) {
|
|
247
|
+
errors.push("'ui.elements' must be an array");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (manifest.openwiki_hubs !== undefined) {
|
|
253
|
+
if (!Array.isArray(manifest.openwiki_hubs)) {
|
|
254
|
+
errors.push("'openwiki_hubs' must be an array");
|
|
255
|
+
} else {
|
|
256
|
+
for (const hub of manifest.openwiki_hubs) {
|
|
257
|
+
if (!hub || !hub.path || !isSafeRelativePath(hub.path)) {
|
|
258
|
+
errors.push("Each entry in 'openwiki_hubs' must have a safe relative 'path'");
|
|
59
259
|
}
|
|
60
260
|
}
|
|
61
261
|
}
|
|
@@ -67,75 +267,88 @@ export function validatePluginManifest(manifest) {
|
|
|
67
267
|
};
|
|
68
268
|
}
|
|
69
269
|
|
|
270
|
+
/** Read and validate every `<dir>/<name>/plugin.json` under one plugins directory. */
|
|
271
|
+
export function readPluginsDir(dir, scope) {
|
|
272
|
+
const out = [];
|
|
273
|
+
if (!fs.existsSync(dir)) return out;
|
|
274
|
+
let entries = [];
|
|
275
|
+
try {
|
|
276
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
277
|
+
} catch {
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
for (const entry of entries) {
|
|
282
|
+
// Staging directories from an in-flight install are never plugins.
|
|
283
|
+
if (entry.name.startsWith('.')) continue;
|
|
284
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
285
|
+
const pluginDir = path.join(dir, entry.name);
|
|
286
|
+
const manifestPath = path.join(pluginDir, 'plugin.json');
|
|
287
|
+
if (!fs.existsSync(manifestPath)) continue;
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
291
|
+
const validation = validatePluginManifest(manifest);
|
|
292
|
+
out.push({
|
|
293
|
+
id: manifest.id || entry.name,
|
|
294
|
+
manifest,
|
|
295
|
+
dir: pluginDir,
|
|
296
|
+
pluginsDir: dir,
|
|
297
|
+
manifestPath,
|
|
298
|
+
scope,
|
|
299
|
+
linked: entry.isSymbolicLink(),
|
|
300
|
+
valid: validation.valid,
|
|
301
|
+
errors: validation.errors
|
|
302
|
+
});
|
|
303
|
+
} catch (err) {
|
|
304
|
+
out.push({
|
|
305
|
+
id: entry.name,
|
|
306
|
+
manifest: {},
|
|
307
|
+
dir: pluginDir,
|
|
308
|
+
pluginsDir: dir,
|
|
309
|
+
manifestPath,
|
|
310
|
+
scope,
|
|
311
|
+
linked: entry.isSymbolicLink(),
|
|
312
|
+
valid: false,
|
|
313
|
+
errors: [`JSON parse error: ${err.message}`]
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
|
|
70
320
|
/**
|
|
71
321
|
* Discover installed plugins across project and global directories.
|
|
72
|
-
*
|
|
322
|
+
* A project plugin shadows a global plugin with the same id.
|
|
73
323
|
* @param {string} [projectRoot] - Current project root directory
|
|
74
|
-
* @returns {Array<{ id: string, manifest: object, dir: string, manifestPath: string, valid: boolean, errors: string[] }>}
|
|
75
324
|
*/
|
|
76
325
|
export function discoverPlugins(projectRoot = process.cwd()) {
|
|
326
|
+
const seen = new Set();
|
|
77
327
|
const plugins = [];
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
328
|
+
const sources = [
|
|
329
|
+
[projectPluginsDir(projectRoot), 'project'],
|
|
330
|
+
[globalPluginsDir(), 'global']
|
|
81
331
|
];
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
for (const entry of entries) {
|
|
95
|
-
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
96
|
-
const pluginDir = path.join(dir, entry.name);
|
|
97
|
-
const manifestPath = path.join(pluginDir, 'plugin.json');
|
|
98
|
-
|
|
99
|
-
if (fs.existsSync(manifestPath)) {
|
|
100
|
-
try {
|
|
101
|
-
const raw = fs.readFileSync(manifestPath, 'utf8');
|
|
102
|
-
const manifest = JSON.parse(raw);
|
|
103
|
-
const validation = validatePluginManifest(manifest);
|
|
104
|
-
const id = manifest.id || entry.name;
|
|
105
|
-
|
|
106
|
-
if (!seenIds.has(id)) {
|
|
107
|
-
seenIds.add(id);
|
|
108
|
-
plugins.push({
|
|
109
|
-
id,
|
|
110
|
-
manifest,
|
|
111
|
-
dir: pluginDir,
|
|
112
|
-
manifestPath,
|
|
113
|
-
valid: validation.valid,
|
|
114
|
-
errors: validation.errors
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
} catch (err) {
|
|
118
|
-
// Record unparseable manifest
|
|
119
|
-
const id = entry.name;
|
|
120
|
-
if (!seenIds.has(id)) {
|
|
121
|
-
seenIds.add(id);
|
|
122
|
-
plugins.push({
|
|
123
|
-
id,
|
|
124
|
-
manifest: {},
|
|
125
|
-
dir: pluginDir,
|
|
126
|
-
manifestPath,
|
|
127
|
-
valid: false,
|
|
128
|
-
errors: [`JSON parse error: ${err.message}`]
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
332
|
+
const visited = new Set();
|
|
333
|
+
for (const [dir, scope] of sources) {
|
|
334
|
+
const resolved = path.resolve(dir);
|
|
335
|
+
// When the project *is* the home brain the two directories coincide.
|
|
336
|
+
if (visited.has(resolved)) continue;
|
|
337
|
+
visited.add(resolved);
|
|
338
|
+
for (const p of readPluginsDir(dir, scope)) {
|
|
339
|
+
if (seen.has(p.id)) continue;
|
|
340
|
+
seen.add(p.id);
|
|
341
|
+
plugins.push(p);
|
|
133
342
|
}
|
|
134
343
|
}
|
|
135
|
-
|
|
136
344
|
return plugins;
|
|
137
345
|
}
|
|
138
346
|
|
|
347
|
+
/** Plugins shipped inside the total-recall package, installable on any node. */
|
|
348
|
+
export function listBundledPlugins() {
|
|
349
|
+
return readPluginsDir(bundledPluginsDir(), 'bundled');
|
|
350
|
+
}
|
|
351
|
+
|
|
139
352
|
/**
|
|
140
353
|
* Retrieve a specific plugin by id.
|
|
141
354
|
*/
|
|
@@ -174,16 +387,7 @@ export function getPluginCategories(projectRoot = process.cwd()) {
|
|
|
174
387
|
* Collect all filesystem paths watched by installed plugins for plugin-directed compilation.
|
|
175
388
|
*/
|
|
176
389
|
export function getPluginWatchPaths(projectRoot = process.cwd(), vaultDir) {
|
|
177
|
-
|
|
178
|
-
if (vaultDir && (!resolvedProjectRoot || resolvedProjectRoot === process.cwd())) {
|
|
179
|
-
try {
|
|
180
|
-
const parent = path.resolve(vaultDir, '..', '..', '..');
|
|
181
|
-
if (fs.existsSync(path.join(parent, '.agent'))) {
|
|
182
|
-
resolvedProjectRoot = parent;
|
|
183
|
-
}
|
|
184
|
-
} catch {}
|
|
185
|
-
}
|
|
186
|
-
|
|
390
|
+
const resolvedProjectRoot = resolveProjectRoot(projectRoot, vaultDir);
|
|
187
391
|
const plugins = discoverPlugins(resolvedProjectRoot);
|
|
188
392
|
const paths = new Set();
|
|
189
393
|
|