Recall discovers AI coding sessions through source adapters. A source adapter is the single entry point for a tool. Search is the base capability; usage is an optional extension on the same adapter.
Start with search. Add usage only when the tool exposes token data.
Create src/adapters/<tool>.rs:
use crate::adapters::{RawMessage, RawSession, SourceAdapter};
use crate::types::Role;
pub(crate) struct MyToolAdapter;
impl SourceAdapter for MyToolAdapter {
fn id(&self) -> &str { "my-tool" } // stored in DB, used for filtering
fn label(&self) -> &str { "MT" } // short label shown in TUI
fn scan(&self) -> anyhow::Result<Vec<RawSession>> {
// Return empty vec if the tool is not installed
let data_dir = dirs::home_dir()
.ok_or_else(|| anyhow::anyhow!("no home dir"))?
.join(".my-tool");
if !data_dir.exists() {
return Ok(vec![]);
}
let mut sessions = Vec::new();
// Parse session files or databases here...
// For each session found:
sessions.push(RawSession::search_only(
"unique-session-id", // tool's native session ID
Some("/path/to/project".to_string()),
1700000000000, // Unix timestamp in milliseconds
None,
None,
vec![
RawMessage {
role: Role::User,
content: "user message text".to_string(),
timestamp: None,
},
RawMessage {
role: Role::Assistant,
content: "assistant response text".to_string(),
timestamp: None,
},
],
));
Ok(sessions)
}
}In src/adapters/mod.rs, add two lines:
pub(crate) mod my_tool; // add module declarationpub(crate) fn all_adapters() -> Vec<Box<dyn SourceAdapter>> {
vec![
Box::new(claude_code::ClaudeCodeAdapter),
Box::new(opencode::OpenCodeAdapter),
Box::new(codex::CodexAdapter),
Box::new(my_tool::MyToolAdapter), // add to registry
]
}That's it. The DB schema, search engine, TUI source filter, and CLI --source flag all pick it up automatically.
| Field | Type | Required | Notes |
|---|---|---|---|
id() |
&str |
yes | Lowercase, kebab-case. Stored in SQLite sessions.source column. |
label() |
&str |
yes | 2-4 uppercase chars. Shown in TUI session list and filter bar. |
source_id |
String |
yes | The tool's native session identifier. Must be unique per source. |
started_at |
i64 |
yes | Unix timestamp in milliseconds. |
messages |
Vec<RawMessage> |
yes | Ordered by time. Only User and Assistant roles. |
Usage is not a separate adapter. It is token data attached to the same
RawSession.
Add a parser version:
const USAGE_PARSER_VERSION: u32 = 1;
impl SourceAdapter for MyToolAdapter {
fn usage_parser_version(&self) -> Option<u32> {
Some(USAGE_PARSER_VERSION)
}
// ...
}Then attach usage events to the session:
let usage_events = vec![/* RawUsageEvent values parsed from the same tool data */];
sessions.push(
RawSession::search_only(source_id, directory, started_at, updated_at, entrypoint, messages)
.with_usage(usage_events, USAGE_PARSER_VERSION),
);If the adapter uses file mtimes to skip unchanged sessions, pass the same parser version into file scanning:
file_scan::run_file_scan_with_options(
store,
"my-tool",
since_ts,
file_scan::FileScanOptions {
usage_parser_version: Some(USAGE_PARSER_VERSION),
},
entries,
parse_my_tool_session_for_entry,
)This lets Recall backfill usage when the usage parser changes, even when the session messages did not change.
Usage event rules:
| Field | Notes |
|---|---|
event_key |
Stable and unique within the session. |
model / provider |
Use source data. Use unknown only when the source does not record it. |
| token fields | Split into input, output, cache read, cache write, and reasoning tokens. |
token_source |
Observed for source-reported values, Derived for deltas from counters, Estimated only for estimates. |
parser_version |
Set to USAGE_PARSER_VERSION; bump it when usage parsing changes. |
- If the tool is not installed, return
Ok(vec![])-- never error on missing data. - Open external databases read-only (
SQLITE_OPEN_READ_ONLY) to avoid locking the user's data. - Extract only
textcontent. Skip tool calls, images, and internal metadata. - Use
tracing::warn!for recoverable parse errors, skip the session, and continue. - Do not add usage support until the source exposes real token data.
- Do not create a separate usage adapter for the same tool.
make check # must pass before push — same gate as CI
cargo run -- sync -v # should show "Scanning MT..." with session count
make search Q="test --source mt"
cargo run -- usage --source my-tool # only when usage support was added
make run # TUI filter should include MTCI runs make check — the same single command you run locally. There is no separate CI-only logic.
make check = cargo fmt --check → cargo clippy → cargo test
Regression and eval harness tests live in src/integration/ inside the library crate. Run them with:
cargo test integration::regression
cargo test integration::eval_harnessThe former standalone targets cargo test --test regression and cargo test --test eval_harness are no longer used.
Always run make check before pushing. If it passes locally, CI will pass.
Releases are driven by cargo-release, which bumps Cargo.toml, updates
Cargo.lock, commits, tags, and pushes in one step. The GitHub Actions
release workflow triggers on v* tag push and builds cross-platform
binaries.
Recall is not published to crates.io. publish = false in Cargo.toml is the
current application release boundary, not a package metadata bug.
cargo install cargo-release
git config core.hooksPath .githooks # enables auto DCO signoffThe .githooks/prepare-commit-msg hook appends Signed-off-by to every
commit, so both hand-written and cargo-release-driven commits satisfy the
project's DCO convention.
make release-patch # dry-run: shows what would happen
make release-patch EXECUTE=1 # apply: bump, commit, tag, pushrelease-minor and release-major work the same way. The tag name is
v{{version}} and the commit subject is chore(release): bump to v{{version}}.
If Cargo.toml is at 0.1.0 but tags v0.1.1..v0.1.3 already exist (because
earlier releases were tagged without bumping Cargo.toml), a patch bump will
collide with v0.1.1. Skip to the next free version explicitly:
cargo release 0.1.4 # dry-run
cargo release 0.1.4 --execute # apply