chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
use super::*;
|
||||
use crate::memory_types::PipelineState;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Global memory activity state - updated by sidecar, read by info widget
|
||||
static MEMORY_ACTIVITY: Mutex<Option<MemoryActivity>> = Mutex::new(None);
|
||||
|
||||
/// Maximum number of recent events to keep
|
||||
const MAX_RECENT_EVENTS: usize = 10;
|
||||
|
||||
/// Staleness timeout: auto-reset to Idle if state has been non-Idle for this long
|
||||
const STALENESS_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
/// Get current memory activity state
|
||||
pub fn get_activity() -> Option<MemoryActivity> {
|
||||
MEMORY_ACTIVITY.lock().ok().and_then(|guard| guard.clone())
|
||||
}
|
||||
|
||||
pub fn activity_snapshot() -> Option<crate::protocol::MemoryActivitySnapshot> {
|
||||
get_activity().as_ref().map(memory_activity_snapshot)
|
||||
}
|
||||
|
||||
pub fn apply_remote_activity_snapshot(snapshot: &crate::protocol::MemoryActivitySnapshot) {
|
||||
if let Ok(mut guard) = MEMORY_ACTIVITY.lock() {
|
||||
let recent_events = guard
|
||||
.as_ref()
|
||||
.map(|activity| activity.recent_events.clone())
|
||||
.unwrap_or_default();
|
||||
let now = Instant::now();
|
||||
let state_since = now
|
||||
.checked_sub(Duration::from_millis(snapshot.state_age_ms))
|
||||
.unwrap_or(now);
|
||||
|
||||
*guard = Some(MemoryActivity {
|
||||
state: from_snapshot_state(&snapshot.state),
|
||||
state_since,
|
||||
pipeline: snapshot.pipeline.as_ref().map(from_snapshot_pipeline),
|
||||
recent_events,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the memory activity state
|
||||
pub fn set_state(state: MemoryState) {
|
||||
if let Ok(mut guard) = MEMORY_ACTIVITY.lock() {
|
||||
if let Some(activity) = guard.as_mut() {
|
||||
activity.state = state;
|
||||
activity.state_since = Instant::now();
|
||||
} else {
|
||||
*guard = Some(MemoryActivity {
|
||||
state,
|
||||
state_since: Instant::now(),
|
||||
pipeline: None,
|
||||
recent_events: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an event to the activity log
|
||||
pub fn add_event(kind: MemoryEventKind) {
|
||||
crate::memory_log::log_event(&kind);
|
||||
|
||||
if let Ok(mut guard) = MEMORY_ACTIVITY.lock() {
|
||||
let event = MemoryEvent {
|
||||
kind,
|
||||
timestamp: Instant::now(),
|
||||
detail: None,
|
||||
};
|
||||
|
||||
if let Some(activity) = guard.as_mut() {
|
||||
activity.recent_events.insert(0, event);
|
||||
activity.recent_events.truncate(MAX_RECENT_EVENTS);
|
||||
} else {
|
||||
*guard = Some(MemoryActivity {
|
||||
state: MemoryState::Idle,
|
||||
state_since: Instant::now(),
|
||||
pipeline: None,
|
||||
recent_events: vec![event],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a new pipeline run (called at the beginning of each memory check)
|
||||
pub fn pipeline_start() {
|
||||
if let Ok(mut guard) = MEMORY_ACTIVITY.lock() {
|
||||
if let Some(activity) = guard.as_mut() {
|
||||
activity.pipeline = Some(PipelineState::new());
|
||||
} else {
|
||||
*guard = Some(MemoryActivity {
|
||||
state: MemoryState::Idle,
|
||||
state_since: Instant::now(),
|
||||
pipeline: Some(PipelineState::new()),
|
||||
recent_events: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update pipeline step status
|
||||
#[expect(
|
||||
clippy::collapsible_if,
|
||||
reason = "Memory activity updates keep optional state transitions explicit"
|
||||
)]
|
||||
pub fn pipeline_update(f: impl FnOnce(&mut PipelineState)) {
|
||||
if let Ok(mut guard) = MEMORY_ACTIVITY.lock() {
|
||||
if let Some(activity) = guard.as_mut() {
|
||||
if let Some(pipeline) = activity.pipeline.as_mut() {
|
||||
f(pipeline);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for staleness and auto-reset if needed.
|
||||
/// Returns true if state was reset due to staleness.
|
||||
#[expect(
|
||||
clippy::collapsible_if,
|
||||
reason = "Memory activity timeout checks keep nested optional state explicit"
|
||||
)]
|
||||
pub fn check_staleness() -> bool {
|
||||
if let Ok(mut guard) = MEMORY_ACTIVITY.lock() {
|
||||
if let Some(activity) = guard.as_mut() {
|
||||
if !matches!(activity.state, MemoryState::Idle)
|
||||
&& activity.state_since.elapsed().as_secs() >= STALENESS_TIMEOUT_SECS
|
||||
{
|
||||
crate::logging::info(&format!(
|
||||
"Memory state stale ({:?} for {}s), auto-resetting to Idle",
|
||||
activity.state,
|
||||
activity.state_since.elapsed().as_secs()
|
||||
));
|
||||
activity.state = MemoryState::Idle;
|
||||
activity.state_since = Instant::now();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Clear activity (reset to idle with no events)
|
||||
pub fn clear_activity() {
|
||||
if let Ok(mut guard) = MEMORY_ACTIVITY.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Record that a memory payload was injected into model context.
|
||||
/// This feeds the memory info widget with injected content + metadata.
|
||||
pub fn record_injected_prompt(prompt: &str, count: usize, age_ms: u64) {
|
||||
crate::telemetry::record_memory_injected(count, age_ms);
|
||||
let items = parse_injected_items(prompt, 8);
|
||||
let preview = prompt_preview(prompt, 72);
|
||||
add_event(MemoryEventKind::MemoryInjected {
|
||||
count,
|
||||
prompt_chars: prompt.chars().count(),
|
||||
age_ms,
|
||||
preview: preview.clone(),
|
||||
items,
|
||||
});
|
||||
add_event(MemoryEventKind::MemorySurfaced {
|
||||
memory_preview: preview,
|
||||
});
|
||||
}
|
||||
|
||||
fn parse_injected_items(prompt: &str, max_items: usize) -> Vec<InjectedMemoryItem> {
|
||||
let mut items: Vec<InjectedMemoryItem> = Vec::new();
|
||||
let mut section = String::from("Memory");
|
||||
|
||||
for raw_line in prompt.lines() {
|
||||
let line = raw_line.trim();
|
||||
if line.is_empty() || line == "# Memory" {
|
||||
continue;
|
||||
}
|
||||
if let Some(header) = line.strip_prefix("## ") {
|
||||
let header = header.trim();
|
||||
if !header.is_empty() {
|
||||
section = header.to_string();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = if let Some(rest) = line.strip_prefix("- ") {
|
||||
Some(rest.trim())
|
||||
} else if let Some((prefix, rest)) = line.split_once(". ") {
|
||||
if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()) {
|
||||
Some(rest.trim())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(content) = content {
|
||||
if content.is_empty() {
|
||||
continue;
|
||||
}
|
||||
items.push(InjectedMemoryItem {
|
||||
section: section.clone(),
|
||||
content: content.to_string(),
|
||||
});
|
||||
if items.len() >= max_items {
|
||||
return items;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if items.is_empty() {
|
||||
let fallback = prompt
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| {
|
||||
!line.is_empty()
|
||||
&& !line.starts_with('#')
|
||||
&& !line.starts_with("## ")
|
||||
&& !line.starts_with("- ")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if !fallback.is_empty() {
|
||||
items.push(InjectedMemoryItem {
|
||||
section,
|
||||
content: fallback,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
fn prompt_preview(prompt: &str, max_chars: usize) -> String {
|
||||
let bullet = prompt
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find_map(|line| {
|
||||
if line.starts_with("- ") {
|
||||
Some(line.trim_start_matches("- ").trim())
|
||||
} else if let Some((prefix, rest)) = line.split_once(". ") {
|
||||
if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()) {
|
||||
Some(rest.trim())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| prompt.trim());
|
||||
|
||||
if bullet.chars().count() <= max_chars {
|
||||
bullet.to_string()
|
||||
} else {
|
||||
let mut out = String::new();
|
||||
for (i, ch) in bullet.chars().enumerate() {
|
||||
if i >= max_chars.saturating_sub(3) {
|
||||
break;
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out.push_str("...");
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn memory_activity_snapshot(activity: &MemoryActivity) -> crate::protocol::MemoryActivitySnapshot {
|
||||
crate::protocol::MemoryActivitySnapshot {
|
||||
state: snapshot_state(&activity.state),
|
||||
state_age_ms: activity.state_since.elapsed().as_millis() as u64,
|
||||
pipeline: activity.pipeline.as_ref().map(snapshot_pipeline),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_state(state: &MemoryState) -> crate::protocol::MemoryStateSnapshot {
|
||||
match state {
|
||||
MemoryState::Idle => crate::protocol::MemoryStateSnapshot::Idle,
|
||||
MemoryState::Embedding => crate::protocol::MemoryStateSnapshot::Embedding,
|
||||
MemoryState::SidecarChecking { count } => {
|
||||
crate::protocol::MemoryStateSnapshot::SidecarChecking { count: *count }
|
||||
}
|
||||
MemoryState::FoundRelevant { count } => {
|
||||
crate::protocol::MemoryStateSnapshot::FoundRelevant { count: *count }
|
||||
}
|
||||
MemoryState::Extracting { reason } => crate::protocol::MemoryStateSnapshot::Extracting {
|
||||
reason: reason.clone(),
|
||||
},
|
||||
MemoryState::Maintaining { phase } => crate::protocol::MemoryStateSnapshot::Maintaining {
|
||||
phase: phase.clone(),
|
||||
},
|
||||
MemoryState::ToolAction { action, detail } => {
|
||||
crate::protocol::MemoryStateSnapshot::ToolAction {
|
||||
action: action.clone(),
|
||||
detail: detail.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_pipeline(pipeline: &PipelineState) -> crate::protocol::MemoryPipelineSnapshot {
|
||||
crate::protocol::MemoryPipelineSnapshot {
|
||||
search: snapshot_step_status(&pipeline.search),
|
||||
search_result: pipeline.search_result.as_ref().map(snapshot_step_result),
|
||||
verify: snapshot_step_status(&pipeline.verify),
|
||||
verify_result: pipeline.verify_result.as_ref().map(snapshot_step_result),
|
||||
verify_progress: pipeline.verify_progress,
|
||||
inject: snapshot_step_status(&pipeline.inject),
|
||||
inject_result: pipeline.inject_result.as_ref().map(snapshot_step_result),
|
||||
maintain: snapshot_step_status(&pipeline.maintain),
|
||||
maintain_result: pipeline.maintain_result.as_ref().map(snapshot_step_result),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_step_status(status: &StepStatus) -> crate::protocol::MemoryStepStatusSnapshot {
|
||||
match status {
|
||||
StepStatus::Pending => crate::protocol::MemoryStepStatusSnapshot::Pending,
|
||||
StepStatus::Running => crate::protocol::MemoryStepStatusSnapshot::Running,
|
||||
StepStatus::Done => crate::protocol::MemoryStepStatusSnapshot::Done,
|
||||
StepStatus::Error => crate::protocol::MemoryStepStatusSnapshot::Error,
|
||||
StepStatus::Skipped => crate::protocol::MemoryStepStatusSnapshot::Skipped,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_step_result(result: &StepResult) -> crate::protocol::MemoryStepResultSnapshot {
|
||||
crate::protocol::MemoryStepResultSnapshot {
|
||||
summary: result.summary.clone(),
|
||||
latency_ms: result.latency_ms,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_snapshot_state(snapshot: &crate::protocol::MemoryStateSnapshot) -> MemoryState {
|
||||
match snapshot {
|
||||
crate::protocol::MemoryStateSnapshot::Idle => MemoryState::Idle,
|
||||
crate::protocol::MemoryStateSnapshot::Embedding => MemoryState::Embedding,
|
||||
crate::protocol::MemoryStateSnapshot::SidecarChecking { count } => {
|
||||
MemoryState::SidecarChecking { count: *count }
|
||||
}
|
||||
crate::protocol::MemoryStateSnapshot::FoundRelevant { count } => {
|
||||
MemoryState::FoundRelevant { count: *count }
|
||||
}
|
||||
crate::protocol::MemoryStateSnapshot::Extracting { reason } => MemoryState::Extracting {
|
||||
reason: reason.clone(),
|
||||
},
|
||||
crate::protocol::MemoryStateSnapshot::Maintaining { phase } => MemoryState::Maintaining {
|
||||
phase: phase.clone(),
|
||||
},
|
||||
crate::protocol::MemoryStateSnapshot::ToolAction { action, detail } => {
|
||||
MemoryState::ToolAction {
|
||||
action: action.clone(),
|
||||
detail: detail.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn from_snapshot_pipeline(snapshot: &crate::protocol::MemoryPipelineSnapshot) -> PipelineState {
|
||||
PipelineState {
|
||||
search: from_snapshot_step_status(&snapshot.search),
|
||||
search_result: snapshot
|
||||
.search_result
|
||||
.as_ref()
|
||||
.map(from_snapshot_step_result),
|
||||
verify: from_snapshot_step_status(&snapshot.verify),
|
||||
verify_result: snapshot
|
||||
.verify_result
|
||||
.as_ref()
|
||||
.map(from_snapshot_step_result),
|
||||
verify_progress: snapshot.verify_progress,
|
||||
inject: from_snapshot_step_status(&snapshot.inject),
|
||||
inject_result: snapshot
|
||||
.inject_result
|
||||
.as_ref()
|
||||
.map(from_snapshot_step_result),
|
||||
maintain: from_snapshot_step_status(&snapshot.maintain),
|
||||
maintain_result: snapshot
|
||||
.maintain_result
|
||||
.as_ref()
|
||||
.map(from_snapshot_step_result),
|
||||
started_at: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_snapshot_step_status(snapshot: &crate::protocol::MemoryStepStatusSnapshot) -> StepStatus {
|
||||
match snapshot {
|
||||
crate::protocol::MemoryStepStatusSnapshot::Pending => StepStatus::Pending,
|
||||
crate::protocol::MemoryStepStatusSnapshot::Running => StepStatus::Running,
|
||||
crate::protocol::MemoryStepStatusSnapshot::Done => StepStatus::Done,
|
||||
crate::protocol::MemoryStepStatusSnapshot::Error => StepStatus::Error,
|
||||
crate::protocol::MemoryStepStatusSnapshot::Skipped => StepStatus::Skipped,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_snapshot_step_result(snapshot: &crate::protocol::MemoryStepResultSnapshot) -> StepResult {
|
||||
StepResult {
|
||||
summary: snapshot.summary.clone(),
|
||||
latency_ms: snapshot.latency_ms,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use crate::memory_graph::MemoryGraph;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::SystemTime;
|
||||
|
||||
// === Graph Cache ===
|
||||
|
||||
struct GraphCacheEntry {
|
||||
graph: MemoryGraph,
|
||||
modified: Option<SystemTime>,
|
||||
}
|
||||
|
||||
struct GraphCache {
|
||||
entries: HashMap<PathBuf, GraphCacheEntry>,
|
||||
}
|
||||
|
||||
impl GraphCache {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
entries: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static GRAPH_CACHE: OnceLock<Mutex<GraphCache>> = OnceLock::new();
|
||||
|
||||
fn graph_cache() -> &'static Mutex<GraphCache> {
|
||||
GRAPH_CACHE.get_or_init(|| Mutex::new(GraphCache::new()))
|
||||
}
|
||||
|
||||
fn graph_mtime(path: &PathBuf) -> Option<SystemTime> {
|
||||
std::fs::metadata(path).ok().and_then(|m| m.modified().ok())
|
||||
}
|
||||
|
||||
pub(super) fn cached_graph(path: &PathBuf) -> Option<MemoryGraph> {
|
||||
let modified = graph_mtime(path);
|
||||
let cache = graph_cache().lock().ok()?;
|
||||
let entry = cache.entries.get(path)?;
|
||||
if entry.modified == modified {
|
||||
Some(entry.graph.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn cache_graph(path: PathBuf, graph: &MemoryGraph) {
|
||||
let modified = graph_mtime(&path);
|
||||
if let Ok(mut cache) = graph_cache().lock() {
|
||||
cache.entries.insert(
|
||||
path,
|
||||
GraphCacheEntry {
|
||||
graph: graph.clone(),
|
||||
modified,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
type LastInjectedMemorySetBySession = HashMap<String, (HashSet<String>, Instant)>;
|
||||
type InjectedMemoryIdsBySession = HashMap<String, HashMap<String, Instant>>;
|
||||
|
||||
/// Pending memory prompt from background check - ready to inject on next turn.
|
||||
/// Keyed by session ID so each session gets its own pending memory.
|
||||
static PENDING_MEMORY: Mutex<Option<HashMap<String, PendingMemory>>> = Mutex::new(None);
|
||||
|
||||
/// Signature of the last injected prompt to suppress near-immediate duplicates.
|
||||
/// Keyed by session ID.
|
||||
static LAST_INJECTED_PROMPT_SIGNATURE: Mutex<Option<HashMap<String, (String, Instant)>>> =
|
||||
Mutex::new(None);
|
||||
|
||||
/// Recently injected memory ID sets per session.
|
||||
/// Used to suppress near-duplicate re-injection even when formatting differs.
|
||||
static LAST_INJECTED_MEMORY_SET: Mutex<Option<LastInjectedMemorySetBySession>> = Mutex::new(None);
|
||||
|
||||
/// Memory IDs that have already been injected into the conversation, with the
|
||||
/// time they were injected. Used to prevent the same memory from being
|
||||
/// re-injected on subsequent turns while it is still fresh in the transcript.
|
||||
/// Keyed by session ID.
|
||||
static INJECTED_MEMORY_IDS: Mutex<Option<InjectedMemoryIdsBySession>> = Mutex::new(None);
|
||||
|
||||
/// Guard to ensure only one memory check runs at a time, per session.
|
||||
/// Keyed by session ID.
|
||||
static MEMORY_CHECK_IN_PROGRESS: Mutex<Option<HashSet<String>>> = Mutex::new(None);
|
||||
|
||||
/// Suppress repeated identical memory payloads within this many seconds.
|
||||
const MEMORY_REPEAT_SUPPRESSION_SECS: u64 = 90;
|
||||
/// Suppress substantially overlapping memory sets for a bit longer.
|
||||
const MEMORY_SET_REPEAT_SUPPRESSION_SECS: u64 = 180;
|
||||
/// If a new pending payload overlaps this much with the last injected set,
|
||||
/// treat it as too similar to surface again immediately.
|
||||
const MEMORY_SET_OVERLAP_SUPPRESSION_RATIO: f32 = 0.8;
|
||||
/// How long an injected memory counts as "already known" to a session.
|
||||
///
|
||||
/// Injection payloads are ephemeral (not persisted into history), but the
|
||||
/// model's response that consumed them IS part of the transcript, so
|
||||
/// re-injecting the same memory shortly afterwards is pure noise. This
|
||||
/// tracking used to be cleared on every detected topic change, which fires
|
||||
/// often on real sessions (cosine similarity between consecutive coding turns
|
||||
/// regularly sits below the topic threshold), so the same memory could be
|
||||
/// re-injected minutes apart in one transcript. A TTL keeps the dedup stable
|
||||
/// across topic wobble while still letting genuinely old memories resurface in
|
||||
/// long sessions once they may have scrolled out of (or been compacted from)
|
||||
/// the context window.
|
||||
const INJECTED_MEMORY_TTL_SECS: u64 = 45 * 60;
|
||||
|
||||
fn injected_recently(at: &Instant) -> bool {
|
||||
at.elapsed().as_secs() < INJECTED_MEMORY_TTL_SECS
|
||||
}
|
||||
|
||||
/// A pending memory result from async checking.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingMemory {
|
||||
/// The formatted memory prompt ready for injection.
|
||||
pub prompt: String,
|
||||
/// Optional UI-focused rendering of the injected memory payload.
|
||||
/// This can contain extra display-only metadata that is not sent to the model.
|
||||
pub display_prompt: Option<String>,
|
||||
/// When this was computed.
|
||||
pub computed_at: Instant,
|
||||
/// Number of relevant memories found.
|
||||
pub count: usize,
|
||||
/// IDs of memories included in this prompt (for dedup tracking).
|
||||
pub memory_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl PendingMemory {
|
||||
/// Check if this pending memory is still fresh (not too old).
|
||||
pub fn is_fresh(&self) -> bool {
|
||||
self.computed_at.elapsed().as_secs() < 120
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_signature(prompt: &str) -> String {
|
||||
prompt
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
fn memory_set(ids: &[String]) -> HashSet<String> {
|
||||
ids.iter().cloned().collect()
|
||||
}
|
||||
|
||||
fn memory_overlap_ratio(left: &HashSet<String>, right: &HashSet<String>) -> f32 {
|
||||
if left.is_empty() || right.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let intersection = left.intersection(right).count() as f32;
|
||||
let baseline = left.len().max(right.len()) as f32;
|
||||
intersection / baseline
|
||||
}
|
||||
|
||||
/// Take pending memory if available and fresh for the given session.
|
||||
pub fn take_pending_memory(session_id: &str) -> Option<PendingMemory> {
|
||||
if let Ok(mut guard) = PENDING_MEMORY.lock() {
|
||||
let map = guard.get_or_insert_with(HashMap::new);
|
||||
if let Some(pending) = map.remove(session_id) {
|
||||
if !pending.is_fresh() {
|
||||
crate::memory_log::log_pending_discarded(session_id, "stale (>120s)");
|
||||
return None;
|
||||
}
|
||||
|
||||
// If every memory in this payload is still fresh in the session's
|
||||
// injected set, the model already knows all of it; do not re-inject
|
||||
// just because formatting or ranking shifted slightly.
|
||||
if !pending.memory_ids.is_empty()
|
||||
&& pending
|
||||
.memory_ids
|
||||
.iter()
|
||||
.all(|id| is_memory_injected(session_id, id))
|
||||
{
|
||||
crate::memory_log::log_pending_discarded(
|
||||
session_id,
|
||||
"all memories already known to session",
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let sig = prompt_signature(&pending.prompt);
|
||||
if let Ok(mut last_guard) = LAST_INJECTED_PROMPT_SIGNATURE.lock() {
|
||||
let sig_map = last_guard.get_or_insert_with(HashMap::new);
|
||||
if let Some((last_sig, last_at)) = sig_map.get(session_id)
|
||||
&& *last_sig == sig
|
||||
&& last_at.elapsed().as_secs() < MEMORY_REPEAT_SUPPRESSION_SECS
|
||||
{
|
||||
crate::memory_log::log_pending_discarded(session_id, "duplicate suppressed");
|
||||
return None;
|
||||
}
|
||||
sig_map.insert(session_id.to_string(), (sig, Instant::now()));
|
||||
}
|
||||
|
||||
if !pending.memory_ids.is_empty() {
|
||||
let pending_set = memory_set(&pending.memory_ids);
|
||||
if let Ok(mut last_guard) = LAST_INJECTED_MEMORY_SET.lock() {
|
||||
let set_map = last_guard.get_or_insert_with(HashMap::new);
|
||||
if let Some((last_set, last_at)) = set_map.get(session_id) {
|
||||
let overlap = memory_overlap_ratio(last_set, &pending_set);
|
||||
if overlap >= MEMORY_SET_OVERLAP_SUPPRESSION_RATIO
|
||||
&& last_at.elapsed().as_secs() < MEMORY_SET_REPEAT_SUPPRESSION_SECS
|
||||
{
|
||||
crate::memory_log::log_pending_discarded(
|
||||
session_id,
|
||||
"overlapping memory set suppressed",
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
set_map.insert(session_id.to_string(), (pending_set, Instant::now()));
|
||||
}
|
||||
}
|
||||
|
||||
if !pending.memory_ids.is_empty() {
|
||||
mark_memories_injected(session_id, &pending.memory_ids);
|
||||
}
|
||||
|
||||
crate::memory_log::log_pending_consumed(
|
||||
session_id,
|
||||
pending.count,
|
||||
pending.computed_at.elapsed().as_millis() as u64,
|
||||
pending.prompt.chars().count(),
|
||||
);
|
||||
|
||||
return Some(pending);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Store a pending memory result for the given session.
|
||||
pub fn set_pending_memory(session_id: &str, prompt: String, count: usize) {
|
||||
set_pending_memory_with_ids(session_id, prompt, count, Vec::new());
|
||||
}
|
||||
|
||||
/// Store a pending memory result with associated memory IDs for dedup tracking.
|
||||
pub fn set_pending_memory_with_ids(
|
||||
session_id: &str,
|
||||
prompt: String,
|
||||
count: usize,
|
||||
memory_ids: Vec<String>,
|
||||
) {
|
||||
set_pending_memory_with_ids_and_display(session_id, prompt, count, memory_ids, None);
|
||||
}
|
||||
|
||||
/// Store a pending memory result with associated memory IDs and optional display-only content.
|
||||
pub fn set_pending_memory_with_ids_and_display(
|
||||
session_id: &str,
|
||||
prompt: String,
|
||||
count: usize,
|
||||
memory_ids: Vec<String>,
|
||||
display_prompt: Option<String>,
|
||||
) {
|
||||
crate::memory_log::log_pending_prepared(session_id, &prompt, count, &memory_ids);
|
||||
|
||||
if let Ok(mut guard) = PENDING_MEMORY.lock() {
|
||||
let map = guard.get_or_insert_with(HashMap::new);
|
||||
let new_sig = prompt_signature(&prompt);
|
||||
let new_memory_set = memory_set(&memory_ids);
|
||||
|
||||
if let Some(existing) = map.get(session_id)
|
||||
&& existing.is_fresh()
|
||||
{
|
||||
let existing_sig = prompt_signature(&existing.prompt);
|
||||
let overlap = memory_overlap_ratio(&memory_set(&existing.memory_ids), &new_memory_set);
|
||||
if existing_sig == new_sig || overlap >= MEMORY_SET_OVERLAP_SUPPRESSION_RATIO {
|
||||
crate::memory_log::log_pending_discarded(
|
||||
session_id,
|
||||
"similar pending payload already queued",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
map.insert(
|
||||
session_id.to_string(),
|
||||
PendingMemory {
|
||||
prompt,
|
||||
display_prompt,
|
||||
computed_at: Instant::now(),
|
||||
count,
|
||||
memory_ids,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark memory IDs as already injected for a session (prevents re-injection on future turns).
|
||||
pub fn mark_memories_injected(session_id: &str, ids: &[String]) {
|
||||
crate::memory_log::log_marked_injected(session_id, ids);
|
||||
insert_injected_ids(session_id, ids);
|
||||
}
|
||||
|
||||
/// Mark memory IDs as already KNOWN to a session without them having been
|
||||
/// injected, e.g. because they were just extracted from this session's own
|
||||
/// transcript. The conversation already contains this information, so
|
||||
/// re-injecting it would be a pure echo.
|
||||
pub fn mark_memories_known(session_id: &str, ids: &[String], reason: &str) {
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
crate::memory_log::log_marked_known(session_id, ids, reason);
|
||||
insert_injected_ids(session_id, ids);
|
||||
}
|
||||
|
||||
fn insert_injected_ids(session_id: &str, ids: &[String]) {
|
||||
if let Ok(mut guard) = INJECTED_MEMORY_IDS.lock() {
|
||||
let outer = guard.get_or_insert_with(HashMap::new);
|
||||
let set = outer
|
||||
.entry(session_id.to_string())
|
||||
.or_insert_with(HashMap::new);
|
||||
set.retain(|_, at| injected_recently(at));
|
||||
let now = Instant::now();
|
||||
for id in ids {
|
||||
set.insert(id.clone(), now);
|
||||
}
|
||||
crate::logging::info(&format!(
|
||||
"[{}] Marked {} memory IDs as injected (total tracked: {})",
|
||||
session_id,
|
||||
ids.len(),
|
||||
set.len()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace injected memory tracking for a session with the provided IDs.
|
||||
/// Used when restoring persisted session state so the same logical session does
|
||||
/// not re-inject memories after reload/resume.
|
||||
pub fn sync_injected_memories(session_id: &str, ids: &[String]) {
|
||||
if let Ok(mut guard) = INJECTED_MEMORY_IDS.lock() {
|
||||
let outer = guard.get_or_insert_with(HashMap::new);
|
||||
if ids.is_empty() {
|
||||
outer.remove(session_id);
|
||||
return;
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
outer.insert(
|
||||
session_id.to_string(),
|
||||
ids.iter().cloned().map(|id| (id, now)).collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a memory ID has already been injected for a session.
|
||||
/// An injected ID "expires" after [`INJECTED_MEMORY_TTL_SECS`], at which point
|
||||
/// the memory may be surfaced again.
|
||||
pub fn is_memory_injected(session_id: &str, id: &str) -> bool {
|
||||
if let Ok(guard) = INJECTED_MEMORY_IDS.lock()
|
||||
&& let Some(outer) = guard.as_ref()
|
||||
&& let Some(set) = outer.get(session_id)
|
||||
&& let Some(at) = set.get(id)
|
||||
{
|
||||
return injected_recently(at);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if a memory ID has already been injected in ANY session.
|
||||
/// Used by the singleton memory agent which doesn't track per-session state.
|
||||
pub fn is_memory_injected_any(id: &str) -> bool {
|
||||
if let Ok(guard) = INJECTED_MEMORY_IDS.lock()
|
||||
&& let Some(outer) = guard.as_ref()
|
||||
{
|
||||
return outer
|
||||
.values()
|
||||
.any(|set| set.get(id).is_some_and(injected_recently));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Clear injected memory tracking for a session (call on session reset or topic change).
|
||||
pub fn clear_injected_memories(session_id: &str) {
|
||||
if let Ok(mut guard) = LAST_INJECTED_PROMPT_SIGNATURE.lock()
|
||||
&& let Some(map) = guard.as_mut()
|
||||
{
|
||||
map.remove(session_id);
|
||||
}
|
||||
if let Ok(mut guard) = LAST_INJECTED_MEMORY_SET.lock()
|
||||
&& let Some(map) = guard.as_mut()
|
||||
{
|
||||
map.remove(session_id);
|
||||
}
|
||||
|
||||
if let Ok(mut guard) = INJECTED_MEMORY_IDS.lock()
|
||||
&& let Some(outer) = guard.as_mut()
|
||||
&& let Some(set) = outer.remove(session_id)
|
||||
&& !set.is_empty()
|
||||
{
|
||||
crate::logging::info(&format!(
|
||||
"[{}] Clearing {} tracked injected memory IDs",
|
||||
session_id,
|
||||
set.len()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all injected memory tracking across all sessions.
|
||||
pub fn clear_all_injected_memories() {
|
||||
if let Ok(mut guard) = LAST_INJECTED_PROMPT_SIGNATURE.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
if let Ok(mut guard) = LAST_INJECTED_MEMORY_SET.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
if let Ok(mut guard) = INJECTED_MEMORY_IDS.lock() {
|
||||
if let Some(outer) = guard.as_ref() {
|
||||
let total: usize = outer.values().map(|s| s.len()).sum();
|
||||
if total > 0 {
|
||||
crate::logging::info(&format!(
|
||||
"Clearing {} tracked injected memory IDs across {} sessions",
|
||||
total,
|
||||
outer.len()
|
||||
));
|
||||
}
|
||||
}
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear any pending memory result for a session.
|
||||
pub fn clear_pending_memory(session_id: &str) {
|
||||
if let Ok(mut guard) = PENDING_MEMORY.lock()
|
||||
&& let Some(map) = guard.as_mut()
|
||||
{
|
||||
map.remove(session_id);
|
||||
}
|
||||
if let Ok(mut guard) = LAST_INJECTED_PROMPT_SIGNATURE.lock()
|
||||
&& let Some(map) = guard.as_mut()
|
||||
{
|
||||
map.remove(session_id);
|
||||
}
|
||||
if let Ok(mut guard) = LAST_INJECTED_MEMORY_SET.lock()
|
||||
&& let Some(map) = guard.as_mut()
|
||||
{
|
||||
map.remove(session_id);
|
||||
}
|
||||
clear_injected_memories(session_id);
|
||||
}
|
||||
|
||||
/// Clear all pending memory state across all sessions.
|
||||
pub fn clear_all_pending_memory() {
|
||||
if let Ok(mut guard) = PENDING_MEMORY.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
if let Ok(mut guard) = LAST_INJECTED_PROMPT_SIGNATURE.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
if let Ok(mut guard) = LAST_INJECTED_MEMORY_SET.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
clear_all_injected_memories();
|
||||
}
|
||||
|
||||
/// Check if there's a pending memory for a specific session.
|
||||
pub fn has_pending_memory(session_id: &str) -> bool {
|
||||
PENDING_MEMORY
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| g.as_ref().map(|m| m.contains_key(session_id)))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check if there's any pending memory across all sessions.
|
||||
pub fn has_any_pending_memory() -> bool {
|
||||
PENDING_MEMORY
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| g.as_ref().map(|m| !m.is_empty()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(super) fn begin_memory_check(session_id: &str) -> bool {
|
||||
if let Ok(mut guard) = MEMORY_CHECK_IN_PROGRESS.lock() {
|
||||
let set = guard.get_or_insert_with(HashSet::new);
|
||||
return set.insert(session_id.to_string());
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn finish_memory_check(session_id: &str) {
|
||||
if let Ok(mut guard) = MEMORY_CHECK_IN_PROGRESS.lock()
|
||||
&& let Some(set) = guard.as_mut()
|
||||
{
|
||||
set.remove(session_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn insert_pending_memory_for_test(session_id: &str, pending: PendingMemory) {
|
||||
let mut guard = PENDING_MEMORY.lock().expect("pending memory lock");
|
||||
let map = guard.get_or_insert_with(HashMap::new);
|
||||
map.insert(session_id.to_string(), pending);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn backdate_injected_memory_for_test(
|
||||
session_id: &str,
|
||||
id: &str,
|
||||
age: std::time::Duration,
|
||||
) {
|
||||
if let Ok(mut guard) = INJECTED_MEMORY_IDS.lock()
|
||||
&& let Some(outer) = guard.as_mut()
|
||||
&& let Some(set) = outer.get_mut(session_id)
|
||||
&& let Some(at) = set.get_mut(id)
|
||||
{
|
||||
*at = Instant::now() - age;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user