@lmzhen/dsh-evolution-core 0.1.0-rc.3 → 0.1.0-rc.31
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 +1111 -237
- package/lib/types/constants.d.ts +51 -0
- package/lib/types/curator.d.ts +58 -3
- package/lib/types/index.d.ts +4 -0
- package/lib/types/io.d.ts +7 -2
- package/lib/types/learn-prompt.d.ts +19 -0
- package/lib/types/memory-store.d.ts +38 -9
- package/lib/types/mutations.d.ts +24 -0
- package/lib/types/prompts.d.ts +11 -3
- package/lib/types/quality.d.ts +57 -0
- package/lib/types/skill-store.d.ts +52 -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";
|
|
@@ -20,16 +20,25 @@ function evolutionIoAdapter(provider) {
|
|
|
20
20
|
list: (path) => provider().list(path),
|
|
21
21
|
exists: (path) => provider().exists(path),
|
|
22
22
|
rename: (path, destination) => provider().rename(path, destination),
|
|
23
|
-
copy: (path, destination) => provider().copy(path, destination)
|
|
23
|
+
copy: (path, destination) => provider().copy(path, destination),
|
|
24
|
+
size: (path) => {
|
|
25
|
+
const io = provider();
|
|
26
|
+
return io.size ? io.size(path) : Promise.resolve(null);
|
|
27
|
+
}
|
|
24
28
|
};
|
|
25
29
|
}
|
|
26
30
|
function nodeEvolutionIo() {
|
|
31
|
+
const isMissing = (error) => {
|
|
32
|
+
const code = error?.code;
|
|
33
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
34
|
+
};
|
|
27
35
|
return {
|
|
28
36
|
async readText(path) {
|
|
29
37
|
try {
|
|
30
38
|
return await readFile(path, "utf8");
|
|
31
|
-
} catch {
|
|
32
|
-
return null;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (isMissing(error)) return null;
|
|
41
|
+
throw error;
|
|
33
42
|
}
|
|
34
43
|
},
|
|
35
44
|
async writeText(path, content) {
|
|
@@ -55,8 +64,9 @@ function nodeEvolutionIo() {
|
|
|
55
64
|
try {
|
|
56
65
|
await stat(path);
|
|
57
66
|
return true;
|
|
58
|
-
} catch {
|
|
59
|
-
return false;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (isMissing(error)) return false;
|
|
69
|
+
throw error;
|
|
60
70
|
}
|
|
61
71
|
},
|
|
62
72
|
async rename(path, destination) {
|
|
@@ -69,13 +79,17 @@ function nodeEvolutionIo() {
|
|
|
69
79
|
recursive: true,
|
|
70
80
|
force: true
|
|
71
81
|
});
|
|
82
|
+
},
|
|
83
|
+
async size(path) {
|
|
84
|
+
try {
|
|
85
|
+
return (await stat(path)).size;
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (isMissing(error)) return null;
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
72
90
|
}
|
|
73
91
|
};
|
|
74
92
|
}
|
|
75
|
-
/** Absolute path helper kept separate so stores stay platform-correct. */
|
|
76
|
-
function childPath(parent, ...parts) {
|
|
77
|
-
return join(parent, ...parts);
|
|
78
|
-
}
|
|
79
93
|
//#endregion
|
|
80
94
|
//#region lib/types/usage.js
|
|
81
95
|
/**
|
|
@@ -155,15 +169,102 @@ function latestActivityAt(record) {
|
|
|
155
169
|
if (values.length === 0) return null;
|
|
156
170
|
return values.sort().reverse()[0] ?? null;
|
|
157
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Curator suppression sidecar: built-in skills the curator has archived stay
|
|
174
|
+
* suppressed across re-seeds, so the lifecycle never fights a re-created
|
|
175
|
+
* bundled skill. Best-effort load/save, mirroring the usage sidecar posture.
|
|
176
|
+
* Versioned shape ({ version, names }) with legacy plain-array compat.
|
|
177
|
+
*/
|
|
178
|
+
const SUPPRESSED_FILE_VERSION = 1;
|
|
179
|
+
function suppressedFile(root) {
|
|
180
|
+
return join(root, ".curator-suppressed.json");
|
|
181
|
+
}
|
|
182
|
+
async function loadSuppressedNames(root, io = nodeEvolutionIo()) {
|
|
183
|
+
const raw = await io.readText(suppressedFile(root));
|
|
184
|
+
if (raw === null) return /* @__PURE__ */ new Set();
|
|
185
|
+
try {
|
|
186
|
+
const parsed = JSON.parse(raw);
|
|
187
|
+
const names = Array.isArray(parsed) ? parsed : typeof parsed === "object" && parsed !== null && Array.isArray(parsed.names) ? parsed.names : [];
|
|
188
|
+
return new Set(names.filter((entry) => typeof entry === "string"));
|
|
189
|
+
} catch {
|
|
190
|
+
return /* @__PURE__ */ new Set();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async function saveSuppressedNames(root, names, io = nodeEvolutionIo()) {
|
|
194
|
+
await io.writeText(suppressedFile(root), JSON.stringify({
|
|
195
|
+
version: 1,
|
|
196
|
+
names: [...names].sort()
|
|
197
|
+
}, null, 2));
|
|
198
|
+
}
|
|
199
|
+
//#endregion
|
|
200
|
+
//#region lib/types/constants.js
|
|
201
|
+
/**
|
|
202
|
+
* Shared constants for the dsh-evolution plugin family.
|
|
203
|
+
*
|
|
204
|
+
* Two classes of value live here, deliberately separated by section so future
|
|
205
|
+
* edits do not blur the semantic boundary:
|
|
206
|
+
*
|
|
207
|
+
* 1. **Fixed protocol/format/security invariants** — changing these breaks an
|
|
208
|
+
* on-disk format, a naming/format contract, a path-security boundary, or a
|
|
209
|
+
* cross-component invariant. They are NOT exposed as deployment config.
|
|
210
|
+
*
|
|
211
|
+
* 2. **Cross-package shared tunable defaults** — the same semantic default is
|
|
212
|
+
* read (with a config override path) by more than one package (e.g.
|
|
213
|
+
* `evolution-policy` and `evolution-curator` both default `staleAfterDays`
|
|
214
|
+
* to 30). Centralizing them here means one authoritative default: a config
|
|
215
|
+
* override still applies per package, but the fallback is single-sourced.
|
|
216
|
+
*
|
|
217
|
+
* Package-private tunables (used by exactly one package) stay in that package,
|
|
218
|
+
* not here — see evolution-replay's `DEFAULT_WEIGHTS` and evolution-feedback's
|
|
219
|
+
* threshold, which are intentionally left where they are used.
|
|
220
|
+
* @module @lmzhen/dsh-evolution-core
|
|
221
|
+
*/
|
|
222
|
+
/** Skill frontmatter `name` validated for the file name (lowercase + hyphen). */
|
|
223
|
+
const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
224
|
+
/** Allowed skill support-file subdirectories (path-traversal boundary). */
|
|
225
|
+
const SUPPORT_DIRS = [
|
|
226
|
+
"references",
|
|
227
|
+
"templates",
|
|
228
|
+
"scripts",
|
|
229
|
+
"assets"
|
|
230
|
+
];
|
|
231
|
+
/** Delimiter between durable memory entries (on-disk storage format). */
|
|
232
|
+
const ENTRY_DELIMITER = "\n§\n";
|
|
233
|
+
/** Built-in skill names the curator must never lifecycle-manage. */
|
|
234
|
+
const PROTECTED_BUILTIN_SKILLS = new Set(["plan"]);
|
|
235
|
+
const MAX_SKILL_NAME_LENGTH = 64;
|
|
236
|
+
const MAX_DESCRIPTION_LENGTH = 1024;
|
|
237
|
+
const MAX_SKILL_CONTENT_CHARS = 1e5;
|
|
238
|
+
const MAX_SKILL_FILE_BYTES = 1048576;
|
|
239
|
+
const DEFAULT_REVIEW_MEMORY_INTERVAL = 10;
|
|
240
|
+
const DEFAULT_REVIEW_SKILL_INTERVAL = 10;
|
|
241
|
+
/** Skill-review completion channel trigger mode: 'cadence' | 'completion' | 'both'. */
|
|
242
|
+
const DEFAULT_SKILL_REVIEW_TRIGGER = "both";
|
|
243
|
+
/** Cumulative session tool calls before a session counts as "proven long" for the completion channel. */
|
|
244
|
+
const DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS = 20;
|
|
245
|
+
const DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS = 3;
|
|
246
|
+
const DEFAULT_SUBSTANTIVE_MIN_USER_CHARS = 200;
|
|
247
|
+
const DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS = 500;
|
|
248
|
+
const DEFAULT_MAX_OPS_PER_PLAN = 32;
|
|
249
|
+
const DEFAULT_CURATOR_INTERVAL_HOURS = 168;
|
|
250
|
+
const DEFAULT_MIN_IDLE_HOURS = 2;
|
|
251
|
+
const DEFAULT_STALE_AFTER_DAYS = 30;
|
|
252
|
+
const DEFAULT_ARCHIVE_AFTER_DAYS = 90;
|
|
253
|
+
const DEFAULT_MEMORY_CHAR_LIMIT = 2200;
|
|
254
|
+
const DEFAULT_USER_CHAR_LIMIT = 1375;
|
|
255
|
+
const DEFAULT_SKILL_CONTENT_CHARS = 1e5;
|
|
158
256
|
//#endregion
|
|
159
257
|
//#region lib/types/curator.js
|
|
160
258
|
/**
|
|
161
259
|
* Deterministic skill curator: active → stale → archived transitions.
|
|
162
|
-
* Pure function
|
|
260
|
+
* Pure function with one deliberate side effect: records in the passed
|
|
261
|
+
* `usage` map are MUTATED (state/archived_at) to carry the transition — the
|
|
262
|
+
* caller owns the map and decides whether to clone first (dry-run) or persist
|
|
263
|
+
* after. File moves are performed by SkillLibrary.
|
|
163
264
|
*/
|
|
164
|
-
const PROTECTED_BUILTIN_SKILLS = new Set(["plan"]);
|
|
165
265
|
function buildCuratorRunReport(input) {
|
|
166
266
|
return {
|
|
267
|
+
schemaVersion: 1,
|
|
167
268
|
runId: input.runId,
|
|
168
269
|
startedAt: input.startedAt,
|
|
169
270
|
finishedAt: input.finishedAt,
|
|
@@ -172,7 +273,95 @@ function buildCuratorRunReport(input) {
|
|
|
172
273
|
archiveCandidates: [...input.archiveCandidates],
|
|
173
274
|
archived: [...input.archived],
|
|
174
275
|
failed: [...input.failed],
|
|
175
|
-
...input.snapshotPath === void 0 ? {} : { snapshotPath: input.snapshotPath }
|
|
276
|
+
...input.snapshotPath === void 0 ? {} : { snapshotPath: input.snapshotPath },
|
|
277
|
+
...input.llmReviewEnabled === void 0 ? {} : { llmReviewEnabled: input.llmReviewEnabled }
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const NOMINATION_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
281
|
+
/**
|
|
282
|
+
* Parse the curator LLM's YAML nomination block (consolidations + prunings).
|
|
283
|
+
* Line-oriented and lenient by design: the LLM output is advisory, every name
|
|
284
|
+
* is re-validated against the tree before any file move happens downstream.
|
|
285
|
+
*/
|
|
286
|
+
function parseCuratorNominations(text) {
|
|
287
|
+
const prunings = [];
|
|
288
|
+
const consolidations = [];
|
|
289
|
+
let section = null;
|
|
290
|
+
let currentFrom = "";
|
|
291
|
+
for (const line of text.split("\n")) {
|
|
292
|
+
const consolidated = /^\s*-\s*from:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
|
|
293
|
+
if (consolidated) {
|
|
294
|
+
section = "consolidations";
|
|
295
|
+
currentFrom = consolidated[1] ?? "";
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const into = /^\s*into:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
|
|
299
|
+
if (into) {
|
|
300
|
+
const intoName = into[1] ?? "";
|
|
301
|
+
if (section === "consolidations" && currentFrom !== "" && currentFrom !== intoName) consolidations.push({
|
|
302
|
+
from: currentFrom,
|
|
303
|
+
into: intoName
|
|
304
|
+
});
|
|
305
|
+
currentFrom = "";
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const pruned = /^\s*-\s*name:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
|
|
309
|
+
if (pruned) {
|
|
310
|
+
section = "prunings";
|
|
311
|
+
const name = pruned[1];
|
|
312
|
+
if (name) prunings.push(name);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const valid = (name) => NOMINATION_NAME_RE.test(name);
|
|
316
|
+
return {
|
|
317
|
+
prunings: prunings.filter(valid),
|
|
318
|
+
consolidations: consolidations.filter((item) => valid(item.from) && valid(item.into))
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* The lifecycle-candidate gate, shared by the transition engine and the scope
|
|
323
|
+
* view so the two can never disagree: records failing ANY of these gates are
|
|
324
|
+
* outside the managed scope.
|
|
325
|
+
*/
|
|
326
|
+
function lifecycleCandidate(name, record, config, bundled) {
|
|
327
|
+
if (record.pinned) return false;
|
|
328
|
+
if (config.excludeSkillNames?.has(name)) return false;
|
|
329
|
+
if (config.suppressedNames?.has(name)) return false;
|
|
330
|
+
if (config.referencedSkillNames?.has(name)) return false;
|
|
331
|
+
if (!(record.created_by === "agent" || config.manageUnmanaged === true) && !(config.pruneBuiltins === true && bundled)) return false;
|
|
332
|
+
if (PROTECTED_BUILTIN_SKILLS.has(name)) return false;
|
|
333
|
+
if (record.state === "archived") return false;
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Read-only scope classification, derived from the SAME gate the transition
|
|
338
|
+
* engine uses (`lifecycleCandidate`), so the view always predicts what a
|
|
339
|
+
* curator pass may touch. `protectedNames` carries the marker info the usage
|
|
340
|
+
* records lack (bundled / hub-installed / pinned from `SkillLibrary.list()`).
|
|
341
|
+
*/
|
|
342
|
+
function computeScopeView(usage, config, protectedNames) {
|
|
343
|
+
const managed = [];
|
|
344
|
+
const watched = [];
|
|
345
|
+
const exempted = [];
|
|
346
|
+
const protectedSet = /* @__PURE__ */ new Set();
|
|
347
|
+
for (const [name, record] of usage) {
|
|
348
|
+
if (config.excludeSkillNames?.has(name) || config.referencedSkillNames?.has(name)) {
|
|
349
|
+
exempted.push(name);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
const bundled = config.bundledNames?.has(name) === true;
|
|
353
|
+
const suppressed = config.suppressedNames?.has(name) === true;
|
|
354
|
+
if (record.pinned || bundled || suppressed || protectedNames?.has(name) === true) protectedSet.add(name);
|
|
355
|
+
if (lifecycleCandidate(name, record, config, bundled)) {
|
|
356
|
+
managed.push(name);
|
|
357
|
+
if (record.state === "stale" || record.quality_warn === true) watched.push(name);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
managed: managed.sort(),
|
|
362
|
+
watched: watched.sort(),
|
|
363
|
+
exempted: exempted.sort(),
|
|
364
|
+
protected: [...protectedSet].sort()
|
|
176
365
|
};
|
|
177
366
|
}
|
|
178
367
|
function daysSince(iso, created, now) {
|
|
@@ -186,11 +375,7 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
|
|
|
186
375
|
markStale: []
|
|
187
376
|
};
|
|
188
377
|
for (const [name, record] of usage) {
|
|
189
|
-
if (record.
|
|
190
|
-
if (config.excludeSkillNames?.has(name)) continue;
|
|
191
|
-
if (record.created_by !== "agent" && config.manageUnmanaged !== true) continue;
|
|
192
|
-
if (PROTECTED_BUILTIN_SKILLS.has(name)) continue;
|
|
193
|
-
if (record.state === "archived") continue;
|
|
378
|
+
if (!lifecycleCandidate(name, record, config, config.bundledNames?.has(name) === true)) continue;
|
|
194
379
|
const age = daysSince(null, record.created_at, now.getTime());
|
|
195
380
|
if (record.use_count === 0 && age < config.staleAfterDays) continue;
|
|
196
381
|
const idle = daysSince(latestActivityAt(record), record.created_at, now.getTime());
|
|
@@ -242,6 +427,223 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
|
|
|
242
427
|
return result;
|
|
243
428
|
}
|
|
244
429
|
//#endregion
|
|
430
|
+
//#region lib/types/prompts.js
|
|
431
|
+
/**
|
|
432
|
+
* Review and curation prompts adapted from Hermes Agent
|
|
433
|
+
* `agent/background_review.py`, `agent/curator.py`, and
|
|
434
|
+
* `agent/learn_prompt.py`, with tool names translated to the DSH-native
|
|
435
|
+
* catalog (`memory`, `skill_manage`, `skill`, `bash`, `str_replace_editor`).
|
|
436
|
+
*
|
|
437
|
+
* Every prompt is pinned in a versioned bundle. Review workers verify the
|
|
438
|
+
* bundle digest before spending a model call, so a partially-patched
|
|
439
|
+
* deployment fails closed instead of silently running a truncated prompt.
|
|
440
|
+
*/
|
|
441
|
+
/**
|
|
442
|
+
* Prompt bundle identity. Bump both id and version whenever a prompt's text
|
|
443
|
+
* changes semantically: the bundle digest is the fail-closed signal for
|
|
444
|
+
* review workers, so a stale id across deployments must be distinguishable.
|
|
445
|
+
*/
|
|
446
|
+
const PROMPT_BUNDLE_ID = "dsh-evolution@2";
|
|
447
|
+
const PROMPT_BUNDLE_VERSION = 2;
|
|
448
|
+
const MEMORY_REVIEW_PROMPT = `[Auto-review — Memory]
|
|
449
|
+
Review the conversation above and consider saving to memory if appropriate.
|
|
450
|
+
|
|
451
|
+
Focus on:
|
|
452
|
+
1. Has the user revealed things about themselves — persona, desires, preferences, or personal details worth remembering?
|
|
453
|
+
2. Has the user expressed expectations about how you should behave, their work style, or ways they want you to operate?
|
|
454
|
+
|
|
455
|
+
If something stands out, save it using the memory tool.
|
|
456
|
+
If nothing is worth saving, just say "Nothing to save." and stop.`;
|
|
457
|
+
const SKILL_REVIEW_PROMPT = `[Auto-review — Skills]
|
|
458
|
+
Review the conversation above and update the skill library. Be ACTIVE — most sessions produce at least one skill update, even if small.
|
|
459
|
+
|
|
460
|
+
Target shape: CLASS-LEVEL skills with a rich SKILL.md and a references/ directory for session-specific detail. Not a flat list of narrow one-session skills.
|
|
461
|
+
|
|
462
|
+
Signals that warrant action:
|
|
463
|
+
- The user corrected your style, tone, format, verbosity, workflow, or approach.
|
|
464
|
+
- A non-trivial technique, fix, workaround, or debugging path emerged.
|
|
465
|
+
- A loaded skill turned out wrong, missing, or outdated — patch it now.
|
|
466
|
+
|
|
467
|
+
Preference order:
|
|
468
|
+
1. Patch a skill that was loaded or read this session.
|
|
469
|
+
2. Patch an existing umbrella skill.
|
|
470
|
+
3. Add references/, templates/, or scripts/ support under an existing skill.
|
|
471
|
+
4. Create a new class-level umbrella skill only when nothing fits.
|
|
472
|
+
|
|
473
|
+
Protected skills (bundled/hub-installed) must not be edited. Pinned skills may be patched but not archived.
|
|
474
|
+
|
|
475
|
+
Do NOT capture:
|
|
476
|
+
- Environment-dependent failures (missing binaries, unconfigured credentials).
|
|
477
|
+
- Negative claims about tools ("browser tools do not work").
|
|
478
|
+
- Transient errors that resolved during the session.
|
|
479
|
+
- One-off task narratives.
|
|
480
|
+
|
|
481
|
+
If a tool failed because of setup state, capture the FIX under an existing setup skill — never "this tool does not work" as a standalone constraint.
|
|
482
|
+
|
|
483
|
+
"Nothing to save." is a real option but should NOT be the default.`;
|
|
484
|
+
const COMBINED_REVIEW_PROMPT = `[Auto-review]
|
|
485
|
+
Review the conversation above and update two things.
|
|
486
|
+
|
|
487
|
+
**Memory**: who the user is. Save durable user preferences, personal details, and expectations with the memory tool.
|
|
488
|
+
|
|
489
|
+
**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.
|
|
490
|
+
|
|
491
|
+
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.`;
|
|
492
|
+
const CURATOR_PROMPT = `You are the skill curator. Maintain a healthy, class-level skill library, not a flat pile of narrow one-session skills.
|
|
493
|
+
|
|
494
|
+
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.
|
|
495
|
+
|
|
496
|
+
Right target shape: class-level skills with rich SKILL.md + references/, templates/, scripts/ support files for session-specific detail.
|
|
497
|
+
|
|
498
|
+
Hard rules:
|
|
499
|
+
1. NEVER hard-delete a skill. Archive (moving to .archive/) is the maximum destructive action; archives are recoverable, deletion is not.
|
|
500
|
+
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.
|
|
501
|
+
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.
|
|
502
|
+
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.
|
|
503
|
+
5. Judge overlap on CONTENT, not on usage counters.
|
|
504
|
+
6. Before archiving a merged skill, ensure its unique content was preserved in the umbrella.
|
|
505
|
+
|
|
506
|
+
How to work:
|
|
507
|
+
1. Scan the candidate list. Identify PREFIX CLUSTERS — skills sharing a first word or domain keyword (expect 10-25 clusters).
|
|
508
|
+
2. For each cluster with 2+ members, ask "what is the UMBRELLA CLASS these skills serve?" and consolidate:
|
|
509
|
+
a. MERGE INTO AN EXISTING UMBRELLA (patch a labeled section for each sibling's unique insight, then archive the siblings).
|
|
510
|
+
b. CREATE A NEW UMBRELLA SKILL.md covering the shared workflow with short labeled subsections, then archive the absorbed siblings.
|
|
511
|
+
c. DEMOTE session-specific detail to references/, templates/, or scripts/ under the umbrella.
|
|
512
|
+
3. Keep the umbrella body tight and scannable: exact commands, verbatim paths, ~100-200 lines; never invent flags or APIs.
|
|
513
|
+
|
|
514
|
+
Produce a YAML summary with exactly this shape:
|
|
515
|
+
consolidations:
|
|
516
|
+
- from: <old-skill-name>
|
|
517
|
+
into: <umbrella-skill-name>
|
|
518
|
+
reason: <one short sentence>
|
|
519
|
+
prunings:
|
|
520
|
+
- name: <skill-name>
|
|
521
|
+
reason: <one short sentence>
|
|
522
|
+
Nominate a pruning only when archival is clearly safe (stale AND genuinely obsolete or fully absorbed elsewhere).`;
|
|
523
|
+
const CURATOR_DRY_RUN_BANNER = `═══════════════════════════════════════════════════════════════
|
|
524
|
+
DRY-RUN — REPORT ONLY. DO NOT MUTATE THE SKILL LIBRARY.
|
|
525
|
+
═══════════════════════════════════════════════════════════════
|
|
526
|
+
|
|
527
|
+
This is a PREVIEW pass. Follow every instruction above EXCEPT:
|
|
528
|
+
• Do NOT call skill_manage with action=create, update, patch, delete, write_file, or remove_file.
|
|
529
|
+
• Do NOT move, copy, or rewrite any file under the skills tree.
|
|
530
|
+
|
|
531
|
+
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.
|
|
532
|
+
|
|
533
|
+
If you accidentally take a mutating action, say so explicitly in the summary.`;
|
|
534
|
+
const COMPLETION_SKILL_REVIEW_PROMPT = `[Auto-review — Skills · task complete]
|
|
535
|
+
Your current task now appears complete. Before wrapping up, review the approach and update the skill library via skill_manage.
|
|
536
|
+
|
|
537
|
+
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.
|
|
538
|
+
|
|
539
|
+
Do NOT modify output files or re-run the task. If you are still mid-task, ignore this.`;
|
|
540
|
+
function reviewPrompt(kind) {
|
|
541
|
+
if (kind === "memory") return MEMORY_REVIEW_PROMPT;
|
|
542
|
+
if (kind === "skill") return SKILL_REVIEW_PROMPT;
|
|
543
|
+
return COMBINED_REVIEW_PROMPT;
|
|
544
|
+
}
|
|
545
|
+
function sha256(text) {
|
|
546
|
+
return createHash("sha256").update(text).digest("hex");
|
|
547
|
+
}
|
|
548
|
+
function createPromptBundle(prompts) {
|
|
549
|
+
const canonical = JSON.stringify({
|
|
550
|
+
id: PROMPT_BUNDLE_ID,
|
|
551
|
+
version: 2,
|
|
552
|
+
prompts: Object.fromEntries(Object.entries(prompts).sort())
|
|
553
|
+
});
|
|
554
|
+
return Object.freeze({
|
|
555
|
+
id: PROMPT_BUNDLE_ID,
|
|
556
|
+
version: 2,
|
|
557
|
+
prompts: Object.freeze({ ...prompts }),
|
|
558
|
+
sha256: sha256(canonical)
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
const PROMPT_BUNDLE = createPromptBundle({
|
|
562
|
+
memory: MEMORY_REVIEW_PROMPT,
|
|
563
|
+
skill: SKILL_REVIEW_PROMPT,
|
|
564
|
+
combined: COMBINED_REVIEW_PROMPT,
|
|
565
|
+
curator: CURATOR_PROMPT,
|
|
566
|
+
completion: COMPLETION_SKILL_REVIEW_PROMPT
|
|
567
|
+
});
|
|
568
|
+
function verifyPromptBundle(bundle = PROMPT_BUNDLE) {
|
|
569
|
+
if (bundle.id !== "dsh-evolution@2" || bundle.version !== 2) return false;
|
|
570
|
+
const canonical = JSON.stringify({
|
|
571
|
+
id: PROMPT_BUNDLE_ID,
|
|
572
|
+
version: 2,
|
|
573
|
+
prompts: Object.fromEntries(Object.entries(bundle.prompts).sort())
|
|
574
|
+
});
|
|
575
|
+
return bundle.sha256 === sha256(canonical);
|
|
576
|
+
}
|
|
577
|
+
const DSH_AUTHORING_STANDARDS = `Follow the Hermes skill-authoring standards, translated to DSH tools.
|
|
578
|
+
|
|
579
|
+
Frontmatter:
|
|
580
|
+
- name: lowercase-hyphenated, <=64 chars, no spaces.
|
|
581
|
+
- description: ONE sentence, <=60 characters, ends with a period. State the capability, not the implementation. No marketing words. Do NOT repeat the skill name. Count the characters before saving.
|
|
582
|
+
- version: 0.1.0
|
|
583
|
+
- author: always the literal value "Hermes". NEVER fill it from the environment, git config, or any identity you can probe.
|
|
584
|
+
- platforms: declare [macos], [linux], and/or [windows] only when the skill is genuinely OS-bound; omit for portable skills.
|
|
585
|
+
- metadata.hermes.tags: a few Capitalized, Relevant, Tags.
|
|
586
|
+
- metadata.hermes.related_skills: [a, b] — name sibling skills this one builds on or is referenced by (optional; feeds the quality references factor).
|
|
587
|
+
|
|
588
|
+
Body section order (omit only when empty):
|
|
589
|
+
1. "# <Human Title>" then a 2-3 sentence intro: what it does, what it does NOT do, key dependency stance.
|
|
590
|
+
2. "## When to Use" — concrete trigger phrases.
|
|
591
|
+
3. "## Prerequisites" — exact env vars, install steps, credentials.
|
|
592
|
+
4. "## How to Run" — canonical invocation framed through DSH tools.
|
|
593
|
+
5. "## Quick Reference" — flat command/endpoint list.
|
|
594
|
+
6. "## Procedure" — numbered steps with copy-paste-exact commands.
|
|
595
|
+
7. "## Pitfalls" — known limits and rate limits.
|
|
596
|
+
8. "## Verification" — one check proving the skill worked.
|
|
597
|
+
|
|
598
|
+
DSH-tool framing:
|
|
599
|
+
- Reference DSH tools by name in backticks: \`bash\`, \`str_replace_editor\`, \`write\`, \`skill\`, \`skill_manage\`, \`memory\`.
|
|
600
|
+
- Do not name wrapped shell utilities when a DSH tool already covers them.
|
|
601
|
+
- Larger scripts belong under \`scripts/\` (written with \`skill_manage write_file\`) and are referenced from SKILL.md by relative path.
|
|
602
|
+
|
|
603
|
+
Quality bar:
|
|
604
|
+
- Prefer verbatim flags, paths, and APIs from the source. Never invent them.
|
|
605
|
+
- Keep it tight: ~100 lines simple, ~200 complex.
|
|
606
|
+
- No router/index/hub skills that only point at other skills.
|
|
607
|
+
- References go in \`references/\`, templates in \`templates/\`.`;
|
|
608
|
+
//#endregion
|
|
609
|
+
//#region lib/types/learn-prompt.js
|
|
610
|
+
/**
|
|
611
|
+
* Open-ended `/evolution learn` prompt builder.
|
|
612
|
+
*
|
|
613
|
+
* `learn` is open-ended: the user can name anything they can describe — a
|
|
614
|
+
* directory of code, an API doc URL, a workflow they just walked the agent
|
|
615
|
+
* through, or pasted notes. The prompt instructs the live agent to gather the
|
|
616
|
+
* named sources with its existing tools, then author a single SKILL.md via
|
|
617
|
+
* `skill_manage` following `DSH_AUTHORING_STANDARDS`. There is no separate
|
|
618
|
+
* distillation engine and no model-tool footprint.
|
|
619
|
+
*/
|
|
620
|
+
/**
|
|
621
|
+
* Build the agent prompt for an open-ended `/evolution learn` request.
|
|
622
|
+
*
|
|
623
|
+
* @param userRequest free-text the user gave after `/evolution learn`; an
|
|
624
|
+
* empty string falls back to "the workflow we just went through".
|
|
625
|
+
* @returns a complete instruction the agent runs as a normal turn.
|
|
626
|
+
*/
|
|
627
|
+
function buildLearnPrompt(userRequest) {
|
|
628
|
+
return [
|
|
629
|
+
"[/learn] The user wants you to learn a reusable skill from the request below, and save it.",
|
|
630
|
+
"",
|
|
631
|
+
"THE REQUEST:",
|
|
632
|
+
userRequest.trim() || "the workflow we just went through in this conversation — review the steps taken and distill them into a reusable skill",
|
|
633
|
+
"",
|
|
634
|
+
"The request is open-ended and may mix two kinds of content, in any order: SOURCES to gather (directories, file paths, URLs, \"what we just did\", pasted notes) AND REQUIREMENTS that shape the skill (what to focus on, what to leave out, scope, naming, the angle to take). Treat EVERY part of the request as load-bearing. In particular, prose that comes after a path or link is NOT incidental — it is the user telling you what they want from that source. A request like `<url> focus on the auth flow, skip the deprecated endpoints` means: gather the URL AND honor \"focus on auth, skip deprecated\" as authoring requirements. Never fetch the first source and ignore the rest.",
|
|
635
|
+
"",
|
|
636
|
+
"Do this:",
|
|
637
|
+
"1. Gather every source the user named, using the tools you already have — reads and searches for local files or directories, web access for URLs, this conversation history if they referred to something you just did, and the text they pasted as-is. If the request is ambiguous about scope, make a reasonable choice and note it; do not stall.",
|
|
638
|
+
"2. Author ONE SKILL.md, applying every requirement, focus, and constraint in the request — these govern what the SKILL.md covers and emphasizes, not just which sources you read.",
|
|
639
|
+
"3. Save it with the `skill_manage` tool (action=\"create\"). Pick a sensible category. If the procedure needs a non-trivial script, add it under the skill's `scripts/` with `skill_manage` write_file and reference it by relative path.",
|
|
640
|
+
"",
|
|
641
|
+
DSH_AUTHORING_STANDARDS,
|
|
642
|
+
"",
|
|
643
|
+
"When done, tell the user the skill name, its category, and a one-line summary of what it captured."
|
|
644
|
+
].join("\n");
|
|
645
|
+
}
|
|
646
|
+
//#endregion
|
|
245
647
|
//#region lib/types/threats.js
|
|
246
648
|
/**
|
|
247
649
|
* Threat scanning for agent-authored memory and skill content.
|
|
@@ -417,10 +819,12 @@ const SCOPE_ORDER = {
|
|
|
417
819
|
context: 2,
|
|
418
820
|
strict: 3
|
|
419
821
|
};
|
|
822
|
+
const NO_SCAN_OPTIONS = {};
|
|
420
823
|
/**
|
|
421
824
|
* Scan text at `scope`. Patterns are cumulative: `strict` includes all scopes.
|
|
825
|
+
* `options.excludeLabels` removes matching patterns without changing `scope`.
|
|
422
826
|
*/
|
|
423
|
-
function scanThreats(text, scope = "strict", maxScanChars = 65536) {
|
|
827
|
+
function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
|
|
424
828
|
const findings = [];
|
|
425
829
|
if (ZERO_WIDTH_CHARS.test(text)) findings.push({
|
|
426
830
|
label: "unicode_zero_width",
|
|
@@ -433,8 +837,10 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536) {
|
|
|
433
837
|
scope
|
|
434
838
|
});
|
|
435
839
|
const normalized = text.normalize("NFKC").slice(0, maxScanChars);
|
|
840
|
+
const excluded = new Set(options.excludeLabels ?? []);
|
|
436
841
|
for (const pattern of PATTERNS) {
|
|
437
842
|
if (SCOPE_ORDER[pattern.scope] > SCOPE_ORDER[scope]) continue;
|
|
843
|
+
if (excluded.has(pattern.label)) continue;
|
|
438
844
|
if (pattern.regex.test(normalized)) findings.push({
|
|
439
845
|
label: pattern.label,
|
|
440
846
|
category: pattern.category,
|
|
@@ -444,24 +850,24 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536) {
|
|
|
444
850
|
return findings;
|
|
445
851
|
}
|
|
446
852
|
/** 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);
|
|
853
|
+
function evaluateThreat(text, scope = "strict", maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
|
|
854
|
+
const findings = scanThreats(text, scope, maxScanChars, options);
|
|
449
855
|
return {
|
|
450
856
|
blocked: findings.length > 0,
|
|
451
857
|
findings
|
|
452
858
|
};
|
|
453
859
|
}
|
|
454
860
|
/** User-facing block message for memory writes. */
|
|
455
|
-
function scanMemoryThreats(text, maxScanChars = 65536) {
|
|
456
|
-
const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars);
|
|
861
|
+
function scanMemoryThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
|
|
862
|
+
const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
|
|
457
863
|
if (!blocked) return null;
|
|
458
864
|
const pattern = findings.find((f) => f.category !== "unicode_obfuscation");
|
|
459
865
|
if (pattern) return `Blocked by security scan (${pattern.label}). Rephrase without instruction-like language.`;
|
|
460
866
|
return "Blocked by security scan: invisible or potentially malicious Unicode detected.";
|
|
461
867
|
}
|
|
462
868
|
/** User-facing block message for skill content writes. */
|
|
463
|
-
function scanContentThreats(text, maxScanChars = 65536) {
|
|
464
|
-
const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars);
|
|
869
|
+
function scanContentThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
|
|
870
|
+
const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
|
|
465
871
|
if (!blocked) return null;
|
|
466
872
|
return `Blocked by security scan (${findings[0]?.label ?? "unknown"}). This content appears to contain potentially malicious instructions.`;
|
|
467
873
|
}
|
|
@@ -471,7 +877,13 @@ function scanContentThreats(text, maxScanChars = 65536) {
|
|
|
471
877
|
* File-backed durable memory with Hermes-compatible semantics.
|
|
472
878
|
* Stores are MEMORY.md and USER.md under $DSH_HOME/memories (~/.dsh/memories).
|
|
473
879
|
*/
|
|
474
|
-
|
|
880
|
+
/**
|
|
881
|
+
* Read-guard factor: a memory file larger than this multiple of its target's
|
|
882
|
+
* char limit is treated as externally corrupted and skipped instead of being
|
|
883
|
+
* read whole (aligned with claw `tools/memory.ts` size guard, which uses the
|
|
884
|
+
* same 10× bound around a file that should never exceed the store limit).
|
|
885
|
+
*/
|
|
886
|
+
const READ_GUARD_FACTOR = 10;
|
|
475
887
|
function memoryRoot(env = process.env) {
|
|
476
888
|
return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "memories");
|
|
477
889
|
}
|
|
@@ -506,7 +918,24 @@ var MemoryStore = class {
|
|
|
506
918
|
limitFor(target) {
|
|
507
919
|
return target === "memory" ? this.memoryLimit : this.userLimit;
|
|
508
920
|
}
|
|
921
|
+
/**
|
|
922
|
+
* Read-guard probe: `{ size, limit }` when the on-disk file exceeds
|
|
923
|
+
* `limit * READ_GUARD_FACTOR` bytes, `null` when it is absent, unknown
|
|
924
|
+
* (backend without a size probe), under the bound, or the target has no
|
|
925
|
+
* limit configured.
|
|
926
|
+
*/
|
|
927
|
+
async oversizedFile(target) {
|
|
928
|
+
const size = await this.io.size?.(fileFor(this.root, target));
|
|
929
|
+
if (size === null || size === void 0) return null;
|
|
930
|
+
const limit = this.limitFor(target);
|
|
931
|
+
if (limit <= 0) return null;
|
|
932
|
+
return size > limit * READ_GUARD_FACTOR ? {
|
|
933
|
+
size,
|
|
934
|
+
limit
|
|
935
|
+
} : null;
|
|
936
|
+
}
|
|
509
937
|
async read(target) {
|
|
938
|
+
if (await this.oversizedFile(target)) return [];
|
|
510
939
|
const raw = await this.io.readText(fileFor(this.root, target));
|
|
511
940
|
return raw === null ? [] : [...new Set(normalizeEntries(raw))];
|
|
512
941
|
}
|
|
@@ -534,7 +963,67 @@ var MemoryStore = class {
|
|
|
534
963
|
limit: this.limitFor(target)
|
|
535
964
|
};
|
|
536
965
|
}
|
|
966
|
+
/**
|
|
967
|
+
* StorageHint percentage must clamp at 100 like the render header: a drifted
|
|
968
|
+
* entry can push chars past the limit, and "Storage at 125%" contradicts the
|
|
969
|
+
* clamped usage indicator.
|
|
970
|
+
*/
|
|
971
|
+
storageHint(target, chars) {
|
|
972
|
+
const limit = this.limitFor(target);
|
|
973
|
+
if (limit <= 0) return "";
|
|
974
|
+
const percent = Math.min(100, Math.floor(chars * 100 / limit));
|
|
975
|
+
return percent >= 80 ? ` ⚠️ Storage at ${percent}% (${chars}/${limit} chars).` : "";
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Best-effort raw-copy backup of the on-disk file to `<file>.bak.<stamp>`
|
|
979
|
+
* before a refusal, so an externally modified (or oversized) file stays
|
|
980
|
+
* recoverable. Copies bytes instead of reading them so a pathologically
|
|
981
|
+
* large file is never loaded just to back it up. Failure to back up does
|
|
982
|
+
* not change the refusal semantics.
|
|
983
|
+
*/
|
|
984
|
+
async backupFile(target) {
|
|
985
|
+
const path = fileFor(this.root, target);
|
|
986
|
+
const unique = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 14)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
987
|
+
try {
|
|
988
|
+
await this.io.copy(path, `${path}.bak.${unique}`);
|
|
989
|
+
return `${path}.bak.${unique}`;
|
|
990
|
+
} catch {
|
|
991
|
+
return null;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Read-guard refusal for write paths. Returns the refusal result when the
|
|
996
|
+
* target file is oversized, `null` otherwise. The file is skipped for
|
|
997
|
+
* reading (never loaded), backed up by raw copy, and the model is told to
|
|
998
|
+
* fix it manually — mirroring the drift refusal so corrupted state is never
|
|
999
|
+
* silently overwritten.
|
|
1000
|
+
*/
|
|
1001
|
+
async oversizedRefusal(target) {
|
|
1002
|
+
const oversized = await this.oversizedFile(target);
|
|
1003
|
+
if (!oversized) return null;
|
|
1004
|
+
const backup = await this.backupFile(target);
|
|
1005
|
+
const suffix = backup ? ` A backup was saved to ${basename(backup)}.` : "";
|
|
1006
|
+
return {
|
|
1007
|
+
ok: false,
|
|
1008
|
+
message: `Memory file is ${oversized.size} bytes (limit ${oversized.limit * READ_GUARD_FACTOR}) — skipping read.${suffix} Fix the file manually, then retry.`,
|
|
1009
|
+
entries: [],
|
|
1010
|
+
chars: 0,
|
|
1011
|
+
limit: this.limitFor(target)
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
537
1014
|
async add(target, facts) {
|
|
1015
|
+
const refusal = await this.oversizedRefusal(target);
|
|
1016
|
+
if (refusal) return refusal;
|
|
1017
|
+
if (await this.detectDrift(target)) {
|
|
1018
|
+
const backup = await this.backupFile(target);
|
|
1019
|
+
return {
|
|
1020
|
+
ok: false,
|
|
1021
|
+
message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
|
|
1022
|
+
entries: [],
|
|
1023
|
+
chars: 0,
|
|
1024
|
+
limit: this.limitFor(target)
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
538
1027
|
const content = facts.trim();
|
|
539
1028
|
if (!content) return {
|
|
540
1029
|
ok: false,
|
|
@@ -556,7 +1045,7 @@ var MemoryStore = class {
|
|
|
556
1045
|
this.resetFailures();
|
|
557
1046
|
return {
|
|
558
1047
|
ok: true,
|
|
559
|
-
message:
|
|
1048
|
+
message: `Entry already exists (no duplicate added).${this.storageHint(target, entries.join(ENTRY_DELIMITER).length)}`,
|
|
560
1049
|
entries,
|
|
561
1050
|
chars: entries.join(ENTRY_DELIMITER).length,
|
|
562
1051
|
limit: this.limitFor(target)
|
|
@@ -564,12 +1053,13 @@ var MemoryStore = class {
|
|
|
564
1053
|
}
|
|
565
1054
|
const next = [...entries, this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n${content}` : content];
|
|
566
1055
|
const total = next.join(ENTRY_DELIMITER).length;
|
|
567
|
-
|
|
1056
|
+
const addLimit = this.limitFor(target);
|
|
1057
|
+
if (addLimit > 0 && total > addLimit) return this.failure(target, `Adding this entry would exceed the ${addLimit} char limit. Consolidate or remove stale entries, then retry.`, entries);
|
|
568
1058
|
await this.write(target, next);
|
|
569
1059
|
this.resetFailures();
|
|
570
1060
|
return {
|
|
571
1061
|
ok: true,
|
|
572
|
-
message:
|
|
1062
|
+
message: `Entry added.${this.storageHint(target, total)}`,
|
|
573
1063
|
entries: next,
|
|
574
1064
|
chars: total,
|
|
575
1065
|
limit: this.limitFor(target)
|
|
@@ -590,6 +1080,8 @@ var MemoryStore = class {
|
|
|
590
1080
|
chars: 0,
|
|
591
1081
|
limit: this.limitFor(target)
|
|
592
1082
|
};
|
|
1083
|
+
const refusal = await this.oversizedRefusal(target);
|
|
1084
|
+
if (refusal) return refusal;
|
|
593
1085
|
const content = action === "replace" ? (facts ?? "").trim() : "";
|
|
594
1086
|
if (action === "replace" && !content) return {
|
|
595
1087
|
ok: false,
|
|
@@ -608,13 +1100,16 @@ var MemoryStore = class {
|
|
|
608
1100
|
limit: this.limitFor(target)
|
|
609
1101
|
};
|
|
610
1102
|
}
|
|
611
|
-
if (await this.detectDrift(target))
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
1103
|
+
if (await this.detectDrift(target)) {
|
|
1104
|
+
const backup = await this.backupFile(target);
|
|
1105
|
+
return {
|
|
1106
|
+
ok: false,
|
|
1107
|
+
message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
|
|
1108
|
+
entries: [],
|
|
1109
|
+
chars: 0,
|
|
1110
|
+
limit: this.limitFor(target)
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
618
1113
|
const entries = await this.read(target);
|
|
619
1114
|
const matches = entries.map((entry, index) => ({
|
|
620
1115
|
entry,
|
|
@@ -633,12 +1128,13 @@ var MemoryStore = class {
|
|
|
633
1128
|
if (action === "remove") next.splice(index, 1);
|
|
634
1129
|
else next[index] = content;
|
|
635
1130
|
const total = next.join(ENTRY_DELIMITER).length;
|
|
636
|
-
|
|
1131
|
+
const mutateLimit = this.limitFor(target);
|
|
1132
|
+
if (mutateLimit > 0 && total > mutateLimit) return this.failure(target, `Resulting memory would exceed the ${mutateLimit} char limit.`, entries);
|
|
637
1133
|
await this.write(target, next);
|
|
638
1134
|
this.resetFailures();
|
|
639
1135
|
return {
|
|
640
1136
|
ok: true,
|
|
641
|
-
message: `Entry ${action === "remove" ? "removed" : "replaced"}
|
|
1137
|
+
message: `Entry ${action === "remove" ? "removed" : "replaced"}.${this.storageHint(target, total)}`,
|
|
642
1138
|
entries: next,
|
|
643
1139
|
chars: total,
|
|
644
1140
|
limit: this.limitFor(target)
|
|
@@ -652,13 +1148,18 @@ var MemoryStore = class {
|
|
|
652
1148
|
chars: 0,
|
|
653
1149
|
limit: this.limitFor(target)
|
|
654
1150
|
};
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
1151
|
+
const refusal = await this.oversizedRefusal(target);
|
|
1152
|
+
if (refusal) return refusal;
|
|
1153
|
+
if (await this.detectDrift(target)) {
|
|
1154
|
+
const backup = await this.backupFile(target);
|
|
1155
|
+
return {
|
|
1156
|
+
ok: false,
|
|
1157
|
+
message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
|
|
1158
|
+
entries: [],
|
|
1159
|
+
chars: 0,
|
|
1160
|
+
limit: this.limitFor(target)
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
662
1163
|
const entries = await this.read(target);
|
|
663
1164
|
const working = [...entries];
|
|
664
1165
|
for (const [index, op] of operations.entries()) {
|
|
@@ -726,12 +1227,13 @@ var MemoryStore = class {
|
|
|
726
1227
|
}
|
|
727
1228
|
}
|
|
728
1229
|
const total = working.join(ENTRY_DELIMITER).length;
|
|
729
|
-
|
|
1230
|
+
const batchLimit = this.limitFor(target);
|
|
1231
|
+
if (batchLimit > 0 && total > batchLimit) return this.failure(target, `Batch result (${total} chars) exceeds the ${batchLimit} limit. Remove or shorten more entries in the same batch.`, entries);
|
|
730
1232
|
await this.write(target, working);
|
|
731
1233
|
this.resetFailures();
|
|
732
1234
|
return {
|
|
733
1235
|
ok: true,
|
|
734
|
-
message: `Applied ${operations.length} operation(s)
|
|
1236
|
+
message: `Applied ${operations.length} operation(s).${this.storageHint(target, total)}`,
|
|
735
1237
|
entries: working,
|
|
736
1238
|
chars: total,
|
|
737
1239
|
limit: this.limitFor(target)
|
|
@@ -741,172 +1243,223 @@ var MemoryStore = class {
|
|
|
741
1243
|
const memory = await this.read("memory");
|
|
742
1244
|
const user = await this.read("user");
|
|
743
1245
|
const parts = [];
|
|
744
|
-
for (const [target, entries] of [[
|
|
1246
|
+
for (const [target, label, entries] of [[
|
|
1247
|
+
"memory",
|
|
1248
|
+
"Memory",
|
|
1249
|
+
memory
|
|
1250
|
+
], [
|
|
1251
|
+
"user",
|
|
1252
|
+
"User Profile",
|
|
1253
|
+
user
|
|
1254
|
+
]]) {
|
|
1255
|
+
const oversized = entries.length === 0 ? await this.oversizedFile(target) : null;
|
|
1256
|
+
if (oversized) {
|
|
1257
|
+
parts.push(`## ${label} — file skipped: ${oversized.size} bytes (limit ${oversized.limit * READ_GUARD_FACTOR}); not read`);
|
|
1258
|
+
continue;
|
|
1259
|
+
}
|
|
745
1260
|
const safe = entries.filter((entry) => !scanMemoryThreats(entry));
|
|
746
1261
|
if (safe.length > 0) {
|
|
747
1262
|
const body = safe.join(ENTRY_DELIMITER);
|
|
1263
|
+
const limit = this.limitFor(target);
|
|
1264
|
+
const pct = limit > 0 ? Math.min(100, Math.floor(body.length * 100 / limit)) : 0;
|
|
748
1265
|
const note = safe.length === entries.length ? "" : ` (${entries.length - safe.length} threat-matched entries filtered)`;
|
|
749
|
-
parts.push(`## ${
|
|
1266
|
+
parts.push(`## ${label} (${safe.length} entries) [${pct}% — ${body.length}/${limit} chars]${note}\n${body}`);
|
|
750
1267
|
}
|
|
751
1268
|
}
|
|
752
1269
|
return parts.join("\n\n");
|
|
753
1270
|
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
await this.write("memory", snapshot.memory);
|
|
763
|
-
await this.write("user", snapshot.user);
|
|
764
|
-
}
|
|
1271
|
+
/**
|
|
1272
|
+
* Detect on-disk drift: true when the file is not in the canonical
|
|
1273
|
+
* `render(normalizeEntries(raw))` form. This catches structural anomalies
|
|
1274
|
+
* the writer would quietly normalize away (empty/`§`-only entries, stray
|
|
1275
|
+
* blank lines, leading/trailing delimiters) that indicate the file was
|
|
1276
|
+
* edited outside MemoryStore. Purely single-canonical content reaches the
|
|
1277
|
+
* same serialization and returns false, so a normal write is never flagged.
|
|
1278
|
+
*/
|
|
765
1279
|
async detectDrift(target) {
|
|
1280
|
+
if (await this.oversizedFile(target)) return true;
|
|
766
1281
|
const raw = await this.io.readText(fileFor(this.root, target));
|
|
767
1282
|
if (raw === null) return false;
|
|
768
|
-
|
|
1283
|
+
const entries = normalizeEntries(raw);
|
|
1284
|
+
const limit = this.limitFor(target);
|
|
1285
|
+
if (limit > 0 && entries.some((entry) => entry.length > limit)) return true;
|
|
1286
|
+
return render(entries) !== raw;
|
|
769
1287
|
}
|
|
770
1288
|
};
|
|
771
1289
|
//#endregion
|
|
772
|
-
//#region lib/types/
|
|
1290
|
+
//#region lib/types/mutations.js
|
|
773
1291
|
/**
|
|
774
|
-
*
|
|
775
|
-
*
|
|
776
|
-
*
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
* Every prompt is pinned in a versioned bundle. Review workers verify the
|
|
780
|
-
* bundle digest before spending a model call, so a partially-patched
|
|
781
|
-
* deployment fails closed instead of silently running a truncated prompt.
|
|
1292
|
+
* Curator/author audit trail: `.mutations.json` records every skill mutation
|
|
1293
|
+
* with before/after content hashes so any automated edit is reviewable and
|
|
1294
|
+
* replayable. Best-effort persistence, mirroring the usage sidecar posture.
|
|
1295
|
+
* @module @lmzhen/dsh-evolution-core
|
|
782
1296
|
*/
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
1. Has the user revealed things about themselves — persona, desires, preferences, or personal details worth remembering?
|
|
789
|
-
2. Has the user expressed expectations about how you should behave, their work style, or ways they want you to operate?
|
|
790
|
-
|
|
791
|
-
If something stands out, save it using the memory tool.
|
|
792
|
-
If nothing is worth saving, just say "Nothing to save." and stop.`;
|
|
793
|
-
const SKILL_REVIEW_PROMPT = `[Auto-review — Skills]
|
|
794
|
-
Review the conversation above and update the skill library. Be ACTIVE — most sessions produce at least one skill update, even if small.
|
|
795
|
-
|
|
796
|
-
Target shape: CLASS-LEVEL skills with a rich SKILL.md and a references/ directory for session-specific detail. Not a flat list of narrow one-session skills.
|
|
797
|
-
|
|
798
|
-
Signals that warrant action:
|
|
799
|
-
- The user corrected your style, tone, format, verbosity, workflow, or approach.
|
|
800
|
-
- A non-trivial technique, fix, workaround, or debugging path emerged.
|
|
801
|
-
- A loaded skill turned out wrong, missing, or outdated — patch it now.
|
|
802
|
-
|
|
803
|
-
Preference order:
|
|
804
|
-
1. Patch a skill that was loaded or read this session.
|
|
805
|
-
2. Patch an existing umbrella skill.
|
|
806
|
-
3. Add references/, templates/, or scripts/ support under an existing skill.
|
|
807
|
-
4. Create a new class-level umbrella skill only when nothing fits.
|
|
808
|
-
|
|
809
|
-
Protected skills (bundled/hub-installed) must not be edited. Pinned skills may be patched but not archived.
|
|
810
|
-
|
|
811
|
-
Do NOT capture:
|
|
812
|
-
- Environment-dependent failures (missing binaries, unconfigured credentials).
|
|
813
|
-
- Negative claims about tools ("browser tools do not work").
|
|
814
|
-
- Transient errors that resolved during the session.
|
|
815
|
-
- One-off task narratives.
|
|
816
|
-
|
|
817
|
-
If a tool failed because of setup state, capture the FIX under an existing setup skill — never "this tool does not work" as a standalone constraint.
|
|
818
|
-
|
|
819
|
-
"Nothing to save." is a real option but should NOT be the default.`;
|
|
820
|
-
const COMBINED_REVIEW_PROMPT = `[Auto-review]
|
|
821
|
-
Review the conversation above and update two things.
|
|
822
|
-
|
|
823
|
-
**Memory**: who the user is. Save durable user preferences, personal details, and expectations with the memory tool.
|
|
824
|
-
|
|
825
|
-
**Skills**: how to do this class of task. Be ACTIVE. Follow the same class-level umbrella policy, preference order, protected-skill rules, and do-not-capture list as a skill review.
|
|
826
|
-
|
|
827
|
-
Act on whichever dimension has real signal. If genuinely nothing stands out on either, say "Nothing to save." and stop — but don't reach for that conclusion as a default.`;
|
|
828
|
-
const CURATOR_PROMPT = `You are the skill curator. Maintain a healthy, class-level skill library.
|
|
829
|
-
|
|
830
|
-
Rules:
|
|
831
|
-
1. NEVER hard-delete a skill. Archive is the maximum destructive action.
|
|
832
|
-
2. Do not touch bundled, hub-installed, or pinned skills.
|
|
833
|
-
3. Do not archive recently-created or never-used skills without strong evidence.
|
|
834
|
-
4. Prefer merging narrow skills into class-level umbrellas.
|
|
835
|
-
5. Before archiving a merged skill, ensure its unique content was preserved.
|
|
836
|
-
|
|
837
|
-
Produce a YAML summary:
|
|
838
|
-
consolidations:
|
|
839
|
-
- from: <old-skill-name>
|
|
840
|
-
into: <umbrella-skill-name>
|
|
841
|
-
reason: <one short sentence>
|
|
842
|
-
prunings:
|
|
843
|
-
- name: <skill-name>
|
|
844
|
-
reason: <one short sentence>`;
|
|
845
|
-
function reviewPrompt(kind) {
|
|
846
|
-
if (kind === "memory") return MEMORY_REVIEW_PROMPT;
|
|
847
|
-
if (kind === "skill") return SKILL_REVIEW_PROMPT;
|
|
848
|
-
return COMBINED_REVIEW_PROMPT;
|
|
1297
|
+
const DEFAULT_MUTATION_CAP = 500;
|
|
1298
|
+
/** Version of the `.mutations.json` file shape; writers always emit the current one. */
|
|
1299
|
+
const MUTATIONS_FILE_VERSION = 1;
|
|
1300
|
+
function mutationsFile(root) {
|
|
1301
|
+
return join(root, ".mutations.json");
|
|
849
1302
|
}
|
|
850
|
-
function
|
|
851
|
-
return createHash("sha256").update(
|
|
1303
|
+
function contentHash(content) {
|
|
1304
|
+
return createHash("sha256").update(content).digest("hex");
|
|
852
1305
|
}
|
|
853
|
-
function
|
|
854
|
-
const
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
1306
|
+
async function loadMutations(root, io = nodeEvolutionIo()) {
|
|
1307
|
+
const raw = await io.readText(mutationsFile(root));
|
|
1308
|
+
if (raw === null) return [];
|
|
1309
|
+
try {
|
|
1310
|
+
const parsed = JSON.parse(raw);
|
|
1311
|
+
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");
|
|
1312
|
+
} catch {
|
|
1313
|
+
return [];
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
/** Append one record, trim to `cap`, and write atomically (versioned shape). */
|
|
1317
|
+
async function recordMutation(root, io, record, cap = 500) {
|
|
1318
|
+
const existing = await loadMutations(root, io);
|
|
1319
|
+
existing.push(record);
|
|
1320
|
+
const trimmed = existing.length > cap ? existing.slice(existing.length - cap) : existing;
|
|
1321
|
+
await io.writeText(mutationsFile(root), JSON.stringify({
|
|
861
1322
|
version: 1,
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
});
|
|
1323
|
+
records: trimmed
|
|
1324
|
+
}, null, 2));
|
|
865
1325
|
}
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
1326
|
+
//#endregion
|
|
1327
|
+
//#region lib/types/quality.js
|
|
1328
|
+
/**
|
|
1329
|
+
* Quality scoring and near-duplicate detection for the curated skill library.
|
|
1330
|
+
*
|
|
1331
|
+
* Pure functions over data inputs so the scoring policy is unit-testable and
|
|
1332
|
+
* the same math feeds the usage sidecar, the `skill_manage review` surface and
|
|
1333
|
+
* the learning graph. Weights follow the Hermes/hermes-claw six-factor model;
|
|
1334
|
+
* mutation maturity is a documented DSH approximation (single per-month patch
|
|
1335
|
+
* trend ratio replaces the claw timestamp-trend formula, since DSH usage
|
|
1336
|
+
* records only carry the last patched timestamp).
|
|
1337
|
+
* @module @lmzhen/dsh-evolution-core
|
|
1338
|
+
*/
|
|
1339
|
+
const QUALITY_WEIGHTS = {
|
|
1340
|
+
usageFrequency: .25,
|
|
1341
|
+
stability: .2,
|
|
1342
|
+
recency: .2,
|
|
1343
|
+
references: .1,
|
|
1344
|
+
mutationMaturity: .2,
|
|
1345
|
+
richness: .05
|
|
1346
|
+
};
|
|
1347
|
+
/** Score below which a skill is flagged for review. */
|
|
1348
|
+
const LOW_QUALITY_THRESHOLD = .3;
|
|
1349
|
+
function clamp01(value) {
|
|
1350
|
+
return Math.max(0, Math.min(1, value));
|
|
1351
|
+
}
|
|
1352
|
+
function daysBetween(from, now) {
|
|
1353
|
+
return Math.max(0, (now.getTime() - new Date(from).getTime()) / 864e5);
|
|
1354
|
+
}
|
|
1355
|
+
function computeQualityScores(input) {
|
|
1356
|
+
const now = input.now ?? /* @__PURE__ */ new Date();
|
|
1357
|
+
const scores = /* @__PURE__ */ new Map();
|
|
1358
|
+
for (const [name, record] of input.usage) {
|
|
1359
|
+
const ageDays = Math.max(1, daysBetween(record.created_at, now));
|
|
1360
|
+
const idleDays = daysBetween(latestActivityAt(record) ?? record.created_at, now);
|
|
1361
|
+
const patchCount = record.patch_count;
|
|
1362
|
+
const useCount = record.use_count;
|
|
1363
|
+
const usageFrequency = clamp01(useCount / ageDays);
|
|
1364
|
+
const stability = useCount === 0 ? 1 : clamp01(1 - patchCount / useCount);
|
|
1365
|
+
const recency = idleDays < 30 ? 1 : clamp01(1 - (idleDays - 30) / 150);
|
|
1366
|
+
const references = clamp01((input.referenceCounts?.get(name) ?? 0) / 3);
|
|
1367
|
+
const mutationMaturity = patchCount === 0 ? .3 : patchCount === 1 ? .4 : clamp01((patchCount - 1) / Math.max(1, ageDays / 30));
|
|
1368
|
+
const richness = clamp01((input.supportDirs?.get(name) ?? 0) * .175);
|
|
1369
|
+
const factors = {
|
|
1370
|
+
usageFrequency,
|
|
1371
|
+
stability,
|
|
1372
|
+
recency,
|
|
1373
|
+
references,
|
|
1374
|
+
mutationMaturity,
|
|
1375
|
+
richness
|
|
1376
|
+
};
|
|
1377
|
+
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;
|
|
1378
|
+
scores.set(name, {
|
|
1379
|
+
score,
|
|
1380
|
+
factors,
|
|
1381
|
+
warn: score < LOW_QUALITY_THRESHOLD
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
return scores;
|
|
1385
|
+
}
|
|
1386
|
+
function normalize(content) {
|
|
1387
|
+
return content.toLowerCase().replace(/\s+/g, " ").trim();
|
|
1388
|
+
}
|
|
1389
|
+
function contentHash$1(content) {
|
|
1390
|
+
return createHash("sha256").update(normalize(content)).digest("hex");
|
|
1391
|
+
}
|
|
1392
|
+
function tokenize(content) {
|
|
1393
|
+
return new Set(normalize(content).split(/[^a-z0-9\u4e00-\u9fff]+/).filter(Boolean));
|
|
1394
|
+
}
|
|
1395
|
+
function jaccard(a, b) {
|
|
1396
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
1397
|
+
let intersection = 0;
|
|
1398
|
+
for (const token of a) if (b.has(token)) intersection += 1;
|
|
1399
|
+
return intersection / (a.size + b.size - intersection);
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* Two-phase near-duplicate clustering: exact normalized-hash groups first,
|
|
1403
|
+
* then token-Jaccard edges at {@link DEDUP_SIMILARITY_THRESHOLD} with a token
|
|
1404
|
+
* ratio guard, union-find across the whole set.
|
|
1405
|
+
*/
|
|
1406
|
+
function computeDedupGroups(input) {
|
|
1407
|
+
const threshold = input.threshold ?? .95;
|
|
1408
|
+
const names = [...input.contents.keys()];
|
|
1409
|
+
const hashes = /* @__PURE__ */ new Map();
|
|
1410
|
+
for (const name of names) {
|
|
1411
|
+
const hash = contentHash$1(input.contents.get(name) ?? "");
|
|
1412
|
+
const bucket = hashes.get(hash);
|
|
1413
|
+
if (bucket) bucket.push(name);
|
|
1414
|
+
else hashes.set(hash, [name]);
|
|
1415
|
+
}
|
|
1416
|
+
const parent = /* @__PURE__ */ new Map();
|
|
1417
|
+
const find = (x) => {
|
|
1418
|
+
const root = parent.get(x) ?? x;
|
|
1419
|
+
if (root !== x) parent.set(x, find(root));
|
|
1420
|
+
return parent.get(x) ?? x;
|
|
1421
|
+
};
|
|
1422
|
+
const union = (a, b) => {
|
|
1423
|
+
const [ra, rb] = [find(a), find(b)];
|
|
1424
|
+
if (ra !== rb) parent.set(rb, ra);
|
|
1425
|
+
};
|
|
1426
|
+
for (const [hash, bucketNames] of hashes) {
|
|
1427
|
+
const first = bucketNames[0];
|
|
1428
|
+
if (first === void 0 || bucketNames.length === 1) continue;
|
|
1429
|
+
for (let index = 1; index < bucketNames.length; index += 1) {
|
|
1430
|
+
const peer = bucketNames[index];
|
|
1431
|
+
if (peer) union(first, peer);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
const tokens = /* @__PURE__ */ new Map();
|
|
1435
|
+
const tokenSet = (name) => {
|
|
1436
|
+
let set = tokens.get(name);
|
|
1437
|
+
if (!set) {
|
|
1438
|
+
set = tokenize(input.contents.get(name) ?? "");
|
|
1439
|
+
tokens.set(name, set);
|
|
1440
|
+
}
|
|
1441
|
+
return set;
|
|
1442
|
+
};
|
|
1443
|
+
for (let index = 0; index < names.length; index += 1) {
|
|
1444
|
+
const a = names[index];
|
|
1445
|
+
if (a === void 0) continue;
|
|
1446
|
+
for (let other = index + 1; other < names.length; other += 1) {
|
|
1447
|
+
const b = names[other];
|
|
1448
|
+
if (b === void 0) continue;
|
|
1449
|
+
const [ta, tb] = [tokenSet(a), tokenSet(b)];
|
|
1450
|
+
if (Math.max(ta.size, tb.size) / Math.max(1, Math.min(ta.size, tb.size)) > 5) continue;
|
|
1451
|
+
if (jaccard(ta, tb) >= threshold) union(a, b);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1455
|
+
for (const name of names) {
|
|
1456
|
+
const root = find(name);
|
|
1457
|
+
const group = groups.get(root);
|
|
1458
|
+
if (group) group.push(name);
|
|
1459
|
+
else groups.set(root, [name]);
|
|
1460
|
+
}
|
|
1461
|
+
return [...groups.values()].filter((group) => group.length > 1);
|
|
879
1462
|
}
|
|
880
|
-
const DSH_AUTHORING_STANDARDS = `Follow the Hermes skill-authoring standards, translated to DSH tools.
|
|
881
|
-
|
|
882
|
-
Frontmatter:
|
|
883
|
-
- name: lowercase-hyphenated, <=64 chars, no spaces.
|
|
884
|
-
- description: ONE sentence, <=60 characters, ends with a period. State the capability, not the implementation. No marketing words. Do NOT repeat the skill name. Count the characters before saving.
|
|
885
|
-
- version: 0.1.0
|
|
886
|
-
- author: always the literal value "Hermes". NEVER fill it from the environment, git config, or any identity you can probe.
|
|
887
|
-
- platforms: declare [macos], [linux], and/or [windows] only when the skill is genuinely OS-bound; omit for portable skills.
|
|
888
|
-
- metadata.hermes.tags: a few Capitalized, Relevant, Tags.
|
|
889
|
-
|
|
890
|
-
Body section order (omit only when empty):
|
|
891
|
-
1. "# <Human Title>" then a 2-3 sentence intro: what it does, what it does NOT do, key dependency stance.
|
|
892
|
-
2. "## When to Use" — concrete trigger phrases.
|
|
893
|
-
3. "## Prerequisites" — exact env vars, install steps, credentials.
|
|
894
|
-
4. "## How to Run" — canonical invocation framed through DSH tools.
|
|
895
|
-
5. "## Quick Reference" — flat command/endpoint list.
|
|
896
|
-
6. "## Procedure" — numbered steps with copy-paste-exact commands.
|
|
897
|
-
7. "## Pitfalls" — known limits and rate limits.
|
|
898
|
-
8. "## Verification" — one check proving the skill worked.
|
|
899
|
-
|
|
900
|
-
DSH-tool framing:
|
|
901
|
-
- Reference DSH tools by name in backticks: \`bash\`, \`str_replace_editor\`, \`write\`, \`skill\`, \`skill_manage\`, \`memory\`.
|
|
902
|
-
- Do not name wrapped shell utilities when a DSH tool already covers them.
|
|
903
|
-
- Larger scripts belong under \`scripts/\` (written with \`skill_manage write_file\`) and are referenced from SKILL.md by relative path.
|
|
904
|
-
|
|
905
|
-
Quality bar:
|
|
906
|
-
- Prefer verbatim flags, paths, and APIs from the source. Never invent them.
|
|
907
|
-
- Keep it tight: ~100 lines simple, ~200 complex.
|
|
908
|
-
- No router/index/hub skills that only point at other skills.
|
|
909
|
-
- References go in \`references/\`, templates in \`templates/\`.`;
|
|
910
1463
|
//#endregion
|
|
911
1464
|
//#region lib/types/signals.js
|
|
912
1465
|
/**
|
|
@@ -994,23 +1547,12 @@ function foldTurn(session, fromSeq) {
|
|
|
994
1547
|
* it created unless a `.hermes-managed` marker opts a skill in. Archival is a
|
|
995
1548
|
* move to `.archive/` — never a hard delete.
|
|
996
1549
|
*/
|
|
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
1550
|
const DEFAULT_SKILL_LIMITS = {
|
|
1003
1551
|
maxNameLength: 64,
|
|
1004
1552
|
maxDescriptionLength: MAX_DESCRIPTION_LENGTH,
|
|
1005
1553
|
maxSkillContentChars: MAX_SKILL_CONTENT_CHARS,
|
|
1006
1554
|
maxSkillFileBytes: MAX_SKILL_FILE_BYTES
|
|
1007
1555
|
};
|
|
1008
|
-
const SUPPORT_DIRS = [
|
|
1009
|
-
"references",
|
|
1010
|
-
"templates",
|
|
1011
|
-
"scripts",
|
|
1012
|
-
"assets"
|
|
1013
|
-
];
|
|
1014
1556
|
function skillsRoot(env = process.env) {
|
|
1015
1557
|
return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "skills");
|
|
1016
1558
|
}
|
|
@@ -1069,12 +1611,81 @@ function validateSupportPath(filePath) {
|
|
|
1069
1611
|
if (parts.length < 2) return "Provide a file name, not just a directory.";
|
|
1070
1612
|
return null;
|
|
1071
1613
|
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Index of `pattern` inside `content`, treating whitespace runs (spaces/tabs)
|
|
1616
|
+
* as flexible and literal escape sequences (`\n`, `\t`, `\r`) as their real
|
|
1617
|
+
* characters: a PATTERN whitespace run matches any content run of any length
|
|
1618
|
+
* (even empty), while extra whitespace that only exists in the content is not
|
|
1619
|
+
* skipped — the flexibility is one-sided on the pattern, and a backslash-
|
|
1620
|
+
* escaped char in the pattern matches the real char in the content
|
|
1621
|
+
* (model-copy drift). Returns the [start, end) range in the ORIGINAL content
|
|
1622
|
+
* so a patch can replace exactly the matched span and keep every other byte
|
|
1623
|
+
* intact. Returns null when no fuzzy match exists.
|
|
1624
|
+
*/
|
|
1625
|
+
function fuzzyIndexOf(content, pattern) {
|
|
1626
|
+
const isSpace = (char) => char !== void 0 && /[ \t]/.test(char);
|
|
1627
|
+
const escaped = (char) => {
|
|
1628
|
+
if (char === "n") return "\n";
|
|
1629
|
+
if (char === "t") return " ";
|
|
1630
|
+
if (char === "r") return "\r";
|
|
1631
|
+
return null;
|
|
1632
|
+
};
|
|
1633
|
+
for (let start = 0; start < content.length; start += 1) {
|
|
1634
|
+
let contentIndex = start;
|
|
1635
|
+
let patternIndex = 0;
|
|
1636
|
+
while (patternIndex < pattern.length && contentIndex < content.length) {
|
|
1637
|
+
const patternChar = pattern[patternIndex];
|
|
1638
|
+
const contentChar = content[contentIndex];
|
|
1639
|
+
if (isSpace(patternChar)) {
|
|
1640
|
+
while (patternIndex < pattern.length && isSpace(pattern[patternIndex])) patternIndex += 1;
|
|
1641
|
+
while (contentIndex < content.length && isSpace(content[contentIndex])) contentIndex += 1;
|
|
1642
|
+
continue;
|
|
1643
|
+
}
|
|
1644
|
+
const escapedChar = patternChar === "\\" ? escaped(pattern[patternIndex + 1]) : null;
|
|
1645
|
+
if (escapedChar !== null && contentChar === escapedChar) {
|
|
1646
|
+
patternIndex += 2;
|
|
1647
|
+
contentIndex += 1;
|
|
1648
|
+
continue;
|
|
1649
|
+
}
|
|
1650
|
+
if (patternChar === contentChar) {
|
|
1651
|
+
contentIndex += 1;
|
|
1652
|
+
patternIndex += 1;
|
|
1653
|
+
continue;
|
|
1654
|
+
}
|
|
1655
|
+
break;
|
|
1656
|
+
}
|
|
1657
|
+
if (patternIndex === pattern.length) return [start, contentIndex];
|
|
1658
|
+
}
|
|
1659
|
+
return null;
|
|
1660
|
+
}
|
|
1661
|
+
/** Trim leading whitespace of the first line and trailing whitespace of the last line. */
|
|
1662
|
+
function trimPatternBoundaries(pattern) {
|
|
1663
|
+
const from = pattern.search(/\S/);
|
|
1664
|
+
const trimmed = from < 0 ? pattern : pattern.slice(from);
|
|
1665
|
+
const trailing = trimmed.search(/\s+$/);
|
|
1666
|
+
return trailing < 0 ? trimmed : trimmed.slice(0, trailing);
|
|
1667
|
+
}
|
|
1668
|
+
/** Replace only the fuzzy-matched span, preserving all surrounding bytes. */
|
|
1669
|
+
function fuzzyReplace(content, oldString, newString, replaceAll) {
|
|
1670
|
+
const match = fuzzyIndexOf(content, oldString);
|
|
1671
|
+
if (match === null) return content;
|
|
1672
|
+
const [start, end] = match;
|
|
1673
|
+
const patched = content.slice(0, start) + newString + content.slice(end);
|
|
1674
|
+
return replaceAll ? fuzzyReplace(patched, oldString, newString, true) : patched;
|
|
1675
|
+
}
|
|
1072
1676
|
function fuzzyPatch(content, oldString, newString, replaceAll = false) {
|
|
1073
1677
|
if (content.includes(oldString)) return replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString);
|
|
1074
|
-
const
|
|
1075
|
-
if (
|
|
1076
|
-
|
|
1077
|
-
|
|
1678
|
+
const boundary = trimPatternBoundaries(oldString);
|
|
1679
|
+
if (boundary !== oldString) {
|
|
1680
|
+
if (fuzzyIndexOf(content, boundary) !== null) {
|
|
1681
|
+
const patched = fuzzyReplace(content, boundary, newString, replaceAll);
|
|
1682
|
+
return patched === content ? null : patched;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
if (fuzzyIndexOf(content, oldString) !== null) {
|
|
1686
|
+
const patched = fuzzyReplace(content, oldString, newString, replaceAll);
|
|
1687
|
+
return patched === content ? null : patched;
|
|
1688
|
+
}
|
|
1078
1689
|
return null;
|
|
1079
1690
|
}
|
|
1080
1691
|
var SkillLibrary = class {
|
|
@@ -1107,26 +1718,125 @@ var SkillLibrary = class {
|
|
|
1107
1718
|
return summaries;
|
|
1108
1719
|
}
|
|
1109
1720
|
async read(name) {
|
|
1721
|
+
if (this.badName(name) !== null) return null;
|
|
1110
1722
|
return this.io.readText(join(skillDir(this.root, name), "SKILL.md"));
|
|
1111
1723
|
}
|
|
1112
|
-
|
|
1724
|
+
/** Name-format guard shared by every path-building mutator/reader. */
|
|
1725
|
+
badName(name) {
|
|
1726
|
+
const normalized = name.trim();
|
|
1727
|
+
if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return `Invalid skill name "${normalized}". Use lowercase letters, digits, and hyphens (<= ${this.limits.maxNameLength}).`;
|
|
1728
|
+
return null;
|
|
1729
|
+
}
|
|
1730
|
+
async writeProtection(name, origin = "foreground") {
|
|
1113
1731
|
const dir = skillDir(this.root, name);
|
|
1114
1732
|
for (const marker of ["bundled", "hub-installed"]) if (await this.io.exists(markerPath(dir, marker))) return marker;
|
|
1733
|
+
if (origin === "background_review" && await this.io.exists(markerPath(dir, "pinned"))) return "pinned";
|
|
1115
1734
|
return null;
|
|
1116
1735
|
}
|
|
1117
|
-
async deleteProtection(name) {
|
|
1736
|
+
async deleteProtection(name, options = {}) {
|
|
1118
1737
|
const dir = skillDir(this.root, name);
|
|
1119
|
-
|
|
1738
|
+
const markers = options.allowBundled ? ["hub-installed", "pinned"] : [
|
|
1120
1739
|
"bundled",
|
|
1121
1740
|
"hub-installed",
|
|
1122
1741
|
"pinned"
|
|
1123
|
-
]
|
|
1742
|
+
];
|
|
1743
|
+
for (const marker of markers) if (await this.io.exists(markerPath(dir, marker))) return marker;
|
|
1124
1744
|
return null;
|
|
1125
1745
|
}
|
|
1126
1746
|
async isManaged(name) {
|
|
1127
1747
|
const dir = skillDir(this.root, name);
|
|
1128
1748
|
return await this.io.exists(markerPath(dir, "hermes-managed"));
|
|
1129
1749
|
}
|
|
1750
|
+
/** Whether the skill carries the bundled marker (curator prune-builtins eligibility). */
|
|
1751
|
+
async isBundled(name) {
|
|
1752
|
+
if (this.badName(name) !== null) return false;
|
|
1753
|
+
const dir = skillDir(this.root, name);
|
|
1754
|
+
return await this.io.exists(markerPath(dir, "bundled"));
|
|
1755
|
+
}
|
|
1756
|
+
/** Whether the skill carries the pinned marker (the marker is the factual source; usage.pinned mirrors it). */
|
|
1757
|
+
async isPinned(name) {
|
|
1758
|
+
if (this.badName(name) !== null) return false;
|
|
1759
|
+
const dir = skillDir(this.root, name);
|
|
1760
|
+
return await this.io.exists(markerPath(dir, "pinned"));
|
|
1761
|
+
}
|
|
1762
|
+
/** Count non-empty support subdirectories (richness input for quality scoring). */
|
|
1763
|
+
async countSupportDirs(name) {
|
|
1764
|
+
if (this.badName(name) !== null) return 0;
|
|
1765
|
+
const dir = skillDir(this.root, name);
|
|
1766
|
+
let entries;
|
|
1767
|
+
try {
|
|
1768
|
+
entries = await this.io.list(dir);
|
|
1769
|
+
} catch {
|
|
1770
|
+
return 0;
|
|
1771
|
+
}
|
|
1772
|
+
let count = 0;
|
|
1773
|
+
for (const subdir of SUPPORT_DIRS) {
|
|
1774
|
+
if (!entries.includes(subdir)) continue;
|
|
1775
|
+
try {
|
|
1776
|
+
if ((await this.io.list(join(dir, subdir))).some((file) => file !== ".gitkeep")) count += 1;
|
|
1777
|
+
} catch {}
|
|
1778
|
+
}
|
|
1779
|
+
return count;
|
|
1780
|
+
}
|
|
1781
|
+
/** Best-effort audit trail entry; never blocks the mutation. */
|
|
1782
|
+
async audit(skillName, action, before, after, summary) {
|
|
1783
|
+
try {
|
|
1784
|
+
await recordMutation(this.root, this.io, {
|
|
1785
|
+
skillName,
|
|
1786
|
+
action,
|
|
1787
|
+
...before === null ? {} : { beforeHash: contentHash(before) },
|
|
1788
|
+
...after === null ? {} : { afterHash: contentHash(after) },
|
|
1789
|
+
summary,
|
|
1790
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1791
|
+
});
|
|
1792
|
+
} catch {}
|
|
1793
|
+
}
|
|
1794
|
+
/** Recent mutation audit records (read-only inspection surface). */
|
|
1795
|
+
async listMutations() {
|
|
1796
|
+
return await loadMutations(this.root, this.io);
|
|
1797
|
+
}
|
|
1798
|
+
/**
|
|
1799
|
+
* Pin or unpin a skill (`.pinned` marker). Pinned skills are protected from
|
|
1800
|
+
* deletion, from background-review writes, and from the lifecycle — a
|
|
1801
|
+
* protective mutation, so the autonomous pipeline may never call it. The
|
|
1802
|
+
* marker write is the only state change; content is untouched.
|
|
1803
|
+
*/
|
|
1804
|
+
async setPinned(name, pinned, origin = "foreground") {
|
|
1805
|
+
const normalized = name.trim();
|
|
1806
|
+
if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return {
|
|
1807
|
+
ok: false,
|
|
1808
|
+
message: `Invalid skill name "${normalized}". Use lowercase letters, digits, and hyphens (<= ${this.limits.maxNameLength}).`
|
|
1809
|
+
};
|
|
1810
|
+
if (origin === "background_review") return {
|
|
1811
|
+
ok: false,
|
|
1812
|
+
message: "Only the foreground (user or the main agent) may pin or unpin skills."
|
|
1813
|
+
};
|
|
1814
|
+
const dir = skillDir(this.root, normalized);
|
|
1815
|
+
const marker = markerPath(dir, "pinned");
|
|
1816
|
+
const existing = await this.io.exists(marker);
|
|
1817
|
+
if (pinned && existing) return {
|
|
1818
|
+
ok: true,
|
|
1819
|
+
message: `Skill "${normalized}" is already pinned.`,
|
|
1820
|
+
path: dir
|
|
1821
|
+
};
|
|
1822
|
+
if (!pinned && !existing) return {
|
|
1823
|
+
ok: true,
|
|
1824
|
+
message: `Skill "${normalized}" is not pinned; nothing to do.`,
|
|
1825
|
+
path: dir
|
|
1826
|
+
};
|
|
1827
|
+
if (!await this.io.exists(join(dir, "SKILL.md"))) return {
|
|
1828
|
+
ok: false,
|
|
1829
|
+
message: `Skill "${normalized}" not found.`
|
|
1830
|
+
};
|
|
1831
|
+
if (pinned) await this.io.writeText(marker, "");
|
|
1832
|
+
else await this.io.remove(marker);
|
|
1833
|
+
await this.audit(normalized, pinned ? "pin" : "unpin", null, null, pinned ? "pinned" : "unpinned");
|
|
1834
|
+
return {
|
|
1835
|
+
ok: true,
|
|
1836
|
+
message: pinned ? `Skill "${normalized}" pinned: protected from deletion, background review, and the lifecycle.` : `Skill "${normalized}" unpinned.`,
|
|
1837
|
+
path: dir
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1130
1840
|
async create(name, content, origin) {
|
|
1131
1841
|
const normalized = name.trim();
|
|
1132
1842
|
if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return {
|
|
@@ -1150,19 +1860,26 @@ var SkillLibrary = class {
|
|
|
1150
1860
|
};
|
|
1151
1861
|
await this.io.writeText(join(dir, "SKILL.md"), content.trimEnd() + "\n");
|
|
1152
1862
|
if (origin === "background_review") await this.io.writeText(markerPath(dir, "hermes-managed"), "");
|
|
1863
|
+
await this.audit(normalized, "create", null, content, "created");
|
|
1153
1864
|
return {
|
|
1154
1865
|
ok: true,
|
|
1155
1866
|
message: `Skill "${normalized}" created.`,
|
|
1156
1867
|
path: dir
|
|
1157
1868
|
};
|
|
1158
1869
|
}
|
|
1159
|
-
async update(name, content) {
|
|
1870
|
+
async update(name, content, origin = "foreground") {
|
|
1871
|
+
const badName = this.badName(name);
|
|
1872
|
+
if (badName) return {
|
|
1873
|
+
ok: false,
|
|
1874
|
+
message: badName
|
|
1875
|
+
};
|
|
1160
1876
|
const dir = skillDir(this.root, name);
|
|
1161
|
-
|
|
1877
|
+
const md = await this.io.readText(join(dir, "SKILL.md"));
|
|
1878
|
+
if (!md) return {
|
|
1162
1879
|
ok: false,
|
|
1163
1880
|
message: `Skill "${name}" not found.`
|
|
1164
1881
|
};
|
|
1165
|
-
const protection = await this.writeProtection(name);
|
|
1882
|
+
const protection = await this.writeProtection(name, origin);
|
|
1166
1883
|
if (protection) return {
|
|
1167
1884
|
ok: false,
|
|
1168
1885
|
message: `Skill "${name}" is protected (${protection}).`
|
|
@@ -1178,20 +1895,26 @@ var SkillLibrary = class {
|
|
|
1178
1895
|
message: threat
|
|
1179
1896
|
};
|
|
1180
1897
|
await this.io.writeText(join(dir, "SKILL.md"), content.trimEnd() + "\n");
|
|
1898
|
+
await this.audit(name, "update", md, content, "updated");
|
|
1181
1899
|
return {
|
|
1182
1900
|
ok: true,
|
|
1183
1901
|
message: `Skill "${name}" updated.`,
|
|
1184
1902
|
path: dir
|
|
1185
1903
|
};
|
|
1186
1904
|
}
|
|
1187
|
-
async patch(name, oldString, newString, filePath = "", replaceAll = false) {
|
|
1905
|
+
async patch(name, oldString, newString, filePath = "", replaceAll = false, origin = "foreground") {
|
|
1906
|
+
const badName = this.badName(name);
|
|
1907
|
+
if (badName) return {
|
|
1908
|
+
ok: false,
|
|
1909
|
+
message: badName
|
|
1910
|
+
};
|
|
1188
1911
|
const dir = skillDir(this.root, name);
|
|
1189
1912
|
const skillMd = join(dir, "SKILL.md");
|
|
1190
1913
|
if (!await this.io.exists(skillMd)) return {
|
|
1191
1914
|
ok: false,
|
|
1192
1915
|
message: `Skill "${name}" not found.`
|
|
1193
1916
|
};
|
|
1194
|
-
const protection = await this.writeProtection(name);
|
|
1917
|
+
const protection = await this.writeProtection(name, origin);
|
|
1195
1918
|
if (protection) return {
|
|
1196
1919
|
ok: false,
|
|
1197
1920
|
message: `Skill "${name}" is protected (${protection}).`
|
|
@@ -1238,27 +1961,34 @@ var SkillLibrary = class {
|
|
|
1238
1961
|
message: threat
|
|
1239
1962
|
};
|
|
1240
1963
|
await this.io.writeText(target, patched.trimEnd() + "\n");
|
|
1964
|
+
await this.audit(name, "patch", md, patched, `patched ${patchLabel}`);
|
|
1241
1965
|
return {
|
|
1242
1966
|
ok: true,
|
|
1243
1967
|
message: `Skill "${name}" patched (${patchLabel}).`,
|
|
1244
1968
|
path: dir
|
|
1245
1969
|
};
|
|
1246
1970
|
}
|
|
1247
|
-
async archive(name,
|
|
1971
|
+
async archive(name, options = {}) {
|
|
1972
|
+
const badName = this.badName(name);
|
|
1973
|
+
if (badName) return {
|
|
1974
|
+
ok: false,
|
|
1975
|
+
message: badName
|
|
1976
|
+
};
|
|
1248
1977
|
const dir = skillDir(this.root, name);
|
|
1249
|
-
|
|
1978
|
+
const md = await this.io.readText(join(dir, "SKILL.md"));
|
|
1979
|
+
if (!md) return {
|
|
1250
1980
|
ok: false,
|
|
1251
1981
|
message: `Skill "${name}" not found.`
|
|
1252
1982
|
};
|
|
1253
|
-
const protection = await this.deleteProtection(name);
|
|
1983
|
+
const protection = await this.deleteProtection(name, options.allowBundled === void 0 ? {} : { allowBundled: options.allowBundled });
|
|
1254
1984
|
if (protection) return {
|
|
1255
1985
|
ok: false,
|
|
1256
1986
|
message: `Skill "${name}" is protected (${protection}).`
|
|
1257
1987
|
};
|
|
1258
|
-
if (absorbedInto) {
|
|
1259
|
-
if (!await this.io.readText(join(skillDir(this.root, absorbedInto), "SKILL.md"))) return {
|
|
1988
|
+
if (options.absorbedInto) {
|
|
1989
|
+
if (!await this.io.readText(join(skillDir(this.root, options.absorbedInto), "SKILL.md"))) return {
|
|
1260
1990
|
ok: false,
|
|
1261
|
-
message: `absorbed_into="${absorbedInto}" does not exist.`
|
|
1991
|
+
message: `absorbed_into="${options.absorbedInto}" does not exist.`
|
|
1262
1992
|
};
|
|
1263
1993
|
}
|
|
1264
1994
|
const archiveRoot = join(this.root, ".archive");
|
|
@@ -1270,21 +2000,149 @@ var SkillLibrary = class {
|
|
|
1270
2000
|
await this.io.copy(dir, dest);
|
|
1271
2001
|
await this.io.remove(dir);
|
|
1272
2002
|
}
|
|
1273
|
-
const reason = absorbedInto ? `Consolidated into ${absorbedInto}` : "Archived by self-evolution curator";
|
|
2003
|
+
const reason = options.reason ?? (options.absorbedInto ? `Consolidated into ${options.absorbedInto}` : "Archived by self-evolution curator");
|
|
1274
2004
|
await this.io.writeText(join(dest, ".archive-reason"), `${(/* @__PURE__ */ new Date()).toISOString()}: ${reason}\n`);
|
|
2005
|
+
await this.audit(name, "archive", md, null, reason);
|
|
1275
2006
|
return {
|
|
1276
2007
|
ok: true,
|
|
1277
2008
|
message: `Skill "${name}" archived to .archive.`,
|
|
1278
2009
|
path: dest
|
|
1279
2010
|
};
|
|
1280
2011
|
}
|
|
1281
|
-
|
|
2012
|
+
/**
|
|
2013
|
+
* Merge the bodies of `sources` into `target` and archive the sources with
|
|
2014
|
+
* an absorbed-into marker. Hermes-style consolidation: overlapping skills
|
|
2015
|
+
* collapse into one, and the originals stay recoverable under `.archive/`.
|
|
2016
|
+
*/
|
|
2017
|
+
async consolidate(target, sources, origin = "foreground") {
|
|
2018
|
+
const normalizedSources = [...new Set(sources)].filter((name) => name !== target);
|
|
2019
|
+
if (normalizedSources.length === 0) return {
|
|
2020
|
+
ok: false,
|
|
2021
|
+
message: "Consolidation requires at least one distinct source skill."
|
|
2022
|
+
};
|
|
2023
|
+
for (const name of [target, ...normalizedSources]) if (!SKILL_NAME_RE.test(name)) return {
|
|
2024
|
+
ok: false,
|
|
2025
|
+
message: `Invalid skill name "${name}". Use lowercase letters, digits, and hyphens.`
|
|
2026
|
+
};
|
|
2027
|
+
const targetDir = skillDir(this.root, target);
|
|
2028
|
+
const targetMd = await this.io.readText(join(targetDir, "SKILL.md"));
|
|
2029
|
+
if (!targetMd) return {
|
|
2030
|
+
ok: false,
|
|
2031
|
+
message: `Skill "${target}" not found.`
|
|
2032
|
+
};
|
|
2033
|
+
const targetProtection = await this.writeProtection(target, origin);
|
|
2034
|
+
if (targetProtection) return {
|
|
2035
|
+
ok: false,
|
|
2036
|
+
message: `Skill "${target}" is protected (${targetProtection}).`
|
|
2037
|
+
};
|
|
2038
|
+
const parts = [];
|
|
2039
|
+
for (const source of normalizedSources) {
|
|
2040
|
+
const protection = await this.deleteProtection(source);
|
|
2041
|
+
if (protection) return {
|
|
2042
|
+
ok: false,
|
|
2043
|
+
message: `Skill "${source}" is protected (${protection}).`
|
|
2044
|
+
};
|
|
2045
|
+
const sourceMd = await this.io.readText(join(skillDir(this.root, source), "SKILL.md"));
|
|
2046
|
+
if (!sourceMd) return {
|
|
2047
|
+
ok: false,
|
|
2048
|
+
message: `Skill "${source}" not found.`
|
|
2049
|
+
};
|
|
2050
|
+
const parsed = parseFrontmatter(sourceMd);
|
|
2051
|
+
if (!parsed) return {
|
|
2052
|
+
ok: false,
|
|
2053
|
+
message: `Skill "${source}" has no valid frontmatter; refusing to merge.`
|
|
2054
|
+
};
|
|
2055
|
+
parts.push(`\n<!-- consolidated from ${source} at ${(/* @__PURE__ */ new Date()).toISOString()} -->\n${parsed.body.trim()}`);
|
|
2056
|
+
}
|
|
2057
|
+
const merged = targetMd.trimEnd() + parts.join("\n") + "\n";
|
|
2058
|
+
const validation = validateFrontmatter(merged, target, this.limits);
|
|
2059
|
+
if (validation) return {
|
|
2060
|
+
ok: false,
|
|
2061
|
+
message: `Consolidation rejected: ${validation}`
|
|
2062
|
+
};
|
|
2063
|
+
const threat = scanContentThreats(merged);
|
|
2064
|
+
if (threat) return {
|
|
2065
|
+
ok: false,
|
|
2066
|
+
message: threat
|
|
2067
|
+
};
|
|
2068
|
+
const archived = [];
|
|
2069
|
+
try {
|
|
2070
|
+
for (const source of normalizedSources) {
|
|
2071
|
+
const result = await this.archive(source, { absorbedInto: target });
|
|
2072
|
+
if (!result.ok) return result;
|
|
2073
|
+
archived.push(source);
|
|
2074
|
+
}
|
|
2075
|
+
await this.io.writeText(join(targetDir, "SKILL.md"), merged);
|
|
2076
|
+
} catch (error) {
|
|
2077
|
+
await this.io.writeText(join(targetDir, "SKILL.md"), targetMd).catch(() => {});
|
|
2078
|
+
for (const source of archived.reverse()) await this.restoreFromArchive(source).catch(() => {});
|
|
2079
|
+
return {
|
|
2080
|
+
ok: false,
|
|
2081
|
+
message: `Consolidation failed and was rolled back: ${error instanceof Error ? error.message : String(error)}`
|
|
2082
|
+
};
|
|
2083
|
+
}
|
|
2084
|
+
return {
|
|
2085
|
+
ok: true,
|
|
2086
|
+
message: `Consolidated ${normalizedSources.join(", ")} into "${target}".`,
|
|
2087
|
+
path: targetDir
|
|
2088
|
+
};
|
|
2089
|
+
}
|
|
2090
|
+
/**
|
|
2091
|
+
* Restore one skill from `.archive/` back to the active root. Hermes-style
|
|
2092
|
+
* recoverability: archival never deletes, and this is the control-plane
|
|
2093
|
+
* path back. The `.archive-reason` marker is dropped on restore.
|
|
2094
|
+
*/
|
|
2095
|
+
async restoreFromArchive(name) {
|
|
2096
|
+
if (!SKILL_NAME_RE.test(name)) return {
|
|
2097
|
+
ok: false,
|
|
2098
|
+
message: `Invalid skill name "${name}". Use lowercase letters, digits, and hyphens.`
|
|
2099
|
+
};
|
|
2100
|
+
if (await this.io.exists(join(skillDir(this.root, name), "SKILL.md"))) return {
|
|
2101
|
+
ok: false,
|
|
2102
|
+
message: `Skill "${name}" already exists in the active root; refusing to overwrite.`
|
|
2103
|
+
};
|
|
2104
|
+
const archiveRoot = join(this.root, ".archive");
|
|
2105
|
+
let entries;
|
|
2106
|
+
try {
|
|
2107
|
+
entries = await this.io.list(archiveRoot);
|
|
2108
|
+
} catch {
|
|
2109
|
+
return {
|
|
2110
|
+
ok: false,
|
|
2111
|
+
message: "No skill archive available."
|
|
2112
|
+
};
|
|
2113
|
+
}
|
|
2114
|
+
const chosen = entries.filter((entry) => entry === name || entry.startsWith(`${name}-`)).sort().reverse()[0];
|
|
2115
|
+
if (!chosen) return {
|
|
2116
|
+
ok: false,
|
|
2117
|
+
message: `Skill "${name}" is not in .archive.`
|
|
2118
|
+
};
|
|
2119
|
+
const source = join(archiveRoot, chosen);
|
|
2120
|
+
const dest = skillDir(this.root, name);
|
|
2121
|
+
try {
|
|
2122
|
+
await this.io.rename(source, dest);
|
|
2123
|
+
} catch {
|
|
2124
|
+
await this.io.copy(source, dest);
|
|
2125
|
+
await this.io.remove(source);
|
|
2126
|
+
}
|
|
2127
|
+
if (await this.io.exists(join(dest, ".archive-reason"))) await this.io.remove(join(dest, ".archive-reason"));
|
|
2128
|
+
return {
|
|
2129
|
+
ok: true,
|
|
2130
|
+
message: `Skill "${name}" restored from .archive.`,
|
|
2131
|
+
path: dest
|
|
2132
|
+
};
|
|
2133
|
+
}
|
|
2134
|
+
async writeSupportFile(name, filePath, content, origin = "foreground") {
|
|
2135
|
+
const badName = this.badName(name);
|
|
2136
|
+
if (badName) return {
|
|
2137
|
+
ok: false,
|
|
2138
|
+
message: badName
|
|
2139
|
+
};
|
|
1282
2140
|
const dir = skillDir(this.root, name);
|
|
1283
2141
|
if (!await this.io.exists(join(dir, "SKILL.md"))) return {
|
|
1284
2142
|
ok: false,
|
|
1285
2143
|
message: `Skill "${name}" not found.`
|
|
1286
2144
|
};
|
|
1287
|
-
const protection = await this.writeProtection(name);
|
|
2145
|
+
const protection = await this.writeProtection(name, origin);
|
|
1288
2146
|
if (protection) return {
|
|
1289
2147
|
ok: false,
|
|
1290
2148
|
message: `Skill "${name}" is protected (${protection}).`
|
|
@@ -1304,20 +2162,27 @@ var SkillLibrary = class {
|
|
|
1304
2162
|
message: threat
|
|
1305
2163
|
};
|
|
1306
2164
|
const target = join(dir, ...filePath.replace(/\\/g, "/").split("/").filter(Boolean));
|
|
2165
|
+
const existing = await this.io.readText(target).catch(() => null);
|
|
1307
2166
|
await this.io.writeText(target, content);
|
|
2167
|
+
await this.audit(name, "write_file", existing, content, `wrote ${filePath}`);
|
|
1308
2168
|
return {
|
|
1309
2169
|
ok: true,
|
|
1310
2170
|
message: `Support file "${filePath}" written to "${name}".`,
|
|
1311
2171
|
path: target
|
|
1312
2172
|
};
|
|
1313
2173
|
}
|
|
1314
|
-
async removeSupportFile(name, filePath) {
|
|
2174
|
+
async removeSupportFile(name, filePath, origin = "foreground") {
|
|
2175
|
+
const badName = this.badName(name);
|
|
2176
|
+
if (badName) return {
|
|
2177
|
+
ok: false,
|
|
2178
|
+
message: badName
|
|
2179
|
+
};
|
|
1315
2180
|
const dir = skillDir(this.root, name);
|
|
1316
2181
|
if (!await this.io.exists(join(dir, "SKILL.md"))) return {
|
|
1317
2182
|
ok: false,
|
|
1318
2183
|
message: `Skill "${name}" not found.`
|
|
1319
2184
|
};
|
|
1320
|
-
const protection = await this.writeProtection(name);
|
|
2185
|
+
const protection = await this.writeProtection(name, origin);
|
|
1321
2186
|
if (protection) return {
|
|
1322
2187
|
ok: false,
|
|
1323
2188
|
message: `Skill "${name}" is protected (${protection}).`
|
|
@@ -1332,7 +2197,9 @@ var SkillLibrary = class {
|
|
|
1332
2197
|
ok: false,
|
|
1333
2198
|
message: `File "${filePath}" not found in skill "${name}".`
|
|
1334
2199
|
};
|
|
2200
|
+
const before = await this.io.readText(target).catch(() => null);
|
|
1335
2201
|
await this.io.remove(target);
|
|
2202
|
+
await this.audit(name, "remove_file", before, null, `removed ${filePath}`);
|
|
1336
2203
|
return {
|
|
1337
2204
|
ok: true,
|
|
1338
2205
|
message: `Support file "${filePath}" removed from "${name}".`,
|
|
@@ -1403,7 +2270,7 @@ var SkillLibrary = class {
|
|
|
1403
2270
|
function evolutionHome(env = process.env) {
|
|
1404
2271
|
return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "evolution");
|
|
1405
2272
|
}
|
|
1406
|
-
var JsonState = class {
|
|
2273
|
+
var JsonState = class JsonState {
|
|
1407
2274
|
initial;
|
|
1408
2275
|
path;
|
|
1409
2276
|
value;
|
|
@@ -1412,14 +2279,24 @@ var JsonState = class {
|
|
|
1412
2279
|
this.path = join(evolutionHome(env), name);
|
|
1413
2280
|
this.value = this.loadSync();
|
|
1414
2281
|
}
|
|
2282
|
+
/**
|
|
2283
|
+
* Deep-merge persisted state over the initial defaults. Nested plain
|
|
2284
|
+
* objects merge recursively (so a new default field added under an existing
|
|
2285
|
+
* object is preserved), while arrays and primitives take the on-disk value
|
|
2286
|
+
* wholesale. Keeps forward-compatible defaults across schema additions.
|
|
2287
|
+
*/
|
|
2288
|
+
static mergeDeep(initial, persisted) {
|
|
2289
|
+
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2290
|
+
if (!isRecord(initial) || !isRecord(persisted)) return isRecord(persisted) ? persisted : persisted == null ? initial : persisted;
|
|
2291
|
+
const out = { ...initial };
|
|
2292
|
+
for (const [key, value] of Object.entries(persisted)) out[key] = key in initial ? JsonState.mergeDeep(initial[key], value) : value;
|
|
2293
|
+
return out;
|
|
2294
|
+
}
|
|
1415
2295
|
loadSync() {
|
|
1416
2296
|
try {
|
|
1417
2297
|
const raw = readFileSync(this.path, "utf8");
|
|
1418
2298
|
const parsed = JSON.parse(raw);
|
|
1419
|
-
return
|
|
1420
|
-
...this.initial,
|
|
1421
|
-
...parsed
|
|
1422
|
-
};
|
|
2299
|
+
return JsonState.mergeDeep(this.initial, parsed);
|
|
1423
2300
|
} catch {
|
|
1424
2301
|
return { ...this.initial };
|
|
1425
2302
|
}
|
|
@@ -1443,14 +2320,11 @@ var JsonState = class {
|
|
|
1443
2320
|
async reload() {
|
|
1444
2321
|
try {
|
|
1445
2322
|
const raw = await readFile(this.path, "utf8");
|
|
1446
|
-
this.value =
|
|
1447
|
-
...this.initial,
|
|
1448
|
-
...JSON.parse(raw)
|
|
1449
|
-
};
|
|
2323
|
+
this.value = JsonState.mergeDeep(this.initial, JSON.parse(raw));
|
|
1450
2324
|
} catch {
|
|
1451
2325
|
this.value = { ...this.initial };
|
|
1452
2326
|
}
|
|
1453
2327
|
}
|
|
1454
2328
|
};
|
|
1455
2329
|
//#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,
|
|
2330
|
+
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, buildLearnPrompt, bumpPatch, bumpUse, bumpView, computeDedupGroups, computeLifecycleTransitions, computeQualityScores, computeScopeView, contentHash, emptyRecord, evaluateThreat, evolutionHome, evolutionIoAdapter, foldTurn, getRecord, latestActivityAt, lifecycleCandidate, loadMutations, loadSuppressedNames, loadUsage, markAgentCreated, memoryRoot, mutationsFile, nodeEvolutionIo, observeEvent, parseCuratorNominations, parseFrontmatter, recordMutation, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, usageFile, validateFrontmatter, verifyPromptBundle };
|