@lmzhen/dsh-evolution-curator 0.1.0-rc.13 → 0.1.0-rc.15

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
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { join } from "node:path";
4
4
  import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
5
5
  import z from "@deepseek-ai/schemastery";
6
- import { CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_MIN_IDLE_HOURS, DEFAULT_STALE_AFTER_DAYS, SkillLibrary, buildCuratorRunReport, computeLifecycleTransitions, emptyRecord, evolutionHome, evolutionIoAdapter, loadSuppressedNames, loadUsage, saveSuppressedNames, saveUsage } from "@lmzhen/dsh-evolution-core";
6
+ import { CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_MIN_IDLE_HOURS, DEFAULT_STALE_AFTER_DAYS, SkillLibrary, buildCuratorRunReport, computeLifecycleTransitions, emptyRecord, evolutionHome, evolutionIoAdapter, loadSuppressedNames, loadUsage, parseCuratorNominations, saveSuppressedNames, saveUsage } from "@lmzhen/dsh-evolution-core";
7
7
  //#region lib/types/index.js
8
8
  /**
9
9
  * Deterministic skill lifecycle curator with interval gate and archive.
@@ -23,6 +23,8 @@ var EvolutionCurator = class extends Service {
23
23
  excludeSkillNames: z.array(z.string()).default([]),
24
24
  manageUnmanaged: z.boolean().default(false),
25
25
  pruneBuiltins: z.boolean().default(false),
26
+ referencedSkillNames: z.array(z.string()).default([]),
27
+ autoStart: z.boolean().default(true),
26
28
  curatorReviewMaxTokens: z.number().default(2048)
27
29
  });
28
30
  skills;
@@ -38,6 +40,7 @@ var EvolutionCurator = class extends Service {
38
40
  excludeSkillNames;
39
41
  manageUnmanaged;
40
42
  pruneBuiltins;
43
+ referencedSkillNames;
41
44
  curatorReviewMaxTokens;
42
45
  lastRun = 0;
43
46
  timer;
@@ -56,6 +59,7 @@ var EvolutionCurator = class extends Service {
56
59
  this.excludeSkillNames = new Set(config.excludeSkillNames ?? []);
57
60
  this.manageUnmanaged = config.manageUnmanaged ?? false;
58
61
  this.pruneBuiltins = config.pruneBuiltins ?? false;
62
+ this.referencedSkillNames = new Set(config.referencedSkillNames ?? []);
59
63
  this.curatorReviewMaxTokens = config.curatorReviewMaxTokens ?? 2048;
60
64
  this.lastRun = Date.now();
61
65
  this.ctx.effect(() => {
@@ -63,6 +67,7 @@ var EvolutionCurator = class extends Service {
63
67
  this.stop();
64
68
  };
65
69
  }, "evolution-curator.stop");
70
+ if (config.autoStart ?? true) this.start();
66
71
  }
67
72
  lifecycle() {
68
73
  const snapshot = this.ctx.get("evolutionPolicy")?.get();
@@ -84,23 +89,28 @@ var EvolutionCurator = class extends Service {
84
89
  this.timer = void 0;
85
90
  }
86
91
  /**
87
- * Optional Hermes-curator LLM pass. The model may only NOMINATE pruning
88
- * candidates; archive/restore remains a control-plane operation and every
89
- * nominated name is still checked against lifecycle thresholds and
90
- * protected markers before any file move.
92
+ * Optional Hermes-curator LLM pass. The model may only NOMINATE pruning and
93
+ * consolidation; every move stays a control-plane operation and each
94
+ * nomination is re-validated against the tree and protected markers before
95
+ * any file move. `dryRun` prepends the report-only banner.
91
96
  */
92
- async recommend(candidates) {
93
- if (candidates.length === 0) return [];
97
+ async recommend(candidates, options = {}) {
98
+ const empty = {
99
+ prunings: [],
100
+ consolidations: []
101
+ };
102
+ if (candidates.length === 0) return empty;
94
103
  const llm = this.ctx.get("llm");
95
- if (!llm) return [];
104
+ if (!llm) return empty;
96
105
  const model = this.ctx.get("evolutionPolicy")?.get().curatorModel ?? "deepseek-v4-pro";
97
106
  const prompt = [
107
+ options.dryRun ? CURATOR_DRY_RUN_BANNER : "",
98
108
  CURATOR_PROMPT,
99
109
  "",
100
- "Stale candidates observed by the deterministic lifecycle scanner:",
110
+ `Stale candidates observed by the deterministic lifecycle scanner:${candidates.length === 0 ? " (none)" : ""}`,
101
111
  ...candidates.map((name) => `- ${name}`),
102
112
  "",
103
- "Return a YAML summary with a prunings list. Nominate only candidates whose archival is clearly safe."
113
+ "Return a YAML summary with consolidations and prunings lists. Nominate only actions whose archival/merge is clearly safe."
104
114
  ].join("\n");
105
115
  try {
106
116
  const assembler = new BlockAssembler();
@@ -122,13 +132,13 @@ var EvolutionCurator = class extends Service {
122
132
  maxTokens: this.curatorReviewMaxTokens,
123
133
  purpose: "evolution-curator"
124
134
  })) assembler.push(chunk);
125
- const text = assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("\n");
126
- const names = /* @__PURE__ */ new Set();
127
- const section = text.slice(text.indexOf("prunings:"));
128
- for (const [, name] of section.matchAll(/^\s*-\s*name:\s*([a-z0-9][a-z0-9-]*)\s*$/gm)) if (name) names.add(name);
129
- return [...names].filter((name) => candidates.includes(name));
135
+ const parsed = parseCuratorNominations(assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("\n"));
136
+ return {
137
+ prunings: parsed.prunings.filter((name) => candidates.includes(name)),
138
+ consolidations: parsed.consolidations
139
+ };
130
140
  } catch {
131
- return [];
141
+ return empty;
132
142
  }
133
143
  }
134
144
  skippedReport(runId, startedAt) {
@@ -145,22 +155,25 @@ var EvolutionCurator = class extends Service {
145
155
  }
146
156
  /**
147
157
  * Run one curator pass. `ignoreGates` skips the interval and idle gates so an
148
- * explicit `/evolution curator run` always executes (manual-run semantics).
158
+ * explicit `/evolution curator run` always executes (manual-run semantics):
159
+ * `dryRun` computes the lifecycle and the LLM nominations but performs no
160
+ * mutation, reports what WOULD happen, and does not push out the next run.
149
161
  */
150
162
  async run(options = {}) {
163
+ const { ignoreGates = false, dryRun = false } = options;
151
164
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
152
165
  const runId = randomUUID();
153
166
  const stateService = this.ctx.get("evolutionState");
154
167
  const lifecycle = this.lifecycle();
155
168
  const persisted = await stateService?.loadCuratorState();
156
- if (!options.ignoreGates && persisted && Date.now() - persisted.lastRunAt < lifecycle.intervalHours * 36e5) return {
169
+ if (!ignoreGates && persisted && Date.now() - persisted.lastRunAt < lifecycle.intervalHours * 36e5) return {
157
170
  stale: [],
158
171
  archived: [],
159
172
  errors: [],
160
173
  report: this.skippedReport(runId, startedAt),
161
174
  skipped: "interval"
162
175
  };
163
- if (!options.ignoreGates && this.minIdleHours > 0 && this.recentSessionActive()) return {
176
+ if (!ignoreGates && this.minIdleHours > 0 && this.recentSessionActive()) return {
164
177
  stale: [],
165
178
  archived: [],
166
179
  errors: [],
@@ -168,14 +181,11 @@ var EvolutionCurator = class extends Service {
168
181
  skipped: "active-session"
169
182
  };
170
183
  const root = this.skills.root;
171
- const snapshotPath = await this.skills.snapshotAll("pre-curator-run");
172
- const usage = await loadUsage(root, this.io);
184
+ const rawUsage = await loadUsage(root, this.io);
185
+ const usage = dryRun ? new Map([...rawUsage].map(([name, record]) => [name, { ...record }])) : rawUsage;
186
+ const snapshotPath = dryRun ? void 0 : await this.skills.snapshotAll("pre-curator-run");
173
187
  const suppressedNames = new Set(await loadSuppressedNames(root, this.io));
174
- const bundledNames = /* @__PURE__ */ new Set();
175
- for (const summary of await this.skills.list()) {
176
- if (!usage.has(summary.name)) usage.set(summary.name, emptyRecord());
177
- if (await this.skills.isBundled(summary.name)) bundledNames.add(summary.name);
178
- }
188
+ const { bundledNames, treeNames } = await this.seedBaseline(usage);
179
189
  const result = computeLifecycleTransitions(usage, {
180
190
  staleAfterDays: lifecycle.staleAfterDays,
181
191
  archiveAfterDays: lifecycle.archiveAfterDays,
@@ -184,37 +194,72 @@ var EvolutionCurator = class extends Service {
184
194
  manageUnmanaged: this.manageUnmanaged,
185
195
  pruneBuiltins: this.pruneBuiltins,
186
196
  bundledNames,
187
- suppressedNames
197
+ suppressedNames,
198
+ referencedSkillNames: this.referencedSkillNames
188
199
  });
189
200
  const errors = [];
190
201
  const archivedSkills = [];
191
- const llmNominations = this.llmReview ? await this.recommend(result.markStale) : [];
202
+ const nominations = this.llmReview ? await this.recommend(result.markStale, { dryRun }) : {
203
+ prunings: [],
204
+ consolidations: []
205
+ };
206
+ const llmNominations = nominations.prunings;
192
207
  const archiveCandidates = [...new Set([...result.archive, ...llmNominations])];
193
208
  let suppressedChanged = false;
194
- for (const name of archiveCandidates) {
195
- const archived = await this.skills.archive(name, {
196
- reason: "Lifecycle: reached archive threshold",
197
- allowBundled: this.pruneBuiltins
198
- });
199
- if (!archived.ok) {
200
- const record = usage.get(name);
201
- if (record) record.state = "active";
202
- errors.push(`${name}: ${archived.message}`);
203
- } else {
204
- archivedSkills.push({
205
- name,
206
- path: archived.path ?? "",
207
- reason: "Lifecycle: reached archive threshold"
209
+ if (!dryRun) {
210
+ for (const name of archiveCandidates) {
211
+ const archived = await this.skills.archive(name, {
212
+ reason: "Lifecycle: reached archive threshold",
213
+ allowBundled: this.pruneBuiltins
208
214
  });
209
- if (bundledNames.has(name)) {
210
- suppressedNames.add(name);
211
- suppressedChanged = true;
215
+ if (!archived.ok) {
216
+ const record = usage.get(name);
217
+ if (record) record.state = "active";
218
+ errors.push(`${name}: ${archived.message}`);
219
+ } else {
220
+ archivedSkills.push({
221
+ name,
222
+ path: archived.path ?? "",
223
+ reason: "Lifecycle: reached archive threshold"
224
+ });
225
+ if (bundledNames.has(name)) {
226
+ suppressedNames.add(name);
227
+ suppressedChanged = true;
228
+ }
229
+ }
230
+ }
231
+ const alreadyArchived = new Set(archiveCandidates);
232
+ for (const nomination of nominations.consolidations) {
233
+ if (alreadyArchived.has(nomination.from)) continue;
234
+ if (!treeNames.has(nomination.from) || !treeNames.has(nomination.into)) {
235
+ errors.push(`${nomination.from}: consolidation target or source missing from the skill tree`);
236
+ continue;
237
+ }
238
+ const consolidated = await this.skills.consolidate(nomination.into, [nomination.from], "background_review");
239
+ if (!consolidated.ok) {
240
+ errors.push(`${nomination.from}: ${consolidated.message}`);
241
+ continue;
212
242
  }
243
+ const record = usage.get(nomination.from);
244
+ if (record) {
245
+ record.state = "archived";
246
+ record.archived_at = (/* @__PURE__ */ new Date()).toISOString();
247
+ }
248
+ alreadyArchived.add(nomination.from);
249
+ archivedSkills.push({
250
+ name: nomination.from,
251
+ path: consolidated.path ?? "",
252
+ reason: `Consolidated into ${nomination.into}`
253
+ });
254
+ }
255
+ if (suppressedChanged) try {
256
+ await saveSuppressedNames(root, suppressedNames, this.io);
257
+ } catch {
258
+ this.ctx.logger.warn("evolution-curator: failed to persist suppressed names; archived built-ins may re-enter the lifecycle");
213
259
  }
260
+ await saveUsage(root, usage, this.io);
214
261
  }
215
- if (suppressedChanged) await saveSuppressedNames(root, suppressedNames, this.io);
216
- await saveUsage(root, usage, this.io);
217
- this.lastRun = Date.now();
262
+ if (!dryRun) this.lastRun = Date.now();
218
263
  const report = buildCuratorRunReport({
219
264
  runId,
220
265
  startedAt,
@@ -229,7 +274,7 @@ var EvolutionCurator = class extends Service {
229
274
  reason: errors.find((item) => item.startsWith(`${name}:`))?.slice(name.length + 2) ?? "unknown"
230
275
  };
231
276
  }),
232
- snapshotPath
277
+ ...snapshotPath === void 0 ? {} : { snapshotPath }
233
278
  });
234
279
  const reportsRoot = join(evolutionHome(), "reports");
235
280
  try {
@@ -238,17 +283,38 @@ var EvolutionCurator = class extends Service {
238
283
  this.ctx.logger.warn(`evolution-curator: failed to persist report ${runId}`);
239
284
  this.ctx.logger.warn(error);
240
285
  }
286
+ const summary = `${dryRun ? "dry-run" : "auto"}: stale:${result.markStale.length} archived:${archivedSkills.length} consolidated:${nominations.consolidations.length}`;
241
287
  await stateService?.saveCuratorState({
242
- lastRunAt: this.lastRun,
243
- runCount: (persisted?.runCount ?? 0) + 1,
244
- lastSummary: `stale:${result.markStale.length} archived:${archivedSkills.length}`,
288
+ lastRunAt: dryRun ? persisted?.lastRunAt ?? this.lastRun : this.lastRun,
289
+ runCount: dryRun ? persisted?.runCount ?? 0 : (persisted?.runCount ?? 0) + 1,
290
+ lastSummary: summary,
245
291
  paused: false
246
292
  });
247
293
  return {
248
294
  stale: result.markStale,
249
295
  archived: archivedSkills.map((item) => item.name),
250
296
  errors,
251
- report
297
+ report,
298
+ ...this.llmReview ? { nominations } : {}
299
+ };
300
+ }
301
+ /**
302
+ * Seed baseline records for tree skills the sidecar has not seen yet, so
303
+ * their inactivity clock starts now (first-sight defer) and bundled skills
304
+ * become known candidates only when prune-builtins opts them in. Also
305
+ * returns the full active tree names for nomination validation.
306
+ */
307
+ async seedBaseline(usage) {
308
+ const bundledNames = /* @__PURE__ */ new Set();
309
+ const treeNames = /* @__PURE__ */ new Set();
310
+ for (const summary of await this.skills.list()) {
311
+ treeNames.add(summary.name);
312
+ if (!usage.has(summary.name)) usage.set(summary.name, emptyRecord());
313
+ if (await this.skills.isBundled(summary.name)) bundledNames.add(summary.name);
314
+ }
315
+ return {
316
+ bundledNames,
317
+ treeNames
252
318
  };
253
319
  }
254
320
  recentSessionActive() {
@@ -309,7 +375,11 @@ var EvolutionCurator = class extends Service {
309
375
  if (record) record.state = "active";
310
376
  await saveUsage(this.skills.root, usage, this.io);
311
377
  const suppressed = new Set(await loadSuppressedNames(this.skills.root, this.io));
312
- if (suppressed.delete(name)) await saveSuppressedNames(this.skills.root, suppressed, this.io);
378
+ if (suppressed.delete(name)) try {
379
+ await saveSuppressedNames(this.skills.root, suppressed, this.io);
380
+ } catch {
381
+ this.ctx.logger.warn(`evolution-curator: failed to persist suppressed names after restoring ${name}`);
382
+ }
313
383
  return result;
314
384
  }
315
385
  };
@@ -5,7 +5,7 @@
5
5
  import { Context, Service } from '@deepseek-ai/cordis';
6
6
  import type Schema from '@deepseek-ai/schemastery';
7
7
  import { SkillLibrary } from '@deepseek-ai/dsh-evolution-core';
8
- import { type CuratorRunReport, type SkillActionResult } from '@deepseek-ai/dsh-evolution-core';
8
+ import { type CuratorNominations, type CuratorRunReport, type SkillActionResult } from '@deepseek-ai/dsh-evolution-core';
9
9
  declare module '@deepseek-ai/cordis' {
10
10
  interface Context {
11
11
  evolutionCurator: EvolutionCurator;
@@ -29,6 +29,10 @@ export interface Config {
29
29
  manageUnmanaged?: boolean;
30
30
  /** Archive long-unused bundled skills too (with suppression against re-seeds). */
31
31
  pruneBuiltins?: boolean;
32
+ /** Static scheduled-task skill references; such skills never auto-transition. */
33
+ referencedSkillNames?: string[];
34
+ /** Start the interval timer on context ready (auto-curation). Default true. */
35
+ autoStart?: boolean;
32
36
  /** Max tokens for the optional LLM nomination pass. */
33
37
  curatorReviewMaxTokens?: number;
34
38
  }
@@ -39,6 +43,8 @@ export interface CuratorRunOutcome {
39
43
  errors: string[];
40
44
  report: CuratorRunReport;
41
45
  skipped?: string;
46
+ /** LLM nominations when the optional review pass is enabled (audit visibility). */
47
+ nominations?: CuratorNominations;
42
48
  }
43
49
  export declare class EvolutionCurator extends Service {
44
50
  static inject: string[];
@@ -56,6 +62,7 @@ export declare class EvolutionCurator extends Service {
56
62
  private readonly excludeSkillNames;
57
63
  private readonly manageUnmanaged;
58
64
  private readonly pruneBuiltins;
65
+ private readonly referencedSkillNames;
59
66
  private readonly curatorReviewMaxTokens;
60
67
  private lastRun;
61
68
  private timer;
@@ -64,20 +71,32 @@ export declare class EvolutionCurator extends Service {
64
71
  start(): void;
65
72
  stop(): void;
66
73
  /**
67
- * Optional Hermes-curator LLM pass. The model may only NOMINATE pruning
68
- * candidates; archive/restore remains a control-plane operation and every
69
- * nominated name is still checked against lifecycle thresholds and
70
- * protected markers before any file move.
74
+ * Optional Hermes-curator LLM pass. The model may only NOMINATE pruning and
75
+ * consolidation; every move stays a control-plane operation and each
76
+ * nomination is re-validated against the tree and protected markers before
77
+ * any file move. `dryRun` prepends the report-only banner.
71
78
  */
72
- recommend(candidates: string[]): Promise<string[]>;
79
+ recommend(candidates: string[], options?: {
80
+ dryRun?: boolean;
81
+ }): Promise<CuratorNominations>;
73
82
  private skippedReport;
74
83
  /**
75
84
  * Run one curator pass. `ignoreGates` skips the interval and idle gates so an
76
- * explicit `/evolution curator run` always executes (manual-run semantics).
85
+ * explicit `/evolution curator run` always executes (manual-run semantics):
86
+ * `dryRun` computes the lifecycle and the LLM nominations but performs no
87
+ * mutation, reports what WOULD happen, and does not push out the next run.
77
88
  */
78
89
  run(options?: {
79
90
  ignoreGates?: boolean;
91
+ dryRun?: boolean;
80
92
  }): Promise<CuratorRunOutcome>;
93
+ /**
94
+ * Seed baseline records for tree skills the sidecar has not seen yet, so
95
+ * their inactivity clock starts now (first-sight defer) and bundled skills
96
+ * become known candidates only when prune-builtins opts them in. Also
97
+ * returns the full active tree names for nomination validation.
98
+ */
99
+ private seedBaseline;
81
100
  private recentSessionActive;
82
101
  latestReport(): Promise<CuratorRunReport | null>;
83
102
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-curator",
3
3
  "description": "Deterministic skill lifecycle and recovery (community build)",
4
- "version": "0.1.0-rc.13",
4
+ "version": "0.1.0-rc.15",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -33,20 +33,20 @@
33
33
  "license": "MIT",
34
34
  "dependencies": {
35
35
  "@deepseek-ai/schemastery": "^3.18.1",
36
- "@lmzhen/dsh-evolution-core": "^0.1.0-rc.13"
36
+ "@lmzhen/dsh-evolution-core": "^0.1.0-rc.15"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@deepseek-ai/cordis": "^4.0.1",
40
40
  "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
41
41
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
42
- "@lmzhen/dsh-evolution-io": "^0.1.0-rc.13",
43
- "@lmzhen/dsh-evolution-state": "^0.1.0-rc.13"
42
+ "@lmzhen/dsh-evolution-io": "^0.1.0-rc.15",
43
+ "@lmzhen/dsh-evolution-state": "^0.1.0-rc.15"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
47
47
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
48
- "@lmzhen/dsh-evolution-core": "^0.1.0-rc.13",
49
- "@lmzhen/dsh-evolution-io": "^0.1.0-rc.13",
50
- "@lmzhen/dsh-evolution-state": "^0.1.0-rc.13"
48
+ "@lmzhen/dsh-evolution-core": "^0.1.0-rc.15",
49
+ "@lmzhen/dsh-evolution-io": "^0.1.0-rc.15",
50
+ "@lmzhen/dsh-evolution-state": "^0.1.0-rc.15"
51
51
  }
52
52
  }