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

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 CHANGED
@@ -72,10 +72,6 @@ function nodeEvolutionIo() {
72
72
  }
73
73
  };
74
74
  }
75
- /** Absolute path helper kept separate so stores stay platform-correct. */
76
- function childPath(parent, ...parts) {
77
- return join(parent, ...parts);
78
- }
79
75
  //#endregion
80
76
  //#region lib/types/usage.js
81
77
  /**
@@ -417,10 +413,12 @@ const SCOPE_ORDER = {
417
413
  context: 2,
418
414
  strict: 3
419
415
  };
416
+ const NO_SCAN_OPTIONS = {};
420
417
  /**
421
418
  * Scan text at `scope`. Patterns are cumulative: `strict` includes all scopes.
419
+ * `options.excludeLabels` removes matching patterns without changing `scope`.
422
420
  */
423
- function scanThreats(text, scope = "strict", maxScanChars = 65536) {
421
+ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
424
422
  const findings = [];
425
423
  if (ZERO_WIDTH_CHARS.test(text)) findings.push({
426
424
  label: "unicode_zero_width",
@@ -433,8 +431,10 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536) {
433
431
  scope
434
432
  });
435
433
  const normalized = text.normalize("NFKC").slice(0, maxScanChars);
434
+ const excluded = new Set(options.excludeLabels ?? []);
436
435
  for (const pattern of PATTERNS) {
437
436
  if (SCOPE_ORDER[pattern.scope] > SCOPE_ORDER[scope]) continue;
437
+ if (excluded.has(pattern.label)) continue;
438
438
  if (pattern.regex.test(normalized)) findings.push({
439
439
  label: pattern.label,
440
440
  category: pattern.category,
@@ -444,24 +444,24 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536) {
444
444
  return findings;
445
445
  }
446
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);
447
+ function evaluateThreat(text, scope = "strict", maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
448
+ const findings = scanThreats(text, scope, maxScanChars, options);
449
449
  return {
450
450
  blocked: findings.length > 0,
451
451
  findings
452
452
  };
453
453
  }
454
454
  /** User-facing block message for memory writes. */
455
- function scanMemoryThreats(text, maxScanChars = 65536) {
456
- const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars);
455
+ function scanMemoryThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
456
+ const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
457
457
  if (!blocked) return null;
458
458
  const pattern = findings.find((f) => f.category !== "unicode_obfuscation");
459
459
  if (pattern) return `Blocked by security scan (${pattern.label}). Rephrase without instruction-like language.`;
460
460
  return "Blocked by security scan: invisible or potentially malicious Unicode detected.";
461
461
  }
462
462
  /** User-facing block message for skill content writes. */
463
- function scanContentThreats(text, maxScanChars = 65536) {
464
- const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars);
463
+ function scanContentThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
464
+ const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
465
465
  if (!blocked) return null;
466
466
  return `Blocked by security scan (${findings[0]?.label ?? "unknown"}). This content appears to contain potentially malicious instructions.`;
467
467
  }
@@ -762,10 +762,18 @@ var MemoryStore = class {
762
762
  await this.write("memory", snapshot.memory);
763
763
  await this.write("user", snapshot.user);
764
764
  }
765
+ /**
766
+ * Detect on-disk drift: true when the file is not in the canonical
767
+ * `render(normalizeEntries(raw))` form. This catches structural anomalies
768
+ * the writer would quietly normalize away (empty/`§`-only entries, stray
769
+ * blank lines, leading/trailing delimiters) that indicate the file was
770
+ * edited outside MemoryStore. Purely single-canonical content reaches the
771
+ * same serialization and returns false, so a normal write is never flagged.
772
+ */
765
773
  async detectDrift(target) {
766
774
  const raw = await this.io.readText(fileFor(this.root, target));
767
775
  if (raw === null) return false;
768
- return normalizeEntries(raw).join(ENTRY_DELIMITER) !== raw.trim();
776
+ return render(normalizeEntries(raw)) !== raw;
769
777
  }
770
778
  };
771
779
  //#endregion
@@ -1278,6 +1286,128 @@ var SkillLibrary = class {
1278
1286
  path: dest
1279
1287
  };
1280
1288
  }
1289
+ /**
1290
+ * Merge the bodies of `sources` into `target` and archive the sources with
1291
+ * an absorbed-into marker. Hermes-style consolidation: overlapping skills
1292
+ * collapse into one, and the originals stay recoverable under `.archive/`.
1293
+ */
1294
+ async consolidate(target, sources) {
1295
+ const normalizedSources = [...new Set(sources)].filter((name) => name !== target);
1296
+ if (normalizedSources.length === 0) return {
1297
+ ok: false,
1298
+ message: "Consolidation requires at least one distinct source skill."
1299
+ };
1300
+ for (const name of [target, ...normalizedSources]) if (!SKILL_NAME_RE.test(name)) return {
1301
+ ok: false,
1302
+ message: `Invalid skill name "${name}". Use lowercase letters, digits, and hyphens.`
1303
+ };
1304
+ const targetDir = skillDir(this.root, target);
1305
+ const targetMd = await this.io.readText(join(targetDir, "SKILL.md"));
1306
+ if (!targetMd) return {
1307
+ ok: false,
1308
+ message: `Skill "${target}" not found.`
1309
+ };
1310
+ const targetProtection = await this.writeProtection(target);
1311
+ if (targetProtection) return {
1312
+ ok: false,
1313
+ message: `Skill "${target}" is protected (${targetProtection}).`
1314
+ };
1315
+ const parts = [];
1316
+ for (const source of normalizedSources) {
1317
+ const protection = await this.deleteProtection(source);
1318
+ if (protection) return {
1319
+ ok: false,
1320
+ message: `Skill "${source}" is protected (${protection}).`
1321
+ };
1322
+ const sourceMd = await this.io.readText(join(skillDir(this.root, source), "SKILL.md"));
1323
+ if (!sourceMd) return {
1324
+ ok: false,
1325
+ message: `Skill "${source}" not found.`
1326
+ };
1327
+ const parsed = parseFrontmatter(sourceMd);
1328
+ if (!parsed) return {
1329
+ ok: false,
1330
+ message: `Skill "${source}" has no valid frontmatter; refusing to merge.`
1331
+ };
1332
+ parts.push(`\n<!-- consolidated from ${source} at ${(/* @__PURE__ */ new Date()).toISOString()} -->\n${parsed.body.trim()}`);
1333
+ }
1334
+ const merged = targetMd.trimEnd() + parts.join("\n") + "\n";
1335
+ const validation = validateFrontmatter(merged, target, this.limits);
1336
+ if (validation) return {
1337
+ ok: false,
1338
+ message: `Consolidation rejected: ${validation}`
1339
+ };
1340
+ const threat = scanContentThreats(merged);
1341
+ if (threat) return {
1342
+ ok: false,
1343
+ message: threat
1344
+ };
1345
+ const archived = [];
1346
+ try {
1347
+ for (const source of normalizedSources) {
1348
+ const result = await this.archive(source, target);
1349
+ if (!result.ok) return result;
1350
+ archived.push(source);
1351
+ }
1352
+ await this.io.writeText(join(targetDir, "SKILL.md"), merged);
1353
+ } catch (error) {
1354
+ await this.io.writeText(join(targetDir, "SKILL.md"), targetMd).catch(() => {});
1355
+ for (const source of archived.reverse()) await this.restoreFromArchive(source).catch(() => {});
1356
+ return {
1357
+ ok: false,
1358
+ message: `Consolidation failed and was rolled back: ${error instanceof Error ? error.message : String(error)}`
1359
+ };
1360
+ }
1361
+ return {
1362
+ ok: true,
1363
+ message: `Consolidated ${normalizedSources.join(", ")} into "${target}".`,
1364
+ path: targetDir
1365
+ };
1366
+ }
1367
+ /**
1368
+ * Restore one skill from `.archive/` back to the active root. Hermes-style
1369
+ * recoverability: archival never deletes, and this is the control-plane
1370
+ * path back. The `.archive-reason` marker is dropped on restore.
1371
+ */
1372
+ async restoreFromArchive(name) {
1373
+ if (!SKILL_NAME_RE.test(name)) return {
1374
+ ok: false,
1375
+ message: `Invalid skill name "${name}". Use lowercase letters, digits, and hyphens.`
1376
+ };
1377
+ if (await this.io.exists(join(skillDir(this.root, name), "SKILL.md"))) return {
1378
+ ok: false,
1379
+ message: `Skill "${name}" already exists in the active root; refusing to overwrite.`
1380
+ };
1381
+ const archiveRoot = join(this.root, ".archive");
1382
+ let entries;
1383
+ try {
1384
+ entries = await this.io.list(archiveRoot);
1385
+ } catch {
1386
+ return {
1387
+ ok: false,
1388
+ message: "No skill archive available."
1389
+ };
1390
+ }
1391
+ const chosen = entries.filter((entry) => entry === name || entry.startsWith(`${name}-`)).sort().reverse()[0];
1392
+ if (!chosen) return {
1393
+ ok: false,
1394
+ message: `Skill "${name}" is not in .archive.`
1395
+ };
1396
+ const source = join(archiveRoot, chosen);
1397
+ const dest = skillDir(this.root, name);
1398
+ try {
1399
+ await this.io.rename(source, dest);
1400
+ } catch {
1401
+ await this.io.copy(source, dest);
1402
+ await this.io.remove(source);
1403
+ }
1404
+ if (await this.io.exists(join(dest, ".archive-reason"))) await this.io.remove(join(dest, ".archive-reason"));
1405
+ return {
1406
+ ok: true,
1407
+ message: `Skill "${name}" restored from .archive.`,
1408
+ path: dest
1409
+ };
1410
+ }
1281
1411
  async writeSupportFile(name, filePath, content) {
1282
1412
  const dir = skillDir(this.root, name);
1283
1413
  if (!await this.io.exists(join(dir, "SKILL.md"))) return {
@@ -1403,7 +1533,7 @@ var SkillLibrary = class {
1403
1533
  function evolutionHome(env = process.env) {
1404
1534
  return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "evolution");
1405
1535
  }
1406
- var JsonState = class {
1536
+ var JsonState = class JsonState {
1407
1537
  initial;
1408
1538
  path;
1409
1539
  value;
@@ -1412,14 +1542,24 @@ var JsonState = class {
1412
1542
  this.path = join(evolutionHome(env), name);
1413
1543
  this.value = this.loadSync();
1414
1544
  }
1545
+ /**
1546
+ * Deep-merge persisted state over the initial defaults. Nested plain
1547
+ * objects merge recursively (so a new default field added under an existing
1548
+ * object is preserved), while arrays and primitives take the on-disk value
1549
+ * wholesale. Keeps forward-compatible defaults across schema additions.
1550
+ */
1551
+ static mergeDeep(initial, persisted) {
1552
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1553
+ if (!isRecord(initial) || !isRecord(persisted)) return isRecord(persisted) ? persisted : persisted == null ? initial : persisted;
1554
+ const out = { ...initial };
1555
+ for (const [key, value] of Object.entries(persisted)) out[key] = key in initial ? JsonState.mergeDeep(initial[key], value) : value;
1556
+ return out;
1557
+ }
1415
1558
  loadSync() {
1416
1559
  try {
1417
1560
  const raw = readFileSync(this.path, "utf8");
1418
1561
  const parsed = JSON.parse(raw);
1419
- return {
1420
- ...this.initial,
1421
- ...parsed
1422
- };
1562
+ return JsonState.mergeDeep(this.initial, parsed);
1423
1563
  } catch {
1424
1564
  return { ...this.initial };
1425
1565
  }
@@ -1443,14 +1583,11 @@ var JsonState = class {
1443
1583
  async reload() {
1444
1584
  try {
1445
1585
  const raw = await readFile(this.path, "utf8");
1446
- this.value = {
1447
- ...this.initial,
1448
- ...JSON.parse(raw)
1449
- };
1586
+ this.value = JsonState.mergeDeep(this.initial, JSON.parse(raw));
1450
1587
  } catch {
1451
1588
  this.value = { ...this.initial };
1452
1589
  }
1453
1590
  }
1454
1591
  };
1455
1592
  //#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 };
1593
+ 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, computeLifecycleTransitions, emptyRecord, evaluateThreat, evolutionHome, evolutionIoAdapter, foldTurn, getRecord, latestActivityAt, loadUsage, markAgentCreated, memoryRoot, nodeEvolutionIo, observeEvent, parseFrontmatter, reviewPrompt, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, usageFile, validateFrontmatter, verifyPromptBundle };
package/lib/types/io.d.ts CHANGED
@@ -17,6 +17,4 @@ export interface EvolutionIoLike {
17
17
  /** Lazy adapter over an IO provider registry, shared by every evolution consumer. */
18
18
  export declare function evolutionIoAdapter(provider: () => EvolutionIoLike): EvolutionIoLike;
19
19
  export declare function nodeEvolutionIo(): EvolutionIoLike;
20
- /** Absolute path helper kept separate so stores stay platform-correct. */
21
- export declare function childPath(parent: string, ...parts: string[]): string;
22
20
  //# sourceMappingURL=io.d.ts.map
@@ -55,6 +55,14 @@ export declare class MemoryStore {
55
55
  memory: string[];
56
56
  user: string[];
57
57
  }): Promise<void>;
58
+ /**
59
+ * Detect on-disk drift: true when the file is not in the canonical
60
+ * `render(normalizeEntries(raw))` form. This catches structural anomalies
61
+ * the writer would quietly normalize away (empty/`§`-only entries, stray
62
+ * blank lines, leading/trailing delimiters) that indicate the file was
63
+ * edited outside MemoryStore. Purely single-canonical content reaches the
64
+ * same serialization and returns false, so a normal write is never flagged.
65
+ */
58
66
  detectDrift(target: MemoryTarget): Promise<boolean>;
59
67
  }
60
68
  //# sourceMappingURL=memory-store.d.ts.map
@@ -58,6 +58,18 @@ export declare class SkillLibrary {
58
58
  update(name: string, content: string): Promise<SkillActionResult>;
59
59
  patch(name: string, oldString: string, newString: string, filePath?: string, replaceAll?: boolean): Promise<SkillActionResult>;
60
60
  archive(name: string, absorbedInto?: string): Promise<SkillActionResult>;
61
+ /**
62
+ * Merge the bodies of `sources` into `target` and archive the sources with
63
+ * an absorbed-into marker. Hermes-style consolidation: overlapping skills
64
+ * collapse into one, and the originals stay recoverable under `.archive/`.
65
+ */
66
+ consolidate(target: string, sources: string[]): Promise<SkillActionResult>;
67
+ /**
68
+ * Restore one skill from `.archive/` back to the active root. Hermes-style
69
+ * recoverability: archival never deletes, and this is the control-plane
70
+ * path back. The `.archive-reason` marker is dropped on restore.
71
+ */
72
+ restoreFromArchive(name: string): Promise<SkillActionResult>;
61
73
  writeSupportFile(name: string, filePath: string, content: string): Promise<SkillActionResult>;
62
74
  removeSupportFile(name: string, filePath: string): Promise<SkillActionResult>;
63
75
  snapshotAll(reason?: string): Promise<string>;
@@ -8,6 +8,13 @@ export declare class JsonState<T> {
8
8
  readonly path: string;
9
9
  private value;
10
10
  constructor(name: string, initial: T, env?: NodeJS.ProcessEnv);
11
+ /**
12
+ * Deep-merge persisted state over the initial defaults. Nested plain
13
+ * objects merge recursively (so a new default field added under an existing
14
+ * object is preserved), while arrays and primitives take the on-disk value
15
+ * wholesale. Keeps forward-compatible defaults across schema additions.
16
+ */
17
+ private static mergeDeep;
11
18
  private loadSync;
12
19
  get(): T;
13
20
  set(value: T): void;
@@ -12,17 +12,29 @@ export interface ThreatFinding {
12
12
  category: string;
13
13
  scope: ThreatScope;
14
14
  }
15
+ /**
16
+ * Optional scan controls. Default behavior (`options` omitted) is unchanged:
17
+ * every in-scope pattern blocks. Adapters that need to tolerate a specific
18
+ * benign phrasing (e.g. a skill that legitimately opens with "You are now a ...")
19
+ * can exclude that label by name here. This is opt-in and never widens strict
20
+ * scope; it only permits callers to drop a known-innocent match.
21
+ */
22
+ export interface ScanOptions {
23
+ /** Pattern labels to skip during this scan. */
24
+ excludeLabels?: readonly string[];
25
+ }
15
26
  /**
16
27
  * Scan text at `scope`. Patterns are cumulative: `strict` includes all scopes.
28
+ * `options.excludeLabels` removes matching patterns without changing `scope`.
17
29
  */
18
- export declare function scanThreats(text: string, scope?: ThreatScope, maxScanChars?: number): ThreatFinding[];
30
+ export declare function scanThreats(text: string, scope?: ThreatScope, maxScanChars?: number, options?: ScanOptions): ThreatFinding[];
19
31
  /** Blocking policy: any hit blocks. `severity` is deliberately not a gate. */
20
- export declare function evaluateThreat(text: string, scope?: ThreatScope, maxScanChars?: number): {
32
+ export declare function evaluateThreat(text: string, scope?: ThreatScope, maxScanChars?: number, options?: ScanOptions): {
21
33
  blocked: boolean;
22
34
  findings: ThreatFinding[];
23
35
  };
24
36
  /** User-facing block message for memory writes. */
25
- export declare function scanMemoryThreats(text: string, maxScanChars?: number): string | null;
37
+ export declare function scanMemoryThreats(text: string, maxScanChars?: number, options?: ScanOptions): string | null;
26
38
  /** User-facing block message for skill content writes. */
27
- export declare function scanContentThreats(text: string, maxScanChars?: number): string | null;
39
+ export declare function scanContentThreats(text: string, maxScanChars?: number, options?: ScanOptions): string | null;
28
40
  //# sourceMappingURL=threats.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-core",
3
3
  "description": "Shared stores, prompts, signals and lifecycle logic for the dsh-evolution plugin family (community build)",
4
- "version": "0.1.0-rc.1",
4
+ "version": "0.1.0-rc.11",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },