dsh-trivium 0.5.0 → 0.6.0

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
@@ -264,7 +264,8 @@ Off by default. After you turn **Chips (memory whitelist)** on in Settings, the
264
264
 
265
265
  - If the model never calls `ctx_find` and no chips are checked, the window is mostly the opening short map.
266
266
  - Extraction misses things; dirty data can be archived / deleted on the chip strip or edited / merged in Settings.
267
- - Do not open the same `.tdb` from two Node processes at once (one `dsh web` is enough).
267
+ - Do not open the same `.tdb` from two Node processes at once (one `dsh web` is enough). The panel now says so instead of showing the raw TDB error.
268
+ - **Supported window width: 900 px and up.** At 390 px the host keeps a fixed 188 px settings sidebar, leaving ~154 px for the plugin column (~106 px cards); the layout collapses but is not readable. Phone-width support is intentionally not claimed.
268
269
  - Markdown export is for humans; it cannot be parsed back. Git-friendly write-back is `trivium.jsonl`, not Markdown. External import (WorkBuddy / Claude Code / Codex) is one-shot, not two-way sync, and does not ingest session jsonl dumps.
269
270
  - Do not merge `.tdb` in git (binary). Track `trivium.jsonl`; ignore `.tdb`. jsonl merge conflicts are ordinary text — resolve, then reopen the workspace.
270
271
  - The session layer (when on) projects compaction and fork; it does not split boxes by message count. If the window has not reached DSH’s compact line, **Update checkpoints** will not invent history boxes — use **Create checkpoint**.
@@ -278,6 +279,10 @@ Off by default. After you turn **Chips (memory whitelist)** on in Settings, the
278
279
 
279
280
  ## Changelog
280
281
 
282
+ **0.6.0** — Maintenance release. The read-only revision checker and the stale-deadline cleaner now ship with the package as `dsh-trivium-check` and `dsh-trivium-clear-until` (dry run by default; `--apply` clears only deadlines also present in the body, `--all` clears every stale one). The checker previously existed only in the Git checkout even though the 0.5.1 notes told users to run it. This release also pins two verifications: turn identity comes from the host's own user-message id (checked in a real session log), and a genuine second-window attempt with a stale ETag returns 409 and succeeds after refresh. No defaults or data formats changed.
283
+
284
+ **0.5.1** — Correctness release for 0.5.0. A user turn is now booked by the text actually rendered, so three long chips can no longer ship 3 × ~217 tokens against one 300 allowance; commit is idempotent and an explicit zero budget stays zero. Version resolution returns the current node's own body, type, deadline and state instead of only its id, so a query matching a superseded decision no longer shows the old instruction under the new id. Imports plan URI identity and validate the whole resulting graph before one native transaction: two versions are never merged by similar text, no `supersedes` self-loop is written, and a failed transaction leaves no node, body or edge behind. When a delivered fact changes, the session that saw it gets a short correction notice (never the old body), scoped per workspace and consumed only after delivery. Kernel: `triviumdb@0.9.0-beta.3` (pinned prerelease). Windows are supported from 900 px upward; at 390 px the host's 188 px settings sidebar leaves ~154 px for the panel, which is not readable (it does not scroll horizontally). Upgrade note: `until` values written by 0.4.21 from prose dates are not rewritten automatically — clear them with **Show expired decisions** or `ctx_remember` with `until: ""`.
285
+
281
286
  **0.5.0** — Memory you can audit. Chips and recall now share **one** 300-token allowance per user turn instead of clipping to 300 each, the same node can no longer appear twice, and a tool loop no longer re-injects inside one turn. The delivery ledger is keyed by host message ids, so a failed send is retried and compaction rebuilds, while a fork inherits neither the ledger nor the pins. `ctx_find` is capped at ~1200 estimated tokens with whole-record truncation, and `ctx_read` projects a field allowlist so new metadata cannot grow its output.
282
287
 
283
288
  Settings gains a **Recall check** tab (read-only: it never writes, runs a tool, or requests an embedding) and per-node **Source** / **Decision history** tabs. Provenance is reported only when a write recorded it — no invented sessions, quotes, or deep links. Recall traces name the pipeline branches that actually ran, capped at 20 per workspace+session / 10 minutes / 64 KiB each.
@@ -348,6 +353,11 @@ The peer ranges now explicitly admit the supported DSH `0.1.5`, `0.2.0`, and `0.
348
353
  | Check for updates fails | That request only hits the npm registry for the latest version. Memory is unaffected; try again later. |
349
354
  | Old conversations will not open after rc.8 | That is DSH’s own session-store format change, not a broken `.tdb`. Graph memory still works via `ctx_find`; leftover boxes on unmatched old sessions are plot residue. |
350
355
 
356
+ **Maintenance commands** (shipped with the package since 0.6.0):
357
+
358
+ - `npx dsh-trivium-check <workspace>` — opens the graph **read-only** and reports `supersedes` self loops, cycles, forks, duplicate URIs, dangling endpoints and unresolvable chains. Exit code 0 clean, 2 issues, 1 cannot open.
359
+ - `npx dsh-trivium-clear-until <workspace>` — dry run: lists stale decisions that still carry a deadline. `--apply` clears only deadlines whose text also appears in the body (the shape 0.4.21 produced from prose dates); `--apply --all` clears every stale deadline, including explicit ones. Close the host first — the kernel is single-writer.
360
+
351
361
  Local source checkout: `npm install`, then `node scripts/link-dsh.mjs`, then restart `dsh web`. `triviumdb` is a native module; if it will not install, check Node against `package.json` `engines` (`^22.19.0 || >=24`).
352
362
 
353
363
  ---
@@ -355,8 +365,8 @@ Local source checkout: `npm install`, then `node scripts/link-dsh.mjs`, then res
355
365
  ## Release info
356
366
 
357
367
  - GitHub: https://github.com/QWQcool/dsh-trivium
358
- - Package version: `0.4.22` · [npm](https://www.npmjs.com/package/dsh-trivium)
359
- - Tested host: `@deepseek-ai/dsh@0.1.5-rc.1` (also `0.1.1-rc.2` / `0.1.0-rc.8`)
368
+ - Package version: `0.6.0` · [npm](https://www.npmjs.com/package/dsh-trivium)
369
+ - Tested host: `@deepseek-ai/dsh@0.2.1-rc.4` (the peer range also admits the 0.1.x lines)
360
370
  - License: MIT (depends on [TriviumDB](https://github.com/YoKONCy/TriviumDB), Apache-2.0)
361
371
 
362
372
  ---
package/README.zh-CN.md CHANGED
@@ -260,7 +260,8 @@ Trivium 是记忆内核,不是日记、日历或聊天伴侣。它按**节点
260
260
 
261
261
  - 模型不调 `ctx_find`、也不勾芯片时,窗口里主要是开场短地图。
262
262
  - 抽取会漏;脏数据可在芯片条归档 / 删除,或到设置里改、合并。
263
- - 同一个 `.tdb` 不要两个 Node 进程同时打开(正常只开一个 `dsh web` 即可)。
263
+ - 同一个 `.tdb` 不要两个 Node 进程同时打开(正常只开一个 `dsh web` 即可)。现在面板会给出一句可读说明,而不是原始 TDB 错误。
264
+ - **支持宽度:900px 及以上。** 390px 时宿主固定保留 188px 设置侧栏,插件内容列只剩约 154px(卡片约 106px),布局会收缩但不可读。本版不声称支持手机宽度。
264
265
  - Markdown 导出是给人看的,不能再解析回去。能回写进 git 的是 `trivium.jsonl`,不是 Markdown。外部导入(WorkBuddy / Claude Code / Codex)是一次性,不是双向同步,也不会吃会话 jsonl。
265
266
  - `.tdb` 不要在 git 里合并(二进制)。跟踪 `trivium.jsonl`,忽略 `.tdb`。jsonl 冲突按普通文本解决,再打开工作区即可。
266
267
  - 会话层打开时只投影 compaction 与 fork,不会按消息条数自己切方框。窗口没到 DSH 压缩线时,「更新检查点」也补不出历史方框;要分段请点「生成检查点」。
@@ -274,6 +275,10 @@ Trivium 是记忆内核,不是日记、日历或聊天伴侣。它按**节点
274
275
 
275
276
  ## 更新说明
276
277
 
278
+ **0.6.0** — 维护版本。只读版本链体检与过期期限清理现在随包分发:`dsh-trivium-check`、`dsh-trivium-clear-until`(默认只列不改;`--apply` 只清「期限同时出现在正文里」的那些,`--all` 清所有过期期限)。此前 0.5.1 的说明让用户运行 `scripts/check-integrity.mjs`,但该脚本并不在 npm 包里。本版同时固化两项验收:轮标识取自宿主自己的用户消息 id(已在真实会话日志中确认),以及真实第二窗口带过期 ETag 会得到 409、刷新后成功。默认值与数据格式均未变。
279
+
280
+ **0.5.1** — 0.5.0 的正确性修复版。一轮的预算改为按**实际渲染出的文本**记账,三条长芯片不可能再在同一轮送出 3 × 约 217 token;`commit` 幂等,显式 0 预算保持为 0。版本解析返回当前节点自己的正文、类型、期限与状态,而不是只换 id——查询命中被替代的决策时,不会再把旧指令挂在新 id 下。导入先按 URI 规划身份、校验映射后的完整图,再用一次原生事务提交:两个版本不会因正文相近被合并,不会写出 `supersedes` 自环,事务失败也不留下节点、正文或边。已投递的事实被修改后,看过它的会话会收到一条短更正(不重发旧正文),按工作区隔离,且只有真正送达才消费。内核升到 `triviumdb@0.9.0-beta.3`(精确锁定预发布版)。界面支持宽度为 **≥900px**;390px 时宿主固定 188px 侧栏只给面板留下约 154px,不可读(但不会横向滚动)。升级说明:0.4.21 从正文日期误写的 `until` 不会自动清除,请在「显示过期决策」里清空,或用 `ctx_remember` 传 `until: ""`。
281
+
277
282
  **0.5.0** — 记忆可审计。芯片与召回改为**共用**每轮 300 估算 token(原先各 300),同一节点不会出现两次,一轮内的工具循环也不再重复注入。投递账本按宿主消息 id 记账:失败可重试、压缩后可重建,fork 既不继承账本也不继承 pins。`ctx_find` 默认上限约 1200 估算 token,超预算丢弃整条记录;`ctx_read` 改为字段白名单,新元数据不会撑大默认输出。
278
283
 
279
284
  设置页新增**召回检查**页签(只读:不写库、不跑工具、不请求 embedding),条目详情新增**来源**与**决策历史**页签。只有写入时记录过来源才显示来源,不编造会话、引文或跳转。召回诊断只记录实际跑过的管线分支,每工作区+会话最多 20 条 / 10 分钟 / 每条 64KiB。
@@ -344,6 +349,11 @@ peer 范围显式接受已支持的 DSH `0.1.5`、`0.2.0`、`0.2.1` RC 版本线
344
349
  | 检查更新失败 | 只请求 npm registry 拿最新版本号,不影响记忆;过一会儿再点即可。 |
345
350
  | rc.8 之后旧对话打不开 | 那是 DSH 自己的会话库格式变了,不是 `.tdb` 坏了。图记忆仍可 `ctx_find`;对不上号的旧方框可当情节残留。 |
346
351
 
352
+ **维护命令**(0.6.0 起随包分发):
353
+
354
+ - `npx dsh-trivium-check <工作区>` —— 以**只读**方式打开图,报告 `supersedes` 自环、循环、分叉、重复 URI、悬空端点与无法解析的链。退出码:0 干净,2 有问题,1 打不开。
355
+ - `npx dsh-trivium-clear-until <工作区>` —— 默认只列出仍带期限的过期决策;`--apply` 只清「期限文本同时出现在正文里」的那些(0.4.21 从正文抓日期的形态);`--apply --all` 清掉所有过期期限,包括用户显式填的。先关宿主,内核是单写者。
356
+
347
357
  本地联调:仓库里 `npm install` 后 `node scripts/link-dsh.mjs`,再重启 `dsh web`。`triviumdb` 是原生模块,装不上时先看 Node 是否满足 `package.json` 的 `engines`(`^22.19.0 || >=24`)。
348
358
 
349
359
  ---
@@ -351,8 +361,8 @@ peer 范围显式接受已支持的 DSH `0.1.5`、`0.2.0`、`0.2.1` RC 版本线
351
361
  ## 发布信息
352
362
 
353
363
  - GitHub: https://github.com/QWQcool/dsh-trivium
354
- - 包版本:`0.4.22` · [npm](https://www.npmjs.com/package/dsh-trivium)
355
- - 测试宿主:`@deepseek-ai/dsh@0.1.5-rc.1`(兼 `0.1.1-rc.2` / `0.1.0-rc.8`)
364
+ - 包版本:`0.6.0` · [npm](https://www.npmjs.com/package/dsh-trivium)
365
+ - 测试宿主:`@deepseek-ai/dsh@0.2.1-rc.4`(peer 范围同时接受 0.1.x 各线)
356
366
  - License: MIT(依赖 [TriviumDB](https://github.com/YoKONCy/TriviumDB) 为 Apache-2.0)
357
367
 
358
368
  ---
@@ -35,6 +35,8 @@ export const DROP_REASONS = Object.freeze({
35
35
  const PIN_HEADER = "dsh-trivium pins (L0 only; use ctx_read for full text):";
36
36
  const RECALL_HEADER = (form) =>
37
37
  `dsh-trivium ${form || "recall"} (L0 only; use ctx_read for full text):`;
38
+ const CORRECTION_HEADER =
39
+ "dsh-trivium memory corrections (facts you were given earlier changed; do not rely on their old text):";
38
40
 
39
41
  const MIN_MAP_MATCH_CHARS = 12;
40
42
 
@@ -117,20 +119,34 @@ function shapeCandidate(raw, channel) {
117
119
  supersededBy: Number.isFinite(Number(raw.supersededBy)) ? Number(raw.supersededBy) : null,
118
120
  matchedHistory: raw.matchedHistory || null,
119
121
  note: normaliseText(raw.note, 120),
122
+ correction: raw.correction === true,
120
123
  fingerprint: "",
121
124
  };
122
125
  }
123
126
 
124
- function fingerprintOf(id, l0) {
125
- // Cheap stable content fingerprint: node id plus its normalised L0. Enough to
126
- // tell "same record delivered twice" from "record changed within the turn".
127
+ /**
128
+ * Cheap stable content fingerprint: node id, normalised L0 **and the state the
129
+ * model was shown**. id+L0 alone meant a deadline or archive flag could change
130
+ * without the next pre-step noticing, because the fingerprint still matched.
131
+ */
132
+ export function fingerprintOf(id, l0, record = {}) {
133
+ const state = [
134
+ record.type || "",
135
+ record.until || "",
136
+ record.stale === true ? "s" : "",
137
+ record.archived === true ? "a" : "",
138
+ record.correction === true ? "c" : "",
139
+ ].join("|");
127
140
  let hash = 5381;
128
- const s = String(l0 || "");
141
+ const s = `${String(l0 || "")}\u0000${state}`;
129
142
  for (let i = 0; i < s.length; i += 1) hash = ((hash * 33) ^ s.charCodeAt(i)) >>> 0;
130
143
  return `${id}:${hash.toString(36)}`;
131
144
  }
132
145
 
133
146
  function lineFor(record) {
147
+ if (record.channel === "correction") {
148
+ return `- [${record.type}#${record.id}] ${record.l0}`;
149
+ }
134
150
  const flags = [];
135
151
  if (record.stale) flags.push(record.until ? `stale until=${record.until}` : "stale");
136
152
  if (record.historical) flags.push(record.supersededBy ? `superseded->#${record.supersededBy}` : "superseded");
@@ -150,7 +166,8 @@ function withPath(record) {
150
166
 
151
167
  function blockTextOf(records, channel, form) {
152
168
  if (!records.length) return "";
153
- const header = channel === "pin" ? PIN_HEADER : RECALL_HEADER(form);
169
+ const header =
170
+ channel === "pin" ? PIN_HEADER : channel === "correction" ? CORRECTION_HEADER : RECALL_HEADER(form);
154
171
  return `${header}\n${records.map((r) => r.line).join("\n")}`;
155
172
  }
156
173
 
@@ -161,16 +178,21 @@ function blockTextOf(records, channel, form) {
161
178
  */
162
179
  export function renderContextRecords(plan, { form = "recall" } = {}) {
163
180
  const pinRecords = plan.records.filter((r) => r.channel === "pin");
164
- const recallRecords = plan.records.filter((r) => r.channel !== "pin");
181
+ const correctionRecords = plan.records.filter((r) => r.channel === "correction");
182
+ const recallRecords = plan.records.filter((r) => r.channel !== "pin" && r.channel !== "correction");
165
183
  const pinText = blockTextOf(pinRecords, "pin", form);
184
+ const correctionText = blockTextOf(correctionRecords, "correction", form);
166
185
  const recallText = blockTextOf(recallRecords, "recall", form);
167
186
  const pinTokens = pinText ? estimateTokens(pinText) : 0;
187
+ const correctionTokens = correctionText ? estimateTokens(correctionText) : 0;
168
188
  const recallTokens = recallText ? estimateTokens(recallText) : 0;
189
+ const total = pinTokens + correctionTokens + recallTokens;
169
190
  return {
170
191
  pins: { text: pinText, tokens: pinTokens },
192
+ corrections: { text: correctionText, tokens: correctionTokens },
171
193
  recall: { text: recallText, tokens: recallTokens },
172
- total: pinTokens + recallTokens,
173
- estimatedTokens: pinTokens + recallTokens,
194
+ total,
195
+ estimatedTokens: total,
174
196
  };
175
197
  }
176
198
 
@@ -178,6 +200,40 @@ function measure(records, form) {
178
200
  return renderContextRecords({ records }, { form }).total;
179
201
  }
180
202
 
203
+ /**
204
+ * Budget normalisation.
205
+ *
206
+ * An explicit 0 means "send nothing". It must never be turned back into the
207
+ * default 300 by `Number(budget) || DEFAULT`; only a missing value is default.
208
+ */
209
+ function normaliseBudget(value) {
210
+ if (value === undefined || value === null || value === "") return TURN_ADDITIONAL_BUDGET;
211
+ const n = Number(value);
212
+ if (!Number.isFinite(n)) return 0;
213
+ return Math.max(0, Math.floor(n));
214
+ }
215
+
216
+ /**
217
+ * Copy the current node's own fields onto the record.
218
+ *
219
+ * Without this, a hit on an old version only had its id swapped, so the model
220
+ * saw the new id with the superseded body — the exact defect in REVIEW P1-2.
221
+ */
222
+ function applyProjection(record, resolved, { remapped = false } = {}) {
223
+ if (resolved.type !== undefined && resolved.type !== null && String(resolved.type).trim()) {
224
+ record.type = normaliseText(resolved.type, 24) || record.type;
225
+ }
226
+ if (resolved.l0 !== undefined) record.l0 = normaliseText(resolved.l0, 200);
227
+ if (resolved.until !== undefined) record.until = normaliseText(resolved.until, 32);
228
+ if (resolved.stale !== undefined) record.stale = resolved.stale === true;
229
+ if (resolved.archived !== undefined) record.archived = resolved.archived === true;
230
+ if (resolved.dirty !== undefined) record.dirty = resolved.dirty === true;
231
+ if (Array.isArray(resolved.path)) record.path = normalisePath(resolved.path);
232
+ // A search path describes the candidate node; it cannot be reused for a
233
+ // different (current) node, so it is dropped instead of being faked.
234
+ else if (remapped) record.path = [];
235
+ }
236
+
181
237
  /**
182
238
  * Turn one round of candidates into the exact records to inject.
183
239
  *
@@ -204,7 +260,7 @@ export function selectContextRecords({
204
260
  delivered = null,
205
261
  mapText = "",
206
262
  } = {}) {
207
- const cap = Math.max(1, Number(budget) || TURN_ADDITIONAL_BUDGET);
263
+ const cap = normaliseBudget(budget);
208
264
  const at = now instanceof Date ? now : new Date();
209
265
  const deliveredSet = delivered instanceof Set ? delivered : new Set();
210
266
  const mapBlob = String(mapText || "");
@@ -222,20 +278,43 @@ export function selectContextRecords({
222
278
  drop(raw, DROP_REASONS.invalid, channel);
223
279
  return;
224
280
  }
225
- if (!record.l0) {
226
- drop(record, DROP_REASONS.empty, channel);
227
- return;
228
- }
229
- if (record.archived) {
230
- drop(record, DROP_REASONS.archived, channel);
231
- return;
232
- }
233
- if (record.dirty) {
234
- drop(record, DROP_REASONS.dirty, channel);
281
+
282
+ /*
283
+ * A correction is a short notice that a fact the model was already given
284
+ * changed. It is deliberately NOT a recall candidate: it must survive the
285
+ * archived/stale filters (those describe the fact and would drop the
286
+ * notice), must not be remapped (the note names the new id itself), and its
287
+ * body is the notice, never the old or new full text.
288
+ */
289
+ if (record.correction) {
290
+ if (!record.l0) {
291
+ drop(record, DROP_REASONS.empty, channel);
292
+ return;
293
+ }
294
+ record.channel = "correction";
295
+ record.channels = ["correction"];
296
+ record.fingerprint = fingerprintOf(record.id, record.l0, record);
297
+ record.line = lineFor(record);
298
+ if (deliveredSet.has(record.fingerprint)) {
299
+ drop(record, DROP_REASONS.duplicateTurn, record.channel);
300
+ return;
301
+ }
302
+ if (mapBlob && mapCoversRecord(mapBlob, record.l0)) {
303
+ drop(record, DROP_REASONS.alreadyInMap, record.channel);
304
+ return;
305
+ }
306
+ const key = `correction:${record.id}`;
307
+ if (byId.has(key)) {
308
+ drop(record, DROP_REASONS.duplicateNode, record.channel);
309
+ return;
310
+ }
311
+ byId.set(key, record);
312
+ ordered.push(record);
235
313
  return;
236
314
  }
237
315
 
238
- let targetId = record.id;
316
+ const originalId = record.id;
317
+ let targetId = originalId;
239
318
  if (record.channel !== "pin" && typeof resolveCurrent === "function") {
240
319
  let resolved = null;
241
320
  try {
@@ -252,17 +331,21 @@ export function selectContextRecords({
252
331
  drop(record, DROP_REASONS.noCurrent, channel);
253
332
  return;
254
333
  }
334
+ const remapped = nextId !== originalId;
255
335
  // The successor is gone, archived or expired: do not resurrect the old
256
336
  // node, and do not inject the dead chain either.
257
- if (nextId !== record.id) {
337
+ if (remapped) {
258
338
  if (resolved.archived === true || resolved.stale === true) {
259
339
  drop(record, resolved.archived === true ? DROP_REASONS.archived : DROP_REASONS.stale, channel);
260
340
  return;
261
341
  }
262
- record.matchedHistory = { from: record.id, current: nextId };
342
+ record.matchedHistory = { from: originalId, current: nextId };
263
343
  targetId = nextId;
264
344
  record.id = nextId;
265
345
  }
346
+ // Body, type, deadline and state all come from the node that will be
347
+ // rendered. The matched candidate only supplies the history pointer.
348
+ applyProjection(record, resolved, { remapped });
266
349
  } else if (record.channel !== "pin" && record.stale) {
267
350
  // Recall never injects expired nodes; pinned chips may be stale (the user
268
351
  // chose them) and carry a `stale until=` flag instead.
@@ -270,6 +353,19 @@ export function selectContextRecords({
270
353
  return;
271
354
  }
272
355
 
356
+ if (!record.l0) {
357
+ drop(record, DROP_REASONS.empty, channel);
358
+ return;
359
+ }
360
+ if (record.archived) {
361
+ drop(record, DROP_REASONS.archived, channel);
362
+ return;
363
+ }
364
+ if (record.dirty) {
365
+ drop(record, DROP_REASONS.dirty, channel);
366
+ return;
367
+ }
368
+
273
369
  const existing = byId.get(targetId);
274
370
  if (existing) {
275
371
  if (!existing.channels.includes(record.channel)) existing.channels.push(record.channel);
@@ -280,7 +376,7 @@ export function selectContextRecords({
280
376
 
281
377
  record.channel = record.channel === "pin" ? "pin" : channel;
282
378
  record.channels = [record.channel];
283
- record.fingerprint = fingerprintOf(record.id, record.l0);
379
+ record.fingerprint = fingerprintOf(record.id, record.l0, record);
284
380
  record.line = lineFor(record);
285
381
  if (deliveredSet.has(record.fingerprint)) {
286
382
  drop(record, DROP_REASONS.duplicateTurn, record.channel);
@@ -295,7 +391,7 @@ export function selectContextRecords({
295
391
  };
296
392
 
297
393
  for (const pin of pins) accept(pin, "pin");
298
- for (const correction of corrections) accept(correction, "recall");
394
+ for (const correction of corrections) accept(correction, "correction");
299
395
  for (const hit of recall) accept(hit, "recall");
300
396
 
301
397
  // Greedy fill in priority order: a later, shorter record may still fit after
@@ -314,6 +410,20 @@ export function selectContextRecords({
314
410
  }
315
411
  }
316
412
 
413
+ /*
414
+ * Give every kept record its real cost. The marginal (prefix) cost is what the
415
+ * text grows by when this record is appended, so `sum(tokens) === total` by
416
+ * construction and the ledger never has to guess. Header and wrapper cost is
417
+ * charged to the first record of each block, which is why it is not divided
418
+ * evenly: that would make the numbers look nicer and be wrong.
419
+ */
420
+ let previous = 0;
421
+ for (let i = 0; i < kept.length; i += 1) {
422
+ const cumulative = measure(kept.slice(0, i + 1), form);
423
+ kept[i].tokens = Math.max(0, cumulative - previous);
424
+ previous = cumulative;
425
+ }
426
+
317
427
  const rendered = renderContextRecords({ records: kept }, { form });
318
428
  return {
319
429
  budget: cap,
package/lib/index.js CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  remainingOf,
47
47
  reserve,
48
48
  rollback,
49
+ setSessionWorkspace,
49
50
  } from "./turn-ledger.js";
50
51
  import { syncSidecarOnOpen } from "./sidecar.js";
51
52
 
@@ -634,11 +635,13 @@ export function apply(ctx, config = {}) {
634
635
  beginTurn(sessionId, turnKey);
635
636
  // 300 is the cumulative allowance for one user turn, not per pre-step:
636
637
  // a tool loop that re-enters must not start a fresh 300 each time.
637
- const remaining = remainingOf(sessionId, turnKey, TURN_ADDITIONAL_BUDGET);
638
- if (remaining <= 0) return decision;
638
+ if (remainingOf(sessionId, turnKey, TURN_ADDITIONAL_BUDGET) <= 0) return decision;
639
639
 
640
640
  const db = await openWorkspaceDb(cwdOf(agent));
641
641
  const cwd = cwdOf(agent);
642
+ // Node ids are per database: corrections may only reach sessions reading
643
+ // the same workspace.
644
+ setSessionWorkspace(sessionId, cwd);
642
645
  const pins = chipsOn ? pinCandidates(db, sessionId, { resolveRevision: makeRevisionResolver(db) }) : [];
643
646
  const query = mode === "off" ? "" : proposedQuery(decision, messages);
644
647
  const trace =
@@ -647,6 +650,15 @@ export function apply(ctx, config = {}) {
647
650
  : startTrace({ cwd, sessionId, query, mode, semantic: "not_run", source: "pre-step" });
648
651
  const recall = mode === "off" ? { hits: [], semantic: "not_run" } : await recallCandidates(db, mode, query, (stage, ids) => trace?.stage(stage, stageNodes(db, ids)));
649
652
  const form = mode === "anchor" ? "anchorRecall" : "autoRecall";
653
+ /*
654
+ * Re-read the ledger after the awaits above. Two pre-steps can overlap
655
+ * (the second one starts while the first is still searching), and reading
656
+ * `remaining` only before the awaits would let both spend the same
657
+ * allowance. Selection and reserve below are synchronous, so this value
658
+ * cannot change between planning and booking.
659
+ */
660
+ const remaining = remainingOf(sessionId, turnKey, TURN_ADDITIONAL_BUDGET);
661
+ if (remaining <= 0) return decision;
650
662
  const plan = selectContextRecords({
651
663
  pins,
652
664
  recall: recall.hits,
@@ -680,7 +692,7 @@ export function apply(ctx, config = {}) {
680
692
  // Reserve first, assemble second, commit last: anything that throws while
681
693
  // building the messages rolls the reservation back, so the next pre-step
682
694
  // retries the same content instead of believing it was delivered.
683
- const reservation = reserve(sessionId, turnKey, plan.records);
695
+ const reservation = reserve(sessionId, turnKey, plan.records, { totalTokens: rendered.total });
684
696
  try {
685
697
  const extra = [];
686
698
  if (rendered.pins.text) {
@@ -694,6 +706,16 @@ export function apply(ctx, config = {}) {
694
706
  }),
695
707
  );
696
708
  }
709
+ if (rendered.corrections.text) {
710
+ // Its own message: a correction is a state notice, not recall, and it
711
+ // must never carry the old or new full body.
712
+ extra.push(
713
+ createUserMessage({
714
+ content: [{ type: "text", text: rendered.corrections.text }],
715
+ source: producerSource(),
716
+ }),
717
+ );
718
+ }
697
719
  if (rendered.recall.text) {
698
720
  extra.push(
699
721
  createUserMessage({
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Read-only integrity check for decision revision relations.
3
+ *
4
+ * This module never writes: it walks the in-memory graph and reports what is
5
+ * inconsistent. It deliberately does not repair anything — a chain that was
6
+ * damaged by an old import has to be restored from a backup or an export, and
7
+ * guessing a repair would invent history.
8
+ */
9
+
10
+ import { EDGE_LABELS, REVISION_WALK_LIMIT } from "./schema.js";
11
+
12
+ const SUPERSEDES = EDGE_LABELS.supersedes;
13
+
14
+ function edgeTargetOf(edge) {
15
+ const raw = edge?.targetId ?? edge?.target_id ?? edge?.target ?? edge?.dst;
16
+ const id = Number(raw);
17
+ return Number.isFinite(id) ? id : null;
18
+ }
19
+
20
+ /**
21
+ * @returns {{ok:boolean, nodes:number, edges:number, issues:Array<object>}}
22
+ */
23
+ export function checkRevisionIntegrity(db) {
24
+ const ids = (db.allNodeIds?.() || []).map(Number).filter((id) => Number.isFinite(id));
25
+ const idSet = new Set(ids);
26
+ const payloads = new Map();
27
+ const out = new Map();
28
+ const inn = new Map();
29
+ const uris = new Map();
30
+ const pairs = new Set();
31
+ const issues = [];
32
+
33
+ for (const id of ids) {
34
+ const node = db.get(id);
35
+ const payload = node?.payload || {};
36
+ payloads.set(id, payload);
37
+ const uri = String(payload.uri || "").trim();
38
+ if (uri) {
39
+ if (!uris.has(uri)) uris.set(uri, []);
40
+ uris.get(uri).push(id);
41
+ }
42
+ for (const edge of node?.edges || []) {
43
+ if (String(edge.label) !== SUPERSEDES) continue;
44
+ const to = edgeTargetOf(edge);
45
+ if (to === null) {
46
+ issues.push({ code: "missing-endpoint", id });
47
+ continue;
48
+ }
49
+ if (to === id) issues.push({ code: "self-loop", id });
50
+ const pair = `${id}\u0000${to}`;
51
+ if (pairs.has(pair)) issues.push({ code: "duplicate-edge", id, to });
52
+ pairs.add(pair);
53
+ if (!out.has(id)) out.set(id, []);
54
+ out.get(id).push(to);
55
+ if (!inn.has(to)) inn.set(to, []);
56
+ inn.get(to).push(id);
57
+ }
58
+ }
59
+
60
+ for (const [uri, owners] of uris) {
61
+ if (owners.length > 1) issues.push({ code: "duplicate-uri", uri, ids: owners });
62
+ }
63
+
64
+ const checkEndpoint = (id) => {
65
+ if (!idSet.has(id)) {
66
+ issues.push({ code: "missing-node", id });
67
+ return;
68
+ }
69
+ const payload = payloads.get(id) || {};
70
+ if (payload.type !== "decision") issues.push({ code: "non-decision", id, type: payload.type || "" });
71
+ if (payload.status === "archived") issues.push({ code: "archived-endpoint", id });
72
+ };
73
+
74
+ for (const [id, targets] of out) {
75
+ checkEndpoint(id);
76
+ if (targets.length > 1) issues.push({ code: "fork-successor", id, to: targets });
77
+ for (const to of targets) {
78
+ checkEndpoint(to);
79
+ if (!idSet.has(to)) issues.push({ code: "dangling-edge", id, to });
80
+ }
81
+ }
82
+ for (const [id, sources] of inn) {
83
+ if (sources.length > 1) issues.push({ code: "fork-predecessor", id, from: sources });
84
+ }
85
+
86
+ // Follow each chain once from its head(s). A fork stops the walk; a repeated
87
+ // node is a cycle; more than the walk limit is reported as too-long.
88
+ for (const start of out.keys()) {
89
+ const seen = new Set();
90
+ let current = start;
91
+ let hops = 0;
92
+ while (out.has(current)) {
93
+ if (seen.has(current)) {
94
+ issues.push({ code: "cycle", id: current });
95
+ break;
96
+ }
97
+ seen.add(current);
98
+ // Follow the first edge even when the node forks: a fork is reported on
99
+ // its own, but a cycle behind the fork must still be visible here.
100
+ current = out.get(current)[0];
101
+ hops += 1;
102
+ if (hops > REVISION_WALK_LIMIT) {
103
+ issues.push({ code: "too-long", id: start, hops });
104
+ break;
105
+ }
106
+ }
107
+ }
108
+
109
+ const unique = [];
110
+ const seenIssue = new Set();
111
+ for (const issue of issues) {
112
+ const key = JSON.stringify(issue);
113
+ if (seenIssue.has(key)) continue;
114
+ seenIssue.add(key);
115
+ unique.push(issue);
116
+ }
117
+
118
+ return {
119
+ ok: unique.length === 0,
120
+ nodes: ids.length,
121
+ edges: pairs.size,
122
+ issues: unique,
123
+ };
124
+ }
@@ -84,7 +84,8 @@ export function recallPreview(
84
84
  const plan = selectContextRecords({
85
85
  pins,
86
86
  recall: hits.map((hit) => candidateOf(db, hit)),
87
- budget: Number(budget) || TURN_ADDITIONAL_BUDGET,
87
+ // Passed through untouched: an explicit 0 is a real budget, not "use 300".
88
+ budget,
88
89
  form,
89
90
  resolveCurrent,
90
91
  });
@@ -115,7 +116,7 @@ export function recallPreview(
115
116
  l0: r.l0,
116
117
  historical: r.historical === true,
117
118
  matchedHistory: r.matchedHistory || null,
118
- tokens: 0,
119
+ tokens: Number(r.tokens) || 0,
119
120
  })),
120
121
  dropped: plan.dropped,
121
122
  deferred: plan.deferred,
package/lib/revisions.js CHANGED
@@ -13,7 +13,9 @@
13
13
 
14
14
  import { createHash, randomUUID } from "node:crypto";
15
15
  import { EDGE_LABELS, REVISION_EDGES, nowIso, weightOf } from "./schema.js";
16
+ import { noteFactChanged } from "./turn-ledger.js";
16
17
  import {
18
+ cwdOfDb,
17
19
  currentHeadOf,
18
20
  isRevisionLinked,
19
21
  persistAfterRevision,
@@ -26,6 +28,7 @@ import {
26
28
  } from "./store.js";
27
29
  import { isStalePayload } from "./until.js";
28
30
  import { provenanceOf } from "./provenance.js";
31
+ import { payloadLooksDirty } from "./hygiene.js";
29
32
 
30
33
  /*
31
34
  * The graph predicates live in store.js: the write guards there need them, and
@@ -71,21 +74,33 @@ export function revisionStateOf(db, id) {
71
74
  }
72
75
 
73
76
  /**
74
- * Resolver for context selection.
77
+ * Resolver for context selection and tool output.
75
78
  *
76
- * Returns `{currentId, archived, stale}` for the head of this node's chain, or
77
- * null when the chain cannot be resolved — selection then drops the candidate
78
- * rather than injecting a guess.
79
+ * Returns the full projection of the head node — id, type, body, deadline and
80
+ * state — or null when the chain cannot be resolved. Returning only
81
+ * `currentId` was the REVIEW P1-2 bug: callers swapped the id but kept the
82
+ * superseded candidate's body, so the model read an old instruction under a new
83
+ * id. `path` is null on purpose: a search path belongs to the matched
84
+ * candidate, and reusing it for a different node would be a fabricated fact.
79
85
  */
80
- export function makeRevisionResolver(db) {
86
+ export function makeRevisionResolver(db, { now } = {}) {
87
+ const at = now instanceof Date ? now : undefined;
81
88
  return (id) => {
82
89
  const state = revisionStateOf(db, id);
83
90
  if (!state.ok) return null;
84
- const payload = db.get(state.currentId)?.payload || {};
91
+ const node = db.get(state.currentId);
92
+ if (!node) return null;
93
+ const payload = node.payload || {};
85
94
  return {
86
95
  currentId: state.currentId,
96
+ superseded: state.superseded,
97
+ type: payload.type || "unknown",
98
+ l0: String(payload.text || payload.name || "").slice(0, 200),
99
+ until: String(payload.until || payload.untilAt || ""),
100
+ stale: isStalePayload(payload, at),
87
101
  archived: payload.status === "archived",
88
- stale: isStalePayload(payload),
102
+ dirty: payloadLooksDirty(payload),
103
+ path: null,
89
104
  };
90
105
  };
91
106
  }
@@ -376,6 +391,17 @@ export function supersede(db, input = {}) {
376
391
  return refuse(409, REVISION_CODES.transactionFailed, `关系写入失败,两端保持原状:${err.message}`);
377
392
  }
378
393
  persistAfterRevision(db);
394
+ noteFactChanged(oldId, {
395
+ cwd: cwdOfDb(db),
396
+ type: oldPayload.type || "decision",
397
+ l0: String(oldPayload.text || oldPayload.name || "").slice(0, 200),
398
+ until: String(oldPayload.until || oldPayload.untilAt || ""),
399
+ stale: isStalePayload(oldPayload),
400
+ archived: oldPayload.status === "archived",
401
+ note: `已被 #${successorId} 替代;后续请以 #${successorId} 为准。`,
402
+ currentId: successorId,
403
+ force: true,
404
+ });
379
405
  return {
380
406
  ok: true,
381
407
  oldId,
@@ -426,6 +452,18 @@ export function revokeSupersession(db, input = {}) {
426
452
  return refuse(409, REVISION_CODES.transactionFailed, `撤销失败,关系仍在:${err.message}`);
427
453
  }
428
454
  persistAfterRevision(db);
455
+ const oldPayload = db.get(oldId)?.payload || {};
456
+ noteFactChanged(oldId, {
457
+ cwd: cwdOfDb(db),
458
+ type: oldPayload.type || "decision",
459
+ l0: String(oldPayload.text || oldPayload.name || "").slice(0, 200),
460
+ until: String(oldPayload.until || oldPayload.untilAt || ""),
461
+ stale: isStalePayload(oldPayload),
462
+ archived: oldPayload.status === "archived",
463
+ note: `与 #${successorId} 的替代关系已撤销;#${oldId} 重新成为当前事实。`,
464
+ currentId: oldId,
465
+ force: true,
466
+ });
429
467
  return { ok: true, oldId, successorId, etag: { old: etagOf(db, oldId), new: etagOf(db, successorId) } };
430
468
  }
431
469