total-recall-brain 3.28.2 → 3.30.1

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 (134) 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/SKILL.md +22 -6
  43. package/scaffold/.agent/skills/total-recall/scripts/brain-state.json +1 -0
  44. package/src/cli/agent.mjs +1 -1
  45. package/src/cli/app/index.mjs +493 -0
  46. package/src/cli/brain.mjs +33 -3
  47. package/src/cli/command.mjs +80 -41
  48. package/src/cli/connect.mjs +10 -1
  49. package/src/cli/harness.mjs +1 -1
  50. package/src/cli/init.mjs +3 -1
  51. package/src/cli/mesh.mjs +118 -12
  52. package/src/cli/plugin/create.mjs +144 -25
  53. package/src/cli/plugin/index.mjs +83 -53
  54. package/src/cli/plugin/install.mjs +10 -127
  55. package/src/cli/plugin/list.mjs +44 -44
  56. package/src/cli/plugin/remove.mjs +23 -30
  57. package/src/cli/plugin/scaffold-cmd.mjs +30 -0
  58. package/src/cli/plugin/search.mjs +108 -23
  59. package/src/cli/rebuild.mjs +37 -1
  60. package/src/cli/recall.mjs +17 -2
  61. package/src/cli/research.mjs +4 -6
  62. package/src/cli/share.mjs +2 -0
  63. package/src/core/app-deploy/apply.mjs +386 -0
  64. package/src/core/app-deploy/design-tokens.mjs +217 -0
  65. package/src/core/app-deploy/plan.mjs +249 -0
  66. package/src/core/app-deploy/resolve.mjs +295 -0
  67. package/src/core/app-deploy/source.mjs +337 -0
  68. package/src/core/app-deploy/standalone.mjs +248 -0
  69. package/src/core/app-deploy/upgrade.mjs +256 -0
  70. package/src/core/app-deploy/verify.mjs +213 -0
  71. package/src/core/bound-hosts.mjs +1 -1
  72. package/src/core/brain-registry.mjs +146 -0
  73. package/src/core/brain-state.json +1 -0
  74. package/src/core/daemon-loop.mjs +30 -26
  75. package/src/core/dream.mjs +5 -17
  76. package/src/core/fact-seeker.mjs +53 -368
  77. package/src/core/lan-discovery.mjs +3 -3
  78. package/src/core/mesh-access-sync.mjs +134 -0
  79. package/src/core/mesh-access.mjs +18 -3
  80. package/src/core/mesh-discover.mjs +173 -0
  81. package/src/core/mesh.mjs +131 -32
  82. package/src/core/optimizer.mjs +4 -41
  83. package/src/core/plugin-bundle.mjs +167 -0
  84. package/src/core/plugin-context.mjs +8 -13
  85. package/src/core/plugin-loader.mjs +272 -68
  86. package/src/core/plugin-peers.mjs +163 -0
  87. package/src/core/plugin-public.mjs +85 -0
  88. package/src/core/plugin-runner-child.mjs +24 -0
  89. package/src/core/plugin-runner.mjs +102 -0
  90. package/src/core/plugin-store.mjs +384 -0
  91. package/src/core/plugin-tasks.mjs +212 -0
  92. package/src/core/post-mortem.mjs +40 -9
  93. package/src/core/research-gate.mjs +182 -0
  94. package/src/core/research-queue.mjs +47 -7
  95. package/src/core/research-surface.mjs +211 -0
  96. package/src/core/research.mjs +25 -32
  97. package/src/core/scheduler.mjs +67 -153
  98. package/src/core/schema.mjs +129 -0
  99. package/src/core/search.mjs +12 -1
  100. package/src/core/secret-integration-research.mjs +12 -8
  101. package/src/core/secrets-store.mjs +76 -2
  102. package/src/core/session-watcher.mjs +6 -0
  103. package/src/core/surface.mjs +64 -3
  104. package/src/core/task-executors.mjs +8 -45
  105. package/src/core/vault-cache.mjs +70 -4
  106. package/src/server/api.mjs +21 -0
  107. package/src/server/rest.mjs +2 -0
  108. package/src/server/route-manifest.json +21 -5
  109. package/src/server/routes/collab.mjs +38 -11
  110. package/src/server/routes/extension.mjs +3 -2
  111. package/src/server/routes/memory.mjs +24 -2
  112. package/src/server/routes/mesh.mjs +7 -7
  113. package/src/server/routes/plugins-mesh.mjs +49 -0
  114. package/src/server/routes/plugins.mjs +91 -426
  115. package/src/server/routes/research.mjs +5 -2
  116. package/src/server/routes/share.mjs +2 -0
  117. package/src/server/tools.mjs +29 -2
  118. package/extension/icons/README.md +0 -27
  119. package/extension/lib/preconfigured.js +0 -5
  120. package/extension/popup/popup.css +0 -227
  121. package/extension/popup/popup.html +0 -50
  122. package/extension/popup/popup.js +0 -175
  123. package/frontend/dist/assets/index-U7o-qU99.js +0 -5742
  124. package/scaffold/.agent/plugins/.gitkeep +0 -0
  125. package/scaffold/.agent/plugins/code-quality/README.md +0 -16
  126. package/scaffold/.agent/plugins/code-quality/cli.mjs +0 -15
  127. package/scaffold/.agent/plugins/code-quality/plugin.json +0 -34
  128. package/scaffold/.agent/plugins/git-sentinel/README.md +0 -16
  129. package/scaffold/.agent/plugins/git-sentinel/plugin.json +0 -34
  130. package/scaffold/.agent/plugins/meta-harness/README.md +0 -16
  131. package/scaffold/.agent/plugins/meta-harness/cli.mjs +0 -34
  132. package/scaffold/.agent/plugins/meta-harness/plugin.json +0 -34
  133. package/scaffold/.agent/plugins/system-monitor/README.md +0 -16
  134. package/scaffold/.agent/plugins/system-monitor/plugin.json +0 -38
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Total Recall — Capability Deployment Upgrader
3
+ *
4
+ * Implements Phase 3 of CAPABILITY_DEPLOYMENT_PLUGINS:
5
+ * - Structural-only migration between capability versions
6
+ * - Preservation of tenant-private data and user customizations
7
+ * - Rollback on interrupted or failing upgrade
8
+ * - Truthful status and append-only event logging
9
+ */
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import os from 'node:os';
14
+ import crypto from 'node:crypto';
15
+ import { createDeploymentPlan } from './plan.mjs';
16
+ import { applyDeploymentPlan, resolveAppVaultDir, formatSsssDocument } from './apply.mjs';
17
+ import { resolveCapabilitySource } from './source.mjs';
18
+
19
+ export class AppUpgradeError extends Error {
20
+ constructor(message, details = {}) {
21
+ super(message);
22
+ this.name = 'AppUpgradeError';
23
+ this.details = details;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Reads the installed capability record from the target app vault.
29
+ * @param {string} targetDir
30
+ * @param {string} capabilityId
31
+ * @returns {object|null}
32
+ */
33
+ export function getInstalledCapabilityRecord(targetDir, capabilityId) {
34
+ const vaultDir = resolveAppVaultDir(targetDir);
35
+ const recordPath = path.join(vaultDir, 'system', 'capabilities', `${capabilityId}.md`);
36
+ if (!fs.existsSync(recordPath)) return null;
37
+
38
+ const content = fs.readFileSync(recordPath, 'utf8');
39
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
40
+ if (!frontmatterMatch) return null;
41
+
42
+ const raw = frontmatterMatch[1];
43
+ const record = {};
44
+ for (const line of raw.split('\n')) {
45
+ const colonIdx = line.indexOf(':');
46
+ if (colonIdx === -1) continue;
47
+ const key = line.slice(0, colonIdx).trim();
48
+ let val = line.slice(colonIdx + 1).trim();
49
+ if (val.startsWith('"') && val.endsWith('"')) {
50
+ val = val.slice(1, -1);
51
+ } else if (val.startsWith('[') || val.startsWith('{')) {
52
+ try {
53
+ val = JSON.parse(val);
54
+ } catch {}
55
+ }
56
+ record[key] = val;
57
+ }
58
+ return record;
59
+ }
60
+
61
+ /**
62
+ * Upgrades an installed capability in an application to a new version or pinned hash.
63
+ *
64
+ * @param {string} capabilityId - ID of capability to upgrade
65
+ * @param {object} options
66
+ * @param {string} options.to - New source path, git ref, or pinned package
67
+ * @param {string} [options.target] - Target app directory (default: cwd)
68
+ * @param {string} [options.adapter] - Adapter override
69
+ * @param {boolean} [options.dryRun] - Preflight only
70
+ * @param {string} [options.actor] - SSSS actor
71
+ * @returns {Promise<{
72
+ * success: boolean,
73
+ * capability_id: string,
74
+ * from_version: string,
75
+ * to_version: string,
76
+ * plan_hash: string,
77
+ * removed_files: string[],
78
+ * applied_files: number,
79
+ * dryRun?: boolean
80
+ * }>}
81
+ */
82
+ export async function upgradeCapability(capabilityId, options = {}) {
83
+ const targetDir = options.target || process.cwd();
84
+ const actor = options.actor || 'total-recall/app-deploy';
85
+
86
+ if (!options.to) {
87
+ throw new AppUpgradeError('Missing target source (--to) for upgrade');
88
+ }
89
+
90
+ // 1. Verify existing capability installation
91
+ const existingRecord = getInstalledCapabilityRecord(targetDir, capabilityId);
92
+ if (!existingRecord) {
93
+ throw new AppUpgradeError(
94
+ `Capability '${capabilityId}' is not installed in target application (${targetDir})`
95
+ );
96
+ }
97
+
98
+ const fromVersion = existingRecord.version || 'unknown';
99
+ const oldFiles = Array.isArray(existingRecord.files) ? existingRecord.files : [];
100
+
101
+ // 2. Staged upgrade with rollback protection
102
+ const backupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tr-upgrade-backup-'));
103
+ const obsoleteBackups = [];
104
+ const vaultDir = resolveAppVaultDir(targetDir);
105
+ const capInstallDir = path.join(vaultDir, 'system', 'capabilities');
106
+ const eventDir = path.join(vaultDir, 'system', 'events');
107
+ let newPlan = null;
108
+
109
+ try {
110
+ newPlan = await createDeploymentPlan(options.to, {
111
+ target: targetDir,
112
+ adapter: options.adapter || existingRecord.adapter || 'ssss-app',
113
+ pluginResolver: options.pluginResolver
114
+ });
115
+
116
+ if (!newPlan.valid) {
117
+ throw new AppUpgradeError(
118
+ `Cannot upgrade capability: new version produces ${newPlan.conflicts?.length || 0} conflict(s)`
119
+ );
120
+ }
121
+
122
+ // Identify files from old version that no longer exist in new version
123
+ const newFilePaths = new Set((newPlan.file_operations || []).map((op) => op.path));
124
+ const obsoleteFiles = oldFiles.filter((f) => !newFilePaths.has(f.path));
125
+
126
+ // If dry-run, return planned diff
127
+ if (options.dryRun) {
128
+ fs.rmSync(backupDir, { recursive: true, force: true });
129
+ newPlan.cleanup?.();
130
+ return {
131
+ success: true,
132
+ dryRun: true,
133
+ capability_id: capabilityId,
134
+ from_version: fromVersion,
135
+ to_version: newPlan.capabilities[0]?.version || 'unknown',
136
+ plan_hash: newPlan.plan_hash,
137
+ removed_files: obsoleteFiles.map((f) => f.path),
138
+ applied_files: newPlan.file_operations?.length || 0
139
+ };
140
+ }
141
+
142
+ // Backup obsolete files before removing them
143
+ for (const obs of obsoleteFiles) {
144
+ const targetFilePath = path.join(targetDir, obs.path);
145
+ if (fs.existsSync(targetFilePath)) {
146
+ const backupPath = path.join(backupDir, obs.path);
147
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
148
+ fs.copyFileSync(targetFilePath, backupPath);
149
+ obsoleteBackups.push({ targetPath: targetFilePath, backupPath });
150
+ // Remove obsolete capability file
151
+ fs.rmSync(targetFilePath, { force: true });
152
+ }
153
+ }
154
+
155
+ // Apply new version via applyDeploymentPlan
156
+ const applyResult = await applyDeploymentPlan(newPlan, {
157
+ target: targetDir,
158
+ actor,
159
+ force: true
160
+ });
161
+
162
+ // Record upgrade event envelope
163
+ const toVersion = newPlan.capabilities[0]?.version || 'unknown';
164
+ const upgradeEvent = {
165
+ type: 'event',
166
+ domain: 'ssss.capability',
167
+ action: 'upgraded',
168
+ timestamp: new Date().toISOString(),
169
+ actor,
170
+ target: targetDir,
171
+ payload: {
172
+ capability_id: capabilityId,
173
+ from_version: fromVersion,
174
+ to_version: toVersion,
175
+ plan_hash: newPlan.plan_hash
176
+ }
177
+ };
178
+
179
+ fs.mkdirSync(eventDir, { recursive: true });
180
+ fs.appendFileSync(
181
+ path.join(eventDir, 'capability-events.jsonl'),
182
+ JSON.stringify(upgradeEvent) + '\n',
183
+ 'utf8'
184
+ );
185
+
186
+ // Clean up backup directory
187
+ fs.rmSync(backupDir, { recursive: true, force: true });
188
+ newPlan.cleanup?.();
189
+
190
+ return {
191
+ success: true,
192
+ capability_id: capabilityId,
193
+ from_version: fromVersion,
194
+ to_version: toVersion,
195
+ plan_hash: newPlan.plan_hash,
196
+ removed_files: obsoleteFiles.map((f) => f.path),
197
+ applied_files: applyResult.applied_files
198
+ };
199
+ } catch (err) {
200
+ // Rollback obsolete files that were removed
201
+ for (const b of obsoleteBackups) {
202
+ try {
203
+ if (fs.existsSync(b.backupPath)) {
204
+ fs.mkdirSync(path.dirname(b.targetPath), { recursive: true });
205
+ fs.copyFileSync(b.backupPath, b.targetPath);
206
+ }
207
+ } catch {}
208
+ }
209
+
210
+ // Record truthful failure status
211
+ try {
212
+ if (fs.existsSync(capInstallDir)) {
213
+ const failedRecord = {
214
+ ...existingRecord,
215
+ status: 'upgrade_failed',
216
+ updated_at: new Date().toISOString(),
217
+ error: err.message
218
+ };
219
+ const failedMd = formatSsssDocument(
220
+ failedRecord,
221
+ `# Upgrade Failed\n\nAttempt to upgrade to version from '${options.to}' failed: ${err.message}\n`
222
+ );
223
+ fs.writeFileSync(path.join(capInstallDir, `${capabilityId}.md`), failedMd, 'utf8');
224
+ }
225
+
226
+ const failEvent = {
227
+ type: 'event',
228
+ domain: 'ssss.capability',
229
+ action: 'upgrade_failed',
230
+ timestamp: new Date().toISOString(),
231
+ actor,
232
+ target: targetDir,
233
+ payload: {
234
+ capability_id: capabilityId,
235
+ from_version: fromVersion,
236
+ error: err.message
237
+ }
238
+ };
239
+ fs.appendFileSync(
240
+ path.join(eventDir, 'capability-events.jsonl'),
241
+ JSON.stringify(failEvent) + '\n',
242
+ 'utf8'
243
+ );
244
+ } catch {}
245
+
246
+ try {
247
+ if (fs.existsSync(backupDir)) fs.rmSync(backupDir, { recursive: true, force: true });
248
+ newPlan.cleanup?.();
249
+ } catch {}
250
+
251
+ throw new AppUpgradeError(`Capability upgrade failed: ${err.message}`, {
252
+ capability_id: capabilityId,
253
+ originalError: err
254
+ });
255
+ }
256
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Total Recall — Capability Deployment Verifier
3
+ *
4
+ * Implements Phase 3 of CAPABILITY_DEPLOYMENT_PLUGINS:
5
+ * - Verification of installed capability source file hashes and drift detection
6
+ * - Conformance of SSSS installation records, access grants, and event logs
7
+ * - Verification of application shell gates and entrypoints
8
+ */
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import crypto from 'node:crypto';
13
+ import { resolveAppVaultDir } from './apply.mjs';
14
+ import {
15
+ AppCapabilityInstallationSchema,
16
+ AccessGrantSchema
17
+ } from '../schema.mjs';
18
+
19
+ /**
20
+ * Verifies an application's capability deployments, integrity, and conformance.
21
+ *
22
+ * @param {string} targetDir - Application root directory
23
+ * @param {object} [options]
24
+ * @param {boolean} [options.checkFiles=true] - Verify sha256 hashes of all deployed files
25
+ * @returns {Promise<{
26
+ * valid: boolean,
27
+ * target: string,
28
+ * vault: string,
29
+ * capabilities: Array<{
30
+ * id: string,
31
+ * version: string,
32
+ * status: string,
33
+ * files_checked: number,
34
+ * files_intact: number,
35
+ * drift: Array<{ path: string, expected_sha: string, actual_sha?: string, missing?: boolean }>
36
+ * }>,
37
+ * grants: Array<{ id: string, valid: boolean, scopes: string[] }>,
38
+ * events_count: number,
39
+ * errors: string[]
40
+ * }>}
41
+ */
42
+ export async function verifyApplication(targetDir = process.cwd(), options = {}) {
43
+ const resolvedTarget = path.resolve(targetDir);
44
+ const checkFiles = options.checkFiles !== false;
45
+ const errors = [];
46
+
47
+ if (!fs.existsSync(resolvedTarget)) {
48
+ throw new Error(`Target application directory does not exist: ${resolvedTarget}`);
49
+ }
50
+
51
+ const vaultDir = resolveAppVaultDir(resolvedTarget);
52
+ const capInstallDir = path.join(vaultDir, 'system', 'capabilities');
53
+ const grantDir = path.join(vaultDir, 'system', 'grants');
54
+ const eventDir = path.join(vaultDir, 'system', 'events');
55
+
56
+ const capabilitiesReport = [];
57
+
58
+ // 1. Verify Installed Capabilities
59
+ if (fs.existsSync(capInstallDir)) {
60
+ const files = fs.readdirSync(capInstallDir).filter((f) => f.endsWith('.md'));
61
+ for (const f of files) {
62
+ const capPath = path.join(capInstallDir, f);
63
+ const content = fs.readFileSync(capPath, 'utf8');
64
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
65
+ if (!match) {
66
+ errors.push(`Malformed capability record '${f}': missing YAML frontmatter`);
67
+ continue;
68
+ }
69
+
70
+ const raw = match[1];
71
+ const record = {};
72
+ for (const line of raw.split('\n')) {
73
+ const colonIdx = line.indexOf(':');
74
+ if (colonIdx === -1) continue;
75
+ const key = line.slice(0, colonIdx).trim();
76
+ let val = line.slice(colonIdx + 1).trim();
77
+ if (val.startsWith('"') && val.endsWith('"')) {
78
+ val = val.slice(1, -1);
79
+ } else if (val.startsWith('[') || val.startsWith('{')) {
80
+ try {
81
+ val = JSON.parse(val);
82
+ } catch {}
83
+ }
84
+ record[key] = val;
85
+ }
86
+
87
+ const validation = AppCapabilityInstallationSchema.safeParse(record);
88
+ if (!validation.success) {
89
+ errors.push(
90
+ `Invalid capability installation schema in '${f}': ${validation.error.issues.map((i) => i.message).join('; ')}`
91
+ );
92
+ }
93
+
94
+ const capId = record.capability_id || path.basename(f, '.md');
95
+ const version = record.version || 'unknown';
96
+ const status = record.status || 'unknown';
97
+ const fileList = Array.isArray(record.files) ? record.files : [];
98
+ const drift = [];
99
+ let intactCount = 0;
100
+
101
+ if (checkFiles) {
102
+ for (const fileDesc of fileList) {
103
+ const targetFilePath = path.join(resolvedTarget, fileDesc.path);
104
+ if (!fs.existsSync(targetFilePath)) {
105
+ drift.push({
106
+ path: fileDesc.path,
107
+ expected_sha: fileDesc.sha256,
108
+ missing: true
109
+ });
110
+ } else {
111
+ const actualBytes = fs.readFileSync(targetFilePath);
112
+ const actualSha = crypto.createHash('sha256').update(actualBytes).digest('hex');
113
+ if (actualSha !== fileDesc.sha256) {
114
+ drift.push({
115
+ path: fileDesc.path,
116
+ expected_sha: fileDesc.sha256,
117
+ actual_sha: actualSha
118
+ });
119
+ } else {
120
+ intactCount++;
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ if (status !== 'installed') {
127
+ errors.push(`Capability '${capId}' has non-ready status: '${status}'`);
128
+ }
129
+ if (drift.length > 0) {
130
+ errors.push(
131
+ `Capability '${capId}' has ${drift.length} drifted/missing file(s): ${drift.map((d) => d.path).join(', ')}`
132
+ );
133
+ }
134
+
135
+ capabilitiesReport.push({
136
+ id: capId,
137
+ version,
138
+ status,
139
+ files_checked: fileList.length,
140
+ files_intact: intactCount,
141
+ drift
142
+ });
143
+ }
144
+ }
145
+
146
+ // 2. Verify Access Grants
147
+ const grantsReport = [];
148
+ if (fs.existsSync(grantDir)) {
149
+ const files = fs.readdirSync(grantDir).filter((f) => f.endsWith('.md'));
150
+ for (const f of files) {
151
+ const grantPath = path.join(grantDir, f);
152
+ const content = fs.readFileSync(grantPath, 'utf8');
153
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
154
+ if (!match) continue;
155
+
156
+ const raw = match[1];
157
+ const record = {};
158
+ for (const line of raw.split('\n')) {
159
+ const colonIdx = line.indexOf(':');
160
+ if (colonIdx === -1) continue;
161
+ const key = line.slice(0, colonIdx).trim();
162
+ let val = line.slice(colonIdx + 1).trim();
163
+ if (val.startsWith('"') && val.endsWith('"')) {
164
+ val = val.slice(1, -1);
165
+ } else if (val.startsWith('[') || val.startsWith('{')) {
166
+ try {
167
+ val = JSON.parse(val);
168
+ } catch {}
169
+ }
170
+ record[key] = val;
171
+ }
172
+
173
+ const validation = AccessGrantSchema.safeParse(record);
174
+ if (!validation.success) {
175
+ errors.push(`Invalid access grant '${f}': ${validation.error.issues.map((i) => i.message).join('; ')}`);
176
+ }
177
+
178
+ grantsReport.push({
179
+ id: record.grantee || path.basename(f, '.md'),
180
+ valid: validation.success,
181
+ scopes: record.scopes || []
182
+ });
183
+ }
184
+ }
185
+
186
+ // 3. Verify SSSS Event Ledger
187
+ let eventsCount = 0;
188
+ const eventLogFile = path.join(eventDir, 'capability-events.jsonl');
189
+ if (fs.existsSync(eventLogFile)) {
190
+ const lines = fs.readFileSync(eventLogFile, 'utf8').trim().split('\n').filter(Boolean);
191
+ eventsCount = lines.length;
192
+ for (let i = 0; i < lines.length; i++) {
193
+ try {
194
+ const envelope = JSON.parse(lines[i]);
195
+ if (!envelope.type || !envelope.domain || !envelope.action) {
196
+ errors.push(`Invalid SSSS event envelope at line ${i + 1} of capability-events.jsonl`);
197
+ }
198
+ } catch (parseErr) {
199
+ errors.push(`Corrupt JSON line in capability-events.jsonl at line ${i + 1}: ${parseErr.message}`);
200
+ }
201
+ }
202
+ }
203
+
204
+ return {
205
+ valid: errors.length === 0,
206
+ target: resolvedTarget,
207
+ vault: vaultDir,
208
+ capabilities: capabilitiesReport,
209
+ grants: grantsReport,
210
+ events_count: eventsCount,
211
+ errors
212
+ };
213
+ }
@@ -9,7 +9,7 @@
9
9
  * The Mac Mini is the worked example. Its brain started 2026-08-13 while
10
10
  * Tailscale was still starting, so `getMeshIp()` returned null and the bind
11
11
  * fell back to loopback only. A week later Tailscale answers perfectly, so the
12
- * card derived `listen_hosts = [127.0.0.1, 100.64.0.2]`, recommended the mesh
12
+ * card derived `listen_hosts = [127.0.0.1, 100.64.0.10]`, recommended the mesh
13
13
  * URL, and drew a QR for an address nothing has ever listened on -- while
14
14
  * reporting no warning, because every check downstream was reading the guess.
15
15
  *
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Every brain this machine knows, and which of them are switched on.
3
+ *
4
+ * A machine carries one global brain plus any number of project brains
5
+ * (registered with `brain register`, or simply present in the working
6
+ * directory). Until now nothing could be excluded: recall always read the
7
+ * global brain and whichever project brain the cwd resolved to. A brain that
8
+ * is switched off is skipped by recall; listing still shows it.
9
+ *
10
+ * On/off state lives in one file in the global brain,
11
+ * `config/brain-toggles.json`, keyed by resolved brain directory, so the same
12
+ * switch applies from every repository on the machine. A brain is on unless it
13
+ * has been switched off — nothing changes for anyone who never uses this.
14
+ *
15
+ * Portability (open source): no brain, repo or path is hardcoded.
16
+ */
17
+
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+
22
+ export function globalBrainDirFor(env = process.env) {
23
+ const agentDir = env.AGENT_DIR || path.join(os.homedir(), '.agent');
24
+ return path.join(agentDir, 'skills', 'total-recall');
25
+ }
26
+
27
+ function togglesPath(globalBrainDir) {
28
+ return path.join(globalBrainDir, 'config', 'brain-toggles.json');
29
+ }
30
+
31
+ function registryPath(globalBrainDir) {
32
+ return path.join(globalBrainDir, 'config', 'project-registry.json');
33
+ }
34
+
35
+ function readJson(file, fallback) {
36
+ try {
37
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
38
+ } catch {
39
+ return fallback;
40
+ }
41
+ }
42
+
43
+ /** `{ [resolvedBrainDir]: false }` for every brain switched off. */
44
+ export function readBrainToggles(globalBrainDir = globalBrainDirFor()) {
45
+ const data = readJson(togglesPath(globalBrainDir), {});
46
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
47
+ }
48
+
49
+ export function isBrainEnabled(brainDir, { globalBrainDir = globalBrainDirFor(), toggles = null } = {}) {
50
+ const state = toggles || readBrainToggles(globalBrainDir);
51
+ return state[path.resolve(brainDir)] !== false;
52
+ }
53
+
54
+ /** Switch a brain on or off. Written atomically; only "off" entries are stored. */
55
+ export function setBrainEnabled(brainDir, enabled, { globalBrainDir = globalBrainDirFor() } = {}) {
56
+ const state = readBrainToggles(globalBrainDir);
57
+ const key = path.resolve(brainDir);
58
+ if (enabled) delete state[key];
59
+ else state[key] = false;
60
+
61
+ const file = togglesPath(globalBrainDir);
62
+ fs.mkdirSync(path.dirname(file), { recursive: true });
63
+ const tmp = `${file}.${process.pid}.tmp`;
64
+ fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
65
+ fs.renameSync(tmp, file);
66
+ return { brainDir: key, enabled: !!enabled };
67
+ }
68
+
69
+ function countMarkdown(dir) {
70
+ let count = 0;
71
+ const walk = (current) => {
72
+ let entries = [];
73
+ try {
74
+ entries = fs.readdirSync(current, { withFileTypes: true });
75
+ } catch {
76
+ return;
77
+ }
78
+ for (const entry of entries) {
79
+ if (entry.name.startsWith('.')) continue;
80
+ const full = path.join(current, entry.name);
81
+ if (entry.isDirectory()) walk(full);
82
+ else if (entry.name.endsWith('.md')) count += 1;
83
+ }
84
+ };
85
+ walk(dir);
86
+ return count;
87
+ }
88
+
89
+ /**
90
+ * Every brain on this machine: the global one, each registered project brain,
91
+ * and the active project brain even when it was never registered.
92
+ *
93
+ * @param {{ globalBrainDir?: string, activeProject?: { brainDir: string, projectRoot?: string } | null }} [options]
94
+ * @returns {Array<{ name: string, kind: 'global'|'project', brainDir: string, root: string|null,
95
+ * enabled: boolean, active: boolean, exists: boolean, nodes: number }>}
96
+ */
97
+ export function listLocalBrains(options = {}) {
98
+ const globalBrainDir = options.globalBrainDir || globalBrainDirFor();
99
+ const activeProject = options.activeProject === undefined ? null : options.activeProject;
100
+ const toggles = readBrainToggles(globalBrainDir);
101
+ const activeDir = activeProject ? path.resolve(activeProject.brainDir) : null;
102
+
103
+ const describe = (name, kind, brainDir, root) => {
104
+ const exists = fs.existsSync(brainDir);
105
+ return {
106
+ name,
107
+ kind,
108
+ brainDir: path.resolve(brainDir),
109
+ root: root || null,
110
+ enabled: toggles[path.resolve(brainDir)] !== false,
111
+ active: kind === 'global' ? !activeDir : path.resolve(brainDir) === activeDir,
112
+ exists,
113
+ nodes: exists ? countMarkdown(path.join(brainDir, 'memory-vault')) : 0,
114
+ };
115
+ };
116
+
117
+ const brains = [describe('global', 'global', globalBrainDir, null)];
118
+ const registry = readJson(registryPath(globalBrainDir), []);
119
+ for (const entry of Array.isArray(registry) ? registry : []) {
120
+ if (!entry?.brainDir) continue;
121
+ if (brains.some((b) => b.brainDir === path.resolve(entry.brainDir))) continue;
122
+ brains.push(describe(entry.name || path.basename(entry.path || entry.brainDir), 'project', entry.brainDir, entry.path));
123
+ }
124
+ if (activeProject && !brains.some((b) => b.brainDir === activeDir)) {
125
+ const root = activeProject.projectRoot || null;
126
+ brains.push(describe(root ? path.basename(root) : 'project', 'project', activeProject.brainDir, root));
127
+ }
128
+ return brains;
129
+ }
130
+
131
+ /**
132
+ * Find a brain by `global`, its name, its repo path or its brain path.
133
+ * Names are matched case-insensitively; an ambiguous name returns null.
134
+ */
135
+ export function findLocalBrain(brains, query) {
136
+ const wanted = String(query || '').trim();
137
+ if (!wanted) return null;
138
+ if (wanted.toLowerCase() === 'global') return brains.find((b) => b.kind === 'global') || null;
139
+
140
+ const asPath = path.resolve(wanted);
141
+ const byPath = brains.find((b) => b.brainDir === asPath || (b.root && path.resolve(b.root) === asPath));
142
+ if (byPath) return byPath;
143
+
144
+ const byName = brains.filter((b) => b.name.toLowerCase() === wanted.toLowerCase());
145
+ return byName.length === 1 ? byName[0] : null;
146
+ }
@@ -29,6 +29,7 @@
29
29
  "backups",
30
30
  "browser-profile",
31
31
  "skills-registry",
32
+ "plugins",
32
33
  ".snapshots",
33
34
  ".git",
34
35
  "node_modules"