use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use futures::StreamExt;
use serde::Deserialize;
use meerkat_client::{LlmClient, LlmDoneOutcome, LlmError, LlmEvent, LlmRequest};
use meerkat_core::event::AgentEvent;
use meerkat_core::{Message, Provider, UserMessage};
use crate::identity_first::agent_memory::{
AgentMemoryError, AgentMemoryProvider, MEMORY_TOOL_NAME, compact_whitespace,
truncate_utf8_boundary,
};
use crate::memory::guards::{BackgroundBudget, BackgroundBudgetConfig};
use crate::memory::records::{
EvidenceRef, ManifestTier, MemoryAuthor, MemoryKind, MemoryScope, NewMemoryRecord, RecordMeta,
};
use crate::memory::selector::FactorySelectorHandle;
use crate::memory::taint::{MemberAgentEventSink, SessionTaintTracker};
pub const EMBEDDED_PROMPT_V0: &str = include_str!("distiller_prompt_v0.md");
const MANIFEST_PLACEHOLDER: &str = "{{existing_manifest}}";
const TOMBSTONES_PLACEHOLDER: &str = "{{recent_tombstones}}";
const TRANSCRIPT_PLACEHOLDER: &str = "{{transcript}}";
pub const PRE_ROTATION_TIMEOUT: Duration = Duration::from_secs(15);
pub const MIN_SECONDS_BETWEEN_RUNS: u64 = 120;
const TOMBSTONE_LOOKBACK_MS: u64 = 7 * 24 * 60 * 60 * 1000;
const COMPACTION_HARVEST_LIMIT: usize = 64;
const MAX_TRANSCRIPT_MESSAGE_BYTES: usize = 4 * 1024;
const MAX_TRANSCRIPT_TOTAL_BYTES: usize = 48 * 1024;
const MAX_TRACKED_WINDOWS: usize = 4096;
const DEFAULT_MAX_OUTPUT_TOKENS: u32 = 2048;
#[derive(Debug)]
pub enum DistillerError {
Profile(String),
Auth(String),
Client(String),
Parse(String),
Store(String),
Transcript(String),
}
impl std::fmt::Display for DistillerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Profile(msg) => write!(f, "distiller profile error: {msg}"),
Self::Auth(msg) => write!(f, "distiller auth error: {msg}"),
Self::Client(msg) => write!(f, "distiller client error: {msg}"),
Self::Parse(msg) => write!(f, "distiller parse error: {msg}"),
Self::Store(msg) => write!(f, "distiller store error: {msg}"),
Self::Transcript(msg) => write!(f, "distiller transcript error: {msg}"),
}
}
}
impl std::error::Error for DistillerError {}
#[derive(Debug, Clone, Deserialize)]
pub struct DistillerParams {
#[serde(default = "default_temperature")]
pub temperature: f32,
#[serde(default = "default_max_output_tokens")]
pub max_output_tokens: u32,
#[serde(default = "default_max_manifest_records")]
pub max_manifest_records: usize,
#[serde(default = "default_max_tombstones")]
pub max_tombstones: usize,
}
fn default_temperature() -> f32 {
0.0
}
fn default_max_output_tokens() -> u32 {
DEFAULT_MAX_OUTPUT_TOKENS
}
fn default_max_manifest_records() -> usize {
200
}
fn default_max_tombstones() -> usize {
32
}
impl Default for DistillerParams {
fn default() -> Self {
Self {
temperature: default_temperature(),
max_output_tokens: default_max_output_tokens(),
max_manifest_records: default_max_manifest_records(),
max_tombstones: default_max_tombstones(),
}
}
}
#[derive(Debug, Clone)]
pub struct DistillerProfile {
pub stage: String,
pub version: String,
pub model: String,
pub provider: Provider,
pub prompt_bundle: String,
pub prompt_template: String,
pub params: DistillerParams,
}
#[derive(Debug, Deserialize)]
struct RawProfile {
stage: String,
version: String,
model: String,
#[serde(default)]
provider: Option<String>,
prompt_bundle: String,
#[serde(default)]
params: Option<DistillerParams>,
}
impl DistillerProfile {
pub fn embedded_default() -> Self {
Self {
stage: "distiller".to_string(),
version: "0".to_string(),
model: "claude-haiku-4-5".to_string(),
provider: Provider::Anthropic,
prompt_bundle: "prompts/distiller-v0.md".to_string(),
prompt_template: EMBEDDED_PROMPT_V0.to_string(),
params: DistillerParams::default(),
}
}
pub fn with_model_override(mut self, model: &str) -> Result<Self, DistillerError> {
let model = model.trim();
if model.is_empty() {
return Err(DistillerError::Profile(
"distiller model override must not be empty".to_string(),
));
}
self.provider = meerkat_models::infer_provider(model).ok_or_else(|| {
DistillerError::Profile(format!(
"distiller model override '{model}' is not in the model catalog"
))
})?;
self.model = model.to_string();
Ok(self)
}
pub fn load(path: &Path) -> Result<Self, DistillerError> {
let text = std::fs::read_to_string(path).map_err(|err| {
DistillerError::Profile(format!("cannot read profile '{}': {err}", path.display()))
})?;
let raw: RawProfile = toml::from_str(&text).map_err(|err| {
DistillerError::Profile(format!("invalid profile '{}': {err}", path.display()))
})?;
if raw.stage != "distiller" {
return Err(DistillerError::Profile(format!(
"profile '{}' is for stage '{}', not 'distiller'",
path.display(),
raw.stage
)));
}
if raw.model.trim().is_empty() || raw.model == "PLACEHOLDER" {
return Err(DistillerError::Profile(format!(
"profile '{}' does not name a model",
path.display()
)));
}
let provider = match raw.provider.as_deref() {
Some(name) => Provider::parse_strict(name).ok_or_else(|| {
DistillerError::Profile(format!(
"profile '{}': unknown provider '{name}'",
path.display()
))
})?,
None => meerkat_models::infer_provider(&raw.model).ok_or_else(|| {
DistillerError::Profile(format!(
"profile '{}': model '{}' is not in the catalog; set `provider` explicitly",
path.display(),
raw.model
))
})?,
};
let base = path.parent().unwrap_or_else(|| Path::new("."));
let candidates = [
base.join(&raw.prompt_bundle),
base.parent()
.unwrap_or_else(|| Path::new("."))
.join(&raw.prompt_bundle),
];
let bundle_path = candidates.iter().find(|p| p.is_file()).ok_or_else(|| {
DistillerError::Profile(format!(
"profile '{}': prompt_bundle '{}' does not resolve",
path.display(),
raw.prompt_bundle
))
})?;
let prompt_template = std::fs::read_to_string(bundle_path).map_err(|err| {
DistillerError::Profile(format!(
"cannot read prompt bundle '{}': {err}",
bundle_path.display()
))
})?;
let profile = Self {
stage: raw.stage,
version: raw.version,
model: raw.model,
provider,
prompt_bundle: raw.prompt_bundle,
prompt_template,
params: raw.params.unwrap_or_default(),
};
profile.validate()?;
Ok(profile)
}
fn validate(&self) -> Result<(), DistillerError> {
for placeholder in [
MANIFEST_PLACEHOLDER,
TOMBSTONES_PLACEHOLDER,
TRANSCRIPT_PLACEHOLDER,
] {
if !self.prompt_template.contains(placeholder) {
return Err(DistillerError::Profile(format!(
"prompt bundle '{}' is missing placeholder `{placeholder}`",
self.prompt_bundle
)));
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DistillerConfig {
pub enabled: bool,
pub runs_per_hour: u32,
pub min_interactions: u32,
pub model: Option<String>,
}
impl Default for DistillerConfig {
fn default() -> Self {
Self {
enabled: false,
runs_per_hour: crate::memory::guards::DEFAULT_RUNS_PER_HOUR,
min_interactions: 3,
model: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Epistemic {
OperatorSaid,
Observed,
}
impl Epistemic {
fn parse(value: &str) -> Option<Self> {
match value {
"operator_said" => Some(Self::OperatorSaid),
"observed" => Some(Self::Observed),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProposedAction {
Remember,
Update { target_id: String },
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProposedOp {
pub action: ProposedAction,
pub kind: MemoryKind,
pub title: String,
pub description: String,
pub body: String,
pub tags: Vec<String>,
pub epistemic: Epistemic,
pub evidence_range: Option<(u64, u64)>,
}
#[derive(Debug, Deserialize)]
struct RawOp {
action: String,
#[serde(default)]
target_id: Option<String>,
#[serde(default)]
kind: Option<String>,
title: String,
#[serde(default)]
description: String,
body: String,
#[serde(default)]
tags: Vec<String>,
epistemic: String,
#[serde(default)]
evidence_range: Option<(u64, u64)>,
}
pub fn parse_ops(reply: &str) -> Result<Vec<RawParsedOp>, String> {
let trimmed = reply.trim();
let raw: Vec<RawOp> = match serde_json::from_str(trimmed) {
Ok(raw) => raw,
Err(first_err) => {
let (Some(start), Some(end)) = (trimmed.find('['), trimmed.rfind(']')) else {
return Err(format!("no JSON array in reply: {first_err}"));
};
if start >= end {
return Err(format!("no JSON array in reply: {first_err}"));
}
serde_json::from_str(&trimmed[start..=end]).map_err(|err| err.to_string())?
}
};
Ok(raw.into_iter().map(RawParsedOp).collect())
}
#[derive(Debug)]
pub struct RawParsedOp(RawOp);
pub fn validate_op(op: RawParsedOp, manifest_ids: &[String]) -> Result<ProposedOp, String> {
let raw = op.0;
let action = match raw.action.as_str() {
"remember" => ProposedAction::Remember,
"update" => {
let target = raw
.target_id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty())
.ok_or_else(|| "update op is missing target_id".to_string())?;
if !manifest_ids.iter().any(|id| id == target) {
return Err(format!(
"update op targets '{target}', which is not in the manifest"
));
}
ProposedAction::Update {
target_id: target.to_string(),
}
}
other => return Err(format!("unknown action '{other}'")),
};
let kind = match raw.kind.as_deref() {
None => MemoryKind::Fact,
Some(kind) => {
MemoryKind::parse(kind).ok_or_else(|| format!("unknown record kind '{kind}'"))?
}
};
let epistemic = Epistemic::parse(&raw.epistemic)
.ok_or_else(|| format!("unknown epistemic status '{}'", raw.epistemic))?;
let title = compact_whitespace(&raw.title);
if title.is_empty() {
return Err("op has an empty title".to_string());
}
let body = raw.body.trim().to_string();
if body.is_empty() {
return Err("op has an empty body".to_string());
}
if let Some((start, end)) = raw.evidence_range
&& start > end
{
return Err(format!("evidence_range [{start}, {end}] is inverted"));
}
Ok(ProposedOp {
action,
kind,
title,
description: compact_whitespace(&raw.description),
body,
tags: raw.tags,
epistemic,
evidence_range: raw.evidence_range,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TranscriptMessage {
pub index: u64,
pub role: &'static str,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TranscriptSlice {
pub session_key: String,
pub start_index: u64,
pub end_index: u64,
pub messages: Vec<TranscriptMessage>,
pub head_revision: Option<String>,
}
#[async_trait]
pub trait TranscriptSource: Send + Sync {
async fn read(
&self,
session_key: &str,
from_index: u64,
) -> Result<Option<TranscriptSlice>, DistillerError>;
}
pub struct SessionStoreTranscriptSource {
store: Arc<dyn meerkat::SessionStore>,
}
impl SessionStoreTranscriptSource {
pub fn new(store: Arc<dyn meerkat::SessionStore>) -> Self {
Self { store }
}
}
#[async_trait]
impl TranscriptSource for SessionStoreTranscriptSource {
async fn read(
&self,
session_key: &str,
from_index: u64,
) -> Result<Option<TranscriptSlice>, DistillerError> {
let session_id = meerkat_core::types::SessionId::parse(session_key).map_err(|err| {
DistillerError::Transcript(format!("invalid session key '{session_key}': {err}"))
})?;
let session = self
.store
.load(&session_id)
.await
.map_err(|err| DistillerError::Transcript(err.to_string()))?;
let Some(session) = session else {
return Ok(None);
};
let head_revision = session.transcript_revision().ok();
let all = session.messages();
let end_index = all.len() as u64;
let start_index = from_index.min(end_index);
let messages = all[start_index as usize..]
.iter()
.enumerate()
.filter_map(|(offset, message)| {
let index = start_index + offset as u64;
project_message(message).map(|(role, text)| TranscriptMessage {
index,
role,
text: truncate_utf8_boundary(
&compact_whitespace(&text),
MAX_TRANSCRIPT_MESSAGE_BYTES,
),
})
})
.collect();
Ok(Some(TranscriptSlice {
session_key: session_key.to_string(),
start_index,
end_index,
messages,
head_revision,
}))
}
}
fn project_message(message: &Message) -> Option<(&'static str, String)> {
match message {
Message::System(_) => None,
Message::SystemNotice(notice) => notice
.body
.as_deref()
.map(|body| ("system notice", body.to_string())),
Message::User(user) => Some(("user", user.text_content())),
Message::BlockAssistant(assistant) => Some((
"assistant",
assistant.text_blocks().collect::<Vec<_>>().join("\n"),
)),
Message::ToolResults { results, .. } => {
let text = results
.iter()
.map(|result| meerkat_core::types::text_content(&result.content))
.collect::<Vec<_>>()
.join("\n");
Some(("tool results", text))
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct DiscardEntry {
pub content: String,
pub range: Option<(u64, u64)>,
}
#[async_trait]
pub trait CompactionDiscardSource: Send + Sync {
async fn read_discards(
&self,
session_key: &str,
limit: usize,
) -> Result<Vec<DiscardEntry>, DistillerError>;
async fn drop_scope(&self, session_key: &str) -> Result<usize, DistillerError> {
let _ = session_key;
Ok(0)
}
}
pub struct HnswDiscardSource {
dir: PathBuf,
}
const HARVEST_PAGE_ROWS: usize = 256;
impl HnswDiscardSource {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
}
#[async_trait]
impl CompactionDiscardSource for HnswDiscardSource {
async fn read_discards(
&self,
session_key: &str,
limit: usize,
) -> Result<Vec<DiscardEntry>, DistillerError> {
use meerkat_core::memory::{MemoryEnumerationRequest, MemorySearchScope, MemoryStore};
if !self.dir.is_dir() {
return Ok(Vec::new());
}
let session_id = meerkat_core::types::SessionId::parse(session_key).map_err(|err| {
DistillerError::Transcript(format!("invalid session key '{session_key}': {err}"))
})?;
let dir = self.dir.clone();
let store = tokio::task::spawn_blocking(move || meerkat_memory::HnswMemoryStore::open(dir))
.await
.map_err(|err| DistillerError::Store(err.to_string()))?
.map_err(|err| DistillerError::Store(err.to_string()))?;
let scope = MemorySearchScope::for_session(session_id);
let mut entries = Vec::new();
let mut offset = 0usize;
loop {
let remaining = limit.saturating_sub(entries.len());
if remaining == 0 {
break;
}
let page = store
.enumerate_scoped(
&scope,
MemoryEnumerationRequest {
limit: HARVEST_PAGE_ROWS.min(remaining),
offset,
source_overlap: None,
indexed_after: None,
},
)
.await
.map_err(|err| DistillerError::Store(err.to_string()))?;
for record in page.records {
entries.push(DiscardEntry {
range: record
.metadata
.source
.source_range()
.map(|range| (range.start(), range.end())),
content: record.content,
});
}
match page.next_offset {
Some(next) if entries.len() < limit => offset = next,
Some(_) => {
tracing::warn!(
session_key,
limit,
"compaction discard harvest hit its row ceiling; \
scope has more rows than were read"
);
break;
}
None => break,
}
}
Ok(entries)
}
async fn drop_scope(&self, session_key: &str) -> Result<usize, DistillerError> {
use meerkat_core::memory::{MemoryOwner, MemoryStore};
if !self.dir.is_dir() {
return Ok(0);
}
let session_id = meerkat_core::types::SessionId::parse(session_key).map_err(|err| {
DistillerError::Transcript(format!("invalid session key '{session_key}': {err}"))
})?;
let dir = self.dir.clone();
let store = tokio::task::spawn_blocking(move || meerkat_memory::HnswMemoryStore::open(dir))
.await
.map_err(|err| DistillerError::Store(err.to_string()))?
.map_err(|err| DistillerError::Store(err.to_string()))?;
let receipt = store
.drop_scope(&MemoryOwner::canonical_session(session_id))
.await
.map_err(|err| DistillerError::Store(err.to_string()))?;
Ok(receipt.dropped_entries)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TombstoneMeta {
pub title: String,
pub kind: MemoryKind,
pub tombstoned_at_ms: u64,
}
#[async_trait]
pub trait TombstoneSource: Send + Sync {
async fn recent_tombstones(
&self,
scope: &MemoryScope,
since_ms: u64,
limit: usize,
) -> Result<Vec<TombstoneMeta>, AgentMemoryError>;
}
#[async_trait]
pub trait DistillerClientHandle: Send + Sync {
async fn client(&self) -> Result<Arc<dyn LlmClient>, DistillerError>;
fn invalidate(&self);
}
pub struct FactoryDistillerHandle {
inner: FactorySelectorHandle,
}
impl FactoryDistillerHandle {
pub fn new(
store_path: impl Into<PathBuf>,
config: meerkat::Config,
realm: impl Into<String>,
profile: &DistillerProfile,
) -> Self {
Self {
inner: FactorySelectorHandle::for_model(
store_path,
config,
realm,
&profile.model,
profile.provider,
),
}
}
}
#[async_trait]
impl DistillerClientHandle for FactoryDistillerHandle {
async fn client(&self) -> Result<Arc<dyn LlmClient>, DistillerError> {
use crate::memory::selector::{SelectorError, SelectorHandle};
self.inner.client().await.map_err(|err| match err {
SelectorError::Auth(msg) => DistillerError::Auth(msg),
other => DistillerError::Client(other.to_string()),
})
}
fn invalidate(&self) {
use crate::memory::selector::SelectorHandle;
self.inner.invalidate();
}
}
pub fn render_prompt(
profile: &DistillerProfile,
manifest: &[RecordMeta],
tombstones: &[TombstoneMeta],
transcript_text: &str,
) -> String {
let manifest_text = if manifest.is_empty() {
"(no records)".to_string()
} else {
manifest
.iter()
.take(profile.params.max_manifest_records)
.map(crate::memory::selector::render_manifest_row)
.collect::<Vec<_>>()
.join("\n")
};
let tombstones_text = if tombstones.is_empty() {
"(none)".to_string()
} else {
tombstones
.iter()
.take(profile.params.max_tombstones)
.map(|tombstone| {
format!(
"- [{}] {}",
tombstone.kind.as_str(),
compact_whitespace(&tombstone.title)
)
})
.collect::<Vec<_>>()
.join("\n")
};
profile
.prompt_template
.replace(MANIFEST_PLACEHOLDER, &manifest_text)
.replace(TOMBSTONES_PLACEHOLDER, &tombstones_text)
.replace(TRANSCRIPT_PLACEHOLDER, transcript_text)
}
pub fn render_transcript(slice: &TranscriptSlice) -> String {
let mut lines: Vec<String> = Vec::new();
let mut total = 0usize;
for message in slice.messages.iter().rev() {
let line = format!("[{}] {}: {}", message.index, message.role, message.text);
if total + line.len() + 1 > MAX_TRANSCRIPT_TOTAL_BYTES && !lines.is_empty() {
lines.push("(earlier messages omitted for budget)".to_string());
break;
}
total += line.len() + 1;
lines.push(line);
}
lines.reverse();
lines.join("\n")
}
fn render_discards(entries: &[DiscardEntry]) -> String {
let mut lines: Vec<String> = vec![
"(compaction-discarded content recovered from session semantic memory; \
[N-M] are pre-compaction message offsets)"
.to_string(),
];
let mut total = 0usize;
for entry in entries {
let prefix = match entry.range {
Some((start, end)) => format!("[{start}-{end}]"),
None => "[?]".to_string(),
};
let line = format!(
"{prefix} {}",
truncate_utf8_boundary(
&compact_whitespace(&entry.content),
MAX_TRANSCRIPT_MESSAGE_BYTES
)
);
if total + line.len() + 1 > MAX_TRANSCRIPT_TOTAL_BYTES && lines.len() > 1 {
lines.push("(further discards omitted for budget)".to_string());
break;
}
total += line.len() + 1;
lines.push(line);
}
lines.join("\n")
}
async fn complete_text(
client: &dyn LlmClient,
profile: &DistillerProfile,
prompt: String,
) -> Result<String, DistillerError> {
let request = LlmRequest::new(
&profile.model,
vec![Message::User(UserMessage::text(prompt))],
)
.with_max_tokens(profile.params.max_output_tokens)
.with_temperature(profile.params.temperature);
let mut stream = client.stream(&request);
let mut text = String::new();
while let Some(event) = stream.next().await {
match event.map_err(classify_llm_error)? {
LlmEvent::TextDelta { delta, .. } => text.push_str(&delta),
LlmEvent::Done { outcome } => match outcome {
LlmDoneOutcome::Success { .. } => break,
LlmDoneOutcome::Error { error } => return Err(classify_llm_error(error)),
},
_ => {}
}
}
Ok(text)
}
fn classify_llm_error(error: LlmError) -> DistillerError {
match error {
LlmError::AuthenticationFailed { .. } | LlmError::InvalidApiKey => {
DistillerError::Auth(error.to_string())
}
other => DistillerError::Client(other.to_string()),
}
}
pub async fn extract(
profile: &DistillerProfile,
client: &dyn LlmClient,
manifest: &[RecordMeta],
tombstones: &[TombstoneMeta],
transcript_text: &str,
) -> Result<Vec<RawParsedOp>, DistillerError> {
let prompt = render_prompt(profile, manifest, tombstones, transcript_text);
let reply = complete_text(client, profile, prompt).await?;
match parse_ops(&reply) {
Ok(ops) => Ok(ops),
Err(first_err) => {
let repair_prompt = format!(
"The following reply was supposed to be exactly one JSON array of memory ops \
(each {{\"action\": \"remember\" | \"update\", \"target_id\"?, \"kind\", \
\"title\", \"description\", \"body\", \"tags\", \"epistemic\", \
\"evidence_range\"?}}) but did not parse ({first_err}). Reply with ONLY the \
corrected JSON array, no other text.\n\n{reply}"
);
let repaired = complete_text(client, profile, repair_prompt).await?;
parse_ops(&repaired).map_err(DistillerError::Parse)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DistillCause {
Interactions,
Respawn,
Retire,
Delete,
Reset,
ResumeFallback,
Compaction,
}
impl DistillCause {
pub fn as_str(&self) -> &'static str {
match self {
Self::Interactions => "interactions",
Self::Respawn => "respawn",
Self::Retire => "retire",
Self::Delete => "delete",
Self::Reset => "reset",
Self::ResumeFallback => "resume_fallback",
Self::Compaction => "compaction",
}
}
pub fn quarantines_output(&self) -> bool {
matches!(self, Self::Reset)
}
}
#[derive(Default, Clone)]
struct WindowState {
cursor: u64,
completed_runs: u32,
recorder_wrote: bool,
generation: u64,
last_run_at: Option<Instant>,
last_activity_at: Option<Instant>,
in_flight: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DistillOutcome {
Skipped {
reason: String,
},
Completed {
run_id: String,
written: usize,
quarantined: usize,
},
}
pub(crate) const SKIP_NO_DISCARD_SOURCE: &str = "no compaction discard source wired";
pub(crate) const SKIP_NO_DISCARDS: &str = "no compaction discards to harvest";
impl DistillOutcome {
pub fn compaction_harvest_satisfied(&self) -> bool {
match self {
Self::Completed { .. } => true,
Self::Skipped { reason } => {
reason == SKIP_NO_DISCARDS || reason == SKIP_NO_DISCARD_SOURCE
}
}
}
}
pub type CompactionFollowUp = Arc<dyn Fn(&str, &str, &DistillOutcome) + Send + Sync>;
pub type CompactionObserved = Arc<dyn Fn(&str, &str) + Send + Sync>;
pub struct DistillerEngine {
profile: DistillerProfile,
config: DistillerConfig,
handle: Arc<dyn DistillerClientHandle>,
provider: Arc<dyn AgentMemoryProvider>,
tombstones: Arc<dyn TombstoneSource>,
transcripts: Arc<dyn TranscriptSource>,
compaction: Option<Arc<dyn CompactionDiscardSource>>,
tracker: Option<SessionTaintTracker>,
budget: BackgroundBudget,
realm: String,
events: Mutex<Option<Arc<dyn crate::memory::events::MemoryEventSink>>>,
windows: Mutex<HashMap<(String, String), WindowState>>,
pre_rotation_timeout: Duration,
run_counter: std::sync::atomic::AtomicU64,
compaction_follow_up: Mutex<Option<CompactionFollowUp>>,
compaction_observed: Mutex<Option<CompactionObserved>>,
}
impl DistillerEngine {
#[allow(clippy::too_many_arguments)]
pub fn new(
profile: DistillerProfile,
config: DistillerConfig,
handle: Arc<dyn DistillerClientHandle>,
provider: Arc<dyn AgentMemoryProvider>,
tombstones: Arc<dyn TombstoneSource>,
transcripts: Arc<dyn TranscriptSource>,
compaction: Option<Arc<dyn CompactionDiscardSource>>,
tracker: Option<SessionTaintTracker>,
realm: impl Into<String>,
) -> Self {
let budget = BackgroundBudget::new(BackgroundBudgetConfig {
runs_per_window: config.runs_per_hour,
window: Duration::from_hours(1),
max_concurrent: crate::memory::guards::DEFAULT_MAX_CONCURRENT,
});
Self {
profile,
config,
handle,
provider,
tombstones,
transcripts,
compaction,
tracker,
budget,
realm: realm.into(),
events: Mutex::new(None),
windows: Mutex::new(HashMap::new()),
pre_rotation_timeout: PRE_ROTATION_TIMEOUT,
run_counter: std::sync::atomic::AtomicU64::new(0),
compaction_follow_up: Mutex::new(None),
compaction_observed: Mutex::new(None),
}
}
#[cfg(test)]
fn with_pre_rotation_timeout(mut self, timeout: Duration) -> Self {
self.pre_rotation_timeout = timeout;
self
}
pub fn set_event_sink(&self, sink: Arc<dyn crate::memory::events::MemoryEventSink>) {
self.budget.set_event_sink(sink.clone());
*self
.events
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(sink);
}
pub fn pre_rotation_timeout(&self) -> Duration {
self.pre_rotation_timeout
}
pub fn set_compaction_follow_up(&self, hook: CompactionFollowUp) {
*self
.compaction_follow_up
.lock()
.unwrap_or_else(|err| err.into_inner()) = Some(hook);
}
pub fn set_compaction_observed(&self, hook: CompactionObserved) {
*self
.compaction_observed
.lock()
.unwrap_or_else(|err| err.into_inner()) = Some(hook);
}
fn note_compaction_observed(&self, identity: &str, session_key: &str) {
let hook = self
.compaction_observed
.lock()
.unwrap_or_else(|err| err.into_inner())
.clone();
if let Some(hook) = hook {
hook(identity, session_key);
}
}
pub fn distilled_cursor(&self, identity: &str, session_key: &str) -> u64 {
self.windows
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&(identity.to_string(), session_key.to_string()))
.map(|state| state.cursor)
.unwrap_or(0)
}
fn with_window<T>(
&self,
identity: &str,
session_key: &str,
f: impl FnOnce(&mut WindowState) -> T,
) -> T {
let mut windows = self
.windows
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if windows.len() >= MAX_TRACKED_WINDOWS
&& !windows.contains_key(&(identity.to_string(), session_key.to_string()))
&& let Some(oldest) = windows
.iter()
.min_by_key(|(_, state)| state.last_activity_at)
.map(|(key, _)| key.clone())
{
windows.remove(&oldest);
}
let state = windows
.entry((identity.to_string(), session_key.to_string()))
.or_default();
state.last_activity_at = Some(Instant::now());
f(state)
}
pub fn note_session_generation(&self, identity: &str, session_key: &str, generation: u64) {
self.with_window(identity, session_key, |state| {
state.generation = generation;
});
}
fn note_recorder_write(&self, identity: &str, session_key: &str) {
self.with_window(identity, session_key, |state| {
state.recorder_wrote = true;
});
}
fn note_run_completed(&self, identity: &str, session_key: &str) -> bool {
let min_interactions = self.config.min_interactions;
self.with_window(identity, session_key, |state| {
state.completed_runs += 1;
if state.in_flight {
return false;
}
if state.completed_runs < min_interactions {
return false;
}
if let Some(last) = state.last_run_at
&& last.elapsed() < Duration::from_secs(MIN_SECONDS_BETWEEN_RUNS)
{
return false;
}
state.in_flight = true;
true
})
}
fn identity_scope(&self, identity: &str) -> MemoryScope {
MemoryScope::Identity {
realm: self.realm.clone(),
identity: identity.to_string(),
}
}
fn mint_run_id(&self) -> String {
let seq = self
.run_counter
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
format!("distill-{}-{seq}", now_ms())
}
pub async fn distill_now(
self: &Arc<Self>,
identity: &str,
session_key: &str,
cause: DistillCause,
) -> DistillOutcome {
let outcome = self.distill_inner(identity, session_key, cause).await;
self.with_window(identity, session_key, |state| {
state.in_flight = false;
});
if cause == DistillCause::Compaction {
let follow_up = self
.compaction_follow_up
.lock()
.unwrap_or_else(|err| err.into_inner())
.clone();
if let Some(follow_up) = follow_up {
follow_up(identity, session_key, &outcome);
}
}
match &outcome {
DistillOutcome::Skipped { reason } => {
tracing::debug!(
identity,
session_key,
cause = cause.as_str(),
reason,
"agent memory distiller: run skipped"
);
}
DistillOutcome::Completed {
run_id,
written,
quarantined,
} => {
tracing::info!(
identity,
session_key,
cause = cause.as_str(),
run_id,
written,
quarantined,
"agent memory distiller: run completed"
);
}
}
outcome
}
async fn distill_inner(
self: &Arc<Self>,
identity: &str,
session_key: &str,
cause: DistillCause,
) -> DistillOutcome {
let (cursor, recorder_wrote, generation) =
self.with_window(identity, session_key, |state| {
(state.cursor, state.recorder_wrote, state.generation)
});
let (evidence_text, evidence_range, window_end, head_revision) = match cause {
DistillCause::Compaction => {
let Some(compaction) = self.compaction.as_ref() else {
return DistillOutcome::Skipped {
reason: SKIP_NO_DISCARD_SOURCE.to_string(),
};
};
let entries = match compaction
.read_discards(session_key, COMPACTION_HARVEST_LIMIT)
.await
{
Ok(entries) => entries,
Err(err) => {
tracing::warn!(
identity,
session_key,
error = %err,
"agent memory distiller: compaction harvest failed"
);
return DistillOutcome::Skipped {
reason: format!("compaction harvest failed: {err}"),
};
}
};
if entries.is_empty() {
return DistillOutcome::Skipped {
reason: SKIP_NO_DISCARDS.to_string(),
};
}
let range = discard_evidence_range(&entries);
(render_discards(&entries), range, None, None)
}
_ => {
let slice = match self.transcripts.read(session_key, cursor).await {
Ok(Some(slice)) => slice,
Ok(None) => {
return DistillOutcome::Skipped {
reason: "session not found in the session store".to_string(),
};
}
Err(err) => {
tracing::warn!(
identity,
session_key,
error = %err,
"agent memory distiller: transcript read failed"
);
return DistillOutcome::Skipped {
reason: format!("transcript read failed: {err}"),
};
}
};
if slice.messages.is_empty() {
return DistillOutcome::Skipped {
reason: "empty evidence window".to_string(),
};
}
if cause == DistillCause::Interactions && recorder_wrote {
self.with_window(identity, session_key, |state| {
state.cursor = slice.end_index;
state.recorder_wrote = false;
state.completed_runs = 0;
});
return DistillOutcome::Skipped {
reason: "recorder wrote in window (mutual exclusion)".to_string(),
};
}
let range = Some((slice.start_index, slice.end_index.saturating_sub(1)));
let head_revision = slice.head_revision.clone();
(
render_transcript(&slice),
range,
Some(slice.end_index),
head_revision,
)
}
};
let _permit = match self.budget.try_acquire(&self.realm, "distiller") {
Ok(permit) => permit,
Err(denied) => {
return DistillOutcome::Skipped {
reason: format!("budget denied: {denied}"),
};
}
};
if cause.quarantines_output()
&& let Some(tracker) = self.tracker.as_ref()
{
tracker.mark_reset_boundary(session_key);
}
let scope = self.identity_scope(identity);
let manifest = match self
.provider
.manifest(std::slice::from_ref(&scope), ManifestTier::Full)
.await
{
Ok(manifest) => manifest,
Err(err) => {
tracing::warn!(identity, error = %err, "agent memory distiller: manifest read failed");
return DistillOutcome::Skipped {
reason: format!("manifest read failed: {err}"),
};
}
};
let tombstones = match self
.tombstones
.recent_tombstones(
&scope,
now_ms().saturating_sub(TOMBSTONE_LOOKBACK_MS),
self.profile.params.max_tombstones,
)
.await
{
Ok(tombstones) => tombstones,
Err(err) => {
tracing::warn!(identity, error = %err, "agent memory distiller: tombstone read failed");
return DistillOutcome::Skipped {
reason: format!("tombstone read failed: {err}"),
};
}
};
let client = match self.handle.client().await {
Ok(client) => client,
Err(err) => {
tracing::warn!(identity, error = %err, "agent memory distiller: client acquisition failed");
return DistillOutcome::Skipped {
reason: format!("client acquisition failed: {err}"),
};
}
};
let raw_ops = match extract(
&self.profile,
&*client,
&manifest,
&tombstones,
&evidence_text,
)
.await
{
Ok(ops) => ops,
Err(DistillerError::Auth(message)) => {
tracing::warn!(error = %message, "distiller auth failure; re-resolving client");
self.handle.invalidate();
let retried = match self.handle.client().await {
Ok(client) => {
extract(
&self.profile,
&*client,
&manifest,
&tombstones,
&evidence_text,
)
.await
}
Err(err) => Err(err),
};
match retried {
Ok(ops) => ops,
Err(err) => {
tracing::warn!(identity, error = %err, "agent memory distiller: extraction failed");
return DistillOutcome::Skipped {
reason: format!("extraction failed: {err}"),
};
}
}
}
Err(err) => {
tracing::warn!(identity, error = %err, "agent memory distiller: extraction failed");
return DistillOutcome::Skipped {
reason: format!("extraction failed: {err}"),
};
}
};
let manifest_ids: Vec<String> = manifest.iter().map(|meta| meta.id.clone()).collect();
let run_id = self.mint_run_id();
let author = MemoryAuthor::Distiller {
run_id: run_id.clone(),
};
let mut written = 0usize;
let mut quarantined = 0usize;
for raw in raw_ops {
let op = match validate_op(raw, &manifest_ids) {
Ok(op) => op,
Err(reason) => {
tracing::warn!(run_id, reason, "agent memory distiller: op dropped");
continue;
}
};
let record = self.build_record(
&op,
session_key,
generation,
evidence_range,
head_revision.as_deref(),
);
let result = match &op.action {
ProposedAction::Remember => {
self.provider
.remember_authored(&scope, record, author.clone())
.await
}
ProposedAction::Update { target_id } => {
self.provider
.supersede_authored(&scope, target_id, record, author.clone())
.await
}
};
match result {
Ok(receipt) => {
written += 1;
if matches!(
receipt.status,
crate::memory::records::RecordStatus::Quarantined { .. }
) {
quarantined += 1;
}
}
Err(err) => {
tracing::warn!(run_id, error = %err, "agent memory distiller: write rejected");
}
}
}
self.with_window(identity, session_key, |state| {
if let Some(end) = window_end {
state.cursor = end;
state.recorder_wrote = false;
state.completed_runs = 0;
}
state.last_run_at = Some(Instant::now());
});
DistillOutcome::Completed {
run_id,
written,
quarantined,
}
}
fn build_record(
&self,
op: &ProposedOp,
session_key: &str,
generation: u64,
window_range: Option<(u64, u64)>,
revision: Option<&str>,
) -> NewMemoryRecord {
let mut tags = op.tags.clone();
if op.epistemic == Epistemic::OperatorSaid
&& !tags.iter().any(|tag| tag == "epistemic:operator_said")
{
tags.push("epistemic:operator_said".to_string());
}
let range = match (op.evidence_range, window_range) {
(Some((start, end)), Some((window_start, window_end)))
if start >= window_start && end <= window_end =>
{
Some((start, end))
}
(_, window) => window,
};
NewMemoryRecord {
kind: op.kind,
title: op.title.clone(),
description: op.description.clone(),
body: op.body.clone(),
tags,
evidence: vec![EvidenceRef {
session_id: session_key.to_string(),
generation,
revision: revision.map(str::to_string),
range,
}],
verification: None,
}
}
pub async fn distill_before_rotation(
self: &Arc<Self>,
identity: &str,
session_key: &str,
cause: DistillCause,
) {
let timeout = self.pre_rotation_timeout;
match tokio::time::timeout(timeout, self.distill_now(identity, session_key, cause)).await {
Ok(_) => {}
Err(_) => {
tracing::warn!(
identity,
session_key,
cause = cause.as_str(),
timeout_ms = timeout.as_millis() as u64,
"agent memory distiller: pre-rotation distillation timed out; \
rotation proceeds without it"
);
if let Some(sink) = self
.events
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
{
sink.emit(
crate::memory::events::MemoryTimelineEvent::DistillationTimedOut {
identity: identity.to_string(),
session_key: session_key.to_string(),
cause: cause.as_str().to_string(),
},
);
}
}
}
}
pub async fn drop_orphaned_session_scope(
self: &Arc<Self>,
session_key: &str,
cause: DistillCause,
) {
let Some(compaction) = self.compaction.as_ref() else {
return;
};
match compaction.drop_scope(session_key).await {
Ok(0) => {}
Ok(dropped) => tracing::debug!(
session_key,
cause = cause.as_str(),
dropped,
"agent memory GC: reclaimed orphaned session semantic-memory rows"
),
Err(err) => tracing::warn!(
session_key,
cause = cause.as_str(),
error = %err,
"agent memory GC: dropping orphaned session scope failed; \
rows remain (re-embed tax persists), rotation proceeds"
),
}
}
pub fn spawn_detached(
self: &Arc<Self>,
identity: &str,
session_key: &str,
cause: DistillCause,
) {
let engine = self.clone();
let identity = identity.to_string();
let session_key = session_key.to_string();
tokio::spawn(async move {
engine.distill_now(&identity, &session_key, cause).await;
});
}
}
fn discard_evidence_range(entries: &[DiscardEntry]) -> Option<(u64, u64)> {
let mut bounds: Option<(u64, u64)> = None;
for entry in entries {
if let Some((start, end)) = entry.range {
bounds = Some(match bounds {
None => (start, end),
Some((lo, hi)) => (lo.min(start), hi.max(end)),
});
}
}
bounds
}
pub struct DistillerTriggers {
engine: Arc<DistillerEngine>,
}
impl DistillerTriggers {
pub fn new(engine: Arc<DistillerEngine>) -> Self {
Self { engine }
}
}
impl MemberAgentEventSink for DistillerTriggers {
fn observe(&self, identity: &str, envelope: &meerkat_core::event::EventEnvelope<AgentEvent>) {
match &envelope.payload {
AgentEvent::ToolCallRequested { name, args, .. } if name == MEMORY_TOOL_NAME => {
let is_write = args
.as_value()
.get("action")
.and_then(serde_json::Value::as_str)
.is_some_and(|action| {
matches!(action, "remember" | "update" | "forget" | "propose_to_mob")
});
if is_write && let Some(session) = current_session_of(envelope) {
self.engine.note_recorder_write(identity, &session);
}
}
AgentEvent::RunCompleted { session_id, .. } => {
let session = session_id.to_string();
if self.engine.note_run_completed(identity, &session) {
self.engine
.spawn_detached(identity, &session, DistillCause::Interactions);
}
}
AgentEvent::CompactionCompleted { .. } => {
if let Some(session) = current_session_of(envelope) {
self.engine.note_compaction_observed(identity, &session);
self.engine
.spawn_detached(identity, &session, DistillCause::Compaction);
} else {
tracing::warn!(
identity,
"agent memory distiller: compaction event without session \
attribution; harvest skipped"
);
}
}
_ => {}
}
}
}
fn current_session_of(envelope: &meerkat_core::event::EventEnvelope<AgentEvent>) -> Option<String> {
match &envelope.source {
meerkat_core::event::EventSourceIdentity::Session { session_id } => {
Some(session_id.to_string())
}
_ => None,
}
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::redundant_clone, clippy::unwrap_used)]
mod tests {
use super::*;
use crate::identity_first::agent_memory::{
AgentMemoryRecallRequest, AgentMemoryRecord, AuthoredWriteReceipt,
};
use crate::memory::records::RecordStatus;
use futures::stream;
use std::sync::Mutex as StdMutex;
#[tokio::test]
async fn hnsw_discard_source_enumerates_then_drops_scope()
-> Result<(), Box<dyn std::error::Error>> {
use meerkat_core::memory::{
MemoryIndexRequest, MemoryIndexScope, MemoryMetadata, MemorySource, MemoryStore,
MessageRange,
};
use meerkat_core::types::{MemoryIndexableContent, SessionId};
let dir = tempfile::tempdir()?;
let session = SessionId::new();
{
let store = meerkat_memory::HnswMemoryStore::open(dir.path())?;
let scope = MemoryIndexScope::for_session(session.clone());
for (start, end, text) in [
(0u64, 2u64, "operator prefers terse status updates"),
(2u64, 5u64, "root cause was an expired upstream TLS cert"),
] {
let request = MemoryIndexRequest::new(
scope.clone(),
MemoryIndexableContent::Indexable(text.to_string()),
MemoryMetadata {
session_id: session.clone(),
source: MemorySource::Compaction {
source_range: MessageRange::new(start, end)?,
},
indexed_at: meerkat_core::time_compat::SystemTime::now(),
},
)?;
store.index_scoped(request).await?;
}
}
let source = HnswDiscardSource::new(dir.path());
let discards = source
.read_discards(&session.to_string(), COMPACTION_HARVEST_LIMIT)
.await?;
assert_eq!(discards.len(), 2, "enumeration must return every scope row");
assert!(discards.iter().any(|d| d.range == Some((0, 2))));
assert!(
discards
.iter()
.any(|d| d.content.contains("expired upstream TLS cert"))
);
let dropped = source.drop_scope(&session.to_string()).await?;
assert_eq!(dropped, 2, "drop_scope must reclaim every seeded row");
let after = source
.read_discards(&session.to_string(), COMPACTION_HARVEST_LIMIT)
.await?;
assert!(after.is_empty(), "dropped scope must enumerate empty");
Ok(())
}
struct ScriptedLlm {
replies: StdMutex<Vec<String>>,
prompts: StdMutex<Vec<String>>,
}
impl ScriptedLlm {
fn new(replies: Vec<&str>) -> Self {
Self {
replies: StdMutex::new(replies.into_iter().map(str::to_string).collect()),
prompts: StdMutex::new(Vec::new()),
}
}
fn prompts(&self) -> Vec<String> {
self.prompts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
}
#[async_trait]
impl LlmClient for ScriptedLlm {
fn stream<'a>(&'a self, request: &'a LlmRequest) -> meerkat_client::types::LlmStream<'a> {
let prompt = request
.messages
.iter()
.map(|message| match message {
Message::User(user) => user.text_content(),
_ => String::new(),
})
.collect::<Vec<_>>()
.join("\n");
self.prompts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(prompt);
let reply = {
let mut replies = self
.replies
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if replies.is_empty() {
String::new()
} else {
replies.remove(0)
}
};
Box::pin(stream::iter(vec![
Ok(LlmEvent::TextDelta {
delta: reply,
meta: None,
}),
Ok(LlmEvent::Done {
outcome: LlmDoneOutcome::Success {
stop_reason: meerkat_core::StopReason::EndTurn,
},
}),
]))
}
fn provider(&self) -> Provider {
Provider::Other
}
async fn health_check(&self) -> Result<(), LlmError> {
Ok(())
}
}
struct ScriptedHandle {
client: Arc<ScriptedLlm>,
}
#[async_trait]
impl DistillerClientHandle for ScriptedHandle {
async fn client(&self) -> Result<Arc<dyn LlmClient>, DistillerError> {
Ok(self.client.clone())
}
fn invalidate(&self) {}
}
struct HangingHandle;
#[async_trait]
impl DistillerClientHandle for HangingHandle {
async fn client(&self) -> Result<Arc<dyn LlmClient>, DistillerError> {
futures::future::pending().await
}
fn invalidate(&self) {}
}
#[derive(Default)]
struct CapturingProvider {
remembers: StdMutex<Vec<(MemoryScope, NewMemoryRecord, MemoryAuthor)>>,
supersedes: StdMutex<Vec<(MemoryScope, String, NewMemoryRecord, MemoryAuthor)>>,
manifest: StdMutex<Vec<RecordMeta>>,
quarantine_all: bool,
}
#[async_trait]
impl AgentMemoryProvider for CapturingProvider {
async fn recall(
&self,
_request: AgentMemoryRecallRequest,
) -> Result<Vec<AgentMemoryRecord>, AgentMemoryError> {
Ok(Vec::new())
}
async fn manifest(
&self,
_scopes: &[MemoryScope],
_tier: ManifestTier,
) -> Result<Vec<RecordMeta>, AgentMemoryError> {
Ok(self
.manifest
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone())
}
fn supports_manifest(&self) -> bool {
true
}
async fn remember_authored(
&self,
scope: &MemoryScope,
record: NewMemoryRecord,
author: MemoryAuthor,
) -> Result<AuthoredWriteReceipt, AgentMemoryError> {
self.remembers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push((scope.clone(), record, author));
Ok(AuthoredWriteReceipt {
memory_id: "mem-new".to_string(),
status: if self.quarantine_all {
RecordStatus::Quarantined {
reason: "test".to_string(),
}
} else {
RecordStatus::Active
},
})
}
async fn supersede_authored(
&self,
scope: &MemoryScope,
prior: &str,
record: NewMemoryRecord,
author: MemoryAuthor,
) -> Result<AuthoredWriteReceipt, AgentMemoryError> {
self.supersedes
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push((scope.clone(), prior.to_string(), record, author));
Ok(AuthoredWriteReceipt {
memory_id: "mem-updated".to_string(),
status: RecordStatus::Active,
})
}
fn supports_authored_writes(&self) -> bool {
true
}
}
struct StaticTombstones(Vec<TombstoneMeta>);
#[async_trait]
impl TombstoneSource for StaticTombstones {
async fn recent_tombstones(
&self,
_scope: &MemoryScope,
_since_ms: u64,
_limit: usize,
) -> Result<Vec<TombstoneMeta>, AgentMemoryError> {
Ok(self.0.clone())
}
}
struct StaticTranscript(Option<TranscriptSlice>);
#[async_trait]
impl TranscriptSource for StaticTranscript {
async fn read(
&self,
_session_key: &str,
from_index: u64,
) -> Result<Option<TranscriptSlice>, DistillerError> {
Ok(self.0.clone().map(|mut slice| {
slice.messages.retain(|message| message.index >= from_index);
slice.start_index = from_index.max(slice.start_index);
slice
}))
}
}
fn slice(messages: &[(&'static str, &str)]) -> TranscriptSlice {
TranscriptSlice {
session_key: "sess-1".to_string(),
start_index: 0,
end_index: messages.len() as u64,
messages: messages
.iter()
.enumerate()
.map(|(index, (role, text))| TranscriptMessage {
index: index as u64,
role,
text: text.to_string(),
})
.collect(),
head_revision: Some("rev-head".to_string()),
}
}
fn engine_with(
replies: Vec<&str>,
provider: Arc<CapturingProvider>,
transcript: Option<TranscriptSlice>,
tombstones: Vec<TombstoneMeta>,
tracker: Option<SessionTaintTracker>,
config: DistillerConfig,
) -> (Arc<DistillerEngine>, Arc<ScriptedLlm>) {
let client = Arc::new(ScriptedLlm::new(replies));
let engine = Arc::new(DistillerEngine::new(
DistillerProfile::embedded_default(),
config,
Arc::new(ScriptedHandle {
client: client.clone(),
}),
provider,
Arc::new(StaticTombstones(tombstones)),
Arc::new(StaticTranscript(transcript)),
None,
tracker,
"family",
));
(engine, client)
}
fn enabled_config() -> DistillerConfig {
DistillerConfig {
enabled: true,
..DistillerConfig::default()
}
}
const NOOP_REPLY: &str = "[]";
const ONE_OP_REPLY: &str = r#"[{"action": "remember", "kind": "gotcha",
"title": "Cargo goes through the wrapper",
"description": "When running cargo commands in this repo",
"body": "Operator said: \"always use ./scripts/repo-cargo, never raw cargo\".",
"tags": [], "epistemic": "operator_said", "evidence_range": [1, 2]}]"#;
#[test]
fn prompt_renders_manifest_tombstones_and_transcript() {
let profile = DistillerProfile::embedded_default();
let manifest = vec![RecordMeta {
id: "mem-1".to_string(),
kind: MemoryKind::Gotcha,
title: "Wrapper cargo".to_string(),
description: "When building".to_string(),
age_days: 2,
rank: Some(1),
}];
let tombstones = vec![TombstoneMeta {
title: "Operator phone number".to_string(),
kind: MemoryKind::Fact,
tombstoned_at_ms: 5,
}];
let transcript = render_transcript(&slice(&[
("user", "please run the build"),
("assistant", "running it now"),
]));
let prompt = render_prompt(&profile, &manifest, &tombstones, &transcript);
assert!(prompt.contains("- mem-1 [gotcha, saved 2 days ago, rank 1] Wrapper cargo"));
assert!(prompt.contains("- [fact] Operator phone number"));
assert!(prompt.contains("[0] user: please run the build"));
assert!(prompt.contains("[1] assistant: running it now"));
assert!(!prompt.contains("{{existing_manifest}}"));
assert!(!prompt.contains("{{recent_tombstones}}"));
assert!(!prompt.contains("{{transcript}}"));
}
#[test]
fn prompt_renders_empty_sections_honestly() {
let profile = DistillerProfile::embedded_default();
let prompt = render_prompt(&profile, &[], &[], "(empty)");
assert!(prompt.contains("(no records)"));
assert!(prompt.contains("(none)"));
}
#[tokio::test]
async fn extract_parses_ops_and_tolerates_fences() -> Result<(), Box<dyn std::error::Error>> {
let fenced = format!("```json\n{ONE_OP_REPLY}\n```");
let client = ScriptedLlm::new(vec![&fenced]);
let profile = DistillerProfile::embedded_default();
let ops = extract(&profile, &client, &[], &[], "[0] user: hi").await?;
assert_eq!(ops.len(), 1);
assert_eq!(client.prompts().len(), 1, "fenced JSON needs no repair");
let op = validate_op(ops.into_iter().next().unwrap(), &[]).expect("valid op");
assert_eq!(op.kind, MemoryKind::Gotcha);
assert_eq!(op.epistemic, Epistemic::OperatorSaid);
assert_eq!(op.evidence_range, Some((1, 2)));
Ok(())
}
#[tokio::test]
async fn extract_repairs_malformed_output_once() -> Result<(), Box<dyn std::error::Error>> {
let client = ScriptedLlm::new(vec!["Here are my thoughts, no JSON", NOOP_REPLY]);
let profile = DistillerProfile::embedded_default();
let ops = extract(&profile, &client, &[], &[], "[0] user: hi").await?;
assert!(ops.is_empty());
let prompts = client.prompts();
assert_eq!(prompts.len(), 2, "exactly one repair round-trip");
assert!(prompts[1].contains("ONLY the corrected JSON array"));
Ok(())
}
#[tokio::test]
async fn extract_errors_after_failed_repair() {
let client = ScriptedLlm::new(vec!["nope", "still nope"]);
let profile = DistillerProfile::embedded_default();
let result = extract(&profile, &client, &[], &[], "[0] user: hi").await;
assert!(
matches!(result, Err(DistillerError::Parse(_))),
"{result:?}"
);
}
#[test]
fn validate_op_enforces_action_kind_epistemic_and_targets() {
let parse_one = |json: &str| -> Result<ProposedOp, String> {
let ops = parse_ops(json).expect("parses");
validate_op(
ops.into_iter().next().expect("one op"),
&["mem-1".to_string()],
)
};
let err =
parse_one(r#"[{"action":"remember","title":"t","body":"b","epistemic":"vibes"}]"#)
.expect_err("unknown epistemic");
assert!(err.contains("epistemic"), "{err}");
let err = parse_one(
r#"[{"action":"update","target_id":"mem-9","title":"t","body":"b","epistemic":"observed"}]"#,
)
.expect_err("unknown target");
assert!(err.contains("not in the manifest"), "{err}");
let ok = parse_one(
r#"[{"action":"update","target_id":"mem-1","title":"t","body":"b","epistemic":"observed"}]"#,
)
.expect("valid update");
assert_eq!(
ok.action,
ProposedAction::Update {
target_id: "mem-1".to_string()
}
);
let err = parse_one(
r#"[{"action":"remember","title":"t","body":"b","epistemic":"observed","evidence_range":[9,2]}]"#,
)
.expect_err("inverted range");
assert!(err.contains("inverted"), "{err}");
}
#[tokio::test]
async fn distill_writes_through_authored_seam_with_distiller_author_and_evidence() {
let provider = Arc::new(CapturingProvider::default());
let (engine, _client) = engine_with(
vec![ONE_OP_REPLY],
provider.clone(),
Some(slice(&[
("user", "use the wrapper"),
("assistant", "noted"),
("user", "always ./scripts/repo-cargo, never raw cargo"),
])),
Vec::new(),
None,
enabled_config(),
);
engine.note_session_generation("identity:a", "sess-1", 3);
let outcome = engine
.distill_now("identity:a", "sess-1", DistillCause::Retire)
.await;
assert!(
matches!(outcome, DistillOutcome::Completed { written: 1, .. }),
"{outcome:?}"
);
let remembers = provider.remembers.lock().unwrap();
assert_eq!(remembers.len(), 1);
let (scope, record, author) = &remembers[0];
assert_eq!(
*scope,
MemoryScope::Identity {
realm: "family".to_string(),
identity: "identity:a".to_string()
}
);
assert!(matches!(author, MemoryAuthor::Distiller { .. }));
assert!(author.is_llm(), "Distiller must classify as an LLM author");
assert_eq!(record.evidence.len(), 1);
let evidence = &record.evidence[0];
assert_eq!(evidence.session_id, "sess-1");
assert_eq!(evidence.generation, 3);
assert_eq!(evidence.revision.as_deref(), Some("rev-head"));
assert_eq!(
evidence.range,
Some((1, 2)),
"model-cited range within window"
);
assert!(record.tags.contains(&"epistemic:operator_said".to_string()));
}
#[tokio::test]
async fn hallucinated_evidence_range_falls_back_to_window_bounds() {
let reply = r#"[{"action": "remember", "kind": "fact", "title": "t",
"description": "", "body": "b", "tags": [],
"epistemic": "observed", "evidence_range": [90, 95]}]"#;
let provider = Arc::new(CapturingProvider::default());
let (engine, _client) = engine_with(
vec![reply],
provider.clone(),
Some(slice(&[("user", "a"), ("assistant", "b")])),
Vec::new(),
None,
enabled_config(),
);
engine
.distill_now("identity:a", "sess-1", DistillCause::Retire)
.await;
let remembers = provider.remembers.lock().unwrap();
assert_eq!(remembers[0].1.evidence[0].range, Some((0, 1)));
}
#[tokio::test]
async fn update_ops_supersede_the_target_record() {
let reply = r#"[{"action": "update", "target_id": "mem-1", "kind": "gotcha",
"title": "t", "description": "", "body": "b", "tags": [],
"epistemic": "observed"}]"#;
let provider = Arc::new(CapturingProvider::default());
provider.manifest.lock().unwrap().push(RecordMeta {
id: "mem-1".to_string(),
kind: MemoryKind::Gotcha,
title: "old".to_string(),
description: String::new(),
age_days: 1,
rank: None,
});
let (engine, _client) = engine_with(
vec![reply],
provider.clone(),
Some(slice(&[("user", "a")])),
Vec::new(),
None,
enabled_config(),
);
engine
.distill_now("identity:a", "sess-1", DistillCause::Retire)
.await;
let supersedes = provider.supersedes.lock().unwrap();
assert_eq!(supersedes.len(), 1);
assert_eq!(supersedes[0].1, "mem-1");
assert!(provider.remembers.lock().unwrap().is_empty());
}
#[tokio::test]
async fn recorder_write_in_window_skips_extraction_and_advances_cursor() {
let provider = Arc::new(CapturingProvider::default());
let (engine, client) = engine_with(
vec![ONE_OP_REPLY, ONE_OP_REPLY],
provider.clone(),
Some(slice(&[("user", "a"), ("assistant", "b")])),
Vec::new(),
None,
enabled_config(),
);
engine.note_recorder_write("identity:a", "sess-1");
let outcome = engine
.distill_now("identity:a", "sess-1", DistillCause::Interactions)
.await;
assert!(
matches!(&outcome, DistillOutcome::Skipped { reason } if reason.contains("mutual exclusion")),
"{outcome:?}"
);
assert!(
client.prompts().is_empty(),
"no LLM call in a skipped window"
);
assert!(provider.remembers.lock().unwrap().is_empty());
let outcome = engine
.distill_now("identity:a", "sess-1", DistillCause::Interactions)
.await;
assert!(
matches!(&outcome, DistillOutcome::Skipped { reason } if reason.contains("empty")),
"{outcome:?}"
);
let (engine, _client) = engine_with(
vec![ONE_OP_REPLY],
provider.clone(),
Some(slice(&[("user", "a")])),
Vec::new(),
None,
enabled_config(),
);
engine.note_recorder_write("identity:a", "sess-1");
let outcome = engine
.distill_now("identity:a", "sess-1", DistillCause::Retire)
.await;
assert!(
matches!(outcome, DistillOutcome::Completed { .. }),
"{outcome:?}"
);
}
#[test]
fn interaction_trigger_respects_min_interactions_and_in_flight() {
let provider = Arc::new(CapturingProvider::default());
let (engine, _client) = engine_with(
vec![],
provider,
None,
Vec::new(),
None,
DistillerConfig {
enabled: true,
min_interactions: 3,
..DistillerConfig::default()
},
);
assert!(!engine.note_run_completed("identity:a", "sess-1"));
assert!(!engine.note_run_completed("identity:a", "sess-1"));
assert!(
engine.note_run_completed("identity:a", "sess-1"),
"third run trips"
);
assert!(!engine.note_run_completed("identity:a", "sess-1"));
}
#[tokio::test]
async fn budget_guard_skips_runs_beyond_the_window_cap() {
let provider = Arc::new(CapturingProvider::default());
let (engine, _client) = engine_with(
vec![NOOP_REPLY, NOOP_REPLY],
provider,
Some(slice(&[("user", "a")])),
Vec::new(),
None,
DistillerConfig {
enabled: true,
runs_per_hour: 1,
..DistillerConfig::default()
},
);
let first = engine
.distill_now("identity:a", "sess-1", DistillCause::Retire)
.await;
assert!(
matches!(first, DistillOutcome::Completed { .. }),
"{first:?}"
);
engine.with_window("identity:a", "sess-1", |state| state.cursor = 0);
let second = engine
.distill_now("identity:a", "sess-1", DistillCause::Retire)
.await;
assert!(
matches!(&second, DistillOutcome::Skipped { reason } if reason.contains("budget denied")),
"{second:?}"
);
}
fn envelope(
session: &meerkat_core::types::SessionId,
payload: AgentEvent,
) -> meerkat_core::event::EventEnvelope<AgentEvent> {
meerkat_core::event::EventEnvelope {
event_id: Default::default(),
source: meerkat_core::event::EventSourceIdentity::Session {
session_id: session.clone(),
},
seq: 0,
mob_id: None,
timestamp_ms: 0,
payload,
}
}
#[tokio::test]
async fn trigger_sink_marks_memory_tool_writes_but_not_reads() {
let provider = Arc::new(CapturingProvider::default());
let session = meerkat_core::types::SessionId::new();
let session_key = session.to_string();
let make = |transcript: TranscriptSlice| {
engine_with(
vec![NOOP_REPLY],
provider.clone(),
Some(transcript),
Vec::new(),
None,
enabled_config(),
)
};
let tool_call = |action: &str| AgentEvent::ToolCallRequested {
id: "t-1".to_string(),
name: MEMORY_TOOL_NAME.to_string(),
args: meerkat_core::event::ToolCallArguments::from_value(serde_json::json!({
"action": action, "title": "t", "body": "b"
}))
.expect("object args"),
};
let mut transcript = slice(&[("user", "a")]);
transcript.session_key = session_key.clone();
let (engine, _client) = make(transcript.clone());
let sink = DistillerTriggers::new(engine.clone());
sink.observe("identity:a", &envelope(&session, tool_call("remember")));
let outcome = engine
.distill_now("identity:a", &session_key, DistillCause::Interactions)
.await;
assert!(
matches!(&outcome, DistillOutcome::Skipped { reason } if reason.contains("mutual exclusion")),
"{outcome:?}"
);
let (engine, _client) = make(transcript);
let sink = DistillerTriggers::new(engine.clone());
sink.observe("identity:a", &envelope(&session, tool_call("recall")));
let outcome = engine
.distill_now("identity:a", &session_key, DistillCause::Interactions)
.await;
assert!(
matches!(outcome, DistillOutcome::Completed { .. }),
"{outcome:?}"
);
}
#[tokio::test]
async fn reset_cause_marks_boundary_so_evidence_quarantines() {
let tracker = SessionTaintTracker::new(Default::default());
let provider = Arc::new(CapturingProvider::default());
let (engine, _client) = engine_with(
vec![ONE_OP_REPLY],
provider,
Some(slice(&[("user", "a"), ("assistant", "b"), ("user", "c")])),
Vec::new(),
Some(tracker.clone()),
enabled_config(),
);
assert!(tracker.evidence_quarantine_reason("sess-1").is_none());
engine
.distill_now("identity:a", "sess-1", DistillCause::Reset)
.await;
let reason = tracker
.evidence_quarantine_reason("sess-1")
.expect("reset boundary marked before writes");
assert!(reason.contains("reset"), "{reason}");
}
#[tokio::test]
async fn pre_rotation_distillation_never_blocks_rotation() {
let provider = Arc::new(CapturingProvider::default());
let engine = Arc::new(
DistillerEngine::new(
DistillerProfile::embedded_default(),
enabled_config(),
Arc::new(HangingHandle),
provider,
Arc::new(StaticTombstones(Vec::new())),
Arc::new(StaticTranscript(Some(slice(&[("user", "a")])))),
None,
None,
"family",
)
.with_pre_rotation_timeout(Duration::from_millis(50)),
);
let started = Instant::now();
engine
.distill_before_rotation("identity:a", "sess-1", DistillCause::Respawn)
.await;
assert!(
started.elapsed() < Duration::from_secs(2),
"pre-rotation hook must return at the timeout, not hang"
);
}
#[test]
fn embedded_prompt_matches_calibration_bundle() -> Result<(), Box<dyn std::error::Error>> {
let bundle =
Path::new(env!("CARGO_MANIFEST_DIR")).join("../memory-evals/prompts/distiller-v0.md");
if !bundle.is_file() {
return Ok(());
}
let text = std::fs::read_to_string(bundle)?;
assert_eq!(
text, EMBEDDED_PROMPT_V0,
"memory-evals/prompts/distiller-v0.md and \
src/memory/distiller_prompt_v0.md have drifted"
);
Ok(())
}
#[test]
fn embedded_default_profile_validates_and_names_a_catalog_model() {
let profile = DistillerProfile::embedded_default();
profile.validate().expect("embedded profile must validate");
assert_eq!(
meerkat_models::infer_provider(&profile.model),
Some(profile.provider),
"embedded default model must resolve in the catalog"
);
}
#[test]
fn external_profile_loads_from_evals_layout() -> Result<(), Box<dyn std::error::Error>> {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../memory-evals/profiles/distiller-v0.toml");
if !path.is_file() {
return Ok(());
}
let profile = DistillerProfile::load(&path)?;
assert_eq!(profile.stage, "distiller");
assert_eq!(profile.prompt_template, EMBEDDED_PROMPT_V0);
Ok(())
}
#[test]
fn model_override_is_fail_loud() {
let profile = DistillerProfile::embedded_default();
assert!(profile.clone().with_model_override("not-a-model").is_err());
assert!(profile.clone().with_model_override(" ").is_err());
let overridden = profile
.with_model_override("claude-haiku-4-5")
.expect("catalog model accepted");
assert_eq!(overridden.model, "claude-haiku-4-5");
}
#[test]
fn compaction_harvest_satisfaction_classifies_skip_reasons() {
assert!(
DistillOutcome::Completed {
run_id: "r".to_string(),
written: 0,
quarantined: 0,
}
.compaction_harvest_satisfied()
);
assert!(
DistillOutcome::Skipped {
reason: SKIP_NO_DISCARDS.to_string(),
}
.compaction_harvest_satisfied()
);
assert!(
DistillOutcome::Skipped {
reason: SKIP_NO_DISCARD_SOURCE.to_string(),
}
.compaction_harvest_satisfied()
);
for reason in [
"budget denied: window budget exhausted (2/2 runs)",
"compaction harvest failed: io",
"extraction failed: parse",
] {
assert!(
!DistillOutcome::Skipped {
reason: reason.to_string(),
}
.compaction_harvest_satisfied(),
"{reason}"
);
}
}
#[tokio::test]
async fn compaction_follow_up_fires_with_outcome_and_cursor_is_readable() {
let provider = Arc::new(CapturingProvider::default());
let (engine, _client) = engine_with(
vec![NOOP_REPLY],
provider,
Some(slice(&[("user", "hello")])),
Vec::new(),
None,
enabled_config(),
);
let seen: Arc<StdMutex<Vec<(String, String, bool)>>> = Arc::new(StdMutex::new(Vec::new()));
let sink = seen.clone();
engine.set_compaction_follow_up(Arc::new(move |identity, session, outcome| {
sink.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push((
identity.to_string(),
session.to_string(),
outcome.compaction_harvest_satisfied(),
));
}));
engine
.distill_now("identity:a", "sess-1", DistillCause::Compaction)
.await;
engine
.distill_now("identity:a", "sess-1", DistillCause::Interactions)
.await;
let seen = seen
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
assert_eq!(
seen,
vec![("identity:a".to_string(), "sess-1".to_string(), true)]
);
assert_eq!(engine.distilled_cursor("identity:never", "sess-x"), 0);
engine.with_window("identity:a", "sess-1", |state| state.cursor = 7);
assert_eq!(engine.distilled_cursor("identity:a", "sess-1"), 7);
}
struct StaticDiscards(Vec<DiscardEntry>);
#[async_trait]
impl CompactionDiscardSource for StaticDiscards {
async fn read_discards(
&self,
_session_key: &str,
_limit: usize,
) -> Result<Vec<DiscardEntry>, DistillerError> {
Ok(self.0.clone())
}
}
#[tokio::test]
async fn compaction_harvest_preserves_interaction_window_state() {
let provider = Arc::new(CapturingProvider::default());
let client = Arc::new(ScriptedLlm::new(vec![ONE_OP_REPLY]));
let engine = Arc::new(DistillerEngine::new(
DistillerProfile::embedded_default(),
enabled_config(),
Arc::new(ScriptedHandle { client }),
provider,
Arc::new(StaticTombstones(Vec::new())),
Arc::new(StaticTranscript(Some(slice(&[
("user", "use the wrapper"),
("assistant", "noted"),
])))),
Some(Arc::new(StaticDiscards(vec![DiscardEntry {
content: "discarded: wrapper reminder".to_string(),
range: Some((0, 5)),
}]))),
None,
"family",
));
engine.note_recorder_write("identity:a", "sess-1");
assert!(!engine.note_run_completed("identity:a", "sess-1"));
let outcome = engine
.distill_now("identity:a", "sess-1", DistillCause::Compaction)
.await;
assert!(
matches!(outcome, DistillOutcome::Completed { written: 1, .. }),
"{outcome:?}"
);
let (cursor, recorder_wrote, completed_runs) =
engine.with_window("identity:a", "sess-1", |state| {
(state.cursor, state.recorder_wrote, state.completed_runs)
});
assert_eq!(cursor, 0, "compaction never advances the transcript cursor");
assert!(
recorder_wrote,
"compaction-cause harvests must not clear the recorder \
mutual-exclusion flag for a window they did not distill"
);
assert_eq!(
completed_runs, 1,
"compaction-cause harvests must not zero the interaction counter"
);
let outcome = engine
.distill_now("identity:a", "sess-1", DistillCause::Interactions)
.await;
assert!(
matches!(&outcome, DistillOutcome::Skipped { reason } if reason.contains("mutual exclusion")),
"{outcome:?}"
);
}
#[tokio::test]
async fn compaction_observed_hook_fires_at_observation_even_without_discards() {
let provider = Arc::new(CapturingProvider::default());
let (engine, _client) = engine_with(
vec![NOOP_REPLY],
provider,
Some(slice(&[("user", "hello")])),
Vec::new(),
None,
enabled_config(),
);
let seen: Arc<StdMutex<Vec<(String, String)>>> = Arc::new(StdMutex::new(Vec::new()));
let observed = seen.clone();
engine.set_compaction_observed(Arc::new(move |identity, session| {
observed
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push((identity.to_string(), session.to_string()));
}));
let session = meerkat_core::types::SessionId::new();
let sink = DistillerTriggers::new(engine.clone());
sink.observe(
"identity:a",
&envelope(
&session,
AgentEvent::CompactionCompleted {
summary_tokens: 10,
messages_before: 20,
messages_after: 2,
},
),
);
assert_eq!(
seen.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone(),
vec![("identity:a".to_string(), session.to_string())]
);
sink.observe(
"identity:a",
&envelope(
&session,
AgentEvent::RunCompleted {
session_id: session.clone(),
result: "done".to_string(),
structured_output: None,
extraction_required: false,
usage: Default::default(),
terminal_cause_kind: None,
},
),
);
assert_eq!(
seen.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len(),
1
);
}
}