The local AI layer — what runs, where it goes, how to check it¶
Written from the 2026-09-02 audit, performed live on main. This is the map
behind the Local AI panel on /dashboard/ops and fsmes ai-status —
when this document and the panel disagree, one of them has a bug.
The consumers¶
| Job | Trigger | What it does | Output lands in | Check |
|---|---|---|---|---|
| Run triage | every scored run | qwen reads the run's logs for anomalies nobody asserted | ~/.local/share/fsmes/runs.db (triage_findings, triage_worst columns; full detail inside the scorecard JSON) |
fsmes runs or the panel |
| Nightly rollup | 05:30 timer, Persistent=true (catches up after boot) |
qwen narrates numbers computed from the store | ~/.local/share/fsmes/reports/*.md → pulled to Vault/Sims/Reports/ by fsmes-reports (laptop) |
systemctl --user list-timers fsmes-rollup.timer |
| Design chat | the Design button | qwen answers about the screen being looked at | ~/.local/share/fsmes/design.db → /design-triage → docs/design/backlog/ |
fsmes design-pending |
| Floor assistant (local) | the Assistant button on a plant with no key | routes questions, quotes procedure, picks guides | answers live; stores nothing (falls back to lexical matching when Ollama is down) | ask it something |
Floor agent (cloud) — not a local consumer; read from agent.status() |
the Assistant button when agent.available() is true |
a cloud model works the plant's own tools: reads freely — the four shift analyses included, each answering with the screen's own envelope, coverage ledger and all where the route serves one — proposes every write, puts a walkthrough on the screen | the plant's own ai_turns table (the AI screen), plus ~/.local/share/fsmes/agent-turns.jsonl (one line per turn) and agent-usage.jsonl (the bill) |
the AI screen, fsmes ai conversations, or read the turn log |
Analysis agent (cloud) — account ANALYST, role analyst |
the analysis conversation; off in shadow mode, which says so | every read tool and no write tool: the four shift analyses, plus trace_rollup, trace_graph and maintenance_mttr over this plant's own AI trace, stops and repairs. It counts people by role, workcenter or shift and names one only where a plant has granted people.analyse — which no shipped role holds |
the same ai_turns table, with brain: analysis; an answer that named a person also writes analysis.person_named to the audit trail against personnel/<code> |
the AI screen, or GET /audit?entity_type=personnel for who was named |
| Instruction drafting | fsmes draft-instructions |
qwen drafts work instructions from facts the plant holds | documents module, drafted_by_model set, arriving unapproved |
the Instructions screen |
| Embeddings | fleet job completion | nomic-embed-text embeds run summaries for semantic search | ~/.local/share/fleet/fleet.db (the fleet platform — NOT the fsmes store) |
fleet ask from the laptop |
The cloud agent's reads of the plant leave no record of their own, and are not
meant to: what a read returned is in the turn's transcript, and the turn is in
ai_turns and in the turn log below with the tool names it used. So "did the
assistant read the plant's own OEE or work one out for itself" is answered by
tools on the turn — oee_breakdown, state_timeline, downtime_pareto and
tag_trend are the four that reach the plant's own analyses, and an answer about
a KPI with none of them in the turn is an answer to look at twice. Of the four,
oee_breakdown is the one that carries the coverage ledger;
downtime_pareto carries the line's coverage and no per-machine ledger
(coverage, watched_seconds, unknown_seconds, unknown_share, out of the
same ledger arithmetic since 2026-09-29 — before that they were the recorded
disconnections and read as nought on a plant with none), and state_timeline
and tag_trend carry no coverage figure, because their routes serve none.
Ollama itself: 127.0.0.1:11434 on main (override with MES_OLLAMA),
qwen3:8b ≈ 5.6 GB loaded, idle-unloads after a few minutes. MES_LOCAL_AI=0
declares a machine with no local model: the Ops screen's Local AI panel is
hidden, and the AI screen's Status tab says the layer is off and why. It does
not hide the cloud brain, which is a different model behind a different
setting — the AI tab and fsmes ai-status read that from agent.status(),
whatever MES_LOCAL_AI says, so a plant that runs no local model still sees
what its assistant costs against its cap.
What the audit found (2026-09-02)¶
- Runtime healthy. qwen loaded and answering (a design-chat question that morning), rollup notes for both prior days, timer armed.
- Bug, fixed same day: the store wrote
len(findings) or None, so a run triaged clean recorded NULL — indistinguishable from a run never triaged. Zero-vs-unknown, principle 4's lie pointed the other way. Now 0 means clean, NULL means never ran, and a test holds it. - Data loss, recovered: the triage-columns migration on 2026-08-31 kept a
backup (
runs.db.pre-triage) but the live store came out with 1 run where the backup held 6. The five lost runs were restored at deploy (as never-triaged rows, which is the truth). The unmanaged backup file stays until Scott deletes it deliberately. - A stale claim in the convergence plan: the results store was described as persisting "summary and embedding" per run. It has neither column; embeddings exist only in the fleet database. Semantic search over scored fsmes runs therefore does not exist today — recorded as an open idea, not silently assumed.
The agent's turn log (2026-09-26)¶
MES_AGENT_TURN_FILE, default ~/.local/share/fsmes/agent-turns.jsonl. One
JSON line per turn — per thing the person got back, not per model call:
{"ts": "2026-09-26T19:55:02+00:00", "plant": "bottling", "session": "ea4a5a9da3c3",
"user": "ADMIN", "model": "claude-sonnet-5", "kind": "proposals",
"tools": ["plant_settings", "write_plant_setting"],
"proposals": [{"id": "956d7f527a29", "tool": "write_plant_setting",
"args": {"domain": "quality", "key": "nc_code_prefix", "value": "CR"},
"outcome": "open"}],
"input": 2000, "output": 100, "cache_read": 1600, "cache_write": 0, "usd": 0.00532}
kind is one of reply, proposals, guide, unavailable, error; a
proposal's outcome is open, confirmed, declined or failed; a failed
turn carries error with the exception class. Nothing in the file that is not
already in the transcript the panel shows the person — no message text, no
plant rows, no key.
The same turn is also written into the plant's own database (2026-09-26),
as a row in ai_turns, and that is what the AI screen reads. The two are
the same write: this file is the machine's copy and belongs to whoever
administers the box; the table is the plant's, is behind audit.read, carries
the words on both sides as well as the tool names, and is pruned to
[admin] ai_trace_days. See
the AI screen for what it holds and what it deliberately
does not.
Why it exists. On 2026-09-26 Scott had a thirty-turn conversation with the
assistant and nothing on the machine could say how many of those turns reached
the model. agent-usage.jsonl is the bill and only records calls that
happened, so a turn answered by a regex, or one that failed before the call,
left no trace at all. (The answer was five.) Read it with
agent.turn_rows(), or jq -r '.kind' agent-turns.jsonl | sort | uniq -c.
The rule of the house applies to it as much as to the rest: a turn missing
from this file is unknown, not did not happen — the writer swallows
OSError so a full disk loses the line rather than the answer.
How to act on this from Claude Code¶
- "Is the AI layer OK?" →
fsmes ai-status(or the Ops panel). Check this before blaming the model for anything. - UI findings (
fsmes ui-check, deterministic, zero GPU) → land asinboxnotes indocs/design/backlog/→ judge with/design-triage. A deliberate restyle is accepted withfsmes ui-check --acceptin the branch that made the change, committing the baselines with it — never by hand-closing the note. - Design ideas →
fsmes design-pending→/design-triage. - The nightly note is the daily digest: run health, triage findings, and one UI-conformance paragraph when drift stands unaccepted.
- The rule of the house: unknown is never zero, and a claim about this layer that was not checked on the machine is not a fact.
Known follow-ups¶
- Five modules carry their own
OLLAMA = "http://127.0.0.1:11434"constant (assistant, design, drafting, rollup, triage); fold them ontoai_status.OLLAMA_URL/MES_OLLAMAin one small change. - Fleet's AI activity could join the panel via a small JSON endpoint on fleetd (open question in the plan).
- Embeddings for scored fsmes runs: decide whether wanted; if so, a column and a backfill, budgeted under nomic (cheap), not qwen.
Agent evals (fsmes agent-eval)¶
What: asks a headless Claude Code, given only the fsmes MCP server
(--strict-mcp-config, tools mcp__fsmes__*), questions whose right answer
is computed from the plant's API at the moment of asking - which station
holds the most WIP, which machines are alarming, the line's OEE constraint,
which plans are due, which machines have gone quiet. Trigger: by hand,
fsmes agent-eval <plant>; --agent none proves the machinery without a
model. Output: ~/.local/share/fsmes/agent-evals.jsonl, one row per
question with truth, answer, score and pass; fsmes agent-eval --summary
prints the pass rate per scenario. Check it is alive: the file's mtime.
Not on the nightly timer yet - each run is a paid Claude session.