Skip to content
Open
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
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Purpose

Search and resume past conversations from Claude Code, Codex CLI, and Factory (Droid).
Search and resume past conversations from Claude Code, Codex CLI, Factory (Droid), OpenCode, Copilot CLI, and Cursor.

## Principles

Expand Down Expand Up @@ -41,15 +41,15 @@ cargo install --path .

## Architecture

Rust TUI for searching Claude Code, Codex CLI, and Factory conversation history.
Rust TUI for searching Claude Code, Codex CLI, Factory, OpenCode, Copilot CLI, and Cursor conversation history.

- `src/main.rs` - Entry point, event loop, exec into CLI on resume
- `src/app.rs` - Application state, search logic, background indexing thread
- `src/ui.rs` - Two-pane ratatui rendering, match highlighting
- `src/tui.rs` - Terminal setup/teardown
- `src/theme.rs` - Light/dark theme with auto-detection
- `src/session.rs` - Core types: Session, Message, SearchResult
- `src/parser/` - JSONL parsers for Claude (`~/.claude/projects/`), Codex (`~/.codex/sessions/`), and Factory (`~/.factory/sessions/`)
- `src/parser/` - JSONL parsers for Claude (`~/.claude/projects/`), Codex (`~/.codex/sessions/`), Factory (`~/.factory/sessions/`), OpenCode (`~/.local/share/opencode/`), Copilot CLI (`~/.copilot/session-state/`), and Cursor (`~/.cursor/chats/`)
- `src/index/` - Tantivy full-text search index, stored in `~/Library/Caches/recall/` (macOS) or `~/.cache/recall/` (Linux)

## Key Patterns
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# recall   [![Mentioned in Awesome Claude Code](https://awesome.re/mentioned-badge.svg)](https://github.com/hesreallyhim/awesome-claude-code)

Search and resume your Claude Code conversations. Also supports Codex, OpenCode and Factory (Droid).
Search and resume your Claude Code conversations. Also supports Codex, OpenCode, Factory (Droid), Copilot CLI and Cursor CLI.

**Tip**: Don't like reading? Tell your agent to use `recall search --help` and it'll search for you.

Expand Down Expand Up @@ -66,6 +66,8 @@ For example, to resume conversations in YOLO mode, add this to your `.bashrc` or
```bash
export RECALL_CLAUDE_CMD="claude --dangerously-skip-permissions --resume {id}"
export RECALL_CODEX_CMD="codex --dangerously-bypass-approvals-and-sandbox resume {id}"
export RECALL_COPILOT_CMD="copilot --resume={id}"
export RECALL_CURSOR_CMD="cursor-agent --resume={id}"
```

---
Expand Down
19 changes: 11 additions & 8 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,16 +231,19 @@ impl App {
// Remember currently selected session to preserve selection
let selected_session_id = self.results.get(self.selected).map(|r| r.session.id.clone());

let mut results = if self.query.is_empty() {
self.index.recent(50)?
let results = if self.query.is_empty() {
match &self.search_scope {
SearchScope::Everything => self.index.recent(50)?,
SearchScope::Folder(cwd) => self.index.recent_filtered(50, None, Some(cwd))?,
}
} else {
self.index.search(&self.query, 50)?
};

// Filter by scope if searching within a folder
if let SearchScope::Folder(ref cwd) = self.search_scope {
results.retain(|r| r.session.cwd == *cwd);
let mut results = self.index.search(&self.query, 50)?;
if let SearchScope::Folder(ref cwd) = self.search_scope {
results.retain(|r| r.session.cwd == *cwd);
}
results
}
;

self.results = results;

Expand Down
6 changes: 1 addition & 5 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,18 +233,14 @@ pub fn run_list(
let since_dt = since.as_ref().map(|s| parse_time(s)).transpose()?;
let until_dt = until.as_ref().map(|s| parse_time(s)).transpose()?;

let results = index.recent(limit * 2)?; // Get more to filter
let results = index.recent_filtered(limit, source, cwd.as_deref())?;

let output = ListOutput {
sessions: results
.iter()
// Filter by source
.filter(|r| source.is_none_or(|s| r.session.source == s))
// Filter by time
.filter(|r| since_dt.is_none_or(|t| r.session.timestamp >= t))
.filter(|r| until_dt.is_none_or(|t| r.session.timestamp <= t))
// Filter by working directory
.filter(|r| cwd.as_ref().is_none_or(|c| r.session.cwd == *c))
.take(limit)
.map(|r| r.session.to_summary())
.collect(),
Expand Down
38 changes: 36 additions & 2 deletions src/index/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,15 +309,50 @@ impl SessionIndex {

/// Get recent sessions sorted by timestamp (most recent first)
pub fn recent(&self, limit: usize) -> Result<Vec<SearchResult>> {
self.recent_filtered(limit, None, None)
}

/// Get recent sessions sorted by timestamp (most recent first), with optional filters.
pub fn recent_filtered(
&self,
limit: usize,
source: Option<SessionSource>,
cwd: Option<&str>,
) -> Result<Vec<SearchResult>> {
use tantivy::collector::TopDocs;
use tantivy::query::AllQuery;

let searcher = self.reader.searcher();

let query: Box<dyn Query> = if source.is_none() && cwd.is_none() {
Box::new(AllQuery)
} else {
let mut clauses: Vec<(Occur, Box<dyn Query>)> = Vec::new();
clauses.push((Occur::Must, Box::new(AllQuery)));

if let Some(source) = source {
let term = tantivy::Term::from_field_text(self.source, source.as_str());
clauses.push((
Occur::Must,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
));
}

if let Some(cwd) = cwd {
let term = tantivy::Term::from_field_text(self.cwd, cwd);
clauses.push((
Occur::Must,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
));
}

Box::new(BooleanQuery::new(clauses))
};

// Get all docs sorted by timestamp descending
// Fetch many more docs since each session has multiple messages indexed
let top_docs = searcher.search(
&AllQuery,
&*query,
&TopDocs::with_limit(limit * 100).order_by_fast_field::<i64>("timestamp", tantivy::Order::Desc),
)?;

Expand Down Expand Up @@ -435,4 +470,3 @@ impl SessionIndex {
}
}
}

8 changes: 4 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ mod cli;

#[derive(Parser)]
#[command(name = "recall")]
#[command(version, about = "Search and resume Claude Code, Codex CLI, and Factory conversations")]
#[command(version, about = "Search and resume Claude Code, Codex CLI, Factory, OpenCode, Copilot, and Cursor conversations")]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
Expand All @@ -30,7 +30,7 @@ enum Command {
#[arg(required = true)]
query: Vec<String>,

/// Filter by source (claude, codex, factory, opencode)
/// Filter by source (claude, codex, factory, opencode, copilot, cursor)
#[arg(long, short)]
source: Option<String>,

Expand Down Expand Up @@ -65,7 +65,7 @@ enum Command {
#[arg(long, short, default_value = "20")]
limit: usize,

/// Filter by source (claude, codex, factory, opencode)
/// Filter by source (claude, codex, factory, opencode, copilot, cursor)
#[arg(long, short)]
source: Option<String>,

Expand Down Expand Up @@ -143,7 +143,7 @@ fn main() -> Result<()> {
fn parse_source(source: &Option<String>) -> Result<Option<SessionSource>> {
match source {
Some(s) => SessionSource::parse(s)
.ok_or_else(|| anyhow::anyhow!("Invalid source '{}'. Valid: claude, codex, factory, opencode", s))
.ok_or_else(|| anyhow::anyhow!("Invalid source '{}'. Valid: claude, codex, factory, opencode, copilot, cursor", s))
.map(Some),
None => Ok(None),
}
Expand Down
39 changes: 6 additions & 33 deletions src/parser/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;

use super::{join_consecutive_messages, SessionParser};
use super::{extract_text_content, join_consecutive_messages, SessionParser};

#[derive(Debug, Deserialize)]
struct ClaudeLine {
Expand Down Expand Up @@ -42,7 +42,7 @@ impl SessionParser for ClaudeParser {
fn can_parse(path: &Path) -> bool {
// Claude Code sessions are in ~/.claude/projects/
path.to_str()
.map(|s| s.contains(".claude/projects"))
.map(|s| s.contains(".claude/projects") || s.contains(".claude\\projects"))
.unwrap_or(false)
}

Expand Down Expand Up @@ -114,7 +114,7 @@ impl SessionParser for ClaudeParser {
_ => continue,
};

let content = extract_content(&msg.content);
let content = extract_text_content(&msg.content);
if content.is_empty() {
continue;
}
Expand Down Expand Up @@ -155,42 +155,16 @@ impl SessionParser for ClaudeParser {
}
}

/// Extract text content from Claude's message content field.
/// - User messages: content is a plain string
/// - Assistant messages: content is an array of {type, text} objects
fn extract_content(content: &serde_json::Value) -> String {
match content {
// Direct string (user messages)
serde_json::Value::String(s) => s.clone(),

// Array of content blocks (assistant messages)
serde_json::Value::Array(arr) => {
let mut texts = Vec::new();
for item in arr {
if let Some(obj) = item.as_object() {
// Only extract "text" type blocks, skip tool_use, thinking, etc.
if obj.get("type").and_then(|v| v.as_str()) == Some("text") {
if let Some(text) = obj.get("text").and_then(|v| v.as_str()) {
texts.push(text.to_string());
}
}
}
}
texts.join("\n")
}

_ => String::new(),
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::parser::extract_text_content;

#[test]
fn test_extract_content_string() {
let content = serde_json::json!("Hello, world!");
assert_eq!(extract_content(&content), "Hello, world!");
assert_eq!(extract_text_content(&content), "Hello, world!");
}

#[test]
Expand All @@ -200,7 +174,6 @@ mod tests {
{"type": "tool_use", "name": "Read"},
{"type": "text", "text": "World"}
]);
assert_eq!(extract_content(&content), "Hello\nWorld");
assert_eq!(extract_text_content(&content), "Hello\nWorld");
}

}
46 changes: 45 additions & 1 deletion src/parser/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ impl SessionParser for CodexParser {
fn can_parse(path: &Path) -> bool {
// Codex sessions are in ~/.codex/sessions/ and start with "rollout-"
path.to_str()
.map(|s| s.contains(".codex/sessions"))
.map(|s| s.contains(".codex/sessions") || s.contains(".codex\\sessions"))
.unwrap_or(false)
}

Expand Down Expand Up @@ -83,6 +83,10 @@ impl SessionParser for CodexParser {
match entry.entry_type.as_str() {
"session_meta" => {
if let Some(payload) = &entry.payload {
if is_subagent_session_meta(payload) {
anyhow::bail!("Codex subagent sidechain session: {:?}", path);
}

if let Ok(meta) = serde_json::from_value::<SessionMeta>(payload.clone()) {
// Only set if not already set (first session_meta wins)
if session_id.is_none() {
Expand Down Expand Up @@ -164,6 +168,14 @@ impl SessionParser for CodexParser {
}
}

fn is_subagent_session_meta(payload: &serde_json::Value) -> bool {
payload
.get("source")
.and_then(|source| source.get("subagent"))
.and_then(|subagent| subagent.get("thread_spawn"))
.is_some()
}

/// Extract text content from a Codex response item.
/// Filters out CLI-injected blocks (AGENTS.md instructions, environment_context).
fn extract_codex_content(item: &ResponseItem) -> String {
Expand Down Expand Up @@ -256,4 +268,36 @@ mod tests {
"<environment_context> what is this?"
);
}

#[test]
fn test_is_subagent_session_meta() {
let payload = serde_json::json!({
"id": "child-session",
"source": {
"subagent": {
"thread_spawn": {
"parent_thread_id": "parent-session",
"depth": 1
}
}
}
});

assert!(is_subagent_session_meta(&payload));
}

#[test]
fn test_parse_subagent_session_returns_error() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("rollout-child.jsonl");

let content = r#"{"timestamp":"2026-03-25T07:35:38.039Z","type":"session_meta","payload":{"id":"child-session","forked_from_id":"parent-session","cwd":"/tmp/project","source":{"subagent":{"thread_spawn":{"parent_thread_id":"parent-session","depth":1}}}}}
{"timestamp":"2026-03-25T07:35:38.040Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Child agent response"}]}}
{"timestamp":"2026-03-25T07:35:38.041Z","type":"session_meta","payload":{"id":"parent-session","cwd":"/tmp/project","source":"cli"}}"#;

std::fs::write(&path, content).unwrap();

let err = CodexParser::parse_file(&path).unwrap_err();
assert!(err.to_string().contains("subagent sidechain"));
}
}
Loading