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.
package/lib/review.js CHANGED
@@ -6,145 +6,35 @@
6
6
  * only know keep/rewrite/quarantine) and oversized rewrites degrade to
7
7
  * quarantine instead of landing.
8
8
  *
9
+ * This module owns the batch: the hard daily cap, the rollup, and the
10
+ * scheduler; the queue it drains is `review-candidates.ts`, and the per-item
11
+ * walk itself is a responsibility chain (`review-ring.ts`) the batch assembles
12
+ * once per run — gate → evolve → landing — so the order is visible in one
13
+ * place and each step is one link.
14
+ *
9
15
  * This track is independent from the extraction pipeline by construction:
10
16
  * own prompts (`review-prompt.ts`), own budget (`review_daily`), own ledger
11
17
  * (`review_log`), no cursor, no increment. It only shares the store's write
12
18
  * gates (`applyProposals` / `applySkillProposals` / `quarantine`).
13
19
  */
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";
20
+ import { runChain } from "./chain.js";
21
+ import { resolveCallRoute } from "./llm-call.js";
16
22
  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
- /**
20
- * Output budget for one cheap-gate call: a verdict JSON **plus** the reasoning
21
- * a thinking model spends before emitting it. A 512-token budget cut the gate
22
- * off on reasoning-model calls (2026-10-13) and the cut was ledgered as a
23
- * route failure, so the per-item failure rate read as a network problem.
24
- */
25
- export const GATE_MAX_TOKENS = 1024;
23
+ import { collectCandidates } from "./review-candidates.js";
24
+ import { reviewChain } from "./review-ring.js";
25
+ import { subagentLoaderFor } from "./subagent.js";
26
+ import { zeroUsage } from "./token-usage.js";
26
27
  /** Defaults for the review settings the host may override — and edit live. */
27
28
  export const REVIEW_DEFAULT_DRY_RUN = true;
28
29
  export const REVIEW_DEFAULT_DAILY_BUDGET = 100;
29
30
  export const REVIEW_DEFAULT_BATCH_SIZE = 10;
30
31
  export const REVIEW_DEFAULT_STALE_DAYS = 30;
31
- /** Relative body change: |new-old| / max(old, 1). Above the cap → quarantine. */
32
- export function rewriteMagnitude(oldBody, newBody) {
33
- return Math.abs(newBody.length - oldBody.length) / Math.max(oldBody.length, 1);
34
- }
35
- /**
36
- * Collect review candidates across every workspace carrying active items:
37
- * new items first (newest first), then corrects-linked requeues, then stale
38
- * requeues (30 days untouched). bounded by `limit`.
39
- */
40
- export function collectCandidates(store, opts = {}) {
41
- const now = opts.now ?? Date.now();
42
- const staleMs = (opts.staleDays ?? 30) * 24 * 60 * 60 * 1000;
43
- const limit = opts.limit ?? 100;
44
- const fresh = [];
45
- const requeued = [];
46
- const stale = [];
47
- // One push per enumerated row: store primary keys make (workspace, kind,
48
- // id) unique, so no dedup set is needed — a duplicate here would mean a
49
- // store invariant broke, and failing loud beats silently merging.
50
- const push = (candidate) => {
51
- (candidate.priority === 0 ? fresh : candidate.priority === 1 ? requeued : stale).push(candidate);
52
- };
53
- const partitions = new Set();
54
- for (const summary of store.listWorkspaces()) {
55
- partitions.add(summary.workspaceId);
56
- }
57
- for (const summary of store.listSkillWorkspaces()) {
58
- partitions.add(summary.workspaceId);
59
- }
60
- partitions.add("");
61
- for (const workspaceId of partitions) {
62
- const correctsTargets = new Set();
63
- const memories = store.listAll(workspaceId);
64
- for (const record of memories) {
65
- if (record.corrects !== null && record.corrects.length > 0) {
66
- correctsTargets.add(record.corrects);
67
- }
68
- }
69
- for (const record of memories) {
70
- if (record.status !== "active") {
71
- continue;
72
- }
73
- const state = store.reviewStateOf(workspaceId, "memory", record.id);
74
- const updatedAt = Date.parse(record.updatedAt) || 0;
75
- if (state === undefined) {
76
- push({ workspaceId, kind: "memory", id: record.id, priority: 0, title: record.title, description: record.description, body: record.body, memoryType: record.type });
77
- }
78
- else if (correctsTargets.has(record.id)) {
79
- push({ workspaceId, kind: "memory", id: record.id, priority: 1, title: record.title, description: record.description, body: record.body, memoryType: record.type });
80
- }
81
- else if (now - updatedAt >= staleMs) {
82
- push({ workspaceId, kind: "memory", id: record.id, priority: 2, title: record.title, description: record.description, body: record.body, memoryType: record.type });
83
- }
84
- }
85
- const projectSeen = new Set();
86
- const skillsOf = (partition, scope) => {
87
- for (const record of store.listAllSkills(partition)) {
88
- if (record.status !== "active" || projectSeen.has(record.id)) {
89
- continue;
90
- }
91
- projectSeen.add(record.id);
92
- const state = store.reviewStateOf(partition, "skill", record.id);
93
- const updatedAt = Date.parse(record.updatedAt) || 0;
94
- const candidate = {
95
- workspaceId: partition, kind: "skill", id: record.id,
96
- priority: state === undefined ? 0 : 2,
97
- title: record.title, description: record.description, body: record.body, scope,
98
- };
99
- if (state === undefined || now - updatedAt >= staleMs) {
100
- push(candidate);
101
- }
102
- }
103
- };
104
- if (workspaceId !== "") {
105
- skillsOf(workspaceId, "project");
106
- }
107
- }
108
- // Global skills ride along once (project rows shadow them per id).
109
- skillsGlobal(store, now, staleMs, push);
110
- const byPriority = (a, b) => (a.priority - b.priority);
111
- return [...fresh, ...requeued, ...stale].sort(byPriority).slice(0, limit);
112
- }
113
- function skillsGlobal(store, now, staleMs, push) {
114
- const projectIds = new Set();
115
- for (const summary of store.listSkillWorkspaces()) {
116
- if (summary.workspaceId === "") {
117
- continue;
118
- }
119
- for (const record of store.listAllSkills(summary.workspaceId)) {
120
- // Archived rows shadow nothing (readWithScope shows the global
121
- // row once the project row archives after a move-to-global).
122
- if (record.status === "active") {
123
- projectIds.add(record.id);
124
- }
125
- }
126
- }
127
- for (const record of store.listAllSkills("")) {
128
- if (record.status !== "active" || projectIds.has(record.id)) {
129
- continue;
130
- }
131
- const state = store.reviewStateOf("", "skill", record.id);
132
- const updatedAt = Date.parse(record.updatedAt) || 0;
133
- if (state === undefined || now - updatedAt >= staleMs) {
134
- push({
135
- workspaceId: "", kind: "skill", id: record.id,
136
- priority: state === undefined ? 0 : 2,
137
- title: record.title, description: record.description, body: record.body, scope: "global",
138
- });
139
- }
140
- }
141
- }
142
32
  /**
143
- * Run one review batch: cheap gate per item, evolve pass on approval,
144
- * safeguards before landing, one `item-review` row per terminal path, one
145
- * daily merge at the end. Never throws: LLM and parse failures degrade to
146
- * skip rows (the run keeps going), exactly like extraction's fail-ledgered
147
- * discipline.
33
+ * Run one review batch: assemble the per-item chain once, then walk each
34
+ * candidate through it — cheap gate, evolve pass on approval, safeguards
35
+ * before landing, one `item-review` row per terminal path, one daily merge at
36
+ * the end. LLM and parse failures degrade to skip rows inside the chain (the
37
+ * run keeps going), exactly like extraction's fail-ledgered discipline.
148
38
  */
149
39
  export async function runReviewBatch(deps, candidates, opts) {
150
40
  const summary = { reviewed: 0, approved: 0, applied: 0, dropped: 0, backlog: 0, cheapToken: 0, evolveToken: 0 };
@@ -153,7 +43,13 @@ export async function runReviewBatch(deps, candidates, opts) {
153
43
  const runId = opts.runId ?? `review:${opts.day}:${Date.now()}`;
154
44
  let newItems = 0;
155
45
  let requeued = 0;
156
- const noRoute = deps.llm === undefined || opts.provider === undefined || opts.model === undefined;
46
+ const route = opts.provider === undefined || opts.model === undefined
47
+ ? undefined
48
+ : { provider: opts.provider, model: opts.model };
49
+ const llm = deps.llm;
50
+ // The environment class: one loader and one chain for the whole run, in the
51
+ // order the ring declares. Without a route there is no ring to walk.
52
+ const ring = route === undefined || llm === undefined ? undefined : assembleRing({ llm, route, sessionId: opts.sessionId });
157
53
  let position = 0;
158
54
  for (const candidate of batch) {
159
55
  const remaining = deps.store.consumeReviewBudget(opts.day, opts.budget);
@@ -177,7 +73,7 @@ export async function runReviewBatch(deps, candidates, opts) {
177
73
  else {
178
74
  requeued += 1;
179
75
  }
180
- if (noRoute) {
76
+ if (route === undefined || ring === undefined) {
181
77
  deps.store.logReview({
182
78
  runId, workspaceId: candidate.workspaceId, itemKind: candidate.kind, itemId: candidate.id,
183
79
  verdict: "skip", reason: "no-route", applied: 0,
@@ -186,7 +82,11 @@ export async function runReviewBatch(deps, candidates, opts) {
186
82
  position += 1;
187
83
  continue;
188
84
  }
189
- await reviewOne(deps, runId, candidate, opts, summary, skipReasons);
85
+ const item = {
86
+ deps, loader: ring.loader, route, runId, candidate, opts, summary, skipReasons,
87
+ gate: { usage: zeroUsage(), raw: undefined },
88
+ };
89
+ await runChain(ring.links, item);
190
90
  position += 1;
191
91
  }
192
92
  summary.backlog = Math.max(0, candidates.length - summary.reviewed - summary.dropped);
@@ -198,227 +98,15 @@ export async function runReviewBatch(deps, candidates, opts) {
198
98
  });
199
99
  return summary;
200
100
  }
201
- async function reviewOne(deps, runId, candidate, opts, summary, skipReasons) {
202
- const base = {
203
- runId, workspaceId: candidate.workspaceId, itemKind: candidate.kind, itemId: candidate.id,
204
- };
205
- // ── Cheap gate ──────────────────────────────────────────────
206
- let gateUsage = zeroUsage();
207
- let gateRaw;
208
- try {
209
- const answer = await streamOnce(deps.llm, opts, REVIEW_CHEAP_SYSTEM_PROMPT, gatePrompt(candidate), GATE_MAX_TOKENS);
210
- gateRaw = answer.usage;
211
- const gate = parseGateAnswer(answer.text);
212
- if (gate === null) {
213
- // A cut gate is a failure only when no complete verdict came
214
- // through: the JSON usually lands before the budget runs out, so
215
- // parse first and blame the finish reason only if it did not.
216
- if (answer.finish !== undefined && answer.finish !== "stop") {
217
- throw new Error(`stream finished with ${answer.finish}`);
218
- }
219
- deps.store.logReview({ ...base, verdict: "skip", reason: "gate-parse-failed", cheapUsage: gateRaw, applied: 0 });
220
- skipReasons.push("gate-parse-failed");
221
- return;
222
- }
223
- if (!gate.worthReview) {
224
- summary.cheapToken += totalOf(gateRaw);
225
- deps.store.logReview({ ...base, verdict: "skip", reason: gate.reason, cheapUsage: gateRaw, applied: 0 });
226
- skipReasons.push("cheap-gate");
227
- return;
228
- }
229
- summary.cheapToken += totalOf(gateRaw);
230
- gateUsage = normalizedOf(gateRaw);
231
- }
232
- catch (error) {
233
- // The error text rides the row: a bare `gate-llm-failed` hid a ~50%
234
- // per-item failure rate in production (2026-10-12) with no way to
235
- // tell transient route faults from systematic ones.
236
- deps.store.logReview({ ...base, verdict: "skip", reason: `gate-llm-failed: ${failureHead(error)}`, applied: 0 });
237
- skipReasons.push("gate-llm-failed");
238
- return;
239
- }
240
- summary.approved += 1;
241
- // ── Evolve pass ─────────────────────────────────────────────
242
- const neighbors = neighborHooks(deps.store, candidate);
243
- const extra = candidate.kind === "memory" && candidate.memoryType === "feedback"
244
- ? "注意:feedback 类正文必须带 Why 与 How to apply 两行。"
245
- : undefined;
246
- let evolveRaw;
247
- try {
248
- const system = candidate.kind === "memory" ? REVIEW_MEMORY_SYSTEM_PROMPT : REVIEW_SKILL_SYSTEM_PROMPT;
249
- const answer = await streamOnce(deps.llm, opts, system, evolvePrompt({ id: candidate.id, title: candidate.title, description: candidate.description, body: candidate.body, extra, scope: candidate.scope }, neighbors), 4096);
250
- evolveRaw = answer.usage;
251
- summary.evolveToken += totalOf(evolveRaw);
252
- // The evolve pass carries full replacement text: a cut stream may hold
253
- // half a body, so it never becomes a verdict.
254
- if (answer.finish !== undefined && answer.finish !== "stop") {
255
- throw new Error(`stream finished with ${answer.finish}`);
256
- }
257
- const evolved = parseEvolveAnswer(answer.text);
258
- if (evolved === null) {
259
- deps.store.logReview({ ...base, verdict: "skip", reason: "evolve-parse-failed", cheapUsage: gateUsage, evolveUsage: evolveRaw, applied: 0 });
260
- skipReasons.push("evolve-parse-failed");
261
- return;
262
- }
263
- if (evolved.verdict === "keep") {
264
- deps.store.logReview({ ...base, verdict: "keep", reason: evolved.reason, cheapUsage: gateUsage, evolveUsage: evolveRaw, applied: 0 });
265
- return;
266
- }
267
- if (evolved.verdict === "quarantine") {
268
- landQuarantine(deps, base, candidate, opts, evolved.reason, gateUsage, evolveRaw, proposedOf(evolved), summary);
269
- return;
270
- }
271
- // ── Rewrite: magnitude cap first, dry-run second, gates last ──
272
- const nextBody = evolved.body ?? candidate.body;
273
- if (rewriteMagnitude(candidate.body, nextBody) > REWRITE_MAGNITUDE_CAP) {
274
- landQuarantine(deps, base, candidate, opts, `幅度超限转隔离:${evolved.reason}`, gateUsage, evolveRaw, proposedOf(evolved), summary);
275
- return;
276
- }
277
- if (opts.dryRun) {
278
- deps.store.logReview({ ...base, verdict: "rewrite", reason: `试运行未落盘:${evolved.reason}`, proposed: proposedOf(evolved), cheapUsage: gateUsage, evolveUsage: evolveRaw, applied: 0 });
279
- return;
280
- }
281
- landRewrite(deps, base, candidate, opts, evolved, gateUsage, evolveRaw, summary);
282
- }
283
- catch (error) {
284
- deps.store.logReview({ ...base, verdict: "skip", reason: `evolve-llm-failed: ${failureHead(error)}`, cheapUsage: gateUsage, evolveUsage: evolveRaw, applied: 0 });
285
- skipReasons.push("evolve-llm-failed");
286
- }
287
- }
288
- /**
289
- * The rewrite payload a non-landing row keeps: exactly the fields the model
290
- * supplied, or `undefined` when it proposed no text at all.
291
- */
292
- function proposedOf(evolved) {
293
- const payload = {};
294
- if (evolved.title !== undefined) {
295
- payload.title = evolved.title;
296
- }
297
- if (evolved.description !== undefined) {
298
- payload.description = evolved.description;
299
- }
300
- if (evolved.body !== undefined) {
301
- payload.body = evolved.body;
302
- }
303
- if (evolved.corrects !== undefined) {
304
- payload.corrects = evolved.corrects;
305
- }
306
- return Object.keys(payload).length === 0 ? undefined : payload;
307
- }
308
- function neighborHooks(store, candidate) {
309
- try {
310
- if (candidate.kind === "memory") {
311
- const query = candidate.title.slice(0, 60);
312
- const hits = query.length >= 3 ? store.search(candidate.workspaceId, query, 5) : store.list(candidate.workspaceId).slice(0, 5);
313
- return hits.filter((hit) => hit.id !== candidate.id).slice(0, 4).map((hit) => ({ id: hit.id, title: hit.title, description: hit.description }));
314
- }
315
- const hits = store.searchSkills(candidate.workspaceId, candidate.title.slice(0, 60), 5);
316
- return hits.filter((hit) => hit.id !== candidate.id).slice(0, 4).map((hit) => ({ id: hit.id, title: hit.title, description: hit.description }));
317
- }
318
- catch {
319
- return [];
320
- }
321
- }
322
- function landQuarantine(deps, base, candidate, opts, reason, gateUsage, evolveRaw, proposed, summary) {
323
- const prior = deps.store.reviewStateOf(candidate.workspaceId, candidate.kind, candidate.id)?.evolveCount ?? 0;
324
- if (opts.dryRun) {
325
- deps.store.logReview({ ...base, verdict: "quarantine", reason: `试运行未隔离:${reason}`, proposed, cheapUsage: gateUsage, evolveUsage: evolveRaw, applied: 0, evolveCount: prior });
326
- return;
327
- }
328
- const ok = deps.store.quarantine(candidate.workspaceId, candidate.kind, candidate.id);
329
- deps.store.logReview({
330
- ...base, verdict: "quarantine", reason, cheapUsage: gateUsage, evolveUsage: evolveRaw,
331
- applied: ok ? 1 : 0, evolveCount: ok ? prior + 1 : prior,
332
- });
333
- if (ok) {
334
- summary.applied += 1;
335
- }
336
- }
337
- function landRewrite(deps, base, candidate, opts, evolved, gateUsage, evolveRaw, summary) {
338
- const prior = deps.store.reviewStateOf(candidate.workspaceId, candidate.kind, candidate.id)?.evolveCount ?? 0;
339
- const ctx = { workspaceId: candidate.workspaceId, trigger: "item-review", sessionId: opts.sessionId, runId: base.runId, model: opts.model, decision: evolved.reason };
340
- if (candidate.kind === "memory") {
341
- const [outcome] = deps.store.applyProposals(ctx, [{
342
- action: "update", id: candidate.id,
343
- title: evolved.title, description: evolved.description, body: evolved.body,
344
- corrects: evolved.corrects,
345
- }]);
346
- const ok = outcome !== undefined && outcome.ok;
347
- deps.store.logReview({
348
- ...base, verdict: "rewrite", reason: ok ? evolved.reason : `门禁拒绝:${outcome?.reason ?? "未知原因"}`,
349
- cheapUsage: gateUsage, evolveUsage: evolveRaw, applied: ok ? 1 : 0, evolveCount: ok ? prior + 1 : prior,
350
- });
351
- if (ok) {
352
- summary.applied += 1;
353
- }
354
- return;
355
- }
356
- const { outcomes } = deps.store.applySkillProposals(ctx, [{
357
- action: "update", scope: candidate.scope ?? "project", id: candidate.id,
358
- title: evolved.title, description: evolved.description, body: evolved.body,
359
- }]);
360
- const outcome = outcomes[0];
361
- const ok = outcome !== undefined && outcome.ok;
362
- deps.store.logReview({
363
- ...base, verdict: "rewrite", reason: ok ? evolved.reason : `门禁拒绝:${outcome?.reason ?? "未知原因"}`,
364
- cheapUsage: gateUsage, evolveUsage: evolveRaw, applied: ok ? 1 : 0, evolveCount: ok ? prior + 1 : prior,
365
- });
366
- if (ok) {
367
- summary.applied += 1;
368
- }
369
- }
370
- function totalOf(raw) {
371
- return normalizeUsage(raw)?.total ?? 0;
372
- }
373
- /** One-line error head for ledger rows (fail-loud shape, capped length). */
374
- export function failureHead(error, max = 120) {
375
- const message = error instanceof Error ? error.message : String(error);
376
- const head = message.split("\n", 1)[0] ?? "";
377
- return head.length > max ? `${head.slice(0, max)}…` : head;
378
- }
379
- function normalizedOf(raw) {
380
- return normalizeUsage(raw) ?? zeroUsage();
381
- }
382
- /** One streaming text call with the host session identity forwarded. */
383
- async function streamOnce(llm, opts, system, prompt, maxTokens) {
384
- if (llm === undefined) {
385
- throw new Error("no llm route");
386
- }
387
- let text = "";
388
- let usage;
389
- let finish;
390
- for await (const chunk of llm.stream({
391
- provider: opts.provider,
392
- model: opts.model,
393
- messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
394
- system,
395
- maxTokens,
396
- sessionId: opts.sessionId,
397
- })) {
398
- const piece = chunk;
399
- if (piece?.type === "text-delta" && typeof piece.text === "string") {
400
- text += piece.text;
401
- }
402
- else if (piece?.type === "usage") {
403
- usage = piece.usage;
404
- }
405
- else if (piece?.type === "finish") {
406
- finish = piece.reason;
407
- }
408
- }
409
- const finishKind = finish?.kind;
410
- if (text.trim().length === 0) {
411
- // Nothing usable arrived: the finish reason is the only diagnosis.
412
- throw new Error(finishKind !== undefined && finishKind !== "stop" ? `stream finished with ${finishKind}` : "empty model output");
413
- }
414
- // The caller owns the truncation verdict: a gate answer often completes
415
- // before its budget does, a replacement body never does.
416
- return { text, usage, finish: finishKind };
417
- }
418
101
  /** Current UTC day `YYYY-MM-DD` for the hard-cap account. */
419
102
  export function todayUtc() {
420
103
  return new Date().toISOString().slice(0, 10);
421
104
  }
105
+ /** Assemble one run's ring (simple factory: one context, one bound loader, one chain). */
106
+ function assembleRing(call) {
107
+ const loader = subagentLoaderFor(call);
108
+ return { loader, links: reviewChain() };
109
+ }
422
110
  /**
423
111
  * Wire the review triggers onto the host context. Global single-flight with
424
112
  * a minimum interval: turn boundaries arm the pending workspace, compaction
@@ -484,20 +172,21 @@ export function createReviewScheduler(host, deps, config) {
484
172
  if (typeof id !== "string" || id.length === 0) {
485
173
  return;
486
174
  }
487
- const provider = typeof agent?.options?.provider === "string" ? agent.options.provider : undefined;
488
- const model = typeof agent?.options?.model === "string" ? agent.options.model : undefined;
489
- lastRoute = { provider, model, sessionId: id };
490
- drain(cwdOf(payload.agent));
491
- }));
492
- listeners.push(host.on("session/event", (session, event) => {
493
- if (event?.type !== "compaction/start" || typeof session?.id !== "string") {
175
+ // A drain needs a live route. A session running on the host default
176
+ // model carries no explicit selection, so the default-model service
177
+ // fills it in; with neither, the boundary is only remembered and the
178
+ // next turn boundary reviews it. This is what keeps a review call
179
+ // from being sent with an empty provider or a dead session identity
180
+ // (both of which the provider answers with a bare stream error).
181
+ // Absent resolver (older host, tests) still reads the agent's explicit
182
+ // selection: the fallback is the legacy behavior, never "no route".
183
+ const route = deps.resolveRoute?.(agent) ?? resolveCallRoute(agent, undefined);
184
+ if (route === undefined) {
185
+ pendingWorkspace = cwdOf(agent);
494
186
  return;
495
187
  }
496
- drain(pendingWorkspace);
497
- }));
498
- listeners.push(host.on("agent/disposed", () => {
499
- drain(pendingWorkspace);
500
- pendingWorkspace = undefined;
188
+ lastRoute = { provider: route.provider, model: route.model, sessionId: id };
189
+ drain(cwdOf(agent));
501
190
  }));
502
191
  const armed = config.read();
503
192
  host.logger("continual-evolve").info(`review scheduler armed (daily cap ${armed.dailyBudget}, dry-run ${armed.dryRun ? "on" : "off"})`);
package/lib/store.d.ts CHANGED
@@ -57,6 +57,8 @@ export interface ReviewLogEntry {
57
57
  itemId: string;
58
58
  verdict: "keep" | "rewrite" | "quarantine" | "skip";
59
59
  reason?: string | undefined;
60
+ /** The model this verdict ran on; `undefined` only when the run had no route at all. */
61
+ model?: string | undefined;
60
62
  /** Normalized usage JSON of the cheap gate call, `undefined` when skipped before any call. */
61
63
  cheapUsage?: unknown;
62
64
  /** Normalized usage JSON of the evolve call, `undefined` unless rewritten. */
package/lib/store.js CHANGED
@@ -131,7 +131,8 @@ CREATE TABLE IF NOT EXISTS memory_usage (
131
131
  PRIMARY KEY (workspace_id, memory_id)
132
132
  ) STRICT;
133
133
  -- Per-item content-review verdicts (the item-review trigger's account):
134
- -- one row per reviewed item per run. Pruned with the ledgers.
134
+ -- one row per reviewed item per run, with the model it ran on so a failure
135
+ -- rate can be attributed instead of guessed at. Pruned with the ledgers.
135
136
  CREATE TABLE IF NOT EXISTS review_log (
136
137
  run_id TEXT, ts TEXT NOT NULL, workspace_id TEXT,
137
138
  item_kind TEXT NOT NULL CHECK (item_kind IN ('memory','skill')),
@@ -139,6 +140,7 @@ CREATE TABLE IF NOT EXISTS review_log (
139
140
  verdict TEXT NOT NULL CHECK (verdict IN ('keep','rewrite','quarantine','skip')),
140
141
  reason TEXT,
141
142
  proposed TEXT,
143
+ model TEXT,
142
144
  cheap_usage TEXT, evolve_usage TEXT,
143
145
  applied INTEGER NOT NULL DEFAULT 0,
144
146
  evolve_count INTEGER NOT NULL DEFAULT 0
@@ -313,6 +315,7 @@ export async function openMemoryStore(explicitPath) {
313
315
  ensureExtractionCheapUsage(database);
314
316
  ensureExtractionCheapError(database);
315
317
  ensureReviewProposed(database);
318
+ ensureReviewModel(database);
316
319
  const stmts = {
317
320
  get: database.prepare("SELECT * FROM memories WHERE workspace_id = ? AND id = ?"),
318
321
  list: database.prepare(`SELECT * FROM memories WHERE workspace_id = ? AND status = 'active' ${LIST_ORDER}`),
@@ -367,8 +370,8 @@ export async function openMemoryStore(explicitPath) {
367
370
  memUseGet: database.prepare("SELECT use_count, last_used_at FROM memory_usage WHERE workspace_id = ? AND memory_id = ?"),
368
371
  memUseUpsert: database.prepare("INSERT INTO memory_usage (workspace_id, memory_id, use_count, last_used_at) VALUES (?, ?, 1, ?) " +
369
372
  "ON CONFLICT (workspace_id, memory_id) DO UPDATE SET use_count = use_count + 1, last_used_at = excluded.last_used_at"),
370
- reviewLog: database.prepare("INSERT INTO review_log (run_id, ts, workspace_id, item_kind, item_id, verdict, reason, proposed, cheap_usage, evolve_usage, applied, evolve_count) " +
371
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
373
+ reviewLog: database.prepare("INSERT INTO review_log (run_id, ts, workspace_id, item_kind, item_id, verdict, reason, proposed, model, cheap_usage, evolve_usage, applied, evolve_count) " +
374
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
372
375
  reviewLast: database.prepare(`SELECT ts, applied FROM review_log WHERE workspace_id IS ? AND item_kind = ? AND item_id = ? AND NOT ${INCOMPLETE_REVIEW_SQL} ORDER BY rowid DESC LIMIT 1`),
373
376
  reviewEvolved: database.prepare("SELECT COUNT(*) AS n FROM review_log WHERE workspace_id IS ? AND item_kind = ? AND item_id = ? AND applied = 1"),
374
377
  reviewPrune: database.prepare("DELETE FROM review_log WHERE ts < ?"),
@@ -913,7 +916,7 @@ export async function openMemoryStore(explicitPath) {
913
916
  }
914
917
  },
915
918
  logReview(entry) {
916
- stmts.reviewLog.run(entry.runId ?? null, new Date().toISOString(), entry.workspaceId ?? null, entry.itemKind, entry.itemId, entry.verdict, entry.reason ?? null, entry.proposed === undefined ? null : JSON.stringify(entry.proposed), entry.cheapUsage === undefined ? null : JSON.stringify(entry.cheapUsage), entry.evolveUsage === undefined ? null : JSON.stringify(entry.evolveUsage), entry.applied ?? 0, entry.evolveCount ?? 0);
919
+ stmts.reviewLog.run(entry.runId ?? null, new Date().toISOString(), entry.workspaceId ?? null, entry.itemKind, entry.itemId, entry.verdict, entry.reason ?? null, entry.proposed === undefined ? null : JSON.stringify(entry.proposed), entry.model ?? null, entry.cheapUsage === undefined ? null : JSON.stringify(entry.cheapUsage), entry.evolveUsage === undefined ? null : JSON.stringify(entry.evolveUsage), entry.applied ?? 0, entry.evolveCount ?? 0);
917
920
  },
918
921
  consumeReviewBudget(day, budget) {
919
922
  stmts.dailyUpsert.run(day, budget);
@@ -1038,6 +1041,18 @@ function ensureReviewProposed(database) {
1038
1041
  }
1039
1042
  database.exec("ALTER TABLE review_log ADD COLUMN proposed TEXT");
1040
1043
  }
1044
+ /**
1045
+ * Add the review ledger `model` column to a store created before verdict rows
1046
+ * recorded the model they ran on. Same single-migration discipline as the
1047
+ * columns above.
1048
+ */
1049
+ function ensureReviewModel(database) {
1050
+ const columns = database.prepare("SELECT name FROM pragma_table_info('review_log')").all();
1051
+ if (columns.some((column) => column.name === "model")) {
1052
+ return;
1053
+ }
1054
+ database.exec("ALTER TABLE review_log ADD COLUMN model TEXT");
1055
+ }
1041
1056
  /** Parse the daily skip-counts map; corrupt payloads degrade to empty. */
1042
1057
  function parseCounts(raw) {
1043
1058
  if (raw === null) {
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Every subagent this plugin runs, declared in one table.
3
+ *
4
+ * A definition carries the job's identity, its whole instruction (system
5
+ * prompt), its output-budget fallback and its answer parser. Feature code
6
+ * loads definitions from here and never writes a prompt or a budget inline;
7
+ * adding a job means adding a row, and a job with no row cannot be called.
8
+ *
9
+ * The four review jobs are separate definitions on purpose: the memory rubric
10
+ * requires a memory to survive having its repo name stripped, while a project
11
+ * skill is *supposed* to be repo-bound, so one prompt cannot serve both.
12
+ */
13
+ import { type CheapVerdict, type ParsedAnswer } from "./extraction-prompt.js";
14
+ import { type EvolveVerdict, type GateVerdict } from "./review-prompt.js";
15
+ import type { SubagentDefinition } from "./subagent.js";
16
+ /**
17
+ * Output-budget fallbacks, used only for adapters that advertise no
18
+ * per-request cap of their own. A gate verdict is one JSON object plus the
19
+ * reasoning a thinking model spends before emitting it; the evolve/proposal
20
+ * calls additionally carry full replacement bodies.
21
+ */
22
+ export declare const GATE_MAX_TOKENS = 1024;
23
+ export declare const EVOLVE_MAX_TOKENS = 4096;
24
+ export declare const EXTRACTION_MAX_TOKENS = 4096;
25
+ /** The memory cheap gate: is this entry worth a close look at all? */
26
+ export declare const reviewGateMemory: SubagentDefinition<GateVerdict | null>;
27
+ /** The skill cheap gate: same question, scope-relative home rules. */
28
+ export declare const reviewGateSkill: SubagentDefinition<GateVerdict | null>;
29
+ /** The memory evolve pass: five-dimension judgment plus the proposed rewrite. */
30
+ export declare const reviewEvolveMemory: SubagentDefinition<EvolveVerdict | null>;
31
+ /** The skill evolve pass: scope-relative reuse, executability, duplication. */
32
+ export declare const reviewEvolveSkill: SubagentDefinition<EvolveVerdict | null>;
33
+ /** The extraction pre-gate: does this increment hold anything worth proposing? */
34
+ export declare const extractionGate: SubagentDefinition<CheapVerdict | null>;
35
+ /** The extraction proposal call: the run's one expensive call. */
36
+ export declare const extractionMain: SubagentDefinition<ParsedAnswer>;
37
+ /** Every definition, for the "which jobs exist" read and the id-uniqueness check. */
38
+ export declare const SUBAGENT_DEFINITIONS: readonly SubagentDefinition<unknown>[];
39
+ /** The gate definition one review candidate loads, by item kind. */
40
+ export declare const REVIEW_GATE_BY_KIND: Readonly<Record<"memory" | "skill", SubagentDefinition<GateVerdict | null>>>;
41
+ /** The evolve definition one review candidate loads, by item kind. */
42
+ export declare const REVIEW_EVOLVE_BY_KIND: Readonly<Record<"memory" | "skill", SubagentDefinition<EvolveVerdict | null>>>;
43
+ //# sourceMappingURL=subagent-defs.d.ts.map