@lmzhen/dsh-evolution-core 0.1.0-rc.8 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.js +2175 -473
- package/lib/types/constants.d.ts +53 -0
- package/lib/types/curator.d.ts +71 -4
- package/lib/types/events.d.ts +18 -10
- package/lib/types/evolution-events.d.ts +111 -0
- package/lib/types/gates.d.ts +38 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/io.d.ts +31 -4
- package/lib/types/learn-prompt.d.ts +19 -0
- package/lib/types/memory-store.d.ts +55 -12
- package/lib/types/mutations.d.ts +24 -0
- package/lib/types/prompts.d.ts +26 -6
- package/lib/types/quality.d.ts +70 -0
- package/lib/types/skill-store.d.ts +153 -21
- package/lib/types/state-store.d.ts +2 -15
- package/lib/types/threats.d.ts +16 -4
- package/lib/types/usage.d.ts +69 -2
- package/package.json +5 -5
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,112 @@ 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
|
+
/** True when the pid is alive (EPERM = alive but unowned; ESRCH = gone). */
|
|
56
|
+
const isAlive = (pid) => {
|
|
57
|
+
try {
|
|
58
|
+
process.kill(pid, 0);
|
|
59
|
+
return true;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return error?.code === "EPERM";
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Cross-process write lock (claw `withFileLock` parity): an O_EXCL lock file
|
|
66
|
+
* guards the atomic write. A >5s-old lock is taken over ONLY after probing
|
|
67
|
+
* the holder pid it carries (rc.66): a LIVE holder is never stolen, so a
|
|
68
|
+
* slow writer no longer loses its lock to a peer at the 5s mark (the
|
|
69
|
+
* takeover is the only best-effort surface; the retry budget fails loud —
|
|
70
|
+
* rc.65 — instead of ever proceeding unlocked). Budget = 40 * 50ms (~2s,
|
|
71
|
+
* rc.69): 8-writer contention bursts on a loaded CI runner exceed 10
|
|
72
|
+
* attempts (500ms), and a fail-loud throw was observed instead of a clean
|
|
73
|
+
* serialization.
|
|
74
|
+
*/
|
|
75
|
+
const withWriteLock = async (path, task) => {
|
|
76
|
+
const lock = `${path}.lock`;
|
|
77
|
+
for (let attempt = 0; attempt < 40; attempt += 1) try {
|
|
78
|
+
await writeFile(lock, String(process.pid), { flag: "wx" });
|
|
79
|
+
try {
|
|
80
|
+
return await task();
|
|
81
|
+
} finally {
|
|
82
|
+
await rm(lock, { force: true }).catch(() => {});
|
|
83
|
+
}
|
|
84
|
+
} catch (error) {
|
|
85
|
+
const code = error?.code;
|
|
86
|
+
if (code !== "EEXIST" && code !== "EPERM") throw error;
|
|
87
|
+
try {
|
|
88
|
+
const st = await stat(lock);
|
|
89
|
+
if (Date.now() - st.mtimeMs > 5e3) {
|
|
90
|
+
const holder = Number(await readFile(lock, "utf8").catch(() => ""));
|
|
91
|
+
if (!(Number.isInteger(holder) && holder > 0 && isAlive(holder))) {
|
|
92
|
+
try {
|
|
93
|
+
await rm(lock, { force: true });
|
|
94
|
+
} catch {}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
102
|
+
}
|
|
103
|
+
throw new Error(`could not acquire write lock for ${path} after 40 attempts`);
|
|
104
|
+
};
|
|
27
105
|
return {
|
|
28
106
|
async readText(path) {
|
|
29
107
|
try {
|
|
30
108
|
return await readFile(path, "utf8");
|
|
31
|
-
} catch {
|
|
32
|
-
return null;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (isMissing(error)) return null;
|
|
111
|
+
throw error;
|
|
33
112
|
}
|
|
34
113
|
},
|
|
35
114
|
async writeText(path, content) {
|
|
36
115
|
await mkdir(dirname(path), { recursive: true });
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
116
|
+
await withWriteLock(path, async () => {
|
|
117
|
+
const tmp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
118
|
+
await writeFile(tmp, content, "utf8");
|
|
119
|
+
await rename(tmp, path);
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
async transact(path, task) {
|
|
123
|
+
await mkdir(dirname(path), { recursive: true });
|
|
124
|
+
await withWriteLock(path, async () => {
|
|
125
|
+
let current;
|
|
126
|
+
try {
|
|
127
|
+
current = await readFile(path, "utf8");
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (isMissing(error)) current = null;
|
|
130
|
+
else throw error;
|
|
131
|
+
}
|
|
132
|
+
const next = await task(current);
|
|
133
|
+
if (next === null) {
|
|
134
|
+
await rm(path, { force: true });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const tmp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
138
|
+
await writeFile(tmp, next, "utf8");
|
|
139
|
+
await rename(tmp, path);
|
|
140
|
+
});
|
|
40
141
|
},
|
|
41
142
|
async remove(path) {
|
|
42
143
|
await rm(path, {
|
|
@@ -47,16 +148,18 @@ function nodeEvolutionIo() {
|
|
|
47
148
|
async list(path) {
|
|
48
149
|
try {
|
|
49
150
|
return await readdir(path);
|
|
50
|
-
} catch {
|
|
51
|
-
return [];
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (isMissing(error)) return [];
|
|
153
|
+
throw error;
|
|
52
154
|
}
|
|
53
155
|
},
|
|
54
156
|
async exists(path) {
|
|
55
157
|
try {
|
|
56
158
|
await stat(path);
|
|
57
159
|
return true;
|
|
58
|
-
} catch {
|
|
59
|
-
return false;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (isMissing(error)) return false;
|
|
162
|
+
throw error;
|
|
60
163
|
}
|
|
61
164
|
},
|
|
62
165
|
async rename(path, destination) {
|
|
@@ -69,6 +172,21 @@ function nodeEvolutionIo() {
|
|
|
69
172
|
recursive: true,
|
|
70
173
|
force: true
|
|
71
174
|
});
|
|
175
|
+
},
|
|
176
|
+
async size(path) {
|
|
177
|
+
try {
|
|
178
|
+
return (await stat(path)).size;
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (isMissing(error)) return null;
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
async isSymlink(path) {
|
|
185
|
+
try {
|
|
186
|
+
return (await lstat(path)).isSymbolicLink();
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
72
190
|
}
|
|
73
191
|
};
|
|
74
192
|
}
|
|
@@ -96,22 +214,126 @@ function emptyRecord() {
|
|
|
96
214
|
archived_at: null
|
|
97
215
|
};
|
|
98
216
|
}
|
|
99
|
-
|
|
217
|
+
/** A timestamp passes only when `Date.parse` yields a finite epoch (N-3): a bare
|
|
218
|
+
* string check let garbage like "not-a-date" propagate as Invalid Date → NaN
|
|
219
|
+
* into quality math and lifecycle comparisons. */
|
|
220
|
+
const validTimestamp = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
221
|
+
const nullableTimestamp = (value) => value === null || validTimestamp(value);
|
|
222
|
+
/**
|
|
223
|
+
* Field-level normalization for one sidecar record (rc.42 audit P2-3): the
|
|
224
|
+
* spread used to copy any junk through verbatim, so a corrupted file could
|
|
225
|
+
* carry `use_count: "3"` into the quality math and lifecycle comparisons as
|
|
226
|
+
* NaN. Every field falls back to its `emptyRecord()` baseline unless it has
|
|
227
|
+
* exactly the declared type; an invalid `created_at` anchors the age clock at
|
|
228
|
+
* now (first-sight defer semantics for a record whose age is unknowable).
|
|
229
|
+
* Timestamps additionally require a parseable date (N-3): `"not-a-date"`
|
|
230
|
+
* would otherwise survive the type check as Invalid Date.
|
|
231
|
+
* Pure — exported for unit tests; `loadUsage` is the production caller.
|
|
232
|
+
*/
|
|
233
|
+
function normalizeUsageRecord(record) {
|
|
234
|
+
const base = emptyRecord();
|
|
235
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) return base;
|
|
236
|
+
const raw = record;
|
|
237
|
+
const num = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
238
|
+
const bool = (value, fallback) => typeof value === "boolean" ? value : fallback;
|
|
239
|
+
return {
|
|
240
|
+
created_by: typeof raw.created_by === "string" ? raw.created_by : null,
|
|
241
|
+
use_count: num(raw.use_count, base.use_count),
|
|
242
|
+
view_count: num(raw.view_count, base.view_count),
|
|
243
|
+
patch_count: num(raw.patch_count, base.patch_count),
|
|
244
|
+
last_used_at: nullableTimestamp(raw.last_used_at) ? raw.last_used_at : base.last_used_at,
|
|
245
|
+
last_viewed_at: nullableTimestamp(raw.last_viewed_at) ? raw.last_viewed_at : base.last_viewed_at,
|
|
246
|
+
last_patched_at: nullableTimestamp(raw.last_patched_at) ? raw.last_patched_at : base.last_patched_at,
|
|
247
|
+
created_at: validTimestamp(raw.created_at) ? raw.created_at : base.created_at,
|
|
248
|
+
state: raw.state === "stale" || raw.state === "archived" ? raw.state : "active",
|
|
249
|
+
pinned: bool(raw.pinned, base.pinned),
|
|
250
|
+
archived_at: nullableTimestamp(raw.archived_at) ? raw.archived_at : base.archived_at,
|
|
251
|
+
quality_score: typeof raw.quality_score === "number" && Number.isFinite(raw.quality_score) ? raw.quality_score : void 0,
|
|
252
|
+
quality_warn: typeof raw.quality_warn === "boolean" ? raw.quality_warn : void 0
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
/** Parse a raw usage sidecar; malformed content reads as empty (best-effort telemetry). */
|
|
256
|
+
function parseUsage(raw) {
|
|
100
257
|
const map = /* @__PURE__ */ new Map();
|
|
101
|
-
|
|
102
|
-
|
|
258
|
+
if (raw === null) return map;
|
|
259
|
+
try {
|
|
103
260
|
const parsed = JSON.parse(raw);
|
|
104
|
-
for (const [name, record] of Object.entries(parsed))
|
|
105
|
-
const base = emptyRecord();
|
|
106
|
-
map.set(name, {
|
|
107
|
-
...base,
|
|
108
|
-
...record,
|
|
109
|
-
state: record.state === "stale" || record.state === "archived" ? record.state : "active"
|
|
110
|
-
});
|
|
111
|
-
}
|
|
261
|
+
for (const [name, record] of Object.entries(parsed)) map.set(name, normalizeUsageRecord(record));
|
|
112
262
|
} catch {}
|
|
113
263
|
return map;
|
|
114
264
|
}
|
|
265
|
+
async function loadUsage(root, io = nodeEvolutionIo()) {
|
|
266
|
+
return parseUsage(await io.readText(usageFile(root)));
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Atomic read-modify-write on the usage sidecar (rc.50 P2-2): `task` receives
|
|
270
|
+
* the map parsed from the current on-disk state and may mutate it; the result
|
|
271
|
+
* is persisted inside the same transact so a second process sharing DSH_HOME
|
|
272
|
+
* cannot interleave its RMW and lose a counter update. Callers keep their own
|
|
273
|
+
* single-process serialize chain as the second layer.
|
|
274
|
+
*/
|
|
275
|
+
async function mutateUsage(root, io, task) {
|
|
276
|
+
await transactIo(io, usageFile(root), async (current) => {
|
|
277
|
+
if (current !== null) try {
|
|
278
|
+
JSON.parse(current);
|
|
279
|
+
} catch {
|
|
280
|
+
return current;
|
|
281
|
+
}
|
|
282
|
+
const map = parseUsage(current);
|
|
283
|
+
await task(map);
|
|
284
|
+
return JSON.stringify(Object.fromEntries(map.entries()), null, 2);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Curator-owned usage fields (rc.67 K-2): the curator writes ONLY this set —
|
|
289
|
+
* lifecycle state, archive stamp, the six-factor quality pair, and the
|
|
290
|
+
* marker-mirrored pin flag. Counter and activity-stamp fields belong to the
|
|
291
|
+
* tool-telemetry side (skill-usage / tool-skill-manage), which bumps them
|
|
292
|
+
* through its own transact-backed RMW. A whole-record overwrite by either
|
|
293
|
+
* side would clobber the other side's concurrent increment, so cross-side
|
|
294
|
+
* folds copy this set only.
|
|
295
|
+
*/
|
|
296
|
+
function applyCuratorFields(disk, curated) {
|
|
297
|
+
applyCuratorMetaFields(disk, curated);
|
|
298
|
+
applyCuratorLifecycleFields(disk, curated);
|
|
299
|
+
}
|
|
300
|
+
/** Copy only the lifecycle pair (state/archived_at) — see the ownership split
|
|
301
|
+
* rationale on {@link applyCuratorMetaFields}. */
|
|
302
|
+
function applyCuratorLifecycleFields(disk, curated) {
|
|
303
|
+
disk.state = curated.state;
|
|
304
|
+
disk.archived_at = curated.archived_at;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Copy the recomputed meta pair (quality_score/quality_warn + the
|
|
308
|
+
* marker-mirrored pin flag) — refreshed tree-wide each run by design, so a
|
|
309
|
+
* concurrent curator run's lifecycle changes are never reverted by them.
|
|
310
|
+
*/
|
|
311
|
+
function applyCuratorMetaFields(disk, curated) {
|
|
312
|
+
disk.quality_score = curated.quality_score;
|
|
313
|
+
disk.quality_warn = curated.quality_warn;
|
|
314
|
+
disk.pinned = curated.pinned;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Fold a curator run-start snapshot onto the current on-disk map (rc.67 K-2):
|
|
318
|
+
* each curated record is projected onto its disk peer by copying only the
|
|
319
|
+
* curator-owned fields, so a concurrent tool-side bump between snapshot and
|
|
320
|
+
* save survives. Records absent from the snapshot are left untouched; a
|
|
321
|
+
* curated record with no disk peer is seeded from the snapshot. `stateOwned`
|
|
322
|
+
* (rc.72 H-1) restricts the lifecycle pair to the names this run ACTUALLY
|
|
323
|
+
* transitioned — a concurrent curator run's archive/restore is never reverted
|
|
324
|
+
* by a stale snapshot; without it both pairs apply everywhere.
|
|
325
|
+
*/
|
|
326
|
+
function foldCuratorFields(disk, curated, stateOwned) {
|
|
327
|
+
for (const [name, record] of curated) {
|
|
328
|
+
const diskRecord = disk.get(name);
|
|
329
|
+
if (!diskRecord) {
|
|
330
|
+
disk.set(name, { ...record });
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
applyCuratorMetaFields(diskRecord, record);
|
|
334
|
+
if (stateOwned === void 0 || stateOwned.has(name)) applyCuratorLifecycleFields(diskRecord, record);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
115
337
|
async function saveUsage(root, map, io = nodeEvolutionIo()) {
|
|
116
338
|
const obj = Object.fromEntries(map.entries());
|
|
117
339
|
await io.writeText(usageFile(root), JSON.stringify(obj, null, 2));
|
|
@@ -151,15 +373,174 @@ function latestActivityAt(record) {
|
|
|
151
373
|
if (values.length === 0) return null;
|
|
152
374
|
return values.sort().reverse()[0] ?? null;
|
|
153
375
|
}
|
|
376
|
+
/**
|
|
377
|
+
* Curator suppression sidecar: built-in skills the curator has archived stay
|
|
378
|
+
* suppressed across re-seeds, so the lifecycle never fights a re-created
|
|
379
|
+
* bundled skill. Best-effort load/save, mirroring the usage sidecar posture.
|
|
380
|
+
* Versioned shape ({ version, names }) with legacy plain-array compat.
|
|
381
|
+
*/
|
|
382
|
+
const SUPPRESSED_FILE_VERSION = 1;
|
|
383
|
+
function suppressedFile(root) {
|
|
384
|
+
return join(root, ".curator-suppressed.json");
|
|
385
|
+
}
|
|
386
|
+
async function loadSuppressedNames(root, io = nodeEvolutionIo()) {
|
|
387
|
+
return parseSuppressed(await io.readText(suppressedFile(root)));
|
|
388
|
+
}
|
|
389
|
+
function parseSuppressed(raw) {
|
|
390
|
+
if (raw === null) return /* @__PURE__ */ new Set();
|
|
391
|
+
try {
|
|
392
|
+
const parsed = JSON.parse(raw);
|
|
393
|
+
const names = Array.isArray(parsed) ? parsed : typeof parsed === "object" && parsed !== null && Array.isArray(parsed.names) ? parsed.names : [];
|
|
394
|
+
return new Set(names.filter((entry) => typeof entry === "string"));
|
|
395
|
+
} catch {
|
|
396
|
+
return /* @__PURE__ */ new Set();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async function saveSuppressedNames(root, names, io = nodeEvolutionIo()) {
|
|
400
|
+
await io.writeText(suppressedFile(root), JSON.stringify({
|
|
401
|
+
version: 1,
|
|
402
|
+
names: [...names].sort()
|
|
403
|
+
}, null, 2));
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Atomic read-modify-write on the suppression sidecar (rc.50 P2-2): `task`
|
|
407
|
+
* receives the set parsed from the current on-disk state and may mutate it;
|
|
408
|
+
* the result is persisted inside the same transact so a second process
|
|
409
|
+
* sharing DSH_HOME cannot interleave its RMW. Best-effort posture unchanged.
|
|
410
|
+
*/
|
|
411
|
+
async function updateSuppressedNames(root, io, task) {
|
|
412
|
+
await transactIo(io, suppressedFile(root), async (current) => {
|
|
413
|
+
if (current !== null) try {
|
|
414
|
+
JSON.parse(current);
|
|
415
|
+
} catch {
|
|
416
|
+
return current;
|
|
417
|
+
}
|
|
418
|
+
const names = parseSuppressed(current);
|
|
419
|
+
await task(names);
|
|
420
|
+
return JSON.stringify({
|
|
421
|
+
version: 1,
|
|
422
|
+
names: [...names].sort()
|
|
423
|
+
}, null, 2);
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
//#endregion
|
|
427
|
+
//#region lib/types/constants.js
|
|
428
|
+
/**
|
|
429
|
+
* Shared constants for the dsh-evolution plugin family.
|
|
430
|
+
*
|
|
431
|
+
* Two classes of value live here, deliberately separated by section so future
|
|
432
|
+
* edits do not blur the semantic boundary:
|
|
433
|
+
*
|
|
434
|
+
* 1. **Fixed protocol/format/security invariants** — changing these breaks an
|
|
435
|
+
* on-disk format, a naming/format contract, a path-security boundary, or a
|
|
436
|
+
* cross-component invariant. They are NOT exposed as deployment config.
|
|
437
|
+
*
|
|
438
|
+
* 2. **Cross-package shared tunable defaults** — the same semantic default is
|
|
439
|
+
* read (with a config override path) by more than one package (e.g.
|
|
440
|
+
* `evolution-policy` and `evolution-curator` both default `staleAfterDays`
|
|
441
|
+
* to 30). Centralizing them here means one authoritative default: a config
|
|
442
|
+
* override still applies per package, but the fallback is single-sourced.
|
|
443
|
+
*
|
|
444
|
+
* Package-private tunables (used by exactly one package) stay in that package,
|
|
445
|
+
* not here — see evolution-replay's `DEFAULT_WEIGHTS` and evolution-feedback's
|
|
446
|
+
* threshold, which are intentionally left where they are used.
|
|
447
|
+
* @module @lmzhen/dsh-evolution-core
|
|
448
|
+
*/
|
|
449
|
+
/** Skill frontmatter `name` validated for the file name (lowercase + hyphen). */
|
|
450
|
+
const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
451
|
+
/** Allowed skill support-file subdirectories (path-traversal boundary). */
|
|
452
|
+
const SUPPORT_DIRS = [
|
|
453
|
+
"references",
|
|
454
|
+
"templates",
|
|
455
|
+
"scripts",
|
|
456
|
+
"assets"
|
|
457
|
+
];
|
|
458
|
+
/** Delimiter between durable memory entries (on-disk storage format). */
|
|
459
|
+
const ENTRY_DELIMITER = "\n§\n";
|
|
460
|
+
/** Built-in skill names the curator must never lifecycle-manage. */
|
|
461
|
+
const PROTECTED_BUILTIN_SKILLS = new Set(["plan"]);
|
|
462
|
+
const MAX_SKILL_NAME_LENGTH = 64;
|
|
463
|
+
const MAX_DESCRIPTION_LENGTH = 1024;
|
|
464
|
+
const MAX_SKILL_CONTENT_CHARS = 1e5;
|
|
465
|
+
const MAX_SKILL_FILE_BYTES = 1048576;
|
|
466
|
+
const DEFAULT_REVIEW_MEMORY_INTERVAL = 10;
|
|
467
|
+
const DEFAULT_REVIEW_SKILL_INTERVAL = 10;
|
|
468
|
+
/** Skill-review completion channel trigger mode: 'cadence' | 'completion' | 'both'. */
|
|
469
|
+
const DEFAULT_SKILL_REVIEW_TRIGGER = "both";
|
|
470
|
+
/** Cumulative session tool calls before a session counts as "proven long" for the completion channel. */
|
|
471
|
+
const DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS = 20;
|
|
472
|
+
const DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS = 3;
|
|
473
|
+
const DEFAULT_SUBSTANTIVE_MIN_USER_CHARS = 200;
|
|
474
|
+
const DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS = 500;
|
|
475
|
+
const DEFAULT_MAX_OPS_PER_PLAN = 32;
|
|
476
|
+
const DEFAULT_CURATOR_INTERVAL_HOURS = 168;
|
|
477
|
+
const DEFAULT_MIN_IDLE_HOURS = 2;
|
|
478
|
+
const DEFAULT_STALE_AFTER_DAYS = 30;
|
|
479
|
+
const DEFAULT_ARCHIVE_AFTER_DAYS = 90;
|
|
480
|
+
const DEFAULT_MEMORY_CHAR_LIMIT = 2200;
|
|
481
|
+
const DEFAULT_USER_CHAR_LIMIT = 1375;
|
|
482
|
+
/** Consolidation-failure backoff cap, shared by MemoryStore and memory-files' Config default. */
|
|
483
|
+
const DEFAULT_CONSOLIDATION_FAILURES = 3;
|
|
484
|
+
const DEFAULT_SKILL_CONTENT_CHARS = 1e5;
|
|
485
|
+
//#endregion
|
|
486
|
+
//#region lib/types/gates.js
|
|
487
|
+
/**
|
|
488
|
+
* The control-plane protection sets, held once and queried everywhere
|
|
489
|
+
* (decision B, rc.44 plan M2): the lifecycle engine, the scope view, the LLM
|
|
490
|
+
* nomination gate and the control-plane consolidate all answer "is this name
|
|
491
|
+
* off limits — and why" from the same instance, so the gate sets can never
|
|
492
|
+
* drift apart the way the three pre-rc.46 implementations did.
|
|
493
|
+
*
|
|
494
|
+
* Scope boundary: a GateSet covers NAME-SET protections only. Marker-based
|
|
495
|
+
* protections (pinned / bundled / hub-installed) are file markers resolved by
|
|
496
|
+
* `SkillLibrary.writeProtection` / `deleteProtection` — they depend on the
|
|
497
|
+
* filesystem and the write origin, not on a name list.
|
|
498
|
+
* @module @lmzhen/dsh-evolution-core
|
|
499
|
+
*/
|
|
500
|
+
var EvolutionGateSet = class {
|
|
501
|
+
exclude;
|
|
502
|
+
referenced;
|
|
503
|
+
suppressed;
|
|
504
|
+
constructor(inputs = {}) {
|
|
505
|
+
this.exclude = inputs.exclude ?? /* @__PURE__ */ new Set();
|
|
506
|
+
this.referenced = inputs.referenced ?? /* @__PURE__ */ new Set();
|
|
507
|
+
this.suppressed = inputs.suppressed ?? /* @__PURE__ */ new Set();
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* The first protection blocking this name, or null. Any hit blocks — the
|
|
511
|
+
* order is diagnostic only, so a name in two sets reports the first.
|
|
512
|
+
*/
|
|
513
|
+
blockReason(name) {
|
|
514
|
+
if (this.exclude.has(name)) return "excluded";
|
|
515
|
+
if (this.referenced.has(name)) return "referenced";
|
|
516
|
+
if (this.suppressed.has(name)) return "suppressed";
|
|
517
|
+
if (PROTECTED_BUILTIN_SKILLS.has(name)) return "protected-builtin";
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
isBlocked(name) {
|
|
521
|
+
return this.blockReason(name) !== null;
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
/** Build a GateSet from the curator-style config field names. */
|
|
525
|
+
function createGateSet(config) {
|
|
526
|
+
return new EvolutionGateSet({
|
|
527
|
+
exclude: config.excludeSkillNames,
|
|
528
|
+
referenced: config.referencedSkillNames,
|
|
529
|
+
suppressed: config.suppressedNames
|
|
530
|
+
});
|
|
531
|
+
}
|
|
154
532
|
//#endregion
|
|
155
533
|
//#region lib/types/curator.js
|
|
156
534
|
/**
|
|
157
535
|
* Deterministic skill curator: active → stale → archived transitions.
|
|
158
|
-
* Pure function
|
|
536
|
+
* Pure function with one deliberate side effect: records in the passed
|
|
537
|
+
* `usage` map are MUTATED (state/archived_at) to carry the transition — the
|
|
538
|
+
* caller owns the map and decides whether to clone first (dry-run) or persist
|
|
539
|
+
* after. File moves are performed by SkillLibrary.
|
|
159
540
|
*/
|
|
160
|
-
const PROTECTED_BUILTIN_SKILLS = new Set(["plan"]);
|
|
161
541
|
function buildCuratorRunReport(input) {
|
|
162
542
|
return {
|
|
543
|
+
schemaVersion: 1,
|
|
163
544
|
runId: input.runId,
|
|
164
545
|
startedAt: input.startedAt,
|
|
165
546
|
finishedAt: input.finishedAt,
|
|
@@ -168,25 +549,145 @@ function buildCuratorRunReport(input) {
|
|
|
168
549
|
archiveCandidates: [...input.archiveCandidates],
|
|
169
550
|
archived: [...input.archived],
|
|
170
551
|
failed: [...input.failed],
|
|
171
|
-
...input.
|
|
552
|
+
...input.consolidated === void 0 ? {} : { consolidated: [...input.consolidated] },
|
|
553
|
+
...input.snapshotPath === void 0 ? {} : { snapshotPath: input.snapshotPath },
|
|
554
|
+
...input.llmReviewEnabled === void 0 ? {} : { llmReviewEnabled: input.llmReviewEnabled }
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Render a curator run report as a compact human-readable markdown digest
|
|
559
|
+
* (G6): run metadata first, then the notable sections (archived / failed /
|
|
560
|
+
* stale candidates / LLM nominations).
|
|
561
|
+
*/
|
|
562
|
+
function renderCuratorReportMarkdown(report) {
|
|
563
|
+
const lines = [
|
|
564
|
+
`# Curator run ${report.runId}`,
|
|
565
|
+
"",
|
|
566
|
+
`- **Started** ${report.startedAt}`,
|
|
567
|
+
`- **Finished** ${report.finishedAt}`,
|
|
568
|
+
`- **Stale candidates**: ${report.staleCandidates.length}`,
|
|
569
|
+
`- **LLM nominations**: ${report.llmNominations.length}`,
|
|
570
|
+
`- **Archived**: ${report.archived.length}`,
|
|
571
|
+
`- **Failed**: ${report.failed.length}`,
|
|
572
|
+
...report.snapshotPath === void 0 ? [] : [`- **Snapshot**: ${report.snapshotPath}`],
|
|
573
|
+
...report.llmReviewEnabled === void 0 ? [] : [`- **llmReview**: ${report.llmReviewEnabled}`]
|
|
574
|
+
];
|
|
575
|
+
const section = (title, items) => items.length === 0 ? [] : [
|
|
576
|
+
"",
|
|
577
|
+
`## ${title}`,
|
|
578
|
+
"",
|
|
579
|
+
...items.map((item) => `- ${item}`)
|
|
580
|
+
];
|
|
581
|
+
return [
|
|
582
|
+
...lines,
|
|
583
|
+
...section("Archived", report.archived.map((item) => `${item.name} (${item.reason})`)),
|
|
584
|
+
...section("Failed", report.failed.map((item) => `${item.name}: ${item.reason}`)),
|
|
585
|
+
...section("Stale candidates", report.staleCandidates),
|
|
586
|
+
...section("LLM nominations", report.llmNominations),
|
|
587
|
+
""
|
|
588
|
+
].join("\n");
|
|
589
|
+
}
|
|
590
|
+
const NOMINATION_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
591
|
+
/**
|
|
592
|
+
* Parse the curator LLM's YAML nomination block (consolidations + prunings).
|
|
593
|
+
* Line-oriented and lenient by design: the LLM output is advisory, every name
|
|
594
|
+
* is re-validated against the tree before any file move happens downstream.
|
|
595
|
+
*/
|
|
596
|
+
function parseCuratorNominations(text) {
|
|
597
|
+
const prunings = [];
|
|
598
|
+
const consolidations = [];
|
|
599
|
+
let section = null;
|
|
600
|
+
let currentFrom = "";
|
|
601
|
+
for (const line of text.split("\n")) {
|
|
602
|
+
const consolidated = /^\s*-\s*from:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
|
|
603
|
+
if (consolidated) {
|
|
604
|
+
section = "consolidations";
|
|
605
|
+
currentFrom = consolidated[1] ?? "";
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
const into = /^\s*into:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
|
|
609
|
+
if (into) {
|
|
610
|
+
const intoName = into[1] ?? "";
|
|
611
|
+
if (section === "consolidations" && currentFrom !== "" && currentFrom !== intoName) consolidations.push({
|
|
612
|
+
from: currentFrom,
|
|
613
|
+
into: intoName
|
|
614
|
+
});
|
|
615
|
+
currentFrom = "";
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
const pruned = /^\s*-\s*name:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
|
|
619
|
+
if (pruned) {
|
|
620
|
+
section = "prunings";
|
|
621
|
+
const name = pruned[1];
|
|
622
|
+
if (name) prunings.push(name);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
const valid = (name) => NOMINATION_NAME_RE.test(name);
|
|
626
|
+
return {
|
|
627
|
+
prunings: prunings.filter(valid),
|
|
628
|
+
consolidations: consolidations.filter((item) => valid(item.from) && valid(item.into))
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* The lifecycle-candidate gate, shared by the transition engine and the scope
|
|
633
|
+
* view so the two can never disagree: records failing ANY of these gates are
|
|
634
|
+
* outside the managed scope.
|
|
635
|
+
*/
|
|
636
|
+
function lifecycleCandidate(name, record, config, bundled, gates = createGateSet(config)) {
|
|
637
|
+
if (record.pinned) return false;
|
|
638
|
+
if (gates.isBlocked(name)) return false;
|
|
639
|
+
if (!(record.created_by === "agent" || config.manageUnmanaged === true) && !(config.pruneBuiltins === true && bundled)) return false;
|
|
640
|
+
if (record.state === "archived") return false;
|
|
641
|
+
return true;
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Read-only scope classification, derived from the SAME gate the transition
|
|
645
|
+
* engine uses (`lifecycleCandidate`), so the view always predicts what a
|
|
646
|
+
* curator pass may touch. `protectedNames` carries the marker info the usage
|
|
647
|
+
* records lack (bundled / hub-installed / pinned from `SkillLibrary.list()`).
|
|
648
|
+
*/
|
|
649
|
+
function computeScopeView(usage, config, protectedNames, gates) {
|
|
650
|
+
const managed = [];
|
|
651
|
+
const watched = [];
|
|
652
|
+
const qualityWarned = [];
|
|
653
|
+
const exempted = [];
|
|
654
|
+
const protectedSet = /* @__PURE__ */ new Set();
|
|
655
|
+
const gateSet = gates ?? createGateSet(config);
|
|
656
|
+
for (const [name, record] of usage) {
|
|
657
|
+
if (gateSet.exclude.has(name) || gateSet.referenced.has(name)) {
|
|
658
|
+
exempted.push(name);
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
const bundled = config.bundledNames?.has(name) === true;
|
|
662
|
+
const suppressed = gateSet.suppressed.has(name);
|
|
663
|
+
if (record.pinned || bundled || suppressed || protectedNames?.has(name) === true) protectedSet.add(name);
|
|
664
|
+
if (lifecycleCandidate(name, record, config, bundled, gateSet)) {
|
|
665
|
+
managed.push(name);
|
|
666
|
+
if (record.state === "stale" || record.quality_warn === true) watched.push(name);
|
|
667
|
+
if (record.quality_warn === true) qualityWarned.push(name);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return {
|
|
671
|
+
managed: managed.sort(),
|
|
672
|
+
watched: watched.sort(),
|
|
673
|
+
qualityWarned: qualityWarned.sort(),
|
|
674
|
+
exempted: exempted.sort(),
|
|
675
|
+
protected: [...protectedSet].sort()
|
|
172
676
|
};
|
|
173
677
|
}
|
|
174
678
|
function daysSince(iso, created, now) {
|
|
175
679
|
return (now - new Date(iso ?? created).getTime()) / 864e5;
|
|
176
680
|
}
|
|
177
|
-
function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Date()) {
|
|
681
|
+
function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Date(), gates) {
|
|
178
682
|
const result = {
|
|
179
683
|
transitions: [],
|
|
180
684
|
archive: [],
|
|
181
685
|
reactivate: [],
|
|
182
686
|
markStale: []
|
|
183
687
|
};
|
|
688
|
+
const gateSet = gates ?? createGateSet(config);
|
|
184
689
|
for (const [name, record] of usage) {
|
|
185
|
-
if (record.
|
|
186
|
-
if (config.excludeSkillNames?.has(name)) continue;
|
|
187
|
-
if (record.created_by !== "agent" && config.manageUnmanaged !== true) continue;
|
|
188
|
-
if (PROTECTED_BUILTIN_SKILLS.has(name)) continue;
|
|
189
|
-
if (record.state === "archived") continue;
|
|
690
|
+
if (!lifecycleCandidate(name, record, config, config.bundledNames?.has(name) === true, gateSet)) continue;
|
|
190
691
|
const age = daysSince(null, record.created_at, now.getTime());
|
|
191
692
|
if (record.use_count === 0 && age < config.staleAfterDays) continue;
|
|
192
693
|
const idle = daysSince(latestActivityAt(record), record.created_at, now.getTime());
|
|
@@ -238,6 +739,538 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
|
|
|
238
739
|
return result;
|
|
239
740
|
}
|
|
240
741
|
//#endregion
|
|
742
|
+
//#region lib/types/evolution-events.js
|
|
743
|
+
/**
|
|
744
|
+
* Self-evolution event log (rc.68): an append-only sidecar under
|
|
745
|
+
* `$DSH_HOME/evolution/events.json` that is the single source of truth for
|
|
746
|
+
* the self-improvement loop. Feedback increments and learn actions share one
|
|
747
|
+
* ordered timeline (`seq` is the ordering key), so "feedback before/after a
|
|
748
|
+
* learn on target X" is answerable. The aggregate `feedback.json` is a
|
|
749
|
+
* rebuildable boot cache, never the truth.
|
|
750
|
+
*
|
|
751
|
+
* Rotation (rc.71, 007 design): when the active log reaches
|
|
752
|
+
* `EVENT_LOG_ROTATE_AT` the older half is split into an archive
|
|
753
|
+
* (`events-<lastArchivedSeq>.json`); the boot timeline merges active +
|
|
754
|
+
* archives and dedupes by seq (active copy wins), so the rotation crash window
|
|
755
|
+
* yields the identical timeline. Archival naming is STRICTLY numeric
|
|
756
|
+
* (`/^events-\d+\.json$/`) — user files under the same directory are never
|
|
757
|
+
* read as archives and never pruned (rc.72 G-2).
|
|
758
|
+
*/
|
|
759
|
+
const EVENT_LOG_VERSION = 1;
|
|
760
|
+
/** Active-log split point (rc.71): when the active log reaches this many events
|
|
761
|
+
* the older half is rotated into an archive; the active stays bounded so a
|
|
762
|
+
* single append stays O(active) instead of O(total-history). Tunable default —
|
|
763
|
+
* callers may override per append (the tests use small values). */
|
|
764
|
+
const EVENT_LOG_ROTATE_AT = 4e3;
|
|
765
|
+
/** Number of archives retained (rc.71): older archives are pruned at rotation,
|
|
766
|
+
* mirroring retainReports. The horizon covers the loop-analysis window. */
|
|
767
|
+
const EVENT_LOG_RETAIN_ARCHIVES = 10;
|
|
768
|
+
/** Archive file prefix: `events-<lastArchivedSeq>.json`. The active file is
|
|
769
|
+
* `events.json` and never matches this glob. */
|
|
770
|
+
const EVENT_ARCHIVE_PREFIX = "events-";
|
|
771
|
+
/** Archive naming is strictly numeric: a user file such as `events-backup.json`
|
|
772
|
+
* under the same directory is neither read into the timeline nor pruned. */
|
|
773
|
+
const EVENT_ARCHIVE_RE = /^events-(\d+)\.json$/;
|
|
774
|
+
function eventsFile(home) {
|
|
775
|
+
return join(home, "evolution", "events.json");
|
|
776
|
+
}
|
|
777
|
+
function isEventRecord(event) {
|
|
778
|
+
return typeof event === "object" && event !== null && typeof event.seq === "number";
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Parse an event log body. A missing file, a whitespace-only file (rc.69:
|
|
782
|
+
* rebuildable, NOT malformed) or a corrupt one reads as empty; corrupt content
|
|
783
|
+
* is still refused on append, never overwritten.
|
|
784
|
+
*
|
|
785
|
+
* Per-entry normalization (rc.70 F-1): entries without a numeric `seq` are
|
|
786
|
+
* skipped here and dropped at the next append — valid entries survive, the
|
|
787
|
+
* damaged record is the only loss (self-heal semantics, matching the usage
|
|
788
|
+
* sidecar's per-field normalization on read).
|
|
789
|
+
*/
|
|
790
|
+
function parseEvolutionEvents(raw) {
|
|
791
|
+
if (raw === null || raw.trim() === "") return [];
|
|
792
|
+
try {
|
|
793
|
+
const parsed = JSON.parse(raw);
|
|
794
|
+
if (!Array.isArray(parsed.events)) return [];
|
|
795
|
+
return parsed.events.filter(isEventRecord);
|
|
796
|
+
} catch {
|
|
797
|
+
return [];
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* List the numeric archives under the log's directory, sorted ascending by
|
|
802
|
+
* their last-archived seq. Single glob predicate for the timeline, the
|
|
803
|
+
* retention pass and the feedback migration check (rc.72 H-3).
|
|
804
|
+
*/
|
|
805
|
+
async function listEventArchives(io, path) {
|
|
806
|
+
const dir = dirname(path);
|
|
807
|
+
return (await io.list(dir)).filter((name) => EVENT_ARCHIVE_RE.test(name)).sort((a, b) => {
|
|
808
|
+
return Number.parseInt(a.slice(7, a.length - 5), 10) - Number.parseInt(b.slice(7, b.length - 5), 10);
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Append one event under the write lock (rc.68): `seq` = current max + 1
|
|
813
|
+
* computed inside the transact, so two processes appending concurrently never
|
|
814
|
+
* collide. A malformed log is refused (bytes preserved) and the append fails.
|
|
815
|
+
* Returns the assigned seq.
|
|
816
|
+
*
|
|
817
|
+
* Rotation (rc.71, 007 design): when the active log reaches `rotateAt`, the
|
|
818
|
+
* older half is copied into an archive inside the SAME transact (the archive
|
|
819
|
+
* path has its own lock, so no recursion) and the active is replaced with the
|
|
820
|
+
* newer half + the new event. seqs stay globally monotonic; a crash between
|
|
821
|
+
* archive write and active write leaves both copies, which the timeline merge
|
|
822
|
+
* dedupes by seq. An archive-write failure aborts the append (active keeps the
|
|
823
|
+
* full old content — no loss) and the caller's best-effort handling applies.
|
|
824
|
+
*
|
|
825
|
+
* rc.72 G-1: when the ACTIVE is missing/whitespace but archives exist (a
|
|
826
|
+
* deleted active, or B-2 self-heal), seq derivation consults the archive names
|
|
827
|
+
* — the active restarts AFTER the highest archived seq, never at 1, so a new
|
|
828
|
+
* event can never shadow an archived one in the seq-deduped timeline.
|
|
829
|
+
*/
|
|
830
|
+
async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE_AT) {
|
|
831
|
+
let assigned = 0;
|
|
832
|
+
await transactIo(io, path, async (current) => {
|
|
833
|
+
if (current !== null && current.trim() !== "") try {
|
|
834
|
+
JSON.parse(current);
|
|
835
|
+
} catch {
|
|
836
|
+
return current;
|
|
837
|
+
}
|
|
838
|
+
const nextEvents = await rotateIfDue(io, path, parseEvolutionEvents(current), rotateAt);
|
|
839
|
+
let maxSeq = nextEvents.reduce((max, entry) => Math.max(max, entry.seq), 0);
|
|
840
|
+
if (maxSeq === 0) for (const name of await listEventArchives(io, path)) maxSeq = Math.max(maxSeq, Number.parseInt(name.slice(7, name.length - 5), 10));
|
|
841
|
+
const record = {
|
|
842
|
+
...event,
|
|
843
|
+
seq: maxSeq + 1,
|
|
844
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
845
|
+
};
|
|
846
|
+
assigned = record.seq;
|
|
847
|
+
return JSON.stringify({
|
|
848
|
+
version: 1,
|
|
849
|
+
events: [...nextEvents, record]
|
|
850
|
+
}, null, 2);
|
|
851
|
+
});
|
|
852
|
+
if (assigned === 0) throw new Error(`evolution event log is malformed and was not touched: ${path}`);
|
|
853
|
+
return assigned;
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Split the active log at its midpoint when due: the older half is written to
|
|
857
|
+
* `events-<lastArchivedSeq>.json` (await — a failed archive write aborts the
|
|
858
|
+
* append so the active is never truncated without its copy), old archives are
|
|
859
|
+
* pruned, and the newer half is returned as the next active body. No-op when
|
|
860
|
+
* under the threshold; `rotateAt < 2` is a guarded no-op (rc.72 G-1: a
|
|
861
|
+
* one-event rotate would archive everything and restart seqs at 1).
|
|
862
|
+
*/
|
|
863
|
+
async function rotateIfDue(io, path, events, rotateAt) {
|
|
864
|
+
if (rotateAt < 2 || events.length < rotateAt) return events;
|
|
865
|
+
const mid = Math.ceil(events.length / 2);
|
|
866
|
+
const head = events.slice(0, mid);
|
|
867
|
+
const tail = events.slice(mid);
|
|
868
|
+
if (tail.length === 0) return events;
|
|
869
|
+
const anchor = tail[0]?.seq ?? 0;
|
|
870
|
+
const archivePath = join(dirname(path), `${EVENT_ARCHIVE_PREFIX}${anchor - 1}.json`);
|
|
871
|
+
await io.writeText(archivePath, JSON.stringify({
|
|
872
|
+
version: 1,
|
|
873
|
+
events: head
|
|
874
|
+
}, null, 2));
|
|
875
|
+
await retainEventArchives(io, path);
|
|
876
|
+
return tail;
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Prune old event archives (rc.71): keep the newest `EVENT_LOG_RETAIN_ARCHIVES`.
|
|
880
|
+
* The name's numeric part is the last archived seq, so ordering is NUMERIC —
|
|
881
|
+
* lexicographic would rank `events-10` before `events-2`. Only strictly
|
|
882
|
+
* numeric names participate (rc.72 G-2: user files are never deleted).
|
|
883
|
+
* Best-effort per removal; exported for the retention test.
|
|
884
|
+
*/
|
|
885
|
+
async function retainEventArchives(io, path) {
|
|
886
|
+
const dir = dirname(path);
|
|
887
|
+
const names = await listEventArchives(io, path);
|
|
888
|
+
const excess = names.slice(0, Math.max(0, names.length - 10));
|
|
889
|
+
for (const name of excess) await io.remove(join(dir, name)).catch(() => {});
|
|
890
|
+
}
|
|
891
|
+
/** Read the event log; a missing/whitespace-only file reads as empty,
|
|
892
|
+
* corrupt content is flagged (and refused on append). */
|
|
893
|
+
async function readEvolutionEvents(io, path) {
|
|
894
|
+
let raw;
|
|
895
|
+
try {
|
|
896
|
+
raw = await io.readText(path);
|
|
897
|
+
} catch {
|
|
898
|
+
return {
|
|
899
|
+
events: [],
|
|
900
|
+
malformed: true
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
if (raw === null || raw.trim() === "") return {
|
|
904
|
+
events: [],
|
|
905
|
+
malformed: false
|
|
906
|
+
};
|
|
907
|
+
try {
|
|
908
|
+
const parsed = JSON.parse(raw);
|
|
909
|
+
if (!Array.isArray(parsed.events)) return {
|
|
910
|
+
events: [],
|
|
911
|
+
malformed: false
|
|
912
|
+
};
|
|
913
|
+
return {
|
|
914
|
+
events: parsed.events.filter(isEventRecord),
|
|
915
|
+
malformed: false
|
|
916
|
+
};
|
|
917
|
+
} catch {
|
|
918
|
+
return {
|
|
919
|
+
events: [],
|
|
920
|
+
malformed: true
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Read the full timeline (rc.71): active log + all archives, merged by seq
|
|
926
|
+
* (active copy wins, duplicates only arise from the rotation crash window),
|
|
927
|
+
* sorted ascending. Per-file malformed flag as in `readEvolutionEvents`; a
|
|
928
|
+
* malformed (or unreadable) ARCHIVE is skipped — it never bricks the boot and
|
|
929
|
+
* it is still flagged.
|
|
930
|
+
*/
|
|
931
|
+
async function readEvolutionTimeline(io, path) {
|
|
932
|
+
const dir = dirname(path);
|
|
933
|
+
let malformed = false;
|
|
934
|
+
const bySeq = /* @__PURE__ */ new Map();
|
|
935
|
+
for (const name of await listEventArchives(io, path)) {
|
|
936
|
+
const read = await readEvolutionEvents(io, join(dir, name));
|
|
937
|
+
if (read.malformed) malformed = true;
|
|
938
|
+
for (const event of read.events) bySeq.set(event.seq, event);
|
|
939
|
+
}
|
|
940
|
+
const active = await readEvolutionEvents(io, path);
|
|
941
|
+
if (active.malformed) malformed = true;
|
|
942
|
+
for (const event of active.events) bySeq.set(event.seq, event);
|
|
943
|
+
return {
|
|
944
|
+
events: [...bySeq.values()].sort((a, b) => a.seq - b.seq),
|
|
945
|
+
malformed
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
//#endregion
|
|
949
|
+
//#region lib/types/prompts.js
|
|
950
|
+
/**
|
|
951
|
+
* Review and curation prompts adapted from Hermes Agent
|
|
952
|
+
* `agent/background_review.py`, `agent/curator.py`, and
|
|
953
|
+
* `agent/learn_prompt.py`, with tool names translated to the DSH-native
|
|
954
|
+
* catalog (`memory`, `skill_manage`, `skill`, `bash`, `str_replace_editor`).
|
|
955
|
+
*
|
|
956
|
+
* Alignment policy (2026-08-29): the OPERATIONAL steps and instructions the
|
|
957
|
+
* model follows mirror the Hermes originals structurally (signal list,
|
|
958
|
+
* preference order, support-file taxonomy, curator package integrity,
|
|
959
|
+
* consolidated/pruned reporting block). Tool and platform differences are
|
|
960
|
+
* DSH-adapted (native tool names, pinned-within-review semantics, this
|
|
961
|
+
* platform's index cap), and DSH-only additions are marked as such.
|
|
962
|
+
*
|
|
963
|
+
* Every prompt is pinned in a versioned bundle. Review workers verify the
|
|
964
|
+
* bundle digest before spending a model call, so a partially-patched
|
|
965
|
+
* deployment fails closed instead of silently running a truncated prompt.
|
|
966
|
+
*/
|
|
967
|
+
/**
|
|
968
|
+
* Prompt bundle identity. Bump both id and version whenever a prompt's text
|
|
969
|
+
* changes semantically: the bundle digest is the fail-closed signal for
|
|
970
|
+
* review workers, so a stale id across deployments must be distinguishable.
|
|
971
|
+
*/
|
|
972
|
+
const PROMPT_BUNDLE_ID = "dsh-evolution@7";
|
|
973
|
+
const PROMPT_BUNDLE_VERSION = 7;
|
|
974
|
+
const MEMORY_REVIEW_PROMPT = `[Auto-review — Memory]
|
|
975
|
+
Review the conversation above and consider saving to memory if appropriate.
|
|
976
|
+
|
|
977
|
+
Focus on:
|
|
978
|
+
1. Has the user revealed things about themselves — persona, desires, preferences, or personal details worth remembering?
|
|
979
|
+
2. Has the user expressed expectations about how you should behave, their work style, or ways they want you to operate?
|
|
980
|
+
|
|
981
|
+
If something stands out, save it using the memory tool.
|
|
982
|
+
If nothing is worth saving, just say "Nothing to save." and stop.`;
|
|
983
|
+
const SKILL_REVIEW_PROMPT = `[Auto-review — Skills]
|
|
984
|
+
Review the conversation above and update the skill library. Be ACTIVE — most sessions produce at least one skill update, even if small. A pass that does nothing is a missed learning opportunity, not a neutral outcome.
|
|
985
|
+
|
|
986
|
+
Target shape of the library: CLASS-LEVEL skills, each with a rich SKILL.md and a references/ directory for session-specific detail. Not a long flat list of narrow one-session-one-skill entries. This shapes HOW you update, not WHETHER you update.
|
|
987
|
+
|
|
988
|
+
Signals to look for (any one of these warrants action):
|
|
989
|
+
• User corrected your style, tone, format, legibility, or verbosity. Frustration signals like 'stop doing X', 'this is too verbose', 'don't format like this', 'why are you explaining', 'just give me the answer', 'you always do Y and I hate it', or an explicit 'remember this' are FIRST-CLASS skill signals, not just memory signals. Update the relevant skill(s) to embed the preference so the next session starts already knowing.
|
|
990
|
+
• User corrected your workflow, approach, or sequence of steps. Encode the correction as a pitfall or explicit step in the skill that governs that class of task.
|
|
991
|
+
• Non-trivial technique, fix, workaround, debugging path, or tool-usage pattern emerged that a future session would benefit from. Capture it.
|
|
992
|
+
• A skill that got loaded or consulted this session turned out to be wrong, missing a step, or outdated. Patch it NOW.
|
|
993
|
+
|
|
994
|
+
Read-before-write (enforced by this channel): update, patch, delete, or write support files ONLY into skills you loaded or read in THIS session — ops on unread skills are dropped; CREATE of a brand-new umbrella is the only exception.
|
|
995
|
+
|
|
996
|
+
Preference order — prefer the earliest action that fits, but do pick one when a signal above fired:
|
|
997
|
+
1. UPDATE A CURRENTLY-LOADED SKILL. Look back through the conversation for skills the user loaded or you read. If any of them covers the territory of the new learning, PATCH that one first. It is the skill that was in play, so it's the right one to extend.
|
|
998
|
+
2. UPDATE AN EXISTING UMBRELLA. If no loaded skill fits but an existing class-level skill does, patch it. Add a subsection, a pitfall, or broaden a trigger.
|
|
999
|
+
3. ADD A SUPPORT FILE under an existing umbrella. Skills can be packaged with three kinds of support files — use the right directory per kind:
|
|
1000
|
+
• references/<topic>.md — session-specific detail (error transcripts, reproduction recipes, provider quirks) AND condensed knowledge banks: quoted research, API docs, external authoritative excerpts, or domain notes you found while working on the problem. Write it concise and for the value of the task, not as a full mirror of upstream docs.
|
|
1001
|
+
• templates/<name>.<ext> — starter files meant to be copied and modified (boilerplate configs, scaffolding, a known-good example the agent can reproduce with modifications).
|
|
1002
|
+
• scripts/<name>.<ext> — statically re-runnable actions the skill can invoke directly (verification scripts, fixture generators, deterministic probes, anything the agent should run rather than hand-type each time).
|
|
1003
|
+
Add support files via skill_manage action=write_file with file_path starting 'references/', 'templates/', or 'scripts/'. The umbrella's SKILL.md should gain a one-line pointer to any new support file so future agents know it exists.
|
|
1004
|
+
4. CREATE A NEW CLASS-LEVEL UMBRELLA SKILL when no existing skill covers the class. The name MUST be at the class level. The name MUST NOT be a specific PR number, error string, feature codename, library-alone name, or 'fix-X / debug-Y / audit-Z-today' session artifact. If the proposed name only makes sense for today's task, it's wrong — fall back to (1), (2), or (3).
|
|
1005
|
+
|
|
1006
|
+
User-preference embedding (important): when the user expressed a style/format/workflow preference, the update belongs in the SKILL.md body, not just in memory. Memory captures 'who the user is and what the current situation and state of your operations are'; skills capture 'how to do this class of task for this user'. When they complain about how you handled a task, the skill that governs that task needs to carry the lesson.
|
|
1007
|
+
|
|
1008
|
+
If you notice two existing skills that overlap, note it in your reply — the background curator handles consolidation at scale.
|
|
1009
|
+
|
|
1010
|
+
Two-tier deposition discipline (DSH addition, same spirit as the umbrella rule): before writing, classify the knowledge:
|
|
1011
|
+
• PATTERN (reusable — symptom → mechanism → fix → verification, still valuable next session) belongs in the SKILL.md body.
|
|
1012
|
+
• LOG (one-off — commit SHAs, npm/profile states, what this release changed, this session's process narrative) belongs in a references/ file, never the body. Body density IS reuse rate. Keep new entries tight: a pattern fits in 2-8 physical lines; prefer changing the current-state pointer over appending history.
|
|
1013
|
+
|
|
1014
|
+
Protected skills (DO NOT edit these):
|
|
1015
|
+
• Bundled skills (shipped with the platform).
|
|
1016
|
+
• Hub-installed skills (installed from a hub).
|
|
1017
|
+
Pinned skills are read-only to THIS background review pass — the pinned write guard refuses background changes, so only the foreground may update or archive them. Foreground and delegated-subagent writes to pinned skills remain allowed.
|
|
1018
|
+
If the only skills that need updating are protected, say 'Nothing to save.' and stop.
|
|
1019
|
+
|
|
1020
|
+
Do NOT capture (these become persistent self-imposed constraints that bite you later when the environment changes):
|
|
1021
|
+
• Environment-dependent failures: missing binaries, fresh-install errors, post-migration path mismatches, 'command not found', unconfigured credentials, uninstalled packages. The user can fix these — they are not durable rules.
|
|
1022
|
+
• Negative claims about tools or features ('browser tools do not work', 'X tool is broken', 'cannot use Y'). These harden into refusals the agent cites against itself for months after the actual problem was fixed.
|
|
1023
|
+
• Session-specific transient errors that resolved before the conversation ended. If retrying worked, the lesson is the retry pattern, not the original failure.
|
|
1024
|
+
• One-off task narratives. A user asking 'summarize today's market' or 'analyze this PR' is not a class of work that warrants a skill.
|
|
1025
|
+
|
|
1026
|
+
If a tool failed because of setup state, capture the FIX (install command, config step, env var to set) under an existing setup or troubleshooting skill — never 'this tool does not work' as a standalone constraint.
|
|
1027
|
+
|
|
1028
|
+
'Nothing to save.' is a real option but should NOT be the default. If the session ran smoothly with no corrections and produced no new technique, just say 'Nothing to save.' and stop. Otherwise, act.`;
|
|
1029
|
+
const COMBINED_REVIEW_PROMPT = `[Auto-review]
|
|
1030
|
+
Review the conversation above and update two things:
|
|
1031
|
+
|
|
1032
|
+
**Memory**: who the user is. Did the user reveal persona, desires, preferences, personal details, or expectations about how you should behave? Save facts about the user and durable preferences with the memory tool.
|
|
1033
|
+
|
|
1034
|
+
**Skills**: how to do this class of task. Be ACTIVE — most sessions produce at least one skill update. A pass that does nothing is a missed learning opportunity, not a neutral outcome.
|
|
1035
|
+
|
|
1036
|
+
Target shape of the skill library: CLASS-LEVEL skills with a rich SKILL.md and a references/ directory for session-specific detail. Not a long flat list of narrow one-session-one-skill entries.
|
|
1037
|
+
|
|
1038
|
+
Signals that warrant a skill update (any one is enough):
|
|
1039
|
+
• User corrected your style, tone, format, legibility, verbosity, or approach. Frustration is a FIRST-CLASS skill signal, not just a memory signal. 'stop doing X', 'don't format like this', 'I hate when you Y' — embed the lesson in the skill that governs that task so the next session starts fixed.
|
|
1040
|
+
• Non-trivial technique, fix, workaround, or debugging path emerged.
|
|
1041
|
+
• A skill that was loaded or consulted turned out wrong, missing, or outdated — patch it now.
|
|
1042
|
+
|
|
1043
|
+
Read-before-write (enforced by this channel): update, patch, delete, or write support files ONLY into skills you loaded or read in THIS session — ops on unread skills are dropped; CREATE of a brand-new umbrella is the only exception.
|
|
1044
|
+
|
|
1045
|
+
Preference order for skills — pick the earliest that fits:
|
|
1046
|
+
1. UPDATE A CURRENTLY-LOADED SKILL. Check what skills were loaded or read in the conversation. If one of them covers the learning, PATCH it first. It was in play; it's the right place.
|
|
1047
|
+
2. UPDATE AN EXISTING UMBRELLA. Patch it.
|
|
1048
|
+
3. ADD A SUPPORT FILE under an existing umbrella via skill_manage action=write_file. Three kinds: references/<topic>.md for session-specific detail OR condensed knowledge banks (quoted research, API docs excerpts, domain notes) written concise and task-focused; templates/<name>.<ext> for starter files meant to be copied and modified; scripts/<name>.<ext> for statically re-runnable actions (verification, fixture generators, probes). Add a one-line pointer in SKILL.md so future agents find them.
|
|
1049
|
+
4. CREATE A NEW CLASS-LEVEL UMBRELLA when nothing exists. Name at the class level — NOT a PR number, error string, codename, library-alone name, or 'fix-X / debug-Y' session artifact. If the name only fits today's task, fall back to (1), (2), or (3).
|
|
1050
|
+
|
|
1051
|
+
Two-tier deposition discipline (DSH addition): classify before writing — PATTERN (symptom → mechanism → fix → verification) goes in the SKILL.md body; LOG (commit SHAs, npm/profile states, this release's change list, this session's narrative) goes in a references/ file. Body density IS reuse rate; a pattern fits in 2-8 physical lines.
|
|
1052
|
+
|
|
1053
|
+
User-preference embedding: when the user complains about how you handled a task, update the skill that governs that task — memory alone isn't enough. Memory says 'who the user is and what the current situation and state of your operations are'; skills say 'how to do this class of task for this user'. Both should carry user-preference lessons when relevant.
|
|
1054
|
+
|
|
1055
|
+
If you notice overlapping existing skills, mention it — the background curator handles consolidation.
|
|
1056
|
+
|
|
1057
|
+
Protected skills (DO NOT edit these):
|
|
1058
|
+
• Bundled skills (shipped with the platform).
|
|
1059
|
+
• Hub-installed skills (installed from a hub).
|
|
1060
|
+
Pinned skills are read-only to THIS background review pass — the pinned write guard refuses background changes, so only the foreground may update or archive them. Foreground and delegated-subagent writes to pinned skills remain allowed.
|
|
1061
|
+
If the only skills that need updating are protected, say 'Nothing to save.' and stop.
|
|
1062
|
+
|
|
1063
|
+
Do NOT capture as skills (these become persistent self-imposed constraints that bite you later when the environment changes):
|
|
1064
|
+
• Environment-dependent failures: missing binaries, fresh-install errors, post-migration path mismatches, 'command not found', unconfigured credentials, uninstalled packages. The user can fix these — they are not durable rules.
|
|
1065
|
+
• Negative claims about tools or features ('browser tools do not work', 'X tool is broken', 'cannot use Y'). These harden into refusals the agent cites against itself for months after the actual problem was fixed.
|
|
1066
|
+
• Session-specific transient errors that resolved before the conversation ended. If retrying worked, the lesson is the retry pattern, not the original failure.
|
|
1067
|
+
• One-off task narratives. A user asking 'summarize today's market' or 'analyze this PR' is not a class of work that warrants a skill.
|
|
1068
|
+
|
|
1069
|
+
If a tool failed because of setup state, capture the FIX (install command, config step, env var to set) under an existing setup or troubleshooting skill — never 'this tool does not work' as a standalone constraint.
|
|
1070
|
+
|
|
1071
|
+
Act on whichever of the two dimensions 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.`;
|
|
1072
|
+
const CURATOR_PROMPT = `You are the skill curator. Maintain a healthy, class-level skill library, not a flat pile of narrow one-session skills.
|
|
1073
|
+
|
|
1074
|
+
This is an UMBRELLA-BUILDING consolidation pass, not a passive audit and not a duplicate-finder.
|
|
1075
|
+
|
|
1076
|
+
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.
|
|
1077
|
+
|
|
1078
|
+
Right target shape: class-level skills with rich SKILL.md + references/, templates/, scripts/ support files for session-specific detail.
|
|
1079
|
+
|
|
1080
|
+
Hard rules:
|
|
1081
|
+
1. NEVER hard-delete a skill. Archive (moving to .archive/) is the maximum destructive action; archives are recoverable, deletion is not.
|
|
1082
|
+
2. Do not touch bundled, hub-installed, pinned, or scheduled-task-referenced (referenced) skills. Referenced skills are fully protected — never consolidated, never pruned (there is no scheduled-task reference-rewriting pass; a referenced skill stays in place by design).
|
|
1083
|
+
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. Never archive a never-used skill unless it is at least 30 days old AND its content is genuinely obsolete or fully absorbed elsewhere.
|
|
1084
|
+
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.
|
|
1085
|
+
5. Judge overlap on CONTENT, not on usage counters.
|
|
1086
|
+
6. Before archiving a merged skill, ensure its unique content was preserved in the umbrella.
|
|
1087
|
+
|
|
1088
|
+
How to work:
|
|
1089
|
+
1. Scan the candidate list. Identify PREFIX CLUSTERS — skills sharing a first word or domain keyword. Expected cluster count scales with the library: a large collection may show 10-25 prefix clusters, a small one often has none — a clean "nothing to consolidate" summary is the correct small-library outcome, not a shortage of ambition.
|
|
1090
|
+
2. For each cluster with 2+ members, ask "what is the UMBRELLA CLASS these skills serve?" and consolidate:
|
|
1091
|
+
a. MERGE INTO AN EXISTING UMBRELLA (patch a labeled section for each sibling's unique insight, then archive the siblings).
|
|
1092
|
+
b. CREATE A NEW UMBRELLA SKILL.md covering the shared workflow with short labeled subsections, then archive the absorbed siblings.
|
|
1093
|
+
c. DEMOTE session-specific detail to references/, templates/, or scripts/ under the umbrella. Use the right directory per kind:
|
|
1094
|
+
• references/<topic>.md — session-specific detail OR condensed knowledge banks (quoted research, API docs excerpts, domain notes, provider quirks, reproduction recipes) written concise and task-focused.
|
|
1095
|
+
• templates/<name>.<ext> — starter files meant to be copied and modified.
|
|
1096
|
+
• scripts/<name>.<ext> — statically re-runnable actions (verification scripts, fixture generators, probes).
|
|
1097
|
+
3. Package integrity — not optional: inspect each skill as a COMPLETE directory package, not just SKILL.md. A skill root may include references/, templates/, scripts/, and assets/. If the source skill has support files OR its SKILL.md contains relative links to them, DO NOT flatten only SKILL.md into <umbrella>/references/<old>.md. Choose one safe path instead: keep it as a standalone skill, OR fully merge by re-homing every needed support file into the umbrella's canonical directories AND rewriting the destination instructions to the new paths, OR archive the entire original skill package unchanged. Never leave demoted instructions pointing at files left behind under the old skill directory.
|
|
1098
|
+
4. Flag skills whose NAME is too narrow (contains a PR number, a feature codename, a specific error string, an 'audit'/'diagnosis'/'salvage' session artifact) — they almost always belong as a subsection or support file under a class-level umbrella.
|
|
1099
|
+
5. Iterate. After one consolidation round, scan the remaining set and look for the NEXT umbrella opportunity. Don't stop after 3 merges.
|
|
1100
|
+
|
|
1101
|
+
You are a NOMINATOR, not an executor: this channel has NO tools. Your single deliverable is the structured YAML block below. Never narrate actions you did not take ("merged", "patched", "archived") — you are proposing, and the deterministic engine executes only names from the candidate pool it gave you. (A future execution view would expose skill_manage; today it does not.)
|
|
1102
|
+
|
|
1103
|
+
'keep' is a legitimate decision ONLY when the skill is already a class-level umbrella and none of the proposed merges would improve discoverability. 'This is narrow but distinct from its siblings' is NOT a reason to keep — it's a reason to move it under an umbrella as a subsection or support file.
|
|
1104
|
+
|
|
1105
|
+
Expected output: real umbrella-ification. Process every obvious cluster. If you end the pass with obvious clusters still untouched, you stopped too early — go back and look at the clusters you left alone.
|
|
1106
|
+
|
|
1107
|
+
Keep the umbrella body tight and scannable: exact commands, verbatim paths, ~100-200 lines; never invent flags or APIs.
|
|
1108
|
+
|
|
1109
|
+
When done, write a human summary THEN the structured machine-readable block. The block is the contract: every skill you would move to .archive/ MUST appear in exactly one of the two lists. Return ONLY the YAML block after the summary — no post-block prose. Format EXACTLY:
|
|
1110
|
+
|
|
1111
|
+
## Structured summary (required)
|
|
1112
|
+
\`\`\`yaml
|
|
1113
|
+
consolidations:
|
|
1114
|
+
- from: <old-skill-name>
|
|
1115
|
+
into: <umbrella-skill-name>
|
|
1116
|
+
reason: <one short sentence — why merged, not just 'similar'>
|
|
1117
|
+
prunings:
|
|
1118
|
+
- name: <skill-name>
|
|
1119
|
+
reason: <one short sentence — why archived with no merge target>
|
|
1120
|
+
\`\`\`
|
|
1121
|
+
|
|
1122
|
+
Every skill you would move to .archive/ MUST appear in exactly one of the two lists. If you consolidated X into umbrella Y (patched Y, wrote a references file to Y, or created Y with X's content absorbed), X goes under consolidations with into: Y. If you archived X with no absorption — truly stale, irrelevant, or obsolete — X goes under prunings. Leave a list empty (consolidations: []) if none. Do not omit the block. The block comes AFTER your human-readable summary of clusters processed, patches made, and decisions left alone.`;
|
|
1123
|
+
const CURATOR_DRY_RUN_BANNER = `═══════════════════════════════════════════════════════════════
|
|
1124
|
+
DRY-RUN — REPORT ONLY. DO NOT MUTATE THE SKILL LIBRARY.
|
|
1125
|
+
═══════════════════════════════════════════════════════════════
|
|
1126
|
+
|
|
1127
|
+
This is a PREVIEW pass. Follow every instruction above EXCEPT:
|
|
1128
|
+
• Do NOT call skill_manage with action=create, update, patch, delete, write_file, or remove_file.
|
|
1129
|
+
• Do NOT move, copy, or rewrite any file under the skills tree.
|
|
1130
|
+
|
|
1131
|
+
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.
|
|
1132
|
+
|
|
1133
|
+
If you accidentally take a mutating action, say so explicitly in the summary.`;
|
|
1134
|
+
const COMPLETION_SKILL_REVIEW_PROMPT = `[Auto-review — Skills · task complete]
|
|
1135
|
+
Your current task now appears complete. Before wrapping up, review the approach and update the skill library via skill_manage.
|
|
1136
|
+
|
|
1137
|
+
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.
|
|
1138
|
+
|
|
1139
|
+
Do NOT modify output files or re-run the task. If you are still mid-task, ignore this.`;
|
|
1140
|
+
/**
|
|
1141
|
+
* System-prompt guidance section (Hermes `SKILLS_GUIDANCE`, DSH-adapted).
|
|
1142
|
+
* Registered as a system-prompt section by tool-skill-manage (it mounts
|
|
1143
|
+
* exactly when `skill_manage` is available — the DSH analogue of Hermes'
|
|
1144
|
+
* `if "skill_manage" in agent.valid_tool_names` condition). Instructs the
|
|
1145
|
+
* model to save/repair skills on its own initiative.
|
|
1146
|
+
*/
|
|
1147
|
+
const SKILLS_GUIDANCE = `Skills guidance:
|
|
1148
|
+
• After completing a complex task (5+ tool calls), fixing a tricky error, or discovering a non-trivial workflow, save the approach as a skill with skill_manage so you can reuse it next time.
|
|
1149
|
+
• When using a skill and finding it outdated, incomplete, or wrong, patch it immediately with skill_manage (action='patch') — don't wait to be asked. Skills that aren't maintained become liabilities.`;
|
|
1150
|
+
const PLAN_CHANNEL_NOTE = `
|
|
1151
|
+
|
|
1152
|
+
CHANNEL (subagent): this review channel mounts only the read-only \`skill\` tool — you have NO \`skill_manage\`, NO \`memory\`. Your deliverable is the structured JSON plan below (outputSchema). Describe the patches/creates you RECOMMEND in the plan; never narrate actions you took.`;
|
|
1153
|
+
/** Subagent-channel variant: same review policy, channel-limited deliverable (M-2). */
|
|
1154
|
+
const SKILL_REVIEW_PLAN_PROMPT = `${SKILL_REVIEW_PROMPT}${PLAN_CHANNEL_NOTE}`;
|
|
1155
|
+
/** Subagent-channel variant of the combined review (M-2). */
|
|
1156
|
+
const COMBINED_REVIEW_PLAN_PROMPT = `${COMBINED_REVIEW_PROMPT}${PLAN_CHANNEL_NOTE}`;
|
|
1157
|
+
function reviewPrompt(kind, channel = "agent") {
|
|
1158
|
+
if (kind === "memory") return MEMORY_REVIEW_PROMPT;
|
|
1159
|
+
if (channel === "plan") return kind === "skill" ? SKILL_REVIEW_PLAN_PROMPT : COMBINED_REVIEW_PLAN_PROMPT;
|
|
1160
|
+
if (kind === "skill") return SKILL_REVIEW_PROMPT;
|
|
1161
|
+
return COMBINED_REVIEW_PROMPT;
|
|
1162
|
+
}
|
|
1163
|
+
function sha256(text) {
|
|
1164
|
+
return createHash("sha256").update(text).digest("hex");
|
|
1165
|
+
}
|
|
1166
|
+
function createPromptBundle(prompts) {
|
|
1167
|
+
const canonical = JSON.stringify({
|
|
1168
|
+
id: PROMPT_BUNDLE_ID,
|
|
1169
|
+
version: 7,
|
|
1170
|
+
prompts: Object.fromEntries(Object.entries(prompts).sort())
|
|
1171
|
+
});
|
|
1172
|
+
return Object.freeze({
|
|
1173
|
+
id: PROMPT_BUNDLE_ID,
|
|
1174
|
+
version: 7,
|
|
1175
|
+
prompts: Object.freeze({ ...prompts }),
|
|
1176
|
+
sha256: sha256(canonical)
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
const PROMPT_BUNDLE = createPromptBundle({
|
|
1180
|
+
memory: MEMORY_REVIEW_PROMPT,
|
|
1181
|
+
skill: SKILL_REVIEW_PROMPT,
|
|
1182
|
+
combined: COMBINED_REVIEW_PROMPT,
|
|
1183
|
+
skillPlan: SKILL_REVIEW_PLAN_PROMPT,
|
|
1184
|
+
combinedPlan: COMBINED_REVIEW_PLAN_PROMPT,
|
|
1185
|
+
curator: CURATOR_PROMPT,
|
|
1186
|
+
completion: COMPLETION_SKILL_REVIEW_PROMPT,
|
|
1187
|
+
skillsGuidance: SKILLS_GUIDANCE
|
|
1188
|
+
});
|
|
1189
|
+
function verifyPromptBundle(bundle = PROMPT_BUNDLE) {
|
|
1190
|
+
if (bundle.id !== "dsh-evolution@7" || bundle.version !== 7) return false;
|
|
1191
|
+
const canonical = JSON.stringify({
|
|
1192
|
+
id: PROMPT_BUNDLE_ID,
|
|
1193
|
+
version: 7,
|
|
1194
|
+
prompts: Object.fromEntries(Object.entries(bundle.prompts).sort())
|
|
1195
|
+
});
|
|
1196
|
+
return bundle.sha256 === sha256(canonical);
|
|
1197
|
+
}
|
|
1198
|
+
const DSH_AUTHORING_STANDARDS = `Follow the Hermes skill-authoring standards, translated to DSH tools.
|
|
1199
|
+
|
|
1200
|
+
Frontmatter:
|
|
1201
|
+
- name: lowercase-hyphenated, <=64 chars, no spaces.
|
|
1202
|
+
- 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. If the description contains a colon, wrap the whole value in double quotes.
|
|
1203
|
+
- version: 0.1.0
|
|
1204
|
+
- author: always the literal value "Hermes". NEVER fill it from the environment, git config, or any identity you can probe — an environment-derived name is a privacy leak the user never opted into (skills get shared and published), and the skill names itself as Hermes.
|
|
1205
|
+
- platforms: declare [macos], [linux], and/or [windows] only when the skill is genuinely OS-bound (osascript/apt/systemctl => the matching OS; /proc, signal.SIGKILL => linux; fcntl/termios => POSIX). Prefer fixing it cross-platform first (tempdir, pathlib, pure-Node); omit the field for portable skills.
|
|
1206
|
+
- metadata.hermes.tags: a few Capitalized, Relevant, Tags.
|
|
1207
|
+
- metadata.hermes.related_skills: [a, b] — name sibling skills this one builds on or is referenced by (optional; feeds the quality references factor).
|
|
1208
|
+
|
|
1209
|
+
Body section order (omit only when empty):
|
|
1210
|
+
1. "# <Human Title>" then a 2-3 sentence intro: what it does, what it does NOT do, key dependency stance.
|
|
1211
|
+
2. "## When to Use" — concrete trigger phrases.
|
|
1212
|
+
3. "## Prerequisites" — exact env vars, install steps, credentials.
|
|
1213
|
+
4. "## How to Run" — canonical invocation framed through DSH tools.
|
|
1214
|
+
5. "## Quick Reference" — flat command/endpoint list.
|
|
1215
|
+
6. "## Procedure" — numbered steps with copy-paste-exact commands.
|
|
1216
|
+
7. "## Pitfalls" — known limits and rate limits.
|
|
1217
|
+
8. "## Verification" — one check proving the skill worked.
|
|
1218
|
+
|
|
1219
|
+
DSH-tool framing:
|
|
1220
|
+
- Reference DSH tools by name in backticks: \`bash\`, \`str_replace_editor\`, \`write\`, \`skill\`, \`skill_manage\`, \`memory\`.
|
|
1221
|
+
- Do not name wrapped shell utilities when a DSH tool already covers them.
|
|
1222
|
+
- Larger scripts belong under \`scripts/\` (written with \`skill_manage write_file\`) and are referenced from SKILL.md by relative path.
|
|
1223
|
+
|
|
1224
|
+
Quality bar:
|
|
1225
|
+
- Prefer verbatim flags, paths, and APIs from the source. Never invent them.
|
|
1226
|
+
- Keep it tight: ~100 lines simple, ~200 complex.
|
|
1227
|
+
- No router/index/hub skills that only point at other skills.
|
|
1228
|
+
- References go in \`references/\`, templates in \`templates/\`.
|
|
1229
|
+
|
|
1230
|
+
Learn workflow (when the user asks you to learn a reusable skill, or you decide to turn a source/request into one):
|
|
1231
|
+
1. Gather every source named (files, URLs, "what we just did", pasted notes) with the tools you already have — and treat prose after a source as authoring requirements, not noise.
|
|
1232
|
+
2. Apply every requirement and constraint from the request to the SKILL.md you author.
|
|
1233
|
+
3. Author exactly ONE SKILL.md and save it with \`skill_manage\` (action=create); non-trivial scripts go under \`scripts/\`.
|
|
1234
|
+
4. When done, tell the user the skill name, its category, and a one-line summary of what it captured.`;
|
|
1235
|
+
//#endregion
|
|
1236
|
+
//#region lib/types/learn-prompt.js
|
|
1237
|
+
/**
|
|
1238
|
+
* Open-ended `/evolution learn` prompt builder.
|
|
1239
|
+
*
|
|
1240
|
+
* `learn` is open-ended: the user can name anything they can describe — a
|
|
1241
|
+
* directory of code, an API doc URL, a workflow they just walked the agent
|
|
1242
|
+
* through, or pasted notes. The prompt instructs the live agent to gather the
|
|
1243
|
+
* named sources with its existing tools, then author a single SKILL.md via
|
|
1244
|
+
* `skill_manage` following `DSH_AUTHORING_STANDARDS`. There is no separate
|
|
1245
|
+
* distillation engine and no model-tool footprint.
|
|
1246
|
+
*/
|
|
1247
|
+
/**
|
|
1248
|
+
* Build the agent prompt for an open-ended `/evolution learn` request.
|
|
1249
|
+
*
|
|
1250
|
+
* @param userRequest free-text the user gave after `/evolution learn`; an
|
|
1251
|
+
* empty string falls back to "the workflow we just went through".
|
|
1252
|
+
* @returns a complete instruction the agent runs as a normal turn.
|
|
1253
|
+
*/
|
|
1254
|
+
function buildLearnPrompt(userRequest) {
|
|
1255
|
+
return [
|
|
1256
|
+
"[/learn] The user wants you to learn a reusable skill from the request below, and save it.",
|
|
1257
|
+
"",
|
|
1258
|
+
"THE REQUEST:",
|
|
1259
|
+
userRequest.trim() || "the workflow we just went through in this conversation — review the steps taken and distill them into a reusable skill",
|
|
1260
|
+
"",
|
|
1261
|
+
"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.",
|
|
1262
|
+
"",
|
|
1263
|
+
"Do this:",
|
|
1264
|
+
"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.",
|
|
1265
|
+
"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.",
|
|
1266
|
+
"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.",
|
|
1267
|
+
"",
|
|
1268
|
+
DSH_AUTHORING_STANDARDS,
|
|
1269
|
+
"",
|
|
1270
|
+
"When done, tell the user the skill name, its category, and a one-line summary of what it captured."
|
|
1271
|
+
].join("\n");
|
|
1272
|
+
}
|
|
1273
|
+
//#endregion
|
|
241
1274
|
//#region lib/types/threats.js
|
|
242
1275
|
/**
|
|
243
1276
|
* Threat scanning for agent-authored memory and skill content.
|
|
@@ -413,10 +1446,12 @@ const SCOPE_ORDER = {
|
|
|
413
1446
|
context: 2,
|
|
414
1447
|
strict: 3
|
|
415
1448
|
};
|
|
1449
|
+
const NO_SCAN_OPTIONS = {};
|
|
416
1450
|
/**
|
|
417
1451
|
* Scan text at `scope`. Patterns are cumulative: `strict` includes all scopes.
|
|
1452
|
+
* `options.excludeLabels` removes matching patterns without changing `scope`.
|
|
418
1453
|
*/
|
|
419
|
-
function scanThreats(text, scope = "strict", maxScanChars = 65536) {
|
|
1454
|
+
function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
|
|
420
1455
|
const findings = [];
|
|
421
1456
|
if (ZERO_WIDTH_CHARS.test(text)) findings.push({
|
|
422
1457
|
label: "unicode_zero_width",
|
|
@@ -429,8 +1464,10 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536) {
|
|
|
429
1464
|
scope
|
|
430
1465
|
});
|
|
431
1466
|
const normalized = text.normalize("NFKC").slice(0, maxScanChars);
|
|
1467
|
+
const excluded = new Set(options.excludeLabels ?? []);
|
|
432
1468
|
for (const pattern of PATTERNS) {
|
|
433
1469
|
if (SCOPE_ORDER[pattern.scope] > SCOPE_ORDER[scope]) continue;
|
|
1470
|
+
if (excluded.has(pattern.label)) continue;
|
|
434
1471
|
if (pattern.regex.test(normalized)) findings.push({
|
|
435
1472
|
label: pattern.label,
|
|
436
1473
|
category: pattern.category,
|
|
@@ -440,24 +1477,24 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536) {
|
|
|
440
1477
|
return findings;
|
|
441
1478
|
}
|
|
442
1479
|
/** Blocking policy: any hit blocks. `severity` is deliberately not a gate. */
|
|
443
|
-
function evaluateThreat(text, scope = "strict", maxScanChars = 65536) {
|
|
444
|
-
const findings = scanThreats(text, scope, maxScanChars);
|
|
1480
|
+
function evaluateThreat(text, scope = "strict", maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
|
|
1481
|
+
const findings = scanThreats(text, scope, maxScanChars, options);
|
|
445
1482
|
return {
|
|
446
1483
|
blocked: findings.length > 0,
|
|
447
1484
|
findings
|
|
448
1485
|
};
|
|
449
1486
|
}
|
|
450
1487
|
/** User-facing block message for memory writes. */
|
|
451
|
-
function scanMemoryThreats(text, maxScanChars = 65536) {
|
|
452
|
-
const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars);
|
|
1488
|
+
function scanMemoryThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
|
|
1489
|
+
const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
|
|
453
1490
|
if (!blocked) return null;
|
|
454
1491
|
const pattern = findings.find((f) => f.category !== "unicode_obfuscation");
|
|
455
1492
|
if (pattern) return `Blocked by security scan (${pattern.label}). Rephrase without instruction-like language.`;
|
|
456
1493
|
return "Blocked by security scan: invisible or potentially malicious Unicode detected.";
|
|
457
1494
|
}
|
|
458
1495
|
/** User-facing block message for skill content writes. */
|
|
459
|
-
function scanContentThreats(text, maxScanChars = 65536) {
|
|
460
|
-
const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars);
|
|
1496
|
+
function scanContentThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
|
|
1497
|
+
const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
|
|
461
1498
|
if (!blocked) return null;
|
|
462
1499
|
return `Blocked by security scan (${findings[0]?.label ?? "unknown"}). This content appears to contain potentially malicious instructions.`;
|
|
463
1500
|
}
|
|
@@ -467,7 +1504,39 @@ function scanContentThreats(text, maxScanChars = 65536) {
|
|
|
467
1504
|
* File-backed durable memory with Hermes-compatible semantics.
|
|
468
1505
|
* Stores are MEMORY.md and USER.md under $DSH_HOME/memories (~/.dsh/memories).
|
|
469
1506
|
*/
|
|
470
|
-
|
|
1507
|
+
/**
|
|
1508
|
+
* Read-guard factor: a memory file larger than this multiple of its target's
|
|
1509
|
+
* char limit is treated as externally corrupted and skipped instead of being
|
|
1510
|
+
* read whole (aligned with claw `tools/memory.ts` size guard, which uses the
|
|
1511
|
+
* same 10× bound around a file that should never exceed the store limit).
|
|
1512
|
+
*/
|
|
1513
|
+
const READ_GUARD_FACTOR = 10;
|
|
1514
|
+
/**
|
|
1515
|
+
* Consolidation-failure backoff window (package-private, rc.42 audit P2-1):
|
|
1516
|
+
* only failures inside the window count toward `maxConsolidationFailures`.
|
|
1517
|
+
* The store cannot observe turn boundaries, so the model-facing "this turn"
|
|
1518
|
+
* phrasing is approximated with ten minutes — generous enough to cover one
|
|
1519
|
+
* turn's retry loop, short enough that a failure yesterday never makes today's
|
|
1520
|
+
* first refusal say "stop retrying".
|
|
1521
|
+
*/
|
|
1522
|
+
const FAILURE_WINDOW_MS = 10 * 6e4;
|
|
1523
|
+
/**
|
|
1524
|
+
* Recoverable-error preview bounds (B-line G5, Hermes `_previews` parity):
|
|
1525
|
+
* failed replace/remove/batch calls echo the current entries so the model can
|
|
1526
|
+
* self-recover without re-reading the store. Bounded to five entries of eighty
|
|
1527
|
+
* characters each; package-private because it is an error-message shape, not a
|
|
1528
|
+
* behavior switch.
|
|
1529
|
+
*/
|
|
1530
|
+
const ERROR_PREVIEW_ENTRIES = 5;
|
|
1531
|
+
const ERROR_PREVIEW_WIDTH = 80;
|
|
1532
|
+
function previewEntries(entries) {
|
|
1533
|
+
if (entries.length === 0) return "";
|
|
1534
|
+
const shown = entries.slice(0, ERROR_PREVIEW_ENTRIES).map((entry) => {
|
|
1535
|
+
return `- ${entry.length > ERROR_PREVIEW_WIDTH ? `${entry.slice(0, ERROR_PREVIEW_WIDTH)}…` : entry}`;
|
|
1536
|
+
});
|
|
1537
|
+
const more = entries.length > ERROR_PREVIEW_ENTRIES ? `\n (+${entries.length - ERROR_PREVIEW_ENTRIES} more)` : "";
|
|
1538
|
+
return `\n\nCurrent entries (preview):\n${shown.join("\n")}${more}`;
|
|
1539
|
+
}
|
|
471
1540
|
function memoryRoot(env = process.env) {
|
|
472
1541
|
return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "memories");
|
|
473
1542
|
}
|
|
@@ -491,6 +1560,7 @@ var MemoryStore = class {
|
|
|
491
1560
|
maxFailures;
|
|
492
1561
|
io;
|
|
493
1562
|
failureCount = 0;
|
|
1563
|
+
lastFailureAt = 0;
|
|
494
1564
|
constructor(options = {}) {
|
|
495
1565
|
this.io = options.io ?? nodeEvolutionIo();
|
|
496
1566
|
this.memoryLimit = options.memoryCharLimit ?? 2200;
|
|
@@ -502,7 +1572,24 @@ var MemoryStore = class {
|
|
|
502
1572
|
limitFor(target) {
|
|
503
1573
|
return target === "memory" ? this.memoryLimit : this.userLimit;
|
|
504
1574
|
}
|
|
1575
|
+
/**
|
|
1576
|
+
* Read-guard probe: `{ size, limit }` when the on-disk file exceeds
|
|
1577
|
+
* `limit * READ_GUARD_FACTOR` bytes, `null` when it is absent, unknown
|
|
1578
|
+
* (backend without a size probe), under the bound, or the target has no
|
|
1579
|
+
* limit configured.
|
|
1580
|
+
*/
|
|
1581
|
+
async oversizedFile(target) {
|
|
1582
|
+
const size = await this.io.size?.(fileFor(this.root, target));
|
|
1583
|
+
if (size === null || size === void 0) return null;
|
|
1584
|
+
const limit = this.limitFor(target);
|
|
1585
|
+
if (limit <= 0) return null;
|
|
1586
|
+
return size > limit * READ_GUARD_FACTOR ? {
|
|
1587
|
+
size,
|
|
1588
|
+
limit
|
|
1589
|
+
} : null;
|
|
1590
|
+
}
|
|
505
1591
|
async read(target) {
|
|
1592
|
+
if (await this.oversizedFile(target)) return [];
|
|
506
1593
|
const raw = await this.io.readText(fileFor(this.root, target));
|
|
507
1594
|
return raw === null ? [] : [...new Set(normalizeEntries(raw))];
|
|
508
1595
|
}
|
|
@@ -513,133 +1600,168 @@ var MemoryStore = class {
|
|
|
513
1600
|
this.failureCount = 0;
|
|
514
1601
|
}
|
|
515
1602
|
failure(target, message, entries) {
|
|
1603
|
+
if (Date.now() - this.lastFailureAt > FAILURE_WINDOW_MS) this.failureCount = 0;
|
|
1604
|
+
this.lastFailureAt = Date.now();
|
|
516
1605
|
this.failureCount += 1;
|
|
517
1606
|
const chars = entries.join(ENTRY_DELIMITER).length;
|
|
518
1607
|
if (this.failureCount > this.maxFailures) return {
|
|
519
1608
|
ok: false,
|
|
520
|
-
message: `Memory consolidation failed ${this.failureCount} times this turn. Stop retrying memory calls and continue with the user's task
|
|
1609
|
+
message: `Memory consolidation failed ${this.failureCount} times this turn. Stop retrying memory calls and continue with the user's task.${previewEntries(entries)}`,
|
|
521
1610
|
entries,
|
|
522
1611
|
chars,
|
|
523
1612
|
limit: this.limitFor(target)
|
|
524
1613
|
};
|
|
525
1614
|
return {
|
|
526
1615
|
ok: false,
|
|
527
|
-
message
|
|
1616
|
+
message: `${message}${previewEntries(entries)}`,
|
|
528
1617
|
entries,
|
|
529
1618
|
chars,
|
|
530
1619
|
limit: this.limitFor(target)
|
|
531
1620
|
};
|
|
532
1621
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
1622
|
+
/**
|
|
1623
|
+
* StorageHint percentage must clamp at 100 like the render header: a drifted
|
|
1624
|
+
* entry can push chars past the limit, and "Storage at 125%" contradicts the
|
|
1625
|
+
* clamped usage indicator.
|
|
1626
|
+
*/
|
|
1627
|
+
storageHint(target, chars) {
|
|
1628
|
+
const limit = this.limitFor(target);
|
|
1629
|
+
if (limit <= 0) return "";
|
|
1630
|
+
const percent = Math.min(100, Math.floor(chars * 100 / limit));
|
|
1631
|
+
return percent >= 80 ? ` ⚠️ Storage at ${percent}% (${chars}/${limit} chars).` : "";
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Best-effort raw-copy backup of the on-disk file to `<file>.bak.<stamp>`
|
|
1635
|
+
* before a refusal, so an externally modified (or oversized) file stays
|
|
1636
|
+
* recoverable. Copies bytes instead of reading them so a pathologically
|
|
1637
|
+
* large file is never loaded just to back it up. Failure to back up does
|
|
1638
|
+
* not change the refusal semantics.
|
|
1639
|
+
*/
|
|
1640
|
+
async backupFile(target) {
|
|
1641
|
+
const path = fileFor(this.root, target);
|
|
1642
|
+
const unique = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 14)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1643
|
+
try {
|
|
1644
|
+
await this.io.copy(path, `${path}.bak.${unique}`);
|
|
1645
|
+
return `${path}.bak.${unique}`;
|
|
1646
|
+
} catch {
|
|
1647
|
+
return null;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
/**
|
|
1651
|
+
* Read-guard refusal for write paths. Returns the refusal result when the
|
|
1652
|
+
* target file is oversized, `null` otherwise. The file is skipped for
|
|
1653
|
+
* reading (never loaded), backed up by raw copy, and the model is told to
|
|
1654
|
+
* fix it manually — mirroring the drift refusal so corrupted state is never
|
|
1655
|
+
* silently overwritten.
|
|
1656
|
+
*/
|
|
1657
|
+
async oversizedRefusal(target) {
|
|
1658
|
+
const oversized = await this.oversizedFile(target);
|
|
1659
|
+
if (!oversized) return null;
|
|
1660
|
+
const backup = await this.backupFile(target);
|
|
1661
|
+
const suffix = backup ? ` A backup was saved to ${basename(backup)}.` : "";
|
|
1662
|
+
return {
|
|
536
1663
|
ok: false,
|
|
537
|
-
message:
|
|
1664
|
+
message: `Memory file is ${oversized.size} bytes (limit ${oversized.limit * READ_GUARD_FACTOR}) — skipping read.${suffix} Fix the file manually, then retry.`,
|
|
538
1665
|
entries: [],
|
|
539
1666
|
chars: 0,
|
|
540
1667
|
limit: this.limitFor(target)
|
|
541
1668
|
};
|
|
542
|
-
|
|
543
|
-
|
|
1669
|
+
}
|
|
1670
|
+
async add(target, facts) {
|
|
1671
|
+
if (!facts.trim()) return {
|
|
544
1672
|
ok: false,
|
|
545
|
-
message:
|
|
1673
|
+
message: "Content cannot be empty.",
|
|
546
1674
|
entries: [],
|
|
547
1675
|
chars: 0,
|
|
548
1676
|
limit: this.limitFor(target)
|
|
549
1677
|
};
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
1678
|
+
const path = fileFor(this.root, target);
|
|
1679
|
+
const refusal = await this.oversizedRefusal(target);
|
|
1680
|
+
if (refusal) return refusal;
|
|
1681
|
+
let outcome;
|
|
1682
|
+
await transactIo(this.io, path, async (current) => {
|
|
1683
|
+
const core = await this.addCore(target, facts, current ?? "");
|
|
1684
|
+
outcome = core.result;
|
|
1685
|
+
return core.write ?? current ?? null;
|
|
1686
|
+
});
|
|
1687
|
+
return outcome;
|
|
1688
|
+
}
|
|
1689
|
+
/**
|
|
1690
|
+
* Single-entry add inside the transaction: shared checks (oversized,
|
|
1691
|
+
* drift, threat) and the content computation. `raw` is the locked view
|
|
1692
|
+
* (`current`) — never a second IO read. `write: null` means "no change".
|
|
1693
|
+
*/
|
|
1694
|
+
async addCore(target, facts, raw) {
|
|
1695
|
+
const content = facts.trim();
|
|
1696
|
+
if (!content) return {
|
|
1697
|
+
result: this.failure(target, "Content cannot be empty.", []),
|
|
1698
|
+
write: null
|
|
1699
|
+
};
|
|
1700
|
+
if (this.driftFromRaw(target, raw)) {
|
|
1701
|
+
const backup = await this.backupFile(target);
|
|
553
1702
|
return {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
1703
|
+
result: {
|
|
1704
|
+
ok: false,
|
|
1705
|
+
message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
|
|
1706
|
+
entries: [],
|
|
1707
|
+
chars: 0,
|
|
1708
|
+
limit: this.limitFor(target)
|
|
1709
|
+
},
|
|
1710
|
+
write: null
|
|
559
1711
|
};
|
|
560
1712
|
}
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
await this.write(target, next);
|
|
565
|
-
this.resetFailures();
|
|
566
|
-
return {
|
|
567
|
-
ok: true,
|
|
568
|
-
message: "Entry added.",
|
|
569
|
-
entries: next,
|
|
570
|
-
chars: total,
|
|
571
|
-
limit: this.limitFor(target)
|
|
572
|
-
};
|
|
573
|
-
}
|
|
574
|
-
async replace(target, oldText, facts) {
|
|
575
|
-
return this.mutate(target, oldText, "replace", facts);
|
|
576
|
-
}
|
|
577
|
-
async remove(target, oldText) {
|
|
578
|
-
return this.mutate(target, oldText, "remove", void 0);
|
|
579
|
-
}
|
|
580
|
-
async mutate(target, oldText, action, facts) {
|
|
581
|
-
const needle = oldText.trim();
|
|
582
|
-
if (!needle) return {
|
|
583
|
-
ok: false,
|
|
584
|
-
message: "old_text cannot be empty.",
|
|
585
|
-
entries: [],
|
|
586
|
-
chars: 0,
|
|
587
|
-
limit: this.limitFor(target)
|
|
588
|
-
};
|
|
589
|
-
const content = action === "replace" ? (facts ?? "").trim() : "";
|
|
590
|
-
if (action === "replace" && !content) return {
|
|
591
|
-
ok: false,
|
|
592
|
-
message: "facts is required for replace; use remove to delete.",
|
|
593
|
-
entries: [],
|
|
594
|
-
chars: 0,
|
|
595
|
-
limit: this.limitFor(target)
|
|
596
|
-
};
|
|
597
|
-
if (action === "replace") {
|
|
598
|
-
const threat = scanMemoryThreats(content);
|
|
599
|
-
if (threat) return {
|
|
1713
|
+
const threat = scanMemoryThreats(content);
|
|
1714
|
+
if (threat) return {
|
|
1715
|
+
result: {
|
|
600
1716
|
ok: false,
|
|
601
1717
|
message: threat,
|
|
602
1718
|
entries: [],
|
|
603
1719
|
chars: 0,
|
|
604
1720
|
limit: this.limitFor(target)
|
|
1721
|
+
},
|
|
1722
|
+
write: null
|
|
1723
|
+
};
|
|
1724
|
+
const entries = [...new Set(normalizeEntries(raw))];
|
|
1725
|
+
if (entries.some((entry) => stripDatePrefix(entry) === content)) {
|
|
1726
|
+
this.resetFailures();
|
|
1727
|
+
return {
|
|
1728
|
+
result: {
|
|
1729
|
+
ok: true,
|
|
1730
|
+
message: `Entry already exists (no duplicate added).${this.storageHint(target, entries.join(ENTRY_DELIMITER).length)}`,
|
|
1731
|
+
entries,
|
|
1732
|
+
chars: entries.join(ENTRY_DELIMITER).length,
|
|
1733
|
+
limit: this.limitFor(target)
|
|
1734
|
+
},
|
|
1735
|
+
write: null
|
|
605
1736
|
};
|
|
606
1737
|
}
|
|
607
|
-
|
|
608
|
-
ok: false,
|
|
609
|
-
message: "External drift detected in memory file. Resolve the drift before retrying.",
|
|
610
|
-
entries: [],
|
|
611
|
-
chars: 0,
|
|
612
|
-
limit: this.limitFor(target)
|
|
613
|
-
};
|
|
614
|
-
const entries = await this.read(target);
|
|
615
|
-
const matches = entries.map((entry, index) => ({
|
|
616
|
-
entry,
|
|
617
|
-
index
|
|
618
|
-
})).filter(({ entry }) => entry.includes(needle));
|
|
619
|
-
if (matches.length === 0) return this.failure(target, `No entry matching "${needle}" found.`, entries);
|
|
620
|
-
if (new Set(matches.map((m) => m.entry)).size > 1) return {
|
|
621
|
-
ok: false,
|
|
622
|
-
message: `Multiple distinct entries matched "${needle}". Be more specific.`,
|
|
623
|
-
entries,
|
|
624
|
-
chars: entries.join(ENTRY_DELIMITER).length,
|
|
625
|
-
limit: this.limitFor(target)
|
|
626
|
-
};
|
|
627
|
-
const index = matches[0]?.index ?? -1;
|
|
628
|
-
const next = [...entries];
|
|
629
|
-
if (action === "remove") next.splice(index, 1);
|
|
630
|
-
else next[index] = content;
|
|
1738
|
+
const next = [...entries, this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n${content}` : content];
|
|
631
1739
|
const total = next.join(ENTRY_DELIMITER).length;
|
|
632
|
-
|
|
633
|
-
|
|
1740
|
+
const addLimit = this.limitFor(target);
|
|
1741
|
+
if (addLimit > 0 && total > addLimit) return {
|
|
1742
|
+
result: this.failure(target, `Adding this entry would exceed the ${addLimit} char limit. Consolidate or remove stale entries, then retry.`, entries),
|
|
1743
|
+
write: null
|
|
1744
|
+
};
|
|
634
1745
|
this.resetFailures();
|
|
635
1746
|
return {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
1747
|
+
result: {
|
|
1748
|
+
ok: true,
|
|
1749
|
+
message: `Entry added.${this.storageHint(target, total)}`,
|
|
1750
|
+
entries: next,
|
|
1751
|
+
chars: total,
|
|
1752
|
+
limit: this.limitFor(target)
|
|
1753
|
+
},
|
|
1754
|
+
write: render(next)
|
|
641
1755
|
};
|
|
642
1756
|
}
|
|
1757
|
+
/** Canonical-form drift check derived from the locked view (same formula as `detectDrift`, no second read). */
|
|
1758
|
+
driftFromRaw(target, raw) {
|
|
1759
|
+
if (raw.trim() === "") return false;
|
|
1760
|
+
const entries = normalizeEntries(raw);
|
|
1761
|
+
const limit = this.limitFor(target);
|
|
1762
|
+
if (limit > 0 && entries.some((entry) => entry.length > limit)) return true;
|
|
1763
|
+
return render(entries) !== raw;
|
|
1764
|
+
}
|
|
643
1765
|
async applyBatch(target, operations) {
|
|
644
1766
|
if (operations.length === 0) return {
|
|
645
1767
|
ok: false,
|
|
@@ -648,261 +1770,402 @@ var MemoryStore = class {
|
|
|
648
1770
|
chars: 0,
|
|
649
1771
|
limit: this.limitFor(target)
|
|
650
1772
|
};
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
1773
|
+
const path = fileFor(this.root, target);
|
|
1774
|
+
const refusal = await this.oversizedRefusal(target);
|
|
1775
|
+
if (refusal) return refusal;
|
|
1776
|
+
let outcome;
|
|
1777
|
+
await transactIo(this.io, path, async (current) => {
|
|
1778
|
+
const core = await this.applyBatchCore(target, operations, current ?? "");
|
|
1779
|
+
outcome = core.result;
|
|
1780
|
+
return core.write ?? current ?? null;
|
|
1781
|
+
});
|
|
1782
|
+
return outcome;
|
|
1783
|
+
}
|
|
1784
|
+
/** Batch RMW inside the transaction. `write: null` = failure/no-op, disk untouched. */
|
|
1785
|
+
async applyBatchCore(target, operations, raw) {
|
|
1786
|
+
if (this.driftFromRaw(target, raw)) {
|
|
1787
|
+
const backup = await this.backupFile(target);
|
|
1788
|
+
return {
|
|
1789
|
+
result: {
|
|
1790
|
+
ok: false,
|
|
1791
|
+
message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
|
|
1792
|
+
entries: [],
|
|
1793
|
+
chars: 0,
|
|
1794
|
+
limit: this.limitFor(target)
|
|
1795
|
+
},
|
|
1796
|
+
write: null
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
const entries = [...new Set(normalizeEntries(raw))];
|
|
659
1800
|
const working = [...entries];
|
|
660
1801
|
for (const [index, op] of operations.entries()) {
|
|
661
1802
|
const position = index + 1;
|
|
662
1803
|
if (op.action === "add") {
|
|
663
1804
|
const body = (op.facts ?? "").trim();
|
|
664
1805
|
if (!body) return {
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
1806
|
+
result: {
|
|
1807
|
+
ok: false,
|
|
1808
|
+
message: `Operation ${position} (add): facts is required. No operations were applied.${previewEntries(entries)}`,
|
|
1809
|
+
entries,
|
|
1810
|
+
chars: entries.join(ENTRY_DELIMITER).length,
|
|
1811
|
+
limit: this.limitFor(target)
|
|
1812
|
+
},
|
|
1813
|
+
write: null
|
|
670
1814
|
};
|
|
671
1815
|
const threat = scanMemoryThreats(body);
|
|
672
1816
|
if (threat) return {
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
1817
|
+
result: {
|
|
1818
|
+
ok: false,
|
|
1819
|
+
message: `Operation ${position}: ${threat}`,
|
|
1820
|
+
entries,
|
|
1821
|
+
chars: entries.join(ENTRY_DELIMITER).length,
|
|
1822
|
+
limit: this.limitFor(target)
|
|
1823
|
+
},
|
|
1824
|
+
write: null
|
|
678
1825
|
};
|
|
679
1826
|
if (!working.some((entry) => stripDatePrefix(entry) === body)) working.push(this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n${body}` : body);
|
|
680
1827
|
continue;
|
|
681
1828
|
}
|
|
682
1829
|
const needle = (op.old_text ?? "").trim();
|
|
683
1830
|
if (!needle) return {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
1831
|
+
result: {
|
|
1832
|
+
ok: false,
|
|
1833
|
+
message: `Operation ${position} (${op.action}): old_text is required. No operations were applied.${previewEntries(entries)}`,
|
|
1834
|
+
entries,
|
|
1835
|
+
chars: entries.join(ENTRY_DELIMITER).length,
|
|
1836
|
+
limit: this.limitFor(target)
|
|
1837
|
+
},
|
|
1838
|
+
write: null
|
|
689
1839
|
};
|
|
690
1840
|
const matches = working.map((entry, matchIndex) => ({
|
|
691
1841
|
entry,
|
|
692
1842
|
matchIndex
|
|
693
1843
|
})).filter(({ entry }) => entry.includes(needle));
|
|
694
|
-
if (matches.length === 0) return
|
|
1844
|
+
if (matches.length === 0) return {
|
|
1845
|
+
result: this.failure(target, `Operation ${position}: no entry matching "${needle}" found. No operations were applied.`, entries),
|
|
1846
|
+
write: null
|
|
1847
|
+
};
|
|
695
1848
|
if (new Set(matches.map((m) => m.entry)).size > 1) return {
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
1849
|
+
result: {
|
|
1850
|
+
ok: false,
|
|
1851
|
+
message: `Operation ${position}: "${needle}" matched multiple distinct entries. No operations were applied.${previewEntries(entries)}`,
|
|
1852
|
+
entries,
|
|
1853
|
+
chars: entries.join(ENTRY_DELIMITER).length,
|
|
1854
|
+
limit: this.limitFor(target)
|
|
1855
|
+
},
|
|
1856
|
+
write: null
|
|
701
1857
|
};
|
|
702
1858
|
const matchIndex = matches[0]?.matchIndex ?? -1;
|
|
703
1859
|
if (op.action === "remove") working.splice(matchIndex, 1);
|
|
704
1860
|
else {
|
|
705
1861
|
const body = (op.facts ?? "").trim();
|
|
706
1862
|
if (!body) return {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
1863
|
+
result: {
|
|
1864
|
+
ok: false,
|
|
1865
|
+
message: `Operation ${position} (replace): facts is required.${previewEntries(entries)}`,
|
|
1866
|
+
entries,
|
|
1867
|
+
chars: entries.join(ENTRY_DELIMITER).length,
|
|
1868
|
+
limit: this.limitFor(target)
|
|
1869
|
+
},
|
|
1870
|
+
write: null
|
|
712
1871
|
};
|
|
713
1872
|
const threat = scanMemoryThreats(body);
|
|
714
1873
|
if (threat) return {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
1874
|
+
result: {
|
|
1875
|
+
ok: false,
|
|
1876
|
+
message: `Operation ${position}: ${threat}`,
|
|
1877
|
+
entries,
|
|
1878
|
+
chars: entries.join(ENTRY_DELIMITER).length,
|
|
1879
|
+
limit: this.limitFor(target)
|
|
1880
|
+
},
|
|
1881
|
+
write: null
|
|
720
1882
|
};
|
|
721
1883
|
working[matchIndex] = body;
|
|
722
1884
|
}
|
|
723
1885
|
}
|
|
724
1886
|
const total = working.join(ENTRY_DELIMITER).length;
|
|
725
|
-
|
|
726
|
-
|
|
1887
|
+
const batchLimit = this.limitFor(target);
|
|
1888
|
+
if (batchLimit > 0 && total > batchLimit) return {
|
|
1889
|
+
result: this.failure(target, `Batch result (${total} chars) exceeds the ${batchLimit} limit. Remove or shorten more entries in the same batch.`, entries),
|
|
1890
|
+
write: null
|
|
1891
|
+
};
|
|
727
1892
|
this.resetFailures();
|
|
728
1893
|
return {
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
1894
|
+
result: {
|
|
1895
|
+
ok: true,
|
|
1896
|
+
message: `Applied ${operations.length} operation(s).${this.storageHint(target, total)}`,
|
|
1897
|
+
entries: working,
|
|
1898
|
+
chars: total,
|
|
1899
|
+
limit: this.limitFor(target)
|
|
1900
|
+
},
|
|
1901
|
+
write: render(working)
|
|
734
1902
|
};
|
|
735
1903
|
}
|
|
736
1904
|
async renderContext() {
|
|
737
1905
|
const memory = await this.read("memory");
|
|
738
1906
|
const user = await this.read("user");
|
|
739
1907
|
const parts = [];
|
|
740
|
-
for (const [target, entries] of [[
|
|
1908
|
+
for (const [target, label, entries] of [[
|
|
1909
|
+
"memory",
|
|
1910
|
+
"Memory",
|
|
1911
|
+
memory
|
|
1912
|
+
], [
|
|
1913
|
+
"user",
|
|
1914
|
+
"User Profile",
|
|
1915
|
+
user
|
|
1916
|
+
]]) {
|
|
1917
|
+
const oversized = entries.length === 0 ? await this.oversizedFile(target) : null;
|
|
1918
|
+
if (oversized) {
|
|
1919
|
+
parts.push(`## ${label} — file skipped: ${oversized.size} bytes (limit ${oversized.limit * READ_GUARD_FACTOR}); not read`);
|
|
1920
|
+
continue;
|
|
1921
|
+
}
|
|
741
1922
|
const safe = entries.filter((entry) => !scanMemoryThreats(entry));
|
|
742
1923
|
if (safe.length > 0) {
|
|
743
1924
|
const body = safe.join(ENTRY_DELIMITER);
|
|
1925
|
+
const limit = this.limitFor(target);
|
|
1926
|
+
const pct = limit > 0 ? Math.min(100, Math.floor(body.length * 100 / limit)) : 0;
|
|
744
1927
|
const note = safe.length === entries.length ? "" : ` (${entries.length - safe.length} threat-matched entries filtered)`;
|
|
745
|
-
parts.push(`## ${
|
|
1928
|
+
parts.push(`## ${label} (${safe.length} entries) [${pct}% — ${body.length}/${limit} chars]${note}\n${body}`);
|
|
746
1929
|
}
|
|
747
1930
|
}
|
|
748
1931
|
return parts.join("\n\n");
|
|
749
1932
|
}
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
1933
|
+
/**
|
|
1934
|
+
* Detect on-disk drift: true when the file is not in the canonical
|
|
1935
|
+
* `render(normalizeEntries(raw))` form. This catches structural anomalies
|
|
1936
|
+
* the writer would quietly normalize away (empty/`§`-only entries, stray
|
|
1937
|
+
* blank lines, leading/trailing delimiters) that indicate the file was
|
|
1938
|
+
* edited outside MemoryStore. Purely single-canonical content reaches the
|
|
1939
|
+
* same serialization and returns false, so a normal write is never flagged.
|
|
1940
|
+
*
|
|
1941
|
+
* An absent, empty, or whitespace-only file is the "never written" state
|
|
1942
|
+
* (rc.42 audit P1-6): it parses to zero entries, so the canonical form
|
|
1943
|
+
* `'\n'` can never byte-match it and every write path was permanently
|
|
1944
|
+
* refused with "External drift detected" — including the repairs the model
|
|
1945
|
+
* would need to make. Such files are adopted instead of flagged.
|
|
1946
|
+
*/
|
|
761
1947
|
async detectDrift(target) {
|
|
1948
|
+
if (await this.oversizedFile(target)) return true;
|
|
762
1949
|
const raw = await this.io.readText(fileFor(this.root, target));
|
|
763
|
-
if (raw === null) return false;
|
|
764
|
-
|
|
1950
|
+
if (raw === null || raw.trim() === "") return false;
|
|
1951
|
+
const entries = normalizeEntries(raw);
|
|
1952
|
+
const limit = this.limitFor(target);
|
|
1953
|
+
if (limit > 0 && entries.some((entry) => entry.length > limit)) return true;
|
|
1954
|
+
return render(entries) !== raw;
|
|
765
1955
|
}
|
|
766
1956
|
};
|
|
767
1957
|
//#endregion
|
|
768
|
-
//#region lib/types/
|
|
1958
|
+
//#region lib/types/mutations.js
|
|
769
1959
|
/**
|
|
770
|
-
*
|
|
771
|
-
*
|
|
772
|
-
*
|
|
773
|
-
*
|
|
774
|
-
*
|
|
775
|
-
* Every prompt is pinned in a versioned bundle. Review workers verify the
|
|
776
|
-
* bundle digest before spending a model call, so a partially-patched
|
|
777
|
-
* deployment fails closed instead of silently running a truncated prompt.
|
|
1960
|
+
* Curator/author audit trail: `.mutations.json` records every skill mutation
|
|
1961
|
+
* with before/after content hashes so any automated edit is reviewable and
|
|
1962
|
+
* replayable. Best-effort persistence, mirroring the usage sidecar posture.
|
|
1963
|
+
* @module @lmzhen/dsh-evolution-core
|
|
778
1964
|
*/
|
|
779
|
-
const
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
const
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
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.`;
|
|
824
|
-
const CURATOR_PROMPT = `You are the skill curator. Maintain a healthy, class-level skill library.
|
|
825
|
-
|
|
826
|
-
Rules:
|
|
827
|
-
1. NEVER hard-delete a skill. Archive is the maximum destructive action.
|
|
828
|
-
2. Do not touch bundled, hub-installed, or pinned skills.
|
|
829
|
-
3. Do not archive recently-created or never-used skills without strong evidence.
|
|
830
|
-
4. Prefer merging narrow skills into class-level umbrellas.
|
|
831
|
-
5. Before archiving a merged skill, ensure its unique content was preserved.
|
|
832
|
-
|
|
833
|
-
Produce a YAML summary:
|
|
834
|
-
consolidations:
|
|
835
|
-
- from: <old-skill-name>
|
|
836
|
-
into: <umbrella-skill-name>
|
|
837
|
-
reason: <one short sentence>
|
|
838
|
-
prunings:
|
|
839
|
-
- name: <skill-name>
|
|
840
|
-
reason: <one short sentence>`;
|
|
841
|
-
function reviewPrompt(kind) {
|
|
842
|
-
if (kind === "memory") return MEMORY_REVIEW_PROMPT;
|
|
843
|
-
if (kind === "skill") return SKILL_REVIEW_PROMPT;
|
|
844
|
-
return COMBINED_REVIEW_PROMPT;
|
|
1965
|
+
const DEFAULT_MUTATION_CAP = 500;
|
|
1966
|
+
/** Version of the `.mutations.json` file shape; writers always emit the current one. */
|
|
1967
|
+
const MUTATIONS_FILE_VERSION = 1;
|
|
1968
|
+
function mutationsFile(root) {
|
|
1969
|
+
return join(root, ".mutations.json");
|
|
1970
|
+
}
|
|
1971
|
+
function contentHash(content) {
|
|
1972
|
+
return createHash("sha256").update(content).digest("hex");
|
|
1973
|
+
}
|
|
1974
|
+
/**
|
|
1975
|
+
* Parse a raw mutations sidecar; malformed content reads as empty (auditing is
|
|
1976
|
+
* best-effort). Versioned shape ({ version, records }) with legacy
|
|
1977
|
+
* plain-array compat, plus a field-level guard for records without the
|
|
1978
|
+
* required identity/timestamp fields (rc.42 audit P2-3).
|
|
1979
|
+
*/
|
|
1980
|
+
function parseMutationRecords(raw) {
|
|
1981
|
+
if (raw === null) return [];
|
|
1982
|
+
try {
|
|
1983
|
+
const parsed = JSON.parse(raw);
|
|
1984
|
+
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");
|
|
1985
|
+
} catch {
|
|
1986
|
+
return [];
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
async function loadMutations(root, io = nodeEvolutionIo()) {
|
|
1990
|
+
return parseMutationRecords(await io.readText(mutationsFile(root)));
|
|
1991
|
+
}
|
|
1992
|
+
/** Append one record, trim to `cap`, and write atomically (versioned shape). */
|
|
1993
|
+
async function recordMutation(root, io, record, cap = 500) {
|
|
1994
|
+
await transactIo(io, mutationsFile(root), async (current) => {
|
|
1995
|
+
if (current !== null) try {
|
|
1996
|
+
JSON.parse(current);
|
|
1997
|
+
} catch {
|
|
1998
|
+
return current;
|
|
1999
|
+
}
|
|
2000
|
+
const existing = parseMutationRecords(current);
|
|
2001
|
+
existing.push(record);
|
|
2002
|
+
const trimmed = existing.length > cap ? existing.slice(existing.length - cap) : existing;
|
|
2003
|
+
return Promise.resolve(JSON.stringify({
|
|
2004
|
+
version: 1,
|
|
2005
|
+
records: trimmed
|
|
2006
|
+
}, null, 2));
|
|
2007
|
+
});
|
|
845
2008
|
}
|
|
846
|
-
|
|
847
|
-
|
|
2009
|
+
//#endregion
|
|
2010
|
+
//#region lib/types/quality.js
|
|
2011
|
+
/**
|
|
2012
|
+
* Quality scoring and near-duplicate detection for the curated skill library.
|
|
2013
|
+
*
|
|
2014
|
+
* Pure functions over data inputs so the scoring policy is unit-testable and
|
|
2015
|
+
* the same math feeds the usage sidecar, the `skill_manage review` surface and
|
|
2016
|
+
* the learning graph. Weights follow the Hermes/hermes-claw six-factor model;
|
|
2017
|
+
* mutation maturity is a documented DSH approximation (single per-month patch
|
|
2018
|
+
* trend ratio replaces the claw timestamp-trend formula, since DSH usage
|
|
2019
|
+
* records only carry the last patched timestamp).
|
|
2020
|
+
* @module @lmzhen/dsh-evolution-core
|
|
2021
|
+
*/
|
|
2022
|
+
const QUALITY_WEIGHTS = {
|
|
2023
|
+
usageFrequency: .25,
|
|
2024
|
+
stability: .2,
|
|
2025
|
+
recency: .2,
|
|
2026
|
+
references: .1,
|
|
2027
|
+
mutationMaturity: .2,
|
|
2028
|
+
richness: .05
|
|
2029
|
+
};
|
|
2030
|
+
/** Score below which a skill is flagged for review. */
|
|
2031
|
+
const LOW_QUALITY_THRESHOLD = .3;
|
|
2032
|
+
function clamp01(value) {
|
|
2033
|
+
return Math.max(0, Math.min(1, value));
|
|
848
2034
|
}
|
|
849
|
-
function
|
|
850
|
-
|
|
851
|
-
id: PROMPT_BUNDLE_ID,
|
|
852
|
-
version: 1,
|
|
853
|
-
prompts: Object.fromEntries(Object.entries(prompts).sort())
|
|
854
|
-
});
|
|
855
|
-
return Object.freeze({
|
|
856
|
-
id: PROMPT_BUNDLE_ID,
|
|
857
|
-
version: 1,
|
|
858
|
-
prompts: Object.freeze({ ...prompts }),
|
|
859
|
-
sha256: sha256(canonical)
|
|
860
|
-
});
|
|
2035
|
+
function daysBetween(from, now) {
|
|
2036
|
+
return Math.max(0, (now.getTime() - new Date(from).getTime()) / 864e5);
|
|
861
2037
|
}
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
2038
|
+
function computeQualityScores(input) {
|
|
2039
|
+
const now = input.now ?? /* @__PURE__ */ new Date();
|
|
2040
|
+
const scores = /* @__PURE__ */ new Map();
|
|
2041
|
+
for (const [name, record] of input.usage) {
|
|
2042
|
+
const ageDays = Math.max(1, daysBetween(record.created_at, now));
|
|
2043
|
+
const idleDays = daysBetween(latestActivityAt(record) ?? record.created_at, now);
|
|
2044
|
+
const patchCount = record.patch_count;
|
|
2045
|
+
const useCount = record.use_count;
|
|
2046
|
+
const usageFrequency = clamp01(useCount / ageDays);
|
|
2047
|
+
const stability = useCount === 0 ? 1 : clamp01(1 - patchCount / useCount);
|
|
2048
|
+
const recency = idleDays < 30 ? 1 : clamp01(1 - (idleDays - 30) / 150);
|
|
2049
|
+
const references = clamp01((input.referenceCounts?.get(name) ?? 0) / 3);
|
|
2050
|
+
const mutationMaturity = patchCount === 0 ? .3 : patchCount === 1 ? .4 : clamp01((patchCount - 1) / Math.max(1, ageDays / 30));
|
|
2051
|
+
const richness = clamp01((input.supportDirs?.get(name) ?? 0) * .175);
|
|
2052
|
+
const factors = {
|
|
2053
|
+
usageFrequency,
|
|
2054
|
+
stability,
|
|
2055
|
+
recency,
|
|
2056
|
+
references,
|
|
2057
|
+
mutationMaturity,
|
|
2058
|
+
richness
|
|
2059
|
+
};
|
|
2060
|
+
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;
|
|
2061
|
+
scores.set(name, {
|
|
2062
|
+
score,
|
|
2063
|
+
factors,
|
|
2064
|
+
warn: score < LOW_QUALITY_THRESHOLD
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2067
|
+
return scores;
|
|
2068
|
+
}
|
|
2069
|
+
function normalize(content) {
|
|
2070
|
+
return content.toLowerCase().replace(/\s+/g, " ").trim();
|
|
2071
|
+
}
|
|
2072
|
+
function contentHash$1(content) {
|
|
2073
|
+
return createHash("sha256").update(normalize(content)).digest("hex");
|
|
2074
|
+
}
|
|
2075
|
+
function tokenize(content) {
|
|
2076
|
+
return new Set(normalize(content).split(/[^a-z0-9\u4e00-\u9fff]+/).filter(Boolean));
|
|
2077
|
+
}
|
|
2078
|
+
function jaccard(a, b) {
|
|
2079
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
2080
|
+
let intersection = 0;
|
|
2081
|
+
for (const token of a) if (b.has(token)) intersection += 1;
|
|
2082
|
+
return intersection / (a.size + b.size - intersection);
|
|
2083
|
+
}
|
|
2084
|
+
/**
|
|
2085
|
+
* Two-phase near-duplicate clustering: exact normalized-hash groups first,
|
|
2086
|
+
* then token-Jaccard edges at {@link DEDUP_SIMILARITY_THRESHOLD} with a token
|
|
2087
|
+
* ratio guard, union-find across the whole set.
|
|
2088
|
+
*/
|
|
2089
|
+
function computeDedupGroups(input) {
|
|
2090
|
+
const threshold = input.threshold ?? .95;
|
|
2091
|
+
const names = [...input.contents.keys()];
|
|
2092
|
+
const hashes = /* @__PURE__ */ new Map();
|
|
2093
|
+
for (const name of names) {
|
|
2094
|
+
const hash = contentHash$1(input.contents.get(name) ?? "");
|
|
2095
|
+
const bucket = hashes.get(hash);
|
|
2096
|
+
if (bucket) bucket.push(name);
|
|
2097
|
+
else hashes.set(hash, [name]);
|
|
2098
|
+
}
|
|
2099
|
+
const parent = /* @__PURE__ */ new Map();
|
|
2100
|
+
const find = (x) => {
|
|
2101
|
+
const root = parent.get(x) ?? x;
|
|
2102
|
+
if (root !== x) parent.set(x, find(root));
|
|
2103
|
+
return parent.get(x) ?? x;
|
|
2104
|
+
};
|
|
2105
|
+
const union = (a, b) => {
|
|
2106
|
+
const [ra, rb] = [find(a), find(b)];
|
|
2107
|
+
if (ra !== rb) parent.set(rb, ra);
|
|
2108
|
+
};
|
|
2109
|
+
for (const [hash, bucketNames] of hashes) {
|
|
2110
|
+
const first = bucketNames[0];
|
|
2111
|
+
if (first === void 0 || bucketNames.length === 1) continue;
|
|
2112
|
+
for (let index = 1; index < bucketNames.length; index += 1) {
|
|
2113
|
+
const peer = bucketNames[index];
|
|
2114
|
+
if (peer) union(first, peer);
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
const tokens = /* @__PURE__ */ new Map();
|
|
2118
|
+
const tokenSet = (name) => {
|
|
2119
|
+
let set = tokens.get(name);
|
|
2120
|
+
if (!set) {
|
|
2121
|
+
set = tokenize(input.contents.get(name) ?? "");
|
|
2122
|
+
tokens.set(name, set);
|
|
2123
|
+
}
|
|
2124
|
+
return set;
|
|
2125
|
+
};
|
|
2126
|
+
for (let index = 0; index < names.length; index += 1) {
|
|
2127
|
+
const a = names[index];
|
|
2128
|
+
if (a === void 0) continue;
|
|
2129
|
+
for (let other = index + 1; other < names.length; other += 1) {
|
|
2130
|
+
const b = names[other];
|
|
2131
|
+
if (b === void 0) continue;
|
|
2132
|
+
const [ta, tb] = [tokenSet(a), tokenSet(b)];
|
|
2133
|
+
if (Math.max(ta.size, tb.size) / Math.max(1, Math.min(ta.size, tb.size)) > 5) continue;
|
|
2134
|
+
if (jaccard(ta, tb) >= threshold) union(a, b);
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2138
|
+
for (const name of names) {
|
|
2139
|
+
const root = find(name);
|
|
2140
|
+
const group = groups.get(root);
|
|
2141
|
+
if (group) group.push(name);
|
|
2142
|
+
else groups.set(root, [name]);
|
|
2143
|
+
}
|
|
2144
|
+
return [...groups.values()].filter((group) => group.length > 1);
|
|
2145
|
+
}
|
|
2146
|
+
/**
|
|
2147
|
+
* Prefix-cluster index over a name set (rc.67 merge heuristic, input side):
|
|
2148
|
+
* the curator prompt asks the model to identify "prefix clusters — skills
|
|
2149
|
+
* sharing a first word or domain keyword"; the deterministic index supplies
|
|
2150
|
+
* stable ground truth instead of letting the model infer clusters from the
|
|
2151
|
+
* raw list. Key = first alphanumeric run of the lowercased name; groups with
|
|
2152
|
+
* at least two members, largest first then alphabetical. Orientation-only:
|
|
2153
|
+
* nomination authority stays with the LLM and the candidate-pool gates.
|
|
2154
|
+
*/
|
|
2155
|
+
function computePrefixClusters(names) {
|
|
2156
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2157
|
+
for (const name of names) {
|
|
2158
|
+
const key = name.toLowerCase().split(/[^a-z0-9]+/)[0];
|
|
2159
|
+
if (!key) continue;
|
|
2160
|
+
const bucket = groups.get(key);
|
|
2161
|
+
if (bucket) bucket.push(name);
|
|
2162
|
+
else groups.set(key, [name]);
|
|
2163
|
+
}
|
|
2164
|
+
return [...groups.entries()].filter(([, members]) => members.length >= 2).map(([key, members]) => ({
|
|
2165
|
+
key,
|
|
2166
|
+
members
|
|
2167
|
+
})).sort((a, b) => b.members.length - a.members.length || a.key.localeCompare(b.key));
|
|
875
2168
|
}
|
|
876
|
-
const DSH_AUTHORING_STANDARDS = `Follow the Hermes skill-authoring standards, translated to DSH tools.
|
|
877
|
-
|
|
878
|
-
Frontmatter:
|
|
879
|
-
- name: lowercase-hyphenated, <=64 chars, no spaces.
|
|
880
|
-
- 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.
|
|
881
|
-
- version: 0.1.0
|
|
882
|
-
- author: always the literal value "Hermes". NEVER fill it from the environment, git config, or any identity you can probe.
|
|
883
|
-
- platforms: declare [macos], [linux], and/or [windows] only when the skill is genuinely OS-bound; omit for portable skills.
|
|
884
|
-
- metadata.hermes.tags: a few Capitalized, Relevant, Tags.
|
|
885
|
-
|
|
886
|
-
Body section order (omit only when empty):
|
|
887
|
-
1. "# <Human Title>" then a 2-3 sentence intro: what it does, what it does NOT do, key dependency stance.
|
|
888
|
-
2. "## When to Use" — concrete trigger phrases.
|
|
889
|
-
3. "## Prerequisites" — exact env vars, install steps, credentials.
|
|
890
|
-
4. "## How to Run" — canonical invocation framed through DSH tools.
|
|
891
|
-
5. "## Quick Reference" — flat command/endpoint list.
|
|
892
|
-
6. "## Procedure" — numbered steps with copy-paste-exact commands.
|
|
893
|
-
7. "## Pitfalls" — known limits and rate limits.
|
|
894
|
-
8. "## Verification" — one check proving the skill worked.
|
|
895
|
-
|
|
896
|
-
DSH-tool framing:
|
|
897
|
-
- Reference DSH tools by name in backticks: \`bash\`, \`str_replace_editor\`, \`write\`, \`skill\`, \`skill_manage\`, \`memory\`.
|
|
898
|
-
- Do not name wrapped shell utilities when a DSH tool already covers them.
|
|
899
|
-
- Larger scripts belong under \`scripts/\` (written with \`skill_manage write_file\`) and are referenced from SKILL.md by relative path.
|
|
900
|
-
|
|
901
|
-
Quality bar:
|
|
902
|
-
- Prefer verbatim flags, paths, and APIs from the source. Never invent them.
|
|
903
|
-
- Keep it tight: ~100 lines simple, ~200 complex.
|
|
904
|
-
- No router/index/hub skills that only point at other skills.
|
|
905
|
-
- References go in \`references/\`, templates in \`templates/\`.`;
|
|
906
2169
|
//#endregion
|
|
907
2170
|
//#region lib/types/signals.js
|
|
908
2171
|
/**
|
|
@@ -990,31 +2253,53 @@ function foldTurn(session, fromSeq) {
|
|
|
990
2253
|
* it created unless a `.hermes-managed` marker opts a skill in. Archival is a
|
|
991
2254
|
* move to `.archive/` — never a hard delete.
|
|
992
2255
|
*/
|
|
993
|
-
const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
994
|
-
const MAX_SKILL_NAME_LENGTH = 64;
|
|
995
|
-
const MAX_DESCRIPTION_LENGTH = 1024;
|
|
996
|
-
const MAX_SKILL_CONTENT_CHARS = 1e5;
|
|
997
|
-
const MAX_SKILL_FILE_BYTES = 1048576;
|
|
998
2256
|
const DEFAULT_SKILL_LIMITS = {
|
|
999
2257
|
maxNameLength: 64,
|
|
1000
2258
|
maxDescriptionLength: MAX_DESCRIPTION_LENGTH,
|
|
1001
2259
|
maxSkillContentChars: MAX_SKILL_CONTENT_CHARS,
|
|
1002
2260
|
maxSkillFileBytes: MAX_SKILL_FILE_BYTES
|
|
1003
2261
|
};
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
"templates",
|
|
1007
|
-
"scripts",
|
|
1008
|
-
"assets"
|
|
1009
|
-
];
|
|
2262
|
+
/** Extra file name carried inside a snapshot's `extras/` directory. */
|
|
2263
|
+
const SNAPSHOT_EXTRA_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
1010
2264
|
function skillsRoot(env = process.env) {
|
|
1011
2265
|
return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "skills");
|
|
1012
2266
|
}
|
|
2267
|
+
/**
|
|
2268
|
+
* Map a requesting session onto the two origin surfaces (rc.44 plan M2-2.3):
|
|
2269
|
+
* the APPROVAL surface treats every delegated subagent as the autonomous
|
|
2270
|
+
* review channel, while the LIBRARY surface keeps the Hermes distinction -
|
|
2271
|
+
* the review fork is 'background_review' (the pinned guard blocks its
|
|
2272
|
+
* writes) and any other subagent is 'subagent' (agent-authored, not
|
|
2273
|
+
* review-channel). `isReview` marks the caller as the background review
|
|
2274
|
+
* pipeline itself. Single source: the two tools and the review executor all
|
|
2275
|
+
* read this table instead of re-deriving it.
|
|
2276
|
+
*/
|
|
2277
|
+
function resolveOrigins(headerOrigin, isReview = false) {
|
|
2278
|
+
if (isReview) return {
|
|
2279
|
+
approval: "background_review",
|
|
2280
|
+
library: "background_review"
|
|
2281
|
+
};
|
|
2282
|
+
if (headerOrigin === "subagent") return {
|
|
2283
|
+
approval: "background_review",
|
|
2284
|
+
library: "subagent"
|
|
2285
|
+
};
|
|
2286
|
+
return {
|
|
2287
|
+
approval: "foreground",
|
|
2288
|
+
library: "foreground"
|
|
2289
|
+
};
|
|
2290
|
+
}
|
|
1013
2291
|
function skillDir(root, name) {
|
|
1014
2292
|
return join(root, name);
|
|
1015
2293
|
}
|
|
2294
|
+
/** Dot-prefixed on-disk marker name. SINGLE source: `list()` matches directory
|
|
2295
|
+
* entries against this name, and path builders must never hardcode a marker
|
|
2296
|
+
* literal (N-1: the rc.49 exists()-probe convergence dropped the dot,
|
|
2297
|
+
* poisoning every protectedBy/managed report). */
|
|
2298
|
+
function markerEntryName(marker) {
|
|
2299
|
+
return `.${marker}`;
|
|
2300
|
+
}
|
|
1016
2301
|
function markerPath(dir, marker) {
|
|
1017
|
-
return join(dir,
|
|
2302
|
+
return join(dir, markerEntryName(marker));
|
|
1018
2303
|
}
|
|
1019
2304
|
function parseFrontmatter(content) {
|
|
1020
2305
|
if (!content.trimStart().startsWith("---")) return null;
|
|
@@ -1036,6 +2321,26 @@ function parseFrontmatter(content) {
|
|
|
1036
2321
|
body
|
|
1037
2322
|
};
|
|
1038
2323
|
}
|
|
2324
|
+
/**
|
|
2325
|
+
* Skill names referenced by a SKILL.md's `related_skills` frontmatter
|
|
2326
|
+
* (B-line G3, rc.44): the single parsing source for the quality references
|
|
2327
|
+
* factor and the learning-graph edges. The DSH frontmatter parser keeps the
|
|
2328
|
+
* YAML value as a string (`"[a, b]"`), so names are scanned out of it; each
|
|
2329
|
+
* must satisfy the skill-name shape and the referencing skill itself is
|
|
2330
|
+
* excluded. Pure and deduplicated.
|
|
2331
|
+
*/
|
|
2332
|
+
function relatedSkillNames(content, exclude) {
|
|
2333
|
+
const parsed = parseFrontmatter(content);
|
|
2334
|
+
if (!parsed) return [];
|
|
2335
|
+
const raw = parsed.frontmatter["related_skills"];
|
|
2336
|
+
if (typeof raw !== "string") return [];
|
|
2337
|
+
const names = /* @__PURE__ */ new Set();
|
|
2338
|
+
for (const match of Array.from(raw.matchAll(/[a-z0-9][a-z0-9-]*/g))) {
|
|
2339
|
+
const target = match[0];
|
|
2340
|
+
if (target && SKILL_NAME_RE.test(target) && target !== exclude) names.add(target);
|
|
2341
|
+
}
|
|
2342
|
+
return [...names];
|
|
2343
|
+
}
|
|
1039
2344
|
function validateFrontmatter(content, expectedName, limits = DEFAULT_SKILL_LIMITS) {
|
|
1040
2345
|
const parsed = parseFrontmatter(content);
|
|
1041
2346
|
if (!parsed) return "SKILL.md must start and end with YAML frontmatter and include a body.";
|
|
@@ -1048,6 +2353,31 @@ function validateFrontmatter(content, expectedName, limits = DEFAULT_SKILL_LIMIT
|
|
|
1048
2353
|
if (content.length > limits.maxSkillContentChars) return `SKILL.md content exceeds ${limits.maxSkillContentChars} characters.`;
|
|
1049
2354
|
return null;
|
|
1050
2355
|
}
|
|
2356
|
+
/** Hermes authoring quality bar for descriptions (the 60-char Rule). The
|
|
2357
|
+
* platform's own index limit stays in `validateFrontmatter`; this bar is the
|
|
2358
|
+
* target the authoring standard names, enforced as ADVISORY feedback. */
|
|
2359
|
+
const AUTHORING_DESCRIPTION_BAR = 60;
|
|
2360
|
+
/**
|
|
2361
|
+
* Advisory authoring feedback (P0): evaluate frontmatter against the
|
|
2362
|
+
* authoring bar WITHOUT changing platform validation semantics. The bar is
|
|
2363
|
+
* the quality target, `validateFrontmatter`'s limits are the compatibility
|
|
2364
|
+
* floor, and this bridge layer tells the model when its text would be
|
|
2365
|
+
* truncated or route-poor instead of silently shipping it.
|
|
2366
|
+
*/
|
|
2367
|
+
function authoringFeedback(frontmatter) {
|
|
2368
|
+
const description = frontmatter.description ?? "";
|
|
2369
|
+
const over60 = description.length > 60;
|
|
2370
|
+
const hasColon = description.includes(":");
|
|
2371
|
+
const lines = [];
|
|
2372
|
+
lines.push(over60 ? `Description is ${description.length}/60 characters — exceeds the 60-char authoring bar (Hermes standard; the catalog truncates at the configured platform cap).` : `Description ${description.length}/60 characters — within the authoring bar.`);
|
|
2373
|
+
if (hasColon) lines.push("Description contains a colon — wrap the whole value in double quotes.");
|
|
2374
|
+
return {
|
|
2375
|
+
descriptionChars: description.length,
|
|
2376
|
+
over60,
|
|
2377
|
+
hasColon,
|
|
2378
|
+
lines
|
|
2379
|
+
};
|
|
2380
|
+
}
|
|
1051
2381
|
async function listNames(root, io) {
|
|
1052
2382
|
const entries = await io.list(root);
|
|
1053
2383
|
const names = [];
|
|
@@ -1065,65 +2395,274 @@ function validateSupportPath(filePath) {
|
|
|
1065
2395
|
if (parts.length < 2) return "Provide a file name, not just a directory.";
|
|
1066
2396
|
return null;
|
|
1067
2397
|
}
|
|
2398
|
+
/**
|
|
2399
|
+
* Index of `pattern` inside `content`, treating whitespace runs (spaces/tabs)
|
|
2400
|
+
* as flexible and literal escape sequences (`\n`, `\t`, `\r`) as their real
|
|
2401
|
+
* characters: a PATTERN whitespace run matches any content run of any length
|
|
2402
|
+
* (even empty), while extra whitespace that only exists in the content is not
|
|
2403
|
+
* skipped — the flexibility is one-sided on the pattern, and a backslash-
|
|
2404
|
+
* escaped char in the pattern matches the real char in the content
|
|
2405
|
+
* (model-copy drift). Returns the [start, end) range in the ORIGINAL content
|
|
2406
|
+
* so a patch can replace exactly the matched span and keep every other byte
|
|
2407
|
+
* intact. Returns null when no fuzzy match exists.
|
|
2408
|
+
*/
|
|
2409
|
+
function fuzzyIndexOf(content, pattern, from = 0) {
|
|
2410
|
+
const isSpace = (char) => char !== void 0 && /[ \t]/.test(char);
|
|
2411
|
+
const escaped = (char) => {
|
|
2412
|
+
if (char === "n") return "\n";
|
|
2413
|
+
if (char === "t") return " ";
|
|
2414
|
+
if (char === "r") return "\r";
|
|
2415
|
+
return null;
|
|
2416
|
+
};
|
|
2417
|
+
for (let start = from; start < content.length; start += 1) {
|
|
2418
|
+
let contentIndex = start;
|
|
2419
|
+
let patternIndex = 0;
|
|
2420
|
+
while (patternIndex < pattern.length && contentIndex < content.length) {
|
|
2421
|
+
const patternChar = pattern[patternIndex];
|
|
2422
|
+
const contentChar = content[contentIndex];
|
|
2423
|
+
if (isSpace(patternChar)) {
|
|
2424
|
+
while (patternIndex < pattern.length && isSpace(pattern[patternIndex])) patternIndex += 1;
|
|
2425
|
+
while (contentIndex < content.length && isSpace(content[contentIndex])) contentIndex += 1;
|
|
2426
|
+
continue;
|
|
2427
|
+
}
|
|
2428
|
+
const escapedChar = patternChar === "\\" ? escaped(pattern[patternIndex + 1]) : null;
|
|
2429
|
+
if (escapedChar !== null && contentChar === escapedChar) {
|
|
2430
|
+
patternIndex += 2;
|
|
2431
|
+
contentIndex += 1;
|
|
2432
|
+
continue;
|
|
2433
|
+
}
|
|
2434
|
+
if (patternChar === contentChar) {
|
|
2435
|
+
contentIndex += 1;
|
|
2436
|
+
patternIndex += 1;
|
|
2437
|
+
continue;
|
|
2438
|
+
}
|
|
2439
|
+
break;
|
|
2440
|
+
}
|
|
2441
|
+
if (patternIndex === pattern.length) return [start, contentIndex];
|
|
2442
|
+
}
|
|
2443
|
+
return null;
|
|
2444
|
+
}
|
|
2445
|
+
/** Trim leading whitespace of the first line and trailing whitespace of the last line. */
|
|
2446
|
+
function trimPatternBoundaries(pattern) {
|
|
2447
|
+
const from = pattern.search(/\S/);
|
|
2448
|
+
const trimmed = from < 0 ? pattern : pattern.slice(from);
|
|
2449
|
+
const trailing = trimmed.search(/\s+$/);
|
|
2450
|
+
return trailing < 0 ? trimmed : trimmed.slice(0, trailing);
|
|
2451
|
+
}
|
|
2452
|
+
/** Replace only the fuzzy-matched span, preserving all surrounding bytes. */
|
|
2453
|
+
function fuzzyReplace(content, oldString, newString, replaceAll) {
|
|
2454
|
+
let current = content;
|
|
2455
|
+
let scanFrom = 0;
|
|
2456
|
+
for (;;) {
|
|
2457
|
+
const match = fuzzyIndexOf(current, oldString, scanFrom);
|
|
2458
|
+
if (match === null) return current;
|
|
2459
|
+
const [start, end] = match;
|
|
2460
|
+
const next = current.slice(0, start) + newString + current.slice(end);
|
|
2461
|
+
if (!replaceAll) return next;
|
|
2462
|
+
current = next;
|
|
2463
|
+
scanFrom = start + newString.length;
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
1068
2466
|
function fuzzyPatch(content, oldString, newString, replaceAll = false) {
|
|
2467
|
+
if (oldString === "") return null;
|
|
1069
2468
|
if (content.includes(oldString)) return replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString);
|
|
1070
|
-
const
|
|
1071
|
-
if (
|
|
1072
|
-
|
|
1073
|
-
|
|
2469
|
+
const boundary = trimPatternBoundaries(oldString);
|
|
2470
|
+
if (boundary === "") return null;
|
|
2471
|
+
if (boundary !== oldString) {
|
|
2472
|
+
if (fuzzyIndexOf(content, boundary) !== null) {
|
|
2473
|
+
const patched = fuzzyReplace(content, boundary, newString, replaceAll);
|
|
2474
|
+
return patched === content ? null : patched;
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
if (fuzzyIndexOf(content, oldString) !== null) {
|
|
2478
|
+
const patched = fuzzyReplace(content, oldString, newString, replaceAll);
|
|
2479
|
+
return patched === content ? null : patched;
|
|
2480
|
+
}
|
|
1074
2481
|
return null;
|
|
1075
2482
|
}
|
|
1076
2483
|
var SkillLibrary = class {
|
|
1077
2484
|
root;
|
|
1078
2485
|
limits;
|
|
1079
2486
|
io;
|
|
1080
|
-
|
|
2487
|
+
onMutation;
|
|
2488
|
+
constructor(root = skillsRoot(), io = nodeEvolutionIo(), limits = DEFAULT_SKILL_LIMITS, onMutation) {
|
|
1081
2489
|
this.root = root;
|
|
1082
2490
|
this.io = io;
|
|
1083
2491
|
this.limits = limits;
|
|
2492
|
+
this.onMutation = onMutation;
|
|
2493
|
+
}
|
|
2494
|
+
/** Notify the mutation observer after a successful write; observers must never fail the mutation. */
|
|
2495
|
+
notifyMutation(event) {
|
|
2496
|
+
try {
|
|
2497
|
+
this.onMutation?.(event);
|
|
2498
|
+
} catch {}
|
|
1084
2499
|
}
|
|
1085
2500
|
async list() {
|
|
1086
2501
|
const summaries = [];
|
|
1087
2502
|
for (const name of await listNames(this.root, this.io)) {
|
|
1088
|
-
const dir =
|
|
2503
|
+
const dir = this.dirOf(name);
|
|
1089
2504
|
const md = await this.io.readText(join(dir, "SKILL.md"));
|
|
1090
2505
|
if (!md) continue;
|
|
1091
2506
|
const parsed = parseFrontmatter(md);
|
|
1092
|
-
|
|
1093
|
-
|
|
2507
|
+
let entries = [];
|
|
2508
|
+
try {
|
|
2509
|
+
entries = await this.io.list(dir);
|
|
2510
|
+
} catch {}
|
|
2511
|
+
const has = (marker) => entries.includes(markerEntryName(marker));
|
|
2512
|
+
const protectedBy = has("bundled") ? "bundled" : has("hub-installed") ? "hub-installed" : has("pinned") ? "pinned" : null;
|
|
1094
2513
|
summaries.push({
|
|
1095
2514
|
name,
|
|
1096
2515
|
description: parsed?.frontmatter.description ?? "",
|
|
1097
2516
|
path: dir,
|
|
1098
2517
|
protectedBy,
|
|
1099
|
-
managed,
|
|
2518
|
+
managed: has("hermes-managed"),
|
|
1100
2519
|
archived: false
|
|
1101
2520
|
});
|
|
1102
2521
|
}
|
|
1103
2522
|
return summaries;
|
|
1104
2523
|
}
|
|
1105
|
-
async read(
|
|
1106
|
-
|
|
2524
|
+
async read(rawName) {
|
|
2525
|
+
const name = rawName.trim();
|
|
2526
|
+
if (this.badName(name) !== null) return null;
|
|
2527
|
+
return this.io.readText(join(this.dirOf(name), "SKILL.md"));
|
|
2528
|
+
}
|
|
2529
|
+
/**
|
|
2530
|
+
|
|
2531
|
+
* Single path-building choke point (rc.42 audit P2-5): every directory path
|
|
2532
|
+
|
|
2533
|
+
* is built from the TRIMMED name, so a name that passes `badName` (which
|
|
2534
|
+
|
|
2535
|
+
* trims before validating) can never mint a second, whitespace-padded
|
|
2536
|
+
|
|
2537
|
+
* directory next to the real one. Callers keep passing raw user input.
|
|
2538
|
+
|
|
2539
|
+
*/
|
|
2540
|
+
dirOf(name) {
|
|
2541
|
+
return skillDir(this.root, name.trim());
|
|
2542
|
+
}
|
|
2543
|
+
/** Name-format guard shared by every path-building mutator/reader. */
|
|
2544
|
+
badName(name) {
|
|
2545
|
+
const normalized = name.trim();
|
|
2546
|
+
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}).`;
|
|
2547
|
+
return null;
|
|
1107
2548
|
}
|
|
1108
|
-
async writeProtection(
|
|
1109
|
-
const
|
|
2549
|
+
async writeProtection(rawName, origin = "foreground") {
|
|
2550
|
+
const name = rawName.trim();
|
|
2551
|
+
const dir = this.dirOf(name);
|
|
1110
2552
|
for (const marker of ["bundled", "hub-installed"]) if (await this.io.exists(markerPath(dir, marker))) return marker;
|
|
2553
|
+
if (origin === "background_review" && await this.io.exists(markerPath(dir, "pinned"))) return "pinned";
|
|
1111
2554
|
return null;
|
|
1112
2555
|
}
|
|
1113
|
-
async deleteProtection(
|
|
1114
|
-
const
|
|
1115
|
-
|
|
2556
|
+
async deleteProtection(rawName, options = {}) {
|
|
2557
|
+
const name = rawName.trim();
|
|
2558
|
+
const dir = this.dirOf(name);
|
|
2559
|
+
const markers = options.allowBundled ? ["hub-installed", "pinned"] : [
|
|
1116
2560
|
"bundled",
|
|
1117
2561
|
"hub-installed",
|
|
1118
2562
|
"pinned"
|
|
1119
|
-
]
|
|
2563
|
+
];
|
|
2564
|
+
for (const marker of markers) if (await this.io.exists(markerPath(dir, marker))) return marker;
|
|
1120
2565
|
return null;
|
|
1121
2566
|
}
|
|
1122
|
-
async isManaged(
|
|
1123
|
-
const
|
|
2567
|
+
async isManaged(rawName) {
|
|
2568
|
+
const name = rawName.trim();
|
|
2569
|
+
const dir = this.dirOf(name);
|
|
1124
2570
|
return await this.io.exists(markerPath(dir, "hermes-managed"));
|
|
1125
2571
|
}
|
|
1126
|
-
|
|
2572
|
+
/** Whether the skill carries the bundled marker (curator prune-builtins eligibility). */
|
|
2573
|
+
async isBundled(rawName) {
|
|
2574
|
+
const name = rawName.trim();
|
|
2575
|
+
if (this.badName(name) !== null) return false;
|
|
2576
|
+
const dir = this.dirOf(name);
|
|
2577
|
+
return await this.io.exists(markerPath(dir, "bundled"));
|
|
2578
|
+
}
|
|
2579
|
+
/** Whether the skill carries the pinned marker (the marker is the factual source; usage.pinned mirrors it). */
|
|
2580
|
+
async isPinned(rawName) {
|
|
2581
|
+
const name = rawName.trim();
|
|
2582
|
+
if (this.badName(name) !== null) return false;
|
|
2583
|
+
const dir = this.dirOf(name);
|
|
2584
|
+
return await this.io.exists(markerPath(dir, "pinned"));
|
|
2585
|
+
}
|
|
2586
|
+
/** Count non-empty support subdirectories (richness input for quality scoring). */
|
|
2587
|
+
async countSupportDirs(rawName) {
|
|
2588
|
+
const name = rawName.trim();
|
|
2589
|
+
if (this.badName(name) !== null) return 0;
|
|
2590
|
+
const dir = this.dirOf(name);
|
|
2591
|
+
let entries;
|
|
2592
|
+
try {
|
|
2593
|
+
entries = await this.io.list(dir);
|
|
2594
|
+
} catch {
|
|
2595
|
+
return 0;
|
|
2596
|
+
}
|
|
2597
|
+
let count = 0;
|
|
2598
|
+
for (const subdir of SUPPORT_DIRS) {
|
|
2599
|
+
if (!entries.includes(subdir)) continue;
|
|
2600
|
+
try {
|
|
2601
|
+
if ((await this.io.list(join(dir, subdir))).some((file) => file !== ".gitkeep")) count += 1;
|
|
2602
|
+
} catch {}
|
|
2603
|
+
}
|
|
2604
|
+
return count;
|
|
2605
|
+
}
|
|
2606
|
+
/** Best-effort audit trail entry; never blocks the mutation. */
|
|
2607
|
+
async audit(skillName, action, before, after, summary) {
|
|
2608
|
+
try {
|
|
2609
|
+
await recordMutation(this.root, this.io, {
|
|
2610
|
+
skillName,
|
|
2611
|
+
action,
|
|
2612
|
+
...before === null ? {} : { beforeHash: contentHash(before) },
|
|
2613
|
+
...after === null ? {} : { afterHash: contentHash(after) },
|
|
2614
|
+
summary,
|
|
2615
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2616
|
+
});
|
|
2617
|
+
} catch {}
|
|
2618
|
+
}
|
|
2619
|
+
/** Recent mutation audit records (read-only inspection surface). */
|
|
2620
|
+
async listMutations() {
|
|
2621
|
+
return await loadMutations(this.root, this.io);
|
|
2622
|
+
}
|
|
2623
|
+
/**
|
|
2624
|
+
* Pin or unpin a skill (`.pinned` marker). Pinned skills are protected from
|
|
2625
|
+
* deletion, from background-review writes, and from the lifecycle — a
|
|
2626
|
+
* protective mutation, so the autonomous pipeline may never call it. The
|
|
2627
|
+
* marker write is the only state change; content is untouched.
|
|
2628
|
+
*/
|
|
2629
|
+
async setPinned(name, pinned, origin = "foreground") {
|
|
2630
|
+
const normalized = name.trim();
|
|
2631
|
+
if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return {
|
|
2632
|
+
ok: false,
|
|
2633
|
+
message: `Invalid skill name "${normalized}". Use lowercase letters, digits, and hyphens (<= ${this.limits.maxNameLength}).`
|
|
2634
|
+
};
|
|
2635
|
+
if (origin === "background_review") return {
|
|
2636
|
+
ok: false,
|
|
2637
|
+
message: "Only the foreground (user or the main agent) may pin or unpin skills."
|
|
2638
|
+
};
|
|
2639
|
+
const dir = this.dirOf(normalized);
|
|
2640
|
+
const marker = markerPath(dir, "pinned");
|
|
2641
|
+
const existing = await this.io.exists(marker);
|
|
2642
|
+
if (pinned && existing) return {
|
|
2643
|
+
ok: true,
|
|
2644
|
+
message: `Skill "${normalized}" is already pinned.`,
|
|
2645
|
+
path: dir
|
|
2646
|
+
};
|
|
2647
|
+
if (!pinned && !existing) return {
|
|
2648
|
+
ok: true,
|
|
2649
|
+
message: `Skill "${normalized}" is not pinned; nothing to do.`,
|
|
2650
|
+
path: dir
|
|
2651
|
+
};
|
|
2652
|
+
if (!await this.io.exists(join(dir, "SKILL.md"))) return {
|
|
2653
|
+
ok: false,
|
|
2654
|
+
message: `Skill "${normalized}" not found.`
|
|
2655
|
+
};
|
|
2656
|
+
if (pinned) await this.io.writeText(marker, "");
|
|
2657
|
+
else await this.io.remove(marker);
|
|
2658
|
+
await this.audit(normalized, pinned ? "pin" : "unpin", null, null, pinned ? "pinned" : "unpinned");
|
|
2659
|
+
return {
|
|
2660
|
+
ok: true,
|
|
2661
|
+
message: pinned ? `Skill "${normalized}" pinned: protected from deletion, background review, and the lifecycle.` : `Skill "${normalized}" unpinned.`,
|
|
2662
|
+
path: dir
|
|
2663
|
+
};
|
|
2664
|
+
}
|
|
2665
|
+
async create(name, content, origin = "foreground") {
|
|
1127
2666
|
const normalized = name.trim();
|
|
1128
2667
|
if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return {
|
|
1129
2668
|
ok: false,
|
|
@@ -1139,26 +2678,39 @@ var SkillLibrary = class {
|
|
|
1139
2678
|
ok: false,
|
|
1140
2679
|
message: threat
|
|
1141
2680
|
};
|
|
1142
|
-
const dir =
|
|
2681
|
+
const dir = this.dirOf(normalized);
|
|
1143
2682
|
if (await this.io.exists(join(dir, "SKILL.md"))) return {
|
|
1144
2683
|
ok: false,
|
|
1145
2684
|
message: `Skill "${normalized}" already exists.`
|
|
1146
2685
|
};
|
|
1147
2686
|
await this.io.writeText(join(dir, "SKILL.md"), content.trimEnd() + "\n");
|
|
1148
|
-
if (origin
|
|
2687
|
+
if (origin !== "foreground") await this.io.writeText(markerPath(dir, "hermes-managed"), "");
|
|
2688
|
+
await this.audit(normalized, "create", null, content, "created");
|
|
2689
|
+
this.notifyMutation({
|
|
2690
|
+
action: "create",
|
|
2691
|
+
name: normalized,
|
|
2692
|
+
filePath: dir
|
|
2693
|
+
});
|
|
1149
2694
|
return {
|
|
1150
2695
|
ok: true,
|
|
1151
2696
|
message: `Skill "${normalized}" created.`,
|
|
1152
2697
|
path: dir
|
|
1153
2698
|
};
|
|
1154
2699
|
}
|
|
1155
|
-
async update(
|
|
1156
|
-
const
|
|
1157
|
-
|
|
2700
|
+
async update(rawName, content, origin = "foreground") {
|
|
2701
|
+
const name = rawName.trim();
|
|
2702
|
+
const badName = this.badName(name);
|
|
2703
|
+
if (badName) return {
|
|
2704
|
+
ok: false,
|
|
2705
|
+
message: badName
|
|
2706
|
+
};
|
|
2707
|
+
const dir = this.dirOf(name);
|
|
2708
|
+
const md = await this.io.readText(join(dir, "SKILL.md"));
|
|
2709
|
+
if (!md) return {
|
|
1158
2710
|
ok: false,
|
|
1159
2711
|
message: `Skill "${name}" not found.`
|
|
1160
2712
|
};
|
|
1161
|
-
const protection = await this.writeProtection(name);
|
|
2713
|
+
const protection = await this.writeProtection(name, origin);
|
|
1162
2714
|
if (protection) return {
|
|
1163
2715
|
ok: false,
|
|
1164
2716
|
message: `Skill "${name}" is protected (${protection}).`
|
|
@@ -1174,20 +2726,32 @@ var SkillLibrary = class {
|
|
|
1174
2726
|
message: threat
|
|
1175
2727
|
};
|
|
1176
2728
|
await this.io.writeText(join(dir, "SKILL.md"), content.trimEnd() + "\n");
|
|
2729
|
+
await this.audit(name, "update", md, content, "updated");
|
|
2730
|
+
this.notifyMutation({
|
|
2731
|
+
action: "update",
|
|
2732
|
+
name,
|
|
2733
|
+
filePath: dir
|
|
2734
|
+
});
|
|
1177
2735
|
return {
|
|
1178
2736
|
ok: true,
|
|
1179
2737
|
message: `Skill "${name}" updated.`,
|
|
1180
2738
|
path: dir
|
|
1181
2739
|
};
|
|
1182
2740
|
}
|
|
1183
|
-
async patch(
|
|
1184
|
-
const
|
|
2741
|
+
async patch(rawName, oldString, newString, filePath = "", replaceAll = false, origin = "foreground") {
|
|
2742
|
+
const name = rawName.trim();
|
|
2743
|
+
const badName = this.badName(name);
|
|
2744
|
+
if (badName) return {
|
|
2745
|
+
ok: false,
|
|
2746
|
+
message: badName
|
|
2747
|
+
};
|
|
2748
|
+
const dir = this.dirOf(name);
|
|
1185
2749
|
const skillMd = join(dir, "SKILL.md");
|
|
1186
2750
|
if (!await this.io.exists(skillMd)) return {
|
|
1187
2751
|
ok: false,
|
|
1188
2752
|
message: `Skill "${name}" not found.`
|
|
1189
2753
|
};
|
|
1190
|
-
const protection = await this.writeProtection(name);
|
|
2754
|
+
const protection = await this.writeProtection(name, origin);
|
|
1191
2755
|
if (protection) return {
|
|
1192
2756
|
ok: false,
|
|
1193
2757
|
message: `Skill "${name}" is protected (${protection}).`
|
|
@@ -1209,7 +2773,7 @@ var SkillLibrary = class {
|
|
|
1209
2773
|
message: `File not found: ${patchLabel}`
|
|
1210
2774
|
};
|
|
1211
2775
|
const patched = fuzzyPatch(md, oldString, newString, replaceAll);
|
|
1212
|
-
if (
|
|
2776
|
+
if (patched === null) return {
|
|
1213
2777
|
ok: false,
|
|
1214
2778
|
message: `Could not find old_string in "${name}/${patchLabel}". Use update for a full rewrite.`
|
|
1215
2779
|
};
|
|
@@ -1234,40 +2798,69 @@ var SkillLibrary = class {
|
|
|
1234
2798
|
message: threat
|
|
1235
2799
|
};
|
|
1236
2800
|
await this.io.writeText(target, patched.trimEnd() + "\n");
|
|
2801
|
+
await this.audit(name, "patch", md, patched, `patched ${patchLabel}`);
|
|
2802
|
+
this.notifyMutation({
|
|
2803
|
+
action: "patch",
|
|
2804
|
+
name,
|
|
2805
|
+
filePath: dir
|
|
2806
|
+
});
|
|
1237
2807
|
return {
|
|
1238
2808
|
ok: true,
|
|
1239
2809
|
message: `Skill "${name}" patched (${patchLabel}).`,
|
|
1240
2810
|
path: dir
|
|
1241
2811
|
};
|
|
1242
2812
|
}
|
|
1243
|
-
async archive(
|
|
1244
|
-
const
|
|
1245
|
-
|
|
2813
|
+
async archive(rawName, options = {}) {
|
|
2814
|
+
const name = rawName.trim();
|
|
2815
|
+
const badName = this.badName(name);
|
|
2816
|
+
if (badName) return {
|
|
2817
|
+
ok: false,
|
|
2818
|
+
message: badName
|
|
2819
|
+
};
|
|
2820
|
+
const dir = this.dirOf(name);
|
|
2821
|
+
const md = await this.io.readText(join(dir, "SKILL.md"));
|
|
2822
|
+
if (!md) return {
|
|
1246
2823
|
ok: false,
|
|
1247
2824
|
message: `Skill "${name}" not found.`
|
|
1248
2825
|
};
|
|
1249
|
-
const protection = await this.deleteProtection(name);
|
|
2826
|
+
const protection = await this.deleteProtection(name, options.allowBundled === void 0 ? {} : { allowBundled: options.allowBundled });
|
|
1250
2827
|
if (protection) return {
|
|
1251
2828
|
ok: false,
|
|
1252
2829
|
message: `Skill "${name}" is protected (${protection}).`
|
|
1253
2830
|
};
|
|
1254
|
-
if (absorbedInto) {
|
|
1255
|
-
if (!await this.io.readText(join(
|
|
2831
|
+
if (options.absorbedInto) {
|
|
2832
|
+
if (!await this.io.readText(join(this.dirOf(options.absorbedInto), "SKILL.md"))) return {
|
|
1256
2833
|
ok: false,
|
|
1257
|
-
message: `absorbed_into="${absorbedInto}" does not exist.`
|
|
2834
|
+
message: `absorbed_into="${options.absorbedInto}" does not exist.`
|
|
1258
2835
|
};
|
|
1259
2836
|
}
|
|
1260
2837
|
const archiveRoot = join(this.root, ".archive");
|
|
1261
|
-
let dest = join(archiveRoot, name);
|
|
1262
|
-
if (await this.io.exists(dest))
|
|
2838
|
+
let dest = join(archiveRoot, name.trim());
|
|
2839
|
+
if (await this.io.exists(dest)) {
|
|
2840
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 14);
|
|
2841
|
+
dest = join(archiveRoot, `${name.trim()}-${stamp}`);
|
|
2842
|
+
while (await this.io.exists(dest)) dest = join(archiveRoot, `${name.trim()}-${stamp}-${Math.random().toString(36).slice(2, 8)}`);
|
|
2843
|
+
}
|
|
2844
|
+
if (this.io.isSymlink) {
|
|
2845
|
+
if (await this.io.isSymlink(dir) === true) return {
|
|
2846
|
+
ok: false,
|
|
2847
|
+
message: `Skill "${name}" is a symlink; refusing to archive it.`
|
|
2848
|
+
};
|
|
2849
|
+
}
|
|
1263
2850
|
try {
|
|
1264
2851
|
await this.io.rename(dir, dest);
|
|
1265
2852
|
} catch {
|
|
1266
2853
|
await this.io.copy(dir, dest);
|
|
1267
2854
|
await this.io.remove(dir);
|
|
1268
2855
|
}
|
|
1269
|
-
const reason = absorbedInto ? `Consolidated into ${absorbedInto}` : "Archived by self-evolution curator";
|
|
2856
|
+
const reason = options.reason ?? (options.absorbedInto ? `Consolidated into ${options.absorbedInto}` : "Archived by self-evolution curator");
|
|
1270
2857
|
await this.io.writeText(join(dest, ".archive-reason"), `${(/* @__PURE__ */ new Date()).toISOString()}: ${reason}\n`);
|
|
2858
|
+
await this.audit(name, "archive", md, null, reason);
|
|
2859
|
+
this.notifyMutation({
|
|
2860
|
+
action: "archive",
|
|
2861
|
+
name,
|
|
2862
|
+
archivedPath: dest
|
|
2863
|
+
});
|
|
1271
2864
|
return {
|
|
1272
2865
|
ok: true,
|
|
1273
2866
|
message: `Skill "${name}" archived to .archive.`,
|
|
@@ -1279,26 +2872,27 @@ var SkillLibrary = class {
|
|
|
1279
2872
|
* an absorbed-into marker. Hermes-style consolidation: overlapping skills
|
|
1280
2873
|
* collapse into one, and the originals stay recoverable under `.archive/`.
|
|
1281
2874
|
*/
|
|
1282
|
-
async consolidate(target, sources) {
|
|
1283
|
-
const
|
|
2875
|
+
async consolidate(target, sources, origin = "foreground") {
|
|
2876
|
+
const targetName = target.trim();
|
|
2877
|
+
const normalizedSources = [...new Set(sources.map((name) => name.trim()))].filter((name) => name !== targetName);
|
|
1284
2878
|
if (normalizedSources.length === 0) return {
|
|
1285
2879
|
ok: false,
|
|
1286
2880
|
message: "Consolidation requires at least one distinct source skill."
|
|
1287
2881
|
};
|
|
1288
|
-
for (const name of [
|
|
2882
|
+
for (const name of [targetName, ...normalizedSources]) if (!SKILL_NAME_RE.test(name)) return {
|
|
1289
2883
|
ok: false,
|
|
1290
2884
|
message: `Invalid skill name "${name}". Use lowercase letters, digits, and hyphens.`
|
|
1291
2885
|
};
|
|
1292
|
-
const targetDir =
|
|
2886
|
+
const targetDir = this.dirOf(targetName);
|
|
1293
2887
|
const targetMd = await this.io.readText(join(targetDir, "SKILL.md"));
|
|
1294
2888
|
if (!targetMd) return {
|
|
1295
2889
|
ok: false,
|
|
1296
|
-
message: `Skill "${
|
|
2890
|
+
message: `Skill "${targetName}" not found.`
|
|
1297
2891
|
};
|
|
1298
|
-
const targetProtection = await this.writeProtection(
|
|
2892
|
+
const targetProtection = await this.writeProtection(targetName, origin);
|
|
1299
2893
|
if (targetProtection) return {
|
|
1300
2894
|
ok: false,
|
|
1301
|
-
message: `Skill "${
|
|
2895
|
+
message: `Skill "${targetName}" is protected (${targetProtection}).`
|
|
1302
2896
|
};
|
|
1303
2897
|
const parts = [];
|
|
1304
2898
|
for (const source of normalizedSources) {
|
|
@@ -1307,7 +2901,7 @@ var SkillLibrary = class {
|
|
|
1307
2901
|
ok: false,
|
|
1308
2902
|
message: `Skill "${source}" is protected (${protection}).`
|
|
1309
2903
|
};
|
|
1310
|
-
const sourceMd = await this.io.readText(join(
|
|
2904
|
+
const sourceMd = await this.io.readText(join(this.dirOf(source), "SKILL.md"));
|
|
1311
2905
|
if (!sourceMd) return {
|
|
1312
2906
|
ok: false,
|
|
1313
2907
|
message: `Skill "${source}" not found.`
|
|
@@ -1320,7 +2914,7 @@ var SkillLibrary = class {
|
|
|
1320
2914
|
parts.push(`\n<!-- consolidated from ${source} at ${(/* @__PURE__ */ new Date()).toISOString()} -->\n${parsed.body.trim()}`);
|
|
1321
2915
|
}
|
|
1322
2916
|
const merged = targetMd.trimEnd() + parts.join("\n") + "\n";
|
|
1323
|
-
const validation = validateFrontmatter(merged,
|
|
2917
|
+
const validation = validateFrontmatter(merged, targetName, this.limits);
|
|
1324
2918
|
if (validation) return {
|
|
1325
2919
|
ok: false,
|
|
1326
2920
|
message: `Consolidation rejected: ${validation}`
|
|
@@ -1330,14 +2924,30 @@ var SkillLibrary = class {
|
|
|
1330
2924
|
ok: false,
|
|
1331
2925
|
message: threat
|
|
1332
2926
|
};
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
const
|
|
1336
|
-
|
|
2927
|
+
const archived = [];
|
|
2928
|
+
try {
|
|
2929
|
+
for (const source of normalizedSources) {
|
|
2930
|
+
const result = await this.archive(source, { absorbedInto: targetName });
|
|
2931
|
+
if (!result.ok) throw new Error(result.message);
|
|
2932
|
+
archived.push(source);
|
|
2933
|
+
}
|
|
2934
|
+
await this.io.writeText(join(targetDir, "SKILL.md"), merged);
|
|
2935
|
+
} catch (error) {
|
|
2936
|
+
await this.io.writeText(join(targetDir, "SKILL.md"), targetMd).catch(() => {});
|
|
2937
|
+
for (const source of archived.reverse()) await this.restoreFromArchive(source).catch(() => {});
|
|
2938
|
+
return {
|
|
2939
|
+
ok: false,
|
|
2940
|
+
message: `Consolidation failed and was rolled back: ${error instanceof Error ? error.message : String(error)}`
|
|
2941
|
+
};
|
|
1337
2942
|
}
|
|
2943
|
+
this.notifyMutation({
|
|
2944
|
+
action: "consolidate",
|
|
2945
|
+
name: targetName,
|
|
2946
|
+
filePath: targetDir
|
|
2947
|
+
});
|
|
1338
2948
|
return {
|
|
1339
2949
|
ok: true,
|
|
1340
|
-
message: `Consolidated ${normalizedSources.join(", ")} into "${
|
|
2950
|
+
message: `Consolidated ${normalizedSources.join(", ")} into "${targetName}".`,
|
|
1341
2951
|
path: targetDir
|
|
1342
2952
|
};
|
|
1343
2953
|
}
|
|
@@ -1346,12 +2956,13 @@ var SkillLibrary = class {
|
|
|
1346
2956
|
* recoverability: archival never deletes, and this is the control-plane
|
|
1347
2957
|
* path back. The `.archive-reason` marker is dropped on restore.
|
|
1348
2958
|
*/
|
|
1349
|
-
async restoreFromArchive(
|
|
2959
|
+
async restoreFromArchive(rawName) {
|
|
2960
|
+
const name = rawName.trim();
|
|
1350
2961
|
if (!SKILL_NAME_RE.test(name)) return {
|
|
1351
2962
|
ok: false,
|
|
1352
2963
|
message: `Invalid skill name "${name}". Use lowercase letters, digits, and hyphens.`
|
|
1353
2964
|
};
|
|
1354
|
-
if (await this.io.exists(join(
|
|
2965
|
+
if (await this.io.exists(join(this.dirOf(name), "SKILL.md"))) return {
|
|
1355
2966
|
ok: false,
|
|
1356
2967
|
message: `Skill "${name}" already exists in the active root; refusing to overwrite.`
|
|
1357
2968
|
};
|
|
@@ -1371,7 +2982,13 @@ var SkillLibrary = class {
|
|
|
1371
2982
|
message: `Skill "${name}" is not in .archive.`
|
|
1372
2983
|
};
|
|
1373
2984
|
const source = join(archiveRoot, chosen);
|
|
1374
|
-
const dest =
|
|
2985
|
+
const dest = this.dirOf(name);
|
|
2986
|
+
if (this.io.isSymlink) {
|
|
2987
|
+
if (await this.io.isSymlink(source) === true) return {
|
|
2988
|
+
ok: false,
|
|
2989
|
+
message: `Archived entry "${chosen}" is a symlink; refusing to restore it.`
|
|
2990
|
+
};
|
|
2991
|
+
}
|
|
1375
2992
|
try {
|
|
1376
2993
|
await this.io.rename(source, dest);
|
|
1377
2994
|
} catch {
|
|
@@ -1379,19 +2996,30 @@ var SkillLibrary = class {
|
|
|
1379
2996
|
await this.io.remove(source);
|
|
1380
2997
|
}
|
|
1381
2998
|
if (await this.io.exists(join(dest, ".archive-reason"))) await this.io.remove(join(dest, ".archive-reason"));
|
|
2999
|
+
this.notifyMutation({
|
|
3000
|
+
action: "restore",
|
|
3001
|
+
name,
|
|
3002
|
+
filePath: dest
|
|
3003
|
+
});
|
|
1382
3004
|
return {
|
|
1383
3005
|
ok: true,
|
|
1384
3006
|
message: `Skill "${name}" restored from .archive.`,
|
|
1385
3007
|
path: dest
|
|
1386
3008
|
};
|
|
1387
3009
|
}
|
|
1388
|
-
async writeSupportFile(
|
|
1389
|
-
const
|
|
3010
|
+
async writeSupportFile(rawName, filePath, content, origin = "foreground") {
|
|
3011
|
+
const name = rawName.trim();
|
|
3012
|
+
const badName = this.badName(name);
|
|
3013
|
+
if (badName) return {
|
|
3014
|
+
ok: false,
|
|
3015
|
+
message: badName
|
|
3016
|
+
};
|
|
3017
|
+
const dir = this.dirOf(name);
|
|
1390
3018
|
if (!await this.io.exists(join(dir, "SKILL.md"))) return {
|
|
1391
3019
|
ok: false,
|
|
1392
3020
|
message: `Skill "${name}" not found.`
|
|
1393
3021
|
};
|
|
1394
|
-
const protection = await this.writeProtection(name);
|
|
3022
|
+
const protection = await this.writeProtection(name, origin);
|
|
1395
3023
|
if (protection) return {
|
|
1396
3024
|
ok: false,
|
|
1397
3025
|
message: `Skill "${name}" is protected (${protection}).`
|
|
@@ -1411,20 +3039,33 @@ var SkillLibrary = class {
|
|
|
1411
3039
|
message: threat
|
|
1412
3040
|
};
|
|
1413
3041
|
const target = join(dir, ...filePath.replace(/\\/g, "/").split("/").filter(Boolean));
|
|
3042
|
+
const existing = await this.io.readText(target).catch(() => null);
|
|
1414
3043
|
await this.io.writeText(target, content);
|
|
3044
|
+
await this.audit(name, "write_file", existing, content, `wrote ${filePath}`);
|
|
3045
|
+
this.notifyMutation({
|
|
3046
|
+
action: "write_file",
|
|
3047
|
+
name,
|
|
3048
|
+
filePath: target
|
|
3049
|
+
});
|
|
1415
3050
|
return {
|
|
1416
3051
|
ok: true,
|
|
1417
3052
|
message: `Support file "${filePath}" written to "${name}".`,
|
|
1418
3053
|
path: target
|
|
1419
3054
|
};
|
|
1420
3055
|
}
|
|
1421
|
-
async removeSupportFile(
|
|
1422
|
-
const
|
|
3056
|
+
async removeSupportFile(rawName, filePath, origin = "foreground") {
|
|
3057
|
+
const name = rawName.trim();
|
|
3058
|
+
const badName = this.badName(name);
|
|
3059
|
+
if (badName) return {
|
|
3060
|
+
ok: false,
|
|
3061
|
+
message: badName
|
|
3062
|
+
};
|
|
3063
|
+
const dir = this.dirOf(name);
|
|
1423
3064
|
if (!await this.io.exists(join(dir, "SKILL.md"))) return {
|
|
1424
3065
|
ok: false,
|
|
1425
3066
|
message: `Skill "${name}" not found.`
|
|
1426
3067
|
};
|
|
1427
|
-
const protection = await this.writeProtection(name);
|
|
3068
|
+
const protection = await this.writeProtection(name, origin);
|
|
1428
3069
|
if (protection) return {
|
|
1429
3070
|
ok: false,
|
|
1430
3071
|
message: `Skill "${name}" is protected (${protection}).`
|
|
@@ -1439,24 +3080,88 @@ var SkillLibrary = class {
|
|
|
1439
3080
|
ok: false,
|
|
1440
3081
|
message: `File "${filePath}" not found in skill "${name}".`
|
|
1441
3082
|
};
|
|
3083
|
+
const before = await this.io.readText(target).catch(() => null);
|
|
1442
3084
|
await this.io.remove(target);
|
|
3085
|
+
await this.audit(name, "remove_file", before, null, `removed ${filePath}`);
|
|
3086
|
+
this.notifyMutation({
|
|
3087
|
+
action: "remove_file",
|
|
3088
|
+
name,
|
|
3089
|
+
filePath: target
|
|
3090
|
+
});
|
|
1443
3091
|
return {
|
|
1444
3092
|
ok: true,
|
|
1445
3093
|
message: `Support file "${filePath}" removed from "${name}".`,
|
|
1446
3094
|
path: target
|
|
1447
3095
|
};
|
|
1448
3096
|
}
|
|
1449
|
-
|
|
1450
|
-
|
|
3097
|
+
/**
|
|
3098
|
+
* Snapshot the recoverable skills state: active tree, usage/suppression
|
|
3099
|
+
* sidecars, `.archive/` and caller-supplied extras. `extras` are opaque
|
|
3100
|
+
* side files the Snapshot owner cares about (curator state); they are
|
|
3101
|
+
* listed in the manifest and only those names are ever read back.
|
|
3102
|
+
*/
|
|
3103
|
+
async snapshotAll(reason = "pre-mutation", extras = []) {
|
|
3104
|
+
const backupRoot = join(this.root, ".backups");
|
|
3105
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
3106
|
+
let dest = join(backupRoot, `skills-${stamp}`);
|
|
3107
|
+
while (await this.io.exists(dest)) dest = join(backupRoot, `skills-${stamp}-${Math.random().toString(36).slice(2, 8)}`);
|
|
1451
3108
|
const names = await listNames(this.root, this.io);
|
|
1452
|
-
|
|
3109
|
+
await Promise.all(names.map(async (name) => {
|
|
3110
|
+
await this.io.copy(this.dirOf(name), join(dest, name));
|
|
3111
|
+
}));
|
|
3112
|
+
const sidecars = [];
|
|
3113
|
+
for (const sidecar of [usageFile(this.root), suppressedFile(this.root)]) if (await this.io.exists(sidecar)) {
|
|
3114
|
+
const name = basename(sidecar);
|
|
3115
|
+
await this.io.copy(sidecar, join(dest, name));
|
|
3116
|
+
sidecars.push(name);
|
|
3117
|
+
}
|
|
3118
|
+
const archiveRoot = join(this.root, ".archive");
|
|
3119
|
+
let hasArchive = false;
|
|
3120
|
+
if (await this.io.exists(archiveRoot)) {
|
|
3121
|
+
await this.io.copy(archiveRoot, join(dest, ".archive"));
|
|
3122
|
+
hasArchive = true;
|
|
3123
|
+
}
|
|
3124
|
+
const validExtras = extras.filter((extra) => SNAPSHOT_EXTRA_NAME_RE.test(extra.name));
|
|
3125
|
+
const extraNames = validExtras.map((extra) => extra.name);
|
|
3126
|
+
await Promise.all(validExtras.map(async (extra) => {
|
|
3127
|
+
await this.io.writeText(join(dest, "extras", extra.name), extra.content);
|
|
3128
|
+
}));
|
|
1453
3129
|
await this.io.writeText(join(dest, "manifest.json"), JSON.stringify({
|
|
1454
3130
|
reason,
|
|
1455
3131
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1456
|
-
skills: names
|
|
3132
|
+
skills: names,
|
|
3133
|
+
sidecars,
|
|
3134
|
+
hasArchive,
|
|
3135
|
+
extras: extraNames
|
|
1457
3136
|
}, null, 2));
|
|
3137
|
+
await this.retainSnapshots(5);
|
|
1458
3138
|
return dest;
|
|
1459
3139
|
}
|
|
3140
|
+
/** Read and normalize a snapshot manifest; null when the file is missing or unparsable. */
|
|
3141
|
+
async readSnapshotManifest(path) {
|
|
3142
|
+
const raw = await this.io.readText(join(path, "manifest.json"));
|
|
3143
|
+
if (raw === null) return null;
|
|
3144
|
+
try {
|
|
3145
|
+
const manifest = JSON.parse(raw);
|
|
3146
|
+
return {
|
|
3147
|
+
reason: typeof manifest.reason === "string" ? manifest.reason : "",
|
|
3148
|
+
createdAt: typeof manifest.createdAt === "string" ? manifest.createdAt : "",
|
|
3149
|
+
skills: Array.isArray(manifest.skills) ? manifest.skills : [],
|
|
3150
|
+
sidecars: Array.isArray(manifest.sidecars) ? manifest.sidecars : [],
|
|
3151
|
+
...typeof manifest.hasArchive === "boolean" ? { hasArchive: manifest.hasArchive } : {},
|
|
3152
|
+
extras: Array.isArray(manifest.extras) ? manifest.extras : []
|
|
3153
|
+
};
|
|
3154
|
+
} catch {
|
|
3155
|
+
return null;
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
/** Keep only the newest N snapshots (Hermes keep=5 parity); older ones are removed outright. */
|
|
3159
|
+
async retainSnapshots(keep) {
|
|
3160
|
+
const snapshots = await this.listSnapshots();
|
|
3161
|
+
for (const snapshot of snapshots.slice(keep)) try {
|
|
3162
|
+
await this.io.remove(snapshot.path);
|
|
3163
|
+
} catch {}
|
|
3164
|
+
}
|
|
1460
3165
|
async listSnapshots() {
|
|
1461
3166
|
const backupRoot = join(this.root, ".backups");
|
|
1462
3167
|
let entries;
|
|
@@ -1468,96 +3173,93 @@ var SkillLibrary = class {
|
|
|
1468
3173
|
const out = [];
|
|
1469
3174
|
for (const name of entries.sort().reverse()) {
|
|
1470
3175
|
if (!name.startsWith("skills-")) continue;
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
reason: manifest.reason ?? ""
|
|
1479
|
-
});
|
|
1480
|
-
} catch {}
|
|
3176
|
+
const manifest = await this.readSnapshotManifest(join(backupRoot, name));
|
|
3177
|
+
if (manifest === null) continue;
|
|
3178
|
+
out.push({
|
|
3179
|
+
path: join(backupRoot, name),
|
|
3180
|
+
createdAt: manifest.createdAt,
|
|
3181
|
+
reason: manifest.reason
|
|
3182
|
+
});
|
|
1481
3183
|
}
|
|
1482
3184
|
return out;
|
|
1483
3185
|
}
|
|
1484
|
-
|
|
3186
|
+
/**
|
|
3187
|
+
* Read the extras of a snapshot, restricted to the names declared in the
|
|
3188
|
+
* manifest — an `extras/` directory is never listed directly, so unknown
|
|
3189
|
+
* files cannot leak back as state on the next restore.
|
|
3190
|
+
*/
|
|
3191
|
+
async readSnapshotExtras(path) {
|
|
3192
|
+
const manifest = await this.readSnapshotManifest(path);
|
|
3193
|
+
if (manifest === null) return [];
|
|
3194
|
+
const extras = [];
|
|
3195
|
+
for (const name of manifest.extras) {
|
|
3196
|
+
if (!SNAPSHOT_EXTRA_NAME_RE.test(name)) continue;
|
|
3197
|
+
const content = await this.io.readText(join(path, "extras", name));
|
|
3198
|
+
if (content !== null) extras.push({
|
|
3199
|
+
name,
|
|
3200
|
+
content
|
|
3201
|
+
});
|
|
3202
|
+
}
|
|
3203
|
+
return extras;
|
|
3204
|
+
}
|
|
3205
|
+
/**
|
|
3206
|
+
* Manifest-driven restore of the latest snapshot: active tree, sidecars,
|
|
3207
|
+
* `.archive/` and (for full-state snapshots) the extras read back by the
|
|
3208
|
+
* caller. `extras` are additionally written into the pre-rollback safety
|
|
3209
|
+
* snapshot so the rollback itself is undoable with the same state.
|
|
3210
|
+
*/
|
|
3211
|
+
async restoreLatestSnapshot(extras = []) {
|
|
1485
3212
|
const latest = (await this.listSnapshots())[0];
|
|
1486
3213
|
if (!latest) return {
|
|
1487
3214
|
ok: false,
|
|
1488
3215
|
message: "No skill snapshot available."
|
|
1489
3216
|
};
|
|
1490
|
-
await this.snapshotAll("pre-rollback");
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
3217
|
+
await this.snapshotAll("pre-rollback", extras);
|
|
3218
|
+
let rootEntries;
|
|
3219
|
+
try {
|
|
3220
|
+
rootEntries = await this.io.list(this.root);
|
|
3221
|
+
} catch {
|
|
3222
|
+
rootEntries = [];
|
|
3223
|
+
}
|
|
3224
|
+
for (const entry of rootEntries) {
|
|
3225
|
+
if (entry.startsWith(".")) continue;
|
|
3226
|
+
await this.io.remove(join(this.root, entry));
|
|
3227
|
+
}
|
|
3228
|
+
const manifest = await this.readSnapshotManifest(latest.path);
|
|
3229
|
+
if (manifest === null) for (const entry of await this.io.list(latest.path)) {
|
|
3230
|
+
if (entry === "manifest.json" || entry === "extras") continue;
|
|
1495
3231
|
await this.io.copy(join(latest.path, entry), join(this.root, entry));
|
|
1496
3232
|
}
|
|
3233
|
+
else {
|
|
3234
|
+
for (const name of manifest.skills) await this.io.copy(join(latest.path, name), join(this.root, name));
|
|
3235
|
+
for (const sidecar of manifest.sidecars) await this.io.copy(join(latest.path, sidecar), join(this.root, sidecar));
|
|
3236
|
+
const archiveRoot = join(this.root, ".archive");
|
|
3237
|
+
if (manifest.hasArchive === true) {
|
|
3238
|
+
await this.io.remove(archiveRoot);
|
|
3239
|
+
await this.io.copy(join(latest.path, ".archive"), archiveRoot);
|
|
3240
|
+
} else if (manifest.hasArchive === false) await this.io.remove(archiveRoot);
|
|
3241
|
+
}
|
|
3242
|
+
const snapshotExtras = await this.readSnapshotExtras(latest.path);
|
|
3243
|
+
this.notifyMutation({
|
|
3244
|
+
action: "restore",
|
|
3245
|
+
name: "snapshot"
|
|
3246
|
+
});
|
|
1497
3247
|
return {
|
|
1498
3248
|
ok: true,
|
|
1499
3249
|
message: `Restored skill tree from ${latest.path}`,
|
|
1500
|
-
path: latest.path
|
|
3250
|
+
path: latest.path,
|
|
3251
|
+
...snapshotExtras.length === 0 ? {} : { extras: snapshotExtras }
|
|
1501
3252
|
};
|
|
1502
3253
|
}
|
|
1503
3254
|
};
|
|
1504
3255
|
//#endregion
|
|
1505
3256
|
//#region lib/types/state-store.js
|
|
1506
3257
|
/**
|
|
1507
|
-
*
|
|
1508
|
-
*
|
|
3258
|
+
* Evolution home path helper: `$DSH_HOME/evolution` for plugin-owned sidecar
|
|
3259
|
+
* state (reports, activity store, feedback file, state-domain data).
|
|
1509
3260
|
*/
|
|
1510
3261
|
function evolutionHome(env = process.env) {
|
|
1511
3262
|
return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "evolution");
|
|
1512
3263
|
}
|
|
1513
|
-
var JsonState = class {
|
|
1514
|
-
initial;
|
|
1515
|
-
path;
|
|
1516
|
-
value;
|
|
1517
|
-
constructor(name, initial, env = process.env) {
|
|
1518
|
-
this.initial = initial;
|
|
1519
|
-
this.path = join(evolutionHome(env), name);
|
|
1520
|
-
this.value = this.loadSync();
|
|
1521
|
-
}
|
|
1522
|
-
loadSync() {
|
|
1523
|
-
try {
|
|
1524
|
-
const raw = readFileSync(this.path, "utf8");
|
|
1525
|
-
const parsed = JSON.parse(raw);
|
|
1526
|
-
return {
|
|
1527
|
-
...this.initial,
|
|
1528
|
-
...parsed
|
|
1529
|
-
};
|
|
1530
|
-
} catch {
|
|
1531
|
-
return { ...this.initial };
|
|
1532
|
-
}
|
|
1533
|
-
}
|
|
1534
|
-
get() {
|
|
1535
|
-
return this.value;
|
|
1536
|
-
}
|
|
1537
|
-
set(value) {
|
|
1538
|
-
this.value = value;
|
|
1539
|
-
}
|
|
1540
|
-
update(mutator) {
|
|
1541
|
-
mutator(this.value);
|
|
1542
|
-
}
|
|
1543
|
-
async flush() {
|
|
1544
|
-
await mkdir(dirname(this.path), { recursive: true });
|
|
1545
|
-
const tmp = `${this.path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
1546
|
-
await writeFile(tmp, JSON.stringify(this.value, null, 2), "utf8");
|
|
1547
|
-
await rename(tmp, this.path);
|
|
1548
|
-
}
|
|
1549
|
-
/** Merge-on-load helper for persisted maps/records. */
|
|
1550
|
-
async reload() {
|
|
1551
|
-
try {
|
|
1552
|
-
const raw = await readFile(this.path, "utf8");
|
|
1553
|
-
this.value = {
|
|
1554
|
-
...this.initial,
|
|
1555
|
-
...JSON.parse(raw)
|
|
1556
|
-
};
|
|
1557
|
-
} catch {
|
|
1558
|
-
this.value = { ...this.initial };
|
|
1559
|
-
}
|
|
1560
|
-
}
|
|
1561
|
-
};
|
|
1562
3264
|
//#endregion
|
|
1563
|
-
export { COMBINED_REVIEW_PROMPT, CURATOR_PROMPT, DEFAULT_SKILL_LIMITS, DSH_AUTHORING_STANDARDS, ENTRY_DELIMITER,
|
|
3265
|
+
export { AUTHORING_DESCRIPTION_BAR, COMBINED_REVIEW_PLAN_PROMPT, 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, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION, 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, SKILLS_GUIDANCE, SKILL_NAME_RE, SKILL_REVIEW_PLAN_PROMPT, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary, advanceReview, appendEvolutionEvent, applyCuratorFields, applyCuratorLifecycleFields, applyCuratorMetaFields, authoringFeedback, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, computeDedupGroups, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, createGateSet, emptyRecord, evaluateThreat, eventsFile, evolutionHome, evolutionIoAdapter, foldCuratorFields, foldTurn, getRecord, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadSuppressedNames, loadUsage, markAgentCreated, memoryRoot, mutateUsage, mutationsFile, nodeEvolutionIo, normalizeUsageRecord, observeEvent, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, readEvolutionEvents, readEvolutionTimeline, recordMutation, relatedSkillNames, renderCuratorReportMarkdown, resolveOrigins, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, transactIo, updateSuppressedNames, usageFile, validateFrontmatter, verifyPromptBundle };
|