dsh-continual-evolve 0.15.12 → 0.15.14

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-362%20passing-brightgreen)]()
10
+ [![Tests](https://img.shields.io/badge/tests-397%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, 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.
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. Each of the four review jobs (memory and skill, gate and evolve) loads its own rubric from one subagent table — a single prompt cannot serve both, since a memory must survive having its repo name stripped while a project skill is supposed to be repo-bound. 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, along with the model they ran on and the tokens the failed call had already burned, and the output budget comes from the model's own advertised cap instead of a fixed number, so a thinking model is not cut off mid-verdict: 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
 
@@ -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 (362 tests)
84
+ pnpm test # vitest (363 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,17 +95,22 @@ 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
99
- │ ├── review.ts # autonomous quality loop: cheap gate → evolve, budget, scheduler
98
+ │ ├── llm-call.ts # the only transport: route, reasoning effort, output budget, failure facts
99
+ │ ├── subagent.ts # the loader every LLM call goes through (assemble → call → parse → account)
100
+ │ ├── subagent-defs.ts # every subagent job in one table: prompt, budget fallback, answer parser
101
+ │ ├── chain.ts # responsibility chain: one step per link, order assembled by the caller
102
+ │ ├── review-candidates.ts # its queue: what is due (new > corrects-linked > stale)
103
+ │ ├── review.ts # autonomous quality loop: batch, budget, rollup, scheduler
104
+ │ ├── review-ring.ts # its chain: cheap gate → evolve pass → landing
100
105
  │ ├── review-prompt.ts # review prompts + verdict parsing (gate + memory/skill rubrics)
101
- │ ├── store-apply.ts # the sole write gate: validate all, apply atomically, ledger
102
106
  │ ├── memory-rules.ts # mechanical gates: enums, sizes, id shape, secret screening
103
107
  │ ├── memory-section.ts # injection: store query / truncate / session freeze
104
108
  │ ├── memory-guide.ts # the main-session guide (three lines)
105
109
  │ ├── memory-tools.ts # memory_write + memory_read (host-shaped definitions)
106
110
  │ ├── skill-rules.ts # skill gates: field checks, SKILL.md rendering, managed paths
107
111
  │ ├── skill-tools.ts # skill_write + skill_read (project shadowing global)
108
- │ ├── extraction.ts # runner + per-session scheduler (turn-level, no idle timer)
112
+ │ ├── extraction.ts # entry + per-session scheduler (turn-level, no idle timer)
113
+ │ ├── extraction-ring.ts # its chain: cheap pre-gate → proposal call + gated application
109
114
  │ ├── extraction-surface.ts # trajectory slicing: cursor, eligibility, serialization
110
115
  │ ├── extraction-prompt.ts # the extraction prompt + answer parsing
111
116
  │ ├── import-md.ts # lossless legacy markdown migration
@@ -115,7 +120,7 @@ Layout:
115
120
  ├── client/
116
121
  │ └── client.js # hand-written client bundle: read-only card in the plugin manager
117
122
  ├── locale/ # host package metadata: localized display name + description (zh/en)
118
- ├── test/ # vitest suite (26 files)
123
+ ├── test/ # vitest suite (28 files)
119
124
  ├── lib/ # build output (tsc)
120
125
  ├── docs/ # design.md (design) · FAQ.md (real-world pitfalls)
121
126
  └── .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-362%20passing-brightgreen)]()
10
+ [![Tests](https://img.shields.io/badge/tests-397%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 门口,版本细节从源头就不进记忆;评审调用失败记错误原文,不记秃码;门自身预算 1024 token,给推理模型的思考留出余量——截断前已完成解析的 verdict 仍算数,而一条 verdict 都没产出的调用(路由故障、预算截断)不让条目记为已评审,而是留队重试。新写即审、存量走有界积压队列排空、长期未动 30 天回炉。两条硬保证框住自动化:删除永不自动落盘(只有改写与隔离——隔离隐藏不删),超幅改写自动转隔离。日上限默认 100 条(新写 > 被纠正牵连 > 陈旧 > 抽查,尾部丢弃不滚雪球)框住 token 开销;每条终态落一行 `item-review` verdict 行、每天合一行日 rollup(预算 vs 实审 vs 积压 vs 丢弃,token 按便宜/贵分账),方便以后复盘。默认试运行(`review.dryRun` 开):只记 verdict 不落盘,且未落盘的拟改稿全文随行进 `proposed` 列——试运行要看的是「改成什么样」,不只是「为什么改」。
26
+ 4. **内容质量自进化环** —— 第二条后台自动化,与提取在结构上独立(自有提示词、自有日预算、自有 `review_log` 账本、无游标;只共享库的写门):便宜门模型先判某条记忆/技能值不值得细看,批了才跑贵改写,按内容质量打分(记忆:归属 + 准确/可用/可复用/不重复/不过时五维;技能:归属 + 触发精度/可执行完备/不重复三维,其中「可复用」按分区判——项目分区技能只按该项目内跨任务可用判,跨项目可用只对全局分区技能判,绑定本仓库路径/门禁的项目技能不算缺陷)并给出改写提案。四个评审岗位(记忆/技能 × 门/昂贵门)从同一张子代理定义表各装自己的说明书——一份提示词服务不了两者:记忆要求抹掉仓库名仍成立,项目技能本来就该绑仓库。同一套归属三测(主语是用户/环境还是某仓库的某版机制、去名后还成立吗、仓库事实住 ADR/docs)也守在提取 prompt 门口,版本细节从源头就不进记忆;评审调用失败记错误原文,不记秃码,并连同它跑在哪个模型、已烧掉多少 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
 
@@ -81,7 +81,7 @@ profile patch 示例:
81
81
 
82
82
  ```bash
83
83
  pnpm install && pnpm build # 依赖 + 干净构建 -> lib/
84
- pnpm test # vitest(362 例)
84
+ pnpm test # vitest(363 例)
85
85
  pnpm test:coverage # v8 覆盖率,CI 强制阈值
86
86
  pnpm lint # oxlint src test client
87
87
  pnpm check:pack # 发布产物一致性(prepack 也会跑)
@@ -95,17 +95,22 @@ 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 # 直属调用边界:路由、推理档位、失败原文
99
- │ ├── review.ts # 内容质量自进化:便宜门 → 贵改写、预算、调度
98
+ │ ├── llm-call.ts # 唯一传输层:路由、推理档位、输出预算、失败事实
99
+ │ ├── subagent.ts # 所有 LLM 调用经过的装载器(装配 → 调用 → 解析 → 记账)
100
+ │ ├── subagent-defs.ts # 子代理定义表:提示词、预算兜底、应答解析器,一处声明
101
+ │ ├── chain.ts # 责任链:一环节一步,顺序由环境类组装
102
+ │ ├── review-candidates.ts # 评审队列:什么该审(新写 > 被纠正牵连 > 陈旧)
103
+ │ ├── review.ts # 内容质量自进化:批量、预算、日 rollup、调度
104
+ │ ├── review-ring.ts # 它的链:便宜门 → 贵改写 → 落盘
100
105
  │ ├── review-prompt.ts # 评审提示词 + verdict 解析(门 + 记忆/技能 rubric)
101
- │ ├── store-apply.ts # 唯一写门:全量校验、事务原子应用、账本
102
106
  │ ├── memory-rules.ts # 机械门禁:枚举、尺寸、id 规范、secret 筛查
103
107
  │ ├── memory-section.ts # 注入:查库 / 截断 / 会话冻结
104
108
  │ ├── memory-guide.ts # 主会话指南(三行)
105
109
  │ ├── memory-tools.ts # memory_write + memory_read(宿主形状定义)
106
110
  │ ├── skill-rules.ts # 技能门禁:字段校验、SKILL.md 渲染、受管路径
107
111
  │ ├── skill-tools.ts # skill_write + skill_read(项目遮蔽全局)
108
- │ ├── extraction.ts # 运行器 + 按会话分槽的调度器(轮级、无空闲计时器)
112
+ │ ├── extraction.ts # 入口 + 按会话分槽的调度器(轮级、无空闲计时器)
113
+ │ ├── extraction-ring.ts # 它的链:预检门 → 提案调用 + 过门落盘
109
114
  │ ├── extraction-surface.ts # 轨迹切片:游标、资格判定、序列化
110
115
  │ ├── extraction-prompt.ts # 提取 prompt + 应答解析
111
116
  │ ├── import-md.ts # 旧版 markdown 无损迁移
@@ -115,7 +120,7 @@ pnpm check:pack # 发布产物一致性(prepack 也会跑)
115
120
  ├── client/
116
121
  │ └── client.js # 手写 client bundle:官方插件管理内只读卡片
117
122
  ├── locale/ # 宿主包元数据:显示名 + 描述双语(zh/en)
118
- ├── test/ # vitest 测试(26 个文件)
123
+ ├── test/ # vitest 测试(28 个文件)
119
124
  ├── lib/ # 构建产物(tsc)
120
125
  ├── docs/ # design.md(设计)· FAQ.md(踩坑)
121
126
  └── .agents/ # AI 协作层(AGENTS.md、技能、ADR 笔记)
package/lib/chain.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The responsibility chain behind one pass of a background ring.
3
+ *
4
+ * Each link owns exactly one step (call a subagent, land a verdict), knows
5
+ * nothing about its neighbours, and reports whether it terminated the walk.
6
+ * The environment class assembles the links, so adding, removing or
7
+ * reordering a step is an assembly change — the alternative, every step
8
+ * naming its own successor, is what hard-codes a flow into each step and puts
9
+ * the same `kind === "memory" ? A : B` branch in several places.
10
+ *
11
+ * The item the links share is a plain mutable record: each link fills in what
12
+ * it observed and reads only what earlier links left behind.
13
+ */
14
+ /** What one link did: it terminated the walk (`handled`), or the item moves on (`next`). */
15
+ export type ChainOutcome = "handled" | "next";
16
+ /** One step of a chain. */
17
+ export interface ChainLink<Item> {
18
+ /** Stable step name, used when the chain turns out to have no terminal link. */
19
+ readonly name: string;
20
+ handle(item: Item): ChainOutcome | Promise<ChainOutcome>;
21
+ }
22
+ /**
23
+ * Walk the chain until a link handles the item.
24
+ *
25
+ * @param links - The assembled chain, in order.
26
+ * @param item - The mutable item the links read and fill in.
27
+ * @throws When no link handles the item — a chain without a terminal step is a wiring bug, not a quiet no-op.
28
+ */
29
+ export declare function runChain<Item>(links: readonly ChainLink<Item>[], item: Item): Promise<void>;
30
+ //# sourceMappingURL=chain.d.ts.map
package/lib/chain.js ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The responsibility chain behind one pass of a background ring.
3
+ *
4
+ * Each link owns exactly one step (call a subagent, land a verdict), knows
5
+ * nothing about its neighbours, and reports whether it terminated the walk.
6
+ * The environment class assembles the links, so adding, removing or
7
+ * reordering a step is an assembly change — the alternative, every step
8
+ * naming its own successor, is what hard-codes a flow into each step and puts
9
+ * the same `kind === "memory" ? A : B` branch in several places.
10
+ *
11
+ * The item the links share is a plain mutable record: each link fills in what
12
+ * it observed and reads only what earlier links left behind.
13
+ */
14
+ /**
15
+ * Walk the chain until a link handles the item.
16
+ *
17
+ * @param links - The assembled chain, in order.
18
+ * @param item - The mutable item the links read and fill in.
19
+ * @throws When no link handles the item — a chain without a terminal step is a wiring bug, not a quiet no-op.
20
+ */
21
+ export async function runChain(links, item) {
22
+ for (const link of links) {
23
+ if ((await link.handle(item)) === "handled") {
24
+ return;
25
+ }
26
+ }
27
+ throw new Error(`chain exhausted without a terminal link: ${links.map((link) => link.name).join(" → ")}`);
28
+ }
29
+ //# sourceMappingURL=chain.js.map
@@ -0,0 +1,20 @@
1
+ import type { ExtractionDeps, ExtractionResult, ExtractionTarget, RunOptions } from "./extraction.js";
2
+ /** Head length of an error message embedded in a ledger `skip_reason`. */
3
+ export declare const ERROR_REASON_MAX = 300;
4
+ /**
5
+ * The pipeline body: one bounded increment of one session, one LLM call, one
6
+ * gated transactional application, one ledger row, one cursor.
7
+ *
8
+ * @param deps - The store, the trajectory reader, and the LLM.
9
+ * @param target - Whose session, in which workspace, on which route.
10
+ * @param opts - The trigger and the increment ceiling.
11
+ * @returns The terminal outcome of the run.
12
+ * @throws Only when the ledger itself is unwritable (the caller ledgers that too).
13
+ */
14
+ export declare function runExtractionInner(deps: ExtractionDeps, target: ExtractionTarget, opts: RunOptions): Promise<ExtractionResult>;
15
+ /** Split an unexpected throw into a ledger-sized reason head and file body. */
16
+ export declare function errorText(error: unknown): {
17
+ message: string;
18
+ detail: string;
19
+ };
20
+ //# sourceMappingURL=extraction-ring.d.ts.map
@@ -0,0 +1,299 @@
1
+ /**
2
+ * The extraction pass, assembled as a responsibility chain: cheap pre-gate →
3
+ * proposal call (and, on a proposal, the store's gated application).
4
+ *
5
+ * The gate fails open by design — an unclear or dead gate hands the increment
6
+ * to the expensive call, so the worst case is the old single-call behavior,
7
+ * never a stall. Both links ledger everything they observe onto the run's own
8
+ * row: one run stays one row, and a failed call keeps the usage and route it
9
+ * reported before it died.
10
+ */
11
+ import { runChain } from "./chain.js";
12
+ import { observedUsageOf } from "./llm-call.js";
13
+ import { boundaryCursorOf, eventsAfterCursor, evaluateSlice, seqOfCursor, serializeIncrement } from "./extraction-surface.js";
14
+ import { CHEAP_INCREMENT_MAX, cheapPrompt, extractionPrompt, manifestLineOf } from "./extraction-prompt.js";
15
+ import { extractionGate, extractionMain } from "./subagent-defs.js";
16
+ import { cutFailureOf, subagentLoaderFor } from "./subagent.js";
17
+ /** Head length of a failed run's raw answer kept in the ledger `files` column. */
18
+ const FAILURE_RAW_MAX = 2048;
19
+ /** Head length of an unexpected error's message/stack trace kept in the ledger `files` column. */
20
+ const UNEXPECTED_ERROR_RAW_MAX = 2048;
21
+ /** Head length of an error message embedded in a ledger `skip_reason`. */
22
+ export const ERROR_REASON_MAX = 300;
23
+ /** Head length of a failed pre-gate call's error kept in the ledger `cheap_error` column. */
24
+ const GATE_ERROR_MAX = 200;
25
+ /**
26
+ * The pipeline body: one bounded increment of one session, one LLM call, one
27
+ * gated transactional application, one ledger row, one cursor.
28
+ *
29
+ * @param deps - The store, the trajectory reader, and the LLM.
30
+ * @param target - Whose session, in which workspace, on which route.
31
+ * @param opts - The trigger and the increment ceiling.
32
+ * @returns The terminal outcome of the run.
33
+ * @throws Only when the ledger itself is unwritable (the caller ledgers that too).
34
+ */
35
+ export async function runExtractionInner(deps, target, opts) {
36
+ const { store } = deps;
37
+ const ledgerBase = {
38
+ sessionId: target.sessionId,
39
+ workspaceId: target.workspaceId,
40
+ trigger: opts.trigger,
41
+ };
42
+ if (target.internalAgent) {
43
+ store.logSkipped({ ...ledgerBase, skipReason: "internal-agent", status: "skipped" });
44
+ return { status: "skipped", skipReason: "internal-agent" };
45
+ }
46
+ if (target.workspaceId === undefined) {
47
+ store.logSkipped({ ...ledgerBase, skipReason: "no-workspace", status: "skipped" });
48
+ return { status: "skipped", skipReason: "no-workspace" };
49
+ }
50
+ if (deps.surface === undefined) {
51
+ store.logSkipped({ ...ledgerBase, skipReason: "no-surface", status: "skipped" });
52
+ return { status: "skipped", skipReason: "no-surface" };
53
+ }
54
+ if (target.provider === undefined || target.model === undefined || deps.llm === undefined) {
55
+ store.logSkipped({ ...ledgerBase, skipReason: "no-route", status: "skipped" });
56
+ return { status: "skipped", skipReason: "no-route" };
57
+ }
58
+ // Narrowed once, here: the loader takes one concrete route and session.
59
+ const workspaceId = target.workspaceId;
60
+ const route = { provider: target.provider, model: target.model };
61
+ const call = { llm: deps.llm, route, sessionId: target.sessionId };
62
+ const cursor = store.cursor(workspaceId, target.sessionId);
63
+ let all;
64
+ try {
65
+ const snapshot = await deps.surface.readSurface(target.sessionId);
66
+ all = Array.isArray(snapshot.events) ? snapshot.events : [];
67
+ }
68
+ catch (error) {
69
+ const { message, detail } = errorText(error);
70
+ try {
71
+ store.logSkipped({ ...ledgerBase, skipReason: "surface-read-failed", status: "failed", files: detail });
72
+ }
73
+ catch (ledgerError) {
74
+ throw new Error(`surface read failed (${message}); failure ledger write also failed (${errorText(ledgerError).message})`);
75
+ }
76
+ return { status: "failed", skipReason: `surface read failed: ${message}` };
77
+ }
78
+ const events = eventsAfterCursor(all, cursor === undefined ? undefined : `seq:${cursor}`);
79
+ const slice = evaluateSlice({ events, internalAgent: false });
80
+ if (!slice.eligible) {
81
+ store.logSkipped({ ...ledgerBase, skipReason: slice.skipReason, status: "skipped" });
82
+ return { status: "skipped", skipReason: slice.skipReason };
83
+ }
84
+ const boundary = boundaryCursorOf(all);
85
+ const boundarySeq = seqOfCursor(boundary);
86
+ const item = {
87
+ deps,
88
+ target,
89
+ opts,
90
+ ledgerBase: { ...ledgerBase, workspaceId },
91
+ route,
92
+ loader: subagentLoaderFor(call),
93
+ increment: serializeIncrement(slice.events, opts.incrementMaxChars),
94
+ userSeqs: slice.userSeqs,
95
+ userText: slice.userText,
96
+ boundarySeq,
97
+ advanceCursor: () => {
98
+ if (boundarySeq !== undefined) {
99
+ store.setCursor(workspaceId, target.sessionId, boundarySeq);
100
+ }
101
+ },
102
+ cheapUsage: undefined,
103
+ cheapError: undefined,
104
+ };
105
+ await runChain(extractionChain(), item);
106
+ if (item.result === undefined) {
107
+ // Unreachable while the main link always handles the item; kept so a
108
+ // future chain edit cannot silently return "nothing happened".
109
+ throw new Error("extraction chain ended without a result");
110
+ }
111
+ return item.result;
112
+ }
113
+ /** Assemble the extraction chain: the pre-gate (fail open) then the proposal call. */
114
+ function extractionChain() {
115
+ return [gateLink(), proposeLink()];
116
+ }
117
+ /**
118
+ * Cheap pre-gate: a small verdict before the expensive full call (manifest +
119
+ * full-body candidates + full increment). A "not worth it" consumes the
120
+ * increment exactly like a model skip; an unclear gate (unparseable/failed)
121
+ * fails OPEN to the full call. A gate that dies still rides the run's own row
122
+ * as `cheap_error` — without it a failed gate is indistinguishable from a gate
123
+ * that never existed, which is how a 512-token budget truncating every call on
124
+ * a reasoning model stayed invisible — and now its observed usage rides the
125
+ * same row.
126
+ */
127
+ function gateLink() {
128
+ return {
129
+ name: "extraction-gate",
130
+ async handle(item) {
131
+ const gateStarted = Date.now();
132
+ try {
133
+ const answer = await item.loader.run(extractionGate, cheapPrompt({
134
+ incrementHead: item.increment.slice(0, CHEAP_INCREMENT_MAX),
135
+ userSeqs: item.userSeqs,
136
+ }));
137
+ item.cheapUsage = answer.usage;
138
+ const verdict = answer.answer;
139
+ // A gate cut at its budget that still answered is a usable verdict;
140
+ // only a cut with nothing parseable rides the run's row as a gate
141
+ // error. Parse before blaming the finish reason.
142
+ if (verdict === null && answer.finish !== undefined && answer.finish !== "stop") {
143
+ throw cutFailureOf(answer, item.route);
144
+ }
145
+ if (verdict !== null && !verdict.worth) {
146
+ item.advanceCursor();
147
+ item.deps.store.logSkipped({
148
+ ...item.ledgerBase,
149
+ skipReason: `model-skip (cheap-gate): ${verdict.reason}`,
150
+ status: "skipped",
151
+ model: item.route.model,
152
+ cheapUsage: item.cheapUsage,
153
+ durationMs: Date.now() - gateStarted,
154
+ });
155
+ item.result = { status: "skipped", skipReason: verdict.reason, cursor: item.boundarySeq };
156
+ return "handled";
157
+ }
158
+ }
159
+ catch (error) {
160
+ // Fail open: the proposal call below is the old behavior. The error
161
+ // head is kept for the run's own row (never a row of its own, so
162
+ // one run stays one ledger row), and whatever the call reported
163
+ // before it died is kept with it.
164
+ item.cheapError = errorHead(error);
165
+ item.cheapUsage = observedUsageOf(error) ?? item.cheapUsage;
166
+ }
167
+ return "next";
168
+ },
169
+ };
170
+ }
171
+ /** The proposal call: the run's one expensive call, then the store's gates. */
172
+ function proposeLink() {
173
+ return {
174
+ name: "extraction-propose",
175
+ async handle(item) {
176
+ const { store } = item.deps;
177
+ const workspaceId = item.ledgerBase.workspaceId;
178
+ const manifest = store.list(workspaceId).slice(0, 50).map(manifestLineOf);
179
+ const candidates = candidateRecords(store, workspaceId, item.userText);
180
+ const prompt = extractionPrompt({
181
+ increment: item.increment,
182
+ manifest,
183
+ candidates: candidates.map(formatCandidate),
184
+ userSeqs: item.userSeqs,
185
+ });
186
+ const started = Date.now();
187
+ let answer;
188
+ try {
189
+ answer = await item.loader.run(extractionMain, prompt);
190
+ // The full call carries the proposal payload: a cut stream is a failure.
191
+ if (answer.finish !== undefined && answer.finish !== "stop") {
192
+ throw cutFailureOf(answer, item.route);
193
+ }
194
+ }
195
+ catch (error) {
196
+ const { message, detail } = errorText(error);
197
+ try {
198
+ store.logSkipped({
199
+ ...item.ledgerBase,
200
+ skipReason: "llm-failed",
201
+ status: "failed",
202
+ model: item.route.model,
203
+ usage: observedUsageOf(error),
204
+ durationMs: Date.now() - started,
205
+ cheapUsage: item.cheapUsage,
206
+ cheapError: item.cheapError,
207
+ files: detail,
208
+ });
209
+ }
210
+ catch (ledgerError) {
211
+ throw new Error(`llm call failed (${message}); failure ledger write also failed (${errorText(ledgerError).message})`);
212
+ }
213
+ item.result = { status: "failed", skipReason: `llm call failed: ${message}` };
214
+ return "handled";
215
+ }
216
+ const text = answer.text;
217
+ const usage = answer.usage;
218
+ const parsed = answer.answer;
219
+ if (parsed.decision === "error") {
220
+ // A malformed answer is a failed run: the increment stays unconsumed
221
+ // and the next trigger retries it. The answer's head rides in the
222
+ // ledger row — the stream is gone after this, so without it the
223
+ // failure is undiagnosable after the fact.
224
+ store.logSkipped({
225
+ ...item.ledgerBase,
226
+ skipReason: parsed.reason,
227
+ status: "failed",
228
+ model: item.route.model,
229
+ usage,
230
+ cheapUsage: item.cheapUsage,
231
+ cheapError: item.cheapError,
232
+ durationMs: Date.now() - started,
233
+ files: text.slice(0, FAILURE_RAW_MAX),
234
+ });
235
+ item.result = { status: "failed", skipReason: parsed.reason };
236
+ return "handled";
237
+ }
238
+ if (parsed.decision === "skip") {
239
+ // A real model decision (even "nothing worth keeping") consumed the
240
+ // increment — advancing the cursor here is what keeps retries honest.
241
+ item.advanceCursor();
242
+ store.logSkipped({
243
+ ...item.ledgerBase,
244
+ skipReason: `model-skip: ${parsed.reason}`,
245
+ status: "skipped",
246
+ model: item.route.model,
247
+ usage,
248
+ cheapUsage: item.cheapUsage,
249
+ cheapError: item.cheapError,
250
+ durationMs: Date.now() - started,
251
+ });
252
+ item.result = { status: "skipped", skipReason: parsed.reason, cursor: item.boundarySeq };
253
+ return "handled";
254
+ }
255
+ const outcomes = store.applyProposals({
256
+ workspaceId,
257
+ trigger: item.opts.trigger,
258
+ sessionId: item.target.sessionId,
259
+ runId: `extract:${item.target.sessionId}:${item.boundarySeq ?? "tail"}`,
260
+ model: item.route.model,
261
+ usage,
262
+ cheapUsage: item.cheapUsage,
263
+ cheapError: item.cheapError,
264
+ decision: parsed.reason,
265
+ }, parsed.proposals);
266
+ if (!outcomes.every((outcome) => outcome.ok)) {
267
+ item.result = { status: "rejected", skipReason: outcomes.find((outcome) => !outcome.ok)?.reason };
268
+ return "handled";
269
+ }
270
+ item.advanceCursor();
271
+ // Hygiene rides on every applied run: orphan FTS rows dropped, secret
272
+ // leakage quarantined — never a separate scheduler.
273
+ const patrol = store.patrol();
274
+ void patrol;
275
+ item.result = { status: "applied", applied: outcomes.filter((outcome) => outcome.ok).length, cursor: item.boundarySeq };
276
+ return "handled";
277
+ },
278
+ };
279
+ }
280
+ /** Similar existing memories (full bodies) for the model to update against. */
281
+ function candidateRecords(store, workspaceId, userText) {
282
+ const query = userText.split("\n")[0]?.slice(0, 100).trim() ?? "";
283
+ const hits = query.length >= 3 ? store.search(workspaceId, query, 8) : [];
284
+ return hits.length > 0 ? hits : store.list(workspaceId).slice(0, 8);
285
+ }
286
+ function formatCandidate(record) {
287
+ return [`# 候选 id=${record.id}(type=${record.type})`, `title=${record.title}`, `钩子=${record.description}`, "全文:", record.body].join("\n");
288
+ }
289
+ /** Split an unexpected throw into a ledger-sized reason head and file body. */
290
+ export function errorText(error) {
291
+ const message = error instanceof Error && error.message.length > 0 ? error.message : String(error);
292
+ const stack = error instanceof Error && typeof error.stack === "string" ? error.stack : message;
293
+ return { message, detail: `${message}\n${stack}`.slice(0, UNEXPECTED_ERROR_RAW_MAX) };
294
+ }
295
+ /** One-line head of a thrown pre-gate error (flat, so the column stays greppable). */
296
+ function errorHead(error) {
297
+ return errorText(error).message.replace(/\s+/g, " ").trim().slice(0, GATE_ERROR_MAX);
298
+ }
299
+ //# sourceMappingURL=extraction-ring.js.map