diff --git a/CHANGELOG.md b/CHANGELOG.md index c13fa974..dcbcecfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,43 @@ All notable changes to the Toolpath workspace are documented here. +## Model on the turn payload + kind v1.2.0 — 2026-07-16 + +Derived agent-coding-session documents never wrote the model into the +`conversation.append` structural payload: it rode only the step actor +string (`agent:`) and `meta.actors[…].model`, so consumers +reading `structural.model` got null and had to parse the actor string +(reported by Pathbase, pathbase#225). + +Kind `agent-coding-session` **v1.2.0** adds an optional `model` to the +turn payload — the model that produced the turn, verbatim from the +source. The append is now self-describing about attribution. + +- The shared `toolpath_convo::derive_path` emits `model` when + `Turn.model` is set, which covers all seven providers (claude, + gemini, codex, opencode, cursor, pi, copilot) — they derive through + it. +- When the source records no model, the key is **omitted entirely** — + the `agent:unknown` actor sentinel never appears in the payload. +- The actor string and `meta.actors` are unchanged; the payload `model` + is the same value they attribute. +- Compat: the field is additive and optional, so v1.2.0 documents are + structurally valid v1.1.0 documents; the new version exists so + consumers can rely on the payload carrying the model when the source + records one. `PATH_KIND_AGENT_CODING_SESSION` now points at v1.2.0; + `…_V1_1_0` and `…_V1_0_0` name the superseded URIs. `path p validate` + bundles all three schemas; `path kind` resolves the bare name to + v1.2.0. + +Crates bumped (every crate that depends on `toolpath`, matching the +v1.1.0 precedent since the emitted kind URI changes): `toolpath` 0.8.0, +`toolpath-convo` 0.12.0, `toolpath-git` 0.7.0, `toolpath-github` 0.7.0, +`toolpath-claude` 0.13.0, `toolpath-gemini` 0.7.0, `toolpath-codex` +0.7.0, `toolpath-copilot` 0.2.0, `toolpath-opencode` 0.6.0, +`toolpath-cursor` 0.3.0, `toolpath-pi` 0.7.0, `toolpath-dot` 0.6.0, +`toolpath-md` 0.8.0, `path-cli` 0.16.0, `toolpath-cli` 0.16.0. +`pathbase-client` is unaffected. + ## Derive: resolve duplicate step ids — 2026-07-01 - **`toolpath-convo`** (0.11.1): `derive_path` now guarantees the derived diff --git a/CLAUDE.md b/CLAUDE.md index b6c63e53..a7bd91bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -249,7 +249,7 @@ When changing a crate's public API (new types, new trait impls, new public metho The `toolpath-cli` shim lives **outside** the workspace (`exclude = ["crates/toolpath-cli"]` in the root `Cargo.toml`). Both `toolpath-cli` and `path-cli` produce a binary literally named `path`, and cargo can't write two bin targets to the same workspace `target/debug/path` — so the shim opts out and gets its own `crates/toolpath-cli/target/` (covered by the `crates/*/target` line in `.gitignore`). Practical consequences: `cargo build --workspace`, `cargo test --workspace`, and `cargo run -p toolpath-cli` from the repo root **do not** include the shim. To touch it, use `--manifest-path crates/toolpath-cli/Cargo.toml`. The release script special-cases the shim in `get_version` and `publish` so the workflow is otherwise unchanged. -Build the site after changes: `cd site && pnpm run build` (should produce 11 pages). +Build the site after changes: `cd site && pnpm run build` (should produce 12 pages). ## Things to know @@ -262,9 +262,10 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - `toolpath-gemini` treats main file + sibling sub-agent UUID dir as one conversation. Sub-agent files are folded into `DelegatedWork` with populated `turns` (unlike `toolpath-claude`, whose sub-agent turns live in separate session files and stay empty). See `docs/agents/formats/gemini.md` for the full format reference. - Provider-specific extras convention: `Turn.extra` and `WatcherEvent::Progress.data` use provider-namespaced keys (e.g. `extra["claude"]`, `extra["gemini"]`). `toolpath-claude` populates `Turn.extra["claude"]` from `ConversationEntry.extra`; `toolpath-gemini` populates `Turn.extra["gemini"]` with the full `tokens` struct, per-thought metadata, and tool-call status. This lets trait-only consumers access provider metadata without importing provider types. - Shared derivation: `toolpath-convo` provides a provider-agnostic `ConversationView → Path` mapping via `toolpath_convo::derive_path`. New conversation providers should build on it rather than re-implementing the mapping. -- Path kinds: `toolpath::v1::PathMeta.kind` is an optional URI naming a hosted kind spec; URIs are immutable and semver-versioned. The only one defined so far is `https://toolpath.net/kinds/agent-coding-session/v1.1.0` (constant `toolpath::v1::PATH_KIND_AGENT_CODING_SESSION`; `…_V1_0_0` names the superseded URI); every conversation → `Path` derivation sets it via the shared `toolpath_convo::derive_path` or each provider crate's own. Carried through the JSONL form via `PathOpen.meta` and `PathMeta` patch lines. Spec sources live in `site/kinds///{index.md,schema.json}` (schema.json is a symlink into `crates/path-cli/kinds/`, which `path p validate` bundles — both versions) and publish under `https://toolpath.net/kinds/`; the registry index is `site/kinds/index.md`. RFC: "Document Kind". JSON Schema: `$defs/pathMeta`. +- Path kinds: `toolpath::v1::PathMeta.kind` is an optional URI naming a hosted kind spec; URIs are immutable and semver-versioned. The only one defined so far is `https://toolpath.net/kinds/agent-coding-session/v1.2.0` (constant `toolpath::v1::PATH_KIND_AGENT_CODING_SESSION`; `…_V1_1_0` and `…_V1_0_0` name the superseded URIs); every conversation → `Path` derivation sets it via the shared `toolpath_convo::derive_path` or each provider crate's own. Carried through the JSONL form via `PathOpen.meta` and `PathMeta` patch lines. Spec sources live in `site/kinds///{index.md,schema.json}` (schema.json is a symlink into `crates/path-cli/kinds/`, which `path p validate` bundles — all versions) and publish under `https://toolpath.net/kinds/`; the registry index is `site/kinds/index.md`. RFC: "Document Kind". JSON Schema: `$defs/pathMeta`. - Token accounting (kind v1.1.0): two keys on `conversation.append`/`Turn`, both optional. `token_usage` = "the total for a message" (on the group's final step; `Σ` over a path = session total). `attributed_token_usage` = "this step's own attributed spend", populated only where the source genuinely reports per-step spend (its own key, so the sum is unaffected; remainder = group total − Σ attributed, computed not stored). One provider message can span several steps (Claude writes one JSONL line per content block); `Turn.group_id` groups them. `toolpath-claude` fills `group_id` from `message.id` and takes the **field-wise-max** group total (line order not trusted). Claude's per-line `usage` is a cumulative *streaming snapshot* (Anthropic streaming API: `message_start` seeds output near 0, `message_delta` is cumulative), NOT a per-block cost — so Claude emits no `attributed_token_usage`; the projector re-expands the total onto every line. `toolpath-codex` differences the cumulative `total_token_usage` (dedup-safe: never sum `last_token_usage` — Codex re-emits it stale; openai/codex #14489), attributes each per-call delta to the step it follows, and derives the round total from those attributions. pi/opencode decode all-zero wire counters as `None`. Never stamp a cumulative counter, a repeated message total, or zero-filled placeholders onto a step; never derive attribution from Claude's streaming snapshots. - Token usage `breakdowns` (kind v1.1.0, additive): an optional third key on `TokenUsage` — a decomposition of a top-level class into named sub-classes, keyed by class (e.g. `"output"`), inner map sub-class → tokens (e.g. `breakdowns["output"]["reasoning"] = 243`). INFORMATIONAL ONLY: **never summed into any total** (the parent class already counts those tokens, so the session-total guarantee is untouched); invariant `Σ(inner) ≤ parent`; omitted when empty; rides both `token_usage` and `attributed_token_usage`. Per-provider reality: **Gemini** reports `thoughts` (reasoning) as an additive sibling that the derivation used to **drop** (under-counting output) — it's now folded into `output_tokens` *and* recorded as `breakdowns["output"]["reasoning"]`, with the projector un-folding it on the reverse path for a lossless round-trip (`Some(0)` preserved as a real Gemini-3 zero-reasoning signal). **OpenCode** folds `reasoning` into output and records the same breakdown. **Codex** differences `reasoning_output_tokens` (⊆ output, cumulative) into `breakdowns["output"]["reasoning"]` on both per-step `attributed_token_usage` and per-round `token_usage`. **Claude** records no breakdown (its JSONL `usage` doesn't itemize thinking tokens). +- Model attribution (kind v1.2.0): an assistant `conversation.append` payload carries an optional `model` — the model that produced the turn, verbatim from the source — so consumers read `structural.model` instead of parsing the actor string. Emitted by the shared `derive_path` when `Turn.model` is `Some`; **omitted entirely when the source records none** (the `agent:unknown` actor sentinel never appears in the payload). The actor string (`agent:`) and `meta.actors[…].model` are unchanged and carry the same value. - Pi provider: `toolpath-pi` reads Pi session JSONL from `~/.pi/agent/sessions/`. Sessions use a tree (id/parentId) in a single file, and may link to a parent file via `parentSession` in the header. The tree is preserved as a DAG in the derived `Path`. - Codex provider: `toolpath-codex` reads Codex CLI rollout files from `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`. Sessions are date-bucketed (not project-keyed). File-change fidelity is excellent — Codex's `patch_apply_end` events carry either the unified diff (for updates) or the full file content (for adds), so the derived `Path` gets a real `raw` perspective on every file artifact. See `docs/agents/formats/codex.md` for the full format reference. - Copilot provider (**preview, schema reverse-engineered**): `toolpath-copilot` reads the GitHub Copilot CLI (`@github/copilot`) `events.jsonl` stream from `~/.copilot/session-state//` (root overridable via `COPILOT_HOME`; also globs the legacy `history-session-state/`). Sessions are global (id-keyed dirs), not project-keyed. The `events.jsonl` schema is **undocumented** but was **verified against a first-hand capture at `copilotVersion` 1.0.67**: envelope is `{type, data, id, timestamp, parentId}`; cwd + git (branch/remote/`headCommit`) live under `session.start`'s `data.context` (primary; `workspace.yaml` is the fallback, parsed by a tolerant key-scan with no YAML dep); tool calls carry `toolCallId`/`toolName`/`arguments` with results under `result.content`; `assistant.message` carries `reasoningText` (→ thinking) and per-message `outputTokens` (summed for the session total). The reader stays deliberately tolerant (payload inline / under `data` / under `payload`; multiple key spellings; unknown events preserved as `ConversationEvent`s). `subagent.*` and `session.shutdown` (`tokenDetails`) are now observed via a real feature-elicit capture at 1.0.68; event types not yet observed (`skill.invoked`, `hook.*`, `abort`, compaction) and the `checkpoints/` format remain unverified. Wired into the CLI both directions — forward (`path p import/list/show copilot`, `path share`) and reverse via `CopilotProjector` (`path p export copilot`, `path resume`; the projector emits `events.jsonl` in the observed shape, remaps foreign tool names via `native_name`, and — for resume — writes `session-state//` + a `session-store.db` `sessions` row, INSERTing only a fresh id). **Verified in copilot 1.0.67**: a projected session loads and resumes in the real `copilot --resume`. Reaching that mapped the loader's writer contract (UUID `id`/`parentId`, offset-ISO timestamps on every event, `turnId`/`messageId` on turn-scoped events, non-empty `toolCallId`, full `session.start` shape, and `subagent.*` fields `agentName`/`agentDisplayName`/`agentDescription`/`toolCallId`) — documented with verbatim rejection messages in `docs/agents/formats/copilot-cli/writing-compatible.md`; verified on a small session and a 5817-event sub-agent session. Also validated in isolation by the cross-harness conformance matrix (`crates/path-cli/tests/cross_harness_matrix.rs`, fixture `test-fixtures/copilot/`) + a round-trip test. File fidelity is **Codex-grade**: `edit`/`create` completes embed the real file-state diff inline (`result.detailedContent`), which upgrades the arg-derived `FileMutation.raw_diff`; sub-agents are `task` tool calls with thin `subagent.*` markers sharing the `toolCallId` (delegations pair by it). Session token totals take `output` from the summed per-message `outputTokens` and merge input/cache totals from `session.shutdown`'s `tokenDetails` (Copilot reports only `output` per-message, so input+cache would otherwise be dropped); no per-turn attribution. Full format reference + verification checklist: `docs/agents/formats/copilot-cli/`. diff --git a/Cargo.lock b/Cargo.lock index 301920f1..af5fea4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2441,7 +2441,7 @@ dependencies = [ [[package]] name = "path-cli" -version = "0.15.0" +version = "0.16.0" dependencies = [ "anyhow", "assert_cmd", @@ -4065,7 +4065,7 @@ dependencies = [ [[package]] name = "toolpath" -version = "0.7.0" +version = "0.8.0" dependencies = [ "serde", "serde_json", @@ -4073,7 +4073,7 @@ dependencies = [ [[package]] name = "toolpath-claude" -version = "0.12.0" +version = "0.13.0" dependencies = [ "anyhow", "chrono", @@ -4090,7 +4090,7 @@ dependencies = [ [[package]] name = "toolpath-codex" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "chrono", @@ -4104,7 +4104,7 @@ dependencies = [ [[package]] name = "toolpath-convo" -version = "0.11.1" +version = "0.12.0" dependencies = [ "chrono", "jsonschema", @@ -4117,7 +4117,7 @@ dependencies = [ [[package]] name = "toolpath-copilot" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "chrono", @@ -4132,7 +4132,7 @@ dependencies = [ [[package]] name = "toolpath-cursor" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "chrono", @@ -4148,14 +4148,14 @@ dependencies = [ [[package]] name = "toolpath-dot" -version = "0.5.0" +version = "0.6.0" dependencies = [ "toolpath", ] [[package]] name = "toolpath-gemini" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "chrono", @@ -4170,7 +4170,7 @@ dependencies = [ [[package]] name = "toolpath-git" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "chrono", @@ -4181,7 +4181,7 @@ dependencies = [ [[package]] name = "toolpath-github" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "chrono", @@ -4193,7 +4193,7 @@ dependencies = [ [[package]] name = "toolpath-md" -version = "0.7.0" +version = "0.8.0" dependencies = [ "serde_json", "toolpath", @@ -4201,7 +4201,7 @@ dependencies = [ [[package]] name = "toolpath-opencode" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "chrono", @@ -4219,7 +4219,7 @@ dependencies = [ [[package]] name = "toolpath-pi" -version = "0.6.1" +version = "0.7.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 835a95e6..7af47ec2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,20 +24,20 @@ edition = "2024" license = "Apache-2.0" [workspace.dependencies] -toolpath = { version = "0.7.0", path = "crates/toolpath" } -toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" } -toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" } -toolpath-claude = { version = "0.12.0", path = "crates/toolpath-claude", default-features = false } -toolpath-gemini = { version = "0.6.0", path = "crates/toolpath-gemini", default-features = false } -toolpath-codex = { version = "0.6.0", path = "crates/toolpath-codex" } -toolpath-copilot = { version = "0.1.0", path = "crates/toolpath-copilot" } -toolpath-opencode = { version = "0.5.0", path = "crates/toolpath-opencode" } -toolpath-cursor = { version = "0.2.0", path = "crates/toolpath-cursor" } -toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" } -toolpath-dot = { version = "0.5.0", path = "crates/toolpath-dot" } -toolpath-md = { version = "0.7.0", path = "crates/toolpath-md" } -toolpath-pi = { version = "0.6.1", path = "crates/toolpath-pi" } -path-cli = { version = "0.15.0", path = "crates/path-cli" } +toolpath = { version = "0.8.0", path = "crates/toolpath" } +toolpath-convo = { version = "0.12.0", path = "crates/toolpath-convo" } +toolpath-git = { version = "0.7.0", path = "crates/toolpath-git" } +toolpath-claude = { version = "0.13.0", path = "crates/toolpath-claude", default-features = false } +toolpath-gemini = { version = "0.7.0", path = "crates/toolpath-gemini", default-features = false } +toolpath-codex = { version = "0.7.0", path = "crates/toolpath-codex" } +toolpath-copilot = { version = "0.2.0", path = "crates/toolpath-copilot" } +toolpath-opencode = { version = "0.6.0", path = "crates/toolpath-opencode" } +toolpath-cursor = { version = "0.3.0", path = "crates/toolpath-cursor" } +toolpath-github = { version = "0.7.0", path = "crates/toolpath-github" } +toolpath-dot = { version = "0.6.0", path = "crates/toolpath-dot" } +toolpath-md = { version = "0.8.0", path = "crates/toolpath-md" } +toolpath-pi = { version = "0.7.0", path = "crates/toolpath-pi" } +path-cli = { version = "0.16.0", path = "crates/path-cli" } pathbase-client = { version = "0.2.0", path = "crates/pathbase-client" } reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls"] } diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index 328767c2..575c972f 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "path-cli" -version = "0.15.0" +version = "0.16.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/path-cli/kinds/agent-coding-session/v1.2.0/schema.json b/crates/path-cli/kinds/agent-coding-session/v1.2.0/schema.json new file mode 100644 index 00000000..57ea8ac2 --- /dev/null +++ b/crates/path-cli/kinds/agent-coding-session/v1.2.0/schema.json @@ -0,0 +1,250 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://toolpath.net/kinds/agent-coding-session/v1.2.0/schema.json", + "title": "Toolpath kind: agent-coding-session v1.2.0", + "description": "Additive constraints on a Toolpath `Path` whose `meta.kind` is the agent-coding-session v1.2.0 URI. Apply alongside the base Toolpath schema; the path is valid when both pass. These constraints are by structural `type`, not by artifact key — a `change` entry is checked only when its `structural.type` matches one this kind defines (`conversation.append`, `file.write`, `conversation.event`). Everything is additive: unmentioned properties are allowed, so producer-specific extras never invalidate a path. From v1.1.0: `group_id` on the turn payload, and the group-level accounting rule — within a `group_id` group, the last step (document order) carries the group's total `token_usage` verbatim from the source and the other steps carry none; values are per-group amounts (never cumulative session counters); summing `token_usage` over a path's steps therefore yields the session totals. The once-per-group rule is normative prose (JSON Schema cannot express it); producers must enforce it. New in v1.2.0: an optional `model` on the turn payload — the model that produced the turn, verbatim from the source; omitted when the source records none. The human-readable contract is at https://toolpath.net/kinds/agent-coding-session/v1.2.0/.", + "type": "object", + + "$defs": { + "tokenUsage": { + "type": "object", + "description": "Token accounting for one source group (a message for Claude, a round for Codex). `token_usage` always means the total for a group, verbatim from the source — never a cumulative session counter, never a per-step share. Within a `group_id` group the total sits on the group's last step (document order) and the other steps carry none; a step without a `group_id` is a group of one, so its value is that group's total. Summing over a path's steps therefore yields the session totals. `input_tokens`/`output_tokens` are always emitted (possibly null); cache counters appear only when the source records them.", + "properties": { + "input_tokens": { "type": ["integer", "null"] }, + "output_tokens": { "type": ["integer", "null"] }, + "cache_read_tokens": { "type": "integer" }, + "cache_write_tokens": { "type": "integer" }, + "breakdowns": { + "type": "object", + "description": "Optional decomposition of a top-level class into named sub-classes, keyed by the class being broken down (e.g. \"output\"); each value maps sub-class → tokens. INFORMATIONAL: never summed into the total — the parent class already counts these. Invariant: Σ(inner) ≤ the parent class's value.", + "additionalProperties": { "type": "object", "additionalProperties": { "type": "integer" } } + } + }, + "required": ["input_tokens", "output_tokens"] + }, + + "attributedTokenUsage": { + "type": "object", + "description": "This step's own attributed spend, when the source provides step-aligned data — distinct from `token_usage` (the group total). Optional and orthogonal: it rides its own key so summing `token_usage` over steps is unaffected. Within a `group_id` group, the sum of `attributed_token_usage` over its steps is the group's attributed spend; the unattributed remainder (`group token_usage − Σ attributed`) is computed by consumers, never recorded. Same field shape as `tokenUsage`. A producer populates it only when the source genuinely reports per-step spend — among current producers, Codex does (each step is a separate API call with a reported per-call delta); Claude does not (its per-block usage is a cumulative streaming snapshot, not a per-block cost), so Claude-derived steps carry the group total only.", + "properties": { + "input_tokens": { "type": ["integer", "null"] }, + "output_tokens": { "type": ["integer", "null"] }, + "cache_read_tokens": { "type": "integer" }, + "cache_write_tokens": { "type": "integer" } + } + }, + + "toolResult": { + "type": "object", + "properties": { + "content": { "type": "string" }, + "is_error": { "type": "boolean" } + }, + "required": ["content", "is_error"] + }, + + "toolUse": { + "type": "object", + "description": "One tool invocation. `input` is producer-specific JSON (left unconstrained). `category` is Toolpath's classification, or null when the tool is unrecognized. `result` is present only when the result was available in the same turn.", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "input": true, + "category": { + "type": ["string", "null"], + "enum": [ + "file_read", + "file_write", + "file_search", + "shell", + "network", + "delegation", + null + ] + }, + "result": { "$ref": "#/$defs/toolResult" } + }, + "required": ["id", "name", "input", "category"] + }, + + "environment": { + "type": "object", + "description": "Working environment captured at the turn. All fields optional.", + "properties": { + "working_dir": { "type": "string" }, + "vcs_branch": { "type": "string" }, + "vcs_revision": { "type": "string" } + } + }, + + "delegation": { + "type": "object", + "description": "Sub-agent work spawned from a turn. `turns` carries the sub-agent's own turns when the producer inlines them.", + "properties": { + "agent_id": { "type": "string" }, + "prompt": { "type": "string" }, + "turns": { "type": "array" }, + "result": { "type": "string" } + }, + "required": ["agent_id", "prompt"] + }, + + "conversationAppend": { + "type": "object", + "description": "The turn payload: the `structural` object of the one `change` entry whose `type` is `conversation.append`. `role` and `text` are always present (text may be empty); everything else appears only when the turn carries it. `group_id` is the provider's identifier for the source accounting unit this turn was derived from — a message for Claude (`message.id`), a round for Codex (`turn_id`). A grouping key, not a step identifier: steps sharing a `group_id` came from one accounting unit (Claude Code writes one JSONL line per content block; a Codex round emits several turns).", + "properties": { + "type": { "const": "conversation.append" }, + "role": { "type": "string" }, + "text": { "type": "string" }, + "thinking": { "type": "string" }, + "model": { + "type": "string", + "description": "The model that produced the turn (assistant turns), verbatim from the source — the same value the step actor (`agent:`) and `meta.actors` attribute. Present only when the source records one; never a placeholder." + }, + "group_id": { "type": "string" }, + "tool_uses": { + "type": "array", + "items": { "$ref": "#/$defs/toolUse" } + }, + "token_usage": { "$ref": "#/$defs/tokenUsage" }, + "attributed_token_usage": { "$ref": "#/$defs/attributedTokenUsage" }, + "stop_reason": { "type": "string" }, + "delegations": { + "type": "array", + "items": { "$ref": "#/$defs/delegation" } + }, + "environment": { "$ref": "#/$defs/environment" } + }, + "required": ["type", "role", "text"] + }, + + "fileWrite": { + "type": "object", + "description": "The `structural` object of a sibling `file.write` change keyed by file path. The unified diff (when present) lives on the artifact change's `raw`, not here. `tool_id`/`tool` link the mutation to the `ToolInvocation` that caused it when attributable.", + "properties": { + "type": { "const": "file.write" }, + "tool_id": { "type": "string" }, + "tool": { "type": "string" }, + "operation": { "type": "string" }, + "before": { "type": "string" }, + "after": { "type": "string" }, + "rename_to": { "type": "string" } + }, + "required": ["type"] + }, + + "conversationEvent": { + "type": "object", + "description": "The `structural` object of a `conversation.event` change — a non-turn entry (attachment, preamble line, snapshot, …) preserved for round-trip fidelity. `entry_type` names the source entry kind; the producer's flattened event data rides alongside.", + "properties": { + "type": { "const": "conversation.event" }, + "entry_type": { "type": "string" }, + "event_source_id": { "type": "string" } + }, + "required": ["type", "entry_type"] + }, + + "artifactChange": { + "type": "object", + "description": "An artifact change, constrained only when its `structural.type` is one this kind defines.", + "allOf": [ + { + "if": { + "type": "object", + "properties": { + "structural": { + "type": "object", + "properties": { "type": { "const": "conversation.append" } }, + "required": ["type"] + } + }, + "required": ["structural"] + }, + "then": { + "properties": { + "structural": { "$ref": "#/$defs/conversationAppend" } + } + } + }, + { + "if": { + "type": "object", + "properties": { + "structural": { + "type": "object", + "properties": { "type": { "const": "file.write" } }, + "required": ["type"] + } + }, + "required": ["structural"] + }, + "then": { + "properties": { + "structural": { "$ref": "#/$defs/fileWrite" } + } + } + }, + { + "if": { + "type": "object", + "properties": { + "structural": { + "type": "object", + "properties": { "type": { "const": "conversation.event" } }, + "required": ["type"] + } + }, + "required": ["structural"] + }, + "then": { + "properties": { + "structural": { "$ref": "#/$defs/conversationEvent" } + } + } + } + ] + } + }, + + "properties": { + "meta": { + "type": "object", + "description": "Path metadata. `kind` pins this spec; `source` names the producing harness; `producer`/`files_changed`/`vcs_remote` are flattened session-level fields (PathMeta carries `extra` via serde flatten, so they sit directly under `meta`, not under `meta.extra`).", + "properties": { + "kind": { + "const": "https://toolpath.net/kinds/agent-coding-session/v1.2.0" + }, + "source": { "type": "string" }, + "files_changed": { + "type": "array", + "items": { "type": "string" } + }, + "vcs_remote": { "type": "string" }, + "producer": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "version": { "type": "string" } + }, + "required": ["name"] + } + }, + "required": ["kind"] + }, + + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "change": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/artifactChange" } + } + } + } + } + }, + + "required": ["meta"] +} diff --git a/crates/path-cli/src/cmd_kind.rs b/crates/path-cli/src/cmd_kind.rs index 955bcf07..3fee5bb9 100644 --- a/crates/path-cli/src/cmd_kind.rs +++ b/crates/path-cli/src/cmd_kind.rs @@ -82,7 +82,7 @@ mod tests { #[test] fn newest_version_resolves_and_is_json() { let k = kinds::resolve("agent-coding-session").unwrap(); - assert_eq!(k.version, "v1.1.0"); + assert_eq!(k.version, "v1.2.0"); let _: serde_json::Value = serde_json::from_str(k.schema).expect("bundled schema is valid JSON"); } diff --git a/crates/path-cli/src/kinds.rs b/crates/path-cli/src/kinds.rs index 34131bfd..e3dc4fdd 100644 --- a/crates/path-cli/src/kinds.rs +++ b/crates/path-cli/src/kinds.rs @@ -38,9 +38,15 @@ pub const BUNDLED_KINDS: &[BundledKind] = &[ BundledKind { name: "agent-coding-session", version: "v1.1.0", - uri: toolpath::v1::PATH_KIND_AGENT_CODING_SESSION, + uri: toolpath::v1::PATH_KIND_AGENT_CODING_SESSION_V1_1_0, schema: include_str!("../kinds/agent-coding-session/v1.1.0/schema.json"), }, + BundledKind { + name: "agent-coding-session", + version: "v1.2.0", + uri: toolpath::v1::PATH_KIND_AGENT_CODING_SESSION, + schema: include_str!("../kinds/agent-coding-session/v1.2.0/schema.json"), + }, ]; /// A parsed `(major, minor, patch)` version triple. @@ -256,7 +262,7 @@ mod tests { #[test] fn resolve_picks_newest_for_bare_name() { let k = resolve("agent-coding-session").expect("bundled"); - assert_eq!(k.version, "v1.1.0"); + assert_eq!(k.version, "v1.2.0"); } #[test] diff --git a/crates/path-cli/src/query/mod.rs b/crates/path-cli/src/query/mod.rs index 6d5d2cfc..fe259def 100644 --- a/crates/path-cli/src/query/mod.rs +++ b/crates/path-cli/src/query/mod.rs @@ -405,7 +405,7 @@ mod tests { fn wrap_graph_filters_by_kind() { let graph = Graph::from_path(forked_path()); let mut out = Vec::new(); - let sel = kinds::parse_kind_selector("agent-coding-session/v1.1.0"); + let sel = kinds::parse_kind_selector("agent-coding-session/v1.2.0"); wrap_graph(&doc_src("g"), &graph, Some(&sel), None, &mut out); assert_eq!(out.len(), 4, "matching kind keeps all steps"); diff --git a/crates/path-cli/src/schema.rs b/crates/path-cli/src/schema.rs index d98d33e4..111f3ab1 100644 --- a/crates/path-cli/src/schema.rs +++ b/crates/path-cli/src/schema.rs @@ -222,7 +222,7 @@ mod tests { validate(&doc).expect("base is optional on path identity"); } - const ACS_KIND: &str = "https://toolpath.net/kinds/agent-coding-session/v1.1.0"; + const ACS_KIND: &str = "https://toolpath.net/kinds/agent-coding-session/v1.2.0"; fn acs_graph(append: serde_json::Value) -> serde_json::Value { json!({ diff --git a/crates/path-cli/tests/query.rs b/crates/path-cli/tests/query.rs index 68525567..a9041355 100644 --- a/crates/path-cli/tests/query.rs +++ b/crates/path-cli/tests/query.rs @@ -471,7 +471,8 @@ fn kind_lists_bundled_kinds() { .assert() .success() .stdout(predicate::str::contains("agent-coding-session")) - .stdout(predicate::str::contains("v1.1.0")); + .stdout(predicate::str::contains("v1.1.0")) + .stdout(predicate::str::contains("v1.2.0")); } #[test] @@ -481,7 +482,7 @@ fn kind_prints_newest_schema() { .assert() .success() .stdout(predicate::str::contains( - "kinds/agent-coding-session/v1.1.0/schema.json", + "kinds/agent-coding-session/v1.2.0/schema.json", )); } diff --git a/crates/toolpath-claude/Cargo.toml b/crates/toolpath-claude/Cargo.toml index 5f463412..a0aaf7ab 100644 --- a/crates/toolpath-claude/Cargo.toml +++ b/crates/toolpath-claude/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-claude" -version = "0.12.0" +version = "0.13.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-cli/Cargo.toml b/crates/toolpath-cli/Cargo.toml index dd3ddc7f..54cf9fe6 100644 --- a/crates/toolpath-cli/Cargo.toml +++ b/crates/toolpath-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-cli" -version = "0.15.0" +version = "0.16.0" edition = "2024" license = "Apache-2.0" repository = "https://github.com/empathic/toolpath" @@ -14,7 +14,7 @@ name = "path" path = "src/main.rs" [dependencies] -path-cli = { path = "../path-cli", version = "0.15.0" } +path-cli = { path = "../path-cli", version = "0.16.0" } anyhow = "1.0" [workspace] diff --git a/crates/toolpath-codex/Cargo.toml b/crates/toolpath-codex/Cargo.toml index edf70bbf..1df0dc19 100644 --- a/crates/toolpath-codex/Cargo.toml +++ b/crates/toolpath-codex/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-codex" -version = "0.6.0" +version = "0.7.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-convo/Cargo.toml b/crates/toolpath-convo/Cargo.toml index 43652540..6ec4f1d0 100644 --- a/crates/toolpath-convo/Cargo.toml +++ b/crates/toolpath-convo/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-convo" -version = "0.11.1" +version = "0.12.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-convo/src/derive.rs b/crates/toolpath-convo/src/derive.rs index b0d13266..eacd601c 100644 --- a/crates/toolpath-convo/src/derive.rs +++ b/crates/toolpath-convo/src/derive.rs @@ -158,6 +158,16 @@ pub fn derive_path(view: &ConversationView, config: &DeriveConfig) -> Path { ); } + // The model rides the payload itself (as well as the actor string / + // `meta.actors`), so the append is self-describing. Omitted when the + // source records none — never a sentinel string. + if let Some(model) = &turn.model { + extra.insert( + "model".to_string(), + serde_json::Value::String(model.clone()), + ); + } + if config.include_tool_uses && !turn.tool_uses.is_empty() { let arr: Vec = turn .tool_uses @@ -864,7 +874,7 @@ mod tests { // ...and survives a JSON round-trip. let json = serde_json::to_string(&path).unwrap(); assert!( - json.contains(r#""kind":"https://toolpath.net/kinds/agent-coding-session/v1.1.0""#) + json.contains(r#""kind":"https://toolpath.net/kinds/agent-coding-session/v1.2.0""#) ); } @@ -946,12 +956,29 @@ mod tests { assert_eq!(path.steps[0].step.actor, "agent:claude-opus-4-7"); } + #[test] + fn test_assistant_model_in_append_payload() { + let mut turn = base_turn("t1", Role::Assistant); + turn.model = Some("claude-opus-4-8".into()); + let view = view_with(vec![turn]); + let path = derive_path(&view, &DeriveConfig::default()); + let sc = conv_change(&path.steps[0]); + assert_eq!( + sc.extra.get("model"), + Some(&serde_json::json!("claude-opus-4-8")) + ); + } + #[test] fn test_assistant_without_model() { let turn = base_turn("t1", Role::Assistant); let view = view_with(vec![turn]); let path = derive_path(&view, &DeriveConfig::default()); assert_eq!(path.steps[0].step.actor, "agent:unknown"); + // No model recorded → no `model` key at all; the `agent:unknown` + // actor sentinel never leaks into the payload. + let sc = conv_change(&path.steps[0]); + assert!(!sc.extra.contains_key("model")); } #[test] @@ -1102,7 +1129,7 @@ mod tests { let schema_src = std::fs::read_to_string(concat!( env!("CARGO_MANIFEST_DIR"), - "/../path-cli/kinds/agent-coding-session/v1.1.0/schema.json" + "/../path-cli/kinds/agent-coding-session/v1.2.0/schema.json" )) .expect("read kind schema"); let schema: serde_json::Value = serde_json::from_str(&schema_src).unwrap(); diff --git a/crates/toolpath-convo/src/extract.rs b/crates/toolpath-convo/src/extract.rs index c1d83800..59c2ded6 100644 --- a/crates/toolpath-convo/src/extract.rs +++ b/crates/toolpath-convo/src/extract.rs @@ -288,8 +288,14 @@ fn build_turn(step: &Step, extra: &HashMap) -> Turn { .and_then(|v| v.as_str()) .map(|s| s.to_string()); - // Model is attributed via the step actor (`agent:{model}`). - let model = model_from_actor(&step.step.actor); + // Model: prefer the payload's own `model` (kind v1.2.0 — the append is + // self-describing), falling back to the step actor (`agent:{model}`) for + // documents produced before the payload carried it. + let model = extra + .get("model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| model_from_actor(&step.step.actor)); let stop_reason = extra .get("stop_reason") @@ -731,6 +737,30 @@ mod tests { assert_eq!(view.turns[0].group_id.as_deref(), Some("msg_01abc")); } + #[test] + fn test_payload_model_preferred_over_actor() { + // The payload's `model` (kind v1.2.0) wins even when the actor + // string doesn't follow the `agent:` convention. + let path = make_path(vec![make_step( + "step-001", + "agent:unknown", + "2026-01-01T00:00:00Z", + vec![], + vec![( + "claude-code://sess-1", + "conversation.append", + extras(&[ + ("role", serde_json::json!("assistant")), + ("text", serde_json::json!("")), + ("model", serde_json::json!("claude-opus-4-8")), + ]), + )], + )]); + + let view = extract_conversation(&path); + assert_eq!(view.turns[0].model.as_deref(), Some("claude-opus-4-8")); + } + #[test] fn test_tool_invocations_attached_to_parent() { let path = make_path(vec![ diff --git a/crates/toolpath-copilot/Cargo.toml b/crates/toolpath-copilot/Cargo.toml index cd58626d..53530497 100644 --- a/crates/toolpath-copilot/Cargo.toml +++ b/crates/toolpath-copilot/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-copilot" -version = "0.1.0" +version = "0.2.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-cursor/Cargo.toml b/crates/toolpath-cursor/Cargo.toml index ca09aa92..b02cf9cc 100644 --- a/crates/toolpath-cursor/Cargo.toml +++ b/crates/toolpath-cursor/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-cursor" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-dot/Cargo.toml b/crates/toolpath-dot/Cargo.toml index 22d6ffff..d53953a0 100644 --- a/crates/toolpath-dot/Cargo.toml +++ b/crates/toolpath-dot/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-dot" -version = "0.5.0" +version = "0.6.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-gemini/Cargo.toml b/crates/toolpath-gemini/Cargo.toml index 76fdeedc..7ba036b6 100644 --- a/crates/toolpath-gemini/Cargo.toml +++ b/crates/toolpath-gemini/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-gemini" -version = "0.6.0" +version = "0.7.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-git/Cargo.toml b/crates/toolpath-git/Cargo.toml index 89d089a6..5453f9ab 100644 --- a/crates/toolpath-git/Cargo.toml +++ b/crates/toolpath-git/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-git" -version = "0.6.0" +version = "0.7.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-github/Cargo.toml b/crates/toolpath-github/Cargo.toml index 08dd7f5d..d4030e2b 100644 --- a/crates/toolpath-github/Cargo.toml +++ b/crates/toolpath-github/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-github" -version = "0.6.0" +version = "0.7.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-md/Cargo.toml b/crates/toolpath-md/Cargo.toml index 07efdae3..83e5ad1d 100644 --- a/crates/toolpath-md/Cargo.toml +++ b/crates/toolpath-md/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-md" -version = "0.7.0" +version = "0.8.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-opencode/Cargo.toml b/crates/toolpath-opencode/Cargo.toml index 82e90bd4..9966a7d0 100644 --- a/crates/toolpath-opencode/Cargo.toml +++ b/crates/toolpath-opencode/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-opencode" -version = "0.5.0" +version = "0.6.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-pi/Cargo.toml b/crates/toolpath-pi/Cargo.toml index 72b1e236..f8ff6209 100644 --- a/crates/toolpath-pi/Cargo.toml +++ b/crates/toolpath-pi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-pi" -version = "0.6.1" +version = "0.7.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath/Cargo.toml b/crates/toolpath/Cargo.toml index 77f38495..edfcb39d 100644 --- a/crates/toolpath/Cargo.toml +++ b/crates/toolpath/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath" -version = "0.7.0" +version = "0.8.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath/src/jsonl.rs b/crates/toolpath/src/jsonl.rs index 17139da6..e399aa32 100644 --- a/crates/toolpath/src/jsonl.rs +++ b/crates/toolpath/src/jsonl.rs @@ -1250,7 +1250,7 @@ mod tests { }; let jsonl = p.to_jsonl_string().unwrap(); assert!( - jsonl.contains(r#""kind":"https://toolpath.net/kinds/agent-coding-session/v1.1.0""#) + jsonl.contains(r#""kind":"https://toolpath.net/kinds/agent-coding-session/v1.2.0""#) ); let back = Path::from_jsonl_str(&jsonl).unwrap(); assert_eq!(canonical_json(&p), canonical_json(&back)); @@ -1259,14 +1259,14 @@ mod tests { #[test] fn path_meta_line_can_set_kind() { let patch = PathMetaPatch { - kind: Some("https://toolpath.net/kinds/agent-coding-session/v1.1.0".into()), + kind: Some("https://toolpath.net/kinds/agent-coding-session/v1.2.0".into()), ..Default::default() }; let mut meta = PathMeta::default(); apply_meta_patch(&mut meta, patch); assert_eq!( meta.kind.as_deref(), - Some("https://toolpath.net/kinds/agent-coding-session/v1.1.0") + Some("https://toolpath.net/kinds/agent-coding-session/v1.2.0") ); } diff --git a/crates/toolpath/src/lib.rs b/crates/toolpath/src/lib.rs index 423ab3f1..f8c0c102 100644 --- a/crates/toolpath/src/lib.rs +++ b/crates/toolpath/src/lib.rs @@ -147,8 +147,8 @@ pub mod v1 { pub use crate::types::{ ActorDefinition, ArtifactChange, Base, Graph, GraphIdentity, GraphMeta, Identity, Key, - PATH_KIND_AGENT_CODING_SESSION, PATH_KIND_AGENT_CODING_SESSION_V1_0_0, Path, PathIdentity, - PathMeta, PathOrRef, PathRef, Ref, Signature, Step, StepIdentity, StepMeta, - StructuralChange, VcsSource, + PATH_KIND_AGENT_CODING_SESSION, PATH_KIND_AGENT_CODING_SESSION_V1_0_0, + PATH_KIND_AGENT_CODING_SESSION_V1_1_0, Path, PathIdentity, PathMeta, PathOrRef, PathRef, + Ref, Signature, Step, StepIdentity, StepMeta, StructuralChange, VcsSource, }; } diff --git a/crates/toolpath/src/types.rs b/crates/toolpath/src/types.rs index d1be6137..423f3063 100644 --- a/crates/toolpath/src/types.rs +++ b/crates/toolpath/src/types.rs @@ -141,16 +141,21 @@ pub struct Base { } /// [`PathMeta::kind`] URI for a path derived from an AI coding conversation. -/// Spec at . +/// Spec at . /// -/// v1.1.0 specifies message-level token accounting: steps derived from one -/// provider message share a `message_id`, and the message's `token_usage` -/// appears on exactly one of them (the group's last step in document -/// order), so summing usage over a path's steps yields session totals. +/// v1.2.0 makes the turn payload self-describing about attribution: an +/// assistant `conversation.append` carries an optional `model` (the model +/// that produced the turn), omitted when the source records none. pub const PATH_KIND_AGENT_CODING_SESSION: &str = + "https://toolpath.net/kinds/agent-coding-session/v1.2.0"; + +/// The previous version URI. v1.1.0 documents attribute the model only via +/// the step actor (`agent:`) and `meta.actors`; the append payload +/// carries no `model` key. +pub const PATH_KIND_AGENT_CODING_SESSION_V1_1_0: &str = "https://toolpath.net/kinds/agent-coding-session/v1.1.0"; -/// The previous version URI. Documents produced before the v1.1.0 +/// The v1.0.0 URI. Documents produced before the v1.1.0 /// accounting rule carry this kind; consumers summing their `token_usage` /// per step must deduplicate repeated message-level usage themselves. pub const PATH_KIND_AGENT_CODING_SESSION_V1_0_0: &str = @@ -836,12 +841,12 @@ mod tests { }; let json = serde_json::to_string(&meta).unwrap(); assert!( - json.contains(r#""kind":"https://toolpath.net/kinds/agent-coding-session/v1.1.0""#) + json.contains(r#""kind":"https://toolpath.net/kinds/agent-coding-session/v1.2.0""#) ); let parsed: PathMeta = serde_json::from_str(&json).unwrap(); assert_eq!( parsed.kind.as_deref(), - Some("https://toolpath.net/kinds/agent-coding-session/v1.1.0") + Some("https://toolpath.net/kinds/agent-coding-session/v1.2.0") ); } diff --git a/site/_data/crates.json b/site/_data/crates.json index dc86dffe..af3dd9a2 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -1,7 +1,7 @@ [ { "name": "toolpath", - "version": "0.7.0", + "version": "0.8.0", "description": "Core types, builders, and query API", "docs": "https://docs.rs/toolpath", "crate": "https://crates.io/crates/toolpath", @@ -9,7 +9,7 @@ }, { "name": "toolpath-convo", - "version": "0.11.1", + "version": "0.12.0", "description": "Provider-agnostic conversation types, traits, and Toolpath-Path derivation", "docs": "https://docs.rs/toolpath-convo", "crate": "https://crates.io/crates/toolpath-convo", @@ -17,7 +17,7 @@ }, { "name": "toolpath-git", - "version": "0.6.0", + "version": "0.7.0", "description": "Derive from git repository history", "docs": "https://docs.rs/toolpath-git", "crate": "https://crates.io/crates/toolpath-git", @@ -25,7 +25,7 @@ }, { "name": "toolpath-github", - "version": "0.6.0", + "version": "0.7.0", "description": "Derive from GitHub pull requests", "docs": "https://docs.rs/toolpath-github", "crate": "https://crates.io/crates/toolpath-github", @@ -33,7 +33,7 @@ }, { "name": "toolpath-claude", - "version": "0.12.0", + "version": "0.13.0", "description": "Derive from Claude conversation logs", "docs": "https://docs.rs/toolpath-claude", "crate": "https://crates.io/crates/toolpath-claude", @@ -41,7 +41,7 @@ }, { "name": "toolpath-gemini", - "version": "0.6.0", + "version": "0.7.0", "description": "Derive from Gemini CLI conversation logs", "docs": "https://docs.rs/toolpath-gemini", "crate": "https://crates.io/crates/toolpath-gemini", @@ -49,7 +49,7 @@ }, { "name": "toolpath-codex", - "version": "0.6.0", + "version": "0.7.0", "description": "Derive from Codex CLI rollout files", "docs": "https://docs.rs/toolpath-codex", "crate": "https://crates.io/crates/toolpath-codex", @@ -57,7 +57,7 @@ }, { "name": "toolpath-copilot", - "version": "0.1.0", + "version": "0.2.0", "description": "Derive from GitHub Copilot CLI session logs (preview)", "docs": "https://docs.rs/toolpath-copilot", "crate": "https://crates.io/crates/toolpath-copilot", @@ -65,7 +65,7 @@ }, { "name": "toolpath-opencode", - "version": "0.5.0", + "version": "0.6.0", "description": "Derive from opencode SQLite databases", "docs": "https://docs.rs/toolpath-opencode", "crate": "https://crates.io/crates/toolpath-opencode", @@ -73,7 +73,7 @@ }, { "name": "toolpath-pi", - "version": "0.6.1", + "version": "0.7.0", "description": "Derive Toolpath provenance documents from Pi (pi.dev) agent session logs", "docs": "https://docs.rs/toolpath-pi", "crate": "https://crates.io/crates/toolpath-pi", @@ -81,7 +81,7 @@ }, { "name": "toolpath-cursor", - "version": "0.2.0", + "version": "0.3.0", "description": "Derive Toolpath provenance documents from Cursor (IDE) composers", "docs": "https://docs.rs/toolpath-cursor", "crate": "https://crates.io/crates/toolpath-cursor", @@ -89,7 +89,7 @@ }, { "name": "toolpath-dot", - "version": "0.5.0", + "version": "0.6.0", "description": "Graphviz DOT visualization", "docs": "https://docs.rs/toolpath-dot", "crate": "https://crates.io/crates/toolpath-dot", @@ -97,7 +97,7 @@ }, { "name": "toolpath-md", - "version": "0.7.0", + "version": "0.8.0", "description": "Markdown rendering for LLM consumption", "docs": "https://docs.rs/toolpath-md", "crate": "https://crates.io/crates/toolpath-md", @@ -113,7 +113,7 @@ }, { "name": "path-cli", - "version": "0.15.0", + "version": "0.16.0", "description": "Unified CLI (binary: path)", "docs": "https://docs.rs/path-cli", "crate": "https://crates.io/crates/path-cli", @@ -121,7 +121,7 @@ }, { "name": "toolpath-cli", - "version": "0.15.0", + "version": "0.16.0", "description": "Deprecated alias for path-cli", "docs": "https://docs.rs/toolpath-cli", "crate": "https://crates.io/crates/toolpath-cli", diff --git a/site/kinds/agent-coding-session/index.md b/site/kinds/agent-coding-session/index.md index 5bf48e6d..ed5aaad9 100644 --- a/site/kinds/agent-coding-session/index.md +++ b/site/kinds/agent-coding-session/index.md @@ -12,5 +12,6 @@ Documents reference a specific version URI. They do not depend on this landing p ## Versions -- [**v1.1.0**](/kinds/agent-coding-session/v1.1.0/): `https://toolpath.net/kinds/agent-coding-session/v1.1.0` _(current)_ — adds `group_id` and specifies message-level token accounting (a message's usage appears on exactly one step, so per-step sums equal session totals) +- [**v1.2.0**](/kinds/agent-coding-session/v1.2.0/): `https://toolpath.net/kinds/agent-coding-session/v1.2.0` _(current)_ — adds an optional `model` to the turn payload (the model that produced the turn; previously only in the step actor and `meta.actors`) +- [**v1.1.0**](/kinds/agent-coding-session/v1.1.0/): `https://toolpath.net/kinds/agent-coding-session/v1.1.0` — adds `group_id` and specifies message-level token accounting (a message's usage appears on exactly one step, so per-step sums equal session totals) - [**v1.0.0**](/kinds/agent-coding-session/v1.0.0/): `https://toolpath.net/kinds/agent-coding-session/v1.0.0` — superseded; see its erratum on token accounting diff --git a/site/kinds/agent-coding-session/v1.2.0/index.md b/site/kinds/agent-coding-session/v1.2.0/index.md new file mode 100644 index 00000000..37649036 --- /dev/null +++ b/site/kinds/agent-coding-session/v1.2.0/index.md @@ -0,0 +1,164 @@ +--- +layout: base.njk +title: "Kind: agent-coding-session v1.2.0" +permalink: /kinds/agent-coding-session/v1.2.0/ +--- + +# Kind: `agent-coding-session` v1.2.0 + +
+
URI
+
https://toolpath.net/kinds/agent-coding-session/v1.2.0
+
Schema
+
schema.json
+
+ +A Toolpath path whose `meta.kind` is this URI records an AI coding conversation. It is an ordinary path with the extra structure described here. `head`-ancestry, dead ends, signatures, and `base` all behave as in the [base format](/format/). + +Every such path comes from one place: the shared `ConversationView → Path` derivation in `toolpath-convo` (`derive_path`), which the provider crates (`toolpath-claude`, `toolpath-gemini`, `toolpath-codex`, `toolpath-opencode`, `toolpath-cursor`, `toolpath-pi`, `toolpath-copilot`) all call. The field shapes below are therefore exact. The only producer-specific parts are the contents of a tool's `input`, the diff text in a change's `raw`, and the value (not the meaning) of `group_id`. + +Constraints apply by structural `type`, not by artifact key: a `change` entry is checked only when its `structural.type` is one named here, and extra properties never make a path invalid. [`schema.json`](./schema.json) encodes the rules; apply it alongside the base schema. The URI is immutable. Later revisions ship under a new version URI. + +**Changed from [v1.1.0](/kinds/agent-coding-session/v1.1.0/):** the turn payload gains an optional `model` — see [`model`](#model). v1.2.0 documents are structurally valid v1.1.0 documents; the new version exists so consumers can rely on the payload carrying the model when the source records one, without parsing the actor string. + +## The turn payload + +One entry in a turn's `change` map has `structural.type` of `"conversation.append"`. Find it by that type: the artifact key is producer-specific, formed as `://` from the harness in `meta.source` (e.g. `claude-code://…`, `gemini-cli://…`, `codex://…`, `opencode://…`, `cursor://…`, `pi://…`, `copilot://…`). + +Its `structural` object always carries: + +| Field | Type | Meaning | +| ------ | ------ | ------------------------------------------------------------------ | +| `type` | string | the literal `"conversation.append"` | +| `role` | string | `"user"`, `"assistant"`, `"system"`, or a producer-specific string | +| `text` | string | the visible prose; present even when empty (`""`) | + +It may also carry any of the following, present only when the turn has them: + +| Field | Type | Meaning | +| ------------------------ | ------ | -------------------------------------------------------------------- | +| `thinking` | string | the model's reasoning text | +| `model` | string | the model that produced the turn (see below) | +| `group_id` | string | groups the steps derived from one source accounting unit (see below) | +| `tool_uses` | array | tools the agent invoked (shape below) | +| `token_usage` | object | the group's token counts (shape and rule below) | +| `attributed_token_usage` | object | this step's own attributed spend, when known (see below) | +| `stop_reason` | string | why the model stopped (`end_turn`, `tool_use`, …) | +| `delegations` | array | sub-agent work spawned from this turn (shape below) | +| `environment` | object | working environment at this turn (shape below) | + +There is no provider-specific blob: every field the derivation captures is one of those listed above. + +### `model` + +The model that produced the turn, verbatim from the source (e.g. `claude-opus-4-8`, `gpt-5.5`). Present only when the source records one — never a placeholder; a turn whose source records no model simply carries no `model` key. It is the same value the step actor (`agent:`) and `meta.actors` attribute (see [Actors](#actors)); the payload carries it too so the append is self-describing and consumers never have to parse the actor string. + +### `group_id` + +The provider's identifier for the **source accounting unit** these steps were derived from — Claude Code's `message.id` (`msg_…`) for one split message, Codex's round `turn_id` for one round (which may itself contain several messages). It is a **grouping key, not a step identifier**: when a producer derives several steps from one accounting unit (Claude Code writes one JSONL line per content block; a Codex round emits a commentary turn plus a final turn), every sibling step carries the same `group_id`. A step without a `group_id` is its own group of one. The stored value is the provider's verbatim id; only its _meaning_ (which unit it names) is provider-specific. + +### Group accounting + +How `token_usage` on steps relates to the source's accounting units: + +1. `token_usage` records a group's spend — a **per-group amount, never a cumulative session counter**. +2. Within a run of consecutive steps sharing a `group_id` (document order), the run's **last step carries the group's total `token_usage`, verbatim from the source**. In this version, the run's other steps carry none. +3. A step without a `group_id` is its own group and carries its own `token_usage` (when the source records one). + +Consequence: **summing `token_usage` over a v1.2.0 path's steps yields the session totals.** Consumers need no dedup heuristics. (JSON Schema cannot express the once-per-run rule, so it is normative prose, enforced by producer test suites.) + +`token_usage` has **one meaning everywhere it appears: the total for a group**. A step without a `group_id` is a one-step group, so its `token_usage` is that group's total (which is also its own spend — the two coincide for a group of one). Within a multi-step group, the total sits on the final step. Interpreting a value never requires reading the rest of its group: the key tells you it is a total, and `group_id` on the same payload tells you which group it totals. Per-step spend, when the source has it, rides a separate [`attributed_token_usage`](#per-step-attribution-attributed_token_usage) key — never `token_usage`. When a source format offers both a group total and a finer breakdown (Claude's `usage.iterations`, opencode's per-part `step-finish` tokens), `token_usage` carries the total; the breakdown is subordinate detail and does not ride `token_usage`. + +### Per-step attribution: `attributed_token_usage` + +Some sources expose, per step, the spend attributable to that step alone — distinct from the group total. Where a producer has it, the step carries an **`attributed_token_usage`** object (same shape as [`token_usage`](#token_usage)) holding _this step's own share_. It is **optional and orthogonal to `token_usage`**: whether a number is a group total or a step share is structural — the key it sits under — never positional. This is the rule that lets per-step accounting be added by any producer at any time without a new kind version. + +How it relates to the group total: + +- Within a `group_id` group, `Σ attributed_token_usage` over the group's steps is the group's attributed spend. The **unattributed remainder** — anything the source could not pin to a step — is _computed_ by a consumer as `group's token_usage − Σ group's attributed_token_usage`; it is never recorded, so stored values stay verbatim source observations and source inconsistencies stay visible. +- For a group where the source attributes everything (e.g. Codex, where each step is a separate API call and the per-call delta is reported directly), the remainder is zero and `Σ attributed_token_usage == token_usage`. +- A group with no per-step data carries no `attributed_token_usage` at all — only the group total. Producers must not fabricate a split. + +A producer populates `attributed_token_usage` only when the source genuinely reports per-step spend. Among current producers, **Codex does** (its `token_count` events carry a per-call delta). **Claude does not**: its per-content-block `usage` values are cumulative streaming snapshots stamped at flush time, not per-block costs, so deriving a split from them would be fabrication — Claude-derived steps carry the group total only. + +`Σ token_usage` over a path's steps is unaffected by `attributed_token_usage` (they are separate keys), so the session-total guarantee above always holds. A consumer wanting per-step cost reads `attributed_token_usage` where present and falls back to the group total otherwise. + +### `tool_uses` + +Each element is an object: + +| Field | Type | Notes | +| ---------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `id` | string | provider-assigned invocation ID | +| `name` | string | provider tool name (`Read`, `Bash`, `edit`, …) | +| `input` | any | tool arguments; shape is producer-specific | +| `category` | string \| null | Toolpath's classification: `file_read`, `file_write`, `file_search`, `shell`, `network`, `delegation`, or `null` when unrecognized | +| `result` | object | `{ "content": string, "is_error": boolean }`, when the result landed in the same turn | + +`id`, `name`, `input`, and `category` are always present (`category` may be `null`); `result` is optional. + +### `token_usage` + +| Field | Type | Notes | +| -------------------- | --------------- | ------------------------------------------------- | +| `input_tokens` | integer \| null | always present | +| `output_tokens` | integer \| null | always present | +| `cache_read_tokens` | integer | only when the source records it | +| `cache_write_tokens` | integer | only when the source records it | +| `breakdowns` | object | only when the source itemizes a class (see below) | + +Values follow the [group accounting](#group-accounting) rule above. + +`breakdowns` is an **optional, informational** decomposition of a top-level class into named sub-classes. It is keyed by the class being broken down (e.g. `"output"`); each value is a map of sub-class → tokens (e.g. `{ "output": { "reasoning": 450 } }`). Breakdowns are **never summed into any total** — the parent class already counts these tokens; a breakdown only says _how_ that class divides. Invariant: **`Σ(inner) ≤` the parent class's value**. The field is omitted entirely when empty. The same shape and rule apply on `attributed_token_usage`. Among current producers, Gemini, OpenCode, and Codex record `output → { reasoning }` (their reasoning/thoughts tokens are part of `output_tokens`); Claude records none (its JSONL `usage` does not itemize thinking tokens). + +### `environment` + +`{ "working_dir"?: string, "vcs_branch"?: string, "vcs_revision"?: string }`; every field optional. + +### `delegations` + +Each element is `{ "agent_id": string, "prompt": string, "turns"?: array, "result"?: string }`. `turns` holds the sub-agent's own turns when the producer inlines them. + +## File changes + +When a turn writes files, its step carries sibling `change` entries keyed by file path, each with `structural.type` of `"file.write"`. The unified diff, when available, is on the change's `raw`, not inside `structural`. The `structural` object holds, all optional: + +| Field | Meaning | +| ------------------ | ------------------------------------------------------------------ | +| `tool_id` | the `tool_uses[].id` that produced the mutation, when attributable | +| `tool` | that tool's `name` | +| `operation` | `"add"`, `"update"`, `"delete"`, or a producer-specific tag | +| `before` / `after` | file contents before / after, when known | +| `rename_to` | the new path, for a rename | + +## Non-turn entries + +Entries that aren't turns (attachments, preamble lines, snapshots, hook results) become steps with `structural.type` of `"conversation.event"`, carrying `entry_type` and sometimes `event_source_id` plus the producer's event data. They exist so a document round-trips back to the source format. They are not part of the transcript. + +## Actors + +`step.actor` follows the `type:name` convention, assigned by role: + +| Actor | Turn | +| ----------------- | ---------------------------------------------------------------------------------------------- | +| `human:user` | a user message | +| `agent:` | a model reply, named by the recorded model, or `agent:unknown` when none was recorded | +| `tool:` | a system turn (session init, system prompt), any other producer role, or a non-turn event step | + +`meta.actors` defines each actor the steps reference; `agent:` entries carry `provider` and `model`. A turn's original role is always in its `role` field, so collapsing system and other roles onto `tool:` loses nothing. Walk steps in `head`-ancestry order for the linear transcript. + +The turn payload's [`model`](#model) matches the actor's: `agent:` and the payload `model` name the same recorded value, and `agent:unknown` corresponds to a payload with no `model` key (the `unknown` sentinel exists only in the actor string, never in the payload). + +## Path metadata + +| Field | Meaning | +| -------------------- | ----------------------------------------------------------------------------------------------------- | +| `meta.kind` | this URI | +| `meta.source` | the producing harness: `claude-code`, `gemini-cli`, `codex`, `opencode`, `cursor`, `pi`, or `copilot` | +| `meta.title` | session title | +| `meta.actors` | the actor definitions the steps reference | +| `meta.files_changed` | file paths touched across the session | +| `meta.vcs_remote` | repository URL, when known | +| `meta.producer` | `{ "name": string, "version"?: string }`, the software that produced the session | + +`files_changed`, `vcs_remote`, and `producer` sit directly under `meta` (they ride `PathMeta`'s flattened `extra`), not under a nested `meta.extra`. diff --git a/site/kinds/agent-coding-session/v1.2.0/schema.json b/site/kinds/agent-coding-session/v1.2.0/schema.json new file mode 120000 index 00000000..4282dd28 --- /dev/null +++ b/site/kinds/agent-coding-session/v1.2.0/schema.json @@ -0,0 +1 @@ +../../../../crates/path-cli/kinds/agent-coding-session/v1.2.0/schema.json \ No newline at end of file diff --git a/site/kinds/index.md b/site/kinds/index.md index 91164e5a..356d2c28 100644 --- a/site/kinds/index.md +++ b/site/kinds/index.md @@ -14,4 +14,4 @@ Kind URIs are immutable: revisions ship at a new version URI, and old URIs keep | Kind | Current URI | Spec | | ------------------------------------------------------ | -------------------------------------------------------- | --------------------------------------------- | -| [`agent-coding-session`](/kinds/agent-coding-session/) | `https://toolpath.net/kinds/agent-coding-session/v1.1.0` | [v1.1.0](/kinds/agent-coding-session/v1.1.0/) | +| [`agent-coding-session`](/kinds/agent-coding-session/) | `https://toolpath.net/kinds/agent-coding-session/v1.2.0` | [v1.2.0](/kinds/agent-coding-session/v1.2.0/) |