thwip-cli 1.9.2__tar.gz → 1.11.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. thwip_cli-1.11.0/ARCHITECTURE.md +222 -0
  2. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/PKG-INFO +61 -2
  3. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/README.md +60 -1
  4. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/docs/verification.md +3 -0
  5. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/pyproject.toml +1 -1
  6. thwip_cli-1.11.0/tests/test_compaction.py +139 -0
  7. thwip_cli-1.11.0/tests/test_evals.py +124 -0
  8. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_memory.py +2 -1
  9. thwip_cli-1.11.0/tests/test_retrieval.py +78 -0
  10. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_session.py +20 -2
  11. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/__init__.py +1 -1
  12. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/native_agent.py +3 -1
  13. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/native_print.py +5 -1
  14. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/cli.py +115 -40
  15. thwip_cli-1.11.0/thwip/compaction.py +85 -0
  16. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/config.py +16 -3
  17. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/data/thwip.1 +2 -1
  18. thwip_cli-1.11.0/thwip/evals/__init__.py +12 -0
  19. thwip_cli-1.11.0/thwip/evals/__main__.py +92 -0
  20. thwip_cli-1.11.0/thwip/evals/fake_agent.py +90 -0
  21. thwip_cli-1.11.0/thwip/evals/runner.py +230 -0
  22. thwip_cli-1.11.0/thwip/evals/tasks.py +249 -0
  23. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/memory.py +37 -7
  24. thwip_cli-1.11.0/thwip/retrieval.py +132 -0
  25. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/session.py +14 -5
  26. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/uv.lock +1 -1
  27. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/.github/workflows/publish.yml +0 -0
  28. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/.gitignore +0 -0
  29. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/LICENSE +0 -0
  30. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/docs/handoff-research.md +0 -0
  31. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/install.sh +0 -0
  32. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/__init__.py +0 -0
  33. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/fake_providers.py +0 -0
  34. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_agents.py +0 -0
  35. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_audit_regressions.py +0 -0
  36. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_catalog.py +0 -0
  37. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_cli.py +0 -0
  38. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_compatible_streaming.py +0 -0
  39. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_config.py +0 -0
  40. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_detector.py +0 -0
  41. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_direct_providers_e2e.py +0 -0
  42. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_handoff.py +0 -0
  43. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_native_agents.py +0 -0
  44. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_native_launcher.py +0 -0
  45. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_native_print.py +0 -0
  46. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_native_rpc.py +0 -0
  47. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_parity_commands.py +0 -0
  48. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_repair_verification.py +0 -0
  49. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_tools.py +0 -0
  50. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_utils.py +0 -0
  51. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/__main__.py +0 -0
  52. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/__init__.py +0 -0
  53. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/base.py +0 -0
  54. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/catalog.py +0 -0
  55. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/chat_messages.py +0 -0
  56. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/claude_agent.py +0 -0
  57. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/deepseek_agent.py +0 -0
  58. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/google_agent.py +0 -0
  59. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/groq_agent.py +0 -0
  60. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/native_common.py +0 -0
  61. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/native_rpc.py +0 -0
  62. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/ollama_agent.py +0 -0
  63. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/openai_agent.py +0 -0
  64. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/openrouter_agent.py +0 -0
  65. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/data/context_template.md +0 -0
  66. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/detector.py +0 -0
  67. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/endpoints.py +0 -0
  68. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/handoff.py +0 -0
  69. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/limits.py +0 -0
  70. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/shortcuts.py +0 -0
  71. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/theme.py +0 -0
  72. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/tools/__init__.py +0 -0
  73. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/tools/code_runner.py +0 -0
  74. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/tools/file_editor.py +0 -0
  75. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/tools/git_ops.py +0 -0
  76. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/tools/terminal.py +0 -0
  77. {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/utils.py +0 -0
@@ -0,0 +1,222 @@
1
+ # thwip — how it works, in plain language
2
+
3
+ Written 2026-10-03 against version 1.9.2. About 8,700 lines of Python, 21 test files.
4
+
5
+ ## What thwip is
6
+
7
+ You install one command. Inside it you can talk to seven different AI providers, swap between
8
+ them in the middle of a conversation without losing what was said, and let them read and change
9
+ files in your project with your approval.
10
+
11
+ Nothing else does the swapping part. That is the idea the project is built around.
12
+
13
+ ## Status today
14
+
15
+ | Thing | State |
16
+ |---|---|
17
+ | Published | Yes, on PyPI as `thwip-cli`, version 1.9.2 |
18
+ | Providers | Seven: Anthropic, OpenAI, Google, DeepSeek, Groq, Ollama, OpenRouter |
19
+ | Tools the AI can use | Four: file editing, terminal, code running, git |
20
+ | Tests | 21 test files, all offline using fake providers |
21
+ | Cost tracking | Yes, per provider, saved between runs |
22
+ | Evaluation | Started 2026-10-03: `thwip/evals/` with five tasks, an offline fake adapter, and `python -m thwip.evals`. Run live against Claude Code, Antigravity and Codex the same day |
23
+ | Compaction | Yes, since 2026-10-03: `compaction.py` summarises older turns on a provider other than the active one, keeps recent turns verbatim, and is offered automatically near the context window. Quota warnings come from the CLIs' own usage reports |
24
+ | Retrieval | Yes, since 2026-10-03: `retrieval.py` splits `context.md` at headings and ranks sections against the current message with BM25; the head always goes, the rest by relevance, within a budget. No embeddings or vector store yet; measured by `memory-deep-recall` |
25
+
26
+ ## What it does today, in plain language
27
+
28
+ **Finds what you already have.** On startup it scans your machine for installed AI tools and API
29
+ keys, so you do not configure anything by hand. That is `detector.py` and `config.py`.
30
+
31
+ **Talks to seven providers through one interface.** Each provider has its own adapter file that
32
+ knows that provider's quirks. They all follow the same contract, so the rest of the program does
33
+ not care which one is active. That is the `agents/` folder.
34
+
35
+ **Switches providers mid-conversation.** When you switch, it takes everything said so far and
36
+ re-sends it to the new provider, because the new provider has never seen any of it.
37
+
38
+ **Lets the AI touch your files, with limits.** The AI can read files, write files, run commands,
39
+ run code and inspect git. It cannot leave your project folder, and anything that changes a file
40
+ asks you first. That is the `tools/` folder.
41
+
42
+ **Keeps a project memory.** It reads a `context.md` file from your project and gives it to the AI
43
+ as background, so the AI knows what the project is. That is `memory.py`.
44
+
45
+ **Tracks what you spend.** Every request records tokens and estimated cost per provider. That is
46
+ `limits.py` and `utils.estimate_cost`.
47
+
48
+ **Warns you before an expensive switch.** It estimates how big the transfer will be and tells you
49
+ if you are close to the new provider's limit. That is `handoff.py`.
50
+
51
+ ## What every file does
52
+
53
+ ### The starting points
54
+
55
+ | File | Lines | What it does |
56
+ |---|---|---|
57
+ | `__main__.py` | 6 | Lets you run `python -m thwip` |
58
+ | `cli.py` | 1,906 | The whole interactive terminal: the prompt loop, every slash command, the display. The biggest file by far |
59
+ | `shortcuts.py` | 82 | Tab completion and keyboard shortcuts |
60
+ | `theme.py` | 471 | All the colours, banners, tables and markdown rendering. Each provider gets its own brand colour |
61
+
62
+ ### Setup and discovery
63
+
64
+ | File | Lines | What it does |
65
+ |---|---|---|
66
+ | `config.py` | 477 | Reads and writes `~/.thwip/config.toml`, finds API keys in environment variables and other tools' config files, runs first-time setup |
67
+ | `detector.py` | 283 | Scans your machine for installed AI CLIs, npm packages, pip packages and editor extensions |
68
+ | `endpoints.py` | 42 | Lets you point an adapter at a different server, for proxies or self-hosted models |
69
+
70
+ ### The provider adapters
71
+
72
+ | File | Lines | What it does |
73
+ |---|---|---|
74
+ | `agents/base.py` | 424 | The contract every adapter must follow. Defines what a capability is, what events can come back while streaming, and what a model's limits are. Everything else inherits from this |
75
+ | `agents/__init__.py` | 141 | The registry: finds the adapters, lists which are ready, picks a fallback when one hits a limit |
76
+ | `agents/claude_agent.py` | 464 | Anthropic |
77
+ | `agents/openai_agent.py` | 459 | OpenAI |
78
+ | `agents/google_agent.py` | 437 | Google Gemini |
79
+ | `agents/deepseek_agent.py` | 235 | DeepSeek, over an OpenAI-shaped API |
80
+ | `agents/openrouter_agent.py` | 223 | OpenRouter, which fronts 100+ models |
81
+ | `agents/ollama_agent.py` | 218 | Local models on your own machine. Free, private, no quota |
82
+ | `agents/groq_agent.py` | 203 | Groq, the fast one |
83
+ | `agents/catalog.py` | 139 | Asks each provider which models it actually offers right now, instead of trusting a hardcoded list |
84
+ | `agents/chat_messages.py` | 20 | Converts tool arguments into the format the Chat Completions API expects |
85
+
86
+ ### Driving other people's CLIs
87
+
88
+ These exist because some tools are signed in through their own app, and thwip should use that
89
+ sign-in rather than ask for an API key.
90
+
91
+ | File | Lines | What it does |
92
+ |---|---|---|
93
+ | `agents/native_print.py` | 357 | Runs the Claude Code and Antigravity CLIs one turn at a time and reads their streamed output |
94
+ | `agents/native_agent.py` | 288 | Talks to the Codex App Server over JSON-RPC |
95
+ | `agents/native_common.py` | 129 | Shared helpers: scrubbing output, classifying rate limits, building prompts |
96
+ | `agents/native_rpc.py` | 86 | The transport that starts the other process and sends messages to it |
97
+
98
+ ### The tools the AI can use
99
+
100
+ | File | Lines | What it does |
101
+ |---|---|---|
102
+ | `tools/__init__.py` | 265 | The registry. Also translates one tool definition into both OpenAI's and Anthropic's formats, because they disagree on the shape |
103
+ | `tools/file_editor.py` | 127 | Read, write, edit and list files inside the project folder |
104
+ | `tools/terminal.py` | 99 | Run shell commands, with process-tree cleanup so nothing is left running |
105
+ | `tools/code_runner.py` | 60 | Run Python and Node snippets |
106
+ | `tools/git_ops.py` | 44 | Status, diff and log. Read-only |
107
+
108
+ ### Evaluation
109
+
110
+ | File | Lines | What it does |
111
+ |---|---|---|
112
+ | `evals/tasks.py` | ~150 | The task set. Each task says which source file it exercises and carries a deterministic check |
113
+ | `evals/runner.py` | ~190 | Runs a task the way the REPL would (tool rounds included), records latency, tokens, cost and tool calls, applies the check, writes JSON |
114
+ | `evals/fake_agent.py` | ~70 | A rule-based adapter that follows the event contract, so the harness runs with no network |
115
+ | `evals/__main__.py` | ~90 | `python -m thwip.evals`: list, run offline, run live, save a report |
116
+
117
+ ### State, memory and accounting
118
+
119
+ | File | Lines | What it does |
120
+ |---|---|---|
121
+ | `session.py` | 291 | Holds the conversation. Converts it to a plain provider-neutral list when switching. Saves and reloads sessions |
122
+ | `memory.py` | ~450 | Reads and writes the project's `context.md`, chooses which sections to send for a question, and syncs an Obsidian vault of project cards. Only ever overwrites files it created itself |
123
+ | `retrieval.py` | ~140 | Heading-based chunking and BM25 ranking for project memory. Deterministic, no model, no network |
124
+ | `compaction.py` | ~90 | Worker selection (never the active provider), the summary request, and the split between summarised and verbatim turns |
125
+ | `limits.py` | 112 | Records tokens and cost per provider, remembers when you hit a limit, saves it to disk |
126
+ | `handoff.py` | 93 | Before a switch, estimates the request size, fingerprints the text, and reports which capabilities you gain or lose |
127
+ | `utils.py` | 107 | Cost estimation, token formatting, stream collection, key masking |
128
+
129
+ ## How one question actually flows through the code
130
+
131
+ 1. You type into the prompt in `cli.py`.
132
+ 2. `session.py` adds your message to the conversation.
133
+ 3. `memory.py` reads the project's `context.md`, keeps its head, and adds the sections `retrieval.py` ranks highest for your message, within a budget.
134
+ 4. `tools/__init__.py` produces the tool definitions in the shape the active provider expects.
135
+ 5. The active adapter in `agents/` sends: the system prompt, the project memory, the whole
136
+ conversation so far, and the tool definitions.
137
+ 6. The reply streams back as a series of small events: text, thinking, a tool request, token counts.
138
+ 7. If the model asks for a tool, the tool runs. If it changes a file, you are asked first.
139
+ 8. The tool result goes back to the model and it continues.
140
+ 9. `limits.py` records the tokens and cost.
141
+ 10. `theme.py` renders it all.
142
+
143
+ ## The context cost when you switch, and how to cut it
144
+
145
+ ### What happens now
146
+
147
+ Providers keep no memory of your conversation. Everything lives on your machine. So when you
148
+ switch, `session.to_portable_messages()` flattens the entire conversation into a plain list and the
149
+ new adapter sends all of it again.
150
+
151
+ Read that function and notice what it does not do: there is no trimming, no summarising, no limit.
152
+ Every user and assistant message, from the first one, goes every time.
153
+
154
+ So a single switch sends:
155
+
156
+ - the system prompt
157
+ - up to 8,000 characters of project memory
158
+ - every message in the conversation so far
159
+ - the full tool definitions
160
+
161
+ Your actual question might be twenty words. The rest is overhead, and it repeats on every switch.
162
+
163
+ `handoff.py` already measures this for you. It reports `estimated_input_tokens` and a
164
+ `context_pressure` of "below advisory budget", "near limit" or "likely over budget". The measuring
165
+ instrument exists. Nothing acts on it yet.
166
+
167
+ ### Three ways to cut it, in order of payoff
168
+
169
+ **1. Send the relevant part of the project memory, not the first part.**
170
+
171
+ `memory.injection()` reads `context.md` and keeps the first 8,000 characters. Your thwip context
172
+ file is about 27,700 characters, so roughly two thirds is discarded, and what survives is the top of
173
+ the file rather than the part that answers the question.
174
+
175
+ Fix: split the file into chunks by heading, convert each chunk into an embedding once, store them,
176
+ and at question time send only the few chunks closest in meaning to what was asked. Typical result
177
+ is a few hundred tokens instead of two thousand, and better answers because there is less noise.
178
+
179
+ **2. Trim old conversation turns.** Done: `/compact` and the automatic offer at 65 percent of the window summarise older turns on another provider and keep the recent ones verbatim.
180
+
181
+ **3. Send tool definitions only when they are needed.**
182
+
183
+ Full tool schemas go with every request. For a conversation that is not touching files, that is
184
+ wasted. Decide per turn whether tools are plausibly needed.
185
+
186
+ ### How to prove it worked
187
+
188
+ Do item 1 first, and record `estimated_input_tokens` and cost per switch before and after. That
189
+ before-and-after number is the single most interesting thing you can put on a resume about this
190
+ project, because it is a measurement, not a claim.
191
+
192
+ ## What is missing to call this an AI engineering project
193
+
194
+ What is already here and counts: multi-provider integration, tool calling with provider-native
195
+ continuations, streaming, human approval before any mutation, cost and token accounting, retries
196
+ and fallback, offline tests with fake providers.
197
+
198
+ What is missing:
199
+
200
+ | Missing | Why it matters |
201
+ |---|---|
202
+ | Evaluation harness | No way to tell whether a change made the system better or worse. Every job description asks for this |
203
+ | Published benchmark | Seven providers wired up and never compared. The comparison is the differentiator nobody else can make cheaply |
204
+ | Retrieval | Project memory is truncated rather than searched |
205
+ | Structured outputs with validation | Only one adapter touches schemas. No validate-and-repair loop |
206
+ | Tracing | Tokens and cost are counted, but there is no per-request record of latency, tool calls and errors you can query |
207
+ | Output guardrails | Path containment and approvals protect the filesystem. Nothing checks the model's output itself |
208
+
209
+ ## Build order
210
+
211
+ 1. **Evaluation harness, about two weeks.** 30 to 50 tasks with a defined success condition. Score
212
+ across all seven providers. Record pass rate, latency, cost, tool-call success, malformed output.
213
+ Run it in CI so a regression fails the build. Writing these tasks is also how you will learn this
214
+ codebase properly, because you cannot test the tool layer without reading it.
215
+ 2. **Published benchmark, a few days.** Put the numbers on the thwip website with the method.
216
+ 3. **Retrieval in `memory.py`, one to two weeks.** Chunk, embed, store locally, retrieve top matches.
217
+ Measure it with the harness from step 1.
218
+ 4. **Structured outputs, a few days.** Schemas, validation, repair retry on malformed JSON.
219
+ 5. **Tracing, a few days.** One record per request: model, latency, tokens, cost, tools, errors.
220
+
221
+ Skip fine-tuning. Skip rewriting any of this in LangChain; the hand-written adapter layer is the
222
+ part that makes the project distinctive.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: thwip-cli
3
- Version: 1.9.2
3
+ Version: 1.11.0
4
4
  Summary: Universal coding agent multiplexer: detect, switch, and route between AI coding agents seamlessly
5
5
  Project-URL: Homepage, https://github.com/tanmayhutt/thwip-cli
6
6
  Project-URL: Repository, https://github.com/tanmayhutt/thwip-cli
@@ -108,7 +108,7 @@ thwip install-man # then `man thwip` works
108
108
  | `/model [id]` | Pick a model for the current agent from a numbered list or by ID |
109
109
  | `/new` | Start a fresh conversation; the current one is saved first |
110
110
  | `/resume [name]` | Resume a saved session from a numbered list |
111
- | `/compact` | Summarize the conversation with the current model to free context; the summary is plain text and travels across providers |
111
+ | `/compact` | Summarize older turns on another provider and keep recent turns verbatim; the summary is plain text and travels across providers |
112
112
  | `/diff [staged]` | Show the project's git diff |
113
113
  | `/copy` | Copy the last response to the clipboard |
114
114
  | `/export [path]` | Write the conversation to Markdown with per-message model attribution |
@@ -203,6 +203,16 @@ notes are never touched.
203
203
  | `/memory link` | Add a pointer to `AGENTS.md` and `CLAUDE.md` so the CLIs read it outside thwip too |
204
204
  | `/memory sync all` | File every project found under the configured `scan` folders and rebuild all cross-project links at once |
205
205
 
206
+ **How the memory reaches the model.** The file is split into sections at its headings. The head
207
+ (frontmatter, Snapshot, Current Work) always goes. The rest is ranked against your current message
208
+ with BM25, the keyword-ranking formula used by search engines, and the best-matching sections are
209
+ added within a character budget. A warm native session is only sent sections it has not already
210
+ seen, as an excerpt attached to the message. Nothing is truncated from the top any more; a note
211
+ says how many sections were left out so the model can ask. No embeddings, no vector database:
212
+ at a few dozen sections per project that would add a dependency without adding recall. The
213
+ `memory-deep-recall` evaluation task measures this against the old first-8,000-characters behaviour
214
+ (`python -m thwip.evals --memory truncate`).
215
+
206
216
  When you `/quit` after a real conversation, thwip offers the update once. Settings live under
207
217
  `[memory]` in `~/.thwip/config.toml`:
208
218
 
@@ -220,6 +230,26 @@ If your vault already has hand-written project notes under `Projects/`, set `car
220
230
  subfolder such as `Projects/thwip`. thwip's cards, hubs, and dashboard then live inside that
221
231
  folder and link among themselves, and your own notes stay untouched.
222
232
 
233
+ ## Evaluation harness
234
+
235
+ `thwip.evals` runs a fixed set of tasks against any adapter and scores them deterministically:
236
+ pass or fail, latency, tokens, estimated cost, tool calls, malformed tool calls. The same tasks run
237
+ against an in-process fake adapter, so the scoring code is itself tested offline and in CI.
238
+
239
+ ```bash
240
+ python -m thwip.evals --list # the task set and the source file each task exercises
241
+ python -m thwip.evals # offline, against the fake adapter; exit code 1 on any failure
242
+ python -m thwip.evals --provider broken # a deliberately bad adapter, to see failures reported
243
+ python -m thwip.evals --provider all --out evals.json # every ready provider, results saved as JSON
244
+ python -m thwip.evals --provider openai --task tool-read-file
245
+ ```
246
+
247
+ Each task names the file it tests: the streaming event contract in `agents/base.py`, exact
248
+ instruction following, a tool round trip and the path guard in `tools/`, the consistency of
249
+ the OpenAI and Anthropic tool schemas, deep recall from a long project memory file, recall
250
+ across a provider handoff, recall after compaction, and the compaction worker rule. Native CLIs bring their own tools, so for them the tool
251
+ tasks score the final answer only and say so in the note.
252
+
223
253
  ## Live model lists
224
254
 
225
255
  Model lists are not hardcoded. Each connected source supplies its own list:
@@ -253,6 +283,35 @@ The repository uses this to run every direct adapter end to end against a local
253
283
  provider in `tests/fake_providers.py`, so streaming, tool rounds, live catalogs, and
254
284
  HTTP 429 handling are verified through the real SDKs without spending on real keys.
255
285
 
286
+ ## Compaction and early warnings
287
+
288
+ Two different limits, two different protections.
289
+
290
+ **Context window.** When the conversation's estimated size reaches `compact_at_percent` of the
291
+ active model's window (65 percent by default; models that publish no window are assumed to have
292
+ `assumed_context_tokens`, 200,000), thwip offers to compact. Compaction summarises the older turns
293
+ and keeps the last `keep_recent_messages` (4) word for word. The summary is written by a ready
294
+ provider other than the active one, because the provider that is running out of room or quota is
295
+ the one that cannot be trusted with one more request; the active provider is used only when nothing
296
+ else is connected, with a warning. `/compact` does the same on demand. The transcript on disk is
297
+ never deleted. Every provider's warm session restarts from the compact history on its next turn.
298
+
299
+ **Usage quota.** Codex and Claude Code report their 5-hour and 7-day usage after each turn. When a
300
+ window reaches `quota_warn_percent` (85 percent by default) thwip warns once per window and names
301
+ the ready alternatives, so you can switch before the limit hits rather than after. Antigravity
302
+ reports no windows, so for it the first signal is still the limit error, which failover handles.
303
+
304
+ ```toml
305
+ [limits]
306
+ quota_warn_percent = 85
307
+ compact_at_percent = 65
308
+ assumed_context_tokens = 200000
309
+ keep_recent_messages = 4
310
+ ```
311
+
312
+ The evaluation harness covers both: `compaction-recall` checks a fact from early in a long
313
+ conversation survives summarisation, and `compaction-worker-never-active` checks the worker rule.
314
+
256
315
  ## Usage-limit failover
257
316
 
258
317
  When the active provider reports an exhausted limit (Codex "You've hit your usage
@@ -71,7 +71,7 @@ thwip install-man # then `man thwip` works
71
71
  | `/model [id]` | Pick a model for the current agent from a numbered list or by ID |
72
72
  | `/new` | Start a fresh conversation; the current one is saved first |
73
73
  | `/resume [name]` | Resume a saved session from a numbered list |
74
- | `/compact` | Summarize the conversation with the current model to free context; the summary is plain text and travels across providers |
74
+ | `/compact` | Summarize older turns on another provider and keep recent turns verbatim; the summary is plain text and travels across providers |
75
75
  | `/diff [staged]` | Show the project's git diff |
76
76
  | `/copy` | Copy the last response to the clipboard |
77
77
  | `/export [path]` | Write the conversation to Markdown with per-message model attribution |
@@ -166,6 +166,16 @@ notes are never touched.
166
166
  | `/memory link` | Add a pointer to `AGENTS.md` and `CLAUDE.md` so the CLIs read it outside thwip too |
167
167
  | `/memory sync all` | File every project found under the configured `scan` folders and rebuild all cross-project links at once |
168
168
 
169
+ **How the memory reaches the model.** The file is split into sections at its headings. The head
170
+ (frontmatter, Snapshot, Current Work) always goes. The rest is ranked against your current message
171
+ with BM25, the keyword-ranking formula used by search engines, and the best-matching sections are
172
+ added within a character budget. A warm native session is only sent sections it has not already
173
+ seen, as an excerpt attached to the message. Nothing is truncated from the top any more; a note
174
+ says how many sections were left out so the model can ask. No embeddings, no vector database:
175
+ at a few dozen sections per project that would add a dependency without adding recall. The
176
+ `memory-deep-recall` evaluation task measures this against the old first-8,000-characters behaviour
177
+ (`python -m thwip.evals --memory truncate`).
178
+
169
179
  When you `/quit` after a real conversation, thwip offers the update once. Settings live under
170
180
  `[memory]` in `~/.thwip/config.toml`:
171
181
 
@@ -183,6 +193,26 @@ If your vault already has hand-written project notes under `Projects/`, set `car
183
193
  subfolder such as `Projects/thwip`. thwip's cards, hubs, and dashboard then live inside that
184
194
  folder and link among themselves, and your own notes stay untouched.
185
195
 
196
+ ## Evaluation harness
197
+
198
+ `thwip.evals` runs a fixed set of tasks against any adapter and scores them deterministically:
199
+ pass or fail, latency, tokens, estimated cost, tool calls, malformed tool calls. The same tasks run
200
+ against an in-process fake adapter, so the scoring code is itself tested offline and in CI.
201
+
202
+ ```bash
203
+ python -m thwip.evals --list # the task set and the source file each task exercises
204
+ python -m thwip.evals # offline, against the fake adapter; exit code 1 on any failure
205
+ python -m thwip.evals --provider broken # a deliberately bad adapter, to see failures reported
206
+ python -m thwip.evals --provider all --out evals.json # every ready provider, results saved as JSON
207
+ python -m thwip.evals --provider openai --task tool-read-file
208
+ ```
209
+
210
+ Each task names the file it tests: the streaming event contract in `agents/base.py`, exact
211
+ instruction following, a tool round trip and the path guard in `tools/`, the consistency of
212
+ the OpenAI and Anthropic tool schemas, deep recall from a long project memory file, recall
213
+ across a provider handoff, recall after compaction, and the compaction worker rule. Native CLIs bring their own tools, so for them the tool
214
+ tasks score the final answer only and say so in the note.
215
+
186
216
  ## Live model lists
187
217
 
188
218
  Model lists are not hardcoded. Each connected source supplies its own list:
@@ -216,6 +246,35 @@ The repository uses this to run every direct adapter end to end against a local
216
246
  provider in `tests/fake_providers.py`, so streaming, tool rounds, live catalogs, and
217
247
  HTTP 429 handling are verified through the real SDKs without spending on real keys.
218
248
 
249
+ ## Compaction and early warnings
250
+
251
+ Two different limits, two different protections.
252
+
253
+ **Context window.** When the conversation's estimated size reaches `compact_at_percent` of the
254
+ active model's window (65 percent by default; models that publish no window are assumed to have
255
+ `assumed_context_tokens`, 200,000), thwip offers to compact. Compaction summarises the older turns
256
+ and keeps the last `keep_recent_messages` (4) word for word. The summary is written by a ready
257
+ provider other than the active one, because the provider that is running out of room or quota is
258
+ the one that cannot be trusted with one more request; the active provider is used only when nothing
259
+ else is connected, with a warning. `/compact` does the same on demand. The transcript on disk is
260
+ never deleted. Every provider's warm session restarts from the compact history on its next turn.
261
+
262
+ **Usage quota.** Codex and Claude Code report their 5-hour and 7-day usage after each turn. When a
263
+ window reaches `quota_warn_percent` (85 percent by default) thwip warns once per window and names
264
+ the ready alternatives, so you can switch before the limit hits rather than after. Antigravity
265
+ reports no windows, so for it the first signal is still the limit error, which failover handles.
266
+
267
+ ```toml
268
+ [limits]
269
+ quota_warn_percent = 85
270
+ compact_at_percent = 65
271
+ assumed_context_tokens = 200000
272
+ keep_recent_messages = 4
273
+ ```
274
+
275
+ The evaluation harness covers both: `compaction-recall` checks a fact from early in a long
276
+ conversation survives summarisation, and `compaction-worker-never-active` checks the worker rule.
277
+
219
278
  ## Usage-limit failover
220
279
 
221
280
  When the active provider reports an exhausted limit (Codex "You've hit your usage
@@ -19,6 +19,9 @@ each using its existing sign-in. No API keys were configured.
19
19
  | Ctrl+C during a response | Turn cancelled, child process terminated, REPL continued, unanswered message removed |
20
20
  | Ctrl+C at a Codex permission prompt | Turn cancelled, no file created, no leftover process, REPL continued |
21
21
  | Usage-limit failover | With a test-only shim making Codex report "You've hit your usage limit", the real REPL showed the alternatives, switched to Claude Code on `1`, retried the message, and answered; history held one clean pair |
22
+ | Compaction and quota warnings (2026-10-03) | Live with lowered thresholds: Codex received per-window quota warnings naming Claude Code and Antigravity as ready; the compaction offer appeared as the conversation grew; on `y` Claude Code wrote the summary while Codex was active; Codex then recalled the early code word from the compacted history; no leftover processes. Offline: worker never the active provider, recent turns kept starting at a user message, limit and empty-summary errors, offer fires once per size, warning once per window |
23
+ | Retrieval for project memory (2026-10-03) | Offline: `memory-deep-recall` fails in truncate mode and passes in retrieve mode with fewer memory characters; `handoff-recall` passes in both because conversation history is never cut. Live numbers recorded in context.md |
24
+ | Evaluation harness (2026-10-03) | Five tasks pass on the offline fake; the broken fake fails the contract and exact-word tasks as intended. Live run over Claude Code, Antigravity and Codex: all three pass the contract, exact-word and containment tasks; the read-file task scores native CLIs on the answer only because their tool calls are not observable. Latency per task: Claude Code 4 to 14 s, Codex 5 to 37 s, Antigravity 40 to 45 s on this network |
22
25
  | First-run and returning-user walkthrough (v1.9.2) | Driven as a brand-new user from an empty config: onboarding, chat, `/memory init`, compact `/switch`, `/status`, `/memory update` with diff, quit offer; then restart, `/resume`, `/history`, `/memory`, Ctrl+C exit with the memory offer. Fixed on the way: `/switch` printed four blocks (now one line unless a capability is lost), long paths wrapped every panel (now shown relative to home), the template wrote an empty `tags: []`, `/status` showed an API-key field for native agents, sessions stored the project as `.` so `/resume` from another directory pointed at the wrong project, `/resume <number>` was not accepted, and Ctrl+C at the prompt skipped the memory offer |
23
26
  | Config sections (v1.9.0) | Found and fixed: the loader's section whitelist dropped `[memory]` and `[endpoints]`, so onboarding repeated and file-based endpoint overrides were ignored. Round-trip regression test added |
24
27
  | Vault sync all (v1.9.0) | `/memory sync all` over the three project collections filed 26 cards under `Projects/thwip/` with Stack, Areas, and Tags hubs and a dashboard inside that folder, and left every hand-written note untouched |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thwip-cli"
7
- version = "1.9.2"
7
+ version = "1.11.0"
8
8
  description = "Universal coding agent multiplexer: detect, switch, and route between AI coding agents seamlessly"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,139 @@
1
+ """Compaction on a different provider, proactive offers, and quota warnings."""
2
+
3
+ from types import SimpleNamespace
4
+
5
+ import pytest
6
+
7
+ from thwip.agents.base import AgentDone, LimitHit, LimitStatus, TextDelta
8
+ from thwip.compaction import choose_worker, compacted_messages, split_for_compaction, summarize, transcript
9
+
10
+
11
+ class Agent:
12
+ capabilities = set()
13
+
14
+ def __init__(self, name, reply="summary text", limit=False):
15
+ self.name = name
16
+ self.display_name = name
17
+ self.company = name
18
+ self.reply = reply
19
+ self.limit = limit
20
+ self.calls = 0
21
+
22
+ def is_configured(self):
23
+ return True
24
+
25
+ def get_default_model(self):
26
+ return f"{self.name}-model"
27
+
28
+ def get_handoff_models(self):
29
+ from thwip.agents.base import ModelInfo
30
+ return [ModelInfo(id=f"{self.name}-model", name=self.name)]
31
+
32
+ def get_model_info(self, model_id):
33
+ return next((m for m in self.get_handoff_models() if m.id == model_id), None)
34
+
35
+ async def chat(self, **kwargs):
36
+ self.calls += 1
37
+ if self.limit:
38
+ yield LimitHit(error_type=LimitStatus.QUOTA_EXHAUSTED, message="usage limit")
39
+ return
40
+ yield TextDelta(content=self.reply)
41
+ yield AgentDone()
42
+
43
+
44
+ def test_worker_is_never_the_active_provider_when_another_is_ready():
45
+ a, b, c = Agent("a"), Agent("b"), Agent("c")
46
+ assert choose_worker(a, [a, b, c]) is b
47
+ assert choose_worker(a, [a, b, c], chain=["c/m", "b/m"]) is c
48
+ assert choose_worker(a, [a]) is a, "last resort: the active provider when nothing else exists"
49
+ assert choose_worker(a, []) is a, "a connected active provider is still a last resort"
50
+ assert choose_worker(SimpleNamespace(name="z", is_configured=lambda: False), []) is None
51
+
52
+
53
+ def test_split_keeps_recent_turns_starting_at_a_user_message():
54
+ history = [{"role": "user", "content": f"q{i}"} if i % 2 == 0 else {"role": "assistant", "content": f"a{i}"} for i in range(10)]
55
+ older, recent = split_for_compaction(history, 3)
56
+ assert [m["content"] for m in recent] == ["q8", "a9"], "an odd cut moves forward to the next user message"
57
+ assert len(older) == 8
58
+ assert split_for_compaction(history, 0) == (history, [])
59
+ assert split_for_compaction(history[:2], 4) == (history[:2], [])
60
+ assert "[User]\nq0" in transcript(history)
61
+
62
+
63
+ @pytest.mark.asyncio
64
+ async def test_summarize_uses_the_worker_and_reports_limits():
65
+ worker = Agent("b", reply=" Context\n- things ")
66
+ assert await summarize(worker, [{"role": "user", "content": "x"}]) == "Context\n- things"
67
+ with pytest.raises(RuntimeError, match="hit a limit"):
68
+ await summarize(Agent("c", limit=True), [{"role": "user", "content": "x"}])
69
+ with pytest.raises(RuntimeError, match="empty summary"):
70
+ await summarize(Agent("d", reply=" "), [{"role": "user", "content": "x"}])
71
+ compact = compacted_messages("S", [{"role": "user", "content": "recent"}])
72
+ assert compact[0]["content"].endswith("S") and compact[-1]["content"] == "recent" and len(compact) == 3
73
+
74
+
75
+ @pytest.fixture
76
+ def cli(tmp_path, monkeypatch):
77
+ from thwip.cli import ThwipCLI
78
+ from thwip.config import ThwipConfig
79
+ from thwip.session import Session
80
+ from thwip.tools import ToolManager
81
+
82
+ monkeypatch.setenv("THWIP_CONFIG_DIR", str(tmp_path / "cfg"))
83
+ cli = ThwipCLI.__new__(ThwipCLI)
84
+ cli.config = ThwipConfig(project=str(tmp_path), auto_save=False)
85
+ cli.tool_manager = ToolManager(str(tmp_path))
86
+ cli.session = Session(project_path=str(tmp_path), current_agent="a", current_model="a-model")
87
+ active, other = Agent("a"), Agent("b", reply="Context\n- CODEWORD is OLIVE")
88
+ cli.current_agent = active
89
+ cli.registry = SimpleNamespace(get_ready_agents=lambda: [active, other], get_agent=lambda n: active)
90
+ for i in range(6):
91
+ cli.session.add_user_message(f"question {i}")
92
+ cli.session.add_assistant_message(f"answer {i}", agent_name="a", model="a-model")
93
+ cli.session.set_native_session("a", "thread-1", "a-model")
94
+ return cli, active, other
95
+
96
+
97
+ @pytest.mark.asyncio
98
+ async def test_cmd_compact_uses_other_provider_and_keeps_recent_turns(cli):
99
+ cli, active, other = cli
100
+ assert await cli.cmd_compact() is True
101
+ assert other.calls == 1 and active.calls == 0, "the active provider never writes its own summary"
102
+ contents = [m.content for m in cli.session.messages]
103
+ assert contents[0].startswith("Summary of the conversation so far") and "OLIVE" in contents[0]
104
+ assert contents[-4:] == ["question 4", "answer 4", "question 5", "answer 5"]
105
+ assert cli.session.native_sessions == {}, "native sessions restart from the compact history"
106
+
107
+
108
+ @pytest.mark.asyncio
109
+ async def test_compaction_offer_fires_once_near_the_window(cli, monkeypatch):
110
+ cli, _active, _other = cli
111
+ cli.config.limits.assumed_context_tokens = 300
112
+ cli.config.limits.compact_at_percent = 50
113
+ asked = []
114
+
115
+ async def ask(question):
116
+ asked.append(question)
117
+ return "n"
118
+ monkeypatch.setattr(cli, "_ask_text", ask)
119
+ await cli._maybe_offer_compaction()
120
+ await cli._maybe_offer_compaction()
121
+ assert len(asked) == 1 and "using b" in asked[0] and asked[0].startswith("Compact")
122
+ cli.config.limits.assumed_context_tokens = 10_000_000
123
+ cli._compaction_offered_at = 0
124
+ await cli._maybe_offer_compaction()
125
+ assert len(asked) == 1, "no offer when the conversation is small"
126
+
127
+
128
+ def test_quota_warning_recommends_a_ready_alternative(cli, monkeypatch):
129
+ cli, active, _other = cli
130
+ printed = []
131
+ monkeypatch.setattr("thwip.cli.print_warning", lambda message: printed.append(message))
132
+ active.limit_windows = [{"label": "5h", "used_percent": 91, "resets_at": 1790000000},
133
+ {"label": "7d", "used_percent": 20, "resets_at": None}]
134
+ cli._quota_warning()
135
+ cli._quota_warning()
136
+ assert len(printed) == 1 and "91%" in printed[0] and "/switch b" in printed[0]
137
+ active.limit_windows[0]["resets_at"] = 1790009999
138
+ cli._quota_warning()
139
+ assert len(printed) == 2, "a new window (new reset time) warns again"