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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,8 @@ jobs:
version: 2026.6.1
cache: true

- name: Install Rust quality components
run: mise exec -- rustup component add clippy rustfmt

- name: Run quality gate
run: mise run check
16 changes: 16 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[workspace]
resolver = "3"
members = [
"adapters/claude-code",
"crates/autophagy-cli",
"crates/autophagy-core",
"crates/autophagy-events",
Expand Down
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ change because of what happened?”
## Status

Autophagy is in foundation development. Agent Event Protocol (AEP) v0.1, the
transactional local SQLite event store, and a generic JSONL CLI vertical slice
are implemented. No daemon, native-agent adapter, or background capture ships
yet.
transactional local SQLite event store, generic JSONL CLI vertical slice, and
incremental Claude Code history adapter are implemented. No daemon or
background capture ships yet.

## Principles

Expand All @@ -27,6 +27,7 @@ yet.
## Repository map

```text
adapters/claude-code/ Native transcript discovery and AEP normalization
crates/autophagy-cli/ User-facing import, sessions, and search commands
crates/autophagy-core/ Reusable streaming import application services
crates/autophagy-events/ AEP Rust types, parsing, and validation
Expand Down Expand Up @@ -65,6 +66,16 @@ See the [generic JSONL guide](docs/guides/generic-jsonl.md) for dry-run,
project selection, standard input, JSON output, privacy controls, and exit-code
semantics.

Preview the exact Claude Code transcripts selected without writing a database:

```sh
mise exec -- cargo run -p autophagy-cli -- --output json \
import --adapter claude-code --dry-run
```

See the [Claude Code adapter guide](docs/guides/claude-code.md) for incremental
cursoring, subagents, content policy, and the normalization capability matrix.

## Try the contract

Install [mise](https://mise.jdx.dev/), then run:
Expand Down
25 changes: 25 additions & 0 deletions adapters/claude-code/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
[package]
name = "autophagy-adapter-claude-code"
description = "Incremental Claude Code transcript adapter for Autophagy"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish = false

[dependencies]
autophagy-events = { path = "../../crates/autophagy-events" }
autophagy-store = { path = "../../crates/autophagy-store" }
directories.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
thiserror.workspace = true
time.workspace = true

[dev-dependencies]
tempfile = "3.27"

[lints]
workspace = true
149 changes: 149 additions & 0 deletions adapters/claude-code/src/discovery.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
use std::{
env, fs, io,
path::{Path, PathBuf},
};

use directories::BaseDirs;
use serde::Serialize;

/// Whether a transcript belongs to a primary Claude Code session or subagent.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionKind {
/// Top-level session transcript.
Main,
/// Nested `agent-*.jsonl` subagent transcript.
Subagent,
}

/// Metadata-only description of one transcript selected for import.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct DiscoveredSession {
/// Absolute source path.
pub path: PathBuf,
/// Stable slash-separated path relative to the discovery root.
pub relative_path: String,
/// File size observed during discovery.
pub size_bytes: u64,
/// Transcript category.
pub kind: SessionKind,
}

/// Discovery controls. Discovery never opens transcript contents.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DiscoveryOptions {
/// Claude Code projects directory, or one explicit JSONL file.
pub input: PathBuf,
/// Include nested subagent transcripts.
pub include_subagents: bool,
}

/// Exact set of source files an import will consider.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct DiscoveryPlan {
/// Canonical input root or explicit file.
pub root: PathBuf,
/// Sorted, deterministic transcript list.
pub files: Vec<DiscoveredSession>,
}

/// Resolve `${CLAUDE_CONFIG_DIR:-~/.claude}/projects`.
///
/// # Errors
/// Returns an error if no home directory can be determined.
pub fn default_projects_root() -> Result<PathBuf, DiscoveryError> {
if let Some(config) = env::var_os("CLAUDE_CONFIG_DIR").filter(|value| !value.is_empty()) {
return Ok(PathBuf::from(config).join("projects"));
}
let base = BaseDirs::new().ok_or(DiscoveryError::HomeUnavailable)?;
Ok(base.home_dir().join(".claude/projects"))
}

/// Discover selected Claude Code transcript files without reading their contents.
///
/// # Errors
/// Returns an error for an inaccessible input or filesystem traversal failure.
pub fn discover(options: &DiscoveryOptions) -> Result<DiscoveryPlan, DiscoveryError> {
let root = fs::canonicalize(&options.input)?;
let metadata = fs::metadata(&root)?;
let mut files = Vec::new();
if metadata.is_file() {
add_file(&root, &root, options.include_subagents, &mut files)?;
} else if metadata.is_dir() {
walk(&root, &root, options.include_subagents, &mut files)?;
} else {
return Err(DiscoveryError::UnsupportedInput(root));
}
files.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
Ok(DiscoveryPlan { root, files })
}

fn walk(
root: &Path,
directory: &Path,
include_subagents: bool,
files: &mut Vec<DiscoveredSession>,
) -> Result<(), DiscoveryError> {
let mut entries = fs::read_dir(directory)?.collect::<Result<Vec<_>, _>>()?;
entries.sort_by_key(fs::DirEntry::file_name);
for entry in entries {
let file_type = entry.file_type()?;
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
walk(root, &entry.path(), include_subagents, files)?;
} else if file_type.is_file() {
add_file(root, &entry.path(), include_subagents, files)?;
}
}
Ok(())
}

fn add_file(
root: &Path,
path: &Path,
include_subagents: bool,
files: &mut Vec<DiscoveredSession>,
) -> Result<(), DiscoveryError> {
if path.extension().and_then(|value| value.to_str()) != Some("jsonl") {
return Ok(());
}
let is_subagent = path
.file_stem()
.and_then(|value| value.to_str())
.is_some_and(|name| name.starts_with("agent-"));
if is_subagent && !include_subagents {
return Ok(());
}
let relative = if root.is_file() {
path.file_name().map(PathBuf::from).unwrap_or_default()
} else {
path.strip_prefix(root).unwrap_or(path).to_path_buf()
};
files.push(DiscoveredSession {
path: path.to_path_buf(),
relative_path: relative.to_string_lossy().replace('\\', "/"),
size_bytes: fs::metadata(path)?.len(),
kind: if is_subagent {
SessionKind::Subagent
} else {
SessionKind::Main
},
});
Ok(())
}

/// Failure while resolving or enumerating Claude Code history.
#[derive(Debug, thiserror::Error)]
pub enum DiscoveryError {
/// Filesystem operation failed.
#[error("could not discover Claude Code transcripts: {0}")]
Io(#[from] io::Error),
/// Platform home directory was unavailable.
#[error("could not determine the home directory for Claude Code history")]
HomeUnavailable,
/// Input was neither a regular file nor directory.
#[error("Claude Code input is not a regular file or directory: {}", .0.display())]
UnsupportedInput(PathBuf),
}
Loading