From d18d3d281908332a304294f2de61f3e0254a3a5e Mon Sep 17 00:00:00 2001 From: Alex Holmberg Date: Thu, 10 Sep 2026 14:27:02 +0200 Subject: [PATCH 1/2] fix: index Cursor transcript lookups Read composer and bubble prefixes through the key index so each conversation no longer scans unrelated rows. Add instruction-budget regressions and run the Cursor tests on macOS, Windows and Linux. --- .github/workflows/ci.yml | 17 +++++ crates/memscribe-adapters/Cargo.toml | 1 + crates/memscribe-adapters/src/cursor.rs | 96 ++++++++++++++++++++----- 3 files changed, 95 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 314eb58..950d80e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,23 @@ env: CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse jobs: + cursor-regression: + name: Cursor capture (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + - name: Install pinned toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.96.0 + - uses: Swatinem/rust-cache@v2 + - name: Verify Cursor capture and indexed lookups + run: cargo test -p memscribe-adapters --no-default-features --features cursor --locked + # 1. The deterministic test suite: unit + golden + conformance + property. test: name: test (workspace, all-features) diff --git a/crates/memscribe-adapters/Cargo.toml b/crates/memscribe-adapters/Cargo.toml index 5e18703..d5dcf64 100644 --- a/crates/memscribe-adapters/Cargo.toml +++ b/crates/memscribe-adapters/Cargo.toml @@ -51,6 +51,7 @@ zstd = { workspace = true, optional = true } rusqlite = { workspace = true, optional = true } [dev-dependencies] +rusqlite = { workspace = true, features = ["hooks"] } proptest = { workspace = true } serde_json = { workspace = true } tempfile = { workspace = true } diff --git a/crates/memscribe-adapters/src/cursor.rs b/crates/memscribe-adapters/src/cursor.rs index 001c35f..7c8205d 100644 --- a/crates/memscribe-adapters/src/cursor.rs +++ b/crates/memscribe-adapters/src/cursor.rs @@ -432,8 +432,9 @@ fn col_bytes(row: &rusqlite::Row<'_>, idx: usize) -> rusqlite::Result> { /// Load all `composerData:*` rows. Errors if `cursorDiskKV` is missing. fn load_composers(conn: &rusqlite::Connection) -> rusqlite::Result> { - let mut stmt = - conn.prepare("SELECT value FROM cursorDiskKV WHERE key LIKE 'composerData:%'")?; + let mut stmt = conn.prepare( + "SELECT value FROM cursorDiskKV WHERE key >= 'composerData:' AND key < 'composerData;'", + )?; let rows = stmt.query_map([], |row| { // Values are stored as TEXT (JSON) in current builds, BLOB in older // ones; read as bytes either way (see `col_bytes`). @@ -482,15 +483,15 @@ fn load_composers(conn: &rusqlite::Connection) -> rusqlite::Result /// Load all `bubbleId::*` rows for one composer. fn load_bubbles(conn: &rusqlite::Connection, composer_id: &str) -> Vec { - // `escape '\'` guards composer ids that contain LIKE metacharacters (`%`/`_`). - let prefix = format!("bubbleId:{}:", escape_like(composer_id)); - let like = format!("{prefix}%"); + // ':' and ';' bound the exact prefix in the store's binary key index. + let prefix = format!("bubbleId:{composer_id}:"); + let prefix_end = format!("bubbleId:{composer_id};"); let mut stmt = - match conn.prepare("SELECT key, value FROM cursorDiskKV WHERE key LIKE ?1 ESCAPE '\\'") { + match conn.prepare("SELECT key, value FROM cursorDiskKV WHERE key >= ?1 AND key < ?2") { Ok(s) => s, Err(_) => return Vec::new(), }; - let rows = stmt.query_map([&like], |row| { + let rows = stmt.query_map([&prefix, &prefix_end], |row| { let key: String = row.get(0)?; // `value` is TEXT in current builds — read tolerantly (see `col_bytes`). let bytes = col_bytes(row, 1)?; @@ -744,18 +745,6 @@ fn json_as_i64(v: &Value) -> Option { .or_else(|| v.as_str().and_then(|s| s.trim().parse::().ok())) } -/// Escape `%`, `_`, and `\` for a SQLite `LIKE … ESCAPE '\'` prefix match. -fn escape_like(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for c in s.chars() { - if matches!(c, '%' | '_' | '\\') { - out.push('\\'); - } - out.push(c); - } - out -} - /// Serialize a normalized record to a [`RawRecord`] with stable provenance. fn record_for(file: &std::path::Path, line_no: u64, value: &Value) -> RawRecord { let line = serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()); @@ -1168,6 +1157,75 @@ mod tests { use super::*; use memscribe_core::SourceLocation; + fn populated_key_store() -> rusqlite::Connection { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE cursorDiskKV (key TEXT UNIQUE, value BLOB); + WITH RECURSIVE entries(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM entries WHERE n < 10000) + INSERT INTO cursorDiskKV SELECT 'unrelated:' || n, '{}' FROM entries;", + ).unwrap(); + conn + } + + #[test] + fn composer_lookup_does_not_scan_unrelated_rows() { + let conn = populated_key_store(); + conn.execute( + "INSERT INTO cursorDiskKV VALUES (?1, ?2)", + rusqlite::params!["composerData:session", r#"{"composerId":"session"}"#], + ) + .unwrap(); + conn.progress_handler(1000, Some(|| true)); + + let composers = load_composers(&conn).expect("indexed lookup fits the instruction budget"); + + assert_eq!(composers.len(), 1); + assert_eq!(composers[0].composer_id, "session"); + } + + #[test] + fn bubble_lookup_does_not_scan_unrelated_rows() { + let conn = populated_key_store(); + conn.execute( + "INSERT INTO cursorDiskKV VALUES (?1, ?2)", + rusqlite::params!["bubbleId:session:message", r#"{"text":"preserved"}"#], + ) + .unwrap(); + conn.progress_handler(1000, Some(|| true)); + + let bubbles = load_bubbles(&conn, "session"); + + assert_eq!( + bubbles.len(), + 1, + "indexed lookup fits the instruction budget" + ); + assert_eq!(bubbles[0].bubble_id, "message"); + } + + #[test] + fn bubble_lookup_preserves_literal_ids_and_excludes_adjacent_prefixes() { + let conn = populated_key_store(); + let composer_id = "session%_\\[é]:nested"; + for id in [ + composer_id.to_string(), + format!("{composer_id}extra"), + "other".into(), + ] { + conn.execute( + "INSERT INTO cursorDiskKV VALUES (?1, ?2)", + rusqlite::params![format!("bubbleId:{id}:message"), r#"{"text":"preserved"}"#], + ) + .unwrap(); + } + + let bubbles = load_bubbles(&conn, composer_id); + + assert_eq!(bubbles.len(), 1); + assert_eq!(bubbles[0].bubble_id, "message"); + assert_eq!(bubbles[0].value["text"], "preserved"); + } + fn raw(s: &str, line: u64) -> RawRecord { RawRecord::from_line(s, SourceLocation::new("cursor.jsonl", 0, line)) } From e49da9ad9af6f2076b45307f2b80ea8100a21213 Mon Sep 17 00:00:00 2001 From: Alex Holmberg Date: Thu, 10 Sep 2026 14:32:03 +0200 Subject: [PATCH 2/2] style: format governance assertions for pinned rustfmt --- crates/memscribe-core/src/governance_doc.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/memscribe-core/src/governance_doc.rs b/crates/memscribe-core/src/governance_doc.rs index 6f228b3..9db8753 100644 --- a/crates/memscribe-core/src/governance_doc.rs +++ b/crates/memscribe-core/src/governance_doc.rs @@ -1776,7 +1776,10 @@ Secrets must never be committed. CI scans for them. "; let d = classify("docs/adr/0005-no-secrets.md", content).unwrap(); - assert!(d.ban, "explicit ban: true front matter must set GovernanceDoc::ban"); + assert!( + d.ban, + "explicit ban: true front matter must set GovernanceDoc::ban" + ); assert!(d.governance_effective); } @@ -1805,7 +1808,10 @@ TLS verification must always be on. None. "; let d = classify("docs/adr/0006-tls-verification.md", content).unwrap(); - assert!(d.ban, "a non-falsy policy: value must be honored as a ban assertion"); + assert!( + d.ban, + "a non-falsy policy: value must be honored as a ban assertion" + ); } /// Absence of `ban:`/`policy:` must never be inferred true from ban-shaped