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.
Files changed (132) hide show
  1. package/bin/total-recall.mjs +24 -1
  2. package/docs/ARCHITECTURE.md +26 -18
  3. package/extension/background.js +259 -84
  4. package/extension/content-script.css +7 -13
  5. package/extension/content-script.js +252 -477
  6. package/extension/icons/icon-128.png +0 -0
  7. package/extension/icons/icon-16.png +0 -0
  8. package/extension/icons/icon-32.png +0 -0
  9. package/extension/icons/icon-48.png +0 -0
  10. package/extension/icons/mark.svg +6 -0
  11. package/extension/lib/brain-client.js +160 -73
  12. package/extension/lib/tokens.css +187 -0
  13. package/extension/lib/ui-helpers.js +229 -0
  14. package/extension/lib/ui-helpers.spec.mjs +94 -0
  15. package/extension/manifest.json +20 -5
  16. package/extension/options/options.css +64 -218
  17. package/extension/options/options.html +91 -43
  18. package/extension/options/options.js +208 -85
  19. package/extension/sidepanel/sidepanel.css +268 -970
  20. package/extension/sidepanel/sidepanel.html +200 -166
  21. package/extension/sidepanel/sidepanel.js +788 -942
  22. package/frontend/dist/assets/index-DGMNZi_1.js +5742 -0
  23. package/frontend/dist/index.html +1 -1
  24. package/frontend/src/api/plugins.ts +124 -92
  25. package/frontend/src/components/ResearchAgendaTab.spec.tsx +11 -0
  26. package/frontend/src/components/ResearchAgendaTab.tsx +19 -14
  27. package/frontend/src/pages/PluginsPage.spec.tsx +87 -16
  28. package/frontend/src/pages/PluginsPage.tsx +454 -1218
  29. package/package.json +2 -1
  30. package/plugins/code-quality/README.md +25 -0
  31. package/plugins/code-quality/cli.mjs +25 -0
  32. package/plugins/code-quality/plugin.json +17 -0
  33. package/plugins/git-sentinel/README.md +27 -0
  34. package/{scaffold/.agent/plugins → plugins}/git-sentinel/cli.mjs +8 -5
  35. package/plugins/git-sentinel/plugin.json +18 -0
  36. package/plugins/system-monitor/README.md +26 -0
  37. package/{scaffold/.agent/plugins → plugins}/system-monitor/cli.mjs +5 -4
  38. package/{scaffold/.agent/plugins → plugins}/system-monitor/generator.mjs +4 -5
  39. package/plugins/system-monitor/plugin.json +29 -0
  40. package/scaffold/.agent/skills/meta-harness/SKILL.md +4 -4
  41. package/scaffold/.agent/skills/plugins/SKILL.md +53 -0
  42. package/scaffold/.agent/skills/total-recall/scripts/brain-state.json +1 -0
  43. package/src/cli/agent.mjs +1 -1
  44. package/src/cli/app/index.mjs +493 -0
  45. package/src/cli/brain.mjs +33 -3
  46. package/src/cli/command.mjs +80 -41
  47. package/src/cli/harness.mjs +1 -1
  48. package/src/cli/init.mjs +3 -1
  49. package/src/cli/mesh.mjs +118 -12
  50. package/src/cli/plugin/create.mjs +144 -25
  51. package/src/cli/plugin/index.mjs +83 -53
  52. package/src/cli/plugin/install.mjs +10 -127
  53. package/src/cli/plugin/list.mjs +44 -44
  54. package/src/cli/plugin/remove.mjs +23 -30
  55. package/src/cli/plugin/scaffold-cmd.mjs +30 -0
  56. package/src/cli/plugin/search.mjs +108 -23
  57. package/src/cli/rebuild.mjs +37 -1
  58. package/src/cli/recall.mjs +17 -2
  59. package/src/cli/research.mjs +4 -6
  60. package/src/cli/share.mjs +2 -0
  61. package/src/core/app-deploy/apply.mjs +386 -0
  62. package/src/core/app-deploy/design-tokens.mjs +217 -0
  63. package/src/core/app-deploy/plan.mjs +249 -0
  64. package/src/core/app-deploy/resolve.mjs +295 -0
  65. package/src/core/app-deploy/source.mjs +337 -0
  66. package/src/core/app-deploy/standalone.mjs +248 -0
  67. package/src/core/app-deploy/upgrade.mjs +256 -0
  68. package/src/core/app-deploy/verify.mjs +213 -0
  69. package/src/core/bound-hosts.mjs +1 -1
  70. package/src/core/brain-registry.mjs +146 -0
  71. package/src/core/brain-state.json +1 -0
  72. package/src/core/daemon-loop.mjs +30 -26
  73. package/src/core/dream.mjs +5 -17
  74. package/src/core/fact-seeker.mjs +53 -368
  75. package/src/core/lan-discovery.mjs +3 -3
  76. package/src/core/mesh-access-sync.mjs +134 -0
  77. package/src/core/mesh-access.mjs +18 -3
  78. package/src/core/mesh-discover.mjs +173 -0
  79. package/src/core/mesh.mjs +131 -32
  80. package/src/core/optimizer.mjs +4 -41
  81. package/src/core/plugin-bundle.mjs +167 -0
  82. package/src/core/plugin-context.mjs +8 -13
  83. package/src/core/plugin-loader.mjs +272 -68
  84. package/src/core/plugin-peers.mjs +163 -0
  85. package/src/core/plugin-public.mjs +85 -0
  86. package/src/core/plugin-runner-child.mjs +24 -0
  87. package/src/core/plugin-runner.mjs +102 -0
  88. package/src/core/plugin-store.mjs +384 -0
  89. package/src/core/plugin-tasks.mjs +212 -0
  90. package/src/core/post-mortem.mjs +40 -9
  91. package/src/core/research-gate.mjs +182 -0
  92. package/src/core/research-queue.mjs +47 -7
  93. package/src/core/research-surface.mjs +211 -0
  94. package/src/core/research.mjs +25 -32
  95. package/src/core/scheduler.mjs +67 -153
  96. package/src/core/schema.mjs +129 -0
  97. package/src/core/search.mjs +12 -1
  98. package/src/core/secret-integration-research.mjs +12 -8
  99. package/src/core/secrets-store.mjs +32 -2
  100. package/src/core/session-watcher.mjs +6 -0
  101. package/src/core/surface.mjs +64 -3
  102. package/src/core/task-executors.mjs +8 -45
  103. package/src/core/vault-cache.mjs +70 -4
  104. package/src/server/api.mjs +21 -0
  105. package/src/server/rest.mjs +2 -0
  106. package/src/server/route-manifest.json +21 -5
  107. package/src/server/routes/collab.mjs +38 -11
  108. package/src/server/routes/extension.mjs +3 -2
  109. package/src/server/routes/memory.mjs +24 -2
  110. package/src/server/routes/mesh.mjs +7 -7
  111. package/src/server/routes/plugins-mesh.mjs +49 -0
  112. package/src/server/routes/plugins.mjs +91 -426
  113. package/src/server/routes/research.mjs +5 -2
  114. package/src/server/routes/share.mjs +2 -0
  115. package/src/server/tools.mjs +29 -2
  116. package/extension/icons/README.md +0 -27
  117. package/extension/lib/preconfigured.js +0 -5
  118. package/extension/popup/popup.css +0 -227
  119. package/extension/popup/popup.html +0 -50
  120. package/extension/popup/popup.js +0 -175
  121. package/frontend/dist/assets/index-U7o-qU99.js +0 -5742
  122. package/scaffold/.agent/plugins/.gitkeep +0 -0
  123. package/scaffold/.agent/plugins/code-quality/README.md +0 -16
  124. package/scaffold/.agent/plugins/code-quality/cli.mjs +0 -15
  125. package/scaffold/.agent/plugins/code-quality/plugin.json +0 -34
  126. package/scaffold/.agent/plugins/git-sentinel/README.md +0 -16
  127. package/scaffold/.agent/plugins/git-sentinel/plugin.json +0 -34
  128. package/scaffold/.agent/plugins/meta-harness/README.md +0 -16
  129. package/scaffold/.agent/plugins/meta-harness/cli.mjs +0 -34
  130. package/scaffold/.agent/plugins/meta-harness/plugin.json +0 -34
  131. package/scaffold/.agent/plugins/system-monitor/README.md +0 -16
  132. 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 { discoverPlugins, getPluginWatchPaths, getPlugin, getPluginCategories, validatePluginManifest } from './plugin-loader.mjs';
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
- let resolvedProjectRoot = projectRoot;
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
- const generatorPath = path.isAbsolute(generatorRel)
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
- const mod = await import(generatorPath);
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
- * Each plugin directory contains a `plugin.json` manifest.
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 searchDirs = [
79
- path.join(projectRoot, '.agent', 'plugins'),
80
- path.join(os.homedir(), '.agent', 'plugins')
328
+ const sources = [
329
+ [projectPluginsDir(projectRoot), 'project'],
330
+ [globalPluginsDir(), 'global']
81
331
  ];
82
-
83
- const seenIds = new Set();
84
-
85
- for (const dir of searchDirs) {
86
- if (!fs.existsSync(dir)) continue;
87
- let entries = [];
88
- try {
89
- entries = fs.readdirSync(dir, { withFileTypes: true });
90
- } catch {
91
- continue;
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
- let resolvedProjectRoot = projectRoot;
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