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.
- thwip_cli-1.11.0/ARCHITECTURE.md +222 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/PKG-INFO +61 -2
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/README.md +60 -1
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/docs/verification.md +3 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/pyproject.toml +1 -1
- thwip_cli-1.11.0/tests/test_compaction.py +139 -0
- thwip_cli-1.11.0/tests/test_evals.py +124 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_memory.py +2 -1
- thwip_cli-1.11.0/tests/test_retrieval.py +78 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_session.py +20 -2
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/__init__.py +1 -1
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/native_agent.py +3 -1
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/native_print.py +5 -1
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/cli.py +115 -40
- thwip_cli-1.11.0/thwip/compaction.py +85 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/config.py +16 -3
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/data/thwip.1 +2 -1
- thwip_cli-1.11.0/thwip/evals/__init__.py +12 -0
- thwip_cli-1.11.0/thwip/evals/__main__.py +92 -0
- thwip_cli-1.11.0/thwip/evals/fake_agent.py +90 -0
- thwip_cli-1.11.0/thwip/evals/runner.py +230 -0
- thwip_cli-1.11.0/thwip/evals/tasks.py +249 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/memory.py +37 -7
- thwip_cli-1.11.0/thwip/retrieval.py +132 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/session.py +14 -5
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/uv.lock +1 -1
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/.github/workflows/publish.yml +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/.gitignore +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/LICENSE +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/docs/handoff-research.md +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/install.sh +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/__init__.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/fake_providers.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_agents.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_audit_regressions.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_catalog.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_cli.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_compatible_streaming.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_config.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_detector.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_direct_providers_e2e.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_handoff.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_native_agents.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_native_launcher.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_native_print.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_native_rpc.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_parity_commands.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_repair_verification.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_tools.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/tests/test_utils.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/__main__.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/__init__.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/base.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/catalog.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/chat_messages.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/claude_agent.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/deepseek_agent.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/google_agent.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/groq_agent.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/native_common.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/native_rpc.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/ollama_agent.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/openai_agent.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/agents/openrouter_agent.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/data/context_template.md +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/detector.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/endpoints.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/handoff.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/limits.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/shortcuts.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/theme.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/tools/__init__.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/tools/code_runner.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/tools/file_editor.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/tools/git_ops.py +0 -0
- {thwip_cli-1.9.2 → thwip_cli-1.11.0}/thwip/tools/terminal.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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.
|
|
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"
|