attestation · architecture

Two Flows and a Starved Signal

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.

machine-initiated human-initiated rarely traversed

Flow 1 — A question from the researcher

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.

Researcher "what's new on MoE?" Hermes Agent picks a tool MCP stdio mcp/_tool.py @tool — connection · user lookup · envelope feed.* · 19 list · search · rate kg.* · 5 concepts · path runs.* · 6 compare · claims_check sym.* · 7 solve · verify one conn per call hermes.db — SQLite + sqlite-vec items · item_vectors · clicks · runs · corpora Ollama embed · chat embed_query() on search nothing leaves the machine local disk only answer
A query fans out to one namespace of four. The @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.

Flow 2 — The nightly refresh

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.

cron hourly flock -n one run at a time busy → skip tick exit 0, logged attest ingest fetch · dedup · embed must succeed fails → exit nonzero, no tag attest tag LLM tags → item_tags best-effort fails → logged, retried next run hermes.db items + item_vectors always · item_tags eventually the graph is derived fresh from item_tags on every read
Four observed failures are encoded in this script: a bare 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.

The loop that doesn't close

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.

ChannelSourceRowsWhy it's rare
Web buttonsui68Requires opening a local page and clicking
Agent-recordedagent2Agent must choose to call it, unprompted
Asked "why?"implicit34Harvested from 99 logged requests; the rest were already rated
Simulated readersimulated24The 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.

stated opinion ui + agent · 70 rows · all + one opinion per 74 items asked "why is this here?" implicit · weak + only simulated reader simulated · + and − clicks source kept forever so rows stay separable click classifier needs BOTH classes silent without a − re-ranks the next list_feed bootstrap rows excluded: tautological skew is caveated
Only the simulated channel supplies negatives. Nothing infers rejection from silence — an unopened item could be boring, untimely, or already known, and guessing would poison the one class the ranker is starving for.

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.