1use async_trait::async_trait;
27
28use crate::agentic::compress::is_compaction_text;
29use crate::metrics::TurnMetrics;
30
31#[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#[derive(Debug, Clone)]
88pub struct SessionContext {
89 pub workspace: String,
91 pub session_id: String,
93}
94
95#[async_trait]
104pub trait MemoryProvider: Send + Sync {
105 fn name(&self) -> &str;
107
108 async fn initialize(&mut self, _ctx: &SessionContext) -> anyhow::Result<()> {
111 Ok(())
112 }
113
114 fn system_prompt_block(&self) -> Option<String> {
120 None
121 }
122
123 async fn prefetch(&self, _query: &str) -> anyhow::Result<String> {
127 Ok(String::new())
128 }
129
130 fn build_messages(&self, system_prompt: &str, new_task: &str) -> Vec<MemMessage>;
140
141 async fn sync_turn(&mut self, user: &str, assistant: &str, metrics: &TurnMetrics);
146
147 fn reset(&mut self) {}
151
152 fn restore_turns(&mut self, _turns: &[crate::ConversationTurn]) {}
159
160 fn take_compaction_record(&mut self) -> Option<String> {
168 None
169 }
170
171 async fn on_pre_compress(&self, _messages: &[MemMessage]) -> String {
175 String::new()
176 }
177
178 async fn on_session_end(&mut self, _messages: &[MemMessage]) {}
180
181 fn usage(&self) -> Option<(String, usize, usize)> {
184 None
185 }
186
187 fn add_note(&mut self, _fact: &str) -> anyhow::Result<()> {
193 Err(NotesUnsupported.into())
194 }
195
196 fn replace_note(&mut self, _old_substring: &str, _new_text: &str) -> anyhow::Result<()> {
200 Err(NotesUnsupported.into())
201 }
202
203 fn remove_note(&mut self, _substring: &str) -> anyhow::Result<()> {
207 Err(NotesUnsupported.into())
208 }
209}
210
211#[derive(Debug, thiserror::Error)]
218#[error("this memory provider does not support persistent notes")]
219pub struct NotesUnsupported;
220
221pub 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 pub fn add_provider(&mut self, p: impl MemoryProvider + 'static) {
243 self.providers.push(Box::new(p));
244 }
245
246 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 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 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 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 vec![
293 MemMessage::system(system_prompt),
294 MemMessage::user(new_task),
295 ]
296 }
297
298 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 pub fn reset_all(&mut self) {
307 for p in &mut self.providers {
308 p.reset();
309 }
310 }
311
312 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 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 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 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 pub fn usage(&self) -> Vec<(String, usize, usize)> {
349 self.providers.iter().filter_map(|p| p.usage()).collect()
350 }
351
352 pub fn add_note(&mut self, fact: &str) -> anyhow::Result<()> {
360 self.route_note_op(|p| p.add_note(fact))
361 }
362
363 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 pub fn remove_note(&mut self, substring: &str) -> anyhow::Result<()> {
372 self.route_note_op(|p| p.remove_note(substring))
373 }
374
375 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
409pub struct RollingWindow {
418 max_turns: usize,
419 history: Vec<(String, String)>,
421}
422
423impl RollingWindow {
424 pub fn new(max_turns: usize) -> Self {
426 Self {
427 max_turns: max_turns.max(1),
428 history: Vec::new(),
429 }
430 }
431
432 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 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 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 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
506pub const DEFAULT_CONTEXT_TOKENS: u32 = 8_192;
521
522#[derive(Debug, Clone)]
524struct TurnRecord {
525 user: String,
526 assistant: String,
527 est_tokens: u32,
532}
533
534fn 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
540fn 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
567pub struct TokenBudget {
592 max_tokens: u32,
594 threshold_pct: f32,
596 history: Vec<TurnRecord>,
597 pruned_count: usize,
598 last_prompt_tokens: Option<u32>,
600 delta_since_prompt: i64,
602 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 #[must_use]
622 pub fn with_estimation(mut self, est: crate::tokens::TokenEstimation) -> Self {
623 self.est = est;
624 self
625 }
626
627 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 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 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 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 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 self.last_prompt_tokens = Some(u.input_tokens);
702 self.delta_since_prompt = self.est.tokens_for_chars(assistant.len()) as i64;
703 }
704 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 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
756pub use crate::notes::NoteStore;
765
766pub const MEMORY_INDEX_BUDGET: usize = 12;
779
780pub struct MemoryIndex {
806 rows: Vec<(usize, String)>,
808 truncated: bool,
811 notes_path: std::path::PathBuf,
813}
814
815impl MemoryIndex {
816 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 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 pub fn rows(&self) -> &[(usize, String)] {
838 &self.rows
839 }
840
841 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 let mut notes = NoteStore::new(self.notes_path.clone(), NoteStore::DEFAULT_CHAR_LIMIT);
863 notes.initialize(ctx).await?;
864 self.capture_from(¬es);
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 Vec::new()
893 }
894
895 async fn sync_turn(&mut self, _user: &str, _assistant: &str, _metrics: &TurnMetrics) {}
896}
897
898#[derive(Clone)]
904struct SumTurn {
905 user: String,
906 assistant: String,
907 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 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
944fn 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 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
977pub struct Summarizing {
1007 max_tokens: u32,
1008 threshold_pct: f32,
1009 history: Vec<SumTurn>,
1010 prev_summary: String,
1013 compress_count: usize,
1015 state: crate::agentic::CompressState,
1018 summarizer: Option<crate::agentic::Summarizer>,
1021 last_prompt_tokens: Option<u32>,
1023 delta_since_prompt: i64,
1025 pending_record: Option<String>,
1028 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 #[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 pub fn with_budget(mut self, tokens: u32) -> Self {
1072 self.max_tokens = tokens.max(1);
1073 self
1074 }
1075
1076 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 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 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 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 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 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 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 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 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 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 self.state.reset();
1250 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
1283pub 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
1362pub 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
1402pub struct SoulProvider {
1414 soul: String,
1416 pub source: SoulSource,
1418 override_path: Option<std::path::PathBuf>,
1420}
1421
1422#[derive(Debug, Clone, PartialEq, Eq)]
1424pub enum SoulSource {
1425 Default,
1427 Global,
1429 Workspace,
1431 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 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 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 pub fn load(&mut self, workspace: &str) {
1478 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 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 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 }
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 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 Vec::new()
1530 }
1531
1532 async fn sync_turn(&mut self, _user: &str, _assistant: &str, _metrics: &TurnMetrics) {}
1533}
1534
1535#[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 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 assert_eq!(msgs.len(), 6);
1591 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 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 #[tokio::test]
1635 async fn token_budget_prunes_oldest_when_over_budget() {
1636 let mut tb = TokenBudget::new(512, 0.99);
1638 let budget = 506;
1639 let big = "x".repeat(200); tb.sync_turn(&big, &big, &metrics_with_input(200)).await;
1641 assert_eq!(tb.history.len(), 1);
1643 tb.sync_turn(&big, &big, &metrics_with_input(520)).await;
1644 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 #[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); }
1666
1667 #[tokio::test]
1675 async fn token_budget_no_runaway_drift_b3_regression() {
1676 let mut tb = TokenBudget::new(8_192, 0.80); let mut old_running_sum: u64 = 0;
1678 let turns = 20u32;
1679 for i in 0..turns {
1680 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 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 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 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 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 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 #[tokio::test]
1746 async fn summarizing_no_runaway_compression_b3_regression() {
1747 let mut s = Summarizing::new(8_192) .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 #[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); }
1774
1775 #[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 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); }
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 let clamped = Summarizing::new(100).with_budget(0);
1800 assert_eq!(clamped.max_tokens, 1);
1801 }
1802
1803 #[test]
1808 fn token_budget_does_not_sit_at_static_default_with_capability_data() {
1809 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 #[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 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 #[test]
1874 fn coach_soul_forbids_mutation_and_mandates_advice() {
1875 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 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 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 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 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 assert_eq!(sp.source, SoulSource::Default);
1958 }
1959
1960 #[tokio::test]
1967 async fn summarizing_compresses_when_over_budget() {
1968 let mut s = Summarizing::new(100) .with_summarizer(stub_summarizer("SUMMARY"));
1970
1971 let big = "x".repeat(200);
1972 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 s.sync_turn(&big, &big, &metrics_with_input(120)).await;
1979
1980 assert!(s.compress_count >= 1, "compress_count={}", s.compress_count);
1981 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 #[tokio::test]
2001 async fn summarizing_compresses_repeatedly() {
2002 let mut s = Summarizing::new(100).with_summarizer(stub_summarizer("SUMMARY"));
2005 let text = "x".repeat(120);
2006 for i in 0..20u32 {
2007 s.sync_turn(&text, &text, &metrics_with_input(50 * (i + 1)))
2010 .await;
2011 }
2012 assert!(s.compress_count >= 1);
2014 }
2015
2016 #[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 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 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 #[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 #[tokio::test]
2086 async fn memory_manager_routes_take_compaction_record() {
2087 let mut mgr = MemoryManager::new();
2088 mgr.add_provider(RollingWindow::new(50)); 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 #[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"), crate::ConversationTurn::new(marked.clone(), ""), crate::ConversationTurn::new("recent task", "recent reply"),
2117 ];
2118 let mut s = Summarizing::new(8_192);
2119 s.restore_turns(&turns);
2120
2121 let pre = s.on_pre_compress(&[]).await;
2123 assert!(pre.contains("summary of earlier work"), "got: {pre:?}");
2124
2125 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 assert!(!msgs.iter().any(|m| m.content.is_empty()));
2136 }
2137
2138 #[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); 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 #[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 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 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 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 #[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 assert_eq!(tb.last_prompt_tokens, Some(1_000));
2226 assert_eq!(tb.used_tokens(), 1_001);
2227 }
2228
2229 #[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); 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 #[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)), crate::ConversationTurn::new("xxxx", "yyyy"), ];
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 #[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); }
2274
2275 #[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"), ];
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 #[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 #[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; }
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 #[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); }
2380
2381 #[tokio::test]
2382 async fn token_budget_does_not_prune_within_budget() {
2383 let mut tb = TokenBudget::new(200, 1.0); 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; assert_eq!(tb.history.len(), 1);
2391 }
2392
2393 #[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; let text = "x".repeat(40); 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 #[tokio::test]
2413 async fn summarizing_fallback_placeholder_when_no_summarizer() {
2414 let mut s = Summarizing::new(10); 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); }
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 for _ in 0..5u32 {
2454 s.sync_turn("question text", "answer text", &metrics_with_input(2))
2455 .await;
2456 }
2457 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 #[tokio::test]
2470 async fn rolling_window_on_session_end_noop() {
2471 let mut rw = RollingWindow::new(5);
2472 rw.on_session_end(&[]).await; }
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 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 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 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 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)); 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 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); }
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 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 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 #[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_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 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 assert!(block.contains("note:1 note number 0"), "got: {block}");
2724 assert!(block.contains("note:2 note number 1"), "got: {block}");
2725 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 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); idx.initialize(&index_ctx(dir.path())).await.unwrap();
2751 assert!(idx.system_prompt_block().is_none());
2752 }
2753
2754 #[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 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 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 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}