Skip to main content

newt_core/
memory.rs

1//! Pluggable memory architecture for newt's chat REPL.
2//!
3//! Modelled on hermes-agent's `MemoryProvider` ABC + `MemoryManager`
4//! orchestrator, adapted for Rust and newt's local-first constraints.
5//!
6//! ## Design principles (from hermes-agent)
7//!
8//! - **Fault isolation** — one provider's panic/error never blocks others.
9//! - **Frozen system prompt** — `system_prompt_block()` is captured once at
10//!   session start and never rebuilt mid-session (preserves the model's prefix
11//!   cache / KV cache across turns).
12//! - **Non-blocking sync** — `sync_turn` should queue writes; the chat loop
13//!   never waits on memory backends.
14//! - **Single integration point** — `MemoryManager` is the only thing the
15//!   chat loop interacts with; it fans out to all registered providers.
16//!
17//! ## Built-in providers (shipped in order)
18//!
19//! | Provider | Issue |
20//! |---|---|
21//! | `RollingWindow` | #105 — keep last N turns (ships here) |
22//! | `TokenBudget`   | #106 — prune by context-window % |
23//! | `Summarizing`   | #107 — LLM summarization of old turns |
24//! | `NoteStore`     | #108 — persistent NOTES.md the agent writes to |
25
26use async_trait::async_trait;
27
28use crate::agentic::compress::is_compaction_text;
29use crate::metrics::TurnMetrics;
30
31// ---------------------------------------------------------------------------
32// Message type (mirrors the inference layer without creating a dep cycle)
33// ---------------------------------------------------------------------------
34
35/// A single message in a conversation.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct MemMessage {
38    pub role: Role,
39    pub content: String,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum Role {
44    System,
45    User,
46    Assistant,
47    Tool,
48}
49
50impl Role {
51    pub fn as_str(&self) -> &'static str {
52        match self {
53            Self::System => "system",
54            Self::User => "user",
55            Self::Assistant => "assistant",
56            Self::Tool => "tool",
57        }
58    }
59}
60
61impl MemMessage {
62    pub fn system(content: impl Into<String>) -> Self {
63        Self {
64            role: Role::System,
65            content: content.into(),
66        }
67    }
68    pub fn user(content: impl Into<String>) -> Self {
69        Self {
70            role: Role::User,
71            content: content.into(),
72        }
73    }
74    pub fn assistant(content: impl Into<String>) -> Self {
75        Self {
76            role: Role::Assistant,
77            content: content.into(),
78        }
79    }
80}
81
82// ---------------------------------------------------------------------------
83// Session context passed to providers at initialization
84// ---------------------------------------------------------------------------
85
86/// Context provided to each `MemoryProvider::initialize` call.
87#[derive(Debug, Clone)]
88pub struct SessionContext {
89    /// Absolute path to the current workspace.
90    pub workspace: String,
91    /// Identifier for this session (timestamp-based or UUID).
92    pub session_id: String,
93}
94
95// ---------------------------------------------------------------------------
96// MemoryProvider trait
97// ---------------------------------------------------------------------------
98
99/// Contract for all memory backends.
100///
101/// Implement this trait to create a custom memory backend — rolling window,
102/// vector store, summarisation engine, or anything else.
103#[async_trait]
104pub trait MemoryProvider: Send + Sync {
105    /// Short stable identifier, e.g. `"rolling_window"`.
106    fn name(&self) -> &str;
107
108    /// One-time setup at session start (load files, warm caches, etc.).
109    /// Default: no-op.
110    async fn initialize(&mut self, _ctx: &SessionContext) -> anyhow::Result<()> {
111        Ok(())
112    }
113
114    /// Return a static block for the system prompt.
115    ///
116    /// Called once at session start and **frozen** — mid-session writes to
117    /// memory must NOT change this return value (keeps the KV/prefix cache
118    /// valid across all turns).  Return `None` to contribute nothing.
119    fn system_prompt_block(&self) -> Option<String> {
120        None
121    }
122
123    /// Recall relevant context to prepend to the user turn **before** the API
124    /// call.  Should be fast — use cached results, don't block.
125    /// Return empty string to contribute nothing.
126    async fn prefetch(&self, _query: &str) -> anyhow::Result<String> {
127        Ok(String::new())
128    }
129
130    /// Build the full message list (including history) to send to the model.
131    ///
132    /// **This is where history management lives.**  Implementations decide:
133    /// - How many past turns to include
134    /// - Whether to summarise old turns
135    /// - Where to inject `prefetch` context
136    ///
137    /// `system_prompt` is the fully-assembled system message content.
138    /// `new_task` is the current user turn.
139    fn build_messages(&self, system_prompt: &str, new_task: &str) -> Vec<MemMessage>;
140
141    /// Persist a completed turn.
142    ///
143    /// Implementations **should** queue writes and return immediately so the
144    /// chat loop never blocks on memory I/O.
145    async fn sync_turn(&mut self, user: &str, assistant: &str, metrics: &TurnMetrics);
146
147    /// Clear conversation-local history while preserving provider configuration
148    /// and system-prompt state. Used when the TUI starts a fresh conversation
149    /// inside the same running process.
150    fn reset(&mut self) {}
151
152    /// Replace conversation-local history with durable restored turns.
153    ///
154    /// The default is a no-op for providers without conversation-local state.
155    /// Providers that include prior user/assistant turns in `build_messages`
156    /// must override this, otherwise `/conversation restore` will silently
157    /// leave their in-memory history unchanged.
158    fn restore_turns(&mut self, _turns: &[crate::ConversationTurn]) {}
159
160    /// Take (and clear) the compaction record minted since the last call —
161    /// the full marked summary message a compressing provider inserted into
162    /// its working set (Step 18.5, #247). The caller persists it as a turn
163    /// record (`user` = the marked message, `assistant` = empty, token
164    /// columns NULL — it is not a backend-measured turn) so a later restore
165    /// can rehydrate the same working-set shape via [`Self::restore_turns`].
166    /// Default: `None` — providers that never compress mint nothing.
167    fn take_compaction_record(&mut self) -> Option<String> {
168        None
169    }
170
171    /// Called **before** old messages are discarded (e.g. during compression).
172    /// Extract anything worth keeping from `messages`; return it as a string
173    /// to include in the compression summary. Return empty string for nothing.
174    async fn on_pre_compress(&self, _messages: &[MemMessage]) -> String {
175        String::new()
176    }
177
178    /// Called once when the session ends. Use for final extraction / cleanup.
179    async fn on_session_end(&mut self, _messages: &[MemMessage]) {}
180
181    /// Report current usage for display (e.g. `/memory` command).
182    /// Returns `(label, current, max)` — e.g. `("turns", 12, 20)`.
183    fn usage(&self) -> Option<(String, usize, usize)> {
184        None
185    }
186
187    /// Add a persistent note.
188    ///
189    /// Providers that persist notes (e.g. `NoteStore`) override this.
190    /// Default: return [`NotesUnsupported`], which `MemoryManager::add_note`
191    /// recognises and skips while looking for a note-capable provider.
192    fn add_note(&mut self, _fact: &str) -> anyhow::Result<()> {
193        Err(NotesUnsupported.into())
194    }
195
196    /// Replace the single persisted note containing `old_substring` with
197    /// `new_text` (Step 19.3 — the `save_note` tool's replace action).
198    /// Default: [`NotesUnsupported`], same routing contract as `add_note`.
199    fn replace_note(&mut self, _old_substring: &str, _new_text: &str) -> anyhow::Result<()> {
200        Err(NotesUnsupported.into())
201    }
202
203    /// Remove the single persisted note containing `substring` (Step 19.3 —
204    /// the `save_note` tool's remove action).
205    /// Default: [`NotesUnsupported`], same routing contract as `add_note`.
206    fn remove_note(&mut self, _substring: &str) -> anyhow::Result<()> {
207        Err(NotesUnsupported.into())
208    }
209}
210
211/// Error returned by the default [`MemoryProvider::add_note`] for providers
212/// that don't persist notes.
213///
214/// `MemoryManager::add_note` skips providers returning this and keeps
215/// looking; any *other* error (e.g. the over-budget curator error from
216/// `NoteStore`) is surfaced to the caller.
217#[derive(Debug, thiserror::Error)]
218#[error("this memory provider does not support persistent notes")]
219pub struct NotesUnsupported;
220
221// ---------------------------------------------------------------------------
222// MemoryManager — single integration point
223// ---------------------------------------------------------------------------
224
225/// Orchestrates all registered `MemoryProvider`s.
226///
227/// The chat loop interacts exclusively with `MemoryManager`; individual
228/// providers are invisible to the rest of the codebase.  Provider errors are
229/// caught and logged so one failure never blocks others.
230pub struct MemoryManager {
231    providers: Vec<Box<dyn MemoryProvider>>,
232}
233
234impl MemoryManager {
235    pub fn new() -> Self {
236        Self {
237            providers: Vec::new(),
238        }
239    }
240
241    /// Register a provider. Providers are consulted in registration order.
242    pub fn add_provider(&mut self, p: impl MemoryProvider + 'static) {
243        self.providers.push(Box::new(p));
244    }
245
246    /// Initialize all providers. Called once at session start.
247    pub async fn initialize_all(&mut self, ctx: &SessionContext) {
248        for p in &mut self.providers {
249            if let Err(e) = p.initialize(ctx).await {
250                tracing::warn!(provider = p.name(), error = %e, "memory provider init failed");
251            }
252        }
253    }
254
255    /// Assemble the frozen system-prompt contribution from all providers.
256    /// Call once at session start and cache the result.
257    pub fn build_system_prompt_additions(&self) -> String {
258        self.providers
259            .iter()
260            .filter_map(|p| p.system_prompt_block())
261            .collect::<Vec<_>>()
262            .join("\n\n")
263    }
264
265    /// Collect recalled context from all providers before a turn.
266    pub async fn prefetch_all(&self, query: &str) -> String {
267        let mut parts = Vec::new();
268        for p in &self.providers {
269            match p.prefetch(query).await {
270                Ok(s) if !s.is_empty() => parts.push(s),
271                Err(e) => {
272                    tracing::warn!(provider = p.name(), error = %e, "prefetch failed");
273                }
274                _ => {}
275            }
276        }
277        parts.join("\n\n")
278    }
279
280    /// Build the message list for the next API call.
281    ///
282    /// Uses the **first** provider that implements `build_messages` to a
283    /// non-empty result.  Falls back to a minimal [system, user] pair.
284    pub fn build_messages(&self, system_prompt: &str, new_task: &str) -> Vec<MemMessage> {
285        for p in &self.providers {
286            let msgs = p.build_messages(system_prompt, new_task);
287            if !msgs.is_empty() {
288                return msgs;
289            }
290        }
291        // Minimal fallback (should not happen with at least one provider).
292        vec![
293            MemMessage::system(system_prompt),
294            MemMessage::user(new_task),
295        ]
296    }
297
298    /// Persist a completed turn to all providers.
299    pub async fn sync_all(&mut self, user: &str, assistant: &str, metrics: &TurnMetrics) {
300        for p in &mut self.providers {
301            p.sync_turn(user, assistant, metrics).await;
302        }
303    }
304
305    /// Clear conversation-local history from every provider.
306    pub fn reset_all(&mut self) {
307        for p in &mut self.providers {
308            p.reset();
309        }
310    }
311
312    /// Replace conversation-local history in every provider from durable turns.
313    pub fn restore_turns(&mut self, turns: &[crate::ConversationTurn]) {
314        for p in &mut self.providers {
315            p.restore_turns(turns);
316        }
317    }
318
319    /// Drain the first pending compaction record from any provider — the
320    /// TUI's save site calls this after `sync_all` and persists the result
321    /// as a turn record (Step 18.5, #247).
322    pub fn take_compaction_record(&mut self) -> Option<String> {
323        self.providers
324            .iter_mut()
325            .find_map(|p| p.take_compaction_record())
326    }
327
328    /// Notify all providers before old messages are dropped.
329    pub async fn on_pre_compress(&self, messages: &[MemMessage]) -> String {
330        let mut parts = Vec::new();
331        for p in &self.providers {
332            let s = p.on_pre_compress(messages).await;
333            if !s.is_empty() {
334                parts.push(s);
335            }
336        }
337        parts.join("\n\n")
338    }
339
340    /// Notify all providers at session end.
341    pub async fn on_session_end(&mut self, messages: &[MemMessage]) {
342        for p in &mut self.providers {
343            p.on_session_end(messages).await;
344        }
345    }
346
347    /// Report usage from all providers.
348    pub fn usage(&self) -> Vec<(String, usize, usize)> {
349        self.providers.iter().filter_map(|p| p.usage()).collect()
350    }
351
352    /// Add a fact to the first provider that accepts it (first `Ok` wins).
353    ///
354    /// No name-based special-casing: every provider is offered the note via
355    /// the trait's `add_note`. Providers whose default returns
356    /// [`NotesUnsupported`] are skipped; the first *real* rejection (e.g.
357    /// `NoteStore`'s over-budget curator error) is surfaced if no provider
358    /// accepts the note.
359    pub fn add_note(&mut self, fact: &str) -> anyhow::Result<()> {
360        self.route_note_op(|p| p.add_note(fact))
361    }
362
363    /// Replace the single persisted note containing `old_substring` —
364    /// same first-note-capable-provider routing as [`Self::add_note`].
365    pub fn replace_note(&mut self, old_substring: &str, new_text: &str) -> anyhow::Result<()> {
366        self.route_note_op(|p| p.replace_note(old_substring, new_text))
367    }
368
369    /// Remove the single persisted note containing `substring` —
370    /// same first-note-capable-provider routing as [`Self::add_note`].
371    pub fn remove_note(&mut self, substring: &str) -> anyhow::Result<()> {
372        self.route_note_op(|p| p.remove_note(substring))
373    }
374
375    /// Shared routing for note mutations: offer the operation to every
376    /// provider in registration order; first `Ok` wins, [`NotesUnsupported`]
377    /// is skipped, and the first *real* rejection (over-budget curator error,
378    /// scan rejection, ambiguous-substring error) is surfaced if no provider
379    /// accepts.
380    fn route_note_op(
381        &mut self,
382        mut op: impl FnMut(&mut dyn MemoryProvider) -> anyhow::Result<()>,
383    ) -> anyhow::Result<()> {
384        let mut first_err: Option<anyhow::Error> = None;
385        for p in &mut self.providers {
386            match op(p.as_mut()) {
387                Ok(()) => return Ok(()),
388                Err(e) if e.is::<NotesUnsupported>() => continue,
389                Err(e) => {
390                    first_err.get_or_insert(e);
391                }
392            }
393        }
394        match first_err {
395            Some(e) => Err(e),
396            None => anyhow::bail!(
397                "no note-capable memory provider registered — add [memory] provider = \"note_store\" to newt.toml"
398            ),
399        }
400    }
401}
402
403impl Default for MemoryManager {
404    fn default() -> Self {
405        Self::new()
406    }
407}
408
409// ---------------------------------------------------------------------------
410// RollingWindow — built-in provider #1 (closes #105)
411// ---------------------------------------------------------------------------
412
413/// Keep the last `max_turns` conversation turns; discard older ones.
414///
415/// Simple, predictable, zero overhead.  The default provider.
416/// Configure via `[memory] window = 20` in `newt.toml`.
417pub struct RollingWindow {
418    max_turns: usize,
419    /// Each entry is `(user_message, assistant_message)`.
420    history: Vec<(String, String)>,
421}
422
423impl RollingWindow {
424    /// Create a rolling window that retains the last `max_turns` turns.
425    pub fn new(max_turns: usize) -> Self {
426        Self {
427            max_turns: max_turns.max(1),
428            history: Vec::new(),
429        }
430    }
431
432    /// Create from config, falling back to the default window size.
433    pub fn from_config() -> Self {
434        let window = newt_core_memory_window();
435        Self::new(window)
436    }
437
438    pub fn turn_count(&self) -> usize {
439        self.history.len()
440    }
441}
442
443fn newt_core_memory_window() -> usize {
444    crate::Config::resolve()
445        .ok()
446        .and_then(|c| c.memory)
447        .map(|m| m.window)
448        .unwrap_or(20)
449}
450
451#[async_trait]
452impl MemoryProvider for RollingWindow {
453    fn name(&self) -> &str {
454        "rolling_window"
455    }
456
457    fn build_messages(&self, system_prompt: &str, new_task: &str) -> Vec<MemMessage> {
458        let mut msgs = vec![MemMessage::system(system_prompt)];
459
460        // Include the retained history window. A restored compaction record
461        // (Step 18.5) is a user-side summary with no reply — never dispatch
462        // an empty assistant message for it.
463        let start = self.history.len().saturating_sub(self.max_turns);
464        for (user, asst) in &self.history[start..] {
465            msgs.push(MemMessage::user(user));
466            if !asst.is_empty() {
467                msgs.push(MemMessage::assistant(asst));
468            }
469        }
470
471        // Current turn.
472        msgs.push(MemMessage::user(new_task));
473        msgs
474    }
475
476    async fn sync_turn(&mut self, user: &str, assistant: &str, _metrics: &TurnMetrics) {
477        self.history.push((user.to_string(), assistant.to_string()));
478        // Keep only the last max_turns entries in storage too, so memory
479        // doesn't grow unboundedly over very long sessions.
480        if self.history.len() > self.max_turns * 2 {
481            let drain_to = self.history.len() - self.max_turns;
482            self.history.drain(..drain_to);
483        }
484    }
485
486    fn reset(&mut self) {
487        self.history.clear();
488    }
489
490    fn restore_turns(&mut self, turns: &[crate::ConversationTurn]) {
491        self.history = turns
492            .iter()
493            .map(|t| (t.user.clone(), t.assistant.clone()))
494            .collect();
495    }
496
497    fn usage(&self) -> Option<(String, usize, usize)> {
498        Some((
499            "turns".into(),
500            self.history.len().min(self.max_turns),
501            self.max_turns,
502        ))
503    }
504}
505
506// ---------------------------------------------------------------------------
507// TokenBudget — built-in provider #2 (closes #106)
508// ---------------------------------------------------------------------------
509
510/// Static context-token budget used only when neither an explicit
511/// `[memory] context_tokens` override nor empirical capability data exists
512/// (a fresh model with no probe history — Step 18.2, #247).
513///
514/// This is the LAST tier of the budget precedence. Callers that have probe
515/// capability data (the TUI's `model-capabilities.json`) must resolve a
516/// budget from it and inject the value at provider construction
517/// ([`TokenBudget::new`] / [`Summarizing::new`] / `with_budget`) — the old
518/// `from_config()` path that silently fell back to this constant while
519/// ignoring probe data was deleted in Step 18.2.
520pub const DEFAULT_CONTEXT_TOKENS: u32 = 8_192;
521
522/// Per-turn record stored by `TokenBudget`.
523#[derive(Debug, Clone)]
524struct TurnRecord {
525    user: String,
526    assistant: String,
527    /// chars/4 (ceiling) estimate of THIS turn's content only — what dropping
528    /// the turn removes from future prompts. NOT the backend usage reading:
529    /// `input_tokens` already contains every prior turn, so storing and
530    /// summing it per turn double-counts history (Step 18.1).
531    est_tokens: u32,
532}
533
534/// Ceiling estimate of one turn's content contribution under the configured
535/// [`TokenEstimation`](crate::tokens::TokenEstimation) heuristic.
536fn turn_content_estimate(user: &str, assistant: &str, est: crate::tokens::TokenEstimation) -> u32 {
537    (est.tokens_for_chars(user.len()) + est.tokens_for_chars(assistant.len())) as u32
538}
539
540/// Column-first token anchor for restored history (Step 18.5, #247).
541///
542/// Finds the LAST turn carrying a backend-reported `tokens_in` (the 17.6
543/// column) and reconstructs the anchored state the live session had right
544/// after that turn's `sync_turn`: `last_prompt_tokens` = the measured prompt
545/// (which already contained the system prompt and every prior turn), and the
546/// delta = the chars/4 estimate of that turn's reply plus every LATER
547/// (unmeasured) turn's content — the same arithmetic the live path applies
548/// per turn. Rows with NULL columns (pre-17.6 rows, silent backends)
549/// contribute estimates to the delta but never become the anchor: a fallback
550/// estimate is never presented as a measurement (18.1 semantics). No
551/// measured turn at all → `(None, 0)`, and the provider falls back to
552/// summing per-turn content estimates exactly as before.
553fn restored_token_anchor(
554    turns: &[crate::ConversationTurn],
555    est: crate::tokens::TokenEstimation,
556) -> (Option<u32>, i64) {
557    let Some(pos) = turns.iter().rposition(|t| t.tokens_in.is_some()) else {
558        return (None, 0);
559    };
560    let mut delta = est.tokens_for_chars(turns[pos].assistant.len()) as i64;
561    for t in &turns[pos + 1..] {
562        delta += i64::from(turn_content_estimate(&t.user, &t.assistant, est));
563    }
564    (turns[pos].tokens_in, delta)
565}
566
567/// Keep turns up to `threshold_pct` of the model's context window.
568///
569/// Budget math (fixed in Step 18.1): the fullness figure anchors on the
570/// backend's last-reported prompt size — which already includes the system
571/// prompt and ALL prior turns — plus a chars/4 estimate of content added or
572/// removed since that report. The previous implementation summed
573/// `input + output` per turn across history, double-counting every prior turn
574/// in every later reading (the B3 baseline measured 5.4× inflation by turn
575/// 20), which forced spurious pruning. Without any backend report the figure
576/// falls back to summing per-turn content estimates (each turn counted once).
577///
578/// Configure via `[memory] provider = "token_budget"`. The budget is a
579/// construction-time value injected by the caller (Step 18.2, #247) with
580/// this precedence:
581///
582/// 1. explicit `[memory] context_tokens` (a deliberate user override),
583/// 2. capability-derived (`max_ok_input` else `safe_context` from the
584///    caller's probe cache — newt-core deliberately has no dependency on
585///    the probe types, mirroring the `with_summarizer` injection),
586/// 3. [`DEFAULT_CONTEXT_TOKENS`] when neither exists (fresh model).
587///
588/// The value is fixed for the provider's lifetime: budgets refresh per
589/// session; mid-session capability ratchets are tracked live by the
590/// agentic loop's own pre-send guard, not by this provider.
591pub struct TokenBudget {
592    /// Maximum context tokens (model's `num_ctx`; can be overridden).
593    max_tokens: u32,
594    /// Prune when used tokens exceed this fraction of `max_tokens`.
595    threshold_pct: f32,
596    history: Vec<TurnRecord>,
597    pruned_count: usize,
598    /// Backend-reported prompt size of the most recent turn (truth anchor).
599    last_prompt_tokens: Option<u32>,
600    /// Estimated tokens added (+) / removed (−) relative to that anchor.
601    delta_since_prompt: i64,
602    /// `[context.estimation]` token-estimation heuristic (config-set via
603    /// [`TokenBudget::with_estimation`]); drives every chars→token estimate here.
604    est: crate::tokens::TokenEstimation,
605}
606
607impl TokenBudget {
608    pub fn new(max_tokens: u32, threshold_pct: f32) -> Self {
609        Self {
610            max_tokens: max_tokens.max(512),
611            threshold_pct: threshold_pct.clamp(0.1, 0.99),
612            history: Vec::new(),
613            pruned_count: 0,
614            last_prompt_tokens: None,
615            delta_since_prompt: 0,
616            est: crate::tokens::TokenEstimation::default(),
617        }
618    }
619
620    /// Builder: set the token-estimation heuristic from `[context.estimation]`.
621    #[must_use]
622    pub fn with_estimation(mut self, est: crate::tokens::TokenEstimation) -> Self {
623        self.est = est;
624        self
625    }
626
627    /// Inject a resolved token budget (builder form of the `max_tokens`
628    /// constructor argument, mirroring `Summarizing::with_summarizer`).
629    /// Same ≥512 clamp as [`TokenBudget::new`].
630    ///
631    /// Step 18.2 (#247): this replaces the deleted `from_config()` path,
632    /// whose silent 8,192 fallback ignored empirical capability data. The
633    /// caller resolves the budget (explicit config override → capability
634    /// cache → [`DEFAULT_CONTEXT_TOKENS`]) and injects the value here.
635    pub fn with_budget(mut self, tokens: u32) -> Self {
636        self.max_tokens = tokens.max(512);
637        self
638    }
639
640    fn budget_tokens(&self) -> u32 {
641        (self.max_tokens as f32 * self.threshold_pct) as u32
642    }
643
644    /// Current context fullness: last backend-reported prompt size plus the
645    /// estimated delta since, or the per-turn content-estimate sum when no
646    /// report exists (Step 18.1).
647    fn used_tokens(&self) -> u32 {
648        match self.last_prompt_tokens {
649            Some(p) => (i64::from(p) + self.delta_since_prompt).max(0) as u32,
650            None => self.history.iter().map(|r| r.est_tokens).sum(),
651        }
652    }
653
654    /// Prune oldest turns until we're within budget. Returns how many were dropped.
655    fn prune_to_budget(&mut self) -> usize {
656        let budget = self.budget_tokens();
657        let mut dropped = 0;
658        while self.used_tokens() > budget && !self.history.is_empty() {
659            let removed = self.history.remove(0);
660            // Dropping a turn shrinks the NEXT prompt by its content estimate.
661            self.delta_since_prompt -= i64::from(removed.est_tokens);
662            dropped += 1;
663        }
664        dropped
665    }
666}
667
668#[async_trait]
669impl MemoryProvider for TokenBudget {
670    fn name(&self) -> &str {
671        "token_budget"
672    }
673
674    fn build_messages(&self, system_prompt: &str, new_task: &str) -> Vec<MemMessage> {
675        let mut msgs = vec![MemMessage::system(system_prompt)];
676        for r in &self.history {
677            msgs.push(MemMessage::user(&r.user));
678            // A restored compaction record (Step 18.5) has no reply — never
679            // dispatch an empty assistant message for it.
680            if !r.assistant.is_empty() {
681                msgs.push(MemMessage::assistant(&r.assistant));
682            }
683        }
684        msgs.push(MemMessage::user(new_task));
685        msgs
686    }
687
688    async fn sync_turn(&mut self, user: &str, assistant: &str, metrics: &TurnMetrics) {
689        let est = turn_content_estimate(user, assistant, self.est);
690        self.history.push(TurnRecord {
691            user: user.to_string(),
692            assistant: assistant.to_string(),
693            est_tokens: est,
694        });
695        match metrics.usage {
696            Some(u) => {
697                // `input_tokens` is the largest single prompt the backend
698                // evaluated this turn (Step 18.1) — it already contains the
699                // system prompt, all prior turns, and this user message. The
700                // only content not yet inside any prompt is the new reply.
701                self.last_prompt_tokens = Some(u.input_tokens);
702                self.delta_since_prompt = self.est.tokens_for_chars(assistant.len()) as i64;
703            }
704            // No backend report this turn: the whole turn is unaccounted
705            // relative to the (possibly absent) anchor.
706            None => self.delta_since_prompt += i64::from(est),
707        }
708        let dropped = self.prune_to_budget();
709        self.pruned_count += dropped;
710        if dropped > 0 {
711            tracing::info!(
712                dropped,
713                budget = self.budget_tokens(),
714                used = self.used_tokens(),
715                "TokenBudget pruned old turns"
716            );
717        }
718    }
719
720    fn reset(&mut self) {
721        self.history.clear();
722        self.pruned_count = 0;
723        self.last_prompt_tokens = None;
724        self.delta_since_prompt = 0;
725    }
726
727    fn restore_turns(&mut self, turns: &[crate::ConversationTurn]) {
728        self.history = turns
729            .iter()
730            .map(|t| TurnRecord {
731                user: t.user.clone(),
732                assistant: t.assistant.clone(),
733                est_tokens: turn_content_estimate(&t.user, &t.assistant, self.est),
734            })
735            .collect();
736        // Step 18.5 (#247): column-first restore — re-anchor on the last
737        // backend-reported prompt size from the 17.6 columns instead of
738        // re-estimating the whole history at chars/4. NULL columns fall
739        // back to the estimate sum (anchor stays None — an estimate is
740        // never presented as a measurement).
741        let (anchor, delta) = restored_token_anchor(turns, self.est);
742        self.last_prompt_tokens = anchor;
743        self.delta_since_prompt = delta;
744        self.pruned_count = self.prune_to_budget();
745    }
746
747    fn usage(&self) -> Option<(String, usize, usize)> {
748        Some((
749            "tokens".into(),
750            self.used_tokens() as usize,
751            self.budget_tokens() as usize,
752        ))
753    }
754}
755
756// ---------------------------------------------------------------------------
757// NoteStore — built-in provider #3 (closes #108; v2 in Step 19.1 / #248)
758// ---------------------------------------------------------------------------
759
760// NoteStore grew its own module in Step 19.1 (`§`-delimited entries,
761// substring addressing, curated cap, atomic writes + advisory lock).
762// Re-exported here so the public path `newt_core::memory::NoteStore`
763// stays stable.
764pub use crate::notes::NoteStore;
765
766// ---------------------------------------------------------------------------
767// MemoryIndex — budgeted progressive-disclosure index (Workstream A MVP, #319)
768// ---------------------------------------------------------------------------
769
770/// The memory index budget: the maximum number of items the frozen memory
771/// INDEX may list, **pinned by CI** (the modulex `DEFAULT_TOOL_BUDGET = 12`
772/// pattern — progressive-disclosure memory design §2.3/§3.3). The index is
773/// the cheap layer that rides in every request; the verbatim bodies are pulled
774/// on demand via `memory_fetch`. Growing this is a deliberate edit to this
775/// constant with its own justification, asserted by a test — never a side
776/// effect of a feature. Starts small (≈ the `DEFAULT_TOOL_BUDGET` order of
777/// magnitude); tune empirically against probe data, never down silently.
778pub const MEMORY_INDEX_BUDGET: usize = 12;
779
780/// A budgeted, frozen INDEX of memory the model can navigate (#319).
781///
782/// Instead of freezing every NOTE body verbatim into the system prompt (what
783/// [`NoteStore::system_prompt_block`] does today), this provider surfaces a
784/// SMALL index — note ids + first-line titles — capped at
785/// [`MEMORY_INDEX_BUDGET`] items. The bodies are pulled on demand via the
786/// `memory_fetch` tool (`note:<id>`). This is `use_skill`'s index-then-fetch
787/// shape applied to memory.
788///
789/// **Additive and opt-in.** It is registered only under
790/// `[memory] disclosure = "index"` (default `frozen`), so with the default
791/// config it is never constructed and behavior is bit-for-bit unchanged. Like
792/// [`NoteStore`] and [`SoulProvider`] it is system-prompt-only —
793/// `build_messages` returns `Vec::new()` so it never competes for the
794/// "first non-empty `build_messages`" slot in [`MemoryManager::build_messages`].
795///
796/// The index is frozen at session start (KV-cache-safe). Notes created
797/// mid-session don't appear until next session — the same accepted limitation
798/// `NoteStore`'s frozen snapshot has today; `memory_fetch` by a known id still
799/// works mid-session even when the index hasn't refreshed (the index is a
800/// convenience surface, the fetch is the capability — design §8.7).
801///
802/// Past-turn keywords are deliberately NOT duplicated here: that is exactly
803/// what `recall` provides (snippet search over the store). The index points at
804/// recall for that axis rather than competing with it (design §3.1/§8.5).
805pub struct MemoryIndex {
806    /// `(id, title)` rows captured at `initialize`, capped at the budget.
807    rows: Vec<(usize, String)>,
808    /// True when more notes existed than the budget could list — the overflow
809    /// line tells the model the tail is reachable via `recall`.
810    truncated: bool,
811    /// Source NOTES path (read once at `initialize`, like `NoteStore`).
812    notes_path: std::path::PathBuf,
813}
814
815impl MemoryIndex {
816    /// Build an index over the NOTES file at `notes_path` (the same file
817    /// [`NoteStore`] reads). The bodies are fetched via `memory_fetch`; this
818    /// provider only ever surfaces ids + titles.
819    pub fn new(notes_path: impl Into<std::path::PathBuf>) -> Self {
820        Self {
821            rows: Vec::new(),
822            truncated: false,
823            notes_path: notes_path.into(),
824        }
825    }
826
827    /// Construct over the default NOTES location (`~/.newt/NOTES.md`), mirroring
828    /// [`NoteStore::default_path`].
829    pub fn default_path() -> Self {
830        let path = crate::Config::user_config_path()
831            .map(|p| p.with_file_name("NOTES.md"))
832            .unwrap_or_else(|| std::path::PathBuf::from("NOTES.md"));
833        Self::new(path)
834    }
835
836    /// Rows currently listed in the index (test introspection).
837    pub fn rows(&self) -> &[(usize, String)] {
838        &self.rows
839    }
840
841    /// Capture the index from a fresh [`NoteStore`] read, capping at
842    /// [`MEMORY_INDEX_BUDGET`]. Shared by `initialize` and tests.
843    fn capture_from(&mut self, notes: &NoteStore) {
844        let all = notes.index_entries();
845        self.truncated = all.len() > MEMORY_INDEX_BUDGET;
846        self.rows = all
847            .into_iter()
848            .take(MEMORY_INDEX_BUDGET)
849            .map(|(id, title)| (id, title.to_string()))
850            .collect();
851    }
852}
853
854#[async_trait]
855impl MemoryProvider for MemoryIndex {
856    fn name(&self) -> &str {
857        "memory_index"
858    }
859
860    async fn initialize(&mut self, ctx: &SessionContext) -> anyhow::Result<()> {
861        // Read the same NOTES file NoteStore reads, once, and freeze the index.
862        let mut notes = NoteStore::new(self.notes_path.clone(), NoteStore::DEFAULT_CHAR_LIMIT);
863        notes.initialize(ctx).await?;
864        self.capture_from(&notes);
865        Ok(())
866    }
867
868    fn system_prompt_block(&self) -> Option<String> {
869        if self.rows.is_empty() {
870            return None;
871        }
872        let mut block =
873            String::from("## Memory index (call `memory_fetch` with an id to read a body)\n");
874        for (id, title) in &self.rows {
875            let title = if title.is_empty() {
876                "(untitled)"
877            } else {
878                title
879            };
880            block.push_str(&format!("- note:{id}  {title}\n"));
881        }
882        if self.truncated {
883            block
884                .push_str("(more notes exist than are listed — use `recall` to search the rest)\n");
885        }
886        Some(block.trim_end().to_string())
887    }
888
889    fn build_messages(&self, _system_prompt: &str, _new_task: &str) -> Vec<MemMessage> {
890        // System-prompt-only (like NoteStore / SoulProvider) — never competes
891        // for the first-non-empty build_messages slot.
892        Vec::new()
893    }
894
895    async fn sync_turn(&mut self, _user: &str, _assistant: &str, _metrics: &TurnMetrics) {}
896}
897
898// ---------------------------------------------------------------------------
899// Summarizing — built-in provider #4 (closes #107)
900// ---------------------------------------------------------------------------
901
902/// Per-turn record with a content-size estimate for budget tracking.
903#[derive(Clone)]
904struct SumTurn {
905    user: String,
906    assistant: String,
907    /// chars/4 (ceiling) estimate of THIS turn's content only — see
908    /// [`TurnRecord::est_tokens`] for why backend usage is not stored per
909    /// turn (it would double-count history; Step 18.1).
910    est_tokens: u32,
911}
912
913impl SumTurn {
914    fn new(
915        user: impl Into<String>,
916        assistant: impl Into<String>,
917        est: crate::tokens::TokenEstimation,
918    ) -> Self {
919        let user = user.into();
920        let assistant = assistant.into();
921        let est_tokens = turn_content_estimate(&user, &assistant, est);
922        Self {
923            user,
924            assistant,
925            est_tokens,
926        }
927    }
928
929    /// Wire-shape view of this entry for the shared compression pipeline.
930    /// A compaction entry (or any unpaired side) renders only its non-empty
931    /// half — the pipeline's summary message is a lone user-role message.
932    fn to_wire(&self) -> Vec<serde_json::Value> {
933        let mut v = Vec::with_capacity(2);
934        if !self.user.is_empty() {
935            v.push(serde_json::json!({"role": "user", "content": self.user}));
936        }
937        if !self.assistant.is_empty() {
938            v.push(serde_json::json!({"role": "assistant", "content": self.assistant}));
939        }
940        v
941    }
942}
943
944/// Rebuild pair-shaped history from the pipeline's assembled wire messages.
945/// A compaction message (and any other unpaired side) becomes a lone-sided
946/// entry; `system`/`tool` roles never occur in the provider's wire view.
947fn wire_to_history(
948    messages: &[serde_json::Value],
949    est: crate::tokens::TokenEstimation,
950) -> Vec<SumTurn> {
951    let mut out: Vec<SumTurn> = Vec::new();
952    for m in messages {
953        let content = m["content"].as_str().unwrap_or_default();
954        match m["role"].as_str() {
955            Some("user") => out.push(SumTurn::new(content, "", est)),
956            Some("assistant") => {
957                match out.last_mut() {
958                    // Pair with the preceding reply-less user entry — unless
959                    // that entry is a compaction message, which stands alone.
960                    Some(last)
961                        if last.assistant.is_empty()
962                            && !last.user.is_empty()
963                            && !is_compaction_text(&last.user) =>
964                    {
965                        last.assistant = content.to_string();
966                        last.est_tokens = turn_content_estimate(&last.user, &last.assistant, est);
967                    }
968                    _ => out.push(SumTurn::new("", content, est)),
969                }
970            }
971            _ => {}
972        }
973    }
974    out
975}
976
977/// LLM-powered summarisation of old turns when context fills.
978///
979/// Since Step 18.5 (#247) this provider owns no summarisation logic of its
980/// own: when its anchored fullness figure crosses the budget it delegates to
981/// the SAME prune → boundary → redacted summary → marker assembly pipeline
982/// the agentic loop uses ([`crate::agentic::compress`], Step 18.4). The
983/// pre-18.4 implementation — its own prompt template, placeholder text, and
984/// anti-thrash counters — is deleted; the summary message in history is now
985/// the pipeline's marked compaction message ([`SUMMARY_PREFIX`]).
986///
987/// Fullness tracking is unchanged (Step 18.1): last backend-reported prompt
988/// size plus the estimated content delta since — the old per-turn
989/// `input + output` running sum double-counted history and triggered
990/// spurious compressions.
991///
992/// Continuity (Step 18.5): the latest compaction message is offered to the
993/// caller once via [`MemoryProvider::take_compaction_record`] for durable
994/// persistence, and [`MemoryProvider::restore_turns`] rehydrates it — the
995/// restored working set is `[compaction message] + [turns recorded after
996/// it]`, so the next compression chains off the previous summary instead of
997/// re-summarizing from scratch.
998///
999/// Configure: `[memory] provider = "summarizing"`, plus an optional explicit
1000/// `context_tokens` override. The compression budget (`max_tokens`) is a
1001/// construction-time value injected by the caller with the same precedence
1002/// as [`TokenBudget`] (Step 18.2, #247): explicit config override →
1003/// capability-derived (`max_ok_input` else `safe_context`) →
1004/// [`DEFAULT_CONTEXT_TOKENS`]. Fixed per session; the loop's pre-send guard
1005/// tracks mid-session capability ratchets live.
1006pub struct Summarizing {
1007    max_tokens: u32,
1008    threshold_pct: f32,
1009    history: Vec<SumTurn>,
1010    /// The latest compaction message (full marked text) — the prev-summary
1011    /// chain. Rehydrated by `restore_turns` (Step 18.5).
1012    prev_summary: String,
1013    /// Compactions minted this session (summaries + static fallbacks).
1014    compress_count: usize,
1015    /// Anti-thrash state, shared semantics with the loop (Step 18.4): two
1016    /// consecutive <10% reclaims disable compression until reset.
1017    state: crate::agentic::CompressState,
1018    /// Injected summariser — the loop's async [`crate::agentic::Summarizer`]
1019    /// shape; `None` degrades to the pipeline's static fallback marker.
1020    summarizer: Option<crate::agentic::Summarizer>,
1021    /// Backend-reported prompt size of the most recent turn (truth anchor).
1022    last_prompt_tokens: Option<u32>,
1023    /// Estimated tokens added (+) / removed (−) relative to that anchor.
1024    delta_since_prompt: i64,
1025    /// Compaction message minted by the last compression, awaiting durable
1026    /// persistence via `take_compaction_record` (Step 18.5).
1027    pending_record: Option<String>,
1028    /// `[context.estimation]` token-estimation heuristic, and the summarizer
1029    /// input-cap floor (`[context] summary_input_cap_floor_chars`). Default to
1030    /// the universal values; the TUI sets them from config via
1031    /// [`Summarizing::with_estimation`].
1032    est: crate::tokens::TokenEstimation,
1033    summary_input_cap_floor_chars: usize,
1034}
1035
1036impl Summarizing {
1037    pub fn new(max_tokens: u32) -> Self {
1038        Self {
1039            max_tokens: max_tokens.max(1),
1040            threshold_pct: 0.80,
1041            history: Vec::new(),
1042            prev_summary: String::new(),
1043            compress_count: 0,
1044            state: crate::agentic::CompressState::new(),
1045            summarizer: None,
1046            last_prompt_tokens: None,
1047            delta_since_prompt: 0,
1048            pending_record: None,
1049            est: crate::tokens::TokenEstimation::default(),
1050            summary_input_cap_floor_chars: 8_192,
1051        }
1052    }
1053
1054    /// Builder: set the token-estimation heuristic + summarizer cap floor from
1055    /// config (`[context.estimation]` / `[context] summary_input_cap_floor_chars`).
1056    #[must_use]
1057    pub fn with_estimation(
1058        mut self,
1059        est: crate::tokens::TokenEstimation,
1060        summary_input_cap_floor_chars: usize,
1061    ) -> Self {
1062        self.est = est;
1063        self.summary_input_cap_floor_chars = summary_input_cap_floor_chars;
1064        self
1065    }
1066
1067    /// Inject a resolved token budget (builder form of the `max_tokens`
1068    /// constructor argument). Same ≥1 clamp as [`Summarizing::new`].
1069    /// See [`TokenBudget::with_budget`] for the resolution precedence the
1070    /// caller is expected to apply (Step 18.2, #247).
1071    pub fn with_budget(mut self, tokens: u32) -> Self {
1072        self.max_tokens = tokens.max(1);
1073        self
1074    }
1075
1076    /// Inject a summariser (required for real summaries; tests can use a
1077    /// stub). Takes the loop's async [`crate::agentic::SummarizeFn`] shape
1078    /// since Step 18.5 — the TUI passes the very same summarizer it builds
1079    /// for the loop, so there is exactly one HTTP wiring. The old sync
1080    /// `Fn(&str) -> Result<String>` form (whose TUI impl blocked inside
1081    /// `sync_turn`) is gone with the logic that called it.
1082    pub fn with_summarizer(
1083        mut self,
1084        f: impl Fn(String) -> crate::agentic::SummarizeFuture + Send + Sync + 'static,
1085    ) -> Self {
1086        self.summarizer = Some(Box::new(f));
1087        self
1088    }
1089
1090    fn budget(&self) -> u32 {
1091        (self.max_tokens as f32 * self.threshold_pct) as u32
1092    }
1093
1094    /// Current context fullness: last backend-reported prompt size plus the
1095    /// estimated delta since, or the per-turn content-estimate sum when no
1096    /// report exists (Step 18.1).
1097    fn used_tokens(&self) -> u32 {
1098        match self.last_prompt_tokens {
1099            Some(p) => (i64::from(p) + self.delta_since_prompt).max(0) as u32,
1100            None => self.history.iter().map(|t| t.est_tokens).sum(),
1101        }
1102    }
1103
1104    /// Delegate one compression to the shared 18.4 pipeline and apply the
1105    /// assembled result back to pair-shaped history (Step 18.5, #247).
1106    async fn compress_via_pipeline(&mut self) {
1107        use crate::agentic::compress::{compress, CompressAction, CompressRequest};
1108        if self.history.is_empty() {
1109            return;
1110        }
1111        let messages: Vec<serde_json::Value> =
1112            self.history.iter().flat_map(SumTurn::to_wire).collect();
1113        // The original-task anchor: the first REAL user message — a leading
1114        // compaction message (rehydrated history) is not the task.
1115        let task = self
1116            .history
1117            .iter()
1118            .find(|t| !t.user.is_empty() && !is_compaction_text(&t.user))
1119            .map(|t| t.user.clone())
1120            .unwrap_or_default();
1121        let outcome = compress(
1122            CompressRequest {
1123                messages: &messages,
1124                budget: self.budget() as usize,
1125                max_messages: None,
1126                task: &task,
1127                hard_budget: true,
1128                // The memory budget is config-derived — authoritative, so
1129                // refuse semantics are preserved here (Step 20.3).
1130                authoritative: true,
1131                focus: None,
1132                est: self.est,
1133                summary_input_cap_floor_chars: self.summary_input_cap_floor_chars,
1134                compaction_store: None,
1135            },
1136            self.summarizer.as_deref(),
1137            &mut self.state,
1138        )
1139        .await;
1140        if !outcome.fired {
1141            return;
1142        }
1143        self.history = wire_to_history(&outcome.messages, self.est);
1144        // Reflect the content change against the backend anchor. The
1145        // pipeline's figures are chars/4 estimates over the wire shape —
1146        // the same currency the delta already tracks.
1147        self.delta_since_prompt += outcome.tokens_after as i64 - outcome.tokens_before as i64;
1148        if matches!(
1149            outcome.action,
1150            CompressAction::Summarized | CompressAction::StaticFallback
1151        ) {
1152            // The pipeline inserted its marked summary at the boundary head —
1153            // the first compaction entry is the freshly minted one.
1154            if let Some(c) = self.history.iter().find(|t| is_compaction_text(&t.user)) {
1155                self.prev_summary = c.user.clone();
1156                self.pending_record = Some(c.user.clone());
1157                self.compress_count += 1;
1158            }
1159        }
1160        tracing::info!(
1161            compress_count = self.compress_count,
1162            action = outcome.action.describe(),
1163            tokens_before = outcome.tokens_before,
1164            tokens_after = outcome.tokens_after,
1165            "Summarizing: compressed context via shared pipeline"
1166        );
1167    }
1168}
1169
1170#[async_trait]
1171impl MemoryProvider for Summarizing {
1172    fn name(&self) -> &str {
1173        "summarizing"
1174    }
1175
1176    fn build_messages(&self, system_prompt: &str, new_task: &str) -> Vec<MemMessage> {
1177        let mut msgs = vec![MemMessage::system(system_prompt)];
1178        for t in &self.history {
1179            if !t.user.is_empty() {
1180                msgs.push(MemMessage::user(&t.user));
1181            }
1182            if !t.assistant.is_empty() {
1183                msgs.push(MemMessage::assistant(&t.assistant));
1184            }
1185        }
1186        msgs.push(MemMessage::user(new_task));
1187        msgs
1188    }
1189
1190    async fn sync_turn(&mut self, user: &str, assistant: &str, metrics: &TurnMetrics) {
1191        let est = turn_content_estimate(user, assistant, self.est);
1192        self.history.push(SumTurn::new(user, assistant, self.est));
1193        match metrics.usage {
1194            Some(u) => {
1195                // Anchor on the largest single prompt the backend evaluated
1196                // this turn (Step 18.1); only the reply is not yet inside it.
1197                self.last_prompt_tokens = Some(u.input_tokens);
1198                self.delta_since_prompt = self.est.tokens_for_chars(assistant.len()) as i64;
1199            }
1200            None => self.delta_since_prompt += i64::from(est),
1201        }
1202        // Over budget → the shared pipeline (which owns anti-thrash); the
1203        // cheap is_disabled gate just avoids rebuilding the wire view for
1204        // a call that would be refused anyway.
1205        if self.used_tokens() > self.budget() && !self.state.is_disabled() {
1206            self.compress_via_pipeline().await;
1207        }
1208    }
1209
1210    fn reset(&mut self) {
1211        self.history.clear();
1212        self.prev_summary.clear();
1213        self.compress_count = 0;
1214        self.state.reset();
1215        self.last_prompt_tokens = None;
1216        self.delta_since_prompt = 0;
1217        self.pending_record = None;
1218    }
1219
1220    fn restore_turns(&mut self, turns: &[crate::ConversationTurn]) {
1221        // Step 18.5 (#247): rehydrate the prev-summary chain instead of
1222        // dropping it. The latest persisted compaction record cuts the
1223        // working set: everything before it is covered by the summary (and
1224        // stays durable in the store); the record itself was appended just
1225        // before the turn that triggered the compression, so the turns after
1226        // it are exactly the ones the live boundary's last-user anchor
1227        // guaranteed survived.
1228        let cut = turns
1229            .iter()
1230            .rposition(|t| is_compaction_text(&t.user) && t.assistant.is_empty());
1231        let live = match cut {
1232            Some(k) => &turns[k + 1..],
1233            None => turns,
1234        };
1235        self.history.clear();
1236        self.prev_summary.clear();
1237        if let Some(k) = cut {
1238            self.history
1239                .push(SumTurn::new(turns[k].user.clone(), "", self.est));
1240            self.prev_summary = turns[k].user.clone();
1241        }
1242        self.history.extend(
1243            live.iter()
1244                .map(|t| SumTurn::new(&*t.user, &*t.assistant, self.est)),
1245        );
1246        self.compress_count = 0;
1247        self.pending_record = None;
1248        // A restore is a conversation boundary — re-arm anti-thrash (F4).
1249        self.state.reset();
1250        // Column-first token accounting (Step 18.5): anchor on the last
1251        // backend-reported prompt size among the turns actually in the
1252        // working set — those prompts already contained the compaction
1253        // message. NULL columns fall back to estimates without ever
1254        // becoming the anchor. NO re-compression here: re-summarizing on
1255        // restore is exactly the from-scratch behavior this step removes;
1256        // the next live turn compresses if genuinely over budget.
1257        let (anchor, delta) = restored_token_anchor(live, self.est);
1258        self.last_prompt_tokens = anchor;
1259        self.delta_since_prompt = delta;
1260    }
1261
1262    fn take_compaction_record(&mut self) -> Option<String> {
1263        self.pending_record.take()
1264    }
1265
1266    async fn on_pre_compress(&self, _messages: &[MemMessage]) -> String {
1267        if self.prev_summary.is_empty() {
1268            String::new()
1269        } else {
1270            format!("Previous compression summary:\n{}", self.prev_summary)
1271        }
1272    }
1273
1274    fn usage(&self) -> Option<(String, usize, usize)> {
1275        Some((
1276            "tokens".into(),
1277            self.used_tokens() as usize,
1278            self.budget() as usize,
1279        ))
1280    }
1281}
1282
1283// ---------------------------------------------------------------------------
1284// SoulProvider — built-in provider #5 (closes #111)
1285// ---------------------------------------------------------------------------
1286
1287/// Default agent identity injected when no soul file is found.
1288///
1289/// This is the single source of truth for the built-in identity — the TUI's
1290/// system-prompt builder falls back to this same constant rather than keeping
1291/// its own copy, so the tool list can't drift between the two paths again.
1292/// Keep the tool list in sync with the tools the agent actually exposes.
1293pub const DEFAULT_SOUL: &str = "\
1294You are newt, a small, fast, local-first agentic coder. \
1295Be concise and direct. \
1296You have tools: run_command, read_file, write_file, edit_file, list_dir, find, use_skill, web_fetch. \
1297Use them to actually complete tasks rather than describing what to do.\n\
1298\n\
1299## How to work\n\
1300\n\
1301**One change at a time.** Read only the files you need for the immediate next step. \
1302Make the change. Commit it. Then move to the next step. \
1303Never accumulate multiple uncommitted edits — a committed partial result survives a crash; \
1304an uncommitted complete result does not.\n\
1305\n\
1306**Read minimum, act fast.** Resist reading the entire codebase before acting. \
1307Read the specific file or function you are about to change, make the change, \
1308commit, then read the next thing. The session has a finite context window — \
1309every token spent reading is a token not spent writing.\n\
1310\n\
1311**Prefer edit_file over write_file.** For any existing file, use edit_file \
1312to replace a specific string — you only generate the change, not the whole file. \
1313Only use write_file when creating a new file or when you have generated the \
1314complete contents in full. write_file will refuse if the new content is \
1315significantly shorter than the original.\n\
1316\n\
1317**Stop when blocked.** If the same tool call fails twice in a row with the \
1318same error, stop immediately and tell the user what blocked you and why. \
1319Do not try alternative installation methods, do not loop, do not pivot to \
1320answering a different question. Two identical failures are a signal to report, \
1321not to retry. One sentence explaining the block is worth more than ten more \
1322failed tool calls.\n\
1323\n\
1324**Seek ground truth.** After every action, verify what actually happened — \
1325not what you intended. Do not proceed on assumptions about your own actions; \
1326confirm them. After writing a file, the tool reports the new line count — \
1327check it matches what you expected. After editing code, the tool reports \
1328whether it compiled — if it did not, fix the error before committing. \
1329Before committing, confirm you are on the right branch. \
1330A belief that something worked is worthless; a tool result that confirms it \
1331is ground truth.\n\
1332\n\
1333**Never describe a code change — make it.** Do not paste code into the chat. \
1334If the task requires a code change, call edit_file or write_file immediately. \
1335A markdown code block in the conversation is invisible to the filesystem — \
1336it does not modify any file. Write the code once, into the file, via the tool. \
1337Showing code in text is NOT completing a task; calling the tool IS.\n\
1338\n\
1339**Exploration budget.** Treat read-only rounds (list_dir, read_file) as expensive. \
1340Spend at most three consecutive rounds on exploration before making a write. \
1341Once you have read the file you need, stop reading and call edit_file or write_file. \
1342Continued reading without writing means you are lost — make your best attempt \
1343at the change based on what you have already read, then verify.\n\
1344\n\
1345**Working code first, then the three Cs.** Make it work, then make it right. \
1346Shipping a working result that hardcodes a list or a constant to get there is \
1347fine; functional results come first. Then RETURN to the three Cs: lift hardcoded \
1348knowledge (keyword lists, magic values, language or domain rules) into pure DATA \
1349that is Composed, Configured, and Convention-driven, so a new case is config, \
1350not code. Don't let this block shipping; do circle back and de-hardcode once it \
1351works.";
1352
1353/// Loads an agent identity from a Markdown soul file and injects it as a
1354/// frozen system-prompt block.
1355///
1356/// Resolution order (first non-empty file wins):
1357/// 1. Explicit path in `[memory] soul_file = "..."` config
1358/// 2. `.newt/soul.md` in the current workspace
1359/// 3. `~/.newt/soul.md` (global user soul)
1360/// 4. Built-in default identity
1361///
1362/// The soul is read **once** at `initialize()` and frozen — mid-session
1363/// writes don't rebuild the system prompt (preserves the KV/prefix cache).
1364pub struct SoulProvider {
1365    /// The soul text after resolution — frozen at `initialize()`.
1366    soul: String,
1367    /// Resolved path of the soul that was actually loaded (for display).
1368    pub source: SoulSource,
1369    /// Explicit override path (from config).
1370    override_path: Option<std::path::PathBuf>,
1371}
1372
1373/// Where the soul was loaded from.
1374#[derive(Debug, Clone, PartialEq, Eq)]
1375pub enum SoulSource {
1376    /// Built-in default identity.
1377    Default,
1378    /// `~/.newt/soul.md`
1379    Global,
1380    /// `.newt/soul.md` in the workspace.
1381    Workspace,
1382    /// Explicit path from `[memory] soul_file = "..."`.
1383    Explicit(std::path::PathBuf),
1384}
1385
1386impl std::fmt::Display for SoulSource {
1387    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1388        match self {
1389            Self::Default => write!(f, "built-in default"),
1390            Self::Global => write!(f, "~/.newt/soul.md"),
1391            Self::Workspace => write!(f, ".newt/soul.md"),
1392            Self::Explicit(p) => write!(f, "{}", p.display()),
1393        }
1394    }
1395}
1396
1397impl SoulProvider {
1398    /// Create with an optional explicit override path (from config).
1399    pub fn new(override_path: Option<std::path::PathBuf>) -> Self {
1400        Self {
1401            soul: DEFAULT_SOUL.to_string(),
1402            source: SoulSource::Default,
1403            override_path,
1404        }
1405    }
1406
1407    /// Create from config, reading `[memory] soul_file` if present.
1408    pub fn from_config() -> Self {
1409        let override_path = crate::Config::resolve()
1410            .ok()
1411            .and_then(|c| c.memory)
1412            .and_then(|m| m.soul_file)
1413            .map(std::path::PathBuf::from);
1414        Self::new(override_path)
1415    }
1416
1417    fn try_load(path: &std::path::Path) -> Option<String> {
1418        let text = std::fs::read_to_string(path).ok()?;
1419        let trimmed = text.trim().to_string();
1420        if trimmed.is_empty() {
1421            None
1422        } else {
1423            Some(trimmed)
1424        }
1425    }
1426
1427    /// Resolve and load the soul for `workspace`. Called from `initialize`.
1428    pub fn load(&mut self, workspace: &str) {
1429        // 1. Explicit override.
1430        if let Some(ref p) = self.override_path {
1431            if let Some(text) = Self::try_load(p) {
1432                self.soul = text;
1433                self.source = SoulSource::Explicit(p.clone());
1434                return;
1435            }
1436        }
1437
1438        // 2. Per-workspace soul.
1439        let ws_soul = std::path::Path::new(workspace)
1440            .join(".newt")
1441            .join("soul.md");
1442        if let Some(text) = Self::try_load(&ws_soul) {
1443            self.soul = text;
1444            self.source = SoulSource::Workspace;
1445            return;
1446        }
1447
1448        // 3. Global user soul.
1449        if let Some(global) = crate::Config::user_config_path().map(|p| p.with_file_name("soul.md"))
1450        {
1451            if let Some(text) = Self::try_load(&global) {
1452                self.soul = text;
1453                self.source = SoulSource::Global;
1454            }
1455        }
1456
1457        // 4. Built-in default (already set in `new()` — nothing to do).
1458    }
1459}
1460
1461#[async_trait]
1462impl MemoryProvider for SoulProvider {
1463    fn name(&self) -> &str {
1464        "soul"
1465    }
1466
1467    async fn initialize(&mut self, ctx: &SessionContext) -> anyhow::Result<()> {
1468        self.load(&ctx.workspace);
1469        tracing::info!(source = %self.source, "soul loaded");
1470        Ok(())
1471    }
1472
1473    /// Return the frozen soul as the system prompt base.
1474    fn system_prompt_block(&self) -> Option<String> {
1475        Some(self.soul.clone())
1476    }
1477
1478    fn build_messages(&self, _system_prompt: &str, _new_task: &str) -> Vec<MemMessage> {
1479        // Soul is system-prompt-only; history is managed by other providers.
1480        Vec::new()
1481    }
1482
1483    async fn sync_turn(&mut self, _user: &str, _assistant: &str, _metrics: &TurnMetrics) {}
1484}
1485
1486// ---------------------------------------------------------------------------
1487// Tests
1488// ---------------------------------------------------------------------------
1489
1490#[cfg(test)]
1491mod tests {
1492    use super::*;
1493    use crate::metrics::TokenUsage;
1494
1495    fn dummy_metrics() -> TurnMetrics {
1496        TurnMetrics {
1497            elapsed_ms: 100,
1498            usage: Some(TokenUsage {
1499                input_tokens: 10,
1500                output_tokens: 5,
1501            }),
1502            cost_usd: Some(0.0),
1503            model_id: "test".into(),
1504            endpoint: "http://localhost".into(),
1505            ..Default::default()
1506        }
1507    }
1508
1509    #[tokio::test]
1510    async fn rolling_window_empty_produces_two_messages() {
1511        let rw = RollingWindow::new(5);
1512        let msgs = rw.build_messages("sys", "hello");
1513        assert_eq!(msgs.len(), 2);
1514        assert_eq!(msgs[0].role, Role::System);
1515        assert_eq!(msgs[1].role, Role::User);
1516        assert_eq!(msgs[1].content, "hello");
1517    }
1518
1519    #[tokio::test]
1520    async fn rolling_window_includes_history() {
1521        let mut rw = RollingWindow::new(5);
1522        rw.sync_turn("q1", "a1", &dummy_metrics()).await;
1523        rw.sync_turn("q2", "a2", &dummy_metrics()).await;
1524        let msgs = rw.build_messages("sys", "q3");
1525        // system + (q1,a1) + (q2,a2) + q3 = 6
1526        assert_eq!(msgs.len(), 6);
1527        assert_eq!(msgs[1].content, "q1");
1528        assert_eq!(msgs[2].content, "a1");
1529        assert_eq!(msgs[5].content, "q3");
1530    }
1531
1532    #[tokio::test]
1533    async fn rolling_window_caps_at_max_turns() {
1534        let mut rw = RollingWindow::new(2);
1535        for i in 0..5u32 {
1536            rw.sync_turn(&format!("q{i}"), &format!("a{i}"), &dummy_metrics())
1537                .await;
1538        }
1539        let msgs = rw.build_messages("sys", "q5");
1540        // system + 2 turns * 2 messages + current = 6
1541        assert_eq!(msgs.len(), 6);
1542        // The last 2 turns should be q3/a3 and q4/a4
1543        assert_eq!(msgs[1].content, "q3");
1544        assert_eq!(msgs[3].content, "q4");
1545        assert_eq!(msgs[5].content, "q5");
1546    }
1547
1548    #[tokio::test]
1549    async fn rolling_window_usage_reports_correctly() {
1550        let mut rw = RollingWindow::new(10);
1551        rw.sync_turn("q", "a", &dummy_metrics()).await;
1552        rw.sync_turn("q", "a", &dummy_metrics()).await;
1553        let (label, cur, max) = rw.usage().unwrap();
1554        assert_eq!(label, "turns");
1555        assert_eq!(cur, 2);
1556        assert_eq!(max, 10);
1557    }
1558
1559    #[tokio::test]
1560    async fn memory_manager_routes_to_provider() {
1561        let mut mgr = MemoryManager::new();
1562        mgr.add_provider(RollingWindow::new(5));
1563        let msgs = mgr.build_messages("sys", "hello");
1564        assert_eq!(msgs[0].role, Role::System);
1565        assert_eq!(msgs.last().unwrap().content, "hello");
1566    }
1567
1568    // --- TokenBudget tests ---
1569
1570    /// Metrics with a given backend-reported prompt size (`input_tokens`).
1571    fn metrics_with_input(input_tokens: u32) -> TurnMetrics {
1572        let mut m = dummy_metrics();
1573        m.usage = Some(TokenUsage {
1574            input_tokens,
1575            output_tokens: 20,
1576        });
1577        m
1578    }
1579
1580    /// SEMANTICS CHANGED in Step 18.1: pruning fires when the BACKEND-reported
1581    /// prompt size exceeds the budget — the prompt already contains all prior
1582    /// turns, so the provider no longer manufactures overflow by summing
1583    /// per-turn readings. Here the reported prompt genuinely grows past the
1584    /// 100-token budget, so the oldest turns must be dropped.
1585    #[tokio::test]
1586    async fn token_budget_prunes_oldest_when_over_budget() {
1587        // max_tokens floors at 512; threshold caps at 0.99 → budget = 506.
1588        let mut tb = TokenBudget::new(512, 0.99);
1589        let budget = 506;
1590        let big = "x".repeat(200); // each turn adds 100 est tokens (2×200/4)
1591        tb.sync_turn(&big, &big, &metrics_with_input(200)).await;
1592        // Turn 1: used = 200 (reported) + 50 (reply est) = 250 ≤ 506 → kept.
1593        assert_eq!(tb.history.len(), 1);
1594        tb.sync_turn(&big, &big, &metrics_with_input(520)).await;
1595        // Turn 2: used = 520 (reported, includes both turns) + 50 = 570 > 506
1596        // → prune the oldest turn, reclaiming its 100-token estimate.
1597        assert!(tb.used_tokens() <= budget, "used = {}", tb.used_tokens());
1598        assert_eq!(tb.history.len(), 1, "the oldest turn must have been pruned");
1599    }
1600
1601    /// SEMANTICS CHANGED in Step 18.1: with a backend report, fullness =
1602    /// reported prompt size (30 — already includes system + history + the
1603    /// user message) + chars/4 ceiling of the reply ("a" → 1). The old
1604    /// assertion (50 = input 30 + output 20) double-counted: output tokens
1605    /// were added on top of every FUTURE turn's input that re-contains them.
1606    #[tokio::test]
1607    async fn token_budget_uses_metrics_when_available() {
1608        let mut tb = TokenBudget::new(1000, 1.0);
1609        let mut m = dummy_metrics();
1610        m.usage = Some(crate::metrics::TokenUsage {
1611            input_tokens: 30,
1612            output_tokens: 20,
1613        });
1614        tb.sync_turn("q", "a", &m).await;
1615        assert_eq!(tb.used_tokens(), 31); // 30 (reported prompt) + 1 (reply est)
1616    }
1617
1618    /// Regression for the B3 baseline's runaway drift (the 5.4× scenario):
1619    /// a 20-turn session whose backend-evaluated prompts grow 2,582 → 4,748
1620    /// tokens. The old running sum tracked 25,602 "used" tokens by the end —
1621    /// 5.4× the largest prompt the backend ever evaluated — and would have
1622    /// pruned the entire history against an 8,192 budget. The anchored math
1623    /// must stay pinned to the last real prompt (+ reply estimate) and never
1624    /// prune a conversation that genuinely fits.
1625    #[tokio::test]
1626    async fn token_budget_no_runaway_drift_b3_regression() {
1627        let mut tb = TokenBudget::new(8_192, 0.80); // budget = 6,553
1628        let mut old_running_sum: u64 = 0;
1629        let turns = 20u32;
1630        for i in 0..turns {
1631            // Backend-reported prompt grows linearly 2,582 → 4,748 (B3 drift
1632            // table endpoints); output fixed at 20 tokens per turn.
1633            let input = 2_582 + (4_748 - 2_582) * i / (turns - 1);
1634            old_running_sum += u64::from(input) + 20;
1635            tb.sync_turn("reply ok", "ok", &metrics_with_input(input))
1636                .await;
1637        }
1638        // Truthful fullness: the last real prompt (4,748) + the tiny reply
1639        // estimate — comfortably inside the budget, so nothing was pruned.
1640        let used = u64::from(tb.used_tokens());
1641        assert!(
1642            (4_748..4_800).contains(&used),
1643            "used must track the last real prompt, got {used}"
1644        );
1645        assert_eq!(tb.history.len(), turns as usize, "no spurious pruning");
1646        assert_eq!(tb.pruned_count, 0);
1647        // And it must be nowhere near the old inflating sum (≥ 5× smaller —
1648        // the B3 baseline measured 5.4× on the real session).
1649        assert!(
1650            used * 5 < old_running_sum,
1651            "anchored used ({used}) must be at least 5× below the old \
1652             running sum ({old_running_sum})"
1653        );
1654    }
1655
1656    /// Async stub summarizer in the loop's `SummarizeFn` shape (the only
1657    /// shape since Step 18.5 — the provider delegates to the shared
1658    /// pipeline, which is async).
1659    fn stub_summarizer(
1660        reply: &'static str,
1661    ) -> impl Fn(String) -> crate::agentic::SummarizeFuture + Send + Sync {
1662        move |_req: String| -> crate::agentic::SummarizeFuture {
1663            Box::pin(async move { Ok(reply.to_string()) })
1664        }
1665    }
1666
1667    /// Stub summarizer that records every request it receives — proves the
1668    /// provider routes through the shared pipeline (Step 18.5).
1669    fn capturing_summarizer(
1670        reply: &'static str,
1671        calls: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
1672    ) -> impl Fn(String) -> crate::agentic::SummarizeFuture + Send + Sync {
1673        move |req: String| -> crate::agentic::SummarizeFuture {
1674            calls.lock().unwrap().push(req);
1675            Box::pin(async move { Ok(reply.to_string()) })
1676        }
1677    }
1678
1679    /// A turn carrying 17.6 token columns, for restore tests.
1680    fn turn_with_tokens(
1681        user: &str,
1682        assistant: &str,
1683        tokens_in: Option<u32>,
1684        tokens_out: Option<u32>,
1685    ) -> crate::ConversationTurn {
1686        let mut t = crate::ConversationTurn::new(user, assistant);
1687        t.tokens_in = tokens_in;
1688        t.tokens_out = tokens_out;
1689        t
1690    }
1691
1692    /// Same B3 drift scenario through `Summarizing`: the old running sum
1693    /// blew past an 8,192-token budget by turn ~2 and burned summarizer
1694    /// calls on a conversation whose real prompts never exceeded 4,748
1695    /// tokens. The anchored math must never trigger compression here.
1696    #[tokio::test]
1697    async fn summarizing_no_runaway_compression_b3_regression() {
1698        let mut s = Summarizing::new(8_192) // budget = 6,553
1699            .with_summarizer(stub_summarizer("SUMMARY"));
1700        for i in 0..20u32 {
1701            let input = 2_582 + (4_748 - 2_582) * i / 19;
1702            s.sync_turn("reply ok", "ok", &metrics_with_input(input))
1703                .await;
1704        }
1705        assert_eq!(
1706            s.compress_count, 0,
1707            "prompts never exceeded the budget — compression must not fire"
1708        );
1709        assert!(s.prev_summary.is_empty());
1710        assert_eq!(s.history.len(), 20);
1711    }
1712
1713    // --- Budget injection (Step 18.2, #247) ---
1714
1715    /// The budget is a plain construction-time value: whatever number the
1716    /// caller resolved (e.g. the TUI's capability-derived figure) is exactly
1717    /// what governs pruning — `usage()` exposes it as the denominator.
1718    #[test]
1719    fn token_budget_construction_injects_budget() {
1720        let tb = TokenBudget::new(24_000, 0.80);
1721        let (label, _used, budget) = tb.usage().unwrap();
1722        assert_eq!(label, "tokens");
1723        assert_eq!(budget, 19_200); // 24_000 × 0.80
1724    }
1725
1726    /// `with_budget` is the builder form of the same injection, mirroring
1727    /// `with_summarizer` — it replaces the constructor value.
1728    #[test]
1729    fn token_budget_with_budget_overrides_constructor_value() {
1730        let tb = TokenBudget::new(512, 0.80).with_budget(24_000);
1731        assert_eq!(tb.usage().unwrap().2, 19_200);
1732        // Same ≥512 clamp as the constructor.
1733        let clamped = TokenBudget::new(4_096, 1.0).with_budget(0);
1734        assert_eq!(clamped.max_tokens, 512);
1735    }
1736
1737    #[test]
1738    fn summarizing_construction_injects_budget() {
1739        let s = Summarizing::new(32_768);
1740        let (label, _used, budget) = s.usage().unwrap();
1741        assert_eq!(label, "tokens");
1742        assert_eq!(budget, 26_214); // 32_768 × 0.80
1743    }
1744
1745    #[test]
1746    fn summarizing_with_budget_overrides_constructor_value() {
1747        let s = Summarizing::new(100).with_budget(32_768);
1748        assert_eq!(s.usage().unwrap().2, 26_214);
1749        // Same ≥1 clamp as the constructor.
1750        let clamped = Summarizing::new(100).with_budget(0);
1751        assert_eq!(clamped.max_tokens, 1);
1752    }
1753
1754    /// The deleted `from_config()` path silently fell back to 8,192 even
1755    /// when the caller had empirical capability data. Construction-time
1756    /// injection means a capability-derived number flows through verbatim —
1757    /// the provider must NOT sit at the static default.
1758    #[test]
1759    fn token_budget_does_not_sit_at_static_default_with_capability_data() {
1760        // A capability-derived budget (e.g. max_ok_input = 24,000) injected
1761        // at construction.
1762        let capability_derived = 24_000;
1763        let tb = TokenBudget::new(capability_derived, 0.80);
1764        let static_default_budget = (DEFAULT_CONTEXT_TOKENS as f32 * 0.80) as usize;
1765        assert_ne!(
1766            tb.usage().unwrap().2,
1767            static_default_budget,
1768            "provider budget must reflect injected capability data, \
1769             not the static default"
1770        );
1771        assert_eq!(tb.usage().unwrap().2, 19_200);
1772    }
1773
1774    // --- NoteStore tests live in crate::notes (split out in Step 19.1) ---
1775
1776    // --- Summarizing tests ---
1777
1778    // --- SoulProvider tests ---
1779
1780    #[tokio::test]
1781    async fn soul_provider_uses_default_when_no_file() {
1782        let mut sp = SoulProvider::new(None);
1783        let ctx = SessionContext {
1784            workspace: "/nonexistent".into(),
1785            session_id: "s".into(),
1786        };
1787        sp.initialize(&ctx).await.unwrap();
1788        assert_eq!(sp.source, SoulSource::Default);
1789        let block = sp.system_prompt_block().unwrap();
1790        assert!(block.contains("newt"), "default soul should mention newt");
1791    }
1792
1793    #[test]
1794    fn default_soul_lists_all_current_tools() {
1795        // Regression: DEFAULT_SOUL went stale when use_skill (#135) and
1796        // web_fetch (#139) were added but the constant wasn't updated, so
1797        // default-identity sessions never learned those tools existed.
1798        // find (#496) is the latest such addition.
1799        for tool in [
1800            "run_command",
1801            "read_file",
1802            "write_file",
1803            "edit_file",
1804            "list_dir",
1805            "find",
1806            "use_skill",
1807            "web_fetch",
1808        ] {
1809            assert!(
1810                DEFAULT_SOUL.contains(tool),
1811                "DEFAULT_SOUL must advertise `{tool}`"
1812            );
1813        }
1814    }
1815
1816    #[tokio::test]
1817    async fn soul_provider_loads_workspace_soul() {
1818        let dir = tempfile::tempdir().unwrap();
1819        // Create .newt/soul.md inside the temp dir.
1820        let newt_dir = dir.path().join(".newt");
1821        std::fs::create_dir_all(&newt_dir).unwrap();
1822        std::fs::write(newt_dir.join("soul.md"), "You are a Django expert.").unwrap();
1823
1824        let mut sp = SoulProvider::new(None);
1825        let ctx = SessionContext {
1826            workspace: dir.path().to_string_lossy().into(),
1827            session_id: "s".into(),
1828        };
1829        sp.initialize(&ctx).await.unwrap();
1830        assert_eq!(sp.source, SoulSource::Workspace);
1831        let block = sp.system_prompt_block().unwrap();
1832        assert!(block.contains("Django"), "should use workspace soul");
1833    }
1834
1835    #[tokio::test]
1836    async fn soul_provider_explicit_path_wins() {
1837        let dir = tempfile::tempdir().unwrap();
1838        let soul_file = dir.path().join("custom_soul.md");
1839        std::fs::write(&soul_file, "You are a security auditor.").unwrap();
1840
1841        // Also create a workspace soul — explicit should win.
1842        let ws_dir = tempfile::tempdir().unwrap();
1843        let newt_dir = ws_dir.path().join(".newt");
1844        std::fs::create_dir_all(&newt_dir).unwrap();
1845        std::fs::write(newt_dir.join("soul.md"), "You are a Django expert.").unwrap();
1846
1847        let mut sp = SoulProvider::new(Some(soul_file.clone()));
1848        let ctx = SessionContext {
1849            workspace: ws_dir.path().to_string_lossy().into(),
1850            session_id: "s".into(),
1851        };
1852        sp.initialize(&ctx).await.unwrap();
1853        assert_eq!(sp.source, SoulSource::Explicit(soul_file));
1854        let block = sp.system_prompt_block().unwrap();
1855        assert!(
1856            block.contains("security auditor"),
1857            "explicit path should win"
1858        );
1859    }
1860
1861    #[tokio::test]
1862    async fn soul_provider_empty_workspace_soul_falls_through() {
1863        let dir = tempfile::tempdir().unwrap();
1864        let newt_dir = dir.path().join(".newt");
1865        std::fs::create_dir_all(&newt_dir).unwrap();
1866        // Empty workspace soul → should fall through to default.
1867        std::fs::write(newt_dir.join("soul.md"), "   ").unwrap();
1868
1869        let mut sp = SoulProvider::new(None);
1870        let ctx = SessionContext {
1871            workspace: dir.path().to_string_lossy().into(),
1872            session_id: "s".into(),
1873        };
1874        sp.initialize(&ctx).await.unwrap();
1875        // Empty file → falls through to default.
1876        assert_eq!(sp.source, SoulSource::Default);
1877    }
1878
1879    /// SEMANTICS CHANGED in Step 18.5: compression delegates to the shared
1880    /// 18.4 pipeline, whose boundary protects the head (the original task)
1881    /// and a ≥3-message tail — so a conversation needs enough turns to leave
1882    /// a summarizable middle (the old provider summarized the oldest 50%
1883    /// unconditionally). The summary in history is the pipeline's marked
1884    /// compaction message.
1885    #[tokio::test]
1886    async fn summarizing_compresses_when_over_budget() {
1887        let mut s = Summarizing::new(100) // budget = 80 tokens
1888            .with_summarizer(stub_summarizer("SUMMARY"));
1889
1890        let big = "x".repeat(200);
1891        // Five turns whose anchored fullness stays at/under the budget.
1892        for i in 0..5u32 {
1893            s.sync_turn(&big, &big, &metrics_with_input(10 + i)).await;
1894        }
1895        assert_eq!(s.compress_count, 0);
1896        // The reported prompt crosses the budget → delegate to the pipeline.
1897        s.sync_turn(&big, &big, &metrics_with_input(120)).await;
1898
1899        assert!(s.compress_count >= 1, "compress_count={}", s.compress_count);
1900        // The chain head is the pipeline's marked compaction message.
1901        assert!(
1902            s.prev_summary.starts_with(crate::agentic::SUMMARY_PREFIX),
1903            "prev_summary must be the marked compaction message"
1904        );
1905        assert!(s.prev_summary.contains("SUMMARY"));
1906        assert!(s.prev_summary.contains(crate::agentic::SUMMARY_END_MARKER));
1907        assert!(
1908            s.history
1909                .iter()
1910                .any(|t| t.user.starts_with(crate::agentic::SUMMARY_PREFIX)
1911                    && t.assistant.is_empty()),
1912            "the compaction message must live in history as a lone user entry"
1913        );
1914    }
1915
1916    /// SEMANTICS CHANGED in Step 18.1: the reported prompt sizes must
1917    /// actually grow past the budget for compression to fire (the old test's
1918    /// flat 40-token turns only crossed it because the running sum inflated).
1919    #[tokio::test]
1920    async fn summarizing_compresses_repeatedly() {
1921        // Verify that the provider compresses across many turns and doesn't
1922        // panic. The exact compress_count depends on savings/anti-thrash.
1923        let mut s = Summarizing::new(100).with_summarizer(stub_summarizer("SUMMARY"));
1924        let text = "x".repeat(120);
1925        for i in 0..20u32 {
1926            // Backend-reported prompt grows 50, 100, 150, … — repeatedly
1927            // crossing the 80-token budget.
1928            s.sync_turn(&text, &text, &metrics_with_input(50 * (i + 1)))
1929                .await;
1930        }
1931        // Should have compressed at least once without panicking.
1932        assert!(s.compress_count >= 1);
1933    }
1934
1935    // --- Continuity: restore + delegation (Step 18.5, #247) ---
1936
1937    /// Restore must route through the shared pipeline exactly once when a
1938    /// later turn overflows, with the shared template and redaction applied
1939    /// — the proof the legacy duplicate path is gone.
1940    #[tokio::test]
1941    async fn summarizing_delegates_to_shared_pipeline() {
1942        let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
1943        let mut s =
1944            Summarizing::new(100).with_summarizer(capturing_summarizer("PIPE-SUM", calls.clone()));
1945        let secret = "sk-aaaaaaaaaaaaaaaaaaaaaaaa";
1946        let big = "x".repeat(200);
1947        // An early reply carries a credential — it lands in the summarized
1948        // middle and must be redacted by the SHARED pipeline's pass.
1949        s.sync_turn(
1950            "the original task",
1951            &format!("noted {secret}"),
1952            &metrics_with_input(10),
1953        )
1954        .await;
1955        for i in 0..4u32 {
1956            s.sync_turn(&big, &big, &metrics_with_input(11 + i)).await;
1957        }
1958        assert!(calls.lock().unwrap().is_empty(), "under budget — no calls");
1959        s.sync_turn(&big, &big, &metrics_with_input(120)).await;
1960
1961        let reqs = calls.lock().unwrap();
1962        assert_eq!(reqs.len(), 1, "exactly one summarizer call per compression");
1963        let req = &reqs[0];
1964        assert!(
1965            req.contains("## Conversation middle to summarise"),
1966            "must be the shared pipeline's request template"
1967        );
1968        assert!(req.contains("## Original Task"));
1969        assert!(req.contains("the original task"), "task anchored verbatim");
1970        assert!(
1971            !req.contains(secret),
1972            "secret must not reach the summarizer"
1973        );
1974        assert!(req.contains("[REDACTED]"), "shared redaction pass applied");
1975        drop(reqs);
1976        // Assembly used the shared markers around the returned body.
1977        assert!(s.prev_summary.starts_with(crate::agentic::SUMMARY_PREFIX));
1978        assert!(s.prev_summary.contains("PIPE-SUM"));
1979        assert!(s.prev_summary.contains(crate::agentic::SUMMARY_END_MARKER));
1980    }
1981
1982    /// The compaction record is offered for persistence exactly once.
1983    #[tokio::test]
1984    async fn summarizing_compaction_record_is_minted_once_and_drained() {
1985        let mut s = Summarizing::new(100).with_summarizer(stub_summarizer("SUMMARY"));
1986        assert!(s.take_compaction_record().is_none(), "nothing minted yet");
1987        let big = "x".repeat(200);
1988        for i in 0..5u32 {
1989            s.sync_turn(&big, &big, &metrics_with_input(10 + i)).await;
1990        }
1991        s.sync_turn(&big, &big, &metrics_with_input(120)).await;
1992        let record = s
1993            .take_compaction_record()
1994            .expect("compression must mint a record");
1995        assert!(record.starts_with(crate::agentic::SUMMARY_PREFIX));
1996        assert_eq!(record, s.prev_summary, "the record IS the chain head");
1997        assert!(
1998            s.take_compaction_record().is_none(),
1999            "the record is drained on take — never persisted twice"
2000        );
2001    }
2002
2003    /// The manager drains the record from whichever provider minted it.
2004    #[tokio::test]
2005    async fn memory_manager_routes_take_compaction_record() {
2006        let mut mgr = MemoryManager::new();
2007        mgr.add_provider(RollingWindow::new(50)); // mints nothing
2008        mgr.add_provider(Summarizing::new(100).with_summarizer(stub_summarizer("SUMMARY")));
2009        assert!(mgr.take_compaction_record().is_none());
2010        let big = "x".repeat(200);
2011        for i in 0..5u32 {
2012            mgr.sync_all(&big, &big, &metrics_with_input(10 + i)).await;
2013        }
2014        mgr.sync_all(&big, &big, &metrics_with_input(120)).await;
2015        let record = mgr
2016            .take_compaction_record()
2017            .expect("manager must surface the Summarizing provider's record");
2018        assert!(record.starts_with(crate::agentic::SUMMARY_PREFIX));
2019        assert!(mgr.take_compaction_record().is_none());
2020    }
2021
2022    /// The memory.rs:919-class bug (#247 / 18.5): restoring a compressed
2023    /// conversation must rehydrate the summary message and the prev-summary
2024    /// chain instead of rebuilding raw history and silently dropping both.
2025    #[tokio::test]
2026    async fn summarizing_restore_rehydrates_compaction_summary() {
2027        let marked = format!(
2028            "{}\nsummary of earlier work\n{}",
2029            crate::agentic::SUMMARY_PREFIX,
2030            crate::agentic::SUMMARY_END_MARKER
2031        );
2032        let turns = vec![
2033            crate::ConversationTurn::new("old task", "old reply"), // covered by the summary
2034            crate::ConversationTurn::new(marked.clone(), ""),      // the persisted record
2035            crate::ConversationTurn::new("recent task", "recent reply"),
2036        ];
2037        let mut s = Summarizing::new(8_192);
2038        s.restore_turns(&turns);
2039
2040        // The chain is back: the next compression sees the previous summary.
2041        let pre = s.on_pre_compress(&[]).await;
2042        assert!(pre.contains("summary of earlier work"), "got: {pre:?}");
2043
2044        // Working set = [compaction message] + turns recorded after it; the
2045        // summarized turn is not duplicated alongside its own summary.
2046        let msgs = s.build_messages("sys", "next");
2047        assert!(msgs
2048            .iter()
2049            .any(|m| m.content.starts_with(crate::agentic::SUMMARY_PREFIX)));
2050        assert!(!msgs.iter().any(|m| m.content == "old task"));
2051        assert!(msgs.iter().any(|m| m.content == "recent task"));
2052        assert!(msgs.iter().any(|m| m.content == "recent reply"));
2053        // The lone-sided compaction entry never dispatches an empty message.
2054        assert!(!msgs.iter().any(|m| m.content.is_empty()));
2055    }
2056
2057    /// Restore must NOT burn a summarizer call: re-summarizing from scratch
2058    /// on restore is exactly the behavior 18.5 removes. The next live turn
2059    /// compresses if genuinely over budget.
2060    #[tokio::test]
2061    async fn summarizing_restore_never_resummarizes() {
2062        let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
2063        let mut s =
2064            Summarizing::new(10).with_summarizer(capturing_summarizer("SUMMARY", calls.clone()));
2065        let big = "x".repeat(400);
2066        let turns: Vec<crate::ConversationTurn> = (0..6)
2067            .map(|i| crate::ConversationTurn::new(format!("q{i} {big}"), format!("a{i} {big}")))
2068            .collect();
2069        s.restore_turns(&turns); // far over the 8-token budget
2070        assert!(
2071            calls.lock().unwrap().is_empty(),
2072            "restore must never call the summarizer"
2073        );
2074        assert_eq!(s.history.len(), 6, "restored history intact");
2075    }
2076
2077    /// After restore, the rehydrated compaction message chains into the NEXT
2078    /// compression as part of the summarized middle — it is neither the task
2079    /// anchor nor a fresh user message (the F1 self-poisoning shape).
2080    #[tokio::test]
2081    async fn restored_compaction_chains_into_next_compression() {
2082        let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
2083        let mut s =
2084            Summarizing::new(100).with_summarizer(capturing_summarizer("NEW-SUM", calls.clone()));
2085        let marked = format!(
2086            "{}\nCHAIN-ME: facts from the first compaction\n{}",
2087            crate::agentic::SUMMARY_PREFIX,
2088            crate::agentic::SUMMARY_END_MARKER
2089        );
2090        let big = "y".repeat(100);
2091        let mut turns = vec![crate::ConversationTurn::new(marked.clone(), "")];
2092        for i in 0..4 {
2093            turns.push(crate::ConversationTurn::new(
2094                format!("task {i} {big}"),
2095                format!("reply {i} {big}"),
2096            ));
2097        }
2098        s.restore_turns(&turns);
2099
2100        // One live over-budget turn → one pipeline compression.
2101        s.sync_turn(&big, &big, &metrics_with_input(200)).await;
2102        let reqs = calls.lock().unwrap();
2103        assert_eq!(reqs.len(), 1, "one call through the shared path");
2104        assert!(
2105            reqs[0].contains("CHAIN-ME"),
2106            "the previous summary must be summarizer INPUT (the chain), got: {}",
2107            &reqs[0]
2108        );
2109        // The Original-Task anchor (the text right under the header) must be
2110        // the first REAL user message, not the compaction message.
2111        let anchored = reqs[0]
2112            .split("## Original Task")
2113            .nth(1)
2114            .and_then(|rest| rest.lines().nth(1).map(str::to_string))
2115            .unwrap_or_default();
2116        assert!(
2117            anchored.starts_with("task 0"),
2118            "task anchor must skip the compaction message, got: {anchored:?}"
2119        );
2120        drop(reqs);
2121        // The old compaction message was replaced by the new one.
2122        let compactions: Vec<&SumTurn> = s
2123            .history
2124            .iter()
2125            .filter(|t| t.user.starts_with(crate::agentic::SUMMARY_PREFIX))
2126            .collect();
2127        assert_eq!(compactions.len(), 1, "exactly one compaction in history");
2128        assert!(compactions[0].user.contains("NEW-SUM"));
2129        assert!(s.prev_summary.contains("NEW-SUM"));
2130    }
2131
2132    /// 18.5 token restore: the anchor comes from the persisted 17.6 column
2133    /// of the LAST measured turn — not a chars/4 re-estimation of history.
2134    #[test]
2135    fn token_budget_restore_anchors_on_column_tokens() {
2136        let mut tb = TokenBudget::new(100_000, 0.80);
2137        let turns = vec![
2138            turn_with_tokens("u1", "a1", Some(900), Some(10)),
2139            turn_with_tokens("u2", "aa", Some(1_000), Some(12)),
2140        ];
2141        tb.restore_turns(&turns);
2142        // 1,000 (measured prompt — already contains everything before it)
2143        // + ceil(2/4) = 1 for the reply not yet inside any prompt.
2144        assert_eq!(tb.last_prompt_tokens, Some(1_000));
2145        assert_eq!(tb.used_tokens(), 1_001);
2146    }
2147
2148    /// NULL columns (pre-17.6 rows, silent backends) fall back to the
2149    /// estimate WITHOUT ever becoming the anchor — an estimate is never
2150    /// presented as a measurement (18.1 honesty).
2151    #[test]
2152    fn token_budget_restore_null_columns_fall_back_to_estimate() {
2153        let mut tb = TokenBudget::new(100_000, 0.80);
2154        let text = "x".repeat(40); // 10 est tokens per side
2155        let turns = vec![
2156            crate::ConversationTurn::new(text.clone(), text.clone()),
2157            crate::ConversationTurn::new(text.clone(), text.clone()),
2158        ];
2159        tb.restore_turns(&turns);
2160        assert_eq!(
2161            tb.last_prompt_tokens, None,
2162            "no measurement in the store → no anchor, only estimates"
2163        );
2164        assert_eq!(tb.used_tokens(), 40, "2 turns × (10 + 10) est tokens");
2165    }
2166
2167    /// Turns recorded after the last measured one (silent backend) extend
2168    /// the delta with estimates while the anchor stays the real measurement.
2169    #[test]
2170    fn token_budget_restore_unmeasured_tail_extends_delta() {
2171        let mut tb = TokenBudget::new(100_000, 0.80);
2172        let turns = vec![
2173            turn_with_tokens("u1", "aaaa", Some(1_000), Some(8)), // reply est 1
2174            crate::ConversationTurn::new("xxxx", "yyyy"),         // est 2
2175        ];
2176        tb.restore_turns(&turns);
2177        assert_eq!(tb.last_prompt_tokens, Some(1_000));
2178        assert_eq!(tb.used_tokens(), 1_003);
2179    }
2180
2181    /// Same column-first restore through `Summarizing`.
2182    #[test]
2183    fn summarizing_restore_anchors_on_column_tokens() {
2184        let mut s = Summarizing::new(100_000);
2185        let turns = vec![
2186            turn_with_tokens("u1", "a1", Some(700), Some(9)),
2187            turn_with_tokens("u2", "aaaa", Some(2_000), Some(11)),
2188        ];
2189        s.restore_turns(&turns);
2190        assert_eq!(s.last_prompt_tokens, Some(2_000));
2191        assert_eq!(s.used_tokens(), 2_001); // 2,000 + ceil(4/4)
2192    }
2193
2194    /// Measurements taken BEFORE the compaction cut describe prompts of a
2195    /// pre-compression shape — they must not anchor the restored (smaller)
2196    /// working set.
2197    #[test]
2198    fn summarizing_restore_ignores_measurements_before_the_cut() {
2199        let marked = format!(
2200            "{}\nolder work compressed\n{}",
2201            crate::agentic::SUMMARY_PREFIX,
2202            crate::agentic::SUMMARY_END_MARKER
2203        );
2204        let mut s = Summarizing::new(100_000);
2205        let turns = vec![
2206            turn_with_tokens("old", "old", Some(50_000), Some(10)),
2207            crate::ConversationTurn::new(marked, ""),
2208            crate::ConversationTurn::new("after", "compaction"), // unmeasured
2209        ];
2210        s.restore_turns(&turns);
2211        assert_eq!(
2212            s.last_prompt_tokens, None,
2213            "a pre-compression measurement must not anchor the cut working set"
2214        );
2215    }
2216
2217    /// A compaction record restored into the default provider stays in the
2218    /// window as a user-side summary — never dispatched with an empty
2219    /// assistant half.
2220    #[test]
2221    fn rolling_window_restores_compaction_record_without_empty_assistant() {
2222        let marked = format!(
2223            "{}\nearlier work\n{}",
2224            crate::agentic::SUMMARY_PREFIX,
2225            crate::agentic::SUMMARY_END_MARKER
2226        );
2227        let mut rw = RollingWindow::new(10);
2228        rw.restore_turns(&[
2229            crate::ConversationTurn::new(marked.clone(), ""),
2230            crate::ConversationTurn::new("next task", "next reply"),
2231        ]);
2232        let msgs = rw.build_messages("sys", "go");
2233        assert!(msgs.iter().any(|m| m.content == marked));
2234        assert!(!msgs.iter().any(|m| m.content.is_empty()));
2235    }
2236
2237    // --- MemoryManager hook coverage ---
2238
2239    #[tokio::test]
2240    async fn memory_manager_on_pre_compress() {
2241        let mgr = MemoryManager::new();
2242        let result = mgr.on_pre_compress(&[]).await;
2243        assert!(result.is_empty());
2244    }
2245
2246    #[tokio::test]
2247    async fn memory_manager_on_session_end() {
2248        let mut mgr = MemoryManager::new();
2249        mgr.add_provider(RollingWindow::new(5));
2250        mgr.on_session_end(&[]).await; // must not panic
2251    }
2252
2253    #[tokio::test]
2254    async fn memory_manager_prefetch_all_empty() {
2255        let mgr = MemoryManager::new();
2256        let result = mgr.prefetch_all("query").await;
2257        assert!(result.is_empty());
2258    }
2259
2260    #[tokio::test]
2261    async fn memory_manager_build_system_prompt_additions_from_note_store() {
2262        let dir = tempfile::tempdir().unwrap();
2263        let path = dir.path().join("NOTES.md");
2264        std::fs::write(&path, "fact one\nfact two").unwrap();
2265        let mut ns = NoteStore::new(path, 2200);
2266        let ctx = SessionContext {
2267            workspace: "/ws".into(),
2268            session_id: "s".into(),
2269        };
2270        ns.initialize(&ctx).await.unwrap();
2271
2272        let mut mgr = MemoryManager::new();
2273        mgr.add_provider(ns);
2274        let additions = mgr.build_system_prompt_additions();
2275        assert!(additions.contains("fact one"));
2276    }
2277
2278    #[tokio::test]
2279    async fn memory_manager_add_note_fails_with_no_note_store() {
2280        let mut mgr = MemoryManager::new();
2281        mgr.add_provider(RollingWindow::new(5));
2282        let err = mgr.add_note("fact").unwrap_err().to_string();
2283        assert!(
2284            err.contains("no note-capable memory provider"),
2285            "guidance error expected: {err}"
2286        );
2287    }
2288
2289    // --- TokenBudget additional coverage ---
2290
2291    #[tokio::test]
2292    async fn token_budget_usage_reporting() {
2293        let tb = TokenBudget::new(1000, 0.80);
2294        let (label, cur, max) = tb.usage().unwrap();
2295        assert_eq!(label, "tokens");
2296        assert_eq!(cur, 0);
2297        assert_eq!(max, 800); // 1000 * 0.80
2298    }
2299
2300    #[tokio::test]
2301    async fn token_budget_does_not_prune_within_budget() {
2302        let mut tb = TokenBudget::new(200, 1.0); // budget = 200
2303        let mut m = dummy_metrics();
2304        m.usage = Some(crate::metrics::TokenUsage {
2305            input_tokens: 50,
2306            output_tokens: 50,
2307        });
2308        tb.sync_turn("q", "a", &m).await; // 50 + 1 reply-est — within budget
2309        assert_eq!(tb.history.len(), 1);
2310    }
2311
2312    /// Without any backend report the provider falls back to summing per-turn
2313    /// content estimates — each turn counted once (no double-count).
2314    #[tokio::test]
2315    async fn token_budget_estimate_fallback_counts_each_turn_once() {
2316        let mut tb = TokenBudget::new(1000, 1.0);
2317        let mut m = dummy_metrics();
2318        m.usage = None; // backend reported nothing
2319        let text = "x".repeat(40); // 40 chars → 10 est tokens per side
2320        tb.sync_turn(&text, &text, &m).await;
2321        tb.sync_turn(&text, &text, &m).await;
2322        assert_eq!(tb.used_tokens(), 40, "2 turns × (10 + 10) est tokens");
2323    }
2324
2325    // --- Summarizing additional coverage ---
2326
2327    /// SEMANTICS CHANGED in Step 18.5: with no summarizer the shared
2328    /// pipeline inserts its STATIC fallback marker (the only surviving form
2329    /// of the old placeholder-discard) — the provider's own "turns
2330    /// summarised" placeholder is deleted with the rest of the legacy path.
2331    #[tokio::test]
2332    async fn summarizing_fallback_placeholder_when_no_summarizer() {
2333        let mut s = Summarizing::new(10); // tiny budget (8) to trigger compression
2334        for i in 0..6u32 {
2335            s.sync_turn(
2336                &format!("question {i}"),
2337                &format!("answer {i}"),
2338                &metrics_with_input(6 + 4 * i),
2339            )
2340            .await;
2341        }
2342        let compaction = s
2343            .history
2344            .iter()
2345            .find(|t| t.user.starts_with(crate::agentic::SUMMARY_PREFIX))
2346            .expect("static fallback marker should be inserted");
2347        assert!(
2348            compaction
2349                .user
2350                .contains("Summary generation was unavailable"),
2351            "got: {}",
2352            compaction.user
2353        );
2354    }
2355
2356    #[tokio::test]
2357    async fn summarizing_usage_reporting() {
2358        let s = Summarizing::new(1000);
2359        let (label, cur, max) = s.usage().unwrap();
2360        assert_eq!(label, "tokens");
2361        assert_eq!(cur, 0);
2362        assert_eq!(max, 800); // 1000 * 0.80
2363    }
2364
2365    #[tokio::test]
2366    async fn summarizing_on_pre_compress_returns_prev_summary() {
2367        let mut s = Summarizing::new(10).with_summarizer(stub_summarizer("PRIOR SUMMARY"));
2368        // Build up history UNDER budget first: the pipeline's boundary needs
2369        // enough turns to leave a summarizable middle (Step 18.5 — head +
2370        // ≥3-message tail are protected), and a too-early trigger would burn
2371        // anti-thrash slots on nothing-to-summarize passes.
2372        for _ in 0..5u32 {
2373            s.sync_turn("question text", "answer text", &metrics_with_input(2))
2374                .await;
2375        }
2376        // Now cross the 8-token budget → compression sets prev_summary.
2377        s.sync_turn("question text", "answer text", &metrics_with_input(50))
2378            .await;
2379        let pre = s.on_pre_compress(&[]).await;
2380        assert!(
2381            pre.contains("PRIOR SUMMARY"),
2382            "compression must have run and set prev_summary, got: {pre:?}"
2383        );
2384    }
2385
2386    // --- RollingWindow additional coverage ---
2387
2388    #[tokio::test]
2389    async fn rolling_window_on_session_end_noop() {
2390        let mut rw = RollingWindow::new(5);
2391        rw.on_session_end(&[]).await; // must not panic
2392    }
2393
2394    #[tokio::test]
2395    async fn rolling_window_add_note_returns_notes_unsupported() {
2396        let mut rw = RollingWindow::new(5);
2397        let err = rw.add_note("fact").unwrap_err();
2398        assert!(err.is::<NotesUnsupported>());
2399    }
2400
2401    #[tokio::test]
2402    async fn rolling_window_replace_and_remove_note_return_notes_unsupported() {
2403        // The trait defaults (Step 19.3) mirror add_note so the manager's
2404        // routing can skip note-less providers for every mutation kind.
2405        let mut rw = RollingWindow::new(5);
2406        assert!(rw
2407            .replace_note("old", "new")
2408            .unwrap_err()
2409            .is::<NotesUnsupported>());
2410        assert!(rw.remove_note("old").unwrap_err().is::<NotesUnsupported>());
2411    }
2412
2413    #[tokio::test]
2414    async fn memory_manager_replace_and_remove_route_to_note_store() {
2415        let dir = tempfile::tempdir().unwrap();
2416        let path = dir.path().join("NOTES.md");
2417        let mut mgr = MemoryManager::new();
2418        // A note-less provider first — routing must skip it (NotesUnsupported).
2419        mgr.add_provider(RollingWindow::new(5));
2420        mgr.add_provider(NoteStore::new(path.clone(), 2200));
2421        let ctx = SessionContext {
2422            workspace: "/ws".into(),
2423            session_id: "s".into(),
2424        };
2425        mgr.initialize_all(&ctx).await;
2426
2427        mgr.add_note("model alpha is the fast tier").unwrap();
2428        mgr.add_note("workspace uses just check").unwrap();
2429
2430        mgr.replace_note("alpha", "model beta is the fast tier")
2431            .unwrap();
2432        let raw = std::fs::read_to_string(&path).unwrap();
2433        assert!(raw.contains("model beta"), "{raw}");
2434        assert!(!raw.contains("model alpha"), "{raw}");
2435
2436        mgr.remove_note("just check").unwrap();
2437        let raw = std::fs::read_to_string(&path).unwrap();
2438        assert!(!raw.contains("just check"), "{raw}");
2439        assert!(raw.contains("model beta"), "other entry untouched: {raw}");
2440
2441        // Real rejections surface: ambiguity / zero-match errors come back.
2442        let err = mgr.remove_note("nonexistent").unwrap_err().to_string();
2443        assert!(err.contains("no entry contains"), "{err}");
2444    }
2445
2446    #[tokio::test]
2447    async fn memory_manager_replace_and_remove_fail_with_no_note_store() {
2448        let mut mgr = MemoryManager::new();
2449        mgr.add_provider(RollingWindow::new(5));
2450        let err = mgr.replace_note("a", "b").unwrap_err().to_string();
2451        assert!(err.contains("no note-capable memory provider"), "{err}");
2452        let err = mgr.remove_note("a").unwrap_err().to_string();
2453        assert!(err.contains("no note-capable memory provider"), "{err}");
2454    }
2455
2456    #[tokio::test]
2457    async fn memory_manager_add_note_routes_to_note_store() {
2458        let dir = tempfile::tempdir().unwrap();
2459        let path = dir.path().join("NOTES.md");
2460        let mut mgr = MemoryManager::new();
2461        mgr.add_provider(RollingWindow::new(5));
2462        mgr.add_provider(NoteStore::new(path.clone(), 2200));
2463        let ctx = SessionContext {
2464            workspace: "/ws".into(),
2465            session_id: "s".into(),
2466        };
2467        mgr.initialize_all(&ctx).await;
2468        mgr.add_note("the answer is 42").unwrap();
2469        let raw = std::fs::read_to_string(&path).unwrap();
2470        assert!(raw.contains("the answer is 42"));
2471    }
2472
2473    /// A provider with a non-`note_store` name that accepts notes — proves
2474    /// the manager no longer special-cases `name() == "note_store"`.
2475    struct CustomNotes {
2476        notes: Vec<String>,
2477    }
2478
2479    #[async_trait]
2480    impl MemoryProvider for CustomNotes {
2481        fn name(&self) -> &str {
2482            "custom_notes"
2483        }
2484        fn build_messages(&self, _system_prompt: &str, _new_task: &str) -> Vec<MemMessage> {
2485            Vec::new()
2486        }
2487        async fn sync_turn(&mut self, _user: &str, _assistant: &str, _metrics: &TurnMetrics) {}
2488        fn add_note(&mut self, fact: &str) -> anyhow::Result<()> {
2489            self.notes.push(fact.to_string());
2490            Ok(())
2491        }
2492    }
2493
2494    #[tokio::test]
2495    async fn memory_manager_add_note_first_ok_wins_regardless_of_name() {
2496        let mut mgr = MemoryManager::new();
2497        mgr.add_provider(RollingWindow::new(5)); // unsupported — skipped
2498        mgr.add_provider(CustomNotes { notes: Vec::new() });
2499        mgr.add_note("routed by capability, not by name").unwrap();
2500    }
2501
2502    #[tokio::test]
2503    async fn memory_manager_add_note_surfaces_curator_error() {
2504        // A real rejection from a note-capable provider (the over-budget
2505        // curator error) must reach the caller, not be swallowed by the
2506        // generic "no provider" message.
2507        let dir = tempfile::tempdir().unwrap();
2508        let path = dir.path().join("NOTES.md");
2509        let mut mgr = MemoryManager::new();
2510        mgr.add_provider(RollingWindow::new(5));
2511        mgr.add_provider(NoteStore::new(path, 40));
2512        let ctx = SessionContext {
2513            workspace: "/ws".into(),
2514            session_id: "s".into(),
2515        };
2516        mgr.initialize_all(&ctx).await;
2517        mgr.add_note("an entry that fits").unwrap();
2518        let err = mgr.add_note(&"x".repeat(80)).unwrap_err().to_string();
2519        assert!(
2520            err.contains("Replace or remove existing entries first"),
2521            "curator error must propagate: {err}"
2522        );
2523        assert!(err.contains("an entry that fits"), "{err}");
2524    }
2525
2526    #[tokio::test]
2527    async fn memory_manager_sync_all() {
2528        let mut mgr = MemoryManager::new();
2529        mgr.add_provider(RollingWindow::new(5));
2530        mgr.sync_all("q", "a", &dummy_metrics()).await;
2531        let usage = mgr.usage();
2532        assert_eq!(usage[0].1, 1); // 1 turn stored
2533    }
2534
2535    #[tokio::test]
2536    async fn memory_manager_reset_all_clears_conversation_history() {
2537        let mut mgr = MemoryManager::new();
2538        mgr.add_provider(RollingWindow::new(5));
2539        mgr.sync_all("old task", "old reply", &dummy_metrics())
2540            .await;
2541
2542        let before = mgr.build_messages("system", "new task");
2543        assert!(before.iter().any(|m| m.content == "old task"));
2544        assert!(before.iter().any(|m| m.content == "old reply"));
2545
2546        mgr.reset_all();
2547
2548        let after = mgr.build_messages("system", "new task");
2549        assert!(!after.iter().any(|m| m.content == "old task"));
2550        assert!(!after.iter().any(|m| m.content == "old reply"));
2551        assert!(after.iter().any(|m| m.content == "new task"));
2552    }
2553
2554    #[tokio::test]
2555    async fn memory_manager_restore_turns_replaces_conversation_history() {
2556        let mut mgr = MemoryManager::new();
2557        mgr.add_provider(RollingWindow::new(5));
2558        mgr.sync_all("old task", "old reply", &dummy_metrics())
2559            .await;
2560
2561        mgr.restore_turns(&[
2562            crate::ConversationTurn::new("restored task", "restored reply"),
2563            crate::ConversationTurn::new("follow up", "followed up"),
2564        ]);
2565
2566        let messages = mgr.build_messages("system", "new task");
2567        assert!(!messages.iter().any(|m| m.content == "old task"));
2568        assert!(!messages.iter().any(|m| m.content == "old reply"));
2569        assert!(messages.iter().any(|m| m.content == "restored task"));
2570        assert!(messages.iter().any(|m| m.content == "restored reply"));
2571        assert!(messages.iter().any(|m| m.content == "follow up"));
2572        assert!(messages.iter().any(|m| m.content == "followed up"));
2573        assert!(messages.iter().any(|m| m.content == "new task"));
2574    }
2575
2576    #[tokio::test]
2577    async fn memory_manager_fallback_with_no_providers() {
2578        let mgr = MemoryManager::new();
2579        let msgs = mgr.build_messages("sys", "task");
2580        assert_eq!(msgs.len(), 2);
2581    }
2582
2583    // -- MemoryIndex (progressive-disclosure memory, Workstream A MVP, #319) --
2584
2585    fn index_ctx(dir: &std::path::Path) -> SessionContext {
2586        SessionContext {
2587            workspace: dir.to_string_lossy().into(),
2588            session_id: "s".into(),
2589        }
2590    }
2591
2592    /// Seed a NOTES file with `n` entries (using NoteStore's own write path so
2593    /// the §-delimited on-disk format is exactly what `MemoryIndex` reads).
2594    async fn seed_notes(path: &std::path::Path, n: usize) {
2595        let mut ns = NoteStore::new(path, NoteStore::DEFAULT_CHAR_LIMIT);
2596        ns.initialize(&index_ctx(path.parent().unwrap()))
2597            .await
2598            .unwrap();
2599        for i in 0..n {
2600            ns.add(&format!("note number {i}\nbody line for {i}"))
2601                .unwrap();
2602        }
2603    }
2604
2605    /// CI-PINNED BUDGET (the modulex `DEFAULT_TOOL_BUDGET` pattern, design
2606    /// §2.3/§3.3): the frozen memory index lists at most `MEMORY_INDEX_BUDGET`
2607    /// items, no matter how many notes exist. Growing the budget is a
2608    /// deliberate edit to the constant — this test fails if a feature grows the
2609    /// default surface as a side effect.
2610    #[tokio::test]
2611    async fn memory_index_stays_under_pinned_budget() {
2612        let dir = tempfile::tempdir().unwrap();
2613        let path = dir.path().join("NOTES.md");
2614        // Seed MANY more notes than the budget.
2615        seed_notes(&path, MEMORY_INDEX_BUDGET + 25).await;
2616
2617        let mut idx = MemoryIndex::new(&path);
2618        idx.initialize(&index_ctx(dir.path())).await.unwrap();
2619
2620        assert!(
2621            idx.rows().len() <= MEMORY_INDEX_BUDGET,
2622            "index surface ({}) exceeds the pinned budget ({MEMORY_INDEX_BUDGET})",
2623            idx.rows().len()
2624        );
2625        assert_eq!(idx.rows().len(), MEMORY_INDEX_BUDGET, "fills to the cap");
2626        // The block names the overflow recovery (recall), never silently drops.
2627        let block = idx.system_prompt_block().unwrap();
2628        assert!(block.contains("use `recall`"), "overflow hint: {block}");
2629    }
2630
2631    #[tokio::test]
2632    async fn memory_index_lists_ids_and_titles_not_bodies() {
2633        let dir = tempfile::tempdir().unwrap();
2634        let path = dir.path().join("NOTES.md");
2635        seed_notes(&path, 2).await;
2636
2637        let mut idx = MemoryIndex::new(&path);
2638        idx.initialize(&index_ctx(dir.path())).await.unwrap();
2639        let block = idx.system_prompt_block().unwrap();
2640
2641        // Ids + first-line titles are listed …
2642        assert!(block.contains("note:1  note number 0"), "got: {block}");
2643        assert!(block.contains("note:2  note number 1"), "got: {block}");
2644        // … but NOT the bodies (those are fetched via memory_fetch).
2645        assert!(!block.contains("body line for 0"), "body leaked: {block}");
2646        assert!(
2647            block.contains("call `memory_fetch`"),
2648            "names the fetch tool: {block}"
2649        );
2650    }
2651
2652    #[tokio::test]
2653    async fn memory_index_is_system_prompt_only() {
2654        // Like NoteStore / SoulProvider — never competes for the
2655        // first-non-empty build_messages slot.
2656        let dir = tempfile::tempdir().unwrap();
2657        let path = dir.path().join("NOTES.md");
2658        seed_notes(&path, 1).await;
2659        let mut idx = MemoryIndex::new(&path);
2660        idx.initialize(&index_ctx(dir.path())).await.unwrap();
2661        assert!(idx.build_messages("sys", "task").is_empty());
2662    }
2663
2664    #[tokio::test]
2665    async fn memory_index_empty_notes_contributes_no_block() {
2666        let dir = tempfile::tempdir().unwrap();
2667        let path = dir.path().join("NOTES.md");
2668        let mut idx = MemoryIndex::new(&path); // no notes file at all
2669        idx.initialize(&index_ctx(dir.path())).await.unwrap();
2670        assert!(idx.system_prompt_block().is_none());
2671    }
2672
2673    /// INERT BY DEFAULT (#319 acceptance): with no MemoryIndex registered (the
2674    /// `disclosure = "frozen"` default), the manager's system-prompt additions
2675    /// and messages are byte-identical to a manager that also omits it — and
2676    /// crucially they carry NO "Memory index" block. Opting in (index mode)
2677    /// ADDS the block, proving the frozen path is genuinely the no-op branch.
2678    /// The MVP changes nothing unless opted in.
2679    #[tokio::test]
2680    async fn disclosure_frozen_default_is_bit_for_bit_unchanged() {
2681        let dir = tempfile::tempdir().unwrap();
2682        let path = dir.path().join("NOTES.md");
2683        seed_notes(&path, 3).await;
2684
2685        // Today's shape: RollingWindow + NoteStore, NO MemoryIndex (frozen).
2686        async fn build(
2687            path: &std::path::Path,
2688            ws: &std::path::Path,
2689            with_index: bool,
2690        ) -> (String, Vec<MemMessage>) {
2691            let mut mgr = MemoryManager::new();
2692            mgr.add_provider(RollingWindow::new(20));
2693            mgr.add_provider(NoteStore::new(path, NoteStore::DEFAULT_CHAR_LIMIT));
2694            if with_index {
2695                mgr.add_provider(MemoryIndex::new(path));
2696            }
2697            mgr.initialize_all(&SessionContext {
2698                workspace: ws.to_string_lossy().into(),
2699                session_id: "s".into(),
2700            })
2701            .await;
2702            (
2703                mgr.build_system_prompt_additions(),
2704                mgr.build_messages("sys", "task"),
2705            )
2706        }
2707
2708        let (frozen_sys, frozen_msgs) = build(&path, dir.path(), false).await;
2709        // Registration is the only difference; omitting MemoryIndex is a no-op.
2710        let (frozen_sys2, frozen_msgs2) = build(&path, dir.path(), false).await;
2711        assert_eq!(frozen_sys, frozen_sys2);
2712        assert_eq!(frozen_msgs, frozen_msgs2);
2713        assert!(
2714            !frozen_sys.contains("Memory index"),
2715            "frozen mode must NOT add the block: {frozen_sys}"
2716        );
2717
2718        // Opting in (index mode) ADDS the index block.
2719        let (index_sys, _index_msgs) = build(&path, dir.path(), true).await;
2720        assert!(
2721            index_sys.contains("Memory index"),
2722            "index mode adds the block: {index_sys}"
2723        );
2724    }
2725}