dsh-continual-evolve 0.7.0 → 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/index.d.ts CHANGED
@@ -18,6 +18,15 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
18
18
  maxReviewInputChars: z<number, number, "defined">;
19
19
  /** Output budget for the cheap gate call. */
20
20
  reviewBudgetTokens: z<number, number, "defined">;
21
+ /**
22
+ * Planner prefix-cache routing for the gate and planner inputs: `auto`
23
+ * prepends a session-derived message prefix when the session shows
24
+ * cache-read evidence (dropping the redundant flat trajectory text),
25
+ * `session` always prefixes, `off` keeps the legacy flat-text input.
26
+ */
27
+ plannerPrefixCache: z<"session" | "off" | "auto", "session" | "off" | "auto", "defined">;
28
+ /** Session-prefix budget for Route A planning inputs, in characters. */
29
+ plannerPrefixMaxChars: z<number, number, "defined">;
21
30
  /** After an approved gate run with applied edits, queue a visible follow-up notice. */
22
31
  notifyOnAutoReview: z<boolean, boolean, "defined">;
23
32
  /** Cross-session (global) edits require an explicit human approval. */
@@ -74,6 +83,25 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
74
83
  promotionMinChars: z<number, number, "defined">;
75
84
  /** Entry-directory lines injected per build before folding into a counter. */
76
85
  injectionDirectoryLines: z<number, number, "defined">;
86
+ /**
87
+ * Storage hygiene (#20): how much append-only past each write keeps.
88
+ * `snapshots` = full-state copies per store, `refinements` = tail lines
89
+ * per store history, `reviews` = tail lines of the shared audit trail,
90
+ * `tokenUsage` = tail lines of the direct-call token ledger.
91
+ * Absent fields fall back to the store defaults (20 / 500 / 500 / 500) —
92
+ * rollback needs recent snapshots, readers need a recent window.
93
+ */
94
+ historyRetain: z<Schemastery.ObjectS<NoInfer<{
95
+ snapshots: z<number, number, "plain">;
96
+ refinements: z<number, number, "plain">;
97
+ reviews: z<number, number, "plain">;
98
+ tokenUsage: z<number, number, "plain">;
99
+ }>>, Schemastery.ObjectT<NoInfer<{
100
+ snapshots: z<number, number, "plain">;
101
+ refinements: z<number, number, "plain">;
102
+ reviews: z<number, number, "plain">;
103
+ tokenUsage: z<number, number, "plain">;
104
+ }>>, "plain">;
77
105
  }>>, Schemastery.ObjectT<NoInfer<{
78
106
  /** Root for evolution stores; defaults to the resolved DSH home. */
79
107
  baseDir: z<string, string, "plain">;
@@ -87,6 +115,15 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
87
115
  maxReviewInputChars: z<number, number, "defined">;
88
116
  /** Output budget for the cheap gate call. */
89
117
  reviewBudgetTokens: z<number, number, "defined">;
118
+ /**
119
+ * Planner prefix-cache routing for the gate and planner inputs: `auto`
120
+ * prepends a session-derived message prefix when the session shows
121
+ * cache-read evidence (dropping the redundant flat trajectory text),
122
+ * `session` always prefixes, `off` keeps the legacy flat-text input.
123
+ */
124
+ plannerPrefixCache: z<"session" | "off" | "auto", "session" | "off" | "auto", "defined">;
125
+ /** Session-prefix budget for Route A planning inputs, in characters. */
126
+ plannerPrefixMaxChars: z<number, number, "defined">;
90
127
  /** After an approved gate run with applied edits, queue a visible follow-up notice. */
91
128
  notifyOnAutoReview: z<boolean, boolean, "defined">;
92
129
  /** Cross-session (global) edits require an explicit human approval. */
@@ -143,6 +180,25 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
143
180
  promotionMinChars: z<number, number, "defined">;
144
181
  /** Entry-directory lines injected per build before folding into a counter. */
145
182
  injectionDirectoryLines: z<number, number, "defined">;
183
+ /**
184
+ * Storage hygiene (#20): how much append-only past each write keeps.
185
+ * `snapshots` = full-state copies per store, `refinements` = tail lines
186
+ * per store history, `reviews` = tail lines of the shared audit trail,
187
+ * `tokenUsage` = tail lines of the direct-call token ledger.
188
+ * Absent fields fall back to the store defaults (20 / 500 / 500 / 500) —
189
+ * rollback needs recent snapshots, readers need a recent window.
190
+ */
191
+ historyRetain: z<Schemastery.ObjectS<NoInfer<{
192
+ snapshots: z<number, number, "plain">;
193
+ refinements: z<number, number, "plain">;
194
+ reviews: z<number, number, "plain">;
195
+ tokenUsage: z<number, number, "plain">;
196
+ }>>, Schemastery.ObjectT<NoInfer<{
197
+ snapshots: z<number, number, "plain">;
198
+ refinements: z<number, number, "plain">;
199
+ reviews: z<number, number, "plain">;
200
+ tokenUsage: z<number, number, "plain">;
201
+ }>>, "plain">;
146
202
  }>>, "plain">;
147
203
  /**
148
204
  * Structurally typed resolved config (loader passes the validated object).
package/lib/index.js CHANGED
@@ -37,6 +37,15 @@ export const Config = z.object({
37
37
  maxReviewInputChars: z.natural().default(40000),
38
38
  /** Output budget for the cheap gate call. */
39
39
  reviewBudgetTokens: z.natural().default(4096),
40
+ /**
41
+ * Planner prefix-cache routing for the gate and planner inputs: `auto`
42
+ * prepends a session-derived message prefix when the session shows
43
+ * cache-read evidence (dropping the redundant flat trajectory text),
44
+ * `session` always prefixes, `off` keeps the legacy flat-text input.
45
+ */
46
+ plannerPrefixCache: z.union([z.const("auto"), z.const("session"), z.const("off")]).default("auto"),
47
+ /** Session-prefix budget for Route A planning inputs, in characters. */
48
+ plannerPrefixMaxChars: z.natural().default(12000),
40
49
  /** After an approved gate run with applied edits, queue a visible follow-up notice. */
41
50
  notifyOnAutoReview: z.boolean().default(true),
42
51
  /** Cross-session (global) edits require an explicit human approval. */
@@ -93,6 +102,20 @@ export const Config = z.object({
93
102
  promotionMinChars: z.natural().default(100),
94
103
  /** Entry-directory lines injected per build before folding into a counter. */
95
104
  injectionDirectoryLines: z.natural().default(15),
105
+ /**
106
+ * Storage hygiene (#20): how much append-only past each write keeps.
107
+ * `snapshots` = full-state copies per store, `refinements` = tail lines
108
+ * per store history, `reviews` = tail lines of the shared audit trail,
109
+ * `tokenUsage` = tail lines of the direct-call token ledger.
110
+ * Absent fields fall back to the store defaults (20 / 500 / 500 / 500) —
111
+ * rollback needs recent snapshots, readers need a recent window.
112
+ */
113
+ historyRetain: z.object({
114
+ snapshots: z.natural(),
115
+ refinements: z.natural(),
116
+ reviews: z.natural(),
117
+ tokenUsage: z.natural(),
118
+ }),
96
119
  });
97
120
  export function apply(ctx, config) {
98
121
  const baseDir = resolveDshHome(config.baseDir);
@@ -111,7 +134,7 @@ export function apply(ctx, config) {
111
134
  .warn(`skill materialization failed for ${result.id}: ${cause instanceof Error ? cause.message : String(cause)}`);
112
135
  }
113
136
  },
114
- });
137
+ }, config.historyRetain ? { historyRetain: { ...config.historyRetain } } : {});
115
138
  ctx.provide(EVOLUTION_SERVICE, { engine, baseDir });
116
139
  ctx.systemPrompt.section({
117
140
  name: "tool:continual-evolve",
@@ -139,6 +162,7 @@ export function apply(ctx, config) {
139
162
  autoRollbackOnReject: config.autoRollbackOnReject ?? true,
140
163
  autoCase: config.autoCase ?? true,
141
164
  promotionPolicy,
165
+ ...(config.autoReview !== undefined ? { autoReview: config.autoReview } : {}),
142
166
  });
143
167
  // Plugin-owned file logging: every cordis log message lands in
144
168
  // <baseDir>/evolve/plugin.log regardless of how dsh web was launched —
@@ -165,7 +189,10 @@ export function apply(ctx, config) {
165
189
  promotionPolicy,
166
190
  autoCase: config.autoCase ?? true,
167
191
  rubricKey,
192
+ ...(config.historyRetain?.reviews !== undefined ? { reviewsRetain: config.historyRetain.reviews } : {}),
168
193
  ...(config.reviewModel ? { reviewModel: config.reviewModel } : {}),
194
+ ...(config.plannerPrefixCache ? { prefixCacheMode: config.plannerPrefixCache } : {}),
195
+ ...(config.plannerPrefixMaxChars !== undefined ? { prefixMaxChars: config.plannerPrefixMaxChars } : {}),
169
196
  });
170
197
  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)`);
171
198
  }
package/lib/llm-text.d.ts CHANGED
@@ -9,6 +9,16 @@
9
9
  * and JSON parsing.
10
10
  */
11
11
  import type { Context } from "@deepseek-ai/cordis";
12
+ import { type Message, type TokenUsage } from "@deepseek-ai/dsh-llm";
13
+ /** Terminal outcome of one shared direct LLM call. */
14
+ export type StreamTextOutcome = "success" | "max-tokens" | "error" | "aborted" | "empty";
15
+ /** Provider usage and terminal result observed after the stream settles. */
16
+ export interface StreamTextObservation {
17
+ usage?: TokenUsage;
18
+ outcome: StreamTextOutcome;
19
+ }
20
+ /** Diagnostic observer invoked before streamText returns or throws. */
21
+ export type StreamTextUsageObserver = (observation: StreamTextObservation) => void;
12
22
  export interface StreamTextOptions {
13
23
  provider: string;
14
24
  model: string;
@@ -16,6 +26,19 @@ export interface StreamTextOptions {
16
26
  prompt: string;
17
27
  maxTokens?: number;
18
28
  signal?: AbortSignal | undefined;
29
+ /**
30
+ * Route A session prefix: session-derived messages sent before the
31
+ * trailing caller message so providers with prompt caching can serve
32
+ * the shared context at cache-read price. Empty/omitted keeps the
33
+ * legacy single-message request.
34
+ */
35
+ prefixMessages?: readonly Message[];
36
+ /**
37
+ * Observe the provider-reported usage and terminal outcome after the
38
+ * stream settles. The callback runs before success/error return and is
39
+ * diagnostic-only: throwing from it never changes the model call result.
40
+ */
41
+ onUsage?: StreamTextUsageObserver;
19
42
  }
20
43
  /**
21
44
  * Stream a single-turn text completion through `ctx.llm`. Forces
package/lib/llm-text.js CHANGED
@@ -11,40 +11,57 @@ import { EVOLVE_MESSAGE_SOURCE } from "./message-source.js";
11
11
  */
12
12
  export async function streamText(ctx, opts) {
13
13
  const assembler = new BlockAssembler();
14
- for await (const chunk of ctx.llm.stream({
15
- provider: opts.provider,
16
- model: opts.model,
17
- system: opts.system,
18
- messages: [
19
- createUserMessage({
20
- content: [{ type: "text", text: opts.prompt }],
21
- source: EVOLVE_MESSAGE_SOURCE,
22
- }),
23
- ],
24
- reasoningEffort: ReasoningEffortId("off"),
25
- maxTokens: opts.maxTokens ?? 8000,
26
- ...(opts.signal ? { signal: opts.signal } : {}),
27
- })) {
28
- assembler.push(chunk);
14
+ let outcome = "error";
15
+ try {
16
+ for await (const chunk of ctx.llm.stream({
17
+ provider: opts.provider,
18
+ model: opts.model,
19
+ system: opts.system,
20
+ messages: [
21
+ ...(opts.prefixMessages ?? []),
22
+ createUserMessage({
23
+ content: [{ type: "text", text: opts.prompt }],
24
+ source: EVOLVE_MESSAGE_SOURCE,
25
+ }),
26
+ ],
27
+ reasoningEffort: ReasoningEffortId("off"),
28
+ maxTokens: opts.maxTokens ?? 8000,
29
+ ...(opts.signal ? { signal: opts.signal } : {}),
30
+ })) {
31
+ assembler.push(chunk);
32
+ }
33
+ const finish = assembler.finish;
34
+ if (finish.kind === "error") {
35
+ outcome = "error";
36
+ throw new Error(`evolve: LLM call failed: ${finish.failure?.message ?? "unknown"}`);
37
+ }
38
+ if (finish.kind === "aborted") {
39
+ outcome = "aborted";
40
+ throw new Error("evolve: LLM call aborted");
41
+ }
42
+ if (finish.kind === "max-tokens") {
43
+ outcome = "max-tokens";
44
+ throw new Error("evolve: LLM output budget exhausted (max-tokens)");
45
+ }
46
+ const text = assembler
47
+ .blocks()
48
+ .filter((block) => block.type === "text")
49
+ .map((block) => block.text)
50
+ .join("\n");
51
+ if (text.length === 0) {
52
+ outcome = "empty";
53
+ throw new Error("evolve: LLM produced no text output");
54
+ }
55
+ outcome = "success";
56
+ return text;
29
57
  }
30
- const finish = assembler.finish;
31
- if (finish.kind === "error") {
32
- throw new Error(`evolve: LLM call failed: ${finish.failure?.message ?? "unknown"}`);
58
+ finally {
59
+ try {
60
+ opts.onUsage?.({ ...(assembler.usage ? { usage: assembler.usage } : {}), outcome });
61
+ }
62
+ catch {
63
+ // Usage observation is diagnostic; its failure must not alter the model call.
64
+ }
33
65
  }
34
- if (finish.kind === "aborted") {
35
- throw new Error("evolve: LLM call aborted");
36
- }
37
- if (finish.kind === "max-tokens") {
38
- throw new Error("evolve: LLM output budget exhausted (max-tokens)");
39
- }
40
- const text = assembler
41
- .blocks()
42
- .filter((block) => block.type === "text")
43
- .map((block) => block.text)
44
- .join("\n");
45
- if (text.length === 0) {
46
- throw new Error("evolve: LLM produced no text output");
47
- }
48
- return text;
49
66
  }
50
67
  //# sourceMappingURL=llm-text.js.map
package/lib/planner.d.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  import type { Context } from "@deepseek-ai/cordis";
10
10
  import type { Agent } from "@deepseek-ai/dsh-agent";
11
11
  import type { HarnessState, RefinementProposal, RefinementResult } from "./types.js";
12
+ import { type PrefixCacheOptions } from "./prefix-cache.js";
13
+ import { type TokenUsageTarget } from "./token-usage.js";
12
14
  export declare const PLANNER_SYSTEM_PROMPT = "You are the /evolve continual harness subsystem.\n\nYour job is to improve the editable continual harness state. Instead of\nsummarizing the conversation you emit precise Create, Update, or Delete edits\nto reusable state: prompt notes, memories, skills, and subagent specs.\n\nRules:\n- The base system prompt is immutable and MUST NOT be rewritten (never edit id \"base_system_prompt\").\n- Prefer small evidence-backed edits. If no useful edit is justified, return an empty edits array.\n- prompt = narrow behavioral policy addendums; memory = ONE FACT per entry, typed via metadata.memoryType\n- (user = who the user is; feedback = a pitfall/correction or confirmed approach, MUST carry Why + How to apply;\n- project = work goal/constraint not derivable from the repo; reference = URL/dashboard/ticket pointer).\n- Memory rules: one fact per entry (title = one-line retrieval hook, content = the fact alone);\n- never save what the repo already records (code structure, past fixes, git history, AGENTS.md) or what only\n- matters to this conversation \u2014 save the non-obvious point instead; before creating, update the existing\n- entry when one already covers the fact (creates carry metadata {memoryType} or they are rejected);\n- skill = repeatable procedures (must carry a python reference {type:\"python\", import, callable}\n and an arguments object); subagent = reusable delegation roles.\n- Skill entries are authored to the DSH skill quality standard\n (skill-creator, distilled from the official deepseek-harness 11 skills;\n the full facts are provided in the <skill_quality_standard> block below):\n only for a REAL trigger scenario grounded in the trajectory\n (who, in what real task, what signal) \u2014 never invent one to pad the store;\n never duplicate the official 11 skills or existing entries; content is a\n SKILL.md document (frontmatter routing with \"use when / do not use when\"\n description, boundary declaration, prerequisites and exclusions, layered\n information, verifiable completion criteria). Self-check every proposed\n skill against the 7 structural features and state the result in its\n reason field.\n- Repeated multi-step workflows (session start/end routines, recurring\n wrap-up or handoff procedures) may be proposed as guidance skills:\n kind=skill, skill_kind=\"guidance\", content = a SKILL.md document (no\n python reference \u2014 executable skills keep requiring reference +\n arguments). Only propose with repeated evidence in the trajectory, never\n for one-off flows. Guidance skills materialize as discoverable SKILL.md\n files under <skillsRoot>/<kebab-name>/SKILL.md and are always offered to\n the user for a decision before they land.\n- Local edits are session-scoped; global edits persist across sessions.\n- Gap C2: EVERY edit MUST include a \"blastRadius\" field indicating how broadly\n the edit applies: \"general\" (cross-project tactical rule, applies everywhere),\n \"project\" (valid for the current project/repo), or \"session\" (one-off,\n specific to this session's context). The review gate validates that\n local-scope edits use \"session\" or \"project\", and global-scope edits use\n \"general\" or \"project\". When in doubt, prefer narrower blast radius.\n- Ground every edit in evidence: the session trajectory (recent direct user\n messages) is provided when available; prefer edits backed by it over\n speculation, and never invent preferences the user did not express.\n- Stale entries (superseded by newer ones, never referenced in recent\n trajectories, obsolete facts): propose action \"archive\" instead of\n \"delete\" \u2014 archive hides the entry from injection while keeping its data\n restorable; it requires only kind + id.\n- Output JSON only, exactly this shape:\n{\n \"summary\": \"one sentence\",\n \"rationale\": \"why these edits are justified by the evidence\",\n \"expectedOutcome\": \"what should improve and how to validate it\",\n \"edits\": [\n {\n \"action\": \"create|update|delete\",\n \"kind\": \"prompt|memory|skill|subagent\",\n \"id\": \"stable id for update/delete, optional for create\",\n \"title\": \"required for create/update except delete\",\n \"content\": \"required for create/update except delete\",\n \"path\": \"optional grouping path\",\n \"reference\": {\"type\":\"python\",\"import\":\"pkg.mod\",\"callable\":\"fn\"} ,\n \"arguments\": {\"name\": {\"type\":\"string\",\"required\":true,\"description\":\"...\"}},\n \"skill_kind\": \"executable|guidance (optional; skill kind only \u2014 guidance = SKILL.md document without reference)\",\n \"metadata\": {},\n \"reason\": \"why this edit is useful\",\n \"blastRadius\": \"general|project|session\"\n }\n ]\n}";
13
15
  export interface PlanOptions {
14
16
  agent: Agent;
@@ -32,6 +34,16 @@ export interface PlanOptions {
32
34
  global?: boolean;
33
35
  signal?: AbortSignal;
34
36
  maxOutputTokens?: number;
37
+ /**
38
+ * Prefix-cache routing for the planning input. Route A prepends a
39
+ * session-derived message prefix and drops the auto-extracted
40
+ * trajectory block (an explicitly passed `trajectory` is always kept);
41
+ * Route B keeps the legacy flat-text input. Absent → auto-detect with
42
+ * the default budget.
43
+ */
44
+ prefixCache?: PrefixCacheOptions;
45
+ /** Direct-call token ledger destination; the planner phase is fixed by this module. */
46
+ tokenUsage?: TokenUsageTarget;
35
47
  }
36
48
  export declare function planWithLlm(ctx: Context, options: PlanOptions): Promise<RefinementProposal>;
37
49
  //# sourceMappingURL=planner.d.ts.map
package/lib/planner.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { parseProposal } from "./plan.js";
2
2
  import { formatHarnessStateForPrompt, historyForPrompt } from "./render.js";
3
- import { recentUserText } from "./inject.js";
3
+ import { recentUserText, sessionEventsOf } from "./inject.js";
4
+ import { buildPrefixMessages, detectPlannerRoute, resolvePrefixCache } from "./prefix-cache.js";
4
5
  import { skillQualityGuide } from "./skillquality.js";
5
6
  import { streamText } from "./llm-text.js";
7
+ import { createTokenUsageObserver } from "./token-usage.js";
6
8
  export const PLANNER_SYSTEM_PROMPT = `You are the /evolve continual harness subsystem.
7
9
 
8
10
  Your job is to improve the editable continual harness state. Instead of
@@ -84,10 +86,22 @@ export async function planWithLlm(ctx, options) {
84
86
  const scopeInstruction = options.global
85
87
  ? "Requested scope: global. Only propose stable cross-session lessons, durable preferences, reusable skills/subagents, or explicitly project-qualified facts."
86
88
  : "Requested scope: local. Prefer session-scoped edits for current task progress; global entries are read-only context — do not propose update/delete for them.";
87
- // Ground the plan in the caller's session: the trajectory block is the
88
- // most recent direct user messages ("" when none qualify — the block is
89
- // then omitted entirely, keeping an empty trajectory zero-cost).
90
- const trajectory = options.trajectory ?? recentUserText(agent);
89
+ // Ground the plan in the caller's session: Route A carries the context
90
+ // as a session-derived message prefix (cache-eligible on providers with
91
+ // prompt caching) and drops the auto-extracted trajectory block; Route B
92
+ // keeps the legacy flat-text block. An empty prefix falls back to B.
93
+ const events = sessionEventsOf(agent);
94
+ const routing = resolvePrefixCache(options.prefixCache);
95
+ const route = detectPlannerRoute(events, routing.mode);
96
+ let prefixMessages = [];
97
+ if (route === "A") {
98
+ prefixMessages = buildPrefixMessages(events, {
99
+ ...(agent.options.provider ? { provider: agent.options.provider } : {}),
100
+ ...(agent.options.model ? { model: agent.options.model } : {}),
101
+ maxChars: routing.maxChars,
102
+ });
103
+ }
104
+ const trajectory = prefixMessages.length > 0 ? (options.trajectory ?? "") : (options.trajectory ?? recentUserText(agent));
91
105
  // The skill quality standard is always present: the skill-creator
92
106
  // template facts when installed, the builtin distilled guide otherwise
93
107
  // (~1KB — planning is low-frequency, and the standard keeps skill
@@ -111,6 +125,10 @@ export async function planWithLlm(ctx, options) {
111
125
  prompt: userPrompt,
112
126
  maxTokens: options.maxOutputTokens ?? 8000,
113
127
  signal: options.signal,
128
+ ...(prefixMessages.length > 0 ? { prefixMessages } : {}),
129
+ ...(options.tokenUsage
130
+ ? { onUsage: createTokenUsageObserver(options.tokenUsage, "planner", agent.options.provider, agent.options.model) }
131
+ : {}),
114
132
  });
115
133
  return parseProposal(text);
116
134
  }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Prefix-cache-aware planning input routing (Route A/B).
3
+ *
4
+ * The planner and the review gate re-send session context on every call as
5
+ * flat text. On providers with prompt caching that context is re-billed at
6
+ * full price each time. Route A instead prepends session-derived messages —
7
+ * the same evidence in structured form, eligible for cache-read pricing —
8
+ * and drops the redundant flat trajectory block. Route B keeps the legacy
9
+ * flat-text input byte-for-byte.
10
+ *
11
+ * Adapted from dsh-continual-harness (Route A/B + cache-detect): this port
12
+ * deliberately does NOT reproduce the host loop's byte-identical cache key
13
+ * (no deriveMessages/tools/sessionId plumbing on this seam), so a hit needs
14
+ * a provider that keys on the message-prefix content. Sources are stamped
15
+ * honestly and every reduction is documented below.
16
+ */
17
+ import { type AssistantMessage, type UserMessage } from "@deepseek-ai/dsh-llm";
18
+ /** Planning input shape: A = session-derived message prefix, B = flat-text trajectory. */
19
+ export type PlannerRoute = "A" | "B";
20
+ /** Routing mode: auto-detect from cache evidence, always prefix, or never. */
21
+ export type PlannerPrefixCacheMode = "auto" | "session" | "off";
22
+ /** Per-call routing overrides; absent fields fall back to the resolved defaults. */
23
+ export interface PrefixCacheOptions {
24
+ mode?: PlannerPrefixCacheMode;
25
+ maxChars?: number;
26
+ }
27
+ /** Default session-prefix budget for one Route A input, in characters. */
28
+ export declare const DEFAULT_PREFIX_MAX_CHARS = 12000;
29
+ /**
30
+ * Resolve per-call overrides against the defaults.
31
+ *
32
+ * @param opts - sparse per-call overrides.
33
+ * @returns the effective mode (default "auto") and char budget.
34
+ * @throws When maxChars is present but not a positive safe integer (fail loud on misconfiguration).
35
+ */
36
+ export declare function resolvePrefixCache(opts?: PrefixCacheOptions): {
37
+ mode: PlannerPrefixCacheMode;
38
+ maxChars: number;
39
+ };
40
+ /**
41
+ * True when any assistant message in this session reported cache-read
42
+ * tokens — evidence the provider can serve a warm prefix.
43
+ *
44
+ * @param events - session log rows, newest last.
45
+ * @returns whether cache evidence exists (false on an empty/unreadable log).
46
+ */
47
+ export declare function hasCacheEvidence(events: readonly unknown[]): boolean;
48
+ /**
49
+ * Pick the planning input route for one call.
50
+ *
51
+ * @param events - session log rows, newest last.
52
+ * @param mode - routing mode (default "auto").
53
+ * @returns "A" when the session prefix should carry the context, else "B".
54
+ */
55
+ export declare function detectPlannerRoute(events: readonly unknown[], mode?: PlannerPrefixCacheMode): PlannerRoute;
56
+ /**
57
+ * Rebuild a tail-biased session prefix from the event log.
58
+ *
59
+ * Inclusion mirrors the trajectory grounding (only direct human `user`
60
+ * speech plus the model's own assistant text), so injected plugin context
61
+ * and tool results never echo back into the planning input. Whole messages
62
+ * are dropped oldest-first over budget; a single over-budget message keeps
63
+ * its tail.
64
+ *
65
+ * @param events - session log rows, newest last.
66
+ * @param opts - provider/model stamps the assistant messages (the calling
67
+ * agent's own route); maxChars caps the prefix (default {@link DEFAULT_PREFIX_MAX_CHARS}).
68
+ * @returns the prefix messages, oldest first ([] when nothing qualifies —
69
+ * callers fall back to Route B).
70
+ * @throws When assistant text exists but provider/model is missing (an
71
+ * assistant message cannot be honestly stamped without its route).
72
+ */
73
+ export declare function buildPrefixMessages(events: readonly unknown[], opts: {
74
+ provider?: string;
75
+ model?: string;
76
+ maxChars?: number;
77
+ }): (UserMessage | AssistantMessage)[];
78
+ //# sourceMappingURL=prefix-cache.d.ts.map
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Prefix-cache-aware planning input routing (Route A/B).
3
+ *
4
+ * The planner and the review gate re-send session context on every call as
5
+ * flat text. On providers with prompt caching that context is re-billed at
6
+ * full price each time. Route A instead prepends session-derived messages —
7
+ * the same evidence in structured form, eligible for cache-read pricing —
8
+ * and drops the redundant flat trajectory block. Route B keeps the legacy
9
+ * flat-text input byte-for-byte.
10
+ *
11
+ * Adapted from dsh-continual-harness (Route A/B + cache-detect): this port
12
+ * deliberately does NOT reproduce the host loop's byte-identical cache key
13
+ * (no deriveMessages/tools/sessionId plumbing on this seam), so a hit needs
14
+ * a provider that keys on the message-prefix content. Sources are stamped
15
+ * honestly and every reduction is documented below.
16
+ */
17
+ import { createAssistantMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
18
+ /** Default session-prefix budget for one Route A input, in characters. */
19
+ export const DEFAULT_PREFIX_MAX_CHARS = 12000;
20
+ /**
21
+ * Resolve per-call overrides against the defaults.
22
+ *
23
+ * @param opts - sparse per-call overrides.
24
+ * @returns the effective mode (default "auto") and char budget.
25
+ * @throws When maxChars is present but not a positive safe integer (fail loud on misconfiguration).
26
+ */
27
+ export function resolvePrefixCache(opts) {
28
+ const mode = opts?.mode ?? "auto";
29
+ const maxChars = opts?.maxChars ?? DEFAULT_PREFIX_MAX_CHARS;
30
+ if (!Number.isSafeInteger(maxChars) || maxChars < 1) {
31
+ throw new Error(`evolve: prefix-cache maxChars must be a positive integer, got ${String(opts?.maxChars)}`);
32
+ }
33
+ return { mode, maxChars };
34
+ }
35
+ /**
36
+ * True when any assistant message in this session reported cache-read
37
+ * tokens — evidence the provider can serve a warm prefix.
38
+ *
39
+ * @param events - session log rows, newest last.
40
+ * @returns whether cache evidence exists (false on an empty/unreadable log).
41
+ */
42
+ export function hasCacheEvidence(events) {
43
+ for (const raw of events) {
44
+ if (typeof raw !== "object" || raw === null)
45
+ continue;
46
+ const event = raw;
47
+ if (event.type !== "assistant/message")
48
+ continue;
49
+ const read = event.data?.usage?.cacheReadTokens;
50
+ if (typeof read === "number" && read > 0)
51
+ return true;
52
+ }
53
+ return false;
54
+ }
55
+ /**
56
+ * Pick the planning input route for one call.
57
+ *
58
+ * @param events - session log rows, newest last.
59
+ * @param mode - routing mode (default "auto").
60
+ * @returns "A" when the session prefix should carry the context, else "B".
61
+ */
62
+ export function detectPlannerRoute(events, mode) {
63
+ const resolved = mode ?? "auto";
64
+ if (resolved === "session")
65
+ return "A";
66
+ if (resolved === "off")
67
+ return "B";
68
+ return hasCacheEvidence(events) ? "A" : "B";
69
+ }
70
+ /**
71
+ * Extract one message's text without depending on the dsh-llm ContentBlock
72
+ * type: string blocks pass through, object blocks contribute their `text`
73
+ * field when present. Non-text blocks (tool calls, images) are dropped.
74
+ */
75
+ function blockText(content) {
76
+ if (typeof content === "string")
77
+ return content;
78
+ if (!Array.isArray(content))
79
+ return "";
80
+ const parts = [];
81
+ for (const block of content) {
82
+ if (typeof block === "string") {
83
+ parts.push(block);
84
+ }
85
+ else if (block !== null && typeof block === "object" && "text" in block && typeof block.text === "string") {
86
+ parts.push(block.text);
87
+ }
88
+ }
89
+ return parts.filter((text) => text.length > 0).join(" ");
90
+ }
91
+ /**
92
+ * Rebuild a tail-biased session prefix from the event log.
93
+ *
94
+ * Inclusion mirrors the trajectory grounding (only direct human `user`
95
+ * speech plus the model's own assistant text), so injected plugin context
96
+ * and tool results never echo back into the planning input. Whole messages
97
+ * are dropped oldest-first over budget; a single over-budget message keeps
98
+ * its tail.
99
+ *
100
+ * @param events - session log rows, newest last.
101
+ * @param opts - provider/model stamps the assistant messages (the calling
102
+ * agent's own route); maxChars caps the prefix (default {@link DEFAULT_PREFIX_MAX_CHARS}).
103
+ * @returns the prefix messages, oldest first ([] when nothing qualifies —
104
+ * callers fall back to Route B).
105
+ * @throws When assistant text exists but provider/model is missing (an
106
+ * assistant message cannot be honestly stamped without its route).
107
+ */
108
+ export function buildPrefixMessages(events, opts) {
109
+ const maxChars = opts.maxChars ?? DEFAULT_PREFIX_MAX_CHARS;
110
+ if (!Number.isSafeInteger(maxChars) || maxChars < 1) {
111
+ throw new Error(`evolve: prefix-cache maxChars must be a positive integer, got ${String(opts.maxChars)}`);
112
+ }
113
+ const pairs = [];
114
+ for (let i = events.length - 1; i >= 0; i -= 1) {
115
+ const raw = events[i];
116
+ if (typeof raw !== "object" || raw === null)
117
+ continue;
118
+ const event = raw;
119
+ const isUser = event.type === "user/message";
120
+ const isAssistant = event.type === "assistant/message";
121
+ if (!isUser && !isAssistant)
122
+ continue;
123
+ if (isUser) {
124
+ const kind = event.data?.source?.kind;
125
+ if (kind !== undefined && kind !== "user")
126
+ continue;
127
+ }
128
+ const text = blockText(event.data?.content).trim();
129
+ if (text.length === 0)
130
+ continue;
131
+ pairs.push({ role: isUser ? "user" : "assistant", text });
132
+ }
133
+ if (pairs.length === 0)
134
+ return [];
135
+ // Collected newest-first; restore conversation order before budgeting.
136
+ pairs.reverse();
137
+ // Tail-biased budget: drop oldest whole messages over budget; a lone
138
+ // over-budget message keeps its tail.
139
+ let total = pairs.reduce((sum, pair) => sum + pair.text.length, 0);
140
+ while (pairs.length > 1 && total > maxChars) {
141
+ const dropped = pairs.shift();
142
+ total -= dropped?.text.length ?? 0;
143
+ }
144
+ const oldest = pairs[0];
145
+ if (oldest !== undefined && total > maxChars) {
146
+ oldest.text = oldest.text.slice(-maxChars);
147
+ }
148
+ const messages = [];
149
+ for (const pair of pairs) {
150
+ if (pair.role === "user") {
151
+ messages.push(createUserMessage({
152
+ source: { kind: "user" },
153
+ content: [{ type: "text", text: pair.text }],
154
+ }));
155
+ }
156
+ else {
157
+ if (!opts.provider || !opts.model) {
158
+ throw new Error("evolve: prefix-cache needs the agent provider/model to stamp assistant messages");
159
+ }
160
+ messages.push(createAssistantMessage({
161
+ source: { provider: opts.provider, model: opts.model },
162
+ content: [{ type: "text", text: pair.text }],
163
+ }));
164
+ }
165
+ }
166
+ return messages;
167
+ }
168
+ //# sourceMappingURL=prefix-cache.js.map
package/lib/review.d.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  import type { Context } from "@deepseek-ai/cordis";
9
9
  import type { Agent } from "@deepseek-ai/dsh-agent";
10
10
  import type { HarnessState, RefinementResult } from "./types.js";
11
+ import { type PrefixCacheOptions } from "./prefix-cache.js";
12
+ import { type TokenUsageTarget } from "./token-usage.js";
11
13
  export interface AutoRefineReview {
12
14
  shouldRefine: boolean;
13
15
  rationale: string;
@@ -30,6 +32,15 @@ export interface ReviewOptions {
30
32
  /** Gap C1: optional provider/model override for the review gate (cheaper model). */
31
33
  overrideProvider?: string;
32
34
  overrideModel?: string;
35
+ /**
36
+ * Prefix-cache routing for the gate input. Route A prepends a
37
+ * session-derived message prefix and drops the flat `<conversation>`
38
+ * block (same source, structured form); Route B keeps it. Absent →
39
+ * auto-detect with the default budget.
40
+ */
41
+ prefixCache?: PrefixCacheOptions;
42
+ /** Direct-call token ledger destination; the review phase is fixed by this module. */
43
+ tokenUsage?: TokenUsageTarget;
33
44
  }
34
45
  export declare const AUTO_REVIEW_SYSTEM_PROMPT = "You are the automatic /evolve review gate.\n\nDecide whether this checkpoint should run /evolve. Auto /evolve writes local\nharness state by default, so approve when the trajectory contains evidence\nuseful to this session's future turns: a repeated failure, a reusable tactic,\na repeated delegation role, a durable fact or preference, a user correction\nthat should persist, or a narrow behavioral policy.\n\nThe current harness state below includes GLOBAL entries (scope=global) plus\nthis session's local entries (scope=local). When a topic is already covered\nby a global entry, do NOT approve a local duplicate of it \u2014 decline and say\nin the rationale that the topic is already covered globally.\n\nReject one-off noise, unsupported hypotheses, transient tool outputs, and\nrequests that carry no reusable content.\n\nStale local entries (superseded, long-unused, obsolete facts) are a valid\nrefine target: approve with instructions naming the entry ids, and tell the\nplanner to archive them (archive hides from injection, data stays restorable)\nrather than delete.\n\nSkill-related trajectories (the evidence concerns creating or improving a\nskill entry) are judged against the DSH skill quality standard (skill-audit\ndimensions: frontmatter routing, the 7 structural features, paragraph\nskeleton, no duplication of the official 11 skills or covered skills).\nApprove only when the trajectory shows a REAL trigger scenario and the\nresulting skill would meet the standard; otherwise decline and say in the\nrationale what must improve \u2014 drafting follows skill-creator, and the\nplanner receives the standard as its <skill_quality_standard> block.\n\nRepeated multi-step workflows (session start/end routines, recurring\nwrap-up or handoff procedures) are a valid refine target: approve with\ninstructions telling the planner to propose a guidance skill (kind=skill,\nskill_kind=guidance \u2014 a SKILL.md document, no python reference). Only\npropose when the same workflow recurs in the trajectory \u2014 never for\none-off flows. Auto-created skills are always offered to the user for a\ndecision before they land; the gate never writes a skill silently.\n\nReturn JSON only:\n{\n \"shouldRefine\": true|false,\n \"rationale\": \"short reason\",\n \"instructions\": \"optional concise instructions for /evolve if shouldRefine is true\"\n}";
35
46
  /** Parse the gate's JSON reply. */