1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
//! Formatting and composition helpers for `prompt-context`.
//!
//! Why: separating the Markdown rendering logic from the fetch and filter
//! layers keeps each piece independently readable and testable.
//! What: exports `compose_injection`, `push_section`, `drawer_preview`,
//! `render_tags`, `count_facts`.
//! Test: `compose_injection_truncates_at_cap`,
//! `compose_injection_empty_inputs_yields_empty`.
use ;
use ;
/// Most tags rendered on one drawer's injected preview.
///
/// Why (issue #5038): tags were rendered in full, uncapped. Over the same
/// 17,176-firing corpus the `_(tags: …)_` suffix is **53.0% of every byte the
/// hook injects** (24.9 MB of 47.0 MB) — the label outweighs the content it
/// labels on 82.3% of drawers, at a mean 333 bytes of tags against 220 bytes of
/// preview. [`INJECTION_BYTE_CAP`] does not fix that; it just means the noise
/// evicts real drawers instead of growing the block.
/// What: `4`. The rule is that a label must never outweigh its payload, so the
/// cap is the largest one whose *worst observed* tag block stays clear of
/// [`DRAWER_PREVIEW_CHARS`] (220). Per-drawer tag-block bytes after provenance
/// filtering, over the corpus:
///
/// | cap | p50 | p95 | max |
/// |---|---|---|---|
/// | none | 208 | 382 | 903 |
/// | 6 | 112 | 150 | 244 — over the content budget |
/// | 5 | 98 | 127 | 204 — 93% of it, effectively equal weight |
/// | **4** | **82** | **104** | **174** |
/// | 3 | 67 | 84 | 159 |
///
/// Combined with the provenance filter this returns 40.1% of the total
/// injection to content. Tags are not dropped entirely — they orient a model
/// that has only a 220-character preview to go on — but the surplus is
/// announced (`+N more`) rather than rendered, matching the withheld-signal
/// rule the #5037 ruling sets for drawers.
///
/// The count alone does not enforce the rule it is argued from: four 55-char
/// tags exceed 220 characters, and no cap on *how many* can prevent that.
/// [`MAX_RENDERED_TAG_CHARS`] enforces the byte side directly; this constant is
/// the common-case cap, that one is the guarantee (#5038 review).
/// Test: `injection_caps_rendered_tag_count`.
pub const MAX_RENDERED_TAGS: usize = 4;
/// Hard character ceiling on one drawer's rendered tag suffix.
///
/// Why (#5038 review): [`MAX_RENDERED_TAGS`] is justified by "a label must never
/// outweigh the payload it labels" but enforces a count, which is only a proxy.
/// Tag length is not bounded anywhere — a palace using long hyphenated tags
/// (`slate-prioritization-in-flight`, `trusty-search-reinstall-in-flight`, both
/// real tags from the corpus) blows the budget at four. This makes the stated
/// rule the actual invariant rather than an argument for a different one.
/// What: [`DRAWER_PREVIEW_CHARS`] — the same budget the content preview gets, so
/// the label can at most equal its payload, never exceed it. Measured in
/// characters, matching how `DRAWER_PREVIEW_CHARS` measures the content. One
/// exception, deliberate: a *single* tag longer than the whole budget still
/// renders whole, because a suffix reading only `_(tags: +1 more)_` tells the
/// model strictly less than the tag would.
/// Test: `injection_caps_rendered_tag_bytes`.
pub const MAX_RENDERED_TAG_CHARS: usize = DRAWER_PREVIEW_CHARS;
/// Byte budget for the rendered "Relevant KG facts" section.
///
/// Why (ADR-0028 D7): the KG section is the last of the four injection
/// sections and the only one with no length control of its own — `top_k`
/// bounded the triple *count* but a triple's rendered width is unbounded, so a
/// palace with long subject or object strings could spend the whole remaining
/// budget on the section ADR-0028 C10 measured as 93.5% plumbing estate-wide.
/// D7 allocates it 256 bytes. That is a real allocation, not a token gesture: a
/// hot triple is short by construction (`tga is_alias_for
/// trusty-git-analytics` renders to 44 bytes), so 256 bytes carries roughly
/// five of them.
/// What: `256`. Triples render until the next line would cross it, then the
/// section stops. There is no `+N more` marker — the surplus is by definition
/// lower-ranked than what fit, and announcing it would spend bytes the cap
/// exists to protect.
/// Test: `compose_injection_caps_kg_section_bytes`.
///
/// # Spec References
/// - [ADR-0028 D7](docs/adr/0028-memory-recall-tiers-standing-current-episodic.md)
pub const KG_SECTION_BYTE_CAP: usize = 256;
/// Compose the final injection block.
///
/// Why: a single coherent Markdown block is easier for the model to read
/// than three loose strings, and the section headers tell the model
/// where each piece came from so it can weigh them appropriately.
/// What: appends sections in priority order (workspace facts → drawers →
/// KG triples), each separated by a blank line. Truncates at
/// [`INJECTION_BYTE_CAP`] bytes with a `…` marker. `withheld` is how many
/// drawers the relevance floor dropped (#5037); it is announced by
/// [`withheld_notice`] only as a suffix to a drawer section that has something
/// in it.
///
/// Returns an empty string when every section is empty, and the caller prints
/// nothing rather than a placeholder. Silence is the correct output for a
/// prompt with no relevant memory: it costs zero tokens, and a sentence saying
/// so costs real ones on every turn of every session (#5819).
/// Test: `compose_injection_truncates_at_cap`,
/// `compose_injection_announces_withheld_drawers`,
/// `compose_injection_is_silent_when_everything_was_withheld`,
/// `compose_injection_empty_inputs_yields_empty`,
/// `prompt_context_recalls_palace_drawers`.
/// The bullet lines the Tier S block already printed.
///
/// Why (#5879): `gather_hot_facts` admits hot predicates from EVERY palace and
/// `select_relevant_triples` admits them from the CURRENT one, so the KG section
/// is a subset of the Tier S block above it and re-rendered whatever the two
/// shared. That cost nothing only while no palace held a hot triple; the first
/// `kg_assert` with a hot predicate makes every prompt of every session pay for
/// the duplicate.
///
/// The section stays rather than being deleted, because the two sources can
/// genuinely disagree: `gather_hot_facts` logs and SKIPS a palace whose KG fails
/// to read, and this whole block is `None` when the global fetch fails, and in
/// both cases the KG section is the only thing carrying the fact.
///
/// What: the trimmed text of every `- ` line in the block, as a set. Comparing
/// whole lines rather than substrings is deliberate — a short object like
/// `- tm` would substring-match half the block.
/// Test: `compose_injection_drops_a_kg_fact_tier_s_already_rendered`,
/// `compose_injection_keeps_a_kg_fact_tier_s_did_not_render`.
pub
/// Render the "further results were withheld" line for a non-empty drawer
/// section.
///
/// Why (issue #5037, requirement 4, narrowed by #5819): when the injection
/// already shows the reader some memories, saying how many more exist below the
/// floor is cheap and tells them `memory_recall` would return more. #5037 also
/// emitted a longer variant when the floor kept *nothing*, on the reasoning that
/// "no good match" and "empty palace" call for opposite next actions. That
/// variant is removed: it rendered on every prompt that matched nothing, which
/// is most prompts, and it spent roughly 200 bytes per turn to report having
/// nothing to say. Silence carries the same information at zero cost — the
/// reader who wants to know either way calls `memory_recall`.
/// What: one italic Markdown line naming the count and pointing at
/// `memory_recall`. Only ever appended after at least one rendered drawer.
/// Test: `compose_injection_announces_withheld_drawers`,
/// `compose_injection_is_silent_when_everything_was_withheld`.
pub
/// Render a drawer's tag suffix, filtered and capped.
///
/// Why (issue #5038): the previous inline render walked `d.tags` in full with
/// no filter and no count cap, so storage provenance and a 20-tag topical list
/// took more of the injection than the drawer content did. `memory_remember`
/// stamps every drawer with `creator:client`, `creator:version`,
/// `creator:source` and `creator:cwd` — the last an absolute path, ~90
/// characters, repeated on every drawer of every firing. Over 17,176 real
/// firings in the enriched-prompt log those are 24.2% of rendered tags but
/// **33.4% of rendered tag bytes**, and on 2.8% of drawers they are the entire
/// list, producing a `_(tags: …)_` suffix made of nothing but paths and version
/// numbers. See [`MAX_RENDERED_TAGS`] for the count-cap half.
/// What: drops the `creator:*` namespace via
/// [`crate::attribution::is_creator_tag`] — the same predicate the TUI and
/// dashboard renderers already hide those tags with — keeps at most
/// [`MAX_RENDERED_TAGS`] of the rest in stored order, and appends `+N more`
/// when any were held back so the surplus is announced rather than hidden.
/// Returns `None` (no suffix at all) when nothing survives, the common case for
/// a drawer whose only tags were provenance. Filtering is render-time only; the
/// tags stay in storage and stay queryable through `memory_list` /
/// `memory_recall`.
/// Test: `injection_drops_provenance_tags`, `injection_caps_rendered_tag_count`,
/// `injection_caps_rendered_tag_bytes`,
/// `prompt_context_injection_has_no_provenance_tags`.
pub
/// Append `section` to `out` separated by a blank line when `out`
/// already has content.
///
/// Why: avoids leading or double blank lines in the composed injection by
/// centralising the separator logic.
/// What: pushes a newline separator before `section` when `out` is non-empty,
/// then appends the section text. No-ops on empty `section`.
/// Test: indirectly via `compose_injection_*` tests.
pub
/// Collapse a drawer's content to a single-line preview capped at
/// [`DRAWER_PREVIEW_CHARS`].
///
/// Why: dumping the full drawer body would burn the byte budget on a
/// single entry; a short single-line preview is enough to remind the model
/// what's available and lets it pull more via MCP recall if needed.
/// What: whitespace-collapses and truncates to [`DRAWER_PREVIEW_CHARS`]
/// chars with a trailing `…` when cut.
/// Test: indirectly via `prompt_context_recalls_palace_drawers`; the exactness
/// the backfill report depends on is pinned by `preview_matches_injection_bullet`.
// #4891: `pub(crate)` so `commands::backfill_report` reuses this exact
// truncation when joining drawers back to the hook logs.
pub
/// Approximate the number of facts in the rendered prompt-context body.
///
/// Why: the daemon's response is plain Markdown; counting bullet lines
/// (`- ` prefix) gives a quick proxy for "how many facts were injected" that
/// is useful for log analysis without an additional round trip.
/// What: counts non-empty lines whose first non-whitespace characters are
/// `- `. Returns 0 for an empty / placeholder body.
/// Test: covered indirectly by `single_event_roundtrip` in the integration
/// tests; the heuristic is intentionally cheap and approximate.
pub