@fractalsynapse/dsh-chapters 0.0.0-stage → 0.2.21

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 (195) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +76 -2
  3. package/cordis.patch.yml +363 -0
  4. package/docs/architecture.md +692 -0
  5. package/docs/autorun-guide.md +248 -0
  6. package/docs/backlog.md +2022 -0
  7. package/docs/contract.md +938 -0
  8. package/docs/development.md +990 -0
  9. package/docs/host-compaction-seam.md +225 -0
  10. package/docs/host-protected-head-ask.md +80 -0
  11. package/docs/knowledge-repo.md +1332 -0
  12. package/docs/plans-carriage.md +571 -0
  13. package/docs/project-state.md +642 -0
  14. package/docs/provider.md +98 -0
  15. package/docs/superpowers/plans/2026-10-17-chapters-save.md +198 -0
  16. package/docs/superpowers/plans/2026-11-02-chapter-removal.md +151 -0
  17. package/docs/superpowers/specs/2026-11-02-chapter-removal-design.md +115 -0
  18. package/docs/verify.md +561 -0
  19. package/lib/archive.d.ts +161 -0
  20. package/lib/archive.js +229 -0
  21. package/lib/archive.js.map +1 -0
  22. package/lib/arrival.d.ts +23 -0
  23. package/lib/arrival.js +125 -0
  24. package/lib/arrival.js.map +1 -0
  25. package/lib/artifact-query.d.ts +62 -0
  26. package/lib/artifact-query.js +187 -0
  27. package/lib/artifact-query.js.map +1 -0
  28. package/lib/attach.d.ts +46 -0
  29. package/lib/attach.js +120 -0
  30. package/lib/attach.js.map +1 -0
  31. package/lib/auto-save.d.ts +145 -0
  32. package/lib/auto-save.js +334 -0
  33. package/lib/auto-save.js.map +1 -0
  34. package/lib/aux-message.d.ts +17 -0
  35. package/lib/aux-message.js +20 -0
  36. package/lib/aux-message.js.map +1 -0
  37. package/lib/client.js +1419 -0
  38. package/lib/client.js.map +1 -0
  39. package/lib/codegraph.d.ts +76 -0
  40. package/lib/codegraph.js +190 -0
  41. package/lib/codegraph.js.map +1 -0
  42. package/lib/commands.d.ts +52 -0
  43. package/lib/commands.js +946 -0
  44. package/lib/commands.js.map +1 -0
  45. package/lib/compose.d.ts +30 -0
  46. package/lib/compose.js +185 -0
  47. package/lib/compose.js.map +1 -0
  48. package/lib/continue-core.d.ts +245 -0
  49. package/lib/continue-core.js +405 -0
  50. package/lib/continue-core.js.map +1 -0
  51. package/lib/diag.d.ts +55 -0
  52. package/lib/diag.js +186 -0
  53. package/lib/diag.js.map +1 -0
  54. package/lib/engine-core.d.ts +301 -0
  55. package/lib/engine-core.js +531 -0
  56. package/lib/engine-core.js.map +1 -0
  57. package/lib/engine-note.d.ts +35 -0
  58. package/lib/engine-note.js +32 -0
  59. package/lib/engine-note.js.map +1 -0
  60. package/lib/engine.d.ts +329 -0
  61. package/lib/engine.js +1179 -0
  62. package/lib/engine.js.map +1 -0
  63. package/lib/enrich-queue.d.ts +66 -0
  64. package/lib/enrich-queue.js +126 -0
  65. package/lib/enrich-queue.js.map +1 -0
  66. package/lib/enrich-store.d.ts +31 -0
  67. package/lib/enrich-store.js +103 -0
  68. package/lib/enrich-store.js.map +1 -0
  69. package/lib/enrich-wire.d.ts +163 -0
  70. package/lib/enrich-wire.js +353 -0
  71. package/lib/enrich-wire.js.map +1 -0
  72. package/lib/enrich.d.ts +102 -0
  73. package/lib/enrich.js +267 -0
  74. package/lib/enrich.js.map +1 -0
  75. package/lib/gitops.d.ts +107 -0
  76. package/lib/gitops.js +539 -0
  77. package/lib/gitops.js.map +1 -0
  78. package/lib/index.d.ts +241 -0
  79. package/lib/index.js +1162 -0
  80. package/lib/index.js.map +1 -0
  81. package/lib/indexing.d.ts +98 -0
  82. package/lib/indexing.js +223 -0
  83. package/lib/indexing.js.map +1 -0
  84. package/lib/injections.d.ts +90 -0
  85. package/lib/injections.js +165 -0
  86. package/lib/injections.js.map +1 -0
  87. package/lib/last-resort.d.ts +24 -0
  88. package/lib/last-resort.js +27 -0
  89. package/lib/last-resort.js.map +1 -0
  90. package/lib/log-shape.d.ts +38 -0
  91. package/lib/log-shape.js +93 -0
  92. package/lib/log-shape.js.map +1 -0
  93. package/lib/models-command.d.ts +74 -0
  94. package/lib/models-command.js +100 -0
  95. package/lib/models-command.js.map +1 -0
  96. package/lib/notice.d.ts +74 -0
  97. package/lib/notice.js +92 -0
  98. package/lib/notice.js.map +1 -0
  99. package/lib/plans.d.ts +154 -0
  100. package/lib/plans.js +315 -0
  101. package/lib/plans.js.map +1 -0
  102. package/lib/preset.d.ts +186 -0
  103. package/lib/preset.js +212 -0
  104. package/lib/preset.js.map +1 -0
  105. package/lib/prompt-keys.d.ts +7 -0
  106. package/lib/prompt-keys.js +8 -0
  107. package/lib/prompt-keys.js.map +1 -0
  108. package/lib/prompt-loop.d.ts +73 -0
  109. package/lib/prompt-loop.js +147 -0
  110. package/lib/prompt-loop.js.map +1 -0
  111. package/lib/prompt-push.d.ts +47 -0
  112. package/lib/prompt-push.js +115 -0
  113. package/lib/prompt-push.js.map +1 -0
  114. package/lib/prompt-runner.d.ts +48 -0
  115. package/lib/prompt-runner.js +90 -0
  116. package/lib/prompt-runner.js.map +1 -0
  117. package/lib/prompt-state.d.ts +142 -0
  118. package/lib/prompt-state.js +101 -0
  119. package/lib/prompt-state.js.map +1 -0
  120. package/lib/prompt.d.ts +270 -0
  121. package/lib/prompt.js +408 -0
  122. package/lib/prompt.js.map +1 -0
  123. package/lib/provider.d.ts +44 -0
  124. package/lib/provider.js +66 -0
  125. package/lib/provider.js.map +1 -0
  126. package/lib/redact.d.ts +24 -0
  127. package/lib/redact.js +85 -0
  128. package/lib/redact.js.map +1 -0
  129. package/lib/registry.d.ts +187 -0
  130. package/lib/registry.js +235 -0
  131. package/lib/registry.js.map +1 -0
  132. package/lib/removal.d.ts +159 -0
  133. package/lib/removal.js +556 -0
  134. package/lib/removal.js.map +1 -0
  135. package/lib/render.d.ts +97 -0
  136. package/lib/render.js +530 -0
  137. package/lib/render.js.map +1 -0
  138. package/lib/repair-run.d.ts +76 -0
  139. package/lib/repair-run.js +506 -0
  140. package/lib/repair-run.js.map +1 -0
  141. package/lib/repair.d.ts +208 -0
  142. package/lib/repair.js +357 -0
  143. package/lib/repair.js.map +1 -0
  144. package/lib/repo.d.ts +78 -0
  145. package/lib/repo.js +184 -0
  146. package/lib/repo.js.map +1 -0
  147. package/lib/rules-commands.d.ts +46 -0
  148. package/lib/rules-commands.js +379 -0
  149. package/lib/rules-commands.js.map +1 -0
  150. package/lib/rules.d.ts +267 -0
  151. package/lib/rules.js +478 -0
  152. package/lib/rules.js.map +1 -0
  153. package/lib/save-core.d.ts +72 -0
  154. package/lib/save-core.js +220 -0
  155. package/lib/save-core.js.map +1 -0
  156. package/lib/search.d.ts +112 -0
  157. package/lib/search.js +282 -0
  158. package/lib/search.js.map +1 -0
  159. package/lib/session-rules.d.ts +222 -0
  160. package/lib/session-rules.js +454 -0
  161. package/lib/session-rules.js.map +1 -0
  162. package/lib/signature.d.ts +45 -0
  163. package/lib/signature.js +199 -0
  164. package/lib/signature.js.map +1 -0
  165. package/lib/store.d.ts +156 -0
  166. package/lib/store.js +368 -0
  167. package/lib/store.js.map +1 -0
  168. package/lib/sync.d.ts +220 -0
  169. package/lib/sync.js +810 -0
  170. package/lib/sync.js.map +1 -0
  171. package/lib/tools.d.ts +113 -0
  172. package/lib/tools.js +1090 -0
  173. package/lib/tools.js.map +1 -0
  174. package/lib/treedx/client.d.ts +57 -0
  175. package/lib/treedx/client.js +82 -0
  176. package/lib/treedx/client.js.map +1 -0
  177. package/lib/treedx/provider.d.ts +39 -0
  178. package/lib/treedx/provider.js +479 -0
  179. package/lib/treedx/provider.js.map +1 -0
  180. package/lib/treedx/state.d.ts +27 -0
  181. package/lib/treedx/state.js +65 -0
  182. package/lib/treedx/state.js.map +1 -0
  183. package/lib/types.d.ts +210 -0
  184. package/lib/types.js +31 -0
  185. package/lib/types.js.map +1 -0
  186. package/lib/version.d.ts +9 -0
  187. package/lib/version.js +10 -0
  188. package/lib/version.js.map +1 -0
  189. package/lib/vocabulary.d.ts +41 -0
  190. package/lib/vocabulary.js +118 -0
  191. package/lib/vocabulary.js.map +1 -0
  192. package/lib/warnings.d.ts +10 -0
  193. package/lib/warnings.js +18 -0
  194. package/lib/warnings.js.map +1 -0
  195. package/package.json +151 -3
@@ -0,0 +1,97 @@
1
+ import type { ArtifactRef, ChapterRange, RenderConfig, RenderedChapter, SessionEventLike, ToolResultCandidate, ToolResultOverride } from './types.ts';
2
+ /** chars/4 until the host tokenMeter is wired in. Labelled an estimate everywhere it surfaces. */
3
+ export declare const estimateTokens: (text: string) => number;
4
+ /**
5
+ * A fence strictly longer than any backtick run inside `body`, so embedded code blocks cannot close it.
6
+ * Markdown requires the closing fence to be at least as long as the opening one.
7
+ */
8
+ export declare function longerFence(body: string): string;
9
+ /** Wrapped in a fence long enough to survive any content, including nested fences. */
10
+ export declare function fenced(content: string, info?: string): string;
11
+ export declare const sha256: (text: string) => string;
12
+ /** `artifacts/8f/8f3a1c9d….txt` — sharded so one directory never holds the whole subtree. */
13
+ export declare const artifactPath: (hash: string) => string;
14
+ export declare function artifactRef(text: string, opts: {
15
+ toolName: string;
16
+ sourceSeq: number;
17
+ inlined: boolean;
18
+ }): ArtifactRef;
19
+ /**
20
+ * Validate model-supplied ranges. Never trust them (hard rule): an overlap duplicates archived text and a
21
+ * gap silently drops history, so both are errors here rather than data that quietly goes missing.
22
+ */
23
+ export declare function validateRanges(chapters: ChapterRange[], archiveCeiling: number,
24
+ /** The watermark: ranges must start HERE, not wherever the submitter liked (#59). */
25
+ archiveFloor?: number): ChapterRange[];
26
+ /**
27
+ * Ranges whose render blew past the oversize ceiling (#59). `chapterTokenTarget` stays the SOFT target
28
+ * — split at the next boundary, never clip — and a modest overshoot is a warning, because a single
29
+ * indivisible turn can legitimately exceed it. Past `target × factor` the chapter is not a chapter:
30
+ * nobody can `read` it back inside the window it exists for, which is the whole point of archiving.
31
+ * The field held an 89,982-token chapter, an unrepaired 237,870-token orphan and eleven over 32K.
32
+ */
33
+ export declare function oversizeRanges(rendered: readonly {
34
+ range: ChapterRange;
35
+ stats: {
36
+ estimatedTokens: number;
37
+ events?: number | undefined;
38
+ };
39
+ }[], chapterTokenTarget: number, oversizeFactor: number): {
40
+ errors: string[];
41
+ indivisible: string[];
42
+ };
43
+ /**
44
+ * The capped public form (#61). `chapters_segment` once emitted 49,932 characters of its OWN output while
45
+ * also reporting `(Omitted 275266 bytes …)` — ≈81K est tokens into a 32K-class window, asking a small
46
+ * model to judge a candidate list it never received. Ranking is by size descending, so what a cap
47
+ * removes is the SMALL stuff: the judgment the model is actually asked for stays intact.
48
+ *
49
+ * The caller reports what was withheld — see `candidateTotals` — because a capped list that does not say
50
+ * it is capped is a silent truncation, and AGENTS.md forbids truncating to fit.
51
+ */
52
+ export declare function toolResultCandidates(events: SessionEventLike[], range: ChapterRange, capTokens?: number): ToolResultCandidate[];
53
+ /** Totals for the WHOLE candidate set, so a capped response can name what it left out. */
54
+ export declare function candidateTotals(events: SessionEventLike[], range: ChapterRange): {
55
+ count: number;
56
+ bytes: number;
57
+ };
58
+ /**
59
+ * Render one chapter. `overrides` is the model's sparse exception list; anything not named follows the
60
+ * default rule (small results inline, large ones deferred). Either way the artifact is written, so a wrong
61
+ * call costs a `read` and never loses data.
62
+ *
63
+ * Plans (docs/plans-carriage.md § 2) are read over the WHOLE `events` list, not just the range: a
64
+ * submission and its verdict can straddle a chapter boundary, and pairing them inside one range only
65
+ * would report an approved plan as `pending`. The plans block is appended AFTER the verbatim
66
+ * transcript, so the transcript stays exactly what the log said.
67
+ */
68
+ export declare function renderChapter(events: SessionEventLike[], range: ChapterRange, config: RenderConfig, overrides?: readonly ToolResultOverride[], topics?: readonly string[],
69
+ /** Optional CodeGraph symbols verified against the project index. Written to
70
+ * frontmatter when non-empty; absent → no field emitted (backward compatible). */
71
+ codegraphSymbols?: readonly string[]): RenderedChapter;
72
+ /**
73
+ * The cumulative index that replaces compacted history. Built from registry records, never from an LLM —
74
+ * which is the entire latency argument: no summarization call, no tokens, no paraphrase.
75
+ */
76
+ /** One line of a checkpoint notice's chapter index. */
77
+ export interface NoticeBullet {
78
+ path: string;
79
+ title: string;
80
+ summary: string;
81
+ }
82
+ /**
83
+ * The chapter index inside a continuation notice, capped at `cap` lines (0 = list everything).
84
+ *
85
+ * The cap is the difference between a notice whose size tracks history and one that settles. Each line costs
86
+ * ~120 characters, so an uncapped index moves the surface across the compaction trigger depending on how many
87
+ * chapters a run happened to write — which makes a leg parked on that boundary undistillable as a tape (measured
88
+ * on `heavy`: identical served bytes, boundary one assistant/tool round apart between two runs). Past the cap the
89
+ * text is byte-identical, and the omission is STATED rather than silent, because a model that cannot see the
90
+ * list must still know to search for it.
91
+ */
92
+ export declare function noticeIndexSection(entries: readonly NoticeBullet[], cap: number): string;
93
+ export declare function renderIndex(entries: readonly {
94
+ path: string;
95
+ title: string;
96
+ summary: string;
97
+ }[]): string;
package/lib/render.js ADDED
@@ -0,0 +1,530 @@
1
+ /**
2
+ * Chapter renderer: durable events in a seq range → one Markdown file, deterministically.
3
+ *
4
+ * This module is pure and cordis-free. The model never writes chapter text (invariant 4 in AGENTS.md) — it
5
+ * supplies ranges, and every byte of the body comes from the log. So correctness here IS the "retrievable
6
+ * memory" promise.
7
+ *
8
+ * Two hazards this file exists to handle:
9
+ *
10
+ * 1. **Fence collision.** Conversation text routinely contains ``` blocks. A Markdown file that embeds a
11
+ * triple-backtick fence inside a triple-backtick fence terminates early and silently truncates the rest
12
+ * of the chapter. `longerFence()` solves this by measuring the longest backtick run in the payload and
13
+ * using one longer. Verified by test, because the failure is invisible by eye.
14
+ * 2. **Silent holes.** An event that renders to nothing must never vanish quietly — it is recorded in
15
+ * `stats.unrenderedSeqs` so a gap becomes a visible assertion rather than lost history.
16
+ * 3. **Borrowed context.** Host-injected instruction/snapshot/catalog text is project state, not
17
+ * conversation: it is screened at this chokepoint (src/injections.ts) and replaced by a countable
18
+ * marker, so a project's own docs never travel in the shared knowledge corpus.
19
+ */
20
+ import { createHash } from 'node:crypto';
21
+ import { DEFAULT_REDACTIONS, redactText } from "./redact.js";
22
+ import { omittedMarker, screenConversation, stripReminderSpans } from "./injections.js";
23
+ import { blocksOf, callArgsText, resultCallId, sameCall, textOfBlocks, toolResultText } from "./log-shape.js";
24
+ import { detectPlans, planTopicsOf, PLAN_TOOL } from "./plans.js";
25
+ /** The invocation-argument head printed in the transcript when no artifact is written. */
26
+ const CALL_HEAD_CHARS = 400;
27
+ /** The plan head printed inline under the Plans block when the full text is a reference. */
28
+ const PLAN_HEAD_FALLBACK_CHARS = 900;
29
+ /** chars/4 until the host tokenMeter is wired in. Labelled an estimate everywhere it surfaces. */
30
+ export const estimateTokens = (text) => Math.ceil(text.length / 4);
31
+ /**
32
+ * A fence strictly longer than any backtick run inside `body`, so embedded code blocks cannot close it.
33
+ * Markdown requires the closing fence to be at least as long as the opening one.
34
+ */
35
+ export function longerFence(body) {
36
+ let max = 0;
37
+ for (const match of body.matchAll(/`+/g))
38
+ max = Math.max(max, match[0].length);
39
+ return '`'.repeat(Math.max(3, max + 1));
40
+ }
41
+ /** Wrapped in a fence long enough to survive any content, including nested fences. */
42
+ export function fenced(content, info = '') {
43
+ const fence = longerFence(content);
44
+ return `${fence}${info}\n${content}\n${fence}`;
45
+ }
46
+ export const sha256 = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
47
+ /** `artifacts/8f/8f3a1c9d….txt` — sharded so one directory never holds the whole subtree. */
48
+ export const artifactPath = (hash) => `artifacts/${hash.slice(0, 2)}/${hash}.txt`;
49
+ export function artifactRef(text, opts) {
50
+ const hash = sha256(text);
51
+ return {
52
+ sha256: hash,
53
+ path: artifactPath(hash),
54
+ bytes: Buffer.byteLength(text, 'utf8'),
55
+ content: text,
56
+ inlined: opts.inlined,
57
+ toolName: opts.toolName,
58
+ sourceSeq: opts.sourceSeq,
59
+ };
60
+ }
61
+ /**
62
+ * `textOf` kept as the local name the renderer's call sites already use; the
63
+ * implementation moved to log-shape.ts so the plan detector reads a payload
64
+ * exactly the way the transcript does (docs/plans-carriage.md § 1).
65
+ */
66
+ const textOf = textOfBlocks;
67
+ /**
68
+ * Validate model-supplied ranges. Never trust them (hard rule): an overlap duplicates archived text and a
69
+ * gap silently drops history, so both are errors here rather than data that quietly goes missing.
70
+ */
71
+ export function validateRanges(chapters, archiveCeiling,
72
+ /** The watermark: ranges must start HERE, not wherever the submitter liked (#59). */
73
+ archiveFloor = 0) {
74
+ if (chapters.length === 0)
75
+ throw new Error('no chapters supplied');
76
+ chapters.forEach((c, i) => {
77
+ if (c.startSeq > c.endSeq) {
78
+ throw new Error(`chapter ${i} ("${c.title}") has startSeq ${c.startSeq} above endSeq ${c.endSeq}`);
79
+ }
80
+ if (c.endSeq > archiveCeiling) {
81
+ throw new Error(`chapter ${i} ("${c.title}") ends at seq ${c.endSeq}, past the archive ceiling ${archiveCeiling}`);
82
+ }
83
+ if (i > 0) {
84
+ const prev = chapters[i - 1];
85
+ if (c.startSeq <= prev.startSeq) {
86
+ throw new Error(`chapter ${i} ("${c.title}") descends: starts ${c.startSeq} after ${prev.startSeq}`);
87
+ }
88
+ if (c.startSeq <= prev.endSeq) {
89
+ throw new Error(`chapter ${i} ("${c.title}") overlaps "${prev.title}": ${c.startSeq}-${prev.endSeq} in both`);
90
+ }
91
+ // #59: the overlap check existed; its twin did not. An overlap duplicates text, a GAP drops
92
+ // it, and AGENTS.md has always said so — a submitted list with a hole was accepted silently.
93
+ if (c.startSeq > prev.endSeq + 1) {
94
+ throw new Error(`chapter ${i} ("${c.title}") starts at seq ${c.startSeq}, leaving a gap at `
95
+ + `${prev.endSeq + 1}-${c.startSeq - 1} — those seqs would be archived by nobody`);
96
+ }
97
+ }
98
+ });
99
+ // The watermark floor, checked LAST: shape, ceiling, order and contiguity each report their own
100
+ // problem first, and this is the one that only makes sense once they are all sound (#59).
101
+ if (chapters[0].startSeq > archiveFloor) {
102
+ throw new Error(`chapter 0 ("${chapters[0].title}") starts at seq ${chapters[0].startSeq}, above the watermark ${archiveFloor} — `
103
+ + `seqs ${archiveFloor}-${chapters[0].startSeq - 1} would be dropped without a word (a gap is loss, AGENTS.md)`);
104
+ }
105
+ return chapters;
106
+ }
107
+ /**
108
+ * Ranges whose render blew past the oversize ceiling (#59). `chapterTokenTarget` stays the SOFT target
109
+ * — split at the next boundary, never clip — and a modest overshoot is a warning, because a single
110
+ * indivisible turn can legitimately exceed it. Past `target × factor` the chapter is not a chapter:
111
+ * nobody can `read` it back inside the window it exists for, which is the whole point of archiving.
112
+ * The field held an 89,982-token chapter, an unrepaired 237,870-token orphan and eleven over 32K.
113
+ */
114
+ export function oversizeRanges(rendered, chapterTokenTarget, oversizeFactor) {
115
+ const ceiling = Math.floor(chapterTokenTarget * oversizeFactor);
116
+ const errors = [];
117
+ const indivisible = [];
118
+ for (const r of rendered) {
119
+ if (r.stats.estimatedTokens <= ceiling)
120
+ continue;
121
+ // #117: the escape valve. A range holding MORE THAN ONE event can be split, so exceeding the ceiling is
122
+ // the caller's mistake and stays a refusal. A range holding exactly ONE event cannot be reduced by any
123
+ // boundary choice — the event is the atom — and refusing it means a long session becomes permanently
124
+ // un-compactable. Measured, not theorised: this session's `chapters_continue` was refused four times
125
+ // because one `command/done` event rendered 77,903 est tokens. Clipping remains forbidden, so the honest
126
+ // options were "archive it whole" or "never archive this session again".
127
+ //
128
+ // The result is a loud, counted warning rather than silence (#61): the chapter carries one event larger
129
+ // than the window it exists for, and a reader must be told that `read` must go back to it in slices.
130
+ if (r.stats.events === 1) {
131
+ indivisible.push(`chapter "${r.range.title}" is ONE indivisible event (seq ${String(r.range.startSeq)}) at `
132
+ + `${String(r.stats.estimatedTokens)} est tokens, over the hard oversize ceiling ${String(ceiling)} `
133
+ + '(= chapterTokenTarget ' + String(chapterTokenTarget) + ' × ' + String(oversizeFactor) + ') — archived whole, '
134
+ + 'because clipping is forbidden and no split can shrink a single event; reload it with read offset/limit');
135
+ continue;
136
+ }
137
+ errors.push(`"${r.range.title}" (seqs ${String(r.range.startSeq)}-${String(r.range.endSeq)}) rendered ${String(r.stats.estimatedTokens)} est tokens, `
138
+ + `over the hard oversize ceiling ${String(ceiling)} (= chapterTokenTarget ${String(chapterTokenTarget)} × ${String(oversizeFactor)}) `
139
+ + '— split at the next boundary; clipping is forbidden');
140
+ }
141
+ return { errors, indivisible };
142
+ }
143
+ /** Enumerate tool results in a range so the model can decide keep/defer against real sizes. */
144
+ /** Every oversized tool result in a range, ranked by size DESCENDING (dedupe keeps the last copy). */
145
+ function collectCandidates(events, range) {
146
+ const out = [];
147
+ for (const ev of events) {
148
+ if (ev.type !== 'tool/result' || ev.seq < range.startSeq || ev.seq > range.endSeq)
149
+ continue;
150
+ {
151
+ const msg = ev.data?.message;
152
+ const cid = msg?.source?.callId;
153
+ if (typeof cid === 'string') {
154
+ let last = -1;
155
+ for (const o of events)
156
+ if (o.type === 'tool/result' && o.seq >= range.startSeq && o.seq <= range.endSeq) {
157
+ const om = o.data?.message;
158
+ if (om?.source?.callId === cid && o.seq > last)
159
+ last = o.seq;
160
+ }
161
+ if (ev.seq !== last)
162
+ continue; // keep last copy of a redelivery
163
+ }
164
+ }
165
+ const text = toolResultText(ev.data ?? {});
166
+ const call = events.find((c) => c.type === 'tool/call' && c.seq < ev.seq && sameCall(c, ev));
167
+ out.push({
168
+ seq: ev.seq,
169
+ toolName: String(call?.data?.name ?? ev.data?.toolName ?? 'tool'),
170
+ bytes: Buffer.byteLength(text, 'utf8'),
171
+ estimatedTokens: estimateTokens(text),
172
+ excerpt: text.slice(0, 160),
173
+ });
174
+ }
175
+ // NO sort without a cap: log order is what every existing caller (and the model's own
176
+ // `toolResultOverrides` list) already sees, and reordering it would be an undocumented behavior
177
+ // change riding along with a loudness fix. Ranking belongs to the cap alone.
178
+ return out;
179
+ }
180
+ /**
181
+ * The capped public form (#61). `chapters_segment` once emitted 49,932 characters of its OWN output while
182
+ * also reporting `(Omitted 275266 bytes …)` — ≈81K est tokens into a 32K-class window, asking a small
183
+ * model to judge a candidate list it never received. Ranking is by size descending, so what a cap
184
+ * removes is the SMALL stuff: the judgment the model is actually asked for stays intact.
185
+ *
186
+ * The caller reports what was withheld — see `candidateTotals` — because a capped list that does not say
187
+ * it is capped is a silent truncation, and AGENTS.md forbids truncating to fit.
188
+ */
189
+ export function toolResultCandidates(events, range, capTokens) {
190
+ if (capTokens === undefined)
191
+ return collectCandidates(events, range);
192
+ const ranked = [...collectCandidates(events, range)].sort((a, b) => b.bytes - a.bytes);
193
+ let used = 0;
194
+ const kept = [];
195
+ for (const c of ranked) {
196
+ if (used + c.estimatedTokens > capTokens && kept.length > 0)
197
+ break;
198
+ kept.push(c);
199
+ used += c.estimatedTokens;
200
+ }
201
+ return kept;
202
+ }
203
+ /** Totals for the WHOLE candidate set, so a capped response can name what it left out. */
204
+ export function candidateTotals(events, range) {
205
+ const all = collectCandidates(events, range);
206
+ return { count: all.length, bytes: all.reduce((m, c) => m + c.bytes, 0) };
207
+ }
208
+ /**
209
+ * Render one chapter. `overrides` is the model's sparse exception list; anything not named follows the
210
+ * default rule (small results inline, large ones deferred). Either way the artifact is written, so a wrong
211
+ * call costs a `read` and never loses data.
212
+ *
213
+ * Plans (docs/plans-carriage.md § 2) are read over the WHOLE `events` list, not just the range: a
214
+ * submission and its verdict can straddle a chapter boundary, and pairing them inside one range only
215
+ * would report an approved plan as `pending`. The plans block is appended AFTER the verbatim
216
+ * transcript, so the transcript stays exactly what the log said.
217
+ */
218
+ export function renderChapter(events, range, config, overrides = [], topics = [],
219
+ /** Optional CodeGraph symbols verified against the project index. Written to
220
+ * frontmatter when non-empty; absent → no field emitted (backward compatible). */
221
+ codegraphSymbols) {
222
+ const inline = new Map(overrides.map((o) => [o.seq, o.inline]));
223
+ // render → redact → write: the single chokepoint, so chapters AND the
224
+ // deferred artifact files (whose bodies are the redacted text, via
225
+ // ArtifactRef.content) are covered without caller changes.
226
+ const redact = (t) => redactText(t, config.redactions ?? DEFAULT_REDACTIONS).text;
227
+ const inRange = events
228
+ .filter((e) => e.seq >= range.startSeq && e.seq <= range.endSeq)
229
+ .sort((a, b) => a.seq - b.seq);
230
+ // Host redelivery (measured 2026-09-28, treeseed run: 25 calls carried TWO
231
+ // tool/result events for one callId — read x17, chapters_artifact x3,
232
+ // web_fetch x2): keep only the LAST result per callId, else the verbatim
233
+ // body inflates with the same text twice and counts double.
234
+ const lastResultSeq = new Map();
235
+ for (const ev of inRange) {
236
+ if (ev.type !== 'tool/result')
237
+ continue;
238
+ const cid = resultCallId(ev);
239
+ if (cid !== undefined)
240
+ lastResultSeq.set(cid, ev.seq);
241
+ }
242
+ // Plans are scanned over the WHOLE event list, not just this range: a verdict
243
+ // can land past a chapter boundary, and pairing inside one range only would
244
+ // report an APPROVED plan as pending (docs/plans-carriage.md § 1).
245
+ const scan = detectPlans(events, { ceiling: range.endSeq });
246
+ const chapterPlans = scan.plans.filter((p) => p.seq >= range.startSeq && p.seq <= range.endSeq);
247
+ const flipsTo = scan.flips.filter((f) => f.seq <= range.endSeq);
248
+ const modeAtEnd = flipsTo.length === 0 ? 'unknown' : flipsTo[flipsTo.length - 1].active ? 'active' : 'inactive';
249
+ const body = [`# ${range.title}`, '', `> ${range.summary}`, ''];
250
+ const artifacts = [];
251
+ const unrenderedSeqs = [];
252
+ let toolCalls = 0;
253
+ let inlinedCount = 0;
254
+ let deferredCount = 0;
255
+ let conversationMessages = 0;
256
+ let invocationArtifacts = 0;
257
+ for (const ev of inRange) {
258
+ const data = (ev.data ?? {});
259
+ switch (ev.type) {
260
+ case 'user/message': {
261
+ // The one boundary (src/injections.ts): host-injected context — a
262
+ // project's own instruction files, runtime snapshots, skill catalogs,
263
+ // and THIS plugin's TOC notices — is not conversation. Whole injected
264
+ // events render as a countable marker; injected spans inside human
265
+ // messages are removed the same way. Originals stay in the session log.
266
+ const screen = screenConversation(data, textOf(data.content), 'user');
267
+ if (screen.omittedEvent) {
268
+ body.push(omittedMarker(screen.sourceKind, screen.removedChars), '');
269
+ break;
270
+ }
271
+ if (screen.removedChars > 0)
272
+ body.push(omittedMarker('<system-reminder> span(s)', screen.removedChars), '');
273
+ const text = redact(screen.text);
274
+ if (text.length === 0) {
275
+ if (screen.removedChars === 0)
276
+ unrenderedSeqs.push(ev.seq); // a marker above IS a render
277
+ break;
278
+ }
279
+ conversationMessages += 1;
280
+ body.push(`**User:**`, '', fenced(text), '');
281
+ break;
282
+ }
283
+ case 'assistant/message': {
284
+ const message = (data.message ?? data);
285
+ const asstSpans = stripReminderSpans(textOf(message.content));
286
+ if (asstSpans.removedChars > 0)
287
+ body.push(omittedMarker('<system-reminder> span(s)', asstSpans.removedChars), '');
288
+ const text = redact(asstSpans.text);
289
+ const reasoning = redact(stripReminderSpans(blocksOf(message.content)
290
+ .filter((b) => b.type === 'reasoning')
291
+ .map((b) => b.text ?? '')
292
+ .join('\n')).text);
293
+ let rendered = false;
294
+ if (reasoning.length > 0) {
295
+ body.push('<sub>_reasoning:_</sub>', '', fenced(reasoning), '');
296
+ rendered = true;
297
+ }
298
+ if (text.length > 0) {
299
+ body.push(`**Assistant:**`, '', fenced(text), '');
300
+ rendered = true;
301
+ }
302
+ if (rendered)
303
+ conversationMessages += 1;
304
+ else if (asstSpans.removedChars === 0)
305
+ unrenderedSeqs.push(ev.seq);
306
+ break;
307
+ }
308
+ case 'tool/call': {
309
+ toolCalls += 1;
310
+ const name = String(data.name ?? 'unknown');
311
+ const args = redact(callArgsText(data));
312
+ if (name === PLAN_TOOL) {
313
+ // The Plans block below owns this plan's bytes (full artifact + head +
314
+ // outline + verdict). Point at it rather than printing a JSON envelope
315
+ // whose head would look like the plan and is not.
316
+ const doc = chapterPlans.find((p) => p.seq === ev.seq);
317
+ body.push(`- ↳ **${name}** plan "${doc?.title ?? '(unparsed)'}" — verbatim text and the user's verdict in the Plans section below (seq ${ev.seq})`);
318
+ break;
319
+ }
320
+ const floor = config.callArgArtifactTokens ?? 0;
321
+ if (floor > 0 && estimateTokens(args) > floor) {
322
+ // Large invocation arguments are artifact-backed, so the bounded head
323
+ // below can never be the only copy of what was attempted.
324
+ const ref = artifactRef(args, { toolName: name, sourceSeq: ev.seq, inlined: false });
325
+ artifacts.push(ref);
326
+ invocationArtifacts += 1;
327
+ body.push(`- ↳ **${name}** \`${args.slice(0, CALL_HEAD_CHARS)}\`${args.length > CALL_HEAD_CHARS ? ' …' : ''} (seq ${ev.seq})`
328
+ + ` — full arguments: ${ref.bytes} B ≈ ${estimateTokens(args)} tok, \`${ref.sha256.slice(0, 7)}…\` → \`${ref.path}\``);
329
+ break;
330
+ }
331
+ // Invocations stay inline as a bounded head: this is the "what was
332
+ // attempted" half of the record (docs/architecture.md § Chapter Rendering).
333
+ body.push(`- ↳ **${name}** \`${args.slice(0, CALL_HEAD_CHARS)}\`${args.length > CALL_HEAD_CHARS ? ' …' : ''} (seq ${ev.seq})`);
334
+ break;
335
+ }
336
+ case 'plan/mode':
337
+ // NOT a hole and NOT prose: the mode flip is printed in the Plans block
338
+ // below and folded into the `planMode:` frontmatter line. Naming the type
339
+ // here is what keeps it out of unrenderedSeqs — an unnamed event would
340
+ // look like a silently dropped byte (verify.md P4).
341
+ break;
342
+ case 'tool/result': {
343
+ {
344
+ const cid = resultCallId(ev);
345
+ if (cid !== undefined && lastResultSeq.get(cid) !== ev.seq)
346
+ break; // earlier copy of a redelivery
347
+ }
348
+ const text = redact(toolResultText(data));
349
+ // #59 — an empty result defers NOTHING. `arrival.ts` has always skipped the empty string;
350
+ // the renderer did not, so a payload-less read wrote hundreds of references to sha256("") and
351
+ // the chapter claimed `artifacts: 102` while its tree held one 0-byte file. Say it is empty.
352
+ if (text.length === 0) {
353
+ body.push(` - result (seq ${ev.seq}, empty)`);
354
+ break;
355
+ }
356
+ const tokens = estimateTokens(text);
357
+ const wantsInline = inline.get(ev.seq) ?? tokens <= config.toolResultDeferFloorTokens;
358
+ const priorCall = [...inRange].reverse().find((c) => c.type === 'tool/call' && c.seq < ev.seq);
359
+ const toolName = String(priorCall?.data?.name ?? 'tool');
360
+ const ref = artifactRef(text, { toolName, sourceSeq: ev.seq, inlined: wantsInline });
361
+ artifacts.push(ref);
362
+ if (wantsInline) {
363
+ inlinedCount += 1;
364
+ body.push(` - result (seq ${ev.seq}, ${ref.bytes} B, also at \`${ref.path}\`):`, '', fenced(text), '');
365
+ }
366
+ else {
367
+ deferredCount += 1;
368
+ body.push(` - result: ${ref.bytes} B ≈ ${tokens} tok, \`${ref.sha256.slice(0, 7)}…\` → \`${ref.path}\``);
369
+ }
370
+ break;
371
+ }
372
+ case 'turn/start':
373
+ case 'turn/end':
374
+ case 'step/start':
375
+ case 'step/end':
376
+ break; // lifecycle noise: intentionally absent from the body, ranges still recorded in frontmatter
377
+ default:
378
+ unrenderedSeqs.push(ev.seq);
379
+ }
380
+ }
381
+ // ---- Plans block, AFTER the verbatim transcript (docs/plans-carriage.md § 2).
382
+ // Absent entirely when the chapter holds no plan and no mode flip — a session
383
+ // that never entered plan mode gains ZERO extra bytes, which is what keeps the
384
+ // model tapes byte-identical (verify.md P8).
385
+ const planFacts = [];
386
+ const planDocs = [];
387
+ const flipsInRange = scan.flips.filter((f) => f.seq >= range.startSeq && f.seq <= range.endSeq);
388
+ if (chapterPlans.length > 0 || flipsInRange.length > 0) {
389
+ body.push('', '---', '', '## Plans in this chapter', '');
390
+ for (const p of chapterPlans) {
391
+ const planBody = redact(p.text);
392
+ const ref = artifactRef(planBody, { toolName: PLAN_TOOL, sourceSeq: p.seq, inlined: false });
393
+ artifacts.push(ref);
394
+ const tokens = estimateTokens(planBody);
395
+ planFacts.push({
396
+ seq: p.seq,
397
+ status: p.status,
398
+ title: p.title,
399
+ sha256: ref.sha256,
400
+ artifactPath: ref.path,
401
+ bytes: ref.bytes,
402
+ estTokens: tokens,
403
+ resultSeq: p.resultSeq,
404
+ });
405
+ const kb = ref.bytes < 1024 ? `${ref.bytes} B` : `${(ref.bytes / 1024).toFixed(1)} KB`;
406
+ body.push(`### Plan — "${p.title}" — ${p.status.toUpperCase()}`, '');
407
+ body.push(`- verdict: submitted seq ${p.seq}`
408
+ + (p.resultSeq !== null
409
+ ? ` → answered seq ${p.resultSeq}: \`${(p.verdict ?? '').slice(0, 200).replace(/`/g, '\'')}\``
410
+ : ' — NO RESULT: the review was never answered (pending); do not treat this as approved'));
411
+ body.push(`- full text: \`${ref.path}\` (${kb} ≈ ${tokens} tok, \`${ref.sha256.slice(0, 7)}…\`) — query it with chapters_artifact (path or sha): action 'toc' | 'search' | 'read'`);
412
+ if (p.outline.length > 0)
413
+ body.push(`- outline: ${p.outline.join(' · ')}`);
414
+ const headChars = config.planInlineChars ?? PLAN_HEAD_FALLBACK_CHARS;
415
+ if (headChars > 0 && planBody.length > 0) {
416
+ body.push(`- head (first ${headChars} chars, verbatim):`, '', fenced(planBody.slice(0, headChars)), '');
417
+ }
418
+ if (p.status === 'superseded') {
419
+ body.push('- this submission was superseded by a later one of the same plan — the newest is operative');
420
+ }
421
+ // The plan ALSO becomes a document of its own (docs/plans-carriage.md § 11):
422
+ // indexed by topic, searchable as a plan, and addressable across sessions —
423
+ // which is what "how did this plan evolve, and what happened to it" needs.
424
+ // The artifact stays the content-addressed retrieval handle; the document is
425
+ // the queryable record. Same bytes, two addressing roles, one write each.
426
+ planDocs.push({
427
+ seq: p.seq,
428
+ title: p.title,
429
+ status: p.status,
430
+ body: planBody,
431
+ sha256: ref.sha256,
432
+ topics: [...new Set(planTopicsOf(planBody))],
433
+ estTokens: tokens,
434
+ });
435
+ body.push('');
436
+ }
437
+ for (const f of flipsInRange) {
438
+ body.push(`- plan mode ${f.active ? 'ACTIVE' : 'inactive'} from seq ${f.seq} (log event plan/mode)`);
439
+ }
440
+ body.push(`- plan mode at chapter end: ${modeAtEnd}`);
441
+ }
442
+ const bodyText = body.join('\n');
443
+ const estimatedTokens = estimateTokens(bodyText);
444
+ const frontmatter = [
445
+ '---',
446
+ `title: ${JSON.stringify(range.title)}`,
447
+ `seqRange: [${range.startSeq}, ${range.endSeq}]`,
448
+ `events: ${inRange.length}`,
449
+ `tokens: ${estimatedTokens} # chars/4 estimate`,
450
+ // The declared anchor MUST hash exactly what parseChapterFile returns as
451
+ // the body (file = frontmatter + bodyText + '\n' ⇒ parsed body carries the
452
+ // trailing newline). Hashing bare bodyText made EVERY real chapter fail
453
+ // the enrich guard's self-consistency check — caught by the enrich e2e
454
+ // 2026-09-23, invisible for eras behind the host logger.
455
+ `sha256: ${sha256(`${bodyText}\n`)}`,
456
+ `topics: [${[...new Set(topics)].map((t) => JSON.stringify(t)).join(', ')}]`,
457
+ // #59: the two numbers are NOT the same thing. Content-addressed reuse means 335 references
458
+ // can point at one blob; reporting references as `artifacts:` made both the field diagnostic and
459
+ // the registry bloat unreadable.
460
+ `artifacts: ${new Set(artifacts.map((a) => a.sha256)).size}`,
461
+ `artifactRefs: ${artifacts.length}`,
462
+ // Plans ride in frontmatter ONLY when this chapter actually carries one, so a
463
+ // session that never entered plan mode writes byte-identical files to before
464
+ // this feature (verify.md P8). Compact `seq:status:sha8` triples: the minimal
465
+ // parser in indexing.ts reads flat scalar lists, never nested YAML. The full
466
+ // facts live in the registry record; the prose lives in the Plans block.
467
+ ...(planFacts.length > 0
468
+ ? [`plans: [${planFacts.map((p) => `${p.seq}:${p.status}:${p.sha256.slice(0, 8)}`).join(', ')}]`]
469
+ : []),
470
+ ...(chapterPlans.length > 0 || flipsInRange.length > 0 ? [`planMode: ${modeAtEnd}`] : []),
471
+ // CodeGraph symbols verified against the project index during this chapter's
472
+ // turns. Written ONLY when non-empty so existing chapters stay byte-identical.
473
+ ...(codegraphSymbols !== undefined && codegraphSymbols.length > 0
474
+ ? [`codegraphSymbols: [${[...new Set(codegraphSymbols)].map((s) => JSON.stringify(s)).join(', ')}]`]
475
+ : []),
476
+ // Subagent lineage (issue #47): stamped ONLY when the authoring session's
477
+ // header carries parentSession — a plain session's file is byte-identical to
478
+ // the pre-feature shape (verify discipline same as the plans block, P8), and
479
+ // the value is the host's own durable fact, never an inference.
480
+ ...(config.subagentOf !== undefined && config.subagentOf !== ''
481
+ ? ['subagent: true', `parentSession: ${JSON.stringify(config.subagentOf)}`]
482
+ : []),
483
+ `unrenderedSeqs: [${unrenderedSeqs.join(', ')}]`,
484
+ '---',
485
+ '',
486
+ ].join('\n');
487
+ return {
488
+ range,
489
+ topics: [...new Set(topics)],
490
+ markdown: frontmatter + bodyText + '\n',
491
+ artifacts,
492
+ stats: {
493
+ estimatedTokens,
494
+ estimatedBytes: Buffer.byteLength(bodyText, 'utf8'),
495
+ events: inRange.length,
496
+ messages: conversationMessages,
497
+ toolCalls,
498
+ toolResultsInlined: inlinedCount,
499
+ toolResultsDeferred: deferredCount,
500
+ overTarget: estimatedTokens > config.chapterTokenTarget,
501
+ unrenderedSeqs,
502
+ plans: planFacts,
503
+ planDocs,
504
+ planMode: modeAtEnd,
505
+ invocationArtifacts,
506
+ },
507
+ };
508
+ }
509
+ /**
510
+ * The chapter index inside a continuation notice, capped at `cap` lines (0 = list everything).
511
+ *
512
+ * The cap is the difference between a notice whose size tracks history and one that settles. Each line costs
513
+ * ~120 characters, so an uncapped index moves the surface across the compaction trigger depending on how many
514
+ * chapters a run happened to write — which makes a leg parked on that boundary undistillable as a tape (measured
515
+ * on `heavy`: identical served bytes, boundary one assistant/tool round apart between two runs). Past the cap the
516
+ * text is byte-identical, and the omission is STATED rather than silent, because a model that cannot see the
517
+ * list must still know to search for it.
518
+ */
519
+ export function noticeIndexSection(entries, cap) {
520
+ if (cap <= 0 || entries.length <= cap)
521
+ return renderIndex(entries);
522
+ return renderIndex(entries.slice(0, cap))
523
+ + '\n… and older chapters are not listed here — use chapters_search to reach them.';
524
+ }
525
+ export function renderIndex(entries) {
526
+ return entries
527
+ .map((e, i) => `${i + 1}. [${e.title}](${e.path}) — ${e.summary}`)
528
+ .join('\n');
529
+ }
530
+ //# sourceMappingURL=render.js.map