diff --git a/crates/vestige-core/src/advanced/compression.rs b/crates/vestige-core/src/advanced/compression.rs index 2ea2309..e99458b 100644 --- a/crates/vestige-core/src/advanced/compression.rs +++ b/crates/vestige-core/src/advanced/compression.rs @@ -274,7 +274,10 @@ impl MemoryCompressor { // Update stats self.stats.memories_compressed += memories.len(); self.stats.compressions_created += 1; - self.stats.bytes_saved += original_size - compressed.compressed_size; + // saturating_sub: short/repetitive memories can compress LARGER than the + // original (header + bullet facts > tiny inputs), which would underflow a + // usize subtraction (panic in debug, huge wrap in release). + self.stats.bytes_saved += original_size.saturating_sub(compressed.compressed_size); self.stats.operations += 1; self.update_average_stats(&compressed); diff --git a/crates/vestige-core/src/advanced/dreams.rs b/crates/vestige-core/src/advanced/dreams.rs index 5cf3492..81d9039 100644 --- a/crates/vestige-core/src/advanced/dreams.rs +++ b/crates/vestige-core/src/advanced/dreams.rs @@ -357,8 +357,13 @@ impl ConsolidationScheduler { /// Stage 1: Replay recent memories in sequence fn stage1_replay(&self, memories: &[DreamMemory]) -> MemoryReplay { - // Sort by creation time for sequential replay - let mut sorted: Vec<_> = memories.iter().take(MAX_REPLAY_MEMORIES).collect(); + // Select the MOST RECENT memories, then order them chronologically for + // sequential replay. The old code took the first N in arbitrary input + // order BEFORE sorting, so "recent" memories were dropped whenever the + // caller's slice was not already recency-ordered. + let mut sorted: Vec<_> = memories.iter().collect(); + sorted.sort_by_key(|m| std::cmp::Reverse(m.created_at)); + sorted.truncate(MAX_REPLAY_MEMORIES); sorted.sort_by_key(|m| m.created_at); let sequence: Vec = sorted.iter().map(|m| m.id.clone()).collect(); diff --git a/crates/vestige-core/src/advanced/prediction_error.rs b/crates/vestige-core/src/advanced/prediction_error.rs index 3693d77..17277db 100644 --- a/crates/vestige-core/src/advanced/prediction_error.rs +++ b/crates/vestige-core/src/advanced/prediction_error.rs @@ -493,6 +493,10 @@ impl PredictionErrorGate { ) -> GateDecision { match intent { EvaluationIntent::ForceCreate => { + // Count this evaluation: the fallback branches reach evaluate() + // (which counts), but these direct branches must count themselves + // or create/update/supersede rates can exceed 1.0. + self.stats.total_evaluations += 1; self.stats.creates += 1; GateDecision::Create { reason: CreateReason::ExplicitCreate, @@ -504,6 +508,7 @@ impl PredictionErrorGate { // Find the target candidate if let Some(c) = candidates.iter().find(|c| c.id == target_id) { let similarity = cosine_similarity(new_embedding, &c.embedding); + self.stats.total_evaluations += 1; self.stats.updates += 1; GateDecision::Update { target_id: target_id.clone(), @@ -522,6 +527,7 @@ impl PredictionErrorGate { } => { if let Some(c) = candidates.iter().find(|c| c.id == old_memory_id) { let similarity = cosine_similarity(new_embedding, &c.embedding); + self.stats.total_evaluations += 1; self.stats.supersedes += 1; GateDecision::Supersede { old_memory_id, diff --git a/crates/vestige-core/src/advanced/reconsolidation.rs b/crates/vestige-core/src/advanced/reconsolidation.rs index 933442c..93c99e6 100644 --- a/crates/vestige-core/src/advanced/reconsolidation.rs +++ b/crates/vestige-core/src/advanced/reconsolidation.rs @@ -551,6 +551,10 @@ impl ReconsolidationManager { /// /// Returns the reconsolidation result with all applied modifications. pub fn reconsolidate(&mut self, memory_id: &str) -> Option { + // remove() already guarantees idempotency: a second call finds no entry + // and returns None via `?`. The `reconsolidated` flag was never set to + // true anywhere, so the guard below was dead — but keep it as a correct, + // explicit belt-and-suspenders in case the entry is ever retained. let state = self.labile_memories.remove(memory_id)?; if state.reconsolidated { diff --git a/crates/vestige-core/src/advanced/speculative.rs b/crates/vestige-core/src/advanced/speculative.rs index 184705e..9de3df5 100644 --- a/crates/vestige-core/src/advanced/speculative.rs +++ b/crates/vestige-core/src/advanced/speculative.rs @@ -268,13 +268,23 @@ impl SpeculativeRetriever { } } - // Update file-memory associations + // Update file-memory associations. Dedupe and cap per file so the map + // cannot grow without bound in a long-running server (mirrors the + // MAX_PATTERN_HISTORY trim on access_sequence above). if let Some(file) = file_context && let Ok(mut map) = self.file_memory_map.write() { - map.entry(file.to_string()) - .or_insert_with(Vec::new) - .push(memory_id.to_string()); + let ids = map.entry(file.to_string()).or_insert_with(Vec::new); + let id = memory_id.to_string(); + if !ids.contains(&id) { + ids.push(id); + // keep only the most recent N associations per file + const MAX_FILE_MEMORIES: usize = 256; + if ids.len() > MAX_FILE_MEMORIES { + let excess = ids.len() - MAX_FILE_MEMORIES; + ids.drain(0..excess); + } + } } } diff --git a/crates/vestige-core/src/codebase/relationships.rs b/crates/vestige-core/src/codebase/relationships.rs index 01bc1aa..21ac580 100644 --- a/crates/vestige-core/src/codebase/relationships.rs +++ b/crates/vestige-core/src/codebase/relationships.rs @@ -176,6 +176,15 @@ impl RelationshipTracker { let id = relationship.id.clone(); + // Reject duplicate ids: a second insert with the same id but different + // files would overwrite the relationship while leaving the stale id in + // each previous file's index, corrupting get_related_files. + if self.relationships.contains_key(&id) { + return Err(RelationshipError::Invalid(format!( + "duplicate relationship id: {id}" + ))); + } + // Index by each file for file in &relationship.files { self.file_relationships diff --git a/crates/vestige-core/src/connectors/github.rs b/crates/vestige-core/src/connectors/github.rs index 4ed0b2d..3d6c23c 100644 --- a/crates/vestige-core/src/connectors/github.rs +++ b/crates/vestige-core/src/connectors/github.rs @@ -103,6 +103,18 @@ impl GithubConnector { "owner and repo are required".to_string(), )); } + // owner/repo are interpolated raw into request URLs; restrict them to + // GitHub's actual charset so `/`, `%`, `?`, `#`, traversal sequences, etc. + // cannot break out of the path or redirect the request. + let valid = |s: &str| { + s.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) + }; + if !valid(&config.owner) || !valid(&config.repo) { + return Err(ConnectorError::Config( + "owner/repo may only contain [A-Za-z0-9._-]".to_string(), + )); + } let client = reqwest::Client::builder() .user_agent(USER_AGENT) .build() diff --git a/crates/vestige-core/src/connectors/redmine.rs b/crates/vestige-core/src/connectors/redmine.rs index 321b29e..03c4012 100644 --- a/crates/vestige-core/src/connectors/redmine.rs +++ b/crates/vestige-core/src/connectors/redmine.rs @@ -491,7 +491,11 @@ impl Connector for RedmineConnector { // Enumerate all issue ids (open AND closed) for the reconcile pass. // status_id=* is mandatory here too, or closed issues read as deleted. let mut ids = Vec::new(); - let mut offset: u32 = 0; + // u64 offset (a u32 could wrap on a huge/compromised total_count, turning + // the loop infinite + allocating unboundedly). Also hard-cap pages. + let mut offset: u64 = 0; + const MAX_PAGES: u32 = 10_000; + let mut pages = 0u32; loop { let url = format!("{}/issues.json", self.config.root()); let resp = self @@ -518,8 +522,14 @@ impl Connector for RedmineConnector { for issue in &page.issues { ids.push(issue.id.to_string()); } - offset += page.issues.len() as u32; - if (offset as u64) >= page.total_count { + let new_offset = offset + page.issues.len() as u64; + // Defensive: a non-advancing page would loop forever. + if new_offset <= offset { + break; + } + offset = new_offset; + pages += 1; + if offset >= page.total_count || pages >= MAX_PAGES { break; } } diff --git a/crates/vestige-core/src/consolidation/phases.rs b/crates/vestige-core/src/consolidation/phases.rs index 7f74489..9fe41b7 100644 --- a/crates/vestige-core/src/consolidation/phases.rs +++ b/crates/vestige-core/src/consolidation/phases.rs @@ -626,13 +626,15 @@ impl DreamEngine { tag_b: &str, conn_type: CreativeConnectionType, ) -> String { + // char-boundary-safe: &content[..60] panics if a multi-byte UTF-8 char + // straddles byte 60. get(..60) returns None at a non-boundary => fall back. let a_summary = if a.content.len() > 60 { - &a.content[..60] + a.content.get(..60).unwrap_or(&a.content) } else { &a.content }; let b_summary = if b.content.len() > 60 { - &b.content[..60] + b.content.get(..60).unwrap_or(&b.content) } else { &b.content };