From 921852741786ecce15c06f955d8251f9e9826f6c Mon Sep 17 00:00:00 2001 From: dracarys18 Date: Fri, 2 Oct 2026 21:54:02 +0530 Subject: [PATCH] feat: add uji --- Cargo.toml | 5 +- README.md | 1 + cli/src/lib.rs | 9 +- docs/formats/README.md | 1 + docs/formats/uji.md | 95 +++ docs/translations/README.de.md | 1 + docs/translations/README.es.md | 1 + docs/translations/README.fr.md | 1 + docs/translations/README.it.md | 1 + docs/translations/README.ja.md | 1 + docs/translations/README.ko.md | 1 + docs/translations/README.mr.md | 1 + docs/translations/README.pt-BR.md | 1 + docs/translations/README.ru.md | 1 + docs/translations/README.ta.md | 1 + docs/translations/README.zh-CN.md | 1 + docs/translations/README.zh-TW.md | 1 + docs/usage.md | 7 +- src/harness/mod.rs | 1 + src/harness/uji.rs | 943 +++++++++++++++++++++++++++++ src/lib.rs | 2 +- src/local.rs | 72 ++- src/transcript.rs | 6 +- src/wasm.rs | 9 +- tests/integration/cross_harness.rs | 8 +- tests/integration/main.rs | 1 + tests/integration/properties.rs | 3 +- tests/integration/uji.rs | 329 ++++++++++ 28 files changed, 1479 insertions(+), 25 deletions(-) create mode 100644 docs/formats/uji.md create mode 100644 src/harness/uji.rs create mode 100644 tests/integration/uji.rs diff --git a/Cargo.toml b/Cargo.toml index 1ec6c6a..d30cd41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,13 +100,16 @@ required-features = ["search"] [features] # Explicit cloud Cowork reads reuse the Claude Desktop transport. cowork_remote = ["claude_chat"] -default = ["opencode", "hermes", "claude_chat", "cowork_remote", "chatgpt", "search"] +default = ["opencode", "hermes", "uji", "claude_chat", "cowork_remote", "chatgpt", "search"] # The OpenCode harness store reads OpenCode's SQLite database. The OpenCode # codec itself is always available; only the on-disk store needs rusqlite. opencode = ["dep:rusqlite"] # Hermes Agent's canonical session store is SQLite. The JSON export codec is # always available; only local discovery/loading needs rusqlite. hermes = ["dep:rusqlite"] +# uji keeps its sessions in SQLite. The JSON text codec is always available; +# only the database store needs rusqlite. +uji = ["dep:rusqlite"] # Claude Chat is a live, read-only remote store. Its codec remains available # featureless; the feature adds browser-compatible HTTPS access and macOS # Claude Desktop cookie decryption. diff --git a/README.md b/README.md index 9727819..2f09b2c 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ Each name links to its format documentation. Use the ID with `--from` and `--wit | [Grok Bot](docs/formats/grok-bot.md) | `grok_bot` | Yes | Via local gateway | | [fx](docs/formats/fx.md) | `fx` | Yes | Yes | | [Antigravity](docs/formats/antigravity.md) | `antigravity` | Yes | Yes | +| [uji](docs/formats/uji.md) | `uji` | Yes | Yes | | [Hermes Agent](docs/formats/hermes.md) | `hermes` | Yes | No | | [Amp](docs/formats/amp.md) | `amp` | Yes | No | | [Cloud Cowork](docs/formats/cowork-remote.md) | `cowork_remote` | Live account | No | diff --git a/cli/src/lib.rs b/cli/src/lib.rs index fadca96..ab43418 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -69,7 +69,7 @@ mod pager; mod view; pub const HARNESSES: &str = "harnesses: claude_code, claude_chat, chatgpt, codex, opencode, pi, campfire, cursor, cursor_desktop, grok, fx, hermes, \ - amp, antigravity, simple, cowork, cowork_remote"; + amp, antigravity, simple, cowork, cowork_remote, uji"; /// The `txcript` binary's command line. #[derive(Parser)] @@ -1324,6 +1324,7 @@ mod style { HarnessId::Antigravity => "\x1b[94m", // bright blue HarnessId::Simple => "\x1b[92m", // bright green HarnessId::Cowork => "\x1b[38;5;208m", // orange + HarnessId::Uji => "\x1b[38;5;180m", // tan } } } @@ -1943,12 +1944,12 @@ fn fresh_identity( if out.is_some() { return; } - // Codex stamps its rollouts with v7 UUIDs; matching the shape keeps the - // copy out of any version-aware code path. v4 everywhere else. Harnesses + // Codex and uji stamp their sessions with v7 UUIDs; matching the shape + // keeps the copy out of any version-aware code path. v4 everywhere else. Harnesses // that need a different spelling (opencode's `ses_` prefix) re-shape this // themselves in `from_common`. common.meta.id = match target { - HarnessId::Codex => uuid::Uuid::now_v7().to_string(), + HarnessId::Codex | HarnessId::Uji => uuid::Uuid::now_v7().to_string(), _ => uuid::Uuid::new_v4().to_string(), }; common.meta.timestamp = chrono::Utc::now(); diff --git a/docs/formats/README.md b/docs/formats/README.md index 633fdde..6195053 100644 --- a/docs/formats/README.md +++ b/docs/formats/README.md @@ -57,3 +57,4 @@ than none. | [pi.md](pi.md) | pi | `src/harness/pi.rs` | | [campfire.md](campfire.md) | Campfire (embeds pi) | `src/harness/campfire.rs` | | [simple.md](simple.md) | Simple (txcript's own interchange format) | `src/harness/simple.rs` | +| [uji.md](uji.md) | uji | `src/harness/uji.rs` | diff --git a/docs/formats/uji.md b/docs/formats/uji.md new file mode 100644 index 0000000..a925585 --- /dev/null +++ b/docs/formats/uji.md @@ -0,0 +1,95 @@ +# uji + +uji keeps every session in one `SQLite` database, `uji.db`. Each session is +a row in `sessions`, and each entry in its transcript is a row in +`messages`, whose `data` column holds the entry as JSON. txcript's portable +text form for uji is the session row with its message rows nested in a +`messages` array, each row's `data` decoded. Provenance is **open source**: +the format is read from uji's storage and agent code, pinned below. + +``` +~/.local/share/uji/uji.db one database for every session +├─ sessions one row per session +│ id, title, directory, metadata; parent is set for +│ time_created, time_updated, parent sessions another session started +├─ messages ordered rows per session +│ id, session_id, seq, type, data is the entry as JSON +│ time_created, data +└─ settings uji's own settings, untouched +``` + +## On disk + +The database resolves the way uji resolves it: `$UJI_DB`, else `uji.db` in +`$UJI_DATA_DIR`, else in `$XDG_DATA_HOME/uji` when that path is absolute, +else in `~/.local/share/uji`. Discovery lists every `sessions` row, including +sessions another session started, which carry their `parent` as +`Relation::Spawn` lineage. Loading reads the session's `messages` rows in +`seq` order. Times are milliseconds since the Unix epoch. + +Saving writes a new session and its rows in one transaction, replacing any +session with the same id. `uji resume --id` accepts only UUID-shaped ids, so +a transcript whose id is not a UUID is saved under a fresh one, and the +returned id is the one to resume. Only uji creates and migrates the +database, so saving into one that does not exist yet fails and asks you to +start uji once. Delete removes the session and its rows. + +## Dissection of a transcript + +| Their name | What it is | Maps to | +|---|---|---| +| `sessions` row | `id`, `title` (`untitled` until uji names it), `directory`, `time_created`, `parent` | `Meta`; `untitled` becomes no title | +| `user`, `context` | `text`, and `images` with `media_type` and base64 `data` | `Role::User` with `Block::Text` and `Block::Image` | +| `assistant` | `text`, `reasoning`, `tool_calls` with `id`, `name` and JSON-string `arguments`, and `replay` | `Role::Assistant` with `Block::Thinking`, `Block::Text` and `Block::ToolUse` | +| `tool` | `tool_call_id`, `name`, `content`, `images` | `Role::User` with `Block::ToolResult`, followed by any images | +| `compaction` | `summary` of the messages before it | `Role::User` with the summary as `Block::Text` | +| `shell`, `error`, `system` | `!` commands, turn errors, and notes | kept in the native body, no conversational turn | + +Tool names map to the Claude convention and back: `read_file` ⇄ `Read`, +`write_file` ⇄ `Write` and `edit_file` ⇄ `Edit`, with `path` ⇄ `file_path`, +and `run_command` ⇄ `Bash`, with `timeout` in seconds ⇄ `timeout_ms`. Other +names, such as plugin and MCP tools, pass through as `Tool::Raw`. A tool +result is an error when its content starts with `error:` or `denied:`, which +is how uji words failed and refused calls. + +`replay` holds what a provider needs back on a later turn, tagged with the +API and the model it came from: + +| `replay.api` | Contents | Maps to | +|---|---|---| +| `anthropic` | the reply's content blocks, in order: `thinking` with its `signature`, `redacted_thinking` with its `data`, `text`, and `tool_use` | one `Block::Thinking` per thinking block, with `signature`, or with `encrypted` for redacted thinking | +| `responses` | the reply's output items: `reasoning` items with their encrypted content, and `message` and `function_call` items | one `Block::Thinking` per reasoning item, with the item as JSON in `encrypted` | + +Writing rebuilds `replay` from those blocks, using the message's model, so +uji sends signed thinking and encrypted reasoning back when it continues with +the same model. Without them, the thinking text is written as `reasoning`. + +## Caveats + +- An assistant row has one `text`, so several text blocks in one message + join with blank lines, and text written after a tool call moves before it. +- uji stores no stop reason or per-message token usage, so none survive a + hop through uji. +- A user-side slash command (`Tool::Command`) and its output have no slot in + uji and are left out. +- Gemini's per-call thought signatures have no slot in `Common`, so they do + not survive a hop out of uji. +- Images keep their type and data. uji's own `width`, `height` and `name` + are not carried into `Common`. + +## References + +- Schema and migrations: + [`lua/uji/core/store/init.lua`](https://github.com/uji-labs/uji/blob/3e8bcc1bad3c852e5409ed3246cda2c17b9083e3/lua/uji/core/store/init.lua) +- Message rows and their decoding: + [`lua/uji/core/store/session.lua`](https://github.com/uji-labs/uji/blob/3e8bcc1bad3c852e5409ed3246cda2c17b9083e3/lua/uji/core/store/session.lua) +- Assistant entries and `replay`: + [`lua/uji/core/loop.lua`](https://github.com/uji-labs/uji/blob/3e8bcc1bad3c852e5409ed3246cda2c17b9083e3/lua/uji/core/loop.lua) +- Database location: + [`lua/uji/core/paths.lua`](https://github.com/uji-labs/uji/blob/3e8bcc1bad3c852e5409ed3246cda2c17b9083e3/lua/uji/core/paths.lua) + +The parser (`src/harness/uji.rs`) and the integration tests +(`tests/integration/uji.rs`, including a real temp `SQLite` database with +uji's schema) are the normative mapping. + +Last verified: 2026-10-02. diff --git a/docs/translations/README.de.md b/docs/translations/README.de.md index 3e1ac3a..27770ab 100644 --- a/docs/translations/README.de.md +++ b/docs/translations/README.de.md @@ -125,6 +125,7 @@ Jeder Name führt zur Dokumentation des jeweiligen Formats. Verwende die ID mit | [Grok Bot](../formats/grok-bot.md) | `grok_bot` | Ja | Über lokales Gateway | | [fx](../formats/fx.md) | `fx` | Ja | Ja | | [Antigravity](../formats/antigravity.md) | `antigravity` | Ja | Ja | +| [uji](../formats/uji.md) | `uji` | Ja | Ja | | [Hermes Agent](../formats/hermes.md) | `hermes` | Ja | Nein | | [Amp](../formats/amp.md) | `amp` | Ja | Nein | | [Claude Chat](../formats/claude-chat.md) | `claude_chat` | Online-Konto | Nein | diff --git a/docs/translations/README.es.md b/docs/translations/README.es.md index f76486c..2b8813e 100644 --- a/docs/translations/README.es.md +++ b/docs/translations/README.es.md @@ -125,6 +125,7 @@ Cada nombre enlaza a la documentación de su formato. Usa el ID con `--from` y ` | [Grok Bot](../formats/grok-bot.md) | `grok_bot` | Sí | Mediante la pasarela local | | [fx](../formats/fx.md) | `fx` | Sí | Sí | | [Antigravity](../formats/antigravity.md) | `antigravity` | Sí | Sí | +| [uji](../formats/uji.md) | `uji` | Sí | Sí | | [Hermes Agent](../formats/hermes.md) | `hermes` | Sí | No | | [Amp](../formats/amp.md) | `amp` | Sí | No | | [Claude Chat](../formats/claude-chat.md) | `claude_chat` | Cuenta en línea | No | diff --git a/docs/translations/README.fr.md b/docs/translations/README.fr.md index c8f3437..36373ae 100644 --- a/docs/translations/README.fr.md +++ b/docs/translations/README.fr.md @@ -125,6 +125,7 @@ Chaque nom renvoie à la documentation du format correspondant. Utilisez l'ID av | [Grok Bot](../formats/grok-bot.md) | `grok_bot` | Oui | Via la passerelle locale | | [fx](../formats/fx.md) | `fx` | Oui | Oui | | [Antigravity](../formats/antigravity.md) | `antigravity` | Oui | Oui | +| [uji](../formats/uji.md) | `uji` | Oui | Oui | | [Hermes Agent](../formats/hermes.md) | `hermes` | Oui | Non | | [Amp](../formats/amp.md) | `amp` | Oui | Non | | [Claude Chat](../formats/claude-chat.md) | `claude_chat` | Compte en ligne | Non | diff --git a/docs/translations/README.it.md b/docs/translations/README.it.md index f84c3b4..17f846e 100644 --- a/docs/translations/README.it.md +++ b/docs/translations/README.it.md @@ -125,6 +125,7 @@ Ogni nome rimanda alla documentazione del relativo formato. Usa l'ID con `--from | [Grok Bot](../formats/grok-bot.md) | `grok_bot` | Sì | Tramite gateway locale | | [fx](../formats/fx.md) | `fx` | Sì | Sì | | [Antigravity](../formats/antigravity.md) | `antigravity` | Sì | Sì | +| [uji](../formats/uji.md) | `uji` | Sì | Sì | | [Hermes Agent](../formats/hermes.md) | `hermes` | Sì | No | | [Amp](../formats/amp.md) | `amp` | Sì | No | | [Claude Chat](../formats/claude-chat.md) | `claude_chat` | Account online | No | diff --git a/docs/translations/README.ja.md b/docs/translations/README.ja.md index bff58cf..732798e 100644 --- a/docs/translations/README.ja.md +++ b/docs/translations/README.ja.md @@ -125,6 +125,7 @@ writeFileSync("rollout.jsonl", output); | [Grok Bot](../formats/grok-bot.md) | `grok_bot` | 対応 | ローカルゲートウェイ経由 | | [fx](../formats/fx.md) | `fx` | 対応 | 対応 | | [Antigravity](../formats/antigravity.md) | `antigravity` | 対応 | 対応 | +| [uji](../formats/uji.md) | `uji` | 対応 | 対応 | | [Hermes Agent](../formats/hermes.md) | `hermes` | 対応 | 非対応 | | [Amp](../formats/amp.md) | `amp` | 対応 | 非対応 | | [Claude Chat](../formats/claude-chat.md) | `claude_chat` | オンラインアカウント | 非対応 | diff --git a/docs/translations/README.ko.md b/docs/translations/README.ko.md index 6ae3a34..4a16c8a 100644 --- a/docs/translations/README.ko.md +++ b/docs/translations/README.ko.md @@ -125,6 +125,7 @@ writeFileSync("rollout.jsonl", output); | [Grok Bot](../formats/grok-bot.md) | `grok_bot` | 지원 | 로컬 게이트웨이 사용 | | [fx](../formats/fx.md) | `fx` | 지원 | 지원 | | [Antigravity](../formats/antigravity.md) | `antigravity` | 지원 | 지원 | +| [uji](../formats/uji.md) | `uji` | 지원 | 지원 | | [Hermes Agent](../formats/hermes.md) | `hermes` | 지원 | 미지원 | | [Amp](../formats/amp.md) | `amp` | 지원 | 미지원 | | [Claude Chat](../formats/claude-chat.md) | `claude_chat` | 온라인 계정 | 미지원 | diff --git a/docs/translations/README.mr.md b/docs/translations/README.mr.md index 4c584f0..d2befe4 100644 --- a/docs/translations/README.mr.md +++ b/docs/translations/README.mr.md @@ -125,6 +125,7 @@ writeFileSync("rollout.jsonl", output); | [Grok Bot](../formats/grok-bot.md) | `grok_bot` | होय | स्थानिक गेटवेद्वारे | | [fx](../formats/fx.md) | `fx` | होय | होय | | [Antigravity](../formats/antigravity.md) | `antigravity` | होय | होय | +| [uji](../formats/uji.md) | `uji` | होय | होय | | [Hermes Agent](../formats/hermes.md) | `hermes` | होय | नाही | | [Amp](../formats/amp.md) | `amp` | होय | नाही | | [Claude Chat](../formats/claude-chat.md) | `claude_chat` | ऑनलाइन खाते | नाही | diff --git a/docs/translations/README.pt-BR.md b/docs/translations/README.pt-BR.md index 808823e..c009a21 100644 --- a/docs/translations/README.pt-BR.md +++ b/docs/translations/README.pt-BR.md @@ -125,6 +125,7 @@ Cada nome leva à documentação do respectivo formato. Use o ID com `--from` e | [Grok Bot](../formats/grok-bot.md) | `grok_bot` | Sim | Via gateway local | | [fx](../formats/fx.md) | `fx` | Sim | Sim | | [Antigravity](../formats/antigravity.md) | `antigravity` | Sim | Sim | +| [uji](../formats/uji.md) | `uji` | Sim | Sim | | [Hermes Agent](../formats/hermes.md) | `hermes` | Sim | Não | | [Amp](../formats/amp.md) | `amp` | Sim | Não | | [Claude Chat](../formats/claude-chat.md) | `claude_chat` | Conta online | Não | diff --git a/docs/translations/README.ru.md b/docs/translations/README.ru.md index 1a1382e..ec84aa1 100644 --- a/docs/translations/README.ru.md +++ b/docs/translations/README.ru.md @@ -125,6 +125,7 @@ writeFileSync("rollout.jsonl", output); | [Grok Bot](../formats/grok-bot.md) | `grok_bot` | Да | Через локальный шлюз | | [fx](../formats/fx.md) | `fx` | Да | Да | | [Antigravity](../formats/antigravity.md) | `antigravity` | Да | Да | +| [uji](../formats/uji.md) | `uji` | Да | Да | | [Hermes Agent](../formats/hermes.md) | `hermes` | Да | Нет | | [Amp](../formats/amp.md) | `amp` | Да | Нет | | [Claude Chat](../formats/claude-chat.md) | `claude_chat` | Онлайн-аккаунт | Нет | diff --git a/docs/translations/README.ta.md b/docs/translations/README.ta.md index 2fb4b6a..83646fb 100644 --- a/docs/translations/README.ta.md +++ b/docs/translations/README.ta.md @@ -125,6 +125,7 @@ writeFileSync("rollout.jsonl", output); | [Grok Bot](../formats/grok-bot.md) | `grok_bot` | ஆம் | உள்ளூர் நுழைவாயில் வழியாக | | [fx](../formats/fx.md) | `fx` | ஆம் | ஆம் | | [Antigravity](../formats/antigravity.md) | `antigravity` | ஆம் | ஆம் | +| [uji](../formats/uji.md) | `uji` | ஆம் | ஆம் | | [Hermes Agent](../formats/hermes.md) | `hermes` | ஆம் | இல்லை | | [Amp](../formats/amp.md) | `amp` | ஆம் | இல்லை | | [Claude Chat](../formats/claude-chat.md) | `claude_chat` | இணையக் கணக்கு | இல்லை | diff --git a/docs/translations/README.zh-CN.md b/docs/translations/README.zh-CN.md index 16ebeb2..a5bfda3 100644 --- a/docs/translations/README.zh-CN.md +++ b/docs/translations/README.zh-CN.md @@ -125,6 +125,7 @@ writeFileSync("rollout.jsonl", output); | [Grok Bot](../formats/grok-bot.md) | `grok_bot` | 支持 | 通过本地网关 | | [fx](../formats/fx.md) | `fx` | 支持 | 支持 | | [Antigravity](../formats/antigravity.md) | `antigravity` | 支持 | 支持 | +| [uji](../formats/uji.md) | `uji` | 支持 | 支持 | | [Hermes Agent](../formats/hermes.md) | `hermes` | 支持 | 不支持 | | [Amp](../formats/amp.md) | `amp` | 支持 | 不支持 | | [Claude Chat](../formats/claude-chat.md) | `claude_chat` | 在线账户 | 不支持 | diff --git a/docs/translations/README.zh-TW.md b/docs/translations/README.zh-TW.md index 4c496bc..387bd11 100644 --- a/docs/translations/README.zh-TW.md +++ b/docs/translations/README.zh-TW.md @@ -125,6 +125,7 @@ writeFileSync("rollout.jsonl", output); | [Grok Bot](../formats/grok-bot.md) | `grok_bot` | 支援 | 透過本機閘道 | | [fx](../formats/fx.md) | `fx` | 支援 | 支援 | | [Antigravity](../formats/antigravity.md) | `antigravity` | 支援 | 支援 | +| [uji](../formats/uji.md) | `uji` | 支援 | 支援 | | [Hermes Agent](../formats/hermes.md) | `hermes` | 支援 | 不支援 | | [Amp](../formats/amp.md) | `amp` | 支援 | 不支援 | | [Claude Chat](../formats/claude-chat.md) | `claude_chat` | 線上帳號 | 不支援 | diff --git a/docs/usage.md b/docs/usage.md index ca51e64..7d9d373 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -120,13 +120,13 @@ txcript = "0.14" # txcript = { version = "0.14", default-features = false } ``` -Default features: `opencode` (the SQLite stores: OpenCode, both Cursors, Antigravity), `hermes`, `claude_chat`, `cowork_remote`, `chatgpt`, and `search`. +Default features: `opencode` (the SQLite stores: OpenCode, both Cursors, Antigravity), `hermes`, `uji`, `claude_chat`, `cowork_remote`, `chatgpt`, and `search`. Three layers, smallest to largest: - `Codec`: `to_common` / `from_common` per harness; `convert::` chains them through the canonical model. - `TextCodec`: `from_text` / `to_text` to parse and render a harness's native session text, no I/O. -- `Store`: discover/load/save against a real backend (session directories, or SQLite DBs for OpenCode, Hermes, both Cursors, and Antigravity). +- `Store`: discover/load/save against a real backend (session directories, or SQLite DBs for OpenCode, Hermes, uji, both Cursors, and Antigravity). Convert in memory (no filesystem): @@ -222,7 +222,7 @@ writeFileSync("session.jsonl", convert(input, "codex", "claude_code")); const common = JSON.parse(toCommon(input, "codex")); // { meta, messages } const pi = fromCommon(JSON.stringify(common), "pi"); -harnesses(); // ["claude_code","claude_chat","cowork_remote","chatgpt","codex","opencode","pi","campfire","cursor","cursor_desktop","grok","grok_bot","fx","hermes","amp","antigravity","simple","cowork"] +harnesses(); // ["claude_code","claude_chat","cowork_remote","chatgpt","codex","opencode","pi","campfire","cursor","cursor_desktop","grok","grok_bot","fx","hermes","amp","antigravity","simple","cowork","uji"] ``` Text-in / text-out: `input` is the source harness's native session text and the result is the target's. Invalid harness names or unparseable input throw a JS `Error`. @@ -258,6 +258,7 @@ const matches = JSON.parse(index.query(JSON.stringify({ pattern: "relay bug" })) | `antigravity` | JSON dump of the conversation database, protobuf blobs hex-encoded | | `simple` | the [Simple](formats/simple.md) interchange JSON document | | `cowork` | JSON bundle of the session record, Claude Code transcript, and audit log | +| `uji` | the session row with its message rows, each row's `data` decoded | To build the wasm from source instead: diff --git a/src/harness/mod.rs b/src/harness/mod.rs index b550c47..903b715 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -24,6 +24,7 @@ pub mod hermes; pub mod opencode; pub mod pi; pub mod simple; +pub mod uji; pub(crate) mod jsonl; diff --git a/src/harness/uji.rs b/src/harness/uji.rs new file mode 100644 index 0000000..0fd8918 --- /dev/null +++ b/src/harness/uji.rs @@ -0,0 +1,943 @@ +//! uji sessions: one `SQLite` database, `~/.local/share/uji/uji.db`. +//! +//! uji keeps a `sessions` row per session and a `messages` row per transcript +//! entry, whose `data` column holds the entry as JSON. The native body is the +//! session row with its message rows nested in a `messages` array, each row's +//! `data` decoded; that JSON object is the harness's portable text form. +//! +//! The codec reads `user`, `context`, `assistant`, `tool`, and `compaction` +//! entries. Assistant reasoning survives through [`Common`] as +//! [`Block::Thinking`], and uji's provider replay data rides along: Anthropic +//! thinking signatures as `signature`, redacted thinking and Responses +//! reasoning items as `encrypted`. `shell`, `error`, and `system` entries stay +//! in the native body without a conversational turn. + +use chrono::{DateTime, Utc}; +use serde_json::{Map, Value, json}; + +#[cfg(feature = "uji")] +use rusqlite::{Connection, OpenFlags, OptionalExtension}; +#[cfg(feature = "uji")] +use std::collections::HashMap; +use std::collections::HashSet; +#[cfg(feature = "uji")] +use std::path::Path; +use std::path::PathBuf; + +use crate::common::{Block, ImageSource, Lineage, Message, Meta, Relation, Role, Tool, ToolOutput}; +#[cfg(feature = "uji")] +use crate::error::Error; +use crate::error::Result; +use crate::transcript::{Codec, Common, Discovered, Harness, Saved, Store, TextCodec, Transcript}; + +/// The title uji gives a session before it names one. +const UNTITLED: &str = "untitled"; + +/// The uji harness marker. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Uji; + +impl Harness for Uji { + const NAME: &'static str = "uji"; + type Body = Value; +} + +impl TextCodec for Uji { + fn from_text(text: &str) -> Result> { + let body: Value = serde_json::from_str(text)?; + Ok(Transcript::new(meta_from_body(&body), body)) + } + + fn to_text(transcript: &Transcript) -> Result { + Ok(serde_json::to_string_pretty(&transcript.body)?) + } +} + +impl Codec for Uji { + fn to_common(transcript: &Transcript) -> Result> { + Ok(Transcript::new( + transcript.meta.clone(), + messages_from_body(&transcript.body, &transcript.meta), + )) + } + + fn from_common(transcript: &Transcript) -> Result> { + Ok(Transcript::new( + transcript.meta.clone(), + body_from_messages(&transcript.meta, &transcript.body), + )) + } +} + +/// uji's session database. +#[derive(Debug, Clone)] +pub struct UjiStore { + pub db_path: PathBuf, +} + +impl UjiStore { + pub fn new(path: impl Into) -> Self { + Self { + db_path: path.into(), + } + } + + /// Resolve the database the way uji does: `$UJI_DB`, else `uji.db` in + /// `$UJI_DATA_DIR`, else in `$XDG_DATA_HOME/uji` when that is absolute, + /// else in `~/.local/share/uji`. + #[must_use] + pub fn default_db() -> Option { + let set = |name: &str| std::env::var_os(name).filter(|value| !value.is_empty()); + if let Some(db) = set("UJI_DB") { + return Some(Self::new(db)); + } + set("UJI_DATA_DIR") + .map(PathBuf::from) + .or_else(|| { + set("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|base| base.is_absolute()) + .map(|base| base.join("uji")) + }) + .or_else(|| super::home_dir().map(|home| home.join(".local/share/uji"))) + .map(|dir| Self::new(dir.join("uji.db"))) + } +} + +#[cfg(feature = "uji")] +impl Store for UjiStore { + type H = Uji; + type Ref = String; + + fn discover(&self) -> Result>> { + if !self.db_path.is_file() { + return Ok(Vec::new()); + } + let conn = open_read_only(&self.db_path)?; + let rows = query_rows(&conn, "SELECT * FROM sessions", &[])?; + Ok(rows + .into_iter() + .filter_map(|row| { + let meta = meta_from_body(&Value::Object(row)); + (!meta.id.is_empty()).then(|| Discovered { + reference: meta.id.clone(), + meta, + }) + }) + .collect()) + } + + fn load(&self, reference: &String) -> Result> { + let conn = open_read_only(&self.db_path)?; + let mut session = query_rows( + &conn, + "SELECT * FROM sessions WHERE id = ?1", + &[reference.as_str()], + )? + .pop() + .ok_or_else(|| not_found(reference, &self.db_path))?; + let rows = query_rows( + &conn, + "SELECT id, seq, time_created, data FROM messages WHERE session_id = ?1 ORDER BY seq", + &[reference.as_str()], + )?; + let messages = rows + .into_iter() + .map(|mut row| { + if let Some(Value::String(data)) = row.get("data") + && let Ok(decoded) = serde_json::from_str::(data) + { + row.insert("data".to_string(), decoded); + } + Value::Object(row) + }) + .collect(); + session.insert("messages".to_string(), Value::Array(messages)); + let body = Value::Object(session); + Ok(Transcript::new(meta_from_body(&body), body)) + } + + fn save(&self, transcript: &Transcript) -> Result> { + let mut conn = open_writable(&self.db_path)?; + let id = session_id(transcript); + let body = &transcript.body; + let created = body + .get("time_created") + .and_then(Value::as_i64) + .unwrap_or_else(|| transcript.meta.timestamp.timestamp_millis()); + let updated = body + .get("time_updated") + .and_then(Value::as_i64) + .unwrap_or(created); + let title = body + .get("title") + .and_then(Value::as_str) + .filter(|title| !title.is_empty()) + .unwrap_or(UNTITLED); + let directory = body.get("directory").and_then(Value::as_str).unwrap_or(""); + let parent = body.get("parent").and_then(Value::as_str); + let tx = conn.transaction().map_err(sqlite_error)?; + let parent = match parent { + Some(parent) if session_exists(&tx, parent)? => Some(parent), + _ => None, + }; + tx.execute("DELETE FROM messages WHERE session_id = ?1", [&id]) + .map_err(sqlite_error)?; + tx.execute("DELETE FROM sessions WHERE id = ?1", [&id]) + .map_err(sqlite_error)?; + if let Some(parent) = parent { + tx.execute( + "INSERT INTO sessions (id, title, directory, parent, time_created, time_updated) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![id, title, directory, parent, created, updated], + ) + } else { + tx.execute( + "INSERT INTO sessions (id, title, directory, time_created, time_updated) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![id, title, directory, created, updated], + ) + } + .map_err(sqlite_error)?; + let rows = body + .get("messages") + .and_then(Value::as_array) + .map_or(&[][..], Vec::as_slice); + let mut next_seq = 0i64; + for row in rows { + let seq = row + .get("seq") + .and_then(Value::as_i64) + .filter(|seq| *seq > next_seq) + .unwrap_or(next_seq + 1); + next_seq = seq; + let data = row.get("data").cloned().unwrap_or(Value::Null); + let kind = data + .get("type") + .and_then(Value::as_str) + .unwrap_or("system") + .to_string(); + let message_id = row + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .map_or_else(|| uuid::Uuid::new_v4().to_string(), String::from); + let time = row + .get("time_created") + .and_then(Value::as_i64) + .unwrap_or(created); + let data = match data { + Value::String(raw) => raw, + other => serde_json::to_string(&other)?, + }; + tx.execute( + "INSERT INTO messages (id, session_id, seq, type, time_created, data) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![message_id, id, seq, kind, time, data], + ) + .map_err(sqlite_error)?; + } + tx.commit().map_err(sqlite_error)?; + Ok(Saved { + reference: id.clone(), + id, + }) + } + + fn delete(&self, reference: &String) -> Result<()> { + let mut conn = open_writable(&self.db_path)?; + let tx = conn.transaction().map_err(sqlite_error)?; + tx.execute("DELETE FROM messages WHERE session_id = ?1", [reference]) + .map_err(sqlite_error)?; + let removed = tx + .execute("DELETE FROM sessions WHERE id = ?1", [reference]) + .map_err(sqlite_error)?; + if removed == 0 { + return Err(not_found(reference, &self.db_path)); + } + tx.commit().map_err(sqlite_error) + } + + fn fingerprints(&self, refs: &[String]) -> Result> { + let mut output = HashMap::with_capacity(refs.len()); + if !self.db_path.is_file() { + return Ok(output); + } + let conn = open_read_only(&self.db_path)?; + let mut statement = conn + .prepare( + "SELECT s.time_updated, \ + (SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id), \ + (SELECT COALESCE(MAX(seq), 0) FROM messages m WHERE m.session_id = s.id) \ + FROM sessions s WHERE s.id = ?1", + ) + .map_err(sqlite_error)?; + for reference in refs { + let cursor = statement + .query_row([reference], |row| { + Ok(format!( + "{}:{}:{}", + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)? + )) + }) + .optional() + .map_err(sqlite_error)? + .unwrap_or_default(); + output.insert(reference.clone(), cursor); + } + Ok(output) + } +} + +#[cfg(not(feature = "uji"))] +impl Store for UjiStore { + type H = Uji; + type Ref = String; + + fn discover(&self) -> Result>> { + Ok(Vec::new()) + } + + fn load(&self, _reference: &String) -> Result> { + Err(sqlite_unavailable()) + } + + fn save(&self, _transcript: &Transcript) -> Result> { + Err(sqlite_unavailable()) + } + + fn delete(&self, _reference: &String) -> Result<()> { + Err(sqlite_unavailable()) + } +} + +#[cfg(not(feature = "uji"))] +fn sqlite_unavailable() -> crate::error::Error { + crate::error::Error::Unconvertible { + harness: Uji::NAME, + detail: "uji store support requires the `uji` feature for SQLite".to_string(), + } +} + +#[cfg(feature = "uji")] +fn open_read_only(path: &Path) -> Result { + Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(sqlite_error) +} + +/// Open uji's database for writing. Only uji creates and migrates it, so a +/// database that does not exist yet is an error rather than one to create. +#[cfg(feature = "uji")] +fn open_writable(path: &Path) -> Result { + if !path.is_file() { + return Err(Error::Unconvertible { + harness: Uji::NAME, + detail: format!( + "no uji database at {}; start uji once to create it", + path.display() + ), + }); + } + let conn = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(sqlite_error)?; + conn.busy_timeout(std::time::Duration::from_secs(5)) + .map_err(sqlite_error)?; + conn.execute_batch("PRAGMA foreign_keys = ON") + .map_err(sqlite_error)?; + Ok(conn) +} + +#[cfg(feature = "uji")] +fn session_exists(conn: &Connection, id: &str) -> Result { + let mut statement = conn + .prepare("SELECT 1 FROM sessions WHERE id = ?1") + .map_err(sqlite_error)?; + statement.exists([id]).map_err(sqlite_error) +} + +#[cfg(feature = "uji")] +fn query_rows(conn: &Connection, sql: &str, args: &[&str]) -> Result>> { + let mut statement = conn.prepare(sql).map_err(sqlite_error)?; + let names: Vec = statement + .column_names() + .into_iter() + .map(String::from) + .collect(); + let mut rows = statement + .query(rusqlite::params_from_iter(args.iter())) + .map_err(sqlite_error)?; + let mut output = Vec::new(); + while let Some(row) = rows.next().map_err(sqlite_error)? { + let mut object = Map::with_capacity(names.len()); + for (index, name) in names.iter().enumerate() { + let value = match row.get_ref(index).map_err(sqlite_error)? { + rusqlite::types::ValueRef::Null => Value::Null, + rusqlite::types::ValueRef::Integer(number) => json!(number), + rusqlite::types::ValueRef::Real(number) => json!(number), + rusqlite::types::ValueRef::Text(bytes) | rusqlite::types::ValueRef::Blob(bytes) => { + Value::String(String::from_utf8_lossy(bytes).into_owned()) + } + }; + object.insert(name.clone(), value); + } + output.push(object); + } + Ok(output) +} + +/// The id to save under. `uji resume --id` takes only UUID-shaped ids, so any +/// other id is replaced with a fresh one. +#[cfg(feature = "uji")] +fn session_id(transcript: &Transcript) -> String { + let id = transcript + .body + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .unwrap_or(&transcript.meta.id); + if uuid::Uuid::try_parse(id).is_ok() { + id.to_string() + } else { + uuid::Uuid::new_v4().to_string() + } +} + +#[cfg(feature = "uji")] +fn not_found(reference: &str, db: &Path) -> Error { + Error::Malformed { + harness: Uji::NAME, + detail: format!("session `{reference}` not found in {}", db.display()), + } +} + +#[cfg(feature = "uji")] +#[allow(clippy::needless_pass_by_value)] +fn sqlite_error(error: rusqlite::Error) -> Error { + Error::Malformed { + harness: Uji::NAME, + detail: error.to_string(), + } +} + +fn millis(value: Option<&Value>) -> Option> { + value + .and_then(Value::as_i64) + .and_then(DateTime::from_timestamp_millis) +} + +fn meta_from_body(body: &Value) -> Meta { + let string = |key: &str| { + body.get(key) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(String::from) + }; + Meta { + id: string("id").unwrap_or_default(), + timestamp: millis(body.get("time_created")).unwrap_or(DateTime::::UNIX_EPOCH), + cwd: string("directory"), + git_branch: None, + title: string("title").filter(|title| title != UNTITLED), + cli_version: None, + model: None, + lineage: string("parent").map(|parent| Lineage { + parent, + relation: Relation::Spawn, + }), + } +} + +fn messages_from_body(body: &Value, meta: &Meta) -> Vec { + body.get("messages") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|row| { + let timestamp = millis(row.get("time_created")).unwrap_or(meta.timestamp); + message_from_entry(row.get("data")?, timestamp) + }) + .collect() +} + +fn text_of(entry: &Value, key: &str) -> String { + entry + .get(key) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() +} + +fn user(content: Vec, timestamp: DateTime) -> Option { + (!content.is_empty()).then_some(Message { + role: Role::User, + content, + timestamp, + model: None, + stop_reason: None, + usage: None, + }) +} + +fn message_from_entry(entry: &Value, timestamp: DateTime) -> Option { + match entry.get("type").and_then(Value::as_str)? { + "user" | "context" => { + let mut content = Vec::new(); + let text = text_of(entry, "text"); + if !text.is_empty() { + content.push(Block::Text { text }); + } + content.extend(images(entry)); + user(content, timestamp) + } + "compaction" => { + let summary = text_of(entry, "summary"); + user( + if summary.is_empty() { + Vec::new() + } else { + vec![Block::Text { text: summary }] + }, + timestamp, + ) + } + "tool" => { + let content = text_of(entry, "content"); + let mut blocks = vec![Block::ToolResult { + tool_use_id: text_of(entry, "tool_call_id"), + is_error: content.starts_with("error:") || content.starts_with("denied:"), + content: ToolOutput::Text(content), + }]; + blocks.extend(images(entry)); + user(blocks, timestamp) + } + "assistant" => assistant_message(entry, timestamp), + _ => None, + } +} + +fn images(entry: &Value) -> impl Iterator + '_ { + entry + .get("images") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|image| { + Some(Block::Image { + source: ImageSource { + source_type: "base64".to_string(), + media_type: image.get("media_type")?.as_str()?.to_string(), + data: image.get("data")?.as_str()?.to_string(), + }, + }) + }) +} + +fn assistant_message(entry: &Value, timestamp: DateTime) -> Option { + let replay = entry.get("replay"); + let api = replay.and_then(|r| r.get("api")).and_then(Value::as_str); + let mut content = match api { + Some("anthropic") => anthropic_thinking(replay), + Some("responses") => responses_thinking(replay), + _ => Vec::new(), + }; + if content.is_empty() { + let reasoning = text_of(entry, "reasoning"); + if !reasoning.is_empty() { + content.push(Block::Thinking { + text: reasoning, + signature: None, + encrypted: None, + }); + } + } + let text = text_of(entry, "text"); + if !text.is_empty() { + content.push(Block::Text { text }); + } + content.extend( + entry + .get("tool_calls") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(tool_use), + ); + if content.is_empty() { + return None; + } + Some(Message { + role: Role::Assistant, + content, + timestamp, + model: replay + .and_then(|r| r.get("model")) + .and_then(Value::as_str) + .filter(|model| !model.is_empty()) + .map(String::from), + stop_reason: None, + usage: None, + }) +} + +fn anthropic_thinking(replay: Option<&Value>) -> Vec { + replay + .and_then(|r| r.get("content")) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|block| match block.get("type").and_then(Value::as_str)? { + "thinking" => Some(Block::Thinking { + text: text_of(block, "thinking"), + signature: block + .get("signature") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(String::from), + encrypted: None, + }), + "redacted_thinking" => Some(Block::Thinking { + text: String::new(), + signature: None, + encrypted: block.get("data").and_then(Value::as_str).map(String::from), + }), + _ => None, + }) + .collect() +} + +fn responses_thinking(replay: Option<&Value>) -> Vec { + replay + .and_then(|r| r.get("items")) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|entry| entry.get("kind").and_then(Value::as_str) == Some("reasoning")) + .filter_map(|entry| { + let item = entry.get("item")?; + let text = item + .get("summary") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n\n"); + Some(Block::Thinking { + text, + signature: None, + encrypted: Some(item.to_string()), + }) + }) + .collect() +} + +fn tool_use(call: &Value) -> Option { + let name = call.get("name").and_then(Value::as_str)?; + let raw = call + .get("arguments") + .and_then(Value::as_str) + .unwrap_or("{}"); + let input = if raw.trim().is_empty() { + Value::Object(Map::new()) + } else { + serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string())) + }; + let (name, input) = canonical_tool(name, input); + Some(Block::ToolUse { + id: text_of(call, "id"), + tool: Tool::from_canonical(&name, input), + }) +} + +/// uji's built-in tools and the canonical tools they correspond to. +const TOOLS: [(&str, &str); 4] = [ + ("read_file", "Read"), + ("write_file", "Write"), + ("edit_file", "Edit"), + ("run_command", "Bash"), +]; + +fn canonical_tool(name: &str, input: Value) -> (String, Value) { + let Some(canonical) = TOOLS + .iter() + .find(|(native, _)| *native == name) + .map(|(_, canonical)| *canonical) + else { + return (name.to_string(), input); + }; + let Value::Object(mut object) = input else { + return (name.to_string(), input); + }; + if canonical == "Bash" { + if let Some(seconds) = object.get("timeout").and_then(Value::as_u64) { + object.remove("timeout"); + object.insert( + "timeout_ms".to_string(), + json!(seconds.saturating_mul(1000)), + ); + } + } else { + rename_key(&mut object, "path", "file_path"); + } + (canonical.to_string(), Value::Object(object)) +} + +fn native_tool(tool: &Tool) -> (String, Value) { + let (name, input) = tool.to_canonical(); + let Some(native) = TOOLS + .iter() + .find(|(_, canonical)| *canonical == name) + .map(|(native, _)| *native) + else { + return (name, input); + }; + let Value::Object(mut object) = input else { + return (name, input); + }; + if native == "run_command" { + if let Some(ms) = object.get("timeout_ms").and_then(Value::as_u64) + && ms % 1000 == 0 + { + object.remove("timeout_ms"); + object.insert("timeout".to_string(), json!(ms / 1000)); + } + } else { + rename_key(&mut object, "file_path", "path"); + } + (native.to_string(), Value::Object(object)) +} + +fn rename_key(object: &mut Map, from: &str, to: &str) { + if let Some(value) = object.remove(from) { + object.insert(to.to_string(), value); + } +} + +fn body_from_messages(meta: &Meta, messages: &[Message]) -> Value { + let mut names = std::collections::HashMap::new(); + let mut commands = HashSet::new(); + let mut rows = Vec::new(); + for message in messages { + let time = message.timestamp.timestamp_millis(); + let entries = match message.role { + Role::Assistant => assistant_entry(meta, message, &mut names) + .into_iter() + .collect(), + Role::User => user_entries(message, &names, &mut commands), + }; + for data in entries { + rows.push(json!({ "seq": rows.len() + 1, "time_created": time, "data": data })); + } + } + let created = meta.timestamp.timestamp_millis(); + let updated = messages + .iter() + .map(|message| message.timestamp.timestamp_millis()) + .max() + .unwrap_or(created) + .max(created); + let mut body = json!({ + "id": meta.id, + "title": meta.title.clone().unwrap_or_else(|| UNTITLED.to_string()), + "directory": meta.cwd.clone().unwrap_or_default(), + "time_created": created, + "time_updated": updated, + "messages": rows, + }); + if let Some(lineage) = meta + .lineage + .as_ref() + .filter(|lineage| lineage.relation == Relation::Spawn) + { + body["parent"] = Value::String(lineage.parent.clone()); + } + body +} + +fn assistant_entry( + meta: &Meta, + message: &Message, + names: &mut std::collections::HashMap, +) -> Option { + let mut text = Vec::new(); + let mut reasoning = Vec::new(); + let mut calls = Vec::new(); + for block in &message.content { + match block { + Block::Text { text: part } => text.push(part.clone()), + Block::Artifact { artifact } => text.push(artifact.display_text()), + Block::Thinking { text: part, .. } if !part.is_empty() => reasoning.push(part.clone()), + Block::ToolUse { id, tool } => { + let (name, input) = native_tool(tool); + names.insert(id.clone(), name.clone()); + let arguments = match input { + Value::String(raw) => raw, + other => other.to_string(), + }; + calls.push(json!({ "id": id, "name": name, "arguments": arguments })); + } + _ => {} + } + } + if text.is_empty() && reasoning.is_empty() && calls.is_empty() { + return None; + } + let mut entry = json!({ "type": "assistant", "text": text.join("\n\n"), "tool_calls": calls }); + if !reasoning.is_empty() { + entry["reasoning"] = Value::String(reasoning.join("\n\n")); + } + let model = message.model.as_ref().or(meta.model.as_ref()); + if let Some(replay) = model.and_then(|model| replay(message, model)) { + entry["replay"] = replay; + } + Some(entry) +} + +/// Rebuild uji's provider replay from thinking blocks that carry one: signed +/// Anthropic thinking, or Responses reasoning items kept in `encrypted`. +fn replay(message: &Message, model: &str) -> Option { + let signed = message.content.iter().any(|block| { + matches!( + block, + Block::Thinking { + signature: Some(_), + .. + } + ) + }); + if signed { + let content: Vec = message + .content + .iter() + .filter_map(|block| match block { + Block::Thinking { + text, + signature: Some(signature), + .. + } => Some(json!({ "type": "thinking", "thinking": text, "signature": signature })), + Block::Thinking { + encrypted: Some(data), + .. + } => Some(json!({ "type": "redacted_thinking", "data": data })), + Block::Text { text } => Some(json!({ "type": "text", "text": text })), + Block::ToolUse { .. } => Some(json!({ "type": "tool_use" })), + _ => None, + }) + .collect(); + return Some(json!({ "api": "anthropic", "model": model, "content": content })); + } + let mut items = Vec::new(); + for block in &message.content { + match block { + Block::Thinking { + encrypted: Some(raw), + .. + } => { + let item = serde_json::from_str::(raw) + .ok() + .filter(|item| item.get("type").and_then(Value::as_str) == Some("reasoning"))?; + items.push(json!({ "kind": "reasoning", "item": item })); + } + Block::Text { .. } => items.push(json!({ "kind": "message" })), + Block::ToolUse { .. } => items.push(json!({ "kind": "function_call" })), + _ => {} + } + } + items + .iter() + .any(|entry| entry["kind"] == "reasoning") + .then(|| json!({ "api": "responses", "model": model, "items": items })) +} + +fn user_entries( + message: &Message, + names: &std::collections::HashMap, + commands: &mut HashSet, +) -> Vec { + let mut entries: Vec = Vec::new(); + let mut pending: Option = None; + let flush = |pending: &mut Option, entries: &mut Vec| { + if let Some(entry) = pending.take() { + entries.push(entry); + } + }; + for block in &message.content { + match block { + Block::Text { text } => { + let entry = match pending.take() { + Some(entry) if entry["type"] == "user" => entry, + other => { + if let Some(entry) = other { + entries.push(entry); + } + json!({ "type": "user", "text": "" }) + } + }; + let mut entry = entry; + let joined = match entry["text"].as_str().unwrap_or_default() { + "" => text.clone(), + before => format!("{before}\n\n{text}"), + }; + entry["text"] = Value::String(joined); + pending = Some(entry); + } + Block::Artifact { artifact } => { + flush(&mut pending, &mut entries); + entries.push(json!({ "type": "user", "text": artifact.display_text() })); + } + Block::Image { source } => { + let image = json!({ + "media_type": source.media_type, + "data": source.data, + "name": format!("image.{}", extension(&source.media_type)), + }); + let target = pending.get_or_insert_with(|| json!({ "type": "user", "text": "" })); + match target["images"].as_array_mut() { + Some(list) => list.push(image), + None => target["images"] = json!([image]), + } + } + Block::ToolUse { + id, + tool: Tool::Command { .. }, + } => { + commands.insert(id.clone()); + } + Block::ToolResult { + tool_use_id, + content, + .. + } if !commands.contains(tool_use_id) => { + flush(&mut pending, &mut entries); + let text = match content { + ToolOutput::Text(text) => text.clone(), + ToolOutput::Json(value) => value.to_string(), + }; + pending = Some(json!({ + "type": "tool", + "tool_call_id": tool_use_id, + "name": names.get(tool_use_id).cloned().unwrap_or_default(), + "content": text, + })); + } + _ => {} + } + } + flush(&mut pending, &mut entries); + entries +} + +fn extension(media_type: &str) -> &str { + match media_type { + "image/jpeg" => "jpg", + other => other.strip_prefix("image/").unwrap_or("png"), + } +} diff --git a/src/lib.rs b/src/lib.rs index a340523..ecb7fc3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ //! Typed conversion for coding-agent session transcripts. //! //! Claude Code, Claude Chat, Cowork, Codex, `OpenCode`, pi, Campfire, Cursor, -//! Grok, Hermes, Amp, and Antigravity record similar conversation data in +//! Grok, Hermes, Amp, Antigravity, and uji record similar conversation data in //! different stores. This crate maps each format through [`Transcript`] and //! converts with [`convert::`](convert): `A` -> [`Common`] -> `B`. //! diff --git a/src/local.rs b/src/local.rs index 4c8dd57..324c806 100644 --- a/src/local.rs +++ b/src/local.rs @@ -35,6 +35,8 @@ use crate::harness::cowork_remote; #[cfg(feature = "hermes")] use crate::harness::hermes; +#[cfg(feature = "uji")] +use crate::harness::uji; #[cfg(feature = "opencode")] use crate::harness::cursor_desktop; @@ -63,7 +65,7 @@ enum Locator { ClaudeChatRemote(claude_chat::ClaudeChatRef), #[cfg(feature = "chatgpt")] ChatGptRemote(chatgpt::ChatGptRef), - #[cfg(any(feature = "opencode", feature = "hermes"))] + #[cfg(any(feature = "opencode", feature = "hermes", feature = "uji"))] Id(String), } @@ -164,6 +166,21 @@ pub fn discover_with(mut on_store: impl FnMut(HarnessId, usize)) -> Vec } } + #[cfg(feature = "uji")] + { + on_store(HarnessId::Uji, out.len()); + if let Some(store) = uji::UjiStore::default_db() { + for d in store.discover().unwrap_or_default() { + out.push(Session { + harness: HarnessId::Uji, + meta: d.meta, + updated_at: None, + locator: Locator::Id(d.reference), + }); + } + } + } + #[cfg(feature = "opencode")] { on_store(HarnessId::CursorDesktop, out.len()); @@ -334,7 +351,7 @@ impl Session { Locator::ChatGptRemote(reference) => { format!("https://chatgpt.com/c/{}", reference.conversation_id) } - #[cfg(any(feature = "opencode", feature = "hermes"))] + #[cfg(any(feature = "opencode", feature = "hermes", feature = "uji"))] Locator::Id(id) => format!("{} db session {id}", self.harness), } } @@ -389,6 +406,10 @@ impl Session { (HarnessId::Hermes, Locator::Id(id)) => { hermes::Hermes::to_common(&required(hermes::HermesStore::default_root())?.load(id)?) } + #[cfg(feature = "uji")] + (HarnessId::Uji, Locator::Id(id)) => { + uji::Uji::to_common(&required(uji::UjiStore::default_db())?.load(id)?) + } #[cfg(feature = "opencode")] (HarnessId::CursorDesktop, Locator::Id(id)) => { let store = required(cursor_desktop::CursorDesktopStore::default_root())?; @@ -449,6 +470,8 @@ impl Session { (HarnessId::Hermes, Locator::Id(id)) => { required(hermes::HermesStore::default_root())?.delete(id) } + #[cfg(feature = "uji")] + (HarnessId::Uji, Locator::Id(id)) => required(uji::UjiStore::default_db())?.delete(id), #[cfg(feature = "opencode")] (HarnessId::CursorDesktop, Locator::Id(id)) => { required(cursor_desktop::CursorDesktopStore::default_root())?.delete(id) @@ -513,6 +536,8 @@ pub fn fingerprints(sessions: &[Session]) -> Vec { HarnessId::Cowork => group.files(cowork::CoworkStore::default_root()), #[cfg(feature = "hermes")] HarnessId::Hermes => group.ids(hermes::HermesStore::default_root()), + #[cfg(feature = "uji")] + HarnessId::Uji => group.ids(uji::UjiStore::default_db()), #[cfg(feature = "opencode")] HarnessId::CursorDesktop => { group.ids(cursor_desktop::CursorDesktopStore::default_root()); @@ -598,7 +623,7 @@ impl Group<'_> { } } - #[cfg(any(feature = "opencode", feature = "hermes"))] + #[cfg(any(feature = "opencode", feature = "hermes", feature = "uji"))] fn ids(self, store: Option) where S: Store, @@ -633,7 +658,7 @@ impl Session { Locator::ClaudeChatRemote(_) => None, #[cfg(feature = "chatgpt")] Locator::ChatGptRemote(_) => None, - #[cfg(any(feature = "opencode", feature = "hermes"))] + #[cfg(any(feature = "opencode", feature = "hermes", feature = "uji"))] Locator::Id(_) => None, } } @@ -647,7 +672,7 @@ impl Session { Locator::CoworkRemote(_) => None, #[cfg(feature = "chatgpt")] Locator::ChatGptRemote(_) => None, - #[cfg(any(feature = "opencode", feature = "hermes"))] + #[cfg(any(feature = "opencode", feature = "hermes", feature = "uji"))] Locator::Id(_) => None, } } @@ -661,13 +686,13 @@ impl Session { Locator::CoworkRemote(_) => None, #[cfg(feature = "claude_chat")] Locator::ClaudeChatRemote(_) => None, - #[cfg(any(feature = "opencode", feature = "hermes"))] + #[cfg(any(feature = "opencode", feature = "hermes", feature = "uji"))] Locator::Id(_) => None, } } /// The database id behind an id-backed session. - #[cfg(any(feature = "opencode", feature = "hermes"))] + #[cfg(any(feature = "opencode", feature = "hermes", feature = "uji"))] fn id(&self) -> Option<&String> { match &self.locator { Locator::Id(id) => Some(id), @@ -902,6 +927,7 @@ pub fn write_with( .to_string(), }), HarnessId::OpenCode => write_opencode(common), + HarnessId::Uji => write_uji(common, root), // The desktop app reads one fixed database; a root override names an // alternate `User` directory rather than a session directory. HarnessId::CursorDesktop => write_cursor_desktop(common, root), @@ -1087,6 +1113,35 @@ fn write_opencode(_: &Transcript) -> Result { }) } +/// uji keeps every session in one database: a root override names the +/// directory that holds `uji.db`. +#[cfg(feature = "uji")] +fn write_uji(common: &Transcript, root: Option<&Path>) -> Result { + let store = match root { + Some(dir) => uji::UjiStore::new(dir.join("uji.db")), + None => required(uji::UjiStore::default_db())?, + }; + let parent = store + .db_path + .parent() + .ok_or_else(|| artifact_error("uji database has no parent directory"))?; + let prepared = materialize_artifacts(common, &parent.join("txcript-artifacts"))?; + let native = uji::Uji::from_common(&prepared)?; + let saved = store.save(&native)?; + Ok(Written { + id: saved.id, + location: store.db_path.display().to_string(), + }) +} + +#[cfg(not(feature = "uji"))] +fn write_uji(_: &Transcript, _: Option<&Path>) -> Result { + Err(Error::Unconvertible { + harness: "uji", + detail: "uji support not compiled in (enable the `uji` feature)".to_string(), + }) +} + /// The command that resumes session `id` in `harness` — `(binary, args)`, /// for the caller to exec or spawn. Overridable per harness with /// `TRANSCRIPT__RESUME_CMD`, a template where `{id}` is substituted @@ -1121,6 +1176,7 @@ pub fn resume_command(harness: HarnessId, id: &str) -> (String, Vec) { HarnessId::Grok => ("grok".into(), vec!["--resume".into(), id]), HarnessId::Fx => ("fx".into(), vec!["--resume".into(), id]), HarnessId::Hermes => ("hermes".into(), vec!["--resume".into(), id]), + HarnessId::Uji => ("uji".into(), vec!["resume".into(), "--id".into(), id]), HarnessId::Amp => ("amp".into(), vec!["threads".into(), "continue".into(), id]), HarnessId::Antigravity => ("agy".into(), vec![format!("--conversation={id}")]), // No per-session entry point: the Claude desktop app lists the @@ -1235,7 +1291,7 @@ mod resume_template_tests { assert!(apply_resume_template(" ", "id").is_none()); } - #[cfg(any(feature = "opencode", feature = "hermes"))] + #[cfg(any(feature = "opencode", feature = "hermes", feature = "uji"))] #[test] fn id_locator_location_formats_cleanly() { let session = super::Session { diff --git a/src/transcript.rs b/src/transcript.rs index c89ba1e..d4ca9b6 100644 --- a/src/transcript.rs +++ b/src/transcript.rs @@ -462,10 +462,11 @@ pub enum HarnessId { Antigravity, Simple, Cowork, + Uji, } impl HarnessId { - pub const ALL: [HarnessId; 18] = [ + pub const ALL: [HarnessId; 19] = [ HarnessId::ClaudeCode, HarnessId::ClaudeChat, HarnessId::CoworkRemote, @@ -484,6 +485,7 @@ impl HarnessId { HarnessId::Antigravity, HarnessId::Simple, HarnessId::Cowork, + HarnessId::Uji, ]; /// The stable lowercase name, matching the corresponding [`Harness::NAME`]. @@ -508,6 +510,7 @@ impl HarnessId { HarnessId::Antigravity => "antigravity", HarnessId::Simple => "simple", HarnessId::Cowork => "cowork", + HarnessId::Uji => "uji", } } } @@ -555,6 +558,7 @@ impl FromStr for HarnessId { Ok(HarnessId::Antigravity) } "simple" | "simple_json" | "simple-json" => Ok(HarnessId::Simple), + "uji" => Ok(HarnessId::Uji), "cowork" | "claude_cowork" | "claude-cowork" | "claude_desktop" | "claude-desktop" => { Ok(HarnessId::Cowork) } diff --git a/src/wasm.rs b/src/wasm.rs index e1bbb6f..c9d5293 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -15,7 +15,7 @@ use wasm_bindgen::prelude::*; use crate::common; use crate::harness::{ amp, antigravity, campfire, chatgpt, claude_chat, claude_code, codex, cowork, cowork_remote, - cursor, cursor_desktop, fx, grok, grok_bot, hermes, opencode, pi, simple, + cursor, cursor_desktop, fx, grok, grok_bot, hermes, opencode, pi, simple, uji, }; use crate::transcript::{Codec, Common, HarnessId, TextCodec, Transcript}; @@ -32,10 +32,11 @@ use crate::transcript::{Codec, Common, HarnessId, TextCodec, Transcript}; /// `hermes sessions export` JSON object for hermes, the thread JSON document /// for amp, the JSON dump of the conversation database for antigravity, the /// interchange JSON document for simple, the JSON bundle of the session -/// record, transcript and audit log for cowork); `from`/`to` are harness +/// record, transcript and audit log for cowork, the session row with its +/// message rows for uji); `from`/`to` are harness /// names (`"claude_code"`, `"claude_chat"`, `"cowork_remote"`, `"chatgpt"`, `"codex"`, `"opencode"`, `"pi"`, `"campfire"`, /// `"cursor"`, `"cursor_desktop"`, `"grok"`, `"grok_bot"`, `"fx"`, `"hermes"`, `"amp"`, -/// `"antigravity"`, `"simple"`, `"cowork"`). Returns the target harness's +/// `"antigravity"`, `"simple"`, `"cowork"`, `"uji"`). Returns the target harness's /// native text. #[wasm_bindgen] pub fn convert(input: &str, from: &str, to: &str) -> Result { @@ -217,6 +218,7 @@ fn parse_to_common(harness: HarnessId, text: &str) -> crate::Result go::(text), HarnessId::Simple => go::(text), HarnessId::Cowork => go::(text), + HarnessId::Uji => go::(text), } } @@ -243,6 +245,7 @@ fn render_from_common(harness: HarnessId, common: &Transcript) -> crate: HarnessId::Antigravity => go::(common), HarnessId::Simple => go::(common), HarnessId::Cowork => go::(common), + HarnessId::Uji => go::(common), } } diff --git a/tests/integration/cross_harness.rs b/tests/integration/cross_harness.rs index 7a80ba5..f70d293 100644 --- a/tests/integration/cross_harness.rs +++ b/tests/integration/cross_harness.rs @@ -7,7 +7,7 @@ use chrono::{DateTime, Utc}; use txcript::common; use txcript::harness::{ amp, antigravity, campfire, claude_code, codex, cowork, cursor, cursor_desktop, fx, grok, - grok_bot, hermes, opencode, pi, simple, + grok_bot, hermes, opencode, pi, simple, uji, }; use txcript::{Codec, Common, Transcript, convert}; @@ -303,8 +303,10 @@ fn assert_cycle(common: &Transcript, context: &str) { "{context}: cowork" ); - // And all the way back to Claude. - let round = convert::(&cowork).unwrap(); + let uji = convert::(&cowork).unwrap(); + + // And all the way back to Claude, through uji. + let round = convert::(&uji).unwrap(); assert_eq!( signature(&claude_code::ClaudeCode::to_common(&round).unwrap()), expected, diff --git a/tests/integration/main.rs b/tests/integration/main.rs index 4eb9cce..58663ce 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -26,6 +26,7 @@ mod pi; mod properties; mod simple; mod store_delete; +mod uji; #[cfg(feature = "search")] mod search; diff --git a/tests/integration/properties.rs b/tests/integration/properties.rs index 176c2da..d367dff 100644 --- a/tests/integration/properties.rs +++ b/tests/integration/properties.rs @@ -19,7 +19,7 @@ use serde_json::json; use txcript::common::{Block, Message, Meta, Role, Tool, ToolOutput}; use txcript::harness::{ amp, antigravity, campfire, claude_code, codex, cowork, cursor, cursor_desktop, fx, grok, - grok_bot, hermes, opencode, pi, simple, + grok_bot, hermes, opencode, pi, simple, uji, }; use txcript::{Codec, Common, Transcript}; @@ -280,5 +280,6 @@ proptest! { assert_fixpoint::("antigravity", &common)?; assert_fixpoint::("simple", &common)?; assert_fixpoint::("cowork", &common)?; + assert_fixpoint::("uji", &common)?; } } diff --git a/tests/integration/uji.rs b/tests/integration/uji.rs new file mode 100644 index 0000000..62c6587 --- /dev/null +++ b/tests/integration/uji.rs @@ -0,0 +1,329 @@ +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +//! Integration tests for uji's session database and its JSON text form. + +use chrono::{DateTime, Utc}; +use serde_json::{Value, json}; +#[cfg(feature = "uji")] +use txcript::Store; +use txcript::common::{Block, Relation, Role, Tool, ToolOutput}; +use txcript::harness::uji; +use txcript::{Codec, TextCodec, Transcript}; + +const ID: &str = "019a0000-0000-7000-8000-000000000001"; + +fn ts(s: &str) -> DateTime { + s.parse().unwrap() +} + +fn row(seq: u64, time: i64, data: &Value) -> Value { + json!({ "id": format!("m{seq}"), "seq": seq, "time_created": time, "data": data }) +} + +fn native() -> Value { + json!({ + "id": ID, + "title": "fix the bug", + "directory": "/repo", + "time_created": 1_768_000_000_000_i64, + "time_updated": 1_768_000_009_000_i64, + "parent": null, + "messages": [ + row(1, 1_768_000_001_000, &json!({ + "type": "user", + "text": "fix it", + "images": [{ "media_type": "image/png", "data": "iVBO", "name": "shot.png", "width": 2, "height": 2 }] + })), + row(2, 1_768_000_002_000, &json!({ + "type": "assistant", + "text": "Looking.", + "reasoning": "plan", + "tool_calls": [ + { "id": "c1", "name": "read_file", "arguments": "{\"path\":\"/repo/a.rs\",\"offset\":3}" }, + { "id": "c2", "name": "run_command", "arguments": "{\"command\":\"cargo test\",\"timeout\":30}" }, + { "id": "c3", "name": "web_search", "arguments": "{\"query\":\"relay\"}" } + ] + })), + row(3, 1_768_000_003_000, &json!({ "type": "tool", "tool_call_id": "c1", "name": "read_file", "content": " 3| fn a()" })), + row(4, 1_768_000_004_000, &json!({ "type": "tool", "tool_call_id": "c2", "name": "run_command", "content": "error: command timed out after 30s" })), + row(5, 1_768_000_005_000, &json!({ "type": "tool", "tool_call_id": "c3", "name": "web_search", "content": "results" })), + row(6, 1_768_000_006_000, &json!({ "type": "shell", "command": "ls", "output": "a.rs", "code": 0 })), + row(7, 1_768_000_007_000, &json!({ + "type": "assistant", + "text": "done", + "reasoning": "check", + "tool_calls": [], + "replay": { + "api": "anthropic", + "model": "claude-opus-4-8", + "content": [ + { "type": "thinking", "thinking": "check", "signature": "sig" }, + { "type": "text", "text": "done" } + ] + } + })), + row(8, 1_768_000_008_000, &json!({ "type": "compaction", "summary": "sum", "through": 7, "files": [] })), + row(9, 1_768_000_009_000, &json!({ "type": "error", "text": "interrupted" })) + ] + }) +} + +fn parsed() -> Transcript { + uji::Uji::from_text(&native().to_string()).unwrap() +} + +#[test] +fn native_session_maps_messages_tools_and_reasoning() { + let common = uji::Uji::to_common(&parsed()).unwrap(); + assert_eq!(common.meta.id, ID); + assert_eq!(common.meta.cwd.as_deref(), Some("/repo")); + assert_eq!(common.meta.title.as_deref(), Some("fix the bug")); + assert_eq!(common.meta.timestamp, ts("2026-01-09T23:06:40Z")); + assert!(common.meta.lineage.is_none()); + + let body = &common.body; + assert_eq!(body.len(), 7, "shell and error entries carry no turn"); + + assert_eq!(body[0].role, Role::User); + assert!(matches!(&body[0].content[0], Block::Text { text } if text == "fix it")); + assert!( + matches!(&body[0].content[1], Block::Image { source } if source.media_type == "image/png") + ); + + assert_eq!(body[1].role, Role::Assistant); + assert!(matches!(&body[1].content[0], Block::Thinking { text, .. } if text == "plan")); + assert!(matches!(&body[1].content[1], Block::Text { text } if text == "Looking.")); + assert!(matches!( + &body[1].content[2], + Block::ToolUse { id, tool: Tool::Read { file_path, offset: Some(3), limit: None } } + if id == "c1" && file_path == "/repo/a.rs" + )); + assert!(matches!( + &body[1].content[3], + Block::ToolUse { tool: Tool::Bash { command, timeout_ms: Some(30_000), .. }, .. } + if command == "cargo test" + )); + assert!(matches!( + &body[1].content[4], + Block::ToolUse { tool: Tool::Raw { tool_name, .. }, .. } if tool_name == "web_search" + )); + + assert!(matches!( + &body[2].content[0], + Block::ToolResult { tool_use_id, is_error: false, content: ToolOutput::Text(text) } + if tool_use_id == "c1" && text == " 3| fn a()" + )); + assert!(matches!( + &body[3].content[0], + Block::ToolResult { is_error: true, .. } + )); + + assert_eq!(body[5].model.as_deref(), Some("claude-opus-4-8")); + assert!(matches!( + &body[5].content[0], + Block::Thinking { text, signature: Some(signature), .. } if text == "check" && signature == "sig" + )); + assert!(matches!(&body[6].content[0], Block::Text { text } if text == "sum")); +} + +#[test] +fn codec_fixpoint_through_common_loses_nothing() { + let common = uji::Uji::to_common(&parsed()).unwrap(); + let back = uji::Uji::to_common(&uji::Uji::from_common(&common).unwrap()).unwrap(); + assert_eq!(back, common); +} + +#[test] +fn from_common_is_deterministic() { + let common = uji::Uji::to_common(&parsed()).unwrap(); + let first = uji::Uji::from_common(&common).unwrap(); + let second = uji::Uji::from_common(&common).unwrap(); + assert_eq!(first.body, second.body); +} + +#[test] +fn from_common_writes_uji_tool_names_and_rebuilds_the_replay() { + let common = uji::Uji::to_common(&parsed()).unwrap(); + let body = uji::Uji::from_common(&common).unwrap().body; + let rows = body["messages"].as_array().unwrap(); + let calls = rows[1]["data"]["tool_calls"].as_array().unwrap(); + assert_eq!(calls[0]["name"], "read_file"); + assert_eq!( + serde_json::from_str::(calls[0]["arguments"].as_str().unwrap()).unwrap(), + json!({ "path": "/repo/a.rs", "offset": 3 }) + ); + assert_eq!(calls[1]["name"], "run_command"); + assert_eq!( + serde_json::from_str::(calls[1]["arguments"].as_str().unwrap()).unwrap(), + json!({ "command": "cargo test", "timeout": 30 }) + ); + assert_eq!(rows[2]["data"]["name"], "read_file"); + let replay = &rows[5]["data"]["replay"]; + assert_eq!(replay["api"], "anthropic"); + assert_eq!(replay["model"], "claude-opus-4-8"); + assert_eq!(replay["content"][0]["signature"], "sig"); +} + +#[test] +fn responses_reasoning_items_survive_as_encrypted_thinking() { + let item = json!({ "id": "rs_1", "type": "reasoning", "summary": [{ "type": "summary_text", "text": "think" }], "encrypted_content": "enc" }); + let body = json!({ + "id": ID, + "title": "untitled", + "directory": "/repo", + "time_created": 1_768_000_000_000_i64, + "time_updated": 1_768_000_000_000_i64, + "messages": [ + row(1, 1_768_000_001_000, &json!({ "type": "user", "text": "hi" })), + row(2, 1_768_000_002_000, &json!({ + "type": "assistant", + "text": "hello", + "reasoning": "think", + "tool_calls": [], + "replay": { + "api": "responses", + "model": "gpt-5.5", + "items": [{ "kind": "reasoning", "item": item }, { "kind": "message", "id": "msg_1" }] + } + })) + ] + }); + let common = uji::Uji::to_common(&uji::Uji::from_text(&body.to_string()).unwrap()).unwrap(); + assert!( + common.meta.title.is_none(), + "uji's placeholder title is not a title" + ); + let Block::Thinking { + text, encrypted, .. + } = &common.body[1].content[0] + else { + panic!("expected thinking"); + }; + assert_eq!(text, "think"); + assert_eq!( + serde_json::from_str::(encrypted.as_deref().unwrap()).unwrap(), + item + ); + let rebuilt = uji::Uji::from_common(&common).unwrap().body; + let replay = &rebuilt["messages"][1]["data"]["replay"]; + assert_eq!(replay["api"], "responses"); + assert_eq!(replay["items"][0]["item"], item); +} + +#[test] +fn a_session_started_by_another_records_its_parent() { + let mut body = native(); + body["parent"] = json!("019a0000-0000-7000-8000-0000000000aa"); + let meta = uji::Uji::from_text(&body.to_string()).unwrap().meta; + let lineage = meta.lineage.unwrap(); + assert_eq!(lineage.parent, "019a0000-0000-7000-8000-0000000000aa"); + assert_eq!(lineage.relation, Relation::Spawn); +} + +#[cfg(feature = "uji")] +fn uji_schema(path: &std::path::Path) -> rusqlite::Connection { + let conn = rusqlite::Connection::open(path).unwrap(); + conn.execute_batch( + "CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT NOT NULL, directory TEXT NOT NULL, + time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, + parent TEXT REFERENCES sessions(id) ON DELETE CASCADE); + CREATE TABLE messages (id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + seq INTEGER NOT NULL, type TEXT NOT NULL, time_created INTEGER NOT NULL, data TEXT NOT NULL); + CREATE UNIQUE INDEX messages_session_seq ON messages(session_id, seq); + CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL); + PRAGMA user_version = 2;", + ) + .unwrap(); + conn +} + +#[cfg(feature = "uji")] +fn uji_database(path: &std::path::Path) { + let conn = uji_schema(path); + let body = native(); + conn.execute( + "INSERT INTO sessions (id, title, directory, time_created, time_updated) VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ID, "fix the bug", "/repo", 1_768_000_000_000_i64, 1_768_000_009_000_i64], + ) + .unwrap(); + for message in body["messages"].as_array().unwrap() { + conn.execute( + "INSERT INTO messages (id, session_id, seq, type, time_created, data) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + message["id"].as_str().unwrap(), + ID, + message["seq"].as_i64().unwrap(), + message["data"]["type"].as_str().unwrap(), + message["time_created"].as_i64().unwrap(), + message["data"].to_string() + ], + ) + .unwrap(); + } +} + +#[cfg(feature = "uji")] +#[test] +fn store_discovers_loads_and_round_trips_through_a_fresh_database() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("uji.db"); + uji_database(&db); + let store = uji::UjiStore::new(&db); + + let found = store.discover().unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].reference, ID); + assert_eq!(found[0].meta.title.as_deref(), Some("fix the bug")); + assert_eq!(found[0].meta.cwd.as_deref(), Some("/repo")); + + let loaded = store.load(&ID.to_string()).unwrap(); + let empty = dir.path().join("copy.db"); + uji_schema(&empty); + let copy = uji::UjiStore::new(&empty); + let saved = copy.save(&loaded).unwrap(); + assert_eq!(saved.id, ID); + let reloaded = copy.load(&saved.reference).unwrap(); + assert_eq!(reloaded.body["messages"], loaded.body["messages"]); + assert_eq!( + uji::Uji::to_common(&reloaded).unwrap(), + uji::Uji::to_common(&loaded).unwrap() + ); +} + +#[cfg(feature = "uji")] +#[test] +fn saving_needs_the_database_uji_creates() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("uji.db"); + let store = uji::UjiStore::new(&db); + let common = uji::Uji::to_common(&parsed()).unwrap(); + let error = store + .save(&uji::Uji::from_common(&common).unwrap()) + .unwrap_err(); + assert!(error.to_string().contains("start uji once")); + assert!(!db.exists(), "txcript never creates uji's database"); +} + +#[cfg(feature = "uji")] +#[test] +fn saving_replaces_ids_uji_cannot_resume_and_delete_removes_the_session() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("uji.db"); + uji_database(&db); + let store = uji::UjiStore::new(&db); + let mut common = uji::Uji::to_common(&parsed()).unwrap(); + common.meta.id = "ses_not_a_uuid".to_string(); + let saved = store + .save(&uji::Uji::from_common(&common).unwrap()) + .unwrap(); + assert!(uuid::Uuid::try_parse(&saved.id).is_ok()); + assert_eq!(store.discover().unwrap().len(), 2); + + let before = store.fingerprints(std::slice::from_ref(&saved.id)).unwrap(); + assert_ne!(before[&saved.id], ""); + + store.delete(&saved.id).unwrap(); + assert_eq!(store.discover().unwrap().len(), 1); + assert!(store.delete(&saved.id).is_err()); +}