dsh-trivium 0.5.0 → 0.5.1
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 +4 -1
- package/README.zh-CN.md +4 -1
- package/lib/context-budget.js +134 -24
- package/lib/index.js +25 -3
- package/lib/integrity.js +124 -0
- package/lib/recall-preview.js +3 -2
- package/lib/revisions.js +45 -7
- package/lib/server.js +11 -0
- package/lib/sidecar.js +8 -2
- package/lib/store.js +344 -117
- package/lib/tools.js +17 -23
- package/lib/turn-ledger.js +118 -12
- package/package.json +2 -2
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,8 @@ Off by default. After you turn **Chips (memory whitelist)** on in Settings, the
|
|
|
278
279
|
|
|
279
280
|
## Changelog
|
|
280
281
|
|
|
282
|
+
**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: ""`.
|
|
283
|
+
|
|
281
284
|
**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
285
|
|
|
283
286
|
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.
|
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,8 @@ Trivium 是记忆内核,不是日记、日历或聊天伴侣。它按**节点
|
|
|
274
275
|
|
|
275
276
|
## 更新说明
|
|
276
277
|
|
|
278
|
+
**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: ""`。
|
|
279
|
+
|
|
277
280
|
**0.5.0** — 记忆可审计。芯片与召回改为**共用**每轮 300 估算 token(原先各 300),同一节点不会出现两次,一轮内的工具循环也不再重复注入。投递账本按宿主消息 id 记账:失败可重试、压缩后可重建,fork 既不继承账本也不继承 pins。`ctx_find` 默认上限约 1200 估算 token,超预算丢弃整条记录;`ctx_read` 改为字段白名单,新元数据不会撑大默认输出。
|
|
278
281
|
|
|
279
282
|
设置页新增**召回检查**页签(只读:不写库、不跑工具、不请求 embedding),条目详情新增**来源**与**决策历史**页签。只有写入时记录过来源才显示来源,不编造会话、引文或跳转。召回诊断只记录实际跑过的管线分支,每工作区+会话最多 20 条 / 10 分钟 / 每条 64KiB。
|
package/lib/context-budget.js
CHANGED
|
@@ -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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 =
|
|
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
|
|
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
|
|
173
|
-
estimatedTokens:
|
|
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 =
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if (record.
|
|
234
|
-
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
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, "
|
|
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
|
-
|
|
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({
|
package/lib/integrity.js
ADDED
|
@@ -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
|
+
}
|
package/lib/recall-preview.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
77
|
-
* null when the chain cannot be resolved
|
|
78
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
package/lib/server.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
dbPathFor,
|
|
8
8
|
deleteGitSidecars,
|
|
9
9
|
deleteNode,
|
|
10
|
+
describeDbFailure,
|
|
10
11
|
exportGraph,
|
|
11
12
|
getActiveCwd,
|
|
12
13
|
importGraph,
|
|
@@ -122,6 +123,16 @@ export function registerMemoryApi(ctx, { liveOptions, status }) {
|
|
|
122
123
|
try {
|
|
123
124
|
await handle(req, res, { liveOptions, status });
|
|
124
125
|
} catch (err) {
|
|
126
|
+
const lock = describeDbFailure(err);
|
|
127
|
+
if (lock) {
|
|
128
|
+
sendJson(res, lock.status, {
|
|
129
|
+
ok: false,
|
|
130
|
+
code: lock.code,
|
|
131
|
+
message: lock.message,
|
|
132
|
+
detail: lock.detail,
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
125
136
|
sendJson(res, 500, { ok: false, message: String(err.message || err) });
|
|
126
137
|
}
|
|
127
138
|
},
|
package/lib/sidecar.js
CHANGED
|
@@ -383,8 +383,14 @@ async function applySidecar(db, parsed) {
|
|
|
383
383
|
if (!payload.untilAt) delete payload.untilAt;
|
|
384
384
|
if (!payload.fail) delete payload.fail;
|
|
385
385
|
if (!payload.fix) delete payload.fix;
|
|
386
|
-
|
|
387
|
-
|
|
386
|
+
/*
|
|
387
|
+
* URI identity only. A JSONL row always carries a URI (parseSidecar
|
|
388
|
+
* rejects rows without one), so a missing local URI means a new node.
|
|
389
|
+
* Falling back to text similarity here merged two versions of the same
|
|
390
|
+
* decision into one identity and lost the older body — the same P1-3
|
|
391
|
+
* defect the JSON import path had.
|
|
392
|
+
*/
|
|
393
|
+
const dest = store.findByUri(db, uri);
|
|
388
394
|
if (dest != null) {
|
|
389
395
|
try {
|
|
390
396
|
store.mergeInto(db, dest, payload);
|