返回 CodeWhale
base_preview.rs
根目录 / crates / tui / src / prompts / base_preview.rs
1 //! Exact effective base-prompt preview (#3928).
2 //!
3 //! `/constitution preview` shows the *structured user constitution*. This module
4 //! shows something different and stricter: **the exact bytes the next turn's
5 //! system prompt is assembled from**, block by block, with provenance, digests,
6 //! and byte/token measures.
7 //!
8 //! Three properties make it trustworthy:
9 //!
10 //! - **Exactness by construction.** [`preview`] takes the very
11 //! [`SystemPrompt`] the caller is about to send. It never re-derives, re-orders,
12 //! or re-renders anything, so "what you previewed" and "what was sent" cannot
13 //! drift. [`BasePromptPreview::exact_digest`] is computed over the unredacted
14 //! assembled text and equals the digest of
15 //! [`system_prompt_flat_text`](super::system_prompt_flat_text).
16 //! - **Truthful provenance.** Each segment says where its bytes came from:
17 //! the bundled constant, a config-directory override (with the opt-in gate
18 //! that let it in), an embedder composer hook, the user-global constitution
19 //! file, workspace-generated context, or a marked WorldState fragment. No
20 //! segment is described by a source-tree path it does not actually load from.
21 //! - **Human-only.** This is a pure function of a `&SystemPrompt`. It makes no
22 //! provider call, expands no tool catalog, spawns nothing, and takes no
23 //! registry — so previewing costs nothing and cannot change the next turn.
24 //!
25 //! Display text is redacted (home paths, key-shaped tokens, URL userinfo) while
26 //! the measures and digests stay over the real bytes, so a redacted preview
27 //! still tells the truth about size and identity.
28
29 use std::path::Path;
30
31 use crate::models::{SystemBlock, SystemPrompt};
32
33 /// Pager title for the preview surface.
34 pub const PREVIEW_TITLE: &str = "Effective Base Prompt";
35
36 /// Bytes-per-token divisor for the coarse, deterministic token estimate.
37 /// Shared with the constitution's own projection so the two agree.
38 pub(crate) const APPROX_BYTES_PER_TOKEN: usize = codewhale_config::APPROX_BYTES_PER_TOKEN;
39
40 /// Deterministic size measures for a run of prompt bytes.
41 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
42 pub struct Measures {
43 pub byte_len: usize,
44 pub char_len: usize,
45 /// Coarse estimate, not a tokenizer result. Labeled as approximate wherever
46 /// it is displayed.
47 pub approx_tokens: usize,
48 }
49
50 impl Measures {
51 #[must_use]
52 pub fn of(text: &str) -> Self {
53 let byte_len = text.len();
54 Self {
55 byte_len,
56 char_len: text.chars().count(),
57 approx_tokens: byte_len.div_ceil(APPROX_BYTES_PER_TOKEN),
58 }
59 }
60
61 fn add(self, other: Self) -> Self {
62 Self {
63 byte_len: self.byte_len + other.byte_len,
64 char_len: self.char_len + other.char_len,
65 approx_tokens: self.approx_tokens + other.approx_tokens,
66 }
67 }
68 }
69
70 /// Where a segment's bytes actually came from.
71 #[derive(Debug, Clone, PartialEq, Eq)]
72 pub enum SegmentProvenance {
73 /// A compile-time constant shipped in the binary.
74 Bundled { symbol: &'static str },
75 /// A file under `$CODEWHALE_HOME` that replaced a bundled constant, plus the
76 /// opt-in environment gate that allowed it.
77 ConfigOverride {
78 path: String,
79 opt_in_env: &'static str,
80 },
81 /// An embedder replaced the static composition through the public hook.
82 EmbedderComposer,
83 /// The structured user-global constitution file.
84 UserGlobalConstitution { path: String },
85 /// Repo-local law or project instructions read from the workspace.
86 Workspace { path: String },
87 /// Generated in memory from the workspace (no file backs it).
88 WorkspaceGenerated,
89 /// A marked, volatile WorldState fragment below the cache boundary.
90 WorldStateFragment { marker: String },
91 /// Composed from several of the above; the label names which.
92 Composed { detail: String },
93 }
94
95 impl SegmentProvenance {
96 /// Short label for the preview surface.
97 #[must_use]
98 pub fn label(&self) -> String {
99 match self {
100 Self::Bundled { symbol } => format!("bundled ({symbol})"),
101 Self::ConfigOverride { path, opt_in_env } => {
102 format!("config override {path} (opted in via {opt_in_env})")
103 }
104 Self::EmbedderComposer => "embedder composer hook".to_string(),
105 Self::UserGlobalConstitution { path } => format!("user-global constitution {path}"),
106 Self::Workspace { path } => format!("workspace {path}"),
107 Self::WorkspaceGenerated => "workspace-generated (no file)".to_string(),
108 Self::WorldStateFragment { marker } => format!("world-state fragment {marker}"),
109 Self::Composed { detail } => format!("composed: {detail}"),
110 }
111 }
112
113 /// True when these bytes belong to the cache-stable prefix. WorldState
114 /// fragments drift turn-over-turn and are not cacheable.
115 #[must_use]
116 pub fn is_cache_stable(&self) -> bool {
117 !matches!(self, Self::WorldStateFragment { .. })
118 }
119 }
120
121 /// One system block, measured and attributed.
122 #[derive(Debug, Clone, PartialEq, Eq)]
123 pub struct PreviewSegment {
124 /// 0-based position in the assembled prompt.
125 pub index: usize,
126 pub label: String,
127 pub provenance: SegmentProvenance,
128 /// Measures over the **real** bytes, before redaction.
129 pub measures: Measures,
130 /// Digest over the real bytes.
131 pub digest: String,
132 /// Display text, redacted. May differ from the real bytes; `redacted` says so.
133 pub text: String,
134 pub redacted: bool,
135 }
136
137 /// The exact effective base prompt for the next turn.
138 #[derive(Debug, Clone, PartialEq, Eq)]
139 pub struct BasePromptPreview {
140 pub segments: Vec<PreviewSegment>,
141 /// Totals over every segment's real bytes.
142 pub total: Measures,
143 /// Totals over the cache-stable prefix only.
144 pub cache_stable: Measures,
145 /// Digest of the full assembled, unredacted prompt text. This is the
146 /// identity check: it must equal the digest of what is actually sent.
147 pub exact_digest: String,
148 /// How many segments were redacted for display.
149 pub redacted_segments: usize,
150 }
151
152 impl BasePromptPreview {
153 /// True when nothing needed masking, so the displayed text is byte-exact.
154 #[must_use]
155 pub fn display_is_exact(&self) -> bool {
156 self.redacted_segments == 0
157 }
158
159 /// Rejoin the displayed segments the same way the prompt is assembled.
160 #[must_use]
161 pub fn display_text(&self) -> String {
162 self.segments
163 .iter()
164 .map(|segment| segment.text.as_str())
165 .collect::<Vec<_>>()
166 .join("\n\n")
167 }
168 }
169
170 /// Which source the effective base prompt currently comes from.
171 #[derive(Debug, Clone, PartialEq, Eq)]
172 pub enum BasePromptSource {
173 Bundled,
174 ConfigOverride { path: String },
175 EmbedderComposer,
176 }
177
178 impl BasePromptSource {
179 fn provenance(&self) -> SegmentProvenance {
180 match self {
181 Self::Bundled => SegmentProvenance::Bundled {
182 symbol: "BASE_PROMPT",
183 },
184 Self::ConfigOverride { path } => SegmentProvenance::ConfigOverride {
185 path: path.clone(),
186 opt_in_env: super::BASE_PROMPT_OVERRIDE_OPT_IN_ENV,
187 },
188 Self::EmbedderComposer => SegmentProvenance::EmbedderComposer,
189 }
190 }
191 }
192
193 /// Sources the caller already knows, so the preview never guesses.
194 ///
195 /// Everything here is provenance metadata. Supplying it wrong makes the labels
196 /// wrong; it can never change the previewed bytes, which come from the
197 /// `SystemPrompt` alone.
198 #[derive(Debug, Clone, Default)]
199 pub struct PreviewSources<'a> {
200 /// Where the effective base prompt came from. `None` means bundled.
201 pub base_prompt: Option<BasePromptSource>,
202 /// Path of the loaded user-global constitution, when one is active.
203 pub user_constitution_path: Option<&'a Path>,
204 /// Workspace root, used to shorten workspace-relative paths in display text.
205 pub workspace: Option<&'a Path>,
206 /// Home directory, masked to `~` in display text.
207 pub home: Option<&'a Path>,
208 }
209
210 /// Build the exact effective base-prompt preview.
211 ///
212 /// Pure over `prompt`: no I/O beyond the caller-supplied path metadata, no
213 /// provider call, no tool expansion.
214 #[must_use]
215 pub fn preview(prompt: &SystemPrompt, sources: &PreviewSources<'_>) -> BasePromptPreview {
216 let blocks: Vec<SystemBlock> = match prompt {
217 SystemPrompt::Text(text) => vec![SystemBlock {
218 block_type: "text".to_string(),
219 text: text.clone(),
220 cache_control: None,
221 }],
222 SystemPrompt::Blocks(blocks) => blocks.clone(),
223 };
224
225 let mut segments = Vec::with_capacity(blocks.len());
226 let mut total = Measures::default();
227 let mut cache_stable = Measures::default();
228 let mut redacted_segments = 0;
229
230 for (index, block) in blocks.iter().enumerate() {
231 let provenance = classify(index, &block.text, sources);
232 let measures = Measures::of(&block.text);
233 let display = redact(&block.text, sources);
234 let redacted = display != block.text;
235 if redacted {
236 redacted_segments += 1;
237 }
238 total = total.add(measures);
239 if provenance.is_cache_stable() {
240 cache_stable = cache_stable.add(measures);
241 }
242 segments.push(PreviewSegment {
243 index,
244 label: segment_label(index, &block.text),
245 provenance,
246 measures,
247 digest: digest(&block.text),
248 text: display,
249 redacted,
250 });
251 }
252
253 let exact = super::system_prompt_flat_text(prompt);
254 BasePromptPreview {
255 segments,
256 total,
257 cache_stable,
258 exact_digest: digest(&exact),
259 redacted_segments,
260 }
261 }
262
263 /// Render the preview as the pager body: a provenance/measure table, then the
264 /// segments themselves.
265 #[must_use]
266 pub fn render_report(preview: &BasePromptPreview) -> String {
267 use std::fmt::Write as _;
268
269 let mut out = String::new();
270 out.push_str("Exact effective base prompt for the next turn\n\n");
271 let _ = writeln!(
272 out,
273 "Total: {} bytes, {} chars, ~{} tokens (estimate) across {} block(s)",
274 preview.total.byte_len,
275 preview.total.char_len,
276 preview.total.approx_tokens,
277 preview.segments.len()
278 );
279 let _ = writeln!(
280 out,
281 "Cache-stable prefix: {} bytes, ~{} tokens (estimate)",
282 preview.cache_stable.byte_len, preview.cache_stable.approx_tokens
283 );
284 let _ = writeln!(
285 out,
286 "Digest of the exact sent bytes: {}",
287 preview.exact_digest
288 );
289 if preview.display_is_exact() {
290 out.push_str("Displayed text is byte-exact; nothing needed redaction.\n");
291 } else {
292 let _ = writeln!(
293 out,
294 "{} block(s) are redacted below for display. Measures and digests are over the real bytes.",
295 preview.redacted_segments
296 );
297 }
298 out.push_str(
299 "\nNo provider request was made and no tool catalog was expanded to build this preview.\n",
300 );
301
302 out.push_str("\nBlocks\n");
303 for segment in &preview.segments {
304 let _ = writeln!(
305 out,
306 "- [{}] {} — {} — {} bytes, ~{} tokens, digest {}{}",
307 segment.index,
308 segment.label,
309 segment.provenance.label(),
310 segment.measures.byte_len,
311 segment.measures.approx_tokens,
312 segment.digest,
313 if segment.redacted { " (redacted)" } else { "" }
314 );
315 }
316
317 for segment in &preview.segments {
318 let _ = write!(
319 out,
320 "\n--- [{}] {} ---\n{}\n",
321 segment.index, segment.label, segment.text
322 );
323 }
324 out
325 }
326
327 fn segment_label(index: usize, text: &str) -> String {
328 if let Some(marker) = world_state_marker(text) {
329 return format!("world state: {marker}");
330 }
331 if index == 0 {
332 return "constitution prefix (cache-stable)".to_string();
333 }
334 if text.starts_with("## Authority Recap") {
335 return "authority recap trailer".to_string();
336 }
337 format!("trailer block {index}")
338 }
339
340 fn classify(index: usize, text: &str, sources: &PreviewSources<'_>) -> SegmentProvenance {
341 if let Some(marker) = world_state_marker(text) {
342 return SegmentProvenance::WorldStateFragment { marker };
343 }
344 if index > 0 {
345 return SegmentProvenance::Bundled {
346 symbol: "AUTHORITY_RECAP / locale trailer",
347 };
348 }
349
350 // Block 0 is the composed cache-stable prefix. Name every source that
351 // actually contributed rather than pretending it is one file.
352 let base = sources
353 .base_prompt
354 .clone()
355 .unwrap_or(BasePromptSource::Bundled);
356 let mut parts = vec![base.provenance().label()];
357 if text.contains("<codewhale_user_constitution") {
358 parts.push(match sources.user_constitution_path {
359 Some(path) => SegmentProvenance::UserGlobalConstitution {
360 path: display_path(path, sources),
361 }
362 .label(),
363 // The block is present but the caller did not tell us which file it
364 // came from. Say that, rather than naming a path we did not read.
365 None => "user-global constitution (path not reported)".to_string(),
366 });
367 }
368 if text.contains("<project_context") || text.contains("## Project Context") {
369 parts.push(SegmentProvenance::WorkspaceGenerated.label());
370 }
371 if parts.len() == 1 {
372 return base.provenance();
373 }
374 SegmentProvenance::Composed {
375 detail: parts.join(" + "),
376 }
377 }
378
379 fn world_state_marker(text: &str) -> Option<String> {
380 let first = text.lines().next()?.trim();
381 let inner = first.strip_prefix("<!-- cw:ctx:")?.strip_suffix("-->")?;
382 Some(inner.trim().to_string())
383 }
384
385 fn display_path(path: &Path, sources: &PreviewSources<'_>) -> String {
386 let text = path.display().to_string();
387 redact(&text, sources)
388 }
389
390 /// Mask home paths, key-shaped tokens, and URL userinfo for display.
391 ///
392 /// Deliberately conservative and local: the preview shows prompt bytes, so it
393 /// must not become a way to read a secret that leaked into one.
394 fn redact(text: &str, sources: &PreviewSources<'_>) -> String {
395 let mut out = text.to_string();
396 if let Some(home) = sources.home {
397 let home = home.display().to_string();
398 if !home.is_empty() && home != "/" {
399 out = out.replace(&home, "~");
400 }
401 }
402 out = redact_userinfo(&out);
403 redact_key_shaped(&out)
404 }
405
406 /// Replace `scheme://user:secret@host` with `scheme://***@host`.
407 fn redact_userinfo(text: &str) -> String {
408 let mut out = String::with_capacity(text.len());
409 let mut rest = text;
410 while let Some(at) = rest.find("://") {
411 let (head, tail) = rest.split_at(at + 3);
412 out.push_str(head);
413 let end = tail
414 .find(|c: char| c.is_whitespace() || c == '/' || c == '"')
415 .unwrap_or(tail.len());
416 let (authority, remainder) = tail.split_at(end);
417 match authority.rsplit_once('@') {
418 Some((_, host)) => {
419 out.push_str("***@");
420 out.push_str(host);
421 }
422 None => out.push_str(authority),
423 }
424 rest = remainder;
425 }
426 out.push_str(rest);
427 out
428 }
429
430 /// Mask long opaque tokens that look like credentials (`sk-…`, `ghp_…`, bearer
431 /// values). Ordinary prose and paths are left alone.
432 fn redact_key_shaped(text: &str) -> String {
433 const PREFIXES: &[&str] = &["sk-", "sk_", "ghp_", "gho_", "xoxb-", "xoxp-", "Bearer "];
434 let mut out = String::with_capacity(text.len());
435 for token in text.split_inclusive(char::is_whitespace) {
436 let trimmed = token.trim_end();
437 let looks_secret = PREFIXES
438 .iter()
439 .any(|prefix| trimmed.starts_with(prefix) && trimmed.len() > prefix.len() + 8);
440 if looks_secret {
441 out.push_str("[redacted]");
442 out.push_str(&token[trimmed.len()..]);
443 } else {
444 out.push_str(token);
445 }
446 }
447 out
448 }
449
450 /// FNV-1a 64-bit, hex. Same construction the constitution projection uses, so
451 /// digests from the two surfaces are comparable.
452 fn digest(text: &str) -> String {
453 const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
454 const PRIME: u64 = 0x0000_0100_0000_01b3;
455 let mut hash = OFFSET;
456 for &b in text.as_bytes() {
457 hash ^= u64::from(b);
458 hash = hash.wrapping_mul(PRIME);
459 }
460 format!("{hash:016x}")
461 }
462
463 #[cfg(test)]
464 mod tests {
465 use super::*;
466
467 fn blocks(texts: &[&str]) -> SystemPrompt {
468 SystemPrompt::Blocks(
469 texts
470 .iter()
471 .map(|text| SystemBlock {
472 block_type: "text".to_string(),
473 text: (*text).to_string(),
474 cache_control: None,
475 })
476 .collect(),
477 )
478 }
479
480 #[test]
481 fn preview_digest_equals_the_exact_sent_prompt() {
482 let prompt = blocks(&[
483 "# Constitution\nBe truthful.",
484 "<!-- cw:ctx:workspace -->\ncwd: /tmp",
485 "## Authority Recap\nConsult Whose word wins.",
486 ]);
487 let preview = preview(&prompt, &PreviewSources::default());
488
489 let exact = crate::prompts::system_prompt_flat_text(&prompt);
490 assert_eq!(preview.exact_digest, digest(&exact));
491 // Nothing needed masking, so the display is byte-exact too.
492 assert!(preview.display_is_exact());
493 assert_eq!(preview.display_text(), exact);
494 assert_eq!(preview.total.byte_len, exact.len() - 2 * "\n\n".len());
495 }
496
497 #[test]
498 fn measures_are_per_block_and_sum_to_the_total() {
499 let prompt = blocks(&["aaaa", "bbbbbbbb"]);
500 let preview = preview(&prompt, &PreviewSources::default());
501 assert_eq!(preview.segments[0].measures.byte_len, 4);
502 assert_eq!(preview.segments[0].measures.approx_tokens, 1);
503 assert_eq!(preview.segments[1].measures.approx_tokens, 2);
504 assert_eq!(preview.total.byte_len, 12);
505 assert_eq!(preview.total.approx_tokens, 3);
506 }
507
508 #[test]
509 fn world_state_fragments_are_named_and_excluded_from_the_cache_stable_total() {
510 let prompt = blocks(&[
511 "# Constitution",
512 "<!-- cw:ctx:route -->\nmodel: glm-5.2",
513 "<!-- cw:ctx:token_budget -->\nrelay",
514 ]);
515 let preview = preview(&prompt, &PreviewSources::default());
516
517 assert_eq!(
518 preview.segments[1].provenance,
519 SegmentProvenance::WorldStateFragment {
520 marker: "route".to_string()
521 }
522 );
523 assert!(preview.segments[1].label.contains("route"));
524 assert!(!preview.segments[1].provenance.is_cache_stable());
525 assert_eq!(
526 preview.cache_stable.byte_len,
527 preview.segments[0].measures.byte_len
528 );
529 assert!(preview.cache_stable.byte_len < preview.total.byte_len);
530 }
531
532 #[test]
533 fn bundled_and_overridden_base_prompts_are_labeled_differently() {
534 let prompt = blocks(&["# Constitution"]);
535
536 let bundled = preview(&prompt, &PreviewSources::default());
537 assert_eq!(
538 bundled.segments[0].provenance,
539 SegmentProvenance::Bundled {
540 symbol: "BASE_PROMPT"
541 }
542 );
543 assert!(bundled.segments[0].provenance.label().contains("bundled"));
544
545 let overridden = preview(
546 &prompt,
547 &PreviewSources {
548 base_prompt: Some(BasePromptSource::ConfigOverride {
549 path: "/home/u/.codewhale/prompts/constitution.md".to_string(),
550 }),
551 ..PreviewSources::default()
552 },
553 );
554 let label = overridden.segments[0].provenance.label();
555 assert!(label.contains("config override"), "{label}");
556 assert!(
557 label.contains(super::super::BASE_PROMPT_OVERRIDE_OPT_IN_ENV),
558 "the opt-in gate that allowed the override must be named: {label}"
559 );
560
561 let embedder = preview(
562 &prompt,
563 &PreviewSources {
564 base_prompt: Some(BasePromptSource::EmbedderComposer),
565 ..PreviewSources::default()
566 },
567 );
568 assert_eq!(
569 embedder.segments[0].provenance,
570 SegmentProvenance::EmbedderComposer
571 );
572 }
573
574 #[test]
575 fn constitution_block_provenance_never_invents_a_path() {
576 let prompt = blocks(&[
577 "# Constitution\n\n<codewhale_user_constitution source=\"user-global\">\nx\n</codewhale_user_constitution>",
578 ]);
579 let unknown = preview(&prompt, &PreviewSources::default());
580 let label = unknown.segments[0].provenance.label();
581 assert!(label.contains("path not reported"), "{label}");
582 assert!(!label.contains("crates/"), "no source-tree path: {label}");
583
584 let known = preview(
585 &prompt,
586 &PreviewSources {
587 user_constitution_path: Some(Path::new("/home/u/.codewhale/constitution.json")),
588 ..PreviewSources::default()
589 },
590 );
591 assert!(
592 known.segments[0]
593 .provenance
594 .label()
595 .contains("constitution.json")
596 );
597 }
598
599 #[test]
600 fn redaction_masks_secrets_without_lying_about_size() {
601 let raw = "key sk-abcdefghijklmnop and https://user:pw@example.com/x";
602 let prompt = blocks(&[raw]);
603 let preview = preview(&prompt, &PreviewSources::default());
604 let segment = &preview.segments[0];
605
606 assert!(segment.redacted);
607 assert!(!segment.text.contains("sk-abcdefghijklmnop"));
608 assert!(!segment.text.contains("pw@"));
609 assert!(segment.text.contains("***@example.com"));
610 // Measures and digest still describe the real bytes.
611 assert_eq!(segment.measures.byte_len, raw.len());
612 assert_eq!(segment.digest, digest(raw));
613 assert_eq!(preview.exact_digest, digest(raw));
614 assert!(!preview.display_is_exact());
615 }
616
617 #[test]
618 fn home_paths_are_masked_in_display_only() {
619 let raw = "constitution at /Users/someone/.codewhale/constitution.json";
620 let prompt = blocks(&[raw]);
621 let preview = preview(
622 &prompt,
623 &PreviewSources {
624 home: Some(Path::new("/Users/someone")),
625 ..PreviewSources::default()
626 },
627 );
628 assert!(preview.segments[0].text.contains("~/.codewhale"));
629 assert!(!preview.segments[0].text.contains("/Users/someone"));
630 assert_eq!(preview.segments[0].measures.byte_len, raw.len());
631 }
632
633 #[test]
634 fn report_states_the_no_request_no_tool_expansion_boundary() {
635 let prompt = blocks(&["# Constitution"]);
636 let report = render_report(&preview(&prompt, &PreviewSources::default()));
637 assert!(report.contains("No provider request was made"));
638 assert!(report.contains("no tool catalog was expanded"));
639 assert!(report.contains("Digest of the exact sent bytes"));
640 assert!(
641 report.contains("~"),
642 "token estimate must be marked approximate"
643 );
644 }
645
646 #[test]
647 fn text_prompts_preview_as_one_segment() {
648 let prompt = SystemPrompt::Text("flat prompt".to_string());
649 let preview = preview(&prompt, &PreviewSources::default());
650 assert_eq!(preview.segments.len(), 1);
651 assert_eq!(preview.exact_digest, digest("flat prompt"));
652 }
653 }
654
654 lines RUST