@lmzhen/dsh-evolution-curator 0.1.0-rc.37 → 0.1.0-rc.39

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/README.md CHANGED
@@ -28,4 +28,10 @@ Independent of request-prefix construction. This package does not alter the asse
28
28
 
29
29
  - `archive` never deletes: skills move to `.archive/` with a `.archive-reason` marker.
30
30
  - `restore(name)` (service) / `/evolution skill restore <name>` brings one archived skill back to the active root and resets its usage state.
31
- - `consolidate(target, sources)` (service) / `/evolution consolidate` merges source bodies into the target, archives the sources with an absorbed-into marker, and folds their usage records into `archived` state. Both operations snapshot the skill tree first (`pre-consolidate` / `pre-restore`).
31
+ - `consolidate(target, sources)` (service) / `/evolution consolidate` merges source bodies into the target, archives the sources with an absorbed-into marker, and folds their usage records into `archived` state. Both operations snapshot the full state first (`pre-consolidate` / `pre-restore`).
32
+ - `restoreSnapshot` (service) / `/evolution restore` rolls the FULL state back to the latest snapshot: active tree, usage/suppression sidecars, `.archive/` and the curator state carried in the snapshot (`curator-state.json`), so the interval gate does not immediately re-fire after a rollback. The restore itself is undoable — the pre-rollback safety snapshot preserves the current tree plus its state.
33
+
34
+ ## Automatic scheduling
35
+
36
+ - `autoStart` (default true) arms an hourly interval check plus a deferred catch-up check `bootGraceSeconds` (default 10) after host boot. Both decide due-ness from the **persisted** `lastRunAt`, so a restart with an overdue schedule runs the first pass within the boot grace instead of waiting a full interval. `bootGraceSeconds: 0` disables the deferral (not recommended: the check may run against a half-mounted host). All scheduling gates — interval, idle, first-run deferral, and the reentrancy guard — remain inside `run()`.
37
+ - `autoStart: false` disables both automatic checks; `/evolution curator run` (manual, gate-skipping) still works.
package/lib/index.js CHANGED
@@ -35,6 +35,7 @@ var EvolutionCurator = class extends Service {
35
35
  pruneBuiltins: z.boolean().default(false),
36
36
  referencedSkillNames: z.array(z.string()).default([]),
37
37
  autoStart: z.boolean().default(true),
38
+ bootGraceSeconds: z.number().default(10),
38
39
  curatorReviewMaxTokens: z.number().default(2048)
39
40
  });
40
41
  skills;
@@ -51,9 +52,12 @@ var EvolutionCurator = class extends Service {
51
52
  manageUnmanaged;
52
53
  pruneBuiltins;
53
54
  referencedSkillNames;
55
+ bootGraceSeconds;
54
56
  curatorReviewMaxTokens;
55
57
  lastRun = 0;
56
58
  timer;
59
+ bootCheck;
60
+ running = false;
57
61
  constructor(ctx, config = {}) {
58
62
  super(ctx, "evolutionCurator");
59
63
  this.io = evolutionIoAdapter(() => ctx.evolutionIo.provider());
@@ -70,6 +74,7 @@ var EvolutionCurator = class extends Service {
70
74
  this.manageUnmanaged = config.manageUnmanaged ?? false;
71
75
  this.pruneBuiltins = config.pruneBuiltins ?? false;
72
76
  this.referencedSkillNames = new Set(config.referencedSkillNames ?? []);
77
+ this.bootGraceSeconds = config.bootGraceSeconds ?? 10;
73
78
  this.curatorReviewMaxTokens = config.curatorReviewMaxTokens ?? 2048;
74
79
  this.lastRun = Date.now();
75
80
  this.ctx.effect(() => {
@@ -89,14 +94,30 @@ var EvolutionCurator = class extends Service {
89
94
  }
90
95
  start() {
91
96
  if (!this.enabled || this.timer) return;
92
- this.timer = setInterval(() => {
93
- if (Date.now() - this.lastRun >= this.lifecycle().intervalHours * 36e5) this.run();
94
- }, 3600 * 1e3);
97
+ this.bootCheck = setTimeout(() => {
98
+ this.bootCheck = void 0;
99
+ this.autoCheck();
100
+ }, this.bootGraceSeconds * 1e3);
101
+ this.bootCheck.unref();
102
+ this.timer = setInterval(() => void this.autoCheck(), 3600 * 1e3);
95
103
  this.timer.unref();
96
104
  }
97
105
  stop() {
98
106
  if (this.timer) clearInterval(this.timer);
99
107
  this.timer = void 0;
108
+ if (this.bootCheck) clearTimeout(this.bootCheck);
109
+ this.bootCheck = void 0;
110
+ }
111
+ /**
112
+ * One automatic schedule check: run a pass when the persisted curator state
113
+ * (falling back to the in-memory clock for state-less compositions) is at
114
+ * least one interval old. All gates — interval, idle, first-run defer,
115
+ * reentrancy — stay inside `run()`, so this method only decides whether to
116
+ * wake it, and never duplicates gate logic.
117
+ */
118
+ async autoCheck() {
119
+ const last = (await this.curatorStateService()?.loadCuratorState())?.lastRunAt ?? this.lastRun;
120
+ if (Date.now() - last >= this.lifecycle().intervalHours * 36e5) await this.run();
100
121
  }
101
122
  /**
102
123
  * Optional Hermes-curator LLM pass. The model may only NOMINATE pruning and
@@ -151,6 +172,47 @@ var EvolutionCurator = class extends Service {
151
172
  return empty;
152
173
  }
153
174
  }
175
+ /** Optional curator-state service (evolution-state-json / storage-domain). */
176
+ curatorStateService() {
177
+ return this.ctx.get("evolutionState");
178
+ }
179
+ /**
180
+ * Full-state snapshot: the skills tree plus the current curator state as an
181
+ * `extras/curator-state.json` side file. Every pre-mutation snapshot in the
182
+ * curator goes through here so a later `restoreSnapshot()` can rewind both
183
+ * the tree and the state (Hermes curator_backup backs up `.curator_state`).
184
+ */
185
+ async snapshotFull(reason = "pre-mutation") {
186
+ const state = await this.curatorStateService()?.loadCuratorState();
187
+ const extras = state === null || state === void 0 ? [] : [{
188
+ name: "curator-state.json",
189
+ content: JSON.stringify(state, null, 2)
190
+ }];
191
+ return await this.skills.snapshotAll(reason, extras);
192
+ }
193
+ /**
194
+ * Full-state rollback: restore the latest snapshot's tree/sidecars/archive
195
+ * AND the curator state it carried. The pre-rollback safety snapshot keeps
196
+ * the current tree plus current state (as extras), so the rollback itself
197
+ * is reversible.
198
+ */
199
+ async restoreSnapshot() {
200
+ const stateService = this.curatorStateService();
201
+ const currentState = await stateService?.loadCuratorState();
202
+ const extras = currentState === null || currentState === void 0 ? [] : [{
203
+ name: "curator-state.json",
204
+ content: JSON.stringify(currentState, null, 2)
205
+ }];
206
+ const result = await this.skills.restoreLatestSnapshot(extras);
207
+ if (!result.ok) return result;
208
+ const stateExtra = result.extras?.find((extra) => extra.name === "curator-state.json");
209
+ if (stateExtra && stateService) try {
210
+ await stateService.saveCuratorState(JSON.parse(stateExtra.content));
211
+ } catch (error) {
212
+ this.ctx.logger.warn(`evolution-curator: failed to restore curator state: ${error instanceof Error ? error.message : String(error)}`);
213
+ }
214
+ return result;
215
+ }
154
216
  skippedReport(runId, startedAt) {
155
217
  return buildCuratorRunReport({
156
218
  runId,
@@ -169,12 +231,29 @@ var EvolutionCurator = class extends Service {
169
231
  * explicit `/evolution curator run` always executes (manual-run semantics):
170
232
  * `dryRun` computes the lifecycle and the LLM nominations but performs no
171
233
  * mutation, reports what WOULD happen, and does not push out the next run.
234
+ * Reentrant calls (autoStart timer + manual command at the same instant) are
235
+ * skipped with an explicit `already-running` outcome.
172
236
  */
173
237
  async run(options = {}) {
238
+ if (this.running) return {
239
+ stale: [],
240
+ archived: [],
241
+ errors: [],
242
+ report: this.skippedReport("already-running", (/* @__PURE__ */ new Date()).toISOString()),
243
+ skipped: "already-running"
244
+ };
245
+ this.running = true;
246
+ try {
247
+ return await this.runCore(options);
248
+ } finally {
249
+ this.running = false;
250
+ }
251
+ }
252
+ async runCore(options = {}) {
174
253
  const { ignoreGates = false, dryRun = false } = options;
175
254
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
176
255
  const runId = randomUUID();
177
- const stateService = this.ctx.get("evolutionState");
256
+ const stateService = this.curatorStateService();
178
257
  const lifecycle = this.lifecycle();
179
258
  const persisted = await stateService?.loadCuratorState();
180
259
  if (!ignoreGates && persisted && Date.now() - persisted.lastRunAt < lifecycle.intervalHours * 36e5) return {
@@ -210,7 +289,7 @@ var EvolutionCurator = class extends Service {
210
289
  const root = this.skills.root;
211
290
  const rawUsage = await loadUsage(root, this.io);
212
291
  const usage = dryRun ? new Map([...rawUsage].map(([name, record]) => [name, { ...record }])) : rawUsage;
213
- const snapshotPath = dryRun ? void 0 : await this.skills.snapshotAll("pre-curator-run");
292
+ const snapshotPath = dryRun ? void 0 : await this.snapshotFull("pre-curator-run");
214
293
  const suppressedNames = new Set(await loadSuppressedNames(root, this.io));
215
294
  const { bundledNames, treeNames } = await this.seedBaseline(usage);
216
295
  const result = computeLifecycleTransitions(usage, {
@@ -501,7 +580,7 @@ var EvolutionCurator = class extends Service {
501
580
  ok: false,
502
581
  message: `Skill(s) excluded from lifecycle management: ${blocked.join(", ")}`
503
582
  };
504
- await this.skills.snapshotAll("pre-consolidate");
583
+ await this.snapshotFull("pre-consolidate");
505
584
  const result = await this.skills.consolidate(target, sources);
506
585
  if (!result.ok) return result;
507
586
  const usage = await loadUsage(this.skills.root, this.io);
@@ -518,7 +597,7 @@ var EvolutionCurator = class extends Service {
518
597
  * and reset its usage state, keeping the recoverable-archive invariant.
519
598
  */
520
599
  async restore(name) {
521
- await this.skills.snapshotAll("pre-restore");
600
+ await this.snapshotFull("pre-restore");
522
601
  const result = await this.skills.restoreFromArchive(name);
523
602
  if (!result.ok) return result;
524
603
  const usage = await loadUsage(this.skills.root, this.io);
@@ -33,6 +33,8 @@ export interface Config {
33
33
  referencedSkillNames?: string[];
34
34
  /** Start the interval timer on context ready (auto-curation). Default true. */
35
35
  autoStart?: boolean;
36
+ /** Seconds between host boot and the first automatic schedule check (restart catch-up). */
37
+ bootGraceSeconds?: number;
36
38
  /** Max tokens for the optional LLM nomination pass. */
37
39
  curatorReviewMaxTokens?: number;
38
40
  }
@@ -82,13 +84,24 @@ export declare class EvolutionCurator extends Service {
82
84
  private readonly manageUnmanaged;
83
85
  private readonly pruneBuiltins;
84
86
  private readonly referencedSkillNames;
87
+ private readonly bootGraceSeconds;
85
88
  private readonly curatorReviewMaxTokens;
86
89
  private lastRun;
87
90
  private timer;
91
+ private bootCheck;
92
+ private running;
88
93
  constructor(ctx: Context, config?: Config);
89
94
  private lifecycle;
90
95
  start(): void;
91
96
  stop(): void;
97
+ /**
98
+ * One automatic schedule check: run a pass when the persisted curator state
99
+ * (falling back to the in-memory clock for state-less compositions) is at
100
+ * least one interval old. All gates — interval, idle, first-run defer,
101
+ * reentrancy — stay inside `run()`, so this method only decides whether to
102
+ * wake it, and never duplicates gate logic.
103
+ */
104
+ private autoCheck;
92
105
  /**
93
106
  * Optional Hermes-curator LLM pass. The model may only NOMINATE pruning and
94
107
  * consolidation; every move stays a control-plane operation and each
@@ -98,17 +111,41 @@ export declare class EvolutionCurator extends Service {
98
111
  recommend(candidates: string[], options?: {
99
112
  dryRun?: boolean;
100
113
  }): Promise<CuratorNominations>;
114
+ /** Optional curator-state service (evolution-state-json / storage-domain). */
115
+ private curatorStateService;
116
+ /**
117
+ * Full-state snapshot: the skills tree plus the current curator state as an
118
+ * `extras/curator-state.json` side file. Every pre-mutation snapshot in the
119
+ * curator goes through here so a later `restoreSnapshot()` can rewind both
120
+ * the tree and the state (Hermes curator_backup backs up `.curator_state`).
121
+ */
122
+ snapshotFull(reason?: string): Promise<string>;
123
+ /**
124
+ * Full-state rollback: restore the latest snapshot's tree/sidecars/archive
125
+ * AND the curator state it carried. The pre-rollback safety snapshot keeps
126
+ * the current tree plus current state (as extras), so the rollback itself
127
+ * is reversible.
128
+ */
129
+ restoreSnapshot(): Promise<SkillActionResult & {
130
+ extras?: Array<{
131
+ name: string;
132
+ content: string;
133
+ }>;
134
+ }>;
101
135
  private skippedReport;
102
136
  /**
103
137
  * Run one curator pass. `ignoreGates` skips the interval and idle gates so an
104
138
  * explicit `/evolution curator run` always executes (manual-run semantics):
105
139
  * `dryRun` computes the lifecycle and the LLM nominations but performs no
106
140
  * mutation, reports what WOULD happen, and does not push out the next run.
141
+ * Reentrant calls (autoStart timer + manual command at the same instant) are
142
+ * skipped with an explicit `already-running` outcome.
107
143
  */
108
144
  run(options?: {
109
145
  ignoreGates?: boolean;
110
146
  dryRun?: boolean;
111
147
  }): Promise<CuratorRunOutcome>;
148
+ private runCore;
112
149
  /**
113
150
  * Seed baseline records for tree skills the sidecar has not seen yet, so
114
151
  * their inactivity clock starts now (first-sight defer) and bundled skills
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.37",
4
+ "version": "0.1.0-rc.39",
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.37"
36
+ "@lmzhen/dsh-evolution-core": "^0.1.0-rc.39"
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.37",
43
- "@lmzhen/dsh-evolution-state": "^0.1.0-rc.37"
42
+ "@lmzhen/dsh-evolution-io": "^0.1.0-rc.39",
43
+ "@lmzhen/dsh-evolution-state": "^0.1.0-rc.39"
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.37",
49
- "@lmzhen/dsh-evolution-io": "^0.1.0-rc.37",
50
- "@lmzhen/dsh-evolution-state": "^0.1.0-rc.37"
48
+ "@lmzhen/dsh-evolution-core": "^0.1.0-rc.39",
49
+ "@lmzhen/dsh-evolution-io": "^0.1.0-rc.39",
50
+ "@lmzhen/dsh-evolution-state": "^0.1.0-rc.39"
51
51
  }
52
52
  }