dsh-continual-evolve 0.15.6 → 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 +6 -46
- package/lib/card-routes.js +3 -8
- 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 +97 -0
- package/lib/review.js +458 -0
- package/lib/skill-tools.js +3 -2
- package/lib/store-apply.js +40 -5
- package/lib/store.d.ts +80 -22
- package/lib/store.js +171 -48
- package/lib/token-usage.d.ts +3 -2
- package/lib/token-usage.js +3 -2
- package/package.json +1 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The content-quality review prompts (the model side of the autonomous
|
|
3
|
+
* evolution loop): a cheap gate that decides whether an item deserves a
|
|
4
|
+
* close look, and the evolve pass that judges content quality dimension by
|
|
5
|
+
* dimension and proposes the rewrite. Code owns every consequence — the
|
|
6
|
+
* model only returns verdict JSON, never touches the store.
|
|
7
|
+
*
|
|
8
|
+
* Memories are judged on five dimensions (accurate, usable, reusable,
|
|
9
|
+
* non-duplicate, fresh); skills on four (description trigger precision,
|
|
10
|
+
* body executability, reusability, non-duplicate). The prompts are
|
|
11
|
+
* deliberately behavior-blind: there are no task datasets here (unlike
|
|
12
|
+
* Hermes GEPA), only the item text plus its provenance.
|
|
13
|
+
*/
|
|
14
|
+
export const REVIEW_CHEAP_SYSTEM_PROMPT = "你是记忆库的内容评审门。只判这条记忆/技能值不值得细看:有明确事实、做法、偏好或可复用规程就批;空话、纯过程记录、一次性的瞬时输出、当轮已可推导的一律拒。只输出 JSON:{\"worthReview\": true|false, \"reason\": \"一句话\"}。";
|
|
15
|
+
/** Memory rubric: accurate, usable, reusable, non-duplicate, fresh. */
|
|
16
|
+
export const REVIEW_MEMORY_SYSTEM_PROMPT = "你是记忆库的内容评审。按五维判这条记忆:①准确(事实可信、无幻觉);②可用(Why/How 具体、可执行,反馈类必须带 Why 与 How to apply);③可复用(未来会话用得上);④不重复(与候选旧条比,有重复就指出来);⑤不过时(未被纠正链取代)。只输出 JSON:{\"verdict\": \"keep|rewrite|quarantine\", \"reason\": \"一句话\", \"title\"?: \"改写后标题\", \"description\"?: \"改写后钩子\", \"body\"?: \"改写后正文全文\", \"corrects\"?: \"被取代的同区条目id\"}。verdict 说明:keep=不动;rewrite=给出改写后全文三件;quarantine=涉密/有害/完全过时。禁删除,禁编造引用。";
|
|
17
|
+
/** Skill rubric: trigger precision, executability, reusability, non-duplicate. */
|
|
18
|
+
export const REVIEW_SKILL_SYSTEM_PROMPT = "你是技能库的内容评审。按四维判这条技能:①触发精度(description 是否让人/模型在该用时想起、不该用时不打扰);②正文可执行完备(步骤完整、输入输出明确);③可复用(跨任务可用);④不重复(与候选既有技能比)。只输出 JSON:{\"verdict\": \"keep|rewrite|quarantine\", \"reason\": \"一句话\", \"title\"?: \"改写后标题\", \"description\"?: \"改写后钩子\", \"body\"?: \"改写后正文全文\"}。verdict 说明:keep=不动;rewrite=给出改写后全文三件;quarantine=涉密/有害/完全过时。禁删除。";
|
|
19
|
+
/** The cheap-gate prompt for one item (id + hook + body, truncated by caller). */
|
|
20
|
+
export function gatePrompt(entry) {
|
|
21
|
+
return [
|
|
22
|
+
`kind=${entry.kind} id=${entry.id}`,
|
|
23
|
+
`title=${entry.title}`,
|
|
24
|
+
`钩子=${entry.description}`,
|
|
25
|
+
"全文:",
|
|
26
|
+
entry.body,
|
|
27
|
+
].join("\n");
|
|
28
|
+
}
|
|
29
|
+
/** The evolve prompt for one item plus its similar neighbors for dup-checking. */
|
|
30
|
+
export function evolvePrompt(entry, neighbors) {
|
|
31
|
+
const lines = [
|
|
32
|
+
`id=${entry.id}`,
|
|
33
|
+
`title=${entry.title}`,
|
|
34
|
+
`钩子=${entry.description}`,
|
|
35
|
+
"全文:",
|
|
36
|
+
entry.body,
|
|
37
|
+
];
|
|
38
|
+
if (entry.extra !== undefined && entry.extra.length > 0) {
|
|
39
|
+
lines.push("", entry.extra);
|
|
40
|
+
}
|
|
41
|
+
if (neighbors.length > 0) {
|
|
42
|
+
lines.push("", "同类候选(判重复用):");
|
|
43
|
+
for (const neighbor of neighbors) {
|
|
44
|
+
lines.push(`- id=${neighbor.id} title=${neighbor.title} 钩子=${neighbor.description}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return lines.join("\n");
|
|
48
|
+
}
|
|
49
|
+
/** Parse the cheap-gate answer; `null` when the model did not return verdict JSON. */
|
|
50
|
+
export function parseGateAnswer(text) {
|
|
51
|
+
const parsed = parseJsonObject(text);
|
|
52
|
+
if (parsed === null || typeof parsed.worthReview !== "boolean") {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
worthReview: parsed.worthReview,
|
|
57
|
+
reason: typeof parsed.reason === "string" && parsed.reason.length > 0 ? parsed.reason.slice(0, 300) : "无理由",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** Parse the evolve answer; `null` when the model did not return verdict JSON. */
|
|
61
|
+
export function parseEvolveAnswer(text) {
|
|
62
|
+
const parsed = parseJsonObject(text);
|
|
63
|
+
if (parsed === null || (parsed.verdict !== "keep" && parsed.verdict !== "rewrite" && parsed.verdict !== "quarantine")) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const verdict = {
|
|
67
|
+
verdict: parsed.verdict,
|
|
68
|
+
reason: typeof parsed.reason === "string" && parsed.reason.length > 0 ? parsed.reason.slice(0, 300) : "无理由",
|
|
69
|
+
};
|
|
70
|
+
if (typeof parsed.title === "string" && parsed.title.length > 0) {
|
|
71
|
+
verdict.title = parsed.title.slice(0, 500);
|
|
72
|
+
}
|
|
73
|
+
if (typeof parsed.description === "string" && parsed.description.length > 0) {
|
|
74
|
+
verdict.description = parsed.description.slice(0, 1000);
|
|
75
|
+
}
|
|
76
|
+
if (typeof parsed.body === "string" && parsed.body.length > 0) {
|
|
77
|
+
verdict.body = parsed.body.slice(0, 65536);
|
|
78
|
+
}
|
|
79
|
+
if (typeof parsed.corrects === "string" && parsed.corrects.length > 0) {
|
|
80
|
+
verdict.corrects = parsed.corrects.slice(0, 200);
|
|
81
|
+
}
|
|
82
|
+
return verdict;
|
|
83
|
+
}
|
|
84
|
+
/** Extract the first JSON object from model output (fenced or bare). */
|
|
85
|
+
function parseJsonObject(text) {
|
|
86
|
+
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
87
|
+
const candidate = (fenced?.[1] ?? text).trim();
|
|
88
|
+
const start = candidate.indexOf("{");
|
|
89
|
+
const end = candidate.lastIndexOf("}");
|
|
90
|
+
if (start === -1 || end <= start) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(candidate.slice(start, end + 1));
|
|
95
|
+
/* v8 ignore next 3 — the slice always starts with `{`, so a
|
|
96
|
+
successful parse is an object by construction; the guard stays. */
|
|
97
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
98
|
+
? parsed
|
|
99
|
+
: null;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=review-prompt.js.map
|
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-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
|
}
|