use crate::Client;
use crate::compact::Summary;
use serde_json::json;
#[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),
}
}
#[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),
}
}
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"
}
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()
}
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
)
}
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"
}
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()
}
}
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
}
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")
}
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")));
}
fn render_opencode(summary: &Summary, context_id: &str, cwd: Option<&str>) -> String {
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"
}
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.";
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.";
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"
}
fn claude_summary_body(summary: &Summary) -> String {
goose_summary_text(summary)
}
fn print_claude(summary: &Summary) -> String {
format!(
"✻ Conversation compacted (ctrl+o for history)\n\nCompact summary\n\nSummary:\n{}\n",
claude_summary_body(summary)
)
}
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.";
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"
}
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")
}
fn print_goose() -> String {
"Compaction complete\n".to_string()
}
fn render_crush(summary: &Summary, context_id: &str) -> String {
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"
}
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")
}
fn print_crush(summary: &Summary) -> String {
format!("{}\n", crush_summary_text(summary))
}
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:";
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"
}
fn codex_summary_text(summary: &Summary) -> String {
opencode_summary_text(summary)
}
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()
}
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]
)
}
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))
}
fn print_pi(summary: &Summary) -> String {
format!(
"[compaction] Compacted from {} tokens\n\n{}\n",
thousands(summary.tokens_before),
pi_summary_text(summary)
)
}
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"))
}
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
})
}
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
}