use harness_core::{Event, Hook, HookOutcome, Memory, MemoryEntry, ModelOutput, World};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
#[derive(Debug, Clone)]
pub struct CapturedTurn {
pub session: String,
pub role: String,
pub content: String,
}
pub struct TranscriptRecorder {
tx: mpsc::UnboundedSender<CapturedTurn>,
session: String,
max_chars: usize,
last: Mutex<Option<u64>>,
}
pub const DEFAULT_MAX_CHARS: usize = 2_000;
impl TranscriptRecorder {
pub fn new(session: impl Into<String>) -> (Self, mpsc::UnboundedReceiver<CapturedTurn>) {
let (tx, rx) = mpsc::unbounded_channel();
(
Self {
tx,
session: session.into(),
max_chars: DEFAULT_MAX_CHARS,
last: Mutex::new(None),
},
rx,
)
}
pub fn with_max_chars(mut self, max_chars: usize) -> Self {
self.max_chars = max_chars;
self
}
pub fn note_user(&self, text: impl Into<String>) {
self.enqueue("user", text.into());
}
fn enqueue(&self, role: &str, content: String) {
if content.trim().is_empty() {
return;
}
let content = truncate(content, self.max_chars);
let mut h = DefaultHasher::new();
role.hash(&mut h);
content.hash(&mut h);
let digest = h.finish();
{
let mut last = self.last.lock().unwrap_or_else(|e| e.into_inner());
if *last == Some(digest) {
return;
}
*last = Some(digest);
}
let _ = self.tx.send(CapturedTurn {
session: self.session.clone(),
role: role.into(),
content,
});
}
}
fn truncate(s: String, max: usize) -> String {
if max == 0 || s.chars().count() <= max {
return s;
}
let kept: String = s.chars().take(max).collect();
format!("{kept}… [{} chars truncated]", s.chars().count() - max)
}
fn assistant_text(out: &ModelOutput) -> String {
out.text
.clone()
.filter(|t| !t.trim().is_empty())
.or_else(|| out.reasoning.clone())
.unwrap_or_default()
}
impl Hook for TranscriptRecorder {
fn name(&self) -> &str {
"transcript-recorder"
}
fn matches(&self, ev: &Event<'_>) -> bool {
matches!(ev, Event::PostModel { .. } | Event::PostToolUse { .. })
}
fn fire(&self, ev: &Event<'_>, _world: &mut World) -> HookOutcome {
match ev {
Event::PostModel { out } => self.enqueue("assistant", assistant_text(out)),
Event::PostToolUse { action, result } => {
let body = serde_json::to_string(&result.content).unwrap_or_default();
self.enqueue("tool", format!("[{}] {}", action.tool, body));
}
_ => {}
}
HookOutcome::Allow
}
}
pub fn spawn_transcript_writer(
rx: mpsc::UnboundedReceiver<CapturedTurn>,
memory: Arc<dyn Memory>,
) -> tokio::task::JoinHandle<()> {
spawn_transcript_writer_with_ttl(rx, memory, Some(DEFAULT_TTL_DAYS))
}
pub const DEFAULT_TTL_DAYS: u32 = 30;
pub fn spawn_transcript_writer_with_ttl(
mut rx: mpsc::UnboundedReceiver<CapturedTurn>,
memory: Arc<dyn Memory>,
ttl_days: Option<u32>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
while let Some(turn) = rx.recv().await {
let mut entry = MemoryEntry::new(turn.content)
.with_source("transcript")
.with_tags([
format!("role:{}", turn.role),
format!("session:{}", turn.session),
]);
if let Some(days) = ttl_days {
entry = entry.with_ttl_days(days);
}
if let Err(e) = memory.write(entry).await {
tracing::warn!(error = %e, "transcript write failed");
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use harness_core::{Action, MemoryError, ToolResult};
use std::sync::Mutex;
#[test]
fn a_long_tool_result_is_truncated_with_a_marker() {
let (rec, mut rx) = TranscriptRecorder::new("s");
rec.note_user("x".repeat(DEFAULT_MAX_CHARS + 500));
let turn = rx.try_recv().expect("one turn");
assert!(turn.content.chars().count() < DEFAULT_MAX_CHARS + 100);
assert!(
turn.content.contains("500 chars truncated"),
"{}",
turn.content
);
}
#[test]
fn truncation_respects_char_boundaries() {
let (rec, mut rx) = TranscriptRecorder::new("s");
let rec = rec.with_max_chars(3);
rec.note_user("你好世界啊");
let turn = rx.try_recv().expect("one turn");
assert!(turn.content.starts_with("你好世"));
assert!(turn.content.contains("2 chars truncated"));
}
#[test]
fn a_verbatim_repeat_of_the_previous_turn_is_dropped() {
let (rec, mut rx) = TranscriptRecorder::new("s");
rec.note_user("[list_dir] {\"path\":\".\"}");
rec.note_user("[list_dir] {\"path\":\".\"}");
rec.note_user("[list_dir] {\"path\":\".\"}");
assert!(rx.try_recv().is_ok());
assert!(rx.try_recv().is_err(), "repeats must not be enqueued");
}
#[test]
fn a_repeat_after_something_else_is_kept() {
let (rec, mut rx) = TranscriptRecorder::new("s");
rec.note_user("ls");
rec.note_user("cat x");
rec.note_user("ls");
assert_eq!(rx.try_recv().unwrap().content, "ls");
assert_eq!(rx.try_recv().unwrap().content, "cat x");
assert_eq!(rx.try_recv().unwrap().content, "ls");
}
#[test]
fn max_chars_zero_disables_truncation() {
let (rec, mut rx) = TranscriptRecorder::new("s");
let rec = rec.with_max_chars(0);
let long = "y".repeat(50_000);
rec.note_user(long.clone());
assert_eq!(rx.try_recv().unwrap().content, long);
}
#[tokio::test]
async fn written_turns_carry_a_ttl_by_default() {
let (tx, rx) = mpsc::unbounded_channel();
let mem = Arc::new(CapMem::default());
let h = spawn_transcript_writer(rx, mem.clone());
tx.send(CapturedTurn {
session: "s".into(),
role: "tool".into(),
content: "[ls] {}".into(),
})
.unwrap();
drop(tx);
h.await.unwrap();
let got = mem.0.lock().unwrap();
assert_eq!(got.len(), 1);
assert!(
got[0].expires_ms.is_some(),
"a transcript turn must expire on its own"
);
}
#[tokio::test]
async fn an_explicit_none_ttl_keeps_turns_forever() {
let (tx, rx) = mpsc::unbounded_channel();
let mem = Arc::new(CapMem::default());
let h = spawn_transcript_writer_with_ttl(rx, mem.clone(), None);
tx.send(CapturedTurn {
session: "s".into(),
role: "tool".into(),
content: "[ls] {}".into(),
})
.unwrap();
drop(tx);
h.await.unwrap();
assert!(mem.0.lock().unwrap()[0].expires_ms.is_none());
}
#[derive(Default)]
struct CapMem(Mutex<Vec<MemoryEntry>>);
#[async_trait::async_trait]
impl Memory for CapMem {
async fn recall(&self, _q: &str, _k: usize) -> Result<Vec<MemoryEntry>, MemoryError> {
Ok(vec![])
}
async fn write(&self, e: MemoryEntry) -> Result<(), MemoryError> {
self.0.lock().unwrap().push(e);
Ok(())
}
}
fn out(text: &str) -> ModelOutput {
ModelOutput {
text: Some(text.into()),
..Default::default()
}
}
#[tokio::test]
async fn captures_user_assistant_and_tool_turns_into_memory() {
let (recorder, rx) = TranscriptRecorder::new("sess-1");
let mem = Arc::new(CapMem::default());
let handle = spawn_transcript_writer(rx, mem.clone());
recorder.note_user("hello");
let mut world = harness_context::default_world(std::env::temp_dir());
let o = out("hi there");
recorder.fire(&Event::PostModel { out: &o }, &mut world);
let action = Action {
tool: "read_file".into(),
call_id: "c1".into(),
args: serde_json::json!({"path": "x"}),
};
let result = ToolResult {
ok: true,
content: serde_json::json!({"content": "data"}),
trace: None,
};
recorder.fire(
&Event::PostToolUse {
action: &action,
result: &result,
},
&mut world,
);
drop(recorder);
handle.await.unwrap();
let stored = mem.0.lock().unwrap();
assert_eq!(stored.len(), 3, "user + assistant + tool");
let roles: Vec<&str> = stored
.iter()
.filter_map(|e| e.tags.iter().find(|t| t.starts_with("role:")))
.map(|s| s.as_str())
.collect();
assert!(roles.contains(&"role:user"));
assert!(roles.contains(&"role:assistant"));
assert!(roles.contains(&"role:tool"));
assert!(
stored
.iter()
.all(|e| e.tags.iter().any(|t| t == "session:sess-1"))
);
}
}
#[cfg(test)]
mod export_surface {
#[test]
fn superleo_facing_exports_resolve() {
let _: u32 = crate::experience::DEFAULT_TTL_DAYS;
let _ = crate::experience::spawn_transcript_writer_with_ttl;
let (_rec, _rx) = crate::experience::TranscriptRecorder::new("s");
let _: usize = crate::experience::DEFAULT_MAX_CHARS;
}
}