dsh-continual-evolve 0.3.0 → 0.4.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 +83 -409
- package/README.zh.md +84 -272
- package/lib/apply.js +7 -1
- package/lib/auto.d.ts +17 -0
- package/lib/auto.js +5 -2
- package/lib/benchmark-command.js +2 -0
- package/lib/command.d.ts +3 -0
- package/lib/command.js +40 -2
- package/lib/fate.d.ts +2 -1
- package/lib/fate.js +5 -4
- package/lib/index.d.ts +22 -0
- package/lib/index.js +19 -1
- package/lib/inject.d.ts +16 -1
- package/lib/inject.js +55 -14
- package/lib/promotion.d.ts +62 -0
- package/lib/promotion.js +102 -0
- package/lib/service.js +2 -1
- package/lib/skill-render.d.ts +9 -1
- package/lib/skill-render.js +40 -2
- package/lib/state.js +6 -1
- package/lib/usage.d.ts +17 -4
- package/lib/usage.js +41 -10
- package/lib/wrapup-command.d.ts +2 -1
- package/lib/wrapup-command.js +4 -3
- package/lib/wrapup.d.ts +15 -6
- package/lib/wrapup.js +45 -6
- package/package.json +9 -7
package/lib/usage.js
CHANGED
|
@@ -14,29 +14,44 @@ const USAGE_FILE = "usage.json";
|
|
|
14
14
|
function usagePath(baseDir) {
|
|
15
15
|
return join(baseDir, "evolve", USAGE_FILE);
|
|
16
16
|
}
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Load the usage store from disk; returns an empty store when absent or
|
|
19
|
+
* corrupt. Accepts BOTH on-disk shapes:
|
|
20
|
+
* - legacy (≤0.3.x): a flat `{ "kind:id": count }` map,
|
|
21
|
+
* - v2: `{ version: 2, counts, lastSession }` with per-session dedup.
|
|
22
|
+
*/
|
|
18
23
|
export function loadUsage(baseDir) {
|
|
19
24
|
const path = usagePath(baseDir);
|
|
20
25
|
try {
|
|
21
26
|
if (!existsSync(path))
|
|
22
|
-
return { counts: {} };
|
|
27
|
+
return { counts: {}, lastSession: {} };
|
|
23
28
|
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
24
29
|
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
|
25
|
-
|
|
30
|
+
const record = raw;
|
|
31
|
+
if (record["version"] === 2 && typeof record["counts"] === "object" && record["counts"] !== null) {
|
|
32
|
+
return {
|
|
33
|
+
counts: record["counts"],
|
|
34
|
+
lastSession: typeof record["lastSession"] === "object" && record["lastSession"] !== null
|
|
35
|
+
? record["lastSession"]
|
|
36
|
+
: {},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// Legacy flat map: every key is a count.
|
|
40
|
+
return { counts: raw, lastSession: {} };
|
|
26
41
|
}
|
|
27
|
-
return { counts: {} };
|
|
42
|
+
return { counts: {}, lastSession: {} };
|
|
28
43
|
}
|
|
29
44
|
catch {
|
|
30
|
-
return { counts: {} };
|
|
45
|
+
return { counts: {}, lastSession: {} };
|
|
31
46
|
}
|
|
32
47
|
}
|
|
33
|
-
/** Persist the usage store atomically. */
|
|
48
|
+
/** Persist the usage store atomically (always the v2 shape). */
|
|
34
49
|
export function saveUsage(baseDir, store) {
|
|
35
50
|
const dir = join(baseDir, "evolve");
|
|
36
51
|
mkdirSync(dir, { recursive: true });
|
|
37
52
|
const path = usagePath(baseDir);
|
|
38
53
|
const tmp = `${path}.${process.pid}.tmp`;
|
|
39
|
-
writeFileSync(tmp, `${JSON.stringify(store.counts, null, 2)}\n`, "utf8");
|
|
54
|
+
writeFileSync(tmp, `${JSON.stringify({ version: 2, counts: store.counts, lastSession: store.lastSession ?? {} }, null, 2)}\n`, "utf8");
|
|
40
55
|
renameSync(tmp, path);
|
|
41
56
|
}
|
|
42
57
|
/** Build the usage key for an entry. */
|
|
@@ -46,16 +61,32 @@ export function usageKey(kind, id) {
|
|
|
46
61
|
/**
|
|
47
62
|
* Increment injection counts for the entries that were actually injected.
|
|
48
63
|
* Called after `entriesSectionText` renders the injected block. Keys not
|
|
49
|
-
* present in the store are initialized to 1
|
|
64
|
+
* present in the store are initialized to 1.
|
|
65
|
+
*
|
|
66
|
+
* Session dedup (2026-08-22): with a sessionId, each key counts AT MOST
|
|
67
|
+
* ONCE per session — the old per-build counting produced meaningless
|
|
68
|
+
* numbers (one entry hit 2311× in a week) and hid the real "how many
|
|
69
|
+
* sessions found this useful" signal that staleness decay needs. Without
|
|
70
|
+
* a sessionId the call degrades to legacy always-increment behavior.
|
|
50
71
|
*/
|
|
51
|
-
export function recordInjection(baseDir, injectedKeys) {
|
|
72
|
+
export function recordInjection(baseDir, injectedKeys, sessionId) {
|
|
52
73
|
if (injectedKeys.length === 0)
|
|
53
74
|
return;
|
|
54
75
|
const store = loadUsage(baseDir);
|
|
76
|
+
let dirty = false;
|
|
55
77
|
for (const key of injectedKeys) {
|
|
78
|
+
if (sessionId !== undefined && store.lastSession?.[key] === sessionId) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
56
81
|
store.counts[key] = (store.counts[key] ?? 0) + 1;
|
|
82
|
+
if (store.lastSession && sessionId !== undefined) {
|
|
83
|
+
store.lastSession[key] = sessionId;
|
|
84
|
+
}
|
|
85
|
+
dirty = true;
|
|
86
|
+
}
|
|
87
|
+
if (dirty) {
|
|
88
|
+
saveUsage(baseDir, store);
|
|
57
89
|
}
|
|
58
|
-
saveUsage(baseDir, store);
|
|
59
90
|
}
|
|
60
91
|
/**
|
|
61
92
|
* Get the injection count for a specific entry. Returns 0 when the entry
|
package/lib/wrapup-command.d.ts
CHANGED
|
@@ -4,5 +4,6 @@
|
|
|
4
4
|
import type { Context } from "@deepseek-ai/cordis";
|
|
5
5
|
import type { CommandInvocation, CommandResult } from "@deepseek-ai/dsh-commands";
|
|
6
6
|
import type { EvolutionEngine } from "./service.js";
|
|
7
|
-
|
|
7
|
+
import { type PromotionPolicy } from "./promotion.js";
|
|
8
|
+
export declare function executeWrapupCommand(ctx: Context, engine: EvolutionEngine, invocation: CommandInvocation, policy?: PromotionPolicy): Promise<CommandResult>;
|
|
8
9
|
//# sourceMappingURL=wrapup-command.d.ts.map
|
package/lib/wrapup-command.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { questionServiceOf, requireGlobalApproval } from "./approval.js";
|
|
2
2
|
import { assessLocalEntries, candidateKey, filterPromotable, listLocalCandidates, splitArchiveGuards, splitPromoteBlocked, splitPromoteProposals, wholePromoteProposals } from "./wrapup.js";
|
|
3
|
+
import { DEFAULT_PROMOTION_POLICY } from "./promotion.js";
|
|
3
4
|
function success(text) {
|
|
4
5
|
return { kind: "success", text };
|
|
5
6
|
}
|
|
6
|
-
export async function executeWrapupCommand(ctx, engine, invocation) {
|
|
7
|
+
export async function executeWrapupCommand(ctx, engine, invocation, policy = DEFAULT_PROMOTION_POLICY) {
|
|
7
8
|
const sessionId = invocation.agent.id;
|
|
8
9
|
const localState = engine.load("local", sessionId);
|
|
9
10
|
const globalState = engine.load("global", undefined);
|
|
@@ -16,7 +17,7 @@ export async function executeWrapupCommand(ctx, engine, invocation) {
|
|
|
16
17
|
const byKey = new Map(candidates.map((candidate) => [candidateKey(candidate.kind, candidate.id), candidate]));
|
|
17
18
|
// 2. Partition by action. Deterministic guards re-check the LIVE global
|
|
18
19
|
// store right before anything lands (state may have changed mid-call).
|
|
19
|
-
const { promotable, skipped } = filterPromotable(assessment.items, globalState, candidates);
|
|
20
|
+
const { promotable, skipped } = filterPromotable(assessment.items, globalState, candidates, policy);
|
|
20
21
|
const promoteItems = promotable.filter((item) => item.verdict === "promote");
|
|
21
22
|
const archiveItems = assessment.items.filter((item) => item.verdict === "archive");
|
|
22
23
|
// Split promotion (A-form): archive a mixed entry but promote ONLY the
|
|
@@ -33,7 +34,7 @@ export async function executeWrapupCommand(ctx, engine, invocation) {
|
|
|
33
34
|
splitSkipped.push({ key: item.key, reason: "not in the audited candidate list" });
|
|
34
35
|
continue;
|
|
35
36
|
}
|
|
36
|
-
const blocked = splitPromoteBlocked(item, globalState, candidate.kind);
|
|
37
|
+
const blocked = splitPromoteBlocked(item, globalState, candidate.kind, policy);
|
|
37
38
|
if (blocked) {
|
|
38
39
|
splitSkipped.push({ key: item.key, reason: blocked });
|
|
39
40
|
continue;
|
package/lib/wrapup.d.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import type { Context } from "@deepseek-ai/cordis";
|
|
22
22
|
import type { Agent } from "@deepseek-ai/dsh-agent";
|
|
23
23
|
import type { HarnessEntry, HarnessState, RefinementKind, RefinementProposal } from "./types.js";
|
|
24
|
+
import { type PromotionPolicy } from "./promotion.js";
|
|
24
25
|
/** What should happen to one local entry at session end. */
|
|
25
26
|
export type WrapupVerdict = "promote" | "archive" | "keep";
|
|
26
27
|
/** A classified local entry: `key` matches one audited candidate exactly. */
|
|
@@ -138,9 +139,16 @@ export interface PromotableSplit {
|
|
|
138
139
|
* Apply-time deterministic guard: re-check every promote verdict against the
|
|
139
140
|
* global store right before it lands. The LLM classification may be stale
|
|
140
141
|
* (a gate ran while assessing) or wrong; this ensures a promote never writes
|
|
141
|
-
* a duplicate global entry. Pure and unit-tested.
|
|
142
|
+
* a duplicate, project-scoped, or too-thin global entry. Pure and unit-tested.
|
|
143
|
+
*
|
|
144
|
+
* Guards (2026-08-22 promotion policy):
|
|
145
|
+
* - audited candidate list + title coverage (pre-existing),
|
|
146
|
+
* - project-scoped content markers (absolute paths / session ids) — the
|
|
147
|
+
* global store is shared across projects and must stay portable,
|
|
148
|
+
* - thin content below the policy floor (framing outweighs the fact),
|
|
149
|
+
* - near-duplicate of an existing global entry by content overlap.
|
|
142
150
|
*/
|
|
143
|
-
export declare function filterPromotable(items: readonly WrapupItem[], globalState: HarnessState, candidates: readonly WrapupCandidate[]): PromotableSplit;
|
|
151
|
+
export declare function filterPromotable(items: readonly WrapupItem[], globalState: HarnessState, candidates: readonly WrapupCandidate[], policy?: PromotionPolicy): PromotableSplit;
|
|
144
152
|
export interface ArchiveReviewSplit {
|
|
145
153
|
/** Archives that may proceed silently: topic already covered, no real
|
|
146
154
|
* distillation source, or the archive half of an already-approved split. */
|
|
@@ -171,11 +179,12 @@ export declare function needsArchiveReview(item: WrapupItem, candidate: WrapupCa
|
|
|
171
179
|
export declare function splitArchiveGuards(items: readonly WrapupItem[], candidates: readonly WrapupCandidate[]): ArchiveReviewSplit;
|
|
172
180
|
/**
|
|
173
181
|
* Apply-time guard for a split promotion (archive + promote sub-object):
|
|
174
|
-
* the cleaned
|
|
175
|
-
*
|
|
176
|
-
*
|
|
182
|
+
* the cleaned payload must pass the same promotion policy as a whole
|
|
183
|
+
* promote — no global coverage duplicate, no project-scoped content, not
|
|
184
|
+
* too thin, no near-duplicate global entry. A blocked split is dropped (the
|
|
185
|
+
* entry still archives plain) rather than half-promoting a redundancy.
|
|
177
186
|
*/
|
|
178
|
-
export declare function splitPromoteBlocked(item: WrapupItem, globalState: HarnessState, kind: RefinementKind): string | undefined;
|
|
187
|
+
export declare function splitPromoteBlocked(item: WrapupItem, globalState: HarnessState, kind: RefinementKind, policy?: PromotionPolicy): string | undefined;
|
|
179
188
|
/**
|
|
180
189
|
* Shared proposal builders for a WHOLE promotion — used by both the
|
|
181
190
|
* `/evolve wrapup` command and the gate's local-fate dimension so the two
|
package/lib/wrapup.js
CHANGED
|
@@ -4,6 +4,7 @@ import { compactText } from "./render.js";
|
|
|
4
4
|
import { streamText } from "./llm-text.js";
|
|
5
5
|
import { getUsageCount, loadUsage } from "./usage.js";
|
|
6
6
|
import { recencyScore } from "./inject.js";
|
|
7
|
+
import { DEFAULT_PROMOTION_POLICY, mostSimilarGlobalEntry, projectScopedReason } from "./promotion.js";
|
|
7
8
|
export function candidateKey(kind, id) {
|
|
8
9
|
return `${kind}:${id}`;
|
|
9
10
|
}
|
|
@@ -158,9 +159,16 @@ export function parseWrapupAssessment(text, candidates) {
|
|
|
158
159
|
* Apply-time deterministic guard: re-check every promote verdict against the
|
|
159
160
|
* global store right before it lands. The LLM classification may be stale
|
|
160
161
|
* (a gate ran while assessing) or wrong; this ensures a promote never writes
|
|
161
|
-
* a duplicate global entry. Pure and unit-tested.
|
|
162
|
+
* a duplicate, project-scoped, or too-thin global entry. Pure and unit-tested.
|
|
163
|
+
*
|
|
164
|
+
* Guards (2026-08-22 promotion policy):
|
|
165
|
+
* - audited candidate list + title coverage (pre-existing),
|
|
166
|
+
* - project-scoped content markers (absolute paths / session ids) — the
|
|
167
|
+
* global store is shared across projects and must stay portable,
|
|
168
|
+
* - thin content below the policy floor (framing outweighs the fact),
|
|
169
|
+
* - near-duplicate of an existing global entry by content overlap.
|
|
162
170
|
*/
|
|
163
|
-
export function filterPromotable(items, globalState, candidates) {
|
|
171
|
+
export function filterPromotable(items, globalState, candidates, policy = DEFAULT_PROMOTION_POLICY) {
|
|
164
172
|
const byKey = new Map(candidates.map((candidate) => [candidateKey(candidate.kind, candidate.id), candidate]));
|
|
165
173
|
const promotable = [];
|
|
166
174
|
const skipped = [];
|
|
@@ -176,6 +184,26 @@ export function filterPromotable(items, globalState, candidates) {
|
|
|
176
184
|
skipped.push({ key: item.key, reason: "already covered globally" });
|
|
177
185
|
continue;
|
|
178
186
|
}
|
|
187
|
+
const scoped = projectScopedReason(`${candidate.title}\n${candidate.content}`, policy);
|
|
188
|
+
if (scoped) {
|
|
189
|
+
skipped.push({ key: item.key, reason: scoped });
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (candidate.content.length < policy.minPromoteChars) {
|
|
193
|
+
skipped.push({
|
|
194
|
+
key: item.key,
|
|
195
|
+
reason: `too thin to promote (${candidate.content.length} < ${policy.minPromoteChars} chars) — keep local or merge`,
|
|
196
|
+
});
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const similar = mostSimilarGlobalEntry(globalState, candidate.kind, candidate.title, candidate.content, policy);
|
|
200
|
+
if (similar) {
|
|
201
|
+
skipped.push({
|
|
202
|
+
key: item.key,
|
|
203
|
+
reason: `near-duplicate of global ${candidate.kind}:${similar.id} "${similar.title}" (overlap ${similar.score.toFixed(2)}) — update that entry instead`,
|
|
204
|
+
});
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
179
207
|
promotable.push(item);
|
|
180
208
|
}
|
|
181
209
|
return { promotable, skipped };
|
|
@@ -223,16 +251,27 @@ export function splitArchiveGuards(items, candidates) {
|
|
|
223
251
|
}
|
|
224
252
|
/**
|
|
225
253
|
* Apply-time guard for a split promotion (archive + promote sub-object):
|
|
226
|
-
* the cleaned
|
|
227
|
-
*
|
|
228
|
-
*
|
|
254
|
+
* the cleaned payload must pass the same promotion policy as a whole
|
|
255
|
+
* promote — no global coverage duplicate, no project-scoped content, not
|
|
256
|
+
* too thin, no near-duplicate global entry. A blocked split is dropped (the
|
|
257
|
+
* entry still archives plain) rather than half-promoting a redundancy.
|
|
229
258
|
*/
|
|
230
|
-
export function splitPromoteBlocked(item, globalState, kind) {
|
|
259
|
+
export function splitPromoteBlocked(item, globalState, kind, policy = DEFAULT_PROMOTION_POLICY) {
|
|
231
260
|
if (!item.promote)
|
|
232
261
|
return "no split payload";
|
|
233
262
|
if (globalCoverageDetected(globalState, kind, { id: "", title: item.promote.title })) {
|
|
234
263
|
return "split promotion duplicates a globally covered topic";
|
|
235
264
|
}
|
|
265
|
+
const scoped = projectScopedReason(`${item.promote.title}\n${item.promote.content}`, policy);
|
|
266
|
+
if (scoped)
|
|
267
|
+
return `split promotion is ${scoped}`;
|
|
268
|
+
if (item.promote.content.length < policy.minPromoteChars) {
|
|
269
|
+
return `split promotion too thin (${item.promote.content.length} < ${policy.minPromoteChars} chars)`;
|
|
270
|
+
}
|
|
271
|
+
const similar = mostSimilarGlobalEntry(globalState, kind, item.promote.title, item.promote.content, policy);
|
|
272
|
+
if (similar) {
|
|
273
|
+
return `split promotion near-duplicates global ${kind}:${similar.id} "${similar.title}" (overlap ${similar.score.toFixed(2)})`;
|
|
274
|
+
}
|
|
236
275
|
return undefined;
|
|
237
276
|
}
|
|
238
277
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-continual-evolve",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Continual self-evolution plugin for DeepSeek Harness: versioned, auditable, rollback-safe harness state (prompt notes, memories, skills, subagent specs) refined from session trajectories.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"dev": "tsc -p tsconfig.json --watch",
|
|
51
51
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
52
52
|
"test": "vitest run",
|
|
53
|
+
"test:coverage": "vitest run --coverage",
|
|
53
54
|
"test:watch": "vitest",
|
|
54
55
|
"lint": "oxlint src test",
|
|
55
56
|
"clean": "rm -rf lib"
|
|
@@ -62,14 +63,15 @@
|
|
|
62
63
|
},
|
|
63
64
|
"devDependencies": {
|
|
64
65
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
65
|
-
"@deepseek-ai/dsh-agent": "0.1.
|
|
66
|
-
"@deepseek-ai/dsh-commands": "0.1.
|
|
67
|
-
"@deepseek-ai/dsh-home-paths": "0.1.
|
|
68
|
-
"@deepseek-ai/dsh-llm": "0.1.
|
|
69
|
-
"@deepseek-ai/dsh-system-prompt": "0.1.
|
|
70
|
-
"@deepseek-ai/dsh-tools": "0.1.
|
|
66
|
+
"@deepseek-ai/dsh-agent": "0.1.1-rc.2",
|
|
67
|
+
"@deepseek-ai/dsh-commands": "0.1.1-rc.2",
|
|
68
|
+
"@deepseek-ai/dsh-home-paths": "0.1.1-rc.2",
|
|
69
|
+
"@deepseek-ai/dsh-llm": "0.1.1-rc.2",
|
|
70
|
+
"@deepseek-ai/dsh-system-prompt": "0.1.1-rc.2",
|
|
71
|
+
"@deepseek-ai/dsh-tools": "0.1.1-rc.2",
|
|
71
72
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
72
73
|
"@types/node": "^22.10.0",
|
|
74
|
+
"@vitest/coverage-v8": "^3.2.0",
|
|
73
75
|
"oxlint": "^0.16.0",
|
|
74
76
|
"typescript": "^5.9.0",
|
|
75
77
|
"vitest": "^3.2.0"
|