1use crate::handlers::default_excludes_subagents;
7use crate::wire::{
8 GetRequest, GetResponse, GetResult, MessageView, PartKind, PartSummary, ResponsePart,
9 SearchModeWire, SearchRequest, SearchResponse, SortBy,
10};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Surface {
19 Mcp,
20 Cli,
21}
22
23fn count_noun(count: usize, noun: &str) -> String {
25 if count == 1 {
26 format!("{count} {noun}")
27 } else {
28 format!("{count} {noun}s")
29 }
30}
31
32pub fn render_subagents_footer(children: &[crate::wire::Session], surface: Surface) -> String {
37 use std::fmt::Write;
38 let how = match surface {
39 Surface::Mcp => "pass an id to pond_get(session_id=...)",
40 Surface::Cli => "pass an id to `pond get --session-id <ID>`",
41 };
42 let mut out = String::new();
43 let _ = writeln!(out);
44 let _ = writeln!(out, "subagents ({}) - {how}:", children.len());
45 for child in children {
46 let _ = writeln!(out, " {} | {}", child.id, child.source_agent);
47 }
48 out
49}
50
51fn fmt_ts(ts: &chrono::DateTime<chrono::Utc>) -> String {
53 ts.format("%Y-%m-%d %H:%M:%SZ").to_string()
54}
55
56fn opt_name(value: &Option<crate::adapter::extract::Extracted<String>>) -> &str {
59 value.as_deref().map(String::as_str).unwrap_or("?")
60}
61
62fn push_lines(out: &mut String, body: &str, indent: &str) {
66 use std::fmt::Write;
67 for line in body.lines() {
68 let _ = writeln!(out, "{indent}{line}");
69 }
70}
71
72const SEARCH_TRANSCRIPT_BUDGET: usize = 10_000;
78
79pub fn render_search_transcript(
80 response: &SearchResponse,
81 request: &SearchRequest,
82 surface: Surface,
83) -> String {
84 use std::fmt::Write;
85 let prefix = match surface {
86 Surface::Mcp => "pond_search",
87 Surface::Cli => "pond search",
88 };
89 let subagent_note = match (default_excludes_subagents(&request.filters), surface) {
90 (false, _) => "",
91 (true, Surface::Mcp) => {
92 " Subagent sessions excluded; reach them via pond_sql_query (parent_session_id)."
93 }
94 (true, Surface::Cli) => {
95 " Subagent sessions excluded; reach them via `pond sql` (parent_session_id)."
96 }
97 };
98 let recency_note = if matches!(request.sort_by, SortBy::Recency) {
99 " Sorted by recency (newest first) - rank is NOT match strength."
100 } else {
101 ""
102 };
103 if response.sessions.is_empty() {
104 if response.searchable_in_scope == 0 {
109 let scoped = request.filters.project.is_some()
110 || request.filters.session_id.is_some()
111 || request.filters.from_date.is_some()
112 || request.filters.to_date.is_some();
113 if scoped {
114 return format!(
115 "{prefix}: 0 searchable messages in scope - the filters exclude \
116 everything before retrieval. Widen or drop project/date filters.\
117 {subagent_note}\n"
118 );
119 }
120 return match surface {
123 Surface::Cli => format!(
124 "{prefix}: no sessions stored yet - run `pond init` to set up \
125 adapters, then `pond sync` to import your history.\n"
126 ),
127 Surface::Mcp => format!(
128 "{prefix}: the store has no searchable messages yet (nothing \
129 ingested so far). Not an absence signal about the topic.\n"
130 ),
131 };
132 }
133 let fts_hint = match surface {
134 Surface::Mcp => {
135 " For exact strings or identifiers, try pond_sql_query: SELECT \
136 message_id, session_id, search_text FROM messages WHERE \
137 contains_tokens(search_text, '...')."
138 }
139 Surface::Cli => {
140 " For exact strings or identifiers, try: pond sql \"SELECT \
141 message_id, session_id, search_text FROM messages WHERE \
142 contains_tokens(search_text, '...')\"."
143 }
144 };
145 return format!(
146 "{prefix}: no matches for {:?} across {} in \
147 scope.{subagent_note}{fts_hint}\n",
148 request.query,
149 count_noun(response.searchable_in_scope, "searchable message"),
150 );
151 }
152 let shown: usize = response.sessions.iter().map(|s| s.matches.len()).sum();
153 let vector_mode = matches!(request.mode, SearchModeWire::Vector);
159 let head_noun = if vector_mode {
160 "nearest message"
161 } else {
162 "matching message"
163 };
164 let mut out = String::new();
165 let _ = writeln!(
166 out,
167 "{prefix}: {} ({} searchable in scope), showing {} from {}.{}{}",
168 count_noun(response.matched_total, head_noun),
169 response.searchable_in_scope,
170 count_noun(shown, "hit"),
171 count_noun(response.sessions.len(), "session"),
172 subagent_note,
173 recency_note,
174 );
175 let order = if matches!(request.sort_by, SortBy::Recency) {
176 "newest session first"
177 } else {
178 "ordered by best hit"
179 };
180 let full_hint = match surface {
181 Surface::Mcp => "pond_get <message_id> for full",
182 Surface::Cli => "`pond get --message-id <ID>` for full",
183 };
184 let mode_note = match (vector_mode, surface) {
185 (false, _) => "",
186 (true, Surface::Cli) => {
187 " Vector mode returns the closest rows by meaning even when none are strong; for exact-word matching use --mode fts."
188 }
189 (true, Surface::Mcp) => {
190 " Vector mode returns the closest rows by meaning even when none are strong; for exact-word matching set mode=\"fts\"."
191 }
192 };
193 let _ = writeln!(
194 out,
195 "key: session rules group hits by session, {order}; within a session, messages are newest-first. \"--- [n] score | role | time | message_id | project | agent | session ---\" delimits each hit + matched text. {full_hint}; raise limit for more (no pagination).{mode_note}"
196 );
197 let mut index = 0;
198 let n_sessions = response.sessions.len();
199 for (session_index, session) in response.sessions.iter().enumerate() {
200 let best = session
203 .matches
204 .iter()
205 .map(|hit| hit.score)
206 .fold(0.0_f64, f64::max);
207 let _ = writeln!(out);
208 let _ = writeln!(
209 out,
210 "{}",
211 rule_line(&format!(
212 "session [{}] best {:.2} | {}/{} matched | {} | {} | {}",
213 session_index + 1,
214 best,
215 session.matched_message_count,
216 session.session_messages_count,
217 session.project,
218 session.source_agent,
219 session.session_id,
220 )),
221 );
222 let remaining = SEARCH_TRANSCRIPT_BUDGET.saturating_sub(out.len());
227 let share = remaining / (n_sessions - session_index);
228 let session_start = out.len();
229 let mut rendered = 0usize;
230 for hit in &session.matches {
231 if rendered > 0 && out.len().saturating_sub(session_start) >= share {
232 break;
233 }
234 index += 1;
235 let _ = writeln!(out);
236 let _ = writeln!(
237 out,
238 "{}",
239 rule_line(&format!(
240 "[{index}] {:.2} | {} | {} | {} | {} | {} | {}",
241 hit.score,
242 hit.role.as_str(),
243 fmt_ts(&hit.timestamp),
244 hit.message_id,
245 session.project,
246 session.source_agent,
247 session.session_id,
248 )),
249 );
250 push_lines(&mut out, &hit.text, "");
251 rendered += 1;
252 }
253 let omitted = session.matches.len() - rendered;
257 if omitted > 0 {
258 let latest_hint = match surface {
259 Surface::Mcp => "read with session_from=end",
260 Surface::Cli => "read with `pond get --session-id <ID> --session-from end`",
261 };
262 let _ = writeln!(
263 out,
264 "... {omitted} more match(es) in this session not shown (char budget); \
265 {latest_hint} for the session's latest state"
266 );
267 }
268 }
269 out
270}
271
272pub fn render_get_transcript(
273 response: &GetResponse,
274 request: &GetRequest,
275 surface: Surface,
276) -> String {
277 use std::fmt::Write;
278 let prefix = match surface {
279 Surface::Mcp => "pond_get",
280 Surface::Cli => "pond get",
281 };
282 let session = &response.session;
283 let mut out = String::new();
284 match &response.result {
285 GetResult::Session {
286 messages,
287 before_remaining,
288 after_remaining,
289 } => {
290 let _ = writeln!(
291 out,
292 "{prefix}: session {}, {}.",
293 session.id,
294 count_noun(messages.len(), "message"),
295 );
296 let (expand_hint, page_hint) = match surface {
297 Surface::Mcp => (
298 "pond_get message_id=<id> to expand any tool body",
299 "Page with session_before_message_id / session_after_message_id.",
300 ),
301 Surface::Cli => (
302 "`pond get --message-id <ID>` to expand any tool body",
303 "Page with --session-before-message-id / --session-after-message-id.",
304 ),
305 };
306 let _ = writeln!(
307 out,
308 "key: \"--- [n] role | time | message_id ---\" delimits each message; \"->\" tool call, \"<-\" result; {expand_hint}. {page_hint}"
309 );
310 if *before_remaining > 0
312 && let Some(first) = messages.first()
313 {
314 let page_up = match surface {
315 Surface::Mcp => format!("pass session_before_message_id={}", first.id),
316 Surface::Cli => {
317 format!("pass --session-before-message-id {}", first.id)
318 }
319 };
320 let _ = writeln!(
321 out,
322 "... {before_remaining} earlier messages; {page_up} to page up",
323 );
324 }
325 for (idx, message) in messages.iter().enumerate() {
326 let _ = writeln!(out);
327 render_message(
328 &mut out,
329 idx + 1,
330 message,
331 None,
332 &message.parts_summary,
333 false,
334 );
335 }
336 let _ = writeln!(out);
337 let _ = writeln!(
338 out,
339 "session {} | {} | {}",
340 session.id, session.source_agent, session.project,
341 );
342 if *after_remaining > 0
344 && let Some(last) = messages.last()
345 {
346 let page_down = match surface {
347 Surface::Mcp => format!("pass session_after_message_id={}", last.id),
348 Surface::Cli => format!("pass --session-after-message-id {}", last.id),
349 };
350 let _ = writeln!(
351 out,
352 "... {after_remaining} later messages; {page_down} to page down",
353 );
354 }
355 }
356 GetResult::Message {
357 target,
358 target_parts,
359 target_parts_remaining,
360 siblings,
361 } => {
362 let _ = writeln!(
363 out,
364 "{prefix}: thread around {} in session {} (context -{}/+{}).",
365 target.id,
366 session.id,
367 request.message_context_before,
368 request.message_context_after,
369 );
370 let expand_hint = match surface {
371 Surface::Mcp => "pond_get message_id=<id> to expand any line",
372 Surface::Cli => "`pond get --message-id <ID>` to expand any line",
373 };
374 let _ = writeln!(
375 out,
376 "key: \"--- [n] role | time | message_id ---\" delimits each message; \">\" = the one you requested; \"->\" tool call, \"<-\" result. {expand_hint}."
377 );
378 let mut thread: Vec<(&MessageView, bool)> =
385 siblings.iter().map(|view| (view, false)).collect();
386 thread.push((target, true));
387 thread.sort_by(|a, b| {
388 a.0.timestamp
389 .cmp(&b.0.timestamp)
390 .then_with(|| a.0.id.cmp(&b.0.id))
391 });
392 thread.retain(|(view, is_target)| *is_target || message_has_content(view));
393 for (idx, (view, is_target)) in thread.iter().enumerate() {
394 let _ = writeln!(out);
395 let parts: Option<&[ResponsePart]> = is_target.then_some(target_parts.as_slice());
398 render_message(
399 &mut out,
400 idx + 1,
401 view,
402 parts,
403 &view.parts_summary,
404 *is_target,
405 );
406 }
407 let _ = writeln!(out);
408 let _ = writeln!(
409 out,
410 "session {} | {} | {}",
411 session.id, session.source_agent, session.project,
412 );
413 if *target_parts_remaining > 0 {
414 let _ = writeln!(
415 out,
416 "... {} more parts of {} omitted (response budget)",
417 target_parts_remaining, target.id,
418 );
419 }
420 }
421 }
422 out
423}
424
425fn message_has_content(view: &MessageView) -> bool {
429 view.text.as_deref().is_some_and(|t| !t.trim().is_empty())
430 || view
431 .content
432 .as_deref()
433 .is_some_and(|c| !c.trim().is_empty())
434 || !view.parts_summary.is_empty()
435}
436
437const RULE_WIDTH: usize = 72;
439
440fn rule_line(inner: &str) -> String {
444 let head = format!("--- {inner} ");
445 let pad = RULE_WIDTH.saturating_sub(head.chars().count()).max(3);
446 format!("{head}{}", "-".repeat(pad))
447}
448
449fn render_message(
454 out: &mut String,
455 index: usize,
456 view: &MessageView,
457 parts: Option<&[ResponsePart]>,
458 summary: &[PartSummary],
459 is_target: bool,
460) {
461 use std::fmt::Write;
462 let marker = if is_target { "> " } else { "" };
463 let _ = writeln!(
464 out,
465 "{}",
466 rule_line(&format!(
467 "[{index}] {marker}{} | {} | {}",
468 view.role.as_str(),
469 fmt_ts(&view.timestamp),
470 view.id,
471 )),
472 );
473 if let Some(text) = &view.text {
474 push_lines(out, text, "");
475 }
476 if let Some(content) = &view.content {
477 push_lines(out, content, "");
478 }
479 match parts {
480 Some(parts) => {
481 for part in parts {
482 render_part_full(out, part);
483 }
484 }
485 None => {
486 for part in summary {
487 render_part_summary(out, part);
488 }
489 }
490 }
491}
492
493fn render_part_full(out: &mut String, part: &ResponsePart) {
494 use std::fmt::Write;
495 match &part.kind {
496 PartKind::Text { text } => {
497 if let Some(text) = text {
498 push_lines(out, text, "");
499 }
500 }
501 PartKind::Reasoning { text } => {
502 let _ = writeln!(out, " (reasoning)");
503 if let Some(text) = text {
504 push_lines(out, text, " ");
505 }
506 }
507 PartKind::ToolCall {
508 name,
509 call_id,
510 params,
511 ..
512 } => {
513 let _ = writeln!(out, " -> {} [{}]", opt_name(name), opt_name(call_id));
514 push_lines(out, &value_to_text(params), " ");
515 }
516 PartKind::ToolResult {
517 name,
518 call_id,
519 is_failure,
520 result,
521 } => {
522 let status = if *is_failure { "failed" } else { "ok" };
523 let _ = writeln!(
524 out,
525 " <- {} [{}] ({status})",
526 opt_name(name),
527 opt_name(call_id),
528 );
529 push_lines(out, &value_to_text(result), " ");
530 }
531 PartKind::File {
532 media_type,
533 file_name,
534 ..
535 } => {
536 let label = file_name
537 .as_deref()
538 .or(media_type.as_deref())
539 .unwrap_or("file");
540 let _ = writeln!(out, " [file {label}]");
541 }
542 PartKind::ToolApprovalRequest { approval_id, .. } => {
543 let _ = writeln!(out, " [approval request {approval_id}]");
544 }
545 PartKind::ToolApprovalResponse {
546 approval_id,
547 approved,
548 ..
549 } => {
550 let verb = if *approved { "approved" } else { "denied" };
551 let _ = writeln!(out, " [approval {approval_id} {verb}]");
552 }
553 }
554}
555
556fn render_part_summary(out: &mut String, summary: &PartSummary) {
557 use std::fmt::Write;
558 let label = summary.label.as_deref().unwrap_or("");
559 let call = summary
560 .call_id
561 .as_deref()
562 .map(|id| format!(" [{id}]"))
563 .unwrap_or_default();
564 match summary.kind.as_str() {
565 "tool_call" => {
566 let _ = writeln!(out, " -> {label}{call}");
567 }
568 "tool_result" => {
569 let _ = writeln!(out, " <- {label}{call}");
570 }
571 "file" => {
572 let _ = writeln!(out, " [file {label}]");
573 }
574 other => {
575 let _ = writeln!(out, " [{other} {label}]");
576 }
577 }
578}
579
580fn value_to_text(value: &serde_json::Value) -> String {
583 match value {
584 serde_json::Value::String(text) => text.clone(),
585 serde_json::Value::Null => String::new(),
586 other => serde_json::to_string(other).unwrap_or_default(),
587 }
588}
589
590#[cfg(test)]
591mod tests {
592 #![allow(clippy::expect_used, clippy::unwrap_used)]
593
594 use super::*;
595 use crate::wire::{Role, SearchFilters, SearchModeWire, SearchResult, SessionFrom};
596
597 #[test]
598 fn get_transcript_marks_target_and_renders_tool_parts() {
599 let ts = chrono::DateTime::from_timestamp(0, 0).unwrap();
600 let tool_call: ResponsePart = serde_json::from_value(serde_json::json!({
601 "id": "p1", "ordinal": 0, "provenance": "conversational",
602 "type": "tool_call", "name": "Bash", "call_id": "toolu_x",
603 "params": { "command": "ls" }, "provider_executed": false,
604 }))
605 .unwrap();
606 let tool_result: ResponsePart = serde_json::from_value(serde_json::json!({
607 "id": "p2", "ordinal": 1, "provenance": "conversational",
608 "type": "tool_result", "name": "Bash", "call_id": "toolu_x",
609 "is_failure": false, "result": "file.txt",
610 }))
611 .unwrap();
612 let target = MessageView {
613 id: "m1".to_owned(),
614 role: crate::wire::Role::Assistant,
615 timestamp: ts,
616 text: Some("Let me list files.".to_owned()),
617 content: None,
618 parts_summary: Vec::new(),
619 };
620 let response = GetResponse {
621 session: crate::wire::GetSession {
622 id: "s1".to_owned(),
623 source_agent: "claude-code".to_owned(),
624 project: "/p".to_owned(),
625 created_at: ts,
626 },
627 result: GetResult::Message {
628 target,
629 target_parts: vec![tool_call, tool_result],
630 target_parts_remaining: 0,
631 siblings: Vec::new(),
632 },
633 };
634 let request = GetRequest {
635 protocol_version: crate::PROTOCOL_VERSION,
636 namespace: None,
637 session_id: None,
638 message_id: Some("m1".to_owned()),
639 session_limit: 20,
640 session_from: SessionFrom::default(),
641 session_after_message_id: None,
642 session_before_message_id: None,
643 message_context_before: 3,
644 message_context_after: 3,
645 };
646
647 let transcript = crate::render::render_get_transcript(&response, &request, Surface::Mcp);
648 assert!(transcript.contains("--- [1] > assistant | 1970-01-01 00:00:00Z | m1 ---"));
649 assert!(transcript.contains("Let me list files."));
650 assert!(transcript.contains(" -> Bash [toolu_x]"));
651 assert!(transcript.contains(" <- Bash [toolu_x] (ok)"));
652 assert!(transcript.contains("session s1 | claude-code | /p"));
653 }
654
655 #[test]
656 fn search_transcript_renders_header_and_hits() {
657 let response = SearchResponse {
658 sessions: vec![crate::wire::SearchSession {
659 session_id: "s1".to_owned(),
660 project: "pond".to_owned(),
661 source_agent: "claude-code".to_owned(),
662 session_messages_count: 2,
663 matched_message_count: 1,
664 matches: vec![SearchResult {
665 message_id: "m1".to_owned(),
666 role: Role::User,
667 timestamp: chrono::DateTime::from_timestamp(0, 0).unwrap(),
668 text: "hello\nworld".to_owned(),
669 score: 1.0,
670 parts_summary: Vec::new(),
671 }],
672 }],
673 matched_total: 1,
674 searchable_in_scope: 2,
675 has_more: false,
676 };
677 let request = SearchRequest {
678 protocol_version: crate::PROTOCOL_VERSION,
679 namespace: None,
680 query: "hi".to_owned(),
681 mode: SearchModeWire::Vector,
682 sort_by: SortBy::Relevance,
683 filters: SearchFilters::default(),
684 limit: 10,
685 };
686
687 let transcript = crate::render::render_search_transcript(&response, &request, Surface::Mcp);
688 assert!(transcript.starts_with(
689 "pond_search: 1 nearest message (2 searchable in scope), showing 1 hit from 1 \
690 session."
691 ));
692 assert!(transcript.contains("Vector mode returns the closest rows"));
694 assert!(
695 transcript.contains("key: session rules group hits by session, ordered by best hit")
696 );
697 assert!(
698 transcript
699 .contains("--- session [1] best 1.00 | 1/2 matched | pond | claude-code | s1")
700 );
701 assert!(
704 transcript.contains(
705 "--- [1] 1.00 | user | 1970-01-01 00:00:00Z | m1 | pond | claude-code | s1"
706 )
707 );
708 assert!(transcript.contains("hello\nworld"));
710 }
711
712 #[test]
713 fn search_transcript_budget_keeps_every_session_and_footers_the_truncated_one() {
714 let big = "x".repeat(600);
715 let hit = |id: usize| SearchResult {
716 message_id: format!("m{id}"),
717 role: Role::Assistant,
718 timestamp: chrono::DateTime::from_timestamp(id as i64, 0).unwrap(),
719 text: big.clone(),
720 score: 0.9,
721 parts_summary: Vec::new(),
722 };
723 let session = |id: &str, matches: Vec<SearchResult>| crate::wire::SearchSession {
724 session_id: id.to_owned(),
725 project: "pond".to_owned(),
726 source_agent: "claude-code".to_owned(),
727 session_messages_count: 100,
728 matched_message_count: matches.len(),
729 matches,
730 };
731 let mut sessions = vec![session("fat", (0..40).map(hit).collect())];
734 for s in 1..=5 {
735 sessions.push(session(&format!("s{s}"), vec![hit(s * 1000)]));
736 }
737 let response = SearchResponse {
738 sessions,
739 matched_total: 45,
740 searchable_in_scope: 200,
741 has_more: false,
742 };
743 let request = SearchRequest {
744 protocol_version: crate::PROTOCOL_VERSION,
745 namespace: None,
746 query: "x".to_owned(),
747 mode: SearchModeWire::Vector,
748 sort_by: SortBy::Relevance,
749 filters: SearchFilters::default(),
750 limit: 10,
751 };
752 let transcript = crate::render::render_search_transcript(&response, &request, Surface::Mcp);
753
754 assert!(
757 transcript.len() < SEARCH_TRANSCRIPT_BUDGET + 3_000,
758 "transcript {} exceeds the soft budget",
759 transcript.len(),
760 );
761 for id in ["fat", "s1", "s2", "s3", "s4", "s5"] {
763 assert!(
764 transcript.contains(&format!("| {id}\n"))
765 || transcript.contains(&format!("| {id} ")),
766 "session {id} did not render",
767 );
768 }
769 assert!(transcript.contains("more match(es) in this session not shown (char budget)"));
772 assert!(transcript.contains("session_from=end"));
773 }
774}