1use harness_core::{Event, Hook, HookOutcome, Memory, MemoryEntry, ModelOutput, World};
29use std::collections::hash_map::DefaultHasher;
30use std::hash::{Hash, Hasher};
31use std::sync::{Arc, Mutex};
32use tokio::sync::mpsc;
33
34#[derive(Debug, Clone)]
36pub struct CapturedTurn {
37 pub session: String,
38 pub role: String,
39 pub content: String,
40}
41
42pub struct TranscriptRecorder {
46 tx: mpsc::UnboundedSender<CapturedTurn>,
47 session: String,
48 max_chars: usize,
50 last: Mutex<Option<u64>>,
52}
53
54pub const DEFAULT_MAX_CHARS: usize = 2_000;
57
58impl TranscriptRecorder {
59 pub fn new(session: impl Into<String>) -> (Self, mpsc::UnboundedReceiver<CapturedTurn>) {
62 let (tx, rx) = mpsc::unbounded_channel();
63 (
64 Self {
65 tx,
66 session: session.into(),
67 max_chars: DEFAULT_MAX_CHARS,
68 last: Mutex::new(None),
69 },
70 rx,
71 )
72 }
73
74 pub fn with_max_chars(mut self, max_chars: usize) -> Self {
77 self.max_chars = max_chars;
78 self
79 }
80
81 pub fn note_user(&self, text: impl Into<String>) {
85 self.enqueue("user", text.into());
86 }
87
88 fn enqueue(&self, role: &str, content: String) {
89 if content.trim().is_empty() {
90 return;
91 }
92 let content = truncate(content, self.max_chars);
93
94 let mut h = DefaultHasher::new();
100 role.hash(&mut h);
101 content.hash(&mut h);
102 let digest = h.finish();
103 {
104 let mut last = self.last.lock().unwrap_or_else(|e| e.into_inner());
105 if *last == Some(digest) {
106 return;
107 }
108 *last = Some(digest);
109 }
110
111 let _ = self.tx.send(CapturedTurn {
112 session: self.session.clone(),
113 role: role.into(),
114 content,
115 });
116 }
117}
118
119fn truncate(s: String, max: usize) -> String {
122 if max == 0 || s.chars().count() <= max {
123 return s;
124 }
125 let kept: String = s.chars().take(max).collect();
126 format!("{kept}… [{} chars truncated]", s.chars().count() - max)
127}
128
129fn assistant_text(out: &ModelOutput) -> String {
132 out.text
133 .clone()
134 .filter(|t| !t.trim().is_empty())
135 .or_else(|| out.reasoning.clone())
136 .unwrap_or_default()
137}
138
139impl Hook for TranscriptRecorder {
140 fn name(&self) -> &str {
141 "transcript-recorder"
142 }
143 fn matches(&self, ev: &Event<'_>) -> bool {
144 matches!(ev, Event::PostModel { .. } | Event::PostToolUse { .. })
145 }
146 fn fire(&self, ev: &Event<'_>, _world: &mut World) -> HookOutcome {
147 match ev {
148 Event::PostModel { out } => self.enqueue("assistant", assistant_text(out)),
149 Event::PostToolUse { action, result } => {
150 let body = serde_json::to_string(&result.content).unwrap_or_default();
151 self.enqueue("tool", format!("[{}] {}", action.tool, body));
152 }
153 _ => {}
154 }
155 HookOutcome::Allow
156 }
157}
158
159pub fn spawn_transcript_writer(
168 rx: mpsc::UnboundedReceiver<CapturedTurn>,
169 memory: Arc<dyn Memory>,
170) -> tokio::task::JoinHandle<()> {
171 spawn_transcript_writer_with_ttl(rx, memory, Some(DEFAULT_TTL_DAYS))
172}
173
174pub const DEFAULT_TTL_DAYS: u32 = 30;
179
180pub fn spawn_transcript_writer_with_ttl(
183 mut rx: mpsc::UnboundedReceiver<CapturedTurn>,
184 memory: Arc<dyn Memory>,
185 ttl_days: Option<u32>,
186) -> tokio::task::JoinHandle<()> {
187 tokio::spawn(async move {
188 while let Some(turn) = rx.recv().await {
189 let mut entry = MemoryEntry::new(turn.content)
190 .with_source("transcript")
191 .with_tags([
192 format!("role:{}", turn.role),
193 format!("session:{}", turn.session),
194 ]);
195 if let Some(days) = ttl_days {
196 entry = entry.with_ttl_days(days);
197 }
198 if let Err(e) = memory.write(entry).await {
199 tracing::warn!(error = %e, "transcript write failed");
200 }
201 }
202 })
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use harness_core::{Action, MemoryError, ToolResult};
209 use std::sync::Mutex;
210
211 #[test]
212 fn a_long_tool_result_is_truncated_with_a_marker() {
213 let (rec, mut rx) = TranscriptRecorder::new("s");
214 rec.note_user("x".repeat(DEFAULT_MAX_CHARS + 500));
215 let turn = rx.try_recv().expect("one turn");
216 assert!(turn.content.chars().count() < DEFAULT_MAX_CHARS + 100);
217 assert!(
218 turn.content.contains("500 chars truncated"),
219 "{}",
220 turn.content
221 );
222 }
223
224 #[test]
225 fn truncation_respects_char_boundaries() {
226 let (rec, mut rx) = TranscriptRecorder::new("s");
228 let rec = rec.with_max_chars(3);
229 rec.note_user("你好世界啊");
230 let turn = rx.try_recv().expect("one turn");
231 assert!(turn.content.starts_with("你好世"));
232 assert!(turn.content.contains("2 chars truncated"));
233 }
234
235 #[test]
236 fn a_verbatim_repeat_of_the_previous_turn_is_dropped() {
237 let (rec, mut rx) = TranscriptRecorder::new("s");
240 rec.note_user("[list_dir] {\"path\":\".\"}");
241 rec.note_user("[list_dir] {\"path\":\".\"}");
242 rec.note_user("[list_dir] {\"path\":\".\"}");
243 assert!(rx.try_recv().is_ok());
244 assert!(rx.try_recv().is_err(), "repeats must not be enqueued");
245 }
246
247 #[test]
248 fn a_repeat_after_something_else_is_kept() {
249 let (rec, mut rx) = TranscriptRecorder::new("s");
252 rec.note_user("ls");
253 rec.note_user("cat x");
254 rec.note_user("ls");
255 assert_eq!(rx.try_recv().unwrap().content, "ls");
256 assert_eq!(rx.try_recv().unwrap().content, "cat x");
257 assert_eq!(rx.try_recv().unwrap().content, "ls");
258 }
259
260 #[test]
261 fn max_chars_zero_disables_truncation() {
262 let (rec, mut rx) = TranscriptRecorder::new("s");
263 let rec = rec.with_max_chars(0);
264 let long = "y".repeat(50_000);
265 rec.note_user(long.clone());
266 assert_eq!(rx.try_recv().unwrap().content, long);
267 }
268
269 #[tokio::test]
270 async fn written_turns_carry_a_ttl_by_default() {
271 let (tx, rx) = mpsc::unbounded_channel();
272 let mem = Arc::new(CapMem::default());
273 let h = spawn_transcript_writer(rx, mem.clone());
274 tx.send(CapturedTurn {
275 session: "s".into(),
276 role: "tool".into(),
277 content: "[ls] {}".into(),
278 })
279 .unwrap();
280 drop(tx);
281 h.await.unwrap();
282 let got = mem.0.lock().unwrap();
283 assert_eq!(got.len(), 1);
284 assert!(
285 got[0].expires_ms.is_some(),
286 "a transcript turn must expire on its own"
287 );
288 }
289
290 #[tokio::test]
291 async fn an_explicit_none_ttl_keeps_turns_forever() {
292 let (tx, rx) = mpsc::unbounded_channel();
293 let mem = Arc::new(CapMem::default());
294 let h = spawn_transcript_writer_with_ttl(rx, mem.clone(), None);
295 tx.send(CapturedTurn {
296 session: "s".into(),
297 role: "tool".into(),
298 content: "[ls] {}".into(),
299 })
300 .unwrap();
301 drop(tx);
302 h.await.unwrap();
303 assert!(mem.0.lock().unwrap()[0].expires_ms.is_none());
304 }
305
306 #[derive(Default)]
307 struct CapMem(Mutex<Vec<MemoryEntry>>);
308 #[async_trait::async_trait]
309 impl Memory for CapMem {
310 async fn recall(&self, _q: &str, _k: usize) -> Result<Vec<MemoryEntry>, MemoryError> {
311 Ok(vec![])
312 }
313 async fn write(&self, e: MemoryEntry) -> Result<(), MemoryError> {
314 self.0.lock().unwrap().push(e);
315 Ok(())
316 }
317 }
318
319 fn out(text: &str) -> ModelOutput {
320 ModelOutput {
321 text: Some(text.into()),
322 ..Default::default()
323 }
324 }
325
326 #[tokio::test]
327 async fn captures_user_assistant_and_tool_turns_into_memory() {
328 let (recorder, rx) = TranscriptRecorder::new("sess-1");
329 let mem = Arc::new(CapMem::default());
330 let handle = spawn_transcript_writer(rx, mem.clone());
331
332 recorder.note_user("hello");
334 let mut world = harness_context::default_world(std::env::temp_dir());
335 let o = out("hi there");
336 recorder.fire(&Event::PostModel { out: &o }, &mut world);
337 let action = Action {
338 tool: "read_file".into(),
339 call_id: "c1".into(),
340 args: serde_json::json!({"path": "x"}),
341 };
342 let result = ToolResult {
343 ok: true,
344 content: serde_json::json!({"content": "data"}),
345 trace: None,
346 };
347 recorder.fire(
348 &Event::PostToolUse {
349 action: &action,
350 result: &result,
351 },
352 &mut world,
353 );
354
355 drop(recorder);
357 handle.await.unwrap();
358
359 let stored = mem.0.lock().unwrap();
360 assert_eq!(stored.len(), 3, "user + assistant + tool");
361 let roles: Vec<&str> = stored
362 .iter()
363 .filter_map(|e| e.tags.iter().find(|t| t.starts_with("role:")))
364 .map(|s| s.as_str())
365 .collect();
366 assert!(roles.contains(&"role:user"));
367 assert!(roles.contains(&"role:assistant"));
368 assert!(roles.contains(&"role:tool"));
369 assert!(
370 stored
371 .iter()
372 .all(|e| e.tags.iter().any(|t| t == "session:sess-1"))
373 );
374 }
375}
376
377#[cfg(test)]
378mod export_surface {
379 #[test]
383 fn superleo_facing_exports_resolve() {
384 let _: u32 = crate::experience::DEFAULT_TTL_DAYS;
385 let _ = crate::experience::spawn_transcript_writer_with_ttl;
386 let (_rec, _rx) = crate::experience::TranscriptRecorder::new("s");
387 let _: usize = crate::experience::DEFAULT_MAX_CHARS;
388 }
389}