@fractalsynapse/dsh-chapters 0.0.0-stage → 0.2.20

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 -4
@@ -0,0 +1,270 @@
1
+ import { type SessionState } from './registry.ts';
2
+ import type { SessionEventLike } from './types.ts';
3
+ /** The stored override key (durable kv, same table as `enrichment.model`). */
4
+ export declare const PROMPT_SETTING_KEY = "prompt.model";
5
+ export interface PromptRoute {
6
+ provider: string;
7
+ model: string;
8
+ }
9
+ /**
10
+ * An offer, or a refusal. `quiet: true` marks the refusals that are NOT news — "there is nothing to recommend
11
+ * yet" and "suggestions are off" — so a caller can tell silence from failure (#113: the first thing the
12
+ * feature did on a fresh thread was shout). Genuine failures (no route, assembly, the model call) leave
13
+ * `quiet` falsy so they stay visible.
14
+ *
15
+ * WHY THIS IS NOT WIRED INTO `/chapters-prompt suggest` yet: the composer ghost obtains its suggestion by
16
+ * EXECUTING that same command over the client remote (`src/client/prompt-ghost.ts:61-65`) and treats
17
+ * `kind === 'success'` with non-empty text as AN OFFER. So softening the command's `kind` would render
18
+ * "no suggestion — nothing to recommend yet" as ghost text: a new bug, worse than the one it fixes. The
19
+ * command result carries no payload slot for this distinction, and the ghost's own guard
20
+ * (`shouldShowGhost`) has nothing to key on. Fixing the silence properly needs one of: a structured field on
21
+ * the command result the client may read, the ghost calling the surface through its own seam instead of a
22
+ * human-facing command, or the refusal text being machine-tagged. Decided deliberately, not by prose-prefix
23
+ * matching — see #113.
24
+ */
25
+ export interface PromptSuggestionResult {
26
+ ok: boolean;
27
+ text: string; /** 'nothing to recommend' when quiet; otherwise what went wrong — the bar shows it (#61). */
28
+ quiet?: boolean;
29
+ reason?: string;
30
+ }
31
+ /** What the surface will and won't accept, for a caller that must REPORT it (the command block, #113). */
32
+ export interface PromptLimits {
33
+ maxChars: number;
34
+ contextChars: number;
35
+ }
36
+ export interface PromptConfig {
37
+ /** config default for the model; '' ⇒ off unless a stored override says otherwise. */
38
+ model: string;
39
+ /** hard total budget on the assembled input slice. */
40
+ contextChars: number;
41
+ /** cap on the accepted recommendation; over-cap is invalid, never truncated. */
42
+ maxChars: number;
43
+ /** #188: share of the model's context window the derived input budget takes. */
44
+ contextRatio: number;
45
+ }
46
+ export interface PromptDeps {
47
+ getSetting(key: string): string | undefined;
48
+ putSetting(key: string, value: string): Promise<void>;
49
+ deleteSetting(key: string): Promise<void>;
50
+ /** #188 REVERSES the old economy on the maintainer's direction: the chapter history of the
51
+ * ANCESTOR PATH (a continuation mid-goal inherits its lineage, not an empty list), each entry
52
+ * carrying its plots — an index a model cannot research is worthless, so the trajectory rides
53
+ * inside the assembly instead. */
54
+ chaptersOf(session: SuggestSession): Promise<ChapterHistoryEntry[]>;
55
+ /** #188: the selected model's real context window, for the derived budget; null ⇒ no window
56
+ * known ⇒ the config fallback, loudly sourced. Absent dep ⇒ same fallback. */
57
+ resolveWindow?(route: PromptRoute): Promise<number | null>;
58
+ /** #188: the core rules in force for this session (the block a rebuilt head renders from). */
59
+ rulesOf?(session: SuggestSession): Promise<string>;
60
+ /** #188: the active plan text, when one governs the session. */
61
+ planOf?(session: SuggestSession): Promise<string>;
62
+ config: PromptConfig;
63
+ /** The last route the conversation used on this plane, or null. */
64
+ conversationRoute(): PromptRoute | null;
65
+ /** One auxiliary completion; may reject — `suggest` catches and refuses honestly. */
66
+ fetch(prompt: string, route: PromptRoute): Promise<string>;
67
+ warn?(msg: string): void;
68
+ }
69
+ /** A live session as the command hands it over: id, an optional title, and the
70
+ * surface events (what the working model sees — the same view the defining model reads). */
71
+ export interface SuggestSession {
72
+ id: string;
73
+ title?: string | undefined; /** #188: the workspace the chapter paths resolve against (the caller's cwd). */
74
+ cwd?: string | undefined;
75
+ events: SessionEventLike[]; /** #113: when a goal governs the session, the constraint on the next step — see `goalDirectiveOf`. Text, not a goal object: goal semantics stay in one module. */
76
+ goalDirective?: string | undefined;
77
+ }
78
+ /** The surface the host command and `/chapters-status` speak to. */
79
+ export interface PromptSurface {
80
+ /** Effective spec after precedence; '' ⇒ OFF. */
81
+ effective(): {
82
+ spec: string;
83
+ source: 'override' | 'config' | 'unset';
84
+ };
85
+ setModel(spec: string | null): Promise<void>;
86
+ suggest(session: SuggestSession): Promise<PromptSuggestionResult>;
87
+ /** #113: the Suggested Next Step bar's server-side access — the row plus the process-local loop flags. */
88
+ bar?: import('./prompt-state.ts').PromptBarAccess;
89
+ /** #188: the EFFECTIVE caps with their sources, so an output can state the number it validated
90
+ * against AND where it came from (stored beside the model, derived from its window, or config). */
91
+ limits(route: PromptRoute | null): Promise<PromptLimits & {
92
+ sources: PromptLimitSources;
93
+ }>;
94
+ /** #188: the caps ride beside the model — stored strings ('' deletes), validated by the caller. */
95
+ setCaps?(opts: {
96
+ maxChars?: string | null;
97
+ contextChars?: string | null;
98
+ }): Promise<void>;
99
+ /** One line for /chapters-status — ON names the model, OFF names the command. */
100
+ statusLine(): string;
101
+ }
102
+ /**
103
+ * Effective spec = stored override ?? config default, trimmed. `undefined`
104
+ * (row absent — cleared) falls back; a STORED '' is the deliberate OFF.
105
+ */
106
+ export declare function effectivePromptSpec(setting: string | undefined, configModel: string): {
107
+ spec: string;
108
+ source: 'override' | 'config' | 'unset';
109
+ };
110
+ /**
111
+ * 'provider/model' routes as named; a bare model name rides the conversation's
112
+ * provider (the enrich-wire rule); '' is not a route. A bare name with no
113
+ * provider answers with its reason rather than guessing.
114
+ */
115
+ export declare function resolvePromptRoute(spec: string, conversation: PromptRoute | null): {
116
+ route: PromptRoute | null;
117
+ reason?: string;
118
+ };
119
+ /**
120
+ * The conversation tail from surface events, screened through the ONE injection
121
+ * predicate (docs/contract.md, #59): injected events drop, injected spans strip.
122
+ * Chronological; text-bearing human/assistant messages only.
123
+ */
124
+ export declare function tailFromEvents(events: SessionEventLike[]): {
125
+ role: 'user' | 'assistant';
126
+ text: string;
127
+ }[];
128
+ /** One chapter of the assembled history (#188): the index facts PLUS the trajectory. A model that
129
+ * cannot research gains nothing from an index it cannot follow — so each chapter carries its plots. */
130
+ export interface ChapterHistoryEntry {
131
+ number: number;
132
+ title: string;
133
+ summary: string;
134
+ plots: string[];
135
+ }
136
+ /** The settings keys the caps ride beside the model in (#188): switch models, switch its caps. */
137
+ export declare const PROMPT_MAXCHARS_KEY = "prompt.maxChars";
138
+ export declare const PROMPT_CONTEXTCHARS_KEY = "prompt.contextChars";
139
+ /**
140
+ * Extract a chapter's trajectory from its archived bytes, deterministically: every line-start `PLOT:`
141
+ * in order, consecutive duplicates collapsed (a checkpoint carries the plot forward verbatim — a
142
+ * repeat, not a new fact). Zero model tokens, works on chapters archived before this feature existed,
143
+ * and prose that merely mentions the word mid-line is not a plot line.
144
+ */
145
+ export declare function extractPlots(body: string): string[];
146
+ export interface PromptLimitSources {
147
+ maxChars: 'stored' | 'config';
148
+ contextChars: 'stored' | 'derived' | 'config';
149
+ }
150
+ /**
151
+ * The caps resolution chain (#188): stored-beside-the-model → derived from the model's REAL context
152
+ * window (`window × ratio` tokens at the repo's one estimator, 4 chars/token) → config fallback. A
153
+ * 1M-window model and a 32K model get budgets 30× apart from ONE rule; a garbage stored value is
154
+ * ignored rather than parsed into a NaN budget; and the source of each number is reported, because a
155
+ * cap nobody can attribute is a cap nobody can tune.
156
+ */
157
+ export declare function resolvePromptLimits(input: {
158
+ config: {
159
+ maxChars: number;
160
+ contextChars: number;
161
+ contextRatio: number;
162
+ };
163
+ window?: number | null;
164
+ stored?: {
165
+ maxChars?: string;
166
+ contextChars?: string;
167
+ };
168
+ }): {
169
+ maxChars: number;
170
+ contextChars: number;
171
+ sources: PromptLimitSources;
172
+ };
173
+ /**
174
+ * The LAST turn, whole (#188): from the final human request through every assistant text that
175
+ * followed it. Reuses the tail's defusing machinery (host-injected context screened, reminder spans
176
+ * stripped) and then keeps only the closing exchange — the present the director must follow from.
177
+ * Never sliced: a message enters whole or the assembly refuses.
178
+ */
179
+ export declare function lastTurnFromEvents(events: SessionEventLike[]): {
180
+ role: 'user' | 'assistant';
181
+ text: string;
182
+ }[];
183
+ export interface SuggestFacts {
184
+ /** #113: the active goal's constraint on the next step, already worded. Absent ⇒ no goal block. */
185
+ goalDirective?: string | undefined;
186
+ /** #188: the active plan text, when one governs the session. */
187
+ plan?: string | undefined;
188
+ /** #188: the core rules in force — the same block a rebuilt head renders from. */
189
+ rules?: string | undefined;
190
+ title?: string | undefined;
191
+ /** The ancestor path's chapters, oldest first, each with its plots (#188). */
192
+ chapters: ChapterHistoryEntry[];
193
+ /** The last turn whole: the human's request through the final response (#188 — never sliced). */
194
+ lastTurn: {
195
+ role: 'user' | 'assistant';
196
+ text: string;
197
+ }[];
198
+ }
199
+ /**
200
+ * Assemble the auxiliary input: deterministic (same facts ⇒ same bytes), hard
201
+ * total ≤ `capChars`. Oldest chapter lines drop first, then oldest tail
202
+ * messages; every drop is ANNOUNCED in the input itself — a silent squeeze
203
+ * would make the answer look derived from bytes that were never sent.
204
+ */
205
+ /**
206
+ * Assemble the director's whole world (#188). Deterministic: same facts ⇒ same bytes.
207
+ *
208
+ * Two invariants the old assembly violated and this one exists to keep:
209
+ * - ZERO TRUNCATION. Nothing the model reads is ever sliced — the old 500-char per-message ellipsis
210
+ * is a hallucination invite and is gone. Messages and chapters enter whole or not at all.
211
+ * - The budget is enforced ONLY by the drop ladder: over budget, the OLDEST chapter goes first (the
212
+ * recent trajectory is what leads), each drop ANNOUNCED in the input itself. The protected set —
213
+ * skeleton, goal (objective whole; the old 600-char slice was itself a truncation), plan, rules,
214
+ * last turn — never slices; if it alone cannot fit, the suggestion REFUSES with the numbers.
215
+ * A refused suggestion costs one turn; a truncated one poisons the next.
216
+ */
217
+ export declare function buildSuggestInput(facts: SuggestFacts, budgetChars: number, maxChars: number): {
218
+ input: string;
219
+ } | {
220
+ refusal: string;
221
+ };
222
+ /**
223
+ * Validate one answer. A wrapping code fence or one pair of wrapping quotes is
224
+ * formatting noise and comes off; the rest is the human's next prompt verbatim.
225
+ * Over-cap or empty is INVALID (never truncated) — the caller retries once and
226
+ * then refuses with the measured numbers.
227
+ */
228
+ export declare function validateSuggestion(raw: string, maxChars: number): {
229
+ ok: true;
230
+ text: string;
231
+ } | {
232
+ ok: false;
233
+ reason: string;
234
+ };
235
+ /**
236
+ * The watermarks this surface caches per session: the seq of the last surface
237
+ * event at generation. A new completed turn moves the seq and retires the
238
+ * suggestion; a restart simply loses the cache (it is a convenience, never truth).
239
+ */
240
+ export declare function createPromptSurface(deps: PromptDeps): PromptSurface;
241
+ /** Narrow slice: what `registerHostCommands` needs from the surface. */
242
+ /**
243
+ * #225: the STATUS plane may ask for more than the command surface carries — `planeStatus` and
244
+ * `sessionRow` are provided by the wiring (index.ts) when it owns the transport/door facts. Typed
245
+ * as an extension so the commands layer can feature-check them honestly.
246
+ */
247
+ export interface PromptStatusExtras {
248
+ planeStatus(): string[];
249
+ sessionRow(sessionId: string): Promise<string>;
250
+ }
251
+ export type PromptCommandSurface = PromptSurface;
252
+ /** Active chapters of one registry row, mapped to the facts the input uses. */
253
+ export declare function promptChaptersOf(state: SessionState): {
254
+ number: number;
255
+ title: string;
256
+ summary: string;
257
+ }[];
258
+ /**
259
+ * The ancestor path's chapters with their plots (#188), oldest line first. `rows` is the lineage
260
+ * root-first INCLUDING the live session; `readBody` fetches one chapter's archived bytes so the
261
+ * plots come out of the durable archive itself — deterministic, zero model tokens, and it works on
262
+ * chapters archived before this feature existed. A body that cannot be read contributes its index
263
+ * facts with no plots: a missing file degrades the trajectory, never the assembly.
264
+ */
265
+ export declare function promptChaptersForLineage(opts: {
266
+ rows: {
267
+ chapters: SessionState['chapters'];
268
+ }[];
269
+ readBody: (path: string) => string | null;
270
+ }): ChapterHistoryEntry[];
package/lib/prompt.js ADDED
@@ -0,0 +1,408 @@
1
+ /**
2
+ * dsh-chapters — prompt recommendations (#71, site §28).
3
+ *
4
+ * One model reads the context and DOES the work; a second model reads the context
5
+ * and DEFINES it: the next prompt the human would send, offered as a ghost layer
6
+ * in the composer. Off by default, and the model IS the switch: the effective spec
7
+ * (stored override ?? config `promptModel`) being non-empty is what enables the
8
+ * feature — one knob, no second boolean that could drift against it.
9
+ *
10
+ * Transport here is the PULL: the client overlay executes
11
+ * `/chapters-prompt suggest` over `remote.commands` (the door-proven wire) and this
12
+ * module answers from a one-per-turn-boundary cache or one bounded auxiliary call.
13
+ * The 0.2.x push upgrade (plugin record + projection) is in docs/backlog.md; the
14
+ * measured host facts are in docs/contract.md § Composer Ghost Layer.
15
+ *
16
+ * Invariants the answer MUST respect: the recommendation is UI state only. It is
17
+ * never archived, never enters a notice or a rule, never steers a model request —
18
+ * it reaches the log only when a human accepts it and sends, as their own message.
19
+ *
20
+ * @module dsh-chapters/src/prompt
21
+ */
22
+ import { textOfBlocks } from "./log-shape.js";
23
+ import { screenConversation, stripReminderSpans } from "./injections.js";
24
+ import { isActiveChapter } from "./registry.js";
25
+ /** The stored override key (durable kv, same table as `enrichment.model`). */
26
+ export const PROMPT_SETTING_KEY = 'prompt.model';
27
+ /**
28
+ * Effective spec = stored override ?? config default, trimmed. `undefined`
29
+ * (row absent — cleared) falls back; a STORED '' is the deliberate OFF.
30
+ */
31
+ export function effectivePromptSpec(setting, configModel) {
32
+ if (setting !== undefined)
33
+ return { spec: setting.trim(), source: 'override' };
34
+ const c = configModel.trim();
35
+ return { spec: c, source: c === '' ? 'unset' : 'config' };
36
+ }
37
+ /**
38
+ * 'provider/model' routes as named; a bare model name rides the conversation's
39
+ * provider (the enrich-wire rule); '' is not a route. A bare name with no
40
+ * provider answers with its reason rather than guessing.
41
+ */
42
+ export function resolvePromptRoute(spec, conversation) {
43
+ if (spec === '')
44
+ return { route: null, reason: 'prompt suggestions are off' };
45
+ const i = spec.indexOf('/');
46
+ if (i > 0 && i < spec.length - 1)
47
+ return { route: { provider: spec.slice(0, i), model: spec.slice(i + 1) } };
48
+ if (conversation !== null && conversation.provider !== '')
49
+ return { route: { provider: conversation.provider, model: spec } };
50
+ return { route: null, reason: `no provider to route "${spec}" — name it as provider/model` };
51
+ }
52
+ /**
53
+ * The conversation tail from surface events, screened through the ONE injection
54
+ * predicate (docs/contract.md, #59): injected events drop, injected spans strip.
55
+ * Chronological; text-bearing human/assistant messages only.
56
+ */
57
+ export function tailFromEvents(events) {
58
+ const out = [];
59
+ for (const ev of events) {
60
+ const data = (ev.data ?? {});
61
+ if (ev.type === 'user/message') {
62
+ const screen = screenConversation(data, textOfBlocks(data.content), 'user');
63
+ if (screen.omittedEvent)
64
+ continue;
65
+ const text = stripReminderSpans(screen.text).text.trim();
66
+ if (text !== '')
67
+ out.push({ role: 'user', text });
68
+ }
69
+ else if (ev.type === 'assistant/message') {
70
+ const message = (data.message ?? data);
71
+ const text = stripReminderSpans(textOfBlocks(message.content)).text.trim();
72
+ if (text !== '')
73
+ out.push({ role: 'assistant', text });
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+ /** The settings keys the caps ride beside the model in (#188): switch models, switch its caps. */
79
+ export const PROMPT_MAXCHARS_KEY = 'prompt.maxChars';
80
+ export const PROMPT_CONTEXTCHARS_KEY = 'prompt.contextChars';
81
+ /**
82
+ * Extract a chapter's trajectory from its archived bytes, deterministically: every line-start `PLOT:`
83
+ * in order, consecutive duplicates collapsed (a checkpoint carries the plot forward verbatim — a
84
+ * repeat, not a new fact). Zero model tokens, works on chapters archived before this feature existed,
85
+ * and prose that merely mentions the word mid-line is not a plot line.
86
+ */
87
+ export function extractPlots(body) {
88
+ const out = [];
89
+ for (const raw of body.split('\n')) {
90
+ const m = /^PLOT:[ \t]*(.+)$/.exec(raw.trim());
91
+ if (m === null)
92
+ continue;
93
+ const t = m[1].trim();
94
+ if (t === '')
95
+ continue;
96
+ if (out.length > 0 && out[out.length - 1] === t)
97
+ continue;
98
+ out.push(t);
99
+ }
100
+ return out;
101
+ }
102
+ /**
103
+ * The caps resolution chain (#188): stored-beside-the-model → derived from the model's REAL context
104
+ * window (`window × ratio` tokens at the repo's one estimator, 4 chars/token) → config fallback. A
105
+ * 1M-window model and a 32K model get budgets 30× apart from ONE rule; a garbage stored value is
106
+ * ignored rather than parsed into a NaN budget; and the source of each number is reported, because a
107
+ * cap nobody can attribute is a cap nobody can tune.
108
+ */
109
+ export function resolvePromptLimits(input) {
110
+ const posInt = (v) => {
111
+ if (v === undefined)
112
+ return null;
113
+ const t = v.trim();
114
+ if (!/^\d+$/.test(t))
115
+ return null;
116
+ const n = Number(t);
117
+ return Number.isFinite(n) && n > 0 ? n : null;
118
+ };
119
+ const sm = posInt(input.stored?.maxChars);
120
+ const sc = posInt(input.stored?.contextChars);
121
+ const w = input.window;
122
+ const derived = w !== undefined && w !== null && Number.isFinite(w) && w > 0
123
+ ? Math.floor(w * input.config.contextRatio * 4)
124
+ : null;
125
+ const useDerived = derived !== null && derived > 0;
126
+ return {
127
+ maxChars: sm ?? input.config.maxChars,
128
+ contextChars: sc ?? (useDerived ? derived : input.config.contextChars),
129
+ sources: {
130
+ maxChars: sm !== null ? 'stored' : 'config',
131
+ contextChars: sc !== null ? 'stored' : (useDerived ? 'derived' : 'config'),
132
+ },
133
+ };
134
+ }
135
+ /**
136
+ * The LAST turn, whole (#188): from the final human request through every assistant text that
137
+ * followed it. Reuses the tail's defusing machinery (host-injected context screened, reminder spans
138
+ * stripped) and then keeps only the closing exchange — the present the director must follow from.
139
+ * Never sliced: a message enters whole or the assembly refuses.
140
+ */
141
+ export function lastTurnFromEvents(events) {
142
+ const tail = tailFromEvents(events);
143
+ let from = -1;
144
+ for (let i = tail.length - 1; i >= 0; i--) {
145
+ if (tail[i].role === 'user') {
146
+ from = i;
147
+ break;
148
+ }
149
+ }
150
+ return from === -1 ? tail : tail.slice(from);
151
+ }
152
+ /**
153
+ * Assemble the auxiliary input: deterministic (same facts ⇒ same bytes), hard
154
+ * total ≤ `capChars`. Oldest chapter lines drop first, then oldest tail
155
+ * messages; every drop is ANNOUNCED in the input itself — a silent squeeze
156
+ * would make the answer look derived from bytes that were never sent.
157
+ */
158
+ /**
159
+ * Assemble the director's whole world (#188). Deterministic: same facts ⇒ same bytes.
160
+ *
161
+ * Two invariants the old assembly violated and this one exists to keep:
162
+ * - ZERO TRUNCATION. Nothing the model reads is ever sliced — the old 500-char per-message ellipsis
163
+ * is a hallucination invite and is gone. Messages and chapters enter whole or not at all.
164
+ * - The budget is enforced ONLY by the drop ladder: over budget, the OLDEST chapter goes first (the
165
+ * recent trajectory is what leads), each drop ANNOUNCED in the input itself. The protected set —
166
+ * skeleton, goal (objective whole; the old 600-char slice was itself a truncation), plan, rules,
167
+ * last turn — never slices; if it alone cannot fit, the suggestion REFUSES with the numbers.
168
+ * A refused suggestion costs one turn; a truncated one poisons the next.
169
+ */
170
+ export function buildSuggestInput(facts, budgetChars, maxChars) {
171
+ const head = 'You are the second reader of a DSH session — a model that DEFINES the next step and never executes it.';
172
+ const replyRules = `Write the ONE next prompt the human should send, ADDRESSED TO THE MODEL as a directive the human would type into the composer ('run the tests', 'fix the flake in X first') — never advice to the user ('you should consider', 'I recommend that you'). A single imperative request; plain text; at most ${maxChars} characters; no preamble, no quotes, no markdown; it must follow from the latest state and propose nothing already done. Reply with ONLY that prompt. If it changes the standing objective, prefix it with GOAL: (the human sends those; autorun will not). Do not use the prefix for an ordinary next step.`;
173
+ const chapterBlock = (dropOldest) => {
174
+ const cs = facts.chapters.slice(dropOldest);
175
+ if (cs.length === 0)
176
+ return ['## Where it has been (no chapters yet)'];
177
+ const out = ['## Where it has been (chapters, oldest first — title, summary, and the plots that carry the trajectory)'];
178
+ for (const c of cs) {
179
+ out.push(`${c.number}. ${c.title} — ${c.summary}`);
180
+ if (c.plots.length > 0) {
181
+ out.push(' Plots:');
182
+ for (const pl of c.plots)
183
+ out.push(` - ${pl}`);
184
+ }
185
+ }
186
+ return out;
187
+ };
188
+ const assemble = (dropOldest) => {
189
+ const parts = [
190
+ head,
191
+ '',
192
+ `## Session${facts.title !== undefined && facts.title !== '' ? `: ${facts.title}` : ''}`,
193
+ // #113: the goal is stated BEFORE the history — it decides whether a candidate next step counts
194
+ // as progress, so it is never a footnote. #188: the plan and the standing orders ride with it.
195
+ ...(facts.goalDirective !== undefined && facts.goalDirective !== '' ? ['', String(facts.goalDirective)] : []),
196
+ ...(facts.plan !== undefined && facts.plan !== '' ? ['', '## Active plan', String(facts.plan)] : []),
197
+ ...(facts.rules !== undefined && facts.rules !== '' ? ['', '## Standing orders', String(facts.rules)] : []),
198
+ '',
199
+ ...chapterBlock(dropOldest),
200
+ '',
201
+ ...(facts.lastTurn.length > 0
202
+ ? ['## Last turn (request through final response)', ...facts.lastTurn.map((m) => `${m.role === 'user' ? 'Human' : 'Assistant'}: ${m.text}`)]
203
+ : ['## Last turn (empty)']),
204
+ ];
205
+ if (dropOldest > 0) {
206
+ parts.push(`(older chapters dropped to fit the ${budgetChars}-char budget: ${dropOldest} chapter(s) — the recent trajectory leads)`);
207
+ }
208
+ parts.push('', replyRules);
209
+ return parts.join('\n');
210
+ };
211
+ for (let drop = 0; drop <= facts.chapters.length; drop++) {
212
+ const input = assemble(drop);
213
+ if (input.length <= budgetChars)
214
+ return { input };
215
+ }
216
+ const floor = assemble(facts.chapters.length).length;
217
+ return {
218
+ refusal: `the suggestion input cannot fit — the protected context (goal, plan, rules, last turn) assembles to ${floor} chars, exceeding the ${budgetChars}-char budget with all ${facts.chapters.length} chapter(s) already dropped. Refusing rather than truncating (#188): raise the budget or set a larger model with /chapters-prompt model.`,
219
+ };
220
+ }
221
+ /**
222
+ * Validate one answer. A wrapping code fence or one pair of wrapping quotes is
223
+ * formatting noise and comes off; the rest is the human's next prompt verbatim.
224
+ * Over-cap or empty is INVALID (never truncated) — the caller retries once and
225
+ * then refuses with the measured numbers.
226
+ */
227
+ export function validateSuggestion(raw, maxChars) {
228
+ let text = raw.trim();
229
+ const fence = text.match(/^`{2,}[^\n]*\n([\s\S]*?)\n`{2,}\s*$/);
230
+ if (fence?.[1] !== undefined)
231
+ text = fence[1].trim();
232
+ const quoted = text.match(/^"([\s\S]+)"$/) ?? text.match(/^“([\s\S]+)”$/);
233
+ if (quoted?.[1] !== undefined)
234
+ text = quoted[1].trim();
235
+ if (text === '')
236
+ return { ok: false, reason: 'the model returned no text' };
237
+ if (text.length > maxChars)
238
+ return { ok: false, reason: `the answer is ${text.length} characters, over the ${maxChars}-char cap` };
239
+ return { ok: true, text };
240
+ }
241
+ /**
242
+ * The watermarks this surface caches per session: the seq of the last surface
243
+ * event at generation. A new completed turn moves the seq and retires the
244
+ * suggestion; a restart simply loses the cache (it is a convenience, never truth).
245
+ */
246
+ export function createPromptSurface(deps) {
247
+ const cache = new Map();
248
+ const inflight = new Map();
249
+ const refuse = (reason, quiet = false) => ({ ok: false, text: reason, ...(quiet ? { quiet: true } : {}) });
250
+ const effective = () => effectivePromptSpec(deps.getSetting(PROMPT_SETTING_KEY), deps.config.model);
251
+ // #188: one resolution for BOTH the run path and `limits()` — stored beside the model, then the
252
+ // model's real window by ratio, then config. Two ladders would drift; one cannot.
253
+ const resolveLimits = async (route) => {
254
+ const storedMax = deps.getSetting(PROMPT_MAXCHARS_KEY);
255
+ const storedCtx = deps.getSetting(PROMPT_CONTEXTCHARS_KEY);
256
+ let window = null;
257
+ if (route !== null && deps.resolveWindow !== undefined) {
258
+ try {
259
+ window = await deps.resolveWindow(route);
260
+ }
261
+ catch {
262
+ window = null;
263
+ }
264
+ }
265
+ return resolvePromptLimits({
266
+ config: { maxChars: deps.config.maxChars, contextChars: deps.config.contextChars, contextRatio: deps.config.contextRatio },
267
+ window,
268
+ stored: {
269
+ ...(storedMax !== undefined ? { maxChars: storedMax } : {}),
270
+ ...(storedCtx !== undefined ? { contextChars: storedCtx } : {}),
271
+ },
272
+ });
273
+ };
274
+ const run = async (session, seq) => {
275
+ const { spec } = effective();
276
+ const { route, reason } = resolvePromptRoute(spec, deps.conversationRoute());
277
+ if (route === null)
278
+ return refuse(`prompt suggestions cannot run: ${reason ?? 'no route'}`);
279
+ let input;
280
+ let chaptersCount = 0;
281
+ // Defaults, so a refusal inside the try still leaves a truthful cap for the retry line.
282
+ let limits = { maxChars: deps.config.maxChars, contextChars: deps.config.contextChars };
283
+ try {
284
+ const chapters = await deps.chaptersOf(session);
285
+ chaptersCount = chapters.length;
286
+ limits = await resolveLimits(route);
287
+ const lastTurn = lastTurnFromEvents(session.events);
288
+ const rules = deps.rulesOf !== undefined ? await deps.rulesOf(session) : '';
289
+ const plan = deps.planOf !== undefined ? await deps.planOf(session) : '';
290
+ const built = buildSuggestInput({
291
+ ...(session.title !== undefined ? { title: session.title } : {}),
292
+ ...(session.goalDirective !== undefined ? { goalDirective: session.goalDirective } : {}),
293
+ ...(plan !== '' ? { plan } : {}),
294
+ ...(rules !== '' ? { rules } : {}),
295
+ chapters,
296
+ lastTurn,
297
+ }, limits.contextChars, limits.maxChars);
298
+ if ('refusal' in built)
299
+ return refuse(`no recommendation: ${built.refusal}`);
300
+ input = built.input;
301
+ }
302
+ catch (error) {
303
+ return refuse(`could not assemble the context: ${String(error?.message ?? error).slice(0, 160)}`);
304
+ }
305
+ const cap = `Remember: reply with ONLY the suggested prompt, plain text, at most ${limits.maxChars} characters.`;
306
+ try {
307
+ let text = await deps.fetch(input, route);
308
+ let verdict = validateSuggestion(text, limits.maxChars);
309
+ if (!verdict.ok) {
310
+ deps.warn?.(`prompt retry (${verdict.reason}) after ${chaptersCount} chapter(s) + last turn`);
311
+ text = await deps.fetch(`${input}\n${cap}`, route);
312
+ verdict = validateSuggestion(text, limits.maxChars);
313
+ }
314
+ if (!verdict.ok)
315
+ return refuse(`no recommendation: ${verdict.reason}`);
316
+ cache.set(session.id, { text: verdict.text, seq });
317
+ return { ok: true, text: verdict.text };
318
+ }
319
+ catch (error) {
320
+ const message = `prompt model call failed: ${String(error?.message ?? error).slice(0, 160)}`;
321
+ deps.warn?.(message);
322
+ return refuse(message);
323
+ }
324
+ };
325
+ return {
326
+ effective,
327
+ async setModel(spec) {
328
+ if (spec === null)
329
+ await deps.deleteSetting(PROMPT_SETTING_KEY);
330
+ else
331
+ await deps.putSetting(PROMPT_SETTING_KEY, spec.trim());
332
+ cache.clear();
333
+ inflight.clear();
334
+ },
335
+ async suggest(session) {
336
+ const { spec } = effective();
337
+ if (spec === '')
338
+ return refuse('prompt suggestions are off — enable with /chapters-prompt model <provider/model>', true);
339
+ const events = session.events;
340
+ if (events.length === 0)
341
+ return refuse('nothing to recommend yet — the session has no surface events', true);
342
+ // #113: the cache key is the COMPLETED TURN, not the last surface event. A turn is many events — every
343
+ // step appends one — so an event-scoped key missed on each step and asked the model again, which is how
344
+ // one turn produced three different suggestions for the maintainer. Inside a turn the human sees one
345
+ // stable offer; a new turn is a new question.
346
+ let turnSeq = -1;
347
+ for (const ev of events) {
348
+ if (ev.type === 'turn/end' && ev.seq > turnSeq)
349
+ turnSeq = ev.seq;
350
+ }
351
+ const tail = tailFromEvents(events);
352
+ if (!tail.some((m) => m.role === 'assistant'))
353
+ return refuse('nothing to recommend yet — no completed assistant turn', true);
354
+ const fresh = cache.get(session.id);
355
+ if (fresh !== undefined && fresh.seq === turnSeq)
356
+ return { ok: true, text: fresh.text };
357
+ const pending = inflight.get(session.id);
358
+ if (pending !== undefined)
359
+ return await pending;
360
+ const job = run(session, turnSeq).finally(() => { inflight.delete(session.id); });
361
+ inflight.set(session.id, job);
362
+ return await job;
363
+ },
364
+ limits: (route) => resolveLimits(route),
365
+ setCaps: async (opts) => {
366
+ if (opts.maxChars !== undefined) {
367
+ if (opts.maxChars === null || opts.maxChars === '')
368
+ await deps.deleteSetting(PROMPT_MAXCHARS_KEY);
369
+ else
370
+ await deps.putSetting(PROMPT_MAXCHARS_KEY, opts.maxChars);
371
+ }
372
+ if (opts.contextChars !== undefined) {
373
+ if (opts.contextChars === null || opts.contextChars === '')
374
+ await deps.deleteSetting(PROMPT_CONTEXTCHARS_KEY);
375
+ else
376
+ await deps.putSetting(PROMPT_CONTEXTCHARS_KEY, opts.contextChars);
377
+ }
378
+ },
379
+ statusLine() {
380
+ const { spec, source } = effective();
381
+ if (spec === '')
382
+ return `Prompt suggestions: OFF${source === 'override' ? ' (explicitly off — /chapters-prompt model <provider/model> to enable)' : ' — enable with /chapters-prompt model <provider/model>'}`;
383
+ return `Prompt suggestions: ON — ${spec} (${source}; the composer ghost offers its answer, accepted only by your keystroke)`;
384
+ },
385
+ };
386
+ }
387
+ /** Active chapters of one registry row, mapped to the facts the input uses. */
388
+ export function promptChaptersOf(state) {
389
+ return state.chapters.filter(isActiveChapter).map((c) => ({ number: c.number, title: c.title, summary: c.summary }));
390
+ }
391
+ /**
392
+ * The ancestor path's chapters with their plots (#188), oldest line first. `rows` is the lineage
393
+ * root-first INCLUDING the live session; `readBody` fetches one chapter's archived bytes so the
394
+ * plots come out of the durable archive itself — deterministic, zero model tokens, and it works on
395
+ * chapters archived before this feature existed. A body that cannot be read contributes its index
396
+ * facts with no plots: a missing file degrades the trajectory, never the assembly.
397
+ */
398
+ export function promptChaptersForLineage(opts) {
399
+ const out = [];
400
+ for (const row of opts.rows) {
401
+ for (const c of row.chapters.filter(isActiveChapter)) {
402
+ const body = opts.readBody(c.path) ?? '';
403
+ out.push({ number: c.number, title: c.title, summary: c.summary, plots: body === '' ? [] : extractPlots(body) });
404
+ }
405
+ }
406
+ return out;
407
+ }
408
+ //# sourceMappingURL=prompt.js.map