total-recall-brain 3.31.2 → 3.32.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 (47) hide show
  1. package/bin/total-recall.mjs +20 -2
  2. package/package.json +2 -2
  3. package/scaffold/.agent/skills/plugins/SKILL.md +5 -0
  4. package/scaffold/.agent/skills/total-recall/SKILL.md +33 -3
  5. package/src/cli/api.mjs +133 -0
  6. package/src/cli/app/index.mjs +181 -1
  7. package/src/cli/brief.mjs +195 -0
  8. package/src/cli/command.mjs +269 -5
  9. package/src/cli/config.mjs +25 -7
  10. package/src/cli/connect.mjs +1 -1
  11. package/src/cli/daemon.mjs +3 -3
  12. package/src/cli/group.mjs +151 -0
  13. package/src/cli/init.mjs +54 -0
  14. package/src/cli/plugin/create.mjs +20 -10
  15. package/src/cli/project-id.mjs +43 -0
  16. package/src/cli/repo-expert-generate.mjs +37 -3
  17. package/src/cli/secret.mjs +36 -3
  18. package/src/cli/skill-config.mjs +211 -0
  19. package/src/cli/skill.mjs +37 -2
  20. package/src/core/api-routes.mjs +99 -0
  21. package/src/core/app-deploy/adapter-contract.mjs +176 -0
  22. package/src/core/app-deploy/design-tokens.mjs +148 -108
  23. package/src/core/app-deploy/plan.mjs +2 -2
  24. package/src/core/app-deploy/resolve.mjs +30 -2
  25. package/src/core/app-deploy/runtime-cli/node.mjs.tmpl +211 -0
  26. package/src/core/app-deploy/runtime-cli/python.py.tmpl +178 -0
  27. package/src/core/app-deploy/runtime-cli.mjs +254 -0
  28. package/src/core/app-deploy/ui-elements.mjs +473 -0
  29. package/src/core/app-deploy/verify.mjs +18 -0
  30. package/src/core/command-surface.mjs +129 -0
  31. package/src/core/daemon-control.mjs +31 -12
  32. package/src/core/daemon-loop.mjs +14 -7
  33. package/src/core/plugin-loader.mjs +12 -12
  34. package/src/core/project-id.mjs +72 -0
  35. package/src/core/provider-catalog.mjs +1 -1
  36. package/src/core/schema.mjs +72 -13
  37. package/src/core/secrets-env-export.mjs +3 -4
  38. package/src/core/secrets-store.mjs +30 -29
  39. package/src/core/skill-config.mjs +376 -0
  40. package/src/core/skills-registry.mjs +147 -31
  41. package/src/core/source-adapters.mjs +2 -2
  42. package/src/core/surface.mjs +109 -16
  43. package/src/server/routes/help.mjs +1 -1
  44. package/src/server/routes/system.mjs +1 -1
  45. package/plugins/code-quality/README.md +0 -25
  46. package/plugins/code-quality/cli.mjs +0 -25
  47. package/plugins/code-quality/plugin.json +0 -17
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Repo-layer configuration for layered skills.
3
+ *
4
+ * A layered skill's plugin-owned core ships the layer contract as
5
+ * `core/config.schema.json` (JSON Schema, root type object) and may ship
6
+ * `core/detect.mjs`. The canonical repo config is a `skill_config` SSSS
7
+ * document in the repo's project vault (`system/skills/<id>.md`), written
8
+ * through the operation service after the whole config validates against the
9
+ * contract. `config.json` next to `core/` is a projection of that record for
10
+ * the core scripts to read; it is rebuilt after every write.
11
+ *
12
+ * Collections: an array property annotated with `"x-collection": "<noun>"`
13
+ * gets `<noun> list|add|remove` verbs; items are keyed by `"x-key"` (default
14
+ * `name`).
15
+ */
16
+ import crypto from 'node:crypto';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { isDeepStrictEqual } from 'node:util';
20
+ import { pathToFileURL } from 'node:url';
21
+ import { z } from 'zod';
22
+ import { writeVfsDocument } from './ssss-operation-service.mjs';
23
+ import { findVfsDocumentByPath } from './vfs-documents.mjs';
24
+
25
+ const SKILL_ID = /^[a-z][a-z0-9-]{0,63}$/;
26
+ const FORBIDDEN_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']);
27
+
28
+ export class SkillConfigError extends Error {
29
+ constructor(message, { exitCode = 2, issues = [] } = {}) {
30
+ super(message);
31
+ this.exitCode = exitCode;
32
+ this.issues = issues;
33
+ }
34
+ }
35
+
36
+ function regularFile(file) {
37
+ return fs.existsSync(file) && !fs.lstatSync(file).isSymbolicLink() && fs.statSync(file).isFile();
38
+ }
39
+
40
+ /** Locate a deployed layered skill inside a repository. */
41
+ export function resolveSkillDir(repoRoot, skillId) {
42
+ if (!SKILL_ID.test(skillId || '')) throw new SkillConfigError('Skill id must be lowercase kebab-case');
43
+ const skillDir = path.join(path.resolve(repoRoot), '.agent', 'skills', skillId);
44
+ if (!fs.existsSync(skillDir)) throw new SkillConfigError(`Skill '${skillId}' is not deployed in ${repoRoot}`, { exitCode: 1 });
45
+ if (fs.lstatSync(skillDir).isSymbolicLink()) throw new SkillConfigError(`Refusing symlinked skill directory: ${skillDir}`, { exitCode: 1 });
46
+ return skillDir;
47
+ }
48
+
49
+ /** Load and compile the core-owned layer contract. */
50
+ export function loadConfigSchema(skillDir) {
51
+ const coreDir = path.join(skillDir, 'core');
52
+ if (fs.existsSync(coreDir) && fs.lstatSync(coreDir).isSymbolicLink()) {
53
+ throw new SkillConfigError(`Refusing symlinked skill core: ${coreDir}`, { exitCode: 1 });
54
+ }
55
+ const schemaPath = path.join(coreDir, 'config.schema.json');
56
+ if (!regularFile(schemaPath)) {
57
+ throw new SkillConfigError(`Skill has no config contract (expected core/config.schema.json)`, { exitCode: 1 });
58
+ }
59
+ const raw = fs.readFileSync(schemaPath);
60
+ let schema;
61
+ try { schema = JSON.parse(raw.toString('utf8')); }
62
+ catch (error) { throw new SkillConfigError(`core/config.schema.json is not valid JSON: ${error.message}`, { exitCode: 1 }); }
63
+ if (schema?.type !== 'object') throw new SkillConfigError('core/config.schema.json must describe an object', { exitCode: 1 });
64
+ let validator;
65
+ try { validator = z.fromJSONSchema(schema); }
66
+ catch (error) { throw new SkillConfigError(`core/config.schema.json is not a supported JSON Schema: ${error.message}`, { exitCode: 1 }); }
67
+ return { schema, validator, schemaPath, sha256: crypto.createHash('sha256').update(raw).digest('hex') };
68
+ }
69
+
70
+ function formatIssues(issues) {
71
+ return issues.map((issue) => `${issue.path.length ? issue.path.join('.') : '(root)'}: ${issue.message}`);
72
+ }
73
+
74
+ /** Validate a whole config document; returns the normalized value (defaults applied). */
75
+ export function validateSkillConfig(contract, value) {
76
+ const result = contract.validator.safeParse(value);
77
+ if (result.success) return { valid: true, value: result.data, errors: [] };
78
+ return { valid: false, value: null, errors: formatIssues(result.error.issues) };
79
+ }
80
+
81
+ export function configPath(skillDir) {
82
+ return path.join(skillDir, 'config.json');
83
+ }
84
+
85
+ export function recordPath(skillId) {
86
+ return `system/skills/${skillId}.md`;
87
+ }
88
+
89
+ /** The repo's own project vault. Never walks up into a parent project's brain. */
90
+ export function projectVaultFor(skillDir) {
91
+ const repoRoot = path.resolve(skillDir, '..', '..', '..');
92
+ const brain = path.join(repoRoot, '.agent', 'skills', 'total-recall');
93
+ if (!regularFile(path.join(brain, 'SKILL.md'))) {
94
+ throw new SkillConfigError(`No project brain in ${repoRoot}; run 'total-recall init --project' there first`, { exitCode: 1 });
95
+ }
96
+ return path.join(brain, 'memory-vault');
97
+ }
98
+
99
+ function readProjection(skillDir) {
100
+ const file = configPath(skillDir);
101
+ if (!fs.existsSync(file)) return null;
102
+ if (!regularFile(file)) throw new SkillConfigError(`Refusing non-regular config file: ${file}`, { exitCode: 1 });
103
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
104
+ catch (error) { throw new SkillConfigError(`config.json is not valid JSON: ${error.message}`, { exitCode: 1 }); }
105
+ }
106
+
107
+ /**
108
+ * Current repo config. `source` is 'record' (canonical), 'projection' (a
109
+ * config.json with no record yet, e.g. a repo skill awaiting import), or null.
110
+ */
111
+ export function readSkillConfigState(skillDir) {
112
+ const skillId = path.basename(skillDir);
113
+ const record = findVfsDocumentByPath(recordPath(skillId), projectVaultFor(skillDir));
114
+ const projection = readProjection(skillDir);
115
+ if (record) {
116
+ if (record.type !== 'skill_config' || !record.config || typeof record.config !== 'object') {
117
+ throw new SkillConfigError(`${recordPath(skillId)} is not a skill_config record`, { exitCode: 1 });
118
+ }
119
+ return { config: record.config, source: 'record', record, projection, inSync: isDeepStrictEqual(projection, record.config) };
120
+ }
121
+ return { config: projection, source: projection === null ? null : 'projection', record: null, projection, inSync: projection === null };
122
+ }
123
+
124
+ export function readSkillConfig(skillDir) {
125
+ return readSkillConfigState(skillDir).config;
126
+ }
127
+
128
+ /** Rebuild config.json from the canonical record (atomic replace). */
129
+ export function projectSkillConfig(skillDir, config) {
130
+ const file = configPath(skillDir);
131
+ if (fs.existsSync(file) && !regularFile(file)) throw new SkillConfigError(`Refusing non-regular config file: ${file}`, { exitCode: 1 });
132
+ const temp = path.join(skillDir, `.config.json.${process.pid}.${Date.now()}.tmp`);
133
+ fs.writeFileSync(temp, `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o644 });
134
+ try { fs.renameSync(temp, file); }
135
+ catch (error) { fs.rmSync(temp, { force: true }); throw error; }
136
+ }
137
+
138
+ /**
139
+ * The single write path for repo-layer config: validate against the core
140
+ * contract, commit the skill_config record through the SSSS operation service,
141
+ * then rebuild the config.json projection.
142
+ */
143
+ export async function writeSkillConfig(skillDir, contract, value) {
144
+ const checked = validateSkillConfig(contract, value);
145
+ if (!checked.valid) {
146
+ throw new SkillConfigError(`Config does not satisfy core/config.schema.json:\n ${checked.errors.join('\n ')}`, { issues: checked.errors });
147
+ }
148
+ const file = configPath(skillDir);
149
+ if (fs.existsSync(file) && !regularFile(file)) throw new SkillConfigError(`Refusing non-regular config file: ${file}`, { exitCode: 1 });
150
+ const skillId = path.basename(skillDir);
151
+ const vaultRoot = projectVaultFor(skillDir);
152
+ const existing = findVfsDocumentByPath(recordPath(skillId), vaultRoot);
153
+ const now = new Date().toISOString();
154
+ const createdAt = existing?.created_at ? new Date(existing.created_at).toISOString() : now;
155
+ await writeVfsDocument(recordPath(skillId), {
156
+ type: 'skill_config',
157
+ title: `Skill config: ${skillId}`,
158
+ description: `Repo-layer configuration for the ${skillId} skill.`,
159
+ timestamp: now,
160
+ skill_id: skillId,
161
+ config: checked.value,
162
+ schema_sha256: contract.sha256,
163
+ created_at: createdAt,
164
+ ...(existing ? { updated_at: now } : {}),
165
+ }, '', { actorRole: 'system', intent: `Update repo config for skill ${skillId}`, vaultRoot });
166
+ projectSkillConfig(skillDir, checked.value);
167
+ return checked.value;
168
+ }
169
+
170
+ function splitPath(dotted) {
171
+ if (!dotted) return [];
172
+ const segments = String(dotted).split('.');
173
+ for (const segment of segments) {
174
+ if (!segment || FORBIDDEN_SEGMENTS.has(segment)) throw new SkillConfigError(`Invalid config path '${dotted}'`);
175
+ }
176
+ return segments;
177
+ }
178
+
179
+ export function getConfigValue(config, dotted) {
180
+ let node = config;
181
+ for (const segment of splitPath(dotted)) {
182
+ if (node === null || typeof node !== 'object' || !Object.hasOwn(node, segment)) return undefined;
183
+ node = node[segment];
184
+ }
185
+ return node;
186
+ }
187
+
188
+ /** Return a copy of `config` with `dotted` set (or removed when `value` is undefined). */
189
+ export function setConfigValue(config, dotted, value) {
190
+ const segments = splitPath(dotted);
191
+ if (segments.length === 0) throw new SkillConfigError('A config path is required');
192
+ const root = structuredClone(config ?? {});
193
+ let node = root;
194
+ for (const segment of segments.slice(0, -1)) {
195
+ if (node[segment] === undefined) node[segment] = {};
196
+ if (node[segment] === null || typeof node[segment] !== 'object') {
197
+ throw new SkillConfigError(`Config path '${dotted}' crosses a non-object value at '${segment}'`);
198
+ }
199
+ node = node[segment];
200
+ }
201
+ const last = segments.at(-1);
202
+ if (value === undefined) delete node[last];
203
+ else node[last] = value;
204
+ return root;
205
+ }
206
+
207
+ /** CLI values: JSON when it parses (numbers, booleans, arrays, objects), otherwise a string. */
208
+ export function parseCliValue(raw) {
209
+ try { return JSON.parse(raw); } catch { return raw; }
210
+ }
211
+
212
+ /** Array properties exposed as collection verbs, keyed by noun. */
213
+ export function listCollections(schema) {
214
+ const collections = {};
215
+ for (const [property, definition] of Object.entries(schema.properties || {})) {
216
+ const noun = definition?.['x-collection'];
217
+ if (definition?.type === 'array' && typeof noun === 'string' && SKILL_ID.test(noun)) {
218
+ collections[noun] = { property, key: definition['x-key'] || 'name' };
219
+ }
220
+ }
221
+ return collections;
222
+ }
223
+
224
+ /** Parse `key=value` pairs (or one JSON object) into a collection item. */
225
+ export function parseCollectionItem(args) {
226
+ if (args.length === 1 && args[0].trim().startsWith('{')) {
227
+ const item = parseCliValue(args[0]);
228
+ if (item && typeof item === 'object' && !Array.isArray(item)) return item;
229
+ throw new SkillConfigError('Collection item must be a JSON object');
230
+ }
231
+ if (args.length === 0) throw new SkillConfigError('Collection item needs key=value fields or a JSON object');
232
+ let item = {};
233
+ for (const arg of args) {
234
+ const at = arg.indexOf('=');
235
+ if (at <= 0) throw new SkillConfigError(`Expected key=value, got '${arg}'`);
236
+ item = setConfigValue(item, arg.slice(0, at), parseCliValue(arg.slice(at + 1)));
237
+ }
238
+ return item;
239
+ }
240
+
241
+ export function addCollectionItem(config, collection, item) {
242
+ const current = getConfigValue(config, collection.property) ?? [];
243
+ if (!Array.isArray(current)) throw new SkillConfigError(`'${collection.property}' is not an array`);
244
+ const key = item?.[collection.key];
245
+ if (key === undefined || key === '') throw new SkillConfigError(`Item needs a '${collection.key}' field`);
246
+ if (current.some((entry) => entry?.[collection.key] === key)) {
247
+ throw new SkillConfigError(`${collection.property} already has ${collection.key} '${key}'`);
248
+ }
249
+ return setConfigValue(config, collection.property, [...current, item]);
250
+ }
251
+
252
+ export function removeCollectionItem(config, collection, key) {
253
+ const current = getConfigValue(config, collection.property) ?? [];
254
+ if (!Array.isArray(current)) throw new SkillConfigError(`'${collection.property}' is not an array`);
255
+ const next = current.filter((entry) => String(entry?.[collection.key]) !== String(key));
256
+ if (next.length === current.length) throw new SkillConfigError(`${collection.property} has no ${collection.key} '${key}'`, { exitCode: 1 });
257
+ return setConfigValue(config, collection.property, next);
258
+ }
259
+
260
+ /** Run the core's detector. It proposes a config; nothing is written here. */
261
+ export async function detectSkillConfig(skillDir, repoRoot) {
262
+ const detector = path.join(skillDir, 'core', 'detect.mjs');
263
+ if (!regularFile(detector)) throw new SkillConfigError('Skill core has no detector (expected core/detect.mjs)', { exitCode: 1 });
264
+ const mod = await import(`${pathToFileURL(detector).href}?t=${fs.statSync(detector).mtimeMs}`);
265
+ const detect = mod.detect || mod.default;
266
+ if (typeof detect !== 'function') throw new SkillConfigError('core/detect.mjs must export detect()', { exitCode: 1 });
267
+ const proposal = await detect({ repoRoot: path.resolve(repoRoot), skillDir });
268
+ if (!proposal || typeof proposal !== 'object' || Array.isArray(proposal)) {
269
+ throw new SkillConfigError('detect() must return a config object', { exitCode: 1 });
270
+ }
271
+ return proposal;
272
+ }
273
+
274
+ /** Required top-level properties still absent from `config`. */
275
+ export function missingRequired(schema, config) {
276
+ return (schema.required || []).filter((key) => config?.[key] === undefined);
277
+ }
278
+
279
+ /**
280
+ * Values at schema locations annotated `"x-command": true`, with their config
281
+ * paths. A command is a shell-style string or an argv array of strings.
282
+ */
283
+ export function collectCommandValues(schema, value, at = []) {
284
+ if (!schema || value === undefined || value === null) return [];
285
+ if (schema['x-command'] === true && (typeof value === 'string'
286
+ || (Array.isArray(value) && value.length && value.every((part) => typeof part === 'string')))) {
287
+ return [{ path: at.join('.'), command: value }];
288
+ }
289
+ const found = [];
290
+ if (schema.type === 'array' && Array.isArray(value) && schema.items) {
291
+ value.forEach((item, i) => found.push(...collectCommandValues(schema.items, item, [...at, String(i)])));
292
+ }
293
+ if (schema.properties && typeof value === 'object' && !Array.isArray(value)) {
294
+ for (const [key, child] of Object.entries(schema.properties)) {
295
+ found.push(...collectCommandValues(child, value[key], [...at, key]));
296
+ }
297
+ }
298
+ return found;
299
+ }
300
+
301
+ function onPath(binary) {
302
+ for (const dir of (process.env.PATH || '').split(path.delimiter)) {
303
+ if (!dir) continue;
304
+ const candidate = path.join(dir, binary);
305
+ try {
306
+ if (fs.statSync(candidate).isFile()) { fs.accessSync(candidate, fs.constants.X_OK); return true; }
307
+ } catch { /* keep looking */ }
308
+ }
309
+ return false;
310
+ }
311
+
312
+ /** Whether a gate command's program exists; does not run it. */
313
+ export function resolveCommand(command, repoRoot) {
314
+ const tokens = Array.isArray(command) ? [...command] : String(command).trim().split(/\s+/).filter(Boolean);
315
+ while (tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens.shift();
316
+ const program = tokens[0];
317
+ if (!program) return { ok: false, reason: 'empty command' };
318
+ if (path.isAbsolute(program)) {
319
+ return fs.existsSync(program) ? { ok: true } : { ok: false, reason: `${program} does not exist` };
320
+ }
321
+ if (program.includes('/')) {
322
+ const file = path.resolve(repoRoot, program);
323
+ const inside = !path.relative(repoRoot, file).startsWith('..');
324
+ return inside && fs.existsSync(file) ? { ok: true } : { ok: false, reason: `${program} does not exist in the repo` };
325
+ }
326
+ if (['npm', 'pnpm', 'yarn', 'bun'].includes(program) && tokens[1] === 'run' && tokens[2]) {
327
+ let scripts = {};
328
+ try { scripts = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).scripts || {}; } catch { /* no package.json */ }
329
+ return Object.hasOwn(scripts, tokens[2]) ? { ok: true } : { ok: false, reason: `package.json has no '${tokens[2]}' script` };
330
+ }
331
+ return onPath(program) ? { ok: true } : { ok: false, reason: `'${program}' is not on PATH` };
332
+ }
333
+
334
+ /**
335
+ * Check a deployed layered skill's repo layer against its core contract.
336
+ * Shared by `skill status`, `skill config <id> check`, and `app verify`.
337
+ * Each check is { id, level: 'error'|'warn', ok, message }; `ok` is false when
338
+ * any error-level check fails.
339
+ */
340
+ export function checkSkillLayerContract(skillDir) {
341
+ const skillId = path.basename(skillDir);
342
+ const repoRoot = path.resolve(skillDir, '..', '..', '..');
343
+ const checks = [];
344
+ const add = (id, ok, message, level = 'error') => checks.push({ id, level, ok, message });
345
+ const result = () => ({ skill_id: skillId, path: skillDir, ok: checks.every((c) => c.ok || c.level !== 'error'), checks });
346
+
347
+ let contract;
348
+ try { contract = loadConfigSchema(skillDir); add('contract', true, 'core/config.schema.json loads'); }
349
+ catch (error) { add('contract', false, error.message); return result(); }
350
+
351
+ let state;
352
+ try { state = readSkillConfigState(skillDir); }
353
+ catch (error) { add('record', false, error.message); return result(); }
354
+ if (state.source === null) { add('record', false, `Not configured; run 'total-recall skill config ${skillId} init'`); return result(); }
355
+ if (state.source === 'projection') {
356
+ add('record', false, `config.json is not recorded in SSSS; run 'total-recall skill config ${skillId} import'`);
357
+ } else {
358
+ add('record', true, `Recorded at ${recordPath(skillId)}`);
359
+ }
360
+
361
+ const checked = validateSkillConfig(contract, state.config);
362
+ add('config', checked.valid, checked.valid ? 'Config satisfies the contract' : `Config violates the contract: ${checked.errors.join('; ')}`);
363
+ if (state.source === 'record') {
364
+ const current = state.record.schema_sha256 === contract.sha256;
365
+ add('contract-version', current, current ? 'Validated against the current core contract'
366
+ : 'The core contract changed since this config was recorded; re-save it to re-validate', 'warn');
367
+ add('projection', state.inSync, state.inSync ? 'config.json matches the record'
368
+ : `config.json has drifted from the record; run 'total-recall skill config ${skillId} rebuild'`);
369
+ }
370
+ for (const { path: at, command } of collectCommandValues(contract.schema, state.config)) {
371
+ const resolved = resolveCommand(command, repoRoot);
372
+ const shown = Array.isArray(command) ? command.join(' ') : command;
373
+ add(`command:${at}`, resolved.ok, resolved.ok ? `${at}: '${shown}' resolves` : `${at}: ${resolved.reason}`);
374
+ }
375
+ return result();
376
+ }