Releases: xesws/socrates-pen
Release list
0.26.0
Full Changelog: 0.25.1...0.26.0
0.25.1
0.25.0
0.24.0
Logs you can actually read — local time, full text, real anchors, follow-ups on record
This started with a reader asking for a strengths-and-weaknesses profile of themselves as a learner: a radar, which concepts they keep asking about, where they got stuck. Pulling the existing trajectory to build it turned up four data defects I was about to file as "later". The reader's answer: fix every one of them first, then do the research. This release is that fix.
What the reader saw
One session — 64 turns, 2 h 49 min, 10.7 M input tokens — landed on disk like this:
| Wanted | Actually there |
|---|---|
| When each line was said | ui_messages carried no timestamps at all; trajectory rows had ts, in UTC — reader subtracts eight hours by hand |
| The reader's own words | truncated at 240 chars; a 300-char turn about "precise English terms, I need them for interviews" lost its second half |
| What Socrates answered | a 200-char preview; the full text lived only in the session file, which retention deletes after seven days |
| Where in the handbook this turn was | 36 of 66 rows said level="封面", start_line=1 — while selected_chars was 285–873. The selection existed; the position was fabricated |
| The two follow-ups offered, and whether the reader clicked one | not recorded. A clicked follow-up and a typed message looked identical to the backend |
| The second half of an approved write-back | not recorded at all. The finally in /v1/chat/approve was _ = ok |
| Fast model or main model | not recorded |
diagnose.aggregate filters 封面 out via is_curriculum, so the existing weak-spot aggregator could not see 55 % of the conversation — the whole cassette arc, the opening and closing HTTP questions.
How the fake anchors happened
Those 36 rows were not empty selections. In Reading view, src/selection.ts mapped the highlighted rendered text back to source lines with linesFromQuote, and when it failed it fell back to {startLine: 1, endLine: 1}. 1 is a real line number. The sidecar dutifully ran idx.locate(1) → cover page.
Ported the matcher to Python and measured it against the 13 distinct selections in the trajectory on the real 32,070-line handbook:
| Matching rule | Matched |
|---|---|
| Collapse whitespace, substring search (v0.23.1) | 0 / 13 |
| Keep only letters, digits, CJK; whole quote, then first 48 chars, then last 48 | 11 / 13 |
| The other two | the handbook had been rewritten after the selection (the reader asked for a paragraph to be recast in RL terms). The original text no longer exists |
The zero is not subtle: readers highlight rendered text, but the source is full of **, backticks, table pipes, #, [link](url). Whitespace folding doesn't touch any of that.
The two unrecoverable rows prove something else: this cannot be repaired by backfilling. The handbook is written back to every day; what doesn't match today matches less tomorrow. The only fix is to record it correctly at the moment it happens.
Root causes
Four of them, each a "saved effort while recording":
- The plugin told a plausible lie (sent 1) when it couldn't find the line, instead of saying "don't know" (send 0).
- Trajectory rows were designed to be "enough for
diagnose", not "enough to analyse a reader" — 240 / 200 char truncation, no follow-ups, no route. Yet it is the only record that is never deleted or rewritten. - Timestamps were minted in three places:
session.to_dictin UTC,trajectory.append_turnin UTC, bubbles nowhere. - When one turn spans two requests, nobody logged the second one.
What changed
Time has one definition point. New pen/clock.py:now_iso(): local time, with offset, to the second (2026-09-03T10:47:06+08:00). Still an absolute instant — fromisoformat reads it back in one line, and it sorts correctly next to old +00:00 rows — but what you read is the clock on the reader's wall. Bubbles, trajectory rows, compaction notes and updated_at all take it from there.
Quote → line stays in the plugin (src/locate.ts), with the new rule: strip both sides to letters, digits and CJK; try the whole quote, then the first 48 chars, then the last 48. A probe hit reports only the lines the probe itself covers — narrow and right, never extrapolated to the full quote length. Not found returns null, and selection.ts sends 0.
What 0 means lives only in the sidecar (tutor._place): if the previous anchor in the same book is still in range, reuse it and mark located="sticky"; otherwise record line 1 and mark "none"; a normal hit is "exact". The three values ride along in the anchor so analysis can weight them by trust. The [邻域] context fed to the model follows the same rule — it used to get the cover page, which was worse than nothing.
Trajectory rows are self-sufficient (the module docstring in pen/trajectory.py says why):
| Field | Content |
|---|---|
phase |
chat / approve |
asked_at · ts · duration_s |
when asked, when answered, how long the reader waited |
user_text · assistant_text |
the full sentence, the full reply — no truncation |
offered |
the two follow-ups this reply ended with |
picked |
whether this message was a clicked follow-up (dyn / deep) or typed (empty). Decided in the sidecar, no protocol change: last turn's last_chips and the deep probe's mark_clicked were already in hand |
route |
fast / base |
anchor.located |
exact / sticky / none |
The finally in /v1/chat/approve now appends a phase="approve" row with allow, the full second-half text, and a clock.
What counts as a turn is decided only by trajectory.is_turn. diagnose.aggregate and app._footprint both ask it — otherwise every write-back doubles the hits at one spot and a single turn becomes a "weak spot". load_turns no longer silently drops rows without an anchor: a reader's data should not be filtered at read time; the consumer filters.
Gates
- New
scripts/check-locate.mjs(13 checks) bundles the realsrc/locate.ts: bold, backticks, table, heading, fenced code,<details>summary, head probe, tail probe; not-found must benull. - 8 new pytest cases (
test_trajectory.py3,test_app.py3,test_tutor.py1,test_diagnose.py1,test_compact.py1). Each was watched fail first:_placewithout a fallback → sticky test red;is_turnnot filtering → double-count test red;load_turnskeeping{}→ the oldtest_corrupt_jsonl_skippedred (so empty objects are still skipped; only anchor-less rows are kept). - Totals: pytest 1014 passed; twelve frontend gates, 488 checks, all green.
Not done
- No backfill of old rows. Explained above; and the sidecar is appending to the same file — rewriting it beside a live process is a race. The learner profile that prompted this recomputes anchors in memory with the new rule and says which rows came out that way.
- Old rows'
tsare still UTC.fromisoformataccepts them and ordering is unaffected; just read the offset. propose_fold_mdcallsllm_create_kwargs(which forcesstream=True) and then readsresp.choices[0]— guaranteed to blow up on a real endpoint. ButPOST /v1/writeback/proposehas zero callers insrc/; it has been a dead route since the initial commit. Separate release.
0.23.1
Gemini 3's thought signatures — the shot that produced it has to carry it back
If you switched to a Gemini 3 model and hit a red bar, this is why:
Error code: 400 - {'error': {'code': 400,
'message': 'Request contains an invalid argument.', 'status': 'INVALID_ARGUMENT'}}
Which argument? It doesn't say. That was the hard part — Google swallows the real message once the history gets long. The same failure, with only five messages of history, says what it actually means:
Function call is missing a thought_signature in functionCall parts.
Gemini 3 attaches a signature to every tool call it makes:
{"id": "call_297731", "type": "function",
"function": {"name": "get_weather", "arguments": "{\"city\":\"Beijing\"}"},
"extra_content": {"google": {"thought_signature": "EoYCCoMCARFNMg/vBOfJ…"}}}Send that call back without the signature and the whole request is rejected.
Two independent ways this broke
1. We were throwing the signature away. The streaming assembler kept only id, name, and arguments from each fragment and dropped everything else. So even if you used Gemini for the entire conversation, the first tool call was the last thing that worked — every request after it was a 400.
The fix doesn't hard-code a field name, it goes by provenance: whatever the vendor puts on the fragment rides back out untouched. Hard-coding extra_content would just mean fixing this again when the next vendor picks a different name. (This is the third time this exact bug has appeared — v0.22.4 was the reasoning text.)
2. Fast Mode mixes vendors in one history. Tool calls made by your fast model carry no Google signature, so the moment the conversation escalates back to a Gemini base model, same 400. The same thing happens to any conversation where you switched providers partway through — and that history can't be repaired retroactively.
For those, the request is flattened before it goes out: an unsigned tool call becomes plain text ((I called read_file with …)), and its result becomes a plain message. What's lost is the structure, not the content — the model still reads what was called, with what arguments, and what came back. For rounds that already executed, nothing depends on that structure any more.
Rounds that do carry a signature are left exactly as they are, and every other vendor's history is returned untouched — not a copy, the same list. If you're not on Google, this release changes nothing about what goes over the wire.
Verified against a live endpoint
- Full Gemini conversation, real streaming: second shot goes from 400 to a normal answer.
- A real 60-message conversation with 22 tool-call rounds made by a different vendor: 400 as-is, answers normally after flattening.
pytest1005 passed,npm run buildclean across all eleven gates.
Full Changelog: 0.23.0...0.23.1
0.23.0
Every vendor's thinking dialect, in one table — and a preflight that fires the real shot
Two halves of the same bug.
The bug
thinking_wire() was the one place in the repo that branched on the model name, and it knew three vendors. Everything else fell through to DeepSeek's private wire format:
out["reasoning_effort"] = lv
out["extra_body"] = {"thinking": {"type": "enabled"}}The OpenAI SDK flattens extra_body keys to the top level of the request body. So any compatible endpoint without a thinking field 400s on the spot — Unknown name "thinking": Cannot find field. Patching one vendor at a time was never going to end, because the default branch itself was wrong: one vendor's private spelling was being treated as everyone's default.
And the preflight couldn't see it. The old probe sent no thinking dialect, no tools, and didn't stream. An endpoint that rejects your reasoning dialect — or doesn't support tool calling at all — got a green light in the status bar. You found out when the conversation turned red.
pen/providers.py — one table, nine vendors
The single definition point for vendor → four thinking levels. Every cell is a literal wire payload, so nothing is computed at request time.
| Vendor | off | low / medium / high |
|---|---|---|
| Generic OpenAI-compatible | (nothing) | bare reasoning_effort — never extra_body |
| DeepSeek | thinking: disabled |
effort + thinking: enabled, high → max |
| GLM | 5.3 forced; others disabled |
low / high / max |
| Celeris | effort: none |
low / medium / xhigh |
3+ and 2.5 Pro → low; 2.5 Flash → none |
effort passed through | |
| OpenAI | reasoning models → low; non-reasoning → nothing at all |
passed through |
| Kimi | K3 → low; K2.7 → enabled; K2.x → disabled |
K3 low / high / max |
| Meta | effort: minimal (none 400s) |
low / medium / xhigh |
| OpenRouter | (nothing) | bare effort |
Model names are matched with care that was earned the hard way:
llamadoes not mean Meta. A llama endpoint on Together, Groq, Ollama, or vLLM has no dialect relationship with Muse Spark.- Gemini 2.5 Pro cannot turn thinking off (Google: "N/A: Cannot disable thinking", minimum budget 128). Only Flash and Flash-Lite accept
thinkingBudget=0. gpt-5-chat-latestandgpt-5-proare not reasoning models — sending themreasoning_effortis a 400.openai/o3-mini— gateways prefix the model name, and a pattern anchored at the start of the string misses it silently.
Two deliberate behavior changes: DeepSeek's off now sends an explicit disabled (per DeepSeek's docs, sending nothing means thinking enabled at high — so the old "off" was full-throttle thinking), and unrecognized models no longer receive extra_body.thinking at all.
The preflight now fires the real shot
It calls llm_create_kwargs() itself — the same function the main conversation uses — so the probe carries tools, streaming, stream_options, and your thinking dialect. When the main request grows a new field, the probe grows it too.
max_tokens=1 is gone. That was a parameter we invented to save money, and reasoning models often reject a minimum output that small — so the preflight was manufacturing 400s you'd never hit and then reporting them as your configuration problem. Cost is now controlled by hanging up after the first frame. A green light is exactly one upstream call.
When the full shape fails with a 400, it strips one thing at a time to find out which thing:
① full shape ok → green
not a 400 (401/404/unreachable) → report it, stripping won't help
② − thinking dialect ok → "this endpoint doesn't accept our reasoning-effort dialect"
③ − tools ok → "this endpoint doesn't support tool calling"
④ − stream_options ok → "this endpoint doesn't report token usage"
⑤ − stream ok → "this endpoint doesn't support streaming"
The quoted error is always from shot ①. Later shots are situations we created to locate the problem — if one of them breaks in a different way, the ladder stops rather than blaming you for a failure you never hit.
New in settings: a Provider dropdown
Common settings now lead with Provider, above Base URL. Each option carries a hint line: the official endpoint, what its model names look like, and whether thinking can be switched off. Fast Mode has its own.
- Defaults to Automatic (matched from the model name), so upgrading changes nothing — the request body doesn't even gain a key.
- Picking a vendor pre-fills Base URL, but never overwrites an address you typed. It only fills in when the field is empty or still holds another vendor's default. People routing through a gateway need this dropdown the most, and their address belongs to no vendor.
- Changing the provider re-runs the preflight immediately.
If the status bar says your endpoint rejects the reasoning dialect and you don't know which vendor it really is, pick Generic OpenAI-compatible — it's the only option guaranteed to send no extra_body at any level.
Full Changelog: 0.22.5...0.23.0
0.22.5
If you configured a Gemini model and anything other than Thinking = off, every turn failed. This release fixes that.
What the endpoint was saying
Error code: 400 - Invalid JSON payload received.
Unknown name "thinking": Cannot find field. status: INVALID_ARGUMENT
That was not a misconfiguration on your side — Socrates was sending the wrong thing.
What was wrong
There is exactly one place in Socrates that changes the request based on which model you use: the part that turns the Thinking setting into whatever field your endpoint expects. It knew about three cases. Everything else fell through to DeepSeek's spelling, which sends a thinking object alongside the effort level.
Google's OpenAI-compatible layer doesn't have a thinking field, and rejects any request carrying one. So Gemini worked only with Thinking set to off — that setting happens to send nothing at all — and failed on low, medium, and high.
What changed
Gemini models now get the plain reasoning_effort field, which Google's compatibility layer accepts directly. Gemini 3 and later cannot turn reasoning off at all, so choosing off on those models sends the lowest level instead of an unsupported one. When the version in the model name can't be read, Socrates assumes reasoning can't be disabled — being a little slower is a far better outcome than another rejected request.
A note on how this was verified
The Gemini behaviour above comes from Google's documentation, not from measurement — unlike the Celeris numbers in 0.22.0, where every figure was probed against the live endpoint. The fix was tested against a local stand-in that rejects unknown top-level fields the same way Google does, and the original failure reproduces there once the fix is disabled.
After installing, Stop then Start the sidecar so it upgrades to 0.22.5. Requires Obsidian desktop 1.5.0+ and Python 3.11+ on the same machine.
0.22.4
If your model has Thinking turned on and it looked up your handbook mid-answer, the next request failed. This release fixes that.
What the endpoint was saying
The `reasoning_content` in the thinking mode must be passed back to the API.
Some endpoints require that, once a model has answered in thinking mode, its reasoning is included when that same message is sent back as part of the conversation. Socrates was measuring the reasoning as it streamed — that is what makes the status line count up while you wait — and then discarding it. So the moment the model called a tool and Socrates asked it to continue, the request was missing something the endpoint insists on.
This was not a Fast Mode problem. A plain conversation with Thinking on, where the model read your handbook once, failed exactly the same way. Fast Mode simply made it certain: the fast model always reads first and then hands the turn to the base model, so the base model always inherits a tool call it has to continue from.
It had also been invisible. Until 0.22.3 this rejection was shown as "check the Base URL, model name, and API key" — all three of which were fine. Transcribing what the endpoint actually said is what made it findable.
What changed
Reasoning is now kept and sent back with the message that produced it. Three things keep that from becoming a cost of its own:
- It is only sent when the current request is itself in thinking mode. Endpoints that never asked for the field don't receive it.
- It is capped, and it is forgotten as soon as you send your next message — the requirement only covers the tool call currently in progress.
- It is never written to disk, and it now counts toward the context estimate, since it really is on the wire.
When Fast Mode hands a turn back to the base model, the fast model's reasoning goes with it. That reads oddly but it is the truth of what happened: that reasoning is what produced the tool call being continued. Dropping it would put you right back into the same rejection.
After installing, Stop then Start the sidecar so it upgrades to 0.22.4. Requires Obsidian desktop 1.5.0+ and Python 3.11+ on the same machine.
0.22.3
0.22.2 shipped a configuration check that could accuse you of something you had already fixed. This release makes it stop guessing.
What went wrong
With Image understanding switched off, the status line still said: "The endpoint rejected the image. This model likely has no vision — turn off Image understanding in settings."
It was already off. And the check had sent no image at all.
The classifier that decides "was this an image rejection?" works by looking for words like image_url or vision in whatever the endpoint sent back. That is a guess, and it was only ever asked in conversations that really had pictures in them — so it guessed well. 0.22.2 reused it for the new configuration check without telling it whether a picture had been attached. When the endpoint returned an error for some unrelated reason and its text happened to contain a matching word, the check announced a conclusion that could not possibly be true.
Whether a picture was sent is now something every caller has to state outright. With no picture attached, "the endpoint rejected the image" is ruled out by construction rather than by a better guess.
When we can't classify an error, you now see the endpoint's own words
The old fallback was "The endpoint rejected this request. Check the Base URL, model name, and API key in settings." If you had already checked all three, that sentence gave you nothing — while the endpoint had actually said why, and we simply weren't passing it along.
Errors we can't classify now quote the endpoint directly, with anything key-shaped redacted and the text capped so it fits on one line.
The check no longer blames you for its own shortcut
The configuration check sends a deliberately tiny request to keep it nearly free. That shortcut can itself be rejected by models that require a larger minimum response — an error a real conversation would never hit. A false alarm is as bad as a false all-clear, so an unclassified rejection is now retried once in the shape a real turn would take. Only on the failure path, so normal use costs nothing extra.
Also fixed: the status dot could show amber while the text beside it still read "saved", because the dot and the text were coming from two different pieces of state.
After installing, Stop then Start the sidecar so it upgrades to 0.22.3. Requires Obsidian desktop 1.5.0+ and Python 3.11+ on the same machine.
0.22.2
Two bugs, both of which showed up as "the settings say everything is fine."
1. Turning off Image understanding did nothing once you had pasted a picture
Paste an image, let the endpoint reject it, then turn Image understanding off. Every following turn was still rejected — even though you pasted nothing new.
The gate only looked at images attached to this turn. But a conversation keeps its history in memory, so the picture from the earlier turn was re-sent with every single request. The toggle was real; it just never touched the thing that was actually being sent.
Images are now dropped at the one place a request is built, so the toggle takes effect on the very next turn. It is not destructive: turning it back on restores the pictures you pasted, because switching the toggle off means "stop sending these", not "delete them".
2. The status dot could not tell you whether your configuration actually worked
The dot only ever answered "is the sidecar process alive", and the settings page only answered "is there a key on file". A dead key, a model name this endpoint has never heard of, an endpoint with no vision at all — none of those were checked anywhere, so all three showed as perfectly normal until you sent a turn and got red text back.
There is now a real check. When your configuration changes, the sidecar sends one tiny request to your endpoint and reports what came back:
- No key saved yet
- The endpoint rejected this key
- This endpoint has no model called
<name>(404) — previously reported as "an unexpected error (NotFoundError)", which told you nothing - The endpoint rejected the image — when Image understanding is on, the check includes a 1×1 pixel, because whether an endpoint accepts pictures is something only the endpoint can answer
- Can't reach the endpoint
The panel's status dot now has three states instead of two: green means reachable and working, amber means reachable but the configuration is broken, red means the sidecar is down. The settings page shows the same verdict next to the key status.
The check runs when something actually changes — you open settings, leave the Base URL or model field, flip Image understanding, or save a key. It never runs on a timer, and it never runs just because you selected some text.
After installing, Stop then Start the sidecar so it upgrades to 0.22.2. Requires Obsidian desktop 1.5.0+ and Python 3.11+ on the same machine.