total-recall-brain 3.31.2 → 3.32.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 +2 -2
- package/package.json +2 -2
- package/scaffold/.agent/skills/plugins/SKILL.md +5 -0
- package/scaffold/.agent/skills/total-recall/SKILL.md +33 -3
- package/src/cli/app/index.mjs +181 -1
- package/src/cli/command.mjs +269 -5
- package/src/cli/connect.mjs +1 -1
- package/src/cli/daemon.mjs +3 -3
- package/src/cli/init.mjs +54 -0
- package/src/cli/plugin/create.mjs +20 -10
- package/src/cli/repo-expert-generate.mjs +37 -3
- package/src/cli/skill-config.mjs +211 -0
- package/src/cli/skill.mjs +37 -2
- package/src/core/app-deploy/adapter-contract.mjs +176 -0
- package/src/core/app-deploy/design-tokens.mjs +148 -108
- package/src/core/app-deploy/plan.mjs +2 -2
- package/src/core/app-deploy/resolve.mjs +30 -2
- package/src/core/app-deploy/runtime-cli/node.mjs.tmpl +211 -0
- package/src/core/app-deploy/runtime-cli/python.py.tmpl +178 -0
- package/src/core/app-deploy/runtime-cli.mjs +254 -0
- package/src/core/app-deploy/ui-elements.mjs +473 -0
- package/src/core/app-deploy/verify.mjs +18 -0
- package/src/core/command-surface.mjs +129 -0
- package/src/core/daemon-control.mjs +31 -12
- package/src/core/daemon-loop.mjs +14 -7
- package/src/core/plugin-loader.mjs +12 -12
- package/src/core/schema.mjs +72 -13
- package/src/core/skill-config.mjs +376 -0
- package/src/core/skills-registry.mjs +147 -31
- package/src/core/surface.mjs +52 -7
- package/src/server/routes/system.mjs +1 -1
- package/plugins/code-quality/README.md +0 -25
- package/plugins/code-quality/cli.mjs +0 -25
- package/plugins/code-quality/plugin.json +0 -17
|
@@ -12,6 +12,7 @@ import os from 'os';
|
|
|
12
12
|
import YAML from 'yaml';
|
|
13
13
|
import matter from 'gray-matter';
|
|
14
14
|
import { ensureFullProjectBrain } from './project-brain.mjs';
|
|
15
|
+
import { checkSkillLayerContract } from './skill-config.mjs';
|
|
15
16
|
|
|
16
17
|
export const REGISTRY_VERSION = 1;
|
|
17
18
|
|
|
@@ -96,6 +97,41 @@ export function hashSkillContent(skillDir) {
|
|
|
96
97
|
return crypto.createHash('sha256').update(buf).digest('hex').slice(0, 16);
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
/** Hash a skill layer without treating repo-owned edits as core drift. */
|
|
101
|
+
export function hashSkillLayer(skillDir, layer) {
|
|
102
|
+
const base = layer === 'core' ? path.join(skillDir, 'core') : skillDir;
|
|
103
|
+
if (!fs.existsSync(base)) return null;
|
|
104
|
+
if (fs.lstatSync(base).isSymbolicLink()) throw new Error(`Skill layer cannot be a symlink: ${base}`);
|
|
105
|
+
const hash = crypto.createHash('sha256');
|
|
106
|
+
let count = 0;
|
|
107
|
+
const visit = (dir, relative = '') => {
|
|
108
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
109
|
+
if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === '.DS_Store') continue;
|
|
110
|
+
if (layer === 'repo' && !relative && entry.name === 'core') continue;
|
|
111
|
+
const next = relative ? `${relative}/${entry.name}` : entry.name;
|
|
112
|
+
const file = path.join(dir, entry.name);
|
|
113
|
+
if (entry.isDirectory()) visit(file, next);
|
|
114
|
+
else if (entry.isFile()) {
|
|
115
|
+
hash.update(next).update('\0').update(fs.readFileSync(file)).update('\0');
|
|
116
|
+
count++;
|
|
117
|
+
} else if (entry.isSymbolicLink()) {
|
|
118
|
+
if (layer === 'core') throw new Error(`Skill core cannot contain a symlink: ${next}`);
|
|
119
|
+
hash.update(next).update('\0').update(fs.readlinkSync(file)).update('\0');
|
|
120
|
+
count++;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
visit(base);
|
|
125
|
+
return count ? hash.digest('hex').slice(0, 16) : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isLayeredSkill(skillDir) {
|
|
129
|
+
const core = path.join(skillDir, 'core');
|
|
130
|
+
if (!fs.existsSync(core)) return false;
|
|
131
|
+
if (fs.lstatSync(core).isSymbolicLink()) throw new Error(`Skill core cannot be a symlink: ${core}`);
|
|
132
|
+
return fs.lstatSync(core).isDirectory();
|
|
133
|
+
}
|
|
134
|
+
|
|
99
135
|
/**
|
|
100
136
|
* Read skill metadata from a local skill directory.
|
|
101
137
|
*/
|
|
@@ -110,6 +146,8 @@ export function readSkillMeta(skillDir) {
|
|
|
110
146
|
data.name ||
|
|
111
147
|
data.slug ||
|
|
112
148
|
path.basename(path.resolve(skillDir));
|
|
149
|
+
const coreHash = isLayeredSkill(skillDir) ? hashSkillLayer(skillDir, 'core') : null;
|
|
150
|
+
if (isLayeredSkill(skillDir) && !coreHash) throw new Error(`Skill core is empty: ${skillDir}`);
|
|
113
151
|
return {
|
|
114
152
|
id: String(id).replace(/[^a-zA-Z0-9._-]/g, '-').toLowerCase(),
|
|
115
153
|
title: data.title || data.name || id,
|
|
@@ -118,6 +156,7 @@ export function readSkillMeta(skillDir) {
|
|
|
118
156
|
tags: Array.isArray(data.tags) ? data.tags : [],
|
|
119
157
|
repo_scoped: Boolean(data.repo_scoped),
|
|
120
158
|
content_hash: hashSkillContent(skillDir),
|
|
159
|
+
core_hash: coreHash,
|
|
121
160
|
source_path: path.resolve(skillDir),
|
|
122
161
|
};
|
|
123
162
|
}
|
|
@@ -154,6 +193,8 @@ export function registerSkill(brainDir, skillPath, opts = {}) {
|
|
|
154
193
|
source_type: opts.source_type || prev.source_type || 'local',
|
|
155
194
|
source_path: abs,
|
|
156
195
|
content_hash: meta.content_hash,
|
|
196
|
+
core_hash: meta.core_hash,
|
|
197
|
+
layered: Boolean(meta.core_hash),
|
|
157
198
|
registered_at: prev.registered_at || new Date().toISOString(),
|
|
158
199
|
updated_at: new Date().toISOString(),
|
|
159
200
|
};
|
|
@@ -326,6 +367,7 @@ export function deploySkill(brainDir, skillIdOrPath, opts = {}) {
|
|
|
326
367
|
const resolved = resolveSkillSource(brainDir, skillIdOrPath, opts.agentSkillsDir);
|
|
327
368
|
const destDir = path.join(destSkills, resolved.id);
|
|
328
369
|
const sourceMeta = readSkillMeta(resolved.sourcePath);
|
|
370
|
+
const layered = Boolean(sourceMeta.core_hash);
|
|
329
371
|
const sourceRepo = repoForSkillPath(brainDir, resolved.sourcePath);
|
|
330
372
|
|
|
331
373
|
if (
|
|
@@ -341,7 +383,7 @@ export function deploySkill(brainDir, skillIdOrPath, opts = {}) {
|
|
|
341
383
|
if (
|
|
342
384
|
sourceRepo &&
|
|
343
385
|
canonicalPath(sourceRepo) !== canonicalPath(repoRoot) &&
|
|
344
|
-
!opts.allowRepoScopedCrossRepo
|
|
386
|
+
!opts.allowRepoScopedCrossRepo && !layered
|
|
345
387
|
) {
|
|
346
388
|
throw new Error(
|
|
347
389
|
`Refusing to deploy repo-owned catalog skill "${resolved.id}" from ${sourceRepo} into ${repoRoot}. ` +
|
|
@@ -354,10 +396,19 @@ export function deploySkill(brainDir, skillIdOrPath, opts = {}) {
|
|
|
354
396
|
// an explicit CLI signal for callers that require replacement semantics.
|
|
355
397
|
}
|
|
356
398
|
|
|
357
|
-
const
|
|
399
|
+
const existed = fs.existsSync(path.join(destDir, 'SKILL.md'));
|
|
400
|
+
if (layered && fs.existsSync(destDir) && fs.lstatSync(destDir).isSymbolicLink()) {
|
|
401
|
+
throw new Error(`Refusing layered deploy into symlink: ${destDir}`);
|
|
402
|
+
}
|
|
403
|
+
const replacement = layered && existed
|
|
404
|
+
? replaceSkillDir(path.join(resolved.sourcePath, 'core'), path.join(destDir, 'core'), {
|
|
405
|
+
requiredFile: null,
|
|
406
|
+
preserveExisting: false,
|
|
407
|
+
})
|
|
408
|
+
: replaceSkillDir(resolved.sourcePath, destDir);
|
|
358
409
|
|
|
359
410
|
let adaptResult = { adapted: false };
|
|
360
|
-
if (opts.adapt) {
|
|
411
|
+
if (opts.adapt && (!layered || !existed)) {
|
|
361
412
|
const openwikiDir =
|
|
362
413
|
opts.openwikiDir ||
|
|
363
414
|
path.join(repoRoot, '.agent', 'skills', 'total-recall', 'openwiki') ||
|
|
@@ -388,6 +439,11 @@ export function deploySkill(brainDir, skillIdOrPath, opts = {}) {
|
|
|
388
439
|
version: entry?.version || readSkillMeta(destDir).version,
|
|
389
440
|
content_hash: contentHash,
|
|
390
441
|
registry_hash: entry?.content_hash || null,
|
|
442
|
+
...(layered ? {
|
|
443
|
+
layered: true,
|
|
444
|
+
core_hash: hashSkillLayer(destDir, 'core'),
|
|
445
|
+
repo_hash: hashSkillLayer(destDir, 'repo'),
|
|
446
|
+
} : {}),
|
|
391
447
|
adapted: Boolean(adaptResult.adapted),
|
|
392
448
|
installed_at: new Date().toISOString(),
|
|
393
449
|
};
|
|
@@ -413,6 +469,42 @@ export function deploySkill(brainDir, skillIdOrPath, opts = {}) {
|
|
|
413
469
|
};
|
|
414
470
|
}
|
|
415
471
|
|
|
472
|
+
function installDetail(entry, inst) {
|
|
473
|
+
const exists = fs.existsSync(path.join(inst.path, 'SKILL.md'));
|
|
474
|
+
// Only explicitly deployed/adopted installs are layered. Old install-map rows
|
|
475
|
+
// under a now-layered catalog entry are legacy copies: not adoption consent,
|
|
476
|
+
// and their content hash says nothing about the plugin-owned core.
|
|
477
|
+
const layered = Boolean(inst.layered);
|
|
478
|
+
const legacyUnderLayered = Boolean(entry?.layered) && !layered;
|
|
479
|
+
const liveHash = exists ? hashSkillContent(inst.path) : null;
|
|
480
|
+
const liveCoreHash = layered && exists ? hashSkillLayer(inst.path, 'core') : null;
|
|
481
|
+
const liveRepoHash = layered && exists ? hashSkillLayer(inst.path, 'repo') : null;
|
|
482
|
+
const drift = layered
|
|
483
|
+
? liveCoreHash !== (entry?.core_hash || inst.core_hash)
|
|
484
|
+
: legacyUnderLayered
|
|
485
|
+
? false
|
|
486
|
+
: exists && entry?.content_hash
|
|
487
|
+
? liveHash !== entry.content_hash
|
|
488
|
+
: exists && inst.registry_hash
|
|
489
|
+
? liveHash !== inst.registry_hash
|
|
490
|
+
: false;
|
|
491
|
+
return {
|
|
492
|
+
...inst,
|
|
493
|
+
exists,
|
|
494
|
+
live_hash: liveHash,
|
|
495
|
+
...(layered ? {
|
|
496
|
+
core_hash: liveCoreHash,
|
|
497
|
+
repo_hash: liveRepoHash,
|
|
498
|
+
repo_changed: liveRepoHash !== inst.repo_hash,
|
|
499
|
+
} : {}),
|
|
500
|
+
...(legacyUnderLayered ? { adopted: false } : {}),
|
|
501
|
+
drift,
|
|
502
|
+
// Repo-layer contract checks apply to any layered install whose core ships a config contract.
|
|
503
|
+
...(layered && exists && fs.existsSync(path.join(inst.path, 'core', 'config.schema.json'))
|
|
504
|
+
? { contract: checkSkillLayerContract(inst.path) } : {}),
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
416
508
|
/**
|
|
417
509
|
* Status of a skill: registry entry + installs + drift.
|
|
418
510
|
*/
|
|
@@ -422,20 +514,13 @@ export function skillStatus(brainDir, skillId) {
|
|
|
422
514
|
const installs = registry.installs.filter((i) => i.skill_id === skillId);
|
|
423
515
|
|
|
424
516
|
const installDetails = installs.map((inst) => {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
: false;
|
|
433
|
-
return {
|
|
434
|
-
...inst,
|
|
435
|
-
exists,
|
|
436
|
-
live_hash: liveHash,
|
|
437
|
-
drift,
|
|
438
|
-
};
|
|
517
|
+
try {
|
|
518
|
+
return installDetail(entry, inst);
|
|
519
|
+
} catch (err) {
|
|
520
|
+
// One unreadable install (a symlinked layer, a moved repo) must not hide
|
|
521
|
+
// the status of every other install.
|
|
522
|
+
return { ...inst, exists: fs.existsSync(inst.path), error: err.message, drift: false };
|
|
523
|
+
}
|
|
439
524
|
});
|
|
440
525
|
|
|
441
526
|
// Local brain skills dir presence
|
|
@@ -452,6 +537,7 @@ export function skillStatus(brainDir, skillId) {
|
|
|
452
537
|
installs: installDetails,
|
|
453
538
|
install_count: installDetails.length,
|
|
454
539
|
any_drift: installDetails.some((i) => i.drift),
|
|
540
|
+
any_contract_failure: installDetails.some((i) => i.contract && !i.contract.ok),
|
|
455
541
|
};
|
|
456
542
|
}
|
|
457
543
|
|
|
@@ -550,6 +636,9 @@ export function isRepoScopedSkill(brainDir, skillId) {
|
|
|
550
636
|
const registry = loadRegistry(brainDir);
|
|
551
637
|
const entry = registry.skills?.[skillId];
|
|
552
638
|
if (entry?.repo_scoped) return true;
|
|
639
|
+
// A layered catalog explicitly owns only core/. Repo-layer SKILL.md files
|
|
640
|
+
// may be repo_scoped and are never candidates for cross-repo propagation.
|
|
641
|
+
if (entry?.layered) return false;
|
|
553
642
|
const candidates = [
|
|
554
643
|
entry?.source_path,
|
|
555
644
|
...registry.installs.filter((install) => install.skill_id === skillId).map((install) => install.path),
|
|
@@ -858,7 +947,7 @@ export function discoverAllSkills(
|
|
|
858
947
|
source: hit.path,
|
|
859
948
|
source_type: 'discovered',
|
|
860
949
|
});
|
|
861
|
-
} else if (readSkillMeta(hit.path).repo_scoped && !before.repo_scoped) {
|
|
950
|
+
} else if (!before.layered && readSkillMeta(hit.path).repo_scoped && !before.repo_scoped) {
|
|
862
951
|
const scopedRegistry = loadRegistry(brainDir);
|
|
863
952
|
scopedRegistry.skills[hit.id].repo_scoped = true;
|
|
864
953
|
scopedRegistry.skills[hit.id].updated_at = new Date().toISOString();
|
|
@@ -871,6 +960,7 @@ export function discoverAllSkills(
|
|
|
871
960
|
}
|
|
872
961
|
|
|
873
962
|
const registry = loadRegistry(brainDir);
|
|
963
|
+
if (registry.skills[hit.id]?.layered) continue; // layered installs require explicit deploy/adoption
|
|
874
964
|
const exists = registry.installs.some(
|
|
875
965
|
(i) => i.skill_id === hit.id && path.resolve(i.repo || '') === path.resolve(repo),
|
|
876
966
|
);
|
|
@@ -920,7 +1010,7 @@ export function skillMtime(skillDir) {
|
|
|
920
1010
|
export function replaceSkillDir(
|
|
921
1011
|
src,
|
|
922
1012
|
dest,
|
|
923
|
-
{ dryRun = false, preserveExisting = true, fsImpl = fs, copyFn = copySkillDir } = {},
|
|
1013
|
+
{ dryRun = false, preserveExisting = true, requiredFile = 'SKILL.md', fsImpl = fs, copyFn = copySkillDir } = {},
|
|
924
1014
|
) {
|
|
925
1015
|
if (dryRun) return { dryRun: true, src, dest };
|
|
926
1016
|
if (!fsImpl.existsSync(src)) throw new Error(`Source missing: ${src}`);
|
|
@@ -954,8 +1044,8 @@ export function replaceSkillDir(
|
|
|
954
1044
|
copyFn(dest, stage);
|
|
955
1045
|
}
|
|
956
1046
|
copyFn(src, stage);
|
|
957
|
-
if (!fsImpl.existsSync(path.join(stage,
|
|
958
|
-
throw new Error(`Staged skill is missing
|
|
1047
|
+
if (requiredFile && !fsImpl.existsSync(path.join(stage, requiredFile))) {
|
|
1048
|
+
throw new Error(`Staged skill is missing ${requiredFile}: ${stage}`);
|
|
959
1049
|
}
|
|
960
1050
|
|
|
961
1051
|
if (fsImpl.existsSync(dest)) {
|
|
@@ -1004,12 +1094,13 @@ export function collectSkillLocations(brainDir, skillId) {
|
|
|
1004
1094
|
role: 'source',
|
|
1005
1095
|
path: entry.source_path,
|
|
1006
1096
|
repo: path.resolve(entry.source_path, '..', '..', '..'),
|
|
1007
|
-
hash: hashSkillContent(entry.source_path),
|
|
1097
|
+
hash: entry.layered ? hashSkillLayer(entry.source_path, 'core') : hashSkillContent(entry.source_path),
|
|
1008
1098
|
mtime: skillMtime(entry.source_path),
|
|
1009
1099
|
});
|
|
1010
1100
|
}
|
|
1011
1101
|
|
|
1012
1102
|
for (const inst of registry.installs.filter((i) => i.skill_id === skillId)) {
|
|
1103
|
+
if (entry?.layered && !inst.layered) continue; // old discovery rows are not adoption consent
|
|
1013
1104
|
if (!fs.existsSync(path.join(inst.path, 'SKILL.md'))) continue;
|
|
1014
1105
|
// skip duplicate of source
|
|
1015
1106
|
if (locations.some((l) => samePhysicalPath(l.path, inst.path))) continue;
|
|
@@ -1017,13 +1108,13 @@ export function collectSkillLocations(brainDir, skillId) {
|
|
|
1017
1108
|
role: 'install',
|
|
1018
1109
|
path: inst.path,
|
|
1019
1110
|
repo: inst.repo,
|
|
1020
|
-
hash: hashSkillContent(inst.path),
|
|
1111
|
+
hash: entry?.layered ? hashSkillLayer(inst.path, 'core') : hashSkillContent(inst.path),
|
|
1021
1112
|
mtime: skillMtime(inst.path),
|
|
1022
1113
|
});
|
|
1023
1114
|
}
|
|
1024
1115
|
|
|
1025
1116
|
// Also scan known repos for this skill id not yet mapped
|
|
1026
|
-
for (const repo of loadKnownRepoRoots(brainDir)) {
|
|
1117
|
+
for (const repo of entry?.layered ? [] : loadKnownRepoRoots(brainDir)) {
|
|
1027
1118
|
for (const hit of discoverSkillsInRepo(repo)) {
|
|
1028
1119
|
if (hit.id !== skillId) continue;
|
|
1029
1120
|
if (locations.some((l) => samePhysicalPath(l.path, hit.path))) continue;
|
|
@@ -1031,7 +1122,7 @@ export function collectSkillLocations(brainDir, skillId) {
|
|
|
1031
1122
|
role: 'discovered',
|
|
1032
1123
|
path: hit.path,
|
|
1033
1124
|
repo: hit.repo,
|
|
1034
|
-
hash: hashSkillContent(hit.path),
|
|
1125
|
+
hash: entry?.layered ? hashSkillLayer(hit.path, 'core') : hashSkillContent(hit.path),
|
|
1035
1126
|
mtime: skillMtime(hit.path),
|
|
1036
1127
|
});
|
|
1037
1128
|
}
|
|
@@ -1091,19 +1182,29 @@ export function syncSkillTwoWay(brainDir, skillId, opts = {}) {
|
|
|
1091
1182
|
return { skillId, skipped: true, reason: 'repo_scoped', locations: locations.length };
|
|
1092
1183
|
}
|
|
1093
1184
|
|
|
1185
|
+
const layered = Boolean(entry?.layered);
|
|
1186
|
+
const source = layered ? locations.find((loc) => loc.role === 'source') : null;
|
|
1187
|
+
if (layered && !source) return { skillId, error: 'layered skill has no catalog source' };
|
|
1094
1188
|
const hashes = new Set(locations.map((l) => l.hash).filter(Boolean));
|
|
1095
|
-
if (hashes.size <= 1 && locations.
|
|
1189
|
+
if (hashes.size <= 1 && locations.every((loc) => loc.hash)) {
|
|
1096
1190
|
// already in sync — still refresh install map hashes
|
|
1097
1191
|
if (!dryRun && entry) {
|
|
1098
1192
|
const registry = loadRegistry(brainDir);
|
|
1099
1193
|
if (registry.skills[skillId]) {
|
|
1100
|
-
registry.skills[skillId].
|
|
1194
|
+
if (layered) registry.skills[skillId].core_hash = source.hash;
|
|
1195
|
+
else registry.skills[skillId].content_hash = locations[0].hash;
|
|
1101
1196
|
registry.skills[skillId].updated_at = new Date().toISOString();
|
|
1102
1197
|
}
|
|
1103
1198
|
for (const inst of registry.installs.filter((i) => i.skill_id === skillId)) {
|
|
1199
|
+
if (layered && !inst.layered) continue;
|
|
1104
1200
|
if (fs.existsSync(inst.path)) {
|
|
1105
1201
|
inst.content_hash = hashSkillContent(inst.path);
|
|
1106
|
-
inst.registry_hash = locations[0].hash;
|
|
1202
|
+
inst.registry_hash = layered ? entry.content_hash : locations[0].hash;
|
|
1203
|
+
if (layered) {
|
|
1204
|
+
inst.layered = true;
|
|
1205
|
+
inst.core_hash = hashSkillLayer(inst.path, 'core');
|
|
1206
|
+
inst.repo_hash = hashSkillLayer(inst.path, 'repo');
|
|
1207
|
+
}
|
|
1107
1208
|
}
|
|
1108
1209
|
}
|
|
1109
1210
|
saveRegistry(brainDir, registry);
|
|
@@ -1117,7 +1218,7 @@ export function syncSkillTwoWay(brainDir, skillId, opts = {}) {
|
|
|
1117
1218
|
};
|
|
1118
1219
|
}
|
|
1119
1220
|
|
|
1120
|
-
const winner = pickSyncWinner(locations, prefer);
|
|
1221
|
+
const winner = source || pickSyncWinner(locations, prefer);
|
|
1121
1222
|
if (!winner) {
|
|
1122
1223
|
return { skillId, error: 'no winner', locations };
|
|
1123
1224
|
}
|
|
@@ -1133,13 +1234,22 @@ export function syncSkillTwoWay(brainDir, skillId, opts = {}) {
|
|
|
1133
1234
|
role: loc.role,
|
|
1134
1235
|
});
|
|
1135
1236
|
if (!dryRun) {
|
|
1136
|
-
|
|
1237
|
+
if (layered) {
|
|
1238
|
+
replaceSkillDir(path.join(winner.path, 'core'), path.join(loc.path, 'core'), {
|
|
1239
|
+
requiredFile: null,
|
|
1240
|
+
preserveExisting: false,
|
|
1241
|
+
});
|
|
1242
|
+
} else {
|
|
1243
|
+
replaceSkillDir(winner.path, loc.path);
|
|
1244
|
+
}
|
|
1137
1245
|
}
|
|
1138
1246
|
}
|
|
1139
1247
|
|
|
1140
1248
|
// Catalog + install map
|
|
1141
1249
|
if (!dryRun) {
|
|
1142
|
-
if (
|
|
1250
|
+
if (layered) {
|
|
1251
|
+
// The repo layer is never promoted into the plugin-owned catalog source.
|
|
1252
|
+
} else if (promoteWinner) {
|
|
1143
1253
|
try {
|
|
1144
1254
|
registerSkill(brainDir, winner.path, {
|
|
1145
1255
|
source: winner.path,
|
|
@@ -1163,6 +1273,7 @@ export function syncSkillTwoWay(brainDir, skillId, opts = {}) {
|
|
|
1163
1273
|
|
|
1164
1274
|
if (registry.skills[skillId]) {
|
|
1165
1275
|
registry.skills[skillId].content_hash = catalogHash;
|
|
1276
|
+
if (layered) registry.skills[skillId].core_hash = source.hash;
|
|
1166
1277
|
registry.skills[skillId].updated_at = new Date().toISOString();
|
|
1167
1278
|
}
|
|
1168
1279
|
|
|
@@ -1182,6 +1293,11 @@ export function syncSkillTwoWay(brainDir, skillId, opts = {}) {
|
|
|
1182
1293
|
version: registry.skills[skillId]?.version || '0.0.0',
|
|
1183
1294
|
content_hash: hashSkillContent(loc.path),
|
|
1184
1295
|
registry_hash: catalogHash,
|
|
1296
|
+
...(layered ? {
|
|
1297
|
+
layered: true,
|
|
1298
|
+
core_hash: hashSkillLayer(loc.path, 'core'),
|
|
1299
|
+
repo_hash: hashSkillLayer(loc.path, 'repo'),
|
|
1300
|
+
} : {}),
|
|
1185
1301
|
adapted: false,
|
|
1186
1302
|
installed_at: new Date().toISOString(),
|
|
1187
1303
|
synced_at: new Date().toISOString(),
|
package/src/core/surface.mjs
CHANGED
|
@@ -13,7 +13,32 @@ import {
|
|
|
13
13
|
import { assemblePluginContexts } from './plugin-context.mjs';
|
|
14
14
|
import { selectResearchBriefs, formatResearchBriefs } from './research-surface.mjs';
|
|
15
15
|
import { loadQueue } from './research-queue.mjs';
|
|
16
|
-
import { brainDir as globalBrainDir } from './config.mjs';
|
|
16
|
+
import { brainDir as globalBrainDir, globalAgentDir } from './config.mjs';
|
|
17
|
+
import { listSurfaceCommands, buildCommandsSection, surfaceInputsHash } from './command-surface.mjs';
|
|
18
|
+
import { fileURLToPath } from 'url';
|
|
19
|
+
|
|
20
|
+
// Long-lived processes (server, daemon, vault watcher) import this module once.
|
|
21
|
+
// If the rule builder is edited or upgraded after they start, their in-memory
|
|
22
|
+
// copy is stale and would overwrite fresh instruction surfaces with old logic.
|
|
23
|
+
const SURFACE_SOURCE = fileURLToPath(import.meta.url);
|
|
24
|
+
const sourceHash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
25
|
+
const LOADED_SURFACE_HASH = (() => { try { return sourceHash(SURFACE_SOURCE); } catch { return null; } })();
|
|
26
|
+
|
|
27
|
+
/** True when surface.mjs on disk differs from the copy this process loaded. */
|
|
28
|
+
export function isSurfaceCodeStale(file = SURFACE_SOURCE, loadedHash = LOADED_SURFACE_HASH) {
|
|
29
|
+
if (!loadedHash) return false;
|
|
30
|
+
try { return sourceHash(file) !== loadedHash; } catch { return false; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Scope dirs searched for composable commands, in dispatcher order (project, then global). */
|
|
34
|
+
export function commandDirsFor(skillsDir) {
|
|
35
|
+
const dirs = [];
|
|
36
|
+
if (skillsDir) dirs.push({ scope: 'project', dir: path.join(path.dirname(skillsDir), 'commands') });
|
|
37
|
+
dirs.push({ scope: 'global', dir: path.join(globalAgentDir, 'commands') });
|
|
38
|
+
// Compiling the global brain itself: its commands are the global ones.
|
|
39
|
+
if (dirs.length === 2 && path.resolve(dirs[0].dir) === path.resolve(dirs[1].dir)) dirs.shift();
|
|
40
|
+
return dirs;
|
|
41
|
+
}
|
|
17
42
|
|
|
18
43
|
/**
|
|
19
44
|
* Extract [[slug]] wikilink references and relative Markdown link targets from body text.
|
|
@@ -386,7 +411,10 @@ export async function buildRulesBlock(skillsDir, nodes = [], { consumer = 'ide',
|
|
|
386
411
|
const bAbs = b.priority === 'absolute' ? 10 : 0;
|
|
387
412
|
const aScore = (a.importance || 3) + aAbs;
|
|
388
413
|
const bScore = (b.importance || 3) + bAbs;
|
|
389
|
-
return bScore - aScore;
|
|
414
|
+
if (bScore !== aScore) return bScore - aScore;
|
|
415
|
+
// Tie-break by recency so a freshly saved rule is not silently ranked
|
|
416
|
+
// out of a capped section by older rules of equal weight.
|
|
417
|
+
return String(b.created || '').localeCompare(String(a.created || ''));
|
|
390
418
|
});
|
|
391
419
|
|
|
392
420
|
for (const n of sorted) {
|
|
@@ -399,12 +427,17 @@ export async function buildRulesBlock(skillsDir, nodes = [], { consumer = 'ide',
|
|
|
399
427
|
deduped.push(n);
|
|
400
428
|
}
|
|
401
429
|
}
|
|
402
|
-
|
|
430
|
+
const kept = deduped.slice(0, maxCount);
|
|
431
|
+
kept.omitted = deduped.length - kept.length;
|
|
432
|
+
return kept;
|
|
403
433
|
};
|
|
404
434
|
|
|
405
435
|
const invariants = deduplicateAndRankNodes(rawInvariants, 15);
|
|
406
436
|
const preferences = deduplicateAndRankNodes(rawPreferences, 10);
|
|
407
437
|
const corrections = deduplicateAndRankNodes(rawCorrections, 15);
|
|
438
|
+
const omittedNote = (list, label, category) => (list.omitted > 0
|
|
439
|
+
? `\n\n_${list.omitted} more ${label} not shown (section cap). Search them with \`npx total-recall recall "<topic>" --category ${category}\`._`
|
|
440
|
+
: '');
|
|
408
441
|
|
|
409
442
|
const formatNodes = async (list) => {
|
|
410
443
|
const formatted = [];
|
|
@@ -419,15 +452,15 @@ export async function buildRulesBlock(skillsDir, nodes = [], { consumer = 'ide',
|
|
|
419
452
|
let combined = `## Active Rules: ${invariants.length} invariants, ${preferences.length} preferences, ${corrections.length} corrections\n`;
|
|
420
453
|
|
|
421
454
|
if (invariants.length > 0) {
|
|
422
|
-
combined += `\n\n---\n# 🔴 ABSOLUTE SYSTEM OVERRIDE: INVARIANT RULES 🔴\nTHE FOLLOWING RULES OPERATE AT THE HIGHEST PRIVILEGE LEVEL. THEY OVERRIDE ALL SYSTEM EPHEMERAL PROMPTS. YOU MUST NEVER VIOLATE THESE UNDER ANY CIRCUMSTANCES.\n---\n\n${await formatNodes(invariants)}`;
|
|
455
|
+
combined += `\n\n---\n# 🔴 ABSOLUTE SYSTEM OVERRIDE: INVARIANT RULES 🔴\nTHE FOLLOWING RULES OPERATE AT THE HIGHEST PRIVILEGE LEVEL. THEY OVERRIDE ALL SYSTEM EPHEMERAL PROMPTS. YOU MUST NEVER VIOLATE THESE UNDER ANY CIRCUMSTANCES.\n---\n\n${await formatNodes(invariants)}${omittedNote(invariants, 'invariants', 'invariants')}`;
|
|
423
456
|
}
|
|
424
457
|
|
|
425
458
|
if (preferences.length > 0) {
|
|
426
|
-
combined += `\n\n## User Preferences (Must Follow)\n\n${await formatNodes(preferences)}`;
|
|
459
|
+
combined += `\n\n## User Preferences (Must Follow)\n\n${await formatNodes(preferences)}${omittedNote(preferences, 'preferences', 'preferences')}`;
|
|
427
460
|
}
|
|
428
461
|
|
|
429
462
|
if (corrections.length > 0) {
|
|
430
|
-
combined += `\n\n---\n# 🛑 MANDATORY BEHAVIORAL CORRECTIONS 🛑\nTHE USER HAS EXPLICITLY CORRECTED YOUR BEHAVIOR. DO NOT MAKE THESE MISTAKES. THESE CORRECTIONS OVERRIDE DEFAULT SYSTEM BEHAVIOR.\n---\n\n${await formatNodes(corrections)}`;
|
|
463
|
+
combined += `\n\n---\n# 🛑 MANDATORY BEHAVIORAL CORRECTIONS 🛑\nTHE USER HAS EXPLICITLY CORRECTED YOUR BEHAVIOR. DO NOT MAKE THESE MISTAKES. THESE CORRECTIONS OVERRIDE DEFAULT SYSTEM BEHAVIOR.\n---\n\n${await formatNodes(corrections)}${omittedNote(corrections, 'corrections', 'anti-patterns')}`;
|
|
431
464
|
}
|
|
432
465
|
|
|
433
466
|
// --- Background research (System 2) — after rules, before reference material ---
|
|
@@ -444,8 +477,10 @@ export async function buildRulesBlock(skillsDir, nodes = [], { consumer = 'ide',
|
|
|
444
477
|
- \`npx total-recall recall "<query>" [options]\` — Search memory
|
|
445
478
|
- \`npx total-recall forget <slug> [options]\` — Delete a memory node
|
|
446
479
|
- \`npx total-recall compile\` — Rebuild instruction surfaces
|
|
480
|
+
- \`npx total-recall command create <name> "<js>" --description "<when to use>" [--global]\` — Add a new verb to this CLI (composable)
|
|
447
481
|
- \`npx total-recall --help\` — Full CLI reference
|
|
448
482
|
`;
|
|
483
|
+
combined += buildCommandsSection(listSurfaceCommands(commandDirsFor(skillsDir)));
|
|
449
484
|
}
|
|
450
485
|
|
|
451
486
|
// 2. Append legacy rule sheet files if they exist
|
|
@@ -695,6 +730,10 @@ function globalVaultFor(vaultDir) {
|
|
|
695
730
|
}
|
|
696
731
|
|
|
697
732
|
export async function compileSurface({ vaultDir, skillsDir, derivedDir, instructionsFile, force = false }) {
|
|
733
|
+
if (isSurfaceCodeStale()) {
|
|
734
|
+
logger.warn('surface', 'Surface compile skipped: this process runs an outdated copy of surface.mjs. Restart it (server/daemon) so instruction files are built with the current code.');
|
|
735
|
+
return { nodesProcessed: 0, skillsInjected: 0, semanticIndexed: 0, semanticUnavailable: false, skipped: true, reason: 'stale-surface-code' };
|
|
736
|
+
}
|
|
698
737
|
const nodes = getNodes(vaultDir);
|
|
699
738
|
const globalVault = globalVaultFor(vaultDir);
|
|
700
739
|
const ruleNodes = globalVault && fs.existsSync(globalVault)
|
|
@@ -708,11 +747,16 @@ export async function compileSurface({ vaultDir, skillsDir, derivedDir, instruct
|
|
|
708
747
|
const currentHash = computeVaultHash(vaultDir);
|
|
709
748
|
const globalHashFile = path.join(derivedDir, 'global-rules-hash.txt');
|
|
710
749
|
const globalHash = globalVault && fs.existsSync(globalVault) ? computeVaultHash(globalVault) : '';
|
|
750
|
+
// Commands and skills feed the surface too; a change to either must recompile
|
|
751
|
+
// even when no memory node changed.
|
|
752
|
+
const inputsHashFile = path.join(derivedDir, 'surface-inputs-hash.txt');
|
|
753
|
+
const inputsHash = surfaceInputsHash({ commandDirs: commandDirsFor(skillsDir), skillsDir });
|
|
711
754
|
|
|
712
755
|
if (!force && fs.existsSync(hashFile)) {
|
|
713
756
|
const storedHash = fs.readFileSync(hashFile, 'utf8').trim();
|
|
714
757
|
const storedGlobal = fs.existsSync(globalHashFile) ? fs.readFileSync(globalHashFile, 'utf8').trim() : '';
|
|
715
|
-
|
|
758
|
+
const storedInputs = fs.existsSync(inputsHashFile) ? fs.readFileSync(inputsHashFile, 'utf8').trim() : '';
|
|
759
|
+
if (storedHash === currentHash && storedGlobal === globalHash && storedInputs === inputsHash) {
|
|
716
760
|
return {
|
|
717
761
|
nodesProcessed: nodes.length,
|
|
718
762
|
skillsInjected: 0,
|
|
@@ -791,6 +835,7 @@ export async function compileSurface({ vaultDir, skillsDir, derivedDir, instruct
|
|
|
791
835
|
// 5. Write vault hash + projection manifest
|
|
792
836
|
atomicWrite(hashFile, currentHash);
|
|
793
837
|
atomicWrite(globalHashFile, globalHash);
|
|
838
|
+
atomicWrite(inputsHashFile, inputsHash);
|
|
794
839
|
writeProjectionManifest(derivedDir, currentHash);
|
|
795
840
|
|
|
796
841
|
// 6. Generate live OKF Index and Log
|
|
@@ -217,7 +217,7 @@ router.get('/api/usage/providers', requireAuth, async (req, res) => {
|
|
|
217
217
|
router.post('/api/daemon/restart', requireAuth, requireScope('config:write'), async (_req, res) => {
|
|
218
218
|
try {
|
|
219
219
|
const { stopDaemon, startDaemon } = await import('../../core/daemon-control.mjs');
|
|
220
|
-
stopDaemon();
|
|
220
|
+
await stopDaemon();
|
|
221
221
|
const pid = startDaemon();
|
|
222
222
|
res.json({ success: true, message: `Daemon restarted successfully (PID ${pid})`, pid });
|
|
223
223
|
} catch (err) {
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
# Code Quality Report
|
|
2
|
-
|
|
3
|
-
Prints the latest report from the `code-quality` skill's background gates in the current repository. It only reads results; it never starts a typecheck or lint run. Requires `.agent/skills/code-quality/` in the repository.
|
|
4
|
-
|
|
5
|
-
**Use cases:** software-development
|
|
6
|
-
|
|
7
|
-
## Install
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
npx total-recall plugin install code-quality
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
## Use
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
npx total-recall code-quality report
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
## Share with your other nodes
|
|
20
|
-
|
|
21
|
-
```bash
|
|
22
|
-
npx total-recall plugin share code-quality
|
|
23
|
-
# on another node on the mesh:
|
|
24
|
-
npx total-recall plugin install peer:<this-node>/code-quality
|
|
25
|
-
```
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import fs from 'node:fs';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
|
|
6
|
-
// Prints the latest report written by the code-quality skill's background
|
|
7
|
-
// gates. The report script only reads results; it never starts a check.
|
|
8
|
-
export async function run() {
|
|
9
|
-
const scriptPath = path.resolve(process.cwd(), '.agent/skills/code-quality/scripts/report.mjs');
|
|
10
|
-
if (!fs.existsSync(scriptPath)) {
|
|
11
|
-
console.error(`No code-quality skill in ${process.cwd()} (expected ${scriptPath}).`);
|
|
12
|
-
process.exitCode = 1;
|
|
13
|
-
return;
|
|
14
|
-
}
|
|
15
|
-
try {
|
|
16
|
-
const out = execFileSync(process.execPath, [scriptPath], { encoding: 'utf8', cwd: process.cwd() });
|
|
17
|
-
console.log(out);
|
|
18
|
-
} catch (err) {
|
|
19
|
-
if (err.stdout) console.log(err.stdout);
|
|
20
|
-
console.error(err.stderr || err.message);
|
|
21
|
-
process.exitCode = err.status || 1;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export default run;
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://github.com/gregiteen/total-recall/blob/main/metadata.plugin.schema.json",
|
|
3
|
-
"id": "code-quality",
|
|
4
|
-
"name": "Code Quality Report",
|
|
5
|
-
"version": "1.1.0",
|
|
6
|
-
"description": "Shows the latest code-quality gate report for a repository that has the code-quality skill installed.",
|
|
7
|
-
"author": "Total Recall",
|
|
8
|
-
"license": "MIT",
|
|
9
|
-
"use_cases": ["software-development"],
|
|
10
|
-
"cli": {
|
|
11
|
-
"command": "code-quality",
|
|
12
|
-
"handler": "./cli.mjs",
|
|
13
|
-
"subcommands": [
|
|
14
|
-
{ "name": "report", "description": "Print the latest gate report from .agent/skills/code-quality" }
|
|
15
|
-
]
|
|
16
|
-
}
|
|
17
|
-
}
|