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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` 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
Expand Down
24 changes: 12 additions & 12 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
34 changes: 34 additions & 0 deletions PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<REDACTED>` 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 <name>` 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 `<REDACTED>` 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
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> <url>` 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 <name>` changes it later, `attempt sync add <name>
<url>` 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
Expand Down
51 changes: 48 additions & 3 deletions crates/attempt/src/cmd_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<String>,
/// 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<SyncProfile>,
/// 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,
Expand Down Expand Up @@ -170,6 +182,33 @@ pub fn run(cli: &Cli, args: &SyncArgs) -> Result<ExitCode> {
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
Expand Down Expand Up @@ -413,13 +452,18 @@ fn add_peer(
}
_ => bail!("give --key <device key> or --pair <pairing token>"),
};
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(),
Expand Down Expand Up @@ -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,
Expand Down
48 changes: 48 additions & 0 deletions crates/attemptdb-adapters/src/otel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,18 @@ fn put_number(event: &mut Event, attrs: &Map<String, Value>, 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<String, Value>, dest: &str, names: &[&str]) {
if let Some(v) = text_attr(attrs, names) {
event.attrs.insert(dest.into(), json!(v));
Expand Down Expand Up @@ -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 `<REDACTED>`. 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 != "<REDACTED>")
.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"][..]),
Expand Down
Loading
Loading