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
27 changes: 27 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ members = [
"aikit-magictool",
"aikit-textgrad",
"aikit-skillopt",
"aikit-session-capture",
]

[package]
Expand Down Expand Up @@ -66,6 +67,14 @@ walkdir = "2"
# Tool detection
which = "8.0"

# Session capture (spec 010): on-disk adapter parsing for AI coding tools.
# Optional — pulled when `agent-adapters` feature is on.
aikit-session-capture = { path = "aikit-session-capture", version = "0.1.0", optional = true }
# SQLite for the production EventStore/CursorStore (capture serve surface).
rusqlite = { version = "0.39", features = ["bundled"], optional = true }
# async trait for the EventStore/CursorStore impls.
async-trait = { version = "0.1", optional = true }

# aikit SDK (agent catalog and deploy)
# claude-control/codex-app-server enable bidirectional live-session bridges.
aikit-sdk = { path = "aikit-sdk", version = "0.3.0", features = ["claude-control", "codex-app-server"] }
Expand Down Expand Up @@ -147,6 +156,24 @@ path = "tests/serve/serve_disconnect_test.rs"

[features]
tools = ["dep:aikit-magictool"]
# Session capture (spec 010): on-disk adapter parsing for AI coding tools.
# Pulls the adapter crate + SQLite storage + flips aikit-sdk capabilities.
agent-adapters = [
"dep:aikit-session-capture",
"dep:rusqlite",
"dep:async-trait",
"aikit-sdk/agent-adapters",
]
claudecode = ["agent-adapters", "aikit-sdk/claudecode", "aikit-session-capture/claudecode"]
codex = ["agent-adapters", "aikit-sdk/codex", "aikit-session-capture/codex"]
opencode = ["agent-adapters", "aikit-sdk/opencode", "aikit-session-capture/opencode"]
# Watcher (spec 010 §14.5): notify-based fs watcher for automated capture.
watcher = ["aikit-session-capture/watcher"]
# MCP tools (spec 010 §15): registers check_file_freshness etc.
mcp-tools = ["aikit-session-capture/mcp-tools"]
# Crossmount (spec 010 §8/§19.3): WSL2 ↔ Windows home enumeration.
crossmount = ["aikit-session-capture/crossmount"]
default = ["claudecode", "codex", "opencode", "watcher"]

[profile.release]
opt-level = 3
Expand Down
11 changes: 11 additions & 0 deletions aikit-sdk/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ claude-control = ["claude-sdk", "dep:tokio"]
# Bidirectional Codex sessions: drive `aikit-agent-codex`'s app-server client
# (JSON-RPC over stdio) over a tokio bridge with a Control handle.
codex-app-server = ["dep:aikit-agent-codex", "dep:tokio"]
# Agent session capture (spec 010): passive on-disk session parsing for AI
# coding tools. Pulls the aikit-session-capture crate and flips the matching
# Backend `passive_capture` capability. Per-adapter features below gate the
# individual adapter modules so consumers can opt in to just Claude Code
# without dragging rusqlite (opencode) etc.
agent-adapters = ["dep:aikit-session-capture"]
claudecode = ["agent-adapters", "aikit-session-capture/claudecode"]
codex = ["agent-adapters", "aikit-session-capture/codex"]
opencode = ["agent-adapters", "aikit-session-capture/opencode"]

[dependencies]
tracing = "0.1"
Expand All @@ -38,9 +47,11 @@ claude-agent-sdk = { git = "https://github.com/aroff/claude-agent-sdk-rust", rev
aikit-agent-codex = { path = "../aikit-agent-codex", version = "0.1.0", optional = true }
tokio = { version = "1", features = ["rt", "sync", "macros"], optional = true }
jsonschema = "0.46"
aikit-session-capture = { path = "../aikit-session-capture", version = "0.1.0", optional = true }

[dev-dependencies]
insta = { version = "1.39", features = ["json"] }
assert_cmd = "2.0"
mockito = "1.2"
serde_json = "1.0"
tokio = { version = "1", features = ["rt", "macros"] }
55 changes: 53 additions & 2 deletions aikit-sdk/src/aikit_agent_adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,7 @@ where
.current_dir
.clone()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let mut config = AgentConfig::from_env(workdir, options.stream, options.model.clone())
.map_err(|e| emit_error(prompt, options, &mut on_event, e.to_string()))?;
let mut config = config_for_injected_gateway(workdir, options);

apply_session_options(options, &mut config);

Expand All @@ -95,6 +94,58 @@ where
)
}

fn config_for_injected_gateway(workdir: PathBuf, options: &RunOptions) -> AgentConfig {
AgentConfig::from_env(workdir.clone(), options.stream, options.model.clone()).unwrap_or_else(
|_| AgentConfig {
model: options
.model
.clone()
.or_else(|| std::env::var("AIKIT_MODEL").ok())
.unwrap_or_else(|| "gpt-4o".to_string()),
base_url: std::env::var("AIKIT_LLM_URL")
.unwrap_or_else(|_| "https://api.openai.com/v1".to_string()),
api_key: "injected-gateway".to_string(),
stream: options.stream
|| std::env::var("AIKIT_STREAM")
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
.unwrap_or(false),
max_iterations: std::env::var("AIKIT_MAX_ITERATIONS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10u32),
max_subagent_depth: std::env::var("AIKIT_MAX_SUBAGENT_DEPTH")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2u32),
context_budget_tokens: std::env::var("AIKIT_CONTEXT_BUDGET_TOKENS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(12000u64),
allowed_roots: vec![workdir.clone()],
skills_dirs: Vec::new(),
agents_md_path: agents_md_path(&workdir),
workdir,
timeout_secs: 60,
connect_timeout_secs: 10,
session_persona: None,
session_agents: std::collections::HashMap::new(),
host_tool_provider: None,
},
)
}

fn agents_md_path(workdir: &std::path::Path) -> Option<PathBuf> {
let agents_md = workdir.join("AGENTS.md");
let claude_md = workdir.join("CLAUDE.md");
if agents_md.exists() {
Some(agents_md)
} else if claude_md.exists() {
Some(claude_md)
} else {
None
}
}

fn apply_session_options(options: &RunOptions, config: &mut AgentConfig) {
// Apply session persona: deserialize from JSON, then apply model override if no CLI --model.
if let Some(ref persona_val) = options.session_persona {
Expand Down
172 changes: 172 additions & 0 deletions aikit-sdk/src/cost/extract.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
//! Cost extraction: the pull path from adapter-emitted `TokenEvent`s (spec
//! 010 §16).
//!
//! [`client_computed_cost`] is a **fallback only** — called when the
//! provider-side cost signal is absent AND `passive_capture` capability is
//! true. Produces a [`CostSnapshot`] with `ClientComputed` provenance and
//! `is_estimate == true`.

use crate::cost::{CostProvenance, CostSnapshot, PricingTable, Spend, SpendScope};
use crate::runner::backend::Backend;

fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}

/// Map a Backend to the ToolKind an adapter would carry.
/// Returns `None` for Backends with no adapter compiled in.
fn backend_to_tool_kind(b: Backend) -> Option<aikit_session_capture::ToolKind> {
match b {
#[cfg(feature = "claudecode")]
Backend::Claude => Some(aikit_session_capture::ToolKind::ClaudeCode),
#[cfg(feature = "codex")]
Backend::Codex => Some(aikit_session_capture::ToolKind::Codex),
#[cfg(feature = "opencode")]
Backend::OpenCode => Some(aikit_session_capture::ToolKind::OpenCode),
_ => None,
}
}

/// Compute cost from adapter-emitted `TokenEvent`s. Fallback only — called
/// when the provider-side cost signal is absent (spec 010 §16).
///
/// Produces a [`CostSnapshot`] with `ClientComputed` provenance and
/// `is_estimate == true` (spec 009 §5 invariant).
pub async fn client_computed_cost(
store: &dyn aikit_session_capture::EventStore,
backend: Backend,
session_id: &str,
pricing: &PricingTable,
) -> Option<CostSnapshot> {
let tool_kind = backend_to_tool_kind(backend)?;
let tokens = store
.token_events_for_session(tool_kind, session_id)
.await
.ok()?;
if tokens.is_empty() {
return None;
}
let spend_usd = pricing.estimate(&tokens)?;
let per_model = pricing.per_model_breakdown(&tokens);

Some(CostSnapshot {
backend: backend.key().to_string(),
captured_at_ms: now_ms(),
windows: vec![],
spend: Some(Spend {
amount_usd: spend_usd,
scope: SpendScope::Session,
is_estimate: true,
}),
per_model,
credits: None,
provenance: CostProvenance::ClientComputed,
})
}

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

#[cfg(all(feature = "agent-adapters", feature = "claudecode"))]
#[tokio::test]
async fn client_computed_cost_produces_estimate() {
use aikit_session_capture::{EventBatch, EventStore, InMemoryEventStore, TokenEvent};

let store = InMemoryEventStore::new();
let token_ev = TokenEvent {
source_event_id: "tk_1".into(),
session_id: "s1".into(),
tool: aikit_session_capture::ToolKind::ClaudeCode,
model: Some("claude-sonnet-4-20250514".into()),
request_id: None,
input_tokens: Some(1000),
cache_read_tokens: Some(500),
cache_creation_tokens: None,
cache_creation_1h_tokens: None,
output_tokens: Some(200),
reasoning_tokens: None,
captured_at_ms: 1000,
captured_via: aikit_session_capture::CaptureSource::Transcript,
};
store
.upsert_events(EventBatch {
tool_events: vec![],
token_events: vec![token_ev],
cache_observations: vec![],
})
.await
.unwrap();

let pricing = PricingTable::default();
let snapshot = client_computed_cost(&store, Backend::Claude, "s1", &pricing).await;

assert!(snapshot.is_some(), "should produce a CostSnapshot");
let snapshot = snapshot.unwrap();
assert_eq!(
snapshot.provenance,
CostProvenance::ClientComputed,
"provenance MUST be ClientComputed"
);
assert!(
snapshot.spend.as_ref().unwrap().is_estimate,
"is_estimate MUST be true for ClientComputed (spec 009 §5)"
);
assert!(
snapshot.spend.as_ref().unwrap().amount_usd > 0.0,
"cost should be non-zero"
);
}

#[test]
fn pricing_estimate_sums_all_events() {
use aikit_session_capture::{CaptureSource, TokenEvent};

let tokens = vec![
TokenEvent {
source_event_id: "1".into(),
session_id: "s1".into(),
tool: aikit_session_capture::ToolKind::ClaudeCode,
model: Some("default".into()),
request_id: None,
input_tokens: Some(1_000_000),
cache_read_tokens: None,
cache_creation_tokens: None,
cache_creation_1h_tokens: None,
output_tokens: Some(1_000_000),
reasoning_tokens: None,
captured_at_ms: 0,
captured_via: CaptureSource::Transcript,
},
TokenEvent {
source_event_id: "2".into(),
session_id: "s1".into(),
tool: aikit_session_capture::ToolKind::ClaudeCode,
model: Some("default".into()),
request_id: None,
input_tokens: Some(500_000),
cache_read_tokens: None,
cache_creation_tokens: None,
cache_creation_1h_tokens: None,
output_tokens: Some(500_000),
reasoning_tokens: None,
captured_at_ms: 0,
captured_via: CaptureSource::Transcript,
},
];
let pricing = PricingTable::default();
// 3M input * $3/Mtok + 3M output * $15/Mtok = $9 + $45 = $54 wait...
// Actually: (1M+0.5M) input = 1.5M * $3 = $4.50
// (1M+0.5M) output = 1.5M * $15 = $22.50
// Total = $27.00
let cost = pricing.estimate(&tokens).unwrap();
assert!(
(cost - 27.0).abs() < 0.01,
"expected $27.00, got ${cost:.2}"
);
}
}
Loading
Loading