@lmzhen/dsh-evolution-core 0.1.0-rc.5 → 0.1.0-rc.51
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/lib/index.js +1585 -426
- package/lib/types/constants.d.ts +53 -0
- package/lib/types/curator.d.ts +68 -4
- package/lib/types/events.d.ts +18 -10
- package/lib/types/gates.d.ts +38 -0
- package/lib/types/index.d.ts +5 -0
- package/lib/types/io.d.ts +31 -6
- package/lib/types/learn-prompt.d.ts +19 -0
- package/lib/types/memory-store.d.ts +45 -12
- package/lib/types/mutations.d.ts +24 -0
- package/lib/types/prompts.d.ts +13 -5
- package/lib/types/quality.d.ts +57 -0
- package/lib/types/skill-store.d.ts +131 -21
- package/lib/types/state-store.d.ts +2 -15
- package/lib/types/threats.d.ts +16 -4
- package/lib/types/usage.d.ts +37 -2
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
|
-
import { dirname, join } from "node:path";
|
|
2
|
-
import { cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
1
|
+
import { basename, dirname, join } from "node:path";
|
|
2
|
+
import { cp, lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import { readFileSync } from "node:fs";
|
|
6
5
|
//#region lib/types/io.js
|
|
7
6
|
/**
|
|
8
|
-
* Structural IO seam for the
|
|
7
|
+
* Structural IO seam for the evolution plugin family.
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* (and the facade's own tests) can use `nodeEvolutionIo`.
|
|
9
|
+
* Every evolution package passes `ctx.evolutionIo.provider()`; standalone
|
|
10
|
+
* consumers (and the core's own tests) can use `nodeEvolutionIo`.
|
|
13
11
|
*/
|
|
12
|
+
/**
|
|
13
|
+
* Run `task` inside `io.transact` when the backend provides it; otherwise fall
|
|
14
|
+
* back to a plain read → task → write/remove sequence (no cross-process lock —
|
|
15
|
+
* callers keep their single-process serialize chain as the second layer).
|
|
16
|
+
*/
|
|
17
|
+
async function transactIo(io, path, task) {
|
|
18
|
+
if (io.transact) {
|
|
19
|
+
await io.transact(path, task);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const next = await task(await io.readText(path));
|
|
23
|
+
if (next === null) await io.remove(path);
|
|
24
|
+
else await io.writeText(path, next);
|
|
25
|
+
}
|
|
14
26
|
/** Lazy adapter over an IO provider registry, shared by every evolution consumer. */
|
|
15
27
|
function evolutionIoAdapter(provider) {
|
|
16
28
|
return {
|
|
@@ -20,23 +32,95 @@ function evolutionIoAdapter(provider) {
|
|
|
20
32
|
list: (path) => provider().list(path),
|
|
21
33
|
exists: (path) => provider().exists(path),
|
|
22
34
|
rename: (path, destination) => provider().rename(path, destination),
|
|
23
|
-
copy: (path, destination) => provider().copy(path, destination)
|
|
35
|
+
copy: (path, destination) => provider().copy(path, destination),
|
|
36
|
+
size: (path) => {
|
|
37
|
+
const io = provider();
|
|
38
|
+
return io.size ? io.size(path) : Promise.resolve(null);
|
|
39
|
+
},
|
|
40
|
+
transact: (path, task) => {
|
|
41
|
+
const io = provider();
|
|
42
|
+
return io.transact ? io.transact(path, task) : transactIo(io, path, task);
|
|
43
|
+
},
|
|
44
|
+
isSymlink: (path) => {
|
|
45
|
+
const io = provider();
|
|
46
|
+
return io.isSymlink ? io.isSymlink(path) : Promise.resolve(null);
|
|
47
|
+
}
|
|
24
48
|
};
|
|
25
49
|
}
|
|
26
50
|
function nodeEvolutionIo() {
|
|
51
|
+
const isMissing = (error) => {
|
|
52
|
+
const code = error?.code;
|
|
53
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Cross-process write lock (claw `withFileLock` parity): an O_EXCL lock file
|
|
57
|
+
* guards the atomic write; a >5s-old lock is treated as stale and taken
|
|
58
|
+
* over. After the retry budget the write proceeds unlocked — the lock is a
|
|
59
|
+
* best-effort accommodation for multi-process deployments, never a read of
|
|
60
|
+
* availability.
|
|
61
|
+
*/
|
|
62
|
+
const withWriteLock = async (path, task) => {
|
|
63
|
+
const lock = `${path}.lock`;
|
|
64
|
+
for (let attempt = 0; attempt < 10; attempt += 1) try {
|
|
65
|
+
await writeFile(lock, String(process.pid), { flag: "wx" });
|
|
66
|
+
try {
|
|
67
|
+
return await task();
|
|
68
|
+
} finally {
|
|
69
|
+
await rm(lock, { force: true }).catch(() => {});
|
|
70
|
+
}
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error?.code !== "EEXIST") throw error;
|
|
73
|
+
try {
|
|
74
|
+
const st = await stat(lock);
|
|
75
|
+
if (Date.now() - st.mtimeMs > 5e3) {
|
|
76
|
+
try {
|
|
77
|
+
await rm(lock, { force: true });
|
|
78
|
+
} catch {}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
85
|
+
}
|
|
86
|
+
return await task();
|
|
87
|
+
};
|
|
27
88
|
return {
|
|
28
89
|
async readText(path) {
|
|
29
90
|
try {
|
|
30
91
|
return await readFile(path, "utf8");
|
|
31
|
-
} catch {
|
|
32
|
-
return null;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (isMissing(error)) return null;
|
|
94
|
+
throw error;
|
|
33
95
|
}
|
|
34
96
|
},
|
|
35
97
|
async writeText(path, content) {
|
|
36
98
|
await mkdir(dirname(path), { recursive: true });
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
99
|
+
await withWriteLock(path, async () => {
|
|
100
|
+
const tmp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
101
|
+
await writeFile(tmp, content, "utf8");
|
|
102
|
+
await rename(tmp, path);
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
async transact(path, task) {
|
|
106
|
+
await mkdir(dirname(path), { recursive: true });
|
|
107
|
+
await withWriteLock(path, async () => {
|
|
108
|
+
let current;
|
|
109
|
+
try {
|
|
110
|
+
current = await readFile(path, "utf8");
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (isMissing(error)) current = null;
|
|
113
|
+
else throw error;
|
|
114
|
+
}
|
|
115
|
+
const next = await task(current);
|
|
116
|
+
if (next === null) {
|
|
117
|
+
await rm(path, { force: true });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const tmp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
121
|
+
await writeFile(tmp, next, "utf8");
|
|
122
|
+
await rename(tmp, path);
|
|
123
|
+
});
|
|
40
124
|
},
|
|
41
125
|
async remove(path) {
|
|
42
126
|
await rm(path, {
|
|
@@ -47,16 +131,18 @@ function nodeEvolutionIo() {
|
|
|
47
131
|
async list(path) {
|
|
48
132
|
try {
|
|
49
133
|
return await readdir(path);
|
|
50
|
-
} catch {
|
|
51
|
-
return [];
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (isMissing(error)) return [];
|
|
136
|
+
throw error;
|
|
52
137
|
}
|
|
53
138
|
},
|
|
54
139
|
async exists(path) {
|
|
55
140
|
try {
|
|
56
141
|
await stat(path);
|
|
57
142
|
return true;
|
|
58
|
-
} catch {
|
|
59
|
-
return false;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (isMissing(error)) return false;
|
|
145
|
+
throw error;
|
|
60
146
|
}
|
|
61
147
|
},
|
|
62
148
|
async rename(path, destination) {
|
|
@@ -69,13 +155,24 @@ function nodeEvolutionIo() {
|
|
|
69
155
|
recursive: true,
|
|
70
156
|
force: true
|
|
71
157
|
});
|
|
158
|
+
},
|
|
159
|
+
async size(path) {
|
|
160
|
+
try {
|
|
161
|
+
return (await stat(path)).size;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (isMissing(error)) return null;
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
async isSymlink(path) {
|
|
168
|
+
try {
|
|
169
|
+
return (await lstat(path)).isSymbolicLink();
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
72
173
|
}
|
|
73
174
|
};
|
|
74
175
|
}
|
|
75
|
-
/** Absolute path helper kept separate so stores stay platform-correct. */
|
|
76
|
-
function childPath(parent, ...parts) {
|
|
77
|
-
return join(parent, ...parts);
|
|
78
|
-
}
|
|
79
176
|
//#endregion
|
|
80
177
|
//#region lib/types/usage.js
|
|
81
178
|
/**
|
|
@@ -100,22 +197,65 @@ function emptyRecord() {
|
|
|
100
197
|
archived_at: null
|
|
101
198
|
};
|
|
102
199
|
}
|
|
103
|
-
|
|
200
|
+
const isTimestamp = (value) => value === null || typeof value === "string";
|
|
201
|
+
/**
|
|
202
|
+
* Field-level normalization for one sidecar record (rc.42 audit P2-3): the
|
|
203
|
+
* spread used to copy any junk through verbatim, so a corrupted file could
|
|
204
|
+
* carry `use_count: "3"` into the quality math and lifecycle comparisons as
|
|
205
|
+
* NaN. Every field falls back to its `emptyRecord()` baseline unless it has
|
|
206
|
+
* exactly the declared type; an invalid `created_at` anchors the age clock at
|
|
207
|
+
* now (first-sight defer semantics for a record whose age is unknowable).
|
|
208
|
+
* Pure — exported for unit tests; `loadUsage` is the production caller.
|
|
209
|
+
*/
|
|
210
|
+
function normalizeUsageRecord(record) {
|
|
211
|
+
const base = emptyRecord();
|
|
212
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) return base;
|
|
213
|
+
const raw = record;
|
|
214
|
+
const num = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
215
|
+
const bool = (value, fallback) => typeof value === "boolean" ? value : fallback;
|
|
216
|
+
return {
|
|
217
|
+
created_by: typeof raw.created_by === "string" ? raw.created_by : null,
|
|
218
|
+
use_count: num(raw.use_count, base.use_count),
|
|
219
|
+
view_count: num(raw.view_count, base.view_count),
|
|
220
|
+
patch_count: num(raw.patch_count, base.patch_count),
|
|
221
|
+
last_used_at: isTimestamp(raw.last_used_at) ? raw.last_used_at : base.last_used_at,
|
|
222
|
+
last_viewed_at: isTimestamp(raw.last_viewed_at) ? raw.last_viewed_at : base.last_viewed_at,
|
|
223
|
+
last_patched_at: isTimestamp(raw.last_patched_at) ? raw.last_patched_at : base.last_patched_at,
|
|
224
|
+
created_at: typeof raw.created_at === "string" ? raw.created_at : base.created_at,
|
|
225
|
+
state: raw.state === "stale" || raw.state === "archived" ? raw.state : "active",
|
|
226
|
+
pinned: bool(raw.pinned, base.pinned),
|
|
227
|
+
archived_at: isTimestamp(raw.archived_at) ? raw.archived_at : base.archived_at,
|
|
228
|
+
quality_score: typeof raw.quality_score === "number" && Number.isFinite(raw.quality_score) ? raw.quality_score : void 0,
|
|
229
|
+
quality_warn: typeof raw.quality_warn === "boolean" ? raw.quality_warn : void 0
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/** Parse a raw usage sidecar; malformed content reads as empty (best-effort telemetry). */
|
|
233
|
+
function parseUsage(raw) {
|
|
104
234
|
const map = /* @__PURE__ */ new Map();
|
|
105
|
-
|
|
106
|
-
|
|
235
|
+
if (raw === null) return map;
|
|
236
|
+
try {
|
|
107
237
|
const parsed = JSON.parse(raw);
|
|
108
|
-
for (const [name, record] of Object.entries(parsed))
|
|
109
|
-
const base = emptyRecord();
|
|
110
|
-
map.set(name, {
|
|
111
|
-
...base,
|
|
112
|
-
...record,
|
|
113
|
-
state: record.state === "stale" || record.state === "archived" ? record.state : "active"
|
|
114
|
-
});
|
|
115
|
-
}
|
|
238
|
+
for (const [name, record] of Object.entries(parsed)) map.set(name, normalizeUsageRecord(record));
|
|
116
239
|
} catch {}
|
|
117
240
|
return map;
|
|
118
241
|
}
|
|
242
|
+
async function loadUsage(root, io = nodeEvolutionIo()) {
|
|
243
|
+
return parseUsage(await io.readText(usageFile(root)));
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Atomic read-modify-write on the usage sidecar (rc.50 P2-2): `task` receives
|
|
247
|
+
* the map parsed from the current on-disk state and may mutate it; the result
|
|
248
|
+
* is persisted inside the same transact so a second process sharing DSH_HOME
|
|
249
|
+
* cannot interleave its RMW and lose a counter update. Callers keep their own
|
|
250
|
+
* single-process serialize chain as the second layer.
|
|
251
|
+
*/
|
|
252
|
+
async function mutateUsage(root, io, task) {
|
|
253
|
+
await transactIo(io, usageFile(root), async (current) => {
|
|
254
|
+
const map = parseUsage(current);
|
|
255
|
+
await task(map);
|
|
256
|
+
return JSON.stringify(Object.fromEntries(map.entries()), null, 2);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
119
259
|
async function saveUsage(root, map, io = nodeEvolutionIo()) {
|
|
120
260
|
const obj = Object.fromEntries(map.entries());
|
|
121
261
|
await io.writeText(usageFile(root), JSON.stringify(obj, null, 2));
|
|
@@ -155,15 +295,169 @@ function latestActivityAt(record) {
|
|
|
155
295
|
if (values.length === 0) return null;
|
|
156
296
|
return values.sort().reverse()[0] ?? null;
|
|
157
297
|
}
|
|
298
|
+
/**
|
|
299
|
+
* Curator suppression sidecar: built-in skills the curator has archived stay
|
|
300
|
+
* suppressed across re-seeds, so the lifecycle never fights a re-created
|
|
301
|
+
* bundled skill. Best-effort load/save, mirroring the usage sidecar posture.
|
|
302
|
+
* Versioned shape ({ version, names }) with legacy plain-array compat.
|
|
303
|
+
*/
|
|
304
|
+
const SUPPRESSED_FILE_VERSION = 1;
|
|
305
|
+
function suppressedFile(root) {
|
|
306
|
+
return join(root, ".curator-suppressed.json");
|
|
307
|
+
}
|
|
308
|
+
async function loadSuppressedNames(root, io = nodeEvolutionIo()) {
|
|
309
|
+
return parseSuppressed(await io.readText(suppressedFile(root)));
|
|
310
|
+
}
|
|
311
|
+
function parseSuppressed(raw) {
|
|
312
|
+
if (raw === null) return /* @__PURE__ */ new Set();
|
|
313
|
+
try {
|
|
314
|
+
const parsed = JSON.parse(raw);
|
|
315
|
+
const names = Array.isArray(parsed) ? parsed : typeof parsed === "object" && parsed !== null && Array.isArray(parsed.names) ? parsed.names : [];
|
|
316
|
+
return new Set(names.filter((entry) => typeof entry === "string"));
|
|
317
|
+
} catch {
|
|
318
|
+
return /* @__PURE__ */ new Set();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
async function saveSuppressedNames(root, names, io = nodeEvolutionIo()) {
|
|
322
|
+
await io.writeText(suppressedFile(root), JSON.stringify({
|
|
323
|
+
version: 1,
|
|
324
|
+
names: [...names].sort()
|
|
325
|
+
}, null, 2));
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Atomic read-modify-write on the suppression sidecar (rc.50 P2-2): `task`
|
|
329
|
+
* receives the set parsed from the current on-disk state and may mutate it;
|
|
330
|
+
* the result is persisted inside the same transact so a second process
|
|
331
|
+
* sharing DSH_HOME cannot interleave its RMW. Best-effort posture unchanged.
|
|
332
|
+
*/
|
|
333
|
+
async function updateSuppressedNames(root, io, task) {
|
|
334
|
+
await transactIo(io, suppressedFile(root), async (current) => {
|
|
335
|
+
const names = parseSuppressed(current);
|
|
336
|
+
await task(names);
|
|
337
|
+
return JSON.stringify({
|
|
338
|
+
version: 1,
|
|
339
|
+
names: [...names].sort()
|
|
340
|
+
}, null, 2);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
//#endregion
|
|
344
|
+
//#region lib/types/constants.js
|
|
345
|
+
/**
|
|
346
|
+
* Shared constants for the dsh-evolution plugin family.
|
|
347
|
+
*
|
|
348
|
+
* Two classes of value live here, deliberately separated by section so future
|
|
349
|
+
* edits do not blur the semantic boundary:
|
|
350
|
+
*
|
|
351
|
+
* 1. **Fixed protocol/format/security invariants** — changing these breaks an
|
|
352
|
+
* on-disk format, a naming/format contract, a path-security boundary, or a
|
|
353
|
+
* cross-component invariant. They are NOT exposed as deployment config.
|
|
354
|
+
*
|
|
355
|
+
* 2. **Cross-package shared tunable defaults** — the same semantic default is
|
|
356
|
+
* read (with a config override path) by more than one package (e.g.
|
|
357
|
+
* `evolution-policy` and `evolution-curator` both default `staleAfterDays`
|
|
358
|
+
* to 30). Centralizing them here means one authoritative default: a config
|
|
359
|
+
* override still applies per package, but the fallback is single-sourced.
|
|
360
|
+
*
|
|
361
|
+
* Package-private tunables (used by exactly one package) stay in that package,
|
|
362
|
+
* not here — see evolution-replay's `DEFAULT_WEIGHTS` and evolution-feedback's
|
|
363
|
+
* threshold, which are intentionally left where they are used.
|
|
364
|
+
* @module @lmzhen/dsh-evolution-core
|
|
365
|
+
*/
|
|
366
|
+
/** Skill frontmatter `name` validated for the file name (lowercase + hyphen). */
|
|
367
|
+
const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
368
|
+
/** Allowed skill support-file subdirectories (path-traversal boundary). */
|
|
369
|
+
const SUPPORT_DIRS = [
|
|
370
|
+
"references",
|
|
371
|
+
"templates",
|
|
372
|
+
"scripts",
|
|
373
|
+
"assets"
|
|
374
|
+
];
|
|
375
|
+
/** Delimiter between durable memory entries (on-disk storage format). */
|
|
376
|
+
const ENTRY_DELIMITER = "\n§\n";
|
|
377
|
+
/** Built-in skill names the curator must never lifecycle-manage. */
|
|
378
|
+
const PROTECTED_BUILTIN_SKILLS = new Set(["plan"]);
|
|
379
|
+
const MAX_SKILL_NAME_LENGTH = 64;
|
|
380
|
+
const MAX_DESCRIPTION_LENGTH = 1024;
|
|
381
|
+
const MAX_SKILL_CONTENT_CHARS = 1e5;
|
|
382
|
+
const MAX_SKILL_FILE_BYTES = 1048576;
|
|
383
|
+
const DEFAULT_REVIEW_MEMORY_INTERVAL = 10;
|
|
384
|
+
const DEFAULT_REVIEW_SKILL_INTERVAL = 10;
|
|
385
|
+
/** Skill-review completion channel trigger mode: 'cadence' | 'completion' | 'both'. */
|
|
386
|
+
const DEFAULT_SKILL_REVIEW_TRIGGER = "both";
|
|
387
|
+
/** Cumulative session tool calls before a session counts as "proven long" for the completion channel. */
|
|
388
|
+
const DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS = 20;
|
|
389
|
+
const DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS = 3;
|
|
390
|
+
const DEFAULT_SUBSTANTIVE_MIN_USER_CHARS = 200;
|
|
391
|
+
const DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS = 500;
|
|
392
|
+
const DEFAULT_MAX_OPS_PER_PLAN = 32;
|
|
393
|
+
const DEFAULT_CURATOR_INTERVAL_HOURS = 168;
|
|
394
|
+
const DEFAULT_MIN_IDLE_HOURS = 2;
|
|
395
|
+
const DEFAULT_STALE_AFTER_DAYS = 30;
|
|
396
|
+
const DEFAULT_ARCHIVE_AFTER_DAYS = 90;
|
|
397
|
+
const DEFAULT_MEMORY_CHAR_LIMIT = 2200;
|
|
398
|
+
const DEFAULT_USER_CHAR_LIMIT = 1375;
|
|
399
|
+
/** Consolidation-failure backoff cap, shared by MemoryStore and memory-files' Config default. */
|
|
400
|
+
const DEFAULT_CONSOLIDATION_FAILURES = 3;
|
|
401
|
+
const DEFAULT_SKILL_CONTENT_CHARS = 1e5;
|
|
402
|
+
//#endregion
|
|
403
|
+
//#region lib/types/gates.js
|
|
404
|
+
/**
|
|
405
|
+
* The control-plane protection sets, held once and queried everywhere
|
|
406
|
+
* (decision B, rc.44 plan M2): the lifecycle engine, the scope view, the LLM
|
|
407
|
+
* nomination gate and the control-plane consolidate all answer "is this name
|
|
408
|
+
* off limits — and why" from the same instance, so the gate sets can never
|
|
409
|
+
* drift apart the way the three pre-rc.46 implementations did.
|
|
410
|
+
*
|
|
411
|
+
* Scope boundary: a GateSet covers NAME-SET protections only. Marker-based
|
|
412
|
+
* protections (pinned / bundled / hub-installed) are file markers resolved by
|
|
413
|
+
* `SkillLibrary.writeProtection` / `deleteProtection` — they depend on the
|
|
414
|
+
* filesystem and the write origin, not on a name list.
|
|
415
|
+
* @module @lmzhen/dsh-evolution-core
|
|
416
|
+
*/
|
|
417
|
+
var EvolutionGateSet = class {
|
|
418
|
+
exclude;
|
|
419
|
+
referenced;
|
|
420
|
+
suppressed;
|
|
421
|
+
constructor(inputs = {}) {
|
|
422
|
+
this.exclude = inputs.exclude ?? /* @__PURE__ */ new Set();
|
|
423
|
+
this.referenced = inputs.referenced ?? /* @__PURE__ */ new Set();
|
|
424
|
+
this.suppressed = inputs.suppressed ?? /* @__PURE__ */ new Set();
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* The first protection blocking this name, or null. Any hit blocks — the
|
|
428
|
+
* order is diagnostic only, so a name in two sets reports the first.
|
|
429
|
+
*/
|
|
430
|
+
blockReason(name) {
|
|
431
|
+
if (this.exclude.has(name)) return "excluded";
|
|
432
|
+
if (this.referenced.has(name)) return "referenced";
|
|
433
|
+
if (this.suppressed.has(name)) return "suppressed";
|
|
434
|
+
if (PROTECTED_BUILTIN_SKILLS.has(name)) return "protected-builtin";
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
isBlocked(name) {
|
|
438
|
+
return this.blockReason(name) !== null;
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
/** Build a GateSet from the curator-style config field names. */
|
|
442
|
+
function createGateSet(config) {
|
|
443
|
+
return new EvolutionGateSet({
|
|
444
|
+
exclude: config.excludeSkillNames,
|
|
445
|
+
referenced: config.referencedSkillNames,
|
|
446
|
+
suppressed: config.suppressedNames
|
|
447
|
+
});
|
|
448
|
+
}
|
|
158
449
|
//#endregion
|
|
159
450
|
//#region lib/types/curator.js
|
|
160
451
|
/**
|
|
161
452
|
* Deterministic skill curator: active → stale → archived transitions.
|
|
162
|
-
* Pure function
|
|
453
|
+
* Pure function with one deliberate side effect: records in the passed
|
|
454
|
+
* `usage` map are MUTATED (state/archived_at) to carry the transition — the
|
|
455
|
+
* caller owns the map and decides whether to clone first (dry-run) or persist
|
|
456
|
+
* after. File moves are performed by SkillLibrary.
|
|
163
457
|
*/
|
|
164
|
-
const PROTECTED_BUILTIN_SKILLS = new Set(["plan"]);
|
|
165
458
|
function buildCuratorRunReport(input) {
|
|
166
459
|
return {
|
|
460
|
+
schemaVersion: 1,
|
|
167
461
|
runId: input.runId,
|
|
168
462
|
startedAt: input.startedAt,
|
|
169
463
|
finishedAt: input.finishedAt,
|
|
@@ -172,25 +466,144 @@ function buildCuratorRunReport(input) {
|
|
|
172
466
|
archiveCandidates: [...input.archiveCandidates],
|
|
173
467
|
archived: [...input.archived],
|
|
174
468
|
failed: [...input.failed],
|
|
175
|
-
...input.snapshotPath === void 0 ? {} : { snapshotPath: input.snapshotPath }
|
|
469
|
+
...input.snapshotPath === void 0 ? {} : { snapshotPath: input.snapshotPath },
|
|
470
|
+
...input.llmReviewEnabled === void 0 ? {} : { llmReviewEnabled: input.llmReviewEnabled }
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Render a curator run report as a compact human-readable markdown digest
|
|
475
|
+
* (G6): run metadata first, then the notable sections (archived / failed /
|
|
476
|
+
* stale candidates / LLM nominations).
|
|
477
|
+
*/
|
|
478
|
+
function renderCuratorReportMarkdown(report) {
|
|
479
|
+
const lines = [
|
|
480
|
+
`# Curator run ${report.runId}`,
|
|
481
|
+
"",
|
|
482
|
+
`- **Started** ${report.startedAt}`,
|
|
483
|
+
`- **Finished** ${report.finishedAt}`,
|
|
484
|
+
`- **Stale candidates**: ${report.staleCandidates.length}`,
|
|
485
|
+
`- **LLM nominations**: ${report.llmNominations.length}`,
|
|
486
|
+
`- **Archived**: ${report.archived.length}`,
|
|
487
|
+
`- **Failed**: ${report.failed.length}`,
|
|
488
|
+
...report.snapshotPath === void 0 ? [] : [`- **Snapshot**: ${report.snapshotPath}`],
|
|
489
|
+
...report.llmReviewEnabled === void 0 ? [] : [`- **llmReview**: ${report.llmReviewEnabled}`]
|
|
490
|
+
];
|
|
491
|
+
const section = (title, items) => items.length === 0 ? [] : [
|
|
492
|
+
"",
|
|
493
|
+
`## ${title}`,
|
|
494
|
+
"",
|
|
495
|
+
...items.map((item) => `- ${item}`)
|
|
496
|
+
];
|
|
497
|
+
return [
|
|
498
|
+
...lines,
|
|
499
|
+
...section("Archived", report.archived.map((item) => `${item.name} (${item.reason})`)),
|
|
500
|
+
...section("Failed", report.failed.map((item) => `${item.name}: ${item.reason}`)),
|
|
501
|
+
...section("Stale candidates", report.staleCandidates),
|
|
502
|
+
...section("LLM nominations", report.llmNominations),
|
|
503
|
+
""
|
|
504
|
+
].join("\n");
|
|
505
|
+
}
|
|
506
|
+
const NOMINATION_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
507
|
+
/**
|
|
508
|
+
* Parse the curator LLM's YAML nomination block (consolidations + prunings).
|
|
509
|
+
* Line-oriented and lenient by design: the LLM output is advisory, every name
|
|
510
|
+
* is re-validated against the tree before any file move happens downstream.
|
|
511
|
+
*/
|
|
512
|
+
function parseCuratorNominations(text) {
|
|
513
|
+
const prunings = [];
|
|
514
|
+
const consolidations = [];
|
|
515
|
+
let section = null;
|
|
516
|
+
let currentFrom = "";
|
|
517
|
+
for (const line of text.split("\n")) {
|
|
518
|
+
const consolidated = /^\s*-\s*from:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
|
|
519
|
+
if (consolidated) {
|
|
520
|
+
section = "consolidations";
|
|
521
|
+
currentFrom = consolidated[1] ?? "";
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
const into = /^\s*into:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
|
|
525
|
+
if (into) {
|
|
526
|
+
const intoName = into[1] ?? "";
|
|
527
|
+
if (section === "consolidations" && currentFrom !== "" && currentFrom !== intoName) consolidations.push({
|
|
528
|
+
from: currentFrom,
|
|
529
|
+
into: intoName
|
|
530
|
+
});
|
|
531
|
+
currentFrom = "";
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
const pruned = /^\s*-\s*name:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
|
|
535
|
+
if (pruned) {
|
|
536
|
+
section = "prunings";
|
|
537
|
+
const name = pruned[1];
|
|
538
|
+
if (name) prunings.push(name);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const valid = (name) => NOMINATION_NAME_RE.test(name);
|
|
542
|
+
return {
|
|
543
|
+
prunings: prunings.filter(valid),
|
|
544
|
+
consolidations: consolidations.filter((item) => valid(item.from) && valid(item.into))
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* The lifecycle-candidate gate, shared by the transition engine and the scope
|
|
549
|
+
* view so the two can never disagree: records failing ANY of these gates are
|
|
550
|
+
* outside the managed scope.
|
|
551
|
+
*/
|
|
552
|
+
function lifecycleCandidate(name, record, config, bundled, gates = createGateSet(config)) {
|
|
553
|
+
if (record.pinned) return false;
|
|
554
|
+
if (gates.isBlocked(name)) return false;
|
|
555
|
+
if (!(record.created_by === "agent" || config.manageUnmanaged === true) && !(config.pruneBuiltins === true && bundled)) return false;
|
|
556
|
+
if (record.state === "archived") return false;
|
|
557
|
+
return true;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Read-only scope classification, derived from the SAME gate the transition
|
|
561
|
+
* engine uses (`lifecycleCandidate`), so the view always predicts what a
|
|
562
|
+
* curator pass may touch. `protectedNames` carries the marker info the usage
|
|
563
|
+
* records lack (bundled / hub-installed / pinned from `SkillLibrary.list()`).
|
|
564
|
+
*/
|
|
565
|
+
function computeScopeView(usage, config, protectedNames, gates) {
|
|
566
|
+
const managed = [];
|
|
567
|
+
const watched = [];
|
|
568
|
+
const qualityWarned = [];
|
|
569
|
+
const exempted = [];
|
|
570
|
+
const protectedSet = /* @__PURE__ */ new Set();
|
|
571
|
+
const gateSet = gates ?? createGateSet(config);
|
|
572
|
+
for (const [name, record] of usage) {
|
|
573
|
+
if (gateSet.exclude.has(name) || gateSet.referenced.has(name)) {
|
|
574
|
+
exempted.push(name);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
const bundled = config.bundledNames?.has(name) === true;
|
|
578
|
+
const suppressed = gateSet.suppressed.has(name);
|
|
579
|
+
if (record.pinned || bundled || suppressed || protectedNames?.has(name) === true) protectedSet.add(name);
|
|
580
|
+
if (lifecycleCandidate(name, record, config, bundled, gateSet)) {
|
|
581
|
+
managed.push(name);
|
|
582
|
+
if (record.state === "stale" || record.quality_warn === true) watched.push(name);
|
|
583
|
+
if (record.quality_warn === true) qualityWarned.push(name);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
managed: managed.sort(),
|
|
588
|
+
watched: watched.sort(),
|
|
589
|
+
qualityWarned: qualityWarned.sort(),
|
|
590
|
+
exempted: exempted.sort(),
|
|
591
|
+
protected: [...protectedSet].sort()
|
|
176
592
|
};
|
|
177
593
|
}
|
|
178
594
|
function daysSince(iso, created, now) {
|
|
179
595
|
return (now - new Date(iso ?? created).getTime()) / 864e5;
|
|
180
596
|
}
|
|
181
|
-
function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Date()) {
|
|
597
|
+
function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Date(), gates) {
|
|
182
598
|
const result = {
|
|
183
599
|
transitions: [],
|
|
184
600
|
archive: [],
|
|
185
601
|
reactivate: [],
|
|
186
602
|
markStale: []
|
|
187
603
|
};
|
|
604
|
+
const gateSet = gates ?? createGateSet(config);
|
|
188
605
|
for (const [name, record] of usage) {
|
|
189
|
-
if (record.
|
|
190
|
-
if (config.excludeSkillNames?.has(name)) continue;
|
|
191
|
-
if (record.created_by !== "agent" && config.manageUnmanaged !== true) continue;
|
|
192
|
-
if (PROTECTED_BUILTIN_SKILLS.has(name)) continue;
|
|
193
|
-
if (record.state === "archived") continue;
|
|
606
|
+
if (!lifecycleCandidate(name, record, config, config.bundledNames?.has(name) === true, gateSet)) continue;
|
|
194
607
|
const age = daysSince(null, record.created_at, now.getTime());
|
|
195
608
|
if (record.use_count === 0 && age < config.staleAfterDays) continue;
|
|
196
609
|
const idle = daysSince(latestActivityAt(record), record.created_at, now.getTime());
|
|
@@ -242,6 +655,225 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
|
|
|
242
655
|
return result;
|
|
243
656
|
}
|
|
244
657
|
//#endregion
|
|
658
|
+
//#region lib/types/prompts.js
|
|
659
|
+
/**
|
|
660
|
+
* Review and curation prompts adapted from Hermes Agent
|
|
661
|
+
* `agent/background_review.py`, `agent/curator.py`, and
|
|
662
|
+
* `agent/learn_prompt.py`, with tool names translated to the DSH-native
|
|
663
|
+
* catalog (`memory`, `skill_manage`, `skill`, `bash`, `str_replace_editor`).
|
|
664
|
+
*
|
|
665
|
+
* Every prompt is pinned in a versioned bundle. Review workers verify the
|
|
666
|
+
* bundle digest before spending a model call, so a partially-patched
|
|
667
|
+
* deployment fails closed instead of silently running a truncated prompt.
|
|
668
|
+
*/
|
|
669
|
+
/**
|
|
670
|
+
* Prompt bundle identity. Bump both id and version whenever a prompt's text
|
|
671
|
+
* changes semantically: the bundle digest is the fail-closed signal for
|
|
672
|
+
* review workers, so a stale id across deployments must be distinguishable.
|
|
673
|
+
*/
|
|
674
|
+
const PROMPT_BUNDLE_ID = "dsh-evolution@3";
|
|
675
|
+
const PROMPT_BUNDLE_VERSION = 3;
|
|
676
|
+
const MEMORY_REVIEW_PROMPT = `[Auto-review — Memory]
|
|
677
|
+
Review the conversation above and consider saving to memory if appropriate.
|
|
678
|
+
|
|
679
|
+
Focus on:
|
|
680
|
+
1. Has the user revealed things about themselves — persona, desires, preferences, or personal details worth remembering?
|
|
681
|
+
2. Has the user expressed expectations about how you should behave, their work style, or ways they want you to operate?
|
|
682
|
+
|
|
683
|
+
If something stands out, save it using the memory tool.
|
|
684
|
+
If nothing is worth saving, just say "Nothing to save." and stop.`;
|
|
685
|
+
const SKILL_REVIEW_PROMPT = `[Auto-review — Skills]
|
|
686
|
+
Review the conversation above and update the skill library. Be ACTIVE — most sessions produce at least one skill update, even if small.
|
|
687
|
+
|
|
688
|
+
Target shape: CLASS-LEVEL skills with a rich SKILL.md and a references/ directory for session-specific detail. Not a flat list of narrow one-session skills.
|
|
689
|
+
|
|
690
|
+
Signals that warrant action:
|
|
691
|
+
- The user corrected your style, tone, format, verbosity, workflow, or approach.
|
|
692
|
+
- A non-trivial technique, fix, workaround, or debugging path emerged.
|
|
693
|
+
- A loaded skill turned out wrong, missing, or outdated — patch it now.
|
|
694
|
+
|
|
695
|
+
Only update skills you loaded or read in THIS session; never touch skills you have not read.
|
|
696
|
+
|
|
697
|
+
Preference order:
|
|
698
|
+
1. Patch a skill that was loaded or read this session.
|
|
699
|
+
2. Patch an existing umbrella skill.
|
|
700
|
+
3. Add references/, templates/, or scripts/ support under an existing skill.
|
|
701
|
+
4. Create a new class-level umbrella skill only when nothing fits.
|
|
702
|
+
|
|
703
|
+
Protected skills (bundled/hub-installed) must not be edited. Pinned skills are read-only to the background review: the pinned write guard refuses background changes, so only the foreground may update or archive them.
|
|
704
|
+
|
|
705
|
+
Do NOT capture:
|
|
706
|
+
- Environment-dependent failures (missing binaries, unconfigured credentials).
|
|
707
|
+
- Negative claims about tools ("browser tools do not work").
|
|
708
|
+
- Transient errors that resolved during the session.
|
|
709
|
+
- One-off task narratives.
|
|
710
|
+
|
|
711
|
+
If a tool failed because of setup state, capture the FIX under an existing setup skill — never "this tool does not work" as a standalone constraint.
|
|
712
|
+
|
|
713
|
+
"Nothing to save." is a real option but should NOT be the default.`;
|
|
714
|
+
const COMBINED_REVIEW_PROMPT = `[Auto-review]
|
|
715
|
+
Review the conversation above and update two things.
|
|
716
|
+
|
|
717
|
+
**Memory**: who the user is. Save durable user preferences, personal details, and expectations with the memory tool.
|
|
718
|
+
|
|
719
|
+
**Skills**: how to do this class of task. Be ACTIVE. Only update skills you loaded or read in THIS session. Follow the same class-level umbrella policy, preference order, protected-skill rules, and do-not-capture list as a skill review.
|
|
720
|
+
|
|
721
|
+
Act on whichever dimension has real signal. If genuinely nothing stands out on either, say "Nothing to save." and stop — but don't reach for that conclusion as a default.`;
|
|
722
|
+
const CURATOR_PROMPT = `You are the skill curator. Maintain a healthy, class-level skill library, not a flat pile of narrow one-session skills.
|
|
723
|
+
|
|
724
|
+
The goal is a LIBRARY OF CLASS-LEVEL INSTRUCTIONS. A skill collection of many narrow skills where each captures one session's specific bug is a FAILURE of the library. An agent searching skills matches on descriptions, not exact names; one broad umbrella with labeled subsections beats five narrow siblings for discoverability.
|
|
725
|
+
|
|
726
|
+
Right target shape: class-level skills with rich SKILL.md + references/, templates/, scripts/ support files for session-specific detail.
|
|
727
|
+
|
|
728
|
+
Hard rules:
|
|
729
|
+
1. NEVER hard-delete a skill. Archive (moving to .archive/) is the maximum destructive action; archives are recoverable, deletion is not.
|
|
730
|
+
2. Do not touch bundled, hub-installed, pinned, or scheduled-task-referenced (\`referenced\`) skills. Referenced skills MAY be consolidated into an umbrella, but never simply pruned.
|
|
731
|
+
3. Do not archive recently-created or never-used skills without strong evidence. "use=0" is NOT evidence either way — it only means the trigger has not come up yet.
|
|
732
|
+
4. Do NOT reject consolidation on the grounds that "each skill has a distinct trigger". The right bar is: would a human maintainer write this as N separate skills, or one skill with N labeled subsections? When the answer is the latter, merge.
|
|
733
|
+
5. Judge overlap on CONTENT, not on usage counters.
|
|
734
|
+
6. Before archiving a merged skill, ensure its unique content was preserved in the umbrella.
|
|
735
|
+
|
|
736
|
+
How to work:
|
|
737
|
+
1. Scan the candidate list. Identify PREFIX CLUSTERS — skills sharing a first word or domain keyword (expect 10-25 clusters).
|
|
738
|
+
2. For each cluster with 2+ members, ask "what is the UMBRELLA CLASS these skills serve?" and consolidate:
|
|
739
|
+
a. MERGE INTO AN EXISTING UMBRELLA (patch a labeled section for each sibling's unique insight, then archive the siblings).
|
|
740
|
+
b. CREATE A NEW UMBRELLA SKILL.md covering the shared workflow with short labeled subsections, then archive the absorbed siblings.
|
|
741
|
+
c. DEMOTE session-specific detail to references/, templates/, or scripts/ under the umbrella.
|
|
742
|
+
3. Keep the umbrella body tight and scannable: exact commands, verbatim paths, ~100-200 lines; never invent flags or APIs.
|
|
743
|
+
|
|
744
|
+
Produce a YAML summary with exactly this shape:
|
|
745
|
+
consolidations:
|
|
746
|
+
- from: <old-skill-name>
|
|
747
|
+
into: <umbrella-skill-name>
|
|
748
|
+
reason: <one short sentence>
|
|
749
|
+
prunings:
|
|
750
|
+
- name: <skill-name>
|
|
751
|
+
reason: <one short sentence>
|
|
752
|
+
Nominate a pruning only when archival is clearly safe (stale AND genuinely obsolete or fully absorbed elsewhere).`;
|
|
753
|
+
const CURATOR_DRY_RUN_BANNER = `═══════════════════════════════════════════════════════════════
|
|
754
|
+
DRY-RUN — REPORT ONLY. DO NOT MUTATE THE SKILL LIBRARY.
|
|
755
|
+
═══════════════════════════════════════════════════════════════
|
|
756
|
+
|
|
757
|
+
This is a PREVIEW pass. Follow every instruction above EXCEPT:
|
|
758
|
+
• Do NOT call skill_manage with action=create, update, patch, delete, write_file, or remove_file.
|
|
759
|
+
• Do NOT move, copy, or rewrite any file under the skills tree.
|
|
760
|
+
|
|
761
|
+
Your output IS the deliverable: produce the exact same human-readable summary and YAML block you would on a live run, describing the actions you WOULD take. A reviewer will decide whether to approve a live run.
|
|
762
|
+
|
|
763
|
+
If you accidentally take a mutating action, say so explicitly in the summary.`;
|
|
764
|
+
const COMPLETION_SKILL_REVIEW_PROMPT = `[Auto-review — Skills · task complete]
|
|
765
|
+
Your current task now appears complete. Before wrapping up, review the approach and update the skill library via skill_manage.
|
|
766
|
+
|
|
767
|
+
Follow the skills review policy: be ACTIVE, prefer class-level umbrellas, patch ONLY skills loaded or read this session, and capture non-trivial techniques and user corrections. Do NOT capture environment-dependent failures, negative claims about tools, or one-off task narratives.
|
|
768
|
+
|
|
769
|
+
Do NOT modify output files or re-run the task. If you are still mid-task, ignore this.`;
|
|
770
|
+
function reviewPrompt(kind) {
|
|
771
|
+
if (kind === "memory") return MEMORY_REVIEW_PROMPT;
|
|
772
|
+
if (kind === "skill") return SKILL_REVIEW_PROMPT;
|
|
773
|
+
return COMBINED_REVIEW_PROMPT;
|
|
774
|
+
}
|
|
775
|
+
function sha256(text) {
|
|
776
|
+
return createHash("sha256").update(text).digest("hex");
|
|
777
|
+
}
|
|
778
|
+
function createPromptBundle(prompts) {
|
|
779
|
+
const canonical = JSON.stringify({
|
|
780
|
+
id: PROMPT_BUNDLE_ID,
|
|
781
|
+
version: 3,
|
|
782
|
+
prompts: Object.fromEntries(Object.entries(prompts).sort())
|
|
783
|
+
});
|
|
784
|
+
return Object.freeze({
|
|
785
|
+
id: PROMPT_BUNDLE_ID,
|
|
786
|
+
version: 3,
|
|
787
|
+
prompts: Object.freeze({ ...prompts }),
|
|
788
|
+
sha256: sha256(canonical)
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
const PROMPT_BUNDLE = createPromptBundle({
|
|
792
|
+
memory: MEMORY_REVIEW_PROMPT,
|
|
793
|
+
skill: SKILL_REVIEW_PROMPT,
|
|
794
|
+
combined: COMBINED_REVIEW_PROMPT,
|
|
795
|
+
curator: CURATOR_PROMPT,
|
|
796
|
+
completion: COMPLETION_SKILL_REVIEW_PROMPT
|
|
797
|
+
});
|
|
798
|
+
function verifyPromptBundle(bundle = PROMPT_BUNDLE) {
|
|
799
|
+
if (bundle.id !== "dsh-evolution@3" || bundle.version !== 3) return false;
|
|
800
|
+
const canonical = JSON.stringify({
|
|
801
|
+
id: PROMPT_BUNDLE_ID,
|
|
802
|
+
version: 3,
|
|
803
|
+
prompts: Object.fromEntries(Object.entries(bundle.prompts).sort())
|
|
804
|
+
});
|
|
805
|
+
return bundle.sha256 === sha256(canonical);
|
|
806
|
+
}
|
|
807
|
+
const DSH_AUTHORING_STANDARDS = `Follow the Hermes skill-authoring standards, translated to DSH tools.
|
|
808
|
+
|
|
809
|
+
Frontmatter:
|
|
810
|
+
- name: lowercase-hyphenated, <=64 chars, no spaces.
|
|
811
|
+
- description: ONE sentence, <=60 characters, ends with a period. State the capability, not the implementation. No marketing words. Do NOT repeat the skill name. Count the characters before saving.
|
|
812
|
+
- version: 0.1.0
|
|
813
|
+
- author: always the literal value "Hermes". NEVER fill it from the environment, git config, or any identity you can probe.
|
|
814
|
+
- platforms: declare [macos], [linux], and/or [windows] only when the skill is genuinely OS-bound; omit for portable skills.
|
|
815
|
+
- metadata.hermes.tags: a few Capitalized, Relevant, Tags.
|
|
816
|
+
- metadata.hermes.related_skills: [a, b] — name sibling skills this one builds on or is referenced by (optional; feeds the quality references factor).
|
|
817
|
+
|
|
818
|
+
Body section order (omit only when empty):
|
|
819
|
+
1. "# <Human Title>" then a 2-3 sentence intro: what it does, what it does NOT do, key dependency stance.
|
|
820
|
+
2. "## When to Use" — concrete trigger phrases.
|
|
821
|
+
3. "## Prerequisites" — exact env vars, install steps, credentials.
|
|
822
|
+
4. "## How to Run" — canonical invocation framed through DSH tools.
|
|
823
|
+
5. "## Quick Reference" — flat command/endpoint list.
|
|
824
|
+
6. "## Procedure" — numbered steps with copy-paste-exact commands.
|
|
825
|
+
7. "## Pitfalls" — known limits and rate limits.
|
|
826
|
+
8. "## Verification" — one check proving the skill worked.
|
|
827
|
+
|
|
828
|
+
DSH-tool framing:
|
|
829
|
+
- Reference DSH tools by name in backticks: \`bash\`, \`str_replace_editor\`, \`write\`, \`skill\`, \`skill_manage\`, \`memory\`.
|
|
830
|
+
- Do not name wrapped shell utilities when a DSH tool already covers them.
|
|
831
|
+
- Larger scripts belong under \`scripts/\` (written with \`skill_manage write_file\`) and are referenced from SKILL.md by relative path.
|
|
832
|
+
|
|
833
|
+
Quality bar:
|
|
834
|
+
- Prefer verbatim flags, paths, and APIs from the source. Never invent them.
|
|
835
|
+
- Keep it tight: ~100 lines simple, ~200 complex.
|
|
836
|
+
- No router/index/hub skills that only point at other skills.
|
|
837
|
+
- References go in \`references/\`, templates in \`templates/\`.`;
|
|
838
|
+
//#endregion
|
|
839
|
+
//#region lib/types/learn-prompt.js
|
|
840
|
+
/**
|
|
841
|
+
* Open-ended `/evolution learn` prompt builder.
|
|
842
|
+
*
|
|
843
|
+
* `learn` is open-ended: the user can name anything they can describe — a
|
|
844
|
+
* directory of code, an API doc URL, a workflow they just walked the agent
|
|
845
|
+
* through, or pasted notes. The prompt instructs the live agent to gather the
|
|
846
|
+
* named sources with its existing tools, then author a single SKILL.md via
|
|
847
|
+
* `skill_manage` following `DSH_AUTHORING_STANDARDS`. There is no separate
|
|
848
|
+
* distillation engine and no model-tool footprint.
|
|
849
|
+
*/
|
|
850
|
+
/**
|
|
851
|
+
* Build the agent prompt for an open-ended `/evolution learn` request.
|
|
852
|
+
*
|
|
853
|
+
* @param userRequest free-text the user gave after `/evolution learn`; an
|
|
854
|
+
* empty string falls back to "the workflow we just went through".
|
|
855
|
+
* @returns a complete instruction the agent runs as a normal turn.
|
|
856
|
+
*/
|
|
857
|
+
function buildLearnPrompt(userRequest) {
|
|
858
|
+
return [
|
|
859
|
+
"[/learn] The user wants you to learn a reusable skill from the request below, and save it.",
|
|
860
|
+
"",
|
|
861
|
+
"THE REQUEST:",
|
|
862
|
+
userRequest.trim() || "the workflow we just went through in this conversation — review the steps taken and distill them into a reusable skill",
|
|
863
|
+
"",
|
|
864
|
+
"The request is open-ended and may mix two kinds of content, in any order: SOURCES to gather (directories, file paths, URLs, \"what we just did\", pasted notes) AND REQUIREMENTS that shape the skill (what to focus on, what to leave out, scope, naming, the angle to take). Treat EVERY part of the request as load-bearing. In particular, prose that comes after a path or link is NOT incidental — it is the user telling you what they want from that source. A request like `<url> focus on the auth flow, skip the deprecated endpoints` means: gather the URL AND honor \"focus on auth, skip deprecated\" as authoring requirements. Never fetch the first source and ignore the rest.",
|
|
865
|
+
"",
|
|
866
|
+
"Do this:",
|
|
867
|
+
"1. Gather every source the user named, using the tools you already have — reads and searches for local files or directories, web access for URLs, this conversation history if they referred to something you just did, and the text they pasted as-is. If the request is ambiguous about scope, make a reasonable choice and note it; do not stall.",
|
|
868
|
+
"2. Author ONE SKILL.md, applying every requirement, focus, and constraint in the request — these govern what the SKILL.md covers and emphasizes, not just which sources you read.",
|
|
869
|
+
"3. Save it with the `skill_manage` tool (action=\"create\"). Pick a sensible category. If the procedure needs a non-trivial script, add it under the skill's `scripts/` with `skill_manage` write_file and reference it by relative path.",
|
|
870
|
+
"",
|
|
871
|
+
DSH_AUTHORING_STANDARDS,
|
|
872
|
+
"",
|
|
873
|
+
"When done, tell the user the skill name, its category, and a one-line summary of what it captured."
|
|
874
|
+
].join("\n");
|
|
875
|
+
}
|
|
876
|
+
//#endregion
|
|
245
877
|
//#region lib/types/threats.js
|
|
246
878
|
/**
|
|
247
879
|
* Threat scanning for agent-authored memory and skill content.
|
|
@@ -417,10 +1049,12 @@ const SCOPE_ORDER = {
|
|
|
417
1049
|
context: 2,
|
|
418
1050
|
strict: 3
|
|
419
1051
|
};
|
|
1052
|
+
const NO_SCAN_OPTIONS = {};
|
|
420
1053
|
/**
|
|
421
1054
|
* Scan text at `scope`. Patterns are cumulative: `strict` includes all scopes.
|
|
1055
|
+
* `options.excludeLabels` removes matching patterns without changing `scope`.
|
|
422
1056
|
*/
|
|
423
|
-
function scanThreats(text, scope = "strict", maxScanChars = 65536) {
|
|
1057
|
+
function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
|
|
424
1058
|
const findings = [];
|
|
425
1059
|
if (ZERO_WIDTH_CHARS.test(text)) findings.push({
|
|
426
1060
|
label: "unicode_zero_width",
|
|
@@ -433,8 +1067,10 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536) {
|
|
|
433
1067
|
scope
|
|
434
1068
|
});
|
|
435
1069
|
const normalized = text.normalize("NFKC").slice(0, maxScanChars);
|
|
1070
|
+
const excluded = new Set(options.excludeLabels ?? []);
|
|
436
1071
|
for (const pattern of PATTERNS) {
|
|
437
1072
|
if (SCOPE_ORDER[pattern.scope] > SCOPE_ORDER[scope]) continue;
|
|
1073
|
+
if (excluded.has(pattern.label)) continue;
|
|
438
1074
|
if (pattern.regex.test(normalized)) findings.push({
|
|
439
1075
|
label: pattern.label,
|
|
440
1076
|
category: pattern.category,
|
|
@@ -444,24 +1080,24 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536) {
|
|
|
444
1080
|
return findings;
|
|
445
1081
|
}
|
|
446
1082
|
/** Blocking policy: any hit blocks. `severity` is deliberately not a gate. */
|
|
447
|
-
function evaluateThreat(text, scope = "strict", maxScanChars = 65536) {
|
|
448
|
-
const findings = scanThreats(text, scope, maxScanChars);
|
|
1083
|
+
function evaluateThreat(text, scope = "strict", maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
|
|
1084
|
+
const findings = scanThreats(text, scope, maxScanChars, options);
|
|
449
1085
|
return {
|
|
450
1086
|
blocked: findings.length > 0,
|
|
451
1087
|
findings
|
|
452
1088
|
};
|
|
453
1089
|
}
|
|
454
1090
|
/** User-facing block message for memory writes. */
|
|
455
|
-
function scanMemoryThreats(text, maxScanChars = 65536) {
|
|
456
|
-
const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars);
|
|
1091
|
+
function scanMemoryThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
|
|
1092
|
+
const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
|
|
457
1093
|
if (!blocked) return null;
|
|
458
1094
|
const pattern = findings.find((f) => f.category !== "unicode_obfuscation");
|
|
459
1095
|
if (pattern) return `Blocked by security scan (${pattern.label}). Rephrase without instruction-like language.`;
|
|
460
1096
|
return "Blocked by security scan: invisible or potentially malicious Unicode detected.";
|
|
461
1097
|
}
|
|
462
1098
|
/** User-facing block message for skill content writes. */
|
|
463
|
-
function scanContentThreats(text, maxScanChars = 65536) {
|
|
464
|
-
const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars);
|
|
1099
|
+
function scanContentThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
|
|
1100
|
+
const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
|
|
465
1101
|
if (!blocked) return null;
|
|
466
1102
|
return `Blocked by security scan (${findings[0]?.label ?? "unknown"}). This content appears to contain potentially malicious instructions.`;
|
|
467
1103
|
}
|
|
@@ -471,7 +1107,39 @@ function scanContentThreats(text, maxScanChars = 65536) {
|
|
|
471
1107
|
* File-backed durable memory with Hermes-compatible semantics.
|
|
472
1108
|
* Stores are MEMORY.md and USER.md under $DSH_HOME/memories (~/.dsh/memories).
|
|
473
1109
|
*/
|
|
474
|
-
|
|
1110
|
+
/**
|
|
1111
|
+
* Read-guard factor: a memory file larger than this multiple of its target's
|
|
1112
|
+
* char limit is treated as externally corrupted and skipped instead of being
|
|
1113
|
+
* read whole (aligned with claw `tools/memory.ts` size guard, which uses the
|
|
1114
|
+
* same 10× bound around a file that should never exceed the store limit).
|
|
1115
|
+
*/
|
|
1116
|
+
const READ_GUARD_FACTOR = 10;
|
|
1117
|
+
/**
|
|
1118
|
+
* Consolidation-failure backoff window (package-private, rc.42 audit P2-1):
|
|
1119
|
+
* only failures inside the window count toward `maxConsolidationFailures`.
|
|
1120
|
+
* The store cannot observe turn boundaries, so the model-facing "this turn"
|
|
1121
|
+
* phrasing is approximated with ten minutes — generous enough to cover one
|
|
1122
|
+
* turn's retry loop, short enough that a failure yesterday never makes today's
|
|
1123
|
+
* first refusal say "stop retrying".
|
|
1124
|
+
*/
|
|
1125
|
+
const FAILURE_WINDOW_MS = 10 * 6e4;
|
|
1126
|
+
/**
|
|
1127
|
+
* Recoverable-error preview bounds (B-line G5, Hermes `_previews` parity):
|
|
1128
|
+
* failed replace/remove/batch calls echo the current entries so the model can
|
|
1129
|
+
* self-recover without re-reading the store. Bounded to five entries of eighty
|
|
1130
|
+
* characters each; package-private because it is an error-message shape, not a
|
|
1131
|
+
* behavior switch.
|
|
1132
|
+
*/
|
|
1133
|
+
const ERROR_PREVIEW_ENTRIES = 5;
|
|
1134
|
+
const ERROR_PREVIEW_WIDTH = 80;
|
|
1135
|
+
function previewEntries(entries) {
|
|
1136
|
+
if (entries.length === 0) return "";
|
|
1137
|
+
const shown = entries.slice(0, ERROR_PREVIEW_ENTRIES).map((entry) => {
|
|
1138
|
+
return `- ${entry.length > ERROR_PREVIEW_WIDTH ? `${entry.slice(0, ERROR_PREVIEW_WIDTH)}…` : entry}`;
|
|
1139
|
+
});
|
|
1140
|
+
const more = entries.length > ERROR_PREVIEW_ENTRIES ? `\n (+${entries.length - ERROR_PREVIEW_ENTRIES} more)` : "";
|
|
1141
|
+
return `\n\nCurrent entries (preview):\n${shown.join("\n")}${more}`;
|
|
1142
|
+
}
|
|
475
1143
|
function memoryRoot(env = process.env) {
|
|
476
1144
|
return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "memories");
|
|
477
1145
|
}
|
|
@@ -495,6 +1163,7 @@ var MemoryStore = class {
|
|
|
495
1163
|
maxFailures;
|
|
496
1164
|
io;
|
|
497
1165
|
failureCount = 0;
|
|
1166
|
+
lastFailureAt = 0;
|
|
498
1167
|
constructor(options = {}) {
|
|
499
1168
|
this.io = options.io ?? nodeEvolutionIo();
|
|
500
1169
|
this.memoryLimit = options.memoryCharLimit ?? 2200;
|
|
@@ -506,7 +1175,24 @@ var MemoryStore = class {
|
|
|
506
1175
|
limitFor(target) {
|
|
507
1176
|
return target === "memory" ? this.memoryLimit : this.userLimit;
|
|
508
1177
|
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Read-guard probe: `{ size, limit }` when the on-disk file exceeds
|
|
1180
|
+
* `limit * READ_GUARD_FACTOR` bytes, `null` when it is absent, unknown
|
|
1181
|
+
* (backend without a size probe), under the bound, or the target has no
|
|
1182
|
+
* limit configured.
|
|
1183
|
+
*/
|
|
1184
|
+
async oversizedFile(target) {
|
|
1185
|
+
const size = await this.io.size?.(fileFor(this.root, target));
|
|
1186
|
+
if (size === null || size === void 0) return null;
|
|
1187
|
+
const limit = this.limitFor(target);
|
|
1188
|
+
if (limit <= 0) return null;
|
|
1189
|
+
return size > limit * READ_GUARD_FACTOR ? {
|
|
1190
|
+
size,
|
|
1191
|
+
limit
|
|
1192
|
+
} : null;
|
|
1193
|
+
}
|
|
509
1194
|
async read(target) {
|
|
1195
|
+
if (await this.oversizedFile(target)) return [];
|
|
510
1196
|
const raw = await this.io.readText(fileFor(this.root, target));
|
|
511
1197
|
return raw === null ? [] : [...new Set(normalizeEntries(raw))];
|
|
512
1198
|
}
|
|
@@ -517,24 +1203,86 @@ var MemoryStore = class {
|
|
|
517
1203
|
this.failureCount = 0;
|
|
518
1204
|
}
|
|
519
1205
|
failure(target, message, entries) {
|
|
1206
|
+
if (Date.now() - this.lastFailureAt > FAILURE_WINDOW_MS) this.failureCount = 0;
|
|
1207
|
+
this.lastFailureAt = Date.now();
|
|
520
1208
|
this.failureCount += 1;
|
|
521
1209
|
const chars = entries.join(ENTRY_DELIMITER).length;
|
|
522
1210
|
if (this.failureCount > this.maxFailures) return {
|
|
523
1211
|
ok: false,
|
|
524
|
-
message: `Memory consolidation failed ${this.failureCount} times this turn. Stop retrying memory calls and continue with the user's task
|
|
1212
|
+
message: `Memory consolidation failed ${this.failureCount} times this turn. Stop retrying memory calls and continue with the user's task.${previewEntries(entries)}`,
|
|
525
1213
|
entries,
|
|
526
1214
|
chars,
|
|
527
1215
|
limit: this.limitFor(target)
|
|
528
1216
|
};
|
|
529
1217
|
return {
|
|
530
1218
|
ok: false,
|
|
531
|
-
message
|
|
1219
|
+
message: `${message}${previewEntries(entries)}`,
|
|
532
1220
|
entries,
|
|
533
1221
|
chars,
|
|
534
1222
|
limit: this.limitFor(target)
|
|
535
1223
|
};
|
|
536
1224
|
}
|
|
1225
|
+
/**
|
|
1226
|
+
* StorageHint percentage must clamp at 100 like the render header: a drifted
|
|
1227
|
+
* entry can push chars past the limit, and "Storage at 125%" contradicts the
|
|
1228
|
+
* clamped usage indicator.
|
|
1229
|
+
*/
|
|
1230
|
+
storageHint(target, chars) {
|
|
1231
|
+
const limit = this.limitFor(target);
|
|
1232
|
+
if (limit <= 0) return "";
|
|
1233
|
+
const percent = Math.min(100, Math.floor(chars * 100 / limit));
|
|
1234
|
+
return percent >= 80 ? ` ⚠️ Storage at ${percent}% (${chars}/${limit} chars).` : "";
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Best-effort raw-copy backup of the on-disk file to `<file>.bak.<stamp>`
|
|
1238
|
+
* before a refusal, so an externally modified (or oversized) file stays
|
|
1239
|
+
* recoverable. Copies bytes instead of reading them so a pathologically
|
|
1240
|
+
* large file is never loaded just to back it up. Failure to back up does
|
|
1241
|
+
* not change the refusal semantics.
|
|
1242
|
+
*/
|
|
1243
|
+
async backupFile(target) {
|
|
1244
|
+
const path = fileFor(this.root, target);
|
|
1245
|
+
const unique = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 14)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1246
|
+
try {
|
|
1247
|
+
await this.io.copy(path, `${path}.bak.${unique}`);
|
|
1248
|
+
return `${path}.bak.${unique}`;
|
|
1249
|
+
} catch {
|
|
1250
|
+
return null;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Read-guard refusal for write paths. Returns the refusal result when the
|
|
1255
|
+
* target file is oversized, `null` otherwise. The file is skipped for
|
|
1256
|
+
* reading (never loaded), backed up by raw copy, and the model is told to
|
|
1257
|
+
* fix it manually — mirroring the drift refusal so corrupted state is never
|
|
1258
|
+
* silently overwritten.
|
|
1259
|
+
*/
|
|
1260
|
+
async oversizedRefusal(target) {
|
|
1261
|
+
const oversized = await this.oversizedFile(target);
|
|
1262
|
+
if (!oversized) return null;
|
|
1263
|
+
const backup = await this.backupFile(target);
|
|
1264
|
+
const suffix = backup ? ` A backup was saved to ${basename(backup)}.` : "";
|
|
1265
|
+
return {
|
|
1266
|
+
ok: false,
|
|
1267
|
+
message: `Memory file is ${oversized.size} bytes (limit ${oversized.limit * READ_GUARD_FACTOR}) — skipping read.${suffix} Fix the file manually, then retry.`,
|
|
1268
|
+
entries: [],
|
|
1269
|
+
chars: 0,
|
|
1270
|
+
limit: this.limitFor(target)
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
537
1273
|
async add(target, facts) {
|
|
1274
|
+
const refusal = await this.oversizedRefusal(target);
|
|
1275
|
+
if (refusal) return refusal;
|
|
1276
|
+
if (await this.detectDrift(target)) {
|
|
1277
|
+
const backup = await this.backupFile(target);
|
|
1278
|
+
return {
|
|
1279
|
+
ok: false,
|
|
1280
|
+
message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
|
|
1281
|
+
entries: [],
|
|
1282
|
+
chars: 0,
|
|
1283
|
+
limit: this.limitFor(target)
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
538
1286
|
const content = facts.trim();
|
|
539
1287
|
if (!content) return {
|
|
540
1288
|
ok: false,
|
|
@@ -556,7 +1304,7 @@ var MemoryStore = class {
|
|
|
556
1304
|
this.resetFailures();
|
|
557
1305
|
return {
|
|
558
1306
|
ok: true,
|
|
559
|
-
message:
|
|
1307
|
+
message: `Entry already exists (no duplicate added).${this.storageHint(target, entries.join(ENTRY_DELIMITER).length)}`,
|
|
560
1308
|
entries,
|
|
561
1309
|
chars: entries.join(ENTRY_DELIMITER).length,
|
|
562
1310
|
limit: this.limitFor(target)
|
|
@@ -564,101 +1312,38 @@ var MemoryStore = class {
|
|
|
564
1312
|
}
|
|
565
1313
|
const next = [...entries, this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n${content}` : content];
|
|
566
1314
|
const total = next.join(ENTRY_DELIMITER).length;
|
|
567
|
-
|
|
1315
|
+
const addLimit = this.limitFor(target);
|
|
1316
|
+
if (addLimit > 0 && total > addLimit) return this.failure(target, `Adding this entry would exceed the ${addLimit} char limit. Consolidate or remove stale entries, then retry.`, entries);
|
|
568
1317
|
await this.write(target, next);
|
|
569
1318
|
this.resetFailures();
|
|
570
1319
|
return {
|
|
571
1320
|
ok: true,
|
|
572
|
-
message:
|
|
1321
|
+
message: `Entry added.${this.storageHint(target, total)}`,
|
|
573
1322
|
entries: next,
|
|
574
1323
|
chars: total,
|
|
575
1324
|
limit: this.limitFor(target)
|
|
576
1325
|
};
|
|
577
1326
|
}
|
|
578
|
-
async
|
|
579
|
-
|
|
580
|
-
}
|
|
581
|
-
async remove(target, oldText) {
|
|
582
|
-
return this.mutate(target, oldText, "remove", void 0);
|
|
583
|
-
}
|
|
584
|
-
async mutate(target, oldText, action, facts) {
|
|
585
|
-
const needle = oldText.trim();
|
|
586
|
-
if (!needle) return {
|
|
587
|
-
ok: false,
|
|
588
|
-
message: "old_text cannot be empty.",
|
|
589
|
-
entries: [],
|
|
590
|
-
chars: 0,
|
|
591
|
-
limit: this.limitFor(target)
|
|
592
|
-
};
|
|
593
|
-
const content = action === "replace" ? (facts ?? "").trim() : "";
|
|
594
|
-
if (action === "replace" && !content) return {
|
|
1327
|
+
async applyBatch(target, operations) {
|
|
1328
|
+
if (operations.length === 0) return {
|
|
595
1329
|
ok: false,
|
|
596
|
-
message: "
|
|
1330
|
+
message: "operations list is empty.",
|
|
597
1331
|
entries: [],
|
|
598
1332
|
chars: 0,
|
|
599
1333
|
limit: this.limitFor(target)
|
|
600
1334
|
};
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
1335
|
+
const refusal = await this.oversizedRefusal(target);
|
|
1336
|
+
if (refusal) return refusal;
|
|
1337
|
+
if (await this.detectDrift(target)) {
|
|
1338
|
+
const backup = await this.backupFile(target);
|
|
1339
|
+
return {
|
|
604
1340
|
ok: false,
|
|
605
|
-
message:
|
|
1341
|
+
message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
|
|
606
1342
|
entries: [],
|
|
607
1343
|
chars: 0,
|
|
608
1344
|
limit: this.limitFor(target)
|
|
609
1345
|
};
|
|
610
1346
|
}
|
|
611
|
-
if (await this.detectDrift(target)) return {
|
|
612
|
-
ok: false,
|
|
613
|
-
message: "External drift detected in memory file. Resolve the drift before retrying.",
|
|
614
|
-
entries: [],
|
|
615
|
-
chars: 0,
|
|
616
|
-
limit: this.limitFor(target)
|
|
617
|
-
};
|
|
618
|
-
const entries = await this.read(target);
|
|
619
|
-
const matches = entries.map((entry, index) => ({
|
|
620
|
-
entry,
|
|
621
|
-
index
|
|
622
|
-
})).filter(({ entry }) => entry.includes(needle));
|
|
623
|
-
if (matches.length === 0) return this.failure(target, `No entry matching "${needle}" found.`, entries);
|
|
624
|
-
if (new Set(matches.map((m) => m.entry)).size > 1) return {
|
|
625
|
-
ok: false,
|
|
626
|
-
message: `Multiple distinct entries matched "${needle}". Be more specific.`,
|
|
627
|
-
entries,
|
|
628
|
-
chars: entries.join(ENTRY_DELIMITER).length,
|
|
629
|
-
limit: this.limitFor(target)
|
|
630
|
-
};
|
|
631
|
-
const index = matches[0]?.index ?? -1;
|
|
632
|
-
const next = [...entries];
|
|
633
|
-
if (action === "remove") next.splice(index, 1);
|
|
634
|
-
else next[index] = content;
|
|
635
|
-
const total = next.join(ENTRY_DELIMITER).length;
|
|
636
|
-
if (total > this.limitFor(target)) return this.failure(target, `Resulting memory would exceed the ${this.limitFor(target)} char limit.`, entries);
|
|
637
|
-
await this.write(target, next);
|
|
638
|
-
this.resetFailures();
|
|
639
|
-
return {
|
|
640
|
-
ok: true,
|
|
641
|
-
message: `Entry ${action === "remove" ? "removed" : "replaced"}.`,
|
|
642
|
-
entries: next,
|
|
643
|
-
chars: total,
|
|
644
|
-
limit: this.limitFor(target)
|
|
645
|
-
};
|
|
646
|
-
}
|
|
647
|
-
async applyBatch(target, operations) {
|
|
648
|
-
if (operations.length === 0) return {
|
|
649
|
-
ok: false,
|
|
650
|
-
message: "operations list is empty.",
|
|
651
|
-
entries: [],
|
|
652
|
-
chars: 0,
|
|
653
|
-
limit: this.limitFor(target)
|
|
654
|
-
};
|
|
655
|
-
if (await this.detectDrift(target)) return {
|
|
656
|
-
ok: false,
|
|
657
|
-
message: "External drift detected in memory file. Resolve the drift before retrying.",
|
|
658
|
-
entries: [],
|
|
659
|
-
chars: 0,
|
|
660
|
-
limit: this.limitFor(target)
|
|
661
|
-
};
|
|
662
1347
|
const entries = await this.read(target);
|
|
663
1348
|
const working = [...entries];
|
|
664
1349
|
for (const [index, op] of operations.entries()) {
|
|
@@ -667,7 +1352,7 @@ var MemoryStore = class {
|
|
|
667
1352
|
const body = (op.facts ?? "").trim();
|
|
668
1353
|
if (!body) return {
|
|
669
1354
|
ok: false,
|
|
670
|
-
message: `Operation ${position} (add): facts is required. No operations were applied
|
|
1355
|
+
message: `Operation ${position} (add): facts is required. No operations were applied.${previewEntries(entries)}`,
|
|
671
1356
|
entries,
|
|
672
1357
|
chars: entries.join(ENTRY_DELIMITER).length,
|
|
673
1358
|
limit: this.limitFor(target)
|
|
@@ -686,7 +1371,7 @@ var MemoryStore = class {
|
|
|
686
1371
|
const needle = (op.old_text ?? "").trim();
|
|
687
1372
|
if (!needle) return {
|
|
688
1373
|
ok: false,
|
|
689
|
-
message: `Operation ${position} (${op.action}): old_text is required. No operations were applied
|
|
1374
|
+
message: `Operation ${position} (${op.action}): old_text is required. No operations were applied.${previewEntries(entries)}`,
|
|
690
1375
|
entries,
|
|
691
1376
|
chars: entries.join(ENTRY_DELIMITER).length,
|
|
692
1377
|
limit: this.limitFor(target)
|
|
@@ -698,7 +1383,7 @@ var MemoryStore = class {
|
|
|
698
1383
|
if (matches.length === 0) return this.failure(target, `Operation ${position}: no entry matching "${needle}" found. No operations were applied.`, entries);
|
|
699
1384
|
if (new Set(matches.map((m) => m.entry)).size > 1) return {
|
|
700
1385
|
ok: false,
|
|
701
|
-
message: `Operation ${position}: "${needle}" matched multiple distinct entries. No operations were applied
|
|
1386
|
+
message: `Operation ${position}: "${needle}" matched multiple distinct entries. No operations were applied.${previewEntries(entries)}`,
|
|
702
1387
|
entries,
|
|
703
1388
|
chars: entries.join(ENTRY_DELIMITER).length,
|
|
704
1389
|
limit: this.limitFor(target)
|
|
@@ -709,7 +1394,7 @@ var MemoryStore = class {
|
|
|
709
1394
|
const body = (op.facts ?? "").trim();
|
|
710
1395
|
if (!body) return {
|
|
711
1396
|
ok: false,
|
|
712
|
-
message: `Operation ${position} (replace): facts is required
|
|
1397
|
+
message: `Operation ${position} (replace): facts is required.${previewEntries(entries)}`,
|
|
713
1398
|
entries,
|
|
714
1399
|
chars: entries.join(ENTRY_DELIMITER).length,
|
|
715
1400
|
limit: this.limitFor(target)
|
|
@@ -726,12 +1411,13 @@ var MemoryStore = class {
|
|
|
726
1411
|
}
|
|
727
1412
|
}
|
|
728
1413
|
const total = working.join(ENTRY_DELIMITER).length;
|
|
729
|
-
|
|
1414
|
+
const batchLimit = this.limitFor(target);
|
|
1415
|
+
if (batchLimit > 0 && total > batchLimit) return this.failure(target, `Batch result (${total} chars) exceeds the ${batchLimit} limit. Remove or shorten more entries in the same batch.`, entries);
|
|
730
1416
|
await this.write(target, working);
|
|
731
1417
|
this.resetFailures();
|
|
732
1418
|
return {
|
|
733
1419
|
ok: true,
|
|
734
|
-
message: `Applied ${operations.length} operation(s)
|
|
1420
|
+
message: `Applied ${operations.length} operation(s).${this.storageHint(target, total)}`,
|
|
735
1421
|
entries: working,
|
|
736
1422
|
chars: total,
|
|
737
1423
|
limit: this.limitFor(target)
|
|
@@ -741,172 +1427,239 @@ var MemoryStore = class {
|
|
|
741
1427
|
const memory = await this.read("memory");
|
|
742
1428
|
const user = await this.read("user");
|
|
743
1429
|
const parts = [];
|
|
744
|
-
for (const [target, entries] of [[
|
|
1430
|
+
for (const [target, label, entries] of [[
|
|
1431
|
+
"memory",
|
|
1432
|
+
"Memory",
|
|
1433
|
+
memory
|
|
1434
|
+
], [
|
|
1435
|
+
"user",
|
|
1436
|
+
"User Profile",
|
|
1437
|
+
user
|
|
1438
|
+
]]) {
|
|
1439
|
+
const oversized = entries.length === 0 ? await this.oversizedFile(target) : null;
|
|
1440
|
+
if (oversized) {
|
|
1441
|
+
parts.push(`## ${label} — file skipped: ${oversized.size} bytes (limit ${oversized.limit * READ_GUARD_FACTOR}); not read`);
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
745
1444
|
const safe = entries.filter((entry) => !scanMemoryThreats(entry));
|
|
746
1445
|
if (safe.length > 0) {
|
|
747
1446
|
const body = safe.join(ENTRY_DELIMITER);
|
|
1447
|
+
const limit = this.limitFor(target);
|
|
1448
|
+
const pct = limit > 0 ? Math.min(100, Math.floor(body.length * 100 / limit)) : 0;
|
|
748
1449
|
const note = safe.length === entries.length ? "" : ` (${entries.length - safe.length} threat-matched entries filtered)`;
|
|
749
|
-
parts.push(`## ${
|
|
1450
|
+
parts.push(`## ${label} (${safe.length} entries) [${pct}% — ${body.length}/${limit} chars]${note}\n${body}`);
|
|
750
1451
|
}
|
|
751
1452
|
}
|
|
752
1453
|
return parts.join("\n\n");
|
|
753
1454
|
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
1455
|
+
/**
|
|
1456
|
+
* Detect on-disk drift: true when the file is not in the canonical
|
|
1457
|
+
* `render(normalizeEntries(raw))` form. This catches structural anomalies
|
|
1458
|
+
* the writer would quietly normalize away (empty/`§`-only entries, stray
|
|
1459
|
+
* blank lines, leading/trailing delimiters) that indicate the file was
|
|
1460
|
+
* edited outside MemoryStore. Purely single-canonical content reaches the
|
|
1461
|
+
* same serialization and returns false, so a normal write is never flagged.
|
|
1462
|
+
*
|
|
1463
|
+
* An absent, empty, or whitespace-only file is the "never written" state
|
|
1464
|
+
* (rc.42 audit P1-6): it parses to zero entries, so the canonical form
|
|
1465
|
+
* `'\n'` can never byte-match it and every write path was permanently
|
|
1466
|
+
* refused with "External drift detected" — including the repairs the model
|
|
1467
|
+
* would need to make. Such files are adopted instead of flagged.
|
|
1468
|
+
*/
|
|
765
1469
|
async detectDrift(target) {
|
|
1470
|
+
if (await this.oversizedFile(target)) return true;
|
|
766
1471
|
const raw = await this.io.readText(fileFor(this.root, target));
|
|
767
|
-
if (raw === null) return false;
|
|
768
|
-
|
|
1472
|
+
if (raw === null || raw.trim() === "") return false;
|
|
1473
|
+
const entries = normalizeEntries(raw);
|
|
1474
|
+
const limit = this.limitFor(target);
|
|
1475
|
+
if (limit > 0 && entries.some((entry) => entry.length > limit)) return true;
|
|
1476
|
+
return render(entries) !== raw;
|
|
769
1477
|
}
|
|
770
1478
|
};
|
|
771
1479
|
//#endregion
|
|
772
|
-
//#region lib/types/
|
|
1480
|
+
//#region lib/types/mutations.js
|
|
773
1481
|
/**
|
|
774
|
-
*
|
|
775
|
-
*
|
|
776
|
-
*
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
* Every prompt is pinned in a versioned bundle. Review workers verify the
|
|
780
|
-
* bundle digest before spending a model call, so a partially-patched
|
|
781
|
-
* deployment fails closed instead of silently running a truncated prompt.
|
|
1482
|
+
* Curator/author audit trail: `.mutations.json` records every skill mutation
|
|
1483
|
+
* with before/after content hashes so any automated edit is reviewable and
|
|
1484
|
+
* replayable. Best-effort persistence, mirroring the usage sidecar posture.
|
|
1485
|
+
* @module @lmzhen/dsh-evolution-core
|
|
782
1486
|
*/
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
3. Add references/, templates/, or scripts/ support under an existing skill.
|
|
807
|
-
4. Create a new class-level umbrella skill only when nothing fits.
|
|
808
|
-
|
|
809
|
-
Protected skills (bundled/hub-installed) must not be edited. Pinned skills may be patched but not archived.
|
|
810
|
-
|
|
811
|
-
Do NOT capture:
|
|
812
|
-
- Environment-dependent failures (missing binaries, unconfigured credentials).
|
|
813
|
-
- Negative claims about tools ("browser tools do not work").
|
|
814
|
-
- Transient errors that resolved during the session.
|
|
815
|
-
- One-off task narratives.
|
|
816
|
-
|
|
817
|
-
If a tool failed because of setup state, capture the FIX under an existing setup skill — never "this tool does not work" as a standalone constraint.
|
|
818
|
-
|
|
819
|
-
"Nothing to save." is a real option but should NOT be the default.`;
|
|
820
|
-
const COMBINED_REVIEW_PROMPT = `[Auto-review]
|
|
821
|
-
Review the conversation above and update two things.
|
|
822
|
-
|
|
823
|
-
**Memory**: who the user is. Save durable user preferences, personal details, and expectations with the memory tool.
|
|
824
|
-
|
|
825
|
-
**Skills**: how to do this class of task. Be ACTIVE. Follow the same class-level umbrella policy, preference order, protected-skill rules, and do-not-capture list as a skill review.
|
|
826
|
-
|
|
827
|
-
Act on whichever dimension has real signal. If genuinely nothing stands out on either, say "Nothing to save." and stop — but don't reach for that conclusion as a default.`;
|
|
828
|
-
const CURATOR_PROMPT = `You are the skill curator. Maintain a healthy, class-level skill library.
|
|
829
|
-
|
|
830
|
-
Rules:
|
|
831
|
-
1. NEVER hard-delete a skill. Archive is the maximum destructive action.
|
|
832
|
-
2. Do not touch bundled, hub-installed, or pinned skills.
|
|
833
|
-
3. Do not archive recently-created or never-used skills without strong evidence.
|
|
834
|
-
4. Prefer merging narrow skills into class-level umbrellas.
|
|
835
|
-
5. Before archiving a merged skill, ensure its unique content was preserved.
|
|
836
|
-
|
|
837
|
-
Produce a YAML summary:
|
|
838
|
-
consolidations:
|
|
839
|
-
- from: <old-skill-name>
|
|
840
|
-
into: <umbrella-skill-name>
|
|
841
|
-
reason: <one short sentence>
|
|
842
|
-
prunings:
|
|
843
|
-
- name: <skill-name>
|
|
844
|
-
reason: <one short sentence>`;
|
|
845
|
-
function reviewPrompt(kind) {
|
|
846
|
-
if (kind === "memory") return MEMORY_REVIEW_PROMPT;
|
|
847
|
-
if (kind === "skill") return SKILL_REVIEW_PROMPT;
|
|
848
|
-
return COMBINED_REVIEW_PROMPT;
|
|
1487
|
+
const DEFAULT_MUTATION_CAP = 500;
|
|
1488
|
+
/** Version of the `.mutations.json` file shape; writers always emit the current one. */
|
|
1489
|
+
const MUTATIONS_FILE_VERSION = 1;
|
|
1490
|
+
function mutationsFile(root) {
|
|
1491
|
+
return join(root, ".mutations.json");
|
|
1492
|
+
}
|
|
1493
|
+
function contentHash(content) {
|
|
1494
|
+
return createHash("sha256").update(content).digest("hex");
|
|
1495
|
+
}
|
|
1496
|
+
/**
|
|
1497
|
+
* Parse a raw mutations sidecar; malformed content reads as empty (auditing is
|
|
1498
|
+
* best-effort). Versioned shape ({ version, records }) with legacy
|
|
1499
|
+
* plain-array compat, plus a field-level guard for records without the
|
|
1500
|
+
* required identity/timestamp fields (rc.42 audit P2-3).
|
|
1501
|
+
*/
|
|
1502
|
+
function parseMutationRecords(raw) {
|
|
1503
|
+
if (raw === null) return [];
|
|
1504
|
+
try {
|
|
1505
|
+
const parsed = JSON.parse(raw);
|
|
1506
|
+
return (Array.isArray(parsed) ? parsed : typeof parsed === "object" && parsed !== null && Array.isArray(parsed.records) ? parsed.records : []).filter((entry) => typeof entry === "object" && entry !== null && typeof entry.skillName === "string" && typeof entry.action === "string" && typeof entry.at === "string");
|
|
1507
|
+
} catch {
|
|
1508
|
+
return [];
|
|
1509
|
+
}
|
|
849
1510
|
}
|
|
850
|
-
function
|
|
851
|
-
return
|
|
1511
|
+
async function loadMutations(root, io = nodeEvolutionIo()) {
|
|
1512
|
+
return parseMutationRecords(await io.readText(mutationsFile(root)));
|
|
852
1513
|
}
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
sha256: sha256(canonical)
|
|
1514
|
+
/** Append one record, trim to `cap`, and write atomically (versioned shape). */
|
|
1515
|
+
async function recordMutation(root, io, record, cap = 500) {
|
|
1516
|
+
await transactIo(io, mutationsFile(root), (current) => {
|
|
1517
|
+
const existing = parseMutationRecords(current);
|
|
1518
|
+
existing.push(record);
|
|
1519
|
+
const trimmed = existing.length > cap ? existing.slice(existing.length - cap) : existing;
|
|
1520
|
+
return Promise.resolve(JSON.stringify({
|
|
1521
|
+
version: 1,
|
|
1522
|
+
records: trimmed
|
|
1523
|
+
}, null, 2));
|
|
864
1524
|
});
|
|
865
1525
|
}
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
1526
|
+
//#endregion
|
|
1527
|
+
//#region lib/types/quality.js
|
|
1528
|
+
/**
|
|
1529
|
+
* Quality scoring and near-duplicate detection for the curated skill library.
|
|
1530
|
+
*
|
|
1531
|
+
* Pure functions over data inputs so the scoring policy is unit-testable and
|
|
1532
|
+
* the same math feeds the usage sidecar, the `skill_manage review` surface and
|
|
1533
|
+
* the learning graph. Weights follow the Hermes/hermes-claw six-factor model;
|
|
1534
|
+
* mutation maturity is a documented DSH approximation (single per-month patch
|
|
1535
|
+
* trend ratio replaces the claw timestamp-trend formula, since DSH usage
|
|
1536
|
+
* records only carry the last patched timestamp).
|
|
1537
|
+
* @module @lmzhen/dsh-evolution-core
|
|
1538
|
+
*/
|
|
1539
|
+
const QUALITY_WEIGHTS = {
|
|
1540
|
+
usageFrequency: .25,
|
|
1541
|
+
stability: .2,
|
|
1542
|
+
recency: .2,
|
|
1543
|
+
references: .1,
|
|
1544
|
+
mutationMaturity: .2,
|
|
1545
|
+
richness: .05
|
|
1546
|
+
};
|
|
1547
|
+
/** Score below which a skill is flagged for review. */
|
|
1548
|
+
const LOW_QUALITY_THRESHOLD = .3;
|
|
1549
|
+
function clamp01(value) {
|
|
1550
|
+
return Math.max(0, Math.min(1, value));
|
|
1551
|
+
}
|
|
1552
|
+
function daysBetween(from, now) {
|
|
1553
|
+
return Math.max(0, (now.getTime() - new Date(from).getTime()) / 864e5);
|
|
1554
|
+
}
|
|
1555
|
+
function computeQualityScores(input) {
|
|
1556
|
+
const now = input.now ?? /* @__PURE__ */ new Date();
|
|
1557
|
+
const scores = /* @__PURE__ */ new Map();
|
|
1558
|
+
for (const [name, record] of input.usage) {
|
|
1559
|
+
const ageDays = Math.max(1, daysBetween(record.created_at, now));
|
|
1560
|
+
const idleDays = daysBetween(latestActivityAt(record) ?? record.created_at, now);
|
|
1561
|
+
const patchCount = record.patch_count;
|
|
1562
|
+
const useCount = record.use_count;
|
|
1563
|
+
const usageFrequency = clamp01(useCount / ageDays);
|
|
1564
|
+
const stability = useCount === 0 ? 1 : clamp01(1 - patchCount / useCount);
|
|
1565
|
+
const recency = idleDays < 30 ? 1 : clamp01(1 - (idleDays - 30) / 150);
|
|
1566
|
+
const references = clamp01((input.referenceCounts?.get(name) ?? 0) / 3);
|
|
1567
|
+
const mutationMaturity = patchCount === 0 ? .3 : patchCount === 1 ? .4 : clamp01((patchCount - 1) / Math.max(1, ageDays / 30));
|
|
1568
|
+
const richness = clamp01((input.supportDirs?.get(name) ?? 0) * .175);
|
|
1569
|
+
const factors = {
|
|
1570
|
+
usageFrequency,
|
|
1571
|
+
stability,
|
|
1572
|
+
recency,
|
|
1573
|
+
references,
|
|
1574
|
+
mutationMaturity,
|
|
1575
|
+
richness
|
|
1576
|
+
};
|
|
1577
|
+
const score = usageFrequency * QUALITY_WEIGHTS.usageFrequency + stability * QUALITY_WEIGHTS.stability + recency * QUALITY_WEIGHTS.recency + references * QUALITY_WEIGHTS.references + mutationMaturity * QUALITY_WEIGHTS.mutationMaturity + richness * QUALITY_WEIGHTS.richness;
|
|
1578
|
+
scores.set(name, {
|
|
1579
|
+
score,
|
|
1580
|
+
factors,
|
|
1581
|
+
warn: score < LOW_QUALITY_THRESHOLD
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
return scores;
|
|
1585
|
+
}
|
|
1586
|
+
function normalize(content) {
|
|
1587
|
+
return content.toLowerCase().replace(/\s+/g, " ").trim();
|
|
1588
|
+
}
|
|
1589
|
+
function contentHash$1(content) {
|
|
1590
|
+
return createHash("sha256").update(normalize(content)).digest("hex");
|
|
1591
|
+
}
|
|
1592
|
+
function tokenize(content) {
|
|
1593
|
+
return new Set(normalize(content).split(/[^a-z0-9\u4e00-\u9fff]+/).filter(Boolean));
|
|
1594
|
+
}
|
|
1595
|
+
function jaccard(a, b) {
|
|
1596
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
1597
|
+
let intersection = 0;
|
|
1598
|
+
for (const token of a) if (b.has(token)) intersection += 1;
|
|
1599
|
+
return intersection / (a.size + b.size - intersection);
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* Two-phase near-duplicate clustering: exact normalized-hash groups first,
|
|
1603
|
+
* then token-Jaccard edges at {@link DEDUP_SIMILARITY_THRESHOLD} with a token
|
|
1604
|
+
* ratio guard, union-find across the whole set.
|
|
1605
|
+
*/
|
|
1606
|
+
function computeDedupGroups(input) {
|
|
1607
|
+
const threshold = input.threshold ?? .95;
|
|
1608
|
+
const names = [...input.contents.keys()];
|
|
1609
|
+
const hashes = /* @__PURE__ */ new Map();
|
|
1610
|
+
for (const name of names) {
|
|
1611
|
+
const hash = contentHash$1(input.contents.get(name) ?? "");
|
|
1612
|
+
const bucket = hashes.get(hash);
|
|
1613
|
+
if (bucket) bucket.push(name);
|
|
1614
|
+
else hashes.set(hash, [name]);
|
|
1615
|
+
}
|
|
1616
|
+
const parent = /* @__PURE__ */ new Map();
|
|
1617
|
+
const find = (x) => {
|
|
1618
|
+
const root = parent.get(x) ?? x;
|
|
1619
|
+
if (root !== x) parent.set(x, find(root));
|
|
1620
|
+
return parent.get(x) ?? x;
|
|
1621
|
+
};
|
|
1622
|
+
const union = (a, b) => {
|
|
1623
|
+
const [ra, rb] = [find(a), find(b)];
|
|
1624
|
+
if (ra !== rb) parent.set(rb, ra);
|
|
1625
|
+
};
|
|
1626
|
+
for (const [hash, bucketNames] of hashes) {
|
|
1627
|
+
const first = bucketNames[0];
|
|
1628
|
+
if (first === void 0 || bucketNames.length === 1) continue;
|
|
1629
|
+
for (let index = 1; index < bucketNames.length; index += 1) {
|
|
1630
|
+
const peer = bucketNames[index];
|
|
1631
|
+
if (peer) union(first, peer);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
const tokens = /* @__PURE__ */ new Map();
|
|
1635
|
+
const tokenSet = (name) => {
|
|
1636
|
+
let set = tokens.get(name);
|
|
1637
|
+
if (!set) {
|
|
1638
|
+
set = tokenize(input.contents.get(name) ?? "");
|
|
1639
|
+
tokens.set(name, set);
|
|
1640
|
+
}
|
|
1641
|
+
return set;
|
|
1642
|
+
};
|
|
1643
|
+
for (let index = 0; index < names.length; index += 1) {
|
|
1644
|
+
const a = names[index];
|
|
1645
|
+
if (a === void 0) continue;
|
|
1646
|
+
for (let other = index + 1; other < names.length; other += 1) {
|
|
1647
|
+
const b = names[other];
|
|
1648
|
+
if (b === void 0) continue;
|
|
1649
|
+
const [ta, tb] = [tokenSet(a), tokenSet(b)];
|
|
1650
|
+
if (Math.max(ta.size, tb.size) / Math.max(1, Math.min(ta.size, tb.size)) > 5) continue;
|
|
1651
|
+
if (jaccard(ta, tb) >= threshold) union(a, b);
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1655
|
+
for (const name of names) {
|
|
1656
|
+
const root = find(name);
|
|
1657
|
+
const group = groups.get(root);
|
|
1658
|
+
if (group) group.push(name);
|
|
1659
|
+
else groups.set(root, [name]);
|
|
1660
|
+
}
|
|
1661
|
+
return [...groups.values()].filter((group) => group.length > 1);
|
|
879
1662
|
}
|
|
880
|
-
const DSH_AUTHORING_STANDARDS = `Follow the Hermes skill-authoring standards, translated to DSH tools.
|
|
881
|
-
|
|
882
|
-
Frontmatter:
|
|
883
|
-
- name: lowercase-hyphenated, <=64 chars, no spaces.
|
|
884
|
-
- description: ONE sentence, <=60 characters, ends with a period. State the capability, not the implementation. No marketing words. Do NOT repeat the skill name. Count the characters before saving.
|
|
885
|
-
- version: 0.1.0
|
|
886
|
-
- author: always the literal value "Hermes". NEVER fill it from the environment, git config, or any identity you can probe.
|
|
887
|
-
- platforms: declare [macos], [linux], and/or [windows] only when the skill is genuinely OS-bound; omit for portable skills.
|
|
888
|
-
- metadata.hermes.tags: a few Capitalized, Relevant, Tags.
|
|
889
|
-
|
|
890
|
-
Body section order (omit only when empty):
|
|
891
|
-
1. "# <Human Title>" then a 2-3 sentence intro: what it does, what it does NOT do, key dependency stance.
|
|
892
|
-
2. "## When to Use" — concrete trigger phrases.
|
|
893
|
-
3. "## Prerequisites" — exact env vars, install steps, credentials.
|
|
894
|
-
4. "## How to Run" — canonical invocation framed through DSH tools.
|
|
895
|
-
5. "## Quick Reference" — flat command/endpoint list.
|
|
896
|
-
6. "## Procedure" — numbered steps with copy-paste-exact commands.
|
|
897
|
-
7. "## Pitfalls" — known limits and rate limits.
|
|
898
|
-
8. "## Verification" — one check proving the skill worked.
|
|
899
|
-
|
|
900
|
-
DSH-tool framing:
|
|
901
|
-
- Reference DSH tools by name in backticks: \`bash\`, \`str_replace_editor\`, \`write\`, \`skill\`, \`skill_manage\`, \`memory\`.
|
|
902
|
-
- Do not name wrapped shell utilities when a DSH tool already covers them.
|
|
903
|
-
- Larger scripts belong under \`scripts/\` (written with \`skill_manage write_file\`) and are referenced from SKILL.md by relative path.
|
|
904
|
-
|
|
905
|
-
Quality bar:
|
|
906
|
-
- Prefer verbatim flags, paths, and APIs from the source. Never invent them.
|
|
907
|
-
- Keep it tight: ~100 lines simple, ~200 complex.
|
|
908
|
-
- No router/index/hub skills that only point at other skills.
|
|
909
|
-
- References go in \`references/\`, templates in \`templates/\`.`;
|
|
910
1663
|
//#endregion
|
|
911
1664
|
//#region lib/types/signals.js
|
|
912
1665
|
/**
|
|
@@ -994,26 +1747,41 @@ function foldTurn(session, fromSeq) {
|
|
|
994
1747
|
* it created unless a `.hermes-managed` marker opts a skill in. Archival is a
|
|
995
1748
|
* move to `.archive/` — never a hard delete.
|
|
996
1749
|
*/
|
|
997
|
-
const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
998
|
-
const MAX_SKILL_NAME_LENGTH = 64;
|
|
999
|
-
const MAX_DESCRIPTION_LENGTH = 1024;
|
|
1000
|
-
const MAX_SKILL_CONTENT_CHARS = 1e5;
|
|
1001
|
-
const MAX_SKILL_FILE_BYTES = 1048576;
|
|
1002
1750
|
const DEFAULT_SKILL_LIMITS = {
|
|
1003
1751
|
maxNameLength: 64,
|
|
1004
1752
|
maxDescriptionLength: MAX_DESCRIPTION_LENGTH,
|
|
1005
1753
|
maxSkillContentChars: MAX_SKILL_CONTENT_CHARS,
|
|
1006
1754
|
maxSkillFileBytes: MAX_SKILL_FILE_BYTES
|
|
1007
1755
|
};
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
"templates",
|
|
1011
|
-
"scripts",
|
|
1012
|
-
"assets"
|
|
1013
|
-
];
|
|
1756
|
+
/** Extra file name carried inside a snapshot's `extras/` directory. */
|
|
1757
|
+
const SNAPSHOT_EXTRA_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
1014
1758
|
function skillsRoot(env = process.env) {
|
|
1015
1759
|
return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "skills");
|
|
1016
1760
|
}
|
|
1761
|
+
/**
|
|
1762
|
+
* Map a requesting session onto the two origin surfaces (rc.44 plan M2-2.3):
|
|
1763
|
+
* the APPROVAL surface treats every delegated subagent as the autonomous
|
|
1764
|
+
* review channel, while the LIBRARY surface keeps the Hermes distinction -
|
|
1765
|
+
* the review fork is 'background_review' (the pinned guard blocks its
|
|
1766
|
+
* writes) and any other subagent is 'subagent' (agent-authored, not
|
|
1767
|
+
* review-channel). `isReview` marks the caller as the background review
|
|
1768
|
+
* pipeline itself. Single source: the two tools and the review executor all
|
|
1769
|
+
* read this table instead of re-deriving it.
|
|
1770
|
+
*/
|
|
1771
|
+
function resolveOrigins(headerOrigin, isReview = false) {
|
|
1772
|
+
if (isReview) return {
|
|
1773
|
+
approval: "background_review",
|
|
1774
|
+
library: "background_review"
|
|
1775
|
+
};
|
|
1776
|
+
if (headerOrigin === "subagent") return {
|
|
1777
|
+
approval: "background_review",
|
|
1778
|
+
library: "subagent"
|
|
1779
|
+
};
|
|
1780
|
+
return {
|
|
1781
|
+
approval: "foreground",
|
|
1782
|
+
library: "foreground"
|
|
1783
|
+
};
|
|
1784
|
+
}
|
|
1017
1785
|
function skillDir(root, name) {
|
|
1018
1786
|
return join(root, name);
|
|
1019
1787
|
}
|
|
@@ -1040,6 +1808,26 @@ function parseFrontmatter(content) {
|
|
|
1040
1808
|
body
|
|
1041
1809
|
};
|
|
1042
1810
|
}
|
|
1811
|
+
/**
|
|
1812
|
+
* Skill names referenced by a SKILL.md's `related_skills` frontmatter
|
|
1813
|
+
* (B-line G3, rc.44): the single parsing source for the quality references
|
|
1814
|
+
* factor and the learning-graph edges. The DSH frontmatter parser keeps the
|
|
1815
|
+
* YAML value as a string (`"[a, b]"`), so names are scanned out of it; each
|
|
1816
|
+
* must satisfy the skill-name shape and the referencing skill itself is
|
|
1817
|
+
* excluded. Pure and deduplicated.
|
|
1818
|
+
*/
|
|
1819
|
+
function relatedSkillNames(content, exclude) {
|
|
1820
|
+
const parsed = parseFrontmatter(content);
|
|
1821
|
+
if (!parsed) return [];
|
|
1822
|
+
const raw = parsed.frontmatter["related_skills"];
|
|
1823
|
+
if (typeof raw !== "string") return [];
|
|
1824
|
+
const names = /* @__PURE__ */ new Set();
|
|
1825
|
+
for (const match of Array.from(raw.matchAll(/[a-z0-9][a-z0-9-]*/g))) {
|
|
1826
|
+
const target = match[0];
|
|
1827
|
+
if (target && SKILL_NAME_RE.test(target) && target !== exclude) names.add(target);
|
|
1828
|
+
}
|
|
1829
|
+
return [...names];
|
|
1830
|
+
}
|
|
1043
1831
|
function validateFrontmatter(content, expectedName, limits = DEFAULT_SKILL_LIMITS) {
|
|
1044
1832
|
const parsed = parseFrontmatter(content);
|
|
1045
1833
|
if (!parsed) return "SKILL.md must start and end with YAML frontmatter and include a body.";
|
|
@@ -1069,65 +1857,274 @@ function validateSupportPath(filePath) {
|
|
|
1069
1857
|
if (parts.length < 2) return "Provide a file name, not just a directory.";
|
|
1070
1858
|
return null;
|
|
1071
1859
|
}
|
|
1860
|
+
/**
|
|
1861
|
+
* Index of `pattern` inside `content`, treating whitespace runs (spaces/tabs)
|
|
1862
|
+
* as flexible and literal escape sequences (`\n`, `\t`, `\r`) as their real
|
|
1863
|
+
* characters: a PATTERN whitespace run matches any content run of any length
|
|
1864
|
+
* (even empty), while extra whitespace that only exists in the content is not
|
|
1865
|
+
* skipped — the flexibility is one-sided on the pattern, and a backslash-
|
|
1866
|
+
* escaped char in the pattern matches the real char in the content
|
|
1867
|
+
* (model-copy drift). Returns the [start, end) range in the ORIGINAL content
|
|
1868
|
+
* so a patch can replace exactly the matched span and keep every other byte
|
|
1869
|
+
* intact. Returns null when no fuzzy match exists.
|
|
1870
|
+
*/
|
|
1871
|
+
function fuzzyIndexOf(content, pattern, from = 0) {
|
|
1872
|
+
const isSpace = (char) => char !== void 0 && /[ \t]/.test(char);
|
|
1873
|
+
const escaped = (char) => {
|
|
1874
|
+
if (char === "n") return "\n";
|
|
1875
|
+
if (char === "t") return " ";
|
|
1876
|
+
if (char === "r") return "\r";
|
|
1877
|
+
return null;
|
|
1878
|
+
};
|
|
1879
|
+
for (let start = from; start < content.length; start += 1) {
|
|
1880
|
+
let contentIndex = start;
|
|
1881
|
+
let patternIndex = 0;
|
|
1882
|
+
while (patternIndex < pattern.length && contentIndex < content.length) {
|
|
1883
|
+
const patternChar = pattern[patternIndex];
|
|
1884
|
+
const contentChar = content[contentIndex];
|
|
1885
|
+
if (isSpace(patternChar)) {
|
|
1886
|
+
while (patternIndex < pattern.length && isSpace(pattern[patternIndex])) patternIndex += 1;
|
|
1887
|
+
while (contentIndex < content.length && isSpace(content[contentIndex])) contentIndex += 1;
|
|
1888
|
+
continue;
|
|
1889
|
+
}
|
|
1890
|
+
const escapedChar = patternChar === "\\" ? escaped(pattern[patternIndex + 1]) : null;
|
|
1891
|
+
if (escapedChar !== null && contentChar === escapedChar) {
|
|
1892
|
+
patternIndex += 2;
|
|
1893
|
+
contentIndex += 1;
|
|
1894
|
+
continue;
|
|
1895
|
+
}
|
|
1896
|
+
if (patternChar === contentChar) {
|
|
1897
|
+
contentIndex += 1;
|
|
1898
|
+
patternIndex += 1;
|
|
1899
|
+
continue;
|
|
1900
|
+
}
|
|
1901
|
+
break;
|
|
1902
|
+
}
|
|
1903
|
+
if (patternIndex === pattern.length) return [start, contentIndex];
|
|
1904
|
+
}
|
|
1905
|
+
return null;
|
|
1906
|
+
}
|
|
1907
|
+
/** Trim leading whitespace of the first line and trailing whitespace of the last line. */
|
|
1908
|
+
function trimPatternBoundaries(pattern) {
|
|
1909
|
+
const from = pattern.search(/\S/);
|
|
1910
|
+
const trimmed = from < 0 ? pattern : pattern.slice(from);
|
|
1911
|
+
const trailing = trimmed.search(/\s+$/);
|
|
1912
|
+
return trailing < 0 ? trimmed : trimmed.slice(0, trailing);
|
|
1913
|
+
}
|
|
1914
|
+
/** Replace only the fuzzy-matched span, preserving all surrounding bytes. */
|
|
1915
|
+
function fuzzyReplace(content, oldString, newString, replaceAll) {
|
|
1916
|
+
let current = content;
|
|
1917
|
+
let scanFrom = 0;
|
|
1918
|
+
for (;;) {
|
|
1919
|
+
const match = fuzzyIndexOf(current, oldString, scanFrom);
|
|
1920
|
+
if (match === null) return current;
|
|
1921
|
+
const [start, end] = match;
|
|
1922
|
+
const next = current.slice(0, start) + newString + current.slice(end);
|
|
1923
|
+
if (!replaceAll) return next;
|
|
1924
|
+
current = next;
|
|
1925
|
+
scanFrom = start + newString.length;
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1072
1928
|
function fuzzyPatch(content, oldString, newString, replaceAll = false) {
|
|
1929
|
+
if (oldString === "") return null;
|
|
1073
1930
|
if (content.includes(oldString)) return replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString);
|
|
1074
|
-
const
|
|
1075
|
-
if (
|
|
1076
|
-
|
|
1077
|
-
|
|
1931
|
+
const boundary = trimPatternBoundaries(oldString);
|
|
1932
|
+
if (boundary === "") return null;
|
|
1933
|
+
if (boundary !== oldString) {
|
|
1934
|
+
if (fuzzyIndexOf(content, boundary) !== null) {
|
|
1935
|
+
const patched = fuzzyReplace(content, boundary, newString, replaceAll);
|
|
1936
|
+
return patched === content ? null : patched;
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
if (fuzzyIndexOf(content, oldString) !== null) {
|
|
1940
|
+
const patched = fuzzyReplace(content, oldString, newString, replaceAll);
|
|
1941
|
+
return patched === content ? null : patched;
|
|
1942
|
+
}
|
|
1078
1943
|
return null;
|
|
1079
1944
|
}
|
|
1080
1945
|
var SkillLibrary = class {
|
|
1081
1946
|
root;
|
|
1082
1947
|
limits;
|
|
1083
1948
|
io;
|
|
1084
|
-
|
|
1949
|
+
onMutation;
|
|
1950
|
+
constructor(root = skillsRoot(), io = nodeEvolutionIo(), limits = DEFAULT_SKILL_LIMITS, onMutation) {
|
|
1085
1951
|
this.root = root;
|
|
1086
1952
|
this.io = io;
|
|
1087
1953
|
this.limits = limits;
|
|
1954
|
+
this.onMutation = onMutation;
|
|
1955
|
+
}
|
|
1956
|
+
/** Notify the mutation observer after a successful write; observers must never fail the mutation. */
|
|
1957
|
+
notifyMutation(event) {
|
|
1958
|
+
try {
|
|
1959
|
+
this.onMutation?.(event);
|
|
1960
|
+
} catch {}
|
|
1088
1961
|
}
|
|
1089
1962
|
async list() {
|
|
1090
1963
|
const summaries = [];
|
|
1091
1964
|
for (const name of await listNames(this.root, this.io)) {
|
|
1092
|
-
const dir =
|
|
1965
|
+
const dir = this.dirOf(name);
|
|
1093
1966
|
const md = await this.io.readText(join(dir, "SKILL.md"));
|
|
1094
1967
|
if (!md) continue;
|
|
1095
1968
|
const parsed = parseFrontmatter(md);
|
|
1096
|
-
|
|
1097
|
-
|
|
1969
|
+
let entries = [];
|
|
1970
|
+
try {
|
|
1971
|
+
entries = await this.io.list(dir);
|
|
1972
|
+
} catch {}
|
|
1973
|
+
const has = (marker) => entries.includes(marker);
|
|
1974
|
+
const protectedBy = has("bundled") ? "bundled" : has("hub-installed") ? "hub-installed" : has("pinned") ? "pinned" : null;
|
|
1098
1975
|
summaries.push({
|
|
1099
1976
|
name,
|
|
1100
1977
|
description: parsed?.frontmatter.description ?? "",
|
|
1101
1978
|
path: dir,
|
|
1102
1979
|
protectedBy,
|
|
1103
|
-
managed,
|
|
1980
|
+
managed: has("hermes-managed"),
|
|
1104
1981
|
archived: false
|
|
1105
1982
|
});
|
|
1106
1983
|
}
|
|
1107
1984
|
return summaries;
|
|
1108
1985
|
}
|
|
1109
|
-
async read(
|
|
1110
|
-
|
|
1986
|
+
async read(rawName) {
|
|
1987
|
+
const name = rawName.trim();
|
|
1988
|
+
if (this.badName(name) !== null) return null;
|
|
1989
|
+
return this.io.readText(join(this.dirOf(name), "SKILL.md"));
|
|
1990
|
+
}
|
|
1991
|
+
/**
|
|
1992
|
+
|
|
1993
|
+
* Single path-building choke point (rc.42 audit P2-5): every directory path
|
|
1994
|
+
|
|
1995
|
+
* is built from the TRIMMED name, so a name that passes `badName` (which
|
|
1996
|
+
|
|
1997
|
+
* trims before validating) can never mint a second, whitespace-padded
|
|
1998
|
+
|
|
1999
|
+
* directory next to the real one. Callers keep passing raw user input.
|
|
2000
|
+
|
|
2001
|
+
*/
|
|
2002
|
+
dirOf(name) {
|
|
2003
|
+
return skillDir(this.root, name.trim());
|
|
2004
|
+
}
|
|
2005
|
+
/** Name-format guard shared by every path-building mutator/reader. */
|
|
2006
|
+
badName(name) {
|
|
2007
|
+
const normalized = name.trim();
|
|
2008
|
+
if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return `Invalid skill name "${normalized}". Use lowercase letters, digits, and hyphens (<= ${this.limits.maxNameLength}).`;
|
|
2009
|
+
return null;
|
|
1111
2010
|
}
|
|
1112
|
-
async writeProtection(
|
|
1113
|
-
const
|
|
2011
|
+
async writeProtection(rawName, origin = "foreground") {
|
|
2012
|
+
const name = rawName.trim();
|
|
2013
|
+
const dir = this.dirOf(name);
|
|
1114
2014
|
for (const marker of ["bundled", "hub-installed"]) if (await this.io.exists(markerPath(dir, marker))) return marker;
|
|
2015
|
+
if (origin === "background_review" && await this.io.exists(markerPath(dir, "pinned"))) return "pinned";
|
|
1115
2016
|
return null;
|
|
1116
2017
|
}
|
|
1117
|
-
async deleteProtection(
|
|
1118
|
-
const
|
|
1119
|
-
|
|
2018
|
+
async deleteProtection(rawName, options = {}) {
|
|
2019
|
+
const name = rawName.trim();
|
|
2020
|
+
const dir = this.dirOf(name);
|
|
2021
|
+
const markers = options.allowBundled ? ["hub-installed", "pinned"] : [
|
|
1120
2022
|
"bundled",
|
|
1121
2023
|
"hub-installed",
|
|
1122
2024
|
"pinned"
|
|
1123
|
-
]
|
|
2025
|
+
];
|
|
2026
|
+
for (const marker of markers) if (await this.io.exists(markerPath(dir, marker))) return marker;
|
|
1124
2027
|
return null;
|
|
1125
2028
|
}
|
|
1126
|
-
async isManaged(
|
|
1127
|
-
const
|
|
2029
|
+
async isManaged(rawName) {
|
|
2030
|
+
const name = rawName.trim();
|
|
2031
|
+
const dir = this.dirOf(name);
|
|
1128
2032
|
return await this.io.exists(markerPath(dir, "hermes-managed"));
|
|
1129
2033
|
}
|
|
1130
|
-
|
|
2034
|
+
/** Whether the skill carries the bundled marker (curator prune-builtins eligibility). */
|
|
2035
|
+
async isBundled(rawName) {
|
|
2036
|
+
const name = rawName.trim();
|
|
2037
|
+
if (this.badName(name) !== null) return false;
|
|
2038
|
+
const dir = this.dirOf(name);
|
|
2039
|
+
return await this.io.exists(markerPath(dir, "bundled"));
|
|
2040
|
+
}
|
|
2041
|
+
/** Whether the skill carries the pinned marker (the marker is the factual source; usage.pinned mirrors it). */
|
|
2042
|
+
async isPinned(rawName) {
|
|
2043
|
+
const name = rawName.trim();
|
|
2044
|
+
if (this.badName(name) !== null) return false;
|
|
2045
|
+
const dir = this.dirOf(name);
|
|
2046
|
+
return await this.io.exists(markerPath(dir, "pinned"));
|
|
2047
|
+
}
|
|
2048
|
+
/** Count non-empty support subdirectories (richness input for quality scoring). */
|
|
2049
|
+
async countSupportDirs(rawName) {
|
|
2050
|
+
const name = rawName.trim();
|
|
2051
|
+
if (this.badName(name) !== null) return 0;
|
|
2052
|
+
const dir = this.dirOf(name);
|
|
2053
|
+
let entries;
|
|
2054
|
+
try {
|
|
2055
|
+
entries = await this.io.list(dir);
|
|
2056
|
+
} catch {
|
|
2057
|
+
return 0;
|
|
2058
|
+
}
|
|
2059
|
+
let count = 0;
|
|
2060
|
+
for (const subdir of SUPPORT_DIRS) {
|
|
2061
|
+
if (!entries.includes(subdir)) continue;
|
|
2062
|
+
try {
|
|
2063
|
+
if ((await this.io.list(join(dir, subdir))).some((file) => file !== ".gitkeep")) count += 1;
|
|
2064
|
+
} catch {}
|
|
2065
|
+
}
|
|
2066
|
+
return count;
|
|
2067
|
+
}
|
|
2068
|
+
/** Best-effort audit trail entry; never blocks the mutation. */
|
|
2069
|
+
async audit(skillName, action, before, after, summary) {
|
|
2070
|
+
try {
|
|
2071
|
+
await recordMutation(this.root, this.io, {
|
|
2072
|
+
skillName,
|
|
2073
|
+
action,
|
|
2074
|
+
...before === null ? {} : { beforeHash: contentHash(before) },
|
|
2075
|
+
...after === null ? {} : { afterHash: contentHash(after) },
|
|
2076
|
+
summary,
|
|
2077
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2078
|
+
});
|
|
2079
|
+
} catch {}
|
|
2080
|
+
}
|
|
2081
|
+
/** Recent mutation audit records (read-only inspection surface). */
|
|
2082
|
+
async listMutations() {
|
|
2083
|
+
return await loadMutations(this.root, this.io);
|
|
2084
|
+
}
|
|
2085
|
+
/**
|
|
2086
|
+
* Pin or unpin a skill (`.pinned` marker). Pinned skills are protected from
|
|
2087
|
+
* deletion, from background-review writes, and from the lifecycle — a
|
|
2088
|
+
* protective mutation, so the autonomous pipeline may never call it. The
|
|
2089
|
+
* marker write is the only state change; content is untouched.
|
|
2090
|
+
*/
|
|
2091
|
+
async setPinned(name, pinned, origin = "foreground") {
|
|
2092
|
+
const normalized = name.trim();
|
|
2093
|
+
if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return {
|
|
2094
|
+
ok: false,
|
|
2095
|
+
message: `Invalid skill name "${normalized}". Use lowercase letters, digits, and hyphens (<= ${this.limits.maxNameLength}).`
|
|
2096
|
+
};
|
|
2097
|
+
if (origin === "background_review") return {
|
|
2098
|
+
ok: false,
|
|
2099
|
+
message: "Only the foreground (user or the main agent) may pin or unpin skills."
|
|
2100
|
+
};
|
|
2101
|
+
const dir = this.dirOf(normalized);
|
|
2102
|
+
const marker = markerPath(dir, "pinned");
|
|
2103
|
+
const existing = await this.io.exists(marker);
|
|
2104
|
+
if (pinned && existing) return {
|
|
2105
|
+
ok: true,
|
|
2106
|
+
message: `Skill "${normalized}" is already pinned.`,
|
|
2107
|
+
path: dir
|
|
2108
|
+
};
|
|
2109
|
+
if (!pinned && !existing) return {
|
|
2110
|
+
ok: true,
|
|
2111
|
+
message: `Skill "${normalized}" is not pinned; nothing to do.`,
|
|
2112
|
+
path: dir
|
|
2113
|
+
};
|
|
2114
|
+
if (!await this.io.exists(join(dir, "SKILL.md"))) return {
|
|
2115
|
+
ok: false,
|
|
2116
|
+
message: `Skill "${normalized}" not found.`
|
|
2117
|
+
};
|
|
2118
|
+
if (pinned) await this.io.writeText(marker, "");
|
|
2119
|
+
else await this.io.remove(marker);
|
|
2120
|
+
await this.audit(normalized, pinned ? "pin" : "unpin", null, null, pinned ? "pinned" : "unpinned");
|
|
2121
|
+
return {
|
|
2122
|
+
ok: true,
|
|
2123
|
+
message: pinned ? `Skill "${normalized}" pinned: protected from deletion, background review, and the lifecycle.` : `Skill "${normalized}" unpinned.`,
|
|
2124
|
+
path: dir
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
async create(name, content, origin = "foreground") {
|
|
1131
2128
|
const normalized = name.trim();
|
|
1132
2129
|
if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return {
|
|
1133
2130
|
ok: false,
|
|
@@ -1143,26 +2140,39 @@ var SkillLibrary = class {
|
|
|
1143
2140
|
ok: false,
|
|
1144
2141
|
message: threat
|
|
1145
2142
|
};
|
|
1146
|
-
const dir =
|
|
2143
|
+
const dir = this.dirOf(normalized);
|
|
1147
2144
|
if (await this.io.exists(join(dir, "SKILL.md"))) return {
|
|
1148
2145
|
ok: false,
|
|
1149
2146
|
message: `Skill "${normalized}" already exists.`
|
|
1150
2147
|
};
|
|
1151
2148
|
await this.io.writeText(join(dir, "SKILL.md"), content.trimEnd() + "\n");
|
|
1152
|
-
if (origin
|
|
2149
|
+
if (origin !== "foreground") await this.io.writeText(markerPath(dir, "hermes-managed"), "");
|
|
2150
|
+
await this.audit(normalized, "create", null, content, "created");
|
|
2151
|
+
this.notifyMutation({
|
|
2152
|
+
action: "create",
|
|
2153
|
+
name: normalized,
|
|
2154
|
+
filePath: dir
|
|
2155
|
+
});
|
|
1153
2156
|
return {
|
|
1154
2157
|
ok: true,
|
|
1155
2158
|
message: `Skill "${normalized}" created.`,
|
|
1156
2159
|
path: dir
|
|
1157
2160
|
};
|
|
1158
2161
|
}
|
|
1159
|
-
async update(
|
|
1160
|
-
const
|
|
1161
|
-
|
|
2162
|
+
async update(rawName, content, origin = "foreground") {
|
|
2163
|
+
const name = rawName.trim();
|
|
2164
|
+
const badName = this.badName(name);
|
|
2165
|
+
if (badName) return {
|
|
2166
|
+
ok: false,
|
|
2167
|
+
message: badName
|
|
2168
|
+
};
|
|
2169
|
+
const dir = this.dirOf(name);
|
|
2170
|
+
const md = await this.io.readText(join(dir, "SKILL.md"));
|
|
2171
|
+
if (!md) return {
|
|
1162
2172
|
ok: false,
|
|
1163
2173
|
message: `Skill "${name}" not found.`
|
|
1164
2174
|
};
|
|
1165
|
-
const protection = await this.writeProtection(name);
|
|
2175
|
+
const protection = await this.writeProtection(name, origin);
|
|
1166
2176
|
if (protection) return {
|
|
1167
2177
|
ok: false,
|
|
1168
2178
|
message: `Skill "${name}" is protected (${protection}).`
|
|
@@ -1178,20 +2188,32 @@ var SkillLibrary = class {
|
|
|
1178
2188
|
message: threat
|
|
1179
2189
|
};
|
|
1180
2190
|
await this.io.writeText(join(dir, "SKILL.md"), content.trimEnd() + "\n");
|
|
2191
|
+
await this.audit(name, "update", md, content, "updated");
|
|
2192
|
+
this.notifyMutation({
|
|
2193
|
+
action: "update",
|
|
2194
|
+
name,
|
|
2195
|
+
filePath: dir
|
|
2196
|
+
});
|
|
1181
2197
|
return {
|
|
1182
2198
|
ok: true,
|
|
1183
2199
|
message: `Skill "${name}" updated.`,
|
|
1184
2200
|
path: dir
|
|
1185
2201
|
};
|
|
1186
2202
|
}
|
|
1187
|
-
async patch(
|
|
1188
|
-
const
|
|
2203
|
+
async patch(rawName, oldString, newString, filePath = "", replaceAll = false, origin = "foreground") {
|
|
2204
|
+
const name = rawName.trim();
|
|
2205
|
+
const badName = this.badName(name);
|
|
2206
|
+
if (badName) return {
|
|
2207
|
+
ok: false,
|
|
2208
|
+
message: badName
|
|
2209
|
+
};
|
|
2210
|
+
const dir = this.dirOf(name);
|
|
1189
2211
|
const skillMd = join(dir, "SKILL.md");
|
|
1190
2212
|
if (!await this.io.exists(skillMd)) return {
|
|
1191
2213
|
ok: false,
|
|
1192
2214
|
message: `Skill "${name}" not found.`
|
|
1193
2215
|
};
|
|
1194
|
-
const protection = await this.writeProtection(name);
|
|
2216
|
+
const protection = await this.writeProtection(name, origin);
|
|
1195
2217
|
if (protection) return {
|
|
1196
2218
|
ok: false,
|
|
1197
2219
|
message: `Skill "${name}" is protected (${protection}).`
|
|
@@ -1213,7 +2235,7 @@ var SkillLibrary = class {
|
|
|
1213
2235
|
message: `File not found: ${patchLabel}`
|
|
1214
2236
|
};
|
|
1215
2237
|
const patched = fuzzyPatch(md, oldString, newString, replaceAll);
|
|
1216
|
-
if (
|
|
2238
|
+
if (patched === null) return {
|
|
1217
2239
|
ok: false,
|
|
1218
2240
|
message: `Could not find old_string in "${name}/${patchLabel}". Use update for a full rewrite.`
|
|
1219
2241
|
};
|
|
@@ -1238,40 +2260,68 @@ var SkillLibrary = class {
|
|
|
1238
2260
|
message: threat
|
|
1239
2261
|
};
|
|
1240
2262
|
await this.io.writeText(target, patched.trimEnd() + "\n");
|
|
2263
|
+
await this.audit(name, "patch", md, patched, `patched ${patchLabel}`);
|
|
2264
|
+
this.notifyMutation({
|
|
2265
|
+
action: "patch",
|
|
2266
|
+
name,
|
|
2267
|
+
filePath: dir
|
|
2268
|
+
});
|
|
1241
2269
|
return {
|
|
1242
2270
|
ok: true,
|
|
1243
2271
|
message: `Skill "${name}" patched (${patchLabel}).`,
|
|
1244
2272
|
path: dir
|
|
1245
2273
|
};
|
|
1246
2274
|
}
|
|
1247
|
-
async archive(
|
|
1248
|
-
const
|
|
1249
|
-
|
|
2275
|
+
async archive(rawName, options = {}) {
|
|
2276
|
+
const name = rawName.trim();
|
|
2277
|
+
const badName = this.badName(name);
|
|
2278
|
+
if (badName) return {
|
|
2279
|
+
ok: false,
|
|
2280
|
+
message: badName
|
|
2281
|
+
};
|
|
2282
|
+
const dir = this.dirOf(name);
|
|
2283
|
+
const md = await this.io.readText(join(dir, "SKILL.md"));
|
|
2284
|
+
if (!md) return {
|
|
1250
2285
|
ok: false,
|
|
1251
2286
|
message: `Skill "${name}" not found.`
|
|
1252
2287
|
};
|
|
1253
|
-
const protection = await this.deleteProtection(name);
|
|
2288
|
+
const protection = await this.deleteProtection(name, options.allowBundled === void 0 ? {} : { allowBundled: options.allowBundled });
|
|
1254
2289
|
if (protection) return {
|
|
1255
2290
|
ok: false,
|
|
1256
2291
|
message: `Skill "${name}" is protected (${protection}).`
|
|
1257
2292
|
};
|
|
1258
|
-
if (absorbedInto) {
|
|
1259
|
-
if (!await this.io.readText(join(
|
|
2293
|
+
if (options.absorbedInto) {
|
|
2294
|
+
if (!await this.io.readText(join(this.dirOf(options.absorbedInto), "SKILL.md"))) return {
|
|
1260
2295
|
ok: false,
|
|
1261
|
-
message: `absorbed_into="${absorbedInto}" does not exist.`
|
|
2296
|
+
message: `absorbed_into="${options.absorbedInto}" does not exist.`
|
|
1262
2297
|
};
|
|
1263
2298
|
}
|
|
1264
2299
|
const archiveRoot = join(this.root, ".archive");
|
|
1265
|
-
let dest = join(archiveRoot, name);
|
|
1266
|
-
if (await this.io.exists(dest))
|
|
2300
|
+
let dest = join(archiveRoot, name.trim());
|
|
2301
|
+
if (await this.io.exists(dest)) {
|
|
2302
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 14);
|
|
2303
|
+
dest = join(archiveRoot, `${name.trim()}-${stamp}`);
|
|
2304
|
+
}
|
|
2305
|
+
if (this.io.isSymlink) {
|
|
2306
|
+
if (await this.io.isSymlink(dir) === true) return {
|
|
2307
|
+
ok: false,
|
|
2308
|
+
message: `Skill "${name}" is a symlink; refusing to archive it.`
|
|
2309
|
+
};
|
|
2310
|
+
}
|
|
1267
2311
|
try {
|
|
1268
2312
|
await this.io.rename(dir, dest);
|
|
1269
2313
|
} catch {
|
|
1270
2314
|
await this.io.copy(dir, dest);
|
|
1271
2315
|
await this.io.remove(dir);
|
|
1272
2316
|
}
|
|
1273
|
-
const reason = absorbedInto ? `Consolidated into ${absorbedInto}` : "Archived by self-evolution curator";
|
|
2317
|
+
const reason = options.reason ?? (options.absorbedInto ? `Consolidated into ${options.absorbedInto}` : "Archived by self-evolution curator");
|
|
1274
2318
|
await this.io.writeText(join(dest, ".archive-reason"), `${(/* @__PURE__ */ new Date()).toISOString()}: ${reason}\n`);
|
|
2319
|
+
await this.audit(name, "archive", md, null, reason);
|
|
2320
|
+
this.notifyMutation({
|
|
2321
|
+
action: "archive",
|
|
2322
|
+
name,
|
|
2323
|
+
archivedPath: dest
|
|
2324
|
+
});
|
|
1275
2325
|
return {
|
|
1276
2326
|
ok: true,
|
|
1277
2327
|
message: `Skill "${name}" archived to .archive.`,
|
|
@@ -1283,26 +2333,27 @@ var SkillLibrary = class {
|
|
|
1283
2333
|
* an absorbed-into marker. Hermes-style consolidation: overlapping skills
|
|
1284
2334
|
* collapse into one, and the originals stay recoverable under `.archive/`.
|
|
1285
2335
|
*/
|
|
1286
|
-
async consolidate(target, sources) {
|
|
1287
|
-
const
|
|
2336
|
+
async consolidate(target, sources, origin = "foreground") {
|
|
2337
|
+
const targetName = target.trim();
|
|
2338
|
+
const normalizedSources = [...new Set(sources.map((name) => name.trim()))].filter((name) => name !== targetName);
|
|
1288
2339
|
if (normalizedSources.length === 0) return {
|
|
1289
2340
|
ok: false,
|
|
1290
2341
|
message: "Consolidation requires at least one distinct source skill."
|
|
1291
2342
|
};
|
|
1292
|
-
for (const name of [
|
|
2343
|
+
for (const name of [targetName, ...normalizedSources]) if (!SKILL_NAME_RE.test(name)) return {
|
|
1293
2344
|
ok: false,
|
|
1294
2345
|
message: `Invalid skill name "${name}". Use lowercase letters, digits, and hyphens.`
|
|
1295
2346
|
};
|
|
1296
|
-
const targetDir =
|
|
2347
|
+
const targetDir = this.dirOf(targetName);
|
|
1297
2348
|
const targetMd = await this.io.readText(join(targetDir, "SKILL.md"));
|
|
1298
2349
|
if (!targetMd) return {
|
|
1299
2350
|
ok: false,
|
|
1300
|
-
message: `Skill "${
|
|
2351
|
+
message: `Skill "${targetName}" not found.`
|
|
1301
2352
|
};
|
|
1302
|
-
const targetProtection = await this.writeProtection(
|
|
2353
|
+
const targetProtection = await this.writeProtection(targetName, origin);
|
|
1303
2354
|
if (targetProtection) return {
|
|
1304
2355
|
ok: false,
|
|
1305
|
-
message: `Skill "${
|
|
2356
|
+
message: `Skill "${targetName}" is protected (${targetProtection}).`
|
|
1306
2357
|
};
|
|
1307
2358
|
const parts = [];
|
|
1308
2359
|
for (const source of normalizedSources) {
|
|
@@ -1311,7 +2362,7 @@ var SkillLibrary = class {
|
|
|
1311
2362
|
ok: false,
|
|
1312
2363
|
message: `Skill "${source}" is protected (${protection}).`
|
|
1313
2364
|
};
|
|
1314
|
-
const sourceMd = await this.io.readText(join(
|
|
2365
|
+
const sourceMd = await this.io.readText(join(this.dirOf(source), "SKILL.md"));
|
|
1315
2366
|
if (!sourceMd) return {
|
|
1316
2367
|
ok: false,
|
|
1317
2368
|
message: `Skill "${source}" not found.`
|
|
@@ -1324,7 +2375,7 @@ var SkillLibrary = class {
|
|
|
1324
2375
|
parts.push(`\n<!-- consolidated from ${source} at ${(/* @__PURE__ */ new Date()).toISOString()} -->\n${parsed.body.trim()}`);
|
|
1325
2376
|
}
|
|
1326
2377
|
const merged = targetMd.trimEnd() + parts.join("\n") + "\n";
|
|
1327
|
-
const validation = validateFrontmatter(merged,
|
|
2378
|
+
const validation = validateFrontmatter(merged, targetName, this.limits);
|
|
1328
2379
|
if (validation) return {
|
|
1329
2380
|
ok: false,
|
|
1330
2381
|
message: `Consolidation rejected: ${validation}`
|
|
@@ -1334,14 +2385,30 @@ var SkillLibrary = class {
|
|
|
1334
2385
|
ok: false,
|
|
1335
2386
|
message: threat
|
|
1336
2387
|
};
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
const
|
|
1340
|
-
|
|
2388
|
+
const archived = [];
|
|
2389
|
+
try {
|
|
2390
|
+
for (const source of normalizedSources) {
|
|
2391
|
+
const result = await this.archive(source, { absorbedInto: targetName });
|
|
2392
|
+
if (!result.ok) throw new Error(result.message);
|
|
2393
|
+
archived.push(source);
|
|
2394
|
+
}
|
|
2395
|
+
await this.io.writeText(join(targetDir, "SKILL.md"), merged);
|
|
2396
|
+
} catch (error) {
|
|
2397
|
+
await this.io.writeText(join(targetDir, "SKILL.md"), targetMd).catch(() => {});
|
|
2398
|
+
for (const source of archived.reverse()) await this.restoreFromArchive(source).catch(() => {});
|
|
2399
|
+
return {
|
|
2400
|
+
ok: false,
|
|
2401
|
+
message: `Consolidation failed and was rolled back: ${error instanceof Error ? error.message : String(error)}`
|
|
2402
|
+
};
|
|
1341
2403
|
}
|
|
2404
|
+
this.notifyMutation({
|
|
2405
|
+
action: "consolidate",
|
|
2406
|
+
name: targetName,
|
|
2407
|
+
filePath: targetDir
|
|
2408
|
+
});
|
|
1342
2409
|
return {
|
|
1343
2410
|
ok: true,
|
|
1344
|
-
message: `Consolidated ${normalizedSources.join(", ")} into "${
|
|
2411
|
+
message: `Consolidated ${normalizedSources.join(", ")} into "${targetName}".`,
|
|
1345
2412
|
path: targetDir
|
|
1346
2413
|
};
|
|
1347
2414
|
}
|
|
@@ -1350,12 +2417,13 @@ var SkillLibrary = class {
|
|
|
1350
2417
|
* recoverability: archival never deletes, and this is the control-plane
|
|
1351
2418
|
* path back. The `.archive-reason` marker is dropped on restore.
|
|
1352
2419
|
*/
|
|
1353
|
-
async restoreFromArchive(
|
|
2420
|
+
async restoreFromArchive(rawName) {
|
|
2421
|
+
const name = rawName.trim();
|
|
1354
2422
|
if (!SKILL_NAME_RE.test(name)) return {
|
|
1355
2423
|
ok: false,
|
|
1356
2424
|
message: `Invalid skill name "${name}". Use lowercase letters, digits, and hyphens.`
|
|
1357
2425
|
};
|
|
1358
|
-
if (await this.io.exists(join(
|
|
2426
|
+
if (await this.io.exists(join(this.dirOf(name), "SKILL.md"))) return {
|
|
1359
2427
|
ok: false,
|
|
1360
2428
|
message: `Skill "${name}" already exists in the active root; refusing to overwrite.`
|
|
1361
2429
|
};
|
|
@@ -1375,7 +2443,13 @@ var SkillLibrary = class {
|
|
|
1375
2443
|
message: `Skill "${name}" is not in .archive.`
|
|
1376
2444
|
};
|
|
1377
2445
|
const source = join(archiveRoot, chosen);
|
|
1378
|
-
const dest =
|
|
2446
|
+
const dest = this.dirOf(name);
|
|
2447
|
+
if (this.io.isSymlink) {
|
|
2448
|
+
if (await this.io.isSymlink(source) === true) return {
|
|
2449
|
+
ok: false,
|
|
2450
|
+
message: `Archived entry "${chosen}" is a symlink; refusing to restore it.`
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
1379
2453
|
try {
|
|
1380
2454
|
await this.io.rename(source, dest);
|
|
1381
2455
|
} catch {
|
|
@@ -1383,19 +2457,30 @@ var SkillLibrary = class {
|
|
|
1383
2457
|
await this.io.remove(source);
|
|
1384
2458
|
}
|
|
1385
2459
|
if (await this.io.exists(join(dest, ".archive-reason"))) await this.io.remove(join(dest, ".archive-reason"));
|
|
2460
|
+
this.notifyMutation({
|
|
2461
|
+
action: "restore",
|
|
2462
|
+
name,
|
|
2463
|
+
filePath: dest
|
|
2464
|
+
});
|
|
1386
2465
|
return {
|
|
1387
2466
|
ok: true,
|
|
1388
2467
|
message: `Skill "${name}" restored from .archive.`,
|
|
1389
2468
|
path: dest
|
|
1390
2469
|
};
|
|
1391
2470
|
}
|
|
1392
|
-
async writeSupportFile(
|
|
1393
|
-
const
|
|
2471
|
+
async writeSupportFile(rawName, filePath, content, origin = "foreground") {
|
|
2472
|
+
const name = rawName.trim();
|
|
2473
|
+
const badName = this.badName(name);
|
|
2474
|
+
if (badName) return {
|
|
2475
|
+
ok: false,
|
|
2476
|
+
message: badName
|
|
2477
|
+
};
|
|
2478
|
+
const dir = this.dirOf(name);
|
|
1394
2479
|
if (!await this.io.exists(join(dir, "SKILL.md"))) return {
|
|
1395
2480
|
ok: false,
|
|
1396
2481
|
message: `Skill "${name}" not found.`
|
|
1397
2482
|
};
|
|
1398
|
-
const protection = await this.writeProtection(name);
|
|
2483
|
+
const protection = await this.writeProtection(name, origin);
|
|
1399
2484
|
if (protection) return {
|
|
1400
2485
|
ok: false,
|
|
1401
2486
|
message: `Skill "${name}" is protected (${protection}).`
|
|
@@ -1415,20 +2500,33 @@ var SkillLibrary = class {
|
|
|
1415
2500
|
message: threat
|
|
1416
2501
|
};
|
|
1417
2502
|
const target = join(dir, ...filePath.replace(/\\/g, "/").split("/").filter(Boolean));
|
|
2503
|
+
const existing = await this.io.readText(target).catch(() => null);
|
|
1418
2504
|
await this.io.writeText(target, content);
|
|
2505
|
+
await this.audit(name, "write_file", existing, content, `wrote ${filePath}`);
|
|
2506
|
+
this.notifyMutation({
|
|
2507
|
+
action: "write_file",
|
|
2508
|
+
name,
|
|
2509
|
+
filePath: target
|
|
2510
|
+
});
|
|
1419
2511
|
return {
|
|
1420
2512
|
ok: true,
|
|
1421
2513
|
message: `Support file "${filePath}" written to "${name}".`,
|
|
1422
2514
|
path: target
|
|
1423
2515
|
};
|
|
1424
2516
|
}
|
|
1425
|
-
async removeSupportFile(
|
|
1426
|
-
const
|
|
2517
|
+
async removeSupportFile(rawName, filePath, origin = "foreground") {
|
|
2518
|
+
const name = rawName.trim();
|
|
2519
|
+
const badName = this.badName(name);
|
|
2520
|
+
if (badName) return {
|
|
2521
|
+
ok: false,
|
|
2522
|
+
message: badName
|
|
2523
|
+
};
|
|
2524
|
+
const dir = this.dirOf(name);
|
|
1427
2525
|
if (!await this.io.exists(join(dir, "SKILL.md"))) return {
|
|
1428
2526
|
ok: false,
|
|
1429
2527
|
message: `Skill "${name}" not found.`
|
|
1430
2528
|
};
|
|
1431
|
-
const protection = await this.writeProtection(name);
|
|
2529
|
+
const protection = await this.writeProtection(name, origin);
|
|
1432
2530
|
if (protection) return {
|
|
1433
2531
|
ok: false,
|
|
1434
2532
|
message: `Skill "${name}" is protected (${protection}).`
|
|
@@ -1443,24 +2541,88 @@ var SkillLibrary = class {
|
|
|
1443
2541
|
ok: false,
|
|
1444
2542
|
message: `File "${filePath}" not found in skill "${name}".`
|
|
1445
2543
|
};
|
|
2544
|
+
const before = await this.io.readText(target).catch(() => null);
|
|
1446
2545
|
await this.io.remove(target);
|
|
2546
|
+
await this.audit(name, "remove_file", before, null, `removed ${filePath}`);
|
|
2547
|
+
this.notifyMutation({
|
|
2548
|
+
action: "remove_file",
|
|
2549
|
+
name,
|
|
2550
|
+
filePath: target
|
|
2551
|
+
});
|
|
1447
2552
|
return {
|
|
1448
2553
|
ok: true,
|
|
1449
2554
|
message: `Support file "${filePath}" removed from "${name}".`,
|
|
1450
2555
|
path: target
|
|
1451
2556
|
};
|
|
1452
2557
|
}
|
|
1453
|
-
|
|
1454
|
-
|
|
2558
|
+
/**
|
|
2559
|
+
* Snapshot the recoverable skills state: active tree, usage/suppression
|
|
2560
|
+
* sidecars, `.archive/` and caller-supplied extras. `extras` are opaque
|
|
2561
|
+
* side files the Snapshot owner cares about (curator state); they are
|
|
2562
|
+
* listed in the manifest and only those names are ever read back.
|
|
2563
|
+
*/
|
|
2564
|
+
async snapshotAll(reason = "pre-mutation", extras = []) {
|
|
2565
|
+
const backupRoot = join(this.root, ".backups");
|
|
2566
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
2567
|
+
let dest = join(backupRoot, `skills-${stamp}`);
|
|
2568
|
+
while (await this.io.exists(dest)) dest = join(backupRoot, `skills-${stamp}-${Math.random().toString(36).slice(2, 8)}`);
|
|
1455
2569
|
const names = await listNames(this.root, this.io);
|
|
1456
|
-
|
|
2570
|
+
await Promise.all(names.map(async (name) => {
|
|
2571
|
+
await this.io.copy(this.dirOf(name), join(dest, name));
|
|
2572
|
+
}));
|
|
2573
|
+
const sidecars = [];
|
|
2574
|
+
for (const sidecar of [usageFile(this.root), suppressedFile(this.root)]) if (await this.io.exists(sidecar)) {
|
|
2575
|
+
const name = basename(sidecar);
|
|
2576
|
+
await this.io.copy(sidecar, join(dest, name));
|
|
2577
|
+
sidecars.push(name);
|
|
2578
|
+
}
|
|
2579
|
+
const archiveRoot = join(this.root, ".archive");
|
|
2580
|
+
let hasArchive = false;
|
|
2581
|
+
if (await this.io.exists(archiveRoot)) {
|
|
2582
|
+
await this.io.copy(archiveRoot, join(dest, ".archive"));
|
|
2583
|
+
hasArchive = true;
|
|
2584
|
+
}
|
|
2585
|
+
const validExtras = extras.filter((extra) => SNAPSHOT_EXTRA_NAME_RE.test(extra.name));
|
|
2586
|
+
const extraNames = validExtras.map((extra) => extra.name);
|
|
2587
|
+
await Promise.all(validExtras.map(async (extra) => {
|
|
2588
|
+
await this.io.writeText(join(dest, "extras", extra.name), extra.content);
|
|
2589
|
+
}));
|
|
1457
2590
|
await this.io.writeText(join(dest, "manifest.json"), JSON.stringify({
|
|
1458
2591
|
reason,
|
|
1459
2592
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1460
|
-
skills: names
|
|
2593
|
+
skills: names,
|
|
2594
|
+
sidecars,
|
|
2595
|
+
hasArchive,
|
|
2596
|
+
extras: extraNames
|
|
1461
2597
|
}, null, 2));
|
|
2598
|
+
await this.retainSnapshots(5);
|
|
1462
2599
|
return dest;
|
|
1463
2600
|
}
|
|
2601
|
+
/** Read and normalize a snapshot manifest; null when the file is missing or unparsable. */
|
|
2602
|
+
async readSnapshotManifest(path) {
|
|
2603
|
+
const raw = await this.io.readText(join(path, "manifest.json"));
|
|
2604
|
+
if (raw === null) return null;
|
|
2605
|
+
try {
|
|
2606
|
+
const manifest = JSON.parse(raw);
|
|
2607
|
+
return {
|
|
2608
|
+
reason: typeof manifest.reason === "string" ? manifest.reason : "",
|
|
2609
|
+
createdAt: typeof manifest.createdAt === "string" ? manifest.createdAt : "",
|
|
2610
|
+
skills: Array.isArray(manifest.skills) ? manifest.skills : [],
|
|
2611
|
+
sidecars: Array.isArray(manifest.sidecars) ? manifest.sidecars : [],
|
|
2612
|
+
...typeof manifest.hasArchive === "boolean" ? { hasArchive: manifest.hasArchive } : {},
|
|
2613
|
+
extras: Array.isArray(manifest.extras) ? manifest.extras : []
|
|
2614
|
+
};
|
|
2615
|
+
} catch {
|
|
2616
|
+
return null;
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
/** Keep only the newest N snapshots (Hermes keep=5 parity); oldest folded into .backups history. */
|
|
2620
|
+
async retainSnapshots(keep) {
|
|
2621
|
+
const snapshots = await this.listSnapshots();
|
|
2622
|
+
for (const snapshot of snapshots.slice(keep)) try {
|
|
2623
|
+
await this.io.remove(snapshot.path);
|
|
2624
|
+
} catch {}
|
|
2625
|
+
}
|
|
1464
2626
|
async listSnapshots() {
|
|
1465
2627
|
const backupRoot = join(this.root, ".backups");
|
|
1466
2628
|
let entries;
|
|
@@ -1472,96 +2634,93 @@ var SkillLibrary = class {
|
|
|
1472
2634
|
const out = [];
|
|
1473
2635
|
for (const name of entries.sort().reverse()) {
|
|
1474
2636
|
if (!name.startsWith("skills-")) continue;
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
reason: manifest.reason ?? ""
|
|
1483
|
-
});
|
|
1484
|
-
} catch {}
|
|
2637
|
+
const manifest = await this.readSnapshotManifest(join(backupRoot, name));
|
|
2638
|
+
if (manifest === null) continue;
|
|
2639
|
+
out.push({
|
|
2640
|
+
path: join(backupRoot, name),
|
|
2641
|
+
createdAt: manifest.createdAt,
|
|
2642
|
+
reason: manifest.reason
|
|
2643
|
+
});
|
|
1485
2644
|
}
|
|
1486
2645
|
return out;
|
|
1487
2646
|
}
|
|
1488
|
-
|
|
2647
|
+
/**
|
|
2648
|
+
* Read the extras of a snapshot, restricted to the names declared in the
|
|
2649
|
+
* manifest — an `extras/` directory is never listed directly, so unknown
|
|
2650
|
+
* files cannot leak back as state on the next restore.
|
|
2651
|
+
*/
|
|
2652
|
+
async readSnapshotExtras(path) {
|
|
2653
|
+
const manifest = await this.readSnapshotManifest(path);
|
|
2654
|
+
if (manifest === null) return [];
|
|
2655
|
+
const extras = [];
|
|
2656
|
+
for (const name of manifest.extras) {
|
|
2657
|
+
if (!SNAPSHOT_EXTRA_NAME_RE.test(name)) continue;
|
|
2658
|
+
const content = await this.io.readText(join(path, "extras", name));
|
|
2659
|
+
if (content !== null) extras.push({
|
|
2660
|
+
name,
|
|
2661
|
+
content
|
|
2662
|
+
});
|
|
2663
|
+
}
|
|
2664
|
+
return extras;
|
|
2665
|
+
}
|
|
2666
|
+
/**
|
|
2667
|
+
* Manifest-driven restore of the latest snapshot: active tree, sidecars,
|
|
2668
|
+
* `.archive/` and (for full-state snapshots) the extras read back by the
|
|
2669
|
+
* caller. `extras` are additionally written into the pre-rollback safety
|
|
2670
|
+
* snapshot so the rollback itself is undoable with the same state.
|
|
2671
|
+
*/
|
|
2672
|
+
async restoreLatestSnapshot(extras = []) {
|
|
1489
2673
|
const latest = (await this.listSnapshots())[0];
|
|
1490
2674
|
if (!latest) return {
|
|
1491
2675
|
ok: false,
|
|
1492
2676
|
message: "No skill snapshot available."
|
|
1493
2677
|
};
|
|
1494
|
-
await this.snapshotAll("pre-rollback");
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
2678
|
+
await this.snapshotAll("pre-rollback", extras);
|
|
2679
|
+
let rootEntries;
|
|
2680
|
+
try {
|
|
2681
|
+
rootEntries = await this.io.list(this.root);
|
|
2682
|
+
} catch {
|
|
2683
|
+
rootEntries = [];
|
|
2684
|
+
}
|
|
2685
|
+
for (const entry of rootEntries) {
|
|
2686
|
+
if (entry.startsWith(".")) continue;
|
|
2687
|
+
await this.io.remove(join(this.root, entry));
|
|
2688
|
+
}
|
|
2689
|
+
const manifest = await this.readSnapshotManifest(latest.path);
|
|
2690
|
+
if (manifest === null) for (const entry of await this.io.list(latest.path)) {
|
|
2691
|
+
if (entry === "manifest.json" || entry === "extras") continue;
|
|
1499
2692
|
await this.io.copy(join(latest.path, entry), join(this.root, entry));
|
|
1500
2693
|
}
|
|
2694
|
+
else {
|
|
2695
|
+
for (const name of manifest.skills) await this.io.copy(join(latest.path, name), join(this.root, name));
|
|
2696
|
+
for (const sidecar of manifest.sidecars) await this.io.copy(join(latest.path, sidecar), join(this.root, sidecar));
|
|
2697
|
+
const archiveRoot = join(this.root, ".archive");
|
|
2698
|
+
if (manifest.hasArchive === true) {
|
|
2699
|
+
await this.io.remove(archiveRoot);
|
|
2700
|
+
await this.io.copy(join(latest.path, ".archive"), archiveRoot);
|
|
2701
|
+
} else if (manifest.hasArchive === false) await this.io.remove(archiveRoot);
|
|
2702
|
+
}
|
|
2703
|
+
const snapshotExtras = await this.readSnapshotExtras(latest.path);
|
|
2704
|
+
this.notifyMutation({
|
|
2705
|
+
action: "restore",
|
|
2706
|
+
name: "snapshot"
|
|
2707
|
+
});
|
|
1501
2708
|
return {
|
|
1502
2709
|
ok: true,
|
|
1503
2710
|
message: `Restored skill tree from ${latest.path}`,
|
|
1504
|
-
path: latest.path
|
|
2711
|
+
path: latest.path,
|
|
2712
|
+
...snapshotExtras.length === 0 ? {} : { extras: snapshotExtras }
|
|
1505
2713
|
};
|
|
1506
2714
|
}
|
|
1507
2715
|
};
|
|
1508
2716
|
//#endregion
|
|
1509
2717
|
//#region lib/types/state-store.js
|
|
1510
2718
|
/**
|
|
1511
|
-
*
|
|
1512
|
-
*
|
|
2719
|
+
* Evolution home path helper: `$DSH_HOME/evolution` for plugin-owned sidecar
|
|
2720
|
+
* state (reports, activity store, feedback file, state-domain data).
|
|
1513
2721
|
*/
|
|
1514
2722
|
function evolutionHome(env = process.env) {
|
|
1515
2723
|
return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "evolution");
|
|
1516
2724
|
}
|
|
1517
|
-
var JsonState = class {
|
|
1518
|
-
initial;
|
|
1519
|
-
path;
|
|
1520
|
-
value;
|
|
1521
|
-
constructor(name, initial, env = process.env) {
|
|
1522
|
-
this.initial = initial;
|
|
1523
|
-
this.path = join(evolutionHome(env), name);
|
|
1524
|
-
this.value = this.loadSync();
|
|
1525
|
-
}
|
|
1526
|
-
loadSync() {
|
|
1527
|
-
try {
|
|
1528
|
-
const raw = readFileSync(this.path, "utf8");
|
|
1529
|
-
const parsed = JSON.parse(raw);
|
|
1530
|
-
return {
|
|
1531
|
-
...this.initial,
|
|
1532
|
-
...parsed
|
|
1533
|
-
};
|
|
1534
|
-
} catch {
|
|
1535
|
-
return { ...this.initial };
|
|
1536
|
-
}
|
|
1537
|
-
}
|
|
1538
|
-
get() {
|
|
1539
|
-
return this.value;
|
|
1540
|
-
}
|
|
1541
|
-
set(value) {
|
|
1542
|
-
this.value = value;
|
|
1543
|
-
}
|
|
1544
|
-
update(mutator) {
|
|
1545
|
-
mutator(this.value);
|
|
1546
|
-
}
|
|
1547
|
-
async flush() {
|
|
1548
|
-
await mkdir(dirname(this.path), { recursive: true });
|
|
1549
|
-
const tmp = `${this.path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
1550
|
-
await writeFile(tmp, JSON.stringify(this.value, null, 2), "utf8");
|
|
1551
|
-
await rename(tmp, this.path);
|
|
1552
|
-
}
|
|
1553
|
-
/** Merge-on-load helper for persisted maps/records. */
|
|
1554
|
-
async reload() {
|
|
1555
|
-
try {
|
|
1556
|
-
const raw = await readFile(this.path, "utf8");
|
|
1557
|
-
this.value = {
|
|
1558
|
-
...this.initial,
|
|
1559
|
-
...JSON.parse(raw)
|
|
1560
|
-
};
|
|
1561
|
-
} catch {
|
|
1562
|
-
this.value = { ...this.initial };
|
|
1563
|
-
}
|
|
1564
|
-
}
|
|
1565
|
-
};
|
|
1566
2725
|
//#endregion
|
|
1567
|
-
export { COMBINED_REVIEW_PROMPT, CURATOR_PROMPT, DEFAULT_SKILL_LIMITS, DSH_AUTHORING_STANDARDS, ENTRY_DELIMITER,
|
|
2726
|
+
export { COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CONSOLIDATION_FAILURES, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_SKILL_CONTENT_CHARS, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, DEFAULT_SKILL_REVIEW_TRIGGER, DEFAULT_STALE_AFTER_DAYS, DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS, DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS, DEFAULT_SUBSTANTIVE_MIN_USER_CHARS, DEFAULT_USER_CHAR_LIMIT, DSH_AUTHORING_STANDARDS, ENTRY_DELIMITER, EvolutionGateSet, LOW_QUALITY_THRESHOLD, MAX_DESCRIPTION_LENGTH, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MEMORY_REVIEW_PROMPT, MUTATIONS_FILE_VERSION, MemoryStore, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, QUALITY_WEIGHTS, SKILL_NAME_RE, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary, advanceReview, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, computeDedupGroups, computeLifecycleTransitions, computeQualityScores, computeScopeView, contentHash, createGateSet, emptyRecord, evaluateThreat, evolutionHome, evolutionIoAdapter, foldTurn, getRecord, latestActivityAt, lifecycleCandidate, loadMutations, loadSuppressedNames, loadUsage, markAgentCreated, memoryRoot, mutateUsage, mutationsFile, nodeEvolutionIo, normalizeUsageRecord, observeEvent, parseCuratorNominations, parseFrontmatter, recordMutation, relatedSkillNames, renderCuratorReportMarkdown, resolveOrigins, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, transactIo, updateSuppressedNames, usageFile, validateFrontmatter, verifyPromptBundle };
|