docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
This commit is contained in:
@@ -7,7 +7,6 @@ use anyhow::{Context, Result};
|
||||
|
||||
use super::MemoryStorage;
|
||||
|
||||
/// Build a `memory.tar.gz` archive with session logs and MEMORY.md files.
|
||||
pub fn build_memory_archive(storage: &MemoryStorage) -> Result<Vec<u8>> {
|
||||
use flate2::Compression;
|
||||
use flate2::write::GzEncoder;
|
||||
@@ -16,7 +15,6 @@ pub fn build_memory_archive(storage: &MemoryStorage) -> Result<Vec<u8>> {
|
||||
let enc = GzEncoder::new(buf, Compression::default());
|
||||
let mut ar = tar::Builder::new(enc);
|
||||
|
||||
// Session logs
|
||||
let sessions_dir = storage.workspace_dir().join("sessions");
|
||||
if sessions_dir.is_dir() {
|
||||
for entry in std::fs::read_dir(&sessions_dir)
|
||||
@@ -32,7 +30,6 @@ pub fn build_memory_archive(storage: &MemoryStorage) -> Result<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
// MEMORY.md files
|
||||
let global_mem = storage.global_memory_file();
|
||||
if global_mem.is_file() {
|
||||
ar.append_path_with_name(&global_mem, "global/MEMORY.md")
|
||||
|
||||
@@ -285,7 +285,7 @@ impl MemoryBackend for MemoryBackendImpl {
|
||||
Box::new(std::io::Error::other(e.to_string()))
|
||||
})?;
|
||||
|
||||
// ── Sync phase 1: reindex dirty files, collect chunks needing embeddings ──
|
||||
// Sync phase 1: reindex dirty files, collect chunks needing embeddings
|
||||
let mut reindex_chunks: Vec<(String, String)> = Vec::new();
|
||||
let mut needs_release = false;
|
||||
if let Some(ref watcher) = self.watcher
|
||||
@@ -313,7 +313,7 @@ impl MemoryBackend for MemoryBackendImpl {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Async phase: embed missing chunks (no &index borrow) ──
|
||||
// Async phase: embed missing chunks (no &index borrow)
|
||||
let provider = self.make_embedding_provider().await;
|
||||
if !reindex_chunks.is_empty()
|
||||
&& let Some(ref provider) = provider
|
||||
@@ -345,7 +345,7 @@ impl MemoryBackend for MemoryBackendImpl {
|
||||
index.release_claim();
|
||||
}
|
||||
|
||||
// ── Sync phase 2: FTS search ──
|
||||
// Sync phase 2: FTS search
|
||||
let mut search_config = self.search_config.clone();
|
||||
search_config.max_results = max_results;
|
||||
search_config.min_score = min_score as f32;
|
||||
@@ -369,7 +369,7 @@ impl MemoryBackend for MemoryBackendImpl {
|
||||
|
||||
let vec_available = index.vec_available() && provider.is_some();
|
||||
|
||||
// ── Async phase: embed query for vector search (no &index borrow) ──
|
||||
// Async phase: embed query for vector search (no &index borrow)
|
||||
let query_embedding = if vec_available {
|
||||
if let Some(ref provider) = provider {
|
||||
match provider.embed_batch(&[query]).await {
|
||||
@@ -389,7 +389,7 @@ impl MemoryBackend for MemoryBackendImpl {
|
||||
None
|
||||
};
|
||||
|
||||
// ── Sync phase 3: vector search + scoring + merge (borrows &index) ──
|
||||
// Sync phase 3: vector search + scoring + merge (borrows &index)
|
||||
let results = super::search::hybrid_search_merge(
|
||||
&index,
|
||||
fts_results,
|
||||
@@ -607,7 +607,7 @@ mod factory_tests {
|
||||
let stored = backend.search_config_for_test();
|
||||
|
||||
// None of these are overridden by the caller in search() — they must
|
||||
// survive the factory path unchanged.
|
||||
// survive the factory path `unchanged`.
|
||||
assert_eq!(stored.max_results, 7);
|
||||
assert!(stored.mmr.enabled, "MMR enabled must be stored");
|
||||
assert!(
|
||||
@@ -688,7 +688,8 @@ mod factory_tests {
|
||||
};
|
||||
// watcher.is_some() reflects whether startup succeeded.
|
||||
// (On environments without inotify/FSEvents this may be None; skip rather than fail.)
|
||||
let _ = params_with_watcher.watcher.is_some(); // just verify it compiles
|
||||
// just verify it compiles
|
||||
let _ = params_with_watcher.watcher.is_some();
|
||||
|
||||
// Failure path: non-existent directory → watcher must return None.
|
||||
let missing = tmp.path().join("does_not_exist");
|
||||
@@ -808,7 +809,8 @@ mod factory_tests {
|
||||
let params = MemoryBackendParams {
|
||||
embed_config: Some(MemoryEmbeddingConfig::default()),
|
||||
embed_base_url: "http://localhost".to_string(),
|
||||
embed_api_key: None, // no key → provider cannot be created
|
||||
// no key → provider cannot be created
|
||||
embed_api_key: None,
|
||||
..make_params_fts_only("test-embed-no-key")
|
||||
};
|
||||
let backend = MemoryBackendImpl::from_session_params(storage, ¶ms);
|
||||
@@ -882,7 +884,7 @@ mod factory_tests {
|
||||
"global memory dir must not exist before initialization"
|
||||
);
|
||||
|
||||
// --- Wrong ordering (watcher before init) ---
|
||||
// Wrong ordering (watcher before init)
|
||||
// The watcher returns None because the directory does not exist.
|
||||
let watcher_before_init = crate::watcher::MemoryFileWatcher::start(&global);
|
||||
assert!(
|
||||
@@ -890,7 +892,7 @@ mod factory_tests {
|
||||
"watcher must fail (None) when directory does not exist yet"
|
||||
);
|
||||
|
||||
// --- Correct ordering (init, then watcher) ---
|
||||
// Correct ordering (init, then watcher)
|
||||
// After ensure_initialized the directories and MEMORY.md templates exist.
|
||||
storage.ensure_initialized().unwrap();
|
||||
|
||||
|
||||
@@ -95,7 +95,8 @@ fn split_by_headers<'a>(lines: &[&'a str]) -> Vec<Section<'a>> {
|
||||
let mut sections: Vec<Section<'a>> = Vec::new();
|
||||
let mut current_lines: Vec<&'a str> = Vec::new();
|
||||
let mut current_start = 0;
|
||||
let mut header_stack: Vec<(usize, String)> = Vec::new(); // (level, text)
|
||||
// (level, text)
|
||||
let mut header_stack: Vec<(usize, String)> = Vec::new();
|
||||
|
||||
for (i, &line) in lines.iter().enumerate() {
|
||||
if let Some(level) = header_level(line) {
|
||||
@@ -258,7 +259,8 @@ mod tests {
|
||||
let h1 = chunk_hash("hello world");
|
||||
let h2 = chunk_hash("hello world");
|
||||
assert_eq!(h1, h2);
|
||||
assert_eq!(h1.len(), 64); // blake3 hex = 64 chars
|
||||
// blake3 hex = 64 chars
|
||||
assert_eq!(h1.len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -340,10 +342,12 @@ mod tests {
|
||||
assert_eq!(header_level("# Title"), Some(1));
|
||||
assert_eq!(header_level("## Section"), Some(2));
|
||||
assert_eq!(header_level("### Subsection"), Some(3));
|
||||
assert_eq!(header_level("#hashtag"), None); // no space after #
|
||||
// no space after #
|
||||
assert_eq!(header_level("#hashtag"), None);
|
||||
assert_eq!(header_level("not a header"), None);
|
||||
assert_eq!(header_level(""), None);
|
||||
assert_eq!(header_level("##"), Some(2)); // header with no text
|
||||
// header with no text
|
||||
assert_eq!(header_level("##"), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -77,9 +77,7 @@ pub fn check_dream_gates(
|
||||
DreamGate::Open { sessions }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dream prompt, response processing, and execution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use super::text_utils::{has_markdown_headers, is_no_reply};
|
||||
|
||||
@@ -318,7 +316,7 @@ pub fn process_dream_response(response: &str) -> Option<String> {
|
||||
/// Minimum age (in seconds) a session file must have before cleanup will
|
||||
/// delete it. Protects against removing files that a concurrent session
|
||||
/// may still be actively appending to.
|
||||
const CLEANUP_RECENCY_GUARD_SECS: u64 = 300; // 5 minutes
|
||||
const CLEANUP_RECENCY_GUARD_SECS: u64 = 300;
|
||||
|
||||
/// Delete session log files whose stems were processed during dream.
|
||||
///
|
||||
@@ -701,9 +699,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// build_dream_user_message tests
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
fn write_session_content(dir: &Path, name: &str, content: &str) {
|
||||
fs::create_dir_all(dir).unwrap();
|
||||
@@ -775,9 +771,7 @@ mod tests {
|
||||
assert_eq!(msg.processed_stems, vec!["exists"]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// process_dream_response tests
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn process_empty_response_returns_none() {
|
||||
@@ -835,9 +829,7 @@ mod tests {
|
||||
assert_eq!(result, input);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// execute_dream tests
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
use super::super::storage::MemoryStorage;
|
||||
use std::path::PathBuf;
|
||||
@@ -979,9 +971,7 @@ mod tests {
|
||||
assert_eq!(memory.trim(), response);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// DREAM_SYSTEM_PROMPT sanity checks
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn dream_system_prompt_has_required_content() {
|
||||
@@ -1024,9 +1014,7 @@ mod tests {
|
||||
assert_eq!(normalized, input);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Session cleanup tests
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cleanup_deletes_processed_sessions_on_completed() {
|
||||
@@ -1240,7 +1228,8 @@ mod tests {
|
||||
|
||||
// Create 5 session files. The first 2 will fill past the cap;
|
||||
// sessions 3-5 should survive cleanup.
|
||||
let half_cap = MAX_DREAM_INPUT_CHARS / 2 + 500; // slightly over half
|
||||
// slightly over half
|
||||
let half_cap = MAX_DREAM_INPUT_CHARS / 2 + 500;
|
||||
write_old_session_content(&sessions, "aaa-first", &"a".repeat(half_cap));
|
||||
write_old_session_content(&sessions, "bbb-second", &"b".repeat(half_cap));
|
||||
write_old_session_content(&sessions, "ccc-third", "small content 3");
|
||||
@@ -1316,9 +1305,7 @@ mod tests {
|
||||
assert!(!sessions.join("exists.md").exists());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// is_scaffold_template tests
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn scaffold_detects_old_workspace_template() {
|
||||
@@ -1398,9 +1385,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// build_dream_user_message with existing memory tests
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn build_message_prepends_existing_memory() {
|
||||
|
||||
@@ -199,7 +199,7 @@ mod tests {
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// --- DreamLock tests ---
|
||||
// DreamLock tests
|
||||
|
||||
#[test]
|
||||
fn no_file_means_no_prior_consolidation() {
|
||||
@@ -240,7 +240,7 @@ mod tests {
|
||||
let lock = DreamLock::new(dir.path());
|
||||
|
||||
let old_time = SystemTime::now() - Duration::from_secs(7200);
|
||||
fs::write(&lock.path, "4000000000").unwrap(); // dead PID
|
||||
fs::write(&lock.path, "4000000000").unwrap();
|
||||
filetime::set_file_mtime(&lock.path, FileTime::from_system_time(old_time)).unwrap();
|
||||
|
||||
let prior = lock
|
||||
@@ -358,7 +358,8 @@ mod tests {
|
||||
fn rollback_on_nonexistent_file_is_noop() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let lock = DreamLock::new(dir.path());
|
||||
lock.rollback(None).unwrap(); // no file to delete, should be fine
|
||||
// no file to delete, should be fine
|
||||
lock.rollback(None).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -385,7 +386,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// --- sessions_since tests ---
|
||||
// sessions_since tests
|
||||
|
||||
fn write_session(dir: &Path, name: &str, age_secs: u64) {
|
||||
fs::create_dir_all(dir).unwrap();
|
||||
@@ -401,8 +402,10 @@ mod tests {
|
||||
let sessions = dir.path().join("sessions");
|
||||
let cutoff = SystemTime::now() - Duration::from_secs(3600);
|
||||
|
||||
write_session(&sessions, "2026-01-01-proj-aaa11111", 1800); // 30min ago, after cutoff
|
||||
write_session(&sessions, "2025-12-31-proj-bbb22222", 7200); // 2h ago, before cutoff
|
||||
// 30min ago, after cutoff
|
||||
write_session(&sessions, "2026-01-01-proj-aaa11111", 1800);
|
||||
// 2h ago, before cutoff
|
||||
write_session(&sessions, "2025-12-31-proj-bbb22222", 7200);
|
||||
|
||||
let result = sessions_since(&sessions, cutoff, None).unwrap();
|
||||
assert_eq!(result, vec!["2026-01-01-proj-aaa11111"]);
|
||||
|
||||
@@ -1,39 +1,28 @@
|
||||
//! Embedding provider abstraction for memory vector search.
|
||||
//!
|
||||
//! Defines the `EmbeddingProvider` trait and an API-based implementation
|
||||
//! that calls an OpenAI-compatible embeddings API endpoint.
|
||||
//!
|
||||
//! Embeddings are cached in the sqlite-vec `chunks_vec` table — the vec0
|
||||
//! virtual table IS the cache. No separate cache needed.
|
||||
//! There is no separate embedding cache: the sqlite-vec `chunks_vec` vec0
|
||||
//! virtual table is the cache.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Maximum retry attempts for transient API errors (429, 5xx).
|
||||
const MAX_RETRIES: usize = 3;
|
||||
/// Initial backoff delay in milliseconds (doubles on each retry: 1s, 2s, 4s).
|
||||
/// Doubles on each retry, so the waits are 1s, 2s, 4s.
|
||||
const INITIAL_BACKOFF_MS: u64 = 1000;
|
||||
|
||||
/// Trait for generating text embeddings.
|
||||
///
|
||||
/// Implementations must be `Send + Sync` so they can be used in `Send`
|
||||
/// futures (e.g., inside `tokio::spawn`). The `embed_batch` method is
|
||||
/// async to support API-based providers.
|
||||
#[async_trait]
|
||||
pub trait EmbeddingProvider: Send + Sync {
|
||||
/// Embed a batch of texts, returning one vector per input text.
|
||||
/// Returns one vector per input text, in input order.
|
||||
async fn embed_batch(
|
||||
&self,
|
||||
texts: &[&str],
|
||||
) -> Result<Vec<Vec<f32>>, Box<dyn std::error::Error>>;
|
||||
|
||||
/// The model name used for embeddings.
|
||||
fn model_name(&self) -> &str;
|
||||
|
||||
/// The dimensionality of the embedding vectors.
|
||||
fn dimensions(&self) -> usize;
|
||||
}
|
||||
|
||||
/// API-based embedding provider using an OpenAI-compatible embeddings endpoint.
|
||||
/// Talks to an OpenAI-compatible `/embeddings` endpoint.
|
||||
pub struct ApiEmbeddingProvider {
|
||||
api_base: String,
|
||||
model: String,
|
||||
@@ -113,7 +102,7 @@ impl EmbeddingProvider for ApiEmbeddingProvider {
|
||||
|
||||
let mut all_embeddings = Vec::with_capacity(texts.len());
|
||||
|
||||
// Process in batches to respect API payload limits
|
||||
// Split into batches to stay under the API payload limit.
|
||||
for batch in texts.chunks(self.max_batch_size) {
|
||||
let input: Vec<&str> = batch.to_vec();
|
||||
let body_json = serde_json::json!({
|
||||
@@ -122,7 +111,6 @@ impl EmbeddingProvider for ApiEmbeddingProvider {
|
||||
"dimensions": self.dimensions,
|
||||
});
|
||||
|
||||
// Retry with exponential backoff on transient errors (429, 5xx)
|
||||
let mut last_err = String::new();
|
||||
let mut success = false;
|
||||
for attempt in 0..MAX_RETRIES {
|
||||
@@ -177,7 +165,6 @@ impl EmbeddingProvider for ApiEmbeddingProvider {
|
||||
break;
|
||||
}
|
||||
|
||||
// Retry on 429 (rate limit) or 5xx (server error)
|
||||
if status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
|
||||
last_err = format!(
|
||||
"HTTP {status}: {}",
|
||||
@@ -186,7 +173,6 @@ impl EmbeddingProvider for ApiEmbeddingProvider {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Non-retryable error (4xx other than 429)
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("embedding API error {status}: {body}").into());
|
||||
}
|
||||
@@ -211,8 +197,7 @@ impl EmbeddingProvider for ApiEmbeddingProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// A mock embedding provider for testing that returns deterministic vectors.
|
||||
/// Uses blake3 hash of text → float values for reproducible results.
|
||||
/// Test double whose vectors are a deterministic function of the input text.
|
||||
pub struct MockEmbeddingProvider {
|
||||
pub dimensions: usize,
|
||||
}
|
||||
|
||||
@@ -213,9 +213,7 @@ impl MemoryIndex {
|
||||
&self.db
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Indexing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Reindex a single memory file. Compares chunk hashes to avoid redundant work.
|
||||
///
|
||||
@@ -350,9 +348,7 @@ impl MemoryIndex {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Search
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// FTS5 keyword search. Returns results ranked by BM25 score.
|
||||
///
|
||||
@@ -443,7 +439,6 @@ impl MemoryIndex {
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Get a chunk by its ID.
|
||||
pub fn get_chunk(&self, id: &str) -> Result<Option<ChunkRecord>, rusqlite::Error> {
|
||||
let mut stmt = self.db.prepare(
|
||||
"SELECT rowid, id, path, start_line, end_line, text, hash, source, access_count, \
|
||||
@@ -511,9 +506,7 @@ impl MemoryIndex {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Vector operations (no-op if !vec_available)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Return chunks that don't have embeddings yet.
|
||||
pub fn chunks_without_embeddings(&self) -> Result<Vec<(String, String)>, rusqlite::Error> {
|
||||
@@ -575,9 +568,7 @@ impl MemoryIndex {
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reindex claim coordination (multi-agent)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Try to claim exclusive reindex rights using the `meta` table.
|
||||
///
|
||||
@@ -615,9 +606,7 @@ impl MemoryIndex {
|
||||
.execute("UPDATE meta SET value = '' WHERE key = 'reindex_claim'", []);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Delete all indexed chunks for a given file path.
|
||||
///
|
||||
@@ -628,7 +617,7 @@ impl MemoryIndex {
|
||||
/// a single transaction so the index stays consistent even on partial failure.
|
||||
///
|
||||
/// Returns the number of chunks removed, which is 0 when the path was not
|
||||
/// previously indexed (idempotent).
|
||||
/// indexed (idempotent).
|
||||
pub fn delete_path(&mut self, path: &Path) -> Result<usize, rusqlite::Error> {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
let existing = self.get_chunks_for_path(&path_str)?;
|
||||
@@ -958,17 +947,14 @@ mod tests {
|
||||
assert!(!version.is_empty(), "sqlite-vec should report a version");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Append-then-reindex regression test
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Simulates the `/memory append` → immediate-reindex flow.
|
||||
///
|
||||
/// Previously the TUI's `AppendMemory` action wrote the file and returned
|
||||
/// without reindexing. Appended content was only searchable after a future
|
||||
/// watcher-driven sync or the next session startup. The fix reindexes
|
||||
/// immediately after append; this test ensures that regression cannot silently
|
||||
/// re-appear.
|
||||
/// The TUI's `AppendMemory` action must reindex right after writing the
|
||||
/// file. When it does not, appended content stays unsearchable until a
|
||||
/// watcher-driven sync or the next session startup. This test ensures that
|
||||
/// regression cannot silently re-appear.
|
||||
#[test]
|
||||
fn test_append_then_reindex_is_immediately_searchable() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -1000,9 +986,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// delete_path tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Deleting an indexed file removes all its chunks and they are no longer searchable.
|
||||
#[test]
|
||||
@@ -1090,9 +1074,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// access tracking + admin helper tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// record_access increments access_count and sets last_accessed.
|
||||
#[test]
|
||||
@@ -1171,9 +1153,7 @@ mod tests {
|
||||
assert!(paths.is_empty(), "fresh index has no indexed paths");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// reindex maintenance path regression tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Regression test for the reindex maintenance flow:
|
||||
///
|
||||
@@ -1207,7 +1187,8 @@ mod tests {
|
||||
std::fs::remove_file(&file).unwrap();
|
||||
|
||||
// Simulate `kigi memory reindex` Phase 1: compare indexed vs current.
|
||||
let current: std::collections::BTreeSet<String> = vec![].into_iter().collect(); // empty = no files
|
||||
// empty = no files
|
||||
let current: std::collections::BTreeSet<String> = vec![].into_iter().collect();
|
||||
let indexed = idx.all_indexed_paths().unwrap();
|
||||
for path in &indexed {
|
||||
if !current.contains(path) {
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
//! Memory system for cross-session knowledge persistence.
|
||||
//!
|
||||
//! This crate provides a markdown-based memory storage layer that allows
|
||||
//! Kigi to persist important information across sessions. Memory files are
|
||||
//! stored under `~/.kigi/memory/` with workspace-scoped subdirectories
|
||||
//! keyed by a blake3 hash of the workspace path.
|
||||
//! Markdown-based memory storage that persists knowledge across sessions.
|
||||
//!
|
||||
//! ## Data Layout
|
||||
//!
|
||||
@@ -18,9 +13,8 @@
|
||||
//!
|
||||
//! ## Feature Flag
|
||||
//!
|
||||
//! Memory is gated behind `--experimental-memory` CLI flag or
|
||||
//! `KIGI_MEMORY=1` environment variable. When disabled, this crate
|
||||
//! is not initialized by the host.
|
||||
//! Memory is gated behind the `--experimental-memory` CLI flag or
|
||||
//! `KIGI_MEMORY=1`; when disabled the host never initializes this crate.
|
||||
|
||||
pub mod archive;
|
||||
pub mod backend;
|
||||
@@ -41,13 +35,12 @@ pub use backend::{MemoryBackendImpl, MemoryBackendParams};
|
||||
pub use index::{MemoryIndex, init_sqlite_vec};
|
||||
pub use storage::{MemoryScope, MemoryStorage};
|
||||
|
||||
/// Embed all chunks that don't have embeddings yet.
|
||||
/// Embeds every chunk that has no embedding yet, returning how many succeeded.
|
||||
///
|
||||
/// Queries the index for unembedded chunks, batches them through the
|
||||
/// embedding provider, and upserts the results. Logs progress.
|
||||
///
|
||||
/// This is the async glue between the sync `MemoryIndex` and the async
|
||||
/// `EmbeddingProvider`. Call after reindex, flush writes, or session-end writes.
|
||||
/// The async glue between the sync `MemoryIndex` and the async
|
||||
/// `EmbeddingProvider`. Call after reindex, flush writes, or session-end
|
||||
/// writes. Failures are logged and skipped rather than propagated, so a dead
|
||||
/// embedding endpoint degrades search instead of breaking the session.
|
||||
pub async fn embed_missing_chunks(
|
||||
index: &MemoryIndex,
|
||||
provider: &dyn embedding::EmbeddingProvider,
|
||||
@@ -68,7 +61,7 @@ pub async fn embed_missing_chunks(
|
||||
let total = chunks.len();
|
||||
let mut embedded = 0;
|
||||
|
||||
// Batch in groups of 32 (provider's typical max batch size)
|
||||
// 32 matches the typical provider max batch size.
|
||||
for batch in chunks.chunks(32) {
|
||||
let texts: Vec<&str> = batch.iter().map(|(_, text)| text.as_str()).collect();
|
||||
match provider.embed_batch(&texts).await {
|
||||
|
||||
@@ -112,7 +112,7 @@ pub fn mmr_rerank(results: &mut Vec<SearchResult>, relevance: &[f64], config: &M
|
||||
.map(|i| std::mem::replace(&mut results[i], placeholder_result()))
|
||||
.collect();
|
||||
*results = reordered;
|
||||
// `results` is now reordered, so the caller's `relevance` slice is stale
|
||||
// `results` is reordered, so the caller's `relevance` slice is stale
|
||||
// and must not be read again.
|
||||
}
|
||||
|
||||
@@ -297,9 +297,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Jaccard similarity unit tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_jaccard_identical() {
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
//! SQL schema constants for the memory index.
|
||||
//!
|
||||
//! The index uses three tables:
|
||||
//! - `meta` — key-value metadata (embedding dimensions, schema version)
|
||||
//! - `chunks` — indexed text chunks with blake3 content hashes
|
||||
//! - `chunks_fts` — contentless FTS5 virtual table for BM25 keyword search
|
||||
//!
|
||||
//! When sqlite-vec is available, a fourth table is created:
|
||||
//! - `chunks_vec` — vec0 virtual table for KNN vector search
|
||||
//! SQL schema for the memory index: `meta` key-value settings, `chunks` text
|
||||
//! with blake3 content hashes, a contentless FTS5 `chunks_fts` for BM25
|
||||
//! keyword search, and — only when sqlite-vec loaded — a vec0 `chunks_vec`
|
||||
//! for KNN vector search.
|
||||
|
||||
/// Schema version. Bump when making breaking schema changes that require
|
||||
/// dropping and recreating tables.
|
||||
/// Bump on a breaking change that requires dropping and recreating tables.
|
||||
pub const SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// Generate the SQL schema for the memory index.
|
||||
/// `dimensions` sizes the `chunks_vec` embedding column; when `vec_available`
|
||||
/// is false that table is omitted entirely.
|
||||
///
|
||||
/// `dimensions` controls the embedding vector size for `chunks_vec`.
|
||||
/// If `vec_available` is false, the `chunks_vec` table is not created.
|
||||
///
|
||||
/// Connection pragmas (busy_timeout, journal_mode) are applied on the open
|
||||
/// path (`kigi_sqlite_journal::JournalMode::open`) — the journal mode depends
|
||||
/// on the database's filesystem.
|
||||
/// Connection pragmas (busy_timeout, journal_mode) deliberately live on the
|
||||
/// open path (`kigi_sqlite_journal::JournalMode::open`) instead of here,
|
||||
/// because the journal mode depends on the database's filesystem.
|
||||
pub fn schema_sql(dimensions: usize, vec_available: bool) -> String {
|
||||
let mut sql = r#"
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
@@ -62,10 +54,8 @@ INSERT OR IGNORE INTO meta(key, value) VALUES ('reindex_claim', '');
|
||||
sql
|
||||
}
|
||||
|
||||
/// SQL to insert or update an embedding dimension record in the meta table.
|
||||
pub const UPSERT_META_SQL: &str = "INSERT OR REPLACE INTO meta(key, value) VALUES (?1, ?2)";
|
||||
|
||||
/// SQL to query a meta value by key.
|
||||
pub const GET_META_SQL: &str = "SELECT value FROM meta WHERE key = ?1";
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -460,7 +460,7 @@ mod tests {
|
||||
|
||||
let config = MemorySearchConfig {
|
||||
max_results: 3,
|
||||
min_score: 0.0, // accept all
|
||||
min_score: 0.0,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -562,9 +562,7 @@ mod tests {
|
||||
assert!(results[0].score > 0.0, "score should be positive");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Temporal decay unit tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_is_evergreen_source() {
|
||||
@@ -589,8 +587,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_evergreen_sources_never_decay() {
|
||||
let now = 86400 * 365; // 1 year
|
||||
let created = 0; // created at epoch
|
||||
let now = 86400 * 365;
|
||||
// created at epoch
|
||||
let created = 0;
|
||||
let half_life = Some(30.0);
|
||||
|
||||
assert_eq!(
|
||||
@@ -606,7 +605,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_session_chunks_decay_with_half_life() {
|
||||
let half_life = Some(30.0);
|
||||
let now = 86400 * 30; // 30 days after epoch
|
||||
// 30 days after epoch
|
||||
let now = 86400 * 30;
|
||||
let created = 0;
|
||||
|
||||
let multiplier = temporal_decay_multiplier("session", created, now, half_life);
|
||||
@@ -620,7 +620,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_decay_at_two_half_lives() {
|
||||
let half_life = Some(30.0);
|
||||
let now = 86400 * 60; // 60 days
|
||||
let now = 86400 * 60;
|
||||
let created = 0;
|
||||
|
||||
let multiplier = temporal_decay_multiplier("session", created, now, half_life);
|
||||
@@ -634,7 +634,8 @@ mod tests {
|
||||
fn test_fresh_session_chunk_no_decay() {
|
||||
let half_life = Some(30.0);
|
||||
let now = 1_000_000;
|
||||
let created = now; // just created
|
||||
// just created
|
||||
let created = now;
|
||||
|
||||
let multiplier = temporal_decay_multiplier("session", created, now, half_life);
|
||||
assert!(
|
||||
@@ -658,7 +659,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_future_created_at_no_negative_age() {
|
||||
let now = 1_000_000;
|
||||
let created = now + 86400; // 1 day in the future (clock skew)
|
||||
// 1 day in the future (clock skew)
|
||||
let created = now + 86400;
|
||||
let half_life = Some(30.0);
|
||||
|
||||
let multiplier = temporal_decay_multiplier("session", created, now, half_life);
|
||||
@@ -726,9 +728,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PR-8: access-frequency boost tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// A chunk with access_count > 0 scores higher than an identical chunk
|
||||
/// with access_count = 0, all else equal.
|
||||
@@ -871,9 +871,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PR: scoring normalization fix tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// FTS-only results (no vector search) should score well above a
|
||||
/// reasonable min_score threshold (e.g., 0.3). Before the fix,
|
||||
@@ -1067,9 +1065,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Empty-template filter + score clamp tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// The auto-generated global MEMORY.md stub, written verbatim by
|
||||
/// `MemoryStorage::ensure_initialized` (storage.rs), including the trailing
|
||||
@@ -1253,7 +1249,8 @@ mod tests {
|
||||
);
|
||||
|
||||
let config = MemorySearchConfig {
|
||||
min_score: 0.0, // accept all by score, so only the filter can exclude
|
||||
// accept all by score, so only the filter can exclude
|
||||
min_score: 0.0,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
||||
@@ -408,8 +408,8 @@ impl MemoryStorage {
|
||||
///
|
||||
/// Deletes MEMORY.md, sessions/, index.sqlite, and any other workspace files.
|
||||
/// The directory will be recreated on next session start via `ensure_initialized()`.
|
||||
/// Returns `Ok(true)` if the directory existed and was removed, `Ok(false)` if
|
||||
/// it didn't exist.
|
||||
/// Returns `Ok(true)` if the directory existed and this call removed it,
|
||||
/// `Ok(false)` if it didn't exist.
|
||||
pub fn clear_workspace(&self) -> std::io::Result<bool> {
|
||||
match std::fs::remove_dir_all(&self.workspace_dir) {
|
||||
Ok(()) => {
|
||||
@@ -426,8 +426,8 @@ impl MemoryStorage {
|
||||
/// Does not remove the global memory directory itself (other workspaces may
|
||||
/// have subdirectories there). The file will be recreated on next session
|
||||
/// start via `ensure_initialized()`.
|
||||
/// Returns `Ok(true)` if the file existed and was removed, `Ok(false)` if
|
||||
/// it didn't exist.
|
||||
/// Returns `Ok(true)` if the file existed and this call removed it,
|
||||
/// `Ok(false)` if it didn't exist.
|
||||
pub fn clear_global(&self) -> std::io::Result<bool> {
|
||||
let path = self.global_memory_file();
|
||||
match std::fs::remove_file(&path) {
|
||||
@@ -1035,9 +1035,7 @@ mod tests {
|
||||
assert!(content.contains("## Second"), "appended content present");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// normalize_memory_content tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_normalize_single_line() {
|
||||
@@ -1114,9 +1112,7 @@ mod tests {
|
||||
assert_eq!(result, "## First line\n\nSecond line\nThird line");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// append_to_memory tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_append_to_memory_workspace_empty_file() {
|
||||
@@ -1217,9 +1213,7 @@ mod tests {
|
||||
assert!(!workspace_dir.join("MEMORY.md").exists());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// clear_workspace / clear_global tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_clear_workspace_removes_directory() {
|
||||
@@ -1304,9 +1298,7 @@ mod tests {
|
||||
assert!(after.contains("Project Memory"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// normalize_remote_url tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_normalize_ssh_url() {
|
||||
@@ -1380,9 +1372,7 @@ mod tests {
|
||||
assert_eq!(https, ssh_scheme);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// extract_repo_identity tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_extract_repo_identity_from_current_repo() {
|
||||
@@ -1438,9 +1428,7 @@ mod tests {
|
||||
assert_eq!(parts[0].len(), 8, "hash suffix should be 8 hex chars");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// is_ephemeral_cwd tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_ephemeral_linux_tmp() {
|
||||
@@ -1575,9 +1563,7 @@ mod tests {
|
||||
assert!(!storage.is_ephemeral());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// gc tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn set_dir_mtime_days_ago(dir: &Path, days: u64) {
|
||||
let t =
|
||||
@@ -1788,9 +1774,7 @@ mod tests {
|
||||
assert!(!other.exists());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// is_empty_workspace / is_older_than unit tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_is_empty_workspace_no_sessions_dir() {
|
||||
|
||||
@@ -2,24 +2,16 @@
|
||||
//! (`session::helpers::memory_flush`) and dream (`session::memory::dream`)
|
||||
//! response-processing paths.
|
||||
//!
|
||||
//! These live here, in the memory subsystem, so `dream` no longer reaches
|
||||
//! *up* into `session::helpers::memory_flush` for them — which removes the
|
||||
//! `dream` <-> `memory_flush` module dependency cycle and is a prerequisite
|
||||
//! for extracting the memory subsystem into its own crate.
|
||||
//! They live down here rather than in `memory_flush` so that `dream` need not
|
||||
//! reach *up* into it, which would form a `dream` <-> `memory_flush` cycle.
|
||||
|
||||
/// Check if text contains at least one markdown header (`#` or `##`).
|
||||
///
|
||||
/// Used by both flush and dream response processing to ensure the model
|
||||
/// produced structured output.
|
||||
pub fn has_markdown_headers(text: &str) -> bool {
|
||||
text.contains("## ") || text.contains("# ")
|
||||
}
|
||||
|
||||
/// Check if the response matches the NO_REPLY convention.
|
||||
///
|
||||
/// Strips all non-alphanumeric characters, lowercases, and checks if the
|
||||
/// remainder is exactly `"noreply"`. This handles common separator variants:
|
||||
/// `"no reply"`, `"no_reply"`, `"no-reply"`, `"NO REPLY"`, etc.
|
||||
/// Matches the NO_REPLY convention across separator variants — `"no reply"`,
|
||||
/// `"no_reply"`, `"no-reply"`, `"NO REPLY"` — by comparing only the
|
||||
/// lowercased alphanumerics.
|
||||
pub fn is_no_reply(text: &str) -> bool {
|
||||
let normalized: String = text
|
||||
.to_lowercase()
|
||||
|
||||
Reference in New Issue
Block a user