failproofai 1.0.0-beta.2 → 1.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +6 -6
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js +1 -1
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
- package/.next/standalone/.next/server/app/policies/page.js +2 -2
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
- package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__10s_z4l._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__1ky3im9._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0oy_-36.js → node_modules_html-to-image_es_index_0jfts82.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
- package/.next/standalone/.next/static/chunks/{3s7myk_co0gbh.js → 01h6lfjz1_oie.js} +1 -1
- package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 0a7k_x4rp_97z.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 0cvd6bcdwchc_.js} +1 -1
- package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 13-obgukzg_ke.js} +1 -1
- package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
- package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 1h0_yaoq5f3yx.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 29g8ykjk6sx_8.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 2rqm-klet_gbl.js} +1 -1
- package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.js → 2uvyh8y0naofj.js} +1 -1
- package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
- package/.next/standalone/.next/static/chunks/3p-n_j80zlzi3.js +6 -0
- package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
- package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
- package/.next/standalone/Cargo.lock +1992 -198
- package/.next/standalone/Cargo.toml +1 -1
- package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
- package/.next/standalone/app/components/pause-notices.tsx +111 -0
- package/.next/standalone/app/policies/hooks-client.tsx +81 -6
- package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
- package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
- package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
- package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
- package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
- package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
- package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
- package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
- package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
- package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
- package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
- package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
- package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
- package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
- package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
- package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
- package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
- package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
- package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
- package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
- package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
- package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
- package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
- package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
- package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
- package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
- package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
- package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
- package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
- package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
- package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
- package/.next/standalone/package.json +2 -2
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +129 -0
- package/dist/cli.mjs +2195 -770
- package/dist/worker.mjs +365 -52
- package/package.json +2 -2
- package/src/hooks/builtin-policies.ts +139 -0
- package/src/hooks/cloud-connection.ts +205 -0
- package/src/hooks/cloud-enrollment-cli.ts +227 -0
- package/src/hooks/cloud-enrollment.ts +173 -0
- package/src/hooks/cloud-managed-policies.ts +138 -0
- package/src/hooks/collector-config.ts +226 -0
- package/src/hooks/configure-wizard.ts +221 -5
- package/src/hooks/custom-hooks-loader.ts +69 -25
- package/src/hooks/daemon-client.ts +18 -0
- package/src/hooks/handler.ts +144 -6
- package/src/hooks/hook-activity-store.ts +50 -0
- package/src/hooks/loader-utils.ts +6 -4
- package/src/hooks/policy-types.ts +35 -0
- package/src/hooks/session-pause-cli.ts +157 -0
- package/src/hooks/session-pause.ts +236 -0
- package/src/hooks/tui.ts +115 -0
- package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
- package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
- package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
- package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
//! Claude Code transcript line → AgentEye events.
|
|
2
|
+
//!
|
|
3
|
+
//! Every function here is pure in `(line, ctx, offset, state)`, which is what
|
|
4
|
+
//! makes a live tail and a full re-read byte-identical and therefore
|
|
5
|
+
//! dedup-collapsible. Nothing may consult the clock, a counter, or anything
|
|
6
|
+
//! outside its arguments.
|
|
7
|
+
//!
|
|
8
|
+
//! Record shapes verified against real transcripts on disk (Claude Code 2.1.x):
|
|
9
|
+
//!
|
|
10
|
+
//! ```text
|
|
11
|
+
//! user {type:"user", timestamp, uuid, message:{role, content}}
|
|
12
|
+
//! content is a STRING for a prompt, or an ARRAY containing
|
|
13
|
+
//! {type:"tool_result", tool_use_id, content, is_error}
|
|
14
|
+
//! assistant {type:"assistant", timestamp, message:{model, id, content:[
|
|
15
|
+
//! {type:"text"|"tool_use"|"thinking", ...}], usage}}
|
|
16
|
+
//! a failed turn is flagged by isApiErrorMessage / isAbortedMidStream
|
|
17
|
+
//! / message.model == "<synthetic>"
|
|
18
|
+
//! system {type:"system", subtype, timestamp, ...}
|
|
19
|
+
//! only subtype "compact_boundary" is modelled
|
|
20
|
+
//! ```
|
|
21
|
+
//!
|
|
22
|
+
//! # `thinking` blocks emit nothing, and that is a decision
|
|
23
|
+
//!
|
|
24
|
+
//! Measured over every transcript on this machine: **7,687 of 7,687 thinking
|
|
25
|
+
//! blocks carry `"thinking": ""`**, with the entire payload in an opaque
|
|
26
|
+
//! `signature` attestation. Emitting one event per block would add 7,687
|
|
27
|
+
//! contentless rows against 27,521 assistant lines. The explicit arm in
|
|
28
|
+
//! [`assistant_events`] emits only a block that actually carries text, so a
|
|
29
|
+
//! future Claude that starts populating the field shows up as data arriving
|
|
30
|
+
//! rather than as a silent gap — and costs nothing until then.
|
|
31
|
+
|
|
32
|
+
use serde_json::{Map, Value, json};
|
|
33
|
+
|
|
34
|
+
use crate::cursor::TailState;
|
|
35
|
+
use crate::filetail::Ctx;
|
|
36
|
+
|
|
37
|
+
/// Longest derived id component.
|
|
38
|
+
const MAX_ID_PART: usize = 48;
|
|
39
|
+
|
|
40
|
+
/// The placeholder `message.model` on a turn Claude Code fabricated rather than
|
|
41
|
+
/// served. Never a real model, so it must not reach `model` columns or
|
|
42
|
+
/// [`TailState::last_model`].
|
|
43
|
+
const SYNTHETIC_MODEL: &str = "<synthetic>";
|
|
44
|
+
|
|
45
|
+
/// Make a derived id component safe and bounded.
|
|
46
|
+
pub fn sanitize_id_part(s: &str) -> String {
|
|
47
|
+
let cleaned: String = s
|
|
48
|
+
.chars()
|
|
49
|
+
.map(|c| {
|
|
50
|
+
if c.is_ascii_alphanumeric() || c == '.' || c == '_' {
|
|
51
|
+
c
|
|
52
|
+
} else {
|
|
53
|
+
'-'
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
.collect();
|
|
57
|
+
cleaned
|
|
58
|
+
.split('-')
|
|
59
|
+
.filter(|p| !p.is_empty())
|
|
60
|
+
.collect::<Vec<_>>()
|
|
61
|
+
.join("-")
|
|
62
|
+
.chars()
|
|
63
|
+
.take(MAX_ID_PART)
|
|
64
|
+
.collect()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// Normalize a transcript timestamp to the microsecond form ingest expects,
|
|
68
|
+
/// offset by `index` microseconds.
|
|
69
|
+
///
|
|
70
|
+
/// The offset is what keeps several events derived from ONE line in order:
|
|
71
|
+
/// the server sorts by `(ts, random id)`, so an assistant turn's text and its
|
|
72
|
+
/// tool calls would otherwise come back shuffled. Index 0 is untouched, so the
|
|
73
|
+
/// primary event of a line keeps the line's exact time.
|
|
74
|
+
///
|
|
75
|
+
/// Saturates within the second rather than carrying, so an offset can never
|
|
76
|
+
/// reorder an event past a genuinely later line.
|
|
77
|
+
pub fn with_index(ts: &str, index: usize) -> Option<String> {
|
|
78
|
+
let body = ts.strip_suffix('Z')?;
|
|
79
|
+
let (main, frac) = match body.rsplit_once('.') {
|
|
80
|
+
Some((m, f)) => (m, f),
|
|
81
|
+
None => (body, ""),
|
|
82
|
+
};
|
|
83
|
+
// Claude writes milliseconds; pad to microseconds so every event has one
|
|
84
|
+
// shape and sorts against the hook source's events correctly.
|
|
85
|
+
let mut micros: u32 = if frac.is_empty() {
|
|
86
|
+
0
|
|
87
|
+
} else {
|
|
88
|
+
let padded = format!("{frac:0<6}");
|
|
89
|
+
padded.get(..6)?.parse().ok()?
|
|
90
|
+
};
|
|
91
|
+
micros = (micros + index.min(999) as u32).min(999_999);
|
|
92
|
+
Some(format!("{main}.{micros:06}Z"))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
fn ts_of(v: &Value) -> Option<&str> {
|
|
96
|
+
v.get("timestamp")?.as_str()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// The envelope every emitted event carries.
|
|
100
|
+
///
|
|
101
|
+
/// `claude_line_offset` is the dedup discriminator: two identical events from
|
|
102
|
+
/// different lines must hash differently, or the server collapses them.
|
|
103
|
+
fn base(ctx: &Ctx, kind: &str, ts: &str, index: usize, offset: u64) -> Option<Map<String, Value>> {
|
|
104
|
+
let mut m = Map::new();
|
|
105
|
+
m.insert("timestamp".into(), json!(with_index(ts, index)?));
|
|
106
|
+
m.insert("session_id".into(), json!(ctx.session_id));
|
|
107
|
+
m.insert("agent_id".into(), json!(ctx.agent_id));
|
|
108
|
+
m.insert("type".into(), json!(kind));
|
|
109
|
+
m.insert("environment".into(), json!(ctx.environment));
|
|
110
|
+
m.insert("claude_line_offset".into(), json!(offset));
|
|
111
|
+
if index > 0 {
|
|
112
|
+
m.insert("claude_block_index".into(), json!(index));
|
|
113
|
+
}
|
|
114
|
+
Some(m)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/// Build the session's `agent_start` from its header lines.
|
|
118
|
+
///
|
|
119
|
+
/// Scans for the first line carrying a usable timestamp rather than trusting
|
|
120
|
+
/// line 1: a transcript can open with metadata records that have none, and a
|
|
121
|
+
/// session with no start event is absent from the product entirely.
|
|
122
|
+
pub fn agent_start(header: &[String], ctx: &Ctx, offset: u64) -> Option<(Value, Option<String>)> {
|
|
123
|
+
let mut cwd: Option<String> = None;
|
|
124
|
+
let mut git_branch: Option<String> = None;
|
|
125
|
+
let mut version: Option<String> = None;
|
|
126
|
+
let mut goal: Option<String> = None;
|
|
127
|
+
let mut first_ts: Option<String> = None;
|
|
128
|
+
|
|
129
|
+
for line in header {
|
|
130
|
+
let Ok(v) = serde_json::from_str::<Value>(line) else {
|
|
131
|
+
continue;
|
|
132
|
+
};
|
|
133
|
+
if first_ts.is_none()
|
|
134
|
+
&& let Some(t) = ts_of(&v)
|
|
135
|
+
{
|
|
136
|
+
first_ts = Some(t.to_string());
|
|
137
|
+
}
|
|
138
|
+
if cwd.is_none()
|
|
139
|
+
&& let Some(c) = v.get("cwd").and_then(|c| c.as_str())
|
|
140
|
+
{
|
|
141
|
+
cwd = Some(c.to_string());
|
|
142
|
+
}
|
|
143
|
+
if git_branch.is_none()
|
|
144
|
+
&& let Some(b) = v.get("gitBranch").and_then(|b| b.as_str())
|
|
145
|
+
{
|
|
146
|
+
git_branch = Some(b.to_string());
|
|
147
|
+
}
|
|
148
|
+
if version.is_none()
|
|
149
|
+
&& let Some(x) = v.get("version").and_then(|x| x.as_str())
|
|
150
|
+
{
|
|
151
|
+
version = Some(x.to_string());
|
|
152
|
+
}
|
|
153
|
+
// The session's goal is its first real human prompt.
|
|
154
|
+
if goal.is_none()
|
|
155
|
+
&& v.get("type").and_then(|t| t.as_str()) == Some("user")
|
|
156
|
+
&& let Some(text) = v
|
|
157
|
+
.get("message")
|
|
158
|
+
.and_then(|m| m.get("content"))
|
|
159
|
+
.and_then(|c| c.as_str())
|
|
160
|
+
&& !text.is_empty()
|
|
161
|
+
{
|
|
162
|
+
goal = Some(text.chars().take(1024).collect());
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let ts = first_ts?;
|
|
167
|
+
let mut m = base(ctx, "agent_start", &ts, 0, offset)?;
|
|
168
|
+
if let Some(g) = goal {
|
|
169
|
+
m.insert("goal".into(), json!(g));
|
|
170
|
+
}
|
|
171
|
+
if let Some(c) = cwd {
|
|
172
|
+
m.insert("claude_cwd".into(), json!(c));
|
|
173
|
+
}
|
|
174
|
+
if let Some(b) = git_branch {
|
|
175
|
+
m.insert("claude_git_branch".into(), json!(b));
|
|
176
|
+
}
|
|
177
|
+
if let Some(v) = version {
|
|
178
|
+
m.insert("claude_version".into(), json!(v));
|
|
179
|
+
}
|
|
180
|
+
Some((Value::Object(m), Some(ts)))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/// [`agent_start`] plus the parent link a subagent transcript needs.
|
|
184
|
+
///
|
|
185
|
+
/// ⚠️ The fields are `claude_parent_session_id` and `claude_agent_id`, and the
|
|
186
|
+
/// names are load-bearing. `parent_id` is the one name that must NOT be used:
|
|
187
|
+
/// the dashboard matches it against an **`agent_id`**, not a session id, so a
|
|
188
|
+
/// session UUID there resolves to nothing and every subagent link is silently
|
|
189
|
+
/// dropped.
|
|
190
|
+
///
|
|
191
|
+
/// Both are derived from `ctx.session_id`, which the format built from the path
|
|
192
|
+
/// — so this stays pure and agrees with the id the cursor is keyed on. Split
|
|
193
|
+
/// from the right because an agent id never contains `:` while a hypothetical
|
|
194
|
+
/// parent directory might.
|
|
195
|
+
///
|
|
196
|
+
/// Nested subagents (spawn depth 2, 9 of 122 here) name the ROOT session, not
|
|
197
|
+
/// their spawning sibling: the immediate parent is only in the sidecar, which
|
|
198
|
+
/// this function cannot see, and the root is the useful grouping key anyway.
|
|
199
|
+
pub fn subagent_start(
|
|
200
|
+
header: &[String],
|
|
201
|
+
ctx: &Ctx,
|
|
202
|
+
offset: u64,
|
|
203
|
+
) -> Option<(Value, Option<String>)> {
|
|
204
|
+
let (mut event, ts) = agent_start(header, ctx, offset)?;
|
|
205
|
+
if let Some(m) = event.as_object_mut()
|
|
206
|
+
&& let Some((parent, agent)) = ctx.session_id.rsplit_once(':')
|
|
207
|
+
{
|
|
208
|
+
m.insert("claude_parent_session_id".into(), json!(parent));
|
|
209
|
+
m.insert("claude_agent_id".into(), json!(agent));
|
|
210
|
+
}
|
|
211
|
+
Some((event, ts))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/// The single `agent_end`, derived deterministically from the last timestamp
|
|
215
|
+
/// and the file size (used as its offset, so it is unique per session).
|
|
216
|
+
///
|
|
217
|
+
/// Index 999 puts it after every content event sharing that timestamp — the
|
|
218
|
+
/// server's tie-break is a random id, so without this a session can appear to
|
|
219
|
+
/// end before its last turn.
|
|
220
|
+
pub fn agent_end(ctx: &Ctx, last_ts: &str, size: u64) -> Value {
|
|
221
|
+
match base(ctx, "agent_end", last_ts, 999, size) {
|
|
222
|
+
Some(m) => Value::Object(m),
|
|
223
|
+
// `base` only fails on an unparseable timestamp, which cannot happen
|
|
224
|
+
// here: `last_ts` came from a line this transform already accepted.
|
|
225
|
+
None => json!({
|
|
226
|
+
"timestamp": last_ts,
|
|
227
|
+
"session_id": ctx.session_id,
|
|
228
|
+
"agent_id": ctx.agent_id,
|
|
229
|
+
"type": "agent_end",
|
|
230
|
+
"environment": ctx.environment,
|
|
231
|
+
}),
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/// One content line to its timestamp and the events it yields.
|
|
236
|
+
pub fn transform_line(
|
|
237
|
+
line: &str,
|
|
238
|
+
ctx: &Ctx,
|
|
239
|
+
offset: u64,
|
|
240
|
+
state: &mut TailState,
|
|
241
|
+
) -> (Option<String>, Vec<Value>) {
|
|
242
|
+
let Ok(v) = serde_json::from_str::<Value>(line) else {
|
|
243
|
+
return (None, Vec::new());
|
|
244
|
+
};
|
|
245
|
+
// No timestamp means a metadata record (mode, file-history-snapshot,
|
|
246
|
+
// ai-title, …). Skipped for free rather than needing a type allowlist that
|
|
247
|
+
// would have to grow with every Claude release.
|
|
248
|
+
let Some(ts) = ts_of(&v).map(str::to_string) else {
|
|
249
|
+
return (None, Vec::new());
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
let events = match v.get("type").and_then(|t| t.as_str()) {
|
|
253
|
+
Some("user") => user_events(&v, ctx, &ts, offset, state),
|
|
254
|
+
Some("assistant") => assistant_events(&v, ctx, &ts, offset, state),
|
|
255
|
+
Some("system") => system_events(&v, ctx, &ts, offset, state),
|
|
256
|
+
_ => Vec::new(),
|
|
257
|
+
};
|
|
258
|
+
(Some(ts), events)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/// A `system` line. Seven subtypes occur on this machine (`turn_duration`,
|
|
262
|
+
/// `stop_hook_summary`, `local_command`, `away_summary`, `compact_boundary`,
|
|
263
|
+
/// `informational`, `bridge_status`); only the one that changes what the model
|
|
264
|
+
/// can see is modelled.
|
|
265
|
+
fn system_events(v: &Value, ctx: &Ctx, ts: &str, offset: u64, state: &TailState) -> Vec<Value> {
|
|
266
|
+
match v.get("subtype").and_then(|s| s.as_str()) {
|
|
267
|
+
Some("compact_boundary") => compact_boundary(v, ctx, ts, offset, state),
|
|
268
|
+
_ => Vec::new(),
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/// `system`/`compact_boundary` — the only on-disk record that the context was
|
|
273
|
+
/// reset.
|
|
274
|
+
///
|
|
275
|
+
/// Modelled as a `model_request` because that is what a compaction is: the next
|
|
276
|
+
/// request is built from a summary instead of the transcript. `model` is
|
|
277
|
+
/// stamped from carried state for the same reason a user prompt is — the
|
|
278
|
+
/// server builds this row's summary from the model alone.
|
|
279
|
+
///
|
|
280
|
+
/// This is orthogonal to the file SHRINKING, which `/compact` also does and
|
|
281
|
+
/// which the engine already handles by re-reading from zero. Nothing here
|
|
282
|
+
/// fights that: the re-read produces this same event at this same offset, so
|
|
283
|
+
/// the server dedups it.
|
|
284
|
+
///
|
|
285
|
+
/// Only the scalar `compactMetadata` fields are promoted. `preservedMessages`
|
|
286
|
+
/// is a uuid index that grows with the preserved window and answers no question
|
|
287
|
+
/// an operator would ask, so shipping it would be volume without signal.
|
|
288
|
+
fn compact_boundary(v: &Value, ctx: &Ctx, ts: &str, offset: u64, state: &TailState) -> Vec<Value> {
|
|
289
|
+
let Some(mut m) = base(ctx, "model_request", ts, 0, offset) else {
|
|
290
|
+
return Vec::new();
|
|
291
|
+
};
|
|
292
|
+
m.insert("claude_kind".into(), json!("compact_boundary"));
|
|
293
|
+
if let Some(model) = &state.last_model {
|
|
294
|
+
m.insert("model".into(), json!(model));
|
|
295
|
+
}
|
|
296
|
+
if let Some(meta) = v.get("compactMetadata") {
|
|
297
|
+
for (src, dst) in [
|
|
298
|
+
("trigger", "claude_compact_trigger"),
|
|
299
|
+
("preTokens", "claude_compact_pre_tokens"),
|
|
300
|
+
("postTokens", "claude_compact_post_tokens"),
|
|
301
|
+
("cumulativeDroppedTokens", "claude_compact_dropped_tokens"),
|
|
302
|
+
] {
|
|
303
|
+
if let Some(x) = meta.get(src).filter(|x| !x.is_null()) {
|
|
304
|
+
m.insert(dst.into(), x.clone());
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if let Some(ms) = meta.get("durationMs").and_then(|x| x.as_u64()) {
|
|
308
|
+
m.insert("duration_ms".into(), json!(ms));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if let Some(c) = v.get("cwd").and_then(|c| c.as_str()) {
|
|
312
|
+
m.insert("claude_cwd".into(), json!(c));
|
|
313
|
+
}
|
|
314
|
+
vec![Value::Object(m)]
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/// A `user` line is either a human prompt or the results of tool calls.
|
|
318
|
+
fn user_events(v: &Value, ctx: &Ctx, ts: &str, offset: u64, state: &mut TailState) -> Vec<Value> {
|
|
319
|
+
let Some(content) = v.get("message").and_then(|m| m.get("content")) else {
|
|
320
|
+
return Vec::new();
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// A string is a real prompt.
|
|
324
|
+
if let Some(text) = content.as_str() {
|
|
325
|
+
if text.is_empty() {
|
|
326
|
+
return Vec::new();
|
|
327
|
+
}
|
|
328
|
+
let Some(mut m) = base(ctx, "model_request", ts, 0, offset) else {
|
|
329
|
+
return Vec::new();
|
|
330
|
+
};
|
|
331
|
+
// Inherited from the last assistant turn: a user line names no model,
|
|
332
|
+
// and the server builds this row's summary from the model alone.
|
|
333
|
+
if let Some(model) = &state.last_model {
|
|
334
|
+
m.insert("model".into(), json!(model));
|
|
335
|
+
}
|
|
336
|
+
m.insert(
|
|
337
|
+
"messages".into(),
|
|
338
|
+
json!([{ "role": "user", "content": text }]),
|
|
339
|
+
);
|
|
340
|
+
if let Some(c) = v.get("cwd").and_then(|c| c.as_str()) {
|
|
341
|
+
m.insert("claude_cwd".into(), json!(c));
|
|
342
|
+
}
|
|
343
|
+
return vec![Value::Object(m)];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// An array carries tool results.
|
|
347
|
+
let Some(blocks) = content.as_array() else {
|
|
348
|
+
return Vec::new();
|
|
349
|
+
};
|
|
350
|
+
let mut out = Vec::new();
|
|
351
|
+
for (i, block) in blocks.iter().enumerate() {
|
|
352
|
+
if block.get("type").and_then(|t| t.as_str()) != Some("tool_result") {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
let call_id = block.get("tool_use_id").and_then(|x| x.as_str());
|
|
356
|
+
let Some(mut m) = base(ctx, "tool_result", ts, i, offset) else {
|
|
357
|
+
continue;
|
|
358
|
+
};
|
|
359
|
+
if let Some(id) = call_id {
|
|
360
|
+
m.insert("tool_call_id".into(), json!(id));
|
|
361
|
+
// The tool's name appears on NO result line. Without carrying it
|
|
362
|
+
// from the call, every result is a blank row in the product.
|
|
363
|
+
if let Some(name) = state.tool_name(id) {
|
|
364
|
+
m.insert("tool_name".into(), json!(name));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if let Some(output) = block.get("content") {
|
|
368
|
+
m.insert("output".into(), json!(stringify(output)));
|
|
369
|
+
}
|
|
370
|
+
if block.get("is_error").and_then(|e| e.as_bool()) == Some(true) {
|
|
371
|
+
m.insert(
|
|
372
|
+
"error".into(),
|
|
373
|
+
json!(stringify(block.get("content").unwrap_or(&Value::Null))),
|
|
374
|
+
);
|
|
375
|
+
m.insert("error_type".into(), json!("claude_tool_error"));
|
|
376
|
+
}
|
|
377
|
+
out.push(Value::Object(m));
|
|
378
|
+
}
|
|
379
|
+
out
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/// An `assistant` line is text and/or tool calls, plus token usage.
|
|
383
|
+
fn assistant_events(
|
|
384
|
+
v: &Value,
|
|
385
|
+
ctx: &Ctx,
|
|
386
|
+
ts: &str,
|
|
387
|
+
offset: u64,
|
|
388
|
+
state: &mut TailState,
|
|
389
|
+
) -> Vec<Value> {
|
|
390
|
+
let Some(message) = v.get("message") else {
|
|
391
|
+
return Vec::new();
|
|
392
|
+
};
|
|
393
|
+
let model = message.get("model").and_then(|m| m.as_str());
|
|
394
|
+
if let Some(m) = model.filter(|m| *m != SYNTHETIC_MODEL) {
|
|
395
|
+
// `<synthetic>` is excluded deliberately: it is the placeholder on a
|
|
396
|
+
// fabricated turn, and letting it in would stamp "<synthetic>" as the
|
|
397
|
+
// model on every later user prompt that inherits from here.
|
|
398
|
+
state.last_model = Some(m.to_string());
|
|
399
|
+
}
|
|
400
|
+
let message_id = message.get("id").and_then(|i| i.as_str());
|
|
401
|
+
|
|
402
|
+
// A turn Claude Code recorded as a failure replaces the whole line: one
|
|
403
|
+
// `error`, no content rows, and no usage.
|
|
404
|
+
if let Some(ev) = error_turn(v, message, ctx, ts, offset) {
|
|
405
|
+
return vec![ev];
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// One API response is written across several lines that each repeat the
|
|
409
|
+
// SAME usage object. Attributing it per line multiplies token totals, so it
|
|
410
|
+
// is counted once per message id. The claim is staked at the bottom of this
|
|
411
|
+
// function, by the line that actually emits — see there.
|
|
412
|
+
let usage_is_new = match (message_id, state.last_usage_message_id.as_deref()) {
|
|
413
|
+
(Some(id), Some(seen)) => id != seen,
|
|
414
|
+
(Some(_), None) => true,
|
|
415
|
+
_ => false,
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
let Some(blocks) = message.get("content").and_then(|c| c.as_array()) else {
|
|
419
|
+
return Vec::new();
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
let mut out: Vec<Value> = Vec::new();
|
|
423
|
+
for (i, block) in blocks.iter().enumerate() {
|
|
424
|
+
match block.get("type").and_then(|t| t.as_str()) {
|
|
425
|
+
Some("text") => {
|
|
426
|
+
let Some(text) = block.get("text").and_then(|t| t.as_str()) else {
|
|
427
|
+
continue;
|
|
428
|
+
};
|
|
429
|
+
if text.is_empty() {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
let Some(mut m) = base(ctx, "model_response", ts, i, offset) else {
|
|
433
|
+
continue;
|
|
434
|
+
};
|
|
435
|
+
m.insert("role".into(), json!("assistant"));
|
|
436
|
+
m.insert("content".into(), json!(text));
|
|
437
|
+
if let Some(model) = model {
|
|
438
|
+
m.insert("model".into(), json!(model));
|
|
439
|
+
}
|
|
440
|
+
if let Some(id) = message_id {
|
|
441
|
+
m.insert("claude_message_id".into(), json!(id));
|
|
442
|
+
}
|
|
443
|
+
if let Some(sr) = message.get("stop_reason").and_then(|s| s.as_str()) {
|
|
444
|
+
m.insert("claude_stop_reason".into(), json!(sr));
|
|
445
|
+
}
|
|
446
|
+
out.push(Value::Object(m));
|
|
447
|
+
}
|
|
448
|
+
Some("tool_use") => {
|
|
449
|
+
let Some(mut m) = base(ctx, "tool_use", ts, i, offset) else {
|
|
450
|
+
continue;
|
|
451
|
+
};
|
|
452
|
+
let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("tool");
|
|
453
|
+
// Fall back to an offset-derived id so parallel identical calls
|
|
454
|
+
// cannot hash-collapse into one row.
|
|
455
|
+
let id = block
|
|
456
|
+
.get("id")
|
|
457
|
+
.and_then(|x| x.as_str())
|
|
458
|
+
.map(str::to_string)
|
|
459
|
+
.unwrap_or_else(|| format!("claude-{offset}-{i}"));
|
|
460
|
+
state.remember_tool(id.clone(), name.to_string());
|
|
461
|
+
m.insert("tool_name".into(), json!(name));
|
|
462
|
+
m.insert("tool_call_id".into(), json!(id));
|
|
463
|
+
if let Some(input) = block.get("input") {
|
|
464
|
+
m.insert("input".into(), input.clone());
|
|
465
|
+
}
|
|
466
|
+
if let Some(model) = model {
|
|
467
|
+
m.insert("model".into(), json!(model));
|
|
468
|
+
}
|
|
469
|
+
out.push(Value::Object(m));
|
|
470
|
+
}
|
|
471
|
+
// Emitted only when it carries text — see the module docs. Kept as
|
|
472
|
+
// an explicit arm so the 7,687-of-7,687-empty measurement has a
|
|
473
|
+
// place to live and a populated block cannot slip past unnoticed.
|
|
474
|
+
Some("thinking") => {
|
|
475
|
+
let text = block
|
|
476
|
+
.get("thinking")
|
|
477
|
+
.and_then(|t| t.as_str())
|
|
478
|
+
.unwrap_or_default();
|
|
479
|
+
if text.trim().is_empty() {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
let Some(mut m) = base(ctx, "model_response", ts, i, offset) else {
|
|
483
|
+
continue;
|
|
484
|
+
};
|
|
485
|
+
m.insert("role".into(), json!("assistant"));
|
|
486
|
+
m.insert("content".into(), json!(text));
|
|
487
|
+
m.insert("claude_kind".into(), json!("thinking"));
|
|
488
|
+
if let Some(model) = model {
|
|
489
|
+
m.insert("model".into(), json!(model));
|
|
490
|
+
}
|
|
491
|
+
if let Some(id) = message_id {
|
|
492
|
+
m.insert("claude_message_id".into(), json!(id));
|
|
493
|
+
}
|
|
494
|
+
out.push(Value::Object(m));
|
|
495
|
+
}
|
|
496
|
+
_ => {}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Attach usage to the FIRST event of the line, so a line yielding several
|
|
501
|
+
// events reports its tokens once.
|
|
502
|
+
//
|
|
503
|
+
// The group is claimed HERE, by a line that actually emitted — not up top
|
|
504
|
+
// on sight of the id. Claiming it earlier loses the whole group whenever
|
|
505
|
+
// its first line emits nothing, which is the common case, not an edge one:
|
|
506
|
+
// Claude writes the thinking block as its own line at the head of a group,
|
|
507
|
+
// and 7,699 of 11,213 groups on this machine begin with a line that yields
|
|
508
|
+
// no event. Under the earlier gate those groups reported zero tokens —
|
|
509
|
+
// 8.4M of 10.3M output tokens corpus-wide, silently.
|
|
510
|
+
//
|
|
511
|
+
// Deferring is also strictly more accurate: usage accumulates across a
|
|
512
|
+
// group's lines, and the later line this now bills carries the larger
|
|
513
|
+
// figure in 7,703 of the 8,599 multi-line groups (identical in the rest).
|
|
514
|
+
// Still pure — `last_usage_message_id` lives in the cursor, so a resumed
|
|
515
|
+
// read holds the same value at the same offset as a full re-read.
|
|
516
|
+
if usage_is_new
|
|
517
|
+
&& let Some(usage) = message.get("usage")
|
|
518
|
+
&& let Some(first) = out.first_mut()
|
|
519
|
+
&& let Some(obj) = first.as_object_mut()
|
|
520
|
+
{
|
|
521
|
+
if let Some(n) = usage.get("input_tokens").and_then(|x| x.as_u64()) {
|
|
522
|
+
obj.insert("input_tokens".into(), json!(n));
|
|
523
|
+
}
|
|
524
|
+
if let Some(n) = usage.get("output_tokens").and_then(|x| x.as_u64()) {
|
|
525
|
+
obj.insert("output_tokens".into(), json!(n));
|
|
526
|
+
}
|
|
527
|
+
obj.insert("claude_usage".into(), usage.clone());
|
|
528
|
+
if let Some(id) = message_id {
|
|
529
|
+
state.last_usage_message_id = Some(id.to_string());
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
out
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/// A turn Claude Code recorded as a failure, or `None` if this is a normal one.
|
|
537
|
+
///
|
|
538
|
+
/// Three independent markers mean the same thing and any one alone is enough:
|
|
539
|
+
/// `isApiErrorMessage`, `isAbortedMidStream`, and the fabricated
|
|
540
|
+
/// `message.model == "<synthetic>"`. On this machine: 2 aborted, 5 synthetic,
|
|
541
|
+
/// 0 api-error (the field is present 5 times, always `false`) — so the
|
|
542
|
+
/// api-error arm is written from the marker's meaning rather than from a live
|
|
543
|
+
/// sample, and is the one to re-check first if this ever misfires.
|
|
544
|
+
///
|
|
545
|
+
/// There is deliberately no "only if it has content" guard: a failed turn is
|
|
546
|
+
/// precisely the one with nothing to check.
|
|
547
|
+
///
|
|
548
|
+
/// The line yields this event and NOTHING else — no `model_response` for its
|
|
549
|
+
/// partial text, no usage. Usage in particular: a synthetic turn's usage object
|
|
550
|
+
/// is all zeros and it is interleaved *inside* a real `message.id` group, so
|
|
551
|
+
/// letting it claim the group would zero out the real response's tokens.
|
|
552
|
+
/// Suppressing the content rows is safe here because none of the 7 flagged
|
|
553
|
+
/// lines carries a `tool_use` block — all 7 are text-only — so no tool call can
|
|
554
|
+
/// be lost this way.
|
|
555
|
+
fn error_turn(v: &Value, message: &Value, ctx: &Ctx, ts: &str, offset: u64) -> Option<Value> {
|
|
556
|
+
let api_error = v.get("isApiErrorMessage").and_then(|x| x.as_bool()) == Some(true);
|
|
557
|
+
let aborted = v.get("isAbortedMidStream").and_then(|x| x.as_bool()) == Some(true);
|
|
558
|
+
let synthetic = message.get("model").and_then(|m| m.as_str()) == Some(SYNTHETIC_MODEL);
|
|
559
|
+
if !(api_error || aborted || synthetic) {
|
|
560
|
+
return None;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
let mut m = base(ctx, "error", ts, 0, offset)?;
|
|
564
|
+
// Claude Code's own label (`"server_error"`) when it wrote one — that is
|
|
565
|
+
// the string an operator can group on — else whichever marker fired.
|
|
566
|
+
let error_type = v
|
|
567
|
+
.get("error")
|
|
568
|
+
.and_then(|e| e.as_str())
|
|
569
|
+
.filter(|s| !s.is_empty())
|
|
570
|
+
.unwrap_or(if aborted {
|
|
571
|
+
"claude_aborted"
|
|
572
|
+
} else if api_error {
|
|
573
|
+
"claude_api_error"
|
|
574
|
+
} else {
|
|
575
|
+
"claude_synthetic"
|
|
576
|
+
});
|
|
577
|
+
m.insert("error_type".into(), json!(error_type));
|
|
578
|
+
|
|
579
|
+
// The server's `is_error` is a truthiness check on the message, so this
|
|
580
|
+
// must never be empty — a failed turn rendering as a success is worse than
|
|
581
|
+
// a generic sentence.
|
|
582
|
+
let text = message.get("content").map(stringify).unwrap_or_default();
|
|
583
|
+
let detail = if text.trim().is_empty() {
|
|
584
|
+
if aborted {
|
|
585
|
+
"assistant turn aborted mid-stream"
|
|
586
|
+
} else if api_error {
|
|
587
|
+
"assistant turn failed with an API error"
|
|
588
|
+
} else {
|
|
589
|
+
"synthetic assistant turn (no model response)"
|
|
590
|
+
}
|
|
591
|
+
} else {
|
|
592
|
+
text.trim()
|
|
593
|
+
};
|
|
594
|
+
m.insert("message".into(), json!(detail));
|
|
595
|
+
if let Some(model) = message.get("model").and_then(|x| x.as_str()) {
|
|
596
|
+
m.insert("claude_model".into(), json!(model));
|
|
597
|
+
}
|
|
598
|
+
if let Some(id) = message.get("id").and_then(|x| x.as_str()) {
|
|
599
|
+
m.insert("claude_message_id".into(), json!(id));
|
|
600
|
+
}
|
|
601
|
+
if let Some(sr) = message.get("stop_reason").and_then(|s| s.as_str()) {
|
|
602
|
+
m.insert("claude_stop_reason".into(), json!(sr));
|
|
603
|
+
}
|
|
604
|
+
Some(Value::Object(m))
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/// Render a tool result payload as text. Claude uses a bare string, or an array
|
|
608
|
+
/// of typed blocks.
|
|
609
|
+
fn stringify(v: &Value) -> String {
|
|
610
|
+
match v {
|
|
611
|
+
Value::String(s) => s.clone(),
|
|
612
|
+
Value::Array(items) => items
|
|
613
|
+
.iter()
|
|
614
|
+
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
|
|
615
|
+
.collect::<Vec<_>>()
|
|
616
|
+
.join("\n"),
|
|
617
|
+
other => other.to_string(),
|
|
618
|
+
}
|
|
619
|
+
}
|