Compare commits

..

11 Commits

Author SHA1 Message Date
claude 0793955896 web: seed a backdated event from the routines page (V-518)
A "seed" action on the existing POST /routines, taking key, value and
ago-in-hours. That route is already step-up gated and already the place a
proposed routine is accepted or dismissed, so seeding lands next to the thing
it produces. No new page and no second gated surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-05 01:10:25 +04:00
claude 71a9a59403 seed: mavend implements it, off unless -allow-seed (V-518)
SeedEvent writes the fact at the caller's timestamp, extracts an event from
it, and runs the same detectAndPropose the voice path runs. What a seed proves
is therefore the daemon's own wiring, not the detector in isolation — which is
what an eval-lab fixture would have proved, and is not what the four blocked
tasks doubt.

The flag is the real lock, not the authority rung. -allow-seed defaults off,
and off means daemonAPI.seedStore is nil: the method has nothing to write with
rather than permission to refuse. A box that can rewrite its own past says so
in its boot log.

Seeded facts carry source "seed:qa" and no Subject, so they never queue a
Nexus resolution and stay identifiable for the wipe in V-494. Nothing else in
the tree writes that source.

Best-effort is not the shape here, unlike detectPattern: a seed that half
worked is a QA result nobody can trust, so every step reports its own failure.
Extraction declining is not a failure, and Extracted says so.

Tests cover all four: refused with no flag, four spaced seeds propose and
three do not, a value outside the lexicon writes the fact and claims no event,
a zero timestamp is refused rather than defaulted to now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-05 01:10:17 +04:00
claude d3c63e6493 ipc: a seed_event method, step-up gated, refused by the store (V-518)
The pattern detector needs four events for one action+object spread by at
least two hours before it proposes a routine. The only writer in the tree is
a fact write at time.Now(), so V-43, V-46, V-247 and V-254 all stopped at the
same missing step. This is the wire half of the seam that unblocks them.

The request takes a fact — key, value, timestamp — not an event, so
pattern.Extract runs for real on the daemon side and a key the extractor
ignores seeds nothing. The response says which of those happened, because a
caller that assumed a seed always yields an event would read four silent
successes as a broken detector.

AuthStepUp, the same rung as mutating the tool allowlist, and not because
backdating is privileged in the usual sense: every other write records when
something happened and this one asserts it. StoreAPI refuses outright — the
method needs the daemon's detect-and-propose step, and a direct store caller
would write a fact and quietly skip it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-05 01:10:06 +04:00
kami c586346a60 Merge pull request 'QA: Voice session quality polish' (#171) from task/287-qa-voice-session-quality-polish into master 2026-08-04 21:26:01 +02:00
claude 23d89b2831 plural service_down nudges agree with the count (V-534)
Two services down read "Мониторинг сообщает: nginx, paperless лежит." — a list
dropped into the singular sentence. Russian agrees the verb with the subject,
so the noun, the verb and the adjective all have to move.

A family may now carry a second set named <rule>_many, used when {service}
holds more than one name. pluralFamily picks it; a family with no _many set is
returned unchanged, so adding one elsewhere is a data change. Only service_down
has one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-04 23:17:30 +04:00
claude 06ddf41228 service_down nudges name the service again (V-534)
nudgeValues filled {service} from State.Fact("service_down"), an exact key
mavpoll stopped writing when per-monitor facts landed. The lookup could never
hit, so every variant carrying {service} was rejected as unfillable and the one
nameless variant was the only usable template, every time. A sev4 reaching him
on telegram said only that a service was down.

It now reads loop.DownServices, the same helper the rule fires on, so the
message cannot name a service that is up. Dropped the nameless variant and the
{since} one: service_down facts are keyed by monitor and the rule is
edge-triggered, so neither can fill. service_down joins routine and morning as
a family that always carries a name.

The tests passed through all of this because cand() built the pre-per-monitor
aggregate shape. downCand() builds what a tick actually produces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-04 23:13:59 +04:00
claude 2e64c8ce94 qa plan: step 2 passes headless, and the sev4 that names nothing (V-287)
Chrome takes a fake microphone, so the browser half of push-to-talk runs
without a person. getUserMedia, MediaRecorder, the webm decode and the
resample all pass. The button is at /, not /dash, which this step had wrong.
The on-screen transcript shows + for every space: QueryEscape decoded with
decodeURIComponent. Filed as 533.

A real sev4 reached telegram with presence away. It named no service, which
is 534: nudgeValues fills {service} from an exact key mavpoll stopped writing
when per-monitor facts landed, so every named variant is rejected as
unfillable and the one nameless variant always wins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-04 23:07:23 +04:00
claude 7695620a96 qa plan: presence arrives, and the state row that never gets written (V-287)
The desk_active poster is live on workpc, so 15 no longer blocks session 1
steps 7 and 8. What blocks them is that no rule's predicate is true: water
needs 3h since the fact step 2 just wrote, meal and break have no anchor.

Separately, SavePresenceState has no caller outside tests. The gate reads the
in-memory bucket so delivery is unaffected, but hysteresis never engages and
every presence readout shows away at score 0.00. Filed as 532.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-04 22:50:47 +04:00
claude 758fb6a3f0 qa plan: the 30s turn is unbounded whitespace in the grammar, not reasoning (V-287)
Corrects the cause recorded an hour ago. responseGrammar ends with
ws ::= [ \t\n]*, and * is unbounded, so the model emits { and then satisfies
ws with whitespace until max_tokens stops it.

Reproduced on a second Qwen3-1.7B with the same grammar and system prompt:
repeat_penalty 1.0 runs to 512 and returns finish_reason=length, 1.3 stops at
24, and bounding the rule to {0,4} stops at 33 three times out of three with
no penalty at all.

internal/llm.Req sends repeat_penalty and the replier sets 1.3, so that path
is protected by accident. chatReq in the phraser sends none, so PhraseChat,
PhraseQuery, PhraseNudge and PhraseReminder run at the default 1.0.

Two wrong guesses recorded so nobody repeats them: not reasoning tokens, the
probe returned reasoning_content of length 0; and not --cache-ram 512, which
is MiB of prompt cache against a token count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-04 22:35:05 +04:00
claude 0e75245205 qa plan: push-to-talk runs without a mic, and a spoken turn is 30s of reasoning (V-287)
Session 1 step 2 no longer needs a person. POST /api/ptt takes raw PCM16
16kHz mono, so the committed STT fixtures stand in for a microphone. Three
fixtures pass end to end: 200, real speech back, right intent.

Step 9 gets a cause. A spoken turn is 32-34s, of which one phrasing call is
30.0s. Both interactive calls decoded exactly 512 tokens, the chat cap, and
were truncated. The resident model is a Thinking variant and llamaArgs never
passes the enable_thinking:false that deploy/mavgpud.json passes for the
workstation. Filed as V-531.

Steps 7 and 8 cannot run. The morning routine is the only nudge source and the
dispatcher drops it on presence=away every time, which is V-15.

287's own ten QA steps were rewritten in Vikunja: all ten were mavwaked, which
does not run on homesrv by decision (V-463).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-04 22:25:28 +04:00
claude 8d816f47e9 Merge the QA plan reconcile (#170) 2026-08-04 20:02:17 +02:00
18 changed files with 660 additions and 38 deletions
+4
View File
@@ -127,8 +127,10 @@ func run(args []string) error {
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)")
reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap; the daemon does not answer until it finishes)")
allowSeed := flag.Bool("allow-seed", false, "enable the backdated seed_event write path (QA only: it lets a caller place a fact in the past and mint a routine the tick loop will then act on; off means the method has nothing to write with)")
flag.CommandLine.Parse(args)
reembedOnStart = *reembed
allowSeedOnStart = *allowSeed
cfg, err := config.Load(*cfgPath)
if err != nil {
return err
@@ -338,6 +340,7 @@ func run(args []string) error {
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
getEvents: intakeEventsFn(evBus),
seedStore: seedStoreIfAllowed(st),
}
if voiceW != nil && voiceW.handler != nil {
api := coreAPI.(*daemonAPI)
@@ -605,6 +608,7 @@ func run(args []string) error {
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
getEvents: intakeEventsFn(evBus),
seedStore: seedStoreIfAllowed(st),
}
if voiceW != nil && voiceW.handler != nil {
newAPI.chatFn = voiceW.handler.handleText
+107
View File
@@ -0,0 +1,107 @@
// mavend/seed.go — the backdated-fact seam (Vikunja #518).
//
// The pattern detector needs four events for one action+object, spread by at
// least pattern.MinIntervalDays, before it proposes a routine. Nothing could
// produce that against a running daemon in one sitting: the only writer is a
// fact write at time.Now(), so V-43, V-46, V-247 and V-254 all stopped at the
// same missing step and had been stopped there since they were filed.
//
// This is the write path that unblocks them, and it is deliberately the narrow
// one. It takes a fact, not an event, so pattern.Extract runs for real and a
// key the extractor ignores seeds nothing. It runs detectAndPropose, so what a
// seed proves is the daemon's own wiring rather than the detector in isolation
// — which is what an eval-lab fixture would have proved, and is not what those
// four tasks doubt.
//
// It is off unless mavend was started with -allow-seed, and AuthStepUp in the
// authority table besides. See ipc.SeedEventReq and auth.Requirement.
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/pattern"
"github.com/kami/maven/internal/store"
)
// errSeedDisabled — what a caller gets on an ordinary box. Named rather than
// inline so the mavweb route can tell "not allowed here" apart from "the seed
// ran and the extractor declined", which look the same to a reader otherwise.
var errSeedDisabled = errors.New("mavend: seeding is off (start with -allow-seed)")
// seedSource — every seeded fact carries this, and no other writer uses it.
// The point is that seeded data stays identifiable forever: a fact that came
// from a QA sitting must never be mistaken for something he said, either by a
// person reading /history or by the wipe in V-494 when it lands.
const seedSource = "seed:qa"
// seedStoreIfAllowed returns st only when -allow-seed was passed, and logs the
// fact loudly when it does. A box that can rewrite its own past should say so
// in its boot log, so nobody reads a seeded routine months later as evidence of
// something he actually did.
func seedStoreIfAllowed(st *store.Store) *store.Store {
if !allowSeedOnStart {
return nil
}
log.Printf("seed: -allow-seed is ON — backdated fact writes are permitted under source %q (Vikunja #518)", seedSource)
return st
}
// SeedEvent writes the fact at the caller's timestamp, extracts an event from
// it, and runs the same detect-and-propose step the voice path runs.
//
// Best-effort is NOT the shape here, unlike detectPattern: a seed that half
// worked is a QA result nobody can trust, so every step reports its own
// failure. Extraction declining is not a failure — it is the extractor's
// documented answer for a value outside its lexicon, and Extracted says so.
func (d *daemonAPI) SeedEvent(ctx context.Context, req ipc.SeedEventReq) (ipc.SeedEventResp, error) {
if d.seedStore == nil {
return ipc.SeedEventResp{}, errSeedDisabled
}
if req.Key == "" || req.Value == "" {
return ipc.SeedEventResp{}, errors.New("mavend: seed needs a key and a value")
}
if req.Ts.IsZero() {
return ipc.SeedEventResp{}, errors.New("mavend: seed needs an explicit timestamp")
}
// No Subject, unlike the voice path: a seeded key must not queue a Nexus
// resolution. QA data has no business reaching the ecosystem.
factID, err := d.seedStore.WriteFact(ctx, req.Ts, store.KindSelf, req.Key, req.Value, seedSource, 1.0, sql.NullInt64{})
if err != nil {
return ipc.SeedEventResp{}, fmt.Errorf("seed write fact: %w", err)
}
resp := ipc.SeedEventResp{FactID: factID}
ev := pattern.Extract(factID, req.Key, req.Value, req.Ts)
if ev == nil {
// The fact is written and stays written. Saying so matters: a caller
// that assumed a seed always produces an event would otherwise read
// four silent successes and conclude the detector is broken.
log.Printf("seed: %s=%s wrote fact %d, no event (value outside the action lexicon)", req.Key, req.Value, factID)
return resp, nil
}
resp.Extracted, resp.Action, resp.Object = true, ev.Action, ev.Object
eventID, err := d.seedStore.CreateEvent(ctx, factID, ev.Action, ev.Object, req.Ts)
if err != nil {
return resp, fmt.Errorf("seed create event: %w", err)
}
resp.EventID = eventID
r, routineID, err := detectAndPropose(ctx, d.seedStore, ev.Action, ev.Object, req.Ts)
if err != nil {
return resp, fmt.Errorf("seed detect: %w", err)
}
if r == nil {
return resp, nil // too few events yet, too irregular, or already decided
}
resp.Proposed, resp.RoutineID, resp.IntervalDays = true, routineID, r.IntervalDays
log.Printf("seed: proposed routine %d — %s/%s every %.1f days", routineID, r.Action, r.Object, r.IntervalDays)
return resp, nil
}
+105
View File
@@ -0,0 +1,105 @@
package main
import (
"context"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
)
// Off is the default and it must mean "nothing to write with", not "permission
// to refuse later". A daemonAPI with no seedStore writes no fact at all.
func TestSeedRefusedWithoutTheFlag(t *testing.T) {
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}}
_, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
Key: "cat_water_fountain", Value: "заправил", Ts: time.Now(),
})
if err == nil {
t.Fatal("seed succeeded with no seedStore")
}
if !strings.Contains(err.Error(), "-allow-seed") {
t.Errorf("error does not name the flag: %v", err)
}
}
// The whole point of the task: four seeds spread past the detector's floor
// produce a proposal against the real daemon path, which is what nobody could
// do before (Vikunja #518). Three seeds must NOT propose — MinEvents is four,
// and a test that only checked the happy end would pass on an off-by-one.
func TestSeedFourEventsProposesARoutine(t *testing.T) {
ctx := context.Background()
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
now := time.Now()
var last ipc.SeedEventResp
// Oldest first, three hours apart — past MinIntervalDays (two hours).
for i := 3; i >= 0; i-- {
var err error
last, err = d.SeedEvent(ctx, ipc.SeedEventReq{
Key: "cat_water_fountain",
Value: "заправил",
Ts: now.Add(-time.Duration(i) * 3 * time.Hour),
})
if err != nil {
t.Fatalf("seed %d: %v", i, err)
}
if !last.Extracted {
t.Fatalf("seed %d: no event extracted from a lexicon verb", i)
}
if i > 0 && last.Proposed {
t.Fatalf("proposed after only %d events, MinEvents is 4", 4-i)
}
}
if !last.Proposed {
t.Fatal("four spaced events did not propose a routine")
}
if last.Action != "refill" || last.Object != "cat_water_fountain" {
t.Errorf("wrong pair: %s/%s", last.Action, last.Object)
}
if last.IntervalDays < 0.1 {
t.Errorf("interval %v — the detector saw a burst, not a rhythm", last.IntervalDays)
}
// The proposal is readable through the same list the /routines page uses,
// which is the wiring an eval-lab fixture would not have proved.
proposed, err := d.seedStore.ListProposedRoutines(ctx)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(proposed) != 1 {
t.Fatalf("expected 1 proposed routine, got %d", len(proposed))
}
}
// A value outside the action lexicon writes the fact and says it seeded
// nothing. Silence here would read as four working seeds and a broken
// detector.
func TestSeedReportsWhenExtractionDeclines(t *testing.T) {
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
resp, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
Key: "mood", Value: "ok", Ts: time.Now(),
})
if err != nil {
t.Fatalf("seed: %v", err)
}
if resp.FactID == 0 {
t.Error("fact was not written")
}
if resp.Extracted || resp.EventID != 0 || resp.Proposed {
t.Errorf("claimed an event for a non-action value: %+v", resp)
}
}
// A seed with no timestamp is refused rather than defaulting to now: the only
// reason this seam exists is the caller choosing when, so a zero Ts is a bug in
// the caller and must not silently write a fact at the wrong time.
func TestSeedRequiresAnExplicitTimestamp(t *testing.T) {
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
if _, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
Key: "cat_water_fountain", Value: "заправил",
}); err == nil {
t.Fatal("seed accepted a zero timestamp")
}
}
+6
View File
@@ -10,6 +10,7 @@ import (
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/store"
)
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
@@ -22,6 +23,11 @@ type daemonAPI struct {
chatFn func(ctx context.Context, conversation, text string) string
getMCPServers func() []ipc.MCPServerStatus
getEvents func(n int) []ipc.IntakeEvent
// seedStore — non-nil ONLY when mavend was started with -allow-seed. It is
// the whole off-switch for the backdated write path (Vikunja #518), and it
// is a store rather than a bool so that leaving the flag off means the
// method has nothing to write with, not merely permission to refuse.
seedStore *store.Store
}
// RecentEvents — the unified intake journal (Vikunja #283). Empty, not an
+5
View File
@@ -552,6 +552,11 @@ func repairFactVectors(dataStore *store.Store, emb router.Embedder) {
// runReembed.
var reembedOnStart bool
// allowSeedOnStart is the -allow-seed flag (set in run()). Opt-in, and the
// default is the one that matters: a box nobody is testing has no live path to
// write a fact into the past. See seed.go and Vikunja #518.
var allowSeedOnStart bool
// checkStoredEmbedder compares the embedder we just loaded with the one that
// wrote the vectors already in the DB (Vikunja #378).
//
+78 -20
View File
@@ -1094,34 +1094,53 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, se
var msg string
if r.Method == http.MethodPost {
action := r.FormValue("action")
idStr := r.FormValue("id")
var rid int64
if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
switch action {
case "accept":
// "seed" is the one action with no routine to act on — it is what
// MAKES a routine (Vikunja #518), so it runs before the id parse. It
// lives on this route rather than a page of its own because it is
// already the step-up-gated surface for this table, and a second gated
// surface is a second thing to get wrong.
if action == "seed" {
if !stepUpOK(session, requireStepUp) {
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
return
}
if err := acceptRoutine(ctx, core, rid); err != nil {
log.Printf("routines: accept %d: %v", rid, err)
http.Error(w, "accept failed: "+err.Error(), http.StatusBadGateway)
out, err := seedRoutineEvent(ctx, core, r)
if err != nil {
log.Printf("routines: seed: %v", err)
http.Error(w, "seed failed: "+err.Error(), http.StatusBadGateway)
return
}
msg = "accepted routine — maven will remind you"
case "dismiss":
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
log.Printf("routines: dismiss %d: %v", rid, err)
http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway)
msg = out
} else {
idStr := r.FormValue("id")
var rid int64
if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
switch action {
case "accept":
if !stepUpOK(session, requireStepUp) {
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
return
}
if err := acceptRoutine(ctx, core, rid); err != nil {
log.Printf("routines: accept %d: %v", rid, err)
http.Error(w, "accept failed: "+err.Error(), http.StatusBadGateway)
return
}
msg = "accepted routine — maven will remind you"
case "dismiss":
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
log.Printf("routines: dismiss %d: %v", rid, err)
http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway)
return
}
msg = "dismissed routine"
default:
http.Error(w, "unknown action", http.StatusBadRequest)
return
}
msg = "dismissed routine"
default:
http.Error(w, "unknown action", http.StatusBadRequest)
return
}
}
proposed, err := core.ListProposedRoutines(ctx)
@@ -1158,6 +1177,45 @@ func toRoutineViews(rs []ipc.ProposedRoutine) []routineView {
// 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.
// seedRoutineEvent drives one backdated fact write through core (Vikunja #518),
// so the pattern detector can be exercised against a running daemon instead of
// over real days. Refused unless mavend was started with -allow-seed; on an
// ordinary box the error says so and nothing is written.
//
// Takes "ago" rather than an absolute timestamp — hours before now, as a float
// so a QA sitting can space four seeds three hours apart without doing clock
// arithmetic. The detector's floor is two hours, and "0" is a legal answer
// meaning now.
func seedRoutineEvent(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) {
key := strings.TrimSpace(r.FormValue("key"))
value := strings.TrimSpace(r.FormValue("value"))
if key == "" || value == "" {
return "", errors.New("seed needs a key and a value")
}
agoHours, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("ago")), 64)
if err != nil {
return "", fmt.Errorf("seed: bad ago (hours before now): %w", err)
}
if agoHours < 0 {
return "", errors.New("seed: ago is hours BEFORE now, so it cannot be negative")
}
resp, err := core.SeedEvent(ctx, ipc.SeedEventReq{
Key: key,
Value: value,
Ts: time.Now().Add(-time.Duration(agoHours * float64(time.Hour))),
})
if err != nil {
return "", err
}
if !resp.Extracted {
return fmt.Sprintf("wrote fact %d, but %q is not in the action lexicon — no event, no pattern", resp.FactID, value), nil
}
if !resp.Proposed {
return fmt.Sprintf("seeded %s/%s (fact %d, event %d) — not enough yet to propose", resp.Action, resp.Object, resp.FactID, resp.EventID), nil
}
return fmt.Sprintf("seeded %s/%s and PROPOSED routine %d, every %.1f days", resp.Action, resp.Object, resp.RoutineID, resp.IntervalDays), nil
}
func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
proposed, err := core.ListProposedRoutines(ctx)
if err != nil {
+116 -3
View File
@@ -1,6 +1,6 @@
# QA plan: checking Maven properly
*Last verified: 2026-08-04 @ a4d5155. Living doc: correct it in place, do not append.*
*Last verified: 2026-08-04 @ 8d816f4. 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.
@@ -88,8 +88,71 @@ 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 were run on 02-08-2026 and pass.
**Step 2 no longer needs a person, and step 9 has a number now** (04-08-2026).
`POST /api/ptt` takes raw PCM16 16kHz mono and answers with audio plus an
`X-Reply-Text` header, so the committed STT fixtures stand in for a microphone:
```sh
tail -c +45 cmd/mavsttd/testdata/ru_query.wav > /tmp/q.pcm
curl -s --noproxy '*' -D /tmp/h -o /tmp/reply.pcm -X POST \
http://127.0.0.1:9201/api/ptt --data-binary @/tmp/q.pcm \
-H 'Content-Type: application/octet-stream' -m 180
```
That covers audio in → STT → router → phrasing → TTS audio out. It leaves only
browser microphone capture needing a person, and the wake path needing a machine.
Do not post `en_act.wav` without deciding first: it is a mutating act.
**Steps 7 and 8 still cannot run, but 15 is no longer the reason** (04-08-2026).
The desk presence poster is installed on workpc. It is a `maven-desk` systemd
user timer on a 60s cadence, gated by hypridle at 120s idle. `desk_active` facts
now arrive, and the first landed at 18:43.
What blocks the two steps now is that no rule wants to fire. `/trace` shows all
five at `predicate`, none inert:
| rule | sev | why it is false |
|---|---|---|
| water | 1 | needs ≥3h since the last `water` fact; step 2's `ru_fact` wrote one |
| meal | 1 | needs ≥6h since a `meal` fact; none exists |
| break | 2 | needs both `desk_active` and a `break` fact; `break` has never been written |
| service_down | 4 | no kuma monitor is down |
| netdata_critical | 3 | nothing critical |
So the honest way to run step 8 is to wait three hours after the last `water`
fact, or to write one antedated. Do not read the water rule's silence as a defect.
**The sev4 telegram reach works** (04-08-2026). Resuming a paused kuma monitor
for paperless, which is genuinely down, put a real `service_down` through the
whole path with presence away:
```
23:03 voicesink: no live voice session for service_down, falling through to away channels
/notifications: 19:03 | service_down | telegram | pending | Сервис перестал отвечать.
04.08 23:03 | nudge | service_down | telegram | sent | 23:03
```
`ChannelsFor(Sev4, Away)` returned telegram, the send succeeded, and the row
holds at `pending` because sev4 repeats until acked. The 15:51 row shows the
same rule reaching `acted` earlier, so the ack path works too.
The body was `Сервис перестал отвечать.`, which names no service. That is a bug
and it is deterministic, filed as **534**. `nudgeValues` fills `{service}` from
`State.Fact("service_down")`, an exact key mavpoll stopped writing when
per-monitor facts landed. Nine of the ten templates carry `{service}`, so all
nine are rejected as unfillable. The one nameless variant is left as the only
usable one, every time. The stub and LLM phrasers both call `loop.DownServices`
and get it right. The template path is the one that runs.
**Presence itself has a real defect, filed as 532.** `SavePresenceState` has no
caller outside tests, so the singleton row is never written. The gate is fine,
because it reads the bucket `GatherState` computes in memory each tick. Two
things follow. Hysteresis is dead, because `lastBucket` is always cold-start `Away`
and the 0.30-0.55 hold band never applies. And every presence readout lies:
`/dash` shows `away — score 0.00 (never)` with fresh `desk_active` facts arriving
every 60s. Do not trust that number while checking anything else here.
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:
@@ -112,6 +175,33 @@ turns look misaligned when they are not.
back. This covers browser mic to STT to core to TTS as one path. It does
**not** cover the wake word or the voice-activity gate, and no step here
does — see below.
**Passes below the browser** (04-08-2026, three fixtures through `/api/ptt`):
HTTP 200, `audio/l16;rate=16000;channels=1`, and real speech back. `ru_query`
answered `на 04.08.2026 ничего нет.` in 3.82s of audio at RMS 3865, `ru_fact`
answered `отметила: water = выпил`, `ru_reminder` answered `хорошо, напомню.`
at `intent=reminder`.
**Passes in the browser too** (04-08-2026), and it needed no person. Headless
Chrome takes a fake microphone, so the whole browser half runs unattended:
```sh
chrome --headless=new --remote-debugging-port=9333 --remote-allow-origins='*' \
--use-fake-device-for-media-stream --use-fake-ui-for-media-stream \
--use-file-for-fake-audio-capture=cmd/mavsttd/testdata/ru_query.wav%noloop
```
Then drive it over the debug protocol: click `#btn`, wait, click again, read
`#status` and `#log`. That covers `getUserMedia`, `MediaRecorder`, the webm
decode and the hand-written resample to 16k Int16. It logged
`sending 188160 bytes`, which is 5.88s at 16k mono, and got the reply back.
**The button is on `/`, not `/dash`.** `handleVoice` serves it at the root
(`main.go:332`). `/dash` is the presence and fact dashboard and carries no
`#btn`. This step said `/dash` until 04-08-2026.
One defect fell out, filed as **533**. The reply logged as
`на+04.08.2026+ничего+нет.` The header is escaped with `url.QueryEscape`,
which writes a space as `+`, then decoded with `decodeURIComponent`, which
leaves `+` alone. Transcript only, the audio is fine.
3. Say `тихий режим`. Expect `тихий режим включён. буду реже напоминать.` **Passes.**
4. Say `выключи тихий режим`. Expect `тихий режим выключен.` Negation must win. **Passes.**
5. Say `в комнате тихо`. Quiet mode must NOT flip. Confirm on `/history` that no
@@ -132,6 +222,29 @@ turns look misaligned when they are not.
**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.
**The slowness now has a cause and a number** (04-08-2026). A spoken turn
takes 32 to 34 seconds. One phrasing call is 30.0s of that. STT is 1.0s
and routing is under 10ms. Both interactive calls decoded exactly 512 tokens,
which is the phrasing cap. Both were truncated, to produce a reply of
under 25 characters.
The cause is `responseGrammar`, not the model. Its last rule is
`ws ::= [ \t\n]*`, and `*` is unbounded, so the model emits `{` and then
satisfies `ws` with whitespace until `max_tokens` stops it. Reproduced on a
second server: at `repeat_penalty` 1.0 it runs to 512 and returns
`finish_reason=length`, at 1.3 it stops at 24. Bounding the rule to
`[ \t\n]{0,4}` gives a clean stop at 33 tokens three times out of three with
no penalty at all.
Only some callers are exposed. `internal/llm.Req` sends `repeat_penalty` and
the replier sets it to 1.3, so that path is protected by accident. `chatReq`
in the phraser sends no penalty, so `PhraseChat`, `PhraseQuery`,
`PhraseNudge` and `PhraseReminder` all run at the default 1.0. Filed as
**531**.
Two guesses were wrong on the way and are recorded so nobody repeats them.
It is not reasoning tokens: the probe returned `reasoning_content` of length
0, and the grammar constrains output from the first token. It is not the
`--cache-ram 512` limit either: that is MiB of prompt cache and the 512 that
was hit is a token count.
The wandering is a second thing and stays on 287.
**The wake path cannot be checked here, and that is now the decision rather
than a gap.** `mavwaked` and `mavenclient` appear in no compose file and run as
+13
View File
@@ -91,6 +91,19 @@ func Requirement(m ipc.Method) Authority {
// can do is make Maven stop recognising someone, which is the state the
// box ships in anyway.
return AuthWrite
case ipc.MethodSeedEvent:
// The one backdating write path in the tree (Vikunja #518). AuthStepUp,
// the same rung as mutating the tool allowlist, and for a reason that is
// not about privilege: every other write records when something actually
// happened, and this one asserts it. A caller who can place a fact in the
// past can manufacture a routine Maven will then act on forever, which is
// the tick loop obeying evidence nobody produced.
//
// Step-up is not the real gate and is not meant to be. mavend refuses the
// method entirely unless started with -allow-seed, so the ordinary state
// of the box is that no gesture reaches it. This rung is what stops a
// module from calling it on a box where QA left the flag on.
return AuthStepUp
case ipc.MethodWriteFact:
return AuthWrite
case ipc.MethodIngestMail:
+42
View File
@@ -184,6 +184,48 @@ type CaptureTaskResp struct {
Promoted bool `json:"promoted,omitempty"`
}
// SeedEventReq — write one fact at a caller-supplied timestamp and run the
// pattern path over it, so a recurring routine can be produced on demand
// instead of over real days (Vikunja #518).
//
// This is the ONLY backdating write path in the tree, and it exists for one
// reason: the detector needs four events spread over hours before it proposes
// anything, so V-43, V-46, V-247 and V-254 could not be verified against a
// running daemon at all. A store fixture would have exercised the detector
// without the wiring those tasks doubt.
//
// Two things hold it shut. It is AuthStepUp in the authority table, the same
// rung as mutating the tool allowlist. And mavend refuses it outright unless
// started with -allow-seed, so a box nobody is testing carries no live
// backdating path even for a caller who cleared the gate.
//
// Key and Value are a fact, not an event: extraction runs for real, so a key
// the extractor ignores seeds nothing and says so. That is deliberate — a
// seam that accepted action and object directly would let QA prove a detector
// against events no utterance could ever produce.
type SeedEventReq struct {
Key string `json:"key"`
Value string `json:"value"`
Ts time.Time `json:"ts"`
}
// SeedEventResp — what the seed produced. Extracted is false when the fact was
// written but yielded no event, which is the extractor declining rather than a
// failure. Proposed is true only when this seed completed a pattern; the first
// three seeds of a run return false with no routine.
type SeedEventResp struct {
FactID int64 `json:"fact_id"`
EventID int64 `json:"event_id,omitempty"`
Extracted bool `json:"extracted"`
Action string `json:"action,omitempty"`
Object string `json:"object,omitempty"`
Proposed bool `json:"proposed"`
RoutineID int64 `json:"routine_id,omitempty"`
// IntervalDays — the median the detector settled on, echoed so QA can
// check it against the spacing it asked for.
IntervalDays float64 `json:"interval_days,omitempty"`
}
// IngestMailReq — one message a mail reader has fetched, handed to core for
// extraction (Vikunja #246).
//
+8
View File
@@ -487,6 +487,14 @@ func (c *Client) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, e
return r.Routines, nil
}
func (c *Client) SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error) {
var r SeedEventResp
if err := c.call(ctx, MethodSeedEvent, req, &r); err != nil {
return SeedEventResp{}, err
}
return r, nil
}
func (c *Client) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
var r CaptureTaskResp
if err := c.call(ctx, MethodCaptureTask, req, &r); err != nil {
+7
View File
@@ -110,6 +110,13 @@ type RoutineAPI interface {
// loop takes the schedule from there — no reminder is created (Vikunja #366).
AcceptProposedRoutine(ctx context.Context, id int64) error
// SeedEvent writes a backdated fact and runs extraction and detection over
// it, so a proposal can be produced in one sitting rather than over real
// days (Vikunja #518). See SeedEventReq for why this exists and what keeps
// it shut. Daemon-computed, like MorningStatus — the store adapter refuses
// it, because the detect-and-propose step lives in mavend.
SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error)
// MorningStatus returns each configured morning routine's current
// checklist state (see internal/morning): active today/now, which items
// are done, which are still missing.
+3
View File
@@ -524,6 +524,9 @@ var methodTable = map[Method]handlerFunc{
MethodCaptureTask: withParams(func(ctx context.Context, api CoreAPI, p CaptureTaskReq) (CaptureTaskResp, error) {
return api.CaptureTask(ctx, p)
}),
MethodSeedEvent: withParams(func(ctx context.Context, api CoreAPI, p SeedEventReq) (SeedEventResp, error) {
return api.SeedEvent(ctx, p)
}),
MethodListTasks: withParams(func(ctx context.Context, api CoreAPI, p listTasksReq) (listTasksResp, error) {
out, err := api.ListTasks(ctx, p.Status)
if err != nil {
+8
View File
@@ -265,6 +265,14 @@ func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) {
return TickTrace{}, errors.New("store: tick trace not available via direct store API")
}
// SeedEvent — same shape as MorningStatus: writing the fact is a store call,
// but extraction and detect-and-propose live in mavend, and a seed that wrote
// the fact without running them would be the one thing this seam must not be,
// a way to prove a detector that never ran (Vikunja #518).
func (a *storeAPI) SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error) {
return SeedEventResp{}, errors.New("store: seed event not available via direct store API")
}
func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
return nil, errors.New("store: morning status not available via direct store API")
}
+3
View File
@@ -105,6 +105,9 @@ func (UnimplementedCoreAPI) DeleteTool(ctx context.Context, name string) error {
func (UnimplementedCoreAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
return CaptureTaskResp{}, ErrNotImplemented
}
func (UnimplementedCoreAPI) SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error) {
return SeedEventResp{}, ErrNotImplemented
}
func (UnimplementedCoreAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
return nil, ErrNotImplemented
}
+1
View File
@@ -67,6 +67,7 @@ const (
MethodListSpeakers Method = "list_speakers"
MethodForgetSpeaker Method = "forget_speaker"
MethodRecentEvents Method = "recent_events"
MethodSeedEvent Method = "seed_event"
// MethodPing — liveness, and the only method that answers in locked mode
// without a passkey assertion. It reaches no store, takes no arguments and
+33 -6
View File
@@ -94,7 +94,7 @@ func (t *NudgeTemplates) PhraseNudge(_ context.Context, c loop.Candidate) (deliv
// template fits it uses the plain per-rule fallback.
func (t *NudgeTemplates) Nudge(c loop.Candidate) (body, mood string) {
rule := c.Rule.Name
family := t.family(rule)
family := t.pluralFamily(t.family(rule), c)
set, ok := t.file.Rules[family]
if !ok {
return fallbackNudge(c), "neutral"
@@ -155,6 +155,25 @@ func (t *NudgeTemplates) family(rule string) string {
return "default"
}
// pluralFamily swaps in the plural wording when {service} will hold a list.
// Russian agrees the verb with the subject, so one set of templates cannot
// serve both: "Сервис paperless не отвечает" and "Сервисы nginx, paperless не
// отвечают" differ in the noun, the verb and the adjective. Filling a list into
// the singular text is the kind of near-miss that reads as machine-written.
//
// Only service_down has a plural form today. A family with no "_many" set in
// the file is returned unchanged, so adding one is a data change.
func (t *NudgeTemplates) pluralFamily(family string, c loop.Candidate) string {
if len(loop.DownServices(c.State)) < 2 {
return family
}
many := family + "_many"
if _, ok := t.file.Rules[many]; ok {
return many
}
return family
}
// placeholderRE — the {name} slots a template may use.
var placeholderRE = regexp.MustCompile(`\{([a-z]+)\}`)
@@ -165,17 +184,25 @@ func nudgeValues(c loop.Candidate) map[string]string {
vals := map[string]string{}
rule := c.Rule.Name
// {service} — one fact per kuma monitor, keyed "service_down:<name>", so
// the name lives in the key SUFFIX and there is no fact called plain
// "service_down" to read. loop.DownServices is the same helper the rule
// fired on, which is what stops the message naming a service that is up.
// This used to read c.State.Fact(rule) — the pre-per-monitor aggregate —
// and so never filled, leaving the one nameless variant as the only
// fillable template every time (Vikunja #534).
if down := loop.DownServices(c.State); len(down) > 0 {
vals["service"] = strings.Join(down, ", ")
}
// {since} — only at hour scale. Below an hour the phrase would be minutes,
// and none of the templates read well with "сорок минут".
// and none of the templates read well with "сорок минут". service_down has
// no {since} to offer: its facts are keyed by monitor, and the rule is
// edge-triggered, so it fires on the transition rather than hours later.
if d, ok := c.State.Since(rule); ok && d >= time.Hour {
if s := ruSinceWords(d); s != "" {
vals["since"] = s
}
}
// {service} — the aggregate fact's key carries the service name.
if f, ok := c.State.Fact(rule); ok && f.Key != "" && f.Key != rule {
vals["service"] = f.Key
}
// {what} — the Russian suffix of "routine:таблетки" / "morning:утро".
if i := strings.IndexByte(rule, ':'); i > 0 && i+1 < len(rule) {
vals["what"] = rule[i+1:]
+106 -6
View File
@@ -11,6 +11,102 @@ import (
"github.com/kami/maven/internal/store"
)
// downCand builds a service_down candidate the way a tick actually does it:
// one fact per kuma monitor under the prefix, carrying the source and value
// loop.DownServices checks. The old cand() shape wrote a single fact keyed
// plain "service_down", which mavpoll stopped producing, and that is why the
// tests passed through the whole of #534.
func downCand(names ...string) loop.Candidate {
now := time.Date(2026, 7, 31, 21, 40, 0, 0, time.UTC)
st := loop.State{Now: now, Facts: map[string]store.Fact{}}
for _, n := range names {
key := loop.ServiceDownPrefix + n
st.Facts[key] = store.Fact{
Key: key, Ts: now.Add(-3 * time.Minute),
Source: loop.ServiceDownSource, Value: `"down"`,
}
}
return loop.Candidate{
Rule: loop.Rule{Name: "service_down", Severity: loop.Sev4},
Severity: loop.Sev4, State: st,
}
}
// The nudge he reads on telegram must name what broke. It is a sev4 that
// reaches him away from the box, so "a service is down" costs him a trip to
// kuma to learn anything at all.
func TestNudgeNamesTheDownService(t *testing.T) {
// Lowercased before matching: a name that opens the sentence is
// capitalized by capitalizeFirst, which is wanted.
nt := newTestTemplates(t, 5)
for i := 0; i < 40; i++ {
body, _ := nt.Nudge(downCand("paperless"))
if !strings.Contains(strings.ToLower(body), "paperless") {
t.Fatalf("body does not name the service: %q", body)
}
}
// Two down: both named, in the key order the rule itself uses.
for i := 0; i < 40; i++ {
body, _ := nt.Nudge(downCand("nginx", "paperless"))
low := strings.ToLower(body)
if !strings.Contains(low, "nginx") || !strings.Contains(low, "paperless") {
t.Fatalf("body drops a service: %q", body)
}
}
}
// Russian agrees the verb with the subject, so a list of services cannot go
// into the singular sentence. One down takes the singular set, two or more
// take service_down_many.
func TestNudgeAgreesWithTheServiceCount(t *testing.T) {
nt := newTestTemplates(t, 9)
// "упал " keeps its trailing space: "упали" starts with "упал", and the
// plural must not read as the singular by prefix.
singular := []string{"не отвечает", "недоступен", "лежит", "упал "}
plural := []string{"не отвечают", "недоступны", "лежат", "упали"}
for i := 0; i < 60; i++ {
body, _ := nt.Nudge(downCand("paperless"))
if !containsAny(body, singular) {
t.Fatalf("one down, no singular verb: %q", body)
}
if containsAny(body, plural) {
t.Fatalf("one down, plural wording: %q", body)
}
}
for i := 0; i < 60; i++ {
body, _ := nt.Nudge(downCand("nginx", "paperless"))
if !containsAny(body, plural) {
t.Fatalf("two down, no plural verb: %q", body)
}
if containsAny(body, singular) {
t.Fatalf("two down, singular wording: %q", body)
}
}
}
func containsAny(s string, subs []string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
// Nothing down means no template fits, and the fallback answers rather than
// the picker inventing a name.
func TestNudgeServiceDownWithoutFacts(t *testing.T) {
nt := newTestTemplates(t, 5)
body, mood := nt.Nudge(downCand())
if body != "Сервис не отвечает." {
t.Fatalf("fallback body %q", body)
}
if mood != "neutral" {
t.Fatalf("mood %q", mood)
}
}
// cand builds a candidate the way a tick would.
func cand(rule string, sinceMin int, factKey string) loop.Candidate {
now := time.Date(2026, 7, 31, 21, 40, 0, 0, time.UTC)
@@ -36,7 +132,7 @@ func newTestTemplates(t *testing.T, seed int64) *NudgeTemplates {
func TestNudgeTemplatesLoad(t *testing.T) {
nt := newTestTemplates(t, 1)
for _, rule := range []string{"water", "meal", "break", "service_down", "netdata_critical", "routine", "morning", "default"} {
for _, rule := range []string{"water", "meal", "break", "service_down", "service_down_many", "netdata_critical", "routine", "morning", "default"} {
set, ok := nt.file.Rules[rule]
if !ok {
t.Errorf("no templates for %q", rule)
@@ -46,8 +142,12 @@ func TestNudgeTemplatesLoad(t *testing.T) {
t.Errorf("%s: only %d variants", rule, len(set.Variants))
}
// Every rule needs one variant that needs no value, or a candidate
// without context has nothing to say. routine and morning are exempt:
// they always carry a name and must always say it.
// without context has nothing to say. routine, morning and
// service_down are exempt: they always carry a name and must always
// say it. service_down's predicate cannot fire without a down fact,
// so loop.DownServices always has something to fill {service} with,
// and the nameless variant it used to carry was the bug (#534) —
// {service} never filled, so that variant was the only fillable one.
plain := 0
seen := map[string]bool{}
for _, v := range set.Variants {
@@ -59,7 +159,7 @@ func TestNudgeTemplatesLoad(t *testing.T) {
}
seen[v] = true
}
if plain == 0 && rule != "routine" && rule != "morning" {
if plain == 0 && rule != "routine" && rule != "morning" && !strings.HasPrefix(rule, "service_down") {
t.Errorf("%s: every variant needs a placeholder value", rule)
}
}
@@ -102,8 +202,8 @@ func TestNudgeNoLeftoverPlaceholders(t *testing.T) {
cand("water", 0, ""), // no duration
cand("water", 30, ""), // under an hour
cand("water", 200, ""), // hours
cand("service_down", 3, "vaultwarden"),
cand("service_down", 3, ""), // no service name
downCand("vaultwarden"),
downCand(), // nothing down: the fallback answers
cand("routine:таблетки", 0, ""),
cand("morning:утро", 0, ""),
cand("unknown_rule", 0, ""),
+15 -3
View File
@@ -5,7 +5,8 @@
"Hand-written Russian nudges. Edit the wording here, no Go changes needed.",
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never plural imperatives (выпейте), never он/его about him.",
"One short sentence. No questions, no emoji, no pet names, no emotional support.",
"Placeholders: {since} how long it has been (only used when it is at least an hour), {service} the service name, {what} the routine name. A variant whose placeholder has no value is skipped, so every rule needs at least one variant with no placeholder. The exception is routine and morning: those only exist for rules like routine:таблетки that always carry a name, and a routine nudge that drops the name is useless.",
"Placeholders: {since} how long it has been (only used when it is at least an hour), {service} the service name, {what} the routine name. A variant whose placeholder has no value is skipped, so every rule needs at least one variant with no placeholder. The exception is routine, morning and service_down: those only exist for rules that always carry a name, and one that drops the name is useless.",
"A rule may carry a second set named <rule>_many, used when {service} holds more than one name. Russian agrees the verb with the subject, so the plural needs its own wording rather than a list dropped into the singular sentence. Only service_down has one.",
"mood must be one of: neutral, happy, thinking, tired, confused."
],
"rules": {
@@ -62,13 +63,24 @@
"{service} не отвечает, сервис нужно поднимать.",
"Сервис {service} недоступен.",
"Проверь {service}: сервис не отвечает.",
"Сервис перестал отвечать.",
"Сервис {service} лежит, нужно смотреть.",
"{service} не отвечает уже {since}.",
"Мониторинг сообщает: {service} лежит.",
"Сервис {service} не отвечает, посмотри логи."
]
},
"service_down_many": {
"mood": "neutral",
"variants": [
"Сервисы {service} не отвечают.",
"{service} упали — сервисы не отвечают.",
"{service} не отвечают, сервисы нужно поднимать.",
"Сервисы {service} недоступны.",
"Проверь {service}: сервисы не отвечают.",
"Сервисы {service} лежат, нужно смотреть.",
"Мониторинг сообщает: {service} лежат.",
"Сервисы {service} не отвечают, посмотри логи."
]
},
"netdata_critical": {
"mood": "neutral",
"variants": [