dsh-continual-evolve 0.15.10 → 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-333%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)]()
@@ -23,7 +23,7 @@ Reusable experience gathered in one session (user preferences, pitfalls, project
23
23
  1. **Session-start injection** — the plugin registers a single system-prompt section: the workspace's memory index as a store query (hooks only, whole-line truncated over budget, ordered feedback > user > reference), the central-library path, and a three-line guide. The section is computed once per session and reused byte-for-byte, keeping the system prompt stable and the prompt cache warm; an empty store costs zero tokens.
24
24
  2. **Four tools, one writer** — `memory_write` (create/update/delete) is the explicit path for "remember …" / "forget …": every proposal passes the code gates (id shape, type enum, the feedback Why/How contract, secret screening carried over from v1, 64KB body cap, optional `corrects` links to the same-workspace active memory being superseded) inside one transaction. `memory_read` fetches bodies by id and searches by keyword — whitespace-separated terms are AND-combined, with literal substring matching for short terms and whenever the trigram index misses; the injected index carries hooks only. Each memory body served by id increments that memory's view counter (views + last viewed, the same presence heartbeat as skills). `skill_write` (create/update/delete in the project, plus an explicit move-to-global) sediments reusable procedures through the same discipline (kebab ids, secret screening, 64KB cap, no duplicate ids or bodies across scopes); every applied skill is materialized by the plugin as `<workspace>/.agents/skills/<id>/SKILL.md` — the database is the source of truth, the files are a read-only projection patrol restores on drift. `skill_read` fetches skill bodies by id and searches project plus global skills (the project shadows a same-named global); each body served by id increments that skill's view counter (views + last viewed), which the card shows as a presence heartbeat — viewed, not applied, so long-unviewed rows are removal candidates while pruning itself stays human. On hosts without the tools service the plugin degrades to "tell the user" phrasing and stays functional.
25
25
  3. **Proposal-based extraction** — the trajectory-to-memory automation, audited in the `extraction_log`: every `agent/turn-stopping` schedules that session's boundary, with state kept **per session** (a run in flight keeps only its newest boundary — coalescing, not queueing; `compaction/start` and session close drain it) and **no idle timer**. A run reads the session increment since its cursor, feeds FTS-similar existing memories to one LLM call, and the model's structured proposal lands through the same gates; failures and rejections never advance the cursor. A cheap pre-gate (increment head + ownership triple-test, tiny verdict) runs first: refusals consume the increment as a model skip without the full call, and an unclear gate fails open to the full call — worst case degrades to the old single-call behavior, never to a stall. Full and gate usages ledger in separate `usage` / `cheap_usage` columns, and a gate that dies keeps its error head in the same row's `cheap_error` — a dead gate stays tellable from a gate that never existed. Internal agents, empty increments, turns without real user prose, and turns that already carried an explicit `memory_write` are mechanically skipped — and every skip is ledgered, consecutive quiet turns of one session as a single row carrying how many wake-ups it stands for.
26
- 4. **Autonomous content-quality loop** — the second background automation, independent from extraction by construction (own prompts, own daily budget, own `review_log` ledger, no cursor; only the store's write gates are shared): a cheap gate model call decides whether a memory/skill entry deserves a close look, and only approvals run the evolve pass that judges content quality (memories on home-ownership plus accuracy/usability/reusability/duplication/freshness, skills on home-ownership plus trigger precision/executability/duplication, where reuse is scope-aware: a project-partition skill is judged inside its project (in-project reuse) and the cross-project test applies to global-scope skills only — a repo-bound row in the global partition is the mis-homed case) and proposes the rewrite. The same three home tests (user/environment vs repo-version subject, name-stripped sentence, repo facts live in ADR/docs) guard the extraction prompt, so repo-version specifics never enter as memories in the first place. Gate/leg failures ledger the error text instead of a bare code. New writes are reviewed as they land, the existing stock drains through a bounded backlog queue, and untouched entries requeue after 30 days. Two hard guarantees bound the autonomy: delete never lands automatically (rewrite or quarantine only — quarantine hides, never deletes) and oversized rewrites degrade to quarantine. A daily cap (default 100 items, new > corrects-linked > stale > spot-check priority, the tail drops instead of rolling over) bounds the token spend, and every terminal path lands one `item-review` verdict row plus one daily rollup row (budget vs reviewed vs backlog vs dropped, tokens split by cheap/evolve purpose) for later replay. Ships in trial mode (`review.dryRun`, default on): verdicts ledgered, nothing applied — and a declined rewrite now rides the row's `proposed` column, because a trial has to show what it would write, not just why.
26
+ 4. **Autonomous content-quality loop** — the second background automation, independent from extraction by construction (own prompts, own daily budget, own `review_log` ledger, no cursor; only the store's write gates are shared): a cheap gate model call decides whether a memory/skill entry deserves a close look, and only approvals run the evolve pass that judges content quality (memories on home-ownership plus accuracy/usability/reusability/duplication/freshness, skills on home-ownership plus trigger precision/executability/duplication, where reuse is scope-aware: a project-partition skill is judged inside its project (in-project reuse) and the cross-project test applies to global-scope skills only — a repo-bound row in the global partition is the mis-homed case) and proposes the rewrite. The same three home tests (user/environment vs repo-version subject, name-stripped sentence, repo facts live in ADR/docs) guard the extraction prompt, so repo-version specifics never enter as memories in the first place. Gate/leg failures ledger the error text instead of a bare code, and the gate's own budget (1024 tokens) leaves room for a thinking model's reasoning: a verdict that completed before a cut still counts, while a call that produced no verdict at all (route fault, budget cut) leaves the item queued instead of marking it reviewed. New writes are reviewed as they land, the existing stock drains through a bounded backlog queue, and untouched entries requeue after 30 days. Two hard guarantees bound the autonomy: delete never lands automatically (rewrite or quarantine only — quarantine hides, never deletes) and oversized rewrites degrade to quarantine. A daily cap (default 100 items, new > corrects-linked > stale > spot-check priority, the tail drops instead of rolling over) bounds the token spend, and every terminal path lands one `item-review` verdict row plus one daily rollup row (budget vs reviewed vs backlog vs dropped, tokens split by cheap/evolve purpose) for later replay. Ships in trial mode (`review.dryRun`, default on): verdicts ledgered, nothing applied — and a declined rewrite now rides the row's `proposed` column, because a trial has to show what it would write, not just why.
27
27
  5. **Rollback is a ledger, not a mechanism** — every mutation lands an `extraction_log` row with full before/after snapshots; undo means rewriting the `before` values. Skill mutations land the same way in `skill_log`, review verdicts in `review_log`. A hygiene pass rides on every applied run: orphan FTS rows are dropped and secret-bearing rows are quarantined (hidden, never deleted; a quarantined skill's materialized file is removed so it stops loading). The read-only card surfaces quarantine anomalies.
28
28
  6. **Read-only card** — mounted on the plugin's page in the official plugin manager (the `plugins.bundle.config` slot; hosts without it silently degrade to no card), shaped like the market's own settings card and aligned with ZCode's Settings → Memory viewer: a dropdown scope selector over the store's workspace partitions (opening on the host's current workspace), a search box, per-entry relative updated times, click-to-preview bodies (5 MiB cap), patrol warnings, and a skills section listing the workspace's project skills plus visible global skills (global rows badged, project shadowing global) with the same preview, each row showing its view count. Localized through the host's `locale/*.json` metadata channel; host theme tokens, the `Button` primitive, and zh/en copy that tracks the DSH language. The card is a request-time projection of the database and has no edit path.
29
29
 
@@ -60,8 +60,11 @@ No commands. On the first session after restart the index is injected automatica
60
60
  | `review.dryRun` | `true` | Ledger verdicts without landing them (flip off after the trial distribution reads clean) |
61
61
  | `review.dailyBudget` | `100` | Reviewed items per UTC day (new > corrects-linked > stale > spot-check, tail drops) |
62
62
  | `review.batchSize` | `10` | Max items per drain run |
63
+ | `review.staleDays` | `30` | Days a reviewed item stays out of the queue before it requeues (a review that produced no verdict never counts) |
63
64
  | `memoryCard.enabled` | `true` | Read-only memory card in the official plugin manager (degrades to no card when the host lacks `webServer`/`plugins.bundle.config`) |
64
65
 
66
+ Every `review.*` field is volatile: a settings surface edits it without a remount, and the scheduler re-reads the values on every drain.
67
+
65
68
  Profile patch example:
66
69
 
67
70
  ```yaml
@@ -78,7 +81,7 @@ Profile patch example:
78
81
 
79
82
  ```bash
80
83
  pnpm install && pnpm build # deps + clean build -> lib/
81
- pnpm test # vitest (328 tests)
84
+ pnpm test # vitest (362 tests)
82
85
  pnpm test:coverage # v8 coverage, CI-enforced thresholds
83
86
  pnpm lint # oxlint src test client
84
87
  pnpm check:pack # published-artifact consistency (also runs on prepack)
@@ -92,6 +95,7 @@ Layout:
92
95
  │ ├── store.ts # central SQLite store: DDL/WAL/FTS, reads, ledger, patrol, cursors
93
96
  │ ├── store-apply.ts # the sole write gate: validate all, apply atomically, ledger
94
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
95
99
  │ ├── review.ts # autonomous quality loop: cheap gate → evolve, budget, scheduler
96
100
  │ ├── review-prompt.ts # review prompts + verdict parsing (gate + memory/skill rubrics)
97
101
  │ ├── store-apply.ts # the sole write gate: validate all, apply atomically, ledger
@@ -111,7 +115,7 @@ Layout:
111
115
  ├── client/
112
116
  │ └── client.js # hand-written client bundle: read-only card in the plugin manager
113
117
  ├── locale/ # host package metadata: localized display name + description (zh/en)
114
- ├── test/ # vitest suite (25 files)
118
+ ├── test/ # vitest suite (26 files)
115
119
  ├── lib/ # build output (tsc)
116
120
  ├── docs/ # design.md (design) · FAQ.md (real-world pitfalls)
117
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-333%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)]()
@@ -23,7 +23,7 @@ Agent 每个会话积累的可复用经验(用户偏好、踩坑教训、项
23
23
  1. **开局注入** —— 插件只注册一个 system-prompt section:注入本工作区的记忆索引(查库结果,只含钩子,超预算按整行截断,feedback > user > reference 排序)、中央库路径、三行指南。该 section 每会话只算一次并逐字节复用,system prompt 稳定、prompt cache 持续命中;空 store 零 token。
24
24
  2. **四个工具,一个写者** —— `memory_write`(create/update/delete)承接显式的记住/忘掉:每条提案过代码门禁(id 规范、type 枚举、feedback 的 Why/How 契约、v1 移植的 secret 筛查、64KB 正文上限、可选 `corrects` 指向被替代的同工作区 active 记忆)后在一个事务内落盘。`memory_read` 按 id 取正文、按关键词检索——空格分隔的关键词取 AND,短词与 trigram 未命中时回退字面子串匹配;注入索引只有钩子。按 id 取到的每份记忆正文计一次查阅(与技能同纪律的存在心跳)。`skill_write`(项目内 create/update/delete + 显式搬到全局)沉淀可复用的操作规程,走同一套纪律(短横线 id、secret 筛查、64KB 上限、跨范围禁重名重文);每条落地的技能由插件代码物化为 `<workspace>/.agents/skills/<id>/SKILL.md`——库是唯一真相,文件是只读投影,漂移由巡检按库还原。`skill_read` 按 id 取技能正文、检索项目与全局技能(同名时项目遮蔽全局);按 id 取到的每份正文计一次查阅(查阅次数 + 上次查阅,卡片展示)。这是存在心跳——只证明被翻过,不证明有用:长期零查阅是删除候选,剪枝拍板仍需人工。宿主没有工具服务时降级为告诉用户措辞,注入不受影响。
25
25
  3. **提案制提取** —— 轨迹到记忆的自动化,全程 `extraction_log` 可对账:每个 `agent/turn-stopping` 调度**该会话自己**的边界,状态按会话隔离(运行中只保留最新边界——合并而非排队;`compaction/start` 与会话收尾触发 drain),**没有空闲计时器**。一次运行读取该会话游标之后的轨迹增量,用 FTS 取相似既有记忆喂给一次 LLM 调用,模型的结构化提案过同一套门禁落盘;失败与被拒不推游标,下次触发重试。贵调用之前先过一道便宜预检门(增量前 4KB + 归属三测,小 verdict):拒了直接按 model-skip 消费游标,不烧全量 prompt;门不清不楚就透传给贵调用——最差退化成老单调用,永不 stall。贵用量与门用量分 `usage` / `cheap_usage` 两列记账;门自己挂了按 fail-open 透传,错误原文留在同一行的 `cheap_error`——门死了与门不存在可区分。内部 agent、空增量、无真实用户文本的轮次、当轮已有显式 `memory_write` 的轮次机械跳过——每次跳过都记账,同一会话连续的空转轮次合成一行并累计唤醒次数。
26
- 4. **内容质量自进化环** —— 第二条后台自动化,与提取在结构上独立(自有提示词、自有日预算、自有 `review_log` 账本、无游标;只共享库的写门):便宜门模型先判某条记忆/技能值不值得细看,批了才跑贵改写,按内容质量打分(记忆:归属 + 准确/可用/可复用/不重复/不过时五维;技能:归属 + 触发精度/可执行完备/不重复三维,其中「可复用」按分区判——项目分区技能只按该项目内跨任务可用判,跨项目可用只对全局分区技能判,绑定本仓库路径/门禁的项目技能不算缺陷)并给出改写提案。同一套归属三测(主语是用户/环境还是某仓库的某版机制、去名后还成立吗、仓库事实住 ADR/docs)也守在提取 prompt 门口,版本细节从源头就不进记忆;评审调用失败记错误原文,不记秃码。新写即审、存量走有界积压队列排空、长期未动 30 天回炉。两条硬保证框住自动化:删除永不自动落盘(只有改写与隔离——隔离隐藏不删),超幅改写自动转隔离。日上限默认 100 条(新写 > 被纠正牵连 > 陈旧 > 抽查,尾部丢弃不滚雪球)框住 token 开销;每条终态落一行 `item-review` verdict 行、每天合一行日 rollup(预算 vs 实审 vs 积压 vs 丢弃,token 按便宜/贵分账),方便以后复盘。默认试运行(`review.dryRun` 开):只记 verdict 不落盘,且未落盘的拟改稿全文随行进 `proposed` 列——试运行要看的是「改成什么样」,不只是「为什么改」。
26
+ 4. **内容质量自进化环** —— 第二条后台自动化,与提取在结构上独立(自有提示词、自有日预算、自有 `review_log` 账本、无游标;只共享库的写门):便宜门模型先判某条记忆/技能值不值得细看,批了才跑贵改写,按内容质量打分(记忆:归属 + 准确/可用/可复用/不重复/不过时五维;技能:归属 + 触发精度/可执行完备/不重复三维,其中「可复用」按分区判——项目分区技能只按该项目内跨任务可用判,跨项目可用只对全局分区技能判,绑定本仓库路径/门禁的项目技能不算缺陷)并给出改写提案。同一套归属三测(主语是用户/环境还是某仓库的某版机制、去名后还成立吗、仓库事实住 ADR/docs)也守在提取 prompt 门口,版本细节从源头就不进记忆;评审调用失败记错误原文,不记秃码;门自身预算 1024 token,给推理模型的思考留出余量——截断前已完成解析的 verdict 仍算数,而一条 verdict 都没产出的调用(路由故障、预算截断)不让条目记为已评审,而是留队重试。新写即审、存量走有界积压队列排空、长期未动 30 天回炉。两条硬保证框住自动化:删除永不自动落盘(只有改写与隔离——隔离隐藏不删),超幅改写自动转隔离。日上限默认 100 条(新写 > 被纠正牵连 > 陈旧 > 抽查,尾部丢弃不滚雪球)框住 token 开销;每条终态落一行 `item-review` verdict 行、每天合一行日 rollup(预算 vs 实审 vs 积压 vs 丢弃,token 按便宜/贵分账),方便以后复盘。默认试运行(`review.dryRun` 开):只记 verdict 不落盘,且未落盘的拟改稿全文随行进 `proposed` 列——试运行要看的是「改成什么样」,不只是「为什么改」。
27
27
  5. **回滚靠账本,不靠机制** —— 每次变更落一条带 before/after 全文快照的 `extraction_log` 行;undo 就是取 before 重写。技能变更同样落 `skill_log`,评审 verdict 落 `review_log`。每次应用后随跑一次巡检:孤儿 FTS 行清除、含密钥条目隔离(隐藏,不物理删除;被隔离技能的物化文件一并撤下,使其不再被加载)。只读卡片显形巡检异常。
28
28
  6. **只读卡片** —— 挂在官方插件管理的本 bundle 页面(`plugins.bundle.config` slot,老宿主自动降级为无卡片):形态对齐市场自家设置卡,内容对齐 ZCode 设置的记忆选项卡——下拉作用域选择器列出库里的工作区分区(默认选中宿主当前工作区)、搜索框、每条记忆的相对更新时间、点击行内展开正文预览(5 MiB 上限)、巡检异常警告,以及技能区(本工作区技能 + 可见的全局技能,全局行打标记,同名时项目遮蔽全局),预览同记忆,每行显示查阅次数。显示名与描述走宿主 `locale/*.json` 通道双语;宿主主题 token、`Button` 原语、中英文案跟随 DSH 语言。卡片是数据库的请求时投影,没有编辑路径。
29
29
 
@@ -60,8 +60,11 @@ dsh plugin add ZK-Andy/dsh-continual-evolve
60
60
  | `review.dryRun` | `true` | 只记 verdict 不落盘(试运行分布看顺后再关) |
61
61
  | `review.dailyBudget` | `100` | 每天评审上限(新写 > 被纠正牵连 > 陈旧 > 抽查,尾部丢弃) |
62
62
  | `review.batchSize` | `10` | 每次 drain 最多审几条 |
63
+ | `review.staleDays` | `30` | 已评审条目重回队列前要等几天(没产出 verdict 的评审从不算数) |
63
64
  | `memoryCard.enabled` | `true` | 官方插件管理内的只读记忆卡片(宿主缺 `webServer`/`plugins.bundle.config` 时自动降级为无卡片) |
64
65
 
66
+ `review.*` 全部是 volatile 字段:设置界面可在不重挂载的情况下修改,调度器每次 drain 重新读取取值。
67
+
65
68
  profile patch 示例:
66
69
 
67
70
  ```yaml
@@ -78,7 +81,7 @@ profile patch 示例:
78
81
 
79
82
  ```bash
80
83
  pnpm install && pnpm build # 依赖 + 干净构建 -> lib/
81
- pnpm test # vitest(328 例)
84
+ pnpm test # vitest(362 例)
82
85
  pnpm test:coverage # v8 覆盖率,CI 强制阈值
83
86
  pnpm lint # oxlint src test client
84
87
  pnpm check:pack # 发布产物一致性(prepack 也会跑)
@@ -92,6 +95,7 @@ pnpm check:pack # 发布产物一致性(prepack 也会跑)
92
95
  │ ├── store.ts # 中央 SQLite 库:DDL/WAL/FTS、读面、账本、巡检、游标
93
96
  │ ├── store-apply.ts # 唯一写门:全量校验、事务原子应用、账本
94
97
  │ ├── token-usage.ts # 统一记账口:归一各家用量、纯累加
98
+ │ ├── llm-call.ts # 直属调用边界:路由、推理档位、失败原文
95
99
  │ ├── review.ts # 内容质量自进化:便宜门 → 贵改写、预算、调度
96
100
  │ ├── review-prompt.ts # 评审提示词 + verdict 解析(门 + 记忆/技能 rubric)
97
101
  │ ├── store-apply.ts # 唯一写门:全量校验、事务原子应用、账本
@@ -111,7 +115,7 @@ pnpm check:pack # 发布产物一致性(prepack 也会跑)
111
115
  ├── client/
112
116
  │ └── client.js # 手写 client bundle:官方插件管理内只读卡片
113
117
  ├── locale/ # 宿主包元数据:显示名 + 描述双语(zh/en)
114
- ├── test/ # vitest 测试(25 个文件)
118
+ ├── test/ # vitest 测试(26 个文件)
115
119
  ├── lib/ # 构建产物(tsc)
116
120
  ├── docs/ # design.md(设计)· FAQ.md(踩坑)
117
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. */
@@ -9,6 +10,10 @@ const UNEXPECTED_ERROR_RAW_MAX = 2048;
9
10
  const ERROR_REASON_MAX = 300;
10
11
  /** Head length of a failed pre-gate call's error kept in the ledger `cheap_error` column. */
11
12
  const GATE_ERROR_MAX = 200;
13
+ /** Output budget for one cheap-gate call (see review.ts's `GATE_MAX_TOKENS`). */
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;
12
17
  /** Split an unexpected throw into a ledger-sized reason head and file body. */
13
18
  function errorText(error) {
14
19
  const message = error instanceof Error && error.message.length > 0 ? error.message : String(error);
@@ -82,6 +87,9 @@ async function runExtractionInner(deps, target, opts) {
82
87
  store.logSkipped({ ...ledgerBase, skipReason: "no-route", status: "skipped" });
83
88
  return { status: "skipped", skipReason: "no-route" };
84
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 };
85
93
  const cursor = store.cursor(target.workspaceId, target.sessionId);
86
94
  let all;
87
95
  try {
@@ -127,9 +135,15 @@ async function runExtractionInner(deps, target, opts) {
127
135
  {
128
136
  const gateStarted = Date.now();
129
137
  try {
130
- const gate = await streamOnce(deps.llm, target, cheapPrompt({ incrementHead: increment.slice(0, CHEAP_INCREMENT_MAX), userSeqs: slice.userSeqs }), EXTRACTION_CHEAP_SYSTEM_PROMPT, 512);
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);
131
139
  cheapUsage = gate.usage;
132
140
  const verdict = parseCheapAnswer(gate.text);
141
+ // A gate cut at its budget that still answered is a usable verdict;
142
+ // only a cut with nothing parseable rides the run's row as a gate
143
+ // error. Parse before blaming the finish reason.
144
+ if (verdict === null && gate.finish !== undefined && gate.finish !== "stop") {
145
+ throw new Error(`stream finished with ${gate.finish}`);
146
+ }
133
147
  if (verdict !== null && !verdict.worth) {
134
148
  advanceCursor();
135
149
  store.logSkipped({
@@ -162,14 +176,18 @@ async function runExtractionInner(deps, target, opts) {
162
176
  let text;
163
177
  let usage;
164
178
  try {
165
- const answer = await streamOnce(deps.llm, target, prompt);
179
+ const answer = await streamOnce(llm, route, target.sessionId, EXTRACTION_SYSTEM_PROMPT, prompt, EXTRACTION_MAX_TOKENS);
180
+ // The full call carries the proposal payload: a cut stream is a failure.
181
+ if (answer.finish !== undefined && answer.finish !== "stop") {
182
+ throw new Error(`stream finished with ${answer.finish}`);
183
+ }
166
184
  text = answer.text;
167
185
  usage = answer.usage;
168
186
  }
169
187
  catch (error) {
170
188
  const { message, detail } = errorText(error);
171
189
  try {
172
- 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 });
173
191
  }
174
192
  catch (ledgerError) {
175
193
  throw new Error(`llm call failed (${message}); failure ledger write also failed (${errorText(ledgerError).message})`);
@@ -241,40 +259,6 @@ function candidateRecords(store, workspaceId, userText) {
241
259
  function formatCandidate(record) {
242
260
  return [`# 候选 id=${record.id}(type=${record.type})`, `title=${record.title}`, `钩子=${record.description}`, "全文:", record.body].join("\n");
243
261
  }
244
- /** One streaming text call with the host session identity forwarded. */
245
- async function streamOnce(llm, target, prompt, system = EXTRACTION_SYSTEM_PROMPT, maxTokens = 4096) {
246
- let text = "";
247
- let usage;
248
- let finish;
249
- const options = {
250
- provider: target.provider,
251
- model: target.model,
252
- messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
253
- system,
254
- maxTokens,
255
- // MissingSessionID pitfall: direct calls must carry the host session.
256
- sessionId: target.sessionId,
257
- };
258
- for await (const chunk of llm.stream(options)) {
259
- const piece = chunk;
260
- if (piece?.type === "text-delta" && typeof piece.text === "string") {
261
- text += piece.text;
262
- }
263
- else if (piece?.type === "usage") {
264
- usage = piece.usage;
265
- }
266
- else if (piece?.type === "finish") {
267
- finish = piece.reason;
268
- }
269
- }
270
- if (finish !== undefined && finish.kind !== "stop") {
271
- throw new Error(`stream finished with ${finish.kind ?? "unknown"}`);
272
- }
273
- if (text.trim().length === 0) {
274
- throw new Error("empty model output");
275
- }
276
- return { text, usage };
277
- }
278
262
  /**
279
263
  * Wire the extraction triggers onto the host context. Returns a disposer
280
264
  * that removes the listeners. The scheduler is the only background
@@ -297,12 +281,14 @@ export function createExtractionScheduler(host, deps) {
297
281
  if (typeof id !== "string" || id.length === 0) {
298
282
  return null;
299
283
  }
300
- 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);
301
287
  return {
302
288
  agent,
303
289
  sessionId: id,
304
- provider: typeof route?.provider === "string" ? route.provider : undefined,
305
- model: typeof route?.model === "string" ? route.model : undefined,
290
+ provider: route?.provider,
291
+ model: route?.model,
306
292
  };
307
293
  };
308
294
  const drain = (sessionId, trigger) => {
package/lib/index.d.ts CHANGED
@@ -82,19 +82,25 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
82
82
  /**
83
83
  * The autonomous content-quality loop (own prompts, own daily budget,
84
84
  * own ledger — shares only the store's write gates with extraction).
85
- * `dryRun` ledgers verdicts without landing them (trial mode, default
86
- * on); `dailyBudget` caps reviewed items per UTC day (default 100).
85
+ * Every field is volatile: a settings surface edits them without a
86
+ * remount, so the scheduler re-reads them per drain instead of capturing
87
+ * them at mount. `dryRun` ledgers verdicts without landing them (trial
88
+ * mode, default on); `dailyBudget` caps reviewed items per UTC day
89
+ * (default 100); `staleDays` is how long a reviewed item stays out of the
90
+ * queue before it comes back (default 30).
87
91
  */
88
92
  review: z<Schemastery.ObjectS<NoInfer<{
89
- enabled: z<boolean, boolean, "plain">;
90
- dryRun: z<boolean, boolean, "plain">;
91
- dailyBudget: z<number, number, "plain">;
92
- batchSize: z<number, number, "plain">;
93
+ enabled: z<boolean, boolean, "volatile">;
94
+ dryRun: z<boolean, boolean, "volatile">;
95
+ dailyBudget: z<number, number, "volatile">;
96
+ batchSize: z<number, number, "volatile">;
97
+ staleDays: z<number, number, "volatile">;
93
98
  }>>, Schemastery.ObjectT<NoInfer<{
94
- enabled: z<boolean, boolean, "plain">;
95
- dryRun: z<boolean, boolean, "plain">;
96
- dailyBudget: z<number, number, "plain">;
97
- batchSize: z<number, number, "plain">;
99
+ enabled: z<boolean, boolean, "volatile">;
100
+ dryRun: z<boolean, boolean, "volatile">;
101
+ dailyBudget: z<number, number, "volatile">;
102
+ batchSize: z<number, number, "volatile">;
103
+ staleDays: z<number, number, "volatile">;
98
104
  }>>, "plain">;
99
105
  /**
100
106
  * The read-only memory card in the official plugin manager
@@ -141,19 +147,25 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
141
147
  /**
142
148
  * The autonomous content-quality loop (own prompts, own daily budget,
143
149
  * own ledger — shares only the store's write gates with extraction).
144
- * `dryRun` ledgers verdicts without landing them (trial mode, default
145
- * on); `dailyBudget` caps reviewed items per UTC day (default 100).
150
+ * Every field is volatile: a settings surface edits them without a
151
+ * remount, so the scheduler re-reads them per drain instead of capturing
152
+ * them at mount. `dryRun` ledgers verdicts without landing them (trial
153
+ * mode, default on); `dailyBudget` caps reviewed items per UTC day
154
+ * (default 100); `staleDays` is how long a reviewed item stays out of the
155
+ * queue before it comes back (default 30).
146
156
  */
147
157
  review: z<Schemastery.ObjectS<NoInfer<{
148
- enabled: z<boolean, boolean, "plain">;
149
- dryRun: z<boolean, boolean, "plain">;
150
- dailyBudget: z<number, number, "plain">;
151
- batchSize: z<number, number, "plain">;
158
+ enabled: z<boolean, boolean, "volatile">;
159
+ dryRun: z<boolean, boolean, "volatile">;
160
+ dailyBudget: z<number, number, "volatile">;
161
+ batchSize: z<number, number, "volatile">;
162
+ staleDays: z<number, number, "volatile">;
152
163
  }>>, Schemastery.ObjectT<NoInfer<{
153
- enabled: z<boolean, boolean, "plain">;
154
- dryRun: z<boolean, boolean, "plain">;
155
- dailyBudget: z<number, number, "plain">;
156
- batchSize: z<number, number, "plain">;
164
+ enabled: z<boolean, boolean, "volatile">;
165
+ dryRun: z<boolean, boolean, "volatile">;
166
+ dailyBudget: z<number, number, "volatile">;
167
+ batchSize: z<number, number, "volatile">;
168
+ staleDays: z<number, number, "volatile">;
157
169
  }>>, "plain">;
158
170
  /**
159
171
  * The read-only memory card in the official plugin manager
package/lib/index.js CHANGED
@@ -31,7 +31,8 @@
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 { createReviewScheduler } from "./review.js";
34
+ import { resolveCallRoute } from "./llm-call.js";
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";
37
38
  import { registerSkillTools } from "./skill-tools.js";
@@ -63,14 +64,19 @@ export const Config = z.object({
63
64
  /**
64
65
  * The autonomous content-quality loop (own prompts, own daily budget,
65
66
  * own ledger — shares only the store's write gates with extraction).
66
- * `dryRun` ledgers verdicts without landing them (trial mode, default
67
- * on); `dailyBudget` caps reviewed items per UTC day (default 100).
67
+ * Every field is volatile: a settings surface edits them without a
68
+ * remount, so the scheduler re-reads them per drain instead of capturing
69
+ * them at mount. `dryRun` ledgers verdicts without landing them (trial
70
+ * mode, default on); `dailyBudget` caps reviewed items per UTC day
71
+ * (default 100); `staleDays` is how long a reviewed item stays out of the
72
+ * queue before it comes back (default 30).
68
73
  */
69
74
  review: z.object({
70
- enabled: z.boolean(),
71
- dryRun: z.boolean(),
72
- dailyBudget: z.natural(),
73
- batchSize: z.natural(),
75
+ enabled: z.boolean().volatile(),
76
+ dryRun: z.boolean().volatile(),
77
+ dailyBudget: z.natural().volatile(),
78
+ batchSize: z.natural().volatile(),
79
+ staleDays: z.natural().volatile(),
74
80
  }),
75
81
  /**
76
82
  * The read-only memory card in the official plugin manager
@@ -82,6 +88,34 @@ export const Config = z.object({
82
88
  enabled: z.boolean(),
83
89
  }),
84
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
+ }
85
119
  /** Register the memory tools through a nested inject (see memory-tools.ts). */
86
120
  function mountToolsWhenAvailable(host, store, state) {
87
121
  const nested = host;
@@ -129,7 +163,11 @@ function mountExtractionWhenAvailable(host, store, config) {
129
163
  log.warn("sessionQuery service present but empty — extraction not armed (injection and tools unaffected)");
130
164
  return;
131
165
  }
132
- 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
+ }));
133
171
  });
134
172
  }
135
173
  /**
@@ -140,7 +178,7 @@ function mountExtractionWhenAvailable(host, store, config) {
140
178
  */
141
179
  function mountReviewWhenAvailable(host, store, config) {
142
180
  const log = host.logger("continual-evolve");
143
- if (config.review?.enabled === false) {
181
+ if (config.review?.enabled?.get() === false) {
144
182
  log.info("review disabled by config — extraction and tools unaffected");
145
183
  return;
146
184
  }
@@ -153,14 +191,20 @@ function mountReviewWhenAvailable(host, store, config) {
153
191
  nested.inject(["llm"], (scoped) => {
154
192
  const context = scoped;
155
193
  const llm = (context.llm ?? context.get?.("llm"));
156
- createReviewScheduler(events, { store, llm }, {
194
+ const resolveRoute = routeResolverOf(scoped, context.get);
195
+ registerSchedulerDisposer(scoped, createReviewScheduler(events, {
196
+ store, llm,
197
+ ...(resolveRoute === undefined ? {} : { resolveRoute }),
198
+ }, {
157
199
  enabled: true,
158
- dryRun: config.review?.dryRun ?? true,
159
- dailyBudget: config.review?.dailyBudget ?? 100,
160
- batchSize: config.review?.batchSize ?? 10,
161
- staleDays: 30,
200
+ read: () => ({
201
+ dryRun: config.review?.dryRun?.get() ?? REVIEW_DEFAULT_DRY_RUN,
202
+ dailyBudget: config.review?.dailyBudget?.get() ?? REVIEW_DEFAULT_DAILY_BUDGET,
203
+ batchSize: config.review?.batchSize?.get() ?? REVIEW_DEFAULT_BATCH_SIZE,
204
+ staleDays: config.review?.staleDays?.get() ?? REVIEW_DEFAULT_STALE_DAYS,
205
+ }),
162
206
  minIntervalMs: 5 * 60 * 1000,
163
- });
207
+ }));
164
208
  });
165
209
  }
166
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;
@@ -48,6 +53,20 @@ export interface ReviewRunSummary {
48
53
  }
49
54
  /** A rewrite lands only when the body-length ratio stays within this bound. */
50
55
  export declare const REWRITE_MAGNITUDE_CAP = 0.3;
56
+ /**
57
+ * Output budget for one cheap-gate call: a verdict JSON **plus** the reasoning
58
+ * a thinking model spends before emitting it. A 512-token budget cut the gate
59
+ * off on reasoning-model calls (2026-10-13) and the cut was ledgered as a
60
+ * route failure, so the per-item failure rate read as a network problem.
61
+ */
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;
65
+ /** Defaults for the review settings the host may override — and edit live. */
66
+ export declare const REVIEW_DEFAULT_DRY_RUN = true;
67
+ export declare const REVIEW_DEFAULT_DAILY_BUDGET = 100;
68
+ export declare const REVIEW_DEFAULT_BATCH_SIZE = 10;
69
+ export declare const REVIEW_DEFAULT_STALE_DAYS = 30;
51
70
  /** Relative body change: |new-old| / max(old, 1). Above the cap → quarantine. */
52
71
  export declare function rewriteMagnitude(oldBody: string, newBody: string): number;
53
72
  /**
@@ -80,12 +99,21 @@ export interface ReviewSchedulerHost {
80
99
  warn(message: string): void;
81
100
  };
82
101
  }
83
- export interface ReviewSchedulerConfig {
84
- enabled: boolean;
102
+ /** The editable review settings, re-read on every drain. */
103
+ export interface ReviewSettings {
85
104
  dryRun: boolean;
86
105
  dailyBudget: number;
87
106
  batchSize: number;
88
107
  staleDays: number;
108
+ }
109
+ export interface ReviewSchedulerConfig {
110
+ enabled: boolean;
111
+ /**
112
+ * Read the current settings for each drain: the host's configuration is
113
+ * volatile, so values edited live take effect without a remount and are
114
+ * never captured once at mount time.
115
+ */
116
+ read: () => ReviewSettings;
89
117
  /** Minimum ms between two drains (default 5 minutes). */
90
118
  minIntervalMs: number;
91
119
  }
package/lib/review.js CHANGED
@@ -14,8 +14,23 @@
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;
20
+ /**
21
+ * Output budget for one cheap-gate call: a verdict JSON **plus** the reasoning
22
+ * a thinking model spends before emitting it. A 512-token budget cut the gate
23
+ * off on reasoning-model calls (2026-10-13) and the cut was ledgered as a
24
+ * route failure, so the per-item failure rate read as a network problem.
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;
29
+ /** Defaults for the review settings the host may override — and edit live. */
30
+ export const REVIEW_DEFAULT_DRY_RUN = true;
31
+ export const REVIEW_DEFAULT_DAILY_BUDGET = 100;
32
+ export const REVIEW_DEFAULT_BATCH_SIZE = 10;
33
+ export const REVIEW_DEFAULT_STALE_DAYS = 30;
19
34
  /** Relative body change: |new-old| / max(old, 1). Above the cap → quarantine. */
20
35
  export function rewriteMagnitude(oldBody, newBody) {
21
36
  return Math.abs(newBody.length - oldBody.length) / Math.max(oldBody.length, 1);
@@ -141,7 +156,10 @@ export async function runReviewBatch(deps, candidates, opts) {
141
156
  const runId = opts.runId ?? `review:${opts.day}:${Date.now()}`;
142
157
  let newItems = 0;
143
158
  let requeued = 0;
144
- 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;
145
163
  let position = 0;
146
164
  for (const candidate of batch) {
147
165
  const remaining = deps.store.consumeReviewBudget(opts.day, opts.budget);
@@ -165,7 +183,7 @@ export async function runReviewBatch(deps, candidates, opts) {
165
183
  else {
166
184
  requeued += 1;
167
185
  }
168
- if (noRoute) {
186
+ if (noRoute || route === undefined) {
169
187
  deps.store.logReview({
170
188
  runId, workspaceId: candidate.workspaceId, itemKind: candidate.kind, itemId: candidate.id,
171
189
  verdict: "skip", reason: "no-route", applied: 0,
@@ -174,7 +192,7 @@ export async function runReviewBatch(deps, candidates, opts) {
174
192
  position += 1;
175
193
  continue;
176
194
  }
177
- await reviewOne(deps, runId, candidate, opts, summary, skipReasons);
195
+ await reviewOne(deps, runId, candidate, opts, route, summary, skipReasons);
178
196
  position += 1;
179
197
  }
180
198
  summary.backlog = Math.max(0, candidates.length - summary.reviewed - summary.dropped);
@@ -186,7 +204,7 @@ export async function runReviewBatch(deps, candidates, opts) {
186
204
  });
187
205
  return summary;
188
206
  }
189
- async function reviewOne(deps, runId, candidate, opts, summary, skipReasons) {
207
+ async function reviewOne(deps, runId, candidate, opts, route, summary, skipReasons) {
190
208
  const base = {
191
209
  runId, workspaceId: candidate.workspaceId, itemKind: candidate.kind, itemId: candidate.id,
192
210
  };
@@ -194,10 +212,16 @@ async function reviewOne(deps, runId, candidate, opts, summary, skipReasons) {
194
212
  let gateUsage = zeroUsage();
195
213
  let gateRaw;
196
214
  try {
197
- const answer = await streamOnce(deps.llm, opts, REVIEW_CHEAP_SYSTEM_PROMPT, gatePrompt(candidate), 512);
215
+ const answer = await streamOnce(deps.llm, route, opts.sessionId, REVIEW_CHEAP_SYSTEM_PROMPT, gatePrompt(candidate), GATE_MAX_TOKENS);
198
216
  gateRaw = answer.usage;
199
217
  const gate = parseGateAnswer(answer.text);
200
218
  if (gate === null) {
219
+ // A cut gate is a failure only when no complete verdict came
220
+ // through: the JSON usually lands before the budget runs out, so
221
+ // parse first and blame the finish reason only if it did not.
222
+ if (answer.finish !== undefined && answer.finish !== "stop") {
223
+ throw new Error(`stream finished with ${answer.finish}`);
224
+ }
201
225
  deps.store.logReview({ ...base, verdict: "skip", reason: "gate-parse-failed", cheapUsage: gateRaw, applied: 0 });
202
226
  skipReasons.push("gate-parse-failed");
203
227
  return;
@@ -228,9 +252,14 @@ async function reviewOne(deps, runId, candidate, opts, summary, skipReasons) {
228
252
  let evolveRaw;
229
253
  try {
230
254
  const system = candidate.kind === "memory" ? REVIEW_MEMORY_SYSTEM_PROMPT : REVIEW_SKILL_SYSTEM_PROMPT;
231
- 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);
232
256
  evolveRaw = answer.usage;
233
257
  summary.evolveToken += totalOf(evolveRaw);
258
+ // The evolve pass carries full replacement text: a cut stream may hold
259
+ // half a body, so it never becomes a verdict.
260
+ if (answer.finish !== undefined && answer.finish !== "stop") {
261
+ throw new Error(`stream finished with ${answer.finish}`);
262
+ }
234
263
  const evolved = parseEvolveAnswer(answer.text);
235
264
  if (evolved === null) {
236
265
  deps.store.logReview({ ...base, verdict: "skip", reason: "evolve-parse-failed", cheapUsage: gateUsage, evolveUsage: evolveRaw, applied: 0 });
@@ -356,41 +385,6 @@ export function failureHead(error, max = 120) {
356
385
  function normalizedOf(raw) {
357
386
  return normalizeUsage(raw) ?? zeroUsage();
358
387
  }
359
- /** One streaming text call with the host session identity forwarded. */
360
- async function streamOnce(llm, opts, system, prompt, maxTokens) {
361
- if (llm === undefined) {
362
- throw new Error("no llm route");
363
- }
364
- let text = "";
365
- let usage;
366
- let finish;
367
- for await (const chunk of llm.stream({
368
- provider: opts.provider,
369
- model: opts.model,
370
- messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
371
- system,
372
- maxTokens,
373
- sessionId: opts.sessionId,
374
- })) {
375
- const piece = chunk;
376
- if (piece?.type === "text-delta" && typeof piece.text === "string") {
377
- text += piece.text;
378
- }
379
- else if (piece?.type === "usage") {
380
- usage = piece.usage;
381
- }
382
- else if (piece?.type === "finish") {
383
- finish = piece.reason;
384
- }
385
- }
386
- if (finish !== undefined && finish.kind !== "stop") {
387
- throw new Error(`stream finished with ${finish.kind ?? "unknown"}`);
388
- }
389
- if (text.trim().length === 0) {
390
- throw new Error("empty model output");
391
- }
392
- return { text, usage };
393
- }
394
388
  /** Current UTC day `YYYY-MM-DD` for the hard-cap account. */
395
389
  export function todayUtc() {
396
390
  return new Date().toISOString().slice(0, 10);
@@ -429,19 +423,20 @@ export function createReviewScheduler(host, deps, config) {
429
423
  pendingWorkspace = undefined;
430
424
  void (async () => {
431
425
  try {
426
+ const settings = config.read();
432
427
  const day = todayUtc();
433
428
  const daily = deps.store.getDaily(day);
434
- const remaining = config.dailyBudget - (daily?.reviewed ?? 0);
429
+ const remaining = settings.dailyBudget - (daily?.reviewed ?? 0);
435
430
  if (remaining <= 0) {
436
431
  return;
437
432
  }
438
- const candidates = collectCandidates(deps.store, { staleDays: config.staleDays, limit: config.batchSize * 2 });
433
+ const candidates = collectCandidates(deps.store, { staleDays: settings.staleDays, limit: settings.batchSize * 2 });
439
434
  const ordered = workspace === undefined
440
435
  ? candidates
441
436
  : [...candidates.filter((c) => c.workspaceId === workspace), ...candidates.filter((c) => c.workspaceId !== workspace)];
442
- await runReviewBatch(deps, ordered.slice(0, config.batchSize), {
443
- day, budget: config.dailyBudget, dryRun: config.dryRun,
444
- batchSize: config.batchSize, staleDays: config.staleDays,
437
+ await runReviewBatch(deps, ordered.slice(0, settings.batchSize), {
438
+ day, budget: settings.dailyBudget, dryRun: settings.dryRun,
439
+ batchSize: settings.batchSize, staleDays: settings.staleDays,
445
440
  provider: lastRoute?.provider, model: lastRoute?.model, sessionId: lastRoute?.sessionId,
446
441
  });
447
442
  }
@@ -459,22 +454,24 @@ export function createReviewScheduler(host, deps, config) {
459
454
  if (typeof id !== "string" || id.length === 0) {
460
455
  return;
461
456
  }
462
- const provider = typeof agent?.options?.provider === "string" ? agent.options.provider : undefined;
463
- const model = typeof agent?.options?.model === "string" ? agent.options.model : undefined;
464
- lastRoute = { provider, model, sessionId: id };
465
- drain(cwdOf(payload.agent));
466
- }));
467
- listeners.push(host.on("session/event", (session, event) => {
468
- 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);
469
468
  return;
470
469
  }
471
- drain(pendingWorkspace);
472
- }));
473
- listeners.push(host.on("agent/disposed", () => {
474
- drain(pendingWorkspace);
475
- pendingWorkspace = undefined;
470
+ lastRoute = { provider: route.provider, model: route.model, sessionId: id };
471
+ drain(cwdOf(agent));
476
472
  }));
477
- host.logger("continual-evolve").info(`review scheduler armed (daily cap ${config.dailyBudget}, dry-run ${config.dryRun ? "on" : "off"})`);
473
+ const armed = config.read();
474
+ host.logger("continual-evolve").info(`review scheduler armed (daily cap ${armed.dailyBudget}, dry-run ${armed.dryRun ? "on" : "off"})`);
478
475
  return () => {
479
476
  pendingWorkspace = undefined;
480
477
  for (const listener of listeners) {
package/lib/store.js CHANGED
@@ -37,6 +37,14 @@ const LEDGER_OCCURRENCES = "occurrences";
37
37
  * row per occurrence — a persistent fault deserves a timeline, not a counter.
38
38
  */
39
39
  const COALESCED_SKIP_REASONS = new Set(["no-user-prose", "no-new-events"]);
40
+ /**
41
+ * Verdict rows that leave an item queued: a call that never produced a verdict
42
+ * must not read as "reviewed". Without this a single gate fault (a route blip,
43
+ * a budget cut) locked the item out for `staleDays`, because the failure row
44
+ * counted as a finished review (2026-10-13).
45
+ */
46
+ const INCOMPLETE_REVIEW_SQL = "(coalesce(reason, '') LIKE 'gate-llm-failed%' OR coalesce(reason, '') LIKE 'evolve-llm-failed%' " +
47
+ "OR coalesce(reason, '') IN ('no-route', 'budget-exhausted', 'gate-parse-failed', 'evolve-parse-failed'))";
40
48
  const DDL = `
41
49
  PRAGMA journal_mode=WAL;
42
50
  PRAGMA busy_timeout=5000;CREATE TABLE IF NOT EXISTS memories (
@@ -361,7 +369,7 @@ export async function openMemoryStore(explicitPath) {
361
369
  "ON CONFLICT (workspace_id, memory_id) DO UPDATE SET use_count = use_count + 1, last_used_at = excluded.last_used_at"),
362
370
  reviewLog: database.prepare("INSERT INTO review_log (run_id, ts, workspace_id, item_kind, item_id, verdict, reason, proposed, cheap_usage, evolve_usage, applied, evolve_count) " +
363
371
  "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
364
- reviewLast: database.prepare("SELECT ts, applied FROM review_log WHERE workspace_id IS ? AND item_kind = ? AND item_id = ? ORDER BY rowid DESC LIMIT 1"),
372
+ reviewLast: database.prepare(`SELECT ts, applied FROM review_log WHERE workspace_id IS ? AND item_kind = ? AND item_id = ? AND NOT ${INCOMPLETE_REVIEW_SQL} ORDER BY rowid DESC LIMIT 1`),
365
373
  reviewEvolved: database.prepare("SELECT COUNT(*) AS n FROM review_log WHERE workspace_id IS ? AND item_kind = ? AND item_id = ? AND applied = 1"),
366
374
  reviewPrune: database.prepare("DELETE FROM review_log WHERE ts < ?"),
367
375
  dailyGet: database.prepare("SELECT * FROM review_daily WHERE day = ?"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-continual-evolve",
3
- "version": "0.15.10",
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": {