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/README.md +19 -8
- package/README.zh.md +19 -8
- package/client/client.js +6 -46
- package/lib/card-routes.js +3 -8
- package/lib/extraction-prompt.d.ts +17 -1
- package/lib/extraction-prompt.js +48 -0
- package/lib/extraction.js +37 -5
- package/lib/index.d.ts +34 -0
- package/lib/index.js +45 -0
- package/lib/memory-rules.d.ts +7 -0
- package/lib/memory-snapshot.d.ts +1 -3
- package/lib/memory-snapshot.js +1 -11
- package/lib/memory-tools.js +8 -1
- package/lib/review-prompt.d.ts +55 -0
- package/lib/review-prompt.js +105 -0
- package/lib/review.d.ts +99 -0
- package/lib/review.js +467 -0
- package/lib/skill-tools.js +3 -2
- package/lib/store-apply.d.ts +2 -0
- package/lib/store-apply.js +44 -8
- package/lib/store.d.ts +82 -22
- package/lib/store.js +188 -51
- package/lib/token-usage.d.ts +3 -2
- package/lib/token-usage.js +3 -2
- package/package.json +1 -1
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
|
package/lib/skill-tools.js
CHANGED
|
@@ -117,8 +117,9 @@ export function registerSkillTools(tools, store) {
|
|
|
117
117
|
if (found === undefined) {
|
|
118
118
|
return `没有找到 id 为 "${args.id}" 的技能。`;
|
|
119
119
|
}
|
|
120
|
-
//
|
|
121
|
-
// Search/list hits are noise
|
|
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
|
}
|
package/lib/store-apply.d.ts
CHANGED
|
@@ -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
|
}
|
package/lib/store-apply.js
CHANGED
|
@@ -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) {
|