dsh-continual-evolve 0.15.11 → 0.15.12

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 CHANGED
@@ -7,7 +7,7 @@ English | [中文](README.zh.md)
7
7
  [![CI](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml/badge.svg)](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml)
8
8
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9
9
  [![Node](https://img.shields.io/badge/node-%5E22.19%20%7C%7C%20%3E%3D24-339933)](package.json)
10
- [![Tests](https://img.shields.io/badge/tests-337%20passing-brightgreen)]()
10
+ [![Tests](https://img.shields.io/badge/tests-362%20passing-brightgreen)]()
11
11
  [![Coverage · statements](https://img.shields.io/badge/coverage_statements-96%25-brightgreen)]()
12
12
  [![Coverage · branches](https://img.shields.io/badge/coverage_branches-90%25-green)]()
13
13
  [![Coverage · functions](https://img.shields.io/badge/coverage_functions-100%25-brightgreen)]()
@@ -81,7 +81,7 @@ Profile patch example:
81
81
 
82
82
  ```bash
83
83
  pnpm install && pnpm build # deps + clean build -> lib/
84
- pnpm test # vitest (337 tests)
84
+ pnpm test # vitest (362 tests)
85
85
  pnpm test:coverage # v8 coverage, CI-enforced thresholds
86
86
  pnpm lint # oxlint src test client
87
87
  pnpm check:pack # published-artifact consistency (also runs on prepack)
@@ -95,6 +95,7 @@ Layout:
95
95
  │ ├── store.ts # central SQLite store: DDL/WAL/FTS, reads, ledger, patrol, cursors
96
96
  │ ├── store-apply.ts # the sole write gate: validate all, apply atomically, ledger
97
97
  │ ├── token-usage.ts # single accounting entry: normalize provider usage, pure sums
98
+ │ ├── llm-call.ts # shared direct-call boundary: route, reasoning effort, failure text
98
99
  │ ├── review.ts # autonomous quality loop: cheap gate → evolve, budget, scheduler
99
100
  │ ├── review-prompt.ts # review prompts + verdict parsing (gate + memory/skill rubrics)
100
101
  │ ├── store-apply.ts # the sole write gate: validate all, apply atomically, ledger
@@ -114,7 +115,7 @@ Layout:
114
115
  ├── client/
115
116
  │ └── client.js # hand-written client bundle: read-only card in the plugin manager
116
117
  ├── locale/ # host package metadata: localized display name + description (zh/en)
117
- ├── test/ # vitest suite (25 files)
118
+ ├── test/ # vitest suite (26 files)
118
119
  ├── lib/ # build output (tsc)
119
120
  ├── docs/ # design.md (design) · FAQ.md (real-world pitfalls)
120
121
  └── .agents/ # AI collaboration layer (AGENTS.md, skills, ADR notes)
package/README.zh.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [![CI](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml/badge.svg)](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml)
8
8
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9
9
  [![Node](https://img.shields.io/badge/node-%5E22.19%20%7C%7C%20%3E%3D24-339933)](package.json)
10
- [![Tests](https://img.shields.io/badge/tests-337%20passing-brightgreen)]()
10
+ [![Tests](https://img.shields.io/badge/tests-362%20passing-brightgreen)]()
11
11
  [![Coverage · statements](https://img.shields.io/badge/coverage_statements-96%25-brightgreen)]()
12
12
  [![Coverage · branches](https://img.shields.io/badge/coverage_branches-90%25-green)]()
13
13
  [![Coverage · functions](https://img.shields.io/badge/coverage_functions-100%25-brightgreen)]()
@@ -81,7 +81,7 @@ profile patch 示例:
81
81
 
82
82
  ```bash
83
83
  pnpm install && pnpm build # 依赖 + 干净构建 -> lib/
84
- pnpm test # vitest(337 例)
84
+ pnpm test # vitest(362 例)
85
85
  pnpm test:coverage # v8 覆盖率,CI 强制阈值
86
86
  pnpm lint # oxlint src test client
87
87
  pnpm check:pack # 发布产物一致性(prepack 也会跑)
@@ -95,6 +95,7 @@ pnpm check:pack # 发布产物一致性(prepack 也会跑)
95
95
  │ ├── store.ts # 中央 SQLite 库:DDL/WAL/FTS、读面、账本、巡检、游标
96
96
  │ ├── store-apply.ts # 唯一写门:全量校验、事务原子应用、账本
97
97
  │ ├── token-usage.ts # 统一记账口:归一各家用量、纯累加
98
+ │ ├── llm-call.ts # 直属调用边界:路由、推理档位、失败原文
98
99
  │ ├── review.ts # 内容质量自进化:便宜门 → 贵改写、预算、调度
99
100
  │ ├── review-prompt.ts # 评审提示词 + verdict 解析(门 + 记忆/技能 rubric)
100
101
  │ ├── store-apply.ts # 唯一写门:全量校验、事务原子应用、账本
@@ -114,7 +115,7 @@ pnpm check:pack # 发布产物一致性(prepack 也会跑)
114
115
  ├── client/
115
116
  │ └── client.js # 手写 client bundle:官方插件管理内只读卡片
116
117
  ├── locale/ # 宿主包元数据:显示名 + 描述双语(zh/en)
117
- ├── test/ # vitest 测试(25 个文件)
118
+ ├── test/ # vitest 测试(26 个文件)
118
119
  ├── lib/ # 构建产物(tsc)
119
120
  ├── docs/ # design.md(设计)· FAQ.md(踩坑)
120
121
  └── .agents/ # AI 协作层(AGENTS.md、技能、ADR 笔记)
@@ -1,14 +1,18 @@
1
1
  import { type SkipReason, type SurfaceReader } from "./extraction-surface.js";
2
2
  import type { LedgerEntry, MemoryStore } from "./store.js";
3
- /** The minimal LLM face extraction calls (duck-typed host service). */
4
- export interface LlmStream {
5
- stream(options: Record<string, unknown>): AsyncIterable<unknown>;
6
- }
3
+ import { type LlmRuntime, type RouteResolver } from "./llm-call.js";
4
+ export type { LlmStream, LlmRuntime, RouteResolver } from "./llm-call.js";
7
5
  /** Everything a run needs: the store, the session reader, and the LLM. */
8
6
  export interface ExtractionDeps {
9
7
  store: MemoryStore;
10
8
  surface: SurfaceReader | undefined;
11
- llm: LlmStream | undefined;
9
+ llm: LlmRuntime | undefined;
10
+ /**
11
+ * Route resolution for a direct call: the agent's explicit selection first,
12
+ * the host default model second. Absent keeps the legacy agent-options-only
13
+ * behavior (tests and older hosts).
14
+ */
15
+ resolveRoute?: RouteResolver | undefined;
12
16
  }
13
17
  /** One extraction target: whose session, in which workspace, on which route. */
14
18
  export interface ExtractionTarget {
package/lib/extraction.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { boundaryCursorOf, evaluateSlice, eventsAfterCursor, isInternalAgent, seqOfCursor, serializeIncrement } from "./extraction-surface.js";
2
2
  import { CHEAP_INCREMENT_MAX, EXTRACTION_CHEAP_SYSTEM_PROMPT, EXTRACTION_SYSTEM_PROMPT, cheapPrompt, extractionPrompt, manifestLineOf, parseCheapAnswer, parseExtractionAnswer } from "./extraction-prompt.js";
3
3
  import { cwdOf } from "./memory-section.js";
4
+ import { resolveCallRoute, streamOnce } from "./llm-call.js";
4
5
  /** Head length of a failed run's raw answer kept in the ledger `files` column. */
5
6
  const FAILURE_RAW_MAX = 2048;
6
7
  /** Head length of an unexpected error's message/stack trace kept in the ledger `files` column. */
@@ -11,6 +12,8 @@ const ERROR_REASON_MAX = 300;
11
12
  const GATE_ERROR_MAX = 200;
12
13
  /** Output budget for one cheap-gate call (see review.ts's `GATE_MAX_TOKENS`). */
13
14
  const GATE_MAX_TOKENS = 1024;
15
+ /** Output budget for the full proposal call: a decision plus replacement bodies. */
16
+ const EXTRACTION_MAX_TOKENS = 4096;
14
17
  /** Split an unexpected throw into a ledger-sized reason head and file body. */
15
18
  function errorText(error) {
16
19
  const message = error instanceof Error && error.message.length > 0 ? error.message : String(error);
@@ -84,6 +87,9 @@ async function runExtractionInner(deps, target, opts) {
84
87
  store.logSkipped({ ...ledgerBase, skipReason: "no-route", status: "skipped" });
85
88
  return { status: "skipped", skipReason: "no-route" };
86
89
  }
90
+ // Narrowed once, here: the shared call boundary takes a concrete route.
91
+ const llm = deps.llm;
92
+ const route = { provider: target.provider, model: target.model };
87
93
  const cursor = store.cursor(target.workspaceId, target.sessionId);
88
94
  let all;
89
95
  try {
@@ -129,7 +135,7 @@ async function runExtractionInner(deps, target, opts) {
129
135
  {
130
136
  const gateStarted = Date.now();
131
137
  try {
132
- const gate = await streamOnce(deps.llm, target, cheapPrompt({ incrementHead: increment.slice(0, CHEAP_INCREMENT_MAX), userSeqs: slice.userSeqs }), EXTRACTION_CHEAP_SYSTEM_PROMPT, GATE_MAX_TOKENS);
138
+ const gate = await streamOnce(llm, route, target.sessionId, EXTRACTION_CHEAP_SYSTEM_PROMPT, cheapPrompt({ incrementHead: increment.slice(0, CHEAP_INCREMENT_MAX), userSeqs: slice.userSeqs }), GATE_MAX_TOKENS);
133
139
  cheapUsage = gate.usage;
134
140
  const verdict = parseCheapAnswer(gate.text);
135
141
  // A gate cut at its budget that still answered is a usable verdict;
@@ -170,7 +176,7 @@ async function runExtractionInner(deps, target, opts) {
170
176
  let text;
171
177
  let usage;
172
178
  try {
173
- const answer = await streamOnce(deps.llm, target, prompt);
179
+ const answer = await streamOnce(llm, route, target.sessionId, EXTRACTION_SYSTEM_PROMPT, prompt, EXTRACTION_MAX_TOKENS);
174
180
  // The full call carries the proposal payload: a cut stream is a failure.
175
181
  if (answer.finish !== undefined && answer.finish !== "stop") {
176
182
  throw new Error(`stream finished with ${answer.finish}`);
@@ -181,7 +187,7 @@ async function runExtractionInner(deps, target, opts) {
181
187
  catch (error) {
182
188
  const { message, detail } = errorText(error);
183
189
  try {
184
- store.logSkipped({ ...ledgerBase, skipReason: "llm-failed", status: "failed", durationMs: Date.now() - started, cheapUsage, cheapError, files: detail });
190
+ store.logSkipped({ ...ledgerBase, skipReason: "llm-failed", status: "failed", model: target.model, durationMs: Date.now() - started, cheapUsage, cheapError, files: detail });
185
191
  }
186
192
  catch (ledgerError) {
187
193
  throw new Error(`llm call failed (${message}); failure ledger write also failed (${errorText(ledgerError).message})`);
@@ -253,41 +259,6 @@ function candidateRecords(store, workspaceId, userText) {
253
259
  function formatCandidate(record) {
254
260
  return [`# 候选 id=${record.id}(type=${record.type})`, `title=${record.title}`, `钩子=${record.description}`, "全文:", record.body].join("\n");
255
261
  }
256
- /** One streaming text call with the host session identity forwarded. */
257
- async function streamOnce(llm, target, prompt, system = EXTRACTION_SYSTEM_PROMPT, maxTokens = 4096) {
258
- let text = "";
259
- let usage;
260
- let finish;
261
- const options = {
262
- provider: target.provider,
263
- model: target.model,
264
- messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
265
- system,
266
- maxTokens,
267
- // MissingSessionID pitfall: direct calls must carry the host session.
268
- sessionId: target.sessionId,
269
- };
270
- for await (const chunk of llm.stream(options)) {
271
- const piece = chunk;
272
- if (piece?.type === "text-delta" && typeof piece.text === "string") {
273
- text += piece.text;
274
- }
275
- else if (piece?.type === "usage") {
276
- usage = piece.usage;
277
- }
278
- else if (piece?.type === "finish") {
279
- finish = piece.reason;
280
- }
281
- }
282
- const finishKind = finish?.kind;
283
- if (text.trim().length === 0) {
284
- // Nothing usable arrived: the finish reason is the only diagnosis.
285
- throw new Error(finishKind !== undefined && finishKind !== "stop" ? `stream finished with ${finishKind}` : "empty model output");
286
- }
287
- // The caller owns the truncation verdict: a gate answer often completes
288
- // before its budget does, a proposal body never does.
289
- return { text, usage, finish: finishKind };
290
- }
291
262
  /**
292
263
  * Wire the extraction triggers onto the host context. Returns a disposer
293
264
  * that removes the listeners. The scheduler is the only background
@@ -310,12 +281,14 @@ export function createExtractionScheduler(host, deps) {
310
281
  if (typeof id !== "string" || id.length === 0) {
311
282
  return null;
312
283
  }
313
- const route = agent?.options;
284
+ // Absent resolver (older host, tests) still reads the agent's explicit
285
+ // selection: the fallback is the legacy behavior, never "no route".
286
+ const route = deps.resolveRoute?.(agent) ?? resolveCallRoute(agent, undefined);
314
287
  return {
315
288
  agent,
316
289
  sessionId: id,
317
- provider: typeof route?.provider === "string" ? route.provider : undefined,
318
- model: typeof route?.model === "string" ? route.model : undefined,
290
+ provider: route?.provider,
291
+ model: route?.model,
319
292
  };
320
293
  };
321
294
  const drain = (sessionId, trigger) => {
package/lib/index.js CHANGED
@@ -31,6 +31,7 @@
31
31
  import z from "@deepseek-ai/schemastery";
32
32
  import { mountCardRoutes } from "./card-routes.js";
33
33
  import { createExtractionScheduler } from "./extraction.js";
34
+ import { resolveCallRoute } from "./llm-call.js";
34
35
  import { createReviewScheduler, REVIEW_DEFAULT_BATCH_SIZE, REVIEW_DEFAULT_DAILY_BUDGET, REVIEW_DEFAULT_DRY_RUN, REVIEW_DEFAULT_STALE_DAYS, } from "./review.js";
35
36
  import { createFrozenMemorySection, cwdOf, DEFAULT_MAX_CHARS, DEFAULT_MEMORY_SECTION_ORDER, MEMORY_SECTION_NAME, memorySectionText, } from "./memory-section.js";
36
37
  import { registerMemoryTools } from "./memory-tools.js";
@@ -87,6 +88,34 @@ export const Config = z.object({
87
88
  enabled: z.boolean(),
88
89
  }),
89
90
  });
91
+ /**
92
+ * Build the route resolver a scheduler uses for its direct calls: the agent's
93
+ * explicit selection first, the host's default-model service second.
94
+ *
95
+ * The default-model service is read through `ctx.get` on every call and never
96
+ * joins an inject list — a host without it (or an older one) must keep working,
97
+ * and its absence only means a session with no explicit selection has no route.
98
+ */
99
+ function routeResolverOf(scoped, get) {
100
+ if (typeof get !== "function") {
101
+ return undefined;
102
+ }
103
+ const read = get.bind(scoped);
104
+ return (agent) => resolveCallRoute(agent, read);
105
+ }
106
+ /**
107
+ * Keep one scheduler's disposer on the owning fiber.
108
+ *
109
+ * Without this a remount (HMR, a live config edit) leaves the previous
110
+ * listeners attached: two schedulers then drain the same boundary, and the
111
+ * older one holds an LLM service the remount already replaced.
112
+ */
113
+ function registerSchedulerDisposer(scoped, dispose) {
114
+ const effect = scoped.effect;
115
+ if (typeof effect === "function") {
116
+ effect.call(scoped, () => dispose);
117
+ }
118
+ }
90
119
  /** Register the memory tools through a nested inject (see memory-tools.ts). */
91
120
  function mountToolsWhenAvailable(host, store, state) {
92
121
  const nested = host;
@@ -134,7 +163,11 @@ function mountExtractionWhenAvailable(host, store, config) {
134
163
  log.warn("sessionQuery service present but empty — extraction not armed (injection and tools unaffected)");
135
164
  return;
136
165
  }
137
- createExtractionScheduler(events, { store, surface, llm });
166
+ const resolveRoute = routeResolverOf(scoped, context.get);
167
+ registerSchedulerDisposer(scoped, createExtractionScheduler(events, {
168
+ store, surface, llm,
169
+ ...(resolveRoute === undefined ? {} : { resolveRoute }),
170
+ }));
138
171
  });
139
172
  }
140
173
  /**
@@ -158,7 +191,11 @@ function mountReviewWhenAvailable(host, store, config) {
158
191
  nested.inject(["llm"], (scoped) => {
159
192
  const context = scoped;
160
193
  const llm = (context.llm ?? context.get?.("llm"));
161
- createReviewScheduler(events, { store, llm }, {
194
+ const resolveRoute = routeResolverOf(scoped, context.get);
195
+ registerSchedulerDisposer(scoped, createReviewScheduler(events, {
196
+ store, llm,
197
+ ...(resolveRoute === undefined ? {} : { resolveRoute }),
198
+ }, {
162
199
  enabled: true,
163
200
  read: () => ({
164
201
  dryRun: config.review?.dryRun?.get() ?? REVIEW_DEFAULT_DRY_RUN,
@@ -167,7 +204,7 @@ function mountReviewWhenAvailable(host, store, config) {
167
204
  staleDays: config.review?.staleDays?.get() ?? REVIEW_DEFAULT_STALE_DAYS,
168
205
  }),
169
206
  minIntervalMs: 5 * 60 * 1000,
170
- });
207
+ }));
171
208
  });
172
209
  }
173
210
  /**
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The shared boundary for the plugin's own LLM calls: extraction's cheap
3
+ * pre-gate and full proposal call, and review's gate and evolve passes all
4
+ * stream through here.
5
+ *
6
+ * Three disciplines live in one place because all three were learned the hard
7
+ * way, and none of them is visible at a call site:
8
+ *
9
+ * - **The route.** `agent.options` carries an *explicit* selection only. A
10
+ * session running on the host's default model carries nothing there, so the
11
+ * route falls back to the host's `agentDefaultModel` service — the same
12
+ * value the host Agent loop itself runs on. A caller that still has no
13
+ * route refuses to send instead of streaming into a provider rejection.
14
+ * - **The reasoning effort.** An exact provider/model advertises the effort
15
+ * ids it accepts, and a request naming one it does not accept is rejected
16
+ * by capability validation before any provider I/O. The lowest *enabled*
17
+ * effort the model advertises is used; a model with no reasoning metadata
18
+ * gets no caller-supplied effort at all. This is the pre-teardown
19
+ * `llm-text.ts` discipline (see the 2026-09-24 capability note), restored
20
+ * after the direct calls that replaced that boundary shipped without it.
21
+ * - **The failure text.** A terminal `error`/`aborted` finish carries the
22
+ * host's own failure object. Keeping only its `kind` produced a ledger full
23
+ * of "stream finished with error" that named no cause for a whole day.
24
+ */
25
+ /** The minimal LLM face a direct call needs (duck-typed host service). */
26
+ export interface LlmStream {
27
+ stream(options: Record<string, unknown>): AsyncIterable<unknown>;
28
+ }
29
+ /** Exact-model capability metadata, narrowed to the field this plugin reads. */
30
+ export interface ModelInfo {
31
+ reasoning?: {
32
+ efforts?: unknown;
33
+ } | undefined;
34
+ }
35
+ /** The LLM service plus the optional exact-model capability query. */
36
+ export interface LlmRuntime extends LlmStream {
37
+ /** Resolve one exact provider/model's capabilities; absent on older hosts and test fakes. */
38
+ resolveModelInfo?(provider: string, model: string): Promise<ModelInfo | undefined> | ModelInfo | undefined;
39
+ }
40
+ /** One concrete provider/model route for a direct call. */
41
+ export interface ModelSelection {
42
+ provider: string;
43
+ model: string;
44
+ reasoningEffort?: string | undefined;
45
+ }
46
+ /** The host's default-model service, reached as `ctx.get("agentDefaultModel")`. */
47
+ export interface DefaultModelService {
48
+ currentSelection(): unknown;
49
+ }
50
+ /** Read one sibling service off the host context (`ctx.get`). */
51
+ export type ServiceGetter = (name: string) => unknown;
52
+ /** Resolve one direct call's route from the agent plus the host default model. */
53
+ export type RouteResolver = (agent: unknown) => ModelSelection | undefined;
54
+ /** Effort ids that mean "reasoning off"; a model may advertise only these. */
55
+ export declare const CLOSED_REASONING_EFFORTS: ReadonlySet<string>;
56
+ /**
57
+ * Select the lowest reasoning effort an exact model advertises.
58
+ *
59
+ * An enabled effort wins over a declared closing effort: the adapter's first
60
+ * enabled effort is the lowest enabled level it exposes, and a closing effort
61
+ * is only a fallback when the model publishes no enabled level at all. No
62
+ * capability list means no caller-supplied effort.
63
+ *
64
+ * @param modelInfo - Exact resolved-model metadata, when the provider exposes it.
65
+ * @returns The provider-owned effort id to send, or `undefined` to omit the field.
66
+ * @throws When the host reports reasoning metadata in a shape this plugin cannot trust.
67
+ */
68
+ export declare function selectLowestReasoningEffort(modelInfo: ModelInfo | undefined): string | undefined;
69
+ /**
70
+ * The host's default model selection, when the host exposes that service.
71
+ *
72
+ * The service is read through `ctx.get` and never joins an inject list: a host
73
+ * without it (or an older one) must keep working, and a failed read is simply
74
+ * "no default", never a mount failure.
75
+ *
76
+ * @param get - The host context's sibling-service reader, when available.
77
+ * @returns The default provider/model (plus effort), or `undefined` when unknown.
78
+ */
79
+ export declare function defaultRouteOf(get: ServiceGetter | undefined): ModelSelection | undefined;
80
+ /**
81
+ * Resolve the route one direct call runs on: the agent's explicit selection
82
+ * first, the host default second.
83
+ *
84
+ * @param agent - The host Agent the call belongs to (its `options` may be empty).
85
+ * @param get - The host context's sibling-service reader, when available.
86
+ * @returns The route to call with, or `undefined` when there is none to use.
87
+ */
88
+ export declare function resolveCallRoute(agent: unknown, get: ServiceGetter | undefined): ModelSelection | undefined;
89
+ /** One completed direct call: its text, usage, terminal finish kind, and chosen effort. */
90
+ export interface StreamOnceResult {
91
+ text: string;
92
+ usage: unknown;
93
+ /** The host's finish kind (`stop`, `max-tokens`, …); `undefined` when the stream omitted it. */
94
+ finish: string | undefined;
95
+ /** The capability-derived effort actually sent, for diagnostics. */
96
+ reasoningEffort: string | undefined;
97
+ }
98
+ /**
99
+ * Stream one single-turn text call on one route.
100
+ *
101
+ * A terminal `error` or `aborted` finish always throws, carrying the host's own
102
+ * failure text — partial text never rescues a failed call. A `max-tokens` cut
103
+ * is *not* thrown here: a gate answer often completes before its budget does,
104
+ * so the caller owns that verdict and reads `finish`.
105
+ *
106
+ * @param llm - The LLM service; `undefined` means the host exposed no route at all.
107
+ * @param route - The exact provider/model to call.
108
+ * @param sessionId - The host Agent identity providers use for request routing.
109
+ * @param system - The system prompt for this call.
110
+ * @param prompt - The user prompt for this call.
111
+ * @param maxTokens - Output budget for this call.
112
+ * @returns The assembled text, usage, finish kind, and chosen effort.
113
+ * @throws On a missing LLM service, capability lookup failure, terminal provider error or abort, and empty output.
114
+ */
115
+ export declare function streamOnce(llm: LlmRuntime | undefined, route: ModelSelection, sessionId: string | undefined, system: string, prompt: string, maxTokens: number): Promise<StreamOnceResult>;
116
+ //# sourceMappingURL=llm-call.d.ts.map
@@ -0,0 +1,206 @@
1
+ /**
2
+ * The shared boundary for the plugin's own LLM calls: extraction's cheap
3
+ * pre-gate and full proposal call, and review's gate and evolve passes all
4
+ * stream through here.
5
+ *
6
+ * Three disciplines live in one place because all three were learned the hard
7
+ * way, and none of them is visible at a call site:
8
+ *
9
+ * - **The route.** `agent.options` carries an *explicit* selection only. A
10
+ * session running on the host's default model carries nothing there, so the
11
+ * route falls back to the host's `agentDefaultModel` service — the same
12
+ * value the host Agent loop itself runs on. A caller that still has no
13
+ * route refuses to send instead of streaming into a provider rejection.
14
+ * - **The reasoning effort.** An exact provider/model advertises the effort
15
+ * ids it accepts, and a request naming one it does not accept is rejected
16
+ * by capability validation before any provider I/O. The lowest *enabled*
17
+ * effort the model advertises is used; a model with no reasoning metadata
18
+ * gets no caller-supplied effort at all. This is the pre-teardown
19
+ * `llm-text.ts` discipline (see the 2026-09-24 capability note), restored
20
+ * after the direct calls that replaced that boundary shipped without it.
21
+ * - **The failure text.** A terminal `error`/`aborted` finish carries the
22
+ * host's own failure object. Keeping only its `kind` produced a ledger full
23
+ * of "stream finished with error" that named no cause for a whole day.
24
+ */
25
+ /** Effort ids that mean "reasoning off"; a model may advertise only these. */
26
+ export const CLOSED_REASONING_EFFORTS = new Set(["disabled", "off", "none"]);
27
+ /**
28
+ * Select the lowest reasoning effort an exact model advertises.
29
+ *
30
+ * An enabled effort wins over a declared closing effort: the adapter's first
31
+ * enabled effort is the lowest enabled level it exposes, and a closing effort
32
+ * is only a fallback when the model publishes no enabled level at all. No
33
+ * capability list means no caller-supplied effort.
34
+ *
35
+ * @param modelInfo - Exact resolved-model metadata, when the provider exposes it.
36
+ * @returns The provider-owned effort id to send, or `undefined` to omit the field.
37
+ * @throws When the host reports reasoning metadata in a shape this plugin cannot trust.
38
+ */
39
+ export function selectLowestReasoningEffort(modelInfo) {
40
+ const efforts = modelInfo?.reasoning?.efforts;
41
+ if (efforts === undefined) {
42
+ return undefined;
43
+ }
44
+ if (!Array.isArray(efforts)) {
45
+ throw new Error("continual-evolve: invalid model reasoning metadata");
46
+ }
47
+ const ids = [];
48
+ for (const effort of efforts) {
49
+ const id = effort?.id;
50
+ if (typeof id !== "string") {
51
+ throw new Error("continual-evolve: invalid model reasoning metadata");
52
+ }
53
+ ids.push(id);
54
+ }
55
+ return ids.find((id) => !CLOSED_REASONING_EFFORTS.has(id)) ?? ids[0];
56
+ }
57
+ /**
58
+ * The host's default model selection, when the host exposes that service.
59
+ *
60
+ * The service is read through `ctx.get` and never joins an inject list: a host
61
+ * without it (or an older one) must keep working, and a failed read is simply
62
+ * "no default", never a mount failure.
63
+ *
64
+ * @param get - The host context's sibling-service reader, when available.
65
+ * @returns The default provider/model (plus effort), or `undefined` when unknown.
66
+ */
67
+ export function defaultRouteOf(get) {
68
+ if (get === undefined) {
69
+ return undefined;
70
+ }
71
+ let service;
72
+ try {
73
+ service = get("agentDefaultModel");
74
+ }
75
+ catch {
76
+ // An absent service is not an error here: the caller falls back to refusing.
77
+ return undefined;
78
+ }
79
+ const currentSelection = service?.currentSelection;
80
+ if (typeof currentSelection !== "function") {
81
+ return undefined;
82
+ }
83
+ try {
84
+ return selectionOf(currentSelection.call(service));
85
+ }
86
+ catch {
87
+ // A throwing service is "no default" — the refusal path stays reachable.
88
+ return undefined;
89
+ }
90
+ }
91
+ /**
92
+ * Resolve the route one direct call runs on: the agent's explicit selection
93
+ * first, the host default second.
94
+ *
95
+ * @param agent - The host Agent the call belongs to (its `options` may be empty).
96
+ * @param get - The host context's sibling-service reader, when available.
97
+ * @returns The route to call with, or `undefined` when there is none to use.
98
+ */
99
+ export function resolveCallRoute(agent, get) {
100
+ const options = agent?.options;
101
+ return selectionOf(options) ?? defaultRouteOf(get);
102
+ }
103
+ /** Narrow one candidate selection object; empty strings count as absent. */
104
+ function selectionOf(value) {
105
+ if (typeof value !== "object" || value === null) {
106
+ return undefined;
107
+ }
108
+ const { provider, model, reasoningEffort } = value;
109
+ if (typeof provider !== "string" || provider.length === 0) {
110
+ return undefined;
111
+ }
112
+ if (typeof model !== "string" || model.length === 0) {
113
+ return undefined;
114
+ }
115
+ return typeof reasoningEffort === "string" && reasoningEffort.length > 0
116
+ ? { provider, model, reasoningEffort }
117
+ : { provider, model };
118
+ }
119
+ /**
120
+ * Stream one single-turn text call on one route.
121
+ *
122
+ * A terminal `error` or `aborted` finish always throws, carrying the host's own
123
+ * failure text — partial text never rescues a failed call. A `max-tokens` cut
124
+ * is *not* thrown here: a gate answer often completes before its budget does,
125
+ * so the caller owns that verdict and reads `finish`.
126
+ *
127
+ * @param llm - The LLM service; `undefined` means the host exposed no route at all.
128
+ * @param route - The exact provider/model to call.
129
+ * @param sessionId - The host Agent identity providers use for request routing.
130
+ * @param system - The system prompt for this call.
131
+ * @param prompt - The user prompt for this call.
132
+ * @param maxTokens - Output budget for this call.
133
+ * @returns The assembled text, usage, finish kind, and chosen effort.
134
+ * @throws On a missing LLM service, capability lookup failure, terminal provider error or abort, and empty output.
135
+ */
136
+ export async function streamOnce(llm, route, sessionId, system, prompt, maxTokens) {
137
+ if (llm === undefined) {
138
+ throw new Error("no llm route");
139
+ }
140
+ const reasoningEffort = await resolveReasoningEffort(llm, route);
141
+ let text = "";
142
+ let usage;
143
+ let finish;
144
+ for await (const chunk of llm.stream({
145
+ provider: route.provider,
146
+ model: route.model,
147
+ messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
148
+ system,
149
+ maxTokens,
150
+ sessionId,
151
+ ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
152
+ })) {
153
+ const piece = chunk;
154
+ if (piece?.type === "text-delta" && typeof piece.text === "string") {
155
+ text += piece.text;
156
+ }
157
+ else if (piece?.type === "usage") {
158
+ usage = piece.usage;
159
+ }
160
+ else if (piece?.type === "finish") {
161
+ finish = piece.reason;
162
+ }
163
+ }
164
+ const finishKind = finish?.kind;
165
+ if (finishKind === "error" || finishKind === "aborted") {
166
+ throw new Error(failureTextOf(finishKind, finish?.failure));
167
+ }
168
+ if (text.trim().length === 0) {
169
+ // Nothing usable arrived: the finish reason is the only diagnosis.
170
+ throw new Error(finishKind !== undefined && finishKind !== "stop" ? `stream finished with ${finishKind}` : "empty model output");
171
+ }
172
+ return { text, usage, finish: finishKind, reasoningEffort };
173
+ }
174
+ /**
175
+ * Resolve the effort to send for one exact route.
176
+ *
177
+ * The host's capability query is the only source: an explicit selection's
178
+ * effort belongs to the *main* conversation and must not silently raise an
179
+ * auxiliary call's cost. No query (older host, test fake) means no effort.
180
+ */
181
+ async function resolveReasoningEffort(llm, route) {
182
+ if (typeof llm.resolveModelInfo !== "function") {
183
+ return undefined;
184
+ }
185
+ const modelInfo = await llm.resolveModelInfo(route.provider, route.model);
186
+ return selectLowestReasoningEffort(modelInfo);
187
+ }
188
+ /** Render a terminal failure for the ledger: host code plus host message. */
189
+ function failureTextOf(kind, failure) {
190
+ const detail = failureDetailOf(failure);
191
+ return detail === undefined ? `stream finished with ${kind}` : `LLM call ${kind}: ${detail}`;
192
+ }
193
+ /** Flatten the host failure object to `code: message` when either is present. */
194
+ function failureDetailOf(failure) {
195
+ if (typeof failure !== "object" || failure === null) {
196
+ return undefined;
197
+ }
198
+ const { code, message } = failure;
199
+ const head = typeof message === "string" && message.length > 0 ? message : undefined;
200
+ const label = typeof code === "string" && code.length > 0 ? code : undefined;
201
+ if (head !== undefined && label !== undefined) {
202
+ return `${label}: ${head}`;
203
+ }
204
+ return head ?? label;
205
+ }
206
+ //# sourceMappingURL=llm-call.js.map
package/lib/review.d.ts CHANGED
@@ -1,11 +1,16 @@
1
1
  import type { MemoryStore } from "./store.js";
2
- /** The minimal LLM face review calls (same duck-typed host service as extraction). */
3
- export interface ReviewLlm {
4
- stream(options: Record<string, unknown>): AsyncIterable<unknown>;
5
- }
2
+ import { type LlmRuntime, type RouteResolver } from "./llm-call.js";
3
+ /** The LLM face review calls, aliased so each scheduler can name its own. */
4
+ export type ReviewLlm = LlmRuntime;
6
5
  export interface ReviewDeps {
7
6
  store: MemoryStore;
8
- llm: ReviewLlm | undefined;
7
+ llm: LlmRuntime | undefined;
8
+ /**
9
+ * Route resolution for a direct call: the agent's explicit selection first,
10
+ * the host default model second. Absent keeps the legacy agent-options-only
11
+ * behavior (tests and older hosts).
12
+ */
13
+ resolveRoute?: RouteResolver | undefined;
9
14
  }
10
15
  export interface ReviewCandidate {
11
16
  workspaceId: string;
@@ -55,6 +60,8 @@ export declare const REWRITE_MAGNITUDE_CAP = 0.3;
55
60
  * route failure, so the per-item failure rate read as a network problem.
56
61
  */
57
62
  export declare const GATE_MAX_TOKENS = 1024;
63
+ /** Output budget for the evolve pass: a verdict plus full replacement text. */
64
+ export declare const EVOLVE_MAX_TOKENS = 4096;
58
65
  /** Defaults for the review settings the host may override — and edit live. */
59
66
  export declare const REVIEW_DEFAULT_DRY_RUN = true;
60
67
  export declare const REVIEW_DEFAULT_DAILY_BUDGET = 100;
package/lib/review.js CHANGED
@@ -14,6 +14,7 @@
14
14
  import { normalizeUsage, zeroUsage } from "./token-usage.js";
15
15
  import { evolvePrompt, gatePrompt, parseEvolveAnswer, parseGateAnswer, REVIEW_CHEAP_SYSTEM_PROMPT, REVIEW_MEMORY_SYSTEM_PROMPT, REVIEW_SKILL_SYSTEM_PROMPT, } from "./review-prompt.js";
16
16
  import { cwdOf } from "./memory-section.js";
17
+ import { resolveCallRoute, streamOnce } from "./llm-call.js";
17
18
  /** A rewrite lands only when the body-length ratio stays within this bound. */
18
19
  export const REWRITE_MAGNITUDE_CAP = 0.3;
19
20
  /**
@@ -23,6 +24,8 @@ export const REWRITE_MAGNITUDE_CAP = 0.3;
23
24
  * route failure, so the per-item failure rate read as a network problem.
24
25
  */
25
26
  export const GATE_MAX_TOKENS = 1024;
27
+ /** Output budget for the evolve pass: a verdict plus full replacement text. */
28
+ export const EVOLVE_MAX_TOKENS = 4096;
26
29
  /** Defaults for the review settings the host may override — and edit live. */
27
30
  export const REVIEW_DEFAULT_DRY_RUN = true;
28
31
  export const REVIEW_DEFAULT_DAILY_BUDGET = 100;
@@ -153,7 +156,10 @@ export async function runReviewBatch(deps, candidates, opts) {
153
156
  const runId = opts.runId ?? `review:${opts.day}:${Date.now()}`;
154
157
  let newItems = 0;
155
158
  let requeued = 0;
156
- const noRoute = deps.llm === undefined || opts.provider === undefined || opts.model === undefined;
159
+ const route = opts.provider === undefined || opts.model === undefined
160
+ ? undefined
161
+ : { provider: opts.provider, model: opts.model };
162
+ const noRoute = deps.llm === undefined || route === undefined;
157
163
  let position = 0;
158
164
  for (const candidate of batch) {
159
165
  const remaining = deps.store.consumeReviewBudget(opts.day, opts.budget);
@@ -177,7 +183,7 @@ export async function runReviewBatch(deps, candidates, opts) {
177
183
  else {
178
184
  requeued += 1;
179
185
  }
180
- if (noRoute) {
186
+ if (noRoute || route === undefined) {
181
187
  deps.store.logReview({
182
188
  runId, workspaceId: candidate.workspaceId, itemKind: candidate.kind, itemId: candidate.id,
183
189
  verdict: "skip", reason: "no-route", applied: 0,
@@ -186,7 +192,7 @@ export async function runReviewBatch(deps, candidates, opts) {
186
192
  position += 1;
187
193
  continue;
188
194
  }
189
- await reviewOne(deps, runId, candidate, opts, summary, skipReasons);
195
+ await reviewOne(deps, runId, candidate, opts, route, summary, skipReasons);
190
196
  position += 1;
191
197
  }
192
198
  summary.backlog = Math.max(0, candidates.length - summary.reviewed - summary.dropped);
@@ -198,7 +204,7 @@ export async function runReviewBatch(deps, candidates, opts) {
198
204
  });
199
205
  return summary;
200
206
  }
201
- async function reviewOne(deps, runId, candidate, opts, summary, skipReasons) {
207
+ async function reviewOne(deps, runId, candidate, opts, route, summary, skipReasons) {
202
208
  const base = {
203
209
  runId, workspaceId: candidate.workspaceId, itemKind: candidate.kind, itemId: candidate.id,
204
210
  };
@@ -206,7 +212,7 @@ async function reviewOne(deps, runId, candidate, opts, summary, skipReasons) {
206
212
  let gateUsage = zeroUsage();
207
213
  let gateRaw;
208
214
  try {
209
- const answer = await streamOnce(deps.llm, opts, REVIEW_CHEAP_SYSTEM_PROMPT, gatePrompt(candidate), GATE_MAX_TOKENS);
215
+ const answer = await streamOnce(deps.llm, route, opts.sessionId, REVIEW_CHEAP_SYSTEM_PROMPT, gatePrompt(candidate), GATE_MAX_TOKENS);
210
216
  gateRaw = answer.usage;
211
217
  const gate = parseGateAnswer(answer.text);
212
218
  if (gate === null) {
@@ -246,7 +252,7 @@ async function reviewOne(deps, runId, candidate, opts, summary, skipReasons) {
246
252
  let evolveRaw;
247
253
  try {
248
254
  const system = candidate.kind === "memory" ? REVIEW_MEMORY_SYSTEM_PROMPT : REVIEW_SKILL_SYSTEM_PROMPT;
249
- const answer = await streamOnce(deps.llm, opts, system, evolvePrompt({ id: candidate.id, title: candidate.title, description: candidate.description, body: candidate.body, extra, scope: candidate.scope }, neighbors), 4096);
255
+ const answer = await streamOnce(deps.llm, route, opts.sessionId, system, evolvePrompt({ id: candidate.id, title: candidate.title, description: candidate.description, body: candidate.body, extra, scope: candidate.scope }, neighbors), EVOLVE_MAX_TOKENS);
250
256
  evolveRaw = answer.usage;
251
257
  summary.evolveToken += totalOf(evolveRaw);
252
258
  // The evolve pass carries full replacement text: a cut stream may hold
@@ -379,42 +385,6 @@ export function failureHead(error, max = 120) {
379
385
  function normalizedOf(raw) {
380
386
  return normalizeUsage(raw) ?? zeroUsage();
381
387
  }
382
- /** One streaming text call with the host session identity forwarded. */
383
- async function streamOnce(llm, opts, system, prompt, maxTokens) {
384
- if (llm === undefined) {
385
- throw new Error("no llm route");
386
- }
387
- let text = "";
388
- let usage;
389
- let finish;
390
- for await (const chunk of llm.stream({
391
- provider: opts.provider,
392
- model: opts.model,
393
- messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
394
- system,
395
- maxTokens,
396
- sessionId: opts.sessionId,
397
- })) {
398
- const piece = chunk;
399
- if (piece?.type === "text-delta" && typeof piece.text === "string") {
400
- text += piece.text;
401
- }
402
- else if (piece?.type === "usage") {
403
- usage = piece.usage;
404
- }
405
- else if (piece?.type === "finish") {
406
- finish = piece.reason;
407
- }
408
- }
409
- const finishKind = finish?.kind;
410
- if (text.trim().length === 0) {
411
- // Nothing usable arrived: the finish reason is the only diagnosis.
412
- throw new Error(finishKind !== undefined && finishKind !== "stop" ? `stream finished with ${finishKind}` : "empty model output");
413
- }
414
- // The caller owns the truncation verdict: a gate answer often completes
415
- // before its budget does, a replacement body never does.
416
- return { text, usage, finish: finishKind };
417
- }
418
388
  /** Current UTC day `YYYY-MM-DD` for the hard-cap account. */
419
389
  export function todayUtc() {
420
390
  return new Date().toISOString().slice(0, 10);
@@ -484,20 +454,21 @@ export function createReviewScheduler(host, deps, config) {
484
454
  if (typeof id !== "string" || id.length === 0) {
485
455
  return;
486
456
  }
487
- const provider = typeof agent?.options?.provider === "string" ? agent.options.provider : undefined;
488
- const model = typeof agent?.options?.model === "string" ? agent.options.model : undefined;
489
- lastRoute = { provider, model, sessionId: id };
490
- drain(cwdOf(payload.agent));
491
- }));
492
- listeners.push(host.on("session/event", (session, event) => {
493
- if (event?.type !== "compaction/start" || typeof session?.id !== "string") {
457
+ // A drain needs a live route. A session running on the host default
458
+ // model carries no explicit selection, so the default-model service
459
+ // fills it in; with neither, the boundary is only remembered and the
460
+ // next turn boundary reviews it. This is what keeps a review call
461
+ // from being sent with an empty provider or a dead session identity
462
+ // (both of which the provider answers with a bare stream error).
463
+ // Absent resolver (older host, tests) still reads the agent's explicit
464
+ // selection: the fallback is the legacy behavior, never "no route".
465
+ const route = deps.resolveRoute?.(agent) ?? resolveCallRoute(agent, undefined);
466
+ if (route === undefined) {
467
+ pendingWorkspace = cwdOf(agent);
494
468
  return;
495
469
  }
496
- drain(pendingWorkspace);
497
- }));
498
- listeners.push(host.on("agent/disposed", () => {
499
- drain(pendingWorkspace);
500
- pendingWorkspace = undefined;
470
+ lastRoute = { provider: route.provider, model: route.model, sessionId: id };
471
+ drain(cwdOf(agent));
501
472
  }));
502
473
  const armed = config.read();
503
474
  host.logger("continual-evolve").info(`review scheduler armed (daily cap ${armed.dailyBudget}, dry-run ${armed.dryRun ? "on" : "off"})`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-continual-evolve",
3
- "version": "0.15.11",
3
+ "version": "0.15.12",
4
4
  "description": "ZCode-style workspace memory for DeepSeek Harness: one central SQLite store partitioned by workspace, index injection, and sole-writer gated writes.",
5
5
  "license": "MIT",
6
6
  "repository": {