Read spending from zenmoney in the poller, answer it from facts (#125) #62
Closed
claude
wants to merge 1 commits from
overnight/money-zenmoney into overnight/task-priority
pull from: overnight/money-zenmoney
merge into: kami:overnight/task-priority
kami:master
kami:task/725-capability-ledger-and-empirical-baseline
kami:task/692-heads-path-may-equal-model-path-and-noth
kami:task/694-staticcheck-and-deadcode-are-still-not-i
kami:task/682-go-1-25-5-and-x-text-0-14-0-carry-20-rea
kami:task/674-caveats
kami:task/673-mavgpud-serves-the-model-to-the-whole-la
kami:task/487-capture-device-doc
kami:task/487-capture-device
kami:task/487-wake-word-deploy
kami:task/487-wake-word-threshold
kami:task/487-wake-word-stage-two
kami:task/671-mavwaked-registers-as-a-voice-consumer-i
kami:task/670-cut-claude-md-to-200-lines
kami:task/515-deploy-mavwaked-workpc
kami:task/669-prune-claude-md
kami:task/668-e4b-phrasing
kami:task/668-title-capital
kami:task/668-kiwix-answers-a-question-it-cannot-answe
kami:task/666-only-a-stage-0-grammar-may-take-the-pers
kami:task/487-mavwaked-has-no-wake-word-only-an-energy
kami:task/486-deploy-the-workstation-transcriber
kami:task/486-move-stt-and-tts-to-the-workstation-wher
kami:task/665-crisperwhisper-2-russian
kami:task/664-routing-heads-in-go
kami:task/662-usage-harness-source-badge
kami:task/661-post-merge-usage-rerun
kami:task/661-routing-heads-step-3-train-the-multi-hea
kami:task/660-router-prompt-destination
kami:task/659-destination-fixture
kami:task/655-query-source-is-a-routing-decision-made
kami:task/654-a-pending-clarify-has-no-way-out-neither
kami:task/654-week-of-usage-eval-docs
kami:task/649-needs-kami-telegram-is-the-only-reach-an
kami:task/643-memorystore-search-decodes-and-unmarshal
kami:task/641-two-maps-grow-for-the-process-lifetime-w
kami:task/644-mavcaldav-is-built-documented-as-running
kami:task/642-the-store-caps-sqlite-at-one-connection
kami:task/647-factenrichmentworker-walks-the-pending-q
kami:task/646-v-637-follow-up-telegram-intake-has-no-d
kami:task/638-no-deadline-survives-the-turn-path-from
kami:task/637-inbound-telegram-turns-and-corrections-f
kami:task/636-correcting-a-turn-from-telegram-and-from
kami:task/634-an-act-alias-resolves-the-verb-but-not-t
kami:task/630-one-gesture-correction-on-chat-v-628
kami:task/629-persist-the-routing-trace-and-record-it
kami:task/631-mode-inventory-written-from-the-handlers
kami:task/586-defaultfactparser-uses-hand-written-russ
kami:task/633-reconcile-the-seed-labels-with-the-handl
kami:task/627-reminder-verbs-has-no-alarm-verb-so-an-a
kami:task/626-the-classifier-seeds-teach-an-older-inte
kami:task/546-route-with-a-fine-tuned-e5-small-instead
kami:task/586-measure-the-fact-parser
kami:fix/gofmt-ecosystem-acts
kami:task/584-media-store-a-failed-write-leaks-its-bud
kami:task/518-no-write-path-for-a-backdated-event-so-t
kami:task/287-qa-voice-session-quality-polish
kami:task/492-qa-plan-reconcile
kami:task/530-sweep-tail-four-files-the-russian-sweep
kami:task/405-score-how-often-a-real-utterance-reaches
kami:task/529-money-and-list-pick-a-mechanism
kami:task/528-sweep-tail-the-three-files-on-467
kami:task/527-embedder-open-set-phrasings-stop-being-r
kami:task/526-morphology-a-dictionary-answers-the-gram
kami:task/525-lexicons-the-finite-russian-sets-move-to
kami:task/524-entity-reference-ask-nexus-about-every-l
kami:task/523-risk-tiers-take-hexis-s-tier-for-a-hexis
kami:task/521-review-pr-111-query-strings-declension-h
kami:task/491-llama-server-core-dumps-on-every-sigterm
kami:task/479-bug-an-unconfigured-capability-does-not
kami:task/467-bug-spoken-task-capture-is-dead-the-rout
kami:task/463-deploy-mavwaked-and-mavenclient-run-nowh
kami:task/480-hearing-no-shipped-client-can-start-a-re
kami:task/432-ambient-calendar-intake-is-fragile-and-p
kami:task/431-board-surface-maven-holds-the-work-board
kami:task/433-reactivehandler-has-30-fields-and-is-pas
kami:task/371-swap-the-embedder-for-an-asymmetric-retr
kami:task/408-review-31-07-split-the-30-method-coreapi
kami:task/410-review-31-07-hand-rolled-string-enums-st
kami:task/423-review-pr50-split-internal-ipc-server-go
kami:task/422-review-pr50-split-cmd-mavend-tick-go-860
kami:task/409-review-31-07-finish-moving-mavweb-markup
kami:task/482-ambient-ingest-reads-a-notification-s-ti
kami:task/444-kuma-a-fact-per-monitor-so-she-can-name
kami:task/452-capability-model-homelab-docker-restart
kami:task/449-destructive-confirm-policy-risk-tiers-no
kami:task/453-grocery-list-items-table-fourth-append-o
kami:task/399-run-the-persona-checks-inside-the-daemon
kami:task/448-bounded-follow-up-state-pending-candidat
kami:task/455-conversation-repair-name-the-misroute-co
kami:task/454-go-mod-tidy
kami:task/458-pronunciation-dictionary-for-piper
kami:task/456-command-history-read-only-query-over-exi
kami:task/457-clarification-templates-for-the-router-s
kami:task/474-query-source-ordering-feeds-and-calendar
kami:task/469-reminders-spelled-out-times-fail-the-bod
kami:task/475-bug-the-praxis-attention-capability-is-u
kami:task/481-bug-a-transient-complaint-is-stored-as-a
kami:task/476-bug-the-router-transliterates-latin-enti
kami:task/385-decide-whether-a-parked-clarify-question
kami:task/377-backfill-routines
kami:task/421-weather-geocoder
kami:task/390-no-read-path-for-delivery-attempts
kami:task/386-recall-fixture-filler-note-ids
kami:task/473-bug-morning-item-has-no-required-flag
kami:task/465-bug-make-simulate-routes-with-an-empty
kami:task/467-bug-spoken-task-capture-is-dead
kami:task/466-bug-a-pending-clarify-is-global-so-one-u
kami:task/468-bug-pattern-detect-has-no-minimum-interv
kami:task/462-bug-checkfeminine-flags-second-person-ma
kami:task/443-safekey-drops-cyrillic-so-russian-calend
kami:task/471-bug-agendaquerygrammars-covers-today-but
kami:task/383-slottext-in-clarify-answer-would-clobber
kami:task/323-qa-phraser-coverage-is-65-3-but-the-llam
kami:task/498-bug-and-x-reach-the-model-with-no-determ
kami:task/506-strings-family-6-summaries-and-reports-i
kami:task/504-strings-family-4-act-and-smart-home-repl
kami:task/503-strings-family-3-query-answers-and-gaps
kami:task/502-strings-family-2-capture-acknowledgement
kami:task/501-strings-family-1-phrasing-fallbacks-into
kami:task/397-phrasechat-and-phrasequery-hide-model-fa
kami:task/396-the-reply-path-can-t-be-tested-llmreplie
kami:task/496-recall-a-cross-language-question-loses-i
kami:task/495-bug-x-escapes-the-personal-boundary-and
kami:task/499-llama-server-holds-7-9gb-rss-for-a-1-1gb
kami:task/470-bug-a-question-writes-invented-knowledge
kami:task/493-bug-the-memory-index-stores-the-raw-utte
kami:task/490-name-the-gap-world-questions-through-the
kami:task/485-run-the-big-model-on-the-workstation-wit
kami:task/489-workstation-deploy-mavgpud-on-workpc-and
kami:task/488-workstation-a-supervisor-that-keeps-llam
kami:task/483-docs-offload-design
kami:task/483-design-offload-ml-to-the-workstation-kee
kami:task/459-docs-refresh-the-qa-plan-against-the-liv
kami:task/446-doc-reorg-tier-the-tree-retire-the-three
kami:fix/367-voice-parks-routine-accept
kami:task/365-dialogue-slots-and-router-slots-are-hand
kami:task/364-snooze-does-nothing-at-runtime-the-gate
kami:task/447-retire-progress-md-the-backlog-and-the-f
kami:task/445-session-workflow
kami:overnight/eco-versioned-traces
kami:overnight/eco-entity-refs
kami:overnight/eco-degraded-suite
kami:overnight/netscan
kami:overnight/smarthome
kami:overnight/replay-simulator
kami:overnight/event-envelope
kami:overnight/coldstart-unlock
kami:overnight/voice-barge-in
kami:overnight/stt-golden-audio
kami:overnight/senses-speaker
kami:overnight/senses-hearing
kami:overnight/senses-media-vision
kami:overnight/mcp-tools
kami:overnight/mcp-client
kami:overnight/self-update
kami:overnight/model-swap
kami:overnight/web-crawler
kami:overnight/rss-feeds
kami:overnight/email-poller
kami:overnight/email-extract
kami:overnight/email-imap
kami:overnight/task-priority
kami:overnight/task-capture
kami:overnight/behavior-profile
kami:overnight/day-plan
kami:overnight/ambient-calendar
kami:overnight/local-calendar
kami:overnight/memory-eval
kami:overnight/proactive-proposals
kami:overnight/split-voice-quiet
kami:overnight/nginx-maven-block
kami:overnight/stepup-chat-surface
kami:integration/small-batch
kami:docs/fix-drift
kami:fix/ru-wording
kami:integration/jul31
kami:overnight/resident-1.7b
kami:overnight/nudge-templates
kami:overnight/kiwix-rewrite
kami:overnight/eval-writeup
kami:overnight/fix-truncation
kami:overnight/kiwix-client
kami:overnight/ru-prompts
kami:overnight/external-data
kami:overnight/phrasing-grammar
kami:overnight/talk-eval
kami:overnight/prompt-context
kami:overnight/prompt-address
kami:overnight/eval-label-kill
kami:overnight/delivery-boundary
kami:overnight/address-check
kami:overnight/system-replies-pr
kami:overnight/clock-intent-pr
kami:overnight/embedder-backfill-pr
kami:overnight/embedder-marker-pr
kami:overnight/note-recall-pr
kami:overnight/thinking-off-pr
kami:overnight/dialogue-persist-pr
kami:overnight/persona-2p-pr
kami:overnight/clarify-expiry-pr
kami:overnight/clarify-rework
kami:overnight/phrasing
kami:overnight/bakeoff
kami:overnight/recall-margin
kami:overnight/router-on
kami:overnight/slot-extract
kami:overnight/embedder-e5
kami:overnight/router-refusal
kami:overnight/eval-rerun
kami:overnight/eval-harnesses
kami:overnight/eval-rerun-base
kami:overnight/fmt-gate
kami:overnight/routines-fire
kami:overnight/router-prompt
kami:overnight/away-leak
kami:overnight/recall-eval
kami:overnight/snooze-works
kami:overnight/clarify-wiring
kami:overnight/delivery-tests
kami:overnight/routine-accept
kami:overnight/llm-router-flag
kami:overnight/clarify-data-layer
kami:overnight/loop-rule-tests
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "overnight/money-zenmoney"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What changed
Money tracking as the task body specifies it: the poller holds the token, core
never sees it, transactions become
facts(kind='env', source='poll:zenmoney').internal/zenmoney(new, pure Go):Client.Since(ctx, from, to)POSTs/v8/diff/with a Bearer token and sums the transactions dated in the window,per currency. Excludes tombstoned rows (
deleted), transfers/exchanges (incomeand outcome both non-zero — moving his own money is not spending), and anything
outside the window. No currency conversion: amounts stay in their own currency's
short title, because a figure he can check against his bank beats one he cannot.
Summary.Value()refuses to encode a summary built from zero transactions.FormatRUrenders the reply;StaleAfter = 26h.cmd/mavpoll: new source alongside netdata/kuma/wg, on its own cadence(
-zenmoney-interval, default 1h).writeIfChangedRawkeeps the existingno-churn discipline for JSON-valued facts. Keys:
money_today,money_month.internal/router/money.go+cmd/mavend/actions_money.go: no new intent.ParseMoneyQuerymatches "сколько я потратил (сегодня|в этом месяце)?", "покажимои траты", "how much did I spend today", and refuses "я потратил весь день".
The
moneyquery source sits before the recall sources and answers only from thelatest fact — three outcomes, no fourth: the figure, a dated stale figure, or
«я не отслеживаю траты — не подключено.»
docker-compose.yml/.gitignore: the flags are documented andcommented OUT, plus the token mount, and
deploy/zenmoney.tokenis ignored.Why
the tick loop reads these keys, so a total is never a reason to speak. Maven is
not a nag, least of all about money.
psto every user on the box and ends up in the compose file and shell history.
Read once at start; a rotated token means a restart.
An empty or failed read writes nothing and leaves the last good total alone —
silence, not a zero, because a zero recited as fact is the worst failure here.
A stale fact is spoken with its date rather than as today's number.
utterances and no persona text, and has no path to the external search
capability. The only thing leaving the box is the diff request, to the service
that already has the data. The error path deliberately reports the HTTP status
only — a failed diff body can echo account data, and that string reaches the log.
Log lines name keys, never figures.
BLOCKED — live credentials
There is no zenmoney account or token in this environment, so the live half is
unverified: the real
/v8/diff/response shape beyond the fields decoded here,real multi-currency data, and rate limits. Everything is tested against a recorded
fixture (
internal/zenmoney/testdata/diff.json). To finish:deploy/zenmoney.token(gitignored, mode 0600).-zenmoney-token-file /run/secrets/zenmoney.tokento the mavpoll command.
docker compose logs mavpollshowsmoney_month updated (poll:zenmoney)and compare the figure against the zenmoney app for the same period.
One accepted caveat, documented at
Client.Since: the diff cursor is the windowstart, so a transaction he EDITED this month but DATED last month is fetched and
then excluded by date — editing old records cannot inflate the current total.
How verified
make buildexit 0,make testexit 0.internal/zenmoney/client_test.go— wire contract (POST/v8/diff/,Bearer,cursor = window start) against the recorded fixture; per-currency sums with
transfers/deleted/out-of-window rows excluded; largest-first ordering; an empty
window is not a zero and yields no fact value; a 401 error string does not echo
the response body;
New("")refuses; window helpers; fact value round-trip;persona and exact-figure checks on
FormatRU.cmd/mavpoll/main_test.go— two facts written withkind=env/source=poll:zenmoneyand decodable values; nothing written on an empty read; nothing written on a 401;
unchanged totals do not churn;
runstill refuses with nothing to poll.cmd/mavend/actions_money_test.go— right key per window, exact figure in thereply, «не подключено» when there is no fact (asserted to contain no digit at
all), stale facts dated, non-money questions passed through, and the query-source
order guarded against the recall sources.
Vikunja #125
The token boundary is the right one and it is drawn where the code can hold it. mavpoll reads the token from a file, so it never enters argv or compose. Core only ever calls
LatestFactBySource.internal/zenmoneyhas no import path to anything outbound except its own diff call.Summary.Value()refuses to encode a zero-transaction read. That is the correct shape for "silence, not a zero". No tick rule fires onmoney_today, so a total never makes her speak first.Three findings.
1. After midnight, yesterday's total is spoken as today's, and the staleness guard does not catch it.
pollZenmoneyskips the write when the window read is empty. That is right in isolation. But the day window rolls over at midnight, and the first spend of a new day may be hours away. Walk it:money_today= 1749.5 RUB,Ts= 01.08 22:00.Value()returns ok=false, nothing is written. The 01.08 fact is still the latestmoney_today.h.now().Sub(fact.Ts)is 11 hours, underStaleAfterof 26h, so no date prefix.сегодня ты потратил 1749.5 RUB.That is a confidently wrong number about his money, which is the thing the package comment says is the worst outcome. The empty-read rule protects against writing a zero and correctly declines to overwrite. It does not protect against reading back a fact whose window has expired. The fix belongs in the key or in the read. Date-stamp the key (
money_today:2026-08-02). Or store the window'sFrominsideFactValue, and havequeryMoneyrefuse a fact whoseFromis not today's midnight. The currentTscheck cannot express this, because the fact is fresh and still wrong.2. Staleness measures when the number last changed, not when it was last read.
writeIfChangedRawreturns early whenprev.Value == jsonVal, soTsonly advances when the total moves. Formoney_montha quiet stretch is normal. Say he spends nothing for 27 hours. The month fact is byte-identical and never rewritten.queryMoneythen prefixesданные от 30.07to a figure that is current and correct. The reverse of finding 1, same root cause:Tsis being asked to mean "as of", and it means "last changed". Carry a read timestamp inFactValue, or write unconditionally for these two keys and let the value dedupe go.3. The currency map depends on instruments arriving in the same delta, and nothing tests the case where they do not.
summarizebuildscurfromresp.Instrumentonly. The request setsserverTimestampto the window start, so the server returns objects changed at or after it. An instrument row is not guaranteed to be in that set. For a day window with a cursor of 00:00 it often will not be. Thencurrency()returns"?"for every id andFormatRUproducesсегодня ты потратил 1749.5 ?., which mavttsd then reads aloud.testdata/diff.jsonalways ships both instruments, so every test takes the happy path andcurrency's fallback branch is never asserted. Fetch instruments with a separate cursor-zero diff and cache them for the process lifetime. Failing that, drop an unknown-currency amount from the spoken string rather than labelling it?.Smaller notes.
ParseMoneyQueryreturnsMoneyMonthfor any unrecognised period, so "сколько я потратил вчера?" and "на прошлой неделе" both answer with the month-to-date total. Answering a different question than the one asked is worse here than declining. Recognise "вчера" and refuse the windows you do not store.тратыandрасходыare inmoneyNounsand also in theaskingset, so either one alone satisfies both halves of the gate. "у меня в этом месяце большие траты" is a statement, and it gets answered with a figure. The two lists are meant to be independent evidence.заработалis a money noun, but the key selection only ever picks a spending key. "сколько я заработал в этом месяце?" readsmoney_monthand answers with the spend line first, income second. The window parse is doing double duty as an income/outcome parse and only has one of them.isNoFactErrinactions_money.gohand-rolls the unwrap loop thaterrors.Isperforms, and mavpoll's ownisNoFacttwo files over already useserrors.Is. The hand-rolled version misses any error implementingIs(error) bool.pollZenmoneyruns straight intomoneyWindow's doc comment with no blank line. The type declaration therefore absorbs the function's whole comment block.Since's doc says a transaction dated in the window but last changed beforefrom"cannot exist". A planned transaction entered last month and dated this month is exactly that case. It goes silently missing from the total.Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.
Pull request closed