From 63e429c574413192a72f323ed697187b40b1d64f Mon Sep 17 00:00:00 2001 From: Matthew Gribben Date: Thu, 13 Aug 2026 17:39:12 +1000 Subject: [PATCH 1/6] docs: add Cursor Agent source spec Normative spec for the cursor wire source and cursor-agent-transcript-jsonl container. Implementation of adapters is out of scope for this commit. --- docs/cursor-source-spec.md | 315 +++++++++++++++++++++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 docs/cursor-source-spec.md diff --git a/docs/cursor-source-spec.md b/docs/cursor-source-spec.md new file mode 100644 index 0000000..971665c --- /dev/null +++ b/docs/cursor-source-spec.md @@ -0,0 +1,315 @@ +# Spec: Cursor Agent source in Trajectory + +Status: **normative for implementation** +Wire source: `cursor` +Native container: `cursor-agent-transcript-jsonl` +Reference tree: Cursor Agent CLI (`agent` / `cursor-agent`, 2026.08.11-e8db854) under `~/.cursor` + +Related: + +- [Architecture](architecture.md) +- [Adapter authoring](adapter-authoring.md) +- [Listing](../contracts/spec/listing.md) +- [Identity](../contracts/spec/identity.md) +- [Timestamps](../contracts/spec/timestamps.md) +- [Diagnostics](../contracts/spec/diagnostics.md) +- [Streaming file sources](streaming-file-sources.md) +- Peer source: [Grok Build](grok-build-source-spec.md) + +--- + +## 1. Executive summary + +**Cursor Agent** (the `agent` / `cursor-agent` CLI, also used by Cursor IDE +Agent) persists each session as a JSONL transcript: + +```text +~/.cursor/ + projects// + repo.json + agent-transcripts//.jsonl # primary transcript + chats/// + meta.json # listing title / timestamps (optional join) + store.db # encrypted blob store — not v1 normalize input +``` + +Trajectory v1 treats **one session** as **one agent-transcript JSONL byte +stream**. Listing discovers those files under an explicit Cursor home root and +surfaces session UUIDs, paths, titles, and update times. + +```text +.jsonl → source adapter "cursor" → shared normalizer → IR → projections +``` + +--- + +## 2. Wire vocabulary + +| Field | Value | +| --- | --- | +| Wire source | `cursor` | +| Display | Cursor Agent | +| CLI aliases (non-wire) | `cursor-agent` → `cursor` | +| Native container | `cursor-agent-transcript-jsonl` | +| Default list root | `$CURSOR_HOME` when non-empty, else `~/.cursor` | +| Env override (samples) | `TRAJECTORY_CURSOR_ROOT`, `CURSOR_HOME` | + +Do not accept `cursor-cli`, `anysphere`, or `cursor-composer` on the wire +`source` field. + +--- + +## 3. Accepted container (v1) + +### Shape — agent transcript JSONL only + +Normalize input is the **exact UTF-8 bytes** of +`projects//agent-transcripts//.jsonl`: + +- One JSON object per line (optional final newline). +- Two observed top-level shapes (both valid in one file): + + 1. **Turn line** — `{ "role": "user" | "assistant", "message": { "content": [ ...parts ] } }` + 2. **Control line** — `{ "type": "turn_ended", "status": "success" | "error", "error"?: string }` + +- Content parts (v1 pin, observed on CLI 2026.08.11): + + | `type` | Fields | + | --- | --- | + | `text` | `text` (string) | + | `tool_use` | `name` (string), `input` (object). **No native id.** | + +**Not accepted as normalize input in v1:** `store.db` (encrypted), +`prompt_history.json`, `worker.log`, `repo.json`, MCP caches, terminals, +canvases, or Cursor IDE composer databases under +`~/Library/Application Support/Cursor`. + +Callers that list sessions should pass: + +```text +source_context.group_id = +``` + +Transcript lines do not embed the session id; without a supplied group id the +group falls through to the `default` sentinel (see identity contract). + +### Observed v1 limitations (do not invent around them) + +- **No tool results** in the JSONL. `tool_use` parts are call-only. Do not + synthesize fake results. Shared linking will leave those calls unmatched + (existing orphan/unmatched-call diagnostics apply). +- **No native record or tool-call ids.** Identity is location-based + (JSONL line start byte + component index). +- **No per-line timestamps.** Shared timestamp policy synthesizes body times. +- **No model id** on transcript lines. Session `lastUsedModel` lives only in + encrypted `store.db` / CLI meta — omit model unless a future transcript + field appears. Never invent provider/model from filenames. + +Unknown future fields on known objects are ignored (forward compatible), not +fatal. + +--- + +## 4. Listing + +See [listing.md](../contracts/spec/listing.md). Cursor rules: + +| Field | Rule | +| --- | --- | +| Discovery | `projects/*/agent-transcripts//.jsonl` under the Cursor home root (two extra directory levels after `projects/`) | +| `id` | Session directory name (UUID string). Filename stem must equal the directory name; otherwise skip the file | +| `path` | Absolute path to the `.jsonl` | +| `updated_at` | Matching `chats/*//meta.json` `updatedAtMs` (ms epoch → UTC); else transcript mtime | +| `title` | Matching `meta.json` `title` when non-empty; else bounded peek of first `role=user` text part (listing contract 64 KiB / 200 lines / 120 scalars) | +| `size_bytes` | Transcript file length | +| Missing root | Empty page | +| Non-transcript files | Ignored (`repo.json`, `worker.log`, `store.db`, terminals, MCP caches, …) | + +`chats//` is `md5(cwd)` on this CLI pin, but listing **must not** require +decoding CWD. Join meta by scanning `chats/*//meta.json` when that +tree exists under the same explicit root. A fixture that only has `projects/` +is valid — title then comes from the bounded peek. + +Default sample-CLI root: `$CURSOR_HOME` or `~/.cursor`. Conformance **never** +reads the developer home; it uses a declarative store fixture whose root is +the temporary `$ROOT` (typically containing `projects/` and optional `chats/`). + +--- + +## 5. Decode mapping + +### 5.1 Session meta + +Emit synthetic session meta with: + +- `source` / source name: `cursor` +- `model`: omit (not present on the JSONL) +- `cwd` / `git_branch`: omit from transcript-only input + +### 5.2 Line types + +| Line | IR emission | +| --- | --- | +| `role=user` | `Message` role **user**. Join `text` parts with `\n`. Then ignore non-text user parts per §5.3 | +| `role=assistant` | If any non-empty joined `text`: `Message` role **assistant**. Then for each `tool_use` part: tool-call component with `name`, `arguments` = compact JSON of `input` (empty object `{}` if `input` missing/non-object). Native tool id is **absent** → location identity (`tool-call` component index) | +| `type=turn_ended` | **Skip** (control record). If `status` is `error`, emit diagnostic `turn_ended_error` once per such line (content-safe; **do not** embed `error` text, paths, or raw JSON) | +| missing both `role` and `type` | Ignore the line (not a diagnostic) | +| unknown non-empty `type` (and no `role`) | Diagnostic `unknown_semantic_record`, skip line | +| unknown `role` | Diagnostic `unknown_semantic_record`, skip line | +| invalid JSON / non-object | Diagnostic `invalid_json_line` / `non_object_json_line`, skip line | +| blank lines | Ignore | + +Empty message content after trim is dropped (shared normalizer policy). An +assistant line that is only `tool_use` parts still emits the tool-call +components. + +### 5.3 Content parts + +| `type` | Rule | +| --- | --- | +| `text` | Include in the joined message body | +| `tool_use` | Tool-call as §5.2. `name` missing/empty → skip that part with diagnostic `tool_use_missing_name` | +| `image` / image-like | Drop with diagnostic `image_content_dropped` (once per source occurrence) | +| unknown non-empty part `type` | Diagnostic `unknown_content_part`, skip part | +| missing / empty part `type` | Ignore part | + +### 5.4 Identity anchors + +| Preference | Source | +| --- | --- | +| Native id | none on this pin | +| Location | UTF-8 **byte** offset of the JSONL line start in the input buffer; tool-call `component_index` distinguishes multiple `tool_use` parts on one line | +| Sequence | none required | + +### 5.5 Partial mode + +The transcript is append-only in normal use. Partial normalize accepts a byte +slice with `source_context.partial = true` and optional `base_byte_offset`. + +### 5.5.1 Live session streaming (LS-05) + +Core `apply_append` / `apply_snapshot` for Cursor: + +- Stream re-normalize uses `partial=true` and `base_byte_offset=0` over the + full committed complete-line prefix (oracle path). +- Incomplete lines remain in the stream pending buffer; they are not records. +- Pure prefix shrink → `source-truncated`. +- Non-prefix shorter rewrite → `source-replaced` (not `source-compacted`; + compaction is Grok Build-only). +- Completed lines are `stable` while the stream is open; `final` on `finish` + when `finalize_on_close` (default true). No Cursor-specific provisional + records in v1 (there are no synthetic tool results). +- See [`streaming-file-sources.md`](streaming-file-sources.md). + +Lock `group_id` from listing `id` (session UUID) when following from the sample +CLI, same reason as Grok Build: the filename is the session id and lines do +not carry it. + +### 5.6 Model invocations + +Do **not** invent model-invocation provenance from this container. + +--- + +## 6. Diagnostics (source-local codes) + +| Code | When | +| --- | --- | +| `invalid_json_line` | Line is not valid JSON | +| `non_object_json_line` | JSON value is not an object | +| `unknown_semantic_record` | Unknown `type` or `role` | +| `unknown_content_part` | Unknown content-part `type` | +| `tool_use_missing_name` | `tool_use` without a name | +| `image_content_dropped` | Image part omitted | +| `turn_ended_error` | `turn_ended` with `status=error` | +| plus shared codes | `timestamps_synthesized`, unmatched tool calls, bounds, etc. | + +--- + +## 7. Non-goals (v1) + +- Decoding encrypted `store.db` blobs +- Cursor IDE composer / bubble chats that are not `agent-transcripts` JSONL +- Reconstructing tool results that the JSONL does not persist +- Live Cursor API / ACP client inside core packages +- Reading `cli-config.json`, `authInfo`, API keys, or MCP server configs +- Nesting `Task` subagent sessions into one IR (each transcript file is its + own trajectory) + +--- + +## 8. Conformance + +Minimum shared cases under `conformance/cases/cursor/`: + +| Case | Intent | +| --- | --- | +| `full` | user text, assistant text + tools, `turn_ended` success | +| `tool-calls` | multiple `tool_use` on one assistant line; unmatched calls | +| `cleanup` | invalid JSON, non-object, unknown type/role/part, missing tool name, `turn_ended` error | +| `listing` | declarative store pagination (projects + optional chats meta) | +| `partial-chunk` | partial mode + base offset identity | +| `byte-identity` | location identity stable under prefix / offset | + +Streaming (under `conformance/cases/streaming/`): + +| Case | Intent | +| --- | --- | +| `cursor-append-sequence` | successive append-bytes ≡ prefix oracle (`stream-oracle-parity`) | + +Listing store: `conformance/stores/cursor-pagination/` (and title variant if +needed) with synthetic UUIDs and `$ROOT` paths only. + +Privacy: synthetic ids/paths only; no real home directories, emails, or live +transcripts. + +--- + +## 9. Implementation slices + +Clone the Grok Build / Claude Code adapter seams. Do not share IR types across +languages. + +| Slice | Deliverable | +| --- | --- | +| CU-01 | This spec + vocabulary in schemas (`conformance-case-v1`, streaming schemas, compatibility-manifest). **Do not** claim `cursor` in `compatibility.json` / runtime-capabilities until CU-06 | +| CU-02 | Shared cases + listing store fixture + streaming append sequence (goldens generated from a trusted local build, **hand-reviewed**) | +| CU-03 | Decode in all four runtimes (Python `sources/cursor.py`, Rust `normalize.rs`, TS `internal.ts`, .NET `Adapters/Cursor/`) | +| CU-04 | Listing in all four runtimes + `contracts/spec/listing.md` Cursor bullet | +| CU-05 | Stream source switch + sample CLIs (`--source cursor`, alias `cursor-agent`, default `~/.cursor`, file-stream allow-list) + `streaming-file-sources.md` / `streaming-file-io.md` rows | +| CU-06 | Advertise: `compatibility.json`, runtime-capabilities, README, CHANGELOG Unreleased, `EXPECTED_SOURCES`, docs index — **only after** four-runtime conformance is green | + +### File map (expected) + +- `docs/cursor-source-spec.md` (this file) +- `docs/adapter-authoring.md`, `docs/architecture.md`, `docs/streaming-file-sources.md`, `docs/streaming-file-io.md`, `docs/streaming-core-api.md` +- `contracts/spec/listing.md`, `contracts/schemas/*`, `contracts/compatibility.json` (CU-06) +- `conformance/cases/cursor/**`, `conformance/stores/cursor-*/`, `conformance/cases/streaming/cursor-append-sequence/` +- Python: `sources/cursor.py`, `listing/cursor.py`, enums/exports/tests +- Rust: `normalize.rs` / `listing.rs` / `streaming.rs` / `model.rs` +- TypeScript: `internal.ts`, `index.ts`, `streaming.ts`, `trajectory-node` listing, CLIs +- .NET: `Adapters/Cursor/*`, `TrajectoryIR.cs`, stream switch, sample CLI, tests +- Sample CLIs in all four languages; `tools/validate_release_metadata.py` + +--- + +## 10. Definition of done + +A Cursor source is complete when: + +1. Spec + schema vocabulary updated. +2. Shared normalize/list/stream cases pass on **every** runtime that advertises it. +3. Capability manifests agree (`cursor` in `implemented.sources` only after that). +4. Sample CLIs can `list` / `show` / `browse --watch` / `stream` with `--source cursor`. +5. Existing batch and stream conformance stays green; identity baseline updated only for new reviewed goldens. +6. No paths, secrets, raw lines, or native ids in diagnostics. + +## 11. Implementation checklist + +- [ ] Vocabulary in conformance + compatibility schemas +- [ ] Listing rules in `contracts/spec/listing.md` +- [ ] Shared cases + store fixture + stream append sequence +- [ ] .NET / TypeScript / Rust / Python decode + list +- [ ] CLI aliases and default roots; file-stream allow-list +- [ ] `compatibility.json` + runtime capability manifests only when all claiming runtimes pass +- [ ] README source list + Unreleased changelog From 47964019b452b5f328191d69b0a5e2a2b15bf7bd Mon Sep 17 00:00:00 2001 From: Matthew Gribben Date: Thu, 13 Aug 2026 18:39:34 +1000 Subject: [PATCH 2/6] feat(cursor): add Cursor Agent source across four runtimes Implement CU-01 through CU-06: schema vocabulary, shared normalize/list/stream fixtures, decode and listing adapters, sample CLI --source cursor (alias cursor-agent), and advertise cursor only after the four-runtime matrix is green. --- CHANGELOG.md | 2 + README.md | 5 +- .../cases/cursor/byte-identity/case.json | 8 + .../byte-identity/expected.canonical.json | 1 + .../cursor/byte-identity/expected.letta.json | 1 + .../cases/cursor/byte-identity/input.jsonl | 2 + conformance/cases/cursor/cleanup/case.json | 8 + .../cases/cursor/cleanup/expected.letta.json | 1 + conformance/cases/cursor/cleanup/input.jsonl | 7 + conformance/cases/cursor/full/case.json | 14 ++ .../cases/cursor/full/expected.canonical.json | 1 + .../cases/cursor/full/expected.letta.json | 1 + conformance/cases/cursor/full/input.jsonl | 5 + conformance/cases/cursor/listing/case.json | 9 + .../cursor/listing/expected.listing.json | 1 + conformance/cases/cursor/listing/input.jsonl | 1 + .../cases/cursor/partial-chunk/case.json | 8 + .../partial-chunk/expected.canonical.json | 1 + .../cursor/partial-chunk/expected.letta.json | 1 + .../cases/cursor/partial-chunk/input.jsonl | 2 + conformance/cases/cursor/tool-calls/case.json | 8 + .../cursor/tool-calls/expected.canonical.json | 1 + .../cursor/tool-calls/expected.letta.json | 1 + .../cases/cursor/tool-calls/input.jsonl | 3 + .../cursor-append-sequence/case.json | 18 ++ .../expected.append-assistant.json | 1 + .../expected.append-tool-ended.json | 1 + .../expected.append-user.json | 1 + .../cursor-append-sequence/step-1.jsonl | 1 + .../cursor-append-sequence/step-2.jsonl | 1 + .../cursor-append-sequence/step-3.jsonl | 2 + conformance/identity-baseline.sha256 | 8 + .../stores/cursor-pagination/store.json | 13 ++ contracts/compatibility.json | 3 +- .../compatibility-manifest-v1.schema.json | 3 +- .../schemas/conformance-case-v1.schema.json | 2 +- .../schemas/streaming-case-v1.schema.json | 3 +- .../schemas/streaming-cursor-v1.schema.json | 3 +- .../schemas/streaming-delta-v1.schema.json | 3 +- .../schemas/trajectory-stream-v1.schema.json | 3 +- contracts/spec/diagnostics.md | 3 + contracts/spec/listing.md | 15 ++ docs/adapter-authoring.md | 1 + docs/architecture.md | 2 +- docs/cursor-source-spec.md | 14 +- docs/live-session-streaming.md | 2 +- docs/release-readiness.md | 2 +- docs/streaming-core-api.md | 2 +- docs/streaming-file-io.md | 2 +- docs/streaming-file-sources.md | 6 +- dotnet/samples/Trajectory.Cli/Program.cs | 12 +- dotnet/samples/Trajectory.Cli/README.md | 5 +- .../Cursor/CursorJsonlSourceAdapter.cs | 151 ++++++++++++ .../Adapters/Cursor/CursorTrajectoryLister.cs | 74 ++++++ .../HypabolicTrajectoryV1OutputAdapter.cs | 1 + .../Letta/LettaCanonicalV1OutputAdapter.cs | 1 + dotnet/src/Trajectory/Diagnostics.cs | 3 + .../Generated/DefaultAdapterRegistry.g.cs | 3 + dotnet/src/Trajectory/Listing/ListingTitle.cs | 26 +++ .../Trajectory/Streaming/TrajectoryStream.cs | 1 + dotnet/src/Trajectory/TrajectoryConverter.cs | 7 + dotnet/src/Trajectory/TrajectoryIR.cs | 1 + .../src/Trajectory/runtime-capabilities.json | 3 +- .../tests/Trajectory.Conformance/Program.cs | 1 + .../ContractsAndConformanceTests.cs | 2 +- python/runtime-capabilities.json | 3 +- python/samples/trajectory_cli/README.md | 5 +- python/samples/trajectory_cli/cli.py | 16 +- python/src/hypabolic_trajectory/__init__.py | 1 + python/src/hypabolic_trajectory/_enums.py | 1 + .../src/hypabolic_trajectory/diagnostics.py | 6 + .../hypabolic_trajectory/listing/__init__.py | 1 + .../hypabolic_trajectory/listing/cursor.py | 121 ++++++++++ .../src/hypabolic_trajectory/listing/title.py | 22 ++ .../runtime-capabilities.json | 3 +- .../hypabolic_trajectory/sources/__init__.py | 1 + .../hypabolic_trajectory/sources/cursor.py | 221 ++++++++++++++++++ python/tests/test_py10_full.py | 1 + .../tests/test_py10b_sources_claude_codex.py | 1 + python/tests/test_py10b_sources_hermes.py | 1 + python/tests/test_py11_full_conformance.py | 11 +- python/tests/test_py15b_tip_gate.py | 2 +- python/tests/test_py17_first_ship_join.py | 10 +- python/tests/test_smoke.py | 2 + python/tools/trajectory_conformance/runner.py | 2 +- rust/README.md | 2 +- .../runtime-capabilities.json | 3 +- rust/crates/hypabolic-trajectory/src/lib.rs | 5 +- .../hypabolic-trajectory/src/listing.rs | 61 +++++ rust/crates/hypabolic-trajectory/src/model.rs | 3 + .../hypabolic-trajectory/src/normalize.rs | 83 +++++++ .../hypabolic-trajectory/src/streaming.rs | 3 +- rust/tools/trajectory-cli/README.md | 5 +- rust/tools/trajectory-cli/src/main.rs | 26 ++- rust/tools/trajectory-conformance/src/main.rs | 11 +- tools/validate_release_metadata.py | 2 +- typescript/packages/trajectory-cli/README.md | 5 +- typescript/packages/trajectory-cli/src/cli.ts | 18 +- .../packages/trajectory-node/src/index.ts | 65 ++++++ .../packages/trajectory-testing/src/cli.ts | 5 +- .../trajectory/runtime-capabilities.json | 3 +- typescript/packages/trajectory/src/index.ts | 6 +- .../packages/trajectory/src/internal.ts | 58 +++++ .../packages/trajectory/src/streaming.ts | 2 + 104 files changed, 1217 insertions(+), 78 deletions(-) create mode 100644 conformance/cases/cursor/byte-identity/case.json create mode 100644 conformance/cases/cursor/byte-identity/expected.canonical.json create mode 100644 conformance/cases/cursor/byte-identity/expected.letta.json create mode 100644 conformance/cases/cursor/byte-identity/input.jsonl create mode 100644 conformance/cases/cursor/cleanup/case.json create mode 100644 conformance/cases/cursor/cleanup/expected.letta.json create mode 100644 conformance/cases/cursor/cleanup/input.jsonl create mode 100644 conformance/cases/cursor/full/case.json create mode 100644 conformance/cases/cursor/full/expected.canonical.json create mode 100644 conformance/cases/cursor/full/expected.letta.json create mode 100644 conformance/cases/cursor/full/input.jsonl create mode 100644 conformance/cases/cursor/listing/case.json create mode 100644 conformance/cases/cursor/listing/expected.listing.json create mode 100644 conformance/cases/cursor/listing/input.jsonl create mode 100644 conformance/cases/cursor/partial-chunk/case.json create mode 100644 conformance/cases/cursor/partial-chunk/expected.canonical.json create mode 100644 conformance/cases/cursor/partial-chunk/expected.letta.json create mode 100644 conformance/cases/cursor/partial-chunk/input.jsonl create mode 100644 conformance/cases/cursor/tool-calls/case.json create mode 100644 conformance/cases/cursor/tool-calls/expected.canonical.json create mode 100644 conformance/cases/cursor/tool-calls/expected.letta.json create mode 100644 conformance/cases/cursor/tool-calls/input.jsonl create mode 100644 conformance/cases/streaming/cursor-append-sequence/case.json create mode 100644 conformance/cases/streaming/cursor-append-sequence/expected.append-assistant.json create mode 100644 conformance/cases/streaming/cursor-append-sequence/expected.append-tool-ended.json create mode 100644 conformance/cases/streaming/cursor-append-sequence/expected.append-user.json create mode 100644 conformance/cases/streaming/cursor-append-sequence/step-1.jsonl create mode 100644 conformance/cases/streaming/cursor-append-sequence/step-2.jsonl create mode 100644 conformance/cases/streaming/cursor-append-sequence/step-3.jsonl create mode 100644 conformance/stores/cursor-pagination/store.json create mode 100644 dotnet/src/Trajectory/Adapters/Cursor/CursorJsonlSourceAdapter.cs create mode 100644 dotnet/src/Trajectory/Adapters/Cursor/CursorTrajectoryLister.cs create mode 100644 python/src/hypabolic_trajectory/listing/cursor.py create mode 100644 python/src/hypabolic_trajectory/sources/cursor.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ea6f1b5..36f4e36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ remain synchronized across NuGet, npm, and crates.io preview artifacts. ## [Unreleased] +- **Source:** added Cursor Agent (`cursor`) transcript JSONL decoding, listing, + and file streaming across all four runtimes. - **Docs:** removed delivery-plan / status trackers (`implementation-plan`, `multi-language-plan`, `live-session-streaming-plan`, `live-session-streaming-status`, `python-implementation-spec`, diff --git a/README.md b/README.md index 05e0e45..d906b73 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ GitHub Release. See [docs/publishing.md](docs/publishing.md). ## What you get - **Multi-source ingest** — Pi, Claude Code, Codex, OpenClaw, Hermes, AHP - (Shape A offline ChatState snapshots), and Grok Build (`grok-build`) + (Shape A offline ChatState snapshots), Grok Build (`grok-build`), and Cursor Agent (`cursor`) - **Deterministic normalization** — stable IDs, ordering, hashes, content-safe diagnostics - **Multiple outputs** from one decode: Hypabolic trajectory, canonical @@ -251,6 +251,7 @@ timestamps, identity formulas, OTEL import matrix, filtered conformance argv). | Hermes | Message array or `{ session, messages }` JSON | Export file; core listing is SQLite-free | | AHP | Shape A chat snapshot `{ chat, session? }` JSON | Export file only; listing is Phase 3 (empty stub) | | Grok Build | `chat_history.jsonl` (alias `grok`) | `$GROK_HOME/sessions` or `~/.grok/sessions` | +| Cursor Agent | `cursor-agent-transcript-jsonl` (alias `cursor-agent`) | `$CURSOR_HOME` or `~/.cursor` | Override listing roots with `--root` / `TRAJECTORY__ROOT` in the sample CLIs, or pass an explicit root to listing APIs. @@ -407,7 +408,7 @@ Shared flags: | Flag | Meaning | | --- | --- | -| `--source ` | `pi`, `claude-code`, `codex`, `openclaw`, `hermes`, `ahp`, `grok-build` (alias `grok`) | +| `--source ` | `pi`, `claude-code`, `codex`, `openclaw`, `hermes`, `ahp`, `grok-build`, `cursor` (aliases `grok`, `cursor-agent`) | | `--root ` | Override store root | | `--limit ` | Listing page size (default 50) | | `--format ` | `both` (default), `messages`, or `hypabolic` | diff --git a/conformance/cases/cursor/byte-identity/case.json b/conformance/cases/cursor/byte-identity/case.json new file mode 100644 index 0000000..3fb4ce9 --- /dev/null +++ b/conformance/cases/cursor/byte-identity/case.json @@ -0,0 +1,8 @@ +{ + "$schema": "../../../../contracts/schemas/conformance-case-v1.schema.json", + "case_version": 1, "id": "cursor/byte-identity", + "operation": {"normalize-letta": {"expected": "expected.letta.json", "comparison": "json-exact"}, "normalize-canonical": {"expected": "expected.canonical.json", "comparison": "json-exact"}}, + "source": "cursor", "transcript": "input.jsonl", "native_container": "cursor-agent-transcript-jsonl", + "source_context": {"group_id": "019f0000-0000-7000-8000-00000000c005", "partial": true, "base_byte_offset": 100}, "bounds": {}, "filters": {}, "projection_options": {}, "mode": "partial", + "required_capabilities": ["normalize", "normalize-partial", "typed-diagnostics", "deterministic-rerun"], "expected": {"result": "diagnostics", "diagnostic_codes": ["tool_call_id_synthesized", "timestamps_synthesized"]}, "comparison": "json-exact" +} diff --git a/conformance/cases/cursor/byte-identity/expected.canonical.json b/conformance/cases/cursor/byte-identity/expected.canonical.json new file mode 100644 index 0000000..2952d9b --- /dev/null +++ b/conformance/cases/cursor/byte-identity/expected.canonical.json @@ -0,0 +1 @@ +{"records":[{"source_type":"cursor","source_group_id":"019f0000-0000-7000-8000-00000000c005","stable_source_record_id":"794cd6244d15dc23087c4f46a0fb66716d93cb5c46af7ab414636a653cc067b5","source_identity_kind":"location","source_order_id":"1|0000-00-00T00:00:00.001Z|00000000000000000000|794cd6244d15dc23087c4f46a0fb66716d93cb5c46af7ab414636a653cc067b5","component_index":0,"record_type":"user","record_id":"c2a146bc4f8c4bd1d6bdb11cd3e50c9f682080229671f291cba4a572dc7124e3","record_hash":"1a783c3a645ad7625bdd4cfa0978030fa120bafbe2f151c59dcb5333ef8cbc55","content_hash":"5fbc4375f5663257a9217c0790626420f84e06a1374ea52e4edd45e1476571e3","source_timestamp":null,"record_timestamp":"2026-01-01T00:00:00.000Z","content":"café \uD83D\uDE80","tool_call_id":null,"tool_name":null,"tool_arguments_json":null,"tool_result_json":null,"record_json":"{\"content\":\"café \\uD83D\\uDE80\",\"role\":\"user\",\"timestamp\":\"2026-01-01T00:00:00.000Z\"}"},{"source_type":"cursor","source_group_id":"019f0000-0000-7000-8000-00000000c005","stable_source_record_id":"27e6cd3c2c9c8e595b169f76526a83931640f8894cc124257a85ca6efc74f586","source_identity_kind":"location","source_order_id":"1|0000-00-00T00:00:00.001Z|00000000000000000000|27e6cd3c2c9c8e595b169f76526a83931640f8894cc124257a85ca6efc74f586","component_index":0,"record_type":"assistant","record_id":"081bf4725a9b52917b8aa4f9b2f5ede056391d1127dafd59816a7425df9b431a","record_hash":"7cfe633def87ad6c849485d2d5dc59f4593875ef67bc816b03720e2bb72b21fe","content_hash":"1d6b4cd339ff333544c224663a177298cbe9d2a85b8547d0554f041033f34838","source_timestamp":null,"record_timestamp":"2026-01-01T00:00:15.000Z","content":"Ready.","tool_call_id":null,"tool_name":null,"tool_arguments_json":null,"tool_result_json":null,"record_json":"{\"content\":\"Ready.\",\"role\":\"assistant\",\"timestamp\":\"2026-01-01T00:00:15.000Z\"}"},{"source_type":"cursor","source_group_id":"019f0000-0000-7000-8000-00000000c005","stable_source_record_id":"27e6cd3c2c9c8e595b169f76526a83931640f8894cc124257a85ca6efc74f586","source_identity_kind":"location","source_order_id":"1|0000-00-00T00:00:00.001Z|00000000000000000000|27e6cd3c2c9c8e595b169f76526a83931640f8894cc124257a85ca6efc74f586","component_index":1,"record_type":"assistant-tool-call","record_id":"baeb465e971f9f34ffa07dc7f4bc7262115391f84007588b5e21e4d5bd029d3c","record_hash":"8d8cce2f4ac84457a17e5277cbcc11d04bf911c4df5c9031dc493a21dd16fc53","content_hash":"b1b7237cacbadf1811823159a094c36ba8131cb6f82bcfbbeb45a2929131d7ec","source_timestamp":null,"record_timestamp":"2026-01-01T00:00:30.000Z","content":null,"tool_call_id":"call_3","tool_name":"scan","tool_arguments_json":"{\"query\":\"\\uD83D\\uDE80\"}","tool_result_json":null,"record_json":"{\"content\":null,\"role\":\"assistant\",\"timestamp\":\"2026-01-01T00:00:30.000Z\",\"tool_calls\":[{\"args\":\"{\\\"query\\\":\\\"\\\\uD83D\\\\uDE80\\\"}\",\"id\":\"call_3\",\"name\":\"scan\"}]}"}],"diagnostics":[{"code":"tool_call_id_synthesized","message":"Synthesized tool-call ID \"call_3\".","inputLine":2,"recordIndex":3},{"code":"timestamps_synthesized","message":"Synthesized timestamps for 3 normalized records.","count":3}],"normalizer_version":"0.2.0","canonical_schema_version":1,"config":{"bounds":{"toolArguments":{"maxCharacters":20000},"toolResults":{"maxCharacters":2500,"strategy":"head-tail"}},"filters":{"toolResults":"include"}}} \ No newline at end of file diff --git a/conformance/cases/cursor/byte-identity/expected.letta.json b/conformance/cases/cursor/byte-identity/expected.letta.json new file mode 100644 index 0000000..ab63d16 --- /dev/null +++ b/conformance/cases/cursor/byte-identity/expected.letta.json @@ -0,0 +1 @@ +{"records":[{"role":"meta","source":"cursor"},{"role":"user","content":"café \uD83D\uDE80","timestamp":"2026-01-01T00:00:00.000Z"},{"role":"assistant","content":"Ready.","timestamp":"2026-01-01T00:00:15.000Z"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_3","name":"scan","args":"{\"query\":\"\\uD83D\\uDE80\"}"}],"timestamp":"2026-01-01T00:00:30.000Z"}],"diagnostics":[{"code":"tool_call_id_synthesized","message":"Synthesized tool-call ID \"call_3\".","inputLine":2,"recordIndex":3},{"code":"timestamps_synthesized","message":"Synthesized timestamps for 3 normalized records.","count":3}]} \ No newline at end of file diff --git a/conformance/cases/cursor/byte-identity/input.jsonl b/conformance/cases/cursor/byte-identity/input.jsonl new file mode 100644 index 0000000..b892698 --- /dev/null +++ b/conformance/cases/cursor/byte-identity/input.jsonl @@ -0,0 +1,2 @@ +{"role":"user","message":{"content":[{"type":"text","text":"café 🚀"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"Ready."},{"type":"tool_use","name":"scan","input":{"query":"🚀"}}]}} diff --git a/conformance/cases/cursor/cleanup/case.json b/conformance/cases/cursor/cleanup/case.json new file mode 100644 index 0000000..0b6560b --- /dev/null +++ b/conformance/cases/cursor/cleanup/case.json @@ -0,0 +1,8 @@ +{ + "$schema": "../../../../contracts/schemas/conformance-case-v1.schema.json", + "case_version": 1, "id": "cursor/cleanup", + "operation": {"normalize-letta": {"expected": "expected.letta.json", "comparison": "json-exact"}}, + "source": "cursor", "transcript": "input.jsonl", "native_container": "cursor-agent-transcript-jsonl", + "source_context": {"group_id": "019f0000-0000-7000-8000-00000000c003"}, "bounds": {}, "filters": {}, "projection_options": {}, "mode": "whole", + "required_capabilities": ["normalize", "typed-diagnostics", "deterministic-rerun"], "expected": {"result": "diagnostics", "diagnostic_codes": ["invalid_json_line", "non_object_json_line", "unknown_semantic_record", "unknown_semantic_record", "image_content_dropped", "unknown_content_part", "tool_use_missing_name", "turn_ended_error", "timestamps_synthesized"]}, "comparison": "json-exact" +} diff --git a/conformance/cases/cursor/cleanup/expected.letta.json b/conformance/cases/cursor/cleanup/expected.letta.json new file mode 100644 index 0000000..f34c495 --- /dev/null +++ b/conformance/cases/cursor/cleanup/expected.letta.json @@ -0,0 +1 @@ +{"records":[{"role":"meta","source":"cursor"},{"role":"user","content":"Clean this up.","timestamp":"2026-01-01T00:00:00.000Z"},{"role":"assistant","content":"Done.","timestamp":"2026-01-01T00:00:15.000Z"}],"diagnostics":[{"code":"invalid_json_line","message":"Skipped invalid JSON on line 1.","inputLine":1},{"code":"non_object_json_line","message":"Skipped non-object JSON on line 2.","inputLine":2},{"code":"unknown_semantic_record","message":"Skipped an unknown Cursor semantic record on line 3.","inputLine":3},{"code":"unknown_semantic_record","message":"Skipped an unknown Cursor semantic record on line 4.","inputLine":4},{"code":"image_content_dropped","message":"Dropped image content on a Cursor record on line 5.","inputLine":5},{"code":"unknown_content_part","message":"Skipped an unknown Cursor content part on line 5.","inputLine":5},{"code":"tool_use_missing_name","message":"Skipped a Cursor tool_use part without a name on line 6.","inputLine":6},{"code":"turn_ended_error","message":"A Cursor turn ended with an error.","inputLine":7},{"code":"timestamps_synthesized","message":"Synthesized timestamps for 2 normalized records.","count":2}]} \ No newline at end of file diff --git a/conformance/cases/cursor/cleanup/input.jsonl b/conformance/cases/cursor/cleanup/input.jsonl new file mode 100644 index 0000000..ffc9078 --- /dev/null +++ b/conformance/cases/cursor/cleanup/input.jsonl @@ -0,0 +1,7 @@ +not valid json +[1,2] +{"type":"future_record","secret":"SECRET_TOKEN_xyz"} +{"role":"sideways","message":{"content":[]}} +{"role":"user","message":{"content":[{"type":"text","text":"Clean this up."},{"type":"image","url":"/Users/real-user/image.png"},{"type":"future_part"}]}} +{"role":"assistant","message":{"content":[{"type":"tool_use","input":{"x":1}},{"type":"text","text":"Done."}]}} +{"type":"turn_ended","status":"error","error":"SECRET_TOKEN_xyz /Users/real-user/"} diff --git a/conformance/cases/cursor/full/case.json b/conformance/cases/cursor/full/case.json new file mode 100644 index 0000000..9bbe6ef --- /dev/null +++ b/conformance/cases/cursor/full/case.json @@ -0,0 +1,14 @@ +{ + "$schema": "../../../../contracts/schemas/conformance-case-v1.schema.json", + "case_version": 1, + "id": "cursor/full", + "operation": {"normalize-letta": {"expected": "expected.letta.json", "comparison": "json-exact"}, "normalize-canonical": {"expected": "expected.canonical.json", "comparison": "json-exact"}}, + "source": "cursor", + "transcript": "input.jsonl", + "native_container": "cursor-agent-transcript-jsonl", + "source_context": {"group_id": "019f0000-0000-7000-8000-00000000c001"}, + "bounds": {}, "filters": {}, "projection_options": {}, "mode": "whole", + "required_capabilities": ["normalize", "typed-diagnostics", "deterministic-rerun"], + "expected": {"result": "diagnostics", "diagnostic_codes": ["tool_call_id_synthesized", "timestamps_synthesized"]}, + "comparison": "json-exact" +} diff --git a/conformance/cases/cursor/full/expected.canonical.json b/conformance/cases/cursor/full/expected.canonical.json new file mode 100644 index 0000000..27d3ce0 --- /dev/null +++ b/conformance/cases/cursor/full/expected.canonical.json @@ -0,0 +1 @@ +{"records":[{"source_type":"cursor","source_group_id":"019f0000-0000-7000-8000-00000000c001","stable_source_record_id":"meta","source_identity_kind":"synthetic","source_order_id":"0|0000-00-00T00:00:00.000Z|00000000000000000000|meta","component_index":0,"record_type":"meta","record_id":"a48360c82b4d6a8be92235d81029bb1f4d44d994d10e4e2ef1a1f71c3e797d2a","record_hash":"449874414212c07c7abf9f676bbf20782bd243c445ba0c7f8bf2c0d925cbc070","content_hash":"644db2fb5dd68058fc4ac19e1547faa6c25e90f4a40c59c0f3e2b31a180816d1","source_timestamp":null,"record_timestamp":null,"content":null,"tool_call_id":null,"tool_name":null,"tool_arguments_json":null,"tool_result_json":null,"record_json":"{\"role\":\"meta\",\"source\":\"cursor\"}"},{"source_type":"cursor","source_group_id":"019f0000-0000-7000-8000-00000000c001","stable_source_record_id":"0372e0a985c7f3de0a15a61b32073b3a133fea685b033e59cc2c5074a78521d4","source_identity_kind":"location","source_order_id":"1|0000-00-00T00:00:00.001Z|00000000000000000000|0372e0a985c7f3de0a15a61b32073b3a133fea685b033e59cc2c5074a78521d4","component_index":0,"record_type":"user","record_id":"76623fb8e7a0527b9523622cadaa128bbd7bf6c5b2779aa6c690069bab779634","record_hash":"710a11c2df31b1512724eeb457697c6079db30c58f4c17adaa6b4b758b2f833c","content_hash":"9f3b669d230f4e650956f3054f808cb27277b2c2f5aefecc929b6cb9a088865b","source_timestamp":null,"record_timestamp":"2026-01-01T00:00:00.000Z","content":"List files in src.","tool_call_id":null,"tool_name":null,"tool_arguments_json":null,"tool_result_json":null,"record_json":"{\"content\":\"List files in src.\",\"role\":\"user\",\"timestamp\":\"2026-01-01T00:00:00.000Z\"}"},{"source_type":"cursor","source_group_id":"019f0000-0000-7000-8000-00000000c001","stable_source_record_id":"94b8ae1da3897d9f0e72ab012e5ae1986d1bbdf9b18e7843b93d10639112fa76","source_identity_kind":"location","source_order_id":"1|0000-00-00T00:00:00.001Z|00000000000000000000|94b8ae1da3897d9f0e72ab012e5ae1986d1bbdf9b18e7843b93d10639112fa76","component_index":0,"record_type":"assistant","record_id":"955f794904a1b12a47e46feb5887929ef2cee2710992ed628170515cf89811ee","record_hash":"236173971df5b5118d91ab3e09e4eec672bdce45fe754345dd9f893a20b0ffa3","content_hash":"042f7d72ad55d352ac9c255a0fa9631355f4ab320d107b22882b2e1bf72698d2","source_timestamp":null,"record_timestamp":"2026-01-01T00:00:15.000Z","content":"I'll list src.","tool_call_id":null,"tool_name":null,"tool_arguments_json":null,"tool_result_json":null,"record_json":"{\"content\":\"I'll list src.\",\"role\":\"assistant\",\"timestamp\":\"2026-01-01T00:00:15.000Z\"}"},{"source_type":"cursor","source_group_id":"019f0000-0000-7000-8000-00000000c001","stable_source_record_id":"94b8ae1da3897d9f0e72ab012e5ae1986d1bbdf9b18e7843b93d10639112fa76","source_identity_kind":"location","source_order_id":"1|0000-00-00T00:00:00.001Z|00000000000000000000|94b8ae1da3897d9f0e72ab012e5ae1986d1bbdf9b18e7843b93d10639112fa76","component_index":1,"record_type":"assistant-tool-call","record_id":"f77c73e709438bca87a2e3ddf0a6c45f1cfe8c714c497f8ce46305eac0792d65","record_hash":"5a69abef843b1fbad333530e7b6546cbaa709cab74b875dc80bd0d85256f54cf","content_hash":"eef0fac9f98b11fb69060b524fb94f77ce14268c7b4a3ede36506c3d9a6dcd0b","source_timestamp":null,"record_timestamp":"2026-01-01T00:00:30.000Z","content":null,"tool_call_id":"call_3","tool_name":"run_terminal_command","tool_arguments_json":"{\"command\":\"ls src\"}","tool_result_json":null,"record_json":"{\"content\":null,\"role\":\"assistant\",\"timestamp\":\"2026-01-01T00:00:30.000Z\",\"tool_calls\":[{\"args\":\"{\\\"command\\\":\\\"ls src\\\"}\",\"id\":\"call_3\",\"name\":\"run_terminal_command\"}]}"},{"source_type":"cursor","source_group_id":"019f0000-0000-7000-8000-00000000c001","stable_source_record_id":"9b8fd64fcb6f5d416860d85998c76dd4a2ce1f938fef1b0c559a54457c57600f","source_identity_kind":"location","source_order_id":"1|0000-00-00T00:00:00.001Z|00000000000000000000|9b8fd64fcb6f5d416860d85998c76dd4a2ce1f938fef1b0c559a54457c57600f","component_index":0,"record_type":"assistant","record_id":"10f3bb9359a8297638cf59bb83bf14be7edf413345b6beab0a35a7e7271219ee","record_hash":"c3ca376d04b831b901f786cdf3bf41da5d9fcd4d8726862a0d82e55e5b35e294","content_hash":"37fc41f38255dea64c81213c2402d4eeab4be2509a74c0dabbb41e96d4c25f39","source_timestamp":null,"record_timestamp":"2026-01-01T00:00:45.000Z","content":"src has main.rs and lib.rs.","tool_call_id":null,"tool_name":null,"tool_arguments_json":null,"tool_result_json":null,"record_json":"{\"content\":\"src has main.rs and lib.rs.\",\"role\":\"assistant\",\"timestamp\":\"2026-01-01T00:00:45.000Z\"}"}],"diagnostics":[{"code":"tool_call_id_synthesized","message":"Synthesized tool-call ID \"call_3\".","inputLine":2,"recordIndex":3},{"code":"timestamps_synthesized","message":"Synthesized timestamps for 4 normalized records.","count":4}],"normalizer_version":"0.2.0","canonical_schema_version":1,"config":{"bounds":{"toolArguments":{"maxCharacters":20000},"toolResults":{"maxCharacters":2500,"strategy":"head-tail"}},"filters":{"toolResults":"include"}}} \ No newline at end of file diff --git a/conformance/cases/cursor/full/expected.letta.json b/conformance/cases/cursor/full/expected.letta.json new file mode 100644 index 0000000..c674858 --- /dev/null +++ b/conformance/cases/cursor/full/expected.letta.json @@ -0,0 +1 @@ +{"records":[{"role":"meta","source":"cursor"},{"role":"user","content":"List files in src.","timestamp":"2026-01-01T00:00:00.000Z"},{"role":"assistant","content":"I'll list src.","timestamp":"2026-01-01T00:00:15.000Z"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_3","name":"run_terminal_command","args":"{\"command\":\"ls src\"}"}],"timestamp":"2026-01-01T00:00:30.000Z"},{"role":"assistant","content":"src has main.rs and lib.rs.","timestamp":"2026-01-01T00:00:45.000Z"}],"diagnostics":[{"code":"tool_call_id_synthesized","message":"Synthesized tool-call ID \"call_3\".","inputLine":2,"recordIndex":3},{"code":"timestamps_synthesized","message":"Synthesized timestamps for 4 normalized records.","count":4}]} \ No newline at end of file diff --git a/conformance/cases/cursor/full/input.jsonl b/conformance/cases/cursor/full/input.jsonl new file mode 100644 index 0000000..2223b74 --- /dev/null +++ b/conformance/cases/cursor/full/input.jsonl @@ -0,0 +1,5 @@ +{"role":"user","message":{"content":[{"type":"text","text":"List files in src."}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"I'll list src."},{"type":"tool_use","name":"run_terminal_command","input":{"command":"ls src"}}]}} +{"type":"turn_ended","status":"success"} +{"role":"assistant","message":{"content":[{"type":"text","text":"src has main.rs and lib.rs."}]}} +{"type":"turn_ended","status":"success"} diff --git a/conformance/cases/cursor/listing/case.json b/conformance/cases/cursor/listing/case.json new file mode 100644 index 0000000..a3316d8 --- /dev/null +++ b/conformance/cases/cursor/listing/case.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../contracts/schemas/conformance-case-v1.schema.json", + "case_version": 1, "id": "cursor/listing", + "operation": {"list-trajectories": {"expected": "expected.listing.json", "comparison": "json-exact"}}, + "source": "cursor", "transcript": "input.jsonl", "native_container": "declarative-store", + "store": "cursor-pagination", "listing": {"limit": 1, "all_pages": true}, + "source_context": {}, "bounds": {}, "filters": {}, "projection_options": {}, "mode": "whole", + "required_capabilities": ["list-explicit-root", "deterministic-rerun"], "expected": {"result": "success", "diagnostic_codes": []}, "comparison": "json-exact" +} diff --git a/conformance/cases/cursor/listing/expected.listing.json b/conformance/cases/cursor/listing/expected.listing.json new file mode 100644 index 0000000..ce9f399 --- /dev/null +++ b/conformance/cases/cursor/listing/expected.listing.json @@ -0,0 +1 @@ +[{"items":[{"id":"019f0000-0000-7000-8000-00000000c002","path":"$ROOT/store/projects/%2Fworkspace%2Fnew/agent-transcripts/019f0000-0000-7000-8000-00000000c002/019f0000-0000-7000-8000-00000000c002.jsonl","updated_at":"2026-04-03T00:00:00.000Z","title":"Meta title","size_bytes":87}],"next_cursor":"MQowCjAxOWYwMDAwLTAwMDAtNzAwMC04MDAwLTAwMDAwMDAwYzAwMg"},{"items":[{"id":"019f0000-0000-7000-8000-00000000c003","path":"$ROOT/store/projects/%2Fworkspace%2Fmid/agent-transcripts/019f0000-0000-7000-8000-00000000c003/019f0000-0000-7000-8000-00000000c003.jsonl","updated_at":"2026-04-02T00:00:00.000Z","title":"Mid Cursor session","size_bytes":84}],"next_cursor":"MQoxCjAxOWYwMDAwLTAwMDAtNzAwMC04MDAwLTAwMDAwMDAwYzAwMw"},{"items":[{"id":"019f0000-0000-7000-8000-00000000c001","path":"$ROOT/store/projects/%2Fworkspace%2Fold/agent-transcripts/019f0000-0000-7000-8000-00000000c001/019f0000-0000-7000-8000-00000000c001.jsonl","updated_at":"2026-04-01T00:00:00.000Z","title":"Older Cursor session","size_bytes":86}],"next_cursor":null}] \ No newline at end of file diff --git a/conformance/cases/cursor/listing/input.jsonl b/conformance/cases/cursor/listing/input.jsonl new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/conformance/cases/cursor/listing/input.jsonl @@ -0,0 +1 @@ +{} diff --git a/conformance/cases/cursor/partial-chunk/case.json b/conformance/cases/cursor/partial-chunk/case.json new file mode 100644 index 0000000..83f10f8 --- /dev/null +++ b/conformance/cases/cursor/partial-chunk/case.json @@ -0,0 +1,8 @@ +{ + "$schema": "../../../../contracts/schemas/conformance-case-v1.schema.json", + "case_version": 1, "id": "cursor/partial-chunk", + "operation": {"normalize-letta": {"expected": "expected.letta.json", "comparison": "json-exact"}, "normalize-canonical": {"expected": "expected.canonical.json", "comparison": "json-exact"}}, + "source": "cursor", "transcript": "input.jsonl", "native_container": "cursor-agent-transcript-jsonl", + "source_context": {"group_id": "019f0000-0000-7000-8000-00000000c004", "partial": true, "base_byte_offset": 0}, "bounds": {}, "filters": {}, "projection_options": {}, "mode": "partial", + "required_capabilities": ["normalize", "normalize-partial", "typed-diagnostics", "deterministic-rerun"], "expected": {"result": "diagnostics", "diagnostic_codes": ["timestamps_synthesized"]}, "comparison": "json-exact" +} diff --git a/conformance/cases/cursor/partial-chunk/expected.canonical.json b/conformance/cases/cursor/partial-chunk/expected.canonical.json new file mode 100644 index 0000000..4f11e2d --- /dev/null +++ b/conformance/cases/cursor/partial-chunk/expected.canonical.json @@ -0,0 +1 @@ +{"records":[{"source_type":"cursor","source_group_id":"019f0000-0000-7000-8000-00000000c004","stable_source_record_id":"meta","source_identity_kind":"synthetic","source_order_id":"0|0000-00-00T00:00:00.000Z|00000000000000000000|meta","component_index":0,"record_type":"meta","record_id":"2e600e600604b0436cad761281b849e510455398f7905d868c1f57b32fc3f558","record_hash":"449874414212c07c7abf9f676bbf20782bd243c445ba0c7f8bf2c0d925cbc070","content_hash":"644db2fb5dd68058fc4ac19e1547faa6c25e90f4a40c59c0f3e2b31a180816d1","source_timestamp":null,"record_timestamp":null,"content":null,"tool_call_id":null,"tool_name":null,"tool_arguments_json":null,"tool_result_json":null,"record_json":"{\"role\":\"meta\",\"source\":\"cursor\"}"},{"source_type":"cursor","source_group_id":"019f0000-0000-7000-8000-00000000c004","stable_source_record_id":"90facd2bc946d88f3930360775765888608651c06d6dd9b63187478e9f2e127d","source_identity_kind":"location","source_order_id":"1|0000-00-00T00:00:00.001Z|00000000000000000000|90facd2bc946d88f3930360775765888608651c06d6dd9b63187478e9f2e127d","component_index":0,"record_type":"user","record_id":"075448a588a25342f7af2a1fe6c18ddfe66a2560b0f3b6f16e9daf65ec9eb063","record_hash":"1a783c3a645ad7625bdd4cfa0978030fa120bafbe2f151c59dcb5333ef8cbc55","content_hash":"5fbc4375f5663257a9217c0790626420f84e06a1374ea52e4edd45e1476571e3","source_timestamp":null,"record_timestamp":"2026-01-01T00:00:00.000Z","content":"café \uD83D\uDE80","tool_call_id":null,"tool_name":null,"tool_arguments_json":null,"tool_result_json":null,"record_json":"{\"content\":\"café \\uD83D\\uDE80\",\"role\":\"user\",\"timestamp\":\"2026-01-01T00:00:00.000Z\"}"},{"source_type":"cursor","source_group_id":"019f0000-0000-7000-8000-00000000c004","stable_source_record_id":"c772c6e3e0e7c3737b5fc478e26a618627bce4d22af82e758baf4182b564588f","source_identity_kind":"location","source_order_id":"1|0000-00-00T00:00:00.001Z|00000000000000000000|c772c6e3e0e7c3737b5fc478e26a618627bce4d22af82e758baf4182b564588f","component_index":0,"record_type":"assistant","record_id":"e175005ccee33a0af260cf9edabd312986d761f0089affccdf29f0ebd62230df","record_hash":"8819175c580eee56c8814826c4ed5430c748357c6b2974cedac675de17de6fce","content_hash":"9ed4572c205f2cb4ce5fcd03779dd83461763167ea26716228b0b0b49ae2729d","source_timestamp":null,"record_timestamp":"2026-01-01T00:00:15.000Z","content":"Acknowledged.","tool_call_id":null,"tool_name":null,"tool_arguments_json":null,"tool_result_json":null,"record_json":"{\"content\":\"Acknowledged.\",\"role\":\"assistant\",\"timestamp\":\"2026-01-01T00:00:15.000Z\"}"}],"diagnostics":[{"code":"timestamps_synthesized","message":"Synthesized timestamps for 2 normalized records.","count":2}],"normalizer_version":"0.2.0","canonical_schema_version":1,"config":{"bounds":{"toolArguments":{"maxCharacters":20000},"toolResults":{"maxCharacters":2500,"strategy":"head-tail"}},"filters":{"toolResults":"include"}}} \ No newline at end of file diff --git a/conformance/cases/cursor/partial-chunk/expected.letta.json b/conformance/cases/cursor/partial-chunk/expected.letta.json new file mode 100644 index 0000000..7fe6aaf --- /dev/null +++ b/conformance/cases/cursor/partial-chunk/expected.letta.json @@ -0,0 +1 @@ +{"records":[{"role":"meta","source":"cursor"},{"role":"user","content":"café \uD83D\uDE80","timestamp":"2026-01-01T00:00:00.000Z"},{"role":"assistant","content":"Acknowledged.","timestamp":"2026-01-01T00:00:15.000Z"}],"diagnostics":[{"code":"timestamps_synthesized","message":"Synthesized timestamps for 2 normalized records.","count":2}]} \ No newline at end of file diff --git a/conformance/cases/cursor/partial-chunk/input.jsonl b/conformance/cases/cursor/partial-chunk/input.jsonl new file mode 100644 index 0000000..8e6eb38 --- /dev/null +++ b/conformance/cases/cursor/partial-chunk/input.jsonl @@ -0,0 +1,2 @@ +{"role":"user","message":{"content":[{"type":"text","text":"café 🚀"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"Acknowledged."}]}} diff --git a/conformance/cases/cursor/tool-calls/case.json b/conformance/cases/cursor/tool-calls/case.json new file mode 100644 index 0000000..3c78fa8 --- /dev/null +++ b/conformance/cases/cursor/tool-calls/case.json @@ -0,0 +1,8 @@ +{ + "$schema": "../../../../contracts/schemas/conformance-case-v1.schema.json", + "case_version": 1, "id": "cursor/tool-calls", + "operation": {"normalize-letta": {"expected": "expected.letta.json", "comparison": "json-exact"}, "normalize-canonical": {"expected": "expected.canonical.json", "comparison": "json-exact"}}, + "source": "cursor", "transcript": "input.jsonl", "native_container": "cursor-agent-transcript-jsonl", + "source_context": {"group_id": "019f0000-0000-7000-8000-00000000c002"}, "bounds": {}, "filters": {}, "projection_options": {}, "mode": "whole", + "required_capabilities": ["normalize", "typed-diagnostics", "deterministic-rerun"], "expected": {"result": "diagnostics", "diagnostic_codes": ["tool_call_id_synthesized", "tool_call_id_synthesized", "timestamps_synthesized"]}, "comparison": "json-exact" +} diff --git a/conformance/cases/cursor/tool-calls/expected.canonical.json b/conformance/cases/cursor/tool-calls/expected.canonical.json new file mode 100644 index 0000000..6029505 --- /dev/null +++ b/conformance/cases/cursor/tool-calls/expected.canonical.json @@ -0,0 +1 @@ +{"records":[{"source_type":"cursor","source_group_id":"019f0000-0000-7000-8000-00000000c002","stable_source_record_id":"meta","source_identity_kind":"synthetic","source_order_id":"0|0000-00-00T00:00:00.000Z|00000000000000000000|meta","component_index":0,"record_type":"meta","record_id":"a2edc9856debd2c5aeb7634c214f0565fe579332845eb3fc319224812284d8d2","record_hash":"449874414212c07c7abf9f676bbf20782bd243c445ba0c7f8bf2c0d925cbc070","content_hash":"644db2fb5dd68058fc4ac19e1547faa6c25e90f4a40c59c0f3e2b31a180816d1","source_timestamp":null,"record_timestamp":null,"content":null,"tool_call_id":null,"tool_name":null,"tool_arguments_json":null,"tool_result_json":null,"record_json":"{\"role\":\"meta\",\"source\":\"cursor\"}"},{"source_type":"cursor","source_group_id":"019f0000-0000-7000-8000-00000000c002","stable_source_record_id":"b14de40080fc3083b9ddae4a4a453384eec403b884712e0bebc13abcb55b71a1","source_identity_kind":"location","source_order_id":"1|0000-00-00T00:00:00.001Z|00000000000000000000|b14de40080fc3083b9ddae4a4a453384eec403b884712e0bebc13abcb55b71a1","component_index":0,"record_type":"user","record_id":"c3f382ebf5493b7af43fe0c486fb2b905af39d54bd23fc5e2476c1405488b17f","record_hash":"75e2351c33e239445b40ff4f38b429c64ce68a08386012dca4ff7f462b9dea45","content_hash":"f27fe41fad9d4e3d989f61ec749d6aa235527f68bded0e1cebadeb9fa5e25fae","source_timestamp":null,"record_timestamp":"2026-01-01T00:00:00.000Z","content":"Inspect the project.","tool_call_id":null,"tool_name":null,"tool_arguments_json":null,"tool_result_json":null,"record_json":"{\"content\":\"Inspect the project.\",\"role\":\"user\",\"timestamp\":\"2026-01-01T00:00:00.000Z\"}"},{"source_type":"cursor","source_group_id":"019f0000-0000-7000-8000-00000000c002","stable_source_record_id":"e41058c70c85d6e0887bf553aef6909fb8c99e8dc7c27372f44d411a7c3e9dc5","source_identity_kind":"location","source_order_id":"1|0000-00-00T00:00:00.001Z|00000000000000000000|e41058c70c85d6e0887bf553aef6909fb8c99e8dc7c27372f44d411a7c3e9dc5","component_index":0,"record_type":"assistant-tool-call","record_id":"facd70239189d8bb2d7342acbbddf9dfb6be486f859384aabf1cfd30176ae5a8","record_hash":"7448fdf1f31b1bf533368b784989b23bd4331023c71a4a8f3f90050fbe471a10","content_hash":"80ec43bc312c4432f7025d949e5da41aa18e3d7c0ac77bceeb94d6966a13e88b","source_timestamp":null,"record_timestamp":"2026-01-01T00:00:15.000Z","content":null,"tool_call_id":"call_2","tool_name":"read_file","tool_arguments_json":"{\"path\":\"README.md\"}","tool_result_json":null,"record_json":"{\"content\":null,\"role\":\"assistant\",\"timestamp\":\"2026-01-01T00:00:15.000Z\",\"tool_calls\":[{\"args\":\"{\\\"path\\\":\\\"README.md\\\"}\",\"id\":\"call_2\",\"name\":\"read_file\"}]}"},{"source_type":"cursor","source_group_id":"019f0000-0000-7000-8000-00000000c002","stable_source_record_id":"e41058c70c85d6e0887bf553aef6909fb8c99e8dc7c27372f44d411a7c3e9dc5","source_identity_kind":"location","source_order_id":"1|0000-00-00T00:00:00.001Z|00000000000000000000|e41058c70c85d6e0887bf553aef6909fb8c99e8dc7c27372f44d411a7c3e9dc5","component_index":1,"record_type":"assistant-tool-call","record_id":"ef69c85e83181bb23e5d97177f2a830ebd32a1924b2255cbb1cfee5f821cc01e","record_hash":"a7a0d298a3d23e8331324415be19527ec5992495bc85c4432b55f7b4a15e69b2","content_hash":"7c08e8af28fd5f368288321884a169a91030af69bbc9a00bdf9ae7d6c4f32357","source_timestamp":null,"record_timestamp":"2026-01-01T00:00:30.000Z","content":null,"tool_call_id":"call_3","tool_name":"list_files","tool_arguments_json":"{}","tool_result_json":null,"record_json":"{\"content\":null,\"role\":\"assistant\",\"timestamp\":\"2026-01-01T00:00:30.000Z\",\"tool_calls\":[{\"args\":\"{}\",\"id\":\"call_3\",\"name\":\"list_files\"}]}"},{"source_type":"cursor","source_group_id":"019f0000-0000-7000-8000-00000000c002","stable_source_record_id":"89e0046d128fbd9e3eb9edd91bfd71b2a2b3042c09fa77c068a14af592746e81","source_identity_kind":"location","source_order_id":"1|0000-00-00T00:00:00.001Z|00000000000000000000|89e0046d128fbd9e3eb9edd91bfd71b2a2b3042c09fa77c068a14af592746e81","component_index":0,"record_type":"assistant","record_id":"a3c0e2e8ec5c1b564e59b6819e5d4403b46fa25d2120f763174382b8cd8b2b01","record_hash":"751f27ada8c0574f51bd3e3a847414e2e5a5af008ba6ebee28b28772769b056b","content_hash":"16324b333855ec34ecd63721c579d5b472fdf6916cd32d0d2790e314aa182760","source_timestamp":null,"record_timestamp":"2026-01-01T00:00:45.000Z","content":"I found the project files.","tool_call_id":null,"tool_name":null,"tool_arguments_json":null,"tool_result_json":null,"record_json":"{\"content\":\"I found the project files.\",\"role\":\"assistant\",\"timestamp\":\"2026-01-01T00:00:45.000Z\"}"}],"diagnostics":[{"code":"tool_call_id_synthesized","message":"Synthesized tool-call ID \"call_2\".","inputLine":2,"recordIndex":2},{"code":"tool_call_id_synthesized","message":"Synthesized tool-call ID \"call_3\".","inputLine":2,"recordIndex":3},{"code":"timestamps_synthesized","message":"Synthesized timestamps for 4 normalized records.","count":4}],"normalizer_version":"0.2.0","canonical_schema_version":1,"config":{"bounds":{"toolArguments":{"maxCharacters":20000},"toolResults":{"maxCharacters":2500,"strategy":"head-tail"}},"filters":{"toolResults":"include"}}} \ No newline at end of file diff --git a/conformance/cases/cursor/tool-calls/expected.letta.json b/conformance/cases/cursor/tool-calls/expected.letta.json new file mode 100644 index 0000000..133c70f --- /dev/null +++ b/conformance/cases/cursor/tool-calls/expected.letta.json @@ -0,0 +1 @@ +{"records":[{"role":"meta","source":"cursor"},{"role":"user","content":"Inspect the project.","timestamp":"2026-01-01T00:00:00.000Z"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_2","name":"read_file","args":"{\"path\":\"README.md\"}"}],"timestamp":"2026-01-01T00:00:15.000Z"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_3","name":"list_files","args":"{}"}],"timestamp":"2026-01-01T00:00:30.000Z"},{"role":"assistant","content":"I found the project files.","timestamp":"2026-01-01T00:00:45.000Z"}],"diagnostics":[{"code":"tool_call_id_synthesized","message":"Synthesized tool-call ID \"call_2\".","inputLine":2,"recordIndex":2},{"code":"tool_call_id_synthesized","message":"Synthesized tool-call ID \"call_3\".","inputLine":2,"recordIndex":3},{"code":"timestamps_synthesized","message":"Synthesized timestamps for 4 normalized records.","count":4}]} \ No newline at end of file diff --git a/conformance/cases/cursor/tool-calls/input.jsonl b/conformance/cases/cursor/tool-calls/input.jsonl new file mode 100644 index 0000000..835442c --- /dev/null +++ b/conformance/cases/cursor/tool-calls/input.jsonl @@ -0,0 +1,3 @@ +{"role":"user","message":{"content":[{"type":"text","text":"Inspect the project."}]}} +{"role":"assistant","message":{"content":[{"type":"tool_use","name":"read_file","input":{"path":"README.md"}},{"type":"tool_use","name":"list_files","input":{}}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"I found the project files."}]}} diff --git a/conformance/cases/streaming/cursor-append-sequence/case.json b/conformance/cases/streaming/cursor-append-sequence/case.json new file mode 100644 index 0000000..335bcae --- /dev/null +++ b/conformance/cases/streaming/cursor-append-sequence/case.json @@ -0,0 +1,18 @@ +{ + "$schema":"../../../../contracts/schemas/streaming-case-v1.schema.json", + "case_version":1, + "id":"streaming/cursor-append-sequence", + "description":"Cursor Agent JSONL successive append chunks equal the full-prefix oracle.", + "source":"cursor", + "group_id":"stream-cursor-append-sequence", + "required_capabilities":["stream-core","stream-apply-append","stream-jsonl-framing","stream-full-snapshot","stream-record-delta","stream-cursor-v1","stream-file-jsonl"], + "options":{"delivery":"both","include_provisional":true,"require_complete_lines":true,"finalize_on_close":true,"reorder":"reject","reset_policy":"return-reset-required"}, + "steps":[ + {"id":"append-user","input":{"kind":"append-bytes","material":"step-1.jsonl","source_revision":"gen-0"},"expected":{"update_kind":"updated","complete":false,"result":"expected.append-user.json"},"double_invoke":true}, + {"id":"append-assistant","input":{"kind":"append-bytes","material":"step-2.jsonl","source_revision":"gen-0"},"expected":{"update_kind":"updated","complete":false,"result":"expected.append-assistant.json"},"double_invoke":true}, + {"id":"append-tool-ended","input":{"kind":"append-bytes","material":"step-3.jsonl","source_revision":"gen-0"},"expected":{"update_kind":"updated","complete":false,"result":"expected.append-tool-ended.json"},"double_invoke":true} + ], + "comparison":["stream-json-exact","stream-delta-apply","stream-idempotence","stream-oracle-parity"], + "oracle":{"prefix_re_normalize":true,"append_equals_prefix":true}, + "privacy":{"forbidden_substrings":["SECRET_TOKEN_xyz","/Users/real-user/","sk-live-","auth.json"]} +} diff --git a/conformance/cases/streaming/cursor-append-sequence/expected.append-assistant.json b/conformance/cases/streaming/cursor-append-sequence/expected.append-assistant.json new file mode 100644 index 0000000..6053422 --- /dev/null +++ b/conformance/cases/streaming/cursor-append-sequence/expected.append-assistant.json @@ -0,0 +1 @@ +{"kind":"updated","revision":{"revision":1,"revision_id":"d66452cabb1ca252406f306db4da8058c91b475678bcfb6eb5c268de44643289","parent_revision_id":"e3ca9a0b1f6886b935f85994797c2d790795aa2c744eaa1ca4e2b2808eef8775","complete":false,"generation":0},"cursor":{"cursor_version":1,"source":"cursor","group_id":"stream-cursor-append-sequence","generation":0,"position":{"kind":"byte","next_byte_offset":172,"pending_byte_length":0},"source_revision":"gen-0","prefix_sha256":"7ea8c852d15741c10fba573dae2f7143deda94dddfff3e92a8d37706080c1c63"},"snapshot":{"schema_id":"trajectory-stream-v1","source":"cursor","group_id":"stream-cursor-append-sequence","revision":{"revision":1,"revision_id":"d66452cabb1ca252406f306db4da8058c91b475678bcfb6eb5c268de44643289","parent_revision_id":"e3ca9a0b1f6886b935f85994797c2d790795aa2c744eaa1ca4e2b2808eef8775","complete":false,"generation":0},"records":[{"status":"stable","record":{"id":"9de276d87f5e8f7194e6431c328f40e51035f32992727c9713c20c7363e1b5c2","kind":"meta","role":"meta","order":-1,"source_timestamp":null,"timestamp":null,"source_name":"cursor","provenance":{"stable_source_record_id":"meta","source_identity_kind":"synthetic","source_order_id":"0|0000-00-00T00:00:00.000Z|00000000000000000000|meta","component_key":"meta","component_index":0,"component_type_ordinal":0},"hashes":{"content_sha256":"644db2fb5dd68058fc4ac19e1547faa6c25e90f4a40c59c0f3e2b31a180816d1","record_sha256":"449874414212c07c7abf9f676bbf20782bd243c445ba0c7f8bf2c0d925cbc070"}}},{"status":"stable","record":{"id":"aa8640af350fa97afe672fd0141d1d55cc83076945ba08645b5f8b53682fd102","kind":"message","role":"user","order":0,"source_timestamp":null,"timestamp":"2026-01-01T00:00:00.000Z","content":"Open the project.","provenance":{"stable_source_record_id":"6ebba93534b4d1d87c4de1ea853123f31f51415a8d90fd9c5f1af103ef7322a4","source_identity_kind":"location","source_order_id":"1|0000-00-00T00:00:00.001Z|00000000000000000000|6ebba93534b4d1d87c4de1ea853123f31f51415a8d90fd9c5f1af103ef7322a4","component_key":"message:0","component_index":0,"component_type_ordinal":0,"source_offset":0,"source_anchor_kind":"byte"},"hashes":{"content_sha256":"b8179191542122f8837300d64aab098703277a5324d73531198d5b85debca78d","record_sha256":"60eb6e0ee653a39c34e9350850ec89cff43b437ee365e9ea163fed291811682e"}}},{"status":"stable","record":{"id":"dd19522a3eb9b3da5caf8cf1dbc0e90cbe44976d125bcc228c27773a73a71e8c","kind":"message","role":"assistant","order":1,"source_timestamp":null,"timestamp":"2026-01-01T00:00:15.000Z","content":"I will inspect it.","provenance":{"stable_source_record_id":"92eff3d96b51c00de935069939fa09e9587fbc57d5e3b1cd8562635b1f098d1c","source_identity_kind":"location","source_order_id":"1|0000-00-00T00:00:00.001Z|00000000000000000000|92eff3d96b51c00de935069939fa09e9587fbc57d5e3b1cd8562635b1f098d1c","component_key":"message:0","component_index":0,"component_type_ordinal":0,"source_offset":83,"source_anchor_kind":"byte"},"hashes":{"content_sha256":"b817e200c7197cdc7825924377b2661b8859ca4554749eb6eef27c8b1ed101bd","record_sha256":"910d657b66a0d10b88631a383fdf579ced10b0d630700b890dc49cd23d30319d"}}}],"diagnostics":[{"code":"timestamps_synthesized","message":"Synthesized timestamps for 2 normalized records.","count":2}],"complete":false},"delta":{"schema_id":"trajectory-stream-v1","base_revision_id":"e3ca9a0b1f6886b935f85994797c2d790795aa2c744eaa1ca4e2b2808eef8775","revision":{"revision":1,"revision_id":"d66452cabb1ca252406f306db4da8058c91b475678bcfb6eb5c268de44643289","parent_revision_id":"e3ca9a0b1f6886b935f85994797c2d790795aa2c744eaa1ca4e2b2808eef8775","complete":false,"generation":0},"operations":[{"op":"upsert","record":{"status":"stable","record":{"id":"dd19522a3eb9b3da5caf8cf1dbc0e90cbe44976d125bcc228c27773a73a71e8c","kind":"message","role":"assistant","order":1,"source_timestamp":null,"timestamp":"2026-01-01T00:00:15.000Z","content":"I will inspect it.","provenance":{"stable_source_record_id":"92eff3d96b51c00de935069939fa09e9587fbc57d5e3b1cd8562635b1f098d1c","source_identity_kind":"location","source_order_id":"1|0000-00-00T00:00:00.001Z|00000000000000000000|92eff3d96b51c00de935069939fa09e9587fbc57d5e3b1cd8562635b1f098d1c","component_key":"message:0","component_index":0,"component_type_ordinal":0,"source_offset":83,"source_anchor_kind":"byte"},"hashes":{"content_sha256":"b817e200c7197cdc7825924377b2661b8859ca4554749eb6eef27c8b1ed101bd","record_sha256":"910d657b66a0d10b88631a383fdf579ced10b0d630700b890dc49cd23d30319d"}}}},{"op":"diagnostic_add","diagnostic":{"code":"timestamps_synthesized","message":"Synthesized timestamps for 2 normalized records.","count":2}}]},"diagnostics":[{"code":"timestamps_synthesized","message":"Synthesized timestamps for 2 normalized records.","count":2}],"provisional":{"include":true,"provisional_ids":[],"finalized_ids":[]},"consumed":{"complete_records":3,"bytes":89,"first_source_position":83,"last_source_position":171}} diff --git a/conformance/cases/streaming/cursor-append-sequence/expected.append-tool-ended.json b/conformance/cases/streaming/cursor-append-sequence/expected.append-tool-ended.json new file mode 100644 index 0000000..ae2e28f --- /dev/null +++ b/conformance/cases/streaming/cursor-append-sequence/expected.append-tool-ended.json @@ -0,0 +1 @@ +{"kind":"updated","revision":{"revision":2,"revision_id":"690df6dc06108226c9d0975652a2317135f4db23d40346220f43b8f31138927b","parent_revision_id":"d66452cabb1ca252406f306db4da8058c91b475678bcfb6eb5c268de44643289","complete":false,"generation":0},"cursor":{"cursor_version":1,"source":"cursor","group_id":"stream-cursor-append-sequence","generation":0,"position":{"kind":"byte","next_byte_offset":305,"pending_byte_length":0},"source_revision":"gen-0","prefix_sha256":"af4be485bc1aa0224309a36a7800371882824c46a72d0a37ca1fcdf5c0933297"},"snapshot":{"schema_id":"trajectory-stream-v1","source":"cursor","group_id":"stream-cursor-append-sequence","revision":{"revision":2,"revision_id":"690df6dc06108226c9d0975652a2317135f4db23d40346220f43b8f31138927b","parent_revision_id":"d66452cabb1ca252406f306db4da8058c91b475678bcfb6eb5c268de44643289","complete":false,"generation":0},"records":[{"status":"stable","record":{"id":"9de276d87f5e8f7194e6431c328f40e51035f32992727c9713c20c7363e1b5c2","kind":"meta","role":"meta","order":-1,"source_timestamp":null,"timestamp":null,"source_name":"cursor","provenance":{"stable_source_record_id":"meta","source_identity_kind":"synthetic","source_order_id":"0|0000-00-00T00:00:00.000Z|00000000000000000000|meta","component_key":"meta","component_index":0,"component_type_ordinal":0},"hashes":{"content_sha256":"644db2fb5dd68058fc4ac19e1547faa6c25e90f4a40c59c0f3e2b31a180816d1","record_sha256":"449874414212c07c7abf9f676bbf20782bd243c445ba0c7f8bf2c0d925cbc070"}}},{"status":"stable","record":{"id":"aa8640af350fa97afe672fd0141d1d55cc83076945ba08645b5f8b53682fd102","kind":"message","role":"user","order":0,"source_timestamp":null,"timestamp":"2026-01-01T00:00:00.000Z","content":"Open the project.","provenance":{"stable_source_record_id":"6ebba93534b4d1d87c4de1ea853123f31f51415a8d90fd9c5f1af103ef7322a4","source_identity_kind":"location","source_order_id":"1|0000-00-00T00:00:00.001Z|00000000000000000000|6ebba93534b4d1d87c4de1ea853123f31f51415a8d90fd9c5f1af103ef7322a4","component_key":"message:0","component_index":0,"component_type_ordinal":0,"source_offset":0,"source_anchor_kind":"byte"},"hashes":{"content_sha256":"b8179191542122f8837300d64aab098703277a5324d73531198d5b85debca78d","record_sha256":"60eb6e0ee653a39c34e9350850ec89cff43b437ee365e9ea163fed291811682e"}}},{"status":"stable","record":{"id":"dd19522a3eb9b3da5caf8cf1dbc0e90cbe44976d125bcc228c27773a73a71e8c","kind":"message","role":"assistant","order":1,"source_timestamp":null,"timestamp":"2026-01-01T00:00:15.000Z","content":"I will inspect it.","provenance":{"stable_source_record_id":"92eff3d96b51c00de935069939fa09e9587fbc57d5e3b1cd8562635b1f098d1c","source_identity_kind":"location","source_order_id":"1|0000-00-00T00:00:00.001Z|00000000000000000000|92eff3d96b51c00de935069939fa09e9587fbc57d5e3b1cd8562635b1f098d1c","component_key":"message:0","component_index":0,"component_type_ordinal":0,"source_offset":83,"source_anchor_kind":"byte"},"hashes":{"content_sha256":"b817e200c7197cdc7825924377b2661b8859ca4554749eb6eef27c8b1ed101bd","record_sha256":"910d657b66a0d10b88631a383fdf579ced10b0d630700b890dc49cd23d30319d"}}},{"status":"stable","record":{"id":"9ac8b886a4488355fca9b7ef85ae4b0690cdd785185a288139b322efaeaefe21","kind":"message","role":"assistant","order":2,"source_timestamp":null,"timestamp":"2026-01-01T00:00:30.000Z","content":"The project is ready.","provenance":{"stable_source_record_id":"b1b076e71b5a6e69bb10a8ebfb9adf671123ede0fea631562863ff8114d65a40","source_identity_kind":"location","source_order_id":"1|0000-00-00T00:00:00.001Z|00000000000000000000|b1b076e71b5a6e69bb10a8ebfb9adf671123ede0fea631562863ff8114d65a40","component_key":"message:0","component_index":0,"component_type_ordinal":0,"source_offset":172,"source_anchor_kind":"byte"},"hashes":{"content_sha256":"8c874e0a4608e1b2fdd1643f6ada7af244b0507369cc5da44bd61fc186d9d364","record_sha256":"2e0cb06065633009ad7f0d7e36cc09969e373b56ab0c76984e37d90231c85cf6"}}}],"diagnostics":[{"code":"timestamps_synthesized","message":"Synthesized timestamps for 3 normalized records.","count":3}],"complete":false},"delta":{"schema_id":"trajectory-stream-v1","base_revision_id":"d66452cabb1ca252406f306db4da8058c91b475678bcfb6eb5c268de44643289","revision":{"revision":2,"revision_id":"690df6dc06108226c9d0975652a2317135f4db23d40346220f43b8f31138927b","parent_revision_id":"d66452cabb1ca252406f306db4da8058c91b475678bcfb6eb5c268de44643289","complete":false,"generation":0},"operations":[{"op":"upsert","record":{"status":"stable","record":{"id":"9ac8b886a4488355fca9b7ef85ae4b0690cdd785185a288139b322efaeaefe21","kind":"message","role":"assistant","order":2,"source_timestamp":null,"timestamp":"2026-01-01T00:00:30.000Z","content":"The project is ready.","provenance":{"stable_source_record_id":"b1b076e71b5a6e69bb10a8ebfb9adf671123ede0fea631562863ff8114d65a40","source_identity_kind":"location","source_order_id":"1|0000-00-00T00:00:00.001Z|00000000000000000000|b1b076e71b5a6e69bb10a8ebfb9adf671123ede0fea631562863ff8114d65a40","component_key":"message:0","component_index":0,"component_type_ordinal":0,"source_offset":172,"source_anchor_kind":"byte"},"hashes":{"content_sha256":"8c874e0a4608e1b2fdd1643f6ada7af244b0507369cc5da44bd61fc186d9d364","record_sha256":"2e0cb06065633009ad7f0d7e36cc09969e373b56ab0c76984e37d90231c85cf6"}}}},{"op":"diagnostic_add","diagnostic":{"code":"timestamps_synthesized","message":"Synthesized timestamps for 3 normalized records.","count":3}}]},"diagnostics":[{"code":"timestamps_synthesized","message":"Synthesized timestamps for 3 normalized records.","count":3}],"provisional":{"include":true,"provisional_ids":[],"finalized_ids":[]},"consumed":{"complete_records":4,"bytes":133,"first_source_position":172,"last_source_position":304}} diff --git a/conformance/cases/streaming/cursor-append-sequence/expected.append-user.json b/conformance/cases/streaming/cursor-append-sequence/expected.append-user.json new file mode 100644 index 0000000..4b57b2d --- /dev/null +++ b/conformance/cases/streaming/cursor-append-sequence/expected.append-user.json @@ -0,0 +1 @@ +{"kind":"updated","revision":{"revision":0,"revision_id":"e3ca9a0b1f6886b935f85994797c2d790795aa2c744eaa1ca4e2b2808eef8775","parent_revision_id":null,"complete":false,"generation":0},"cursor":{"cursor_version":1,"source":"cursor","group_id":"stream-cursor-append-sequence","generation":0,"position":{"kind":"byte","next_byte_offset":83,"pending_byte_length":0},"source_revision":"gen-0","prefix_sha256":"28d346881ee2890b220372ffb2b11c241265832a2c6b624e8df17a15ee7c7049"},"snapshot":{"schema_id":"trajectory-stream-v1","source":"cursor","group_id":"stream-cursor-append-sequence","revision":{"revision":0,"revision_id":"e3ca9a0b1f6886b935f85994797c2d790795aa2c744eaa1ca4e2b2808eef8775","parent_revision_id":null,"complete":false,"generation":0},"records":[{"status":"stable","record":{"id":"9de276d87f5e8f7194e6431c328f40e51035f32992727c9713c20c7363e1b5c2","kind":"meta","role":"meta","order":-1,"source_timestamp":null,"timestamp":null,"source_name":"cursor","provenance":{"stable_source_record_id":"meta","source_identity_kind":"synthetic","source_order_id":"0|0000-00-00T00:00:00.000Z|00000000000000000000|meta","component_key":"meta","component_index":0,"component_type_ordinal":0},"hashes":{"content_sha256":"644db2fb5dd68058fc4ac19e1547faa6c25e90f4a40c59c0f3e2b31a180816d1","record_sha256":"449874414212c07c7abf9f676bbf20782bd243c445ba0c7f8bf2c0d925cbc070"}}},{"status":"stable","record":{"id":"aa8640af350fa97afe672fd0141d1d55cc83076945ba08645b5f8b53682fd102","kind":"message","role":"user","order":0,"source_timestamp":null,"timestamp":"2026-01-01T00:00:00.000Z","content":"Open the project.","provenance":{"stable_source_record_id":"6ebba93534b4d1d87c4de1ea853123f31f51415a8d90fd9c5f1af103ef7322a4","source_identity_kind":"location","source_order_id":"1|0000-00-00T00:00:00.001Z|00000000000000000000|6ebba93534b4d1d87c4de1ea853123f31f51415a8d90fd9c5f1af103ef7322a4","component_key":"message:0","component_index":0,"component_type_ordinal":0,"source_offset":0,"source_anchor_kind":"byte"},"hashes":{"content_sha256":"b8179191542122f8837300d64aab098703277a5324d73531198d5b85debca78d","record_sha256":"60eb6e0ee653a39c34e9350850ec89cff43b437ee365e9ea163fed291811682e"}}}],"diagnostics":[{"code":"timestamps_synthesized","message":"Synthesized timestamps for 1 normalized records.","count":1}],"complete":false},"delta":{"schema_id":"trajectory-stream-v1","base_revision_id":null,"revision":{"revision":0,"revision_id":"e3ca9a0b1f6886b935f85994797c2d790795aa2c744eaa1ca4e2b2808eef8775","parent_revision_id":null,"complete":false,"generation":0},"operations":[{"op":"upsert","record":{"status":"stable","record":{"id":"9de276d87f5e8f7194e6431c328f40e51035f32992727c9713c20c7363e1b5c2","kind":"meta","role":"meta","order":-1,"source_timestamp":null,"timestamp":null,"source_name":"cursor","provenance":{"stable_source_record_id":"meta","source_identity_kind":"synthetic","source_order_id":"0|0000-00-00T00:00:00.000Z|00000000000000000000|meta","component_key":"meta","component_index":0,"component_type_ordinal":0},"hashes":{"content_sha256":"644db2fb5dd68058fc4ac19e1547faa6c25e90f4a40c59c0f3e2b31a180816d1","record_sha256":"449874414212c07c7abf9f676bbf20782bd243c445ba0c7f8bf2c0d925cbc070"}}}},{"op":"upsert","record":{"status":"stable","record":{"id":"aa8640af350fa97afe672fd0141d1d55cc83076945ba08645b5f8b53682fd102","kind":"message","role":"user","order":0,"source_timestamp":null,"timestamp":"2026-01-01T00:00:00.000Z","content":"Open the project.","provenance":{"stable_source_record_id":"6ebba93534b4d1d87c4de1ea853123f31f51415a8d90fd9c5f1af103ef7322a4","source_identity_kind":"location","source_order_id":"1|0000-00-00T00:00:00.001Z|00000000000000000000|6ebba93534b4d1d87c4de1ea853123f31f51415a8d90fd9c5f1af103ef7322a4","component_key":"message:0","component_index":0,"component_type_ordinal":0,"source_offset":0,"source_anchor_kind":"byte"},"hashes":{"content_sha256":"b8179191542122f8837300d64aab098703277a5324d73531198d5b85debca78d","record_sha256":"60eb6e0ee653a39c34e9350850ec89cff43b437ee365e9ea163fed291811682e"}}}},{"op":"diagnostic_add","diagnostic":{"code":"timestamps_synthesized","message":"Synthesized timestamps for 1 normalized records.","count":1}}]},"diagnostics":[{"code":"timestamps_synthesized","message":"Synthesized timestamps for 1 normalized records.","count":1}],"provisional":{"include":true,"provisional_ids":[],"finalized_ids":[]},"consumed":{"complete_records":2,"bytes":83,"first_source_position":0,"last_source_position":82}} diff --git a/conformance/cases/streaming/cursor-append-sequence/step-1.jsonl b/conformance/cases/streaming/cursor-append-sequence/step-1.jsonl new file mode 100644 index 0000000..3d46aa9 --- /dev/null +++ b/conformance/cases/streaming/cursor-append-sequence/step-1.jsonl @@ -0,0 +1 @@ +{"role":"user","message":{"content":[{"type":"text","text":"Open the project."}]}} diff --git a/conformance/cases/streaming/cursor-append-sequence/step-2.jsonl b/conformance/cases/streaming/cursor-append-sequence/step-2.jsonl new file mode 100644 index 0000000..366e377 --- /dev/null +++ b/conformance/cases/streaming/cursor-append-sequence/step-2.jsonl @@ -0,0 +1 @@ +{"role":"assistant","message":{"content":[{"type":"text","text":"I will inspect it."}]}} diff --git a/conformance/cases/streaming/cursor-append-sequence/step-3.jsonl b/conformance/cases/streaming/cursor-append-sequence/step-3.jsonl new file mode 100644 index 0000000..428a095 --- /dev/null +++ b/conformance/cases/streaming/cursor-append-sequence/step-3.jsonl @@ -0,0 +1,2 @@ +{"role":"assistant","message":{"content":[{"type":"text","text":"The project is ready."}]}} +{"type":"turn_ended","status":"success"} diff --git a/conformance/identity-baseline.sha256 b/conformance/identity-baseline.sha256 index 6ec55c8..dc84f8e 100644 --- a/conformance/identity-baseline.sha256 +++ b/conformance/identity-baseline.sha256 @@ -6,6 +6,14 @@ cfc8c476785991f134d7cdaf544f9964e21f3fec5b0b03fba1bf83bbe58bcbc4 conformance/ca a6a92f3a06ebcf0e4705c4de23b88d5387a1e9b8413af8e42aacab04bfad5b7d conformance/cases/claude-code/tool-call/expected.letta.json d5372a5c5a79f01a6805a39c69f7f46ac021abfcdf3293e1f6ede1daed2209d0 conformance/cases/codex/full/expected.canonical.json feee2ae51ea1ccfeef9b83b234bc7bfa908d4ebe75cbb3f042f4ec587f1554e4 conformance/cases/codex/full/expected.letta.json +05ff31964b9898698b08f88a2259039c294045044e3401b68d493cfb93b440d4 conformance/cases/cursor/byte-identity/expected.canonical.json +497ca7ef4174fdadc41f5d2581b7e86a4fbfe4720ae9cfeecf11c4e6ac323e88 conformance/cases/cursor/byte-identity/expected.letta.json +09cc5322a00fe976fc9f7bb6ec9fcdb5d8b16cf900a4fa15ddc0e0be1a9f0e48 conformance/cases/cursor/full/expected.canonical.json +b321f8a4a5623d153965c5dbc5a4cb3ec53dc045af391eeba635ba08c23ac5be conformance/cases/cursor/full/expected.letta.json +c919b40eb93e0a7b7da79e2a86fd88ca4c175b7f8e4ae60dade42e1ef353a091 conformance/cases/cursor/partial-chunk/expected.canonical.json +3595e596eb9d61bbdff44f012f40a53b5455de44b11361424e0ab23a9dcdc80d conformance/cases/cursor/partial-chunk/expected.letta.json +83e8a47fe6a3a5562450d9c86b8e91329cbba90e9173caf6adb30800871525f8 conformance/cases/cursor/tool-calls/expected.canonical.json +84bb6c1900dd31a0f8e10c354366996271759b04f6d52195ed2f9fa35dfdc479 conformance/cases/cursor/tool-calls/expected.letta.json c1f10dd5034dfd63d5738254be395aeef139b8eb42138d8f2104e5dc819199c9 conformance/cases/grok-build/backend-later-result/expected.letta.json 70ea0579e7a5b964eafa6bd1730c08910fab5a11282e85b75868d34282c484c9 conformance/cases/grok-build/backend-tools/expected.canonical.json 199e9b2c6cfd4449b11795ff3941d7f0a949ce581c51c749c386ebe2ea72bfac conformance/cases/grok-build/backend-tools/expected.letta.json diff --git a/conformance/stores/cursor-pagination/store.json b/conformance/stores/cursor-pagination/store.json new file mode 100644 index 0000000..46805f0 --- /dev/null +++ b/conformance/stores/cursor-pagination/store.json @@ -0,0 +1,13 @@ +{ + "store_version": 1, + "files": [ + {"path":"store/projects/%2Fworkspace%2Fold/agent-transcripts/019f0000-0000-7000-8000-00000000c001/019f0000-0000-7000-8000-00000000c001.jsonl","content":"{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Older Cursor session\"}]}}\n","updated_at":"2026-04-01T00:00:00.000Z"}, + {"path":"store/projects/%2Fworkspace%2Fmid/agent-transcripts/019f0000-0000-7000-8000-00000000c003/019f0000-0000-7000-8000-00000000c003.jsonl","content":"{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Mid Cursor session\"}]}}\n","updated_at":"2026-04-02T00:00:00.000Z"}, + {"path":"store/projects/%2Fworkspace%2Fnew/agent-transcripts/019f0000-0000-7000-8000-00000000c002/019f0000-0000-7000-8000-00000000c002.jsonl","content":"{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Newest Cursor session\"}]}}\n","updated_at":"2026-04-03T00:00:00.000Z"}, + {"path":"store/chats/hash/019f0000-0000-7000-8000-00000000c002/meta.json","content":"{\"title\":\"Meta title\",\"updatedAtMs\":1775174400000}\n","updated_at":"2026-04-04T00:00:00.000Z"}, + {"path":"store/projects/%2Fworkspace%2Fnew/repo.json","content":"{}\n","updated_at":"2026-04-05T00:00:00.000Z"}, + {"path":"store/projects/%2Fworkspace%2Fnew/worker.log","content":"secret noise\n","updated_at":"2026-04-05T00:00:00.000Z"}, + {"path":"store/projects/%2Fworkspace%2Fnew/store.db","content":"encrypted\n","updated_at":"2026-04-05T00:00:00.000Z"}, + {"path":"store/projects/%2Fworkspace%2Fnew/agent-transcripts/019f0000-0000-7000-8000-00000000c002/not-the-session.jsonl","content":"{}\n","updated_at":"2026-04-05T00:00:00.000Z"} + ] +} diff --git a/contracts/compatibility.json b/contracts/compatibility.json index ef02d0b..e7b7132 100644 --- a/contracts/compatibility.json +++ b/contracts/compatibility.json @@ -80,7 +80,8 @@ "openclaw", "hermes", "ahp", - "grok-build" + "grok-build", + "cursor" ], "outputs": [ "letta-trajectory-v1", diff --git a/contracts/schemas/compatibility-manifest-v1.schema.json b/contracts/schemas/compatibility-manifest-v1.schema.json index 9698f25..fb238e4 100644 --- a/contracts/schemas/compatibility-manifest-v1.schema.json +++ b/contracts/schemas/compatibility-manifest-v1.schema.json @@ -139,7 +139,8 @@ "openclaw", "hermes", "ahp", - "grok-build" + "grok-build", + "cursor" ] }, "uniqueItems": true diff --git a/contracts/schemas/conformance-case-v1.schema.json b/contracts/schemas/conformance-case-v1.schema.json index 2c3bbbc..16532b4 100644 --- a/contracts/schemas/conformance-case-v1.schema.json +++ b/contracts/schemas/conformance-case-v1.schema.json @@ -35,7 +35,7 @@ } } }, - "source": { "enum": ["pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build"] }, + "source": { "enum": ["pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build", "cursor"] }, "transcript": { "type": "string", "minLength": 1 }, "native_container": { "type": ["string", "null"] }, "store": { "type": "string", "pattern": "^[a-z0-9][a-z0-9/-]*$" }, diff --git a/contracts/schemas/streaming-case-v1.schema.json b/contracts/schemas/streaming-case-v1.schema.json index 5f98786..a780eae 100644 --- a/contracts/schemas/streaming-case-v1.schema.json +++ b/contracts/schemas/streaming-case-v1.schema.json @@ -36,7 +36,8 @@ "openclaw", "hermes", "ahp", - "grok-build" + "grok-build", + "cursor" ] }, "group_id": { diff --git a/contracts/schemas/streaming-cursor-v1.schema.json b/contracts/schemas/streaming-cursor-v1.schema.json index b2311bc..70a6c96 100644 --- a/contracts/schemas/streaming-cursor-v1.schema.json +++ b/contracts/schemas/streaming-cursor-v1.schema.json @@ -57,7 +57,8 @@ "openclaw", "hermes", "ahp", - "grok-build" + "grok-build", + "cursor" ] }, "int64": { diff --git a/contracts/schemas/streaming-delta-v1.schema.json b/contracts/schemas/streaming-delta-v1.schema.json index 0212f6e..7b186a9 100644 --- a/contracts/schemas/streaming-delta-v1.schema.json +++ b/contracts/schemas/streaming-delta-v1.schema.json @@ -63,7 +63,8 @@ "openclaw", "hermes", "ahp", - "grok-build" + "grok-build", + "cursor" ] }, "timestamp": { diff --git a/contracts/schemas/trajectory-stream-v1.schema.json b/contracts/schemas/trajectory-stream-v1.schema.json index c52d44a..4482b2e 100644 --- a/contracts/schemas/trajectory-stream-v1.schema.json +++ b/contracts/schemas/trajectory-stream-v1.schema.json @@ -36,7 +36,8 @@ "openclaw", "hermes", "ahp", - "grok-build" + "grok-build", + "cursor" ] }, "recordStatus": { diff --git a/contracts/spec/diagnostics.md b/contracts/spec/diagnostics.md index d3b3ffa..b2977aa 100644 --- a/contracts/spec/diagnostics.md +++ b/contracts/spec/diagnostics.md @@ -33,6 +33,9 @@ Stable diagnostic codes currently are: - `image_content_dropped` - `backend_tool_result_synthesized` - `encrypted_reasoning_included` +- `unknown_content_part` +- `tool_use_missing_name` +- `turn_ended_error` Codes are additive and are never repurposed. Diagnostic ordering is source decode order followed by normalization order. Counts and indexes are decimal diff --git a/contracts/spec/listing.md b/contracts/spec/listing.md index b7bfa60..63428c5 100644 --- a/contracts/spec/listing.md +++ b/contracts/spec/listing.md @@ -46,6 +46,16 @@ additional items remain. (`generated_title` then `session_summary`) and `updated_at` (`last_active_at` then `updated_at`); otherwise use the history file mtime. Ignore non-session files (locks, `events.jsonl`, `updates.jsonl`, etc.). +- Cursor Agent: `projects/*/agent-transcripts//.jsonl`, + under the default `$CURSOR_HOME` when non-empty, otherwise `~/.cursor`. + The item ID is the session directory name and `path` is the transcript path; + skip files whose stem does not equal the session directory name. Use matching + `chats/*//meta.json` `updatedAtMs` for `updated_at`, otherwise + the transcript mtime; use a non-empty meta `title`, otherwise a bounded peek + of the first user text. Report `size_bytes` from the transcript. Join meta by + scanning `chats/*//meta.json` without decoding the CWD. A missing + root returns an empty page. Ignore unrelated files such as `repo.json`, + `worker.log`, and `store.db`. ## Optional title derivation @@ -60,6 +70,11 @@ Per-source rules: - **Grok Build:** `summary.json` `generated_title`, else `session_summary` (unchanged; no transcript scan). +- **Cursor Agent:** matching `chats/*//meta.json` `title`, else + the first user turn's joined text from the bounded transcript peek. Matching + `updatedAtMs` supplies `updated_at`; otherwise use transcript mtime. Discover + only `projects/*/agent-transcripts//.jsonl` and ignore + unrelated Cursor files. - **Codex:** scan early rollout records. Prefer the first `response_item` with `payload.role === "user"` whose extracted text is not harness injection. Noise includes markers such as `# AGENTS.md`, ``, diff --git a/docs/adapter-authoring.md b/docs/adapter-authoring.md index b7a481d..d8eb612 100644 --- a/docs/adapter-authoring.md +++ b/docs/adapter-authoring.md @@ -19,6 +19,7 @@ Related: snapshot source (`ahp`); Shape B reduce is in core stream apply; listing and live WebSocket remain caller-owned. - [Grok Build source](grok-build-source-spec.md) — Grok Build (`grok-build`) session history +- [Cursor source](cursor-source-spec.md) — Cursor Agent (`cursor`) transcript JSONL ## Architecture reminder diff --git a/docs/architecture.md b/docs/architecture.md index 0c26be1..282eae9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -84,7 +84,7 @@ Python is an independent native **3.11+** implementation under `python/` Built-in sources: **Pi**, **Claude Code**, **Codex**, **OpenClaw**, **Hermes**, **AHP** (Shape A offline ChatState snapshot; listing deferred), -and **Grok Build** (`grok-build`). +and **Grok Build** (`grok-build`), plus **Cursor Agent** (`cursor`). AHP export-directory listing, multi-chat unpack, and a first-class live WebSocket host remain caller-owned. See diff --git a/docs/cursor-source-spec.md b/docs/cursor-source-spec.md index 971665c..eca50c5 100644 --- a/docs/cursor-source-spec.md +++ b/docs/cursor-source-spec.md @@ -306,10 +306,10 @@ A Cursor source is complete when: ## 11. Implementation checklist -- [ ] Vocabulary in conformance + compatibility schemas -- [ ] Listing rules in `contracts/spec/listing.md` -- [ ] Shared cases + store fixture + stream append sequence -- [ ] .NET / TypeScript / Rust / Python decode + list -- [ ] CLI aliases and default roots; file-stream allow-list -- [ ] `compatibility.json` + runtime capability manifests only when all claiming runtimes pass -- [ ] README source list + Unreleased changelog +- [x] Vocabulary in conformance + compatibility schemas +- [x] Listing rules in `contracts/spec/listing.md` +- [x] Shared cases + store fixture + stream append sequence +- [x] .NET / TypeScript / Rust / Python decode + list +- [x] CLI aliases and default roots; file-stream allow-list +- [x] `compatibility.json` + runtime capability manifests only when all claiming runtimes pass +- [x] README source list + Unreleased changelog diff --git a/docs/live-session-streaming.md b/docs/live-session-streaming.md index 98a6bc2..06a5979 100644 --- a/docs/live-session-streaming.md +++ b/docs/live-session-streaming.md @@ -565,7 +565,7 @@ Stream inputs are **`bytes` only** (no str offsets). Optional `[io]` / `[ahp]` e | `stream-reset` | Reset-required / reset ops | | `stream-provisional-records` | Provisional lifecycle | | `stream-deterministic-replay` | Idempotent replay | -| `stream-file-jsonl` | Pi, Claude Code, Codex, OpenClaw, Grok Build | +| `stream-file-jsonl` | Pi, Claude Code, Codex, OpenClaw, Grok Build, Cursor Agent | | `stream-ahp-snapshot` | Shape A successive snapshots | | `stream-ahp-action-log` | Shape B reducer + apply | diff --git a/docs/release-readiness.md b/docs/release-readiness.md index f0d8f49..74647ae 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -27,7 +27,7 @@ Distinguish **published registry packages** from **this repository tip**. | Checked-in `VERSION` | `0.1.2` (Grok Build + AHP tip cut; **already used** — do not retag) | | Capability slice | `ML13` (historical slice id; AHP is a post-`0.1.0` source addition) | | Normalizer contract | `0.2.0` | -| Implemented sources (tip) | Published set **plus** `ahp` and `grok-build` | +| Implemented sources (tip) | Published set **plus** `ahp`, `grok-build`, and `cursor` | | AHP scope | Batch Shape A + core stream Shape A/B in-tree; optional AHP clients in-tree (fake-host tested); listing and real WebSocket transport still **not** shipped | | Next registry publish | A **new** synchronized tag **strictly after `0.1.2`** (for example `v0.1.3` or `v0.2.0`) for the live-session stream cut. **Do not tag or retag `v0.1.2`.** `skip-duplicate` / “already published” fallbacks must not treat a reused `0.1.2` as a successful stream ship. | diff --git a/docs/streaming-core-api.md b/docs/streaming-core-api.md index ba14403..1eecfaf 100644 --- a/docs/streaming-core-api.md +++ b/docs/streaming-core-api.md @@ -93,7 +93,7 @@ Options (defaults): ## `apply_append` (LS-05) -File JSONL sources (`pi`, `claude-code`, `codex`, `openclaw`, `grok-build`): +File JSONL sources (`pi`, `claude-code`, `codex`, `openclaw`, `grok-build`, `cursor`): 1. Frame `pending + segment` into complete lines + new pending tail. 2. Incomplete / mid-UTF-8 only → `kind=unchanged` with updated `pending_byte_length`. diff --git a/docs/streaming-file-io.md b/docs/streaming-file-io.md index 032185c..e51606b 100644 --- a/docs/streaming-file-io.md +++ b/docs/streaming-file-io.md @@ -38,7 +38,7 @@ I/O **only** calls core apply APIs. It never invents transcript diagnostics. | --- | --- | | `root` | **Required.** Absolute or resolvable directory that bounds the session file. No implicit `~` multi-session watch. | | `path` | **Required.** File to follow. After resolve, must be **under** `root` (same-root containment). | -| `source` / stream options | JSONL file sources: `pi`, `claude-code`, `codex`, `openclaw`, `grok-build`. Passed through to core `StreamOptions`. | +| `source` / stream options | JSONL file sources: `pi`, `claude-code`, `codex`, `openclaw`, `grok-build`, `cursor`. Passed through to core `StreamOptions`. | Missing root or path → **host error** (not a stream update). diff --git a/docs/streaming-file-sources.md b/docs/streaming-file-sources.md index 5cc2b00..86d17e1 100644 --- a/docs/streaming-file-sources.md +++ b/docs/streaming-file-sources.md @@ -1,7 +1,7 @@ # Streaming file JSONL sources (LS-05) Pure-core `apply_append` for file-backed coding-agent transcripts: -`pi`, `claude-code`, `codex`, `openclaw`, and `grok-build`. +`pi`, `claude-code`, `codex`, `openclaw`, `grok-build`, and `cursor`. Normative contracts: [`contracts/spec/streaming.md`](../contracts/spec/streaming.md), product design: [`live-session-streaming.md`](live-session-streaming.md), @@ -80,6 +80,7 @@ When a **snapshot** supplies committed material shorter than the prior | --- | --- | | New material is a pure byte-prefix of prior `committed_prefix` | `source-truncated` | | Non-prefix rewrite and source is `grok-build` | `source-compacted` | +| Non-prefix rewrite and source is `cursor` | `source-replaced` | | Non-prefix rewrite and other JSONL sources | `source-replaced` | Default policy returns `kind = "reset-required"` **without** advancing the @@ -122,6 +123,7 @@ across chunks (same as one-shot normalize of the joined prefix). | `codex` | byte | truncate, replace | Append-only contract; still detect shrink | | `openclaw` | byte | truncate, replace | Delivery-mirror not provisional | | `grok-build` | byte | **compaction**, truncate, replace | Synthetic backend tools provisional | +| `cursor` | byte | truncate, replace | Cursor Agent transcript JSONL; no provisional records | ## Fixtures @@ -136,7 +138,7 @@ Per-source append sequences: - `pi-append-sequence`, `claude-code-append-sequence`, `codex-append-sequence`, `openclaw-append-sequence`, `grok-build-append-sequence`, - `grok-build-backend-provisional` + `grok-build-backend-provisional`, `cursor-append-sequence` ## Privacy diff --git a/dotnet/samples/Trajectory.Cli/Program.cs b/dotnet/samples/Trajectory.Cli/Program.cs index 5dc72a0..7436555 100644 --- a/dotnet/samples/Trajectory.Cli/Program.cs +++ b/dotnet/samples/Trajectory.Cli/Program.cs @@ -68,6 +68,7 @@ internal static class Sources "hermes", "ahp", "grok-build", + "cursor", ]; public static TrajectorySource Parse(string value) => value.Trim().ToLowerInvariant() switch @@ -79,6 +80,7 @@ internal static class Sources "hermes" => TrajectorySource.Hermes, "ahp" => TrajectorySource.Ahp, "grok-build" or "grok" => TrajectorySource.GrokBuild, + "cursor" or "cursor-agent" => TrajectorySource.Cursor, _ => throw new TrajectoryNormalizationException( NormalizationErrorCode.UnknownSource, $"Unknown source '{value}'. Expected one of: {string.Join(", ", Names)} (alias: grok)."), @@ -93,6 +95,7 @@ internal static class Sources TrajectorySource.Hermes => "hermes", TrajectorySource.Ahp => "ahp", TrajectorySource.GrokBuild => "grok-build", + TrajectorySource.Cursor => "cursor", _ => source.ToString().ToLowerInvariant(), }; } @@ -115,6 +118,7 @@ public static string Resolve(TrajectorySource source, string? rootOverride) TrajectorySource.Hermes => "TRAJECTORY_HERMES_ROOT", TrajectorySource.Ahp => "TRAJECTORY_AHP_ROOT", TrajectorySource.GrokBuild => "TRAJECTORY_GROK_BUILD_ROOT", + TrajectorySource.Cursor => "TRAJECTORY_CURSOR_ROOT", _ => null, }; if (envKey is not null) @@ -143,6 +147,7 @@ public static string Resolve(TrajectorySource source, string? rootOverride) TrajectorySource.Hermes => Path.Combine(home, ".hermes"), TrajectorySource.Ahp => home, TrajectorySource.GrokBuild => ResolveGrokBuildRoot(home), + TrajectorySource.Cursor => Expand(Environment.GetEnvironmentVariable("CURSOR_HOME")?.Trim() ?? Path.Combine(home, ".cursor")), _ => home, }; } @@ -167,6 +172,7 @@ private static string ResolveGrokBuildRoot(string home) TrajectorySource.Hermes => "~/.hermes/state.db", TrajectorySource.Ahp => "explicit export root only (no home default)", TrajectorySource.GrokBuild => "$GROK_HOME/sessions or ~/.grok/sessions (or TRAJECTORY_GROK_BUILD_ROOT)", + TrajectorySource.Cursor => "$CURSOR_HOME or ~/.cursor (or TRAJECTORY_CURSOR_ROOT)", _ => "n/a", }; @@ -253,7 +259,7 @@ internal static class StreamCli { public static readonly string[] FileSources = [ - "pi", "claude-code", "codex", "openclaw", "grok-build", + "pi", "claude-code", "codex", "openclaw", "grok-build", "cursor", ]; public static StreamDelivery ParseEmit(string? value) @@ -440,7 +446,7 @@ public static async Task FollowAsync( // Grok history lines do not embed the session id; listing id is the group. // Other file sources lock group from the transcript. - if (source != TrajectorySource.GrokBuild) + if (source is not (TrajectorySource.GrokBuild or TrajectorySource.Cursor)) { groupId = null; } @@ -536,7 +542,7 @@ public static async Task FollowAsync( internal class GlobalSettings : CommandSettings { [CommandOption("-s|--source ")] - [Description("Transcript source: pi, claude-code, codex, openclaw, hermes, ahp, grok-build (alias: grok).")] + [Description("Transcript source: pi, claude-code, codex, openclaw, hermes, ahp, grok-build, cursor (aliases: grok, cursor-agent).")] public string? Source { get; init; } [CommandOption("-r|--root ")] diff --git a/dotnet/samples/Trajectory.Cli/README.md b/dotnet/samples/Trajectory.Cli/README.md index 17fb03c..7f8eaa4 100644 --- a/dotnet/samples/Trajectory.Cli/README.md +++ b/dotnet/samples/Trajectory.Cli/README.md @@ -5,7 +5,7 @@ Local sample TUI for browsing agent sessions with [`Hypabolic.Trajectory`](../.. ## What it does -- Lists sessions from local agent stores (Pi, Claude Code, Codex, OpenClaw, Hermes, Grok Build) +- Lists sessions from local agent stores (Pi, Claude Code, Codex, OpenClaw, Hermes, Grok Build, Cursor Agent) - Normalizes AHP Shape A offline snapshots via `show --path` (no default store) - Interactively pick a session, then Watch live or Show snapshot - Prints privacy-safe summaries (record counts, roles, tool calls, diagnostics) @@ -26,6 +26,7 @@ Default store roots (override with `--root` or env): | `hermes` | `~/.hermes` | `TRAJECTORY_HERMES_ROOT` | | `ahp` | _(none — export file only)_ | _(not applicable)_ | | `grok-build` (alias `grok`) | `$GROK_HOME/sessions` or `~/.grok/sessions` | `TRAJECTORY_GROK_BUILD_ROOT` or `GROK_HOME` | +| `cursor` (alias `cursor-agent`) | `$CURSOR_HOME` or `~/.cursor` | `TRAJECTORY_CURSOR_ROOT` or `CURSOR_HOME` | Hermes core listing is SQLite-free and returns empty pages. Export message JSON from Hermes and use `show --path`. @@ -104,7 +105,7 @@ Shared flags: `--source`, `--root`, `--limit`, `--show-content`. Stream flags: `--emit snapshot+delta|snapshot|delta` (default `snapshot+delta`), `--follow`, `--interval`, `--max-updates`, `--path` / `--id` (+ explicit `--root` for listing). File stream sources: `pi`, `claude-code`, `codex`, `openclaw`, -`grok-build`. +`grok-build`, `cursor`. AHP stream flags: `--url` (sample: `fake://…`), `--chat`, `--from-seq`, `--token` / `TRAJECTORY_AHP_TOKEN`, `--snapshot-path`, `--actions-path`. diff --git a/dotnet/src/Trajectory/Adapters/Cursor/CursorJsonlSourceAdapter.cs b/dotnet/src/Trajectory/Adapters/Cursor/CursorJsonlSourceAdapter.cs new file mode 100644 index 0000000..d803644 --- /dev/null +++ b/dotnet/src/Trajectory/Adapters/Cursor/CursorJsonlSourceAdapter.cs @@ -0,0 +1,151 @@ +using System.Buffers; +using System.Text; +using System.Text.Json; +using Hypabolic.Trajectory.Internal; + +namespace Hypabolic.Trajectory.Adapters.Cursor; + +internal sealed class CursorJsonlSourceAdapter : ISourceAdapter +{ + public TrajectorySource Source => TrajectorySource.Cursor; + + public DecodedSession Decode(ReadOnlyMemory transcriptUtf8, SourceContext sourceContext) + { + _ = sourceContext; + var diagnostics = new List(); + var events = new List(); + foreach (var line in ParseJsonLines(transcriptUtf8, diagnostics)) + { + using var document = line.Document; + var row = document.RootElement; + var role = GetString(row, "role"); + var type = GetString(row, "type"); + var componentIndex = 0; + void Emit(DecodedEvent decoded) => events.Add(decoded with + { + SourceOffset = line.ByteOffset, + SourceAnchorKind = SourceAnchorKind.Byte, + InputLine = line.Line, + ComponentIndex = componentIndex++, + }); + + if (role is "user" or "assistant") + { + var textParts = new List(); + var tools = new List(); + if (row.TryGetProperty("message", out var message) && message.ValueKind == JsonValueKind.Object && + message.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Array) + { + foreach (var part in content.EnumerateArray()) + { + if (part.ValueKind != JsonValueKind.Object) continue; + var partType = GetString(part, "type"); + if (partType == "text") + { + var text = GetString(part, "text"); + if (text is not null) textParts.Add(text); + } + else if (role == "assistant" && partType == "tool_use") tools.Add(part.Clone()); + else if (partType is "image" or "image_url" or "input_image" or "output_image") + { + diagnostics.Add(new TrajectoryDiagnostic { Code = DiagnosticCodes.ImageContentDropped, Message = $"Dropped image content on a Cursor record on line {line.Line}.", InputLine = line.Line }); + } + else if (!string.IsNullOrEmpty(partType)) + { + diagnostics.Add(new TrajectoryDiagnostic { Code = DiagnosticCodes.UnknownContentPart, Message = $"Skipped an unknown Cursor content part on line {line.Line}.", InputLine = line.Line }); + } + } + } + var textBody = string.Join("\n", textParts); + if (!string.IsNullOrWhiteSpace(textBody)) + { + Emit(new DecodedEvent { Kind = DecodedEventKind.Message, Role = role == "user" ? TrajectoryRole.User : TrajectoryRole.Assistant, Content = textBody, ComponentIndex = 0 }); + } + if (role == "assistant") + { + foreach (var part in tools) + { + var name = GetString(part, "name"); + if (string.IsNullOrWhiteSpace(name)) + { + diagnostics.Add(new TrajectoryDiagnostic { Code = DiagnosticCodes.ToolUseMissingName, Message = $"Skipped a Cursor tool_use part without a name on line {line.Line}.", InputLine = line.Line }); + continue; + } + var args = part.TryGetProperty("input", out var input) && input.ValueKind == JsonValueKind.Object + ? CompactJson(input) : "{}"; + Emit(new DecodedEvent { Kind = DecodedEventKind.ToolCall, Role = TrajectoryRole.Assistant, ToolName = name, ToolArgumentsJson = args, ComponentIndex = 0 }); + } + } + continue; + } + + if (type == "turn_ended") + { + if (GetString(row, "status") == "error") diagnostics.Add(new TrajectoryDiagnostic { Code = DiagnosticCodes.TurnEndedError, Message = "A Cursor turn ended with an error.", InputLine = line.Line }); + continue; + } + if (!string.IsNullOrEmpty(role) || !string.IsNullOrEmpty(type)) diagnostics.Add(new TrajectoryDiagnostic { Code = DiagnosticCodes.UnknownSemanticRecord, Message = $"Skipped an unknown Cursor semantic record on line {line.Line}.", InputLine = line.Line }); + } + + return new DecodedSession + { + Context = new DecodedSessionContext { Source = Source, SourceName = "cursor" }, + Events = events, + ModelInvocations = [], + Diagnostics = diagnostics, + }; + } + + private static IReadOnlyList ParseJsonLines(ReadOnlyMemory transcript, List diagnostics) + { + var parsed = new List(); + var bytes = transcript.Span; + var offset = 0; + var lineNumber = 1; + while (offset <= bytes.Length) + { + var remaining = bytes[offset..]; + var newline = remaining.IndexOf((byte)'\n'); + var length = newline >= 0 ? newline : remaining.Length; + var lineMemory = transcript.Slice(offset, length); + if (!lineMemory.Span.IsEmpty && lineMemory.Span[^1] == (byte)'\r') lineMemory = lineMemory[..^1]; + if (!lineMemory.Span.IsEmpty && !lineMemory.Span.ToArray().All(static value => value is (byte)' ' or (byte)'\t' or (byte)'\r')) + { + try + { + var document = JsonDocument.Parse(lineMemory); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + document.Dispose(); + diagnostics.Add(new TrajectoryDiagnostic { Code = DiagnosticCodes.NonObjectJsonLine, Message = $"Skipped non-object JSON on line {lineNumber}.", InputLine = lineNumber }); + } + else parsed.Add(new JsonLine(document, lineNumber, offset)); + } + catch (JsonException) + { + diagnostics.Add(new TrajectoryDiagnostic { Code = DiagnosticCodes.InvalidJsonLine, Message = $"Skipped invalid JSON on line {lineNumber}.", InputLine = lineNumber }); + } + } + if (newline < 0) break; + offset += length + 1; + lineNumber++; + } + return parsed; + } + + private static string? GetString(JsonElement element, string name) => + element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + + private static string CompactJson(JsonElement value) + { + var buffer = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer)) + { + value.WriteTo(writer); + } + + return Encoding.UTF8.GetString(buffer.WrittenSpan); + } + + private sealed record JsonLine(JsonDocument Document, int Line, long ByteOffset); +} diff --git a/dotnet/src/Trajectory/Adapters/Cursor/CursorTrajectoryLister.cs b/dotnet/src/Trajectory/Adapters/Cursor/CursorTrajectoryLister.cs new file mode 100644 index 0000000..e85d0ac --- /dev/null +++ b/dotnet/src/Trajectory/Adapters/Cursor/CursorTrajectoryLister.cs @@ -0,0 +1,74 @@ +using System.Text.Json; +using Hypabolic.Trajectory.Listing; + +namespace Hypabolic.Trajectory.Adapters.Cursor; + +internal sealed class CursorTrajectoryLister : ITrajectoryLister +{ + public TrajectorySource Source => TrajectorySource.Cursor; + + public IReadOnlyList List(string? root) + { + var cursorRoot = string.IsNullOrWhiteSpace(root) ? DefaultRoot() : root; + if (!Directory.Exists(cursorRoot)) return []; + var metadata = new Dictionary(StringComparer.Ordinal); + try + { + foreach (var hash in Directory.EnumerateDirectories(Path.Combine(cursorRoot, "chats"))) + foreach (var session in Directory.EnumerateDirectories(hash)) + { + try + { + using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(session, "meta.json"))); + if (document.RootElement.ValueKind == JsonValueKind.Object) + metadata[Path.GetFileName(session)!] = document.RootElement.Clone(); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or JsonException) { } + } + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) { } + + var items = new List(); + IEnumerable projects; + try { projects = Directory.EnumerateDirectories(Path.Combine(cursorRoot, "projects")); } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) { return []; } + foreach (var project in projects) + { + var sessionsRoot = Path.Combine(project, "agent-transcripts"); + IEnumerable sessions; + try { sessions = Directory.EnumerateDirectories(sessionsRoot); } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) { continue; } + foreach (var session in sessions) + { + var id = Path.GetFileName(session); + if (string.IsNullOrEmpty(id)) continue; + var path = Path.Combine(session, id + ".jsonl"); + if (!File.Exists(path)) continue; + try + { + var info = new FileInfo(path); + metadata.TryGetValue(id, out var meta); + var updated = meta.ValueKind == JsonValueKind.Object && meta.TryGetProperty("updatedAtMs", out var updatedMs) && updatedMs.TryGetInt64(out var milliseconds) && milliseconds >= 0 + ? DateTimeOffset.FromUnixTimeMilliseconds(milliseconds) + : info.LastWriteTimeUtc; + var title = meta.ValueKind == JsonValueKind.Object && meta.TryGetProperty("title", out var titleValue) && titleValue.ValueKind == JsonValueKind.String + ? ListingTitle.FormatTitle(titleValue.GetString()) : ListingTitle.DeriveCursorTitle(path); + items.Add(new TrajectoryListing { Id = id, Path = Path.GetFullPath(path), UpdatedAt = updated, Title = title, SizeBytes = info.Length }); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) { } + } + } + return items.OrderByDescending(static item => item.UpdatedAt).ThenBy(static item => item.Id, StringComparer.Ordinal).ToArray(); + } + + private static string DefaultRoot() + { + var configured = Environment.GetEnvironmentVariable("CURSOR_HOME")?.Trim(); + if (!string.IsNullOrEmpty(configured)) return Expand(configured); + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor"); + } + + private static string Expand(string path) => path.StartsWith("~/") || path == "~" + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), path == "~" ? string.Empty : path[2..]) + : path; +} diff --git a/dotnet/src/Trajectory/Adapters/Hypabolic/HypabolicTrajectoryV1OutputAdapter.cs b/dotnet/src/Trajectory/Adapters/Hypabolic/HypabolicTrajectoryV1OutputAdapter.cs index 4e96cf2..0627724 100644 --- a/dotnet/src/Trajectory/Adapters/Hypabolic/HypabolicTrajectoryV1OutputAdapter.cs +++ b/dotnet/src/Trajectory/Adapters/Hypabolic/HypabolicTrajectoryV1OutputAdapter.cs @@ -303,6 +303,7 @@ private static void WriteNullableTimestamp( TrajectorySource.DeepAgents => "deepagents", TrajectorySource.Ahp => "ahp", TrajectorySource.GrokBuild => "grok-build", + TrajectorySource.Cursor => "cursor", _ => throw new ArgumentOutOfRangeException(nameof(source)), }; diff --git a/dotnet/src/Trajectory/Adapters/Letta/LettaCanonicalV1OutputAdapter.cs b/dotnet/src/Trajectory/Adapters/Letta/LettaCanonicalV1OutputAdapter.cs index 350f8e3..fb232e3 100644 --- a/dotnet/src/Trajectory/Adapters/Letta/LettaCanonicalV1OutputAdapter.cs +++ b/dotnet/src/Trajectory/Adapters/Letta/LettaCanonicalV1OutputAdapter.cs @@ -231,6 +231,7 @@ private static string FormatTimestamp(DateTimeOffset value) => TrajectorySource.DeepAgents => "deepagents", TrajectorySource.Ahp => "ahp", TrajectorySource.GrokBuild => "grok-build", + TrajectorySource.Cursor => "cursor", _ => throw new ArgumentOutOfRangeException(nameof(source)), }; } diff --git a/dotnet/src/Trajectory/Diagnostics.cs b/dotnet/src/Trajectory/Diagnostics.cs index 81e8fdd..a2b47e1 100644 --- a/dotnet/src/Trajectory/Diagnostics.cs +++ b/dotnet/src/Trajectory/Diagnostics.cs @@ -37,6 +37,9 @@ public static class DiagnosticCodes public const string ImageContentDropped = "image_content_dropped"; public const string BackendToolResultSynthesized = "backend_tool_result_synthesized"; public const string EncryptedReasoningIncluded = "encrypted_reasoning_included"; + public const string UnknownContentPart = "unknown_content_part"; + public const string ToolUseMissingName = "tool_use_missing_name"; + public const string TurnEndedError = "turn_ended_error"; } public sealed record TrajectoryDiagnostic diff --git a/dotnet/src/Trajectory/Generated/DefaultAdapterRegistry.g.cs b/dotnet/src/Trajectory/Generated/DefaultAdapterRegistry.g.cs index 31d4147..dee5e52 100644 --- a/dotnet/src/Trajectory/Generated/DefaultAdapterRegistry.g.cs +++ b/dotnet/src/Trajectory/Generated/DefaultAdapterRegistry.g.cs @@ -3,6 +3,7 @@ using Hypabolic.Trajectory.Adapters.ClaudeCode; using Hypabolic.Trajectory.Adapters.Codex; using Hypabolic.Trajectory.Adapters.GrokBuild; +using Hypabolic.Trajectory.Adapters.Cursor; using Hypabolic.Trajectory.Adapters.Hermes; using Hypabolic.Trajectory.Adapters.Hypabolic; using Hypabolic.Trajectory.Adapters.Letta; @@ -20,6 +21,7 @@ public static TrajectoryEngine Register(TrajectoryEngine engine) => engine .AddBuiltInSource(new ClaudeCodeJsonlSourceAdapter()) .AddBuiltInSource(new CodexJsonlSourceAdapter()) .AddBuiltInSource(new GrokBuildJsonlSourceAdapter()) + .AddBuiltInSource(new CursorJsonlSourceAdapter()) .AddBuiltInSource(new HermesJsonSourceAdapter()) .AddBuiltInSource(new OpenClawJsonlSourceAdapter()) .AddBuiltInSource(new PiJsonlSourceAdapter()) @@ -32,6 +34,7 @@ public static TrajectoryEngine Register(TrajectoryEngine engine) => engine .AddBuiltInLister(new ClaudeCodeTrajectoryLister()) .AddBuiltInLister(new CodexTrajectoryLister()) .AddBuiltInLister(new GrokBuildTrajectoryLister()) + .AddBuiltInLister(new CursorTrajectoryLister()) .AddBuiltInLister(new HermesTrajectoryLister()) .AddBuiltInLister(new OpenClawTrajectoryLister()) .AddBuiltInLister(new PiTrajectoryLister()); diff --git a/dotnet/src/Trajectory/Listing/ListingTitle.cs b/dotnet/src/Trajectory/Listing/ListingTitle.cs index 177d302..1385152 100644 --- a/dotnet/src/Trajectory/Listing/ListingTitle.cs +++ b/dotnet/src/Trajectory/Listing/ListingTitle.cs @@ -207,6 +207,32 @@ internal static class ListingTitle return null; } + internal static string? DeriveCursorTitle(string path) + { + foreach (var row in ScanJsonLines(path)) + { + using var document = row; + var root = document.RootElement; + if (GetString(root, "role") != "user" || + !root.TryGetProperty("message", out var message) || + message.ValueKind != JsonValueKind.Object || + !message.TryGetProperty("content", out var content) || + content.ValueKind != JsonValueKind.Array) + { + continue; + } + + var text = string.Join("\n", content.EnumerateArray() + .Where(static part => part.ValueKind == JsonValueKind.Object && GetString(part, "type") == "text") + .Select(static part => GetString(part, "text")) + .Where(static value => value is not null)); + var title = TitleFromUserText(text); + if (title is not null) return title; + } + + return null; + } + private static IEnumerable ScanJsonLines(string path) { FileStream stream; diff --git a/dotnet/src/Trajectory/Streaming/TrajectoryStream.cs b/dotnet/src/Trajectory/Streaming/TrajectoryStream.cs index db309c8..817b965 100644 --- a/dotnet/src/Trajectory/Streaming/TrajectoryStream.cs +++ b/dotnet/src/Trajectory/Streaming/TrajectoryStream.cs @@ -2801,6 +2801,7 @@ private static string SourceWireName(TrajectorySource source) => TrajectorySource.Hermes => "hermes", TrajectorySource.Ahp => "ahp", TrajectorySource.GrokBuild => "grok-build", + TrajectorySource.Cursor => "cursor", _ => source.ToString().ToLowerInvariant(), }; diff --git a/dotnet/src/Trajectory/TrajectoryConverter.cs b/dotnet/src/Trajectory/TrajectoryConverter.cs index d811872..068ea11 100644 --- a/dotnet/src/Trajectory/TrajectoryConverter.cs +++ b/dotnet/src/Trajectory/TrajectoryConverter.cs @@ -208,4 +208,11 @@ public static ValueTask ListGrokBuildTrajectoriesAsync( cursor, limit, cancellationToken); + + public static ValueTask ListCursorTrajectoriesAsync( + string? root = null, + string? cursor = null, + int limit = 50, + CancellationToken cancellationToken = default) => + ListTrajectoriesAsync(TrajectorySource.Cursor, root, cursor, limit, cancellationToken); } diff --git a/dotnet/src/Trajectory/TrajectoryIR.cs b/dotnet/src/Trajectory/TrajectoryIR.cs index 800fc75..da760f8 100644 --- a/dotnet/src/Trajectory/TrajectoryIR.cs +++ b/dotnet/src/Trajectory/TrajectoryIR.cs @@ -12,6 +12,7 @@ public enum TrajectorySource DeepAgents, Ahp, GrokBuild, + Cursor, } public enum TrajectoryRole diff --git a/dotnet/src/Trajectory/runtime-capabilities.json b/dotnet/src/Trajectory/runtime-capabilities.json index 31a4bf6..98aff56 100644 --- a/dotnet/src/Trajectory/runtime-capabilities.json +++ b/dotnet/src/Trajectory/runtime-capabilities.json @@ -9,7 +9,8 @@ "openclaw", "hermes", "ahp", - "grok-build" + "grok-build", + "cursor" ], "outputs": [ "letta-trajectory-v1", diff --git a/dotnet/tests/Trajectory.Conformance/Program.cs b/dotnet/tests/Trajectory.Conformance/Program.cs index d8b097f..812e217 100644 --- a/dotnet/tests/Trajectory.Conformance/Program.cs +++ b/dotnet/tests/Trajectory.Conformance/Program.cs @@ -1113,6 +1113,7 @@ private static Utf8JsonWriter NewWriter(Stream output) => "hermes" => TrajectorySource.Hermes, "ahp" => TrajectorySource.Ahp, "grok-build" => TrajectorySource.GrokBuild, + "cursor" => TrajectorySource.Cursor, _ => throw new ProtocolException($"Unsupported conformance source '{source}'."), }; diff --git a/dotnet/tests/Trajectory.Tests/ContractsAndConformanceTests.cs b/dotnet/tests/Trajectory.Tests/ContractsAndConformanceTests.cs index c1966b7..5b0538d 100644 --- a/dotnet/tests/Trajectory.Tests/ContractsAndConformanceTests.cs +++ b/dotnet/tests/Trajectory.Tests/ContractsAndConformanceTests.cs @@ -51,7 +51,7 @@ public void CompatibilityManifestIsValidAndMatchesRuntimeConstants() .Select(static item => item.GetString()) .ToHashSet(StringComparer.Ordinal); Assert.Equal( - new[] { "pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build" } + new[] { "pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build", "cursor" } .Order(StringComparer.Ordinal), sources.Order(StringComparer.Ordinal)); Assert.Equal("1.42.0", OtelGenAiConventions.Version); diff --git a/python/runtime-capabilities.json b/python/runtime-capabilities.json index 1a010cb..334975e 100644 --- a/python/runtime-capabilities.json +++ b/python/runtime-capabilities.json @@ -9,7 +9,8 @@ "openclaw", "hermes", "ahp", - "grok-build" + "grok-build", + "cursor" ], "outputs": [ "letta-trajectory-v1", diff --git a/python/samples/trajectory_cli/README.md b/python/samples/trajectory_cli/README.md index f27141a..8df626e 100644 --- a/python/samples/trajectory_cli/README.md +++ b/python/samples/trajectory_cli/README.md @@ -9,7 +9,7 @@ install of the core package). ## What it does -- Lists sessions from local agent stores (Pi, Claude Code, Codex, OpenClaw, Hermes, Grok Build) +- Lists sessions from local agent stores (Pi, Claude Code, Codex, OpenClaw, Hermes, Grok Build, Cursor Agent) - Normalizes AHP Shape A offline snapshots via `show --path` (no default store) - Interactive browse with numbered selection prompts, then Watch live or Show snapshot - Privacy-safe summaries (record counts, roles, tool calls, diagnostics) @@ -29,6 +29,7 @@ pure stream apply; the CLI owns lifetime, explicit roots, and AHP transport. | `hermes` | `~/.hermes` | `TRAJECTORY_HERMES_ROOT` | | `ahp` | _(none — export file only)_ | _(not applicable)_ | | `grok-build` (alias `grok`) | `$GROK_HOME/sessions` or `~/.grok/sessions` | `TRAJECTORY_GROK_BUILD_ROOT` or `GROK_HOME` | +| `cursor` (alias `cursor-agent`) | `$CURSOR_HOME` or `~/.cursor` | `TRAJECTORY_CURSOR_ROOT` or `CURSOR_HOME` | Hermes core listing is SQLite-free and returns empty pages. Export message JSON from Hermes and use `show --path`. @@ -110,7 +111,7 @@ Shared flags: `--source`, `--root`, `--limit`, `--show-content`. Stream flags: `--emit snapshot+delta|snapshot|delta` (default `snapshot+delta`), `--follow`, `--interval`, `--max-updates`, `--path` / `--id` (+ explicit `--root` for listing resolution). File stream sources: `pi`, `claude-code`, `codex`, -`openclaw`, `grok-build`. +`openclaw`, `grok-build`, `cursor`. AHP stream flags: `--url` (sample: `fake://…`), `--chat`, `--from-seq`, `--token` / `TRAJECTORY_AHP_TOKEN`, `--snapshot-path`, `--actions-path`. diff --git a/python/samples/trajectory_cli/cli.py b/python/samples/trajectory_cli/cli.py index 8a9f547..ea1487c 100644 --- a/python/samples/trajectory_cli/cli.py +++ b/python/samples/trajectory_cli/cli.py @@ -58,6 +58,7 @@ "hermes", "ahp", "grok-build", + "cursor", ) # File JSONL sources supported by optional stream file I/O (not hermes/ahp). @@ -67,6 +68,7 @@ "codex", "openclaw", "grok-build", + "cursor", ) EmitMode = Literal["snapshot+delta", "snapshot", "delta"] @@ -88,6 +90,7 @@ "hermes": "TRAJECTORY_HERMES_ROOT", "ahp": "TRAJECTORY_AHP_ROOT", "grok-build": "TRAJECTORY_GROK_BUILD_ROOT", + "cursor": "TRAJECTORY_CURSOR_ROOT", } @@ -147,7 +150,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace: "--source", dest="source", default=None, - help="Transcript source: pi, claude-code, codex, openclaw, hermes, ahp, grok-build.", + help="Transcript source: pi, claude-code, codex, openclaw, hermes, ahp, grok-build, cursor.", ) parser.add_argument( "-r", @@ -325,6 +328,8 @@ def parse_source(value: str) -> str: return "claude-code" if normalized in ("grok", "grokbuild"): return "grok-build" + if normalized in ("cursor-agent", "cursoragent"): + return "cursor" raise TrajectoryError( "unknown_source", f"Unknown source '{value}'. Expected one of: {', '.join(SOURCES)}.", @@ -404,6 +409,11 @@ def default_root(source: str) -> str: if grok_home: return str(Path(expand_home(grok_home)) / "sessions") return str(home / ".grok" / "sessions") + if source == "cursor": + cursor_env = os.environ.get("CURSOR_HOME", "").strip() + if cursor_env: + return expand_home(cursor_env) + return str(home / ".cursor") # ahp — no home default store; listing needs an explicit export root. return str(home) @@ -420,6 +430,7 @@ def describe_default(source: str) -> str: "hermes": "~/.hermes/state.db", "ahp": "explicit export root only (no home default)", "grok-build": "$GROK_HOME/sessions or ~/.grok/sessions (or TRAJECTORY_GROK_BUILD_ROOT)", + "cursor": "$CURSOR_HOME or ~/.cursor (or TRAJECTORY_CURSOR_ROOT)", }[source] @@ -563,7 +574,7 @@ def follow_file_session( # Grok history lines do not embed the session id; listing id is the group. # Other file sources lock group from the transcript — a listing stem that # disagrees with the session record would reset instead of showing a tail. - if source != "grok-build": + if source not in ("grok-build", "cursor"): group_id = None delivery = emit_to_delivery(emit) stream_opts = StreamOptions(source=source, group_id=group_id, delivery=delivery) @@ -1391,6 +1402,7 @@ def print_help() -> None: hermes ~/.hermes ahp explicit export root only (use show --path) grok-build $GROK_HOME/sessions or ~/.grok/sessions + cursor $CURSOR_HOME or ~/.cursor Root overrides: --root or TRAJECTORY__ROOT (e.g. TRAJECTORY_PI_ROOT). OpenClaw also honors OPENCLAW_STATE_DIR / CLAWDBOT_STATE_DIR. diff --git a/python/src/hypabolic_trajectory/__init__.py b/python/src/hypabolic_trajectory/__init__.py index 468488c..f9da291 100644 --- a/python/src/hypabolic_trajectory/__init__.py +++ b/python/src/hypabolic_trajectory/__init__.py @@ -51,6 +51,7 @@ "hermes", "ahp", "grok-build", + "cursor", ) # --------------------------------------------------------------------------- diff --git a/python/src/hypabolic_trajectory/_enums.py b/python/src/hypabolic_trajectory/_enums.py index 8722f02..666c328 100644 --- a/python/src/hypabolic_trajectory/_enums.py +++ b/python/src/hypabolic_trajectory/_enums.py @@ -18,3 +18,4 @@ class TrajectorySource(StrEnum): HERMES = "hermes" AHP = "ahp" GROK_BUILD = "grok-build" + CURSOR = "cursor" diff --git a/python/src/hypabolic_trajectory/diagnostics.py b/python/src/hypabolic_trajectory/diagnostics.py index c6d3d68..9a392a3 100644 --- a/python/src/hypabolic_trajectory/diagnostics.py +++ b/python/src/hypabolic_trajectory/diagnostics.py @@ -47,6 +47,9 @@ DIAG_IMAGE_CONTENT_DROPPED: Final[str] = "image_content_dropped" DIAG_BACKEND_TOOL_RESULT_SYNTHESIZED: Final[str] = "backend_tool_result_synthesized" DIAG_ENCRYPTED_REASONING_INCLUDED: Final[str] = "encrypted_reasoning_included" +DIAG_UNKNOWN_CONTENT_PART: Final[str] = "unknown_content_part" +DIAG_TOOL_USE_MISSING_NAME: Final[str] = "tool_use_missing_name" +DIAG_TURN_ENDED_ERROR: Final[str] = "turn_ended_error" # OTEL projection diagnostic (exact fixture message in §4). DIAG_MODEL_SPAN_OMITTED: Final[str] = "model_span_omitted" @@ -85,6 +88,9 @@ DIAG_IMAGE_CONTENT_DROPPED, DIAG_BACKEND_TOOL_RESULT_SYNTHESIZED, DIAG_ENCRYPTED_REASONING_INCLUDED, + DIAG_UNKNOWN_CONTENT_PART, + DIAG_TOOL_USE_MISSING_NAME, + DIAG_TURN_ENDED_ERROR, DIAG_MODEL_SPAN_OMITTED, } ) diff --git a/python/src/hypabolic_trajectory/listing/__init__.py b/python/src/hypabolic_trajectory/listing/__init__.py index 71ed3f7..13708dc 100644 --- a/python/src/hypabolic_trajectory/listing/__init__.py +++ b/python/src/hypabolic_trajectory/listing/__init__.py @@ -31,6 +31,7 @@ from hypabolic_trajectory.listing import ahp as _ahp # noqa: E402, F401 from hypabolic_trajectory.listing import claude_code as _claude_code # noqa: E402, F401 from hypabolic_trajectory.listing import codex as _codex # noqa: E402, F401 +from hypabolic_trajectory.listing import cursor as _cursor # noqa: E402, F401 from hypabolic_trajectory.listing import grok_build as _grok_build # noqa: E402, F401 from hypabolic_trajectory.listing import hermes as _hermes # noqa: E402, F401 from hypabolic_trajectory.listing import openclaw as _openclaw # noqa: E402, F401 diff --git a/python/src/hypabolic_trajectory/listing/cursor.py b/python/src/hypabolic_trajectory/listing/cursor.py new file mode 100644 index 0000000..1251b9a --- /dev/null +++ b/python/src/hypabolic_trajectory/listing/cursor.py @@ -0,0 +1,121 @@ +"""Cursor Agent transcript discovery and listing.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any, Final + +from hypabolic_trajectory._enums import TrajectorySource +from hypabolic_trajectory.dto import TrajectoryListing, TrajectoryListingPage +from hypabolic_trajectory.listing.common import paginate +from hypabolic_trajectory.listing.protocol import register_lister +from hypabolic_trajectory.listing.title import derive_cursor_title, format_title +from hypabolic_trajectory.timestamps import format_ms + +_META_NAME: Final[str] = "meta.json" + + +class CursorTrajectoryLister: + @property + def source(self) -> TrajectorySource: + return TrajectorySource.CURSOR + + def list_page( + self, *, root: str | Path, cursor: str | None, limit: int + ) -> TrajectoryListingPage: + return paginate(_discover(root), cursor=cursor, limit=limit) + + +def _discover(root: str | Path) -> list[TrajectoryListing]: + if not isinstance(root, (str, Path)): + raise TypeError("root must be str or Path") + root_path = Path(root) + try: + project_entries = list(os.scandir(root_path / "projects")) + except OSError: + return [] + meta = _scan_meta(root_path / "chats") + items: list[TrajectoryListing] = [] + for project in project_entries: + try: + if not project.is_dir(follow_symlinks=True): + continue + sessions = list(os.scandir(Path(project.path) / "agent-transcripts")) + except OSError: + continue + for session in sessions: + try: + if not session.is_dir(follow_symlinks=True): + continue + transcript = Path(session.path) / f"{session.name}.jsonl" + if transcript.stem != session.name or not transcript.is_file(): + continue + stat = transcript.stat() + record_meta = meta.get(session.name, {}) + updated = _updated_at(record_meta.get("updatedAtMs"), stat) + title = _meta_title(record_meta.get("title")) + if title is None: + title = derive_cursor_title(transcript) + items.append(TrajectoryListing( + id=session.name, + path=str(transcript.resolve()), + updated_at=updated, + title=title, + size_bytes=stat.st_size if stat.st_size >= 0 else None, + )) + except OSError: + continue + return items + + +def _scan_meta(chats: Path) -> dict[str, dict[str, Any]]: + result: dict[str, dict[str, Any]] = {} + try: + hashes = list(os.scandir(chats)) + except OSError: + return result + for hash_entry in hashes: + try: + if not hash_entry.is_dir(follow_symlinks=True): + continue + for session in os.scandir(hash_entry.path): + if not session.is_dir(follow_symlinks=True) or session.name in result: + continue + path = Path(session.path) / _META_NAME + try: + data = json.loads(path.read_text(encoding="utf-8"), parse_constant=_reject) + except (OSError, UnicodeError, json.JSONDecodeError, ValueError): + continue + if isinstance(data, dict): + result[session.name] = data + except OSError: + continue + return result + + +def _updated_at(value: Any, stat: os.stat_result) -> str | None: + if type(value) is int and value >= 0: + try: + return format_ms(value) + except Exception: + pass + try: + return format_ms(stat.st_mtime_ns // 1_000_000) + except Exception: + return None + + +def _meta_title(value: Any) -> str | None: + return format_title(value) if isinstance(value, str) else None + + +def _reject(value: str) -> None: + raise ValueError(value) + + +CURSOR_TRAJECTORY_LISTER: Final[CursorTrajectoryLister] = CursorTrajectoryLister() +register_lister(CURSOR_TRAJECTORY_LISTER) + +__all__ = ["CURSOR_TRAJECTORY_LISTER", "CursorTrajectoryLister"] diff --git a/python/src/hypabolic_trajectory/listing/title.py b/python/src/hypabolic_trajectory/listing/title.py index ce1ea86..34e569f 100644 --- a/python/src/hypabolic_trajectory/listing/title.py +++ b/python/src/hypabolic_trajectory/listing/title.py @@ -141,6 +141,28 @@ def derive_generic_user_title(path: str | Path) -> str | None: return None +def derive_cursor_title(path: str | Path) -> str | None: + """Return the first Cursor user turn's joined text as a bounded title.""" + for row in _scan_json_lines(path): + if _string(row.get("role")) != "user": + continue + message = row.get("message") if isinstance(row.get("message"), dict) else None + content = message.get("content") if message else None + if not isinstance(content, list): + continue + text = "\n".join( + part.get("text", "") + for part in content + if isinstance(part, dict) + and _string(part.get("type")) == "text" + and isinstance(part.get("text"), str) + ) + title = title_from_user_text(text) + if title is not None: + return title + return None + + def _scan_json_lines(path: str | Path) -> Iterator[dict[str, Any]]: try: with open(path, "rb") as handle: diff --git a/python/src/hypabolic_trajectory/runtime-capabilities.json b/python/src/hypabolic_trajectory/runtime-capabilities.json index 1a010cb..334975e 100644 --- a/python/src/hypabolic_trajectory/runtime-capabilities.json +++ b/python/src/hypabolic_trajectory/runtime-capabilities.json @@ -9,7 +9,8 @@ "openclaw", "hermes", "ahp", - "grok-build" + "grok-build", + "cursor" ], "outputs": [ "letta-trajectory-v1", diff --git a/python/src/hypabolic_trajectory/sources/__init__.py b/python/src/hypabolic_trajectory/sources/__init__.py index 9bf5aee..227ccc0 100644 --- a/python/src/hypabolic_trajectory/sources/__init__.py +++ b/python/src/hypabolic_trajectory/sources/__init__.py @@ -22,6 +22,7 @@ from hypabolic_trajectory.sources import ahp as _ahp # noqa: E402, F401 from hypabolic_trajectory.sources import claude_code as _claude_code # noqa: E402, F401 from hypabolic_trajectory.sources import codex as _codex # noqa: E402, F401 +from hypabolic_trajectory.sources import cursor as _cursor # noqa: E402, F401 from hypabolic_trajectory.sources import grok_build as _grok_build # noqa: E402, F401 from hypabolic_trajectory.sources import hermes as _hermes # noqa: E402, F401 from hypabolic_trajectory.sources import openclaw as _openclaw # noqa: E402, F401 diff --git a/python/src/hypabolic_trajectory/sources/cursor.py b/python/src/hypabolic_trajectory/sources/cursor.py new file mode 100644 index 0000000..4cbe0c7 --- /dev/null +++ b/python/src/hypabolic_trajectory/sources/cursor.py @@ -0,0 +1,221 @@ +"""Cursor Agent ``agent-transcripts`` JSONL source adapter.""" + +from __future__ import annotations + +import json +from typing import Any, Final + +from hypabolic_trajectory._enums import TrajectorySource +from hypabolic_trajectory.canonical import compact_json +from hypabolic_trajectory.diagnostics import ( + DIAG_IMAGE_CONTENT_DROPPED, + DIAG_INVALID_JSON_LINE, + DIAG_NON_OBJECT_JSON_LINE, + DIAG_TOOL_USE_MISSING_NAME, + DIAG_TURN_ENDED_ERROR, + DIAG_UNKNOWN_CONTENT_PART, + DIAG_UNKNOWN_SEMANTIC_RECORD, + Diagnostic, +) +from hypabolic_trajectory.dto import SourceContext +from hypabolic_trajectory.ir.models import SourceAnchorKind, TrajectoryRole +from hypabolic_trajectory.sources.decoded import DecodedEvent, DecodedSession +from hypabolic_trajectory.sources.protocol import register_source_adapter + +_SOURCE: Final[TrajectorySource] = TrajectorySource.CURSOR +_SOURCE_NAME: Final[str] = "cursor" +_IMAGE_TYPES: Final[frozenset[str]] = frozenset( + {"image", "image_url", "input_image", "output_image"} +) + + +class CursorSourceAdapter: + @property + def source(self) -> TrajectorySource: + return _SOURCE + + def decode( + self, transcript: bytes, *, source_context: SourceContext + ) -> DecodedSession: + return decode_cursor(transcript, source_context=source_context) + + +def decode_cursor( + transcript: bytes, *, source_context: SourceContext | None = None +) -> DecodedSession: + if type(transcript) is not bytes: + raise TypeError("transcript must be bytes") + diagnostics: list[Diagnostic] = [] + events: list[DecodedEvent] = [] + + for line_no, offset, row in _parse_json_lines(transcript, diagnostics): + role = _string(row, "role") + row_type = _string(row, "type") + component_index = 0 + + def emit( + *, kind: str, role_value: TrajectoryRole, content: str | None = None, + tool_name: str | None = None, arguments_json: str | None = None, + ) -> None: + nonlocal component_index + events.append( + DecodedEvent( + kind=kind, # type: ignore[arg-type] + role=role_value, + content=content, + tool_name=tool_name, + arguments_json=arguments_json, + source_offset=offset, + source_anchor_kind=SourceAnchorKind.BYTE, + input_line=line_no, + component_index=component_index, + ) + ) + component_index += 1 + + if role in ("user", "assistant"): + message = row.get("message") + content = message.get("content") if isinstance(message, dict) else None + text_parts: list[str] = [] + if isinstance(content, list): + tool_parts: list[dict[str, Any]] = [] + for part in content: + if not isinstance(part, dict): + continue + part_type = _string(part, "type") + if part_type == "text": + text = part.get("text") + if isinstance(text, str): + text_parts.append(text) + elif role == "assistant" and part_type == "tool_use": + tool_parts.append(part) + elif part_type in _IMAGE_TYPES: + diagnostics.append(Diagnostic( + code=DIAG_IMAGE_CONTENT_DROPPED, + message=( + f"Dropped image content on a Cursor record on line {line_no}." + ), + input_line=line_no, + )) + elif part_type: + diagnostics.append(Diagnostic( + code=DIAG_UNKNOWN_CONTENT_PART, + message=( + f"Skipped an unknown Cursor content part on line {line_no}." + ), + input_line=line_no, + )) + if text_parts: + text = "\n".join(text_parts) + if text.strip(): + emit( + kind="message", + role_value=( + TrajectoryRole.USER if role == "user" else TrajectoryRole.ASSISTANT + ), + content=text, + ) + for part in tool_parts: + name = _non_empty(_string(part, "name")) + if name is None: + diagnostics.append(Diagnostic( + code=DIAG_TOOL_USE_MISSING_NAME, + message=( + f"Skipped a Cursor tool_use part without a name " + f"on line {line_no}." + ), + input_line=line_no, + )) + continue + value = part.get("input") + arguments = compact_json(value) if isinstance(value, dict) else "{}" + # Tool calls are retained even when the assistant has no text. + emit( + kind="tool-call", + role_value=TrajectoryRole.ASSISTANT, + tool_name=name, + arguments_json=arguments, + ) + + continue + + if row_type == "turn_ended": + if _string(row, "status") == "error": + diagnostics.append(Diagnostic( + code=DIAG_TURN_ENDED_ERROR, + message="A Cursor turn ended with an error.", + input_line=line_no, + )) + continue + if role or row_type: + diagnostics.append(Diagnostic( + code=DIAG_UNKNOWN_SEMANTIC_RECORD, + message=f"Skipped an unknown Cursor semantic record on line {line_no}.", + input_line=line_no, + )) + + return DecodedSession( + source=_SOURCE, + source_name=_SOURCE_NAME, + group_resolved=False, + model=None, + events=tuple(events), + model_invocations=(), + diagnostics=tuple(diagnostics), + ) + + +def _parse_json_lines( + transcript: bytes, diagnostics: list[Diagnostic] +) -> list[tuple[int, int, dict[str, Any]]]: + parsed: list[tuple[int, int, dict[str, Any]]] = [] + offset = 0 + line_no = 1 + while offset <= len(transcript): + newline = transcript.find(b"\n", offset) + end = len(transcript) if newline < 0 else newline + line = transcript[offset:end] + if line.endswith(b"\r"): + line = line[:-1] + if line.strip(): + try: + value = json.loads(line, parse_constant=_reject_constant) + except (json.JSONDecodeError, UnicodeDecodeError, ValueError): + diagnostics.append(Diagnostic( + code=DIAG_INVALID_JSON_LINE, + message=f"Skipped invalid JSON on line {line_no}.", + input_line=line_no, + )) + else: + if not isinstance(value, dict): + diagnostics.append(Diagnostic( + code=DIAG_NON_OBJECT_JSON_LINE, + message=f"Skipped non-object JSON on line {line_no}.", + input_line=line_no, + )) + else: + parsed.append((line_no, offset, value)) + if newline < 0: + break + offset = newline + 1 + line_no += 1 + return parsed + + +def _reject_constant(value: str) -> None: + raise ValueError(f"invalid JSON constant: {value}") + + +def _string(value: dict[str, Any], key: str) -> str | None: + item = value.get(key) + return item if isinstance(item, str) else None + + +def _non_empty(value: str | None) -> str | None: + return value if value is not None and value.strip() else None + + +CURSOR_SOURCE_ADAPTER: Final[CursorSourceAdapter] = CursorSourceAdapter() +register_source_adapter(CURSOR_SOURCE_ADAPTER) + +__all__ = ["CURSOR_SOURCE_ADAPTER", "CursorSourceAdapter", "decode_cursor"] diff --git a/python/tests/test_py10_full.py b/python/tests/test_py10_full.py index b3612a7..f89e863 100644 --- a/python/tests/test_py10_full.py +++ b/python/tests/test_py10_full.py @@ -108,6 +108,7 @@ "hermes", "ahp", "grok-build", + "cursor", ) diff --git a/python/tests/test_py10b_sources_claude_codex.py b/python/tests/test_py10b_sources_claude_codex.py index bb35e43..f3a7429 100644 --- a/python/tests/test_py10b_sources_claude_codex.py +++ b/python/tests/test_py10b_sources_claude_codex.py @@ -45,6 +45,7 @@ "hermes", "ahp", "grok-build", + "cursor", ) diff --git a/python/tests/test_py10b_sources_hermes.py b/python/tests/test_py10b_sources_hermes.py index 3a5ff21..ffd332e 100644 --- a/python/tests/test_py10b_sources_hermes.py +++ b/python/tests/test_py10b_sources_hermes.py @@ -38,6 +38,7 @@ "hermes", "ahp", "grok-build", + "cursor", ] CLAIMED_OPS = ["normalize-letta", "normalize-canonical"] diff --git a/python/tests/test_py11_full_conformance.py b/python/tests/test_py11_full_conformance.py index a6c7259..f46b689 100644 --- a/python/tests/test_py11_full_conformance.py +++ b/python/tests/test_py11_full_conformance.py @@ -68,6 +68,7 @@ "hermes", "ahp", "grok-build", + "cursor", ] TIP_OUTPUTS: list[str] = [ "letta-trajectory-v1", @@ -102,9 +103,9 @@ NORMALIZER_CONTRACT_VERSION = "0.2.0" # Current tip case inventory under unfiltered verify (all declared pairs). -# 39 batch + 41 stream cases (LS-08 matrix + H1/H3 + hermes-provider-* export apply). -EXPECTED_TIP_CASES = 80 -EXPECTED_TIP_OPERATIONS = 107 +# 45 batch + 42 stream cases (LS-08 matrix + H1/H3 + hermes-provider-* export apply + Cursor). +EXPECTED_TIP_CASES = 87 +EXPECTED_TIP_OPERATIONS = 118 def _load_json(path: Path) -> dict: @@ -221,5 +222,5 @@ def test_identity_baseline_goldens_unchanged() -> None: assert not failures, "identity baseline mismatches:\n" + "\n".join(failures) assert checked >= 1, "identity baseline checked zero files" - # Tip identity set currently pins 37 identity-bearing goldens. - assert checked == 37, f"expected 37 identity goldens, checked {checked}" + # Tip identity set currently pins 45 identity-bearing goldens. + assert checked == 45, f"expected 45 identity goldens, checked {checked}" diff --git a/python/tests/test_py15b_tip_gate.py b/python/tests/test_py15b_tip_gate.py index 388618f..fcd3464 100644 --- a/python/tests/test_py15b_tip_gate.py +++ b/python/tests/test_py15b_tip_gate.py @@ -29,7 +29,7 @@ VALIDATOR = TOOLS / "validate_release_metadata.py" GENERATOR = TOOLS / "conformance_argv_from_capabilities.py" -TIP_SOURCES = ["pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build"] +TIP_SOURCES = ["pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build", "cursor"] TIP_OUTPUTS = [ "letta-trajectory-v1", "letta-canonical-v1", diff --git a/python/tests/test_py17_first_ship_join.py b/python/tests/test_py17_first_ship_join.py index a6ac012..d18ded3 100644 --- a/python/tests/test_py17_first_ship_join.py +++ b/python/tests/test_py17_first_ship_join.py @@ -52,7 +52,7 @@ ), } -TIP_SOURCES = ["pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build"] +TIP_SOURCES = ["pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build", "cursor"] TIP_OUTPUTS = [ "letta-trajectory-v1", "letta-canonical-v1", @@ -84,10 +84,10 @@ ] TIP_SLICE = "ML13" NORMALIZER_CONTRACT_VERSION = "0.2.0" -# 39 batch + 41 stream cases (LS-08 matrix + H1/H3 + hermes-provider-* export apply). -EXPECTED_TIP_CASES = 80 -EXPECTED_TIP_OPERATIONS = 107 -EXPECTED_IDENTITY_GOLDENS = 37 +# 45 batch + 42 stream cases (LS-08 matrix + H1/H3 + hermes-provider-* export apply + Cursor). +EXPECTED_TIP_CASES = 87 +EXPECTED_TIP_OPERATIONS = 118 +EXPECTED_IDENTITY_GOLDENS = 45 # Exhaustive root __all__ (export owner PY-04a → PY-12 pin). _EXPECTED_ROOT_ALL = frozenset( diff --git a/python/tests/test_smoke.py b/python/tests/test_smoke.py index 25d7c4e..c81a2b2 100644 --- a/python/tests/test_smoke.py +++ b/python/tests/test_smoke.py @@ -66,10 +66,12 @@ def test_schema_and_source_constants() -> None: "hermes", "ahp", "grok-build", + "cursor", ) assert ht.TrajectorySource.PI == "pi" assert ht.TrajectorySource.AHP == "ahp" assert ht.TrajectorySource.GROK_BUILD == "grok-build" + assert ht.TrajectorySource.CURSOR == "cursor" assert set(ht.TrajectorySource) == set(ht.IMPLEMENTED_SOURCES) diff --git a/python/tools/trajectory_conformance/runner.py b/python/tools/trajectory_conformance/runner.py index 5a6c1e8..9ef8c1b 100644 --- a/python/tools/trajectory_conformance/runner.py +++ b/python/tools/trajectory_conformance/runner.py @@ -132,7 +132,7 @@ # ``{temp_root}/store`` (goldens show ``$ROOT/store/...``). All other sources # (pi, openclaw, hermes/ahp stubs) list at the temp root itself. _LISTING_STORE_PREFIX_SOURCES: Final[frozenset[str]] = frozenset( - {"claude-code", "codex", "grok-build"} + {"claude-code", "codex", "grok-build", "cursor"} ) diff --git a/rust/README.md b/rust/README.md index 1a1231a..622ff46 100644 --- a/rust/README.md +++ b/rust/README.md @@ -9,7 +9,7 @@ additionally implements **AHP** Shape A offline snapshot ingest (wire name `ahp`; listing deferred). AHP is **not** in registry packages at `0.1.0` and needs a new tag to publish. -- byte-oriented Pi, Claude Code, Codex, OpenClaw, Hermes, and Grok Build +- byte-oriented Pi, Claude Code, Codex, OpenClaw, Hermes, Grok Build, and Cursor Agent decoding and normalization; - AHP Shape A ChatState snapshot decode (export file / `show --path`; listing empty stub); diff --git a/rust/crates/hypabolic-trajectory/runtime-capabilities.json b/rust/crates/hypabolic-trajectory/runtime-capabilities.json index a42aecb..97e5d85 100644 --- a/rust/crates/hypabolic-trajectory/runtime-capabilities.json +++ b/rust/crates/hypabolic-trajectory/runtime-capabilities.json @@ -9,7 +9,8 @@ "openclaw", "hermes", "ahp", - "grok-build" + "grok-build", + "cursor" ], "outputs": [ "letta-trajectory-v1", diff --git a/rust/crates/hypabolic-trajectory/src/lib.rs b/rust/crates/hypabolic-trajectory/src/lib.rs index 4f01684..d6f14b9 100644 --- a/rust/crates/hypabolic-trajectory/src/lib.rs +++ b/rust/crates/hypabolic-trajectory/src/lib.rs @@ -14,6 +14,7 @@ pub use listing::{ ListingOptions, TrajectoryListing, TrajectoryListingPage, list_ahp_trajectories, list_claude_code_trajectories, list_codex_trajectories, list_grok_build_trajectories, list_hermes_trajectories, list_openclaw_trajectories, list_pi_trajectories, + list_cursor_trajectories, }; pub use model::{ AppliedConfig, Bounds, Diagnostic, Filters, IrRecord, ModelInvocation, ModelTokenUsage, @@ -22,10 +23,10 @@ pub use model::{ TruncationStrategy, }; pub use normalize::{ - AhpSourceAdapter, ClaudeCodeSourceAdapter, CodexSourceAdapter, GrokBuildSourceAdapter, + AhpSourceAdapter, ClaudeCodeSourceAdapter, CodexSourceAdapter, CursorSourceAdapter, GrokBuildSourceAdapter, HermesSourceAdapter, OpenClawSourceAdapter, PiSourceAdapter, SourceAdapter, normalize_ahp, normalize_claude_code, normalize_codex, normalize_grok_build, normalize_hermes, - normalize_openclaw, normalize_pi, + normalize_openclaw, normalize_pi, normalize_cursor, }; pub use projection::{ OutputAdapter, canonical_value, hypabolic_value, letta_value, openai_value, diff --git a/rust/crates/hypabolic-trajectory/src/listing.rs b/rust/crates/hypabolic-trajectory/src/listing.rs index 7a1ba67..7ee7b00 100644 --- a/rust/crates/hypabolic-trajectory/src/listing.rs +++ b/rust/crates/hypabolic-trajectory/src/listing.rs @@ -220,6 +220,55 @@ pub fn list_grok_build_trajectories( paginate(options, items) } +/// Lists Cursor Agent transcripts under `projects/*/agent-transcripts/*`. +pub fn list_cursor_trajectories( + options: &ListingOptions<'_>, +) -> Result { + validate_limit(options.limit)?; + let root = absolute_path(options.root); + let mut items = Vec::new(); + let mut meta = Vec::new(); + if let Ok(hashes) = fs::read_dir(root.join("chats")) { + for hash in hashes.flatten() { + if !hash.file_type().map(|kind| kind.is_dir()).unwrap_or(false) { continue; } + if let Ok(sessions) = fs::read_dir(hash.path()) { + for session in sessions.flatten() { + let path = session.path().join("meta.json"); + let Ok(text) = fs::read_to_string(path) else { continue }; + let Ok(value) = serde_json::from_str::(&text) else { continue }; + if value.is_object() { meta.push((session.file_name().to_string_lossy().into_owned(), value)); } + } + } + } + } + let projects = match fs::read_dir(root.join("projects")) { + Ok(value) => value, + Err(error) if missing_or_denied(&error) => return Ok(TrajectoryListingPage { items, next_cursor: None }), + Err(error) => return Err(io_error("Could not enumerate the Cursor store.", &error)), + }; + for project in projects.flatten() { + if !project.file_type().map(|kind| kind.is_dir()).unwrap_or(false) { continue; } + let sessions_root = project.path().join("agent-transcripts"); + let Ok(sessions) = fs::read_dir(sessions_root) else { continue }; + for session in sessions.flatten() { + if !session.file_type().map(|kind| kind.is_dir()).unwrap_or(false) { continue; } + let id = session.file_name().to_string_lossy().into_owned(); + let path = session.path().join(format!("{id}.jsonl")); + let Ok(metadata) = fs::metadata(&path) else { continue }; + if !metadata.is_file() || path.file_stem().and_then(|value| value.to_str()) != Some(id.as_str()) { continue; } + let found = meta.iter().find(|(key, _)| key == &id).map(|(_, value)| value); + let updated = found.and_then(|value| value.get("updatedAtMs")).and_then(serde_json::Value::as_i64) + .and_then(|value| format_ms(value).ok()).unwrap_or_else(|| { + metadata.modified().ok().and_then(|value| value.duration_since(UNIX_EPOCH).ok()).and_then(|value| i64::try_from(value.as_millis()).ok()).and_then(|value| format_ms(value).ok()).unwrap_or_default() + }); + let title = found.and_then(|value| value.get("title")).and_then(serde_json::Value::as_str).and_then(format_title) + .or_else(|| derive_cursor_title(&path)); + items.push(TrajectoryListing { id, path, updated_at: updated, title, size_bytes: metadata.len() }); + } + } + paginate(options, items) +} + fn absolute_path(path: &Path) -> PathBuf { if path.is_absolute() { path.to_path_buf() @@ -712,6 +761,18 @@ fn derive_generic_user_title(path: &Path) -> Option { None } +fn derive_cursor_title(path: &Path) -> Option { + for value in scan_json_lines(path) { + if value.get("role").and_then(serde_json::Value::as_str) != Some("user") { continue; } + let Some(parts) = value.get("message").and_then(|value| value.get("content")).and_then(serde_json::Value::as_array) else { continue }; + let text = parts.iter().filter_map(|part| { + (part.get("type").and_then(serde_json::Value::as_str) == Some("text")).then(|| part.get("text").and_then(serde_json::Value::as_str)).flatten() + }).collect::>().join("\n"); + if let Some(title) = title_from_user_text(&text) { return Some(title); } + } + None +} + fn scan_json_lines(path: &Path) -> Vec { let Ok(file) = fs::File::open(path) else { return Vec::new(); diff --git a/rust/crates/hypabolic-trajectory/src/model.rs b/rust/crates/hypabolic-trajectory/src/model.rs index 1dcb4fa..8e206a1 100644 --- a/rust/crates/hypabolic-trajectory/src/model.rs +++ b/rust/crates/hypabolic-trajectory/src/model.rs @@ -155,6 +155,8 @@ pub enum TrajectorySource { Ahp, /// Grok Build `chat_history.jsonl`. GrokBuild, + /// Cursor Agent agent-transcript JSONL. + Cursor, } impl TrajectorySource { @@ -167,6 +169,7 @@ impl TrajectorySource { Self::Hermes => "hermes", Self::Ahp => "ahp", Self::GrokBuild => "grok-build", + Self::Cursor => "cursor", } } } diff --git a/rust/crates/hypabolic-trajectory/src/normalize.rs b/rust/crates/hypabolic-trajectory/src/normalize.rs index 901786b..bdae568 100644 --- a/rust/crates/hypabolic-trajectory/src/normalize.rs +++ b/rust/crates/hypabolic-trajectory/src/normalize.rs @@ -248,6 +248,89 @@ pub fn normalize_grok_build(request: NormalizeRequest<'_>) -> Result &'static str { "cursor" } + + fn normalize(&self, request: NormalizeRequest<'_>) -> Result { + normalize_cursor(request) + } +} + +/// Normalizes Cursor Agent `agent-transcripts` JSONL. +pub fn normalize_cursor(request: NormalizeRequest<'_>) -> Result { + let config = resolve_config(request)?; + let mut events = Vec::new(); + let mut diagnostics = Vec::new(); + let mut offset = 0_usize; + let mut line = 1_usize; + loop { + let relative_end = request.transcript[offset..].iter().position(|value| *value == b'\n'); + let end = relative_end.map_or(request.transcript.len(), |value| offset + value); + let line_end = if end > offset && request.transcript[end - 1] == b'\r' { end - 1 } else { end }; + let slice = &request.transcript[offset..line_end]; + if !slice.iter().all(|value| matches!(*value, b' ' | b'\t' | b'\r')) { + let source_offset = i64::try_from(offset).map_err(|_| TrajectoryError::new("invalid_input", "Transcript byte offset exceeds signed 64-bit range."))?; + match std::str::from_utf8(slice).ok().and_then(|text| serde_json::from_str::(text).ok()) { + Some(Value::Object(row)) => { + let role = string_value(row.get("role")); + let row_type = string_value(row.get("type")); + let mut component_index = 0_usize; + let mut emit = |kind: EventKind, role_value: Role, content: Option, tool_name: Option, arguments_json: Option| { + events.push(DecodedEvent { kind, role: role_value, content, tool_call_id: None, tool_name, arguments_json, is_error: None, native_id: None, producer_version: None, source_sequence: None, source_offset, input_line: Some(line), timestamp_ms: None, timestamp_precise: None, component_index, model: None }); + component_index += 1; + }; + if matches!(role, Some("user" | "assistant")) { + let content = row.get("message").and_then(Value::as_object).and_then(|message| message.get("content")).and_then(Value::as_array); + let mut texts = Vec::new(); + if let Some(parts) = content { + for part in parts { + let Some(part) = part.as_object() else { continue }; + match string_value(part.get("type")) { + Some("text") => if let Some(text) = string_value(part.get("text")) { texts.push(text.to_owned()); }, + Some("image" | "image_url" | "input_image" | "output_image") => diagnostics.push(Diagnostic { code: "image_content_dropped".into(), message: format!("Dropped image content on a Cursor record on line {line}."), input_line: Some(line), record_index: None, count: None }), + Some("tool_use") if role == Some("assistant") => {}, + Some(_) => diagnostics.push(Diagnostic { code: "unknown_content_part".into(), message: format!("Skipped an unknown Cursor content part on line {line}."), input_line: Some(line), record_index: None, count: None }), + None => {}, + } + } + } + let text = texts.join("\n"); + if !text.trim().is_empty() { emit(EventKind::Message, if role == Some("user") { Role::User } else { Role::Assistant }, Some(text), None, None); } + if role == Some("assistant") { + if let Some(parts) = content { + for part in parts { + let Some(part) = part.as_object() else { continue }; + if string_value(part.get("type")) != Some("tool_use") { continue; } + let Some(name) = string_value(part.get("name")).filter(|value| !value.trim().is_empty()) else { + diagnostics.push(Diagnostic { code: "tool_use_missing_name".into(), message: format!("Skipped a Cursor tool_use part without a name on line {line}."), input_line: Some(line), record_index: None, count: None }); + continue; + }; + let arguments = match part.get("input").filter(|value| value.is_object()) { Some(value) => relaxed_json(value)?, None => "{}".into() }; + emit(EventKind::ToolCall, Role::Assistant, None, Some(name.into()), Some(arguments)); + } + } + } + } else if row_type == Some("turn_ended") { + if string_value(row.get("status")) == Some("error") { diagnostics.push(Diagnostic { code: "turn_ended_error".into(), message: "A Cursor turn ended with an error.".into(), input_line: Some(line), record_index: None, count: None }); } + } else if role.is_some() || row_type.is_some() { + diagnostics.push(Diagnostic { code: "unknown_semantic_record".into(), message: format!("Skipped an unknown Cursor semantic record on line {line}."), input_line: Some(line), record_index: None, count: None }); + } + } + Some(_) => diagnostics.push(Diagnostic { code: "non_object_json_line".into(), message: format!("Skipped non-object JSON on line {line}."), input_line: Some(line), record_index: None, count: None }), + None => diagnostics.push(Diagnostic { code: "invalid_json_line".into(), message: format!("Skipped invalid JSON on line {line}."), input_line: Some(line), record_index: None, count: None }), + } + } + if end == request.transcript.len() { break; } + offset = end + 1; + line += 1; + } + normalize_decoded(config, DecodedSession { source: TrajectorySource::Cursor, source_name: "cursor", group_id: None, cwd: None, git_branch: None, model: None, producer_version: None, created_at_ms: None, events, model_invocations: Vec::new(), diagnostics }) +} + fn decode_grok_build( bytes: &[u8], include_encrypted_reasoning: bool, diff --git a/rust/crates/hypabolic-trajectory/src/streaming.rs b/rust/crates/hypabolic-trajectory/src/streaming.rs index 5eca545..e1cbc99 100644 --- a/rust/crates/hypabolic-trajectory/src/streaming.rs +++ b/rust/crates/hypabolic-trajectory/src/streaming.rs @@ -11,7 +11,7 @@ use crate::model::{ NormalizeOptions, NormalizeRequest, SourceContext, TrajectoryError, TrajectorySource, }; use crate::normalize::{ - normalize_ahp, normalize_claude_code, normalize_codex, normalize_grok_build, normalize_hermes, + normalize_ahp, normalize_claude_code, normalize_codex, normalize_cursor, normalize_grok_build, normalize_hermes, normalize_openclaw, normalize_pi, }; use crate::projection::{hypabolic_value, sha256}; @@ -1201,6 +1201,7 @@ fn normalize_source( TrajectorySource::Hermes => normalize_hermes(request), TrajectorySource::Ahp => normalize_ahp(request), TrajectorySource::GrokBuild => normalize_grok_build(request), + TrajectorySource::Cursor => normalize_cursor(request), } } diff --git a/rust/tools/trajectory-cli/README.md b/rust/tools/trajectory-cli/README.md index 266e9d7..9666d80 100644 --- a/rust/tools/trajectory-cli/README.md +++ b/rust/tools/trajectory-cli/README.md @@ -7,7 +7,7 @@ Local sample TUI for browsing agent sessions with ## What it does -- Lists sessions from local agent stores (Pi, Claude Code, Codex, OpenClaw, Hermes, Grok Build) +- Lists sessions from local agent stores (Pi, Claude Code, Codex, OpenClaw, Hermes, Grok Build, Cursor Agent) - Normalizes AHP Shape A offline snapshots via `show --path` (no default store) - Interactive browse with `dialoguer` selection prompts, then Watch live or Show snapshot - Privacy-safe summaries (record counts, roles, tool calls, diagnostics) @@ -26,6 +26,7 @@ This sample is a **consumer process**, not a Trajectory daemon. | `hermes` | `~/.hermes` | `TRAJECTORY_HERMES_ROOT` | | `ahp` | _(none — export file only)_ | _(not applicable)_ | | `grok-build` (alias `grok`) | `$GROK_HOME/sessions` or `~/.grok/sessions` | `TRAJECTORY_GROK_BUILD_ROOT` or `GROK_HOME` | +| `cursor` (alias `cursor-agent`) | `$CURSOR_HOME` or `~/.cursor` | `TRAJECTORY_CURSOR_ROOT` or `CURSOR_HOME` | Hermes core listing is SQLite-free and returns empty pages. Export message JSON from Hermes and use `show --path`. @@ -101,7 +102,7 @@ Shared flags: `--source`, `--root`, `--limit`, `--show-content`, `--watch`, Stream flags: `--emit snapshot+delta|snapshot|delta` (default `snapshot+delta`), `--follow`, `--interval`, `--max-updates`, `--path` / `--id` (+ explicit `--root` for listing). File stream sources: `pi`, `claude-code`, `codex`, `openclaw`, -`grok-build`. +`grok-build`, `cursor`. AHP stream flags: `--url` (sample: `fake://…`), `--chat`, `--from-seq`, `--token` / `TRAJECTORY_AHP_TOKEN`, `--snapshot-path`, `--actions-path`. diff --git a/rust/tools/trajectory-cli/src/main.rs b/rust/tools/trajectory-cli/src/main.rs index 4f28476..42cb218 100644 --- a/rust/tools/trajectory-cli/src/main.rs +++ b/rust/tools/trajectory-cli/src/main.rs @@ -21,9 +21,9 @@ use hypabolic_trajectory::{ ListingOptions, NormalizeOptions, NormalizeRequest, RecordKind, SourceContext, StreamDelivery, StreamOptions, StreamUpdate, Trajectory, TrajectoryError, TrajectoryListing, TrajectorySource, list_ahp_trajectories, list_claude_code_trajectories, list_codex_trajectories, - list_grok_build_trajectories, list_hermes_trajectories, list_openclaw_trajectories, + list_cursor_trajectories, list_grok_build_trajectories, list_hermes_trajectories, list_openclaw_trajectories, list_pi_trajectories, normalize_ahp, normalize_claude_code, normalize_codex, - normalize_grok_build, normalize_hermes, normalize_openclaw, normalize_pi, project_hypabolic, + normalize_cursor, normalize_grok_build, normalize_hermes, normalize_openclaw, normalize_pi, project_hypabolic, project_letta, }; use hypabolic_trajectory_ahp::{ @@ -44,10 +44,12 @@ enum SourceArg { Ahp, #[value(name = "grok-build", alias = "grok")] GrokBuild, + #[value(name = "cursor", alias = "cursor-agent")] + Cursor, } impl SourceArg { - const ALL: [SourceArg; 7] = [ + const ALL: [SourceArg; 8] = [ SourceArg::Pi, SourceArg::ClaudeCode, SourceArg::Codex, @@ -55,6 +57,7 @@ impl SourceArg { SourceArg::Hermes, SourceArg::Ahp, SourceArg::GrokBuild, + SourceArg::Cursor, ]; fn wire_name(self) -> &'static str { @@ -66,6 +69,7 @@ impl SourceArg { Self::Hermes => "hermes", Self::Ahp => "ahp", Self::GrokBuild => "grok-build", + Self::Cursor => "cursor", } } } @@ -113,7 +117,7 @@ struct Cli { #[command(subcommand)] command: Option, - /// Transcript source: pi, claude-code, codex, openclaw, hermes, ahp, grok-build. + /// Transcript source: pi, claude-code, codex, openclaw, hermes, ahp, grok-build, cursor. #[arg(short, long, global = true, value_enum)] source: Option, @@ -251,6 +255,7 @@ fn source_to_trajectory(source: SourceArg) -> TrajectorySource { SourceArg::Hermes => TrajectorySource::Hermes, SourceArg::Ahp => TrajectorySource::Ahp, SourceArg::GrokBuild => TrajectorySource::GrokBuild, + SourceArg::Cursor => TrajectorySource::Cursor, } } @@ -275,7 +280,7 @@ fn run_stream( if !is_stream_file_source(source) { return Err(TrajectoryError::new( "invalid_input", - "stream supports file JSONL sources only: pi, claude-code, codex, openclaw, grok-build. Use ahp-stream for AHP.", + "stream supports file JSONL sources only: pi, claude-code, codex, openclaw, grok-build, cursor. Use ahp-stream for AHP.", )); } let (root, path, group_id, follow) = if path.is_none() && id.is_none() { @@ -304,7 +309,7 @@ fn follow_file_session( let delivery = emit.delivery(); // Grok history lines do not embed the session id; listing id is the group. // Other file sources lock group from the transcript. - let group_id = if matches!(source, SourceArg::GrokBuild) { + let group_id = if matches!(source, SourceArg::GrokBuild | SourceArg::Cursor) { group_id } else { None @@ -792,7 +797,7 @@ fn run_browse(cli: &Cli, id: Option) -> Result normalize_hermes(request), SourceArg::Ahp => normalize_ahp(request), SourceArg::GrokBuild => normalize_grok_build(request), + SourceArg::Cursor => normalize_cursor(request), } } @@ -1202,6 +1208,7 @@ fn list_source( SourceArg::Hermes => list_hermes_trajectories(&options), SourceArg::Ahp => list_ahp_trajectories(&options), SourceArg::GrokBuild => list_grok_build_trajectories(&options), + SourceArg::Cursor => list_cursor_trajectories(&options), } } @@ -1218,6 +1225,7 @@ fn resolve_root(source: SourceArg, root_override: Option<&Path>) -> PathBuf { SourceArg::Hermes => "TRAJECTORY_HERMES_ROOT", SourceArg::Ahp => "TRAJECTORY_AHP_ROOT", SourceArg::GrokBuild => "TRAJECTORY_GROK_BUILD_ROOT", + SourceArg::Cursor => "TRAJECTORY_CURSOR_ROOT", }; if let Ok(value) = env::var(env_key) { if !value.trim().is_empty() { @@ -1262,6 +1270,7 @@ fn resolve_root(source: SourceArg, root_override: Option<&Path>) -> PathBuf { } home.join(".grok").join("sessions") } + SourceArg::Cursor => env::var("CURSOR_HOME").ok().filter(|value| !value.trim().is_empty()).map(|value| PathBuf::from(expand_home(value.trim()))).unwrap_or_else(|| home.join(".cursor")), } } @@ -1278,6 +1287,7 @@ fn describe_default(source: SourceArg) -> &'static str { SourceArg::GrokBuild => { "~/.grok/sessions (or $GROK_HOME/sessions / TRAJECTORY_GROK_BUILD_ROOT)" } + SourceArg::Cursor => "$CURSOR_HOME or ~/.cursor (or TRAJECTORY_CURSOR_ROOT)", } } diff --git a/rust/tools/trajectory-conformance/src/main.rs b/rust/tools/trajectory-conformance/src/main.rs index 9220996..e40820a 100644 --- a/rust/tools/trajectory-conformance/src/main.rs +++ b/rust/tools/trajectory-conformance/src/main.rs @@ -14,9 +14,9 @@ use hypabolic_trajectory::{ TrajectorySource, TruncationStrategy, apply_ahp_actions, apply_ahp_snapshot, apply_append, apply_hermes_export, apply_snapshot, create_stream, finish_stream, json_safe_from_value, list_ahp_trajectories, list_claude_code_trajectories, list_codex_trajectories, - list_grok_build_trajectories, list_hermes_trajectories, list_openclaw_trajectories, + list_cursor_trajectories, list_grok_build_trajectories, list_hermes_trajectories, list_openclaw_trajectories, list_pi_trajectories, normalize_ahp, normalize_claude_code, normalize_codex, - normalize_grok_build, normalize_hermes, normalize_openclaw, normalize_pi, project_canonical, + normalize_cursor, normalize_grok_build, normalize_hermes, normalize_openclaw, normalize_pi, project_canonical, project_hypabolic, project_letta, project_minimal_jsonl, project_openai, project_opentelemetry, reset_stream, update_to_value, }; @@ -182,7 +182,7 @@ fn run() -> Result { } if !matches!( manifest.source.as_str(), - "pi" | "claude-code" | "codex" | "openclaw" | "hermes" | "ahp" | "grok-build" + "pi" | "claude-code" | "codex" | "openclaw" | "hermes" | "ahp" | "grok-build" | "cursor" ) { return Err(format!( "Rust does not support source '{}'.", @@ -312,6 +312,7 @@ fn parse_trajectory_source(name: &str) -> Result Ok(TrajectorySource::Hermes), "ahp" => Ok(TrajectorySource::Ahp), "grok-build" => Ok(TrajectorySource::GrokBuild), + "cursor" => Ok(TrajectorySource::Cursor), other => Err(StreamEngineError::Protocol(format!( "Unknown stream source '{other}'." ))), @@ -890,6 +891,7 @@ fn execute( "hermes" => normalize_hermes(normalize_request), "ahp" => normalize_ahp(normalize_request), "grok-build" => normalize_grok_build(normalize_request), + "cursor" => normalize_cursor(normalize_request), _ => unreachable!("source is validated before execution"), }?; let output = match operation { @@ -945,7 +947,7 @@ fn execute_listing(repository_root: &Path, manifest: &Manifest) -> Result Result list_hermes_trajectories(&options), "ahp" => list_ahp_trajectories(&options), "grok-build" => list_grok_build_trajectories(&options), + "cursor" => list_cursor_trajectories(&options), _ => unreachable!("source is validated before execution"), }?; let items = page diff --git a/tools/validate_release_metadata.py b/tools/validate_release_metadata.py index 1e3e6ad..99baa98 100755 --- a/tools/validate_release_metadata.py +++ b/tools/validate_release_metadata.py @@ -27,7 +27,7 @@ def read_package_version(root: Path) -> str: "jsonl-minimal", "otel-genai-spans-v1", ] -EXPECTED_SOURCES = ["pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build"] +EXPECTED_SOURCES = ["pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build", "cursor"] # Tip capability set advertised by peer runtimes (ML13 + LS-12 stream core). # PY-15b / ship require python/runtime-capabilities.json equality to this set # (order-sensitive). Optional package stream caps (file-io / ahp-client / diff --git a/typescript/packages/trajectory-cli/README.md b/typescript/packages/trajectory-cli/README.md index 89346eb..ff62166 100644 --- a/typescript/packages/trajectory-cli/README.md +++ b/typescript/packages/trajectory-cli/README.md @@ -8,7 +8,7 @@ Local sample CLI/TUI for browsing agent sessions with ## What it does -- Lists sessions from local agent stores (Pi, Claude Code, Codex, OpenClaw, Hermes, Grok Build) +- Lists sessions from local agent stores (Pi, Claude Code, Codex, OpenClaw, Hermes, Grok Build, Cursor Agent) - Normalizes AHP Shape A offline snapshots via `show --path` (no default store) - Interactive browse: pick source → pick session → Watch live or Show snapshot - Summary includes record counts, roles, tool calls, diagnostics, and message/Hypabolic projections @@ -27,6 +27,7 @@ This sample is a **consumer process**, not a Trajectory daemon. | `hermes` | `~/.hermes` | `TRAJECTORY_HERMES_ROOT` | | `ahp` | _(none — export file only)_ | _(not applicable)_ | | `grok-build` (alias `grok`) | `$GROK_HOME/sessions` or `~/.grok/sessions` | `TRAJECTORY_GROK_BUILD_ROOT` or `GROK_HOME` | +| `cursor` (alias `cursor-agent`) | `$CURSOR_HOME` or `~/.cursor` | `TRAJECTORY_CURSOR_ROOT` or `CURSOR_HOME` | Hermes listing in the core Node package is SQLite-free and returns empty pages. Export message JSON from Hermes and use `show --path`. @@ -105,7 +106,7 @@ Shared flags: `--source`, `--root`, `--limit`, `--show-content`, `--format`. Stream flags: `--emit snapshot+delta|snapshot|delta` (default `snapshot+delta`), `--follow`, `--interval`, `--max-updates`, `--path` / `--id` (+ explicit `--root` for listing). File stream sources: `pi`, `claude-code`, `codex`, `openclaw`, -`grok-build`. +`grok-build`, `cursor`. AHP stream flags: `--url` (sample: `fake://…`), `--chat`, `--from-seq`, `--token` / `TRAJECTORY_AHP_TOKEN`, `--snapshot-path`, `--actions-path`. diff --git a/typescript/packages/trajectory-cli/src/cli.ts b/typescript/packages/trajectory-cli/src/cli.ts index 8c4d3ca..0b43b9d 100644 --- a/typescript/packages/trajectory-cli/src/cli.ts +++ b/typescript/packages/trajectory-cli/src/cli.ts @@ -27,6 +27,7 @@ import { listAhpTrajectories, listClaudeCodeTrajectories, listCodexTrajectories, + listCursorTrajectories, listGrokBuildTrajectories, listHermesTrajectories, listOpenClawTrajectories, @@ -42,9 +43,9 @@ import { type AhpTransport, } from "@hypabolic/trajectory-ahp"; -const SOURCES = ["pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build"] as const; +const SOURCES = ["pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build", "cursor"] as const; type SourceName = (typeof SOURCES)[number]; -const STREAM_FILE_SOURCES = ["pi", "claude-code", "codex", "openclaw", "grok-build"] as const; +const STREAM_FILE_SOURCES = ["pi", "claude-code", "codex", "openclaw", "grok-build", "cursor"] as const; type StreamFileSource = (typeof STREAM_FILE_SOURCES)[number]; type EmitMode = "snapshot+delta" | "snapshot" | "delta"; @@ -253,6 +254,7 @@ function parseSource(value: string): SourceName { if ((SOURCES as readonly string[]).includes(normalized)) return normalized as SourceName; if (normalized === "claude" || normalized === "claudecode") return "claude-code"; if (normalized === "grok") return "grok-build"; + if (normalized === "cursor-agent") return "cursor"; throw new TrajectoryNormalizationError( "unknown_source", `Unknown source '${value}'. Expected one of: ${SOURCES.join(", ")} (alias: grok).`, @@ -274,6 +276,7 @@ function defaultRoot(source: SourceName): string { hermes: "TRAJECTORY_HERMES_ROOT", ahp: "TRAJECTORY_AHP_ROOT", "grok-build": "TRAJECTORY_GROK_BUILD_ROOT", + cursor: "TRAJECTORY_CURSOR_ROOT", }; const fromTrajectory = process.env[envMap[source]]?.trim(); if (fromTrajectory) return expandHome(fromTrajectory); @@ -303,6 +306,8 @@ function defaultRoot(source: SourceName): string { if (grokHome) return join(expandHome(grokHome), "sessions"); return join(home, ".grok", "sessions"); } + case "cursor": + return expandHome(process.env.CURSOR_HOME?.trim() || join(home, ".cursor")); } } @@ -322,6 +327,8 @@ function describeDefault(source: SourceName): string { return "explicit export root only (no home default)"; case "grok-build": return "$GROK_HOME/sessions or ~/.grok/sessions (or TRAJECTORY_GROK_BUILD_ROOT)"; + case "cursor": + return "$CURSOR_HOME or ~/.cursor (or TRAJECTORY_CURSOR_ROOT)"; } } @@ -346,6 +353,8 @@ async function listForSource( return listAhpTrajectories(options); case "grok-build": return listGrokBuildTrajectories(options); + case "cursor": + return listCursorTrajectories(options); } } @@ -402,7 +411,7 @@ async function followFileSession( const delivery = emitToDelivery(args.emit); // Grok history lines do not embed the session id; listing id is the group. // Other file sources lock group from the transcript. - const groupId = source === "grok-build" ? target.groupId : undefined; + const groupId = source === "grok-build" || source === "cursor" ? target.groupId : undefined; console.log(`${BOLD}${CYAN}Trajectory stream${RESET} ${DIM}live file follow (not a daemon)${RESET}`); console.log(`${DIM}source${RESET} ${source}`); console.log(`${DIM}root${RESET} ${target.root}`); @@ -1205,7 +1214,7 @@ Usage: [--from-seq N] [--token T] [--snapshot-path F] [--actions-path F] trajectory help -Sources: ${SOURCES.join(", ")} +Sources: ${SOURCES.join(", ")} (alias: cursor-agent) File stream sources: ${STREAM_FILE_SOURCES.join(", ")} Default roots: @@ -1216,6 +1225,7 @@ Default roots: hermes ~/.hermes ahp explicit export root only (use show --path) grok-build $GROK_HOME/sessions or ~/.grok/sessions (alias: grok) + cursor $CURSOR_HOME or ~/.cursor (alias: cursor-agent) Root overrides: --root or TRAJECTORY__ROOT (e.g. TRAJECTORY_PI_ROOT). OpenClaw also honors OPENCLAW_STATE_DIR / CLAWDBOT_STATE_DIR. diff --git a/typescript/packages/trajectory-node/src/index.ts b/typescript/packages/trajectory-node/src/index.ts index 57bddec..182e1c3 100644 --- a/typescript/packages/trajectory-node/src/index.ts +++ b/typescript/packages/trajectory-node/src/index.ts @@ -120,6 +120,13 @@ export async function listGrokBuildTrajectories( return listDiscovered(options, discoverGrokBuild); } +/** Cursor Agent sessions under projects and agent-transcripts directories. */ +export async function listCursorTrajectories( + options: ListingOptions, +): Promise { + return listDiscovered(options, discoverCursor); +} + async function discoverHermes(root: string): Promise { // SQLite-backed session discovery is intentionally not implemented in the // core Node listing package so package dependencies stay free of native @@ -181,6 +188,52 @@ async function discoverGrokBuild(root: string): Promise { return items; } +async function discoverCursor(root: string): Promise { + const absoluteRoot = isAbsolute(root) ? root : resolve(root); + const meta = new Map>(); + try { + const hashes = await readdir(join(absoluteRoot, "chats"), { withFileTypes: true }); + for (const hash of hashes) { + if (!hash.isDirectory()) continue; + let sessions; + try { sessions = await readdir(join(absoluteRoot, "chats", hash.name), { withFileTypes: true }); } + catch (error) { if (isMissingOrDenied(error)) continue; throw error; } + for (const session of sessions) { + if (!session.isDirectory() || meta.has(session.name)) continue; + try { + const parsed: unknown = JSON.parse(await readFile(join(absoluteRoot, "chats", hash.name, session.name, "meta.json"), "utf8")); + if (isRecord(parsed)) meta.set(session.name, parsed); + } catch { /* malformed or absent metadata is optional */ } + } + } + } catch (error) { if (!isMissingOrDenied(error)) throw error; } + const items: TrajectoryListing[] = []; + let projects; + try { projects = await readdir(join(absoluteRoot, "projects"), { withFileTypes: true }); } + catch (error) { if (isMissingOrDenied(error)) return items; throw error; } + for (const project of projects) { + if (!project.isDirectory()) continue; + let sessions; + try { sessions = await readdir(join(absoluteRoot, "projects", project.name, "agent-transcripts"), { withFileTypes: true }); } + catch (error) { if (isMissingOrDenied(error)) continue; throw error; } + for (const session of sessions) { + if (!session.isDirectory()) continue; + const path = join(absoluteRoot, "projects", project.name, "agent-transcripts", session.name, `${session.name}.jsonl`); + try { + const info = await stat(path); + if (!info.isFile()) continue; + const entry = meta.get(session.name); + const updatedAtMs = entry?.updatedAtMs; + const updatedAt = typeof updatedAtMs === "number" && Number.isSafeInteger(updatedAtMs) && updatedAtMs >= 0 + ? new Date(updatedAtMs).toISOString() : info.mtime.toISOString(); + const title = typeof entry?.title === "string" ? formatTitle(entry.title) : await deriveCursorTitle(path); + items.push({ id: session.name, path, updatedAt, ...(title === undefined ? {} : { title }), sizeBytes: info.size }); + } catch (error) { if (!isMissingOrDenied(error)) throw error; } + } + } + return items; +} + async function readGrokBuildSummaryMeta( summaryPath: string, fallbackMtime: Date, @@ -459,6 +512,18 @@ async function deriveGenericUserTitle(path: string): Promise return undefined; } +async function deriveCursorTitle(path: string): Promise { + for await (const row of scanJsonLines(path)) { + if (stringField(row.role) !== "user") continue; + const message = isRecord(row.message) ? row.message : undefined; + const parts = Array.isArray(message?.content) ? message.content : []; + const text = parts.filter(isRecord).filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text as string).join("\n"); + const title = titleFromUserText(text); + if (title !== undefined) return title; + } + return undefined; +} + async function* scanJsonLines(path: string): AsyncGenerator> { const stream = createReadStream(path, { encoding: "utf8", diff --git a/typescript/packages/trajectory-testing/src/cli.ts b/typescript/packages/trajectory-testing/src/cli.ts index 46fbc43..3f07195 100644 --- a/typescript/packages/trajectory-testing/src/cli.ts +++ b/typescript/packages/trajectory-testing/src/cli.ts @@ -41,6 +41,7 @@ import { listAhpTrajectories, listClaudeCodeTrajectories, listCodexTrajectories, + listCursorTrajectories, listGrokBuildTrajectories, listHermesTrajectories, listOpenClawTrajectories, @@ -181,7 +182,7 @@ async function execute(request: Request): Promise { const caseDirectory = safeResolve(casesRoot, request.case); const manifest = JSON.parse(await readFile(join(caseDirectory, "case.json"), "utf8")) as Manifest; if (manifest.id !== request.case) throw new Error("The requested case does not match its manifest ID."); - if (!["pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build"].includes(manifest.source)) { + if (!["pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build", "cursor"].includes(manifest.source)) { throw new Error(`TypeScript does not support source '${manifest.source}'.`); } @@ -766,6 +767,8 @@ async function executeListing(repositoryRoot: string, manifest: Manifest): Promi ? listAhpTrajectories : manifest.source === "grok-build" ? listGrokBuildTrajectories + : manifest.source === "cursor" + ? listCursorTrajectories : listPiTrajectories; const page = await list({ root: listingRoot, diff --git a/typescript/packages/trajectory/runtime-capabilities.json b/typescript/packages/trajectory/runtime-capabilities.json index f911c8c..43ebf17 100644 --- a/typescript/packages/trajectory/runtime-capabilities.json +++ b/typescript/packages/trajectory/runtime-capabilities.json @@ -9,7 +9,8 @@ "openclaw", "hermes", "ahp", - "grok-build" + "grok-build", + "cursor" ], "outputs": [ "letta-trajectory-v1", diff --git a/typescript/packages/trajectory/src/index.ts b/typescript/packages/trajectory/src/index.ts index eafbf79..a4d4e79 100644 --- a/typescript/packages/trajectory/src/index.ts +++ b/typescript/packages/trajectory/src/index.ts @@ -3,7 +3,7 @@ * adapters derive byte anchors from this UTF-8 encoding, never UTF-16 indices. */ export type TranscriptInput = Uint8Array | string; -export type TrajectorySource = "pi" | "claude-code" | "codex" | "openclaw" | "hermes" | "ahp" | "grok-build"; +export type TrajectorySource = "pi" | "claude-code" | "codex" | "openclaw" | "hermes" | "ahp" | "grok-build" | "cursor"; export interface SourceContext { readonly groupId?: string; @@ -41,7 +41,7 @@ export interface TrajectoryDiagnostic { } export const NORMALIZER_CONTRACT_VERSION = "0.2.0"; -export const ImplementedSources = ["pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build"] as const; +export const ImplementedSources = ["pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build", "cursor"] as const; export const OutputSchemaIds = { lettaTrajectoryV1: "letta-trajectory-v1", lettaCanonicalV1: "letta-canonical-v1", @@ -62,6 +62,7 @@ import { normalizeClaudeCode, normalizeCodex, normalizeGrokBuild, + normalizeCursor, normalizeHermes, normalizeOpenClaw, normalizePi, @@ -102,6 +103,7 @@ export function normalizeToIR(request: NormalizeRequest): TrajectoryIR { if (request.source === "hermes") return normalizeHermes(normalized); if (request.source === "ahp") return normalizeAhp(normalized); if (request.source === "grok-build") return normalizeGrokBuild(normalized); + if (request.source === "cursor") return normalizeCursor(normalized); throw new TrajectoryNormalizationError("unknown_source", `No source adapter is registered for '${String(request.source)}'.`); } diff --git a/typescript/packages/trajectory/src/internal.ts b/typescript/packages/trajectory/src/internal.ts index 99abac2..bcf5f9a 100644 --- a/typescript/packages/trajectory/src/internal.ts +++ b/typescript/packages/trajectory/src/internal.ts @@ -207,6 +207,10 @@ export function normalizeGrokBuild(request: NormalizeRequest & { transcriptBytes return normalizeDecoded(request, decodeGrokBuild(request.transcriptBytes, request.sourceContext)); } +export function normalizeCursor(request: NormalizeRequest & { transcriptBytes: Uint8Array }): TrajectoryIR { + return normalizeDecoded(request, decodeCursor(request.transcriptBytes)); +} + function normalizeDecoded( request: NormalizeRequest & { transcriptBytes: Uint8Array }, decoded: DecodedSession, @@ -1815,6 +1819,60 @@ function decodeGrokBuild( }; } +function decodeCursor(bytes: Uint8Array): DecodedSession { + const diagnostics: TrajectoryDiagnostic[] = []; + const events: DecodedEvent[] = []; + forEachJsonLine(bytes, diagnostics, (row, inputLine, sourceOffset) => { + const role = stringValue(row.role); + const type = stringValue(row.type); + let componentIndex = 0; + const emit = (kind: EventKind, eventRole: Role, values: DecodedEventValues): void => { + const defined = Object.fromEntries(Object.entries(values).filter((entry) => entry[1] !== undefined)) as Partial; + events.push({ kind, role: eventRole, sourceOffset, inputLine, componentIndex: componentIndex++, ...defined }); + }; + if (role === "user" || role === "assistant") { + const message = isObject(row.message) ? row.message : undefined; + const content = message && Array.isArray(message.content) ? message.content : []; + const toolParts: JsonObject[] = []; + const textParts: string[] = []; + for (const value of content) { + if (!isObject(value)) continue; + const partType = stringValue(value.type); + if (partType === "text") { + const text = stringValue(value.text); + if (text !== undefined) textParts.push(text); + } else if (role === "assistant" && partType === "tool_use") { + toolParts.push(value); + } else if (partType === "image" || partType === "image_url" || partType === "input_image" || partType === "output_image") { + diagnostics.push({ code: "image_content_dropped", message: `Dropped image content on a Cursor record on line ${inputLine}.`, inputLine }); + } else if (partType) { + diagnostics.push({ code: "unknown_content_part", message: `Skipped an unknown Cursor content part on line ${inputLine}.`, inputLine }); + } + } + const text = textParts.join("\n"); + if (text.trim()) emit("message", role, { content: text }); + if (role === "assistant") { + for (const part of toolParts) { + const name = nonEmpty(stringValue(part.name)); + if (!name) { + diagnostics.push({ code: "tool_use_missing_name", message: `Skipped a Cursor tool_use part without a name on line ${inputLine}.`, inputLine }); + continue; + } + const input = isObject(part.input) ? compactJson(part.input) : "{}"; + emit("tool-call", "assistant", { toolName: name, argumentsJson: input }); + } + } + return; + } + if (type === "turn_ended") { + if (stringValue(row.status) === "error") diagnostics.push({ code: "turn_ended_error", message: "A Cursor turn ended with an error.", inputLine }); + return; + } + if (role || type) diagnostics.push({ code: "unknown_semantic_record", message: `Skipped an unknown Cursor semantic record on line ${inputLine}.`, inputLine }); + }); + return { source: "cursor", sourceName: "cursor", groupResolved: false, events, modelInvocations: [], diagnostics }; +} + function grokBuildContentText( content: JsonValue | undefined, diagnostics: TrajectoryDiagnostic[], diff --git a/typescript/packages/trajectory/src/streaming.ts b/typescript/packages/trajectory/src/streaming.ts index 6b02ddb..0bb401f 100644 --- a/typescript/packages/trajectory/src/streaming.ts +++ b/typescript/packages/trajectory/src/streaming.ts @@ -10,6 +10,7 @@ import { normalizeClaudeCode, normalizeCodex, normalizeGrokBuild, + normalizeCursor, normalizeHermes, normalizeOpenClaw, normalizePi, @@ -47,6 +48,7 @@ function normalizeToIR(request: { if (request.source === "hermes") return normalizeHermes(normalized); if (request.source === "ahp") return normalizeAhp(normalized); if (request.source === "grok-build") return normalizeGrokBuild(normalized); + if (request.source === "cursor") return normalizeCursor(normalized); throw new TrajectoryNormalizationError( "unknown_source", `No source adapter is registered for '${String(request.source)}'.`, From 1723903decf6b8e5e8a7da569f58605dfe38875c Mon Sep 17 00:00:00 2001 From: Matthew Gribben Date: Thu, 13 Aug 2026 20:51:41 +1000 Subject: [PATCH 3/6] fix(cursor): close review findings for compact JSON and CLI walks Match .NET tool-argument JSON to the other runtimes, keep user-role tool_use from being treated as unknown parts, and add list/show/browse --watch coverage for --source cursor. --- README.md | 2 +- conformance/cases/cursor/cleanup/input.jsonl | 2 +- .../cursor/listing/expected.listing.json | 2 +- .../cursor-append-sequence/case.json | 2 +- ...-ended.json => expected.append-ended.json} | 0 .../stores/cursor-pagination/store.json | 3 +- docs/live-session-streaming.md | 1 + dotnet/samples/Trajectory.Cli/Program.cs | 4 +- .../Cursor/CursorJsonlSourceAdapter.cs | 15 +- .../Adapters/Cursor/CursorTrajectoryLister.cs | 8 +- .../src/Trajectory/Adapters/Cursor/README.md | 10 ++ dotnet/src/Trajectory/CanonicalJson.cs | 153 +++++++++++++++++- dotnet/src/Trajectory/Listing/ListingTitle.cs | 3 +- .../Trajectory.Tests/SampleCliStreamTests.cs | 76 +++++++++ .../Trajectory.Tests/StreamingCoreTests.cs | 1 + python/README.md | 2 +- python/samples/trajectory_cli/cli.py | 8 +- .../src/hypabolic_trajectory/diagnostics.py | 1 + .../hypabolic_trajectory/listing/cursor.py | 2 +- .../src/hypabolic_trajectory/listing/title.py | 4 +- .../hypabolic_trajectory/sources/cursor.py | 2 + python/tests/test_ls11_sample_cli_stream.py | 60 +++++++ python/tests/test_py13_sample_cli.py | 1 + python/tests/test_stream_core.py | 1 + rust/README.md | 2 +- .../hypabolic-trajectory/src/listing.rs | 2 +- .../hypabolic-trajectory/src/normalize.rs | 1 + .../hypabolic-trajectory/src/streaming.rs | 6 + rust/tools/trajectory-cli/src/main.rs | 3 +- .../tests/ls11_sample_cli_stream.rs | 100 ++++++++++++ typescript/packages/trajectory-cli/src/cli.ts | 2 +- .../packages/trajectory-node/src/index.ts | 5 +- .../packages/trajectory/src/internal.ts | 4 +- typescript/test/sample-cli-stream.test.mjs | 64 ++++++++ typescript/test/stream-core.test.mjs | 6 + 35 files changed, 514 insertions(+), 44 deletions(-) rename conformance/cases/streaming/cursor-append-sequence/{expected.append-tool-ended.json => expected.append-ended.json} (100%) create mode 100644 dotnet/src/Trajectory/Adapters/Cursor/README.md diff --git a/README.md b/README.md index d906b73..07abbe0 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ timestamps, identity formulas, OTEL import matrix, filtered conformance argv). | Hermes | Message array or `{ session, messages }` JSON | Export file; core listing is SQLite-free | | AHP | Shape A chat snapshot `{ chat, session? }` JSON | Export file only; listing is Phase 3 (empty stub) | | Grok Build | `chat_history.jsonl` (alias `grok`) | `$GROK_HOME/sessions` or `~/.grok/sessions` | -| Cursor Agent | `cursor-agent-transcript-jsonl` (alias `cursor-agent`) | `$CURSOR_HOME` or `~/.cursor` | +| Cursor Agent | `.jsonl` (alias `cursor-agent`) | `$CURSOR_HOME` or `~/.cursor` | Override listing roots with `--root` / `TRAJECTORY__ROOT` in the sample CLIs, or pass an explicit root to listing APIs. diff --git a/conformance/cases/cursor/cleanup/input.jsonl b/conformance/cases/cursor/cleanup/input.jsonl index ffc9078..6f09d5d 100644 --- a/conformance/cases/cursor/cleanup/input.jsonl +++ b/conformance/cases/cursor/cleanup/input.jsonl @@ -2,6 +2,6 @@ not valid json [1,2] {"type":"future_record","secret":"SECRET_TOKEN_xyz"} {"role":"sideways","message":{"content":[]}} -{"role":"user","message":{"content":[{"type":"text","text":"Clean this up."},{"type":"image","url":"/Users/real-user/image.png"},{"type":"future_part"}]}} +{"role":"user","message":{"content":[{"type":"text","text":"Clean this up."},{"type":"image","url":"/Users/real-user/image.png"},{"type":"future_part"},{"type":"tool_use","name":"ignored_user_tool","input":{"secret":"not emitted"}}]}} {"role":"assistant","message":{"content":[{"type":"tool_use","input":{"x":1}},{"type":"text","text":"Done."}]}} {"type":"turn_ended","status":"error","error":"SECRET_TOKEN_xyz /Users/real-user/"} diff --git a/conformance/cases/cursor/listing/expected.listing.json b/conformance/cases/cursor/listing/expected.listing.json index ce9f399..c9f6db7 100644 --- a/conformance/cases/cursor/listing/expected.listing.json +++ b/conformance/cases/cursor/listing/expected.listing.json @@ -1 +1 @@ -[{"items":[{"id":"019f0000-0000-7000-8000-00000000c002","path":"$ROOT/store/projects/%2Fworkspace%2Fnew/agent-transcripts/019f0000-0000-7000-8000-00000000c002/019f0000-0000-7000-8000-00000000c002.jsonl","updated_at":"2026-04-03T00:00:00.000Z","title":"Meta title","size_bytes":87}],"next_cursor":"MQowCjAxOWYwMDAwLTAwMDAtNzAwMC04MDAwLTAwMDAwMDAwYzAwMg"},{"items":[{"id":"019f0000-0000-7000-8000-00000000c003","path":"$ROOT/store/projects/%2Fworkspace%2Fmid/agent-transcripts/019f0000-0000-7000-8000-00000000c003/019f0000-0000-7000-8000-00000000c003.jsonl","updated_at":"2026-04-02T00:00:00.000Z","title":"Mid Cursor session","size_bytes":84}],"next_cursor":"MQoxCjAxOWYwMDAwLTAwMDAtNzAwMC04MDAwLTAwMDAwMDAwYzAwMw"},{"items":[{"id":"019f0000-0000-7000-8000-00000000c001","path":"$ROOT/store/projects/%2Fworkspace%2Fold/agent-transcripts/019f0000-0000-7000-8000-00000000c001/019f0000-0000-7000-8000-00000000c001.jsonl","updated_at":"2026-04-01T00:00:00.000Z","title":"Older Cursor session","size_bytes":86}],"next_cursor":null}] \ No newline at end of file +[{"items":[{"id":"019f0000-0000-7000-8000-00000000c002","path":"$ROOT/store/projects/%2Fworkspace%2Fnew/agent-transcripts/019f0000-0000-7000-8000-00000000c002/019f0000-0000-7000-8000-00000000c002.jsonl","updated_at":"2026-04-04T00:00:00.000Z","title":"Meta title","size_bytes":87}],"next_cursor":"MQowCjAxOWYwMDAwLTAwMDAtNzAwMC04MDAwLTAwMDAwMDAwYzAwMg"},{"items":[{"id":"019f0000-0000-7000-8000-00000000c003","path":"$ROOT/store/projects/%2Fworkspace%2Fmid/agent-transcripts/019f0000-0000-7000-8000-00000000c003/019f0000-0000-7000-8000-00000000c003.jsonl","updated_at":"2026-04-02T00:00:00.000Z","title":"Mid Cursor session","size_bytes":84}],"next_cursor":"MQoxCjAxOWYwMDAwLTAwMDAtNzAwMC04MDAwLTAwMDAwMDAwYzAwMw"},{"items":[{"id":"019f0000-0000-7000-8000-00000000c001","path":"$ROOT/store/projects/%2Fworkspace%2Fold/agent-transcripts/019f0000-0000-7000-8000-00000000c001/019f0000-0000-7000-8000-00000000c001.jsonl","updated_at":"2026-04-01T00:00:00.000Z","title":"Older Cursor session","size_bytes":86}],"next_cursor":null}] diff --git a/conformance/cases/streaming/cursor-append-sequence/case.json b/conformance/cases/streaming/cursor-append-sequence/case.json index 335bcae..231627b 100644 --- a/conformance/cases/streaming/cursor-append-sequence/case.json +++ b/conformance/cases/streaming/cursor-append-sequence/case.json @@ -10,7 +10,7 @@ "steps":[ {"id":"append-user","input":{"kind":"append-bytes","material":"step-1.jsonl","source_revision":"gen-0"},"expected":{"update_kind":"updated","complete":false,"result":"expected.append-user.json"},"double_invoke":true}, {"id":"append-assistant","input":{"kind":"append-bytes","material":"step-2.jsonl","source_revision":"gen-0"},"expected":{"update_kind":"updated","complete":false,"result":"expected.append-assistant.json"},"double_invoke":true}, - {"id":"append-tool-ended","input":{"kind":"append-bytes","material":"step-3.jsonl","source_revision":"gen-0"},"expected":{"update_kind":"updated","complete":false,"result":"expected.append-tool-ended.json"},"double_invoke":true} + {"id":"append-ended","input":{"kind":"append-bytes","material":"step-3.jsonl","source_revision":"gen-0"},"expected":{"update_kind":"updated","complete":false,"result":"expected.append-ended.json"},"double_invoke":true} ], "comparison":["stream-json-exact","stream-delta-apply","stream-idempotence","stream-oracle-parity"], "oracle":{"prefix_re_normalize":true,"append_equals_prefix":true}, diff --git a/conformance/cases/streaming/cursor-append-sequence/expected.append-tool-ended.json b/conformance/cases/streaming/cursor-append-sequence/expected.append-ended.json similarity index 100% rename from conformance/cases/streaming/cursor-append-sequence/expected.append-tool-ended.json rename to conformance/cases/streaming/cursor-append-sequence/expected.append-ended.json diff --git a/conformance/stores/cursor-pagination/store.json b/conformance/stores/cursor-pagination/store.json index 46805f0..1532c41 100644 --- a/conformance/stores/cursor-pagination/store.json +++ b/conformance/stores/cursor-pagination/store.json @@ -4,7 +4,8 @@ {"path":"store/projects/%2Fworkspace%2Fold/agent-transcripts/019f0000-0000-7000-8000-00000000c001/019f0000-0000-7000-8000-00000000c001.jsonl","content":"{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Older Cursor session\"}]}}\n","updated_at":"2026-04-01T00:00:00.000Z"}, {"path":"store/projects/%2Fworkspace%2Fmid/agent-transcripts/019f0000-0000-7000-8000-00000000c003/019f0000-0000-7000-8000-00000000c003.jsonl","content":"{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Mid Cursor session\"}]}}\n","updated_at":"2026-04-02T00:00:00.000Z"}, {"path":"store/projects/%2Fworkspace%2Fnew/agent-transcripts/019f0000-0000-7000-8000-00000000c002/019f0000-0000-7000-8000-00000000c002.jsonl","content":"{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Newest Cursor session\"}]}}\n","updated_at":"2026-04-03T00:00:00.000Z"}, - {"path":"store/chats/hash/019f0000-0000-7000-8000-00000000c002/meta.json","content":"{\"title\":\"Meta title\",\"updatedAtMs\":1775174400000}\n","updated_at":"2026-04-04T00:00:00.000Z"}, + {"path":"store/chats/hash/019f0000-0000-7000-8000-00000000c002/meta.json","content":"{\"title\":\"Meta title\",\"updatedAtMs\":1775260800000}\n","updated_at":"2026-04-04T00:00:00.000Z"}, + {"path":"store/chats/hash/019f0000-0000-7000-8000-00000000c003/meta.json","content":"{\"title\":\" \"}\n","updated_at":"2026-04-02T00:00:00.000Z"}, {"path":"store/projects/%2Fworkspace%2Fnew/repo.json","content":"{}\n","updated_at":"2026-04-05T00:00:00.000Z"}, {"path":"store/projects/%2Fworkspace%2Fnew/worker.log","content":"secret noise\n","updated_at":"2026-04-05T00:00:00.000Z"}, {"path":"store/projects/%2Fworkspace%2Fnew/store.db","content":"encrypted\n","updated_at":"2026-04-05T00:00:00.000Z"}, diff --git a/docs/live-session-streaming.md b/docs/live-session-streaming.md index 06a5979..fad0626 100644 --- a/docs/live-session-streaming.md +++ b/docs/live-session-streaming.md @@ -433,6 +433,7 @@ StreamOptions = { | `codex` | snapshot-bytes, append-bytes | byte | truncate, replace (despite append-only contract) | open function calls | core | | `openclaw` | snapshot-bytes, append-bytes | byte | truncate, replace | delivery-mirror not provisional | core | | `grok-build` | snapshot-bytes, append-bytes | byte | **compaction**, truncate, replace | synthetic backend tool result | core | +| `cursor` | snapshot-bytes, append-bytes | byte | truncate, replace | none / no provisional records | core | | `ahp` | ahp-snapshot, ahp-actions | snapshot-revision / ahp-server-seq | revision conflict, sequence gap, protocol change | `activeTurn`, incomplete tools | core reducer; optional client | | `hermes` | hermes-export via provider | hermes-row | soft-delete, db generation, change-token invalidation | none native | **optional provider**; core decode only | diff --git a/dotnet/samples/Trajectory.Cli/Program.cs b/dotnet/samples/Trajectory.Cli/Program.cs index 7436555..a7ae2f9 100644 --- a/dotnet/samples/Trajectory.Cli/Program.cs +++ b/dotnet/samples/Trajectory.Cli/Program.cs @@ -83,7 +83,7 @@ internal static class Sources "cursor" or "cursor-agent" => TrajectorySource.Cursor, _ => throw new TrajectoryNormalizationException( NormalizationErrorCode.UnknownSource, - $"Unknown source '{value}'. Expected one of: {string.Join(", ", Names)} (alias: grok)."), + $"Unknown source '{value}'. Expected one of: {string.Join(", ", Names)} (aliases: grok, cursor-agent)."), }; public static string WireName(TrajectorySource source) => source switch @@ -444,7 +444,7 @@ public static async Task FollowAsync( AnsiConsole.WriteLine(); - // Grok history lines do not embed the session id; listing id is the group. + // Grok history and Cursor transcript lines do not embed the session id; listing id is the group. // Other file sources lock group from the transcript. if (source is not (TrajectorySource.GrokBuild or TrajectorySource.Cursor)) { diff --git a/dotnet/src/Trajectory/Adapters/Cursor/CursorJsonlSourceAdapter.cs b/dotnet/src/Trajectory/Adapters/Cursor/CursorJsonlSourceAdapter.cs index d803644..6695e62 100644 --- a/dotnet/src/Trajectory/Adapters/Cursor/CursorJsonlSourceAdapter.cs +++ b/dotnet/src/Trajectory/Adapters/Cursor/CursorJsonlSourceAdapter.cs @@ -1,5 +1,3 @@ -using System.Buffers; -using System.Text; using System.Text.Json; using Hypabolic.Trajectory.Internal; @@ -45,7 +43,8 @@ void Emit(DecodedEvent decoded) => events.Add(decoded with var text = GetString(part, "text"); if (text is not null) textParts.Add(text); } - else if (role == "assistant" && partType == "tool_use") tools.Add(part.Clone()); + else if (partType == "tool_use" && role == "assistant") tools.Add(part.Clone()); + else if (partType == "tool_use") { } else if (partType is "image" or "image_url" or "input_image" or "output_image") { diagnostics.Add(new TrajectoryDiagnostic { Code = DiagnosticCodes.ImageContentDropped, Message = $"Dropped image content on a Cursor record on line {line.Line}.", InputLine = line.Line }); @@ -137,15 +136,7 @@ private static IReadOnlyList ParseJsonLines(ReadOnlyMemory trans element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; private static string CompactJson(JsonElement value) - { - var buffer = new ArrayBufferWriter(); - using (var writer = new Utf8JsonWriter(buffer)) - { - value.WriteTo(writer); - } - - return Encoding.UTF8.GetString(buffer.WrittenSpan); - } + => CanonicalJson.Relaxed(value); private sealed record JsonLine(JsonDocument Document, int Line, long ByteOffset); } diff --git a/dotnet/src/Trajectory/Adapters/Cursor/CursorTrajectoryLister.cs b/dotnet/src/Trajectory/Adapters/Cursor/CursorTrajectoryLister.cs index e85d0ac..124751c 100644 --- a/dotnet/src/Trajectory/Adapters/Cursor/CursorTrajectoryLister.cs +++ b/dotnet/src/Trajectory/Adapters/Cursor/CursorTrajectoryLister.cs @@ -20,8 +20,9 @@ public IReadOnlyList List(string? root) try { using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(session, "meta.json"))); - if (document.RootElement.ValueKind == JsonValueKind.Object) - metadata[Path.GetFileName(session)!] = document.RootElement.Clone(); + var sessionId = Path.GetFileName(session); + if (document.RootElement.ValueKind == JsonValueKind.Object && sessionId is not null && !metadata.ContainsKey(sessionId)) + metadata[sessionId] = document.RootElement.Clone(); } catch (Exception error) when (error is IOException or UnauthorizedAccessException or JsonException) { } } @@ -52,7 +53,8 @@ public IReadOnlyList List(string? root) ? DateTimeOffset.FromUnixTimeMilliseconds(milliseconds) : info.LastWriteTimeUtc; var title = meta.ValueKind == JsonValueKind.Object && meta.TryGetProperty("title", out var titleValue) && titleValue.ValueKind == JsonValueKind.String - ? ListingTitle.FormatTitle(titleValue.GetString()) : ListingTitle.DeriveCursorTitle(path); + ? ListingTitle.FormatTitle(titleValue.GetString()) : null; + title ??= ListingTitle.DeriveCursorTitle(path); items.Add(new TrajectoryListing { Id = id, Path = Path.GetFullPath(path), UpdatedAt = updated, Title = title, SizeBytes = info.Length }); } catch (Exception error) when (error is IOException or UnauthorizedAccessException) { } diff --git a/dotnet/src/Trajectory/Adapters/Cursor/README.md b/dotnet/src/Trajectory/Adapters/Cursor/README.md new file mode 100644 index 0000000..1e988c6 --- /dev/null +++ b/dotnet/src/Trajectory/Adapters/Cursor/README.md @@ -0,0 +1,10 @@ +# Cursor source adapter + +Cursor Agent stores `cursor-agent-transcript-jsonl` under +`~/.cursor/projects//agent-transcripts//.jsonl`. +Transcripts have no native record or tool IDs; Trajectory uses location +identity. `store.db` is encrypted and is not v1 normalize input. + +Listing identity is the session-directory UUID; callers following a listed +session lock `group_id` from the listing ID. An optional +`chats/*//meta.json` join supplies `title` and `updated_at`. diff --git a/dotnet/src/Trajectory/CanonicalJson.cs b/dotnet/src/Trajectory/CanonicalJson.cs index d54e6ba..a77dce4 100644 --- a/dotnet/src/Trajectory/CanonicalJson.cs +++ b/dotnet/src/Trajectory/CanonicalJson.cs @@ -1,3 +1,4 @@ +using System.Text; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Nodes; @@ -12,10 +13,156 @@ internal static class CanonicalJson WriteIndented = false, }; - public static string Serialize(JsonNode? value) => - Sort(value)?.ToJsonString(SerializerOptions) ?? "null"; + public static string Serialize(JsonNode? value) => Emit(Sort(value)); - public static string Compact(JsonNode value) => value.ToJsonString(SerializerOptions); + public static string Compact(JsonNode value) => Emit(value); + + public static string Relaxed(JsonElement value) + { + var output = new StringBuilder(); + WriteElement(value, output); + return output.ToString(); + } + + private static string Emit(JsonNode? value) + { + if (value is null) + { + return "null"; + } + + var output = new StringBuilder(); + WriteNode(value, output); + return output.ToString(); + } + + private static void WriteNode(JsonNode value, StringBuilder output) + { + switch (value) + { + case JsonArray array: + output.Append('['); + for (var i = 0; i < array.Count; i++) + { + if (i > 0) output.Append(','); + if (array[i] is { } item) WriteNode(item, output); + else output.Append("null"); + } + + output.Append(']'); + return; + case JsonObject obj: + output.Append('{'); + var first = true; + foreach (var property in obj) + { + if (!first) output.Append(','); + first = false; + WriteString(property.Key, output); + output.Append(':'); + if (property.Value is { } item) WriteNode(item, output); + else output.Append("null"); + } + + output.Append('}'); + return; + case JsonValue jsonValue when jsonValue.TryGetValue(out var text): + WriteString(text ?? string.Empty, output); + return; + default: + output.Append(value.ToJsonString(SerializerOptions)); + return; + } + } + + private static void WriteElement(JsonElement value, StringBuilder output) + { + switch (value.ValueKind) + { + case JsonValueKind.Null: + output.Append("null"); + return; + case JsonValueKind.True: + output.Append("true"); + return; + case JsonValueKind.False: + output.Append("false"); + return; + case JsonValueKind.Number: + output.Append(value.GetRawText()); + return; + case JsonValueKind.String: + WriteString(value.GetString() ?? string.Empty, output); + return; + case JsonValueKind.Array: + output.Append('['); + var firstArrayItem = true; + foreach (var item in value.EnumerateArray()) + { + if (!firstArrayItem) output.Append(','); + firstArrayItem = false; + WriteElement(item, output); + } + + output.Append(']'); + return; + case JsonValueKind.Object: + output.Append('{'); + var firstProperty = true; + foreach (var property in value.EnumerateObject()) + { + if (!firstProperty) output.Append(','); + firstProperty = false; + WriteString(property.Name, output); + output.Append(':'); + WriteElement(property.Value, output); + } + + output.Append('}'); + return; + default: + throw new JsonException($"Unsupported JSON value kind: {value.ValueKind}."); + } + } + + private static void WriteString(string value, StringBuilder output) + { + const string Hex = "0123456789ABCDEF"; + output.Append('"'); + foreach (var unit in value) + { + switch (unit) + { + case '"': output.Append("\\\""); break; + case '\\': output.Append("\\\\"); break; + case '\b': output.Append("\\b"); break; + case '\t': output.Append("\\t"); break; + case '\n': output.Append("\\n"); break; + case '\f': output.Append("\\f"); break; + case '\r': output.Append("\\r"); break; + default: + if (unit <= 0x1F || + (unit >= '\uE000' && unit <= '\uF8FF') || + unit is '\u2028' or '\u2029' || + (unit >= '\uD800' && unit <= '\uDFFF')) + { + output.Append("\\u"); + output.Append(Hex[(unit >> 12) & 0xF]); + output.Append(Hex[(unit >> 8) & 0xF]); + output.Append(Hex[(unit >> 4) & 0xF]); + output.Append(Hex[unit & 0xF]); + } + else + { + output.Append(unit); + } + + break; + } + } + + output.Append('"'); + } private static JsonNode? Sort(JsonNode? value) { diff --git a/dotnet/src/Trajectory/Listing/ListingTitle.cs b/dotnet/src/Trajectory/Listing/ListingTitle.cs index 1385152..807ac6e 100644 --- a/dotnet/src/Trajectory/Listing/ListingTitle.cs +++ b/dotnet/src/Trajectory/Listing/ListingTitle.cs @@ -226,8 +226,7 @@ internal static class ListingTitle .Where(static part => part.ValueKind == JsonValueKind.Object && GetString(part, "type") == "text") .Select(static part => GetString(part, "text")) .Where(static value => value is not null)); - var title = TitleFromUserText(text); - if (title is not null) return title; + return FormatTitle(text); } return null; diff --git a/dotnet/tests/Trajectory.Tests/SampleCliStreamTests.cs b/dotnet/tests/Trajectory.Tests/SampleCliStreamTests.cs index e763d3b..e0d50b5 100644 --- a/dotnet/tests/Trajectory.Tests/SampleCliStreamTests.cs +++ b/dotnet/tests/Trajectory.Tests/SampleCliStreamTests.cs @@ -12,6 +12,7 @@ namespace Hypabolic.Trajectory.Tests; public sealed class SampleCliStreamTests { private const string Chat = "ahp-chat:/00000000-0000-4000-8000-0000000000c1"; + private const string CursorId = "019f0000-0000-7000-8000-00000000c0a1"; private static readonly string SessionLine = """{"type":"session","version":3,"id":"ls11-stream-dotnet","timestamp":"2026-01-01T00:00:00.000Z","cwd":"/workspace/demo"}""" @@ -21,6 +22,12 @@ public sealed class SampleCliStreamTests """{"type":"message","id":"m1","parentId":null,"timestamp":"2026-01-01T00:00:01.000Z","message":{"role":"user","content":[{"type":"text","text":"hello"}]},"sessionId":"ls11-stream-dotnet"}""" + "\n"; + private const string CursorLine = + """{"role":"user","message":{"content":[{"type":"text","text":"hello"}]}}""" + + "\n" + + """{"role":"assistant","message":{"content":[{"type":"text","text":"done"}]}}""" + + "\n"; + private static string RepositoryRoot { get @@ -283,6 +290,75 @@ public void BrowseWatchListedSession() } } + [Fact] + public void CursorListShowAndBrowseWatchUseListedSessionId() + { + var root = Path.Combine( + Path.GetTempPath(), + "traj-ls11-cursor-dotnet-" + Guid.NewGuid().ToString("N")); + var path = Path.Combine( + root, + "projects", + "%2Fworkspace%2Fdemo", + "agent-transcripts", + CursorId, + CursorId + ".jsonl"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + try + { + File.WriteAllText(path, CursorLine); + + var (listCode, listStdout, listStderr) = RunCli( + "list", + "--source", "cursor", + "--root", root, + "--limit", "5"); + Assert.True(listCode == 0, $"exit={listCode}\nstderr={listStderr}\nstdout={listStdout}"); + // Spectre wraps the UUID column at the redirected-console width, so + // the contiguous listing id may not appear as one substring. + Assert.Contains("019f0000", listStdout, StringComparison.Ordinal); + Assert.Contains("00000000c0a1", listStdout, StringComparison.Ordinal); + Assert.DoesNotContain("No sessions found", listStdout, StringComparison.OrdinalIgnoreCase); + + var (showCode, showStdout, showStderr) = RunCli( + "show", + "--source", "cursor", + "--root", root, + "--id", CursorId); + Assert.True(showCode == 0, $"exit={showCode}\nstderr={showStderr}\nstdout={showStdout}"); + Assert.Contains(CursorId, showStdout, StringComparison.Ordinal); + Assert.Contains("Content omitted", showStdout, StringComparison.Ordinal); + + var (browseCode, browseStdout, browseStderr) = RunCli( + "browse", + "--source", "cursor-agent", + "--root", root, + "--id", CursorId, + "--watch", + "--emit", "snapshot+delta", + "--max-updates", "1"); + Assert.True( + browseCode == 0, + $"exit={browseCode}\nstderr={browseStderr}\nstdout={browseStdout}"); + Assert.Contains("stream update", browseStdout, StringComparison.OrdinalIgnoreCase); + Assert.Contains("snapshot", browseStdout, StringComparison.OrdinalIgnoreCase); + Assert.Contains("delta", browseStdout, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Content omitted", browseStdout, StringComparison.Ordinal); + Assert.DoesNotContain("hello", browseStdout, StringComparison.Ordinal); + } + finally + { + try + { + Directory.Delete(root, recursive: true); + } + catch + { + // best-effort cleanup + } + } + } + [Fact] public void BrowseWatchRejectsAhp() { diff --git a/dotnet/tests/Trajectory.Tests/StreamingCoreTests.cs b/dotnet/tests/Trajectory.Tests/StreamingCoreTests.cs index a82da49..ab26a31 100644 --- a/dotnet/tests/Trajectory.Tests/StreamingCoreTests.cs +++ b/dotnet/tests/Trajectory.Tests/StreamingCoreTests.cs @@ -426,6 +426,7 @@ public void IdenticalSuccessiveAppends_BothCommit() [InlineData(TrajectorySource.Codex, "codex-append-sequence", "stream-codex-append", 3)] [InlineData(TrajectorySource.OpenClaw, "openclaw-append-sequence", "stream-openclaw-append", 3)] [InlineData(TrajectorySource.GrokBuild, "grok-build-append-sequence", "stream-grok-build-append-sequence", 3)] + [InlineData(TrajectorySource.Cursor, "cursor-append-sequence", "stream-cursor-append-sequence", 3)] public void PerSource_AppendOracleParity( TrajectorySource source, string caseId, diff --git a/python/README.md b/python/README.md index 7eff577..ba3b55d 100644 --- a/python/README.md +++ b/python/README.md @@ -275,7 +275,7 @@ Advertised surface is the tip matrix in [`python/runtime-capabilities.json`](runtime-capabilities.json) (must equal `contracts/compatibility.json` and peer TS/Rust manifests at ship): -- **Sources:** `pi`, `claude-code`, `codex`, `openclaw`, `hermes`, `ahp` (Shape A offline ChatState snapshot; listing Phase 3 stub) +- **Sources:** `pi`, `claude-code`, `codex`, `openclaw`, `hermes`, `ahp` (Shape A offline ChatState snapshot; listing Phase 3 stub), `grok-build`, `cursor` - **Outputs:** `letta-trajectory-v1`, `letta-canonical-v1`, `hypabolic-trajectory-v1`, `openai-chat-messages`, `jsonl-minimal`, `otel-genai-spans-v1` - **Capabilities:** `normalize`, `normalize-partial`, `list-explicit-root`, `typed-diagnostics`, `typed-fatal-errors`, `deterministic-rerun` - **Slice:** `ML13` (historical id; AHP is a post-`0.1.0` source addition on tip) diff --git a/python/samples/trajectory_cli/cli.py b/python/samples/trajectory_cli/cli.py index ea1487c..621e161 100644 --- a/python/samples/trajectory_cli/cli.py +++ b/python/samples/trajectory_cli/cli.py @@ -150,7 +150,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace: "--source", dest="source", default=None, - help="Transcript source: pi, claude-code, codex, openclaw, hermes, ahp, grok-build, cursor.", + help="Transcript source: pi, claude-code, codex, openclaw, hermes, ahp, grok-build, cursor (aliases: grok, cursor-agent).", ) parser.add_argument( "-r", @@ -328,7 +328,7 @@ def parse_source(value: str) -> str: return "claude-code" if normalized in ("grok", "grokbuild"): return "grok-build" - if normalized in ("cursor-agent", "cursoragent"): + if normalized == "cursor-agent": return "cursor" raise TrajectoryError( "unknown_source", @@ -571,7 +571,7 @@ def follow_file_session( show_content: bool, ) -> int: """Follow one JSONL file. Caller owns process lifetime (not a daemon).""" - # Grok history lines do not embed the session id; listing id is the group. + # Grok history and Cursor transcript lines do not embed the session id; listing id is the group. # Other file sources lock group from the transcript — a listing stem that # disagrees with the session record would reset instead of showing a tail. if source not in ("grok-build", "cursor"): @@ -1402,7 +1402,7 @@ def print_help() -> None: hermes ~/.hermes ahp explicit export root only (use show --path) grok-build $GROK_HOME/sessions or ~/.grok/sessions - cursor $CURSOR_HOME or ~/.cursor + cursor $CURSOR_HOME or ~/.cursor (alias: cursor-agent) Root overrides: --root or TRAJECTORY__ROOT (e.g. TRAJECTORY_PI_ROOT). OpenClaw also honors OPENCLAW_STATE_DIR / CLAWDBOT_STATE_DIR. diff --git a/python/src/hypabolic_trajectory/diagnostics.py b/python/src/hypabolic_trajectory/diagnostics.py index 9a392a3..fa339bf 100644 --- a/python/src/hypabolic_trajectory/diagnostics.py +++ b/python/src/hypabolic_trajectory/diagnostics.py @@ -47,6 +47,7 @@ DIAG_IMAGE_CONTENT_DROPPED: Final[str] = "image_content_dropped" DIAG_BACKEND_TOOL_RESULT_SYNTHESIZED: Final[str] = "backend_tool_result_synthesized" DIAG_ENCRYPTED_REASONING_INCLUDED: Final[str] = "encrypted_reasoning_included" +# Shared Grok Build / Cursor source diagnostics. DIAG_UNKNOWN_CONTENT_PART: Final[str] = "unknown_content_part" DIAG_TOOL_USE_MISSING_NAME: Final[str] = "tool_use_missing_name" DIAG_TURN_ENDED_ERROR: Final[str] = "turn_ended_error" diff --git a/python/src/hypabolic_trajectory/listing/cursor.py b/python/src/hypabolic_trajectory/listing/cursor.py index 1251b9a..dabbea6 100644 --- a/python/src/hypabolic_trajectory/listing/cursor.py +++ b/python/src/hypabolic_trajectory/listing/cursor.py @@ -50,7 +50,7 @@ def _discover(root: str | Path) -> list[TrajectoryListing]: if not session.is_dir(follow_symlinks=True): continue transcript = Path(session.path) / f"{session.name}.jsonl" - if transcript.stem != session.name or not transcript.is_file(): + if not transcript.is_file(): continue stat = transcript.stat() record_meta = meta.get(session.name, {}) diff --git a/python/src/hypabolic_trajectory/listing/title.py b/python/src/hypabolic_trajectory/listing/title.py index 34e569f..a188271 100644 --- a/python/src/hypabolic_trajectory/listing/title.py +++ b/python/src/hypabolic_trajectory/listing/title.py @@ -157,9 +157,7 @@ def derive_cursor_title(path: str | Path) -> str | None: and _string(part.get("type")) == "text" and isinstance(part.get("text"), str) ) - title = title_from_user_text(text) - if title is not None: - return title + return format_title(text) return None diff --git a/python/src/hypabolic_trajectory/sources/cursor.py b/python/src/hypabolic_trajectory/sources/cursor.py index 4cbe0c7..9352297 100644 --- a/python/src/hypabolic_trajectory/sources/cursor.py +++ b/python/src/hypabolic_trajectory/sources/cursor.py @@ -89,6 +89,8 @@ def emit( text_parts.append(text) elif role == "assistant" and part_type == "tool_use": tool_parts.append(part) + elif part_type == "tool_use": + pass elif part_type in _IMAGE_TYPES: diagnostics.append(Diagnostic( code=DIAG_IMAGE_CONTENT_DROPPED, diff --git a/python/tests/test_ls11_sample_cli_stream.py b/python/tests/test_ls11_sample_cli_stream.py index cab9172..3f4ff9c 100644 --- a/python/tests/test_ls11_sample_cli_stream.py +++ b/python/tests/test_ls11_sample_cli_stream.py @@ -16,6 +16,7 @@ FIXTURE_PI = REPO_ROOT / "conformance" / "cases" / "pi" / "tool-calls" / "input.jsonl" STREAM_CASES = REPO_ROOT / "conformance" / "cases" / "streaming" CHAT = "ahp-chat:/00000000-0000-4000-8000-0000000000c1" +CURSOR_ID = "019f0000-0000-7000-8000-00000000c0a1" if str(SAMPLES_DIR) not in sys.path: sys.path.insert(0, str(SAMPLES_DIR)) @@ -31,6 +32,10 @@ b'"message":{"role":"user","content":[{"type":"text","text":"hello"}]},' b'"sessionId":"ls11-stream-py"}\n' ) +CURSOR_LINE = ( + b'{"role":"user","message":{"content":[{"type":"text","text":"hello"}]}}\n' + b'{"role":"assistant","message":{"content":[{"type":"text","text":"done"}]}}\n' +) def test_parse_emit_and_delivery() -> None: @@ -325,6 +330,61 @@ def test_browse_watch_listed_session( assert "hello" not in out +def test_cursor_cli_list_show_browse_watch( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + root = tmp_path / "cursor" + path = ( + root + / "projects" + / "%2Fworkspace%2Fdemo" + / "agent-transcripts" + / CURSOR_ID + / f"{CURSOR_ID}.jsonl" + ) + path.parent.mkdir(parents=True) + path.write_bytes(CURSOR_LINE) + + code = trajectory_cli.main( + ["list", "--source", "cursor", "--root", str(root), "--limit", "5"] + ) + assert code == 0 + listed = capsys.readouterr().out + assert CURSOR_ID in listed + + code = trajectory_cli.main( + ["show", "--source", "cursor", "--root", str(root), "--id", CURSOR_ID] + ) + assert code == 0 + shown = capsys.readouterr().out + assert CURSOR_ID in shown + assert "Content omitted" in shown + + code = trajectory_cli.main( + [ + "browse", + "--source", + "cursor-agent", + "--root", + str(root), + "--id", + CURSOR_ID, + "--watch", + "--emit", + "snapshot+delta", + "--max-updates", + "1", + ] + ) + assert code == 0 + out = capsys.readouterr().out + assert "stream update" in out + assert "snapshot" in out + assert "delta" in out + assert "Content omitted" in out + assert "hello" not in out + + def test_browse_watch_rejects_ahp(capsys: pytest.CaptureFixture[str]) -> None: code = trajectory_cli.main( ["browse", "--source", "ahp", "--root", "/tmp", "--id", "x", "--watch"] diff --git a/python/tests/test_py13_sample_cli.py b/python/tests/test_py13_sample_cli.py index 5a66e29..ef45d74 100644 --- a/python/tests/test_py13_sample_cli.py +++ b/python/tests/test_py13_sample_cli.py @@ -30,6 +30,7 @@ def test_parse_source_aliases() -> None: assert trajectory_cli.parse_source("pi") == "pi" assert trajectory_cli.parse_source("Claude") == "claude-code" assert trajectory_cli.parse_source("claudecode") == "claude-code" + assert trajectory_cli.parse_source("cursor-agent") == "cursor" with pytest.raises(Exception) as exc_info: trajectory_cli.parse_source("nope") assert getattr(exc_info.value, "code", None) == "unknown_source" diff --git a/python/tests/test_stream_core.py b/python/tests/test_stream_core.py index 3d08cdd..562e098 100644 --- a/python/tests/test_stream_core.py +++ b/python/tests/test_stream_core.py @@ -488,6 +488,7 @@ def test_file_compaction_returns_source_compacted() -> None: "stream-grok-build-append-sequence", 3, ), + ("cursor", "cursor-append-sequence", "stream-cursor-append-sequence", 3), ], ) def test_per_source_append_oracle( diff --git a/rust/README.md b/rust/README.md index 622ff46..4f61527 100644 --- a/rust/README.md +++ b/rust/README.md @@ -53,7 +53,7 @@ python3 conformance/verify.py --repository-root . -- \ `runtime-capabilities.json` is the machine-readable declaration of implemented sources and six outputs (slice `ML13`). On this tip the source set includes -`ahp` and `grok-build`; published registry `0.1.0` stopped at Hermes. See +`ahp`, `grok-build`, and `cursor`; published registry `0.1.0` stopped at Hermes. See [docs/release-readiness.md](../docs/release-readiness.md) and [docs/ahp-source-spec.md](../docs/ahp-source-spec.md). `write_schema` and `write_minimal_jsonl` provide `std::io::Write` surfaces; the latter emits one diff --git a/rust/crates/hypabolic-trajectory/src/listing.rs b/rust/crates/hypabolic-trajectory/src/listing.rs index 7ee7b00..3d31bc9 100644 --- a/rust/crates/hypabolic-trajectory/src/listing.rs +++ b/rust/crates/hypabolic-trajectory/src/listing.rs @@ -768,7 +768,7 @@ fn derive_cursor_title(path: &Path) -> Option { let text = parts.iter().filter_map(|part| { (part.get("type").and_then(serde_json::Value::as_str) == Some("text")).then(|| part.get("text").and_then(serde_json::Value::as_str)).flatten() }).collect::>().join("\n"); - if let Some(title) = title_from_user_text(&text) { return Some(title); } + return format_title(&text); } None } diff --git a/rust/crates/hypabolic-trajectory/src/normalize.rs b/rust/crates/hypabolic-trajectory/src/normalize.rs index bdae568..839b454 100644 --- a/rust/crates/hypabolic-trajectory/src/normalize.rs +++ b/rust/crates/hypabolic-trajectory/src/normalize.rs @@ -293,6 +293,7 @@ pub fn normalize_cursor(request: NormalizeRequest<'_>) -> Result if let Some(text) = string_value(part.get("text")) { texts.push(text.to_owned()); }, Some("image" | "image_url" | "input_image" | "output_image") => diagnostics.push(Diagnostic { code: "image_content_dropped".into(), message: format!("Dropped image content on a Cursor record on line {line}."), input_line: Some(line), record_index: None, count: None }), Some("tool_use") if role == Some("assistant") => {}, + Some("tool_use") => {}, Some(_) => diagnostics.push(Diagnostic { code: "unknown_content_part".into(), message: format!("Skipped an unknown Cursor content part on line {line}."), input_line: Some(line), record_index: None, count: None }), None => {}, } diff --git a/rust/crates/hypabolic-trajectory/src/streaming.rs b/rust/crates/hypabolic-trajectory/src/streaming.rs index e1cbc99..4c2fb4e 100644 --- a/rust/crates/hypabolic-trajectory/src/streaming.rs +++ b/rust/crates/hypabolic-trajectory/src/streaming.rs @@ -4187,6 +4187,12 @@ mod tests { "stream-grok-build-append-sequence", 3, ), + ( + "cursor-append-sequence", + TrajectorySource::Cursor, + "stream-cursor-append-sequence", + 3, + ), ]; for (case_id, source, group_id, steps) in cases { let mut chunks = Vec::new(); diff --git a/rust/tools/trajectory-cli/src/main.rs b/rust/tools/trajectory-cli/src/main.rs index 42cb218..ebb7639 100644 --- a/rust/tools/trajectory-cli/src/main.rs +++ b/rust/tools/trajectory-cli/src/main.rs @@ -267,6 +267,7 @@ fn is_stream_file_source(source: SourceArg) -> bool { | SourceArg::Codex | SourceArg::Openclaw | SourceArg::GrokBuild + | SourceArg::Cursor ) } @@ -307,7 +308,7 @@ fn follow_file_session( let interval = cli.interval; let max_updates = cli.max_updates; let delivery = emit.delivery(); - // Grok history lines do not embed the session id; listing id is the group. + // Grok history and Cursor transcript lines do not embed the session id; listing id is the group. // Other file sources lock group from the transcript. let group_id = if matches!(source, SourceArg::GrokBuild | SourceArg::Cursor) { group_id diff --git a/rust/tools/trajectory-cli/tests/ls11_sample_cli_stream.rs b/rust/tools/trajectory-cli/tests/ls11_sample_cli_stream.rs index 4a628d9..b7f9f08 100644 --- a/rust/tools/trajectory-cli/tests/ls11_sample_cli_stream.rs +++ b/rust/tools/trajectory-cli/tests/ls11_sample_cli_stream.rs @@ -12,6 +12,7 @@ use std::time::{Duration, Instant}; use std::{env, fs, thread}; const CHAT: &str = "ahp-chat:/00000000-0000-4000-8000-0000000000c1"; +const CURSOR_ID: &str = "019f0000-0000-7000-8000-00000000c0a1"; const SESSION_LINE: &str = concat!( r#"{"type":"session","version":3,"id":"ls11-stream-rs","timestamp":"2026-01-01T00:00:00.000Z","cwd":"/workspace/demo"}"#, @@ -21,6 +22,12 @@ const USER_LINE: &str = concat!( r#"{"type":"message","id":"m1","parentId":null,"timestamp":"2026-01-01T00:00:01.000Z","message":{"role":"user","content":[{"type":"text","text":"hello"}]},"sessionId":"ls11-stream-rs"}"#, "\n" ); +const CURSOR_LINE: &str = + concat!( + r#"{"role":"user","message":{"content":[{"type":"text","text":"hello"}]}}"#, + "\n", + r#"{"role":"assistant","message":{"content":[{"type":"text","text":"done"}]}}"# + ); fn trajectory_bin() -> PathBuf { if let Some(path) = env::var_os("CARGO_BIN_EXE_trajectory") { @@ -328,6 +335,99 @@ fn browse_watch_listed_session() { ); } +fn write_cursor_store(root: &Path) -> PathBuf { + let path = root + .join("projects") + .join("%2Fworkspace%2Fdemo") + .join("agent-transcripts") + .join(CURSOR_ID) + .join(format!("{CURSOR_ID}.jsonl")); + fs::create_dir_all(path.parent().expect("cursor transcript parent")) + .expect("create cursor store"); + fs::write(&path, format!("{CURSOR_LINE}\n")).expect("write cursor transcript"); + path +} + +#[test] +fn cursor_list_show_and_browse_watch_use_listed_session_id() { + let root = env::temp_dir().join(format!("traj-ls11-cursor-rs-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + write_cursor_store(&root); + + let root_text = root.to_str().expect("utf8 cursor root"); + let listed = run_cli(&[ + "list", "--source", "cursor", "--root", root_text, "--limit", "5", + ]); + assert_eq!(listed.code, 0, "stderr={}\nstdout={}", listed.stderr, listed.stdout); + assert!( + listed.stdout.contains(CURSOR_ID), + "session id missing:\n{}", + listed.stdout + ); + + let shown = run_cli(&[ + "show", "--source", "cursor", "--root", root_text, "--id", CURSOR_ID, + ]); + assert_eq!(shown.code, 0, "stderr={}\nstdout={}", shown.stderr, shown.stdout); + assert!( + shown.stdout.contains(CURSOR_ID), + "session id missing:\n{}", + shown.stdout + ); + assert!( + shown.stdout.contains("Content omitted"), + "privacy omission missing:\n{}", + shown.stdout + ); + + let browsed = run_cli(&[ + "browse", + "--source", + "cursor", + "--root", + root_text, + "--id", + CURSOR_ID, + "--watch", + "--emit", + "snapshot+delta", + "--max-updates", + "1", + ]); + let _ = fs::remove_dir_all(&root); + assert_eq!( + browsed.code, 0, + "stderr={}\nstdout={}", + browsed.stderr, + browsed.stdout + ); + assert!( + browsed.stdout.contains("stream update"), + "missing stream update:\n{}", + browsed.stdout + ); + assert!( + browsed.stdout.contains("snapshot"), + "missing snapshot:\n{}", + browsed.stdout + ); + assert!( + browsed.stdout.contains("delta"), + "missing delta:\n{}", + browsed.stdout + ); + assert!( + browsed.stdout.contains("Content omitted"), + "privacy omission missing:\n{}", + browsed.stdout + ); + assert!( + !browsed.stdout.contains("hello"), + "privacy leak:\n{}", + browsed.stdout + ); +} + #[test] fn browse_watch_rejects_ahp() { let out = run_cli(&[ diff --git a/typescript/packages/trajectory-cli/src/cli.ts b/typescript/packages/trajectory-cli/src/cli.ts index 0b43b9d..c4f15e3 100644 --- a/typescript/packages/trajectory-cli/src/cli.ts +++ b/typescript/packages/trajectory-cli/src/cli.ts @@ -257,7 +257,7 @@ function parseSource(value: string): SourceName { if (normalized === "cursor-agent") return "cursor"; throw new TrajectoryNormalizationError( "unknown_source", - `Unknown source '${value}'. Expected one of: ${SOURCES.join(", ")} (alias: grok).`, + `Unknown source '${value}'. Expected one of: ${SOURCES.join(", ")} (aliases: grok, cursor-agent).`, ); } diff --git a/typescript/packages/trajectory-node/src/index.ts b/typescript/packages/trajectory-node/src/index.ts index 182e1c3..a1a78a5 100644 --- a/typescript/packages/trajectory-node/src/index.ts +++ b/typescript/packages/trajectory-node/src/index.ts @@ -226,7 +226,7 @@ async function discoverCursor(root: string): Promise { const updatedAtMs = entry?.updatedAtMs; const updatedAt = typeof updatedAtMs === "number" && Number.isSafeInteger(updatedAtMs) && updatedAtMs >= 0 ? new Date(updatedAtMs).toISOString() : info.mtime.toISOString(); - const title = typeof entry?.title === "string" ? formatTitle(entry.title) : await deriveCursorTitle(path); + const title = typeof entry?.title === "string" ? (formatTitle(entry.title) ?? await deriveCursorTitle(path)) : await deriveCursorTitle(path); items.push({ id: session.name, path, updatedAt, ...(title === undefined ? {} : { title }), sizeBytes: info.size }); } catch (error) { if (!isMissingOrDenied(error)) throw error; } } @@ -518,8 +518,7 @@ async function deriveCursorTitle(path: string): Promise { const message = isRecord(row.message) ? row.message : undefined; const parts = Array.isArray(message?.content) ? message.content : []; const text = parts.filter(isRecord).filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text as string).join("\n"); - const title = titleFromUserText(text); - if (title !== undefined) return title; + return formatTitle(text); } return undefined; } diff --git a/typescript/packages/trajectory/src/internal.ts b/typescript/packages/trajectory/src/internal.ts index bcf5f9a..ab03bcf 100644 --- a/typescript/packages/trajectory/src/internal.ts +++ b/typescript/packages/trajectory/src/internal.ts @@ -1841,8 +1841,10 @@ function decodeCursor(bytes: Uint8Array): DecodedSession { if (partType === "text") { const text = stringValue(value.text); if (text !== undefined) textParts.push(text); - } else if (role === "assistant" && partType === "tool_use") { + } else if (partType === "tool_use" && role === "assistant") { toolParts.push(value); + } else if (partType === "tool_use") { + // User-role tool_use parts are not calls and are ignored. } else if (partType === "image" || partType === "image_url" || partType === "input_image" || partType === "output_image") { diagnostics.push({ code: "image_content_dropped", message: `Dropped image content on a Cursor record on line ${inputLine}.`, inputLine }); } else if (partType) { diff --git a/typescript/test/sample-cli-stream.test.mjs b/typescript/test/sample-cli-stream.test.mjs index 5574ead..c2327a4 100644 --- a/typescript/test/sample-cli-stream.test.mjs +++ b/typescript/test/sample-cli-stream.test.mjs @@ -17,11 +17,15 @@ const ACTIONS = join( "conformance/cases/streaming/ahp-action-turn-flow/step-actions.jsonl", ); const CHAT = "ahp-chat:/00000000-0000-4000-8000-0000000000c1"; +const CURSOR_ID = "019f0000-0000-7000-8000-00000000c0a1"; const SESSION_LINE = '{"type":"session","version":3,"id":"ls11-stream-ts","timestamp":"2026-01-01T00:00:00.000Z","cwd":"/workspace/demo"}\n'; const USER_LINE = '{"type":"message","id":"m1","parentId":null,"timestamp":"2026-01-01T00:00:01.000Z","message":{"role":"user","content":[{"type":"text","text":"hello"}]},"sessionId":"ls11-stream-ts"}\n'; +const CURSOR_LINE = + '{"role":"user","message":{"content":[{"type":"text","text":"hello"}]}}\n' + + '{"role":"assistant","message":{"content":[{"type":"text","text":"done"}]}}\n'; function runCli(args, { timeoutMs = 15000 } = {}) { return new Promise((resolve, reject) => { @@ -150,6 +154,66 @@ test("browse --watch listed session", async () => { assert.doesNotMatch(stdout, /hello/); }); +test("cursor list, show, and browse --watch use the listed session id", async () => { + const root = await mkdtemp(join(tmpdir(), "traj-ls11-cursor-")); + const path = join( + root, + "projects", + "%2Fworkspace%2Fdemo", + "agent-transcripts", + CURSOR_ID, + `${CURSOR_ID}.jsonl`, + ); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, CURSOR_LINE); + + const listed = await runCli([ + "list", + "--source", + "cursor", + "--root", + root, + "--limit", + "5", + ]); + assert.equal(listed.code, 0, listed.stderr); + assert.match(listed.stdout, new RegExp(CURSOR_ID)); + + const shown = await runCli([ + "show", + "--source", + "cursor", + "--root", + root, + "--id", + CURSOR_ID, + ]); + assert.equal(shown.code, 0, shown.stderr); + assert.match(shown.stdout, new RegExp(CURSOR_ID)); + assert.match(shown.stdout, /Content omitted/); + + const browsed = await runCli([ + "browse", + "--source", + "cursor-agent", + "--root", + root, + "--id", + CURSOR_ID, + "--watch", + "--emit", + "snapshot+delta", + "--max-updates", + "1", + ]); + assert.equal(browsed.code, 0, browsed.stderr); + assert.match(browsed.stdout, /stream update/); + assert.match(browsed.stdout, /snapshot/); + assert.match(browsed.stdout, /delta/); + assert.match(browsed.stdout, /Content omitted/); + assert.doesNotMatch(browsed.stdout, /hello/); +}); + test("browse --watch rejects ahp", async () => { const { code, stderr } = await runCli([ "browse", diff --git a/typescript/test/stream-core.test.mjs b/typescript/test/stream-core.test.mjs index fe0bb68..03fb63f 100644 --- a/typescript/test/stream-core.test.mjs +++ b/typescript/test/stream-core.test.mjs @@ -539,6 +539,12 @@ test("per-source append oracle parity", async () => { groupId: "stream-grok-build-append-sequence", steps: 3, }, + { + source: "cursor", + caseId: "cursor-append-sequence", + groupId: "stream-cursor-append-sequence", + steps: 3, + }, ]; for (const { source, caseId, groupId, steps } of cases) { const chunks = []; From cd32764b837e3131d1474f3bf771581a4154c819 Mon Sep 17 00:00:00 2001 From: Matthew Gribben Date: Thu, 13 Aug 2026 21:47:02 +1000 Subject: [PATCH 4/6] fix(release): recover from the partial 0.1.3 registry publish NuGet verify now waits longer and tries the v2 package URL; nupkg push no longer double-uploads symbols. New npm and crates names can first- publish with token secrets when OIDC cannot create them. --- .github/workflows/release.yml | 25 +++++++++++++++++-- CHANGELOG.md | 17 ++++++++++++- docs/publishing.md | 8 ++++-- python/tests/test_py14b_release_workflow.py | 5 ++++ .../test_verify_published_stream_artifact.py | 19 ++++++++++++++ tools/verify_published_stream_artifact.py | 25 +++++++++++++------ 6 files changed, 87 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d959949..6fe8ac2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -396,7 +396,9 @@ jobs: exit 1 fi shopt -s nullglob - pkgs=(artifacts/release/nuget/*.nupkg artifacts/release/nuget/*.snupkg) + # Push nupkgs only. Adjacent .snupkg symbols ride along with the + # nupkg; a second glob of .snupkg hits Conflict after that upload. + pkgs=(artifacts/release/nuget/*.nupkg) if [[ ${#pkgs[@]} -eq 0 ]]; then echo "No NuGet packages found under artifacts/release/nuget" >&2 exit 1 @@ -445,6 +447,7 @@ jobs: - name: Publish packages env: NODE_AUTH_TOKEN: ${{ needs.resolve.outputs.npm_auth == 'token' && secrets.NPM_TOKEN || '' }} + NPM_TOKEN_FALLBACK: ${{ secrets.NPM_TOKEN }} run: | set -euo pipefail VERSION="${{ needs.resolve.outputs.version }}" @@ -473,8 +476,13 @@ jobs: --registry npm \ --package "$workspace" \ --version "$VERSION" + elif [[ -n "${NPM_TOKEN_FALLBACK:-}" ]]; then + # OIDC cannot create a brand-new package name (0.1.3: trajectory-ahp / hermes). + echo "::notice::$workspace missing on npm; retrying first publish with NPM_TOKEN" + NODE_AUTH_TOKEN="$NPM_TOKEN_FALLBACK" npm publish --workspace "$workspace" --access public else - echo "Failed to publish $workspace@${VERSION}" >&2 + echo "Failed to publish $workspace@${VERSION}." >&2 + echo "If this package name has never been published, create it once with an npm token (OIDC cannot create new names), then configure trusted publishing." >&2 exit 1 fi done @@ -505,6 +513,7 @@ jobs: - name: Stamp version and publish env: CARGO_REGISTRY_TOKEN: ${{ steps.crates-auth.outputs.token }} + CARGO_REGISTRY_TOKEN_FALLBACK: ${{ secrets.CARGO_REGISTRY_TOKEN }} run: | set -euo pipefail VERSION="${{ needs.resolve.outputs.version }}" @@ -535,6 +544,18 @@ jobs: --version "$VERSION" || exit 1 return 0 fi + if echo "$out" | grep -qi 'Trusted Publishing tokens do not support creating new crates'; then + if [[ -n "${CARGO_REGISTRY_TOKEN_FALLBACK:-}" ]]; then + echo "::notice::${crate} is a new crate; retrying first publish with CARGO_REGISTRY_TOKEN" + CARGO_REGISTRY_TOKEN="$CARGO_REGISTRY_TOKEN_FALLBACK" \ + cargo +stable publish --manifest-path rust/Cargo.toml \ + -p "$crate" --locked --allow-dirty + return $? + fi + echo "${crate} has never been published. OIDC cannot create new crate names." >&2 + echo "Publish it once with a crates.io token, then add a Trusted Publishing policy." >&2 + exit 1 + fi if curl -fsSL "https://crates.io/api/v1/crates/${crate}/${VERSION}" >/dev/null 2>&1; then echo "::notice::${crate}@${VERSION} already on crates.io; verifying stream content" python3 tools/verify_published_stream_artifact.py \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 36f4e36..7475604 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,23 @@ remain synchronized across NuGet, npm, and crates.io preview artifacts. ## [Unreleased] +## 0.1.4 — 2026-08-13 + +Cursor Agent source plus a synchronized re-publish after the partial `0.1.3` +registry cut. **Do not retag `v0.1.3`** — PyPI / most NuGet / existing npm and +crates names already accepted `0.1.3`. + - **Source:** added Cursor Agent (`cursor`) transcript JSONL decoding, listing, - and file streaming across all four runtimes. + and file streaming across all four runtimes. Wire name `cursor` (alias + `cursor-agent`). Default store `$CURSOR_HOME` or `~/.cursor`. Encrypted + `store.db` is not v1 normalize input. + +- **Release:** NuGet verify waits longer for the CDN and tries the v2 package + URL; `dotnet nuget push` no longer double-uploads `.snupkg`. New npm/crates + names (`trajectory-ahp`, `trajectory-hermes`, `hypabolic-trajectory-io` / + `-ahp` / `-hermes`) retry first-publish with token secrets when OIDC cannot + create them. + - **Docs:** removed delivery-plan / status trackers (`implementation-plan`, `multi-language-plan`, `live-session-streaming-plan`, `live-session-streaming-status`, `python-implementation-spec`, diff --git a/docs/publishing.md b/docs/publishing.md index 4023b42..524b472 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -25,8 +25,12 @@ Cross-ecosystem package map (core vs optional): ## Create a release (normal path) `v0.1.3` is the live-session streaming cut (core stream APIs + optional I/O / -AHP / Hermes packages). Do **not** retag `v0.1.0` or `v0.1.2`. Further -releases use a new synchronized tag (`v0.1.4`, `v0.2.0`, …). +AHP / Hermes packages). That tag **partially published**: PyPI, existing npm +names, existing crates, and NuGet accepted `0.1.3`; brand-new npm/crates names +(`trajectory-ahp`, `trajectory-hermes`, `hypabolic-trajectory-io` / `-ahp` / +`-hermes`) cannot be created by OIDC and need a one-time token first-publish. +Do **not** retag `v0.1.0`, `v0.1.2`, or `v0.1.3`. Further releases use a new +synchronized tag (`v0.1.4`, `v0.2.0`, …). ```bash git checkout main diff --git a/python/tests/test_py14b_release_workflow.py b/python/tests/test_py14b_release_workflow.py index 677fc49..c9c976c 100644 --- a/python/tests/test_py14b_release_workflow.py +++ b/python/tests/test_py14b_release_workflow.py @@ -102,11 +102,16 @@ def test_already_published_fallbacks_require_stream_content_check() -> None: nuget = _job_block(text, "publish-nuget") assert "--skip-duplicate" in nuget assert "--registry nuget" in nuget + assert "artifacts/release/nuget/*.nupkg" in nuget + assert "artifacts/release/nuget/*.snupkg" not in nuget npm = _job_block(text, "publish-npm") assert "already published; verifying stream content" in npm + assert "NPM_TOKEN_FALLBACK" in npm crates = _job_block(text, "publish-crates") assert "already published; verifying stream content" in crates assert "already on crates.io; verifying stream content" in crates + assert "CARGO_REGISTRY_TOKEN_FALLBACK" in crates + assert "Trusted Publishing tokens do not support creating new crates" in crates pypi = _job_block(text, "publish-pypi") assert "skip-existing: true" in pypi assert "--registry pypi" in pypi diff --git a/python/tests/test_verify_published_stream_artifact.py b/python/tests/test_verify_published_stream_artifact.py index fae2036..f36e05d 100644 --- a/python/tests/test_verify_published_stream_artifact.py +++ b/python/tests/test_verify_published_stream_artifact.py @@ -18,6 +18,7 @@ from verify_published_stream_artifact import ( # noqa: E402 CORE_STREAM_CAPS, VerifyError, + download_nuget, identify_package, main, read_archive, @@ -81,6 +82,24 @@ def test_pre_stream_nupkg_fails_with_retag_guidance(tmp_path: Path) -> None: assert "stream-core" in str(exc.value) +def test_nuget_download_falls_back_to_v2_package_url(monkeypatch: pytest.MonkeyPatch) -> None: + seen: list[str] = [] + + def fake_get(url: str) -> bytes: + seen.append(url) + if "flatcontainer" in url: + raise VerifyError("HTTP Error 404: The specified blob does not exist.") + return b"nupkg-bytes" + + monkeypatch.setattr( + "verify_published_stream_artifact._http_get", + fake_get, + ) + assert download_nuget("Hypabolic.Trajectory", "0.1.3") == b"nupkg-bytes" + assert any("flatcontainer" in url for url in seen) + assert any("/api/v2/package/Hypabolic.Trajectory/0.1.3" in url for url in seen) + + def test_optional_npm_tarball_requires_package_capabilities(tmp_path: Path) -> None: tgz = _write_tgz( tmp_path / "hypabolic-trajectory-ahp-0.1.3.tgz", diff --git a/tools/verify_published_stream_artifact.py b/tools/verify_published_stream_artifact.py index c9c054c..5dc33ab 100644 --- a/tools/verify_published_stream_artifact.py +++ b/tools/verify_published_stream_artifact.py @@ -28,6 +28,8 @@ USER_AGENT = "TrajectoryReleaseVerifier/1.0 (+https://github.com/Hypabolic/Trajectory)" RETRY_ATTEMPTS = 8 RETRY_SLEEP_SECONDS = 15 +NUGET_RETRY_ATTEMPTS = 18 +NUGET_RETRY_SLEEP_SECONDS = 20 CORE_STREAM_CAPS = ( "stream-core", @@ -399,10 +401,17 @@ def format_failure(package: str, version: str, details: Iterable[str]) -> str: def download_nuget(package: str, version: str) -> bytes: ident = package.lower() ver = version.lower() - url = ( - f"https://api.nuget.org/v3-flatcontainer/{ident}/{ver}/{ident}.{ver}.nupkg" + urls = ( + f"https://api.nuget.org/v3-flatcontainer/{ident}/{ver}/{ident}.{ver}.nupkg", + f"https://www.nuget.org/api/v2/package/{package}/{version}", ) - return _http_get(url) + last_error: Exception | None = None + for url in urls: + try: + return _http_get(url) + except (VerifyError, urllib.error.URLError, TimeoutError) as exc: + last_error = exc + raise VerifyError(f"{package}@{version} nuget download failed: {last_error}") def download_npm(package: str, version: str) -> bytes: @@ -443,7 +452,9 @@ def download_pypi(package: str, version: str, *, prefer: str = "wheel") -> bytes def download_with_retries(registry: str, package: str, version: str) -> bytes: last_error: Exception | None = None - for attempt in range(1, RETRY_ATTEMPTS + 1): + attempts = NUGET_RETRY_ATTEMPTS if registry == "nuget" else RETRY_ATTEMPTS + sleep_s = NUGET_RETRY_SLEEP_SECONDS if registry == "nuget" else RETRY_SLEEP_SECONDS + for attempt in range(1, attempts + 1): try: if registry == "nuget": return download_nuget(package, version) @@ -458,11 +469,11 @@ def download_with_retries(registry: str, package: str, version: str) -> bytes: last_error = exc print( f"{package}@{version} {registry} download attempt {attempt}/" - f"{RETRY_ATTEMPTS} failed: {exc}", + f"{attempts} failed: {exc}", file=sys.stderr, ) - if attempt < RETRY_ATTEMPTS: - time.sleep(RETRY_SLEEP_SECONDS) + if attempt < attempts: + time.sleep(sleep_s) raise VerifyError( f"could not download {package}@{version} from {registry}: {last_error}" ) From 11002becb0d1f9b5525293baa2a989918ffe31be Mon Sep 17 00:00:00 2001 From: Matthew Gribben Date: Thu, 13 Aug 2026 21:54:01 +1000 Subject: [PATCH 5/6] fix(ci): advertise cursor in capability gates and format adapters Update CI/source-count assertions for the new Cursor cases, rustfmt and clippy the Cursor adapters, and add listCursorTrajectories to the TypeScript export smoke list. --- .github/workflows/ci.yml | 8 +- .../Adapters/Cursor/CursorTrajectoryLister.cs | 18 +- python/tests/test_py10_full.py | 6 +- rust/crates/hypabolic-trajectory/src/lib.rs | 14 +- .../hypabolic-trajectory/src/listing.rs | 102 ++++++++--- .../hypabolic-trajectory/src/normalize.rs | 172 +++++++++++++++--- .../hypabolic-trajectory/src/streaming.rs | 4 +- rust/tools/trajectory-cli/src/main.rs | 16 +- .../tests/ls11_sample_cli_stream.rs | 26 ++- rust/tools/trajectory-conformance/src/main.rs | 10 +- typescript/test/runtime-conformance.test.mjs | 4 +- typescript/test/workspace-smoke.test.mjs | 1 + 12 files changed, 286 insertions(+), 95 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d19b0b..571a9d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,7 +78,7 @@ jobs: .runtime == "dotnet" and .slice == "ML13" and .normalizer_contract_version == "0.2.0" - and .sources == ["pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build"] + and .sources == ["pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build", "cursor"] and .outputs == [ "letta-trajectory-v1", "letta-canonical-v1", @@ -236,7 +236,7 @@ jobs: .runtime == "typescript" and .slice == "ML13" and .normalizer_contract_version == "0.2.0" - and .sources == ["pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build"] + and .sources == ["pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build", "cursor"] and .outputs == [ "letta-trajectory-v1", "letta-canonical-v1", @@ -378,7 +378,7 @@ jobs: .runtime == "python" and .slice == "ML13" and .normalizer_contract_version == "0.2.0" - and .sources == ["pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build"] + and .sources == ["pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build", "cursor"] and .outputs == [ "letta-trajectory-v1", "letta-canonical-v1", @@ -530,7 +530,7 @@ jobs: .runtime == "rust" and .slice == "ML13" and .normalizer_contract_version == "0.2.0" - and .sources == ["pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build"] + and .sources == ["pi", "claude-code", "codex", "openclaw", "hermes", "ahp", "grok-build", "cursor"] and .outputs == [ "letta-trajectory-v1", "letta-canonical-v1", diff --git a/dotnet/src/Trajectory/Adapters/Cursor/CursorTrajectoryLister.cs b/dotnet/src/Trajectory/Adapters/Cursor/CursorTrajectoryLister.cs index 124751c..43681a3 100644 --- a/dotnet/src/Trajectory/Adapters/Cursor/CursorTrajectoryLister.cs +++ b/dotnet/src/Trajectory/Adapters/Cursor/CursorTrajectoryLister.cs @@ -15,17 +15,17 @@ public IReadOnlyList List(string? root) try { foreach (var hash in Directory.EnumerateDirectories(Path.Combine(cursorRoot, "chats"))) - foreach (var session in Directory.EnumerateDirectories(hash)) - { - try + foreach (var session in Directory.EnumerateDirectories(hash)) { - using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(session, "meta.json"))); - var sessionId = Path.GetFileName(session); - if (document.RootElement.ValueKind == JsonValueKind.Object && sessionId is not null && !metadata.ContainsKey(sessionId)) - metadata[sessionId] = document.RootElement.Clone(); + try + { + using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(session, "meta.json"))); + var sessionId = Path.GetFileName(session); + if (document.RootElement.ValueKind == JsonValueKind.Object && sessionId is not null && !metadata.ContainsKey(sessionId)) + metadata[sessionId] = document.RootElement.Clone(); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or JsonException) { } } - catch (Exception error) when (error is IOException or UnauthorizedAccessException or JsonException) { } - } } catch (Exception error) when (error is IOException or UnauthorizedAccessException) { } diff --git a/python/tests/test_py10_full.py b/python/tests/test_py10_full.py index f89e863..4dbafab 100644 --- a/python/tests/test_py10_full.py +++ b/python/tests/test_py10_full.py @@ -256,7 +256,7 @@ def test_filtered_verify_full_protocol_v1_tip_surface_green() -> None: summary = json.loads(completed.stdout) assert summary["status"] == "success" # Current tip case inventory: 39 batch cases / 66 operations (all declared pairs). - assert summary["cases"] == 39 + assert summary["cases"] == 45 assert summary["operations"] == 66 @@ -288,8 +288,8 @@ def test_stream_verify_matrix_green() -> None: summary = json.loads(completed.stdout) assert summary["status"] == "success" # 41 streaming cases × stream-sequence (includes hermes-provider-* export apply). - assert summary["cases"] == 41 - assert summary["operations"] == 41 + assert summary["cases"] == 42 + assert summary["operations"] == 42 assert summary["stream_unsupported_skips"] == 0 diff --git a/rust/crates/hypabolic-trajectory/src/lib.rs b/rust/crates/hypabolic-trajectory/src/lib.rs index d6f14b9..614f356 100644 --- a/rust/crates/hypabolic-trajectory/src/lib.rs +++ b/rust/crates/hypabolic-trajectory/src/lib.rs @@ -12,9 +12,9 @@ mod streaming; pub use canonical::{canonical_json, relaxed_json}; pub use listing::{ ListingOptions, TrajectoryListing, TrajectoryListingPage, list_ahp_trajectories, - list_claude_code_trajectories, list_codex_trajectories, list_grok_build_trajectories, - list_hermes_trajectories, list_openclaw_trajectories, list_pi_trajectories, - list_cursor_trajectories, + list_claude_code_trajectories, list_codex_trajectories, list_cursor_trajectories, + list_grok_build_trajectories, list_hermes_trajectories, list_openclaw_trajectories, + list_pi_trajectories, }; pub use model::{ AppliedConfig, Bounds, Diagnostic, Filters, IrRecord, ModelInvocation, ModelTokenUsage, @@ -23,10 +23,10 @@ pub use model::{ TruncationStrategy, }; pub use normalize::{ - AhpSourceAdapter, ClaudeCodeSourceAdapter, CodexSourceAdapter, CursorSourceAdapter, GrokBuildSourceAdapter, - HermesSourceAdapter, OpenClawSourceAdapter, PiSourceAdapter, SourceAdapter, normalize_ahp, - normalize_claude_code, normalize_codex, normalize_grok_build, normalize_hermes, - normalize_openclaw, normalize_pi, normalize_cursor, + AhpSourceAdapter, ClaudeCodeSourceAdapter, CodexSourceAdapter, CursorSourceAdapter, + GrokBuildSourceAdapter, HermesSourceAdapter, OpenClawSourceAdapter, PiSourceAdapter, + SourceAdapter, normalize_ahp, normalize_claude_code, normalize_codex, normalize_cursor, + normalize_grok_build, normalize_hermes, normalize_openclaw, normalize_pi, }; pub use projection::{ OutputAdapter, canonical_value, hypabolic_value, letta_value, openai_value, diff --git a/rust/crates/hypabolic-trajectory/src/listing.rs b/rust/crates/hypabolic-trajectory/src/listing.rs index 3d31bc9..2df7b67 100644 --- a/rust/crates/hypabolic-trajectory/src/listing.rs +++ b/rust/crates/hypabolic-trajectory/src/listing.rs @@ -230,40 +230,86 @@ pub fn list_cursor_trajectories( let mut meta = Vec::new(); if let Ok(hashes) = fs::read_dir(root.join("chats")) { for hash in hashes.flatten() { - if !hash.file_type().map(|kind| kind.is_dir()).unwrap_or(false) { continue; } + if !hash.file_type().is_ok_and(|kind| kind.is_dir()) { + continue; + } if let Ok(sessions) = fs::read_dir(hash.path()) { for session in sessions.flatten() { let path = session.path().join("meta.json"); - let Ok(text) = fs::read_to_string(path) else { continue }; - let Ok(value) = serde_json::from_str::(&text) else { continue }; - if value.is_object() { meta.push((session.file_name().to_string_lossy().into_owned(), value)); } + let Ok(text) = fs::read_to_string(path) else { + continue; + }; + let Ok(value) = serde_json::from_str::(&text) else { + continue; + }; + if value.is_object() { + meta.push((session.file_name().to_string_lossy().into_owned(), value)); + } } } } } let projects = match fs::read_dir(root.join("projects")) { Ok(value) => value, - Err(error) if missing_or_denied(&error) => return Ok(TrajectoryListingPage { items, next_cursor: None }), + Err(error) if missing_or_denied(&error) => { + return Ok(TrajectoryListingPage { + items, + next_cursor: None, + }); + } Err(error) => return Err(io_error("Could not enumerate the Cursor store.", &error)), }; for project in projects.flatten() { - if !project.file_type().map(|kind| kind.is_dir()).unwrap_or(false) { continue; } + if !project.file_type().is_ok_and(|kind| kind.is_dir()) { + continue; + } let sessions_root = project.path().join("agent-transcripts"); - let Ok(sessions) = fs::read_dir(sessions_root) else { continue }; + let Ok(sessions) = fs::read_dir(sessions_root) else { + continue; + }; for session in sessions.flatten() { - if !session.file_type().map(|kind| kind.is_dir()).unwrap_or(false) { continue; } + if !session.file_type().is_ok_and(|kind| kind.is_dir()) { + continue; + } let id = session.file_name().to_string_lossy().into_owned(); let path = session.path().join(format!("{id}.jsonl")); - let Ok(metadata) = fs::metadata(&path) else { continue }; - if !metadata.is_file() || path.file_stem().and_then(|value| value.to_str()) != Some(id.as_str()) { continue; } - let found = meta.iter().find(|(key, _)| key == &id).map(|(_, value)| value); - let updated = found.and_then(|value| value.get("updatedAtMs")).and_then(serde_json::Value::as_i64) - .and_then(|value| format_ms(value).ok()).unwrap_or_else(|| { - metadata.modified().ok().and_then(|value| value.duration_since(UNIX_EPOCH).ok()).and_then(|value| i64::try_from(value.as_millis()).ok()).and_then(|value| format_ms(value).ok()).unwrap_or_default() + let Ok(metadata) = fs::metadata(&path) else { + continue; + }; + if !metadata.is_file() + || path.file_stem().and_then(|value| value.to_str()) != Some(id.as_str()) + { + continue; + } + let found = meta + .iter() + .find(|(key, _)| key == &id) + .map(|(_, value)| value); + let updated = found + .and_then(|value| value.get("updatedAtMs")) + .and_then(serde_json::Value::as_i64) + .and_then(|value| format_ms(value).ok()) + .unwrap_or_else(|| { + metadata + .modified() + .ok() + .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) + .and_then(|value| i64::try_from(value.as_millis()).ok()) + .and_then(|value| format_ms(value).ok()) + .unwrap_or_default() }); - let title = found.and_then(|value| value.get("title")).and_then(serde_json::Value::as_str).and_then(format_title) + let title = found + .and_then(|value| value.get("title")) + .and_then(serde_json::Value::as_str) + .and_then(format_title) .or_else(|| derive_cursor_title(&path)); - items.push(TrajectoryListing { id, path, updated_at: updated, title, size_bytes: metadata.len() }); + items.push(TrajectoryListing { + id, + path, + updated_at: updated, + title, + size_bytes: metadata.len(), + }); } } paginate(options, items) @@ -763,11 +809,25 @@ fn derive_generic_user_title(path: &Path) -> Option { fn derive_cursor_title(path: &Path) -> Option { for value in scan_json_lines(path) { - if value.get("role").and_then(serde_json::Value::as_str) != Some("user") { continue; } - let Some(parts) = value.get("message").and_then(|value| value.get("content")).and_then(serde_json::Value::as_array) else { continue }; - let text = parts.iter().filter_map(|part| { - (part.get("type").and_then(serde_json::Value::as_str) == Some("text")).then(|| part.get("text").and_then(serde_json::Value::as_str)).flatten() - }).collect::>().join("\n"); + if value.get("role").and_then(serde_json::Value::as_str) != Some("user") { + continue; + } + let Some(parts) = value + .get("message") + .and_then(|value| value.get("content")) + .and_then(serde_json::Value::as_array) + else { + continue; + }; + let text = parts + .iter() + .filter_map(|part| { + (part.get("type").and_then(serde_json::Value::as_str) == Some("text")) + .then(|| part.get("text").and_then(serde_json::Value::as_str)) + .flatten() + }) + .collect::>() + .join("\n"); return format_title(&text); } None diff --git a/rust/crates/hypabolic-trajectory/src/normalize.rs b/rust/crates/hypabolic-trajectory/src/normalize.rs index 839b454..632ba53 100644 --- a/rust/crates/hypabolic-trajectory/src/normalize.rs +++ b/rust/crates/hypabolic-trajectory/src/normalize.rs @@ -253,7 +253,9 @@ pub fn normalize_grok_build(request: NormalizeRequest<'_>) -> Result &'static str { "cursor" } + fn source(&self) -> &'static str { + "cursor" + } fn normalize(&self, request: NormalizeRequest<'_>) -> Result { normalize_cursor(request) @@ -268,68 +270,184 @@ pub fn normalize_cursor(request: NormalizeRequest<'_>) -> Result offset && request.transcript[end - 1] == b'\r' { end - 1 } else { end }; + let line_end = if end > offset && request.transcript[end - 1] == b'\r' { + end - 1 + } else { + end + }; let slice = &request.transcript[offset..line_end]; - if !slice.iter().all(|value| matches!(*value, b' ' | b'\t' | b'\r')) { - let source_offset = i64::try_from(offset).map_err(|_| TrajectoryError::new("invalid_input", "Transcript byte offset exceeds signed 64-bit range."))?; - match std::str::from_utf8(slice).ok().and_then(|text| serde_json::from_str::(text).ok()) { + if !slice + .iter() + .all(|value| matches!(*value, b' ' | b'\t' | b'\r')) + { + let source_offset = i64::try_from(offset).map_err(|_| { + TrajectoryError::new( + "invalid_input", + "Transcript byte offset exceeds signed 64-bit range.", + ) + })?; + match std::str::from_utf8(slice) + .ok() + .and_then(|text| serde_json::from_str::(text).ok()) + { Some(Value::Object(row)) => { let role = string_value(row.get("role")); let row_type = string_value(row.get("type")); let mut component_index = 0_usize; - let mut emit = |kind: EventKind, role_value: Role, content: Option, tool_name: Option, arguments_json: Option| { - events.push(DecodedEvent { kind, role: role_value, content, tool_call_id: None, tool_name, arguments_json, is_error: None, native_id: None, producer_version: None, source_sequence: None, source_offset, input_line: Some(line), timestamp_ms: None, timestamp_precise: None, component_index, model: None }); - component_index += 1; - }; + let mut emit = + |kind: EventKind, + role_value: Role, + content: Option, + tool_name: Option, + arguments_json: Option| { + events.push(DecodedEvent { + kind, + role: role_value, + content, + tool_call_id: None, + tool_name, + arguments_json, + is_error: None, + native_id: None, + producer_version: None, + source_sequence: None, + source_offset, + input_line: Some(line), + timestamp_ms: None, + timestamp_precise: None, + component_index, + model: None, + }); + component_index += 1; + }; if matches!(role, Some("user" | "assistant")) { - let content = row.get("message").and_then(Value::as_object).and_then(|message| message.get("content")).and_then(Value::as_array); + let content = row + .get("message") + .and_then(Value::as_object) + .and_then(|message| message.get("content")) + .and_then(Value::as_array); let mut texts = Vec::new(); if let Some(parts) = content { for part in parts { - let Some(part) = part.as_object() else { continue }; + let Some(part) = part.as_object() else { + continue; + }; match string_value(part.get("type")) { Some("text") => if let Some(text) = string_value(part.get("text")) { texts.push(text.to_owned()); }, Some("image" | "image_url" | "input_image" | "output_image") => diagnostics.push(Diagnostic { code: "image_content_dropped".into(), message: format!("Dropped image content on a Cursor record on line {line}."), input_line: Some(line), record_index: None, count: None }), - Some("tool_use") if role == Some("assistant") => {}, - Some("tool_use") => {}, + Some("tool_use") | None => {} Some(_) => diagnostics.push(Diagnostic { code: "unknown_content_part".into(), message: format!("Skipped an unknown Cursor content part on line {line}."), input_line: Some(line), record_index: None, count: None }), - None => {}, } } } let text = texts.join("\n"); - if !text.trim().is_empty() { emit(EventKind::Message, if role == Some("user") { Role::User } else { Role::Assistant }, Some(text), None, None); } + if !text.trim().is_empty() { + emit( + EventKind::Message, + if role == Some("user") { + Role::User + } else { + Role::Assistant + }, + Some(text), + None, + None, + ); + } if role == Some("assistant") { if let Some(parts) = content { for part in parts { - let Some(part) = part.as_object() else { continue }; - if string_value(part.get("type")) != Some("tool_use") { continue; } - let Some(name) = string_value(part.get("name")).filter(|value| !value.trim().is_empty()) else { + let Some(part) = part.as_object() else { + continue; + }; + if string_value(part.get("type")) != Some("tool_use") { + continue; + } + let Some(name) = string_value(part.get("name")) + .filter(|value| !value.trim().is_empty()) + else { diagnostics.push(Diagnostic { code: "tool_use_missing_name".into(), message: format!("Skipped a Cursor tool_use part without a name on line {line}."), input_line: Some(line), record_index: None, count: None }); continue; }; - let arguments = match part.get("input").filter(|value| value.is_object()) { Some(value) => relaxed_json(value)?, None => "{}".into() }; - emit(EventKind::ToolCall, Role::Assistant, None, Some(name.into()), Some(arguments)); + let arguments = + match part.get("input").filter(|value| value.is_object()) { + Some(value) => relaxed_json(value)?, + None => "{}".into(), + }; + emit( + EventKind::ToolCall, + Role::Assistant, + None, + Some(name.into()), + Some(arguments), + ); } } } } else if row_type == Some("turn_ended") { - if string_value(row.get("status")) == Some("error") { diagnostics.push(Diagnostic { code: "turn_ended_error".into(), message: "A Cursor turn ended with an error.".into(), input_line: Some(line), record_index: None, count: None }); } + if string_value(row.get("status")) == Some("error") { + diagnostics.push(Diagnostic { + code: "turn_ended_error".into(), + message: "A Cursor turn ended with an error.".into(), + input_line: Some(line), + record_index: None, + count: None, + }); + } } else if role.is_some() || row_type.is_some() { - diagnostics.push(Diagnostic { code: "unknown_semantic_record".into(), message: format!("Skipped an unknown Cursor semantic record on line {line}."), input_line: Some(line), record_index: None, count: None }); + diagnostics.push(Diagnostic { + code: "unknown_semantic_record".into(), + message: format!( + "Skipped an unknown Cursor semantic record on line {line}." + ), + input_line: Some(line), + record_index: None, + count: None, + }); } } - Some(_) => diagnostics.push(Diagnostic { code: "non_object_json_line".into(), message: format!("Skipped non-object JSON on line {line}."), input_line: Some(line), record_index: None, count: None }), - None => diagnostics.push(Diagnostic { code: "invalid_json_line".into(), message: format!("Skipped invalid JSON on line {line}."), input_line: Some(line), record_index: None, count: None }), + Some(_) => diagnostics.push(Diagnostic { + code: "non_object_json_line".into(), + message: format!("Skipped non-object JSON on line {line}."), + input_line: Some(line), + record_index: None, + count: None, + }), + None => diagnostics.push(Diagnostic { + code: "invalid_json_line".into(), + message: format!("Skipped invalid JSON on line {line}."), + input_line: Some(line), + record_index: None, + count: None, + }), } } - if end == request.transcript.len() { break; } + if end == request.transcript.len() { + break; + } offset = end + 1; line += 1; } - normalize_decoded(config, DecodedSession { source: TrajectorySource::Cursor, source_name: "cursor", group_id: None, cwd: None, git_branch: None, model: None, producer_version: None, created_at_ms: None, events, model_invocations: Vec::new(), diagnostics }) + normalize_decoded( + config, + DecodedSession { + source: TrajectorySource::Cursor, + source_name: "cursor", + group_id: None, + cwd: None, + git_branch: None, + model: None, + producer_version: None, + created_at_ms: None, + events, + model_invocations: Vec::new(), + diagnostics, + }, + ) } fn decode_grok_build( diff --git a/rust/crates/hypabolic-trajectory/src/streaming.rs b/rust/crates/hypabolic-trajectory/src/streaming.rs index 4c2fb4e..59ba0dc 100644 --- a/rust/crates/hypabolic-trajectory/src/streaming.rs +++ b/rust/crates/hypabolic-trajectory/src/streaming.rs @@ -11,8 +11,8 @@ use crate::model::{ NormalizeOptions, NormalizeRequest, SourceContext, TrajectoryError, TrajectorySource, }; use crate::normalize::{ - normalize_ahp, normalize_claude_code, normalize_codex, normalize_cursor, normalize_grok_build, normalize_hermes, - normalize_openclaw, normalize_pi, + normalize_ahp, normalize_claude_code, normalize_codex, normalize_cursor, normalize_grok_build, + normalize_hermes, normalize_openclaw, normalize_pi, }; use crate::projection::{hypabolic_value, sha256}; diff --git a/rust/tools/trajectory-cli/src/main.rs b/rust/tools/trajectory-cli/src/main.rs index ebb7639..8292e42 100644 --- a/rust/tools/trajectory-cli/src/main.rs +++ b/rust/tools/trajectory-cli/src/main.rs @@ -21,10 +21,10 @@ use hypabolic_trajectory::{ ListingOptions, NormalizeOptions, NormalizeRequest, RecordKind, SourceContext, StreamDelivery, StreamOptions, StreamUpdate, Trajectory, TrajectoryError, TrajectoryListing, TrajectorySource, list_ahp_trajectories, list_claude_code_trajectories, list_codex_trajectories, - list_cursor_trajectories, list_grok_build_trajectories, list_hermes_trajectories, list_openclaw_trajectories, - list_pi_trajectories, normalize_ahp, normalize_claude_code, normalize_codex, - normalize_cursor, normalize_grok_build, normalize_hermes, normalize_openclaw, normalize_pi, project_hypabolic, - project_letta, + list_cursor_trajectories, list_grok_build_trajectories, list_hermes_trajectories, + list_openclaw_trajectories, list_pi_trajectories, normalize_ahp, normalize_claude_code, + normalize_codex, normalize_cursor, normalize_grok_build, normalize_hermes, normalize_openclaw, + normalize_pi, project_hypabolic, project_letta, }; use hypabolic_trajectory_ahp::{ AhpClientEvent, AhpClientEventKind, AhpClientOptions, AhpStreamClient, FakeAhpHost, @@ -1271,7 +1271,13 @@ fn resolve_root(source: SourceArg, root_override: Option<&Path>) -> PathBuf { } home.join(".grok").join("sessions") } - SourceArg::Cursor => env::var("CURSOR_HOME").ok().filter(|value| !value.trim().is_empty()).map(|value| PathBuf::from(expand_home(value.trim()))).unwrap_or_else(|| home.join(".cursor")), + SourceArg::Cursor => env::var("CURSOR_HOME") + .ok() + .filter(|value| !value.trim().is_empty()) + .map_or_else( + || home.join(".cursor"), + |value| PathBuf::from(expand_home(value.trim())), + ), } } diff --git a/rust/tools/trajectory-cli/tests/ls11_sample_cli_stream.rs b/rust/tools/trajectory-cli/tests/ls11_sample_cli_stream.rs index b7f9f08..248b89c 100644 --- a/rust/tools/trajectory-cli/tests/ls11_sample_cli_stream.rs +++ b/rust/tools/trajectory-cli/tests/ls11_sample_cli_stream.rs @@ -22,12 +22,11 @@ const USER_LINE: &str = concat!( r#"{"type":"message","id":"m1","parentId":null,"timestamp":"2026-01-01T00:00:01.000Z","message":{"role":"user","content":[{"type":"text","text":"hello"}]},"sessionId":"ls11-stream-rs"}"#, "\n" ); -const CURSOR_LINE: &str = - concat!( - r#"{"role":"user","message":{"content":[{"type":"text","text":"hello"}]}}"#, - "\n", - r#"{"role":"assistant","message":{"content":[{"type":"text","text":"done"}]}}"# - ); +const CURSOR_LINE: &str = concat!( + r#"{"role":"user","message":{"content":[{"type":"text","text":"hello"}]}}"#, + "\n", + r#"{"role":"assistant","message":{"content":[{"type":"text","text":"done"}]}}"# +); fn trajectory_bin() -> PathBuf { if let Some(path) = env::var_os("CARGO_BIN_EXE_trajectory") { @@ -358,7 +357,11 @@ fn cursor_list_show_and_browse_watch_use_listed_session_id() { let listed = run_cli(&[ "list", "--source", "cursor", "--root", root_text, "--limit", "5", ]); - assert_eq!(listed.code, 0, "stderr={}\nstdout={}", listed.stderr, listed.stdout); + assert_eq!( + listed.code, 0, + "stderr={}\nstdout={}", + listed.stderr, listed.stdout + ); assert!( listed.stdout.contains(CURSOR_ID), "session id missing:\n{}", @@ -368,7 +371,11 @@ fn cursor_list_show_and_browse_watch_use_listed_session_id() { let shown = run_cli(&[ "show", "--source", "cursor", "--root", root_text, "--id", CURSOR_ID, ]); - assert_eq!(shown.code, 0, "stderr={}\nstdout={}", shown.stderr, shown.stdout); + assert_eq!( + shown.code, 0, + "stderr={}\nstdout={}", + shown.stderr, shown.stdout + ); assert!( shown.stdout.contains(CURSOR_ID), "session id missing:\n{}", @@ -398,8 +405,7 @@ fn cursor_list_show_and_browse_watch_use_listed_session_id() { assert_eq!( browsed.code, 0, "stderr={}\nstdout={}", - browsed.stderr, - browsed.stdout + browsed.stderr, browsed.stdout ); assert!( browsed.stdout.contains("stream update"), diff --git a/rust/tools/trajectory-conformance/src/main.rs b/rust/tools/trajectory-conformance/src/main.rs index e40820a..93b013c 100644 --- a/rust/tools/trajectory-conformance/src/main.rs +++ b/rust/tools/trajectory-conformance/src/main.rs @@ -14,11 +14,11 @@ use hypabolic_trajectory::{ TrajectorySource, TruncationStrategy, apply_ahp_actions, apply_ahp_snapshot, apply_append, apply_hermes_export, apply_snapshot, create_stream, finish_stream, json_safe_from_value, list_ahp_trajectories, list_claude_code_trajectories, list_codex_trajectories, - list_cursor_trajectories, list_grok_build_trajectories, list_hermes_trajectories, list_openclaw_trajectories, - list_pi_trajectories, normalize_ahp, normalize_claude_code, normalize_codex, - normalize_cursor, normalize_grok_build, normalize_hermes, normalize_openclaw, normalize_pi, project_canonical, - project_hypabolic, project_letta, project_minimal_jsonl, project_openai, project_opentelemetry, - reset_stream, update_to_value, + list_cursor_trajectories, list_grok_build_trajectories, list_hermes_trajectories, + list_openclaw_trajectories, list_pi_trajectories, normalize_ahp, normalize_claude_code, + normalize_codex, normalize_cursor, normalize_grok_build, normalize_hermes, normalize_openclaw, + normalize_pi, project_canonical, project_hypabolic, project_letta, project_minimal_jsonl, + project_openai, project_opentelemetry, reset_stream, update_to_value, }; use serde::Deserialize; use serde_json::{Map, Value, json}; diff --git a/typescript/test/runtime-conformance.test.mjs b/typescript/test/runtime-conformance.test.mjs index 5992673..ef88b7b 100644 --- a/typescript/test/runtime-conformance.test.mjs +++ b/typescript/test/runtime-conformance.test.mjs @@ -34,8 +34,8 @@ test("TypeScript runner passes every advertised shared operation deterministical assert.deepEqual(JSON.parse(output), { protocol_version: "1", status: "success", - cases: 80, - operations: 107, + cases: 87, + operations: 118, stream_unsupported_skips: 0, }); }); diff --git a/typescript/test/workspace-smoke.test.mjs b/typescript/test/workspace-smoke.test.mjs index b30ad10..39fb45d 100644 --- a/typescript/test/workspace-smoke.test.mjs +++ b/typescript/test/workspace-smoke.test.mjs @@ -33,6 +33,7 @@ test("workspace packages install and import through public exports", () => { "listAhpTrajectories", "listClaudeCodeTrajectories", "listCodexTrajectories", + "listCursorTrajectories", "listGrokBuildTrajectories", "listHermesTrajectories", "listOpenClawTrajectories", From 3c2c0b78a9450859dd5f805ba691fbeac1411d3c Mon Sep 17 00:00:00 2001 From: Matthew Gribben Date: Thu, 13 Aug 2026 21:58:27 +1000 Subject: [PATCH 6/6] fix(ci): update filtered tip operation count for Cursor cases --- python/tests/test_py10_full.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/tests/test_py10_full.py b/python/tests/test_py10_full.py index 4dbafab..d05a8e3 100644 --- a/python/tests/test_py10_full.py +++ b/python/tests/test_py10_full.py @@ -255,9 +255,9 @@ def test_filtered_verify_full_protocol_v1_tip_surface_green() -> None: ) summary = json.loads(completed.stdout) assert summary["status"] == "success" - # Current tip case inventory: 39 batch cases / 66 operations (all declared pairs). + # Current tip case inventory: 45 batch cases / 76 operations (all declared pairs). assert summary["cases"] == 45 - assert summary["operations"] == 66 + assert summary["operations"] == 76 def test_stream_verify_matrix_green() -> None: