Compare commits

...

25 Commits

Author SHA1 Message Date
claude dc266056d1 docs: the shape and the rules for offloading model work (V-483)
483 is an umbrella and its children are the work, so what it owes them is
the shape they must all obey. docs/offload.md records it: the degradation
rule and where its line falls, admission control rather than a GPU
arbiter, the embedder staying on homesrv because it backs the classifier,
and the inventory of what runs a model on the box today.

CLAUDE.md gets a pointer, because an agent about to add a model caller or
touch a daemon seam needs to know this before it starts, not after.
2026-08-02 15:08:35 +02:00
kami 1c786b7156 Merge pull request 'Docs: refresh the QA plan against the live task list' (#92) from task/483-design-offload-ml-to-the-workstation-kee into master
Reviewed-on: #92
2026-08-02 15:08:16 +02:00
claude a3af10a830 gitignore the root .env, it holds a live token (V-484)
It was untracked but not ignored, so one git add -A would have committed
MAVEN_AMBIENT_TOKEN. Same class as deploy/telegram.env, which is already
ignored.
2026-08-02 15:08:07 +02:00
claude c0de473382 ipc, worker: dial and bind through netaddr (V-484)
Five hardcoded transports, three in internal/ipc and two in
internal/worker, all now go through the seam address. The unix perms
logic moved into netaddr, so the two copies of parentDir and the umask
dance are gone.

peerCaller already returned ok=false for a non-unix conn, so the
SO_PEERCRED path degrades correctly on tcp with no change.
2026-08-02 15:08:07 +02:00
claude 3e534340bf ipc: pin that a scheme-less address still dials unix (V-484)
Six cases. The load-bearing one is the first: every deploy in the tree
writes a bare path, and it must keep meaning a unix socket with no
handshake in front of the payload.

The rest cover the tcp seam: a good token round-trips, a wrong one comes
back ErrUnauthorized, a stranger that speaks HTTP at the port is dropped
while the listener stays up for the next peer, and a tokenless tcp bind
fails rather than serving his turns to anyone who connects.
2026-08-02 15:08:07 +02:00
claude 1a704d704d ipc: a seam address that can name a transport (V-484)
internal/netaddr parses a daemon seam address and dials or binds it. A
scheme-less address is unix and behaves exactly as it does today: same
0700 parent dir, same 0600 socket, same bytes on the wire. tcp://host:port
is the new option, and it is what lets a module live on another host.

Over tcp the filesystem permission that authenticated the unix socket is
gone, and what crosses this seam is audio of the owner speaking. So a tcp
listener requires a shared token, checked in constant time before the
first protocol frame is read, and a peer that fails is dropped without
taking the listener down with it.
2026-08-02 15:08:07 +02:00
kami e57adcb001 Merge pull request 'Docs: refresh the QA plan against the live task list' (#91) from task/459-docs-refresh-the-qa-plan-against-the-liv into master
Reviewed-on: #91
2026-08-02 15:07:21 +02:00
claude bec7362b7b config: clear three of 472's five QA blockers (V-459)
morning_routines, feeds, crawl.on_demand and netscan.enabled in
deploy/mavend.json; -ambient-token on mavweb, interpolated from a gitignored
/.env. All verified on the box: the dispatcher builds the morning plan,
/api/ambient answers 401/201, the crawler reads a named page, netscan finds 3
devices, and /events fills with scan:lan and ambient:notif.

Filed 482 (ambient reads a notification's wall clock as UTC). Corrected 479:
both capabilities work once configured, so it is not a routing defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJYcaBiuny9UGSpFweQVQ1
2026-08-02 15:52:56 +04:00
claude a3ec746a01 docs: the six ready tasks all ran, and all six stop at the deploy (V-459) 2026-08-02 15:44:17 +04:00
claude af0eec250e docs: the operations sitting and the degraded-mode suite both ran (V-459) 2026-08-02 15:15:08 +04:00
claude 20aa2d59c9 docs: session 3 results and the query-source findings (V-459) 2026-08-02 14:40:22 +04:00
claude 4bad90dedb docs: point the QA findings at their new task ids (V-459) 2026-08-02 14:25:39 +04:00
claude 2b8d0f74fa docs: session 1 and 2 results, and the classifier baseline was wrong (V-459)
Ran sessions 1 and 2 on the live box.

Session 1 steps 1 and 3-6 pass. Steps 2 and 7-9 need a person at the box.
POST /api/chat is drivable with form encoding and a cookie jar, so the text
half needs no browser.

Session 2 confirms the deploy matches the bench at 72.7% full accuracy, and
contradicts two recorded numbers. The classifier scores 68.8% at p50 16.6us,
not 36.8% at 31ms. Router latency measured under contention again.

Also: 319 item 2 point 2 closes on the recall margin sweep, CheckFeminine has
a false positive on second-person masculine verbs, and the wake path cannot be
checked because mavwaked and mavenclient are deployed nowhere.
2026-08-02 14:24:01 +04:00
claude af9d2133dc docs: session 3 holds five sittings now, not three (V-459)
The refresh added the query-sources and operations sittings and left the
heading counting three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJYcaBiuny9UGSpFweQVQ1
2026-08-02 13:57:01 +04:00
claude a1fdfccd61 docs: refresh the QA plan against the live task list (V-459)
The plan named 40 task numbers on 2026-08-01. Ten open QA tasks were missing
and two of the named ones had closed, so the 44-of-50 header was wrong twice
over.

- header is 42 of 50, and every open task now appears
- placed the ten unlisted QA tasks: 14, 248, 249, 250, 258, 283, 284, 285,
  286, 323
- new Operations sitting for 249 and 250, and a Query sources sitting for
  258 and 286
- 14 and 284 join housekeeping: both are gated on something unbuilt
- dropped the 317 and 354 rows, closed 01-08-2026, with one line saying what
  landed
- 319's gate recalibration is done; what is left is re-deriving QueryMinMargin
- 323 is down to the 60s startup timeout arm after PR #90
- new "Not this repo" section for 358 (Hexis) and 362 (training workspace)
- router latency is ~27x, not 90x; the 2.7s p50 was contention, not the model

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJYcaBiuny9UGSpFweQVQ1
2026-08-02 13:56:02 +04:00
kami 5c05163266 Merge pull request 'QA: phraser coverage is 65.3% but the llama-server subprocess lifecycle is 0% — the suspicion in this task was correct' (#90) from task/323-qa-phraser-coverage-is-65-3-but-the-llam into master 2026-08-02 11:39:20 +02:00
kami 92d2629001 Merge pull request 'Voice cannot accept a routine (V-367); the last three prompts are Russian (V-404)' (#89) from fix/367-voice-parks-routine-accept into master 2026-08-02 11:39:16 +02:00
kami bdcfccce77 Merge pull request 'Session workflow: pickup and wrap around the task flow' (#85) from task/445-session-workflow into master 2026-08-02 11:39:11 +02:00
kami f4deccacc9 Merge pull request 'dialogue.Slots and router.Slots are hand-kept copies that already drifted' (#88) from task/365-dialogue-slots-and-router-slots-are-hand into master 2026-08-02 11:39:07 +02:00
kami 8aaac01de6 Merge pull request 'Doc reorg: tier the tree, retire the three planning files' (#86) from task/446-doc-reorg-tier-the-tree-retire-the-three into master 2026-08-02 11:37:43 +02:00
claude feb6f2c03d phraser: test the spawn path, the one thing coverage never touched (V-323)
Every phraser test built the phraser with NewLLMPhraserAt, which starts no
process, so NewLLMPhraser, spawnLlamaServer, startLlamaProc, llamaProc.Close
and extractPort sat at 0% while the package headline read 65.3%.

These drive the real spawn code against a fake llama-server script: the port
scrape, the three reachable startup-race arms (start failure, stderr EOF,
context cancel), and Close actually reaping the child. The orphan test
re-execs the test binary as the daemon, SIGKILLs it, and asserts Pdeathsig
killed the grandchild. The last test rebuilds the production command line and
checks kill-maven.sh's pattern still matches it — that pattern has gone stale
twice and leaked orphans both times.

Package coverage 65.3% -> 76.9%. The 60s timeout arm stays untested; it needs
an injectable clock in production code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012YQGVXu5J1iCMCff5J4S1R
2026-08-02 10:15:35 +04:00
claude 99bb3526db Stop asking for Russian in English on the last three prompts (V-404)
#400 rewrote the chat and query prompts in Russian and left three pieces
of English prose behind.

PhraseReminder's user prompt was fully English. It is Russian now, and it
no longer restates the JSON contract or the persona rules: the call goes
through chat(), so nudgeSystem already states both, and a second copy of a
contract is one more thing that can drift out of step with the first.

querySystemPrompt and router.KnowledgePrompt both closed with the English
"Respond ONLY with valid JSON:". That sentence is prose instruction, not
wire format — the JSON skeleton after it is the wire format, and it is
unchanged. Kept rather than deleted: the GBNF grammar makes it close to
redundant, but the grammar is switchable off (phraser NoGrammar), and the
sentence is the floor when it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CotYKycuio1GwLbYh9jfc
2026-08-02 10:01:58 +04:00
claude bb8cb8d014 Voice parks a routine proposal, it never accepts it (V-367)
Accepting a proposed routine gives the tick loop a standing new reason to
speak. DESIGN.md § "surface caps authority" puts that at layer 3, and says
voice is structurally incapable of layer 3 because a room mic is reachable
by anyone in the room. The /routines button was gated at step-up; the voice
path accepted outright. The two surfaces disagreed, so one of them was wrong.

A spoken "да" now leaves the row 'proposed' and sends him to /routines,
where the gated button is. A spoken "нет" still dismisses: declining does
not move the boundary outward, so voice keeps it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CotYKycuio1GwLbYh9jfc
2026-08-02 09:58:07 +04:00
kami c0d61a71a4 session: pickup and wrap around the task flow, one disposable handoff (V-445)
task start and task pr already own the branch, the identity and the PR.
What was missing sat on either side of them.

pickup runs task start, reads TASK.md and any handoff, then restates the
assumption set and stops. That pause is the point: every wasted session
here began with an agent that inferred the goal instead of stating it
back. wrap runs the tests, updates the durable docs, commits in slices,
calls task pr, and records in Vikunja what task pr cannot know.

HANDOFF.md is gitignored and injected by a SessionStart hook. It holds
what the next agent needs to resume and nothing else. TASK.md is the
brief for the branch and does not change. Anything that would still
matter next week goes to Vikunja, CLAUDE.md or docs/.

CLAUDE.md documented none of this, which is why an agent would rebuild
it. It does now, including the two hooks in ~/.claude/hooks.

.claude/ was ignored wholesale. The workflow is now tracked, because how
a session behaves should be reviewed like code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 03:22:46 +04:00
kami 0b89294af7 hooks: refuse master, cap a code commit at 300 lines, require the task ref (V-445)
Two git hooks, tracked in .githooks and wired with core.hooksPath so a
fresh clone gets them with one config line.

pre-commit refuses master and refuses more than 300 changed lines in
non-markdown files. Markdown is exempt because docs land as one batch.
This is a commit-time guard, which diff-budget.sh is not: that hook
blocks the agent's edits and says nothing when either of us commits.

commit-msg requires (V-<id>), not (#<id>). Gitea autolinks # to its own
issues, and Vikunja is the tracker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 03:22:46 +04:00
24 changed files with 1992 additions and 159 deletions
+160
View File
@@ -0,0 +1,160 @@
# Maven project dictionary for the direct-prose skill.
#
# These terms override every word preference in the skill's word-choice tables.
# Each entry exists because the name drifted in real docs or real answers, not
# because the word looked improvable.
#
# Format and the rule for adding a term: ~/.claude/skills/direct-prose/references/modes.md
terms:
resident_model:
name: resident model
meaning: the one always-warm Qwen3-1.7B llama-server that both routes and phrases
avoid:
- the model
- the LLM
- the 1.7B
- the phraser model
examples:
good: The resident model emits GBNF-constrained JSON.
bad: The 1.7B emits GBNF-constrained JSON.
router:
name: router
meaning: the stage that turns an utterance into a Decision with one of 7 intents
avoid:
- orchestrator
- intent classifier
- dispatcher
classifier:
name: classifier
meaning: the embedder nearest-neighbour path that runs when the router is off or errors
avoid:
- the fallback
- the floor
- the old router
examples:
good: A router error falls through to the classifier.
bad: A router error falls through to the floor.
cascade:
name: cascade
meaning: the ordered path stage 0, then router, then classifier
avoid:
- the pipeline
- the chain
- the fallback chain
stage_0:
name: stage 0
meaning: the deterministic rules that answer before the resident model is called
avoid:
- the fast path
- bypass
- deterministic assist
- preemption
examples:
good: Stage 0 routes agenda questions to IntentQuery.
bad: The bypass routes agenda questions to IntentQuery.
query_source:
name: query source
meaning: one entry in querySources, which either claims a turn or passes
avoid:
- arm
- handler
- branch
- answerer
examples:
good: Kiwix is the last query source before the model answers from memory.
bad: Kiwix is the last arm before the model answers from memory.
personal_boundary:
name: personal boundary
meaning: the query source that stops a question about him from reaching the world
avoid:
- the boundary
- the privacy gate
- the personal filter
clarify:
name: clarify
meaning: the turn outcome where Maven asks instead of acting
avoid:
- refusal
- rejection
- punt
examples:
good: The gate produced two false clarifies.
bad: The gate produced two false refusals.
fact:
name: fact
meaning: a keyed, supersedable row in the fact store
avoid:
- memory entry
- datum
- record
note:
name: note
meaning: free text he captured, indexed for recall
avoid:
- memo
- entry
memory:
name: memory
meaning: the embedded index over notes and facts that backs recall
avoid:
- RAG store
- vector db
- long-term memory
nudge:
name: nudge
meaning: one proactive message the digestion worker proposes and the dispatcher sends
avoid:
- suggestion
- proposal
- proactive prompt
- reminder
examples:
good: A fact can close the nudge that asked for it.
bad: A fact can close the suggestion that asked for it.
digestion_worker:
name: digestion worker
meaning: the background engine that consolidates memory and proposes nudges
avoid:
- digestion tick
- background engine
- reflection loop
reach:
name: reach
meaning: an outbound channel Maven speaks through, such as telegram, ntfy or voice
avoid:
- sink
- delivery channel
- notification backend
ecosystem:
name: ecosystem
meaning: Nexus, Praxis and Hexis together
avoid:
- the services
- the integrations
- upstream
act:
name: act
meaning: the intent that runs a capability through Hexis
avoid:
- action
- command
- execution
examples:
good: An act with no allowlisted fn is gated to a clarify.
bad: An action with no allowlisted fn is gated to a clarify.
+20
View File
@@ -0,0 +1,20 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "f=.claude/prose-dictionary.yaml; [ -f \"$f\" ] && jq -Rs '{hookSpecificOutput:{hookEventName:\"SessionStart\",additionalContext:(\"Project prose dictionary. These terms override every word preference in the direct-prose output style. Use the name, never the avoid list.\\n\\n\"+.)}}' \"$f\" 2>/dev/null || true",
"statusMessage": "Loading prose dictionary"
},
{
"type": "command",
"command": "f=HANDOFF.md; [ -f \"$f\" ] && jq -Rs '{hookSpecificOutput:{hookEventName:\"SessionStart\",additionalContext:(\"An unconsumed HANDOFF.md is present. Run the pickup skill before anything else: read it, read the Vikunja task it names, restate the assumption set in at most five bullets, and wait for the user to confirm before writing code. It is a claim from the previous session, not truth. Delete it once consumed.\\n\\n\"+.)}}' \"$f\" 2>/dev/null || true",
"statusMessage": "Loading handoff"
}
]
}
]
}
}
+78
View File
@@ -0,0 +1,78 @@
---
name: pickup
description: Start a work session on a Maven task. Runs task start, reads the brief and the disposable handoff, restates the assumption set, and waits for correction before touching code. Use at the start of any session that continues earlier work, when the user says "pickup", "continue", "resume", or names a Vikunja task id.
---
# Pickup
The point of this skill is the pause in step 5. Every wasted session in this repo
started with an agent that inferred the goal instead of stating it back.
## 0. Get on the branch
```sh
task start <vikunja-id>
```
`~/.local/bin/task` owns the branch, the identity and the PR. It cuts
`task/<id>-<slug>` off `origin/master` and sets the commit author to the `claude`
gitea user. It writes `TASK.md` from the Vikunja task, and pulls any waiting
review comments into `.task/review-comments.md`. Do not hand-roll any of that.
`TASK.md` is the brief and it is immutable. If it says a PR already exists, this
is a review-fix session and not new work. Read the comments first.
## 1. Read the handoff
`HANDOFF.md` at the repo root, if it exists. It is gitignored, it belongs to one
session, and it holds only what is needed to resume. Treat it as a claim from the
previous agent, not as truth. It can be stale or wrong.
If there is no handoff, that is normal. It means the last session closed clean.
## 2. Read the durable state
In this order, and stop as soon as you have enough:
- The Vikunja task, by id. Project Maven is ID 2, MCP at `http://localhost:9100/mcp`.
The task description and its comments hold the goal, the constraints, and the
assumption ledger. This outranks the handoff on every conflict.
- `CLAUDE.md`, the section that covers the area you are about to touch.
- The one file under `docs/` that owns the area. Check its `Last verified` line.
If the sha is behind the code you are reading, say so in step 4 and trust the code.
Do not read the dated files under `docs/evals/`. They are measurements from one day,
never updated. Read one only when you need the number it recorded.
If no task id is known, ask for one before doing anything else. Work without a task
is work nobody can resume.
## 3. Look at the ground
`git status`, `git log --oneline -5`, and the diff on the current branch. What the
repo says beats what any document says.
## 4. Restate, then stop
Write at most five bullets and stop. Do not write code, do not open files to "check
one thing first", do not start with a small safe change.
```
Task: V-359, one line.
Done: what is already on the branch.
Next: the one thing this session does.
Constraints: what would make this wrong.
Assuming: the beliefs that, if false, waste the session.
```
Then ask: is this right? Wait for the answer.
A corrected assumption goes into the Vikunja task as a comment, not into the handoff.
The handoff dies tonight. The task does not.
## 5. Then begin
- Delete `HANDOFF.md`. It has been consumed and must not outlive this step.
- On master, cut the branch: `scripts/task-branch.sh <id> <slug>`.
- One task per session. When context passes roughly half, run `/wrap` rather than
pushing on. A compacted session is a session that forgot why it made a choice.
+94
View File
@@ -0,0 +1,94 @@
---
name: wrap
description: Close a Maven work session cleanly. Runs the tests, updates the durable docs, commits in reviewable slices with the Vikunja ref, pushes so the PR opens, records state in Vikunja, and leaves a disposable handoff only if work remains. Use when the user says "wrap", "wrap up", "done for now", or when context passes roughly half.
---
# Wrap
Run every step. A partial wrap is worse than none, because the next session trusts
the parts that did run.
## 1. Prove it works
`make test`. If something fails, fix it or say plainly in the handoff and in Vikunja
that it fails, with the output. Never wrap on an untested claim.
## 2. Update the durable docs
Ask what a future agent would have to learn the hard way, and write that down.
- `CLAUDE.md` when a fact an agent needs before touching code has changed: routing
behaviour, a measured number, a flag default, a constraint. A commit that changed
routing or phrasing without touching the matching CLAUDE.md section is a bug.
Correct stale text in place. Do not append a new paragraph next to the wrong one.
- `AGENTS.md` when the recipe to build, run or preview changed.
- The one file under `docs/` that owns the area, plus its `Last verified: <date> @ <sha>`
line. Only a doc directly under `docs/` carries that line.
- A new dated file under `docs/evals/` when you measured something. Never edit an
existing dated file. A newer measurement is a new file, and the living doc points
at it.
Nothing that must survive tonight goes anywhere else. Not into the handoff, not into
a commit message, not into a comment in the code.
## 3. Commit in slices
Under 300 changed lines per commit in non-markdown files, enforced by `.githooks/pre-commit`.
Markdown is exempt and may land as one batch.
Each commit is one idea, subject in the repo's voice, lowercase area prefix, and it
ends with the Vikunja ref:
```
router: narrow the single-token rule (V-359)
```
If a change genuinely cannot split under 300 lines, say why in the commit body before
reaching for `--no-verify`.
## 4. Land it
```sh
task pr
```
It refuses a dirty tree, pushes, opens or refreshes the PR against the repo default
branch, labels the Vikunja task in-review, comments the PR url on it, and pushes an
ntfy. Do not push by hand and do not call `tea` yourself.
## 5. Record what `task pr` cannot know
Comment on the Vikunja task: what you measured, what is still open. List every
assumption that turned out to be wrong. If the session found new work, create a task
for it now rather than describing it in prose.
This step is what makes the handoff disposable.
## 6. Leave the handoff, or leave none
If the task is finished, delete `HANDOFF.md` and stop. An empty root is the correct
end state.
If work remains, write `HANDOFF.md` with nothing but what the next agent needs to
resume, and no history:
```markdown
# Handoff — <date>
Task: V-359 <one line>
Branch: task/359-<slug>, cut from master
## Where I stopped
<two sentences, mid-thought detail that is nowhere else>
## Next action
<the single concrete next step>
## Do not
<the trap I nearly fell into, or the approach already ruled out>
```
Nothing else goes in it. No summary of what landed, that is in git and Vikunja. No
design rationale, that is in `docs/`. No fact an agent needs on any task, that is in
`CLAUDE.md`. If a line in the handoff would still matter next week, it is in the wrong
file.
+30
View File
@@ -0,0 +1,30 @@
#!/bin/sh
# Every commit names the Vikunja task it belongs to.
#
# router: narrow the single-token rule (V-359)
#
# V- and not #, because Gitea autolinks #359 to a Gitea issue, which is a
# different tracker and a wrong link.
#
# Exempt: merges, reverts, fixup/squash, and the initial commit.
msg_file=$1
subject=$(sed -n '1p' "$msg_file")
case "$subject" in
Merge\ *|Revert\ *|fixup!\ *|squash!\ *|amend!\ *) exit 0 ;;
esac
if [ -f "$(git rev-parse --git-dir)/MERGE_HEAD" ]; then
exit 0
fi
if printf '%s' "$subject" | grep -qE '\(V-[0-9]+\)$'; then
exit 0
fi
echo "commit-msg: subject must end with a Vikunja task ref." >&2
echo " got: $subject" >&2
echo " want: router: narrow the single-token rule (V-359)" >&2
echo " No task yet? Create one. Work without a task is work nobody can resume." >&2
exit 1
+30
View File
@@ -0,0 +1,30 @@
#!/bin/sh
# Two guards, both bypassable with --no-verify when you mean it.
# 1. master is not a working branch.
# 2. a code commit stays under 300 changed lines.
# Markdown is exempt from the size cap on purpose: docs land as one batch.
branch=$(git symbolic-ref --short HEAD 2>/dev/null)
case "$branch" in
master|main)
echo "pre-commit: refusing to commit on $branch." >&2
echo " task start <vikunja-id> # branch off origin/master, write TASK.md" >&2
exit 1
;;
esac
# Added + deleted lines across staged files that are not markdown.
# numstat prints "-\t-\t<path>" for binaries; those count 0 and that is fine,
# a binary blob is not the kind of diff this cap exists to stop.
loc=$(git diff --cached --numstat -- . ':(exclude)*.md' |
awk '$1 ~ /^[0-9]+$/ { a += $1 } $2 ~ /^[0-9]+$/ { d += $2 } END { print a + d + 0 }')
if [ "$loc" -gt 300 ]; then
echo "pre-commit: $loc changed lines in non-markdown files, cap is 300." >&2
echo " Split it. Each commit should be one reviewable idea." >&2
echo " git reset <path> to unstage, or --no-verify if this genuinely cannot split." >&2
exit 1
fi
exit 0
+17 -2
View File
@@ -40,6 +40,9 @@ deploy/telegram.env
deploy/zenmoney.token
# IMAP password, read by mavmaild (never in argv, never committed)
deploy/imap.password
# Compose interpolation secrets — MAVEN_AMBIENT_TOKEN today. docker compose
# reads this file itself; it is not an env_file on any service.
/.env
# Temp files
/tmp/
@@ -50,7 +53,19 @@ opencode.json
# Test coverage output
coverage.out
# Agent worktrees and local agent state
.claude/
# Agent worktrees and local agent state. The workflow itself is tracked: the
# hooks, the skills and the prose dictionary are how a session behaves, so they
# get reviewed like code. Everything else under .claude/ is scratch.
/.claude/*
!/.claude/settings.json
!/.claude/prose-dictionary.yaml
!/.claude/skills/
# The disposable handoff. One session, then deleted. Never committed:
# anything worth keeping belongs in Vikunja, CLAUDE.md or docs/.
/HANDOFF.md
/models/stt
/models/tts
# root .env — MAVEN_AMBIENT_TOKEN and friends, same class as deploy/telegram.env
.env
+72 -5
View File
@@ -28,6 +28,14 @@ model is a one-line change to `phraser.model_path` in `deploy/mavend.json`.
See `docs/rearchitecture.md` for the target architecture, `docs/design.md` for the folded design spec, and
`AGENTS.md` for local-preview + model-download recipes.
**Model work is moving to the workstation** (owner's call, 2026-08-02). homesrv cannot grow a
GPU and the workstation has 16GB of VRAM. So the resident model, STT and TTS become preferred
remotes with a floor on homesrv. The workstation is never assumed up. Fall back silently when
it would only do the job better. Name the gap when the 1.7B cannot do it at all. The embedder
stays on homesrv permanently, because it backs that floor. Read `docs/offload.md` before
touching a daemon seam or adding a model caller. Vikunja #483 is the umbrella, #484 to #487
are the work.
## Build & test
CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored toolchain
@@ -127,11 +135,13 @@ on in deploy** — this section used to say it was wired `nil`, which stopped be
Cascade order: `stage0.go` exact-match fast-path → LLM router (when non-nil) → classifier
fallback. Any LLM error falls through to the classifier so a turn never breaks on the model.
Measured on the 77-case RU fixture (`docs/evals/2026-07-31-model-bakeoff.md`): the classifier scores
36.8% full accuracy at p50 31ms; Qwen3-1.7B scores 67.5% intent-only / 72.7% through the
cascade at p50 ≈825ms. Accuracy roughly doubled, latency is ~27× worse, and that trade was
accepted deliberately. **The ≈2.7s figure that stood here until 2026-08-02 was contention,
not the model.** See `docs/evals/2026-07-31-routing.md` line 61, which measures the LLM router at
Measured on the 77-case RU fixture. **Re-measured 2026-08-02: the classifier scores 68.8%
full accuracy at p50 16.6µs**, not the 36.8% at p50 31ms that stood here from
`docs/evals/2026-07-31-model-bakeoff.md`. That older figure predates the stage 0 rules and the
seed additions, both of which now score inside the classifier baseline. Qwen3-1.7B scores
77.9% intent-only / 72.7% through the cascade. So the router buys about 4 points of accuracy,
not a doubling, and the trade is worth re-arguing rather than assuming. **The ≈2.7s figure
that stood here until 2026-08-02 was contention, not the model.** See `docs/evals/2026-07-31-routing.md` line 61, which measures the LLM router at
p50 825ms / p95 1.2s / max 3.0s and the full cascade at p50 0.80-1.04s. Do not plan latency
work off the bakeoff table. `Confidence: 1.0` used to be hardcoded in `llmrouter.go`, so the LLM
path could never ask for clarification (6/6 refusal cases missed on the fixture) — Vikunja
@@ -213,3 +223,60 @@ data pans on a phone. Local preview + headless screenshot recipe is in `AGENTS.m
This repo is project **Maven** (ID 2) in Vikunja. MCP: `http://localhost:9100/mcp` (or
`http://192.168.1.104:9100/mcp` from workpc). Feature/bug/deploy tasks go there.
Vikunja is the durable task store. A task holds the goal, the constraints and the
assumption ledger. Work without a task id is work nobody can resume, so a session that
has no id asks for one before it starts.
## Session workflow
`~/.local/bin/task` owns the branch, the commit identity and the PR. One task, one
session, one PR.
```sh
task start <vikunja-id> # branch off origin/master, write TASK.md, fetch review comments
task pr # push, open or refresh the PR, label Vikunja, notify
task comments # re-pull this branch's review comments into .task/
```
Around that, `/pickup` opens a session and `/wrap` closes it. Wrap at roughly half
context rather than letting the session compact.
Five stores, and each one owns something the others must not hold:
| Store | Holds | Lifetime |
|---|---|---|
| Vikunja task | goal, constraints, assumption ledger, status | durable |
| `CLAUDE.md`, `AGENTS.md` | what an agent must know before touching code | durable |
| `docs/` | design, measurements, decisions | durable |
| `TASK.md` | the brief for this branch, written by `task start`, immutable | one branch |
| `HANDOFF.md` | only what the next agent needs to resume | one session |
`TASK.md` and `.task/` are excluded through `.git/info/exclude`. `HANDOFF.md` is
gitignored and injected at session start. If a line in the handoff would still matter
next week, it is in the wrong file.
Docs are tiered by path, so staleness is visible from the filename. Files directly under
`docs/` are living and carry a `Last verified: <date> @ <sha>` line. Files under
`docs/evals/` are dated measurements and are never edited after the day, so a newer
number is a new file. Files under `docs/archive/` are dead and read by nobody by default.
## Git guards
Two hooks in `.githooks/`, tracked, wired with `core.hooksPath`. Fresh clone:
```sh
git config core.hooksPath .githooks
```
- `pre-commit` refuses master, and refuses more than 300 changed lines in non-markdown
files. Markdown is exempt and may land as one batch.
- `commit-msg` requires the subject to end with `(V-<id>)`. `V-` and not `#`, because
Gitea autolinks `#123` to a Gitea issue, which is the wrong tracker.
Two more guards live outside the repo, in `~/.claude/hooks/`. `diff-budget.sh` blocks
further edits past 600 changed lines on a `task/` branch. `prose_lint_hook.py` checks
prose on every write. Both measure against `origin/master`, so a local master that is
ahead of the remote makes the diff budget read high.
`--no-verify` exists. Using it means saying why in the commit body.
+12 -8
View File
@@ -107,14 +107,18 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
return pr != nil && !h.now().After(pr.expiry)
},
yes: func() string {
// Only record the acceptance. The tick loop reads accepted
// routines and nudges on their own interval. Building a
// reminder here made a routine fire exactly once (Vikunja #366).
if err := h.dataStore.AcceptProposedRoutine(ctx, pr.routineID, h.now()); err != nil {
log.Printf("voice: accept proposed routine: %v", err)
return "не получилось запомнить рутину."
}
return "буду напоминать."
// Voice does NOT accept (Vikunja #367). Accepting hands the
// tick loop a standing new reason to speak, which is the same
// tier as enabling a tool — and DESIGN.md § "surface caps
// authority" says a room mic, reachable by anyone present, is
// structurally incapable of layer 3. So a spoken "да" leaves
// the row 'proposed' and points at the authed page, where the
// accept button is gated at step-up. The convenience of
// answering out loud stays; the authority does not move.
//
// Acceptance itself is recorded by /routines, and the tick
// loop nudges on the interval from there (Vikunja #366).
return "поняла — подтверди на странице рутин, и начну напоминать."
},
no: func() string {
if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil {
+79
View File
@@ -9,6 +9,7 @@ import (
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/pattern"
"github.com/kami/maven/internal/store"
@@ -283,3 +284,81 @@ func TestTickProposalCooldownSpacesAnnouncements(t *testing.T) {
}
}
}
// TestVoiceYesDoesNotAcceptRoutine — Vikunja #367. Accepting a routine hands
// the tick loop a standing new reason to speak, which DESIGN.md puts at layer
// 3, and voice is structurally incapable of layer 3. A spoken "да" must park
// the decision for the authed page, not flip the row itself.
func TestVoiceYesDoesNotAcceptRoutine(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedRefillEvents(t, st, ctx, now, pattern.MinEvents-1)
h := &reactiveHandler{api: ipc.NewStoreAPI(st), dataStore: st, now: func() time.Time { return now }}
// The MinEvents'th event is the one that makes the pattern detectable, and
// it goes through the voice path so the proposal is parked for a y/n.
last := now.Add(time.Duration(pattern.MinEvents-1) * 7 * 24 * time.Hour)
factID, err := st.WriteFact(ctx, last, store.KindSelf, "cat_water", "refill", "voice", 1.0, sql.NullInt64{})
if err != nil {
t.Fatalf("write fact: %v", err)
}
if phrase := h.detectPattern(ctx, factID, "cat_water", "refill", last); phrase == "" {
t.Fatal("expected a parked routine proposal")
}
reply, handled := h.resolveConfirm(ctx, "да")
if !handled {
t.Fatal("the spoken yes should be consumed by the routine confirm")
}
if !strings.Contains(reply, "рутин") {
t.Fatalf("reply should send him to the routines page, got %q", reply)
}
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineAccepted)
if err != nil {
t.Fatalf("list accepted: %v", err)
}
if len(rows) != 0 {
t.Fatalf("voice accepted a routine: %+v", rows)
}
proposed, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
if err != nil {
t.Fatalf("list proposed: %v", err)
}
if len(proposed) != 1 {
t.Fatalf("proposed routines = %d, want 1 (still waiting for the page)", len(proposed))
}
}
// TestVoiceNoStillDismissesRoutine — declining does not move the boundary
// outward, so voice keeps it. Only acceptance is gated.
func TestVoiceNoStillDismissesRoutine(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedRefillEvents(t, st, ctx, now, pattern.MinEvents-1)
h := &reactiveHandler{api: ipc.NewStoreAPI(st), dataStore: st, now: func() time.Time { return now }}
last := now.Add(time.Duration(pattern.MinEvents-1) * 7 * 24 * time.Hour)
factID, err := st.WriteFact(ctx, last, store.KindSelf, "cat_water", "refill", "voice", 1.0, sql.NullInt64{})
if err != nil {
t.Fatalf("write fact: %v", err)
}
if phrase := h.detectPattern(ctx, factID, "cat_water", "refill", last); phrase == "" {
t.Fatal("expected a parked routine proposal")
}
if _, handled := h.resolveConfirm(ctx, "нет"); !handled {
t.Fatal("the spoken no should be consumed by the routine confirm")
}
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineDismissed)
if err != nil {
t.Fatalf("list dismissed: %v", err)
}
if len(rows) != 1 {
t.Fatalf("dismissed routines = %d, want 1", len(rows))
}
}
+4 -7
View File
@@ -1210,13 +1210,10 @@ func routineRows(rs []ipc.ProposedRoutine) []routineRow {
return out
}
// acceptRoutine creates the recurring reminder for a proposal, then marks the
// proposal accepted and links the reminder to it. Weekly patterns get a cron
// expression; any other interval fires once.
//
// TODO(vikunja#46): this mirrors the voice accept path in cmd/mavend/voice.go.
// When the tick loop learns to read accepted proposals directly, both callers
// should hand off to one place in core instead of each building a reminder.
// acceptRoutine marks a proposal accepted. This page is the ONLY surface that
// may do it (Vikunja #367): accepting gives the tick loop a standing new
// reason to speak, which DESIGN.md puts at layer 3, and the button here is
// behind step-up. Voice can park the question and dismiss, never accept.
func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
proposed, err := core.ListProposedRoutines(ctx)
if err != nil {
+51 -1
View File
@@ -76,6 +76,56 @@
"snippet_runes": 1500
},
"//morning_routines": [
"The daily checklist (Vikunja #280). Each item is done when its fact_key",
"gets a non-voided fact inside the window, so 'выпил воды' closes water and",
"nothing has to be ticked by hand. nudge_at fires once, at the end of the",
"window, and only for what is still open. Weekdays empty = every day."
],
"morning_routines": [
{
"name": "утро",
"window_start": "08:00",
"window_end": "11:00",
"nudge_at": "10:30",
"severity": 1,
"items": [
{ "key": "medicine", "fact_key": "medicine", "label": "лекарство" },
{ "key": "water", "fact_key": "water", "label": "вода" },
{ "key": "pets", "fact_key": "pets", "label": "покормить кота" }
]
}
],
"//feeds": [
"RSS reading (Vikunja #258). Every item lands as a note with source",
"rss:<name>, which is also what puts entries in the intake journal that",
"/events reads. Only the feed URL leaves the box.",
"This is a starting pair, not a curated set — trim or extend it."
],
"feeds": {
"poll_interval": "30m",
"max_items": 5,
"max_age": "24h",
"sources": [
{ "name": "lwn", "url": "https://lwn.net/headlines/newrss", "category": "технологии" },
{ "name": "archlinux", "url": "https://archlinux.org/feeds/news/", "category": "технологии" }
]
},
"//crawl": [
"Reading a web page (Vikunja #259). on_demand answers 'посмотри <URL>'.",
"No allow_hosts, so any public host he names is readable; private",
"addresses are refused unconditionally by internal/webfetch and do not",
"need listing. Setting allow_hosts here would also narrow on-demand,",
"which is the point of leaving it empty."
],
"crawl": {
"on_demand": true,
"timeout": "10s",
"max_runes": 4000
},
"digest": {
"enabled": true,
"window": "30m",
@@ -119,7 +169,7 @@
"timeout": "400ms",
"rate": 100,
"max_hosts": 256,
"enabled": false
"enabled": true
},
"nexus": { "url": "http://nexus:9740" },
+9
View File
@@ -79,7 +79,16 @@ services:
<<: *image
# voice.bind is 0.0.0.0:9100 in deploy/mavend.json so mavweb can reach it
# cross-container. Verified 2026-07-06.
# -ambient-token turns on POST /api/ambient (Vikunja #126): the phone posts
# notification text, mavweb keeps only a meeting time. Empty ⇒ no route at
# all, which is what a missing MAVEN_AMBIENT_TOKEN gives. The value comes
# from the gitignored .env docker compose reads for interpolation, NOT from
# an env_file — flags are interpolated before any service env exists.
# Weakness worth naming: mavweb takes this as a flag, so it is visible in
# `ps` inside this container, unlike the zenmoney and IMAP secrets which are
# read from files.
command: ["mavweb", "-addr", ":9201", "-voice", "mavend:9100", "-core", "/run/maven/mavend.sock",
"-ambient-token", "${MAVEN_AMBIENT_TOKEN:-}",
"-nexus", "http://nexus:9740", "-praxis", "http://praxis:8989", "-hexis", "http://hexis:9741"]
depends_on: [mavend]
# loopback-only on purpose: /tools defines+executes arbitrary argv and
+114
View File
@@ -0,0 +1,114 @@
# Offloading model work to the workstation
*Last verified: 2026-08-02 @ 5c05163. Living doc: correct it in place, do not append.*
Owner's call, 2026-08-02. Vikunja #483 is the umbrella. Tasks #484 to #487 are the
work, and this file holds the shape and the rules all four must obey.
## The goal
homesrv cannot grow a GPU. The workstation has 16GB of VRAM. Move the model work
to the workstation and leave homesrv running the logic that must be always-on,
deterministic and cheap.
## Why this is tractable
The split already exists structurally. `mavsttd` and `mavttsd` are separate
daemons that core reaches over a socket, not linked libraries. Moving them off-box
is a transport change, not a redesign.
The microphone is at the workstation, because that is where the owner sits and
homesrv is headless. So speech-to-text and the wake word are already on the
workstation side by construction. Audio never has to cross the LAN. Only the core
turn does.
## The constraint that shapes everything
The workstation's GPU is often busy: CPT runs, experiments, Correx, the manga-recap
pipeline. It also sleeps. homesrv does not.
So an offloaded model is never *the* model. It is the preferred one, with a floor
on homesrv. That is the shape the cascade already has, where a router error falls
through to the classifier.
## The degradation rule
Two cases, and the line between them is sharp.
**Fall back silently** when the workstation model would only do the job *better*:
routing, phrasing, a nudge. Falling back costs nothing that exists today, because
the resident Qwen3-1.7B is today's production quality. The owner should not be told
that his reply was phrased by the smaller model.
**Name the gap** when the resident model cannot do the job *at all*. A world
question that a 1.7B answers by inventing is the case. A wrong answer is worse
than "не могу сейчас". This is the rule CLAUDE.md already states for a sibling
service being down.
Nothing in between. A turn never breaks on the workstation being asleep.
## Admission control, not a scheduler
There is no GPU arbiter. That is a service with its own failure modes, and nothing
here needs work *distributed*. It needs admission control. The workstation
advertises free VRAM over a health endpoint, and Maven treats it as one more query
source that claims a turn or passes. llama-server also refuses to load when VRAM is
short, so the failure is detectable without cooperation from the owner's other
jobs.
The caller must be able to ask "is this peer usable right now" without a turn
hanging on a timeout. A dead remote is a normal state, not an error state.
## What stays on homesrv, permanently
The **embedder** (multilingual-e5-small, ONNX, CPU). It backs the classifier, which
must answer while the GPU is saturated. It is also cheap enough on CPU that moving
it buys nothing. Four callers:
| Caller | What for |
|---|---|
| `internal/router/classifier.go` | the routing floor |
| `cmd/mavend/actions_query.go` (`queryEmbed`) | memory recall |
| `cmd/mavend/feeds.go` | ingest embedding for every RSS item |
| `internal/crawl/watch.go` | ingest embedding for every crawled page |
`internal/speaker` becomes a fifth once it lands.
## Inventory: what runs a model on homesrv today
The **resident model** is one llama-server with seven callers:
| Caller | What for |
|---|---|
| `cmd/mavend/voicewire.go` | routing |
| `cmd/mavend/replier_llm.go` | replies |
| `cmd/mavend/tick.go` | digestion worker: `PhraseNudge`, `PhraseReminder` |
| `cmd/mavend/capture.go` | capture summarisation (unreachable, see #480) |
| `cmd/mavend/mail.go` | mail extraction (off, no IMAP) |
| `cmd/mavend/kiwixwire.go` | answering from a Kiwix, search or crawl passage |
| `memoryeval.go`, `modelswap.go` | admin and evals |
Then the embedder above, **whisper.cpp** in `mavsttd`, and **piper** in `mavttsd`.
`mavwaked` uses no model at all: an energy-threshold VAD over 30ms frames.
## Order
1. **Transport** (#484). Nothing else is possible until a seam can cross a host.
`internal/netaddr` landed in PR #92. A seam address now carries its own scheme,
and a scheme-less one is still unix. A tcp seam requires a shared token, because
the filesystem permission that authenticated the unix socket is gone.
2. **The resident model** (#485). Biggest quality delta. A 16GB card runs a 7-14B,
which fixes what the 1.7B gets wrong: world knowledge, and the persona the CPT
targets. The degradation path is already written and measured, since the
classifier scores 68.8% full accuracy at p50 16.6µs on its own.
3. **Speech-to-text and text-to-speech** (#486). They gain a real margin, but on
quality alone, and both already work.
4. **The wake word** (#487). Independent of all of the above.
## Assumptions
- The LAN is trusted enough that wireguard is supported but not required (owner's
call). What crosses the wire is still his utterances. That is why the tcp seam
carries its own token instead of assuming a network boundary.
- The workstation is not expected to be up. Every child task must still serve a
turn while it is down.
+415 -44
View File
@@ -1,19 +1,62 @@
# QA plan: checking Maven properly
*Last verified: 2026-08-02 @ 7079a24. Living doc: correct it in place, do not append.*
*Last verified: 2026-08-02 @ 20aa2d5. Living doc: correct it in place, do not append.*
Written 2026-08-01, after the 35-PR stack landed and the box came back up.
Refreshed 2026-08-02 against the live list, after PRs #85-#90.
44 of the 50 open Vikunja tasks are `QA:` tasks. They are verification work, not
42 of the 50 open Vikunja tasks are `QA:` tasks. They are verification work, not
build work. Most sat unverifiable while Maven was down for 11 days. That
blocker is gone.
The plan as written on 2026-08-01 named 40 task numbers. Ten open `QA:` tasks were
missing and two of the named ones had closed. Every open task now appears below,
the eight non-QA ones in the last two sections.
This plan orders them by what unblocks what. Do sessions 1 and 2 first. Almost everything
downstream assumes the voice loop works, and nobody has confirmed that since
the redeploy.
---
## What the 02-08-2026 run found
Sessions 1, 2 and 3 all ran. Read these five before picking anything up.
- **470: a question writes invented knowledge into memory.** Recall then serves
it back. `что дальше?` lands on `IntentFact` and stores the model's answer as a
`self` fact at confidence 1.00. Two junk rows then claimed seven unrelated
world questions through recall, outranking the search leg. A question about the
capital of Australia was answered `какая последняя версия языка Go?`. Two bad
writes silently disabled world answering, with nothing logged.
- **466: a pending clarify is global.** One unanswerable clarify swallowed the
next three utterances from three separate sessions. With ntfy, telegram and
voice all live, a clarify raised on web chat eats the next telegram message.
- **467: spoken task capture is dead.** The router calls the capture marker an
`act`, and capture is reachable only from the `note` intent.
- **The classifier baseline in this repo was wrong**, and it flattered the
router. See session 2 and **464**.
- **477: the model swap and the self-update cannot be triggered on this box.**
Both are built and both are correct in test. The swap needs a passkey and
WebAuthn is unconfigured. `mavupdate` needs to reach a socket that only an
in-container uid can open.
- **479: an unconfigured capability lets the question escape to web search.**
Netscan off, asked `какие устройства в сети?`. She answered from the live web
with a general article about network hardware. A question about his LAN went to
an upstream engine. The crawler fails the same way.
Twenty-one defects were filed on 02-08-2026: 462 through 482. Six tasks this plan
had written off as blocked turned out to be ready to check. All six ran. Every
one of them is code-correct and stops at the deploy.
Three of the five config blockers in **472** were then cleared. The morning
routine, ambient ingest, feeds, the crawler and netscan are all live. Two remain,
and both are the owner's call: a token for each ecosystem sibling, and seed data
in Nexus and Praxis.
---
## Before you start
Two things bite anyone running these checks on homesrv.
@@ -36,45 +79,75 @@ Nothing here has been confirmed since the redeploy, and everything else assumes
it works. Do this first.
Closes or advances: **44** (conversation), **45** (text chat), **287** (voice
session quality), **321** steps 3-5 (quiet mode), **288** (STT fixtures).
session quality), **321** steps 3-5 (quiet mode), **288** (STT golden audio).
**288 is not blocked.** The fixtures are committed under `cmd/mavsttd/testdata/`
and `make test-stt-golden` runs today. This plan said otherwise until 02-08-2026.
Steps 1 and 3-6 were run on 02-08-2026 and pass. Steps 2 and 7-9 still need a
person at the box, because they need a microphone or a nudge to arrive.
Steps 1 and 3-6 do not need a browser. `POST /api/chat` takes a form-encoded
`text=` field and a cookie jar, and answers with the rendered `/chat` page:
```sh
curl -s --noproxy '*' -c jar -b jar -L -X POST \
http://127.0.0.1:9201/api/chat --data-urlencode 'text=привет'
```
Parse the whole page, not the last text node. The page carries nav and footer
text. A naive tail of the Cyrillic nodes returns the wrong string, which makes
turns look misaligned when they are not.
1. Open `http://127.0.0.1:9201/chat` and hold a short conversation in Russian.
Watch for three things: she answers in feminine forms (`рада`, `поняла`), she
says `ты` and never `вы`, and no pet names appear.
**Passes** (02-08-2026, five turns): `я рада`, `поняла`, `помогла`,
`проверила`, `записала`, `грустна`, `ты` throughout, no pet names.
2. Press push-to-talk on `/dash`. Say `привет`. Confirm a spoken reply comes
back. This is the only check that covers mic to STT to core to TTS to
speaker as one path. It is also the path the eleven-day outage most likely
broke.
3. Say `тихий режим`. Expect `тихий режим включён. буду реже напоминать.`
4. Say `выключи тихий режим`. Expect `тихий режим выключен.` Negation must win.
3. Say `тихий режим`. Expect `тихий режим включён. буду реже напоминать.` **Passes.**
4. Say `выключи тихий режим`. Expect `тихий режим выключен.` Negation must win. **Passes.**
5. Say `в комнате тихо`. Quiet mode must NOT flip. Confirm on `/history` that no
`quiet_hours` fact was written.
`quiet_hours` fact was written. **Passes**: no row written. She answers `пока
не умею отвечать на этот вопрос.`, so it lands on `IntentSystem` with no arm.
6. Say `включи режим тишины`, then `сделай потише`. Both must flip quiet mode
on. These are the noun form and the comparative, added 01-08-2026.
on. These are the noun form and the comparative, added 01-08-2026. **Both pass.**
7. Wait for a nudge, then say `потом` within twenty minutes. Expect `хорошо,
вернусь к этому позже.` and the nudge row on `/notifications` reading
`snoozed`. Say `потом` again with nothing pending: it must route as an
ordinary utterance, not be swallowed.
8. Wait for the water nudge, then say `выпил воды`. Expect the ordinary fact
reply and nothing extra — she must not congratulate you. Check
reply and nothing extra. She must not congratulate you. Check
`/notifications`: the row reads `acted`. Then trigger another nudge and say
`готово`; expect `отлично, отметила.` and the same outcome.
`готово`. Expect `отлично, отметила.` and the same outcome.
9. Note anything where she is slow, cuts off, or talks over herself. That is
287's whole content and it has no written acceptance criteria yet.
**First evidence, in text** (02-08-2026): nothing breaks, but answers wander
and stitch unrelated topics. Asked whether he should move flats, she opened
with the weather. That is 287, and it is a phrasing problem, not a loop problem.
**319 is fixed** (01-08-2026). Single-word Russian utterances no longer come
**The wake path cannot be checked as deployed.** `mavwaked` and `mavenclient`
appear in no compose file and run as no host process. Step 2 covers only
push-to-talk, from `/dash` through mavsttd and mavttsd. Wake word and VAD
are untested by construction. Decide whether they belong in compose or on a
client machine, and say which in the deploy docs. Tracked as **463**.
**319's single-token bug is fixed** (01-08-2026). Single-word Russian utterances no longer come
back as `не совсем поняла — можешь переформулировать?`. `привет` and `поужинал`
both pass now: `thinSingleToken` spares social singles and any token carrying a
verb ending, and only thins a bare nominal like `вода`. If a one-word utterance
still gets clarified during the smoke test, that is a new case for the lexicon,
not the old bug.
verb ending, and only thins a bare nominal like `вода`. A one-word utterance that
still gets clarified in this session is a new case for the lexicon, not the old bug.
---
## Session 2: measurement (half a day, mostly waiting)
Closes or advances: **320** items 2-4, **278** (make the eval lab routine),
**319** (gate recalibration).
Closes or advances: **320** items 2-4, **278** (make the eval lab routine).
Also **248** (memory evaluation), **319** (the margin gate) and **323** (the
startup timeout arm).
The resident llama-server cannot be reached by the eval harness. It binds
`--host 127.0.0.1 --port 0` inside the container, so the port is kernel-assigned
@@ -99,14 +172,67 @@ make eval-recall
A large miss against 72.7% means the deploy differs from the bench harness.
Two things to decide while the numbers are in front of you:
**Run on 02-08-2026 @ af9d213. The deploy matches the bench.** `eval-models`
scored 56 of 77: 72.7% full, 77.9% intent-only, 2 false clarifies and 1 missed.
That is the recorded figure to the decimal, and calendar sat at 2 of 2, so the
stage 0 agenda rules hold. `eval-phrasing` scored 21 of 27 on the talk fixture
against a recorded 20, and the 15 nudge templates passed every check.
- **319's gate recalibration.** The single-token rule needs narrowing or
dropping. This needs your judgement, not a threshold sweep. The fixture and the
daemon disagree about what is correct on two of the three false clarifies.
Two numbers in this repo were wrong, and both flattered the resident model.
- **The classifier is not 36.8% and not 31ms.** `make eval-router` reports
`classifier+onnx: 53/77 (68.8% full)` at p50 16.6µs. The figure repeated here
and in `CLAUDE.md` predates the stage 0 rules and the seed additions. Both now
score inside that baseline. The accuracy gap the router buys is
roughly 4 points, not 36. Re-argue the trade on the real numbers: **464**.
- **Router latency was measured under contention again.** p50 1.126s, p95 1.58s,
max 3.24s, against a recorded p50 825ms. The resident model was serving the
daemon on the same iGPU throughout. Do not record this as a regression, and do
not record it as a measurement either. Stop the stack before timing the router.
`classifier+hash` scores 19.5%, which is the no-ONNX degraded path and is not the
failure floor the deploy uses. Do not quote it as the classifier baseline.
Then three things to decide while the numbers are in front of you:
- **319 is done.** 359 gave the LLM path a real confidence signal.
`thinSingleToken` was narrowed on 01-08-2026, and agenda questions moved to
stage 0. Missed clarify sits at 1 of 6 and false clarifies at 2. Item 2 point 2
closed on 02-08-2026: the `make eval-recall` margin sweep is the distribution
that was asked for, and `0.008` sits at the knee.
| delta | answered | false recall |
|---|---|---|
| 0.005 | 18/27 | 2/5 |
| **0.008** | **18/27** | **1/5** |
| 0.010 | 16/27 | 1/5 |
It removes four of five false recalls at no cost in answers, and the next step
costs two answers for nothing. The hand-picked value survives on evidence.
- **278's real ask** is making the eval lab routine rather than building it. It
is built. Decide whether it runs on a timer, on every merge, or on demand, and
the task can close.
- **248** is the memory evaluation loop. It ships, it writes notes, and it cannot
speak. `make eval-recall` covers the retrieval half. The open question is whether
a written evaluation nobody reads is worth the tick.
**323 is down to one check.** PR #90 covered the spawn path and took phraser
coverage to 76.9%. Only the 60s startup timeout arm is untested, because testing it
needs a `StartupTimeout` field on `Config` rather than a test-only hack. While you
are on the box, time a cold 1.7B load off spinning disk. If it runs near 60s, the
default is too tight and the field earns itself twice.
Warm, it is nowhere near. A second llama-server answered `/health` 1.8s after
launch at `n_ctx 4096` on 02-08-2026. That is page cache, so it does not settle
the question. A cold read needs a cache drop, which needs root.
**`CheckFeminine` has a false positive.** On 02-08-2026 it failed
`query-notes-do-not-answer` for `ты заплатил`, calling it masculine
self-reference. Masculine second person is correct, because the owner is male.
The check matches a masculine
past-tense verb before `за` without confirming the subject is `я`. Fix it in
`internal/phraser/eval/checks.go` before trusting a phrasing score to the case.
The real talk-fixture score on that run is 22 of 27, not 21. Tracked as **462**.
Item 4 of **320** needs a permission I do not have. Kill the `llama-server`
pid under `maven-mavend-1`, post a turn, and confirm it still completes
@@ -115,47 +241,269 @@ check that the failure floor catches a mid-session model death.
---
## Session 3: the interaction batch (a day, or three sittings)
## Session 3: the interaction batch (a day, or five sittings)
These need real use rather than a command, grouped by what one sitting covers.
**Morning and delivery** (**280**, **281**, **128**, **282**): open `/morning`,
walk the seven required behaviours, then check the four interruption outcomes
and the digest gap. **282** needs the `desk_active` script enabled on the desk
PC first, which is **15** and needs you at that machine.
**Morning and delivery** (**280**, **281**, **128**, **282**, **283**, **285**):
open `/morning`, walk the seven required behaviours, then check the four
interruption outcomes and the digest gap. **282** needs the `desk_active` script
enabled on the desk PC first, which is **15** and needs you at that machine.
**283** is the event intake envelope every reach shares, so a delivery check
exercises it whether you name it or not. **285** is not verification: the bridge
framework works and the remaining ask is more adapters. Decide which reach comes
next, or park it.
Run 02-08-2026. **280 is blocked.** No morning routine is configured (**472**).
`morning.Item` also has no required-versus-optional field, so behaviour 1 cannot
hold whatever you configure (**473**). **281's digest gap is closed**, and
its presence rule passes on inspection. Three of its five items need traffic the
box has not had. **283 is blocked**: nothing feeds the intake journal. **128
found the worst defect of the whole session, see below.**
Three of 472's five blockers were cleared the same day, in `deploy/mavend.json`
and `docker-compose.yml`.
- A `morning_routines` block, one routine `утро` 08:00-11:00 with medicine,
water and pets. It is live: the dispatcher logged `dropped morning:утро (sev1,
presence=away)`, so the plan builds and the nudge is proposed. 280's
behaviours and 128 step 11 are checkable now. 473 still stands.
- `-ambient-token` on mavweb, value in a gitignored `/.env` that docker compose
reads for interpolation. `/api/ambient` answers 401 without the token and 201
with it, storing `calendar_event_20260802_Standup`. 283 step 5 and 128 step 8
are unblocked. The token is a flag, so it shows in `ps` inside that container.
The zenmoney and IMAP secrets are read from files instead. Ingest also
reads the notification's wall clock as UTC and stores a 14:30 meeting at 18:30
(**482**).
- `feeds` (two sources), `crawl.on_demand` and `netscan.enabled`. The intake
journal now fills: `/events` holds `scan:lan` and `ambient:notif` rows.
Two are not mine to clear. No sibling has a `token` in `deploy/mavend.json`, so
273 steps 6 and 8 need a credential decision. Nexus has no entities and Praxis no
attention items, so 272 step 3 needs seed data whose content is the owner's call.
For **285**, two facts bear on the choice. Synapse is already running on this box
and healthy, so a Matrix reach has a live target and needs no new service. And
mavweb is already a PWA with a service worker, which 285 itself calls the highest
value adapter left. Today's reaches are ntfy, telegram and voice.
**Query sources** (**258**, **286**): ask her something the RSS feeds answer and
something only a ZIM answers, with the search block on. Live search leads and the
ZIMs are the fallback since 02-08-2026. **286**'s remaining half is doc and
git ingestion, which is build work, not a check.
**Do not read `/trace` for this.** `/trace` is the nudge-rule trace: rule,
severity, predicate, gate, selected. No query-source field exists anywhere in the
codebase. The only evidence of which query source claimed a turn is the
`voice: search:` and `voice: kiwix:` lines in `docker compose logs mavend`
(`actions_query.go:589` and `:660`).
Run 02-08-2026, 20 turns. **Search leads and the personal boundary holds.** Every
world question that reached the boundary was claimed by search. All three
personal questions produced no search and no kiwix line at all.
The rest of this sitting went badly. **Kiwix has zero live coverage.** SearXNG
returns four results for everything, including two invented nonsense terms. So
`querySearch` always claims, and Kiwix is unreachable code as deployed. The ZIM
half of the 02-08-2026 decision is unverified. A ZIM answer cannot signal a
silent search failure, because a ZIM answer cannot happen.
**Ordering defects** in feeds and calendar, plus 258 step 1's utterance not
working: **474**. And the sitting independently found stage 2 of **470**.
**Tasks and calendar** (**129**, **130**, **127**, **126**, **246**): capture a
task by voice, confirm it lands, check prioritisation ordering is not nonsense.
**246** (mail reader) also exercises the `IngestMail` rung that moved to
`AuthWrite` this morning.
Run 02-08-2026. **129 passes.** The page and the spoken answer agree on ordering.
The undistinguished task carries no invented reason on either surface, which is
the thing 129 asks for. **130 fails outright** and **127 half fails**:
**467**, **469**. **246 cannot be run**: `mavmaild` is commented out in
`docker-compose.yml` and there is no `email` block, so nothing in steps 4-13 is
reachable. The `IngestMail` rung does sit at `AuthWrite`
(`internal/auth/policy.go:96`, asserted in `auth_test.go:421`), verified by
reading only.
**Routines and patterns** (**43**, **46**, **247**, **254**): these need history
to detect against. If the database is thin after the outage, they may have
nothing to propose, which is not a failure. Check `/routines` before
concluding anything.
Run 02-08-2026. The answer is the middle case: **the detector ran and found
nothing.** The tick loop is live, and `detectPatterns` is called unconditionally
at `cmd/mavend/tick.go:227`. It has run about 25 times since the restart. It
finds nothing because the events table is empty upstream of it. Rows land there
only from `pattern.Extract` at fact-write time, and `Extract` requires the fact
value to match a closed 7-action lexicon. All 200 facts on `/history` are
`page_heartbeat`, `netdata_alarm`, `quiet_hours`, `name`, `service_down` and
`рост`. Not one lexicon hit, so no event can exist, let alone the four one pair
needs. **46 step 5 passes**: `/routines` renders `noticed 0` with the empty state
and the hint string.
Two things block this sitting, and both are build work. The seeding recipe on
**43** goes through `sqlite3` and cannot work. And `pattern.Detect` has no
minimum-interval floor, so seeding by hand mints a permanent false routine
(**468**). Do not try to seed a pattern with four fast chat turns.
**Ecosystem** (**272**, **273**, **276**): nexus, hexis and praxis are wired and
logged clean at boot. **276** is the degraded-mode suite, which means taking
siblings down on purpose. Worth doing while you are already in there.
logged clean at boot.
Run 02-08-2026, read-only half. All three answer `/health` 200 and `/ecosystem`
lists 18 Hexis capabilities with correct read-only and mutating badges. **272 and
273 are blocked on empty data**, not on code. Nexus holds no entities, Praxis
holds no attention items, and the Calls panel has never recorded a call. See
**472**, and read its warning first. 273's trace fix has never been validated
here. An empty Calls panel is exactly what the old bug looked like. The page is
`/ecosystem`, not `/siblings`.
**276 ran 02-08-2026 and the suite is sound.** 17 `TestEcosystem_` cases pass
under `-race`, not the 10 the task describes. The mutation check bites: patching
the Nexus-error branch of `handleHexisAct` to `return ""` fails
`TestEcosystem_MalformedNexusResponseFailsClosed` on the expected line.
Steps 4 and 6 could not be checked through chat, because no utterance reaches
Praxis (**475**). «что требует внимания» routes to `intent=query` and is answered
by the search leg, identically whether `ecosystem-praxis-1` is up or stopped. The
degraded string never appears because its branch is never entered. Step 5 is
blocked the same way: `перезапусти muzick indexer` clarifies on
`HasFn:false`, and the router had already rewritten the entity name to
`музик индексер` (**476**).
Both steps were checked on `/ecosystem` instead, which reads Praxis directly.
With Praxis stopped the card reads `praxis — unreachable` while Nexus and Hexis
keep rendering. On `docker start` the card returns to `nothing needs attention.`
with no mavend restart. Independent degradation and recovery both hold.
**Operations** (**249**, **250**): both ran 02-08-2026. The code is correct and
neither lever can be pulled on this box. See **477**.
**250** passes steps 1, 2, 3, 9 and 10 on the deploy. The capability announces
itself. `/models` names the model llama-server reports, not the config filename.
Asking her to switch models does nothing. Removing `swap_models` renders `swap
not configured`. Step 4's refusal half passes at HTTP 403, and the 403 comes from
mavend rather than mavweb. WebAuthn is unconfigured, so the web gate fails open
and the wire gate fails closed. Steps 5 to 8 need a passkey assertion nothing on
this box can produce. They pass in test: 13 swap cases and 7 page cases covering
drain, mid-swap refusal, rollback, failed rollback and the not-owned refusal.
**249** passes steps 1 and 2. Step 3 stops it. `mavupdate` health-checks over
`/run/maven/mavend.sock`, which is `srw------- 1 10001 999` inside a docker
volume. The host owner cannot traverse `/var/lib/docker/volumes` and cannot
connect to a socket owned by an in-container uid. `mavupdate` assumes a
host-installed daemon and the deploy is containers. Do not sudo around this.
---
## Housekeeping (one sitting, no box needed)
## Housekeeping (done 02-08-2026, and this section was mostly wrong)
Four QA tasks will not close no matter how long they sit, because they are
gated on something that does not exist:
This section claimed eleven tasks were not verification work. **Three were not.
The other eight are.** Every one of the eight has shipped, tested code behind it.
The error ran one way: it wrote off work that is ready to check. Do not trust a
"nothing is built" line in this plan without grepping for the package first.
- **125** zenmoney: needs a token you have not minted.
- **256** Home Assistant: needs HA configured.
- **257** Bluetooth: BLOCKED, no bluez on the box. Says so in the title.
- **288** STT golden audio: needs fixtures generated.
Relabelled to `Blocked:`, claim verified:
Relabel these so they stop reading as backlog. They are not verification work
that is pending, they are work that has not started.
- **125** zenmoney. `internal/zenmoney/` ships and is tested against a fixture.
`deploy/zenmoney.token` does not exist and the compose mount is commented out.
One token unblocks it.
- **256** Home Assistant. `internal/smarthome/` ships, the `smarthome` block sits
in `deploy/mavend.json` at `enabled: false`, and 8123 and 1883 are closed.
- **14** cold-start unlock. The seam is real at `cmd/mavend/main.go:128` and
`internal/webauthn/prf.go` is in place. `lockedAPI` is gone, replaced by
`Server.Check` in `internal/ipc/server.go`. Gated on an authenticator that
implements the WebAuthn PRF extension, which is hardware, not code.
Same treatment for the five plan-only tasks (**251** MCP, **252** vision,
**253** hearing, **255** speaker recognition, **259** crawler). A `QA:` prefix on
a plan is misleading.
Left alone, because the claim here was false:
- **284** simulator. `cmd/mavend/simulator_test.go`, three scenarios under
`cmd/mavend/testdata/scenarios/`, and a `simulate` target at `Makefile:98`.
**Run 02-08-2026: all three scenarios pass**, plus the determinism and
backwards-step guards. One defect found, see below.
- **288** STT golden audio. Four WAVs and `golden_v1.json` are committed under
`cmd/mavsttd/testdata/`, the make targets exist, and `models/stt/ggml-small.bin`
is on the box. Session 1 lists 288 as blocked on fixtures, which is wrong.
**Run 02-08-2026: all four pass**, WER at or under ceiling with no drift.
| fixture | transcript | WER | ceiling |
|---|---|---|---|
| ru_reminder | `Напомни мне через час позвонить маме.` | 0.00 | 0.10 |
| ru_fact | `А отметь, что я выпил воды.` | 0.20 | 0.25 |
| ru_query | `Что у меня сегодня по календарю?` | 0.00 | 0.10 |
| en_act | `Restart the web server and check the disk space.` | 0.00 | 0.10 |
That also settles a session 1 worry indirectly: whisper.cpp works on Vulkan
after the redeploy. Only the mic and the wake path remain unproven.
**The simulator routes with an empty seed set.** Every `make simulate` run logs
`loaded 0 seed examples from models/seeds`, seven times per scenario. The test
runs from `cmd/mavend`, and the seed path is relative to the repo root. The
scenarios still pass, which means they pass without the classifier having any
seeds to match against. Whatever 284 is proving, it is not proving the routing
the deploy runs. Fix the path before trusting a green simulator.
- **257** Bluetooth. The bluez half is genuinely absent. The LAN-scan half shipped
(`internal/netscan/`), and steps 1-9 run today. Only step 10 is Bluetooth, so
relabelling the whole task would bury real pending work.
- **251** MCP, **253** hearing, **259** crawler. All three ship
(`internal/mcp/`, `internal/capture/`, `internal/crawl/`) with no external gate.
Fully checkable. `259`'s step 1 wants no `crawl` block in `deploy/mavend.json`,
and there is none, so it is already set up correctly.
- **252** vision and **255** speaker recognition. Both ship. Each is blocked only
on a model download: a vision gguf with mmproj, and a speaker embedding model.
Neither is present under `/mnt/hdd1`. Their refusal-path steps run today.
So the honest split is three blocked on a credential or hardware, two blocked on
a download, and six ready to check. That is roughly a session of real QA this
plan had written off as backlog.
**All six ran on 02-08-2026.** Every one of them is code-correct and stops at the
deploy. The pattern repeats often enough to be the headline: the packages pass,
and the box cannot reach them.
**251, MCP.** Steps 1, 2, 3, 4 and 13 pass. Package tests green under `-race`.
Off-by-default is clean, and the SSRF refusal is exact: without `allow_private`
the log reads `refusing to connect to a private address: 127.0.0.1` and `/tools`
shows the server down with zero proposals. Steps 5 to 12 are blocked. `ss -lntp`
shows the Vikunja MCP server on `127.0.0.1:9100` only, so no container reaches it
at any address (**478**). `allow_private` does work, measured both ways.
**253, hearing.** Steps 1, 2 and 17 pass. `internal/capture` covers 90.3%. Steps
7 to 16 are blocked on something nobody can work around: no shipped client calls
`CaptureStart`. There is no `cmd/mavheard`, no mavweb route, and `mavenclient`
never calls it (**480**). Two of its QA steps are also stale.
**257, netscan.** Steps 2, 3 and 9 pass at unit level. Step 1 fails. Steps 4 to 8
need the block enabled. Step 10 is Bluetooth and stays skipped.
**259, crawler.** Steps 1 and 15 pass. Step 2 fails. Steps 3 to 14 need a `crawl`
block that nobody has written.
Both were configured later the same day, and both work. `netscan.enabled: true`
answers `какие устройства в сети?` with `нашла 3 устройства, из них 2 с вебом, 2 с
ssh. список записала.` and the scan lands in the intake journal as `scan:lan`.
`crawl.on_demand: true` answers `посмотри https://lwn.net — что там пишут?` from
the real page. So **479** is one defect, not the routing defect it was filed as.
An unconfigured capability declines its own turn instead of naming the gap.
Nothing is wrong with the routing.
257 step 1 and 259 step 2 fail the same way and share a task (**479**). An
unconfigured capability does not name the gap, so the question escapes to web
search. `какие устройства в сети?` was answered with a general article about
network hardware. That is his LAN going to an upstream engine.
**252 vision and 255 speaker.** Both confirmed blocked. The disk claim was
re-verified rather than taken on trust: 16 text-only ggufs under `/mnt/hdd1`, no
mmproj and no speaker embedding model. Everything not needing the model passes,
including the two refusals that matter. `TestNewLocalRefusesNonPrivateEndpoints`
rejects `https://api.openai.com`, and forget really deletes
(`internal/store/memory.go:145` is a real `DELETE`, not a tombstone). Vision is
19/19, speaker 22/22, media 16/16.
**470 got worse.** Both poisoned facts show `voided` on `/history`, and the
defect survives. Re-measured at 15:42, after four restarts: `почему небо синее?`
still answers `какая последняя версия языка Go?` with no `search:` line. What
comes back is the question he typed, not the value the fact held. So the poison
is a vector in the memory index, and `revert` does not remove it. There is
currently no documented way to repair a poisoned box.
---
@@ -171,16 +519,39 @@ Not QA. These are blocked on a decision or a credential only you have.
| 355 | Deploy the Hexis auth change. Was blocked on Maven being under construction, which it no longer is. The client half is vendored and wired. |
| 357 | Decide whether entity-existence validation is the permanent target guard or whether blessing lands in Nexus. |
| 275 | Hexis native API and MCP parity. |
| — | Decide on `-require-stepup`. Making it the default needs WebAuthn configured first, or it locks you out of your own admin surfaces. See **317**. |
| — | Three nginx sites bind wildcard `:80` (`acme.conf`, `matrix`, `panel`), so the ecosystem's bind-level protection is not in effect and `allow`/`deny` is carrying it alone. See **354**. |
| — | Decide on `-require-stepup`. Making it the default needs WebAuthn configured first, or it locks you out of your own admin surfaces. |
317 and 354 closed on 01-08-2026. The step-up gate now covers `POST /api/chat` and
`/routines`, and the nginx template is locked down with a `maven.<domain>` block for
mavweb. The `-require-stepup` default is still your call.
---
## Not this repo
Two open tasks sit on the Maven board and are not Maven work. Move them or note
where they land, so the board stops reading as 50 things Maven owes.
- **358** replace the rowid execution cursor with a real seq column. This is Hexis,
and it must land before any execution retention or pruning does.
- **362** mirror the router prompt reorder into the relabelling prompt. This is the
training workspace, enforced by `llm/check_prompt_parity.py` there, not here.
---
## Suggested order
1. Session 1. If the voice loop is broken, nothing else matters.
2. The `-require-stepup` and Kuma decisions. Five minutes, unblocks **317** fully
and **16**.
3. Session 2. The numbers tell you whether the router is worth its 90x latency.
2. The `-require-stepup` and Kuma decisions. Five minutes, and it unblocks **16**.
3. Session 2. **Run on 02-08-2026.** The numbers came back worse for the router
than the docs claimed. The classifier is 68.8%, not 36.8%, and 16.6µs, not
31ms. The router buys about 4 points of accuracy for four orders of magnitude
of latency. Whether that still earns its place is now an open question.
4. Housekeeping. Cheap, and it makes the remaining backlog honest.
5. Session 3, split whichever way suits you.
5. Session 3, split whichever way suits you. All five sittings ran on
02-08-2026. Read the per-sitting notes before repeating any of them.
The next thing to fix is not in this plan. Four defects say the same sentence:
a capability is built and no utterance reaches it. **466** (a clarify is global),
**467** (capture is act-routed), **475** (attention is act-routed), **476** (the
router rewrites entity names). Routing is where the work is.
+24 -13
View File
@@ -8,13 +8,15 @@ import (
"net"
"sync"
"time"
"github.com/kami/maven/internal/netaddr"
)
// Client — the module side of the boundary. Wraps a unix-socket connection
// and satisfies CoreAPI, so a module imports ipc, holds a CoreAPI, and is
// agnostic to whether it's been wired in-process (tests / daemon-embedded)
// or over this socket (full topology). The swappability is the seam auth
// will insert into without touching module code.
// Client — the module side of the boundary. Wraps a connection to core and
// satisfies CoreAPI, so a module imports ipc, holds a CoreAPI, and is
// agnostic to whether it's been wired in-process (tests / daemon-embedded),
// over a local unix socket, or over tcp to another host. The swappability is
// the seam auth will insert into without touching module code.
//
// One Client ⇒ one conn ⇒ one concurrent request at a time. A module that
// wants parallel requests opens one Client per goroutine; the store is the
@@ -22,7 +24,8 @@ import (
// per-Client lock keeps frame interleaving impossible by construction.
type Client struct {
conn net.Conn
path string // kept so a dropped conn can be re-dialed (core restart)
path string // the address as configured, kept for errors and logs
addr netaddr.Addr // parsed, so a dropped conn can be re-dialed (core restart)
mu sync.Mutex
}
@@ -81,14 +84,22 @@ var readOnlyMethods = map[Method]bool{
MethodPing: true,
}
// Dial connects to a core socket at path and returns a Client. The module
// owns its Client lifecycle; Close on shutdown.
// Dial connects to core at path and returns a Client. The module owns its
// Client lifecycle; Close on shutdown.
//
// path is a netaddr seam address: a bare path is the unix socket it has
// always been, and "tcp://host:port?token=..." reaches a core on another
// host. See internal/netaddr.
func Dial(path string) (*Client, error) {
c, err := net.Dial("unix", path)
addr, err := netaddr.Parse(path)
if err != nil {
return nil, fmt.Errorf("ipc: dial %s: %w", path, err)
return nil, err
}
return &Client{conn: c, path: path}, nil
c, err := netaddr.Dial(addr)
if err != nil {
return nil, fmt.Errorf("ipc: dial %s: %w", addr, err)
}
return &Client{conn: c, path: path, addr: addr}, nil
}
func (c *Client) Close() error {
@@ -189,9 +200,9 @@ func (c *Client) call(ctx context.Context, m Method, params, result any) error {
// re-dials clean. Caller holds c.mu.
func (c *Client) roundtrip(m Method, raw json.RawMessage, resp *Response) error {
if c.conn == nil {
conn, err := net.Dial("unix", c.path)
conn, err := netaddr.Dial(c.addr)
if err != nil {
return fmt.Errorf("%w: dial %s: %v", errWriteLost, c.path, err)
return fmt.Errorf("%w: dial %s: %v", errWriteLost, c.addr, err)
}
c.conn = conn
}
+20 -40
View File
@@ -8,11 +8,11 @@ import (
"fmt"
"log"
"net"
"os"
"sync"
"sync/atomic"
"time"
"github.com/kami/maven/internal/netaddr"
"github.com/kami/maven/internal/store"
"golang.org/x/sys/unix"
)
@@ -446,6 +446,7 @@ func mapErr(err error) error {
type Server struct {
api atomic.Value // stores CoreAPI
path string
addr netaddr.Addr
ln net.Listener
wg sync.WaitGroup
@@ -610,31 +611,29 @@ type CheckFunc func(ctx context.Context, m Method, params json.RawMessage) error
// MethodAssertStepUp dispatch calls this instead of going through CoreAPI.
type StepUpFunc func(ctx context.Context) error
// Listen creates a Server bound to path. path's parent dir must exist and be
// 0700 (we chmod it if we own it); the socket file itself is created 0600 so
// only the same unix user can connect — the current "auth floor", same radius
// as wg at the network boundary. Removing a stale socket at path first lets
// the daemon restart cleanly.
// Listen creates a Server bound to path.
//
// A bare path is a unix socket, unchanged: its parent dir is 0700 and the
// socket file itself is 0600, so only the same unix user can connect — the
// current "auth floor", same radius as wg at the network boundary. A stale
// socket is removed first so the daemon restarts cleanly.
//
// A "tcp://host:port?token=..." address binds a network listener instead, for
// a module that lives on another host. There is no filesystem there to be the
// auth floor, so netaddr checks the shared token before this package sees the
// connection and a token is mandatory. See internal/netaddr.
func Listen(path string, api CoreAPI) (*Server, error) {
_ = os.Remove(path) // stale socket from a crashed daemon; ignore missing
if err := os.MkdirAll(parentDir(path), 0o700); err != nil {
return nil, fmt.Errorf("ipc: mkdir socket dir: %w", err)
}
// umask could widen the perms on socket creation; tighten then chmod to
// be explicit. 0600 ⇒ read+write by owner only.
oldMask := unix.Umask(0o077)
ln, err := net.Listen("unix", path)
unix.Umask(oldMask)
addr, err := netaddr.Parse(path)
if err != nil {
return nil, fmt.Errorf("ipc: listen %s: %w", path, err)
return nil, err
}
if err := os.Chmod(path, 0o600); err != nil {
_ = ln.Close()
_ = os.Remove(path)
return nil, fmt.Errorf("ipc: chmod socket: %w", err)
ln, err := netaddr.Listen(addr)
if err != nil {
return nil, err
}
s := &Server{
path: path,
addr: addr,
ln: ln,
done: make(chan struct{}),
}
@@ -1268,7 +1267,7 @@ func (s *Server) Close() error {
// missing the seal costs every write since the last clean shutdown.
log.Printf("ipc: %d connection(s) still busy after %s, closing anyway", s.liveConns(), closeGrace)
}
_ = os.Remove(s.path)
netaddr.Cleanup(s.addr)
return err
}
@@ -1338,25 +1337,6 @@ func (s *Server) Path() string { return s.path }
// while the server is serving (dispatch loads api once per request via atomic).
func (s *Server) SetAPI(api CoreAPI) { s.api.Store(api) }
func parentDir(p string) string {
if i := lastIndexByte(p, '/'); i >= 0 {
if i == 0 {
return "/"
}
return p[:i]
}
return "."
}
func lastIndexByte(s string, b byte) int {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == b {
return i
}
}
return -1
}
// peerCaller — read SO_PEERCRED off a unix conn to identify the connecting
// process. Returns ok=false on a non-unix conn or a platform without
// SO_PEERCRED; the caller then proceeds without a Caller (the socket perms
+277
View File
@@ -0,0 +1,277 @@
// Package netaddr parses a daemon seam address and dials or binds it.
//
// Every seam between Maven's daemons used to be a unix socket with the
// network hardcoded at the call site — two dials in internal/ipc, one listen,
// and the same pair again in internal/worker. That is correct for co-located
// daemons and it is the reason a module cannot live on another host. This
// package moves the choice into the address string so a deploy picks the
// transport, not a recompile:
//
// /run/maven/stt.sock unix (the default, unchanged)
// unix:///run/maven/stt.sock unix (explicit, same thing)
// tcp://workstation:9310?token=hunter2 tcp
//
// A scheme-less address is unix and behaves exactly as it did before this
// package existed: same 0700 parent dir, same 0600 socket, same bytes on the
// wire with no handshake in front of them.
//
// Over TCP the filesystem permission that authenticated the unix socket is
// gone, and what crosses this seam is audio of the owner speaking and the
// text of his turns. So a TCP seam carries a shared token, checked before the
// first protocol frame is read. Wireguard is supported underneath and is not
// required.
package netaddr
import (
"crypto/subtle"
"errors"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/sys/unix"
)
// ErrUnauthorized — the peer presented a token the listener does not accept,
// or presented none when one is required.
var ErrUnauthorized = errors.New("netaddr: unauthorized")
// Addr is a parsed seam endpoint.
type Addr struct {
// Network is "unix" or "tcp".
Network string
// Address is the socket path (unix) or host:port (tcp).
Address string
// Token is the shared secret for a tcp seam. Empty for unix, where the
// filesystem does the same job.
Token string
}
// String renders the address for logs and errors. The token is never included.
func (a Addr) String() string {
if a.Network == "unix" {
return a.Address
}
return a.Network + "://" + a.Address
}
// IsUnix reports whether this seam is a unix socket, and so is local, is
// authenticated by file permissions, and needs no handshake.
func (a Addr) IsUnix() bool { return a.Network == "unix" }
// Parse reads a seam address. Anything without a "scheme://" prefix is a unix
// socket path, which keeps every existing config and every default working
// untouched.
func Parse(s string) (Addr, error) {
if !strings.Contains(s, "://") {
return Addr{Network: "unix", Address: s}, nil
}
u, err := url.Parse(s)
if err != nil {
return Addr{}, fmt.Errorf("netaddr: parse %q: %w", s, err)
}
switch u.Scheme {
case "unix":
return Addr{Network: "unix", Address: u.Path}, nil
case "tcp":
if u.Host == "" {
return Addr{}, fmt.Errorf("netaddr: %q has no host:port", s)
}
return Addr{Network: "tcp", Address: u.Host, Token: u.Query().Get("token")}, nil
default:
return Addr{}, fmt.Errorf("netaddr: unsupported scheme %q", u.Scheme)
}
}
// MustParse is Parse for a literal known good at compile time. It panics on a
// bad address, so use it in tests and constants, never on config input.
func MustParse(s string) Addr {
a, err := Parse(s)
if err != nil {
panic(err)
}
return a
}
// handshakeTimeout bounds the token exchange. A peer that cannot write one
// short line in this long is not going to serve a turn either.
const handshakeTimeout = 5 * time.Second
// greeting prefixes the token line. Versioned so a later mTLS seam can be
// told apart from this one on the wire.
const greeting = "MAVEN1 "
// Dial connects to a. On a tcp seam it sends the token and waits for the
// listener to accept it, so a returned conn is already authorized and the
// caller can write its first protocol frame.
func Dial(a Addr) (net.Conn, error) {
return DialTimeout(a, 0)
}
// DialTimeout is Dial with a bound on the connect. Zero means the operating
// system default. The token exchange gets its own timeout either way.
func DialTimeout(a Addr, timeout time.Duration) (net.Conn, error) {
var c net.Conn
var err error
if timeout > 0 {
c, err = net.DialTimeout(a.Network, a.Address, timeout)
} else {
c, err = net.Dial(a.Network, a.Address)
}
if err != nil {
return nil, err
}
if a.IsUnix() {
return c, nil
}
if err := clientHandshake(c, a.Token); err != nil {
_ = c.Close()
return nil, err
}
return c, nil
}
func clientHandshake(c net.Conn, token string) error {
_ = c.SetDeadline(time.Now().Add(handshakeTimeout))
defer c.SetDeadline(time.Time{})
if _, err := c.Write([]byte(greeting + token + "\n")); err != nil {
return fmt.Errorf("netaddr: send token: %w", err)
}
var reply [3]byte
if _, err := readFull(c, reply[:]); err != nil {
return fmt.Errorf("%w: %v", ErrUnauthorized, err)
}
if string(reply[:]) != "ok\n" {
return ErrUnauthorized
}
return nil
}
// Listener wraps a net.Listener so Accept performs the token check for a tcp
// seam. A connection that fails the check is closed and never surfaces, so
// the protocol above this layer only ever sees authorized peers.
type Listener struct {
net.Listener
addr Addr
}
// Accept returns the next authorized connection. Unauthorized peers are
// dropped and Accept keeps waiting: a bad token is a rejected stranger, not a
// reason to stop serving.
func (l *Listener) Accept() (net.Conn, error) {
for {
c, err := l.Listener.Accept()
if err != nil {
return nil, err
}
if l.addr.IsUnix() {
return c, nil
}
if err := serverHandshake(c, l.addr.Token); err != nil {
_ = c.Close()
continue
}
return c, nil
}
}
// Addr reports the parsed seam address this listener was built from.
func (l *Listener) SeamAddr() Addr { return l.addr }
func serverHandshake(c net.Conn, want string) error {
_ = c.SetDeadline(time.Now().Add(handshakeTimeout))
defer c.SetDeadline(time.Time{})
// The line is bounded: greeting, token, newline. Read a byte at a time so
// nothing of the first protocol frame is consumed when the token is short.
line := make([]byte, 0, 128)
var b [1]byte
for {
if _, err := readFull(c, b[:]); err != nil {
return err
}
if b[0] == '\n' {
break
}
line = append(line, b[0])
if len(line) > 512 {
return ErrUnauthorized
}
}
got, ok := strings.CutPrefix(string(line), greeting)
if !ok {
return ErrUnauthorized
}
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 {
return ErrUnauthorized
}
if _, err := c.Write([]byte("ok\n")); err != nil {
return err
}
return nil
}
func readFull(c net.Conn, p []byte) (int, error) {
n := 0
for n < len(p) {
m, err := c.Read(p[n:])
n += m
if err != nil {
return n, err
}
}
return n, nil
}
// Listen binds a. A unix seam gets the perms it has always had: parent dir
// 0700, socket 0600, and any stale socket from a crashed daemon removed
// first. A tcp seam must carry a token, because there is no filesystem to
// stand in for one.
func Listen(a Addr) (*Listener, error) {
if a.IsUnix() {
ln, err := listenUnix(a.Address)
if err != nil {
return nil, err
}
return &Listener{Listener: ln, addr: a}, nil
}
if a.Token == "" {
return nil, fmt.Errorf("netaddr: listen %s: tcp seam requires a token", a)
}
ln, err := net.Listen("tcp", a.Address)
if err != nil {
return nil, fmt.Errorf("netaddr: listen %s: %w", a, err)
}
return &Listener{Listener: ln, addr: a}, nil
}
func listenUnix(path string) (net.Listener, error) {
_ = os.Remove(path) // stale socket from a crashed daemon; ignore missing
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("netaddr: mkdir socket dir: %w", err)
}
// umask could widen the perms on socket creation; tighten then chmod to
// be explicit. 0600 ⇒ read+write by owner only.
oldMask := unix.Umask(0o077)
ln, err := net.Listen("unix", path)
unix.Umask(oldMask)
if err != nil {
return nil, fmt.Errorf("netaddr: listen %s: %w", path, err)
}
if err := os.Chmod(path, 0o600); err != nil {
_ = ln.Close()
_ = os.Remove(path)
return nil, fmt.Errorf("netaddr: chmod socket: %w", err)
}
return ln, nil
}
// Cleanup removes the socket file behind a unix seam. It is a no-op for tcp.
func Cleanup(a Addr) {
if a.IsUnix() && a.Address != "" {
_ = os.Remove(a.Address)
}
}
+185
View File
@@ -0,0 +1,185 @@
package netaddr
import (
"errors"
"net"
"path/filepath"
"testing"
)
// A scheme-less address must stay unix. Every deploy in the tree writes a bare
// path, so this is the test that says the transport change costs them nothing.
func TestParseSchemelessIsUnix(t *testing.T) {
a, err := Parse("/run/maven/stt.sock")
if err != nil {
t.Fatalf("parse: %v", err)
}
if !a.IsUnix() {
t.Fatalf("want unix, got %q", a.Network)
}
if a.Address != "/run/maven/stt.sock" {
t.Fatalf("address = %q", a.Address)
}
if a.Token != "" {
t.Fatalf("unix seam carries a token: %q", a.Token)
}
}
func TestParse(t *testing.T) {
cases := []struct {
in string
net, addr, tk string
wantErr bool
}{
{in: "", net: "unix", addr: ""},
{in: "unix:///run/maven/core.sock", net: "unix", addr: "/run/maven/core.sock"},
{in: "tcp://workstation:9310", net: "tcp", addr: "workstation:9310"},
{in: "tcp://workstation:9310?token=hunter2", net: "tcp", addr: "workstation:9310", tk: "hunter2"},
{in: "tcp://", wantErr: true},
{in: "udp://workstation:9310", wantErr: true},
}
for _, c := range cases {
a, err := Parse(c.in)
if c.wantErr {
if err == nil {
t.Errorf("Parse(%q) = %v, want error", c.in, a)
}
continue
}
if err != nil {
t.Errorf("Parse(%q): %v", c.in, err)
continue
}
if a.Network != c.net || a.Address != c.addr || a.Token != c.tk {
t.Errorf("Parse(%q) = %+v, want %s/%s/%s", c.in, a, c.net, c.addr, c.tk)
}
}
}
// The token must never reach a log line.
func TestStringHidesToken(t *testing.T) {
a := MustParse("tcp://workstation:9310?token=hunter2")
if got := a.String(); got != "tcp://workstation:9310" {
t.Fatalf("String() = %q", got)
}
}
// A unix seam must round-trip with no handshake in front of the payload: the
// first bytes the listener sees are the caller's, exactly as before.
func TestUnixRoundTripHasNoHandshake(t *testing.T) {
a := MustParse(filepath.Join(t.TempDir(), "s.sock"))
ln, err := Listen(a)
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
go echoOnce(ln)
c, err := Dial(a)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer c.Close()
if got := roundTrip(t, c, "hello"); got != "hello" {
t.Fatalf("got %q", got)
}
}
func TestTCPRoundTripWithToken(t *testing.T) {
ln, addr := listenLoopback(t, "s3cret")
defer ln.Close()
go echoOnce(ln)
c, err := Dial(addr)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer c.Close()
if got := roundTrip(t, c, "hello"); got != "hello" {
t.Fatalf("got %q", got)
}
}
func TestTCPWrongTokenIsRejected(t *testing.T) {
ln, addr := listenLoopback(t, "s3cret")
defer ln.Close()
// Accept keeps waiting past the bad peer, so nothing here should ever
// reach the echo. A conn that does means the token was not checked.
go echoOnce(ln)
bad := addr
bad.Token = "wrong"
if _, err := Dial(bad); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("dial with wrong token: err = %v, want ErrUnauthorized", err)
}
}
// A stranger that speaks the protocol instead of the greeting is dropped, and
// the listener stays up for the peer that follows it.
func TestTCPUngreetedPeerDoesNotKillTheListener(t *testing.T) {
ln, addr := listenLoopback(t, "s3cret")
defer ln.Close()
go echoOnce(ln)
raw, err := net.Dial("tcp", addr.Address)
if err != nil {
t.Fatalf("raw dial: %v", err)
}
if _, err := raw.Write([]byte("GET / HTTP/1.1\n")); err != nil {
t.Fatalf("raw write: %v", err)
}
raw.Close()
c, err := Dial(addr)
if err != nil {
t.Fatalf("dial after stranger: %v", err)
}
defer c.Close()
if got := roundTrip(t, c, "still here"); got != "still here" {
t.Fatalf("got %q", got)
}
}
// A tcp seam with no token is a misconfiguration, and it must fail at bind
// rather than serve the owner's turns to anyone who connects.
func TestTCPListenRequiresToken(t *testing.T) {
if _, err := Listen(MustParse("tcp://127.0.0.1:0")); err == nil {
t.Fatal("listen on a tokenless tcp seam succeeded")
}
}
func listenLoopback(t *testing.T, token string) (*Listener, Addr) {
t.Helper()
ln, err := Listen(Addr{Network: "tcp", Address: "127.0.0.1:0", Token: token})
if err != nil {
t.Fatalf("listen: %v", err)
}
return ln, Addr{Network: "tcp", Address: ln.Addr().String(), Token: token}
}
func echoOnce(ln *Listener) {
c, err := ln.Accept()
if err != nil {
return
}
defer c.Close()
buf := make([]byte, 256)
n, err := c.Read(buf)
if err != nil {
return
}
_, _ = c.Write(buf[:n])
}
func roundTrip(t *testing.T, c net.Conn, msg string) string {
t.Helper()
if _, err := c.Write([]byte(msg)); err != nil {
t.Fatalf("write: %v", err)
}
buf := make([]byte, 256)
n, err := c.Read(buf)
if err != nil {
t.Fatalf("read: %v", err)
}
return string(buf[:n])
}
+8 -2
View File
@@ -534,8 +534,14 @@ func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision
text = "reminder"
}
// Russian, like the other two prompts (Vikunja #404). Asking a model for a
// Russian reply in English is asking it to switch languages mid-prompt,
// and a 1.7B sometimes answers in the language it was asked in. The
// persona rules and the JSON contract are not repeated here: this call
// goes through chat(), so nudgeSystem already states both, and a second
// statement of the same contract is one more thing that can drift.
prompt := fmt.Sprintf(
`The user set a reminder: "%s". Rephrase it briefly as a gentle nudge. Respond as JSON: {"response": "...", "mood": "..."}`,
`Он поставил напоминание: "%s". Скажи это своими словами, коротко и мягко — одно предложение.`,
text,
)
resp, err := p.chat(ctx, prompt)
@@ -754,7 +760,7 @@ func (p *LLMPhraser) querySystemPrompt() string {
base := "Ты отвечаешь ему по источникам, которые тебе дали. Отвечай ТОЛЬКО по ним: всё, что ты говоришь, должно быть написано в источниках. " +
"Если ответа в них нет — так и скажи и на этом остановись; не добавляй ничего из своих знаний и не догадывайся. " +
"Не приплетай прошлые реплики разговора. " +
"Отвечай по-русски, коротко и своими словами, начинай с \"вот что я нашла: \". О себе — в женском роде, глаголы в прошедшем времени с окончанием -ла. Он мужчина, обращайся к нему на \"ты\". Respond ONLY with valid JSON: {\"response\": \"...\", \"mood\": \"neutral\"}."
"Отвечай по-русски, коротко и своими словами, начинай с \"вот что я нашла: \". О себе — в женском роде, глаголы в прошедшем времени с окончанием -ла. Он мужчина, обращайся к нему на \"ты\". Отвечай ТОЛЬКО одним объектом JSON: {\"response\": \"...\", \"mood\": \"neutral\"}."
return persona.Prepend(p.cfg.ContextBlock, base)
}
+256
View File
@@ -0,0 +1,256 @@
package phraser
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
// The spawn path (NewLLMPhraser, spawnLlamaServer, startLlamaProc, llamaProc.Close)
// was at 0% coverage: every test built the phraser with NewLLMPhraserAt, which
// starts no process. These tests drive the real spawn code against a fake
// llama-server script, so the startup race arms and the reaping are exercised
// without a model or a GPU.
// fakeLlama writes an executable script standing in for llama-server and returns
// its path. body runs after the script has recorded its own pid.
func fakeLlama(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "fake-llama-server")
script := "#!/bin/sh\n" + body + "\n"
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("write fake server: %v", err)
}
return path
}
// listensThenSleeps prints the line startLlamaProc scrapes, then stays alive
// until killed — the shape of a real llama-server that came up.
const listensThenSleeps = `echo "srv load_model: listening on http://127.0.0.1:18081" >&2
while : ; do sleep 1 ; done`
func testCfg(bin string) Config {
cfg := DefaultConfig("/nonexistent/model.gguf")
cfg.BinPath = bin
return cfg
}
func TestExtractPort(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"127.0.0.1:0", "0"},
{"127.0.0.1:8080", "8080"},
{"127.0.0.1:", "0"},
{"", "0"},
{"8080", "0"}, // no colon: Cut yields no port, so the caller gets the "any port" default
} {
if got := extractPort(tc.in); got != tc.want {
t.Errorf("extractPort(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestStartLlamaProcScrapesPortAndReaps(t *testing.T) {
bin := fakeLlama(t, listensThenSleeps)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p, err := startLlamaProc(ctx, testCfg(bin))
if err != nil {
t.Fatalf("startLlamaProc: %v", err)
}
if p.BaseURL() != "http://127.0.0.1:18081" {
t.Fatalf("BaseURL = %q, want the scraped address", p.BaseURL())
}
pid := p.cmd.Process.Pid
p.cancel = cancel
if err := p.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
// Close must Wait, otherwise the child lingers as a zombie.
if p.cmd.ProcessState == nil {
t.Fatal("Close did not reap the child: ProcessState is nil")
}
if err := syscall.Kill(pid, 0); err == nil {
t.Fatalf("child %d still exists after Close", pid)
}
}
func TestStartLlamaProcFailureArms(t *testing.T) {
t.Run("binary missing", func(t *testing.T) {
cfg := testCfg(filepath.Join(t.TempDir(), "does-not-exist"))
_, err := startLlamaProc(context.Background(), cfg)
if err == nil || !strings.Contains(err.Error(), "llm: start") {
t.Fatalf("err = %v, want a start failure", err)
}
})
t.Run("server exits without listening", func(t *testing.T) {
// stderr closes, so the reader goroutine reports EOF on errCh.
bin := fakeLlama(t, `echo "ggml_vulkan: no device" >&2
exit 1`)
_, err := startLlamaProc(context.Background(), testCfg(bin))
if err == nil || !strings.Contains(err.Error(), "llm: server output") {
t.Fatalf("err = %v, want the server-output arm", err)
}
})
t.Run("context cancelled during startup", func(t *testing.T) {
// Never prints the listen line and never exits: only ctx can end this.
bin := fakeLlama(t, `while : ; do sleep 1 ; done`)
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(150 * time.Millisecond)
cancel()
}()
defer cancel()
_, err := startLlamaProc(ctx, testCfg(bin))
if !errors.Is(err, context.Canceled) {
t.Fatalf("err = %v, want context.Canceled", err)
}
})
}
func TestNewLLMPhraserSpawns(t *testing.T) {
bin := fakeLlama(t, listensThenSleeps)
p, err := NewLLMPhraser(context.Background(), testCfg(bin))
if err != nil {
t.Fatalf("NewLLMPhraser: %v", err)
}
if p.BaseURL() != "http://127.0.0.1:18081" {
t.Fatalf("BaseURL = %q", p.BaseURL())
}
pid := p.be.(*llamaProc).cmd.Process.Pid
if err := p.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if p.BaseURL() != "" {
t.Fatalf("BaseURL after Close = %q, want empty", p.BaseURL())
}
if err := syscall.Kill(pid, 0); err == nil {
t.Fatalf("llama-server %d survived Close", pid)
}
}
func TestNewLLMPhraserSpawnFailure(t *testing.T) {
cfg := testCfg(filepath.Join(t.TempDir(), "does-not-exist"))
p, err := NewLLMPhraser(context.Background(), cfg)
if err == nil {
p.Close()
t.Fatal("want an error when the server cannot start")
}
if p != nil {
t.Fatalf("want a nil phraser on failure, got %#v", p)
}
}
// TestPdeathsigKillsOrphan is the orphan test the task asked for. A SIGKILLed
// mavend never runs Close, so nothing but the kernel's Pdeathsig can stop its
// llama-server. Re-exec this test binary as the "daemon", let it spawn the fake
// server, SIGKILL the daemon, and assert the grandchild died with it.
func TestPdeathsigKillsOrphan(t *testing.T) {
bin := fakeLlama(t, listensThenSleeps)
cmd := exec.Command(os.Args[0], "-test.run=TestSpawnHelperProcess", "-test.v=false")
cmd.Env = append(os.Environ(), "MAVEN_SPAWN_HELPER=1", "MAVEN_FAKE_LLAMA="+bin)
out, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("stdout pipe: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("start helper: %v", err)
}
defer func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }()
buf := make([]byte, 256)
n, err := out.Read(buf)
if err != nil {
t.Fatalf("read child pid: %v", err)
}
childPID, err := strconv.Atoi(strings.TrimSpace(string(buf[:n])))
if err != nil {
t.Fatalf("helper printed %q, want a pid: %v", string(buf[:n]), err)
}
if err := syscall.Kill(childPID, 0); err != nil {
t.Fatalf("llama-server %d not running before the kill: %v", childPID, err)
}
// SIGKILL: the helper gets no chance to clean up, exactly like an OOM kill.
if err := cmd.Process.Signal(syscall.SIGKILL); err != nil {
t.Fatalf("kill helper: %v", err)
}
_, _ = cmd.Process.Wait()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if err := syscall.Kill(childPID, 0); err != nil {
return // gone: Pdeathsig did its job
}
time.Sleep(20 * time.Millisecond)
}
_ = syscall.Kill(childPID, syscall.SIGKILL)
t.Fatalf("llama-server %d outlived the SIGKILLed parent", childPID)
}
// TestSpawnHelperProcess is not a test. It is the child half of
// TestPdeathsigKillsOrphan: spawn a llama-server, print its pid, then block.
func TestSpawnHelperProcess(t *testing.T) {
if os.Getenv("MAVEN_SPAWN_HELPER") != "1" {
t.Skip("helper for TestPdeathsigKillsOrphan")
}
cfg := testCfg(os.Getenv("MAVEN_FAKE_LLAMA"))
p, err := startLlamaProc(context.Background(), cfg)
if err != nil {
fmt.Println("spawn failed:", err)
os.Exit(1)
}
fmt.Println(p.cmd.Process.Pid)
os.Stdout.Sync()
select {} // wait to be killed
}
// TestKillMavenScriptMatchesRealCommandLine pins kill-maven.sh's fallback
// pattern to the command line startLlamaProc actually builds. The script leaked
// orphans twice already, both times because the pattern stopped matching: first
// `llama-server.*maven`, then a hardcoded model name after the model was swapped.
func TestKillMavenScriptMatchesRealCommandLine(t *testing.T) {
src, err := os.ReadFile("../../kill-maven.sh")
if err != nil {
t.Fatalf("read kill-maven.sh: %v", err)
}
m := regexp.MustCompile(`(?m)^\s*LLM='([^']+)'`).FindSubmatch(src)
if m == nil {
t.Fatal("no default LLM='...' pattern in kill-maven.sh")
}
pat, err := regexp.Compile(string(m[1]))
if err != nil {
t.Fatalf("LLM pattern %q does not compile: %v", m[1], err)
}
// Rebuild the command line from the production arg list, so a change to
// startLlamaProc that breaks the sweep fails here instead of on the box.
cfg := DefaultConfig("/opt/maven/models/llm/Qwen3-1.7B-UD-Q4_K_XL.gguf")
cfg.NCtx, cfg.NGpuLayers = 4096, 99
cmdline := strings.Join([]string{
cfg.BinPath,
"-m", cfg.ModelPath,
"--host", "127.0.0.1",
"--port", extractPort(cfg.Listen),
"-c", fmt.Sprintf("%d", cfg.NCtx),
"-ngl", fmt.Sprintf("%d", cfg.NGpuLayers),
"--no-webui",
}, " ")
if !pat.MatchString(cmdline) {
t.Fatalf("kill-maven.sh pattern %q does not match %q — orphans would leak", m[1], cmdline)
}
}
+1 -1
View File
@@ -6,5 +6,5 @@ func KnowledgePrompt() string {
// No self-introduction here: the shared persona block already says who she
// is, and this line used to disagree with it — a different name ("Мавена")
// and a masculine noun ("ассистент") in front of a feminine persona.
return `Ответь кратко из своих знаний. Если не знаешь — скажи "не знаю". Не выдумывай. Respond ONLY with valid JSON: {"response": "...", "mood": "neutral"}.`
return `Ответь кратко из своих знаний. Если не знаешь — скажи "не знаю". Не выдумывай. Отвечай ТОЛЬКО одним объектом JSON: {"response": "...", "mood": "neutral"}.`
}
+14 -2
View File
@@ -24,6 +24,8 @@ import (
"net"
"sync"
"time"
"github.com/kami/maven/internal/netaddr"
)
// Client — one connection to one worker module. NOT goroutine-safe for
@@ -38,15 +40,25 @@ type Client struct {
dial func() (net.Conn, error)
}
// Dial opens a Client to the worker socket at path. The first call lazily
// Dial opens a Client to the worker module at path. The first call lazily
// dials; subsequent calls reuse the conn (a fresh dial happens on next call
// after a teardown). Lazy dial keeps a worker that's restarting from
// blocking core's startup; core attempts the dial on first use.
//
// path is a netaddr seam address. A bare path is the unix socket it has
// always been; "tcp://workstation:9310?token=..." reaches a module on another
// host, which is how stt and tts move to the machine with the GPU and the
// microphone. A bad address surfaces on the first call, not here, because
// Dial does not fail — see internal/netaddr.
func Dial(path string) *Client {
addr, err := netaddr.Parse(path)
return &Client{
path: path,
dial: func() (net.Conn, error) {
return net.Dial("unix", path)
if err != nil {
return nil, err
}
return netaddr.Dial(addr)
},
}
}
+22 -34
View File
@@ -7,10 +7,11 @@
// which module to dial; mixing the two is a config error caught cleanly by
// the wire, not a runtime goroutine panic). One Server per module process.
//
// Socket perms mirror ipc.Server: dir 0700, socket 0600 ⇒ same unix user.
// The module has no key, so the floor is "same user"; the wg/mTLS layers
// are out of scope here (this socket never crosses the network radius —
// it's local-only, point-to-point between two processes on the box).
// The seam address decides the transport. On the default unix socket the
// perms mirror ipc.Server — dir 0700, socket 0600 ⇒ same unix user — and that
// is the whole auth floor, because the seam never leaves the box. A tcp
// address moves the module to another host and takes that floor away, so
// netaddr checks a shared token before the first frame. See internal/netaddr.
package worker
import (
@@ -18,11 +19,10 @@ import (
"encoding/json"
"fmt"
"net"
"os"
"sync"
"sync/atomic"
"golang.org/x/sys/unix"
"github.com/kami/maven/internal/netaddr"
)
// Server — a worker module process's listener. Wires either a Transcriber,
@@ -34,6 +34,7 @@ type Server struct {
s Synthesizer
path string
addr netaddr.Addr
ln net.Listener
wg sync.WaitGroup
@@ -61,25 +62,24 @@ func NewSynthesizerServer(path string, s Synthesizer) *Server {
// two separate processes per the restart-free / fail-independent invariant).
func (srv *Server) SetSynthesizer(s Synthesizer) { srv.s = s }
// Listen binds the unix socket with 0700 dir + 0600 socket perms (same floor
// as internal/ipc). A stale socket at path is removed first so the worker
// process restarts cleanly after a crash, no manual cleanup needed.
// Listen binds the seam address the Server was built with.
//
// A bare path is a unix socket with 0700 dir + 0600 socket perms, the same
// floor as internal/ipc, and a stale socket is removed first so the worker
// process restarts cleanly after a crash. A "tcp://host:port?token=..."
// address binds a network listener instead, so this module can run on the
// workstation while core stays on homesrv; the token is mandatory there,
// because there is no filesystem to be the auth floor. See internal/netaddr.
func (srv *Server) Listen() error {
_ = os.Remove(srv.path)
if err := os.MkdirAll(parentDir(srv.path), 0o700); err != nil {
return fmt.Errorf("worker: mkdir socket dir: %w", err)
}
oldMask := unix.Umask(0o077)
ln, err := net.Listen("unix", srv.path)
unix.Umask(oldMask)
addr, err := netaddr.Parse(srv.path)
if err != nil {
return fmt.Errorf("worker: listen %s: %w", srv.path, err)
return err
}
if err := os.Chmod(srv.path, 0o600); err != nil {
_ = ln.Close()
_ = os.Remove(srv.path)
return fmt.Errorf("worker: chmod socket: %w", err)
ln, err := netaddr.Listen(addr)
if err != nil {
return err
}
srv.addr = addr
srv.ln = ln
return nil
}
@@ -193,7 +193,7 @@ func (srv *Server) Close() error {
}
err := srv.ln.Close()
srv.wg.Wait()
_ = os.Remove(srv.path)
netaddr.Cleanup(srv.addr)
return err
}
@@ -214,15 +214,3 @@ func marshalResult(v any) json.RawMessage {
b, _ := json.Marshal(v)
return b
}
func parentDir(p string) string {
for i := len(p) - 1; i >= 0; i-- {
if p[i] == '/' {
if i == 0 {
return "/"
}
return p[:i]
}
}
return "."
}