diff --git a/CHANGELOG.md b/CHANGELOG.md index c13fa974..a8b1c23b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,153 @@ All notable changes to the Toolpath workspace are documented here. +## `path p cache sync` — incremental session ingestion — 2026-07-09 + +Adds `path p cache sync [types…]`, the first step toward a cache that fills +itself: it enumerates sessions across the installed agent harnesses and +derives into the cache only what is new or changed since the last sync, so +users no longer have to `p import` each session by hand. + +- **`path-cli`** (0.16.0): + - `path p cache sync [claude|gemini|codex|opencode|cursor|pi]…` syncs the + named artifact types; with no arguments it syncs every harness. + Enumeration reuses the `path share` aggregation, and per-session + derivation goes through the same provider managers, so listing and + derivation always agree on provider roots. + - The sync manifest at `~/.toolpath/sync.json` maps artifact type → + artifact id → `{path?, cache_id, modified?, size?, synced_at}`. Change detection is stat-level — source mtime + size + for the file-backed providers, the DB row's updated-at for + opencode/cursor — so deciding "nothing changed" reads no session + bodies and a no-op sync is milliseconds. The manifest is written + atomically (temp file + rename, + `0600`) and checkpointed every 10 writes with derives running + newest-first, so an interrupted first run keeps nearly everything + it derived — and spent its time on the sessions that matter most. + Pending work reports progress on stderr (live ` done/total` + on a TTY, a plain line every 25 items otherwise; no-op syncs stay + silent). + - `ArtifactType` (in `sync.rs`) is the general enum naming artifact + sources: `p cache sync` types, the manifest keys, and import + cache-id prefixes all use it (it absorbs the former `HarnessArg`). + The `Harness` enum stays as the harness-only layer that + `share`/`resume` `--harness` take — you can't resume into a git + repo — mapping into the general enum via `Harness::artifact_type()`. + - Sync owns refresh semantics: it overwrites cache entries it + re-derives (no `--force` needed), while manual `p import` keeps its + error-on-hit default. Sessions deleted upstream keep both their cache + document and their manifest record — the cache is an archive, not a + mirror. + - Per-type summary on stderr (`claude 3 new, 1 updated, 240 unchanged`). + On a default run, harnesses with no sessions stay silent; explicit + types always report. Derivation failures warn and tally as `failed` + without aborting the run. + - `p import` and `share` record what they write: session derives carry + a provenance stub (source stamped before the read), and the cache + write upserts the manifest — so sync never re-derives an artifact + that import or share just produced. `ArtifactType` gains `Git`: + git imports are recorded (repo path + `-` id) + but never re-derived by sync, since repos aren't discoverable. + Github and pathbase stay out of the manifest — remote services, + not artifact sources. + - Every import flow loops the per-session derive helpers — explicit + `--session`, picker multi-select, `--all`, and the most-recent + fallbacks — so everything entering the cache is recorded. + **Breaking**: `p import pi --all` now emits one Path document per + session, consistent with every other provider (it previously + produced a single combined Graph). + - `path query` now syncs before it reads, scoped to the query's own + flags: `--source claude` syncs only claude, `--id` prefixes narrow + likewise, a bare cache-wide query syncs every type, and + `--input`-only queries never touch the cache. Output is quiet + unless something was actually ingested (`synced claude: 3 new, 1 + updated`), a sync failure degrades to querying the cache as-is, + and `--no-sync` opts out. This is the piece that makes the cache + an implementation detail: a new user can run `path query` with no + setup and get their sessions. + - The manifest is now an artifact *index*, not just a cache inventory: + `cache_id` on a record is optional, and a record without one means + "known, not materialized". `p cache rm` downgrades records instead + of orphaning them (the next sync re-materializes the doc — this + also fixes rm'd docs previously staying gone until the source + changed), and sync verifies doc existence before skipping, so + out-of-band deletions self-heal too. + - `--parent-dir ` / `-d` on `p cache sync` and `path query` + restricts ingestion (and the query's reads) to artifacts under a + directory. Stat gate first, always: unchanged+cached artifacts skip + before any scope check; only artifacts that would cost a derive get + the constraint, with a one-line peek for codex (whose cwd lives + inside the rollout) memoized into the manifest so it happens at + most once per artifact. Claude project matching happens in slug + space (its dir slugs are lossy), and claude derives now source + `path.base` from the session's recorded cwd instead of the lossy + slug string. + - Copilot participates in sync like every other harness: an + `ArtifactType::Copilot` (stat-only enumeration over + `session-state//events.jsonl`, cwd peeked from `session.start` + and memoized for `--parent-dir`), per-session import loops with + provenance, and `path query --source copilot` auto-sync. + - `share` uploads straight from the cache when the picked session is + unchanged since its last sync (manifest stamp matches a fresh stat + and the doc exists) — re-deriving would reproduce the same bytes. + A grown session steps around the fast path and derives as before. + - **Maximal ingest, strip at egress**: the cache always holds the + fullest derivation — thinking blocks are ingested for claude and + gemini unconditionally (**breaking**: `p import gemini` loses its + `--include-thinking` flag), and the privacy decision moves to where + it belongs: `share` and `p export pathbase` strip thinking from + uploads by default, with `--include-thinking` there to opt in. The + local cache and `resume` fidelity keep everything. + - Review fixes: `p import --all` warns-and-skips unreadable + sessions again instead of aborting the batch; importing an artifact + the implicit query-sync already cached is a friendly no-op instead + of an exists-error; pi project scoping compares in its dir-encoded + space so hyphenated paths match `--parent-dir`; the copilot cwd + peek tolerates `session.start` anywhere in the first lines and + top-level cwd keys; a derive no longer clobbers the memoized peeked + cwd; claude sessions with no recorded cwd fall back to the caller's + `--project` for `path.base`; a small-file gemini peek that declines + in the prefix now still runs the full-parse fallback; and a + rebase-dropped `toolpath-pi` workspace pin is back at 0.6.1. + - PR review round 2: thinking-stripping now also scrubs sub-agent + turns embedded in `delegations` (gemini folds sub-agent chat files + into them, each turn with its own `thinking`), recursing into + nested delegations; manifest writers are serialized by an advisory + lock (`sync.json.lock`) and every write is a locked read-merge-save + — sync checkpoints merge only the records the run wrote — so + concurrent invocations (query auto-syncs, imports) union their + records instead of clobbering each other; an all-`None` stamp can + no longer vouch for a record in sync's unchanged gate (mirrors + `record_is_current` — unknowable freshness re-derives); the `share` + fast path stats its one artifact directly instead of enumerating + the whole artifact type. + - Claude fingerprints cover the whole session chain. Claude Code + rotates to a new JSONL file on continuation (plan-mode exit, + resume, fork) while `list_conversations` keys the chain by its + *oldest* segment — appends land in the newest file, so statting + the head file froze the fingerprint at the first rotation and sync + never saw later turns. All three stamp sites (enumeration, import + provenance, the `share` fast path) now share `claude_chain_stamp`: + max mtime across the chain's segments plus the sum of their sizes + — exactly the files `read_conversation` merges, so the fingerprint + and the derived doc move in lockstep however rotation behavior + shifts across Claude Code versions. Import also normalizes its + manifest key to the chain head, so importing a successor-segment + id records the artifact sync will look for. +- **`toolpath-gemini`** (0.6.1): new `PathResolver::list_session_entries` + returns each session's listing id, inner `sessionId`, and backing + file/dir path, and `peek_session_id` is now bounded — it scans the + first 4 KiB of a main chat file (identity fields lead the JSON) and + falls back to a full parse only when they don't. `list_sessions` + delegates to it unchanged. This is what lets `p cache sync` fingerprint + gemini sessions without reading chat bodies. +- **`toolpath-claude`** (0.12.1): `ClaudeConvo::session_chain` is now + public — it resolves the full session chain for a session id (oldest + segment first), which is what lets `p cache sync` fingerprint a + conversation across rotations without reading bodies. It rides the + same cached chain index as `list_conversations`, so calling it after + a listing costs no extra IO. +- **`toolpath-cli`** (0.16.0): version bump only (tracks `path-cli`). + ## 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..55c50847 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,11 +129,17 @@ cargo run -p path-cli -- p export pathbase --input # Plumbing: manage the cache cargo run -p path-cli -- p cache ls cargo run -p path-cli -- p cache rm +cargo run -p path-cli -- p cache sync # ingest new/changed sessions from every harness +cargo run -p path-cli -- p cache sync claude codex # only these artifact types +cargo run -p path-cli -- p cache sync -d ~/work/proj # only artifacts under this directory # Inspect / analyze cargo run -p path-cli -- p render dot --input doc.json cargo run -p path-cli -- p render md --input doc.json --detail full -# Query the whole local cache with a jaq (jq) filter over wrapped steps +# Query the whole local cache with a jaq (jq) filter over wrapped steps. +# Queries auto-sync their scope first (--source claude syncs only claude; +# --input-only queries never touch the cache); --no-sync opts out. +# --parent-dir/-d scopes both the sync and the read to that subtree. cargo run -p path-cli -- query 'map(select(.dead_end)) | map(.step.id)' cargo run -p path-cli -- query --source claude 'map(select(any(.change[].structural.token_usage; .input_tokens > 50000)))' cargo run -p path-cli -- query --input doc.json 'map(select(.step.actor | startswith("agent:")))' @@ -165,7 +171,7 @@ cargo run -p path-cli -- auth logout and no deprecation shim. They all now live exclusively under `path p …`. -The **cache** at `~/.toolpath/documents/.json` is the single landing zone for every `import` (and for `import pathbase` downloads). Cache id is `-` — e.g. `claude-abc123`, `git-main`, `pathbase-alex-pathstash-path-pr-42` (Pathbase paths key on `--`, anon paths on `anon-pathstash-`). Files are `0600`, parent directory `0700`. `$TOOLPATH_CONFIG_DIR` overrides the root. Default behavior: error on cache hit; pass `--force` to overwrite. `--no-cache` sends the JSON to stdout for shell composition. +The **cache** at `~/.toolpath/documents/.json` is the single landing zone for every `import` (and for `import pathbase` downloads). Cache id is `-` — e.g. `claude-abc123`, `git-main`, `pathbase-alex-pathstash-path-pr-42` (Pathbase paths key on `--`, anon paths on `anon-pathstash-`). Files are `0600`, parent directory `0700`. `$TOOLPATH_CONFIG_DIR` overrides the root. Default behavior: error on cache hit; pass `--force` to overwrite. `--no-cache` sends the JSON to stdout for shell composition. `p cache sync` fills the cache incrementally from the installed agent harnesses (see "Things to know") and always overwrites what it re-derives. `path auth login` prints `/auth/cli`; the user opens it, logs in, and pastes the 8-character code back into the CLI. The CLI calls @@ -199,14 +205,14 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in - `toolpath-git`: 33 unit + 3 doc tests (derive, branch detection, diffstat) - `toolpath-github`: 32 unit + 3 doc tests (mapping, DAG construction, fixtures) - `toolpath-claude`: 229 unit + 18 integration + 6 doc tests (path resolution, conversation reading, query, chaining, watcher, derive, metadata first-user-message, group_id grouping + once-per-message usage totals) -- `toolpath-gemini`: 161 unit + 29 integration + 5 doc tests (path resolution, chat-file parsing, query, watcher, derive, provider, round-trip fidelity, thoughts-folded-into-output + reasoning breakdown round-trip) +- `toolpath-gemini`: 165 unit + 29 integration + 5 doc tests (path resolution, chat-file parsing, query, watcher, derive, provider, round-trip fidelity, thoughts-folded-into-output + reasoning breakdown round-trip) - `toolpath-codex`: 80 unit + 51 integration + 2 doc tests (rollout parsing, provider assembly, patch-fidelity derive, real-session fixture, source→path fidelity invariants, JSON wire-level round-trip, per-turn token deltas from cumulative counters, reasoning breakdown) - `toolpath-copilot`: 63 unit + 8 integration + 1 doc test (`events.jsonl` envelope/event-type classification incl. `session.start` nested `context` + `tool.execution` `result.content` + `assistant.message` `reasoningText`/`outputTokens`, `session.shutdown` `tokenDetails`, path resolution incl. legacy `history-session-state/`, reader malformed-line tolerance without env races, tolerant `workspace.yaml` parse, `to_view` turn/tool/delegation assembly + per-turn token usage + shutdown-total merge, id-based **and** id-less positional tool pairing, position-stable turn ids, native file-state diff from `result.detailedContent`, `CopilotProjector` round-trip + foreign-tool-name remap). Ships a **real captured feature-elicit session** at `tests/fixtures/real-session.jsonl` (also `test-fixtures/copilot/convo.jsonl`) driving `real_fixture_roundtrip.rs` (forward invariants, projection round-trip fidelity, wire-level serde value-identity). The projector is exercised by the cross-harness matrix in `path-cli`. - `toolpath-opencode`: 52 unit + 19 integration + 1 doc test (SQLite reader, JSON payload serde, provider assembly, snapshot-based derive, tool-input fallback for gitignored paths, reasoning breakdown) - `toolpath-cursor`: 78 unit + 8 integration round-trip + 1 real-DB sanity + 1 doc test (state.vscdb SQLite reader, bubble store + composer header parsing, content-addressed blob lookup, projector with full TOOL_TABLE coverage, JSONL transcript ingest in `examples/dump_fixture.rs`) - `toolpath-pi`: 133 unit + 26 integration + 5 doc tests (types, paths, error, reader, io, provider) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) -- `path-cli`: 326 unit + 105 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy` incl. Copilot, Copilot import/list/show/**export** via `COPILOT_HOME` + Copilot in the `path share` aggregator + Copilot in the cross-harness conformance matrix, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. +- `path-cli`: 359 unit + 121 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. - `toolpath-cli`: 0 tests (it's a one-line `path_cli::run()` shim crate that exists only so `cargo install toolpath-cli` keeps installing the `path` binary) Validate example documents: `for f in examples/*.json; do cargo run -p path-cli -- p validate --input "$f"; done` @@ -222,7 +228,7 @@ The Tauri 2 desktop GUI lives in the private [pathbase](https://github.com/empat ## Versioning and release checklist -When changing a crate's public API (new types, new trait impls, new public methods, new dependencies), bump its version. Use semver: patch for bug fixes, minor for additive changes, major for breaking changes. Pre-1.0 crates treat minor as "potentially breaking." +When changing a crate's public API (new types, new trait impls, new public methods, new dependencies), bump its version. For pre-1.0 library crates, cargo treats the **z** position of `0.y.z` as the compatible slot: bug fixes *and additive changes* bump patch (`0.6.0` → `0.6.1`, so `^0.6` consumers like pathbase-app pick them up for free); bump minor only for potentially-breaking changes. `path-cli` is the app, not a library — it bumps minor per feature. **Every version bump must update all of the following:** @@ -257,7 +263,7 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - `PathOrRef::Path` is `Box` to avoid a large enum variant size difference - The git derivation (`toolpath-git`) uses `git2` (libgit2 bindings), not shelling out to git - Claude conversation data lives in `~/.claude/projects/` as JSONL files; `toolpath-claude` reads these directly -- `toolpath-claude` follows session chains by default — Claude Code rotates JSONL files on context overflow; `read_conversation` merges segments, `list_conversations` returns chain heads. `read_segment`/`list_segments` for single-file access. `ChainIndex` makes this incremental. +- `toolpath-claude` follows session chains by default — Claude Code rotates JSONL files on continuation (plan-mode exit, resume, fork; older versions on autocompact); `read_conversation` merges segments, `list_conversations` returns chain heads (the *oldest* segment of each chain — the rotation-stable id), `session_chain` resolves a chain's segments oldest-first (public since 0.12.1; it's how sync fingerprints whole chains). `read_segment`/`list_segments` for single-file access. `ChainIndex` makes this incremental. - Gemini CLI conversation data lives in `~/.gemini/tmp//chats/`. Main sessions sit at the top (`session--.json`, `kind: "main"`); sub-agents live in sibling `/` directories (`kind: "subagent"`). The `` slot is either a friendly name from `~/.gemini/projects.json` or the SHA-256 hex of the absolute project path; `toolpath-gemini` resolves both. - `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. @@ -273,6 +279,8 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - Format references for the agent on-disk formats we derive from live at `docs/agents/formats/`. The Claude Code format (`~/.claude/projects/…` JSONL) gets the deepest treatment — twelve focused docs at `docs/agents/formats/claude-code/` covering envelope, entry types, tools, session chains, compaction, writing-compatible JSONL, a linear walkthrough, and a version-keyed changelog. Sibling single-file references: `codex.md`, `gemini.md`, `opencode.md`. Keep them in sync with their derive crates when fields or behaviors change. - Interactive session selection: `path p import ` (claude / gemini / pi / codex / opencode) auto-launches a fuzzy picker when stdin and stderr are TTYs and no `--session` was given. Backend: external `fzf` if on `$PATH`, otherwise the embedded skim picker (default-feature `embedded-picker`, defined in `crates/path-cli/src/skim_picker.rs`). Multi-select (TAB) produces a `Graph` document; single-select produces a `Path`. The picker uses `path show --…` as its `--preview` command. When neither backend can run (no TTY, or `--no-default-features` AND no `fzf`), it falls back to most-recent (with `--project`) or prints the manual recipe (without). `path p list --format tsv` is the documented machine-readable surface — column 1 is the project (for claude/gemini/pi) or session id (for codex/opencode), and the trailing column carries `first_user_message` so consumers can fuzzy-match by topic. - Conversation metadata title field: `toolpath-claude::ConversationMetadata`, `toolpath-gemini::ConversationMetadata`, and `toolpath-pi::SessionMeta` all expose `first_user_message: Option` — the first non-empty user-prompt text. Populated cheaply during the metadata pass (single-pass for Claude/Gemini; one extra short read for Pi). Used by the picker UI but useful for any "list sessions by topic" surface. -- `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. +- `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. When the manifest shows the picked session unchanged since its last sync (`sync::fresh_cache_id`: stamps match, doc present; the freshness stat targets that one artifact directly, no sibling enumeration), share uploads the cached doc directly instead of re-deriving. The cache ingests maximally (thinking always included); `share` and `p export pathbase` strip thinking from uploads by default (`--include-thinking` opts in; the strip also scrubs sub-agent turns embedded in `delegations`, recursively) — local projection (`resume`, `p export `) keeps everything. - `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. +- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs every artifact type. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: the *whole session chain* — max segment mtime + summed segment sizes via `claude_chain_stamp`, because Claude Code rotates to a new file on continuation while the chain keeps its oldest segment's id, so appends land in the newest file, not the head; the chain comes from the same cached index `list_conversations` builds; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek; copilot: `session-state//events.jsonl`, pure read-dir + stat) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed every 10 writes (interruption-safe: a killed run keeps nearly everything it derived, and derives run newest-first so partial progress covers the sessions that matter most); writers serialize on an advisory lock (`sync.json.lock`) and every write is a locked read-merge-save — checkpoints merge only the records the run wrote — so concurrent invocations (query auto-syncs, imports) union their records instead of clobbering each other. Pending work reports progress on stderr (`\r`-updating ` done/total` on a TTY, a plain line every 25 items otherwise; no-op syncs stay silent). Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. A record's `cache_id` is *optional*: a record without one is "known, not materialized" — created when a `--parent-dir` constraint excluded a peeked artifact, or when `p cache rm` evicts a doc (rm downgrades the record; the next in-scope sync re-materializes it, and sync also verifies the doc file actually exists before skipping, so even out-of-band deletions self-heal). `--parent-dir ` / `-d` on both `p cache sync` and `path query` restricts ingestion to artifacts under that directory (subtree): path-keyed providers prune whole projects before enumerating (claude compares in *slug space* — its dir slugs are lossy, `/`/`_`/`.` all became `-`), cwd-keyed ones check the directory their cheap headers carry, and codex/copilot — whose cwd lives inside the session file — get a one-line peek only when new/changed, memoized into the record's `path`. The stat gate always runs first: unchanged+cached artifacts skip before any scope check. Out-of-scope work is tallied separately (`N out of scope`) and never touches a materialized record's stamp. Claude derives leave `DeriveConfig.project_path` unset so `path.base` comes from the session's own recorded cwd rather than the lossy slug. `path query` runs this sync implicitly before reading, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), quiet unless something was ingested, degrading to the cache as-is if sync fails; `--no-sync` opts out. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactStub` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_stub` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. +- `ArtifactType` (`crates/path-cli/src/sync.rs`) is the general enum naming artifact sources — the seven agent harnesses (incl. copilot) plus `Git` (8 variants). Git artifacts are *recorded* in the manifest by `p import git` (id `-`, `path` = the repo directory) but never *discovered* — there is no machine-wide registry of repos — so sync reports them and leaves them alone. Github and pathbase are deliberately not artifact types: they are remote services, not local artifact sources, and their imports stay out of the manifest. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`cmd_share.rs`) names the seven agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. diff --git a/Cargo.lock b/Cargo.lock index 301920f1..f4e70cd9 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", @@ -4073,7 +4073,7 @@ dependencies = [ [[package]] name = "toolpath-claude" -version = "0.12.0" +version = "0.12.1" dependencies = [ "anyhow", "chrono", @@ -4155,7 +4155,7 @@ dependencies = [ [[package]] name = "toolpath-gemini" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 835a95e6..89f96d87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,8 @@ license = "Apache-2.0" 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-claude = { version = "0.12.1", path = "crates/toolpath-claude", default-features = false } +toolpath-gemini = { version = "0.6.1", 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" } @@ -37,7 +37,7 @@ 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" } +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/README.md b/README.md index fdca4fc1..33259e97 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,10 @@ path p import opencode # List what's in the cache path p cache ls +# Ingest new/changed agent sessions into the cache (all harnesses, or named ones) +path p cache sync +path p cache sync claude codex + # Export a cached document back into a Claude Code session path p export claude --input claude- --project /path/to/resume 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/src/cmd_cache.rs b/crates/path-cli/src/cmd_cache.rs index e6a08c2d..f2789b8b 100644 --- a/crates/path-cli/src/cmd_cache.rs +++ b/crates/path-cli/src/cmd_cache.rs @@ -23,12 +23,28 @@ pub enum CacheOp { /// Cache id (filename without `.json`) id: String, }, + /// Ingest agent sessions into the cache, deriving only what is new + /// or changed since the last sync (tracked in `$CONFIG_DIR/sync.json`) + #[cfg(not(target_os = "emscripten"))] + Sync { + /// Artifact types to sync (default: every agent harness) + #[arg(value_enum)] + types: Vec, + + /// Only ingest artifacts living under this directory (subtree + /// match). Out-of-scope artifacts are noted in the manifest but + /// not derived. + #[arg(long, short = 'd')] + parent_dir: Option, + }, } pub fn run(op: CacheOp) -> Result<()> { match op { CacheOp::Ls => run_ls(), CacheOp::Rm { id } => run_rm(&id), + #[cfg(not(target_os = "emscripten"))] + CacheOp::Sync { types, parent_dir } => crate::sync::run(types, parent_dir), } } @@ -46,6 +62,12 @@ fn run_ls() -> Result<()> { fn run_rm(id: &str) -> Result<()> { remove_cached(id)?; + // The artifact is still real — downgrade its manifest record to + // "known, not cached" so the next sync can re-materialize it. + #[cfg(not(target_os = "emscripten"))] + if let Err(e) = crate::sync::evict_cache_id(id) { + eprintln!("warning: sync manifest not updated: {e}"); + } eprintln!("Removed {id}"); Ok(()) } diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index dfc82d2b..d9973441 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -192,6 +192,10 @@ pub enum ExportTarget { /// Mark the uploaded graph public (default: unlisted, addressable only by UUID) #[arg(long)] public: bool, + + /// Keep thinking blocks in the uploaded document (stripped by default) + #[arg(long)] + include_thinking: bool, }, } @@ -259,6 +263,7 @@ pub fn run(target: ExportTarget) -> Result<()> { repo, name, public, + include_thinking, } => run_pathbase(PathbaseExportArgs { input, url, @@ -266,6 +271,7 @@ pub fn run(target: ExportTarget) -> Result<()> { repo, name, public, + include_thinking, }), } } @@ -278,6 +284,7 @@ struct PathbaseExportArgs { repo: Option, name: Option, public: bool, + include_thinking: bool, } /// Pathbase upload knobs that don't depend on where the body came from. @@ -1852,6 +1859,56 @@ fn write_cursor_to_stdout(session: &toolpath_cursor::CursorSession) -> Result<() // ── Pathbase ────────────────────────────────────────────────────────── +/// Remove thinking text from every step's structural extras — the +/// egress inverse of the maximal ingest (the cache always derives with +/// thinking included; documents leaving the machine drop it unless the +/// caller opts in). +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn strip_thinking(graph: &mut toolpath::v1::Graph) { + use toolpath::v1::PathOrRef; + for entry in &mut graph.paths { + let PathOrRef::Path(path) = entry else { + continue; + }; + for step in &mut path.steps { + for change in step.change.values_mut() { + if let Some(structural) = &mut change.structural { + structural.extra.remove("thinking"); + // `delegations` embeds full sub-agent turns (gemini + // folds sub-agent files in), each with its own + // `thinking` — reasoning must not ride out inside them. + if let Some(delegations) = structural.extra.get_mut("delegations") { + strip_delegation_thinking(delegations); + } + } + } + } + } +} + +/// Remove `thinking` from every turn of a serialized `Vec`, +/// recursing into the turns' own nested delegations. +#[cfg(not(target_os = "emscripten"))] +fn strip_delegation_thinking(delegations: &mut serde_json::Value) { + let Some(list) = delegations.as_array_mut() else { + return; + }; + for delegation in list { + let Some(turns) = delegation.get_mut("turns").and_then(|t| t.as_array_mut()) else { + continue; + }; + for turn in turns { + let Some(obj) = turn.as_object_mut() else { + continue; + }; + obj.remove("thinking"); + if let Some(nested) = obj.get_mut("delegations") { + strip_delegation_thinking(nested); + } + } + } +} + fn run_pathbase(args: PathbaseExportArgs) -> Result<()> { #[cfg(target_os = "emscripten")] { @@ -1864,8 +1921,11 @@ fn run_pathbase(args: PathbaseExportArgs) -> Result<()> { use crate::cmd_pathbase::preflight_auth; let file = cache_ref(&args.input)?; - let body = std::fs::read_to_string(&file) - .with_context(|| format!("Failed to read {}", file.display()))?; + let mut graph = crate::io::read_document_auto(&file)?; + if !args.include_thinking { + strip_thinking(&mut graph); + } + let body = graph.to_json()?; let upload = PathbaseUploadArgs { url: args.url, anon: args.anon, @@ -2031,6 +2091,125 @@ mod tests { use std::collections::HashMap; use toolpath::v1::{ArtifactChange, PathIdentity, Step, StepIdentity, StructuralChange}; + #[test] + fn strip_thinking_removes_only_thinking_extras() { + let mut extra = HashMap::new(); + extra.insert("text".to_string(), serde_json::json!("hello")); + extra.insert( + "thinking".to_string(), + serde_json::json!("secret reasoning"), + ); + let step = Step { + step: StepIdentity { + id: "s1".into(), + parents: vec![], + actor: "agent:claude-code".into(), + timestamp: "2026-01-01T00:00:00Z".into(), + }, + change: HashMap::from([( + "conversation://x".to_string(), + ArtifactChange { + raw: None, + structural: Some(StructuralChange { + change_type: "conversation.append".into(), + extra, + }), + }, + )]), + meta: None, + }; + let path = toolpath::v1::Path { + path: PathIdentity { + id: "p1".into(), + base: None, + head: "s1".into(), + graph_ref: None, + }, + steps: vec![step], + meta: None, + }; + let mut graph = toolpath::v1::Graph::from_path(path); + + strip_thinking(&mut graph); + + let json = graph.to_json().unwrap(); + assert!(!json.contains("secret reasoning")); + assert!(json.contains("hello"), "non-thinking extras survive"); + } + + #[test] + fn strip_thinking_scrubs_delegation_turns_recursively() { + let mut extra = HashMap::new(); + extra.insert( + "delegations".to_string(), + serde_json::json!([{ + "agent_id": "sub-1", + "prompt": "investigate", + "turns": [{ + "id": "t1", + "role": "assistant", + "timestamp": "2026-01-01T00:00:00Z", + "text": "visible sub-agent text", + "thinking": "sub-agent reasoning", + "delegations": [{ + "agent_id": "sub-2", + "prompt": "deeper", + "turns": [{ + "id": "t2", + "role": "assistant", + "timestamp": "2026-01-01T00:00:01Z", + "text": "nested text", + "thinking": "nested reasoning" + }] + }] + }], + "result": "sub-agent result" + }]), + ); + let step = Step { + step: StepIdentity { + id: "s1".into(), + parents: vec![], + actor: "agent:gemini-cli".into(), + timestamp: "2026-01-01T00:00:00Z".into(), + }, + change: HashMap::from([( + "conversation://x".to_string(), + ArtifactChange { + raw: None, + structural: Some(StructuralChange { + change_type: "conversation.append".into(), + extra, + }), + }, + )]), + meta: None, + }; + let path = toolpath::v1::Path { + path: PathIdentity { + id: "p1".into(), + base: None, + head: "s1".into(), + graph_ref: None, + }, + steps: vec![step], + meta: None, + }; + let mut graph = toolpath::v1::Graph::from_path(path); + + strip_thinking(&mut graph); + + let json = graph.to_json().unwrap(); + assert!(!json.contains("sub-agent reasoning")); + assert!(!json.contains("nested reasoning")); + assert!(json.contains("visible sub-agent text")); + assert!(json.contains("nested text")); + assert!( + json.contains("sub-agent result"), + "delegation results survive" + ); + } + fn make_path_doc() -> toolpath::v1::Graph { let artifact_key = "agent://claude/test-session"; @@ -2859,6 +3038,7 @@ mod tests { std::env::set_var(crate::config::CONFIG_DIR_ENV, temp.path()); } let err = run_pathbase(PathbaseExportArgs { + include_thinking: false, input: input_path.to_string_lossy().to_string(), url: Some("http://127.0.0.1:1".to_string()), anon: false, diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index 48ee8ad2..3213dcdc 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use toolpath::v1::Graph; use crate::cmd_cache::{make_id, write_cached}; +use crate::sync::{ArtifactStub, ArtifactType, claude_chain_stamp, codex_artifact_id, stat_stamp}; #[derive(Subcommand, Debug)] pub enum ImportSource { @@ -89,10 +90,6 @@ pub enum ImportSource { /// Process all sessions in the project #[arg(long)] all: bool, - - /// Include thinking blocks in conversation.append text - #[arg(long)] - include_thinking: bool, }, /// Import from Codex CLI rollout files Codex { @@ -159,7 +156,7 @@ pub enum ImportSource { #[arg(short, long)] session: Option, - /// Process all sessions in the project (emits a Graph) + /// Process all sessions in the project #[arg(long)] all: bool, @@ -201,6 +198,12 @@ pub fn run(args: ImportArgs, pretty: bool) -> Result<()> { pub(crate) struct DerivedDoc { pub(crate) cache_id: String, pub(crate) doc: Graph, + /// Identity + source stamp for the sync manifest, captured *before* + /// the source was read (so a write racing the derive re-syncs next + /// run). `None` for sources the manifest doesn't track (github, + /// pathbase) and for bulk `--all` derives that no longer know + /// per-artifact sources. + pub(crate) provenance: Option, } fn emit(docs: &[DerivedDoc], force: bool, no_cache: bool, pretty: bool) -> Result<()> { @@ -216,8 +219,29 @@ fn emit(docs: &[DerivedDoc], force: bool, no_cache: bool, pretty: bool) -> Resul }; println!("{}", json); } else { + // The implicit sync in `path query` fills the cache under + // these same ids; re-importing an artifact whose record is + // still fresh is a no-op, not an exists-error. + #[cfg(not(target_os = "emscripten"))] + if !force + && let Some(stub) = &d.provenance + && crate::sync::record_is_current(stub, &d.cache_id) + { + println!("{}", crate::cmd_cache::cache_path(&d.cache_id)?.display()); + eprintln!( + "{} is already up to date (pass --force to re-derive)", + d.cache_id + ); + continue; + } let path = write_cached(&d.cache_id, &d.doc, force)?; println!("{}", path.display()); + #[cfg(not(target_os = "emscripten"))] + if let Some(stub) = &d.provenance + && let Err(e) = crate::sync::record_stub(stub, &d.cache_id) + { + eprintln!("warning: sync manifest not updated: {e}"); + } let summary = doc_summary(&d.doc); eprintln!("Imported {} → {}", summary, d.cache_id); } @@ -258,8 +282,7 @@ fn derive(source: ImportSource) -> Result> { project, session, all, - include_thinking, - } => derive_gemini(project, session, all, include_thinking), + } => derive_gemini(project, session, all), ImportSource::Codex { session, all } => derive_codex(session, all), ImportSource::Copilot { session, all } => derive_copilot(session, all), ImportSource::Opencode { @@ -323,8 +346,18 @@ fn derive_git( let canonical = std::fs::canonicalize(&repo_path).unwrap_or(repo_path.clone()); let repo_tag = short_path_hash(&canonical.to_string_lossy()); let inner = doc_inner_id(&doc); - let cache_id = make_id("git", &format!("{repo_tag}-{inner}")); - Ok(vec![DerivedDoc { cache_id, doc }]) + let cache_id = make_id(ArtifactType::Git.name(), &format!("{repo_tag}-{inner}")); + Ok(vec![DerivedDoc { + cache_id, + doc, + provenance: Some(ArtifactStub { + artifact_type: ArtifactType::Git, + id: format!("{repo_tag}-{inner}"), + path: Some(canonical.to_string_lossy().into_owned()), + modified: None, + size: None, + }), + }]) } } @@ -386,7 +419,11 @@ fn derive_github( let path = toolpath_github::derive_pull_request(&owner, &repo_name, pr_number, &config)?; let doc = Graph::from_path(path); let cache_id = make_id("github", &format!("{owner}_{repo_name}-{pr_number}")); - Ok(vec![DerivedDoc { cache_id, doc }]) + Ok(vec![DerivedDoc { + cache_id, + doc, + provenance: None, + }]) } } @@ -405,11 +442,6 @@ fn derive_claude_with_manager( session: Option, all: bool, ) -> Result> { - let make_config = |p: &str| toolpath_claude::derive::DeriveConfig { - project_path: Some(p.to_string()), - include_thinking: false, - }; - // Interactive picker fires only when no explicit `--session` (and not // `--all`); the same flow handles single- and multi-select. If fzf isn't // available, we fall back to most-recent for explicit-project, or print @@ -417,11 +449,17 @@ fn derive_claude_with_manager( let pairs: Vec<(String, String)> = match (project, session, all) { (Some(p), Some(s), _) => vec![(p, s)], (Some(p), None, true) => { - let convos = manager - .read_all_conversations(&p) + let heads = manager + .list_conversations(&p) .map_err(|e| anyhow::anyhow!("{}", e))?; - let cfg = make_config(&p); - return wrap_paths_claude(toolpath_claude::derive::derive_project(&convos, &cfg)); + let mut docs = Vec::with_capacity(heads.len()); + for head in &heads { + match derive_claude_session_with(manager, &p, head) { + Ok(doc) => docs.push(doc), + Err(e) => eprintln!("Warning: skipping session {head}: {e}"), + } + } + return Ok(docs); } (Some(p), None, false) => { #[cfg(not(target_os = "emscripten"))] @@ -435,10 +473,11 @@ fn derive_claude_with_manager( .ok_or_else(|| { anyhow::anyhow!("No conversations found for project: {}", p) })?; - let cfg = make_config(&p); - return wrap_paths_claude(vec![toolpath_claude::derive::derive_path( - &convo, &cfg, - )]); + return Ok(vec![derive_claude_session_with( + manager, + &p, + &convo.session_id, + )?]); } } #[cfg(target_os = "emscripten")] @@ -447,8 +486,11 @@ fn derive_claude_with_manager( .most_recent_conversation(&p) .map_err(|e| anyhow::anyhow!("{}", e))? .ok_or_else(|| anyhow::anyhow!("No conversations found for project: {}", p))?; - let cfg = make_config(&p); - return wrap_paths_claude(vec![toolpath_claude::derive::derive_path(&convo, &cfg)]); + return Ok(vec![derive_claude_session_with( + manager, + &p, + &convo.session_id, + )?]); } } (None, _, _) => { @@ -469,50 +511,78 @@ fn derive_claude_with_manager( } }; - let mut paths: Vec = Vec::with_capacity(pairs.len()); + let mut docs = Vec::with_capacity(pairs.len()); for (project_path, session_id) in &pairs { - let convo = manager - .read_conversation(project_path, session_id) - .map_err(|e| anyhow::anyhow!("{}", e))?; - let cfg = make_config(project_path); - paths.push(toolpath_claude::derive::derive_path(&convo, &cfg)); + docs.push(derive_claude_session_with( + manager, + project_path, + session_id, + )?); } - wrap_paths_claude(paths) + Ok(docs) } /// Derive a single Claude conversation given an explicit project + session. /// Used by `cmd_share` after its picker has resolved the pair; mirrors the /// `(Some(p), Some(s), _)` arm in [`derive_claude_with_manager`]. pub(crate) fn derive_claude_session(project: &str, session: &str) -> Result { - let manager = toolpath_claude::ClaudeConvo::new(); + derive_claude_session_with(&toolpath_claude::ClaudeConvo::new(), project, session) +} + +/// [`derive_claude_session`] against a caller-supplied manager, so sync +/// derives from the same roots it enumerated (and tests can inject a +/// fixture resolver). +pub(crate) fn derive_claude_session_with( + manager: &toolpath_claude::ClaudeConvo, + project: &str, + session: &str, +) -> Result { + // Fingerprint the whole session chain, and key the manifest record + // by the chain head (the rotation-stable id sync enumerates), so a + // caller passing a successor-segment id still records the artifact + // sync will look for. + let (modified, size) = claude_chain_stamp(manager, project, session); + let artifact_id = manager + .session_chain(project, session) + .ok() + .and_then(|chain| chain.into_iter().next()) + .unwrap_or_else(|| session.to_string()); + // The caller's project string often comes from claude's lossy dir + // slugs ('/', '_', '.' all collapsed); leaving it out of the derive + // lets path.base come from the session's own recorded cwd instead. + // The cache is the archive: always derive maximally (thinking + // included) — egress surfaces strip on their way out. let cfg = toolpath_claude::derive::DeriveConfig { - project_path: Some(project.to_string()), - include_thinking: false, + project_path: None, + include_thinking: true, }; let convo = manager .read_conversation(project, session) .map_err(|e| anyhow::anyhow!("{}", e))?; - let path = toolpath_claude::derive::derive_path(&convo, &cfg); - let cache_id = make_id("claude", &path.path.id); + let mut path = toolpath_claude::derive::derive_path(&convo, &cfg); + // Sessions whose entries carry no cwd would otherwise derive with no + // base at all — fall back to the caller's project for those. + if path.path.base.is_none() && project.starts_with('/') { + path.path.base = Some(toolpath::v1::Base { + uri: format!("file://{project}"), + ref_str: None, + branch: None, + }); + } + let cache_id = make_id(ArtifactType::Claude.name(), &path.path.id); Ok(DerivedDoc { cache_id, doc: Graph::from_path(path), + provenance: Some(ArtifactStub { + artifact_type: ArtifactType::Claude, + id: artifact_id, + path: Some(project.to_string()), + modified, + size, + }), }) } -fn wrap_paths_claude(paths: Vec) -> Result> { - Ok(paths - .into_iter() - .map(|p| { - let cache_id = make_id("claude", &p.path.id); - DerivedDoc { - cache_id, - doc: Graph::from_path(p), - } - }) - .collect()) -} - #[cfg(not(target_os = "emscripten"))] fn pick_claude_in_project( manager: &toolpath_claude::ClaudeConvo, @@ -619,10 +689,9 @@ fn derive_gemini( project: Option, session: Option, all: bool, - include_thinking: bool, ) -> Result> { let manager = toolpath_gemini::GeminiConvo::new(); - derive_gemini_with_manager(&manager, project, session, all, include_thinking) + derive_gemini_with_manager(&manager, project, session, all) } fn derive_gemini_with_manager( @@ -630,21 +699,21 @@ fn derive_gemini_with_manager( project: Option, session: Option, all: bool, - include_thinking: bool, ) -> Result> { - let make_config = |p: &str| toolpath_gemini::derive::DeriveConfig { - project_path: Some(p.to_string()), - include_thinking, - }; - let pairs: Vec<(String, String)> = match (project, session, all) { (Some(p), Some(s), _) => vec![(p, s)], (Some(p), None, true) => { - let convos = manager - .read_all_conversations(&p) + let ids = manager + .list_conversations(&p) .map_err(|e| anyhow::anyhow!("{}", e))?; - let cfg = make_config(&p); - return wrap_paths_gemini(toolpath_gemini::derive::derive_project(&convos, &cfg)); + let mut docs = Vec::with_capacity(ids.len()); + for id in &ids { + match derive_gemini_session_with(manager, &p, id) { + Ok(doc) => docs.push(doc), + Err(e) => eprintln!("Warning: skipping session {id}: {e}"), + } + } + return Ok(docs); } (Some(p), None, false) => { #[cfg(not(target_os = "emscripten"))] @@ -658,10 +727,11 @@ fn derive_gemini_with_manager( .ok_or_else(|| { anyhow::anyhow!("No conversations found for project: {}", p) })?; - let cfg = make_config(&p); - return wrap_paths_gemini(vec![toolpath_gemini::derive::derive_path( - &convo, &cfg, - )]); + return Ok(vec![derive_gemini_session_with( + manager, + &p, + &convo.session_uuid, + )?]); } } #[cfg(target_os = "emscripten")] @@ -670,8 +740,11 @@ fn derive_gemini_with_manager( .most_recent_conversation(&p) .map_err(|e| anyhow::anyhow!("{}", e))? .ok_or_else(|| anyhow::anyhow!("No conversations found for project: {}", p))?; - let cfg = make_config(&p); - return wrap_paths_gemini(vec![toolpath_gemini::derive::derive_path(&convo, &cfg)]); + return Ok(vec![derive_gemini_session_with( + manager, + &p, + &convo.session_uuid, + )?]); } } (None, _, _) => { @@ -692,52 +765,68 @@ fn derive_gemini_with_manager( } }; - let mut paths: Vec = Vec::with_capacity(pairs.len()); + let mut docs = Vec::with_capacity(pairs.len()); for (project_path, session_uuid) in &pairs { - let convo = manager - .read_conversation(project_path, session_uuid) - .map_err(|e| anyhow::anyhow!("{}", e))?; - let cfg = make_config(project_path); - paths.push(toolpath_gemini::derive::derive_path(&convo, &cfg)); + docs.push(derive_gemini_session_with( + manager, + project_path, + session_uuid, + )?); } - wrap_paths_gemini(paths) + Ok(docs) } /// Derive a single Gemini conversation given an explicit project + session. -pub(crate) fn derive_gemini_session( +pub(crate) fn derive_gemini_session(project: &str, session: &str) -> Result { + derive_gemini_session_with(&toolpath_gemini::GeminiConvo::new(), project, session) +} + +/// [`derive_gemini_session`] against a caller-supplied manager. +pub(crate) fn derive_gemini_session_with( + manager: &toolpath_gemini::GeminiConvo, project: &str, session: &str, - include_thinking: bool, ) -> Result { - let manager = toolpath_gemini::GeminiConvo::new(); + let entry = manager + .resolver() + .list_session_entries(project) + .ok() + .and_then(|entries| { + entries + .into_iter() + .find(|e| e.id == session || e.session_uuid.as_deref() == Some(session)) + }); + let (artifact_id, (modified, size)) = match &entry { + Some(e) => ( + e.session_uuid.clone().unwrap_or_else(|| e.id.clone()), + stat_stamp(&e.path), + ), + None => (session.to_string(), (None, None)), + }; + // Maximal ingest: thinking is always derived into the cache; egress + // surfaces strip it on the way out. let cfg = toolpath_gemini::derive::DeriveConfig { project_path: Some(project.to_string()), - include_thinking, + include_thinking: true, }; let convo = manager .read_conversation(project, session) .map_err(|e| anyhow::anyhow!("{}", e))?; let path = toolpath_gemini::derive::derive_path(&convo, &cfg); - let cache_id = make_id("gemini", &path.path.id); + let cache_id = make_id(ArtifactType::Gemini.name(), &path.path.id); Ok(DerivedDoc { cache_id, doc: Graph::from_path(path), + provenance: Some(ArtifactStub { + artifact_type: ArtifactType::Gemini, + id: artifact_id, + path: Some(project.to_string()), + modified, + size, + }), }) } -fn wrap_paths_gemini(paths: Vec) -> Result> { - Ok(paths - .into_iter() - .map(|p| { - let cache_id = make_id("gemini", &p.path.id); - DerivedDoc { - cache_id, - doc: Graph::from_path(p), - } - }) - .collect()) -} - #[cfg(not(target_os = "emscripten"))] fn pick_gemini_in_project( manager: &toolpath_gemini::GeminiConvo, @@ -840,18 +929,29 @@ fn pick_gemini_global( fn derive_codex(session: Option, all: bool) -> Result> { let manager = toolpath_codex::CodexConvo::new(); - let config = toolpath_codex::derive::DeriveConfig { project_path: None }; let session_ids: Vec = match (session, all) { (Some(s), _) => vec![s], (None, true) => { - let sessions = manager - .read_all_sessions() + let files = manager + .io() + .list_rollout_files() .map_err(|e| anyhow::anyhow!("{}", e))?; - if sessions.is_empty() { + if files.is_empty() { anyhow::bail!("No Codex sessions found in ~/.codex/sessions"); } - return wrap_paths_codex(toolpath_codex::derive::derive_project(&sessions, &config)); + let mut docs = Vec::with_capacity(files.len()); + for file in &files { + let Some(stem) = file.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + let id = codex_artifact_id(stem); + match derive_codex_session_with(&manager, id) { + Ok(doc) => docs.push(doc), + Err(e) => eprintln!("Warning: skipping session {id}: {e}"), + } + } + return Ok(docs); } (None, false) => { #[cfg(not(target_os = "emscripten"))] @@ -865,9 +965,7 @@ fn derive_codex(session: Option, all: bool) -> Result> { .ok_or_else(|| { anyhow::anyhow!("No Codex sessions found in ~/.codex/sessions") })?; - return wrap_paths_codex(vec![toolpath_codex::derive::derive_path( - &s, &config, - )]); + return Ok(vec![derive_codex_session_with(&manager, &s.id)?]); } } } @@ -879,49 +977,55 @@ fn derive_codex(session: Option, all: bool) -> Result> { .ok_or_else(|| { anyhow::anyhow!("No Codex sessions found in ~/.codex/sessions") })?; - return wrap_paths_codex(vec![toolpath_codex::derive::derive_path(&s, &config)]); + return Ok(vec![derive_codex_session_with(&manager, &s.id)?]); } } }; - let mut paths: Vec = Vec::with_capacity(session_ids.len()); + let mut docs = Vec::with_capacity(session_ids.len()); for sid in &session_ids { - let s = manager - .read_session(sid) - .map_err(|e| anyhow::anyhow!("{}", e))?; - paths.push(toolpath_codex::derive::derive_path(&s, &config)); + docs.push(derive_codex_session_with(&manager, sid)?); } - wrap_paths_codex(paths) + Ok(docs) } /// Derive a single Codex session given an explicit session id. pub(crate) fn derive_codex_session(session: &str) -> Result { - let manager = toolpath_codex::CodexConvo::new(); + derive_codex_session_with(&toolpath_codex::CodexConvo::new(), session) +} + +/// [`derive_codex_session`] against a caller-supplied manager. +pub(crate) fn derive_codex_session_with( + manager: &toolpath_codex::CodexConvo, + session: &str, +) -> Result { + let file = manager.resolver().find_rollout_file(session).ok(); + let (modified, size) = file.as_deref().map(stat_stamp).unwrap_or((None, None)); + let artifact_id = file + .as_deref() + .and_then(|f| f.file_stem()) + .and_then(|stem| stem.to_str()) + .map(|stem| codex_artifact_id(stem).to_string()) + .unwrap_or_else(|| session.to_string()); let config = toolpath_codex::derive::DeriveConfig { project_path: None }; let s = manager .read_session(session) .map_err(|e| anyhow::anyhow!("{}", e))?; let path = toolpath_codex::derive::derive_path(&s, &config); - let cache_id = make_id("codex", &path.path.id); + let cache_id = make_id(ArtifactType::Codex.name(), &path.path.id); Ok(DerivedDoc { cache_id, doc: Graph::from_path(path), + provenance: Some(ArtifactStub { + artifact_type: ArtifactType::Codex, + id: artifact_id, + path: None, + modified, + size, + }), }) } -fn wrap_paths_codex(paths: Vec) -> Result> { - Ok(paths - .into_iter() - .map(|p| { - let cache_id = make_id("codex", &p.path.id); - DerivedDoc { - cache_id, - doc: Graph::from_path(p), - } - }) - .collect()) -} - #[cfg(not(target_os = "emscripten"))] fn pick_codex(manager: &toolpath_codex::CodexConvo) -> Result>> { if !fuzzy::available() { @@ -968,20 +1072,24 @@ fn pick_codex(manager: &toolpath_codex::CodexConvo) -> Result fn derive_copilot(session: Option, all: bool) -> Result> { let manager = toolpath_copilot::CopilotConvo::new(); - let config = toolpath_copilot::derive::DeriveConfig { project_path: None }; let session_ids: Vec = match (session, all) { (Some(s), _) => vec![s], (None, true) => { - let sessions = manager - .read_all_sessions() + let metas = manager + .list_sessions() .map_err(|e| anyhow::anyhow!("{}", e))?; - if sessions.is_empty() { + if metas.is_empty() { anyhow::bail!("No Copilot sessions found in ~/.copilot/session-state"); } - return wrap_paths_copilot(toolpath_copilot::derive::derive_project( - &sessions, &config, - )); + let mut docs = Vec::with_capacity(metas.len()); + for m in &metas { + match derive_copilot_session_with(&manager, &m.id) { + Ok(doc) => docs.push(doc), + Err(e) => eprintln!("Warning: skipping session {}: {e}", m.id), + } + } + return Ok(docs); } (None, false) => { #[cfg(not(target_os = "emscripten"))] @@ -997,9 +1105,7 @@ fn derive_copilot(session: Option, all: bool) -> Result> "No Copilot sessions found in ~/.copilot/session-state" ) })?; - return wrap_paths_copilot(vec![toolpath_copilot::derive::derive_path( - &s, &config, - )]); + return Ok(vec![derive_copilot_session_with(&manager, &s.id)?]); } } } @@ -1011,51 +1117,52 @@ fn derive_copilot(session: Option, all: bool) -> Result> .ok_or_else(|| { anyhow::anyhow!("No Copilot sessions found in ~/.copilot/session-state") })?; - return wrap_paths_copilot(vec![toolpath_copilot::derive::derive_path( - &s, &config, - )]); + return Ok(vec![derive_copilot_session_with(&manager, &s.id)?]); } } }; - let mut paths: Vec = Vec::with_capacity(session_ids.len()); + let mut docs = Vec::with_capacity(session_ids.len()); for sid in &session_ids { - let s = manager - .read_session(sid) - .map_err(|e| anyhow::anyhow!("{}", e))?; - paths.push(toolpath_copilot::derive::derive_path(&s, &config)); + docs.push(derive_copilot_session_with(&manager, sid)?); } - wrap_paths_copilot(paths) + Ok(docs) } /// Derive a single Copilot session given an explicit session id. pub(crate) fn derive_copilot_session(session: &str) -> Result { - let manager = toolpath_copilot::CopilotConvo::new(); + derive_copilot_session_with(&toolpath_copilot::CopilotConvo::new(), session) +} + +/// [`derive_copilot_session`] against a caller-supplied manager. +pub(crate) fn derive_copilot_session_with( + manager: &toolpath_copilot::CopilotConvo, + session: &str, +) -> Result { + let (modified, size) = manager + .resolver() + .events_file(session) + .map(|p| stat_stamp(&p)) + .unwrap_or((None, None)); let config = toolpath_copilot::derive::DeriveConfig { project_path: None }; let s = manager .read_session(session) .map_err(|e| anyhow::anyhow!("{}", e))?; let path = toolpath_copilot::derive::derive_path(&s, &config); - let cache_id = make_id("copilot", &path.path.id); + let cache_id = make_id(ArtifactType::Copilot.name(), &path.path.id); Ok(DerivedDoc { cache_id, doc: Graph::from_path(path), + provenance: Some(ArtifactStub { + artifact_type: ArtifactType::Copilot, + id: session.to_string(), + path: None, + modified, + size, + }), }) } -fn wrap_paths_copilot(paths: Vec) -> Result> { - Ok(paths - .into_iter() - .map(|p| { - let cache_id = make_id("copilot", &p.path.id); - DerivedDoc { - cache_id, - doc: Graph::from_path(p), - } - }) - .collect()) -} - #[cfg(not(target_os = "emscripten"))] fn pick_copilot(manager: &toolpath_copilot::CopilotConvo) -> Result>> { if !fuzzy::available() { @@ -1117,20 +1224,7 @@ fn derive_opencode( #[cfg(not(target_os = "emscripten"))] { let manager = toolpath_opencode::OpencodeConvo::new(); - let config = toolpath_opencode::derive::DeriveConfig { - no_snapshot_diffs, - ..Default::default() - }; - let derive_one = |sid: &str| -> Result { - let s = manager - .read_session(sid) - .map_err(|e| anyhow::anyhow!("{}", e))?; - Ok(toolpath_opencode::derive::derive_path_with_resolver( - &s, - &config, - manager.resolver(), - )) - }; + let derive_one = |sid: &str| derive_opencode_session_with(&manager, sid, no_snapshot_diffs); let session_ids: Vec = match (session, all) { (Some(s), _) => vec![s], @@ -1146,7 +1240,7 @@ fn derive_opencode( for m in &metas { out.push(derive_one(&m.id)?); } - return wrap_paths_opencode(out); + return Ok(out); } (None, false) => match pick_opencode(&manager, project.as_deref())? { Some(picks) => picks, @@ -1155,22 +1249,16 @@ fn derive_opencode( .most_recent_session() .map_err(|e| anyhow::anyhow!("{}", e))? .ok_or_else(|| anyhow::anyhow!("No opencode sessions found"))?; - return wrap_paths_opencode(vec![ - toolpath_opencode::derive::derive_path_with_resolver( - &s, - &config, - manager.resolver(), - ), - ]); + return Ok(vec![derive_one(&s.id)?]); } }, }; - let mut paths: Vec = Vec::with_capacity(session_ids.len()); + let mut docs = Vec::with_capacity(session_ids.len()); for sid in &session_ids { - paths.push(derive_one(sid)?); + docs.push(derive_one(sid)?); } - wrap_paths_opencode(paths) + Ok(docs) } } @@ -1180,7 +1268,26 @@ pub(crate) fn derive_opencode_session( session: &str, no_snapshot_diffs: bool, ) -> Result { - let manager = toolpath_opencode::OpencodeConvo::new(); + derive_opencode_session_with( + &toolpath_opencode::OpencodeConvo::new(), + session, + no_snapshot_diffs, + ) +} + +/// [`derive_opencode_session`] against a caller-supplied manager. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn derive_opencode_session_with( + manager: &toolpath_opencode::OpencodeConvo, + session: &str, + no_snapshot_diffs: bool, +) -> Result { + let modified = manager + .io() + .list_sessions(None) + .ok() + .and_then(|sessions| sessions.into_iter().find(|s| s.id == session)) + .and_then(|s| s.last_activity()); let config = toolpath_opencode::derive::DeriveConfig { no_snapshot_diffs, ..Default::default() @@ -1190,26 +1297,20 @@ pub(crate) fn derive_opencode_session( .map_err(|e| anyhow::anyhow!("{}", e))?; let path = toolpath_opencode::derive::derive_path_with_resolver(&s, &config, manager.resolver()); - let cache_id = make_id("opencode", &path.path.id); + let cache_id = make_id(ArtifactType::Opencode.name(), &path.path.id); Ok(DerivedDoc { cache_id, doc: Graph::from_path(path), + provenance: Some(ArtifactStub { + artifact_type: ArtifactType::Opencode, + id: session.to_string(), + path: None, + modified, + size: None, + }), }) } -fn wrap_paths_opencode(paths: Vec) -> Result> { - Ok(paths - .into_iter() - .map(|p| { - let cache_id = make_id("opencode", &p.path.id); - DerivedDoc { - cache_id, - doc: Graph::from_path(p), - } - }) - .collect()) -} - #[cfg(not(target_os = "emscripten"))] fn pick_opencode( manager: &toolpath_opencode::OpencodeConvo, @@ -1281,13 +1382,7 @@ fn derive_cursor( #[cfg(not(target_os = "emscripten"))] { let manager = toolpath_cursor::CursorConvo::new(); - let derive_one = |sid: &str| -> Result { - let s = manager - .read_session(sid) - .map_err(|e| anyhow::anyhow!("{}", e))?; - let cfg = toolpath_cursor::DeriveConfig::default(); - Ok(toolpath_cursor::derive_path(&s, &cfg)) - }; + let derive_one = |sid: &str| derive_cursor_session_with(&manager, sid); let workspace_filter = project .as_deref() @@ -1318,7 +1413,7 @@ fn derive_cursor( for m in &filtered { out.push(derive_one(&m.id)?); } - return wrap_paths_cursor(out); + return Ok(out); } (None, false) => match pick_cursor(&manager, workspace_filter.as_deref())? { Some(picks) => picks, @@ -1337,48 +1432,60 @@ fn derive_cursor( .unwrap_or_else(chrono::DateTime::::default) }) .ok_or_else(|| anyhow::anyhow!("No Cursor composers found"))?; - return wrap_paths_cursor(vec![derive_one(&pick.id)?]); + return Ok(vec![derive_one(&pick.id)?]); } }, }; - let mut paths: Vec = Vec::with_capacity(session_ids.len()); + let mut docs = Vec::with_capacity(session_ids.len()); for sid in &session_ids { - paths.push(derive_one(sid)?); + docs.push(derive_one(sid)?); } - wrap_paths_cursor(paths) + Ok(docs) } } /// Derive a single cursor composer given an explicit composer id. #[cfg(not(target_os = "emscripten"))] pub(crate) fn derive_cursor_session(session: &str) -> Result { - let manager = toolpath_cursor::CursorConvo::new(); + derive_cursor_session_with(&toolpath_cursor::CursorConvo::new(), session) +} + +/// [`derive_cursor_session`] against a caller-supplied manager. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn derive_cursor_session_with( + manager: &toolpath_cursor::CursorConvo, + session: &str, +) -> Result { + let modified = manager + .io() + .read_composer_headers() + .ok() + .and_then(|h| { + h.all_composers + .into_iter() + .find(|c| c.composer_id == session) + }) + .and_then(|c| c.last_updated_at_utc()); let s = manager .read_session(session) .map_err(|e| anyhow::anyhow!("{}", e))?; let cfg = toolpath_cursor::DeriveConfig::default(); let path = toolpath_cursor::derive_path(&s, &cfg); - let cache_id = make_id("cursor", &path.path.id); + let cache_id = make_id(ArtifactType::Cursor.name(), &path.path.id); Ok(DerivedDoc { cache_id, doc: Graph::from_path(path), + provenance: Some(ArtifactStub { + artifact_type: ArtifactType::Cursor, + id: session.to_string(), + path: None, + modified, + size: None, + }), }) } -fn wrap_paths_cursor(paths: Vec) -> Result> { - Ok(paths - .into_iter() - .map(|p| { - let cache_id = make_id("cursor", &p.path.id); - DerivedDoc { - cache_id, - doc: Graph::from_path(p), - } - }) - .collect()) -} - #[cfg(not(target_os = "emscripten"))] fn pick_cursor( manager: &toolpath_cursor::CursorConvo, @@ -1469,20 +1576,23 @@ fn derive_pi_with_manager( session: Option, all: bool, ) -> Result> { - let config = toolpath_pi::DeriveConfig::default(); - let pairs: Vec<(String, String)> = match (project, session, all) { (Some(p), Some(s), _) => vec![(p, s)], (Some(p), None, true) => { - let sessions = manager - .read_all_sessions(&p) + let metas = manager + .list_sessions(&p) .map_err(|e| anyhow::anyhow!("{}", e))?; - if sessions.is_empty() { + if metas.is_empty() { anyhow::bail!("No Pi sessions found for project: {}", p); } - let doc = toolpath_pi::derive::derive_graph(&sessions, None, &config); - let cache_id = make_id("pi", &doc_inner_id(&doc)); - return Ok(vec![DerivedDoc { cache_id, doc }]); + let mut docs = Vec::with_capacity(metas.len()); + for m in &metas { + match derive_pi_session_with(manager, &p, &m.id) { + Ok(doc) => docs.push(doc), + Err(e) => eprintln!("Warning: skipping session {}: {e}", m.id), + } + } + return Ok(docs); } (Some(p), None, false) => { #[cfg(not(target_os = "emscripten"))] @@ -1496,9 +1606,11 @@ fn derive_pi_with_manager( .ok_or_else(|| { anyhow::anyhow!("No Pi sessions found for project: {}", p) })?; - let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); - let cache_id = make_id("pi", &doc_inner_id(&doc)); - return Ok(vec![DerivedDoc { cache_id, doc }]); + return Ok(vec![derive_pi_session_with( + manager, + &p, + &session.header.id, + )?]); } } #[cfg(target_os = "emscripten")] @@ -1507,9 +1619,11 @@ fn derive_pi_with_manager( .most_recent_session(&p) .map_err(|e| anyhow::anyhow!("{}", e))? .ok_or_else(|| anyhow::anyhow!("No Pi sessions found for project: {}", p))?; - let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); - let cache_id = make_id("pi", &doc_inner_id(&doc)); - return Ok(vec![DerivedDoc { cache_id, doc }]); + return Ok(vec![derive_pi_session_with( + manager, + &p, + &session.header.id, + )?]); } } (None, _, _) => { @@ -1532,12 +1646,7 @@ fn derive_pi_with_manager( let mut docs: Vec = Vec::with_capacity(pairs.len()); for (project_path, session_id) in &pairs { - let session = manager - .read_session(project_path, session_id) - .map_err(|e| anyhow::anyhow!("{}", e))?; - let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); - let cache_id = make_id("pi", &doc_inner_id(&doc)); - docs.push(DerivedDoc { cache_id, doc }); + docs.push(derive_pi_session_with(manager, project_path, session_id)?); } Ok(docs) } @@ -1554,13 +1663,45 @@ pub(crate) fn derive_pi_session( } else { toolpath_pi::PiConvo::new() }; + derive_pi_session_with(&manager, project, session) +} + +/// [`derive_pi_session`] against a caller-supplied manager. +pub(crate) fn derive_pi_session_with( + manager: &toolpath_pi::PiConvo, + project: &str, + session: &str, +) -> Result { + let file = toolpath_pi::reader::list_session_files(manager.resolver(), project) + .ok() + .and_then(|files| { + files.into_iter().find(|f| { + toolpath_pi::reader::peek_header(f).is_ok_and(|h| h.id == session) + || f.file_stem() + .and_then(|stem| stem.to_str()) + .and_then(|stem| stem.split_once('_')) + .is_some_and(|(_, rest)| rest == session) + }) + }); + let (modified, size) = file.as_deref().map(stat_stamp).unwrap_or((None, None)); + let artifact_id = session.to_string(); let config = toolpath_pi::DeriveConfig::default(); let session = manager .read_session(project, session) .map_err(|e| anyhow::anyhow!("{}", e))?; let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); - let cache_id = make_id("pi", &doc_inner_id(&doc)); - Ok(DerivedDoc { cache_id, doc }) + let cache_id = make_id(ArtifactType::Pi.name(), &doc_inner_id(&doc)); + Ok(DerivedDoc { + cache_id, + doc, + provenance: Some(ArtifactStub { + artifact_type: ArtifactType::Pi, + id: artifact_id, + path: Some(project.to_string()), + modified, + size, + }), + }) } #[cfg(not(target_os = "emscripten"))] @@ -1736,7 +1877,11 @@ pub(crate) fn pathbase_fetch_to_doc(target: &str, url_flag: Option<&str>) -> Res let cache_id = make_id("pathbase", &format!("{owner}-{repo}-{id}")); let doc = Graph::from_json(&body) .map_err(|e| anyhow::anyhow!("server returned a non-toolpath document: {e}"))?; - Ok(DerivedDoc { cache_id, doc }) + Ok(DerivedDoc { + cache_id, + doc, + provenance: None, + }) } fn derive_pathbase(target: String, url_flag: Option) -> Result> { diff --git a/crates/path-cli/src/cmd_query.rs b/crates/path-cli/src/cmd_query.rs index 81c0e179..909f2b49 100644 --- a/crates/path-cli/src/cmd_query.rs +++ b/crates/path-cli/src/cmd_query.rs @@ -42,6 +42,11 @@ pub struct QueryArgs { #[arg(long)] project: Option, + /// Keep only paths whose base lives under this directory (subtree + /// match), and scope the implicit sync to it likewise. + #[arg(long, short = 'd')] + parent_dir: Option, + /// Keep only paths whose meta.kind matches this selector /// (semver prefix, e.g. `agent-coding-session` or `…/v1.0`). #[arg(long)] @@ -57,6 +62,10 @@ pub struct QueryArgs { /// reading text/diff content. Composes with `-c`. #[arg(short = 'r', long)] raw: bool, + + /// Skip the automatic cache sync that runs before the query. + #[arg(long)] + no_sync: bool, } const WRAPPER_HELP: &str = "\ @@ -89,11 +98,17 @@ Examples: path query -r '.[0].change[].structural.text' # read a turn's text, unescaped"; pub fn run(args: QueryArgs, pretty: bool) -> Result<()> { + #[cfg(not(target_os = "emscripten"))] + if !args.no_sync { + sync_query_scope(&args); + } + let scope = Scope { source: args.source, ids: args.ids, inputs: args.input, project: args.project, + parent_dir: args.parent_dir, kind: args.kind, }; @@ -103,3 +118,122 @@ pub fn run(args: QueryArgs, pretty: bool) -> Result<()> { crate::query::run(&scope, &args.filter, compact, args.raw) } + +/// Freshen the slice of the cache this query will read, before reading +/// it. Quiet unless something was actually ingested; a sync failure +/// degrades to querying the cache as-is. +#[cfg(not(target_os = "emscripten"))] +fn sync_query_scope(args: &QueryArgs) { + let types = sync_types_for(args.source.as_deref(), &args.ids, &args.input); + if types.is_empty() { + return; + } + let bundle = crate::cmd_share::HarnessBundle::from_environment(); + match crate::sync::sync_bundle(&bundle, &types, args.parent_dir.as_deref()) { + Ok(outcomes) => { + for (t, o) in outcomes { + if o.new + o.updated + o.failed > 0 { + let failed = if o.failed > 0 { + format!(", {} failed", o.failed) + } else { + String::new() + }; + eprintln!( + "synced {}: {} new, {} updated{failed}", + t.name(), + o.new, + o.updated + ); + } + } + } + Err(e) => eprintln!("warning: cache sync skipped: {e}"), + } +} + +/// Which artifact types the query's scope flags reach. `--input`-only +/// invocations never touch the cache; `--source` narrows to one type +/// (non-syncable sources like `pathbase` map to nothing); `--id`s +/// narrow to their prefixes; a bare cache-wide query syncs everything. +#[cfg(not(target_os = "emscripten"))] +fn sync_types_for( + source: Option<&str>, + ids: &[String], + inputs: &[String], +) -> Vec { + use crate::sync::ArtifactType; + let scans_cache = source.is_some() || !ids.is_empty() || inputs.is_empty(); + if !scans_cache { + return Vec::new(); + } + if let Some(source) = source { + return ArtifactType::parse(source).into_iter().collect(); + } + if !ids.is_empty() { + return ArtifactType::ALL + .into_iter() + .filter(|t| { + ids.iter().any(|id| { + id.strip_prefix(t.name()) + .is_some_and(|rest| rest.starts_with('-')) + }) + }) + .collect(); + } + ArtifactType::ALL.to_vec() +} + +#[cfg(all(test, not(target_os = "emscripten")))] +mod tests { + use super::sync_types_for; + use crate::sync::ArtifactType; + + fn s(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn input_only_queries_sync_nothing() { + assert!(sync_types_for(None, &[], &s(&["doc.json"])).is_empty()); + } + + #[test] + fn source_flag_narrows_to_one_type() { + assert_eq!( + sync_types_for(Some("claude"), &[], &[]), + vec![ArtifactType::Claude] + ); + assert!( + sync_types_for(Some("pathbase"), &[], &[]).is_empty(), + "non-syncable sources sync nothing" + ); + } + + #[test] + fn ids_narrow_to_their_prefixes() { + let types = sync_types_for( + None, + &s(&["claude-abc", "codex-def", "pathbase-x-y-z"]), + &[], + ); + assert_eq!(types, vec![ArtifactType::Claude, ArtifactType::Codex]); + // `cursor-…` must not match on the `c` of another type or vice versa. + assert_eq!( + sync_types_for(None, &s(&["cursor-abc"]), &[]), + vec![ArtifactType::Cursor] + ); + } + + #[test] + fn bare_cache_query_syncs_everything() { + assert_eq!(sync_types_for(None, &[], &[]), ArtifactType::ALL.to_vec()); + } + + #[test] + fn source_beats_ids_and_inputs_do_not_disable_cache_scan() { + assert_eq!( + sync_types_for(Some("git"), &s(&["claude-abc"]), &s(&["doc.json"])), + vec![ArtifactType::Git] + ); + } +} diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index ae9e21d4..02272b6d 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -45,9 +45,9 @@ use clap::Args; use std::path::PathBuf; /// Re-exported so external callers (integration tests, future consumers) -/// can construct [`ResumeArgs`] without depending on the `cmd_share` -/// module directly. -pub use crate::cmd_share::HarnessArg; +/// can construct [`ResumeArgs`] without depending on the private +/// `cmd_share` module directly. +pub use crate::cmd_share::Harness; #[derive(Args, Debug)] pub struct ResumeArgs { @@ -65,7 +65,7 @@ pub struct ResumeArgs { /// Pin the resume target. Skips the interactive picker. #[arg(long, value_enum)] - pub harness: Option, + pub harness: Option, /// Skip the cache entirely when fetching from Pathbase: don't read /// an existing entry, don't write the fetched body. Useful for @@ -123,8 +123,7 @@ use toolpath::v1::{Graph, Path as TPath, PathOrRef}; /// Read a path's source harness from `meta.source` (set by /// `toolpath-convo::derive_path` to the provider id), falling back to /// actor-string sniffing across the path's steps. -pub(crate) fn infer_source_harness(path: &TPath) -> Option { - use crate::cmd_share::Harness; +pub(crate) fn infer_source_harness(path: &TPath) -> Option { let meta_source = path.meta.as_ref().and_then(|m| m.source.as_deref()); if let Some(source) = meta_source { match source { @@ -149,6 +148,9 @@ pub(crate) fn infer_source_harness(path: &TPath) -> Option Result<&TPath> { /// Resolve the user-supplied `` argument into a parsed `Graph` /// plus the source harness inferred from its single inline path (if /// any). See spec § "Input resolution" for the order. -pub(crate) fn resolve_input( - args: &ResumeArgs, -) -> Result<(Graph, Option)> { +pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option)> { let raw = args.input.as_str(); enum Shape<'a> { @@ -302,11 +302,7 @@ pub(crate) fn binary_on_path(name: &str, path_override: Option<&std::path::Path> /// explicitly from the IDE's command palette, but `open -a Cursor` /// (macOS) / `xdg-open` (Linux) always work. Treat cursor as available /// when either path is open. -pub(crate) fn harness_available( - harness: crate::cmd_share::Harness, - path_override: Option<&std::path::Path>, -) -> bool { - use crate::cmd_share::Harness; +pub(crate) fn harness_available(harness: Harness, path_override: Option<&std::path::Path>) -> bool { if binary_on_path(harness.name(), path_override) { return true; } @@ -323,16 +319,6 @@ pub(crate) fn harness_available( false } -const ALL_HARNESSES: &[crate::cmd_share::Harness] = &[ - crate::cmd_share::Harness::Claude, - crate::cmd_share::Harness::Gemini, - crate::cmd_share::Harness::Codex, - crate::cmd_share::Harness::Copilot, - crate::cmd_share::Harness::Opencode, - crate::cmd_share::Harness::Cursor, - crate::cmd_share::Harness::Pi, -]; - /// Decide which harness to resume in. /// /// - If `arg` is `Some`, validate the named harness is on PATH and return it. @@ -341,14 +327,11 @@ const ALL_HARNESSES: &[crate::cmd_share::Harness] = &[ /// /// `path_override` is `None` in production; tests pass `Some(dir)` to fake `$PATH`. pub(crate) fn pick_harness( - arg: Option, - source: Option, + arg: Option, + source: Option, path_override: Option<&std::path::Path>, -) -> Result { - use crate::cmd_share::Harness; - - if let Some(a) = arg { - let h = Harness::from_arg(a); +) -> Result { + if let Some(h) = arg { if !harness_available(h, path_override) { anyhow::bail!( "harness `{}` isn't on PATH; install it or pick another with `--harness`", @@ -358,7 +341,7 @@ pub(crate) fn pick_harness( return Ok(h); } - let installed: Vec = ALL_HARNESSES + let installed: Vec = Harness::ALL .iter() .copied() .filter(|h| harness_available(*h, path_override)) @@ -373,10 +356,7 @@ pub(crate) fn pick_harness( interactive_pick(&installed, source) } -fn interactive_pick( - installed: &[crate::cmd_share::Harness], - source: Option, -) -> Result { +fn interactive_pick(installed: &[Harness], source: Option) -> Result { if !crate::fuzzy::available() { let hint = if crate::fuzzy::embedded_picker_available() { "rerun in a terminal" @@ -421,8 +401,7 @@ fn interactive_pick( /// Static map from harness to resume-argv shape. Lives here because /// it's a per-harness CLI convention, not a projection concern. -pub(crate) fn argv_for(harness: crate::cmd_share::Harness, session_id: &str) -> Vec { - use crate::cmd_share::Harness; +pub(crate) fn argv_for(harness: Harness, session_id: &str) -> Vec { match harness { Harness::Claude => vec!["-r".into(), session_id.into()], Harness::Gemini => vec!["--resume".into(), session_id.into()], @@ -441,11 +420,10 @@ pub(crate) fn argv_for(harness: crate::cmd_share::Harness, session_id: &str) -> } pub(crate) fn invocation_for( - harness: crate::cmd_share::Harness, + harness: Harness, session_id: &str, cwd: &std::path::Path, ) -> (String, Vec) { - use crate::cmd_share::Harness; if harness == Harness::Cursor { return cursor_invocation(cwd); } @@ -479,10 +457,9 @@ fn cursor_invocation(cwd: &std::path::Path) -> (String, Vec) { /// returning the projected session id. pub(crate) fn project_into_harness( path: &TPath, - harness: crate::cmd_share::Harness, + harness: Harness, cwd: &std::path::Path, ) -> Result { - use crate::cmd_share::Harness; match harness { Harness::Claude => crate::cmd_export::project_claude(path, cwd), Harness::Gemini => crate::cmd_export::project_gemini(path, cwd), @@ -627,7 +604,7 @@ mod tests { let args = ResumeArgs { input: doc_file.to_string_lossy().to_string(), cwd: Some(cwd.path().to_path_buf()), - harness: Some(HarnessArg::Claude), + harness: Some(Harness::Claude), no_cache: false, force: false, url: None, @@ -642,7 +619,6 @@ mod tests { assert_eq!(cap.cwd, std::fs::canonicalize(cwd.path()).unwrap()); } - use crate::cmd_share::Harness; use toolpath::v1::{Graph, PathMeta, PathOrRef}; fn make_step_with_actor(id: &str, actor: &str) -> toolpath::v1::Step { @@ -923,10 +899,10 @@ mod tests { #[test] fn pick_harness_explicit_arg_validates_path() { let td = fake_path_with(&["claude"]); - let result = pick_harness(Some(HarnessArg::Claude), None, Some(td.path())); + let result = pick_harness(Some(Harness::Claude), None, Some(td.path())); assert_eq!(result.unwrap(), Harness::Claude); - let err = pick_harness(Some(HarnessArg::Gemini), None, Some(td.path())).unwrap_err(); + let err = pick_harness(Some(Harness::Gemini), None, Some(td.path())).unwrap_err(); assert!(err.to_string().contains("`gemini` isn't on PATH")); } @@ -935,7 +911,7 @@ mod tests { fn cursor_available_via_open_fallback_on_macos() { let td = fake_path_with(&["open"]); assert!(harness_available(Harness::Cursor, Some(td.path()))); - let picked = pick_harness(Some(HarnessArg::Cursor), None, Some(td.path())); + let picked = pick_harness(Some(Harness::Cursor), None, Some(td.path())); assert_eq!(picked.unwrap(), Harness::Cursor); } diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index 1eb4f96f..3be5e835 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -5,21 +5,80 @@ use anyhow::Result; use chrono::{DateTime, Utc}; -use clap::{Args, ValueEnum}; +use clap::Args; use std::path::PathBuf; use crate::cmd_export::RepoSpec; - -#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] +use crate::sync::ArtifactType; + +/// An installed agent harness — a runtime sessions can be shared from +/// and resumed into. Deliberately parallel to [`ArtifactType`]: every +/// harness maps onto an artifact type, but not every artifact type is +/// a harness once non-session kinds (git, github) join `ArtifactType` +/// — you can't resume into a git repo. Harness-facing surfaces +/// (`share`/`resume` `--harness`) take this type so non-harness values +/// stay unrepresentable. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, clap::ValueEnum)] #[value(rename_all = "lower")] -pub enum HarnessArg { +pub enum Harness { Claude, Gemini, Codex, - Copilot, Opencode, Cursor, Pi, + Copilot, +} + +impl Harness { + /// Every harness, in presentation order. + pub(crate) const ALL: [Harness; 7] = [ + Harness::Claude, + Harness::Gemini, + Harness::Codex, + Harness::Opencode, + Harness::Cursor, + Harness::Pi, + Harness::Copilot, + ]; + + /// The artifact type this harness's sessions ingest as. + pub(crate) fn artifact_type(&self) -> ArtifactType { + match self { + Harness::Claude => ArtifactType::Claude, + Harness::Gemini => ArtifactType::Gemini, + Harness::Codex => ArtifactType::Codex, + Harness::Opencode => ArtifactType::Opencode, + Harness::Cursor => ArtifactType::Cursor, + Harness::Pi => ArtifactType::Pi, + Harness::Copilot => ArtifactType::Copilot, + } + } + + pub(crate) fn name(&self) -> &'static str { + self.artifact_type().name() + } + + pub(crate) fn symbol(&self) -> &'static str { + self.artifact_type().symbol() + } +} + +impl ArtifactType { + /// The harness this artifact type's sessions come from — `None` + /// for artifact types that aren't agent harnesses (none yet). + pub(crate) fn harness(&self) -> Option { + match self { + ArtifactType::Claude => Some(Harness::Claude), + ArtifactType::Gemini => Some(Harness::Gemini), + ArtifactType::Codex => Some(Harness::Codex), + ArtifactType::Opencode => Some(Harness::Opencode), + ArtifactType::Cursor => Some(Harness::Cursor), + ArtifactType::Pi => Some(Harness::Pi), + ArtifactType::Copilot => Some(Harness::Copilot), + ArtifactType::Git => None, + } + } } #[derive(Args, Debug)] @@ -49,7 +108,7 @@ pub struct ShareArgs { /// Narrow the picker to one harness, or skip the picker entirely /// when used with --session. #[arg(long, value_enum)] - pub harness: Option, + pub harness: Option, /// Skip the picker. Requires --harness; requires --project for /// claude/gemini/pi. @@ -64,93 +123,28 @@ pub struct ShareArgs { /// Skip writing the cache; derive in-memory only #[arg(long)] pub no_cache: bool, -} - -/// Which agent harness a session was produced by. -#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] -pub(crate) enum Harness { - Claude, - Gemini, - Codex, - Copilot, - Opencode, - Cursor, - Pi, -} - -impl Harness { - pub(crate) fn name(&self) -> &'static str { - match self { - Harness::Claude => "claude", - Harness::Gemini => "gemini", - Harness::Codex => "codex", - Harness::Copilot => "copilot", - Harness::Opencode => "opencode", - Harness::Cursor => "cursor", - Harness::Pi => "pi", - } - } - - /// Padded so all symbols line up in the fzf column. Longest is - /// "opencode" (8); pad shorter names to match. - pub(crate) fn symbol(&self) -> &'static str { - match self { - Harness::Claude => "claude ", - Harness::Gemini => "gemini ", - Harness::Codex => "codex ", - Harness::Copilot => "copilot ", - Harness::Opencode => "opencode", - Harness::Cursor => "cursor ", - Harness::Pi => "pi ", - } - } - - /// True when the underlying provider keys sessions by project path. - /// claude/gemini/pi: true. codex/opencode/cursor: false (sessions - /// store cwd per-row, not as a directory key — cursor stores it as - /// `workspaceIdentifier.uri.fsPath` on each composer). - pub(crate) fn project_keyed(&self) -> bool { - matches!(self, Harness::Claude | Harness::Gemini | Harness::Pi) - } - pub(crate) fn from_arg(arg: HarnessArg) -> Self { - match arg { - HarnessArg::Claude => Harness::Claude, - HarnessArg::Gemini => Harness::Gemini, - HarnessArg::Codex => Harness::Codex, - HarnessArg::Copilot => Harness::Copilot, - HarnessArg::Opencode => Harness::Opencode, - HarnessArg::Cursor => Harness::Cursor, - HarnessArg::Pi => Harness::Pi, - } - } - - pub(crate) fn parse(s: &str) -> Option { - match s { - "claude" => Some(Harness::Claude), - "gemini" => Some(Harness::Gemini), - "codex" => Some(Harness::Codex), - "copilot" => Some(Harness::Copilot), - "opencode" => Some(Harness::Opencode), - "cursor" => Some(Harness::Cursor), - "pi" => Some(Harness::Pi), - _ => None, - } - } + /// Keep thinking blocks in the uploaded document (stripped by + /// default; the local cache always keeps them) + #[arg(long)] + pub include_thinking: bool, } -/// One row in the unified session picker. +/// One artifact surfaced by a provider — today always an agent session. +/// Rows feed both the unified `share` picker and `p cache sync`. #[derive(Debug, Clone)] -pub(crate) struct SessionRow { - pub(crate) harness: Harness, +pub(crate) struct ArtifactRow { + pub(crate) artifact_type: ArtifactType, /// Project path for keyed providers; `None` for codex/opencode. - pub(crate) project: Option, + pub(crate) path: Option, /// Recorded cwd from the session (codex/opencode only). pub(crate) cwd: Option, pub(crate) session_id: String, pub(crate) title: String, pub(crate) last_activity: Option>, - pub(crate) message_count: usize, + /// Message count — populated only for harness artifact types + /// (agent sessions); `None` for future non-session artifact kinds. + pub(crate) message_count: Option, pub(crate) matches_cwd: bool, } @@ -171,7 +165,7 @@ pub(crate) struct HarnessBundle { impl HarnessBundle { /// Build the production bundle. Each provider is included /// unconditionally (its `new()` doesn't fail on a missing home dir); - /// `gather_sessions` skips the ones whose listing returns empty/NotFound. + /// `gather_artifacts` skips the ones whose listing returns empty/NotFound. pub(crate) fn from_environment() -> Self { Self { claude: Some(toolpath_claude::ClaudeConvo::new()), @@ -192,49 +186,49 @@ impl HarnessBundle { /// Filters: `harness_filter` keeps only rows from one harness; `project_filter` /// keeps only rows whose project (for keyed) or cwd (for session-keyed) /// canonicalizes to that path. -pub(crate) fn gather_sessions( +pub(crate) fn gather_artifacts( bundle: &HarnessBundle, cwd: &std::path::Path, - harness_filter: Option, + harness_filter: Option, project_filter: Option<&std::path::Path>, -) -> Vec { +) -> Vec { let mut rows = Vec::new(); let canonical_cwd = canonicalize_or_self(cwd); let canonical_project = project_filter.map(canonicalize_or_self); - let want = |h: Harness| harness_filter.is_none_or(|f| f == h); + let want = |h: ArtifactType| harness_filter.is_none_or(|f| f == h); - if want(Harness::Claude) + if want(ArtifactType::Claude) && let Some(mgr) = &bundle.claude { collect_claude(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); } - if want(Harness::Gemini) + if want(ArtifactType::Gemini) && let Some(mgr) = &bundle.gemini { collect_gemini(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); } - if want(Harness::Pi) + if want(ArtifactType::Pi) && let Some(mgr) = &bundle.pi { collect_pi(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); } - if want(Harness::Codex) + if want(ArtifactType::Codex) && let Some(mgr) = &bundle.codex { collect_codex(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); } - if want(Harness::Copilot) + if want(ArtifactType::Copilot) && let Some(mgr) = &bundle.copilot { collect_copilot(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); } - if want(Harness::Opencode) + if want(ArtifactType::Opencode) && let Some(mgr) = &bundle.opencode { collect_opencode(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); } - if want(Harness::Cursor) + if want(ArtifactType::Cursor) && let Some(mgr) = &bundle.cursor { collect_cursor(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); @@ -260,7 +254,7 @@ fn collect_claude( mgr: &toolpath_claude::ClaudeConvo, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { let projects = match mgr.list_projects() { Ok(ps) if !ps.is_empty() => ps, @@ -287,16 +281,16 @@ fn collect_claude( }; let matches_cwd = paths_match(project_path, canonical_cwd); for m in metas { - out.push(SessionRow { - harness: Harness::Claude, - project: Some(m.project_path), + out.push(ArtifactRow { + artifact_type: ArtifactType::Claude, + path: Some(m.project_path), cwd: None, session_id: m.session_id, title: m .first_user_message .unwrap_or_else(|| "(no prompt)".to_string()), last_activity: m.last_activity, - message_count: m.message_count, + message_count: Some(m.message_count), matches_cwd, }); } @@ -307,7 +301,7 @@ fn collect_gemini( mgr: &toolpath_gemini::GeminiConvo, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { let projects = match mgr.list_projects() { Ok(ps) if !ps.is_empty() => ps, @@ -334,16 +328,16 @@ fn collect_gemini( }; let matches_cwd = paths_match(project_path, canonical_cwd); for m in metas { - out.push(SessionRow { - harness: Harness::Gemini, - project: Some(m.project_path), + out.push(ArtifactRow { + artifact_type: ArtifactType::Gemini, + path: Some(m.project_path), cwd: None, session_id: m.session_uuid, title: m .first_user_message .unwrap_or_else(|| "(no prompt)".to_string()), last_activity: m.last_activity, - message_count: m.message_count, + message_count: Some(m.message_count), matches_cwd, }); } @@ -354,7 +348,7 @@ fn collect_pi( mgr: &toolpath_pi::PiConvo, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { let projects = match mgr.list_projects() { Ok(ps) if !ps.is_empty() => ps, @@ -381,20 +375,29 @@ fn collect_pi( }; let matches_cwd = paths_match(project_path, canonical_cwd); for m in metas { - // SessionMeta.timestamp is a String; parse to DateTime when possible. - let last_activity = chrono::DateTime::parse_from_rfc3339(&m.timestamp) + // Pi's SessionMeta.timestamp is the session *start*, so it + // never moves as the session grows; prefer the file's mtime + // as the change-detecting last_activity, falling back to + // the header timestamp when the stat fails. + let last_activity = std::fs::metadata(&m.file_path) + .and_then(|md| md.modified()) .ok() - .map(|d| d.with_timezone(&Utc)); - out.push(SessionRow { - harness: Harness::Pi, - project: Some(project.clone()), + .map(DateTime::::from) + .or_else(|| { + chrono::DateTime::parse_from_rfc3339(&m.timestamp) + .ok() + .map(|d| d.with_timezone(&Utc)) + }); + out.push(ArtifactRow { + artifact_type: ArtifactType::Pi, + path: Some(project.clone()), cwd: None, session_id: m.id, title: m .first_user_message .unwrap_or_else(|| "(no prompt)".to_string()), last_activity, - message_count: m.entry_count, + message_count: Some(m.entry_count), matches_cwd, }); } @@ -405,7 +408,7 @@ fn collect_codex( mgr: &toolpath_codex::CodexConvo, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { let metas = match mgr.list_sessions() { Ok(m) if !m.is_empty() => m, @@ -432,16 +435,16 @@ fn collect_codex( .as_deref() .map(|p| paths_match(p, canonical_cwd)) .unwrap_or(false); - out.push(SessionRow { - harness: Harness::Codex, - project: None, + out.push(ArtifactRow { + artifact_type: ArtifactType::Codex, + path: None, cwd: cwd_str, session_id: m.id, title: m .first_user_message .unwrap_or_else(|| "(no prompt)".to_string()), last_activity: m.last_activity, - message_count: m.line_count, + message_count: Some(m.line_count), matches_cwd, }); } @@ -451,7 +454,7 @@ fn collect_copilot( mgr: &toolpath_copilot::CopilotConvo, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { let metas = match mgr.list_sessions() { Ok(m) if !m.is_empty() => m, @@ -475,16 +478,16 @@ fn collect_copilot( .as_deref() .map(|p| paths_match(p, canonical_cwd)) .unwrap_or(false); - out.push(SessionRow { - harness: Harness::Copilot, - project: None, + out.push(ArtifactRow { + artifact_type: ArtifactType::Copilot, + path: None, cwd: m.cwd, session_id: m.id, title: m .first_user_message .unwrap_or_else(|| "(no prompt)".to_string()), last_activity: m.last_activity, - message_count: m.line_count, + message_count: Some(m.line_count), matches_cwd, }); } @@ -494,7 +497,7 @@ fn collect_opencode( mgr: &toolpath_opencode::OpencodeConvo, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { let metas = match mgr.io().list_session_metadata(None) { Ok(m) if !m.is_empty() => m, @@ -518,14 +521,14 @@ fn collect_opencode( (_, false) => m.title.clone(), _ => "(no prompt)".to_string(), }; - out.push(SessionRow { - harness: Harness::Opencode, - project: None, + out.push(ArtifactRow { + artifact_type: ArtifactType::Opencode, + path: None, cwd: Some(cwd_str), session_id: m.id, title, last_activity: m.last_activity, - message_count: m.message_count, + message_count: Some(m.message_count), matches_cwd, }); } @@ -535,7 +538,7 @@ fn collect_cursor( mgr: &toolpath_cursor::CursorConvo, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { let metas = match mgr.io().list_session_metadata() { Ok(m) if !m.is_empty() => m, @@ -567,54 +570,54 @@ fn collect_cursor( (_, Some(n)) if !n.is_empty() => n.clone(), _ => "(no prompt)".to_string(), }; - out.push(SessionRow { - harness: Harness::Cursor, - project: None, + out.push(ArtifactRow { + artifact_type: ArtifactType::Cursor, + path: None, cwd: Some(cwd_str), session_id: m.id, title, last_activity: m.last_activity, - message_count: m.message_count, + message_count: Some(m.message_count), matches_cwd, }); } } -fn is_not_found_claude(err: &toolpath_claude::ConvoError) -> bool { +pub(crate) fn is_not_found_claude(err: &toolpath_claude::ConvoError) -> bool { use toolpath_claude::ConvoError; matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) || matches!(err, ConvoError::NoHomeDirectory) || matches!(err, ConvoError::ClaudeDirectoryNotFound(_)) } -fn is_not_found_gemini(err: &toolpath_gemini::ConvoError) -> bool { +pub(crate) fn is_not_found_gemini(err: &toolpath_gemini::ConvoError) -> bool { use toolpath_gemini::ConvoError; matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) || matches!(err, ConvoError::NoHomeDirectory) || matches!(err, ConvoError::GeminiDirectoryNotFound(_)) } -fn is_not_found_pi(err: &toolpath_pi::PiError) -> bool { +pub(crate) fn is_not_found_pi(err: &toolpath_pi::PiError) -> bool { use toolpath_pi::PiError; matches!(err, PiError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) || matches!(err, PiError::ProjectNotFound(_)) } -fn is_not_found_codex(err: &toolpath_codex::ConvoError) -> bool { +pub(crate) fn is_not_found_codex(err: &toolpath_codex::ConvoError) -> bool { use toolpath_codex::ConvoError; matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) || matches!(err, ConvoError::NoHomeDirectory) || matches!(err, ConvoError::CodexDirectoryNotFound(_)) } -fn is_not_found_copilot(err: &toolpath_copilot::ConvoError) -> bool { +pub(crate) fn is_not_found_copilot(err: &toolpath_copilot::ConvoError) -> bool { use toolpath_copilot::ConvoError; matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) || matches!(err, ConvoError::NoHomeDirectory) || matches!(err, ConvoError::CopilotDirectoryNotFound(_)) } -fn is_not_found_opencode(err: &toolpath_opencode::ConvoError) -> bool { +pub(crate) fn is_not_found_opencode(err: &toolpath_opencode::ConvoError) -> bool { use toolpath_opencode::ConvoError; matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) || matches!(err, ConvoError::NoHomeDirectory) @@ -622,7 +625,7 @@ fn is_not_found_opencode(err: &toolpath_opencode::ConvoError) -> bool { || matches!(err, ConvoError::DatabaseNotFound(_)) } -fn is_not_found_cursor(err: &toolpath_cursor::CursorError) -> bool { +pub(crate) fn is_not_found_cursor(err: &toolpath_cursor::CursorError) -> bool { use toolpath_cursor::CursorError; matches!(err, CursorError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) || matches!(err, CursorError::NoHomeDirectory) @@ -631,7 +634,7 @@ fn is_not_found_cursor(err: &toolpath_cursor::CursorError) -> bool { } pub fn run(args: ShareArgs) -> Result<()> { - let harness = args.harness.map(Harness::from_arg); + let harness = args.harness.map(|h| h.artifact_type()); if args.session.is_some() && harness.is_none() { anyhow::bail!("--session requires --harness"); @@ -660,7 +663,7 @@ pub fn run(args: ShareArgs) -> Result<()> { let cwd = std::env::current_dir()?; let bundle = HarnessBundle::from_environment(); let project_filter = args.project.as_deref(); - let rows = gather_sessions(&bundle, &cwd, harness, project_filter); + let rows = gather_artifacts(&bundle, &cwd, harness, project_filter); if rows.is_empty() { return bail_no_sessions(&bundle, project_filter); @@ -720,14 +723,15 @@ pub fn run(args: ShareArgs) -> Result<()> { repo: args.repo.clone(), name: args.name.clone(), public: args.public, - harness: Some(harness_to_arg(h)), + harness: h.harness(), session: None, // unused by share_explicit - project: if h.project_keyed() { + project: if h.path_keyed() { Some(PathBuf::from(&key)) } else { None }, no_cache: args.no_cache, + include_thinking: args.include_thinking, }; // Show the conversation title in the confirmation line; the session id // is opaque and doesn't help the user verify they picked the right @@ -736,18 +740,6 @@ pub fn run(args: ShareArgs) -> Result<()> { share_explicit(h, &session, &explicit, auth, base_url) } -fn harness_to_arg(h: Harness) -> HarnessArg { - match h { - Harness::Claude => HarnessArg::Claude, - Harness::Gemini => HarnessArg::Gemini, - Harness::Codex => HarnessArg::Codex, - Harness::Copilot => HarnessArg::Copilot, - Harness::Opencode => HarnessArg::Opencode, - Harness::Cursor => HarnessArg::Cursor, - Harness::Pi => HarnessArg::Pi, - } -} - fn bail_no_sessions( bundle: &HarnessBundle, project_filter: Option<&std::path::Path>, @@ -947,13 +939,13 @@ fn home_relative(path: &std::path::Path, home: Option<&std::path::Path>) -> Stri } fn share_explicit( - harness: Harness, + harness: ArtifactType, session: &str, args: &ShareArgs, auth: crate::cmd_pathbase::AuthMode, base_url: String, ) -> Result<()> { - let project = match (harness.project_keyed(), args.project.as_ref()) { + let project = match (harness.path_keyed(), args.project.as_ref()) { (true, Some(p)) => Some(p.to_string_lossy().into_owned()), (true, None) => anyhow::bail!( "--project required when --harness is {} and --session is set", @@ -962,6 +954,38 @@ fn share_explicit( (false, _) => None, }; + // Fast path: when the manifest shows this exact source state is + // already in the cache, upload the cached doc instead of re-deriving + // — a derive would reproduce it byte-for-byte anyway. + if !args.no_cache + && let Some(cache_id) = crate::sync::fresh_cache_id( + &HarnessBundle::from_environment(), + harness, + project.as_deref(), + session, + ) + { + let doc_path = crate::cmd_cache::cache_path(&cache_id)?; + let mut graph = crate::io::read_document_auto(&doc_path)?; + if !args.include_thinking { + crate::cmd_export::strip_thinking(&mut graph); + } + let body = graph.to_json()?; + eprintln!( + "Cache is current for {} session {cache_id}; uploading without re-deriving", + harness.name() + ); + let summary = format!("{} session {}", harness.name(), cache_id); + let upload = crate::cmd_export::PathbaseUploadArgs { + url: args.url.clone(), + anon: args.anon, + repo: args.repo.clone(), + name: args.name.clone(), + public: args.public, + }; + return crate::cmd_export::run_pathbase_inner(auth, base_url, upload, &body, &summary); + } + let derived = derive_session(harness, project.as_deref(), session)?; let summary = format!("{} session {}", harness.name(), derived.cache_id); @@ -974,6 +998,11 @@ fn share_explicit( // overwrite so cache and upload agree (use `--no-cache` to skip // the cache write entirely). let path = crate::cmd_cache::write_cached(&derived.cache_id, &derived.doc, true)?; + if let Some(stub) = &derived.provenance + && let Err(e) = crate::sync::record_stub(stub, &derived.cache_id) + { + eprintln!("warning: sync manifest not updated: {e}"); + } eprintln!( "Cached {} session → {} ({})", harness.name(), @@ -982,7 +1011,11 @@ fn share_explicit( ); } - let body = derived.doc.to_json()?; + let mut doc = derived.doc; + if !args.include_thinking { + crate::cmd_export::strip_thinking(&mut doc); + } + let body = doc.to_json()?; let upload = crate::cmd_export::PathbaseUploadArgs { url: args.url.clone(), anon: args.anon, @@ -1002,25 +1035,27 @@ fn share_explicit( /// The display column is space-padded rather than tab-separated so the /// columns line up consistently across pickers — terminal tab stops /// produce ugly variable gaps in both fzf and skim. -fn format_picker_row(row: &SessionRow) -> String { +fn format_picker_row(row: &ArtifactRow) -> String { let key = row - .project + .path .clone() .or_else(|| row.cwd.clone()) .unwrap_or_default(); let scope = if row.matches_cwd { "·" } else { " " }; - let leading = format!("{scope} {}", row.harness.symbol()); + let leading = format!("{scope} {}", row.artifact_type.symbol()); let display = render_row( Some(&leading), row.last_activity, - &count(row.message_count, "msgs"), + &row.message_count + .map(|c| count(c, "msgs")) + .unwrap_or_default(), Some(&project_short(&key)), &row.title, ); let title = clean_for_picker_display(&row.title); format!( "{}\t{}\t{}\t{}\t{}", - row.harness.name(), + row.artifact_type.name(), tab_safe(&key), tab_safe(&row.session_id), display, @@ -1031,9 +1066,9 @@ fn format_picker_row(row: &SessionRow) -> String { /// Inverse of [`format_picker_row`] — pulls (harness, key, session, /// title) back out of the line the picker returned. Returns `None` if /// the line is malformed. -fn parse_picker_row(line: &str) -> Option<(Harness, String, String, String)> { +fn parse_picker_row(line: &str) -> Option<(ArtifactType, String, String, String)> { let mut parts = line.split('\t'); - let h = Harness::parse(parts.next()?)?; + let h = ArtifactType::parse(parts.next()?)?; let key = parts.next()?.to_string(); let session = parts.next()?.to_string(); if session.is_empty() { @@ -1048,26 +1083,27 @@ fn parse_picker_row(line: &str) -> Option<(Harness, String, String, String)> { use crate::fuzzy::{clean_for_picker_display, count, project_short, render_row, tab_safe}; fn derive_session( - harness: Harness, + harness: ArtifactType, project: Option<&str>, session: &str, ) -> Result { match harness { - Harness::Claude => { - crate::cmd_import::derive_claude_session(project.expect("project_keyed"), session) + ArtifactType::Claude => { + crate::cmd_import::derive_claude_session(project.expect("path_keyed"), session) } - Harness::Gemini => crate::cmd_import::derive_gemini_session( - project.expect("project_keyed"), - session, - false, - ), - Harness::Pi => { - crate::cmd_import::derive_pi_session(project.expect("project_keyed"), session, None) + ArtifactType::Gemini => { + crate::cmd_import::derive_gemini_session(project.expect("path_keyed"), session) + } + ArtifactType::Copilot => crate::cmd_import::derive_copilot_session(session), + ArtifactType::Pi => { + crate::cmd_import::derive_pi_session(project.expect("path_keyed"), session, None) + } + ArtifactType::Codex => crate::cmd_import::derive_codex_session(session), + ArtifactType::Opencode => crate::cmd_import::derive_opencode_session(session, false), + ArtifactType::Cursor => crate::cmd_import::derive_cursor_session(session), + ArtifactType::Git => { + anyhow::bail!("share only handles agent sessions; git artifacts go through `p import`") } - Harness::Codex => crate::cmd_import::derive_codex_session(session), - Harness::Copilot => crate::cmd_import::derive_copilot_session(session), - Harness::Opencode => crate::cmd_import::derive_opencode_session(session, false), - Harness::Cursor => crate::cmd_import::derive_cursor_session(session), } } @@ -1075,58 +1111,6 @@ fn derive_session( mod tests { use super::*; - #[test] - fn harness_name_and_symbol_are_distinct() { - let all = [ - Harness::Claude, - Harness::Gemini, - Harness::Codex, - Harness::Opencode, - Harness::Cursor, - Harness::Pi, - ]; - let names: Vec<&str> = all.iter().map(|h| h.name()).collect(); - let symbols: Vec<&str> = all.iter().map(|h| h.symbol()).collect(); - assert_eq!(names.len(), 6); - assert_eq!( - names.iter().collect::>().len(), - 6, - "names must be unique" - ); - assert_eq!( - symbols - .iter() - .collect::>() - .len(), - 6, - "symbols must be unique" - ); - } - - #[test] - fn harness_project_keyed_matches_design() { - assert!(Harness::Claude.project_keyed()); - assert!(Harness::Gemini.project_keyed()); - assert!(Harness::Pi.project_keyed()); - assert!(!Harness::Codex.project_keyed()); - assert!(!Harness::Opencode.project_keyed()); - assert!(!Harness::Cursor.project_keyed()); - } - - #[test] - fn harness_from_arg_roundtrips() { - for (arg, harness) in [ - (HarnessArg::Claude, Harness::Claude), - (HarnessArg::Gemini, Harness::Gemini), - (HarnessArg::Codex, Harness::Codex), - (HarnessArg::Opencode, Harness::Opencode), - (HarnessArg::Cursor, Harness::Cursor), - (HarnessArg::Pi, Harness::Pi), - ] { - assert_eq!(Harness::from_arg(arg), harness); - } - } - use std::path::Path; use tempfile::TempDir; @@ -1157,7 +1141,7 @@ mod tests { } #[test] - fn gather_sessions_includes_claude_rows_for_a_project() { + fn gather_artifacts_includes_claude_rows_for_a_project() { let temp = TempDir::new().unwrap(); write_claude_session( &temp.path().join(".claude"), @@ -1167,17 +1151,17 @@ mod tests { ); let bundle = claude_only_bundle(temp.path()); let cwd = Path::new("/test/project"); - let rows = gather_sessions(&bundle, cwd, None, None); + let rows = gather_artifacts(&bundle, cwd, None, None); assert_eq!(rows.len(), 1); - assert_eq!(rows[0].harness, Harness::Claude); + assert_eq!(rows[0].artifact_type, ArtifactType::Claude); assert_eq!(rows[0].session_id, "abc-session-one"); - assert_eq!(rows[0].project.as_deref(), Some("/test/project")); + assert_eq!(rows[0].path.as_deref(), Some("/test/project")); assert!(rows[0].matches_cwd, "cwd should match the project path"); } #[test] - fn gather_sessions_marks_non_matching_project_rows() { + fn gather_artifacts_marks_non_matching_project_rows() { let temp = TempDir::new().unwrap(); write_claude_session( &temp.path().join(".claude"), @@ -1187,22 +1171,22 @@ mod tests { ); let bundle = claude_only_bundle(temp.path()); let cwd = Path::new("/some/other/place"); - let rows = gather_sessions(&bundle, cwd, None, None); + let rows = gather_artifacts(&bundle, cwd, None, None); assert_eq!(rows.len(), 1); assert!(!rows[0].matches_cwd); } #[test] - fn gather_sessions_skips_harness_with_no_home_dir() { + fn gather_artifacts_skips_harness_with_no_home_dir() { // Empty bundle => no rows, no panic. let bundle = HarnessBundle::default(); - let rows = gather_sessions(&bundle, Path::new("/anywhere"), None, None); + let rows = gather_artifacts(&bundle, Path::new("/anywhere"), None, None); assert!(rows.is_empty()); } #[test] - fn gather_sessions_filters_by_harness() { + fn gather_artifacts_filters_by_harness() { let temp = TempDir::new().unwrap(); write_claude_session( &temp.path().join(".claude"), @@ -1212,7 +1196,7 @@ mod tests { ); let bundle = claude_only_bundle(temp.path()); let cwd = Path::new("/test/project"); - let rows = gather_sessions(&bundle, cwd, Some(Harness::Codex), None); + let rows = gather_artifacts(&bundle, cwd, Some(ArtifactType::Codex), None); assert!(rows.is_empty(), "filter to codex must drop claude rows"); } @@ -1239,7 +1223,7 @@ mod tests { } #[test] - fn gather_sessions_includes_codex_rows_with_cwd_match() { + fn gather_artifacts_includes_codex_rows_with_cwd_match() { let temp = TempDir::new().unwrap(); write_codex_session( &temp.path().join(".codex"), @@ -1247,9 +1231,9 @@ mod tests { "/work/proj", ); let bundle = codex_only_bundle(temp.path()); - let rows = gather_sessions(&bundle, Path::new("/work/proj"), None, None); + let rows = gather_artifacts(&bundle, Path::new("/work/proj"), None, None); assert_eq!(rows.len(), 1); - assert_eq!(rows[0].harness, Harness::Codex); + assert_eq!(rows[0].artifact_type, ArtifactType::Codex); assert_eq!(rows[0].cwd.as_deref(), Some("/work/proj")); assert!(rows[0].matches_cwd); } @@ -1281,9 +1265,9 @@ mod tests { let temp = TempDir::new().unwrap(); write_copilot_session(&temp.path().join(".copilot"), "sess-aa", "/work/proj"); let bundle = copilot_only_bundle(temp.path()); - let rows = gather_sessions(&bundle, Path::new("/work/proj"), None, None); + let rows = gather_artifacts(&bundle, Path::new("/work/proj"), None, None); assert_eq!(rows.len(), 1); - assert_eq!(rows[0].harness, Harness::Copilot); + assert_eq!(rows[0].artifact_type, ArtifactType::Copilot); assert_eq!(rows[0].cwd.as_deref(), Some("/work/proj")); assert!(rows[0].matches_cwd); } @@ -1294,12 +1278,17 @@ mod tests { write_copilot_session(&temp.path().join(".copilot"), "sess-aa", "/work/proj"); let bundle = copilot_only_bundle(temp.path()); // Filtering to a different harness drops the copilot row. - let rows = gather_sessions(&bundle, Path::new("/work/proj"), Some(Harness::Codex), None); + let rows = gather_artifacts( + &bundle, + Path::new("/work/proj"), + Some(ArtifactType::Codex), + None, + ); assert!(rows.is_empty()); } #[test] - fn gather_sessions_ranks_cwd_matches_first() { + fn gather_artifacts_ranks_cwd_matches_first() { // Two claude sessions: one in cwd (older), one elsewhere (newer). // Despite the elsewhere row being newer, the cwd-match must come first. let temp = TempDir::new().unwrap(); @@ -1315,7 +1304,7 @@ mod tests { ) .unwrap(); let bundle = claude_only_bundle(temp.path()); - let rows = gather_sessions(&bundle, Path::new("/cwd/project"), None, None); + let rows = gather_artifacts(&bundle, Path::new("/cwd/project"), None, None); assert_eq!(rows.len(), 2); assert_eq!(rows[0].session_id, "in-cwd-session"); @@ -1326,14 +1315,14 @@ mod tests { #[test] #[cfg(unix)] fn paths_match_canonicalizes_through_symlink() { - // `paths_match` is the function that produces `SessionRow.matches_cwd` + // `paths_match` is the function that produces `ArtifactRow.matches_cwd` // (collect_* all delegate to it). Without canonicalization, a user who // navigated to a project via a symlink would see their cwd-row sink // in the picker because the symlink path string ≠ the project path // string. Verify both arguments are canonicalized. // // Note: we test `paths_match` directly rather than going through - // `gather_sessions` because Claude's project-dir slug encoding is + // `gather_artifacts` because Claude's project-dir slug encoding is // lossy (sanitize_project_path: '/', '_', '.' → '-'; unsanitize: only // '-' → '/'). On macOS, tempdir paths contain '.' and end up under // /private/var/..., so the unsanitized slug never round-trips back to @@ -1367,19 +1356,19 @@ mod tests { #[test] fn parse_picker_row_roundtrips_keyed() { - let row = SessionRow { - harness: Harness::Claude, - project: Some("/tmp/proj".to_string()), + let row = ArtifactRow { + artifact_type: ArtifactType::Claude, + path: Some("/tmp/proj".to_string()), cwd: None, session_id: "sess-abc".to_string(), title: "Hello\tworld".to_string(), last_activity: None, - message_count: 3, + message_count: None, matches_cwd: true, }; let line = format_picker_row(&row); let (harness, key, session, title) = parse_picker_row(&line).unwrap(); - assert_eq!(harness, Harness::Claude); + assert_eq!(harness, ArtifactType::Claude); assert_eq!(key, "/tmp/proj"); assert_eq!(session, "sess-abc"); // tab_safe replaces the tab with a space, but the title content @@ -1389,19 +1378,19 @@ mod tests { #[test] fn parse_picker_row_roundtrips_session_keyed() { - let row = SessionRow { - harness: Harness::Codex, - project: None, + let row = ArtifactRow { + artifact_type: ArtifactType::Codex, + path: None, cwd: Some("/work/proj".to_string()), session_id: "0190abcd".to_string(), title: "(no prompt)".to_string(), last_activity: None, - message_count: 0, + message_count: None, matches_cwd: false, }; let line = format_picker_row(&row); let (harness, key, session, title) = parse_picker_row(&line).unwrap(); - assert_eq!(harness, Harness::Codex); + assert_eq!(harness, ArtifactType::Codex); assert_eq!(key, "/work/proj"); // codex has no project; cwd carried as the keyed slot assert_eq!(session, "0190abcd"); assert_eq!(title, "(no prompt)"); @@ -1409,14 +1398,14 @@ mod tests { #[test] fn parse_picker_row_carries_title_with_unicode() { - let row = SessionRow { - harness: Harness::Gemini, - project: Some("/work/proj".to_string()), + let row = ArtifactRow { + artifact_type: ArtifactType::Gemini, + path: Some("/work/proj".to_string()), cwd: None, session_id: "11111111-2222-3333-4444-555555555555".to_string(), title: "Add the share command — finally".to_string(), last_activity: None, - message_count: 42, + message_count: None, matches_cwd: true, }; let line = format_picker_row(&row); diff --git a/crates/path-cli/src/lib.rs b/crates/path-cli/src/lib.rs index 784cd337..d9ed8c8a 100644 --- a/crates/path-cli/src/lib.rs +++ b/crates/path-cli/src/lib.rs @@ -33,6 +33,7 @@ mod query; mod schema; #[cfg(all(not(target_os = "emscripten"), feature = "embedded-picker"))] mod skim_picker; +pub mod sync; mod term; use anyhow::Result; diff --git a/crates/path-cli/src/query/mod.rs b/crates/path-cli/src/query/mod.rs index 6d5d2cfc..ecc002fb 100644 --- a/crates/path-cli/src/query/mod.rs +++ b/crates/path-cli/src/query/mod.rs @@ -30,6 +30,9 @@ pub struct Scope { pub inputs: Vec, /// `--project`: keep only paths whose `base` resolves to this directory. pub project: Option, + /// `--parent-dir`: keep only paths whose `base` lives under this + /// directory (subtree match). + pub parent_dir: Option, /// `--kind`: keep only paths whose `meta.kind` matches this selector. pub kind: Option, } @@ -94,6 +97,7 @@ impl DocSource { fn stream_files(scope: &Scope, emit: &mut dyn FnMut(Val) -> Result<()>) -> Result<()> { let kind_sel = scope.kind.as_deref().map(kinds::parse_kind_selector); let project = scope.project.as_deref().map(canonicalize_or_self); + let parent_dir = scope.parent_dir.as_deref().map(canonicalize_or_self); for src in select_files(scope)? { let graph = match read_source(&src) { @@ -115,6 +119,7 @@ fn stream_files(scope: &Scope, emit: &mut dyn FnMut(Val) -> Result<()>) -> Resul &graph, kind_sel.as_ref(), project.as_deref(), + parent_dir.as_deref(), &mut steps, ); drop(graph); @@ -231,6 +236,7 @@ fn wrap_graph( graph: &Graph, kind_sel: Option<&KindSelector>, project: Option<&FsPath>, + parent_dir: Option<&FsPath>, out: &mut Vec, ) { for entry in &graph.paths { @@ -249,6 +255,11 @@ fn wrap_graph( { continue; } + if let Some(dir) = parent_dir + && !path_matches_parent_dir(path, dir) + { + continue; + } wrap_path(src, path, out); } @@ -307,13 +318,17 @@ fn path_context(path: &Path) -> serde_json::Value { /// Whether a path's `base` resolves to `project` (a canonicalized directory). /// Only `file://` bases can match; VCS bases (`github:…`) never do. fn path_matches_project(path: &Path, project: &FsPath) -> bool { - let Some(base) = &path.path.base else { - return false; - }; - let Some(fs) = base.uri.strip_prefix("file://") else { - return false; - }; - canonicalize_or_self(FsPath::new(fs)) == project + base_fs_path(path).is_some_and(|p| p == project) +} + +fn path_matches_parent_dir(path: &Path, parent_dir: &FsPath) -> bool { + base_fs_path(path).is_some_and(|p| p.starts_with(parent_dir)) +} + +fn base_fs_path(path: &Path) -> Option { + let base = path.path.base.as_ref()?; + let fs = base.uri.strip_prefix("file://")?; + Some(canonicalize_or_self(FsPath::new(fs))) } fn canonicalize_or_self(p: &FsPath) -> PathBuf { @@ -406,12 +421,12 @@ mod tests { let graph = Graph::from_path(forked_path()); let mut out = Vec::new(); let sel = kinds::parse_kind_selector("agent-coding-session/v1.1.0"); - wrap_graph(&doc_src("g"), &graph, Some(&sel), None, &mut out); + wrap_graph(&doc_src("g"), &graph, Some(&sel), None, None, &mut out); assert_eq!(out.len(), 4, "matching kind keeps all steps"); out.clear(); let miss = kinds::parse_kind_selector("agent-coding-session/v2"); - wrap_graph(&doc_src("g"), &graph, Some(&miss), None, &mut out); + wrap_graph(&doc_src("g"), &graph, Some(&miss), None, None, &mut out); assert!(out.is_empty(), "non-matching kind drops the whole path"); } @@ -447,6 +462,7 @@ mod tests { ids: vec![], inputs: vec!["/tmp/some.json".to_string(), "-".to_string()], project: None, + parent_dir: None, kind: None, }; let files = select_files(&scope).unwrap(); diff --git a/crates/path-cli/src/sync.rs b/crates/path-cli/src/sync.rs new file mode 100644 index 00000000..65212dea --- /dev/null +++ b/crates/path-cli/src/sync.rs @@ -0,0 +1,2022 @@ +//! `path p cache sync` — incremental ingestion of artifacts into the +//! document cache — and [`ArtifactType`], the single enum naming the +//! artifact sources the CLI operates over. +//! +//! Sync enumerates artifacts across the requested types (today all six +//! are agent-session providers), compares each against the sync +//! manifest at `$CONFIG_DIR/sync.json`, and derives + caches only what +//! is new or changed. Change detection is stat-level: the fingerprint +//! is the source file's mtime + size (or the database row's updated-at +//! for the SQLite-backed providers), so deciding "nothing changed" +//! never reads session bodies. Artifacts deleted upstream keep both +//! their cache document and their manifest record — the cache is an +//! archive, not a mirror. + +/// The kind of artifact an operation ranges over. One enum, used +/// everywhere a command names artifact sources (`p cache sync` types, +/// `share`/`resume` `--harness`, import cache-id prefixes); `name()` +/// doubles as the manifest key and cache-id prefix. Git artifacts are +/// recorded in the manifest when imported but are not *discoverable* — +/// there is no machine-wide registry of repos to enumerate — so sync +/// never re-derives them. Github and pathbase are absent on purpose: +/// they are remote services, not local artifact sources. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, clap::ValueEnum)] +#[value(rename_all = "lower")] +pub enum ArtifactType { + Claude, + Gemini, + Codex, + Opencode, + Cursor, + Pi, + Copilot, + Git, +} + +impl ArtifactType { + /// Every artifact type, in presentation order. + pub(crate) const ALL: [ArtifactType; 8] = [ + ArtifactType::Claude, + ArtifactType::Gemini, + ArtifactType::Codex, + ArtifactType::Opencode, + ArtifactType::Cursor, + ArtifactType::Pi, + ArtifactType::Copilot, + ArtifactType::Git, + ]; + + pub(crate) fn name(&self) -> &'static str { + match self { + ArtifactType::Claude => "claude", + ArtifactType::Gemini => "gemini", + ArtifactType::Codex => "codex", + ArtifactType::Opencode => "opencode", + ArtifactType::Cursor => "cursor", + ArtifactType::Pi => "pi", + ArtifactType::Copilot => "copilot", + ArtifactType::Git => "git", + } + } + + /// Padded so all symbols line up in the fzf column. Longest is + /// "opencode" (8); pad shorter names to match. + pub(crate) fn symbol(&self) -> &'static str { + match self { + ArtifactType::Claude => "claude ", + ArtifactType::Gemini => "gemini ", + ArtifactType::Codex => "codex ", + ArtifactType::Opencode => "opencode", + ArtifactType::Cursor => "cursor ", + ArtifactType::Pi => "pi ", + ArtifactType::Copilot => "copilot ", + ArtifactType::Git => "git ", + } + } + + /// True when the underlying provider keys artifacts by a filesystem + /// path (the project directory). claude/gemini/pi: true. + /// codex/opencode/cursor: false (sessions store cwd per-row, not as + /// a directory key — cursor stores it as + /// `workspaceIdentifier.uri.fsPath` on each composer). + pub(crate) fn path_keyed(&self) -> bool { + matches!( + self, + ArtifactType::Claude | ArtifactType::Gemini | ArtifactType::Pi | ArtifactType::Git + ) + } + + pub(crate) fn parse(s: &str) -> Option { + ::from_str(s, false).ok() + } +} + +/// An artifact's identity plus the stat-level fingerprint of its +/// source. Sync enumerates these for change detection (producing one +/// never parses session bodies), and `p import`/`share` fill one as +/// the provenance of each derived document so the write can be +/// recorded in the manifest. +#[derive(Debug, Clone)] +pub(crate) struct ArtifactStub { + pub(crate) artifact_type: ArtifactType, + pub(crate) id: String, + /// Filesystem path the artifact is keyed under, for path-keyed + /// providers (the project directory; the repo for git). + pub(crate) path: Option, + /// Source mtime (file providers) or updated-at (DB providers). + pub(crate) modified: Option>, + /// Source file size; `None` for DB-backed providers. + pub(crate) size: Option, +} + +/// (mtime, size) of a file, both `None` when the stat fails. +pub(crate) fn stat_stamp( + path: &std::path::Path, +) -> (Option>, Option) { + match std::fs::metadata(path) { + Ok(md) => ( + md.modified() + .ok() + .map(chrono::DateTime::::from), + Some(md.len()), + ), + Err(_) => (None, None), + } +} + +/// Stat-level fingerprint of a whole claude session chain: max mtime +/// across the chain's segment files plus the sum of their sizes. Claude +/// Code rotates to a new file on continuation (plan-mode exit, resume, +/// fork) while the chain keeps the *first* segment's id — appends land +/// in the newest segment, so statting the head file alone would freeze +/// the fingerprint at the first rotation and sync would never see the +/// later turns. The chain here is exactly the set of files +/// `read_conversation` merges, so the fingerprint and the derived doc +/// move in lockstep. The chain index is already built (and cached) by +/// the `list_conversations` call every caller makes first. +pub(crate) fn claude_chain_stamp( + mgr: &toolpath_claude::ClaudeConvo, + project: &str, + session: &str, +) -> (Option>, Option) { + let segments = match mgr.session_chain(project, session) { + Ok(segments) if !segments.is_empty() => segments, + _ => vec![session.to_string()], + }; + let mut modified: Option> = None; + let mut size: Option = None; + for segment in &segments { + let Ok(file) = mgr.resolver().conversation_file(project, segment) else { + continue; + }; + let (m, s) = stat_stamp(&file); + if let Some(m) = m { + modified = Some(modified.map_or(m, |cur| cur.max(m))); + } + if let Some(s) = s { + size = Some(size.unwrap_or(0) + s); + } + } + (modified, size) +} + +/// The trailing UUID of a codex rollout filename stem +/// (`rollout--`), or the whole stem when it doesn't end +/// in one. Codex's `read_session` resolves either form. +pub(crate) fn codex_artifact_id(stem: &str) -> &str { + stem.len() + .checked_sub(36) + .and_then(|at| stem.get(at..)) + .filter(|tail| tail.bytes().filter(|&b| b == b'-').count() == 4) + .unwrap_or(stem) +} + +#[cfg(not(target_os = "emscripten"))] +pub(crate) use engine::*; + +#[cfg(not(target_os = "emscripten"))] +mod engine { + use anyhow::{Context, Result, anyhow}; + use chrono::{DateTime, Utc}; + use serde::{Deserialize, Serialize}; + use std::collections::BTreeMap; + use std::path::{Path, PathBuf}; + + use super::{ArtifactStub, ArtifactType, codex_artifact_id, stat_stamp}; + use crate::cmd_cache::write_cached; + use crate::cmd_import::DerivedDoc; + use crate::cmd_share::{ + HarnessBundle, is_not_found_claude, is_not_found_codex, is_not_found_cursor, + is_not_found_gemini, is_not_found_opencode, is_not_found_pi, + }; + use crate::config::config_dir; + + const MANIFEST_FILE: &str = "sync.json"; + + /// What the manifest remembers about one known artifact. A record + /// with a `cache_id` is materialized in the cache; one without is + /// merely known — seen during an out-of-scope sync, or evicted by + /// `p cache rm`. + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] + pub(crate) struct SyncRecord { + /// Filesystem path the artifact is keyed under: the project + /// directory for path-keyed providers, the recorded cwd / + /// workspace for the others (when known). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) path: Option, + /// Cache entry the derived document was written to; `None` + /// when the artifact is known but not cached. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) cache_id: Option, + /// Fingerprint: source mtime (file providers) or updated-at + /// (DB providers) at sync time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) modified: Option>, + /// Fingerprint: source file size at sync time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) size: Option, + pub(crate) synced_at: DateTime, + } + + /// The sync manifest: artifact type (`"claude"`, `"codex"`, …) → + /// artifact id → record. Kept as `BTreeMap`s so the JSON on disk is + /// stably ordered. + pub(crate) type Manifest = BTreeMap>; + + /// Per-type tally of what one sync run did. + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] + pub(crate) struct SyncOutcome { + pub(crate) new: usize, + pub(crate) updated: usize, + pub(crate) unchanged: usize, + pub(crate) failed: usize, + /// Artifacts needing work that a `--parent-dir` constraint excluded. + pub(crate) out_of_scope: usize, + } + + impl SyncOutcome { + fn total(&self) -> usize { + self.new + self.updated + self.unchanged + self.failed + self.out_of_scope + } + } + + pub(crate) fn run(types: Vec, parent_dir: Option) -> Result<()> { + let explicit = !types.is_empty(); + let types = resolve_types(&types); + let bundle = HarnessBundle::from_environment(); + let outcomes = sync_bundle(&bundle, &types, parent_dir.as_deref())?; + eprint!("{}", render_summary(&outcomes, explicit)); + Ok(()) + } + + /// Explicit args → dedup'd type list; no args → every type. + fn resolve_types(args: &[ArtifactType]) -> Vec { + if args.is_empty() { + return ArtifactType::ALL.to_vec(); + } + let mut out: Vec = Vec::with_capacity(args.len()); + for &t in args { + if !out.contains(&t) { + out.push(t); + } + } + out + } + + /// Sync the given artifact types from `bundle` into the cache, + /// newest artifacts first. The manifest is checkpointed every few + /// writes (see [`CHECKPOINT_EVERY`]), so an interrupted run keeps + /// nearly everything it derived. Reads come from a point-in-time + /// snapshot; each checkpoint merges only the records this run + /// wrote, under the manifest lock, so concurrent invocations + /// (query auto-syncs, imports) union their records instead of + /// clobbering each other. + pub(crate) fn sync_bundle( + bundle: &HarnessBundle, + types: &[ArtifactType], + parent_dir: Option<&Path>, + ) -> Result> { + let manifest = load_manifest()?; + let mut out = Vec::with_capacity(types.len()); + for &artifact_type in types { + let stubs = enumerate_stubs(bundle, artifact_type, parent_dir); + let records = manifest + .get(artifact_type.name()) + .cloned() + .unwrap_or_default(); + let outcome = sync_stubs(bundle, &stubs, &records, parent_dir)?; + out.push((artifact_type, outcome)); + } + Ok(out) + } + + /// How many manifest writes accumulate before a mid-run checkpoint. + /// Small enough that an interrupted first sync loses at most a few + /// records (the cache docs themselves survive either way); large + /// enough that manifest serialization stays noise against the + /// derives it punctuates. + const CHECKPOINT_EVERY: usize = 10; + + /// The stat gate: a materialized record whose real stamps match the + /// stub needs nothing — no read, no scope check. All-`None` stamps + /// mean freshness is unknowable; only a real stamp can vouch + /// (mirrors `record_is_current`). + fn is_unchanged(rec: Option<&SyncRecord>, stub: &ArtifactStub) -> bool { + rec.is_some_and(|rec| { + (rec.modified.is_some() || rec.size.is_some()) + && rec.modified == stub.modified + && rec.size == stub.size + && rec + .cache_id + .as_deref() + .is_some_and(|id| crate::cmd_cache::cache_path(id).is_ok_and(|p| p.exists())) + }) + } + + /// Stubs newest-first (unstamped last), so an interrupted run has + /// spent its time on the sessions the user most likely wants. + fn newest_first(stubs: &[ArtifactStub]) -> Vec<&ArtifactStub> { + let mut order: Vec<&ArtifactStub> = stubs.iter().collect(); + order.sort_by(|a, b| b.modified.cmp(&a.modified)); + order + } + + /// Merge staged records into the manifest under the lock and clear + /// the stage. + fn flush_writes( + pending: &mut BTreeMap<&'static str, BTreeMap>, + ) -> Result<()> { + if pending.is_empty() { + return Ok(()); + } + let batch = std::mem::take(pending); + update_manifest(move |manifest| { + for (name, records) in batch { + manifest + .entry(name.to_string()) + .or_default() + .extend(records); + } + }) + } + + /// Sync one type's stubs against a snapshot of its manifest records, + /// newest first. Records are checkpointed to the manifest every + /// [`CHECKPOINT_EVERY`] writes (and once more at the end), so an + /// interrupted run keeps nearly everything it derived. Derivation + /// failures are warned and tallied, not fatal; cache-write failures + /// (disk, permissions) abort. + fn sync_stubs( + bundle: &HarnessBundle, + stubs: &[ArtifactStub], + records: &BTreeMap, + parent_dir: Option<&Path>, + ) -> Result { + let mut outcome = SyncOutcome::default(); + // Evaluate the stat gate once per stub: the pass feeds both the + // progress denominator and the loop's skip decision. + let order: Vec<(&ArtifactStub, bool)> = newest_first(stubs) + .into_iter() + .map(|stub| (stub, is_unchanged(records.get(&stub.id), stub))) + .collect(); + let pending_total = order.iter().filter(|(_, unchanged)| !unchanged).count(); + let mut progress = Progress::start( + stubs + .first() + .map(|s| s.artifact_type.symbol()) + .unwrap_or(""), + pending_total, + ); + let mut writes: BTreeMap<&'static str, BTreeMap> = BTreeMap::new(); + let mut unflushed = 0usize; + for (stub, unchanged) in order { + if unchanged { + outcome.unchanged += 1; + continue; + } + let existing = records.get(&stub.id); + let is_new = existing.is_none(); + let stage = |writes: &mut BTreeMap<&'static str, BTreeMap>, + record: SyncRecord| { + writes + .entry(stub.artifact_type.name()) + .or_default() + .insert(stub.id.clone(), record); + }; + // Scope gate: only artifacts that would cost a derive get the + // constraint check (with a bounded peek for codex, memoized in + // the record so it happens at most once per artifact). + if let Some(parent_dir) = parent_dir { + let dir = stub + .path + .clone() + .or_else(|| existing.and_then(|r| r.path.clone())) + .or_else(|| peek_stub_dir(bundle, stub)); + let in_scope = dir.as_deref().is_some_and(|d| match stub.artifact_type { + // Claude and pi paths came from lossy dir encodings; + // compare in their encoded spaces, like the + // enumeration pruning does. + ArtifactType::Claude => claude_project_in_scope(d, parent_dir), + ArtifactType::Pi => pi_project_in_scope(d, parent_dir), + _ => dir_in_scope(d, parent_dir), + }); + if !in_scope { + outcome.out_of_scope += 1; + // Remember what we learned — but never touch the stamp + // of a materialized record, or its staleness would be + // masked from the next in-scope sync. + if existing.is_none_or(|r| r.cache_id.is_none()) { + stage( + &mut writes, + SyncRecord { + path: dir, + cache_id: None, + modified: stub.modified, + size: stub.size, + synced_at: Utc::now(), + }, + ); + unflushed += 1; + } + progress.tick(); + if unflushed >= CHECKPOINT_EVERY { + flush_writes(&mut writes)?; + unflushed = 0; + } + continue; + } + } + // A stub without a path must not erase one a previous pass + // peeked and memoized (codex/copilot cwd). + let memoized_path = stub + .path + .clone() + .or_else(|| existing.and_then(|r| r.path.clone())); + match derive_stub(bundle, stub) { + Ok(derived) => { + // force: sync owns refresh semantics — a re-sync or a + // prior manual `p import` of the same session must not + // error on the existing cache entry. + write_cached(&derived.cache_id, &derived.doc, true)?; + stage( + &mut writes, + SyncRecord { + path: memoized_path, + cache_id: Some(derived.cache_id), + // The stamp was taken before the derive read the + // source, so a write racing the derive re-syncs + // next run instead of going unnoticed. + modified: stub.modified, + size: stub.size, + synced_at: Utc::now(), + }, + ); + unflushed += 1; + if is_new { + outcome.new += 1; + } else { + outcome.updated += 1; + } + } + Err(e) => { + progress.interrupt(); + eprintln!( + "warning: sync {}: {}: {e}", + stub.artifact_type.name(), + stub.id + ); + outcome.failed += 1; + } + } + progress.tick(); + if unflushed >= CHECKPOINT_EVERY { + flush_writes(&mut writes)?; + unflushed = 0; + } + } + flush_writes(&mut writes)?; + progress.interrupt(); + Ok(outcome) + } + + /// Live sync progress on stderr: a `\r`-updating ` done/total` + /// line on a terminal, a plain line every 25 items otherwise. Only + /// artifacts needing work count toward the total — a no-op sync + /// draws nothing. + struct Progress { + label: &'static str, + total: usize, + done: usize, + tty: bool, + } + + impl Progress { + fn start(label: &'static str, total: usize) -> Self { + use std::io::IsTerminal; + let progress = Self { + label, + total, + done: 0, + tty: std::io::stderr().is_terminal(), + }; + progress.draw(); + progress + } + + fn line(&self) -> String { + format!("{} {}/{}", self.label, self.done, self.total) + } + + fn draw(&self) { + if self.total > 0 && self.tty { + eprint!("\r{}", self.line()); + } + } + + fn tick(&mut self) { + if self.total == 0 { + return; + } + self.done += 1; + if self.tty { + self.draw(); + } else if self.done.is_multiple_of(25) { + eprintln!("{}", self.line()); + } + } + + /// Clear the live line so a warning or summary prints clean; + /// the next `tick` redraws in full. + fn interrupt(&self) { + if self.total > 0 && self.tty { + eprint!("\r\x1b[2K"); + } + } + } + + /// Derive one artifact through the same manager it was enumerated + /// from, so listing and derivation always agree on provider roots. + fn derive_stub(bundle: &HarnessBundle, stub: &ArtifactStub) -> Result { + use crate::cmd_import as imp; + let path = || { + stub.path + .as_deref() + .ok_or_else(|| anyhow!("artifact {} has no path", stub.id)) + }; + match stub.artifact_type { + ArtifactType::Claude => { + imp::derive_claude_session_with(mgr(&bundle.claude)?, path()?, &stub.id) + } + ArtifactType::Gemini => { + imp::derive_gemini_session_with(mgr(&bundle.gemini)?, path()?, &stub.id) + } + ArtifactType::Pi => imp::derive_pi_session_with(mgr(&bundle.pi)?, path()?, &stub.id), + ArtifactType::Codex => imp::derive_codex_session_with(mgr(&bundle.codex)?, &stub.id), + ArtifactType::Opencode => { + imp::derive_opencode_session_with(mgr(&bundle.opencode)?, &stub.id, false) + } + ArtifactType::Cursor => imp::derive_cursor_session_with(mgr(&bundle.cursor)?, &stub.id), + ArtifactType::Copilot => { + imp::derive_copilot_session_with(mgr(&bundle.copilot)?, &stub.id) + } + ArtifactType::Git => Err(anyhow!( + "git artifacts are recorded by `p import`, not re-derived by sync" + )), + } + } + + fn mgr(slot: &Option) -> Result<&T> { + slot.as_ref() + .ok_or_else(|| anyhow!("provider not available")) + } + + fn canonicalize_or_self(p: &Path) -> PathBuf { + std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()) + } + + /// Subtree check for a real filesystem path. Canonicalizes both + /// sides, but also accepts the raw parent so a not-yet-resolvable + /// constraint (or an unresolvable dir) still matches literally. + fn dir_in_scope(dir: &str, parent_dir: &Path) -> bool { + let d = canonicalize_or_self(Path::new(dir)); + d.starts_with(canonicalize_or_self(parent_dir)) || d.starts_with(parent_dir) + } + + /// Pi's session-dir names encode '/' as '-', and decoding restores + /// every '-' to '/', so a decoded project string is wrong for any + /// real path containing a hyphen. Compare in the encoded space, + /// where '/' boundaries are '-'. + fn pi_project_in_scope(project: &str, parent_dir: &Path) -> bool { + fn enc(s: &str) -> String { + s.replace('/', "-") + } + let p = enc(project); + [ + enc(&parent_dir.to_string_lossy()), + enc(&canonicalize_or_self(parent_dir).to_string_lossy()), + ] + .iter() + .any(|parent| p == *parent || p.starts_with(&format!("{parent}-"))) + } + + /// Claude's project-dir slugs are lossy — '/', '_', and '.' all + /// became '-', and un-sanitizing only restores '/'. Comparing real + /// paths therefore misfilters any project containing '.' or '_'; + /// compare in slug space instead, where '/' boundaries are '-'. + fn claude_project_in_scope(project: &str, parent_dir: &Path) -> bool { + fn slug(s: &str) -> String { + s.replace(['/', '_', '.'], "-") + } + let p = slug(project); + [ + slug(&parent_dir.to_string_lossy()), + slug(&canonicalize_or_self(parent_dir).to_string_lossy()), + ] + .iter() + .any(|parent| p == *parent || p.starts_with(&format!("{parent}-"))) + } + + /// Where a stub's artifact lives, for providers whose cheap listing + /// doesn't carry it. Codex is the only case: the rollout's first + /// line is `session_meta` with the session cwd — one bounded read, + /// and the result is memoized into the manifest record afterwards. + fn peek_stub_dir(bundle: &HarnessBundle, stub: &ArtifactStub) -> Option { + let file = match stub.artifact_type { + ArtifactType::Codex => bundle + .codex + .as_ref()? + .resolver() + .find_rollout_file(&stub.id) + .ok()?, + ArtifactType::Copilot => bundle + .copilot + .as_ref()? + .resolver() + .events_file(&stub.id) + .ok()?, + _ => return None, + }; + use std::io::{BufRead, BufReader}; + let f = std::fs::File::open(file).ok()?; + match stub.artifact_type { + // Codex: the first line is `session_meta`; its payload + // carries cwd directly. + ArtifactType::Codex => { + let mut line = String::new(); + BufReader::new(f).read_line(&mut line).ok()?; + let v: serde_json::Value = serde_json::from_str(&line).ok()?; + Some(v.get("payload")?.get("cwd")?.as_str()?.to_string()) + } + // Copilot: `session.start` is usually first but the format + // tolerates it appearing later, and older CLIs store cwd at + // the top of the payload instead of under `context` — scan a + // bounded number of lines and accept both shapes, mirroring + // toolpath-copilot's own tolerance. + ArtifactType::Copilot => BufReader::new(f).lines().take(10).find_map(|line| { + let v: serde_json::Value = serde_json::from_str(&line.ok()?).ok()?; + ["data", "payload"].iter().find_map(|env| { + let payload = v.get(env)?; + [payload.get("context").unwrap_or(payload), payload] + .iter() + .find_map(|scope| { + ["cwd", "workingDirectory", "working_dir"] + .iter() + .find_map(|k| Some(scope.get(k)?.as_str()?.to_string())) + }) + }) + }), + _ => None, + } + } + + // ── stat-level enumeration ───────────────────────────────────────── + + /// Enumerate one type's artifacts with stat-level fingerprints. + /// Providers that aren't installed produce no stubs; other listing + /// errors warn and skip so one broken provider can't block the rest. + fn enumerate_stubs( + bundle: &HarnessBundle, + t: ArtifactType, + parent_dir: Option<&Path>, + ) -> Vec { + let mut out = Vec::new(); + match t { + ArtifactType::Claude => { + if let Some(mgr) = &bundle.claude { + stubs_claude(mgr, parent_dir, &mut out); + } + } + ArtifactType::Gemini => { + if let Some(mgr) = &bundle.gemini { + stubs_gemini(mgr, parent_dir, &mut out); + } + } + ArtifactType::Codex => { + if let Some(mgr) = &bundle.codex { + stubs_codex(mgr, &mut out); + } + } + ArtifactType::Opencode => { + if let Some(mgr) = &bundle.opencode { + stubs_opencode(mgr, &mut out); + } + } + ArtifactType::Cursor => { + if let Some(mgr) = &bundle.cursor { + stubs_cursor(mgr, &mut out); + } + } + ArtifactType::Pi => { + if let Some(mgr) = &bundle.pi { + stubs_pi(mgr, parent_dir, &mut out); + } + } + ArtifactType::Copilot => { + if let Some(mgr) = &bundle.copilot { + stubs_copilot(mgr, &mut out); + } + } + // Recorded via `p import`, never discovered: there is no + // machine-wide registry of repos to walk. + ArtifactType::Git => {} + } + out + } + + /// Chain heads via `list_conversations` (bounded first-lines peek + /// per file, no full parse). The head is the chain's *oldest* + /// segment and its id is rotation-stable; the fingerprint covers + /// the whole chain (see `claude_chain_stamp`) because appends land + /// in the newest segment, not the head file. + fn stubs_claude( + mgr: &toolpath_claude::ClaudeConvo, + parent_dir: Option<&Path>, + out: &mut Vec, + ) { + let projects = match mgr.list_projects() { + Ok(ps) => ps, + Err(e) if is_not_found_claude(&e) => return, + Err(e) => { + eprintln!("warning: claude enumeration failed: {e}"); + return; + } + }; + for project in projects { + if let Some(parent_dir) = parent_dir + && !claude_project_in_scope(&project, parent_dir) + { + continue; + } + let heads = match mgr.list_conversations(&project) { + Ok(h) => h, + Err(e) => { + eprintln!("warning: claude project {project} failed: {e}"); + continue; + } + }; + for head in heads { + let (modified, size) = super::claude_chain_stamp(mgr, &project, &head); + out.push(ArtifactStub { + artifact_type: ArtifactType::Claude, + id: head, + path: Some(project.clone()), + modified, + size, + }); + } + } + } + + /// Session entries via a bounded identity peek (`toolpath-gemini` + /// reads at most the first 4 KiB of a main file); the fingerprint + /// stats the main file (or the orphan sub-agent directory). + fn stubs_gemini( + mgr: &toolpath_gemini::GeminiConvo, + parent_dir: Option<&Path>, + out: &mut Vec, + ) { + let projects = match mgr.list_projects() { + Ok(ps) => ps, + Err(e) if is_not_found_gemini(&e) => return, + Err(e) => { + eprintln!("warning: gemini enumeration failed: {e}"); + return; + } + }; + for project in projects { + if let Some(parent_dir) = parent_dir + && !dir_in_scope(&project, parent_dir) + { + continue; + } + let entries = match mgr.resolver().list_session_entries(&project) { + Ok(entries) => entries, + Err(e) => { + eprintln!("warning: gemini project {project} failed: {e}"); + continue; + } + }; + for entry in entries { + let (modified, size) = stat_stamp(&entry.path); + out.push(ArtifactStub { + artifact_type: ArtifactType::Gemini, + id: entry.session_uuid.unwrap_or(entry.id), + path: Some(project.clone()), + modified, + size, + }); + } + } + } + + /// Session-state directories, stat-only: each session is a + /// `/events.jsonl` under `session-state/` (or its legacy + /// sibling); the directory name is the id and the events file is + /// the fingerprint target. + fn stubs_copilot(mgr: &toolpath_copilot::CopilotConvo, out: &mut Vec) { + let mut seen = std::collections::HashSet::new(); + let dirs = [ + mgr.resolver().session_state_dir(), + mgr.resolver().legacy_session_state_dir(), + ]; + for dir in dirs.into_iter().flatten() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let Some(id) = entry.file_name().to_str().map(String::from) else { + continue; + }; + let events = entry.path().join("events.jsonl"); + if !events.exists() || !seen.insert(id.clone()) { + continue; + } + let (modified, size) = stat_stamp(&events); + out.push(ArtifactStub { + artifact_type: ArtifactType::Copilot, + id, + path: None, + modified, + size, + }); + } + } + } + + /// Rollout files, stat-only. The artifact id is the trailing UUID of + /// the filename stem (`rollout--`); `read_session` + /// accepts either the UUID or the full stem, so the fallback is safe. + fn stubs_codex(mgr: &toolpath_codex::CodexConvo, out: &mut Vec) { + let files = match mgr.io().list_rollout_files() { + Ok(f) => f, + Err(e) if is_not_found_codex(&e) => return, + Err(e) => { + eprintln!("warning: codex enumeration failed: {e}"); + return; + } + }; + for file in files { + let Some(stem) = file.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + let id = codex_artifact_id(stem).to_string(); + let (modified, size) = stat_stamp(&file); + out.push(ArtifactStub { + artifact_type: ArtifactType::Codex, + id, + path: None, + modified, + size, + }); + } + } + + /// One header-only `SELECT` — `time_updated` is the fingerprint; no + /// message bodies are loaded. + fn stubs_opencode(mgr: &toolpath_opencode::OpencodeConvo, out: &mut Vec) { + let sessions = match mgr.io().list_sessions(None) { + Ok(s) => s, + Err(e) if is_not_found_opencode(&e) => return, + Err(e) => { + eprintln!("warning: opencode enumeration failed: {e}"); + return; + } + }; + for s in sessions { + out.push(ArtifactStub { + artifact_type: ArtifactType::Opencode, + modified: s.last_activity(), + path: Some(s.directory.to_string_lossy().into_owned()), + id: s.id, + size: None, + }); + } + } + + /// Composer headers (one `SELECT` plus a per-composer bubble-count + /// check) — `lastUpdatedAt` is the fingerprint. Bubble-less drafts + /// are skipped; unlike `share`, composers without a workspace are + /// included, since sync doesn't need to rank them by project. + fn stubs_cursor(mgr: &toolpath_cursor::CursorConvo, out: &mut Vec) { + let listings = match mgr.io().list_composers() { + Ok(l) => l, + Err(e) if is_not_found_cursor(&e) => return, + Err(e) => { + eprintln!("warning: cursor enumeration failed: {e}"); + return; + } + }; + for l in listings.into_iter().filter(|l| l.has_bubbles) { + out.push(ArtifactStub { + artifact_type: ArtifactType::Cursor, + modified: l.head.last_updated_at_utc(), + path: l + .head + .workspace_path() + .map(|p| p.to_string_lossy().into_owned()), + id: l.head.composer_id, + size: None, + }); + } + } + + /// Session files stat-only; the id comes from a one-line header + /// peek, falling back to the filename stem's `_` + /// shape — the same resolution `read_session` accepts. + fn stubs_pi( + mgr: &toolpath_pi::PiConvo, + parent_dir: Option<&Path>, + out: &mut Vec, + ) { + let projects = match mgr.list_projects() { + Ok(ps) => ps, + Err(e) if is_not_found_pi(&e) => return, + Err(e) => { + eprintln!("warning: pi enumeration failed: {e}"); + return; + } + }; + for project in projects { + if let Some(parent_dir) = parent_dir + && !pi_project_in_scope(&project, parent_dir) + { + continue; + } + let files = match toolpath_pi::reader::list_session_files(mgr.resolver(), &project) { + Ok(f) => f, + Err(e) => { + eprintln!("warning: pi project {project} failed: {e}"); + continue; + } + }; + for file in files { + let header_id = toolpath_pi::reader::peek_header(&file) + .ok() + .map(|h| h.id) + .filter(|id| !id.is_empty()); + let stem_id = file + .file_stem() + .and_then(|s| s.to_str()) + .and_then(|s| s.split_once('_')) + .map(|(_, rest)| rest.to_string()); + let Some(id) = header_id.or(stem_id) else { + continue; + }; + let (modified, size) = stat_stamp(&file); + out.push(ArtifactStub { + artifact_type: ArtifactType::Pi, + id, + path: Some(project.clone()), + modified, + size, + }); + } + } + } + + /// One stderr line per artifact type. Types the user didn't name + /// are shown only when they had artifacts, so a default run doesn't + /// list every uninstalled provider. + fn render_summary(outcomes: &[(ArtifactType, SyncOutcome)], explicit: bool) -> String { + let mut s = String::new(); + for (artifact_type, o) in outcomes { + if o.total() == 0 && !explicit { + continue; + } + s.push_str(&format!( + "{} {} new, {} updated, {} unchanged", + artifact_type.symbol(), + o.new, + o.updated, + o.unchanged + )); + if o.failed > 0 { + s.push_str(&format!(", {} failed", o.failed)); + } + if o.out_of_scope > 0 { + s.push_str(&format!(", {} out of scope", o.out_of_scope)); + } + s.push('\n'); + } + if s.is_empty() { + s.push_str("nothing to sync\n"); + } + s + } + + /// Record an externally-derived cache write (`p import`, `share`) in + /// the manifest, so sync doesn't re-derive what was just written. + pub(crate) fn record_stub(stub: &ArtifactStub, cache_id: &str) -> Result<()> { + update_manifest(|manifest| { + manifest + .entry(stub.artifact_type.name().to_string()) + .or_default() + .insert( + stub.id.clone(), + SyncRecord { + path: stub.path.clone(), + cache_id: Some(cache_id.to_string()), + modified: stub.modified, + size: stub.size, + synced_at: Utc::now(), + }, + ); + }) + } + + /// Whether the manifest already records exactly this artifact state + /// under exactly this cache entry, with the doc present — i.e. a + /// write would reproduce what's already there. + pub(crate) fn record_is_current(stub: &ArtifactStub, cache_id: &str) -> bool { + let Ok(manifest) = load_manifest() else { + return false; + }; + manifest + .get(stub.artifact_type.name()) + .and_then(|records| records.get(&stub.id)) + .is_some_and(|rec| { + rec.cache_id.as_deref() == Some(cache_id) + // None stamps mean freshness is unknowable (git); only + // a real, matching stamp can vouch for the cache entry. + && (rec.modified.is_some() || rec.size.is_some()) + && rec.modified == stub.modified + && rec.size == stub.size + && crate::cmd_cache::cache_path(cache_id).is_ok_and(|p| p.exists()) + }) + } + + /// The cache entry for an artifact, when the manifest says it is + /// materialized and a fresh stat shows its source unchanged since — + /// i.e. re-deriving would reproduce the cached doc byte-for-byte. + /// Used by `share` to upload straight from the cache. The stat + /// targets one artifact directly — no enumeration of its siblings. + pub(crate) fn fresh_cache_id( + bundle: &HarnessBundle, + artifact_type: ArtifactType, + project: Option<&str>, + id: &str, + ) -> Option { + let manifest = load_manifest().ok()?; + let rec = manifest.get(artifact_type.name())?.get(id)?; + let cache_id = rec.cache_id.clone()?; + let (modified, size) = stamp_one(bundle, artifact_type, project, id)?; + // None stamps mean freshness is unknowable; only a real, + // matching stamp can vouch for the cache entry. + ((rec.modified.is_some() || rec.size.is_some()) + && rec.modified == modified + && rec.size == size + && crate::cmd_cache::cache_path(&cache_id).is_ok_and(|p| p.exists())) + .then_some(cache_id) + } + + /// Stat-level fingerprint for a single artifact, resolved directly. + /// Uses the same stat targets as `enumerate_stubs` and the + /// `derive_*_session_with` provenance stamps, so the result compares + /// against manifest records. `project` is required for the + /// path-keyed providers (claude/gemini/pi). + fn stamp_one( + bundle: &HarnessBundle, + artifact_type: ArtifactType, + project: Option<&str>, + id: &str, + ) -> Option<(Option>, Option)> { + match artifact_type { + ArtifactType::Claude => Some(super::claude_chain_stamp( + bundle.claude.as_ref()?, + project?, + id, + )), + ArtifactType::Gemini => { + let entries = bundle + .gemini + .as_ref()? + .resolver() + .list_session_entries(project?) + .ok()?; + let entry = entries + .into_iter() + .find(|e| e.id == id || e.session_uuid.as_deref() == Some(id))?; + Some(stat_stamp(&entry.path)) + } + ArtifactType::Pi => { + let mgr = bundle.pi.as_ref()?; + let files = + toolpath_pi::reader::list_session_files(mgr.resolver(), project?).ok()?; + let file = files.into_iter().find(|f| { + toolpath_pi::reader::peek_header(f).is_ok_and(|h| h.id == id) + || f.file_stem() + .and_then(|stem| stem.to_str()) + .and_then(|stem| stem.split_once('_')) + .is_some_and(|(_, rest)| rest == id) + })?; + Some(stat_stamp(&file)) + } + ArtifactType::Codex => { + let file = bundle + .codex + .as_ref()? + .resolver() + .find_rollout_file(id) + .ok()?; + Some(stat_stamp(&file)) + } + ArtifactType::Copilot => { + let file = bundle.copilot.as_ref()?.resolver().events_file(id).ok()?; + Some(stat_stamp(&file)) + } + ArtifactType::Opencode => { + let sessions = bundle.opencode.as_ref()?.io().list_sessions(None).ok()?; + let session = sessions.into_iter().find(|s| s.id == id)?; + Some((session.last_activity(), None)) + } + ArtifactType::Cursor => { + let headers = bundle.cursor.as_ref()?.io().read_composer_headers().ok()?; + let composer = headers + .all_composers + .into_iter() + .find(|c| c.composer_id == id)?; + Some((composer.last_updated_at_utc(), None)) + } + ArtifactType::Git => None, + } + } + + /// `p cache rm` eviction: the doc is gone, so any record pointing + /// at it downgrades to known-but-uncached (the artifact itself is + /// still real; the next in-scope sync re-materializes it). + pub(crate) fn evict_cache_id(cache_id: &str) -> Result<()> { + update_manifest(|manifest| { + for records in manifest.values_mut() { + for rec in records.values_mut() { + if rec.cache_id.as_deref() == Some(cache_id) { + rec.cache_id = None; + } + } + } + }) + } + + // ── manifest IO ──────────────────────────────────────────────────── + + fn manifest_path() -> Result { + Ok(config_dir()?.join(MANIFEST_FILE)) + } + + /// Take the exclusive advisory lock serializing manifest writers + /// across processes (query auto-syncs and imports can run + /// concurrently). A sibling lock file — never renamed, unlike the + /// manifest itself — held until the returned handle drops. + fn lock_manifest() -> Result { + let path = manifest_path()?; + let dir = path.parent().expect("manifest path has a parent"); + std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; + let lock_path = dir.join(format!("{MANIFEST_FILE}.lock")); + let file = std::fs::File::create(&lock_path) + .with_context(|| format!("create {}", lock_path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600)); + } + file.lock() + .with_context(|| format!("lock {}", lock_path.display()))?; + Ok(file) + } + + /// One locked read-modify-write cycle against the manifest. Every + /// writer goes through here, so concurrent invocations merge their + /// records instead of clobbering each other's. + fn update_manifest(mutate: impl FnOnce(&mut Manifest)) -> Result<()> { + let _lock = lock_manifest()?; + let mut manifest = load_manifest()?; + mutate(&mut manifest); + save_manifest(&manifest) + } + + pub(crate) fn load_manifest() -> Result { + let path = manifest_path()?; + let json = match std::fs::read_to_string(&path) { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Manifest::default()), + Err(e) => return Err(anyhow!("read {}: {e}", path.display())), + }; + serde_json::from_str(&json).with_context(|| { + format!( + "parse {}; delete it to re-sync from scratch", + path.display() + ) + }) + } + + /// Write the manifest atomically (temp file + rename) with the same + /// permissions as the rest of `$CONFIG_DIR`. + fn save_manifest(manifest: &Manifest) -> Result<()> { + let path = manifest_path()?; + let dir = path.parent().expect("manifest path has a parent"); + std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)); + } + let json = serde_json::to_string_pretty(manifest)?; + let tmp = dir.join(format!("{MANIFEST_FILE}.{}.tmp", std::process::id())); + std::fs::write(&tmp, json).with_context(|| format!("write {}", tmp.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)) + .with_context(|| format!("chmod 0600 {}", tmp.display()))?; + } + std::fs::rename(&tmp, &path) + .with_context(|| format!("rename {} → {}", tmp.display(), path.display())) + } + + #[cfg(test)] + mod tests { + use super::*; + use crate::config::{CONFIG_DIR_ENV, TEST_ENV_LOCK}; + use std::path::Path; + + /// Run `f` with `$TOOLPATH_CONFIG_DIR` pinned to `/.toolpath`; + /// `f` receives the tempdir root for building provider fixtures. + fn with_cfg R, R>(f: F) -> R { + let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let temp = tempfile::tempdir().unwrap(); + let prev = std::env::var_os(CONFIG_DIR_ENV); + unsafe { + std::env::set_var(CONFIG_DIR_ENV, temp.path().join(".toolpath")); + } + let result = f(temp.path()); + unsafe { + match prev { + Some(v) => std::env::set_var(CONFIG_DIR_ENV, v), + None => std::env::remove_var(CONFIG_DIR_ENV), + } + } + result + } + + fn write_claude_session(home: &Path, project_slug: &str, session: &str, prompt: &str) { + let project_dir = home.join(".claude/projects").join(project_slug); + std::fs::create_dir_all(&project_dir).unwrap(); + let user = format!( + r#"{{"type":"user","uuid":"u-{session}","timestamp":"2024-01-02T00:00:00Z","cwd":"/test/project","message":{{"role":"user","content":"{prompt}"}}}}"# + ); + let asst = format!( + r#"{{"type":"assistant","uuid":"a-{session}","timestamp":"2024-01-02T00:00:01Z","message":{{"role":"assistant","content":"hi"}}}}"# + ); + std::fs::write( + project_dir.join(format!("{session}.jsonl")), + format!("{user}\n{asst}\n"), + ) + .unwrap(); + } + + fn claude_bundle(home: &Path) -> HarnessBundle { + let resolver = + toolpath_claude::PathResolver::new().with_claude_dir(home.join(".claude")); + HarnessBundle { + claude: Some(toolpath_claude::ClaudeConvo::with_resolver(resolver)), + ..Default::default() + } + } + + fn cached_step_count(cache_id: &str) -> usize { + let path = crate::cmd_cache::cache_path(cache_id).unwrap(); + let json = std::fs::read_to_string(path).unwrap(); + let doc = toolpath::v1::Graph::from_json(&json).unwrap(); + doc.single_path().map(|p| p.steps.len()).unwrap_or(0) + } + + fn make_stub(artifact_type: ArtifactType, id: &str) -> ArtifactStub { + ArtifactStub { + artifact_type, + id: id.to_string(), + path: Some("/test/project".to_string()), + modified: None, + size: None, + } + } + + #[test] + fn manifest_roundtrips_and_missing_is_empty() { + with_cfg(|_| { + assert!(load_manifest().unwrap().is_empty()); + + let mut manifest = Manifest::default(); + manifest.entry("claude".to_string()).or_default().insert( + "sess-1".to_string(), + SyncRecord { + path: Some("/test/project".to_string()), + cache_id: Some("claude-p1".to_string()), + modified: Some("2024-01-02T00:00:01.123456789Z".parse().unwrap()), + size: Some(4096), + synced_at: "2026-07-09T00:00:00Z".parse().unwrap(), + }, + ); + save_manifest(&manifest).unwrap(); + assert_eq!(load_manifest().unwrap(), manifest); + }); + } + + #[cfg(unix)] + #[test] + fn manifest_file_is_0600() { + use std::os::unix::fs::PermissionsExt; + with_cfg(|_| { + save_manifest(&Manifest::default()).unwrap(); + let mode = std::fs::metadata(manifest_path().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + }); + } + + #[test] + fn corrupt_manifest_errors_with_hint() { + with_cfg(|_| { + save_manifest(&Manifest::default()).unwrap(); + std::fs::write(manifest_path().unwrap(), "not json").unwrap(); + let err = load_manifest().unwrap_err(); + assert!(err.to_string().contains("re-sync from scratch")); + }); + } + + #[test] + fn enumerate_stubs_stats_claude_sessions() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + let stubs = enumerate_stubs(&bundle, ArtifactType::Claude, None); + assert_eq!(stubs.len(), 1); + assert_eq!(stubs[0].id, "sess-aaa"); + assert_eq!(stubs[0].path.as_deref(), Some("/test/project")); + assert!(stubs[0].modified.is_some(), "file mtime must be stamped"); + assert!(stubs[0].size.unwrap() > 0, "file size must be stamped"); + }); + } + + #[test] + fn first_sync_ingests_then_second_is_unchanged() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + write_claude_session(home, "-test-project", "sess-bbb", "Fix a bug"); + let bundle = claude_bundle(home); + + let outcomes = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); + assert_eq!(outcomes.len(), 1); + let (_, first) = outcomes[0]; + assert_eq!( + (first.new, first.updated, first.unchanged, first.failed), + (2, 0, 0, 0) + ); + + let manifest = load_manifest().unwrap(); + let records = manifest.get("claude").unwrap(); + assert_eq!(records.len(), 2); + let rec = records.get("sess-aaa").unwrap(); + assert_eq!(rec.path.as_deref(), Some("/test/project")); + assert!(rec.modified.is_some()); + assert!(rec.size.is_some()); + let cache_id = rec + .cache_id + .as_deref() + .expect("synced record is materialized"); + assert!( + crate::cmd_cache::cache_path(cache_id).unwrap().exists(), + "cache doc must exist for {cache_id}" + ); + + let (_, second) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; + assert_eq!( + (second.new, second.updated, second.unchanged, second.failed), + (0, 0, 2, 0) + ); + }); + } + + #[test] + fn changed_session_is_rederived() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); + + let cache_id = load_manifest().unwrap()["claude"]["sess-aaa"] + .cache_id + .clone() + .expect("synced record is materialized"); + let steps_before = cached_step_count(&cache_id); + + // Session continues: a later user turn lands in the file, + // changing its size (and mtime). + let file = home.join(".claude/projects/-test-project/sess-aaa.jsonl"); + let mut body = std::fs::read_to_string(&file).unwrap(); + body.push_str( + r#"{"type":"user","uuid":"u-2","timestamp":"2024-01-02T00:05:00Z","cwd":"/test/project","message":{"role":"user","content":"And another thing"}}"#, + ); + body.push('\n'); + std::fs::write(&file, body).unwrap(); + + let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; + assert_eq!( + ( + outcome.new, + outcome.updated, + outcome.unchanged, + outcome.failed + ), + (0, 1, 0, 0) + ); + assert!( + cached_step_count(&cache_id) > steps_before, + "re-derived doc must contain the appended turn" + ); + }); + } + + #[test] + fn sync_touches_only_requested_types() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + + let outcomes = sync_bundle(&bundle, &[ArtifactType::Codex], None).unwrap(); + assert_eq!(outcomes[0].1, SyncOutcome::default()); + assert!( + load_manifest().unwrap().is_empty(), + "codex-only sync must not ingest claude sessions" + ); + }); + } + + #[test] + fn sync_overwrites_cache_entry_it_does_not_remember() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); + + // Losing the manifest (or a prior manual `p import`) leaves a + // cache entry sync doesn't know about; re-syncing must + // overwrite it, not die on the exists-check. + std::fs::remove_file(manifest_path().unwrap()).unwrap(); + let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; + assert_eq!((outcome.new, outcome.failed), (1, 0)); + }); + } + + #[test] + fn failed_derivation_is_tallied_and_skipped() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + let mut stubs = enumerate_stubs(&bundle, ArtifactType::Claude, None); + stubs.push(make_stub(ArtifactType::Claude, "does-not-exist")); + + let outcome = sync_stubs(&bundle, &stubs, &BTreeMap::new(), None).unwrap(); + assert_eq!((outcome.new, outcome.failed), (1, 1)); + let records = &load_manifest().unwrap()["claude"]; + assert!(records.contains_key("sess-aaa")); + assert!( + !records.contains_key("does-not-exist"), + "failed artifacts must not be recorded as synced" + ); + }); + } + + #[test] + fn rotated_session_resyncs_under_its_head_id() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); + let cache_id = load_manifest().unwrap()["claude"]["sess-aaa"] + .cache_id + .clone() + .unwrap(); + let steps_before = cached_step_count(&cache_id); + + // The session rotates: a successor file whose first entry + // carries the predecessor's sessionId (the bridge). + // Appends land here; sess-aaa.jsonl never changes again. + std::fs::write( + home.join(".claude/projects/-test-project/sess-bbb.jsonl"), + concat!( + r#"{"type":"user","uuid":"u-b0","timestamp":"2024-01-02T01:00:00Z","sessionId":"sess-aaa","cwd":"/test/project","message":{"role":"user","content":"bridge"}}"#, + "\n", + r#"{"type":"user","uuid":"u-b1","timestamp":"2024-01-02T01:00:01Z","sessionId":"sess-bbb","cwd":"/test/project","message":{"role":"user","content":"after rotation"}}"#, + "\n", + ), + ) + .unwrap(); + + let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; + assert_eq!( + (outcome.new, outcome.updated, outcome.unchanged), + (0, 1, 0), + "the chain must re-sync under its head id, not read as unchanged" + ); + let manifest = load_manifest().unwrap(); + assert!( + !manifest["claude"].contains_key("sess-bbb"), + "successor segments are not separate artifacts" + ); + assert!( + cached_step_count(&cache_id) > steps_before, + "post-rotation turns must reach the cached doc" + ); + + // And the grown chain settles: a third sync is a no-op. + let (_, again) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; + assert_eq!((again.updated, again.unchanged), (0, 1)); + }); + } + + #[test] + fn all_none_stamps_never_read_as_unchanged() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); + + // A record whose stamps are all None (stat failed when it + // was written) must not match a stub whose stat also + // failed — unknowable freshness re-derives. + let mut records = load_manifest().unwrap()["claude"].clone(); + let rec = records.get_mut("sess-aaa").unwrap(); + rec.modified = None; + rec.size = None; + let stub = make_stub(ArtifactType::Claude, "sess-aaa"); + let outcome = sync_stubs(&bundle, &[stub], &records, None).unwrap(); + assert_eq!((outcome.updated, outcome.unchanged), (1, 0)); + }); + } + + #[test] + fn recorded_import_is_unchanged_to_the_next_sync() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + + // What `p import` does: derive with provenance, write the + // cache, record the stub. + let derived = crate::cmd_import::derive_claude_session_with( + bundle.claude.as_ref().unwrap(), + "/test/project", + "sess-aaa", + ) + .unwrap(); + let stub = derived.provenance.as_ref().unwrap(); + assert_eq!(stub.id, "sess-aaa"); + assert!(stub.modified.is_some() && stub.size.is_some()); + crate::cmd_cache::write_cached(&derived.cache_id, &derived.doc, true).unwrap(); + record_stub(stub, &derived.cache_id).unwrap(); + + // The import's stamp must match sync's own enumeration. + let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; + assert_eq!( + ( + outcome.new, + outcome.updated, + outcome.unchanged, + outcome.failed + ), + (0, 0, 1, 0) + ); + }); + } + + #[test] + fn parent_dir_scopes_path_keyed_enumeration() { + with_cfg(|home| { + write_claude_session(home, "-scope-alpha", "aaaa1111-x", "In alpha"); + write_claude_session(home, "-scope-beta", "bbbb2222-x", "In beta"); + let bundle = claude_bundle(home); + + let (_, scoped) = sync_bundle( + &bundle, + &[ArtifactType::Claude], + Some(Path::new("/scope/alpha")), + ) + .unwrap()[0]; + assert_eq!((scoped.new, scoped.out_of_scope), (1, 0)); + let manifest = load_manifest().unwrap(); + assert!( + !manifest["claude"].contains_key("bbbb2222-x"), + "pruned projects must not be enumerated or recorded" + ); + + // Unscoped sync picks up the rest. + let (_, full) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; + assert_eq!((full.new, full.unchanged), (1, 1)); + }); + } + + fn codex_bundle(home: &Path, cwd: &str) -> HarnessBundle { + let codex_dir = home.join(".codex"); + let dir = codex_dir.join("sessions/2026/05/07"); + std::fs::create_dir_all(&dir).unwrap(); + let meta = format!( + r#"{{"timestamp":"2026-05-07T00:00:00Z","type":"session_meta","payload":{{"id":"00000000-0000-0000-0000-0000000000aa","timestamp":"2026-05-07T00:00:00Z","cwd":"{cwd}","originator":"codex-tui","cli_version":"test","source":"cli","model_provider":"openai"}}}}"# + ); + let user = r#"{"timestamp":"2026-05-07T00:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}}"#; + std::fs::write( + dir.join("rollout-2026-05-07T00-00-00-00000000-0000-0000-0000-0000000000aa.jsonl"), + format!("{meta}\n{user}\n"), + ) + .unwrap(); + let resolver = toolpath_codex::PathResolver::new().with_codex_dir(&codex_dir); + HarnessBundle { + codex: Some(toolpath_codex::CodexConvo::with_resolver(resolver)), + ..Default::default() + } + } + + #[test] + fn out_of_scope_codex_peek_is_memoized_then_scope_match_derives() { + with_cfg(|home| { + let bundle = codex_bundle(home, "/work/proj"); + + // cwd lives outside the constraint: one bounded peek, a + // known-but-uncached record, no derive. + let (_, out) = sync_bundle( + &bundle, + &[ArtifactType::Codex], + Some(Path::new("/elsewhere")), + ) + .unwrap()[0]; + assert_eq!((out.new, out.out_of_scope), (0, 1)); + let rec = load_manifest().unwrap()["codex"]["00000000-0000-0000-0000-0000000000aa"] + .clone(); + assert_eq!( + rec.path.as_deref(), + Some("/work/proj"), + "peeked cwd memoized" + ); + assert!(rec.cache_id.is_none(), "known, not materialized"); + + // Matching constraint: the memoized record answers the scope + // question and the artifact derives. + let (_, hit) = sync_bundle( + &bundle, + &[ArtifactType::Codex], + Some(Path::new("/work/proj")), + ) + .unwrap()[0]; + assert_eq!((hit.new, hit.updated, hit.out_of_scope), (0, 1, 0)); + let rec = load_manifest().unwrap()["codex"]["00000000-0000-0000-0000-0000000000aa"] + .clone(); + assert!(rec.cache_id.is_some(), "materialized now"); + assert_eq!( + rec.path.as_deref(), + Some("/work/proj"), + "deriving must not clobber the memoized peeked cwd" + ); + }); + } + + fn copilot_bundle(home: &Path, id: &str, cwd: &str) -> HarnessBundle { + let copilot_dir = home.join(".copilot"); + let dir = copilot_dir.join("session-state").join(id); + std::fs::create_dir_all(&dir).unwrap(); + let start = format!( + r#"{{"type":"session.start","timestamp":"2026-07-01T00:00:00Z","data":{{"copilotVersion":"1.0.67","context":{{"cwd":"{cwd}"}}}}}}"# + ); + let user = r#"{"type":"user.message","timestamp":"2026-07-01T00:00:01Z","data":{"content":"hi"}}"#; + std::fs::write(dir.join("events.jsonl"), format!("{start}\n{user}\n")).unwrap(); + let resolver = toolpath_copilot::PathResolver::new().with_copilot_dir(&copilot_dir); + HarnessBundle { + copilot: Some(toolpath_copilot::CopilotConvo::with_resolver(resolver)), + ..Default::default() + } + } + + #[test] + fn copilot_syncs_and_scopes_via_memoized_peek() { + with_cfg(|home| { + let bundle = copilot_bundle(home, "sess-cp", "/work/proj"); + + // Out-of-scope first: one peek, a known record with the cwd. + let (_, out) = sync_bundle( + &bundle, + &[ArtifactType::Copilot], + Some(Path::new("/elsewhere")), + ) + .unwrap()[0]; + assert_eq!((out.new, out.out_of_scope), (0, 1)); + let rec = load_manifest().unwrap()["copilot"]["sess-cp"].clone(); + assert_eq!(rec.path.as_deref(), Some("/work/proj")); + assert!(rec.cache_id.is_none()); + + // In scope: derives; then a plain re-sync is a no-op. + let (_, hit) = + sync_bundle(&bundle, &[ArtifactType::Copilot], Some(Path::new("/work"))) + .unwrap()[0]; + assert_eq!((hit.updated, hit.out_of_scope), (1, 0)); + let (_, again) = sync_bundle(&bundle, &[ArtifactType::Copilot], None).unwrap()[0]; + assert_eq!(again.unchanged, 1); + }); + } + + #[test] + fn evicted_cache_entry_rematerializes_on_next_sync() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); + let cache_id = load_manifest().unwrap()["claude"]["sess-aaa"] + .cache_id + .clone() + .unwrap(); + + // `p cache rm`: doc removed, record downgraded to known. + crate::cmd_cache::remove_cached(&cache_id).unwrap(); + evict_cache_id(&cache_id).unwrap(); + assert!( + load_manifest().unwrap()["claude"]["sess-aaa"] + .cache_id + .is_none() + ); + + let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; + assert_eq!((outcome.new, outcome.updated), (0, 1)); + assert!( + crate::cmd_cache::cache_path(&cache_id).unwrap().exists(), + "evicted artifact re-materializes" + ); + }); + } + + #[test] + fn manually_deleted_doc_is_restored_even_with_stale_record() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); + let cache_id = load_manifest().unwrap()["claude"]["sess-aaa"] + .cache_id + .clone() + .unwrap(); + + // Doc deleted behind the CLI's back: the record still claims + // materialization, but sync verifies the doc exists. + let doc = crate::cmd_cache::cache_path(&cache_id).unwrap(); + std::fs::remove_file(&doc).unwrap(); + let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; + assert_eq!((outcome.new, outcome.updated), (0, 1)); + assert!(doc.exists()); + }); + } + + #[test] + fn fresh_cache_id_tracks_source_and_eviction() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + + // Nothing synced yet: no fresh copy. + assert!( + fresh_cache_id( + &bundle, + ArtifactType::Claude, + Some("/test/project"), + "sess-aaa" + ) + .is_none() + ); + + sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); + let cache_id = fresh_cache_id( + &bundle, + ArtifactType::Claude, + Some("/test/project"), + "sess-aaa", + ) + .expect("synced artifact is fresh"); + + // Source grows: stale until re-synced. + let file = home.join(".claude/projects/-test-project/sess-aaa.jsonl"); + let mut body = std::fs::read_to_string(&file).unwrap(); + body.push_str( + r#"{"type":"user","uuid":"u-2","timestamp":"2024-01-02T00:05:00Z","cwd":"/test/project","message":{"role":"user","content":"more"}}"#, + ); + body.push('\n'); + std::fs::write(&file, body).unwrap(); + assert!( + fresh_cache_id( + &bundle, + ArtifactType::Claude, + Some("/test/project"), + "sess-aaa" + ) + .is_none() + ); + sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); + assert!( + fresh_cache_id( + &bundle, + ArtifactType::Claude, + Some("/test/project"), + "sess-aaa" + ) + .is_some() + ); + + // Evicted: known but not materialized, so not fresh. + crate::cmd_cache::remove_cached(&cache_id).unwrap(); + evict_cache_id(&cache_id).unwrap(); + assert!( + fresh_cache_id( + &bundle, + ArtifactType::Claude, + Some("/test/project"), + "sess-aaa" + ) + .is_none() + ); + }); + } + + #[test] + fn pi_scope_matching_survives_hyphenated_paths() { + // decode_project turns the dir-encoded '-' back into '/', so a + // real path /Users/b/my-repo arrives as /Users/b/my/repo; the + // comparator must match it against the real constraint anyway. + assert!(pi_project_in_scope( + "/Users/b/my/repo", + Path::new("/Users/b/my-repo") + )); + assert!(pi_project_in_scope( + "/Users/b/my/repo/sub", + Path::new("/Users/b/my-repo") + )); + assert!(!pi_project_in_scope( + "/Users/b/other", + Path::new("/Users/b/my-repo") + )); + } + + #[test] + fn copilot_peek_accepts_top_level_cwd() { + with_cfg(|home| { + // Older CLIs store cwd at the payload top level, no + // `context` object — the peek must still find it. + let copilot_dir = home.join(".copilot"); + let dir = copilot_dir.join("session-state").join("sess-legacy"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("events.jsonl"), + concat!( + r#"{"type":"session.start","data":{"cwd":"/work/proj"}}"#, + "\n", + r#"{"type":"user.message","data":{"content":"hi"}}"#, + "\n" + ), + ) + .unwrap(); + let resolver = toolpath_copilot::PathResolver::new().with_copilot_dir(&copilot_dir); + let bundle = HarnessBundle { + copilot: Some(toolpath_copilot::CopilotConvo::with_resolver(resolver)), + ..Default::default() + }; + let (_, out) = sync_bundle( + &bundle, + &[ArtifactType::Copilot], + Some(Path::new("/elsewhere")), + ) + .unwrap()[0]; + assert_eq!(out.out_of_scope, 1); + let rec = load_manifest().unwrap()["copilot"]["sess-legacy"].clone(); + assert_eq!(rec.path.as_deref(), Some("/work/proj")); + }); + } + + #[test] + fn derive_stub_errors_when_provider_missing() { + let bundle = HarnessBundle::default(); + let stub = make_stub(ArtifactType::Claude, "sess"); + let Err(err) = derive_stub(&bundle, &stub) else { + panic!("derive_stub must fail without a claude manager"); + }; + assert!(err.to_string().contains("provider not available")); + } + + #[test] + fn newest_first_orders_by_mtime_with_unstamped_last() { + let mut old = make_stub(ArtifactType::Claude, "old"); + old.modified = Some("2026-01-01T00:00:00Z".parse().unwrap()); + let mut new = make_stub(ArtifactType::Claude, "new"); + new.modified = Some("2026-07-01T00:00:00Z".parse().unwrap()); + let unstamped = make_stub(ArtifactType::Claude, "unstamped"); + + let stubs = vec![old, unstamped, new]; + let ids: Vec<&str> = newest_first(&stubs).iter().map(|s| s.id.as_str()).collect(); + assert_eq!(ids, vec!["new", "old", "unstamped"]); + } + + #[test] + fn progress_line_counts_only_pending_work() { + let mut progress = Progress { + label: "claude ", + total: 3, + done: 0, + tty: false, + }; + progress.tick(); + progress.tick(); + assert_eq!(progress.line(), "claude 2/3"); + } + + #[test] + fn resolve_types_defaults_to_all_and_dedups() { + assert_eq!(resolve_types(&[]), ArtifactType::ALL.to_vec()); + assert_eq!( + resolve_types(&[ + ArtifactType::Codex, + ArtifactType::Claude, + ArtifactType::Codex + ]), + vec![ArtifactType::Codex, ArtifactType::Claude] + ); + } + + #[test] + fn render_summary_hides_empty_types_unless_explicit() { + let outcomes = vec![ + ( + ArtifactType::Claude, + SyncOutcome { + new: 2, + updated: 1, + unchanged: 3, + failed: 0, + out_of_scope: 0, + }, + ), + (ArtifactType::Cursor, SyncOutcome::default()), + ]; + let default_run = render_summary(&outcomes, false); + assert!(default_run.contains("claude")); + assert!(!default_run.contains("cursor")); + + let explicit_run = render_summary(&outcomes, true); + assert!(explicit_run.contains("cursor")); + } + + #[test] + fn render_summary_shows_failures_and_empty_case() { + let outcomes = vec![( + ArtifactType::Codex, + SyncOutcome { + new: 0, + updated: 0, + unchanged: 1, + failed: 2, + out_of_scope: 0, + }, + )]; + let s = render_summary(&outcomes, false); + assert!(s.contains("2 failed")); + + assert_eq!(render_summary(&[], false), "nothing to sync\n"); + } + } +} + +#[cfg(test)] +mod type_tests { + use super::ArtifactType; + + #[test] + fn names_and_symbols_are_distinct() { + let names: std::collections::HashSet<&str> = + ArtifactType::ALL.iter().map(|t| t.name()).collect(); + let symbols: std::collections::HashSet<&str> = + ArtifactType::ALL.iter().map(|t| t.symbol()).collect(); + assert_eq!(names.len(), ArtifactType::ALL.len()); + assert_eq!(symbols.len(), ArtifactType::ALL.len()); + } + + #[test] + fn path_keyed_matches_design() { + assert!(ArtifactType::Claude.path_keyed()); + assert!(ArtifactType::Gemini.path_keyed()); + assert!(ArtifactType::Pi.path_keyed()); + assert!(!ArtifactType::Codex.path_keyed()); + assert!(!ArtifactType::Opencode.path_keyed()); + assert!(!ArtifactType::Cursor.path_keyed()); + assert!(ArtifactType::Git.path_keyed()); + } + + #[test] + fn parse_roundtrips_every_name() { + for t in ArtifactType::ALL { + assert_eq!(ArtifactType::parse(t.name()), Some(t)); + } + assert_eq!(ArtifactType::parse("frobnicate"), None); + } +} diff --git a/crates/path-cli/tests/integration.rs b/crates/path-cli/tests/integration.rs index 375b491a..546b558f 100644 --- a/crates/path-cli/tests/integration.rs +++ b/crates/path-cli/tests/integration.rs @@ -658,6 +658,432 @@ fn cache_ls_after_import_lists_entry() { .stdout(predicate::str::contains("git-")); } +/// A `$HOME` with one Claude session. Returns (home-tempdir, session file). +fn claude_home_fixture() -> (tempfile::TempDir, PathBuf) { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("proj"); + std::fs::create_dir_all(&project).unwrap(); + // toolpath-claude maps '/', '_', and '.' to '-' when sanitizing project + // paths into directory slugs — mirror that here so the fixture lands + // where the resolver looks for it. + let project_slug = project + .to_string_lossy() + .replace([std::path::MAIN_SEPARATOR, '_', '.'], "-"); + let project_dir = temp.path().join(".claude/projects").join(&project_slug); + std::fs::create_dir_all(&project_dir).unwrap(); + let session_file = project_dir.join("session-abc.jsonl"); + std::fs::write( + &session_file, + format!( + r#"{{"type":"user","uuid":"u-1","timestamp":"2024-01-01T00:00:00Z","cwd":"{cwd}","message":{{"role":"user","content":"hi"}}}} +{{"type":"assistant","uuid":"a-1","timestamp":"2024-01-01T00:00:01Z","message":{{"role":"assistant","content":"hello"}}}} +"#, + cwd = project.display() + ), + ) + .unwrap(); + (temp, session_file) +} + +#[test] +fn cache_sync_ingests_reskips_and_updates() { + let (home, session_file) = claude_home_fixture(); + let cfg = tempfile::tempdir().unwrap(); + let sync = || { + let mut c = cmd(); + c.env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "cache", "sync", "claude"]); + c + }; + + // First run derives the session into the cache and records it. + sync() + .assert() + .success() + .stderr(predicate::str::contains("1 new, 0 updated, 0 unchanged")); + let manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(cfg.path().join("sync.json")).unwrap()) + .unwrap(); + let record = &manifest["claude"]["session-abc"]; + let cache_id = record["cache_id"].as_str().unwrap(); + assert!( + cfg.path() + .join(format!("documents/{cache_id}.json")) + .exists() + ); + + // Nothing changed: the second run derives nothing. + sync() + .assert() + .success() + .stderr(predicate::str::contains("0 new, 0 updated, 1 unchanged")); + + // The session grows a turn; the third run re-derives it. + let mut body = std::fs::read_to_string(&session_file).unwrap(); + body.push_str( + r#"{"type":"user","uuid":"u-2","timestamp":"2024-01-01T00:05:00Z","cwd":"/x","message":{"role":"user","content":"more"}}"#, + ); + body.push('\n'); + std::fs::write(&session_file, body).unwrap(); + sync() + .assert() + .success() + .stderr(predicate::str::contains("0 new, 1 updated, 0 unchanged")); +} + +#[test] +fn cache_sync_default_run_with_no_sessions_reports_nothing() { + let home = tempfile::tempdir().unwrap(); + let cfg = tempfile::tempdir().unwrap(); + cmd() + .env("HOME", home.path()) + // opencode resolves through $XDG_DATA_HOME before $HOME — drop it + // so the sandboxed run can't see the developer's real database. + .env_remove("XDG_DATA_HOME") + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "cache", "sync"]) + .assert() + .success() + .stderr(predicate::str::contains("nothing to sync")); + assert!(!cfg.path().join("sync.json").exists()); +} + +#[test] +fn import_records_manifest_so_sync_skips() { + let (home, _session_file) = claude_home_fixture(); + let cfg = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args([ + "p", + "import", + "claude", + "--session", + "session-abc", + "--project", + ]) + .arg(&project) + .assert() + .success(); + + let manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(cfg.path().join("sync.json")).unwrap()) + .unwrap(); + let record = &manifest["claude"]["session-abc"]; + assert!(record["modified"].is_string(), "import must stamp mtime"); + assert!(record["size"].is_u64(), "import must stamp size"); + + // Sync sees the import's record and derives nothing. + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "cache", "sync", "claude"]) + .assert() + .success() + .stderr(predicate::str::contains("0 new, 0 updated, 1 unchanged")); +} + +#[test] +fn bulk_import_records_every_session() { + let (home, session_file) = claude_home_fixture(); + let cfg = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + // A second session alongside the fixture's, so --all has a batch. + std::fs::write( + session_file.parent().unwrap().join("deadbeef-second.jsonl"), + format!( + r#"{{"type":"user","uuid":"u-9","timestamp":"2024-02-01T00:00:00Z","cwd":"{cwd}","message":{{"role":"user","content":"second"}}}} +"#, + cwd = project.display() + ), + ) + .unwrap(); + + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "import", "claude", "--all", "--project"]) + .arg(&project) + .assert() + .success(); + + let manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(cfg.path().join("sync.json")).unwrap()) + .unwrap(); + assert_eq!( + manifest["claude"].as_object().unwrap().len(), + 2, + "--all must record every session it writes" + ); + + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "cache", "sync", "claude"]) + .assert() + .success() + .stderr(predicate::str::contains("0 new, 0 updated, 2 unchanged")); +} + +#[test] +fn git_import_lands_in_manifest_and_sync_leaves_it_alone() { + let (dir, branch) = git_fixture(); + let cfg = tempfile::tempdir().unwrap(); + + cmd() + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "import", "git", "--branch"]) + .arg(&branch) + .arg("--repo") + .arg(dir.path()) + .assert() + .success(); + + let manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(cfg.path().join("sync.json")).unwrap()) + .unwrap(); + let git_records = manifest["git"].as_object().unwrap(); + assert_eq!(git_records.len(), 1); + let record = git_records.values().next().unwrap(); + assert!( + record["path"].is_string(), + "git records carry the repo path" + ); + + // Git artifacts are recorded, not discovered: sync reports zeros + // and must not fail or re-derive. + let home = tempfile::tempdir().unwrap(); + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "cache", "sync", "git"]) + .assert() + .success() + .stderr(predicate::str::contains("0 new, 0 updated, 0 unchanged")); +} + +#[test] +fn share_records_manifest_so_sync_skips() { + let (port, server, _temp, project, home) = share_anon_fixture(); + let cfg = tempfile::tempdir().unwrap(); + + cmd() + .env("HOME", &home) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args([ + "share", + "--harness", + "claude", + "--session", + "session-abc", + "--project", + ]) + .arg(&project) + .args(["--anon", "--url"]) + .arg(format!("http://127.0.0.1:{port}")) + .assert() + .success(); + server.join().unwrap(); + + let manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(cfg.path().join("sync.json")).unwrap()) + .unwrap(); + assert!(manifest["claude"]["session-abc"]["modified"].is_string()); + + cmd() + .env("HOME", &home) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "cache", "sync", "claude"]) + .assert() + .success() + .stderr(predicate::str::contains("0 new, 0 updated, 1 unchanged")); +} + +#[test] +fn cache_sync_parent_dir_limits_ingestion() { + let (home, _session_file) = claude_home_fixture(); + let cfg = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "cache", "sync", "claude", "-d", "/nowhere"]) + .assert() + .success() + .stderr(predicate::str::contains("0 new, 0 updated, 0 unchanged")); + + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "cache", "sync", "claude", "-d"]) + .arg(&project) + .assert() + .success() + .stderr(predicate::str::contains("1 new, 0 updated, 0 unchanged")); +} + +#[test] +fn share_uploads_cached_doc_when_source_unchanged() { + let (home, session_file) = claude_home_fixture(); + let cfg = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + let share = |port: u16| { + let mut c = cmd(); + c.env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args([ + "share", + "--harness", + "claude", + "--session", + "session-abc", + "--project", + ]) + .arg(&project) + .args(["--anon", "--url"]) + .arg(format!("http://127.0.0.1:{port}")); + c + }; + + // First share derives + records. + let (port, server) = one_shot_anon_server(); + share(port) + .assert() + .success() + .stderr(predicate::str::contains("uploading without re-deriving").not()); + server.join().unwrap(); + + // Unchanged source: the second share must not re-derive. + let (port, server) = one_shot_anon_server(); + share(port) + .assert() + .success() + .stderr(predicate::str::contains("uploading without re-deriving")); + server.join().unwrap(); + + // The session grows: the fast path steps aside and share re-derives. + let mut body = std::fs::read_to_string(&session_file).unwrap(); + body.push_str( + r#"{"type":"user","uuid":"u-2","timestamp":"2024-01-01T00:05:00Z","cwd":"/x","message":{"role":"user","content":"more"}}"#, + ); + body.push('\n'); + std::fs::write(&session_file, body).unwrap(); + let (port, server) = one_shot_anon_server(); + share(port) + .assert() + .success() + .stderr(predicate::str::contains("uploading without re-deriving").not()) + .stderr(predicate::str::contains("Cached claude session")); + server.join().unwrap(); +} + +#[test] +fn import_ingests_thinking_maximally() { + let (home, session_file) = claude_home_fixture(); + let cfg = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + // Append an assistant turn with a thinking block. + let mut body = std::fs::read_to_string(&session_file).unwrap(); + body.push_str( + r#"{"type":"assistant","uuid":"a-2","timestamp":"2024-01-01T00:00:02Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"secret reasoning"},{"type":"text","text":"done"}]}}"#, + ); + body.push('\n'); + std::fs::write(&session_file, body).unwrap(); + + let out = cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args([ + "p", + "import", + "claude", + "--session", + "session-abc", + "--project", + ]) + .arg(&project) + .output() + .unwrap(); + assert!(out.status.success()); + let doc_path = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let doc = std::fs::read_to_string(&doc_path).unwrap(); + assert!( + doc.contains("secret reasoning"), + "the cache holds the maximal derivation, thinking included" + ); +} + +#[test] +fn import_after_query_sync_is_a_noop_not_an_error() { + let (home, _session_file) = claude_home_fixture(); + let cfg = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + + // A bare query auto-syncs the session into the cache… + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["query", "--source", "claude", "length"]) + .assert() + .success(); + + // …and the documented explicit import must not die on the exists-check. + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args([ + "p", + "import", + "claude", + "--session", + "session-abc", + "--project", + ]) + .arg(&project) + .assert() + .success() + .stderr(predicate::str::contains("already up to date")); +} + +#[cfg(unix)] +#[test] +fn bulk_import_skips_unreadable_sessions() { + use std::os::unix::fs::PermissionsExt; + let (home, session_file) = claude_home_fixture(); + let cfg = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + // A second session that cannot be read. + let bad = session_file.parent().unwrap().join("deadbeef-bad.jsonl"); + std::fs::write(&bad, "x").unwrap(); + std::fs::set_permissions(&bad, std::fs::Permissions::from_mode(0o000)).unwrap(); + + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "import", "claude", "--all", "--project"]) + .arg(&project) + .assert() + .success() + .stderr(predicate::str::contains("Warning: skipping session")) + .stderr(predicate::str::contains("Imported")); +} + +#[test] +fn cache_sync_rejects_unknown_type() { + let cfg = tempfile::tempdir().unwrap(); + cmd() + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "cache", "sync", "frobnicate"]) + .assert() + .failure() + .stderr(predicate::str::contains("invalid value")); +} + #[test] fn export_pathbase_repo_flag_requires_login() { // `export pathbase` without --repo falls through to the anonymous diff --git a/crates/path-cli/tests/query.rs b/crates/path-cli/tests/query.rs index 68525567..30559967 100644 --- a/crates/path-cli/tests/query.rs +++ b/crates/path-cli/tests/query.rs @@ -10,7 +10,14 @@ use predicates::prelude::*; use std::path::Path; fn cmd() -> Command { - Command::cargo_bin("path").unwrap() + // `path query` auto-syncs the cache from the installed harnesses; + // pin $HOME to a shared empty sandbox so tests exercise that path + // without ingesting the developer's real sessions. + static HOME: std::sync::OnceLock = std::sync::OnceLock::new(); + let home = HOME.get_or_init(|| tempfile::tempdir().unwrap()); + let mut c = Command::cargo_bin("path").unwrap(); + c.env("HOME", home.path()).env_remove("XDG_DATA_HOME"); + c } /// Write `json` into `/documents/.json`, creating the dir. @@ -504,3 +511,115 @@ fn kind_unknown_errors() { .failure() .stderr(predicate::str::contains("Bundled kinds")); } + +// ── auto-sync on invocation ────────────────────────────────────────── + +/// A `$HOME` with one real Claude session for exercising query's +/// implicit sync (distinct from the shared empty sandbox in `cmd()`). +fn claude_home() -> tempfile::TempDir { + let home = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + std::fs::create_dir_all(&project).unwrap(); + let slug = project + .to_string_lossy() + .replace([std::path::MAIN_SEPARATOR, '_', '.'], "-"); + let dir = home.path().join(".claude/projects").join(slug); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("feedbeef-query.jsonl"), + format!( + r#"{{"type":"user","uuid":"u-1","timestamp":"2024-01-01T00:00:00Z","cwd":"{cwd}","message":{{"role":"user","content":"hi"}}}} +{{"type":"assistant","uuid":"a-1","timestamp":"2024-01-01T00:00:01Z","message":{{"role":"assistant","content":"hello"}}}} +"#, + cwd = project.display() + ), + ) + .unwrap(); + home +} + +fn fixture_query<'a>( + home: &tempfile::TempDir, + cfg: &Path, + args: impl IntoIterator, +) -> assert_cmd::assert::Assert { + Command::cargo_bin("path") + .unwrap() + .env("HOME", home.path()) + .env_remove("XDG_DATA_HOME") + .env("TOOLPATH_CONFIG_DIR", cfg) + .arg("query") + .args(args) + .assert() +} + +#[test] +fn query_syncs_its_scope_before_reading() { + let home = claude_home(); + let cfg = tempfile::tempdir().unwrap(); + // Empty cache; the query ingests the session and reads it in one go. + fixture_query(&home, cfg.path(), ["--source", "claude", "length > 0"]) + .success() + .stdout(predicate::str::contains("true")) + .stderr(predicate::str::contains("synced claude: 1 new")); + assert!(cfg.path().join("sync.json").exists()); + + // Second run: nothing changed, so the sync is silent. + fixture_query(&home, cfg.path(), ["--source", "claude", "length > 0"]) + .success() + .stdout(predicate::str::contains("true")) + .stderr(predicate::str::contains("synced").not()); +} + +#[test] +fn query_no_sync_reads_the_cache_as_is() { + let home = claude_home(); + let cfg = tempfile::tempdir().unwrap(); + fixture_query(&home, cfg.path(), ["--no-sync", "length"]) + .success() + .stdout(predicate::str::contains("0")); + assert!(!cfg.path().join("sync.json").exists()); +} + +#[test] +fn input_only_query_never_touches_the_cache() { + let home = claude_home(); + let cfg = tempfile::tempdir().unwrap(); + let doc = cfg.path().join("doc.json"); + std::fs::write(&doc, CLAUDE_DOC).unwrap(); + Command::cargo_bin("path") + .unwrap() + .env("HOME", home.path()) + .env_remove("XDG_DATA_HOME") + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["query", "--input"]) + .arg(&doc) + .arg("length > 0") + .assert() + .success() + .stdout(predicate::str::contains("true")); + assert!(!cfg.path().join("sync.json").exists()); +} + +#[test] +fn query_parent_dir_scopes_sync_and_read() { + let home = claude_home(); + let cfg = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + + // Constraint matches the session's project: ingested and read. + fixture_query( + &home, + cfg.path(), + ["--parent-dir", project.to_str().unwrap(), "length > 0"], + ) + .success() + .stdout(predicate::str::contains("true")) + .stderr(predicate::str::contains("synced claude: 1 new")); + + // Constraint elsewhere: nothing read, nothing new ingested. + fixture_query(&home, cfg.path(), ["--parent-dir", "/nowhere", "length"]) + .success() + .stdout(predicate::str::contains("0")) + .stderr(predicate::str::contains("synced").not()); +} diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index 32cac640..ce0139b2 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -8,7 +8,8 @@ #![cfg(not(target_os = "emscripten"))] -use path_cli::cmd_resume::{HarnessArg, RecordingExec, ResumeArgs, run_with_strategy}; +use path_cli::cmd_resume::Harness; +use path_cli::cmd_resume::{RecordingExec, ResumeArgs, run_with_strategy}; mod support; use support::*; @@ -27,7 +28,7 @@ fn file_input_explicit_claude_projects_and_records_exec() { let recorder = RecordingExec::default(); run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Claude), + args_explicit(doc_file, cwd.path(), Harness::Claude), &recorder, ) .unwrap(); @@ -61,7 +62,7 @@ fn file_input_explicit_gemini_projects_and_records_exec() { let recorder = RecordingExec::default(); run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Gemini), + args_explicit(doc_file, cwd.path(), Harness::Gemini), &recorder, ) .unwrap(); @@ -89,7 +90,7 @@ fn file_input_explicit_codex_projects_and_records_exec() { let recorder = RecordingExec::default(); run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Codex), + args_explicit(doc_file, cwd.path(), Harness::Codex), &recorder, ) .unwrap(); @@ -117,7 +118,7 @@ fn file_input_explicit_copilot_projects_and_records_exec() { let recorder = RecordingExec::default(); run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Copilot), + args_explicit(doc_file, cwd.path(), Harness::Copilot), &recorder, ) .unwrap(); @@ -191,7 +192,7 @@ fn file_input_explicit_opencode_projects_and_records_exec() { let recorder = RecordingExec::default(); run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Opencode), + args_explicit(doc_file, cwd.path(), Harness::Opencode), &recorder, ) .unwrap(); @@ -219,11 +220,7 @@ fn file_input_explicit_pi_projects_and_records_exec() { let doc_file = write_path_to_temp(cwd.path(), path); let recorder = RecordingExec::default(); - run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Pi), - &recorder, - ) - .unwrap(); + run_with_strategy(args_explicit(doc_file, cwd.path(), Harness::Pi), &recorder).unwrap(); let cap = recorder.captured(); assert_eq!(cap.binary, "pi"); @@ -264,7 +261,7 @@ fn cache_id_input_loads_and_projects() { let resume_args = ResumeArgs { input: cache_id.to_string(), cwd: Some(cwd.path().to_path_buf()), - harness: Some(HarnessArg::Claude), + harness: Some(Harness::Claude), no_cache: false, force: false, url: None, @@ -304,7 +301,7 @@ fn multi_path_graph_returns_clear_error() { let recorder = RecordingExec::default(); let err = run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Claude), + args_explicit(doc_file, cwd.path(), Harness::Claude), &recorder, ) .unwrap_err(); @@ -326,7 +323,7 @@ fn agentless_path_returns_clear_error() { let recorder = RecordingExec::default(); let err = run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Claude), + args_explicit(doc_file, cwd.path(), Harness::Claude), &recorder, ) .unwrap_err(); @@ -345,7 +342,7 @@ fn explicit_harness_not_on_path_errors() { let recorder = RecordingExec::default(); let err = run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Claude), + args_explicit(doc_file, cwd.path(), Harness::Claude), &recorder, ) .unwrap_err(); diff --git a/crates/path-cli/tests/support/mod.rs b/crates/path-cli/tests/support/mod.rs index 578b5af1..7a168f49 100644 --- a/crates/path-cli/tests/support/mod.rs +++ b/crates/path-cli/tests/support/mod.rs @@ -11,7 +11,8 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; -use path_cli::cmd_resume::{HarnessArg, ResumeArgs}; +use path_cli::cmd_resume::Harness; +use path_cli::cmd_resume::ResumeArgs; /// Process-wide lock for tests that mutate `$HOME`, `$PATH`, or /// `$TOOLPATH_CONFIG_DIR`. Integration tests under `tests/resume.rs` @@ -178,7 +179,7 @@ pub fn write_path_to_temp(dir: &Path, path: toolpath::v1::Path) -> PathBuf { } /// Construct `ResumeArgs` for a file-input + explicit-harness test. -pub fn args_explicit(input: PathBuf, cwd: &Path, harness: HarnessArg) -> ResumeArgs { +pub fn args_explicit(input: PathBuf, cwd: &Path, harness: Harness) -> ResumeArgs { ResumeArgs { input: input.to_string_lossy().to_string(), cwd: Some(cwd.to_path_buf()), diff --git a/crates/toolpath-claude/Cargo.toml b/crates/toolpath-claude/Cargo.toml index 5f463412..0ed4b496 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.12.1" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-claude/src/lib.rs b/crates/toolpath-claude/src/lib.rs index 4d70a3d9..7251769e 100644 --- a/crates/toolpath-claude/src/lib.rs +++ b/crates/toolpath-claude/src/lib.rs @@ -340,13 +340,14 @@ impl ClaudeConvo { /// Resolves the full session chain containing `session_id`, returned /// in chronological order (oldest segment first). /// - /// For single-segment sessions, returns `[session_id]`. - #[allow(dead_code)] - pub(crate) fn session_chain( - &self, - project_path: &str, - session_id: &str, - ) -> Result> { + /// For single-segment sessions, returns `[session_id]`. This is the + /// set of files [`Self::read_conversation`] would merge — appends + /// land in the *last* segment while the chain keeps the first + /// segment's id, so anything fingerprinting a conversation must + /// stat every segment, not the head file alone. Uses the same + /// cached chain index as the other chain surfaces; after a + /// [`Self::list_conversations`] call it costs no extra IO. + pub fn session_chain(&self, project_path: &str, session_id: &str) -> Result> { self.chain_for(project_path, session_id) } 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-gemini/Cargo.toml b/crates/toolpath-gemini/Cargo.toml index 76fdeedc..b5b09f3e 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.6.1" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-gemini/src/lib.rs b/crates/toolpath-gemini/src/lib.rs index 10891df1..09ab8b5a 100644 --- a/crates/toolpath-gemini/src/lib.rs +++ b/crates/toolpath-gemini/src/lib.rs @@ -15,7 +15,7 @@ pub mod watcher; pub use error::{ConvoError, Result}; pub use io::ConvoIO; -pub use paths::PathResolver; +pub use paths::{PathResolver, SessionEntry}; pub use query::ConversationQuery; pub use reader::ConversationReader; pub use types::{ diff --git a/crates/toolpath-gemini/src/paths.rs b/crates/toolpath-gemini/src/paths.rs index 09bb50e7..561811ef 100644 --- a/crates/toolpath-gemini/src/paths.rs +++ b/crates/toolpath-gemini/src/paths.rs @@ -18,6 +18,20 @@ const TMP_DIR: &str = "tmp"; const CHATS_SUBDIR: &str = "chats"; const LOGS_FILE: &str = "logs.json"; +/// One session surfaced by [`PathResolver::list_session_entries`]. +#[derive(Debug, Clone)] +pub struct SessionEntry { + /// Listing key, exactly as [`PathResolver::list_sessions`] returns + /// it: main-file stem or orphan sub-agent directory name. + pub id: String, + /// Inner `sessionId` UUID peeked from a main file (the directory + /// name itself for orphan dirs); `None` when the peek failed. + pub session_uuid: Option, + /// The main chat file, or the orphan sub-agent directory — stat + /// this for change detection. + pub path: PathBuf, +} + #[derive(Debug, Clone)] pub struct PathResolver { home_dir: Option, @@ -194,6 +208,20 @@ impl PathResolver { /// sub-agent bucket and is **not** surfaced as a separate session — /// it gets merged into the main session by `read_session`. pub fn list_sessions(&self, project_path: &str) -> Result> { + Ok(self + .list_session_entries(project_path)? + .into_iter() + .map(|e| e.id) + .collect()) + } + + /// Like [`Self::list_sessions`], but each session comes with the + /// backing main file (or orphan sub-agent directory) and the inner + /// `sessionId` when one could be peeked — enough for stat-level + /// change detection without parsing chat bodies. The peek is + /// bounded (see `peek_session_id`): it scans a fixed-size prefix + /// and falls back to a full parse only when identity isn't there. + pub fn list_session_entries(&self, project_path: &str) -> Result> { let chats = match self.chats_dir(project_path) { Ok(p) => p, Err(_) => return Ok(Vec::new()), @@ -202,9 +230,9 @@ impl PathResolver { return Ok(Vec::new()); } - let mut main_stems: Vec = Vec::new(); + let mut mains: Vec = Vec::new(); let mut main_session_uuids: std::collections::HashSet = Default::default(); - let mut dir_uuids: Vec = Vec::new(); + let mut dirs: Vec = Vec::new(); for entry in fs::read_dir(&chats)?.flatten() { let ft = match entry.file_type() { @@ -220,24 +248,33 @@ impl PathResolver { Some(s) => s.to_string(), None => continue, }; - main_stems.push(stem); - if let Some(uuid) = peek_session_id(&path) { - main_session_uuids.insert(uuid); + let session_uuid = peek_session_id(&path); + if let Some(uuid) = &session_uuid { + main_session_uuids.insert(uuid.clone()); } + mains.push(SessionEntry { + id: stem, + session_uuid, + path, + }); } else if ft.is_dir() && let Some(name) = entry.file_name().to_str() { - dir_uuids.push(name.to_string()); + dirs.push(SessionEntry { + id: name.to_string(), + session_uuid: Some(name.to_string()), + path, + }); } } - let mut out = main_stems; - for uuid in dir_uuids { - if !main_session_uuids.contains(&uuid) { - out.push(uuid); + let mut out = mains; + for dir in dirs { + if !main_session_uuids.contains(&dir.id) { + out.push(dir); } } - out.sort(); + out.sort_by(|a, b| a.id.cmp(&b.id)); Ok(out) } @@ -351,10 +388,26 @@ struct ProjectsFile { projects: HashMap, } -/// Read just the top-level `sessionId` field from a chat JSON file -/// without materialising the whole document. Used by `list_sessions` to -/// correlate main files with sibling sub-agent UUID directories. +/// Byte budget for [`peek_session_id`]'s prefix read. Chat files put +/// their identity fields first, so this is plenty in practice. +const PEEK_BYTES: usize = 4096; + +/// Read just the top-level `sessionId` field from a chat JSON file. +/// Bounded: scans the first [`PEEK_BYTES`] of the file and falls back +/// to a full parse only when the field isn't in the prefix. Used by +/// `list_session_entries` to correlate main files with sibling +/// sub-agent UUID directories. fn peek_session_id(path: &std::path::Path) -> Option { + use std::io::Read; + let file = fs::File::open(path).ok()?; + let mut prefix = Vec::with_capacity(PEEK_BYTES); + file.take(PEEK_BYTES as u64).read_to_end(&mut prefix).ok()?; + if let Some(id) = prefix_session_id(&prefix) { + return Some(id); + } + // The prefix scan can *decline* (identity after `messages`) as well + // as miss, so always fall back to the full parse — for files that + // fit in the prefix this re-reads a few KiB, which is noise. #[derive(Deserialize)] struct Peek { #[serde(rename = "sessionId")] @@ -365,6 +418,31 @@ fn peek_session_id(path: &std::path::Path) -> Option { peek.session_id.filter(|s| !s.is_empty()) } +/// Extract `"sessionId": "…"` from a JSON prefix, trusting it only when +/// it appears before any `"messages"` key — message bodies are the one +/// place user-controlled text could fake the key. +fn prefix_session_id(prefix: &[u8]) -> Option { + let text = match std::str::from_utf8(prefix) { + Ok(t) => t, + // The cut can land mid-codepoint; scan the valid part. + Err(e) => std::str::from_utf8(&prefix[..e.valid_up_to()]).ok()?, + }; + let key_at = text.find("\"sessionId\"")?; + if let Some(messages_at) = text.find("\"messages\"") + && messages_at < key_at + { + return None; + } + let rest = text[key_at + "\"sessionId\"".len()..].trim_start(); + let rest = rest.strip_prefix(':')?.trim_start(); + let rest = rest.strip_prefix('"')?; + let value = &rest[..rest.find('"')?]; + if value.is_empty() || value.contains('\\') { + return None; + } + Some(value.to_string()) +} + /// Canonical `projectHash`: SHA-256 hex of the absolute project path. pub fn project_hash(project_path: &str) -> String { let mut hasher = Sha256::new(); @@ -793,4 +871,82 @@ mod tests { assert!(!sessions.contains(&"sess-uuid-full".to_string())); assert_eq!(sessions.len(), 2); } + + #[test] + fn peek_session_id_reads_id_from_prefix_of_large_file() { + let (_temp, resolver) = setup(); + let chats = resolver.chats_dir("/proj").unwrap(); + fs::create_dir_all(&chats).unwrap(); + let pad = "x".repeat(16 * 1024); + let body = format!( + r#"{{"sessionId":"aaaa-bbbb","projectHash":"h","messages":[{{"content":"{pad}"}}]}}"# + ); + let path = chats.join("session-2026-01-01T00-00-aaaa.json"); + fs::write(&path, body).unwrap(); + assert_eq!(peek_session_id(&path).as_deref(), Some("aaaa-bbbb")); + } + + #[test] + fn peek_session_id_falls_back_when_identity_comes_late() { + let (_temp, resolver) = setup(); + let chats = resolver.chats_dir("/proj").unwrap(); + fs::create_dir_all(&chats).unwrap(); + let pad = "x".repeat(16 * 1024); + let body = format!(r#"{{"messages":[{{"content":"{pad}"}}],"sessionId":"late-id"}}"#); + let path = chats.join("session-2026-01-01T00-00-late.json"); + fs::write(&path, body).unwrap(); + assert_eq!(peek_session_id(&path).as_deref(), Some("late-id")); + } + + #[test] + fn peek_session_id_small_file_with_late_identity_still_resolves() { + let (_temp, resolver) = setup(); + let chats = resolver.chats_dir("/proj").unwrap(); + fs::create_dir_all(&chats).unwrap(); + // Fits inside the prefix, but the prefix scan must decline + // (identity after `messages`) and the serde fallback must win. + let path = chats.join("session-2026-01-01T00-00-tiny.json"); + fs::write(&path, r#"{"messages":[],"sessionId":"tiny-id"}"#).unwrap(); + assert_eq!(peek_session_id(&path).as_deref(), Some("tiny-id")); + } + + #[test] + fn prefix_session_id_rejects_keys_after_messages() { + assert_eq!( + prefix_session_id(br#"{"sessionId":"abc","messages":[]}"#).as_deref(), + Some("abc") + ); + assert_eq!( + prefix_session_id(br#"{"messages":[],"sessionId":"abc"}"#), + None, + "a sessionId after the messages key must not be trusted from the prefix" + ); + assert_eq!(prefix_session_id(br#"{"sessionId":""}"#), None); + } + + #[test] + fn list_session_entries_pairs_ids_with_backing_paths() { + let (_temp, resolver) = setup(); + let chats = resolver.chats_dir("/proj").unwrap(); + fs::create_dir_all(&chats).unwrap(); + let main = chats.join("session-2026-01-01T00-00-aaaa.json"); + fs::write(&main, r#"{"sessionId":"uuid-a","messages":[]}"#).unwrap(); + // uuid-a's sub-agent bucket is claimed by the main file; uuid-b + // is an orphan and must surface as its own session. + fs::create_dir_all(chats.join("uuid-a")).unwrap(); + fs::create_dir_all(chats.join("uuid-b")).unwrap(); + + let entries = resolver.list_session_entries("/proj").unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].id, "session-2026-01-01T00-00-aaaa"); + assert_eq!(entries[0].session_uuid.as_deref(), Some("uuid-a")); + assert_eq!(entries[0].path, main); + assert_eq!(entries[1].id, "uuid-b"); + assert_eq!(entries[1].session_uuid.as_deref(), Some("uuid-b")); + assert_eq!(entries[1].path, chats.join("uuid-b")); + + // The plain listing keeps returning the same ids. + let ids = resolver.list_sessions("/proj").unwrap(); + assert_eq!(ids, vec!["session-2026-01-01T00-00-aaaa", "uuid-b"]); + } } diff --git a/site/_data/crates.json b/site/_data/crates.json index dc86dffe..33564bef 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -33,7 +33,7 @@ }, { "name": "toolpath-claude", - "version": "0.12.0", + "version": "0.12.1", "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.6.1", "description": "Derive from Gemini CLI conversation logs", "docs": "https://docs.rs/toolpath-gemini", "crate": "https://crates.io/crates/toolpath-gemini", @@ -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",