Skip to main content

harness_loop/
memory_layer.rs

1//! Long-term-memory wiring for [`crate::AgentLoop`].
2//!
3//! Two pieces, designed to be installed together:
4//!
5//! - [`MemoryGuide`] — at session start, calls [`Memory::recall`] with the
6//!   current task description and pushes the top-K matches into
7//!   `ctx.guides` as plain text. The model sees a "Relevant prior context"
8//!   section in its system prompt before the very first model call.
9//!
10//! - [`MemoryWriter`] — captures every assistant text turn (via `PostModel`)
11//!   and persists the *last* one as a [`MemoryEntry`] when the run finishes
12//!   (`TaskCompleted`). This turns "this conversation produced an answer"
13//!   into "future sessions can recall the answer".
14//!
15//! Both share an `Arc<dyn Memory>` so a single backend serves recall +
16//! write. The trait is async; the writer hook uses `tokio::spawn` to commit
17//! without blocking the loop.
18//!
19//! ## Wiring
20//!
21//! ```ignore
22//! let mem: Arc<dyn Memory> = Arc::new(FileMemory::open("~/.harness/mem.jsonl")?);
23//! let loop_ = AgentLoop::new(model)
24//!     .with_guide(Arc::new(MemoryGuide::new(mem.clone()).with_top_k(5)))
25//!     .with_hook(Arc::new(MemoryWriter::new(mem)));
26//! ```
27
28use async_trait::async_trait;
29use harness_core::{
30    Block, Context, Event, Execution, Guide, GuideError, GuideId, GuideScope, Hook, HookOutcome,
31    Memory, MemoryEntry, Model, Task, Turn, TurnRole, World,
32};
33use std::sync::{Arc, Mutex, OnceLock};
34
35/// Marker prefix used to identify the recall block in `ctx.guides`. We
36/// strip prior recall blocks on each `apply_before_iter` so the injected
37/// list reflects only the LATEST recall — otherwise ctx.guides grows
38/// unboundedly across iterations.
39const MEMORY_RECALL_MARKER: &str = "[memory-recall]\n";
40
41/// Guide that recalls relevant prior memories and injects them into
42/// `ctx.guides` as a `Block::Text` for the model to see.
43///
44/// Two recall points:
45///
46/// - `apply` (session start): one-shot recall using `ctx.task.description`
47///   as the query. Always fires.
48/// - `apply_before_iter` (every model turn): re-recalls using the **last
49///   user message** as query, replacing the previous recall block. Lets
50///   the recall track topic drift mid-session. No-op when there's no user
51///   message in history (the very first iteration uses the `apply` recall).
52///
53/// Filters (chainable builders, post-recall):
54///
55/// - `with_top_k(k)` — number of candidates to fetch from `Memory::recall`.
56///   Default 5.
57/// - `with_min_score(s)` — drop entries whose recomputed normalised
58///   keyword overlap with the query is below `s`. Default 0.0 (no filter).
59///   Score = `(query_tokens ∩ entry_tokens).len() / query_tokens.len()`.
60/// - `with_required_tags(tags)` — drop entries that don't have ALL these
61///   tags. Default empty (no filter).
62/// - `with_excluded_tags(tags)` — drop entries that have ANY of these
63///   tags. Default empty.
64///
65/// When filters are tight, we over-fetch `top_k * 3` candidates so there's
66/// room to drop without starving the output.
67pub struct MemoryGuide {
68    memory: Arc<dyn Memory>,
69    top_k: usize,
70    min_score: f32,
71    required_tags: Vec<String>,
72    excluded_tags: Vec<String>,
73}
74
75static MEMORY_GUIDE_ID: OnceLock<GuideId> = OnceLock::new();
76static MEMORY_GUIDE_SCOPE: OnceLock<GuideScope> = OnceLock::new();
77
78impl MemoryGuide {
79    /// Construct a guide that recalls up to 5 entries per session.
80    pub fn new(memory: Arc<dyn Memory>) -> Self {
81        Self {
82            memory,
83            top_k: 5,
84            min_score: 0.0,
85            required_tags: Vec::new(),
86            excluded_tags: Vec::new(),
87        }
88    }
89
90    /// Override the number of memories recalled per session. Pick small —
91    /// every recalled line spends prompt tokens.
92    pub fn with_top_k(mut self, k: usize) -> Self {
93        self.top_k = k;
94        self
95    }
96
97    /// Drop entries whose recomputed normalised keyword overlap with the
98    /// query is below `s` ∈ [0, 1]. Default 0.0 (= keep all top_k).
99    ///
100    /// Score formula:
101    /// `(distinct query tokens present in entry.content+tags) / (query token count)`
102    ///
103    /// So a query of 4 tokens needs ≥3 to land in the entry to score ≥ 0.75.
104    pub fn with_min_score(mut self, s: f32) -> Self {
105        self.min_score = s.clamp(0.0, 1.0);
106        self
107    }
108
109    /// Only inject entries that have ALL of these tags. Empty = no filter.
110    pub fn with_required_tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
111        self.required_tags = tags.into_iter().map(Into::into).collect();
112        self
113    }
114
115    /// Drop entries that have ANY of these tags. Empty = no filter.
116    pub fn with_excluded_tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
117        self.excluded_tags = tags.into_iter().map(Into::into).collect();
118        self
119    }
120
121    /// Inner recall + filter + format pass. Returns the formatted block
122    /// text, or `None` if there's nothing to inject.
123    async fn recall_block(&self, query: &str) -> Option<String> {
124        if self.top_k == 0 || query.trim().is_empty() {
125            return None;
126        }
127        // Over-fetch when filters are active so the post-filter has room.
128        let fetch_k = if self.min_score > 0.0
129            || !self.required_tags.is_empty()
130            || !self.excluded_tags.is_empty()
131        {
132            self.top_k.saturating_mul(3).max(self.top_k)
133        } else {
134            self.top_k
135        };
136        let hits = match self.memory.recall(query, fetch_k).await {
137            Ok(v) => v,
138            Err(e) => {
139                tracing::warn!(error = %e, "memory recall failed; proceeding without it");
140                return None;
141            }
142        };
143        let q_tokens = tokenise_for_score(query);
144        let q_len = q_tokens.len().max(1) as f32;
145
146        let mut kept: Vec<&MemoryEntry> = Vec::new();
147        for e in &hits {
148            // Tag filters first — cheaper than re-scoring.
149            if !self.required_tags.is_empty()
150                && !self
151                    .required_tags
152                    .iter()
153                    .all(|t| e.tags.iter().any(|x| x == t))
154            {
155                continue;
156            }
157            if !self.excluded_tags.is_empty()
158                && self
159                    .excluded_tags
160                    .iter()
161                    .any(|t| e.tags.iter().any(|x| x == t))
162            {
163                continue;
164            }
165            if self.min_score > 0.0 {
166                let score = recompute_score(&q_tokens, e);
167                if (score / q_len) < self.min_score {
168                    continue;
169                }
170            }
171            kept.push(e);
172            if kept.len() >= self.top_k {
173                break;
174            }
175        }
176        if kept.is_empty() {
177            return None;
178        }
179        let mut lines = String::from(MEMORY_RECALL_MARKER);
180        lines.push_str("Relevant prior context (from your long-term memory):");
181        for (i, e) in kept.iter().enumerate() {
182            lines.push_str(&format!("\n  {}. {}", i + 1, e.content.trim()));
183        }
184        Some(lines)
185    }
186
187    fn remove_previous_recall_block(ctx: &mut Context) {
188        ctx.guides
189            .retain(|b| !matches!(b, Block::Text(t) if t.starts_with(MEMORY_RECALL_MARKER)));
190    }
191}
192
193/// Pull out the most recent user-role text from `ctx.history`. Used by
194/// `apply_before_iter` to drive the per-turn recall query.
195fn last_user_text(ctx: &Context) -> Option<String> {
196    use harness_core::{Block as B, TurnRole};
197    for turn in ctx.history.iter().rev() {
198        if turn.role != TurnRole::User {
199            continue;
200        }
201        for block in turn.blocks.iter().rev() {
202            if let B::Text(t) = block
203                && !t.trim().is_empty()
204            {
205                return Some(t.clone());
206            }
207        }
208    }
209    None
210}
211
212fn tokenise_for_score(s: &str) -> std::collections::HashSet<String> {
213    s.to_lowercase()
214        .split(|c: char| !c.is_alphanumeric())
215        .filter(|t| t.len() >= 3)
216        .map(String::from)
217        .collect()
218}
219
220fn recompute_score(query_tokens: &std::collections::HashSet<String>, entry: &MemoryEntry) -> f32 {
221    let mut hay = entry.content.to_lowercase();
222    if !entry.tags.is_empty() {
223        hay.push(' ');
224        hay.push_str(&entry.tags.join(" ").to_lowercase());
225    }
226    query_tokens
227        .iter()
228        .filter(|t| hay.contains(t.as_str()))
229        .count() as f32
230}
231
232#[async_trait]
233impl Guide for MemoryGuide {
234    fn id(&self) -> &GuideId {
235        MEMORY_GUIDE_ID.get_or_init(|| "memory-recall".into())
236    }
237    fn kind(&self) -> Execution {
238        // The recall *itself* is computational (keyword match / vector
239        // lookup); the model later infers over the result.
240        Execution::Computational
241    }
242    fn scope(&self) -> &GuideScope {
243        MEMORY_GUIDE_SCOPE.get_or_init(|| GuideScope::Always)
244    }
245    async fn apply(&self, ctx: &mut Context, _w: &World) -> Result<(), GuideError> {
246        Self::remove_previous_recall_block(ctx);
247        if let Some(block) = self.recall_block(&ctx.task.description).await {
248            ctx.guides.push(Block::Text(block));
249        }
250        Ok(())
251    }
252    async fn apply_before_iter(&self, ctx: &mut Context, _w: &World) -> Result<(), GuideError> {
253        // Query = latest user message; fall back to task.description on
254        // turn 0 (before any user turn lands in history — though the loop
255        // pushes the task as a user turn before iter 0 so this is rare).
256        let query = last_user_text(ctx).unwrap_or_else(|| ctx.task.description.clone());
257        Self::remove_previous_recall_block(ctx);
258        if let Some(block) = self.recall_block(&query).await {
259            ctx.guides.push(Block::Text(block));
260        }
261        Ok(())
262    }
263}
264
265/// Hook that writes the final assistant text of every successful run back
266/// into long-term memory.
267///
268/// Behaviour:
269/// - On every `PostModel`, captures `out.text` into an internal slot.
270/// - On `TaskCompleted`, takes the most recent captured text and writes it
271///   as a `MemoryEntry` tagged with the source (defaults to `"session"`).
272/// - On `SessionEnd` without a `TaskCompleted` (i.e. `BudgetExhausted`),
273///   nothing is written — partial work shouldn't pollute long-term memory.
274pub struct MemoryWriter {
275    memory: Arc<dyn Memory>,
276    last_text: Mutex<Option<String>>,
277    source: String,
278    tags: Vec<String>,
279}
280
281impl MemoryWriter {
282    pub fn new(memory: Arc<dyn Memory>) -> Self {
283        Self {
284            memory,
285            last_text: Mutex::new(None),
286            source: "session".into(),
287            tags: Vec::new(),
288        }
289    }
290
291    /// Tag every persisted memory with the given source name (e.g.
292    /// `"investor-bot"`, `"personal-assistant"`). Useful for multi-app
293    /// memory stores.
294    pub fn with_source(mut self, source: impl Into<String>) -> Self {
295        self.source = source.into();
296        self
297    }
298
299    pub fn with_tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
300        self.tags = tags.into_iter().map(Into::into).collect();
301        self
302    }
303}
304
305impl Hook for MemoryWriter {
306    fn name(&self) -> &str {
307        "memory-writer"
308    }
309    fn matches(&self, ev: &Event<'_>) -> bool {
310        matches!(ev, Event::PostModel { .. } | Event::TaskCompleted)
311    }
312    fn fire(&self, ev: &Event<'_>, _w: &mut World) -> HookOutcome {
313        match ev {
314            Event::PostModel { out } => {
315                if let Some(text) = &out.text
316                    && !text.trim().is_empty()
317                    && let Ok(mut slot) = self.last_text.lock()
318                {
319                    *slot = Some(text.clone());
320                }
321            }
322            Event::TaskCompleted => {
323                let Some(text) = self.last_text.lock().ok().and_then(|mut g| g.take()) else {
324                    return HookOutcome::Allow;
325                };
326                let entry = MemoryEntry::new(text)
327                    .with_source(self.source.clone())
328                    .with_tags(self.tags.clone());
329                let mem = self.memory.clone();
330                // Fire-and-forget: we're inside an async loop, so spawning
331                // is safe and avoids blocking the next iteration.
332                tokio::spawn(async move {
333                    if let Err(e) = mem.write(entry).await {
334                        tracing::warn!(error = %e, "memory write failed");
335                    }
336                });
337            }
338            _ => {}
339        }
340        HookOutcome::Allow
341    }
342}
343
344/// The deterministic middle ground between [`MemoryWriter`] (verbatim final
345/// answer — noisy) and [`MemorySynthesizer`] (LLM distillation — costs a
346/// model call): persist only the lines that start with a capture marker,
347/// `"DECISION:"` by default.
348///
349/// Zero model calls, zero cost, and predictable: a run that states no
350/// decision writes nothing at all, so the store accumulates only content an
351/// agent explicitly committed to. Pair it with a system prompt that asks for
352/// conclusions as `DECISION:` lines. Measured against an LLM-free
353/// co-occurrence extractor on technical run transcripts, marker capture was
354/// the only extraction that stayed noise-free.
355///
356/// Marker lines from *every* assistant turn of the run are collected (not
357/// just the final one — an agent may commit to something mid-run), deduped
358/// verbatim, and written as one entry tagged `decision` on `TaskCompleted`.
359pub struct DecisionWriter {
360    memory: Arc<dyn Memory>,
361    markers: Vec<String>,
362    lines: Mutex<Vec<String>>,
363    source: String,
364    tags: Vec<String>,
365}
366
367impl DecisionWriter {
368    pub fn new(memory: Arc<dyn Memory>) -> Self {
369        Self {
370            memory,
371            markers: vec!["DECISION:".into()],
372            lines: Mutex::new(Vec::new()),
373            source: "session".into(),
374            tags: vec!["decision".into()],
375        }
376    }
377
378    /// Replace the line prefixes that trigger capture.
379    pub fn with_markers(mut self, markers: impl IntoIterator<Item = impl Into<String>>) -> Self {
380        self.markers = markers.into_iter().map(Into::into).collect();
381        self
382    }
383
384    /// Tag every persisted entry with the given source name.
385    pub fn with_source(mut self, source: impl Into<String>) -> Self {
386        self.source = source.into();
387        self
388    }
389
390    pub fn with_tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
391        self.tags = tags.into_iter().map(Into::into).collect();
392        self
393    }
394}
395
396impl Hook for DecisionWriter {
397    fn name(&self) -> &str {
398        "decision-writer"
399    }
400    fn matches(&self, ev: &Event<'_>) -> bool {
401        matches!(ev, Event::PostModel { .. } | Event::TaskCompleted)
402    }
403    fn fire(&self, ev: &Event<'_>, _w: &mut World) -> HookOutcome {
404        match ev {
405            Event::PostModel { out } => {
406                let Some(text) = &out.text else {
407                    return HookOutcome::Allow;
408                };
409                let Ok(mut lines) = self.lines.lock() else {
410                    return HookOutcome::Allow;
411                };
412                for line in text.lines() {
413                    let line = line.trim();
414                    if self.markers.iter().any(|m| line.starts_with(m.as_str()))
415                        && !lines.iter().any(|l| l == line)
416                    {
417                        lines.push(line.to_string());
418                    }
419                }
420            }
421            Event::TaskCompleted => {
422                let captured: Vec<String> = match self.lines.lock() {
423                    Ok(mut g) => std::mem::take(&mut *g),
424                    Err(_) => return HookOutcome::Allow,
425                };
426                if captured.is_empty() {
427                    return HookOutcome::Allow;
428                }
429                let entry = MemoryEntry::new(captured.join("\n"))
430                    .with_source(self.source.clone())
431                    .with_tags(self.tags.clone());
432                let mem = self.memory.clone();
433                tokio::spawn(async move {
434                    if let Err(e) = mem.write(entry).await {
435                        tracing::warn!(error = %e, "decision write failed");
436                    }
437                });
438            }
439            _ => {}
440        }
441        HookOutcome::Allow
442    }
443}
444
445/// Smarter alternative to [`MemoryWriter`] — distil the session's assistant
446/// turns into 1..=`max_facts` atomic durable facts using a cheap
447/// "synthesizer" model, instead of persisting the verbatim final answer.
448///
449/// Wire either `MemoryWriter` **or** `MemorySynthesizer`, not both —
450/// `MemorySynthesizer` is a superset of the writer's behaviour with the
451/// extra distillation step.
452///
453/// Behaviour:
454/// - On `PostModel`, appends `out.text` (when present, non-empty) to an
455///   internal buffer.
456/// - On `TaskCompleted`, `tokio::spawn`s a synthesis task: calls
457///   `synth_model.complete()` with a fixed prompt that asks for a JSON
458///   array of `{content, tags}` objects, parses the response, and writes
459///   each one via `Memory::write`.
460/// - Model errors / parse failures fall back to saving the raw response
461///   as a single entry tagged `"synth-raw"` so the session's information
462///   isn't lost entirely.
463/// - On `BudgetExhausted` (no `TaskCompleted` fires), nothing is written.
464///
465/// The synth model should be cheap (`deepseek-v4-flash`, `gpt-5-nano`, etc.).
466/// Constructed independently from the main model so you can use a small
467/// summariser even when the reasoning model is large.
468pub struct MemorySynthesizer {
469    memory: Arc<dyn Memory>,
470    synth_model: Arc<dyn Model>,
471    transcripts: Mutex<Vec<String>>,
472    source: String,
473    base_tags: Vec<String>,
474    max_facts: usize,
475    /// App-specific instructions prepended to the synth prompt. Used to
476    /// give the model domain context (e.g. "this is a personal accounting
477    /// app — transactions are already stored, don't repeat flows as facts").
478    extra_instructions: Option<String>,
479    // JoinHandles of spawned synthesis tasks. The agent loop's owner can
480    // `await flush_pending()` before exiting to guarantee that synth
481    // completes before the process tears down its tokio runtime.
482    pending: Mutex<Vec<tokio::task::JoinHandle<()>>>,
483}
484
485impl MemorySynthesizer {
486    /// Construct a synthesizer that uses `synth_model` to distil the
487    /// session into at most 3 facts.
488    pub fn new(memory: Arc<dyn Memory>, synth_model: Arc<dyn Model>) -> Self {
489        Self {
490            memory,
491            synth_model,
492            transcripts: Mutex::new(Vec::new()),
493            source: "session".into(),
494            base_tags: Vec::new(),
495            max_facts: 3,
496            extra_instructions: None,
497            pending: Mutex::new(Vec::new()),
498        }
499    }
500
501    /// Prepend domain-specific guidance to the synthesizer's prompt. The
502    /// extra text shows up BEFORE the standard "extract durable facts"
503    /// instructions, so it sets context for what the model should consider
504    /// durable in this application.
505    ///
506    /// Example for a personal-accounting app:
507    /// ```ignore
508    /// .with_extra_instructions(
509    ///   "This is a personal-accounting agent. Transaction flows like \
510    ///    '¥199 火锅 microwave' are stored in the txns table — do NOT \
511    ///    re-store them as facts. ONLY record: stable user preferences \
512    ///    (payment habits, category conventions), repeated behaviour \
513    ///    patterns (≥2 mentions), or long-term decisions (subscription \
514    ///    cadences, investment policies). If unsure, prefer empty []."
515    /// )
516    /// ```
517    pub fn with_extra_instructions(mut self, instructions: impl Into<String>) -> Self {
518        self.extra_instructions = Some(instructions.into());
519        self
520    }
521
522    /// Await all background synthesis tasks that have been kicked off so
523    /// far. Call this before your process exits if you want to guarantee
524    /// the last session's memory is on disk — otherwise the tokio runtime
525    /// may be dropped while the spawn is mid-flight.
526    pub async fn flush_pending(&self) {
527        let handles: Vec<tokio::task::JoinHandle<()>> = match self.pending.lock() {
528            Ok(mut g) => std::mem::take(&mut *g),
529            Err(_) => return,
530        };
531        for h in handles {
532            let _ = h.await;
533        }
534    }
535
536    pub fn with_source(mut self, source: impl Into<String>) -> Self {
537        self.source = source.into();
538        self
539    }
540
541    pub fn with_base_tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
542        self.base_tags = tags.into_iter().map(Into::into).collect();
543        self
544    }
545
546    /// Cap how many facts the synthesizer is allowed to emit. Default 3.
547    pub fn with_max_facts(mut self, n: usize) -> Self {
548        self.max_facts = n.max(1);
549        self
550    }
551}
552
553#[derive(serde::Deserialize)]
554struct SynthFact {
555    #[serde(default)]
556    content: String,
557    #[serde(default)]
558    tags: Vec<String>,
559    /// Optional retention hint emitted by the synth model. `None` = keep
560    /// indefinitely (stable preferences, identity). Finite N = expire after
561    /// N days (one-off project state, session-scoped preferences).
562    #[serde(default)]
563    ttl_days: Option<u32>,
564}
565
566/// Best-effort JSON-array extractor: tolerates markdown code fences and
567/// leading/trailing prose around the JSON body.
568fn extract_facts(raw: &str) -> Option<Vec<SynthFact>> {
569    // Strip ```json ... ``` or ``` ... ``` fences if present.
570    let stripped = raw.trim();
571    let body = if let Some(rest) = stripped.strip_prefix("```json") {
572        rest.trim_start_matches('\n')
573            .rsplit_once("```")
574            .map(|(b, _)| b)
575            .unwrap_or(rest)
576    } else if let Some(rest) = stripped.strip_prefix("```") {
577        rest.trim_start_matches('\n')
578            .rsplit_once("```")
579            .map(|(b, _)| b)
580            .unwrap_or(rest)
581    } else {
582        stripped
583    };
584    // Find first '[' and last ']' — JSON array.
585    let start = body.find('[')?;
586    let end = body.rfind(']')?;
587    if end <= start {
588        return None;
589    }
590    serde_json::from_str::<Vec<SynthFact>>(&body[start..=end]).ok()
591}
592
593impl Hook for MemorySynthesizer {
594    fn name(&self) -> &str {
595        "memory-synthesizer"
596    }
597    fn matches(&self, ev: &Event<'_>) -> bool {
598        matches!(ev, Event::PostModel { .. } | Event::TaskCompleted)
599    }
600    fn fire(&self, ev: &Event<'_>, _w: &mut World) -> HookOutcome {
601        match ev {
602            Event::PostModel { out } => {
603                if let Some(text) = &out.text
604                    && !text.trim().is_empty()
605                    && let Ok(mut buf) = self.transcripts.lock()
606                {
607                    buf.push(text.clone());
608                }
609            }
610            Event::TaskCompleted => {
611                let transcript = match self.transcripts.lock() {
612                    Ok(mut g) => std::mem::take(&mut *g).join("\n\n---\n\n"),
613                    Err(_) => return HookOutcome::Allow,
614                };
615                if transcript.trim().is_empty() {
616                    return HookOutcome::Allow;
617                }
618                let mem = self.memory.clone();
619                let model = self.synth_model.clone();
620                let source = self.source.clone();
621                let base_tags = self.base_tags.clone();
622                let max_facts = self.max_facts;
623                let extra = self.extra_instructions.clone();
624                let handle = tokio::spawn(async move {
625                    distil_and_write(mem, model, source, base_tags, max_facts, extra, transcript)
626                        .await;
627                });
628                if let Ok(mut g) = self.pending.lock() {
629                    g.push(handle);
630                }
631            }
632            _ => {}
633        }
634        HookOutcome::Allow
635    }
636}
637
638async fn distil_and_write(
639    memory: Arc<dyn Memory>,
640    model: Arc<dyn Model>,
641    source: String,
642    base_tags: Vec<String>,
643    max_facts: usize,
644    extra_instructions: Option<String>,
645    transcript: String,
646) {
647    let extra_block = match extra_instructions {
648        Some(s) if !s.trim().is_empty() => format!("\n\n[domain context]\n{s}\n"),
649        _ => String::new(),
650    };
651    let prompt = format!(
652        "Below is the assistant's turns from a completed agent session. \
653         Extract 1 to {max_facts} DURABLE FACTS worth remembering for future sessions \
654         (user preferences, decisions made, key findings, learned constraints — NOT \
655         transient details like timestamps or one-off answers).{extra_block} \
656         \n\nReturn ONLY a JSON array (no prose, no markdown fences) where each item is \
657         {{\"content\": \"<one durable fact, 1-2 sentences>\", \"tags\": [\"<keyword>\", ...], \
658         \"ttl_days\": <integer or null>}}. \
659         `ttl_days` controls how long the fact stays in memory: \
660         `null` = permanent (use for stable preferences, identity, long-term decisions); \
661         `7` = one week (current task / sprint scope); \
662         `30`-`180` = project-scope context; \
663         `1` = ephemeral (rarely useful — prefer omitting facts that are this fleeting). \
664         Use 2-5 lowercase keyword tags per fact for retrieval. \
665         If the session produced nothing durable, return [].\
666         \n\n--- SESSION TRANSCRIPT ---\n{transcript}\n--- END TRANSCRIPT ---"
667    );
668
669    let mut ctx = Context::new(Task {
670        description: prompt.clone(),
671        source: None,
672        deadline: None,
673    });
674    ctx.history.push(Turn {
675        role: TurnRole::User,
676        blocks: vec![Block::Text(prompt)],
677    });
678
679    let out = match model.complete(&ctx).await {
680        Ok(o) => o,
681        Err(e) => {
682            tracing::warn!(error = %e, "memory synth model call failed; nothing persisted");
683            return;
684        }
685    };
686    let raw = out.text.unwrap_or_default();
687
688    let parsed = extract_facts(&raw);
689    if let Some(facts) = parsed.as_ref() {
690        for f in facts.iter().take(max_facts) {
691            let content = f.content.trim().to_string();
692            if content.is_empty() {
693                continue;
694            }
695            let mut tags = base_tags.clone();
696            tags.extend(f.tags.clone());
697            let mut entry = MemoryEntry::new(content)
698                .with_source(source.clone())
699                .with_tags(tags);
700            if let Some(days) = f.ttl_days
701                && days > 0
702            {
703                entry = entry.with_ttl_days(days);
704            }
705            if let Err(e) = memory.write(entry).await {
706                tracing::warn!(error = %e, "memory synth write failed");
707            }
708        }
709    } else if !raw.trim().is_empty() {
710        // Parse genuinely failed (not "model returned []"). Persist the raw
711        // payload tagged "synth-raw" so the operator can grep it later.
712        let mut tags = base_tags;
713        tags.push("synth-raw".into());
714        let entry = MemoryEntry::new(raw.trim().to_string())
715            .with_source(source)
716            .with_tags(tags);
717        if let Err(e) = memory.write(entry).await {
718            tracing::warn!(error = %e, "memory synth-raw write failed");
719        }
720    }
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726    use harness_core::{ModelOutput, StopReason};
727    use std::sync::atomic::{AtomicU64, Ordering};
728
729    /// Test-only in-memory backend so we don't touch the filesystem.
730    #[derive(Default)]
731    struct VecMemory {
732        store: Mutex<Vec<MemoryEntry>>,
733    }
734    #[async_trait]
735    impl Memory for VecMemory {
736        async fn recall(
737            &self,
738            query: &str,
739            k: usize,
740        ) -> Result<Vec<MemoryEntry>, harness_core::MemoryError> {
741            let g = self.store.lock().unwrap();
742            let q = query.to_lowercase();
743            let mut hits: Vec<MemoryEntry> = g
744                .iter()
745                .filter(|e| {
746                    let hay = e.content.to_lowercase();
747                    q.split_whitespace().any(|t| hay.contains(t))
748                })
749                .cloned()
750                .collect();
751            hits.truncate(k);
752            Ok(hits)
753        }
754        async fn write(&self, entry: MemoryEntry) -> Result<(), harness_core::MemoryError> {
755            self.store.lock().unwrap().push(entry);
756            Ok(())
757        }
758    }
759
760    static SEQ: AtomicU64 = AtomicU64::new(0);
761
762    #[tokio::test]
763    async fn writer_persists_last_text_on_task_completed() {
764        let mem = Arc::new(VecMemory::default());
765        let w = MemoryWriter::new(mem.clone()).with_source("test-app");
766        let mut world = harness_context::default_world(std::env::temp_dir().join(format!(
767            "harness-mw-{}-{}",
768            std::process::id(),
769            SEQ.fetch_add(1, Ordering::SeqCst)
770        )));
771
772        let out = ModelOutput {
773            text: Some("final answer X".into()),
774            ..Default::default()
775        };
776        let _ = w.fire(&Event::PostModel { out: &out }, &mut world);
777        let _ = w.fire(&Event::TaskCompleted, &mut world);
778
779        // The hook spawns; give the runtime a tick to drain.
780        tokio::task::yield_now().await;
781        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
782
783        let stored = mem.store.lock().unwrap().clone();
784        assert_eq!(stored.len(), 1);
785        assert_eq!(stored[0].content, "final answer X");
786        assert_eq!(stored[0].source.as_deref(), Some("test-app"));
787    }
788
789    #[tokio::test]
790    async fn decision_writer_captures_marker_lines_only() {
791        let mem = Arc::new(VecMemory::default());
792        let w = DecisionWriter::new(mem.clone()).with_source("ops");
793        let mut world = harness_context::default_world(std::env::temp_dir().join(format!(
794            "harness-dw-{}-{}",
795            std::process::id(),
796            SEQ.fetch_add(1, Ordering::SeqCst)
797        )));
798
799        let turn1 = ModelOutput {
800            text: Some("Investigating the queue.\nDECISION: raise db_pool_size to 25.".into()),
801            ..Default::default()
802        };
803        let turn2 = ModelOutput {
804            text: Some("DECISION: raise db_pool_size to 25.\nAll done, closing out.".into()),
805            ..Default::default()
806        };
807        let _ = w.fire(&Event::PostModel { out: &turn1 }, &mut world);
808        let _ = w.fire(&Event::PostModel { out: &turn2 }, &mut world);
809        let _ = w.fire(&Event::TaskCompleted, &mut world);
810
811        tokio::task::yield_now().await;
812        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
813
814        let stored = mem.store.lock().unwrap().clone();
815        assert_eq!(stored.len(), 1);
816        // Dedup: the repeated line is stored once; prose is not stored.
817        assert_eq!(stored[0].content, "DECISION: raise db_pool_size to 25.");
818        assert!(stored[0].tags.contains(&"decision".to_string()));
819    }
820
821    #[tokio::test]
822    async fn decision_writer_writes_nothing_without_markers() {
823        let mem = Arc::new(VecMemory::default());
824        let w = DecisionWriter::new(mem.clone());
825        let mut world = harness_context::default_world(std::env::temp_dir().join(format!(
826            "harness-dw-{}-{}",
827            std::process::id(),
828            SEQ.fetch_add(1, Ordering::SeqCst)
829        )));
830
831        let out = ModelOutput {
832            text: Some("Here are three fun facts about otters.".into()),
833            ..Default::default()
834        };
835        let _ = w.fire(&Event::PostModel { out: &out }, &mut world);
836        let _ = w.fire(&Event::TaskCompleted, &mut world);
837
838        tokio::task::yield_now().await;
839        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
840
841        assert!(mem.store.lock().unwrap().is_empty());
842    }
843
844    #[tokio::test]
845    async fn writer_skips_when_no_task_completed_fires() {
846        let mem = Arc::new(VecMemory::default());
847        let w = MemoryWriter::new(mem.clone());
848        let mut world = harness_context::default_world(std::env::temp_dir().join(format!(
849            "harness-mw-{}-{}",
850            std::process::id(),
851            SEQ.fetch_add(1, Ordering::SeqCst)
852        )));
853
854        let out = ModelOutput {
855            text: Some("partial".into()),
856            stop_reason: StopReason::ToolUse,
857            ..Default::default()
858        };
859        let _ = w.fire(&Event::PostModel { out: &out }, &mut world);
860        // No TaskCompleted ⇒ nothing should be written.
861        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
862        assert!(mem.store.lock().unwrap().is_empty());
863    }
864
865    #[tokio::test]
866    async fn synthesizer_parses_clean_json_and_writes_atomic_facts() {
867        use harness_models::{MockModel, MockResponse};
868
869        let mem = Arc::new(VecMemory::default());
870        let synth: Arc<dyn Model> = Arc::new(MockModel::new().script(MockResponse::text(
871            r#"[
872              {"content": "user prefers dark roast coffee, no sugar", "tags": ["coffee", "preferences"]},
873              {"content": "user lives in Beijing (Asia/Shanghai tz)", "tags": ["location", "timezone"]}
874            ]"#,
875        )));
876        let s = MemorySynthesizer::new(mem.clone(), synth).with_source("test");
877        let mut world = harness_context::default_world(std::env::temp_dir().join(format!(
878            "harness-ms-{}-{}",
879            std::process::id(),
880            SEQ.fetch_add(1, Ordering::SeqCst)
881        )));
882
883        let out_a = ModelOutput {
884            text: Some("I'll remember your coffee preference.".into()),
885            stop_reason: StopReason::ToolUse,
886            ..Default::default()
887        };
888        let out_b = ModelOutput {
889            text: Some("Setting Beijing as your timezone.".into()),
890            ..Default::default()
891        };
892        let _ = s.fire(&Event::PostModel { out: &out_a }, &mut world);
893        let _ = s.fire(&Event::PostModel { out: &out_b }, &mut world);
894        let _ = s.fire(&Event::TaskCompleted, &mut world);
895
896        for _ in 0..50 {
897            if mem.store.lock().unwrap().len() >= 2 {
898                break;
899            }
900            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
901        }
902        let stored = mem.store.lock().unwrap().clone();
903        assert_eq!(stored.len(), 2, "expected 2 atomic facts, got {stored:#?}");
904        assert!(stored.iter().any(|e| e.content.contains("dark roast")));
905        assert!(stored.iter().any(|e| e.content.contains("Beijing")));
906        let coffee = stored
907            .iter()
908            .find(|e| e.content.contains("dark roast"))
909            .unwrap();
910        assert!(coffee.tags.contains(&"coffee".to_string()));
911        assert_eq!(coffee.source.as_deref(), Some("test"));
912    }
913
914    #[tokio::test]
915    async fn synthesizer_strips_markdown_fences_around_json() {
916        use harness_models::{MockModel, MockResponse};
917
918        let mem = Arc::new(VecMemory::default());
919        let synth: Arc<dyn Model> = Arc::new(MockModel::new().script(MockResponse::text(
920            "Here are the facts:\n```json\n[{\"content\":\"fact one\",\"tags\":[\"x\"]}]\n```\n",
921        )));
922        let s = MemorySynthesizer::new(mem.clone(), synth);
923        let mut world = harness_context::default_world(std::env::temp_dir());
924
925        let out = ModelOutput {
926            text: Some("some chat".into()),
927            ..Default::default()
928        };
929        let _ = s.fire(&Event::PostModel { out: &out }, &mut world);
930        let _ = s.fire(&Event::TaskCompleted, &mut world);
931
932        for _ in 0..50 {
933            if !mem.store.lock().unwrap().is_empty() {
934                break;
935            }
936            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
937        }
938        let stored = mem.store.lock().unwrap().clone();
939        assert_eq!(stored.len(), 1);
940        assert_eq!(stored[0].content, "fact one");
941    }
942
943    #[tokio::test]
944    async fn synthesizer_empty_array_persists_nothing() {
945        // Regression: model correctly returns "[]" meaning "no durable
946        // facts to extract". This must NOT fall through to the synth-raw
947        // fallback (which would store the literal "[]" as a memory row).
948        use harness_models::{MockModel, MockResponse};
949
950        let mem = Arc::new(VecMemory::default());
951        let synth: Arc<dyn Model> = Arc::new(MockModel::new().script(MockResponse::text("[]")));
952        let s = MemorySynthesizer::new(mem.clone(), synth);
953        let mut world = harness_context::default_world(std::env::temp_dir());
954
955        let out = ModelOutput {
956            text: Some("fluff".into()),
957            ..Default::default()
958        };
959        let _ = s.fire(&Event::PostModel { out: &out }, &mut world);
960        let _ = s.fire(&Event::TaskCompleted, &mut world);
961
962        // Give the spawned synth task time to run.
963        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
964        let stored = mem.store.lock().unwrap().clone();
965        assert!(stored.is_empty(), "expected nothing stored, got {stored:?}");
966    }
967
968    #[tokio::test]
969    async fn synthesizer_falls_back_to_synth_raw_when_json_unparseable() {
970        use harness_models::{MockModel, MockResponse};
971
972        let mem = Arc::new(VecMemory::default());
973        let synth: Arc<dyn Model> = Arc::new(MockModel::new().script(MockResponse::text(
974            "The user said they like coffee. I think that's important.",
975        )));
976        let s = MemorySynthesizer::new(mem.clone(), synth);
977        let mut world = harness_context::default_world(std::env::temp_dir());
978
979        let out = ModelOutput {
980            text: Some("session chat".into()),
981            ..Default::default()
982        };
983        let _ = s.fire(&Event::PostModel { out: &out }, &mut world);
984        let _ = s.fire(&Event::TaskCompleted, &mut world);
985
986        for _ in 0..50 {
987            if !mem.store.lock().unwrap().is_empty() {
988                break;
989            }
990            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
991        }
992        let stored = mem.store.lock().unwrap().clone();
993        assert_eq!(stored.len(), 1);
994        assert!(stored[0].tags.contains(&"synth-raw".to_string()));
995        assert!(stored[0].content.contains("coffee"));
996    }
997}