Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions crates/lore-core/src/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,53 @@ pub fn search_page(
Ok(SearchPage { hits, next_cursor })
}

/// One row of a session's redacted projection, as [`session_documents_page`]
/// returns it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RedactedDocument {
/// The projection row id; pass the last one back as `after_id`.
pub id: i64,
/// The message role for a message part, otherwise the projected field
/// (`title`, `patch`).
pub label: String,
pub text: String,
}

/// Read a session's searchable text back from the redacted `search_document`
/// projection, in ingest order (title, then messages by seq, then patches).
///
/// This is the read for surfaces that hand archive text to a model: it can only
/// return what search could, so a flagged span, a thinking block, or tool
/// payload JSON never leaves the archive through it. `query::get_session` is
/// the cleartext read and stays local to the UI.
pub fn session_documents_page(
conn: &Connection,
session_id: &str,
after_id: Option<i64>,
limit: i64,
) -> Result<Vec<RedactedDocument>> {
let mut stmt = conn.prepare(
"SELECT sd.id, COALESCE(m.role, sd.field), sd.redacted_text
FROM search_document sd
LEFT JOIN message_part p ON sd.source_kind = 'message_part' AND p.id = sd.source_id
LEFT JOIN message m ON m.id = p.message_id
WHERE sd.session_id = ?1 AND sd.id > ?2
ORDER BY sd.id
LIMIT ?3",
)?;
let rows = stmt.query_map(
rusqlite::params![session_id, after_id.unwrap_or(0), limit.clamp(1, MAX_LIMIT)],
|row| {
Ok(RedactedDocument {
id: row.get(0)?,
label: row.get(1)?,
text: row.get(2)?,
})
},
)?;
Ok(rows.collect::<rusqlite::Result<_>>()?)
}

/// Build the outer keyset predicate (with a leading ` WHERE `) and its bound
/// params for `sort` and `cursor`. Empty when there is no cursor. Each arm keeps
/// only rows strictly after the cursor in that sort's total order; `started_at`
Expand Down
31 changes: 30 additions & 1 deletion crates/lore-core/tests/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
use lore_core::adapters::claude_code::ClaudeCodeAdapter;
use lore_core::adapters::codex::CodexAdapter;
use lore_core::ingest::persist_session;
use lore_core::search::{search, search_page, SortOrder, HIGHLIGHT_START};
use lore_core::search::{search, search_page, session_documents_page, SortOrder, HIGHLIGHT_START};
use lore_core::storage::blob::BlobStore;
use rusqlite::Connection;

Expand Down Expand Up @@ -241,6 +241,35 @@ fn snippets_never_surface_a_secret() {
);
}

#[test]
fn session_documents_read_back_redacted_text_in_order() {
let conn = lore_core::storage::open_in_memory().unwrap();
let (_bd, blobs) = store();
let secret = format!("ghp{}", "_0123456789abcdefghijklmnopqrstuvwxyz");
let sid = persist_claude(
&conn,
&blobs,
&user_message("r1", "/p", &format!("deploy with token {secret} now")),
"r1",
);

let docs = session_documents_page(&conn, &sid, None, 50).unwrap();
assert_eq!(docs.len(), 1);
assert_eq!(docs[0].label, "user");
assert!(docs[0].text.starts_with("deploy with token "));
assert!(docs[0].text.ends_with(" now"));
assert!(!docs[0]
.text
.contains("0123456789abcdefghijklmnopqrstuvwxyz"));

assert!(session_documents_page(&conn, &sid, Some(docs[0].id), 50)
.unwrap()
.is_empty());
assert!(session_documents_page(&conn, "no-such-session", None, 50)
.unwrap()
.is_empty());
}

#[test]
fn keyset_pagination_is_stable_and_complete() {
// SEARCH.md §6: paging must reproduce the single-shot ranked order exactly,
Expand Down
15 changes: 12 additions & 3 deletions crates/lorectl/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ pub enum Command {
Hook,
/// Longer-form summary of archived work.
Report,
/// MCP server over stdio, so agents can search the archive.
Mcp,
}

/// Every command, in the order `--help` lists them.
Expand All @@ -42,6 +44,7 @@ pub const COMMANDS: &[Command] = &[
Command::Inspect,
Command::Search,
Command::Hook,
Command::Mcp,
Command::Report,
];

Expand All @@ -56,6 +59,7 @@ impl Command {
Command::Search => "search",
Command::Hook => "hook",
Command::Report => "report",
Command::Mcp => "mcp",
}
}

Expand All @@ -69,6 +73,7 @@ impl Command {
Command::Search => "Full-text search across archived sessions",
Command::Hook => "Agent hook entry point; run by an agent, not a person",
Command::Report => "Longer-form summary of archived work",
Command::Mcp => "MCP server on stdin/stdout; run by an agent, not a person",
}
}

Expand All @@ -81,7 +86,8 @@ impl Command {
| Command::Search
| Command::Inspect
| Command::Status
| Command::Hook => true,
| Command::Hook
| Command::Mcp => true,
Command::Report => false,
}
}
Expand All @@ -97,7 +103,7 @@ impl Command {
Command::Search => Some("QUERY"),
Command::Inspect => Some("SESSION_ID"),
Command::Hook => Some("EVENT"),
Command::Scan | Command::Status | Command::Report => None,
Command::Scan | Command::Status | Command::Report | Command::Mcp => None,
}
}

Expand Down Expand Up @@ -284,7 +290,10 @@ mod tests {
.filter(|c| c.is_implemented())
.map(|c| c.name())
.collect();
assert_eq!(implemented, ["scan", "status", "inspect", "search", "hook"]);
assert_eq!(
implemented,
["scan", "status", "inspect", "search", "hook", "mcp"]
);
}

#[test]
Expand Down
2 changes: 2 additions & 0 deletions crates/lorectl/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ mod cli;
mod exit;
mod help;
mod hook;
mod mcp;
mod query;
mod scan;
mod status;
Expand Down Expand Up @@ -58,6 +59,7 @@ fn run() -> Result<u8, CliError> {
Action::Run(cli::Command::Scan) => scan::run(&invocation),
Action::Run(cli::Command::Status) => status::run(&invocation),
Action::Run(cli::Command::Hook) => hook::run(&invocation),
Action::Run(cli::Command::Mcp) => mcp::run(&invocation),
Action::Run(cli::Command::Search) => query::search(&invocation),
Action::Run(cli::Command::Inspect) => query::inspect(&invocation),
Action::Run(command) => {
Expand Down
Loading
Loading