@lmzhen/dsh-evolution-core 0.1.0-rc.1

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 ADDED
@@ -0,0 +1,1456 @@
1
+ import { dirname, join } from "node:path";
2
+ import { cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ import { homedir } from "node:os";
5
+ import { readFileSync } from "node:fs";
6
+ //#region lib/types/io.js
7
+ /**
8
+ * Structural IO seam for the legacy facade stores.
9
+ *
10
+ * The facade accepts any object exposing this small async file-tree surface.
11
+ * Native DSH packages pass `ctx.evolutionIo.provider()`; standalone consumers
12
+ * (and the facade's own tests) can use `nodeEvolutionIo`.
13
+ */
14
+ /** Lazy adapter over an IO provider registry, shared by every evolution consumer. */
15
+ function evolutionIoAdapter(provider) {
16
+ return {
17
+ readText: (path) => provider().readText(path),
18
+ writeText: (path, content) => provider().writeText(path, content),
19
+ remove: (path) => provider().remove(path),
20
+ list: (path) => provider().list(path),
21
+ exists: (path) => provider().exists(path),
22
+ rename: (path, destination) => provider().rename(path, destination),
23
+ copy: (path, destination) => provider().copy(path, destination)
24
+ };
25
+ }
26
+ function nodeEvolutionIo() {
27
+ return {
28
+ async readText(path) {
29
+ try {
30
+ return await readFile(path, "utf8");
31
+ } catch {
32
+ return null;
33
+ }
34
+ },
35
+ async writeText(path, content) {
36
+ await mkdir(dirname(path), { recursive: true });
37
+ const tmp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
38
+ await writeFile(tmp, content, "utf8");
39
+ await rename(tmp, path);
40
+ },
41
+ async remove(path) {
42
+ await rm(path, {
43
+ recursive: true,
44
+ force: true
45
+ });
46
+ },
47
+ async list(path) {
48
+ try {
49
+ return await readdir(path);
50
+ } catch {
51
+ return [];
52
+ }
53
+ },
54
+ async exists(path) {
55
+ try {
56
+ await stat(path);
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ },
62
+ async rename(path, destination) {
63
+ await mkdir(dirname(destination), { recursive: true });
64
+ await rename(path, destination);
65
+ },
66
+ async copy(path, destination) {
67
+ await mkdir(dirname(destination), { recursive: true });
68
+ await cp(path, destination, {
69
+ recursive: true,
70
+ force: true
71
+ });
72
+ }
73
+ };
74
+ }
75
+ /** Absolute path helper kept separate so stores stay platform-correct. */
76
+ function childPath(parent, ...parts) {
77
+ return join(parent, ...parts);
78
+ }
79
+ //#endregion
80
+ //#region lib/types/usage.js
81
+ /**
82
+ * Skill usage telemetry sidecar: `$DSH_HOME/skills/.usage.json`.
83
+ * Format-compatible with Hermes Agent / hermes-claw core fields.
84
+ */
85
+ function usageFile(root) {
86
+ return join(root, ".usage.json");
87
+ }
88
+ function emptyRecord() {
89
+ return {
90
+ created_by: null,
91
+ use_count: 0,
92
+ view_count: 0,
93
+ patch_count: 0,
94
+ last_used_at: null,
95
+ last_viewed_at: null,
96
+ last_patched_at: null,
97
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
98
+ state: "active",
99
+ pinned: false,
100
+ archived_at: null
101
+ };
102
+ }
103
+ async function loadUsage(root, io = nodeEvolutionIo()) {
104
+ const map = /* @__PURE__ */ new Map();
105
+ const raw = await io.readText(usageFile(root));
106
+ if (raw !== null) try {
107
+ const parsed = JSON.parse(raw);
108
+ for (const [name, record] of Object.entries(parsed)) {
109
+ const base = emptyRecord();
110
+ map.set(name, {
111
+ ...base,
112
+ ...record,
113
+ state: record.state === "stale" || record.state === "archived" ? record.state : "active"
114
+ });
115
+ }
116
+ } catch {}
117
+ return map;
118
+ }
119
+ async function saveUsage(root, map, io = nodeEvolutionIo()) {
120
+ const obj = Object.fromEntries(map.entries());
121
+ await io.writeText(usageFile(root), JSON.stringify(obj, null, 2));
122
+ }
123
+ function getRecord(map, name) {
124
+ let record = map.get(name);
125
+ if (!record) {
126
+ record = emptyRecord();
127
+ map.set(name, record);
128
+ }
129
+ return record;
130
+ }
131
+ function bumpView(map, name, when = /* @__PURE__ */ new Date()) {
132
+ const record = getRecord(map, name);
133
+ record.view_count += 1;
134
+ record.last_viewed_at = when.toISOString();
135
+ }
136
+ function bumpUse(map, name, when = /* @__PURE__ */ new Date()) {
137
+ const record = getRecord(map, name);
138
+ record.use_count += 1;
139
+ record.last_used_at = when.toISOString();
140
+ }
141
+ function bumpPatch(map, name, when = /* @__PURE__ */ new Date()) {
142
+ const record = getRecord(map, name);
143
+ record.patch_count += 1;
144
+ record.last_patched_at = when.toISOString();
145
+ }
146
+ function markAgentCreated(map, name) {
147
+ getRecord(map, name).created_by = "agent";
148
+ }
149
+ function latestActivityAt(record) {
150
+ const values = [
151
+ record.last_used_at,
152
+ record.last_viewed_at,
153
+ record.last_patched_at
154
+ ].filter((value) => typeof value === "string");
155
+ if (values.length === 0) return null;
156
+ return values.sort().reverse()[0] ?? null;
157
+ }
158
+ //#endregion
159
+ //#region lib/types/curator.js
160
+ /**
161
+ * Deterministic skill curator: active → stale → archived transitions.
162
+ * Pure function; file moves are performed by SkillLibrary.
163
+ */
164
+ const PROTECTED_BUILTIN_SKILLS = new Set(["plan"]);
165
+ function buildCuratorRunReport(input) {
166
+ return {
167
+ runId: input.runId,
168
+ startedAt: input.startedAt,
169
+ finishedAt: input.finishedAt,
170
+ staleCandidates: [...input.staleCandidates],
171
+ llmNominations: [...input.llmNominations],
172
+ archiveCandidates: [...input.archiveCandidates],
173
+ archived: [...input.archived],
174
+ failed: [...input.failed],
175
+ ...input.snapshotPath === void 0 ? {} : { snapshotPath: input.snapshotPath }
176
+ };
177
+ }
178
+ function daysSince(iso, created, now) {
179
+ return (now - new Date(iso ?? created).getTime()) / 864e5;
180
+ }
181
+ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Date()) {
182
+ const result = {
183
+ transitions: [],
184
+ archive: [],
185
+ reactivate: [],
186
+ markStale: []
187
+ };
188
+ for (const [name, record] of usage) {
189
+ if (record.pinned) continue;
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;
194
+ const age = daysSince(null, record.created_at, now.getTime());
195
+ if (record.use_count === 0 && age < config.staleAfterDays) continue;
196
+ const idle = daysSince(latestActivityAt(record), record.created_at, now.getTime());
197
+ const qualityWarn = record.quality_warn === true;
198
+ const staleAfterDays = qualityWarn && config.qualityWarnStaleAfterDays !== void 0 ? config.qualityWarnStaleAfterDays : config.staleAfterDays;
199
+ if (record.state === "active") {
200
+ if (idle >= config.archiveAfterDays) {
201
+ record.state = "archived";
202
+ record.archived_at = now.toISOString();
203
+ result.transitions.push({
204
+ name,
205
+ from: "active",
206
+ to: "archived",
207
+ reason: `idle ${Math.round(idle)}d >= ${config.archiveAfterDays}d`
208
+ });
209
+ result.archive.push(name);
210
+ } else if (idle >= staleAfterDays) {
211
+ record.state = "stale";
212
+ const reason = qualityWarn ? `idle ${Math.round(idle)}d >= quality-warn stale ${staleAfterDays}d` : `idle ${Math.round(idle)}d >= ${staleAfterDays}d`;
213
+ result.transitions.push({
214
+ name,
215
+ from: "active",
216
+ to: "stale",
217
+ reason
218
+ });
219
+ result.markStale.push(name);
220
+ }
221
+ } else if (idle < staleAfterDays) {
222
+ record.state = "active";
223
+ result.transitions.push({
224
+ name,
225
+ from: "stale",
226
+ to: "active",
227
+ reason: `recent activity ${Math.round(idle)}d`
228
+ });
229
+ result.reactivate.push(name);
230
+ } else if (idle >= config.archiveAfterDays) {
231
+ record.state = "archived";
232
+ record.archived_at = now.toISOString();
233
+ result.transitions.push({
234
+ name,
235
+ from: "stale",
236
+ to: "archived",
237
+ reason: `idle ${Math.round(idle)}d >= ${config.archiveAfterDays}d`
238
+ });
239
+ result.archive.push(name);
240
+ }
241
+ }
242
+ return result;
243
+ }
244
+ //#endregion
245
+ //#region lib/types/threats.js
246
+ /**
247
+ * Threat scanning for agent-authored memory and skill content.
248
+ *
249
+ * Ported as a small, dependency-free subset of Hermes Agent's
250
+ * `tools/threat_patterns.py` + hermes-claw `threats.ts`. The policy is the
251
+ * load-bearing part: ANY in-scope hit blocks. Severity and category are
252
+ * metadata for diagnostics only.
253
+ */
254
+ const FILLER = String.raw`(?:\w+\s+){0,8}`;
255
+ const PATTERNS = [
256
+ {
257
+ label: "prompt_injection_ignore",
258
+ category: "prompt_injection",
259
+ scope: "all",
260
+ regex: new RegExp(String.raw`ignore\s+${FILLER}(?:previous|above|prior|all)\s+${FILLER}instructions`, "i")
261
+ },
262
+ {
263
+ label: "disregard_rules",
264
+ category: "prompt_injection",
265
+ scope: "all",
266
+ regex: /disregard\s+(?:your|all|any)\s+(?:instructions|rules|guidelines)/i
267
+ },
268
+ {
269
+ label: "system_prompt_override",
270
+ category: "prompt_injection",
271
+ scope: "all",
272
+ regex: /system\s+prompt\s+override/i
273
+ },
274
+ {
275
+ label: "bypass_restrictions",
276
+ category: "prompt_injection",
277
+ scope: "all",
278
+ regex: /act\s+as\s+(?:if|though)\s+(?:you\s+)?(?:have\s+no|don'?t\s+have)\s+(?:restrictions?|limits?|rules)/i
279
+ },
280
+ {
281
+ label: "new_system_prompt",
282
+ category: "prompt_injection",
283
+ scope: "strict",
284
+ regex: new RegExp(String.raw`new\s+${FILLER}system\s+${FILLER}prompt`, "i")
285
+ },
286
+ {
287
+ label: "forget_everything",
288
+ category: "prompt_injection",
289
+ scope: "strict",
290
+ regex: new RegExp(String.raw`forget\s+${FILLER}(?:everything|all)\s+${FILLER}(?:discussed|you\s+know)`, "i")
291
+ },
292
+ {
293
+ label: "role_hijack",
294
+ category: "role_hijacking",
295
+ scope: "context",
296
+ regex: /you\s+are\s+now\s+(?:a|an|the|acting|playing|pretending)/i
297
+ },
298
+ {
299
+ label: "fake_update",
300
+ category: "role_hijacking",
301
+ scope: "context",
302
+ regex: new RegExp(String.raw`you\s+have\s+been\s+${FILLER}(?:updated|upgraded|patched)\s+to`, "i")
303
+ },
304
+ {
305
+ label: "identity_override",
306
+ category: "role_hijacking",
307
+ scope: "context",
308
+ regex: /\bname\s+yourself\s+\w+/i
309
+ },
310
+ {
311
+ label: "remove_filters",
312
+ category: "role_hijacking",
313
+ scope: "context",
314
+ regex: /(?:respond|answer|reply)\s+without\s+(?:restrictions?|limitations?|filters?|safety)/i
315
+ },
316
+ {
317
+ label: "deception_hide",
318
+ category: "deception",
319
+ scope: "all",
320
+ regex: new RegExp(String.raw`do\s+not\s+${FILLER}tell\s+${FILLER}the\s+user`, "i")
321
+ },
322
+ {
323
+ label: "leak_system_prompt",
324
+ category: "deception",
325
+ scope: "context",
326
+ regex: new RegExp(String.raw`output\s+${FILLER}(?:system|initial)\s+prompt`, "i")
327
+ },
328
+ {
329
+ label: "context_exfil",
330
+ category: "exfiltration",
331
+ scope: "strict",
332
+ regex: /(?:include|output|print|share)\s+(?:the\s+)?(?:conversation|chat\s+history|previous\s+messages|(?:full|entire)\s+context)/i
333
+ },
334
+ {
335
+ label: "send_to_url",
336
+ category: "exfiltration",
337
+ scope: "strict",
338
+ regex: /(?:send|post|upload|transmit)\s+[^\n]{0,512}\s+(?:to|at)\s+https?:\/\//i
339
+ },
340
+ {
341
+ label: "exfil_curl",
342
+ category: "exfiltration",
343
+ scope: "all",
344
+ regex: /curl\s+[^\n]{0,512}\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)/i
345
+ },
346
+ {
347
+ label: "exfil_wget",
348
+ category: "exfiltration",
349
+ scope: "all",
350
+ regex: /wget\s+[^\n]{0,512}\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)/i
351
+ },
352
+ {
353
+ label: "read_secrets",
354
+ category: "exfiltration",
355
+ scope: "all",
356
+ regex: /cat\s+[^\n]{0,512}(?:\.env|credentials|\.netrc|\.pgpass|\.npmrc|\.pypirc)/i
357
+ },
358
+ {
359
+ label: "ssh_backdoor",
360
+ category: "persistence",
361
+ scope: "strict",
362
+ regex: /authorized_keys/i
363
+ },
364
+ {
365
+ label: "agent_config_mod",
366
+ category: "persistence",
367
+ scope: "strict",
368
+ regex: /(?:update|modify|edit|write|change|append|add\s+to)\s+(?:AGENTS\.md|CLAUDE\.md|\.cursorrules|\.clinerules)/i
369
+ },
370
+ {
371
+ label: "hermes_env",
372
+ category: "persistence",
373
+ scope: "strict",
374
+ regex: /\$?HOME\/\.hermes|~\/\.hermes|\.hermes\/\.env/i
375
+ },
376
+ {
377
+ label: "c2_node_registration",
378
+ category: "c2_promptware",
379
+ scope: "context",
380
+ regex: /register\s+(?:as\s+)?a?\s*node/i
381
+ },
382
+ {
383
+ label: "c2_heartbeat",
384
+ category: "c2_promptware",
385
+ scope: "context",
386
+ regex: /(?:heartbeats?|beacon|check[\s-]?in)\s+(?:to|with)/i
387
+ },
388
+ {
389
+ label: "c2_task_pull",
390
+ category: "c2_promptware",
391
+ scope: "context",
392
+ regex: /pull\s+(?:new\s+)?tasks?/i
393
+ },
394
+ {
395
+ label: "known_c2_framework",
396
+ category: "c2_promptware",
397
+ scope: "context",
398
+ regex: /\b(?:cobalt\s*strike|sliver|havoc|mythic|metasploit|brainworm)\b/i
399
+ },
400
+ {
401
+ label: "hardcoded_secret",
402
+ category: "hardcoded_secrets",
403
+ scope: "strict",
404
+ regex: /(?:api[_-]?key|token|secret|password)\s*[=:]\s*["'][a-z0-9+/=_-]{20,}["']/i
405
+ },
406
+ {
407
+ label: "private_key_block",
408
+ category: "hardcoded_secrets",
409
+ scope: "all",
410
+ regex: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----/
411
+ }
412
+ ];
413
+ const ZERO_WIDTH_CHARS = /[\u200b\u200c\u200d\u2060\u2062\u2063\u2064\ufeff]/;
414
+ const BIDI_CHARS = /[\u202a-\u202e\u2066-\u2069]/;
415
+ const SCOPE_ORDER = {
416
+ all: 1,
417
+ context: 2,
418
+ strict: 3
419
+ };
420
+ /**
421
+ * Scan text at `scope`. Patterns are cumulative: `strict` includes all scopes.
422
+ */
423
+ function scanThreats(text, scope = "strict", maxScanChars = 65536) {
424
+ const findings = [];
425
+ if (ZERO_WIDTH_CHARS.test(text)) findings.push({
426
+ label: "unicode_zero_width",
427
+ category: "unicode_obfuscation",
428
+ scope
429
+ });
430
+ if (BIDI_CHARS.test(text)) findings.push({
431
+ label: "unicode_bidi_override",
432
+ category: "unicode_obfuscation",
433
+ scope
434
+ });
435
+ const normalized = text.normalize("NFKC").slice(0, maxScanChars);
436
+ for (const pattern of PATTERNS) {
437
+ if (SCOPE_ORDER[pattern.scope] > SCOPE_ORDER[scope]) continue;
438
+ if (pattern.regex.test(normalized)) findings.push({
439
+ label: pattern.label,
440
+ category: pattern.category,
441
+ scope: pattern.scope
442
+ });
443
+ }
444
+ return findings;
445
+ }
446
+ /** 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);
449
+ return {
450
+ blocked: findings.length > 0,
451
+ findings
452
+ };
453
+ }
454
+ /** User-facing block message for memory writes. */
455
+ function scanMemoryThreats(text, maxScanChars = 65536) {
456
+ const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars);
457
+ if (!blocked) return null;
458
+ const pattern = findings.find((f) => f.category !== "unicode_obfuscation");
459
+ if (pattern) return `Blocked by security scan (${pattern.label}). Rephrase without instruction-like language.`;
460
+ return "Blocked by security scan: invisible or potentially malicious Unicode detected.";
461
+ }
462
+ /** User-facing block message for skill content writes. */
463
+ function scanContentThreats(text, maxScanChars = 65536) {
464
+ const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars);
465
+ if (!blocked) return null;
466
+ return `Blocked by security scan (${findings[0]?.label ?? "unknown"}). This content appears to contain potentially malicious instructions.`;
467
+ }
468
+ //#endregion
469
+ //#region lib/types/memory-store.js
470
+ /**
471
+ * File-backed durable memory with Hermes-compatible semantics.
472
+ * Stores are MEMORY.md and USER.md under $DSH_HOME/memories (~/.dsh/memories).
473
+ */
474
+ const ENTRY_DELIMITER = "\n§\n";
475
+ function memoryRoot(env = process.env) {
476
+ return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "memories");
477
+ }
478
+ function fileFor(root, target) {
479
+ return join(root, target === "memory" ? "MEMORY.md" : "USER.md");
480
+ }
481
+ function normalizeEntries(raw) {
482
+ return raw.split(ENTRY_DELIMITER).map((entry) => entry.trim()).filter(Boolean);
483
+ }
484
+ function render(entries) {
485
+ return entries.join(ENTRY_DELIMITER) + "\n";
486
+ }
487
+ function stripDatePrefix(entry) {
488
+ return entry.replace(/^## \d{4}-\d{2}-\d{2}\n/, "");
489
+ }
490
+ var MemoryStore = class {
491
+ memoryLimit;
492
+ userLimit;
493
+ addDatePrefix;
494
+ root;
495
+ maxFailures;
496
+ io;
497
+ failureCount = 0;
498
+ constructor(options = {}) {
499
+ this.io = options.io ?? nodeEvolutionIo();
500
+ this.memoryLimit = options.memoryCharLimit ?? 2200;
501
+ this.userLimit = options.userCharLimit ?? 1375;
502
+ this.addDatePrefix = options.addDatePrefix ?? false;
503
+ this.root = options.root ?? memoryRoot();
504
+ this.maxFailures = options.maxConsolidationFailures ?? 3;
505
+ }
506
+ limitFor(target) {
507
+ return target === "memory" ? this.memoryLimit : this.userLimit;
508
+ }
509
+ async read(target) {
510
+ const raw = await this.io.readText(fileFor(this.root, target));
511
+ return raw === null ? [] : [...new Set(normalizeEntries(raw))];
512
+ }
513
+ async write(target, entries) {
514
+ await this.io.writeText(fileFor(this.root, target), render(entries));
515
+ }
516
+ resetFailures() {
517
+ this.failureCount = 0;
518
+ }
519
+ failure(target, message, entries) {
520
+ this.failureCount += 1;
521
+ const chars = entries.join(ENTRY_DELIMITER).length;
522
+ if (this.failureCount > this.maxFailures) return {
523
+ ok: false,
524
+ message: `Memory consolidation failed ${this.failureCount} times this turn. Stop retrying memory calls and continue with the user's task.`,
525
+ entries,
526
+ chars,
527
+ limit: this.limitFor(target)
528
+ };
529
+ return {
530
+ ok: false,
531
+ message,
532
+ entries,
533
+ chars,
534
+ limit: this.limitFor(target)
535
+ };
536
+ }
537
+ async add(target, facts) {
538
+ const content = facts.trim();
539
+ if (!content) return {
540
+ ok: false,
541
+ message: "Content cannot be empty.",
542
+ entries: [],
543
+ chars: 0,
544
+ limit: this.limitFor(target)
545
+ };
546
+ const threat = scanMemoryThreats(content);
547
+ if (threat) return {
548
+ ok: false,
549
+ message: threat,
550
+ entries: [],
551
+ chars: 0,
552
+ limit: this.limitFor(target)
553
+ };
554
+ const entries = await this.read(target);
555
+ if (entries.some((entry) => stripDatePrefix(entry) === content)) {
556
+ this.resetFailures();
557
+ return {
558
+ ok: true,
559
+ message: "Entry already exists (no duplicate added).",
560
+ entries,
561
+ chars: entries.join(ENTRY_DELIMITER).length,
562
+ limit: this.limitFor(target)
563
+ };
564
+ }
565
+ const next = [...entries, this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n${content}` : content];
566
+ const total = next.join(ENTRY_DELIMITER).length;
567
+ if (total > this.limitFor(target)) return this.failure(target, `Adding this entry would exceed the ${this.limitFor(target)} char limit. Consolidate or remove stale entries, then retry.`, entries);
568
+ await this.write(target, next);
569
+ this.resetFailures();
570
+ return {
571
+ ok: true,
572
+ message: "Entry added.",
573
+ entries: next,
574
+ chars: total,
575
+ limit: this.limitFor(target)
576
+ };
577
+ }
578
+ async replace(target, oldText, facts) {
579
+ return this.mutate(target, oldText, "replace", facts);
580
+ }
581
+ async remove(target, oldText) {
582
+ return this.mutate(target, oldText, "remove", void 0);
583
+ }
584
+ async mutate(target, oldText, action, facts) {
585
+ const needle = oldText.trim();
586
+ if (!needle) return {
587
+ ok: false,
588
+ message: "old_text cannot be empty.",
589
+ entries: [],
590
+ chars: 0,
591
+ limit: this.limitFor(target)
592
+ };
593
+ const content = action === "replace" ? (facts ?? "").trim() : "";
594
+ if (action === "replace" && !content) return {
595
+ ok: false,
596
+ message: "facts is required for replace; use remove to delete.",
597
+ entries: [],
598
+ chars: 0,
599
+ limit: this.limitFor(target)
600
+ };
601
+ if (action === "replace") {
602
+ const threat = scanMemoryThreats(content);
603
+ if (threat) return {
604
+ ok: false,
605
+ message: threat,
606
+ entries: [],
607
+ chars: 0,
608
+ limit: this.limitFor(target)
609
+ };
610
+ }
611
+ if (await this.detectDrift(target)) return {
612
+ ok: false,
613
+ message: "External drift detected in memory file. Resolve the drift before retrying.",
614
+ entries: [],
615
+ chars: 0,
616
+ limit: this.limitFor(target)
617
+ };
618
+ const entries = await this.read(target);
619
+ const matches = entries.map((entry, index) => ({
620
+ entry,
621
+ index
622
+ })).filter(({ entry }) => entry.includes(needle));
623
+ if (matches.length === 0) return this.failure(target, `No entry matching "${needle}" found.`, entries);
624
+ if (new Set(matches.map((m) => m.entry)).size > 1) return {
625
+ ok: false,
626
+ message: `Multiple distinct entries matched "${needle}". Be more specific.`,
627
+ entries,
628
+ chars: entries.join(ENTRY_DELIMITER).length,
629
+ limit: this.limitFor(target)
630
+ };
631
+ const index = matches[0]?.index ?? -1;
632
+ const next = [...entries];
633
+ if (action === "remove") next.splice(index, 1);
634
+ else next[index] = content;
635
+ const total = next.join(ENTRY_DELIMITER).length;
636
+ if (total > this.limitFor(target)) return this.failure(target, `Resulting memory would exceed the ${this.limitFor(target)} char limit.`, entries);
637
+ await this.write(target, next);
638
+ this.resetFailures();
639
+ return {
640
+ ok: true,
641
+ message: `Entry ${action === "remove" ? "removed" : "replaced"}.`,
642
+ entries: next,
643
+ chars: total,
644
+ limit: this.limitFor(target)
645
+ };
646
+ }
647
+ async applyBatch(target, operations) {
648
+ if (operations.length === 0) return {
649
+ ok: false,
650
+ message: "operations list is empty.",
651
+ entries: [],
652
+ chars: 0,
653
+ limit: this.limitFor(target)
654
+ };
655
+ if (await this.detectDrift(target)) return {
656
+ ok: false,
657
+ message: "External drift detected in memory file. Resolve the drift before retrying.",
658
+ entries: [],
659
+ chars: 0,
660
+ limit: this.limitFor(target)
661
+ };
662
+ const entries = await this.read(target);
663
+ const working = [...entries];
664
+ for (const [index, op] of operations.entries()) {
665
+ const position = index + 1;
666
+ if (op.action === "add") {
667
+ const body = (op.facts ?? "").trim();
668
+ if (!body) return {
669
+ ok: false,
670
+ message: `Operation ${position} (add): facts is required. No operations were applied.`,
671
+ entries,
672
+ chars: entries.join(ENTRY_DELIMITER).length,
673
+ limit: this.limitFor(target)
674
+ };
675
+ const threat = scanMemoryThreats(body);
676
+ if (threat) return {
677
+ ok: false,
678
+ message: `Operation ${position}: ${threat}`,
679
+ entries,
680
+ chars: entries.join(ENTRY_DELIMITER).length,
681
+ limit: this.limitFor(target)
682
+ };
683
+ if (!working.some((entry) => stripDatePrefix(entry) === body)) working.push(this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n${body}` : body);
684
+ continue;
685
+ }
686
+ const needle = (op.old_text ?? "").trim();
687
+ if (!needle) return {
688
+ ok: false,
689
+ message: `Operation ${position} (${op.action}): old_text is required. No operations were applied.`,
690
+ entries,
691
+ chars: entries.join(ENTRY_DELIMITER).length,
692
+ limit: this.limitFor(target)
693
+ };
694
+ const matches = working.map((entry, matchIndex) => ({
695
+ entry,
696
+ matchIndex
697
+ })).filter(({ entry }) => entry.includes(needle));
698
+ if (matches.length === 0) return this.failure(target, `Operation ${position}: no entry matching "${needle}" found. No operations were applied.`, entries);
699
+ if (new Set(matches.map((m) => m.entry)).size > 1) return {
700
+ ok: false,
701
+ message: `Operation ${position}: "${needle}" matched multiple distinct entries. No operations were applied.`,
702
+ entries,
703
+ chars: entries.join(ENTRY_DELIMITER).length,
704
+ limit: this.limitFor(target)
705
+ };
706
+ const matchIndex = matches[0]?.matchIndex ?? -1;
707
+ if (op.action === "remove") working.splice(matchIndex, 1);
708
+ else {
709
+ const body = (op.facts ?? "").trim();
710
+ if (!body) return {
711
+ ok: false,
712
+ message: `Operation ${position} (replace): facts is required.`,
713
+ entries,
714
+ chars: entries.join(ENTRY_DELIMITER).length,
715
+ limit: this.limitFor(target)
716
+ };
717
+ const threat = scanMemoryThreats(body);
718
+ if (threat) return {
719
+ ok: false,
720
+ message: `Operation ${position}: ${threat}`,
721
+ entries,
722
+ chars: entries.join(ENTRY_DELIMITER).length,
723
+ limit: this.limitFor(target)
724
+ };
725
+ working[matchIndex] = body;
726
+ }
727
+ }
728
+ const total = working.join(ENTRY_DELIMITER).length;
729
+ if (total > this.limitFor(target)) return this.failure(target, `Batch result (${total} chars) exceeds the ${this.limitFor(target)} limit. Remove or shorten more entries in the same batch.`, entries);
730
+ await this.write(target, working);
731
+ this.resetFailures();
732
+ return {
733
+ ok: true,
734
+ message: `Applied ${operations.length} operation(s).`,
735
+ entries: working,
736
+ chars: total,
737
+ limit: this.limitFor(target)
738
+ };
739
+ }
740
+ async renderContext() {
741
+ const memory = await this.read("memory");
742
+ const user = await this.read("user");
743
+ const parts = [];
744
+ for (const [target, entries] of [["Memory", memory], ["User Profile", user]]) {
745
+ const safe = entries.filter((entry) => !scanMemoryThreats(entry));
746
+ if (safe.length > 0) {
747
+ const body = safe.join(ENTRY_DELIMITER);
748
+ const note = safe.length === entries.length ? "" : ` (${entries.length - safe.length} threat-matched entries filtered)`;
749
+ parts.push(`## ${target} (${safe.length} entries)${note}\n${body}`);
750
+ }
751
+ }
752
+ return parts.join("\n\n");
753
+ }
754
+ async snapshot() {
755
+ const [memory, user] = await Promise.all([this.read("memory"), this.read("user")]);
756
+ return {
757
+ memory,
758
+ user
759
+ };
760
+ }
761
+ async restoreSnapshot(snapshot) {
762
+ await this.write("memory", snapshot.memory);
763
+ await this.write("user", snapshot.user);
764
+ }
765
+ async detectDrift(target) {
766
+ const raw = await this.io.readText(fileFor(this.root, target));
767
+ if (raw === null) return false;
768
+ return normalizeEntries(raw).join(ENTRY_DELIMITER) !== raw.trim();
769
+ }
770
+ };
771
+ //#endregion
772
+ //#region lib/types/prompts.js
773
+ /**
774
+ * Review and curation prompts adapted from Hermes Agent
775
+ * `agent/background_review.py`, `agent/curator.py`, and
776
+ * `agent/learn_prompt.py`, with tool names translated to the DSH-native
777
+ * catalog (`memory`, `skill_manage`, `skill`, `bash`, `str_replace_editor`).
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.
782
+ */
783
+ const PROMPT_BUNDLE_ID = "dsh-evolution@1";
784
+ const MEMORY_REVIEW_PROMPT = `[Auto-review — Memory]
785
+ Review the conversation above and consider saving to memory if appropriate.
786
+
787
+ Focus on:
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;
849
+ }
850
+ function sha256(text) {
851
+ return createHash("sha256").update(text).digest("hex");
852
+ }
853
+ function createPromptBundle(prompts) {
854
+ const canonical = JSON.stringify({
855
+ id: PROMPT_BUNDLE_ID,
856
+ version: 1,
857
+ prompts: Object.fromEntries(Object.entries(prompts).sort())
858
+ });
859
+ return Object.freeze({
860
+ id: PROMPT_BUNDLE_ID,
861
+ version: 1,
862
+ prompts: Object.freeze({ ...prompts }),
863
+ sha256: sha256(canonical)
864
+ });
865
+ }
866
+ const PROMPT_BUNDLE = createPromptBundle({
867
+ memory: MEMORY_REVIEW_PROMPT,
868
+ skill: SKILL_REVIEW_PROMPT,
869
+ combined: COMBINED_REVIEW_PROMPT,
870
+ curator: CURATOR_PROMPT
871
+ });
872
+ function verifyPromptBundle(bundle = PROMPT_BUNDLE) {
873
+ const canonical = JSON.stringify({
874
+ id: bundle.id,
875
+ version: bundle.version,
876
+ prompts: Object.fromEntries(Object.entries(bundle.prompts).sort())
877
+ });
878
+ return bundle.sha256 === sha256(canonical);
879
+ }
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
+ //#endregion
911
+ //#region lib/types/signals.js
912
+ /**
913
+ * Deterministic review signal gate.
914
+ *
915
+ * Scans a DSH session event log for durable learning signals before any LLM
916
+ * is spent. `turn/end` calls `observeTurn`; the returned review kind is
917
+ * accumulated until a configured interval fires.
918
+ */
919
+ const CORRECTION_PATTERNS = [
920
+ /(?:don'?t|do not|stop|never)\s+(?:do|use|format|explain|write|say)/i,
921
+ /(?:too|very|way\s+too)\s+(?:verbose|long|detailed|short|brief)/i,
922
+ /(?:I|we)\s+(?:prefer|like|want|need)\b/i,
923
+ /remember\s+(?:this|that|to)/i
924
+ ];
925
+ const FIX_PATTERNS = [/worked after|fixed by|the fix was|root cause/i, /retry(?:ing)? worked|workaround/i];
926
+ /** Fold one session event into the current turn observation. */
927
+ function observeEvent(signal, event) {
928
+ if (event.type === "user/message") {
929
+ const text = event.data.content.map((block) => block.type === "text" ? block.text : "").join(" ");
930
+ signal.userChars += text.length;
931
+ if (CORRECTION_PATTERNS.some((pattern) => pattern.test(text))) signal.memorySignal = true;
932
+ if (FIX_PATTERNS.some((pattern) => pattern.test(text))) signal.skillSignal = true;
933
+ return;
934
+ }
935
+ if (event.type === "assistant/message") {
936
+ const text = event.data.message.content.map((block) => block.type === "text" ? block.text : "").join(" ");
937
+ signal.assistantChars += text.length;
938
+ return;
939
+ }
940
+ if (event.type === "tool/call") {
941
+ signal.toolCalls += 1;
942
+ if (event.data.name === "skill") signal.skillSignal = true;
943
+ if (event.data.name === "skill_manage") signal.skillSignal = true;
944
+ }
945
+ }
946
+ /** Compute review cadence after `turn/end`. */
947
+ function advanceReview(state, turn, signal, config) {
948
+ if (turn === state.lastTurn) return null;
949
+ state.lastTurn = turn;
950
+ signal.substantive = signal.toolCalls >= config.substantiveMinToolCalls || signal.userChars >= config.substantiveMinUserChars || signal.assistantChars >= config.substantiveMinAgentChars;
951
+ if (!signal.substantive) return null;
952
+ state.turnsSinceMemory += signal.memorySignal ? 1 : 0;
953
+ state.turnsSinceSkill += signal.skillSignal ? 1 : Math.max(1, signal.toolCalls);
954
+ const memoryDue = state.turnsSinceMemory >= config.memoryInterval;
955
+ const skillDue = state.turnsSinceSkill >= config.skillInterval;
956
+ if (memoryDue && skillDue) {
957
+ state.turnsSinceMemory = 0;
958
+ state.turnsSinceSkill = 0;
959
+ return "combined";
960
+ }
961
+ if (memoryDue) {
962
+ state.turnsSinceMemory = 0;
963
+ return "memory";
964
+ }
965
+ if (skillDue) {
966
+ state.turnsSinceSkill = 0;
967
+ return "skill";
968
+ }
969
+ return null;
970
+ }
971
+ /** Fold all events between two sequence boundaries into one TurnSignals. */
972
+ function foldTurn(session, fromSeq) {
973
+ const signal = {
974
+ substantive: false,
975
+ toolCalls: 0,
976
+ userChars: 0,
977
+ assistantChars: 0,
978
+ memorySignal: false,
979
+ skillSignal: false
980
+ };
981
+ for (let index = Math.max(0, fromSeq); index < session.events.length; index += 1) {
982
+ const event = session.events[index];
983
+ if (event) observeEvent(signal, event);
984
+ }
985
+ return signal;
986
+ }
987
+ //#endregion
988
+ //#region lib/types/skill-store.js
989
+ /**
990
+ * Skill library management for the self-evolution plugin.
991
+ *
992
+ * Skills live under `$DSH_HOME/skills` (`~/.dsh/skills` by default), matching
993
+ * the default dsh skill-filesystem user root. The plugin only manages skills
994
+ * it created unless a `.hermes-managed` marker opts a skill in. Archival is a
995
+ * move to `.archive/` — never a hard delete.
996
+ */
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
+ const DEFAULT_SKILL_LIMITS = {
1003
+ maxNameLength: 64,
1004
+ maxDescriptionLength: MAX_DESCRIPTION_LENGTH,
1005
+ maxSkillContentChars: MAX_SKILL_CONTENT_CHARS,
1006
+ maxSkillFileBytes: MAX_SKILL_FILE_BYTES
1007
+ };
1008
+ const SUPPORT_DIRS = [
1009
+ "references",
1010
+ "templates",
1011
+ "scripts",
1012
+ "assets"
1013
+ ];
1014
+ function skillsRoot(env = process.env) {
1015
+ return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "skills");
1016
+ }
1017
+ function skillDir(root, name) {
1018
+ return join(root, name);
1019
+ }
1020
+ function markerPath(dir, marker) {
1021
+ return join(dir, `.${marker}`);
1022
+ }
1023
+ function parseFrontmatter(content) {
1024
+ if (!content.trimStart().startsWith("---")) return null;
1025
+ const end = content.indexOf("\n---", 3);
1026
+ if (end < 0) return null;
1027
+ const block = content.slice(3, end);
1028
+ const body = content.slice(end + 4).trim();
1029
+ if (!body) return null;
1030
+ const frontmatter = {};
1031
+ for (const line of block.split("\n")) {
1032
+ const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
1033
+ if (match) {
1034
+ const [, key, value] = match;
1035
+ if (key && value !== void 0) frontmatter[key] = value.trim().replace(/^["']|["']$/g, "");
1036
+ }
1037
+ }
1038
+ return {
1039
+ frontmatter,
1040
+ body
1041
+ };
1042
+ }
1043
+ function validateFrontmatter(content, expectedName, limits = DEFAULT_SKILL_LIMITS) {
1044
+ const parsed = parseFrontmatter(content);
1045
+ if (!parsed) return "SKILL.md must start and end with YAML frontmatter and include a body.";
1046
+ if (!parsed.frontmatter.name) return "Frontmatter must include a name field.";
1047
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(parsed.frontmatter.name)) return `Invalid skill name "${parsed.frontmatter.name}" — use lowercase letters, digits, and hyphens.`;
1048
+ if (parsed.frontmatter.name.length > limits.maxNameLength) return `Skill name exceeds ${limits.maxNameLength} characters.`;
1049
+ if (expectedName && parsed.frontmatter.name !== expectedName) return `Frontmatter name "${parsed.frontmatter.name}" does not match target skill "${expectedName}".`;
1050
+ if (!parsed.frontmatter.description) return "Frontmatter must include a description field.";
1051
+ if (parsed.frontmatter.description.length > limits.maxDescriptionLength) return `Description exceeds ${limits.maxDescriptionLength} characters.`;
1052
+ if (content.length > limits.maxSkillContentChars) return `SKILL.md content exceeds ${limits.maxSkillContentChars} characters.`;
1053
+ return null;
1054
+ }
1055
+ async function listNames(root, io) {
1056
+ const entries = await io.list(root);
1057
+ const names = [];
1058
+ for (const entry of entries) {
1059
+ if (entry.startsWith(".")) continue;
1060
+ if (await io.exists(join(root, entry, "SKILL.md"))) names.push(entry);
1061
+ }
1062
+ return names.sort();
1063
+ }
1064
+ function validateSupportPath(filePath) {
1065
+ const normalized = filePath.replace(/\\/g, "/");
1066
+ if (normalized.includes("..")) return "Path traversal is not allowed.";
1067
+ const parts = normalized.split("/").filter(Boolean);
1068
+ if (parts.length === 0 || !SUPPORT_DIRS.includes(parts[0])) return `file_path must be under one of: ${SUPPORT_DIRS.join(", ")}.`;
1069
+ if (parts.length < 2) return "Provide a file name, not just a directory.";
1070
+ return null;
1071
+ }
1072
+ function fuzzyPatch(content, oldString, newString, replaceAll = false) {
1073
+ if (content.includes(oldString)) return replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString);
1074
+ const trimmed = content.replaceAll(/[ ]+$/gm, "");
1075
+ if (trimmed.includes(oldString)) return trimmed.replace(oldString, newString);
1076
+ const whitespace = content.replaceAll(/[ ]+/g, " ");
1077
+ if (whitespace.includes(oldString)) return whitespace.replace(oldString, newString);
1078
+ return null;
1079
+ }
1080
+ var SkillLibrary = class {
1081
+ root;
1082
+ limits;
1083
+ io;
1084
+ constructor(root = skillsRoot(), io = nodeEvolutionIo(), limits = DEFAULT_SKILL_LIMITS) {
1085
+ this.root = root;
1086
+ this.io = io;
1087
+ this.limits = limits;
1088
+ }
1089
+ async list() {
1090
+ const summaries = [];
1091
+ for (const name of await listNames(this.root, this.io)) {
1092
+ const dir = skillDir(this.root, name);
1093
+ const md = await this.io.readText(join(dir, "SKILL.md"));
1094
+ if (!md) continue;
1095
+ const parsed = parseFrontmatter(md);
1096
+ const protectedBy = await this.deleteProtection(name);
1097
+ const managed = await this.io.exists(markerPath(dir, "hermes-managed"));
1098
+ summaries.push({
1099
+ name,
1100
+ description: parsed?.frontmatter.description ?? "",
1101
+ path: dir,
1102
+ protectedBy,
1103
+ managed,
1104
+ archived: false
1105
+ });
1106
+ }
1107
+ return summaries;
1108
+ }
1109
+ async read(name) {
1110
+ return this.io.readText(join(skillDir(this.root, name), "SKILL.md"));
1111
+ }
1112
+ async writeProtection(name) {
1113
+ const dir = skillDir(this.root, name);
1114
+ for (const marker of ["bundled", "hub-installed"]) if (await this.io.exists(markerPath(dir, marker))) return marker;
1115
+ return null;
1116
+ }
1117
+ async deleteProtection(name) {
1118
+ const dir = skillDir(this.root, name);
1119
+ for (const marker of [
1120
+ "bundled",
1121
+ "hub-installed",
1122
+ "pinned"
1123
+ ]) if (await this.io.exists(markerPath(dir, marker))) return marker;
1124
+ return null;
1125
+ }
1126
+ async isManaged(name) {
1127
+ const dir = skillDir(this.root, name);
1128
+ return await this.io.exists(markerPath(dir, "hermes-managed"));
1129
+ }
1130
+ async create(name, content, origin) {
1131
+ const normalized = name.trim();
1132
+ if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return {
1133
+ ok: false,
1134
+ message: `Invalid skill name "${normalized}". Use lowercase letters, digits, and hyphens (<= ${this.limits.maxNameLength}).`
1135
+ };
1136
+ const validation = validateFrontmatter(content, normalized, this.limits);
1137
+ if (validation) return {
1138
+ ok: false,
1139
+ message: validation
1140
+ };
1141
+ const threat = scanContentThreats(content);
1142
+ if (threat) return {
1143
+ ok: false,
1144
+ message: threat
1145
+ };
1146
+ const dir = skillDir(this.root, normalized);
1147
+ if (await this.io.exists(join(dir, "SKILL.md"))) return {
1148
+ ok: false,
1149
+ message: `Skill "${normalized}" already exists.`
1150
+ };
1151
+ await this.io.writeText(join(dir, "SKILL.md"), content.trimEnd() + "\n");
1152
+ if (origin === "background_review") await this.io.writeText(markerPath(dir, "hermes-managed"), "");
1153
+ return {
1154
+ ok: true,
1155
+ message: `Skill "${normalized}" created.`,
1156
+ path: dir
1157
+ };
1158
+ }
1159
+ async update(name, content) {
1160
+ const dir = skillDir(this.root, name);
1161
+ if (!await this.io.readText(join(dir, "SKILL.md"))) return {
1162
+ ok: false,
1163
+ message: `Skill "${name}" not found.`
1164
+ };
1165
+ const protection = await this.writeProtection(name);
1166
+ if (protection) return {
1167
+ ok: false,
1168
+ message: `Skill "${name}" is protected (${protection}).`
1169
+ };
1170
+ const validation = validateFrontmatter(content, name, this.limits);
1171
+ if (validation) return {
1172
+ ok: false,
1173
+ message: validation
1174
+ };
1175
+ const threat = scanContentThreats(content);
1176
+ if (threat) return {
1177
+ ok: false,
1178
+ message: threat
1179
+ };
1180
+ await this.io.writeText(join(dir, "SKILL.md"), content.trimEnd() + "\n");
1181
+ return {
1182
+ ok: true,
1183
+ message: `Skill "${name}" updated.`,
1184
+ path: dir
1185
+ };
1186
+ }
1187
+ async patch(name, oldString, newString, filePath = "", replaceAll = false) {
1188
+ const dir = skillDir(this.root, name);
1189
+ const skillMd = join(dir, "SKILL.md");
1190
+ if (!await this.io.exists(skillMd)) return {
1191
+ ok: false,
1192
+ message: `Skill "${name}" not found.`
1193
+ };
1194
+ const protection = await this.writeProtection(name);
1195
+ if (protection) return {
1196
+ ok: false,
1197
+ message: `Skill "${name}" is protected (${protection}).`
1198
+ };
1199
+ let target = skillMd;
1200
+ let patchLabel = "SKILL.md";
1201
+ if (filePath) {
1202
+ const validation = validateSupportPath(filePath);
1203
+ if (validation) return {
1204
+ ok: false,
1205
+ message: validation
1206
+ };
1207
+ target = join(dir, ...filePath.replace(/\\/g, "/").split("/").filter(Boolean));
1208
+ patchLabel = filePath;
1209
+ }
1210
+ const md = await this.io.readText(target);
1211
+ if (!md) return {
1212
+ ok: false,
1213
+ message: `File not found: ${patchLabel}`
1214
+ };
1215
+ const patched = fuzzyPatch(md, oldString, newString, replaceAll);
1216
+ if (!patched) return {
1217
+ ok: false,
1218
+ message: `Could not find old_string in "${name}/${patchLabel}". Use update for a full rewrite.`
1219
+ };
1220
+ if (target === skillMd) {
1221
+ const validation = validateFrontmatter(patched, name, this.limits);
1222
+ if (validation) return {
1223
+ ok: false,
1224
+ message: `Patch rejected: ${validation}`
1225
+ };
1226
+ }
1227
+ if (Buffer.byteLength(patched, "utf8") > this.limits.maxSkillFileBytes && target !== skillMd) return {
1228
+ ok: false,
1229
+ message: `Patched file exceeds ${this.limits.maxSkillFileBytes} bytes.`
1230
+ };
1231
+ if (patched.length > this.limits.maxSkillContentChars && target === skillMd) return {
1232
+ ok: false,
1233
+ message: `Patched content exceeds ${this.limits.maxSkillContentChars} characters.`
1234
+ };
1235
+ const threat = scanContentThreats(patched);
1236
+ if (threat) return {
1237
+ ok: false,
1238
+ message: threat
1239
+ };
1240
+ await this.io.writeText(target, patched.trimEnd() + "\n");
1241
+ return {
1242
+ ok: true,
1243
+ message: `Skill "${name}" patched (${patchLabel}).`,
1244
+ path: dir
1245
+ };
1246
+ }
1247
+ async archive(name, absorbedInto = "") {
1248
+ const dir = skillDir(this.root, name);
1249
+ if (!await this.io.readText(join(dir, "SKILL.md"))) return {
1250
+ ok: false,
1251
+ message: `Skill "${name}" not found.`
1252
+ };
1253
+ const protection = await this.deleteProtection(name);
1254
+ if (protection) return {
1255
+ ok: false,
1256
+ message: `Skill "${name}" is protected (${protection}).`
1257
+ };
1258
+ if (absorbedInto) {
1259
+ if (!await this.io.readText(join(skillDir(this.root, absorbedInto), "SKILL.md"))) return {
1260
+ ok: false,
1261
+ message: `absorbed_into="${absorbedInto}" does not exist.`
1262
+ };
1263
+ }
1264
+ const archiveRoot = join(this.root, ".archive");
1265
+ let dest = join(archiveRoot, name);
1266
+ if (await this.io.exists(dest)) dest = join(archiveRoot, `${name}-${(/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 14)}`);
1267
+ try {
1268
+ await this.io.rename(dir, dest);
1269
+ } catch {
1270
+ await this.io.copy(dir, dest);
1271
+ await this.io.remove(dir);
1272
+ }
1273
+ const reason = absorbedInto ? `Consolidated into ${absorbedInto}` : "Archived by self-evolution curator";
1274
+ await this.io.writeText(join(dest, ".archive-reason"), `${(/* @__PURE__ */ new Date()).toISOString()}: ${reason}\n`);
1275
+ return {
1276
+ ok: true,
1277
+ message: `Skill "${name}" archived to .archive.`,
1278
+ path: dest
1279
+ };
1280
+ }
1281
+ async writeSupportFile(name, filePath, content) {
1282
+ const dir = skillDir(this.root, name);
1283
+ if (!await this.io.exists(join(dir, "SKILL.md"))) return {
1284
+ ok: false,
1285
+ message: `Skill "${name}" not found.`
1286
+ };
1287
+ const protection = await this.writeProtection(name);
1288
+ if (protection) return {
1289
+ ok: false,
1290
+ message: `Skill "${name}" is protected (${protection}).`
1291
+ };
1292
+ const validation = validateSupportPath(filePath);
1293
+ if (validation) return {
1294
+ ok: false,
1295
+ message: validation
1296
+ };
1297
+ if (Buffer.byteLength(content, "utf8") > this.limits.maxSkillFileBytes) return {
1298
+ ok: false,
1299
+ message: `Support file exceeds ${this.limits.maxSkillFileBytes} bytes.`
1300
+ };
1301
+ const threat = scanContentThreats(content);
1302
+ if (threat) return {
1303
+ ok: false,
1304
+ message: threat
1305
+ };
1306
+ const target = join(dir, ...filePath.replace(/\\/g, "/").split("/").filter(Boolean));
1307
+ await this.io.writeText(target, content);
1308
+ return {
1309
+ ok: true,
1310
+ message: `Support file "${filePath}" written to "${name}".`,
1311
+ path: target
1312
+ };
1313
+ }
1314
+ async removeSupportFile(name, filePath) {
1315
+ const dir = skillDir(this.root, name);
1316
+ if (!await this.io.exists(join(dir, "SKILL.md"))) return {
1317
+ ok: false,
1318
+ message: `Skill "${name}" not found.`
1319
+ };
1320
+ const protection = await this.writeProtection(name);
1321
+ if (protection) return {
1322
+ ok: false,
1323
+ message: `Skill "${name}" is protected (${protection}).`
1324
+ };
1325
+ const validation = validateSupportPath(filePath);
1326
+ if (validation) return {
1327
+ ok: false,
1328
+ message: validation
1329
+ };
1330
+ const target = join(dir, ...filePath.replace(/\\/g, "/").split("/").filter(Boolean));
1331
+ if (!await this.io.exists(target)) return {
1332
+ ok: false,
1333
+ message: `File "${filePath}" not found in skill "${name}".`
1334
+ };
1335
+ await this.io.remove(target);
1336
+ return {
1337
+ ok: true,
1338
+ message: `Support file "${filePath}" removed from "${name}".`,
1339
+ path: target
1340
+ };
1341
+ }
1342
+ async snapshotAll(reason = "pre-mutation") {
1343
+ const dest = join(join(this.root, ".backups"), `skills-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`);
1344
+ const names = await listNames(this.root, this.io);
1345
+ for (const name of names) await this.io.copy(skillDir(this.root, name), join(dest, name));
1346
+ await this.io.writeText(join(dest, "manifest.json"), JSON.stringify({
1347
+ reason,
1348
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1349
+ skills: names
1350
+ }, null, 2));
1351
+ return dest;
1352
+ }
1353
+ async listSnapshots() {
1354
+ const backupRoot = join(this.root, ".backups");
1355
+ let entries;
1356
+ try {
1357
+ entries = await this.io.list(backupRoot);
1358
+ } catch {
1359
+ return [];
1360
+ }
1361
+ const out = [];
1362
+ for (const name of entries.sort().reverse()) {
1363
+ if (!name.startsWith("skills-")) continue;
1364
+ try {
1365
+ const raw = await this.io.readText(join(backupRoot, name, "manifest.json"));
1366
+ if (raw === null) continue;
1367
+ const manifest = JSON.parse(raw);
1368
+ out.push({
1369
+ path: join(backupRoot, name),
1370
+ createdAt: manifest.createdAt ?? "",
1371
+ reason: manifest.reason ?? ""
1372
+ });
1373
+ } catch {}
1374
+ }
1375
+ return out;
1376
+ }
1377
+ async restoreLatestSnapshot() {
1378
+ const latest = (await this.listSnapshots())[0];
1379
+ if (!latest) return {
1380
+ ok: false,
1381
+ message: "No skill snapshot available."
1382
+ };
1383
+ await this.snapshotAll("pre-rollback");
1384
+ for (const name of await listNames(this.root, this.io)) await this.io.remove(skillDir(this.root, name));
1385
+ const entries = await this.io.list(latest.path);
1386
+ for (const entry of entries) {
1387
+ if (entry === "manifest.json") continue;
1388
+ await this.io.copy(join(latest.path, entry), join(this.root, entry));
1389
+ }
1390
+ return {
1391
+ ok: true,
1392
+ message: `Restored skill tree from ${latest.path}`,
1393
+ path: latest.path
1394
+ };
1395
+ }
1396
+ };
1397
+ //#endregion
1398
+ //#region lib/types/state-store.js
1399
+ /**
1400
+ * Small crash-safe JSON state store for plugin-owned sidecar state.
1401
+ * Writes are atomic (temp + rename). Reads are synchronous for startup use.
1402
+ */
1403
+ function evolutionHome(env = process.env) {
1404
+ return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "evolution");
1405
+ }
1406
+ var JsonState = class {
1407
+ initial;
1408
+ path;
1409
+ value;
1410
+ constructor(name, initial, env = process.env) {
1411
+ this.initial = initial;
1412
+ this.path = join(evolutionHome(env), name);
1413
+ this.value = this.loadSync();
1414
+ }
1415
+ loadSync() {
1416
+ try {
1417
+ const raw = readFileSync(this.path, "utf8");
1418
+ const parsed = JSON.parse(raw);
1419
+ return {
1420
+ ...this.initial,
1421
+ ...parsed
1422
+ };
1423
+ } catch {
1424
+ return { ...this.initial };
1425
+ }
1426
+ }
1427
+ get() {
1428
+ return this.value;
1429
+ }
1430
+ set(value) {
1431
+ this.value = value;
1432
+ }
1433
+ update(mutator) {
1434
+ mutator(this.value);
1435
+ }
1436
+ async flush() {
1437
+ await mkdir(dirname(this.path), { recursive: true });
1438
+ const tmp = `${this.path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
1439
+ await writeFile(tmp, JSON.stringify(this.value, null, 2), "utf8");
1440
+ await rename(tmp, this.path);
1441
+ }
1442
+ /** Merge-on-load helper for persisted maps/records. */
1443
+ async reload() {
1444
+ try {
1445
+ const raw = await readFile(this.path, "utf8");
1446
+ this.value = {
1447
+ ...this.initial,
1448
+ ...JSON.parse(raw)
1449
+ };
1450
+ } catch {
1451
+ this.value = { ...this.initial };
1452
+ }
1453
+ }
1454
+ };
1455
+ //#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, childPath, computeLifecycleTransitions, emptyRecord, evaluateThreat, evolutionHome, evolutionIoAdapter, foldTurn, getRecord, latestActivityAt, loadUsage, markAgentCreated, memoryRoot, nodeEvolutionIo, observeEvent, parseFrontmatter, reviewPrompt, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, usageFile, validateFrontmatter, verifyPromptBundle };