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, render_report. \
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**Present findings — don't just report a blocker.** When the task is to \
1340gather or summarize (a status roll-up, a triage sweep, a morning briefing), \
1341your deliverable is a rendered report, not a one-line status. The moment you \
1342have what was asked, call render_report to present it. A failed data source \
1343is one degraded section, not a dead end — render the rest and mark the failed \
1344part `degraded` (or `error`), so the human sees the partial result plus \
1345exactly what is missing. Ending such a task with only \"X is broken\" leaves \
1346the work you already did invisible.\n\
1347\n\
1348**Exploration budget.** Treat read-only rounds (list_dir, read_file) as expensive. \
1349Spend at most three consecutive rounds on exploration before making a write. \
1350Once you have read the file you need, stop reading and call edit_file or write_file. \
1351Continued reading without writing means you are lost — make your best attempt \
1352at the change based on what you have already read, then verify.\n\
1353\n\
1354**Working code first, then the three Cs.** Make it work, then make it right. \
1355Shipping a working result that hardcodes a list or a constant to get there is \
1356fine; functional results come first. Then RETURN to the three Cs: lift hardcoded \
1357knowledge (keyword lists, magic values, language or domain rules) into pure DATA \
1358that is Composed, Configured, and Convention-driven, so a new case is config, \
1359not code. Don't let this block shipping; do circle back and de-hardcode once it \
1360works.";
1361
1362/// FR-5 (#999): the ADVISE-first identity installed when a persona's altitude is
1363/// [`crate::Altitude::Coach`] (front-matter `altitude = "coach"`). It REPLACES
1364/// [`DEFAULT_SOUL`] rather than layering over it — the doer soul ("never
1365/// describe a change, make it") directly contradicts a coach, so appending a
1366/// coach overlay onto the doer soul would ship two opposing identities in one
1367/// prompt. This IS the whole base identity for a coaching turn; the persona's
1368/// own markdown body still overlays on top, now without self-contradiction.
1369pub const COACH_SOUL: &str = "\
1370You are newt in COACH mode: an advisor, not a doer. Be concise and direct. \
1371You help the human reason about their code and infrastructure — you explain, \
1372present options, and recommend the next step. You do NOT make the change \
1373yourself.\n\
1374\n\
1375## How to coach\n\
1376\n\
1377**Advise; do not act.** Present the command, the edit, or the plan as text, \
1378with the reasoning behind it, and let the human decide and execute. A coaching \
1379turn's product is UNDERSTANDING and a recommendation — never a mutated file or \
1380an executed command. Do not call write_file, edit_file, or a state-changing \
1381run_command; if the change needs making, say so and let the human make it (or \
1382switch out of coach mode).\n\
1383\n\
1384**Ground your advice in the real code.** Use the read-only tools — read_file, \
1385list_dir, find, web_fetch — to see what is actually there before you advise. \
1386Advice built on an assumption about the code is worse than none; confirm \
1387against ground truth, then recommend.\n\
1388\n\
1389**Show the command, don't run it.** When the answer is 'run X', write X in the \
1390reply with a one-line explanation of what it does and why — as something the \
1391human runs, not something you run. A command in the conversation is guidance; \
1392executing it would be doing the human's job for them.\n\
1393\n\
1394**Teach the reasoning, not just the answer.** Explain WHY, name the trade-offs, \
1395and point at the one next step. The human is learning from you, not delegating \
1396to you — leave them able to make the next call themselves.\n\
1397\n\
1398**Stop when unsure.** If you cannot ground a recommendation, say what you would \
1399need to check rather than guessing. One honest 'here is what I'd verify first' \
1400beats a confident wrong answer.";
1401
1402/// Loads an agent identity from a Markdown soul file and injects it as a
1403/// frozen system-prompt block.
1404///
1405/// Resolution order (first non-empty file wins):
1406/// 1. Explicit path in `[memory] soul_file = "..."` config
1407/// 2. `.newt/soul.md` in the current workspace
1408/// 3. `~/.newt/soul.md` (global user soul)
1409/// 4. Built-in default identity
1410///
1411/// The soul is read **once** at `initialize()` and frozen — mid-session
1412/// writes don't rebuild the system prompt (preserves the KV/prefix cache).
1413pub struct SoulProvider {
1414    /// The soul text after resolution — frozen at `initialize()`.
1415    soul: String,
1416    /// Resolved path of the soul that was actually loaded (for display).
1417    pub source: SoulSource,
1418    /// Explicit override path (from config).
1419    override_path: Option<std::path::PathBuf>,
1420}
1421
1422/// Where the soul was loaded from.
1423#[derive(Debug, Clone, PartialEq, Eq)]
1424pub enum SoulSource {
1425    /// Built-in default identity.
1426    Default,
1427    /// `~/.newt/soul.md`
1428    Global,
1429    /// `.newt/soul.md` in the workspace.
1430    Workspace,
1431    /// Explicit path from `[memory] soul_file = "..."`.
1432    Explicit(std::path::PathBuf),
1433}
1434
1435impl std::fmt::Display for SoulSource {
1436    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1437        match self {
1438            Self::Default => write!(f, "built-in default"),
1439            Self::Global => write!(f, "~/.newt/soul.md"),
1440            Self::Workspace => write!(f, ".newt/soul.md"),
1441            Self::Explicit(p) => write!(f, "{}", p.display()),
1442        }
1443    }
1444}
1445
1446impl SoulProvider {
1447    /// Create with an optional explicit override path (from config).
1448    pub fn new(override_path: Option<std::path::PathBuf>) -> Self {
1449        Self {
1450            soul: DEFAULT_SOUL.to_string(),
1451            source: SoulSource::Default,
1452            override_path,
1453        }
1454    }
1455
1456    /// Create from config, reading `[memory] soul_file` if present.
1457    pub fn from_config() -> Self {
1458        let override_path = crate::Config::resolve()
1459            .ok()
1460            .and_then(|c| c.memory)
1461            .and_then(|m| m.soul_file)
1462            .map(std::path::PathBuf::from);
1463        Self::new(override_path)
1464    }
1465
1466    fn try_load(path: &std::path::Path) -> Option<String> {
1467        let text = std::fs::read_to_string(path).ok()?;
1468        let trimmed = text.trim().to_string();
1469        if trimmed.is_empty() {
1470            None
1471        } else {
1472            Some(trimmed)
1473        }
1474    }
1475
1476    /// Resolve and load the soul for `workspace`. Called from `initialize`.
1477    pub fn load(&mut self, workspace: &str) {
1478        // 1. Explicit override.
1479        if let Some(ref p) = self.override_path {
1480            if let Some(text) = Self::try_load(p) {
1481                self.soul = text;
1482                self.source = SoulSource::Explicit(p.clone());
1483                return;
1484            }
1485        }
1486
1487        // 2. Per-workspace soul.
1488        let ws_soul = std::path::Path::new(workspace)
1489            .join(".newt")
1490            .join("soul.md");
1491        if let Some(text) = Self::try_load(&ws_soul) {
1492            self.soul = text;
1493            self.source = SoulSource::Workspace;
1494            return;
1495        }
1496
1497        // 3. Global user soul.
1498        if let Some(global) = crate::Config::user_config_path().map(|p| p.with_file_name("soul.md"))
1499        {
1500            if let Some(text) = Self::try_load(&global) {
1501                self.soul = text;
1502                self.source = SoulSource::Global;
1503            }
1504        }
1505
1506        // 4. Built-in default (already set in `new()` — nothing to do).
1507    }
1508}
1509
1510#[async_trait]
1511impl MemoryProvider for SoulProvider {
1512    fn name(&self) -> &str {
1513        "soul"
1514    }
1515
1516    async fn initialize(&mut self, ctx: &SessionContext) -> anyhow::Result<()> {
1517        self.load(&ctx.workspace);
1518        tracing::info!(source = %self.source, "soul loaded");
1519        Ok(())
1520    }
1521
1522    /// Return the frozen soul as the system prompt base.
1523    fn system_prompt_block(&self) -> Option<String> {
1524        Some(self.soul.clone())
1525    }
1526
1527    fn build_messages(&self, _system_prompt: &str, _new_task: &str) -> Vec<MemMessage> {
1528        // Soul is system-prompt-only; history is managed by other providers.
1529        Vec::new()
1530    }
1531
1532    async fn sync_turn(&mut self, _user: &str, _assistant: &str, _metrics: &TurnMetrics) {}
1533}
1534
1535// ---------------------------------------------------------------------------
1536// Tests
1537// ---------------------------------------------------------------------------
1538
1539#[cfg(test)]
1540mod tests {
1541    use super::*;
1542    use crate::metrics::TokenUsage;
1543
1544    fn dummy_metrics() -> TurnMetrics {
1545        TurnMetrics {
1546            elapsed_ms: 100,
1547            usage: Some(TokenUsage {
1548                input_tokens: 10,
1549                output_tokens: 5,
1550            }),
1551            cost_usd: Some(0.0),
1552            model_id: "test".into(),
1553            endpoint: "http://localhost".into(),
1554            ..Default::default()
1555        }
1556    }
1557
1558    #[tokio::test]
1559    async fn rolling_window_empty_produces_two_messages() {
1560        let rw = RollingWindow::new(5);
1561        let msgs = rw.build_messages("sys", "hello");
1562        assert_eq!(msgs.len(), 2);
1563        assert_eq!(msgs[0].role, Role::System);
1564        assert_eq!(msgs[1].role, Role::User);
1565        assert_eq!(msgs[1].content, "hello");
1566    }
1567
1568    #[tokio::test]
1569    async fn rolling_window_includes_history() {
1570        let mut rw = RollingWindow::new(5);
1571        rw.sync_turn("q1", "a1", &dummy_metrics()).await;
1572        rw.sync_turn("q2", "a2", &dummy_metrics()).await;
1573        let msgs = rw.build_messages("sys", "q3");
1574        // system + (q1,a1) + (q2,a2) + q3 = 6
1575        assert_eq!(msgs.len(), 6);
1576        assert_eq!(msgs[1].content, "q1");
1577        assert_eq!(msgs[2].content, "a1");
1578        assert_eq!(msgs[5].content, "q3");
1579    }
1580
1581    #[tokio::test]
1582    async fn rolling_window_caps_at_max_turns() {
1583        let mut rw = RollingWindow::new(2);
1584        for i in 0..5u32 {
1585            rw.sync_turn(&format!("q{i}"), &format!("a{i}"), &dummy_metrics())
1586                .await;
1587        }
1588        let msgs = rw.build_messages("sys", "q5");
1589        // system + 2 turns * 2 messages + current = 6
1590        assert_eq!(msgs.len(), 6);
1591        // The last 2 turns should be q3/a3 and q4/a4
1592        assert_eq!(msgs[1].content, "q3");
1593        assert_eq!(msgs[3].content, "q4");
1594        assert_eq!(msgs[5].content, "q5");
1595    }
1596
1597    #[tokio::test]
1598    async fn rolling_window_usage_reports_correctly() {
1599        let mut rw = RollingWindow::new(10);
1600        rw.sync_turn("q", "a", &dummy_metrics()).await;
1601        rw.sync_turn("q", "a", &dummy_metrics()).await;
1602        let (label, cur, max) = rw.usage().unwrap();
1603        assert_eq!(label, "turns");
1604        assert_eq!(cur, 2);
1605        assert_eq!(max, 10);
1606    }
1607
1608    #[tokio::test]
1609    async fn memory_manager_routes_to_provider() {
1610        let mut mgr = MemoryManager::new();
1611        mgr.add_provider(RollingWindow::new(5));
1612        let msgs = mgr.build_messages("sys", "hello");
1613        assert_eq!(msgs[0].role, Role::System);
1614        assert_eq!(msgs.last().unwrap().content, "hello");
1615    }
1616
1617    // --- TokenBudget tests ---
1618
1619    /// Metrics with a given backend-reported prompt size (`input_tokens`).
1620    fn metrics_with_input(input_tokens: u32) -> TurnMetrics {
1621        let mut m = dummy_metrics();
1622        m.usage = Some(TokenUsage {
1623            input_tokens,
1624            output_tokens: 20,
1625        });
1626        m
1627    }
1628
1629    /// SEMANTICS CHANGED in Step 18.1: pruning fires when the BACKEND-reported
1630    /// prompt size exceeds the budget — the prompt already contains all prior
1631    /// turns, so the provider no longer manufactures overflow by summing
1632    /// per-turn readings. Here the reported prompt genuinely grows past the
1633    /// 100-token budget, so the oldest turns must be dropped.
1634    #[tokio::test]
1635    async fn token_budget_prunes_oldest_when_over_budget() {
1636        // max_tokens floors at 512; threshold caps at 0.99 → budget = 506.
1637        let mut tb = TokenBudget::new(512, 0.99);
1638        let budget = 506;
1639        let big = "x".repeat(200); // each turn adds 100 est tokens (2×200/4)
1640        tb.sync_turn(&big, &big, &metrics_with_input(200)).await;
1641        // Turn 1: used = 200 (reported) + 50 (reply est) = 250 ≤ 506 → kept.
1642        assert_eq!(tb.history.len(), 1);
1643        tb.sync_turn(&big, &big, &metrics_with_input(520)).await;
1644        // Turn 2: used = 520 (reported, includes both turns) + 50 = 570 > 506
1645        // → prune the oldest turn, reclaiming its 100-token estimate.
1646        assert!(tb.used_tokens() <= budget, "used = {}", tb.used_tokens());
1647        assert_eq!(tb.history.len(), 1, "the oldest turn must have been pruned");
1648    }
1649
1650    /// SEMANTICS CHANGED in Step 18.1: with a backend report, fullness =
1651    /// reported prompt size (30 — already includes system + history + the
1652    /// user message) + chars/4 ceiling of the reply ("a" → 1). The old
1653    /// assertion (50 = input 30 + output 20) double-counted: output tokens
1654    /// were added on top of every FUTURE turn's input that re-contains them.
1655    #[tokio::test]
1656    async fn token_budget_uses_metrics_when_available() {
1657        let mut tb = TokenBudget::new(1000, 1.0);
1658        let mut m = dummy_metrics();
1659        m.usage = Some(crate::metrics::TokenUsage {
1660            input_tokens: 30,
1661            output_tokens: 20,
1662        });
1663        tb.sync_turn("q", "a", &m).await;
1664        assert_eq!(tb.used_tokens(), 31); // 30 (reported prompt) + 1 (reply est)
1665    }
1666
1667    /// Regression for the B3 baseline's runaway drift (the 5.4× scenario):
1668    /// a 20-turn session whose backend-evaluated prompts grow 2,582 → 4,748
1669    /// tokens. The old running sum tracked 25,602 "used" tokens by the end —
1670    /// 5.4× the largest prompt the backend ever evaluated — and would have
1671    /// pruned the entire history against an 8,192 budget. The anchored math
1672    /// must stay pinned to the last real prompt (+ reply estimate) and never
1673    /// prune a conversation that genuinely fits.
1674    #[tokio::test]
1675    async fn token_budget_no_runaway_drift_b3_regression() {
1676        let mut tb = TokenBudget::new(8_192, 0.80); // budget = 6,553
1677        let mut old_running_sum: u64 = 0;
1678        let turns = 20u32;
1679        for i in 0..turns {
1680            // Backend-reported prompt grows linearly 2,582 → 4,748 (B3 drift
1681            // table endpoints); output fixed at 20 tokens per turn.
1682            let input = 2_582 + (4_748 - 2_582) * i / (turns - 1);
1683            old_running_sum += u64::from(input) + 20;
1684            tb.sync_turn("reply ok", "ok", &metrics_with_input(input))
1685                .await;
1686        }
1687        // Truthful fullness: the last real prompt (4,748) + the tiny reply
1688        // estimate — comfortably inside the budget, so nothing was pruned.
1689        let used = u64::from(tb.used_tokens());
1690        assert!(
1691            (4_748..4_800).contains(&used),
1692            "used must track the last real prompt, got {used}"
1693        );
1694        assert_eq!(tb.history.len(), turns as usize, "no spurious pruning");
1695        assert_eq!(tb.pruned_count, 0);
1696        // And it must be nowhere near the old inflating sum (≥ 5× smaller —
1697        // the B3 baseline measured 5.4× on the real session).
1698        assert!(
1699            used * 5 < old_running_sum,
1700            "anchored used ({used}) must be at least 5× below the old \
1701             running sum ({old_running_sum})"
1702        );
1703    }
1704
1705    /// Async stub summarizer in the loop's `SummarizeFn` shape (the only
1706    /// shape since Step 18.5 — the provider delegates to the shared
1707    /// pipeline, which is async).
1708    fn stub_summarizer(
1709        reply: &'static str,
1710    ) -> impl Fn(String) -> crate::agentic::SummarizeFuture + Send + Sync {
1711        move |_req: String| -> crate::agentic::SummarizeFuture {
1712            Box::pin(async move { Ok(reply.to_string()) })
1713        }
1714    }
1715
1716    /// Stub summarizer that records every request it receives — proves the
1717    /// provider routes through the shared pipeline (Step 18.5).
1718    fn capturing_summarizer(
1719        reply: &'static str,
1720        calls: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
1721    ) -> impl Fn(String) -> crate::agentic::SummarizeFuture + Send + Sync {
1722        move |req: String| -> crate::agentic::SummarizeFuture {
1723            calls.lock().unwrap().push(req);
1724            Box::pin(async move { Ok(reply.to_string()) })
1725        }
1726    }
1727
1728    /// A turn carrying 17.6 token columns, for restore tests.
1729    fn turn_with_tokens(
1730        user: &str,
1731        assistant: &str,
1732        tokens_in: Option<u32>,
1733        tokens_out: Option<u32>,
1734    ) -> crate::ConversationTurn {
1735        let mut t = crate::ConversationTurn::new(user, assistant);
1736        t.tokens_in = tokens_in;
1737        t.tokens_out = tokens_out;
1738        t
1739    }
1740
1741    /// Same B3 drift scenario through `Summarizing`: the old running sum
1742    /// blew past an 8,192-token budget by turn ~2 and burned summarizer
1743    /// calls on a conversation whose real prompts never exceeded 4,748
1744    /// tokens. The anchored math must never trigger compression here.
1745    #[tokio::test]
1746    async fn summarizing_no_runaway_compression_b3_regression() {
1747        let mut s = Summarizing::new(8_192) // budget = 6,553
1748            .with_summarizer(stub_summarizer("SUMMARY"));
1749        for i in 0..20u32 {
1750            let input = 2_582 + (4_748 - 2_582) * i / 19;
1751            s.sync_turn("reply ok", "ok", &metrics_with_input(input))
1752                .await;
1753        }
1754        assert_eq!(
1755            s.compress_count, 0,
1756            "prompts never exceeded the budget — compression must not fire"
1757        );
1758        assert!(s.prev_summary.is_empty());
1759        assert_eq!(s.history.len(), 20);
1760    }
1761
1762    // --- Budget injection (Step 18.2, #247) ---
1763
1764    /// The budget is a plain construction-time value: whatever number the
1765    /// caller resolved (e.g. the TUI's capability-derived figure) is exactly
1766    /// what governs pruning — `usage()` exposes it as the denominator.
1767    #[test]
1768    fn token_budget_construction_injects_budget() {
1769        let tb = TokenBudget::new(24_000, 0.80);
1770        let (label, _used, budget) = tb.usage().unwrap();
1771        assert_eq!(label, "tokens");
1772        assert_eq!(budget, 19_200); // 24_000 × 0.80
1773    }
1774
1775    /// `with_budget` is the builder form of the same injection, mirroring
1776    /// `with_summarizer` — it replaces the constructor value.
1777    #[test]
1778    fn token_budget_with_budget_overrides_constructor_value() {
1779        let tb = TokenBudget::new(512, 0.80).with_budget(24_000);
1780        assert_eq!(tb.usage().unwrap().2, 19_200);
1781        // Same ≥512 clamp as the constructor.
1782        let clamped = TokenBudget::new(4_096, 1.0).with_budget(0);
1783        assert_eq!(clamped.max_tokens, 512);
1784    }
1785
1786    #[test]
1787    fn summarizing_construction_injects_budget() {
1788        let s = Summarizing::new(32_768);
1789        let (label, _used, budget) = s.usage().unwrap();
1790        assert_eq!(label, "tokens");
1791        assert_eq!(budget, 26_214); // 32_768 × 0.80
1792    }
1793
1794    #[test]
1795    fn summarizing_with_budget_overrides_constructor_value() {
1796        let s = Summarizing::new(100).with_budget(32_768);
1797        assert_eq!(s.usage().unwrap().2, 26_214);
1798        // Same ≥1 clamp as the constructor.
1799        let clamped = Summarizing::new(100).with_budget(0);
1800        assert_eq!(clamped.max_tokens, 1);
1801    }
1802
1803    /// The deleted `from_config()` path silently fell back to 8,192 even
1804    /// when the caller had empirical capability data. Construction-time
1805    /// injection means a capability-derived number flows through verbatim —
1806    /// the provider must NOT sit at the static default.
1807    #[test]
1808    fn token_budget_does_not_sit_at_static_default_with_capability_data() {
1809        // A capability-derived budget (e.g. max_ok_input = 24,000) injected
1810        // at construction.
1811        let capability_derived = 24_000;
1812        let tb = TokenBudget::new(capability_derived, 0.80);
1813        let static_default_budget = (DEFAULT_CONTEXT_TOKENS as f32 * 0.80) as usize;
1814        assert_ne!(
1815            tb.usage().unwrap().2,
1816            static_default_budget,
1817            "provider budget must reflect injected capability data, \
1818             not the static default"
1819        );
1820        assert_eq!(tb.usage().unwrap().2, 19_200);
1821    }
1822
1823    // --- NoteStore tests live in crate::notes (split out in Step 19.1) ---
1824
1825    // --- Summarizing tests ---
1826
1827    // --- SoulProvider tests ---
1828
1829    #[tokio::test]
1830    async fn soul_provider_uses_default_when_no_file() {
1831        let mut sp = SoulProvider::new(None);
1832        let ctx = SessionContext {
1833            workspace: "/nonexistent".into(),
1834            session_id: "s".into(),
1835        };
1836        sp.initialize(&ctx).await.unwrap();
1837        assert_eq!(sp.source, SoulSource::Default);
1838        let block = sp.system_prompt_block().unwrap();
1839        assert!(block.contains("newt"), "default soul should mention newt");
1840    }
1841
1842    #[test]
1843    fn default_soul_lists_all_current_tools() {
1844        // Regression: DEFAULT_SOUL went stale when use_skill (#135) and
1845        // web_fetch (#139) were added but the constant wasn't updated, so
1846        // default-identity sessions never learned those tools existed.
1847        // find (#496) is the latest such addition; render_report (#1004) is
1848        // the newest.
1849        for tool in [
1850            "run_command",
1851            "read_file",
1852            "write_file",
1853            "edit_file",
1854            "list_dir",
1855            "find",
1856            "use_skill",
1857            "web_fetch",
1858            "render_report",
1859        ] {
1860            assert!(
1861                DEFAULT_SOUL.contains(tool),
1862                "DEFAULT_SOUL must advertise `{tool}`"
1863            );
1864        }
1865    }
1866
1867    /// FR-5 (#999) golden contract: the coach identity must instruct the model
1868    /// to ADVISE, not act — name the mutating tools as things NOT to call, frame
1869    /// the turn as advising, and drop the doer's imperative. This is the
1870    /// deterministic essence of "a scripted incident turn emits no mutating tool
1871    /// call": the model's behavior follows from this directive, so if COACH_SOUL
1872    /// ever loses it the coach silently regresses into a doer.
1873    #[test]
1874    fn coach_soul_forbids_mutation_and_mandates_advice() {
1875        // Names the mutating tools it must not call.
1876        for mutating in ["write_file", "edit_file", "run_command"] {
1877            assert!(
1878                COACH_SOUL.contains(mutating),
1879                "COACH_SOUL must name `{mutating}` (as forbidden)"
1880            );
1881        }
1882        assert!(
1883            COACH_SOUL.contains("Do not call write_file"),
1884            "COACH_SOUL must carry the explicit no-mutation directive"
1885        );
1886        assert!(
1887            COACH_SOUL.to_lowercase().contains("advis"),
1888            "COACH_SOUL must frame the turn as advising"
1889        );
1890        // And it must NOT carry the doer's act-first imperative.
1891        assert!(
1892            !COACH_SOUL.contains("Never describe a code change — make it"),
1893            "COACH_SOUL must not inherit the doer imperative"
1894        );
1895    }
1896
1897    #[tokio::test]
1898    async fn soul_provider_loads_workspace_soul() {
1899        let dir = tempfile::tempdir().unwrap();
1900        // Create .newt/soul.md inside the temp dir.
1901        let newt_dir = dir.path().join(".newt");
1902        std::fs::create_dir_all(&newt_dir).unwrap();
1903        std::fs::write(newt_dir.join("soul.md"), "You are a Django expert.").unwrap();
1904
1905        let mut sp = SoulProvider::new(None);
1906        let ctx = SessionContext {
1907            workspace: dir.path().to_string_lossy().into(),
1908            session_id: "s".into(),
1909        };
1910        sp.initialize(&ctx).await.unwrap();
1911        assert_eq!(sp.source, SoulSource::Workspace);
1912        let block = sp.system_prompt_block().unwrap();
1913        assert!(block.contains("Django"), "should use workspace soul");
1914    }
1915
1916    #[tokio::test]
1917    async fn soul_provider_explicit_path_wins() {
1918        let dir = tempfile::tempdir().unwrap();
1919        let soul_file = dir.path().join("custom_soul.md");
1920        std::fs::write(&soul_file, "You are a security auditor.").unwrap();
1921
1922        // Also create a workspace soul — explicit should win.
1923        let ws_dir = tempfile::tempdir().unwrap();
1924        let newt_dir = ws_dir.path().join(".newt");
1925        std::fs::create_dir_all(&newt_dir).unwrap();
1926        std::fs::write(newt_dir.join("soul.md"), "You are a Django expert.").unwrap();
1927
1928        let mut sp = SoulProvider::new(Some(soul_file.clone()));
1929        let ctx = SessionContext {
1930            workspace: ws_dir.path().to_string_lossy().into(),
1931            session_id: "s".into(),
1932        };
1933        sp.initialize(&ctx).await.unwrap();
1934        assert_eq!(sp.source, SoulSource::Explicit(soul_file));
1935        let block = sp.system_prompt_block().unwrap();
1936        assert!(
1937            block.contains("security auditor"),
1938            "explicit path should win"
1939        );
1940    }
1941
1942    #[tokio::test]
1943    async fn soul_provider_empty_workspace_soul_falls_through() {
1944        let dir = tempfile::tempdir().unwrap();
1945        let newt_dir = dir.path().join(".newt");
1946        std::fs::create_dir_all(&newt_dir).unwrap();
1947        // Empty workspace soul → should fall through to default.
1948        std::fs::write(newt_dir.join("soul.md"), "   ").unwrap();
1949
1950        let mut sp = SoulProvider::new(None);
1951        let ctx = SessionContext {
1952            workspace: dir.path().to_string_lossy().into(),
1953            session_id: "s".into(),
1954        };
1955        sp.initialize(&ctx).await.unwrap();
1956        // Empty file → falls through to default.
1957        assert_eq!(sp.source, SoulSource::Default);
1958    }
1959
1960    /// SEMANTICS CHANGED in Step 18.5: compression delegates to the shared
1961    /// 18.4 pipeline, whose boundary protects the head (the original task)
1962    /// and a ≥3-message tail — so a conversation needs enough turns to leave
1963    /// a summarizable middle (the old provider summarized the oldest 50%
1964    /// unconditionally). The summary in history is the pipeline's marked
1965    /// compaction message.
1966    #[tokio::test]
1967    async fn summarizing_compresses_when_over_budget() {
1968        let mut s = Summarizing::new(100) // budget = 80 tokens
1969            .with_summarizer(stub_summarizer("SUMMARY"));
1970
1971        let big = "x".repeat(200);
1972        // Five turns whose anchored fullness stays at/under the budget.
1973        for i in 0..5u32 {
1974            s.sync_turn(&big, &big, &metrics_with_input(10 + i)).await;
1975        }
1976        assert_eq!(s.compress_count, 0);
1977        // The reported prompt crosses the budget → delegate to the pipeline.
1978        s.sync_turn(&big, &big, &metrics_with_input(120)).await;
1979
1980        assert!(s.compress_count >= 1, "compress_count={}", s.compress_count);
1981        // The chain head is the pipeline's marked compaction message.
1982        assert!(
1983            s.prev_summary.starts_with(crate::agentic::SUMMARY_PREFIX),
1984            "prev_summary must be the marked compaction message"
1985        );
1986        assert!(s.prev_summary.contains("SUMMARY"));
1987        assert!(s.prev_summary.contains(crate::agentic::SUMMARY_END_MARKER));
1988        assert!(
1989            s.history
1990                .iter()
1991                .any(|t| t.user.starts_with(crate::agentic::SUMMARY_PREFIX)
1992                    && t.assistant.is_empty()),
1993            "the compaction message must live in history as a lone user entry"
1994        );
1995    }
1996
1997    /// SEMANTICS CHANGED in Step 18.1: the reported prompt sizes must
1998    /// actually grow past the budget for compression to fire (the old test's
1999    /// flat 40-token turns only crossed it because the running sum inflated).
2000    #[tokio::test]
2001    async fn summarizing_compresses_repeatedly() {
2002        // Verify that the provider compresses across many turns and doesn't
2003        // panic. The exact compress_count depends on savings/anti-thrash.
2004        let mut s = Summarizing::new(100).with_summarizer(stub_summarizer("SUMMARY"));
2005        let text = "x".repeat(120);
2006        for i in 0..20u32 {
2007            // Backend-reported prompt grows 50, 100, 150, … — repeatedly
2008            // crossing the 80-token budget.
2009            s.sync_turn(&text, &text, &metrics_with_input(50 * (i + 1)))
2010                .await;
2011        }
2012        // Should have compressed at least once without panicking.
2013        assert!(s.compress_count >= 1);
2014    }
2015
2016    // --- Continuity: restore + delegation (Step 18.5, #247) ---
2017
2018    /// Restore must route through the shared pipeline exactly once when a
2019    /// later turn overflows, with the shared template and redaction applied
2020    /// — the proof the legacy duplicate path is gone.
2021    #[tokio::test]
2022    async fn summarizing_delegates_to_shared_pipeline() {
2023        let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
2024        let mut s =
2025            Summarizing::new(100).with_summarizer(capturing_summarizer("PIPE-SUM", calls.clone()));
2026        let secret = "sk-aaaaaaaaaaaaaaaaaaaaaaaa";
2027        let big = "x".repeat(200);
2028        // An early reply carries a credential — it lands in the summarized
2029        // middle and must be redacted by the SHARED pipeline's pass.
2030        s.sync_turn(
2031            "the original task",
2032            &format!("noted {secret}"),
2033            &metrics_with_input(10),
2034        )
2035        .await;
2036        for i in 0..4u32 {
2037            s.sync_turn(&big, &big, &metrics_with_input(11 + i)).await;
2038        }
2039        assert!(calls.lock().unwrap().is_empty(), "under budget — no calls");
2040        s.sync_turn(&big, &big, &metrics_with_input(120)).await;
2041
2042        let reqs = calls.lock().unwrap();
2043        assert_eq!(reqs.len(), 1, "exactly one summarizer call per compression");
2044        let req = &reqs[0];
2045        assert!(
2046            req.contains("## Conversation middle to summarise"),
2047            "must be the shared pipeline's request template"
2048        );
2049        assert!(req.contains("## Original Task"));
2050        assert!(req.contains("the original task"), "task anchored verbatim");
2051        assert!(
2052            !req.contains(secret),
2053            "secret must not reach the summarizer"
2054        );
2055        assert!(req.contains("[REDACTED]"), "shared redaction pass applied");
2056        drop(reqs);
2057        // Assembly used the shared markers around the returned body.
2058        assert!(s.prev_summary.starts_with(crate::agentic::SUMMARY_PREFIX));
2059        assert!(s.prev_summary.contains("PIPE-SUM"));
2060        assert!(s.prev_summary.contains(crate::agentic::SUMMARY_END_MARKER));
2061    }
2062
2063    /// The compaction record is offered for persistence exactly once.
2064    #[tokio::test]
2065    async fn summarizing_compaction_record_is_minted_once_and_drained() {
2066        let mut s = Summarizing::new(100).with_summarizer(stub_summarizer("SUMMARY"));
2067        assert!(s.take_compaction_record().is_none(), "nothing minted yet");
2068        let big = "x".repeat(200);
2069        for i in 0..5u32 {
2070            s.sync_turn(&big, &big, &metrics_with_input(10 + i)).await;
2071        }
2072        s.sync_turn(&big, &big, &metrics_with_input(120)).await;
2073        let record = s
2074            .take_compaction_record()
2075            .expect("compression must mint a record");
2076        assert!(record.starts_with(crate::agentic::SUMMARY_PREFIX));
2077        assert_eq!(record, s.prev_summary, "the record IS the chain head");
2078        assert!(
2079            s.take_compaction_record().is_none(),
2080            "the record is drained on take — never persisted twice"
2081        );
2082    }
2083
2084    /// The manager drains the record from whichever provider minted it.
2085    #[tokio::test]
2086    async fn memory_manager_routes_take_compaction_record() {
2087        let mut mgr = MemoryManager::new();
2088        mgr.add_provider(RollingWindow::new(50)); // mints nothing
2089        mgr.add_provider(Summarizing::new(100).with_summarizer(stub_summarizer("SUMMARY")));
2090        assert!(mgr.take_compaction_record().is_none());
2091        let big = "x".repeat(200);
2092        for i in 0..5u32 {
2093            mgr.sync_all(&big, &big, &metrics_with_input(10 + i)).await;
2094        }
2095        mgr.sync_all(&big, &big, &metrics_with_input(120)).await;
2096        let record = mgr
2097            .take_compaction_record()
2098            .expect("manager must surface the Summarizing provider's record");
2099        assert!(record.starts_with(crate::agentic::SUMMARY_PREFIX));
2100        assert!(mgr.take_compaction_record().is_none());
2101    }
2102
2103    /// The memory.rs:919-class bug (#247 / 18.5): restoring a compressed
2104    /// conversation must rehydrate the summary message and the prev-summary
2105    /// chain instead of rebuilding raw history and silently dropping both.
2106    #[tokio::test]
2107    async fn summarizing_restore_rehydrates_compaction_summary() {
2108        let marked = format!(
2109            "{}\nsummary of earlier work\n{}",
2110            crate::agentic::SUMMARY_PREFIX,
2111            crate::agentic::SUMMARY_END_MARKER
2112        );
2113        let turns = vec![
2114            crate::ConversationTurn::new("old task", "old reply"), // covered by the summary
2115            crate::ConversationTurn::new(marked.clone(), ""),      // the persisted record
2116            crate::ConversationTurn::new("recent task", "recent reply"),
2117        ];
2118        let mut s = Summarizing::new(8_192);
2119        s.restore_turns(&turns);
2120
2121        // The chain is back: the next compression sees the previous summary.
2122        let pre = s.on_pre_compress(&[]).await;
2123        assert!(pre.contains("summary of earlier work"), "got: {pre:?}");
2124
2125        // Working set = [compaction message] + turns recorded after it; the
2126        // summarized turn is not duplicated alongside its own summary.
2127        let msgs = s.build_messages("sys", "next");
2128        assert!(msgs
2129            .iter()
2130            .any(|m| m.content.starts_with(crate::agentic::SUMMARY_PREFIX)));
2131        assert!(!msgs.iter().any(|m| m.content == "old task"));
2132        assert!(msgs.iter().any(|m| m.content == "recent task"));
2133        assert!(msgs.iter().any(|m| m.content == "recent reply"));
2134        // The lone-sided compaction entry never dispatches an empty message.
2135        assert!(!msgs.iter().any(|m| m.content.is_empty()));
2136    }
2137
2138    /// Restore must NOT burn a summarizer call: re-summarizing from scratch
2139    /// on restore is exactly the behavior 18.5 removes. The next live turn
2140    /// compresses if genuinely over budget.
2141    #[tokio::test]
2142    async fn summarizing_restore_never_resummarizes() {
2143        let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
2144        let mut s =
2145            Summarizing::new(10).with_summarizer(capturing_summarizer("SUMMARY", calls.clone()));
2146        let big = "x".repeat(400);
2147        let turns: Vec<crate::ConversationTurn> = (0..6)
2148            .map(|i| crate::ConversationTurn::new(format!("q{i} {big}"), format!("a{i} {big}")))
2149            .collect();
2150        s.restore_turns(&turns); // far over the 8-token budget
2151        assert!(
2152            calls.lock().unwrap().is_empty(),
2153            "restore must never call the summarizer"
2154        );
2155        assert_eq!(s.history.len(), 6, "restored history intact");
2156    }
2157
2158    /// After restore, the rehydrated compaction message chains into the NEXT
2159    /// compression as part of the summarized middle — it is neither the task
2160    /// anchor nor a fresh user message (the F1 self-poisoning shape).
2161    #[tokio::test]
2162    async fn restored_compaction_chains_into_next_compression() {
2163        let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
2164        let mut s =
2165            Summarizing::new(100).with_summarizer(capturing_summarizer("NEW-SUM", calls.clone()));
2166        let marked = format!(
2167            "{}\nCHAIN-ME: facts from the first compaction\n{}",
2168            crate::agentic::SUMMARY_PREFIX,
2169            crate::agentic::SUMMARY_END_MARKER
2170        );
2171        let big = "y".repeat(100);
2172        let mut turns = vec![crate::ConversationTurn::new(marked.clone(), "")];
2173        for i in 0..4 {
2174            turns.push(crate::ConversationTurn::new(
2175                format!("task {i} {big}"),
2176                format!("reply {i} {big}"),
2177            ));
2178        }
2179        s.restore_turns(&turns);
2180
2181        // One live over-budget turn → one pipeline compression.
2182        s.sync_turn(&big, &big, &metrics_with_input(200)).await;
2183        let reqs = calls.lock().unwrap();
2184        assert_eq!(reqs.len(), 1, "one call through the shared path");
2185        assert!(
2186            reqs[0].contains("CHAIN-ME"),
2187            "the previous summary must be summarizer INPUT (the chain), got: {}",
2188            reqs[0]
2189        );
2190        // The Original-Task anchor (the text right under the header) must be
2191        // the first REAL user message, not the compaction message.
2192        let anchored = reqs[0]
2193            .split("## Original Task")
2194            .nth(1)
2195            .and_then(|rest| rest.lines().nth(1).map(str::to_string))
2196            .unwrap_or_default();
2197        assert!(
2198            anchored.starts_with("task 0"),
2199            "task anchor must skip the compaction message, got: {anchored:?}"
2200        );
2201        drop(reqs);
2202        // The old compaction message was replaced by the new one.
2203        let compactions: Vec<&SumTurn> = s
2204            .history
2205            .iter()
2206            .filter(|t| t.user.starts_with(crate::agentic::SUMMARY_PREFIX))
2207            .collect();
2208        assert_eq!(compactions.len(), 1, "exactly one compaction in history");
2209        assert!(compactions[0].user.contains("NEW-SUM"));
2210        assert!(s.prev_summary.contains("NEW-SUM"));
2211    }
2212
2213    /// 18.5 token restore: the anchor comes from the persisted 17.6 column
2214    /// of the LAST measured turn — not a chars/4 re-estimation of history.
2215    #[test]
2216    fn token_budget_restore_anchors_on_column_tokens() {
2217        let mut tb = TokenBudget::new(100_000, 0.80);
2218        let turns = vec![
2219            turn_with_tokens("u1", "a1", Some(900), Some(10)),
2220            turn_with_tokens("u2", "aa", Some(1_000), Some(12)),
2221        ];
2222        tb.restore_turns(&turns);
2223        // 1,000 (measured prompt — already contains everything before it)
2224        // + ceil(2/4) = 1 for the reply not yet inside any prompt.
2225        assert_eq!(tb.last_prompt_tokens, Some(1_000));
2226        assert_eq!(tb.used_tokens(), 1_001);
2227    }
2228
2229    /// NULL columns (pre-17.6 rows, silent backends) fall back to the
2230    /// estimate WITHOUT ever becoming the anchor — an estimate is never
2231    /// presented as a measurement (18.1 honesty).
2232    #[test]
2233    fn token_budget_restore_null_columns_fall_back_to_estimate() {
2234        let mut tb = TokenBudget::new(100_000, 0.80);
2235        let text = "x".repeat(40); // 10 est tokens per side
2236        let turns = vec![
2237            crate::ConversationTurn::new(text.clone(), text.clone()),
2238            crate::ConversationTurn::new(text.clone(), text.clone()),
2239        ];
2240        tb.restore_turns(&turns);
2241        assert_eq!(
2242            tb.last_prompt_tokens, None,
2243            "no measurement in the store → no anchor, only estimates"
2244        );
2245        assert_eq!(tb.used_tokens(), 40, "2 turns × (10 + 10) est tokens");
2246    }
2247
2248    /// Turns recorded after the last measured one (silent backend) extend
2249    /// the delta with estimates while the anchor stays the real measurement.
2250    #[test]
2251    fn token_budget_restore_unmeasured_tail_extends_delta() {
2252        let mut tb = TokenBudget::new(100_000, 0.80);
2253        let turns = vec![
2254            turn_with_tokens("u1", "aaaa", Some(1_000), Some(8)), // reply est 1
2255            crate::ConversationTurn::new("xxxx", "yyyy"),         // est 2
2256        ];
2257        tb.restore_turns(&turns);
2258        assert_eq!(tb.last_prompt_tokens, Some(1_000));
2259        assert_eq!(tb.used_tokens(), 1_003);
2260    }
2261
2262    /// Same column-first restore through `Summarizing`.
2263    #[test]
2264    fn summarizing_restore_anchors_on_column_tokens() {
2265        let mut s = Summarizing::new(100_000);
2266        let turns = vec![
2267            turn_with_tokens("u1", "a1", Some(700), Some(9)),
2268            turn_with_tokens("u2", "aaaa", Some(2_000), Some(11)),
2269        ];
2270        s.restore_turns(&turns);
2271        assert_eq!(s.last_prompt_tokens, Some(2_000));
2272        assert_eq!(s.used_tokens(), 2_001); // 2,000 + ceil(4/4)
2273    }
2274
2275    /// Measurements taken BEFORE the compaction cut describe prompts of a
2276    /// pre-compression shape — they must not anchor the restored (smaller)
2277    /// working set.
2278    #[test]
2279    fn summarizing_restore_ignores_measurements_before_the_cut() {
2280        let marked = format!(
2281            "{}\nolder work compressed\n{}",
2282            crate::agentic::SUMMARY_PREFIX,
2283            crate::agentic::SUMMARY_END_MARKER
2284        );
2285        let mut s = Summarizing::new(100_000);
2286        let turns = vec![
2287            turn_with_tokens("old", "old", Some(50_000), Some(10)),
2288            crate::ConversationTurn::new(marked, ""),
2289            crate::ConversationTurn::new("after", "compaction"), // unmeasured
2290        ];
2291        s.restore_turns(&turns);
2292        assert_eq!(
2293            s.last_prompt_tokens, None,
2294            "a pre-compression measurement must not anchor the cut working set"
2295        );
2296    }
2297
2298    /// A compaction record restored into the default provider stays in the
2299    /// window as a user-side summary — never dispatched with an empty
2300    /// assistant half.
2301    #[test]
2302    fn rolling_window_restores_compaction_record_without_empty_assistant() {
2303        let marked = format!(
2304            "{}\nearlier work\n{}",
2305            crate::agentic::SUMMARY_PREFIX,
2306            crate::agentic::SUMMARY_END_MARKER
2307        );
2308        let mut rw = RollingWindow::new(10);
2309        rw.restore_turns(&[
2310            crate::ConversationTurn::new(marked.clone(), ""),
2311            crate::ConversationTurn::new("next task", "next reply"),
2312        ]);
2313        let msgs = rw.build_messages("sys", "go");
2314        assert!(msgs.iter().any(|m| m.content == marked));
2315        assert!(!msgs.iter().any(|m| m.content.is_empty()));
2316    }
2317
2318    // --- MemoryManager hook coverage ---
2319
2320    #[tokio::test]
2321    async fn memory_manager_on_pre_compress() {
2322        let mgr = MemoryManager::new();
2323        let result = mgr.on_pre_compress(&[]).await;
2324        assert!(result.is_empty());
2325    }
2326
2327    #[tokio::test]
2328    async fn memory_manager_on_session_end() {
2329        let mut mgr = MemoryManager::new();
2330        mgr.add_provider(RollingWindow::new(5));
2331        mgr.on_session_end(&[]).await; // must not panic
2332    }
2333
2334    #[tokio::test]
2335    async fn memory_manager_prefetch_all_empty() {
2336        let mgr = MemoryManager::new();
2337        let result = mgr.prefetch_all("query").await;
2338        assert!(result.is_empty());
2339    }
2340
2341    #[tokio::test]
2342    async fn memory_manager_build_system_prompt_additions_from_note_store() {
2343        let dir = tempfile::tempdir().unwrap();
2344        let path = dir.path().join("NOTES.md");
2345        std::fs::write(&path, "fact one\nfact two").unwrap();
2346        let mut ns = NoteStore::new(path, 2200);
2347        let ctx = SessionContext {
2348            workspace: "/ws".into(),
2349            session_id: "s".into(),
2350        };
2351        ns.initialize(&ctx).await.unwrap();
2352
2353        let mut mgr = MemoryManager::new();
2354        mgr.add_provider(ns);
2355        let additions = mgr.build_system_prompt_additions();
2356        assert!(additions.contains("fact one"));
2357    }
2358
2359    #[tokio::test]
2360    async fn memory_manager_add_note_fails_with_no_note_store() {
2361        let mut mgr = MemoryManager::new();
2362        mgr.add_provider(RollingWindow::new(5));
2363        let err = mgr.add_note("fact").unwrap_err().to_string();
2364        assert!(
2365            err.contains("no note-capable memory provider"),
2366            "guidance error expected: {err}"
2367        );
2368    }
2369
2370    // --- TokenBudget additional coverage ---
2371
2372    #[tokio::test]
2373    async fn token_budget_usage_reporting() {
2374        let tb = TokenBudget::new(1000, 0.80);
2375        let (label, cur, max) = tb.usage().unwrap();
2376        assert_eq!(label, "tokens");
2377        assert_eq!(cur, 0);
2378        assert_eq!(max, 800); // 1000 * 0.80
2379    }
2380
2381    #[tokio::test]
2382    async fn token_budget_does_not_prune_within_budget() {
2383        let mut tb = TokenBudget::new(200, 1.0); // budget = 200
2384        let mut m = dummy_metrics();
2385        m.usage = Some(crate::metrics::TokenUsage {
2386            input_tokens: 50,
2387            output_tokens: 50,
2388        });
2389        tb.sync_turn("q", "a", &m).await; // 50 + 1 reply-est — within budget
2390        assert_eq!(tb.history.len(), 1);
2391    }
2392
2393    /// Without any backend report the provider falls back to summing per-turn
2394    /// content estimates — each turn counted once (no double-count).
2395    #[tokio::test]
2396    async fn token_budget_estimate_fallback_counts_each_turn_once() {
2397        let mut tb = TokenBudget::new(1000, 1.0);
2398        let mut m = dummy_metrics();
2399        m.usage = None; // backend reported nothing
2400        let text = "x".repeat(40); // 40 chars → 10 est tokens per side
2401        tb.sync_turn(&text, &text, &m).await;
2402        tb.sync_turn(&text, &text, &m).await;
2403        assert_eq!(tb.used_tokens(), 40, "2 turns × (10 + 10) est tokens");
2404    }
2405
2406    // --- Summarizing additional coverage ---
2407
2408    /// SEMANTICS CHANGED in Step 18.5: with no summarizer the shared
2409    /// pipeline inserts its STATIC fallback marker (the only surviving form
2410    /// of the old placeholder-discard) — the provider's own "turns
2411    /// summarised" placeholder is deleted with the rest of the legacy path.
2412    #[tokio::test]
2413    async fn summarizing_fallback_placeholder_when_no_summarizer() {
2414        let mut s = Summarizing::new(10); // tiny budget (8) to trigger compression
2415        for i in 0..6u32 {
2416            s.sync_turn(
2417                &format!("question {i}"),
2418                &format!("answer {i}"),
2419                &metrics_with_input(6 + 4 * i),
2420            )
2421            .await;
2422        }
2423        let compaction = s
2424            .history
2425            .iter()
2426            .find(|t| t.user.starts_with(crate::agentic::SUMMARY_PREFIX))
2427            .expect("static fallback marker should be inserted");
2428        assert!(
2429            compaction
2430                .user
2431                .contains("Summary generation was unavailable"),
2432            "got: {}",
2433            compaction.user
2434        );
2435    }
2436
2437    #[tokio::test]
2438    async fn summarizing_usage_reporting() {
2439        let s = Summarizing::new(1000);
2440        let (label, cur, max) = s.usage().unwrap();
2441        assert_eq!(label, "tokens");
2442        assert_eq!(cur, 0);
2443        assert_eq!(max, 800); // 1000 * 0.80
2444    }
2445
2446    #[tokio::test]
2447    async fn summarizing_on_pre_compress_returns_prev_summary() {
2448        let mut s = Summarizing::new(10).with_summarizer(stub_summarizer("PRIOR SUMMARY"));
2449        // Build up history UNDER budget first: the pipeline's boundary needs
2450        // enough turns to leave a summarizable middle (Step 18.5 — head +
2451        // ≥3-message tail are protected), and a too-early trigger would burn
2452        // anti-thrash slots on nothing-to-summarize passes.
2453        for _ in 0..5u32 {
2454            s.sync_turn("question text", "answer text", &metrics_with_input(2))
2455                .await;
2456        }
2457        // Now cross the 8-token budget → compression sets prev_summary.
2458        s.sync_turn("question text", "answer text", &metrics_with_input(50))
2459            .await;
2460        let pre = s.on_pre_compress(&[]).await;
2461        assert!(
2462            pre.contains("PRIOR SUMMARY"),
2463            "compression must have run and set prev_summary, got: {pre:?}"
2464        );
2465    }
2466
2467    // --- RollingWindow additional coverage ---
2468
2469    #[tokio::test]
2470    async fn rolling_window_on_session_end_noop() {
2471        let mut rw = RollingWindow::new(5);
2472        rw.on_session_end(&[]).await; // must not panic
2473    }
2474
2475    #[tokio::test]
2476    async fn rolling_window_add_note_returns_notes_unsupported() {
2477        let mut rw = RollingWindow::new(5);
2478        let err = rw.add_note("fact").unwrap_err();
2479        assert!(err.is::<NotesUnsupported>());
2480    }
2481
2482    #[tokio::test]
2483    async fn rolling_window_replace_and_remove_note_return_notes_unsupported() {
2484        // The trait defaults (Step 19.3) mirror add_note so the manager's
2485        // routing can skip note-less providers for every mutation kind.
2486        let mut rw = RollingWindow::new(5);
2487        assert!(rw
2488            .replace_note("old", "new")
2489            .unwrap_err()
2490            .is::<NotesUnsupported>());
2491        assert!(rw.remove_note("old").unwrap_err().is::<NotesUnsupported>());
2492    }
2493
2494    #[tokio::test]
2495    async fn memory_manager_replace_and_remove_route_to_note_store() {
2496        let dir = tempfile::tempdir().unwrap();
2497        let path = dir.path().join("NOTES.md");
2498        let mut mgr = MemoryManager::new();
2499        // A note-less provider first — routing must skip it (NotesUnsupported).
2500        mgr.add_provider(RollingWindow::new(5));
2501        mgr.add_provider(NoteStore::new(path.clone(), 2200));
2502        let ctx = SessionContext {
2503            workspace: "/ws".into(),
2504            session_id: "s".into(),
2505        };
2506        mgr.initialize_all(&ctx).await;
2507
2508        mgr.add_note("model alpha is the fast tier").unwrap();
2509        mgr.add_note("workspace uses just check").unwrap();
2510
2511        mgr.replace_note("alpha", "model beta is the fast tier")
2512            .unwrap();
2513        let raw = std::fs::read_to_string(&path).unwrap();
2514        assert!(raw.contains("model beta"), "{raw}");
2515        assert!(!raw.contains("model alpha"), "{raw}");
2516
2517        mgr.remove_note("just check").unwrap();
2518        let raw = std::fs::read_to_string(&path).unwrap();
2519        assert!(!raw.contains("just check"), "{raw}");
2520        assert!(raw.contains("model beta"), "other entry untouched: {raw}");
2521
2522        // Real rejections surface: ambiguity / zero-match errors come back.
2523        let err = mgr.remove_note("nonexistent").unwrap_err().to_string();
2524        assert!(err.contains("no entry contains"), "{err}");
2525    }
2526
2527    #[tokio::test]
2528    async fn memory_manager_replace_and_remove_fail_with_no_note_store() {
2529        let mut mgr = MemoryManager::new();
2530        mgr.add_provider(RollingWindow::new(5));
2531        let err = mgr.replace_note("a", "b").unwrap_err().to_string();
2532        assert!(err.contains("no note-capable memory provider"), "{err}");
2533        let err = mgr.remove_note("a").unwrap_err().to_string();
2534        assert!(err.contains("no note-capable memory provider"), "{err}");
2535    }
2536
2537    #[tokio::test]
2538    async fn memory_manager_add_note_routes_to_note_store() {
2539        let dir = tempfile::tempdir().unwrap();
2540        let path = dir.path().join("NOTES.md");
2541        let mut mgr = MemoryManager::new();
2542        mgr.add_provider(RollingWindow::new(5));
2543        mgr.add_provider(NoteStore::new(path.clone(), 2200));
2544        let ctx = SessionContext {
2545            workspace: "/ws".into(),
2546            session_id: "s".into(),
2547        };
2548        mgr.initialize_all(&ctx).await;
2549        mgr.add_note("the answer is 42").unwrap();
2550        let raw = std::fs::read_to_string(&path).unwrap();
2551        assert!(raw.contains("the answer is 42"));
2552    }
2553
2554    /// A provider with a non-`note_store` name that accepts notes — proves
2555    /// the manager no longer special-cases `name() == "note_store"`.
2556    struct CustomNotes {
2557        notes: Vec<String>,
2558    }
2559
2560    #[async_trait]
2561    impl MemoryProvider for CustomNotes {
2562        fn name(&self) -> &str {
2563            "custom_notes"
2564        }
2565        fn build_messages(&self, _system_prompt: &str, _new_task: &str) -> Vec<MemMessage> {
2566            Vec::new()
2567        }
2568        async fn sync_turn(&mut self, _user: &str, _assistant: &str, _metrics: &TurnMetrics) {}
2569        fn add_note(&mut self, fact: &str) -> anyhow::Result<()> {
2570            self.notes.push(fact.to_string());
2571            Ok(())
2572        }
2573    }
2574
2575    #[tokio::test]
2576    async fn memory_manager_add_note_first_ok_wins_regardless_of_name() {
2577        let mut mgr = MemoryManager::new();
2578        mgr.add_provider(RollingWindow::new(5)); // unsupported — skipped
2579        mgr.add_provider(CustomNotes { notes: Vec::new() });
2580        mgr.add_note("routed by capability, not by name").unwrap();
2581    }
2582
2583    #[tokio::test]
2584    async fn memory_manager_add_note_surfaces_curator_error() {
2585        // A real rejection from a note-capable provider (the over-budget
2586        // curator error) must reach the caller, not be swallowed by the
2587        // generic "no provider" message.
2588        let dir = tempfile::tempdir().unwrap();
2589        let path = dir.path().join("NOTES.md");
2590        let mut mgr = MemoryManager::new();
2591        mgr.add_provider(RollingWindow::new(5));
2592        mgr.add_provider(NoteStore::new(path, 40));
2593        let ctx = SessionContext {
2594            workspace: "/ws".into(),
2595            session_id: "s".into(),
2596        };
2597        mgr.initialize_all(&ctx).await;
2598        mgr.add_note("an entry that fits").unwrap();
2599        let err = mgr.add_note(&"x".repeat(80)).unwrap_err().to_string();
2600        assert!(
2601            err.contains("Replace or remove existing entries first"),
2602            "curator error must propagate: {err}"
2603        );
2604        assert!(err.contains("an entry that fits"), "{err}");
2605    }
2606
2607    #[tokio::test]
2608    async fn memory_manager_sync_all() {
2609        let mut mgr = MemoryManager::new();
2610        mgr.add_provider(RollingWindow::new(5));
2611        mgr.sync_all("q", "a", &dummy_metrics()).await;
2612        let usage = mgr.usage();
2613        assert_eq!(usage[0].1, 1); // 1 turn stored
2614    }
2615
2616    #[tokio::test]
2617    async fn memory_manager_reset_all_clears_conversation_history() {
2618        let mut mgr = MemoryManager::new();
2619        mgr.add_provider(RollingWindow::new(5));
2620        mgr.sync_all("old task", "old reply", &dummy_metrics())
2621            .await;
2622
2623        let before = mgr.build_messages("system", "new task");
2624        assert!(before.iter().any(|m| m.content == "old task"));
2625        assert!(before.iter().any(|m| m.content == "old reply"));
2626
2627        mgr.reset_all();
2628
2629        let after = mgr.build_messages("system", "new task");
2630        assert!(!after.iter().any(|m| m.content == "old task"));
2631        assert!(!after.iter().any(|m| m.content == "old reply"));
2632        assert!(after.iter().any(|m| m.content == "new task"));
2633    }
2634
2635    #[tokio::test]
2636    async fn memory_manager_restore_turns_replaces_conversation_history() {
2637        let mut mgr = MemoryManager::new();
2638        mgr.add_provider(RollingWindow::new(5));
2639        mgr.sync_all("old task", "old reply", &dummy_metrics())
2640            .await;
2641
2642        mgr.restore_turns(&[
2643            crate::ConversationTurn::new("restored task", "restored reply"),
2644            crate::ConversationTurn::new("follow up", "followed up"),
2645        ]);
2646
2647        let messages = mgr.build_messages("system", "new task");
2648        assert!(!messages.iter().any(|m| m.content == "old task"));
2649        assert!(!messages.iter().any(|m| m.content == "old reply"));
2650        assert!(messages.iter().any(|m| m.content == "restored task"));
2651        assert!(messages.iter().any(|m| m.content == "restored reply"));
2652        assert!(messages.iter().any(|m| m.content == "follow up"));
2653        assert!(messages.iter().any(|m| m.content == "followed up"));
2654        assert!(messages.iter().any(|m| m.content == "new task"));
2655    }
2656
2657    #[tokio::test]
2658    async fn memory_manager_fallback_with_no_providers() {
2659        let mgr = MemoryManager::new();
2660        let msgs = mgr.build_messages("sys", "task");
2661        assert_eq!(msgs.len(), 2);
2662    }
2663
2664    // -- MemoryIndex (progressive-disclosure memory, Workstream A MVP, #319) --
2665
2666    fn index_ctx(dir: &std::path::Path) -> SessionContext {
2667        SessionContext {
2668            workspace: dir.to_string_lossy().into(),
2669            session_id: "s".into(),
2670        }
2671    }
2672
2673    /// Seed a NOTES file with `n` entries (using NoteStore's own write path so
2674    /// the §-delimited on-disk format is exactly what `MemoryIndex` reads).
2675    async fn seed_notes(path: &std::path::Path, n: usize) {
2676        let mut ns = NoteStore::new(path, NoteStore::DEFAULT_CHAR_LIMIT);
2677        ns.initialize(&index_ctx(path.parent().unwrap()))
2678            .await
2679            .unwrap();
2680        for i in 0..n {
2681            ns.add(&format!("note number {i}\nbody line for {i}"))
2682                .unwrap();
2683        }
2684    }
2685
2686    /// CI-PINNED BUDGET (the modulex `DEFAULT_TOOL_BUDGET` pattern, design
2687    /// §2.3/§3.3): the frozen memory index lists at most `MEMORY_INDEX_BUDGET`
2688    /// items, no matter how many notes exist. Growing the budget is a
2689    /// deliberate edit to the constant — this test fails if a feature grows the
2690    /// default surface as a side effect.
2691    #[tokio::test]
2692    async fn memory_index_stays_under_pinned_budget() {
2693        let dir = tempfile::tempdir().unwrap();
2694        let path = dir.path().join("NOTES.md");
2695        // Seed MANY more notes than the budget.
2696        seed_notes(&path, MEMORY_INDEX_BUDGET + 25).await;
2697
2698        let mut idx = MemoryIndex::new(&path);
2699        idx.initialize(&index_ctx(dir.path())).await.unwrap();
2700
2701        assert!(
2702            idx.rows().len() <= MEMORY_INDEX_BUDGET,
2703            "index surface ({}) exceeds the pinned budget ({MEMORY_INDEX_BUDGET})",
2704            idx.rows().len()
2705        );
2706        assert_eq!(idx.rows().len(), MEMORY_INDEX_BUDGET, "fills to the cap");
2707        // The block names the overflow recovery (recall), never silently drops.
2708        let block = idx.system_prompt_block().unwrap();
2709        assert!(block.contains("use `recall`"), "overflow hint: {block}");
2710    }
2711
2712    #[tokio::test]
2713    async fn memory_index_lists_ids_and_titles_not_bodies() {
2714        let dir = tempfile::tempdir().unwrap();
2715        let path = dir.path().join("NOTES.md");
2716        seed_notes(&path, 2).await;
2717
2718        let mut idx = MemoryIndex::new(&path);
2719        idx.initialize(&index_ctx(dir.path())).await.unwrap();
2720        let block = idx.system_prompt_block().unwrap();
2721
2722        // Ids + first-line titles are listed …
2723        assert!(block.contains("note:1  note number 0"), "got: {block}");
2724        assert!(block.contains("note:2  note number 1"), "got: {block}");
2725        // … but NOT the bodies (those are fetched via memory_fetch).
2726        assert!(!block.contains("body line for 0"), "body leaked: {block}");
2727        assert!(
2728            block.contains("call `memory_fetch`"),
2729            "names the fetch tool: {block}"
2730        );
2731    }
2732
2733    #[tokio::test]
2734    async fn memory_index_is_system_prompt_only() {
2735        // Like NoteStore / SoulProvider — never competes for the
2736        // first-non-empty build_messages slot.
2737        let dir = tempfile::tempdir().unwrap();
2738        let path = dir.path().join("NOTES.md");
2739        seed_notes(&path, 1).await;
2740        let mut idx = MemoryIndex::new(&path);
2741        idx.initialize(&index_ctx(dir.path())).await.unwrap();
2742        assert!(idx.build_messages("sys", "task").is_empty());
2743    }
2744
2745    #[tokio::test]
2746    async fn memory_index_empty_notes_contributes_no_block() {
2747        let dir = tempfile::tempdir().unwrap();
2748        let path = dir.path().join("NOTES.md");
2749        let mut idx = MemoryIndex::new(&path); // no notes file at all
2750        idx.initialize(&index_ctx(dir.path())).await.unwrap();
2751        assert!(idx.system_prompt_block().is_none());
2752    }
2753
2754    /// INERT BY DEFAULT (#319 acceptance): with no MemoryIndex registered (the
2755    /// `disclosure = "frozen"` default), the manager's system-prompt additions
2756    /// and messages are byte-identical to a manager that also omits it — and
2757    /// crucially they carry NO "Memory index" block. Opting in (index mode)
2758    /// ADDS the block, proving the frozen path is genuinely the no-op branch.
2759    /// The MVP changes nothing unless opted in.
2760    #[tokio::test]
2761    async fn disclosure_frozen_default_is_bit_for_bit_unchanged() {
2762        let dir = tempfile::tempdir().unwrap();
2763        let path = dir.path().join("NOTES.md");
2764        seed_notes(&path, 3).await;
2765
2766        // Today's shape: RollingWindow + NoteStore, NO MemoryIndex (frozen).
2767        async fn build(
2768            path: &std::path::Path,
2769            ws: &std::path::Path,
2770            with_index: bool,
2771        ) -> (String, Vec<MemMessage>) {
2772            let mut mgr = MemoryManager::new();
2773            mgr.add_provider(RollingWindow::new(20));
2774            mgr.add_provider(NoteStore::new(path, NoteStore::DEFAULT_CHAR_LIMIT));
2775            if with_index {
2776                mgr.add_provider(MemoryIndex::new(path));
2777            }
2778            mgr.initialize_all(&SessionContext {
2779                workspace: ws.to_string_lossy().into(),
2780                session_id: "s".into(),
2781            })
2782            .await;
2783            (
2784                mgr.build_system_prompt_additions(),
2785                mgr.build_messages("sys", "task"),
2786            )
2787        }
2788
2789        let (frozen_sys, frozen_msgs) = build(&path, dir.path(), false).await;
2790        // Registration is the only difference; omitting MemoryIndex is a no-op.
2791        let (frozen_sys2, frozen_msgs2) = build(&path, dir.path(), false).await;
2792        assert_eq!(frozen_sys, frozen_sys2);
2793        assert_eq!(frozen_msgs, frozen_msgs2);
2794        assert!(
2795            !frozen_sys.contains("Memory index"),
2796            "frozen mode must NOT add the block: {frozen_sys}"
2797        );
2798
2799        // Opting in (index mode) ADDS the index block.
2800        let (index_sys, _index_msgs) = build(&path, dir.path(), true).await;
2801        assert!(
2802            index_sys.contains("Memory index"),
2803            "index mode adds the block: {index_sys}"
2804        );
2805    }
2806}