From faa180d5e7b043d2ac64526ad1c142e29fed6d0f Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Tue, 12 May 2026 16:41:49 -0400 Subject: [PATCH 1/2] feat(toolpath): add meta.kind path-kind field; tag conversation paths "convo" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New optional `meta.kind` on `Path` (`PathMeta::kind` + the `PATH_KIND_CONVERSATION` constant), plumbed through the JSONL form. Every conversation -> Path derivation now sets `meta.kind = "convo"` — the shared `toolpath_convo::derive_path` and each conversation provider crate's own. Documented in the RFC ("Document Kind") and the JSON Schema. Additive; no version bumps yet. --- CHANGELOG.md | 20 ++++++++++ CLAUDE.md | 1 + RFC.md | 23 ++++++++++- crates/toolpath-claude/src/derive.rs | 20 +++++++++- crates/toolpath-codex/src/derive.rs | 16 +++++++- crates/toolpath-convo/src/derive.rs | 22 +++++++++-- crates/toolpath-gemini/src/derive.rs | 15 ++++++- crates/toolpath-opencode/src/derive.rs | 23 ++++++++++- crates/toolpath-pi/src/derive.rs | 5 +++ crates/toolpath/schema/toolpath.schema.json | 5 +++ crates/toolpath/src/jsonl.rs | 43 +++++++++++++++++++++ crates/toolpath/src/lib.rs | 5 ++- crates/toolpath/src/types.rs | 24 ++++++++++++ 13 files changed, 207 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14d5c40..25baea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to the Toolpath workspace are documented here. +## `meta.kind` — new path-kind field; conversation paths tagged `convo` — unreleased + +New optional `meta.kind` field on `Path` (`toolpath::v1::PathMeta::kind`, plus +the `toolpath::v1::PATH_KIND_CONVERSATION` constant) — a hint to renderers and +generic parsers that a path follows a recognizable shape. The only defined +value is `"convo"`: each step is a `conversation.append` change carrying that +turn's `role`, `text`, and so on, and `meta.source` names the producing +harness. Absent means generic — existing documents parse and validate +unchanged, and `kind` is omitted when unset. + +Every conversation → `Path` derivation now sets `meta.kind = "convo"` (the +shared `toolpath_convo::derive_path` and each conversation provider crate's +own). The JSONL form carries `kind` through `PathOpen.meta` and `PathMeta` +patch lines. Documented in the RFC ("Document Kind") and the JSON Schema +(`$defs/pathMeta`). + +Touches `toolpath`, `toolpath-convo`, `toolpath-claude`, `toolpath-gemini`, +`toolpath-codex`, `toolpath-opencode`, and `toolpath-pi`; versions to be +bumped at release. + ## `path resume` — one-shot resume into a coding agent — 2026-05-09 `path-cli` 0.9.0. New subcommand `path resume ` that fetches a diff --git a/CLAUDE.md b/CLAUDE.md index 1556973..e556de4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -221,6 +221,7 @@ Build the site after changes: `cd site && pnpm run build` (should produce 7 page - `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` has an optional `kind` field — a hint to renderers/parsers about the path's shape. Currently the only value is `"convo"` (constant `toolpath::v1::PATH_KIND_CONVERSATION`): a path where each step is a `conversation.append` change carrying that turn's content. Every conversation→`Path` derivation sets it — the shared `derive_path` and each provider crate's own (`toolpath-claude`/`-gemini`/`-codex`/`-opencode`; `-pi` inherits it via the shared mapping). It rides through the JSONL form too (`PathOpen.meta` / `PathMeta` patch lines). Documented in `RFC.md` ("Document Kind") and the JSON Schema's `$defs/pathMeta`. - 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. - opencode provider: `toolpath-opencode` reads a SQLite database at `~/.local/share/opencode/opencode.db` (opened read-only). Each session's messages and 12 typed part variants (text, reasoning, tool, step-start/-finish, snapshot, patch, file, agent, subtask, retry, compaction) land as one step per message with tool invocations attached. File diffs come from a sibling bare git repo at `snapshot//[]/` via `git2` tree↔tree diffs — opencode respects the user's `.gitignore`, so changes under gitignored paths fall back to tool-input-derived structural changes with no `raw` perspective. Project id is the SHA of the repo's first root commit. See `docs/agents/formats/opencode.md` for the full format reference. diff --git a/RFC.md b/RFC.md index a3d062c..5113f85 100644 --- a/RFC.md +++ b/RFC.md @@ -169,7 +169,7 @@ A **path** collects steps and provides root context: | -------- | --------------------------------------------------- | | `path` | Identity, base context, and head reference | | `steps` | Array of step objects | -| `meta` | Path-level metadata (title, actors, signatures) | +| `meta` | Path-level metadata (title, kind, actors, signatures) | The `path.base` anchors the entire tree to a specific state (repo + ref + commit). Steps within inherit this context. @@ -276,11 +276,30 @@ paths. | Field | Description | | ------------ | -------------------------------------------------- | +| `kind` | Path kind — see [Document Kind](#document-kind) (paths only) | | `intent` | Human-readable description of purpose | | `refs` | Links to issues, docs, reasoning | | `actors` | Actor definitions with identities and keys | | `signatures` | Cryptographic signatures for verification | +#### Document Kind + +`meta.kind` on a **path** classifies it — a hint that the path follows a +recognizable shape worth special-casing. It is always optional; an absent or +unrecognized `kind` should be treated as a generic path. The only value defined +so far is **`convo`** (future revisions may register more). + +A **`convo`** path is an AI coding conversation. Each conversational-turn step +carries one [`ArtifactChange`](#change-perspectives) whose `structural.type` is +`"conversation.append"` — find it by that `type`, not by artifact key. That +change's `structural` object always has `role` (`"user"` / `"assistant"` / +`"system"` / producer-specific) and, when the turn has prose, `text`; it may +also carry `thinking`, `tool_uses`, token counts, `stop_reason`, environment +fields, and a producer-namespaced bag of anything else. `meta.source` names the +producing harness (`claude-code`, `gemini-cli`, `codex`, `opencode`, `pi`); +structure beyond the `conversation.append` change — synthetic steps, file-write +artifacts, tool records — is producer-specific. + #### Actor Definitions `meta.actors` maps actor strings to full definitions: @@ -565,7 +584,7 @@ The path provides: - **base**: Where this tree branches from (repo + ref + commit) - **head**: Current tip of the active path - **steps**: All steps including dead ends (step-001a has no descendants) -- **meta**: Path-level metadata including actors and signatures +- **meta**: Path-level metadata including `kind` (see [Document Kind](#document-kind)), actors, and signatures ### Base Context diff --git a/crates/toolpath-claude/src/derive.rs b/crates/toolpath-claude/src/derive.rs index ec4907b..d63066c 100644 --- a/crates/toolpath-claude/src/derive.rs +++ b/crates/toolpath-claude/src/derive.rs @@ -12,8 +12,8 @@ use std::collections::HashMap; use std::path::Path as FsPath; use std::process::Command; use toolpath::v1::{ - ActorDefinition, ArtifactChange, Base, Identity, Path, PathIdentity, PathMeta, Step, - StepIdentity, StructuralChange, + ActorDefinition, ArtifactChange, Base, Identity, PATH_KIND_CONVERSATION, Path, PathIdentity, + PathMeta, Step, StepIdentity, StructuralChange, }; use toolpath_convo::file_write_diff; @@ -611,6 +611,7 @@ pub fn derive_path(conversation: &Conversation, config: &DeriveConfig) -> Path { steps, meta: Some(PathMeta { title: Some(format!("Claude session: {}", session_short)), + kind: Some(PATH_KIND_CONVERSATION.to_string()), source: Some("claude-code".to_string()), actors: if actors.is_empty() { None @@ -768,6 +769,21 @@ mod tests { assert!(path.steps[1].step.actor.starts_with("agent:")); } + #[test] + fn test_derive_path_meta_kind_is_convo() { + let convo = make_conversation(vec![make_entry( + "uuid-1111-aaaa", + MessageRole::User, + "Hello", + "2024-01-01T00:00:00Z", + )]); + let path = derive_path(&convo, &DeriveConfig::default()); + assert_eq!( + path.meta.as_ref().unwrap().kind.as_deref(), + Some(PATH_KIND_CONVERSATION) + ); + } + #[test] fn test_derive_path_step_parents() { let entries = vec![ diff --git a/crates/toolpath-codex/src/derive.rs b/crates/toolpath-codex/src/derive.rs index bfca648..6ac3f8e 100644 --- a/crates/toolpath-codex/src/derive.rs +++ b/crates/toolpath-codex/src/derive.rs @@ -16,8 +16,8 @@ use crate::types::{PatchChange, Session}; use serde_json::{Map, Value, json}; use std::collections::HashMap; use toolpath::v1::{ - ActorDefinition, ArtifactChange, Base, Identity, Path, PathIdentity, PathMeta, Step, - StepIdentity, StructuralChange, + ActorDefinition, ArtifactChange, Base, Identity, PATH_KIND_CONVERSATION, Path, PathIdentity, + PathMeta, Step, StepIdentity, StructuralChange, }; use toolpath_convo::{ConversationView, Role, Turn}; @@ -172,6 +172,7 @@ fn derive_path_from_view( steps, meta: Some(PathMeta { title: Some(format!("Codex session: {}", session_short)), + kind: Some(PATH_KIND_CONVERSATION.to_string()), source: Some("codex".to_string()), actors: if actors.is_empty() { None @@ -584,6 +585,17 @@ mod tests { assert!(actors.contains_key("agent:gpt-5.4")); } + #[test] + fn derive_path_meta_kind_is_convo() { + let (_t, mgr, id) = fixture_session(&minimal_body()); + let session = mgr.read_session(&id).unwrap(); + let path = derive_path(&session, &DeriveConfig::default()); + assert_eq!( + path.meta.as_ref().unwrap().kind.as_deref(), + Some(PATH_KIND_CONVERSATION) + ); + } + #[test] fn derive_path_preserves_conversation_artifact() { let (_t, mgr, id) = fixture_session(&minimal_body()); diff --git a/crates/toolpath-convo/src/derive.rs b/crates/toolpath-convo/src/derive.rs index 2b2886c..b245094 100644 --- a/crates/toolpath-convo/src/derive.rs +++ b/crates/toolpath-convo/src/derive.rs @@ -3,13 +3,15 @@ //! Provider-agnostic mapping used by the Pi, Claude, and future conversation //! providers. Takes a [`ConversationView`] and emits a [`Path`] document with //! one step per turn and a `conversation.append` structural change carrying -//! the turn's text, thinking, tool uses, and token usage. +//! the turn's text, thinking, tool uses, and token usage. The emitted path is +//! tagged with `meta.kind = "convo"` (`toolpath::v1::PATH_KIND_CONVERSATION`) +//! so renderers and parsers know it follows the conversation shape. use std::collections::HashMap; use toolpath::v1::{ - ActorDefinition, ArtifactChange, Base, Path, PathIdentity, PathMeta, Step, StepIdentity, - StructuralChange, + ActorDefinition, ArtifactChange, Base, PATH_KIND_CONVERSATION, Path, PathIdentity, PathMeta, + Step, StepIdentity, StructuralChange, }; use crate::{ConversationView, Role, ToolCategory, ToolInvocation, Turn}; @@ -243,6 +245,7 @@ pub fn derive_path(view: &ConversationView, config: &DeriveConfig) -> Path { let mut meta = PathMeta { title: Some(title), + kind: Some(PATH_KIND_CONVERSATION.to_string()), source: view.provider_id.clone(), ..Default::default() }; @@ -519,6 +522,19 @@ mod tests { assert_eq!(path.path.head, ""); } + #[test] + fn test_meta_kind_is_convo() { + let view = view_with(vec![base_turn("t1", Role::User)]); + let path = derive_path(&view, &DeriveConfig::default()); + assert_eq!( + path.meta.as_ref().unwrap().kind.as_deref(), + Some(PATH_KIND_CONVERSATION) + ); + // ...and survives a JSON round-trip. + let json = serde_json::to_string(&path).unwrap(); + assert!(json.contains(r#""kind":"convo""#)); + } + #[test] fn test_single_user_turn() { let mut turn = base_turn("t1", Role::User); diff --git a/crates/toolpath-gemini/src/derive.rs b/crates/toolpath-gemini/src/derive.rs index f0bc090..42f565e 100644 --- a/crates/toolpath-gemini/src/derive.rs +++ b/crates/toolpath-gemini/src/derive.rs @@ -15,8 +15,8 @@ use crate::types::{ChatFile, Conversation, GeminiMessage, GeminiRole, ToolCall}; use serde_json::json; use std::collections::HashMap; use toolpath::v1::{ - ActorDefinition, ArtifactChange, Base, Identity, Path, PathIdentity, PathMeta, Step, - StepIdentity, StructuralChange, + ActorDefinition, ArtifactChange, Base, Identity, PATH_KIND_CONVERSATION, Path, PathIdentity, + PathMeta, Step, StepIdentity, StructuralChange, }; use toolpath_convo::ToolCategory; @@ -126,6 +126,7 @@ pub fn derive_path(conversation: &Conversation, config: &DeriveConfig) -> Path { session_short } )), + kind: Some(PATH_KIND_CONVERSATION.to_string()), source: Some("gemini-cli".to_string()), actors: if actors.is_empty() { None @@ -518,6 +519,16 @@ mod tests { assert_eq!(path.path.head, path.steps.last().unwrap().step.id); } + #[test] + fn test_derive_path_meta_kind_is_convo() { + let convo = main_only_convo(); + let path = derive_path(&convo, &DeriveConfig::default()); + assert_eq!( + path.meta.as_ref().unwrap().kind.as_deref(), + Some(PATH_KIND_CONVERSATION) + ); + } + #[test] fn test_derive_path_parents_chain() { let convo = main_only_convo(); diff --git a/crates/toolpath-opencode/src/derive.rs b/crates/toolpath-opencode/src/derive.rs index cfb74c4..02fe900 100644 --- a/crates/toolpath-opencode/src/derive.rs +++ b/crates/toolpath-opencode/src/derive.rs @@ -18,8 +18,8 @@ use serde_json::{Map, Value, json}; use std::collections::HashMap; use std::path::{Path as StdPath, PathBuf}; use toolpath::v1::{ - ActorDefinition, ArtifactChange, Base, Identity, Path, PathIdentity, PathMeta, Step, - StepIdentity, StructuralChange, + ActorDefinition, ArtifactChange, Base, Identity, PATH_KIND_CONVERSATION, Path, PathIdentity, + PathMeta, Step, StepIdentity, StructuralChange, }; use toolpath_convo::{ConversationView, Role, Turn}; @@ -165,6 +165,7 @@ fn derive_path_from_view( steps, meta: Some(PathMeta { title: Some(format!("opencode session: {}", session.title)), + kind: Some(PATH_KIND_CONVERSATION.to_string()), source: Some("opencode".to_string()), actors: if actors.is_empty() { None @@ -691,6 +692,24 @@ mod tests { assert!(actors.contains_key("agent:claude-sonnet-4-6")); } + #[test] + fn derive_meta_kind_is_convo() { + let (_t, mgr, resolver) = fixture(BASIC_SQL); + let s = mgr.read_session("ses_abc123").unwrap(); + let p = derive_path_with_resolver( + &s, + &DeriveConfig { + no_snapshot_diffs: true, + ..Default::default() + }, + &resolver, + ); + assert_eq!( + p.meta.as_ref().unwrap().kind.as_deref(), + Some(PATH_KIND_CONVERSATION) + ); + } + #[test] fn derive_fallback_file_artifact_from_tool() { let (_t, mgr, resolver) = fixture(BASIC_SQL); diff --git a/crates/toolpath-pi/src/derive.rs b/crates/toolpath-pi/src/derive.rs index d60a8be..db6214c 100644 --- a/crates/toolpath-pi/src/derive.rs +++ b/crates/toolpath-pi/src/derive.rs @@ -106,6 +106,11 @@ mod tests { "got: {}", path.path.id ); + // The shared derivation tags conversation paths with `meta.kind`. + assert_eq!( + path.meta.as_ref().unwrap().kind.as_deref(), + Some(toolpath::v1::PATH_KIND_CONVERSATION) + ); } #[test] diff --git a/crates/toolpath/schema/toolpath.schema.json b/crates/toolpath/schema/toolpath.schema.json index aab7962..791f94a 100644 --- a/crates/toolpath/schema/toolpath.schema.json +++ b/crates/toolpath/schema/toolpath.schema.json @@ -336,6 +336,11 @@ "type": "string", "description": "Human-readable title" }, + "kind": { + "type": "string", + "description": "The kind of this path — a hint to renderers and parsers that it follows a particular shape and may carry kind-specific conventions. Currently the only defined value is \"convo\": a path derived from an agent conversation, where each step is a `conversation.append` change carrying that turn's content. Absent means generic — no kind-specific structure is implied.", + "examples": ["convo"] + }, "source": { "type": "string", "description": "Source reference (e.g., PR URL)" diff --git a/crates/toolpath/src/jsonl.rs b/crates/toolpath/src/jsonl.rs index 69f5898..266d032 100644 --- a/crates/toolpath/src/jsonl.rs +++ b/crates/toolpath/src/jsonl.rs @@ -101,6 +101,8 @@ pub struct PathOpenMeta { #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub intent: Option, @@ -148,6 +150,8 @@ pub struct PathMetaPatch { #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub intent: Option, @@ -347,6 +351,7 @@ impl Path { let mut meta = PathMeta::default(); if let Some(m) = po.meta { meta.title = m.title; + meta.kind = m.kind; meta.source = m.source; meta.intent = m.intent; meta.refs = m.refs; @@ -504,6 +509,9 @@ fn apply_meta_patch(path_meta: &mut PathMeta, patch: PathMetaPatch) { if let Some(v) = patch.title { path_meta.title = Some(v); } + if let Some(v) = patch.kind { + path_meta.kind = Some(v); + } if let Some(v) = patch.source { path_meta.source = Some(v); } @@ -546,6 +554,7 @@ fn resolve_head(explicit: Option, steps: &[Step]) -> Result bool { m.title.is_none() + && m.kind.is_none() && m.source.is_none() && m.intent.is_none() && m.refs.is_empty() @@ -670,12 +679,14 @@ fn step_meta_is_empty(m: &StepMeta) -> bool { fn path_meta_for_open(m: &PathMeta) -> Option { let open = PathOpenMeta { title: m.title.clone(), + kind: m.kind.clone(), source: m.source.clone(), intent: m.intent.clone(), refs: m.refs.clone(), extra: m.extra.clone(), }; if open.title.is_none() + && open.kind.is_none() && open.source.is_none() && open.intent.is_none() && open.refs.is_empty() @@ -1222,6 +1233,38 @@ mod tests { assert_eq!(canonical_json(&p), canonical_json(&back)); } + #[test] + fn roundtrip_kind_in_path_meta() { + let p = Path { + path: PathIdentity { + id: "p".into(), + base: None, + head: "s1".into(), + graph_ref: None, + }, + steps: vec![make_step("s1", None)], + meta: Some(PathMeta { + kind: Some(crate::v1::PATH_KIND_CONVERSATION.to_string()), + ..Default::default() + }), + }; + let jsonl = p.to_jsonl_string().unwrap(); + assert!(jsonl.contains(r#""kind":"convo""#)); + let back = Path::from_jsonl_str(&jsonl).unwrap(); + assert_eq!(canonical_json(&p), canonical_json(&back)); + } + + #[test] + fn path_meta_line_can_set_kind() { + let patch = PathMetaPatch { + kind: Some("convo".into()), + ..Default::default() + }; + let mut meta = PathMeta::default(); + apply_meta_patch(&mut meta, patch); + assert_eq!(meta.kind.as_deref(), Some("convo")); + } + #[test] fn change_artifact_roundtrip_preserved() { // Sanity check that we don't mangle ArtifactChange fields through diff --git a/crates/toolpath/src/lib.rs b/crates/toolpath/src/lib.rs index 19fe93b..2afb7f5 100644 --- a/crates/toolpath/src/lib.rs +++ b/crates/toolpath/src/lib.rs @@ -50,6 +50,7 @@ pub mod v1 { //! Optional annotations for richer context: //! //! - [`StepMeta`], [`PathMeta`], [`GraphMeta`] — metadata containers + //! - [`PATH_KIND_CONVERSATION`] — value for [`PathMeta::kind`] on conversation-derived paths //! - [`ActorDefinition`] — full actor details (name, provider, keys) //! - [`Identity`] — external identity reference //! - [`Key`] — cryptographic key reference @@ -146,7 +147,7 @@ pub mod v1 { pub use crate::types::{ ActorDefinition, ArtifactChange, Base, Graph, GraphIdentity, GraphMeta, Identity, Key, - Path, PathIdentity, PathMeta, PathOrRef, PathRef, Ref, Signature, Step, StepIdentity, - StepMeta, StructuralChange, VcsSource, + PATH_KIND_CONVERSATION, 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 a6551bd..9d514a6 100644 --- a/crates/toolpath/src/types.rs +++ b/crates/toolpath/src/types.rs @@ -140,12 +140,18 @@ pub struct Base { pub branch: Option, } +/// [`PathMeta::kind`] value for a path derived from an AI coding conversation. +/// See the Toolpath RFC's "Document Kind" section. +pub const PATH_KIND_CONVERSATION: &str = "convo"; + /// Path metadata #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct PathMeta { #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub intent: Option, @@ -810,6 +816,24 @@ mod tests { assert!(json.contains("issues/1")); } + #[test] + fn test_path_meta_kind_serde() { + let meta = PathMeta { + kind: Some(PATH_KIND_CONVERSATION.to_string()), + ..Default::default() + }; + let json = serde_json::to_string(&meta).unwrap(); + assert!(json.contains(r#""kind":"convo""#)); + let parsed: PathMeta = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.kind.as_deref(), Some("convo")); + } + + #[test] + fn test_path_meta_kind_omitted_when_none() { + let json = serde_json::to_string(&PathMeta::default()).unwrap(); + assert!(!json.contains("kind")); + } + #[test] fn test_identity_serialization() { let id = super::Identity { From d8b26ded61f137f3bc038311231cbc33b1a487ac Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Wed, 13 May 2026 13:31:58 -0400 Subject: [PATCH 2/2] rename `convo` kind value to `agent-coding-session` Renames the kind value `"convo"` to `"agent-coding-session"` and the constant `PATH_KIND_CONVERSATION` to `PATH_KIND_AGENT_CODING_SESSION`. Updates schema, RFC, CHANGELOG, CLAUDE.md, and all derive crates + tests. --- CHANGELOG.md | 8 ++++---- CLAUDE.md | 2 +- RFC.md | 4 ++-- crates/toolpath-claude/src/derive.rs | 6 +++--- crates/toolpath-codex/src/derive.rs | 6 +++--- crates/toolpath-convo/src/derive.rs | 10 +++++----- crates/toolpath-gemini/src/derive.rs | 6 +++--- crates/toolpath-opencode/src/derive.rs | 6 +++--- crates/toolpath-pi/src/derive.rs | 2 +- crates/toolpath/schema/toolpath.schema.json | 2 +- crates/toolpath/src/jsonl.rs | 8 ++++---- crates/toolpath/src/lib.rs | 4 ++-- crates/toolpath/src/types.rs | 8 ++++---- 13 files changed, 36 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25baea8..deb634a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,17 +2,17 @@ All notable changes to the Toolpath workspace are documented here. -## `meta.kind` — new path-kind field; conversation paths tagged `convo` — unreleased +## `meta.kind` — new path-kind field; conversation paths tagged `agent-coding-session` — unreleased New optional `meta.kind` field on `Path` (`toolpath::v1::PathMeta::kind`, plus -the `toolpath::v1::PATH_KIND_CONVERSATION` constant) — a hint to renderers and +the `toolpath::v1::PATH_KIND_AGENT_CODING_SESSION` constant) — a hint to renderers and generic parsers that a path follows a recognizable shape. The only defined -value is `"convo"`: each step is a `conversation.append` change carrying that +value is `"agent-coding-session"`: each step is a `conversation.append` change carrying that turn's `role`, `text`, and so on, and `meta.source` names the producing harness. Absent means generic — existing documents parse and validate unchanged, and `kind` is omitted when unset. -Every conversation → `Path` derivation now sets `meta.kind = "convo"` (the +Every conversation → `Path` derivation now sets `meta.kind = "agent-coding-session"` (the shared `toolpath_convo::derive_path` and each conversation provider crate's own). The JSONL form carries `kind` through `PathOpen.meta` and `PathMeta` patch lines. Documented in the RFC ("Document Kind") and the JSON Schema diff --git a/CLAUDE.md b/CLAUDE.md index e556de4..34686ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -221,7 +221,7 @@ Build the site after changes: `cd site && pnpm run build` (should produce 7 page - `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` has an optional `kind` field — a hint to renderers/parsers about the path's shape. Currently the only value is `"convo"` (constant `toolpath::v1::PATH_KIND_CONVERSATION`): a path where each step is a `conversation.append` change carrying that turn's content. Every conversation→`Path` derivation sets it — the shared `derive_path` and each provider crate's own (`toolpath-claude`/`-gemini`/`-codex`/`-opencode`; `-pi` inherits it via the shared mapping). It rides through the JSONL form too (`PathOpen.meta` / `PathMeta` patch lines). Documented in `RFC.md` ("Document Kind") and the JSON Schema's `$defs/pathMeta`. +- Path kinds: `toolpath::v1::PathMeta` has an optional `kind` field — a hint to renderers/parsers about the path's shape. Currently the only value is `"agent-coding-session"` (constant `toolpath::v1::PATH_KIND_AGENT_CODING_SESSION`): a path where each step is a `conversation.append` change carrying that turn's content. Every conversation→`Path` derivation sets it — the shared `derive_path` and each provider crate's own (`toolpath-claude`/`-gemini`/`-codex`/`-opencode`; `-pi` inherits it via the shared mapping). It rides through the JSONL form too (`PathOpen.meta` / `PathMeta` patch lines). Documented in `RFC.md` ("Document Kind") and the JSON Schema's `$defs/pathMeta`. - 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. - opencode provider: `toolpath-opencode` reads a SQLite database at `~/.local/share/opencode/opencode.db` (opened read-only). Each session's messages and 12 typed part variants (text, reasoning, tool, step-start/-finish, snapshot, patch, file, agent, subtask, retry, compaction) land as one step per message with tool invocations attached. File diffs come from a sibling bare git repo at `snapshot//[]/` via `git2` tree↔tree diffs — opencode respects the user's `.gitignore`, so changes under gitignored paths fall back to tool-input-derived structural changes with no `raw` perspective. Project id is the SHA of the repo's first root commit. See `docs/agents/formats/opencode.md` for the full format reference. diff --git a/RFC.md b/RFC.md index 5113f85..90c3dde 100644 --- a/RFC.md +++ b/RFC.md @@ -287,9 +287,9 @@ paths. `meta.kind` on a **path** classifies it — a hint that the path follows a recognizable shape worth special-casing. It is always optional; an absent or unrecognized `kind` should be treated as a generic path. The only value defined -so far is **`convo`** (future revisions may register more). +so far is **`agent-coding-session`** (future revisions may register more). -A **`convo`** path is an AI coding conversation. Each conversational-turn step +A **`agent-coding-session`** path is an AI coding conversation. Each conversational-turn step carries one [`ArtifactChange`](#change-perspectives) whose `structural.type` is `"conversation.append"` — find it by that `type`, not by artifact key. That change's `structural` object always has `role` (`"user"` / `"assistant"` / diff --git a/crates/toolpath-claude/src/derive.rs b/crates/toolpath-claude/src/derive.rs index d63066c..ad84b33 100644 --- a/crates/toolpath-claude/src/derive.rs +++ b/crates/toolpath-claude/src/derive.rs @@ -12,7 +12,7 @@ use std::collections::HashMap; use std::path::Path as FsPath; use std::process::Command; use toolpath::v1::{ - ActorDefinition, ArtifactChange, Base, Identity, PATH_KIND_CONVERSATION, Path, PathIdentity, + ActorDefinition, ArtifactChange, Base, Identity, PATH_KIND_AGENT_CODING_SESSION, Path, PathIdentity, PathMeta, Step, StepIdentity, StructuralChange, }; use toolpath_convo::file_write_diff; @@ -611,7 +611,7 @@ pub fn derive_path(conversation: &Conversation, config: &DeriveConfig) -> Path { steps, meta: Some(PathMeta { title: Some(format!("Claude session: {}", session_short)), - kind: Some(PATH_KIND_CONVERSATION.to_string()), + kind: Some(PATH_KIND_AGENT_CODING_SESSION.to_string()), source: Some("claude-code".to_string()), actors: if actors.is_empty() { None @@ -780,7 +780,7 @@ mod tests { let path = derive_path(&convo, &DeriveConfig::default()); assert_eq!( path.meta.as_ref().unwrap().kind.as_deref(), - Some(PATH_KIND_CONVERSATION) + Some(PATH_KIND_AGENT_CODING_SESSION) ); } diff --git a/crates/toolpath-codex/src/derive.rs b/crates/toolpath-codex/src/derive.rs index 6ac3f8e..bc082b2 100644 --- a/crates/toolpath-codex/src/derive.rs +++ b/crates/toolpath-codex/src/derive.rs @@ -16,7 +16,7 @@ use crate::types::{PatchChange, Session}; use serde_json::{Map, Value, json}; use std::collections::HashMap; use toolpath::v1::{ - ActorDefinition, ArtifactChange, Base, Identity, PATH_KIND_CONVERSATION, Path, PathIdentity, + ActorDefinition, ArtifactChange, Base, Identity, PATH_KIND_AGENT_CODING_SESSION, Path, PathIdentity, PathMeta, Step, StepIdentity, StructuralChange, }; use toolpath_convo::{ConversationView, Role, Turn}; @@ -172,7 +172,7 @@ fn derive_path_from_view( steps, meta: Some(PathMeta { title: Some(format!("Codex session: {}", session_short)), - kind: Some(PATH_KIND_CONVERSATION.to_string()), + kind: Some(PATH_KIND_AGENT_CODING_SESSION.to_string()), source: Some("codex".to_string()), actors: if actors.is_empty() { None @@ -592,7 +592,7 @@ mod tests { let path = derive_path(&session, &DeriveConfig::default()); assert_eq!( path.meta.as_ref().unwrap().kind.as_deref(), - Some(PATH_KIND_CONVERSATION) + Some(PATH_KIND_AGENT_CODING_SESSION) ); } diff --git a/crates/toolpath-convo/src/derive.rs b/crates/toolpath-convo/src/derive.rs index b245094..619e606 100644 --- a/crates/toolpath-convo/src/derive.rs +++ b/crates/toolpath-convo/src/derive.rs @@ -4,13 +4,13 @@ //! providers. Takes a [`ConversationView`] and emits a [`Path`] document with //! one step per turn and a `conversation.append` structural change carrying //! the turn's text, thinking, tool uses, and token usage. The emitted path is -//! tagged with `meta.kind = "convo"` (`toolpath::v1::PATH_KIND_CONVERSATION`) +//! tagged with `meta.kind = "agent-coding-session"` (`toolpath::v1::PATH_KIND_AGENT_CODING_SESSION`) //! so renderers and parsers know it follows the conversation shape. use std::collections::HashMap; use toolpath::v1::{ - ActorDefinition, ArtifactChange, Base, PATH_KIND_CONVERSATION, Path, PathIdentity, PathMeta, + ActorDefinition, ArtifactChange, Base, PATH_KIND_AGENT_CODING_SESSION, Path, PathIdentity, PathMeta, Step, StepIdentity, StructuralChange, }; @@ -245,7 +245,7 @@ pub fn derive_path(view: &ConversationView, config: &DeriveConfig) -> Path { let mut meta = PathMeta { title: Some(title), - kind: Some(PATH_KIND_CONVERSATION.to_string()), + kind: Some(PATH_KIND_AGENT_CODING_SESSION.to_string()), source: view.provider_id.clone(), ..Default::default() }; @@ -528,11 +528,11 @@ mod tests { let path = derive_path(&view, &DeriveConfig::default()); assert_eq!( path.meta.as_ref().unwrap().kind.as_deref(), - Some(PATH_KIND_CONVERSATION) + Some(PATH_KIND_AGENT_CODING_SESSION) ); // ...and survives a JSON round-trip. let json = serde_json::to_string(&path).unwrap(); - assert!(json.contains(r#""kind":"convo""#)); + assert!(json.contains(r#""kind":"agent-coding-session""#)); } #[test] diff --git a/crates/toolpath-gemini/src/derive.rs b/crates/toolpath-gemini/src/derive.rs index 42f565e..c4eb807 100644 --- a/crates/toolpath-gemini/src/derive.rs +++ b/crates/toolpath-gemini/src/derive.rs @@ -15,7 +15,7 @@ use crate::types::{ChatFile, Conversation, GeminiMessage, GeminiRole, ToolCall}; use serde_json::json; use std::collections::HashMap; use toolpath::v1::{ - ActorDefinition, ArtifactChange, Base, Identity, PATH_KIND_CONVERSATION, Path, PathIdentity, + ActorDefinition, ArtifactChange, Base, Identity, PATH_KIND_AGENT_CODING_SESSION, Path, PathIdentity, PathMeta, Step, StepIdentity, StructuralChange, }; use toolpath_convo::ToolCategory; @@ -126,7 +126,7 @@ pub fn derive_path(conversation: &Conversation, config: &DeriveConfig) -> Path { session_short } )), - kind: Some(PATH_KIND_CONVERSATION.to_string()), + kind: Some(PATH_KIND_AGENT_CODING_SESSION.to_string()), source: Some("gemini-cli".to_string()), actors: if actors.is_empty() { None @@ -525,7 +525,7 @@ mod tests { let path = derive_path(&convo, &DeriveConfig::default()); assert_eq!( path.meta.as_ref().unwrap().kind.as_deref(), - Some(PATH_KIND_CONVERSATION) + Some(PATH_KIND_AGENT_CODING_SESSION) ); } diff --git a/crates/toolpath-opencode/src/derive.rs b/crates/toolpath-opencode/src/derive.rs index 02fe900..409aa0d 100644 --- a/crates/toolpath-opencode/src/derive.rs +++ b/crates/toolpath-opencode/src/derive.rs @@ -18,7 +18,7 @@ use serde_json::{Map, Value, json}; use std::collections::HashMap; use std::path::{Path as StdPath, PathBuf}; use toolpath::v1::{ - ActorDefinition, ArtifactChange, Base, Identity, PATH_KIND_CONVERSATION, Path, PathIdentity, + ActorDefinition, ArtifactChange, Base, Identity, PATH_KIND_AGENT_CODING_SESSION, Path, PathIdentity, PathMeta, Step, StepIdentity, StructuralChange, }; use toolpath_convo::{ConversationView, Role, Turn}; @@ -165,7 +165,7 @@ fn derive_path_from_view( steps, meta: Some(PathMeta { title: Some(format!("opencode session: {}", session.title)), - kind: Some(PATH_KIND_CONVERSATION.to_string()), + kind: Some(PATH_KIND_AGENT_CODING_SESSION.to_string()), source: Some("opencode".to_string()), actors: if actors.is_empty() { None @@ -706,7 +706,7 @@ mod tests { ); assert_eq!( p.meta.as_ref().unwrap().kind.as_deref(), - Some(PATH_KIND_CONVERSATION) + Some(PATH_KIND_AGENT_CODING_SESSION) ); } diff --git a/crates/toolpath-pi/src/derive.rs b/crates/toolpath-pi/src/derive.rs index db6214c..7b04deb 100644 --- a/crates/toolpath-pi/src/derive.rs +++ b/crates/toolpath-pi/src/derive.rs @@ -109,7 +109,7 @@ mod tests { // The shared derivation tags conversation paths with `meta.kind`. assert_eq!( path.meta.as_ref().unwrap().kind.as_deref(), - Some(toolpath::v1::PATH_KIND_CONVERSATION) + Some(toolpath::v1::PATH_KIND_AGENT_CODING_SESSION) ); } diff --git a/crates/toolpath/schema/toolpath.schema.json b/crates/toolpath/schema/toolpath.schema.json index 791f94a..22db4f2 100644 --- a/crates/toolpath/schema/toolpath.schema.json +++ b/crates/toolpath/schema/toolpath.schema.json @@ -339,7 +339,7 @@ "kind": { "type": "string", "description": "The kind of this path — a hint to renderers and parsers that it follows a particular shape and may carry kind-specific conventions. Currently the only defined value is \"convo\": a path derived from an agent conversation, where each step is a `conversation.append` change carrying that turn's content. Absent means generic — no kind-specific structure is implied.", - "examples": ["convo"] + "examples": ["agent-coding-session"] }, "source": { "type": "string", diff --git a/crates/toolpath/src/jsonl.rs b/crates/toolpath/src/jsonl.rs index 266d032..d9da2e0 100644 --- a/crates/toolpath/src/jsonl.rs +++ b/crates/toolpath/src/jsonl.rs @@ -1244,12 +1244,12 @@ mod tests { }, steps: vec![make_step("s1", None)], meta: Some(PathMeta { - kind: Some(crate::v1::PATH_KIND_CONVERSATION.to_string()), + kind: Some(crate::v1::PATH_KIND_AGENT_CODING_SESSION.to_string()), ..Default::default() }), }; let jsonl = p.to_jsonl_string().unwrap(); - assert!(jsonl.contains(r#""kind":"convo""#)); + assert!(jsonl.contains(r#""kind":"agent-coding-session""#)); let back = Path::from_jsonl_str(&jsonl).unwrap(); assert_eq!(canonical_json(&p), canonical_json(&back)); } @@ -1257,12 +1257,12 @@ mod tests { #[test] fn path_meta_line_can_set_kind() { let patch = PathMetaPatch { - kind: Some("convo".into()), + kind: Some("agent-coding-session".into()), ..Default::default() }; let mut meta = PathMeta::default(); apply_meta_patch(&mut meta, patch); - assert_eq!(meta.kind.as_deref(), Some("convo")); + assert_eq!(meta.kind.as_deref(), Some("agent-coding-session")); } #[test] diff --git a/crates/toolpath/src/lib.rs b/crates/toolpath/src/lib.rs index 2afb7f5..8974fa3 100644 --- a/crates/toolpath/src/lib.rs +++ b/crates/toolpath/src/lib.rs @@ -50,7 +50,7 @@ pub mod v1 { //! Optional annotations for richer context: //! //! - [`StepMeta`], [`PathMeta`], [`GraphMeta`] — metadata containers - //! - [`PATH_KIND_CONVERSATION`] — value for [`PathMeta::kind`] on conversation-derived paths + //! - [`PATH_KIND_AGENT_CODING_SESSION`] — value for [`PathMeta::kind`] on conversation-derived paths //! - [`ActorDefinition`] — full actor details (name, provider, keys) //! - [`Identity`] — external identity reference //! - [`Key`] — cryptographic key reference @@ -147,7 +147,7 @@ pub mod v1 { pub use crate::types::{ ActorDefinition, ArtifactChange, Base, Graph, GraphIdentity, GraphMeta, Identity, Key, - PATH_KIND_CONVERSATION, Path, PathIdentity, PathMeta, PathOrRef, PathRef, Ref, Signature, + PATH_KIND_AGENT_CODING_SESSION, 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 9d514a6..a95b7c2 100644 --- a/crates/toolpath/src/types.rs +++ b/crates/toolpath/src/types.rs @@ -142,7 +142,7 @@ pub struct Base { /// [`PathMeta::kind`] value for a path derived from an AI coding conversation. /// See the Toolpath RFC's "Document Kind" section. -pub const PATH_KIND_CONVERSATION: &str = "convo"; +pub const PATH_KIND_AGENT_CODING_SESSION: &str = "agent-coding-session"; /// Path metadata #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -819,13 +819,13 @@ mod tests { #[test] fn test_path_meta_kind_serde() { let meta = PathMeta { - kind: Some(PATH_KIND_CONVERSATION.to_string()), + kind: Some(PATH_KIND_AGENT_CODING_SESSION.to_string()), ..Default::default() }; let json = serde_json::to_string(&meta).unwrap(); - assert!(json.contains(r#""kind":"convo""#)); + assert!(json.contains(r#""kind":"agent-coding-session""#)); let parsed: PathMeta = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.kind.as_deref(), Some("convo")); + assert_eq!(parsed.kind.as_deref(), Some("agent-coding-session")); } #[test]