dsh-continual-evolve 0.15.13 → 0.15.15

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-395%20passing-brightgreen)]()
10
+ [![Tests](https://img.shields.io/badge/tests-409%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)]()
@@ -24,7 +24,7 @@ Reusable experience gathered in one session (user preferences, pitfalls, project
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
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
- 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.
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. Plugin diagnostics have a durable home of their own: a stock host attaches no exporter to its logger, so `plugin_log` is the only home — the host logger is not mirrored. All four ledgers rotate on one 30-day window, pruned by a day-throttled check that rides store readiness, log writes and the post-extraction hygiene pass.
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
 
30
30
  Memory types follow the ZCode taxonomy: `user` (profile and environment facts), `feedback` (corrected or confirmed practices — bodies must carry **Why:** and **How to apply:** lines), `reference` (resource pointers). The extraction boundary is person- and environment-scoped knowledge only: decisions and trade-offs are repo-scoped by nature and belong to the repo's ADR route, never to memory.
@@ -95,9 +95,12 @@ 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
+ │ ├── plugin-log.ts # the plugin's own log table (the host logger has no sink to write to)
99
+ │ ├── retention.ts # ledger retention: one 30-day window, day-throttled prune trigger
98
100
  │ ├── llm-call.ts # the only transport: route, reasoning effort, output budget, failure facts
99
101
  │ ├── subagent.ts # the loader every LLM call goes through (assemble → call → parse → account)
100
102
  │ ├── subagent-defs.ts # every subagent job in one table: prompt, budget fallback, answer parser
103
+ │ ├── prompt-date.ts # the clock prompts judge dates by: the run's day + its instruction line
101
104
  │ ├── chain.ts # responsibility chain: one step per link, order assembled by the caller
102
105
  │ ├── review-candidates.ts # its queue: what is due (new > corrects-linked > stale)
103
106
  │ ├── review.ts # autonomous quality loop: batch, budget, rollup, scheduler
@@ -120,7 +123,7 @@ Layout:
120
123
  ├── client/
121
124
  │ └── client.js # hand-written client bundle: read-only card in the plugin manager
122
125
  ├── locale/ # host package metadata: localized display name + description (zh/en)
123
- ├── test/ # vitest suite (28 files)
126
+ ├── test/ # vitest suite (29 files)
124
127
  ├── lib/ # build output (tsc)
125
128
  ├── docs/ # design.md (design) · FAQ.md (real-world pitfalls)
126
129
  └── .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-395%20passing-brightgreen)]()
10
+ [![Tests](https://img.shields.io/badge/tests-409%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)]()
@@ -24,7 +24,7 @@ Agent 每个会话积累的可复用经验(用户偏好、踩坑教训、项
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
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
- 5. **回滚靠账本,不靠机制** —— 每次变更落一条带 before/after 全文快照的 `extraction_log` 行;undo 就是取 before 重写。技能变更同样落 `skill_log`,评审 verdict 落 `review_log`。每次应用后随跑一次巡检:孤儿 FTS 行清除、含密钥条目隔离(隐藏,不物理删除;被隔离技能的物化文件一并撤下,使其不再被加载)。只读卡片显形巡检异常。
27
+ 5. **回滚靠账本,不靠机制** —— 每次变更落一条带 before/after 全文快照的 `extraction_log` 行;undo 就是取 before 重写。技能变更同样落 `skill_log`,评审 verdict 落 `review_log`。每次应用后随跑一次巡检:孤儿 FTS 行清除、含密钥条目隔离(隐藏,不物理删除;被隔离技能的物化文件一并撤下,使其不再被加载)。只读卡片显形巡检异常。插件诊断另有自己的 durable 家:宿主 logger 开箱没有 exporter,因此诊断只落 `plugin_log`,不再镜像宿主 logger;四张账本共用一个 30 天窗口裁剪,触发点是 store 就绪、写日志与提取后的巡检,按 UTC 日节流。
28
28
  6. **只读卡片** —— 挂在官方插件管理的本 bundle 页面(`plugins.bundle.config` slot,老宿主自动降级为无卡片):形态对齐市场自家设置卡,内容对齐 ZCode 设置的记忆选项卡——下拉作用域选择器列出库里的工作区分区(默认选中宿主当前工作区)、搜索框、每条记忆的相对更新时间、点击行内展开正文预览(5 MiB 上限)、巡检异常警告,以及技能区(本工作区技能 + 可见的全局技能,全局行打标记,同名时项目遮蔽全局),预览同记忆,每行显示查阅次数。显示名与描述走宿主 `locale/*.json` 通道双语;宿主主题 token、`Button` 原语、中英文案跟随 DSH 语言。卡片是数据库的请求时投影,没有编辑路径。
29
29
 
30
30
  记忆类型沿 ZCode 分类法:`user`(画像与环境事实)、`feedback`(被纠正/确认的做法——正文必带 **Why:** 与 **How to apply:**)、`reference`(资源指针)。提取边界只收"跟人与环境走的知识":决策与取舍天然属于仓库,走仓库的 ADR 路线,不进记忆库。
@@ -95,9 +95,12 @@ pnpm check:pack # 发布产物一致性(prepack 也会跑)
95
95
  │ ├── store.ts # 中央 SQLite 库:DDL/WAL/FTS、读面、账本、巡检、游标
96
96
  │ ├── store-apply.ts # 唯一写门:全量校验、事务原子应用、账本
97
97
  │ ├── token-usage.ts # 统一记账口:归一各家用量、纯累加
98
+ │ ├── plugin-log.ts # 插件自有日志:只写库表(宿主 logger 无承接方)
99
+ │ ├── retention.ts # 账本保留期:30 天窗口 + 按日节流的裁剪触发
98
100
  │ ├── llm-call.ts # 唯一传输层:路由、推理档位、输出预算、失败事实
99
101
  │ ├── subagent.ts # 所有 LLM 调用经过的装载器(装配 → 调用 → 解析 → 记账)
100
102
  │ ├── subagent-defs.ts # 子代理定义表:提示词、预算兜底、应答解析器,一处声明
103
+ │ ├── prompt-date.ts # 提示词的时钟:本轮基准日 + 那行「今天」指令
101
104
  │ ├── chain.ts # 责任链:一环节一步,顺序由环境类组装
102
105
  │ ├── review-candidates.ts # 评审队列:什么该审(新写 > 被纠正牵连 > 陈旧)
103
106
  │ ├── review.ts # 内容质量自进化:批量、预算、日 rollup、调度
@@ -120,7 +123,7 @@ pnpm check:pack # 发布产物一致性(prepack 也会跑)
120
123
  ├── client/
121
124
  │ └── client.js # 手写 client bundle:官方插件管理内只读卡片
122
125
  ├── locale/ # 宿主包元数据:显示名 + 描述双语(zh/en)
123
- ├── test/ # vitest 测试(28 个文件)
126
+ ├── test/ # vitest 测试(29 个文件)
124
127
  ├── lib/ # 构建产物(tsc)
125
128
  ├── docs/ # design.md(设计)· FAQ.md(踩坑)
126
129
  └── .agents/ # AI 协作层(AGENTS.md、技能、ADR 笔记)
@@ -12,6 +12,8 @@ import { runChain } from "./chain.js";
12
12
  import { observedUsageOf } from "./llm-call.js";
13
13
  import { boundaryCursorOf, eventsAfterCursor, evaluateSlice, seqOfCursor, serializeIncrement } from "./extraction-surface.js";
14
14
  import { CHEAP_INCREMENT_MAX, cheapPrompt, extractionPrompt, manifestLineOf } from "./extraction-prompt.js";
15
+ import { todayUtc } from "./prompt-date.js";
16
+ import { pruneIfDue } from "./retention.js";
15
17
  import { extractionGate, extractionMain } from "./subagent-defs.js";
16
18
  import { cutFailureOf, subagentLoaderFor } from "./subagent.js";
17
19
  /** Head length of a failed run's raw answer kept in the ledger `files` column. */
@@ -58,7 +60,7 @@ export async function runExtractionInner(deps, target, opts) {
58
60
  // Narrowed once, here: the loader takes one concrete route and session.
59
61
  const workspaceId = target.workspaceId;
60
62
  const route = { provider: target.provider, model: target.model };
61
- const call = { llm: deps.llm, route, sessionId: target.sessionId };
63
+ const call = { llm: deps.llm, route, sessionId: target.sessionId, today: opts.today ?? todayUtc() };
62
64
  const cursor = store.cursor(workspaceId, target.sessionId);
63
65
  let all;
64
66
  try {
@@ -272,6 +274,10 @@ function proposeLink() {
272
274
  // leakage quarantined — never a separate scheduler.
273
275
  const patrol = store.patrol();
274
276
  void patrol;
277
+ // Retention rides the same run: the ledgers only grow, and an
278
+ // applied extraction is what writes most of their rows. Day-throttled
279
+ // inside, so this is one state read on all but the day's first run.
280
+ pruneIfDue(store);
275
281
  item.result = { status: "applied", applied: outcomes.filter((outcome) => outcome.ok).length, cursor: item.boundarySeq };
276
282
  return "handled";
277
283
  },
@@ -1,6 +1,7 @@
1
1
  import { type SkipReason, type SurfaceReader } from "./extraction-surface.js";
2
2
  import type { LedgerEntry, MemoryStore } from "./store.js";
3
3
  import { type LlmRuntime, type RouteResolver } from "./llm-call.js";
4
+ import type { PluginLog } from "./plugin-log.js";
4
5
  export type { LlmStream, LlmRuntime, RouteResolver } from "./llm-call.js";
5
6
  /** Everything a run needs: the store, the session reader, and the LLM. */
6
7
  export interface ExtractionDeps {
@@ -36,6 +37,8 @@ export interface RunOptions {
36
37
  trigger: LedgerEntry["trigger"];
37
38
  /** Character ceiling for the serialized increment (default 40000). */
38
39
  incrementMaxChars?: number;
40
+ /** Baseline day `YYYY-MM-DD` (UTC) the prompts judge dates by; defaults to the run's wall clock. */
41
+ today?: string | undefined;
39
42
  }
40
43
  /**
41
44
  * Run one extraction pass for a target. Never throws unless the ledger
@@ -48,10 +51,8 @@ export declare function runExtraction(deps: ExtractionDeps, target: ExtractionTa
48
51
  /** The event wiring surface (duck-typed cordis context). */
49
52
  export interface SchedulerHost {
50
53
  on(event: string, handler: (...args: never[]) => void): unknown;
51
- logger(name: string): {
52
- info(message: string): void;
53
- warn(message: string): void;
54
- };
54
+ /** The plugin's own log face: durable in the store, mirrored live. */
55
+ log: PluginLog;
55
56
  }
56
57
  /**
57
58
  * Wire the extraction triggers onto the host context. Returns a disposer
package/lib/extraction.js CHANGED
@@ -127,7 +127,7 @@ export function createExtractionScheduler(host, deps) {
127
127
  catch {
128
128
  // The ledger is down — the warn below is the last resort.
129
129
  }
130
- host.logger("continual-evolve").warn(`extraction run failed unexpectedly: ${message}`);
130
+ host.log.warn("extraction-unexpected", `run failed unexpectedly: ${message}`, { sessionId });
131
131
  }).finally(() => {
132
132
  slot.running = false;
133
133
  if (slot.pending !== null) {
@@ -186,7 +186,7 @@ export function createExtractionScheduler(host, deps) {
186
186
  // its own reference and simply finds nothing left to drain.
187
187
  slots.delete(id);
188
188
  }));
189
- host.logger("continual-evolve").info("extraction scheduler armed (per-session slots, turn-level, single-flight per session)");
189
+ host.log.info("extraction-armed", "scheduler armed (per-session slots, turn-level, single-flight per session)");
190
190
  return () => {
191
191
  slots.clear();
192
192
  for (const listener of listeners) {
package/lib/index.d.ts CHANGED
@@ -43,10 +43,6 @@ interface HostContext {
43
43
  text: string | ((context: SectionContext) => string);
44
44
  }): void;
45
45
  };
46
- logger(name: string): {
47
- info(message: string): void;
48
- warn(message: string): void;
49
- };
50
46
  }
51
47
  export declare const Config: z<Schemastery.ObjectS<NoInfer<{
52
48
  /**
package/lib/index.js CHANGED
@@ -38,6 +38,8 @@ import { registerMemoryTools } from "./memory-tools.js";
38
38
  import { registerSkillTools } from "./skill-tools.js";
39
39
  import { importWorkspaceMd } from "./import-md.js";
40
40
  import { openMemoryStore } from "./store.js";
41
+ import { createPluginLog } from "./plugin-log.js";
42
+ import { pruneIfDue } from "./retention.js";
41
43
  import { newestRegistryPath } from "./workspace-hint.js";
42
44
  export const name = "continual-evolve";
43
45
  export const inject = ["systemPrompt"];
@@ -117,23 +119,23 @@ function registerSchedulerDisposer(scoped, dispose) {
117
119
  }
118
120
  }
119
121
  /** Register the memory tools through a nested inject (see memory-tools.ts). */
120
- function mountToolsWhenAvailable(host, store, state) {
122
+ function mountToolsWhenAvailable(host, store, state, log) {
121
123
  const nested = host;
122
124
  if (typeof nested.inject !== "function") {
123
- host.logger("continual-evolve").warn("host context exposes no inject — memory tools not registered (injection unaffected; explicit memory management falls back to telling the user)");
125
+ log.warn("tools-not-mounted", "host context exposes no inject — memory tools not registered (injection unaffected; explicit memory management falls back to telling the user)");
124
126
  return;
125
127
  }
126
128
  nested.inject(["tools"], (scoped) => {
127
129
  const tools = scoped.tools;
128
130
  if (tools === undefined || typeof tools.register !== "function") {
129
- host.logger("continual-evolve").warn("tools service present but empty — memory tools not registered (injection unaffected)");
131
+ log.warn("tools-empty", "tools service present but empty — memory tools not registered (injection unaffected)");
130
132
  return;
131
133
  }
132
134
  registerMemoryTools(tools, store);
133
135
  registerSkillTools(tools, store);
134
136
  state.toolsAvailable = true;
135
- host.logger("continual-evolve").info("memory_write and memory_read registered");
136
- host.logger("continual-evolve").info("skill_write and skill_read registered");
137
+ log.info("memory-tools-registered", "memory_write and memory_read registered");
138
+ log.info("skill-tools-registered", "skill_write and skill_read registered");
137
139
  });
138
140
  }
139
141
  /**
@@ -143,28 +145,29 @@ function mountToolsWhenAvailable(host, store, state) {
143
145
  * without either service (or without `.on`) simply never arms — injection
144
146
  * and the explicit tools remain functional.
145
147
  */
146
- function mountExtractionWhenAvailable(host, store, config) {
147
- const log = host.logger("continual-evolve");
148
+ function mountExtractionWhenAvailable(host, store, config, log) {
148
149
  if (config.memoryIndex?.extraction === false) {
149
- log.info("extraction disabled by config — injection and memory_write remain active");
150
+ log.info("extraction-disabled", "extraction disabled by config — injection and memory_write remain active");
150
151
  return;
151
152
  }
152
153
  const nested = host;
153
154
  const events = host;
154
- if (typeof nested.inject !== "function" || typeof events.on !== "function") {
155
- log.warn("host context exposes no inject/on — extraction not armed (injection and tools unaffected)");
155
+ const on = events.on;
156
+ if (typeof nested.inject !== "function" || typeof on !== "function") {
157
+ log.warn("extraction-not-armed", "host context exposes no inject/on — extraction not armed (injection and tools unaffected)");
156
158
  return;
157
159
  }
160
+ const schedulerHost = { on: (event, handler) => on.call(events, event, handler), log };
158
161
  nested.inject(["sessionQuery", "llm"], (scoped) => {
159
162
  const context = scoped;
160
163
  const surface = context.sessionQuery;
161
164
  const llm = (context.llm ?? context.get?.("llm"));
162
165
  if (surface === undefined || typeof surface.readSurface !== "function") {
163
- log.warn("sessionQuery service present but empty — extraction not armed (injection and tools unaffected)");
166
+ log.warn("extraction-no-surface", "sessionQuery service present but empty — extraction not armed (injection and tools unaffected)");
164
167
  return;
165
168
  }
166
169
  const resolveRoute = routeResolverOf(scoped, context.get);
167
- registerSchedulerDisposer(scoped, createExtractionScheduler(events, {
170
+ registerSchedulerDisposer(scoped, createExtractionScheduler(schedulerHost, {
168
171
  store, surface, llm,
169
172
  ...(resolveRoute === undefined ? {} : { resolveRoute }),
170
173
  }));
@@ -176,23 +179,24 @@ function mountExtractionWhenAvailable(host, store, config) {
176
179
  * increments), so it mounts independently from extraction. Own prompts, own
177
180
  * budget, own ledger; the store's write gates are the only shared surface.
178
181
  */
179
- function mountReviewWhenAvailable(host, store, config) {
180
- const log = host.logger("continual-evolve");
182
+ function mountReviewWhenAvailable(host, store, config, log) {
181
183
  if (config.review?.enabled?.get() === false) {
182
- log.info("review disabled by config — extraction and tools unaffected");
184
+ log.info("review-disabled", "review disabled by config — extraction and tools unaffected");
183
185
  return;
184
186
  }
185
187
  const nested = host;
186
188
  const events = host;
187
- if (typeof nested.inject !== "function" || typeof events.on !== "function") {
188
- log.warn("host context exposes no inject/on — review not armed (injection and tools unaffected)");
189
+ const on = events.on;
190
+ if (typeof nested.inject !== "function" || typeof on !== "function") {
191
+ log.warn("review-not-armed", "host context exposes no inject/on — review not armed (injection and tools unaffected)");
189
192
  return;
190
193
  }
194
+ const schedulerHost = { on: (event, handler) => on.call(events, event, handler), log };
191
195
  nested.inject(["llm"], (scoped) => {
192
196
  const context = scoped;
193
197
  const llm = (context.llm ?? context.get?.("llm"));
194
198
  const resolveRoute = routeResolverOf(scoped, context.get);
195
- registerSchedulerDisposer(scoped, createReviewScheduler(events, {
199
+ registerSchedulerDisposer(scoped, createReviewScheduler(schedulerHost, {
196
200
  store, llm,
197
201
  ...(resolveRoute === undefined ? {} : { resolveRoute }),
198
202
  }, {
@@ -216,17 +220,17 @@ function mountReviewWhenAvailable(host, store, config) {
216
220
  * `ctx.get` on every request (the FAQ #2 sibling-service rule — the service is
217
221
  * optional, so it must not join the inject list, which would gate the mount).
218
222
  */
219
- function mountCardWhenAvailable(host, store) {
223
+ function mountCardWhenAvailable(host, store, log) {
220
224
  const nested = host;
221
225
  if (typeof nested.inject !== "function") {
222
- host.logger("continual-evolve").warn("host context exposes no inject — memory card routes not mounted (memory section unaffected)");
226
+ log.warn("card-not-mounted", "host context exposes no inject — memory card routes not mounted (memory section unaffected)");
223
227
  return;
224
228
  }
225
229
  nested.inject(["webServer"], (scoped) => {
226
230
  const context = scoped;
227
231
  const webServer = context.webServer;
228
232
  if (webServer === undefined) {
229
- host.logger("continual-evolve").warn("webServer service present but empty — memory card routes not mounted (memory section unaffected)");
233
+ log.warn("card-empty-server", "webServer service present but empty — memory card routes not mounted (memory section unaffected)");
230
234
  return;
231
235
  }
232
236
  mountCardRoutes(webServer, store, {
@@ -238,20 +242,32 @@ export function apply(host, config) {
238
242
  if (config.memoryIndex?.enabled === false) {
239
243
  return;
240
244
  }
241
- const log = host.logger("continual-evolve");
242
245
  const state = { store: undefined, toolsAvailable: false };
246
+ // One log face for the whole plugin, writing to the store once it is open.
247
+ // Created before the store exists: the early mounts and the failure path
248
+ // below call it, and lines written before readiness are dropped.
249
+ const log = createPluginLog(() => state.store);
243
250
  void openMemoryStore().then((store) => {
244
251
  state.store = store;
245
- mountToolsWhenAvailable(host, store, state);
246
- mountExtractionWhenAvailable(host, store, config);
247
- mountReviewWhenAvailable(host, store, config);
252
+ // Retention has no scheduler: the day-throttled check rides mount and
253
+ // every log write, so the ledgers stay bounded in a long-lived process.
254
+ try {
255
+ pruneIfDue(store);
256
+ }
257
+ catch (error) {
258
+ log.warn("prune-failed", `ledger retention skipped: ${error instanceof Error ? error.message : String(error)}`);
259
+ }
260
+ mountToolsWhenAvailable(host, store, state, log);
261
+ mountExtractionWhenAvailable(host, store, config, log);
262
+ mountReviewWhenAvailable(host, store, config, log);
248
263
  if (config.memoryCard?.enabled !== false) {
249
- mountCardWhenAvailable(host, store);
264
+ mountCardWhenAvailable(host, store, log);
250
265
  }
251
- }, (error) => {
252
- // node:sqlite missing (old runtime) or the database unusable:
253
- // degrade to a no-op plugin, never take the host down.
254
- log.warn(`memory store unavailable — injection and tools disabled: ${error instanceof Error ? error.message : String(error)}`);
266
+ }, (_error) => {
267
+ // node:sqlite missing (old runtime) or the database unusable: degrade
268
+ // to a no-op plugin, never take the host down. Nothing is logged: the
269
+ // store is this plugin's only log home, and it is the thing that just
270
+ // failed — the host's logger has no sink to fall back on.
255
271
  });
256
272
  const frozen = createFrozenMemorySection();
257
273
  host.systemPrompt.section({
@@ -272,7 +288,7 @@ export function apply(host, config) {
272
288
  importWorkspaceMd(store, cwd);
273
289
  }
274
290
  catch (error) {
275
- log.warn(`legacy memory import failed for ${cwd}: ${error instanceof Error ? error.message : String(error)}`);
291
+ log.warn("md-import-failed", `legacy memory import failed for ${cwd}: ${error instanceof Error ? error.message : String(error)}`, { workspaceId: cwd });
276
292
  }
277
293
  }
278
294
  return frozen.textFor(context.agent, (agent) => memorySectionText(agent, {
@@ -283,6 +299,6 @@ export function apply(host, config) {
283
299
  }));
284
300
  },
285
301
  });
286
- log.info("continual-evolve memory section registered (central SQLite store, sole-writer gates)");
302
+ log.info("section-registered", "continual-evolve memory section registered (central SQLite store, sole-writer gates)");
287
303
  }
288
304
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,21 @@
1
+ import type { MemoryStore } from "./store.js";
2
+ /** The partition a line belongs to, when the call site knows one. */
3
+ export interface LogWhere {
4
+ workspaceId?: string | undefined;
5
+ sessionId?: string | undefined;
6
+ }
7
+ /** The log face feature code holds: durable in the store, nothing else. */
8
+ export interface PluginLog {
9
+ info(event: string, message: string, where?: LogWhere): void;
10
+ warn(event: string, message: string, where?: LogWhere): void;
11
+ error(event: string, message: string, where?: LogWhere): void;
12
+ }
13
+ /**
14
+ * Bind a log face to one store accessor.
15
+ *
16
+ * @param storeOf - Reads the store each call: the sink outlives store startup,
17
+ * and a host whose store never opened has nowhere to write.
18
+ * @returns The log face feature code calls.
19
+ */
20
+ export declare function createPluginLog(storeOf: () => MemoryStore | undefined): PluginLog;
21
+ //# sourceMappingURL=plugin-log.d.ts.map
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The plugin's log face: one call writes one durable row.
3
+ *
4
+ * The host's logger is not a channel this plugin uses. A stock host attaches no
5
+ * exporter to it (docs/FAQ.md #4), so it can never be the home of a fact worth
6
+ * re-reading — and the sink belongs to the user's environment, which the plugin
7
+ * does not control. The store is the only home, which also means a diagnostic
8
+ * written while the store is unavailable is dropped: this face never throws,
9
+ * because a log line that can take a session down costs more than a lost line.
10
+ */
11
+ import { pruneIfDue } from "./retention.js";
12
+ /**
13
+ * Bind a log face to one store accessor.
14
+ *
15
+ * @param storeOf - Reads the store each call: the sink outlives store startup,
16
+ * and a host whose store never opened has nowhere to write.
17
+ * @returns The log face feature code calls.
18
+ */
19
+ export function createPluginLog(storeOf) {
20
+ const write = (level, event, message, where) => {
21
+ const store = storeOf();
22
+ if (store === undefined) {
23
+ return;
24
+ }
25
+ try {
26
+ store.logPlugin({ level, event, message, ...where });
27
+ // Retention rides the writes: the log is the one table written
28
+ // without a scheduler of its own, so it prunes itself.
29
+ pruneIfDue(store);
30
+ }
31
+ catch {
32
+ // Swallowed by design: the durable write and its prune are
33
+ // best-effort, and there is no second channel to fall back to.
34
+ }
35
+ };
36
+ return {
37
+ info: (event, message, where) => write("info", event, message, where),
38
+ warn: (event, message, where) => write("warn", event, message, where),
39
+ error: (event, message, where) => write("error", event, message, where),
40
+ };
41
+ }
42
+ //# sourceMappingURL=plugin-log.js.map
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The clock every prompt is anchored to: the run's baseline day and the line
3
+ * that hands it to the model.
4
+ *
5
+ * The model has no clock of its own. A direct LLM call carries an instruction
6
+ * and a user prompt and nothing else — no runtime context, no timestamps — so
7
+ * "is this date in the future?" is answered from the model's own prior unless
8
+ * the caller says otherwise. A run resolves its day once at the boundary and
9
+ * carries it as data, so a replay and a live run see the same prompts.
10
+ */
11
+ /**
12
+ * The current UTC day `YYYY-MM-DD`.
13
+ *
14
+ * UTC is the plugin's unit: the review account's day uses the same value, so
15
+ * one run cannot straddle two dates.
16
+ *
17
+ * @returns Today's date in UTC.
18
+ */
19
+ export declare function todayUtc(): string;
20
+ /**
21
+ * The date-baseline line appended to the instruction of a job that makes
22
+ * time-relative judgments.
23
+ *
24
+ * It names the day *and* says which side of it is a defect, because the rule
25
+ * being protected is "no future dates", not "never touch dates": the 2026-10-14
26
+ * trial produced 8 of 13 rewrite verdicts that stripped correct past dates as
27
+ * if they were future ones.
28
+ *
29
+ * @param today - The run's baseline day `YYYY-MM-DD` (UTC).
30
+ * @returns The instruction line to append to a system prompt.
31
+ */
32
+ export declare function dateBaseline(today: string): string;
33
+ //# sourceMappingURL=prompt-date.d.ts.map
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The clock every prompt is anchored to: the run's baseline day and the line
3
+ * that hands it to the model.
4
+ *
5
+ * The model has no clock of its own. A direct LLM call carries an instruction
6
+ * and a user prompt and nothing else — no runtime context, no timestamps — so
7
+ * "is this date in the future?" is answered from the model's own prior unless
8
+ * the caller says otherwise. A run resolves its day once at the boundary and
9
+ * carries it as data, so a replay and a live run see the same prompts.
10
+ */
11
+ /**
12
+ * The current UTC day `YYYY-MM-DD`.
13
+ *
14
+ * UTC is the plugin's unit: the review account's day uses the same value, so
15
+ * one run cannot straddle two dates.
16
+ *
17
+ * @returns Today's date in UTC.
18
+ */
19
+ export function todayUtc() {
20
+ return new Date().toISOString().slice(0, 10);
21
+ }
22
+ /**
23
+ * The date-baseline line appended to the instruction of a job that makes
24
+ * time-relative judgments.
25
+ *
26
+ * It names the day *and* says which side of it is a defect, because the rule
27
+ * being protected is "no future dates", not "never touch dates": the 2026-10-14
28
+ * trial produced 8 of 13 rewrite verdicts that stripped correct past dates as
29
+ * if they were future ones.
30
+ *
31
+ * @param today - The run's baseline day `YYYY-MM-DD` (UTC).
32
+ * @returns The instruction line to append to a system prompt.
33
+ */
34
+ export function dateBaseline(today) {
35
+ return `时间基准:今天是 ${today}(UTC)。正文里早于或等于今天的日期是按期发生的过去事实,不是缺陷、不要据此改写;只有晚于今天的日期才算「未来日期」错误。把相对日期(如"周四")折算成绝对日期时,以今天为参照。`;
36
+ }
37
+ //# sourceMappingURL=prompt-date.js.map
@@ -0,0 +1,23 @@
1
+ /**
2
+ * How long the plugin keeps its own ledgers, and when it prunes them.
3
+ *
4
+ * `MemoryStore.pruneLedger` shipped with no production caller, so "the ledgers
5
+ * are pruned" was true of the API and false of the machine (2026-10-14 real
6
+ * store: 547 extraction rows in 9 days, 115 review rows in 3). Retention is a
7
+ * policy with a trigger, and the trigger is the part that was missing.
8
+ *
9
+ * The check is day-throttled and idempotent, so every writer can call it
10
+ * cheaply and a long-running process prunes without a scheduler of its own.
11
+ */
12
+ import { type MemoryStore } from "./store.js";
13
+ /** Rows older than this many days are pruned. */
14
+ export declare const LEDGER_RETENTION_DAYS = 30;
15
+ /**
16
+ * Prune the ledgers and the plugin log when this UTC day has not been pruned.
17
+ *
18
+ * @param store - The store to prune.
19
+ * @param now - The instant to judge by (defaults to the wall clock; tests pass one).
20
+ * @returns Rows removed, or 0 when today's prune already ran.
21
+ */
22
+ export declare function pruneIfDue(store: MemoryStore, now?: Date): number;
23
+ //# sourceMappingURL=retention.d.ts.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * How long the plugin keeps its own ledgers, and when it prunes them.
3
+ *
4
+ * `MemoryStore.pruneLedger` shipped with no production caller, so "the ledgers
5
+ * are pruned" was true of the API and false of the machine (2026-10-14 real
6
+ * store: 547 extraction rows in 9 days, 115 review rows in 3). Retention is a
7
+ * policy with a trigger, and the trigger is the part that was missing.
8
+ *
9
+ * The check is day-throttled and idempotent, so every writer can call it
10
+ * cheaply and a long-running process prunes without a scheduler of its own.
11
+ */
12
+ import { GLOBAL_WORKSPACE } from "./store.js";
13
+ /** Rows older than this many days are pruned. */
14
+ export const LEDGER_RETENTION_DAYS = 30;
15
+ /** The state key holding the UTC day of the last prune. */
16
+ const PRUNE_STATE_KEY = "prune:last";
17
+ /**
18
+ * Prune the ledgers and the plugin log when this UTC day has not been pruned.
19
+ *
20
+ * @param store - The store to prune.
21
+ * @param now - The instant to judge by (defaults to the wall clock; tests pass one).
22
+ * @returns Rows removed, or 0 when today's prune already ran.
23
+ */
24
+ export function pruneIfDue(store, now = new Date()) {
25
+ const day = now.toISOString().slice(0, 10);
26
+ if (store.state(GLOBAL_WORKSPACE, PRUNE_STATE_KEY) === day) {
27
+ return 0;
28
+ }
29
+ const cutoff = new Date(now.getTime() - LEDGER_RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString();
30
+ const removed = store.pruneLedger(cutoff);
31
+ store.setState(GLOBAL_WORKSPACE, PRUNE_STATE_KEY, day);
32
+ return removed;
33
+ }
34
+ //# sourceMappingURL=retention.js.map
package/lib/review.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type LlmRuntime, type RouteResolver } from "./llm-call.js";
2
+ import type { PluginLog } from "./plugin-log.js";
2
3
  import type { MemoryStore } from "./store.js";
3
4
  /** The LLM face review calls, aliased so each scheduler can name its own. */
4
5
  export type ReviewLlm = LlmRuntime;
@@ -27,7 +28,7 @@ export interface ReviewCandidate {
27
28
  scope?: "project" | "global" | undefined;
28
29
  }
29
30
  export interface ReviewRunOptions {
30
- /** UTC day `YYYY-MM-DD` the hard cap accounts against. */
31
+ /** UTC day `YYYY-MM-DD` the hard cap accounts against — also the clock the prompts judge dates by. */
31
32
  day: string;
32
33
  /** Hard cap for the day (100). Candidates past it count as dropped. */
33
34
  budget: number;
@@ -64,15 +65,11 @@ export declare const REVIEW_DEFAULT_STALE_DAYS = 30;
64
65
  * run keeps going), exactly like extraction's fail-ledgered discipline.
65
66
  */
66
67
  export declare function runReviewBatch(deps: ReviewDeps, candidates: readonly ReviewCandidate[], opts: ReviewRunOptions): Promise<ReviewRunSummary>;
67
- /** Current UTC day `YYYY-MM-DD` for the hard-cap account. */
68
- export declare function todayUtc(): string;
69
68
  /** The event wiring surface (duck-typed cordis context, mirrors extraction). */
70
69
  export interface ReviewSchedulerHost {
71
70
  on(event: string, handler: (...args: never[]) => void): unknown;
72
- logger(name: string): {
73
- info(message: string): void;
74
- warn(message: string): void;
75
- };
71
+ /** The plugin's own log face: durable in the store, mirrored live. */
72
+ log: PluginLog;
76
73
  }
77
74
  /** The editable review settings, re-read on every drain. */
78
75
  export interface ReviewSettings {
package/lib/review.js CHANGED
@@ -20,8 +20,9 @@
20
20
  import { runChain } from "./chain.js";
21
21
  import { resolveCallRoute } from "./llm-call.js";
22
22
  import { cwdOf } from "./memory-section.js";
23
+ import { todayUtc } from "./prompt-date.js";
23
24
  import { collectCandidates } from "./review-candidates.js";
24
- import { reviewChain } from "./review-ring.js";
25
+ import { failureHead, reviewChain } from "./review-ring.js";
25
26
  import { subagentLoaderFor } from "./subagent.js";
26
27
  import { zeroUsage } from "./token-usage.js";
27
28
  /** Defaults for the review settings the host may override — and edit live. */
@@ -49,59 +50,85 @@ export async function runReviewBatch(deps, candidates, opts) {
49
50
  const llm = deps.llm;
50
51
  // The environment class: one loader and one chain for the whole run, in the
51
52
  // order the ring declares. Without a route there is no ring to walk.
52
- const ring = route === undefined || llm === undefined ? undefined : assembleRing({ llm, route, sessionId: opts.sessionId });
53
+ const ring = route === undefined || llm === undefined ? undefined : assembleRing({ llm, route, sessionId: opts.sessionId, today: opts.day });
53
54
  let position = 0;
54
- for (const candidate of batch) {
55
- const remaining = deps.store.consumeReviewBudget(opts.day, opts.budget);
56
- if (remaining < 0) {
57
- // Hard cap hit: this item and everything after it drops (low
58
- // priority never rolls over — it simply waits for a future run).
59
- summary.dropped += batch.length - position;
60
- for (const item of batch.slice(position)) {
55
+ // The run's account is written whatever happens to the loop: a fault that
56
+ // escapes one item (or the loop itself) is rethrown *after* the rollup is
57
+ // merged, so a dead run can never leave `reviewed` incremented with every
58
+ // other counter at zero and nothing saying why (2026-10-14 incident).
59
+ let escaped;
60
+ let failed = false;
61
+ try {
62
+ for (const candidate of batch) {
63
+ try {
64
+ const remaining = deps.store.consumeReviewBudget(opts.day, opts.budget);
65
+ if (remaining < 0) {
66
+ // Hard cap hit: this item and everything after it drops (low
67
+ // priority never rolls over — it simply waits for a future run).
68
+ summary.dropped += batch.length - position;
69
+ for (const item of batch.slice(position)) {
70
+ deps.store.logReview({
71
+ runId, workspaceId: item.workspaceId, itemKind: item.kind, itemId: item.id,
72
+ verdict: "skip", reason: "budget-exhausted", applied: 0,
73
+ });
74
+ }
75
+ skipReasons.push("budget-exhausted");
76
+ break;
77
+ }
78
+ summary.reviewed += 1;
79
+ if (candidate.priority === 0) {
80
+ newItems += 1;
81
+ }
82
+ else {
83
+ requeued += 1;
84
+ }
85
+ if (route === undefined || ring === undefined) {
86
+ deps.store.logReview({
87
+ runId, workspaceId: candidate.workspaceId, itemKind: candidate.kind, itemId: candidate.id,
88
+ verdict: "skip", reason: "no-route", applied: 0,
89
+ });
90
+ skipReasons.push("no-route");
91
+ position += 1;
92
+ continue;
93
+ }
94
+ const item = {
95
+ deps, loader: ring.loader, route, runId, candidate, opts, summary, skipReasons,
96
+ gate: { usage: zeroUsage(), raw: undefined },
97
+ };
98
+ await runChain(ring.links, item);
99
+ }
100
+ catch (error) {
101
+ // A fault escaping the chain (a store write, a wiring bug) gets its
102
+ // own row carrying the error text and costs the batch only this
103
+ // item — the model-facing failures already degrade inside the ring.
61
104
  deps.store.logReview({
62
- runId, workspaceId: item.workspaceId, itemKind: item.kind, itemId: item.id,
63
- verdict: "skip", reason: "budget-exhausted", applied: 0,
105
+ runId, workspaceId: candidate.workspaceId, itemKind: candidate.kind, itemId: candidate.id,
106
+ verdict: "skip", reason: `review-failed: ${failureHead(error)}`,
107
+ model: route?.model, applied: 0,
64
108
  });
109
+ skipReasons.push("review-failed");
65
110
  }
66
- skipReasons.push("budget-exhausted");
67
- break;
68
- }
69
- summary.reviewed += 1;
70
- if (candidate.priority === 0) {
71
- newItems += 1;
72
- }
73
- else {
74
- requeued += 1;
75
- }
76
- if (route === undefined || ring === undefined) {
77
- deps.store.logReview({
78
- runId, workspaceId: candidate.workspaceId, itemKind: candidate.kind, itemId: candidate.id,
79
- verdict: "skip", reason: "no-route", applied: 0,
80
- });
81
- skipReasons.push("no-route");
82
111
  position += 1;
83
- continue;
84
112
  }
85
- const item = {
86
- deps, loader: ring.loader, route, runId, candidate, opts, summary, skipReasons,
87
- gate: { usage: zeroUsage(), raw: undefined },
88
- };
89
- await runChain(ring.links, item);
90
- position += 1;
91
113
  }
92
- summary.backlog = Math.max(0, candidates.length - summary.reviewed - summary.dropped);
93
- deps.store.mergeDaily(opts.day, opts.budget, {
94
- newItems, requeued, approved: summary.approved, applied: summary.applied,
95
- cheapToken: summary.cheapToken, evolveToken: summary.evolveToken,
96
- backlog: summary.backlog, dropped: summary.dropped,
97
- skipReasons: skipReasons.length > 0 ? skipReasons : undefined,
98
- });
114
+ catch (error) {
115
+ failed = true;
116
+ escaped = error;
117
+ }
118
+ finally {
119
+ summary.backlog = Math.max(0, candidates.length - summary.reviewed - summary.dropped);
120
+ deps.store.mergeDaily(opts.day, opts.budget, {
121
+ newItems, requeued, approved: summary.approved, applied: summary.applied,
122
+ cheapToken: summary.cheapToken, evolveToken: summary.evolveToken,
123
+ backlog: summary.backlog, dropped: summary.dropped,
124
+ skipReasons: skipReasons.length > 0 ? skipReasons : undefined,
125
+ });
126
+ }
127
+ if (failed) {
128
+ throw escaped;
129
+ }
99
130
  return summary;
100
131
  }
101
- /** Current UTC day `YYYY-MM-DD` for the hard-cap account. */
102
- export function todayUtc() {
103
- return new Date().toISOString().slice(0, 10);
104
- }
105
132
  /** Assemble one run's ring (simple factory: one context, one bound loader, one chain). */
106
133
  function assembleRing(call) {
107
134
  const loader = subagentLoaderFor(call);
@@ -140,26 +167,39 @@ export function createReviewScheduler(host, deps, config) {
140
167
  const workspace = triggerWorkspace ?? pendingWorkspace;
141
168
  pendingWorkspace = undefined;
142
169
  void (async () => {
170
+ const settings = config.read();
171
+ const day = todayUtc();
143
172
  try {
144
- const settings = config.read();
145
- const day = todayUtc();
146
173
  const daily = deps.store.getDaily(day);
147
174
  const remaining = settings.dailyBudget - (daily?.reviewed ?? 0);
148
175
  if (remaining <= 0) {
176
+ host.log.info("review-budget-spent", `drain skipped: daily budget spent (${daily?.reviewed ?? 0}/${settings.dailyBudget})`, { workspaceId: workspace });
149
177
  return;
150
178
  }
151
179
  const candidates = collectCandidates(deps.store, { staleDays: settings.staleDays, limit: settings.batchSize * 2 });
152
180
  const ordered = workspace === undefined
153
181
  ? candidates
154
182
  : [...candidates.filter((c) => c.workspaceId === workspace), ...candidates.filter((c) => c.workspaceId !== workspace)];
155
- await runReviewBatch(deps, ordered.slice(0, settings.batchSize), {
183
+ const summary = await runReviewBatch(deps, ordered.slice(0, settings.batchSize), {
156
184
  day, budget: settings.dailyBudget, dryRun: settings.dryRun,
157
185
  batchSize: settings.batchSize, staleDays: settings.staleDays,
158
186
  provider: lastRoute?.provider, model: lastRoute?.model, sessionId: lastRoute?.sessionId,
159
187
  });
188
+ host.log.info("review-drain", `reviewed ${summary.reviewed}, approved ${summary.approved}, ` +
189
+ `applied ${summary.applied}, dropped ${summary.dropped}, backlog ${summary.backlog}`, { workspaceId: workspace, sessionId: lastRoute?.sessionId });
160
190
  }
161
191
  catch (error) {
162
- host.logger("continual-evolve").warn(`review drain failed: ${error instanceof Error ? error.message : String(error)}`);
192
+ const head = failureHead(error);
193
+ host.log.warn("review-drain-failed", head, { workspaceId: workspace, sessionId: lastRoute?.sessionId });
194
+ // Two records on purpose: the log is the live channel, the day's
195
+ // rollup is the durable one every later reading sees. A drain that
196
+ // died must leave a trace in the ledger, not only in a log row.
197
+ try {
198
+ deps.store.mergeDaily(day, settings.dailyBudget, { skipReasons: [`drain-failed: ${head}`] });
199
+ }
200
+ catch (recordError) {
201
+ host.log.warn("review-drain-unledgered", failureHead(recordError), { workspaceId: workspace });
202
+ }
163
203
  }
164
204
  finally {
165
205
  running = false;
@@ -189,7 +229,7 @@ export function createReviewScheduler(host, deps, config) {
189
229
  drain(cwdOf(agent));
190
230
  }));
191
231
  const armed = config.read();
192
- host.logger("continual-evolve").info(`review scheduler armed (daily cap ${armed.dailyBudget}, dry-run ${armed.dryRun ? "on" : "off"})`);
232
+ host.log.info("review-armed", `scheduler armed (daily cap ${armed.dailyBudget}, dry-run ${armed.dryRun ? "on" : "off"})`);
193
233
  return () => {
194
234
  pendingWorkspace = undefined;
195
235
  for (const listener of listeners) {
package/lib/store.d.ts CHANGED
@@ -153,6 +153,21 @@ export interface LedgerEntry {
153
153
  /** Applied/attempted mutations: `{id, action, before?, after?, sourceSeqs}`; a parse-failed run instead carries the raw answer's head. */
154
154
  files?: unknown;
155
155
  }
156
+ /** One plugin-log line: the level, a stable event slug, and the message. */
157
+ export interface PluginLogEntry {
158
+ level: "info" | "warn" | "error";
159
+ /**
160
+ * The event's stable name (`drain-failed`, `card-no-webserver` …), so the
161
+ * table can be counted and grepped instead of read line by line.
162
+ */
163
+ event: string;
164
+ message: string;
165
+ /** The partition the line belongs to, when the call site knows one. */
166
+ workspaceId?: string | undefined;
167
+ sessionId?: string | undefined;
168
+ }
169
+ /** Longest message kept; a runaway error string must not become a table row. */
170
+ export declare const PLUGIN_LOG_MESSAGE_MAX = 2000;
156
171
  /** Workspace rows the card's selector lists. */
157
172
  export interface WorkspaceSummary {
158
173
  workspaceId: string;
@@ -219,7 +234,12 @@ export interface MemoryStore {
219
234
  mostRecentlyActiveWorkspace(): string | undefined;
220
235
  /** Hygiene pass: orphan FTS cleanup + secret quarantine. */
221
236
  patrol(): PatrolResult;
222
- /** Ledger rows older than the cutoff, removed (the one unbounded table). */
237
+ /**
238
+ * Append one plugin-log line — the durable home for plugin diagnostics,
239
+ * because the host's logger has no exporter on a stock install.
240
+ */
241
+ logPlugin(entry: PluginLogEntry): void;
242
+ /** Ledger and log rows older than the cutoff, removed (the unbounded tables). */
223
243
  pruneLedger(beforeIso: string): number;
224
244
  close(): void;
225
245
  /** One active skill row from a partition, undefined when absent. */
package/lib/store.js CHANGED
@@ -162,7 +162,22 @@ CREATE TABLE IF NOT EXISTS review_daily (
162
162
  skip_top TEXT,
163
163
  skip_counts TEXT
164
164
  ) STRICT;
165
+ -- The plugin's own log. A stock host attaches no exporter to its logger, so a
166
+ -- ctx.logger call leaves no trace anywhere (docs/FAQ.md #4) — diagnostics that
167
+ -- have no domain ledger row of their own land here instead. Pruned with the
168
+ -- ledgers; the event column is a stable slug, so runs can be counted and grepped.
169
+ CREATE TABLE IF NOT EXISTS plugin_log (
170
+ ts TEXT NOT NULL,
171
+ level TEXT NOT NULL CHECK (level IN ('info','warn','error')),
172
+ event TEXT NOT NULL,
173
+ message TEXT NOT NULL,
174
+ workspace_id TEXT,
175
+ session_id TEXT
176
+ ) STRICT;
177
+ CREATE INDEX IF NOT EXISTS idx_plugin_log_ts ON plugin_log(ts);
165
178
  `;
179
+ /** Longest message kept; a runaway error string must not become a table row. */
180
+ export const PLUGIN_LOG_MESSAGE_MAX = 2000;
166
181
  /** Default database location: `$DSH_HOME/evolve/memory.db`, else `~/.dsh/…`. */
167
182
  export function defaultStorePath() {
168
183
  const home = process.env.DSH_HOME?.trim();
@@ -375,6 +390,8 @@ export async function openMemoryStore(explicitPath) {
375
390
  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`),
376
391
  reviewEvolved: database.prepare("SELECT COUNT(*) AS n FROM review_log WHERE workspace_id IS ? AND item_kind = ? AND item_id = ? AND applied = 1"),
377
392
  reviewPrune: database.prepare("DELETE FROM review_log WHERE ts < ?"),
393
+ pluginLog: database.prepare("INSERT INTO plugin_log (ts, level, event, message, workspace_id, session_id) VALUES (?, ?, ?, ?, ?, ?)"),
394
+ pluginLogPrune: database.prepare("DELETE FROM plugin_log WHERE ts < ?"),
378
395
  dailyGet: database.prepare("SELECT * FROM review_daily WHERE day = ?"),
379
396
  dailyUpsert: database.prepare("INSERT INTO review_daily (day, budget, reviewed, new_items, requeued, approved, applied, cheap_token, evolve_token, backlog, dropped, skip_top, skip_counts) " +
380
397
  "VALUES (?, ?, 0, 0, 0, 0, 0, 0, 0, 0, 0, NULL, NULL) " +
@@ -794,11 +811,15 @@ export async function openMemoryStore(explicitPath) {
794
811
  stmts.stateSet.run(GLOBAL_WORKSPACE, PATROL_STATE_KEY, JSON.stringify(result));
795
812
  return result;
796
813
  },
814
+ logPlugin(entry) {
815
+ stmts.pluginLog.run(new Date().toISOString(), entry.level, entry.event, entry.message.slice(0, PLUGIN_LOG_MESSAGE_MAX), entry.workspaceId ?? null, entry.sessionId ?? null);
816
+ },
797
817
  pruneLedger(beforeIso) {
798
818
  const memory = Number(stmts.prune.run(beforeIso).changes);
799
819
  const skills = Number(stmts.skillPrune.run(beforeIso).changes);
800
820
  const reviews = Number(stmts.reviewPrune.run(beforeIso).changes);
801
- return memory + skills + reviews;
821
+ const logs = Number(stmts.pluginLogPrune.run(beforeIso).changes);
822
+ return memory + skills + reviews + logs;
802
823
  },
803
824
  close() {
804
825
  database.close();
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { EXTRACTION_CHEAP_SYSTEM_PROMPT, EXTRACTION_SYSTEM_PROMPT, parseCheapAnswer, parseExtractionAnswer, } from "./extraction-prompt.js";
14
14
  import { parseEvolveAnswer, parseGateAnswer, REVIEW_MEMORY_CHEAP_SYSTEM_PROMPT, REVIEW_MEMORY_SYSTEM_PROMPT, REVIEW_SKILL_CHEAP_SYSTEM_PROMPT, REVIEW_SKILL_SYSTEM_PROMPT, } from "./review-prompt.js";
15
+ import { dateBaseline } from "./prompt-date.js";
15
16
  /**
16
17
  * Output-budget fallbacks, used only for adapters that advertise no
17
18
  * per-request cap of their own. A gate verdict is one JSON object plus the
@@ -21,45 +22,53 @@ import { parseEvolveAnswer, parseGateAnswer, REVIEW_MEMORY_CHEAP_SYSTEM_PROMPT,
21
22
  export const GATE_MAX_TOKENS = 1024;
22
23
  export const EVOLVE_MAX_TOKENS = 4096;
23
24
  export const EXTRACTION_MAX_TOKENS = 4096;
25
+ /**
26
+ * Bind one instruction to the run's clock.
27
+ *
28
+ * Every job here reads item text that can name dates, and a direct call
29
+ * carries no clock of its own — so "today" is instruction, not data, and it
30
+ * arrives as the same appended line for every job in the ring.
31
+ */
32
+ const withDateBaseline = (instruction) => (context) => `${instruction}\n\n${dateBaseline(context.today)}`;
24
33
  /** The memory cheap gate: is this entry worth a close look at all? */
25
34
  export const reviewGateMemory = {
26
35
  id: "review-gate-memory",
27
- system: REVIEW_MEMORY_CHEAP_SYSTEM_PROMPT,
36
+ system: withDateBaseline(REVIEW_MEMORY_CHEAP_SYSTEM_PROMPT),
28
37
  maxTokens: GATE_MAX_TOKENS,
29
38
  parse: (text) => parseGateAnswer(text),
30
39
  };
31
40
  /** The skill cheap gate: same question, scope-relative home rules. */
32
41
  export const reviewGateSkill = {
33
42
  id: "review-gate-skill",
34
- system: REVIEW_SKILL_CHEAP_SYSTEM_PROMPT,
43
+ system: withDateBaseline(REVIEW_SKILL_CHEAP_SYSTEM_PROMPT),
35
44
  maxTokens: GATE_MAX_TOKENS,
36
45
  parse: (text) => parseGateAnswer(text),
37
46
  };
38
47
  /** The memory evolve pass: five-dimension judgment plus the proposed rewrite. */
39
48
  export const reviewEvolveMemory = {
40
49
  id: "review-evolve-memory",
41
- system: REVIEW_MEMORY_SYSTEM_PROMPT,
50
+ system: withDateBaseline(REVIEW_MEMORY_SYSTEM_PROMPT),
42
51
  maxTokens: EVOLVE_MAX_TOKENS,
43
52
  parse: (text) => parseEvolveAnswer(text),
44
53
  };
45
54
  /** The skill evolve pass: scope-relative reuse, executability, duplication. */
46
55
  export const reviewEvolveSkill = {
47
56
  id: "review-evolve-skill",
48
- system: REVIEW_SKILL_SYSTEM_PROMPT,
57
+ system: withDateBaseline(REVIEW_SKILL_SYSTEM_PROMPT),
49
58
  maxTokens: EVOLVE_MAX_TOKENS,
50
59
  parse: (text) => parseEvolveAnswer(text),
51
60
  };
52
61
  /** The extraction pre-gate: does this increment hold anything worth proposing? */
53
62
  export const extractionGate = {
54
63
  id: "extraction-gate",
55
- system: EXTRACTION_CHEAP_SYSTEM_PROMPT,
64
+ system: withDateBaseline(EXTRACTION_CHEAP_SYSTEM_PROMPT),
56
65
  maxTokens: GATE_MAX_TOKENS,
57
66
  parse: (text) => parseCheapAnswer(text),
58
67
  };
59
68
  /** The extraction proposal call: the run's one expensive call. */
60
69
  export const extractionMain = {
61
70
  id: "extraction-main",
62
- system: EXTRACTION_SYSTEM_PROMPT,
71
+ system: withDateBaseline(EXTRACTION_SYSTEM_PROMPT),
63
72
  maxTokens: EXTRACTION_MAX_TOKENS,
64
73
  parse: (text) => parseExtractionAnswer(text),
65
74
  };
package/lib/subagent.d.ts CHANGED
@@ -15,12 +15,22 @@
15
15
  * across call sites, and a job that has no definition cannot be called.
16
16
  */
17
17
  import { SubagentCallError, type LlmRuntime, type ModelSelection } from "./llm-call.js";
18
+ /** The per-call facts a definition's instruction may be built from. */
19
+ export interface SubagentContext {
20
+ /** The run's baseline day `YYYY-MM-DD` (UTC) — the clock time-relative judgments use. */
21
+ readonly today: string;
22
+ }
18
23
  /** One subagent definition: everything that distinguishes one job from another. */
19
24
  export interface SubagentDefinition<Answer> {
20
25
  /** The job's name, for diagnostics; unique across the registry (asserted by test). */
21
26
  readonly id: string;
22
- /** The job's entire instruction, sent as the system prompt — nothing is appended at the call site. */
23
- readonly system: string;
27
+ /**
28
+ * The job's entire instruction, sent as the system prompt — nothing is
29
+ * appended at the call site. The builder form is for jobs whose
30
+ * instruction depends on a per-call fact (the clock a rubric judges dates
31
+ * against); the loader resolves it once per call.
32
+ */
33
+ readonly system: string | ((context: SubagentContext) => string);
24
34
  /** Output budget fallback; the exact model's advertised cap wins when it has one. */
25
35
  readonly maxTokens: number;
26
36
  /**
@@ -30,11 +40,13 @@ export interface SubagentDefinition<Answer> {
30
40
  */
31
41
  readonly parse: (text: string) => Answer;
32
42
  }
33
- /** What one call is bound to: the LLM service, the exact route, the session identity. */
43
+ /** What one call is bound to: the LLM service, the exact route, the session identity, the day. */
34
44
  export interface SubagentCall {
35
45
  llm: LlmRuntime | undefined;
36
46
  route: ModelSelection;
37
47
  sessionId: string | undefined;
48
+ /** The run's baseline day `YYYY-MM-DD` (UTC): the clock every instruction is anchored to. */
49
+ today: string;
38
50
  }
39
51
  /** One finished call: the parsed answer, the raw text, and the observed call facts. */
40
52
  export interface SubagentAnswer<Answer> {
@@ -61,7 +73,7 @@ export interface SubagentLoader {
61
73
  * Build the loader for one call context — the simple factory: the context
62
74
  * produces the bound runner, the definitions table produces the strategies.
63
75
  *
64
- * @param call - The LLM service, the exact route, and the session identity to send.
76
+ * @param call - The LLM service, the exact route, the session identity, and the baseline day to send.
65
77
  * @returns A loader whose `run` is the only LLM entry point a feature needs.
66
78
  */
67
79
  export declare function subagentLoaderFor(call: SubagentCall): SubagentLoader;
package/lib/subagent.js CHANGED
@@ -19,13 +19,14 @@ import { SubagentCallError, streamOnce } from "./llm-call.js";
19
19
  * Build the loader for one call context — the simple factory: the context
20
20
  * produces the bound runner, the definitions table produces the strategies.
21
21
  *
22
- * @param call - The LLM service, the exact route, and the session identity to send.
22
+ * @param call - The LLM service, the exact route, the session identity, and the baseline day to send.
23
23
  * @returns A loader whose `run` is the only LLM entry point a feature needs.
24
24
  */
25
25
  export function subagentLoaderFor(call) {
26
26
  return {
27
27
  async run(definition, prompt) {
28
- const result = await streamOnce(call.llm, call.route, call.sessionId, definition.system, prompt, definition.maxTokens);
28
+ const system = typeof definition.system === "function" ? definition.system({ today: call.today }) : definition.system;
29
+ const result = await streamOnce(call.llm, call.route, call.sessionId, system, prompt, definition.maxTokens);
29
30
  return { answer: definition.parse(result.text), text: result.text, usage: result.usage, finish: result.finish };
30
31
  },
31
32
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-continual-evolve",
3
- "version": "0.15.13",
3
+ "version": "0.15.15",
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": {