thwip-cli 1.9.2__tar.gz → 1.10.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 (75) hide show
  1. thwip_cli-1.10.0/ARCHITECTURE.md +224 -0
  2. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/PKG-INFO +31 -1
  3. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/README.md +30 -0
  4. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/docs/verification.md +2 -0
  5. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/pyproject.toml +1 -1
  6. thwip_cli-1.10.0/tests/test_evals.py +116 -0
  7. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/tests/test_memory.py +2 -1
  8. thwip_cli-1.10.0/tests/test_retrieval.py +78 -0
  9. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/tests/test_session.py +20 -2
  10. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/__init__.py +1 -1
  11. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/agents/native_agent.py +3 -1
  12. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/agents/native_print.py +5 -1
  13. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/cli.py +30 -6
  14. thwip_cli-1.10.0/thwip/evals/__init__.py +12 -0
  15. thwip_cli-1.10.0/thwip/evals/__main__.py +92 -0
  16. thwip_cli-1.10.0/thwip/evals/fake_agent.py +84 -0
  17. thwip_cli-1.10.0/thwip/evals/runner.py +219 -0
  18. thwip_cli-1.10.0/thwip/evals/tasks.py +208 -0
  19. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/memory.py +37 -7
  20. thwip_cli-1.10.0/thwip/retrieval.py +132 -0
  21. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/session.py +14 -5
  22. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/uv.lock +1 -1
  23. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/.github/workflows/publish.yml +0 -0
  24. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/.gitignore +0 -0
  25. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/LICENSE +0 -0
  26. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/docs/handoff-research.md +0 -0
  27. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/install.sh +0 -0
  28. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/tests/__init__.py +0 -0
  29. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/tests/fake_providers.py +0 -0
  30. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/tests/test_agents.py +0 -0
  31. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/tests/test_audit_regressions.py +0 -0
  32. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/tests/test_catalog.py +0 -0
  33. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/tests/test_cli.py +0 -0
  34. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/tests/test_compatible_streaming.py +0 -0
  35. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/tests/test_config.py +0 -0
  36. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/tests/test_detector.py +0 -0
  37. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/tests/test_direct_providers_e2e.py +0 -0
  38. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/tests/test_handoff.py +0 -0
  39. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/tests/test_native_agents.py +0 -0
  40. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/tests/test_native_launcher.py +0 -0
  41. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/tests/test_native_print.py +0 -0
  42. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/tests/test_native_rpc.py +0 -0
  43. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/tests/test_parity_commands.py +0 -0
  44. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/tests/test_repair_verification.py +0 -0
  45. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/tests/test_tools.py +0 -0
  46. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/tests/test_utils.py +0 -0
  47. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/__main__.py +0 -0
  48. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/agents/__init__.py +0 -0
  49. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/agents/base.py +0 -0
  50. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/agents/catalog.py +0 -0
  51. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/agents/chat_messages.py +0 -0
  52. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/agents/claude_agent.py +0 -0
  53. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/agents/deepseek_agent.py +0 -0
  54. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/agents/google_agent.py +0 -0
  55. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/agents/groq_agent.py +0 -0
  56. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/agents/native_common.py +0 -0
  57. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/agents/native_rpc.py +0 -0
  58. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/agents/ollama_agent.py +0 -0
  59. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/agents/openai_agent.py +0 -0
  60. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/agents/openrouter_agent.py +0 -0
  61. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/config.py +0 -0
  62. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/data/context_template.md +0 -0
  63. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/data/thwip.1 +0 -0
  64. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/detector.py +0 -0
  65. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/endpoints.py +0 -0
  66. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/handoff.py +0 -0
  67. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/limits.py +0 -0
  68. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/shortcuts.py +0 -0
  69. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/theme.py +0 -0
  70. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/tools/__init__.py +0 -0
  71. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/tools/code_runner.py +0 -0
  72. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/tools/file_editor.py +0 -0
  73. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/tools/git_ops.py +0 -0
  74. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/tools/terminal.py +0 -0
  75. {thwip_cli-1.9.2 → thwip_cli-1.10.0}/thwip/utils.py +0 -0
@@ -0,0 +1,224 @@
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
+ | 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` |
24
+
25
+ ## What it does today, in plain language
26
+
27
+ **Finds what you already have.** On startup it scans your machine for installed AI tools and API
28
+ keys, so you do not configure anything by hand. That is `detector.py` and `config.py`.
29
+
30
+ **Talks to seven providers through one interface.** Each provider has its own adapter file that
31
+ knows that provider's quirks. They all follow the same contract, so the rest of the program does
32
+ not care which one is active. That is the `agents/` folder.
33
+
34
+ **Switches providers mid-conversation.** When you switch, it takes everything said so far and
35
+ re-sends it to the new provider, because the new provider has never seen any of it.
36
+
37
+ **Lets the AI touch your files, with limits.** The AI can read files, write files, run commands,
38
+ run code and inspect git. It cannot leave your project folder, and anything that changes a file
39
+ asks you first. That is the `tools/` folder.
40
+
41
+ **Keeps a project memory.** It reads a `context.md` file from your project and gives it to the AI
42
+ as background, so the AI knows what the project is. That is `memory.py`.
43
+
44
+ **Tracks what you spend.** Every request records tokens and estimated cost per provider. That is
45
+ `limits.py` and `utils.estimate_cost`.
46
+
47
+ **Warns you before an expensive switch.** It estimates how big the transfer will be and tells you
48
+ if you are close to the new provider's limit. That is `handoff.py`.
49
+
50
+ ## What every file does
51
+
52
+ ### The starting points
53
+
54
+ | File | Lines | What it does |
55
+ |---|---|---|
56
+ | `__main__.py` | 6 | Lets you run `python -m thwip` |
57
+ | `cli.py` | 1,906 | The whole interactive terminal: the prompt loop, every slash command, the display. The biggest file by far |
58
+ | `shortcuts.py` | 82 | Tab completion and keyboard shortcuts |
59
+ | `theme.py` | 471 | All the colours, banners, tables and markdown rendering. Each provider gets its own brand colour |
60
+
61
+ ### Setup and discovery
62
+
63
+ | File | Lines | What it does |
64
+ |---|---|---|
65
+ | `config.py` | 477 | Reads and writes `~/.thwip/config.toml`, finds API keys in environment variables and other tools' config files, runs first-time setup |
66
+ | `detector.py` | 283 | Scans your machine for installed AI CLIs, npm packages, pip packages and editor extensions |
67
+ | `endpoints.py` | 42 | Lets you point an adapter at a different server, for proxies or self-hosted models |
68
+
69
+ ### The provider adapters
70
+
71
+ | File | Lines | What it does |
72
+ |---|---|---|
73
+ | `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 |
74
+ | `agents/__init__.py` | 141 | The registry: finds the adapters, lists which are ready, picks a fallback when one hits a limit |
75
+ | `agents/claude_agent.py` | 464 | Anthropic |
76
+ | `agents/openai_agent.py` | 459 | OpenAI |
77
+ | `agents/google_agent.py` | 437 | Google Gemini |
78
+ | `agents/deepseek_agent.py` | 235 | DeepSeek, over an OpenAI-shaped API |
79
+ | `agents/openrouter_agent.py` | 223 | OpenRouter, which fronts 100+ models |
80
+ | `agents/ollama_agent.py` | 218 | Local models on your own machine. Free, private, no quota |
81
+ | `agents/groq_agent.py` | 203 | Groq, the fast one |
82
+ | `agents/catalog.py` | 139 | Asks each provider which models it actually offers right now, instead of trusting a hardcoded list |
83
+ | `agents/chat_messages.py` | 20 | Converts tool arguments into the format the Chat Completions API expects |
84
+
85
+ ### Driving other people's CLIs
86
+
87
+ These exist because some tools are signed in through their own app, and thwip should use that
88
+ sign-in rather than ask for an API key.
89
+
90
+ | File | Lines | What it does |
91
+ |---|---|---|
92
+ | `agents/native_print.py` | 357 | Runs the Claude Code and Antigravity CLIs one turn at a time and reads their streamed output |
93
+ | `agents/native_agent.py` | 288 | Talks to the Codex App Server over JSON-RPC |
94
+ | `agents/native_common.py` | 129 | Shared helpers: scrubbing output, classifying rate limits, building prompts |
95
+ | `agents/native_rpc.py` | 86 | The transport that starts the other process and sends messages to it |
96
+
97
+ ### The tools the AI can use
98
+
99
+ | File | Lines | What it does |
100
+ |---|---|---|
101
+ | `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 |
102
+ | `tools/file_editor.py` | 127 | Read, write, edit and list files inside the project folder |
103
+ | `tools/terminal.py` | 99 | Run shell commands, with process-tree cleanup so nothing is left running |
104
+ | `tools/code_runner.py` | 60 | Run Python and Node snippets |
105
+ | `tools/git_ops.py` | 44 | Status, diff and log. Read-only |
106
+
107
+ ### Evaluation
108
+
109
+ | File | Lines | What it does |
110
+ |---|---|---|
111
+ | `evals/tasks.py` | ~150 | The task set. Each task says which source file it exercises and carries a deterministic check |
112
+ | `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 |
113
+ | `evals/fake_agent.py` | ~70 | A rule-based adapter that follows the event contract, so the harness runs with no network |
114
+ | `evals/__main__.py` | ~90 | `python -m thwip.evals`: list, run offline, run live, save a report |
115
+
116
+ ### State, memory and accounting
117
+
118
+ | File | Lines | What it does |
119
+ |---|---|---|
120
+ | `session.py` | 291 | Holds the conversation. Converts it to a plain provider-neutral list when switching. Saves and reloads sessions |
121
+ | `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 |
122
+ | `retrieval.py` | ~140 | Heading-based chunking and BM25 ranking for project memory. Deterministic, no model, no network |
123
+ | `limits.py` | 112 | Records tokens and cost per provider, remembers when you hit a limit, saves it to disk |
124
+ | `handoff.py` | 93 | Before a switch, estimates the request size, fingerprints the text, and reports which capabilities you gain or lose |
125
+ | `utils.py` | 107 | Cost estimation, token formatting, stream collection, key masking |
126
+
127
+ ## How one question actually flows through the code
128
+
129
+ 1. You type into the prompt in `cli.py`.
130
+ 2. `session.py` adds your message to the conversation.
131
+ 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.
132
+ 4. `tools/__init__.py` produces the tool definitions in the shape the active provider expects.
133
+ 5. The active adapter in `agents/` sends: the system prompt, the project memory, the whole
134
+ conversation so far, and the tool definitions.
135
+ 6. The reply streams back as a series of small events: text, thinking, a tool request, token counts.
136
+ 7. If the model asks for a tool, the tool runs. If it changes a file, you are asked first.
137
+ 8. The tool result goes back to the model and it continues.
138
+ 9. `limits.py` records the tokens and cost.
139
+ 10. `theme.py` renders it all.
140
+
141
+ ## The context cost when you switch, and how to cut it
142
+
143
+ ### What happens now
144
+
145
+ Providers keep no memory of your conversation. Everything lives on your machine. So when you
146
+ switch, `session.to_portable_messages()` flattens the entire conversation into a plain list and the
147
+ new adapter sends all of it again.
148
+
149
+ Read that function and notice what it does not do: there is no trimming, no summarising, no limit.
150
+ Every user and assistant message, from the first one, goes every time.
151
+
152
+ So a single switch sends:
153
+
154
+ - the system prompt
155
+ - up to 8,000 characters of project memory
156
+ - every message in the conversation so far
157
+ - the full tool definitions
158
+
159
+ Your actual question might be twenty words. The rest is overhead, and it repeats on every switch.
160
+
161
+ `handoff.py` already measures this for you. It reports `estimated_input_tokens` and a
162
+ `context_pressure` of "below advisory budget", "near limit" or "likely over budget". The measuring
163
+ instrument exists. Nothing acts on it yet.
164
+
165
+ ### Three ways to cut it, in order of payoff
166
+
167
+ **1. Send the relevant part of the project memory, not the first part.**
168
+
169
+ `memory.injection()` reads `context.md` and keeps the first 8,000 characters. Your thwip context
170
+ file is about 27,700 characters, so roughly two thirds is discarded, and what survives is the top of
171
+ the file rather than the part that answers the question.
172
+
173
+ Fix: split the file into chunks by heading, convert each chunk into an embedding once, store them,
174
+ and at question time send only the few chunks closest in meaning to what was asked. Typical result
175
+ is a few hundred tokens instead of two thousand, and better answers because there is less noise.
176
+
177
+ **2. Trim old conversation turns.**
178
+
179
+ There is already a `/compact` command in `cli.py`, but it is manual. Make it automatic: once the
180
+ estimate from `handoff.py` crosses a threshold, summarise the oldest turns into a short paragraph
181
+ and keep the recent ones verbatim.
182
+
183
+ **3. Send tool definitions only when they are needed.**
184
+
185
+ Full tool schemas go with every request. For a conversation that is not touching files, that is
186
+ wasted. Decide per turn whether tools are plausibly needed.
187
+
188
+ ### How to prove it worked
189
+
190
+ Do item 1 first, and record `estimated_input_tokens` and cost per switch before and after. That
191
+ before-and-after number is the single most interesting thing you can put on a resume about this
192
+ project, because it is a measurement, not a claim.
193
+
194
+ ## What is missing to call this an AI engineering project
195
+
196
+ What is already here and counts: multi-provider integration, tool calling with provider-native
197
+ continuations, streaming, human approval before any mutation, cost and token accounting, retries
198
+ and fallback, offline tests with fake providers.
199
+
200
+ What is missing:
201
+
202
+ | Missing | Why it matters |
203
+ |---|---|
204
+ | Evaluation harness | No way to tell whether a change made the system better or worse. Every job description asks for this |
205
+ | Published benchmark | Seven providers wired up and never compared. The comparison is the differentiator nobody else can make cheaply |
206
+ | Retrieval | Project memory is truncated rather than searched |
207
+ | Structured outputs with validation | Only one adapter touches schemas. No validate-and-repair loop |
208
+ | Tracing | Tokens and cost are counted, but there is no per-request record of latency, tool calls and errors you can query |
209
+ | Output guardrails | Path containment and approvals protect the filesystem. Nothing checks the model's output itself |
210
+
211
+ ## Build order
212
+
213
+ 1. **Evaluation harness, about two weeks.** 30 to 50 tasks with a defined success condition. Score
214
+ across all seven providers. Record pass rate, latency, cost, tool-call success, malformed output.
215
+ Run it in CI so a regression fails the build. Writing these tasks is also how you will learn this
216
+ codebase properly, because you cannot test the tool layer without reading it.
217
+ 2. **Published benchmark, a few days.** Put the numbers on the thwip website with the method.
218
+ 3. **Retrieval in `memory.py`, one to two weeks.** Chunk, embed, store locally, retrieve top matches.
219
+ Measure it with the harness from step 1.
220
+ 4. **Structured outputs, a few days.** Schemas, validation, repair retry on malformed JSON.
221
+ 5. **Tracing, a few days.** One record per request: model, latency, tokens, cost, tools, errors.
222
+
223
+ Skip fine-tuning. Skip rewriting any of this in LangChain; the hand-written adapter layer is the
224
+ 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.10.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
@@ -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, and recall
250
+ across a provider handoff. 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:
@@ -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, and recall
213
+ across a provider handoff. 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:
@@ -19,6 +19,8 @@ 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
+ | 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 |
23
+ | 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
24
  | 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
25
  | 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
26
  | 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.10.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,116 @@
1
+ """The evaluation harness, run against the offline fake adapters."""
2
+
3
+ import json
4
+
5
+ import pytest
6
+
7
+ from thwip.evals import TASKS, get_tasks, run_suite, run_task, summarize
8
+ from thwip.evals.fake_agent import FakeEvalAgent
9
+ from thwip.evals.runner import write_report
10
+
11
+
12
+ def test_task_set_is_well_formed():
13
+ ids = [t.id for t in TASKS]
14
+ assert len(ids) == len(set(ids)) == 7
15
+ assert all(t.touches and t.title for t in TASKS)
16
+ assert [t.id for t in get_tasks(["tool-read-file"])] == ["tool-read-file"]
17
+
18
+
19
+ @pytest.mark.asyncio
20
+ async def test_all_tasks_pass_on_the_fake_adapter():
21
+ results = await run_suite([FakeEvalAgent()], TASKS)
22
+ failures = [(r.task_id, r.note) for r in results if not r.passed]
23
+ assert failures == []
24
+ by_id = {r.task_id: r for r in results}
25
+ assert by_id["tool-read-file"].tool_calls == 1 and by_id["tool-read-file"].rounds == 2
26
+ assert by_id["tool-path-containment"].tool_calls == 0 and "declined" in by_id["tool-path-containment"].note
27
+ assert by_id["tool-schemas-consistent"].provider == "fake" and by_id["contract-stream-and-done"].input_tokens > 0
28
+ summary = summarize(results)
29
+ assert summary["fake"]["pass_rate"] == 1.0 and summary["fake"]["errors"] == 0
30
+
31
+
32
+ @pytest.mark.asyncio
33
+ async def test_broken_adapter_is_caught():
34
+ results = await run_suite([FakeEvalAgent(broken=True)], get_tasks(["contract-stream-and-done", "exact-single-word"]))
35
+ assert [r.passed for r in results] == [False, False]
36
+ assert "AgentDone" in results[0].note and "pong pong" in results[1].note
37
+
38
+
39
+ @pytest.mark.asyncio
40
+ async def test_containment_check_catches_a_leak(tmp_path):
41
+ """If the tool layer ever let ../secret.txt through, the task must fail."""
42
+ from thwip.evals.tasks import Observation, check_path_containment
43
+
44
+ leaked = Observation(text="It says SECRET-42", tool_outputs=["SECRET-42\n"])
45
+ assert check_path_containment(leaked, {})[0] is False
46
+ refused = Observation(text="refused", tool_outputs=["Error: Path '../secret.txt' is outside the project workspace."])
47
+ assert check_path_containment(refused, {})[0] is True
48
+
49
+
50
+ @pytest.mark.asyncio
51
+ async def test_report_is_written(tmp_path):
52
+ results = [await run_task(FakeEvalAgent(), get_tasks(["exact-single-word"])[0])]
53
+ path = write_report(results, tmp_path / "out" / "evals.json")
54
+ payload = json.loads(path.read_text())
55
+ assert payload["results"][0]["passed"] is True and payload["summary"]["fake"]["tasks"] == 1
56
+
57
+
58
+ def test_cli_lists_and_runs(capsys):
59
+ from thwip.evals.__main__ import main
60
+
61
+ assert main(["--list"]) == 0
62
+ assert "tool-read-file" in capsys.readouterr().out
63
+ assert main(["--provider", "fake"]) == 0
64
+ assert main(["--provider", "broken", "--task", "exact-single-word"]) == 1
65
+
66
+
67
+ @pytest.mark.asyncio
68
+ async def test_native_agents_are_scored_on_the_answer_only():
69
+ from thwip.evals.tasks import Observation, check_path_containment, check_tool_read
70
+
71
+ assert check_tool_read(Observation(text="PLUM"), {"native": True})[0] is True
72
+ assert check_tool_read(Observation(text="PLUM"), {"native": False})[0] is False, "a direct adapter must go through thwip's tool"
73
+ assert "native" in check_path_containment(Observation(text="I will not read outside the project."), {"native": True})[1]
74
+
75
+
76
+ @pytest.mark.asyncio
77
+ async def test_suite_closes_native_agents():
78
+ closed = []
79
+
80
+ class Native(FakeEvalAgent):
81
+ native_tools = True
82
+ async def close(self):
83
+ closed.append(True)
84
+
85
+ await run_suite([Native()], get_tasks(["exact-single-word"]))
86
+ assert closed == [True]
87
+
88
+
89
+ @pytest.mark.asyncio
90
+ async def test_native_agent_project_points_at_fixture_during_task():
91
+ seen = []
92
+
93
+ class Native(FakeEvalAgent):
94
+ native_tools = True
95
+ project = "/original"
96
+
97
+ async def chat(self, messages, **kwargs):
98
+ seen.append(self.project)
99
+ async for event in super().chat(messages, **kwargs):
100
+ yield event
101
+
102
+ agent = Native()
103
+ await run_task(agent, get_tasks(["exact-single-word"])[0])
104
+ assert seen and seen[0] != "/original" and "thwip-eval-" in seen[0]
105
+ assert agent.project == "/original"
106
+
107
+
108
+ @pytest.mark.asyncio
109
+ async def test_recall_tasks_show_retrieval_beats_truncation():
110
+ tasks = get_tasks(["memory-deep-recall", "handoff-recall"])
111
+ retrieve = {r.task_id: r for r in await run_suite([FakeEvalAgent()], tasks, memory_mode="retrieve")}
112
+ truncate = {r.task_id: r for r in await run_suite([FakeEvalAgent()], tasks, memory_mode="truncate")}
113
+ assert retrieve["memory-deep-recall"].passed and retrieve["memory-deep-recall"].memory_chars < truncate["memory-deep-recall"].memory_chars
114
+ assert not truncate["memory-deep-recall"].passed, "the old 8,000-character cut drops the buried fact"
115
+ assert retrieve["handoff-recall"].passed and truncate["handoff-recall"].passed, "conversation history is never cut"
116
+ assert retrieve["handoff-recall"].memory_chars == 0
@@ -31,7 +31,8 @@ def test_init_detects_stack_and_never_overwrites(tmp_path):
31
31
  assert memory.init() .startswith("---\nproject: Custom"), "init never replaces an existing file"
32
32
  assert "Project memory (context.md)" in memory.injection() and "# Custom" in memory.injection()
33
33
  memory.write("---\nproject: Big\n---\n" + "x" * 20000)
34
- assert memory.injection().endswith("[Project memory truncated; read the full file with /memory]")
34
+ assert memory.injection(mode="truncate").endswith("[Project memory truncated; read the full file with /memory]")
35
+ assert "not shown" in memory.injection("anything"), "retrieve mode says how many sections were left out"
35
36
  assert memory.touch_updated("---\nupdated: 2000-01-01\nproject: Big\n---\n\nbody").split("\n")[1] != "updated: 2000-01-01"
36
37
 
37
38
 
@@ -0,0 +1,78 @@
1
+ """Retrieval over project memory: chunking, ranking, budgets, and per-turn excerpts."""
2
+
3
+ from thwip.memory import ProjectMemory
4
+ from thwip.retrieval import chunk_markdown, rank, select, tokenize
5
+
6
+ DOC = """---
7
+ project: demo
8
+ area: Tools
9
+ ---
10
+
11
+ # demo Context
12
+
13
+ ## Snapshot
14
+
15
+ - Purpose: demo app
16
+
17
+ ## Current Work
18
+
19
+ - Now: nothing
20
+
21
+ ## Decisions
22
+
23
+ - Use SQLite because it needs no server.
24
+ - The deploy token rotates on Thursdays; CODEWORD-deploy: THURSDAY.
25
+
26
+ ## Known Issues
27
+
28
+ - Antigravity resets on hotel wifi.
29
+
30
+ ## Recent Changes
31
+
32
+ ### 2026-09-01
33
+
34
+ - Added the login page.
35
+ """ + "".join(f"\n### 2026-08-{d:02d}\n\n- Filler change number {d} about {'frontend' if d % 2 else 'backend'} work.\n" for d in range(1, 30)) + """
36
+ ## Notes
37
+
38
+ """ + "".join(f"- Long note number {i} about the build pipeline and its caching layer, written to exceed one chunk.\n" for i in range(40))
39
+
40
+
41
+ def test_chunking_marks_head_and_splits_long_sections():
42
+ chunks = chunk_markdown(DOC)
43
+ ids = [c.id for c in chunks]
44
+ assert ids[:4] == ["frontmatter", "demo-context", "snapshot", "current-work"]
45
+ assert all(c.is_head for c in chunks[:4]) and not any(c.is_head for c in chunks[4:])
46
+ assert any(c.heading == "2026-08-07" for c in chunks), "dated subsections become their own chunks"
47
+ long_section = [c for c in chunks if c.heading == "Notes"]
48
+ assert len(long_section) > 1 and all(c.text.startswith("## Notes") for c in long_section)
49
+ assert "the" not in tokenize("the deploy token") and "deploy" in tokenize("the deploy token")
50
+
51
+
52
+ def test_ranking_prefers_the_matching_section():
53
+ chunks = chunk_markdown(DOC)
54
+ best = rank(chunks, "when does the deploy token rotate")[0][1]
55
+ assert best.id == "decisions" and "THURSDAY" in best.text
56
+ assert rank(chunks, "hotel wifi resets")[0][1].id == "known-issues"
57
+ assert rank(chunks, "") == []
58
+
59
+
60
+ def test_select_respects_budget_and_exclusions():
61
+ chunks = chunk_markdown(DOC)
62
+ chosen = select(chunks, "deploy token", budget=600)
63
+ assert [c.id for c in chosen][:1] == ["frontmatter"] and sum(len(c.text) for c in chosen) <= 600
64
+ without = select(chunks, "deploy token", budget=5000, exclude={"decisions"}, include_head=False)
65
+ assert "decisions" not in [c.id for c in without] and not any(c.is_head for c in without)
66
+
67
+
68
+ def test_memory_injection_modes(tmp_path):
69
+ (tmp_path / "context.md").write_text(DOC)
70
+ memory = ProjectMemory(str(tmp_path))
71
+ cut = DOC.index("CODEWORD") - 20
72
+ truncated = memory.injection(mode="truncate", budget=cut)
73
+ assert "THURSDAY" not in truncated and "truncated" in truncated, "the old behaviour drops the buried fact"
74
+ retrieved = memory.injection("deploy token rotation", budget=600)
75
+ assert "THURSDAY" in retrieved and "Snapshot" in retrieved and "not shown" in retrieved
76
+ excerpt = memory.excerpt("hotel wifi", exclude={"frontmatter", "snapshot"})
77
+ assert next(c.id for c in excerpt) == "known-issues"
78
+ assert memory.injection("anything", budget=50) == "" or "Project memory" in memory.injection("anything", budget=50)
@@ -139,13 +139,31 @@ def test_native_sessions_are_tracked_validated_and_persisted(tmp_path, monkeypat
139
139
  session.add_user_message("q")
140
140
  session.add_assistant_message("a", agent_name="openai", model="m")
141
141
  session.set_native_session("openai", "thread-1", "m")
142
- assert session.native_session("openai") == {"id": "thread-1", "synced": 2, "model": "m"}
142
+ assert session.native_session("openai") == {"id": "thread-1", "synced": 2, "model": "m", "chunks": []}
143
143
  session.native_sessions["claude"] = {"id": "x", "synced": 99}
144
144
  assert session.native_session("claude") is None, "a record that claims more messages than exist is ignored"
145
145
  path = session.save("native-track")
146
146
  loaded = Session.load("native-track")
147
- assert loaded.native_session("openai") == {"id": "thread-1", "synced": 2, "model": "m"}
147
+ assert loaded.native_session("openai") == {"id": "thread-1", "synced": 2, "model": "m", "chunks": []}
148
148
  loaded.clear_context()
149
149
  assert loaded.native_sessions == {}
150
150
  path.write_text(path.read_text().replace('"synced": 2', '"synced": "2"'))
151
151
  assert Session.load("native-track") is None, "malformed native session records reject the file"
152
+
153
+
154
+ def test_native_session_remembers_memory_chunks(tmp_path, monkeypatch):
155
+ from thwip.session import Session
156
+
157
+ monkeypatch.setenv("THWIP_CONFIG_DIR", str(tmp_path))
158
+ session = Session(project_path=str(tmp_path), current_agent="openai", current_model="m")
159
+ session.add_user_message("q")
160
+ session.add_assistant_message("a", agent_name="openai", model="m")
161
+ session.set_native_session("openai", "t1", "m", chunks=["frontmatter", "snapshot"])
162
+ session.add_user_message("q2")
163
+ session.add_assistant_message("a2", agent_name="openai", model="m")
164
+ session.set_native_session("openai", "t1", "m", chunks=["decisions"])
165
+ assert session.native_session("openai")["chunks"] == ["frontmatter", "snapshot", "decisions"]
166
+ session.set_native_session("openai", "t2", "m", chunks=["known-issues"])
167
+ assert session.native_session("openai")["chunks"] == ["known-issues"], "a new native session starts with a clean slate"
168
+ session.save("chunks-track")
169
+ assert Session.load("chunks-track").native_session("openai")["chunks"] == ["known-issues"]
@@ -1,4 +1,4 @@
1
1
  """thwip: Universal Coding Agent Multiplexer."""
2
2
 
3
- __version__ = "1.9.2"
3
+ __version__ = "1.10.0"
4
4
  __app_name__ = "thwip"
@@ -185,7 +185,7 @@ class NativeAgent(BaseAgent):
185
185
  self._thread_id = session["thread"]["id"]
186
186
  return self._thread_id, False
187
187
 
188
- async def chat(self, messages, model=None, system_prompt=None, tools=None, stream=True, resume=None):
188
+ async def chat(self, messages, model=None, system_prompt=None, tools=None, stream=True, resume=None, memory_note=""):
189
189
  rpc = await self._live_rpc()
190
190
  event_task = None
191
191
  chosen = model or self.get_default_model()
@@ -196,6 +196,8 @@ class NativeAgent(BaseAgent):
196
196
  if resume_id and not resumed:
197
197
  yield NativeActivity(description="Previous Codex thread unavailable; sending the full conversation to a new one.")
198
198
  prompt, _full = build_incremental_prompt(messages, None, synced if resumed else 0)
199
+ if memory_note:
200
+ prompt = f"{prompt}\n\n{memory_note}"
199
201
  # Drop notifications left over from an earlier turn on this process.
200
202
  while not rpc.events.empty():
201
203
  stale = rpc.events.get_nowait()
@@ -197,12 +197,14 @@ class PrintAgent(BaseAgent):
197
197
  command += ["--conversation", resume_id]
198
198
  return command, None
199
199
 
200
- async def chat(self, messages, model=None, system_prompt=None, tools=None, stream=True, resume=None):
200
+ async def chat(self, messages, model=None, system_prompt=None, tools=None, stream=True, resume=None, memory_note=""):
201
201
  chosen = model or self.get_default_model()
202
202
  resume_id = resume.get("id") if isinstance(resume, dict) else None
203
203
  synced = resume.get("synced", 0) if isinstance(resume, dict) else 0
204
204
  if resume_id:
205
205
  prompt, _full = build_incremental_prompt(messages, None if self.name == "claude" else system_prompt, synced)
206
+ if memory_note:
207
+ prompt = f"{prompt}\n\n{memory_note}"
206
208
  try:
207
209
  async for event in self._turn(prompt, chosen, system_prompt, resume_id=resume_id, new_id=None):
208
210
  yield event
@@ -212,6 +214,8 @@ class PrintAgent(BaseAgent):
212
214
  yield NativeActivity(description=f"Previous {self.display_name} session unavailable "
213
215
  f"({scrub(str(exc), 120)}); sending the full conversation to a new one.")
214
216
  prompt, _full = build_incremental_prompt(messages, None if self.name == "claude" else system_prompt, 0)
217
+ if memory_note:
218
+ prompt = f"{prompt}\n\n{memory_note}"
215
219
  async for event in self._turn(prompt, chosen, system_prompt, resume_id=None, new_id=str(uuid.uuid4())):
216
220
  yield event
217
221