dsh-continual-evolve 0.1.1 → 0.3.0
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 +172 -17
- package/README.zh.md +87 -10
- package/lib/apply.js +3 -1
- package/lib/approval.d.ts +25 -0
- package/lib/approval.js +9 -1
- package/lib/auto.d.ts +99 -5
- package/lib/auto.js +165 -6
- package/lib/benchmark-command.d.ts +9 -0
- package/lib/benchmark-command.js +331 -0
- package/lib/benchmark.d.ts +84 -0
- package/lib/benchmark.js +107 -1
- package/lib/command.js +33 -221
- package/lib/evaluate.d.ts +43 -7
- package/lib/evaluate.js +172 -43
- package/lib/evolve-event.d.ts +38 -0
- package/lib/evolve-event.js +49 -0
- package/lib/failures.d.ts +39 -0
- package/lib/failures.js +170 -0
- package/lib/fate.d.ts +128 -0
- package/lib/fate.js +342 -0
- package/lib/goal-command.d.ts +7 -0
- package/lib/goal-command.js +37 -0
- package/lib/index.d.ts +51 -21
- package/lib/index.js +32 -2
- package/lib/inject.d.ts +8 -0
- package/lib/inject.js +51 -4
- package/lib/llm-text.d.ts +30 -0
- package/lib/llm-text.js +49 -0
- package/lib/mount-command.d.ts +10 -0
- package/lib/mount-command.js +48 -0
- package/lib/mount.js +5 -0
- package/lib/plan.js +5 -0
- package/lib/planner.d.ts +8 -1
- package/lib/planner.js +40 -39
- package/lib/render.d.ts +1 -3
- package/lib/render.js +2 -5
- package/lib/review.d.ts +5 -2
- package/lib/review.js +27 -38
- package/lib/rollback.d.ts +1 -3
- package/lib/rollback.js +0 -8
- package/lib/score.d.ts +37 -4
- package/lib/score.js +120 -10
- package/lib/service.d.ts +2 -2
- package/lib/service.js +5 -2
- package/lib/skill-render.d.ts +15 -0
- package/lib/skill-render.js +30 -0
- package/lib/skill.d.ts +12 -7
- package/lib/skill.js +36 -31
- package/lib/skillquality.d.ts +80 -0
- package/lib/skillquality.js +311 -0
- package/lib/store.d.ts +1 -3
- package/lib/store.js +0 -7
- package/lib/tool.js +28 -4
- package/lib/types.d.ts +39 -0
- package/lib/types.js +19 -0
- package/lib/usage.d.ts +32 -0
- package/lib/usage.js +84 -0
- package/lib/validate.d.ts +12 -2
- package/lib/validate.js +51 -2
- package/lib/wrapup-command.d.ts +8 -0
- package/lib/wrapup-command.js +211 -0
- package/lib/wrapup.d.ts +215 -0
- package/lib/wrapup.js +427 -0
- package/package.json +8 -8
package/lib/fate.js
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
2
|
+
import { questionServiceOf } from "./approval.js";
|
|
3
|
+
import { assessLocalEntries, candidateKey, filterPromotable, listLocalCandidates, splitArchiveGuards, splitPromoteBlocked, splitPromoteProposals, wholePromoteProposals, } from "./wrapup.js";
|
|
4
|
+
/** Turns a declined local-fate proposal stays silent before being offered again. */
|
|
5
|
+
export const FATE_CONSULT_COOLDOWN_TURNS = 10;
|
|
6
|
+
/**
|
|
7
|
+
* Partition an assessed wrap-up classification into concrete fate actions,
|
|
8
|
+
* re-running the deterministic guards against the LIVE global store (state
|
|
9
|
+
* may have changed while the LLM call was in flight). Pure and unit-tested;
|
|
10
|
+
* mirrors the partition step of the wrap-up command.
|
|
11
|
+
*/
|
|
12
|
+
export function planLocalFates(items, candidates, globalState) {
|
|
13
|
+
const byKey = new Map(candidates.map((candidate) => [candidateKey(candidate.kind, candidate.id), candidate]));
|
|
14
|
+
const { promotable, skipped } = filterPromotable(items, globalState, candidates);
|
|
15
|
+
const promoteItems = promotable.filter((item) => item.verdict === "promote");
|
|
16
|
+
const archiveItems = items.filter((item) => item.verdict === "archive");
|
|
17
|
+
const splits = [];
|
|
18
|
+
const splitSkipped = [];
|
|
19
|
+
for (const item of archiveItems) {
|
|
20
|
+
if (!item.promote)
|
|
21
|
+
continue;
|
|
22
|
+
const candidate = byKey.get(item.key);
|
|
23
|
+
if (!candidate) {
|
|
24
|
+
splitSkipped.push({ key: item.key, reason: "not in the audited candidate list" });
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const blocked = splitPromoteBlocked(item, globalState, candidate.kind);
|
|
28
|
+
if (blocked) {
|
|
29
|
+
splitSkipped.push({ key: item.key, reason: blocked });
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
splits.push({ item, candidate });
|
|
33
|
+
}
|
|
34
|
+
const plainArchives = archiveItems.filter((item) => !item.promote);
|
|
35
|
+
const { silent: silentArchives, review: reviewArchives } = splitArchiveGuards(plainArchives, candidates);
|
|
36
|
+
return { candidates, promotable: promoteItems, splits, silentArchives, reviewArchives, skipped, splitSkipped };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The cooldown key of a candidate set: the sorted `kind:id` list. The set is
|
|
40
|
+
* the unit of consultation — a declined proposal is not offered again within
|
|
41
|
+
* the cooldown window, and a changed set (new entries appeared) starts a
|
|
42
|
+
* fresh consultation.
|
|
43
|
+
*/
|
|
44
|
+
export function fateSetKey(candidates) {
|
|
45
|
+
return candidates
|
|
46
|
+
.map((candidate) => candidateKey(candidate.kind, candidate.id))
|
|
47
|
+
.sort()
|
|
48
|
+
.join("|");
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Whether the local-fate dimension is due for this gate run. Turn-interval
|
|
52
|
+
* gates respect the fate cadence (an independent counter — goal-driven
|
|
53
|
+
* sessions run the review EVERY round, the fate assessment must not);
|
|
54
|
+
* compaction is unconditional: experiences about to be summarized away get
|
|
55
|
+
* their fate check regardless. Goal-blocked assessments are unconditional
|
|
56
|
+
* here too — the gate's own streak counter (auto.ts runGoalBlockedFate)
|
|
57
|
+
* already gates their frequency, so the cadence must not re-block them.
|
|
58
|
+
*/
|
|
59
|
+
export function fateCadenceDue(state, reason, intervalTurns) {
|
|
60
|
+
// Compact and goal-blocked assessments bypass the cadence (see header).
|
|
61
|
+
if (reason === "compact" || reason === "goal_blocked")
|
|
62
|
+
return true;
|
|
63
|
+
return state.turns - state.lastFateAt >= intervalTurns;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Ask the user whether to execute the gate's local-fate proposal. ONE dialog
|
|
67
|
+
* covers every governed action (promotes, split promotions, review-required
|
|
68
|
+
* archives) — the gate never spams questions. Conservative on every edge:
|
|
69
|
+
* no question service → not approved; the question call fails → not approved;
|
|
70
|
+
* the same candidate set was declined within the cooldown → not asked again.
|
|
71
|
+
* A decline records the cooldown (the consultSkillEdits pattern).
|
|
72
|
+
*/
|
|
73
|
+
export async function consultLocalFates(ctx, agent, plan, gate) {
|
|
74
|
+
const needsDialog = plan.promotable.length + plan.splits.length + plan.reviewArchives.length > 0;
|
|
75
|
+
if (!needsDialog) {
|
|
76
|
+
return { approved: true, asked: false, reason: "nothing-to-ask" };
|
|
77
|
+
}
|
|
78
|
+
const setKey = fateSetKey(plan.candidates);
|
|
79
|
+
const lastReject = gate.fateRejects.get(setKey);
|
|
80
|
+
if (lastReject !== undefined && gate.turns - lastReject < FATE_CONSULT_COOLDOWN_TURNS) {
|
|
81
|
+
return { approved: false, asked: false, reason: "cooldown" };
|
|
82
|
+
}
|
|
83
|
+
const userQuestions = questionServiceOf(ctx);
|
|
84
|
+
if (!userQuestions) {
|
|
85
|
+
return { approved: false, asked: false, reason: "unavailable" };
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const answer = await userQuestions.ask({
|
|
89
|
+
questions: [
|
|
90
|
+
{
|
|
91
|
+
id: "evolve-fate-consult",
|
|
92
|
+
question: consultQuestion(plan),
|
|
93
|
+
options: [{ label: "执行" }, { label: "不执行" }],
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
agent,
|
|
97
|
+
});
|
|
98
|
+
const approved = answer.answers?.find((entry) => entry.id === "evolve-fate-consult")?.selected?.includes("执行") ?? false;
|
|
99
|
+
if (!approved) {
|
|
100
|
+
gate.fateRejects.set(setKey, gate.turns);
|
|
101
|
+
}
|
|
102
|
+
return { approved, asked: true, reason: approved ? "consented" : "declined" };
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return { approved: false, asked: true, reason: "error" };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** The user-visible fate proposal: every governed action, with real titles. */
|
|
109
|
+
function consultQuestion(plan) {
|
|
110
|
+
const byKey = new Map(plan.candidates.map((candidate) => [candidateKey(candidate.kind, candidate.id), candidate]));
|
|
111
|
+
const lines = [];
|
|
112
|
+
if (plan.promotable.length > 0 || plan.splits.length > 0) {
|
|
113
|
+
lines.push("【提升到跨会话全局 store】");
|
|
114
|
+
for (const item of plan.promotable) {
|
|
115
|
+
lines.push(`- ${item.key}「${byKey.get(item.key)?.title ?? item.key}」 — ${item.reason}`);
|
|
116
|
+
}
|
|
117
|
+
for (const { item } of plan.splits) {
|
|
118
|
+
lines.push(`- ${item.key} → 拆出提升「${item.promote?.title}」(原条目随之归档)`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (plan.reviewArchives.length > 0) {
|
|
122
|
+
lines.push("【归档(未被全局覆盖且源自真实对话,需确认)】");
|
|
123
|
+
for (const item of plan.reviewArchives) {
|
|
124
|
+
lines.push(`- ${item.key}「${byKey.get(item.key)?.title ?? item.key}」 — ${item.reason}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return [
|
|
128
|
+
"自进化门禁检测到本会话的 local 条目需要归宿处理(提升条目将写入跨会话全局 store,归档条目隐藏但可恢复):",
|
|
129
|
+
...lines,
|
|
130
|
+
"是否执行?",
|
|
131
|
+
].join("\n");
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Deterministically apply the fate plan. `"full"` applies everything
|
|
135
|
+
* (promotes, splits, silent + review archives); `"silent-only"` applies only
|
|
136
|
+
* the deterministic silent archives (the compaction path — nothing governed).
|
|
137
|
+
* Promotes go through the SAME proposals as the wrap-up command
|
|
138
|
+
* (wholePromoteProposals / splitPromoteProposals), so both paths write
|
|
139
|
+
* identical global entries and local retirement stamps.
|
|
140
|
+
*/
|
|
141
|
+
export function applyLocalFates(engine, sessionId, plan, localState, mode) {
|
|
142
|
+
const applied = [];
|
|
143
|
+
const results = [];
|
|
144
|
+
const byKey = new Map(plan.candidates.map((candidate) => [candidateKey(candidate.kind, candidate.id), candidate]));
|
|
145
|
+
if (mode === "full") {
|
|
146
|
+
for (const item of plan.promotable) {
|
|
147
|
+
const candidate = byKey.get(item.key);
|
|
148
|
+
if (!candidate)
|
|
149
|
+
continue;
|
|
150
|
+
const proposals = wholePromoteProposals(item, candidate, sessionId);
|
|
151
|
+
const globalResult = engine.apply("global", undefined, proposals.global, { scope: "global" });
|
|
152
|
+
const createdId = globalResult.appliedEdits.find((edit) => edit.applied)?.id ?? candidate.id;
|
|
153
|
+
const localResult = engine.apply("local", sessionId, proposals.localStamp(createdId), {
|
|
154
|
+
scope: "local",
|
|
155
|
+
baselineState: localState,
|
|
156
|
+
});
|
|
157
|
+
results.push(globalResult, localResult);
|
|
158
|
+
applied.push(`提升 ${item.key} → 全局 store(global:${createdId},本地副本已归档)`);
|
|
159
|
+
}
|
|
160
|
+
for (const { item, candidate } of plan.splits) {
|
|
161
|
+
if (!item.promote)
|
|
162
|
+
continue;
|
|
163
|
+
const proposals = splitPromoteProposals(item, candidate, sessionId);
|
|
164
|
+
const globalResult = engine.apply("global", undefined, proposals.global, { scope: "global" });
|
|
165
|
+
const createdId = globalResult.appliedEdits.find((edit) => edit.applied)?.id ?? candidate.id;
|
|
166
|
+
const localResult = engine.apply("local", sessionId, proposals.localStamp(createdId), {
|
|
167
|
+
scope: "local",
|
|
168
|
+
baselineState: localState,
|
|
169
|
+
});
|
|
170
|
+
results.push(globalResult, localResult);
|
|
171
|
+
applied.push(`拆解 ${item.key} → 清洗「${item.promote.title}」入全局(原条目已归档)`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const archives = mode === "full" ? [...plan.silentArchives, ...plan.reviewArchives] : plan.silentArchives;
|
|
175
|
+
for (const item of archives) {
|
|
176
|
+
const candidate = byKey.get(item.key);
|
|
177
|
+
if (!candidate)
|
|
178
|
+
continue;
|
|
179
|
+
const proposal = {
|
|
180
|
+
summary: `gate fate: archive local ${item.key} — ${item.reason}`,
|
|
181
|
+
rationale: item.reason,
|
|
182
|
+
expectedOutcome: "The entry stops being injected but stays restorable.",
|
|
183
|
+
edits: [{ action: "archive", kind: candidate.kind, id: candidate.id }],
|
|
184
|
+
};
|
|
185
|
+
const result = engine.apply("local", sessionId, proposal, { scope: "local", baselineState: localState });
|
|
186
|
+
results.push(result);
|
|
187
|
+
applied.push(`归档 ${item.key}「${candidate.title}」`);
|
|
188
|
+
}
|
|
189
|
+
return { applied, results };
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* The gate's local-fate phase. Runs after the review phase on every gate
|
|
193
|
+
* trigger (turn_interval / compact), subject to cadence and cooldown. All
|
|
194
|
+
* failures are contained and recorded — a broken fate dimension never
|
|
195
|
+
* disturbs the agent loop.
|
|
196
|
+
*/
|
|
197
|
+
export async function runLocalFatePhase(ctx, engine, agent, config, state, reason, record) {
|
|
198
|
+
if (!config.localFate)
|
|
199
|
+
return;
|
|
200
|
+
const logger = ctx.logger("continual-evolve");
|
|
201
|
+
const sessionId = agent.id;
|
|
202
|
+
const localState = engine.load("local", sessionId);
|
|
203
|
+
const globalState = engine.load("global", undefined);
|
|
204
|
+
const candidates = listLocalCandidates(localState, globalState, engine.baseDir);
|
|
205
|
+
if (candidates.length === 0)
|
|
206
|
+
return;
|
|
207
|
+
if (!fateCadenceDue(state, reason, config.fateIntervalTurns))
|
|
208
|
+
return;
|
|
209
|
+
const setKey = fateSetKey(candidates);
|
|
210
|
+
const lastReject = state.fateRejects.get(setKey);
|
|
211
|
+
if (lastReject !== undefined && state.turns - lastReject < FATE_CONSULT_COOLDOWN_TURNS) {
|
|
212
|
+
logger.info(`auto-review local-fate skipped [${sessionId}]: candidate set declined ${state.turns - lastReject} turns ago (cooldown)`);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const turnsSinceFate = state.turns - state.lastFateAt;
|
|
216
|
+
state.lastFateAt = state.turns;
|
|
217
|
+
let assessment;
|
|
218
|
+
try {
|
|
219
|
+
assessment = await assessLocalEntries(ctx, agent, candidates);
|
|
220
|
+
}
|
|
221
|
+
catch (cause) {
|
|
222
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
223
|
+
logger.warn(`auto-review local-fate failed for ${sessionId}: ${message}`);
|
|
224
|
+
record({
|
|
225
|
+
sessionId,
|
|
226
|
+
reason,
|
|
227
|
+
turnsSinceLastReview: turnsSinceFate,
|
|
228
|
+
outcome: "failed",
|
|
229
|
+
rationale: `fate assessment error: ${message}`,
|
|
230
|
+
});
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const plan = planLocalFates(assessment.items, candidates, globalState);
|
|
234
|
+
const needsDialog = plan.promotable.length + plan.splits.length + plan.reviewArchives.length > 0;
|
|
235
|
+
let consent = { approved: false, asked: false, reason: "nothing-to-ask" };
|
|
236
|
+
if (reason !== "compact" && needsDialog) {
|
|
237
|
+
consent = await consultLocalFates(ctx, agent, plan, state);
|
|
238
|
+
}
|
|
239
|
+
if (consent.approved) {
|
|
240
|
+
const { applied, results } = applyLocalFates(engine, sessionId, plan, localState, "full");
|
|
241
|
+
logger.info(`auto-review local-fate approved (${reason}) [${sessionId}]: ${plan.promotable.length} promoted, ${plan.splits.length} split, ${plan.reviewArchives.length} review-archived, ${plan.silentArchives.length} silent-archived — ${assessment.rationale}`);
|
|
242
|
+
record({
|
|
243
|
+
sessionId,
|
|
244
|
+
reason,
|
|
245
|
+
turnsSinceLastReview: turnsSinceFate,
|
|
246
|
+
outcome: "approved",
|
|
247
|
+
rationale: `fate: ${assessment.rationale} (${applied.join("; ")})`,
|
|
248
|
+
refinementId: results.map((result) => result.id).join(","),
|
|
249
|
+
});
|
|
250
|
+
if (config.notifyOnAutoReview && reason === "turn_interval" && applied.length > 0) {
|
|
251
|
+
notifyFateApplied(ctx, agent, applied);
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (consent.reason === "declined" || consent.reason === "unavailable" || consent.reason === "error") {
|
|
256
|
+
const withheld = consent.reason;
|
|
257
|
+
const outcome = withheld === "declined" ? "declined" : "deferred";
|
|
258
|
+
logger.info(`auto-review local-fate ${outcome} (${reason}) [${sessionId}]: ${plan.promotable.length} promotes, ${plan.splits.length} splits, ${plan.reviewArchives.length} review-archives withheld — ${assessment.rationale}`);
|
|
259
|
+
record({
|
|
260
|
+
sessionId,
|
|
261
|
+
reason,
|
|
262
|
+
turnsSinceLastReview: turnsSinceFate,
|
|
263
|
+
outcome,
|
|
264
|
+
rationale: `fate ${withheld}: ${assessment.rationale} (${plan.promotable.length} promotes, ${plan.splits.length} splits, ${plan.reviewArchives.length} review-archives withheld)`,
|
|
265
|
+
});
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (reason === "compact" && needsDialog) {
|
|
269
|
+
// Compaction: no dialog. Only deterministic silent archives apply;
|
|
270
|
+
// governed actions are deferred with an audit record.
|
|
271
|
+
const { applied, results } = applyLocalFates(engine, sessionId, plan, localState, "silent-only");
|
|
272
|
+
if (applied.length > 0) {
|
|
273
|
+
logger.info(`auto-review local-fate (compact) [${sessionId}]: silent-archived ${applied.length} — ${assessment.rationale}`);
|
|
274
|
+
record({
|
|
275
|
+
sessionId,
|
|
276
|
+
reason,
|
|
277
|
+
turnsSinceLastReview: turnsSinceFate,
|
|
278
|
+
outcome: "approved",
|
|
279
|
+
rationale: `fate (compact): ${assessment.rationale} (${applied.join("; ")})`,
|
|
280
|
+
refinementId: results.map((result) => result.id).join(","),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
record({
|
|
284
|
+
sessionId,
|
|
285
|
+
reason,
|
|
286
|
+
turnsSinceLastReview: turnsSinceFate,
|
|
287
|
+
outcome: "deferred",
|
|
288
|
+
rationale: `fate (compact): ${plan.promotable.length} promotes, ${plan.splits.length} splits, ${plan.reviewArchives.length} review-archives deferred — run /evolve wrapup for a full session exit`,
|
|
289
|
+
});
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
// Silent archives on a plain turn-interval gate with nothing to ask.
|
|
293
|
+
if (plan.silentArchives.length > 0) {
|
|
294
|
+
const { applied, results } = applyLocalFates(engine, sessionId, plan, localState, "silent-only");
|
|
295
|
+
logger.info(`auto-review local-fate (${reason}) [${sessionId}]: silent-archived ${applied.length} covered/operational entries — ${assessment.rationale}`);
|
|
296
|
+
record({
|
|
297
|
+
sessionId,
|
|
298
|
+
reason,
|
|
299
|
+
turnsSinceLastReview: turnsSinceFate,
|
|
300
|
+
outcome: "approved",
|
|
301
|
+
rationale: `fate: ${assessment.rationale} (${applied.join("; ")})`,
|
|
302
|
+
refinementId: results.map((result) => result.id).join(","),
|
|
303
|
+
});
|
|
304
|
+
if (config.notifyOnAutoReview && reason === "turn_interval") {
|
|
305
|
+
notifyFateApplied(ctx, agent, applied);
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
// Assessed, nothing to do.
|
|
310
|
+
logger.info(`auto-review local-fate assessed (${reason}) [${sessionId}]: ${candidates.length} candidates, no action — ${assessment.rationale}`);
|
|
311
|
+
record({
|
|
312
|
+
sessionId,
|
|
313
|
+
reason,
|
|
314
|
+
turnsSinceLastReview: turnsSinceFate,
|
|
315
|
+
outcome: "assessed",
|
|
316
|
+
rationale: `fate: ${assessment.rationale} (${candidates.length} candidates, no action)`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
/** The user-visible notice after the gate applied local-fate actions. */
|
|
320
|
+
export function buildFateNotice(applied) {
|
|
321
|
+
return [
|
|
322
|
+
"🔎 自动进化门禁:本会话 local 条目归宿处理完成:",
|
|
323
|
+
applied.map((line) => `- ${line}`).join("\n"),
|
|
324
|
+
"查看全部条目:/evolve list;撤销:/evolve rollback <refinement id>",
|
|
325
|
+
"请用一句话简短确认即可,不要调用任何工具。",
|
|
326
|
+
].join("\n");
|
|
327
|
+
}
|
|
328
|
+
/** Queue the follow-up notice turn (turn_interval only, like the review notice). */
|
|
329
|
+
function notifyFateApplied(ctx, agent, applied) {
|
|
330
|
+
try {
|
|
331
|
+
agent.followup(createUserMessage({
|
|
332
|
+
content: [{ type: "text", text: buildFateNotice(applied) }],
|
|
333
|
+
source: { kind: "plugin", plugin: "dsh-continual-evolve" },
|
|
334
|
+
}));
|
|
335
|
+
}
|
|
336
|
+
catch (cause) {
|
|
337
|
+
ctx
|
|
338
|
+
.logger("continual-evolve")
|
|
339
|
+
.warn(`local-fate notice failed for ${agent.id}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
//# sourceMappingURL=fate.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/evolve goal` subcommand handler. Extracted from command.ts (P2-2).
|
|
3
|
+
*/
|
|
4
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
5
|
+
import type { CommandInvocation, CommandResult } from "@deepseek-ai/dsh-commands";
|
|
6
|
+
export declare function executeGoalCommand(ctx: Context, invocation: CommandInvocation, rest: string[]): CommandResult;
|
|
7
|
+
//# sourceMappingURL=goal-command.d.ts.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { blockEvolutionGoal, completeEvolutionGoal, goalServiceOf, goalStatusText, upsertEvolutionGoal } from "./goal.js";
|
|
2
|
+
function success(text) {
|
|
3
|
+
return { kind: "success", text };
|
|
4
|
+
}
|
|
5
|
+
function error(text) {
|
|
6
|
+
return { kind: "error", text };
|
|
7
|
+
}
|
|
8
|
+
export function executeGoalCommand(ctx, invocation, rest) {
|
|
9
|
+
const agent = invocation.agent;
|
|
10
|
+
const goals = goalServiceOf(ctx);
|
|
11
|
+
if (!goals) {
|
|
12
|
+
return error(`/evolve goal requires the goals service (load @deepseek-ai/dsh-goal)`);
|
|
13
|
+
}
|
|
14
|
+
const sub = rest[0] ?? "";
|
|
15
|
+
try {
|
|
16
|
+
if (sub === "done") {
|
|
17
|
+
const view = completeEvolutionGoal(ctx, agent);
|
|
18
|
+
return view ? success(`evolution goal completed: ${goalStatusText(view)}`) : success("(no goal to complete)");
|
|
19
|
+
}
|
|
20
|
+
if (sub === "block") {
|
|
21
|
+
const reason = rest.slice(1).join(" ") || "user requested block";
|
|
22
|
+
const view = blockEvolutionGoal(ctx, agent, reason);
|
|
23
|
+
return view ? success(`evolution goal blocked: ${goalStatusText(view)}`) : success("(no active goal to block)");
|
|
24
|
+
}
|
|
25
|
+
if (sub.length === 0) {
|
|
26
|
+
const current = goals.get(agent);
|
|
27
|
+
return current ? success(goalStatusText(current)) : success("(no evolution goal — /evolve goal <objective> to create one)");
|
|
28
|
+
}
|
|
29
|
+
const objective = rest.join(" ");
|
|
30
|
+
const view = upsertEvolutionGoal(ctx, agent, objective);
|
|
31
|
+
return success(`evolution goal ready: ${goalStatusText(view)}\n(active goal drives the review gate every round)`);
|
|
32
|
+
}
|
|
33
|
+
catch (cause) {
|
|
34
|
+
return error(cause instanceof Error ? cause.message : String(cause));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=goal-command.js.map
|
package/lib/index.d.ts
CHANGED
|
@@ -34,6 +34,29 @@ export declare const Config: z<Schemastery.ObjectS<{
|
|
|
34
34
|
logMaxBytes: z<number, number>;
|
|
35
35
|
/** After a benchmark decision rejects a candidate, roll the refinement back automatically. */
|
|
36
36
|
autoRollbackOnReject: z<boolean, boolean>;
|
|
37
|
+
/**
|
|
38
|
+
* Gap C1: optional model override for the review gate (cheaper model).
|
|
39
|
+
* Format: "provider/model" or just "model" (same provider as the agent).
|
|
40
|
+
* When absent, the review gate uses the agent's own provider/model.
|
|
41
|
+
*/
|
|
42
|
+
reviewModel: z<string, string>;
|
|
43
|
+
/**
|
|
44
|
+
* Gate local-fate dimension (#11 P2): the gate audits the session's local
|
|
45
|
+
* entries on its own cadence and proposes promote/archive — consulted
|
|
46
|
+
* first, never written silently. Only meaningful with autoReview on.
|
|
47
|
+
*/
|
|
48
|
+
localFate: z<boolean, boolean>;
|
|
49
|
+
/**
|
|
50
|
+
* Minimum turns between local-fate assessments on the turn-interval path
|
|
51
|
+
* (compaction is unconditional). Absent → follows reviewIntervalTurns.
|
|
52
|
+
*/
|
|
53
|
+
fateIntervalTurns: z<number, number>;
|
|
54
|
+
/**
|
|
55
|
+
* Goal-blocked trigger (D3): after this many CONSECUTIVE gate runs that
|
|
56
|
+
* observe the session goal in phase "blocked", run one local-fate
|
|
57
|
+
* assessment so the encounter is distilled. 0 disables.
|
|
58
|
+
*/
|
|
59
|
+
goalBlockedWrapupTurns: z<number, number>;
|
|
37
60
|
}>, Schemastery.ObjectT<{
|
|
38
61
|
/** Root for evolution stores; defaults to the resolved DSH home. */
|
|
39
62
|
baseDir: z<string, string>;
|
|
@@ -63,28 +86,35 @@ export declare const Config: z<Schemastery.ObjectS<{
|
|
|
63
86
|
logMaxBytes: z<number, number>;
|
|
64
87
|
/** After a benchmark decision rejects a candidate, roll the refinement back automatically. */
|
|
65
88
|
autoRollbackOnReject: z<boolean, boolean>;
|
|
89
|
+
/**
|
|
90
|
+
* Gap C1: optional model override for the review gate (cheaper model).
|
|
91
|
+
* Format: "provider/model" or just "model" (same provider as the agent).
|
|
92
|
+
* When absent, the review gate uses the agent's own provider/model.
|
|
93
|
+
*/
|
|
94
|
+
reviewModel: z<string, string>;
|
|
95
|
+
/**
|
|
96
|
+
* Gate local-fate dimension (#11 P2): the gate audits the session's local
|
|
97
|
+
* entries on its own cadence and proposes promote/archive — consulted
|
|
98
|
+
* first, never written silently. Only meaningful with autoReview on.
|
|
99
|
+
*/
|
|
100
|
+
localFate: z<boolean, boolean>;
|
|
101
|
+
/**
|
|
102
|
+
* Minimum turns between local-fate assessments on the turn-interval path
|
|
103
|
+
* (compaction is unconditional). Absent → follows reviewIntervalTurns.
|
|
104
|
+
*/
|
|
105
|
+
fateIntervalTurns: z<number, number>;
|
|
106
|
+
/**
|
|
107
|
+
* Goal-blocked trigger (D3): after this many CONSECUTIVE gate runs that
|
|
108
|
+
* observe the session goal in phase "blocked", run one local-fate
|
|
109
|
+
* assessment so the encounter is distilled. 0 disables.
|
|
110
|
+
*/
|
|
111
|
+
goalBlockedWrapupTurns: z<number, number>;
|
|
66
112
|
}>>;
|
|
67
|
-
/**
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
reviewIntervalTurns?: number;
|
|
73
|
-
maxReviewInputChars?: number;
|
|
74
|
-
reviewBudgetTokens?: number;
|
|
75
|
-
notifyOnAutoReview?: boolean;
|
|
76
|
-
requireGlobalApproval?: boolean;
|
|
77
|
-
skillsDir?: string;
|
|
78
|
-
rubricKey?: string;
|
|
79
|
-
/** Write all cordis log messages to <baseDir>/evolve/plugin.log (JSONL). */
|
|
80
|
-
logToFile?: boolean;
|
|
81
|
-
/** File log level: 0=error, 1=info, 2=warn, 3=debug. */
|
|
82
|
-
logLevel?: number;
|
|
83
|
-
/** Rotate the file log when it exceeds this many bytes. */
|
|
84
|
-
logMaxBytes?: number;
|
|
85
|
-
/** After a benchmark decision rejects a candidate, roll the refinement back automatically. */
|
|
86
|
-
autoRollbackOnReject?: boolean;
|
|
87
|
-
}
|
|
113
|
+
/**
|
|
114
|
+
* Structurally typed resolved config (loader passes the validated object).
|
|
115
|
+
* Derived from the schemastery schema — single source of truth, no manual sync.
|
|
116
|
+
*/
|
|
117
|
+
export type EvolveConfig = Partial<Schemastery.TypeT<typeof Config>>;
|
|
88
118
|
export interface EvolutionService {
|
|
89
119
|
readonly engine: EvolutionEngine;
|
|
90
120
|
readonly baseDir: string;
|
package/lib/index.js
CHANGED
|
@@ -52,6 +52,29 @@ export const Config = z.object({
|
|
|
52
52
|
logMaxBytes: z.natural().default(5 * 1024 * 1024),
|
|
53
53
|
/** After a benchmark decision rejects a candidate, roll the refinement back automatically. */
|
|
54
54
|
autoRollbackOnReject: z.boolean().default(true),
|
|
55
|
+
/**
|
|
56
|
+
* Gap C1: optional model override for the review gate (cheaper model).
|
|
57
|
+
* Format: "provider/model" or just "model" (same provider as the agent).
|
|
58
|
+
* When absent, the review gate uses the agent's own provider/model.
|
|
59
|
+
*/
|
|
60
|
+
reviewModel: z.string(),
|
|
61
|
+
/**
|
|
62
|
+
* Gate local-fate dimension (#11 P2): the gate audits the session's local
|
|
63
|
+
* entries on its own cadence and proposes promote/archive — consulted
|
|
64
|
+
* first, never written silently. Only meaningful with autoReview on.
|
|
65
|
+
*/
|
|
66
|
+
localFate: z.boolean().default(true),
|
|
67
|
+
/**
|
|
68
|
+
* Minimum turns between local-fate assessments on the turn-interval path
|
|
69
|
+
* (compaction is unconditional). Absent → follows reviewIntervalTurns.
|
|
70
|
+
*/
|
|
71
|
+
fateIntervalTurns: z.natural(),
|
|
72
|
+
/**
|
|
73
|
+
* Goal-blocked trigger (D3): after this many CONSECUTIVE gate runs that
|
|
74
|
+
* observe the session goal in phase "blocked", run one local-fate
|
|
75
|
+
* assessment so the encounter is distilled. 0 disables.
|
|
76
|
+
*/
|
|
77
|
+
goalBlockedWrapupTurns: z.natural().min(0).default(3),
|
|
55
78
|
});
|
|
56
79
|
export function apply(ctx, config) {
|
|
57
80
|
const baseDir = resolveDshHome(config.baseDir);
|
|
@@ -59,7 +82,10 @@ export function apply(ctx, config) {
|
|
|
59
82
|
const engine = createEvolutionEngine(baseDir, {
|
|
60
83
|
onApplied: (result) => {
|
|
61
84
|
try {
|
|
62
|
-
syncSkillsFromResult(skillsRoot, result);
|
|
85
|
+
const warnings = syncSkillsFromResult(skillsRoot, result);
|
|
86
|
+
for (const warning of warnings) {
|
|
87
|
+
ctx.logger("continual-evolve").warn(warning);
|
|
88
|
+
}
|
|
63
89
|
}
|
|
64
90
|
catch (cause) {
|
|
65
91
|
ctx
|
|
@@ -108,8 +134,12 @@ export function apply(ctx, config) {
|
|
|
108
134
|
maxInputChars: config.maxReviewInputChars ?? 40000,
|
|
109
135
|
budgetTokens: config.reviewBudgetTokens ?? 4096,
|
|
110
136
|
notifyOnAutoReview: config.notifyOnAutoReview ?? true,
|
|
137
|
+
localFate: config.localFate ?? true,
|
|
138
|
+
fateIntervalTurns: config.fateIntervalTurns ?? config.reviewIntervalTurns ?? 6,
|
|
139
|
+
goalBlockedWrapupTurns: config.goalBlockedWrapupTurns ?? 3,
|
|
140
|
+
...(config.reviewModel ? { reviewModel: config.reviewModel } : {}),
|
|
111
141
|
});
|
|
112
|
-
ctx.logger("continual-evolve").info(`continual-evolve auto-review enabled (every ${config.reviewIntervalTurns ?? 6} turns)`);
|
|
142
|
+
ctx.logger("continual-evolve").info(`continual-evolve auto-review enabled (every ${config.reviewIntervalTurns ?? 6} turns; local-fate ${config.localFate ?? true ? "on" : "off"} every ${config.fateIntervalTurns ?? config.reviewIntervalTurns ?? 6} turns)`);
|
|
113
143
|
}
|
|
114
144
|
ctx.logger("continual-evolve").info(`continual-evolve mounted (baseDir=${baseDir})`);
|
|
115
145
|
}
|
package/lib/inject.d.ts
CHANGED
|
@@ -103,6 +103,14 @@ export declare function hasAnyEntries(state: HarnessState): boolean;
|
|
|
103
103
|
export declare function formatPromptEntriesSection(entries: readonly HarnessEntry[], query?: string): string;
|
|
104
104
|
/** The reusable delegation-specs block (empty when there are no visible subagent entries). */
|
|
105
105
|
export declare function formatSubagentSpecsSection(entries: readonly HarnessEntry[], query?: string): string;
|
|
106
|
+
/**
|
|
107
|
+
* Gap B3: a lightweight directory of ALL non-archived entries across all
|
|
108
|
+
* kinds — one line per entry (`- [kind:id] title`), no content. This gives
|
|
109
|
+
* the model a zero-cost overview of what exists so it can ask for full text
|
|
110
|
+
* via `evolve_list` or `/evolve list`. The directory is appended after the
|
|
111
|
+
* curated top-N injection sections and adds minimal tokens.
|
|
112
|
+
*/
|
|
113
|
+
export declare function formatEntriesDirectory(...kindEntries: readonly HarnessEntry[][]): string;
|
|
106
114
|
/**
|
|
107
115
|
* Walk the parent-session chain from `agent` upward and return the nearest
|
|
108
116
|
* session whose local store is non-empty, if any. Children inherit their
|
package/lib/inject.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isArchived } from "./types.js";
|
|
2
2
|
import { mergeHarnessStates } from "./state.js";
|
|
3
3
|
import { entryLine } from "./render.js";
|
|
4
|
+
import { recordInjection } from "./usage.js";
|
|
4
5
|
/** Prompt sections render at most this many entries per kind. */
|
|
5
6
|
export const MAX_INJECTED_ENTRIES_PER_KIND = 6;
|
|
6
7
|
/** Per-entry content budget inside the injected block (matches render.ts). */
|
|
@@ -183,6 +184,30 @@ export function formatSubagentSpecsSection(entries, query) {
|
|
|
183
184
|
}
|
|
184
185
|
return lines.join("\n");
|
|
185
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Gap B3: a lightweight directory of ALL non-archived entries across all
|
|
189
|
+
* kinds — one line per entry (`- [kind:id] title`), no content. This gives
|
|
190
|
+
* the model a zero-cost overview of what exists so it can ask for full text
|
|
191
|
+
* via `evolve_list` or `/evolve list`. The directory is appended after the
|
|
192
|
+
* curated top-N injection sections and adds minimal tokens.
|
|
193
|
+
*/
|
|
194
|
+
export function formatEntriesDirectory(...kindEntries) {
|
|
195
|
+
const allEntries = kindEntries.flat().filter((e) => !isArchived(e));
|
|
196
|
+
if (allEntries.length === 0) {
|
|
197
|
+
return "";
|
|
198
|
+
}
|
|
199
|
+
// Skip the directory when it would be redundant (all entries already shown
|
|
200
|
+
// in the curated sections above — 6/kind cap means ≤6 entries total).
|
|
201
|
+
const totalCapped = kindEntries.reduce((sum, entries) => sum + Math.min(entries.filter((e) => !isArchived(e)).length, MAX_INJECTED_ENTRIES_PER_KIND), 0);
|
|
202
|
+
if (allEntries.length <= totalCapped) {
|
|
203
|
+
return "";
|
|
204
|
+
}
|
|
205
|
+
const lines = ["# Continual Harness — Entry Directory", "All entries (use evolve_list for full text of any entry):"];
|
|
206
|
+
for (const entry of allEntries.sort((a, b) => `${a.kind}:${a.id}`.localeCompare(`${b.kind}:${b.id}`))) {
|
|
207
|
+
lines.push(`- [${entry.kind}:${entry.id}] ${entry.title}`);
|
|
208
|
+
}
|
|
209
|
+
return lines.join("\n");
|
|
210
|
+
}
|
|
186
211
|
/**
|
|
187
212
|
* Walk the parent-session chain from `agent` upward and return the nearest
|
|
188
213
|
* session whose local store is non-empty, if any. Children inherit their
|
|
@@ -222,10 +247,32 @@ export function entriesSectionText(engine, agent, query) {
|
|
|
222
247
|
const promptEntries = Object.values(merged.entries.prompt);
|
|
223
248
|
const subagentEntries = Object.values(merged.entries.subagent);
|
|
224
249
|
const relevanceQuery = (query ?? recentUserText(agent)).trim();
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
250
|
+
// Build injected text and collect which entries were included (gap B1).
|
|
251
|
+
const promptText = formatPromptEntriesSection(promptEntries, relevanceQuery);
|
|
252
|
+
const subagentText = formatSubagentSpecsSection(subagentEntries, relevanceQuery);
|
|
253
|
+
const injectedKeys = [];
|
|
254
|
+
// Collect keys from the visible (ranked, capped) entries that actually appear.
|
|
255
|
+
const visiblePrompt = promptEntries.filter((e) => !isArchived(e));
|
|
256
|
+
const visibleSubagent = subagentEntries.filter((e) => !isArchived(e));
|
|
257
|
+
for (const entry of rankEntries(visiblePrompt, relevanceQuery).slice(0, MAX_INJECTED_ENTRIES_PER_KIND)) {
|
|
258
|
+
injectedKeys.push(`prompt:${entry.id}`);
|
|
259
|
+
}
|
|
260
|
+
for (const entry of rankEntries(visibleSubagent, relevanceQuery).slice(0, MAX_INJECTED_ENTRIES_PER_KIND)) {
|
|
261
|
+
injectedKeys.push(`subagent:${entry.id}`);
|
|
262
|
+
}
|
|
263
|
+
// Record usage durably (best-effort: failure never blocks injection).
|
|
264
|
+
if (injectedKeys.length > 0) {
|
|
265
|
+
try {
|
|
266
|
+
recordInjection(engine.baseDir, injectedKeys);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
// Usage recording is diagnostic; never interrupt the injection path.
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// Gap B3: lightweight directory of ALL entries (id+title, one line each).
|
|
273
|
+
// Zero-cost index so the model knows what exists and can ask for full text.
|
|
274
|
+
const directoryText = formatEntriesDirectory(Object.values(merged.entries.prompt), Object.values(merged.entries.memory), Object.values(merged.entries.skill), Object.values(merged.entries.subagent));
|
|
275
|
+
const parts = [promptText, subagentText, directoryText].filter((part) => part.length > 0);
|
|
229
276
|
return parts.join("\n\n");
|
|
230
277
|
}
|
|
231
278
|
//# sourceMappingURL=inject.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified LLM text call: a shared streaming-text helper used by the review
|
|
3
|
+
* gate, planner, and wrap-up assessor. Eliminates ~120 lines of duplicated
|
|
4
|
+
* BlockAssembler + finish-state-check + text-extraction boilerplate.
|
|
5
|
+
*
|
|
6
|
+
* Every caller needs the same sequence:
|
|
7
|
+
* provider/model validation → stream → assemble → check finish → extract text
|
|
8
|
+
* This module owns that sequence; callers keep only their prompt construction
|
|
9
|
+
* and JSON parsing.
|
|
10
|
+
*/
|
|
11
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
12
|
+
export interface StreamTextOptions {
|
|
13
|
+
provider: string;
|
|
14
|
+
model: string;
|
|
15
|
+
system: string;
|
|
16
|
+
prompt: string;
|
|
17
|
+
maxTokens?: number;
|
|
18
|
+
signal?: AbortSignal | undefined;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Stream a single-turn text completion through `ctx.llm`. Forces
|
|
22
|
+
* `reasoningEffort: off` so the model spends its budget on the answer,
|
|
23
|
+
* not visible thinking (reasoning models otherwise produce zero text
|
|
24
|
+
* blocks — the exact failure recorded in FAQ #7).
|
|
25
|
+
*
|
|
26
|
+
* @returns The concatenated text blocks from the response.
|
|
27
|
+
* @throws On provider error, abort, max-token truncation, or empty output.
|
|
28
|
+
*/
|
|
29
|
+
export declare function streamText(ctx: Context, opts: StreamTextOptions): Promise<string>;
|
|
30
|
+
//# sourceMappingURL=llm-text.d.ts.map
|