goosedump 0.9.4

Coding agent context data browser
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
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (C) Jarkko Sakkinen 2026

//! Emit a compaction summary in a client's own conventions: `render` produces
//! the client's genuine on-disk compaction artifact (insertable session
//! records, e.g. Pi's `{"type":"compaction"}` entry), and `print` reproduces
//! what the client shows on screen for a compaction. Every shape is sourced
//! from the client's code or documented behavior — no generic fallbacks.

use crate::Client;
use crate::compact::Summary;
use serde_json::json;

/// Render the summary as `client`'s native on-disk compaction artifact.
#[must_use]
pub fn render(client: Client, summary: &Summary, context_id: &str, cwd: Option<&str>) -> String {
    match client {
        Client::Claude => render_claude(summary, context_id, cwd),
        Client::Codex => render_codex(summary, context_id, cwd),
        Client::Crush => render_crush(summary, context_id),
        Client::Gemini => render_gemini(summary, context_id, cwd),
        Client::Goose => render_goose(summary, context_id, cwd),
        Client::Opencode => render_opencode(summary, context_id, cwd),
        Client::Pi => render_pi(summary, context_id, cwd),
    }
}

/// Print the summary the way `client` renders a compaction on screen.
#[must_use]
pub fn print(client: Client, summary: &Summary) -> String {
    match client {
        Client::Claude => print_claude(summary),
        Client::Codex => print_codex(),
        Client::Crush => print_crush(summary),
        Client::Gemini => print_gemini(summary),
        Client::Goose => print_goose(),
        Client::Opencode => print_opencode(summary),
        Client::Pi => print_pi(summary),
    }
}

/// Gemini-cli compression appends a `$set` record that replaces the whole
/// `messages` array; the summary rides as a plain user message carrying a
/// `<state_snapshot>` XML block, acknowledged by a fixed gemini message
/// (`chatCompressionService.ts` / `chatRecordingService.ts`). The chats file
/// starts with a `{sessionId, projectHash, startTime, lastUpdated, kind}`
/// header, `projectHash` being the hex SHA-256 of the project root.
fn render_gemini(summary: &Summary, context_id: &str, cwd: Option<&str>) -> String {
    let session_id = crate::format::bare_session_id(context_id);
    let timestamp = synthetic_timestamp_millis(0);
    let snapshot = gemini_state_snapshot(summary);
    let lines = [
        serde_json::to_string(&json!({
            "sessionId": session_id,
            "projectHash": crate::import::gemini_project_hash(cwd.unwrap_or("")),
            "startTime": timestamp,
            "lastUpdated": timestamp,
            "kind": "main"
        }))
        .unwrap_or_default(),
        serde_json::to_string(&json!({
            "$set": {
                "messages": [
                    {
                        "id": derived_hex_id(context_id, 0),
                        "timestamp": timestamp,
                        "type": "user",
                        "content": [{"text": snapshot}]
                    },
                    {
                        "id": derived_hex_id(context_id, 1),
                        "timestamp": timestamp,
                        "type": "gemini",
                        "content": [{"text": "Got it. Thanks for the additional context!"}]
                    }
                ],
                "lastUpdated": timestamp
            }
        }))
        .unwrap_or_default(),
    ];
    lines.join("\n") + "\n"
}

/// The `<state_snapshot>` XML block gemini-cli's compression prompt mandates
/// (`prompts/snippets.ts` `getCompressionPrompt`). Tags without content are
/// omitted; `task_state` items carry the prompt's `[TODO]` markers.
fn gemini_state_snapshot(summary: &Summary) -> String {
    let mut tags: Vec<String> = Vec::new();
    push_gemini_tag(&mut tags, "overall_goal", &bullets(&summary.goals));
    push_gemini_tag(
        &mut tags,
        "active_constraints",
        &bullets(&summary.preferences),
    );
    push_gemini_tag(
        &mut tags,
        "key_knowledge",
        std::slice::from_ref(&summary.brief),
    );
    let mut files: Vec<String> = Vec::new();
    files.extend(summary.read.iter().map(|p| format!("- READ: {p}")));
    files.extend(summary.modified.iter().map(|p| format!("- MODIFIED: {p}")));
    files.extend(summary.created.iter().map(|p| format!("- CREATED: {p}")));
    push_gemini_tag(&mut tags, "file_system_state", &files);
    push_gemini_tag(&mut tags, "recent_actions", &bullets(&summary.commits));
    let tasks: Vec<String> = summary
        .outstanding
        .iter()
        .map(|item| format!("- [TODO] {item}"))
        .collect();
    push_gemini_tag(&mut tags, "task_state", &tasks);
    format!("<state_snapshot>\n{}\n</state_snapshot>", tags.join("\n"))
}

fn push_gemini_tag(tags: &mut Vec<String>, tag: &str, lines: &[String]) {
    let body: Vec<&str> = lines
        .iter()
        .map(String::as_str)
        .filter(|line| !line.is_empty())
        .collect();
    if body.is_empty() {
        return;
    }
    let indented: Vec<String> = body
        .iter()
        .flat_map(|chunk| chunk.lines())
        .map(|line| format!("    {line}"))
        .collect();
    tags.push(format!("  <{tag}>\n{}\n  </{tag}>", indented.join("\n")));
}

fn bullets(items: &[String]) -> Vec<String> {
    items.iter().map(|item| format!("- {item}")).collect()
}

/// Gemini-cli prints only a status line for compression; the snapshot itself
/// is never displayed (`CompressionMessage.tsx`).
fn print_gemini(summary: &Summary) -> String {
    let compressed = (gemini_state_snapshot(summary).chars().count() / 4) as u64;
    format!(
        "Chat history compressed from {} to {} tokens.\n",
        summary.tokens_before, compressed
    )
}

/// Pi stores compaction as a dedicated session entry
/// (`packages/coding-agent/src/core/session-manager.ts:69-78`):
/// `{"type":"compaction", id, parentId, timestamp, summary,
/// firstKeptEntryId, tokensBefore}`. A seeded session needs the
/// `{"type":"session","version":3,...}` header (version 3 skips the v1
/// migration) and a self-referencing `firstKeptEntryId`, which Pi's
/// `buildContextEntries` provably treats as "keep nothing before".
fn render_pi(summary: &Summary, context_id: &str, cwd: Option<&str>) -> String {
    let session_id = crate::format::bare_session_id(context_id);
    let entry_id = pi_entry_id(session_id);
    let timestamp = crate::format::synthetic_timestamp(0);
    let lines = [
        serde_json::to_string(&json!({
            "type": "session",
            "version": 3,
            "id": session_id,
            "timestamp": timestamp,
            "cwd": cwd.unwrap_or(".")
        }))
        .unwrap_or_default(),
        serde_json::to_string(&json!({
            "type": "compaction",
            "id": entry_id,
            "parentId": null,
            "timestamp": timestamp,
            "summary": pi_summary_text(summary),
            "firstKeptEntryId": entry_id,
            "tokensBefore": summary.tokens_before
        }))
        .unwrap_or_default(),
    ];
    lines.join("\n") + "\n"
}

/// Pi entry ids are 8 lowercase hex chars; loading does not validate, but
/// staying in-format keeps the seed indistinguishable from Pi's own entries.
fn pi_entry_id(session_id: &str) -> String {
    let hex: String = session_id
        .chars()
        .filter(char::is_ascii_hexdigit)
        .take(8)
        .collect();
    if hex.len() == 8 {
        hex.to_ascii_lowercase()
    } else {
        "00000000".to_string()
    }
}

/// The anchored-summary markdown skeleton that Pi's and opencode's
/// summarization prompts both mandate (`## Goal`, `## Constraints &
/// Preferences`, `## Progress`, `## Critical Context`). Sections without
/// content are omitted; the per-client file listing is appended by callers.
fn anchored_sections(summary: &Summary) -> Vec<String> {
    let mut out: Vec<String> = Vec::new();
    push_markdown_section(&mut out, "## Goal", &summary.goals, "- ");
    push_markdown_section(
        &mut out,
        "## Constraints & Preferences",
        &summary.preferences,
        "- ",
    );
    if !summary.commits.is_empty() || !summary.outstanding.is_empty() {
        out.push("## Progress".to_string());
        push_markdown_section(&mut out, "### Done", &summary.commits, "- [x] ");
        push_markdown_section(&mut out, "### In Progress", &summary.outstanding, "- [ ] ");
    }
    if !summary.brief.is_empty() {
        out.push("## Critical Context".to_string());
        out.push(summary.brief.clone());
    }
    out
}

/// Pi's summary layout (`compaction/compaction.ts:387-418`): the anchored
/// skeleton plus the `<read-files>` / `<modified-files>` blocks its compactor
/// appends (`utils.ts:72-82`).
fn pi_summary_text(summary: &Summary) -> String {
    let mut out = anchored_sections(summary);
    let modified = [summary.modified.as_slice(), summary.created.as_slice()].concat();
    push_pi_files(&mut out, "read-files", &summary.read);
    push_pi_files(&mut out, "modified-files", &modified);
    out.join("\n\n")
}

/// Opencode's summary layout (`packages/core/src/session/compaction.ts`
/// `SUMMARY_TEMPLATE`): the anchored skeleton plus a `## Relevant Files`
/// section.
fn opencode_summary_text(summary: &Summary) -> String {
    let mut out = anchored_sections(summary);
    let files: Vec<String> = summary
        .read
        .iter()
        .chain(summary.modified.iter())
        .chain(summary.created.iter())
        .cloned()
        .collect();
    push_markdown_section(&mut out, "## Relevant Files", &files, "- ");
    out.join("\n\n")
}

fn push_markdown_section(out: &mut Vec<String>, header: &str, items: &[String], bullet: &str) {
    if items.is_empty() {
        return;
    }
    let mut section = vec![header.to_string()];
    section.extend(items.iter().map(|item| format!("{bullet}{item}")));
    out.push(section.join("\n"));
}

fn push_pi_files(out: &mut Vec<String>, tag: &str, paths: &[String]) {
    if paths.is_empty() {
        return;
    }
    out.push(format!("<{tag}>\n{}\n</{tag}>", paths.join("\n")));
}

/// Opencode stores a compaction as a user message carrying a
/// `{"type":"compaction","auto":false}` part, answered by an assistant
/// message whose info JSON is flagged `"summary": true` with the summary as
/// an ordinary text part (`session/compaction.ts:356-381,513-536`;
/// `filterCompacted`, `session/message-v2.ts:521-572`). The marker is only
/// honored when `summary && finish && !error`, and the v1 schema requires
/// `agent` + `model` on user info and `providerID`/`modelID`/`path`/`cost`/
/// `tokens` on assistant info (`schema/src/v1/session.ts:332-355,453-485`),
/// so all of those are populated. Emitted as the same row-oriented
/// projection `render`/`import` use for opencode.
fn render_opencode(summary: &Summary, context_id: &str, cwd: Option<&str>) -> String {
    // Epoch-millisecond twin of `format::synthetic_timestamp`'s base.
    const BASE_MS: u64 = 1_767_225_600_000;
    let session_id = crate::format::bare_session_id(context_id);
    let user_id = format!("{}-compact", derived_hex_id(context_id, 0));
    let assistant_id = format!("{}-summary", derived_hex_id(context_id, 1));
    let directory = cwd.unwrap_or("");
    let messages = [
        json!({
            "session_id": session_id,
            "id": user_id,
            "data": {
                "role": "user",
                "id": user_id,
                "parentID": "",
                "agent": "build",
                "model": {"providerID": "goosedump", "modelID": "compact"},
                "time": {"created": BASE_MS}
            },
            "time_created": 0
        }),
        json!({
            "session_id": session_id,
            "id": assistant_id,
            "data": {
                "role": "assistant",
                "id": assistant_id,
                "parentID": user_id,
                "mode": "compaction",
                "agent": "compaction",
                "providerID": "goosedump",
                "modelID": "compact",
                "summary": true,
                "finish": "stop",
                "cost": 0,
                "tokens": {"input": 0, "output": 0, "reasoning": 0,
                           "cache": {"read": 0, "write": 0}},
                "path": {"cwd": directory, "root": directory},
                "time": {"created": BASE_MS, "completed": BASE_MS + 1}
            },
            "time_created": 1
        }),
    ];
    let parts = [
        json!({
            "session_id": session_id,
            "message_id": user_id,
            "data": {"type": "compaction", "auto": false},
            "time_created": 0
        }),
        json!({
            "session_id": session_id,
            "message_id": assistant_id,
            "data": {"type": "text", "text": opencode_summary_text(summary)},
            "time_created": 1
        }),
    ];
    serde_json::to_string(&json!({
        "session": {"directory": directory, "message_count": 2},
        "messages": messages,
        "parts": parts
    }))
    .unwrap_or_default()
        + "\n"
}

/// The intro Claude Code prepends to a compact summary (`w3r` in the 2.1.207
/// binary; empirically captured from a real `/compact`).
const CLAUDE_SUMMARY_INTRO: &str = "This session is being continued from a previous \
conversation that ran out of context. The summary below covers the earlier portion of the \
conversation.";

/// The resume tail Claude Code appends after the summary body (same capture).
const CLAUDE_SUMMARY_TAIL: &str = "Continue the conversation from where it left off without \
asking the user any further questions. Resume directly — do not acknowledge the summary, do \
not recap what was happening, do not preface with \"I'll continue\" or similar. Pick up the \
last task as if the break never happened.";

/// Claude Code writes two records on compaction (verbatim-captured from
/// v2.1.207): a `{"type":"system","subtype":"compact_boundary"}` record with
/// `parentUuid: null` and `compactMetadata`, then the summary as a
/// `{"type":"user"}` record chained to the boundary's uuid, flagged
/// `isCompactSummary: true` + `isVisibleInTranscriptOnly: true`, whose
/// `message.content` is a BARE STRING. Resume reconstruction walks
/// `parentUuid` from the last record; a boundary+summary pair alone resumes
/// (empirically verified).
fn render_claude(summary: &Summary, context_id: &str, cwd: Option<&str>) -> String {
    let session_id = crate::format::bare_session_id(context_id);
    let boundary_uuid = derived_uuid(context_id, 0);
    let summary_uuid = derived_uuid(context_id, 1);
    let timestamp = synthetic_timestamp_millis(0);
    let post_tokens = (claude_summary_body(summary).chars().count() / 4) as u64;
    let content = format!(
        "{CLAUDE_SUMMARY_INTRO}\n\nSummary:\n{}\n\n{CLAUDE_SUMMARY_TAIL}",
        claude_summary_body(summary)
    );
    let lines = [
        serde_json::to_string(&json!({
            "parentUuid": null,
            "isSidechain": false,
            "type": "system",
            "subtype": "compact_boundary",
            "content": "Conversation compacted",
            "isMeta": false,
            "timestamp": timestamp,
            "uuid": boundary_uuid,
            "level": "info",
            "compactMetadata": {
                "trigger": "manual",
                "preTokens": summary.tokens_before,
                "postTokens": post_tokens
            },
            "userType": "external",
            "cwd": cwd.unwrap_or(""),
            "sessionId": session_id
        }))
        .unwrap_or_default(),
        serde_json::to_string(&json!({
            "parentUuid": boundary_uuid,
            "isSidechain": false,
            "type": "user",
            "message": {"role": "user", "content": content},
            "isVisibleInTranscriptOnly": true,
            "isCompactSummary": true,
            "uuid": summary_uuid,
            "timestamp": timestamp,
            "userType": "external",
            "cwd": cwd.unwrap_or(""),
            "sessionId": session_id
        }))
        .unwrap_or_default(),
    ];
    lines.join("\n") + "\n"
}

/// Claude Code's compact prompt asks for the numbered-section analysis goose
/// later adopted verbatim, so the summary body shares goose's section map.
fn claude_summary_body(summary: &Summary) -> String {
    goose_summary_text(summary)
}

/// Claude Code's transcript marker plus the summary as the transcript view
/// (ctrl+o) exposes it; the prompt view shows only "Compact summary
/// (ctrl+o to expand)" (`eho`/`Lpo` in the 2.1.207 binary).
fn print_claude(summary: &Summary) -> String {
    format!(
        "✻ Conversation compacted (ctrl+o for history)\n\nCompact summary\n\nSummary:\n{}\n",
        claude_summary_body(summary)
    )
}

/// The agent-only continuation goose appends after a compaction summary
/// (`context_mgmt/mod.rs:31-44` `CONVERSATION_CONTINUATION_TEXT`).
const GOOSE_CONTINUATION_TEXT: &str = "Your context was compacted. The previous message \
contains a summary of the conversation so far.\nDo not mention that you read a summary or \
that conversation summarization occurred.\nJust continue the conversation naturally based \
on the summarized context.";

/// Goose stores the compaction summary as a user-role message whose only
/// marker is `metadata_json` `{"userVisible":false,"agentVisible":true}`,
/// followed by an equally agent-only assistant continuation message
/// (`context_mgmt/mod.rs:67-185`; persisted via `replace_conversation`,
/// `session_manager.rs:1813-1852`). Emitted as the goose row projection with
/// the metadata carried per row.
fn render_goose(summary: &Summary, context_id: &str, cwd: Option<&str>) -> String {
    let session_id = crate::format::bare_session_id(context_id);
    let agent_only = json!({"userVisible": false, "agentVisible": true});
    let messages = [
        json!({
            "message_id": format!("msg_{session_id}_{}", derived_hex_id(context_id, 0)),
            "session_id": session_id,
            "role": "user",
            "content_json": [{"type": "text", "text": goose_summary_text(summary)}],
            "created_timestamp": 0,
            "metadata_json": agent_only
        }),
        json!({
            "message_id": format!("msg_{session_id}_{}", derived_hex_id(context_id, 1)),
            "session_id": session_id,
            "role": "assistant",
            "content_json": [{"type": "text", "text": GOOSE_CONTINUATION_TEXT}],
            "created_timestamp": 1,
            "metadata_json": agent_only
        }),
    ];
    let session = json!({
        "id": session_id,
        "name": "",
        "working_dir": cwd.unwrap_or(""),
        "updated_at": ""
    });
    serde_json::to_string(&json!({"session": session, "messages": messages})).unwrap_or_default()
        + "\n"
}

/// The numbered sections goose's `prompts/compaction.md` template mandates;
/// numbering follows the template, sections without content are omitted.
fn goose_summary_text(summary: &Summary) -> String {
    let mut out: Vec<String> = Vec::new();
    push_markdown_section(&mut out, "1. **User Intent**", &summary.goals, "   - ");
    let files: Vec<String> = summary
        .read
        .iter()
        .chain(summary.modified.iter())
        .chain(summary.created.iter())
        .cloned()
        .collect();
    push_markdown_section(&mut out, "3. **Files + Code**", &files, "   - ");
    push_markdown_section(
        &mut out,
        "5. **Problem Solving**",
        &summary.commits,
        "   - ",
    );
    push_markdown_section(
        &mut out,
        "6. **User Messages**",
        &summary.preferences,
        "   - ",
    );
    push_markdown_section(
        &mut out,
        "7. **Pending Tasks**",
        &summary.outstanding,
        "   - ",
    );
    if !summary.brief.is_empty() {
        out.push(format!("8. **Current Work**\n{}", summary.brief));
    }
    out.join("\n\n")
}

/// After a manual `/compact` goose prints only the completion notice; the
/// summary itself is agent-only and never shown
/// (`execute_commands.rs` `handle_compact_command`; `agent.rs:1754-1793`).
fn print_goose() -> String {
    "Compaction complete\n".to_string()
}

/// Crush appends the summary as a new assistant message flagged
/// `is_summary_message=1` in the same session and points the session row's
/// `summary_message_id` at it; truncation is applied at read time
/// (`agent.go:1293-1436`, `messages.sql:13-26`). Parts use crush's
/// `{"type", "data"}` wrappers ending with an `end_turn` finish part.
fn render_crush(summary: &Summary, context_id: &str) -> String {
    // Epoch-second twin of `format::synthetic_timestamp`'s base.
    const BASE_SECS: u64 = 1_767_225_600;
    let session_id = crate::format::bare_session_id(context_id);
    let message_id = derived_uuid(context_id, 0);
    let row = json!({
        "id": message_id,
        "session_id": session_id,
        "role": "assistant",
        "is_summary_message": 1,
        "parts": [
            {"type": "text", "data": {"text": crush_summary_text(summary)}},
            {"type": "finish", "data": {"reason": "end_turn", "time": BASE_SECS}}
        ],
        "created_at": BASE_SECS
    });
    serde_json::to_string(&json!({
        "session": {"message_count": 1, "summary_message_id": message_id},
        "messages": [row]
    }))
    .unwrap_or_default()
        + "\n"
}

/// The sections crush's `templates/summary.md` system prompt mandates
/// (no emojis); sections without content are omitted.
fn crush_summary_text(summary: &Summary) -> String {
    let mut out: Vec<String> = Vec::new();
    push_markdown_section(&mut out, "## Current State", &summary.goals, "- ");
    let mut changes: Vec<String> = summary.commits.clone();
    changes.extend(
        summary
            .read
            .iter()
            .chain(summary.modified.iter())
            .chain(summary.created.iter())
            .cloned(),
    );
    push_markdown_section(&mut out, "## Files & Changes", &changes, "- ");
    if !summary.brief.is_empty() {
        out.push(format!("## Technical Context\n{}", summary.brief));
    }
    push_markdown_section(
        &mut out,
        "## Strategy & Approach",
        &summary.preferences,
        "- ",
    );
    push_markdown_section(&mut out, "## Exact Next Steps", &summary.outstanding, "- ");
    out.join("\n\n")
}

/// Crush streams the summary as an ordinary assistant markdown message in
/// the chat (spinner label "Summarizing"; no post-hoc filtering in the UI),
/// so the on-screen artifact is the summary text itself
/// (`ui/chat/assistant.go:518-522`).
fn print_crush(summary: &Summary) -> String {
    format!("{}\n", crush_summary_text(summary))
}

/// The fixed handoff paragraph codex prepends to every compaction summary
/// (`prompts/templates/compact/summary_prefix.md`); re-reads detect a summary
/// by `message.starts_with(PREFIX + "\n")`.
const CODEX_SUMMARY_PREFIX: &str = "Another language model started to solve this problem and \
produced a summary of its thinking process. You also have access to the state of the tools \
that were used by that language model. Use this to build on the work that has already been \
done and avoid duplicating work. Here is the summary produced by the other language model, \
use the information in this summary to assist with your own analysis:";

/// Codex persists compaction as a `{"type":"compacted"}` rollout line whose
/// `replacement_history` becomes the entire history on resume
/// (`protocol.rs:3175-3191`; `rollout_reconstruction.rs:340-365`), preceded
/// by the assistant summary as a normal `response_item`. The `session_meta`
/// first line needs `id`/`timestamp`/`cwd`/`originator`/`cli_version`
/// (`protocol.rs:3023-3071`); resume-by-id matches the UUID in the
/// `rollout-<ts>-<uuid>.jsonl` filename against `payload.id`.
fn render_codex(summary: &Summary, context_id: &str, cwd: Option<&str>) -> String {
    let timestamp = synthetic_timestamp_millis(0);
    let body = codex_summary_text(summary);
    let text = format!("{CODEX_SUMMARY_PREFIX}\n{body}");
    let lines = [
        serde_json::to_string(&json!({
            "timestamp": timestamp,
            "type": "session_meta",
            "payload": {
                "id": derived_uuid(context_id, 0),
                "timestamp": timestamp,
                "cwd": cwd.unwrap_or(""),
                "originator": "goosedump",
                "cli_version": env!("CARGO_PKG_VERSION")
            }
        }))
        .unwrap_or_default(),
        serde_json::to_string(&json!({
            "timestamp": timestamp,
            "type": "response_item",
            "payload": {
                "type": "message",
                "role": "assistant",
                "content": [{"type": "output_text", "text": body}]
            }
        }))
        .unwrap_or_default(),
        serde_json::to_string(&json!({
            "timestamp": timestamp,
            "type": "compacted",
            "payload": {
                "message": text,
                "replacement_history": [{
                    "type": "message",
                    "role": "user",
                    "content": [{"type": "input_text", "text": text}]
                }]
            }
        }))
        .unwrap_or_default(),
    ];
    lines.join("\n") + "\n"
}

/// Codex's compact prompt asks for a free-form structured handoff (progress,
/// decisions, constraints/preferences, next steps, critical data), so the
/// anchored skeleton plus the file list fits it.
fn codex_summary_text(summary: &Summary) -> String {
    opencode_summary_text(summary)
}

/// Codex never displays the summary text: the TUI shows only the info cell
/// and the follow-up warning (`history_cell/notices.rs:203-211`;
/// `compact.rs:372-375`), live and on replay alike.
fn print_codex() -> String {
    "• Context compacted\n\
     ⚠ Heads up: Long threads and multiple compactions can cause the model to be less \
     accurate. Start a new thread when possible to keep threads small and targeted.\n"
        .to_string()
}

/// Deterministic UUID-shaped id derived from the context id, for clients
/// whose session ids must parse as UUIDs (codex resume-by-id).
fn derived_uuid(context_id: &str, idx: usize) -> String {
    let hex = derived_hex_id(context_id, idx);
    format!(
        "{}-{}-{}-{}-{}",
        &hex[0..8],
        &hex[8..12],
        &hex[12..16],
        &hex[16..20],
        &hex[20..32]
    )
}

/// Opencode's TUI draws the compaction as a horizontal rule titled
/// `Compaction` (`routes/session/index.tsx:1442-1449`) and renders the
/// summary as a normal assistant markdown message.
fn print_opencode(summary: &Summary) -> String {
    const WIDTH: usize = 80;
    const TITLE: &str = " Compaction ";
    let side = (WIDTH - TITLE.len()) / 2;
    let rule = format!(
        "{}{TITLE}{}",
        "".repeat(side),
        "".repeat(WIDTH - TITLE.len() - side)
    );
    format!("{rule}\n\n{}\n", opencode_summary_text(summary))
}

/// Pi's TUI shows a `[compaction]` labelled box with "Compacted from
/// {N} tokens" (thousands-separated, per `tokensBefore.toLocaleString()`)
/// followed by the summary markdown when expanded
/// (`components/compaction-summary-message.ts`).
fn print_pi(summary: &Summary) -> String {
    format!(
        "[compaction] Compacted from {} tokens\n\n{}\n",
        thousands(summary.tokens_before),
        pi_summary_text(summary)
    )
}

/// Millisecond-precision variant of [`crate::format::synthetic_timestamp`]
/// for clients whose stores stamp `%.3fZ` (gemini, codex).
fn synthetic_timestamp_millis(idx: usize) -> String {
    let base = crate::format::synthetic_timestamp(idx);
    base.strip_suffix('Z')
        .map_or(base.clone(), |head| format!("{head}.000Z"))
}

/// Deterministic 32-hex message id derived from the context id, matching the
/// dashless hex ids gemini-cli records.
fn derived_hex_id(context_id: &str, idx: usize) -> String {
    use sha2::{Digest, Sha256};
    use std::fmt::Write as _;
    let mut hasher = Sha256::new();
    hasher.update(context_id.as_bytes());
    hasher.update([u8::try_from(idx).unwrap_or_default()]);
    let digest = hasher.finalize();
    digest[..16].iter().fold(String::new(), |mut out, byte| {
        let _ = write!(out, "{byte:02x}");
        out
    })
}

/// Format an integer with `,` thousands separators, matching the JS
/// `toLocaleString()` output the clients print.
fn thousands(value: u64) -> String {
    let digits = value.to_string();
    let mut out = String::with_capacity(digits.len() + digits.len() / 3);
    for (idx, ch) in digits.chars().enumerate() {
        if idx > 0 && (digits.len() - idx).is_multiple_of(3) {
            out.push(',');
        }
        out.push(ch);
    }
    out
}