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
7 changes: 7 additions & 0 deletions .atlasignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# What Atlas's code index (symbols, chunks, embeddings) leaves out beyond
# .gitignore and its built-in skips. Gitignore syntax; a `!` line re-includes
# something a built-in rule skipped.

# The engine is first-party code (ADR-0003), not a vendored dependency: index
# it despite the built-in `vendor/` skip.
!vendor/atlas-engine/
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Shared memory also records **session lifecycle** (session start and end, todo ad

Terms that are *not* shared memory: **capture** (raw transcripts, see Timeline), **knowledge notes** (user-written pages in the Knowledge panel), the **codebase index** (derived from source), and any agent's own memory files (`CLAUDE.md`, `AGENTS.md`, Claude's auto-memory directory). These may be *sources* shared memory cites or imports, but an entry in them is not an entry in shared memory.

Shared memory reaches an agent one way only: it **pulls** it through the **memory tool server**, the in-process MCP server Atlas hands every session that can take it (ADR-0010). Nothing is prepended to a user's message; the text goes to the agent exactly as typed. The server's **instructions** are the protocol — read first, write as you learn — and its tools are, in that order: **`memory_briefing`** (the first look of a session: preferences, working memory, the **durable index**, the **curated pack**, the **recent-session handoff** and the **handoff note**), **`memory_changes`** (what *other* sessions recorded since this session's **last look**), **`memory_search`** (the record plus the codebase index's documents), **`memory_get`**, **`memory_list`**, **`memory_history`** (every revision of one entry), **`memory_why`** (the recorded sessions behind a file or a commit), **`memory_remember`**, **`memory_feedback`** (useful, wrong or stale) and **`memory_forget`**. An agent without HTTP MCP reaches the same server through the **stdio bridge**, the Atlas binary run as `atlas mcp-bridge` (ADR-0019).
Shared memory reaches an agent one way only: it **pulls** it through the **memory tool server**, the in-process MCP server Atlas hands every session that can take it (ADR-0010). Nothing is prepended to a user's message; the text goes to the agent exactly as typed. The server's **instructions** are the protocol — read first, write as you learn — and its tools are, in that order: **`memory_briefing`** (the first look of a session: preferences, working memory, the **durable index**, the **curated pack**, the **recent-session handoff** and the **handoff note**), **`memory_changes`** (what *other* sessions recorded since this session's **last look**), **`memory_search`** (the record plus the codebase index's documents), **`memory_get`**, **`memory_list`**, **`memory_history`** (every revision of one entry), **`memory_why`** (the recorded sessions behind a file or a commit), **`memory_remember`**, **`memory_feedback`** (useful, wrong or stale) and **`memory_forget`**. Every ACP agent, whatever it advertises, reaches the same server through the **stdio bridge**, the Atlas binary run as `atlas mcp-bridge`, its token in a private file and never in the MCP entry (ADR-0019, ADR-0020); only the in-process native agent gets it over HTTP.

- The **durable index** is one capped line per durable entry, each kind's best up to its display cap, ranked by recency (two-week half-life), use count and confidence; `memory_get` expands a line. It lists shared-memory entries; it is not the codebase index, and not the retrieval index `memory_search` also searches.
- The **curated pack** is the high-signal part of the project's foreign memory files (Claude's memory directory, `CLAUDE.md`, `AGENTS.md`), recency-ranked and budget-bounded. The **recent-session handoff** is the tail of the most recent other session in the scope, whichever agent ran it, read from capture (or, where capture was never enabled, from Atlas's own session transcripts) — never from an agent's own files.
Expand Down
56 changes: 56 additions & 0 deletions crates/atlas-agent-manager/tests/session_mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ async fn an_agent_advertising_http_mcp_gets_the_server_with_its_token_on_session
!asked[0].org_access,
"an ACP connection never carries organisation access (ADR-0014), whatever the agent is",
);
assert!(
!asked[0].in_process,
"an ACP agent is another process, so no credential may ride its entries (ADR-0020)",
);
assert_eq!(asked[0].agent_id.as_str(), "fake-agent");
assert_eq!(
asked[0].session_id, None,
Expand Down Expand Up @@ -154,6 +158,58 @@ async fn an_agent_without_http_mcp_gets_no_server_entry() {
drop(thread);
}

/// Offers the host's server the way it reaches an ACP agent (ADR-0020): a
/// stdio bridge whose entry names a token file and holds no secret.
struct BridgeOffering;

impl SessionMcpServers for BridgeOffering {
fn offer(&self, _request: &SessionMcpRequest) -> SessionMcpOffer {
let server = acp::McpServer::Stdio(
acp::McpServerStdio::new("atlas_memory", "/Applications/Atlas.app/atlas").args(vec![
"mcp-bridge".into(),
"http://127.0.0.1:4321/mcp".into(),
"/private/atlas/mcp-bridge/l/t.token".into(),
]),
);
SessionMcpOffer::new(vec![server], |_| {})
}
}

/// Stdio is the one transport every ACP agent takes, advertised or not; the
/// bridge reaches the agent as offered, pre-approved for the Claude Code
/// adapter like an HTTP entry.
#[tokio::test]
async fn a_bridged_server_reaches_an_agent_without_http_mcp_pre_approved() {
let Some((manager, pid_file)) =
manager_offering_mcp("mcp-new-bridge", json!({}), Arc::new(BridgeOffering))
else {
eprintln!("skipping: no python3 on this machine");
return;
};
let thread = manager
.new_session(custom("fake-agent"), vec![std::env::temp_dir()])
.await
.expect("a session opens on the real agent");

let requests = session_requests(&pid_file);
assert_eq!(
requests[0].1["mcpServers"],
json!([{
"name": "atlas_memory",
"command": "/Applications/Atlas.app/atlas",
"args": ["mcp-bridge", "http://127.0.0.1:4321/mcp", "/private/atlas/mcp-bridge/l/t.token"],
"env": [],
}])
);
assert_eq!(
requests[0].1["_meta"]["claudeCode"]["options"]["allowedTools"],
json!(["mcp__atlas_memory"]),
);

manager.shutdown();
drop(thread);
}

#[tokio::test]
async fn a_loaded_session_gets_the_server_and_its_token_is_bound_to_that_session() {
let offering = Arc::new(TokenOffering::default());
Expand Down
3 changes: 3 additions & 0 deletions crates/atlas-agent-servers/src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,9 @@ impl AcpConnection {
// Likewise for the organisation: a binary Atlas does not own
// is never handed the user's organisation (ADR-0014).
org_access: false,
// A separate program, free to put its MCP configuration on a
// command line: no credential may ride an entry (ADR-0020).
in_process: false,
cwd: cwd.to_path_buf(),
session_id: session_id.cloned(),
},
Expand Down
32 changes: 25 additions & 7 deletions crates/atlas-agent-servers/src/session_mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
//! sends a transport the agent did not advertise ([`admissible`]). ACP makes
//! stdio mandatory for every agent and HTTP/SSE opt-in through
//! `mcpCapabilities`, and that is the only gate: never the agent's identity.
//!
//! Nothing secret may ride an entry handed to an agent in another process
//! ([`SessionMcpRequest::in_process`]): an ACP adapter is free to put its MCP
//! configuration on a command line, where any local user's `ps` reads it (the
//! Claude Agent SDK passes `--mcp-config <json>`, headers and `env` included).
//! ADR-0020.

use std::path::PathBuf;
use std::sync::Arc;
Expand Down Expand Up @@ -42,6 +48,13 @@ pub struct SessionMcpRequest {
/// binary is never handed the user's organisation — decided by the
/// connection, never by which agent it is.
pub org_access: bool,
/// Whether the agent runs **inside the Atlas process**, so an entry it is
/// handed never leaves Atlas's memory. An agent in another process (every
/// ACP agent) may write its MCP configuration anywhere — a command line,
/// a config file — so the host must not put a credential in any of an
/// entry's fields: not a header, not an argument, not `env` (ADR-0020).
/// A property of the connection, never of which agent it is.
pub in_process: bool,
/// The directory the session runs in.
pub cwd: PathBuf,
/// The session being loaded or resumed; `None` for a new session, whose
Expand Down Expand Up @@ -360,7 +373,8 @@ pub fn offer_for(
provider.map_or_else(SessionMcpOffer::none, |p| p.offer(request))
}

/// `_meta` for a session request carrying `servers`: the Claude Code adapter
/// `_meta` for a session request carrying `servers` (only ever the host's
/// offer, never the user's own servers): the Claude Code adapter
/// passes `_meta.claudeCode.options` to the Agent SDK, whose `allowedTools` rule
/// `mcp__<server>` runs that server's tools without a permission prompt: the
/// standing the native agent's servers have (`default_tools_approval_mode =
Expand All @@ -372,8 +386,11 @@ pub fn preapproval_meta(servers: &[acp::McpServer], ask_first: &AskFirst) -> Opt
let rules: Vec<serde_json::Value> = servers
.iter()
.filter_map(|server| match server {
acp::McpServer::Http(http) if ask_first.tools_on(&http.name).next().is_none() => {
Some(format!("mcp__{}", http.name).into())
acp::McpServer::Http(acp::McpServerHttp { name, .. })
| acp::McpServer::Stdio(acp::McpServerStdio { name, .. })
if ask_first.tools_on(name).next().is_none() =>
{
Some(format!("mcp__{name}").into())
}
_ => None,
})
Expand Down Expand Up @@ -414,11 +431,12 @@ mod tests {
}

/// Claude Code asked before every Atlas tool call (review of #354): the
/// offered HTTP servers ride the session request as pre-approved.
/// offered servers ride the session request as pre-approved, whichever
/// transport carries them (an ACP agent gets the stdio bridge, ADR-0020).
#[test]
fn offered_http_servers_are_preapproved_for_claude_code() {
let stdio = acp::McpServer::Stdio(acp::McpServerStdio::new("local", "/bin/true"));
let servers = [http("atlas_memory"), http("atlas_code"), stdio];
fn offered_servers_are_preapproved_for_claude_code() {
let stdio = acp::McpServer::Stdio(acp::McpServerStdio::new("atlas_code", "/bin/atlas"));
let servers = [http("atlas_memory"), stdio];
let meta = preapproval_meta(&servers, &AskFirst::none()).unwrap();
assert_eq!(
serde_json::Value::Object(meta),
Expand Down
8 changes: 8 additions & 0 deletions crates/atlas-codeindex/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ atlas-embed = { path = "../atlas-embed" }
scip = "0.10"
protobuf = "3"

# On macOS the examples embed on the Apple-Silicon GPU, as the app does
# (`src-tauri/Cargo.toml` turns the same feature on for the same target):
# on the CPU, eval_retrieval embeds about 2 chunks a second. Additive over the
# plain dev-dependency above; Linux and Windows builds never see it, and
# `gpu_device()` falls back to the CPU when Metal fails to start.
[target.'cfg(target_os = "macos")'.dev-dependencies]
atlas-embed = { path = "../atlas-embed", features = ["metal"] }

# The workspace lint table is only real for members that opt in —
# without this stanza it is dead configuration (#60).
[lints]
Expand Down
2 changes: 1 addition & 1 deletion crates/atlas-codeindex/eval/questions.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@
{"q":"send a UI action to the window and wait for its reply with a timeout","gold":["src-tauri/src/commands/ui_server/bridge.rs"]}
{"q":"app configuration defaults and per-project overrides","gold":["src-tauri/src/state/atlas_config.rs"]}
{"q":"map memory document ids to vector keys with content hashes","gold":["crates/atlas-memory/src/manifest.rs"]}
{"q":"shared memory sqlite store per git scope","gold":["src-tauri/src/commands/shared_memory.rs"]}
{"q":"shared memory sqlite store per git scope","gold":["src-tauri/src/commands/shared_memory.rs","crates/atlas-memory/src/record.rs"]}
{"q":"spawn a pseudo terminal for a shell","gold":["crates/atlas-terminal/src/lib.rs"]}
{"q":"time out memory retrieval so a tool call never blocks","gold":["src-tauri/src/commands/memory_retrieve.rs"]}
10 changes: 8 additions & 2 deletions crates/atlas-codeindex/examples/build_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
for (reason, n) in ix.status()?.skipped {
println!(" skipped {reason}: {n}");
}
for (reason, dir, n) in atlas_codeindex::skipped_by_dir(&stats.skipped) {
println!(" {reason} {dir}: {n}");
}

let t = Instant::now();
let st = ix.reconcile(&CancelToken::new())?;
Expand All @@ -47,17 +50,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}

let t = Instant::now();
let (hits, page) = ix.find_symbol(&SymbolQuery {
let (hits, page, exact) = ix.find_symbol_with_exact(&SymbolQuery {
query: query.clone(),
limit: 5,
..SymbolQuery::default()
})?;
println!(
"find_symbol {query:?}: {} of {} in {:?}",
"find_symbol {query:?}: {} of {} ({exact} exact) in {:?}",
hits.len(),
page.total,
t.elapsed()
);
if exact == 0 && !hits.is_empty() {
println!(" no exact match for {query:?}; closest by words:");
}
for h in hits {
println!(
" {} {} {}:{}-{}",
Expand Down
Loading
Loading