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/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
- /** Load the usage store from disk; returns an empty store when absent or corrupt. */
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
- return { counts: raw };
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; existing keys are incremented.
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
@@ -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
- export declare function executeWrapupCommand(ctx: Context, engine: EvolutionEngine, invocation: CommandInvocation): Promise<CommandResult>;
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
@@ -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 title must not duplicate a topic already covered globally. A
175
- * duplicate split is dropped (the entry still archives plain) rather than
176
- * half-promoting a redundancy.
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 title must not duplicate a topic already covered globally. A
227
- * duplicate split is dropped (the entry still archives plain) rather than
228
- * half-promoting a redundancy.
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.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.0-rc.7",
66
- "@deepseek-ai/dsh-commands": "0.1.0-rc.7",
67
- "@deepseek-ai/dsh-home-paths": "0.1.0-rc.7",
68
- "@deepseek-ai/dsh-llm": "0.1.0-rc.7",
69
- "@deepseek-ai/dsh-system-prompt": "0.1.0-rc.7",
70
- "@deepseek-ai/dsh-tools": "0.1.0-rc.7",
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"