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