diff --git a/CHANGELOG.md b/CHANGELOG.md index 83057e2..101c71c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,25 @@ RFC; a release that bumps one says so here. ## [Unreleased] +## [0.2.12] — 2026-09-09 + +- Export the conversation over OTel by default: `attempt hook install` + sets `OTEL_LOG_USER_PROMPTS=1` and `OTEL_LOG_ASSISTANT_RESPONSES=1` for + Claude Code and `log_user_prompt = true` for Codex. The adapter stores the + prompt of a `user_prompt` record and the reply of an `assistant_response` + record as `content` under the capture mode; the sizes become + `x_otel_prompt_chars` / `x_otel_response_chars`. Tool arguments and tool + content remain off. +- New sync profile `messages`: `semantic` plus the conversation — the prompt + of a submitted prompt and the message of a turn stop, an agent message or + an OTel prompt/reply record, secret-redacted on the device. Commands, tool + input, tool output, errors and raw payloads never leave. `attempt sync + profile ` changes a configured peer without re-pairing; `sync.json` + gains `send_messages`. +- The VibeMon migration installers default to `--profile messages` and + create new databases as `local_semantic` (`--metadata-only` opts out); + existing databases keep their mode. + ## [0.2.11] — 2026-09-08 - Resolve OTel session/project identity from filtered metadata, without diff --git a/Cargo.lock b/Cargo.lock index 41d021a..dca8bc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -513,7 +513,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "attempt-hook" -version = "0.2.11" +version = "0.2.12" dependencies = [ "attemptdb-capture", "winresource", @@ -521,7 +521,7 @@ dependencies = [ [[package]] name = "attemptdb" -version = "0.2.11" +version = "0.2.12" dependencies = [ "anyhow", "attemptdb-adapters", @@ -549,7 +549,7 @@ dependencies = [ [[package]] name = "attemptdb-adapters" -version = "0.2.11" +version = "0.2.12" dependencies = [ "attemptdb-core", "serde", @@ -560,7 +560,7 @@ dependencies = [ [[package]] name = "attemptdb-bench" -version = "0.2.11" +version = "0.2.12" dependencies = [ "anyhow", "arrow", @@ -579,7 +579,7 @@ dependencies = [ [[package]] name = "attemptdb-capture" -version = "0.2.11" +version = "0.2.12" dependencies = [ "anyhow", "argon2", @@ -609,7 +609,7 @@ dependencies = [ [[package]] name = "attemptdb-core" -version = "0.2.11" +version = "0.2.12" dependencies = [ "chrono", "crc32c", @@ -624,7 +624,7 @@ dependencies = [ [[package]] name = "attemptdb-mcp" -version = "0.2.11" +version = "0.2.12" dependencies = [ "anyhow", "attemptdb-capture", @@ -641,7 +641,7 @@ dependencies = [ [[package]] name = "attemptdb-project" -version = "0.2.11" +version = "0.2.12" dependencies = [ "attemptdb-core", "serde", @@ -652,7 +652,7 @@ dependencies = [ [[package]] name = "attemptdb-query" -version = "0.2.11" +version = "0.2.12" dependencies = [ "anyhow", "async-trait", @@ -672,7 +672,7 @@ dependencies = [ [[package]] name = "attemptdb-server" -version = "0.2.11" +version = "0.2.12" dependencies = [ "anyhow", "attemptdb-adapters", @@ -696,7 +696,7 @@ dependencies = [ [[package]] name = "attemptdb-storage" -version = "0.2.11" +version = "0.2.12" dependencies = [ "arrow", "attemptdb-core", @@ -718,7 +718,7 @@ dependencies = [ [[package]] name = "attemptdb-ui" -version = "0.2.11" +version = "0.2.12" dependencies = [ "anyhow", "attemptdb-capture", diff --git a/Cargo.toml b/Cargo.toml index a57a09c..5cb92e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ members = [ ] [workspace.package] -version = "0.2.11" +version = "0.2.12" edition = "2024" rust-version = "1.94" license = "Apache-2.0" diff --git a/PROGRESS.md b/PROGRESS.md index fb9a2fe..4237779 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,6 +2,40 @@ Execution log for `TODO.md`. Newest session first. Read this before working. +## 2026-09-09 — the conversation leaves the device by default + +The owner's decision: every VibeMon install must collect and upload both +the user's prompts and the agent's replies. Until now the installer set +`OTEL_LOG_USER_PROMPTS=0`, so Claude Code exported `` for both +records, and the `semantic` profile kept every content field local; the +server ceiling was `metadata_only`. The natural language of a session existed +only in the local hook rows. + +Three layers changed, each with a test. The OTel adapter stores the prompt of +a `user_prompt` record and the reply of an `assistant_response` record as +content under the capture mode (`x_otel_prompt_chars` / `x_otel_response_chars` +in attrs; a `metadata_only` database keeps only the sizes). `attempt hook +install` now sets `OTEL_LOG_USER_PROMPTS=1`, `OTEL_LOG_ASSISTANT_RESPONSES=1` +and Codex `log_user_prompt = true`, tool details and content still off. A new +sync profile `messages` (`PeerConfig.send_messages`) uploads only the prompt +and message fields of prompt, turn-stop, agent-message and OTel prompt/reply +events, secret-redacted; `keep_messages_only` clears command, error, tool +input, tool output, extra and raw on every other event. The round-trip test +starts a server with a `local_semantic` ceiling and checks the conversation +arrives while a canary in `command`, `tool_output` and `raw` does not, then a +`metadata_only` ceiling strips it all. `attempt sync profile ` switches a +configured peer without re-pairing. The VibeMon installers default to +`--profile messages` and `local_semantic` (`--metadata-only` opts out) and +raise an existing metadata-only database's mode. `deploy/fly.toml` sets the +production ceiling to `local_semantic`; the deployed health reports it. + +Owner's machine: 0.2.12 installed over 0.2.11 (backup in +`~/.vibemon-backup/attempt-0.2.11`), daemon restarted, hooks reinstalled with +the new env, peer switched to `messages`, first sync uploaded. Agents started +before the reinstall keep exporting `` until restarted. Known gap: +the daemon's self-update does not re-run `hook install`, so devices installed +before this release upload metadata only until the user reconnects. + ## 2026-09-08 — keep OTel identity lookup off historical content The owner's installed-settings probe reached production for Codex, but diff --git a/README.md b/README.md index f99aa3b..6333586 100644 --- a/README.md +++ b/README.md @@ -206,9 +206,12 @@ store, and not a claim that inferred intent is ground truth. `--send-content` is an explicit flag — and even then credentials (issuer-format tokens, private keys, JWTs) are redacted on the device first. The server enforces its own ceiling regardless of what a client sends. - `--profile metadata_only|semantic|full` names what leaves; `attempt sync - add ` uploads to a second server under its own profile and - cursor, and `attempt sync connect vibemon` is the hosted companion's URL. + `--profile metadata_only|semantic|messages|full` names what leaves + (`messages` adds only the conversation — your prompts and the agent's + replies, secret-redacted — and keeps commands and tool output local); + `attempt sync profile ` changes it later, `attempt sync add + ` uploads to a second server under its own profile and cursor, and + `attempt sync connect vibemon` is the hosted companion's URL. - **Inferences travel only with their provenance.** `--send-inferences` (off by default) uploads attempts, handoffs, work units, and decisions — each with the event ids it was derived from, its confidence, and the algorithm diff --git a/crates/attempt/src/cmd_sync.rs b/crates/attempt/src/cmd_sync.rs index a9e2414..14ae1e2 100644 --- a/crates/attempt/src/cmd_sync.rs +++ b/crates/attempt/src/cmd_sync.rs @@ -49,6 +49,15 @@ pub enum SyncCmd { #[arg(long)] json: bool, }, + /// Change what leaves this device for a peer without re-pairing it: metadata_only, semantic, messages or full. + Profile { + /// The new profile. + #[arg(value_name = "PROFILE", value_parser = parse_profile)] + profile: SyncProfile, + /// Which peer to change. + #[arg(long, value_name = "NAME", default_value = DEFAULT_PEER)] + peer: String, + }, /// Forget a peer (`default` when it is the only one). The local database is untouched. Disconnect { /// Required when more than one peer is configured. @@ -88,12 +97,15 @@ pub struct PeerArgs { /// A label for this device on the server (with --pair). #[arg(long, value_name = "TEXT")] pub label: Option, - /// What leaves the device: metadata_only, semantic (default: adds inferences with evidence ids and confidence, never prompts or output), full (adds content, secret-redacted). + /// What leaves the device: metadata_only, semantic (default: adds inferences with evidence ids and confidence, never prompts or output), messages (adds your prompts and the agent's messages, secret-redacted; commands and tool output stay local), full (adds all content, secret-redacted). #[arg(long, value_name = "PROFILE", value_parser = parse_profile)] pub profile: Option, /// Also upload content (prompts, commands, tool output), on top of the profile. #[arg(long)] pub send_content: bool, + /// Also upload the conversation (your prompts and the agent's messages), on top of the profile. + #[arg(long)] + pub send_messages: bool, /// Also upload this device's inferences (attempts, handoffs, work units, decisions), on top of the profile. #[arg(long)] pub send_inferences: bool, @@ -170,6 +182,33 @@ pub fn run(cli: &Cli, args: &SyncArgs) -> Result { Ok(ExitCode::SUCCESS) } SyncCmd::Remove { name } => remove_peer(&config_dir, name), + SyncCmd::Profile { profile, peer } => { + let name = validate_peer_name(peer)?; + let mut cfg = SyncConfig::load(&config_dir)?.unwrap_or_default(); + let names = cfg.names_list(); + let Some(p) = cfg.peers.get_mut(&name) else { + bail!("peer `{name}` is not configured (peers: {names})"); + }; + let before = p.profile(); + p.set_profile(*profile); + let after = p.profile(); + cfg.save(&config_dir)?; + if before == after { + println!( + "peer {name}: profile {after} — {} (unchanged)", + after.summary() + ); + } else { + println!( + "peer {name}: profile {before} → {after} — {}", + after.summary() + ); + println!( + "the daemon picks this up on its next tick; `attempt sync now` uploads at once" + ); + } + Ok(ExitCode::SUCCESS) + } SyncCmd::Now { peer, json } => { let cfg = load_connected(&config_dir)?; // Hooks only spool when no daemon is running. The uploader is @@ -413,13 +452,18 @@ fn add_peer( } _ => bail!("give --key or --pair "), }; - let (send_content, send_inferences) = - SyncProfile::resolve(a.profile, a.send_content, a.send_inferences); + let (send_content, send_inferences, send_messages) = SyncProfile::resolve( + a.profile, + a.send_content, + a.send_inferences, + a.send_messages, + ); let peer = PeerConfig { url: url.clone(), key, send_content, send_inferences, + send_messages, batch_events: DEFAULT_BATCH_EVENTS, interval_secs: a.interval, include: a.include.iter().map(|s| s.trim().to_string()).collect(), @@ -523,6 +567,7 @@ fn peer_json(p: &PeerConfig) -> Value { "profile": p.profile(), "send_content": p.send_content, "send_inferences": p.send_inferences, + "send_messages": p.send_messages, "interval_secs": p.interval_secs, "include": p.include, "exclude": p.exclude, diff --git a/crates/attemptdb-adapters/src/otel.rs b/crates/attemptdb-adapters/src/otel.rs index 2cb40a0..768c271 100644 --- a/crates/attemptdb-adapters/src/otel.rs +++ b/crates/attemptdb-adapters/src/otel.rs @@ -244,6 +244,18 @@ fn put_number(event: &mut Event, attrs: &Map, dest: &str, names: } } +/// Claude Code truncates exported text at 60 KB; this is the ceiling on what +/// one record may carry into `content`, counted in characters so a multibyte +/// message is never cut inside a code point. +pub const MAX_MESSAGE_CHARS: usize = 65_536; + +fn truncate_chars(s: &str, max: usize) -> String { + match s.char_indices().nth(max) { + Some((i, _)) => format!("{}…", &s[..i]), + None => s.to_string(), + } +} + fn put_text(event: &mut Event, attrs: &Map, dest: &str, names: &[&str]) { if let Some(v) = text_attr(attrs, names) { event.attrs.insert(dest.into(), json!(v)); @@ -405,9 +417,45 @@ fn make_event( ), ("x_otel_attempt", &["attempt", "retry_count"][..]), ("x_otel_event_sequence", &["event.sequence"][..]), + // The size of what was said, whether or not the text itself was + // exported: a prompt or reply's length is metadata. + ("x_otel_prompt_chars", &["prompt_length"][..]), + ("x_otel_response_chars", &["response_length"][..]), ] { put_number(&mut event, &attrs, dest, names); } + // What was said. Claude Code exports the user's prompt on `user_prompt` + // and its own reply on `assistant_response` (Codex: `codex.user_prompt`) + // only when the provider is configured to (`OTEL_LOG_USER_PROMPTS`, + // `OTEL_LOG_ASSISTANT_RESPONSES`, `log_user_prompt`); otherwise the field + // reads ``. The text is content, never metadata: it lives in + // `content` under the capture mode like a hook's prompt, and the + // `messages` sync profile is what lets it leave the device. + if signal == Signal::Logs && ctx.capture_mode.persists_content_locally() { + let spoken = |key: &str| { + attrs + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty() && *s != "") + .map(|s| truncate_chars(s, MAX_MESSAGE_CHARS)) + }; + let mut content = attemptdb_core::event::EventContent::default(); + match name { + "user_prompt" | "claude_code.user_prompt" | "codex.user_prompt" => { + content.prompt = spoken("prompt"); + } + "assistant_response" + | "claude_code.assistant_response" + | "codex.assistant_response" => { + content.message = spoken("response"); + } + _ => {} + } + if !content.is_empty() { + event.content = Some(content); + } + } for (dest, names) in [ ("x_otel_request_id", &["request_id", "response_id"][..]), ("x_otel_client_request_id", &["client_request_id"][..]), diff --git a/crates/attemptdb-adapters/tests/otel.rs b/crates/attemptdb-adapters/tests/otel.rs index c1a847e..5e09282 100644 --- a/crates/attemptdb-adapters/tests/otel.rs +++ b/crates/attemptdb-adapters/tests/otel.rs @@ -263,3 +263,85 @@ fn invalid_records_are_reported_and_oversized_batches_fail() { json!(vec![json!({"timeUnixNano":"1"}); MAX_RECORDS + 1]); assert!(normalise(&ctx, Provider::Codex, Signal::Logs, &payload).is_err()); } + +#[test] +fn exported_prompt_and_reply_become_content_under_the_capture_mode_never_metadata() { + let prompt = logs( + "claude-code", + "claude_code.user_prompt", + vec![ + attr("session.id", json!({"stringValue":"fixture-session"})), + attr("event.name", json!({"stringValue":"user_prompt"})), + attr("prompt_length", json!({"intValue":"27"})), + attr( + "prompt", + json!({"stringValue":"make the retries idempotent"}), + ), + ], + ); + let reply = logs( + "claude-code", + "claude_code.assistant_response", + vec![ + attr("session.id", json!({"stringValue":"fixture-session"})), + attr("event.name", json!({"stringValue":"assistant_response"})), + attr("response_length", json!({"intValue":"38"})), + attr( + "response", + json!({"stringValue":"I will read the webhook handler first."}), + ), + attr("model", json!({"stringValue":"claude-sonnet-4-6"})), + ], + ); + let redacted = logs( + "claude-code", + "claude_code.assistant_response", + vec![ + attr("session.id", json!({"stringValue":"fixture-session"})), + attr("event.name", json!({"stringValue":"assistant_response"})), + attr("response_length", json!({"intValue":"38"})), + attr("response", json!({"stringValue":""})), + ], + ); + // Local content mode: the text is content, its size is metadata. + let ctx = context(CaptureMode::LocalSemantic); + let e = &normalise(&ctx, Provider::ClaudeCode, Signal::Logs, &prompt) + .unwrap() + .events[0]; + assert_eq!( + e.content.as_ref().unwrap().prompt.as_deref(), + Some("make the retries idempotent") + ); + assert_eq!(e.attrs["x_otel_prompt_chars"], 27); + assert!( + !serde_json::to_string(&e.attrs) + .unwrap() + .contains("idempotent") + ); + assert!(e.is_telemetry()); + let e = &normalise(&ctx, Provider::ClaudeCode, Signal::Logs, &reply) + .unwrap() + .events[0]; + assert_eq!( + e.content.as_ref().unwrap().message.as_deref(), + Some("I will read the webhook handler first.") + ); + assert_eq!(e.attrs["x_otel_response_chars"], 38); + assert!(!serde_json::to_string(&e.attrs).unwrap().contains("webhook")); + // A provider-side redaction leaves no content, only the size. + let e = &normalise(&ctx, Provider::ClaudeCode, Signal::Logs, &redacted) + .unwrap() + .events[0]; + assert!(e.content.is_none()); + assert_eq!(e.attrs["x_otel_response_chars"], 38); + // Metadata-only capture: the text never lands anywhere. + let ctx = context(CaptureMode::MetadataOnly); + for payload in [&prompt, &reply] { + let e = &normalise(&ctx, Provider::ClaudeCode, Signal::Logs, payload) + .unwrap() + .events[0]; + assert!(e.content.is_none() && e.raw.is_none()); + let text = serde_json::to_string(e).unwrap(); + assert!(!text.contains("idempotent") && !text.contains("webhook")); + } +} diff --git a/crates/attemptdb-capture/src/otel_install.rs b/crates/attemptdb-capture/src/otel_install.rs index 90979ef..e113883 100644 --- a/crates/attemptdb-capture/src/otel_install.rs +++ b/crates/attemptdb-capture/src/otel_install.rs @@ -94,7 +94,13 @@ fn claude_values(config: &ReceiverConfig) -> Map { ("OTEL_METRICS_EXPORTER", "otlp"), ("OTEL_LOGS_EXPORTER", "otlp"), ("OTEL_TRACES_EXPORTER", "otlp"), - ("OTEL_LOG_USER_PROMPTS", "0"), + // The conversation is exported: the user's prompt on `user_prompt` + // and the reply on `assistant_response`. Both land in `content` + // under the local capture mode and leave only under the `messages` + // (or `full`) sync profile. Commands, tool arguments and tool output + // stay off: they are captured by the hooks and never exported here. + ("OTEL_LOG_USER_PROMPTS", "1"), + ("OTEL_LOG_ASSISTANT_RESPONSES", "1"), ("OTEL_LOG_TOOL_DETAILS", "0"), ("OTEL_LOG_TOOL_CONTENT", "0"), ("OTEL_METRICS_INCLUDE_SESSION_ID", "true"), @@ -124,7 +130,8 @@ fn claude_values(config: &ReceiverConfig) -> Map { fn codex_values(config: &ReceiverConfig) -> Map { let mut values = Map::new(); - values.insert("log_user_prompt".into(), json!(false)); + // Codex exports the prompt text only; it has no reply event. + values.insert("log_user_prompt".into(), json!(true)); for (key, signal) in [ ("exporter", "logs"), ("metrics_exporter", "metrics"), @@ -423,16 +430,19 @@ mod tests { } #[test] - fn claude_default_is_private_idempotent_and_reversible() { + fn claude_default_exports_the_conversation_but_no_tool_content_idempotent_and_reversible() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("settings.json"); - let original = json!({"env":{"OTHER_SETTING":"kept","OTEL_LOG_USER_PROMPTS":"1"},"hooks":{"Stop":[{"hooks":[{"type":"command","command":"other-hook"}]}]}}); + let original = json!({"env":{"OTHER_SETTING":"kept","OTEL_LOG_USER_PROMPTS":"0"},"hooks":{"Stop":[{"hooks":[{"type":"command","command":"other-hook"}]}]}}); std::fs::write(&path, original.to_string()).unwrap(); assert!(configure(AgentKind::ClaudeCode, &path, &config(), false, false).unwrap()); let first = std::fs::read(&path).unwrap(); let installed: Value = serde_json::from_slice(&first).unwrap(); assert_eq!(installed["hooks"], original["hooks"]); - assert_eq!(installed["env"]["OTEL_LOG_USER_PROMPTS"], "0"); + assert_eq!(installed["env"]["OTEL_LOG_USER_PROMPTS"], "1"); + assert_eq!(installed["env"]["OTEL_LOG_ASSISTANT_RESPONSES"], "1"); + assert_eq!(installed["env"]["OTEL_LOG_TOOL_DETAILS"], "0"); + assert_eq!(installed["env"]["OTEL_LOG_TOOL_CONTENT"], "0"); assert_eq!( installed["env"]["OTEL_EXPORTER_OTLP_LOGS_PROTOCOL"], "http/json" @@ -479,7 +489,7 @@ mod tests { let doc = installed.parse::().unwrap(); assert_eq!(doc["hooks"]["state"]["example"].as_str(), Some("trusted")); assert_eq!(doc["otel"]["environment"].as_str(), Some("development")); - assert_eq!(doc["otel"]["log_user_prompt"].as_bool(), Some(false)); + assert_eq!(doc["otel"]["log_user_prompt"].as_bool(), Some(true)); assert_eq!( doc["otel"]["exporter"]["otlp-http"]["protocol"].as_str(), Some("json") @@ -548,12 +558,14 @@ mod tests { assert!(configure(AgentKind::ClaudeCode, &path, &config(), false, false).unwrap()); assert!(!read_ledger(&path).unwrap().pending); let mut doc: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); - doc["env"]["OTEL_LOG_USER_PROMPTS"] = json!("1"); + // The user turns tool-argument logging on by hand: an owned key whose + // value no longer matches what the installer wrote. + doc["env"]["OTEL_LOG_TOOL_DETAILS"] = json!("1"); std::fs::write(&path, doc.to_string()).unwrap(); assert!(configure(AgentKind::ClaudeCode, &path, &config(), false, false).is_err()); assert!(configure(AgentKind::ClaudeCode, &path, &config(), true, false).unwrap()); let restored: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); - assert_eq!(restored["env"]["OTEL_LOG_USER_PROMPTS"], "1"); + assert_eq!(restored["env"]["OTEL_LOG_TOOL_DETAILS"], "1"); } #[test] diff --git a/crates/attemptdb-capture/src/sync.rs b/crates/attemptdb-capture/src/sync.rs index aa319c0..f97e739 100644 --- a/crates/attemptdb-capture/src/sync.rs +++ b/crates/attemptdb-capture/src/sync.rs @@ -22,7 +22,7 @@ use crate::locator::Locator; use anyhow::{Context, Result, anyhow, bail}; -use attemptdb_core::{CaptureMode, Event, EventId, Timestamp, secrets}; +use attemptdb_core::{CaptureMode, Event, EventId, EventKind, Timestamp, secrets}; use attemptdb_storage::{Database, OpenOptions}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -88,15 +88,21 @@ pub enum SyncProfile { /// confidence, and algorithm version. Content still stays local, so the /// inferences' `objective`/`rationale` are removed before upload. Semantic, + /// `semantic` plus the conversation's natural language: the user's + /// prompts and the agent's messages (secret-redacted on the device). + /// Commands, tool input and tool output stay local. The VibeMon + /// installer's default. + Messages, /// Metadata, inferences, and content (secret-redacted on the device; /// the server's capture-mode ceiling still applies). Full, } impl SyncProfile { - pub const ALL: [SyncProfile; 3] = [ + pub const ALL: [SyncProfile; 4] = [ SyncProfile::MetadataOnly, SyncProfile::Semantic, + SyncProfile::Messages, SyncProfile::Full, ]; @@ -104,41 +110,47 @@ impl SyncProfile { match self { SyncProfile::MetadataOnly => "metadata_only", SyncProfile::Semantic => "semantic", + SyncProfile::Messages => "messages", SyncProfile::Full => "full", } } - /// `(send_content, send_inferences)`. - pub fn flags(self) -> (bool, bool) { + /// `(send_content, send_inferences, send_messages)`. + pub fn flags(self) -> (bool, bool, bool) { match self { - SyncProfile::MetadataOnly => (false, false), - SyncProfile::Semantic => (false, true), - SyncProfile::Full => (true, true), + SyncProfile::MetadataOnly => (false, false, false), + SyncProfile::Semantic => (false, true, false), + SyncProfile::Messages => (false, true, true), + SyncProfile::Full => (true, true, true), } } - /// The profile that names a flag pair. `send_content` without - /// `send_inferences` has no name of its own; it reports `full` because - /// content is the stronger signal — a reader must never see - /// `metadata_only` or `semantic` on a peer that receives content. - pub fn from_flags(send_content: bool, send_inferences: bool) -> Self { - match (send_content, send_inferences) { - (false, false) => SyncProfile::MetadataOnly, - (false, true) => SyncProfile::Semantic, - (true, _) => SyncProfile::Full, + /// The profile that names a flag combination. `send_content` covers + /// everything and reports `full` whatever the other flags say — a reader + /// must never see `metadata_only`, `semantic` or `messages` on a peer + /// that receives commands and tool output. `send_messages` without + /// inferences has no name of its own and reports `messages`: the + /// conversation is the stronger signal. + pub fn from_flags(send_content: bool, send_inferences: bool, send_messages: bool) -> Self { + match (send_content, send_inferences, send_messages) { + (true, _, _) => SyncProfile::Full, + (false, _, true) => SyncProfile::Messages, + (false, true, false) => SyncProfile::Semantic, + (false, false, false) => SyncProfile::MetadataOnly, } } - /// Flags for a command line: the profile (`metadata_only` when none is - /// given) with the explicit `--send-content` / `--send-inferences` - /// switches on top. The switches only ever add. + /// Flags for a command line: the profile (`semantic` when none is + /// given) with the explicit `--send-content` / `--send-inferences` / + /// `--send-messages` switches on top. The switches only ever add. pub fn resolve( profile: Option, send_content: bool, send_inferences: bool, - ) -> (bool, bool) { - let (c, i) = profile.unwrap_or(SyncProfile::Semantic).flags(); - (c || send_content, i || send_inferences) + send_messages: bool, + ) -> (bool, bool, bool) { + let (c, i, m) = profile.unwrap_or(SyncProfile::Semantic).flags(); + (c || send_content, i || send_inferences, m || send_messages) } /// One phrase for humans. @@ -148,6 +160,9 @@ impl SyncProfile { SyncProfile::Semantic => { "metadata and inferences (with evidence ids and confidence); content stays local" } + SyncProfile::Messages => { + "metadata, inferences, and the conversation (your prompts and the agent's messages, secrets redacted); commands and tool output stay local" + } SyncProfile::Full => { "metadata, inferences, and content (secrets redacted on this device)" } @@ -170,7 +185,9 @@ impl FromStr for SyncProfile { .into_iter() .find(|p| p.as_str().eq_ignore_ascii_case(s) || p.as_str().replace('_', "-") == s) .ok_or_else(|| { - anyhow!("unknown profile `{s}`: expected metadata_only, semantic, or full") + anyhow!( + "unknown profile `{s}`: expected metadata_only, semantic, messages, or full" + ) }) } } @@ -196,6 +213,13 @@ pub struct PeerConfig { /// fields (`objective`, `rationale`) are removed first. #[serde(default)] pub send_inferences: bool, + /// Upload the conversation's natural language — `content.prompt` of a + /// submitted prompt and `content.message` of an agent message or turn + /// stop, plus the same fields of OTel `user_prompt` / + /// `assistant_response` records — and nothing else content-bearing. + /// Commands, tool input and tool output never leave under this flag. + #[serde(default)] + pub send_messages: bool, #[serde(default = "default_batch")] pub batch_events: usize, #[serde(default = "default_interval")] @@ -219,6 +243,7 @@ impl PeerConfig { key: key.into(), send_content: false, send_inferences: false, + send_messages: false, batch_events: DEFAULT_BATCH_EVENTS, interval_secs: DEFAULT_INTERVAL_SECS, include: vec![], @@ -228,14 +253,20 @@ impl PeerConfig { /// The name of this peer's flag combination (see [`SyncProfile::from_flags`]). pub fn profile(&self) -> SyncProfile { - SyncProfile::from_flags(self.send_content, self.send_inferences) + SyncProfile::from_flags(self.send_content, self.send_inferences, self.send_messages) } - /// Set both flags from a profile. + /// Set the flags from a profile. pub fn set_profile(&mut self, profile: SyncProfile) { - let (c, i) = profile.flags(); + let (c, i, m) = profile.flags(); self.send_content = c; self.send_inferences = i; + self.send_messages = m; + } + + /// Whether any content-bearing field may leave under this peer. + pub fn sends_any_content(&self) -> bool { + self.send_content || self.send_messages } pub fn interval(&self) -> Duration { @@ -813,7 +844,7 @@ pub fn upload_once_with( /// the profile sends it: the encrypted blobs are one file each, and a /// metadata upload never opens them. fn events_after(db: &Database, cfg: &PeerConfig, after: u64) -> Result> { - let reader = cfg.send_content.then(|| { + let reader = cfg.sends_any_content().then(|| { attemptdb_storage::blobs::BlobReader::new( db.blob_store(), db.key_provider().map(|k| k.as_ref()), @@ -863,6 +894,64 @@ pub fn upload_all( .collect() } +/// Is this the record of something a person or an agent *said*? Only those +/// events keep their conversation text under `send_messages`. +pub fn is_message_event(e: &Event) -> bool { + match e.kind { + EventKind::PromptSubmitted | EventKind::AgentMessage | EventKind::TurnStopped => true, + EventKind::Unknown => { + e.attrs.get("source").and_then(Value::as_str) == Some("otel") + && matches!( + e.provider_event_name.as_str(), + "user_prompt" + | "claude_code.user_prompt" + | "assistant_response" + | "claude_code.assistant_response" + | "codex.user_prompt" + | "codex.assistant_response" + ) + } + _ => false, + } +} + +/// The `messages` profile in one place: keep `content.prompt` and +/// `content.message` of a message event, drop every other content-bearing +/// field and the raw payload, and strip everything from any other event. +/// Returns whether the event still carries text (the caller redacts it). +pub fn keep_messages_only(e: &mut Event) -> bool { + e.raw = None; + if !is_message_event(e) { + e.capture_mode = CaptureMode::MetadataOnly; + e.apply_capture_mode(); + return false; + } + if let Some(c) = &mut e.content { + c.command = None; + c.error = None; + c.tool_input = None; + c.tool_output = None; + c.extra.clear(); + if c.prompt.as_deref().is_some_and(str::is_empty) { + c.prompt = None; + } + if c.message.as_deref().is_some_and(str::is_empty) { + c.message = None; + } + if c.is_empty() { + e.content = None; + } + } + if e.content.is_none() { + e.capture_mode = CaptureMode::MetadataOnly; + e.apply_capture_mode(); + return false; + } + // The server clamps to its own ceiling; the batch says what it carries. + e.capture_mode = CaptureMode::LocalSemantic; + true +} + fn upload_events( agent: &ureq::Agent, cfg: &PeerConfig, @@ -887,7 +976,7 @@ fn upload_events( } return Ok(report); } - let capture_mode = if cfg.send_content { + let capture_mode = if cfg.sends_any_content() { CaptureMode::LocalSemantic } else { CaptureMode::MetadataOnly @@ -907,6 +996,8 @@ fn upload_events( // Content leaves only on explicit opt-in, and never with // a credential in it (RFC 0006 §5). redacted += e.redact_secrets(); + } else if cfg.send_messages && keep_messages_only(&mut e) { + redacted += e.redact_secrets(); } else { e.capture_mode = CaptureMode::MetadataOnly; e.apply_capture_mode(); @@ -1518,32 +1609,109 @@ mod tests { // -- profiles ----------------------------------------------------------- + fn spoken(kind: EventKind, name: &str, source_otel: bool) -> Event { + let device = attemptdb_core::DeviceId::new(); + let mut e = Event::new( + device, + attemptdb_core::event::Provider::ClaudeCode, + name, + kind, + attemptdb_core::ProjectRef::derive("/home/dev/example", None, &device), + "fixture-session", + CaptureMode::LocalSemantic, + "test", + ); + if source_otel { + e.attrs.insert("source".into(), json!("otel")); + } + let mut c = attemptdb_core::event::EventContent { + prompt: Some("make the retries idempotent".into()), + message: Some("I will read the webhook handler first.".into()), + command: Some("npm test -- CANARY_COMMAND".into()), + error: Some("CANARY_ERROR".into()), + tool_input: Some(json!({"command":"CANARY_INPUT"})), + tool_output: Some(json!("CANARY_OUTPUT")), + ..Default::default() + }; + c.extra + .insert("elicitation_content".into(), json!("CANARY_EXTRA")); + e.content = Some(c); + e.raw = Some(json!({"prompt":"CANARY_RAW"})); + e + } + + #[test] + fn messages_profile_keeps_only_what_was_said() { + // A hook prompt, a turn stop, an agent message and an OTel reply keep + // their text; everything else content-bearing is gone, and so is raw. + for (kind, name, otel) in [ + (EventKind::PromptSubmitted, "UserPromptSubmit", false), + (EventKind::TurnStopped, "Stop", false), + (EventKind::AgentMessage, "transcript", false), + (EventKind::Unknown, "assistant_response", true), + (EventKind::Unknown, "user_prompt", true), + ] { + let mut e = spoken(kind, name, otel); + assert!(keep_messages_only(&mut e), "{name}"); + let text = serde_json::to_string(&e).unwrap(); + assert!(text.contains("make the retries idempotent"), "{name}"); + assert!( + text.contains("I will read the webhook handler first."), + "{name}" + ); + assert!(!text.contains("CANARY"), "{name}: {text}"); + assert_eq!(e.capture_mode, CaptureMode::LocalSemantic); + assert!(e.raw.is_none()); + } + // A tool call is not a message: nothing content-bearing leaves. + let mut e = spoken(EventKind::ToolCallFinished, "PostToolUse", false); + assert!(!keep_messages_only(&mut e)); + assert!(e.content.is_none() && e.raw.is_none()); + assert_eq!(e.capture_mode, CaptureMode::MetadataOnly); + // An OTel record that is not a prompt or reply is metadata only. + let mut e = spoken(EventKind::Unknown, "api_request", true); + assert!(!keep_messages_only(&mut e)); + assert!(e.content.is_none()); + // A message event whose text was redacted upstream carries nothing. + let mut e = spoken(EventKind::Unknown, "assistant_response", true); + e.content.as_mut().unwrap().message = None; + e.content.as_mut().unwrap().prompt = None; + assert!(!keep_messages_only(&mut e)); + assert!(e.content.is_none()); + } + #[test] fn profile_flag_table() { - // (send_content, send_inferences) → profile; all four combinations. + // (send_content, send_inferences, send_messages) → profile. let table = [ - (false, false, SyncProfile::MetadataOnly), - (false, true, SyncProfile::Semantic), - (true, true, SyncProfile::Full), - // Content without inferences has no name; content is the - // stronger signal, so it reports `full`. - (true, false, SyncProfile::Full), + (false, false, false, SyncProfile::MetadataOnly), + (false, true, false, SyncProfile::Semantic), + (false, true, true, SyncProfile::Messages), + // Messages without inferences have no name; the conversation is + // the stronger signal, so it reports `messages`. + (false, false, true, SyncProfile::Messages), + (true, true, true, SyncProfile::Full), + // Content without inferences or messages has no name; content is + // the stronger signal, so it reports `full`. + (true, false, false, SyncProfile::Full), + (true, true, false, SyncProfile::Full), ]; - for (content, inferences, expected) in table { + for (content, inferences, messages, expected) in table { assert_eq!( - SyncProfile::from_flags(content, inferences), + SyncProfile::from_flags(content, inferences, messages), expected, - "({content}, {inferences})" + "({content}, {inferences}, {messages})" ); let mut peer = PeerConfig::new("https://x", "k"); peer.send_content = content; peer.send_inferences = inferences; + peer.send_messages = messages; assert_eq!(peer.profile(), expected); } // Named profiles round-trip through their flags. for p in SyncProfile::ALL { - let (c, i) = p.flags(); - assert_eq!(SyncProfile::from_flags(c, i), p); + let (c, i, m) = p.flags(); + assert_eq!(SyncProfile::from_flags(c, i, m), p); let mut peer = PeerConfig::new("https://x", "k"); peer.set_profile(p); assert_eq!(peer.profile(), p); @@ -1569,31 +1737,53 @@ mod tests { #[test] fn profile_resolution_with_explicit_overrides() { // No profile given: `semantic` (the 2026-08-31 decision) — - // inferences travel, content does not. - assert_eq!(SyncProfile::resolve(None, false, false), (false, true)); - assert_eq!(SyncProfile::resolve(None, true, false), (true, true)); - assert_eq!(SyncProfile::resolve(None, false, true), (false, true)); + // inferences travel, content and messages do not. + assert_eq!( + SyncProfile::resolve(None, false, false, false), + (false, true, false) + ); assert_eq!( - SyncProfile::resolve(Some(SyncProfile::MetadataOnly), false, false), - (false, false) + SyncProfile::resolve(None, true, false, false), + (true, true, false) ); assert_eq!( - SyncProfile::resolve(Some(SyncProfile::Semantic), false, false), - (false, true) + SyncProfile::resolve(None, false, true, false), + (false, true, false) ); assert_eq!( - SyncProfile::resolve(Some(SyncProfile::Semantic), true, false), - (true, true), + SyncProfile::resolve(None, false, false, true), + (false, true, true) + ); + assert_eq!( + SyncProfile::resolve(Some(SyncProfile::MetadataOnly), false, false, false), + (false, false, false) + ); + assert_eq!( + SyncProfile::resolve(Some(SyncProfile::Semantic), false, false, false), + (false, true, false) + ); + assert_eq!( + SyncProfile::resolve(Some(SyncProfile::Semantic), true, false, false), + (true, true, false), "--send-content on top of semantic" ); assert_eq!( - SyncProfile::resolve(Some(SyncProfile::Full), false, false), - (true, true) + SyncProfile::resolve(Some(SyncProfile::Semantic), false, false, true), + (false, true, true), + "--send-messages on top of semantic is the messages profile" + ); + assert_eq!( + SyncProfile::resolve(Some(SyncProfile::Messages), false, false, false), + (false, true, true) + ); + assert_eq!( + SyncProfile::resolve(Some(SyncProfile::Full), false, false, false), + (true, true, true) ); + // The switches only ever add: a profile cannot be narrowed by them. assert_eq!( - SyncProfile::resolve(Some(SyncProfile::MetadataOnly), false, true), - (false, true), - "switches only add" + SyncProfile::resolve(Some(SyncProfile::Full), false, false, false), + SyncProfile::Full.flags() ); } diff --git a/crates/attemptdb-capture/tests/sync.rs b/crates/attemptdb-capture/tests/sync.rs index 05f35dc..6df12d7 100644 --- a/crates/attemptdb-capture/tests/sync.rs +++ b/crates/attemptdb-capture/tests/sync.rs @@ -64,6 +64,15 @@ struct ServerHandle { } async fn start_server(root: &Path, device: DeviceId, max_open: usize) -> ServerHandle { + start_server_with(root, device, max_open, CaptureMode::MetadataOnly).await +} + +async fn start_server_with( + root: &Path, + device: DeviceId, + max_open: usize, + ceiling: CaptureMode, +) -> ServerHandle { let keys = root.join("keys.json"); std::fs::write( &keys, @@ -78,6 +87,7 @@ async fn start_server(root: &Path, device: DeviceId, max_open: usize) -> ServerH keys_file: keys, max_open, body_limit: 256 * 1024, + capture_mode: ceiling, ..Default::default() }) .await @@ -257,6 +267,90 @@ async fn send_content_is_an_explicit_opt_in_and_the_server_still_has_the_last_wo server.stop().await; } +/// One prompt, one tool call, one turn stop and one OTel reply, each with +/// every content field filled: what the `messages` profile must and must +/// not let through. +fn conversation(device: DeviceId) -> Vec { + let mut out = Vec::new(); + let mut push = |name: &str, kind: EventKind, otel: bool| { + let mut ev = Event::new( + device, + Provider::ClaudeCode, + name, + kind, + ProjectRef::derive("/home/dev/work/repo", None, &device), + "session-talk", + CaptureMode::LocalSemantic, + "sync-test/0.1", + ); + if otel { + ev.attrs.insert("source".into(), json!("otel")); + } + ev.content = Some(EventContent { + prompt: Some("make the retries idempotent".into()), + message: Some("I will read the webhook handler first.".into()), + command: Some("npm test -- CANARY_COMMAND".into()), + tool_output: Some(json!("CANARY_OUTPUT")), + ..Default::default() + }); + ev.raw = Some(json!({"prompt": "CANARY_RAW"})); + out.push(ev); + }; + push("UserPromptSubmit", EventKind::PromptSubmitted, false); + push("PostToolUse", EventKind::ToolCallFinished, false); + push("Stop", EventKind::TurnStopped, false); + push("assistant_response", EventKind::Unknown, true); + out +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn messages_profile_uploads_only_the_conversation_and_the_server_keeps_it() { + let tmp = tempfile::tempdir().unwrap(); + let (locator, device) = local_db(tmp.path()); + write_events(&locator, conversation(device)); + let server = start_server_with(tmp.path(), device, 4, CaptureMode::LocalSemantic).await; + let c = peer(&server.url, SyncProfile::Messages); + let r = upload(&locator, &c).await.unwrap(); + assert_eq!(r.accepted, 4); + assert_eq!( + r.stripped_content, 0, + "the ceiling allows content; nothing is stripped" + ); + let stored = server.tenant_events(); + assert_eq!(stored.len(), 4); + let text = serde_json::to_string(&stored).unwrap(); + assert!(text.contains("make the retries idempotent")); + assert!(text.contains("I will read the webhook handler first.")); + assert!( + !text.contains("CANARY"), + "commands, tool output and raw never leave: {text}" + ); + for ev in &stored { + match ev.kind { + EventKind::ToolCallFinished => assert!(ev.content.is_none() && ev.raw.is_none()), + _ => { + let c = ev.content.as_ref().expect("message events keep their text"); + assert!(c.command.is_none() && c.tool_output.is_none() && c.extra.is_empty()); + assert!(ev.raw.is_none()); + } + } + } + server.stop().await; + + // Under a metadata_only ceiling the same upload persists no text at all. + let tmp = tempfile::tempdir().unwrap(); + let (locator, device) = local_db(tmp.path()); + write_events(&locator, conversation(device)); + let server = start_server(tmp.path(), device, 4).await; + let r = upload(&locator, &peer(&server.url, SyncProfile::Messages)) + .await + .unwrap(); + assert_eq!(r.accepted, 4); + assert_eq!(r.stripped_content, 3); + assert!(server.tenant_events().iter().all(|e| e.content.is_none())); + server.stop().await; +} + fn events_for(device: DeviceId, remote: &str, n: usize, tag: &str) -> Vec { (0..n) .map(|i| { diff --git a/deploy/fly.toml b/deploy/fly.toml index 06da9da..88249c8 100644 --- a/deploy/fly.toml +++ b/deploy/fly.toml @@ -36,7 +36,12 @@ swap_size_mb = 512 # ATTEMPTDB_VERSION is passed by deploy/fly-up.sh (--build-arg). [env] - ATTEMPTDB_CAPTURE_MODE = "metadata_only" + # The ceiling on what a tenant may persist here. `local_semantic` keeps + # the content a device chooses to send — under the `messages` profile + # that is the conversation only (prompts and the agent's replies, secret- + # redacted on the device); commands and tool output never leave a device + # unless it opts into `full`. Metadata-only clients are unaffected. + ATTEMPTDB_CAPTURE_MODE = "local_semantic" # Three resident tenants ~= 400 MiB at 20k events each, inside 1 GB with # room for a burst. Raise this only together with `[[vm]] memory` below. ATTEMPTDB_MAX_OPEN = "3" diff --git a/docs/migration/vibemon-install.ps1 b/docs/migration/vibemon-install.ps1 index 61ef5c5..ea210d6 100644 --- a/docs/migration/vibemon-install.ps1 +++ b/docs/migration/vibemon-install.ps1 @@ -24,9 +24,13 @@ # exchanged for a pairing token at the web first # -Web URL the product web (default: https://vibemon.dev) # -Server URL sync server (default https://sync.vibemon.dev or $env:VIBEMON_SYNC_URL) -# -Profile NAME metadata_only | semantic | full (default semantic) -# -LocalContent keep prompts / commands / tool output in the LOCAL -# encrypted database on a NEW install (off by default) +# -Profile NAME metadata_only | semantic | messages | full (default +# messages: metadata, inferences, and the conversation — +# your prompts and the agent's messages, secrets redacted; +# commands and tool output stay on this machine) +# -LocalContent accepted for compatibility: a NEW database keeps prompts, +# commands and tool output in the LOCAL encrypted database +# by default now (local_semantic); -MetadataOnly opts out # -KeepLegacy leave the legacy hook entries in place # -DryRun print the commands instead of running them # -NoReport do not tell vibemon.dev how this run ended. By default @@ -46,9 +50,10 @@ param( [string]$ApiKey = "", [string]$Web = "", [string]$Server = "", - [ValidateSet("metadata_only", "semantic", "full")] - [string]$Profile = "semantic", + [ValidateSet("metadata_only", "semantic", "messages", "full")] + [string]$Profile = "messages", [switch]$LocalContent, + [switch]$MetadataOnly, [switch]$KeepLegacy, [switch]$DryRun, [switch]$NoReport, @@ -261,9 +266,18 @@ $Step = "init" $exists = $false if (-not $DryRun) { try { attempt status *> $null; $exists = ($LASTEXITCODE -eq 0) } catch { $exists = $false } } if ($exists) { - if (-not (Invoke-Step @("attempt", "init", "--source", "vibemon"))) { Fail "attempt init failed" } + # An existing metadata-only database is raised to local_semantic so the + # conversation can be kept (encrypted, on this machine) and uploaded under + # the messages profile; any other existing mode is left alone. + $existingMode = "" + try { $existingMode = ((attempt status --json 2>$null | ConvertFrom-Json).capture_mode) } catch { $existingMode = "" } + if ((-not $MetadataOnly) -and ($existingMode -eq "metadata_only")) { + if (-not (Invoke-Step @("attempt", "init", "--capture-mode", "local_semantic", "--source", "vibemon"))) { Fail "attempt init failed" } + } else { + if (-not (Invoke-Step @("attempt", "init", "--source", "vibemon"))) { Fail "attempt init failed" } + } } else { - $mode = if ($LocalContent) { "local_semantic" } else { "metadata_only" } + $mode = if ($MetadataOnly) { "metadata_only" } else { "local_semantic" } if (-not (Invoke-Step @("attempt", "init", "--capture-mode", $mode, "--source", "vibemon"))) { Fail "attempt init failed" } } diff --git a/docs/migration/vibemon-install.sh b/docs/migration/vibemon-install.sh index 03de2ac..5c389a1 100755 --- a/docs/migration/vibemon-install.sh +++ b/docs/migration/vibemon-install.sh @@ -41,12 +41,16 @@ # $VIBEMON_SYNC_URL; the web's answer to a vbm_ key # names the server too) # --web URL the product web (default: https://vibemon.dev) -# --profile NAME what leaves this machine: metadata_only | semantic | full -# (default semantic: metadata plus this device's -# inferences with evidence — never prompts or output) -# --local-content keep prompts / commands / tool output in the LOCAL -# encrypted database on a NEW install (off: the machine -# keeps the metadata-only promise until you choose) +# --profile NAME what leaves this machine: metadata_only | semantic | +# messages | full (default messages: metadata, this +# device's inferences with evidence, and the conversation +# — your prompts and the agent's messages, secrets +# redacted; commands and tool output stay here) +# --local-content accepted for compatibility: a NEW database keeps +# prompts / commands / tool output in the LOCAL encrypted +# database by default now (local_semantic) +# --metadata-only create a NEW database that stores no content at all +# (nothing to upload under any profile but metadata) # --keep-legacy leave the ~/.vibemon/notify.sh hook entries in place # --purge-legacy delete ~/.vibemon once nothing references it # --dry-run print the commands instead of running them @@ -68,8 +72,8 @@ SERVER="${VIBEMON_SYNC_URL:-https://sync.vibemon.dev}" WEB="${VIBEMON_WEB_URL:-https://vibemon.dev}" TOKEN="" LEGACY_KEY="" -PROFILE="semantic" -NEW_DB_MODE="metadata_only" +PROFILE="messages" +NEW_DB_MODE="local_semantic" KEEP_LEGACY=0 PURGE_LEGACY=0 DRY_RUN=0 @@ -105,6 +109,7 @@ while [ $# -gt 0 ]; do --profile) PROFILE="$2"; shift 2 ;; --profile=*) PROFILE="${1#--profile=}"; shift ;; --local-content) NEW_DB_MODE="local_semantic"; shift ;; + --metadata-only) NEW_DB_MODE="metadata_only"; shift ;; --keep-legacy) KEEP_LEGACY=1; shift ;; --purge-legacy) PURGE_LEGACY=1; shift ;; --dry-run) DRY_RUN=1; shift ;; @@ -146,8 +151,8 @@ run() { if [ "$DRY_RUN" -eq 1 ]; then say "+ $*"; else "$@"; fi } case "$PROFILE" in - metadata_only|semantic|full) ;; - *) fail "unknown --profile $PROFILE (metadata_only | semantic | full)" ;; + metadata_only|semantic|messages|full) ;; + *) fail "unknown --profile $PROFILE (metadata_only | semantic | messages | full)" ;; esac # One line back to the web when this script exits, however it exits (see @@ -427,10 +432,19 @@ start_session_runtime() { } STEP=init -# 3. The local database. Created metadata-only unless --local-content; an -# existing database is left exactly as it is (mode, settings, data). +# 3. The local database. Created local_semantic (content encrypted on this +# machine) unless --metadata-only; an existing database is left exactly as +# it is (mode, settings, data). if [ "$DRY_RUN" -eq 0 ] && attempt status >/dev/null 2>&1; then - run attempt init --source vibemon + # An existing metadata-only database is raised to local_semantic so the + # conversation can be kept (encrypted, on this machine) and uploaded + # under the messages profile; any other existing mode is left alone. + EXISTING_MODE="$(attempt status --json 2>/dev/null | sed -n 's/.*"capture_mode": *"\([a-z_]*\)".*/\1/p' | head -n 1)" + if [ "$NEW_DB_MODE" = local_semantic ] && [ "$EXISTING_MODE" = metadata_only ]; then + run attempt init --capture-mode local_semantic --source vibemon + else + run attempt init --source vibemon + fi else run attempt init --capture-mode "$NEW_DB_MODE" --source vibemon fi diff --git a/docs/otel.md b/docs/otel.md index 667182b..870f0e4 100644 --- a/docs/otel.md +++ b/docs/otel.md @@ -74,7 +74,14 @@ and `/codex/v1/{logs,metrics,traces}`. Only uncompressed OTLP/HTTP JSON is accep Claude uses per-signal endpoint/protocol/header environment settings. Codex uses `[otel]` exporter/metrics_exporter/trace_exporter with `otlp-http` and -`protocol="json"`. Prompt/tool-content logging defaults off. Managed, project +`protocol="json"`. The conversation is exported by default (`OTEL_LOG_USER_PROMPTS=1`, +`OTEL_LOG_ASSISTANT_RESPONSES=1`; Codex `log_user_prompt = true`): the +prompt of a `user_prompt` record and the reply of an `assistant_response` +record land in `content` under the database's capture mode (never in +metadata; `x_otel_prompt_chars` / `x_otel_response_chars` carry only the +size) and leave the device only under the `messages` or `full` sync profile. +Tool arguments and tool content stay off (`OTEL_LOG_TOOL_DETAILS=0`, +`OTEL_LOG_TOOL_CONTENT=0`). Managed, project or shell settings can override configuration; actual receipts are the final check. Foreign exporters are preserved with a visible installation error. Configure diff --git a/docs/rfcs/0006-privacy-and-sync.md b/docs/rfcs/0006-privacy-and-sync.md index 21cbc47..f7ea082 100644 --- a/docs/rfcs/0006-privacy-and-sync.md +++ b/docs/rfcs/0006-privacy-and-sync.md @@ -661,16 +661,27 @@ catches up from its own position while the others move on. older files keep working, and `--send-content` / `--send-inferences` still apply on top of a profile (they only ever add): -| `--profile` | `send_content` | `send_inferences` | What leaves the device | -|---|---|---|---| -| `metadata_only` (default) | false | false | metadata rows only | -| `semantic` | false | true | metadata + inferences with provenance (`objective`/`rationale` removed) | -| `full` | true | true | metadata + inferences + content (secret-redacted on the device; server ceiling still applies) | - -`send_content = true, send_inferences = false` has no name of its own and -reports `full`: content is the stronger signal, and a reader must never see -`metadata_only` or `semantic` on a peer that receives content. The profile -is shown by `connect`, `status`, and `status --json` (`"profile"`). +| `--profile` | `send_content` | `send_inferences` | `send_messages` | What leaves the device | +|---|---|---|---|---| +| `metadata_only` (default) | false | false | false | metadata rows only | +| `semantic` | false | true | false | metadata + inferences with provenance (`objective`/`rationale` removed) | +| `messages` (the VibeMon installer's default, 2026-09-09) | false | true | true | `semantic` + the conversation: `content.prompt` of a submitted prompt and `content.message` of a turn stop, an agent message or an OTel `user_prompt` / `assistant_response` record, secret-redacted on the device. Commands, tool input, tool output, errors and `raw` never leave. | +| `full` | true | true | true | metadata + inferences + content (secret-redacted on the device; server ceiling still applies) | + +`send_content = true` reports `full` whatever the other flags say, and +`send_messages = true` without `send_content` reports `messages`: the stronger +signal names the peer, and a reader must never see `metadata_only` or +`semantic` on a peer that receives any text. `attempt sync profile ` +changes a configured peer's profile without re-pairing it. The profile is +shown by `connect`, `status`, and `status --json` (`"profile"`). + +Under `messages` the uploader keeps only the two conversation fields of a +message event (`prompt_submitted`, `turn_stopped`, `agent_message`, and the +OTel prompt/reply records) and strips every other event to metadata before +serialisation, exactly as `metadata_only` does; the batch is sent as +`local_semantic` so a server whose ceiling allows content persists the text. +Under a `metadata_only` server ceiling the text is dropped on arrival and the +acknowledgement counts it in `stripped_content`. **Daemon reload.** The daemon re-reads `sync.json` on every tick (at most the smallest configured interval apart; every 10 s while no peer is