1use std::path::{Path, PathBuf};
33use std::sync::{Arc, Mutex};
34use std::time::Duration;
35
36use async_trait::async_trait;
37use futures::StreamExt;
38use serde::Deserialize;
39
40use meerkat_client::{LlmClient, LlmDoneOutcome, LlmError, LlmEvent, LlmRequest};
41use meerkat_core::event::AgentEvent;
42use meerkat_core::{Message, Provider, SystemNoticeKind, SystemNoticeMessage, UserMessage};
43
44use crate::identity_first::agent_memory::{compact_whitespace, truncate_utf8_boundary};
45use crate::memory::capabilities::StewardStore;
46use crate::memory::distiller::{CompactionFollowUp, DistillOutcome, DistillerEngine};
47use crate::memory::events::{MemoryEventSink, MemoryTimelineEvent};
48use crate::memory::guards::{BackgroundBudget, BackgroundBudgetConfig};
49use crate::memory::records::{ManifestTier, MemoryScope, RecordStatus};
50use crate::memory::selector::FactorySelectorHandle;
51use crate::memory::taint::MemberAgentEventSink;
52
53pub const EMBEDDED_PROMPT_V0: &str = include_str!("hygienist_prompt_v0.md");
58
59const TRANSCRIPT_PLACEHOLDER: &str = "{{transcript}}";
60const PROTECTED_RANGES_PLACEHOLDER: &str = "{{protected_ranges}}";
61
62pub const DEFAULT_RUNS_PER_DAY: u32 = 2;
66const MAX_TRANSCRIPT_MESSAGE_BYTES: usize = 2 * 1024;
68const MAX_TRANSCRIPT_TOTAL_BYTES: usize = 48 * 1024;
69const DEFAULT_MAX_OUTPUT_TOKENS: u32 = 2048;
71const SPAN_QUARANTINE_LIMIT: usize = 256;
73const MAX_COLLAPSE_REPLACEMENT_BYTES: usize = 512;
75
76#[derive(Debug)]
81pub enum HygienistError {
82 Profile(String),
83 Auth(String),
84 Client(String),
85 Parse(String),
86 Seam(String),
87 Spans(String),
88}
89
90impl std::fmt::Display for HygienistError {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 match self {
93 Self::Profile(msg) => write!(f, "hygienist profile error: {msg}"),
94 Self::Auth(msg) => write!(f, "hygienist auth error: {msg}"),
95 Self::Client(msg) => write!(f, "hygienist client error: {msg}"),
96 Self::Parse(msg) => write!(f, "hygienist parse error: {msg}"),
97 Self::Seam(msg) => write!(f, "hygienist revision seam error: {msg}"),
98 Self::Spans(msg) => write!(f, "hygienist span source error: {msg}"),
99 }
100 }
101}
102
103impl std::error::Error for HygienistError {}
104
105#[derive(Debug, Clone, Deserialize)]
110pub struct HygienistParams {
111 #[serde(default = "default_temperature")]
112 pub temperature: f32,
113 #[serde(default = "default_max_output_tokens")]
114 pub max_output_tokens: u32,
115}
116
117fn default_temperature() -> f32 {
118 0.0
119}
120fn default_max_output_tokens() -> u32 {
121 DEFAULT_MAX_OUTPUT_TOKENS
122}
123
124impl Default for HygienistParams {
125 fn default() -> Self {
126 Self {
127 temperature: default_temperature(),
128 max_output_tokens: default_max_output_tokens(),
129 }
130 }
131}
132
133#[derive(Debug, Clone)]
135pub struct HygienistProfile {
136 pub stage: String,
137 pub version: String,
138 pub model: String,
139 pub provider: Provider,
140 pub prompt_bundle: String,
141 pub prompt_template: String,
142 pub params: HygienistParams,
143}
144
145#[derive(Debug, Deserialize)]
146struct RawProfile {
147 stage: String,
148 version: String,
149 model: String,
150 #[serde(default)]
151 provider: Option<String>,
152 prompt_bundle: String,
153 #[serde(default)]
154 params: Option<HygienistParams>,
155}
156
157impl HygienistProfile {
158 pub fn embedded_default() -> Self {
162 Self {
163 stage: "hygienist".to_string(),
164 version: "0".to_string(),
165 model: "claude-sonnet-4-6".to_string(),
166 provider: Provider::Anthropic,
167 prompt_bundle: "prompts/hygienist-v0.md".to_string(),
168 prompt_template: EMBEDDED_PROMPT_V0.to_string(),
169 params: HygienistParams::default(),
170 }
171 }
172
173 pub fn with_model_override(mut self, model: &str) -> Result<Self, HygienistError> {
176 let model = model.trim();
177 if model.is_empty() {
178 return Err(HygienistError::Profile(
179 "hygienist model override must not be empty".to_string(),
180 ));
181 }
182 self.provider = meerkat_models::infer_provider(model).ok_or_else(|| {
183 HygienistError::Profile(format!(
184 "hygienist model override '{model}' is not in the model catalog"
185 ))
186 })?;
187 self.model = model.to_string();
188 Ok(self)
189 }
190
191 pub fn load(path: &Path) -> Result<Self, HygienistError> {
194 let text = std::fs::read_to_string(path).map_err(|err| {
195 HygienistError::Profile(format!("cannot read profile '{}': {err}", path.display()))
196 })?;
197 let raw: RawProfile = toml::from_str(&text).map_err(|err| {
198 HygienistError::Profile(format!("invalid profile '{}': {err}", path.display()))
199 })?;
200 if raw.stage != "hygienist" {
201 return Err(HygienistError::Profile(format!(
202 "profile '{}' is for stage '{}', not 'hygienist'",
203 path.display(),
204 raw.stage
205 )));
206 }
207 if raw.model.trim().is_empty() || raw.model == "PLACEHOLDER" {
208 return Err(HygienistError::Profile(format!(
209 "profile '{}' does not name a model",
210 path.display()
211 )));
212 }
213 let provider = match raw.provider.as_deref() {
214 Some(name) => Provider::parse_strict(name).ok_or_else(|| {
215 HygienistError::Profile(format!(
216 "profile '{}': unknown provider '{name}'",
217 path.display()
218 ))
219 })?,
220 None => meerkat_models::infer_provider(&raw.model).ok_or_else(|| {
221 HygienistError::Profile(format!(
222 "profile '{}': model '{}' is not in the catalog; set `provider` explicitly",
223 path.display(),
224 raw.model
225 ))
226 })?,
227 };
228 let base = path.parent().unwrap_or_else(|| Path::new("."));
229 let candidates = [
230 base.join(&raw.prompt_bundle),
231 base.parent()
232 .unwrap_or_else(|| Path::new("."))
233 .join(&raw.prompt_bundle),
234 ];
235 let bundle_path = candidates.iter().find(|p| p.is_file()).ok_or_else(|| {
236 HygienistError::Profile(format!(
237 "profile '{}': prompt_bundle '{}' does not resolve",
238 path.display(),
239 raw.prompt_bundle
240 ))
241 })?;
242 let prompt_template = std::fs::read_to_string(bundle_path).map_err(|err| {
243 HygienistError::Profile(format!(
244 "cannot read prompt bundle '{}': {err}",
245 bundle_path.display()
246 ))
247 })?;
248 let profile = Self {
249 stage: raw.stage,
250 version: raw.version,
251 model: raw.model,
252 provider,
253 prompt_bundle: raw.prompt_bundle,
254 prompt_template,
255 params: raw.params.unwrap_or_default(),
256 };
257 profile.validate()?;
258 Ok(profile)
259 }
260
261 fn validate(&self) -> Result<(), HygienistError> {
262 for placeholder in [TRANSCRIPT_PLACEHOLDER, PROTECTED_RANGES_PLACEHOLDER] {
263 if !self.prompt_template.contains(placeholder) {
264 return Err(HygienistError::Profile(format!(
265 "prompt bundle '{}' is missing placeholder `{placeholder}`",
266 self.prompt_bundle
267 )));
268 }
269 }
270 Ok(())
271 }
272}
273
274#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct HygienistConfig {
283 pub enabled: bool,
284 pub runs_per_day: u32,
286 pub model: Option<String>,
288}
289
290impl Default for HygienistConfig {
291 fn default() -> Self {
292 Self {
293 enabled: false,
294 runs_per_day: DEFAULT_RUNS_PER_DAY,
295 model: None,
296 }
297 }
298}
299
300#[async_trait]
306pub trait HygienistClientHandle: Send + Sync {
307 async fn client(&self) -> Result<Arc<dyn LlmClient>, HygienistError>;
308 fn invalidate(&self) {}
309}
310
311pub struct FactoryHygienistHandle {
314 inner: FactorySelectorHandle,
315}
316
317impl FactoryHygienistHandle {
318 pub fn new(
319 store_path: PathBuf,
320 config: meerkat::Config,
321 realm: impl Into<String>,
322 profile: &HygienistProfile,
323 ) -> Self {
324 Self {
325 inner: FactorySelectorHandle::for_model(
326 store_path,
327 config,
328 realm,
329 &profile.model,
330 profile.provider,
331 ),
332 }
333 }
334}
335
336#[async_trait]
337impl HygienistClientHandle for FactoryHygienistHandle {
338 async fn client(&self) -> Result<Arc<dyn LlmClient>, HygienistError> {
339 use crate::memory::selector::{SelectorError, SelectorHandle};
340 self.inner.client().await.map_err(|err| match err {
341 SelectorError::Auth(msg) => HygienistError::Auth(msg),
342 other => HygienistError::Client(other.to_string()),
343 })
344 }
345
346 fn invalidate(&self) {
347 use crate::memory::selector::SelectorHandle;
348 self.inner.invalidate();
349 }
350}
351
352#[derive(Debug, Clone, PartialEq, Eq)]
358pub struct AppliedRevision {
359 pub parent_revision: String,
360 pub revision: String,
361 pub message_count: usize,
362}
363
364#[async_trait]
371pub trait TranscriptRevisionSeam: Send + Sync {
372 async fn read_messages(
378 &self,
379 session_key: &str,
380 ) -> Result<Option<(Vec<Message>, Option<String>)>, String>;
381
382 async fn rewrite(
390 &self,
391 session_key: &str,
392 start: usize,
393 end: usize,
394 replacement: Vec<Message>,
395 note: &str,
396 expected_parent_revision: Option<String>,
397 ) -> Result<AppliedRevision, String>;
398}
399
400pub trait TranscriptEditSessionService:
405 meerkat_core::service::SessionServiceHistoryExt
406 + meerkat_core::service::SessionServiceTranscriptEditExt
407{
408}
409
410impl<T> TranscriptEditSessionService for T where
411 T: meerkat_core::service::SessionServiceHistoryExt
412 + meerkat_core::service::SessionServiceTranscriptEditExt
413 + ?Sized
414{
415}
416
417pub struct SessionServiceRevisionSeam {
421 service: Arc<dyn TranscriptEditSessionService>,
422}
423
424impl SessionServiceRevisionSeam {
425 pub fn new(service: Arc<dyn TranscriptEditSessionService>) -> Self {
426 Self { service }
427 }
428}
429
430#[async_trait]
431impl TranscriptRevisionSeam for SessionServiceRevisionSeam {
432 async fn read_messages(
433 &self,
434 session_key: &str,
435 ) -> Result<Option<(Vec<Message>, Option<String>)>, String> {
436 let session_id = meerkat_core::types::SessionId::parse(session_key)
437 .map_err(|err| format!("invalid session key '{session_key}': {err}"))?;
438 let messages = match self
439 .service
440 .read_history(
441 &session_id,
442 meerkat_core::service::SessionHistoryQuery {
443 offset: 0,
444 limit: None,
445 },
446 )
447 .await
448 {
449 Ok(page) => page.messages,
450 Err(meerkat_core::SessionError::NotFound { .. }) => return Ok(None),
451 Err(err) => return Err(err.to_string()),
452 };
453 let head_revision = match self
458 .service
459 .list_transcript_revisions(
460 &session_id,
461 meerkat_core::service::SessionTranscriptRevisionListQuery {
462 limit: Some(0),
463 offset: None,
464 },
465 )
466 .await
467 {
468 Ok(list) => Some(list.head_revision),
469 Err(meerkat_core::SessionError::Unsupported(_)) => None,
470 Err(err) => return Err(err.to_string()),
471 };
472 Ok(Some((messages, head_revision)))
473 }
474
475 async fn rewrite(
476 &self,
477 session_key: &str,
478 start: usize,
479 end: usize,
480 replacement: Vec<Message>,
481 note: &str,
482 expected_parent_revision: Option<String>,
483 ) -> Result<AppliedRevision, String> {
484 let session_id = meerkat_core::types::SessionId::parse(session_key)
485 .map_err(|err| format!("invalid session key '{session_key}': {err}"))?;
486 let mut reason = meerkat_core::TranscriptRewriteReason::new("hygiene");
487 reason.note = Some(note.to_string());
488 let result = self
489 .service
490 .rewrite_session_transcript(
491 &session_id,
492 meerkat_core::service::SessionTranscriptRewriteRequest {
493 selection: meerkat_core::TranscriptRewriteSelection::MessageRange {
494 start,
495 end,
496 },
497 replacement,
498 reason,
499 actor: Some("mobkit-hygienist".to_string()),
500 expected_parent_revision,
506 running_behavior: meerkat_core::TranscriptEditRunningBehavior::default(),
507 },
508 )
509 .await
510 .map_err(|err| err.to_string())?;
511 Ok(AppliedRevision {
512 parent_revision: result.parent_revision,
513 revision: result.revision,
514 message_count: result.message_count,
515 })
516 }
517}
518
519#[derive(Debug, Clone, PartialEq, Eq)]
525pub struct SpanReference {
526 pub record_id: String,
527 pub quarantined: bool,
530 pub range: Option<(u64, u64)>,
534}
535
536#[async_trait]
538pub trait SpanReferenceSource: Send + Sync {
539 async fn span_references(
540 &self,
541 identity: &str,
542 session_key: &str,
543 ) -> Result<Vec<SpanReference>, String>;
544}
545
546pub struct StoreSpanReferenceSource {
553 store: Arc<dyn StewardStore>,
554 realm: String,
555}
556
557impl StoreSpanReferenceSource {
558 pub fn new(store: Arc<dyn StewardStore>, realm: impl Into<String>) -> Self {
559 Self {
560 store,
561 realm: realm.into(),
562 }
563 }
564}
565
566#[async_trait]
567impl SpanReferenceSource for StoreSpanReferenceSource {
568 async fn span_references(
569 &self,
570 identity: &str,
571 session_key: &str,
572 ) -> Result<Vec<SpanReference>, String> {
573 let mut references = Vec::new();
574 let quarantined = self
575 .store
576 .quarantined_records(&self.realm, SPAN_QUARANTINE_LIMIT)
577 .await
578 .map_err(|err| err.to_string())?;
579 for record in &quarantined {
580 for evidence in &record.provenance.evidence {
581 if evidence.session_id == session_key {
582 references.push(SpanReference {
583 record_id: record.id.clone(),
584 quarantined: true,
585 range: evidence.range,
586 });
587 }
588 }
589 }
590 let scopes = vec![
591 MemoryScope::Identity {
592 realm: self.realm.clone(),
593 identity: identity.to_string(),
594 },
595 MemoryScope::Realm {
596 realm: self.realm.clone(),
597 },
598 ];
599 let manifest = self
600 .store
601 .manifest(&scopes, ManifestTier::Full)
602 .await
603 .map_err(|err| err.to_string())?;
604 let ids: Vec<String> = manifest.into_iter().map(|meta| meta.id).collect();
605 let records = self
606 .store
607 .records_by_ids(&self.realm, &ids)
608 .await
609 .map_err(|err| err.to_string())?;
610 for record in &records {
611 if record.status != RecordStatus::Active {
612 continue;
613 }
614 for evidence in &record.provenance.evidence {
615 if evidence.session_id == session_key {
616 references.push(SpanReference {
617 record_id: record.id.clone(),
618 quarantined: false,
619 range: evidence.range,
620 });
621 }
622 }
623 }
624 Ok(references)
625 }
626}
627
628pub trait DistillationGate: Send + Sync {
634 fn distilled_through(&self, identity: &str, session_key: &str) -> u64;
637}
638
639impl DistillationGate for DistillerEngine {
640 fn distilled_through(&self, identity: &str, session_key: &str) -> u64 {
641 self.distilled_cursor(identity, session_key)
642 }
643}
644
645#[derive(Debug, Clone, PartialEq, Eq)]
651pub enum RevisionAction {
652 PruneToolResults,
655 Collapse { replacement: String },
658}
659
660#[derive(Debug, Clone, PartialEq, Eq)]
661pub struct RevisionOp {
662 pub action: RevisionAction,
663 pub start: usize,
665 pub end: usize,
666 pub rationale: String,
667}
668
669#[derive(Debug, Clone, PartialEq, Eq, Default)]
671pub struct RevisionProposal {
672 pub ops: Vec<RevisionOp>,
673}
674
675#[derive(Deserialize)]
676struct RawReply {
677 #[serde(default)]
678 ops: Vec<RawOp>,
679}
680
681#[derive(Deserialize)]
682struct RawOp {
683 op: String,
684 range: (usize, usize),
685 #[serde(default)]
686 replacement: Option<String>,
687 #[serde(default)]
688 rationale: String,
689}
690
691pub fn parse_revision_reply(reply: &str) -> Result<RevisionProposal, String> {
696 let start = reply
697 .find('{')
698 .ok_or_else(|| "reply contains no JSON object".to_string())?;
699 let end = reply
700 .rfind('}')
701 .ok_or_else(|| "reply contains no closing brace".to_string())?;
702 if end < start {
703 return Err("reply braces are unbalanced".to_string());
704 }
705 let raw: RawReply = serde_json::from_str(&reply[start..=end])
706 .map_err(|err| format!("reply did not parse: {err}"))?;
707 let mut ops = Vec::new();
708 for raw_op in raw.ops {
709 let (start, end) = raw_op.range;
710 let action = match raw_op.op.as_str() {
711 "prune_tool_results" => RevisionAction::PruneToolResults,
712 "collapse" => {
713 let replacement = raw_op
714 .replacement
715 .as_deref()
716 .map(compact_whitespace)
717 .unwrap_or_default();
718 if replacement.is_empty() {
719 return Err("collapse op without a replacement note".to_string());
720 }
721 RevisionAction::Collapse {
722 replacement: truncate_utf8_boundary(
723 &replacement,
724 MAX_COLLAPSE_REPLACEMENT_BYTES,
725 ),
726 }
727 }
728 other => return Err(format!("unknown op '{other}'")),
729 };
730 ops.push(RevisionOp {
731 action,
732 start,
733 end,
734 rationale: compact_whitespace(&raw_op.rationale),
735 });
736 }
737 Ok(RevisionProposal { ops })
738}
739
740#[derive(Debug, Clone, Copy, PartialEq, Eq)]
747pub enum HygieneRole {
748 System,
749 SystemNotice,
750 User,
751 Assistant,
753 AssistantToolUse,
756 ToolResults,
757}
758
759impl HygieneRole {
760 pub fn of(message: &Message) -> Self {
761 match message {
762 Message::System(_) => Self::System,
763 Message::SystemNotice(_) => Self::SystemNotice,
764 Message::User(_) => Self::User,
765 Message::BlockAssistant(assistant) => {
766 if assistant.has_tool_calls() {
767 Self::AssistantToolUse
768 } else {
769 Self::Assistant
770 }
771 }
772 Message::ToolResults { .. } => Self::ToolResults,
773 }
774 }
775
776 pub fn as_str(&self) -> &'static str {
777 match self {
778 Self::System => "system",
779 Self::SystemNotice => "system notice",
780 Self::User => "user",
781 Self::Assistant => "assistant",
782 Self::AssistantToolUse => "assistant (tool call)",
783 Self::ToolResults => "tool results",
784 }
785 }
786}
787
788#[derive(Debug, Clone, PartialEq, Eq)]
791pub enum RevisionReject {
792 InvalidRange { detail: String },
794 IllegalRole { detail: String },
797 QuarantineReferenced { record_id: String },
800 OrderingUnmet { cursor: u64, needed: u64 },
803}
804
805impl std::fmt::Display for RevisionReject {
806 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
807 match self {
808 Self::InvalidRange { detail } => write!(f, "invalid range: {detail}"),
809 Self::IllegalRole { detail } => write!(f, "illegal role: {detail}"),
810 Self::QuarantineReferenced { record_id } => write!(
811 f,
812 "range referenced by quarantined record '{record_id}' (review incomplete)"
813 ),
814 Self::OrderingUnmet { cursor, needed } => write!(
815 f,
816 "distillation cursor {cursor} has not covered the affected window (needs {needed})"
817 ),
818 }
819 }
820}
821
822#[derive(Debug, Clone, PartialEq, Eq)]
824pub struct ValidatedRevision {
825 pub ops: Vec<RevisionOp>,
826 pub flagged_active_records: Vec<String>,
829}
830
831#[derive(Debug, Clone, Copy, PartialEq, Eq)]
833pub enum OrderingContext {
834 SequencedAfterHarvest,
837 Cursor(Option<u64>),
841}
842
843pub fn validate_revision(
846 proposal: &RevisionProposal,
847 roles: &[HygieneRole],
848 spans: &[SpanReference],
849 ordering: OrderingContext,
850) -> Result<ValidatedRevision, RevisionReject> {
851 let len = roles.len();
852 let mut sorted: Vec<&RevisionOp> = proposal.ops.iter().collect();
853 sorted.sort_by_key(|op| op.start);
854 let mut previous_end = 0usize;
855 for op in &sorted {
856 if op.start >= op.end {
857 return Err(RevisionReject::InvalidRange {
858 detail: format!("empty range [{}, {})", op.start, op.end),
859 });
860 }
861 if op.end > len {
862 return Err(RevisionReject::InvalidRange {
863 detail: format!(
864 "range [{}, {}) exceeds transcript length {len}",
865 op.start, op.end
866 ),
867 });
868 }
869 if op.start < previous_end {
870 return Err(RevisionReject::InvalidRange {
871 detail: format!("range [{}, {}) overlaps an earlier op", op.start, op.end),
872 });
873 }
874 previous_end = op.end;
875 for (index, &role) in roles.iter().enumerate().take(op.end).skip(op.start) {
876 match op.action {
877 RevisionAction::PruneToolResults => {
878 if role != HygieneRole::ToolResults {
879 return Err(RevisionReject::IllegalRole {
880 detail: format!(
881 "prune_tool_results range [{}, {}) covers a {} message at [{index}]",
882 op.start,
883 op.end,
884 role.as_str()
885 ),
886 });
887 }
888 }
889 RevisionAction::Collapse { .. } => {
890 if !matches!(
891 role,
892 HygieneRole::User | HygieneRole::SystemNotice | HygieneRole::Assistant
893 ) {
894 return Err(RevisionReject::IllegalRole {
895 detail: format!(
896 "collapse range [{}, {}) covers a {} message at [{index}]",
897 op.start,
898 op.end,
899 role.as_str()
900 ),
901 });
902 }
903 }
904 }
905 }
906 }
907 let mut flagged: Vec<String> = Vec::new();
909 for span in spans {
910 let (span_start, span_end) = match span.range {
911 Some((start, end)) => (start as usize, (end as usize).saturating_add(1)),
912 None => (0, len.max(1)),
915 };
916 let touched = sorted
917 .iter()
918 .any(|op| op.start < span_end && span_start < op.end);
919 if !touched {
920 continue;
921 }
922 if span.quarantined {
923 return Err(RevisionReject::QuarantineReferenced {
924 record_id: span.record_id.clone(),
925 });
926 }
927 if !flagged.contains(&span.record_id) {
928 flagged.push(span.record_id.clone());
929 }
930 }
931 if let OrderingContext::Cursor(Some(cursor)) = ordering {
933 let needed = sorted.iter().map(|op| op.end as u64).max().unwrap_or(0);
934 if needed > cursor {
935 return Err(RevisionReject::OrderingUnmet { cursor, needed });
936 }
937 }
938 Ok(ValidatedRevision {
939 ops: sorted.into_iter().cloned().collect(),
940 flagged_active_records: flagged,
941 })
942}
943
944fn message_text(message: &Message) -> String {
949 match message {
950 Message::System(_) => "(system prompt — untouchable)".to_string(),
951 Message::SystemNotice(notice) => notice.body.clone().unwrap_or_default(),
952 Message::User(user) => user.text_content(),
953 Message::BlockAssistant(assistant) => {
954 assistant.text_blocks().collect::<Vec<_>>().join("\n")
955 }
956 Message::ToolResults { results, .. } => results
957 .iter()
958 .map(|result| meerkat_core::types::text_content(&result.content))
959 .collect::<Vec<_>>()
960 .join("\n"),
961 }
962}
963
964pub fn render_transcript(messages: &[Message]) -> String {
967 let mut lines: Vec<String> = Vec::new();
968 let mut total = 0usize;
969 for (index, message) in messages.iter().enumerate().rev() {
970 let role = HygieneRole::of(message);
971 let text = truncate_utf8_boundary(
972 &compact_whitespace(&message_text(message)),
973 MAX_TRANSCRIPT_MESSAGE_BYTES,
974 );
975 let line = format!("[{index}] {}: {text}", role.as_str());
976 if total + line.len() + 1 > MAX_TRANSCRIPT_TOTAL_BYTES && !lines.is_empty() {
977 lines.push("(earlier messages omitted for budget)".to_string());
978 break;
979 }
980 total += line.len() + 1;
981 lines.push(line);
982 }
983 lines.reverse();
984 lines.join("\n")
985}
986
987fn render_protected_ranges(spans: &[SpanReference]) -> String {
988 if spans.is_empty() {
989 return "(none)".to_string();
990 }
991 spans
992 .iter()
993 .map(|span| {
994 let range = match span.range {
995 Some((start, end)) => format!("[{start}-{end}]"),
996 None => "[whole session]".to_string(),
997 };
998 format!(
999 "- {} {} referenced by record '{}'",
1000 if span.quarantined {
1001 "QUARANTINED"
1002 } else {
1003 "active"
1004 },
1005 range,
1006 span.record_id
1007 )
1008 })
1009 .collect::<Vec<_>>()
1010 .join("\n")
1011}
1012
1013pub fn render_prompt(
1014 profile: &HygienistProfile,
1015 messages: &[Message],
1016 spans: &[SpanReference],
1017) -> String {
1018 profile
1019 .prompt_template
1020 .replace(TRANSCRIPT_PLACEHOLDER, &render_transcript(messages))
1021 .replace(
1022 PROTECTED_RANGES_PLACEHOLDER,
1023 &render_protected_ranges(spans),
1024 )
1025}
1026
1027pub fn build_replacement(
1036 messages: &[Message],
1037 ops: &[RevisionOp],
1038) -> Option<(usize, usize, Vec<Message>)> {
1039 let hull_start = ops.iter().map(|op| op.start).min()?;
1040 let hull_end = ops.iter().map(|op| op.end).max()?;
1041 let mut replacement = Vec::new();
1042 let mut index = hull_start;
1043 while index < hull_end {
1044 if let Some(op) = ops.iter().find(|op| op.start == index) {
1045 match &op.action {
1046 RevisionAction::PruneToolResults => {
1047 for pruned in &messages[op.start..op.end] {
1048 if let Message::ToolResults {
1049 results,
1050 created_at,
1051 } = pruned
1052 {
1053 let stubbed = results
1054 .iter()
1055 .map(|result| {
1056 meerkat_core::types::ToolResult::new(
1057 result.tool_use_id.clone(),
1058 format!("[pruned by hygienist: {}]", op.rationale),
1059 result.is_error,
1060 )
1061 })
1062 .collect();
1063 replacement.push(Message::ToolResults {
1064 results: stubbed,
1065 created_at: *created_at,
1066 });
1067 }
1068 }
1069 }
1070 RevisionAction::Collapse { replacement: note } => {
1071 replacement.push(Message::SystemNotice(SystemNoticeMessage::new(
1072 SystemNoticeKind::Generic,
1073 format!(
1074 "[hygienist] collapsed {} messages: {note}",
1075 op.end - op.start
1076 ),
1077 )));
1078 }
1079 }
1080 index = op.end;
1081 } else {
1082 replacement.push(messages[index].clone());
1083 index += 1;
1084 }
1085 }
1086 Some((hull_start, hull_end, replacement))
1087}
1088
1089#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1095pub enum HygieneCause {
1096 PostCompaction,
1099 OnDemand,
1100}
1101
1102impl HygieneCause {
1103 pub fn as_str(&self) -> &'static str {
1104 match self {
1105 Self::PostCompaction => "post_compaction",
1106 Self::OnDemand => "on_demand",
1107 }
1108 }
1109}
1110
1111#[derive(Debug, Clone, PartialEq, Eq)]
1113pub enum HygieneOutcome {
1114 Skipped {
1115 reason: String,
1116 },
1117 Blocked {
1118 reason: String,
1119 },
1120 Applied {
1121 run_id: String,
1122 revision: AppliedRevision,
1123 ops: usize,
1124 flagged_active_records: Vec<String>,
1125 },
1126}
1127
1128pub struct HygienistEngine {
1129 profile: HygienistProfile,
1130 config: HygienistConfig,
1131 handle: Arc<dyn HygienistClientHandle>,
1132 seam: Arc<dyn TranscriptRevisionSeam>,
1133 spans: Arc<dyn SpanReferenceSource>,
1134 gate: Option<Arc<dyn DistillationGate>>,
1135 budget: BackgroundBudget,
1136 realm: String,
1137 events: Mutex<Option<Arc<dyn MemoryEventSink>>>,
1138 run_counter: std::sync::atomic::AtomicU64,
1139}
1140
1141impl HygienistEngine {
1142 pub fn new(
1143 profile: HygienistProfile,
1144 config: HygienistConfig,
1145 handle: Arc<dyn HygienistClientHandle>,
1146 seam: Arc<dyn TranscriptRevisionSeam>,
1147 spans: Arc<dyn SpanReferenceSource>,
1148 gate: Option<Arc<dyn DistillationGate>>,
1149 realm: impl Into<String>,
1150 ) -> Self {
1151 let budget = BackgroundBudget::new(BackgroundBudgetConfig {
1153 runs_per_window: config.runs_per_day,
1154 #[allow(clippy::duration_suboptimal_units)]
1157 window: Duration::from_secs(24 * 60 * 60),
1158 max_concurrent: 1,
1159 });
1160 Self {
1161 profile,
1162 config,
1163 handle,
1164 seam,
1165 spans,
1166 gate,
1167 budget,
1168 realm: realm.into(),
1169 events: Mutex::new(None),
1170 run_counter: std::sync::atomic::AtomicU64::new(0),
1171 }
1172 }
1173
1174 pub fn config(&self) -> &HygienistConfig {
1175 &self.config
1176 }
1177
1178 pub fn set_event_sink(&self, sink: Arc<dyn MemoryEventSink>) {
1180 self.budget.set_event_sink(sink.clone());
1181 *self
1182 .events
1183 .lock()
1184 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(sink);
1185 }
1186
1187 fn emit(&self, event: MemoryTimelineEvent) {
1188 if let Some(sink) = self
1189 .events
1190 .lock()
1191 .unwrap_or_else(std::sync::PoisonError::into_inner)
1192 .as_ref()
1193 {
1194 sink.emit(event);
1195 }
1196 }
1197
1198 fn mint_run_id(&self) -> String {
1199 let seq = self
1200 .run_counter
1201 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1202 format!("hygiene-{}-{seq}", now_ms())
1203 }
1204
1205 pub async fn hygiene_now(
1208 self: &Arc<Self>,
1209 identity: &str,
1210 session_key: &str,
1211 cause: HygieneCause,
1212 ) -> HygieneOutcome {
1213 let outcome = self.hygiene_inner(identity, session_key, cause).await;
1214 match &outcome {
1215 HygieneOutcome::Skipped { reason } => {
1216 tracing::debug!(
1217 identity,
1218 session_key,
1219 cause = cause.as_str(),
1220 reason,
1221 "agent memory hygienist: pass skipped"
1222 );
1223 self.emit(MemoryTimelineEvent::HygieneSkipped {
1224 identity: identity.to_string(),
1225 session_key: session_key.to_string(),
1226 cause: cause.as_str().to_string(),
1227 reason: reason.clone(),
1228 });
1229 }
1230 HygieneOutcome::Blocked { reason } => {
1231 tracing::warn!(
1232 identity,
1233 session_key,
1234 cause = cause.as_str(),
1235 reason,
1236 "agent memory hygienist: revision blocked"
1237 );
1238 self.emit(MemoryTimelineEvent::HygieneBlocked {
1239 identity: identity.to_string(),
1240 session_key: session_key.to_string(),
1241 cause: cause.as_str().to_string(),
1242 reason: reason.clone(),
1243 });
1244 }
1245 HygieneOutcome::Applied {
1246 run_id,
1247 revision,
1248 ops,
1249 flagged_active_records,
1250 } => {
1251 tracing::info!(
1252 identity,
1253 session_key,
1254 cause = cause.as_str(),
1255 run_id,
1256 ops,
1257 revision = %revision.revision,
1258 "agent memory hygienist: revision applied"
1259 );
1260 self.emit(MemoryTimelineEvent::HygieneApplied {
1261 identity: identity.to_string(),
1262 session_key: session_key.to_string(),
1263 cause: cause.as_str().to_string(),
1264 parent_revision: revision.parent_revision.clone(),
1265 revision: revision.revision.clone(),
1266 ops: *ops,
1267 flagged_active_records: flagged_active_records.clone(),
1268 });
1269 }
1270 }
1271 outcome
1272 }
1273
1274 async fn hygiene_inner(
1275 self: &Arc<Self>,
1276 identity: &str,
1277 session_key: &str,
1278 cause: HygieneCause,
1279 ) -> HygieneOutcome {
1280 let (messages, head_revision) = match self.seam.read_messages(session_key).await {
1283 Ok(Some(read)) => read,
1284 Ok(None) => {
1285 return HygieneOutcome::Skipped {
1286 reason: "session not found".to_string(),
1287 };
1288 }
1289 Err(err) => {
1290 return HygieneOutcome::Skipped {
1291 reason: format!("transcript read failed: {err}"),
1292 };
1293 }
1294 };
1295 if messages.is_empty() {
1296 return HygieneOutcome::Skipped {
1297 reason: "empty transcript".to_string(),
1298 };
1299 }
1300 let spans = match self.spans.span_references(identity, session_key).await {
1301 Ok(spans) => spans,
1302 Err(err) => {
1303 return HygieneOutcome::Blocked {
1306 reason: format!("span references unavailable: {err}"),
1307 };
1308 }
1309 };
1310
1311 let _permit = match self.budget.try_acquire(&self.realm, "hygienist") {
1312 Ok(permit) => permit,
1313 Err(denied) => {
1314 return HygieneOutcome::Skipped {
1315 reason: format!("budget denied: {denied}"),
1316 };
1317 }
1318 };
1319
1320 let client = match self.handle.client().await {
1321 Ok(client) => client,
1322 Err(err) => {
1323 return HygieneOutcome::Skipped {
1324 reason: format!("client acquisition failed: {err}"),
1325 };
1326 }
1327 };
1328 let prompt = render_prompt(&self.profile, &messages, &spans);
1329 let reply = match complete_text(&*client, &self.profile, prompt.clone()).await {
1330 Ok(reply) => reply,
1331 Err(HygienistError::Auth(message)) => {
1332 tracing::warn!(error = %message, "hygienist auth failure; re-resolving client");
1334 self.handle.invalidate();
1335 let retried = match self.handle.client().await {
1336 Ok(client) => complete_text(&*client, &self.profile, prompt).await,
1337 Err(err) => Err(err),
1338 };
1339 match retried {
1340 Ok(reply) => reply,
1341 Err(err) => {
1342 return HygieneOutcome::Skipped {
1343 reason: format!("completion failed: {err}"),
1344 };
1345 }
1346 }
1347 }
1348 Err(err) => {
1349 return HygieneOutcome::Skipped {
1350 reason: format!("completion failed: {err}"),
1351 };
1352 }
1353 };
1354 let proposal = match parse_revision_reply(&reply) {
1355 Ok(proposal) => proposal,
1356 Err(err) => {
1357 return HygieneOutcome::Skipped {
1358 reason: format!("reply did not parse: {err}"),
1359 };
1360 }
1361 };
1362 if proposal.ops.is_empty() {
1363 return HygieneOutcome::Skipped {
1364 reason: "no-op judgment (preferred output)".to_string(),
1365 };
1366 }
1367 let roles: Vec<HygieneRole> = messages.iter().map(HygieneRole::of).collect();
1368 let ordering = match cause {
1369 HygieneCause::PostCompaction => OrderingContext::SequencedAfterHarvest,
1370 HygieneCause::OnDemand => OrderingContext::Cursor(
1371 self.gate
1372 .as_ref()
1373 .map(|gate| gate.distilled_through(identity, session_key)),
1374 ),
1375 };
1376 let validated = match validate_revision(&proposal, &roles, &spans, ordering) {
1377 Ok(validated) => validated,
1378 Err(reject) => {
1379 return HygieneOutcome::Blocked {
1380 reason: reject.to_string(),
1381 };
1382 }
1383 };
1384 let run_id = self.mint_run_id();
1385 self.emit(MemoryTimelineEvent::HygieneProposed {
1386 identity: identity.to_string(),
1387 session_key: session_key.to_string(),
1388 cause: cause.as_str().to_string(),
1389 ops: validated.ops.len(),
1390 flagged_active_records: validated.flagged_active_records.clone(),
1391 });
1392 let Some((hull_start, hull_end, replacement)) =
1393 build_replacement(&messages, &validated.ops)
1394 else {
1395 return HygieneOutcome::Skipped {
1396 reason: "validated proposal had no ops".to_string(),
1397 };
1398 };
1399 let rationales = validated
1400 .ops
1401 .iter()
1402 .map(|op| op.rationale.as_str())
1403 .collect::<Vec<_>>()
1404 .join("; ");
1405 let note = truncate_utf8_boundary(&format!("{run_id}: {rationales}"), 512);
1406 match self
1407 .seam
1408 .rewrite(
1409 session_key,
1410 hull_start,
1411 hull_end,
1412 replacement,
1413 ¬e,
1414 head_revision,
1415 )
1416 .await
1417 {
1418 Ok(revision) => HygieneOutcome::Applied {
1419 run_id,
1420 revision,
1421 ops: validated.ops.len(),
1422 flagged_active_records: validated.flagged_active_records,
1423 },
1424 Err(err) => HygieneOutcome::Skipped {
1425 reason: format!("revision apply refused: {err}"),
1426 },
1427 }
1428 }
1429
1430 pub fn spawn_detached(
1432 self: &Arc<Self>,
1433 identity: &str,
1434 session_key: &str,
1435 cause: HygieneCause,
1436 ) {
1437 let engine = self.clone();
1438 let identity = identity.to_string();
1439 let session_key = session_key.to_string();
1440 tokio::spawn(async move {
1441 engine.hygiene_now(&identity, &session_key, cause).await;
1442 });
1443 }
1444}
1445
1446pub fn distiller_follow_up(engine: Arc<HygienistEngine>) -> CompactionFollowUp {
1452 Arc::new(
1453 move |identity: &str, session_key: &str, outcome: &DistillOutcome| {
1454 if outcome.compaction_harvest_satisfied() {
1455 engine.spawn_detached(identity, session_key, HygieneCause::PostCompaction);
1456 } else {
1457 let reason = match outcome {
1458 DistillOutcome::Skipped { reason } => reason.clone(),
1459 DistillOutcome::Completed { .. } => unreachable!("completed harvests satisfy"),
1460 };
1461 tracing::warn!(
1462 identity,
1463 session_key,
1464 reason,
1465 "agent memory hygienist: post-compaction pass withheld (harvest incomplete)"
1466 );
1467 engine.emit(MemoryTimelineEvent::HygieneSkipped {
1468 identity: identity.to_string(),
1469 session_key: session_key.to_string(),
1470 cause: HygieneCause::PostCompaction.as_str().to_string(),
1471 reason: format!("distiller harvest incomplete: {reason}"),
1472 });
1473 }
1474 },
1475 )
1476}
1477
1478pub struct HygienistTriggers {
1487 engine: Arc<HygienistEngine>,
1488}
1489
1490impl HygienistTriggers {
1491 pub fn new(engine: Arc<HygienistEngine>) -> Self {
1492 Self { engine }
1493 }
1494}
1495
1496impl MemberAgentEventSink for HygienistTriggers {
1497 fn observe(&self, identity: &str, envelope: &meerkat_core::event::EventEnvelope<AgentEvent>) {
1498 let identity = crate::member_comms_id::logical_memory_identity(identity);
1501 let identity = identity.as_str();
1502 if let AgentEvent::CompactionCompleted { .. } = &envelope.payload {
1503 match &envelope.source {
1504 meerkat_core::event::EventSourceIdentity::Session { session_id } => {
1505 self.engine.spawn_detached(
1506 identity,
1507 &session_id.to_string(),
1508 HygieneCause::PostCompaction,
1509 );
1510 }
1511 _ => {
1512 tracing::warn!(
1513 identity,
1514 "agent memory hygienist: compaction event without session \
1515 attribution; pass skipped"
1516 );
1517 }
1518 }
1519 }
1520 }
1521}
1522
1523pub async fn complete_text(
1529 client: &dyn LlmClient,
1530 profile: &HygienistProfile,
1531 prompt: String,
1532) -> Result<String, HygienistError> {
1533 let request = LlmRequest::new(
1534 &profile.model,
1535 vec![Message::User(UserMessage::text(prompt))],
1536 )
1537 .with_max_tokens(profile.params.max_output_tokens)
1538 .with_temperature(profile.params.temperature);
1539 let mut stream = client.stream(&request);
1540 let mut text = String::new();
1541 while let Some(event) = stream.next().await {
1542 match event.map_err(classify_llm_error)? {
1543 LlmEvent::TextDelta { delta, .. } => text.push_str(&delta),
1544 LlmEvent::Done { outcome } => match outcome {
1545 LlmDoneOutcome::Success { .. } => break,
1546 LlmDoneOutcome::Error { error } => return Err(classify_llm_error(error)),
1547 },
1548 _ => {}
1549 }
1550 }
1551 Ok(text)
1552}
1553
1554fn classify_llm_error(error: LlmError) -> HygienistError {
1555 match error {
1556 LlmError::AuthenticationFailed { .. } | LlmError::InvalidApiKey => {
1557 HygienistError::Auth(error.to_string())
1558 }
1559 other => HygienistError::Client(other.to_string()),
1560 }
1561}
1562
1563fn now_ms() -> u64 {
1564 std::time::SystemTime::now()
1565 .duration_since(std::time::UNIX_EPOCH)
1566 .map(|duration| duration.as_millis() as u64)
1567 .unwrap_or(0)
1568}
1569
1570#[cfg(test)]
1571#[allow(clippy::expect_used, clippy::panic)]
1572mod tests {
1573 use super::*;
1574 use meerkat_core::StopReason;
1575 use meerkat_core::types::{AssistantBlock, BlockAssistantMessage, ToolResult};
1576 use std::sync::Mutex as StdMutex;
1577
1578 fn user(text: &str) -> Message {
1579 Message::User(UserMessage::text(text))
1580 }
1581
1582 fn assistant(text: &str) -> Message {
1583 Message::BlockAssistant(BlockAssistantMessage::new(
1584 vec![AssistantBlock::Text {
1585 text: text.to_string(),
1586 meta: None,
1587 }],
1588 StopReason::EndTurn,
1589 ))
1590 }
1591
1592 fn assistant_tool_call(id: &str) -> Message {
1593 let args = serde_json::value::RawValue::from_string(r#"{"cmd":"ls"}"#.to_string())
1594 .expect("raw args");
1595 Message::BlockAssistant(BlockAssistantMessage::new(
1596 vec![AssistantBlock::ToolUse {
1597 id: id.to_string(),
1598 name: "shell".to_string(),
1599 args,
1600 meta: None,
1601 }],
1602 StopReason::ToolUse,
1603 ))
1604 }
1605
1606 fn tool_results(id: &str, text: &str) -> Message {
1607 Message::tool_results(vec![ToolResult::new(
1608 id.to_string(),
1609 text.to_string(),
1610 false,
1611 )])
1612 }
1613
1614 fn transcript() -> Vec<Message> {
1615 vec![
1616 user("please check the logs"), assistant_tool_call("call-1"), tool_results("call-1", "3000 lines of log"), assistant("logs are clean; decision: ship"), user("scaffold notice"), user("scaffold notice"), user("scaffold notice"), assistant("done"), ]
1625 }
1626
1627 fn roles(messages: &[Message]) -> Vec<HygieneRole> {
1628 messages.iter().map(HygieneRole::of).collect()
1629 }
1630
1631 fn prune(start: usize, end: usize) -> RevisionOp {
1632 RevisionOp {
1633 action: RevisionAction::PruneToolResults,
1634 start,
1635 end,
1636 rationale: "dead output".to_string(),
1637 }
1638 }
1639
1640 fn collapse(start: usize, end: usize) -> RevisionOp {
1641 RevisionOp {
1642 action: RevisionAction::Collapse {
1643 replacement: "repeated scaffolding".to_string(),
1644 },
1645 start,
1646 end,
1647 rationale: "scaffolding".to_string(),
1648 }
1649 }
1650
1651 #[test]
1654 fn parse_accepts_ops_and_rejects_unknown() {
1655 let proposal = parse_revision_reply(
1656 r#"{"ops": [
1657 {"op": "prune_tool_results", "range": [2, 3], "rationale": "dead"},
1658 {"op": "collapse", "range": [4, 7], "replacement": "notices", "rationale": "dup"}
1659 ]}"#,
1660 )
1661 .expect("parses");
1662 assert_eq!(proposal.ops.len(), 2);
1663 assert_eq!(proposal.ops[0].action, RevisionAction::PruneToolResults);
1664 assert!(matches!(
1665 proposal.ops[1].action,
1666 RevisionAction::Collapse { .. }
1667 ));
1668
1669 assert!(parse_revision_reply(r#"{"ops": [{"op": "delete", "range": [0, 1]}]}"#).is_err());
1670 assert!(
1671 parse_revision_reply(r#"{"ops": [{"op": "collapse", "range": [0, 1]}]}"#).is_err(),
1672 "collapse without replacement must not parse"
1673 );
1674 assert_eq!(
1675 parse_revision_reply(r#"{"ops": []}"#).expect("noop parses"),
1676 RevisionProposal::default()
1677 );
1678 assert!(parse_revision_reply("Sure! {\"ops\": []} Done.").is_ok());
1680 }
1681
1682 #[test]
1685 fn validator_accepts_legal_prune_and_collapse() {
1686 let messages = transcript();
1687 let proposal = RevisionProposal {
1688 ops: vec![prune(2, 3), collapse(4, 7)],
1689 };
1690 let validated = validate_revision(
1691 &proposal,
1692 &roles(&messages),
1693 &[],
1694 OrderingContext::SequencedAfterHarvest,
1695 )
1696 .expect("legal ops validate");
1697 assert_eq!(validated.ops.len(), 2);
1698 assert!(validated.flagged_active_records.is_empty());
1699 }
1700
1701 #[test]
1702 fn validator_rejects_malformed_ranges() {
1703 let messages = transcript();
1704 let roles = roles(&messages);
1705 for (proposal, name) in [
1706 (
1707 RevisionProposal {
1708 ops: vec![prune(3, 3)],
1709 },
1710 "empty",
1711 ),
1712 (
1713 RevisionProposal {
1714 ops: vec![prune(2, 99)],
1715 },
1716 "out of bounds",
1717 ),
1718 (
1719 RevisionProposal {
1720 ops: vec![collapse(4, 7), collapse(5, 8)],
1721 },
1722 "overlap",
1723 ),
1724 ] {
1725 let result = validate_revision(
1726 &proposal,
1727 &roles,
1728 &[],
1729 OrderingContext::SequencedAfterHarvest,
1730 );
1731 assert!(
1732 matches!(result, Err(RevisionReject::InvalidRange { .. })),
1733 "{name}: {result:?}"
1734 );
1735 }
1736 }
1737
1738 #[test]
1739 fn validator_enforces_role_law() {
1740 let messages = transcript();
1741 let roles = roles(&messages);
1742 let result = validate_revision(
1744 &RevisionProposal {
1745 ops: vec![prune(2, 4)],
1746 },
1747 &roles,
1748 &[],
1749 OrderingContext::SequencedAfterHarvest,
1750 );
1751 assert!(matches!(result, Err(RevisionReject::IllegalRole { .. })));
1752 let result = validate_revision(
1754 &RevisionProposal {
1755 ops: vec![collapse(1, 3)],
1756 },
1757 &roles,
1758 &[],
1759 OrderingContext::SequencedAfterHarvest,
1760 );
1761 assert!(matches!(result, Err(RevisionReject::IllegalRole { .. })));
1762 }
1763
1764 #[test]
1765 fn validator_hard_blocks_quarantine_referenced_spans() {
1766 let messages = transcript();
1767 let spans = vec![SpanReference {
1768 record_id: "mem-q".to_string(),
1769 quarantined: true,
1770 range: Some((2, 2)),
1771 }];
1772 let result = validate_revision(
1773 &RevisionProposal {
1774 ops: vec![prune(2, 3)],
1775 },
1776 &roles(&messages),
1777 &spans,
1778 OrderingContext::SequencedAfterHarvest,
1779 );
1780 assert!(
1781 matches!(
1782 result,
1783 Err(RevisionReject::QuarantineReferenced { ref record_id }) if record_id == "mem-q"
1784 ),
1785 "{result:?}"
1786 );
1787 let spans = vec![SpanReference {
1789 record_id: "mem-q2".to_string(),
1790 quarantined: true,
1791 range: None,
1792 }];
1793 let result = validate_revision(
1794 &RevisionProposal {
1795 ops: vec![collapse(4, 7)],
1796 },
1797 &roles(&messages),
1798 &spans,
1799 OrderingContext::SequencedAfterHarvest,
1800 );
1801 assert!(matches!(
1802 result,
1803 Err(RevisionReject::QuarantineReferenced { .. })
1804 ));
1805 }
1806
1807 #[test]
1808 fn validator_flags_active_spans_without_blocking() {
1809 let messages = transcript();
1810 let spans = vec![
1811 SpanReference {
1812 record_id: "mem-a".to_string(),
1813 quarantined: false,
1814 range: Some((2, 2)),
1815 },
1816 SpanReference {
1817 record_id: "mem-elsewhere".to_string(),
1818 quarantined: false,
1819 range: Some((7, 7)),
1820 },
1821 ];
1822 let validated = validate_revision(
1823 &RevisionProposal {
1824 ops: vec![prune(2, 3)],
1825 },
1826 &roles(&messages),
1827 &spans,
1828 OrderingContext::SequencedAfterHarvest,
1829 )
1830 .expect("active spans flag, not block");
1831 assert_eq!(validated.flagged_active_records, vec!["mem-a".to_string()]);
1832 }
1833
1834 #[test]
1835 fn validator_refuses_when_ordering_invariant_unmet() {
1836 let messages = transcript();
1837 let result = validate_revision(
1838 &RevisionProposal {
1839 ops: vec![prune(2, 3)],
1840 },
1841 &roles(&messages),
1842 &[],
1843 OrderingContext::Cursor(Some(1)),
1844 );
1845 assert!(
1846 matches!(
1847 result,
1848 Err(RevisionReject::OrderingUnmet {
1849 cursor: 1,
1850 needed: 3
1851 })
1852 ),
1853 "{result:?}"
1854 );
1855 assert!(
1857 validate_revision(
1858 &RevisionProposal {
1859 ops: vec![prune(2, 3)],
1860 },
1861 &roles(&messages),
1862 &[],
1863 OrderingContext::Cursor(Some(3)),
1864 )
1865 .is_ok()
1866 );
1867 assert!(
1868 validate_revision(
1869 &RevisionProposal {
1870 ops: vec![prune(2, 3)],
1871 },
1872 &roles(&messages),
1873 &[],
1874 OrderingContext::Cursor(None),
1875 )
1876 .is_ok()
1877 );
1878 }
1879
1880 #[test]
1883 fn replacement_preserves_pairing_and_collapses_runs() {
1884 let messages = transcript();
1885 let ops = vec![prune(2, 3), collapse(4, 7)];
1886 let (start, end, replacement) =
1887 build_replacement(&messages, &ops).expect("ops produce a hull");
1888 assert_eq!((start, end), (2, 7));
1889 assert_eq!(replacement.len(), 3);
1891 match &replacement[0] {
1892 Message::ToolResults { results, .. } => {
1893 assert_eq!(results[0].tool_use_id, "call-1");
1894 let text = meerkat_core::types::text_content(&results[0].content);
1895 assert!(text.contains("[pruned by hygienist"), "{text}");
1896 }
1897 other => panic!("expected tool results, got {other:?}"),
1898 }
1899 match &replacement[1] {
1900 Message::BlockAssistant(assistant) => {
1901 assert!(assistant.text_blocks().any(|text| text.contains("ship")));
1902 }
1903 other => panic!("expected assistant, got {other:?}"),
1904 }
1905 match &replacement[2] {
1906 Message::SystemNotice(notice) => {
1907 let body = notice.body.as_deref().unwrap_or_default();
1908 assert!(body.contains("collapsed 3 messages"), "{body}");
1909 assert!(body.contains("repeated scaffolding"), "{body}");
1910 }
1911 other => panic!("expected system notice, got {other:?}"),
1912 }
1913 }
1914
1915 struct ScriptedSeam {
1918 messages: Vec<Message>,
1919 rewrites: StdMutex<Vec<(usize, usize, usize)>>,
1920 refuse: bool,
1921 #[allow(clippy::option_option)]
1925 last_expected_parent: StdMutex<Option<Option<String>>>,
1926 }
1927
1928 const SCRIPTED_HEAD_REVISION: &str = "rev-head-at-read";
1931
1932 #[async_trait]
1933 impl TranscriptRevisionSeam for ScriptedSeam {
1934 async fn read_messages(
1935 &self,
1936 _session_key: &str,
1937 ) -> Result<Option<(Vec<Message>, Option<String>)>, String> {
1938 Ok(Some((
1939 self.messages.clone(),
1940 Some(SCRIPTED_HEAD_REVISION.to_string()),
1941 )))
1942 }
1943
1944 async fn rewrite(
1945 &self,
1946 _session_key: &str,
1947 start: usize,
1948 end: usize,
1949 replacement: Vec<Message>,
1950 _note: &str,
1951 expected_parent_revision: Option<String>,
1952 ) -> Result<AppliedRevision, String> {
1953 *self
1954 .last_expected_parent
1955 .lock()
1956 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1957 Some(expected_parent_revision);
1958 if self.refuse {
1959 return Err("session is running".to_string());
1960 }
1961 self.rewrites
1962 .lock()
1963 .unwrap_or_else(std::sync::PoisonError::into_inner)
1964 .push((start, end, replacement.len()));
1965 Ok(AppliedRevision {
1966 parent_revision: "rev-parent".to_string(),
1967 revision: "rev-new".to_string(),
1968 message_count: self.messages.len() - (end - start) + replacement.len(),
1969 })
1970 }
1971 }
1972
1973 struct ScriptedSpans(Vec<SpanReference>);
1974
1975 #[async_trait]
1976 impl SpanReferenceSource for ScriptedSpans {
1977 async fn span_references(
1978 &self,
1979 _identity: &str,
1980 _session_key: &str,
1981 ) -> Result<Vec<SpanReference>, String> {
1982 Ok(self.0.clone())
1983 }
1984 }
1985
1986 struct ScriptedLlm {
1987 reply: String,
1988 }
1989
1990 #[async_trait]
1991 impl LlmClient for ScriptedLlm {
1992 fn stream<'a>(&'a self, _request: &'a LlmRequest) -> meerkat_client::types::LlmStream<'a> {
1993 let reply = self.reply.clone();
1994 Box::pin(futures::stream::iter(vec![
1995 Ok(LlmEvent::TextDelta {
1996 delta: reply,
1997 meta: None,
1998 }),
1999 Ok(LlmEvent::Done {
2000 outcome: LlmDoneOutcome::Success {
2001 stop_reason: meerkat_core::StopReason::EndTurn,
2002 },
2003 }),
2004 ]))
2005 }
2006
2007 fn provider(&self) -> Provider {
2008 Provider::Other
2009 }
2010
2011 async fn health_check(&self) -> Result<(), LlmError> {
2012 Ok(())
2013 }
2014 }
2015
2016 struct ScriptedHandle {
2017 reply: String,
2018 }
2019
2020 #[async_trait]
2021 impl HygienistClientHandle for ScriptedHandle {
2022 async fn client(&self) -> Result<Arc<dyn LlmClient>, HygienistError> {
2023 Ok(Arc::new(ScriptedLlm {
2024 reply: self.reply.clone(),
2025 }))
2026 }
2027 }
2028
2029 struct FixedGate(u64);
2030
2031 impl DistillationGate for FixedGate {
2032 fn distilled_through(&self, _identity: &str, _session_key: &str) -> u64 {
2033 self.0
2034 }
2035 }
2036
2037 fn engine_with(
2038 reply: &str,
2039 seam: Arc<ScriptedSeam>,
2040 spans: Vec<SpanReference>,
2041 gate: Option<Arc<dyn DistillationGate>>,
2042 runs_per_day: u32,
2043 ) -> Arc<HygienistEngine> {
2044 Arc::new(HygienistEngine::new(
2045 HygienistProfile::embedded_default(),
2046 HygienistConfig {
2047 enabled: true,
2048 runs_per_day,
2049 model: None,
2050 },
2051 Arc::new(ScriptedHandle {
2052 reply: reply.to_string(),
2053 }),
2054 seam,
2055 Arc::new(ScriptedSpans(spans)),
2056 gate,
2057 "family",
2058 ))
2059 }
2060
2061 fn seam() -> Arc<ScriptedSeam> {
2062 Arc::new(ScriptedSeam {
2063 messages: transcript(),
2064 rewrites: StdMutex::new(Vec::new()),
2065 refuse: false,
2066 last_expected_parent: StdMutex::new(None),
2067 })
2068 }
2069
2070 const PRUNE_REPLY: &str =
2071 r#"{"ops": [{"op": "prune_tool_results", "range": [2, 3], "rationale": "dead"}]}"#;
2072
2073 #[tokio::test]
2074 async fn engine_applies_validated_revision_and_emits_events() {
2075 let scripted = seam();
2076 let engine = engine_with(PRUNE_REPLY, scripted.clone(), Vec::new(), None, 2);
2077 let sink = Arc::new(crate::memory::events::CollectingEventSink::new());
2078 engine.set_event_sink(sink.clone());
2079 let outcome = engine
2080 .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2081 .await;
2082 match outcome {
2083 HygieneOutcome::Applied { revision, ops, .. } => {
2084 assert_eq!(revision.revision, "rev-new");
2085 assert_eq!(ops, 1);
2086 }
2087 other => panic!("expected Applied, got {other:?}"),
2088 }
2089 assert_eq!(
2090 scripted
2091 .rewrites
2092 .lock()
2093 .unwrap_or_else(std::sync::PoisonError::into_inner)
2094 .as_slice(),
2095 &[(2, 3, 1)]
2096 );
2097 assert_eq!(
2100 scripted
2101 .last_expected_parent
2102 .lock()
2103 .unwrap_or_else(std::sync::PoisonError::into_inner)
2104 .clone(),
2105 Some(Some(SCRIPTED_HEAD_REVISION.to_string())),
2106 "hygiene must CAS against the head it read"
2107 );
2108 assert_eq!(
2109 sink.types(),
2110 vec!["memory.hygiene.proposed", "memory.hygiene.applied"]
2111 );
2112 }
2113
2114 #[tokio::test]
2115 async fn engine_blocks_quarantine_referenced_revision() {
2116 let scripted = seam();
2117 let spans = vec![SpanReference {
2118 record_id: "mem-q".to_string(),
2119 quarantined: true,
2120 range: Some((2, 2)),
2121 }];
2122 let engine = engine_with(PRUNE_REPLY, scripted.clone(), spans, None, 2);
2123 let sink = Arc::new(crate::memory::events::CollectingEventSink::new());
2124 engine.set_event_sink(sink.clone());
2125 let outcome = engine
2126 .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2127 .await;
2128 assert!(
2129 matches!(outcome, HygieneOutcome::Blocked { .. }),
2130 "{outcome:?}"
2131 );
2132 assert!(
2133 scripted
2134 .rewrites
2135 .lock()
2136 .unwrap_or_else(std::sync::PoisonError::into_inner)
2137 .is_empty(),
2138 "blocked revision must not reach the seam"
2139 );
2140 assert_eq!(sink.types(), vec!["memory.hygiene.blocked"]);
2141 }
2142
2143 #[tokio::test]
2144 async fn engine_refuses_on_demand_beyond_distiller_cursor() {
2145 let scripted = seam();
2146 let engine = engine_with(
2147 PRUNE_REPLY,
2148 scripted.clone(),
2149 Vec::new(),
2150 Some(Arc::new(FixedGate(1))),
2151 2,
2152 );
2153 let outcome = engine
2154 .hygiene_now("identity:luka", "sess-1", HygieneCause::OnDemand)
2155 .await;
2156 assert!(
2157 matches!(outcome, HygieneOutcome::Blocked { .. }),
2158 "{outcome:?}"
2159 );
2160 let outcome = engine
2162 .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2163 .await;
2164 assert!(
2165 matches!(outcome, HygieneOutcome::Applied { .. }),
2166 "{outcome:?}"
2167 );
2168 }
2169
2170 #[tokio::test]
2171 async fn engine_skips_noop_and_respects_budget() {
2172 let scripted = seam();
2173 let engine = engine_with(r#"{"ops": []}"#, scripted.clone(), Vec::new(), None, 1);
2174 let outcome = engine
2175 .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2176 .await;
2177 assert!(
2178 matches!(&outcome, HygieneOutcome::Skipped { reason } if reason.contains("no-op")),
2179 "{outcome:?}"
2180 );
2181 let outcome = engine
2183 .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2184 .await;
2185 assert!(
2186 matches!(&outcome, HygieneOutcome::Skipped { reason } if reason.contains("budget denied")),
2187 "{outcome:?}"
2188 );
2189 }
2190
2191 #[tokio::test]
2192 async fn engine_reports_seam_refusal_as_skip() {
2193 let scripted = Arc::new(ScriptedSeam {
2194 messages: transcript(),
2195 rewrites: StdMutex::new(Vec::new()),
2196 refuse: true,
2197 last_expected_parent: StdMutex::new(None),
2198 });
2199 let engine = engine_with(PRUNE_REPLY, scripted, Vec::new(), None, 2);
2200 let outcome = engine
2201 .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2202 .await;
2203 assert!(
2204 matches!(&outcome, HygieneOutcome::Skipped { reason } if reason.contains("apply refused")),
2205 "{outcome:?}"
2206 );
2207 }
2208
2209 #[tokio::test]
2212 async fn follow_up_runs_after_satisfied_harvest_and_withholds_otherwise() {
2213 let scripted = seam();
2214 let engine = engine_with(PRUNE_REPLY, scripted.clone(), Vec::new(), None, 4);
2215 let sink = Arc::new(crate::memory::events::CollectingEventSink::new());
2216 engine.set_event_sink(sink.clone());
2217 let follow_up = distiller_follow_up(engine);
2218
2219 follow_up(
2220 "identity:luka",
2221 "sess-1",
2222 &DistillOutcome::Skipped {
2223 reason: "budget denied: window budget exhausted (2/2 runs)".to_string(),
2224 },
2225 );
2226 assert_eq!(sink.types(), vec!["memory.hygiene.skipped"]);
2228
2229 follow_up(
2230 "identity:luka",
2231 "sess-1",
2232 &DistillOutcome::Completed {
2233 run_id: "distill-1".to_string(),
2234 written: 0,
2235 quarantined: 0,
2236 },
2237 );
2238 for _ in 0..100 {
2240 if scripted
2241 .rewrites
2242 .lock()
2243 .unwrap_or_else(std::sync::PoisonError::into_inner)
2244 .len()
2245 == 1
2246 {
2247 break;
2248 }
2249 tokio::time::sleep(Duration::from_millis(10)).await;
2250 }
2251 assert_eq!(
2252 scripted
2253 .rewrites
2254 .lock()
2255 .unwrap_or_else(std::sync::PoisonError::into_inner)
2256 .len(),
2257 1,
2258 "satisfied harvest must trigger the pass"
2259 );
2260 }
2261
2262 #[test]
2265 fn embedded_prompt_matches_calibration_bundle() -> Result<(), Box<dyn std::error::Error>> {
2266 let bundle =
2267 Path::new(env!("CARGO_MANIFEST_DIR")).join("../memory-evals/prompts/hygienist-v0.md");
2268 if !bundle.is_file() {
2269 return Ok(());
2270 }
2271 let text = std::fs::read_to_string(bundle)?;
2272 assert_eq!(
2273 text, EMBEDDED_PROMPT_V0,
2274 "memory-evals/prompts/hygienist-v0.md and src/memory/hygienist_prompt_v0.md have drifted"
2275 );
2276 Ok(())
2277 }
2278
2279 #[test]
2280 fn model_override_is_fail_loud() {
2281 let profile = HygienistProfile::embedded_default();
2282 assert!(profile.clone().with_model_override("").is_err());
2283 assert!(
2284 profile
2285 .clone()
2286 .with_model_override("not-a-real-model-xyz")
2287 .is_err()
2288 );
2289 let overridden = profile
2290 .with_model_override("claude-haiku-4-5")
2291 .expect("catalog model accepted");
2292 assert_eq!(overridden.model, "claude-haiku-4-5");
2293 }
2294}