Compare commits

...

6 Commits

Author SHA1 Message Date
claude b60264701c CLAUDE.md: mavpoll is the env poller, not a Telegram reach (V-583)
Found by the capture and mavpoll sweep. The daemon table called mavpoll the
Telegram long-poll reach. It polls netdata, uptime-kuma, zenmoney and wireguard,
writes facts and sends nothing. Telegram is internal/delivery/telegramsink.

An agent reading that table would have gone to the wrong file to change how
Telegram behaves.

--no-verify: the pre-commit hook refuses master, and this unattended run merges
to master by the owner's instruction.
2026-08-06 01:55:42 +04:00
claude 60540fa934 Merge the capture and mavpoll sweep (#224)
V-581. Session.discard replaces the finish-unlink-remove block that reapLocked
and Abort each spelled out. One abandon closure covers the two Start failure
paths that close and remove the spool. The third closes without removing, and
that was left exactly as it was rather than silently changing what it deletes.

bytesPerSample and bytesPerSecond replace three copies of the byte-rate
arithmetic. The two spellings differed in where the divide by 8 fell, which only
matters for a sample width that is not a multiple of 8 bits. Every format in the
package is 16-bit, so this is arithmetically identical.

In mavpoll, four writers each built their own WriteFactReq literal and three
repeated a redundant error check, where isNoFact is errors.Is and already
covered the equality arm. unchanged and writeFact hold that now, so each writer
keeps only its log line. run lost the zenmoney setup and the ticker, so it is
flag parsing and wiring.

Two stale comments corrected. The package doc said two sources, listed three,
and polls four.

Filed V-583: CLAUDE.md's daemon table calls mavpoll the Telegram long-poll
reach. It is the env poller. Telegram is internal/delivery/telegramsink.
2026-08-06 01:55:18 +04:00
claude db8cbdc20a sweep capture and mavpoll for duplication and magic values (V-581)
capture: one Session.discard for the reaper and Abort, which both closed
the spool and deleted it by hand. One abandon closure for the two failed
Start paths. bytesPerSample and bytesPerSecond replace three copies of
the byte-rate arithmetic.

mavpoll: unchanged and writeFact hold the read-compare and the row shape,
so the three fact writers keep only what differs between them, their log
line. The wg key and source are named constants. Named coreDialWait,
zenTimeoutFactor, maxBodyBytes and factConfidence. run lost the zenmoney
setup to newZenClient and the ticker to loop.

Comments only where they were stale: the package doc counted two sources
and there are four.
2026-08-06 01:54:38 +04:00
claude 91f6ea84a0 Merge the query chain sweep (#223)
V-581. Two comments in querySources sat above the wrong entries, so the money
argument read against the list source and the shopping argument read second. No
entry moved, only the paragraphs.

recallOnTopic holds the topic veto that queryMemory and queryNotes each called,
logging the same rejection in two spellings. Six timeouts and widths got names
beside their source, matching kiwixTimeout and searchTimeout above them.
readBack holds the read-the-evidence fallback that querySearch and queryKiwix
share. queryWeb keeps its own, because its phrase key differs.

Order, the boundary position, Response.Empty as the whole gate and every
claim-or-pass condition are untouched.

The agent listed four differences between sources it could not justify rather
than unifying them silently, which is the right call. Two are now V-582.
2026-08-06 01:52:42 +04:00
claude b726658692 query chain: name the magic values, one topic veto (V-581)
The two recall sources ran the same topic veto with the same log line
spelled two ways. It is one helper now.

Timeouts and read-back budgets that were literals get names beside the
source that spends them. The money and list comments in querySources sat
above the wrong entries, so the ordering argument read backwards.

No source moved, no gate changed.
2026-08-06 01:52:08 +04:00
claude 5f5f14eba7 mavweb: /events gets its page chrome (V-578)
Found by the mavweb sweep. The key was in the sidebar and absent from the chrome
table, so the tab read lowercase and the icon fell through to the default while
the link said Intake. Title now matches the link, and i-download is the closest
symbol ethos-icons.svg has to an inbox.

--no-verify: the pre-commit hook refuses master, and this unattended run merges
to master by the owner's instruction.
2026-08-06 01:51:25 +04:00
5 changed files with 209 additions and 107 deletions
+1 -1
View File
@@ -79,7 +79,7 @@ Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test
| `mavttsd` | Text-to-speech (piper subprocess). |
| `mavwaked` | Wake-word / VAD gate. **Not on homesrv** — see below. |
| `mavenclient` | Voice loop client (mic → stt → core → tts). **Not on homesrv** — see below. |
| `mavpoll` | Telegram long-poll reach. |
| `mavpoll` | Environment poller: netdata alarms, uptime-kuma, zenmoney, wireguard presence. Writes facts, sends nothing. Telegram is `internal/delivery/telegramsink`, not this. |
| `mavcaldav` | CalDAV calendar sync. |
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. |
+63 -22
View File
@@ -88,15 +88,15 @@ var querySources = []querySource{
// through every source to the web search (Vikunja #475). Its matcher needs
// an attention marker, and it falls through when Praxis is not configured.
{name: "attention", answer: (*reactiveHandler).queryAttention},
// Before the recall sources too: "сколько я потратил?" is a question about
// the money facts the poller wrote, and the notes pass would otherwise
// answer it from whatever he once said about spending. Its matcher needs a
// money noun plus an actual ask, so "я потратил весь день" is untouched.
// Next to "tasks" and for the same reason: "что мне купить?" is a question
// about the shopping list, and the recall pass would otherwise answer it
// from an old note about the shop. Its matcher needs an explicit list
// marker, so "надо бы съездить в магазин" is untouched.
{name: "list", answer: (*reactiveHandler).queryList},
// Before the recall sources too: "сколько я потратил?" is a question about
// the money facts the poller wrote, and the notes pass would otherwise
// answer it from whatever he once said about spending. Its matcher needs a
// money noun plus an actual ask, so "я потратил весь день" is untouched.
{name: "money", answer: (*reactiveHandler).queryMoney},
// Also above the recall sources: "что я тебе говорил?" is a question about
// the facts he tapped in, and the notes pass would answer it with whatever
@@ -428,6 +428,10 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
return f.FormatEntries(entries, date), true
}
// homeTimeout — the whole house read. Longer than the weather call because the
// hub is polled over the LAN and answers for every device at once.
const homeTimeout = 10 * time.Second
// queryHome answers a question about the house. Read-only by construction: it
// calls States and nothing else, so there is no confirm turn here — the only
// way to CHANGE something is an enabled allowlist row through tool.Executor.
@@ -444,7 +448,7 @@ func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string,
// unreachable case is different and homeSummary covers it.
return "", false
}
ctxH, cancel := context.WithTimeout(ctx, 10*time.Second)
ctxH, cancel := context.WithTimeout(ctx, homeTimeout)
defer cancel()
return h.home.homeSummary(ctxH)
}
@@ -468,6 +472,10 @@ func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (strin
return h.netscan.scanSummary(ctx)
}
// weatherTimeout — one geocode plus one forecast read. He asked a question with
// a one-line answer, so a provider that is slower than this is a failure.
const weatherTimeout = 5 * time.Second
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
if !h.turnIsAbout(ctx, t, topicWeather, isWeatherQuery) {
return "", false
@@ -478,7 +486,7 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
// so is the only honest answer; picking a city would be inventing one.
return phraser.Q(phraser.QueryWeatherWhere, nil), true
}
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
ctxWT, cancel := context.WithTimeout(ctx, weatherTimeout)
defer cancel()
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
if errors.Is(err, weather.ErrNotConfigured) {
@@ -529,6 +537,27 @@ func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string,
return "", false
}
// memoryRecallWidth and noteRecallWidth — how many candidates each recall pass
// pulls before the gate reads them. Both are small on purpose: the gate wants a
// best hit and its runner-up, and every further row is a margin the top match
// has to beat.
const (
memoryRecallWidth = 3
noteRecallWidth = 5
)
// recallOnTopic — the topic veto both recall sources apply after the score gate
// (#470). A memory about his slow network scored high enough to answer "почему
// небо синее?", because the right-note and must-be-silent score ranges overlap
// and no threshold sits between them.
func recallOnTopic(utterance, text string) bool {
if memory.RecallAllowed(utterance, text) {
return true
}
log.Printf("voice: recall %q rejected for %q: a world question and no shared topic word", text, utterance)
return false
}
// queryMemory — long-term memory first: ONE search over everything Maven
// remembers (notes and facts share this index) and ONE confidence gate, so
// the memory that is clearly the best match answers — a note just as much as
@@ -549,7 +578,7 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string
// a "there is nothing" answer — pass rather than gate the chain.
return "", false
}
hits, herr := h.recall.memStore.Search(ctx, t.vec, 3)
hits, herr := h.recall.memStore.Search(ctx, t.vec, memoryRecallWidth)
if herr != nil {
log.Printf("voice: memory search: %v", herr)
return "", false
@@ -559,12 +588,8 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string
return "", false
}
text := hit.Meta["text"]
// The score cleared the gate and the topic still has to match (#470). A
// note about his slow network scored high enough to answer "почему небо
// синее?", because the right-note and must-be-silent score ranges overlap
// and no threshold sits between them.
if !memory.RecallAllowed(t.dec.Utterance, text) {
log.Printf("voice: recall %q rejected for %q: a world question and no shared topic word", text, t.dec.Utterance)
// The score cleared the gate and the topic still has to match.
if !recallOnTopic(t.dec.Utterance, text) {
return "", false
}
// A note is phrased in Maven's voice; a fact is read back as it was
@@ -600,7 +625,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
// source could not look, and could-not-look passes.
return "", false
}
notes, err := h.api.QueryNotes(ctx, t.vec, 5)
notes, err := h.api.QueryNotes(ctx, t.vec, noteRecallWidth)
if err != nil {
// The store failed, so this source could not look either. It used to
// claim here, which stopped the search, the ZIMs and the model from
@@ -616,10 +641,9 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
if !memory.ConfidentScores(noteScores, h.recall.minScore, h.recall.minMargin) {
return "", false
}
// Same topic veto as queryMemory above: the best note must be about what
// he asked, not merely the nearest vector in the index.
if !memory.RecallAllowed(t.dec.Utterance, notes[0].Text) {
log.Printf("voice: note %q rejected for %q: a world question and no shared topic word", notes[0].Text, t.dec.Utterance)
// The best note must be about what he asked, not merely the nearest vector
// in the index.
if !recallOnTopic(t.dec.Utterance, notes[0].Text) {
return "", false
}
texts := make([]string, len(notes))
@@ -641,6 +665,23 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
// prompt, the persona block and the reply.
const webPageContextRunes = 1500
// webFetchTimeout — the whole named-page source. Longer than the other outside
// sources because he named this page himself, so waiting for it is what he asked
// for, and there is nothing below that can answer instead.
const webFetchTimeout = 30 * time.Second
// readBackRunes — how much of the evidence is read out when the phraser gave
// nothing back. It is spoken aloud, so it is a couple of sentences and not a
// page.
const readBackRunes = 300
// readBack — what an outside source says when the phraser gave nothing back.
// The evidence is read out plainly rather than dropped, because the fetch did
// happen and its result is a better answer than silence.
func readBack(evidence string) string {
return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(evidence, readBackRunes)})
}
// queryWeb — "посмотри https://example.org/x — что там?" (Vikunja #259).
//
// It claims a turn ONLY when he named a URL, which is what keeps a fallback from
@@ -659,7 +700,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
// guess dressed as an answer (Vikunja #479).
return phraser.Q(phraser.QueryPageOff, nil), true
}
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
ctxFetch, cancel := context.WithTimeout(ctx, webFetchTimeout)
defer cancel()
page, err := h.crawler.Page(ctxFetch, link)
if err != nil {
@@ -680,7 +721,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
if reply == "" {
// No phraser (or it failed): read back the top of the page rather than
// pretend the fetch did not happen.
return phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, 300)}), true
return phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, readBackRunes)}), true
}
return reply, true
}
@@ -746,7 +787,7 @@ func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string
if reply == "" {
// No phraser, or it failed. Read back the best evidence rather than
// pretend the search did not happen.
return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(resp.Snippets()[0], 300)}), true
return readBack(resp.Snippets()[0]), true
}
return reply, true
}
@@ -835,7 +876,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
if reply == "" {
// No phraser, or it failed. Read back the best hit rather than pretend
// the search did not happen.
return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(top.Title+" — "+page.Text, 300)}), true
return readBack(top.Title + " — " + page.Text), true
}
return reply, true
}
+106 -58
View File
@@ -6,10 +6,11 @@
// restart-free, fail-independent — a crashing poller can't touch the store key
// (it never had it), worst case a stale env fact until the next tick.
//
// Two sources, each its own provenance (the loop's rules trust source):
// Four sources, each its own provenance (the loop's rules trust source):
// - netdata → poll:netdata resource alarms (disk/mem/cert/temp)
// - kuma → poll:uptimekuma service up/down (the source of truth for it)
// - zenmoney → poll:zenmoney spending/income totals (Vikunja #125)
// - wireguard → infer:wg latest handshake, the presence signal
//
// The zenmoney source is why the token lives HERE and not in core: the poller
// already owns every other third-party credential, it holds no store key, and
@@ -77,26 +78,15 @@ func run(args []string) error {
return fmt.Errorf("nothing to poll: set -netdata, -kuma, -wg and/or -zenmoney-token-file")
}
// The token is read from a file, never taken as a flag value: an argv token
// is visible in `ps` to every user on the box and lands in the compose file
// and the shell history. Read once at start — a rotated token means a
// restart, which is cheaper than re-reading his credential every hour.
var zen *zenmoney.Client
if *zenTokenFile != "" {
raw, err := os.ReadFile(*zenTokenFile)
if err != nil {
return fmt.Errorf("read zenmoney token: %w", err)
}
zen, err = zenmoney.New(strings.TrimSpace(string(raw)), *zenURL, *timeout*3)
if err != nil {
return err
}
zen, err := newZenClient(*zenTokenFile, *zenURL, *timeout)
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
core, err := ipc.DialWait(*socket, 60*time.Second)
core, err := ipc.DialWait(*socket, coreDialWait)
if err != nil {
return err
}
@@ -117,14 +107,46 @@ func run(args []string) error {
// The token is never logged, not even its length.
log.Printf("mavpoll: polling every %s (netdata=%q kuma=%q wg=%q zenmoney=%v every %s)",
*interval, *netdataURL, *kumaURL, *wgIface, zen != nil, *zenInterval)
p.loop(ctx, *interval)
return nil
}
// coreDialWait — how long to wait for core's socket at start. The poller and
// core come up together under compose, so a cold start is a wait, not a failure.
const coreDialWait = 60 * time.Second
// zenTimeoutFactor — the zenmoney client gets a longer deadline than the other
// sources. A diff call walks his whole transaction history, where netdata and
// kuma answer from memory.
const zenTimeoutFactor = 3
// newZenClient builds the money client, or nil when no token file was given.
//
// The token is read from a file, never taken as a flag value: an argv token is
// visible in `ps` to every user on the box and lands in the compose file and
// the shell history. Read once at start — a rotated token means a restart,
// which is cheaper than re-reading his credential every hour.
func newZenClient(tokenFile, baseURL string, timeout time.Duration) (*zenmoney.Client, error) {
if tokenFile == "" {
return nil, nil
}
raw, err := os.ReadFile(tokenFile)
if err != nil {
return nil, fmt.Errorf("read zenmoney token: %w", err)
}
return zenmoney.New(strings.TrimSpace(string(raw)), baseURL, timeout*zenTimeoutFactor)
}
// loop polls until the context is cancelled.
func (p *poller) loop(ctx context.Context, interval time.Duration) {
p.pollOnce(ctx) // fire immediately; don't idle a full interval on start
t := time.NewTicker(*interval)
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
log.Printf("mavpoll: bye")
return nil
return
case <-t.C:
p.pollOnce(ctx)
}
@@ -151,8 +173,8 @@ type poller struct {
zenLast time.Time
}
// pollOnce — one sweep of both sources. A failure in one source logs and does
// NOT abort the other: netdata being down shouldn't blind kuma and vice versa.
// pollOnce — one sweep of every configured source. A failure in one logs and
// does NOT abort the rest: netdata being down shouldn't blind kuma.
func (p *poller) pollOnce(ctx context.Context) {
now := time.Now()
if p.netdataURL != "" {
@@ -244,6 +266,14 @@ func (p *poller) pollZenmoney(ctx context.Context, now time.Time) error {
// ---- wireguard: latest handshake → presence signal -------------------------
const (
// wgFactKey / wgSource — the presence signal, read by the decay in core.
// The source says infer because a handshake is evidence he is home, not a
// reading of where he is.
wgFactKey = "wg_handshake"
wgSource = "infer:wg"
)
// pollWg reads `wg show <iface> latest-handshakes` and writes a wg_handshake
// fact (source=infer:wg) stamped with the MOST RECENT peer handshake time — not
// now(). Presence decays from the real handshake instant, so the fact's ts must
@@ -263,20 +293,19 @@ func (p *poller) pollWg(ctx context.Context) error {
return nil // no peer has ever handshaked → drop out of presence
}
hs := time.Unix(maxTs, 0)
prev, err := p.core.LatestFactBySource(ctx, "wg_handshake", "infer:wg")
prev, err := p.core.LatestFactBySource(ctx, wgFactKey, wgSource)
if err == nil && !hs.After(prev.Ts) {
return nil // not newer → no churn
}
if err != nil && err != ipc.ErrNoFact && !isNoFact(err) {
return fmt.Errorf("read wg_handshake: %w", err)
if err != nil && !isNoFact(err) {
return fmt.Errorf("read %s: %w", wgFactKey, err)
}
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
Ts: hs, Kind: "env", Key: "wg_handshake", Value: `"up"`,
Source: "infer:wg", Confidence: 1.0,
}); err != nil {
return fmt.Errorf("write wg_handshake: %w", err)
// The ts is the handshake instant, not now(): presence decays from when he
// was last seen.
if err := p.writeFact(ctx, wgFactKey, wgSource, `"up"`, hs); err != nil {
return err
}
log.Printf("mavpoll: wg_handshake @ %s (infer:wg)", hs.Format(time.RFC3339))
log.Printf("mavpoll: %s @ %s (%s)", wgFactKey, hs.Format(time.RFC3339), wgSource)
return nil
}
@@ -431,29 +460,53 @@ func kumaState(v float64) string {
// ---- helpers ---------------------------------------------------------------
// writeIfChanged writes a `facts(kind=env)` row only when val differs from the
// latest fact for (key, source). Values are stored JSON-encoded (the store's
// convention: `"down"`, `"critical"`), matching how rules compare f.Value.
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, now time.Time) error {
jv, _ := json.Marshal(val) // string never fails to marshal
// factConfidence — every poll is a direct reading of another service, never an
// inference, so the fact goes in at full confidence.
const factConfidence = 1.0
// unchanged reports whether the latest fact for (key, source) already holds
// jsonVal. A missing fact is not an error here, it is the first write.
func (p *poller) unchanged(ctx context.Context, key, source, jsonVal string) (bool, error) {
prev, err := p.core.LatestFactBySource(ctx, key, source)
switch {
case err == nil && prev.Value == string(jv):
return nil // unchanged → no churn
case err != nil && err != ipc.ErrNoFact && !isNoFact(err):
return fmt.Errorf("read %s: %w", key, err)
case err == nil:
return prev.Value == jsonVal, nil
case isNoFact(err):
return false, nil
default:
return false, fmt.Errorf("read %s: %w", key, err)
}
_, err = p.core.WriteFact(ctx, ipc.WriteFactReq{
}
// writeFact writes one `facts(kind=env)` row. Every poll in this file lands
// here, so the row shape is written once.
func (p *poller) writeFact(ctx context.Context, key, source, jsonVal string, now time.Time) error {
_, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
Ts: now,
Kind: "env",
Key: key,
Value: string(jv),
Value: jsonVal,
Source: source,
Confidence: 1.0, // a direct reading, not an inference
Confidence: factConfidence,
})
if err != nil {
return fmt.Errorf("write %s: %w", key, err)
}
return nil
}
// writeIfChanged writes only when val differs from the latest fact for
// (key, source). Values are stored JSON-encoded (the store's convention:
// `"down"`, `"critical"`), matching how rules compare f.Value.
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, now time.Time) error {
jv, _ := json.Marshal(val) // a string never fails to marshal
same, err := p.unchanged(ctx, key, source, string(jv))
if err != nil || same {
return err // unchanged → no churn
}
if err := p.writeFact(ctx, key, source, string(jv), now); err != nil {
return err
}
log.Printf("mavpoll: %s=%s (%s)", key, val, source)
return nil
}
@@ -466,18 +519,12 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, no
// The log line names the key and the source, never the figures: mavpoll's log
// is not the place his spending ends up.
func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal string, now time.Time) error {
prev, err := p.core.LatestFactBySource(ctx, key, source)
switch {
case err == nil && prev.Value == jsonVal:
return nil
case err != nil && err != ipc.ErrNoFact && !isNoFact(err):
return fmt.Errorf("read %s: %w", key, err)
same, err := p.unchanged(ctx, key, source, jsonVal)
if err != nil || same {
return err
}
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
Ts: now, Kind: "env", Key: key, Value: jsonVal,
Source: source, Confidence: 1.0,
}); err != nil {
return fmt.Errorf("write %s: %w", key, err)
if err := p.writeFact(ctx, key, source, jsonVal, now); err != nil {
return err
}
log.Printf("mavpoll: %s updated (%s)", key, source)
return nil
@@ -490,11 +537,8 @@ func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal str
// The log line names the key only, never the figures: mavpoll's log is not the
// place his spending ends up.
func (p *poller) writeMoneyFact(ctx context.Context, key, jsonVal string, now time.Time) error {
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
Ts: now, Kind: "env", Key: key, Value: jsonVal,
Source: zenmoney.Source, Confidence: 1.0,
}); err != nil {
return fmt.Errorf("write %s: %w", key, err)
if err := p.writeFact(ctx, key, zenmoney.Source, jsonVal, now); err != nil {
return err
}
log.Printf("mavpoll: %s read (%s)", key, zenmoney.Source)
return nil
@@ -506,6 +550,10 @@ func isNoFact(err error) bool {
return errors.Is(err, ipc.ErrNoFact)
}
// maxBodyBytes caps what a source can make the poller hold. Kuma's whole
// metrics page is a few hundred kilobytes, so 4 MiB is slack, not a budget.
const maxBodyBytes = 4 << 20
func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
@@ -519,7 +567,7 @@ func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error)
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
if err != nil {
return nil, err
}
+1
View File
@@ -90,6 +90,7 @@ var pageChrome = map[string]struct{ Title, Icon string }{
"reminders": {"Reminders", "i-calendar"},
"routines": {"Routines", "i-repeat"},
"morning": {"Morning Routines", "i-calendar"},
"events": {"Intake", "i-download"},
"chat": {"Chat", "i-message"},
"voice": {"Voice", "i-mic"},
"ecosystem": {"Ecosystem", "i-grid"},
+38 -26
View File
@@ -161,6 +161,18 @@ func (s *Session) finish() error {
return s.spool.Sync()
}
// discard closes the spool and deletes it, leaving nothing behind. Used by the
// reaper and by Abort, which throw a recording away rather than harvest it.
func (s *Session) discard() {
s.mu.Lock()
_ = s.finish()
path := s.path
s.mu.Unlock()
if path != "" {
_ = os.Remove(path)
}
}
// Duration is how much audio has been collected, from the bytes rather than the
// wall clock: a stream that dropped frames should report the audio that exists,
// not the time that passed.
@@ -174,9 +186,16 @@ func (s *Session) duration() time.Duration {
return pcmDuration(s.format, s.n)
}
// bytesPerSample is one sample across all channels. Cutting a buffer anywhere
// that is not a multiple of it shifts every following sample by a byte.
func bytesPerSample(f audio.Format) int64 { return int64(f.SampleBits / 8 * f.Channels) }
// bytesPerSecond is the format's byte rate, 32000 for the canonical 16 kHz mono.
func bytesPerSecond(f audio.Format) int64 { return int64(f.SampleRate) * bytesPerSample(f) }
// pcmDuration is how long n bytes of PCM lasts in the given format.
func pcmDuration(f audio.Format, n int64) time.Duration {
per := int64(f.SampleRate) * int64(f.Channels) * int64(f.SampleBits) / 8
per := bytesPerSecond(f)
if per <= 0 {
return 0
}
@@ -279,19 +298,21 @@ func (r *Recorder) Start(label string) (*Session, error) {
f.Close()
return nil, err
}
// A session that never opened leaves no spool file behind.
abandon := func(err error) (*Session, error) {
f.Close()
_ = os.Remove(f.Name())
return nil, err
}
// The header is written first and rewritten at Stop with the real length,
// so the spool file is a playable WAV rather than headerless PCM that has
// to be copied to gain 44 bytes.
if _, err := f.Write(hdr); err != nil {
f.Close()
_ = os.Remove(f.Name())
return nil, fmt.Errorf("capture: spool header: %w", err)
return abandon(fmt.Errorf("capture: spool header: %w", err))
}
token, err := newToken()
if err != nil {
f.Close()
_ = os.Remove(f.Name())
return nil, err
return abandon(err)
}
s := &Session{
Label: strings.TrimSpace(label),
@@ -330,15 +351,11 @@ func (r *Recorder) reapLocked() {
}
s.mu.Lock()
s.expired = true
_ = s.finish()
path := s.path
s.mu.Unlock()
if path != "" {
// The audio goes with it. A recording nobody stopped is one nobody is
// waiting for, and keeping it would mean storing a meeting on the
// strength of a dropped connection.
_ = os.Remove(path)
}
// The audio goes with it. A recording nobody stopped is one nobody is
// waiting for, and keeping it would mean storing a meeting on the strength
// of a dropped connection.
s.discard()
r.current = nil
}
@@ -536,13 +553,7 @@ func (r *Recorder) Abort(token string) bool {
return false
}
r.current = nil
s.mu.Lock()
_ = s.finish()
path := s.path
s.mu.Unlock()
if path != "" {
_ = os.Remove(path)
}
s.discard()
return true
}
@@ -576,7 +587,7 @@ func (r *Recorder) transcribeFile(ctx context.Context, path string, format audio
}
// Never cut mid-sample: a split inside an int16 shifts every following
// sample by a byte and turns the tail of the window into noise.
if bps := int64(format.SampleBits / 8 * format.Channels); bps > 0 {
if bps := bytesPerSample(format); bps > 0 {
size -= size % bps
}
if size <= 0 {
@@ -608,12 +619,13 @@ func (r *Recorder) transcribeFile(ctx context.Context, path string, format audio
// is read by him in a note next to the words around it.
const gapMarker = "[…не разобрала…]"
// windowBytes is how many PCM bytes one STT window holds.
// windowBytes is how many PCM bytes one STT window holds, rounded down to a
// whole sample.
func windowBytes(f audio.Format, window time.Duration) int64 {
bps := int64(f.SampleBits / 8 * f.Channels)
bps := bytesPerSample(f)
if bps <= 0 || f.SampleRate <= 0 || window <= 0 {
return 0
}
per := int64(window.Seconds()) * int64(f.SampleRate) * bps
per := int64(window.Seconds()) * bytesPerSecond(f)
return per - per%bps
}