1pub const MAX_PART_CHARS: usize = 9_000;
23
24pub const HOOK_PARTS: usize = 8;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
32pub enum Reason {
33 Startup,
35 Clear,
37 Compact,
39}
40
41impl Reason {
42 pub fn from_hook_source(source: &str) -> Option<Reason> {
46 match source {
47 "startup" => Some(Reason::Startup),
48 "clear" => Some(Reason::Clear),
49 "compact" => Some(Reason::Compact),
50 _ => None,
51 }
52 }
53
54 pub fn as_str(self) -> &'static str {
55 match self {
56 Reason::Startup => "startup",
57 Reason::Clear => "clear",
58 Reason::Compact => "compact",
59 }
60 }
61
62 fn clause(self) -> &'static str {
63 match self {
64 Reason::Startup => "This is the start of a new session.",
65 Reason::Clear => "The conversation was just cleared.",
66 Reason::Compact => "Your recent conversation was just compacted into a summary.",
67 }
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum Tier {
75 Global,
76 Personal,
77}
78
79impl Tier {
80 pub fn as_str(self) -> &'static str {
81 match self {
82 Tier::Global => "global",
83 Tier::Personal => "personal",
84 }
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct Entry {
92 pub label: String,
94 pub tier: Tier,
95 pub text: String,
97 pub name: String,
99 pub system: bool,
101 pub bundle_id: Option<String>,
103 pub path: Option<String>,
105}
106
107#[cfg(test)]
114pub fn compose(entries: &[Entry], reason: Reason, running_summary: Option<&str>) -> Option<String> {
115 compose_items(entries, reason, running_summary).map(|c| c.text)
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub struct ItemSpan {
122 pub entry: Option<usize>,
124 pub start: usize,
125 pub end: usize,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct Composed {
131 pub text: String,
132 pub spans: Vec<ItemSpan>,
134}
135
136struct Tracked {
138 out: String,
139 chars: usize,
140}
141
142impl Tracked {
143 fn push(&mut self, s: &str) -> (usize, usize) {
145 let start = self.chars;
146 self.out.push_str(s);
147 self.chars += s.chars().count();
148 (start, self.chars)
149 }
150}
151
152#[cfg(test)]
155pub fn compose_items(entries: &[Entry], reason: Reason, running_summary: Option<&str>) -> Option<Composed> {
156 compose_items_with(entries, reason, running_summary, false)
157}
158
159pub fn compose_items_with(
164 entries: &[Entry],
165 reason: Reason,
166 running_summary: Option<&str>,
167 global_in_startup_file: bool,
168) -> Option<Composed> {
169 let global: Vec<usize> = (0..entries.len()).filter(|&i| entries[i].tier == Tier::Global).collect();
170 let personal: Vec<usize> = (0..entries.len()).filter(|&i| entries[i].tier == Tier::Personal).collect();
171 let summary = running_summary.filter(|s| !s.trim().is_empty() && reason == Reason::Compact);
172 if global.is_empty() && personal.is_empty() && summary.is_none() {
173 return None;
174 }
175
176 let mut b = Tracked { out: String::new(), chars: 0 };
177 if global_in_startup_file {
178 b.push(&format!(
179 "AgentMux memory for this agent. {} Your Global Memory is already in your startup \
180 instructions, so it isn't repeated here. Read all of this now — it is your complete \
181 Personal Memory, not just the index.\n\n",
182 reason.clause()
183 ));
184 } else {
185 b.push(&format!(
186 "AgentMux memory for this agent. {} Read all of it now — it is your complete Global \
187 Memory and Personal Memory, not just the index.\n\n",
188 reason.clause()
189 ));
190 }
191 let mut spans = Vec::new();
192 let wrote_section = push_sections(&mut b, entries, &global, &personal, &mut spans);
193 if let Some(summary) = summary {
194 if wrote_section {
195 b.push("\n");
196 }
197 let (start, end) = b.push(summary);
198 spans.push(ItemSpan { entry: None, start, end });
199 }
200 Some(Composed { text: b.out, spans })
201}
202
203fn push_sections(b: &mut Tracked, entries: &[Entry], global: &[usize], personal: &[usize], spans: &mut Vec<ItemSpan>) -> bool {
209 let mut wrote_section = false;
210 let sections = [
211 ("Global Memory", global, crate::backend::storage::bundles::GLOBAL_SECTION_SEPARATOR),
212 ("Personal Memory", personal, "\n---\n"),
213 ];
214 for (heading, list, separator) in sections {
215 if list.is_empty() {
216 continue;
217 }
218 if wrote_section {
219 b.push("\n");
220 }
221 let noun = if list.len() == 1 { "entry" } else { "entries" };
222 b.push(&format!("# {heading} ({} {noun})\n", list.len()));
223 for (k, &i) in list.iter().enumerate() {
224 if k > 0 {
225 b.push(separator);
226 }
227 let (start, end) = b.push(&entries[i].text);
228 spans.push(ItemSpan { entry: Some(i), start, end });
229 }
230 b.push("\n");
231 wrote_section = true;
232 }
233 wrote_section
234}
235
236pub const REINJECTION_SIGNATURE: &str = "Your memory was reinjected because your working context was just reset.";
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub enum FallbackReason {
245 Compaction,
247 FreshSession,
249}
250
251impl FallbackReason {
252 pub fn from_wire(reason: &str) -> Option<Self> {
253 match reason {
254 "compaction" => Some(Self::Compaction),
255 "fresh_session" => Some(Self::FreshSession),
256 _ => None,
257 }
258 }
259
260 pub fn clause(self) -> &'static str {
262 match self {
263 Self::Compaction => "Your recent conversation was just compacted into a summary.",
264 Self::FreshSession => {
265 "AgentMux could not resume this agent's prior session, so a fresh one was started. Any record \
266 of the prior conversation AgentMux had came with your first message, in an \
267 <agentmux-continuation> block."
268 }
269 }
270 }
271}
272
273pub fn compose_fallback(entries: &[Entry], reason: FallbackReason) -> Option<Composed> {
282 let global: Vec<usize> = (0..entries.len()).filter(|&i| entries[i].tier == Tier::Global).collect();
283 let personal: Vec<usize> = (0..entries.len()).filter(|&i| entries[i].tier == Tier::Personal).collect();
284 if global.is_empty() && personal.is_empty() {
285 return None;
286 }
287 let mut b = Tracked { out: String::new(), chars: 0 };
288 b.push(&format!(
289 "<system-reminder>\n{REINJECTION_SIGNATURE} {} Below is your\n\
290 complete Global Memory and Personal Memory content — read all of it now,\n\
291 not just the index.\n\n",
292 reason.clause()
293 ));
294 let mut spans = Vec::new();
295 push_sections(&mut b, entries, &global, &personal, &mut spans);
296 b.push("</system-reminder>\n");
297 Some(Composed { text: b.out, spans })
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302pub enum Delivered {
303 Full,
304 Partial(usize),
306 Omitted,
307}
308
309impl Delivered {
310 pub fn of(span: &ItemSpan, delivered_chars: usize) -> Delivered {
313 if span.end <= delivered_chars {
314 Delivered::Full
315 } else if span.start >= delivered_chars {
316 Delivered::Omitted
317 } else {
318 Delivered::Partial(delivered_chars - span.start)
319 }
320 }
321
322 pub fn as_str(self) -> &'static str {
323 match self {
324 Delivered::Full => "full",
325 Delivered::Partial(_) => "partial",
326 Delivered::Omitted => "omitted",
327 }
328 }
329}
330
331#[cfg(test)]
338pub fn split_into_parts(text: &str, max_chars: usize, max_parts: usize) -> Vec<String> {
339 split_into_parts_counted(text, max_chars, max_parts).0
340}
341
342pub fn split_into_parts_counted(text: &str, max_chars: usize, max_parts: usize) -> (Vec<String>, usize) {
345 const LABEL_ROOM: usize = 40;
347 const CUT_NOTE_ROOM: usize = 120;
348 let budget = max_chars.saturating_sub(LABEL_ROOM).max(1);
349
350 let chars: Vec<char> = text.chars().collect();
351 let mut chunks: Vec<String> = Vec::new();
352 let mut at = 0;
353 while at < chars.len() {
354 let end = (at + budget).min(chars.len());
355 let cut = if end == chars.len() {
356 end
357 } else {
358 (at + budget / 2..end).rev().find(|&i| chars[i] == '\n').map_or(end, |i| i + 1)
360 };
361 chunks.push(chars[at..cut].iter().collect());
362 at = cut;
363 }
364
365 let mut delivered = chars.len();
366 if chunks.len() > max_parts {
367 let kept: usize = chunks[..max_parts].iter().map(|c| c.chars().count()).sum();
368 let omitted = chars.len() - kept;
369 chunks.truncate(max_parts);
370 let last = chunks.last_mut().expect("max_parts >= 1");
371 let keep = last.chars().count().min(budget.saturating_sub(CUT_NOTE_ROOM));
373 let dropped = last.chars().count() - keep;
374 *last = last.chars().take(keep).collect();
375 last.push_str(&format!(
376 "\n\n[AgentMux: memory cut here — {} more characters did not fit in one delivery.]",
377 omitted + dropped
378 ));
379 delivered = kept - dropped;
380 }
381
382 let of = chunks.len();
383 let parts = chunks
384 .into_iter()
385 .enumerate()
386 .map(|(i, c)| format!("[AgentMux memory — part {} of {of}]\n{c}", i + 1))
387 .collect();
388 (parts, delivered)
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 fn entry(label: &str, tier: Tier, text: &str) -> Entry {
396 Entry { label: label.into(), tier, text: text.into(), name: label.into(), system: false, bundle_id: None, path: None }
397 }
398
399 #[test]
400 fn spans_point_at_each_item_in_the_composed_text() {
401 let entries = [
402 entry("A", Tier::Global, "alpha"),
403 entry("n.md", Tier::Personal, "notes"),
404 entry("B", Tier::Global, "beta"),
405 ];
406 let c = compose_items(&entries, Reason::Compact, Some("summary")).unwrap();
407 assert_eq!(c.text, compose(&entries, Reason::Compact, Some("summary")).unwrap(), "same text as compose");
408 let chars: Vec<char> = c.text.chars().collect();
409 let at = |s: &ItemSpan| chars[s.start..s.end].iter().collect::<String>();
410 let order: Vec<Option<usize>> = c.spans.iter().map(|s| s.entry).collect();
412 assert_eq!(order, vec![Some(0), Some(2), Some(1), None]);
413 assert_eq!(c.spans.iter().map(at).collect::<Vec<_>>(), vec!["alpha", "beta", "notes", "summary"]);
414 }
415
416 #[test]
417 fn spans_are_counted_in_characters_not_bytes() {
418 let entries = [entry("é", Tier::Global, "ééé"), entry("x", Tier::Personal, "xyz")];
419 let c = compose_items(&entries, Reason::Startup, None).unwrap();
420 let chars: Vec<char> = c.text.chars().collect();
421 assert_eq!(chars[c.spans[1].start..c.spans[1].end].iter().collect::<String>(), "xyz");
422 }
423
424 #[test]
425 fn delivered_status_follows_the_cut() {
426 let span = ItemSpan { entry: Some(0), start: 100, end: 200 };
427 assert_eq!(Delivered::of(&span, 200), Delivered::Full);
428 assert_eq!(Delivered::of(&span, 5_000), Delivered::Full);
429 assert_eq!(Delivered::of(&span, 150), Delivered::Partial(50));
430 assert_eq!(Delivered::of(&span, 100), Delivered::Omitted);
431 assert_eq!(Delivered::of(&span, 10), Delivered::Omitted);
432 }
433
434 #[test]
435 fn counted_split_reports_everything_when_uncut() {
436 let text = "z".repeat(20_000);
437 let (parts, delivered) = split_into_parts_counted(&text, MAX_PART_CHARS, HOOK_PARTS);
438 assert_eq!(delivered, text.chars().count());
439 assert_eq!(parts, split_into_parts(&text, MAX_PART_CHARS, HOOK_PARTS));
440 }
441
442 #[test]
443 fn counted_split_reports_exactly_the_delivered_prefix_when_cut() {
444 let text = (0..200_000).map(|i| char::from(b'a' + (i % 26) as u8)).collect::<String>();
445 let (parts, delivered) = split_into_parts_counted(&text, MAX_PART_CHARS, HOOK_PARTS);
446 assert!(delivered < text.chars().count());
447 let carried: String = parts
448 .iter()
449 .map(|p| p.split_once('\n').unwrap().1.split("\n\n[AgentMux: memory cut here").next().unwrap())
450 .collect();
451 assert_eq!(carried.chars().count(), delivered);
452 assert!(text.starts_with(&carried), "the parts carry a prefix of the text");
453 }
454
455 #[test]
456 fn resume_and_fork_get_no_delivery() {
457 assert_eq!(Reason::from_hook_source("startup"), Some(Reason::Startup));
458 assert_eq!(Reason::from_hook_source("clear"), Some(Reason::Clear));
459 assert_eq!(Reason::from_hook_source("compact"), Some(Reason::Compact));
460 assert_eq!(Reason::from_hook_source("resume"), None, "D10");
461 assert_eq!(Reason::from_hook_source("fork"), None, "a fork continues an intact conversation");
462 assert_eq!(Reason::from_hook_source("anything"), None);
463 }
464
465 #[test]
466 fn composes_global_block_then_personal_then_summary_on_compact() {
467 let entries = [
468 entry("[AgentMux System] App API", Tier::Global, "IMPORTANT: …\n\n# [AgentMux System] App API\n\napi"),
469 entry("[Workspace] Rules", Tier::Global, "# [Workspace] Rules\n\nrules"),
470 entry("notes.md", Tier::Personal, "my notes"),
471 ];
472 let text = compose(&entries, Reason::Compact, Some("# Running summary …\nsummary")).unwrap();
473 let global_block = format!(
474 "IMPORTANT: …\n\n# [AgentMux System] App API\n\napi{}# [Workspace] Rules\n\nrules",
475 crate::backend::storage::bundles::GLOBAL_SECTION_SEPARATOR
476 );
477 assert!(text.contains(&format!("# Global Memory (2 entries)\n{global_block}\n")), "{text}");
478 assert!(text.contains("# Personal Memory (1 entry)\nmy notes\n"));
479 let (g, p, s) = (text.find("# Global").unwrap(), text.find("# Personal").unwrap(), text.find("# Running summary").unwrap());
480 assert!(g < p && p < s);
481 assert!(text.contains("just compacted"));
482 }
483
484 #[test]
485 fn the_summary_rides_only_on_a_compaction() {
486 let entries = [entry("notes.md", Tier::Personal, "n")];
487 let startup = compose(&entries, Reason::Startup, Some("# Running summary …")).unwrap();
488 assert!(!startup.contains("Running summary"));
489 assert!(startup.contains("start of a new session"));
490 }
491
492 #[test]
493 fn nothing_to_deliver_is_none() {
494 assert_eq!(compose(&[], Reason::Startup, None), None);
495 assert_eq!(compose(&[], Reason::Startup, Some("summary")), None, "no summary outside a compaction");
496 assert!(compose(&[], Reason::Compact, Some("summary")).is_some(), "a summary alone is worth delivering");
497 }
498
499 #[test]
500 fn small_text_is_one_labelled_part() {
501 let parts = split_into_parts("hello", MAX_PART_CHARS, HOOK_PARTS);
502 assert_eq!(parts, vec!["[AgentMux memory — part 1 of 1]\nhello".to_string()]);
503 }
504
505 #[test]
506 fn every_part_fits_the_cap_and_the_parts_rebuild_the_text() {
507 let text = (0..3_000).map(|i| format!("line {i} of the memory\n")).collect::<String>();
508 let parts = split_into_parts(&text, MAX_PART_CHARS, HOOK_PARTS);
509 assert!(parts.len() > 1);
510 for (i, p) in parts.iter().enumerate() {
511 assert!(p.chars().count() <= MAX_PART_CHARS, "part {i} is {} chars", p.chars().count());
512 assert!(p.starts_with(&format!("[AgentMux memory — part {} of {}]\n", i + 1, parts.len())));
513 }
514 let rebuilt: String = parts.iter().map(|p| p.split_once('\n').unwrap().1).collect();
515 assert_eq!(rebuilt, text);
516 assert!(parts[..parts.len() - 1].iter().all(|p| p.ends_with('\n')), "cut at line ends");
517 }
518
519 #[test]
520 fn a_long_line_is_cut_mid_line() {
521 let text = "x".repeat(20_000);
522 let parts = split_into_parts(&text, MAX_PART_CHARS, HOOK_PARTS);
523 assert!(parts.iter().all(|p| p.chars().count() <= MAX_PART_CHARS));
524 let rebuilt: String = parts.iter().map(|p| p.split_once('\n').unwrap().1).collect();
525 assert_eq!(rebuilt, text);
526 }
527
528 #[test]
529 fn more_than_the_hooks_can_carry_is_cut_with_a_note() {
530 let text = "y".repeat(MAX_PART_CHARS * (HOOK_PARTS + 2));
531 let parts = split_into_parts(&text, MAX_PART_CHARS, HOOK_PARTS);
532 assert_eq!(parts.len(), HOOK_PARTS);
533 assert!(parts.iter().all(|p| p.chars().count() <= MAX_PART_CHARS));
534 let last = parts.last().unwrap();
535 assert!(last.contains("memory cut here"), "{}", &last[last.len() - 200..]);
536 let kept: usize = parts
537 .iter()
538 .map(|p| p.split_once('\n').unwrap().1.split("\n\n[AgentMux: memory cut here").next().unwrap().len())
539 .sum();
540 let omitted: usize = last.rsplit_once("— ").unwrap().1.split(' ').next().unwrap().parse().unwrap();
541 assert_eq!(kept + omitted, text.len(), "the note's count is exact");
542 }
543
544 #[test]
545 fn multibyte_text_is_counted_in_characters() {
546 let text = "é".repeat(12_000);
547 let parts = split_into_parts(&text, MAX_PART_CHARS, HOOK_PARTS);
548 assert!(parts.iter().all(|p| p.chars().count() <= MAX_PART_CHARS));
549 let rebuilt: String = parts.iter().map(|p| p.split_once('\n').unwrap().1).collect();
550 assert_eq!(rebuilt, text);
551 }
552
553 #[test]
557 fn compose_fallback_writes_the_frontends_exact_message() {
558 let entries = vec![
559 Entry {
560 label: "[AgentMux System] App API".into(),
561 tier: Tier::Global,
562 text: "# [AgentMux System] App API\n\nG1".into(),
563 name: "App API".into(),
564 system: true,
565 bundle_id: Some("b1".into()),
566 path: None,
567 },
568 Entry {
569 label: "[Workspace] Rules".into(),
570 tier: Tier::Global,
571 text: "# [Workspace] Rules\n\nG2".into(),
572 name: "Rules".into(),
573 system: false,
574 bundle_id: Some("b2".into()),
575 path: None,
576 },
577 Entry {
578 label: "notes.md".into(),
579 tier: Tier::Personal,
580 text: "P1".into(),
581 name: "notes.md".into(),
582 system: false,
583 bundle_id: None,
584 path: Some("/m/notes.md".into()),
585 },
586 ];
587 let c = compose_fallback(&entries, FallbackReason::Compaction).unwrap();
588 assert_eq!(
589 c.text,
590 "<system-reminder>\n\
591 Your memory was reinjected because your working context was just reset. Your recent conversation was just compacted into a summary. Below is your\n\
592 complete Global Memory and Personal Memory content — read all of it now,\n\
593 not just the index.\n\n\
594 # Global Memory (2 entries)\n\
595 # [AgentMux System] App API\n\nG1\n\n---\n\n# [Workspace] Rules\n\nG2\n\
596 \n\
597 # Personal Memory (1 entry)\n\
598 P1\n\
599 </system-reminder>\n"
600 );
601 assert_eq!(c.spans.len(), 3);
602 assert!(crate::ambient::digest::is_hidden_reinjection_text(&c.text));
603
604 let fresh = compose_fallback(&entries[2..], FallbackReason::FreshSession).unwrap();
605 assert!(fresh.text.contains(
606 "AgentMux could not resume this agent's prior session, so a fresh one was started. Any record of the prior \
607 conversation AgentMux had came with your first message, in an <agentmux-continuation> block. Below is your\n"
608 ));
609 assert!(!fresh.text.contains("# Global Memory"), "an empty section is left out");
610 assert_eq!(compose_fallback(&[], FallbackReason::Compaction), None);
611 }
612
613 #[test]
614 fn fallback_reasons_are_the_frontends() {
615 assert_eq!(FallbackReason::from_wire("compaction"), Some(FallbackReason::Compaction));
616 assert_eq!(FallbackReason::from_wire("fresh_session"), Some(FallbackReason::FreshSession));
617 assert_eq!(FallbackReason::from_wire("startup"), None);
618 }
619
620 #[test]
621 fn a_delivery_without_the_global_memory_says_where_it_is() {
622 let personal = vec![Entry {
623 label: "notes.md".into(),
624 tier: Tier::Personal,
625 text: "P1".into(),
626 name: "notes.md".into(),
627 system: false,
628 bundle_id: None,
629 path: None,
630 }];
631 let c = compose_items_with(&personal, Reason::Startup, None, true).unwrap();
632 assert!(c.text.contains("Your Global Memory is already in your startup instructions"), "{}", c.text);
633 assert!(!c.text.contains("# Global Memory"));
634 assert!(c.text.contains("# Personal Memory (1 entry)\nP1\n"));
635 assert_eq!(compose_items_with(&[], Reason::Startup, None, true), None);
636 }
637}