dsh-continual-evolve 0.15.11 → 0.15.13

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,257 @@
1
+ import { observedUsageOf } from "./llm-call.js";
2
+ import { evolvePrompt, gatePrompt } from "./review-prompt.js";
3
+ import { REVIEW_EVOLVE_BY_KIND, REVIEW_GATE_BY_KIND } from "./subagent-defs.js";
4
+ import { cutFailureOf } from "./subagent.js";
5
+ import { normalizeUsage, zeroUsage } from "./token-usage.js";
6
+ /** A rewrite lands only when the body-length ratio stays within this bound. */
7
+ export const REWRITE_MAGNITUDE_CAP = 0.3;
8
+ /**
9
+ * Assemble the review chain: the environment class owns the order, the links
10
+ * own their step, and the item carries the run's loader and route.
11
+ *
12
+ * @returns The chain, in order, ending in the landing link.
13
+ */
14
+ export function reviewChain() {
15
+ return [gateLink(), evolveLink(), landLink()];
16
+ }
17
+ /** Cheap gate: decide whether the item deserves the expensive pass at all. */
18
+ function gateLink() {
19
+ return {
20
+ name: "review-gate",
21
+ async handle(item) {
22
+ const { deps, candidate } = item;
23
+ const base = rowBaseOf(item);
24
+ try {
25
+ const answer = await item.loader.run(REVIEW_GATE_BY_KIND[candidate.kind], gatePrompt(candidate));
26
+ const verdict = answer.answer;
27
+ if (verdict === null) {
28
+ // A cut gate is a failure only when no complete verdict came
29
+ // through: the JSON usually lands before the budget runs out, so
30
+ // parse first and blame the finish reason only if it did not.
31
+ if (answer.finish !== undefined && answer.finish !== "stop") {
32
+ throw cutFailureOf(answer, item.route);
33
+ }
34
+ deps.store.logReview({ ...base, verdict: "skip", reason: "gate-parse-failed", cheapUsage: answer.usage, applied: 0 });
35
+ item.skipReasons.push("gate-parse-failed");
36
+ return "handled";
37
+ }
38
+ if (!verdict.worthReview) {
39
+ item.summary.cheapToken += totalOf(answer.usage);
40
+ deps.store.logReview({ ...base, verdict: "skip", reason: verdict.reason, cheapUsage: answer.usage, applied: 0 });
41
+ item.skipReasons.push("cheap-gate");
42
+ return "handled";
43
+ }
44
+ item.summary.cheapToken += totalOf(answer.usage);
45
+ item.gate = { usage: normalizedOf(answer.usage), raw: answer.usage };
46
+ // Approved counts the gate's decision, not the outcome: the
47
+ // evolve leg may still fail and ledger its own skip row.
48
+ item.summary.approved += 1;
49
+ return "next";
50
+ }
51
+ catch (error) {
52
+ // The error text rides the row: a bare `gate-llm-failed` hid a ~50%
53
+ // per-item failure rate in production (2026-10-12) with no way to
54
+ // tell transient route faults from systematic ones — and the usage
55
+ // the call already reported rides it too, or a failed call burns
56
+ // tokens that no account shows.
57
+ deps.store.logReview({
58
+ ...base, verdict: "skip", reason: `gate-llm-failed: ${failureHead(error)}`,
59
+ cheapUsage: observedUsageOf(error), applied: 0,
60
+ });
61
+ item.skipReasons.push("gate-llm-failed");
62
+ return "handled";
63
+ }
64
+ },
65
+ };
66
+ }
67
+ /** The evolve pass: judge the item and produce a verdict, or ledger why not. */
68
+ function evolveLink() {
69
+ return {
70
+ name: "review-evolve",
71
+ async handle(item) {
72
+ const { deps, candidate } = item;
73
+ const base = rowBaseOf(item);
74
+ try {
75
+ const definition = REVIEW_EVOLVE_BY_KIND[candidate.kind];
76
+ const neighbors = neighborHooks(deps.store, candidate);
77
+ const answer = await item.loader.run(definition, evolvePrompt({ id: candidate.id, title: candidate.title, description: candidate.description, body: candidate.body, scope: candidate.scope }, neighbors));
78
+ item.evolveUsage = answer.usage;
79
+ item.summary.evolveToken += totalOf(answer.usage);
80
+ // The evolve pass carries full replacement text: a cut stream may hold
81
+ // half a body, so it never becomes a verdict.
82
+ if (answer.finish !== undefined && answer.finish !== "stop") {
83
+ throw cutFailureOf(answer, item.route);
84
+ }
85
+ if (answer.answer === null) {
86
+ deps.store.logReview({
87
+ ...base, verdict: "skip", reason: "evolve-parse-failed",
88
+ cheapUsage: item.gate.usage, evolveUsage: item.evolveUsage, applied: 0,
89
+ });
90
+ item.skipReasons.push("evolve-parse-failed");
91
+ return "handled";
92
+ }
93
+ item.evolved = answer.answer;
94
+ return "next";
95
+ }
96
+ catch (error) {
97
+ deps.store.logReview({
98
+ ...base, verdict: "skip", reason: `evolve-llm-failed: ${failureHead(error)}`,
99
+ cheapUsage: item.gate.usage, evolveUsage: observedUsageOf(error) ?? item.evolveUsage, applied: 0,
100
+ });
101
+ item.skipReasons.push("evolve-llm-failed");
102
+ return "handled";
103
+ }
104
+ },
105
+ };
106
+ }
107
+ /** Landing: turn one verdict into a row, and (outside dry-run) into a change. */
108
+ function landLink() {
109
+ return {
110
+ name: "review-land",
111
+ handle(item) {
112
+ const evolved = item.evolved;
113
+ if (evolved === undefined) {
114
+ // A landing link reached without a verdict is a wiring bug: fail
115
+ // loud instead of writing a row with nothing behind it.
116
+ throw new Error(`review landing reached without a verdict for ${item.candidate.kind} ${item.candidate.id}`);
117
+ }
118
+ if (evolved.verdict === "keep") {
119
+ item.deps.store.logReview({
120
+ ...rowBaseOf(item), verdict: "keep", reason: evolved.reason,
121
+ cheapUsage: item.gate.usage, evolveUsage: item.evolveUsage, applied: 0,
122
+ });
123
+ return "handled";
124
+ }
125
+ if (evolved.verdict === "quarantine") {
126
+ landQuarantine(item, evolved, evolved.reason);
127
+ return "handled";
128
+ }
129
+ // Rewrite: magnitude cap first, dry-run second, gates last.
130
+ const nextBody = evolved.body ?? item.candidate.body;
131
+ if (rewriteMagnitude(item.candidate.body, nextBody) > REWRITE_MAGNITUDE_CAP) {
132
+ landQuarantine(item, evolved, `幅度超限转隔离:${evolved.reason}`);
133
+ return "handled";
134
+ }
135
+ if (item.opts.dryRun) {
136
+ item.deps.store.logReview({
137
+ ...rowBaseOf(item), verdict: "rewrite", reason: `试运行未落盘:${evolved.reason}`,
138
+ proposed: proposedOf(evolved), cheapUsage: item.gate.usage, evolveUsage: item.evolveUsage, applied: 0,
139
+ });
140
+ return "handled";
141
+ }
142
+ landRewrite(item, evolved);
143
+ return "handled";
144
+ },
145
+ };
146
+ }
147
+ /** Quarantine (or, in trial mode, the row it would have written). */
148
+ function landQuarantine(item, evolved, reason) {
149
+ const { deps, candidate, opts, summary } = item;
150
+ const prior = evolveCountOf(deps.store, candidate);
151
+ if (opts.dryRun) {
152
+ deps.store.logReview({
153
+ ...rowBaseOf(item), verdict: "quarantine", reason: `试运行未隔离:${reason}`, proposed: proposedOf(evolved),
154
+ cheapUsage: item.gate.usage, evolveUsage: item.evolveUsage, applied: 0, evolveCount: prior,
155
+ });
156
+ return;
157
+ }
158
+ const ok = deps.store.quarantine(candidate.workspaceId, candidate.kind, candidate.id);
159
+ deps.store.logReview({
160
+ ...rowBaseOf(item), verdict: "quarantine", reason,
161
+ cheapUsage: item.gate.usage, evolveUsage: item.evolveUsage,
162
+ applied: ok ? 1 : 0, evolveCount: ok ? prior + 1 : prior,
163
+ });
164
+ if (ok) {
165
+ summary.applied += 1;
166
+ }
167
+ }
168
+ /** Apply one rewrite through the store's write gates. */
169
+ function landRewrite(item, evolved) {
170
+ const { deps, candidate, opts, summary } = item;
171
+ const prior = evolveCountOf(deps.store, candidate);
172
+ const ctx = {
173
+ workspaceId: candidate.workspaceId, trigger: "item-review",
174
+ sessionId: opts.sessionId, runId: item.runId, model: opts.model, decision: evolved.reason,
175
+ };
176
+ const outcome = candidate.kind === "memory"
177
+ ? deps.store.applyProposals(ctx, [{
178
+ action: "update", id: candidate.id,
179
+ title: evolved.title, description: evolved.description, body: evolved.body, corrects: evolved.corrects,
180
+ }])[0]
181
+ : deps.store.applySkillProposals(ctx, [{
182
+ action: "update", scope: candidate.scope ?? "project", id: candidate.id,
183
+ title: evolved.title, description: evolved.description, body: evolved.body,
184
+ }]).outcomes[0];
185
+ const ok = outcome !== undefined && outcome.ok;
186
+ deps.store.logReview({
187
+ ...rowBaseOf(item), verdict: "rewrite", reason: ok ? evolved.reason : `门禁拒绝:${outcome?.reason ?? "未知原因"}`,
188
+ cheapUsage: item.gate.usage, evolveUsage: item.evolveUsage, applied: ok ? 1 : 0, evolveCount: ok ? prior + 1 : prior,
189
+ });
190
+ if (ok) {
191
+ summary.applied += 1;
192
+ }
193
+ }
194
+ function rowBaseOf(item) {
195
+ return {
196
+ runId: item.runId,
197
+ workspaceId: item.candidate.workspaceId,
198
+ itemKind: item.candidate.kind,
199
+ itemId: item.candidate.id,
200
+ model: item.route.model,
201
+ };
202
+ }
203
+ function evolveCountOf(store, candidate) {
204
+ return store.reviewStateOf(candidate.workspaceId, candidate.kind, candidate.id)?.evolveCount ?? 0;
205
+ }
206
+ /**
207
+ * The rewrite payload a non-landing row keeps: exactly the fields the model
208
+ * supplied, or `undefined` when it proposed no text at all.
209
+ */
210
+ function proposedOf(evolved) {
211
+ const payload = {};
212
+ if (evolved.title !== undefined) {
213
+ payload.title = evolved.title;
214
+ }
215
+ if (evolved.description !== undefined) {
216
+ payload.description = evolved.description;
217
+ }
218
+ if (evolved.body !== undefined) {
219
+ payload.body = evolved.body;
220
+ }
221
+ if (evolved.corrects !== undefined) {
222
+ payload.corrects = evolved.corrects;
223
+ }
224
+ return Object.keys(payload).length === 0 ? undefined : payload;
225
+ }
226
+ /** Similar existing items (hooks only) for the model's duplication check. */
227
+ function neighborHooks(store, candidate) {
228
+ try {
229
+ if (candidate.kind === "memory") {
230
+ const query = candidate.title.slice(0, 60);
231
+ const hits = query.length >= 3 ? store.search(candidate.workspaceId, query, 5) : store.list(candidate.workspaceId).slice(0, 5);
232
+ return hits.filter((hit) => hit.id !== candidate.id).slice(0, 4).map((hit) => ({ id: hit.id, title: hit.title, description: hit.description }));
233
+ }
234
+ const hits = store.searchSkills(candidate.workspaceId, candidate.title.slice(0, 60), 5);
235
+ return hits.filter((hit) => hit.id !== candidate.id).slice(0, 4).map((hit) => ({ id: hit.id, title: hit.title, description: hit.description }));
236
+ }
237
+ catch {
238
+ return [];
239
+ }
240
+ }
241
+ function totalOf(raw) {
242
+ return normalizeUsage(raw)?.total ?? 0;
243
+ }
244
+ /** One-line error head for ledger rows (fail-loud shape, capped length). */
245
+ export function failureHead(error, max = 120) {
246
+ const message = error instanceof Error ? error.message : String(error);
247
+ const head = message.split("\n", 1)[0] ?? "";
248
+ return head.length > max ? `${head.slice(0, max)}…` : head;
249
+ }
250
+ /** Relative body change: |new-old| / max(old, 1). Above the cap → quarantine. */
251
+ export function rewriteMagnitude(oldBody, newBody) {
252
+ return Math.abs(newBody.length - oldBody.length) / Math.max(oldBody.length, 1);
253
+ }
254
+ function normalizedOf(raw) {
255
+ return normalizeUsage(raw) ?? zeroUsage();
256
+ }
257
+ //# sourceMappingURL=review-ring.js.map
package/lib/review.d.ts CHANGED
@@ -1,11 +1,16 @@
1
+ import { type LlmRuntime, type RouteResolver } from "./llm-call.js";
1
2
  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
- }
3
+ /** The LLM face review calls, aliased so each scheduler can name its own. */
4
+ export type ReviewLlm = LlmRuntime;
6
5
  export interface ReviewDeps {
7
6
  store: MemoryStore;
8
- llm: ReviewLlm | undefined;
7
+ llm: LlmRuntime | undefined;
8
+ /**
9
+ * Route resolution for a direct call: the agent's explicit selection first,
10
+ * the host default model second. Absent keeps the legacy agent-options-only
11
+ * behavior (tests and older hosts).
12
+ */
13
+ resolveRoute?: RouteResolver | undefined;
9
14
  }
10
15
  export interface ReviewCandidate {
11
16
  workspaceId: string;
@@ -46,42 +51,19 @@ export interface ReviewRunSummary {
46
51
  cheapToken: number;
47
52
  evolveToken: number;
48
53
  }
49
- /** A rewrite lands only when the body-length ratio stays within this bound. */
50
- export declare const REWRITE_MAGNITUDE_CAP = 0.3;
51
- /**
52
- * Output budget for one cheap-gate call: a verdict JSON **plus** the reasoning
53
- * a thinking model spends before emitting it. A 512-token budget cut the gate
54
- * off on reasoning-model calls (2026-10-13) and the cut was ledgered as a
55
- * route failure, so the per-item failure rate read as a network problem.
56
- */
57
- export declare const GATE_MAX_TOKENS = 1024;
58
54
  /** Defaults for the review settings the host may override — and edit live. */
59
55
  export declare const REVIEW_DEFAULT_DRY_RUN = true;
60
56
  export declare const REVIEW_DEFAULT_DAILY_BUDGET = 100;
61
57
  export declare const REVIEW_DEFAULT_BATCH_SIZE = 10;
62
58
  export declare const REVIEW_DEFAULT_STALE_DAYS = 30;
63
- /** Relative body change: |new-old| / max(old, 1). Above the cap → quarantine. */
64
- export declare function rewriteMagnitude(oldBody: string, newBody: string): number;
65
- /**
66
- * Collect review candidates across every workspace carrying active items:
67
- * new items first (newest first), then corrects-linked requeues, then stale
68
- * requeues (30 days untouched). bounded by `limit`.
69
- */
70
- export declare function collectCandidates(store: MemoryStore, opts?: {
71
- now?: number | undefined;
72
- staleDays?: number | undefined;
73
- limit?: number | undefined;
74
- }): ReviewCandidate[];
75
59
  /**
76
- * Run one review batch: cheap gate per item, evolve pass on approval,
77
- * safeguards before landing, one `item-review` row per terminal path, one
78
- * daily merge at the end. Never throws: LLM and parse failures degrade to
79
- * skip rows (the run keeps going), exactly like extraction's fail-ledgered
80
- * discipline.
60
+ * Run one review batch: assemble the per-item chain once, then walk each
61
+ * candidate through it — cheap gate, evolve pass on approval, safeguards
62
+ * before landing, one `item-review` row per terminal path, one daily merge at
63
+ * the end. LLM and parse failures degrade to skip rows inside the chain (the
64
+ * run keeps going), exactly like extraction's fail-ledgered discipline.
81
65
  */
82
66
  export declare function runReviewBatch(deps: ReviewDeps, candidates: readonly ReviewCandidate[], opts: ReviewRunOptions): Promise<ReviewRunSummary>;
83
- /** One-line error head for ledger rows (fail-loud shape, capped length). */
84
- export declare function failureHead(error: unknown, max?: number): string;
85
67
  /** Current UTC day `YYYY-MM-DD` for the hard-cap account. */
86
68
  export declare function todayUtc(): string;
87
69
  /** The event wiring surface (duck-typed cordis context, mirrors extraction). */