dsh-continual-evolve 0.15.5 → 0.15.7

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.
@@ -0,0 +1,97 @@
1
+ import type { MemoryStore } from "./store.js";
2
+ /** The minimal LLM face review calls (same duck-typed host service as extraction). */
3
+ export interface ReviewLlm {
4
+ stream(options: Record<string, unknown>): AsyncIterable<unknown>;
5
+ }
6
+ export interface ReviewDeps {
7
+ store: MemoryStore;
8
+ llm: ReviewLlm | undefined;
9
+ }
10
+ export interface ReviewCandidate {
11
+ workspaceId: string;
12
+ kind: "memory" | "skill";
13
+ id: string;
14
+ /** 0 = new, 1 = corrects-linked requeue, 2 = stale requeue. */
15
+ priority: number;
16
+ title: string;
17
+ description: string;
18
+ body: string;
19
+ /** Memory type, present only for memory candidates. */
20
+ memoryType?: string | undefined;
21
+ /** Skill scope, present only for skill candidates. */
22
+ scope?: "project" | "global" | undefined;
23
+ }
24
+ export interface ReviewRunOptions {
25
+ /** UTC day `YYYY-MM-DD` the hard cap accounts against. */
26
+ day: string;
27
+ /** Hard cap for the day (100). Candidates past it count as dropped. */
28
+ budget: number;
29
+ /** Trial mode: verdicts ledgered, nothing applied or quarantined. */
30
+ dryRun: boolean;
31
+ /** Max items per run (batching keeps one prompt small). */
32
+ batchSize?: number | undefined;
33
+ /** Days of untouched age before an item requeues (default 30). */
34
+ staleDays?: number | undefined;
35
+ provider?: string | undefined;
36
+ model?: string | undefined;
37
+ sessionId?: string | undefined;
38
+ runId?: string | undefined;
39
+ }
40
+ export interface ReviewRunSummary {
41
+ reviewed: number;
42
+ approved: number;
43
+ applied: number;
44
+ dropped: number;
45
+ backlog: number;
46
+ cheapToken: number;
47
+ evolveToken: number;
48
+ }
49
+ /** A rewrite lands only when the body-length ratio stays within this bound. */
50
+ export declare const REWRITE_MAGNITUDE_CAP = 0.3;
51
+ /** Relative body change: |new-old| / max(old, 1). Above the cap → quarantine. */
52
+ export declare function rewriteMagnitude(oldBody: string, newBody: string): number;
53
+ /**
54
+ * Collect review candidates across every workspace carrying active items:
55
+ * new items first (newest first), then corrects-linked requeues, then stale
56
+ * requeues (30 days untouched). bounded by `limit`.
57
+ */
58
+ export declare function collectCandidates(store: MemoryStore, opts?: {
59
+ now?: number | undefined;
60
+ staleDays?: number | undefined;
61
+ limit?: number | undefined;
62
+ }): ReviewCandidate[];
63
+ /**
64
+ * Run one review batch: cheap gate per item, evolve pass on approval,
65
+ * safeguards before landing, one `item-review` row per terminal path, one
66
+ * daily merge at the end. Never throws: LLM and parse failures degrade to
67
+ * skip rows (the run keeps going), exactly like extraction's fail-ledgered
68
+ * discipline.
69
+ */
70
+ export declare function runReviewBatch(deps: ReviewDeps, candidates: readonly ReviewCandidate[], opts: ReviewRunOptions): Promise<ReviewRunSummary>;
71
+ /** Current UTC day `YYYY-MM-DD` for the hard-cap account. */
72
+ export declare function todayUtc(): string;
73
+ /** The event wiring surface (duck-typed cordis context, mirrors extraction). */
74
+ export interface ReviewSchedulerHost {
75
+ on(event: string, handler: (...args: never[]) => void): unknown;
76
+ logger(name: string): {
77
+ info(message: string): void;
78
+ warn(message: string): void;
79
+ };
80
+ }
81
+ export interface ReviewSchedulerConfig {
82
+ enabled: boolean;
83
+ dryRun: boolean;
84
+ dailyBudget: number;
85
+ batchSize: number;
86
+ staleDays: number;
87
+ /** Minimum ms between two drains (default 5 minutes). */
88
+ minIntervalMs: number;
89
+ }
90
+ /**
91
+ * Wire the review triggers onto the host context. Global single-flight with
92
+ * a minimum interval: turn boundaries arm the pending workspace, compaction
93
+ * and session close drain immediately. Each drain reviews one batch bounded
94
+ * by the remaining daily budget — never more.
95
+ */
96
+ export declare function createReviewScheduler(host: ReviewSchedulerHost, deps: ReviewDeps, config: ReviewSchedulerConfig): () => void;
97
+ //# sourceMappingURL=review.d.ts.map
package/lib/review.js ADDED
@@ -0,0 +1,458 @@
1
+ /**
2
+ * The autonomous content-quality loop (the model's verdicts, code's
3
+ * consequences): cheap gate first, evolve pass only on approval, every
4
+ * terminal path ledgered as one `item-review` row, one day-rollup merge per
5
+ * run. No human in the loop — delete never lands automatically (verdicts
6
+ * only know keep/rewrite/quarantine) and oversized rewrites degrade to
7
+ * quarantine instead of landing.
8
+ *
9
+ * This track is independent from the extraction pipeline by construction:
10
+ * own prompts (`review-prompt.ts`), own budget (`review_daily`), own ledger
11
+ * (`review_log`), no cursor, no increment. It only shares the store's write
12
+ * gates (`applyProposals` / `applySkillProposals` / `quarantine`).
13
+ */
14
+ import { normalizeUsage, zeroUsage } from "./token-usage.js";
15
+ import { evolvePrompt, gatePrompt, parseEvolveAnswer, parseGateAnswer, REVIEW_CHEAP_SYSTEM_PROMPT, REVIEW_MEMORY_SYSTEM_PROMPT, REVIEW_SKILL_SYSTEM_PROMPT, } from "./review-prompt.js";
16
+ import { cwdOf } from "./memory-section.js";
17
+ /** A rewrite lands only when the body-length ratio stays within this bound. */
18
+ export const REWRITE_MAGNITUDE_CAP = 0.3;
19
+ /** Relative body change: |new-old| / max(old, 1). Above the cap → quarantine. */
20
+ export function rewriteMagnitude(oldBody, newBody) {
21
+ return Math.abs(newBody.length - oldBody.length) / Math.max(oldBody.length, 1);
22
+ }
23
+ /**
24
+ * Collect review candidates across every workspace carrying active items:
25
+ * new items first (newest first), then corrects-linked requeues, then stale
26
+ * requeues (30 days untouched). bounded by `limit`.
27
+ */
28
+ export function collectCandidates(store, opts = {}) {
29
+ const now = opts.now ?? Date.now();
30
+ const staleMs = (opts.staleDays ?? 30) * 24 * 60 * 60 * 1000;
31
+ const limit = opts.limit ?? 100;
32
+ const fresh = [];
33
+ const requeued = [];
34
+ const stale = [];
35
+ // One push per enumerated row: store primary keys make (workspace, kind,
36
+ // id) unique, so no dedup set is needed — a duplicate here would mean a
37
+ // store invariant broke, and failing loud beats silently merging.
38
+ const push = (candidate) => {
39
+ (candidate.priority === 0 ? fresh : candidate.priority === 1 ? requeued : stale).push(candidate);
40
+ };
41
+ const partitions = new Set();
42
+ for (const summary of store.listWorkspaces()) {
43
+ partitions.add(summary.workspaceId);
44
+ }
45
+ for (const summary of store.listSkillWorkspaces()) {
46
+ partitions.add(summary.workspaceId);
47
+ }
48
+ partitions.add("");
49
+ for (const workspaceId of partitions) {
50
+ const correctsTargets = new Set();
51
+ const memories = store.listAll(workspaceId);
52
+ for (const record of memories) {
53
+ if (record.corrects !== null && record.corrects.length > 0) {
54
+ correctsTargets.add(record.corrects);
55
+ }
56
+ }
57
+ for (const record of memories) {
58
+ if (record.status !== "active") {
59
+ continue;
60
+ }
61
+ const state = store.reviewStateOf(workspaceId, "memory", record.id);
62
+ const updatedAt = Date.parse(record.updatedAt) || 0;
63
+ if (state === undefined) {
64
+ push({ workspaceId, kind: "memory", id: record.id, priority: 0, title: record.title, description: record.description, body: record.body, memoryType: record.type });
65
+ }
66
+ else if (correctsTargets.has(record.id)) {
67
+ push({ workspaceId, kind: "memory", id: record.id, priority: 1, title: record.title, description: record.description, body: record.body, memoryType: record.type });
68
+ }
69
+ else if (now - updatedAt >= staleMs) {
70
+ push({ workspaceId, kind: "memory", id: record.id, priority: 2, title: record.title, description: record.description, body: record.body, memoryType: record.type });
71
+ }
72
+ }
73
+ const projectSeen = new Set();
74
+ const skillsOf = (partition, scope) => {
75
+ for (const record of store.listAllSkills(partition)) {
76
+ if (record.status !== "active" || projectSeen.has(record.id)) {
77
+ continue;
78
+ }
79
+ projectSeen.add(record.id);
80
+ const state = store.reviewStateOf(partition, "skill", record.id);
81
+ const updatedAt = Date.parse(record.updatedAt) || 0;
82
+ const candidate = {
83
+ workspaceId: partition, kind: "skill", id: record.id,
84
+ priority: state === undefined ? 0 : 2,
85
+ title: record.title, description: record.description, body: record.body, scope,
86
+ };
87
+ if (state === undefined || now - updatedAt >= staleMs) {
88
+ push(candidate);
89
+ }
90
+ }
91
+ };
92
+ if (workspaceId !== "") {
93
+ skillsOf(workspaceId, "project");
94
+ }
95
+ }
96
+ // Global skills ride along once (project rows shadow them per id).
97
+ skillsGlobal(store, now, staleMs, push);
98
+ const byPriority = (a, b) => (a.priority - b.priority);
99
+ return [...fresh, ...requeued, ...stale].sort(byPriority).slice(0, limit);
100
+ }
101
+ function skillsGlobal(store, now, staleMs, push) {
102
+ const projectIds = new Set();
103
+ for (const summary of store.listSkillWorkspaces()) {
104
+ if (summary.workspaceId === "") {
105
+ continue;
106
+ }
107
+ for (const record of store.listAllSkills(summary.workspaceId)) {
108
+ // Archived rows shadow nothing (readWithScope shows the global
109
+ // row once the project row archives after a move-to-global).
110
+ if (record.status === "active") {
111
+ projectIds.add(record.id);
112
+ }
113
+ }
114
+ }
115
+ for (const record of store.listAllSkills("")) {
116
+ if (record.status !== "active" || projectIds.has(record.id)) {
117
+ continue;
118
+ }
119
+ const state = store.reviewStateOf("", "skill", record.id);
120
+ const updatedAt = Date.parse(record.updatedAt) || 0;
121
+ if (state === undefined || now - updatedAt >= staleMs) {
122
+ push({
123
+ workspaceId: "", kind: "skill", id: record.id,
124
+ priority: state === undefined ? 0 : 2,
125
+ title: record.title, description: record.description, body: record.body, scope: "global",
126
+ });
127
+ }
128
+ }
129
+ }
130
+ /**
131
+ * Run one review batch: cheap gate per item, evolve pass on approval,
132
+ * safeguards before landing, one `item-review` row per terminal path, one
133
+ * daily merge at the end. Never throws: LLM and parse failures degrade to
134
+ * skip rows (the run keeps going), exactly like extraction's fail-ledgered
135
+ * discipline.
136
+ */
137
+ export async function runReviewBatch(deps, candidates, opts) {
138
+ const summary = { reviewed: 0, approved: 0, applied: 0, dropped: 0, backlog: 0, cheapToken: 0, evolveToken: 0 };
139
+ const skipReasons = [];
140
+ const batch = candidates.slice(0, Math.max(1, opts.batchSize ?? 10));
141
+ const runId = opts.runId ?? `review:${opts.day}:${Date.now()}`;
142
+ let newItems = 0;
143
+ let requeued = 0;
144
+ const noRoute = deps.llm === undefined || opts.provider === undefined || opts.model === undefined;
145
+ let position = 0;
146
+ for (const candidate of batch) {
147
+ const remaining = deps.store.consumeReviewBudget(opts.day, opts.budget);
148
+ if (remaining < 0) {
149
+ // Hard cap hit: this item and everything after it drops (low
150
+ // priority never rolls over — it simply waits for a future run).
151
+ summary.dropped += batch.length - position;
152
+ for (const item of batch.slice(position)) {
153
+ deps.store.logReview({
154
+ runId, workspaceId: item.workspaceId, itemKind: item.kind, itemId: item.id,
155
+ verdict: "skip", reason: "budget-exhausted", applied: 0,
156
+ });
157
+ }
158
+ skipReasons.push("budget-exhausted");
159
+ break;
160
+ }
161
+ summary.reviewed += 1;
162
+ if (candidate.priority === 0) {
163
+ newItems += 1;
164
+ }
165
+ else {
166
+ requeued += 1;
167
+ }
168
+ if (noRoute) {
169
+ deps.store.logReview({
170
+ runId, workspaceId: candidate.workspaceId, itemKind: candidate.kind, itemId: candidate.id,
171
+ verdict: "skip", reason: "no-route", applied: 0,
172
+ });
173
+ skipReasons.push("no-route");
174
+ position += 1;
175
+ continue;
176
+ }
177
+ await reviewOne(deps, runId, candidate, opts, summary, skipReasons);
178
+ position += 1;
179
+ }
180
+ summary.backlog = Math.max(0, candidates.length - summary.reviewed - summary.dropped);
181
+ deps.store.mergeDaily(opts.day, opts.budget, {
182
+ newItems, requeued, approved: summary.approved, applied: summary.applied,
183
+ cheapToken: summary.cheapToken, evolveToken: summary.evolveToken,
184
+ backlog: summary.backlog, dropped: summary.dropped,
185
+ skipReasons: skipReasons.length > 0 ? skipReasons : undefined,
186
+ });
187
+ return summary;
188
+ }
189
+ async function reviewOne(deps, runId, candidate, opts, summary, skipReasons) {
190
+ const base = {
191
+ runId, workspaceId: candidate.workspaceId, itemKind: candidate.kind, itemId: candidate.id,
192
+ };
193
+ // ── Cheap gate ──────────────────────────────────────────────
194
+ let gateUsage = zeroUsage();
195
+ let gateRaw;
196
+ try {
197
+ const answer = await streamOnce(deps.llm, opts, REVIEW_CHEAP_SYSTEM_PROMPT, gatePrompt(candidate), 512);
198
+ gateRaw = answer.usage;
199
+ const gate = parseGateAnswer(answer.text);
200
+ if (gate === null) {
201
+ deps.store.logReview({ ...base, verdict: "skip", reason: "gate-parse-failed", cheapUsage: gateRaw, applied: 0 });
202
+ skipReasons.push("gate-parse-failed");
203
+ return;
204
+ }
205
+ if (!gate.worthReview) {
206
+ summary.cheapToken += totalOf(gateRaw);
207
+ deps.store.logReview({ ...base, verdict: "skip", reason: gate.reason, cheapUsage: gateRaw, applied: 0 });
208
+ skipReasons.push("cheap-gate");
209
+ return;
210
+ }
211
+ summary.cheapToken += totalOf(gateRaw);
212
+ gateUsage = normalizedOf(gateRaw);
213
+ }
214
+ catch {
215
+ deps.store.logReview({ ...base, verdict: "skip", reason: "gate-llm-failed", applied: 0 });
216
+ skipReasons.push("gate-llm-failed");
217
+ return;
218
+ }
219
+ summary.approved += 1;
220
+ // ── Evolve pass ─────────────────────────────────────────────
221
+ const neighbors = neighborHooks(deps.store, candidate);
222
+ const extra = candidate.kind === "memory" && candidate.memoryType === "feedback"
223
+ ? "注意:feedback 类正文必须带 Why 与 How to apply 两行。"
224
+ : undefined;
225
+ let evolveRaw;
226
+ try {
227
+ const system = candidate.kind === "memory" ? REVIEW_MEMORY_SYSTEM_PROMPT : REVIEW_SKILL_SYSTEM_PROMPT;
228
+ const answer = await streamOnce(deps.llm, opts, system, evolvePrompt({ id: candidate.id, title: candidate.title, description: candidate.description, body: candidate.body, extra }, neighbors), 4096);
229
+ evolveRaw = answer.usage;
230
+ summary.evolveToken += totalOf(evolveRaw);
231
+ const evolved = parseEvolveAnswer(answer.text);
232
+ if (evolved === null) {
233
+ deps.store.logReview({ ...base, verdict: "skip", reason: "evolve-parse-failed", cheapUsage: gateUsage, evolveUsage: evolveRaw, applied: 0 });
234
+ skipReasons.push("evolve-parse-failed");
235
+ return;
236
+ }
237
+ if (evolved.verdict === "keep") {
238
+ deps.store.logReview({ ...base, verdict: "keep", reason: evolved.reason, cheapUsage: gateUsage, evolveUsage: evolveRaw, applied: 0 });
239
+ return;
240
+ }
241
+ if (evolved.verdict === "quarantine") {
242
+ landQuarantine(deps, base, candidate, opts, evolved.reason, gateUsage, evolveRaw, summary);
243
+ return;
244
+ }
245
+ // ── Rewrite: magnitude cap first, dry-run second, gates last ──
246
+ const nextBody = evolved.body ?? candidate.body;
247
+ if (rewriteMagnitude(candidate.body, nextBody) > REWRITE_MAGNITUDE_CAP) {
248
+ landQuarantine(deps, base, candidate, opts, `幅度超限转隔离:${evolved.reason}`, gateUsage, evolveRaw, summary);
249
+ return;
250
+ }
251
+ if (opts.dryRun) {
252
+ deps.store.logReview({ ...base, verdict: "rewrite", reason: `试运行未落盘:${evolved.reason}`, cheapUsage: gateUsage, evolveUsage: evolveRaw, applied: 0 });
253
+ return;
254
+ }
255
+ landRewrite(deps, base, candidate, opts, evolved, gateUsage, evolveRaw, summary);
256
+ }
257
+ catch {
258
+ deps.store.logReview({ ...base, verdict: "skip", reason: "evolve-llm-failed", cheapUsage: gateUsage, evolveUsage: evolveRaw, applied: 0 });
259
+ skipReasons.push("evolve-llm-failed");
260
+ }
261
+ }
262
+ function neighborHooks(store, candidate) {
263
+ try {
264
+ if (candidate.kind === "memory") {
265
+ const query = candidate.title.slice(0, 60);
266
+ const hits = query.length >= 3 ? store.search(candidate.workspaceId, query, 5) : store.list(candidate.workspaceId).slice(0, 5);
267
+ return hits.filter((hit) => hit.id !== candidate.id).slice(0, 4).map((hit) => ({ id: hit.id, title: hit.title, description: hit.description }));
268
+ }
269
+ const hits = store.searchSkills(candidate.workspaceId, candidate.title.slice(0, 60), 5);
270
+ return hits.filter((hit) => hit.id !== candidate.id).slice(0, 4).map((hit) => ({ id: hit.id, title: hit.title, description: hit.description }));
271
+ }
272
+ catch {
273
+ return [];
274
+ }
275
+ }
276
+ function landQuarantine(deps, base, candidate, opts, reason, gateUsage, evolveRaw, summary) {
277
+ const prior = deps.store.reviewStateOf(candidate.workspaceId, candidate.kind, candidate.id)?.evolveCount ?? 0;
278
+ if (opts.dryRun) {
279
+ deps.store.logReview({ ...base, verdict: "quarantine", reason: `试运行未隔离:${reason}`, cheapUsage: gateUsage, evolveUsage: evolveRaw, applied: 0, evolveCount: prior });
280
+ return;
281
+ }
282
+ const ok = deps.store.quarantine(candidate.workspaceId, candidate.kind, candidate.id);
283
+ deps.store.logReview({
284
+ ...base, verdict: "quarantine", reason, cheapUsage: gateUsage, evolveUsage: evolveRaw,
285
+ applied: ok ? 1 : 0, evolveCount: ok ? prior + 1 : prior,
286
+ });
287
+ if (ok) {
288
+ summary.applied += 1;
289
+ }
290
+ }
291
+ function landRewrite(deps, base, candidate, opts, evolved, gateUsage, evolveRaw, summary) {
292
+ const prior = deps.store.reviewStateOf(candidate.workspaceId, candidate.kind, candidate.id)?.evolveCount ?? 0;
293
+ const ctx = { workspaceId: candidate.workspaceId, trigger: "item-review", sessionId: opts.sessionId, runId: base.runId, model: opts.model, decision: evolved.reason };
294
+ if (candidate.kind === "memory") {
295
+ const [outcome] = deps.store.applyProposals(ctx, [{
296
+ action: "update", id: candidate.id,
297
+ title: evolved.title, description: evolved.description, body: evolved.body,
298
+ corrects: evolved.corrects,
299
+ }]);
300
+ const ok = outcome !== undefined && outcome.ok;
301
+ deps.store.logReview({
302
+ ...base, verdict: "rewrite", reason: ok ? evolved.reason : `门禁拒绝:${outcome?.reason ?? "未知原因"}`,
303
+ cheapUsage: gateUsage, evolveUsage: evolveRaw, applied: ok ? 1 : 0, evolveCount: ok ? prior + 1 : prior,
304
+ });
305
+ if (ok) {
306
+ summary.applied += 1;
307
+ }
308
+ return;
309
+ }
310
+ const { outcomes } = deps.store.applySkillProposals(ctx, [{
311
+ action: "update", scope: candidate.scope ?? "project", id: candidate.id,
312
+ title: evolved.title, description: evolved.description, body: evolved.body,
313
+ }]);
314
+ const outcome = outcomes[0];
315
+ const ok = outcome !== undefined && outcome.ok;
316
+ deps.store.logReview({
317
+ ...base, verdict: "rewrite", reason: ok ? evolved.reason : `门禁拒绝:${outcome?.reason ?? "未知原因"}`,
318
+ cheapUsage: gateUsage, evolveUsage: evolveRaw, applied: ok ? 1 : 0, evolveCount: ok ? prior + 1 : prior,
319
+ });
320
+ if (ok) {
321
+ summary.applied += 1;
322
+ }
323
+ }
324
+ function totalOf(raw) {
325
+ return normalizeUsage(raw)?.total ?? 0;
326
+ }
327
+ function normalizedOf(raw) {
328
+ return normalizeUsage(raw) ?? zeroUsage();
329
+ }
330
+ /** One streaming text call with the host session identity forwarded. */
331
+ async function streamOnce(llm, opts, system, prompt, maxTokens) {
332
+ if (llm === undefined) {
333
+ throw new Error("no llm route");
334
+ }
335
+ let text = "";
336
+ let usage;
337
+ let finish;
338
+ for await (const chunk of llm.stream({
339
+ provider: opts.provider,
340
+ model: opts.model,
341
+ messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
342
+ system,
343
+ maxTokens,
344
+ sessionId: opts.sessionId,
345
+ })) {
346
+ const piece = chunk;
347
+ if (piece?.type === "text-delta" && typeof piece.text === "string") {
348
+ text += piece.text;
349
+ }
350
+ else if (piece?.type === "usage") {
351
+ usage = piece.usage;
352
+ }
353
+ else if (piece?.type === "finish") {
354
+ finish = piece.reason;
355
+ }
356
+ }
357
+ if (finish !== undefined && finish.kind !== "stop") {
358
+ throw new Error(`stream finished with ${finish.kind ?? "unknown"}`);
359
+ }
360
+ if (text.trim().length === 0) {
361
+ throw new Error("empty model output");
362
+ }
363
+ return { text, usage };
364
+ }
365
+ /** Current UTC day `YYYY-MM-DD` for the hard-cap account. */
366
+ export function todayUtc() {
367
+ return new Date().toISOString().slice(0, 10);
368
+ }
369
+ /**
370
+ * Wire the review triggers onto the host context. Global single-flight with
371
+ * a minimum interval: turn boundaries arm the pending workspace, compaction
372
+ * and session close drain immediately. Each drain reviews one batch bounded
373
+ * by the remaining daily budget — never more.
374
+ */
375
+ export function createReviewScheduler(host, deps, config) {
376
+ const listeners = [];
377
+ let running = false;
378
+ let lastRunAt = 0;
379
+ let pendingWorkspace;
380
+ let lastRoute;
381
+ const drain = (triggerWorkspace) => {
382
+ if (!config.enabled || running) {
383
+ if (triggerWorkspace !== undefined) {
384
+ pendingWorkspace = triggerWorkspace;
385
+ }
386
+ return;
387
+ }
388
+ if (Date.now() - lastRunAt < config.minIntervalMs) {
389
+ if (triggerWorkspace !== undefined) {
390
+ pendingWorkspace = triggerWorkspace;
391
+ }
392
+ return;
393
+ }
394
+ if (deps.llm === undefined || lastRoute?.provider === undefined || lastRoute?.model === undefined) {
395
+ return;
396
+ }
397
+ running = true;
398
+ lastRunAt = Date.now();
399
+ const workspace = triggerWorkspace ?? pendingWorkspace;
400
+ pendingWorkspace = undefined;
401
+ void (async () => {
402
+ try {
403
+ const day = todayUtc();
404
+ const daily = deps.store.getDaily(day);
405
+ const remaining = config.dailyBudget - (daily?.reviewed ?? 0);
406
+ if (remaining <= 0) {
407
+ return;
408
+ }
409
+ const candidates = collectCandidates(deps.store, { staleDays: config.staleDays, limit: config.batchSize * 2 });
410
+ const ordered = workspace === undefined
411
+ ? candidates
412
+ : [...candidates.filter((c) => c.workspaceId === workspace), ...candidates.filter((c) => c.workspaceId !== workspace)];
413
+ await runReviewBatch(deps, ordered.slice(0, config.batchSize), {
414
+ day, budget: config.dailyBudget, dryRun: config.dryRun,
415
+ batchSize: config.batchSize, staleDays: config.staleDays,
416
+ provider: lastRoute?.provider, model: lastRoute?.model, sessionId: lastRoute?.sessionId,
417
+ });
418
+ }
419
+ catch (error) {
420
+ host.logger("continual-evolve").warn(`review drain failed: ${error instanceof Error ? error.message : String(error)}`);
421
+ }
422
+ finally {
423
+ running = false;
424
+ }
425
+ })();
426
+ };
427
+ listeners.push(host.on("agent/turn-stopping", (payload) => {
428
+ const agent = payload.agent;
429
+ const id = agent?.id;
430
+ if (typeof id !== "string" || id.length === 0) {
431
+ return;
432
+ }
433
+ const provider = typeof agent?.options?.provider === "string" ? agent.options.provider : undefined;
434
+ const model = typeof agent?.options?.model === "string" ? agent.options.model : undefined;
435
+ lastRoute = { provider, model, sessionId: id };
436
+ drain(cwdOf(payload.agent));
437
+ }));
438
+ listeners.push(host.on("session/event", (session, event) => {
439
+ if (event?.type !== "compaction/start" || typeof session?.id !== "string") {
440
+ return;
441
+ }
442
+ drain(pendingWorkspace);
443
+ }));
444
+ listeners.push(host.on("agent/disposed", () => {
445
+ drain(pendingWorkspace);
446
+ pendingWorkspace = undefined;
447
+ }));
448
+ host.logger("continual-evolve").info(`review scheduler armed (daily cap ${config.dailyBudget}, dry-run ${config.dryRun ? "on" : "off"})`);
449
+ return () => {
450
+ pendingWorkspace = undefined;
451
+ for (const listener of listeners) {
452
+ if (typeof listener === "function") {
453
+ listener();
454
+ }
455
+ }
456
+ };
457
+ }
458
+ //# sourceMappingURL=review.js.map
@@ -64,6 +64,12 @@ export declare function validateRenderedSkill(skill: {
64
64
  export declare function projectSkillsDir(workspaceId: string): string;
65
65
  /** Where global skills materialize: the host-wide discovered skill directory. */
66
66
  export declare function globalSkillsDir(): string;
67
+ /**
68
+ * The store partition a skill scope reads and writes usage in: the global
69
+ * partition for global rows, the workspace itself otherwise. One function
70
+ * so the tool, card, and snapshot paths cannot drift apart.
71
+ */
72
+ export declare function skillUsagePartition(scope: string, cwd: string): string;
67
73
  /**
68
74
  * Resolve and defend the materialized directory for a skill id. Ids are
69
75
  * kebab slugs by gate, but the containment check stays: a caller bug must
@@ -142,6 +142,14 @@ export function projectSkillsDir(workspaceId) {
142
142
  export function globalSkillsDir() {
143
143
  return join(dshHome(), "skills");
144
144
  }
145
+ /**
146
+ * The store partition a skill scope reads and writes usage in: the global
147
+ * partition for global rows, the workspace itself otherwise. One function
148
+ * so the tool, card, and snapshot paths cannot drift apart.
149
+ */
150
+ export function skillUsagePartition(scope, cwd) {
151
+ return scope === "global" ? SKILL_GLOBAL_WORKSPACE : cwd;
152
+ }
145
153
  /**
146
154
  * Resolve and defend the materialized directory for a skill id. Ids are
147
155
  * kebab slugs by gate, but the containment check stays: a caller bug must
@@ -12,6 +12,7 @@
12
12
  * the plugin imports nothing from DSH at runtime.
13
13
  */
14
14
  import { cwdOf } from "./memory-section.js";
15
+ import { skillUsagePartition } from "./skill-rules.js";
15
16
  function textTool(name, description, parameters, execute) {
16
17
  return {
17
18
  name,
@@ -113,7 +114,14 @@ export function registerSkillTools(tools, store) {
113
114
  const limit = clampLimit(args.limit);
114
115
  if (typeof args.id === "string" && args.id.length > 0) {
115
116
  const found = readWithScope(store, cwd, args.id);
116
- return found === undefined ? `没有找到 id 为 "${args.id}" 的技能。` : formatSkill(found.record, found.scope);
117
+ if (found === undefined) {
118
+ return `没有找到 id 为 "${args.id}" 的技能。`;
119
+ }
120
+ // Consulted set: a body served by id means the model opened
121
+ // it for the task at hand. Search/list hits are noise
122
+ // and stay uncounted; human card previews never count.
123
+ store.recordSkillUse(skillUsagePartition(found.scope, cwd), found.record.id, found.scope);
124
+ return formatSkill(found.record, found.scope);
117
125
  }
118
126
  const query = typeof args.query === "string" ? args.query : "";
119
127
  const merged = query.length > 0 ? searchWithScope(store, cwd, query, limit) : listWithScope(store, cwd, limit);