@lmzhen/dsh-evolution-core 0.3.65 → 0.3.67
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 +288 -72
- package/lib/types/events.d.ts +9 -0
- package/lib/types/io.d.ts +31 -6
- package/lib/types/skill-store.d.ts +26 -0
- package/lib/types/threats.d.ts +9 -9
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -79,6 +79,18 @@ const RENAME_RETRY_MAX_ATTEMPTS = 6;
|
|
|
79
79
|
* rethrows with a pointer at the usual causes instead of a bare errno.
|
|
80
80
|
* `fn` is the rename primitive, injectable for deterministic tests.
|
|
81
81
|
*
|
|
82
|
+
* v22 (LOCK-5) documented platform limit: POSIX rename-over-existing is
|
|
83
|
+
* atomic for concurrent READERS, but Windows MoveFileExW(REPLACE_EXISTING)
|
|
84
|
+
* can make the target briefly invisible (ENOENT) while the replacement is in
|
|
85
|
+
* flight. The retry budget above covers the WRITE side only — a LOCKLESS
|
|
86
|
+
* reader (`readText`/`readJson` on a path it does not hold the lock for) can
|
|
87
|
+
* observe that window and see "missing" for a file that was just committed.
|
|
88
|
+
* Verified harmless today: every lockless-read consumer treats the transient
|
|
89
|
+
* miss as self-healing state (no consumer persists a read of null into a
|
|
90
|
+
* write), and distinguishing that ENOENT from a genuinely absent file in
|
|
91
|
+
* `readText` would tax every ordinary missing-file probe. Revisit only if a
|
|
92
|
+
* consumer appears that must never transiently miss.
|
|
93
|
+
*
|
|
82
94
|
* @param tmp - the source path to rename.
|
|
83
95
|
* @param target - the destination path.
|
|
84
96
|
* @param fn - the rename primitive (defaults to `node:fs/promises.rename`).
|
|
@@ -189,8 +201,18 @@ function isProcessAlive(pid) {
|
|
|
189
201
|
const LOCK_SUFFIX = ".lock";
|
|
190
202
|
/** Writer-lock body shape: a decimal pid, a colon, then the claim token. A
|
|
191
203
|
* torn body (no parsable pid) still matches the `\\d+:` prefix rule only when
|
|
192
|
-
* the pid part is intact, which is what the takeover probe needs.
|
|
193
|
-
|
|
204
|
+
* the pid part is intact, which is what the takeover probe needs. The capture
|
|
205
|
+
* group feeds `parseLockBody` (v20 A-1: skill-store's sweepers consume this
|
|
206
|
+
* helper instead of re-inlining a third regex copy — F-17 single-source). */
|
|
207
|
+
const LOCK_BODY_RE = /^(\d+):[0-9a-f]*$/;
|
|
208
|
+
/** Parse a writer-lock body into its holder pid. `null` when the body does
|
|
209
|
+
* not have the `pid:token` shape at all (e.g. a user support file named
|
|
210
|
+
* `*.lock`) — callers leave such files alone. A shape-matching body always
|
|
211
|
+
* yields a number (possibly `0`, which `isProcessAlive` treats as dead). */
|
|
212
|
+
function parseLockBody(body) {
|
|
213
|
+
const match = LOCK_BODY_RE.exec(body.trim());
|
|
214
|
+
return match === null ? null : Number(match[1]);
|
|
215
|
+
}
|
|
194
216
|
/**
|
|
195
217
|
* Build the Node IO backend. `lockAttempts` scales the write-lock retry budget
|
|
196
218
|
* (attempts × 50ms); the default 40 (~2s, rc.69) covers production contention,
|
|
@@ -309,7 +331,18 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
309
331
|
} catch {
|
|
310
332
|
continue;
|
|
311
333
|
}
|
|
312
|
-
const
|
|
334
|
+
const verifyRead = await readFile(lock, "utf8").then((body) => ({
|
|
335
|
+
ok: true,
|
|
336
|
+
body
|
|
337
|
+
}), () => ({
|
|
338
|
+
ok: false,
|
|
339
|
+
body: ""
|
|
340
|
+
}));
|
|
341
|
+
if (!verifyRead.ok) {
|
|
342
|
+
await rm(ticket, { force: true }).catch(() => {});
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const verify = verifyRead.body;
|
|
313
346
|
if (verify === holderContent) {
|
|
314
347
|
const verifyHolder = Number(verify.split(":")[0] ?? "");
|
|
315
348
|
if (!(Number.isInteger(verifyHolder) && verifyHolder > 0 && isAlive(verifyHolder))) await rm(lock, { force: true }).catch(() => {});
|
|
@@ -580,23 +613,40 @@ async function mutateUsage(root, io, task, options = {}) {
|
|
|
580
613
|
await transactIo(io, usageFile(root), async (current) => {
|
|
581
614
|
let shapePreserved = false;
|
|
582
615
|
let recovered = null;
|
|
583
|
-
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
616
|
+
const quarantineCopy = async () => {
|
|
617
|
+
const corruptPath = `${usageFile(root)}.corrupt`;
|
|
618
|
+
return io.writeText(corruptPath, current).then(() => true, () => false);
|
|
619
|
+
};
|
|
620
|
+
if (current !== null) {
|
|
621
|
+
let parsed = null;
|
|
622
|
+
try {
|
|
623
|
+
const probe = JSON.parse(current);
|
|
624
|
+
if (probe !== null && typeof probe === "object" && !Array.isArray(probe)) parsed = probe;
|
|
625
|
+
} catch {
|
|
626
|
+
parsed = null;
|
|
627
|
+
}
|
|
628
|
+
if (parsed === null) {
|
|
629
|
+
if (!await quarantineCopy()) {
|
|
630
|
+
options.onQuarantine?.(`usage sidecar ${usageFile(root)} is unreadable; the .corrupt copy could not be written — refusing this write so the original bytes stay recoverable (telemetry is frozen until the file is recovered manually)`);
|
|
631
|
+
return current;
|
|
632
|
+
}
|
|
633
|
+
options.onQuarantine?.(`usage sidecar ${usageFile(root)} is unreadable (unparsable JSON or wrong top-level shape); the original bytes were copied to ${usageFile(root)}.corrupt and the sidecar restarts empty`);
|
|
634
|
+
} else {
|
|
635
|
+
const record = parsed;
|
|
588
636
|
const isMalformed = (value) => value === null || typeof value !== "object" || Array.isArray(value);
|
|
589
|
-
if (typeof record.version === "number" && record.version > 1)
|
|
590
|
-
|
|
637
|
+
if (typeof record.version === "number" && record.version > 1) {
|
|
638
|
+
options.onQuarantine?.(`usage sidecar ${usageFile(root)} carries schema version ${String(record.version)} (> this runtime) — writes stay frozen and the bytes preserved until the runtime is upgraded`);
|
|
639
|
+
shapePreserved = true;
|
|
640
|
+
} else if (Object.values(record).some(isMalformed)) {
|
|
591
641
|
const bad = Object.keys(record).filter((key) => isMalformed(record[key]));
|
|
592
|
-
|
|
593
|
-
|
|
642
|
+
if (!await quarantineCopy()) {
|
|
643
|
+
options.onQuarantine?.(`usage sidecar ${usageFile(root)} carried ${bad.length} malformed entr${bad.length === 1 ? "y" : "ies"} (${bad.slice(0, 5).join(", ")}); the .corrupt copy could not be written — refusing this write so the original bytes stay recoverable`);
|
|
644
|
+
return current;
|
|
645
|
+
}
|
|
594
646
|
recovered = JSON.stringify(Object.fromEntries(Object.entries(record).filter(([, value]) => !isMalformed(value))));
|
|
595
|
-
options.onQuarantine?.(`usage sidecar ${usageFile(root)} carried ${bad.length} malformed entr${bad.length === 1 ? "y" : "ies"} (${bad.slice(0, 5).join(", ")}); the original bytes were copied to ${
|
|
647
|
+
options.onQuarantine?.(`usage sidecar ${usageFile(root)} carried ${bad.length} malformed entr${bad.length === 1 ? "y" : "ies"} (${bad.slice(0, 5).join(", ")}); the original bytes were copied to ${usageFile(root)}.corrupt and the remaining entries continue to be served`);
|
|
596
648
|
}
|
|
597
649
|
}
|
|
598
|
-
} catch {
|
|
599
|
-
return current;
|
|
600
650
|
}
|
|
601
651
|
if (shapePreserved) return current;
|
|
602
652
|
const map = parseUsage(recovered ?? current);
|
|
@@ -746,6 +796,14 @@ function parseSuppressed(raw) {
|
|
|
746
796
|
}
|
|
747
797
|
}
|
|
748
798
|
async function saveSuppressedNames(root, names, io = nodeEvolutionIo()) {
|
|
799
|
+
const current = await io.readText(suppressedFile(root)).catch(() => null);
|
|
800
|
+
if (current !== null) try {
|
|
801
|
+
const parsed = JSON.parse(current);
|
|
802
|
+
if (parsed !== null && typeof parsed.version === "number" && parsed.version > 1) {
|
|
803
|
+
console.warn(`suppression sidecar ${suppressedFile(root)} declares version ${String(parsed.version)} (newer than 1); not overwritten`);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
} catch {}
|
|
749
807
|
await io.writeText(suppressedFile(root), JSON.stringify({
|
|
750
808
|
version: 1,
|
|
751
809
|
names: [...names].sort()
|
|
@@ -759,10 +817,17 @@ async function saveSuppressedNames(root, names, io = nodeEvolutionIo()) {
|
|
|
759
817
|
*/
|
|
760
818
|
async function updateSuppressedNames(root, io, task) {
|
|
761
819
|
await transactIo(io, suppressedFile(root), async (current) => {
|
|
762
|
-
if (current !== null)
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
820
|
+
if (current !== null) {
|
|
821
|
+
let parsed = null;
|
|
822
|
+
try {
|
|
823
|
+
parsed = JSON.parse(current);
|
|
824
|
+
} catch {
|
|
825
|
+
return current;
|
|
826
|
+
}
|
|
827
|
+
if (parsed !== null && typeof parsed.version === "number" && parsed.version > 1) {
|
|
828
|
+
console.warn(`suppression sidecar ${suppressedFile(root)} declares version ${String(parsed.version)} (newer than 1); not overwritten`);
|
|
829
|
+
return current;
|
|
830
|
+
}
|
|
766
831
|
}
|
|
767
832
|
const names = parseSuppressed(current);
|
|
768
833
|
await task(names);
|
|
@@ -1315,7 +1380,7 @@ async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE
|
|
|
1315
1380
|
}
|
|
1316
1381
|
const events = await rotateIfDue(io, path, v1EventRecords(parsedBody), rotateAt);
|
|
1317
1382
|
let maxSeq = events.reduce((max, entry) => Math.max(max, entry.seq), 0);
|
|
1318
|
-
|
|
1383
|
+
for (const name of await listEventArchives(io, path)) maxSeq = Math.max(maxSeq, Number.parseInt(name.slice(7, name.length - 5), 10));
|
|
1319
1384
|
const record = {
|
|
1320
1385
|
...event,
|
|
1321
1386
|
seq: maxSeq + 1,
|
|
@@ -2072,19 +2137,19 @@ const PATTERNS = [
|
|
|
2072
2137
|
label: "send_to_url",
|
|
2073
2138
|
category: "exfiltration",
|
|
2074
2139
|
scope: "strict",
|
|
2075
|
-
regex: /(?:send|post|upload|transmit)\s+[
|
|
2140
|
+
regex: /(?:send|post|upload|transmit)\s+[\s\S]{0,512}?\s+(?:to|at)\s+https?:\/\//i
|
|
2076
2141
|
},
|
|
2077
2142
|
{
|
|
2078
2143
|
label: "exfil_curl",
|
|
2079
2144
|
category: "exfiltration",
|
|
2080
2145
|
scope: "all",
|
|
2081
|
-
regex: /\bcurl\s+[
|
|
2146
|
+
regex: /\bcurl\s+[\s\S]{0,512}?\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)/i
|
|
2082
2147
|
},
|
|
2083
2148
|
{
|
|
2084
2149
|
label: "exfil_wget",
|
|
2085
2150
|
category: "exfiltration",
|
|
2086
2151
|
scope: "all",
|
|
2087
|
-
regex: /\bwget\s+[
|
|
2152
|
+
regex: /\bwget\s+[\s\S]{0,512}?\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)/i
|
|
2088
2153
|
},
|
|
2089
2154
|
{
|
|
2090
2155
|
label: "read_secrets",
|
|
@@ -2150,7 +2215,37 @@ const PATTERNS = [
|
|
|
2150
2215
|
label: "private_key_block",
|
|
2151
2216
|
category: "hardcoded_secrets",
|
|
2152
2217
|
scope: "all",
|
|
2153
|
-
regex: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY
|
|
2218
|
+
regex: /-----BEGIN\s+(?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED|PGP)\s+)?PRIVATE\s+KEY(?:\s+BLOCK)?-----/
|
|
2219
|
+
},
|
|
2220
|
+
{
|
|
2221
|
+
label: "prompt_injection_ignore_zh",
|
|
2222
|
+
category: "prompt_injection",
|
|
2223
|
+
scope: "all",
|
|
2224
|
+
regex: /(?:忽略|无视|抛开)[\s\S]{0,8}(?:之前|以上|上面|先前|以前)?[\s\S]{0,8}(?:所有|全部|任何)?[\s\S]{0,4}(?:指令|规则|设定|约束)/
|
|
2225
|
+
},
|
|
2226
|
+
{
|
|
2227
|
+
label: "deception_hide_zh",
|
|
2228
|
+
category: "deception",
|
|
2229
|
+
scope: "all",
|
|
2230
|
+
regex: /(?:不要|别|勿)(?:告诉|告知|透露)(?:给)?[\s\S]{0,6}(?:用户|任何人|主人|开发者)/
|
|
2231
|
+
},
|
|
2232
|
+
{
|
|
2233
|
+
label: "system_prompt_leak_zh",
|
|
2234
|
+
category: "deception",
|
|
2235
|
+
scope: "context",
|
|
2236
|
+
regex: /(?:泄露|输出|打印|透露|导出)[\s\S]{0,10}系统提示|系统提示[\s\S]{0,10}(?:泄露|透露|发送|导出)/
|
|
2237
|
+
},
|
|
2238
|
+
{
|
|
2239
|
+
label: "context_exfil_zh",
|
|
2240
|
+
category: "exfiltration",
|
|
2241
|
+
scope: "strict",
|
|
2242
|
+
regex: /(?:对话记录|聊天记录|全部上下文|完整上下文)[\s\S]{0,30}(?:发送|上传|传输|外传|泄露)[\s\S]{0,30}(?:到|至|给)/
|
|
2243
|
+
},
|
|
2244
|
+
{
|
|
2245
|
+
label: "secret_exfil_zh",
|
|
2246
|
+
category: "exfiltration",
|
|
2247
|
+
scope: "strict",
|
|
2248
|
+
regex: /(?:密钥|凭据|口令|密码|环境变量)[\s\S]{0,30}(?:发送|上传|传输|外传|泄露)[\s\S]{0,30}(?:到|至)\s*(?:https?:\/\/|[\w.-]+\.(?:com|net|org|io|cn|dev|xyz|ru)\b)/
|
|
2154
2249
|
}
|
|
2155
2250
|
];
|
|
2156
2251
|
const ZERO_WIDTH_CHARS = new RegExp(`[\\u034f\\u200b\\u2060\\u2061\\u2062\\u2063\\u2064\\u206a-\\u206f\\ufeff\\u{e0000}-\\u{e007f}]`, "u");
|
|
@@ -2199,11 +2294,13 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_
|
|
|
2199
2294
|
scope: "all"
|
|
2200
2295
|
});
|
|
2201
2296
|
const normalized = text.normalize("NFKC");
|
|
2297
|
+
const SPACE_SPLITTERS = /[\u00ad\u061c\u180e\u200c\ufe00-\ufe0f]/gu;
|
|
2298
|
+
const patternTexts = [normalized.replace(SPACE_SPLITTERS, " "), normalized.replace(SPACE_SPLITTERS, "")];
|
|
2202
2299
|
const windows = [];
|
|
2203
|
-
if (
|
|
2300
|
+
for (const patternText of patternTexts) if (patternText.length <= windowSize) windows.push(patternText);
|
|
2204
2301
|
else {
|
|
2205
2302
|
const step = Math.max(Math.floor(windowSize / 2), 1);
|
|
2206
|
-
for (let start = 0; start <
|
|
2303
|
+
for (let start = 0; start < patternText.length; start += step) windows.push(patternText.slice(start, start + windowSize));
|
|
2207
2304
|
}
|
|
2208
2305
|
const seen = /* @__PURE__ */ new Set();
|
|
2209
2306
|
for (const window of windows) for (const pattern of PATTERNS) {
|
|
@@ -2236,27 +2333,26 @@ function scanMemoryThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS
|
|
|
2236
2333
|
const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
|
|
2237
2334
|
if (!blocked) return null;
|
|
2238
2335
|
const pattern = findings.find((f) => f.category !== "unicode_obfuscation");
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
return `Blocked by security scan: invisible or potentially malicious Unicode detected.${exemptionHint}`;
|
|
2336
|
+
if (pattern) return `Blocked by security scan (${pattern.label}). Rephrase without instruction-like language.${THREAT_EXEMPTION_HINT}`;
|
|
2337
|
+
return `Blocked by security scan: invisible or potentially malicious Unicode detected.${THREAT_EXEMPTION_HINT}`;
|
|
2242
2338
|
}
|
|
2243
2339
|
/** User-facing block message for skill content writes. */
|
|
2244
2340
|
function scanContentThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
|
|
2245
2341
|
const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
|
|
2246
2342
|
if (!blocked) return null;
|
|
2247
|
-
return `Blocked by security scan (${findings[0]?.label ?? "unknown"}). This content appears to contain potentially malicious instructions
|
|
2343
|
+
return `Blocked by security scan (${findings[0]?.label ?? "unknown"}). This content appears to contain potentially malicious instructions.${THREAT_EXEMPTION_HINT}`;
|
|
2248
2344
|
}
|
|
2249
2345
|
/**
|
|
2250
|
-
*
|
|
2251
|
-
*
|
|
2252
|
-
*
|
|
2253
|
-
*
|
|
2254
|
-
*
|
|
2255
|
-
*
|
|
2256
|
-
*
|
|
2257
|
-
* message verbatim.
|
|
2346
|
+
* WD2 (0.3.56): the shared tail of every user-facing threat block — names the
|
|
2347
|
+
* deployable self-heal path so the model (or operator) can allowlist a
|
|
2348
|
+
* known-benign label. v20 (B-2) single source: the scan builders embed this
|
|
2349
|
+
* constant verbatim; do NOT re-word it per call site (the three former copies
|
|
2350
|
+
* — memory inline, content inline, and the dead `THREAT_EXEMPT_HINT` export,
|
|
2351
|
+
* whose docblock still claimed a store-side append that memory-store/skill-store
|
|
2352
|
+
* had already removed — had drifted apart). The evolution-threat guard channel
|
|
2353
|
+
* returns the scan message verbatim, so the hint rides along there too.
|
|
2258
2354
|
*/
|
|
2259
|
-
const
|
|
2355
|
+
const THREAT_EXEMPTION_HINT = " A deployment that needs a specific label can exempt it via threatExemptLabels (see the README env/dial reference).";
|
|
2260
2356
|
//#endregion
|
|
2261
2357
|
//#region lib/types/memory-store.js
|
|
2262
2358
|
/**
|
|
@@ -2955,7 +3051,7 @@ function injectCatalogDescriptionCap(composition) {
|
|
|
2955
3051
|
const next = lines[j] ?? "";
|
|
2956
3052
|
if (next.trim() === "") break;
|
|
2957
3053
|
if (!/^\s/.test(next)) break;
|
|
2958
|
-
if (
|
|
3054
|
+
if (/^ {2}config:(\s|$)/.test(next)) hasConfig = true;
|
|
2959
3055
|
end = j;
|
|
2960
3056
|
}
|
|
2961
3057
|
if (hasConfig) continue;
|
|
@@ -3156,9 +3252,14 @@ const SECRET_PATTERNS = [
|
|
|
3156
3252
|
["gitlab token", /glpat-[A-Za-z0-9_-]{16,}/g],
|
|
3157
3253
|
["slack token", /xox[baprs]-[A-Za-z0-9-]{10,}/g],
|
|
3158
3254
|
["jwt", /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g],
|
|
3255
|
+
["npm token", /npm_[A-Za-z0-9]{20,}/g],
|
|
3256
|
+
["stripe key", /[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/g],
|
|
3257
|
+
["github fine-grained token", /github_pat_[A-Za-z0-9_]{20,}/g],
|
|
3258
|
+
["google api key", /AIza[0-9A-Za-z_-]{30,}/g],
|
|
3159
3259
|
["bearer credential", /Bearer[\s]+[a-z0-9._~+/=\-]{16,}/gi]
|
|
3160
3260
|
];
|
|
3161
3261
|
const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("(^|[^\\w-])([\\w-]{0,64}[_\\-])?((?:token|api[_-]?key|secret|password|passwd)(?:[_\\-][\\w-]{0,64})?)\\b([\\t ]*[:=][\\t ]*)([^\\r\\n]+)", "gi");
|
|
3262
|
+
const URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)@/gi;
|
|
3162
3263
|
/**
|
|
3163
3264
|
* Mask credential-shaped text before it crosses a session boundary.
|
|
3164
3265
|
* @param text - the text about to be sent to a model outside this session.
|
|
@@ -3167,7 +3268,12 @@ const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("(^|[^\\w-])([\\w-]
|
|
|
3167
3268
|
function redactSecrets(text) {
|
|
3168
3269
|
let out = text;
|
|
3169
3270
|
for (const [, pattern] of SECRET_PATTERNS) out = out.replace(pattern, "<redacted>");
|
|
3271
|
+
out = out.replace(URL_CREDENTIALS_PATTERN, (_match, lead) => `${lead ?? ""}<redacted>@`);
|
|
3170
3272
|
out = out.replace(INLINE_ASSIGNMENT_PATTERN, (_match, lead, prefix, key, separator) => `${lead ?? ""}${prefix ?? ""}${key ?? ""}${separator ?? ""}<redacted>`);
|
|
3273
|
+
out = out.split("\n").map((line) => {
|
|
3274
|
+
if (!/<redacted>/.test(line) && !/\baws\b|\bAKIA\b|\bsecret\b/i.test(line)) return line;
|
|
3275
|
+
return line.replace(/(?<![A-Za-z0-9/+=])[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])/g, "<redacted>");
|
|
3276
|
+
}).join("\n");
|
|
3171
3277
|
return out;
|
|
3172
3278
|
}
|
|
3173
3279
|
//#endregion
|
|
@@ -3616,6 +3722,8 @@ const MARKER_LOCK_NAMES = [
|
|
|
3616
3722
|
`.pinned${LOCK_SUFFIX}`,
|
|
3617
3723
|
`.hermes-managed${LOCK_SUFFIX}`
|
|
3618
3724
|
];
|
|
3725
|
+
/** v23 (ML-1): `.archive` retention window (see pruneExpiredArchives). */
|
|
3726
|
+
const ARCHIVE_RETENTION_DAYS = 365;
|
|
3619
3727
|
function markerPath(dir, marker) {
|
|
3620
3728
|
return join(dir, markerEntryName(marker));
|
|
3621
3729
|
}
|
|
@@ -4435,13 +4543,22 @@ var SkillLibrary = class {
|
|
|
4435
4543
|
ok: false,
|
|
4436
4544
|
message: `Skill "${normalized}" not found.`
|
|
4437
4545
|
};
|
|
4438
|
-
if (pinned)
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4546
|
+
if (pinned) {
|
|
4547
|
+
try {
|
|
4548
|
+
await this.io.writeText(marker, "");
|
|
4549
|
+
} catch (error) {
|
|
4550
|
+
if (!isCommittedOnly(error)) throw error;
|
|
4551
|
+
durabilityWarning = error instanceof Error ? error.message : String(error);
|
|
4552
|
+
}
|
|
4553
|
+
if (!await this.io.exists(join(dir, "SKILL.md"))) {
|
|
4554
|
+
await this.io.remove(marker).catch(() => {});
|
|
4555
|
+
if ((await this.io.list(dir).catch(() => [])).length === 0) await this.io.remove(dir).catch(() => {});
|
|
4556
|
+
return {
|
|
4557
|
+
ok: false,
|
|
4558
|
+
message: `Skill "${normalized}" was archived concurrently while pinning; the partial marker was removed — retry after the mover settles.`
|
|
4559
|
+
};
|
|
4560
|
+
}
|
|
4561
|
+
} else await this.io.remove(marker);
|
|
4445
4562
|
await this.audit(normalized, pinned ? "pin" : "unpin", null, null, pinned ? "pinned" : "unpinned");
|
|
4446
4563
|
return {
|
|
4447
4564
|
ok: true,
|
|
@@ -4487,6 +4604,10 @@ var SkillLibrary = class {
|
|
|
4487
4604
|
ok: false,
|
|
4488
4605
|
message: `Skill "${normalized}" already exists.`
|
|
4489
4606
|
};
|
|
4607
|
+
for (const entry of await this.io.list(this.root).catch(() => [])) if (typeof entry === "string" && entry !== normalized && entry.toLowerCase() === normalized.toLowerCase()) return {
|
|
4608
|
+
ok: false,
|
|
4609
|
+
message: `Skill "${normalized}" collides with the existing case-variant directory "${entry}" (skill names are lowercase-only); rename one of them.`
|
|
4610
|
+
};
|
|
4490
4611
|
const protection = await this.writeProtection(normalized, origin);
|
|
4491
4612
|
if (protection) return {
|
|
4492
4613
|
ok: false,
|
|
@@ -4530,7 +4651,17 @@ var SkillLibrary = class {
|
|
|
4530
4651
|
ok: false,
|
|
4531
4652
|
message: `Skill "${normalized}" already exists.`
|
|
4532
4653
|
};
|
|
4533
|
-
if (origin !== "foreground")
|
|
4654
|
+
if (origin !== "foreground") {
|
|
4655
|
+
await this.io.writeText(markerPath(dir, "hermes-managed"), "");
|
|
4656
|
+
if (!await this.io.exists(createPath)) {
|
|
4657
|
+
await this.io.remove(markerPath(dir, "hermes-managed")).catch(() => {});
|
|
4658
|
+
if ((await this.io.list(dir).catch(() => [])).length === 0) await this.io.remove(dir).catch(() => {});
|
|
4659
|
+
return {
|
|
4660
|
+
ok: false,
|
|
4661
|
+
message: `Skill "${normalized}" was archived concurrently while being created; the partial marker was removed — retry once the mover settles.`
|
|
4662
|
+
};
|
|
4663
|
+
}
|
|
4664
|
+
}
|
|
4534
4665
|
await this.audit(normalized, "create", null, onDisk, "created");
|
|
4535
4666
|
this.notifyMutation({
|
|
4536
4667
|
action: "create",
|
|
@@ -4863,9 +4994,7 @@ var SkillLibrary = class {
|
|
|
4863
4994
|
* stolen by the sweep. A dead-pid residue would otherwise permanently
|
|
4864
4995
|
* refuse archive/restore. */
|
|
4865
4996
|
async deleteStrandedLocks(dir) {
|
|
4866
|
-
await this.sweepLockIfStranded(join(dir,
|
|
4867
|
-
await this.sweepLockIfStranded(join(dir, ".pinned.lock"));
|
|
4868
|
-
await this.sweepLockIfStranded(join(dir, ".hermes-managed.lock"));
|
|
4997
|
+
for (const markerLock of MARKER_LOCK_NAMES) await this.sweepLockIfStranded(join(dir, markerLock));
|
|
4869
4998
|
for (const supportDir of SUPPORT_DIRS) {
|
|
4870
4999
|
let entries = [];
|
|
4871
5000
|
try {
|
|
@@ -4882,9 +5011,8 @@ var SkillLibrary = class {
|
|
|
4882
5011
|
async sweepLockIfStranded(lockPath) {
|
|
4883
5012
|
const body = await this.io.readText(lockPath).catch(() => null);
|
|
4884
5013
|
if (body === null) return;
|
|
4885
|
-
const
|
|
4886
|
-
if (
|
|
4887
|
-
const pid = Number(match[1]);
|
|
5014
|
+
const pid = parseLockBody(body);
|
|
5015
|
+
if (pid === null) return;
|
|
4888
5016
|
if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) return;
|
|
4889
5017
|
await this.io.remove(lockPath).catch(() => {});
|
|
4890
5018
|
}
|
|
@@ -4908,9 +5036,8 @@ var SkillLibrary = class {
|
|
|
4908
5036
|
throw new Error(`snapshot restore refused: cannot verify ${label} (${error instanceof Error ? error.message : String(error)}); a live writer may hold it`);
|
|
4909
5037
|
}
|
|
4910
5038
|
if (body === null) return;
|
|
4911
|
-
const
|
|
4912
|
-
if (
|
|
4913
|
-
const pid = Number(match[1]);
|
|
5039
|
+
const pid = parseLockBody(body);
|
|
5040
|
+
if (pid === null) return;
|
|
4914
5041
|
if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) throw new Error(`snapshot restore refused: ${label} is being written (write lock present); retry once the write completes`);
|
|
4915
5042
|
await this.io.remove(lockPath).catch(() => {});
|
|
4916
5043
|
}
|
|
@@ -5099,10 +5226,12 @@ var SkillLibrary = class {
|
|
|
5099
5226
|
};
|
|
5100
5227
|
const writes = [];
|
|
5101
5228
|
for (const reference of referenceWrites) {
|
|
5102
|
-
const
|
|
5229
|
+
const previous = await this.io.readText(reference.target).catch(() => null);
|
|
5230
|
+
const base = previous?.trimEnd() ?? "";
|
|
5103
5231
|
writes.push({
|
|
5104
5232
|
target: reference.target,
|
|
5105
|
-
content: base === "" ? reference.content : `${base}\n\n${reference.content}
|
|
5233
|
+
content: base === "" ? reference.content : `${base}\n\n${reference.content}`,
|
|
5234
|
+
expected: previous
|
|
5106
5235
|
});
|
|
5107
5236
|
}
|
|
5108
5237
|
if (mode === "append") {
|
|
@@ -5114,7 +5243,8 @@ var SkillLibrary = class {
|
|
|
5114
5243
|
};
|
|
5115
5244
|
writes.push({
|
|
5116
5245
|
target: join(targetDir, "SKILL.md"),
|
|
5117
|
-
content: merged
|
|
5246
|
+
content: merged,
|
|
5247
|
+
expected: freshTargetMd
|
|
5118
5248
|
});
|
|
5119
5249
|
} else {
|
|
5120
5250
|
const pointerLines = normalizedSources.map((source) => `\n${POINTER_LINE_PREFIX}${source}.md`).join("");
|
|
@@ -5126,7 +5256,8 @@ var SkillLibrary = class {
|
|
|
5126
5256
|
};
|
|
5127
5257
|
writes.push({
|
|
5128
5258
|
target: join(targetDir, "SKILL.md"),
|
|
5129
|
-
content: extended
|
|
5259
|
+
content: extended,
|
|
5260
|
+
expected: freshTargetMd
|
|
5130
5261
|
});
|
|
5131
5262
|
}
|
|
5132
5263
|
return await this.applyTreeChange({
|
|
@@ -5194,6 +5325,11 @@ var SkillLibrary = class {
|
|
|
5194
5325
|
message: `Restructure exceeds 5 moves.`
|
|
5195
5326
|
};
|
|
5196
5327
|
for (const move of moves) {
|
|
5328
|
+
const raw = move;
|
|
5329
|
+
if (raw === null || typeof raw !== "object") return {
|
|
5330
|
+
ok: false,
|
|
5331
|
+
message: "Every restructure move must be an object with a heading."
|
|
5332
|
+
};
|
|
5197
5333
|
if (typeof move.heading !== "string" || !move.heading.trim()) return {
|
|
5198
5334
|
ok: false,
|
|
5199
5335
|
message: "Every restructure move needs a non-empty heading."
|
|
@@ -5247,15 +5383,18 @@ var SkillLibrary = class {
|
|
|
5247
5383
|
const writes = [];
|
|
5248
5384
|
for (const entry of byRel.values()) {
|
|
5249
5385
|
const target = join(dir, ...entry.rel.split("/"));
|
|
5250
|
-
const
|
|
5386
|
+
const previous = await this.io.readText(target).catch(() => null);
|
|
5387
|
+
const base = previous?.trimEnd() ?? "";
|
|
5251
5388
|
writes.push({
|
|
5252
5389
|
target,
|
|
5253
|
-
content: base === "" ? entry.texts.join("\n\n") : `${base}\n\n${entry.texts.join("\n\n")}
|
|
5390
|
+
content: base === "" ? entry.texts.join("\n\n") : `${base}\n\n${entry.texts.join("\n\n")}`,
|
|
5391
|
+
expected: previous
|
|
5254
5392
|
});
|
|
5255
5393
|
}
|
|
5256
5394
|
writes.push({
|
|
5257
5395
|
target: join(dir, "SKILL.md"),
|
|
5258
|
-
content: finalMd
|
|
5396
|
+
content: finalMd,
|
|
5397
|
+
expected: md
|
|
5259
5398
|
});
|
|
5260
5399
|
const result = await this.applyTreeChange({
|
|
5261
5400
|
name,
|
|
@@ -5321,7 +5460,8 @@ var SkillLibrary = class {
|
|
|
5321
5460
|
landing.push({
|
|
5322
5461
|
target: write.target,
|
|
5323
5462
|
content: write.content,
|
|
5324
|
-
previous
|
|
5463
|
+
previous,
|
|
5464
|
+
expected: write.expected
|
|
5325
5465
|
});
|
|
5326
5466
|
}
|
|
5327
5467
|
const written = [];
|
|
@@ -5329,7 +5469,18 @@ var SkillLibrary = class {
|
|
|
5329
5469
|
try {
|
|
5330
5470
|
for (const entry of landing) {
|
|
5331
5471
|
try {
|
|
5332
|
-
|
|
5472
|
+
if (this.transact) {
|
|
5473
|
+
const baseline = entry.expected === void 0 ? entry.previous : entry.expected;
|
|
5474
|
+
const drift = { seen: false };
|
|
5475
|
+
await this.transact(this.io, entry.target, (current) => {
|
|
5476
|
+
if (current !== baseline) {
|
|
5477
|
+
drift.seen = true;
|
|
5478
|
+
return current;
|
|
5479
|
+
}
|
|
5480
|
+
return entry.content;
|
|
5481
|
+
});
|
|
5482
|
+
if (drift.seen) throw new Error(`concurrent modification detected: ${entry.target} changed after the plan was computed (a concurrent writer won the race); no further writes were performed`);
|
|
5483
|
+
} else await this.io.writeText(entry.target, entry.content);
|
|
5333
5484
|
} catch (error) {
|
|
5334
5485
|
if (error?.committed !== true) throw error;
|
|
5335
5486
|
durabilityWarning = error instanceof Error ? error.message : String(error);
|
|
@@ -5544,19 +5695,54 @@ var SkillLibrary = class {
|
|
|
5544
5695
|
};
|
|
5545
5696
|
}
|
|
5546
5697
|
/**
|
|
5698
|
+
* v23 (ML-1): `.archive` retention. Archived skills are recoverable history,
|
|
5699
|
+
* but nothing bounded their growth — the curator auto-archives idle skills,
|
|
5700
|
+
* consolidation and manual deletes take the same path, and every snapshot
|
|
5701
|
+
* copies the whole `.archive` (keep-5 retention amplifies it ×6). Entries
|
|
5702
|
+
* older than this many days are pruned at snapshot time. Generous by
|
|
5703
|
+
* design: a year-old auto-archive is effectively dead recoverability.
|
|
5704
|
+
* Backends without the mtime probe skip pruning (no false deletes on
|
|
5705
|
+
* unknown age).
|
|
5706
|
+
*/
|
|
5707
|
+
async pruneExpiredArchives() {
|
|
5708
|
+
const archiveRoot = join(this.root, ".archive");
|
|
5709
|
+
if (!this.io.mtime) return;
|
|
5710
|
+
let entries = [];
|
|
5711
|
+
try {
|
|
5712
|
+
entries = await this.io.list(archiveRoot);
|
|
5713
|
+
} catch {
|
|
5714
|
+
return;
|
|
5715
|
+
}
|
|
5716
|
+
const cutoff = Date.now() - ARCHIVE_RETENTION_DAYS * 864e5;
|
|
5717
|
+
for (const entry of entries) {
|
|
5718
|
+
const entryPath = join(archiveRoot, entry);
|
|
5719
|
+
const mtime = await this.io.mtime(entryPath).catch(() => null);
|
|
5720
|
+
if (mtime === null || mtime > cutoff) continue;
|
|
5721
|
+
await this.io.remove(entryPath).catch(() => {});
|
|
5722
|
+
console.warn(`skill-store: pruned archived skill "${entry}" (older than ${ARCHIVE_RETENTION_DAYS} days; recoverable from snapshots until they rotate)`);
|
|
5723
|
+
}
|
|
5724
|
+
}
|
|
5725
|
+
/**
|
|
5547
5726
|
* Snapshot the recoverable skills state: active tree, usage/suppression
|
|
5548
5727
|
* sidecars, `.archive/` and caller-supplied extras. `extras` are opaque
|
|
5549
5728
|
* side files the Snapshot owner cares about (curator state); they are
|
|
5550
5729
|
* listed in the manifest and only those names are ever read back.
|
|
5551
5730
|
*/
|
|
5552
5731
|
async snapshotAll(reason = "pre-mutation", extras = []) {
|
|
5732
|
+
await this.pruneExpiredArchives();
|
|
5553
5733
|
const backupRoot = join(this.root, ".backups");
|
|
5554
5734
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
5555
5735
|
let dest = join(backupRoot, `skills-${stamp}`);
|
|
5556
5736
|
while (await this.io.exists(dest)) dest = join(backupRoot, `skills-${stamp}-${Math.random().toString(36).slice(2, 8)}`);
|
|
5557
5737
|
try {
|
|
5558
5738
|
const names = await listNames(this.root, this.io);
|
|
5559
|
-
const
|
|
5739
|
+
const skipped = [];
|
|
5740
|
+
const copyable = [];
|
|
5741
|
+
for (const name of names) if (await this.hasWriteLock(this.dirOf(name))) {
|
|
5742
|
+
console.warn(`skill-store: snapshot skipped "${name}" — a byte-writer holds its write lock; the skill is recorded as skipped in the manifest`);
|
|
5743
|
+
skipped.push(name);
|
|
5744
|
+
} else copyable.push(name);
|
|
5745
|
+
const copyFailure = (await Promise.allSettled(copyable.map(async (name) => {
|
|
5560
5746
|
await this.io.copy(this.dirOf(name), join(dest, name));
|
|
5561
5747
|
}))).find((result) => result.status === "rejected");
|
|
5562
5748
|
if (copyFailure) throw copyFailure.reason;
|
|
@@ -5581,7 +5767,8 @@ var SkillLibrary = class {
|
|
|
5581
5767
|
await this.io.writeText(join(dest, "manifest.json"), JSON.stringify({
|
|
5582
5768
|
reason,
|
|
5583
5769
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5584
|
-
skills:
|
|
5770
|
+
skills: copyable,
|
|
5771
|
+
skipped,
|
|
5585
5772
|
sidecars,
|
|
5586
5773
|
hasArchive,
|
|
5587
5774
|
extras: extraNames
|
|
@@ -5594,6 +5781,24 @@ var SkillLibrary = class {
|
|
|
5594
5781
|
return dest;
|
|
5595
5782
|
}
|
|
5596
5783
|
/** Read and normalize a snapshot manifest; null when the file is missing or unparsable. */
|
|
5784
|
+
/**
|
|
5785
|
+
* V26-03 (v25/v26): sanitize the manifest's `skipped` list before it can
|
|
5786
|
+
* reach a user-visible restore message. Entries must pass the same name
|
|
5787
|
+
* gate as snapshot entries (a corrupted or hand-edited manifest cannot
|
|
5788
|
+
* inject arbitrary text into the result), bounded to 50 entries of at most
|
|
5789
|
+
* 64 chars each (the name-rule maximum — real skill names always fit).
|
|
5790
|
+
*/
|
|
5791
|
+
sanitizeSkippedNames(raw) {
|
|
5792
|
+
if (!Array.isArray(raw)) return [];
|
|
5793
|
+
const out = [];
|
|
5794
|
+
for (const entry of raw) {
|
|
5795
|
+
if (typeof entry !== "string") continue;
|
|
5796
|
+
if (!this.safeSnapshotEntryName(entry)) continue;
|
|
5797
|
+
out.push(entry.length > 64 ? entry.slice(0, 64) : entry);
|
|
5798
|
+
if (out.length >= 50) break;
|
|
5799
|
+
}
|
|
5800
|
+
return out;
|
|
5801
|
+
}
|
|
5597
5802
|
async readSnapshotManifest(path) {
|
|
5598
5803
|
const raw = await this.io.readText(join(path, "manifest.json"));
|
|
5599
5804
|
if (raw === null) return null;
|
|
@@ -5604,6 +5809,7 @@ var SkillLibrary = class {
|
|
|
5604
5809
|
reason: typeof manifest.reason === "string" ? manifest.reason : "",
|
|
5605
5810
|
createdAt: typeof manifest.createdAt === "string" ? manifest.createdAt : "",
|
|
5606
5811
|
skills: manifest.skills,
|
|
5812
|
+
skipped: this.sanitizeSkippedNames(manifest.skipped),
|
|
5607
5813
|
sidecars: Array.isArray(manifest.sidecars) ? manifest.sidecars : [],
|
|
5608
5814
|
...typeof manifest.hasArchive === "boolean" ? { hasArchive: manifest.hasArchive } : {},
|
|
5609
5815
|
extras: Array.isArray(manifest.extras) ? manifest.extras : []
|
|
@@ -5631,7 +5837,14 @@ var SkillLibrary = class {
|
|
|
5631
5837
|
for (const name of entries.sort().reverse()) {
|
|
5632
5838
|
if (!name.startsWith("skills-")) continue;
|
|
5633
5839
|
const manifest = await this.readSnapshotManifest(join(backupRoot, name));
|
|
5634
|
-
if (manifest === null)
|
|
5840
|
+
if (manifest === null) {
|
|
5841
|
+
out.push({
|
|
5842
|
+
path: join(backupRoot, name),
|
|
5843
|
+
createdAt: "",
|
|
5844
|
+
reason: "unreadable or missing manifest (orphan snapshot)"
|
|
5845
|
+
});
|
|
5846
|
+
continue;
|
|
5847
|
+
}
|
|
5635
5848
|
out.push({
|
|
5636
5849
|
path: join(backupRoot, name),
|
|
5637
5850
|
createdAt: manifest.createdAt,
|
|
@@ -5673,8 +5886,9 @@ var SkillLibrary = class {
|
|
|
5673
5886
|
message: "No skill snapshot available."
|
|
5674
5887
|
};
|
|
5675
5888
|
const preRollbackPath = await this.snapshotAll("pre-rollback", extras);
|
|
5889
|
+
let skipped = [];
|
|
5676
5890
|
try {
|
|
5677
|
-
await this.restoreSnapshotIntoRoot(latest.path);
|
|
5891
|
+
skipped = await this.restoreSnapshotIntoRoot(latest.path);
|
|
5678
5892
|
} catch (error) {
|
|
5679
5893
|
const reason = error instanceof Error ? error.message : String(error);
|
|
5680
5894
|
try {
|
|
@@ -5695,9 +5909,10 @@ var SkillLibrary = class {
|
|
|
5695
5909
|
action: "restore",
|
|
5696
5910
|
name: "snapshot"
|
|
5697
5911
|
});
|
|
5912
|
+
const skippedNote = skipped.length === 0 ? "" : ` NOTE: ${skipped.length} skill(s) were skipped when this snapshot was taken (a live writer held their lock) and are NOT restored: ${skipped.join(", ")} — recover them from .backups if a copy exists.`;
|
|
5698
5913
|
return {
|
|
5699
5914
|
ok: true,
|
|
5700
|
-
message: `Restored skill tree from ${latest.path}`,
|
|
5915
|
+
message: `Restored skill tree from ${latest.path}.${skippedNote}`,
|
|
5701
5916
|
path: latest.path,
|
|
5702
5917
|
...snapshotExtras.length === 0 ? {} : { extras: snapshotExtras }
|
|
5703
5918
|
};
|
|
@@ -5762,7 +5977,8 @@ var SkillLibrary = class {
|
|
|
5762
5977
|
if (entry.startsWith(".")) continue;
|
|
5763
5978
|
await this.deleteStrandedLocks(join(this.root, entry));
|
|
5764
5979
|
}
|
|
5980
|
+
return manifest.skipped;
|
|
5765
5981
|
}
|
|
5766
5982
|
};
|
|
5767
5983
|
//#endregion
|
|
5768
|
-
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_BOOT_GRACE_SECONDS, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_CURATOR_REVIEW_MAX_TOKENS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_REVIEW_CONTEXT_MESSAGES, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_MESSAGE_CHARS, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_REVIEW_TIMEOUT_MS, 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, DRIFT_MAX_LINE_CHARS, DRIFT_SIGNALS_VERSION, DRIFT_SIGNAL_NOUNS, DSH_AUTHORING_STANDARDS, ENTRY_DELIMITER, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION, EVOLUTION_WRITE_TOOLS, EvolutionGateSet, FORBIDDEN_CONTROL_KEYS, HEALTH_STAMP_RE, LOCK_BODY_RE, LOCK_SUFFIX, LOW_QUALITY_THRESHOLD, MAINTAIN_OUTPUT_INSTRUCTION, MAINTAIN_PROMPT, MAX_DESCRIPTION_LENGTH, MAX_RESTRUCTURE_MOVES, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MEMORY_REVIEW_PROMPT, MIN_STAMP_BODY_CHARS, MUTATIONS_FILE_VERSION, MemoryStore, PATTERN_OVERLAP, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, QUALITY_WEIGHTS, RESTRUCTURE_TARGET_RE, SKILLS_GUIDANCE, SKILL_NAME_RE, SKILL_REVIEW_PLAN_PROMPT, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary,
|
|
5984
|
+
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_BOOT_GRACE_SECONDS, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_CURATOR_REVIEW_MAX_TOKENS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_REVIEW_CONTEXT_MESSAGES, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_MESSAGE_CHARS, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_REVIEW_TIMEOUT_MS, 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, DRIFT_MAX_LINE_CHARS, DRIFT_SIGNALS_VERSION, DRIFT_SIGNAL_NOUNS, DSH_AUTHORING_STANDARDS, ENTRY_DELIMITER, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION, EVOLUTION_WRITE_TOOLS, EvolutionGateSet, FORBIDDEN_CONTROL_KEYS, HEALTH_STAMP_RE, LOCK_BODY_RE, LOCK_SUFFIX, LOW_QUALITY_THRESHOLD, MAINTAIN_OUTPUT_INSTRUCTION, MAINTAIN_PROMPT, MAX_DESCRIPTION_LENGTH, MAX_RESTRUCTURE_MOVES, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MEMORY_REVIEW_PROMPT, MIN_STAMP_BODY_CHARS, MUTATIONS_FILE_VERSION, MemoryStore, PATTERN_OVERLAP, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, QUALITY_WEIGHTS, RESTRUCTURE_TARGET_RE, SKILLS_GUIDANCE, SKILL_NAME_RE, SKILL_REVIEW_PLAN_PROMPT, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary, THREAT_EXEMPTION_HINT, advanceReview, allowRowCollisions, appendEvolutionEvent, applyCuratorFields, applyCuratorLifecycleFields, applyCuratorMetaFields, assessStructureHealth, authoringFeedback, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, clampedNumber, composePresetComposition, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, createGateSet, duplicateHeadings, emptyRecord, evaluateThreat, eventsFile, evolutionEventPayloadIssue, evolutionHome, evolutionIoAdapter, evolutionRoot, findDriftSignal, foldCuratorFields, foldTurn, frontmatterBlock, frontmatterYamlUnsafeValues, getRecord, isProcessAlive, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadSuppressedNames, loadUsage, makeSerialQueue, markAgentCreated, markerEntryName, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, parseLockBody, pendingSelfCleanup, readEvolutionEvents, readEvolutionTimeline, recordMutation, redactSecrets, relatedSkillNames, renameWithRetry, renderCuratorReportMarkdown, resolveOrigins, resolveRootConfig, resolveSkillsRoot, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, transactIo, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, validateRestructureTarget, verifyPromptBundle, writeDurableTmp, yamlPlainScalarNeedsQuotes };
|
package/lib/types/events.d.ts
CHANGED
|
@@ -21,6 +21,15 @@ export interface EvolutionReviewScheduledEvent {
|
|
|
21
21
|
toolCalls: number;
|
|
22
22
|
userChars: number;
|
|
23
23
|
assistantChars: number;
|
|
24
|
+
/** V24-15 (v24): which delivery channel actually sent the review. The
|
|
25
|
+
* emission point was previously covered on only two of the four delivery
|
|
26
|
+
* paths (subagent success + completion inject), so a consumer on the
|
|
27
|
+
* default inject-mode deployment would have silently missed every cadence
|
|
28
|
+
* review. Every delivery path now emits with its channel:
|
|
29
|
+
* `'subagent'` (spawned review run), `'inject'` (prompt injected into the
|
|
30
|
+
* parent — direct, fallback, or deferred-drain), `'completion'`
|
|
31
|
+
* (completion-trigger prompt, direct or deferred-drain). */
|
|
32
|
+
channel?: 'subagent' | 'inject' | 'completion' | undefined;
|
|
24
33
|
}
|
|
25
34
|
export interface EvolutionPlanAppliedEvent {
|
|
26
35
|
/** Owning session (payload v2): process events carry no session envelope. */
|
package/lib/types/io.d.ts
CHANGED
|
@@ -45,11 +45,17 @@ export interface EvolutionIoLike {
|
|
|
45
45
|
* milliseconds since epoch, or `null` when unknown (unsupported backend,
|
|
46
46
|
* missing path, stat failure). Intended as a cheap invalidation stamp for a
|
|
47
47
|
* cached directory listing; a backend without it keeps event-driven
|
|
48
|
-
* invalidation only.
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
48
|
+
* invalidation only. v20 correction: the former "NO in-tree consumer" note
|
|
49
|
+
* (V9-07, 0.3.51) went stale — there are now FOUR in-tree consumers, and a
|
|
50
|
+
* custom backend that omits `mtime` degrades them silently (every call
|
|
51
|
+
* site is optional-call + null-fallback, so omission stays legal):
|
|
52
|
+
* - evolution-skill-catalog: root-mtime stamp on the summaries cache —
|
|
53
|
+
* the second, out-of-band invalidation signal next to the
|
|
54
|
+
* `evolution/skill-mutated` / `evolution/skills-refresh` events;
|
|
55
|
+
* - evolution-curator: run-report recency ordering (2 call sites);
|
|
56
|
+
* - evolution-commands: `.bak` freshness probe in the preset installer.
|
|
57
|
+
* With `mtime` absent, catalog invalidation degrades to purely
|
|
58
|
+
* event-driven. Register new consumers here (the seam contract).
|
|
53
59
|
*/
|
|
54
60
|
mtime?(this: void, path: string): Promise<number | null>;
|
|
55
61
|
}
|
|
@@ -84,6 +90,18 @@ export declare const pendingSelfCleanup: Map<string, string>;
|
|
|
84
90
|
* rethrows with a pointer at the usual causes instead of a bare errno.
|
|
85
91
|
* `fn` is the rename primitive, injectable for deterministic tests.
|
|
86
92
|
*
|
|
93
|
+
* v22 (LOCK-5) documented platform limit: POSIX rename-over-existing is
|
|
94
|
+
* atomic for concurrent READERS, but Windows MoveFileExW(REPLACE_EXISTING)
|
|
95
|
+
* can make the target briefly invisible (ENOENT) while the replacement is in
|
|
96
|
+
* flight. The retry budget above covers the WRITE side only — a LOCKLESS
|
|
97
|
+
* reader (`readText`/`readJson` on a path it does not hold the lock for) can
|
|
98
|
+
* observe that window and see "missing" for a file that was just committed.
|
|
99
|
+
* Verified harmless today: every lockless-read consumer treats the transient
|
|
100
|
+
* miss as self-healing state (no consumer persists a read of null into a
|
|
101
|
+
* write), and distinguishing that ENOENT from a genuinely absent file in
|
|
102
|
+
* `readText` would tax every ordinary missing-file probe. Revisit only if a
|
|
103
|
+
* consumer appears that must never transiently miss.
|
|
104
|
+
*
|
|
87
105
|
* @param tmp - the source path to rename.
|
|
88
106
|
* @param target - the destination path.
|
|
89
107
|
* @param fn - the rename primitive (defaults to `node:fs/promises.rename`).
|
|
@@ -119,8 +137,15 @@ export declare function isProcessAlive(pid: number): boolean;
|
|
|
119
137
|
export declare const LOCK_SUFFIX = ".lock";
|
|
120
138
|
/** Writer-lock body shape: a decimal pid, a colon, then the claim token. A
|
|
121
139
|
* torn body (no parsable pid) still matches the `\\d+:` prefix rule only when
|
|
122
|
-
* the pid part is intact, which is what the takeover probe needs.
|
|
140
|
+
* the pid part is intact, which is what the takeover probe needs. The capture
|
|
141
|
+
* group feeds `parseLockBody` (v20 A-1: skill-store's sweepers consume this
|
|
142
|
+
* helper instead of re-inlining a third regex copy — F-17 single-source). */
|
|
123
143
|
export declare const LOCK_BODY_RE: RegExp;
|
|
144
|
+
/** Parse a writer-lock body into its holder pid. `null` when the body does
|
|
145
|
+
* not have the `pid:token` shape at all (e.g. a user support file named
|
|
146
|
+
* `*.lock`) — callers leave such files alone. A shape-matching body always
|
|
147
|
+
* yields a number (possibly `0`, which `isProcessAlive` treats as dead). */
|
|
148
|
+
export declare function parseLockBody(body: string): number | null;
|
|
124
149
|
/**
|
|
125
150
|
* Build the Node IO backend. `lockAttempts` scales the write-lock retry budget
|
|
126
151
|
* (attempts × 50ms); the default 40 (~2s, rc.69) covers production contention,
|
|
@@ -77,6 +77,13 @@ export interface SnapshotManifest {
|
|
|
77
77
|
createdAt: string;
|
|
78
78
|
/** Active skill names at snapshot time. */
|
|
79
79
|
skills: string[];
|
|
80
|
+
/** V25-05 (v25): skills that were SKIPPED by the write-lock probe at
|
|
81
|
+
* snapshot time (a live byte-writer held them) — they are NOT in the
|
|
82
|
+
* snapshot directory and are NOT restored by a whole-tree restore, which
|
|
83
|
+
* clears the live tree and copies back only `skills`. Consumers must
|
|
84
|
+
* surface this list; restore reports it in its result message. Absent
|
|
85
|
+
* (empty) on pre-0.3.67 manifests. */
|
|
86
|
+
skipped: string[];
|
|
80
87
|
/** Co-copied sidecar file names (usage/suppression). */
|
|
81
88
|
sidecars: string[];
|
|
82
89
|
/** Whether `.archive/` was co-copied; absent on legacy manifests (do not touch archive on restore). */
|
|
@@ -474,6 +481,17 @@ export declare class SkillLibrary {
|
|
|
474
481
|
private writeSupportFileCore;
|
|
475
482
|
removeSupportFile(rawName: string, filePath: string, origin?: WriteOrigin): Promise<SkillActionResult>;
|
|
476
483
|
private removeSupportFileCore;
|
|
484
|
+
/**
|
|
485
|
+
* v23 (ML-1): `.archive` retention. Archived skills are recoverable history,
|
|
486
|
+
* but nothing bounded their growth — the curator auto-archives idle skills,
|
|
487
|
+
* consolidation and manual deletes take the same path, and every snapshot
|
|
488
|
+
* copies the whole `.archive` (keep-5 retention amplifies it ×6). Entries
|
|
489
|
+
* older than this many days are pruned at snapshot time. Generous by
|
|
490
|
+
* design: a year-old auto-archive is effectively dead recoverability.
|
|
491
|
+
* Backends without the mtime probe skip pruning (no false deletes on
|
|
492
|
+
* unknown age).
|
|
493
|
+
*/
|
|
494
|
+
private pruneExpiredArchives;
|
|
477
495
|
/**
|
|
478
496
|
* Snapshot the recoverable skills state: active tree, usage/suppression
|
|
479
497
|
* sidecars, `.archive/` and caller-supplied extras. `extras` are opaque
|
|
@@ -482,6 +500,14 @@ export declare class SkillLibrary {
|
|
|
482
500
|
*/
|
|
483
501
|
snapshotAll(reason?: string, extras?: SnapshotExtra[]): Promise<string>;
|
|
484
502
|
/** Read and normalize a snapshot manifest; null when the file is missing or unparsable. */
|
|
503
|
+
/**
|
|
504
|
+
* V26-03 (v25/v26): sanitize the manifest's `skipped` list before it can
|
|
505
|
+
* reach a user-visible restore message. Entries must pass the same name
|
|
506
|
+
* gate as snapshot entries (a corrupted or hand-edited manifest cannot
|
|
507
|
+
* inject arbitrary text into the result), bounded to 50 entries of at most
|
|
508
|
+
* 64 chars each (the name-rule maximum — real skill names always fit).
|
|
509
|
+
*/
|
|
510
|
+
private sanitizeSkippedNames;
|
|
485
511
|
readSnapshotManifest(path: string): Promise<SnapshotManifest | null>;
|
|
486
512
|
/** Keep only the newest N snapshots (Hermes keep=5 parity); older ones are removed outright. */
|
|
487
513
|
private retainSnapshots;
|
package/lib/types/threats.d.ts
CHANGED
|
@@ -61,14 +61,14 @@ export declare function scanMemoryThreats(text: string, maxScanChars?: number, o
|
|
|
61
61
|
/** User-facing block message for skill content writes. */
|
|
62
62
|
export declare function scanContentThreats(text: string, maxScanChars?: number, options?: ScanOptions): string | null;
|
|
63
63
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* message verbatim.
|
|
64
|
+
* WD2 (0.3.56): the shared tail of every user-facing threat block — names the
|
|
65
|
+
* deployable self-heal path so the model (or operator) can allowlist a
|
|
66
|
+
* known-benign label. v20 (B-2) single source: the scan builders embed this
|
|
67
|
+
* constant verbatim; do NOT re-word it per call site (the three former copies
|
|
68
|
+
* — memory inline, content inline, and the dead `THREAT_EXEMPT_HINT` export,
|
|
69
|
+
* whose docblock still claimed a store-side append that memory-store/skill-store
|
|
70
|
+
* had already removed — had drifted apart). The evolution-threat guard channel
|
|
71
|
+
* returns the scan message verbatim, so the hint rides along there too.
|
|
72
72
|
*/
|
|
73
|
-
export declare const
|
|
73
|
+
export declare const THREAT_EXEMPTION_HINT = " A deployment that needs a specific label can exempt it via threatExemptLabels (see the README env/dial reference).";
|
|
74
74
|
//# sourceMappingURL=threats.d.ts.map
|
package/package.json
CHANGED