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