How a question from a researcher and a nightly cron tick move through the same 37 namespaced tools — and why the loop that is supposed to close between them stayed open until it was fed synthetically.
The agent never touches SQLite. It picks a tool from one of four namespaces, and every tool
call opens its own short-lived connection, answers, and closes. That per-call connection is why
the process stays stateless between calls and honours RSS_DB the way the CLI does.
The namespace is the part an agent can see. Splitting the server into four files changed
nothing for a caller — it reads a flat list of names — so the tools carry their domain in the
name itself, and no tool repeats it: kg.path, not kg.kg_path. A
37-way choice becomes a 4-way one and then a smaller one. Bounded arguments declare their
bounds in the schema, so a client rejects limit=0 before the call is made rather
than the server refusing it afterwards.
@tool band is where the
connection, the persona lookup and the response envelope are handled once, rather than
thirty-seven times. Only the ranking and tagging paths reach Ollama, and nothing reaches past it.
Measured across all thirty-seven tools: nothing exceeds 2.6 s or 2,558 characters, and the
seven sym.* tools each pay ~2 s for the subprocess that sandboxes them.Cron runs one script under flock. The two steps have deliberately different
failure contracts, and that asymmetry is the whole design: ingest must succeed
because it is deterministic and needs no model, while tagging is best-effort
because a cold Ollama is a degraded run, not a broken one.
PATH that hid
uv, stacked runs racing one SQLite file, both steps silenced to
/dev/null, and a set -e that made a cold model fatal. Tagging failing
leaves items untagged, never unsaved.Both flows write to the same database, and ranking is supposed to improve as feedback accumulates. It hasn't, and the reason is countable rather than architectural.
| Channel | Source | Rows | Why it's rare |
|---|---|---|---|
| Web buttons | ui | 68 | Requires opening a local page and clicking |
| Agent-recorded | agent | 2 | Agent must choose to call it, unprompted |
| Asked "why?" | implicit | 34 | Harvested from 99 logged requests; the rest were already rated |
| Simulated reader | simulated | 24 | The only channel that produces a negative |
Seventy stated opinions across 5,167 items, every one of them positive. The click classifier needs both classes to fire at all, so for a real account it never fired: ranking fell back to embedding similarity permanently, no matter how many items got approved.
Running the two synthetic channels on matt took that history to 128 rows and
turned the classifier on for the first time — evaluate_user went from
None to a number. The number was 1.0, which is the next problem:
42 of 45 positives came from arXiv cs.LG, so the classifier can separate the classes by
source rather than by topic and score perfectly while learning nothing about the reader.
simulate_feedback now measures that skew and returns it beside the counts.
Provenance is the load-bearing part. Every row keeps its
source forever, because provenance decides what a row may be used for.
bootstrap labels are a linear threshold on the same embedding the classifier trains
on, so evaluating against them is a tautology and evaluate_user excludes them.
simulated rows come from a model reading text, independent of the vector — trainable,
but still not a person's judgement.