@theseus-whale/dsh-continuity 0.0.0-stage → 4.12.2

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.
Files changed (258) hide show
  1. package/INSTALL.md +25 -0
  2. package/PANEL.md +92 -0
  3. package/README.md +286 -2
  4. package/RELEASE-NOTES.md +732 -0
  5. package/bin/bc-acceptance-full.mjs +91 -0
  6. package/bin/bc-acceptance-tight.mjs +66 -0
  7. package/bin/bc-acceptance.mjs +22 -0
  8. package/bin/bc-add-keywords.mjs +21 -0
  9. package/bin/bc-demo.mjs +47 -0
  10. package/bin/bc-expand-hint.mjs +179 -0
  11. package/bin/bc-ingest-index.mjs +35 -0
  12. package/bin/bc-ingest.mjs +31 -0
  13. package/bin/bc-label-demo.mjs +18 -0
  14. package/bin/bc-relabel.mjs +150 -0
  15. package/bin/bc-sessionize.mjs +59 -0
  16. package/bin/coref_pipeline.py +47 -0
  17. package/bin/coref_service.py +57 -0
  18. package/bin/debug_ckip.py +16 -0
  19. package/bin/debug_ner_zh.py +18 -0
  20. package/bin/doctor.mjs +275 -0
  21. package/bin/en_ner_error_audit.py +33 -0
  22. package/bin/eval-label-rules.mjs +342 -0
  23. package/bin/export-turns.py +68 -0
  24. package/bin/extract-user-paragraph.mjs +14 -0
  25. package/bin/extraction-composite-smoke.mjs +28 -0
  26. package/bin/extraction-latency.mjs +58 -0
  27. package/bin/extraction-user-smoke.mjs +28 -0
  28. package/bin/extraction_service.py +164 -0
  29. package/bin/facet-active-experiment.mjs +110 -0
  30. package/bin/facet-covid.mjs +8 -0
  31. package/bin/facet-curve.mjs +27 -0
  32. package/bin/facet-embedding-train.mjs +20 -0
  33. package/bin/facet-embedding-validate.mjs +83 -0
  34. package/bin/facet-eval-roleplay.mjs +42 -0
  35. package/bin/facet-eval.mjs +38 -0
  36. package/bin/facet-fold.mjs +31 -0
  37. package/bin/facet-load.mjs +16 -0
  38. package/bin/facet-mlp-scan.mjs +45 -0
  39. package/bin/facet-mvp.mjs +34 -0
  40. package/bin/facet-online-experiments.mjs +78 -0
  41. package/bin/facet-online-race.mjs +99 -0
  42. package/bin/facet-online-validate.mjs +51 -0
  43. package/bin/facet-panel.mjs +22 -0
  44. package/bin/facet-passage.mjs +41 -0
  45. package/bin/facet-random-validate.mjs +48 -0
  46. package/bin/facet-roleplay.mjs +33 -0
  47. package/bin/facet-scan.mjs +56 -0
  48. package/bin/facet-segment.mjs +34 -0
  49. package/bin/facet-train.mjs +19 -0
  50. package/bin/facet-validate.mjs +103 -0
  51. package/bin/facet-zeroshot.mjs +14 -0
  52. package/bin/install-templates.mjs +72 -0
  53. package/bin/memory-bench.mjs +53 -0
  54. package/bin/memory-build-summary.mjs +43 -0
  55. package/bin/memory-build-vector.mjs +31 -0
  56. package/bin/memory-build.mjs +41 -0
  57. package/bin/memory-calibration.mjs +70 -0
  58. package/bin/memory-closed-loop.mjs +119 -0
  59. package/bin/memory-dry-run.mjs +70 -0
  60. package/bin/memory-eval.mjs +104 -0
  61. package/bin/memory-event-alias-test.mjs +89 -0
  62. package/bin/memory-extraction-bench.mjs +58 -0
  63. package/bin/memory-load-gate-replay.mjs +137 -0
  64. package/bin/memory-loop-test.mjs +90 -0
  65. package/bin/memory-pre-turn-harness.mjs +63 -0
  66. package/bin/memory-query.mjs +39 -0
  67. package/bin/memory-replay-hints.mjs +84 -0
  68. package/bin/memory-scale.mjs +58 -0
  69. package/bin/memory-scenario-test.mjs +146 -0
  70. package/bin/memory-user-loop.mjs +95 -0
  71. package/bin/ner_bench_tiny_zh.py +59 -0
  72. package/bin/ner_bench_zh.py +60 -0
  73. package/bin/ner_conll_en.py +139 -0
  74. package/bin/ner_direct_en.py +123 -0
  75. package/bin/ner_direct_zh.py +100 -0
  76. package/bin/ner_pipeline.py +208 -0
  77. package/bin/noise-selfcheck.mjs +50 -0
  78. package/bin/pack-integrity.mjs +47 -0
  79. package/bin/panel-check.mjs +57 -0
  80. package/bin/plugin-integrity.mjs +74 -0
  81. package/bin/preview-live.mjs +13 -0
  82. package/bin/public-install-check.mjs +222 -0
  83. package/bin/relabel-apply.mjs +272 -0
  84. package/bin/relabel-dry-run.mjs +381 -0
  85. package/bin/relik_pipeline.py +39 -0
  86. package/bin/relik_service.py +52 -0
  87. package/bin/srl_pipeline.py +55 -0
  88. package/bin/srl_service.py +68 -0
  89. package/bin/summary-index.mjs +151 -0
  90. package/bin/summary-query.mjs +79 -0
  91. package/bin/vec-check.mjs +27 -0
  92. package/bin/zh_ner_error_audit.py +36 -0
  93. package/bin/zh_ner_slot_eval.py +38 -0
  94. package/client.js +760 -0
  95. package/cordis.patch.yml +378 -0
  96. package/docs/API-NOTES.md +1743 -0
  97. package/docs/ARCHITECTURE.md +355 -0
  98. package/docs/CACHE.md +253 -0
  99. package/docs/CONDUCT.md +30 -0
  100. package/docs/CREATOR-HANDOFF.md +102 -0
  101. package/docs/EXPERIMENT.md +136 -0
  102. package/docs/FACET-MVP-REPORT.md +473 -0
  103. package/docs/GUIDE-0.4.66.md +195 -0
  104. package/docs/GUIDE.md +127 -0
  105. package/docs/MECHANISM.md +243 -0
  106. package/docs/NEW-INSTANCE-HANDOFF.md +219 -0
  107. package/docs/README.md +37 -0
  108. package/docs/SESSION-LAYOUT.md +151 -0
  109. package/docs/history-map.md +249 -0
  110. package/docs/relabel-migration-wiring.md +366 -0
  111. package/guides/00-READ-FIRST.md +206 -0
  112. package/guides/01-INSTALL.md +207 -0
  113. package/guides/02-TABLES.md +225 -0
  114. package/guides/03-AUDIT.md +201 -0
  115. package/guides/04-REBUILD.md +306 -0
  116. package/guides/05-FIRST-RUN.md +191 -0
  117. package/guides/06-EMBEDDING.md +146 -0
  118. package/guides/07-PTC-AND-ASSETS.md +221 -0
  119. package/icon.svg +7 -0
  120. package/locale/en.json +6 -0
  121. package/locale/zh.json +6 -0
  122. package/models/README.md +13 -0
  123. package/models/Xenova/bge-small-zh-v1.5/config.json +31 -0
  124. package/models/Xenova/bge-small-zh-v1.5/onnx/model_quantized.onnx +0 -0
  125. package/models/Xenova/bge-small-zh-v1.5/tokenizer.json +21278 -0
  126. package/models/Xenova/bge-small-zh-v1.5/tokenizer_config.json +15 -0
  127. package/package.json +139 -4
  128. package/src/asset-tool.mjs +237 -0
  129. package/src/assets.mjs +244 -0
  130. package/src/backfill.mjs +74 -0
  131. package/src/backup.mjs +80 -0
  132. package/src/deadend.mjs +95 -0
  133. package/src/deleted-store.mjs +35 -0
  134. package/src/engine.mjs +3831 -0
  135. package/src/facets/classifier.mjs +146 -0
  136. package/src/facets/embedding-service.mjs +53 -0
  137. package/src/facets/embedding-worker.mjs +27 -0
  138. package/src/facets/embedding.mjs +163 -0
  139. package/src/facets/extract.mjs +80 -0
  140. package/src/facets/fold-plan.mjs +84 -0
  141. package/src/facets/hybrid.mjs +29 -0
  142. package/src/facets/index.mjs +113 -0
  143. package/src/facets/linear-probe.mjs +43 -0
  144. package/src/facets/load.mjs +37 -0
  145. package/src/facets/mlp-probe.mjs +54 -0
  146. package/src/facets/mlp-service.mjs +53 -0
  147. package/src/facets/mlp-worker.mjs +26 -0
  148. package/src/facets/online-knn.mjs +58 -0
  149. package/src/facets/online-models.mjs +227 -0
  150. package/src/facets/panel-state.mjs +23 -0
  151. package/src/facets/panel.mjs +73 -0
  152. package/src/facets/random.mjs +26 -0
  153. package/src/facets/scanner.mjs +35 -0
  154. package/src/facets/schema.mjs +75 -0
  155. package/src/facets/seed-examples.mjs +58 -0
  156. package/src/facets/sensitivity.mjs +98 -0
  157. package/src/facets/service.mjs +53 -0
  158. package/src/facets/store.mjs +39 -0
  159. package/src/facets/worker.mjs +23 -0
  160. package/src/folds-store.mjs +109 -0
  161. package/src/groups.mjs +167 -0
  162. package/src/handoff-tool.mjs +51 -0
  163. package/src/handoff.mjs +127 -0
  164. package/src/index.mjs +649 -0
  165. package/src/inject-policy.mjs +17 -0
  166. package/src/learning.mjs +88 -0
  167. package/src/memory/alias-proposal.mjs +40 -0
  168. package/src/memory/bc-audit.mjs +245 -0
  169. package/src/memory/bc-index-updater.mjs +404 -0
  170. package/src/memory/bc-index.mjs +48 -0
  171. package/src/memory/bc-label.mjs +220 -0
  172. package/src/memory/bc-panel-state.mjs +107 -0
  173. package/src/memory/bc-pre-step.mjs +463 -0
  174. package/src/memory/bc-recall.mjs +470 -0
  175. package/src/memory/bc-shift.mjs +123 -0
  176. package/src/memory/bc-store.mjs +41 -0
  177. package/src/memory/bm25.mjs +112 -0
  178. package/src/memory/calibration.mjs +54 -0
  179. package/src/memory/chatter.mjs +29 -0
  180. package/src/memory/cmap-file.mjs +107 -0
  181. package/src/memory/concepts-file.mjs +41 -0
  182. package/src/memory/coref-slots.mjs +40 -0
  183. package/src/memory/event-alias-ledger.mjs +87 -0
  184. package/src/memory/event-log.mjs +95 -0
  185. package/src/memory/event-model.mjs +132 -0
  186. package/src/memory/event-segmenter.mjs +36 -0
  187. package/src/memory/event-summary-tree.mjs +122 -0
  188. package/src/memory/extraction-adapter.mjs +76 -0
  189. package/src/memory/extraction-composite.mjs +75 -0
  190. package/src/memory/extraction-core.mjs +76 -0
  191. package/src/memory/extraction-pre-step.mjs +161 -0
  192. package/src/memory/extraction-provider.mjs +300 -0
  193. package/src/memory/extraction-score.mjs +36 -0
  194. package/src/memory/facet-stabilizer.mjs +59 -0
  195. package/src/memory/history-map.mjs +139 -0
  196. package/src/memory/language-router.mjs +30 -0
  197. package/src/memory/load-gate.mjs +161 -0
  198. package/src/memory/mechanical-note.mjs +104 -0
  199. package/src/memory/onboarding.mjs +163 -0
  200. package/src/memory/persona.mjs +78 -0
  201. package/src/memory/planner.mjs +249 -0
  202. package/src/memory/pre-turn.mjs +80 -0
  203. package/src/memory/query-aliases.mjs +39 -0
  204. package/src/memory/recall-cue.mjs +43 -0
  205. package/src/memory/recall.mjs +65 -0
  206. package/src/memory/referent-candidates.mjs +55 -0
  207. package/src/memory/referent-ledger.mjs +86 -0
  208. package/src/memory/relabel-migrate.mjs +376 -0
  209. package/src/memory/relik-adapter.mjs +25 -0
  210. package/src/memory/special-keywords.mjs +60 -0
  211. package/src/memory/structured-adapters.mjs +75 -0
  212. package/src/memory/summary-index.mjs +335 -0
  213. package/src/memory/summary-tree.mjs +110 -0
  214. package/src/memory/term-extractor.mjs +44 -0
  215. package/src/memory/tokenize.mjs +9 -0
  216. package/src/memory/vector-index.mjs +54 -0
  217. package/src/memory/window-summary.mjs +133 -0
  218. package/src/mood.mjs +133 -0
  219. package/src/peer-tool.mjs +63 -0
  220. package/src/peer.mjs +144 -0
  221. package/src/policy.mjs +170 -0
  222. package/src/preflight.mjs +113 -0
  223. package/src/provenance.mjs +94 -0
  224. package/src/range.mjs +75 -0
  225. package/src/recall-exact-tool.mjs +267 -0
  226. package/src/recall-tool.mjs +521 -0
  227. package/src/recheck.mjs +99 -0
  228. package/src/save-start.mjs +29 -0
  229. package/src/session-guard.mjs +140 -0
  230. package/src/session-log-tool.mjs +322 -0
  231. package/src/session-log.mjs +606 -0
  232. package/src/skills.mjs +111 -0
  233. package/src/split-range.mjs +115 -0
  234. package/src/state.mjs +198 -0
  235. package/src/t0.mjs +149 -0
  236. package/src/tag-confirm.mjs +78 -0
  237. package/src/tag-detect.mjs +42 -0
  238. package/src/tag-maintenance.mjs +285 -0
  239. package/src/tag-reject.mjs +73 -0
  240. package/src/tags-store.mjs +94 -0
  241. package/src/tags.mjs +692 -0
  242. package/src/threads-backend.mjs +26 -0
  243. package/src/threads-core.mjs +1887 -0
  244. package/src/threads-store.mjs +207 -0
  245. package/src/threads-tool.mjs +522 -0
  246. package/src/threads.mjs +929 -0
  247. package/src/tool-preview.mjs +427 -0
  248. package/src/topic.mjs +63 -0
  249. package/src/training.mjs +75 -0
  250. package/src/turn-tag.mjs +62 -0
  251. package/src/user-turn.mjs +39 -0
  252. package/templates/cmap.md +11 -0
  253. package/templates/concepts.md +12 -0
  254. package/templates/habits.md +40 -0
  255. package/templates/keywords.md +6 -0
  256. package/templates/rebuild.md +134 -0
  257. package/templates/rules-safe.md +184 -0
  258. package/templates/rules.md +177 -0
@@ -0,0 +1,1743 @@
1
+ # API notes — what the adapter still needs
2
+
3
+ > **Historical.** Research notes from an older `0.2.x` host. Current architecture is in
4
+ > `docs/ARCHITECTURE.md`; do not use these paths/APIs without re-checking the live host.
5
+
6
+ Researched from the public npm type declarations, not from the running host. The runtime may be a
7
+ newer `0.2.x`; pin peers before publishing.
8
+
9
+ Sources:
10
+
11
+ - [`@deepseek-ai/dsh-compaction` 0.2.1-rc.3 `index.d.ts`](https://unpkg.com/@deepseek-ai/dsh-compaction@0.2.1-rc.3/lib/types/index.d.ts)
12
+ - [`@deepseek-ai/dsh-compaction` `types.d.ts`](https://unpkg.com/@deepseek-ai/dsh-compaction@0.2.1-rc.3/lib/types/types.d.ts)
13
+ - [`@deepseek-ai/dsh-compaction-basic` `index.d.ts`](https://unpkg.com/@deepseek-ai/dsh-compaction-basic@0.2.1-rc.3/lib/types/index.d.ts)
14
+ - [`@deepseek-ai/dsh-compaction-basic` README (zh)](https://unpkg.com/@deepseek-ai/dsh-compaction-basic@0.2.1-rc.3/README.zh.md)
15
+
16
+ ## The seam
17
+
18
+ ```ts
19
+ abstract class CompactionEngine extends Service {
20
+ abstract compactIfNeeded(
21
+ agent: CompactionAgentContext,
22
+ trigger: 'pressure' | 'context-overflow',
23
+ signal: AbortSignal,
24
+ ): Promise<CompactionResult | null>;
25
+
26
+ abstract compactNow(
27
+ agent: ManualCompactAgentContext,
28
+ signal: AbortSignal,
29
+ sourceCommandId?: CommandId,
30
+ ): Promise<CompactionResult | null>;
31
+
32
+ abstract compactRegion(
33
+ start: SessionSeq,
34
+ end: SessionSeq,
35
+ agent: CompactionAgentContext,
36
+ signal?: AbortSignal,
37
+ ): Promise<CompactionResult>;
38
+ }
39
+ ```
40
+
41
+ A successful run replaces the selected surface span with **one summary user message**. The
42
+ transaction writes `compaction/start` → `compaction/summary` → replacement `user/message` →
43
+ `compaction/end`. Edges must be balanced (`toolPairingBalancedBefore` / `After`). The system
44
+ message at surface position 0 is never shadowed.
45
+
46
+ ## Why extend the basic backend
47
+
48
+ `@deepseek-ai/dsh-compaction-basic` already implements the expensive parts:
49
+
50
+ - `BasicCompactionEngine` (subclass of `CompactionEngine`);
51
+ - token-meter-driven pressure and overflow recovery;
52
+ - range selection, retention, durable marker transaction;
53
+ - `summarize()` as the sole subclass hook;
54
+ - public `compactRegion(start, end, agent, signal)`.
55
+
56
+ So the skeleton subclasses it and only overrides `compactIfNeeded` to choose **smaller ranges**
57
+ than the base policy would. `compactNow` and `compactRegion` stay inherited.
58
+
59
+ Its config (all optional):
60
+
61
+ | Field | Default | Meaning |
62
+ |---|---|---|
63
+ | `thresholdRatio` | `0.8` | trigger at `floor(min(W × ratio, W − O − headroom))` |
64
+ | `headroomTokens` | `65536` | extra margin beyond output reserve |
65
+ | `retainRatio` | `0.16` | recent verbatim budget as a fraction of `W − O` |
66
+ | `retainTokens` | — | absolute form, mutually exclusive with `retainRatio` |
67
+ | `maxTokens` | headroom | summary output cap |
68
+ | `auto` | `true` | automatic pressure + overflow recovery |
69
+ | `modelPolicies` | `[]` | per-`{provider,model}` overrides |
70
+
71
+ ## The two missing pieces
72
+
73
+ ### 1. `measure(agent)`
74
+
75
+ Need the used and window tokens for the agent's latest routed request from `ctx.tokenMeter`. The
76
+ README describes the semantics (singleton meter, latest durable routed envelope, fixed estimator)
77
+ but not the call signatures. Inspect with Creator mode:
78
+
79
+ ```
80
+ cordis_inspect_list
81
+ cordis_inspect_query → the token-meter provider's methods/types
82
+ ```
83
+
84
+ ### 2. `oldestRange(agent, budget)`
85
+
86
+ Need to walk `agent.session`'s surface, skipping the system node at position 0, accumulate nodes
87
+ until `budget` is reached, then move the end back to a balanced boundary. The exported helpers:
88
+
89
+ ```ts
90
+ toolPairingBalancedBefore(session, startNode): boolean
91
+ toolPairingBalancedAfter(session, endNode): boolean
92
+ ```
93
+
94
+ Return `{ start, end }` as surface seqs (inclusive), or `null`. If `null`, fall back to
95
+ `super.compactIfNeeded(...)` so the session still compacts at the base threshold.
96
+
97
+ ## Provenance: quiet, not silent
98
+
99
+ The fixed checkpoint preamble is an **event marker**; the provenance header is a **property of the
100
+ memory**. Both can coexist:
101
+
102
+ - `gistHeader({ sessionId, startSeq, endSeq, count })` produces
103
+ `[gist · log:<session>#seq=<start>-<end> · N messages · original retrievable]`.
104
+ - `annotateSummary(blocks, header)` prepends it to the first text block of the summary.
105
+ - `SYSTEM_CONVENTION` goes into the system prompt **once**; it explains the tag and the fallback
106
+ ("say you only remember the gist").
107
+
108
+ The intended `summarize()` override:
109
+
110
+ ```ts
111
+ protected async summarize(input, agent, signal) {
112
+ const base = await super.summarize(input, agent, signal);
113
+ const header = this.pendingHeader;
114
+ this.pendingHeader = null;
115
+ if (!header) return base;
116
+ return { ...base, summary: annotateSummary(base.summary, header) };
117
+ }
118
+ ```
119
+
120
+ Unknowns to confirm at runtime:
121
+
122
+ - the exact field of `SummaryResult` holding the blocks (`summary` is the guess);
123
+ - whether a `ctx.systemPrompt.section({ name, order, interpolate: false, text })` seam exists
124
+ (the `dsh-soul` plugin uses that shape) and where to mount it so it is not itself compacted;
125
+ - whether the preamble can be shortened without reimplementing the replacement transaction.
126
+
127
+ Test for the failure this prevents: ask about a detail that exists only in a compacted range. The
128
+ expected answer is "I only have the gist; the original is at `log:…#seq=…`", not a confident
129
+ fabrication.
130
+
131
+ ## Hard constraints
132
+
133
+ - **One engine per context.** `ctx.compaction` holds exactly one implementation. A plugin must
134
+ **replace** the existing `compaction-basic` row, not insert a second one.
135
+ - **`compactRegion` needs an open turn**; `compactNow` reserves an idle agent instead.
136
+ - **Every pass is an LLM call.** `summarize()` runs one `ctx.llm.stream()`. Ten small passes cost
137
+ ten calls. This is the main reason "breathing" is expensive at the compaction layer; a
138
+ model-free prune path (rewriting old tool receipts) would be cheaper but is a different engine
139
+ and cannot summarize conversation.
140
+ - **Cache:** each replacement invalidates the prefix from the first changed token. Batch small
141
+ passes at turn boundaries; keep the system prefix untouched.
142
+ - **Safety:** replacing a compaction backend can corrupt a session's surface. Test in a scratch
143
+ profile with a disposable conversation; keep the default engine one patch line away.
144
+
145
+ ## Config-only quick win
146
+
147
+ Before writing any engine code, the shipped backend can already approximate the Coopanion band:
148
+
149
+ ```yaml
150
+ - id: <existing compaction-basic loader id>
151
+ config:
152
+ thresholdRatio: 0.92
153
+ retainRatio: 0.5
154
+ headroomTokens: 65536
155
+ ```
156
+
157
+ This keeps a 50% recent verbatim tail and compacts at 92%. It is still a threshold + one summary,
158
+ not breathing, but it is a real, reversible test of the band on DSH — and it changes no code.
159
+
160
+ Find the id in Creator mode: `cordis_inspect_list` for the `Config` provider, locate the
161
+ `@deepseek-ai/dsh-compaction-basic` entry, and read its loader id / tree id. An unknown id in a
162
+ patch is not guaranteed to be a harmless no-op.
163
+
164
+ > **Measured caveat (2026-10-26):** on this desktop profile the base row is disabled and the live
165
+ > engine is preset-scoped (`preset-standard` → group `compaction` → `compaction-basic`), which no
166
+ > patch id can address. The experiment is therefore shipped as a **copy of the preset declaration**
167
+ > with the changed child config (`docs/EXPERIMENT.md`), not as a row override. The base row id is
168
+ > still recorded above for profiles where compaction-basic is host-level.
169
+
170
+ ## Version pinning
171
+
172
+ The DSH app ships `@deepseek-ai/dsh-compaction` / `-basic` `0.2.x` (runtime `0.2.1-rc.3` in the
173
+ packages inspected). Before publishing, pin `peerDependencies` to the range the app actually
174
+ provides and run `dsh plugin ... add` against a scratch profile.
175
+
176
+ ---
177
+
178
+ # Measured at runtime — 2026-10-26
179
+
180
+ Everything below was read from the **running** desktop Host and its installed packages, not from
181
+ npm types. Environment: `@deepseek-ai/dsh-desktop` `0.2.1-rc.3`, bundled runtime
182
+ `@deepseek-ai/dsh-desktop-runtime` `0.2.1-rc.3` under `resources/app.asar/dsh`, profile `desktop`,
183
+ Electron `process.resourcesPath` present.
184
+
185
+ ## 1. Compaction loader rows
186
+
187
+ `cordis_inspect_query` → Host `Config`, `listConfigs`:
188
+
189
+ | What | Exact value |
190
+ |---|---|
191
+ | Base row id | `include:compaction-basic` |
192
+ | Base row `patchId` | `compaction-basic` |
193
+ | Base row name | `@deepseek-ai/dsh-compaction-basic` |
194
+ | Base row enabled in this profile | **false** (`fiberPhase: null`) |
195
+ | Base row `packageDir` | `…/app.asar/dsh/node_modules/@deepseek-ai/dsh-compaction-basic` |
196
+ | Preset row id | `include:preset-standard` |
197
+ | Preset row `patchId` | `preset-standard` |
198
+ | Preset row name | `@deepseek-ai/dsh-agent-preset` |
199
+ | Preset row `packageDir` | `…/app.asar/dsh/node_modules/@deepseek-ai/dsh-agent-preset` |
200
+
201
+ The **effective** engine is not the base row. It is a child of the `standard` preset:
202
+
203
+ ```
204
+ preset-standard
205
+ └─ group: compaction isolate: { compaction: true, toolResultPruner: true }
206
+ ├─ compaction-basic
207
+ ├─ command-compact
208
+ └─ tool-result-pruner
209
+ ```
210
+
211
+ Consequences, confirmed live:
212
+
213
+ - The preset child is **not addressable** by a loader patch id. `Config.listConfigs` rejects
214
+ `include:preset-standard:compaction:compaction-basic` (nested entry ids use `:`), and a patch
215
+ with an id plus `config` targets only top-level rows.
216
+ - Therefore the config-only experiment cannot override "the existing row"; it must **copy the
217
+ preset declaration** with the changed child config. This plugin ships that as the `band` preset
218
+ and leaves `preset-standard` untouched. See `docs/EXPERIMENT.md`.
219
+ - The preset declaration `Config` schema is `{ id, name?, description?, order?, plugins }`.
220
+ - After installing this plugin, the Host roster contains `include:preset-band` and
221
+ `include:preset-continuity` in addition to the four shipped presets.
222
+
223
+ ## 2. `ctx.tokenMeter` (exact)
224
+
225
+ ```ts
226
+ measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement
227
+ estimateMessage(message: Message): number
228
+ ```
229
+
230
+ `TokenMeasurement` is detached and deeply immutable:
231
+
232
+ ```ts
233
+ interface TokenMeasurement {
234
+ readonly logRevision: SessionLogOffset
235
+ readonly baseline:
236
+ | { readonly kind: 'none'; readonly tokens: 0 }
237
+ | { readonly kind: 'estimated'; readonly tokens: number }
238
+ | { readonly kind: 'usage'; readonly tokens: number; readonly usage: Readonly<TokenUsage> }
239
+ readonly surfaceDeltaTokens: number
240
+ readonly totalTokens: number // the pressure number the policy uses
241
+ readonly surfaceTokens: number
242
+ readonly nodes: readonly TokenSurfaceNode[]
243
+ }
244
+ interface TokenSurfaceNode {
245
+ readonly seq: SessionSeq // same positional seqs as session.surface.nodes
246
+ readonly tokens: number // route-priced (images included)
247
+ readonly heuristicTokens: number // fixed estimator, used for shadowedTokenCount
248
+ }
249
+ ```
250
+
251
+ The `nodes` array is positionally aligned with `session.surface.nodes`; `BasicCompactionEngine`
252
+ rejects a measurement whose surface does not match exactly. `measure()` clones the positional
253
+ nodes, so holding one across a surface change is safe but stale.
254
+
255
+ ## 3. Session surface (exact)
256
+
257
+ ```ts
258
+ session.surface: {
259
+ readonly nodes: readonly SessionSeq[] // surface positions, oldest first
260
+ readonly replaceGeneration: number // bumps on a replace transaction
261
+ readonly contentGeneration: number
262
+ }
263
+ session.id: SessionId
264
+ session.seq: SessionLogOffset
265
+ session.eventAt(seq: SessionSeq): SessionEvent | undefined
266
+ session.deriveEventMessage(event: SessionEvent): Message | null
267
+ session.requestHeader(): EpochHeader | undefined
268
+ ```
269
+
270
+ `EpochHeader` is `{ config: { provider, model, maxTokens?, … }, tools?: ToolSchema[], adapterDefaults? }`.
271
+ Real header observed in a compacted session:
272
+
273
+ ```json
274
+ { "provider": "deepseek-official", "model": "deepseek-flash", "reasoningEffort": "max", "maxTokens": 256000 }
275
+ ```
276
+
277
+ That `maxTokens` is the **conversation request's** reserved output, and it is what
278
+ `BasicCompactionEngine` subtracts from the context window. It is not the summary cap.
279
+
280
+ ## 4. Window capacity and the config-only arithmetic
281
+
282
+ ```ts
283
+ ctx.llm.resolveModelInfo(provider, model, signal?): Promise<{
284
+ context?: { contextWindow: number }
285
+ defaultMaxTokens?: number
286
+ …
287
+ }>
288
+ ```
289
+
290
+ Measured for `deepseek-official/deepseek-flash`: `contextWindow = 1_048_576`,
291
+ `defaultMaxTokens = 256_000`. The base backend computes (`resolveCompactSpec`):
292
+
293
+ ```
294
+ messageBudget = contextWindow − reservedCompletionTokens
295
+ threshold = floor(min(contextWindow × thresholdRatio, messageBudget − headroomTokens))
296
+ retain = floor(messageBudget × retainRatio)
297
+ require retain < threshold
298
+ ```
299
+
300
+ Defaults: `thresholdRatio 0.8`, `retainRatio 0.16`, `headroomTokens 65_536`,
301
+ `maxTokens = headroomTokens` (the summary output cap), `compactionRetries 1`,
302
+ `maxOverflowRetries 1`.
303
+
304
+ For this model (`messageBudget = 792_576`):
305
+
306
+ | Config | Threshold | Verbatim retain | One pass at threshold |
307
+ |---|---|---|---|
308
+ | shipped `0.8 / 0.16` | 727,040 | 126,812 | 600,228 |
309
+ | experiment `0.92 / 0.5` | **727,040** | **396,288** | **330,752** |
310
+ | band engine, cache-first `86% / 62% / protect 30%` | triggers at **681,615** | protects **237,773** | one ~190,000 drain to low |
311
+
312
+ Note the cap: `0.92 × W` is 964,690, but `W − O − headroom` is 727,040, so the 92% experiment is
313
+ limited by headroom, not by its ratio. The config is valid (`396,288 < 727,040`).
314
+
315
+ Version note: the older `0.1.5` runtime's `resolveCompactSpec` used
316
+ `threshold = floor(W × ratio)` and `retain = floor(W × retainRatio)` with **no** output reserve or
317
+ headroom, and defaulted `maxTokens` to **8192**. The current `0.2.1` Host subtracts the reserve and
318
+ headroom as shown above and defaults `maxTokens` to the headroom (65,536). Older compaction records
319
+ with `maxTokens: 8192` (for example `session-4185b80f…`) come from that generation; the current-era
320
+ records carry 65,536. The projection in the panel is the 0.2.1 formula.
321
+
322
+ ## 5. Summary result and the provenance field
323
+
324
+ ```ts
325
+ summarize(input: SummarizationInput, agent: Agent, signal?): Promise<SummaryResult>
326
+ type SummaryResult = {
327
+ summary: ContentBlock[] // ← confirmed; blocks, not a string
328
+ provider: string
329
+ model: string
330
+ maxTokens?: number
331
+ usage?: TokenUsage
332
+ } & ({ rawOutput: ContentBlock[]; llmStreamCall: true }
333
+ | { rawOutput?: ContentBlock[]; llmStreamCall?: never })
334
+ ```
335
+
336
+ `frameSummary()` wraps the returned blocks in the fixed checkpoint preamble plus
337
+ `<compacted-summary>…</compacted-summary>`. Our `annotateSummary(base.summary, header)` therefore
338
+ prepends the `[gist …]` line to the first text block, inside the summary tags and after the
339
+ preamble. Verified format from a real transaction:
340
+
341
+ ```
342
+ block 0: "This is an automatically generated checkpoint …" (preamble)
343
+ block 1: "## Primary Request and Intent …" (summary; our header lands here)
344
+ block 2: "</compacted-summary>"
345
+ ```
346
+
347
+ ## 6. Real compaction transaction record
348
+
349
+ Decoded from the local `session.v4.jsonl.zstd` logs (read-only scan; the logs are concatenated
350
+ zstd frames, so one-shot `zstdDecompressSync` only yields the first frame — split at the
351
+ `28 B5 2F FD` magic bytes and decode each frame, or use the persistence package's frame scanner):
352
+
353
+ ```
354
+ session session-2f831fa2-a345-4e99-bce7-5796444fea57
355
+ compactionId b57ba840-1892-4175-b652-06c7e30b8609
356
+ start seq 8255
357
+ summary seq 8257 shadowedRange 4424–7043
358
+ 1259 surface nodes, 363,912 tokens
359
+ summary model deepseek-flash, maxTokens 65536
360
+ usage 505,602 input / 4,731 output
361
+ replace seq 8258 surfaceOp { replace, startSeq 4424, endSeq 7043 }
362
+ source { kind: 'compact-checkpoint', compactionId: b57ba840… }
363
+ end seq 8259
364
+ ```
365
+
366
+ Other observed one-pass cliffs in the same log set: **354,500**, **342,756**, **425,772**,
367
+ **442,443** shadowed tokens. Those are the behavior the band controller exists to replace.
368
+ The summary text in all seven observed transactions starts directly with `## Primary Request`
369
+ — there is no gist header in the shipped backend, which is why `summarize()` is overridden.
370
+
371
+ ## 7. Tool pairing and the transaction seam
372
+
373
+ Confirmed exports of `@deepseek-ai/dsh-compaction` (runtime `lib/index.js`, and exercised by the
374
+ engine's import):
375
+
376
+ ```ts
377
+ toolPairingBalancedBefore(session, seq): boolean // cut immediately BEFORE seq
378
+ toolPairingBalancedAfter(session, seq): boolean // cut immediately AFTER seq
379
+ compactCheckpointSource(compactionId, sourceCommandId?): { kind: 'compact-checkpoint', … }
380
+ isCompactCheckpointSource(source): boolean
381
+ ```
382
+
383
+ The predicates throw when the seq is no longer on the current surface. `compactRegion(start, end,
384
+ agent, signal?)` validates both edges, the span, the active-turn lock, and then runs the transaction.
385
+ `CompactionResult` carries `compactionId, startSeq, summarySeq, endSeq, summary, shadowedRange,
386
+ shadowedSeqs, shadowedTokenCount`.
387
+
388
+ ## 8. System prompt section
389
+
390
+ ```ts
391
+ ctx.systemPrompt.section(section: PromptSection): () => void
392
+ interface PromptSection {
393
+ readonly name: string
394
+ readonly order: number
395
+ readonly text: string | ((context) => string)
396
+ readonly interpolate?: boolean
397
+ readonly complete?: boolean
398
+ }
399
+ ```
400
+
401
+ Section order is a plain number; the repository's named orders run from `HARNESS_IDENTITY (−1000)`
402
+ to `DEPLOYMENT_PERSONA_SUFFIX (10200)`. The standing provenance convention is mounted once at
403
+ **order 9500**, after the tool sections and before structured output, via
404
+ `ctx.inject(['systemPrompt'], …)` so the engine still loads in a profile without that service.
405
+ Live state reports `standingConvention: 'registered'`.
406
+
407
+ ## 9. How this plugin loads the base class
408
+
409
+ The plugin is installed as a **`link:`** dependency (`pnpm` warns that peer dependencies do not
410
+ resolve from the linked target). A plain `import '@deepseek-ai/dsh-compaction-basic'` from the
411
+ workspace would therefore fail, or find a different runtime generation. The engine instead tries,
412
+ in order:
413
+
414
+ 1. `file://<resources>/app.asar/dsh/node_modules/@deepseek-ai/<pkg>/lib/index.js`
415
+ 2. `file://<resources>/app.asar/node_modules/@deepseek-ai/<pkg>/lib/index.js`
416
+ 3. `@deepseek-ai/<pkg>` (portable fallback)
417
+
418
+ The live panel snapshot confirms candidate 1 was used for both `dsh-compaction-basic` and
419
+ `dsh-compaction`; the engine extends the same `BasicCompactionEngine` / Cordis `Service` generation
420
+ as the Host. If Electron cannot import from `app.asar`, the fallback keeps the engine loadable but
421
+ would mix generations — the panel surfaces the chosen package URL under `engine.base`.
422
+
423
+ ## 10. Remaining unknowns / limits
424
+
425
+ - **Manual `/compact` has no gist header yet.** `compactNow` selects its range inside the inherited
426
+ transaction, so this version cannot label it before `summarize()` runs. Automatic band passes are
427
+ labeled; manual compaction keeps the plain checkpoint preamble.
428
+ - **The cache-first policy was observed live on 2026-10-26 at 18:33:16.** One `band-high` pass:
429
+ `estTokens 745,243`, `tokens 179,725`, `count 143`; the next requests returned to ~99.96% cache
430
+ hits. The same session's old policy had produced 53 passes (698,108 tokens, 34 one-node passes).
431
+ See `docs/CACHE.md`.
432
+ - **Panel pixels are unverified.** There is no browser control in this environment. The client
433
+ module, locale registration, both slot registrations, and the full element tree were exercised
434
+ headlessly (`tools/smoke-client.mjs`), but light/dark appearance and layout are not claimed.
435
+ - The **summary cap** `maxTokens` is inherited from `headroomTokens` (65,536) when not configured;
436
+ `compaction/summary` records show 65,536 or 8,192 depending on the session's config generation.
437
+
438
+ ## 11. Cold-start route bug (fixed 2026-10-26)
439
+
440
+ **Symptom after a DSH restart:** `include:dsh-continuity` is `enabled: true, fiberPhase: active`,
441
+ `include:preset-continuity` is active, but `GET /dsh-continuity/state.json` returns **404**. A
442
+ running install worked; only a fresh boot lost the panel.
443
+
444
+ **Cause:** `apply()` read `ctx.get('webServer')` synchronously and returned early when the service
445
+ was not yet published. On a cold start the row can activate before the Web-server row, so the row
446
+ stayed active with no route. The previous session had installed the plugin into an already-running
447
+ Host, which hid the ordering bug.
448
+
449
+ **Fix:** the route is registered through `ctx.inject(['webServer'], (scoped) => { … })`, so it
450
+ appears when the service is ready and is disposed with the plugin. Do not read an optional service
451
+ synchronously in `apply()` unless the row declares it in `inject`; use `ctx.inject` for optional
452
+ services.
453
+
454
+ **Verification after the fix:** the state route must return 200 and `engine` must be non-null on a
455
+ fresh boot, not only after a hot install.
456
+
457
+ ## 12. Cache-first policy (2026-10-26)
458
+
459
+ The policy source of truth is `CACHE_FIRST_POLICY` in `src/policy.mjs`. `preset-continuity` pins
460
+ only `policy.setpoint: 0.86` (the cache-first value) so a hot preset reconciliation cannot fall
461
+ back to the older 0.72 module default; every other field inherits. It previously restated the whole
462
+ policy with the old `0.65/0.80/0.55/8k/4k` values and silently overrode the new code defaults —
463
+ that mismatch was the reason the 18:07 edit appeared not to work. The full analysis and
464
+ measurements are in `docs/CACHE.md`. Key facts:
465
+
466
+ - 53 compactions in `session-0b094f6e…` removed 698,108 tokens; 34 were one-node passes.
467
+ - One request read `cacheRead 733,184`; the request after a 4k compaction read
468
+ `input 733,414 / cacheRead 8,704` — one pass invalidated a 733k-token prefix.
469
+ - `CACHE_FIRST_POLICY` sets `setpoint = high = 0.86`, `low = 0.62`, `maxPerBatch = 190_000`,
470
+ `minAction = 24_000`, `protect = 0.30`, `emergency = 0.96 → 0.80` (0.2.1; was 0.88). At
471
+ `W = 792,576` that is a 681,615 trigger, a 491,397 drain, and a 237,773 protected tail;
472
+ 698,108 / 190,000 ≈ 4 passes.
473
+ - `setpoint = high` removes the proportional-zone passes. `CACHE_GRADUAL_POLICY` (setpoint 0.72)
474
+ is the smoother, lower-cache variant.
475
+ - The preset pins `policy.setpoint: 0.86` and (0.2.1) `policy.emergencyTo: 0.80`. A plugin-manager
476
+ remount at 18:27:37 applied the setpoint pin live; the emergencyTo pin is what lets a remount
477
+ pick up the 0.2.1 fix without reloading the cached `policy.mjs`.
478
+ - Node's ESM module cache persists across a preset reconciliation: a hot remount re-reads the
479
+ preset config but **not** the edited `policy.mjs`. The explicit pin is what made cache-first
480
+ live without a process restart. A full restart is still required to load the `ctx.inject`
481
+ cold-start fix and any code-only default.
482
+ - Existing sessions keep their mounted revision; a restart, a bundle remount, or a fork mounts the
483
+ current policy.
484
+
485
+ ## 13. `session_log` retrieval tool (0.2.0)
486
+
487
+ **Host read API.** The tool uses the exact-read face of `ctx.sessionQuery` (the base
488
+ `session-query-sqlite` row keeps exact reads even with `openAt: never`):
489
+
490
+ ```ts
491
+ ctx.sessionQuery.readSession(sessionId): Promise<{
492
+ session: SessionHeader
493
+ inheritedEventCount: SessionLogOffset
494
+ events: SessionEvent[] // complete raw log, replay-validated
495
+ }>
496
+ ```
497
+
498
+ `readSession` is live-preferred: a live session is read from memory, a persisted one from JSONL.
499
+ It makes the session live? No — the docs say “without making it live”; it returns a cloned
500
+ snapshot. There is also `readEvent({sessionId, seq, before, after})` for a window, but the tool
501
+ reads one snapshot and formats locally so index/get/grep share one view.
502
+
503
+ **Registration.** `src/session-log-tool.mjs` exports `inject = ['tools', 'sessionQuery']` and
504
+ `apply`, which builds a plain `ToolDefinition` and calls `ctx.tools.register(tool)` in the
505
+ **calling (preset) scope**. The `preset-continuity` plugin list carries:
506
+
507
+ ```yaml
508
+ - id: session-log-tool
509
+ name: dsh-continuity/session-log-tool
510
+ ```
511
+
512
+ `preset-band` and `preset-standard` do **not** carry the row, so their agents see no `session_log`
513
+ and the tool registry reports it absent for those scopes. The tool needs no
514
+ `@deepseek-ai/dsh-tools` import (`defineTool` is a validation wrapper; a plain JSON-Schema
515
+ `ToolDefinition` is accepted), which keeps the `link:` install from needing a workspace peer.
516
+
517
+ **Seq contract.** `seq` is the session event seq, the same number in
518
+ `[gist · log:<session>#seq=A-B]`. `A-B` is a *surface* range and can be non-monotonic (for example
519
+ `3005-1872` after a replacement). `get` first looks for a `compaction/summary` whose
520
+ `shadowedRange` equals the requested range and then reads exactly its `shadowedSeqs` in surface
521
+ order; only an unmatched, ordered range falls back to a numeric filter. This is what lets the
522
+ 0.1.0-era gists remain retrievable without rewriting their headers.
523
+
524
+ **Read-only / budget.** The tool calls only `readSession`; it never appends, replaces, projects, or
525
+ compacts. Output is bounded: `index` 12k chars, `get` 16k with `MAX_EVENT_CHARS 2400`, `grep` 12k;
526
+ `limit` default 20 (max 100), `context` max 10. A truncated result carries a manifest, the role
527
+ histogram, and a next seq or a “narrow with grep” instruction. `grep` excludes `system/message` so
528
+ the convention/tool descriptions cannot match; `get <seq>` can still read a system event.
529
+
530
+ **Scope verification.** `state.json` now carries `tools.sessionLog` (registration) and
531
+ `tools.scopes` (per live agent: current preset, `sessionLog`, `convention`, tool count). The probe
532
+ reads the **`agentPreset` projection**, not `session.header.agentPreset`: the header is the
533
+ creation fact, and `session-0b094f6e…` was created as `standard` then switched to `continuity`.
534
+ Using the header alone mislabels that session and skips the convention check. The projection is
535
+ authoritative.
536
+
537
+ **Live 0.2.0 state (before the required restart):** the host row re-imported as `0.2.0` and the
538
+ scope probe shows the long session as `preset: continuity`, `convention: true`, `sessionLog: false`
539
+ with 32 tools; the `session-log-tool` row is part of the preset declaration read at boot, so the
540
+ running process still has the old preset. A restart is required to mount it; after the restart the
541
+ same probe should read `sessionLog: true` and the tool count should increase by one. The retrieval
542
+ logic itself is exercised against the real log by `tools/acceptance-0.2.0.mjs`.
543
+
544
+ The exact-read Host API is already verified live: `state.json.sessionQuery` reports
545
+ `{ source: "ctx.sessionQuery.readSession", sessionId: "session-0b094f6e…", events: 3292,
546
+ firstSeq: 0, lastSeq: 3291 }`. That is the same `readSession` call the tool's `execute` makes;
547
+ only the preset row registration is waiting for the restart.
548
+
549
+ **Policy override answer.** The handoff's premise that `DEFAULT_POLICY.setpoint` is 0.72 is stale:
550
+ the source now exports `CACHE_FIRST_POLICY` as `DEFAULT_POLICY` with 0.86, and
551
+ `CACHE_GRADUAL_POLICY` is the 0.72 variant. The runtime 0.86 comes from both the source default and
552
+ the explicit `policy.setpoint: 0.86` pin in `cordis.patch.yml`. The pin is intentional (it made the
553
+ cache-first policy live across a hot remount and protects against a stale module default), not a
554
+ leftover. The two options for 0.2.0 are: keep 0.86 (one 190k pass per high crossing, fewer cache
555
+ invalidations, higher average context) or switch to 0.72 (24k proportional passes, smoother
556
+ context, more invalidations). The package ships 0.86; the switch is one config/policy line.
557
+
558
+ ## 14. 0.2.0 acceptance record (real session log)
559
+
560
+ `tools/acceptance-0.2.0.mjs` decodes `session-0b094f6e…` (3,293 events, 54 gists) and runs the
561
+ same pure functions and ToolDefinition the model uses:
562
+
563
+ - `index` lists the newest gist `seq=3020 · surface 3005-1872 · 143 msgs · ~179,725 tok`.
564
+ - `get` with the pointer `[gist · log:… #seq=3005-1872 …]` resolves via
565
+ `compactionId b07faeb0-ca36-45c8-88f9-9bfcf1dca9b2` to exactly 143 events, returns a manifest
566
+ (`user:14 assistant:53 tool:76`) and truncates at 5 (`next seq=1222`).
567
+ - `get seq=1604 context=1` returns the raw user message containing
568
+ “鲸鳍不是尖刀/叶形——它是宽的扇面…”.
569
+ - `grep "鲸鳍不是尖刀"` returns seq 1604 (raw) plus the newer gist copies at seq 3020/3021; `get`
570
+ the match ±1 returns the original.
571
+ - `get seq=999999` fails with
572
+ `seq 999999 is not in this session (valid event seqs 0-3291). If the original is unavailable, say
573
+ you only remember the gist.`
574
+ - Frozen-event read-only test and `parsePointer` for plain ids, pointers, and full gist headers
575
+ pass in `test/session-log.test.mjs` (15 tests).
576
+
577
+ ## 15. `emergencyTo` double-pass fix (0.2.1)
578
+
579
+ `emergencyTo` was 0.88, **above** `high = 0.86`. At 97% the emergency pass landed at 88% (697,467),
580
+ still above high, and the next batch immediately ran a second band-high pass to 62% — two prefix
581
+ invalidations and two summarization calls for one overshoot. 96% exactly was *better* because it
582
+ went straight to band-high.
583
+
584
+ 0.2.1 sets `emergencyTo = 0.80` (and pins it in `preset-continuity`, like `setpoint`):
585
+
586
+ | At | One pass | After | Headroom to high 681,615 |
587
+ |---|---:|---:|---:|
588
+ | 97% (768,799) | emergency, budget 134,738 | 634,061 (80%) | 47,554 (6% W) |
589
+ | 96.1% (761,666) | emergency, budget 127,605 | 634,061 (80%) | 47,554 |
590
+ | 96% exactly (760,873) | band-high, budget 190,000 | 570,873 | 110,742 |
591
+ | 0.88 (old) at 97% | emergency 72,000, then band-high 190,000 | 570,873 after two passes | two invalidations |
592
+
593
+ Why 0.80: one emergency lands below high with a 6% window before band-high can fire again; it is
594
+ shallower than the old 0.88-plus-band-high chain and shallower than a band-high at 97% would be
595
+ (190k to 570,873). 0.85 leaves only 1% W (7,926 tokens) and re-triggers almost immediately; 0.62
596
+ would be one pass but a 277k cut. `high`, `low`, `protect`, `maxPerBatch`, `minAction`, `setpoint`,
597
+ `standard`, and Coo were not changed.
598
+
599
+ Tests: `test/policy.test.mjs` now has 14 cases, including exact-96% band-high and a just-over-96%
600
+ emergency that must leave `after ≤ high − 0.05W`; the suite is 46 tests total. The policy pin also
601
+ makes the value visible in `state.json` after a remount even while the cached module still carries
602
+ 0.88. Live check on the 5276 Host after the 0.2.1 pin remount: `engine.policy.emergencyTo = 0.80`,
603
+ `tools.sessionLog.name = session_log`, continuity `sessionLog: true` / `convention: true` /
604
+ `toolCount: 33`, `sessionQuery.events = 4776`; `plugin.version` is still `0.2.0` because the host
605
+ row's package.json require is cached, so a full restart is needed to display `0.2.1`. The 0.2.1 tgz
606
+ carries the new version, code, pin, and docs.
607
+
608
+ ## 16. 0.2.3 — surface stats, A4-safe, summary view
609
+
610
+ ### Stats口径
611
+
612
+ `session_log stats` now defaults to `scope=surface`, reads `session.surface.nodes` on the live
613
+ session, and uses `tokenMeter.measure(session)` for route-priced tokens:
614
+
615
+ - `surfaceTokens` is the model-visible message surface; `baseline` (system/tool schema/request
616
+ overhead) is reported separately; `totalTokens = surfaceTokens + baseline`.
617
+ - Categories: `reasoning`, `tool_result`, `assistant_text`, `tool_call`, `user`, `system`,
618
+ `developer`, `gist` (raw only).
619
+ - `tool_result` is `tool/result` content; `tool_call` is assistant tool-call arguments; the
620
+ `tool/call` marker is not double-counted. Within an assistant event, the event's route-priced
621
+ token count is split across reasoning/text/tool-call by chars, so those three token rows are a
622
+ split of an exact event total.
623
+ - `tools_schema` is a separate baseline row (33 tools / 31,115 chars / ~7,779 tokens in the
624
+ 2026-10-26 session).
625
+ - `tail` walks `tokenMeter` nodes from the end to `protect × W`; `protect 0.30` is a fraction
626
+ (237,773 tokens at W=792,576), not 30 tokens. `scope=raw` remains available and is labelled
627
+ `chars4 estimate`; it includes historical/shadowed content and must not be read as the context.
628
+
629
+ Measured on `session-0b094f6e…` before the A4 change:
630
+
631
+ - current surface (584 nodes, 1,251,664 text chars): assistant 86.8%, tool result 8.8%, user 3.9%,
632
+ system 0.5% by chars; block proxy reasoning ~78%.
633
+ - the last cache-first pass (183,383 tokens, 728,081 chars): reasoning 80.0%, tool result 11.0%,
634
+ tool-call 4.4%, user 2.7%, assistant text 1.9%; 46% of the reasoning was in assistant messages
635
+ that also carried a tool call.
636
+ - 10 v4 sessions: tool result 8.8–81.6% of surface chars, aggregate 31.0%; assistant 66.0%, user
637
+ 1.8%. The Continuity session is reasoning-heavy; coding sessions are tool-heavy.
638
+
639
+ ### A4-safe
640
+
641
+ - Active call ids: every `tool/call` in the current open turn plus every call with no matching
642
+ `tool/result`; pins are the assistant tool-call surface messages and their tool results.
643
+ - `applyToolPreviews` walks non-active `tool/result` nodes oldest-first, builds a 1,280-char
644
+ head/marker/tail preview, and estimates `estimateMessage(original) - estimateMessage(preview)`.
645
+ The target is **`low` (62%)** for `band-high` and **`emergencyTo` (80%)** for `emergency`, not
646
+ `high`: it applies the chosen previews even when they cannot cover the whole gap, and the
647
+ remaining pass then continues to that target even if the estimate is already below `high`.
648
+ Stopping at `high` was the 0.2.2 bug: it left the context hovering at 86% and invalidated the
649
+ prefix on every subsequent batch. The full compression now produces one 24%-W band per high
650
+ crossing.
651
+ - If the preview already reaches the target, no pass runs. If it cannot, the pass folds the oldest
652
+ user/reasoning only then; active pins stay full. `band-high` keeps the 190k pass cap, `emergency`
653
+ keeps its normal uncapped drain; the preview itself is not a pass and is not capped by
654
+ `maxPerBatch`.
655
+ - Applied previews append a `compaction/prune` shadow-price event and a replacement `tool/result`
656
+ with `surfaceOp: replace`; the raw event remains in the log and the marker cites
657
+ `session_log get seq=<n>`. User/assistant/reasoning text is untouched. Only text-only tool
658
+ results are previewed; mixed image/file results are skipped.
659
+ - Active pins count toward the `oldestRange` tail floor, so the pass stops before the oldest active
660
+ pin; if no range fits with an active pin, pressure compaction is skipped rather than folding it.
661
+ - `protect 0.30`, `high 0.86`, `low 0.62`, `maxPerBatch 190000`, `minAction 24000`, `setpoint 0.86`
662
+ are unchanged; A4 is a pre-pass preview, not a policy change.
663
+
664
+ ### B — summary view
665
+
666
+ `summarize()` copies `SummarizationInput.messages` and previews only `role: 'tool'` long text with
667
+ the same 1,280-char cap; human/assistant/reasoning messages stay verbatim. The preview marker cites
668
+ the gist header so the summary model can `session_log get` the range. This changes the summary
669
+ call's input, not the surface, tail, cache, or pass count. A4 and B overlap on the same tool
670
+ results: after A4 has previewed the pass range, B sees the already-short preview and adds little;
671
+ B remains the fallback when A4 is skipped and a quality layer for the gist.
672
+
673
+ ### Tests / version
674
+
675
+ `test/tool-preview.test.mjs` covers active call/pin detection, preview head/tail/marker,
676
+ non-text and short skips, frozen replacement, summary-view copy, and the low/emergency target
677
+ budget. `test/stats.test.mjs` covers block classification, token splitting, tail walking,
678
+ live-surface vs raw, and the tail output. 0.2.3 is 61 tests total (policy 14, provenance 8, range 9,
679
+ session-log 15, tool-preview 9, stats 6). The `session_log` tool injects `tokenMeter`; `state.json`
680
+ now also carries `context` and `previews` for verification.
681
+
682
+ ## 17. 0.2.4 — 3-turn active window, non-text handles, overflow pause
683
+
684
+ ### B: active window
685
+
686
+ `activeCallIds(events, { recentTurns: 3 })`: every call in the current open turn, plus every
687
+ unpaired call whose `turn` is in the last 3 turn numbers. Older unmatched calls are treated as
688
+ abandoned project debris and do not pin the tail. This replaces the 0.2.3 global unmatched set,
689
+ which could pin a very old interrupted call forever. Tests cover the 3-turn boundary.
690
+
691
+ ### D: non-text handles
692
+
693
+ At every `compactIfNeeded` entry (pressure and overflow), `handleNonTextResults()` walks the
694
+ surface, finds `tool/result` nodes with image/file/audio/video blocks, and replaces the whole
695
+ message with a text handle:
696
+
697
+ ```text
698
+ [tool result handle · seq=N · tool=... · call=... · types=image/png · name=... · bytes=...
699
+ path=...]
700
+ topic / function / importance / caveats / summary
701
+ original archived at seq=N; ...
702
+ ```
703
+
704
+ The payload remains in the raw log; it is not in the model context. `nonTextInfo()` is
705
+ deterministic. The semantic fields are produced by one auxiliary `summarize()` call with the
706
+ non-text content in a user message and a structured prompt; unsupported content or an auxiliary
707
+ failure falls back to metadata, with `importance: unknown` (or `high (error)`) rather than a
708
+ fabricated judgment. The handle keeps the same call id, so an active non-text result remains
709
+ active/pinned; a non-active handle may be folded later, with its summary preserved in the gist.
710
+ `session_log get` cannot re-inject binary; the handle carries the attachment path when present.
711
+
712
+ ### A: active-overflow pause
713
+
714
+ `context-overflow` is the only provider-confirmed path that may touch an active result. After D,
715
+ `handleOverflow()` finds the largest active tool result. If its estimated tokens are at least the
716
+ measured overage, it replaces it with a `[continuity pause]` text handle (same call id, still
717
+ active). The handle tells the instance to stop retrying/compressing, tell the user the context
718
+ window is too small, and ask for a larger one; the raw original is at `seq=N`. The base
719
+ `agent/request-error` listener sees the surface progress and retries once; if the result was not
720
+ the cause, the base overflow fallback runs. Normal pressure/band paths never detach an active
721
+ result.
722
+
723
+ ### C: raw overlap
724
+
725
+ `stats scope=raw` includes both a `compaction/summary` event and its replacement user message; the
726
+ raw gist/user shares can be inflated by roughly 10 points. This is archive-only and does not enter
727
+ model context; surface stats remain authoritative. The raw output now says
728
+ `historical; may overlap summaries; not context`.
729
+
730
+ Tests: 0.2.4 is 65 tests total (policy 14, provenance 8, range 9, session-log 15, tool-preview 13,
731
+ stats 6). `test/tool-preview.test.mjs` now covers the 3-turn boundary, non-text handle/pause text,
732
+ original immutability, and call metadata. The `state.json` FACTS retrieval row includes `stats`.
733
+
734
+ ## 18. 0.2.5 — handle re-preview fix, post-pass measurement, manual labels
735
+
736
+ ### A4 must not preview a handle/pause/preview
737
+
738
+ `isNonPreviewableBlocks(blocks)` rejects text containing `[tool result handle`,
739
+ `[continuity pause`, or `[... tool result preview`. `previewToolResultEvent` and
740
+ `previewSummaryInput` both return null/unchanged for those messages. Before 0.2.5, a D semantic
741
+ handle (~1,700 chars) could be truncated by A4; the summary/importance/path were cut and the A4
742
+ marker pointed at the handle's replacement seq instead of the original non-text event. Handles and
743
+ pauses are now terminal representations.
744
+
745
+ ### Post-pass measurement
746
+
747
+ After `compactRegion()` succeeds, the engine calls `measure()` again and writes `afterTokens`,
748
+ `afterSurfaceTokens`, `afterBaselineTokens`, and `rebloatTokens` into the pass record. The panel's
749
+ "after" is now a real measurement; `state.context` updates immediately. A real post-pass re-bloat is
750
+ visible without waiting for the next pressure trigger. `measure()` failure is recorded as a warning;
751
+ the pass record still exists.
752
+
753
+ ### Manual `/compact` labeling
754
+
755
+ `compactNow()` still delegates to the base manual cut (it can be much deeper than the 62% band), but
756
+ after it settles 0.2.5:
757
+
758
+ 1. appends a `[gist · log:…#seq=A-B · N messages · original retrievable via session_log]` header as
759
+ a plugin `user/message` after the summary;
760
+ 2. measures the post-pass surface;
761
+ 3. records a `recordPass` with `reason: 'manual'`, `budget: 0`, the header, and the after/re-bloat
762
+ fields.
763
+
764
+ The base summary itself is unchanged and still unlabeled; the follow-up header is the pointer. This
765
+ makes a manual cut visible in `passes` and retrievable by the model.
766
+
767
+ Tests: 0.2.5 is 66 tests total (policy 14, provenance 8, range 9, session-log 15, tool-preview 14,
768
+ stats 6). `test/tool-preview.test.mjs` adds the handle/pause/preview non-repreview case. The engine
769
+ integration (post-pass/manual header) is checked with `node --check` and the live/state path rather
770
+ than a base-engine unit test.
771
+
772
+ ## 19. 0.2.6 — 515k hard-cap policy
773
+
774
+ `CACHE_FIRST_POLICY` changes from the cache-first 86/62/96→80 band to the 515k hard-cap band:
775
+
776
+ ```text
777
+ setpoint 0.62 → 491,397 (46.9% full)
778
+ high 0.62 → 491,397
779
+ low 0.42 → 332,882 (31.7% full)
780
+ emergency 0.65 → 515,174 (49.1% full)
781
+ emergencyTo 0.45 → 356,659 (34.0% full)
782
+ protect 0.30 → 237,773
783
+ maxPerBatch 190000
784
+ minAction 24000
785
+ ```
786
+
787
+ The full context is 1,048,576; 515,174 is **49.1% full / 65% of W**. The emergency trigger is the
788
+ 515k cap, not the old 96% near-overflow line. `planFold` uses strict `>` for emergency: exactly
789
+ 0.65 still takes one band-high pass to low; only above 0.65 is the emergency path. `emergencyTo`
790
+ 0.45 is below high 0.62, so one emergency cannot chain into band-high.
791
+
792
+ `CACHE_GRADUAL_POLICY` keeps the same water marks with `setpoint: 0.50` for a 50–62% proportional
793
+ zone (the old 0.72 setpoint is invalid above high 0.62).
794
+
795
+ Tests remain 66; `test/policy.test.mjs` now asserts the 61/62 no-op boundary, the 0.64 band-high
796
+ drain, the 0.65 still-band-high boundary, the 0.45 emergency target, and the 0.50 gradual setpoint.
797
+ The preset pins are `setpoint: 0.62` and `emergencyTo: 0.45` in `cordis.patch.yml`; the other
798
+ fields inherit from `src/policy.mjs`.
799
+
800
+ ## 20. 0.2.7 — 92% high/emergency ratio
801
+
802
+ 0.2.7 keeps the 515k emergency cap and moves high/low down:
803
+
804
+ ```text
805
+ setpoint 0.60 → 475,546 (45.4% full)
806
+ high 0.60 → 475,546
807
+ low 0.40 → 317,030 (30.2% full)
808
+ emergency 0.65 → 515,174 (49.1% full)
809
+ emergencyTo 0.42 → 332,882 (31.8% full)
810
+ protect 0.30 → 237,773
811
+ maxPerBatch 190000
812
+ minAction 24000
813
+ ```
814
+
815
+ `high / emergency = 0.60 / 0.65 = 92.3%`, leaving **39,628 tokens (5% W)** between the normal
816
+ trigger and the hard cap. The previous 0.62/0.65 ratio left only 23,777 (3% W), below
817
+ `minAction 24k`; 0.2.7 crosses that structural boundary. The band is still 158,515 (20% W), and the
818
+ emergency band is 182,292, within `maxPerBatch`.
819
+
820
+ The exact boundary is unchanged: at exactly 0.65 the pass is still band-high and is capped at
821
+ 190,000, so it may take two passes to reach 0.40; above 0.65 the emergency pass ignores the cap and
822
+ lands at 0.42.
823
+
824
+ `CACHE_GRADUAL_POLICY` remains `setpoint: 0.50`; its proportional zone is now 50–60% (valid below
825
+ high 0.60). The preset pins are `setpoint: 0.60` and `emergencyTo: 0.42`.
826
+
827
+ Tests: 66 total. Policy tests assert the 59/60 no-op boundary, the 0.62 band-high drain, the
828
+ 0.65 band-high cap, the 0.42 emergency target, and the 0.50 gradual setpoint.
829
+
830
+ ## 21. 0.3.0 — explicit work threads
831
+
832
+ ### State
833
+
834
+ `~/.dsh/continuity/threads.json`, partitioned by session:
835
+
836
+ ```json
837
+ {
838
+ "version": 1,
839
+ "sessions": {
840
+ "<session>": {
841
+ "activeId": "t-…",
842
+ "threads": [{
843
+ "id": "t-…", "label": "…", "why": "…", "next": "…",
844
+ "seqStart": 1, "seqEnd": 2, "seqs": [1, 2], "count": 2,
845
+ "spanStart": "…", "spanEnd": "…", "minutes": 1, "tokens": 2,
846
+ "residue": "…", "archiveRef": "log:<id>#seq=1-2",
847
+ "gistSeq": 3, "compactionId": "…", "active": false,
848
+ "loadSeq": 4, "injected": 2, "loadTokens": 2
849
+ }],
850
+ "pending": { "kind": "save" }
851
+ }
852
+ }
853
+ }
854
+ ```
855
+
856
+ Writes are atomic (`threads.json.<pid>.<time>.tmp` → rename) and serialized. The rules file
857
+ `~/.dsh/continuity/rules.md` is read-only; it is never created, written, or defaulted. A missing
858
+ file means no rules.
859
+
860
+ ### Tool
861
+
862
+ A single `continuity` tool with actions `save`, `load`, `active`, `list` (provider function names
863
+ cannot contain dots; these are the contract's `continuity.save/load/active/list`). `save` requires
864
+ `label`, `why`, and `next`; `load` requires `id`; `budgetTokens` defaults to 8,000 and is capped at
865
+ 20,000.
866
+
867
+ ### Save: validate, defer, commit
868
+
869
+ `prepareSave()` runs in the tool call and writes a `pending` record; it does not append. It:
870
+
871
+ 1. accepts only surface seqs, never the system head, never a gist/thread node, never a node at or
872
+ after the current `turn/start`;
873
+ 2. requires both endpoints to be `toolPairingBalancedBefore/After`; a cut is rejected with the seq;
874
+ 3. defaults to after the latest gist and before the current open turn, walking inward to balanced
875
+ edges (`adjusted: true` when it had to move);
876
+ 4. prices the range, computes the real-clock span/minutes, and creates the thread id/archiveRef.
877
+
878
+ At the next pre-step, after the tool result, `saveThread()` calls the inherited `compactRegion()`
879
+ with a one-shot thread summary. The base transaction appends `compaction/start`, the summary, the
880
+ replacement `user/message` with `surfaceOp` and `sourceEventSeqs`, and `compaction/end`. The engine
881
+ then appends a `compaction/prune` price marker, records the thread, clears `activeId`, and clears
882
+ `pending`. This is the same host validation as an automatic gist: the raw range is never rewritten,
883
+ and the fold is append-only.
884
+
885
+ The gist body starts with `[thread] … 下一步:…` and carries a second `[gist · log:…]` line so
886
+ `session_log index` still recognizes it.
887
+
888
+ ### Load: quote the newest raw segment
889
+
890
+ The tool reads `sessionQuery.readSession`, resolves the thread's exact `seqs` (surface order, not a
891
+ numeric range), and selects newest-to-oldest until `budgetTokens`; the last event is always included,
892
+ so the count is a lower bound. It writes a pending load and sets `activeId`, without appending. At
893
+ the next pre-step the engine appends one normal `user/message`:
894
+
895
+ ```
896
+ [放] <label> · 现在算在「<id>」这条线上 · 下一步:<next> · 上次收:<why> · 存档:<ref>
897
+ —— 以下是从原文取回的最后一段 ——
898
+ [<role>] <raw text>
899
+ ```
900
+
901
+ No original node ids are copied. Missing raw, or a missing seq, produces the index-only line and
902
+ `{ ok:false, reason:"raw-missing", active:true, pending:true }`; it does not invent text.
903
+
904
+ ### Per-turn injection and pins
905
+
906
+ At the first pre-step of each turn (`agent.lastTurn`, else the latest `turn/start` seq), the engine
907
+ appends one `user/message` with:
908
+
909
+ ```
910
+ [线] 当前:<label>(已载入)· 下一步:<next>
911
+ 已收起 N 条:<labels>
912
+ <rules.md verbatim, ≤400 estimated tokens>
913
+ ```
914
+
915
+ This is after the surface tail, never in the system/tool prefix; it is a user message because the DSH
916
+ surface has no developer role. Rules are re-read every turn; >400 tokens is truncated with a warning.
917
+ The line is capped at 200 estimated tokens. The same turn is injected once; the next turn re-reads.
918
+
919
+ After a load, `loadSeq` joins the active tool pins. `oldestRange()` counts from the oldest pin to the
920
+ end as the tail floor, so automatic compaction stops before the loaded quote. A save clears
921
+ `activeId`, making the old thread foldable.
922
+
923
+ Stage switches live in `continuity-engine.config.threads`: `inject` (stage 2) and `pin` (stage 3),
924
+ both default true. Stage 1 (tool + state) is always present. Disabling one stage does not rewrite
925
+ the session log; disabling the tool row returns to 0.2.7 behavior and leaves `threads.json` inert.
926
+
927
+ ### Why the commit is deferred
928
+
929
+ Appending a gist/quote inside the save/load tool call would put a `user/message` between the
930
+ assistant tool-call and its tool result, exactly the pairing failure described in
931
+ `CONTINUITY-THREADS.md` trap 1. The tool therefore returns `pending:true`; the next pre-step commits.
932
+
933
+ Tests: 84 total (threads adds 18). `test/threads.test.mjs` covers explicit cut rejection, current-turn
934
+ exclusion, gist start, default balanced adjustment, load budget/missing raw, active/list/pins,
935
+ A→B→A switching, per-session persistence, read-only rules, the `makeApi()` facade, tool registration,
936
+ and the no-append pending path.
937
+
938
+ ## 22. 0.3.1 — producer-owned source kind hotfix
939
+
940
+ Live 0.3.0 failed its first turn with:
941
+
942
+ ```
943
+ format v4 message requires a producer-owned source kind
944
+ ```
945
+
946
+ The thread injection and load quote used `source: { kind: "plugin", plugin: "dsh-continuity" }`.
947
+ The v4 session format only accepts producer-owned source kinds; an unregistered plugin name is not
948
+ one (the base compaction checkpoint uses the registered `plugin: "compact"` marker). The rejected
949
+ append aborted the turn before an assistant message.
950
+
951
+ 0.3.1 changes every injected message to `source: { kind: "user" }`:
952
+
953
+ - active/rules line (`injectThreadContext`);
954
+ - load quote (`processPendingThread`);
955
+ - manual `/compact` follow-up gist header (`compactNow`);
956
+ - the semantic-summary user input (`semanticSummaryFor`).
957
+
958
+ The `[thread]` gist replacement itself is still created by the base `compactRegion` transaction with
959
+ `compactCheckpointSource` (`plugin: "compact"`), so the gist provenance is unchanged.
960
+
961
+ Tests: 85 total (threads 19). Added `contextMessage` source-kind assertion. Reinstall 0.3.1 and
962
+ restart; the failed turn itself left no invalid event in the raw log (the append was rejected before
963
+ persistence), so no session repair is needed.
964
+
965
+ ## 23. 0.3.2 — post-replacement prune removal and runtime-context source
966
+
967
+ ### The corrupt prune
968
+
969
+ 0.3.0/0.3.1 `saveThread()` appended this after `compactRegion()`:
970
+
971
+ ```js
972
+ session.append('compaction/prune', {
973
+ shadowedRange: { start: draft.seqStart, end: draft.seqEnd },
974
+ shadowedSeqs: [...draft.seqs],
975
+ shadowedTokenCount: result.shadowedTokenCount,
976
+ });
977
+ ```
978
+
979
+ By then the summary replacement had already shadowed that range. v4 replay rejected it:
980
+
981
+ ```
982
+ compaction/prune shadowedSeqs do not name an exact current surface span
983
+ ```
984
+
985
+ 0.3.2 removes the append. The base `compaction/summary` already records
986
+ `shadowedRange`/`shadowedSeqs`/`shadowedTokenCount`; A4's prune remains immediately before its own
987
+ tool-result replacement, where its single seq is still current surface.
988
+
989
+ ### Live repair
990
+
991
+ The invalid event was live session seq 10762 (the save at seq 10755). The original raw log was
992
+ backed up byte-for-byte before repair:
993
+
994
+ ```
995
+ backups/session-0b094f6e-…-20261027-221254.v4.jsonl.zstd
996
+ SHA256 14BD80428DDABCB3A6F1EEE67AD21D94B21B7E51AF61F1C5543759A106E38BEB
997
+ ```
998
+
999
+ The repair keeps the event and its seq (no renumbering) and retargets the prune to the replacement
1000
+ checkpoint seq 10760 with `shadowedTokenCount: 0`; the replacement is a current surface node, so the
1001
+ format check passes and the price is a no-op.
1002
+
1003
+ The first repair decompressed all 5,939 zstd frames and recompressed them as **one** frame. That
1004
+ kept the JSONL but merged frame 0 — the **session header** — with the events. The session store
1005
+ reads frame 0 as the header, so the session disappeared from the list. The final repair preserves
1006
+ the frame structure: every original frame is copied byte-for-byte except the one frame containing
1007
+ seq 10762; frame 0 is untouched.
1008
+
1009
+ ```
1010
+ source frames 5,939
1011
+ repaired frames 5,939
1012
+ frame 0 {"type":"session",…}
1013
+ events 10,910
1014
+ invalid prunes 0
1015
+ surface tail 10884…10906
1016
+ size 10,868,579
1017
+ SHA256 92159A24E888CB412D1D45A8CFC5ED5FBFA03CB84F8FA5A4F1F0A1BD8057995A
1018
+ ```
1019
+
1020
+ The live file now has that frame-preserving hash; the original and the intermediate single-frame
1021
+ repaired copy remain in `backups/` for forensics. The `.repaired.v4.jsonl` is the full logical log.
1022
+
1023
+ ### Source kind
1024
+
1025
+ 0.3.1's `{kind:"user"}` passed v4 but rendered the per-turn rules as a human chat bubble. 0.3.2 uses
1026
+ the registered runtime-context source:
1027
+
1028
+ ```json
1029
+ { "kind": "runtime-context", "form": "snapshot",
1030
+ "sections": [{ "name": "continuity:threads", "text": "…" }] }
1031
+ ```
1032
+
1033
+ The content and section text are identical. The UI renders it as a context block, not as the user
1034
+ typing. The semantic-summary input remains an in-memory `{kind:"user"}` message and is never
1035
+ persisted.
1036
+
1037
+ Tests: 85 total (threads 19). The engine smoke additionally asserts that `saveThread` writes no
1038
+ `compaction/prune` at all.
1039
+
1040
+ ## 24. 0.3.3 — self-authored rules attribution
1041
+
1042
+ 0.3.2 already made the injection **once per turn**, keyed by `agent.lastTurn` / the latest
1043
+ `turn/start` seq (`threadInjectTurn`), not once per model step. It is a `runtime-context` snapshot,
1044
+ not a `user/message`.
1045
+
1046
+ 0.3.3 adds an explicit attribution between the active line and the rules:
1047
+
1048
+ ```text
1049
+ [习惯] 下面是你自己写在 ~/.dsh/continuity/rules.md 的习惯,不是用户的新指令,也不是系统要求;
1050
+ 它不是任务,你可以按自己的判断采用、忽略或改写它。
1051
+ ```
1052
+
1053
+ `threadContextText(line, rulesText)` builds the block; `RULES_ATTRIBUTION` is the constant. This
1054
+ matters because the rules file is authored by the instance, not by the human: the model should not
1055
+ read it as a fresh external demand or a system invariant. The plugin still never writes the file.
1056
+
1057
+ The block is capped at 200 estimated tokens for the line and 400 for the rules; the whole block is
1058
+ appended after the surface tail, so it does not touch the static prefix. If the instance still feels
1059
+ the block is heavy, `continuity-engine.config.threads.inject: false` disables stage 2 while leaving
1060
+ save/load/active/list and the rules file intact.
1061
+
1062
+ Tests: 86 total (threads 20). Added the attribution/source assertions.
1063
+
1064
+ ## 25. 0.3.4 — attach a save to an existing thread
1065
+
1066
+ `save` gains optional `threadId`. The engine never decides whether two ranges belong together; the
1067
+ caller/rules decide and pass the id.
1068
+
1069
+ - Omitted: new thread, generated id, `activeId: null` (unchanged).
1070
+ - Known: the range becomes a new segment of that thread and `activeId` points at it.
1071
+ - Unknown: `continuity.save: unknown threadId <id>` before the range is priced/compacted. There is no
1072
+ pending, gist, or state write.
1073
+
1074
+ ### Shape
1075
+
1076
+ `thread.segments[]` holds `{fromSeq, toSeq, seqs, count, tokens, why, next, residue?, at, spanStart,
1077
+ spanEnd, minutes, archiveRef, gistSeq?, compactionId?}`. Thread-level `why/next/seqs/count/tokens/
1078
+ archiveRef/gistSeq` follow the **latest** segment; `seqStart`/`spanStart` stay the first segment and
1079
+ `seqEnd`/`spanEnd` the latest. `tokens` is the thread total. Old entries without `segments` normalize
1080
+ to exactly one segment in `threads-store.getSession()` and are rewritten with segments on the next
1081
+ `updateSession()`; no old thread is dropped.
1082
+
1083
+ ### Receipts
1084
+
1085
+ ```
1086
+ saved as T5 · seq 1200..1310 · 12.3k tokens
1087
+ saved into T3 · segment 2 · seq 1310..1420 · 9.8k tokens
1088
+ ```
1089
+
1090
+ The tool result also carries `mode`, `segment`, `segments`, and `active`. The durable gist is still
1091
+ committed at the next pre-step; no post-replacement prune.
1092
+
1093
+ ### list / active / load
1094
+
1095
+ `listView`/`activeView` add `segments`, `lastAt`, and `display`
1096
+ (`T3 · 2 段 · 最近 21:40`). `load` keeps whole-thread semantics but quotes the latest segment,
1097
+ carries `segment`/`segments`, and its header says `第2/2段`; the index-only fallback does the same.
1098
+
1099
+ Tests: 89 total (threads 23): new, append, unknown-id-no-write, old one-range compatibility, and the
1100
+ existing load test now uses two segments and asserts the latest. The real-engine smoke performs two
1101
+ saves through the Core, commits through the engine, and asserts one thread with two segments,
1102
+ `activeId` on it, the append receipt, and unknown-id no-write.
1103
+
1104
+ ## 26. 0.3.5 — scan and range/select load
1105
+
1106
+ ### scan
1107
+
1108
+ `continuity.scan({fromSeq, toSeq, headChars?, includeReasoning?, types?, limit?})` is read-only and
1109
+ does not inject. It returns:
1110
+
1111
+ ```json
1112
+ [{ "seq": 1030, "role": "assistant", "type": "message",
1113
+ "head": "**能出图。** …", "chars": 1190, "tokens": 605 }]
1114
+ ```
1115
+
1116
+ Defaults: `types:["message"]` (categories are `message`, `tool-result`, `tool-call`, `gist`,
1117
+ `compaction`, `turn`, `step`, `request`; raw event types and roles are also accepted), reasoning
1118
+ excluded, synthetic `runtime-context`/`compact-checkpoint` messages skipped, 160-char heads (max
1119
+ 200), at most 20 items, and a payload budget of ~900 estimated tokens. `includeReasoning` restores
1120
+ reasoning blocks. It never summarizes and never decides importance.
1121
+
1122
+ Live check on the actual avatar range:
1123
+
1124
+ ```text
1125
+ scan(958, 1050): 10 items, 787 estimated tokens, every head <= 160 chars, no [reasoning]
1126
+ ```
1127
+
1128
+ The 10 items include user/assistant text and assistant tool-call heads; `types:["user/message"]`
1129
+ narrows further. This is deliberately a list, not a judgment.
1130
+
1131
+ ### load range/select
1132
+
1133
+ `continuity.load` accepts, alongside `id`/`budgetTokens`:
1134
+
1135
+ - `fromSeq`/`toSeq`: both or neither; restrict the thread's seqs to the interval;
1136
+ - `select:[seqs]`: exactly these seqs, usually from scan; not tail-truncated;
1137
+ - both: the select must lie inside the range.
1138
+
1139
+ The quote uses `eventText(..., {includeReasoning:false})`; reasoning-only events are skipped. The
1140
+ result adds `segs` (the seqs actually injected), `range`, and `select`; `injected`/`tokens` describe
1141
+ the same set. An explicit select that is outside the thread or outside the range returns
1142
+ `seq-not-in-thread` / `select-outside-range` without writing pending state. The default no-range
1143
+ call still quotes the latest segment; the old object shape and the 8k budget are unchanged.
1144
+
1145
+ The pending text says either `第N/M段`, `范围 A..B`, or `范围 A..B · 选择 N 条` (or `选择 N 条`),
1146
+ so the receipt says which part was used. Tests: 93 total (threads 27), including reasoning-free scan,
1147
+ scan cost <1k, exact select with `segs`, default old-shape load, range load, and rejection paths. The
1148
+ real-engine smoke also does a range/select load and a scan after the two saves.
1149
+
1150
+ ## 27. 0.3.6 — tags sidecar (find / scan / load)
1151
+
1152
+ ### tags.json
1153
+
1154
+ ```
1155
+ ~/.dsh/continuity/tags.json
1156
+ { version, tags, runs: [[from,to,id], …], overrides: { "<seq>": id }, updatedAt }
1157
+ ```
1158
+
1159
+ `resolveTag(seq)` = override → last containing run → `T0`. Runs may overlap; later runs win.
1160
+ Missing and corrupt both return the empty T0 state, set `missing`/`corrupt` on the read result, and
1161
+ never throw. `readTags`/`updateTags` are serialized/atomic; `tags.json` is the only new file and the
1162
+ session log is never written by the tag layer.
1163
+
1164
+ ### find / tag / retag / tags
1165
+
1166
+ `find({id, after?, before?, limit?})` returns `{seqs, count, first, last, span}` and defaults to
1167
+ limit 20. `seqs` come from the message universe (user/assistant, synthetic runtime/checkpoint
1168
+ skipped) resolved through the tags plus exact overrides. `after`/`before` are inclusive seq bounds;
1169
+ `span` uses the first/last event `time`, or null. `tag({seqs,id})` writes overrides;
1170
+ `retag({from,to})` moves all runs and overrides and drops the old tag; `tags()` reports
1171
+ label/status/runs/overrides/lastSeq. The default find payload is <200 tokens; the scan list keeps its
1172
+ ~900-token budget, so 50 turns stay under 2k.
1173
+
1174
+ ### scan / load
1175
+
1176
+ `scan` accepts `seqs` as an alternative to `fromSeq`/`toSeq`; all other defaults are unchanged.
1177
+ `load` accepts:
1178
+
1179
+ - a **thread id**: old latest-segment/range/select behavior; sets active;
1180
+ - a **tag id**: `find(id)` and take earliest 3 + latest 5, visible/non-reasoning, then trim the
1181
+ largest events until the budget is met; `active` is unchanged;
1182
+ - `seqs:[...]`: exact visible seqs (no tail truncation); `active` is unchanged.
1183
+
1184
+ Tag/seqs loads return `segs`; their pending text starts `[取]` and says `默认最早3+最新5` or
1185
+ `按指定 seqs`. `load` with a tag and `fromSeq/toSeq/select` is rejected (use `seqs`). The old
1186
+ `load(threadId, budgetTokens)` test remains green.
1187
+
1188
+ ### Auto tag
1189
+
1190
+ At each pre-step, `autoTagCompletedTurn` finds the latest completed `turn/start..turn/end` range and
1191
+ writes one run if that exact range is absent. T is the active thread id, or `T0`; label is the thread
1192
+ label or `未分类`. This is sidecar-only and never appends to the raw log. A missing/corrupt tags file
1193
+ is recovered with one warning.
1194
+
1195
+ Tests: 104 total (threads 27, tags 11), including resolve precedence, missing/corrupt fallback,
1196
+ find/filters/span/empty, tag+retag immediacy, tool actions, tag/seqs loads, scan seqs, 50-turn cost,
1197
+ budget trim, and the real-engine smoke's thread-tagged turn, idempotent second auto-tag, and T0
1198
+ fallback.
1199
+
1200
+ ## 28. 0.3.7 — tag fixes from live use
1201
+
1202
+ ### P1 tag loads must not move active
1203
+
1204
+ `processPendingThread` used to treat every `pending.kind === 'load'` as a thread load and set
1205
+ `activeId = pending.id`, so `load({id:"T2"})` made the next `[线]` say “当前:无”. 0.3.7 gates the active
1206
+ update on `pending.mode`: `thread` (or legacy no-mode) moves the pointer; `tag`/`seqs` only append the
1207
+ quote and clear `pending`. `tags()` rows include all threads, with `active` from the real `activeId`.
1208
+
1209
+ ### P2 find(T0) is the complement
1210
+
1211
+ `find({id:"T0"})` now returns `{seqs:[], ranges, count, first, last, span, complement:true}`, where
1212
+ `ranges` are the contiguous seq spans whose resolved tag is T0 (explicit T0, untagged, and T0
1213
+ overrides inside other tags). The span is 0..max event seq (or max run/override); `limit` caps the
1214
+ number of ranges, not the total count. T2/T-n find remains the message seq list. So T2=[958,1330]
1215
+ yields T0 ranges [0,957] and [1331,max], never 958–1330.
1216
+
1217
+ ### P3/P4 names and labels
1218
+
1219
+ `tags()` rows are `{id, label?, display, count, runs, overrides, lastSeq, active, ...}`; `display` is
1220
+ the label, else the first 8 chars of the id. `tag({seqs,id,label?})` creates/updates the label. The
1221
+ auto tag keeps an existing real label and replaces an id-placeholder label with the thread label.
1222
+ Tag load receipts use `selectionLoadText` with one display name, so `[取] Avatar` rather than
1223
+ `[取] T2 · T2`.
1224
+
1225
+ ### P5/P6 find bounds and small items
1226
+
1227
+ `after` is strict `seq > after`; `before` is strict `seq < before`; `limit` is stable ascending.
1228
+ Empty tags return count0. Missing/corrupt tags fall back to T0 and warn. `tag` writes update
1229
+ `updatedAt` (and the test asserts it).
1230
+
1231
+ Tests: 108 total (threads 27, tags 15). The engine smoke additionally loads a tag while a thread is
1232
+ active, proves `activeId` and the active line stay, checks the tag row’s `active:false` and the
1233
+ thread row’s `active:true`, and verifies the no-duplicate receipt.
1234
+
1235
+ ## 29. 0.3.8 — tag ranges
1236
+
1237
+ `tag` accepts `{fromSeq,toSeq,id,label?}` as well as `{seqs,id,label?}`. The range form appends one
1238
+ run `[from,to,id]` (the same record the auto-tagger writes) and returns
1239
+ `{ok:true,id,range:[from,to],count:to-from+1}`. It does not write per-seq overrides; the existing
1240
+ `seqs` form still writes overrides and its behavior is unchanged. Per-seq overrides still beat any
1241
+ run, and later runs still beat earlier runs. Adjacent runs are not merged.
1242
+
1243
+ `find` now also returns coverage for a tag: `ranges` (contiguous seq spans where resolve(seq) === id),
1244
+ `covered` (their total length), and `rangeFirst`/`rangeLast`/`rangeSpan`. This makes a range tag cover
1245
+ tool events as well as messages, while `seqs` remains the message list. `find(T0)` remains the
1246
+ complement with `seqs:[]` and `ranges`.
1247
+
1248
+ `untag({fromSeq,toSeq})` appends a T0 run and deletes overrides in the range, returning
1249
+ `{range,count,removedOverrides}`. The tool exposes `untag`; `retag` moves the whole run unchanged.
1250
+
1251
+ Tests: 110 total (threads 27, tags 17). Coverage includes a range run with override priority,
1252
+ tag→find over the whole segment, retag of a run, untag clearing the range, no adjacent-run merge,
1253
+ tool range/untag, and the earlier P1–P6 cases. No raw/gist/save changes.
1254
+
1255
+ ## 30. 0.3.9 — tags() count semantics
1256
+
1257
+ The 0.3.7/0.3.8 `tags()` row used `count = runs + overrides`; a thread tag with one run over ten
1258
+ thousand events displayed `count: 108`-style entry counts. 0.3.9 computes actual resolved coverage
1259
+ once over `0..maxSeq` (max of session events, run ends, and override keys) and exposes:
1260
+
1261
+ - `entries`: runs + overrides (the old number);
1262
+ - `events`: seqs whose resolved tag is this id;
1263
+ - `count`: alias of `events` (kept for the existing field name, now actual events).
1264
+
1265
+ T0’s `events` is its complement; thread rows without a run keep events 0. The `tags` action reads the
1266
+ session max seq through its existing `sessionQuery` so the T0/tail counts are over the whole span.
1267
+ No other tag/load/save behavior changes.
1268
+
1269
+ Tests remain 110 total (tags 17); the assertions now check `entries`/`events`/`count` on a range run
1270
+ and an override tag.
1271
+
1272
+ ## 31. 0.4.0 — fold provenance, aliases, tags.md, first/last, slim injection
1273
+
1274
+ - **Gist T.** `gistHeader` accepts `t` and renders `T=...` inline. The engine computes it with
1275
+ `tagCoverageForRange(tags, from, to)` (resolve per seq, counts sorted desc), for pressure, manual, and
1276
+ thread-save gists. `threadGistText` renders the segment's `tCoverage` and `thread=<segment index>`.
1277
+ - **Folds.** The engine records each compacted shadowed range and summary seq in
1278
+ `~/.dsh/continuity/folds.json` per session. `find` splits its message `seqs` into `visible`/`folded`;
1279
+ T0 also reports empty visible/folded and `covered`. A load selecting a folded seq prepends
1280
+ `[已折叠于 gist seq=N]`; the seqs are also returned as `folded`/`visible`.
1281
+ - **Alias.** Threads accept `alias` on save (new or append). Auto-tag writes the alias as T when
1282
+ present; `tags()` display is alias → label → short id; the alias row is active. tags.md shows
1283
+ symbol → thread id.
1284
+ - **tags.md.** `tags.json` entries accept `keywords`. Every tag/untag/retag plus alias save and auto
1285
+ tag regenerates `tags.md` via `renderTagsMd`; the file is never read. Hand edits are overwritten.
1286
+ - **first/last.** Tag loads accept `first`/`last` (default 3/5); `first:0` or `last:0` means omit that
1287
+ end; both zero is rejected. Thread/seqs loads reject first/last. The receipt text says the actual
1288
+ selection.
1289
+ - **Injection.** The runtime-context block is `[线]` + `[标签] 当前 <alias>(label)· 另有 N 条`, then
1290
+ `[习惯] <H2 headings> · 全文 ~/.dsh/continuity/rules.md`. The full rules text is no longer injected.
1291
+
1292
+ Tests: 114 total (threads 27, tags 21). The key cases are gist T pure/cross, find folded/load
1293
+ annotation, first/last, tags.md overwrite, alias display/active, and the compact habit line. No save/
1294
+ load segment semantics, 8k fallback, or tool-pairing behavior changed.
1295
+
1296
+ ## 32. 0.4.1 — metadata-only tag, tag alias, explicit-only T0 counts
1297
+
1298
+ - `tag` accepts no seqs/ranges when at least one metadata field is present:
1299
+ `tag({id,label})`, `tag({id,keywords})`, `tag({id,alias})`. It updates tags.json, advances
1300
+ `updatedAt`, writes tags.md, and returns the updated tags() row. No run/override is written, so no
1301
+ seq changes hands.
1302
+ - Tag records now accept `alias` (and `tag({id,alias})` also sets the matching thread record’s alias).
1303
+ `tags()` rows include alias; display is tag/thread alias → label → short id; the alias row is active;
1304
+ auto-tag writes the thread alias; tags.md’s symbol column is `alias ?? id`.
1305
+ - T0 counting was initially explicit-only in 0.4.1; 0.4.2 replaces that with the complement so it
1306
+ matches `find(T0).count` (see §33).
1307
+ - Existing `tag({seqs})` and `tag({fromSeq,toSeq})` keep their behavior; they may additionally carry
1308
+ alias/label/keywords.
1309
+
1310
+ Tests: 116 total (threads 27, tags 23). New coverage: metadata-only label/keywords/alias, tag alias
1311
+ setting the thread alias and active row, tags.md symbol, and the smoke path tag alias → auto T9 →
1312
+ `[标签] 当前 T9`.
1313
+
1314
+ ## 33. 0.4.2 — unified T0 counts and display-only gist T
1315
+
1316
+ - `tags()` T0 `events` is the complement seq count over 0..max, equal to `find(T0).count` (including
1317
+ folded seqs). Other tags’ `events` equal `find(id).covered`. `tags.md` renders the same row value.
1318
+ `entries` remains runs+overrides (sidecar records, not messages); `count` is an alias of `events`.
1319
+ A fold does not alter tags, maxSeq, or these numbers.
1320
+ - `tagCoverageForRange(tags,from,to,{threads})` returns display labels: alias → label → short id,
1321
+ excludes T0 when any non-T0 tag is present, uses T0 only for an all-T0 range, sorts by event count,
1322
+ keeps at most three, and appends `+N`. `threadGistText` and `gistHeader` consume that label; the
1323
+ field order remains `thread=… · T=…`.
1324
+ - No changes to find’s complement/ranges, tags() display/keywords, tags.md generation, or save’s
1325
+ segment merge/pending/8k behavior.
1326
+
1327
+ Tests still 116 (threads 27, tags 23): the T0 test now asserts `tags().T0.events === find(T0).count`
1328
+ before/after a fold, and the gist test asserts display, T0 omission, three-tag cap, and thread/T field
1329
+ order.
1330
+
1331
+ ## 34. 0.4.3 — canonical alias identity and find count/page
1332
+
1333
+ - Alias is display only. `tag`, auto-tag, run writes, and find/load resolve alias → canonical thread id.
1334
+ Auto-tag writes `active.id`; the tag entry may carry `alias` for display. `load({id:"T3"})` canonical
1335
+ resolves to the thread, not a second tag.
1336
+ - Migration: on tags/find/load/tag/auto, `migrateAliasTags` moves every alias-named run/override into
1337
+ the canonical thread tag, ensures the canonical alias/label, deletes the alias tag record, and writes
1338
+ tags.md. So `tags()` has one T3 row, `find(T3)` equals `find(threadId)`.
1339
+ - `find` count semantics: `count` = total covered events for the tag (including folded), independent
1340
+ of `limit`; `seqs` = this page (`seqs.length <= limit`); `covered` = `count`. T0’s count is the
1341
+ complement. The tags()/tags.md `events` use the same total.
1342
+ - Untouched: `ranges`/`complement`/`visible`/`folded`, tags() display/keywords, tags.md generation,
1343
+ save merge/pending/8k.
1344
+
1345
+ Tests: 117 (threads 27, tags 24). New coverage: alias migration to canonical, find(T3) ≡ find(threadId),
1346
+ load(T3) → thread, tag(T3) writes canonical, count stable across limit, and the auto-tag canonical smoke.
1347
+
1348
+ ## 35. 0.4.4 — default save clamps to current T; primary-only gist T
1349
+
1350
+ - Default save (`fromSeq`/`toSeq` omitted) still uses the latest-200-message ceiling, but additionally
1351
+ clamps its start to the first surface node at/after the start of the tag run containing the selected
1352
+ end. If the run is shorter, the save is shorter. Explicit from/to is passed through unchanged. This
1353
+ is selection only; pairing, the 8k fallback, and folding math are unchanged.
1354
+ - `tagCoverageForRange` now returns one primary display (largest event count) plus the number of other
1355
+ non-T0 events, e.g. `T3 +2`; no comma list. Display is alias → short label → short id, one form only.
1356
+ All-T0 stays `T0`. `thread=` remains before `T=`.
1357
+ - Tests: 118 (threads 28, tags 24): selectSaveRange minStartSeq clamp and explicit bypass, tagRunStartAt,
1358
+ and primary+secondary labels.
1359
+
1360
+ ## 36. 0.4.5 — dominance block, default append, auto-bound new threads
1361
+
1362
+ - `dominanceStartSeq(tags,end,{islandMax=5})` in threads.mjs starts at the run containing end, walks
1363
+ earlier runs: own runs join; other-T runs of <=5 events are islands crossed; a larger other run is a
1364
+ boundary. 0.4.4’s run-start clamp is replaced by this block start; `selectSaveRange` minStartSeq is
1365
+ unchanged. Explicit from/to still bypasses it.
1366
+ - `prepareSave` without threadId appends when the current T resolves to an existing thread id or
1367
+ alias (active:false is not a filter); T0 or unbound T creates. A new non-T0 thread inherits the
1368
+ current T alias and is active, so `[标签]` has a T immediately. New T0 threads stay inactive.
1369
+ - Header label remains primary + secondary count (`T3`, `T3 +2`), display one form only; 0.4.4 already
1370
+ changed this; 0.4.5 keeps it.
1371
+ - Tests: 119 (threads 28, tags 25): dominance islands vs real boundaries, selectSaveRange clamp, and
1372
+ primary+secondary labels.
1373
+
1374
+ ## 37. 0.4.6 — load withReasoning
1375
+
1376
+ - `load(..., withReasoning:true)` applies to thread, tag, and seqs loads. Default `false` leaves the
1377
+ 0.4.5 8k/no-reasoning path untouched.
1378
+ - The selected visible entries are sorted chronologically and share one 16,000-token body+reasoning
1379
+ budget. Each assistant body pairs its own reasoning blocks, or the nearest reasoning-only event
1380
+ before it in the same turn. Pairing is silent when absent; user messages never pair.
1381
+ - Reasoning renders separately (`[reasoning]`; truncated label `[reasoning · 截断]`). A reasoning over
1382
+ 800 estimated tokens keeps a 150-token head and 650-token tail. Bodies plus reasoning stop at the
1383
+ budget; remaining entries are counted as `skipped`.
1384
+ - Receipts add `reasoningInjected`, `reasoningTokens`, `skipped`; `tokens` is the actual body+reasoning
1385
+ total and `budget` is 16,000 for this mode. Text headers say `含 reasoningN 条 · skipped K`.
1386
+ - Gist generation and scan do not include reasoning.
1387
+
1388
+ Tests: 121 (threads 28, tags 27): internal and preceding reasoning pairing, truncation, 16k stop/skipped,
1389
+ thread and tag paths, default no-reasoning output, and the tool parameter.
1390
+
1391
+ ## 38. 0.4.7 — delete, header counts, label protection, alias id resolution
1392
+
1393
+ - `delete({id,force?})` allows zero-segment/zero-token threads; otherwise `force:true` is required.
1394
+ Before mutation it writes `<id>-<ts>.json` under `~/.dsh/continuity/deleted/` with the raw/normalized
1395
+ thread and tag entries/runs/overrides. It then removes the thread, its tag rows/runs/overrides, regenerates
1396
+ tags.md, and returns `{ok,id,backup}`. No automatic deletion.
1397
+ - `[线]` is `当前:label(N 段 · M tokens)` (M=sum of segment tokens), with `(活动:symbol)` when
1398
+ the active-flag thread differs from `activeId`. `另有 K 条` is unchanged.
1399
+ - Existing thread saves keep their label; `relabel:true` is the only override.
1400
+ - tag/retag resolve tag id first, then alias (tag or thread); unknown metadata retag returns
1401
+ `unknown-tag-id` instead of creating an empty record. Retagging an alias to its own canonical is an
1402
+ idempotent success.
1403
+ - tags.md omits zero-event rows; list reflects threads after explicit deletion. JSON shapes remain
1404
+ read-compatible with 0.4.6.
1405
+
1406
+ Tests: 122 (threads 28, tags 28): empty/forced delete plus backup, tag-row removal and zero-event md,
1407
+ header segment/token/activity mismatch, label protected and relabel, alias retag no-op/unknown.
1408
+
1409
+ ## 39. 0.7.6 — target-addressed peer route
1410
+
1411
+ `POST /dsh-continuity/peer` on the Host's existing Web server. Body:
1412
+ `{ "to": "session-...", "from": "创", "text": "..." }`.
1413
+
1414
+ - `to` is mandatory and must be an exact live main-session id. Lookup is a
1415
+ `Map<sessionId, agent>` fed by `agent/created` / `session/disposed`. A missing target returns
1416
+ `404 target-not-live`; there is no active, newest, or owner fallback.
1417
+ - Delivery is `agent.followup(userMessage("[from] text"))`: fresh id, role user,
1418
+ `source.kind: "user"`; no raw `session.append`, no direct event construction.
1419
+ - The route is loopback-only, POST-only, 16 KiB body and 8,000-character text limits.
1420
+ - It is independent of the Coo pet bridge; it does not open or replace `/coopanion/pet`.
1421
+ - `followup` queues for the next turn; a 200 means accepted, not replied. A reply is a separate
1422
+ call from the other side.
1423
+
1424
+ Tests: 131 (peer 5, route 1): exact target/no fallback, registry ignores subagents/no-followup, payload
1425
+ validation, source/id shape, loopback/method/body refusals, and panel-disabled route mounting.
1426
+
1427
+ ## 40. 0.7.7 — peer_message preset tool
1428
+
1429
+ The continuity preset mounts `peer_message({ to, text, from? })` next to `continuity`. It shares the
1430
+ same singleton live-agent registry as the route.
1431
+
1432
+ - `to` is required and exact; `from` defaults to `Continuity`; text is 1–8000 chars.
1433
+ - Delivery is `target.followup(peerMessage(...))` with a fresh id and `source.kind: "user"`; no raw
1434
+ session append and no owner/active fallback.
1435
+ - Result is `{ok,to,from,queued:"next-turn",id}`; unknown target throws `peer_message: target-not-live`.
1436
+ - The tool is preset-scoped, so standard sessions do not see it; the Host route remains available for
1437
+ external callers such as 创.
1438
+
1439
+ Tests: 134 (peer 5, route 1, peer-tool 3): exact target, default/custom from, source/id/prefix,
1440
+ unknown-target refusal, and input validation before delivery.
1441
+
1442
+ ## 41. 0.7.8 — one-shot tag rejection display
1443
+
1444
+ The Tag gate's decision rules are unchanged. `tag-reject.json` is now a two-step display record:
1445
+
1446
+ - rejection writes `{turn,token,kind,reason,shown:false}`;
1447
+ - the first consuming pre-step renders the existing `[打标·退回]` text, then writes
1448
+ `shown:true` and `shownAt` (does not delete);
1449
+ - the next consuming pre-step deletes the file without rendering;
1450
+ - a second rejection for the same turn preserves `shown:true`, so it cannot render twice.
1451
+
1452
+ `renderTagRejectHeader` keeps the existing wording and `turnmap ... set --turn N --tag <Tx>` line.
1453
+ All Tag-gate writes remain on `userMessage()`; T99 rejections still never append `turn-tags.jsonl`.
1454
+
1455
+ Tests: 138 (peer 5, route 1, peer-tool 3, tag-reject 4): first render/mark, second-step delete,
1456
+ same-turn no-reset, new-turn render, and exact header text.
1457
+
1458
+ ## 42. 0.7.9 — stale rejection and one-steer cleanup
1459
+
1460
+ - `consumeTagReject` now accepts `isResolved(turn)`. The engine header passes the same `tagged`
1461
+ set computed from `turn-tags.jsonl` (last write wins per turn is equivalent for this existence
1462
+ check). A resolved turn deletes `tag-reject.json` without rendering.
1463
+ - The two rejection paths no longer call `appendToSession`; they emit one `steer` whose first line
1464
+ is the old short message and whose remainder is the complete error count/examples. The header
1465
+ remains the fallback when steer cannot be delivered.
1466
+ - Decision rules and `userMessage()` source/id discipline are unchanged.
1467
+
1468
+ Acceptance target: T99 then a legal T14 must not render a stale header; T99 without repair must render
1469
+ once and disappear; one steer per rejection; no new `turn-tags` rows or `gate-errors`.
1470
+
1471
+ Tests: 139 (peer 5, route 1, peer-tool 3, tag-reject 5): resolved delete, one-shot display, same-turn
1472
+ no-reset, new-turn render, and exact header text.
1473
+
1474
+ ## 43. 0.8.0 — dispatch probe
1475
+
1476
+ The Host listener's `agent/turn-stopping` handler writes `dispatch-probe.jsonl` before reading
1477
+ `continuityState.engine`:
1478
+
1479
+ ```json
1480
+ {"at":"...","event":"agent/turn-stopping","turn":1196,"enginePresent":true}
1481
+ ```
1482
+
1483
+ The engine entry probe `hook-probe.jsonl` now includes `taggedSize` alongside `hasAgent` and
1484
+ `canSteer`. Comparison:
1485
+
1486
+ - both files have the turn → dispatch and engine entry both happened;
1487
+ - dispatch only, `enginePresent:false` → engine was not ready;
1488
+ - dispatch only, `enginePresent:true` → engine returned/threw before its entry probe;
1489
+ - neither → the Host did not dispatch that turn-stopping.
1490
+
1491
+ No decision rules or user text changed. Tests: 140 (dispatch 1) verify probe-before-engine and the
1492
+ null/true engine distinction.
1493
+
1494
+ ## 44. 0.8.1 — dispatch identity fields
1495
+
1496
+ `dispatch-probe.jsonl` records:
1497
+
1498
+ - `engineCallable`: `typeof continuityState.engine?.onTurnStopping === 'function'`;
1499
+ - `agentSession`: the session id carried by the event's agent;
1500
+ - `continuitySession`: the session the mounted engine has actually served
1501
+ (`continuityState.engineSession`, set at engine `onTurnStopping` entry);
1502
+ - `foreignSession`: both ids are present and differ.
1503
+
1504
+ `hook-probe.jsonl` additionally records `agentSession`. The fields only diagnose: a foreign session
1505
+ is noise for this gate; a local event with dispatch but no hook points at the index/engine fallback;
1506
+ neither file is a Host non-dispatch. Tests: 140 (dispatch 1 covers the fields and foreign flag).
1507
+
1508
+ ## 45. 0.8.2 — gate messages are runtime context
1509
+
1510
+ The Tag gate's `steer` still holds the current turn open, but its message now uses
1511
+ `source:{kind:"runtime-context", form:"notice", sections:[{name:"continuity:tag-gate",text}]}`
1512
+ instead of `source:{kind:"user"}`. The `[闸] TAG:` / `[闸·打标]` marker stays in the text.
1513
+
1514
+ - The UI no longer attributes the gate to the person as a user bubble; it is a context block.
1515
+ - The model still receives the text (the gate must explain the hold); this is not a full hide.
1516
+ - Same-turn `agent.steer`, decision rules, headers and `tag-reject` behavior are unchanged.
1517
+ - Peer messages still use the real `userMessage()` path; only the gate source changed.
1518
+
1519
+ Tests: 141 (threads 31): source kind/form/section and marker, plus the existing gate suite.
1520
+
1521
+ ## 46. 0.8.3 — first-line TAG declaration
1522
+
1523
+ `findTagDeclaration` (new `src/tag-detect.mjs`) accepts the first non-empty, non-fenced line and the
1524
+ last five non-empty, non-fenced lines. If both contain a TAG, the last-five candidate wins, preserving
1525
+ the old tail priority. Fenced examples remain ignored.
1526
+
1527
+ `onTurnStopping` now uses this helper instead of only reverse-walking five lines, so a long report
1528
+ with `TAG:` on its first line is accepted. The pre-step auto-copy already considered first/last; this
1529
+ aligns the turn gate with the documented rule.
1530
+
1531
+ Tests: 147 (tag-detect 6): first-line, last-line, last-wins, fenced-only, fenced-first followed by
1532
+ real tag, and candidate de-duplication.
1533
+
1534
+ ## 47. 0.8.4 — real-user filtering
1535
+
1536
+ `latestUserTurn` and the pre-step turn map now require `source.kind === "user"` for both
1537
+ `user/message` and `agent/inbox/spliced` entries. `runtime-context`, `time-context`, delivery notices,
1538
+ and the gate's own `continuity:tag-gate` notices are skipped.
1539
+
1540
+ This removes the `[新标签?]` / `[暂停]` quotations of runtime/time context. The helper lives in
1541
+ `src/user-turn.mjs`; the gate's latest-user selection and the `userFirstBySeq` map share it.
1542
+
1543
+ Tests: 151 (user-turn 4): time/runtime skipped, spliced user selected, only-context → null, and source
1544
+ lookup precedence.
1545
+
1546
+ ## 48. 0.8.5 — current turn is not debt
1547
+
1548
+ `closedTurns` now filters `turn < currentTurn`, using `agent.lastTurn` (fallback: latest
1549
+ `turn/start`). The current open turn is excluded from `[打标?]` and `tag-debt.json`.
1550
+
1551
+ `[暂停]` is emitted only when the previous completed turn has a real user quote; an empty quote now
1552
+ omits the whole line instead of printing `「」`. The `[打标?]` list remains for genuinely older
1553
+ missing turns.
1554
+
1555
+ Tests: 151; header behavior is exercised live (turn 1210: no `缺 1 轮`, no empty `[暂停]`).
1556
+
1557
+ ## 49. 0.8.6 — surface-floor start and skippedRange
1558
+
1559
+ `resolveSaveStart` keeps the normal save start at `surface[0]` even when an explicit `fromSeq` is
1560
+ below it; it never throws `not a surface node`. `detectSkippedRange` reports
1561
+ `{from,to,events}` for the uncovered interval before `surface[0]` (message events only), records
1562
+ `save-hole`, and the result/receipt carry `skippedRange`.
1563
+
1564
+ `preflight` now takes the actual segment start/end, so its plan and the landed segment share one
1565
+ source. Nothing auto-fills the hole; `backfill:true` is reserved for 0.8.9.
1566
+
1567
+ Tests: 155 (save-start 4): below-surface fallback, at/above/no explicit, hole count, no-hole/no-range
1568
+ cases.
1569
+
1570
+ ## 50. 0.8.7 — save-hole noise and T0 machine sources
1571
+
1572
+ - `detectSkippedRange` returns null when the interval has zero message events; no receipt/header
1573
+ noise for a purely structural gap.
1574
+ - Peer messages carry `source.peer:true` (still `source.kind:"user"` for the UI) and
1575
+ `contentUndecided` skips `source.peer`, `runtime-context`, `time-context` and other non-user
1576
+ sources. Machine steers/peer notes no longer create T0 undecided debt.
1577
+ - Normal save emits `planNote` when `preflight.plan[0].from !== segment.fromSeq`, naming both values
1578
+ and the reason (plan only counts turn-attributed content events).
1579
+
1580
+ Tests: 168 (save-start 4, t0 13): zero-event suppression, machine-source filtering, and existing
1581
+ save-start cases.
1582
+
1583
+ ## 51. 0.8.8 — peer prefix fallback for T0
1584
+
1585
+ The Host may persist only `source.kind:"user"` on a peer-delivered user message, dropping the extra
1586
+ `source.peer` flag. `contentUndecided` therefore also skips content starting with `[创]`, `[闸]`, or
1587
+ `[闸·打标]`; these are the stable prefixes written by the peer route and the Tag gate.
1588
+
1589
+ This removes the remaining T0 debt from cross-session peer notes and gate steers. The `source.peer`
1590
+ flag remains as an additional, non-required marker.
1591
+
1592
+ Tests: 168 (t0 13): `[创]`/`[闸]` with only `kind:"user"` are skipped; real user content still
1593
+ counts.
1594
+
1595
+ ## 52. 0.8.9 — historical backfill
1596
+
1597
+ `save({fromSeq,toSeq,backfill:true,threadId?})` does not call `selectSaveRange`. It reads
1598
+ `sessionQuery.readSession` events, keeps `user/message`, `assistant/message`, `tool/call`,
1599
+ `tool/result` in `[fromSeq,toSeq]`, drops a leading tool/result or trailing tool/call, and appends
1600
+ one segment to an existing thread (active by default).
1601
+
1602
+ - Strict overlap with any existing segment → `range-already-covered`.
1603
+ - Same gates: `t0-undecided`, `turn-untagged`, `tags-gap`; empty content → `backfill-empty`.
1604
+ - Returns `mode:"backfill"`, `surfaceTouched:false`, `adjusted:false`, actual `seqStart/seqEnd/count`,
1605
+ `seqs`, and `requestedRange` (requested vs content boundary).
1606
+ - No live surface mutation, no pending gist, no make-surface path.
1607
+
1608
+ Tests: 172 (backfill 4): whitelist/range, edge-pair trim, segment boundaries/tokens, strict overlap.
1609
+
1610
+ ## 53. 0.8.10 — ordered segments after backfill
1611
+
1612
+ `normalizeThread` sorts segments by `fromSeq`. This restores the invariant every reader relies on:
1613
+ the last segment is the chronologically latest, so `seqEnd`, `why`, `next`, gist index, `coveredTo`
1614
+ and `at(-1)` projections stay correct after a historical backfill.
1615
+
1616
+ Backfill inserts the new segment at its sorted position, reports that position, and does not let the
1617
+ archival segment's `why/next` become the thread's latest metadata. `label/why/next` are optional for
1618
+ backfill and receive deterministic defaults.
1619
+
1620
+ The backfill segment still inherits the thread's current `gistSeq`/`compactionId` through
1621
+ `normalizeSegment`; it is not recomputed for the historical range. If this is undesirable, that is a
1622
+ separate backfill-gist design change.
1623
+
1624
+ Tests: 173 (backfill 5): out-of-order normalization keeps `seqEnd`/`why`/`next` from the latest
1625
+ segment.
1626
+
1627
+ ## 54. 0.8.11 — fieldless backfill validation
1628
+
1629
+ The tool validator's `save` branch previously required non-empty `label/why/next` for every save.
1630
+ It now skips that requirement when `backfill:true`; normal saves still enforce it in the Core.
1631
+
1632
+ Core's backfill parser generates `历史回补 <from>..<to>` and `检查这段原话` when those fields are
1633
+ absent, so the archival segment never carries empty `why/next`. Tests: 173; the tool-level path is
1634
+ exercised live (fieldless backfill).
1635
+
1636
+ ## 55. 0.8.12 — content time is not write time
1637
+
1638
+ A backfill segment now sets `at = spanEnd` (when its content happened) and `archivedAt = now` (when
1639
+ it was appended). `normalizeSegment` preserves `archivedAt`.
1640
+
1641
+ `normalizeThread` takes thread `updatedAt` from the latest segment's `spanEnd` instead of the
1642
+ source `updatedAt`/`at`; `saveBackfill` writes the latest content's `spanEnd` as the thread
1643
+ timestamp. This prevents a historical backfill from making the thread look recently active.
1644
+
1645
+ Q2 of the user-facing table was corrected: system ordering is fixed, but a reader can still confuse
1646
+ "when the note was written" with "when the event happened" unless it uses `span` or the event
1647
+ timestamp.
1648
+
1649
+ Tests: 173; backfill assertions cover `at === spanEnd`, `archivedAt`, and `normalizeThread`
1650
+ `updatedAt` from the latest `spanEnd`.
1651
+
1652
+ ## 56. 0.8.13 — normalize the persisted file too
1653
+
1654
+ 0.8.12 normalized old backfill timestamps only in the in-memory `normalizeThread` view; direct
1655
+ `threads.json` consumers still saw the old write-time `at`.
1656
+
1657
+ `migrateThreadsFile` now runs once at plugin startup:
1658
+
1659
+ - reads every session, normalizes its threads, compares with the raw JSON;
1660
+ - on change, writes a `threads.json.bak-normalize-*` backup and calls `updateSession` so the file is
1661
+ rewritten in normalized form;
1662
+ - old backfill rows get `at = spanEnd` and `archivedAt = old at` (the old write moment is preserved,
1663
+ not discarded); thread `updatedAt` becomes the latest content `spanEnd`.
1664
+
1665
+ Tests: 174 (migrate 1): old `历史回补` row is normalized on disk, `archivedAt` is preserved, and the
1666
+ backup exists.
1667
+
1668
+ ## 57. 0.8.14 — boundary-silent header
1669
+
1670
+ The header now follows “正常不吵,越界才说”:
1671
+
1672
+ - auto-assignment success is silent; only failure/ambiguity/active-line change speaks;
1673
+ - `[新标签?]` requires zero candidates, ≥40 chars, non-machine text, 30-day text cap, and 20-turn
1674
+ cooldown; it is mutually exclusive with `[候选]`;
1675
+ - `[话题?]` uses keywords/anchors only (label weight 0), generic weight 0; needs one exclusive word
1676
+ (precision ≥0.5, hits ≥5) or ≥2 non-generic words; score gap ≥2; one target; 10-minute same-target
1677
+ cooldown;
1678
+ - `[归拢]`/`[心境]` no longer auto-broadcast; `[接手]` only after load and one line;
1679
+ - `[存?]` requires 8+ unseen content messages, 20+ turns from segment end, and once per
1680
+ `(thread,toSeq)`;
1681
+ - `[规则膨胀?]` once per size signature;
1682
+ - skill body injects only when the skill set changes; the `[技能] 正在用` line remains.
1683
+
1684
+ Self-check: `node bin/noise-selfcheck.mjs`. Tests: 188 (topic 7, inject-policy 7).
1685
+
1686
+ ## 58. 0.8.15 — anchors do not promote generic words
1687
+
1688
+ `topic.mjs` now treats anchors only as a presence requirement; they never move a generic word into
1689
+ `strong`. `strong` is strictly non-generic; `exclusive` requires the word stats (precision ≥0.5,
1690
+ hits ≥5). The `[话题?]` display marks a generic anchor as `(泛·锚)` instead of calling it hard
1691
+ evidence.
1692
+
1693
+ `[接手]` now requires `handoff-pending.json` to have the current `process.pid` and be younger than
1694
+ 10 minutes. A pre-restart pending is deleted without firing. Save still does not create a pending.
1695
+
1696
+ Tests: 189 (topic 8, inject-policy 7).
1697
+
1698
+ ## 59. 0.8.16 — first-miss TAG prompt
1699
+
1700
+ The first missing-TAG steer is no longer the bare `[闸] TAG:`. It now says:
1701
+
1702
+ ```text
1703
+ [闸] 收尾前,先用最后一行总结本轮主题:
1704
+ TAG: <最符合的 T 别名;没有就写一个 2–30 字的新词> — 一句话理由
1705
+ 例:TAG: T14 — 折叠边界
1706
+ ```
1707
+
1708
+ The parser still requires the literal `TAG:`; “总结本轮主题” is explanatory, not a replacement.
1709
+ Later errors keep the detailed `EXPECT` block. It lowers accidental gibberish, but does not stop a
1710
+ deliberate 2–30 char unknown word from auto-creating a T.
1711
+
1712
+ Tests: 189; selfcheck also asserts the new prompt text.
1713
+
1714
+ ## 60. 0.8.17 — confirm new names with evidence
1715
+
1716
+ New-word confirmation now:
1717
+
1718
+ - asks only y/n and says “这一轮最后一行只写 y 或 n;不要写 TAG”;
1719
+ - accepts `y` immediately before a TAG line (last-line parser looks back one line);
1720
+ - requires the word to appear in the current user message or assistant body, excluding TAG
1721
+ lines and `[闸…]` gate text;
1722
+ - rejects a word within edit distance 1 of an existing label and names that alias;
1723
+ - splits declarations only on `—`/`–`, so `F-35`, `COVID-19`, and `D-Day` stay intact;
1724
+ - uses a placeholder new-word example rather than the existing T16 label;
1725
+ - records the confirmed token in `turn-tags.jsonl` and clears pending on the safety valve.
1726
+
1727
+ Tests: 194 (tag-confirm 5).
1728
+
1729
+ ## 61. 0.8.18 — backfill uses the same gate rules
1730
+
1731
+ The declaration-as-record path in the header builder (`tagLineOf`) used a separate ASCII-hyphen
1732
+ split and `resolveTagToken`’s default `allowCreate:true`. That meant a backfill could silently create
1733
+ a tag without y/n.
1734
+
1735
+ It now uses `tagTokenFromDeclaration(line)` and `resolveTagToken(token, { allowCreate: false })`:
1736
+ existing aliases/ids can be copied, new words are `new-pending` and left for the gate. `F-35` is no
1737
+ longer truncated to `F`.
1738
+
1739
+ Tests: 194; selfcheck asserts the backfill line uses `tagTokenFromDeclaration(line)`.
1740
+
1741
+
1742
+
1743
+