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,384 @@
1
+ /**
2
+ * Plugin store — the one implementation of install / remove / share used by
3
+ * both the CLI and the REST API.
4
+ *
5
+ * Plugin code lives on disk under `<brain>/plugins/<id>/`. What the node knows
6
+ * *about* each plugin — where it came from, its content hash at install, whether
7
+ * it is offered to mesh peers, when its scheduled tasks last ran — is SSSS state:
8
+ * a `plugin_record` document at `system/plugins/<id>.md` in the same brain's
9
+ * vault, written through the Core Contract, with an append-only event for every
10
+ * change.
11
+ */
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import crypto from 'node:crypto';
16
+ import { execFile } from 'node:child_process';
17
+ import { promisify } from 'node:util';
18
+ import {
19
+ discoverPlugins,
20
+ readPluginsDir,
21
+ listBundledPlugins,
22
+ projectPluginsDir,
23
+ globalPluginsDir,
24
+ vaultForPluginsDir,
25
+ validatePluginManifest
26
+ } from './plugin-loader.mjs';
27
+ import {
28
+ hashPluginDir,
29
+ packPlugin,
30
+ decodeBundle,
31
+ writeEntriesAtomic,
32
+ copyPluginAtomic
33
+ } from './plugin-bundle.mjs';
34
+ import { parsePeerSource, fetchPeerBundle } from './plugin-peers.mjs';
35
+ import { parsePublicPluginSource, fetchPublicBundle, publicPluginShareUrl } from './plugin-public.mjs';
36
+ import { writeVfsDocument, patchVfsDocument, deleteVfsDocument, appendVfsEvent } from './ssss-operation-service.mjs';
37
+ import { findVfsDocumentByPath } from './vfs-documents.mjs';
38
+
39
+ const execFileAsync = promisify(execFile);
40
+ const ID_PATTERN = /^[a-z][a-z0-9-]{1,63}$/;
41
+ const EVENT_WORKSPACE = 'default';
42
+
43
+ export function recordPath(id) {
44
+ return `system/plugins/${id}.md`;
45
+ }
46
+
47
+ function vaultFor(plugin) {
48
+ return vaultForPluginsDir(plugin.pluginsDir);
49
+ }
50
+
51
+ export function readPluginRecord(plugin) {
52
+ try {
53
+ return findVfsDocumentByPath(recordPath(plugin.id), vaultFor(plugin));
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ async function emit(vaultRoot, id, kind, payload) {
60
+ await appendVfsEvent(
61
+ `plugins/${id}/${kind}-${Date.now()}-${crypto.randomBytes(3).toString('hex')}`,
62
+ { kind: `plugin.${kind}`, plugin_id: id, at: new Date().toISOString(), ...payload },
63
+ { actorRole: 'system', intent: `Record plugin ${kind}`, workspaceId: EVENT_WORKSPACE, vaultRoot }
64
+ );
65
+ }
66
+
67
+ async function writeRecord({ id, manifest, scope, pluginsDir, source, sha256, shared = false }) {
68
+ const now = new Date().toISOString();
69
+ const frontmatter = {
70
+ type: 'plugin_record',
71
+ title: manifest.name || id,
72
+ description: `Install record for plugin ${id}.`,
73
+ timestamp: now,
74
+ plugin_id: id,
75
+ version: manifest.version,
76
+ scope,
77
+ shared,
78
+ public_shared: false,
79
+ source,
80
+ sha256: sha256 || null,
81
+ installed_at: now
82
+ };
83
+ await writeVfsDocument(recordPath(id), frontmatter, '', {
84
+ actorRole: 'system',
85
+ intent: `Record install of plugin ${id}`,
86
+ vaultRoot: vaultForPluginsDir(pluginsDir)
87
+ });
88
+ return frontmatter;
89
+ }
90
+
91
+ /** Ensure a discovered plugin has a record (a hand-copied plugin has none). */
92
+ export async function ensurePluginRecord(plugin) {
93
+ const existing = readPluginRecord(plugin);
94
+ if (existing) return existing;
95
+ let sha256 = null;
96
+ try {
97
+ sha256 = hashPluginDir(plugin.dir).sha256;
98
+ } catch {}
99
+ return writeRecord({
100
+ id: plugin.id,
101
+ manifest: plugin.manifest,
102
+ scope: plugin.scope,
103
+ pluginsDir: plugin.pluginsDir,
104
+ source: { kind: plugin.linked ? 'link' : 'local', ref: plugin.dir },
105
+ sha256
106
+ });
107
+ }
108
+
109
+ export async function patchPluginRecord(plugin, patches) {
110
+ await ensurePluginRecord(plugin);
111
+ return patchVfsDocument(recordPath(plugin.id), { ...patches, timestamp: new Date().toISOString() }, {
112
+ actorRole: 'system',
113
+ intent: `Update plugin record ${plugin.id}`,
114
+ vaultRoot: vaultFor(plugin)
115
+ });
116
+ }
117
+
118
+ /** Facts about one installed plugin, merged with its record. */
119
+ export function describePlugin(plugin) {
120
+ const record = readPluginRecord(plugin);
121
+ const m = plugin.manifest || {};
122
+ let current = null;
123
+ try {
124
+ current = hashPluginDir(plugin.dir);
125
+ } catch {}
126
+ const recordedSha = record?.sha256 || null;
127
+ const taskRuns = record?.task_runs || {};
128
+ return {
129
+ id: plugin.id,
130
+ name: m.name || plugin.id,
131
+ version: m.version || '0.0.0',
132
+ description: m.description || '',
133
+ author: m.author || null,
134
+ license: m.license || null,
135
+ homepage: m.homepage || null,
136
+ use_cases: Array.isArray(m.use_cases) ? m.use_cases : [],
137
+ valid: plugin.valid,
138
+ errors: plugin.errors,
139
+ scope: plugin.scope,
140
+ linked: !!plugin.linked,
141
+ dir: plugin.dir,
142
+ shared: record?.public_shared === true,
143
+ mesh_shared: record?.shared === true,
144
+ share_url: record?.public_shared === true && current?.sha256 ? publicPluginShareUrl(plugin.id, current.sha256) : null,
145
+ source: record?.source || { kind: plugin.linked ? 'link' : 'local', ref: plugin.dir },
146
+ installed_at: record?.installed_at || null,
147
+ sha256: current?.sha256 || null,
148
+ installed_sha256: recordedSha,
149
+ modified_since_install: !!(recordedSha && current?.sha256 && recordedSha !== current.sha256),
150
+ file_count: current?.file_count ?? null,
151
+ size_bytes: current?.size_bytes ?? null,
152
+ categories: m.ssss_schemas?.categories || [],
153
+ tasks: (m.tasks || []).map((t) => ({ ...t, last_run: t.command ? taskRuns[t.command] || null : null })),
154
+ openwiki_hubs: m.openwiki_hubs || [],
155
+ cli: m.cli ? { command: m.cli.command || null, subcommands: m.cli.subcommands || [] } : null,
156
+ has_generator: !!m.compile?.generator
157
+ };
158
+ }
159
+
160
+ export function listInstalledPlugins(projectRoot = process.cwd()) {
161
+ return discoverPlugins(projectRoot).map(describePlugin);
162
+ }
163
+
164
+ /** Bundled plugins shipped in the package, flagged with whether they are installed here. */
165
+ export function listAvailableBundled(projectRoot = process.cwd()) {
166
+ const installed = new Set(discoverPlugins(projectRoot).map((p) => p.id));
167
+ return listBundledPlugins()
168
+ .filter((p) => p.valid)
169
+ .map((p) => ({
170
+ id: p.id,
171
+ name: p.manifest.name,
172
+ version: p.manifest.version,
173
+ description: p.manifest.description,
174
+ use_cases: p.manifest.use_cases || [],
175
+ categories: p.manifest.ssss_schemas?.categories || [],
176
+ tasks: p.manifest.tasks || [],
177
+ cli: p.manifest.cli ? { command: p.manifest.cli.command, subcommands: p.manifest.cli.subcommands || [] } : null,
178
+ installed: installed.has(p.id)
179
+ }));
180
+ }
181
+
182
+ function readManifestFrom(dir) {
183
+ const manifestPath = path.join(dir, 'plugin.json');
184
+ if (!fs.existsSync(manifestPath)) throw new Error(`${dir} does not contain a plugin.json manifest`);
185
+ let manifest;
186
+ try {
187
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
188
+ } catch (err) {
189
+ throw new Error(`Invalid plugin.json: ${err.message}`);
190
+ }
191
+ const validation = validatePluginManifest(manifest);
192
+ if (!validation.valid) throw new Error(`Invalid plugin manifest: ${validation.errors.join('; ')}`);
193
+ return manifest;
194
+ }
195
+
196
+ function assertNotInstalled(id, pluginsDir) {
197
+ if (fs.existsSync(path.join(pluginsDir, id))) {
198
+ throw new Error(`Plugin '${id}' is already installed at ${path.join(pluginsDir, id)}. Remove it first.`);
199
+ }
200
+ }
201
+
202
+ export function isGitSource(source) {
203
+ return /^(https:\/\/|http:\/\/|ssh:\/\/|git@)/.test(source) || source.endsWith('.git');
204
+ }
205
+
206
+ /**
207
+ * Install a plugin.
208
+ * @param {string} source bundled id | public share URL | `peer:<host>/<id>` | git URL | local path
209
+ * @param {{ projectRoot?: string, global?: boolean, link?: boolean, peerDeps?: object, publicDeps?: object }} [options]
210
+ */
211
+ export async function installPlugin(source, options = {}) {
212
+ const { projectRoot = process.cwd(), global: isGlobal = false, link = false, peerDeps, publicDeps } = options;
213
+ if (!source || typeof source !== 'string') throw new Error('Missing plugin source');
214
+ source = source.trim();
215
+
216
+ const scope = isGlobal ? 'global' : 'project';
217
+ const pluginsDir = isGlobal ? globalPluginsDir() : projectPluginsDir(projectRoot);
218
+
219
+ let manifest;
220
+ let dest;
221
+ let sourceRecord;
222
+ let verifiedSha = null;
223
+
224
+ const peer = parsePeerSource(source);
225
+ const publicSource = parsePublicPluginSource(source);
226
+ const bundled = !peer && !publicSource && ID_PATTERN.test(source) ? listBundledPlugins().find((p) => p.id === source) : null;
227
+
228
+ if (publicSource) {
229
+ assertNotInstalled(publicSource.id, pluginsDir);
230
+ const { bundle, sha256 } = await fetchPublicBundle(source, publicDeps);
231
+ const decoded = decodeBundle(bundle, { expectedSha256: sha256 });
232
+ const manifestEntry = decoded.entries.find((e) => e.path === 'plugin.json');
233
+ if (!manifestEntry) throw new Error('Shared plugin bundle has no plugin.json');
234
+ manifest = JSON.parse(manifestEntry.data.toString('utf8'));
235
+ const validation = validatePluginManifest(manifest);
236
+ if (!validation.valid) throw new Error(`Invalid plugin manifest from share link: ${validation.errors.join('; ')}`);
237
+ if (manifest.id !== publicSource.id) throw new Error(`Share link requested '${publicSource.id}' but bundle contained '${manifest.id}'`);
238
+ dest = writeEntriesAtomic(pluginsDir, manifest.id, decoded.entries);
239
+ verifiedSha = decoded.sha256;
240
+ sourceRecord = { kind: 'public', ref: source };
241
+ } else if (peer) {
242
+ assertNotInstalled(peer.id, pluginsDir);
243
+ const { bundle, advertised, peer: peerNode } = await fetchPeerBundle(peer.hostname, peer.id, peerDeps);
244
+ const decoded = decodeBundle(bundle, { expectedSha256: advertised.sha256 });
245
+ const manifestEntry = decoded.entries.find((e) => e.path === 'plugin.json');
246
+ manifest = JSON.parse(manifestEntry.data.toString('utf8'));
247
+ const validation = validatePluginManifest(manifest);
248
+ if (!validation.valid) throw new Error(`Invalid plugin manifest from peer: ${validation.errors.join('; ')}`);
249
+ if (manifest.id !== peer.id) throw new Error(`Peer sent plugin '${manifest.id}' when '${peer.id}' was requested`);
250
+ dest = writeEntriesAtomic(pluginsDir, manifest.id, decoded.entries);
251
+ verifiedSha = decoded.sha256;
252
+ sourceRecord = { kind: 'peer', ref: `peer:${peerNode.hostname}/${peer.id}`, peer_hostname: peerNode.hostname };
253
+ } else if (bundled) {
254
+ if (!bundled.valid) throw new Error(`Bundled plugin '${source}' is invalid: ${bundled.errors.join('; ')}`);
255
+ manifest = bundled.manifest;
256
+ assertNotInstalled(manifest.id, pluginsDir);
257
+ dest = copyPluginAtomic(bundled.dir, pluginsDir, manifest.id);
258
+ sourceRecord = { kind: 'bundled', ref: manifest.id };
259
+ } else if (isGitSource(source)) {
260
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tr-plugin-'));
261
+ try {
262
+ try {
263
+ await execFileAsync('git', ['clone', '--depth', '1', '--', source, tmp], { timeout: 120_000 });
264
+ } catch (err) {
265
+ throw new Error(`Failed to clone ${source}: ${(err.stderr || err.message || '').toString().trim()}`);
266
+ }
267
+ manifest = readManifestFrom(tmp);
268
+ assertNotInstalled(manifest.id, pluginsDir);
269
+ dest = copyPluginAtomic(tmp, pluginsDir, manifest.id);
270
+ } finally {
271
+ fs.rmSync(tmp, { recursive: true, force: true });
272
+ }
273
+ sourceRecord = { kind: 'git', ref: source };
274
+ } else {
275
+ const abs = path.isAbsolute(source) ? source : path.resolve(projectRoot, source);
276
+ if (!fs.existsSync(abs)) {
277
+ throw new Error(`No bundled plugin, share link, peer source, git URL or directory matches '${source}'`);
278
+ }
279
+ manifest = readManifestFrom(abs);
280
+ assertNotInstalled(manifest.id, pluginsDir);
281
+ if (link) {
282
+ fs.mkdirSync(pluginsDir, { recursive: true });
283
+ dest = path.join(pluginsDir, manifest.id);
284
+ fs.symlinkSync(abs, dest, 'junction');
285
+ sourceRecord = { kind: 'link', ref: abs };
286
+ } else {
287
+ dest = copyPluginAtomic(abs, pluginsDir, manifest.id);
288
+ sourceRecord = { kind: 'local', ref: abs };
289
+ }
290
+ }
291
+
292
+ const sha256 = verifiedSha || hashPluginDir(dest).sha256;
293
+ const vaultRoot = vaultForPluginsDir(pluginsDir);
294
+ try {
295
+ await writeRecord({ id: manifest.id, manifest, scope, pluginsDir, source: sourceRecord, sha256 });
296
+ await emit(vaultRoot, manifest.id, 'installed', { version: manifest.version, scope, source: sourceRecord, sha256 });
297
+ } catch (err) {
298
+ // A plugin on disk without its record would be installed but unaccounted
299
+ // for. Undo the install rather than leave the two out of step.
300
+ const stat = fs.lstatSync(dest);
301
+ if (stat.isSymbolicLink()) fs.unlinkSync(dest);
302
+ else fs.rmSync(dest, { recursive: true, force: true });
303
+ throw new Error(`Plugin files were written but its record could not be: ${err.message}`);
304
+ }
305
+
306
+ return {
307
+ plugin: { id: manifest.id, name: manifest.name, version: manifest.version, dir: dest, scope },
308
+ source: sourceRecord,
309
+ sha256
310
+ };
311
+ }
312
+
313
+ function findInstalled(id, { projectRoot = process.cwd(), global: isGlobal } = {}) {
314
+ const all = isGlobal === true ? [] : discoverPlugins(projectRoot);
315
+ if (isGlobal === true) {
316
+ // discoverPlugins() hides a global plugin shadowed by a project one.
317
+ return readPluginsDir(globalPluginsDir(), 'global').find((p) => p.id === id) || null;
318
+ }
319
+ return all.find((p) => p.id === id) || null;
320
+ }
321
+
322
+ export async function uninstallPlugin(id, options = {}) {
323
+ const plugin = findInstalled(id, options);
324
+ if (!plugin) throw new Error(`Plugin '${id}' is not installed`);
325
+ const stat = fs.lstatSync(plugin.dir);
326
+ if (stat.isSymbolicLink()) fs.unlinkSync(plugin.dir);
327
+ else fs.rmSync(plugin.dir, { recursive: true, force: true });
328
+
329
+ const vaultRoot = vaultFor(plugin);
330
+ if (findVfsDocumentByPath(recordPath(id), vaultRoot)) {
331
+ await deleteVfsDocument(recordPath(id), { actorRole: 'system', intent: `Remove plugin record ${id}`, vaultRoot });
332
+ }
333
+ await emit(vaultRoot, id, 'removed', { scope: plugin.scope, linked: stat.isSymbolicLink() });
334
+ return { id, dir: plugin.dir, scope: plugin.scope };
335
+ }
336
+
337
+ export async function setPluginShared(id, shared, options = {}) {
338
+ const plugin = findInstalled(id, options);
339
+ if (!plugin) throw new Error(`Plugin '${id}' is not installed`);
340
+ if (shared && !plugin.valid) throw new Error(`Plugin '${id}' has an invalid manifest and cannot be shared`);
341
+ await patchPluginRecord(plugin, { shared: !!shared, public_shared: !!shared });
342
+ await emit(vaultFor(plugin), id, shared ? 'shared' : 'unshared', { scope: plugin.scope });
343
+ return describePlugin(plugin);
344
+ }
345
+
346
+ /** What this node offers to mesh peers: valid installed plugins whose record says shared. */
347
+ export function listSharedPlugins(projectRoot = process.cwd()) {
348
+ const out = [];
349
+ for (const plugin of discoverPlugins(projectRoot)) {
350
+ if (!plugin.valid) continue;
351
+ const record = readPluginRecord(plugin);
352
+ if (record?.shared !== true) continue;
353
+ try {
354
+ const h = hashPluginDir(plugin.dir);
355
+ out.push({
356
+ id: plugin.id,
357
+ name: plugin.manifest.name,
358
+ version: plugin.manifest.version,
359
+ description: plugin.manifest.description,
360
+ use_cases: plugin.manifest.use_cases || [],
361
+ sha256: h.sha256,
362
+ file_count: h.file_count,
363
+ size_bytes: h.size_bytes,
364
+ _plugin: plugin
365
+ });
366
+ } catch {
367
+ // Unreadable plugin directory: not offered.
368
+ }
369
+ }
370
+ return out;
371
+ }
372
+
373
+ export function packSharedPlugin(id, projectRoot = process.cwd()) {
374
+ const entry = listSharedPlugins(projectRoot).find((p) => p.id === id);
375
+ if (!entry) return null;
376
+ return packPlugin(entry._plugin.dir, entry._plugin.manifest);
377
+ }
378
+
379
+ /** Public sharing is a separate, explicit opt-in from legacy mesh sharing. */
380
+ export function packPublicPlugin(id, projectRoot = process.cwd()) {
381
+ const plugin = discoverPlugins(projectRoot).find((p) => p.id === id && p.valid);
382
+ if (!plugin || readPluginRecord(plugin)?.public_shared !== true) return null;
383
+ return packPlugin(plugin.dir, plugin.manifest);
384
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Scheduled plugin tasks.
3
+ *
4
+ * A manifest task `{ intent, schedule: "<5-field cron>", command }` runs the
5
+ * plugin's own CLI subcommand on that schedule, out of process. Tasks are per
6
+ * node: a plugin installed on this machine runs here whether or not this node
7
+ * is the mesh leader.
8
+ *
9
+ * The last minute-slot each command ran in is kept in the plugin_record, so a
10
+ * restart neither repeats a slot nor loses one: when the daemon was busy or
11
+ * down, the most recent matching slot since the last run (within a day) runs
12
+ * once. A task never runs just because it was installed — the first run is the
13
+ * first matching slot after the scheduler first sees it.
14
+ */
15
+ import { discoverPlugins } from './plugin-loader.mjs';
16
+ import { readPluginRecord, patchPluginRecord, ensurePluginRecord } from './plugin-store.mjs';
17
+ import { runPluginCommand } from './plugin-runner.mjs';
18
+ import { appendVfsEvent } from './ssss-operation-service.mjs';
19
+ import { vaultForPluginsDir } from './plugin-loader.mjs';
20
+ import { logger } from './logger.mjs';
21
+
22
+ const TASK_TIMEOUT_MS = 60_000;
23
+ const MAX_CATCHUP_MINUTES = 24 * 60;
24
+ const FIELD_RANGES = [
25
+ [0, 59], // minute
26
+ [0, 23], // hour
27
+ [1, 31], // day of month
28
+ [1, 12], // month
29
+ [0, 6] // day of week (0 = Sunday; 7 accepted as Sunday)
30
+ ];
31
+
32
+ function parseField(field, [min, max], isDow) {
33
+ const values = new Set();
34
+ for (const part of field.split(',')) {
35
+ const [rangePart, stepPart] = part.split('/');
36
+ const step = stepPart === undefined ? 1 : Number(stepPart);
37
+ if (!Number.isInteger(step) || step < 1) throw new Error(`Invalid step in '${field}'`);
38
+ let lo;
39
+ let hi;
40
+ if (rangePart === '*') {
41
+ lo = min;
42
+ hi = max;
43
+ } else if (rangePart.includes('-')) {
44
+ [lo, hi] = rangePart.split('-').map(Number);
45
+ } else {
46
+ lo = Number(rangePart);
47
+ hi = stepPart === undefined ? lo : max;
48
+ }
49
+ const upper = isDow ? 7 : max;
50
+ if (!Number.isInteger(lo) || !Number.isInteger(hi) || lo < min || hi > upper || lo > hi) {
51
+ throw new Error(`Value out of range in '${field}'`);
52
+ }
53
+ for (let v = lo; v <= hi; v += step) values.add(isDow && v === 7 ? 0 : v);
54
+ }
55
+ return values;
56
+ }
57
+
58
+ /** Parse a 5-field cron expression into matcher sets. Throws on invalid input. */
59
+ export function parseCron(expr) {
60
+ const fields = String(expr || '').trim().split(/\s+/);
61
+ if (fields.length !== 5) throw new Error(`Expected 5 cron fields, got ${fields.length}`);
62
+ const [minute, hour, dom, month, dow] = fields.map((f, i) => parseField(f, FIELD_RANGES[i], i === 4));
63
+ return { minute, hour, dom, month, dow, domStar: fields[2] === '*', dowStar: fields[4] === '*' };
64
+ }
65
+
66
+ /** Standard cron semantics: when both day fields are restricted, either may match. */
67
+ export function cronMatches(cron, date) {
68
+ if (!cron.minute.has(date.getMinutes()) || !cron.hour.has(date.getHours()) || !cron.month.has(date.getMonth() + 1)) {
69
+ return false;
70
+ }
71
+ const domOk = cron.dom.has(date.getDate());
72
+ const dowOk = cron.dow.has(date.getDay());
73
+ if (cron.domStar && cron.dowStar) return true;
74
+ if (cron.domStar) return dowOk;
75
+ if (cron.dowStar) return domOk;
76
+ return domOk || dowOk;
77
+ }
78
+
79
+ const pad = (n) => String(n).padStart(2, '0');
80
+
81
+ /** Local minute-slot key, e.g. 2026-09-22T14:05. */
82
+ export function minuteSlot(date) {
83
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
84
+ }
85
+
86
+ function slotToDate(slot) {
87
+ const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(String(slot || ''));
88
+ return m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5])) : null;
89
+ }
90
+
91
+ /**
92
+ * The most recent slot matching `cron` in (lastSlot, now], or null.
93
+ * @returns {string|null}
94
+ */
95
+ export function latestDueSlot(cron, lastSlot, now = new Date()) {
96
+ const floorNow = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes());
97
+ const last = slotToDate(lastSlot);
98
+ for (let i = 0; i < MAX_CATCHUP_MINUTES; i++) {
99
+ const candidate = new Date(floorNow.getTime() - i * 60_000);
100
+ if (last && candidate <= last) return null;
101
+ if (cronMatches(cron, candidate)) return minuteSlot(candidate);
102
+ }
103
+ return null;
104
+ }
105
+
106
+ /**
107
+ * Run every task that is due. Tasks run sequentially so a slow plugin cannot
108
+ * multiply into concurrent processes on a constrained host.
109
+ * @param {{ projectRoot?: string, now?: Date, runner?: Function }} [options]
110
+ * @returns {Promise<Array<{ plugin: string, command: string, slot: string, ok: boolean, exitCode: number|null }>>}
111
+ */
112
+ export async function runDuePluginTasks(options = {}) {
113
+ const { projectRoot = process.cwd(), now = new Date(), runner = runPluginCommand } = options;
114
+ const results = [];
115
+
116
+ for (const plugin of discoverPlugins(projectRoot)) {
117
+ if (!plugin.valid || !Array.isArray(plugin.manifest.tasks) || plugin.manifest.tasks.length === 0) continue;
118
+
119
+ let record = readPluginRecord(plugin);
120
+ if (!record) record = await ensurePluginRecord(plugin);
121
+ const runs = { ...(record?.task_runs || {}) };
122
+ let changed = false;
123
+
124
+ for (const task of plugin.manifest.tasks) {
125
+ let cron;
126
+ try {
127
+ cron = parseCron(task.schedule);
128
+ } catch (err) {
129
+ logger.warn('plugin-tasks', `Skipping ${plugin.id}:${task.command} — ${err.message}`);
130
+ continue;
131
+ }
132
+
133
+ if (!runs[task.command]) {
134
+ // First sighting: mark the present so the first run is the next real slot.
135
+ runs[task.command] = minuteSlot(now);
136
+ changed = true;
137
+ continue;
138
+ }
139
+
140
+ const slot = latestDueSlot(cron, runs[task.command], now);
141
+ if (!slot) continue;
142
+
143
+ const result = await runner(plugin, { subcommand: task.command, cwd: projectRoot, timeoutMs: TASK_TIMEOUT_MS })
144
+ .catch((err) => ({ ok: false, exitCode: null, output: err.message, durationMs: 0, timedOut: false }));
145
+ runs[task.command] = slot;
146
+ changed = true;
147
+ results.push({ plugin: plugin.id, command: task.command, slot, ok: result.ok, exitCode: result.exitCode });
148
+
149
+ try {
150
+ await appendVfsEvent(
151
+ `plugins/${plugin.id}/task-${Date.now()}`,
152
+ {
153
+ kind: 'plugin.task_run',
154
+ plugin_id: plugin.id,
155
+ command: task.command,
156
+ intent: task.intent,
157
+ slot,
158
+ ok: result.ok,
159
+ exit_code: result.exitCode,
160
+ timed_out: !!result.timedOut,
161
+ duration_ms: result.durationMs,
162
+ output_tail: String(result.output || '').slice(-2000)
163
+ },
164
+ { actorRole: 'system', intent: 'Record plugin task run', vaultRoot: vaultForPluginsDir(plugin.pluginsDir) }
165
+ );
166
+ } catch (err) {
167
+ logger.warn('plugin-tasks', `Could not record task run for ${plugin.id}: ${err.message}`);
168
+ }
169
+ }
170
+
171
+ if (changed) {
172
+ try {
173
+ await patchPluginRecord(plugin, { task_runs: runs });
174
+ } catch (err) {
175
+ logger.warn('plugin-tasks', `Could not update task runs for ${plugin.id}: ${err.message}`);
176
+ }
177
+ }
178
+ }
179
+
180
+ return results;
181
+ }
182
+
183
+ let timer = null;
184
+ let running = false;
185
+
186
+ /** Check for due plugin tasks once a minute. Returns a stop function. */
187
+ export function startPluginTaskScheduler(options = {}) {
188
+ if (timer) return () => stopPluginTaskScheduler();
189
+ const tick = async () => {
190
+ if (running) return;
191
+ running = true;
192
+ try {
193
+ const results = await runDuePluginTasks(options);
194
+ for (const r of results) {
195
+ logger.info('plugin-tasks', `${r.plugin}:${r.command} slot ${r.slot} ${r.ok ? 'ok' : `failed (exit ${r.exitCode})`}`);
196
+ }
197
+ } catch (err) {
198
+ logger.warn('plugin-tasks', `Scheduler tick failed: ${err.message}`);
199
+ } finally {
200
+ running = false;
201
+ }
202
+ };
203
+ timer = setInterval(tick, 60_000);
204
+ timer.unref?.();
205
+ tick();
206
+ return () => stopPluginTaskScheduler();
207
+ }
208
+
209
+ export function stopPluginTaskScheduler() {
210
+ if (timer) clearInterval(timer);
211
+ timer = null;
212
+ }
@@ -2,6 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import matter from 'gray-matter';
4
4
  import crypto from 'crypto';
5
+ import os from 'os';
5
6
  import { callLocalRuntime, loadRuntimeConfig } from './runtime.mjs';
6
7
  import { atomicWrite, safeStringify } from './vault.mjs';
7
8
  import { updateNodeInPlace } from './validated-write.mjs';
@@ -98,6 +99,28 @@ export function readSessionTranscript(sessionPath) {
98
99
  return transcript.slice(0, MAX_TRANSCRIPT_CHARS);
99
100
  }
100
101
 
102
+ /**
103
+ * Which project a session was working on: the basename of the first recorded
104
+ * working directory (Claude Code / Codex store it per entry). Null when the tool
105
+ * doesn't record one — such sessions trigger no autonomous research.
106
+ */
107
+ export function deriveSessionProject(sessionPath) {
108
+ if (!fs.existsSync(sessionPath)) return null;
109
+ const lines = fs.readFileSync(sessionPath, 'utf8').split('\n');
110
+ for (const line of lines) {
111
+ if (!line.includes('"cwd"')) continue;
112
+ try {
113
+ const { cwd } = JSON.parse(line);
114
+ if (typeof cwd === 'string' && cwd.trim()) {
115
+ const name = path.basename(cwd.replace(/[\\/]+$/, ''));
116
+ // A home directory or filesystem root is not a project.
117
+ if (name && cwd !== os.homedir() && name !== path.basename(os.homedir())) return name;
118
+ }
119
+ } catch { /* skip malformed */ }
120
+ }
121
+ return null;
122
+ }
123
+
101
124
  // ─── Post-Mortem Execution ──────────────────────────────────────────────────────
102
125
 
103
126
  /**
@@ -199,19 +222,27 @@ export async function runPostMortem(sessionPath, { vaultDir, inboxDir, runtimeCo
199
222
  message: `Session ${sessionSlug}: ${patterns.length} patterns, ${facts.length} facts, ${skillGaps.length} gaps, ${violations.length} violations`,
200
223
  });
201
224
 
202
- // Feed session transcript to the research agenda for topic inference
225
+ // System 2: while working on a project, find knowledge gaps that would make
226
+ // the AI better at that project and research them in the background, within
227
+ // the autonomous budget. Sessions with no project attribution queue nothing.
203
228
  try {
204
- const { ingestSessionTopics } = await import('./fact-seeker.mjs');
205
- const addedTopics = await ingestSessionTopics(transcript, runtimeConfig);
206
- if (addedTopics.length > 0) {
207
- logger.info({
208
- subsystem: 'post-mortem',
209
- message: `Research agenda: +${addedTopics.length} topics from session "${sessionSlug}"`,
229
+ const project = deriveSessionProject(sessionPath);
230
+ if (project) {
231
+ const { ingestSessionTopics } = await import('./fact-seeker.mjs');
232
+ const queued = await ingestSessionTopics(transcript, runtimeConfig, {
233
+ project,
234
+ sessionId: path.basename(sessionPath, path.extname(sessionPath)),
210
235
  });
236
+ if (queued.length > 0) {
237
+ logger.info({
238
+ subsystem: 'post-mortem',
239
+ message: `Background research for ${project}: +${queued.length} from session "${sessionSlug}"`,
240
+ });
241
+ }
211
242
  }
212
243
  } catch (err) {
213
- // Non-fatal — topic inference failure shouldn't block post-mortem results
214
- logger.info({ subsystem: 'post-mortem', message: `Topic inference skipped: ${err.message}` });
244
+ // Non-fatal — gap inference failure shouldn't block post-mortem results
245
+ logger.info({ subsystem: 'post-mortem', message: `Research gap inference skipped: ${err.message}` });
215
246
  }
216
247
 
217
248
  return { patterns, facts, skill_gaps: skillGaps, violations };