diff --git a/CLAUDE.md b/CLAUDE.md index e4bfa47..0d739ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,8 @@ # TokenEconomizer -A plugin for Claude Code, Codex, the Copilot CLI and Pi (experimentally opencode, Cursor, Grok Build -and Hermes Agent) that reads finished sessions and changes what later sessions in the same repository pay -for: micro-skills, tool pruning, and a loop guard. +A plugin for Claude Code, Codex, the Copilot CLI and Pi (experimentally opencode, Cursor, Grok Build, +Hermes Agent and the Antigravity CLI) that reads finished sessions and changes what later sessions +in the same repository pay for: micro-skills, tool pruning, and a loop guard. See `README.md` for the overview and the method, `docs/PLUGIN.md` for the design and the verified agent behavior, and `docs/ARCHITECTURE.md` for how the package fits together. @@ -12,7 +12,7 @@ agent behavior, and `docs/ARCHITECTURE.md` for how the package fits together. - `toolprune/` — unused-tool evidence and the deny-list proposal; Codex capability packages - `loopguard/` — the per-call failure-loop guard and its per-repository envelope - `trace/` — each agent's session-log format (Claude Code, Codex, Copilot CLI, Pi, opencode, - Cursor, Grok Build, Hermes Agent) + Cursor, Grok Build, Hermes Agent, Antigravity CLI) - `plugin/` — the runtime: hook dispatch, per-repository state, settings, the `te` CLI - `config.py`, `ledger.py`, `pricing.py`, `domain.py`, `stage.py` — shared by all three - `plugin/` — the installable plugin directory: manifests per agent, hook files, launchers diff --git a/README.md b/README.md index bd6b512..a0ccbc0 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,8 @@ mechanisms: itself. TokenEconomizer is a plugin for **Claude Code**, **Codex**, the **GitHub Copilot CLI** and **Pi**, -with experimental support for **opencode**, **Cursor Agent**, **Grok Build** and **Hermes Agent**. Each italic +with experimental support for **opencode**, **Cursor Agent**, **Grok Build**, **Hermes Agent** and +the **Antigravity CLI**. Each italic letter above is a setting, shown with its default ([Settings](#settings)), and each mechanism can be turned on or off. The plugin announces each action in one line, and if any part of it fails, your session carries on as if it were not installed. Its model calls (the fact proposer, and the judge with the @@ -192,6 +193,21 @@ Confirm `tokeneconomizer` is enabled in `grok plugin list`, start `grok`, and ch The learned facts arrive with the session's first tool call, not at its start ([its limits](plugin/README.md#grok-build)). +**Antigravity CLI** (`agy`; experimental; micro-skills' LLM-free facts only) + +```sh +python scripts/build_plugin.py --out dist/marketplace +agy plugin install dist/marketplace/tokeneconomizer +``` + +Start `agy` and send a prompt in a repository, then check with +`~/.tokeneconomizer/bin/te --host antigravity status`. `agy` has no plugin commands, so the +plugin's commands run from a terminal. The plugin makes no model call through `agy`, which has no +option that runs a prompt with every tool off, so the paid proposer and the loop guard are off +and tool pruning is not available. macOS and Linux only: +on Windows `agy` runs hook commands through `cmd /c`, and this plugin's launcher is a POSIX shell +script. See [its limits](plugin/README.md#antigravity-cli). + **opencode** (experimental) ```sh @@ -228,6 +244,7 @@ To update: |---|---| | Claude Code, Copilot CLI, opencode | `git pull` in the clone | | Hermes Agent | `git pull` in the clone, then `hermes gateway restart` | +| Antigravity CLI | build the bundle again and `agy plugin install` it | | Codex | `git pull` in the clone, then the `codex plugin add` line again | | Pi | `pi update --extensions` | @@ -246,6 +263,7 @@ uninstalling, using Copilot with your own model key, and troubleshooting. | Grok Build (experimental) | 1.0.70 | not tested | | opencode (experimental) | 1.19.5 and 2.2.0 | not tested | | Hermes Agent (experimental) | 0.22.5 | not tested | +| Antigravity CLI (experimental) | agy 1.5.1, macOS and Linux | not tested | The commands above were tested on the oldest and newest version in each range; newer versions have not been tested yet. @@ -265,6 +283,9 @@ have not been tested yet. - **Grok Build** support is experimental: it was verified on 1.0.70 only, against its documented hook contract and live hook payloads. Learned facts reach the model with the session's first tool call, and tool pruning is not available. See [its limits](plugin/README.md#grok-build). +- **Antigravity CLI** support is experimental: it was written against agy 1.5.1's hook contract + and transcript format, which are not a public interface, and only micro-skills' LLM-free facts + are supported: the plugin makes no model call through `agy`. See [its limits](plugin/README.md#antigravity-cli). - **Hermes Agent** support is experimental: it was tested on 0.22.5 only, with a scripted local model and one free real model, on the classic CLI and one-shot runs only. Micro-skills and a redirect reaching a real model are verified there; the still-stuck notice reaching a person is @@ -286,13 +307,14 @@ commands. They have the same names on every agent; only the way you type them di | Cursor Agent | from a terminal: `~/.tokeneconomizer/bin/te --host cursor ` | | Grok Build | from a terminal: `~/.tokeneconomizer/bin/te --host grok ` | | Hermes Agent | from a terminal: `~/.tokeneconomizer/bin/te --host hermes ` | +| Antigravity CLI | from a terminal: `~/.tokeneconomizer/bin/te --host antigravity ` | | a terminal, in the repository | `~/.tokeneconomizer/bin/te ` | | Command | What it does | |---|---| | `status` | what it learned and did here, and what your sessions cost | | `microskills` | the facts it injects, and those still forming | -| `toolprune` | the tools it left out, and the evidence (not on Pi, opencode or Hermes Agent) | +| `toolprune` | the tools it left out, and the evidence (not on Pi, opencode, Hermes Agent or the Antigravity CLI) | | `toolprune restore` | give them back; they will not be removed again | | `config` | every setting in effect | | `config set microskills=on` | turn a mechanism on (or `=off`) in this repository; add `--global` for every repository | @@ -375,6 +397,7 @@ what it can leave out differs: | Grok Build | nothing yet | — | | opencode | nothing yet | — | | Hermes Agent | nothing yet | — | +| Antigravity CLI | nothing yet | — | Tool pruning adds only its own entries and never touches deny rules you wrote; a settings file it creates is kept out of git (`.git/info/exclude`). On Claude Code, the session-start line that says diff --git a/docs/PLUGIN.md b/docs/PLUGIN.md index 0089453..e8f9242 100644 --- a/docs/PLUGIN.md +++ b/docs/PLUGIN.md @@ -1,7 +1,7 @@ # The plugin: design and verified agent behaviour How the plugin runs the three mechanisms inside your own Claude Code, Codex, Copilot CLI, Pi, -opencode, Cursor, Grok Build and Hermes Agent sessions, through the hooks (on Pi, the extension events) each agent +opencode, Cursor, Grok Build, Hermes Agent and Antigravity CLI sessions, through the hooks (on Pi, the extension events) each agent exposes: what it does at each hook, what differs from the experiments and why, the agent behaviour each choice rests on, where its state lives, and its known limitations. The code is `tokeneconomizer/plugin/` (what the hooks run) and `plugin/` (the packaging for each agent). To install and use it, see @@ -763,6 +763,25 @@ open-vocabulary. On a repository you do not trust, leave micro-skills off (the d * **Hermes Agent** — experimental: micro-skills and the loop guard, on the classic CLI and one-shot runs, verified with a scripted model and one free real model. Its limits are listed in `plugin/README.md`. +* **Antigravity CLI (`agy`, experimental)** — micro-skills' LLM-free facts, written against + agy 1.5.1 and its own installed hook documentation; tool pruning is not implemented. The + plugin makes no model call through `agy`, which has no option that runs a prompt with every tool + off (`sidecall.NO_SIDE_CALLS`), so the paid proposer and the loop guard's judge never run. What + was captured live: hooks come from a `hooks.json` at the plugin root, run through `sh -c` in + that directory, with camelCase payloads that name no event (the hook file's command does, + `--event`) and leave out a zero (`invocationNum` 0, the first model call of a prompt). A + `PreToolUse` hook that prints `{}` makes agy deny the tool, so none is registered; + `PostToolUse` carries no tool result and a matcher `"*"` never matches, so the results are read + from the conversation's `transcript_full.jsonl` at the next `PreInvocation` and at `Stop`, where + every result of the previous model call is already written (`trace/antigravity.py`). There is no + prompt event and no `SessionEnd`: a prompt is the `PreInvocation` with `invocationNum` 0, and a + conversation is folded by the next session start in its repository once its transcript has been + quiet for ten minutes. Context goes back as `injectSteps`: a persistent `systemMessage` at + `SessionStart`, an `ephemeralMessage` (dropped after the turn) at `PreInvocation`. Calls a subagent + makes are not in the transcript and are not seen. Transcript token fields are absent from older + agy versions (unknown, not zero), and the transcript names no model, so session cost is priced + on the ledger's labelled fallback. macOS and Linux only: agy runs hook commands through + `cmd /c` on Windows, and the launcher is a POSIX shell script. * **Known gaps:** * `codex exec` prints no hook messages, so a headless Codex run shows no notice; `te status` counts it afterwards. On Claude Code the notice reaches the conversation and `claude -p`'s diff --git a/plugin/.antigravity-plugin/plugin.json b/plugin/.antigravity-plugin/plugin.json new file mode 100644 index 0000000..c90e35c --- /dev/null +++ b/plugin/.antigravity-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "tokeneconomizer", + "version": "0.1.0", + "description": "Learn repository facts and redirect coding-agent turns that appear stuck.", + "author": { + "name": "TokenEconomizer authors" + }, + "repository": "https://github.com/microsoft/TokenEconomizer", + "license": "Apache-2.0" +} diff --git a/plugin/README.md b/plugin/README.md index 77e42fa..4b76c34 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -1,13 +1,14 @@ # TokenEconomizer plugin How to install and use TokenEconomizer in Claude Code, Codex, the GitHub Copilot CLI, Pi, Cursor -(experimental), Grok Build (experimental), opencode (experimental) and Hermes Agent (experimental). What the plugin does and why is in the [main README](../README.md); its +(experimental), Grok Build (experimental), opencode (experimental), Hermes Agent (experimental) +and the Antigravity CLI (experimental). What the plugin does and why is in the [main README](../README.md); its design and the verified behaviour of each agent are in [`docs/PLUGIN.md`](../docs/PLUGIN.md). - [Before you start](#before-you-start) - Install and use it on [Claude Code](#claude-code) · [Codex](#codex) · [Copilot CLI](#copilot-cli) · [Pi](#pi) · [Cursor](#cursor) · [Grok Build](#grok-build) · [opencode](#opencode) · - [Hermes Agent](#hermes-agent) + [Hermes Agent](#hermes-agent) · [Antigravity CLI](#antigravity-cli) - [What to expect](#what-to-expect) · [Commands](#commands) · [Settings](#settings) - [Repository map](#repository-map) · [Using with rtk](#using-with-rtk) · [Model calls and API keys](#model-calls-and-api-keys) · [Troubleshooting](#troubleshooting) @@ -539,6 +540,47 @@ log a warning for the empty section of a session with no facts. **Uninstall.** `hermes plugins disable tokeneconomizer`, `rm ~/.hermes/plugins/tokeneconomizer`, `hermes gateway restart`. Optional: `rm -rf ~/.tokeneconomizer`. + +## Antigravity CLI + +Experimental: **micro-skills' LLM-free facts**, written against `agy` 1.5.1 on macOS and Linux +(agy runs hook commands through `cmd /c` on Windows, and the launcher is a POSIX shell script). + +**1. Install.** Build the self-contained plugin, which copies the package into it, and install the +directory: + +```bash +python scripts/build_plugin.py --out dist/marketplace +agy plugin install dist/marketplace/tokeneconomizer +``` + +`agy plugin validate dist/marketplace/tokeneconomizer` checks it. agy reads the plugin's hooks +from `hooks.json` at the plugin root; the `hooks/hooks.json` beside it is Claude Code's, and agy's +validator says it is never loaded. + +**2. Check it.** Run a session, then `~/.tokeneconomizer/bin/te --host antigravity status` in the +repository. agy plugins load no slash commands, so every command runs from a terminal. Micro-skills +is off by default: `te --host antigravity config set microskills=envfacts`. + +**How it works, and what it does not do.** + +- Three hooks are registered: `SessionStart` (injects the learned facts), `PreInvocation` (before + every model call: opens a turn at a new prompt and reads the tool results since the last call) + and `Stop`. +- It never registers `PreToolUse`: a `PreToolUse` hook that prints `{}` makes agy deny the tool. + `PostToolUse` is unused: its payload holds no tool result, so the results are read from the + conversation's `transcript_full.jsonl` instead. A cursor into it is kept in + `~/.tokeneconomizer/antigravity-sessions/`, private to you. +- agy has no `SessionEnd`: a conversation is folded by the next session start in that repository, + once its transcript has been quiet for 10 minutes. +- Tool pruning is not available. Calls a subagent makes are not in the transcript and are not seen. + The transcript names no model, so session cost is priced on a labelled fallback, and older agy + versions leave token counts out of most of it. +- The plugin makes no model call through `agy`: it has no option that runs a prompt with every + tool off, and such a call's prompt holds text a repository controls. So micro-skills' paid + proposer never runs, and the loop guard, which needs a judge's verdict to act, never redirects + a turn. + ## What to expect The plugin works session by session: what it learns from a repository's earlier sessions is @@ -623,6 +665,7 @@ How to run them: | opencode | none (run from a terminal) | `~/.tokeneconomizer/bin/te --host opencode ` | | Grok Build | none (run from a terminal) | `~/.tokeneconomizer/bin/te --host grok ` | | Hermes Agent | none (run from a terminal) | `~/.tokeneconomizer/bin/te --host hermes ` | +| Antigravity CLI | none (run from a terminal) | `~/.tokeneconomizer/bin/te --host antigravity ` | `doctor`, `history` and `repomap` run from a terminal (or as `/te …` on Pi); there is no slash command or Codex skill for them. diff --git a/plugin/hooks.json b/plugin/hooks.json new file mode 100644 index 0000000..9307311 --- /dev/null +++ b/plugin/hooks.json @@ -0,0 +1,13 @@ +{ + "tokeneconomizer": { + "SessionStart": [ + { "type": "command", "command": "./scripts/te-hook --host antigravity --event SessionStart", "timeout": 15 } + ], + "PreInvocation": [ + { "type": "command", "command": "./scripts/te-hook --host antigravity --event PreInvocation", "timeout": 10 } + ], + "Stop": [ + { "type": "command", "command": "./scripts/te-hook --host antigravity --event Stop", "timeout": 10 } + ] + } +} diff --git a/tests/test_antigravity.py b/tests/test_antigravity.py new file mode 100644 index 0000000..af0e07c --- /dev/null +++ b/tests/test_antigravity.py @@ -0,0 +1,444 @@ +"""Antigravity CLI (`agy`) host adapter: one hook call -> several controller events, the transcript +reader, the trace and usage readers, the side call, and the plugin's packaging. + +Every transcript here is synthetic (fake ids, `/work/repo`, `/home/u`): rows shaped as agy 1.5.1 +writes `transcript_full.jsonl`, built by `Transcript` below. (`*.jsonl` is not tracked in this +repository, so fixtures are written at test time.) +""" +import io +import json +import os +import subprocess +from pathlib import Path + +import pytest + +from tokeneconomizer import trace +from tokeneconomizer.plugin import hook, hosts, sidecall +from tokeneconomizer.plugin.hosts import Antigravity, ClaudeCode, Cursor, merge_outputs + +CID = "4c748b89-0000-4000-8000-000000000001" +ROOT = Path(__file__).resolve().parents[1] +NOTE = ("Created At: 2026-11-05T10:00:00Z\nCompleted At: 2026-11-05T10:00:01Z\n" + "A pre-tool hook changed the arguments of this tool call before it ran. " + "Changed: CommandLine.\n\n") + + +class Transcript: + """Rows as agy writes them, appended one prompt or model call at a time.""" + + def __init__(self, path): + self.path, self.n = str(path), 0 + + def _add(self, **row): + row.update(step_index=self.n, status=row.get("status", "DONE"), + created_at="2026-11-05T10:00:00Z") + self.n += 1 + with open(self.path, "a", encoding="utf-8") as fh: + fh.write(json.dumps(row) + "\n") + + def prompt(self, text): + self._add(source="USER_EXPLICIT", type="USER_INPUT", content=( + f"\n{text}\n\n\nnow\n" + "")) + + def call(self, calls, results, tokens=True): + """One model call: its planner row, then one result step per entry of `results` + (`(content, extra)`; extra overrides the step's fields, `type` included).""" + row = {"source": "MODEL", "type": "PLANNER_RESPONSE", + "tool_calls": [{"name": n, "args": a} for n, a in calls]} + if tokens: + row.update(input_tokens=100, cache_read_tokens=1000, output_tokens=20) + self._add(**row) + for content, extra in results: + self._add(**dict({"source": "MODEL", "type": "GENERIC", "content": content}, **extra)) + + def system(self): + self._add(source="SYSTEM", type="CHECKPOINT", content="checkpoint") + + +def sh(cmd): + return "run_command", {"CommandLine": cmd, "Cwd": "/work/repo", "toolAction": "x", + "toolSummary": "y", "WaitMsBeforeAsync": 500} + + +def out(code, text=""): + return f"{NOTE}Command exited with code {code}.\n\nCommand output:\n{text}\r\n", {} + + +def hook_payload(tp, event, ws="/work/repo", **extra): + return dict({"hook_event_name": event, "conversationId": CID, "workspacePaths": [ws], + "transcriptPath": str(tp), "modelName": "m", "lastUserInput": "fix it"}, **extra) + + +@pytest.fixture +def env(tmp_path): + return {"TOKENECONOMIZER_HOME": str(tmp_path / "home"), "TE_FOLD_SYNC": "1", + "TE_MICROSKILLS": "envfacts", "GIT_CEILING_DIRECTORIES": str(tmp_path), + "PATH": os.environ.get("PATH", "")} + + +@pytest.fixture +def tp(tmp_path): + d = tmp_path / "brain" / CID / ".system_generated" / "logs" + d.mkdir(parents=True) + return d / "transcript_full.jsonl" + + +def kinds(events): + return [(e["hook_event_name"], e.get("tool_name") or e.get("prompt"), e.get("prompt_id")) + for e in events] + + +# ---- one agy call -> the controller's events ---------------------------------------------- +def test_expand_puts_a_turns_results_before_the_next_prompt_and_counts_turns(tp, env): + host, t = Antigravity(), Transcript(tp) + t.prompt("fix it") + assert kinds(host.expand(hook_payload(tp, "SessionStart"), env)) == [ + ("SessionStart", None, None)] + # invocationNum 0 is left out of the payload (protojson): the first call of a prompt. + assert kinds(host.expand(hook_payload(tp, "PreInvocation"), env)) == [ + ("UserPromptSubmit", "fix it", f"{CID}:1")] + t.call([sh("ls")], [out(0, "a")]) + t.call([("view_file", {"AbsolutePath": "/work/repo/a.py"})], [("print(1)\n", {})]) + t.call([("replace_file_content", {"TargetFile": "/work/repo/a.py", "ReplacementContent": "x", + "TargetContent": "y"})], [("edited", {"type": "CODE_ACTION"})]) + assert kinds(host.expand(hook_payload(tp, "PreInvocation", invocationNum=3), env)) == [ + ("PostToolUse", "Bash", f"{CID}:1"), ("PostToolUse", "Read", f"{CID}:1"), + ("PostToolUse", "Edit", f"{CID}:1")] + t.call([sh("pytest")], [out(1, "FAILED")]) + t.prompt("again") # a new prompt: its results belong to the old turn + assert kinds(host.expand(hook_payload(tp, "PreInvocation"), env)) == [ + ("PostToolUse", "Bash", f"{CID}:1"), ("UserPromptSubmit", "fix it", f"{CID}:2")] + t.call([sh("ls")], [out(0, "b")]) + stop = host.expand(hook_payload(tp, "Stop", terminationReason="NO_TOOL_CALL"), env) + assert kinds(stop) == [("PostToolUse", "Bash", f"{CID}:2"), ("Stop", None, None)] + assert stop[-1]["terminationReason"] == "NO_TOOL_CALL" + + +def test_expand_does_not_emit_a_step_twice(tp, env): + host, t = Antigravity(), Transcript(tp) + host.expand(hook_payload(tp, "SessionStart"), env) + host.expand(hook_payload(tp, "PreInvocation"), env) + t.call([sh("ls")], [out(0)]) + assert len(host.expand(hook_payload(tp, "PreInvocation", invocationNum=1), env)) == 1 + assert host.expand(hook_payload(tp, "PreInvocation", invocationNum=2), env) == [] + t.call([sh("pwd")], [out(0)]) + again = host.expand(hook_payload(tp, "PreInvocation", invocationNum=3), env) + assert [e["tool_input"] for e in again] == [{"command": "pwd"}] + + +def test_a_call_whose_results_are_not_all_in_waits_for_them(tp, env): + host, t = Antigravity(), Transcript(tp) + host.expand(hook_payload(tp, "SessionStart"), env) + host.expand(hook_payload(tp, "PreInvocation"), env) + t.call([sh("a"), sh("b")], [out(0)]) # the second result is not written yet + assert host.expand(hook_payload(tp, "PreInvocation", invocationNum=1), env) == [] + t._add(source="MODEL", type="GENERIC", content=out(0)[0]) + got = host.expand(hook_payload(tp, "PreInvocation", invocationNum=2), env) + assert [e["tool_input"]["command"] for e in got] == ["a", "b"] + + +def test_a_conversation_this_plugin_did_not_start_is_not_replayed(tp, env): + t = Transcript(tp) + t.prompt("old") + t.call([sh("ls")], [out(0)]) + got = Antigravity().expand(hook_payload(tp, "PreInvocation"), env) + assert kinds(got) == [("UserPromptSubmit", "fix it", f"{CID}:1")] + + +def test_a_resumed_conversation_does_not_replay_its_old_calls_at_session_start(tp, env): + host, t = Antigravity(), Transcript(tp) + t.prompt("old") + t.call([sh("ls")], [out(0)]) # before this plugin saw the conversation + host.expand(hook_payload(tp, "SessionStart"), env) + assert kinds(host.expand(hook_payload(tp, "PreInvocation"), env)) == [ + ("UserPromptSubmit", "fix it", f"{CID}:1")] + t.call([sh("pwd")], [out(0)]) + got = host.expand(hook_payload(tp, "PreInvocation", invocationNum=1), env) + assert [e["tool_input"]["command"] for e in got] == ["pwd"] + + +@pytest.mark.skipif(os.name == "nt", + reason="POSIX permission bits: Windows keeps the state private by ACL") +def test_the_cursor_file_is_private(tp, env): + Antigravity().expand(hook_payload(tp, "SessionStart"), env) + state = Path(env["TOKENECONOMIZER_HOME"]) / "antigravity-sessions" + assert (state.stat().st_mode & 0o777, (state / f"{CID}.json").stat().st_mode & 0o777) == ( + 0o700, 0o600) + + +def test_a_missing_or_unreadable_transcript_gives_no_tool_events(tmp_path, env): + host = Antigravity() + gone = tmp_path / "nope.jsonl" + host.expand(hook_payload(gone, "SessionStart"), env) + assert kinds(host.expand(hook_payload(gone, "PreInvocation"), env)) == [ + ("UserPromptSubmit", "fix it", f"{CID}:1")] + assert kinds(host.expand(hook_payload(gone, "Stop"), env)) == [("Stop", None, None)] + assert host.expand(hook_payload(gone, "PreInvocation", conversationId="../x"), env) == [] + assert host.expand({"hook_event_name": "PostToolUse", "conversationId": CID}, env) == [] + + +# ---- what a result step becomes ------------------------------------------------------------ +def test_generic_and_typed_steps_become_claude_shaped_calls(tp): + t = Transcript(tp) + t.prompt("go") + t.call([sh("pytest -q"), ("grep_search", {"Query": "foo", "SearchPath": "/work/repo", + "toolAction": "z"}), + ("list_dir", {"DirectoryPath": "/work/repo"}), ("call_mcp_tool", { + "ServerName": "s", "ToolName": "t", "toolSummary": "x"})], + [out(1, "ls: nope"), ("hit\n", {"type": "GREP_SEARCH"}), ("a\nb\n", {"type": "LIST_DIRECTORY"}), + ("ok", {"type": "MCP_TOOL"})]) + t.call([("run_command", {"CommandLine": "make"})], + [("done", {"type": "RUN_COMMAND", "exit_code": 2})]) + items = [i for i in trace.antigravity_read(str(tp))[0] if i["kind"] == "tool"] + assert [(i["name"], i["args"]) for i in items] == [ + ("Bash", {"command": "pytest -q"}), ("Grep", {"pattern": "foo", "path": "/work/repo"}), + ("Glob", {"path": "/work/repo"}), ("call_mcp_tool", {"ServerName": "s", "ToolName": "t"}), + ("Bash", {"command": "make"})] + assert items[0]["cwd"] == "/work/repo" + # the rtk notice and the timestamps are gone; the exit code is read from the text, or the + # typed step's own field + assert items[0]["content"].startswith("Command exited with code 1.") + assert [i["exit_code"] for i in items] == [1, None, None, None, 2] + assert [i["is_error"] for i in items] == [True, False, False, False, True] + + +def test_a_step_with_status_error_is_a_failure_with_its_error_text(tp): + t = Transcript(tp) + t.call([("view_file", {"AbsolutePath": "/work/repo/x"})], + [("", {"status": "ERROR", "error": "tool call denied by pre-tool hook: "})]) + (item,) = trace.antigravity_read(str(tp))[0] + assert item["is_error"] and item["exit_code"] is None + assert "denied by pre-tool hook" in item["content"] + host = Antigravity() + name, args, text, code = host.tool_call({ + "tool_name": "Read", "tool_input": {}, "tool_response": { + "content": item["content"], "exit_code": None, "isError": True}}) + assert (name, code) == ("Read", 1) and "denied" in text + assert host.tool_call({"tool_name": "Bash", "tool_input": {}, "tool_response": { + "content": "ok", "exit_code": None, "isError": False}})[3] is None + + +def test_running_steps_system_rows_and_finish_do_not_shift_the_pairing(tp): + t = Transcript(tp) + t.call([sh("sleep 9"), sh("ls")], [("", {"status": "RUNNING"}), out(0, "x")]) + t.system() + t.call([("finish", {"summary": "s"})], [("", {"type": "FINISH"})]) + items = trace.antigravity_read(str(tp))[0] + assert [(i["args"]["command"], i["exit_code"]) for i in items] == [("ls", 0)] + + +# ---- agy's answer -------------------------------------------------------------------------- +def test_render_answers_each_event_in_agys_terms(): + host = Antigravity() + ctx = merge_outputs(host.context("SessionStart", "facts"), host.notice("heads up")) + start = host.render({"hook_event_name": "SessionStart"}, ctx) + (step,) = start["injectSteps"] + assert step["systemMessage"]["systemMessage"].startswith("facts\n\n") + assert "heads up" in step["systemMessage"]["systemMessage"] # told through the model + assert host.render({"hook_event_name": "PreInvocation"}, {"hookSpecificOutput": { + "hookEventName": "PostToolUse", "additionalContext": "redirect"}, "systemMessage": "n"}) == { + "injectSteps": [{"ephemeralMessage": "redirect\n\nn"}]} + assert host.render({"hook_event_name": "Stop"}, ctx) == {} + assert host.render({"hook_event_name": "PreInvocation"}, {}) == {} == host.render( + {"hook_event_name": "SessionStart"}, {}) + + +@pytest.mark.parametrize("reason,normal", [("NO_TOOL_CALL", True), ("USER_CANCELED", False), + ("ERROR", False), (None, False)]) +def test_only_a_turn_that_ran_to_its_end_stopped_normally(reason, normal): + assert Antigravity().stopped_normally({"terminationReason": reason}) is normal + + +def test_session_id_cwd_and_transcript_come_from_the_payload(): + host, p = Antigravity(), hook_payload("/h/.gemini/antigravity-cli/brain/x/t.jsonl", "Stop") + assert (host.session_id(p), host.cwd(p, {}), host.transcript(p)) == ( + CID, "/work/repo", "/h/.gemini/antigravity-cli/brain/x/t.jsonl") + assert host.cwd({"workspacePaths": []}, {}) == os.getcwd() + + +def test_detect_finds_agy_by_its_payload_and_leaves_the_others_alone(): + p = {"conversationId": CID, "transcriptPath": "/home/u/.gemini/antigravity-cli/brain/x/t"} + assert isinstance(hosts.detect(p, {}), Antigravity) + assert isinstance(hosts.detect(p, {}, hint="antigravity"), Antigravity) + assert isinstance(hosts.detect(dict(p, transcriptPath="/x/other.jsonl"), {}), ClaudeCode) + assert isinstance(hosts.detect({"transcript_path": "/x/y"}, {}), ClaudeCode) + assert hosts.HOSTS["antigravity"] is Antigravity + + +# ---- the finished session ------------------------------------------------------------------ +def test_usage_sums_the_rows_that_report_it_and_skips_the_rest(tp): + t = Transcript(tp) + t.prompt("go") + t.call([sh("a")], [out(0)]) + t.call([sh("b")], [out(0)], tokens=False) # an older agy: no token fields + t.call([], []) + t._add(source="MODEL", type="PLANNER_RESPONSE", input_tokens=5, output_tokens=7) + assert trace.antigravity_session_usage(str(tp)) == { + "input_tokens": 205, "cache_read_input_tokens": 2000, "cache_creation_input_tokens": 0, + "output_tokens": 47, "requests": 3, "model": "antigravity"} + assert trace.antigravity_session_usage(str(tp) + ".none") == {} + from tokeneconomizer.plugin import accounting + assert accounting.effective(Antigravity().session_usage(str(tp)))[1] == "fallback" + assert accounting.effective(Antigravity().session_usage(str(tp) + ".none")) == ( + None, "no_usage") + + +def test_the_trace_conversion_feeds_the_mechanisms_and_prompts_are_counted(tp, tmp_path): + t = Transcript(tp) + t.prompt("first") + t.call([sh("pytest")], [out(1, "FAILED x")]) + t.call([("replace_file_content", {"TargetFile": "/work/repo/a.py"})], + [("ok", {"type": "CODE_ACTION"})]) + t.prompt("second") + t.call([("view_file", {"AbsolutePath": "/work/repo/a.py"})], [("body", {"type": "VIEW_FILE"})]) + host, work = Antigravity(), tmp_path / "w" + work.mkdir() + path = host.mechanism_trace(str(tp), str(work)) + assert trace.load_trajectory(path) == [ + ("Bash", {"command": "pytest"}, "Command exited with code 1.\n\nCommand output:\nFAILED x", + False), + ("Edit", {"file_path": "/work/repo/a.py"}, "ok", True), + ("Read", {"file_path": "/work/repo/a.py"}, "body", True)] + assert trace.call_cwds(path)[0] == "/work/repo" + assert host.prompt_count(str(tp)) == 2 + assert host.mechanism_trace(str(tp) + ".none", str(work)) is None + + +# ---- the hook entry point ------------------------------------------------------------------ +def test_main_reads_the_event_from_the_command_line(tp, env): + out_ = io.StringIO() + Transcript(tp).prompt("x") + p = hook_payload(tp, "ignored") + del p["hook_event_name"] + hook.main(["--host", "antigravity", "--event", "SessionStart"], io.StringIO(json.dumps(p)), + out_, dict(env, TE_MICROSKILLS="off")) + assert json.loads(out_.getvalue()) == {} + assert (Path(env["TOKENECONOMIZER_HOME"]) / "antigravity-sessions" / f"{CID}.json").exists() + + +def test_a_sidecall_marker_makes_the_hook_do_nothing(tp, env): + out_ = io.StringIO() + hook.main(["--host", "antigravity", "--event", "SessionStart"], + io.StringIO(json.dumps(hook_payload(tp, "SessionStart"))), out_, + dict(env, TE_SIDECALL="1")) + assert json.loads(out_.getvalue()) == {} + assert not (Path(env["TOKENECONOMIZER_HOME"]) / "antigravity-sessions").exists() + + +def test_the_expand_render_seam_leaves_other_hosts_as_they_were(tmp_path, env): + repo = tmp_path / "repo" + (repo / ".git").mkdir(parents=True) + (repo / ".git" / "HEAD").write_text("ref: refs/heads/main\n") + p = {"session_id": "s", "cwd": str(repo), "transcript_path": "", "hook_event_name": + "SessionStart", "source": "startup"} + assert hook.run(p, env, host_hint="claude") == {} + for cls in (ClaudeCode, Cursor): + assert not hasattr(cls, "expand") and not hasattr(cls, "render") + + +def test_the_seam_runs_each_expanded_event_in_order_and_renders_the_merge(monkeypatch, tmp_path, + env): + seen = [] + + class Fake(ClaudeCode): + name = "claude" + + def expand(self, payload, env): + return [dict(payload, hook_event_name="SessionStart", n=1), + dict(payload, hook_event_name="SessionStart", n=2)] + + def render(self, payload, merged, ctx): + return {"event": payload["hook_event_name"], "merged": merged} + + from tokeneconomizer.plugin import controller + monkeypatch.setitem(hosts.HOSTS, "fake", Fake) + monkeypatch.setattr(controller.LiveController, "handle", lambda self: ( + seen.append(self.p["n"]) or {"additionalContext": f"c{self.p['n']}"})) + got = hook.run({"session_id": "s", "cwd": str(tmp_path), "hook_event_name": "PreInvocation"}, + env, host_hint="fake") + assert seen == [1, 2] + assert got == {"event": "PreInvocation", "merged": {"additionalContext": "c1\n\nc2"}} + + +# ---- end to end: a stuck turn becomes an ephemeral message --------------------------------- +def _run_turn(env, tp, t, prompt, batches, ws): + """One prompt through the hook, as agy calls it: PreInvocation per model call, then Stop.""" + outs = [] + + def call(event, **extra): + buf = io.StringIO() + hook.main(["--host", "antigravity", "--event", event], + io.StringIO(json.dumps(hook_payload(tp, event, ws, lastUserInput=prompt, **extra))), + buf, env) + return json.loads(buf.getvalue()) + + t.prompt(prompt) + outs.append(call("PreInvocation")) + for n, (calls, results) in enumerate(batches, 1): + t.call(calls, results) + outs.append(call("PreInvocation", invocationNum=n)) + call("Stop", terminationReason="NO_TOOL_CALL", executionNum=1) + return outs + + +def test_a_stuck_turn_is_redirected_through_an_ephemeral_message(tp, tmp_path, env): + repo = tmp_path / "repo" + (repo / ".git" / "info").mkdir(parents=True) + (repo / ".git" / "HEAD").write_text("ref: refs/heads/main\n") + (repo / ".git" / "config").write_text( + '[remote "origin"]\n\turl = https://github.com/acme/widget.git\n') + env = dict(env, TE_LOOPGUARD="on") + t, ws = Transcript(tp), str(repo) + hook.main(["--host", "antigravity", "--event", "SessionStart"], io.StringIO( + json.dumps(hook_payload(tp, "SessionStart", ws))), io.StringIO(), env) + green = [([("view_file", {"AbsolutePath": f"{repo}/a.py"})] * 8, [("ok", {})] * 8)] + for k in range(5): + _run_turn(env, tp, t, f"green {k}", green, ws) + fail = "FAILED tests/test_a.py::test_x - AssertionError: assert 1 == 2" + stuck = [([("replace_file_content", {"TargetFile": f"{repo}/pkg/a.py"}), sh("pytest -q")], + [("edited", {"type": "CODE_ACTION"}), out(1, fail)]) for _ in range(20)] + outs = _run_turn(env, tp, t, "stuck", stuck, ws) + steps = [s for o in outs for s in o.get("injectSteps", [])] + assert steps and all(set(s) == {"ephemeralMessage"} for s in steps) + assert any("Process check (automated)" in s["ephemeralMessage"] for s in steps) + + +# ---- the side call ------------------------------------------------------------------------- +def test_antigravity_makes_no_side_call(monkeypatch): + # agy has no option that runs a turn with every tool off, and a side call's prompt holds + # text a repository controls: the proposer and the judge get no runner. + started = [] + monkeypatch.setattr(sidecall.subprocess, "run", lambda *a, **k: started.append(a)) + assert sidecall.runner_for("antigravity", type("C", (), {"paid_model": "m"})()) is None + assert not hasattr(sidecall, "agy_call") and started == [] + + +def test_a_stuck_looking_agy_turn_never_starts_a_judge(tp, env, monkeypatch): + from tokeneconomizer.plugin import controller, judge + spawned = [] + monkeypatch.setattr(judge, "spawn_job", lambda *a, **k: spawned.append(a)) + ctl = type("C", (), {"host": Antigravity(), "ctx": None, "sid": CID})() + verdict = controller.LiveController._judge(ctl, {"judge_confidence": 0.5}, envelope={}) + assert isinstance(verdict, judge.NoVerdict) and spawned == [] + + +# ---- packaging ----------------------------------------------------------------------------- +def test_the_plugin_declares_the_three_events_and_never_pretooluse(): + manifest = json.loads((ROOT / "plugin/.antigravity-plugin/plugin.json").read_text()) + claude = json.loads((ROOT / "plugin/.claude-plugin/plugin.json").read_text()) + assert (manifest["name"], manifest["version"]) == ("tokeneconomizer", claude["version"]) + hooks = json.loads((ROOT / "plugin/hooks.json").read_text()) + assert set(hooks) == {"tokeneconomizer"} + events = hooks["tokeneconomizer"] + # PreToolUse would make agy deny every tool whose hook prints `{}`; PostToolUse carries no + # result; neither is registered. + assert set(events) == {"SessionStart", "PreInvocation", "Stop"} + assert {e: h[0]["timeout"] for e, h in events.items()} == { + "SessionStart": 15, "PreInvocation": 10, "Stop": 10} + for event, (handler,) in events.items(): + assert handler["command"] == f"./scripts/te-hook --host antigravity --event {event}" + assert os.access(ROOT / "plugin/scripts/te-hook", os.X_OK) + # no other agent reads the root hook file: Claude Code's is `hooks/hooks.json`. + assert "hooks" not in claude diff --git a/tests/test_plugin_package.py b/tests/test_plugin_package.py index 54cfbd3..bd58b4f 100644 --- a/tests/test_plugin_package.py +++ b/tests/test_plugin_package.py @@ -185,6 +185,8 @@ def test_the_bundle_runs_without_a_checkout(tmp_path): assert (bundle / ".github" / "plugin" / "plugin.json").exists() assert (bundle / "hooks" / "copilot-hooks.json").exists() assert (bundle / "hermes" / "plugin.yaml").exists() + assert (bundle / "hooks.json").exists() # Antigravity's, at the root + assert (bundle / ".antigravity-plugin" / "plugin.json").exists() assert (bundle / "lib" / "tokeneconomizer" / "__init__.py").exists() assert not (bundle / "lib" / "te_harness").exists() assert not (bundle / "lib" / "te_bench").exists() diff --git a/tokeneconomizer/plugin/cli.py b/tokeneconomizer/plugin/cli.py index 4aba329..77a9b67 100644 --- a/tokeneconomizer/plugin/cli.py +++ b/tokeneconomizer/plugin/cli.py @@ -813,11 +813,13 @@ def cmd_doctor(args): ok = False import shutil as _sh # Only the agents installed here: a Codex-only machine has no Claude Code catalog to probe. - agents = [h for h in ("claude", "codex", "copilot", "grok", "hermes", "opencode", "pi") + agents = [h for h in ("antigravity", "claude", "codex", "copilot", "grok", "hermes", "opencode", + "pi") if _sh.which(sidecall.claude_cli() if h == "claude" else - sidecall.hermes_cli() if h == "hermes" else h)] - print(f"agents " - f"{', '.join(agents) or 'none of claude, codex, copilot, grok, hermes, opencode, pi on PATH'}") + sidecall.hermes_cli() if h == "hermes" else + "agy" if h == "antigravity" else h)] + print("agents " + (', '.join(agents) or 'none of antigravity (agy), claude, codex, ' + 'copilot, grok, hermes, opencode, pi on PATH')) for h in agents: pause = sidecall.paused(h, root) if pause: diff --git a/tokeneconomizer/plugin/controller.py b/tokeneconomizer/plugin/controller.py index 00f3992..cfef0b2 100644 --- a/tokeneconomizer/plugin/controller.py +++ b/tokeneconomizer/plugin/controller.py @@ -326,12 +326,14 @@ def _judge(self, thr, envelope): None before this repository has an envelope -- the guard does not act on a repository whose usual turns it has not learned (the rule fallback never acts without one either). - While this agent's model calls are paused, a judge that never answers: the turn is - spared, as a missing verdict is, rather than redirected on the rule alone.""" + While this agent's model calls are paused, or it has none (`sidecall.NO_SIDE_CALLS`), + a judge that never answers: the turn is spared, as a missing verdict is, rather than + redirected on the rule alone.""" from .judge import AsyncJudge, NoVerdict if envelope is None: return None - if sidecall.paused(self.host.name, self.ctx.root): + if self.host.name in sidecall.NO_SIDE_CALLS or sidecall.paused(self.host.name, + self.ctx.root): return NoVerdict() return AsyncJudge(self.ctx, self.sid, thr.get("judge_confidence")) diff --git a/tokeneconomizer/plugin/hook.py b/tokeneconomizer/plugin/hook.py index 6822a1f..67bd1ae 100644 --- a/tokeneconomizer/plugin/hook.py +++ b/tokeneconomizer/plugin/hook.py @@ -1,12 +1,15 @@ """The entry point every plugin hook executes: hook JSON on stdin, hook JSON on stdout. - python -m tokeneconomizer.plugin.hook [--host claude|codex|copilot|cursor|grok|hermes|opencode|pi] + python -m tokeneconomizer.plugin.hook [--host antigravity|claude|codex|copilot|cursor|grok|hermes| + opencode|pi] [--event NAME] It never fails the agent. Whatever goes wrong -- unreadable input, a full disk, a bug -- the hook prints `{}` (which changes nothing) and records the traceback in `/errors.log`. It also does nothing at all in two cases: `TE_DISABLE=1`, and `TE_SIDECALL=1`, which marks the nested agent process of the plugin's own paid side calls (`sidecall`) so it cannot recurse into these hooks. + +`--event` names the hook event for an agent whose payloads do not (Antigravity's). """ import contextlib import json @@ -34,8 +37,15 @@ def run(payload, env, host_hint=None): return {} if hasattr(host, "record_event"): host.record_event(payload) - out = LiveController(ctx, payload).handle() - # A host whose events cannot all carry context (grok's SessionStart) moves it to one that can. + if hasattr(host, "expand"): + # One call of this agent's hook stands for several events (Antigravity's `PreInvocation` + # is a prompt and the tool results before it): run each and merge their answers. + out = hosts.merge_outputs(*(LiveController(ctx, p).handle() + for p in host.expand(payload, env))) + else: + out = LiveController(ctx, payload).handle() + # A host whose answer has its own shape (agy's `injectSteps`), or whose events cannot all + # carry context (grok's SessionStart), turns the merged answer into its own terms. return host.render(payload, out, ctx) if hasattr(host, "render") else out @@ -60,6 +70,7 @@ def main(argv=None, stdin=None, stdout=None, env=None): if "--host" in argv: i = argv.index("--host") hint = argv[i + 1] if i + 1 < len(argv) else None + event = argv[argv.index("--event") + 1] if "--event" in argv[:-1] else None out = {} try: # The agents send UTF-8 whatever the locale; a text stdin would decode it with the @@ -69,6 +80,8 @@ def main(argv=None, stdin=None, stdout=None, env=None): if not (_truthy(env.get("TE_SIDECALL")) or _truthy(env.get("TE_DISABLE"))): payload = json.loads(raw) if raw.strip() else {} if isinstance(payload, dict): + if event: + payload.setdefault("hook_event_name", event) # stdout carries exactly one JSON object back to the agent. The mechanisms # print progress and warnings (ToolingStage's catalog warning, for one), and a # single stray line there would make the agent discard the whole hook output. diff --git a/tokeneconomizer/plugin/hosts.py b/tokeneconomizer/plugin/hosts.py index 1936985..556d40e 100644 --- a/tokeneconomizer/plugin/hosts.py +++ b/tokeneconomizer/plugin/hosts.py @@ -773,6 +773,182 @@ def session_usage(self, transcript_path): return trace.hermes_session_usage(transcript_path) +class Antigravity(Host): + """Antigravity CLI (`agy`), through the plugin's `hooks.json` (verified on 1.5.1). + + agy's hook contract differs from the shared one, so this adapter translates both ways: + + * Payloads are camelCase and name no event; the hook file's command names it (`--event`). + `SessionStart`, `PreInvocation` (before every model call) and `Stop` are the events used. + A `PreToolUse` hook is never registered: one that prints `{}` makes agy deny the tool. + `PostToolUse` is not needed -- its payload holds no tool result, and the transcript, which + has every result of the previous model call by the next `PreInvocation`, does. + * There is no prompt event either. `PreInvocation` with `invocationNum` 0 (a number protojson + leaves out of the payload) is the first model call of a prompt, with the prompt in + `lastUserInput`. `expand` turns one agy call into the controller's events: the results the + transcript gained since the last call become `PostToolUse` events (those of a turn that + just ended before the new prompt's `UserPromptSubmit`), and `Stop` closes the turn. + * Output is `injectSteps`: a persistent `systemMessage` step at `SessionStart`, an + `ephemeralMessage` (dropped after the turn) at `PreInvocation`. Nothing reaches the + person from a hook, so a notice goes through the model (`relay`), as on Cursor. + * There is no `SessionEnd`: a conversation is folded by the next session start in its + repository once its transcript has been quiet for `fold.STALE_S`. + + The cursor into the transcript, the turn counter and nothing else are kept per conversation + in `/antigravity-sessions/`. Tool pruning is not supported, and subagents' calls + are not in the transcript, so they are not seen. + """ + name = "antigravity" + # The only prompt-time channel is the ephemeral message, which agy drops after the turn: the + # repository map goes in at SessionStart, where it persists. + prompt_context = False + relays_notices = True + interrupt = "Press Esc to cancel the turn." + end_run = "Press Ctrl+C to end the run." + STATE_DIR = "antigravity-sessions" + STATE_KEEP_S = 30 * 86400 + + def session_id(self, p): + return str(p.get("session_id") or p.get("conversationId") or "") + + def transcript(self, p): + return p.get("transcript_path") or p.get("transcriptPath") or "" + + def cwd(self, p, env): + roots = p.get("workspacePaths") + root = roots[0] if isinstance(roots, list) and roots else "" + return p.get("cwd") or root or os.getcwd() + + def turn_key(self, p): + return str(p.get("prompt_id") or "") + + def context(self, event, text): + return {"additionalContext": text} + + def notice(self, text): + return {"additionalContext": self.relay(text)} + + def stopped_normally(self, p): + return (p or {}).get("terminationReason") == "NO_TOOL_CALL" + + def tool_call(self, p): + name, args, text, code = super().tool_call(p) + resp = p.get("tool_response") + if code is None and isinstance(resp, dict) and resp.get("isError"): + code = 1 # a step agy marks as failed, whatever it printed + return name, args, text, code + + # -- one agy hook call -> the controller's events ------------------------------------- + def _state_path(self, env, cid): + from .paths import state_root + return os.path.join(state_root(env), self.STATE_DIR, cid + ".json") + + def _load(self, env, cid): + try: + with open(self._state_path(env, cid), encoding="utf-8") as fh: + st = json.load(fh) + except (OSError, ValueError): + return None + return st if isinstance(st, dict) else None + + def _save(self, env, cid, st): + path = self._state_path(env, cid) + os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True) + tmp = f"{path}.{os.getpid()}.tmp" + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump(st, fh) + os.replace(tmp, path) + + def _prune(self, env): + """Forget cursors of conversations nobody has used for `STATE_KEEP_S`.""" + folder = os.path.dirname(self._state_path(env, "x")) + try: + for name in os.listdir(folder): + path = os.path.join(folder, name) + if time.time() - os.path.getmtime(path) > self.STATE_KEEP_S: + os.remove(path) + except OSError: + pass + + def expand(self, payload, env): + """The controller events one agy hook call stands for, in order; see the class.""" + event = payload.get("hook_event_name") + cid = trace.claude_safe_session_id(self.session_id(payload)) + if event not in ("SessionStart", "PreInvocation", "Stop") or not cid: + return [] + base = {"session_id": cid, "cwd": self.cwd(payload, env), + "transcript_path": self.transcript(payload)} + if event == "SessionStart": + self._prune(env) + if self._load(env, cid) is None: + self._save(env, cid, self._fresh(base["transcript_path"])) + return [dict(base, hook_event_name="SessionStart", source="startup")] + # The first call of a prompt carries no `invocationNum` (protojson drops a zero). + first = event == "PreInvocation" and not payload.get("invocationNum") + st = self._load(env, cid) + path = base["transcript_path"] + out, items = [], [] + if st is None: + st = self._fresh(path) + else: + items, st["offset"], st["last_step_index"] = trace.antigravity_read( + path, int(st.get("offset") or 0), int(st.get("last_step_index", -1)), + final=first or event == "Stop") + key = f"{cid}:{st['turn_seq']}" + for item in (i for i in items if i["kind"] == "tool"): + out.append(dict(base, hook_event_name="PostToolUse", prompt_id=key, + tool_name=item["name"], tool_input=item["args"], + tool_response={"content": item["content"][:RESULT_CAP], + "exit_code": item["exit_code"], + "isError": item["is_error"]})) + if first: + st["turn_seq"] = int(st.get("turn_seq") or 0) + 1 + out.append(dict(base, hook_event_name="UserPromptSubmit", + prompt=str(payload.get("lastUserInput") or ""), + prompt_id=f"{cid}:{st['turn_seq']}")) + elif event == "Stop": + out.append(dict(base, hook_event_name="Stop", + terminationReason=payload.get("terminationReason"))) + self._save(env, cid, st) + return out + + @staticmethod + def _fresh(path): + """The state of a conversation first seen now. Reading starts at the transcript's current + end, so a resumed conversation's earlier steps are not replayed as if they just happened.""" + try: + size = os.path.getsize(path) + except (OSError, TypeError): + size = 0 + return {"offset": size, "last_step_index": -1, "turn_seq": 0} + + def render(self, payload, out, ctx=None): + """agy's answer to this hook call, from the controller's merged output.""" + event = payload.get("hook_event_name") + hso = out.get("hookSpecificOutput") + parts = [hso.get("additionalContext") if isinstance(hso, dict) else None, + out.get("additionalContext"), out.get("systemMessage")] + text = "\n\n".join(t for t in parts if t) + if not text or event == "Stop": + return {} + if event == "SessionStart": + return {"injectSteps": [{"systemMessage": {"systemMessage": text}}]} + return {"injectSteps": [{"ephemeralMessage": text}]} + + # -- the finished session, in the shapes the mechanisms read -------------------------- + def mechanism_trace(self, transcript_path, workdir): + out = os.path.join(workdir, "antigravity_trace.jsonl") + n = trace.antigravity_session_to_claude(transcript_path, out) + return out if n is not None else None + + def prompt_count(self, transcript_path): + return trace.antigravity_prompt_count(transcript_path) + + def session_usage(self, transcript_path): + return trace.antigravity_session_usage(transcript_path) + + class Grok(Host): """The Grok Build CLI (`grok`, xAI), verified on 1.0.70. @@ -1016,7 +1192,8 @@ def _ancestor_args(limit=4): HOSTS = {"claude": ClaudeCode, "codex": Codex, "copilot": Copilot, "opencode": OpenCode, - "pi": Pi, "cursor": Cursor, "hermes": Hermes, "grok": Grok} + "pi": Pi, "cursor": Cursor, "hermes": Hermes, "grok": Grok, + "antigravity": Antigravity} def default_host(env=None, recent=None): @@ -1042,6 +1219,9 @@ def detect(payload, env=None, hint=None): """The adapter for this hook call: an explicit `hint`, else inferred from the payload.""" if hint in HOSTS: return HOSTS[hint]() + if (payload or {}).get("conversationId") and "/antigravity" in str( + payload.get("transcriptPath") or "").replace("\\", "/"): + return Antigravity() tp = str((payload or {}).get("transcript_path") or "") sp = tp.replace("\\", "/") # a Windows path, with either separator # grok runs Claude Code's plugin hooks, with no `--host`, so it is told from Claude Code diff --git a/tokeneconomizer/plugin/sidecall.py b/tokeneconomizer/plugin/sidecall.py index 918e471..c3cc0fd 100644 --- a/tokeneconomizer/plugin/sidecall.py +++ b/tokeneconomizer/plugin/sidecall.py @@ -37,6 +37,9 @@ dispatchers removed (`--disallowed-tools`) and the shell, edit, write, web and subagent tools denied (`--deny`); no approval flag is passed. It leaves a session under `~/.grok/sessions/`, which no hook tracks (they do nothing under `TE_SIDECALL`). + * Antigravity: none. `agy` has no option that runs a turn without tools, and a side call's + prompt holds text a repository controls, so its micro-skills keep to the LLM-free facts and + its loop guard never asks a judge (`NO_SIDE_CALLS`). * Hermes: `hermes chat -Q --oneshot --source tool --ignore-rules --max-turns 1 -t bot_room` on the person's default model. Never `hermes -z`, which auto-approves dangerous commands: a side call's prompt holds text a repository controls, and this form offers it no tool. See @@ -687,6 +690,9 @@ def copilot_call(prompt, model="", cli="copilot", timeout=TIMEOUT_S): return text, eff +# Hosts the plugin makes no model call for: their CLI cannot run a prompt with every tool off. +NO_SIDE_CALLS = frozenset({"antigravity"}) + # The command each agent's side calls run, as the notices name it; the rest run ` -p`. _CALLS = {"codex": "codex exec", "opencode": "opencode run", "cursor": "cursor-agent -p", "hermes": "hermes chat"} @@ -700,7 +706,10 @@ def call_name(host_name): def runner_for(host_name, config): """The optimizer callable for this host. Each host's calls go through that host's own CLI, never another agent's. The caller decides whether a call is allowed: micro-skills' proposer - when micro-skills is on, the loop guard's judge when the guard is.""" + when micro-skills is on, the loop guard's judge when the guard is. None for a host in + `NO_SIDE_CALLS`.""" + if host_name in NO_SIDE_CALLS: + return None model = config.paid_model if host_name == "pi": cli = os.path.expanduser(config.pi_cli) if config.pi_cli else "pi" diff --git a/tokeneconomizer/trace/README.md b/tokeneconomizer/trace/README.md index 6405789..b335c69 100644 --- a/tokeneconomizer/trace/README.md +++ b/tokeneconomizer/trace/README.md @@ -8,6 +8,7 @@ session as an ordered list of `(tool_name, args, result_text, success)` calls th |---|---| | `calls.py` | `load_trajectory` (format sniffed per file), `call_cwds`, `tool_usage`, `strip_rtk_prefix` | | `catalog.py` | the tool list a session loaded, from whichever source the agent publishes | +| `antigravity.py` | Antigravity CLI (`agy`) transcripts: results paired with their calls, read whole by the fold and a few steps at a time by the hook; usage | | `claude.py` | Claude Code transcripts: calls with the shell's directory, usage by message id, subagents, deferred tools | | `codex.py` | `codex exec --json` streams; a session's rollout file, converted to that stream; usage | | `copilot.py` | Copilot CLI event logs: calls, usage, prompt-turns, MCP servers | diff --git a/tokeneconomizer/trace/__init__.py b/tokeneconomizer/trace/__init__.py index 4049f2d..b5c380a 100644 --- a/tokeneconomizer/trace/__init__.py +++ b/tokeneconomizer/trace/__init__.py @@ -6,9 +6,9 @@ * `calls` -- `load_trajectory`: any agent's log as ordered `(name, args, result, ok)` calls; `call_cwds`, `tool_usage`, `strip_rtk_prefix`; * `catalog` -- the tool list a session loaded, from whichever source the agent publishes; - * `claude`, `codex`, `copilot`, `grok`, `hermes`, `opencode`, `pi` -- each agent's own session file: - usage, metadata, prompt-turns, and the conversions that let the readers above - handle it. + * `antigravity`, `claude`, `codex`, `copilot`, `grok`, `hermes`, `opencode`, `pi` -- each agent's + own session file: usage, metadata, prompt-turns, and the conversions that let + the readers above handle it. Stdlib only. """ @@ -27,6 +27,8 @@ from .copilot import (copilot_event_calls, copilot_mcp_servers, copilot_prompt_count, copilot_session_meta, copilot_session_usage, copilot_turns, mcp_server_of) +from .antigravity import (antigravity_prompt_count, antigravity_read, + antigravity_session_to_claude, antigravity_session_usage) from .cursor import cursor_events_to_claude, cursor_prompt_count from .grok import (grok_json_reply, grok_prompt_count, grok_result, grok_session_to_claude, grok_session_usage, grok_tool) @@ -51,6 +53,8 @@ "codex_session_meta", "codex_session_usage", "rollout_item_to_exec", "copilot_event_calls", "copilot_mcp_servers", "copilot_prompt_count", "copilot_session_meta", "copilot_session_usage", "copilot_turns", "mcp_server_of", + "antigravity_prompt_count", "antigravity_read", "antigravity_session_to_claude", + "antigravity_session_usage", "cursor_events_to_claude", "cursor_prompt_count", "grok_json_reply", "grok_prompt_count", "grok_result", "grok_session_to_claude", "grok_session_usage", "grok_tool", diff --git a/tokeneconomizer/trace/antigravity.py b/tokeneconomizer/trace/antigravity.py new file mode 100644 index 0000000..e99b68e --- /dev/null +++ b/tokeneconomizer/trace/antigravity.py @@ -0,0 +1,229 @@ +"""Antigravity CLI (`agy`) sessions, read from the conversation's `transcript_full.jsonl`. + +The transcript is one JSON object per line (snake_case), written under +`~/.gemini/antigravity-cli/brain//.system_generated/logs/`. Verified on 1.5.1: + + * `USER_INPUT` (source `USER_EXPLICIT`) is a prompt, its text wrapped in ``. + * `PLANNER_RESPONSE` (source `MODEL`) is one model call: `tool_calls` [{name, args}] and, on + current versions, `input_tokens` (fresh input only), `cache_read_tokens` and `output_tokens` + (thinking included). Older versions leave the token fields off most rows: missing is unknown, + not zero. + * The tool results follow their planner row, one `MODEL` step per call, in the call order. + Current agy writes them all as `GENERIC` steps whose `content` reads `Created At: ...`, + `Completed At: ...`, perhaps a line saying a pre-tool hook changed the arguments (rtk does), + then `Command exited with code N.` and `Command output:` for a shell call; older versions + write typed steps (`RUN_COMMAND` with an `exit_code`, `VIEW_FILE`, `GREP_SEARCH`, ...). A + failed call has `status: "ERROR"` and an `error` string. `SYSTEM` rows (`CHECKPOINT`, + `EPHEMERAL_MESSAGE`, ...) are agy's own and are skipped. + +`antigravity_read` pairs the results with their calls and is what both the live adapter (a few +steps at a time, from where it stopped) and the fold (the whole file) use; its tool names and +arguments are the ones the mechanisms know (`Bash {command}`, `Read {file_path}`, ...), so no +mechanism needs an agy branch. Nothing is read from a tool's arguments but what that mapping +keeps: a file write's content, a prompt sent to a subagent, and agy's display-only keys +(`toolAction`, `toolSummary`) stay in agy's log. +""" +import json +import os +import re + +from .jsonl import records as _records + +_USER_RE = re.compile(r"\s*(.*?)\s*", re.S) +_EXIT_RE = re.compile(r"Command exited with code (-?\d+)") +# The lines agy puts ahead of a result's text: when it ran, and that a hook rewrote the call. +_PREFIX_RE = re.compile(r"(?:Created At|Completed At):|A pre-tool hook changed the arguments") +_TOKENS = (("input_tokens", "input_tokens"), ("cache_read_tokens", "cache_read_input_tokens"), + ("output_tokens", "output_tokens")) +_ARG_CAP = 2000 # characters of an unmapped tool's string argument that are kept + +# agy tool -> (name the mechanisms read, {their argument: agy's argument names, first found}) +_FILE = ("TargetFile", "AbsolutePath", "Path") +_TOOLS = { + "run_command": ("Bash", {"command": ("CommandLine",)}), + "view_file": ("Read", {"file_path": _FILE}), + "read_file": ("Read", {"file_path": _FILE}), + "write_to_file": ("Write", {"file_path": _FILE}), + "replace_file_content": ("Edit", {"file_path": _FILE}), + "multi_replace_file_content": ("Edit", {"file_path": _FILE}), + "sed_file": ("Edit", {"file_path": _FILE}), + "grep_search": ("Grep", {"pattern": ("Query",), "path": ("SearchPath",)}), + "find_by_name": ("Glob", {"pattern": ("Pattern",), "path": ("SearchDirectory",)}), + "list_dir": ("Glob", {"path": ("DirectoryPath",)}), +} +_UI_KEYS = ("toolAction", "toolSummary", "WaitMsBeforeAsync") + + +def antigravity_tool(name, args): + """(name, args) of a call as the mechanisms read it; a tool without a mapping keeps its name + and its arguments less the UI-only ones.""" + args = args if isinstance(args, dict) else {} + if name in _TOOLS: + mapped, keys = _TOOLS[name] + return mapped, {k: next((args[a] for a in names if isinstance(args.get(a), str)), "") + for k, names in keys.items()} + return str(name or "?"), {k: v[:_ARG_CAP] if isinstance(v, str) else v + for k, v in args.items() if k not in _UI_KEYS} + + +def antigravity_result(row): + """(text, exit_code or None, failed) of one tool-result step.""" + content = row.get("content") if isinstance(row.get("content"), str) else "" + lines = content.replace("\r\n", "\n").split("\n") + while lines and _PREFIX_RE.match(lines[0]): + lines.pop(0) + text = "\n".join(lines).strip("\n") + code = row.get("exit_code") + if not isinstance(code, int) or isinstance(code, bool): + m = _EXIT_RE.search(text[:400]) + code = int(m.group(1)) if m else None + failed = row.get("status") in ("ERROR", "INVALID") or bool(code) + if isinstance(row.get("error"), str) and row["error"]: + text = (row["error"] + "\n" + text).strip("\n") + return text, code, failed + + +def _prompt_text(row): + content = row.get("content") if isinstance(row.get("content"), str) else "" + m = _USER_RE.search(content) + return (m.group(1) if m else content.split("")[0]).strip() + + +def _read_rows(path, offset): + """[(offset after the line, row)] for the complete lines from `offset` on. A last line still + being written is left for the next read; an offset past the end starts over.""" + try: + with open(path, "rb") as fh: + if offset > os.fstat(fh.fileno()).st_size: + offset = 0 + fh.seek(offset) + data = fh.read() + except OSError: + return [] + out, pos = [], offset + for raw in data.splitlines(keepends=True): + try: + row = json.loads(raw) + except ValueError: + if not raw.endswith(b"\n"): + break + row = None + pos += len(raw) + if isinstance(row, dict): + out.append((pos, row)) + return out + + +def _is_result(row): + # A `finish` call may leave a FINISH step; it is agy's way of ending a turn, no tool run. + return row.get("source") == "MODEL" and row.get("type") not in ("PLANNER_RESPONSE", "FINISH") + + +def antigravity_read(path, offset=0, after=-1, final=True): + """(items, offset, step_index): what the transcript at `path` holds from byte `offset` on, + skipping steps up to `after`, and where to read from next. + + An item is {"kind": "prompt", "text"} or {"kind": "tool", "name", "args", "cwd", "content", + "exit_code", "is_error"}, in transcript order. A planner row's calls are paired with the + result steps after it, in order, once the next planner row or prompt closes them. The last + planner row is closed too if all its results are in or `final` is set (a read at the end of + a turn); otherwise it is left for the next read, and the returned offset stays before it. A + call with no result is left out.""" + rows = [(pos, r) for pos, r in _read_rows(path, offset) + if not isinstance(r.get("step_index"), int) or r["step_index"] > after] + items, done, calls, results = [], 0, None, [] + + def close(): + nonlocal calls, results + for spec, row in zip(calls or (), results): + if row.get("status") == "RUNNING": + continue + text, code, failed = antigravity_result(row) + name, args = antigravity_tool(spec.get("name"), spec.get("args")) + cwd = (spec.get("args") or {}).get("Cwd") + items.append({"kind": "tool", "name": name, "args": args, + "cwd": cwd if isinstance(cwd, str) else None, + "content": text, "exit_code": code, "is_error": failed}) + calls, results = None, [] + + for i, (_pos, row) in enumerate(rows): + if row.get("type") == "PLANNER_RESPONSE" or row.get("type") == "USER_INPUT": + close() + done = i + 1 + if row["type"] == "USER_INPUT": + items.append({"kind": "prompt", "text": _prompt_text(row)}) + elif isinstance(row.get("tool_calls"), list): + calls = [c for c in row["tool_calls"] + if isinstance(c, dict) and isinstance(c.get("args") or {}, dict) + and c.get("name") != "finish"] + if calls: + done = i # not consumed until its results are + elif calls: + if _is_result(row): + results.append(row) + else: + done = i + 1 + if calls and (final or len(results) >= len(calls)): + close() + done = len(rows) + if not done: + return items, offset, after + last = rows[done - 1][1].get("step_index") + return items, rows[done - 1][0], last if isinstance(last, int) else after + + +def antigravity_session_to_claude(path, out_path): + """Write the transcript as Claude Code transcript lines (the shape `cursor_events_to_claude` + writes). Returns the number of records, or None if the file is unreadable or empty.""" + items = antigravity_read(path)[0] + if not items: + return None + out = [] + for n, item in enumerate(items): + if item["kind"] == "prompt": + if item["text"]: + out.append({"type": "user", "message": {"content": [ + {"type": "text", "text": item["text"]}]}}) + continue + tool_id = f"agy-{n}" + out.append({"type": "assistant", "cwd": item["cwd"], "message": {"content": [ + {"type": "tool_use", "id": tool_id, "name": item["name"], "input": item["args"]}]}}) + out.append({"type": "user", "message": {"content": [ + {"type": "tool_result", "tool_use_id": tool_id, "content": item["content"], + "is_error": item["is_error"]}]}}) + try: + with open(out_path, "w", encoding="utf-8") as fh: + for rec in out: + fh.write(json.dumps(rec, ensure_ascii=False) + "\n") + except OSError: + return None + return len(out) + + +def antigravity_session_usage(path): + """Token breakdown over the planner rows that report usage; {} when none does. `input_tokens` + is fresh input only, as agy writes it, so cached re-reads are `cache_read_input_tokens`. + The transcript names no model, so `model` is "antigravity": an id no price table has, which + the ledger prices on its labelled fallback curve (with none it would be the Claude curve).""" + totals = {"input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, + "output_tokens": 0} + requests = 0 + for row in _records(path): + if row.get("type") != "PLANNER_RESPONSE": + continue + seen = False + for src, dst in _TOKENS: + v = row.get(src) + if isinstance(v, int) and not isinstance(v, bool): + totals[dst] += v + seen = True + requests += seen + if not requests: + return {} + return dict(totals, requests=requests, model="antigravity") + + +def antigravity_prompt_count(path): + """How many prompts the person gave the conversation.""" + return sum(1 for row in _records(path) if row.get("type") == "USER_INPUT" + and row.get("source") == "USER_EXPLICIT")