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::distiller::{CompactionFollowUp, DistillOutcome, DistillerEngine};
46use crate::memory::events::{MemoryEventSink, MemoryTimelineEvent};
47use crate::memory::guards::{BackgroundBudget, BackgroundBudgetConfig};
48use crate::memory::records::{ManifestTier, MemoryScope, RecordStatus};
49use crate::memory::selector::FactorySelectorHandle;
50use crate::memory::sqlite_store::SqliteAgentMemoryStore;
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 {
552 store: Arc<SqliteAgentMemoryStore>,
553 realm: String,
554}
555
556impl StoreSpanReferenceSource {
557 pub fn new(store: Arc<SqliteAgentMemoryStore>, realm: impl Into<String>) -> Self {
558 Self {
559 store,
560 realm: realm.into(),
561 }
562 }
563}
564
565#[async_trait]
566impl SpanReferenceSource for StoreSpanReferenceSource {
567 async fn span_references(
568 &self,
569 identity: &str,
570 session_key: &str,
571 ) -> Result<Vec<SpanReference>, String> {
572 use crate::identity_first::agent_memory::AgentMemoryProvider;
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 if let AgentEvent::CompactionCompleted { .. } = &envelope.payload {
1499 match &envelope.source {
1500 meerkat_core::event::EventSourceIdentity::Session { session_id } => {
1501 self.engine.spawn_detached(
1502 identity,
1503 &session_id.to_string(),
1504 HygieneCause::PostCompaction,
1505 );
1506 }
1507 _ => {
1508 tracing::warn!(
1509 identity,
1510 "agent memory hygienist: compaction event without session \
1511 attribution; pass skipped"
1512 );
1513 }
1514 }
1515 }
1516 }
1517}
1518
1519pub async fn complete_text(
1525 client: &dyn LlmClient,
1526 profile: &HygienistProfile,
1527 prompt: String,
1528) -> Result<String, HygienistError> {
1529 let request = LlmRequest::new(
1530 &profile.model,
1531 vec![Message::User(UserMessage::text(prompt))],
1532 )
1533 .with_max_tokens(profile.params.max_output_tokens)
1534 .with_temperature(profile.params.temperature);
1535 let mut stream = client.stream(&request);
1536 let mut text = String::new();
1537 while let Some(event) = stream.next().await {
1538 match event.map_err(classify_llm_error)? {
1539 LlmEvent::TextDelta { delta, .. } => text.push_str(&delta),
1540 LlmEvent::Done { outcome } => match outcome {
1541 LlmDoneOutcome::Success { .. } => break,
1542 LlmDoneOutcome::Error { error } => return Err(classify_llm_error(error)),
1543 },
1544 _ => {}
1545 }
1546 }
1547 Ok(text)
1548}
1549
1550fn classify_llm_error(error: LlmError) -> HygienistError {
1551 match error {
1552 LlmError::AuthenticationFailed { .. } | LlmError::InvalidApiKey => {
1553 HygienistError::Auth(error.to_string())
1554 }
1555 other => HygienistError::Client(other.to_string()),
1556 }
1557}
1558
1559fn now_ms() -> u64 {
1560 std::time::SystemTime::now()
1561 .duration_since(std::time::UNIX_EPOCH)
1562 .map(|duration| duration.as_millis() as u64)
1563 .unwrap_or(0)
1564}
1565
1566#[cfg(test)]
1567#[allow(clippy::expect_used, clippy::panic)]
1568mod tests {
1569 use super::*;
1570 use meerkat_core::StopReason;
1571 use meerkat_core::types::{AssistantBlock, BlockAssistantMessage, ToolResult};
1572 use std::sync::Mutex as StdMutex;
1573
1574 fn user(text: &str) -> Message {
1575 Message::User(UserMessage::text(text))
1576 }
1577
1578 fn assistant(text: &str) -> Message {
1579 Message::BlockAssistant(BlockAssistantMessage::new(
1580 vec![AssistantBlock::Text {
1581 text: text.to_string(),
1582 meta: None,
1583 }],
1584 StopReason::EndTurn,
1585 ))
1586 }
1587
1588 fn assistant_tool_call(id: &str) -> Message {
1589 let args = serde_json::value::RawValue::from_string(r#"{"cmd":"ls"}"#.to_string())
1590 .expect("raw args");
1591 Message::BlockAssistant(BlockAssistantMessage::new(
1592 vec![AssistantBlock::ToolUse {
1593 id: id.to_string(),
1594 name: "shell".to_string(),
1595 args,
1596 meta: None,
1597 }],
1598 StopReason::ToolUse,
1599 ))
1600 }
1601
1602 fn tool_results(id: &str, text: &str) -> Message {
1603 Message::tool_results(vec![ToolResult::new(
1604 id.to_string(),
1605 text.to_string(),
1606 false,
1607 )])
1608 }
1609
1610 fn transcript() -> Vec<Message> {
1611 vec![
1612 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"), ]
1621 }
1622
1623 fn roles(messages: &[Message]) -> Vec<HygieneRole> {
1624 messages.iter().map(HygieneRole::of).collect()
1625 }
1626
1627 fn prune(start: usize, end: usize) -> RevisionOp {
1628 RevisionOp {
1629 action: RevisionAction::PruneToolResults,
1630 start,
1631 end,
1632 rationale: "dead output".to_string(),
1633 }
1634 }
1635
1636 fn collapse(start: usize, end: usize) -> RevisionOp {
1637 RevisionOp {
1638 action: RevisionAction::Collapse {
1639 replacement: "repeated scaffolding".to_string(),
1640 },
1641 start,
1642 end,
1643 rationale: "scaffolding".to_string(),
1644 }
1645 }
1646
1647 #[test]
1650 fn parse_accepts_ops_and_rejects_unknown() {
1651 let proposal = parse_revision_reply(
1652 r#"{"ops": [
1653 {"op": "prune_tool_results", "range": [2, 3], "rationale": "dead"},
1654 {"op": "collapse", "range": [4, 7], "replacement": "notices", "rationale": "dup"}
1655 ]}"#,
1656 )
1657 .expect("parses");
1658 assert_eq!(proposal.ops.len(), 2);
1659 assert_eq!(proposal.ops[0].action, RevisionAction::PruneToolResults);
1660 assert!(matches!(
1661 proposal.ops[1].action,
1662 RevisionAction::Collapse { .. }
1663 ));
1664
1665 assert!(parse_revision_reply(r#"{"ops": [{"op": "delete", "range": [0, 1]}]}"#).is_err());
1666 assert!(
1667 parse_revision_reply(r#"{"ops": [{"op": "collapse", "range": [0, 1]}]}"#).is_err(),
1668 "collapse without replacement must not parse"
1669 );
1670 assert_eq!(
1671 parse_revision_reply(r#"{"ops": []}"#).expect("noop parses"),
1672 RevisionProposal::default()
1673 );
1674 assert!(parse_revision_reply("Sure! {\"ops\": []} Done.").is_ok());
1676 }
1677
1678 #[test]
1681 fn validator_accepts_legal_prune_and_collapse() {
1682 let messages = transcript();
1683 let proposal = RevisionProposal {
1684 ops: vec![prune(2, 3), collapse(4, 7)],
1685 };
1686 let validated = validate_revision(
1687 &proposal,
1688 &roles(&messages),
1689 &[],
1690 OrderingContext::SequencedAfterHarvest,
1691 )
1692 .expect("legal ops validate");
1693 assert_eq!(validated.ops.len(), 2);
1694 assert!(validated.flagged_active_records.is_empty());
1695 }
1696
1697 #[test]
1698 fn validator_rejects_malformed_ranges() {
1699 let messages = transcript();
1700 let roles = roles(&messages);
1701 for (proposal, name) in [
1702 (
1703 RevisionProposal {
1704 ops: vec![prune(3, 3)],
1705 },
1706 "empty",
1707 ),
1708 (
1709 RevisionProposal {
1710 ops: vec![prune(2, 99)],
1711 },
1712 "out of bounds",
1713 ),
1714 (
1715 RevisionProposal {
1716 ops: vec![collapse(4, 7), collapse(5, 8)],
1717 },
1718 "overlap",
1719 ),
1720 ] {
1721 let result = validate_revision(
1722 &proposal,
1723 &roles,
1724 &[],
1725 OrderingContext::SequencedAfterHarvest,
1726 );
1727 assert!(
1728 matches!(result, Err(RevisionReject::InvalidRange { .. })),
1729 "{name}: {result:?}"
1730 );
1731 }
1732 }
1733
1734 #[test]
1735 fn validator_enforces_role_law() {
1736 let messages = transcript();
1737 let roles = roles(&messages);
1738 let result = validate_revision(
1740 &RevisionProposal {
1741 ops: vec![prune(2, 4)],
1742 },
1743 &roles,
1744 &[],
1745 OrderingContext::SequencedAfterHarvest,
1746 );
1747 assert!(matches!(result, Err(RevisionReject::IllegalRole { .. })));
1748 let result = validate_revision(
1750 &RevisionProposal {
1751 ops: vec![collapse(1, 3)],
1752 },
1753 &roles,
1754 &[],
1755 OrderingContext::SequencedAfterHarvest,
1756 );
1757 assert!(matches!(result, Err(RevisionReject::IllegalRole { .. })));
1758 }
1759
1760 #[test]
1761 fn validator_hard_blocks_quarantine_referenced_spans() {
1762 let messages = transcript();
1763 let spans = vec![SpanReference {
1764 record_id: "mem-q".to_string(),
1765 quarantined: true,
1766 range: Some((2, 2)),
1767 }];
1768 let result = validate_revision(
1769 &RevisionProposal {
1770 ops: vec![prune(2, 3)],
1771 },
1772 &roles(&messages),
1773 &spans,
1774 OrderingContext::SequencedAfterHarvest,
1775 );
1776 assert!(
1777 matches!(
1778 result,
1779 Err(RevisionReject::QuarantineReferenced { ref record_id }) if record_id == "mem-q"
1780 ),
1781 "{result:?}"
1782 );
1783 let spans = vec![SpanReference {
1785 record_id: "mem-q2".to_string(),
1786 quarantined: true,
1787 range: None,
1788 }];
1789 let result = validate_revision(
1790 &RevisionProposal {
1791 ops: vec![collapse(4, 7)],
1792 },
1793 &roles(&messages),
1794 &spans,
1795 OrderingContext::SequencedAfterHarvest,
1796 );
1797 assert!(matches!(
1798 result,
1799 Err(RevisionReject::QuarantineReferenced { .. })
1800 ));
1801 }
1802
1803 #[test]
1804 fn validator_flags_active_spans_without_blocking() {
1805 let messages = transcript();
1806 let spans = vec![
1807 SpanReference {
1808 record_id: "mem-a".to_string(),
1809 quarantined: false,
1810 range: Some((2, 2)),
1811 },
1812 SpanReference {
1813 record_id: "mem-elsewhere".to_string(),
1814 quarantined: false,
1815 range: Some((7, 7)),
1816 },
1817 ];
1818 let validated = validate_revision(
1819 &RevisionProposal {
1820 ops: vec![prune(2, 3)],
1821 },
1822 &roles(&messages),
1823 &spans,
1824 OrderingContext::SequencedAfterHarvest,
1825 )
1826 .expect("active spans flag, not block");
1827 assert_eq!(validated.flagged_active_records, vec!["mem-a".to_string()]);
1828 }
1829
1830 #[test]
1831 fn validator_refuses_when_ordering_invariant_unmet() {
1832 let messages = transcript();
1833 let result = validate_revision(
1834 &RevisionProposal {
1835 ops: vec![prune(2, 3)],
1836 },
1837 &roles(&messages),
1838 &[],
1839 OrderingContext::Cursor(Some(1)),
1840 );
1841 assert!(
1842 matches!(
1843 result,
1844 Err(RevisionReject::OrderingUnmet {
1845 cursor: 1,
1846 needed: 3
1847 })
1848 ),
1849 "{result:?}"
1850 );
1851 assert!(
1853 validate_revision(
1854 &RevisionProposal {
1855 ops: vec![prune(2, 3)],
1856 },
1857 &roles(&messages),
1858 &[],
1859 OrderingContext::Cursor(Some(3)),
1860 )
1861 .is_ok()
1862 );
1863 assert!(
1864 validate_revision(
1865 &RevisionProposal {
1866 ops: vec![prune(2, 3)],
1867 },
1868 &roles(&messages),
1869 &[],
1870 OrderingContext::Cursor(None),
1871 )
1872 .is_ok()
1873 );
1874 }
1875
1876 #[test]
1879 fn replacement_preserves_pairing_and_collapses_runs() {
1880 let messages = transcript();
1881 let ops = vec![prune(2, 3), collapse(4, 7)];
1882 let (start, end, replacement) =
1883 build_replacement(&messages, &ops).expect("ops produce a hull");
1884 assert_eq!((start, end), (2, 7));
1885 assert_eq!(replacement.len(), 3);
1887 match &replacement[0] {
1888 Message::ToolResults { results, .. } => {
1889 assert_eq!(results[0].tool_use_id, "call-1");
1890 let text = meerkat_core::types::text_content(&results[0].content);
1891 assert!(text.contains("[pruned by hygienist"), "{text}");
1892 }
1893 other => panic!("expected tool results, got {other:?}"),
1894 }
1895 match &replacement[1] {
1896 Message::BlockAssistant(assistant) => {
1897 assert!(assistant.text_blocks().any(|text| text.contains("ship")));
1898 }
1899 other => panic!("expected assistant, got {other:?}"),
1900 }
1901 match &replacement[2] {
1902 Message::SystemNotice(notice) => {
1903 let body = notice.body.as_deref().unwrap_or_default();
1904 assert!(body.contains("collapsed 3 messages"), "{body}");
1905 assert!(body.contains("repeated scaffolding"), "{body}");
1906 }
1907 other => panic!("expected system notice, got {other:?}"),
1908 }
1909 }
1910
1911 struct ScriptedSeam {
1914 messages: Vec<Message>,
1915 rewrites: StdMutex<Vec<(usize, usize, usize)>>,
1916 refuse: bool,
1917 #[allow(clippy::option_option)]
1921 last_expected_parent: StdMutex<Option<Option<String>>>,
1922 }
1923
1924 const SCRIPTED_HEAD_REVISION: &str = "rev-head-at-read";
1927
1928 #[async_trait]
1929 impl TranscriptRevisionSeam for ScriptedSeam {
1930 async fn read_messages(
1931 &self,
1932 _session_key: &str,
1933 ) -> Result<Option<(Vec<Message>, Option<String>)>, String> {
1934 Ok(Some((
1935 self.messages.clone(),
1936 Some(SCRIPTED_HEAD_REVISION.to_string()),
1937 )))
1938 }
1939
1940 async fn rewrite(
1941 &self,
1942 _session_key: &str,
1943 start: usize,
1944 end: usize,
1945 replacement: Vec<Message>,
1946 _note: &str,
1947 expected_parent_revision: Option<String>,
1948 ) -> Result<AppliedRevision, String> {
1949 *self
1950 .last_expected_parent
1951 .lock()
1952 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1953 Some(expected_parent_revision);
1954 if self.refuse {
1955 return Err("session is running".to_string());
1956 }
1957 self.rewrites
1958 .lock()
1959 .unwrap_or_else(std::sync::PoisonError::into_inner)
1960 .push((start, end, replacement.len()));
1961 Ok(AppliedRevision {
1962 parent_revision: "rev-parent".to_string(),
1963 revision: "rev-new".to_string(),
1964 message_count: self.messages.len() - (end - start) + replacement.len(),
1965 })
1966 }
1967 }
1968
1969 struct ScriptedSpans(Vec<SpanReference>);
1970
1971 #[async_trait]
1972 impl SpanReferenceSource for ScriptedSpans {
1973 async fn span_references(
1974 &self,
1975 _identity: &str,
1976 _session_key: &str,
1977 ) -> Result<Vec<SpanReference>, String> {
1978 Ok(self.0.clone())
1979 }
1980 }
1981
1982 struct ScriptedLlm {
1983 reply: String,
1984 }
1985
1986 #[async_trait]
1987 impl LlmClient for ScriptedLlm {
1988 fn stream<'a>(&'a self, _request: &'a LlmRequest) -> meerkat_client::types::LlmStream<'a> {
1989 let reply = self.reply.clone();
1990 Box::pin(futures::stream::iter(vec![
1991 Ok(LlmEvent::TextDelta {
1992 delta: reply,
1993 meta: None,
1994 }),
1995 Ok(LlmEvent::Done {
1996 outcome: LlmDoneOutcome::Success {
1997 stop_reason: meerkat_core::StopReason::EndTurn,
1998 },
1999 }),
2000 ]))
2001 }
2002
2003 fn provider(&self) -> Provider {
2004 Provider::Other
2005 }
2006
2007 async fn health_check(&self) -> Result<(), LlmError> {
2008 Ok(())
2009 }
2010 }
2011
2012 struct ScriptedHandle {
2013 reply: String,
2014 }
2015
2016 #[async_trait]
2017 impl HygienistClientHandle for ScriptedHandle {
2018 async fn client(&self) -> Result<Arc<dyn LlmClient>, HygienistError> {
2019 Ok(Arc::new(ScriptedLlm {
2020 reply: self.reply.clone(),
2021 }))
2022 }
2023 }
2024
2025 struct FixedGate(u64);
2026
2027 impl DistillationGate for FixedGate {
2028 fn distilled_through(&self, _identity: &str, _session_key: &str) -> u64 {
2029 self.0
2030 }
2031 }
2032
2033 fn engine_with(
2034 reply: &str,
2035 seam: Arc<ScriptedSeam>,
2036 spans: Vec<SpanReference>,
2037 gate: Option<Arc<dyn DistillationGate>>,
2038 runs_per_day: u32,
2039 ) -> Arc<HygienistEngine> {
2040 Arc::new(HygienistEngine::new(
2041 HygienistProfile::embedded_default(),
2042 HygienistConfig {
2043 enabled: true,
2044 runs_per_day,
2045 model: None,
2046 },
2047 Arc::new(ScriptedHandle {
2048 reply: reply.to_string(),
2049 }),
2050 seam,
2051 Arc::new(ScriptedSpans(spans)),
2052 gate,
2053 "family",
2054 ))
2055 }
2056
2057 fn seam() -> Arc<ScriptedSeam> {
2058 Arc::new(ScriptedSeam {
2059 messages: transcript(),
2060 rewrites: StdMutex::new(Vec::new()),
2061 refuse: false,
2062 last_expected_parent: StdMutex::new(None),
2063 })
2064 }
2065
2066 const PRUNE_REPLY: &str =
2067 r#"{"ops": [{"op": "prune_tool_results", "range": [2, 3], "rationale": "dead"}]}"#;
2068
2069 #[tokio::test]
2070 async fn engine_applies_validated_revision_and_emits_events() {
2071 let scripted = seam();
2072 let engine = engine_with(PRUNE_REPLY, scripted.clone(), Vec::new(), None, 2);
2073 let sink = Arc::new(crate::memory::events::CollectingEventSink::new());
2074 engine.set_event_sink(sink.clone());
2075 let outcome = engine
2076 .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2077 .await;
2078 match outcome {
2079 HygieneOutcome::Applied { revision, ops, .. } => {
2080 assert_eq!(revision.revision, "rev-new");
2081 assert_eq!(ops, 1);
2082 }
2083 other => panic!("expected Applied, got {other:?}"),
2084 }
2085 assert_eq!(
2086 scripted
2087 .rewrites
2088 .lock()
2089 .unwrap_or_else(std::sync::PoisonError::into_inner)
2090 .as_slice(),
2091 &[(2, 3, 1)]
2092 );
2093 assert_eq!(
2096 scripted
2097 .last_expected_parent
2098 .lock()
2099 .unwrap_or_else(std::sync::PoisonError::into_inner)
2100 .clone(),
2101 Some(Some(SCRIPTED_HEAD_REVISION.to_string())),
2102 "hygiene must CAS against the head it read"
2103 );
2104 assert_eq!(
2105 sink.types(),
2106 vec!["memory.hygiene.proposed", "memory.hygiene.applied"]
2107 );
2108 }
2109
2110 #[tokio::test]
2111 async fn engine_blocks_quarantine_referenced_revision() {
2112 let scripted = seam();
2113 let spans = vec![SpanReference {
2114 record_id: "mem-q".to_string(),
2115 quarantined: true,
2116 range: Some((2, 2)),
2117 }];
2118 let engine = engine_with(PRUNE_REPLY, scripted.clone(), spans, None, 2);
2119 let sink = Arc::new(crate::memory::events::CollectingEventSink::new());
2120 engine.set_event_sink(sink.clone());
2121 let outcome = engine
2122 .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2123 .await;
2124 assert!(
2125 matches!(outcome, HygieneOutcome::Blocked { .. }),
2126 "{outcome:?}"
2127 );
2128 assert!(
2129 scripted
2130 .rewrites
2131 .lock()
2132 .unwrap_or_else(std::sync::PoisonError::into_inner)
2133 .is_empty(),
2134 "blocked revision must not reach the seam"
2135 );
2136 assert_eq!(sink.types(), vec!["memory.hygiene.blocked"]);
2137 }
2138
2139 #[tokio::test]
2140 async fn engine_refuses_on_demand_beyond_distiller_cursor() {
2141 let scripted = seam();
2142 let engine = engine_with(
2143 PRUNE_REPLY,
2144 scripted.clone(),
2145 Vec::new(),
2146 Some(Arc::new(FixedGate(1))),
2147 2,
2148 );
2149 let outcome = engine
2150 .hygiene_now("identity:luka", "sess-1", HygieneCause::OnDemand)
2151 .await;
2152 assert!(
2153 matches!(outcome, HygieneOutcome::Blocked { .. }),
2154 "{outcome:?}"
2155 );
2156 let outcome = engine
2158 .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2159 .await;
2160 assert!(
2161 matches!(outcome, HygieneOutcome::Applied { .. }),
2162 "{outcome:?}"
2163 );
2164 }
2165
2166 #[tokio::test]
2167 async fn engine_skips_noop_and_respects_budget() {
2168 let scripted = seam();
2169 let engine = engine_with(r#"{"ops": []}"#, scripted.clone(), Vec::new(), None, 1);
2170 let outcome = engine
2171 .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2172 .await;
2173 assert!(
2174 matches!(&outcome, HygieneOutcome::Skipped { reason } if reason.contains("no-op")),
2175 "{outcome:?}"
2176 );
2177 let outcome = engine
2179 .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2180 .await;
2181 assert!(
2182 matches!(&outcome, HygieneOutcome::Skipped { reason } if reason.contains("budget denied")),
2183 "{outcome:?}"
2184 );
2185 }
2186
2187 #[tokio::test]
2188 async fn engine_reports_seam_refusal_as_skip() {
2189 let scripted = Arc::new(ScriptedSeam {
2190 messages: transcript(),
2191 rewrites: StdMutex::new(Vec::new()),
2192 refuse: true,
2193 last_expected_parent: StdMutex::new(None),
2194 });
2195 let engine = engine_with(PRUNE_REPLY, scripted, Vec::new(), None, 2);
2196 let outcome = engine
2197 .hygiene_now("identity:luka", "sess-1", HygieneCause::PostCompaction)
2198 .await;
2199 assert!(
2200 matches!(&outcome, HygieneOutcome::Skipped { reason } if reason.contains("apply refused")),
2201 "{outcome:?}"
2202 );
2203 }
2204
2205 #[tokio::test]
2208 async fn follow_up_runs_after_satisfied_harvest_and_withholds_otherwise() {
2209 let scripted = seam();
2210 let engine = engine_with(PRUNE_REPLY, scripted.clone(), Vec::new(), None, 4);
2211 let sink = Arc::new(crate::memory::events::CollectingEventSink::new());
2212 engine.set_event_sink(sink.clone());
2213 let follow_up = distiller_follow_up(engine);
2214
2215 follow_up(
2216 "identity:luka",
2217 "sess-1",
2218 &DistillOutcome::Skipped {
2219 reason: "budget denied: window budget exhausted (2/2 runs)".to_string(),
2220 },
2221 );
2222 assert_eq!(sink.types(), vec!["memory.hygiene.skipped"]);
2224
2225 follow_up(
2226 "identity:luka",
2227 "sess-1",
2228 &DistillOutcome::Completed {
2229 run_id: "distill-1".to_string(),
2230 written: 0,
2231 quarantined: 0,
2232 },
2233 );
2234 for _ in 0..100 {
2236 if scripted
2237 .rewrites
2238 .lock()
2239 .unwrap_or_else(std::sync::PoisonError::into_inner)
2240 .len()
2241 == 1
2242 {
2243 break;
2244 }
2245 tokio::time::sleep(Duration::from_millis(10)).await;
2246 }
2247 assert_eq!(
2248 scripted
2249 .rewrites
2250 .lock()
2251 .unwrap_or_else(std::sync::PoisonError::into_inner)
2252 .len(),
2253 1,
2254 "satisfied harvest must trigger the pass"
2255 );
2256 }
2257
2258 #[test]
2261 fn embedded_prompt_matches_calibration_bundle() -> Result<(), Box<dyn std::error::Error>> {
2262 let bundle =
2263 Path::new(env!("CARGO_MANIFEST_DIR")).join("../memory-evals/prompts/hygienist-v0.md");
2264 if !bundle.is_file() {
2265 return Ok(());
2266 }
2267 let text = std::fs::read_to_string(bundle)?;
2268 assert_eq!(
2269 text, EMBEDDED_PROMPT_V0,
2270 "memory-evals/prompts/hygienist-v0.md and src/memory/hygienist_prompt_v0.md have drifted"
2271 );
2272 Ok(())
2273 }
2274
2275 #[test]
2276 fn model_override_is_fail_loud() {
2277 let profile = HygienistProfile::embedded_default();
2278 assert!(profile.clone().with_model_override("").is_err());
2279 assert!(
2280 profile
2281 .clone()
2282 .with_model_override("not-a-real-model-xyz")
2283 .is_err()
2284 );
2285 let overridden = profile
2286 .with_model_override("claude-haiku-4-5")
2287 .expect("catalog model accepted");
2288 assert_eq!(overridden.model, "claude-haiku-4-5");
2289 }
2290}