Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 40 changed files with 2361 additions and 66 deletions.
| @@ -1733,6 +1733,18 @@ dependencies = [ | ||
| 1733 | 1733 | "zune-inflate", |
| 1734 | 1734 | ] |
| 1735 | 1735 | |
| 1736 | + [[package]] | |
| 1737 | + name = "fallible-iterator" | |
| 1738 | + version = "0.3.0" | |
| 1739 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1740 | + checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" | |
| 1741 | + | |
| 1742 | + [[package]] | |
| 1743 | + name = "fallible-streaming-iterator" | |
| 1744 | + version = "0.1.9" | |
| 1745 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1746 | + checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" | |
| 1747 | + | |
| 1736 | 1748 | [[package]] |
| 1737 | 1749 | name = "fastrand" |
| 1738 | 1750 | version = "2.5.0" |
| @@ -2985,6 +2997,18 @@ name = "hashbrown" | ||
| 2985 | 2997 | version = "0.17.1" |
| 2986 | 2998 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2987 | 2999 | checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" |
| 3000 | + dependencies = [ | |
| 3001 | + "foldhash 0.2.0", | |
| 3002 | + ] | |
| 3003 | + | |
| 3004 | + [[package]] | |
| 3005 | + name = "hashlink" | |
| 3006 | + version = "0.12.2" | |
| 3007 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3008 | + checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" | |
| 3009 | + dependencies = [ | |
| 3010 | + "hashbrown 0.17.1", | |
| 3011 | + ] | |
| 2988 | 3012 | |
| 2989 | 3013 | [[package]] |
| 2990 | 3014 | name = "heapless" |
| @@ -3705,6 +3729,17 @@ dependencies = [ | ||
| 3705 | 3729 | "libc", |
| 3706 | 3730 | ] |
| 3707 | 3731 | |
| 3732 | + [[package]] | |
| 3733 | + name = "libsqlite3-sys" | |
| 3734 | + version = "0.38.2" | |
| 3735 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3736 | + checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" | |
| 3737 | + dependencies = [ | |
| 3738 | + "cc", | |
| 3739 | + "pkg-config", | |
| 3740 | + "vcpkg", | |
| 3741 | + ] | |
| 3742 | + | |
| 3708 | 3743 | [[package]] |
| 3709 | 3744 | name = "linebender_resource_handle" |
| 3710 | 3745 | version = "0.1.1" |
| @@ -5735,6 +5770,31 @@ dependencies = [ | ||
| 5735 | 5770 | "memchr", |
| 5736 | 5771 | ] |
| 5737 | 5772 | |
| 5773 | + [[package]] | |
| 5774 | + name = "rsqlite-vfs" | |
| 5775 | + version = "0.1.1" | |
| 5776 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5777 | + checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" | |
| 5778 | + dependencies = [ | |
| 5779 | + "hashbrown 0.16.1", | |
| 5780 | + "thiserror 2.0.21", | |
| 5781 | + ] | |
| 5782 | + | |
| 5783 | + [[package]] | |
| 5784 | + name = "rusqlite" | |
| 5785 | + version = "0.40.2" | |
| 5786 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5787 | + checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" | |
| 5788 | + dependencies = [ | |
| 5789 | + "bitflags 2.13.2", | |
| 5790 | + "fallible-iterator", | |
| 5791 | + "fallible-streaming-iterator", | |
| 5792 | + "hashlink", | |
| 5793 | + "libsqlite3-sys", | |
| 5794 | + "smallvec", | |
| 5795 | + "sqlite-wasm-rs", | |
| 5796 | + ] | |
| 5797 | + | |
| 5738 | 5798 | [[package]] |
| 5739 | 5799 | name = "rust-embed" |
| 5740 | 5800 | version = "8.13.0" |
| @@ -6452,6 +6512,18 @@ dependencies = [ | ||
| 6452 | 6512 | "bitflags 2.13.2", |
| 6453 | 6513 | ] |
| 6454 | 6514 | |
| 6515 | + [[package]] | |
| 6516 | + name = "sqlite-wasm-rs" | |
| 6517 | + version = "0.5.5" | |
| 6518 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6519 | + checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" | |
| 6520 | + dependencies = [ | |
| 6521 | + "cc", | |
| 6522 | + "js-sys", | |
| 6523 | + "rsqlite-vfs", | |
| 6524 | + "wasm-bindgen", | |
| 6525 | + ] | |
| 6526 | + | |
| 6455 | 6527 | [[package]] |
| 6456 | 6528 | name = "stable_deref_trait" |
| 6457 | 6529 | version = "1.2.1" |
| @@ -7475,6 +7547,12 @@ dependencies = [ | ||
| 7475 | 7547 | "sval_serde", |
| 7476 | 7548 | ] |
| 7477 | 7549 | |
| 7550 | + [[package]] | |
| 7551 | + name = "vcpkg" | |
| 7552 | + version = "0.2.15" | |
| 7553 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7554 | + checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" | |
| 7555 | + | |
| 7478 | 7556 | [[package]] |
| 7479 | 7557 | name = "vectrum-code" |
| 7480 | 7558 | version = "0.2.5" |
| @@ -7490,6 +7568,7 @@ dependencies = [ | ||
| 7490 | 7568 | "openh264", |
| 7491 | 7569 | "qrcode", |
| 7492 | 7570 | "ring", |
| 7571 | + "rusqlite", | |
| 7493 | 7572 | "serde", |
| 7494 | 7573 | "serde_json", |
| 7495 | 7574 | "smol", |
| @@ -21,6 +21,7 @@ openh264 = "0.9" | ||
| 21 | 21 | mermaid-rs-renderer = { version = "0.3.1", default-features = false } |
| 22 | 22 | qrcode = { version = "0.14.1", default-features = false } |
| 23 | 23 | ring = "0.17" |
| 24 | + rusqlite = { version = "0.40", features = ["bundled"] } | |
| 24 | 25 | |
| 25 | 26 | [profile.dev] |
| 26 | 27 | debug = "line-tables-only" |
| @@ -3,7 +3,8 @@ | ||
| 3 | 3 | A native [T3 Code](https://github.com/pingdotgg/t3code)-style desktop client for coding |
| 4 | 4 | agents, built with [GPUI](https://gpui.rs) and [GPUI Kit](https://github.com/longbridge/gpui-kit). |
| 5 | 5 | It drives [Claude Code](https://docs.claude.com/en/docs/claude-code), |
| 6 | - [Pi](https://github.com/earendil-works/pi) and [jcode](https://github.com/1jehuang/jcode). | |
| 6 | + [Pi](https://github.com/earendil-works/pi), [jcode](https://github.com/1jehuang/jcode) and | |
| 7 | + [Empryo](https://empryo.com/). | |
| 7 | 8 | |
| 8 | 9 | Every thread runs its own agent process in the background. Claude Code threads talk to the |
| 9 | 10 | `claude` CLI over the stream-json protocol the Claude Agent SDKs use |
| @@ -11,6 +12,9 @@ Every thread runs its own agent process in the background. Claude Code threads t | ||
| 11 | 12 | talk to `pi --mode rpc` over its JSONL RPC protocol. jcode threads talk to |
| 12 | 13 | `jcode api --stdio`, a bridge to jcode's versioned harness API that starts jcode's shared |
| 13 | 14 | daemon if it isn't running, so jcode threads also show up in jcode's own session list. |
| 15 | + Empryo threads talk to `empryo --headless --chat --events`, Empryo's headless chat: a message | |
| 16 | + per line on stdin, its events as JSONL on stdout, and a session the thread names | |
| 17 | + (`--session-id`) and resumes (`--session`). | |
| 14 | 18 | |
| 15 | 19 | The layout and workflow follow T3 Code. The look follows [MetalUI](https://metalui.dev/), the |
| 16 | 20 | "soft hardware" design system [vectrum.cc](https://vectrum.cc/) is built on: the |
| @@ -64,19 +68,22 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 64 | 68 | it there once the thread has started. |
| 65 | 69 | - A model picker across providers (Ctrl+Shift+M): a rail with **Favorites** and each provider, |
| 66 | 70 | Claude's models (current ones, plus legacy models folded away), every model Pi's configured |
| 67 | - providers offer, and every model route jcode's logins and provider profiles make available. | |
| 71 | + providers offer, every model route jcode's logins and provider profiles make available, and | |
| 72 | + every model of the providers Empryo has a key or sign-in for (`empryo --list-models`). | |
| 68 | 73 | Star models to keep them under Favorites, search across all of them, pick the first nine |
| 69 | 74 | with Ctrl+1–9, and switch providers with Ctrl+Shift+↑/↓. On a new thread, picking another |
| 70 | 75 | provider's model starts a thread of that kind in its place; once a thread has started, |
| 71 | 76 | other providers' models are greyed out, as in T3 Code. `/model` opens the picker too. |
| 72 | 77 | - A reasoning menu (Ctrl+Shift+E): Low to Max (as the model supports, its default marked), |
| 73 | 78 | Claude's Ultracode and Ultrathink, and Fast Mode on models that have it. Pi threads list Pi's thinking levels; |
| 74 | - jcode threads offer effort levels on its Anthropic, OpenAI and OpenRouter routes. | |
| 75 | - All of it switches live. | |
| 76 | - - An access menu (Ctrl+Shift+A): Supervised, Auto-accept edits, Auto or Full access. Pi and | |
| 77 | - jcode have no approval prompts, so their threads always run with full access. With plan | |
| 78 | - mode turned on (Settings → General → Legacy features), a Build/Plan toggle and Shift+Tab | |
| 79 | - switch Claude into plan mode and back. | |
| 79 | + jcode threads offer effort levels on its Anthropic, OpenAI and OpenRouter routes; Empryo | |
| 80 | + threads offer Low, Medium and High (`--effort`). All of it switches live; an Empryo thread | |
| 81 | + restarts Empryo with the new flags once its turn is done, resuming its session. | |
| 82 | + - An access menu (Ctrl+Shift+A): Supervised, Auto-accept edits, Auto or Full access. Pi, | |
| 83 | + jcode and Empryo have no approval prompts, so their threads always run with full access | |
| 84 | + (Empryo's `--mode auto`). With plan mode turned on (Settings → General → Legacy features), a | |
| 85 | + Build/Plan toggle and Shift+Tab switch Claude, or Empryo (`--mode plan`), into plan mode and | |
| 86 | + back. | |
| 80 | 87 | - Finished turns fold their tool calls and interim messages into one **Worked for 12s** row |
| 81 | 88 | above the answer. Click it to unfold. |
| 82 | 89 | - Answers can carry live diagrams and UI, written as plain fenced code blocks. ` ```mermaid ` |
| @@ -138,6 +145,16 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 138 | 145 | before every run, that extension also switches off Pi's other subagent tools. |
| 139 | 146 | jcode gets them as session tools over its API, with its `swarm` and `schedule` |
| 140 | 147 | tools turned off. Subagents can't delegate tasks of their own. |
| 148 | + - The Genome map, for Empryo threads (the DNA button in the sidebar's corner, or G in the | |
| 149 | + right panel): [Empryo's Genome](https://empryo.com/docs/concepts/genome), the ranked map of | |
| 150 | + files, symbols and imports Empryo keeps of a checkout in `.empryo/genome.db`. A graph of the | |
| 151 | + 48 highest ranked files, sized by rank and colored by folder, with a line for each import, | |
| 152 | + and the files ranked below it with how many files import each (`→12`) and what changed in | |
| 153 | + the last two days. A file opens to its symbols, exported ones first with their signatures, | |
| 154 | + and lights up with its imports in the graph. It follows the thread to its worktree and | |
| 155 | + reloads as Empryo updates the map. For a thread on another device, that device reads its | |
| 156 | + Genome and sends it over, and again only when Empryo changed it. The Android app has it | |
| 157 | + too: the DNA button in an Empryo thread's top bar, or Genome map in the right panel. | |
| 141 | 158 | - Background tasks: when a command, monitor or subagent Claude left running finishes, the |
| 142 | 159 | transcript says so and Claude picks the work back up and reports back on its own. A |
| 143 | 160 | long-running command can be sent to the background from its tool card, like Ctrl+B in |
| @@ -292,6 +309,9 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 292 | 309 | (`npm install -g @earendil-works/pi-coding-agent`, then log in to a provider with `pi`) |
| 293 | 310 | - Optional: [jcode](https://github.com/1jehuang/jcode) for jcode threads |
| 294 | 311 | (`curl -fsSL https://jcode.sh/install | bash`, then `jcode login`) |
| 312 | + - Optional: [Empryo](https://empryo.com/) for Empryo threads | |
| 313 | + (`curl -fsSL https://empryo.com/install.sh | bash`, then `empryo --login claude` or another | |
| 314 | + account, or `empryo --set-key <provider> <key>`). Add it in Settings → Providers → Add provider. | |
| 295 | 315 | - Optional: agent-browser for the browser panel |
| 296 | 316 | (`npm install -g agent-browser && agent-browser install`) |
| 297 | 317 | - Optional, for the Device panel: Node.js 22 or newer with npm, and the Android SDK |
| @@ -352,6 +372,8 @@ The shortcuts follow T3 Code's default keymap. | ||
| 352 | 372 | `~/.local/bin`, `~/.npm-global/bin`, `~/.bun/bin`, `~/.volta/bin` and nvm's Node versions. |
| 353 | 373 | - `VECTRUM_CODE_JCODE_BIN`: path to the `jcode` binary. Otherwise it's looked up on `PATH`, |
| 354 | 374 | then in `~/.local/bin` and `~/.cargo/bin`. |
| 375 | + - `VECTRUM_CODE_EMPRYO_BIN`: path to the `empryo` binary. Otherwise it's looked up on `PATH`, | |
| 376 | + then in `~/.empryo/bin` and `~/.local/bin`. | |
| 355 | 377 | - `VECTRUM_UPDATE_URL`: where to look for updates instead of `https://vectrum.cc`, such as a |
| 356 | 378 | test server. Releases from there still need the release key's signature. |
| 357 | 379 | |
| @@ -392,12 +414,14 @@ after changing them, `cargo test export_android_themes -- --ignored` rewrites | ||
| 392 | 414 | |
| 393 | 415 | ## Layout |
| 394 | 416 | |
| 395 | - - `src/claude.rs`: spawns and owns the agent process, `claude`, `pi` or `jcode api --stdio` | |
| 417 | + - `src/claude.rs`: spawns and owns the agent process, `claude`, `pi`, `jcode api --stdio` or | |
| 418 | + `empryo --headless --chat` | |
| 396 | 419 | (stdin writer and stdout/stderr reader threads). |
| 397 | - - `src/session.rs`: per-thread state. Folds Claude's stream-json, Pi's RPC and jcode's | |
| 398 | - harness API events into chat items, handles permission requests, control requests and | |
| 420 | + - `src/session.rs`: per-thread state. Folds Claude's stream-json, Pi's RPC, jcode's | |
| 421 | + harness API and Empryo's headless events into chat items, handles permission requests, control requests and | |
| 399 | 422 | extension dialogs, and snapshots each turn's changes. |
| 400 | 423 | - `src/models.rs`: the model catalog across providers, and favorites. |
| 424 | + - `src/genome.rs`: reads Empryo's Genome (`.empryo/genome.db`) and lays out its graph. | |
| 401 | 425 | - `src/git.rs`: branch and change stats, working-tree snapshots, and diffs. |
| 402 | 426 | - `src/settings.rs`: the saved settings file, cached in memory. |
| 403 | 427 | - `src/update.rs`: the app's updater: release checks, signature and checksum checks, the |
| @@ -437,4 +461,6 @@ highlighting, and T3 Code's project | ||
| 437 | 461 | scripts aren't here yet. The terminal drawer doesn't report mouse clicks to full-screen |
| 438 | 462 | programs (only the wheel), open links, or show T3's popup after a selection. jcode models can't write thread titles (every |
| 439 | 463 | jcode run is saved as a session), and jcode's side panel, applets and background-task |
| 440 | - progress aren't shown. | |
| 464 | + progress aren't shown. Empryo threads don't get the app's subagent and device tools (Empryo | |
| 465 | + loads MCP servers from its config files only), take images by path rather than inline, and | |
| 466 | + can't be steered mid-turn: a message sent while Empryo works becomes its next turn. | |
| @@ -16,6 +16,7 @@ import cc.vectrum.code.net.ClientMessage | ||
| 16 | 16 | import cc.vectrum.code.net.Connection |
| 17 | 17 | import cc.vectrum.code.net.ConnectionStatus |
| 18 | 18 | import cc.vectrum.code.net.Folder |
| 19 | + import cc.vectrum.code.net.GenomeAnswer | |
| 19 | 20 | import cc.vectrum.code.net.ProjectInfo |
| 20 | 21 | import cc.vectrum.code.net.RemoteFileDiff |
| 21 | 22 | import cc.vectrum.code.net.RemoteInstance |
| @@ -33,6 +34,7 @@ import cc.vectrum.code.net.ThreadSummary | ||
| 33 | 34 | import cc.vectrum.code.net.WireJson |
| 34 | 35 | import cc.vectrum.code.net.withPort |
| 35 | 36 | import kotlinx.coroutines.CompletableDeferred |
| 37 | + import kotlinx.coroutines.withTimeoutOrNull | |
| 36 | 38 | import kotlinx.serialization.Serializable |
| 37 | 39 | import kotlinx.serialization.builtins.ListSerializer |
| 38 | 40 | import kotlinx.serialization.json.JsonElement |
| @@ -213,6 +215,7 @@ class HostState(saved: SavedHost, private val app: AppState) { | ||
| 213 | 215 | private val createdCallbacks = mutableMapOf<Long, (String) -> Unit>() |
| 214 | 216 | private val folderRequests = mutableMapOf<String, CompletableDeferred<ServerMessage.Folders>>() |
| 215 | 217 | private val diffRequests = mutableMapOf<Long, CompletableDeferred<ServerMessage.DiffReply>>() |
| 218 | + private val genomeRequests = mutableMapOf<Long, CompletableDeferred<GenomeAnswer>>() | |
| 216 | 219 | private val deviceRequests = mutableMapOf<Long, CompletableDeferred<ServerMessage.DeviceReply>>() |
| 217 | 220 | private val terminals = mutableMapOf<Long, TerminalListener>() |
| 218 | 221 | |
| @@ -286,6 +289,8 @@ class HostState(saved: SavedHost, private val app: AppState) { | ||
| 286 | 289 | folderRequests.clear() |
| 287 | 290 | diffRequests.values.forEach { it.complete(ServerMessage.DiffReply(error = "The host is offline.")) } |
| 288 | 291 | diffRequests.clear() |
| 292 | + genomeRequests.values.forEach { it.complete(GenomeAnswer(error = "The host is offline.")) } | |
| 293 | + genomeRequests.clear() | |
| 289 | 294 | deviceRequests.values.forEach { it.complete(ServerMessage.DeviceReply(error = "The host is offline.")) } |
| 290 | 295 | deviceRequests.clear() |
| 291 | 296 | terminals.values.forEach { it.onExit(reason) } |
| @@ -324,6 +329,7 @@ class HostState(saved: SavedHost, private val app: AppState) { | ||
| 324 | 329 | favorites = message.favorites |
| 325 | 330 | } |
| 326 | 331 | is ServerMessage.DiffReply -> diffRequests.remove(message.request)?.complete(message) |
| 332 | + is ServerMessage.GenomeReply -> genomeRequests.remove(message.request)?.complete(message.answer) | |
| 327 | 333 | is ServerMessage.Welcome -> learnRoutes(message.routes) |
| 328 | 334 | is ServerMessage.Routes -> learnRoutes(message.routes) |
| 329 | 335 | is ServerMessage.Denied -> {} |
| @@ -398,6 +404,28 @@ class HostState(saved: SavedHost, private val app: AppState) { | ||
| 398 | 404 | return reply.await() |
| 399 | 405 | } |
| 400 | 406 | |
| 407 | + /** | |
| 408 | + * Empryo's Genome of a thread's checkout, read on the host; `since` is when | |
| 409 | + * the one this app has was written. Older hosts don't answer, so it gives | |
| 410 | + * up after a while and says why. | |
| 411 | + */ | |
| 412 | + suspend fun genome(thread: String?, project: String, since: Long?): GenomeAnswer { | |
| 413 | + val request = nextRequest++ | |
| 414 | + val reply = CompletableDeferred<GenomeAnswer>() | |
| 415 | + genomeRequests[request] = reply | |
| 416 | + if (!send(ClientMessage.Genome(request, thread, project, since))) { | |
| 417 | + genomeRequests.remove(request) | |
| 418 | + return GenomeAnswer(written = since, error = "$name is offline.") | |
| 419 | + } | |
| 420 | + return withTimeoutOrNull(20_000) { reply.await() } ?: run { | |
| 421 | + genomeRequests.remove(request) | |
| 422 | + GenomeAnswer( | |
| 423 | + written = since, | |
| 424 | + error = "$name didn't send its Genome. Its Vectrum Code may be too old to; update it from its sidebar's corner.", | |
| 425 | + ) | |
| 426 | + } | |
| 427 | + } | |
| 428 | + | |
| 401 | 429 | /** Sends a `DeviceCall` (an object tagged by `call`) and waits for its reply. */ |
| 402 | 430 | suspend fun device(call: JsonObject): ServerMessage.DeviceReply { |
| 403 | 431 | val request = nextRequest++ |
| @@ -416,6 +416,14 @@ sealed class ClientMessage { | ||
| 416 | 416 | val path: String?, |
| 417 | 417 | val ignoreWhitespace: Boolean, |
| 418 | 418 | ) : ClientMessage() |
| 419 | + | |
| 420 | + /** | |
| 421 | + * Empryo's Genome of a thread's checkout (the project's, for a thread not | |
| 422 | + * created yet). `since` is when the one this app has was written, so an | |
| 423 | + * unchanged one isn't sent again. Older hosts don't answer. | |
| 424 | + */ | |
| 425 | + @Serializable @SerialName("genome") | |
| 426 | + data class Genome(val request: Long, val thread: String?, val project: String, val since: Long?) : ClientMessage() | |
| 419 | 427 | } |
| 420 | 428 | |
| 421 | 429 | @Serializable |
| @@ -548,8 +556,63 @@ sealed class ServerMessage { | ||
| 548 | 556 | |
| 549 | 557 | @Serializable @SerialName("diff_reply") |
| 550 | 558 | data class DiffReply(val request: Long = 0, val files: List<RemoteFileDiff> = emptyList(), val error: String? = null) : ServerMessage() |
| 559 | + | |
| 560 | + @Serializable @SerialName("genome_reply") | |
| 561 | + data class GenomeReply(val request: Long = 0, val answer: GenomeAnswer = GenomeAnswer()) : ServerMessage() | |
| 551 | 562 | } |
| 552 | 563 | |
| 564 | + /** What the host found of a checkout's Genome (src/remote/mod.rs). */ | |
| 565 | + @Serializable | |
| 566 | + data class GenomeAnswer( | |
| 567 | + /** When Empryo last wrote it, in milliseconds since the epoch; `null` when there's none yet. */ | |
| 568 | + val written: Long? = null, | |
| 569 | + /** The map, unless it's unchanged since the one asked about, or missing. */ | |
| 570 | + val genome: Genome? = null, | |
| 571 | + val unchanged: Boolean = false, | |
| 572 | + val error: String? = null, | |
| 573 | + ) | |
| 574 | + | |
| 575 | + /** Empryo's ranked map of a checkout (src/genome.rs): its top files, their imports and their layout. */ | |
| 576 | + @Serializable | |
| 577 | + data class Genome( | |
| 578 | + val totalFiles: Long = 0, | |
| 579 | + val totalSymbols: Long = 0, | |
| 580 | + val totalEdges: Long = 0, | |
| 581 | + /** The highest ranked files, highest first. */ | |
| 582 | + val files: List<GenomeFile> = emptyList(), | |
| 583 | + /** Imports between those files: `from` depends on `to`. */ | |
| 584 | + val edges: List<GenomeEdge> = emptyList(), | |
| 585 | + /** Where each file sits in the map, from 0 to 1 on both axes: `[x, y]`. */ | |
| 586 | + val layout: List<List<Float>> = emptyList(), | |
| 587 | + ) | |
| 588 | + | |
| 589 | + @Serializable | |
| 590 | + data class GenomeFile( | |
| 591 | + val path: String = "", | |
| 592 | + val language: String = "", | |
| 593 | + val lines: Long = 0, | |
| 594 | + val symbolCount: Long = 0, | |
| 595 | + val rank: Double = 0.0, | |
| 596 | + /** How many files import it. */ | |
| 597 | + val dependents: Long = 0, | |
| 598 | + val dependencies: Long = 0, | |
| 599 | + /** Changed in the last 48 hours. */ | |
| 600 | + val recent: Boolean = false, | |
| 601 | + val symbols: List<GenomeSymbol> = emptyList(), | |
| 602 | + ) | |
| 603 | + | |
| 604 | + @Serializable | |
| 605 | + data class GenomeSymbol( | |
| 606 | + val name: String = "", | |
| 607 | + val kind: String = "", | |
| 608 | + val line: Long = 0, | |
| 609 | + val exported: Boolean = false, | |
| 610 | + val signature: String? = null, | |
| 611 | + ) | |
| 612 | + | |
| 613 | + @Serializable | |
| 614 | + data class GenomeEdge(val from: Int = 0, val to: Int = 0, val weight: Double = 0.0) | |
| 615 | + | |
| 553 | 616 | /** A message from the host, or `null` for one this app doesn't know. */ |
| 554 | 617 | fun parseServerMessage(text: String): ServerMessage? = try { |
| 555 | 618 | WireJson.decodeFromString(ServerMessage.serializer(), text) |
| @@ -59,6 +59,7 @@ enum class Lucide(val res: Int) { | ||
| 59 | 59 | Ellipsis(R.drawable.lucide_ellipsis), |
| 60 | 60 | EllipsisVertical(R.drawable.lucide_ellipsis_vertical), |
| 61 | 61 | ExternalLink(R.drawable.lucide_external_link), |
| 62 | + Dna(R.drawable.lucide_dna), | |
| 62 | 63 | Eye(R.drawable.lucide_eye), |
| 63 | 64 | EyeOff(R.drawable.lucide_eye_off), |
| 64 | 65 | Feather(R.drawable.lucide_feather), |
| @@ -158,7 +158,7 @@ fun ChatHeader(app: AppState, nav: Nav, thread: OpenThread) { | ||
| 158 | 158 | /** |
| 159 | 159 | * The chat header as a phone's bar: the way back to the threads, the title |
| 160 | 160 | * over its project (or the thread that delegated it), which opens the |
| 161 | - * thread menu, and the right panel's toggle. | |
| 161 | + * thread menu, the Genome map for Empryo threads, and the right panel's toggle. | |
| 162 | 162 | */ |
| 163 | 163 | @Composable |
| 164 | 164 | fun RowScope.PhoneChatBar(app: AppState, nav: Nav, thread: OpenThread) { |
| @@ -209,6 +209,13 @@ fun RowScope.PhoneChatBar(app: AppState, nav: Nav, thread: OpenThread) { | ||
| 209 | 209 | runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(pr.url))) } |
| 210 | 210 | } |
| 211 | 211 | } |
| 212 | + // Empryo's map of the thread's code, as the sidebar's foot offers it on bigger screens. | |
| 213 | + if (summary.provider == "empryo") { | |
| 214 | + BarAction(Lucide.Dna, selected = nav.surface == Surface.Genome) { | |
| 215 | + nav.surfacePicker = false | |
| 216 | + nav.surface = if (nav.surface == Surface.Genome) null else Surface.Genome | |
| 217 | + } | |
| 218 | + } | |
| 212 | 219 | Box { |
| 213 | 220 | BarAction(Lucide.PanelRightOpen, selected = nav.surface != null) { nav.surfacePicker = true } |
| 214 | 221 | if (subagentsWorking > 0 && nav.surface != Surface.Subagents) { |
| @@ -270,7 +270,7 @@ fun ComposerFrame( | ||
| 270 | 270 | val p = palette |
| 271 | 271 | var focused by remember { mutableStateOf(false) } |
| 272 | 272 | var sheet by remember { mutableStateOf<PickerSheet?>(null) } |
| 273 | - val planToggle = app.prefs.bool("plan_mode") && asksPermission(target.provider) | |
| 273 | + val planToggle = app.prefs.bool("plan_mode") && hasPlanMode(target.provider) | |
| 274 | 274 | val attach = rememberAttach(app, state) |
| 275 | 275 | LaunchedEffect(state.modelPickerRequested) { |
| 276 | 276 | if (state.modelPickerRequested) { |
| @@ -32,12 +32,14 @@ val ClaudeOrange = Color(0xffd97757) | ||
| 32 | 32 | fun providerIcon(provider: String): Lucide = when (provider) { |
| 33 | 33 | "pi" -> Lucide.Pi |
| 34 | 34 | "jcode" -> Lucide.Feather |
| 35 | + "empryo" -> Lucide.Dna | |
| 35 | 36 | else -> Lucide.Asterisk |
| 36 | 37 | } |
| 37 | 38 | |
| 38 | 39 | fun providerLabel(provider: String): String = when (provider) { |
| 39 | 40 | "pi" -> "Pi" |
| 40 | 41 | "jcode" -> "jcode" |
| 42 | + "empryo" -> "Empryo" | |
| 41 | 43 | else -> "Claude" |
| 42 | 44 | } |
| 43 | 45 | |
| @@ -45,12 +47,16 @@ fun providerLabel(provider: String): String = when (provider) { | ||
| 45 | 47 | fun agentName(provider: String): String = when (provider) { |
| 46 | 48 | "pi" -> "Pi" |
| 47 | 49 | "jcode" -> "jcode" |
| 50 | + "empryo" -> "Empryo" | |
| 48 | 51 | else -> "Claude Code" |
| 49 | 52 | } |
| 50 | 53 | |
| 51 | - /** Whether the agent asks before running tools. Pi and jcode just run them. */ | |
| 54 | + /** Whether the agent asks before running tools. Pi, jcode and Empryo just run them. */ | |
| 52 | 55 | fun asksPermission(provider: String) = provider == "claude" |
| 53 | 56 | |
| 57 | + /** Whether the agent has a plan mode the composer can switch to. */ | |
| 58 | + fun hasPlanMode(provider: String) = provider == "claude" || provider == "empryo" | |
| 59 | + | |
| 54 | 60 | // ---- reasoning ----------------------------------------------------------------- |
| 55 | 61 | |
| 56 | 62 | /** Claude's `--effort` levels, offered when the model doesn't list its own. */ |
| @@ -82,6 +88,9 @@ fun reasoningLevels(provider: String, model: RemoteModel?): List<String> = when | ||
| 82 | 88 | else -> model?.efforts?.takeIf { it.isNotEmpty() } ?: DefaultEfforts |
| 83 | 89 | } |
| 84 | 90 | |
| 91 | + /** Whether the reasoning menu offers `Default`: the model's own level. */ | |
| 92 | + fun offersDefaultLevel(provider: String) = provider == "claude" || provider == "empryo" | |
| 93 | + | |
| 85 | 94 | /** The level `Default` stands for on this model, when known. */ |
| 86 | 95 | fun defaultLevel(provider: String, model: RemoteModel?): String? = |
| 87 | 96 | if (provider == "claude") model?.defaultEffort else null |
| @@ -234,7 +234,7 @@ fun ColumnScope.TraitsItems(target: PickTarget, close: () -> Unit) { | ||
| 234 | 234 | if (levels.isNotEmpty() || provider == "claude") { |
| 235 | 235 | any = true |
| 236 | 236 | MenuLabel(if (provider == "pi") "Thinking" else "Reasoning") |
| 237 | - if (default == null && provider == "claude") item("default", "Default", null, true) | |
| 237 | + if (default == null && offersDefaultLevel(provider)) item("default", "Default", null, true) | |
| 238 | 238 | for (level in levels) item(level, effortLabel(level), null, default == level) |
| 239 | 239 | if (provider == "claude") { |
| 240 | 240 | if (model == null || model.ultracode) { |
| @@ -531,6 +531,7 @@ private fun setupText(instance: RemoteInstance?): String? { | ||
| 531 | 531 | "missing" -> when (instance.provider) { |
| 532 | 532 | "pi" -> "Open provider setup to install Pi on this environment: npm install -g @earendil-works/pi-coding-agent" |
| 533 | 533 | "jcode" -> "Open provider setup to install jcode on this environment." |
| 534 | + "empryo" -> "Open provider setup to install Empryo on this environment: curl -fsSL https://empryo.com/install.sh | bash" | |
| 534 | 535 | else -> "Open provider setup to install Claude on this environment." |
| 535 | 536 | } |
| 536 | 537 | else -> "$label provider is unavailable." |
| @@ -155,7 +155,7 @@ fun newThreadDefaults(host: HostState, project: String, git: Boolean, from: Thre | ||
| 155 | 155 | // A host that doesn't list its agents: its instance ids start with the provider. |
| 156 | 156 | instances.isEmpty() -> { |
| 157 | 157 | instance = id |
| 158 | - provider = listOf("pi", "jcode").firstOrNull { id.startsWith(it) } ?: "claude" | |
| 158 | + provider = listOf("pi", "jcode", "empryo").firstOrNull { id.startsWith(it) } ?: "claude" | |
| 159 | 159 | model = key.substring(split + 1) |
| 160 | 160 | } |
| 161 | 161 | } |
| @@ -0,0 +1,338 @@ | ||
| 1 | + package cc.vectrum.code.ui.panels | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.Canvas | |
| 4 | + import androidx.compose.foundation.gestures.detectTapGestures | |
| 5 | + import androidx.compose.foundation.layout.Arrangement | |
| 6 | + import androidx.compose.foundation.layout.Box | |
| 7 | + import androidx.compose.foundation.layout.BoxWithConstraints | |
| 8 | + import androidx.compose.foundation.layout.Column | |
| 9 | + import androidx.compose.foundation.layout.Row | |
| 10 | + import androidx.compose.foundation.layout.fillMaxSize | |
| 11 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 12 | + import androidx.compose.foundation.layout.height | |
| 13 | + import androidx.compose.foundation.layout.offset | |
| 14 | + import androidx.compose.foundation.layout.padding | |
| 15 | + import androidx.compose.foundation.layout.size | |
| 16 | + import androidx.compose.foundation.layout.width | |
| 17 | + import androidx.compose.foundation.rememberScrollState | |
| 18 | + import androidx.compose.foundation.shape.CircleShape | |
| 19 | + import androidx.compose.foundation.shape.RoundedCornerShape | |
| 20 | + import androidx.compose.foundation.verticalScroll | |
| 21 | + import androidx.compose.foundation.background | |
| 22 | + import androidx.compose.runtime.Composable | |
| 23 | + import androidx.compose.runtime.LaunchedEffect | |
| 24 | + import androidx.compose.runtime.getValue | |
| 25 | + import androidx.compose.runtime.mutableStateOf | |
| 26 | + import androidx.compose.runtime.remember | |
| 27 | + import androidx.compose.runtime.setValue | |
| 28 | + import androidx.compose.ui.Alignment | |
| 29 | + import androidx.compose.ui.Modifier | |
| 30 | + import androidx.compose.ui.draw.clip | |
| 31 | + import androidx.compose.ui.geometry.Offset | |
| 32 | + import androidx.compose.ui.graphics.Color | |
| 33 | + import androidx.compose.ui.graphics.Path | |
| 34 | + import androidx.compose.ui.graphics.drawscope.Stroke | |
| 35 | + import androidx.compose.ui.input.pointer.pointerInput | |
| 36 | + import androidx.compose.ui.platform.LocalDensity | |
| 37 | + import androidx.compose.ui.text.font.FontWeight | |
| 38 | + import androidx.compose.ui.unit.Dp | |
| 39 | + import androidx.compose.ui.unit.dp | |
| 40 | + import androidx.compose.ui.unit.sp | |
| 41 | + import cc.vectrum.code.data.AppState | |
| 42 | + import cc.vectrum.code.data.ThreadRef | |
| 43 | + import cc.vectrum.code.net.Genome | |
| 44 | + import cc.vectrum.code.net.GenomeFile | |
| 45 | + import cc.vectrum.code.theme.Mono | |
| 46 | + import cc.vectrum.code.theme.Palette | |
| 47 | + import cc.vectrum.code.theme.Rule | |
| 48 | + import cc.vectrum.code.theme.Tag | |
| 49 | + import cc.vectrum.code.theme.Txt | |
| 50 | + import cc.vectrum.code.theme.palette | |
| 51 | + import cc.vectrum.code.theme.well | |
| 52 | + import cc.vectrum.code.ui.Lucide | |
| 53 | + import cc.vectrum.code.ui.Nav | |
| 54 | + import cc.vectrum.code.ui.sidebar.pressTint | |
| 55 | + import kotlinx.coroutines.delay | |
| 56 | + import kotlin.math.max | |
| 57 | + import kotlin.math.sqrt | |
| 58 | + | |
| 59 | + // The Genome map (src/ui/workspace/genome.rs): Empryo's ranked map of the | |
| 60 | + // thread's checkout, which the host reads and sends over. The graph shows | |
| 61 | + // the highest ranked files, sized by rank and colored by folder, with a | |
| 62 | + // line for each import; the list below ranks them, and a file opens to its | |
| 63 | + // symbols. While it shows, it asks the host every few seconds whether | |
| 64 | + // Empryo changed the map. | |
| 65 | + | |
| 66 | + /** How often an open map asks for Empryo's updates. */ | |
| 67 | + private const val WATCH_EVERY = 3_000L | |
| 68 | + | |
| 69 | + /** How many files the map labels by name. */ | |
| 70 | + private const val LABELED = 6 | |
| 71 | + | |
| 72 | + private val MapHeight = 260.dp | |
| 73 | + | |
| 74 | + /** What the panel shows. */ | |
| 75 | + private sealed interface GenomeLoad { | |
| 76 | + data object Loading : GenomeLoad | |
| 77 | + | |
| 78 | + /** Empryo hasn't mapped this checkout yet. */ | |
| 79 | + data object Missing : GenomeLoad | |
| 80 | + | |
| 81 | + data class Failed(val error: String) : GenomeLoad | |
| 82 | + | |
| 83 | + data class Ready(val genome: Genome) : GenomeLoad | |
| 84 | + } | |
| 85 | + | |
| 86 | + /** Whether a thread runs on Empryo, which keeps a Genome. */ | |
| 87 | + fun hasGenome(app: AppState, ref: ThreadRef) = app.thread(ref)?.provider == "empryo" | |
| 88 | + | |
| 89 | + @Composable | |
| 90 | + fun GenomePanel(app: AppState, nav: Nav, ref: ThreadRef) { | |
| 91 | + val p = palette | |
| 92 | + val host = app.host(ref.host) | |
| 93 | + val project = app.thread(ref)?.project.orEmpty() | |
| 94 | + var load by remember(ref) { mutableStateOf<GenomeLoad>(GenomeLoad.Loading) } | |
| 95 | + var written by remember(ref) { mutableStateOf<Long?>(null) } | |
| 96 | + var selected by remember(ref) { mutableStateOf<String?>(null) } | |
| 97 | + | |
| 98 | + LaunchedEffect(ref, host) { | |
| 99 | + if (host == null) return@LaunchedEffect | |
| 100 | + while (true) { | |
| 101 | + val answer = host.genome(ref.thread, project, written) | |
| 102 | + written = answer.written | |
| 103 | + if (!answer.unchanged) { | |
| 104 | + load = when { | |
| 105 | + answer.error != null -> GenomeLoad.Failed(answer.error) | |
| 106 | + answer.genome != null -> GenomeLoad.Ready(answer.genome) | |
| 107 | + else -> GenomeLoad.Missing | |
| 108 | + } | |
| 109 | + } | |
| 110 | + delay(WATCH_EVERY) | |
| 111 | + } | |
| 112 | + } | |
| 113 | + | |
| 114 | + val genome = (load as? GenomeLoad.Ready)?.genome | |
| 115 | + Column(Modifier.fillMaxSize()) { | |
| 116 | + PanelHeader( | |
| 117 | + Lucide.Dna, | |
| 118 | + "Genome", | |
| 119 | + tag = genome?.let { "${it.totalFiles} files · ${it.totalSymbols} symbols" }, | |
| 120 | + ) { nav.surface = null } | |
| 121 | + Box(Modifier.padding(horizontal = 14.dp, vertical = 4.dp)) { Rule() } | |
| 122 | + when (val state = load) { | |
| 123 | + GenomeLoad.Loading -> Message("Reading the Genome…", null) | |
| 124 | + GenomeLoad.Missing -> Message( | |
| 125 | + "No Genome here yet", | |
| 126 | + "Empryo maps a checkout when a thread starts in it. Send this thread a message and the map appears here.", | |
| 127 | + ) | |
| 128 | + is GenomeLoad.Failed -> Message("Couldn't read the Genome", state.error) | |
| 129 | + is GenomeLoad.Ready -> { | |
| 130 | + val selectedIx = state.genome.files.indexOfFirst { it.path == selected }.takeIf { it >= 0 } | |
| 131 | + Column( | |
| 132 | + Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 6.dp, vertical = 4.dp), | |
| 133 | + verticalArrangement = Arrangement.spacedBy(2.dp), | |
| 134 | + ) { | |
| 135 | + GenomeMap(state.genome, selectedIx) { ix -> | |
| 136 | + val path = state.genome.files[ix].path | |
| 137 | + selected = if (selected == path) null else path | |
| 138 | + } | |
| 139 | + Row(Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp)) { | |
| 140 | + Txt( | |
| 141 | + "Top ${state.genome.files.size} of ${state.genome.totalFiles} files by rank · ${state.genome.totalEdges} imports", | |
| 142 | + Modifier.weight(1f), | |
| 143 | + color = p.ink3, | |
| 144 | + size = 11.5.sp, | |
| 145 | + ) | |
| 146 | + written?.let { Txt("updated ${timeOfDay(it)}", color = p.ink3, size = 11.5.sp) } | |
| 147 | + } | |
| 148 | + Box(Modifier.padding(horizontal = 8.dp)) { Rule() } | |
| 149 | + state.genome.files.forEachIndexed { ix, file -> | |
| 150 | + FileRow(ix, file, open = selectedIx == ix) { | |
| 151 | + selected = if (selected == file.path) null else file.path | |
| 152 | + } | |
| 153 | + } | |
| 154 | + } | |
| 155 | + } | |
| 156 | + } | |
| 157 | + } | |
| 158 | + } | |
| 159 | + | |
| 160 | + @Composable | |
| 161 | + private fun Message(title: String, detail: String?) { | |
| 162 | + val p = palette | |
| 163 | + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { | |
| 164 | + Txt(title, color = p.ink2, size = 13.sp) | |
| 165 | + if (!detail.isNullOrBlank()) Txt(detail, color = p.ink3, size = 12.sp, lineHeight = 17.sp) | |
| 166 | + } | |
| 167 | + } | |
| 168 | + | |
| 169 | + /** | |
| 170 | + * The import graph: a dot per file, sized by rank and colored by folder, | |
| 171 | + * with a line for each import. The file opened in the list lights up with | |
| 172 | + * its neighbors; tapping a dot opens its file. | |
| 173 | + */ | |
| 174 | + @Composable | |
| 175 | + private fun GenomeMap(genome: Genome, focus: Int?, onTap: (Int) -> Unit) { | |
| 176 | + val p = palette | |
| 177 | + val density = LocalDensity.current | |
| 178 | + val maxRank = max(genome.files.maxOfOrNull { it.rank } ?: 0.0, 1e-9) | |
| 179 | + fun radius(file: GenomeFile): Dp = (3.5 + 8.5 * sqrt(file.rank / maxRank)).dp | |
| 180 | + val neighbors = focus?.let { f -> genome.edges.mapNotNull { if (it.from == f) it.to else if (it.to == f) it.from else null }.toSet() }.orEmpty() | |
| 181 | + BoxWithConstraints( | |
| 182 | + Modifier | |
| 183 | + .fillMaxWidth() | |
| 184 | + .height(MapHeight) | |
| 185 | + .padding(horizontal = 4.dp) | |
| 186 | + .well(p, RoundedCornerShape(12.dp)) | |
| 187 | + .clip(RoundedCornerShape(12.dp)), | |
| 188 | + ) { | |
| 189 | + val inset = 16.dp | |
| 190 | + val width = maxWidth | |
| 191 | + fun spot(ix: Int): Pair<Dp, Dp> { | |
| 192 | + val xy = genome.layout.getOrNull(ix) ?: listOf(0.5f, 0.5f) | |
| 193 | + return Pair(inset + (width - inset * 2) * xy[0], inset + (MapHeight - inset * 2) * xy.getOrElse(1) { 0.5f }) | |
| 194 | + } | |
| 195 | + val dim = p.ink3.copy(alpha = 0.3f) | |
| 196 | + val lit = p.tints[2 % p.tints.size].copy(alpha = 0.9f) | |
| 197 | + Canvas( | |
| 198 | + Modifier.fillMaxSize().pointerInput(genome) { | |
| 199 | + detectTapGestures { tap -> | |
| 200 | + val hit = genome.files.indices.minByOrNull { ix -> | |
| 201 | + val (x, y) = spot(ix) | |
| 202 | + val dx = x.toPx() - tap.x | |
| 203 | + val dy = y.toPx() - tap.y | |
| 204 | + dx * dx + dy * dy | |
| 205 | + } ?: return@detectTapGestures | |
| 206 | + val (x, y) = spot(hit) | |
| 207 | + val reach = radius(genome.files[hit]).toPx() + 14.dp.toPx() | |
| 208 | + val dx = x.toPx() - tap.x | |
| 209 | + val dy = y.toPx() - tap.y | |
| 210 | + if (dx * dx + dy * dy <= reach * reach) onTap(hit) | |
| 211 | + } | |
| 212 | + }, | |
| 213 | + ) { | |
| 214 | + for (edge in genome.edges) { | |
| 215 | + if (edge.from !in genome.files.indices || edge.to !in genome.files.indices) continue | |
| 216 | + val on = focus != null && (edge.from == focus || edge.to == focus) | |
| 217 | + val (x1, y1) = spot(edge.from) | |
| 218 | + val (x2, y2) = spot(edge.to) | |
| 219 | + val from = Offset(x1.toPx(), y1.toPx()) | |
| 220 | + val to = Offset(x2.toPx(), y2.toPx()) | |
| 221 | + // A slight bend tells crossing lines apart. | |
| 222 | + val mid = Offset((from.x + to.x) / 2 + (to.y - from.y) * 0.12f, (from.y + to.y) / 2 - (to.x - from.x) * 0.12f) | |
| 223 | + val path = Path().apply { | |
| 224 | + moveTo(from.x, from.y) | |
| 225 | + quadraticTo(mid.x, mid.y, to.x, to.y) | |
| 226 | + } | |
| 227 | + drawPath(path, if (on) lit else dim, style = Stroke(width = (if (on) 1.6f else 1f) * density.density)) | |
| 228 | + } | |
| 229 | + genome.files.forEachIndexed { ix, file -> | |
| 230 | + val (x, y) = spot(ix) | |
| 231 | + val center = Offset(x.toPx(), y.toPx()) | |
| 232 | + val r = radius(file).toPx() | |
| 233 | + val faded = focus != null && ix != focus && ix !in neighbors | |
| 234 | + val color = folderColor(file.path, p) | |
| 235 | + drawCircle(if (faded) color.copy(alpha = 0.3f) else color, r, center) | |
| 236 | + if (ix == focus) drawCircle(p.ink, r, center, style = Stroke(width = 2 * density.density)) | |
| 237 | + else if (ix in neighbors) drawCircle(lit, r, center, style = Stroke(width = density.density)) | |
| 238 | + } | |
| 239 | + } | |
| 240 | + // Names for the top files, and for the one in focus. | |
| 241 | + val labeled = ((0 until minOf(LABELED, genome.files.size)) + listOfNotNull(focus)).distinct() | |
| 242 | + for (ix in labeled) { | |
| 243 | + val file = genome.files[ix] | |
| 244 | + val (x, y) = spot(ix) | |
| 245 | + val name = file.path.substringAfterLast('/') | |
| 246 | + val labelWidth = minOf((name.length * 6.2 + 10).dp, 150.dp) | |
| 247 | + val left = (x - labelWidth / 2).coerceIn(2.dp, width - labelWidth - 2.dp) | |
| 248 | + val r = radius(file) | |
| 249 | + val below = y + r + 18.dp < MapHeight | |
| 250 | + Box( | |
| 251 | + Modifier | |
| 252 | + .offset(left, if (below) y + r + 2.dp else y - r - 17.dp) | |
| 253 | + .width(labelWidth), | |
| 254 | + contentAlignment = Alignment.Center, | |
| 255 | + ) { | |
| 256 | + Txt( | |
| 257 | + name, | |
| 258 | + Modifier.background(p.page.copy(alpha = 0.75f), RoundedCornerShape(4.dp)).padding(horizontal = 4.dp), | |
| 259 | + color = if (ix == focus) p.ink else p.ink2, | |
| 260 | + size = 10.5.sp, | |
| 261 | + family = Mono, | |
| 262 | + maxLines = 1, | |
| 263 | + ) | |
| 264 | + } | |
| 265 | + } | |
| 266 | + } | |
| 267 | + } | |
| 268 | + | |
| 269 | + /** A ranked file: its rank, folder color, path, whether it's new and how many files import it; open, its symbols. */ | |
| 270 | + @Composable | |
| 271 | + private fun FileRow(ix: Int, file: GenomeFile, open: Boolean, onClick: () -> Unit) { | |
| 272 | + val p = palette | |
| 273 | + val dir = file.path.substringBeforeLast('/', "").let { if (it.isEmpty()) "" else "$it/" } | |
| 274 | + val name = file.path.substringAfterLast('/') | |
| 275 | + Column { | |
| 276 | + Row( | |
| 277 | + Modifier | |
| 278 | + .fillMaxWidth() | |
| 279 | + .height(40.dp) | |
| 280 | + .then(if (open) Modifier.background(p.rowHover, RoundedCornerShape(8.dp)) else Modifier) | |
| 281 | + .pressTint(8.dp, onClick) | |
| 282 | + .padding(horizontal = 10.dp), | |
| 283 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 284 | + verticalAlignment = Alignment.CenterVertically, | |
| 285 | + ) { | |
| 286 | + Txt("${ix + 1}", Modifier.width(20.dp), color = p.ink3, size = 10.5.sp, family = Mono) | |
| 287 | + Box(Modifier.size(8.dp).background(folderColor(file.path, p), CircleShape)) | |
| 288 | + Row(Modifier.weight(1f)) { | |
| 289 | + Txt(dir, Modifier.weight(1f, fill = false), color = p.ink3, size = 13.sp, maxLines = 1) | |
| 290 | + Txt(name, color = p.ink, size = 13.sp, maxLines = 1) | |
| 291 | + } | |
| 292 | + if (file.recent) Tag("new") | |
| 293 | + if (file.dependents > 0) Txt("→${file.dependents}", color = p.ink2, size = 11.sp, family = Mono) | |
| 294 | + } | |
| 295 | + if (open) { | |
| 296 | + Column( | |
| 297 | + Modifier.fillMaxWidth().padding(start = 46.dp, end = 10.dp, bottom = 8.dp), | |
| 298 | + verticalArrangement = Arrangement.spacedBy(3.dp), | |
| 299 | + ) { | |
| 300 | + Txt( | |
| 301 | + "${file.language} · ${file.lines} lines · imported by ${file.dependents} · imports ${file.dependencies}", | |
| 302 | + color = p.ink3, | |
| 303 | + size = 11.5.sp, | |
| 304 | + ) | |
| 305 | + if (file.symbols.isEmpty()) Txt("No symbols", color = p.ink3, size = 12.sp) | |
| 306 | + for (symbol in file.symbols) { | |
| 307 | + val signature = symbol.signature?.lineSequence()?.firstOrNull()?.trim()?.takeIf { it.isNotEmpty() } | |
| 308 | + ?: "${symbol.kind} ${symbol.name}" | |
| 309 | + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { | |
| 310 | + Txt( | |
| 311 | + if (symbol.exported) "+" else " ", | |
| 312 | + Modifier.width(10.dp), | |
| 313 | + color = if (symbol.exported) p.greenInk else p.ink3, | |
| 314 | + size = 11.5.sp, | |
| 315 | + family = Mono, | |
| 316 | + ) | |
| 317 | + Txt(signature, Modifier.weight(1f), color = p.ink2, size = 11.5.sp, family = Mono, maxLines = 1) | |
| 318 | + Txt(":${symbol.line}", color = p.ink3, size = 11.5.sp, family = Mono) | |
| 319 | + } | |
| 320 | + } | |
| 321 | + val more = file.symbolCount - file.symbols.size | |
| 322 | + if (more > 0) Txt("… $more more", color = p.ink3, size = 11.5.sp) | |
| 323 | + } | |
| 324 | + } | |
| 325 | + } | |
| 326 | + } | |
| 327 | + | |
| 328 | + /** A file's color in the map: one of the palette's tints, the same for every file in a folder. */ | |
| 329 | + private fun folderColor(path: String, p: Palette): Color { | |
| 330 | + val folder = path.substringBeforeLast('/', "") | |
| 331 | + // The desktop's hash, so a folder has the same color on both. | |
| 332 | + var hash = 7u | |
| 333 | + for (byte in folder.encodeToByteArray()) hash = hash * 31u + byte.toUByte().toUInt() | |
| 334 | + return p.tints[(hash % p.tints.size.toUInt()).toInt()] | |
| 335 | + } | |
| 336 | + | |
| 337 | + private fun timeOfDay(millis: Long): String = | |
| 338 | + java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date(millis)) | |
| @@ -60,6 +60,7 @@ private val SURFACES = listOf( | ||
| 60 | 60 | Triple(Surface.Subagents, Lucide.Bot, "Subagents"), |
| 61 | 61 | Triple(Surface.Device, Lucide.Smartphone, "Device"), |
| 62 | 62 | Triple(Surface.Terminal, Lucide.SquareTerminal, "Terminal"), |
| 63 | + Triple(Surface.Genome, Lucide.Dna, "Genome map"), | |
| 63 | 64 | ) |
| 64 | 65 | |
| 65 | 66 | /** The right panel over a thread: "Open a surface", or the surface picked. */ |
| @@ -84,8 +85,9 @@ fun RightPanel(app: AppState, nav: Nav, ref: ThreadRef, shape: Shape = RoundedCo | ||
| 84 | 85 | ) { |
| 85 | 86 | val surface = nav.surface |
| 86 | 87 | when { |
| 87 | - nav.surfacePicker || surface == null -> SurfacePicker(nav) | |
| 88 | + nav.surfacePicker || surface == null -> SurfacePicker(nav, genome = hasGenome(app, ref)) | |
| 88 | 89 | surface == Surface.Subagents -> SubagentsPanel(app, nav, ref) |
| 90 | + surface == Surface.Genome -> GenomePanel(app, nav, ref) | |
| 89 | 91 | // The terminal and device panels draw their own tab bar with a close button. |
| 90 | 92 | surface == Surface.Terminal -> TerminalPanel(app, nav, ref) |
| 91 | 93 | else -> DevicePanel(app, nav, ref) |
| @@ -112,7 +114,7 @@ fun PanelHeader(icon: Lucide, title: String, tag: String? = null, onClose: () -> | ||
| 112 | 114 | |
| 113 | 115 | /** "Open a surface": the surfaces to pick from, on a raised card. */ |
| 114 | 116 | @Composable |
| 115 | - private fun SurfacePicker(nav: Nav) { | |
| 117 | + private fun SurfacePicker(nav: Nav, genome: Boolean) { | |
| 116 | 118 | val p = palette |
| 117 | 119 | Column(Modifier.fillMaxSize()) { |
| 118 | 120 | Row(Modifier.fillMaxWidth().height(48.dp).padding(end = 10.dp), verticalAlignment = Alignment.CenterVertically) { |
| @@ -135,7 +137,8 @@ private fun SurfacePicker(nav: Nav) { | ||
| 135 | 137 | style = TextStyle(textAlign = TextAlign.Center), |
| 136 | 138 | ) |
| 137 | 139 | Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { |
| 138 | - for ((surface, icon, name) in SURFACES) { | |
| 140 | + // The Genome map is Empryo's; other agents' threads don't list it. | |
| 141 | + for ((surface, icon, name) in SURFACES.filter { it.first != Surface.Genome || genome }) { | |
| 139 | 142 | Row( |
| 140 | 143 | Modifier |
| 141 | 144 | .fillMaxWidth() |
| @@ -589,13 +589,14 @@ fun Code(text: String, modifier: Modifier = Modifier) { | ||
| 589 | 589 | /** Claude's asterisk orange, as the desktop's pickers draw it. */ |
| 590 | 590 | val ClaudeOrange = Color(0xffd97757) |
| 591 | 591 | |
| 592 | - /** An agent's mark: Claude's asterisk, Pi's π or jcode's feather. */ | |
| 592 | + /** An agent's mark: Claude's asterisk, Pi's π, jcode's feather or Empryo's DNA strand. */ | |
| 593 | 593 | @Composable |
| 594 | 594 | fun ProviderIcon(provider: String, size: Dp = 13.dp, modifier: Modifier = Modifier) { |
| 595 | 595 | val p = palette |
| 596 | 596 | when (provider) { |
| 597 | 597 | "pi" -> LucideIcon(Lucide.Pi, modifier, size = size, tint = p.ink) |
| 598 | 598 | "jcode" -> LucideIcon(Lucide.Feather, modifier, size = size, tint = p.ink) |
| 599 | + "empryo" -> LucideIcon(Lucide.Dna, modifier, size = size, tint = p.ink) | |
| 599 | 600 | else -> LucideIcon(Lucide.Asterisk, modifier, size = size, tint = ClaudeOrange) |
| 600 | 601 | } |
| 601 | 602 | } |
| @@ -630,7 +631,7 @@ fun ModelControl( | ||
| 630 | 631 | model != null -> model.name |
| 631 | 632 | else -> modelId ?: current |
| 632 | 633 | } |
| 633 | - val provider = instance?.provider ?: instanceId?.takeIf { it in setOf("claude", "pi", "jcode") } | |
| 634 | + val provider = instance?.provider ?: instanceId?.takeIf { it in setOf("claude", "pi", "jcode", "empryo") } | |
| 634 | 635 | val leading: (@Composable androidx.compose.foundation.layout.RowScope.() -> Unit)? = |
| 635 | 636 | if (provider != null) { { ProviderIcon(provider) } } else null |
| 636 | 637 | Cap( |
| @@ -0,0 +1,16 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="m10 16 1.5 1.5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="m14 8-1.5-1.5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + <path android:pathData="M15 2c-1.798 1.998-2.518 3.995-2.807 5.993" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 8 | + <path android:pathData="m16.5 10.5 1 1" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 9 | + <path android:pathData="m17 6-2.891-2.891" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 10 | + <path android:pathData="M2 15c6.667-6 13.333 0 20-6" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 11 | + <path android:pathData="m20 9 .891.891" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 12 | + <path android:pathData="M3.109 14.109 4 15" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 13 | + <path android:pathData="m6.5 12.5 1 1" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 14 | + <path android:pathData="m7 18 2.891 2.891" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 15 | + <path android:pathData="M9 22c1.798-1.998 2.518-3.995 2.807-5.993" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 16 | + </vector> | |
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | //! Spawns the long-running agent process behind a thread: the `claude` CLI |
| 2 | 2 | //! speaking stream-json (the transport the Claude Agent SDKs use), Pi in its |
| 3 | - //! JSONL RPC mode, or jcode's harness API bridge, which connects to jcode's | |
| 4 | - //! shared daemon. All read commands on stdin and stream events on stdout. | |
| 3 | + //! JSONL RPC mode, jcode's harness API bridge, which connects to jcode's | |
| 4 | + //! shared daemon, or Empryo's headless chat, which takes a message per line. | |
| 5 | + //! All read commands on stdin and stream events on stdout. | |
| 5 | 6 | |
| 6 | 7 | use std::{ |
| 7 | 8 | io::{BufRead, BufReader, Write}, |
| @@ -142,15 +143,18 @@ pub struct LaunchConfig { | ||
| 142 | 143 | /// Which agent, with its binary, home folder, arguments and environment. |
| 143 | 144 | pub instance: ProviderInstance, |
| 144 | 145 | pub cwd: PathBuf, |
| 145 | - /// A Claude model alias, `provider/model` for Pi, or a jcode model spec. | |
| 146 | - /// `None` is the default. | |
| 146 | + /// A Claude model alias, `provider/model` for Pi and Empryo, or a jcode | |
| 147 | + /// model spec. `None` is the default. | |
| 147 | 148 | pub model: Option<String>, |
| 148 | 149 | pub permission_mode: PermissionMode, |
| 149 | 150 | /// `None` leaves effort at the model's default. |
| 150 | 151 | pub effort: Option<Effort>, |
| 151 | - /// Session to resume (a Claude or jcode session id, or a Pi session | |
| 152 | - /// file), used to restart a thread whose process stopped. | |
| 152 | + /// Session to resume (a Claude, jcode or Empryo session id, or a Pi | |
| 153 | + /// session file), used to restart a thread whose process stopped. | |
| 153 | 154 | pub resume: Option<String>, |
| 155 | + /// The id a new Empryo session starts under, so the thread knows what | |
| 156 | + /// to resume before Empryo has said anything. | |
| 157 | + pub new_session: Option<String>, | |
| 154 | 158 | /// The thread's agent-browser session, shared with the browser panel. |
| 155 | 159 | pub browser_session: Option<String>, |
| 156 | 160 | /// The thread the agent runs for. |
| @@ -181,6 +185,7 @@ impl AgentProcess { | ||
| 181 | 185 | // jcode's tools run in its daemon, out of reach of this process's |
| 182 | 186 | // environment; the thread tells it about the browser in a message. |
| 183 | 187 | Provider::Jcode => jcode_command(config)?, |
| 188 | + Provider::Empryo => empryo_command(config)?, | |
| 184 | 189 | }; |
| 185 | 190 | // The app's tools; jcode's are registered over its API. |
| 186 | 191 | orchestrator::set_caps(&config.thread, config.tools); |
| @@ -192,20 +197,22 @@ impl AgentProcess { | ||
| 192 | 197 | } |
| 193 | 198 | } |
| 194 | 199 | Provider::Pi => orchestrator::prepare_pi(&mut command, &config.thread, config.tools), |
| 195 | - Provider::Jcode => {} | |
| 200 | + // Empryo takes MCP servers from its config files only. | |
| 201 | + Provider::Jcode | Provider::Empryo => {} | |
| 196 | 202 | } |
| 197 | 203 | // The agent-device launcher, on PATH for agents that may drive devices. |
| 198 | 204 | if config.tools.devices { |
| 199 | 205 | crate::device::agent::prepare_command(&mut command); |
| 200 | 206 | } |
| 201 | - // Claude and Pi take extra system prompt with this flag, once: the | |
| 202 | - // browser's, if the thread has one, and how to write inline UI. | |
| 207 | + // Claude, Pi and Empryo take extra system prompt with a flag, once: | |
| 208 | + // the browser's, if the thread has one, and how to write inline UI. | |
| 203 | 209 | if config.instance.kind != Provider::Jcode { |
| 204 | 210 | let browser = config.browser_session.as_ref().and_then(|session| browser::prepare_agent(&mut command, session)); |
| 205 | 211 | let inline = crate::settings::get(|settings| settings.inline_ui).then(|| crate::inline_ui::INSTRUCTIONS.to_string()); |
| 206 | 212 | let prompts: Vec<String> = browser.into_iter().chain(inline).collect(); |
| 207 | 213 | if !prompts.is_empty() { |
| 208 | - command.args(["--append-system-prompt", &prompts.join("\n\n")]); | |
| 214 | + let flag = if config.instance.kind == Provider::Empryo { "--system" } else { "--append-system-prompt" }; | |
| 215 | + command.args([flag, &prompts.join("\n\n")]); | |
| 209 | 216 | } |
| 210 | 217 | } |
| 211 | 218 | Self::spawn_command(&binary, command, events) |
| @@ -292,6 +299,11 @@ impl AgentProcess { | ||
| 292 | 299 | let _ = self.stdin_tx.send(message.to_string()); |
| 293 | 300 | } |
| 294 | 301 | |
| 302 | + /// Sends a line of text as it is, for agents that read plain lines. | |
| 303 | + pub fn send_line(&self, line: String) { | |
| 304 | + let _ = self.stdin_tx.send(line); | |
| 305 | + } | |
| 306 | + | |
| 295 | 307 | /// Sends a Claude user turn made of API content blocks (text, images). |
| 296 | 308 | pub fn send_user_content(&self, content: Vec<Value>) { |
| 297 | 309 | self.send(json!({ |
| @@ -312,6 +324,16 @@ impl AgentProcess { | ||
| 312 | 324 | })); |
| 313 | 325 | } |
| 314 | 326 | |
| 327 | + /// Sends the agent Ctrl+C. Empryo stops its turn, saves the session and | |
| 328 | + /// exits on it. | |
| 329 | + pub fn interrupt(&self) { | |
| 330 | + if let Ok(child) = self.child.lock() { | |
| 331 | + unsafe { | |
| 332 | + libc::kill(child.id() as libc::pid_t, libc::SIGINT); | |
| 333 | + } | |
| 334 | + } | |
| 335 | + } | |
| 336 | + | |
| 315 | 337 | pub fn kill(&self) { |
| 316 | 338 | if let Ok(mut child) = self.child.lock() { |
| 317 | 339 | let _ = child.kill(); |
| @@ -393,12 +415,43 @@ fn jcode_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> { | ||
| 393 | 415 | Ok((binary, command)) |
| 394 | 416 | } |
| 395 | 417 | |
| 396 | - /// The environment variable that moves an agent's home folder. | |
| 418 | + /// Empryo's headless chat: a message per line on stdin, its events as | |
| 419 | + /// JSONL on stdout. A thread's session is new under the id the thread picked, | |
| 420 | + /// or resumed. It runs tools without asking (`auto`), or plans (`plan`). | |
| 421 | + fn empryo_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> { | |
| 422 | + let (binary, mut command) = base_command(&config.instance)?; | |
| 423 | + command | |
| 424 | + .args(["--headless", "--chat", "--events", "--cwd"]) | |
| 425 | + .arg(&config.cwd) | |
| 426 | + .current_dir(&config.cwd) | |
| 427 | + // Its first run would otherwise wait on a question about add-ons. | |
| 428 | + .env("EMPRYO_NO_PROMPT", "1"); | |
| 429 | + let mode = if config.permission_mode == PermissionMode::Plan { "plan" } else { "auto" }; | |
| 430 | + command.args(["--mode", mode]); | |
| 431 | + if let Some(model) = &config.model { | |
| 432 | + command.args(["--model", model]); | |
| 433 | + } | |
| 434 | + if let Some(effort) = config.effort { | |
| 435 | + command.args(["--effort", effort.as_cli()]); | |
| 436 | + } | |
| 437 | + if let Some(session) = &config.resume { | |
| 438 | + command.args(["--session", session]); | |
| 439 | + } else if let Some(session) = &config.new_session { | |
| 440 | + command.args(["--session-id", session]); | |
| 441 | + } | |
| 442 | + command.args(split_args(&config.instance.args)); | |
| 443 | + Ok((binary, command)) | |
| 444 | + } | |
| 445 | + | |
| 446 | + /// The environment variable that moves an agent's home folder. Empryo | |
| 447 | + /// keeps its keys, settings and sessions list in `~/.empryo`, so its home is | |
| 448 | + /// the folder that stands in for `~`. | |
| 397 | 449 | pub fn home_var(kind: Provider) -> &'static str { |
| 398 | 450 | match kind { |
| 399 | 451 | Provider::Claude => "CLAUDE_CONFIG_DIR", |
| 400 | 452 | Provider::Pi => "PI_CODING_AGENT_DIR", |
| 401 | 453 | Provider::Jcode => "JCODE_HOME", |
| 454 | + Provider::Empryo => "HOME", | |
| 402 | 455 | } |
| 403 | 456 | } |
| 404 | 457 | |
| @@ -408,6 +461,7 @@ pub fn binary_var(kind: Provider) -> &'static str { | ||
| 408 | 461 | Provider::Claude => "VECTRUM_CODE_CLAUDE_BIN", |
| 409 | 462 | Provider::Pi => "VECTRUM_CODE_PI_BIN", |
| 410 | 463 | Provider::Jcode => "VECTRUM_CODE_JCODE_BIN", |
| 464 | + Provider::Empryo => "VECTRUM_CODE_EMPRYO_BIN", | |
| 411 | 465 | } |
| 412 | 466 | } |
| 413 | 467 | |
| @@ -482,6 +536,8 @@ pub fn find_binary(instance: &ProviderInstance) -> Option<PathBuf> { | ||
| 482 | 536 | home.join(".volta/bin/pi"), |
| 483 | 537 | ], |
| 484 | 538 | Provider::Jcode => vec![home.join(".local/bin/jcode"), home.join(".cargo/bin/jcode")], |
| 539 | + // Its installer puts it here. | |
| 540 | + Provider::Empryo => vec![home.join(".empryo/bin/empryo"), home.join(".local/bin/empryo")], | |
| 485 | 541 | }; |
| 486 | 542 | fallbacks.extend([PathBuf::from("/usr/local/bin").join(name), PathBuf::from("/usr/bin").join(name)]); |
| 487 | 543 | // nvm keeps one bin directory per Node version. |
| @@ -0,0 +1,363 @@ | ||
| 1 | + //! Empryo's Genome: the ranked map of a repository Empryo keeps in | |
| 2 | + //! `.empryo/genome.db`, a SQLite file it updates as code changes. The Genome | |
| 3 | + //! map panel reads its most important files, what they export and which of | |
| 4 | + //! them import which, and lays them out as a graph. A host reads it the same | |
| 5 | + //! way for a connected device's Empryo threads and sends it over. | |
| 6 | + | |
| 7 | + use std::{ | |
| 8 | + collections::HashMap, | |
| 9 | + path::{Path, PathBuf}, | |
| 10 | + time::{Duration, SystemTime}, | |
| 11 | + }; | |
| 12 | + | |
| 13 | + use anyhow::{Context as _, Result}; | |
| 14 | + use rusqlite::{Connection, OpenFlags, params_from_iter}; | |
| 15 | + use serde::{Deserialize, Serialize}; | |
| 16 | + | |
| 17 | + /// How many files the map shows, by rank. | |
| 18 | + pub const MAP_FILES: usize = 48; | |
| 19 | + /// How many symbols each file lists. | |
| 20 | + const FILE_SYMBOLS: usize = 12; | |
| 21 | + /// A file changed this recently is marked new, as Empryo does in its map. | |
| 22 | + const RECENT: Duration = Duration::from_secs(48 * 60 * 60); | |
| 23 | + | |
| 24 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 25 | + pub struct Genome { | |
| 26 | + pub total_files: u64, | |
| 27 | + pub total_symbols: u64, | |
| 28 | + pub total_edges: u64, | |
| 29 | + /// The highest ranked files, highest first. | |
| 30 | + pub files: Vec<GenomeFile>, | |
| 31 | + /// Imports between those files: `from` depends on `to`. | |
| 32 | + pub edges: Vec<GenomeEdge>, | |
| 33 | + /// Where each file sits in the map, from 0 to 1 on both axes. | |
| 34 | + pub layout: Vec<(f32, f32)>, | |
| 35 | + } | |
| 36 | + | |
| 37 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 38 | + pub struct GenomeFile { | |
| 39 | + pub path: String, | |
| 40 | + pub language: String, | |
| 41 | + pub lines: u64, | |
| 42 | + pub symbol_count: u64, | |
| 43 | + /// Its PageRank in the import graph; the files' ranks add up to 1. | |
| 44 | + pub rank: f64, | |
| 45 | + /// How many files import it, from anywhere in the repository. | |
| 46 | + pub dependents: u64, | |
| 47 | + /// How many files it imports. | |
| 48 | + pub dependencies: u64, | |
| 49 | + /// Changed in the last 48 hours. | |
| 50 | + pub recent: bool, | |
| 51 | + /// Its symbols, exported ones first. | |
| 52 | + pub symbols: Vec<GenomeSymbol>, | |
| 53 | + } | |
| 54 | + | |
| 55 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 56 | + pub struct GenomeSymbol { | |
| 57 | + pub name: String, | |
| 58 | + pub kind: String, | |
| 59 | + pub line: u64, | |
| 60 | + pub exported: bool, | |
| 61 | + pub signature: Option<String>, | |
| 62 | + } | |
| 63 | + | |
| 64 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 65 | + pub struct GenomeEdge { | |
| 66 | + pub from: usize, | |
| 67 | + pub to: usize, | |
| 68 | + pub weight: f64, | |
| 69 | + } | |
| 70 | + | |
| 71 | + /// The Genome database of the checkout `cwd` is in: Empryo keeps it at the | |
| 72 | + /// checkout's root, which may be a folder or two up. | |
| 73 | + pub fn db_path(cwd: &Path) -> Option<PathBuf> { | |
| 74 | + cwd.ancestors().map(|dir| dir.join(".empryo").join("genome.db")).find(|path| path.is_file()) | |
| 75 | + } | |
| 76 | + | |
| 77 | + /// When Empryo last wrote the Genome of the checkout `cwd` is in, in | |
| 78 | + /// milliseconds since the epoch: its write-ahead log or the database itself. | |
| 79 | + pub fn written_at(cwd: &Path) -> Option<u64> { | |
| 80 | + let path = db_path(cwd)?; | |
| 81 | + [path.with_extension("db-wal"), path] | |
| 82 | + .iter() | |
| 83 | + .filter_map(|path| std::fs::metadata(path).and_then(|meta| meta.modified()).ok()) | |
| 84 | + .max() | |
| 85 | + .and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok()) | |
| 86 | + .map(|since| since.as_millis() as u64) | |
| 87 | + } | |
| 88 | + | |
| 89 | + /// Reads the map of the checkout `cwd` is in. | |
| 90 | + pub fn load(cwd: &Path) -> Result<Option<Genome>> { | |
| 91 | + let Some(path) = db_path(cwd) else { return Ok(None) }; | |
| 92 | + // Opened for writing so SQLite can share Empryo's write-ahead log, but | |
| 93 | + // only ever queried. | |
| 94 | + let db = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX) | |
| 95 | + .with_context(|| format!("couldn't open {}", path.display()))?; | |
| 96 | + db.busy_timeout(Duration::from_secs(5))?; | |
| 97 | + db.pragma_update(None, "query_only", true)?; | |
| 98 | + | |
| 99 | + let count = |table: &str| -> Result<u64> { | |
| 100 | + Ok(db.query_row(&format!("SELECT count(*) FROM {table}"), [], |row| row.get::<_, i64>(0))? as u64) | |
| 101 | + }; | |
| 102 | + let (total_files, total_symbols, total_edges) = (count("files")?, count("symbols")?, count("edges")?); | |
| 103 | + | |
| 104 | + let now_ms = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_millis() as f64; | |
| 105 | + let mut ids = Vec::new(); | |
| 106 | + let mut files = Vec::new(); | |
| 107 | + let mut statement = db.prepare( | |
| 108 | + "SELECT id, path, language, line_count, symbol_count, pagerank, mtime_ms FROM files | |
| 109 | + ORDER BY pagerank DESC, symbol_count DESC LIMIT ?1", | |
| 110 | + )?; | |
| 111 | + let rows = statement.query_map([MAP_FILES as i64], |row| { | |
| 112 | + Ok(( | |
| 113 | + row.get::<_, i64>(0)?, | |
| 114 | + GenomeFile { | |
| 115 | + path: row.get(1)?, | |
| 116 | + language: row.get(2)?, | |
| 117 | + lines: row.get::<_, i64>(3)?.max(0) as u64, | |
| 118 | + symbol_count: row.get::<_, i64>(4)?.max(0) as u64, | |
| 119 | + rank: row.get(5)?, | |
| 120 | + dependents: 0, | |
| 121 | + dependencies: 0, | |
| 122 | + recent: now_ms - row.get::<_, f64>(6)? < RECENT.as_millis() as f64, | |
| 123 | + symbols: Vec::new(), | |
| 124 | + }, | |
| 125 | + )) | |
| 126 | + })?; | |
| 127 | + for row in rows { | |
| 128 | + let (id, file) = row?; | |
| 129 | + ids.push(id); | |
| 130 | + files.push(file); | |
| 131 | + } | |
| 132 | + drop(statement); | |
| 133 | + let index: HashMap<i64, usize> = ids.iter().enumerate().map(|(ix, id)| (*id, ix)).collect(); | |
| 134 | + let placeholders = vec!["?"; ids.len()].join(","); | |
| 135 | + | |
| 136 | + let mut edges = Vec::new(); | |
| 137 | + if !ids.is_empty() { | |
| 138 | + let mut statement = db.prepare(&format!( | |
| 139 | + "SELECT target_file_id, count(*) FROM edges WHERE target_file_id IN ({placeholders}) GROUP BY target_file_id" | |
| 140 | + ))?; | |
| 141 | + let rows = statement.query_map(params_from_iter(&ids), |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))?; | |
| 142 | + for row in rows { | |
| 143 | + let (id, count) = row?; | |
| 144 | + if let Some(&ix) = index.get(&id) { | |
| 145 | + files[ix].dependents = count as u64; | |
| 146 | + } | |
| 147 | + } | |
| 148 | + drop(statement); | |
| 149 | + let mut statement = db.prepare(&format!( | |
| 150 | + "SELECT source_file_id, count(*) FROM edges WHERE source_file_id IN ({placeholders}) GROUP BY source_file_id" | |
| 151 | + ))?; | |
| 152 | + let rows = statement.query_map(params_from_iter(&ids), |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))?; | |
| 153 | + for row in rows { | |
| 154 | + let (id, count) = row?; | |
| 155 | + if let Some(&ix) = index.get(&id) { | |
| 156 | + files[ix].dependencies = count as u64; | |
| 157 | + } | |
| 158 | + } | |
| 159 | + drop(statement); | |
| 160 | + | |
| 161 | + let mut statement = db.prepare(&format!( | |
| 162 | + "SELECT source_file_id, target_file_id, weight FROM edges | |
| 163 | + WHERE source_file_id IN ({placeholders}) AND target_file_id IN ({placeholders})" | |
| 164 | + ))?; | |
| 165 | + let both: Vec<i64> = ids.iter().chain(ids.iter()).copied().collect(); | |
| 166 | + let rows = statement.query_map(params_from_iter(&both), |row| { | |
| 167 | + Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, f64>(2)?)) | |
| 168 | + })?; | |
| 169 | + for row in rows { | |
| 170 | + let (from, to, weight) = row?; | |
| 171 | + if let (Some(&from), Some(&to)) = (index.get(&from), index.get(&to)) | |
| 172 | + && from != to | |
| 173 | + { | |
| 174 | + edges.push(GenomeEdge { from, to, weight }); | |
| 175 | + } | |
| 176 | + } | |
| 177 | + drop(statement); | |
| 178 | + | |
| 179 | + let mut statement = db.prepare(&format!( | |
| 180 | + "SELECT file_id, name, kind, line, is_exported, signature FROM symbols | |
| 181 | + WHERE file_id IN ({placeholders}) ORDER BY is_exported DESC, line" | |
| 182 | + ))?; | |
| 183 | + let rows = statement.query_map(params_from_iter(&ids), |row| { | |
| 184 | + Ok(( | |
| 185 | + row.get::<_, i64>(0)?, | |
| 186 | + GenomeSymbol { | |
| 187 | + name: row.get(1)?, | |
| 188 | + kind: row.get(2)?, | |
| 189 | + line: row.get::<_, i64>(3)?.max(0) as u64, | |
| 190 | + exported: row.get::<_, i64>(4)? != 0, | |
| 191 | + signature: row.get(5)?, | |
| 192 | + }, | |
| 193 | + )) | |
| 194 | + })?; | |
| 195 | + for row in rows { | |
| 196 | + let (id, symbol) = row?; | |
| 197 | + if let Some(&ix) = index.get(&id) | |
| 198 | + && files[ix].symbols.len() < FILE_SYMBOLS | |
| 199 | + { | |
| 200 | + files[ix].symbols.push(symbol); | |
| 201 | + } | |
| 202 | + } | |
| 203 | + } | |
| 204 | + | |
| 205 | + let layout = layout(&files, &edges); | |
| 206 | + Ok(Some(Genome { total_files, total_symbols, total_edges, files, edges, layout })) | |
| 207 | + } | |
| 208 | + | |
| 209 | + /// Places the files with a force-directed layout: imports pull files | |
| 210 | + /// together, every pair pushes apart, and higher ranked files start nearer | |
| 211 | + /// the middle. Deterministic, so the map holds still between reloads. | |
| 212 | + pub fn layout(files: &[GenomeFile], edges: &[GenomeEdge]) -> Vec<(f32, f32)> { | |
| 213 | + let n = files.len(); | |
| 214 | + if n == 0 { | |
| 215 | + return Vec::new(); | |
| 216 | + } | |
| 217 | + if n == 1 { | |
| 218 | + return vec![(0.5, 0.5)]; | |
| 219 | + } | |
| 220 | + // A spiral by rank: the most important file in the middle. | |
| 221 | + let golden = std::f32::consts::PI * (3. - 5f32.sqrt()); | |
| 222 | + let mut pos: Vec<(f32, f32)> = (0..n) | |
| 223 | + .map(|ix| { | |
| 224 | + let radius = ((ix as f32 + 0.5) / n as f32).sqrt() * 0.45; | |
| 225 | + let angle = ix as f32 * golden; | |
| 226 | + (radius * angle.cos(), radius * angle.sin()) | |
| 227 | + }) | |
| 228 | + .collect(); | |
| 229 | + let mut edges_of = vec![0usize; n]; | |
| 230 | + for edge in edges { | |
| 231 | + edges_of[edge.from] += 1; | |
| 232 | + edges_of[edge.to] += 1; | |
| 233 | + } | |
| 234 | + let k = (1. / n as f32).sqrt() * 1.1; | |
| 235 | + let mut temperature = 0.1; | |
| 236 | + for _ in 0..400 { | |
| 237 | + let mut shift = vec![(0f32, 0f32); n]; | |
| 238 | + for a in 0..n { | |
| 239 | + for b in a + 1..n { | |
| 240 | + let (dx, dy) = (pos[a].0 - pos[b].0, pos[a].1 - pos[b].1); | |
| 241 | + let distance = (dx * dx + dy * dy).sqrt().max(0.001); | |
| 242 | + let force = k * k / distance; | |
| 243 | + let (fx, fy) = (dx / distance * force, dy / distance * force); | |
| 244 | + shift[a].0 += fx; | |
| 245 | + shift[a].1 += fy; | |
| 246 | + shift[b].0 -= fx; | |
| 247 | + shift[b].1 -= fy; | |
| 248 | + } | |
| 249 | + } | |
| 250 | + for edge in edges { | |
| 251 | + let (a, b) = (edge.from, edge.to); | |
| 252 | + let (dx, dy) = (pos[a].0 - pos[b].0, pos[a].1 - pos[b].1); | |
| 253 | + let distance = (dx * dx + dy * dy).sqrt().max(0.001); | |
| 254 | + // Hubs have many lines; each pulls less, so they don't swallow the map. | |
| 255 | + let degree = (edges_of[a] + edges_of[b]) as f32; | |
| 256 | + let force = distance * distance / k * (1. + (edge.weight as f32).ln_1p() * 0.2) / degree.sqrt().max(1.); | |
| 257 | + let (fx, fy) = (dx / distance * force, dy / distance * force); | |
| 258 | + shift[a].0 -= fx; | |
| 259 | + shift[a].1 -= fy; | |
| 260 | + shift[b].0 += fx; | |
| 261 | + shift[b].1 += fy; | |
| 262 | + } | |
| 263 | + for (ix, (dx, dy)) in shift.into_iter().enumerate() { | |
| 264 | + // A pull to the middle keeps files nothing imports from drifting off. | |
| 265 | + let gravity = 2.5 * k; | |
| 266 | + let (dx, dy) = (dx - pos[ix].0 * gravity, dy - pos[ix].1 * gravity); | |
| 267 | + let length = (dx * dx + dy * dy).sqrt().max(0.0001); | |
| 268 | + let step = length.min(temperature); | |
| 269 | + pos[ix].0 += dx / length * step; | |
| 270 | + pos[ix].1 += dy / length * step; | |
| 271 | + } | |
| 272 | + temperature *= 0.988; | |
| 273 | + } | |
| 274 | + // A few files far out would shrink everything else: bring the farthest | |
| 275 | + // in to just outside the rest. | |
| 276 | + let mut distances: Vec<f32> = pos.iter().map(|(x, y)| (x * x + y * y).sqrt()).collect(); | |
| 277 | + distances.sort_by(f32::total_cmp); | |
| 278 | + let edge = distances[(n * 9 / 10).min(n - 1)] * 1.15; | |
| 279 | + for (x, y) in &mut pos { | |
| 280 | + let distance = (*x * *x + *y * *y).sqrt(); | |
| 281 | + if distance > edge { | |
| 282 | + let squeeze = (edge + (distance - edge) * 0.2) / distance; | |
| 283 | + *x *= squeeze; | |
| 284 | + *y *= squeeze; | |
| 285 | + } | |
| 286 | + } | |
| 287 | + // Fit into the unit square. | |
| 288 | + let (min_x, max_x) = pos.iter().fold((f32::MAX, f32::MIN), |(lo, hi), (x, _)| (lo.min(*x), hi.max(*x))); | |
| 289 | + let (min_y, max_y) = pos.iter().fold((f32::MAX, f32::MIN), |(lo, hi), (_, y)| (lo.min(*y), hi.max(*y))); | |
| 290 | + let span = (max_x - min_x).max(max_y - min_y).max(0.0001); | |
| 291 | + let (off_x, off_y) = ((1. - (max_x - min_x) / span) / 2., (1. - (max_y - min_y) / span) / 2.); | |
| 292 | + pos.into_iter().map(|(x, y)| ((x - min_x) / span + off_x, (y - min_y) / span + off_y)).collect() | |
| 293 | + } | |
| 294 | + | |
| 295 | + #[cfg(test)] | |
| 296 | + mod tests { | |
| 297 | + use super::{GenomeEdge, GenomeFile, layout, load}; | |
| 298 | + | |
| 299 | + fn file(path: &str, rank: f64) -> GenomeFile { | |
| 300 | + GenomeFile { | |
| 301 | + path: path.into(), | |
| 302 | + language: "rust".into(), | |
| 303 | + lines: 10, | |
| 304 | + symbol_count: 1, | |
| 305 | + rank, | |
| 306 | + dependents: 0, | |
| 307 | + dependencies: 0, | |
| 308 | + recent: false, | |
| 309 | + symbols: Vec::new(), | |
| 310 | + } | |
| 311 | + } | |
| 312 | + | |
| 313 | + #[test] | |
| 314 | + fn layout_fits_the_square() { | |
| 315 | + let files: Vec<GenomeFile> = (0..20).map(|ix| file(&format!("f{ix}.rs"), 1. / (ix as f64 + 1.))).collect(); | |
| 316 | + let edges: Vec<GenomeEdge> = (1..20).map(|ix| GenomeEdge { from: ix, to: 0, weight: 1. }).collect(); | |
| 317 | + let pos = layout(&files, &edges); | |
| 318 | + assert_eq!(pos.len(), 20); | |
| 319 | + assert!(pos.iter().all(|(x, y)| (0. ..=1.).contains(x) && (0. ..=1.).contains(y))); | |
| 320 | + // No two files on top of each other. | |
| 321 | + for a in 0..pos.len() { | |
| 322 | + for b in a + 1..pos.len() { | |
| 323 | + let distance = ((pos[a].0 - pos[b].0).powi(2) + (pos[a].1 - pos[b].1).powi(2)).sqrt(); | |
| 324 | + assert!(distance > 0.01, "{a} and {b} overlap"); | |
| 325 | + } | |
| 326 | + } | |
| 327 | + assert_eq!(layout(&files[..1], &[]), vec![(0.5, 0.5)]); | |
| 328 | + } | |
| 329 | + | |
| 330 | + #[test] | |
| 331 | + fn reads_a_genome() { | |
| 332 | + let dir = std::env::temp_dir().join(format!("vectrum-genome-test-{}", std::process::id())); | |
| 333 | + let sub = dir.join("src"); | |
| 334 | + std::fs::create_dir_all(dir.join(".empryo")).unwrap(); | |
| 335 | + std::fs::create_dir_all(&sub).unwrap(); | |
| 336 | + let db = rusqlite::Connection::open(dir.join(".empryo/genome.db")).unwrap(); | |
| 337 | + db.execute_batch( | |
| 338 | + "PRAGMA journal_mode = WAL; | |
| 339 | + CREATE TABLE files (id INTEGER PRIMARY KEY, path TEXT, mtime_ms REAL, language TEXT, line_count INTEGER, | |
| 340 | + symbol_count INTEGER, pagerank REAL); | |
| 341 | + CREATE TABLE symbols (id INTEGER PRIMARY KEY, file_id INTEGER, name TEXT, kind TEXT, line INTEGER, | |
| 342 | + end_line INTEGER, is_exported INTEGER, signature TEXT); | |
| 343 | + CREATE TABLE edges (source_file_id INTEGER, target_file_id INTEGER, weight REAL); | |
| 344 | + INSERT INTO files VALUES (1, 'src/main.ts', 0, 'typescript', 4, 2, 0.35), (2, 'src/math.ts', 0, 'typescript', 3, 2, 0.65); | |
| 345 | + INSERT INTO symbols VALUES (1, 1, 'main', 'function', 3, 3, 1, NULL), (2, 2, 'get', 'method', 2, 2, 0, NULL), | |
| 346 | + (3, 2, 'add', 'function', 1, 1, 1, 'export function add(a, b)'); | |
| 347 | + INSERT INTO edges VALUES (1, 2, 4.5);", | |
| 348 | + ) | |
| 349 | + .unwrap(); | |
| 350 | + let genome = load(&sub).unwrap().unwrap(); | |
| 351 | + assert_eq!((genome.total_files, genome.total_symbols, genome.total_edges), (2, 3, 1)); | |
| 352 | + assert_eq!(genome.files[0].path, "src/math.ts"); | |
| 353 | + assert_eq!(genome.files[0].dependents, 1); | |
| 354 | + assert_eq!(genome.files[1].dependencies, 1); | |
| 355 | + assert_eq!(genome.files[0].symbols[0].name, "add"); | |
| 356 | + assert!(genome.files[0].symbols[0].exported); | |
| 357 | + assert_eq!(genome.edges.len(), 1); | |
| 358 | + assert_eq!((genome.edges[0].from, genome.edges[0].to), (1, 0)); | |
| 359 | + drop(db); | |
| 360 | + let _ = std::fs::remove_dir_all(&dir); | |
| 361 | + assert!(load(&std::env::temp_dir().join("vectrum-genome-none")).unwrap().is_none()); | |
| 362 | + } | |
| 363 | + } | |
| @@ -7,6 +7,7 @@ mod browser; | ||
| 7 | 7 | mod claude; |
| 8 | 8 | mod clock; |
| 9 | 9 | mod device; |
| 10 | + mod genome; | |
| 10 | 11 | mod git; |
| 11 | 12 | mod http; |
| 12 | 13 | mod inline_ui; |
| @@ -29,16 +29,18 @@ pub enum Provider { | ||
| 29 | 29 | Claude, |
| 30 | 30 | Pi, |
| 31 | 31 | Jcode, |
| 32 | + Empryo, | |
| 32 | 33 | } |
| 33 | 34 | |
| 34 | 35 | impl Provider { |
| 35 | - pub const ALL: [Provider; 3] = [Provider::Claude, Provider::Pi, Provider::Jcode]; | |
| 36 | + pub const ALL: [Provider; 4] = [Provider::Claude, Provider::Pi, Provider::Jcode, Provider::Empryo]; | |
| 36 | 37 | |
| 37 | 38 | pub fn label(self) -> &'static str { |
| 38 | 39 | match self { |
| 39 | 40 | Provider::Claude => "Claude", |
| 40 | 41 | Provider::Pi => "Pi", |
| 41 | 42 | Provider::Jcode => "jcode", |
| 43 | + Provider::Empryo => "Empryo", | |
| 42 | 44 | } |
| 43 | 45 | } |
| 44 | 46 | |
| @@ -48,6 +50,7 @@ impl Provider { | ||
| 48 | 50 | Provider::Claude => "Claude Code", |
| 49 | 51 | Provider::Pi => "Pi", |
| 50 | 52 | Provider::Jcode => "jcode", |
| 53 | + Provider::Empryo => "Empryo", | |
| 51 | 54 | } |
| 52 | 55 | } |
| 53 | 56 | |
| @@ -56,6 +59,7 @@ impl Provider { | ||
| 56 | 59 | Provider::Claude => "claude", |
| 57 | 60 | Provider::Pi => "pi", |
| 58 | 61 | Provider::Jcode => "jcode", |
| 62 | + Provider::Empryo => "empryo", | |
| 59 | 63 | } |
| 60 | 64 | } |
| 61 | 65 | |
| @@ -65,11 +69,17 @@ impl Provider { | ||
| 65 | 69 | self != Provider::Jcode |
| 66 | 70 | } |
| 67 | 71 | |
| 68 | - /// Whether the agent asks before running tools. Pi and jcode just run them. | |
| 72 | + /// Whether the agent asks before running tools. Pi, jcode and Empryo | |
| 73 | + /// just run them. | |
| 69 | 74 | pub fn asks_permission(self) -> bool { |
| 70 | 75 | self == Provider::Claude |
| 71 | 76 | } |
| 72 | 77 | |
| 78 | + /// Whether the agent has a plan mode the composer can switch to. | |
| 79 | + pub fn has_plan_mode(self) -> bool { | |
| 80 | + matches!(self, Provider::Claude | Provider::Empryo) | |
| 81 | + } | |
| 82 | + | |
| 73 | 83 | pub fn from_key(key: &str) -> Option<Self> { |
| 74 | 84 | Self::ALL.into_iter().find(|provider| provider.key() == key) |
| 75 | 85 | } |
| @@ -365,6 +375,7 @@ impl Catalog { | ||
| 365 | 375 | Provider::Claude => probe_claude(&config), |
| 366 | 376 | Provider::Pi => probe_pi(&config), |
| 367 | 377 | Provider::Jcode => probe_jcode(&config), |
| 378 | + Provider::Empryo => probe_empryo(&config), | |
| 368 | 379 | }; |
| 369 | 380 | let latest = if update_checks { latest_version(config.kind) } else { None }; |
| 370 | 381 | (probe, latest) |
| @@ -884,8 +895,125 @@ fn jcode_model_spec(model: &str, provider: &str, method: &str) -> String { | ||
| 884 | 895 | } |
| 885 | 896 | } |
| 886 | 897 | |
| 898 | + /// Asks `empryo --list-models` for the models of the providers Empryo has | |
| 899 | + /// a key or a sign-in for, and reads the model new threads start on from | |
| 900 | + /// its config. | |
| 901 | + fn probe_empryo(config: &ProviderInstance) -> Result<Probe, ProbeStatus> { | |
| 902 | + let failed = |err: &dyn std::fmt::Display| ProbeStatus::Failed(err.to_string()); | |
| 903 | + let (_, mut command) = base_command(config).map_err(|_| ProbeStatus::Missing)?; | |
| 904 | + command | |
| 905 | + .arg("--list-models") | |
| 906 | + .env("EMPRYO_NO_PROMPT", "1") | |
| 907 | + .current_dir(std::env::temp_dir()) | |
| 908 | + .stdin(Stdio::null()) | |
| 909 | + .stdout(Stdio::piped()) | |
| 910 | + .stderr(Stdio::null()); | |
| 911 | + let mut child = command.spawn().map_err(|err| failed(&err))?; | |
| 912 | + let stdout = child.stdout.take().ok_or_else(|| failed(&"no stdout"))?; | |
| 913 | + let (tx, rx) = mpsc::channel(); | |
| 914 | + std::thread::spawn(move || { | |
| 915 | + let mut listing = String::new(); | |
| 916 | + let _ = std::io::Read::read_to_string(&mut BufReader::new(stdout), &mut listing); | |
| 917 | + let _ = tx.send(listing); | |
| 918 | + }); | |
| 919 | + // Each provider's list may come from its API. | |
| 920 | + let listing = rx.recv_timeout(Duration::from_secs(60)); | |
| 921 | + let _ = child.kill(); | |
| 922 | + let _ = child.wait(); | |
| 923 | + let listing = listing.map_err(|_| failed(&"empryo didn't list its models"))?; | |
| 924 | + let models = empryo_models_from(&config.id, &listing); | |
| 925 | + let version = base_command(config).ok().and_then(|(_, mut command)| { | |
| 926 | + let output = command.arg("--version").stdin(Stdio::null()).stderr(Stdio::null()).output().ok()?; | |
| 927 | + // `empryo 3.9.4-beta` | |
| 928 | + let text = String::from_utf8_lossy(&output.stdout).to_string(); | |
| 929 | + text.split_whitespace().find(|word| word.starts_with(|c: char| c.is_ascii_digit())).map(str::to_string) | |
| 930 | + }); | |
| 931 | + let plan = empryo_default_model(config).map(|model| format!("Default {model}")); | |
| 932 | + Ok(Probe { models, plan, version, ..Probe::default() }) | |
| 933 | + } | |
| 934 | + | |
| 935 | + /// The models in `empryo --list-models`' listing: a line per provider, | |
| 936 | + /// `Anthropic (anthropic)`, then one per model, ` anthropic/claude-opus-5 1000k ctx …`. | |
| 937 | + pub fn empryo_models_from(instance: &str, listing: &str) -> Vec<ModelEntry> { | |
| 938 | + let mut models: Vec<ModelEntry> = Vec::new(); | |
| 939 | + let mut provider = String::new(); | |
| 940 | + for line in listing.lines().map(strip_ansi) { | |
| 941 | + if line.trim().is_empty() { | |
| 942 | + continue; | |
| 943 | + } | |
| 944 | + if !line.starts_with(' ') { | |
| 945 | + // `Fake (fake) [custom]` | |
| 946 | + provider = line.split(" (").next().unwrap_or_default().trim().to_string(); | |
| 947 | + continue; | |
| 948 | + } | |
| 949 | + let mut words = line.split_whitespace(); | |
| 950 | + let Some(id) = words.next().filter(|id| id.contains('/')) else { continue }; | |
| 951 | + if models.iter().any(|known| known.id == id) { | |
| 952 | + continue; | |
| 953 | + } | |
| 954 | + let words: Vec<&str> = words.collect(); | |
| 955 | + let context_window = words.windows(2).find(|pair| pair[1] == "ctx").and_then(|pair| { | |
| 956 | + let size = pair[0]; | |
| 957 | + let (number, scale) = match size.chars().last()? { | |
| 958 | + 'k' | 'K' => (&size[..size.len() - 1], 1_000.), | |
| 959 | + 'M' | 'm' => (&size[..size.len() - 1], 1_000_000.), | |
| 960 | + _ => (size, 1.), | |
| 961 | + }; | |
| 962 | + number.parse::<f64>().ok().map(|number| (number * scale) as u64) | |
| 963 | + }); | |
| 964 | + let name = id.split_once('/').map_or(id, |(_, model)| model); | |
| 965 | + models.push(ModelEntry { | |
| 966 | + provider: Provider::Empryo, | |
| 967 | + instance: instance.to_string(), | |
| 968 | + id: id.to_string(), | |
| 969 | + resolved: name.to_string(), | |
| 970 | + name: name.to_string(), | |
| 971 | + detail: provider.clone(), | |
| 972 | + legacy: false, | |
| 973 | + // `--effort` takes these; a model without reasoning ignores it. | |
| 974 | + efforts: vec![Effort::Low, Effort::Medium, Effort::High], | |
| 975 | + fast_mode: false, | |
| 976 | + auto_mode: false, | |
| 977 | + context_window, | |
| 978 | + }); | |
| 979 | + } | |
| 980 | + models | |
| 981 | + } | |
| 982 | + | |
| 983 | + /// The model Empryo starts on when none is picked, from its global config. | |
| 984 | + fn empryo_default_model(config: &ProviderInstance) -> Option<String> { | |
| 985 | + let home = match config.home.trim() { | |
| 986 | + "" => std::env::var_os("HOME").map(std::path::PathBuf::from)?, | |
| 987 | + home => settings::expand_home(home), | |
| 988 | + }; | |
| 989 | + let text = std::fs::read_to_string(home.join(".empryo/config.json")).ok()?; | |
| 990 | + let config: Value = serde_json::from_str(&text).ok()?; | |
| 991 | + config.get("defaultModel").and_then(Value::as_str).filter(|model| !model.is_empty()).map(str::to_string) | |
| 992 | + } | |
| 993 | + | |
| 994 | + /// `text` without its terminal color codes. | |
| 995 | + fn strip_ansi(text: &str) -> String { | |
| 996 | + let mut out = String::with_capacity(text.len()); | |
| 997 | + let mut chars = text.chars(); | |
| 998 | + while let Some(c) = chars.next() { | |
| 999 | + if c == '\u{1b}' { | |
| 1000 | + // CSI sequences end with a letter. | |
| 1001 | + if chars.next() == Some('[') { | |
| 1002 | + for c in chars.by_ref() { | |
| 1003 | + if c.is_ascii_alphabetic() { | |
| 1004 | + break; | |
| 1005 | + } | |
| 1006 | + } | |
| 1007 | + } | |
| 1008 | + continue; | |
| 1009 | + } | |
| 1010 | + out.push(c); | |
| 1011 | + } | |
| 1012 | + out | |
| 1013 | + } | |
| 1014 | + | |
| 887 | 1015 | /// The newest published version of an agent, from the npm registry (or |
| 888 | - /// GitHub, for jcode). | |
| 1016 | + /// GitHub, for jcode, and Empryo's download site). | |
| 889 | 1017 | fn latest_version(kind: Provider) -> Option<String> { |
| 890 | 1018 | let package = match kind { |
| 891 | 1019 | Provider::Claude => "@anthropic-ai/claude-code", |
| @@ -896,6 +1024,10 @@ fn latest_version(kind: Provider) -> Option<String> { | ||
| 896 | 1024 | let tag = serde_json::from_str::<Value>(&body).ok()?.get("tag_name")?.as_str()?.to_string(); |
| 897 | 1025 | return Some(tag.trim_start_matches('v').to_string()); |
| 898 | 1026 | } |
| 1027 | + Provider::Empryo => { | |
| 1028 | + let body = crate::http::get("https://dl.empryo.com/latest.txt", &[], Duration::from_secs(10)).ok()?; | |
| 1029 | + return Some(body.trim().trim_start_matches('v').to_string()).filter(|version| !version.is_empty()); | |
| 1030 | + } | |
| 899 | 1031 | }; |
| 900 | 1032 | let body = crate::http::get(&format!("https://registry.npmjs.org/{package}/latest"), &[], Duration::from_secs(10)).ok()?; |
| 901 | 1033 | serde_json::from_str::<Value>(&body).ok()?.get("version")?.as_str().map(str::to_string) |
| @@ -920,6 +1052,15 @@ pub fn update_command(config: &ProviderInstance) -> Option<(std::process::Comman | ||
| 920 | 1052 | command.args(["update"]); |
| 921 | 1053 | Some((command, "jcode update")) |
| 922 | 1054 | } |
| 1055 | + Provider::Empryo => { | |
| 1056 | + // Empryo updates by running its installer again. | |
| 1057 | + let mut command = std::process::Command::new("sh"); | |
| 1058 | + command | |
| 1059 | + .args(["-c", "curl -fsSL https://empryo.com/install.sh | bash"]) | |
| 1060 | + .env("EMPRYO_QUIET", "1") | |
| 1061 | + .env("EMPRYO_NO_PROMPT", "1"); | |
| 1062 | + Some((command, "curl -fsSL https://empryo.com/install.sh | bash")) | |
| 1063 | + } | |
| 923 | 1064 | } |
| 924 | 1065 | } |
| 925 | 1066 | |
| @@ -959,7 +1100,7 @@ fn provider_label(provider: &str) -> String { | ||
| 959 | 1100 | |
| 960 | 1101 | #[cfg(test)] |
| 961 | 1102 | mod tests { |
| 962 | - use super::{claude_probe_from, first_version, jcode_model_from, provider_label, version_newer}; | |
| 1103 | + use super::{claude_probe_from, empryo_models_from, first_version, jcode_model_from, provider_label, version_newer}; | |
| 963 | 1104 | use serde_json::json; |
| 964 | 1105 | |
| 965 | 1106 | #[::core::prelude::v1::test] |
| @@ -1028,4 +1169,19 @@ mod tests { | ||
| 1028 | 1169 | assert_eq!(jcode_model_from("jcode", &route("auto", "auto", "openrouter", true)).unwrap().id, "auto"); |
| 1029 | 1170 | assert!(jcode_model_from("jcode", &route("claude-opus-5", "Anthropic", "claude-api", false)).is_none()); |
| 1030 | 1171 | } |
| 1172 | + | |
| 1173 | + #[::core::prelude::v1::test] | |
| 1174 | + fn empryo_listing() { | |
| 1175 | + let listing = "\u{1b}[1m\u{1b}[35mAnthropic\u{1b}[0m \u{1b}[2m(anthropic)\u{1b}[0m\n anthropic/claude-opus-5 1000k ctx $5/$25/M\n anthropic/claude-haiku-5\n\nFake (fake) [custom]\n fake/fake-1 128k ctx $3/$15/M (cache r$0.3/w$3.75) ~est\n (model fetch failed: offline)\n"; | |
| 1176 | + let models = empryo_models_from("empryo", listing); | |
| 1177 | + assert_eq!(models.len(), 3); | |
| 1178 | + assert_eq!(models[0].id, "anthropic/claude-opus-5"); | |
| 1179 | + assert_eq!(models[0].name, "claude-opus-5"); | |
| 1180 | + assert_eq!(models[0].detail, "Anthropic"); | |
| 1181 | + assert_eq!(models[0].context_window, Some(1_000_000)); | |
| 1182 | + assert_eq!(models[1].context_window, None); | |
| 1183 | + assert_eq!(models[2].detail, "Fake"); | |
| 1184 | + assert_eq!(models[2].key(), "empryo:fake/fake-1"); | |
| 1185 | + assert_eq!(models[2].context_window, Some(128_000)); | |
| 1186 | + } | |
| 1031 | 1187 | } |
| @@ -587,6 +587,8 @@ pub fn wait_limit(provider: crate::models::Provider) -> Duration { | ||
| 587 | 587 | crate::models::Provider::Claude => Duration::from_secs(30 * 60), |
| 588 | 588 | crate::models::Provider::Pi => Duration::from_secs(270), |
| 589 | 589 | crate::models::Provider::Jcode => Duration::from_secs(100), |
| 590 | + // Empryo threads don't get the app's tools. | |
| 591 | + crate::models::Provider::Empryo => Duration::from_secs(270), | |
| 590 | 592 | } |
| 591 | 593 | } |
| 592 | 594 | |
| @@ -22,7 +22,7 @@ use std::{ | ||
| 22 | 22 | use tungstenite::{Message, WebSocket}; |
| 23 | 23 | |
| 24 | 24 | use super::{ |
| 25 | - ClientMessage, DEFAULT_PORT, PROTOCOL, RemoteFileDiff, Route, RouteKind, ServerMessage, tailnet_peers, tailnet_routes, | |
| 25 | + ClientMessage, DEFAULT_PORT, GenomeAnswer, PROTOCOL, RemoteFileDiff, Route, RouteKind, ServerMessage, tailnet_peers, tailnet_routes, | |
| 26 | 26 | }; |
| 27 | 27 | |
| 28 | 28 | /// How long to wait for a route that goes over the internet or a VPN. |
| @@ -70,6 +70,8 @@ pub struct RemoteClient { | ||
| 70 | 70 | pub events: smol::channel::Receiver<ClientEvent>, |
| 71 | 71 | /// Diffs asked for and not answered yet, by request. |
| 72 | 72 | diffs: Mutex<HashMap<u64, smol::channel::Sender<Result<Vec<RemoteFileDiff>, String>>>>, |
| 73 | + /// Genomes asked for and not answered yet, by request. | |
| 74 | + genomes: Mutex<HashMap<u64, smol::channel::Sender<GenomeAnswer>>>, | |
| 73 | 75 | next_request: AtomicU64, |
| 74 | 76 | } |
| 75 | 77 | |
| @@ -144,7 +146,7 @@ impl RemoteClient { | ||
| 144 | 146 | } |
| 145 | 147 | }); |
| 146 | 148 | } |
| 147 | - Self { outbox, stop, routes, events, diffs: Mutex::default(), next_request: AtomicU64::new(1) } | |
| 149 | + Self { outbox, stop, routes, events, diffs: Mutex::default(), genomes: Mutex::default(), next_request: AtomicU64::new(1) } | |
| 148 | 150 | } |
| 149 | 151 | |
| 150 | 152 | /// Asks the host for the diff between two of a thread's snapshots. The |
| @@ -178,6 +180,35 @@ impl RemoteClient { | ||
| 178 | 180 | self.diffs.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(&request); |
| 179 | 181 | } |
| 180 | 182 | |
| 183 | + /// Asks the host for Empryo's Genome of a thread's checkout. The answer | |
| 184 | + /// arrives on the returned channel once [`Self::resolve_genome`] gets it; | |
| 185 | + /// older hosts never answer. | |
| 186 | + pub fn request_genome( | |
| 187 | + &self, | |
| 188 | + thread: Option<String>, | |
| 189 | + project: std::path::PathBuf, | |
| 190 | + since: Option<u64>, | |
| 191 | + ) -> (u64, smol::channel::Receiver<GenomeAnswer>) { | |
| 192 | + let request = self.next_request.fetch_add(1, Ordering::Relaxed); | |
| 193 | + let (tx, rx) = smol::channel::bounded(1); | |
| 194 | + self.genomes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).insert(request, tx); | |
| 195 | + self.send(&ClientMessage::Genome { request, thread, project, since }); | |
| 196 | + (request, rx) | |
| 197 | + } | |
| 198 | + | |
| 199 | + /// Hands a Genome reply to the request waiting for it. | |
| 200 | + pub fn resolve_genome(&self, request: u64, answer: GenomeAnswer) { | |
| 201 | + let waiting = self.genomes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(&request); | |
| 202 | + if let Some(waiting) = waiting { | |
| 203 | + let _ = waiting.try_send(answer); | |
| 204 | + } | |
| 205 | + } | |
| 206 | + | |
| 207 | + /// Forgets a Genome request that stopped waiting. | |
| 208 | + pub fn forget_genome(&self, request: u64) { | |
| 209 | + self.genomes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(&request); | |
| 210 | + } | |
| 211 | + | |
| 181 | 212 | /// Changes the routes without dropping the connection; the next try, |
| 182 | 213 | /// and the next look for a faster route, use them. |
| 183 | 214 | pub fn set_routes(&self, routes: Vec<Route>) { |
| @@ -22,7 +22,8 @@ | ||
| 22 | 22 | //! the host's [`device_proxy`] on the same port. Clients without a catalog |
| 23 | 23 | //! of their own, like the Android app, ask for the host's agents and models |
| 24 | 24 | //! with [`ClientMessage::Catalog`] and for a turn's diff with |
| 25 | - //! [`ClientMessage::Diff`]. | |
| 25 | + //! [`ClientMessage::Diff`]. A client's Genome map shows the host's Empryo | |
| 26 | + //! Genome, read there on [`ClientMessage::Genome`]. | |
| 26 | 27 | //! |
| 27 | 28 | //! A host can be reached more than one way, like T3 Code's routes: over the |
| 28 | 29 | //! LAN at home and over Tailscale or another VPN away from it. The host |
| @@ -247,6 +248,11 @@ pub enum ClientMessage { | ||
| 247 | 248 | /// `base` and `head`), for one file or all of them. Older hosts don't |
| 248 | 249 | /// answer. |
| 249 | 250 | Diff { request: u64, thread: String, base: String, head: String, path: Option<String>, ignore_whitespace: bool }, |
| 251 | + /// Asks for Empryo's Genome of a thread's checkout (the project's, for a | |
| 252 | + /// thread not created yet), answered with [`ServerMessage::GenomeReply`]. | |
| 253 | + /// `since` is when the Genome the client has was written, so an | |
| 254 | + /// unchanged one isn't sent again. Older hosts don't answer. | |
| 255 | + Genome { request: u64, thread: Option<String>, project: PathBuf, since: Option<u64> }, | |
| 250 | 256 | /// Updates the host's app to the newest release and restarts it, all in |
| 251 | 257 | /// one; it answers with [`ServerMessage::UpdateStatus`] until it goes |
| 252 | 258 | /// down. Only hosts that say they're `updatable` take it. |
| @@ -304,12 +310,28 @@ pub enum ServerMessage { | ||
| 304 | 310 | /// The answer to a [`ClientMessage::Diff`]: the changed files, or what |
| 305 | 311 | /// went wrong. |
| 306 | 312 | DiffReply { request: u64, files: Vec<RemoteFileDiff>, error: Option<String> }, |
| 313 | + /// The answer to a [`ClientMessage::Genome`]. | |
| 314 | + GenomeReply { request: u64, answer: GenomeAnswer }, | |
| 307 | 315 | /// How an update a client asked for goes: `checking`, `downloading` |
| 308 | 316 | /// (with `progress`, 0 to 1), `restarting`, or `failed` with `error`. |
| 309 | 317 | /// The connection drops when the host restarts. |
| 310 | 318 | UpdateStatus { state: String, progress: Option<f32>, error: Option<String> }, |
| 311 | 319 | } |
| 312 | 320 | |
| 321 | + /// What a host found of a checkout's Genome. | |
| 322 | + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 323 | + #[serde(default)] | |
| 324 | + pub struct GenomeAnswer { | |
| 325 | + /// When Empryo last wrote it, in milliseconds since the epoch; `None` | |
| 326 | + /// when there's no Genome yet. | |
| 327 | + pub written: Option<u64>, | |
| 328 | + /// The map, unless it's unchanged since the client's or missing. | |
| 329 | + pub genome: Option<Box<crate::genome::Genome>>, | |
| 330 | + /// It's the one the client has. | |
| 331 | + pub unchanged: bool, | |
| 332 | + pub error: Option<String>, | |
| 333 | + } | |
| 334 | + | |
| 313 | 335 | /// A provider instance on the host and what its last health check found. |
| 314 | 336 | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] |
| 315 | 337 | #[serde(default)] |
| @@ -1083,6 +1105,49 @@ mod tests { | ||
| 1083 | 1105 | assert_eq!((back.lines[1].text.as_str(), back.lines[1].new_line), ("new", Some(1))); |
| 1084 | 1106 | } |
| 1085 | 1107 | |
| 1108 | + #[::core::prelude::v1::test] | |
| 1109 | + fn genome_messages_round_trip() { | |
| 1110 | + let message = ClientMessage::Genome { request: 3, thread: Some("t1".into()), project: "/repo".into(), since: Some(42) }; | |
| 1111 | + let text = serde_json::to_string(&message).unwrap(); | |
| 1112 | + assert_eq!(serde_json::from_str::<ClientMessage>(&text).unwrap(), message); | |
| 1113 | + let genome = crate::genome::Genome { | |
| 1114 | + total_files: 1, | |
| 1115 | + total_symbols: 1, | |
| 1116 | + total_edges: 0, | |
| 1117 | + files: vec![crate::genome::GenomeFile { | |
| 1118 | + path: "src/a.rs".into(), | |
| 1119 | + language: "rust".into(), | |
| 1120 | + lines: 3, | |
| 1121 | + symbol_count: 1, | |
| 1122 | + rank: 1., | |
| 1123 | + dependents: 0, | |
| 1124 | + dependencies: 0, | |
| 1125 | + recent: true, | |
| 1126 | + symbols: vec![crate::genome::GenomeSymbol { | |
| 1127 | + name: "a".into(), | |
| 1128 | + kind: "function".into(), | |
| 1129 | + line: 1, | |
| 1130 | + exported: true, | |
| 1131 | + signature: Some("pub fn a()".into()), | |
| 1132 | + }], | |
| 1133 | + }], | |
| 1134 | + edges: Vec::new(), | |
| 1135 | + layout: vec![(0.5, 0.5)], | |
| 1136 | + }; | |
| 1137 | + let answer = GenomeAnswer { written: Some(42), genome: Some(Box::new(genome)), ..GenomeAnswer::default() }; | |
| 1138 | + let text = serde_json::to_string(&ServerMessage::GenomeReply { request: 3, answer: answer.clone() }).unwrap(); | |
| 1139 | + match serde_json::from_str::<ServerMessage>(&text).unwrap() { | |
| 1140 | + ServerMessage::GenomeReply { request: 3, answer: received } => assert_eq!(received, answer), | |
| 1141 | + _ => panic!("not a Genome reply"), | |
| 1142 | + } | |
| 1143 | + // An unchanged Genome comes back as just that. | |
| 1144 | + let text = r#"{"type": "genome_reply", "request": 4, "answer": {"written": 42, "unchanged": true}}"#; | |
| 1145 | + match serde_json::from_str::<ServerMessage>(text).unwrap() { | |
| 1146 | + ServerMessage::GenomeReply { answer, .. } => assert!(answer.unchanged && answer.genome.is_none()), | |
| 1147 | + _ => panic!("not a Genome reply"), | |
| 1148 | + } | |
| 1149 | + } | |
| 1150 | + | |
| 1086 | 1151 | #[::core::prelude::v1::test] |
| 1087 | 1152 | fn client_and_server_talk() { |
| 1088 | 1153 | use std::time::Duration; |
| @@ -827,6 +827,24 @@ struct JcodeLink { | ||
| 827 | 827 | instructions_sent: bool, |
| 828 | 828 | } |
| 829 | 829 | |
| 830 | + /// Where a thread's Empryo chat stands. Empryo takes a message per line and | |
| 831 | + /// streams each turn's events; it has no requests to keep track of. | |
| 832 | + #[derive(Default)] | |
| 833 | + struct EmpryoLink { | |
| 834 | + /// The session id a new chat was started under, until Empryo is up and | |
| 835 | + /// it can be resumed. | |
| 836 | + new_session: Option<String>, | |
| 837 | + /// Empryo said it's ready for a message since the process started. | |
| 838 | + ready: bool, | |
| 839 | + /// The answer the text streaming now goes into. | |
| 840 | + text: Option<usize>, | |
| 841 | + /// The model or effort changed mid-turn: the next turn starts a | |
| 842 | + /// process with the new flags. | |
| 843 | + relaunch: bool, | |
| 844 | + /// The user stopped the turn, so the process is on its way out. | |
| 845 | + stopping: bool, | |
| 846 | + } | |
| 847 | + | |
| 830 | 848 | pub struct Session { |
| 831 | 849 | /// Names the thread's file on disk. |
| 832 | 850 | pub id: String, |
| @@ -854,8 +872,8 @@ pub struct Session { | ||
| 854 | 872 | pub items: Vec<ChatItem>, |
| 855 | 873 | pub status: SessionStatus, |
| 856 | 874 | pub thinking: bool, |
| 857 | - /// What a restarted process resumes: Claude's or jcode's session id, or | |
| 858 | - /// Pi's session file. | |
| 875 | + /// What a restarted process resumes: Claude's, jcode's or Empryo's | |
| 876 | + /// session id, or Pi's session file. | |
| 859 | 877 | resume_id: Option<String>, |
| 860 | 878 | /// The model picked for the thread; `None` means the agent's default. |
| 861 | 879 | pub model: Option<String>, |
| @@ -942,6 +960,7 @@ pub struct Session { | ||
| 942 | 960 | pending_controls: HashMap<String, PendingControl>, |
| 943 | 961 | next_request_id: u64, |
| 944 | 962 | jcode: JcodeLink, |
| 963 | + empryo: EmpryoLink, | |
| 945 | 964 | _events_task: Task<()>, |
| 946 | 965 | } |
| 947 | 966 | |
| @@ -1100,6 +1119,7 @@ impl Session { | ||
| 1100 | 1119 | pending_controls: HashMap::new(), |
| 1101 | 1120 | next_request_id: 0, |
| 1102 | 1121 | jcode: JcodeLink::default(), |
| 1122 | + empryo: EmpryoLink::default(), | |
| 1103 | 1123 | _events_task: Task::ready(()), |
| 1104 | 1124 | } |
| 1105 | 1125 | } |
| @@ -1124,6 +1144,11 @@ impl Session { | ||
| 1124 | 1144 | |
| 1125 | 1145 | fn start(&mut self, resume: Option<String>, cx: &mut Context<Self>) { |
| 1126 | 1146 | let (tx, rx) = smol::channel::unbounded(); |
| 1147 | + // Empryo starts a new session under an id of the thread's choosing. | |
| 1148 | + let new_session = (self.provider == Provider::Empryo && resume.is_none()).then(|| { | |
| 1149 | + let millis = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis(); | |
| 1150 | + format!("vectrum-{}-{millis:x}", self.id) | |
| 1151 | + }); | |
| 1127 | 1152 | let config = LaunchConfig { |
| 1128 | 1153 | instance: settings::instance(&self.instance, self.provider), |
| 1129 | 1154 | cwd: self.cwd.clone(), |
| @@ -1131,6 +1156,7 @@ impl Session { | ||
| 1131 | 1156 | permission_mode: self.permission_mode, |
| 1132 | 1157 | effort: self.reasoning.effort(), |
| 1133 | 1158 | resume: resume.clone(), |
| 1159 | + new_session: new_session.clone(), | |
| 1134 | 1160 | browser_session: settings::get_for(&self.project, |settings| settings.agent_browser) |
| 1135 | 1161 | .then(|| browser::session_name(&self.id)), |
| 1136 | 1162 | thread: self.id.clone(), |
| @@ -1194,6 +1220,8 @@ impl Session { | ||
| 1194 | 1220 | ); |
| 1195 | 1221 | } |
| 1196 | 1222 | } |
| 1223 | + // Model, effort and session went in as flags. | |
| 1224 | + Provider::Empryo => self.empryo = EmpryoLink { new_session, ..EmpryoLink::default() }, | |
| 1197 | 1225 | } |
| 1198 | 1226 | |
| 1199 | 1227 | self._events_task = cx.spawn(async move |this, cx| { |
| @@ -1546,6 +1574,11 @@ impl Session { | ||
| 1546 | 1574 | let mut images = Vec::new(); |
| 1547 | 1575 | let mut file_paths = Vec::new(); |
| 1548 | 1576 | for path in &attachments { |
| 1577 | + // Empryo's chat takes text only; it reads images from their path. | |
| 1578 | + if self.provider == Provider::Empryo { | |
| 1579 | + file_paths.push(path.display().to_string()); | |
| 1580 | + continue; | |
| 1581 | + } | |
| 1549 | 1582 | match image_media_type(path) { |
| 1550 | 1583 | Some(media_type) => match read_image(path) { |
| 1551 | 1584 | Ok(data) => images.push((media_type, data)), |
| @@ -1639,6 +1672,8 @@ impl Session { | ||
| 1639 | 1672 | self.jcode_request("send the message", request); |
| 1640 | 1673 | } |
| 1641 | 1674 | } |
| 1675 | + // Mid-turn, Empryo holds the message for its next turn. | |
| 1676 | + Provider::Empryo => process.send_line(empryo_line(&prompt)), | |
| 1642 | 1677 | } |
| 1643 | 1678 | self.status = SessionStatus::Working; |
| 1644 | 1679 | } |
| @@ -2134,6 +2169,14 @@ impl Session { | ||
| 2134 | 2169 | Provider::Claude => self.control(PendingControl::Interrupt, json!({"subtype": "interrupt"})), |
| 2135 | 2170 | Provider::Pi => self.pi(json!({"type": "abort"})), |
| 2136 | 2171 | Provider::Jcode => self.jcode_request("stop the turn", json!({"req": "cancel"})), |
| 2172 | + // Empryo stops the turn, saves the session and exits; the next | |
| 2173 | + // message resumes it. | |
| 2174 | + Provider::Empryo => { | |
| 2175 | + if let Some(process) = &self.process { | |
| 2176 | + self.empryo.stopping = true; | |
| 2177 | + process.interrupt(); | |
| 2178 | + } | |
| 2179 | + } | |
| 2137 | 2180 | } |
| 2138 | 2181 | cx.notify(); |
| 2139 | 2182 | } |
| @@ -2163,16 +2206,43 @@ impl Session { | ||
| 2163 | 2206 | } |
| 2164 | 2207 | } |
| 2165 | 2208 | Provider::Jcode => {} |
| 2209 | + Provider::Empryo => self.relaunch_empryo(), | |
| 2166 | 2210 | } |
| 2167 | 2211 | cx.notify(); |
| 2168 | 2212 | } |
| 2169 | 2213 | |
| 2214 | + /// Empryo takes its model and effort as flags: a process that's idle | |
| 2215 | + /// stops, so the next message starts one with the new ones, and a busy | |
| 2216 | + /// one stops after its turn. | |
| 2217 | + fn relaunch_empryo(&mut self) { | |
| 2218 | + if self.process.is_none() { | |
| 2219 | + return; | |
| 2220 | + } | |
| 2221 | + if self.is_working() || self.resume_id.is_none() { | |
| 2222 | + self.empryo.relaunch = true; | |
| 2223 | + return; | |
| 2224 | + } | |
| 2225 | + self.process.take(); | |
| 2226 | + self._events_task = Task::ready(()); | |
| 2227 | + if self.status != SessionStatus::Exited { | |
| 2228 | + self.status = SessionStatus::Idle; | |
| 2229 | + } | |
| 2230 | + } | |
| 2231 | + | |
| 2170 | 2232 | pub fn set_permission_mode(&mut self, mode: PermissionMode, cx: &mut Context<Self>) { |
| 2171 | 2233 | if self.forward(ThreadAction::SetPermissionMode { mode: mode.as_cli().into() }) { |
| 2172 | 2234 | self.permission_mode = mode; |
| 2173 | 2235 | cx.notify(); |
| 2174 | 2236 | return; |
| 2175 | 2237 | } |
| 2238 | + // Empryo plans in its plan mode, a launch flag, and otherwise runs | |
| 2239 | + // its tools without asking. | |
| 2240 | + if self.provider == Provider::Empryo { | |
| 2241 | + self.permission_mode = mode; | |
| 2242 | + self.relaunch_empryo(); | |
| 2243 | + cx.notify(); | |
| 2244 | + return; | |
| 2245 | + } | |
| 2176 | 2246 | // Pi and jcode have no permission prompts to configure. |
| 2177 | 2247 | if !self.provider.asks_permission() { |
| 2178 | 2248 | return; |
| @@ -2255,6 +2325,7 @@ impl Session { | ||
| 2255 | 2325 | } |
| 2256 | 2326 | } |
| 2257 | 2327 | Provider::Jcode => {} |
| 2328 | + Provider::Empryo => self.relaunch_empryo(), | |
| 2258 | 2329 | } |
| 2259 | 2330 | cx.notify(); |
| 2260 | 2331 | } |
| @@ -2604,6 +2675,7 @@ impl Session { | ||
| 2604 | 2675 | Provider::Claude => self.handle_message(message, cx), |
| 2605 | 2676 | Provider::Pi => self.handle_pi_message(message, cx), |
| 2606 | 2677 | Provider::Jcode => self.handle_jcode_message(message, cx), |
| 2678 | + Provider::Empryo => self.handle_empryo_message(message, cx), | |
| 2607 | 2679 | }, |
| 2608 | 2680 | ProcessEvent::Stderr(line) => { |
| 2609 | 2681 | if self.stderr_tail.len() == STDERR_TAIL { |
| @@ -2616,6 +2688,9 @@ impl Session { | ||
| 2616 | 2688 | } |
| 2617 | 2689 | |
| 2618 | 2690 | fn handle_exit(&mut self, code: Option<i32>, cx: &mut Context<Self>) { |
| 2691 | + if self.provider == Provider::Empryo && self.empryo_exit(code, cx) { | |
| 2692 | + return; | |
| 2693 | + } | |
| 2619 | 2694 | self.flush_text(cx); |
| 2620 | 2695 | self.process = None; |
| 2621 | 2696 | self.drop_background_tasks(); |
| @@ -3678,6 +3753,184 @@ impl Session { | ||
| 3678 | 3753 | } |
| 3679 | 3754 | } |
| 3680 | 3755 | |
| 3756 | + // ---- Empryo (headless chat events) -------------------------------- | |
| 3757 | + | |
| 3758 | + fn handle_empryo_message(&mut self, message: Value, cx: &mut Context<Self>) { | |
| 3759 | + let text = |key: &str| message.get(key).and_then(Value::as_str).unwrap_or_default(); | |
| 3760 | + let number = |key: &str| message.get(key).and_then(Value::as_u64); | |
| 3761 | + match text("type") { | |
| 3762 | + "start" => { | |
| 3763 | + if let Some(model) = message.get("model").and_then(Value::as_str) { | |
| 3764 | + self.active_model = Some(model.to_string()); | |
| 3765 | + } | |
| 3766 | + } | |
| 3767 | + // Before each message it takes, the first time once the session | |
| 3768 | + // is loaded and there's something to resume. | |
| 3769 | + "ready" => { | |
| 3770 | + self.empryo.ready = true; | |
| 3771 | + if let Some(session) = self.empryo.new_session.take() { | |
| 3772 | + self.resume_id = Some(session); | |
| 3773 | + } | |
| 3774 | + if self.status == SessionStatus::Starting { | |
| 3775 | + self.status = SessionStatus::Idle; | |
| 3776 | + } | |
| 3777 | + } | |
| 3778 | + "turn-start" => self.begin_turn(cx), | |
| 3779 | + "text-start" => self.empryo.text = None, | |
| 3780 | + "text" => { | |
| 3781 | + self.begin_turn(cx); | |
| 3782 | + self.thinking = false; | |
| 3783 | + let ix = match self.empryo.text { | |
| 3784 | + Some(ix) => ix, | |
| 3785 | + None => { | |
| 3786 | + let ix = self.push_assistant_text(""); | |
| 3787 | + self.empryo.text = Some(ix); | |
| 3788 | + ix | |
| 3789 | + } | |
| 3790 | + }; | |
| 3791 | + self.append_text(ix, text("content"), cx); | |
| 3792 | + } | |
| 3793 | + "text-end" => { | |
| 3794 | + if let Some(ix) = self.empryo.text.take() { | |
| 3795 | + self.flush_item(ix, cx); | |
| 3796 | + } | |
| 3797 | + } | |
| 3798 | + "reasoning" => { | |
| 3799 | + self.begin_turn(cx); | |
| 3800 | + self.thinking = true; | |
| 3801 | + } | |
| 3802 | + "tool-call" => { | |
| 3803 | + self.begin_turn(cx); | |
| 3804 | + self.thinking = false; | |
| 3805 | + if let Some(ix) = self.empryo.text.take() { | |
| 3806 | + self.flush_item(ix, cx); | |
| 3807 | + } | |
| 3808 | + let input = message.get("input").cloned().unwrap_or(Value::Null); | |
| 3809 | + match self.find_tool_mut(text("toolCallId")) { | |
| 3810 | + Some(tool) => tool.input = input, | |
| 3811 | + None => { | |
| 3812 | + self.push_tool(text("toolCallId"), text("tool"), input); | |
| 3813 | + } | |
| 3814 | + } | |
| 3815 | + } | |
| 3816 | + "tool-result" => { | |
| 3817 | + let failed = message.get("ok").and_then(Value::as_bool) == Some(false); | |
| 3818 | + let summary = message | |
| 3819 | + .get("summary") | |
| 3820 | + .and_then(Value::as_str) | |
| 3821 | + .or_else(|| message.get("error").and_then(Value::as_str)) | |
| 3822 | + .unwrap_or_default() | |
| 3823 | + .to_string(); | |
| 3824 | + let tool = match self.find_tool_mut(text("toolCallId")) { | |
| 3825 | + Some(tool) => tool, | |
| 3826 | + None => { | |
| 3827 | + let ix = self.push_tool(text("toolCallId"), text("tool"), Value::Null); | |
| 3828 | + let Some(ChatItem::Tool(tool)) = self.items.get_mut(ix) else { return }; | |
| 3829 | + tool | |
| 3830 | + } | |
| 3831 | + }; | |
| 3832 | + tool.state = if failed { ToolState::Error } else { ToolState::Done }; | |
| 3833 | + tool.result = Some(summary); | |
| 3834 | + } | |
| 3835 | + "context-status" => { | |
| 3836 | + if let Some(tokens) = number("tokens").filter(|tokens| *tokens > 0) { | |
| 3837 | + self.context_tokens = Some(tokens); | |
| 3838 | + } | |
| 3839 | + if let Some(window) = number("contextWindow").filter(|window| *window > 0) { | |
| 3840 | + self.context_window = Some(window); | |
| 3841 | + } | |
| 3842 | + } | |
| 3843 | + // Every model call, the agent's own and its helpers'. | |
| 3844 | + "model-call" if text("lane") == "main" && text("state") == "ok" => { | |
| 3845 | + if let Some(model) = message.get("modelId").and_then(Value::as_str) { | |
| 3846 | + self.active_model = Some(model.to_string()); | |
| 3847 | + } | |
| 3848 | + let output = number("output").unwrap_or(0); | |
| 3849 | + let secs = number("durationMs").unwrap_or(0) as f64 / 1000.; | |
| 3850 | + // Too short a response says little about the model's speed. | |
| 3851 | + if output >= 16 && secs >= 0.25 { | |
| 3852 | + self.tokens_per_sec = Some(output as f64 / secs); | |
| 3853 | + } | |
| 3854 | + } | |
| 3855 | + "warning" => self.push_notice(NoticeKind::Info, text("message").to_string()), | |
| 3856 | + // Stopping a turn reports it as aborted. | |
| 3857 | + "error" if self.interrupt_requested => {} | |
| 3858 | + "error" => { | |
| 3859 | + let error = text("error"); | |
| 3860 | + self.push_notice(NoticeKind::Error, if error.is_empty() { "Empryo ran into an error." } else { error }.to_string()); | |
| 3861 | + } | |
| 3862 | + "turn-done" => { | |
| 3863 | + self.thinking = false; | |
| 3864 | + self.status = SessionStatus::Idle; | |
| 3865 | + if let Some(ix) = self.empryo.text.take() { | |
| 3866 | + self.flush_item(ix, cx); | |
| 3867 | + } | |
| 3868 | + if std::mem::take(&mut self.interrupt_requested) { | |
| 3869 | + self.push_notice(NoticeKind::Info, "Interrupted."); | |
| 3870 | + } | |
| 3871 | + self.finish_open_tools("The turn ended before the tool finished."); | |
| 3872 | + let cost = message.get("cost").and_then(Value::as_f64).filter(|cost| *cost > 0.); | |
| 3873 | + self.total_cost += cost.unwrap_or(0.); | |
| 3874 | + // Empryo's own count leaves out waiting for the Genome. | |
| 3875 | + let duration_ms = self | |
| 3876 | + .turn_started | |
| 3877 | + .map(|start| start.elapsed().as_millis() as u64) | |
| 3878 | + .or_else(|| number("duration")) | |
| 3879 | + .unwrap_or(0); | |
| 3880 | + self.finish_turn(duration_ms, cost, cx); | |
| 3881 | + if std::mem::take(&mut self.empryo.relaunch) { | |
| 3882 | + self.relaunch_empryo(); | |
| 3883 | + } | |
| 3884 | + self.send_next_queued(cx); | |
| 3885 | + } | |
| 3886 | + _ => {} | |
| 3887 | + } | |
| 3888 | + } | |
| 3889 | + | |
| 3890 | + /// Handles Empryo's process ending where that's part of how it works: | |
| 3891 | + /// stopping a turn ends the chat (Ctrl+C takes it down), and so does an | |
| 3892 | + /// idle chat. Returns whether it did; other exits are reported as | |
| 3893 | + /// failures. | |
| 3894 | + fn empryo_exit(&mut self, code: Option<i32>, cx: &mut Context<Self>) -> bool { | |
| 3895 | + // The turn may have ended, clearing the request, before the exit. | |
| 3896 | + let interrupted = std::mem::take(&mut self.interrupt_requested) | std::mem::take(&mut self.empryo.stopping); | |
| 3897 | + let ready = std::mem::take(&mut self.empryo.ready); | |
| 3898 | + self.empryo.text = None; | |
| 3899 | + // A session it couldn't open: carry on in a new one. | |
| 3900 | + if !ready && !interrupted && self.resume_id.is_some() && code == Some(1) { | |
| 3901 | + self.resume_id = None; | |
| 3902 | + self.process = None; | |
| 3903 | + self.thinking = false; | |
| 3904 | + self.push_notice( | |
| 3905 | + NoticeKind::Info, | |
| 3906 | + "Empryo couldn't reopen this thread's session; the next message starts a new one.", | |
| 3907 | + ); | |
| 3908 | + if self.is_working() { | |
| 3909 | + self.status = SessionStatus::Idle; | |
| 3910 | + self.turn_started = None; | |
| 3911 | + self.base_snapshot = None; | |
| 3912 | + } | |
| 3913 | + return true; | |
| 3914 | + } | |
| 3915 | + if !(interrupted || code == Some(0)) { | |
| 3916 | + return false; | |
| 3917 | + } | |
| 3918 | + self.flush_text(cx); | |
| 3919 | + self.process = None; | |
| 3920 | + self.thinking = false; | |
| 3921 | + self.finish_open_tools("The turn was stopped before the tool finished."); | |
| 3922 | + if self.is_working() { | |
| 3923 | + // It went before saying the turn was done. | |
| 3924 | + self.push_notice(NoticeKind::Info, "Interrupted."); | |
| 3925 | + let duration_ms = self.turn_started.map_or(0, |start| start.elapsed().as_millis() as u64); | |
| 3926 | + self.status = SessionStatus::Idle; | |
| 3927 | + self.finish_turn(duration_ms, None, cx); | |
| 3928 | + } | |
| 3929 | + self.status = SessionStatus::Idle; | |
| 3930 | + self.send_next_queued(cx); | |
| 3931 | + true | |
| 3932 | + } | |
| 3933 | + | |
| 3681 | 3934 | /// Registers the app's tools with the thread's jcode session (a |
| 3682 | 3935 | /// subagent's thread gets none) and switches off jcode's own ways of |
| 3683 | 3936 | /// spawning agents. jcode keeps the setup in memory, so it's sent on |
| @@ -3689,6 +3942,10 @@ impl Session { | ||
| 3689 | 3942 | |
| 3690 | 3943 | /// Which of the app's tools this thread's agent gets. |
| 3691 | 3944 | pub fn tool_caps(&self) -> orchestrator::Caps { |
| 3945 | + // Empryo only loads MCP servers from its own config files. | |
| 3946 | + if self.provider == Provider::Empryo { | |
| 3947 | + return orchestrator::Caps::default(); | |
| 3948 | + } | |
| 3692 | 3949 | orchestrator::Caps { delegate: self.delegation.is_none(), devices: crate::device::agent_access_for(&self.project) } |
| 3693 | 3950 | } |
| 3694 | 3951 | |
| @@ -4915,6 +5172,25 @@ pub fn tool_field(input: &Value, keys: &[&str]) -> Option<String> { | ||
| 4915 | 5172 | } |
| 4916 | 5173 | |
| 4917 | 5174 | /// A one-line description of what a tool call is doing. |
| 5175 | + /// A message as one line of Empryo's chat: each line but the last ends in a | |
| 5176 | + /// backslash, which continues the message on the next. A line of the message | |
| 5177 | + /// that ends in a backslash itself gets a space after it. | |
| 5178 | + fn empryo_line(text: &str) -> String { | |
| 5179 | + let lines: Vec<&str> = text.trim().lines().map(|line| line.trim_end_matches('\r')).collect(); | |
| 5180 | + let last = lines.len().saturating_sub(1); | |
| 5181 | + let mut out = String::with_capacity(text.len() + lines.len()); | |
| 5182 | + for (ix, line) in lines.iter().enumerate() { | |
| 5183 | + out.push_str(line); | |
| 5184 | + if line.ends_with('\\') { | |
| 5185 | + out.push(' '); | |
| 5186 | + } | |
| 5187 | + if ix < last { | |
| 5188 | + out.push_str("\\\n"); | |
| 5189 | + } | |
| 5190 | + } | |
| 5191 | + out | |
| 5192 | + } | |
| 5193 | + | |
| 4918 | 5194 | pub fn tool_summary(name: &str, input: &Value, cwd: &std::path::Path) -> String { |
| 4919 | 5195 | let field = |keys: &[&str]| tool_field(input, keys); |
| 4920 | 5196 | let path = |keys: &[&str]| { |
| @@ -4926,8 +5202,16 @@ pub fn tool_summary(name: &str, input: &Value, cwd: &std::path::Path) -> String | ||
| 4926 | 5202 | }) |
| 4927 | 5203 | }; |
| 4928 | 5204 | let summary = match name.to_ascii_lowercase().as_str() { |
| 4929 | - "bash" => field(&["command"]), | |
| 4930 | - "read" | "write" | "edit" | "multiedit" => path(&["file_path", "path"]), | |
| 5205 | + "bash" | "shell" => field(&["command"]), | |
| 5206 | + "read" | "write" | "edit" | "multiedit" | "edit_file" | "multi_edit" | "ast_edit" => { | |
| 5207 | + path(&["file_path", "path"]).or_else(|| { | |
| 5208 | + // Empryo reads several files at once. | |
| 5209 | + let files = input.get("files")?.as_array()?; | |
| 5210 | + let paths: Vec<String> = files.iter().filter_map(|file| tool_field(file, &["path"])).collect(); | |
| 5211 | + (!paths.is_empty()).then(|| paths.join(", ")) | |
| 5212 | + }) | |
| 5213 | + } | |
| 5214 | + "list_dir" => path(&["path"]), | |
| 4931 | 5215 | "notebookedit" => path(&["notebook_path"]), |
| 4932 | 5216 | "glob" | "grep" | "find" => field(&["pattern", "query"]), |
| 4933 | 5217 | "ls" => path(&["path"]), |
| @@ -4956,7 +5240,10 @@ pub fn tool_summary(name: &str, input: &Value, cwd: &std::path::Path) -> String | ||
| 4956 | 5240 | |
| 4957 | 5241 | #[cfg(test)] |
| 4958 | 5242 | mod tests { |
| 4959 | - use super::{AssistantText, TaskStatus, ToolCall, ToolState, finished_prefix, jcode_agent_status, pi_subagent_results}; | |
| 5243 | + use super::{ | |
| 5244 | + AssistantText, TaskStatus, ToolCall, ToolState, empryo_line, finished_prefix, jcode_agent_status, pi_subagent_results, | |
| 5245 | + tool_summary, | |
| 5246 | + }; | |
| 4960 | 5247 | use serde_json::{Value, json}; |
| 4961 | 5248 | |
| 4962 | 5249 | fn tool(name: &str, input: Value) -> ToolCall { |
| @@ -5039,4 +5326,20 @@ mod tests { | ||
| 5039 | 5326 | assert!(answer.view.is_none()); |
| 5040 | 5327 | assert_eq!(answer.text, "First.\n\nSecond"); |
| 5041 | 5328 | } |
| 5329 | + | |
| 5330 | + #[test] | |
| 5331 | + fn empryo_lines() { | |
| 5332 | + assert_eq!(empryo_line("hi"), "hi"); | |
| 5333 | + assert_eq!(empryo_line("one\r\n\nthree\n"), "one\\\n\\\nthree"); | |
| 5334 | + // A line ending in a backslash would otherwise run on. | |
| 5335 | + assert_eq!(empryo_line("C:\\\nnext"), "C:\\ \\\nnext"); | |
| 5336 | + } | |
| 5337 | + | |
| 5338 | + #[test] | |
| 5339 | + fn empryo_tool_summaries() { | |
| 5340 | + let cwd = std::path::Path::new("/repo"); | |
| 5341 | + assert_eq!(tool_summary("read", &json!({"files": [{"path": "a.rs"}, {"path": "b.rs"}]}), cwd), "a.rs, b.rs"); | |
| 5342 | + assert_eq!(tool_summary("shell", &json!({"command": "cargo test"}), cwd), "cargo test"); | |
| 5343 | + assert_eq!(tool_summary("edit_file", &json!({"path": "/repo/src/x.rs"}), cwd), "src/x.rs"); | |
| 5344 | + } | |
| 5042 | 5345 | } |
| @@ -208,6 +208,17 @@ fn generate(project: &Path, prompt: &str) -> Option<String> { | ||
| 208 | 208 | } |
| 209 | 209 | } |
| 210 | 210 | Provider::Jcode => return None, |
| 211 | + Provider::Empryo => { | |
| 212 | + // Without the project, and in a folder of its own: Empryo keeps | |
| 213 | + // a `.empryo` folder wherever it runs. | |
| 214 | + let dir = std::env::temp_dir().join("vectrum-code-empryo"); | |
| 215 | + let _ = std::fs::create_dir_all(&dir); | |
| 216 | + command.args(["--headless", "--bare", "--quiet", "--no-render", "--max-steps", "3", "--model", model]); | |
| 217 | + command.arg("--cwd").arg(dir).env("EMPRYO_NO_PROMPT", "1"); | |
| 218 | + if let Some(effort) = effort.filter(|effort| *effort >= Effort::Low) { | |
| 219 | + command.args(["--effort", effort.as_cli()]); | |
| 220 | + } | |
| 221 | + } | |
| 211 | 222 | } |
| 212 | 223 | let output = command |
| 213 | 224 | .arg(prompt) |
| @@ -145,13 +145,19 @@ fn present_call(name: &str, input: &Value, cwd: &Path) -> Presentation { | ||
| 145 | 145 | return Presentation { icon: Icon::new(Lucide::Smartphone), label, action: Action::Other }; |
| 146 | 146 | } |
| 147 | 147 | let (action, label) = match name.to_ascii_lowercase().as_str() { |
| 148 | - "bash" => (Action::Command, field(&["command"]).map(first_line).unwrap_or_else(|| "Ran command".into())), | |
| 148 | + "bash" | "shell" => (Action::Command, field(&["command"]).map(first_line).unwrap_or_else(|| "Ran command".into())), | |
| 149 | 149 | "bashoutput" | "killshell" => (Action::Command, name.to_string()), |
| 150 | 150 | "read" | "notebookread" => ( |
| 151 | 151 | Action::Read, |
| 152 | - field(&["file_path", "path", "notebook_path"]).map(|path| format!("Read {}", relative_path(&path, cwd))).unwrap_or_else(|| "Read file".into()), | |
| 152 | + field(&["file_path", "path", "notebook_path"]) | |
| 153 | + .map(|path| relative_path(&path, cwd)) | |
| 154 | + // Empryo's read takes a list of files. | |
| 155 | + .or_else(|| Some(crate::session::tool_summary(name, input, cwd)).filter(|paths| !paths.is_empty())) | |
| 156 | + .map(|path| format!("Read {path}")) | |
| 157 | + .unwrap_or_else(|| "Read file".into()), | |
| 153 | 158 | ), |
| 154 | - "write" | "edit" | "multiedit" | "notebookedit" => ( | |
| 159 | + // Empryo's edits, by path or by symbol. | |
| 160 | + "edit_file" | "multi_edit" | "ast_edit" | "write" | "edit" | "multiedit" | "notebookedit" => ( | |
| 155 | 161 | Action::Edit, |
| 156 | 162 | field(&["file_path", "path", "notebook_path"]).map(|path| relative_path(&path, cwd)).unwrap_or_else(|| "Changed a file".into()), |
| 157 | 163 | ), |
| @@ -159,6 +165,15 @@ fn present_call(name: &str, input: &Value, cwd: &Path) -> Presentation { | ||
| 159 | 165 | Action::CodeSearch, |
| 160 | 166 | field(&["pattern", "query"]).map(|pattern| format!("Searched for {}", first_line(pattern))).unwrap_or_else(|| "Searched code".into()), |
| 161 | 167 | ), |
| 168 | + // Empryo's queries of its Genome and language servers. | |
| 169 | + "genome_query" | "genome_analyze" | "genome_impact" | "navigate" | "analyze" => { | |
| 170 | + let summary = crate::session::tool_summary(name, input, cwd); | |
| 171 | + let what = if name.starts_with("genome") { "Genome" } else { "Code intelligence" }; | |
| 172 | + (Action::CodeSearch, if summary.is_empty() { what.to_string() } else { format!("{what}: {summary}") }) | |
| 173 | + } | |
| 174 | + "list_dir" => (Action::CodeSearch, field(&["path"]).map(|path| format!("Listed {}", relative_path(&path, cwd))).unwrap_or_else(|| "Listed files".into())), | |
| 175 | + "web_search" => (Action::WebSearch, field(&["query"]).map(|query| format!("Web search: {query}")).unwrap_or_else(|| "Web search".into())), | |
| 176 | + "fetch_page" => (Action::WebSearch, field(&["url"]).unwrap_or_else(|| "Fetched a page".into())), | |
| 162 | 177 | "ls" => (Action::CodeSearch, field(&["path"]).map(|path| format!("Listed {}", relative_path(&path, cwd))).unwrap_or_else(|| "Listed files".into())), |
| 163 | 178 | "websearch" => (Action::WebSearch, field(&["query"]).map(|query| format!("Web search: {query}")).unwrap_or_else(|| "Web search".into())), |
| 164 | 179 | "webfetch" => (Action::WebSearch, field(&["url"]).unwrap_or_else(|| "Fetched a page".into())), |
| @@ -21,6 +21,7 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [ | ||
| 21 | 21 | Code, |
| 22 | 22 | Feather, |
| 23 | 23 | CornerDownRight, |
| 24 | + Dna, | |
| 24 | 25 | FileCode, |
| 25 | 26 | FileDiff, |
| 26 | 27 | FolderGit2, |
| @@ -28,6 +28,7 @@ pub fn provider_icon(provider: Provider, size: Pixels, p: &Palette) -> Icon { | ||
| 28 | 28 | Provider::Claude => Icon::new(IconName::Asterisk).size(size).text_color(rgba(CLAUDE_ORANGE)), |
| 29 | 29 | Provider::Pi => Icon::new(Lucide::Pi).size(size).text_color(p.ink), |
| 30 | 30 | Provider::Jcode => Icon::new(Lucide::Feather).size(size).text_color(p.ink), |
| 31 | + Provider::Empryo => Icon::new(Lucide::Dna).size(size).text_color(p.ink), | |
| 31 | 32 | } |
| 32 | 33 | } |
| 33 | 34 | |
| @@ -139,7 +140,8 @@ pub fn traits_items( | ||
| 139 | 140 | let mut items = Vec::new(); |
| 140 | 141 | if !levels.is_empty() || provider == Provider::Claude { |
| 141 | 142 | items.push(composer::menu_label(if provider == Provider::Pi { "Thinking" } else { "Reasoning" }, p).into_any_element()); |
| 142 | - if default.is_none() && provider == Provider::Claude { | |
| 143 | + // Empryo leaves effort to the model unless it's told one. | |
| 144 | + if default.is_none() && matches!(provider, Provider::Claude | Provider::Empryo) { | |
| 143 | 145 | items.push(item("traits-default".into(), Reasoning::Default, "Default", None, true)); |
| 144 | 146 | } |
| 145 | 147 | for (ix, level) in levels.into_iter().enumerate() { |
| @@ -493,7 +495,7 @@ impl ModelPicker { | ||
| 493 | 495 | let locked = !state.items.is_empty() && model.instance != state.instance; |
| 494 | 496 | let detail = match model.provider { |
| 495 | 497 | Provider::Claude => instance, |
| 496 | - Provider::Pi | Provider::Jcode => format!("{instance} · {}", model.detail), | |
| 498 | + Provider::Pi | Provider::Jcode | Provider::Empryo => format!("{instance} · {}", model.detail), | |
| 497 | 499 | }; |
| 498 | 500 | h_flex() |
| 499 | 501 | .id(SharedString::from(format!("model-{}", model.key()))) |
| @@ -593,6 +595,9 @@ impl ModelPicker { | ||
| 593 | 595 | } |
| 594 | 596 | (ProbeStatus::Missing, Provider::Claude) => "Open provider setup to install Claude on this environment.".into(), |
| 595 | 597 | (ProbeStatus::Missing, Provider::Jcode) => "Open provider setup to install jcode on this environment.".into(), |
| 598 | + (ProbeStatus::Missing, Provider::Empryo) => { | |
| 599 | + "Open provider setup to install Empryo on this environment: curl -fsSL https://empryo.com/install.sh | bash".into() | |
| 600 | + } | |
| 596 | 601 | (ProbeStatus::Failed(error), _) => format!("{label} provider is unavailable. {error}"), |
| 597 | 602 | (ProbeStatus::Ready, _) => "No models are available for this provider.".into(), |
| 598 | 603 | }; |
| @@ -134,7 +134,7 @@ impl SettingsView { | ||
| 134 | 134 | .row( |
| 135 | 135 | row("Permissions") |
| 136 | 136 | .id("default-permissions") |
| 137 | - .description("Default permissions for new threads. Pi and jcode threads always run with full access.") | |
| 137 | + .description("Default permissions for new threads. Pi, jcode and Empryo threads always run with full access.") | |
| 138 | 138 | .reset(reset(&view, cx, |s| s.default_access.clone(), |s, access| s.default_access = access)) |
| 139 | 139 | .control(rich_choice("default-access", current_access, permissions, &view, |settings, mode| { |
| 140 | 140 | settings.default_access = mode.map(|mode: PermissionMode| mode.as_cli().to_string()) |
| @@ -92,8 +92,10 @@ fn status_headline(state: &InstanceState) -> (Led, String) { | ||
| 92 | 92 | match state.kind() { |
| 93 | 93 | Provider::Claude if state.account.is_some() => parts.push("Authenticated".to_string()), |
| 94 | 94 | Provider::Claude => parts.push("Not authenticated".to_string()), |
| 95 | - Provider::Pi | Provider::Jcode if state.models.is_empty() => parts.push("Needs attention · No models configured".into()), | |
| 96 | - Provider::Pi | Provider::Jcode => parts.push("Available".to_string()), | |
| 95 | + Provider::Pi | Provider::Jcode | Provider::Empryo if state.models.is_empty() => { | |
| 96 | + parts.push("Needs attention · No models configured".into()) | |
| 97 | + } | |
| 98 | + Provider::Pi | Provider::Jcode | Provider::Empryo => parts.push("Available".to_string()), | |
| 97 | 99 | } |
| 98 | 100 | parts.extend(state.plan.clone()); |
| 99 | 101 | let led = if parts[0].starts_with("Not") || parts[0].starts_with("Needs") { Led::Waiting } else { Led::Live }; |
| @@ -147,6 +149,7 @@ fn home_title(kind: Provider) -> &'static str { | ||
| 147 | 149 | Provider::Claude => "CLAUDE_CONFIG_DIR path", |
| 148 | 150 | Provider::Pi => "PI_CODING_AGENT_DIR path", |
| 149 | 151 | Provider::Jcode => "JCODE_HOME path", |
| 152 | + Provider::Empryo => "HOME path", | |
| 150 | 153 | } |
| 151 | 154 | } |
| 152 | 155 | |
| @@ -155,6 +158,7 @@ fn home_placeholder(kind: Provider) -> &'static str { | ||
| 155 | 158 | Provider::Claude => "~/.claude", |
| 156 | 159 | Provider::Pi => "~/.pi/agent", |
| 157 | 160 | Provider::Jcode => "~/.jcode", |
| 161 | + Provider::Empryo => "~/empryo-home", | |
| 158 | 162 | } |
| 159 | 163 | } |
| 160 | 164 | |
| @@ -163,6 +167,7 @@ fn custom_placeholder(kind: Provider) -> &'static str { | ||
| 163 | 167 | Provider::Claude => "claude-sonnet-5", |
| 164 | 168 | Provider::Pi => "anthropic/claude-sonnet-5", |
| 165 | 169 | Provider::Jcode => "claude-oauth:claude-opus-5", |
| 170 | + Provider::Empryo => "anthropic/claude-sonnet-5", | |
| 166 | 171 | } |
| 167 | 172 | } |
| 168 | 173 | |
| @@ -824,6 +829,10 @@ impl SettingsView { | ||
| 824 | 829 | (Provider::Claude, None, _) => "Not signed in. Run claude in a terminal and log in, then refresh.".into(), |
| 825 | 830 | (Provider::Pi, _, _) => "Pi signs in to model providers itself: run pi in a terminal and use /login.".into(), |
| 826 | 831 | (Provider::Jcode, _, _) => "jcode signs in itself: run jcode login in a terminal.".into(), |
| 832 | + (Provider::Empryo, _, _) => { | |
| 833 | + "Empryo signs in itself: run empryo --login claude (or codex, copilot…), or save a key with empryo --set-key <provider> <key>." | |
| 834 | + .into() | |
| 835 | + } | |
| 827 | 836 | }; |
| 828 | 837 | let setup = Section::new("Setup").row(row("Account").description(account)).render(cx); |
| 829 | 838 | |
| @@ -836,6 +845,7 @@ impl SettingsView { | ||
| 836 | 845 | Provider::Claude => "Path to the Claude binary used by this instance.", |
| 837 | 846 | Provider::Pi => "Path to the Pi coding agent binary.", |
| 838 | 847 | Provider::Jcode => "Path to the jcode binary used by this instance.", |
| 848 | + Provider::Empryo => "Path to the empryo binary used by this instance.", | |
| 839 | 849 | }) |
| 840 | 850 | .status(binary_status) |
| 841 | 851 | .control(text_field(&form.binary, 224., window, cx)), |
| @@ -846,6 +856,9 @@ impl SettingsView { | ||
| 846 | 856 | Provider::Claude => "Custom Claude home and config directory. Keeps .claude.json and .claude separate.", |
| 847 | 857 | Provider::Pi => "Custom Pi agent directory, so this instance keeps its own logins, settings and sessions.", |
| 848 | 858 | Provider::Jcode => "Custom jcode home, so this instance keeps its own logins and sessions.", |
| 859 | + Provider::Empryo => { | |
| 860 | + "A folder Empryo runs with as its HOME, so this instance keeps its own ~/.empryo: keys, settings and sign-ins. Its commands see it too." | |
| 861 | + } | |
| 849 | 862 | }) |
| 850 | 863 | .control(text_field(&form.home, 224., window, cx)), |
| 851 | 864 | ); |
| @@ -863,6 +876,7 @@ impl SettingsView { | ||
| 863 | 876 | Provider::Claude => "Additional CLI arguments passed on session start.", |
| 864 | 877 | Provider::Pi => "Additional CLI arguments passed to pi --mode rpc on session start.", |
| 865 | 878 | Provider::Jcode => "Additional CLI arguments passed to jcode ahead of api --stdio on session start.", |
| 879 | + Provider::Empryo => "Additional CLI arguments passed to empryo --headless --chat on session start.", | |
| 866 | 880 | }) |
| 867 | 881 | .control(text_field(&form.args, 224., window, cx)), |
| 868 | 882 | ); |
| @@ -816,7 +816,7 @@ pub(crate) fn model_control( | ||
| 816 | 816 | let name = match model.provider { |
| 817 | 817 | Provider::Claude if model.legacy => format!("{} (legacy)", model.name), |
| 818 | 818 | Provider::Claude => model.name.clone(), |
| 819 | - Provider::Pi | Provider::Jcode => format!("{} · {}", model.name, model.detail), | |
| 819 | + Provider::Pi | Provider::Jcode | Provider::Empryo => format!("{} · {}", model.name, model.detail), | |
| 820 | 820 | }; |
| 821 | 821 | (model.key(), name) |
| 822 | 822 | }) |
| @@ -136,7 +136,7 @@ impl UsageView { | ||
| 136 | 136 | Provider::Claude => sources.claude_dirs.push((label.clone(), home)), |
| 137 | 137 | Provider::Pi => sources.pi_dirs.push((label.clone(), home)), |
| 138 | 138 | // jcode keeps no usage logs this page reads. |
| 139 | - Provider::Jcode => {} | |
| 139 | + Provider::Jcode | Provider::Empryo => {} | |
| 140 | 140 | } |
| 141 | 141 | if let Some(usage) = &state.usage { |
| 142 | 142 | limits.push((SharedString::from(label), usage.clone())); |
| @@ -52,6 +52,7 @@ use crate::{ | ||
| 52 | 52 | mod actions; |
| 53 | 53 | mod delegation; |
| 54 | 54 | mod devices; |
| 55 | + mod genome; | |
| 55 | 56 | mod git_actions; |
| 56 | 57 | mod header; |
| 57 | 58 | mod palette; |
| @@ -261,6 +262,8 @@ pub struct Workspace { | ||
| 261 | 262 | right_panels: HashMap<EntityId, surfaces::Surface>, |
| 262 | 263 | /// The right panel while no thread is open. |
| 263 | 264 | idle_right_panel: Option<surfaces::Surface>, |
| 265 | + /// The Genome map, while it shows: Empryo's map of a checkout. | |
| 266 | + genome: Option<genome::GenomePanel>, | |
| 264 | 267 | /// The surface picker's focus, for its keys. |
| 265 | 268 | surface_focus: FocusHandle, |
| 266 | 269 | settled_open: bool, |
| @@ -435,6 +438,7 @@ impl Workspace { | ||
| 435 | 438 | panel_open: true, |
| 436 | 439 | right_panels: HashMap::new(), |
| 437 | 440 | idle_right_panel: None, |
| 441 | + genome: None, | |
| 438 | 442 | surface_focus: cx.focus_handle(), |
| 439 | 443 | settled_open: false, |
| 440 | 444 | snoozed_open: false, |
| @@ -2255,6 +2259,15 @@ impl Render for Workspace { | ||
| 2255 | 2259 | let floating_device = self.render_floating_device(cx); |
| 2256 | 2260 | let p = metal::palette(cx); |
| 2257 | 2261 | let subagents_visible = self.surface_open(surfaces::Surface::Subagents); |
| 2262 | + // The map follows the open thread to its checkout, and is only for Empryo's. | |
| 2263 | + if self.surface_open(surfaces::Surface::Genome) && !self.genome_available(cx) { | |
| 2264 | + self.set_right_panel(None); | |
| 2265 | + } | |
| 2266 | + self.sync_genome(cx); | |
| 2267 | + let genome_visible = self.surface_open(surfaces::Surface::Genome) && !self.settings_open; | |
| 2268 | + if !self.surface_open(surfaces::Surface::Genome) { | |
| 2269 | + self.genome = None; | |
| 2270 | + } | |
| 2258 | 2271 | let picker_visible = self.surface_open(surfaces::Surface::Picker) && !self.settings_open; |
| 2259 | 2272 | let docked_terminal = |
| 2260 | 2273 | if self.surface_open(surfaces::Surface::Terminal) { self.render_terminal_drawer(true, window, cx) } else { None }; |
| @@ -2263,7 +2276,11 @@ impl Render for Workspace { | ||
| 2263 | 2276 | && self.active_project().is_some() |
| 2264 | 2277 | && window.viewport_size().width >= px(PANEL_MIN_WINDOW); |
| 2265 | 2278 | // The browser gives way before the conversation gets too narrow. |
| 2266 | - let chat_min = if browser.is_some() || docked_terminal.is_some() || device_panel.is_some() { px(360.) } else { px(0.) }; | |
| 2279 | + let chat_min = if browser.is_some() || docked_terminal.is_some() || device_panel.is_some() || genome_visible { | |
| 2280 | + px(360.) | |
| 2281 | + } else { | |
| 2282 | + px(0.) | |
| 2283 | + }; | |
| 2267 | 2284 | let mut drawer = self.render_terminal_drawer(false, window, cx); |
| 2268 | 2285 | let drawer_full_width = panel_visible; |
| 2269 | 2286 | let wallpaper = self.render_start_wallpaper(chat.is_some(), cx); |
| @@ -2430,6 +2447,7 @@ impl Render for Workspace { | ||
| 2430 | 2447 | ) |
| 2431 | 2448 | .when(panel_visible, |this| this.child(self.render_panel(cx))) |
| 2432 | 2449 | .when(subagents_visible, |this| this.child(self.render_subagents_panel(cx))) |
| 2450 | + .when(genome_visible, |this| this.child(self.render_genome_panel(cx))) | |
| 2433 | 2451 | .when(picker_visible, |this| this.child(self.render_surface_picker(cx))) |
| 2434 | 2452 | .children(docked_terminal) |
| 2435 | 2453 | .when_some(browser, |this, browser| this.child(browser)) |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.