dsh-continual-evolve 0.6.1 → 0.7.1

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/tool.js CHANGED
@@ -1,31 +1,93 @@
1
1
  import { defineTool } from "@deepseek-ai/dsh-tools";
2
+ import { MEMORY_TYPE_KEY } from "./types.js";
3
+ import { projectKeyOf } from "./project.js";
2
4
  import { formatHarnessStateForPrompt } from "./render.js";
3
5
  import { requireGlobalApproval } from "./approval.js";
4
6
  import { CONFLICT_WARN_SCORE, buildConflictNotice, mostSimilarEntry } from "./promotion.js";
5
7
  import { entrySourceOf } from "./source.js";
6
8
  import { getUsageCount, loadUsage } from "./usage.js";
7
9
  import { buildEvolveCompleteEvent, emitEvolveComplete } from "./evolve-event.js";
8
- const SCOPES = ["local", "global"];
9
- /** Accept both the boolean tool parameter (`global: true`) and the string form. */
10
+ import { DEFAULT_REVIEWS_RETAIN } from "./store.js";
11
+ const SCOPES = ["local", "project", "global"];
12
+ /**
13
+ * Accept the string form (`scope: "project"`) with the legacy boolean tool
14
+ * parameter (`global: true`) as fallback. `scope` wins when both are given.
15
+ */
10
16
  export function scopeOf(value, fallback) {
11
- return value === "global" || value === true ? "global" : fallback;
17
+ if (value === "global" || value === true) {
18
+ return "global";
19
+ }
20
+ if (value === "project") {
21
+ return "project";
22
+ }
23
+ return fallback;
12
24
  }
13
25
  /** The calling agent's session id; tools always run inside an agent scope. */
14
26
  function sessionIdOf(exec) {
15
27
  return exec.agent?.id;
16
28
  }
29
+ /**
30
+ * Collect the target ids for an evolve_delete call: legacy single `id`
31
+ * plus the batch `ids` array, de-duplicated in first-seen order. Throws
32
+ * loudly when neither carries an id — a silent no-op delete would look
33
+ * like success while deleting nothing.
34
+ *
35
+ * @throws when both `id` and `ids` are absent or empty.
36
+ */
37
+ export function collectDeleteIds(id, ids) {
38
+ const collected = [];
39
+ if (typeof id === "string" && id.length > 0)
40
+ collected.push(id);
41
+ if (Array.isArray(ids)) {
42
+ for (const item of ids) {
43
+ if (typeof item === "string" && item.length > 0 && !collected.includes(item)) {
44
+ collected.push(item);
45
+ }
46
+ }
47
+ }
48
+ if (collected.length === 0) {
49
+ throw new Error("evolve_delete requires id or a non-empty ids array");
50
+ }
51
+ return collected;
52
+ }
53
+ /**
54
+ * The store id a scope reads/writes: session id for local, derived project
55
+ * key for project, undefined for global. Throws for project when the
56
+ * session cwd is unavailable — fail loud instead of writing local-by-mistake.
57
+ */
58
+ function storeIdFor(scope, exec) {
59
+ if (scope === "local") {
60
+ return sessionIdOf(exec);
61
+ }
62
+ if (scope === "project") {
63
+ const key = projectKeyOf(exec.agent);
64
+ if (!key) {
65
+ throw new Error("project scope needs the session cwd (unavailable for this agent) — use local or global instead");
66
+ }
67
+ return key;
68
+ }
69
+ return undefined;
70
+ }
71
+ /** Human-approval gate shared by global and project writes (both cross-session). */
72
+ function needsApproval(scope) {
73
+ return scope === "global" || scope === "project";
74
+ }
75
+ /** Store label used in approval prompts. */
76
+ function storeLabel(scope) {
77
+ return scope === "project" ? "本项目跨会话 store" : "跨会话全局 store";
78
+ }
17
79
  function textResult(text) {
18
80
  return { text };
19
81
  }
20
82
  export function registerEvolveTools(ctx, engine, opts) {
21
83
  ctx.tools.register(defineTool({
22
84
  name: "evolve_list",
23
- description: "List the continual harness state (prompt notes, memories, skills, subagent specs) for the current session (local) or across sessions (global).",
85
+ description: "List the continual harness state (prompt notes, memories, skills, subagent specs) for the current session (local), the current project (project), or across projects (global).",
24
86
  parameters: {
25
87
  scope: {
26
88
  type: "string",
27
89
  enum: SCOPES,
28
- description: "Which store to list: 'local' (default) or 'global'.",
90
+ description: "Which store to list: 'local' (default), 'project', or 'global'.",
29
91
  },
30
92
  },
31
93
  output: {
@@ -34,7 +96,7 @@ export function registerEvolveTools(ctx, engine, opts) {
34
96
  },
35
97
  execute: async (args, exec) => {
36
98
  const scope = scopeOf(args.scope, "local");
37
- const state = engine.load(scope, sessionIdOf(exec));
99
+ const state = engine.load(scope, storeIdFor(scope, exec));
38
100
  const text = formatHarnessStateForPrompt(state);
39
101
  // Append injection usage counts (gap B1).
40
102
  const usage = loadUsage(engine.baseDir);
@@ -64,6 +126,8 @@ export function registerEvolveTools(ctx, engine, opts) {
64
126
  skill_kind: { type: "string", enum: ["executable", "guidance"], description: "For skills: executable (python reference, default) or guidance (SKILL.md document, no reference)." },
65
127
  reference: { type: "object", additionalProperties: true, description: "For executable skills: {type:'python', import, callable}." },
66
128
  arguments: { type: "object", additionalProperties: true, description: "For executable skills: accepted input contract." },
129
+ memoryType: { type: "string", enum: ["user", "feedback", "project", "reference"], description: "Required for memory: one fact per entry (feedback/project must carry Why + How to apply)." },
130
+ scope: { type: "string", enum: SCOPES, description: "Target store: 'local' (default), 'project', or 'global'. Wins over the legacy global flag." },
67
131
  global: { type: "boolean", description: "Set true to write the cross-session store (requires human approval; only for durable, reusable lessons)." },
68
132
  },
69
133
  output: {
@@ -71,15 +135,18 @@ export function registerEvolveTools(ctx, engine, opts) {
71
135
  render: (_args, value) => [{ type: "text", text: value.text ?? "" }],
72
136
  },
73
137
  execute: async (args, exec) => {
74
- const scope = scopeOf(args.global, "local");
75
- if (scope === "global" && opts.requireGlobalApproval) {
138
+ const scope = scopeOf(args.scope ?? args.global, "local");
139
+ // Resolve first: a project scope without a session cwd fails
140
+ // here, before any human approval question is asked.
141
+ const storeId = storeIdFor(scope, exec);
142
+ if (needsApproval(scope) && opts.requireGlobalApproval) {
76
143
  // Informed approval: surface a similarity hit against the
77
- // existing global store BEFORE the human decides — the
78
- // engine's write-time guard still has the final say.
79
- const globalState = engine.load("global", undefined);
80
- const hit = mostSimilarEntry(Object.values(globalState.entries[args.kind]), args.title ?? "", args.content ?? "", CONFLICT_WARN_SCORE);
144
+ // target store BEFORE the human decides — the engine's
145
+ // write-time guard still has the final say.
146
+ const targetState = engine.load(scope, storeId);
147
+ const hit = mostSimilarEntry(Object.values(targetState.entries[args.kind]), args.title ?? "", args.content ?? "", CONFLICT_WARN_SCORE);
81
148
  const conflictNote = hit ? ` ⚠️ ${buildConflictNotice(hit)}——建议改用 evolve_update` : "";
82
- await requireGlobalApproval(ctx, exec.agent, exec.signal, `evolve_add ${args.kind} "${args.title}" → 跨会话全局 store${conflictNote}`);
149
+ await requireGlobalApproval(ctx, exec.agent, exec.signal, `evolve_add ${args.kind} "${args.title}" → ${storeLabel(scope)}${conflictNote}`);
83
150
  }
84
151
  const edit = {
85
152
  action: "create",
@@ -95,7 +162,10 @@ export function registerEvolveTools(ctx, engine, opts) {
95
162
  edit.reference = args.reference;
96
163
  if (args.arguments !== undefined)
97
164
  edit.arguments = args.arguments;
98
- return textResult(applyEditsText(engine, scope, sessionIdOf(exec), [edit], exec.agent));
165
+ if (args.kind === "memory" && args.memoryType !== undefined) {
166
+ edit.metadata = { [MEMORY_TYPE_KEY]: args.memoryType };
167
+ }
168
+ return textResult(applyEditsText(engine, scope, storeId, [edit], exec.agent, sessionIdOf(exec)));
99
169
  },
100
170
  }));
101
171
  ctx.tools.register(defineTool({
@@ -106,6 +176,8 @@ export function registerEvolveTools(ctx, engine, opts) {
106
176
  id: { type: "string", required: true, description: "Existing entry id." },
107
177
  title: { type: "string" },
108
178
  content: { type: "string" },
179
+ memoryType: { type: "string", enum: ["user", "feedback", "project", "reference"], description: "For memory: (re)classify the entry's recall type." },
180
+ scope: { type: "string", enum: SCOPES, description: "Target store: 'local' (default), 'project', or 'global'. Wins over the legacy global flag." },
109
181
  global: { type: "boolean", description: "Set true to edit the cross-session store (requires human approval)." },
110
182
  },
111
183
  output: {
@@ -113,24 +185,33 @@ export function registerEvolveTools(ctx, engine, opts) {
113
185
  render: (_args, value) => [{ type: "text", text: value.text ?? "" }],
114
186
  },
115
187
  execute: async (args, exec) => {
116
- const scope = scopeOf(args.global, "local");
117
- if (scope === "global" && opts.requireGlobalApproval) {
118
- await requireGlobalApproval(ctx, exec.agent, exec.signal, `evolve_update ${args.kind}:${args.id} → 跨会话全局 store`);
188
+ const scope = scopeOf(args.scope ?? args.global, "local");
189
+ const storeId = storeIdFor(scope, exec);
190
+ if (needsApproval(scope) && opts.requireGlobalApproval) {
191
+ await requireGlobalApproval(ctx, exec.agent, exec.signal, `evolve_update ${args.kind}:${args.id} → ${storeLabel(scope)}`);
119
192
  }
120
193
  const edit = { action: "update", kind: args.kind, id: args.id };
121
194
  if (args.title !== undefined)
122
195
  edit.title = args.title;
123
196
  if (args.content !== undefined)
124
197
  edit.content = args.content;
125
- return textResult(applyEditsText(engine, scope, sessionIdOf(exec), [edit], exec.agent));
198
+ if (args.memoryType !== undefined)
199
+ edit.metadata = { [MEMORY_TYPE_KEY]: args.memoryType };
200
+ return textResult(applyEditsText(engine, scope, storeId, [edit], exec.agent, sessionIdOf(exec)));
126
201
  },
127
202
  }));
128
203
  ctx.tools.register(defineTool({
129
204
  name: "evolve_delete",
130
- description: "Delete one harness entry by id.",
205
+ description: "Delete harness entries by id. Pass `id` for one entry or `ids` for several — a batch lands as one refinement behind one approval. Literal ids win: entries whose stored id itself carries a scope-like prefix (e.g. a legacy `local:…` id) are addressed verbatim.",
131
206
  parameters: {
132
207
  kind: { type: "string", enum: ["prompt", "memory", "skill", "subagent"], required: true },
133
- id: { type: "string", required: true },
208
+ id: { type: "string", description: "Existing entry id (single delete; combine with ids or use ids for a batch)." },
209
+ ids: {
210
+ type: "array",
211
+ items: { type: "string" },
212
+ description: "Existing entry ids for one batch delete (one refinement, one approval).",
213
+ },
214
+ scope: { type: "string", enum: SCOPES, description: "Target store: 'local' (default), 'project', or 'global'. Wins over the legacy global flag." },
134
215
  global: { type: "boolean", description: "Set true to edit the cross-session store (requires human approval)." },
135
216
  },
136
217
  output: {
@@ -138,12 +219,14 @@ export function registerEvolveTools(ctx, engine, opts) {
138
219
  render: (_args, value) => [{ type: "text", text: value.text ?? "" }],
139
220
  },
140
221
  execute: async (args, exec) => {
141
- const scope = scopeOf(args.global, "local");
142
- if (scope === "global" && opts.requireGlobalApproval) {
143
- await requireGlobalApproval(ctx, exec.agent, exec.signal, `evolve_delete ${args.kind}:${args.id} → 跨会话全局 store`);
222
+ const scope = scopeOf(args.scope ?? args.global, "local");
223
+ const storeId = storeIdFor(scope, exec);
224
+ const ids = collectDeleteIds(args.id, args.ids);
225
+ if (needsApproval(scope) && opts.requireGlobalApproval) {
226
+ await requireGlobalApproval(ctx, exec.agent, exec.signal, `evolve_delete ${args.kind}:${ids.length > 1 ? `${ids.length} entries (${ids.join(", ")})` : ids[0]} → ${storeLabel(scope)}`);
144
227
  }
145
- const edit = { action: "delete", kind: args.kind, id: args.id };
146
- return textResult(applyEditsText(engine, scope, sessionIdOf(exec), [edit], exec.agent));
228
+ const edits = ids.map((id) => ({ action: "delete", kind: args.kind, id }));
229
+ return textResult(applyEditsText(engine, scope, storeId, edits, exec.agent, sessionIdOf(exec)));
147
230
  },
148
231
  }));
149
232
  ctx.tools.register(defineTool({
@@ -151,6 +234,7 @@ export function registerEvolveTools(ctx, engine, opts) {
151
234
  description: "Deterministically revert a previous refinement by its id (from evolve_list history or the /evolve command).",
152
235
  parameters: {
153
236
  refinementId: { type: "string", required: true, description: "The refinement id to roll back." },
237
+ scope: { type: "string", enum: SCOPES, description: "Target store: 'local' (default), 'project', or 'global'. Wins over the legacy global flag." },
154
238
  global: { type: "boolean", description: "Set true to roll back a cross-session refinement." },
155
239
  },
156
240
  output: {
@@ -158,14 +242,14 @@ export function registerEvolveTools(ctx, engine, opts) {
158
242
  render: (_args, value) => [{ type: "text", text: value.text ?? "" }],
159
243
  },
160
244
  execute: async (args, exec) => {
161
- const scope = scopeOf(args.global, "local");
162
- const result = engine.rollback(scope, sessionIdOf(exec), args.refinementId);
245
+ const scope = scopeOf(args.scope ?? args.global, "local");
246
+ const result = engine.rollback(scope, storeIdFor(scope, exec), args.refinementId);
163
247
  return textResult(`Rolled back ${result.rollbackOf ?? result.id}: ${result.appliedEdits.filter((e) => e.applied).length} edit(s) reverted.`);
164
248
  },
165
249
  }));
166
250
  }
167
- function applyEditsText(engine, scope, sessionId, edits, agent) {
168
- const result = engine.apply(scope, sessionId, {
251
+ function applyEditsText(engine, scope, storeId, edits, agent, eventSessionId) {
252
+ const result = engine.apply(scope, storeId, {
169
253
  summary: "Direct tool edit",
170
254
  rationale: "Model-invoked single edit via evolve_* tool.",
171
255
  expectedOutcome: "Entry is created, updated, or deleted as requested.",
@@ -173,14 +257,16 @@ function applyEditsText(engine, scope, sessionId, edits, agent) {
173
257
  }, agent
174
258
  ? {
175
259
  scope,
176
- ...(entrySourceOf(agent, sessionId) ? { source: entrySourceOf(agent, sessionId) } : {}),
260
+ ...(entrySourceOf(agent, eventSessionId ?? storeId) ? { source: entrySourceOf(agent, eventSessionId ?? storeId) } : {}),
177
261
  }
178
262
  : { scope });
179
263
  const applied = result.appliedEdits.filter((e) => e.applied);
180
264
  const failed = result.appliedEdits.filter((e) => !e.applied);
181
265
  // Gap C4: emit structured evolve_complete event for third-party consumers.
182
- if (applied.length > 0 && sessionId) {
183
- emitEvolveComplete(engine.baseDir, buildEvolveCompleteEvent(result, "manual_tool", sessionId));
266
+ // The event keys on the live session (not the project store key).
267
+ const eventSession = eventSessionId ?? storeId;
268
+ if (applied.length > 0 && eventSession) {
269
+ emitEvolveComplete(engine.baseDir, buildEvolveCompleteEvent(result, "manual_tool", eventSession), engine.retention?.reviews ?? DEFAULT_REVIEWS_RETAIN);
184
270
  }
185
271
  const lines = [`refinement ${result.id}: ${applied.length} applied, ${failed.length} failed`];
186
272
  for (const e of applied) {
package/lib/types.d.ts CHANGED
@@ -20,8 +20,20 @@ export type RefinementKind = "prompt" | "memory" | "skill" | "subagent";
20
20
  export type SkillKind = "executable" | "guidance";
21
21
  /** How an entry changes. */
22
22
  export type RefinementAction = "create" | "update" | "delete" | "archive";
23
- /** Where an entry lives: session-scoped or cross-session. */
24
- export type HarnessScope = "local" | "global";
23
+ /** Where an entry lives: session staging, one project, or cross-project. */
24
+ export type HarnessScope = "local" | "project" | "global";
25
+ /**
26
+ * Memory-entry shape discipline (one fact per entry, typed like ZCode's
27
+ * memory frontmatter): every `memory` create must stamp one of these in
28
+ * `metadata[MEMORY_TYPE_KEY]` — the recall hook the directory shows.
29
+ */
30
+ export declare const MEMORY_TYPE_KEY = "memoryType";
31
+ /** Closed memory-type set: user | feedback | project | reference. */
32
+ export declare const MEMORY_TYPES: readonly ["user", "feedback", "project", "reference"];
33
+ /** One of {@link MEMORY_TYPES}. */
34
+ export type MemoryRecallType = (typeof MEMORY_TYPES)[number];
35
+ /** True when the value is a closed-set memory type. */
36
+ export declare function isMemoryType(value: unknown): value is MemoryRecallType;
25
37
  /**
26
38
  * Metadata key recording which session an entry's content was distilled from
27
39
  * (trajectory citation; see {@link EntrySource}).
package/lib/types.js CHANGED
@@ -8,6 +8,18 @@
8
8
  * code here is an original implementation, written for the DeepSeek Harness
9
9
  * plugin surface.
10
10
  */
11
+ /**
12
+ * Memory-entry shape discipline (one fact per entry, typed like ZCode's
13
+ * memory frontmatter): every `memory` create must stamp one of these in
14
+ * `metadata[MEMORY_TYPE_KEY]` — the recall hook the directory shows.
15
+ */
16
+ export const MEMORY_TYPE_KEY = "memoryType";
17
+ /** Closed memory-type set: user | feedback | project | reference. */
18
+ export const MEMORY_TYPES = ["user", "feedback", "project", "reference"];
19
+ /** True when the value is a closed-set memory type. */
20
+ export function isMemoryType(value) {
21
+ return typeof value === "string" && MEMORY_TYPES.includes(value);
22
+ }
11
23
  /**
12
24
  * Metadata key recording which session an entry's content was distilled from
13
25
  * (trajectory citation; see {@link EntrySource}).
package/lib/validate.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { MEMORY_TYPE_KEY, isMemoryType } from "./types.js";
1
2
  import { validateSkillEntryContent } from "./skillquality.js";
2
3
  const ACTIONS = new Set(["create", "update", "delete", "archive"]);
3
4
  const KINDS = new Set(["prompt", "memory", "skill", "subagent"]);
@@ -18,6 +19,9 @@ export function validateBlastRadiusScope(scope, blastRadius) {
18
19
  if (scope === "global" && blastRadius === "session") {
19
20
  return "global-scope edit must declare blastRadius \"general\" or \"project\" (\"session\" contradicts cross-session persistence)";
20
21
  }
22
+ if (scope === "project" && blastRadius === "session") {
23
+ return "project-scope edit must declare blastRadius \"project\" or \"general\" (\"session\" contradicts cross-session persistence)";
24
+ }
21
25
  return undefined;
22
26
  }
23
27
  /**
@@ -71,6 +75,32 @@ export function validateEdit(edit, computedId, scope, before) {
71
75
  edit.arguments === undefined) {
72
76
  return "update carries no changes";
73
77
  }
78
+ if (edit.kind === "memory" && (edit.action === "create" || edit.action === "update")) {
79
+ // One-fact typed memories: every memory create declares its recall
80
+ // type; feedback/project (pitfalls, conventions) additionally carry
81
+ // the Why + How-to-apply structure so the fact is reusable, not just
82
+ // recorded. Updates are partial — only carried fields are checked,
83
+ // resolved against the persisted entry.
84
+ const carriedType = edit.metadata?.[MEMORY_TYPE_KEY];
85
+ if (edit.action === "create" && !isMemoryType(carriedType)) {
86
+ return `create memory requires metadata.${MEMORY_TYPE_KEY} one of user|feedback|project|reference (one fact per entry; update the existing entry instead of adding a duplicate)`;
87
+ }
88
+ if (carriedType !== undefined && !isMemoryType(carriedType)) {
89
+ return `invalid metadata.${MEMORY_TYPE_KEY} ${JSON.stringify(carriedType)} (one of user|feedback|project|reference)`;
90
+ }
91
+ const effectiveType = isMemoryType(carriedType)
92
+ ? carriedType
93
+ : edit.action === "update"
94
+ ? before?.metadata?.[MEMORY_TYPE_KEY]
95
+ : undefined;
96
+ if ((effectiveType === "feedback" || effectiveType === "project") &&
97
+ (edit.action === "create" || edit.content !== undefined)) {
98
+ const content = edit.content ?? "";
99
+ if (!/why/i.test(content) || !/how/i.test(content)) {
100
+ return `${effectiveType} memory content must carry a Why and a How to apply section (the fact plus why it matters and how to apply it next time)`;
101
+ }
102
+ }
103
+ }
74
104
  if (edit.action === "create" && edit.kind === "skill") {
75
105
  // Guidance skills are SKILL.md documents: no python reference (a
76
106
  // reference on a guidance skill would be an invented contract) and
@@ -13,7 +13,16 @@ export async function executeWrapupCommand(ctx, engine, invocation, policy = DEF
13
13
  return success(`(nothing to wrap up: ${sessionId}'s local store has no active, un-promoted entries — use /evolve list to inspect it)`);
14
14
  }
15
15
  // 1. Classify: the model judges each audited candidate's fate.
16
- const assessment = await assessLocalEntries(ctx, invocation.agent, candidates, { signal: invocation.signal });
16
+ const assessment = await assessLocalEntries(ctx, invocation.agent, candidates, {
17
+ signal: invocation.signal,
18
+ tokenUsage: {
19
+ baseDir: engine.baseDir,
20
+ sessionId,
21
+ retain: engine.retention.tokenUsage,
22
+ onError: (cause) => ctx.logger("continual-evolve").warn(`token-usage ledger failed for ${sessionId}: ${cause instanceof Error ? cause.message : String(cause)}`),
23
+ },
24
+ tokenUsagePhase: "wrapup",
25
+ });
17
26
  const byKey = new Map(candidates.map((candidate) => [candidateKey(candidate.kind, candidate.id), candidate]));
18
27
  // 2. Partition by action. Deterministic guards re-check the LIVE global
19
28
  // store right before anything lands (state may have changed mid-call).
@@ -34,7 +43,7 @@ export async function executeWrapupCommand(ctx, engine, invocation, policy = DEF
34
43
  splitSkipped.push({ key: item.key, reason: "not in the audited candidate list" });
35
44
  continue;
36
45
  }
37
- const blocked = splitPromoteBlocked(item, globalState, candidate.kind, policy);
46
+ const blocked = splitPromoteBlocked(item, globalState, candidate.kind, policy, candidate.metadata);
38
47
  if (blocked) {
39
48
  splitSkipped.push({ key: item.key, reason: blocked });
40
49
  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 TokenUsageTarget } from "./token-usage.js";
24
25
  import { type PromotionPolicy } from "./promotion.js";
25
26
  /** What should happen to one local entry at session end. */
26
27
  export type WrapupVerdict = "promote" | "archive" | "keep";
@@ -199,7 +200,7 @@ export declare function splitArchiveGuards(items: readonly WrapupItem[], candida
199
200
  * too thin, no near-duplicate global entry. A blocked split is dropped (the
200
201
  * entry still archives plain) rather than half-promoting a redundancy.
201
202
  */
202
- export declare function splitPromoteBlocked(item: WrapupItem, globalState: HarnessState, kind: RefinementKind, policy?: PromotionPolicy): string | undefined;
203
+ export declare function splitPromoteBlocked(item: WrapupItem, globalState: HarnessState, kind: RefinementKind, policy?: PromotionPolicy, candidateMetadata?: Record<string, unknown>): string | undefined;
203
204
  /**
204
205
  * Shared proposal builders for a WHOLE promotion — used by both the
205
206
  * `/evolve wrapup` command and the gate's local-fate dimension so the two
@@ -232,12 +233,16 @@ export declare function splitPromoteProposals(item: WrapupItem, candidate: Wrapu
232
233
  * injection). Undefined when nothing applies.
233
234
  */
234
235
  export declare function valenceStampProposal(item: WrapupItem, candidate: WrapupCandidate): RefinementProposal | undefined;
235
- export declare const WRAPUP_ASSESS_SYSTEM_PROMPT = "You are the /evolve session wrap-up assessor.\nA session is ending and its local harness entries need a fate. Classify each\nlisted entry exactly once:\n\n- \"promote\" \u2014 the content is a stable, durable, CROSS-SESSION reusable lesson:\n a durable user preference, a project-level fact or convention, a reusable\n procedure or skill. Future sessions would benefit from seeing it.\n- \"archive\" \u2014 the content is session-specific task progress, one-off noise,\n superseded or obsolete, or already covered by the global store (note\n \"covered globally\" in the reason), or stale (old + never injected \u2014 note\n \"stale (injectionCount=0, recency low)\" in the reason), or CONTRADICTED \u2014\n newer evidence in the trajectory or the global store disproves or overrides\n the entry's content (note what contradicts it in the reason).\n- \"keep\" \u2014 still actively useful to this session, or genuinely uncertain.\n\nRules:\n- CONTRADICTED: when evidence contradicts an entry, set \"contradicted\": true\n on that item (besides the verdict). Entries marked \"(contradicted N\u00D7\n before)\" were contradicted in earlier assessments \u2014 prefer \"archive\" for\n them unless you have concrete evidence the content is valid again.\n- When an entry is marked \"covered globally\" in the listing, prefer \"archive\"\n or \"keep\" over \"promote\" \u2014 promoting a duplicate gains nothing.\n- When an entry is marked \"stale\" (injectionCount=0 and low recency), prefer\n \"archive\" \u2014 the entry has never been used and is old, so it is unlikely to\n be needed again. Only \"keep\" if the content is clearly valuable despite low\n usage (e.g. a safety policy that rarely triggers but is critical).\n- Do not promote local task state, work-in-progress notes, or content tied to\n one session's ephemeral details.\n- Skills: only \"promote\" a skill entry that is a genuinely reusable procedure\n meeting the DSH skill quality standard; one-off workflows are \"archive\" or\n \"keep\".\n- SPLIT PROMOTION: when an entry mixes a stable, cross-session-reusable part\n WITH session-specific snapshot details, do NOT promote it whole. Instead\n give verdict \"archive\" WITH a \"promote\" sub-object holding a CLEANED\n version of only the durable part (a stable title + the persistent facts,\n stripped of dates/states/one-off figures). Ephemeral snapshot content stays\n out of the sub-object \u2014 it is left behind in the archive. A sub-object is\n only meaningful on \"archive\" verdicts.\n\nReturn JSON only:\n{\n \"rationale\": \"one or two sentences\",\n \"items\": [\n {\"key\": \"memory:foo\", \"verdict\": \"promote|archive|keep\", \"reason\": \"why\"},\n {\"key\": \"memory:bar\", \"verdict\": \"archive\", \"reason\": \"why\", \"contradicted\": true,\n \"promote\": {\"title\": \"cleaned stable title\", \"content\": \"cleaned durable part only\"}}\n ]\n}\nOnly keys from the provided list are allowed; any entry you omit defaults to \"keep\".";
236
+ export declare const WRAPUP_ASSESS_SYSTEM_PROMPT = "You are the /evolve session wrap-up assessor.\nA session is ending and its local harness entries need a fate. Classify each\nlisted entry exactly once:\n\n- \"promote\" \u2014 the content is a stable, durable, CROSS-SESSION reusable lesson:\n a durable user preference, a project-level fact or convention, a reusable\n procedure or skill. Future sessions would benefit from seeing it.\n- \"archive\" \u2014 the content is session-specific task progress, one-off noise,\n superseded or obsolete, or already covered by the global store (note\n \"covered globally\" in the reason), or stale (old + never injected \u2014 note\n \"stale (injectionCount=0, recency low)\" in the reason), or CONTRADICTED \u2014\n newer evidence in the trajectory or the global store disproves or overrides\n the entry's content (note what contradicts it in the reason).\n- \"keep\" \u2014 still actively useful to this session, or genuinely uncertain.\n\nRules:\n- CONTRADICTED: when evidence contradicts an entry, set \"contradicted\": true\n on that item (besides the verdict). Entries marked \"(contradicted N\u00D7\n before)\" were contradicted in earlier assessments \u2014 prefer \"archive\" for\n them unless you have concrete evidence the content is valid again.\n- When an entry is marked \"covered globally\" in the listing, prefer \"archive\"\n or \"keep\" over \"promote\" \u2014 promoting a duplicate gains nothing.\n- When an entry is marked \"stale\" (injectionCount=0 and low recency), prefer\n \"archive\" \u2014 the entry has never been used and is old, so it is unlikely to\n be needed again. Only \"keep\" if the content is clearly valuable despite low\n usage (e.g. a safety policy that rarely triggers but is critical).\n- Do not promote local task state, work-in-progress notes, or content tied to\n one session's ephemeral details.\n- Memory entries carry a recall type (user/feedback/project/reference): a\n memory WITHOUT one cannot be promoted (the guard skips it) \u2014 verdict such\n entries \"keep\" (or \"archive\" when they qualify) rather than \"promote\".\n- Skills: only \"promote\" a skill entry that is a genuinely reusable procedure\n meeting the DSH skill quality standard; one-off workflows are \"archive\" or\n \"keep\".\n- SPLIT PROMOTION: when an entry mixes a stable, cross-session-reusable part\n WITH session-specific snapshot details, do NOT promote it whole. Instead\n give verdict \"archive\" WITH a \"promote\" sub-object holding a CLEANED\n version of only the durable part (a stable title + the persistent facts,\n stripped of dates/states/one-off figures). Ephemeral snapshot content stays\n out of the sub-object \u2014 it is left behind in the archive. A sub-object is\n only meaningful on \"archive\" verdicts.\n\nReturn JSON only:\n{\n \"rationale\": \"one or two sentences\",\n \"items\": [\n {\"key\": \"memory:foo\", \"verdict\": \"promote|archive|keep\", \"reason\": \"why\"},\n {\"key\": \"memory:bar\", \"verdict\": \"archive\", \"reason\": \"why\", \"contradicted\": true,\n \"promote\": {\"title\": \"cleaned stable title\", \"content\": \"cleaned durable part only\"}}\n ]\n}\nOnly keys from the provided list are allowed; any entry you omit defaults to \"keep\".";
236
237
  export interface AssessOptions {
237
238
  /** Output token budget for the assessment call. */
238
239
  maxOutputTokens?: number;
239
240
  /** Abort signal forwarded to the model call. */
240
241
  signal?: AbortSignal;
242
+ /** Direct-call token ledger destination; phase is selected by the caller. */
243
+ tokenUsage?: TokenUsageTarget;
244
+ /** Ledger phase for this classifier call (manual wrapup or automatic fate). */
245
+ tokenUsagePhase?: "wrapup" | "fate";
241
246
  }
242
247
  /**
243
248
  * Ask the model to classify the audited local candidates. Routes through the
package/lib/wrapup.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { ARCHIVED_AT_KEY, PROMOTED_AT_KEY, PROMOTED_TO_KEY, SOURCE_SEQS_KEY, SOURCE_SESSION_KEY, SOURCED_FROM_KEY, VALENCE_NEGATIVE_KEY, isArchived } from "./types.js";
2
+ import { MEMORY_TYPE_KEY, isMemoryType } from "./types.js";
2
3
  import { extractJsonObject } from "./plan.js";
3
4
  import { compactText } from "./render.js";
4
5
  import { streamText } from "./llm-text.js";
5
6
  import { getUsageCount, loadUsage } from "./usage.js";
7
+ import { createTokenUsageObserver } from "./token-usage.js";
6
8
  import { recencyScore } from "./inject.js";
7
9
  import { DEFAULT_PROMOTION_POLICY, mostSimilarGlobalEntry, projectScopedReason, secretLeakReason } from "./promotion.js";
8
10
  export function candidateKey(kind, id) {
@@ -190,6 +192,13 @@ export function filterPromotable(items, globalState, candidates, policy = DEFAUL
190
192
  skipped.push({ key: item.key, reason: "already covered globally" });
191
193
  continue;
192
194
  }
195
+ if (candidate.kind === "memory" && !isMemoryType(candidate.metadata[MEMORY_TYPE_KEY])) {
196
+ skipped.push({
197
+ key: item.key,
198
+ reason: `memory has no recall type (metadata.${MEMORY_TYPE_KEY} one of user|feedback|project|reference) — update the local entry to classify it first, then promote`,
199
+ });
200
+ continue;
201
+ }
193
202
  const scoped = projectScopedReason(`${candidate.title}\n${candidate.content}`, policy);
194
203
  if (scoped) {
195
204
  skipped.push({ key: item.key, reason: scoped });
@@ -271,9 +280,12 @@ export function splitArchiveGuards(items, candidates) {
271
280
  * too thin, no near-duplicate global entry. A blocked split is dropped (the
272
281
  * entry still archives plain) rather than half-promoting a redundancy.
273
282
  */
274
- export function splitPromoteBlocked(item, globalState, kind, policy = DEFAULT_PROMOTION_POLICY) {
283
+ export function splitPromoteBlocked(item, globalState, kind, policy = DEFAULT_PROMOTION_POLICY, candidateMetadata) {
275
284
  if (!item.promote)
276
285
  return "no split payload";
286
+ if (kind === "memory" && !isMemoryType(candidateMetadata?.[MEMORY_TYPE_KEY])) {
287
+ return `split promotion needs the source entry classified (metadata.${MEMORY_TYPE_KEY} one of user|feedback|project|reference) — update it first`;
288
+ }
277
289
  if (globalCoverageDetected(globalState, kind, { id: "", title: item.promote.title })) {
278
290
  return "split promotion duplicates a globally covered topic";
279
291
  }
@@ -454,6 +466,9 @@ Rules:
454
466
  usage (e.g. a safety policy that rarely triggers but is critical).
455
467
  - Do not promote local task state, work-in-progress notes, or content tied to
456
468
  one session's ephemeral details.
469
+ - Memory entries carry a recall type (user/feedback/project/reference): a
470
+ memory WITHOUT one cannot be promoted (the guard skips it) — verdict such
471
+ entries "keep" (or "archive" when they qualify) rather than "promote".
457
472
  - Skills: only "promote" a skill entry that is a genuinely reusable procedure
458
473
  meeting the DSH skill quality standard; one-off workflows are "archive" or
459
474
  "keep".
@@ -511,6 +526,11 @@ export async function assessLocalEntries(ctx, agent, candidates, options = {}) {
511
526
  prompt: userPrompt,
512
527
  maxTokens: options.maxOutputTokens ?? 4096,
513
528
  signal: options.signal,
529
+ ...(options.tokenUsage
530
+ ? {
531
+ onUsage: createTokenUsageObserver(options.tokenUsage, options.tokenUsagePhase ?? "wrapup", agent.options.provider, agent.options.model),
532
+ }
533
+ : {}),
514
534
  });
515
535
  return parseWrapupAssessment(text, candidates);
516
536
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-continual-evolve",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
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": {
@@ -57,18 +57,18 @@
57
57
  },
58
58
  "peerDependencies": {
59
59
  "@deepseek-ai/cordis": "^4.0.1",
60
- "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6 || ^0.1.7-alpha.1",
61
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ^0.1.7-alpha.1",
62
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.6 || ^0.1.7-alpha.1",
60
+ "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6 || ^0.1.7-rc.1",
61
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ^0.1.7-rc.1",
62
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6 || ^0.1.7-rc.1",
63
63
  "@deepseek-ai/schemastery": "^3.18.1"
64
64
  },
65
65
  "devDependencies": {
66
- "@deepseek-ai/cordis": "4.0.3",
67
- "@deepseek-ai/dsh-agent": "0.1.7-alpha.1",
68
- "@deepseek-ai/dsh-commands": "0.1.7-alpha.1",
69
- "@deepseek-ai/dsh-home-paths": "0.1.7-alpha.1",
70
- "@deepseek-ai/dsh-llm": "0.1.7-alpha.1",
71
- "@deepseek-ai/dsh-tools": "0.1.7-alpha.1",
66
+ "@deepseek-ai/cordis": "4.0.4",
67
+ "@deepseek-ai/dsh-agent": "0.1.7-rc.1",
68
+ "@deepseek-ai/dsh-commands": "0.1.7-rc.1",
69
+ "@deepseek-ai/dsh-home-paths": "0.1.7-rc.1",
70
+ "@deepseek-ai/dsh-llm": "0.1.7-rc.1",
71
+ "@deepseek-ai/dsh-tools": "0.1.7-rc.1",
72
72
  "@deepseek-ai/schemastery": "3.18.3",
73
73
  "@types/node": "^22.10.0",
74
74
  "@vitest/coverage-v8": "^3.2.0",