@lmzhen/dsh-evolution-core 0.1.0-rc.2 → 0.1.0-rc.20
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 +706 -92
- package/lib/types/constants.d.ts +51 -0
- package/lib/types/curator.d.ts +27 -1
- package/lib/types/index.d.ts +3 -0
- package/lib/types/io.d.ts +0 -2
- package/lib/types/memory-store.d.ts +17 -1
- package/lib/types/mutations.d.ts +24 -0
- package/lib/types/prompts.d.ts +10 -2
- package/lib/types/quality.d.ts +57 -0
- package/lib/types/skill-store.d.ts +41 -13
- package/lib/types/state-store.d.ts +7 -0
- package/lib/types/threats.d.ts +16 -4
- package/lib/types/usage.d.ts +10 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { dirname, join } from "node:path";
|
|
1
|
+
import { basename, dirname, join } from "node:path";
|
|
2
2
|
import { cp, 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";
|
|
@@ -72,10 +72,6 @@ function nodeEvolutionIo() {
|
|
|
72
72
|
}
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
|
-
/** Absolute path helper kept separate so stores stay platform-correct. */
|
|
76
|
-
function childPath(parent, ...parts) {
|
|
77
|
-
return join(parent, ...parts);
|
|
78
|
-
}
|
|
79
75
|
//#endregion
|
|
80
76
|
//#region lib/types/usage.js
|
|
81
77
|
/**
|
|
@@ -155,15 +151,99 @@ function latestActivityAt(record) {
|
|
|
155
151
|
if (values.length === 0) return null;
|
|
156
152
|
return values.sort().reverse()[0] ?? null;
|
|
157
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* Curator suppression sidecar: built-in skills the curator has archived stay
|
|
156
|
+
* suppressed across re-seeds, so the lifecycle never fights a re-created
|
|
157
|
+
* bundled skill. Best-effort load/save, mirroring the usage sidecar posture.
|
|
158
|
+
* Versioned shape ({ version, names }) with legacy plain-array compat.
|
|
159
|
+
*/
|
|
160
|
+
const SUPPRESSED_FILE_VERSION = 1;
|
|
161
|
+
function suppressedFile(root) {
|
|
162
|
+
return join(root, ".curator-suppressed.json");
|
|
163
|
+
}
|
|
164
|
+
async function loadSuppressedNames(root, io = nodeEvolutionIo()) {
|
|
165
|
+
const raw = await io.readText(suppressedFile(root));
|
|
166
|
+
if (raw === null) return /* @__PURE__ */ new Set();
|
|
167
|
+
try {
|
|
168
|
+
const parsed = JSON.parse(raw);
|
|
169
|
+
const names = Array.isArray(parsed) ? parsed : typeof parsed === "object" && parsed !== null && Array.isArray(parsed.names) ? parsed.names : [];
|
|
170
|
+
return new Set(names.filter((entry) => typeof entry === "string"));
|
|
171
|
+
} catch {
|
|
172
|
+
return /* @__PURE__ */ new Set();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function saveSuppressedNames(root, names, io = nodeEvolutionIo()) {
|
|
176
|
+
await io.writeText(suppressedFile(root), JSON.stringify({
|
|
177
|
+
version: 1,
|
|
178
|
+
names: [...names].sort()
|
|
179
|
+
}, null, 2));
|
|
180
|
+
}
|
|
181
|
+
//#endregion
|
|
182
|
+
//#region lib/types/constants.js
|
|
183
|
+
/**
|
|
184
|
+
* Shared constants for the dsh-evolution plugin family.
|
|
185
|
+
*
|
|
186
|
+
* Two classes of value live here, deliberately separated by section so future
|
|
187
|
+
* edits do not blur the semantic boundary:
|
|
188
|
+
*
|
|
189
|
+
* 1. **Fixed protocol/format/security invariants** — changing these breaks an
|
|
190
|
+
* on-disk format, a naming/format contract, a path-security boundary, or a
|
|
191
|
+
* cross-component invariant. They are NOT exposed as deployment config.
|
|
192
|
+
*
|
|
193
|
+
* 2. **Cross-package shared tunable defaults** — the same semantic default is
|
|
194
|
+
* read (with a config override path) by more than one package (e.g.
|
|
195
|
+
* `evolution-policy` and `evolution-curator` both default `staleAfterDays`
|
|
196
|
+
* to 30). Centralizing them here means one authoritative default: a config
|
|
197
|
+
* override still applies per package, but the fallback is single-sourced.
|
|
198
|
+
*
|
|
199
|
+
* Package-private tunables (used by exactly one package) stay in that package,
|
|
200
|
+
* not here — see evolution-replay's `DEFAULT_WEIGHTS` and evolution-feedback's
|
|
201
|
+
* threshold, which are intentionally left where they are used.
|
|
202
|
+
* @module @lmzhen/dsh-evolution-core
|
|
203
|
+
*/
|
|
204
|
+
/** Skill frontmatter `name` validated for the file name (lowercase + hyphen). */
|
|
205
|
+
const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
206
|
+
/** Allowed skill support-file subdirectories (path-traversal boundary). */
|
|
207
|
+
const SUPPORT_DIRS = [
|
|
208
|
+
"references",
|
|
209
|
+
"templates",
|
|
210
|
+
"scripts",
|
|
211
|
+
"assets"
|
|
212
|
+
];
|
|
213
|
+
/** Delimiter between durable memory entries (on-disk storage format). */
|
|
214
|
+
const ENTRY_DELIMITER = "\n§\n";
|
|
215
|
+
/** Built-in skill names the curator must never lifecycle-manage. */
|
|
216
|
+
const PROTECTED_BUILTIN_SKILLS = new Set(["plan"]);
|
|
217
|
+
const MAX_SKILL_NAME_LENGTH = 64;
|
|
218
|
+
const MAX_DESCRIPTION_LENGTH = 1024;
|
|
219
|
+
const MAX_SKILL_CONTENT_CHARS = 1e5;
|
|
220
|
+
const MAX_SKILL_FILE_BYTES = 1048576;
|
|
221
|
+
const DEFAULT_REVIEW_MEMORY_INTERVAL = 10;
|
|
222
|
+
const DEFAULT_REVIEW_SKILL_INTERVAL = 10;
|
|
223
|
+
/** Skill-review completion channel trigger mode: 'cadence' | 'completion' | 'both'. */
|
|
224
|
+
const DEFAULT_SKILL_REVIEW_TRIGGER = "both";
|
|
225
|
+
/** Cumulative session tool calls before a session counts as "proven long" for the completion channel. */
|
|
226
|
+
const DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS = 20;
|
|
227
|
+
const DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS = 3;
|
|
228
|
+
const DEFAULT_SUBSTANTIVE_MIN_USER_CHARS = 200;
|
|
229
|
+
const DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS = 500;
|
|
230
|
+
const DEFAULT_MAX_OPS_PER_PLAN = 32;
|
|
231
|
+
const DEFAULT_CURATOR_INTERVAL_HOURS = 168;
|
|
232
|
+
const DEFAULT_MIN_IDLE_HOURS = 2;
|
|
233
|
+
const DEFAULT_STALE_AFTER_DAYS = 30;
|
|
234
|
+
const DEFAULT_ARCHIVE_AFTER_DAYS = 90;
|
|
235
|
+
const DEFAULT_MEMORY_CHAR_LIMIT = 2200;
|
|
236
|
+
const DEFAULT_USER_CHAR_LIMIT = 1375;
|
|
237
|
+
const DEFAULT_SKILL_CONTENT_CHARS = 1e5;
|
|
158
238
|
//#endregion
|
|
159
239
|
//#region lib/types/curator.js
|
|
160
240
|
/**
|
|
161
241
|
* Deterministic skill curator: active → stale → archived transitions.
|
|
162
242
|
* Pure function; file moves are performed by SkillLibrary.
|
|
163
243
|
*/
|
|
164
|
-
const PROTECTED_BUILTIN_SKILLS = new Set(["plan"]);
|
|
165
244
|
function buildCuratorRunReport(input) {
|
|
166
245
|
return {
|
|
246
|
+
schemaVersion: 1,
|
|
167
247
|
runId: input.runId,
|
|
168
248
|
startedAt: input.startedAt,
|
|
169
249
|
finishedAt: input.finishedAt,
|
|
@@ -175,6 +255,47 @@ function buildCuratorRunReport(input) {
|
|
|
175
255
|
...input.snapshotPath === void 0 ? {} : { snapshotPath: input.snapshotPath }
|
|
176
256
|
};
|
|
177
257
|
}
|
|
258
|
+
const NOMINATION_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
259
|
+
/**
|
|
260
|
+
* Parse the curator LLM's YAML nomination block (consolidations + prunings).
|
|
261
|
+
* Line-oriented and lenient by design: the LLM output is advisory, every name
|
|
262
|
+
* is re-validated against the tree before any file move happens downstream.
|
|
263
|
+
*/
|
|
264
|
+
function parseCuratorNominations(text) {
|
|
265
|
+
const prunings = [];
|
|
266
|
+
const consolidations = [];
|
|
267
|
+
let section = null;
|
|
268
|
+
let currentFrom = "";
|
|
269
|
+
for (const line of text.split("\n")) {
|
|
270
|
+
const consolidated = /^\s*-\s*from:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
|
|
271
|
+
if (consolidated) {
|
|
272
|
+
section = "consolidations";
|
|
273
|
+
currentFrom = consolidated[1] ?? "";
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const into = /^\s*into:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
|
|
277
|
+
if (into) {
|
|
278
|
+
const intoName = into[1] ?? "";
|
|
279
|
+
if (section === "consolidations" && currentFrom !== "" && currentFrom !== intoName) consolidations.push({
|
|
280
|
+
from: currentFrom,
|
|
281
|
+
into: intoName
|
|
282
|
+
});
|
|
283
|
+
currentFrom = "";
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const pruned = /^\s*-\s*name:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
|
|
287
|
+
if (pruned) {
|
|
288
|
+
section = "prunings";
|
|
289
|
+
const name = pruned[1];
|
|
290
|
+
if (name) prunings.push(name);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const valid = (name) => NOMINATION_NAME_RE.test(name);
|
|
294
|
+
return {
|
|
295
|
+
prunings: prunings.filter(valid),
|
|
296
|
+
consolidations: consolidations.filter((item) => valid(item.from) && valid(item.into))
|
|
297
|
+
};
|
|
298
|
+
}
|
|
178
299
|
function daysSince(iso, created, now) {
|
|
179
300
|
return (now - new Date(iso ?? created).getTime()) / 864e5;
|
|
180
301
|
}
|
|
@@ -188,7 +309,10 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
|
|
|
188
309
|
for (const [name, record] of usage) {
|
|
189
310
|
if (record.pinned) continue;
|
|
190
311
|
if (config.excludeSkillNames?.has(name)) continue;
|
|
191
|
-
if (
|
|
312
|
+
if (config.suppressedNames?.has(name)) continue;
|
|
313
|
+
if (config.referencedSkillNames?.has(name)) continue;
|
|
314
|
+
const bundled = config.bundledNames?.has(name) === true;
|
|
315
|
+
if (!(record.created_by === "agent" || config.manageUnmanaged === true) && !(config.pruneBuiltins === true && bundled)) continue;
|
|
192
316
|
if (PROTECTED_BUILTIN_SKILLS.has(name)) continue;
|
|
193
317
|
if (record.state === "archived") continue;
|
|
194
318
|
const age = daysSince(null, record.created_at, now.getTime());
|
|
@@ -417,10 +541,12 @@ const SCOPE_ORDER = {
|
|
|
417
541
|
context: 2,
|
|
418
542
|
strict: 3
|
|
419
543
|
};
|
|
544
|
+
const NO_SCAN_OPTIONS = {};
|
|
420
545
|
/**
|
|
421
546
|
* Scan text at `scope`. Patterns are cumulative: `strict` includes all scopes.
|
|
547
|
+
* `options.excludeLabels` removes matching patterns without changing `scope`.
|
|
422
548
|
*/
|
|
423
|
-
function scanThreats(text, scope = "strict", maxScanChars = 65536) {
|
|
549
|
+
function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
|
|
424
550
|
const findings = [];
|
|
425
551
|
if (ZERO_WIDTH_CHARS.test(text)) findings.push({
|
|
426
552
|
label: "unicode_zero_width",
|
|
@@ -433,8 +559,10 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536) {
|
|
|
433
559
|
scope
|
|
434
560
|
});
|
|
435
561
|
const normalized = text.normalize("NFKC").slice(0, maxScanChars);
|
|
562
|
+
const excluded = new Set(options.excludeLabels ?? []);
|
|
436
563
|
for (const pattern of PATTERNS) {
|
|
437
564
|
if (SCOPE_ORDER[pattern.scope] > SCOPE_ORDER[scope]) continue;
|
|
565
|
+
if (excluded.has(pattern.label)) continue;
|
|
438
566
|
if (pattern.regex.test(normalized)) findings.push({
|
|
439
567
|
label: pattern.label,
|
|
440
568
|
category: pattern.category,
|
|
@@ -444,24 +572,24 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536) {
|
|
|
444
572
|
return findings;
|
|
445
573
|
}
|
|
446
574
|
/** Blocking policy: any hit blocks. `severity` is deliberately not a gate. */
|
|
447
|
-
function evaluateThreat(text, scope = "strict", maxScanChars = 65536) {
|
|
448
|
-
const findings = scanThreats(text, scope, maxScanChars);
|
|
575
|
+
function evaluateThreat(text, scope = "strict", maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
|
|
576
|
+
const findings = scanThreats(text, scope, maxScanChars, options);
|
|
449
577
|
return {
|
|
450
578
|
blocked: findings.length > 0,
|
|
451
579
|
findings
|
|
452
580
|
};
|
|
453
581
|
}
|
|
454
582
|
/** User-facing block message for memory writes. */
|
|
455
|
-
function scanMemoryThreats(text, maxScanChars = 65536) {
|
|
456
|
-
const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars);
|
|
583
|
+
function scanMemoryThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
|
|
584
|
+
const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
|
|
457
585
|
if (!blocked) return null;
|
|
458
586
|
const pattern = findings.find((f) => f.category !== "unicode_obfuscation");
|
|
459
587
|
if (pattern) return `Blocked by security scan (${pattern.label}). Rephrase without instruction-like language.`;
|
|
460
588
|
return "Blocked by security scan: invisible or potentially malicious Unicode detected.";
|
|
461
589
|
}
|
|
462
590
|
/** User-facing block message for skill content writes. */
|
|
463
|
-
function scanContentThreats(text, maxScanChars = 65536) {
|
|
464
|
-
const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars);
|
|
591
|
+
function scanContentThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
|
|
592
|
+
const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
|
|
465
593
|
if (!blocked) return null;
|
|
466
594
|
return `Blocked by security scan (${findings[0]?.label ?? "unknown"}). This content appears to contain potentially malicious instructions.`;
|
|
467
595
|
}
|
|
@@ -471,7 +599,6 @@ function scanContentThreats(text, maxScanChars = 65536) {
|
|
|
471
599
|
* File-backed durable memory with Hermes-compatible semantics.
|
|
472
600
|
* Stores are MEMORY.md and USER.md under $DSH_HOME/memories (~/.dsh/memories).
|
|
473
601
|
*/
|
|
474
|
-
const ENTRY_DELIMITER = "\n§\n";
|
|
475
602
|
function memoryRoot(env = process.env) {
|
|
476
603
|
return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "memories");
|
|
477
604
|
}
|
|
@@ -534,6 +661,30 @@ var MemoryStore = class {
|
|
|
534
661
|
limit: this.limitFor(target)
|
|
535
662
|
};
|
|
536
663
|
}
|
|
664
|
+
/** Percent-based storage hint appended to success message once the target is ≥80% full. */
|
|
665
|
+
storageHint(target, chars) {
|
|
666
|
+
const limit = this.limitFor(target);
|
|
667
|
+
if (limit <= 0) return "";
|
|
668
|
+
const percent = Math.floor(chars * 100 / limit);
|
|
669
|
+
return percent >= 80 ? ` ⚠️ Storage at ${percent}% (${chars}/${limit} chars).` : "";
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Best-effort copy of the drifted on-disk file to `<file>.bak.<stamp>` before
|
|
673
|
+
* refusing the write, so an external edit stays recoverable. Failure to back
|
|
674
|
+
* up does not change the refusal semantics.
|
|
675
|
+
*/
|
|
676
|
+
async backupDrift(target) {
|
|
677
|
+
const path = fileFor(this.root, target);
|
|
678
|
+
const raw = await this.io.readText(path);
|
|
679
|
+
if (raw === null) return null;
|
|
680
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 14);
|
|
681
|
+
try {
|
|
682
|
+
await this.io.writeText(`${path}.bak.${stamp}`, raw);
|
|
683
|
+
return `${path}.bak.${stamp}`;
|
|
684
|
+
} catch {
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
537
688
|
async add(target, facts) {
|
|
538
689
|
const content = facts.trim();
|
|
539
690
|
if (!content) return {
|
|
@@ -556,7 +707,7 @@ var MemoryStore = class {
|
|
|
556
707
|
this.resetFailures();
|
|
557
708
|
return {
|
|
558
709
|
ok: true,
|
|
559
|
-
message:
|
|
710
|
+
message: `Entry already exists (no duplicate added).${this.storageHint(target, entries.join(ENTRY_DELIMITER).length)}`,
|
|
560
711
|
entries,
|
|
561
712
|
chars: entries.join(ENTRY_DELIMITER).length,
|
|
562
713
|
limit: this.limitFor(target)
|
|
@@ -569,7 +720,7 @@ var MemoryStore = class {
|
|
|
569
720
|
this.resetFailures();
|
|
570
721
|
return {
|
|
571
722
|
ok: true,
|
|
572
|
-
message:
|
|
723
|
+
message: `Entry added.${this.storageHint(target, total)}`,
|
|
573
724
|
entries: next,
|
|
574
725
|
chars: total,
|
|
575
726
|
limit: this.limitFor(target)
|
|
@@ -608,13 +759,16 @@ var MemoryStore = class {
|
|
|
608
759
|
limit: this.limitFor(target)
|
|
609
760
|
};
|
|
610
761
|
}
|
|
611
|
-
if (await this.detectDrift(target))
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
762
|
+
if (await this.detectDrift(target)) {
|
|
763
|
+
const backup = await this.backupDrift(target);
|
|
764
|
+
return {
|
|
765
|
+
ok: false,
|
|
766
|
+
message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
|
|
767
|
+
entries: [],
|
|
768
|
+
chars: 0,
|
|
769
|
+
limit: this.limitFor(target)
|
|
770
|
+
};
|
|
771
|
+
}
|
|
618
772
|
const entries = await this.read(target);
|
|
619
773
|
const matches = entries.map((entry, index) => ({
|
|
620
774
|
entry,
|
|
@@ -638,7 +792,7 @@ var MemoryStore = class {
|
|
|
638
792
|
this.resetFailures();
|
|
639
793
|
return {
|
|
640
794
|
ok: true,
|
|
641
|
-
message: `Entry ${action === "remove" ? "removed" : "replaced"}
|
|
795
|
+
message: `Entry ${action === "remove" ? "removed" : "replaced"}.${this.storageHint(target, total)}`,
|
|
642
796
|
entries: next,
|
|
643
797
|
chars: total,
|
|
644
798
|
limit: this.limitFor(target)
|
|
@@ -652,13 +806,16 @@ var MemoryStore = class {
|
|
|
652
806
|
chars: 0,
|
|
653
807
|
limit: this.limitFor(target)
|
|
654
808
|
};
|
|
655
|
-
if (await this.detectDrift(target))
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
809
|
+
if (await this.detectDrift(target)) {
|
|
810
|
+
const backup = await this.backupDrift(target);
|
|
811
|
+
return {
|
|
812
|
+
ok: false,
|
|
813
|
+
message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
|
|
814
|
+
entries: [],
|
|
815
|
+
chars: 0,
|
|
816
|
+
limit: this.limitFor(target)
|
|
817
|
+
};
|
|
818
|
+
}
|
|
662
819
|
const entries = await this.read(target);
|
|
663
820
|
const working = [...entries];
|
|
664
821
|
for (const [index, op] of operations.entries()) {
|
|
@@ -731,7 +888,7 @@ var MemoryStore = class {
|
|
|
731
888
|
this.resetFailures();
|
|
732
889
|
return {
|
|
733
890
|
ok: true,
|
|
734
|
-
message: `Applied ${operations.length} operation(s)
|
|
891
|
+
message: `Applied ${operations.length} operation(s).${this.storageHint(target, total)}`,
|
|
735
892
|
entries: working,
|
|
736
893
|
chars: total,
|
|
737
894
|
limit: this.limitFor(target)
|
|
@@ -762,13 +919,58 @@ var MemoryStore = class {
|
|
|
762
919
|
await this.write("memory", snapshot.memory);
|
|
763
920
|
await this.write("user", snapshot.user);
|
|
764
921
|
}
|
|
922
|
+
/**
|
|
923
|
+
* Detect on-disk drift: true when the file is not in the canonical
|
|
924
|
+
* `render(normalizeEntries(raw))` form. This catches structural anomalies
|
|
925
|
+
* the writer would quietly normalize away (empty/`§`-only entries, stray
|
|
926
|
+
* blank lines, leading/trailing delimiters) that indicate the file was
|
|
927
|
+
* edited outside MemoryStore. Purely single-canonical content reaches the
|
|
928
|
+
* same serialization and returns false, so a normal write is never flagged.
|
|
929
|
+
*/
|
|
765
930
|
async detectDrift(target) {
|
|
766
931
|
const raw = await this.io.readText(fileFor(this.root, target));
|
|
767
932
|
if (raw === null) return false;
|
|
768
|
-
return normalizeEntries(raw)
|
|
933
|
+
return render(normalizeEntries(raw)) !== raw;
|
|
769
934
|
}
|
|
770
935
|
};
|
|
771
936
|
//#endregion
|
|
937
|
+
//#region lib/types/mutations.js
|
|
938
|
+
/**
|
|
939
|
+
* Curator/author audit trail: `.mutations.json` records every skill mutation
|
|
940
|
+
* with before/after content hashes so any automated edit is reviewable and
|
|
941
|
+
* replayable. Best-effort persistence, mirroring the usage sidecar posture.
|
|
942
|
+
* @module @lmzhen/dsh-evolution-core
|
|
943
|
+
*/
|
|
944
|
+
const DEFAULT_MUTATION_CAP = 500;
|
|
945
|
+
/** Version of the `.mutations.json` file shape; writers always emit the current one. */
|
|
946
|
+
const MUTATIONS_FILE_VERSION = 1;
|
|
947
|
+
function mutationsFile(root) {
|
|
948
|
+
return join(root, ".mutations.json");
|
|
949
|
+
}
|
|
950
|
+
function contentHash(content) {
|
|
951
|
+
return createHash("sha256").update(content).digest("hex");
|
|
952
|
+
}
|
|
953
|
+
async function loadMutations(root, io = nodeEvolutionIo()) {
|
|
954
|
+
const raw = await io.readText(mutationsFile(root));
|
|
955
|
+
if (raw === null) return [];
|
|
956
|
+
try {
|
|
957
|
+
const parsed = JSON.parse(raw);
|
|
958
|
+
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");
|
|
959
|
+
} catch {
|
|
960
|
+
return [];
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
/** Append one record, trim to `cap`, and write atomically (versioned shape). */
|
|
964
|
+
async function recordMutation(root, io, record, cap = 500) {
|
|
965
|
+
const existing = await loadMutations(root, io);
|
|
966
|
+
existing.push(record);
|
|
967
|
+
const trimmed = existing.length > cap ? existing.slice(existing.length - cap) : existing;
|
|
968
|
+
await io.writeText(mutationsFile(root), JSON.stringify({
|
|
969
|
+
version: 1,
|
|
970
|
+
records: trimmed
|
|
971
|
+
}, null, 2));
|
|
972
|
+
}
|
|
973
|
+
//#endregion
|
|
772
974
|
//#region lib/types/prompts.js
|
|
773
975
|
/**
|
|
774
976
|
* Review and curation prompts adapted from Hermes Agent
|
|
@@ -780,7 +982,13 @@ var MemoryStore = class {
|
|
|
780
982
|
* bundle digest before spending a model call, so a partially-patched
|
|
781
983
|
* deployment fails closed instead of silently running a truncated prompt.
|
|
782
984
|
*/
|
|
783
|
-
|
|
985
|
+
/**
|
|
986
|
+
* Prompt bundle identity. Bump both id and version whenever a prompt's text
|
|
987
|
+
* changes semantically: the bundle digest is the fail-closed signal for
|
|
988
|
+
* review workers, so a stale id across deployments must be distinguishable.
|
|
989
|
+
*/
|
|
990
|
+
const PROMPT_BUNDLE_ID = "dsh-evolution@2";
|
|
991
|
+
const PROMPT_BUNDLE_VERSION = 2;
|
|
784
992
|
const MEMORY_REVIEW_PROMPT = `[Auto-review — Memory]
|
|
785
993
|
Review the conversation above and consider saving to memory if appropriate.
|
|
786
994
|
|
|
@@ -825,23 +1033,54 @@ Review the conversation above and update two things.
|
|
|
825
1033
|
**Skills**: how to do this class of task. Be ACTIVE. Follow the same class-level umbrella policy, preference order, protected-skill rules, and do-not-capture list as a skill review.
|
|
826
1034
|
|
|
827
1035
|
Act on whichever dimension has real signal. If genuinely nothing stands out on either, say "Nothing to save." and stop — but don't reach for that conclusion as a default.`;
|
|
828
|
-
const CURATOR_PROMPT = `You are the skill curator. Maintain a healthy, class-level skill library.
|
|
1036
|
+
const CURATOR_PROMPT = `You are the skill curator. Maintain a healthy, class-level skill library, not a flat pile of narrow one-session skills.
|
|
1037
|
+
|
|
1038
|
+
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.
|
|
829
1039
|
|
|
830
|
-
|
|
831
|
-
1. NEVER hard-delete a skill. Archive is the maximum destructive action.
|
|
832
|
-
2. Do not touch bundled, hub-installed, or pinned skills.
|
|
833
|
-
3. Do not archive recently-created or never-used skills without strong evidence.
|
|
834
|
-
4. Prefer merging narrow skills into class-level umbrellas.
|
|
835
|
-
5. Before archiving a merged skill, ensure its unique content was preserved.
|
|
1040
|
+
Right target shape: class-level skills with rich SKILL.md + references/, templates/, scripts/ support files for session-specific detail.
|
|
836
1041
|
|
|
837
|
-
|
|
1042
|
+
Hard rules:
|
|
1043
|
+
1. NEVER hard-delete a skill. Archive (moving to .archive/) is the maximum destructive action; archives are recoverable, deletion is not.
|
|
1044
|
+
2. Do not touch bundled, hub-installed, pinned, or scheduled-task-referenced (\`referenced\`) skills. Referenced skills MAY be consolidated into an umbrella, but never simply pruned.
|
|
1045
|
+
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.
|
|
1046
|
+
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.
|
|
1047
|
+
5. Judge overlap on CONTENT, not on usage counters.
|
|
1048
|
+
6. Before archiving a merged skill, ensure its unique content was preserved in the umbrella.
|
|
1049
|
+
|
|
1050
|
+
How to work:
|
|
1051
|
+
1. Scan the candidate list. Identify PREFIX CLUSTERS — skills sharing a first word or domain keyword (expect 10-25 clusters).
|
|
1052
|
+
2. For each cluster with 2+ members, ask "what is the UMBRELLA CLASS these skills serve?" and consolidate:
|
|
1053
|
+
a. MERGE INTO AN EXISTING UMBRELLA (patch a labeled section for each sibling's unique insight, then archive the siblings).
|
|
1054
|
+
b. CREATE A NEW UMBRELLA SKILL.md covering the shared workflow with short labeled subsections, then archive the absorbed siblings.
|
|
1055
|
+
c. DEMOTE session-specific detail to references/, templates/, or scripts/ under the umbrella.
|
|
1056
|
+
3. Keep the umbrella body tight and scannable: exact commands, verbatim paths, ~100-200 lines; never invent flags or APIs.
|
|
1057
|
+
|
|
1058
|
+
Produce a YAML summary with exactly this shape:
|
|
838
1059
|
consolidations:
|
|
839
1060
|
- from: <old-skill-name>
|
|
840
1061
|
into: <umbrella-skill-name>
|
|
841
1062
|
reason: <one short sentence>
|
|
842
1063
|
prunings:
|
|
843
1064
|
- name: <skill-name>
|
|
844
|
-
reason: <one short sentence
|
|
1065
|
+
reason: <one short sentence>
|
|
1066
|
+
Nominate a pruning only when archival is clearly safe (stale AND genuinely obsolete or fully absorbed elsewhere).`;
|
|
1067
|
+
const CURATOR_DRY_RUN_BANNER = `═══════════════════════════════════════════════════════════════
|
|
1068
|
+
DRY-RUN — REPORT ONLY. DO NOT MUTATE THE SKILL LIBRARY.
|
|
1069
|
+
═══════════════════════════════════════════════════════════════
|
|
1070
|
+
|
|
1071
|
+
This is a PREVIEW pass. Follow every instruction above EXCEPT:
|
|
1072
|
+
• Do NOT call skill_manage with action=create, update, patch, delete, write_file, or remove_file.
|
|
1073
|
+
• Do NOT move, copy, or rewrite any file under the skills tree.
|
|
1074
|
+
|
|
1075
|
+
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.
|
|
1076
|
+
|
|
1077
|
+
If you accidentally take a mutating action, say so explicitly in the summary.`;
|
|
1078
|
+
const COMPLETION_SKILL_REVIEW_PROMPT = `[Auto-review — Skills · task complete]
|
|
1079
|
+
Your current task now appears complete. Before wrapping up, review the approach and update the skill library via skill_manage.
|
|
1080
|
+
|
|
1081
|
+
Follow the skills review policy: be ACTIVE, prefer class-level umbrellas, patch skills loaded 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.
|
|
1082
|
+
|
|
1083
|
+
Do NOT modify output files or re-run the task. If you are still mid-task, ignore this.`;
|
|
845
1084
|
function reviewPrompt(kind) {
|
|
846
1085
|
if (kind === "memory") return MEMORY_REVIEW_PROMPT;
|
|
847
1086
|
if (kind === "skill") return SKILL_REVIEW_PROMPT;
|
|
@@ -853,12 +1092,12 @@ function sha256(text) {
|
|
|
853
1092
|
function createPromptBundle(prompts) {
|
|
854
1093
|
const canonical = JSON.stringify({
|
|
855
1094
|
id: PROMPT_BUNDLE_ID,
|
|
856
|
-
version:
|
|
1095
|
+
version: 2,
|
|
857
1096
|
prompts: Object.fromEntries(Object.entries(prompts).sort())
|
|
858
1097
|
});
|
|
859
1098
|
return Object.freeze({
|
|
860
1099
|
id: PROMPT_BUNDLE_ID,
|
|
861
|
-
version:
|
|
1100
|
+
version: 2,
|
|
862
1101
|
prompts: Object.freeze({ ...prompts }),
|
|
863
1102
|
sha256: sha256(canonical)
|
|
864
1103
|
});
|
|
@@ -867,7 +1106,8 @@ const PROMPT_BUNDLE = createPromptBundle({
|
|
|
867
1106
|
memory: MEMORY_REVIEW_PROMPT,
|
|
868
1107
|
skill: SKILL_REVIEW_PROMPT,
|
|
869
1108
|
combined: COMBINED_REVIEW_PROMPT,
|
|
870
|
-
curator: CURATOR_PROMPT
|
|
1109
|
+
curator: CURATOR_PROMPT,
|
|
1110
|
+
completion: COMPLETION_SKILL_REVIEW_PROMPT
|
|
871
1111
|
});
|
|
872
1112
|
function verifyPromptBundle(bundle = PROMPT_BUNDLE) {
|
|
873
1113
|
const canonical = JSON.stringify({
|
|
@@ -908,6 +1148,143 @@ Quality bar:
|
|
|
908
1148
|
- No router/index/hub skills that only point at other skills.
|
|
909
1149
|
- References go in \`references/\`, templates in \`templates/\`.`;
|
|
910
1150
|
//#endregion
|
|
1151
|
+
//#region lib/types/quality.js
|
|
1152
|
+
/**
|
|
1153
|
+
* Quality scoring and near-duplicate detection for the curated skill library.
|
|
1154
|
+
*
|
|
1155
|
+
* Pure functions over data inputs so the scoring policy is unit-testable and
|
|
1156
|
+
* the same math feeds the usage sidecar, the `skill_manage review` surface and
|
|
1157
|
+
* the learning graph. Weights follow the Hermes/hermes-claw six-factor model;
|
|
1158
|
+
* mutation maturity is a documented DSH approximation (single per-month patch
|
|
1159
|
+
* trend ratio replaces the claw timestamp-trend formula, since DSH usage
|
|
1160
|
+
* records only carry the last patched timestamp).
|
|
1161
|
+
* @module @lmzhen/dsh-evolution-core
|
|
1162
|
+
*/
|
|
1163
|
+
const QUALITY_WEIGHTS = {
|
|
1164
|
+
usageFrequency: .25,
|
|
1165
|
+
stability: .2,
|
|
1166
|
+
recency: .2,
|
|
1167
|
+
references: .1,
|
|
1168
|
+
mutationMaturity: .2,
|
|
1169
|
+
richness: .05
|
|
1170
|
+
};
|
|
1171
|
+
/** Score below which a skill is flagged for review. */
|
|
1172
|
+
const LOW_QUALITY_THRESHOLD = .3;
|
|
1173
|
+
function clamp01(value) {
|
|
1174
|
+
return Math.max(0, Math.min(1, value));
|
|
1175
|
+
}
|
|
1176
|
+
function daysBetween(from, now) {
|
|
1177
|
+
return Math.max(0, (now.getTime() - new Date(from).getTime()) / 864e5);
|
|
1178
|
+
}
|
|
1179
|
+
function computeQualityScores(input) {
|
|
1180
|
+
const now = input.now ?? /* @__PURE__ */ new Date();
|
|
1181
|
+
const scores = /* @__PURE__ */ new Map();
|
|
1182
|
+
for (const [name, record] of input.usage) {
|
|
1183
|
+
const ageDays = Math.max(1, daysBetween(record.created_at, now));
|
|
1184
|
+
const idleDays = daysBetween(latestActivityAt(record) ?? record.created_at, now);
|
|
1185
|
+
const patchCount = record.patch_count;
|
|
1186
|
+
const useCount = record.use_count;
|
|
1187
|
+
const usageFrequency = clamp01(useCount / ageDays);
|
|
1188
|
+
const stability = useCount === 0 ? 1 : clamp01(1 - patchCount / useCount);
|
|
1189
|
+
const recency = idleDays < 30 ? 1 : clamp01(1 - (idleDays - 30) / 150);
|
|
1190
|
+
const references = clamp01((input.referenceCounts?.get(name) ?? 0) / 3);
|
|
1191
|
+
const mutationMaturity = patchCount === 0 ? .3 : patchCount === 1 ? .4 : clamp01((patchCount - 1) / Math.max(1, ageDays / 30));
|
|
1192
|
+
const richness = clamp01((input.supportDirs?.get(name) ?? 0) * .175);
|
|
1193
|
+
const factors = {
|
|
1194
|
+
usageFrequency,
|
|
1195
|
+
stability,
|
|
1196
|
+
recency,
|
|
1197
|
+
references,
|
|
1198
|
+
mutationMaturity,
|
|
1199
|
+
richness
|
|
1200
|
+
};
|
|
1201
|
+
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;
|
|
1202
|
+
scores.set(name, {
|
|
1203
|
+
score,
|
|
1204
|
+
factors,
|
|
1205
|
+
warn: score < LOW_QUALITY_THRESHOLD
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
return scores;
|
|
1209
|
+
}
|
|
1210
|
+
function normalize(content) {
|
|
1211
|
+
return content.toLowerCase().replace(/\s+/g, " ").trim();
|
|
1212
|
+
}
|
|
1213
|
+
function contentHash$1(content) {
|
|
1214
|
+
return createHash("sha256").update(normalize(content)).digest("hex");
|
|
1215
|
+
}
|
|
1216
|
+
function tokenize(content) {
|
|
1217
|
+
return new Set(normalize(content).split(/[^a-z0-9\u4e00-\u9fff]+/).filter(Boolean));
|
|
1218
|
+
}
|
|
1219
|
+
function jaccard(a, b) {
|
|
1220
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
1221
|
+
let intersection = 0;
|
|
1222
|
+
for (const token of a) if (b.has(token)) intersection += 1;
|
|
1223
|
+
return intersection / (a.size + b.size - intersection);
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Two-phase near-duplicate clustering: exact normalized-hash groups first,
|
|
1227
|
+
* then token-Jaccard edges at {@link DEDUP_SIMILARITY_THRESHOLD} with a token
|
|
1228
|
+
* ratio guard, union-find across the whole set.
|
|
1229
|
+
*/
|
|
1230
|
+
function computeDedupGroups(input) {
|
|
1231
|
+
const threshold = input.threshold ?? .95;
|
|
1232
|
+
const names = [...input.contents.keys()];
|
|
1233
|
+
const hashes = /* @__PURE__ */ new Map();
|
|
1234
|
+
for (const name of names) {
|
|
1235
|
+
const hash = contentHash$1(input.contents.get(name) ?? "");
|
|
1236
|
+
const bucket = hashes.get(hash);
|
|
1237
|
+
if (bucket) bucket.push(name);
|
|
1238
|
+
else hashes.set(hash, [name]);
|
|
1239
|
+
}
|
|
1240
|
+
const parent = /* @__PURE__ */ new Map();
|
|
1241
|
+
const find = (x) => {
|
|
1242
|
+
const root = parent.get(x) ?? x;
|
|
1243
|
+
if (root !== x) parent.set(x, find(root));
|
|
1244
|
+
return parent.get(x) ?? x;
|
|
1245
|
+
};
|
|
1246
|
+
const union = (a, b) => {
|
|
1247
|
+
const [ra, rb] = [find(a), find(b)];
|
|
1248
|
+
if (ra !== rb) parent.set(rb, ra);
|
|
1249
|
+
};
|
|
1250
|
+
for (const [hash, bucketNames] of hashes) {
|
|
1251
|
+
const first = bucketNames[0];
|
|
1252
|
+
if (first === void 0 || bucketNames.length === 1) continue;
|
|
1253
|
+
for (let index = 1; index < bucketNames.length; index += 1) {
|
|
1254
|
+
const peer = bucketNames[index];
|
|
1255
|
+
if (peer) union(first, peer);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
const tokens = /* @__PURE__ */ new Map();
|
|
1259
|
+
const tokenSet = (name) => {
|
|
1260
|
+
let set = tokens.get(name);
|
|
1261
|
+
if (!set) {
|
|
1262
|
+
set = tokenize(input.contents.get(name) ?? "");
|
|
1263
|
+
tokens.set(name, set);
|
|
1264
|
+
}
|
|
1265
|
+
return set;
|
|
1266
|
+
};
|
|
1267
|
+
for (let index = 0; index < names.length; index += 1) {
|
|
1268
|
+
const a = names[index];
|
|
1269
|
+
if (a === void 0) continue;
|
|
1270
|
+
for (let other = index + 1; other < names.length; other += 1) {
|
|
1271
|
+
const b = names[other];
|
|
1272
|
+
if (b === void 0) continue;
|
|
1273
|
+
const [ta, tb] = [tokenSet(a), tokenSet(b)];
|
|
1274
|
+
if (Math.max(ta.size, tb.size) / Math.max(1, Math.min(ta.size, tb.size)) > 5) continue;
|
|
1275
|
+
if (jaccard(ta, tb) >= threshold) union(a, b);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1279
|
+
for (const name of names) {
|
|
1280
|
+
const root = find(name);
|
|
1281
|
+
const group = groups.get(root);
|
|
1282
|
+
if (group) group.push(name);
|
|
1283
|
+
else groups.set(root, [name]);
|
|
1284
|
+
}
|
|
1285
|
+
return [...groups.values()].filter((group) => group.length > 1);
|
|
1286
|
+
}
|
|
1287
|
+
//#endregion
|
|
911
1288
|
//#region lib/types/signals.js
|
|
912
1289
|
/**
|
|
913
1290
|
* Deterministic review signal gate.
|
|
@@ -994,23 +1371,12 @@ function foldTurn(session, fromSeq) {
|
|
|
994
1371
|
* it created unless a `.hermes-managed` marker opts a skill in. Archival is a
|
|
995
1372
|
* move to `.archive/` — never a hard delete.
|
|
996
1373
|
*/
|
|
997
|
-
const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
998
|
-
const MAX_SKILL_NAME_LENGTH = 64;
|
|
999
|
-
const MAX_DESCRIPTION_LENGTH = 1024;
|
|
1000
|
-
const MAX_SKILL_CONTENT_CHARS = 1e5;
|
|
1001
|
-
const MAX_SKILL_FILE_BYTES = 1048576;
|
|
1002
1374
|
const DEFAULT_SKILL_LIMITS = {
|
|
1003
1375
|
maxNameLength: 64,
|
|
1004
1376
|
maxDescriptionLength: MAX_DESCRIPTION_LENGTH,
|
|
1005
1377
|
maxSkillContentChars: MAX_SKILL_CONTENT_CHARS,
|
|
1006
1378
|
maxSkillFileBytes: MAX_SKILL_FILE_BYTES
|
|
1007
1379
|
};
|
|
1008
|
-
const SUPPORT_DIRS = [
|
|
1009
|
-
"references",
|
|
1010
|
-
"templates",
|
|
1011
|
-
"scripts",
|
|
1012
|
-
"assets"
|
|
1013
|
-
];
|
|
1014
1380
|
function skillsRoot(env = process.env) {
|
|
1015
1381
|
return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "skills");
|
|
1016
1382
|
}
|
|
@@ -1069,12 +1435,79 @@ function validateSupportPath(filePath) {
|
|
|
1069
1435
|
if (parts.length < 2) return "Provide a file name, not just a directory.";
|
|
1070
1436
|
return null;
|
|
1071
1437
|
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Index of `pattern` inside `content`, treating whitespace runs (spaces/tabs)
|
|
1440
|
+
* as flexible and literal escape sequences (`\n`, `\t`, `\r`) as their real
|
|
1441
|
+
* characters: a whitespace run on either side matches a run of any length on
|
|
1442
|
+
* the other, and a backslash-escaped char in the pattern matches the real char
|
|
1443
|
+
* in the content (model-copy drift). Returns the [start, end) range in the
|
|
1444
|
+
* ORIGINAL content so a patch can replace exactly the matched span and keep
|
|
1445
|
+
* every other byte intact. Returns null when no fuzzy match exists.
|
|
1446
|
+
*/
|
|
1447
|
+
function fuzzyIndexOf(content, pattern) {
|
|
1448
|
+
const isSpace = (char) => char !== void 0 && /[ \t]/.test(char);
|
|
1449
|
+
const escaped = (char) => {
|
|
1450
|
+
if (char === "n") return "\n";
|
|
1451
|
+
if (char === "t") return " ";
|
|
1452
|
+
if (char === "r") return "\r";
|
|
1453
|
+
return null;
|
|
1454
|
+
};
|
|
1455
|
+
for (let start = 0; start < content.length; start += 1) {
|
|
1456
|
+
let contentIndex = start;
|
|
1457
|
+
let patternIndex = 0;
|
|
1458
|
+
while (patternIndex < pattern.length && contentIndex < content.length) {
|
|
1459
|
+
const patternChar = pattern[patternIndex];
|
|
1460
|
+
const contentChar = content[contentIndex];
|
|
1461
|
+
if (isSpace(patternChar)) {
|
|
1462
|
+
while (patternIndex < pattern.length && isSpace(pattern[patternIndex])) patternIndex += 1;
|
|
1463
|
+
while (contentIndex < content.length && isSpace(content[contentIndex])) contentIndex += 1;
|
|
1464
|
+
continue;
|
|
1465
|
+
}
|
|
1466
|
+
const escapedChar = patternChar === "\\" ? escaped(pattern[patternIndex + 1]) : null;
|
|
1467
|
+
if (escapedChar !== null && contentChar === escapedChar) {
|
|
1468
|
+
patternIndex += 2;
|
|
1469
|
+
contentIndex += 1;
|
|
1470
|
+
continue;
|
|
1471
|
+
}
|
|
1472
|
+
if (patternChar === contentChar) {
|
|
1473
|
+
contentIndex += 1;
|
|
1474
|
+
patternIndex += 1;
|
|
1475
|
+
continue;
|
|
1476
|
+
}
|
|
1477
|
+
break;
|
|
1478
|
+
}
|
|
1479
|
+
if (patternIndex === pattern.length) return [start, contentIndex];
|
|
1480
|
+
}
|
|
1481
|
+
return null;
|
|
1482
|
+
}
|
|
1483
|
+
/** Trim leading whitespace of the first line and trailing whitespace of the last line. */
|
|
1484
|
+
function trimPatternBoundaries(pattern) {
|
|
1485
|
+
const from = pattern.search(/\S/);
|
|
1486
|
+
const trimmed = from < 0 ? pattern : pattern.slice(from);
|
|
1487
|
+
const trailing = trimmed.search(/\s+$/);
|
|
1488
|
+
return trailing < 0 ? trimmed : trimmed.slice(0, trailing);
|
|
1489
|
+
}
|
|
1490
|
+
/** Replace only the fuzzy-matched span, preserving all surrounding bytes. */
|
|
1491
|
+
function fuzzyReplace(content, oldString, newString, replaceAll) {
|
|
1492
|
+
const match = fuzzyIndexOf(content, oldString);
|
|
1493
|
+
if (match === null) return content;
|
|
1494
|
+
const [start, end] = match;
|
|
1495
|
+
const patched = content.slice(0, start) + newString + content.slice(end);
|
|
1496
|
+
return replaceAll ? fuzzyReplace(patched, oldString, newString, true) : patched;
|
|
1497
|
+
}
|
|
1072
1498
|
function fuzzyPatch(content, oldString, newString, replaceAll = false) {
|
|
1073
1499
|
if (content.includes(oldString)) return replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString);
|
|
1074
|
-
const
|
|
1075
|
-
if (
|
|
1076
|
-
|
|
1077
|
-
|
|
1500
|
+
const boundary = trimPatternBoundaries(oldString);
|
|
1501
|
+
if (boundary !== oldString) {
|
|
1502
|
+
if (fuzzyIndexOf(content, boundary) !== null) {
|
|
1503
|
+
const patched = fuzzyReplace(content, boundary, newString, replaceAll);
|
|
1504
|
+
return patched === content ? null : patched;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
if (fuzzyIndexOf(content, oldString) !== null) {
|
|
1508
|
+
const patched = fuzzyReplace(content, oldString, newString, replaceAll);
|
|
1509
|
+
return patched === content ? null : patched;
|
|
1510
|
+
}
|
|
1078
1511
|
return null;
|
|
1079
1512
|
}
|
|
1080
1513
|
var SkillLibrary = class {
|
|
@@ -1109,24 +1542,66 @@ var SkillLibrary = class {
|
|
|
1109
1542
|
async read(name) {
|
|
1110
1543
|
return this.io.readText(join(skillDir(this.root, name), "SKILL.md"));
|
|
1111
1544
|
}
|
|
1112
|
-
async writeProtection(name) {
|
|
1545
|
+
async writeProtection(name, origin = "foreground") {
|
|
1113
1546
|
const dir = skillDir(this.root, name);
|
|
1114
1547
|
for (const marker of ["bundled", "hub-installed"]) if (await this.io.exists(markerPath(dir, marker))) return marker;
|
|
1548
|
+
if (origin === "background_review" && await this.io.exists(markerPath(dir, "pinned"))) return "pinned";
|
|
1115
1549
|
return null;
|
|
1116
1550
|
}
|
|
1117
|
-
async deleteProtection(name) {
|
|
1551
|
+
async deleteProtection(name, options = {}) {
|
|
1118
1552
|
const dir = skillDir(this.root, name);
|
|
1119
|
-
|
|
1553
|
+
const markers = options.allowBundled ? ["hub-installed", "pinned"] : [
|
|
1120
1554
|
"bundled",
|
|
1121
1555
|
"hub-installed",
|
|
1122
1556
|
"pinned"
|
|
1123
|
-
]
|
|
1557
|
+
];
|
|
1558
|
+
for (const marker of markers) if (await this.io.exists(markerPath(dir, marker))) return marker;
|
|
1124
1559
|
return null;
|
|
1125
1560
|
}
|
|
1126
1561
|
async isManaged(name) {
|
|
1127
1562
|
const dir = skillDir(this.root, name);
|
|
1128
1563
|
return await this.io.exists(markerPath(dir, "hermes-managed"));
|
|
1129
1564
|
}
|
|
1565
|
+
/** Whether the skill carries the bundled marker (curator prune-builtins eligibility). */
|
|
1566
|
+
async isBundled(name) {
|
|
1567
|
+
const dir = skillDir(this.root, name);
|
|
1568
|
+
return await this.io.exists(markerPath(dir, "bundled"));
|
|
1569
|
+
}
|
|
1570
|
+
/** Count non-empty support subdirectories (richness input for quality scoring). */
|
|
1571
|
+
async countSupportDirs(name) {
|
|
1572
|
+
const dir = skillDir(this.root, name);
|
|
1573
|
+
let entries;
|
|
1574
|
+
try {
|
|
1575
|
+
entries = await this.io.list(dir);
|
|
1576
|
+
} catch {
|
|
1577
|
+
return 0;
|
|
1578
|
+
}
|
|
1579
|
+
let count = 0;
|
|
1580
|
+
for (const subdir of SUPPORT_DIRS) {
|
|
1581
|
+
if (!entries.includes(subdir)) continue;
|
|
1582
|
+
try {
|
|
1583
|
+
if ((await this.io.list(join(dir, subdir))).some((file) => file !== ".gitkeep")) count += 1;
|
|
1584
|
+
} catch {}
|
|
1585
|
+
}
|
|
1586
|
+
return count;
|
|
1587
|
+
}
|
|
1588
|
+
/** Best-effort audit trail entry; never blocks the mutation. */
|
|
1589
|
+
async audit(skillName, action, before, after, summary) {
|
|
1590
|
+
try {
|
|
1591
|
+
await recordMutation(this.root, this.io, {
|
|
1592
|
+
skillName,
|
|
1593
|
+
action,
|
|
1594
|
+
...before === null ? {} : { beforeHash: contentHash(before) },
|
|
1595
|
+
...after === null ? {} : { afterHash: contentHash(after) },
|
|
1596
|
+
summary,
|
|
1597
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1598
|
+
});
|
|
1599
|
+
} catch {}
|
|
1600
|
+
}
|
|
1601
|
+
/** Recent mutation audit records (read-only inspection surface). */
|
|
1602
|
+
async listMutations() {
|
|
1603
|
+
return await loadMutations(this.root, this.io);
|
|
1604
|
+
}
|
|
1130
1605
|
async create(name, content, origin) {
|
|
1131
1606
|
const normalized = name.trim();
|
|
1132
1607
|
if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return {
|
|
@@ -1150,19 +1625,21 @@ var SkillLibrary = class {
|
|
|
1150
1625
|
};
|
|
1151
1626
|
await this.io.writeText(join(dir, "SKILL.md"), content.trimEnd() + "\n");
|
|
1152
1627
|
if (origin === "background_review") await this.io.writeText(markerPath(dir, "hermes-managed"), "");
|
|
1628
|
+
await this.audit(normalized, "create", null, content, "created");
|
|
1153
1629
|
return {
|
|
1154
1630
|
ok: true,
|
|
1155
1631
|
message: `Skill "${normalized}" created.`,
|
|
1156
1632
|
path: dir
|
|
1157
1633
|
};
|
|
1158
1634
|
}
|
|
1159
|
-
async update(name, content) {
|
|
1635
|
+
async update(name, content, origin = "foreground") {
|
|
1160
1636
|
const dir = skillDir(this.root, name);
|
|
1161
|
-
|
|
1637
|
+
const md = await this.io.readText(join(dir, "SKILL.md"));
|
|
1638
|
+
if (!md) return {
|
|
1162
1639
|
ok: false,
|
|
1163
1640
|
message: `Skill "${name}" not found.`
|
|
1164
1641
|
};
|
|
1165
|
-
const protection = await this.writeProtection(name);
|
|
1642
|
+
const protection = await this.writeProtection(name, origin);
|
|
1166
1643
|
if (protection) return {
|
|
1167
1644
|
ok: false,
|
|
1168
1645
|
message: `Skill "${name}" is protected (${protection}).`
|
|
@@ -1178,20 +1655,21 @@ var SkillLibrary = class {
|
|
|
1178
1655
|
message: threat
|
|
1179
1656
|
};
|
|
1180
1657
|
await this.io.writeText(join(dir, "SKILL.md"), content.trimEnd() + "\n");
|
|
1658
|
+
await this.audit(name, "update", md, content, "updated");
|
|
1181
1659
|
return {
|
|
1182
1660
|
ok: true,
|
|
1183
1661
|
message: `Skill "${name}" updated.`,
|
|
1184
1662
|
path: dir
|
|
1185
1663
|
};
|
|
1186
1664
|
}
|
|
1187
|
-
async patch(name, oldString, newString, filePath = "", replaceAll = false) {
|
|
1665
|
+
async patch(name, oldString, newString, filePath = "", replaceAll = false, origin = "foreground") {
|
|
1188
1666
|
const dir = skillDir(this.root, name);
|
|
1189
1667
|
const skillMd = join(dir, "SKILL.md");
|
|
1190
1668
|
if (!await this.io.exists(skillMd)) return {
|
|
1191
1669
|
ok: false,
|
|
1192
1670
|
message: `Skill "${name}" not found.`
|
|
1193
1671
|
};
|
|
1194
|
-
const protection = await this.writeProtection(name);
|
|
1672
|
+
const protection = await this.writeProtection(name, origin);
|
|
1195
1673
|
if (protection) return {
|
|
1196
1674
|
ok: false,
|
|
1197
1675
|
message: `Skill "${name}" is protected (${protection}).`
|
|
@@ -1238,27 +1716,29 @@ var SkillLibrary = class {
|
|
|
1238
1716
|
message: threat
|
|
1239
1717
|
};
|
|
1240
1718
|
await this.io.writeText(target, patched.trimEnd() + "\n");
|
|
1719
|
+
await this.audit(name, "patch", md, patched, `patched ${patchLabel}`);
|
|
1241
1720
|
return {
|
|
1242
1721
|
ok: true,
|
|
1243
1722
|
message: `Skill "${name}" patched (${patchLabel}).`,
|
|
1244
1723
|
path: dir
|
|
1245
1724
|
};
|
|
1246
1725
|
}
|
|
1247
|
-
async archive(name,
|
|
1726
|
+
async archive(name, options = {}) {
|
|
1248
1727
|
const dir = skillDir(this.root, name);
|
|
1249
|
-
|
|
1728
|
+
const md = await this.io.readText(join(dir, "SKILL.md"));
|
|
1729
|
+
if (!md) return {
|
|
1250
1730
|
ok: false,
|
|
1251
1731
|
message: `Skill "${name}" not found.`
|
|
1252
1732
|
};
|
|
1253
|
-
const protection = await this.deleteProtection(name);
|
|
1733
|
+
const protection = await this.deleteProtection(name, options.allowBundled === void 0 ? {} : { allowBundled: options.allowBundled });
|
|
1254
1734
|
if (protection) return {
|
|
1255
1735
|
ok: false,
|
|
1256
1736
|
message: `Skill "${name}" is protected (${protection}).`
|
|
1257
1737
|
};
|
|
1258
|
-
if (absorbedInto) {
|
|
1259
|
-
if (!await this.io.readText(join(skillDir(this.root, absorbedInto), "SKILL.md"))) return {
|
|
1738
|
+
if (options.absorbedInto) {
|
|
1739
|
+
if (!await this.io.readText(join(skillDir(this.root, options.absorbedInto), "SKILL.md"))) return {
|
|
1260
1740
|
ok: false,
|
|
1261
|
-
message: `absorbed_into="${absorbedInto}" does not exist.`
|
|
1741
|
+
message: `absorbed_into="${options.absorbedInto}" does not exist.`
|
|
1262
1742
|
};
|
|
1263
1743
|
}
|
|
1264
1744
|
const archiveRoot = join(this.root, ".archive");
|
|
@@ -1270,21 +1750,144 @@ var SkillLibrary = class {
|
|
|
1270
1750
|
await this.io.copy(dir, dest);
|
|
1271
1751
|
await this.io.remove(dir);
|
|
1272
1752
|
}
|
|
1273
|
-
const reason = absorbedInto ? `Consolidated into ${absorbedInto}` : "Archived by self-evolution curator";
|
|
1753
|
+
const reason = options.reason ?? (options.absorbedInto ? `Consolidated into ${options.absorbedInto}` : "Archived by self-evolution curator");
|
|
1274
1754
|
await this.io.writeText(join(dest, ".archive-reason"), `${(/* @__PURE__ */ new Date()).toISOString()}: ${reason}\n`);
|
|
1755
|
+
await this.audit(name, "archive", md, null, reason);
|
|
1275
1756
|
return {
|
|
1276
1757
|
ok: true,
|
|
1277
1758
|
message: `Skill "${name}" archived to .archive.`,
|
|
1278
1759
|
path: dest
|
|
1279
1760
|
};
|
|
1280
1761
|
}
|
|
1281
|
-
|
|
1762
|
+
/**
|
|
1763
|
+
* Merge the bodies of `sources` into `target` and archive the sources with
|
|
1764
|
+
* an absorbed-into marker. Hermes-style consolidation: overlapping skills
|
|
1765
|
+
* collapse into one, and the originals stay recoverable under `.archive/`.
|
|
1766
|
+
*/
|
|
1767
|
+
async consolidate(target, sources, origin = "foreground") {
|
|
1768
|
+
const normalizedSources = [...new Set(sources)].filter((name) => name !== target);
|
|
1769
|
+
if (normalizedSources.length === 0) return {
|
|
1770
|
+
ok: false,
|
|
1771
|
+
message: "Consolidation requires at least one distinct source skill."
|
|
1772
|
+
};
|
|
1773
|
+
for (const name of [target, ...normalizedSources]) if (!SKILL_NAME_RE.test(name)) return {
|
|
1774
|
+
ok: false,
|
|
1775
|
+
message: `Invalid skill name "${name}". Use lowercase letters, digits, and hyphens.`
|
|
1776
|
+
};
|
|
1777
|
+
const targetDir = skillDir(this.root, target);
|
|
1778
|
+
const targetMd = await this.io.readText(join(targetDir, "SKILL.md"));
|
|
1779
|
+
if (!targetMd) return {
|
|
1780
|
+
ok: false,
|
|
1781
|
+
message: `Skill "${target}" not found.`
|
|
1782
|
+
};
|
|
1783
|
+
const targetProtection = await this.writeProtection(target, origin);
|
|
1784
|
+
if (targetProtection) return {
|
|
1785
|
+
ok: false,
|
|
1786
|
+
message: `Skill "${target}" is protected (${targetProtection}).`
|
|
1787
|
+
};
|
|
1788
|
+
const parts = [];
|
|
1789
|
+
for (const source of normalizedSources) {
|
|
1790
|
+
const protection = await this.deleteProtection(source);
|
|
1791
|
+
if (protection) return {
|
|
1792
|
+
ok: false,
|
|
1793
|
+
message: `Skill "${source}" is protected (${protection}).`
|
|
1794
|
+
};
|
|
1795
|
+
const sourceMd = await this.io.readText(join(skillDir(this.root, source), "SKILL.md"));
|
|
1796
|
+
if (!sourceMd) return {
|
|
1797
|
+
ok: false,
|
|
1798
|
+
message: `Skill "${source}" not found.`
|
|
1799
|
+
};
|
|
1800
|
+
const parsed = parseFrontmatter(sourceMd);
|
|
1801
|
+
if (!parsed) return {
|
|
1802
|
+
ok: false,
|
|
1803
|
+
message: `Skill "${source}" has no valid frontmatter; refusing to merge.`
|
|
1804
|
+
};
|
|
1805
|
+
parts.push(`\n<!-- consolidated from ${source} at ${(/* @__PURE__ */ new Date()).toISOString()} -->\n${parsed.body.trim()}`);
|
|
1806
|
+
}
|
|
1807
|
+
const merged = targetMd.trimEnd() + parts.join("\n") + "\n";
|
|
1808
|
+
const validation = validateFrontmatter(merged, target, this.limits);
|
|
1809
|
+
if (validation) return {
|
|
1810
|
+
ok: false,
|
|
1811
|
+
message: `Consolidation rejected: ${validation}`
|
|
1812
|
+
};
|
|
1813
|
+
const threat = scanContentThreats(merged);
|
|
1814
|
+
if (threat) return {
|
|
1815
|
+
ok: false,
|
|
1816
|
+
message: threat
|
|
1817
|
+
};
|
|
1818
|
+
const archived = [];
|
|
1819
|
+
try {
|
|
1820
|
+
for (const source of normalizedSources) {
|
|
1821
|
+
const result = await this.archive(source, { absorbedInto: target });
|
|
1822
|
+
if (!result.ok) return result;
|
|
1823
|
+
archived.push(source);
|
|
1824
|
+
}
|
|
1825
|
+
await this.io.writeText(join(targetDir, "SKILL.md"), merged);
|
|
1826
|
+
} catch (error) {
|
|
1827
|
+
await this.io.writeText(join(targetDir, "SKILL.md"), targetMd).catch(() => {});
|
|
1828
|
+
for (const source of archived.reverse()) await this.restoreFromArchive(source).catch(() => {});
|
|
1829
|
+
return {
|
|
1830
|
+
ok: false,
|
|
1831
|
+
message: `Consolidation failed and was rolled back: ${error instanceof Error ? error.message : String(error)}`
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1834
|
+
return {
|
|
1835
|
+
ok: true,
|
|
1836
|
+
message: `Consolidated ${normalizedSources.join(", ")} into "${target}".`,
|
|
1837
|
+
path: targetDir
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1840
|
+
/**
|
|
1841
|
+
* Restore one skill from `.archive/` back to the active root. Hermes-style
|
|
1842
|
+
* recoverability: archival never deletes, and this is the control-plane
|
|
1843
|
+
* path back. The `.archive-reason` marker is dropped on restore.
|
|
1844
|
+
*/
|
|
1845
|
+
async restoreFromArchive(name) {
|
|
1846
|
+
if (!SKILL_NAME_RE.test(name)) return {
|
|
1847
|
+
ok: false,
|
|
1848
|
+
message: `Invalid skill name "${name}". Use lowercase letters, digits, and hyphens.`
|
|
1849
|
+
};
|
|
1850
|
+
if (await this.io.exists(join(skillDir(this.root, name), "SKILL.md"))) return {
|
|
1851
|
+
ok: false,
|
|
1852
|
+
message: `Skill "${name}" already exists in the active root; refusing to overwrite.`
|
|
1853
|
+
};
|
|
1854
|
+
const archiveRoot = join(this.root, ".archive");
|
|
1855
|
+
let entries;
|
|
1856
|
+
try {
|
|
1857
|
+
entries = await this.io.list(archiveRoot);
|
|
1858
|
+
} catch {
|
|
1859
|
+
return {
|
|
1860
|
+
ok: false,
|
|
1861
|
+
message: "No skill archive available."
|
|
1862
|
+
};
|
|
1863
|
+
}
|
|
1864
|
+
const chosen = entries.filter((entry) => entry === name || entry.startsWith(`${name}-`)).sort().reverse()[0];
|
|
1865
|
+
if (!chosen) return {
|
|
1866
|
+
ok: false,
|
|
1867
|
+
message: `Skill "${name}" is not in .archive.`
|
|
1868
|
+
};
|
|
1869
|
+
const source = join(archiveRoot, chosen);
|
|
1870
|
+
const dest = skillDir(this.root, name);
|
|
1871
|
+
try {
|
|
1872
|
+
await this.io.rename(source, dest);
|
|
1873
|
+
} catch {
|
|
1874
|
+
await this.io.copy(source, dest);
|
|
1875
|
+
await this.io.remove(source);
|
|
1876
|
+
}
|
|
1877
|
+
if (await this.io.exists(join(dest, ".archive-reason"))) await this.io.remove(join(dest, ".archive-reason"));
|
|
1878
|
+
return {
|
|
1879
|
+
ok: true,
|
|
1880
|
+
message: `Skill "${name}" restored from .archive.`,
|
|
1881
|
+
path: dest
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
async writeSupportFile(name, filePath, content, origin = "foreground") {
|
|
1282
1885
|
const dir = skillDir(this.root, name);
|
|
1283
1886
|
if (!await this.io.exists(join(dir, "SKILL.md"))) return {
|
|
1284
1887
|
ok: false,
|
|
1285
1888
|
message: `Skill "${name}" not found.`
|
|
1286
1889
|
};
|
|
1287
|
-
const protection = await this.writeProtection(name);
|
|
1890
|
+
const protection = await this.writeProtection(name, origin);
|
|
1288
1891
|
if (protection) return {
|
|
1289
1892
|
ok: false,
|
|
1290
1893
|
message: `Skill "${name}" is protected (${protection}).`
|
|
@@ -1304,20 +1907,22 @@ var SkillLibrary = class {
|
|
|
1304
1907
|
message: threat
|
|
1305
1908
|
};
|
|
1306
1909
|
const target = join(dir, ...filePath.replace(/\\/g, "/").split("/").filter(Boolean));
|
|
1910
|
+
const existing = await this.io.readText(target).catch(() => null);
|
|
1307
1911
|
await this.io.writeText(target, content);
|
|
1912
|
+
await this.audit(name, "write_file", existing, content, `wrote ${filePath}`);
|
|
1308
1913
|
return {
|
|
1309
1914
|
ok: true,
|
|
1310
1915
|
message: `Support file "${filePath}" written to "${name}".`,
|
|
1311
1916
|
path: target
|
|
1312
1917
|
};
|
|
1313
1918
|
}
|
|
1314
|
-
async removeSupportFile(name, filePath) {
|
|
1919
|
+
async removeSupportFile(name, filePath, origin = "foreground") {
|
|
1315
1920
|
const dir = skillDir(this.root, name);
|
|
1316
1921
|
if (!await this.io.exists(join(dir, "SKILL.md"))) return {
|
|
1317
1922
|
ok: false,
|
|
1318
1923
|
message: `Skill "${name}" not found.`
|
|
1319
1924
|
};
|
|
1320
|
-
const protection = await this.writeProtection(name);
|
|
1925
|
+
const protection = await this.writeProtection(name, origin);
|
|
1321
1926
|
if (protection) return {
|
|
1322
1927
|
ok: false,
|
|
1323
1928
|
message: `Skill "${name}" is protected (${protection}).`
|
|
@@ -1332,7 +1937,9 @@ var SkillLibrary = class {
|
|
|
1332
1937
|
ok: false,
|
|
1333
1938
|
message: `File "${filePath}" not found in skill "${name}".`
|
|
1334
1939
|
};
|
|
1940
|
+
const before = await this.io.readText(target).catch(() => null);
|
|
1335
1941
|
await this.io.remove(target);
|
|
1942
|
+
await this.audit(name, "remove_file", before, null, `removed ${filePath}`);
|
|
1336
1943
|
return {
|
|
1337
1944
|
ok: true,
|
|
1338
1945
|
message: `Support file "${filePath}" removed from "${name}".`,
|
|
@@ -1403,7 +2010,7 @@ var SkillLibrary = class {
|
|
|
1403
2010
|
function evolutionHome(env = process.env) {
|
|
1404
2011
|
return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "evolution");
|
|
1405
2012
|
}
|
|
1406
|
-
var JsonState = class {
|
|
2013
|
+
var JsonState = class JsonState {
|
|
1407
2014
|
initial;
|
|
1408
2015
|
path;
|
|
1409
2016
|
value;
|
|
@@ -1412,14 +2019,24 @@ var JsonState = class {
|
|
|
1412
2019
|
this.path = join(evolutionHome(env), name);
|
|
1413
2020
|
this.value = this.loadSync();
|
|
1414
2021
|
}
|
|
2022
|
+
/**
|
|
2023
|
+
* Deep-merge persisted state over the initial defaults. Nested plain
|
|
2024
|
+
* objects merge recursively (so a new default field added under an existing
|
|
2025
|
+
* object is preserved), while arrays and primitives take the on-disk value
|
|
2026
|
+
* wholesale. Keeps forward-compatible defaults across schema additions.
|
|
2027
|
+
*/
|
|
2028
|
+
static mergeDeep(initial, persisted) {
|
|
2029
|
+
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2030
|
+
if (!isRecord(initial) || !isRecord(persisted)) return isRecord(persisted) ? persisted : persisted == null ? initial : persisted;
|
|
2031
|
+
const out = { ...initial };
|
|
2032
|
+
for (const [key, value] of Object.entries(persisted)) out[key] = key in initial ? JsonState.mergeDeep(initial[key], value) : value;
|
|
2033
|
+
return out;
|
|
2034
|
+
}
|
|
1415
2035
|
loadSync() {
|
|
1416
2036
|
try {
|
|
1417
2037
|
const raw = readFileSync(this.path, "utf8");
|
|
1418
2038
|
const parsed = JSON.parse(raw);
|
|
1419
|
-
return
|
|
1420
|
-
...this.initial,
|
|
1421
|
-
...parsed
|
|
1422
|
-
};
|
|
2039
|
+
return JsonState.mergeDeep(this.initial, parsed);
|
|
1423
2040
|
} catch {
|
|
1424
2041
|
return { ...this.initial };
|
|
1425
2042
|
}
|
|
@@ -1443,14 +2060,11 @@ var JsonState = class {
|
|
|
1443
2060
|
async reload() {
|
|
1444
2061
|
try {
|
|
1445
2062
|
const raw = await readFile(this.path, "utf8");
|
|
1446
|
-
this.value =
|
|
1447
|
-
...this.initial,
|
|
1448
|
-
...JSON.parse(raw)
|
|
1449
|
-
};
|
|
2063
|
+
this.value = JsonState.mergeDeep(this.initial, JSON.parse(raw));
|
|
1450
2064
|
} catch {
|
|
1451
2065
|
this.value = { ...this.initial };
|
|
1452
2066
|
}
|
|
1453
2067
|
}
|
|
1454
2068
|
};
|
|
1455
2069
|
//#endregion
|
|
1456
|
-
export { COMBINED_REVIEW_PROMPT, CURATOR_PROMPT, DEFAULT_SKILL_LIMITS, DSH_AUTHORING_STANDARDS, ENTRY_DELIMITER, JsonState, MAX_DESCRIPTION_LENGTH, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MEMORY_REVIEW_PROMPT, MemoryStore, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROTECTED_BUILTIN_SKILLS, SKILL_NAME_RE, SKILL_REVIEW_PROMPT, SUPPORT_DIRS, SkillLibrary, advanceReview, buildCuratorRunReport, bumpPatch, bumpUse, bumpView,
|
|
2070
|
+
export { COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, 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, JsonState, LOW_QUALITY_THRESHOLD, MAX_DESCRIPTION_LENGTH, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MEMORY_REVIEW_PROMPT, MUTATIONS_FILE_VERSION, MemoryStore, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, QUALITY_WEIGHTS, SKILL_NAME_RE, SKILL_REVIEW_PROMPT, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary, advanceReview, buildCuratorRunReport, bumpPatch, bumpUse, bumpView, computeDedupGroups, computeLifecycleTransitions, computeQualityScores, contentHash, emptyRecord, evaluateThreat, evolutionHome, evolutionIoAdapter, foldTurn, getRecord, latestActivityAt, loadMutations, loadSuppressedNames, loadUsage, markAgentCreated, memoryRoot, mutationsFile, nodeEvolutionIo, observeEvent, parseCuratorNominations, parseFrontmatter, recordMutation, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, usageFile, validateFrontmatter, verifyPromptBundle };
|