total-recall-brain 3.28.2 → 3.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/total-recall.mjs +24 -1
- package/docs/ARCHITECTURE.md +26 -18
- package/extension/background.js +259 -84
- package/extension/content-script.css +7 -13
- package/extension/content-script.js +252 -477
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-32.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/icons/mark.svg +6 -0
- package/extension/lib/brain-client.js +160 -73
- package/extension/lib/tokens.css +187 -0
- package/extension/lib/ui-helpers.js +229 -0
- package/extension/lib/ui-helpers.spec.mjs +94 -0
- package/extension/manifest.json +20 -5
- package/extension/options/options.css +64 -218
- package/extension/options/options.html +91 -43
- package/extension/options/options.js +208 -85
- package/extension/sidepanel/sidepanel.css +268 -970
- package/extension/sidepanel/sidepanel.html +200 -166
- package/extension/sidepanel/sidepanel.js +788 -942
- package/frontend/dist/assets/index-DGMNZi_1.js +5742 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/src/api/plugins.ts +124 -92
- package/frontend/src/components/ResearchAgendaTab.spec.tsx +11 -0
- package/frontend/src/components/ResearchAgendaTab.tsx +19 -14
- package/frontend/src/pages/PluginsPage.spec.tsx +87 -16
- package/frontend/src/pages/PluginsPage.tsx +454 -1218
- package/package.json +2 -1
- package/plugins/code-quality/README.md +25 -0
- package/plugins/code-quality/cli.mjs +25 -0
- package/plugins/code-quality/plugin.json +17 -0
- package/plugins/git-sentinel/README.md +27 -0
- package/{scaffold/.agent/plugins → plugins}/git-sentinel/cli.mjs +8 -5
- package/plugins/git-sentinel/plugin.json +18 -0
- package/plugins/system-monitor/README.md +26 -0
- package/{scaffold/.agent/plugins → plugins}/system-monitor/cli.mjs +5 -4
- package/{scaffold/.agent/plugins → plugins}/system-monitor/generator.mjs +4 -5
- package/plugins/system-monitor/plugin.json +29 -0
- package/scaffold/.agent/skills/meta-harness/SKILL.md +4 -4
- package/scaffold/.agent/skills/plugins/SKILL.md +53 -0
- package/scaffold/.agent/skills/total-recall/scripts/brain-state.json +1 -0
- package/src/cli/agent.mjs +1 -1
- package/src/cli/app/index.mjs +493 -0
- package/src/cli/brain.mjs +33 -3
- package/src/cli/command.mjs +80 -41
- package/src/cli/harness.mjs +1 -1
- package/src/cli/init.mjs +3 -1
- package/src/cli/mesh.mjs +118 -12
- package/src/cli/plugin/create.mjs +144 -25
- package/src/cli/plugin/index.mjs +83 -53
- package/src/cli/plugin/install.mjs +10 -127
- package/src/cli/plugin/list.mjs +44 -44
- package/src/cli/plugin/remove.mjs +23 -30
- package/src/cli/plugin/scaffold-cmd.mjs +30 -0
- package/src/cli/plugin/search.mjs +108 -23
- package/src/cli/rebuild.mjs +37 -1
- package/src/cli/recall.mjs +17 -2
- package/src/cli/research.mjs +4 -6
- package/src/cli/share.mjs +2 -0
- package/src/core/app-deploy/apply.mjs +386 -0
- package/src/core/app-deploy/design-tokens.mjs +217 -0
- package/src/core/app-deploy/plan.mjs +249 -0
- package/src/core/app-deploy/resolve.mjs +295 -0
- package/src/core/app-deploy/source.mjs +337 -0
- package/src/core/app-deploy/standalone.mjs +248 -0
- package/src/core/app-deploy/upgrade.mjs +256 -0
- package/src/core/app-deploy/verify.mjs +213 -0
- package/src/core/bound-hosts.mjs +1 -1
- package/src/core/brain-registry.mjs +146 -0
- package/src/core/brain-state.json +1 -0
- package/src/core/daemon-loop.mjs +30 -26
- package/src/core/dream.mjs +5 -17
- package/src/core/fact-seeker.mjs +53 -368
- package/src/core/lan-discovery.mjs +3 -3
- package/src/core/mesh-access-sync.mjs +134 -0
- package/src/core/mesh-access.mjs +18 -3
- package/src/core/mesh-discover.mjs +173 -0
- package/src/core/mesh.mjs +131 -32
- package/src/core/optimizer.mjs +4 -41
- package/src/core/plugin-bundle.mjs +167 -0
- package/src/core/plugin-context.mjs +8 -13
- package/src/core/plugin-loader.mjs +272 -68
- package/src/core/plugin-peers.mjs +163 -0
- package/src/core/plugin-public.mjs +85 -0
- package/src/core/plugin-runner-child.mjs +24 -0
- package/src/core/plugin-runner.mjs +102 -0
- package/src/core/plugin-store.mjs +384 -0
- package/src/core/plugin-tasks.mjs +212 -0
- package/src/core/post-mortem.mjs +40 -9
- package/src/core/research-gate.mjs +182 -0
- package/src/core/research-queue.mjs +47 -7
- package/src/core/research-surface.mjs +211 -0
- package/src/core/research.mjs +25 -32
- package/src/core/scheduler.mjs +67 -153
- package/src/core/schema.mjs +129 -0
- package/src/core/search.mjs +12 -1
- package/src/core/secret-integration-research.mjs +12 -8
- package/src/core/secrets-store.mjs +32 -2
- package/src/core/session-watcher.mjs +6 -0
- package/src/core/surface.mjs +64 -3
- package/src/core/task-executors.mjs +8 -45
- package/src/core/vault-cache.mjs +70 -4
- package/src/server/api.mjs +21 -0
- package/src/server/rest.mjs +2 -0
- package/src/server/route-manifest.json +21 -5
- package/src/server/routes/collab.mjs +38 -11
- package/src/server/routes/extension.mjs +3 -2
- package/src/server/routes/memory.mjs +24 -2
- package/src/server/routes/mesh.mjs +7 -7
- package/src/server/routes/plugins-mesh.mjs +49 -0
- package/src/server/routes/plugins.mjs +91 -426
- package/src/server/routes/research.mjs +5 -2
- package/src/server/routes/share.mjs +2 -0
- package/src/server/tools.mjs +29 -2
- package/extension/icons/README.md +0 -27
- package/extension/lib/preconfigured.js +0 -5
- package/extension/popup/popup.css +0 -227
- package/extension/popup/popup.html +0 -50
- package/extension/popup/popup.js +0 -175
- package/frontend/dist/assets/index-U7o-qU99.js +0 -5742
- package/scaffold/.agent/plugins/.gitkeep +0 -0
- package/scaffold/.agent/plugins/code-quality/README.md +0 -16
- package/scaffold/.agent/plugins/code-quality/cli.mjs +0 -15
- package/scaffold/.agent/plugins/code-quality/plugin.json +0 -34
- package/scaffold/.agent/plugins/git-sentinel/README.md +0 -16
- package/scaffold/.agent/plugins/git-sentinel/plugin.json +0 -34
- package/scaffold/.agent/plugins/meta-harness/README.md +0 -16
- package/scaffold/.agent/plugins/meta-harness/cli.mjs +0 -34
- package/scaffold/.agent/plugins/meta-harness/plugin.json +0 -34
- package/scaffold/.agent/plugins/system-monitor/README.md +0 -16
- package/scaffold/.agent/plugins/system-monitor/plugin.json +0 -38
|
@@ -0,0 +1,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
|
+
}
|
package/src/core/bound-hosts.mjs
CHANGED
|
@@ -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.
|
|
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
|
+
}
|