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. \
1297Use them to actually complete tasks rather than describing what to do.\n\
1298\n\
1299## How to work\n\
1300\n\
1301**One change at a time.** Read only the files you need for the immediate next step. \
1302Make the change. Commit it. Then move to the next step. \
1303Never accumulate multiple uncommitted edits — a committed partial result survives a crash; \
1304an uncommitted complete result does not.\n\
1305\n\
1306**Read minimum, act fast.** Resist reading the entire codebase before acting. \
1307Read the specific file or function you are about to change, make the change, \
1308commit, then read the next thing. The session has a finite context window — \
1309every token spent reading is a token not spent writing.\n\
1310\n\
1311**Prefer edit_file over write_file.** For any existing file, use edit_file \
1312to replace a specific string — you only generate the change, not the whole file. \
1313Only use write_file when creating a new file or when you have generated the \
1314complete contents in full. write_file will refuse if the new content is \
1315significantly shorter than the original.\n\
1316\n\
1317**Stop when blocked.** If the same tool call fails twice in a row with the \
1318same error, stop immediately and tell the user what blocked you and why. \
1319Do not try alternative installation methods, do not loop, do not pivot to \
1320answering a different question. Two identical failures are a signal to report, \
1321not to retry. One sentence explaining the block is worth more than ten more \
1322failed tool calls.\n\
1323\n\
1324**Seek ground truth.** After every action, verify what actually happened — \
1325not what you intended. Do not proceed on assumptions about your own actions; \
1326confirm them. After writing a file, the tool reports the new line count — \
1327check it matches what you expected. After editing code, the tool reports \
1328whether it compiled — if it did not, fix the error before committing. \
1329Before committing, confirm you are on the right branch. \
1330A belief that something worked is worthless; a tool result that confirms it \
1331is ground truth.\n\
1332\n\
1333**Never describe a code change — make it.** Do not paste code into the chat. \
1334If the task requires a code change, call edit_file or write_file immediately. \
1335A markdown code block in the conversation is invisible to the filesystem — \
1336it does not modify any file. Write the code once, into the file, via the tool. \
1337Showing code in text is NOT completing a task; calling the tool IS.\n\
1338\n\
1339**Exploration budget.** Treat read-only rounds (list_dir, read_file) as expensive. \
1340Spend at most three consecutive rounds on exploration before making a write. \
1341Once you have read the file you need, stop reading and call edit_file or write_file. \
1342Continued reading without writing means you are lost — make your best attempt \
1343at the change based on what you have already read, then verify.\n\
1344\n\
1345**Working code first, then the three Cs.** Make it work, then make it right. \
1346Shipping a working result that hardcodes a list or a constant to get there is \
1347fine; functional results come first. Then RETURN to the three Cs: lift hardcoded \
1348knowledge (keyword lists, magic values, language or domain rules) into pure DATA \
1349that is Composed, Configured, and Convention-driven, so a new case is config, \
1350not code. Don't let this block shipping; do circle back and de-hardcode once it \
1351works.";
1352
1353pub struct SoulProvider {
1365 soul: String,
1367 pub source: SoulSource,
1369 override_path: Option<std::path::PathBuf>,
1371}
1372
1373#[derive(Debug, Clone, PartialEq, Eq)]
1375pub enum SoulSource {
1376 Default,
1378 Global,
1380 Workspace,
1382 Explicit(std::path::PathBuf),
1384}
1385
1386impl std::fmt::Display for SoulSource {
1387 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1388 match self {
1389 Self::Default => write!(f, "built-in default"),
1390 Self::Global => write!(f, "~/.newt/soul.md"),
1391 Self::Workspace => write!(f, ".newt/soul.md"),
1392 Self::Explicit(p) => write!(f, "{}", p.display()),
1393 }
1394 }
1395}
1396
1397impl SoulProvider {
1398 pub fn new(override_path: Option<std::path::PathBuf>) -> Self {
1400 Self {
1401 soul: DEFAULT_SOUL.to_string(),
1402 source: SoulSource::Default,
1403 override_path,
1404 }
1405 }
1406
1407 pub fn from_config() -> Self {
1409 let override_path = crate::Config::resolve()
1410 .ok()
1411 .and_then(|c| c.memory)
1412 .and_then(|m| m.soul_file)
1413 .map(std::path::PathBuf::from);
1414 Self::new(override_path)
1415 }
1416
1417 fn try_load(path: &std::path::Path) -> Option<String> {
1418 let text = std::fs::read_to_string(path).ok()?;
1419 let trimmed = text.trim().to_string();
1420 if trimmed.is_empty() {
1421 None
1422 } else {
1423 Some(trimmed)
1424 }
1425 }
1426
1427 pub fn load(&mut self, workspace: &str) {
1429 if let Some(ref p) = self.override_path {
1431 if let Some(text) = Self::try_load(p) {
1432 self.soul = text;
1433 self.source = SoulSource::Explicit(p.clone());
1434 return;
1435 }
1436 }
1437
1438 let ws_soul = std::path::Path::new(workspace)
1440 .join(".newt")
1441 .join("soul.md");
1442 if let Some(text) = Self::try_load(&ws_soul) {
1443 self.soul = text;
1444 self.source = SoulSource::Workspace;
1445 return;
1446 }
1447
1448 if let Some(global) = crate::Config::user_config_path().map(|p| p.with_file_name("soul.md"))
1450 {
1451 if let Some(text) = Self::try_load(&global) {
1452 self.soul = text;
1453 self.source = SoulSource::Global;
1454 }
1455 }
1456
1457 }
1459}
1460
1461#[async_trait]
1462impl MemoryProvider for SoulProvider {
1463 fn name(&self) -> &str {
1464 "soul"
1465 }
1466
1467 async fn initialize(&mut self, ctx: &SessionContext) -> anyhow::Result<()> {
1468 self.load(&ctx.workspace);
1469 tracing::info!(source = %self.source, "soul loaded");
1470 Ok(())
1471 }
1472
1473 fn system_prompt_block(&self) -> Option<String> {
1475 Some(self.soul.clone())
1476 }
1477
1478 fn build_messages(&self, _system_prompt: &str, _new_task: &str) -> Vec<MemMessage> {
1479 Vec::new()
1481 }
1482
1483 async fn sync_turn(&mut self, _user: &str, _assistant: &str, _metrics: &TurnMetrics) {}
1484}
1485
1486#[cfg(test)]
1491mod tests {
1492 use super::*;
1493 use crate::metrics::TokenUsage;
1494
1495 fn dummy_metrics() -> TurnMetrics {
1496 TurnMetrics {
1497 elapsed_ms: 100,
1498 usage: Some(TokenUsage {
1499 input_tokens: 10,
1500 output_tokens: 5,
1501 }),
1502 cost_usd: Some(0.0),
1503 model_id: "test".into(),
1504 endpoint: "http://localhost".into(),
1505 ..Default::default()
1506 }
1507 }
1508
1509 #[tokio::test]
1510 async fn rolling_window_empty_produces_two_messages() {
1511 let rw = RollingWindow::new(5);
1512 let msgs = rw.build_messages("sys", "hello");
1513 assert_eq!(msgs.len(), 2);
1514 assert_eq!(msgs[0].role, Role::System);
1515 assert_eq!(msgs[1].role, Role::User);
1516 assert_eq!(msgs[1].content, "hello");
1517 }
1518
1519 #[tokio::test]
1520 async fn rolling_window_includes_history() {
1521 let mut rw = RollingWindow::new(5);
1522 rw.sync_turn("q1", "a1", &dummy_metrics()).await;
1523 rw.sync_turn("q2", "a2", &dummy_metrics()).await;
1524 let msgs = rw.build_messages("sys", "q3");
1525 assert_eq!(msgs.len(), 6);
1527 assert_eq!(msgs[1].content, "q1");
1528 assert_eq!(msgs[2].content, "a1");
1529 assert_eq!(msgs[5].content, "q3");
1530 }
1531
1532 #[tokio::test]
1533 async fn rolling_window_caps_at_max_turns() {
1534 let mut rw = RollingWindow::new(2);
1535 for i in 0..5u32 {
1536 rw.sync_turn(&format!("q{i}"), &format!("a{i}"), &dummy_metrics())
1537 .await;
1538 }
1539 let msgs = rw.build_messages("sys", "q5");
1540 assert_eq!(msgs.len(), 6);
1542 assert_eq!(msgs[1].content, "q3");
1544 assert_eq!(msgs[3].content, "q4");
1545 assert_eq!(msgs[5].content, "q5");
1546 }
1547
1548 #[tokio::test]
1549 async fn rolling_window_usage_reports_correctly() {
1550 let mut rw = RollingWindow::new(10);
1551 rw.sync_turn("q", "a", &dummy_metrics()).await;
1552 rw.sync_turn("q", "a", &dummy_metrics()).await;
1553 let (label, cur, max) = rw.usage().unwrap();
1554 assert_eq!(label, "turns");
1555 assert_eq!(cur, 2);
1556 assert_eq!(max, 10);
1557 }
1558
1559 #[tokio::test]
1560 async fn memory_manager_routes_to_provider() {
1561 let mut mgr = MemoryManager::new();
1562 mgr.add_provider(RollingWindow::new(5));
1563 let msgs = mgr.build_messages("sys", "hello");
1564 assert_eq!(msgs[0].role, Role::System);
1565 assert_eq!(msgs.last().unwrap().content, "hello");
1566 }
1567
1568 fn metrics_with_input(input_tokens: u32) -> TurnMetrics {
1572 let mut m = dummy_metrics();
1573 m.usage = Some(TokenUsage {
1574 input_tokens,
1575 output_tokens: 20,
1576 });
1577 m
1578 }
1579
1580 #[tokio::test]
1586 async fn token_budget_prunes_oldest_when_over_budget() {
1587 let mut tb = TokenBudget::new(512, 0.99);
1589 let budget = 506;
1590 let big = "x".repeat(200); tb.sync_turn(&big, &big, &metrics_with_input(200)).await;
1592 assert_eq!(tb.history.len(), 1);
1594 tb.sync_turn(&big, &big, &metrics_with_input(520)).await;
1595 assert!(tb.used_tokens() <= budget, "used = {}", tb.used_tokens());
1598 assert_eq!(tb.history.len(), 1, "the oldest turn must have been pruned");
1599 }
1600
1601 #[tokio::test]
1607 async fn token_budget_uses_metrics_when_available() {
1608 let mut tb = TokenBudget::new(1000, 1.0);
1609 let mut m = dummy_metrics();
1610 m.usage = Some(crate::metrics::TokenUsage {
1611 input_tokens: 30,
1612 output_tokens: 20,
1613 });
1614 tb.sync_turn("q", "a", &m).await;
1615 assert_eq!(tb.used_tokens(), 31); }
1617
1618 #[tokio::test]
1626 async fn token_budget_no_runaway_drift_b3_regression() {
1627 let mut tb = TokenBudget::new(8_192, 0.80); let mut old_running_sum: u64 = 0;
1629 let turns = 20u32;
1630 for i in 0..turns {
1631 let input = 2_582 + (4_748 - 2_582) * i / (turns - 1);
1634 old_running_sum += u64::from(input) + 20;
1635 tb.sync_turn("reply ok", "ok", &metrics_with_input(input))
1636 .await;
1637 }
1638 let used = u64::from(tb.used_tokens());
1641 assert!(
1642 (4_748..4_800).contains(&used),
1643 "used must track the last real prompt, got {used}"
1644 );
1645 assert_eq!(tb.history.len(), turns as usize, "no spurious pruning");
1646 assert_eq!(tb.pruned_count, 0);
1647 assert!(
1650 used * 5 < old_running_sum,
1651 "anchored used ({used}) must be at least 5× below the old \
1652 running sum ({old_running_sum})"
1653 );
1654 }
1655
1656 fn stub_summarizer(
1660 reply: &'static str,
1661 ) -> impl Fn(String) -> crate::agentic::SummarizeFuture + Send + Sync {
1662 move |_req: String| -> crate::agentic::SummarizeFuture {
1663 Box::pin(async move { Ok(reply.to_string()) })
1664 }
1665 }
1666
1667 fn capturing_summarizer(
1670 reply: &'static str,
1671 calls: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
1672 ) -> impl Fn(String) -> crate::agentic::SummarizeFuture + Send + Sync {
1673 move |req: String| -> crate::agentic::SummarizeFuture {
1674 calls.lock().unwrap().push(req);
1675 Box::pin(async move { Ok(reply.to_string()) })
1676 }
1677 }
1678
1679 fn turn_with_tokens(
1681 user: &str,
1682 assistant: &str,
1683 tokens_in: Option<u32>,
1684 tokens_out: Option<u32>,
1685 ) -> crate::ConversationTurn {
1686 let mut t = crate::ConversationTurn::new(user, assistant);
1687 t.tokens_in = tokens_in;
1688 t.tokens_out = tokens_out;
1689 t
1690 }
1691
1692 #[tokio::test]
1697 async fn summarizing_no_runaway_compression_b3_regression() {
1698 let mut s = Summarizing::new(8_192) .with_summarizer(stub_summarizer("SUMMARY"));
1700 for i in 0..20u32 {
1701 let input = 2_582 + (4_748 - 2_582) * i / 19;
1702 s.sync_turn("reply ok", "ok", &metrics_with_input(input))
1703 .await;
1704 }
1705 assert_eq!(
1706 s.compress_count, 0,
1707 "prompts never exceeded the budget — compression must not fire"
1708 );
1709 assert!(s.prev_summary.is_empty());
1710 assert_eq!(s.history.len(), 20);
1711 }
1712
1713 #[test]
1719 fn token_budget_construction_injects_budget() {
1720 let tb = TokenBudget::new(24_000, 0.80);
1721 let (label, _used, budget) = tb.usage().unwrap();
1722 assert_eq!(label, "tokens");
1723 assert_eq!(budget, 19_200); }
1725
1726 #[test]
1729 fn token_budget_with_budget_overrides_constructor_value() {
1730 let tb = TokenBudget::new(512, 0.80).with_budget(24_000);
1731 assert_eq!(tb.usage().unwrap().2, 19_200);
1732 let clamped = TokenBudget::new(4_096, 1.0).with_budget(0);
1734 assert_eq!(clamped.max_tokens, 512);
1735 }
1736
1737 #[test]
1738 fn summarizing_construction_injects_budget() {
1739 let s = Summarizing::new(32_768);
1740 let (label, _used, budget) = s.usage().unwrap();
1741 assert_eq!(label, "tokens");
1742 assert_eq!(budget, 26_214); }
1744
1745 #[test]
1746 fn summarizing_with_budget_overrides_constructor_value() {
1747 let s = Summarizing::new(100).with_budget(32_768);
1748 assert_eq!(s.usage().unwrap().2, 26_214);
1749 let clamped = Summarizing::new(100).with_budget(0);
1751 assert_eq!(clamped.max_tokens, 1);
1752 }
1753
1754 #[test]
1759 fn token_budget_does_not_sit_at_static_default_with_capability_data() {
1760 let capability_derived = 24_000;
1763 let tb = TokenBudget::new(capability_derived, 0.80);
1764 let static_default_budget = (DEFAULT_CONTEXT_TOKENS as f32 * 0.80) as usize;
1765 assert_ne!(
1766 tb.usage().unwrap().2,
1767 static_default_budget,
1768 "provider budget must reflect injected capability data, \
1769 not the static default"
1770 );
1771 assert_eq!(tb.usage().unwrap().2, 19_200);
1772 }
1773
1774 #[tokio::test]
1781 async fn soul_provider_uses_default_when_no_file() {
1782 let mut sp = SoulProvider::new(None);
1783 let ctx = SessionContext {
1784 workspace: "/nonexistent".into(),
1785 session_id: "s".into(),
1786 };
1787 sp.initialize(&ctx).await.unwrap();
1788 assert_eq!(sp.source, SoulSource::Default);
1789 let block = sp.system_prompt_block().unwrap();
1790 assert!(block.contains("newt"), "default soul should mention newt");
1791 }
1792
1793 #[test]
1794 fn default_soul_lists_all_current_tools() {
1795 for tool in [
1800 "run_command",
1801 "read_file",
1802 "write_file",
1803 "edit_file",
1804 "list_dir",
1805 "find",
1806 "use_skill",
1807 "web_fetch",
1808 ] {
1809 assert!(
1810 DEFAULT_SOUL.contains(tool),
1811 "DEFAULT_SOUL must advertise `{tool}`"
1812 );
1813 }
1814 }
1815
1816 #[tokio::test]
1817 async fn soul_provider_loads_workspace_soul() {
1818 let dir = tempfile::tempdir().unwrap();
1819 let newt_dir = dir.path().join(".newt");
1821 std::fs::create_dir_all(&newt_dir).unwrap();
1822 std::fs::write(newt_dir.join("soul.md"), "You are a Django expert.").unwrap();
1823
1824 let mut sp = SoulProvider::new(None);
1825 let ctx = SessionContext {
1826 workspace: dir.path().to_string_lossy().into(),
1827 session_id: "s".into(),
1828 };
1829 sp.initialize(&ctx).await.unwrap();
1830 assert_eq!(sp.source, SoulSource::Workspace);
1831 let block = sp.system_prompt_block().unwrap();
1832 assert!(block.contains("Django"), "should use workspace soul");
1833 }
1834
1835 #[tokio::test]
1836 async fn soul_provider_explicit_path_wins() {
1837 let dir = tempfile::tempdir().unwrap();
1838 let soul_file = dir.path().join("custom_soul.md");
1839 std::fs::write(&soul_file, "You are a security auditor.").unwrap();
1840
1841 let ws_dir = tempfile::tempdir().unwrap();
1843 let newt_dir = ws_dir.path().join(".newt");
1844 std::fs::create_dir_all(&newt_dir).unwrap();
1845 std::fs::write(newt_dir.join("soul.md"), "You are a Django expert.").unwrap();
1846
1847 let mut sp = SoulProvider::new(Some(soul_file.clone()));
1848 let ctx = SessionContext {
1849 workspace: ws_dir.path().to_string_lossy().into(),
1850 session_id: "s".into(),
1851 };
1852 sp.initialize(&ctx).await.unwrap();
1853 assert_eq!(sp.source, SoulSource::Explicit(soul_file));
1854 let block = sp.system_prompt_block().unwrap();
1855 assert!(
1856 block.contains("security auditor"),
1857 "explicit path should win"
1858 );
1859 }
1860
1861 #[tokio::test]
1862 async fn soul_provider_empty_workspace_soul_falls_through() {
1863 let dir = tempfile::tempdir().unwrap();
1864 let newt_dir = dir.path().join(".newt");
1865 std::fs::create_dir_all(&newt_dir).unwrap();
1866 std::fs::write(newt_dir.join("soul.md"), " ").unwrap();
1868
1869 let mut sp = SoulProvider::new(None);
1870 let ctx = SessionContext {
1871 workspace: dir.path().to_string_lossy().into(),
1872 session_id: "s".into(),
1873 };
1874 sp.initialize(&ctx).await.unwrap();
1875 assert_eq!(sp.source, SoulSource::Default);
1877 }
1878
1879 #[tokio::test]
1886 async fn summarizing_compresses_when_over_budget() {
1887 let mut s = Summarizing::new(100) .with_summarizer(stub_summarizer("SUMMARY"));
1889
1890 let big = "x".repeat(200);
1891 for i in 0..5u32 {
1893 s.sync_turn(&big, &big, &metrics_with_input(10 + i)).await;
1894 }
1895 assert_eq!(s.compress_count, 0);
1896 s.sync_turn(&big, &big, &metrics_with_input(120)).await;
1898
1899 assert!(s.compress_count >= 1, "compress_count={}", s.compress_count);
1900 assert!(
1902 s.prev_summary.starts_with(crate::agentic::SUMMARY_PREFIX),
1903 "prev_summary must be the marked compaction message"
1904 );
1905 assert!(s.prev_summary.contains("SUMMARY"));
1906 assert!(s.prev_summary.contains(crate::agentic::SUMMARY_END_MARKER));
1907 assert!(
1908 s.history
1909 .iter()
1910 .any(|t| t.user.starts_with(crate::agentic::SUMMARY_PREFIX)
1911 && t.assistant.is_empty()),
1912 "the compaction message must live in history as a lone user entry"
1913 );
1914 }
1915
1916 #[tokio::test]
1920 async fn summarizing_compresses_repeatedly() {
1921 let mut s = Summarizing::new(100).with_summarizer(stub_summarizer("SUMMARY"));
1924 let text = "x".repeat(120);
1925 for i in 0..20u32 {
1926 s.sync_turn(&text, &text, &metrics_with_input(50 * (i + 1)))
1929 .await;
1930 }
1931 assert!(s.compress_count >= 1);
1933 }
1934
1935 #[tokio::test]
1941 async fn summarizing_delegates_to_shared_pipeline() {
1942 let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
1943 let mut s =
1944 Summarizing::new(100).with_summarizer(capturing_summarizer("PIPE-SUM", calls.clone()));
1945 let secret = "sk-aaaaaaaaaaaaaaaaaaaaaaaa";
1946 let big = "x".repeat(200);
1947 s.sync_turn(
1950 "the original task",
1951 &format!("noted {secret}"),
1952 &metrics_with_input(10),
1953 )
1954 .await;
1955 for i in 0..4u32 {
1956 s.sync_turn(&big, &big, &metrics_with_input(11 + i)).await;
1957 }
1958 assert!(calls.lock().unwrap().is_empty(), "under budget — no calls");
1959 s.sync_turn(&big, &big, &metrics_with_input(120)).await;
1960
1961 let reqs = calls.lock().unwrap();
1962 assert_eq!(reqs.len(), 1, "exactly one summarizer call per compression");
1963 let req = &reqs[0];
1964 assert!(
1965 req.contains("## Conversation middle to summarise"),
1966 "must be the shared pipeline's request template"
1967 );
1968 assert!(req.contains("## Original Task"));
1969 assert!(req.contains("the original task"), "task anchored verbatim");
1970 assert!(
1971 !req.contains(secret),
1972 "secret must not reach the summarizer"
1973 );
1974 assert!(req.contains("[REDACTED]"), "shared redaction pass applied");
1975 drop(reqs);
1976 assert!(s.prev_summary.starts_with(crate::agentic::SUMMARY_PREFIX));
1978 assert!(s.prev_summary.contains("PIPE-SUM"));
1979 assert!(s.prev_summary.contains(crate::agentic::SUMMARY_END_MARKER));
1980 }
1981
1982 #[tokio::test]
1984 async fn summarizing_compaction_record_is_minted_once_and_drained() {
1985 let mut s = Summarizing::new(100).with_summarizer(stub_summarizer("SUMMARY"));
1986 assert!(s.take_compaction_record().is_none(), "nothing minted yet");
1987 let big = "x".repeat(200);
1988 for i in 0..5u32 {
1989 s.sync_turn(&big, &big, &metrics_with_input(10 + i)).await;
1990 }
1991 s.sync_turn(&big, &big, &metrics_with_input(120)).await;
1992 let record = s
1993 .take_compaction_record()
1994 .expect("compression must mint a record");
1995 assert!(record.starts_with(crate::agentic::SUMMARY_PREFIX));
1996 assert_eq!(record, s.prev_summary, "the record IS the chain head");
1997 assert!(
1998 s.take_compaction_record().is_none(),
1999 "the record is drained on take — never persisted twice"
2000 );
2001 }
2002
2003 #[tokio::test]
2005 async fn memory_manager_routes_take_compaction_record() {
2006 let mut mgr = MemoryManager::new();
2007 mgr.add_provider(RollingWindow::new(50)); mgr.add_provider(Summarizing::new(100).with_summarizer(stub_summarizer("SUMMARY")));
2009 assert!(mgr.take_compaction_record().is_none());
2010 let big = "x".repeat(200);
2011 for i in 0..5u32 {
2012 mgr.sync_all(&big, &big, &metrics_with_input(10 + i)).await;
2013 }
2014 mgr.sync_all(&big, &big, &metrics_with_input(120)).await;
2015 let record = mgr
2016 .take_compaction_record()
2017 .expect("manager must surface the Summarizing provider's record");
2018 assert!(record.starts_with(crate::agentic::SUMMARY_PREFIX));
2019 assert!(mgr.take_compaction_record().is_none());
2020 }
2021
2022 #[tokio::test]
2026 async fn summarizing_restore_rehydrates_compaction_summary() {
2027 let marked = format!(
2028 "{}\nsummary of earlier work\n{}",
2029 crate::agentic::SUMMARY_PREFIX,
2030 crate::agentic::SUMMARY_END_MARKER
2031 );
2032 let turns = vec![
2033 crate::ConversationTurn::new("old task", "old reply"), crate::ConversationTurn::new(marked.clone(), ""), crate::ConversationTurn::new("recent task", "recent reply"),
2036 ];
2037 let mut s = Summarizing::new(8_192);
2038 s.restore_turns(&turns);
2039
2040 let pre = s.on_pre_compress(&[]).await;
2042 assert!(pre.contains("summary of earlier work"), "got: {pre:?}");
2043
2044 let msgs = s.build_messages("sys", "next");
2047 assert!(msgs
2048 .iter()
2049 .any(|m| m.content.starts_with(crate::agentic::SUMMARY_PREFIX)));
2050 assert!(!msgs.iter().any(|m| m.content == "old task"));
2051 assert!(msgs.iter().any(|m| m.content == "recent task"));
2052 assert!(msgs.iter().any(|m| m.content == "recent reply"));
2053 assert!(!msgs.iter().any(|m| m.content.is_empty()));
2055 }
2056
2057 #[tokio::test]
2061 async fn summarizing_restore_never_resummarizes() {
2062 let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
2063 let mut s =
2064 Summarizing::new(10).with_summarizer(capturing_summarizer("SUMMARY", calls.clone()));
2065 let big = "x".repeat(400);
2066 let turns: Vec<crate::ConversationTurn> = (0..6)
2067 .map(|i| crate::ConversationTurn::new(format!("q{i} {big}"), format!("a{i} {big}")))
2068 .collect();
2069 s.restore_turns(&turns); assert!(
2071 calls.lock().unwrap().is_empty(),
2072 "restore must never call the summarizer"
2073 );
2074 assert_eq!(s.history.len(), 6, "restored history intact");
2075 }
2076
2077 #[tokio::test]
2081 async fn restored_compaction_chains_into_next_compression() {
2082 let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
2083 let mut s =
2084 Summarizing::new(100).with_summarizer(capturing_summarizer("NEW-SUM", calls.clone()));
2085 let marked = format!(
2086 "{}\nCHAIN-ME: facts from the first compaction\n{}",
2087 crate::agentic::SUMMARY_PREFIX,
2088 crate::agentic::SUMMARY_END_MARKER
2089 );
2090 let big = "y".repeat(100);
2091 let mut turns = vec![crate::ConversationTurn::new(marked.clone(), "")];
2092 for i in 0..4 {
2093 turns.push(crate::ConversationTurn::new(
2094 format!("task {i} {big}"),
2095 format!("reply {i} {big}"),
2096 ));
2097 }
2098 s.restore_turns(&turns);
2099
2100 s.sync_turn(&big, &big, &metrics_with_input(200)).await;
2102 let reqs = calls.lock().unwrap();
2103 assert_eq!(reqs.len(), 1, "one call through the shared path");
2104 assert!(
2105 reqs[0].contains("CHAIN-ME"),
2106 "the previous summary must be summarizer INPUT (the chain), got: {}",
2107 &reqs[0]
2108 );
2109 let anchored = reqs[0]
2112 .split("## Original Task")
2113 .nth(1)
2114 .and_then(|rest| rest.lines().nth(1).map(str::to_string))
2115 .unwrap_or_default();
2116 assert!(
2117 anchored.starts_with("task 0"),
2118 "task anchor must skip the compaction message, got: {anchored:?}"
2119 );
2120 drop(reqs);
2121 let compactions: Vec<&SumTurn> = s
2123 .history
2124 .iter()
2125 .filter(|t| t.user.starts_with(crate::agentic::SUMMARY_PREFIX))
2126 .collect();
2127 assert_eq!(compactions.len(), 1, "exactly one compaction in history");
2128 assert!(compactions[0].user.contains("NEW-SUM"));
2129 assert!(s.prev_summary.contains("NEW-SUM"));
2130 }
2131
2132 #[test]
2135 fn token_budget_restore_anchors_on_column_tokens() {
2136 let mut tb = TokenBudget::new(100_000, 0.80);
2137 let turns = vec![
2138 turn_with_tokens("u1", "a1", Some(900), Some(10)),
2139 turn_with_tokens("u2", "aa", Some(1_000), Some(12)),
2140 ];
2141 tb.restore_turns(&turns);
2142 assert_eq!(tb.last_prompt_tokens, Some(1_000));
2145 assert_eq!(tb.used_tokens(), 1_001);
2146 }
2147
2148 #[test]
2152 fn token_budget_restore_null_columns_fall_back_to_estimate() {
2153 let mut tb = TokenBudget::new(100_000, 0.80);
2154 let text = "x".repeat(40); let turns = vec![
2156 crate::ConversationTurn::new(text.clone(), text.clone()),
2157 crate::ConversationTurn::new(text.clone(), text.clone()),
2158 ];
2159 tb.restore_turns(&turns);
2160 assert_eq!(
2161 tb.last_prompt_tokens, None,
2162 "no measurement in the store → no anchor, only estimates"
2163 );
2164 assert_eq!(tb.used_tokens(), 40, "2 turns × (10 + 10) est tokens");
2165 }
2166
2167 #[test]
2170 fn token_budget_restore_unmeasured_tail_extends_delta() {
2171 let mut tb = TokenBudget::new(100_000, 0.80);
2172 let turns = vec![
2173 turn_with_tokens("u1", "aaaa", Some(1_000), Some(8)), crate::ConversationTurn::new("xxxx", "yyyy"), ];
2176 tb.restore_turns(&turns);
2177 assert_eq!(tb.last_prompt_tokens, Some(1_000));
2178 assert_eq!(tb.used_tokens(), 1_003);
2179 }
2180
2181 #[test]
2183 fn summarizing_restore_anchors_on_column_tokens() {
2184 let mut s = Summarizing::new(100_000);
2185 let turns = vec![
2186 turn_with_tokens("u1", "a1", Some(700), Some(9)),
2187 turn_with_tokens("u2", "aaaa", Some(2_000), Some(11)),
2188 ];
2189 s.restore_turns(&turns);
2190 assert_eq!(s.last_prompt_tokens, Some(2_000));
2191 assert_eq!(s.used_tokens(), 2_001); }
2193
2194 #[test]
2198 fn summarizing_restore_ignores_measurements_before_the_cut() {
2199 let marked = format!(
2200 "{}\nolder work compressed\n{}",
2201 crate::agentic::SUMMARY_PREFIX,
2202 crate::agentic::SUMMARY_END_MARKER
2203 );
2204 let mut s = Summarizing::new(100_000);
2205 let turns = vec![
2206 turn_with_tokens("old", "old", Some(50_000), Some(10)),
2207 crate::ConversationTurn::new(marked, ""),
2208 crate::ConversationTurn::new("after", "compaction"), ];
2210 s.restore_turns(&turns);
2211 assert_eq!(
2212 s.last_prompt_tokens, None,
2213 "a pre-compression measurement must not anchor the cut working set"
2214 );
2215 }
2216
2217 #[test]
2221 fn rolling_window_restores_compaction_record_without_empty_assistant() {
2222 let marked = format!(
2223 "{}\nearlier work\n{}",
2224 crate::agentic::SUMMARY_PREFIX,
2225 crate::agentic::SUMMARY_END_MARKER
2226 );
2227 let mut rw = RollingWindow::new(10);
2228 rw.restore_turns(&[
2229 crate::ConversationTurn::new(marked.clone(), ""),
2230 crate::ConversationTurn::new("next task", "next reply"),
2231 ]);
2232 let msgs = rw.build_messages("sys", "go");
2233 assert!(msgs.iter().any(|m| m.content == marked));
2234 assert!(!msgs.iter().any(|m| m.content.is_empty()));
2235 }
2236
2237 #[tokio::test]
2240 async fn memory_manager_on_pre_compress() {
2241 let mgr = MemoryManager::new();
2242 let result = mgr.on_pre_compress(&[]).await;
2243 assert!(result.is_empty());
2244 }
2245
2246 #[tokio::test]
2247 async fn memory_manager_on_session_end() {
2248 let mut mgr = MemoryManager::new();
2249 mgr.add_provider(RollingWindow::new(5));
2250 mgr.on_session_end(&[]).await; }
2252
2253 #[tokio::test]
2254 async fn memory_manager_prefetch_all_empty() {
2255 let mgr = MemoryManager::new();
2256 let result = mgr.prefetch_all("query").await;
2257 assert!(result.is_empty());
2258 }
2259
2260 #[tokio::test]
2261 async fn memory_manager_build_system_prompt_additions_from_note_store() {
2262 let dir = tempfile::tempdir().unwrap();
2263 let path = dir.path().join("NOTES.md");
2264 std::fs::write(&path, "fact one\nfact two").unwrap();
2265 let mut ns = NoteStore::new(path, 2200);
2266 let ctx = SessionContext {
2267 workspace: "/ws".into(),
2268 session_id: "s".into(),
2269 };
2270 ns.initialize(&ctx).await.unwrap();
2271
2272 let mut mgr = MemoryManager::new();
2273 mgr.add_provider(ns);
2274 let additions = mgr.build_system_prompt_additions();
2275 assert!(additions.contains("fact one"));
2276 }
2277
2278 #[tokio::test]
2279 async fn memory_manager_add_note_fails_with_no_note_store() {
2280 let mut mgr = MemoryManager::new();
2281 mgr.add_provider(RollingWindow::new(5));
2282 let err = mgr.add_note("fact").unwrap_err().to_string();
2283 assert!(
2284 err.contains("no note-capable memory provider"),
2285 "guidance error expected: {err}"
2286 );
2287 }
2288
2289 #[tokio::test]
2292 async fn token_budget_usage_reporting() {
2293 let tb = TokenBudget::new(1000, 0.80);
2294 let (label, cur, max) = tb.usage().unwrap();
2295 assert_eq!(label, "tokens");
2296 assert_eq!(cur, 0);
2297 assert_eq!(max, 800); }
2299
2300 #[tokio::test]
2301 async fn token_budget_does_not_prune_within_budget() {
2302 let mut tb = TokenBudget::new(200, 1.0); let mut m = dummy_metrics();
2304 m.usage = Some(crate::metrics::TokenUsage {
2305 input_tokens: 50,
2306 output_tokens: 50,
2307 });
2308 tb.sync_turn("q", "a", &m).await; assert_eq!(tb.history.len(), 1);
2310 }
2311
2312 #[tokio::test]
2315 async fn token_budget_estimate_fallback_counts_each_turn_once() {
2316 let mut tb = TokenBudget::new(1000, 1.0);
2317 let mut m = dummy_metrics();
2318 m.usage = None; let text = "x".repeat(40); tb.sync_turn(&text, &text, &m).await;
2321 tb.sync_turn(&text, &text, &m).await;
2322 assert_eq!(tb.used_tokens(), 40, "2 turns × (10 + 10) est tokens");
2323 }
2324
2325 #[tokio::test]
2332 async fn summarizing_fallback_placeholder_when_no_summarizer() {
2333 let mut s = Summarizing::new(10); for i in 0..6u32 {
2335 s.sync_turn(
2336 &format!("question {i}"),
2337 &format!("answer {i}"),
2338 &metrics_with_input(6 + 4 * i),
2339 )
2340 .await;
2341 }
2342 let compaction = s
2343 .history
2344 .iter()
2345 .find(|t| t.user.starts_with(crate::agentic::SUMMARY_PREFIX))
2346 .expect("static fallback marker should be inserted");
2347 assert!(
2348 compaction
2349 .user
2350 .contains("Summary generation was unavailable"),
2351 "got: {}",
2352 compaction.user
2353 );
2354 }
2355
2356 #[tokio::test]
2357 async fn summarizing_usage_reporting() {
2358 let s = Summarizing::new(1000);
2359 let (label, cur, max) = s.usage().unwrap();
2360 assert_eq!(label, "tokens");
2361 assert_eq!(cur, 0);
2362 assert_eq!(max, 800); }
2364
2365 #[tokio::test]
2366 async fn summarizing_on_pre_compress_returns_prev_summary() {
2367 let mut s = Summarizing::new(10).with_summarizer(stub_summarizer("PRIOR SUMMARY"));
2368 for _ in 0..5u32 {
2373 s.sync_turn("question text", "answer text", &metrics_with_input(2))
2374 .await;
2375 }
2376 s.sync_turn("question text", "answer text", &metrics_with_input(50))
2378 .await;
2379 let pre = s.on_pre_compress(&[]).await;
2380 assert!(
2381 pre.contains("PRIOR SUMMARY"),
2382 "compression must have run and set prev_summary, got: {pre:?}"
2383 );
2384 }
2385
2386 #[tokio::test]
2389 async fn rolling_window_on_session_end_noop() {
2390 let mut rw = RollingWindow::new(5);
2391 rw.on_session_end(&[]).await; }
2393
2394 #[tokio::test]
2395 async fn rolling_window_add_note_returns_notes_unsupported() {
2396 let mut rw = RollingWindow::new(5);
2397 let err = rw.add_note("fact").unwrap_err();
2398 assert!(err.is::<NotesUnsupported>());
2399 }
2400
2401 #[tokio::test]
2402 async fn rolling_window_replace_and_remove_note_return_notes_unsupported() {
2403 let mut rw = RollingWindow::new(5);
2406 assert!(rw
2407 .replace_note("old", "new")
2408 .unwrap_err()
2409 .is::<NotesUnsupported>());
2410 assert!(rw.remove_note("old").unwrap_err().is::<NotesUnsupported>());
2411 }
2412
2413 #[tokio::test]
2414 async fn memory_manager_replace_and_remove_route_to_note_store() {
2415 let dir = tempfile::tempdir().unwrap();
2416 let path = dir.path().join("NOTES.md");
2417 let mut mgr = MemoryManager::new();
2418 mgr.add_provider(RollingWindow::new(5));
2420 mgr.add_provider(NoteStore::new(path.clone(), 2200));
2421 let ctx = SessionContext {
2422 workspace: "/ws".into(),
2423 session_id: "s".into(),
2424 };
2425 mgr.initialize_all(&ctx).await;
2426
2427 mgr.add_note("model alpha is the fast tier").unwrap();
2428 mgr.add_note("workspace uses just check").unwrap();
2429
2430 mgr.replace_note("alpha", "model beta is the fast tier")
2431 .unwrap();
2432 let raw = std::fs::read_to_string(&path).unwrap();
2433 assert!(raw.contains("model beta"), "{raw}");
2434 assert!(!raw.contains("model alpha"), "{raw}");
2435
2436 mgr.remove_note("just check").unwrap();
2437 let raw = std::fs::read_to_string(&path).unwrap();
2438 assert!(!raw.contains("just check"), "{raw}");
2439 assert!(raw.contains("model beta"), "other entry untouched: {raw}");
2440
2441 let err = mgr.remove_note("nonexistent").unwrap_err().to_string();
2443 assert!(err.contains("no entry contains"), "{err}");
2444 }
2445
2446 #[tokio::test]
2447 async fn memory_manager_replace_and_remove_fail_with_no_note_store() {
2448 let mut mgr = MemoryManager::new();
2449 mgr.add_provider(RollingWindow::new(5));
2450 let err = mgr.replace_note("a", "b").unwrap_err().to_string();
2451 assert!(err.contains("no note-capable memory provider"), "{err}");
2452 let err = mgr.remove_note("a").unwrap_err().to_string();
2453 assert!(err.contains("no note-capable memory provider"), "{err}");
2454 }
2455
2456 #[tokio::test]
2457 async fn memory_manager_add_note_routes_to_note_store() {
2458 let dir = tempfile::tempdir().unwrap();
2459 let path = dir.path().join("NOTES.md");
2460 let mut mgr = MemoryManager::new();
2461 mgr.add_provider(RollingWindow::new(5));
2462 mgr.add_provider(NoteStore::new(path.clone(), 2200));
2463 let ctx = SessionContext {
2464 workspace: "/ws".into(),
2465 session_id: "s".into(),
2466 };
2467 mgr.initialize_all(&ctx).await;
2468 mgr.add_note("the answer is 42").unwrap();
2469 let raw = std::fs::read_to_string(&path).unwrap();
2470 assert!(raw.contains("the answer is 42"));
2471 }
2472
2473 struct CustomNotes {
2476 notes: Vec<String>,
2477 }
2478
2479 #[async_trait]
2480 impl MemoryProvider for CustomNotes {
2481 fn name(&self) -> &str {
2482 "custom_notes"
2483 }
2484 fn build_messages(&self, _system_prompt: &str, _new_task: &str) -> Vec<MemMessage> {
2485 Vec::new()
2486 }
2487 async fn sync_turn(&mut self, _user: &str, _assistant: &str, _metrics: &TurnMetrics) {}
2488 fn add_note(&mut self, fact: &str) -> anyhow::Result<()> {
2489 self.notes.push(fact.to_string());
2490 Ok(())
2491 }
2492 }
2493
2494 #[tokio::test]
2495 async fn memory_manager_add_note_first_ok_wins_regardless_of_name() {
2496 let mut mgr = MemoryManager::new();
2497 mgr.add_provider(RollingWindow::new(5)); mgr.add_provider(CustomNotes { notes: Vec::new() });
2499 mgr.add_note("routed by capability, not by name").unwrap();
2500 }
2501
2502 #[tokio::test]
2503 async fn memory_manager_add_note_surfaces_curator_error() {
2504 let dir = tempfile::tempdir().unwrap();
2508 let path = dir.path().join("NOTES.md");
2509 let mut mgr = MemoryManager::new();
2510 mgr.add_provider(RollingWindow::new(5));
2511 mgr.add_provider(NoteStore::new(path, 40));
2512 let ctx = SessionContext {
2513 workspace: "/ws".into(),
2514 session_id: "s".into(),
2515 };
2516 mgr.initialize_all(&ctx).await;
2517 mgr.add_note("an entry that fits").unwrap();
2518 let err = mgr.add_note(&"x".repeat(80)).unwrap_err().to_string();
2519 assert!(
2520 err.contains("Replace or remove existing entries first"),
2521 "curator error must propagate: {err}"
2522 );
2523 assert!(err.contains("an entry that fits"), "{err}");
2524 }
2525
2526 #[tokio::test]
2527 async fn memory_manager_sync_all() {
2528 let mut mgr = MemoryManager::new();
2529 mgr.add_provider(RollingWindow::new(5));
2530 mgr.sync_all("q", "a", &dummy_metrics()).await;
2531 let usage = mgr.usage();
2532 assert_eq!(usage[0].1, 1); }
2534
2535 #[tokio::test]
2536 async fn memory_manager_reset_all_clears_conversation_history() {
2537 let mut mgr = MemoryManager::new();
2538 mgr.add_provider(RollingWindow::new(5));
2539 mgr.sync_all("old task", "old reply", &dummy_metrics())
2540 .await;
2541
2542 let before = mgr.build_messages("system", "new task");
2543 assert!(before.iter().any(|m| m.content == "old task"));
2544 assert!(before.iter().any(|m| m.content == "old reply"));
2545
2546 mgr.reset_all();
2547
2548 let after = mgr.build_messages("system", "new task");
2549 assert!(!after.iter().any(|m| m.content == "old task"));
2550 assert!(!after.iter().any(|m| m.content == "old reply"));
2551 assert!(after.iter().any(|m| m.content == "new task"));
2552 }
2553
2554 #[tokio::test]
2555 async fn memory_manager_restore_turns_replaces_conversation_history() {
2556 let mut mgr = MemoryManager::new();
2557 mgr.add_provider(RollingWindow::new(5));
2558 mgr.sync_all("old task", "old reply", &dummy_metrics())
2559 .await;
2560
2561 mgr.restore_turns(&[
2562 crate::ConversationTurn::new("restored task", "restored reply"),
2563 crate::ConversationTurn::new("follow up", "followed up"),
2564 ]);
2565
2566 let messages = mgr.build_messages("system", "new task");
2567 assert!(!messages.iter().any(|m| m.content == "old task"));
2568 assert!(!messages.iter().any(|m| m.content == "old reply"));
2569 assert!(messages.iter().any(|m| m.content == "restored task"));
2570 assert!(messages.iter().any(|m| m.content == "restored reply"));
2571 assert!(messages.iter().any(|m| m.content == "follow up"));
2572 assert!(messages.iter().any(|m| m.content == "followed up"));
2573 assert!(messages.iter().any(|m| m.content == "new task"));
2574 }
2575
2576 #[tokio::test]
2577 async fn memory_manager_fallback_with_no_providers() {
2578 let mgr = MemoryManager::new();
2579 let msgs = mgr.build_messages("sys", "task");
2580 assert_eq!(msgs.len(), 2);
2581 }
2582
2583 fn index_ctx(dir: &std::path::Path) -> SessionContext {
2586 SessionContext {
2587 workspace: dir.to_string_lossy().into(),
2588 session_id: "s".into(),
2589 }
2590 }
2591
2592 async fn seed_notes(path: &std::path::Path, n: usize) {
2595 let mut ns = NoteStore::new(path, NoteStore::DEFAULT_CHAR_LIMIT);
2596 ns.initialize(&index_ctx(path.parent().unwrap()))
2597 .await
2598 .unwrap();
2599 for i in 0..n {
2600 ns.add(&format!("note number {i}\nbody line for {i}"))
2601 .unwrap();
2602 }
2603 }
2604
2605 #[tokio::test]
2611 async fn memory_index_stays_under_pinned_budget() {
2612 let dir = tempfile::tempdir().unwrap();
2613 let path = dir.path().join("NOTES.md");
2614 seed_notes(&path, MEMORY_INDEX_BUDGET + 25).await;
2616
2617 let mut idx = MemoryIndex::new(&path);
2618 idx.initialize(&index_ctx(dir.path())).await.unwrap();
2619
2620 assert!(
2621 idx.rows().len() <= MEMORY_INDEX_BUDGET,
2622 "index surface ({}) exceeds the pinned budget ({MEMORY_INDEX_BUDGET})",
2623 idx.rows().len()
2624 );
2625 assert_eq!(idx.rows().len(), MEMORY_INDEX_BUDGET, "fills to the cap");
2626 let block = idx.system_prompt_block().unwrap();
2628 assert!(block.contains("use `recall`"), "overflow hint: {block}");
2629 }
2630
2631 #[tokio::test]
2632 async fn memory_index_lists_ids_and_titles_not_bodies() {
2633 let dir = tempfile::tempdir().unwrap();
2634 let path = dir.path().join("NOTES.md");
2635 seed_notes(&path, 2).await;
2636
2637 let mut idx = MemoryIndex::new(&path);
2638 idx.initialize(&index_ctx(dir.path())).await.unwrap();
2639 let block = idx.system_prompt_block().unwrap();
2640
2641 assert!(block.contains("note:1 note number 0"), "got: {block}");
2643 assert!(block.contains("note:2 note number 1"), "got: {block}");
2644 assert!(!block.contains("body line for 0"), "body leaked: {block}");
2646 assert!(
2647 block.contains("call `memory_fetch`"),
2648 "names the fetch tool: {block}"
2649 );
2650 }
2651
2652 #[tokio::test]
2653 async fn memory_index_is_system_prompt_only() {
2654 let dir = tempfile::tempdir().unwrap();
2657 let path = dir.path().join("NOTES.md");
2658 seed_notes(&path, 1).await;
2659 let mut idx = MemoryIndex::new(&path);
2660 idx.initialize(&index_ctx(dir.path())).await.unwrap();
2661 assert!(idx.build_messages("sys", "task").is_empty());
2662 }
2663
2664 #[tokio::test]
2665 async fn memory_index_empty_notes_contributes_no_block() {
2666 let dir = tempfile::tempdir().unwrap();
2667 let path = dir.path().join("NOTES.md");
2668 let mut idx = MemoryIndex::new(&path); idx.initialize(&index_ctx(dir.path())).await.unwrap();
2670 assert!(idx.system_prompt_block().is_none());
2671 }
2672
2673 #[tokio::test]
2680 async fn disclosure_frozen_default_is_bit_for_bit_unchanged() {
2681 let dir = tempfile::tempdir().unwrap();
2682 let path = dir.path().join("NOTES.md");
2683 seed_notes(&path, 3).await;
2684
2685 async fn build(
2687 path: &std::path::Path,
2688 ws: &std::path::Path,
2689 with_index: bool,
2690 ) -> (String, Vec<MemMessage>) {
2691 let mut mgr = MemoryManager::new();
2692 mgr.add_provider(RollingWindow::new(20));
2693 mgr.add_provider(NoteStore::new(path, NoteStore::DEFAULT_CHAR_LIMIT));
2694 if with_index {
2695 mgr.add_provider(MemoryIndex::new(path));
2696 }
2697 mgr.initialize_all(&SessionContext {
2698 workspace: ws.to_string_lossy().into(),
2699 session_id: "s".into(),
2700 })
2701 .await;
2702 (
2703 mgr.build_system_prompt_additions(),
2704 mgr.build_messages("sys", "task"),
2705 )
2706 }
2707
2708 let (frozen_sys, frozen_msgs) = build(&path, dir.path(), false).await;
2709 let (frozen_sys2, frozen_msgs2) = build(&path, dir.path(), false).await;
2711 assert_eq!(frozen_sys, frozen_sys2);
2712 assert_eq!(frozen_msgs, frozen_msgs2);
2713 assert!(
2714 !frozen_sys.contains("Memory index"),
2715 "frozen mode must NOT add the block: {frozen_sys}"
2716 );
2717
2718 let (index_sys, _index_msgs) = build(&path, dir.path(), true).await;
2720 assert!(
2721 index_sys.contains("Memory index"),
2722 "index mode adds the block: {index_sys}"
2723 );
2724 }
2725}