dsh-continual-evolve 0.15.6 → 0.15.8

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 ADDED
@@ -0,0 +1,467 @@
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 (error) {
215
+ // The error text rides the row: a bare `gate-llm-failed` hid a ~50%
216
+ // per-item failure rate in production (2026-10-12) with no way to
217
+ // tell transient route faults from systematic ones.
218
+ deps.store.logReview({ ...base, verdict: "skip", reason: `gate-llm-failed: ${failureHead(error)}`, applied: 0 });
219
+ skipReasons.push("gate-llm-failed");
220
+ return;
221
+ }
222
+ summary.approved += 1;
223
+ // ── Evolve pass ─────────────────────────────────────────────
224
+ const neighbors = neighborHooks(deps.store, candidate);
225
+ const extra = candidate.kind === "memory" && candidate.memoryType === "feedback"
226
+ ? "注意:feedback 类正文必须带 Why 与 How to apply 两行。"
227
+ : undefined;
228
+ let evolveRaw;
229
+ try {
230
+ const system = candidate.kind === "memory" ? REVIEW_MEMORY_SYSTEM_PROMPT : REVIEW_SKILL_SYSTEM_PROMPT;
231
+ const answer = await streamOnce(deps.llm, opts, system, evolvePrompt({ id: candidate.id, title: candidate.title, description: candidate.description, body: candidate.body, extra }, neighbors), 4096);
232
+ evolveRaw = answer.usage;
233
+ summary.evolveToken += totalOf(evolveRaw);
234
+ const evolved = parseEvolveAnswer(answer.text);
235
+ if (evolved === null) {
236
+ deps.store.logReview({ ...base, verdict: "skip", reason: "evolve-parse-failed", cheapUsage: gateUsage, evolveUsage: evolveRaw, applied: 0 });
237
+ skipReasons.push("evolve-parse-failed");
238
+ return;
239
+ }
240
+ if (evolved.verdict === "keep") {
241
+ deps.store.logReview({ ...base, verdict: "keep", reason: evolved.reason, cheapUsage: gateUsage, evolveUsage: evolveRaw, applied: 0 });
242
+ return;
243
+ }
244
+ if (evolved.verdict === "quarantine") {
245
+ landQuarantine(deps, base, candidate, opts, evolved.reason, gateUsage, evolveRaw, summary);
246
+ return;
247
+ }
248
+ // ── Rewrite: magnitude cap first, dry-run second, gates last ──
249
+ const nextBody = evolved.body ?? candidate.body;
250
+ if (rewriteMagnitude(candidate.body, nextBody) > REWRITE_MAGNITUDE_CAP) {
251
+ landQuarantine(deps, base, candidate, opts, `幅度超限转隔离:${evolved.reason}`, gateUsage, evolveRaw, summary);
252
+ return;
253
+ }
254
+ if (opts.dryRun) {
255
+ deps.store.logReview({ ...base, verdict: "rewrite", reason: `试运行未落盘:${evolved.reason}`, cheapUsage: gateUsage, evolveUsage: evolveRaw, applied: 0 });
256
+ return;
257
+ }
258
+ landRewrite(deps, base, candidate, opts, evolved, gateUsage, evolveRaw, summary);
259
+ }
260
+ catch (error) {
261
+ deps.store.logReview({ ...base, verdict: "skip", reason: `evolve-llm-failed: ${failureHead(error)}`, cheapUsage: gateUsage, evolveUsage: evolveRaw, applied: 0 });
262
+ skipReasons.push("evolve-llm-failed");
263
+ }
264
+ }
265
+ function neighborHooks(store, candidate) {
266
+ try {
267
+ if (candidate.kind === "memory") {
268
+ const query = candidate.title.slice(0, 60);
269
+ const hits = query.length >= 3 ? store.search(candidate.workspaceId, query, 5) : store.list(candidate.workspaceId).slice(0, 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
+ const hits = store.searchSkills(candidate.workspaceId, candidate.title.slice(0, 60), 5);
273
+ return hits.filter((hit) => hit.id !== candidate.id).slice(0, 4).map((hit) => ({ id: hit.id, title: hit.title, description: hit.description }));
274
+ }
275
+ catch {
276
+ return [];
277
+ }
278
+ }
279
+ function landQuarantine(deps, base, candidate, opts, reason, gateUsage, evolveRaw, summary) {
280
+ const prior = deps.store.reviewStateOf(candidate.workspaceId, candidate.kind, candidate.id)?.evolveCount ?? 0;
281
+ if (opts.dryRun) {
282
+ deps.store.logReview({ ...base, verdict: "quarantine", reason: `试运行未隔离:${reason}`, cheapUsage: gateUsage, evolveUsage: evolveRaw, applied: 0, evolveCount: prior });
283
+ return;
284
+ }
285
+ const ok = deps.store.quarantine(candidate.workspaceId, candidate.kind, candidate.id);
286
+ deps.store.logReview({
287
+ ...base, verdict: "quarantine", reason, cheapUsage: gateUsage, evolveUsage: evolveRaw,
288
+ applied: ok ? 1 : 0, evolveCount: ok ? prior + 1 : prior,
289
+ });
290
+ if (ok) {
291
+ summary.applied += 1;
292
+ }
293
+ }
294
+ function landRewrite(deps, base, candidate, opts, evolved, gateUsage, evolveRaw, summary) {
295
+ const prior = deps.store.reviewStateOf(candidate.workspaceId, candidate.kind, candidate.id)?.evolveCount ?? 0;
296
+ const ctx = { workspaceId: candidate.workspaceId, trigger: "item-review", sessionId: opts.sessionId, runId: base.runId, model: opts.model, decision: evolved.reason };
297
+ if (candidate.kind === "memory") {
298
+ const [outcome] = deps.store.applyProposals(ctx, [{
299
+ action: "update", id: candidate.id,
300
+ title: evolved.title, description: evolved.description, body: evolved.body,
301
+ corrects: evolved.corrects,
302
+ }]);
303
+ const ok = outcome !== undefined && outcome.ok;
304
+ deps.store.logReview({
305
+ ...base, verdict: "rewrite", reason: ok ? evolved.reason : `门禁拒绝:${outcome?.reason ?? "未知原因"}`,
306
+ cheapUsage: gateUsage, evolveUsage: evolveRaw, applied: ok ? 1 : 0, evolveCount: ok ? prior + 1 : prior,
307
+ });
308
+ if (ok) {
309
+ summary.applied += 1;
310
+ }
311
+ return;
312
+ }
313
+ const { outcomes } = deps.store.applySkillProposals(ctx, [{
314
+ action: "update", scope: candidate.scope ?? "project", id: candidate.id,
315
+ title: evolved.title, description: evolved.description, body: evolved.body,
316
+ }]);
317
+ const outcome = outcomes[0];
318
+ const ok = outcome !== undefined && outcome.ok;
319
+ deps.store.logReview({
320
+ ...base, verdict: "rewrite", reason: ok ? evolved.reason : `门禁拒绝:${outcome?.reason ?? "未知原因"}`,
321
+ cheapUsage: gateUsage, evolveUsage: evolveRaw, applied: ok ? 1 : 0, evolveCount: ok ? prior + 1 : prior,
322
+ });
323
+ if (ok) {
324
+ summary.applied += 1;
325
+ }
326
+ }
327
+ function totalOf(raw) {
328
+ return normalizeUsage(raw)?.total ?? 0;
329
+ }
330
+ /** One-line error head for ledger rows (fail-loud shape, capped length). */
331
+ export function failureHead(error, max = 120) {
332
+ const message = error instanceof Error ? error.message : String(error);
333
+ const head = message.split("\n", 1)[0] ?? "";
334
+ return head.length > max ? `${head.slice(0, max)}…` : head;
335
+ }
336
+ function normalizedOf(raw) {
337
+ return normalizeUsage(raw) ?? zeroUsage();
338
+ }
339
+ /** One streaming text call with the host session identity forwarded. */
340
+ async function streamOnce(llm, opts, system, prompt, maxTokens) {
341
+ if (llm === undefined) {
342
+ throw new Error("no llm route");
343
+ }
344
+ let text = "";
345
+ let usage;
346
+ let finish;
347
+ for await (const chunk of llm.stream({
348
+ provider: opts.provider,
349
+ model: opts.model,
350
+ messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
351
+ system,
352
+ maxTokens,
353
+ sessionId: opts.sessionId,
354
+ })) {
355
+ const piece = chunk;
356
+ if (piece?.type === "text-delta" && typeof piece.text === "string") {
357
+ text += piece.text;
358
+ }
359
+ else if (piece?.type === "usage") {
360
+ usage = piece.usage;
361
+ }
362
+ else if (piece?.type === "finish") {
363
+ finish = piece.reason;
364
+ }
365
+ }
366
+ if (finish !== undefined && finish.kind !== "stop") {
367
+ throw new Error(`stream finished with ${finish.kind ?? "unknown"}`);
368
+ }
369
+ if (text.trim().length === 0) {
370
+ throw new Error("empty model output");
371
+ }
372
+ return { text, usage };
373
+ }
374
+ /** Current UTC day `YYYY-MM-DD` for the hard-cap account. */
375
+ export function todayUtc() {
376
+ return new Date().toISOString().slice(0, 10);
377
+ }
378
+ /**
379
+ * Wire the review triggers onto the host context. Global single-flight with
380
+ * a minimum interval: turn boundaries arm the pending workspace, compaction
381
+ * and session close drain immediately. Each drain reviews one batch bounded
382
+ * by the remaining daily budget — never more.
383
+ */
384
+ export function createReviewScheduler(host, deps, config) {
385
+ const listeners = [];
386
+ let running = false;
387
+ let lastRunAt = 0;
388
+ let pendingWorkspace;
389
+ let lastRoute;
390
+ const drain = (triggerWorkspace) => {
391
+ if (!config.enabled || running) {
392
+ if (triggerWorkspace !== undefined) {
393
+ pendingWorkspace = triggerWorkspace;
394
+ }
395
+ return;
396
+ }
397
+ if (Date.now() - lastRunAt < config.minIntervalMs) {
398
+ if (triggerWorkspace !== undefined) {
399
+ pendingWorkspace = triggerWorkspace;
400
+ }
401
+ return;
402
+ }
403
+ if (deps.llm === undefined || lastRoute?.provider === undefined || lastRoute?.model === undefined) {
404
+ return;
405
+ }
406
+ running = true;
407
+ lastRunAt = Date.now();
408
+ const workspace = triggerWorkspace ?? pendingWorkspace;
409
+ pendingWorkspace = undefined;
410
+ void (async () => {
411
+ try {
412
+ const day = todayUtc();
413
+ const daily = deps.store.getDaily(day);
414
+ const remaining = config.dailyBudget - (daily?.reviewed ?? 0);
415
+ if (remaining <= 0) {
416
+ return;
417
+ }
418
+ const candidates = collectCandidates(deps.store, { staleDays: config.staleDays, limit: config.batchSize * 2 });
419
+ const ordered = workspace === undefined
420
+ ? candidates
421
+ : [...candidates.filter((c) => c.workspaceId === workspace), ...candidates.filter((c) => c.workspaceId !== workspace)];
422
+ await runReviewBatch(deps, ordered.slice(0, config.batchSize), {
423
+ day, budget: config.dailyBudget, dryRun: config.dryRun,
424
+ batchSize: config.batchSize, staleDays: config.staleDays,
425
+ provider: lastRoute?.provider, model: lastRoute?.model, sessionId: lastRoute?.sessionId,
426
+ });
427
+ }
428
+ catch (error) {
429
+ host.logger("continual-evolve").warn(`review drain failed: ${error instanceof Error ? error.message : String(error)}`);
430
+ }
431
+ finally {
432
+ running = false;
433
+ }
434
+ })();
435
+ };
436
+ listeners.push(host.on("agent/turn-stopping", (payload) => {
437
+ const agent = payload.agent;
438
+ const id = agent?.id;
439
+ if (typeof id !== "string" || id.length === 0) {
440
+ return;
441
+ }
442
+ const provider = typeof agent?.options?.provider === "string" ? agent.options.provider : undefined;
443
+ const model = typeof agent?.options?.model === "string" ? agent.options.model : undefined;
444
+ lastRoute = { provider, model, sessionId: id };
445
+ drain(cwdOf(payload.agent));
446
+ }));
447
+ listeners.push(host.on("session/event", (session, event) => {
448
+ if (event?.type !== "compaction/start" || typeof session?.id !== "string") {
449
+ return;
450
+ }
451
+ drain(pendingWorkspace);
452
+ }));
453
+ listeners.push(host.on("agent/disposed", () => {
454
+ drain(pendingWorkspace);
455
+ pendingWorkspace = undefined;
456
+ }));
457
+ host.logger("continual-evolve").info(`review scheduler armed (daily cap ${config.dailyBudget}, dry-run ${config.dryRun ? "on" : "off"})`);
458
+ return () => {
459
+ pendingWorkspace = undefined;
460
+ for (const listener of listeners) {
461
+ if (typeof listener === "function") {
462
+ listener();
463
+ }
464
+ }
465
+ };
466
+ }
467
+ //# sourceMappingURL=review.js.map
@@ -117,8 +117,9 @@ export function registerSkillTools(tools, store) {
117
117
  if (found === undefined) {
118
118
  return `没有找到 id 为 "${args.id}" 的技能。`;
119
119
  }
120
- // V2 usage loop: a served body is one real consumption.
121
- // Search/list hits are noise and stay uncounted.
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.
122
123
  store.recordSkillUse(skillUsagePartition(found.scope, cwd), found.record.id, found.scope);
123
124
  return formatSkill(found.record, found.scope);
124
125
  }
@@ -26,6 +26,8 @@ export interface ProposalContext {
26
26
  runId?: string | undefined;
27
27
  model?: string | undefined;
28
28
  usage?: unknown;
29
+ /** Pre-gate call payload (extraction cheap gate); carried into the ledger row. */
30
+ cheapUsage?: unknown;
29
31
  /** One-line model decision summary for the ledger. */
30
32
  decision?: string | undefined;
31
33
  }
@@ -11,13 +11,14 @@ function recordOf(row) {
11
11
  sourceSession: row.source_session,
12
12
  sourceSeqs: row.source_seqs,
13
13
  sourceRun: row.source_run,
14
+ corrects: row.corrects ?? null,
14
15
  createdAt: row.created_at,
15
16
  updatedAt: row.updated_at,
16
17
  };
17
18
  }
18
- const INSERT_SQL = "INSERT INTO memories (workspace_id, id, type, title, description, body, status, source_session, source_seqs, source_run, created_at, updated_at) " +
19
- "VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)";
20
- const UPDATE_SQL = "UPDATE memories SET type = ?, title = ?, description = ?, body = ?, updated_at = ? WHERE workspace_id = ? AND id = ?";
19
+ const INSERT_SQL = "INSERT INTO memories (workspace_id, id, type, title, description, body, status, source_session, source_seqs, source_run, corrects, created_at, updated_at) " +
20
+ "VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)";
21
+ const UPDATE_SQL = "UPDATE memories SET type = ?, title = ?, description = ?, body = ?, corrects = COALESCE(?, corrects), updated_at = ? WHERE workspace_id = ? AND id = ?";
21
22
  const DELETE_SQL = "DELETE FROM memories WHERE workspace_id = ? AND id = ?";
22
23
  const FTS_DROP_SQL = "DELETE FROM memories_fts WHERE rowid IN (SELECT rowid FROM memories_fts WHERE workspace_id = ? AND id = ?)";
23
24
  const FTS_INSERT_SQL = "INSERT INTO memories_fts (workspace_id, id, title, description, body) " +
@@ -61,6 +62,7 @@ export function applyProposals(db, ctx, proposals) {
61
62
  durationMs: Date.now() - started,
62
63
  model: ctx.model,
63
64
  usage: ctx.usage,
65
+ cheapUsage: ctx.cheapUsage,
64
66
  files: applied ? files : [],
65
67
  };
66
68
  if (!applied) {
@@ -69,10 +71,10 @@ export function applyProposals(db, ctx, proposals) {
69
71
  writeLedger(db.database.prepare(LEDGER_SQL), ledger);
70
72
  return { outcomes, applied };
71
73
  }
72
- const LEDGER_SQL = "INSERT INTO extraction_log (run_id, ts, session_id, workspace_id, trigger, decision, skip_reason, status, duration_ms, model, usage, files) " +
73
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
74
+ const LEDGER_SQL = "INSERT INTO extraction_log (run_id, ts, session_id, workspace_id, trigger, decision, skip_reason, status, duration_ms, model, usage, cheap_usage, files) " +
75
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
74
76
  function writeLedger(stmt, entry) {
75
- stmt.run(entry.runId ?? null, new Date().toISOString(), entry.sessionId ?? null, entry.workspaceId ?? null, entry.trigger, entry.decision ?? null, entry.skipReason ?? null, entry.status ?? null, entry.durationMs ?? null, entry.model ?? null, entry.usage === undefined ? null : JSON.stringify(entry.usage), entry.files === undefined ? null : JSON.stringify(entry.files));
77
+ stmt.run(entry.runId ?? null, new Date().toISOString(), entry.sessionId ?? null, entry.workspaceId ?? null, entry.trigger, entry.decision ?? null, entry.skipReason ?? null, entry.status ?? null, entry.durationMs ?? null, entry.model ?? null, entry.usage === undefined ? null : JSON.stringify(entry.usage), entry.cheapUsage === undefined ? null : JSON.stringify(entry.cheapUsage), entry.files === undefined ? null : JSON.stringify(entry.files));
76
78
  }
77
79
  /** Validate one proposal and land it; appends the audit payload on success. */
78
80
  function applyOne(db, ctx, proposal, stmts, files) {
@@ -99,6 +101,10 @@ function applyOne(db, ctx, proposal, stmts, files) {
99
101
  if ("reason" in fields) {
100
102
  return fail(proposal, fields.reason);
101
103
  }
104
+ const link = checkCorrects(stmts.get, ctx.workspaceId, proposal, id);
105
+ if ("reason" in link) {
106
+ return fail(proposal, link.reason);
107
+ }
102
108
  const reasons = fieldGateReasons(fields.fields);
103
109
  if (reasons.length > 0) {
104
110
  return fail(proposal, reasons[0] ?? "invalid record");
@@ -108,7 +114,7 @@ function applyOne(db, ctx, proposal, stmts, files) {
108
114
  return fail(proposal, `memory "${id}" already exists — update it instead of creating a duplicate`);
109
115
  }
110
116
  const now = db.now();
111
- stmts.insert.run(ctx.workspaceId, id, fields.fields.type, fields.fields.title, fields.fields.description, fields.fields.body, ctx.sessionId ?? null, proposal.sourceSeqs ?? null, ctx.runId ?? (ctx.trigger === "explicit" ? "explicit" : null), now, now);
117
+ stmts.insert.run(ctx.workspaceId, id, fields.fields.type, fields.fields.title, fields.fields.description, fields.fields.body, ctx.sessionId ?? null, proposal.sourceSeqs ?? null, ctx.runId ?? (ctx.trigger === "explicit" ? "explicit" : null), link.value, now, now);
112
118
  syncFts(db, ctx.workspaceId, id);
113
119
  files.push({
114
120
  id,
@@ -122,7 +128,7 @@ function applyOne(db, ctx, proposal, stmts, files) {
122
128
  if (existing === undefined) {
123
129
  return fail(proposal, `no memory "${id}" to update — create it instead`);
124
130
  }
125
- stmts.update.run(fields.fields.type, fields.fields.title, fields.fields.description, fields.fields.body, db.now(), ctx.workspaceId, id);
131
+ stmts.update.run(fields.fields.type, fields.fields.title, fields.fields.description, fields.fields.body, link.value, db.now(), ctx.workspaceId, id);
126
132
  syncFts(db, ctx.workspaceId, id);
127
133
  files.push({ id, action, before: existing, after: recordOf(stmts.get.get(ctx.workspaceId, id)) });
128
134
  return { id, action, ok: true };
@@ -131,6 +137,36 @@ function applyOne(db, ctx, proposal, stmts, files) {
131
137
  function fail(proposal, reason) {
132
138
  return { id: proposal.id ?? "", action: proposal.action, ok: false, reason };
133
139
  }
140
+ /**
141
+ * Resolve the correction link: absent (or explicitly empty-checked) proposals
142
+ * carry `null`, which keeps an existing link on update and stores null on
143
+ * create. A named target must be a well-formed id in the same workspace,
144
+ * must exist, must be active, and must not be the row itself.
145
+ */
146
+ function checkCorrects(get, workspaceId, proposal, id) {
147
+ const raw = proposal.corrects;
148
+ if (raw === undefined) {
149
+ return { value: null };
150
+ }
151
+ const target = raw.trim();
152
+ if (target.length === 0) {
153
+ return { reason: "corrects must not be empty — omit it instead" };
154
+ }
155
+ if (!isValidMemoryId(target)) {
156
+ return { reason: `malformed corrects target "${target}"` };
157
+ }
158
+ if (target === id) {
159
+ return { reason: `memory "${id}" cannot correct itself` };
160
+ }
161
+ const row = get.get(workspaceId, target);
162
+ if (row === undefined) {
163
+ return { reason: `no memory "${target}" to correct` };
164
+ }
165
+ if (row.status !== "active") {
166
+ return { reason: `memory "${target}" is not active and cannot be corrected` };
167
+ }
168
+ return { value: target };
169
+ }
134
170
  /** The proposal id, derived from the title (slug, CJK-hash fallback) on create. */
135
171
  function idFor(proposal) {
136
172
  if (proposal.id !== undefined && proposal.id.trim().length > 0) {