@lmzhen/dsh-evolution-curator 0.1.0-rc.14 → 0.1.0-rc.16

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, computeQualityScores, 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,10 +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 = await this.seedBaseline(usage);
188
+ const { bundledNames, treeNames } = await this.seedBaseline(usage);
175
189
  const result = computeLifecycleTransitions(usage, {
176
190
  staleAfterDays: lifecycle.staleAfterDays,
177
191
  archiveAfterDays: lifecycle.archiveAfterDays,
@@ -180,41 +194,85 @@ var EvolutionCurator = class extends Service {
180
194
  manageUnmanaged: this.manageUnmanaged,
181
195
  pruneBuiltins: this.pruneBuiltins,
182
196
  bundledNames,
183
- suppressedNames
197
+ suppressedNames,
198
+ referencedSkillNames: this.referencedSkillNames
199
+ });
200
+ const supportDirs = /* @__PURE__ */ new Map();
201
+ for (const name of treeNames) supportDirs.set(name, await this.skills.countSupportDirs(name));
202
+ const quality = computeQualityScores({
203
+ usage,
204
+ supportDirs
184
205
  });
206
+ for (const [name, score] of quality) {
207
+ const record = usage.get(name);
208
+ if (record) {
209
+ record.quality_score = score.score;
210
+ record.quality_warn = score.warn;
211
+ }
212
+ }
185
213
  const errors = [];
186
214
  const archivedSkills = [];
187
- const llmNominations = this.llmReview ? await this.recommend(result.markStale) : [];
215
+ const nominations = this.llmReview ? await this.recommend(result.markStale, { dryRun }) : {
216
+ prunings: [],
217
+ consolidations: []
218
+ };
219
+ const llmNominations = nominations.prunings;
188
220
  const archiveCandidates = [...new Set([...result.archive, ...llmNominations])];
189
221
  let suppressedChanged = false;
190
- for (const name of archiveCandidates) {
191
- const archived = await this.skills.archive(name, {
192
- reason: "Lifecycle: reached archive threshold",
193
- allowBundled: this.pruneBuiltins
194
- });
195
- if (!archived.ok) {
196
- const record = usage.get(name);
197
- if (record) record.state = "active";
198
- errors.push(`${name}: ${archived.message}`);
199
- } else {
200
- archivedSkills.push({
201
- name,
202
- path: archived.path ?? "",
203
- reason: "Lifecycle: reached archive threshold"
222
+ if (!dryRun) {
223
+ for (const name of archiveCandidates) {
224
+ const archived = await this.skills.archive(name, {
225
+ reason: "Lifecycle: reached archive threshold",
226
+ allowBundled: this.pruneBuiltins
204
227
  });
205
- if (bundledNames.has(name)) {
206
- suppressedNames.add(name);
207
- suppressedChanged = true;
228
+ if (!archived.ok) {
229
+ const record = usage.get(name);
230
+ if (record) record.state = "active";
231
+ errors.push(`${name}: ${archived.message}`);
232
+ } else {
233
+ archivedSkills.push({
234
+ name,
235
+ path: archived.path ?? "",
236
+ reason: "Lifecycle: reached archive threshold"
237
+ });
238
+ if (bundledNames.has(name)) {
239
+ suppressedNames.add(name);
240
+ suppressedChanged = true;
241
+ }
208
242
  }
209
243
  }
244
+ const alreadyArchived = new Set(archiveCandidates);
245
+ for (const nomination of nominations.consolidations) {
246
+ if (alreadyArchived.has(nomination.from)) continue;
247
+ if (!treeNames.has(nomination.from) || !treeNames.has(nomination.into)) {
248
+ errors.push(`${nomination.from}: consolidation target or source missing from the skill tree`);
249
+ continue;
250
+ }
251
+ const consolidated = await this.skills.consolidate(nomination.into, [nomination.from], "background_review");
252
+ if (!consolidated.ok) {
253
+ errors.push(`${nomination.from}: ${consolidated.message}`);
254
+ continue;
255
+ }
256
+ const record = usage.get(nomination.from);
257
+ if (record) {
258
+ record.state = "archived";
259
+ record.archived_at = (/* @__PURE__ */ new Date()).toISOString();
260
+ }
261
+ alreadyArchived.add(nomination.from);
262
+ archivedSkills.push({
263
+ name: nomination.from,
264
+ path: consolidated.path ?? "",
265
+ reason: `Consolidated into ${nomination.into}`
266
+ });
267
+ }
268
+ if (suppressedChanged) try {
269
+ await saveSuppressedNames(root, suppressedNames, this.io);
270
+ } catch {
271
+ this.ctx.logger.warn("evolution-curator: failed to persist suppressed names; archived built-ins may re-enter the lifecycle");
272
+ }
273
+ await saveUsage(root, usage, this.io);
210
274
  }
211
- if (suppressedChanged) try {
212
- await saveSuppressedNames(root, suppressedNames, this.io);
213
- } catch {
214
- this.ctx.logger.warn("evolution-curator: failed to persist suppressed names; archived built-ins may re-enter the lifecycle");
215
- }
216
- await saveUsage(root, usage, this.io);
217
- this.lastRun = Date.now();
275
+ if (!dryRun) this.lastRun = Date.now();
218
276
  const report = buildCuratorRunReport({
219
277
  runId,
220
278
  startedAt,
@@ -229,7 +287,7 @@ var EvolutionCurator = class extends Service {
229
287
  reason: errors.find((item) => item.startsWith(`${name}:`))?.slice(name.length + 2) ?? "unknown"
230
288
  };
231
289
  }),
232
- snapshotPath
290
+ ...snapshotPath === void 0 ? {} : { snapshotPath }
233
291
  });
234
292
  const reportsRoot = join(evolutionHome(), "reports");
235
293
  try {
@@ -238,31 +296,39 @@ var EvolutionCurator = class extends Service {
238
296
  this.ctx.logger.warn(`evolution-curator: failed to persist report ${runId}`);
239
297
  this.ctx.logger.warn(error);
240
298
  }
299
+ const summary = `${dryRun ? "dry-run" : "auto"}: stale:${result.markStale.length} archived:${archivedSkills.length} consolidated:${nominations.consolidations.length}`;
241
300
  await stateService?.saveCuratorState({
242
- lastRunAt: this.lastRun,
243
- runCount: (persisted?.runCount ?? 0) + 1,
244
- lastSummary: `stale:${result.markStale.length} archived:${archivedSkills.length}`,
301
+ lastRunAt: dryRun ? persisted?.lastRunAt ?? this.lastRun : this.lastRun,
302
+ runCount: dryRun ? persisted?.runCount ?? 0 : (persisted?.runCount ?? 0) + 1,
303
+ lastSummary: summary,
245
304
  paused: false
246
305
  });
247
306
  return {
248
307
  stale: result.markStale,
249
308
  archived: archivedSkills.map((item) => item.name),
250
309
  errors,
251
- report
310
+ report,
311
+ ...this.llmReview ? { nominations } : {}
252
312
  };
253
313
  }
254
314
  /**
255
315
  * Seed baseline records for tree skills the sidecar has not seen yet, so
256
316
  * their inactivity clock starts now (first-sight defer) and bundled skills
257
- * become known candidates only when prune-builtins opts them in.
317
+ * become known candidates only when prune-builtins opts them in. Also
318
+ * returns the full active tree names for nomination validation.
258
319
  */
259
320
  async seedBaseline(usage) {
260
321
  const bundledNames = /* @__PURE__ */ new Set();
322
+ const treeNames = /* @__PURE__ */ new Set();
261
323
  for (const summary of await this.skills.list()) {
324
+ treeNames.add(summary.name);
262
325
  if (!usage.has(summary.name)) usage.set(summary.name, emptyRecord());
263
326
  if (await this.skills.isBundled(summary.name)) bundledNames.add(summary.name);
264
327
  }
265
- return bundledNames;
328
+ return {
329
+ bundledNames,
330
+ treeNames
331
+ };
266
332
  }
267
333
  recentSessionActive() {
268
334
  const agents = this.ctx.get("agents");
@@ -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,24 +71,30 @@ 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>;
81
93
  /**
82
94
  * Seed baseline records for tree skills the sidecar has not seen yet, so
83
95
  * their inactivity clock starts now (first-sight defer) and bundled skills
84
- * become known candidates only when prune-builtins opts them in.
96
+ * become known candidates only when prune-builtins opts them in. Also
97
+ * returns the full active tree names for nomination validation.
85
98
  */
86
99
  private seedBaseline;
87
100
  private recentSessionActive;
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.14",
4
+ "version": "0.1.0-rc.16",
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.14"
36
+ "@lmzhen/dsh-evolution-core": "^0.1.0-rc.16"
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.14",
43
- "@lmzhen/dsh-evolution-state": "^0.1.0-rc.14"
42
+ "@lmzhen/dsh-evolution-io": "^0.1.0-rc.16",
43
+ "@lmzhen/dsh-evolution-state": "^0.1.0-rc.16"
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.14",
49
- "@lmzhen/dsh-evolution-io": "^0.1.0-rc.14",
50
- "@lmzhen/dsh-evolution-state": "^0.1.0-rc.14"
48
+ "@lmzhen/dsh-evolution-core": "^0.1.0-rc.16",
49
+ "@lmzhen/dsh-evolution-io": "^0.1.0-rc.16",
50
+ "@lmzhen/dsh-evolution-state": "^0.1.0-rc.16"
51
51
  }
52
52
  }