dsh-retrace 0.4.27 → 0.4.29
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 +64 -24
- package/README.zh.md +29 -11
- package/bin/retrace.mjs +1 -1
- package/lib/adapter/contract.js +1 -1
- package/lib/adapter/dsh.js +1 -1
- package/lib/archaeology-cli.js +1 -1
- package/lib/badge.js +1 -1
- package/lib/boot-pin.js +94 -0
- package/lib/boundary-derive.js +107 -0
- package/lib/boundary-now.js +139 -0
- package/lib/boundary-tree.js +126 -0
- package/lib/boundary-what.js +140 -0
- package/lib/client.bundle.js +1245 -419
- package/lib/client.js +1763 -498
- package/lib/close-guard-client.js +39 -12
- package/lib/close-guard.js +130 -0
- package/lib/dynamic-client.js +1245 -419
- package/lib/dynamic-host.js +24 -4
- package/lib/forkmap.js +15 -9
- package/lib/host-core.js +21 -1
- package/lib/http.js +104 -4
- package/lib/index.js +35 -31
- package/lib/llm-summary.js +289 -0
- package/lib/marker-carrier.js +2 -2
- package/lib/migration-traces.js +1 -1
- package/lib/platform/session-paths.js +1 -1
- package/lib/projection/forkmap.js +9 -2
- package/lib/projection/versions.js +4 -0
- package/lib/summary-gate.js +160 -0
- package/lib/summary-store.js +152 -0
- package/lib/version-index.js +58 -8
- package/lib/versioning.js +196 -2
- package/lib/watchdog.js +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,8 +20,15 @@ DeepSeek Harness.
|
|
|
20
20
|
deserves. But rewinding is not just "delete a message": DeepSeek Harness stores
|
|
21
21
|
conversations in an append-only event log, so a recall only rewinds the context
|
|
22
22
|
while changed **artifact files stay changed**. dsh-retrace versions the
|
|
23
|
-
conversation **and its artifacts** together, and
|
|
24
|
-
|
|
23
|
+
conversation **and its artifacts** together, and keeps **every new rewind legal** —
|
|
24
|
+
it cannot dirty the log, and new markers create **no token-meter pairing debt**
|
|
25
|
+
(two-segment atomic pairs land by construction).
|
|
26
|
+
|
|
27
|
+
> ⚠️ **Honest scope (matches the companion contract's own note)**: sessions that
|
|
28
|
+
> already contain **legacy single-segment markers** — written by older versions —
|
|
29
|
+
> are **known design debt**. Before `/compact`, run the companion `check` and
|
|
30
|
+
> clean them up (`fix --remove-markers`); otherwise the host's own T1 self-check
|
|
31
|
+
> blocks compaction. New rewinds do not add to that debt.
|
|
25
32
|
|
|
26
33
|
> 🛡️ **Write safety** · 🔍 **Deep offline checks** · 🔄 **Detect → repair → guard** — see below.
|
|
27
34
|
|
|
@@ -54,7 +61,7 @@ Full steps in [📦 Installation](#-installation).
|
|
|
54
61
|
|
|
55
62
|
| | Capability | What it means |
|
|
56
63
|
|---|---|---|
|
|
57
|
-
| 🛡️ | **Write safety** | Every rewind passes a three-layer pre-write contract guard; running agents are auto-stopped (official `cancel`/`whenIdle`); turn-interval markers are wrapped in a temporary step — **rewinds
|
|
64
|
+
| 🛡️ | **Write safety** | Every rewind passes a three-layer pre-write contract guard; running agents are auto-stopped (official `cancel`/`whenIdle`); turn-interval markers are wrapped in a temporary step — **new rewinds cannot dirty the log and add no token-meter pairing debt**; **legacy single-segment markers are known debt** (run the companion `check` + `fix --remove-markers` before `/compact`) |
|
|
58
65
|
| 🔍 | **Deep offline checks** | Companion `dsh-log-contract` ships 30+ contract rules (token-meter pairing / cross-step references / physical order / inbox replay), validated against real corrupted-session fixtures — it finds the class of problem that makes /compact permanently fail |
|
|
59
66
|
| 🔄 | **Detect → repair → guard** | A watchdog snapshots the log at the first sign of concurrent writes; offline `fix` neutralizes problem markers and clips cross-step references in place; pre-write validation stops bad events before they land |
|
|
60
67
|
|
|
@@ -298,12 +305,12 @@ Read it before filing an issue.
|
|
|
298
305
|
- Stale peer declarations with no remaining import site removed
|
|
299
306
|
(`@deepseek-ai/dsh-home-paths`, `@deepseek-ai/dsh-client-runtime`).
|
|
300
307
|
|
|
301
|
-
### Host-side breaking changes that `0.4.
|
|
308
|
+
### Host-side breaking changes that `0.4.28` adapts to — *not caused by this plugin*
|
|
302
309
|
|
|
303
310
|
1. **`@deepseek-ai/dsh-session` removed the `Session.events` member** (in `0.1.5-rc.1`).
|
|
304
311
|
The class has no `events` field and no `events` getter at all any more; the supported
|
|
305
312
|
readers are `snapshotEvents(fromSeq, toSeqExclusive)` (frozen, sequence-indexed),
|
|
306
|
-
`eventAt(seq)`, `ownEvents()` and `isOwnSeq(seq)`. `0.4.
|
|
313
|
+
`eventAt(seq)`, `ownEvents()` and `isOwnSeq(seq)`. `0.4.28` reaches the log through a
|
|
307
314
|
compatibility accessor that prefers the new API and falls back to the old array, so it
|
|
308
315
|
runs on both host generations.
|
|
309
316
|
**Symptom before the fix:** recall and edit did nothing and surfaced the raw error
|
|
@@ -311,14 +318,14 @@ Read it before filing an issue.
|
|
|
311
318
|
start by locating the target message id, and that lookup read the removed member.
|
|
312
319
|
2. **The client-side session store has no `keys()`** (`ctx.sessions`). A plugin that
|
|
313
320
|
enumerates sessions with `keys()` silently sees **zero** of them: no crash, no error,
|
|
314
|
-
just safety warnings that never fire. `0.4.
|
|
321
|
+
just safety warnings that never fire. `0.4.28` prefers the official `list()` and falls
|
|
315
322
|
back to `keys()`; it deliberately does **not** fall back to enumerating service fields,
|
|
316
323
|
because guessing produces a silent empty result as well.
|
|
317
324
|
3. **The client session controller has no title accessor** — `getTitle` does not exist
|
|
318
325
|
anywhere in `@deepseek-ai/dsh-api-session-controller`, and its `getSnapshot()` carries
|
|
319
326
|
no `title`. See the plugin-side item below: this one used to *overwrite your titles*.
|
|
320
327
|
|
|
321
|
-
### Plugin-side fixes in `0.4.
|
|
328
|
+
### Plugin-side fixes in `0.4.28` (these are ours)
|
|
322
329
|
|
|
323
330
|
- **The edit / recall affordances never appeared at all.** The client half read chat
|
|
324
331
|
nodes from `snapshot.chat.nodes`, a path this host build does not have — nodes live in
|
|
@@ -333,21 +340,35 @@ Read it before filing an issue.
|
|
|
333
340
|
**diagnosable reason** (renderer warning + host-log line) instead of failing silently.
|
|
334
341
|
- **Assigning a short code could overwrite your session title.** The client composed
|
|
335
342
|
`[CODE] <current title>` locally but had no way to read the current title, so the base
|
|
336
|
-
degraded to the session-id prefix (`[XXXXXX]
|
|
343
|
+
degraded to the session-id prefix (`[XXXXXX] <session-id-prefix>`). Title tagging now goes
|
|
337
344
|
through the host route only (`setBadgeTitle`), which reads the current title from the
|
|
338
345
|
session log. Manual renames are unaffected.
|
|
339
346
|
- **Host-side operation failures are logged again** (code + message + stack). They used to
|
|
340
347
|
return the message to the UI without a log line, which is why this whole class of bug
|
|
341
348
|
was hard to diagnose from outside.
|
|
342
349
|
|
|
350
|
+
- **The version and fork views now explain themselves.** They used to show a title plus a row of
|
|
351
|
+
actions with **no sentence anywhere saying what a "version" or a "fork" is** (the only near-miss
|
|
352
|
+
was an empty-state line that disappears as soon as data arrives), and fork rows printed raw node
|
|
353
|
+
types. They now carry an always-visible concept sentence, a type legend, and a plain-language
|
|
354
|
+
"why" line on every row; impact text reads `旧路径的 N 条消息被替换`, not `被遮蔽 N 个节点`.
|
|
355
|
+
- **Client hide-lookup no longer rescans per row.** `useSeqHidden` re-scanned the node map for every
|
|
356
|
+
row (measured **346 ms** at 2000 rows / 20 markers, **1568 ms** at 3000/30 — and that path was
|
|
357
|
+
*dead* on this host until this release made the rows render at all, so it is this fix own cost).
|
|
358
|
+
It now reuses one per-snapshot hide plan: **8.3 ms** and **18.3 ms** respectively, with the
|
|
359
|
+
predicate verified equivalent against the old one.
|
|
343
360
|
### Upgrading
|
|
344
361
|
|
|
345
362
|
```bash
|
|
346
|
-
dsh plugin --profile desktop add dsh-retrace@0.4.
|
|
363
|
+
dsh plugin --profile desktop add dsh-retrace@0.4.29
|
|
347
364
|
# then restart DSH — plugins are not hot-reloaded
|
|
348
365
|
```
|
|
349
366
|
|
|
350
|
-
**`0.4.27`
|
|
367
|
+
**`0.4.27` was withdrawn.** It was briefly published and then recalled — `latest` points at `0.4.26`
|
|
368
|
+
again and `0.4.27` is marked deprecated. **`0.4.28` is its replacement**: it carries every fix
|
|
369
|
+
`0.4.27` had, plus the two items below.
|
|
370
|
+
|
|
371
|
+
**`0.4.28` needs no data migration.** The session format is unchanged (v3), no session is
|
|
351
372
|
re-written, and nothing has to be re-indexed: upgrade, restart, and the two symptoms above
|
|
352
373
|
are gone. If you are on a host that still provides the old members, the compatibility
|
|
353
374
|
accessors keep those paths working — this build does not drop older hosts.
|
|
@@ -372,6 +393,17 @@ the **host surface**, not by semver alone.
|
|
|
372
393
|
|
|
373
394
|
## ⚠️ Requirements & limitations
|
|
374
395
|
|
|
396
|
+
- **Optional dependency (deliberately NOT in `package.json`)**: AI summaries need
|
|
397
|
+
an `llm` service from the host (the official `@deepseek-ai/dsh-llm`, bundled
|
|
398
|
+
with DSH Desktop). The plugin takes it **dynamically** via `ctx.get('llm')`:
|
|
399
|
+
present ⇒ summaries available, absent ⇒ it degrades to **verbatim excerpts
|
|
400
|
+
only**. Install and startup are unaffected either way. Model and credentials
|
|
401
|
+
follow the session's own default selection
|
|
402
|
+
(`agentDefaultModel.currentSelection()`); the plugin adds **no configuration
|
|
403
|
+
surface of its own**. Summaries sit behind a **default-off** switch (at most one
|
|
404
|
+
small call per operation: ≤6×400 chars in, ≤200 tokens out, 5 s timeout); when
|
|
405
|
+
it is off there are **zero LLM calls**, while the verbatim excerpt (zero token
|
|
406
|
+
cost) is **always recorded**.
|
|
375
407
|
- Only **user messages** can be edited; recall works on user and assistant
|
|
376
408
|
messages. Tool results are shadowed along with the recalled range but are not
|
|
377
409
|
themselves recall targets.
|
|
@@ -392,7 +424,8 @@ the **host surface**, not by semver alone.
|
|
|
392
424
|
|
|
393
425
|
- Recall / edit-and-resend / regenerate, each written through a three-layer
|
|
394
426
|
**pre-write contract guard** and a safe-edit path (auto-stop the agent, temp-step
|
|
395
|
-
markers) — rewinds
|
|
427
|
+
markers) — new rewinds do not corrupt the log and add no `/compact` debt;
|
|
428
|
+
**legacy single-segment markers** remain known debt (see the honest note above).
|
|
396
429
|
- In-session **version timeline** + **artifact rollback** (git-first, snapshot
|
|
397
430
|
fallback, dry-run preview, jump-to-conversation).
|
|
398
431
|
- **Fork map + session lineage** in the conversation view.
|
|
@@ -406,19 +439,26 @@ the **host surface**, not by semver alone.
|
|
|
406
439
|
| 🛡️ | **Running-work detection** | every session is scanned for live work: agent running, queued inbox items, background jobs, unclosed turns |
|
|
407
440
|
| 📋 | **Running banner** | sessions with live work show a persistent in-page banner (short session code + reasons), so you can see it before quitting |
|
|
408
441
|
| ⚠️ | **Exit prompt** | on plugin dispose (app exit / reload) a Chinese notice lists each running session and why it is considered busy — it only warns, it never cancels your running agent |
|
|
409
|
-
| 🔒 | **Page-close interception (
|
|
410
|
-
| 🔎 | **Query surface** | `retrace.runningState` (host RPC) + `GET|POST /api/plugins/retrace/runningState` (HTTP) — same shape on both transports |
|
|
411
|
-
|
|
412
|
-
> Desktop note:
|
|
413
|
-
>
|
|
414
|
-
>
|
|
415
|
-
>
|
|
442
|
+
| 🔒 | **Page-close interception (web browsers)** | `beforeunload` interception, armed only where the host reports a native confirm dialog: a strong confirm when work is running (details modal, `[仍关闭]` = confirm-and-go), a light confirm otherwise |
|
|
443
|
+
| 🔎 | **Query surface** | `retrace.runningState` (host RPC) + `GET|POST /api/plugins/retrace/runningState` (HTTP) — same shape on both transports; the all-sessions shape also carries the host-reported page surface (`surface` / `quitVeto`) |
|
|
444
|
+
|
|
445
|
+
> Desktop note: quit entry points differ by version/platform, and the DSH Desktop Electron
|
|
446
|
+
> shell we inspected has no `will-prevent-unload` handler (0 hits across the packaged 2.0.9
|
|
447
|
+
> `app.asar`). Where the entry does reach the page — the external report's DSH Desktop 0.9.0 /
|
|
448
|
+
> Windows — the page `beforeunload` veto is **swallowed silently**: no dialog, no feedback, and
|
|
449
|
+
> the exit looks stuck (only a force-quit works). Where it does not — the 2.0.9 shell we
|
|
450
|
+
> inspected routes the tray item through `requestQuit(0) → window.destroy() → app.exit(0)` — the
|
|
451
|
+
> quit is unaffected either way. The page cannot tell which case it is in, so desktop **never**
|
|
452
|
+
> arms the native gate; it relies on the running banner plus the dispose notice. The gate is
|
|
453
|
+
> armed only where the host reports that the page really surfaces a native dialog
|
|
454
|
+
> (`quitVeto: true`, i.e. browser pages).
|
|
416
455
|
|
|
417
456
|
> Command surface: `retrace.runningState` (host RPC) + `GET|POST /api/plugins/retrace/runningState` (HTTP).
|
|
418
457
|
|
|
419
|
-
**What's next** —
|
|
420
|
-
|
|
421
|
-
|
|
458
|
+
**What's next** — the agent business-layer plan (runtime guard, interruption
|
|
459
|
+
governance, ecosystem-facing interfaces) is **not published yet**: it is a plan,
|
|
460
|
+
not a shipped capability. This README describes the **development line (main)**,
|
|
461
|
+
which may run ahead of the latest npm release.
|
|
422
462
|
|
|
423
463
|
---
|
|
424
464
|
|
|
@@ -466,9 +506,9 @@ and the [issue tracker](https://github.com/yamingmou/dsh-retrace/issues).
|
|
|
466
506
|
|
|
467
507
|
Listed on the [dsh-plugin topic](https://github.com/topics/dsh-plugin).
|
|
468
508
|
|
|
469
|
-
Part of the **Agent business layer (production-grade guarantees)** —
|
|
470
|
-
|
|
471
|
-
|
|
509
|
+
Part of the **Agent business layer (production-grade guarantees)** — the
|
|
510
|
+
framework-agnostic layer that dsh-retrace implements on DeepSeek Harness.
|
|
511
|
+
Companion components:
|
|
472
512
|
|
|
473
513
|
- [**dsh-log-contract**](https://github.com/yamingmou/dsh-log-contract) — the
|
|
474
514
|
business layer's "doctor": 30+ offline contract rules + in-place repair
|
package/README.zh.md
CHANGED
|
@@ -17,8 +17,13 @@
|
|
|
17
17
|
|
|
18
18
|
**撤回 / 编辑重发 / 重新生成** —— 每个会话都该有的三个操作。但回退不只是「撤掉一条
|
|
19
19
|
消息」:DeepSeek Harness 把对话存在 append-only 事件日志里,撤回只回退上下文,改过的
|
|
20
|
-
**产物文件不会自动还原**。dsh-retrace
|
|
21
|
-
|
|
20
|
+
**产物文件不会自动还原**。dsh-retrace 把对话**和它的产物**一起版本化,并保证
|
|
21
|
+
**每一次新的回退都合法**——不会弄脏日志,新写入的 marker **不产生 token-meter 配对债**
|
|
22
|
+
(两段原子对按构造即通过)。
|
|
23
|
+
|
|
24
|
+
> ⚠️ **诚实的边界(与配套契约自己的说明一致)**:**旧版本留下的单段 marker** 是
|
|
25
|
+
> **已知设计债**。压缩前请用配套 `check` 体检并 `fix --remove-markers` 清理,否则宿主的
|
|
26
|
+
> T1 自检会挡住 `/compact`。新的回退不会增加这笔债。
|
|
22
27
|
|
|
23
28
|
> 🛡️ **写安全** · 🔍 **深层体检** · 🔄 **检测→修复→守护** —— 详见下方「生产级保证」。
|
|
24
29
|
|
|
@@ -51,7 +56,7 @@ dsh plugin --profile desktop add dshmarket # 只需一次
|
|
|
51
56
|
|
|
52
57
|
| | 能力 | 说明 |
|
|
53
58
|
|---|---|---|
|
|
54
|
-
| 🛡️ | **写安全** | 每次回退过三层写前契约校验;运行中的 agent 自动停止(官方 `cancel`/`whenIdle`);轮次间 marker 用临时 step 包裹 ——
|
|
59
|
+
| 🛡️ | **写安全** | 每次回退过三层写前契约校验;运行中的 agent 自动停止(官方 `cancel`/`whenIdle`);轮次间 marker 用临时 step 包裹 —— **新的回退不会弄脏日志**,新 marker **不产生 token-meter 配对债**;**旧单段 marker 是已知设计债**(压缩前先 `check` + `fix --remove-markers`) |
|
|
55
60
|
| 🔍 | **深层体检** | 配套 `dsh-log-contract` 30+ 条契约规则(token-meter 配对 / 跨 step 引用 / 物理序 / inbox 重放),用真实损坏会话当测试集 —— 能找出让 /compact 永久失效的那类问题 |
|
|
56
61
|
| 🔄 | **检测→修复→守护** | 看门狗在并发写入第一时间快照日志;离线 `fix` 原地中和问题 marker、裁剪跨 step 引用;写前校验在坏事件落盘前拦住 |
|
|
57
62
|
|
|
@@ -287,6 +292,14 @@ dsh plugin --profile desktop add dsh-retrace@0.4.26
|
|
|
287
292
|
|
|
288
293
|
## ⚠️ 要求与限制
|
|
289
294
|
|
|
295
|
+
- **可选依赖(不写进 `package.json`)**:AI 摘要需要宿主提供 `llm` 服务
|
|
296
|
+
(官方 `@deepseek-ai/dsh-llm`,随 DSH Desktop 内置)。插件用
|
|
297
|
+
`ctx.get('llm')` **动态取用**:有就用,缺失即降级为**只给逐字原文**,
|
|
298
|
+
安装/启动不受影响。模型与凭据沿用会话自身的默认模型选择
|
|
299
|
+
(`agentDefaultModel.currentSelection()`),插件**不新增任何配置面**。
|
|
300
|
+
摘要是**默认关闭**的开关(每次操作至多 1 次小调用,输入 ≤6×400 字、
|
|
301
|
+
输出 ≤200 token、5 秒超时),关闭时**零 LLM 调用**;而逐字原文
|
|
302
|
+
(`excerpt`)零 token 成本,**始终产出**。
|
|
290
303
|
- 只有**用户消息**可以编辑;撤回同时适用于用户与助手消息。工具结果会随区间一并
|
|
291
304
|
被阴影化,但不能单独作为撤回目标。
|
|
292
305
|
- 智能体必须**空闲**:回复流式输出时需先点击 ⏹ 停止,再撤回或编辑;否则 Host
|
|
@@ -301,7 +314,7 @@ dsh plugin --profile desktop add dsh-retrace@0.4.26
|
|
|
301
314
|
|
|
302
315
|
**当前已具备(0.4.x):**
|
|
303
316
|
|
|
304
|
-
- 撤回 / 编辑重发 / 重新生成——每次回退都过**三层写前校验**与安全编辑路径(自动停 agent、临时 step 包裹 marker
|
|
317
|
+
- 撤回 / 编辑重发 / 重新生成——每次回退都过**三层写前校验**与安全编辑路径(自动停 agent、临时 step 包裹 marker),**新的回退不会损坏日志、不新增 `/compact` 债**;**旧单段 marker 需要压缩前清理**(配套 `check` + `fix --remove-markers`)。
|
|
305
318
|
- 单会话**版本时间线** + **产物回退**(git 优先 + 快照兜底、干跑预览、跳转对话)。
|
|
306
319
|
- 对话视图内的**分叉图** + **会话谱系**。
|
|
307
320
|
- **实时看门狗**——并发写入第一时间快照日志。
|
|
@@ -313,13 +326,18 @@ dsh plugin --profile desktop add dsh-retrace@0.4.26
|
|
|
313
326
|
| 🛡️ | **运行中检测** | 逐会话扫描运行中工作:agent 正在跑 / inbox 排队 / 后台 jobs / 未闭合轮 |
|
|
314
327
|
| 📋 | **运行中横幅** | 有运行中工作的会话显示页面常驻横幅(会话短码 + 原因),退出前可见 |
|
|
315
328
|
| ⚠️ | **退出提示** | 插件 dispose(应用退出/重载)时中文提示列出每个运行中会话与原因——只提示,绝不代你取消 agent |
|
|
316
|
-
| 🔒 |
|
|
317
|
-
| 🔎 | **查询面** | `retrace.runningState`(host RPC)+ `GET|POST /api/plugins/retrace/runningState`(HTTP
|
|
329
|
+
| 🔒 | **页面关闭拦截(普通浏览器)** | `beforeunload` 拦截,只在宿主回报会弹原生确认框的页面上武装:有运行中任务强确认(明细模态,`[仍关闭]` 即确认离开),无任务轻确认 |
|
|
330
|
+
| 🔎 | **查询面** | `retrace.runningState`(host RPC)+ `GET|POST /api/plugins/retrace/runningState`(HTTP),两入口同形状;全会话形状另带宿主判定的承载面(`surface` / `quitVeto`) |
|
|
318
331
|
|
|
319
|
-
>
|
|
320
|
-
>
|
|
332
|
+
> 桌面说明:**退出入口随版本/平台而变**,而我们检查的 Electron 壳没有处理 `will-prevent-unload`
|
|
333
|
+
> (装好的 2.0.9 `app.asar` 全文检索 0 命中)。**会走到该入口的那类版本**(外部报告所在的
|
|
334
|
+
> DSH Desktop 0.9.0 / Windows)上,页面 `beforeunload` 否决被**静默吞掉**:不弹界面、不给
|
|
335
|
+
> 反馈,表现为退出卡住(只能强退);**不走该入口的版本**(我们检查的 2.0.9:托盘项走
|
|
336
|
+
> `requestQuit(0) → window.destroy() → app.exit(0)`)上,退出本来就不受影响。页面里分不出
|
|
337
|
+
> 自己属于哪一类,因此**桌面端一律不武装**原生门,保护由运行中横幅 + dispose 提示承担;
|
|
338
|
+
> 只有宿主回报"这个页面会弹原生确认框"(`quitVeto: true`,即普通浏览器页)时才武装。
|
|
321
339
|
|
|
322
|
-
|
|
340
|
+
**未来计划**——agent 业务层规划(运行时守护、中断治理、生态开放接口)**尚未发布**,此节是**计划**而非已上线能力。本 README 描述的是**开发线(main)**,可能领先于 npm 上最新发布版。
|
|
323
341
|
|
|
324
342
|
---
|
|
325
343
|
|
|
@@ -366,8 +384,8 @@ npm pack --dry-run # 校验发布文件清单
|
|
|
366
384
|
|
|
367
385
|
收录于 [dsh-plugin topic](https://github.com/topics/dsh-plugin)。
|
|
368
386
|
|
|
369
|
-
**Agent 业务层(生产级保证)**
|
|
370
|
-
|
|
387
|
+
**Agent 业务层(生产级保证)** 的一部分——即 dsh-retrace 在 DeepSeek Harness 上实现的
|
|
388
|
+
那层框架无关的业务层定义。配套组件:
|
|
371
389
|
|
|
372
390
|
- [**dsh-log-contract**](https://github.com/yamingmou/dsh-log-contract) —— 业务层的
|
|
373
391
|
「医生」:30+ 条离线契约规则 + 原地修复(`fix --neutralize` / `--clip-crossstep`)。
|
package/bin/retrace.mjs
CHANGED
package/lib/adapter/contract.js
CHANGED
|
@@ -80,7 +80,7 @@ const isPlainObject = (value) => typeof value === 'object' && value !== null &&
|
|
|
80
80
|
* 带着更大的 seq 插进被遮蔽区间的位置,于是「位置在前、seq 更大」是合法形态。
|
|
81
81
|
* 官方 `replacementRange`(dsh-session)只按 `indexOf(start) <= indexOf(end)` 的
|
|
82
82
|
* **位置**判定,与 seq 数值大小无关;要求 start <= end 会把真实会话上的合法 span
|
|
83
|
-
* 误判为契约违规(
|
|
83
|
+
* 误判为契约违规(现场实测:位置序 span 的 seq 数值非单调属正常写入)。
|
|
84
84
|
* 真正可判定的是:两端都是当前面上的节点 → 位置连续段 → 首尾一致(见下)。
|
|
85
85
|
*/
|
|
86
86
|
export function assertSpanShape(span, contract = 'Span.shape') {
|
package/lib/adapter/dsh.js
CHANGED
|
@@ -293,7 +293,7 @@ export const dshAdapter = {
|
|
|
293
293
|
}
|
|
294
294
|
|
|
295
295
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
296
|
-
// 语义短码推导(2026-09-02)——工作区 createdAt 序号 +
|
|
296
|
+
// 语义短码推导(2026-09-02)——工作区 createdAt 序号 + 父链,与外部工具
|
|
297
297
|
// 短码表生成器同规则(表的新鲜版,不冲突)。
|
|
298
298
|
// 短码 = 工作区2 + 序号3 + 父工作区2 + 父序号3;根父 = FF000。
|
|
299
299
|
// 只读会话文件帧1(header),全量 ~110 会话 ≈ 30ms,懒加载缓存。
|
package/lib/archaeology-cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-retrace · lib/archaeology-cli.js
|
|
3
3
|
*
|
|
4
|
-
* 会话日志考古 CLI
|
|
4
|
+
* 会话日志考古 CLI 的核心逻辑(只读索引 / 查询 / 抽取 / 文件历史 / 谱系)。
|
|
5
5
|
* 与 dsh-log-contract 的 archaeology.js 分工:B 侧提供 extract/audit 纯函数,
|
|
6
6
|
* 本模块提供 A 侧的文件版本考古(write/edit 重放)与谱系(parent 链)。
|
|
7
7
|
* **只读不写**(纪律 §8.1)。
|
package/lib/badge.js
CHANGED
package/lib/boot-pin.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-retrace — lib/boot-pin.js
|
|
3
|
+
*
|
|
4
|
+
* 启动批量 pin 的**重试调度**(唯一职责:什么时候再跑一次)。
|
|
5
|
+
*
|
|
6
|
+
* 从 lib/index.js 迁出的理由(2026-09-18 外部 issue #1:桌面端托盘退出死锁):
|
|
7
|
+
* 原实现是内联的递归 `setTimeout`,句柄被丢弃、既不 `unref` 也不在 dispose 里清。
|
|
8
|
+
* 宿主是 Node 侧 —— 一条 30s 递归(最多 20 次 ≈ 启动后 10 分钟)会把宿主事件循环
|
|
9
|
+
* 钉住,退出/关机流程得等它跑完(桌面端退不掉的两个拖累之一,另一个见
|
|
10
|
+
* lib/watchdog.js)。
|
|
11
|
+
*
|
|
12
|
+
* 与 lib/watchdog.js 同款:定时器创建/销毁可注入(`schedule` / `unschedule`),
|
|
13
|
+
* 于是"句柄有没有 unref""dispose 有没有撤销"能被用例直接捕获,不靠 review。
|
|
14
|
+
* 职责边界:本模块只决定"何时重试";"重试做什么"由 `run()` 注入(装配点传
|
|
15
|
+
* pinAllResident),"现在有没有可处理的会话"由 `hasResidentSessions()` 注入。
|
|
16
|
+
* 本模块零 import、零宿主服务引用(可单测,也进得了动态件 realm)。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 默认重试上限(对齐 0.4.19 的启动窗口:20 × 30s ≈ 启动后 10 分钟)。 */
|
|
20
|
+
export const BOOT_PIN_MAX_ATTEMPTS = 20
|
|
21
|
+
/** 默认重试间隔。 */
|
|
22
|
+
export const BOOT_PIN_DELAY_MS = 30_000
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 建启动重试链。
|
|
26
|
+
* @param {object} options
|
|
27
|
+
* @param {() => Promise<any>} options.run 实际执行体(返回值里的 `value.total` 用于日志)。
|
|
28
|
+
* @param {() => boolean} options.hasResidentSessions 现在有没有可处理的会话。
|
|
29
|
+
* @param {(line: string) => void} [options.log] 诊断日志。
|
|
30
|
+
* @param {number} [options.maxAttempts] 重试上限(含首跑)。
|
|
31
|
+
* @param {number} [options.delayMs] 重试间隔。
|
|
32
|
+
* @param {(cb: () => void, ms: number) => unknown} [options.schedule] 定时器创建
|
|
33
|
+
* (默认全局 setTimeout;返回的句柄上若有 `unref` 会被调用)。
|
|
34
|
+
* @param {(handle: unknown) => void} [options.unschedule] 定时器销毁(默认全局 clearTimeout)。
|
|
35
|
+
* @returns {{ dispose(): void }} dispose = 停止重试链 + 撤掉挂着的定时器(幂等)。
|
|
36
|
+
*/
|
|
37
|
+
export function createBootPinRetry({
|
|
38
|
+
run,
|
|
39
|
+
hasResidentSessions,
|
|
40
|
+
log = () => {},
|
|
41
|
+
maxAttempts = BOOT_PIN_MAX_ATTEMPTS,
|
|
42
|
+
delayMs = BOOT_PIN_DELAY_MS,
|
|
43
|
+
schedule = (cb, ms) => setTimeout(cb, ms),
|
|
44
|
+
unschedule = (handle) => clearTimeout(handle),
|
|
45
|
+
} = {}) {
|
|
46
|
+
if (typeof run !== 'function') throw new TypeError('createBootPinRetry: run must be a function')
|
|
47
|
+
if (typeof hasResidentSessions !== 'function') throw new TypeError('createBootPinRetry: hasResidentSessions must be a function')
|
|
48
|
+
|
|
49
|
+
let timer = null
|
|
50
|
+
let disposed = false
|
|
51
|
+
|
|
52
|
+
/** 挂下一次(序号 = next)。序号到顶就不挂,链自然收尾。 */
|
|
53
|
+
function arm(next) {
|
|
54
|
+
if (disposed || next >= maxAttempts) return
|
|
55
|
+
timer = schedule(() => {
|
|
56
|
+
timer = null
|
|
57
|
+
step(next + 1)
|
|
58
|
+
}, delayMs)
|
|
59
|
+
// 宿主是 Node 侧:不 unref ⇒ 这条 30s 递归把事件循环钉到启动后 10 分钟,
|
|
60
|
+
// 退出/关机被它拖住。unref 后宿主该退就退(会话仍在时事件循环自有别的句柄撑着)。
|
|
61
|
+
if (timer && typeof timer.unref === 'function') timer.unref()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function step(current) {
|
|
65
|
+
if (disposed) return
|
|
66
|
+
const finish = () => arm(current)
|
|
67
|
+
if (!hasResidentSessions()) {
|
|
68
|
+
finish()
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
Promise.resolve()
|
|
72
|
+
.then(() => run())
|
|
73
|
+
.then((result) => {
|
|
74
|
+
log(`retrace: bootPin(${current}) → ${result?.value?.total ?? 0} 个驻留会话标题已处理`)
|
|
75
|
+
})
|
|
76
|
+
.catch((error) => {
|
|
77
|
+
log(`retrace: bootPin error: ${String(error).slice(0, 160)}`)
|
|
78
|
+
})
|
|
79
|
+
.then(finish)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
step(0)
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
/** 停止重试链并撤掉挂着的定时器(重复调用安全)。 */
|
|
86
|
+
dispose() {
|
|
87
|
+
disposed = true
|
|
88
|
+
if (timer !== null) {
|
|
89
|
+
unschedule(timer)
|
|
90
|
+
timer = null
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-retrace — lib/boundary-derive.js
|
|
3
|
+
*
|
|
4
|
+
* READ-SIDE derivation of the boundary digest ("what did this boundary throw
|
|
5
|
+
* away?") for boundaries that have NO stored artifact record.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists (real-machine finding 2026-09-15): the digest artifact
|
|
8
|
+
* (`<pluginDataHome()>/dsh-retrace/summaries/<sessionId>.jsonl`) is written at
|
|
9
|
+
* OPERATION time and only started shipping with the summary feature, so every
|
|
10
|
+
* boundary that predates it has no record — the read point then rendered one
|
|
11
|
+
* bare line (`替换 / 9-1 16:30 / 当时共 2241 条消息`) with no content at all.
|
|
12
|
+
* The discarded ORIGINALS are still in the session log, so the digest can be
|
|
13
|
+
* recomputed on read.
|
|
14
|
+
*
|
|
15
|
+
* Authority order: a STORED record always wins. The stored digest was built at
|
|
16
|
+
* operation time, when the replaced window was still on the surface; once
|
|
17
|
+
* compaction removes those events, the log can no longer reproduce it. This
|
|
18
|
+
* module is the fallback, never an override.
|
|
19
|
+
*
|
|
20
|
+
* Pure (no imports beyond two pure siblings, no I/O): the caller passes an
|
|
21
|
+
* `eventAt(seq)` accessor, so the host seam, the HTTP route and the tests all
|
|
22
|
+
* drive the same rule and a test can hand it a plain Map.
|
|
23
|
+
*/
|
|
24
|
+
import { makeWhat } from './boundary-what.js'
|
|
25
|
+
import { classifyBoundaryKind, replacedSeqsOfBoundary } from './version-index.js'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Derive one digest record per version that has no stored artifact line.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} input
|
|
31
|
+
* @param {Array<{boundarySeq:number, versionId?:string}>} input.versions - the
|
|
32
|
+
* boundaries to derive for (normally "our" boundaries only; a host-side
|
|
33
|
+
* replacement is skipped even if it is passed in).
|
|
34
|
+
* @param {(seq:number)=>object|undefined} input.eventAt - one event by exact seq.
|
|
35
|
+
* @param {Set<number>} [input.boundarySeqs] - the boundary seqs used to SLIM
|
|
36
|
+
* `discardedSeqs` (same convention as the write side: membership only ever
|
|
37
|
+
* asks about another boundary's seq, `discardedCount` keeps the exact |S|).
|
|
38
|
+
* Defaults to the passed `versions`.
|
|
39
|
+
* @returns {object[]} derived artifact-shaped records (`derived: true`)
|
|
40
|
+
*/
|
|
41
|
+
/** 这一档属于哪一轮(人读):载体事件 → 被替换段里第一个带 turn 的事件 → null。 */
|
|
42
|
+
export function turnOfBoundary(event, spanEvents = []) {
|
|
43
|
+
// 实测:撤回/编辑/重发的"载体"事件常常不带 `data.turn`,被它替换掉的那一段才
|
|
44
|
+
// 带 ⇒ 先看载体,再在被替换段里取第一个可用 turn。都取不到就 null(调用方整段
|
|
45
|
+
// 省略,不编造"第 ? 轮")。
|
|
46
|
+
const usable = (value) => (Number.isSafeInteger(value) && value > 0 ? value : null)
|
|
47
|
+
const own = usable(event?.data?.turn) ?? usable(event?.turn)
|
|
48
|
+
if (own !== null) return own
|
|
49
|
+
for (const spanEvent of spanEvents) {
|
|
50
|
+
const found = usable(spanEvent?.data?.turn) ?? usable(spanEvent?.turn)
|
|
51
|
+
if (found !== null) return found
|
|
52
|
+
}
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function deriveBoundaryRecords({ versions, eventAt, boundarySeqs = null } = {}) {
|
|
57
|
+
const list = Array.isArray(versions) ? versions : []
|
|
58
|
+
if (typeof eventAt !== 'function') return []
|
|
59
|
+
const boundarySet = boundarySeqs instanceof Set
|
|
60
|
+
? boundarySeqs
|
|
61
|
+
: new Set(list.map((version) => version?.boundarySeq).filter((seq) => Number.isSafeInteger(seq)))
|
|
62
|
+
const out = []
|
|
63
|
+
for (const version of list) {
|
|
64
|
+
const boundarySeq = version?.boundarySeq
|
|
65
|
+
if (!Number.isSafeInteger(boundarySeq)) continue
|
|
66
|
+
const event = eventAt(boundarySeq)
|
|
67
|
+
if (event === undefined || event === null) continue
|
|
68
|
+
const kind = classifyBoundaryKind(event)
|
|
69
|
+
// Host-origin surface replacement (a tool result re-rendered by the host):
|
|
70
|
+
// never a read point of ours, so never given a digest.
|
|
71
|
+
if (kind === 'replace') continue
|
|
72
|
+
// The EXACT removed set: the carrier's own citation minus its audit guide
|
|
73
|
+
// item (never a numeric window — see lib/boundary-tree.js).
|
|
74
|
+
const removed = replacedSeqsOfBoundary(event, null)
|
|
75
|
+
const spanEvents = removed.map((seq) => eventAt(seq)).filter((spanEvent) => spanEvent !== undefined)
|
|
76
|
+
// 轮次:载体自身常不带 turn(实测),被替换的那一段才带 ⇒ 用它的 turn 告诉
|
|
77
|
+
// 用户"这一轮的输入"是哪一轮;都取不到就省略(不编造)。
|
|
78
|
+
const turn = turnOfBoundary(event, spanEvents)
|
|
79
|
+
const what = makeWhat({
|
|
80
|
+
op: kind,
|
|
81
|
+
at: event.time,
|
|
82
|
+
spanEvents,
|
|
83
|
+
// The EXACT seq list is passed explicitly: when compaction already ate the
|
|
84
|
+
// originals, `makeWhat` still lists them (role `unknown`, empty excerpt) and
|
|
85
|
+
// reports `replacedMore`, so the row keeps saying "丢弃了 N 条消息" instead
|
|
86
|
+
// of collapsing to a content-free line.
|
|
87
|
+
replacedSeqs: removed,
|
|
88
|
+
// The action's own NEW text is NOT reconstructible here: the carrier keeps
|
|
89
|
+
// only the archive notice (measured: every carrier's second segment reads
|
|
90
|
+
// "(此处内容已被撤回…)"), and the live continuation is a different node.
|
|
91
|
+
// Leaving it empty keeps the client from printing a misleading 延续 line.
|
|
92
|
+
newText: '',
|
|
93
|
+
})
|
|
94
|
+
out.push({
|
|
95
|
+
boundarySeq,
|
|
96
|
+
turn,
|
|
97
|
+
versionId: typeof version.versionId === 'string' ? version.versionId : `v${boundarySeq}`,
|
|
98
|
+
kind,
|
|
99
|
+
at: typeof event.time === 'number' ? event.time : undefined,
|
|
100
|
+
what,
|
|
101
|
+
discardedSeqs: removed.filter((seq) => boundarySet.has(seq)),
|
|
102
|
+
discardedCount: removed.length,
|
|
103
|
+
derived: true,
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
return out
|
|
107
|
+
}
|