Compare commits

..

9 Commits

Author SHA1 Message Date
kami 7b2b96b957 Capture tasks, with one intake seam mail can call later (#130)
A task is not a fact and not a note. A fact is a claim about the world that a
correction supersedes; a note is something to recall by meaning. A task is work
with a lifecycle, and the read that matters is "everything outstanding right
now" — which over an append-only log would mean replaying history on every
question. So: a tasks table, migration #14, statuses candidate/open/done/dropped
that each move forward exactly once.

Dedupe is on normalised text among LIVE rows only, via a partial unique index.
That is the property the mail side needs: an extractor may call CaptureTask for
every message it reads, as often as it likes, without growing the list — while a
weekly errand is still capturable again once the last one is done.

Three ways in, one seam. ipc.CaptureTaskReq is it: the voice path
(router.ParseTaskCapture on an explicit marker — "добавь в задачи …", never
"надо бы поспать"), the /tasks form, and the email reader from #246 when it
exists. Mail-derived items set Source "email:<account>", Status "candidate" and
Evidence to whatever makes the row reviewable; a candidate is inert until he
confirms it on /tasks, and Maven names it as unconfirmed when she recites the
list rather than putting words in his mouth.

No new intent — the router enum is a contract with the relabelling prompt, so
capture rides the note intent and the list rides a query source, both matched
deterministically like the calendar and plan matchers already are.

Nothing here speaks. No tick rule reads tasks; the list is answered when asked
about, which is why /tasks POST is not step-up gated the way /tools and
/routines are — a task write moves no boundary.

Vikunja #130
2026-08-01 02:33:47 +04:00
kami c8444813e2 Answer "что я обычно делаю по вторникам?" by counting, not guessing (#254)
Behavioural memory, narrowed on purpose. internal/memory/behavior.go builds a
profile out of self-facts — distinct days per weekday, median time of day — and
reads it back in RU; router.ParseHabitQuery finds the weekday deterministically;
a `habits` query source answers the question.

Three things the plan doc asks for are deliberately absent, and the doc now
records why:

- The profile is COUNTED, not LLM-generated. A 1.7B asked to summarise a year of
  habits writes fluent claims about the owner's life that no row supports, and a
  wrong claim about him is the most expensive kind of wrong maven can be.
- No cached profile fact, so no "update on fact write" machinery. It is
  recomputed on the question; a cache that can disagree with its own rows is two
  truths.
- No proactive daily plan nudge. A dispatcher proposal at 08:00 every day is the
  definition of a nag. The path from "she noticed a pattern" to "she acts on it"
  already exists in internal/pattern with the proposal queue on /routines, and it
  goes through him.

A one-off is not a habit: an activity needs two distinct days before she will
call it usual, and until then she says she does not know yet. Only self-facts
count — env rows are the world, config rows are her own tuning state. The typical
time is a median so one 03:00 outlier cannot move a morning habit into the night.
An unrecognised fact key is read back verbatim rather than glossed into something
she made up.

The source sits before "calendar" in querySources, and its matcher requires a
habit marker, so "что я делаю в среду?" still reaches the calendar — answering a
question about this coming Wednesday with a statistical average would be
answering a different question.

Verified: make build and make test both exit 0.
2026-08-01 02:21:09 +04:00
kami ed9bdd5e09 Add the day plan she can recite when asked (#128)
The plan answers "какие планы на сегодня?" by putting one day in order:
calendar events (with #126's ambient provenance carried through and hedged),
pending reminders, and one line per morning routine that still has items
outstanding. "что дальше?" trims what has already passed.

It lives in internal/morning, not in a parallel system, because it is the same
question the checklist asks at a different scale — the routine knows what is
missing from a window, the plan knows what the whole day holds, and both read
the same facts and the same idea of "today". BuildPlan is pure; tickLoop.dayPlan
is the impure half that reads the store.

It is not a nag. Nothing here fires, schedules or announces: the plan is built
only when asked, over IPC (day_plan) or on the existing /morning page.
Unprompted delivery stays with the morning nudge and the dispatcher's policy.

The query source sits before "calendar" in querySources because both match
"…на сегодня" and the plan's matcher is the more specific one; IsDayPlanQuery
matches whole words so "планёрка" (a meeting) is not read as a request for the
plan, and refuses any utterance naming another day, since the plan is built for
the clock's own day only.

Verified: make build and make test both exit 0; new tests cover plan ordering,
the checklist-only-what-is-left rule, other-day rejection, the RU rendering
against the persona checks, rest-of-day trimming, the source ordering, and the
matcher's refusals.
2026-08-01 02:15:18 +04:00
kami 49f089d8a6 Read the work calendar as a notification signal, not a mailbox (#126)
Maven does not get a work credential. A corp mail or calendar session living on
the homelab ties the box's blast radius to the employer's data, which is the
thing this task exists to refuse. What she reads instead is the signal: an
Android notification-listener on the phone relays meeting notifications over
wg/LAN to POST /api/ambient, and the ones that clearly describe a meeting become
calendar events at source=ambient:notif, confidence 0.6.

The provenance is the point. A notification is evidence about a meeting, not a
reading of a calendar, so it is never indistinguishable from one: it is stored
below full confidence, store.CalendarEvents keeps the source and confidence on
every row it returns, and the query path hedges — "похоже, Планёрка @ 14:00" for
a relayed event, plain text for a CalDAV read.

The parse is deliberately conservative (internal/calendar/ambient.go). It needs
a real clock reading and a summary that is not just that clock reading;
otherwise it stores nothing at all. A bare hour is not a time, an unread count
is not a time, and "срок 2026.08.15" does not offer 08:15 as a meeting — loose
digits in a notification are far more often a badge or a date, and a mailbox of
noise rendered as invented meetings is worse than a gap.

The ingest is off unless configured: no -ambient-token, no route registered. The
token is a shared secret compared in constant time, because the poster is a
background Android service and WebAuthn has no answer for one. The endpoint is
write-only, accepts one shape of write, and cannot read anything back out.
Reposts of the same notification dedupe against the latest fact for that
key+source, the same append-only discipline cmd/mavcaldav follows.

Not shipped: the Android relay app itself, which is a separate artifact and a
device, not Go in this repo.
2026-08-01 02:04:06 +04:00
kami 3af290152c Render maven's own reminders to a calendar she owns (#127)
Radicale becomes a write-only render target, not a store. sqlite stays
canonical: every poll mavcaldav reads the pending reminders out of core and
publishes each one as a single-event iCal resource, withdrawing the ones that
have fired or been cancelled. Losing the collection costs nothing — the next
tick rebuilds it, and nothing is ever read back from it.

It structurally cannot write to a calendar maven only reads. The render URL and
credential are their own flags, and -render-url is refused at startup when it
names the collection -url reads; the only paths it addresses carry the
maven-reminder- prefix, so even aimed at the wrong collection it can only touch
resources it created. Rendering is off unless -render-url is given.

The calendar data model now lives in one place, internal/calendar: the Event,
the iCal parse it comes from and the render it goes to, the fact key/value
encoding, and the source constants that say which calendars may be written to.
It was a parse inlined in cmd/mavcaldav and a Sprintf in two files; #126 and
#128 both need to agree with it.

Fixes a latent day-boundary bug moved out of that inline parse: it took the day
number off a local clock reading but built the window boundaries in UTC, so on
a box east of Greenwich part of the evening fell outside "today" and the poller
saw an empty calendar after 20:00 UTC. Today is now the owner's day in the
owner's location, which is what the busy gate and the day plan mean.
2026-08-01 01:55:51 +04:00
kami dc7c72a3d7 Add background memory evaluation, off unless configured (#248)
Ships the real, local, testable part of the memory-evaluation plan
(docs/plans/03-memory-evaluation.md): Maven reads back her own recent
memory on a slow ticker, asks the resident model what it notices, and
records the confident answers as notes.

internal/memeval — not internal/memory/eval.go as the plan says, because
internal/store imports internal/memory for the vector backend and an
evaluator has to read store.Fact/Note/Nudge, which would close the
cycle. Evaluate() gathers RecentFacts/RecentNotes/RecentNudges, prompts
under a GBNF grammar bounded to three {observation, confidence,
suggested_action} objects, drops anything under min_confidence,
deduplicates against what earlier runs wrote, and writes the rest as
notes with source infer:memory-eval. /dash already renders notes with
their source, so the output is visible with no UI change.

cmd/mavend/memoryeval.go drives it on its own goroutine and ticker, not
on the 60s tick: an evaluation is a multi-second round-trip on the same
llama-server that answers voice turns, and it runs hourly at most. The
memory_eval config block is absent by default and absence means the
goroutine does not exist. No llama-server phraser also means no loop —
there is no template fallback, because a "memory evaluation" assembled
from templates is a fixed sentence pretending to be an observation.

What it deliberately cannot do, since this is the feature most likely to
turn Maven into a nag:

  - It cannot speak. No dispatcher reference, no channel, no nudge. An
    observation is a thought she wrote down and he reads on /dash.
    Announcing them is a separate decision with its own opt-in.
  - It cannot act. suggested_action is recorded as text and interpreted
    by nobody — no reminder, routine or fact is created from it.
  - It says nothing about an empty store: no memory means no LLM call,
    so there are no observations invented out of two facts.
  - Its own notes are excluded from the next evaluation's input, and are
    written with a nil embedding so they stay out of the recall pool.

The plan's remaining items (dispatching observations, an /eval IPC
method and trace view, RecentEvents) and the fact that output quality is
entirely unmeasured are written up at the bottom of the plan doc.
2026-08-01 01:45:49 +04:00
kami 766ca091a7 Announce tick-inferred routines, opt-in and rate-limited (#247, #43)
The digestion tick already runs the pattern detector over all recorded
events (67563ed) and writes a proposed_routines row. What was missing is
the other half of #247: a proposal that nobody is at the mic for reaches
nothing but the /routines page, so a pattern noticed at 03:00 is only
seen if he goes looking.

This wires the tick's proposals into the existing care-delivery path
rather than a second channel: sev1 nudge, loop.Gate, dispatcher, same
routing table as an accepted routine. Restraints, since a feature that
speaks unprompted is the easiest way to turn Maven into a nag:

  - off unless configured — the new pattern_proposals block, absent by
    default, and deploy/mavend.json ships notify: false;
  - at most one announcement per tick however many patterns surfaced;
  - at most one per cooldown (24h default) across all pairs;
  - sev1, so quiet hours, away and snooze suppress it;
  - suppressed means dropped, not queued — /routines still has it;
  - once per pair for good, since proposed_routines is
    UNIQUE(action, object) and the row survives dismissal.

The body is pattern.PhraseRoutine's literal Russian, not LLM-worded, so
an inferred routine cannot arrive describing something never observed.

Also raises pattern.MinEvents from 3 to 4 — the interval-quality item on
#43. Two intervals with a ±50% band is a coincidence with a mean, not a
pattern, and now that a scan of all history can announce itself the cost
of a false positive is a permanent dismissal of that pair.
2026-08-01 01:37:50 +04:00
kami c5317eb2b4 Move the quiet-toggle and pattern-extraction slices out of voice.go (#321)
Continues the decomposition PR #50 started. voice.go 542 -> 365:

  quiet_toggle.go  144  resolveQuietToggle, quietInflections, quietStem,
                        quietTokens, quietPhrase, quietOn/OffPhrases,
                        classifyQuietToggle  (quiet_toggle_test.go already
                        existed for these)
  patterns.go     +44  detectPattern, next to detectAndPropose which it calls
                        and which patterns.go's own header already pointed at

What is left in voice.go is the handler: reactiveHandler, HandlePushToTalk,
handleText, runTurn, applyAction, replySystem, chatHistory, reply.

Move-only: all 133 distinct non-blank lines removed from voice.go were
matched in the two destination files, zero lines added to voice.go. The only
non-move edits are import lists (log added to patterns.go, unicode and
internal/pattern dropped from voice.go) and two comments that pointed at
voice.go for code that is no longer there.
2026-08-01 01:30:06 +04:00
kami 9190f897a3 Add a locked-down maven.<domain> block to the nginx template (#354)
The template's wildcard `listen 80` with no ACL was fixed in 50cc17f, but it
still only covered nexus/praxis/hexis. mavweb — the one service in the set
that serves an RCE surface (POST /tools defines argv internal/tool executes)
— had no block at all, so anyone wiring it up wrote their own, which is how
the wildcard got there the first time.

Adds a maven.kvmx.ru server with the same wg+LAN bind and allow/deny,
proxying 127.0.0.1:9201, with the WebSocket upgrade /ws needs, a 32m body
limit for push-to-talk PCM, and a 300s read timeout because an LLM turn on
the iGPU is slow.

Also records in deploy/ecosystem/docker-compose.yml that the sibling
`build:` paths pin nothing and ship the sibling working tree, with the
command to check what is about to be deployed. The stale public DNS records
(item 2) are outside the repo.

Verified: nginx -t on the template inside a minimal http{} accepts it.
2026-08-01 01:27:11 +04:00
62 changed files with 6400 additions and 544 deletions
+75 -144
View File
@@ -1,17 +1,24 @@
// mavcaldav — the CalDAV poller module.
// mavcaldav — the CalDAV module: reads calendars into facts, and renders
// maven's own reminders back out to a calendar she owns.
//
// Polls a Radicale (or any CalDAV) server for today's events and writes
// `facts (kind=env, source=poll:caldav)` through core's IPC socket.
// Key-free, restart-free, fail-independent — crashes can't touch the
// store key, worst case a stale calendar_busy fact until the next poll.
// READ side (unchanged behaviour): polls a Radicale (or any CalDAV) server for
// today's events and writes `facts (kind=env, source=poll:caldav)` through
// core's IPC socket. Key-free, restart-free, fail-independent — crashes can't
// touch the store key, worst case a stale calendar_busy fact until the next
// poll. Two facts:
//
// Two facts written:
// - calendar_busy ("true"/"false") — read by the loop gate to suppress
// nudges during meetings
// - calendar_event ("<summary> @ <start>-<end>") — per-event for query
//
// Append-only discipline: a fact is written only when its value CHANGED
// vs the latest for that key+source.
// Append-only discipline: a fact is written only when its value CHANGED vs the
// latest for that key+source.
//
// RENDER side (Vikunja #127, off unless -render-url is given): publishes each
// pending reminder as a single-event iCal resource in a collection maven owns.
// The calendar is a view, sqlite is the store — see render.go. The render URL
// must differ from the read URL, checked at startup, so the render target can
// never be a calendar maven is only supposed to read.
package main
import (
@@ -27,6 +34,7 @@ import (
"syscall"
"time"
"github.com/kami/maven/internal/calendar"
"github.com/kami/maven/internal/ipc"
)
@@ -43,6 +51,10 @@ func run(args []string) error {
url := fs.String("url", "", "CalDAV calendar URL, e.g. http://localhost:5232/kami/personal (required)")
user := fs.String("user", "", "CalDAV basic-auth username (required)")
pass := fs.String("pass", "", "CalDAV basic-auth password (required)")
renderURL := fs.String("render-url", "", "CalDAV collection maven publishes her own reminders to; empty disables rendering")
renderUser := fs.String("render-user", "", "basic-auth username for -render-url (defaults to -user)")
renderPass := fs.String("render-pass", "", "basic-auth password for -render-url (defaults to -pass)")
renderDur := fs.Duration("render-duration", calendar.DefaultReminderDuration, "how long a rendered reminder occupies")
interval := fs.Duration("interval", 5*time.Minute, "poll cadence")
timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout")
if err := fs.Parse(args); err != nil {
@@ -54,6 +66,9 @@ func run(args []string) error {
if *url == "" || *user == "" || *pass == "" {
return fmt.Errorf("-url, -user, -pass are required")
}
if err := checkRenderTarget(*url, *renderURL); err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
@@ -64,16 +79,36 @@ func run(args []string) error {
}
defer core.Close()
hc := &http.Client{Timeout: *timeout}
p := &poller{
core: core,
http: &http.Client{Timeout: *timeout},
http: hc,
url: strings.TrimRight(*url, "/"),
user: *user,
pass: *pass,
}
var rend *renderer
if *renderURL != "" {
ru, rp := *renderUser, *renderPass
if ru == "" {
ru = *user
}
if rp == "" {
rp = *pass
}
rend = newRenderer(core, hc, *renderURL, ru, rp, *renderDur)
log.Printf("mavcaldav: rendering reminders to %s", *renderURL)
}
log.Printf("mavcaldav: polling %s every %s", *url, *interval)
p.pollOnce(ctx) // fire immediately
tick := func() {
p.pollOnce(ctx)
if rend != nil {
rend.renderOnce(ctx)
}
}
tick() // fire immediately
t := time.NewTicker(*interval)
defer t.Stop()
for {
@@ -82,11 +117,30 @@ func run(args []string) error {
log.Printf("mavcaldav: bye")
return nil
case <-t.C:
p.pollOnce(ctx)
tick()
}
}
}
// checkRenderTarget refuses a render URL that is also a read URL. This is the
// structural half of #127's "cannot write to your work calendar": the write
// credential and the write URL are separate flags, and the one calendar maven
// is known to only read is rejected as a target at startup rather than trusted
// at runtime.
func checkRenderTarget(readURL, renderURL string) error {
if renderURL == "" {
return nil
}
if sameCollection(readURL, renderURL) {
return fmt.Errorf("-render-url must differ from -url: maven renders into a calendar she owns, never into one she reads")
}
return nil
}
func sameCollection(a, b string) bool {
return strings.EqualFold(strings.TrimRight(a, "/"), strings.TrimRight(b, "/"))
}
type poller struct {
core ipc.CoreAPI
http *http.Client
@@ -95,12 +149,6 @@ type poller struct {
pass string
}
type icalEvent struct {
start time.Time
end time.Time
summary string
}
func (p *poller) pollOnce(ctx context.Context) {
now := time.Now()
events, err := p.fetchEvents(ctx, now)
@@ -109,38 +157,30 @@ func (p *poller) pollOnce(ctx context.Context) {
return
}
busy := false
for _, e := range events {
if !now.Before(e.start) && now.Before(e.end) {
busy = true
break
}
}
busyVal := "false"
if busy {
if calendar.Busy(events, now) {
busyVal = "true"
}
// Write calendar_busy on change.
if err := p.writeIfChanged(ctx, "calendar_busy", "poll:caldav", busyVal, now); err != nil {
if err := p.writeIfChanged(ctx, "calendar_busy", calendar.SourcePersonal, busyVal, now, 1.0); err != nil {
log.Printf("mavcaldav: write calendar_busy: %v", err)
return
}
// Write per-event facts (one per event, keyed by event summary + start).
// Write per-event facts (one per event, keyed by day + event summary).
// This lets the note RAG path answer "what's on my calendar" without
// reaching back to Radicale.
for _, e := range events {
val := fmt.Sprintf("%s @ %s-%s", e.summary, e.start.Format("15:04"), e.end.Format("15:04"))
eventKey := fmt.Sprintf("calendar_event_%s_%s", e.start.Format("20060102"), safeKey(e.summary))
if err := p.writeIfChanged(ctx, eventKey, "poll:caldav", val, e.start); err != nil {
log.Printf("mavcaldav: write %s: %v", eventKey, err)
key := calendar.FactKey(e)
if err := p.writeIfChanged(ctx, key, calendar.SourcePersonal, calendar.FactValue(e), e.Start, 1.0); err != nil {
log.Printf("mavcaldav: write %s: %v", key, err)
}
}
}
// fetchEvents GETs the calendar URL and parses VEVENTs from the iCal response.
func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]icalEvent, error) {
func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Event, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
if err != nil {
return nil, err
@@ -162,120 +202,11 @@ func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]icalEvent, e
return nil, fmt.Errorf("GET %s: %s", p.url, resp.Status)
}
return parseICal(body, now), nil
}
// parseICal scans iCal text for VEVENT components. Returns events that overlap
// with today (UTC day boundaries) to keep the response manageable.
func parseICal(body []byte, now time.Time) []icalEvent {
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
todayEnd := todayStart.AddDate(0, 0, 1)
var events []icalEvent
text := string(body)
for {
veventStart := strings.Index(text, "BEGIN:VEVENT")
if veventStart < 0 {
break
}
text = text[veventStart+len("BEGIN:VEVENT"):]
veventEnd := strings.Index(text, "END:VEVENT")
if veventEnd < 0 {
break
}
block := text[:veventEnd]
text = text[veventEnd+len("END:VEVENT"):]
e := parseVEVENT(block)
if e == nil {
continue
}
// Only keep events overlapping today.
if e.end.After(todayStart) && e.start.Before(todayEnd) {
events = append(events, *e)
}
}
return events
}
// parseVEVENT extracts start, end, summary from a VEVENT block.
// Supports both UTC (DTEND:20260703T100000Z) and local (DTSTART;TZID=...:...)
// formats. Returns nil for all-day events (no DTSTART/DTEND time component) or
// parse failures.
func parseVEVENT(block string) *icalEvent {
var e icalEvent
lines := strings.Split(block, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "DTSTART"):
if t, ok := parseDT(line); ok {
e.start = t
}
case strings.HasPrefix(line, "DTEND"):
if t, ok := parseDT(line); ok {
e.end = t
}
case strings.HasPrefix(line, "SUMMARY"):
if idx := strings.Index(line, ":"); idx >= 0 {
e.summary = strings.TrimSpace(line[idx+1:])
}
}
}
if e.start.IsZero() || e.end.IsZero() {
return nil
}
return &e
}
// parseDT parses a DTSTART/DTEND value. Supports:
// - UTC: DTEND:20260703T100000Z
// - Local: DTSTART;TZID=Europe/Moscow:20260703T130000
// - Value-date (all-day): DTSTART;VALUE=DATE:20260703 (returns zero time)
func parseDT(line string) (time.Time, bool) {
if strings.Contains(line, "VALUE=DATE:") {
return time.Time{}, false // all-day, skip
}
idx := strings.LastIndex(line, ":")
if idx < 0 {
return time.Time{}, false
}
val := line[idx+1:]
val = strings.TrimSuffix(val, "Z")
// Try UTC first (has Z suffix, or ended in Z before TrimSuffix).
if strings.HasSuffix(line, "Z") {
t, err := time.Parse("20060102T150405", val)
if err != nil {
return time.Time{}, false
}
return t.UTC(), true
}
// Local time — treat as UTC for simplicity (CalDAV server and poller
// run in the same timezone; the gate only needs busy/not-busy accuracy).
t, err := time.Parse("20060102T150405", val)
if err != nil {
return time.Time{}, false
}
return t.UTC(), true
}
// safeKey makes an event summary safe to use as a fact key (alphanumeric + dash).
func safeKey(s string) string {
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' {
b.WriteRune(r)
} else if r == ' ' || r == '_' {
b.WriteRune('-')
}
}
return b.String()
return calendar.ParseICalDay(body, now), nil
}
// writeIfChanged writes a fact only when the value differs from the latest.
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time) error {
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time, confidence float64) error {
prev, err := p.core.LatestFactBySource(ctx, key, source)
switch {
case err == nil && prev.Value == val:
@@ -289,7 +220,7 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts
Key: key,
Value: val,
Source: source,
Confidence: 1.0,
Confidence: confidence,
})
if err != nil {
return fmt.Errorf("write %s: %w", key, err)
+5 -164
View File
@@ -51,165 +51,6 @@ func (f *fakeCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, er
return int64(len(f.writeLog)), nil
}
// ---------------------------------------------------------------------------
// Parsing tests
// ---------------------------------------------------------------------------
func TestParseICal(t *testing.T) {
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
body := []byte(`BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART:20260703T090000Z
DTEND:20260703T100000Z
SUMMARY:Morning standup
END:VEVENT
BEGIN:VEVENT
DTSTART:20260703T140000Z
DTEND:20260703T150000Z
SUMMARY:Team sync
END:VEVENT
BEGIN:VEVENT
DTSTART:20260702T140000Z
DTEND:20260702T150000Z
SUMMARY:Yesterday retro
END:VEVENT
BEGIN:VEVENT
DTSTART:20260704T090000Z
DTEND:20260704T100000Z
SUMMARY:Tomorrow standup
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20260704
DTEND;VALUE=DATE:20260705
SUMMARY:All-day event
END:VEVENT
END:VCALENDAR`)
events := parseICal(body, now)
if len(events) != 2 {
t.Fatalf("got %d events, want 2 (today events, no all-day/past/future)", len(events))
}
// Morning standup — overlaps today.
if events[0].summary != "Morning standup" {
t.Errorf("events[0].summary = %q, want %q", events[0].summary, "Morning standup")
}
wantStart0 := time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC)
if !events[0].start.Equal(wantStart0) {
t.Errorf("events[0].start = %v, want %v", events[0].start, wantStart0)
}
wantEnd0 := time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC)
if !events[0].end.Equal(wantEnd0) {
t.Errorf("events[0].end = %v, want %v", events[0].end, wantEnd0)
}
// Team sync — overlaps today.
if events[1].summary != "Team sync" {
t.Errorf("events[1].summary = %q, want %q", events[1].summary, "Team sync")
}
wantStart1 := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)
if !events[1].start.Equal(wantStart1) {
t.Errorf("events[1].start = %v, want %v", events[1].start, wantStart1)
}
wantEnd1 := time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC)
if !events[1].end.Equal(wantEnd1) {
t.Errorf("events[1].end = %v, want %v", events[1].end, wantEnd1)
}
}
func TestParseVEVENT(t *testing.T) {
// Normal event with TZID in DTSTART and UTC DTEND.
block := "DTSTART;TZID=Europe/Moscow:20260703T130000\nDTEND:20260703T140000Z\nSUMMARY:Stand up meeting"
e := parseVEVENT(block)
if e == nil {
t.Fatal("expected non-nil icalEvent")
}
wantStart := time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)
if !e.start.Equal(wantStart) {
t.Errorf("start = %v, want %v", e.start, wantStart)
}
wantEnd := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)
if !e.end.Equal(wantEnd) {
t.Errorf("end = %v, want %v", e.end, wantEnd)
}
if e.summary != "Stand up meeting" {
t.Errorf("summary = %q, want %q", e.summary, "Stand up meeting")
}
// All-day event (VALUE=DATE) → nil.
allDay := "DTSTART;VALUE=DATE:20260703\nDTEND;VALUE=DATE:20260704\nSUMMARY:All-day"
if e2 := parseVEVENT(allDay); e2 != nil {
t.Error("expected nil for all-day event")
}
}
func TestParseDT(t *testing.T) {
tests := []struct {
name string
line string
want time.Time
wantOK bool
}{
{
name: "UTC",
line: "DTEND:20260703T100000Z",
want: time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC),
wantOK: true,
},
{
name: "local time",
line: "DTSTART;TZID=Europe/Moscow:20260703T130000",
want: time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC),
wantOK: true,
},
{
name: "all-day",
line: "DTSTART;VALUE=DATE:20260703",
want: time.Time{},
wantOK: false,
},
{
name: "invalid",
line: "DTSTART:garbage",
want: time.Time{},
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := parseDT(tt.line)
if ok != tt.wantOK {
t.Errorf("ok = %v, want %v", ok, tt.wantOK)
}
if !got.Equal(tt.want) {
t.Errorf("got = %v, want %v", got, tt.want)
}
})
}
}
func TestSafeKey(t *testing.T) {
tests := []struct {
input string
want string
}{
{"Stand up meeting", "Stand-up-meeting"},
{"Hello_World", "Hello-World"},
{"special@#$chars!!", "specialchars"},
{"ALL_CAPS_123", "ALL-CAPS-123"},
}
for _, tt := range tests {
got := safeKey(tt.input)
if got != tt.want {
t.Errorf("safeKey(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
// ---------------------------------------------------------------------------
// Core logic tests
// ---------------------------------------------------------------------------
@@ -221,7 +62,7 @@ func TestWriteIfChanged(t *testing.T) {
t.Run("no previous fact writes", func(t *testing.T) {
fc := &fakeCore{}
p := &poller{core: fc}
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -249,7 +90,7 @@ func TestWriteIfChanged(t *testing.T) {
},
}
p := &poller{core: fc}
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -265,7 +106,7 @@ func TestWriteIfChanged(t *testing.T) {
},
}
p := &poller{core: fc}
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now)
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now, 1.0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -280,7 +121,7 @@ func TestWriteIfChanged(t *testing.T) {
t.Run("read error other than ErrNoFact returns error", func(t *testing.T) {
fc := &fakeCore{readErr: fmt.Errorf("connection refused")}
p := &poller{core: fc}
err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now)
err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now, 1.0)
if err == nil {
t.Fatal("expected error, got nil")
}
@@ -292,7 +133,7 @@ func TestWriteIfChanged(t *testing.T) {
writeErr: fmt.Errorf("disk full"),
}
p := &poller{core: fc}
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
if err == nil {
t.Fatal("expected error, got nil")
}
+145
View File
@@ -0,0 +1,145 @@
package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/kami/maven/internal/calendar"
"github.com/kami/maven/internal/ipc"
)
// renderer is the write half of maven's own local calendar (Vikunja #127).
//
// It is a RENDER TARGET, not a store. sqlite stays canonical: every tick the
// renderer reads the pending reminders out of core and publishes each one as a
// single-event iCal resource in a CalDAV collection maven owns. Nothing is ever
// read back from that collection, and losing it costs nothing — the next tick
// rebuilds it.
//
// It structurally cannot write to a calendar maven only reads. The URL comes
// from its own flag, checked at startup against every read URL (see
// run in main.go), and the only paths it ever addresses carry
// calendar.ReminderUIDPrefix — so even pointed at the wrong collection it can
// only touch resources it created.
type renderer struct {
core ipc.CoreAPI
http *http.Client
url string
user string
pass string
dur time.Duration
// published maps reminder id → the body last successfully PUT, so an
// unchanged reminder costs nothing. Purely an optimisation: a restart
// re-publishes every reminder once, which is idempotent.
published map[int64]string
}
func newRenderer(core ipc.CoreAPI, hc *http.Client, url, user, pass string, dur time.Duration) *renderer {
return &renderer{
core: core,
http: hc,
url: strings.TrimRight(url, "/"),
user: user,
pass: pass,
dur: dur,
published: make(map[int64]string),
}
}
// renderOnce publishes every pending reminder and withdraws the ones that are
// no longer pending. Errors are logged and skipped: a calendar maven cannot
// reach must never break the reminder itself, which lives in sqlite.
func (r *renderer) renderOnce(ctx context.Context) {
reminders, err := r.core.ListReminders(ctx, renderMaxReminders)
if err != nil {
log.Printf("mavcaldav: list reminders: %v", err)
return
}
live := make(map[int64]bool, len(reminders))
for _, rem := range reminders {
if rem.Status != "pending" {
continue
}
live[rem.ID] = true
e := calendar.ReminderEvent(rem.ID, fireTime(rem), rem.Payload, r.dur)
body := calendar.RenderICal([]calendar.Event{e})
if r.published[rem.ID] == body {
continue
}
if err := r.put(ctx, calendar.ReminderPath(rem.ID), body); err != nil {
log.Printf("mavcaldav: render reminder %d: %v", rem.ID, err)
continue
}
r.published[rem.ID] = body
log.Printf("mavcaldav: rendered reminder %d (%s)", rem.ID, e.Summary)
}
for id := range r.published {
if live[id] {
continue
}
if err := r.delete(ctx, calendar.ReminderPath(id)); err != nil {
log.Printf("mavcaldav: withdraw reminder %d: %v", id, err)
continue
}
delete(r.published, id)
log.Printf("mavcaldav: withdrew reminder %d", id)
}
}
// renderMaxReminders bounds the read. Reminders past this count are older than
// anything a calendar view is useful for.
const renderMaxReminders = 200
// fireTime prefers NextFireTs — for a recurring reminder that is the occurrence
// worth showing; FireTs is the original statement.
func fireTime(rem ipc.Reminder) time.Time {
if !rem.NextFireTs.IsZero() {
return rem.NextFireTs
}
return rem.FireTs
}
func (r *renderer) put(ctx context.Context, name, body string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, r.url+"/"+name, strings.NewReader(body))
if err != nil {
return err
}
req.SetBasicAuth(r.user, r.pass)
req.Header.Set("Content-Type", "text/calendar; charset=utf-8")
return r.do(req, name)
}
func (r *renderer) delete(ctx context.Context, name string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, r.url+"/"+name, nil)
if err != nil {
return err
}
req.SetBasicAuth(r.user, r.pass)
return r.do(req, name)
}
// do runs the request and treats any 2xx, plus 404 on a DELETE, as success —
// a resource that is already gone is the state the caller wanted.
func (r *renderer) do(req *http.Request, name string) error {
resp, err := r.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300:
return nil
case req.Method == http.MethodDelete && resp.StatusCode == http.StatusNotFound:
return nil
}
return fmt.Errorf("%s %s: %s", req.Method, name, resp.Status)
}
+186
View File
@@ -0,0 +1,186 @@
package main
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
)
// reminderCore is a fakeCore that also answers ListReminders.
type reminderCore struct {
fakeCore
reminders []ipc.Reminder
listErr error
}
func (c *reminderCore) ListReminders(context.Context, int) ([]ipc.Reminder, error) {
if c.listErr != nil {
return nil, c.listErr
}
return c.reminders, nil
}
// calSrv records what a CalDAV collection received.
type calSrv struct {
mu sync.Mutex
puts map[string]string
dels []string
status int
*httptest.Server
}
func newCalSrv() *calSrv {
s := &calSrv{puts: map[string]string{}, status: http.StatusCreated}
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
s.mu.Lock()
defer s.mu.Unlock()
switch r.Method {
case http.MethodPut:
s.puts[strings.TrimPrefix(r.URL.Path, "/cal/")] = string(body)
case http.MethodDelete:
s.dels = append(s.dels, strings.TrimPrefix(r.URL.Path, "/cal/"))
}
w.WriteHeader(s.status)
}))
return s
}
func (s *calSrv) putCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.puts)
}
func TestRenderOncePublishesPendingReminders(t *testing.T) {
fire := time.Date(2026, 8, 1, 18, 30, 0, 0, time.UTC)
srv := newCalSrv()
defer srv.Close()
core := &reminderCore{reminders: []ipc.Reminder{
{ID: 7, FireTs: fire, Payload: "позвонить маме", Status: "pending"},
{ID: 8, FireTs: fire, Payload: "уже сделано", Status: "fired"},
{ID: 9, FireTs: fire, Payload: "отменено", Status: "cancelled"},
}}
r := newRenderer(core, srv.Client(), srv.URL+"/cal/", "u", "p", 0)
r.renderOnce(context.Background())
srv.mu.Lock()
body, ok := srv.puts["maven-reminder-7.ics"]
n := len(srv.puts)
srv.mu.Unlock()
if n != 1 {
t.Fatalf("expected exactly the pending reminder to be published, got %d PUTs", n)
}
if !ok {
t.Fatal("pending reminder 7 was not published")
}
if !strings.Contains(body, "SUMMARY:позвонить маме") {
t.Errorf("payload missing from rendered body:\n%s", body)
}
if !strings.Contains(body, "UID:maven-reminder-7") {
t.Errorf("UID missing from rendered body:\n%s", body)
}
}
func TestRenderOnceSkipsUnchanged(t *testing.T) {
srv := newCalSrv()
defer srv.Close()
core := &reminderCore{reminders: []ipc.Reminder{
{ID: 1, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "выпить воды", Status: "pending"},
}}
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
r.renderOnce(context.Background())
r.renderOnce(context.Background())
if got := srv.putCount(); got != 1 {
t.Fatalf("an unchanged reminder was re-published: %d distinct PUTs", got)
}
}
func TestRenderOnceWithdrawsResolvedReminders(t *testing.T) {
srv := newCalSrv()
defer srv.Close()
core := &reminderCore{reminders: []ipc.Reminder{
{ID: 5, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "встреча", Status: "pending"},
}}
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
r.renderOnce(context.Background())
core.reminders[0].Status = "fired"
r.renderOnce(context.Background())
srv.mu.Lock()
dels := append([]string(nil), srv.dels...)
srv.mu.Unlock()
if len(dels) != 1 || dels[0] != "maven-reminder-5.ics" {
t.Fatalf("resolved reminder was not withdrawn: %v", dels)
}
if len(r.published) != 0 {
t.Errorf("published map still holds %v", r.published)
}
}
// A calendar maven cannot reach must never break anything: sqlite is canonical.
func TestRenderOnceSurvivesServerErrors(t *testing.T) {
srv := newCalSrv()
srv.status = http.StatusInternalServerError
defer srv.Close()
core := &reminderCore{reminders: []ipc.Reminder{
{ID: 1, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "x", Status: "pending"},
}}
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
r.renderOnce(context.Background())
if len(r.published) != 0 {
t.Error("a failed PUT must not be recorded as published, or it never retries")
}
}
func TestRenderOnceUsesNextFireForRecurring(t *testing.T) {
srv := newCalSrv()
defer srv.Close()
next := time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC)
core := &reminderCore{reminders: []ipc.Reminder{{
ID: 3,
FireTs: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
NextFireTs: next,
Payload: "зарядка",
Status: "pending",
Cron: "0 7 * * *",
}}}
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
r.renderOnce(context.Background())
srv.mu.Lock()
body := srv.puts["maven-reminder-3.ics"]
srv.mu.Unlock()
if !strings.Contains(body, "DTSTART:20260802T070000Z") {
t.Errorf("recurring reminder should render its next occurrence:\n%s", body)
}
}
func TestCheckRenderTargetRefusesTheCalendarItReads(t *testing.T) {
read := "http://localhost:5232/kami/personal"
if err := checkRenderTarget(read, ""); err != nil {
t.Fatalf("rendering off must be fine: %v", err)
}
if err := checkRenderTarget(read, "http://localhost:5232/kami/maven"); err != nil {
t.Fatalf("a distinct collection must be accepted: %v", err)
}
if err := checkRenderTarget(read, read); err == nil {
t.Error("rendering into the read calendar must be refused")
}
if err := checkRenderTarget(read, read+"/"); err == nil {
t.Error("a trailing slash must not defeat the check")
}
if err := checkRenderTarget(read, strings.ToUpper(read)); err == nil {
t.Error("case must not defeat the check")
}
}
+6
View File
@@ -11,6 +11,12 @@ import (
// actionNote handles router.IntentNote: embed the note, persist it, and
// index it for recall.
func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) string {
// An utterance that explicitly files a task is work, not recall, and
// belongs in the task store (Vikunja #130). Checked before the embedding
// is paid for. Everything else is a note, exactly as before.
if reply, ok := h.captureTaskFromNote(ctx, dec); ok {
return reply
}
// embed the note text with the same model the classifier uses, persist
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
// facts — no predicate reads it (spec's two-memory split).
+92 -3
View File
@@ -5,10 +5,12 @@ import (
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/morning"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/weather"
)
@@ -45,6 +47,21 @@ type querySource struct {
// line here plus its method; where you put the line is the whole decision.
var querySources = []querySource{
{"fact-by-key", (*reactiveHandler).queryFactByKey},
// Before "calendar" on purpose: both match "…на сегодня", and the plan is
// the more specific ask (its matcher requires a plan word), so the calendar
// listing would otherwise swallow it.
{"day-plan", (*reactiveHandler).queryDayPlan},
// Also before "calendar": "что я обычно делаю по средам?" names a weekday,
// and the habit question is the more specific one. Its matcher requires a
// habit marker ("обычно", "каждый", …), so a question about this coming
// Wednesday still reaches the calendar.
{"habits", (*reactiveHandler).queryHabits},
// Before "calendar" and before the recall sources: "что мне нужно
// сделать?" is a question about the task list, and the notes pass would
// otherwise answer it with whatever note happens to be nearest. Its
// matcher requires a task noun or an explicit "что … сделать", so a
// date-bearing question still reaches the calendar.
{"tasks", (*reactiveHandler).queryTasks},
{"calendar", (*reactiveHandler).queryCalendar},
{"weather", (*reactiveHandler).queryWeather},
{"embed", (*reactiveHandler).queryEmbed},
@@ -89,6 +106,75 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
return "", false
}
// queryDayPlan — "какие планы на сегодня?", "что у меня по плану?", "что
// дальше?" (Vikunja #128). Recites the day: calendar events, pending
// reminders, and any morning checklist still outstanding.
//
// Read-only by construction — the plan is assembled and rendered core-side and
// nothing here schedules or announces. "что дальше?" asks for the rest of the
// day, so that phrasing trims what has already passed.
func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (string, bool) {
if !router.IsDayPlanQuery(t.dec.Utterance) {
return "", false
}
plan, err := h.api.DayPlan(ctx)
if err != nil {
log.Printf("voice: day plan: %v", err)
return "не получилось собрать план.", true
}
if !isRestOfDayQuery(t.dec.Utterance) {
return plan.Spoken, true
}
// Rebuild the pure plan so the rest-of-day rendering is the same code that
// rendered the whole day — one formatter, one persona.
p := morning.Plan{Date: plan.Date}
for _, it := range plan.Items {
p.Items = append(p.Items, morning.PlanEntry{
At: it.At,
Text: it.Text,
Kind: morning.PlanKind(it.Kind),
Uncertain: it.Uncertain,
})
}
return p.After(h.now()).FormatRU(), true
}
// isRestOfDayQuery — "что дальше?" and its English form, the only plan phrasing
// that means "from now on" rather than "the whole day".
func isRestOfDayQuery(text string) bool {
s := strings.ToLower(text)
return strings.Contains(s, "дальше") || strings.Contains(s, "next")
}
// habitFactWindow — how many recent facts the behaviour profile is counted
// over. Enough for a season of habits without scanning the whole store on every
// question; the profile is recomputed on read, so the bound is the cost control.
const habitFactWindow = 2000
// queryHabits — "что я обычно делаю по вторникам?" (Vikunja #254). Counts the
// answer out of the fact log rather than asking the model to summarise a life:
// see internal/memory/behavior.go for why nothing here is generated.
func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string, bool) {
q, ok := router.ParseHabitQuery(t.dec.Utterance)
if !ok {
return "", false
}
facts, err := h.api.RecentFacts(ctx, habitFactWindow)
if err != nil {
log.Printf("voice: habits: recent facts: %v", err)
return "не получилось посмотреть записи.", true
}
obs := make([]memory.Observation, 0, len(facts))
for _, f := range facts {
obs = append(obs, memory.Observation{At: f.Ts, Key: f.Key, Kind: f.Kind})
}
profile := memory.BuildProfile(obs, h.now())
if q.HasWeekday {
return profile.FormatWeekdayRU(q.Weekday), true
}
return profile.FormatOverallRU(), true
}
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
// h.now(), not time.Now(): the handler's clock is the injected one, so this
// source can be tested at a fixed time like the rest.
@@ -102,12 +188,15 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
log.Printf("voice: calendar events: %v", err)
return "не получилось проверить календарь.", true
}
values := make([]string, len(events))
// Provenance travels with each event. A work meeting relayed off a phone
// notification (source ambient:notif, #126) is stored below full confidence
// and gets hedged; a CalDAV read is recited plainly.
entries := make([]router.CalendarEntry, len(events))
for i, e := range events {
values[i] = e.Value
entries[i] = router.CalendarEntry{Text: e.Value, Uncertain: e.Confidence < 1.0}
}
var f router.CalendarEventFormatter
return f.Format(values, date), true
return f.FormatEntries(entries, date), true
}
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"context"
"log"
"strings"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
// Task capture on the voice/chat path (Vikunja #130).
//
// Two halves, both deliberately small:
//
// - captureTaskFromNote runs at the top of actionNote. An utterance that
// explicitly files a task ("добавь в задачи купить молоко") goes to the task
// store instead of the note store. Anything without an explicit marker is
// still a note — see router.ParseTaskCapture for why "надо бы поспать" must
// not become a task.
// - queryTasks is a query source that reads the list back.
//
// Nothing here speaks unprompted. Tasks are answered when asked about; no tick
// rule reads the table.
// captureTaskFromNote claims the turn when the utterance explicitly files a
// task, returning the reply. ("", false) hands the turn back to the note path.
func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.Decision) (string, bool) {
text, ok := router.ParseTaskCapture(dec.Utterance)
if !ok {
return "", false
}
resp, err := h.api.CaptureTask(ctx, ipc.CaptureTaskReq{
Text: text,
Source: "tap:voice",
Status: store.TaskOpen, // he stated it himself — not a candidate
Ts: h.now(),
})
if err != nil {
log.Printf("voice: capture task: %v", err)
return "не получилось записать задачу.", true
}
if !resp.Created {
return "это уже в списке.", true
}
return "записала: " + text, true
}
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
//
// Reads the live set and recites it. Newest first, which is the order the store
// returns: this source has no opinion about which task matters more, and
// pretending otherwise would be a guess. Ranking is Vikunja #129.
func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string, bool) {
if !router.IsTaskListQuery(t.dec.Utterance) {
return "", false
}
tasks, err := h.api.ListTasks(ctx, "live")
if err != nil {
log.Printf("voice: list tasks: %v", err)
return "не получилось посмотреть задачи.", true
}
return formatTaskListRU(tasks), true
}
// formatTaskListRU renders the live task list the way Maven says it. Candidates
// are named as candidates — a task she pulled out of his mail is something she
// suggests, and saying it in the same breath as work he actually stated would
// put words in his mouth.
func formatTaskListRU(tasks []ipc.Task) string {
var open, cands []string
for _, t := range tasks {
switch t.Status {
case store.TaskCandidate:
cands = append(cands, t.Text)
default:
open = append(open, t.Text)
}
}
if len(open) == 0 && len(cands) == 0 {
return "задач нет."
}
var b strings.Builder
if len(open) > 0 {
b.WriteString("в списке: ")
b.WriteString(strings.Join(open, "; "))
b.WriteString(".")
}
if len(cands) > 0 {
if b.Len() > 0 {
b.WriteString(" ")
}
b.WriteString("ещё я нашла, но ты не подтвердил: ")
b.WriteString(strings.Join(cands, "; "))
b.WriteString(".")
}
return b.String()
}
+188
View File
@@ -0,0 +1,188 @@
package main
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
)
// taskAPI answers only the three task methods; every other call is
// unimplemented, which is the assertion that capture needs nothing else — in
// particular no embedder, so a filed task costs no model call.
type taskAPI struct {
ipc.UnimplementedCoreAPI
captured []ipc.CaptureTaskReq
created bool
capErr error
tasks []ipc.Task
listArg string
listErr error
}
func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
a.captured = append(a.captured, req)
if a.capErr != nil {
return ipc.CaptureTaskResp{}, a.capErr
}
return ipc.CaptureTaskResp{ID: 1, Created: a.created}, nil
}
func (a *taskAPI) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
a.listArg = status
return a.tasks, a.listErr
}
func taskNow() time.Time { return time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) }
func taskHandler(api ipc.CoreAPI) *reactiveHandler {
return &reactiveHandler{api: api, now: taskNow}
}
func TestCaptureTaskFromNoteFilesTheTask(t *testing.T) {
api := &taskAPI{created: true}
h := taskHandler(api)
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{
Intent: router.IntentNote, Utterance: "добавь в задачи купить молоко",
})
if !ok {
t.Fatal("an explicit capture must claim the turn")
}
if len(api.captured) != 1 {
t.Fatalf("captured %d, want 1", len(api.captured))
}
got := api.captured[0]
if got.Text != "купить молоко" {
t.Errorf("text = %q, want the marker stripped", got.Text)
}
if got.Source != "tap:voice" {
t.Errorf("source = %q, want tap:voice", got.Source)
}
if got.Status != "open" {
t.Errorf("status = %q — work he stated is open, never a candidate", got.Status)
}
if !got.Ts.Equal(taskNow()) {
t.Errorf("ts = %v, want the handler clock", got.Ts)
}
if !strings.Contains(reply, "купить молоко") {
t.Errorf("reply = %q, want it to read the task back", reply)
}
}
// A note is still a note: capture only fires on an explicit marker, so
// ordinary recall is untouched.
func TestCaptureTaskFromNotePassesOrdinaryNotes(t *testing.T) {
api := &taskAPI{}
h := taskHandler(api)
for _, u := range []string{"надо бы поспать", "мне понравился этот фильм", "запиши что я пил воду"} {
if _, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: u}); ok {
t.Errorf("%q was captured as a task", u)
}
}
if len(api.captured) != 0 {
t.Errorf("captured %d requests, want none", len(api.captured))
}
}
func TestCaptureTaskFromNoteSaysAlreadyOnTheList(t *testing.T) {
h := taskHandler(&taskAPI{created: false})
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: "добавь в задачи купить молоко"})
if !ok {
t.Fatal("expected the capture path to claim it")
}
if !strings.Contains(reply, "уже") {
t.Errorf("reply = %q — a deduped capture must not claim it saved something new", reply)
}
}
func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) {
h := taskHandler(&taskAPI{capErr: errors.New("db is on fire")})
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: "добавь задачу починить кран"})
if !ok {
t.Fatal("a failed capture still claims the turn — the note path must not double-write")
}
if !strings.Contains(reply, "не получилось") {
t.Errorf("reply = %q, want an honest failure", reply)
}
}
func TestQueryTasksRecitesTheLiveList(t *testing.T) {
api := &taskAPI{tasks: []ipc.Task{
{ID: 1, Text: "купить молоко", Status: "open"},
{ID: 2, Text: "продлить страховку", Status: "candidate"},
}}
h := taskHandler(api)
reply, ok := h.queryTasks(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня задачи?"},
})
if !ok {
t.Fatal("the task source must claim a task-list question")
}
if api.listArg != "live" {
t.Errorf("ListTasks(%q), want \"live\" — a resolved task is not outstanding work", api.listArg)
}
if !strings.Contains(reply, "купить молоко") || !strings.Contains(reply, "продлить страховку") {
t.Errorf("reply = %q, want both tasks", reply)
}
// The candidate must be named as unconfirmed, not recited as his work.
openIdx := strings.Index(reply, "купить молоко")
candIdx := strings.Index(reply, "продлить страховку")
if !(openIdx < candIdx) {
t.Errorf("reply = %q, want confirmed work before candidates", reply)
}
if !strings.Contains(reply, "не подтвердил") {
t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply)
}
}
func TestQueryTasksEmptyList(t *testing.T) {
h := taskHandler(&taskAPI{})
reply, ok := h.queryTasks(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "что мне нужно сделать?"},
})
if !ok {
t.Fatal("expected the task source to claim it")
}
if reply != "задач нет." {
t.Errorf("reply = %q", reply)
}
}
func TestQueryTasksPassesOtherQuestions(t *testing.T) {
api := &taskAPI{}
h := taskHandler(api)
for _, u := range []string{"как дела?", "какая погода в москве?", "что у меня сегодня?"} {
if _, ok := h.queryTasks(context.Background(), &queryTurn{dec: router.Decision{Utterance: u}}); ok {
t.Errorf("the task source claimed %q", u)
}
}
if api.listArg != "" {
t.Error("a non-task question must not read the task list")
}
}
// The chain must reach the task source before the recall sources, or "что мне
// нужно сделать?" gets answered by whatever note is nearest.
func TestQuerySourcesOrderTasksBeforeRecall(t *testing.T) {
var tasksAt, notesAt = -1, -1
for i, src := range querySources {
switch src.name {
case "tasks":
tasksAt = i
case "notes":
notesAt = i
}
}
if tasksAt < 0 || notesAt < 0 {
t.Fatalf("sources missing: tasks=%d notes=%d", tasksAt, notesAt)
}
if tasksAt > notesAt {
t.Errorf("tasks source at %d, after notes at %d", tasksAt, notesAt)
}
}
+227
View File
@@ -0,0 +1,227 @@
package main
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
)
// planAPI answers only DayPlan; every other call is unimplemented, which is
// exactly the assertion that the plan source needs nothing else.
type planAPI struct {
ipc.UnimplementedCoreAPI
plan ipc.DayPlan
err error
calls int
}
func (a *planAPI) DayPlan(context.Context) (ipc.DayPlan, error) {
a.calls++
if a.err != nil {
return ipc.DayPlan{}, a.err
}
return a.plan, nil
}
func planDay() time.Time { return time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC) }
func samplePlan() ipc.DayPlan {
day := planDay()
mid := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC)
return ipc.DayPlan{
Date: mid,
Items: []ipc.DayPlanItem{
{At: day.Add(-2 * time.Hour), Text: "Standup @ 10:00-10:30", Kind: "event"},
{At: day.Add(2 * time.Hour), Text: "Планёрка @ 14:00-14:30", Kind: "event", Uncertain: true},
{At: day.Add(6 * time.Hour), Text: "позвонить маме", Kind: "reminder"},
},
Spoken: "план на 03.08.2026: 10:00 — Standup @ 10:00-10:30; " +
"похоже, 14:00 — Планёрка @ 14:00-14:30; 18:00 — позвонить маме.",
}
}
func planHandler(api ipc.CoreAPI) *reactiveHandler {
return &reactiveHandler{api: api, now: planDay}
}
func TestQueryDayPlanRecitesTheDay(t *testing.T) {
api := &planAPI{plan: samplePlan()}
h := planHandler(api)
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие планы на сегодня?"},
})
if !ok {
t.Fatal("the plan source must claim a plan question")
}
if reply != api.plan.Spoken {
t.Errorf("reply = %q, want the core's spoken plan %q", reply, api.plan.Spoken)
}
}
// "что дальше?" is the rest of the day, not the whole day: what has already
// happened is not a plan.
func TestQueryDayPlanTrimsToRestOfDay(t *testing.T) {
h := planHandler(&planAPI{plan: samplePlan()})
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
})
if !ok {
t.Fatal("expected the plan source to claim it")
}
if strings.Contains(reply, "Standup") {
t.Errorf("a passed item must not be read back: %q", reply)
}
if !strings.Contains(reply, "Планёрка") || !strings.Contains(reply, "позвонить маме") {
t.Errorf("the rest of the day is missing: %q", reply)
}
// Provenance survives the trim.
if !strings.Contains(reply, "похоже,") {
t.Errorf("a relayed event must stay hedged: %q", reply)
}
}
// A question that is not about the plan must fall through, or the plan buries
// the calendar listing and the weather behind it.
func TestQueryDayPlanPassesOnEverythingElse(t *testing.T) {
for _, q := range []string{
"что у меня сегодня?",
"какие планы на завтра?",
"когда планёрка?",
"какая погода?",
"",
} {
api := &planAPI{plan: samplePlan()}
reply, ok := planHandler(api).queryDayPlan(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
})
if ok {
t.Errorf("%q was claimed by the plan source (reply %q)", q, reply)
}
if api.calls != 0 {
t.Errorf("%q hit the core for a plan it does not want", q)
}
}
}
func TestQueryDayPlanCoreFailure(t *testing.T) {
h := planHandler(&planAPI{err: errors.New("socket closed")})
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "план на сегодня"},
})
if !ok {
t.Fatal("a failed plan read must still answer, not fall through to RAG")
}
if reply != "не получилось собрать план." {
t.Errorf("reply = %q", reply)
}
}
// The day plan must sit before the calendar listing: both match "…на сегодня",
// and the more specific matcher has to get first refusal (see #373 for what
// happens when the order is wrong).
func TestDayPlanSourcePrecedesCalendar(t *testing.T) {
plan, cal := -1, -1
for i, s := range querySources {
switch s.name {
case "day-plan":
plan = i
case "calendar":
cal = i
}
}
if plan < 0 || cal < 0 {
t.Fatalf("sources missing: day-plan=%d calendar=%d", plan, cal)
}
if plan > cal {
t.Errorf("day-plan at %d must come before calendar at %d", plan, cal)
}
}
// habitAPI answers only RecentFacts — the whole input the behaviour profile
// needs (Vikunja #254). Nothing is asked of the LLM, so nothing else is wired.
type habitAPI struct {
ipc.UnimplementedCoreAPI
facts []ipc.Fact
err error
calls int
}
func (a *habitAPI) RecentFacts(_ context.Context, _ int) ([]ipc.Fact, error) {
a.calls++
return a.facts, a.err
}
// tuesdayFacts — n weekly Tuesday rows for key, ending before now.
func tuesdayFacts(key string, hh, weeks int, now time.Time) []ipc.Fact {
d := now
for d.Weekday() != time.Tuesday {
d = d.AddDate(0, 0, -1)
}
var out []ipc.Fact
for i := 0; i < weeks; i++ {
day := d.AddDate(0, 0, -7*i)
out = append(out, ipc.Fact{
Ts: time.Date(day.Year(), day.Month(), day.Day(), hh, 0, 0, 0, now.Location()),
Kind: "self",
Key: key,
})
}
return out
}
func TestQueryHabitsAnswersFromCountedFacts(t *testing.T) {
now := planDay() // a Monday
api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)}
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
reply, ok := h.queryHabits(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что я обычно делаю по вторникам?"},
})
if !ok {
t.Fatal("the habit source must claim a habit question")
}
if want := "по вторникам ты обычно тренируешься около 19:00."; reply != want {
t.Errorf("reply = %q, want %q", reply, want)
}
}
func TestQueryHabitsPassesOnEverythingElse(t *testing.T) {
now := planDay()
for _, q := range []string{"что я делаю в среду?", "что у меня сегодня?", "какие планы на сегодня?", ""} {
api := &habitAPI{}
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
if reply, ok := h.queryHabits(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
}); ok {
t.Errorf("%q was claimed by the habit source (reply %q)", q, reply)
}
if api.calls != 0 {
t.Errorf("%q scanned the fact log for a profile it does not want", q)
}
}
}
// Both specific sources must precede the calendar listing, which matches any
// utterance naming a day.
func TestHabitSourcePrecedesCalendar(t *testing.T) {
habits, cal := -1, -1
for i, s := range querySources {
switch s.name {
case "habits":
habits = i
case "calendar":
cal = i
}
}
if habits < 0 || cal < 0 {
t.Fatalf("sources missing: habits=%d calendar=%d", habits, cal)
}
if habits > cal {
t.Errorf("habits at %d must come before calendar at %d", habits, cal)
}
}
+21 -2
View File
@@ -169,6 +169,7 @@ func run(args []string) error {
coreAPI ipc.CoreAPI
eco *ecosystemWiring
factWorker *factEnrichmentWorker
evalWorker *memoryEvalWorker // nil ⇒ memory evaluation off (the default)
)
if !locked {
@@ -261,13 +262,15 @@ func run(args []string) error {
tickInterval := time.Duration(cfg.TickInterval)
repeatInterval := time.Duration(cfg.RepeatInterval)
autotuneInterval := time.Duration(cfg.AutotuneInterval)
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines))
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
evalWorker = newMemoryEvalWorker(st, phr, cfg)
coreAPI = &daemonAPI{
CoreAPI: ipc.NewStoreAPI(st),
getTrace: tl.trace,
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()) },
}
if voiceW != nil && voiceW.handler != nil {
api := coreAPI.(*daemonAPI)
@@ -438,14 +441,16 @@ func run(args []string) error {
tickInterval := time.Duration(cfg.TickInterval)
repeatInterval := time.Duration(cfg.RepeatInterval)
autotuneInterval := time.Duration(cfg.AutotuneInterval)
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines))
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
evalWorker = newMemoryEvalWorker(st, phr, cfg)
// Swap the CoreAPI from the locked placeholder to the real store adapter.
newAPI := &daemonAPI{
CoreAPI: ipc.NewStoreAPI(st),
getTrace: tl.trace,
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()) },
}
if voiceW != nil && voiceW.handler != nil {
newAPI.chatFn = voiceW.handler.handleText
@@ -476,6 +481,13 @@ func run(args []string) error {
factWorker.run(ctx)
}()
// Start background memory evaluation (nil unless configured).
if evalWorker != nil {
go func() {
evalWorker.run(ctx)
}()
}
dl.unlock()
log.Printf("mavend: unlocked via passkey assertion")
return nil
@@ -514,6 +526,13 @@ func run(args []string) error {
defer wg.Done()
factWorker.run(ctx)
}()
if evalWorker != nil {
wg.Add(1)
go func() {
defer wg.Done()
evalWorker.run(ctx)
}()
}
}
<-ctx.Done()
+88
View File
@@ -0,0 +1,88 @@
// mavend/memoryeval.go — the driver for background memory evaluation
// (Vikunja #248). The evaluator itself is pure-ish and lives in
// internal/memeval; this is the one impure part: a ticker, the store, and the
// resident model's base URL.
//
// It is its own goroutine and NOT a step on the main tick, deliberately. The
// tick runs every 60s and has a delivery deadline behind it; an evaluation is
// a multi-second LLM round-trip on the same llama-server that answers voice
// turns, and it happens hourly at most. Bolting it onto the tick would make
// every hour's tick the slow one for no benefit.
package main
import (
"context"
"log"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/llm"
"github.com/kami/maven/internal/memeval"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/store"
)
// memoryEvalWorker — ticker + evaluator.
type memoryEvalWorker struct {
eval *memeval.Evaluator
interval time.Duration
}
// newMemoryEvalWorker wires the evaluation loop, or returns nil when it should
// not run at all. nil is the normal case and every caller must handle it:
//
// - no memory_eval config block ⇒ off (a capability is off unless configured);
// - no LLM phraser ⇒ nothing to evaluate with. There is no template fallback
// here on purpose: a "memory evaluation" assembled from string templates
// would be a fixed sentence pretending to be an observation.
func newMemoryEvalWorker(st *store.Store, phr phraser.Phraser, cfg *config.Config) *memoryEvalWorker {
if cfg.MemoryEval == nil {
return nil
}
lp, ok := phr.(*phraser.LLMPhraser)
if !ok {
log.Printf("memory eval: configured but no llama-server phraser — evaluation disabled")
return nil
}
interval := time.Duration(cfg.MemoryEval.Interval)
if interval <= 0 {
interval = config.DefaultMemoryEvalInterval
}
// A generous per-request timeout: this is a long prompt to a Thinking model
// and nobody is waiting on the answer.
client := llm.New(lp.BaseURL(), 5*time.Minute)
ev := memeval.NewEvaluator(st, st, client, memeval.Config{
MaxItems: cfg.MemoryEval.MaxItems,
MinConfidence: cfg.MemoryEval.MinConfidence,
ContextBlock: contextBlockFn(cfg, time.Now),
})
log.Printf("memory eval: enabled, every %s", interval)
return &memoryEvalWorker{eval: ev, interval: interval}
}
// run evaluates every interval until ctx is canceled.
//
// The first evaluation waits a full interval rather than firing at startup, the
// opposite of the tick loop's cold-start behaviour. A tick that fires late is a
// nudge that arrives late; an evaluation that fires late is nothing at all, and
// the alternative is a heavy LLM call competing with startup — including with
// the first voice turn after a restart.
func (w *memoryEvalWorker) run(ctx context.Context) {
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
obs, err := w.eval.Evaluate(ctx, now)
if err != nil {
log.Printf("memory eval: %v", err)
continue
}
for _, o := range obs {
log.Printf("memory eval: noted (%.2f, %s): %s", o.Conf, o.Action, o.Text)
}
}
}
}
+47 -1
View File
@@ -1,6 +1,6 @@
// mavend/patterns.go — the shared detect+propose step of pattern inference
// (Vikunja #43). Event *extraction* (fact -> action/object) happens at fact-
// write time in voice.go's detectPattern, tied to whichever channel wrote the
// write time in detectPattern below, tied to whichever channel wrote the
// fact. Detection — turning a run of events into a proposed routine — is
// channel-agnostic: it only needs what's already in the events table, so it
// runs both right after a voice fact-write (for the immediate "напоминать?"
@@ -13,6 +13,7 @@ import (
"context"
"errors"
"fmt"
"log"
"time"
"github.com/kami/maven/internal/pattern"
@@ -72,3 +73,48 @@ func detectAndPropose(ctx context.Context, ds *store.Store, action, object strin
}
return r, id, nil
}
// detectPattern extracts an event from the written fact and runs the pattern
// detector. If a stable recurring pattern is found and no proposed routine
// exists for this action+object yet, one is created and the user is prompted
// to confirm via the park() mechanism. Returns the suggestion phrase when a
// new proposal was created and parked; "" otherwise.
func (h *reactiveHandler) detectPattern(ctx context.Context, factID int64, key, value string, ts time.Time) string {
ev := pattern.Extract(factID, key, value, ts)
if ev == nil {
return "" // not an actionable event
}
if _, err := h.dataStore.CreateEvent(ctx, factID, ev.Action, ev.Object, ts); err != nil {
log.Printf("voice: create event: %v", err)
return ""
}
// Detect+propose (Vikunja #43) is shared with the digestion tick's
// proactive scan — see detectAndPropose above. Event *extraction* stays
// here, tied to this fact write; detection over the accumulated history does
// not need to happen right now for the voice path to have already done
// its job — it's dedupe-safe to also let the next tick find the same
// pattern independently.
r, id, err := detectAndPropose(ctx, h.dataStore, ev.Action, ev.Object, ts)
if err != nil {
log.Printf("voice: detect pattern %s/%s: %v", ev.Action, ev.Object, err)
return ""
}
if r == nil {
return "" // not enough data, too irregular, or already proposed/decided
}
log.Printf("voice: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
// Park the proposal for voice confirmation.
phrase := pattern.PhraseRoutine(r)
h.mu.Lock()
h.pendingRoutine = &pendingRoutineConfirm{
routineID: id,
action: r.Action,
object: r.Object,
interval: r.IntervalDays,
phrase: phrase,
expiry: ts.Add(confirmTTL),
}
h.mu.Unlock()
return phrase
}
+174 -11
View File
@@ -3,9 +3,14 @@ package main
import (
"context"
"database/sql"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/pattern"
"github.com/kami/maven/internal/store"
)
@@ -27,16 +32,16 @@ func seedRefillEvents(t *testing.T, st *store.Store, ctx context.Context, base t
// TestTickDetectsPatternFromStoredEvents proves the tick notices a pattern on
// its own, reading straight from the store — not as a side effect of a live
// utterance (Vikunja #43). Three weekly events with no voice turn in sight
// must produce exactly one proposed routine.
// utterance (Vikunja #43). MinEvents weekly events with no voice turn in
// sight must produce exactly one proposed routine.
func TestTickDetectsPatternFromStoredEvents(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedRefillEvents(t, st, ctx, now, 3)
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
tl.detectPatterns(ctx, now)
tl.detectPatterns(ctx, now, loop.State{})
rows, err := st.ListProposedRoutines(ctx)
if err != nil {
@@ -58,11 +63,11 @@ func TestTickPatternDetectionIsIdempotent(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedRefillEvents(t, st, ctx, now, 3)
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
tl.detectPatterns(ctx, now)
tl.detectPatterns(ctx, now.Add(time.Hour))
tl.detectPatterns(ctx, now, loop.State{})
tl.detectPatterns(ctx, now.Add(time.Hour), loop.State{})
rows, err := st.ListProposedRoutines(ctx)
if err != nil {
@@ -81,10 +86,10 @@ func TestTickPatternDetectionRespectsDismissal(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedRefillEvents(t, st, ctx, now, 3)
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
tl.detectPatterns(ctx, now)
tl.detectPatterns(ctx, now, loop.State{})
rows, err := st.ListProposedRoutines(ctx)
if err != nil {
@@ -99,8 +104,8 @@ func TestTickPatternDetectionRespectsDismissal(t *testing.T) {
// More events for the same pair arrive, and the tick runs again — a
// dismissed pattern must not resurface.
seedRefillEvents(t, st, ctx, now.Add(30*24*time.Hour), 3)
tl.detectPatterns(ctx, now.Add(60*24*time.Hour))
seedRefillEvents(t, st, ctx, now.Add(30*24*time.Hour), pattern.MinEvents)
tl.detectPatterns(ctx, now.Add(60*24*time.Hour), loop.State{})
proposed, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
if err != nil {
@@ -120,3 +125,161 @@ func TestTickPatternDetectionRespectsDismissal(t *testing.T) {
t.Errorf("status = %s, want dismissed", all[0].Status)
}
}
// proposalRule — the rule name announceProposal uses for the seeded pair.
const proposalRule = "proposal:refill cat_water"
// TestTickProposalSilentByDefault — detection is always on, announcing is not.
// With no pattern_proposals block the tick still records the proposal, and says
// nothing about it: Maven is not autonomous, so a behaviour that speaks without
// being asked stays off until it is configured.
func TestTickProposalSilentByDefault(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
markPresent(t, st, ctx, now)
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink, nil)
tl.tick(ctx, now)
if n := countSends(sink, proposalRule); n != 0 {
t.Fatalf("announced %d proposals with no config, want 0", n)
}
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
if err != nil {
t.Fatalf("list proposed: %v", err)
}
if len(rows) != 1 {
t.Fatalf("proposed routines = %d, want 1 (silent, but recorded)", len(rows))
}
}
// TestTickAnnouncesProposalWhenConfigured — with notify on, the proposal goes
// out once through the ordinary delivery path, worded by the detector itself.
// Later ticks stay quiet because the pair is already proposed: one pattern is
// one announcement, ever.
func TestTickAnnouncesProposalWhenConfigured(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
markPresent(t, st, ctx, now)
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink, nil)
tl.proposalCfg = &config.PatternProposalConfig{Notify: true}
tl.tick(ctx, now)
var got *delivery.Sendable
for i := range sink.sends {
if sink.sends[i].RuleName == proposalRule {
got = &sink.sends[i]
}
}
if got == nil {
t.Fatalf("proposal was not announced; sends=%+v", sink.sends)
}
if !strings.Contains(got.Body, "напоминать?") {
t.Errorf("body = %q, want the detector's own question", got.Body)
}
if got.Channel != delivery.ChannelVoice {
t.Errorf("channel = %v, want voice (sev1, present)", got.Channel)
}
// A month of further ticks: the pair already has a row, so there is
// nothing new to detect and nothing more to say.
sink.sends = nil
later := now.Add(40 * 24 * time.Hour)
markPresent(t, st, ctx, later)
tl.tick(ctx, later)
if n := countSends(sink, proposalRule); n != 0 {
t.Fatalf("re-announced an existing proposal %d times, want 0", n)
}
}
// TestTickProposalRespectsGate — a proposal is the least urgent thing Maven can
// say, so it is sev1 and the restraint gate suppresses it. Away presence means
// it is not announced at all: it is not held, not retried, it just lives on
// /routines. The proposal row is still written — noticing is never gated.
func TestTickProposalRespectsGate(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
// no presence probes ⇒ away ⇒ care-class gate blocks.
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink, nil)
tl.proposalCfg = &config.PatternProposalConfig{Notify: true}
tl.tick(ctx, now)
if n := countSends(sink, proposalRule); n != 0 {
t.Fatalf("away: announced %d proposals, want 0", n)
}
if !tl.lastProposalAt.IsZero() {
t.Error("cooldown clock advanced on a suppressed announcement")
}
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
if err != nil {
t.Fatalf("list proposed: %v", err)
}
if len(rows) != 1 {
t.Fatalf("proposed routines = %d, want 1 (detection is never gated)", len(rows))
}
}
// TestTickProposalCooldownSpacesAnnouncements — two patterns detected on the
// same tick must not become two interruptions. The second one waits for the
// cooldown, and is on /routines meanwhile.
func TestTickProposalCooldownSpacesAnnouncements(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
for i := 0; i < pattern.MinEvents; i++ {
ts := now.Add(time.Duration(i) * 3 * 24 * time.Hour)
factID, err := st.WriteFact(ctx, ts, store.KindSelf, "litter_box", "clean", "test", 1.0, sql.NullInt64{})
if err != nil {
t.Fatalf("write fact: %v", err)
}
if _, err := st.CreateEvent(ctx, factID, "clean", "litter_box", ts); err != nil {
t.Fatalf("create event: %v", err)
}
}
markPresent(t, st, ctx, now)
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink, nil)
tl.proposalCfg = &config.PatternProposalConfig{Notify: true, Cooldown: config.Duration(24 * time.Hour)}
tl.tick(ctx, now)
announced := 0
for _, s := range sink.sends {
if strings.HasPrefix(s.RuleName, "proposal:") {
announced++
}
}
if announced != 1 {
t.Fatalf("announced %d proposals on one tick, want exactly 1", announced)
}
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
if err != nil {
t.Fatalf("list proposed: %v", err)
}
if len(rows) != 2 {
t.Fatalf("proposed routines = %d, want 2 (both recorded, one announced)", len(rows))
}
// Still inside the cooldown: silence, even though a proposal is pending.
sink.sends = nil
soon := now.Add(time.Hour)
markPresent(t, st, ctx, soon)
tl.tick(ctx, soon)
for _, s := range sink.sends {
if strings.HasPrefix(s.RuleName, "proposal:") {
t.Fatalf("announced %q inside the cooldown", s.RuleName)
}
}
}
+144
View File
@@ -0,0 +1,144 @@
// Quiet-mode toggle recognition — the pre-route keyword check that lets
// "тихий режим" flip the daemon-wide quiet_hours config without going through
// the router. Moved out of voice.go unchanged (Vikunja #321); the tests live in
// quiet_toggle_test.go.
package main
import (
"context"
"log"
"strings"
"unicode"
"github.com/kami/maven/internal/ipc"
)
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
// the utterance is a quiet-on/off command; ("", false) otherwise. Called from
// runTurn BEFORE the router so a classifier miscue can't drop it — which means
// both the voice path and the text path (mavweb /api/chat, telegram) reach it,
// so a false positive here is a network-reachable way to flip a daemon-wide
// setting. See classifyQuietToggle for the matching rule.
func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) (string, bool) {
on, off := classifyQuietToggle(text)
if !on && !off {
return "", false
}
val := "false"
reply := "тихий режим выключен."
if on {
val = "true"
reply = "тихий режим включён. буду реже напоминать."
}
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
Ts: h.now(),
Kind: "config",
Key: "quiet_hours",
Value: val,
Source: "tap:voice",
Confidence: 1.0,
}); err != nil {
log.Printf("voice: write quiet_hours: %v", err)
return "не получилось переключить тихий режим.", true
}
return reply, true
}
// quietInflections — the inflectional endings a stem may carry and still be
// the same word. Adjective/adverb/noun/verb endings, all ≤3 letters. This is
// what separates "тихий"/"тихом"/"тихо" (stem "тих" + a real ending) from
// "тихонько"/"потихоньку", which are different words: "онько" is not an
// ending, and "потихоньку" doesn't start with the stem at all.
var quietInflections = []string{
"", "а", "е", "и", "й", "о", "у", "ы", "ю", "я",
"ая", "ее", "ей", "ем", "ие", "ий", "им", "их", "ия", "ию", "ое", "ой", "ом", "ую", "ые", "ый", "ым", "ых", "ья",
"ами", "ого", "ому", "ыми", "ать", "ить", "ять",
}
// quietStem reports whether tok is the given stem carrying at most one
// inflectional ending. Word boundaries come from tokenisation (see
// quietTokens), not from a regexp — Go's \b is ASCII-oriented and treats every
// Cyrillic letter as a non-word character, so `\bтих\b` would happily match
// inside "тихонько". Comparing whole tokens sidesteps that entirely.
func quietStem(tok, stem string) bool {
if !strings.HasPrefix(tok, stem) {
return false
}
suffix := tok[len(stem):]
for _, e := range quietInflections {
if suffix == e {
return true
}
}
return false
}
// quietTokens splits an utterance into lowercase word tokens, dropping
// punctuation and spacing. Unicode-aware, so Cyrillic words tokenise the same
// way ASCII ones do.
func quietTokens(text string) []string {
return strings.FieldsFunc(strings.ToLower(strings.TrimSpace(text)), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
}
// quietPhrase matches a pattern (a sequence of stems) against the token list.
// Multi-word patterns match any contiguous run of tokens — "включи тихий
// режим" carries "тихий режим". Single-word patterns match ONLY when they are
// the whole utterance: bare "тихо" is a command, but "в комнате тихо" is a
// remark about the room and must not flip a daemon-wide setting.
func quietPhrase(tokens, pattern []string) bool {
if len(pattern) == 0 || len(tokens) < len(pattern) {
return false
}
if len(pattern) == 1 {
return len(tokens) == 1 && quietStem(tokens[0], pattern[0])
}
for i := 0; i+len(pattern) <= len(tokens); i++ {
hit := true
for j, stem := range pattern {
if !quietStem(tokens[i+j], stem) {
hit = false
break
}
}
if hit {
return true
}
}
return false
}
// quietOffPhrases / quietOnPhrases — the toggle vocabulary, as stem sequences.
var (
quietOffPhrases = [][]string{
{"quiet", "off"}, {"quiet", "end"},
{"громк", "режим"}, {"шумн", "режим"},
{"отмен", "тих"}, {"выключ", "тих"}, {"не", "тих"},
}
quietOnPhrases = [][]string{
{"quiet", "on"}, {"quiet", "mode"},
{"тих", "режим"}, {"не", "шум"}, {"не", "беспоко"},
{"тих"},
}
)
// classifyQuietToggle reads an utterance as a quiet-mode command. OFF is
// resolved before ON for the same reason classifyConfirm checks negatives
// first: the OFF phrases are built out of the ON words ("выключи тихий"
// contains "тихий"), so scanning ON first would shadow them and "выключи
// тихий режим" would turn quiet mode on. Negation wins.
func classifyQuietToggle(text string) (on, off bool) {
tokens := quietTokens(text)
for _, p := range quietOffPhrases {
if quietPhrase(tokens, p) {
return false, true
}
}
for _, p := range quietOnPhrases {
if quietPhrase(tokens, p) {
return true, false
}
}
return false, false
}
+155 -6
View File
@@ -24,6 +24,7 @@ import (
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/morning"
"github.com/kami/maven/internal/pattern"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/routine"
"github.com/kami/maven/internal/store"
@@ -67,6 +68,14 @@ type tickLoop struct {
morningRoutines []morning.Routine
morningLast map[string]time.Time
// proposalCfg — announcement policy for routines the tick inferred itself.
// nil ⇒ detect silently, never announce (the default). lastProposalAt is
// the cooldown clock, in-memory on purpose: a restart is allowed to permit
// one more announcement, and a restart-per-day loop is a bigger problem
// than a duplicate proposal notice.
proposalCfg *config.PatternProposalConfig
lastProposalAt time.Time
// digestQ — in-memory queue of eligible nudges waiting for batch flush.
// populated when digestCfg != nil && digestCfg.Enabled.
digestQ []QueuedNudge
@@ -92,6 +101,7 @@ func newTickLoop(
digestCfg *config.DigestConfig,
routines []routine.Routine,
morningRoutines []morning.Routine,
proposalCfg *config.PatternProposalConfig,
) *tickLoop {
return &tickLoop{
store: st,
@@ -108,6 +118,7 @@ func newTickLoop(
routineLast: make(map[string]time.Time),
morningRoutines: morningRoutines,
morningLast: make(map[string]time.Time),
proposalCfg: proposalCfg,
lastPhrase: make(map[string]delivery.PhrasedNudge),
}
}
@@ -212,7 +223,7 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
// path, so a pattern already sitting in history went unnoticed until he
// happened to mention it again by voice. See patterns.go and
// detectPatterns below for how idempotence and dismissal are respected.
t.detectPatterns(ctx, now)
t.detectPatterns(ctx, now, state)
// reminders: gate-bypassing class. fired once, marked after a successful
// delivery. a failed send leaves the reminder pending — the next tick
@@ -381,16 +392,19 @@ func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.St
// resurrecting — there is nothing tick-specific to get right here beyond
// calling the same shared path the voice route already used.
//
// This only ever creates a row for the /routines page to show. It does not
// notify, ring, or speak — Maven is "not a nag, not autonomous" (CLAUDE.md),
// and detection is not the same act as disturbing him about it. A proposal
// only starts producing nudges once he accepts it (fireAcceptedRoutines).
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time) {
// By default this only creates a row for the /routines page to show: it does
// not notify, ring, or speak. Detection is not the same act as disturbing him
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
// is opt-in through the pattern_proposals config block — see announceProposal
// for the restraints that apply even then. A proposal only starts producing
// recurring nudges once he accepts it (fireAcceptedRoutines).
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop.State) {
pairs, err := t.store.DistinctEventPairs(ctx)
if err != nil {
log.Printf("tick: distinct event pairs: %v", err)
return
}
announced := false
for _, p := range pairs {
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
if err != nil {
@@ -401,9 +415,65 @@ func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time) {
continue // no stable pattern, or already proposed/accepted/dismissed
}
log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
// One announcement per tick at most, whatever the scan turned up. The
// rest are on /routines; they are not lost, they are just not shouted.
if announced {
continue
}
announced = t.announceProposal(ctx, r, now, state)
}
}
// announceProposal offers a freshly inferred routine through the ordinary
// care-delivery path, if announcing is switched on at all. Returns true when
// something was actually sent.
//
// Everything here is restraint. The feature is off unless configured; when on
// it is sev1 (the lowest severity, so quiet hours, away presence and snooze
// all suppress it via loop.Gate exactly like a care nudge); it is spaced by
// proposalCfg.Cooldown across every pair, not per pair; and a suppressed or
// dropped announcement is NOT retried — the cooldown clock advances only on a
// real send, but the proposal row already exists, so the next tick will not
// re-detect it and nothing queues up behind it. A missed announcement means
// he reads it on /routines instead, which is the whole point of the page.
//
// The body is the detector's own literal Russian phrasing (pattern.PhraseRoutine
// — "ты заправляешь поилку раз в 7 дней — напоминать?"), not LLM-generated, so
// an inferred routine cannot arrive worded as something Maven never observed.
func (t *tickLoop) announceProposal(ctx context.Context, r *pattern.ProposedRoutine, now time.Time, state loop.State) bool {
if !t.proposalCfg.AnnounceProposals() {
return false
}
cooldown := time.Duration(t.proposalCfg.Cooldown)
if cooldown <= 0 {
cooldown = config.DefaultProposalCooldown
}
if !t.lastProposalAt.IsZero() && now.Sub(t.lastProposalAt) < cooldown {
return false
}
rule := loop.Rule{Name: "proposal:" + r.Action + " " + r.Object, Severity: loop.Sev1}
if !loop.Gate(state, rule) {
return false
}
body := pattern.PhraseRoutine(r)
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
Body: body,
Summary: body,
}
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
if err != nil {
log.Printf("tick: announce proposal %s/%s: %v", r.Action, r.Object, err)
return false
}
if len(sent) == 0 {
return false // routing dropped it — /routines still has it.
}
t.lastProposalAt = now
return true
}
// digestExpiry — how long a gate-suppressed care nudge stays worth
// resurfacing. 24h: these are daily-cadence rules (water/meal/break run on
// hour-scale cooldowns and re-derive from facts that reset every day), so a
@@ -719,6 +789,77 @@ func (t *tickLoop) morningStatus(ctx context.Context, now time.Time) []ipc.Morni
return out
}
// dayPlan is the read-only "what does today hold" query (Vikunja #128). It is
// the impure half of morning.BuildPlan: it reads the calendar events, the
// pending reminders and the checklist facts, and the pure builder orders them.
//
// It never dispatches. Asking for the plan is a query like any other; the only
// unprompted delivery in maven stays with the morning nudge and the
// dispatcher's policy.
func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
y, m, d := now.Date()
dayStart := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
dayEnd := dayStart.AddDate(0, 0, 1)
var events []morning.PlanEntry
facts, err := t.store.CalendarEvents(ctx, dayStart, dayEnd)
if err != nil {
log.Printf("tick: day plan: calendar events: %v", err)
}
for _, f := range facts {
events = append(events, morning.PlanEntry{
At: f.Ts,
Text: f.Value,
Kind: morning.PlanEvent,
// Provenance below a calendar read (an ambient relay, #126) is
// hedged rather than recited as fact.
Uncertain: f.Confidence < 1.0,
})
}
var reminders []morning.PlanEntry
rems, err := t.store.ListReminders(ctx, dayPlanMaxReminders)
if err != nil {
log.Printf("tick: day plan: list reminders: %v", err)
}
for _, r := range rems {
if r.Status != "pending" {
continue
}
fire := r.NextFireTs
if fire.IsZero() {
fire = r.FireTs
}
reminders = append(reminders, morning.PlanEntry{
At: fire,
Text: strings.TrimSpace(r.Payload),
Kind: morning.PlanReminder,
})
}
var checklistFacts map[string]store.Fact
if len(t.morningRoutines) > 0 {
checklistFacts = t.gatherMorningFacts(ctx)
}
plan := morning.BuildPlan(t.morningRoutines, checklistFacts, events, reminders, now)
out := ipc.DayPlan{Date: plan.Date, Spoken: plan.FormatRU()}
out.Items = make([]ipc.DayPlanItem, len(plan.Items))
for i, it := range plan.Items {
out.Items[i] = ipc.DayPlanItem{
At: it.At,
Text: it.Text,
Kind: string(it.Kind),
Uncertain: it.Uncertain,
}
}
return out
}
// dayPlanMaxReminders bounds the reminder scan. The plan covers one day; a
// pending queue longer than this is a bug elsewhere, not a plan to recite.
const dayPlanMaxReminders = 500
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
// rule:
@@ -798,6 +939,7 @@ type daemonAPI struct {
ipc.CoreAPI
getTrace func() *loop.TickTrace
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
getDayPlan func(ctx context.Context) ipc.DayPlan
chatFn func(ctx context.Context, text string) string
}
@@ -823,6 +965,13 @@ func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStat
return d.getMorningStatus(ctx), nil
}
func (d *daemonAPI) DayPlan(ctx context.Context) (ipc.DayPlan, error) {
if d.getDayPlan == nil {
return ipc.DayPlan{}, errors.New("mavend: day plan not available")
}
return d.getDayPlan(ctx), nil
}
func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
for i, r := range t.RuleTraces {
+2 -2
View File
@@ -46,7 +46,7 @@ func newTestTickLoop(t *testing.T, st *store.Store, sink delivery.Sink, digestCf
Nudges: st,
Reminders: st,
})
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, digestCfg, nil, nil)
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, digestCfg, nil, nil, nil)
}
func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
@@ -63,7 +63,7 @@ func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
sink := &fakeSink{}
d := delivery.NewDispatcher(delivery.Config{Voice: sink, Ntfy: sink, Telegram: sink, Nudges: st, Reminders: st})
rs := []routine.Routine{{Name: "morning", Cron: "0 12 * * *", Body: "полдень, время воды", Severity: 1}}
tl := newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, rs, nil)
tl := newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, rs, nil, nil)
// first tick: seeds, does not fire the routine.
tl.tick(ctx, now)
-177
View File
@@ -50,13 +50,11 @@ import (
"strings"
"sync"
"time"
"unicode"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/pattern"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
@@ -277,181 +275,6 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
return ""
}
// detectPattern extracts an event from the written fact and runs the pattern
// detector. If a stable recurring pattern is found and no proposed routine
// exists for this action+object yet, one is created and the user is prompted
// to confirm via the park() mechanism. Returns the suggestion phrase when a
// new proposal was created and parked; "" otherwise.
func (h *reactiveHandler) detectPattern(ctx context.Context, factID int64, key, value string, ts time.Time) string {
ev := pattern.Extract(factID, key, value, ts)
if ev == nil {
return "" // not an actionable event
}
if _, err := h.dataStore.CreateEvent(ctx, factID, ev.Action, ev.Object, ts); err != nil {
log.Printf("voice: create event: %v", err)
return ""
}
// Detect+propose (Vikunja #43) is shared with the digestion tick's
// proactive scan — see patterns.go. Event *extraction* above stays here,
// tied to this fact write; detection over the accumulated history does
// not need to happen right now for the voice path to have already done
// its job — it's dedupe-safe to also let the next tick find the same
// pattern independently.
r, id, err := detectAndPropose(ctx, h.dataStore, ev.Action, ev.Object, ts)
if err != nil {
log.Printf("voice: detect pattern %s/%s: %v", ev.Action, ev.Object, err)
return ""
}
if r == nil {
return "" // not enough data, too irregular, or already proposed/decided
}
log.Printf("voice: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
// Park the proposal for voice confirmation.
phrase := pattern.PhraseRoutine(r)
h.mu.Lock()
h.pendingRoutine = &pendingRoutineConfirm{
routineID: id,
action: r.Action,
object: r.Object,
interval: r.IntervalDays,
phrase: phrase,
expiry: ts.Add(confirmTTL),
}
h.mu.Unlock()
return phrase
}
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
// the utterance is a quiet-on/off command; ("", false) otherwise. Called from
// runTurn BEFORE the router so a classifier miscue can't drop it — which means
// both the voice path and the text path (mavweb /api/chat, telegram) reach it,
// so a false positive here is a network-reachable way to flip a daemon-wide
// setting. See classifyQuietToggle for the matching rule.
func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) (string, bool) {
on, off := classifyQuietToggle(text)
if !on && !off {
return "", false
}
val := "false"
reply := "тихий режим выключен."
if on {
val = "true"
reply = "тихий режим включён. буду реже напоминать."
}
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
Ts: h.now(),
Kind: "config",
Key: "quiet_hours",
Value: val,
Source: "tap:voice",
Confidence: 1.0,
}); err != nil {
log.Printf("voice: write quiet_hours: %v", err)
return "не получилось переключить тихий режим.", true
}
return reply, true
}
// quietInflections — the inflectional endings a stem may carry and still be
// the same word. Adjective/adverb/noun/verb endings, all ≤3 letters. This is
// what separates "тихий"/"тихом"/"тихо" (stem "тих" + a real ending) from
// "тихонько"/"потихоньку", which are different words: "онько" is not an
// ending, and "потихоньку" doesn't start with the stem at all.
var quietInflections = []string{
"", "а", "е", "и", "й", "о", "у", "ы", "ю", "я",
"ая", "ее", "ей", "ем", "ие", "ий", "им", "их", "ия", "ию", "ое", "ой", "ом", "ую", "ые", "ый", "ым", "ых", "ья",
"ами", "ого", "ому", "ыми", "ать", "ить", "ять",
}
// quietStem reports whether tok is the given stem carrying at most one
// inflectional ending. Word boundaries come from tokenisation (see
// quietTokens), not from a regexp — Go's \b is ASCII-oriented and treats every
// Cyrillic letter as a non-word character, so `\bтих\b` would happily match
// inside "тихонько". Comparing whole tokens sidesteps that entirely.
func quietStem(tok, stem string) bool {
if !strings.HasPrefix(tok, stem) {
return false
}
suffix := tok[len(stem):]
for _, e := range quietInflections {
if suffix == e {
return true
}
}
return false
}
// quietTokens splits an utterance into lowercase word tokens, dropping
// punctuation and spacing. Unicode-aware, so Cyrillic words tokenise the same
// way ASCII ones do.
func quietTokens(text string) []string {
return strings.FieldsFunc(strings.ToLower(strings.TrimSpace(text)), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
}
// quietPhrase matches a pattern (a sequence of stems) against the token list.
// Multi-word patterns match any contiguous run of tokens — "включи тихий
// режим" carries "тихий режим". Single-word patterns match ONLY when they are
// the whole utterance: bare "тихо" is a command, but "в комнате тихо" is a
// remark about the room and must not flip a daemon-wide setting.
func quietPhrase(tokens, pattern []string) bool {
if len(pattern) == 0 || len(tokens) < len(pattern) {
return false
}
if len(pattern) == 1 {
return len(tokens) == 1 && quietStem(tokens[0], pattern[0])
}
for i := 0; i+len(pattern) <= len(tokens); i++ {
hit := true
for j, stem := range pattern {
if !quietStem(tokens[i+j], stem) {
hit = false
break
}
}
if hit {
return true
}
}
return false
}
// quietOffPhrases / quietOnPhrases — the toggle vocabulary, as stem sequences.
var (
quietOffPhrases = [][]string{
{"quiet", "off"}, {"quiet", "end"},
{"громк", "режим"}, {"шумн", "режим"},
{"отмен", "тих"}, {"выключ", "тих"}, {"не", "тих"},
}
quietOnPhrases = [][]string{
{"quiet", "on"}, {"quiet", "mode"},
{"тих", "режим"}, {"не", "шум"}, {"не", "беспоко"},
{"тих"},
}
)
// classifyQuietToggle reads an utterance as a quiet-mode command. OFF is
// resolved before ON for the same reason classifyConfirm checks negatives
// first: the OFF phrases are built out of the ON words ("выключи тихий"
// contains "тихий"), so scanning ON first would shadow them and "выключи
// тихий режим" would turn quiet mode on. Negation wins.
func classifyQuietToggle(text string) (on, off bool) {
tokens := quietTokens(text)
for _, p := range quietOffPhrases {
if quietPhrase(tokens, p) {
return false, true
}
}
for _, p := range quietOnPhrases {
if quietPhrase(tokens, p) {
return true, false
}
}
return false, false
}
// replySystem answers system-observable queries using the handler's clock
// and (in future) system interfaces. The decision's utterance is parsed
// for keywords to determine what the user is asking about.
+135
View File
@@ -0,0 +1,135 @@
package main
import (
"crypto/subtle"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"strings"
"github.com/kami/maven/internal/calendar"
"github.com/kami/maven/internal/ipc"
)
// POST /api/ambient — the work calendar read (Vikunja #126).
//
// Maven does not hold a work credential. A corp mail or calendar session on the
// homelab ties the box's blast radius to the employer's data, so the work
// calendar is read as a SIGNAL instead: an Android notification-listener on the
// owner's phone posts meeting notifications here over wg/LAN, and the ones that
// clearly describe a meeting become calendar events at source=ambient:notif,
// confidence below 1.0. Mail as a notification signal, not a mailbox.
//
// Off unless configured: no -ambient-token, no route. The token is a shared
// secret because the poster is a phone service, not a browser — WebAuthn has no
// answer for a background Android service. The endpoint is write-only and
// accepts exactly one shape of write; it cannot read anything back out.
//
// A notification with no recognisable clock reading stores NOTHING. Maven is
// not a guesser-of-truth, and a mailbox of noise rendered as invented meetings
// is worse than a gap.
// ambientMaxBody bounds the request. A notification is two short lines.
const ambientMaxBody = 8 << 10
type ambientResp struct {
Stored bool `json:"stored"`
Key string `json:"key,omitempty"`
Reason string `json:"reason,omitempty"`
}
// handleAmbient ingests one relayed notification. token is the configured
// shared secret; an empty token means the capability is off and the handler is
// never registered, so it is treated as a hard failure here too.
func handleAmbient(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, token string) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
if token == "" {
http.Error(w, "ambient ingest disabled (no -ambient-token)", http.StatusServiceUnavailable)
return
}
if !ambientAuthorized(r, token) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if core == nil {
http.Error(w, "ambient ingest disabled (no -core)", http.StatusServiceUnavailable)
return
}
var n calendar.Notification
body, err := io.ReadAll(io.LimitReader(r.Body, ambientMaxBody))
if err != nil {
http.Error(w, "read failed", http.StatusBadRequest)
return
}
if err := json.Unmarshal(body, &n); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if n.Posted.IsZero() {
writeAmbient(w, http.StatusBadRequest, ambientResp{Reason: "posted_at is required"})
return
}
ev, ok := calendar.EventFromNotification(n)
if !ok {
// Not an event. 202: the relay did its job, there is just nothing here
// worth remembering, and it must not retry.
writeAmbient(w, http.StatusAccepted, ambientResp{Reason: "no meeting time in notification"})
return
}
key := calendar.FactKey(ev)
val := calendar.FactValue(ev)
// Append-only discipline, same as cmd/mavcaldav: a phone reposts the same
// notification many times, and each repost is the same event.
if prev, err := core.LatestFactBySource(r.Context(), key, calendar.SourceAmbient); err == nil && prev.Value == val {
writeAmbient(w, http.StatusOK, ambientResp{Stored: false, Key: key, Reason: "unchanged"})
return
} else if err != nil && !errors.Is(err, ipc.ErrNoFact) {
log.Printf("ambient: read %s: %v", key, err)
http.Error(w, "read failed", http.StatusBadGateway)
return
}
// kind=env: an observation about the world, never a self-fact — a passive
// signal does not write truth about the owner. Confidence below 1.0 is the
// honest part: this is a notification about a meeting, not a reading of a
// calendar, and the query path hedges when it recites one.
if _, err := core.WriteFact(r.Context(), ipc.WriteFactReq{
Ts: ev.Start,
Kind: "env",
Key: key,
Value: val,
Source: calendar.SourceAmbient,
Confidence: calendar.AmbientConfidence,
}); err != nil {
log.Printf("ambient: write %s: %v", key, err)
http.Error(w, "write failed", http.StatusBadGateway)
return
}
log.Printf("ambient: %s=%s (%s, pkg=%s)", key, val, calendar.SourceAmbient, n.Package)
writeAmbient(w, http.StatusCreated, ambientResp{Stored: true, Key: key})
}
// ambientAuthorized accepts the token as a bearer header or as an X-Maven-Token
// header, compared in constant time.
func ambientAuthorized(r *http.Request, token string) bool {
got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer"))
if got == "" {
got = strings.TrimSpace(r.Header.Get("X-Maven-Token"))
}
return subtle.ConstantTimeCompare([]byte(got), []byte(token)) == 1
}
func writeAmbient(w http.ResponseWriter, code int, resp ambientResp) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(resp)
}
+223
View File
@@ -0,0 +1,223 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/calendar"
"github.com/kami/maven/internal/ipc"
)
const ambientTestToken = "s3cret"
// ambientCore adds provenance-scoped reads to fakeCore, which the dedupe path
// needs.
type ambientCore struct {
fakeCore
latest map[string]ipc.Fact // "key|source" → fact
readErr error
}
func (c *ambientCore) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) {
if c.readErr != nil {
return ipc.Fact{}, c.readErr
}
f, ok := c.latest[key+"|"+source]
if !ok {
return ipc.Fact{}, ipc.ErrNoFact
}
return f, nil
}
func postAmbient(t *testing.T, core ipc.CoreAPI, token string, n calendar.Notification) (*httptest.ResponseRecorder, ambientResp) {
t.Helper()
body, err := json.Marshal(n)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(string(body)))
req.Header.Set("Authorization", "Bearer "+ambientTestToken)
rr := httptest.NewRecorder()
handleAmbient(rr, req, core, token)
var resp ambientResp
json.Unmarshal(rr.Body.Bytes(), &resp)
return rr, resp
}
func meetingNotification() calendar.Notification {
return calendar.Notification{
Package: "com.google.android.gm",
Title: "Планёрка",
Text: "10:00-10:30",
Posted: time.Date(2026, 8, 3, 9, 40, 0, 0, time.UTC),
}
}
func TestHandleAmbientStoresMeeting(t *testing.T) {
core := &ambientCore{}
rr, resp := postAmbient(t, core, ambientTestToken, meetingNotification())
if rr.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201: %s", rr.Code, rr.Body)
}
if !resp.Stored {
t.Errorf("resp = %+v, want stored", resp)
}
if len(core.writeLog) != 1 {
t.Fatalf("expected 1 fact write, got %d", len(core.writeLog))
}
got := core.writeLog[0]
if got.Source != calendar.SourceAmbient {
t.Errorf("source = %q, want %q", got.Source, calendar.SourceAmbient)
}
if got.Confidence >= 1.0 {
t.Errorf("confidence = %v — a notification is not a calendar read", got.Confidence)
}
if got.Confidence != calendar.AmbientConfidence {
t.Errorf("confidence = %v, want %v", got.Confidence, calendar.AmbientConfidence)
}
if got.Kind != "env" {
t.Errorf("kind = %q — a passive signal never writes a self-fact", got.Kind)
}
if want := "calendar_event_20260803_"; !strings.HasPrefix(got.Key, want) {
t.Errorf("key = %q, want prefix %q", got.Key, want)
}
if got.Value != "Планёрка @ 10:00-10:30" {
t.Errorf("value = %q", got.Value)
}
}
// A phone reposts the same notification many times. Each repost is the same
// event, and the append-only log must not fill with duplicates.
func TestHandleAmbientDedupesReposts(t *testing.T) {
core := &ambientCore{}
postAmbient(t, core, ambientTestToken, meetingNotification())
if len(core.writeLog) != 1 {
t.Fatalf("first post did not write")
}
w := core.writeLog[0]
core.latest = map[string]ipc.Fact{w.Key + "|" + w.Source: {Value: w.Value}}
rr, resp := postAmbient(t, core, ambientTestToken, meetingNotification())
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200 for an unchanged repost", rr.Code)
}
if resp.Stored {
t.Error("a repost must not be stored again")
}
if len(core.writeLog) != 1 {
t.Errorf("wrote %d facts, want 1", len(core.writeLog))
}
}
// The conservative half: noise stores nothing at all.
func TestHandleAmbientIgnoresNonMeetings(t *testing.T) {
core := &ambientCore{}
rr, resp := postAmbient(t, core, ambientTestToken, calendar.Notification{
Package: "com.google.android.gm",
Title: "3 новых письма",
Posted: time.Now(),
})
if rr.Code != http.StatusAccepted {
t.Errorf("status = %d, want 202 (accepted, nothing to store — the relay must not retry)", rr.Code)
}
if resp.Stored {
t.Error("a notification with no meeting time must store nothing")
}
if len(core.writeLog) != 0 {
t.Fatalf("wrote %d facts for a non-meeting", len(core.writeLog))
}
}
func TestHandleAmbientAuth(t *testing.T) {
body := `{"title":"Планёрка 10:00","posted_at":"2026-08-03T09:40:00Z"}`
newReq := func(hdr, val string) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body))
if hdr != "" {
r.Header.Set(hdr, val)
}
return r
}
t.Run("no token rejected", func(t *testing.T) {
core := &ambientCore{}
rr := httptest.NewRecorder()
handleAmbient(rr, newReq("", ""), core, ambientTestToken)
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rr.Code)
}
if len(core.writeLog) != 0 {
t.Error("an unauthorized post must not write")
}
})
t.Run("wrong token rejected", func(t *testing.T) {
rr := httptest.NewRecorder()
handleAmbient(rr, newReq("Authorization", "Bearer nope"), &ambientCore{}, ambientTestToken)
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rr.Code)
}
})
t.Run("X-Maven-Token accepted", func(t *testing.T) {
rr := httptest.NewRecorder()
handleAmbient(rr, newReq("X-Maven-Token", ambientTestToken), &ambientCore{}, ambientTestToken)
if rr.Code != http.StatusCreated {
t.Errorf("status = %d, want 201: %s", rr.Code, rr.Body)
}
})
t.Run("capability off", func(t *testing.T) {
rr := httptest.NewRecorder()
handleAmbient(rr, newReq("Authorization", "Bearer "+ambientTestToken), &ambientCore{}, "")
if rr.Code != http.StatusServiceUnavailable {
t.Errorf("status = %d, want 503 when no token is configured", rr.Code)
}
})
t.Run("GET rejected", func(t *testing.T) {
rr := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/api/ambient", nil)
handleAmbient(rr, r, &ambientCore{}, ambientTestToken)
if rr.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want 405 — the ingest is write-only", rr.Code)
}
})
}
func TestHandleAmbientBadInput(t *testing.T) {
t.Run("bad json", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader("{nope"))
req.Header.Set("X-Maven-Token", ambientTestToken)
rr := httptest.NewRecorder()
handleAmbient(rr, req, &ambientCore{}, ambientTestToken)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rr.Code)
}
})
t.Run("missing posted_at", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(`{"title":"Планёрка 10:00"}`))
req.Header.Set("X-Maven-Token", ambientTestToken)
rr := httptest.NewRecorder()
handleAmbient(rr, req, &ambientCore{}, ambientTestToken)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rr.Code)
}
})
t.Run("read error surfaces", func(t *testing.T) {
core := &ambientCore{readErr: fmt.Errorf("socket closed")}
rr, _ := postAmbient(t, core, ambientTestToken, meetingNotification())
if rr.Code != http.StatusBadGateway {
t.Errorf("status = %d, want 502", rr.Code)
}
})
}
+187 -1
View File
@@ -58,6 +58,9 @@ var notificationsHTML string
//go:embed reminders.html
var remindersHTML string
//go:embed tasks.html
var tasksHTML string
//go:embed voice.html
var voiceHTML string
@@ -97,6 +100,7 @@ var sidebarSections = []struct {
Pages: []struct{ Label, URL, Key string }{
{Label: "Rule Trace", URL: "/trace", Key: "trace"},
{Label: "Notifications", URL: "/notifications", Key: "notifications"},
{Label: "Tasks", URL: "/tasks", Key: "tasks"},
{Label: "Reminders", URL: "/reminders", Key: "reminders"},
{Label: "Routines", URL: "/routines", Key: "routines"},
{Label: "Morning", URL: "/morning", Key: "morning"},
@@ -170,6 +174,8 @@ func pageIcon(key string) string {
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
case "notifications":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-bell"/></svg>`
case "tasks":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
case "reminders":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
case "routines":
@@ -202,6 +208,8 @@ func pageTitle(key string) string {
return "Rule Trace"
case "notifications":
return "Notifications"
case "tasks":
return "Tasks"
case "reminders":
return "Reminders"
case "routines":
@@ -334,6 +342,10 @@ func main() {
nexusURL := flag.String("nexus", "", "Nexus base URL for the /ecosystem panel (empty = not configured)")
praxisURL := flag.String("praxis", "", "Praxis base URL for the /ecosystem panel (empty = not configured)")
hexisURL := flag.String("hexis", "", "Hexis base URL for the /ecosystem panel (empty = not configured)")
// Shared secret for POST /api/ambient, the notification-relay ingest that
// reads the work calendar as a signal instead of holding a work credential
// (see ambient.go). Empty ⇒ the route is not registered at all.
ambientToken := flag.String("ambient-token", "", "shared secret for POST /api/ambient notification ingest (empty = ingest disabled, route not registered)")
flag.Parse()
var core ipc.CoreAPI
@@ -381,6 +393,14 @@ func main() {
mux.HandleFunc("/api/signal", func(w http.ResponseWriter, r *http.Request) {
handleSignal(w, r, core)
})
// Off unless configured: no token, no route — an unconfigured ingest is not
// a 503 waiting to be probed, it does not exist.
if *ambientToken != "" {
mux.HandleFunc("/api/ambient", func(w http.ResponseWriter, r *http.Request) {
handleAmbient(w, r, core, *ambientToken)
})
log.Printf("mavweb: ambient notification ingest enabled at POST /api/ambient")
}
mux.HandleFunc("/dash", func(w http.ResponseWriter, r *http.Request) {
handleDash(w, r, core)
})
@@ -396,6 +416,11 @@ func main() {
mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) {
handleReminders(w, r, core)
})
// /tasks — capture + review. POST is not step-up gated; see handleTasks for
// why a task write is not in the same class as /tools or /routines.
mux.HandleFunc("/tasks", func(w http.ResponseWriter, r *http.Request) {
handleTasks(w, r, core)
})
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
handleMorning(w, r, core)
})
@@ -732,6 +757,8 @@ var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Pars
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellTopHTML + voiceHTML + shellBottomHTML))
var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellTopHTML + tasksHTML + shellBottomHTML))
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellTopHTML + routinesHTML + shellBottomHTML))
var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap {
@@ -802,6 +829,145 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
}
}
// taskRow is one line on /tasks, with every timestamp already formatted so the
// template holds no date logic.
type taskRow struct {
ID int64
Text string
Source string
Evidence string
Status string
Due string
Created string
Resolved string
}
// handleTasks serves the task review surface (GET) and the four writes it
// offers (POST): add, confirm, done, drop.
//
// Not step-up gated, unlike /tools and /routines, and the difference is the
// point: enabling a tool defines argv Maven will execute, and accepting a
// routine hands the tick loop a new standing reason to interrupt him. A task is
// neither — nothing in the tick loop reads the tasks table, so the worst a
// weaker caller can do here is write a line onto a list he reads himself. It
// still sits behind whatever transport auth fronts mavweb, like every other
// page.
//
// "confirm" is the only interesting move: it promotes a candidate Maven derived
// from something she read into work he owns. That review step is why derived
// tasks are captured as candidates in the first place.
func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if core == nil {
http.Error(w, "tasks disabled (no -core)", http.StatusServiceUnavailable)
return
}
ctx := r.Context()
var msg, errMsg string
if r.Method == http.MethodPost {
var err error
msg, err = applyTaskPost(ctx, core, r)
if err != nil {
log.Printf("tasks: %v", err)
errMsg = err.Error()
}
}
all, err := core.ListTasks(ctx, "")
if err != nil {
log.Printf("tasks: list: %v", err)
http.Error(w, "tasks error: "+err.Error(), http.StatusBadGateway)
return
}
var cands, open, resolved []taskRow
for _, t := range all {
row := taskRow{
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
}
switch t.Status {
case "candidate":
cands = append(cands, row)
case "open":
open = append(open, row)
default:
resolved = append(resolved, row)
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tasksTmpl.Execute(w, struct {
Msg, Err string
Candidates []taskRow
Open []taskRow
Resolved []taskRow
}{msg, errMsg, cands, open, resolved}); err != nil {
log.Printf("tasks render: %v", err)
}
}
// applyTaskPost performs one write and returns the message to show. A bad
// request returns an error, which the page renders inline rather than as a
// bare 400 — this is a form surface, not an API.
func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) {
action := r.FormValue("action")
if action == "add" {
text := strings.TrimSpace(r.FormValue("text"))
if text == "" {
return "", errors.New("empty task text")
}
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: time.Now()}
if d := r.FormValue("due"); d != "" {
due, err := time.ParseInLocation("2006-01-02", d, time.Local)
if err != nil {
return "", fmt.Errorf("bad due date %q", d)
}
req.Due = &due
}
resp, err := core.CaptureTask(ctx, req)
if err != nil {
return "", err
}
if !resp.Created {
return "already on the list", nil
}
return "added task", nil
}
var id int64
if n, _ := fmt.Sscanf(r.FormValue("id"), "%d", &id); n != 1 {
return "", errors.New("invalid id")
}
var status, msg string
switch action {
case "confirm":
status, msg = "open", "confirmed task"
case "done":
status, msg = "done", "task done"
case "drop":
status, msg = "dropped", "dropped task"
default:
return "", fmt.Errorf("unknown action %q", action)
}
if err := core.SetTaskStatus(ctx, id, status, time.Now()); err != nil {
return "", err
}
return msg, nil
}
func fmtTaskTime(t *time.Time) string {
if t == nil || t.IsZero() {
return "—"
}
return t.Local().Format("02 Jan 15:04")
}
func fmtTaskDate(t *time.Time) string {
if t == nil || t.IsZero() {
return "—"
}
return t.Local().Format("02 Jan")
}
// routineRow is one line on the page: what maven noticed, in her words, and
// how long ago she noticed it.
type routineRow struct {
@@ -947,12 +1113,32 @@ func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
http.Error(w, "core read failed", http.StatusBadGateway)
return
}
view := morningView{Routines: status}
// The day plan (#128) shows on this page because it is the same question at
// a different scale. A plan read that fails must not take the checklist
// down with it — the page degrades to what it had before.
plan, err := core.DayPlan(ctx)
if err != nil {
log.Printf("morning: day plan: %v", err)
view.PlanErr = err.Error()
} else {
view.Plan = &plan
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := morningTmpl.Execute(w, status); err != nil {
if err := morningTmpl.Execute(w, view); err != nil {
log.Printf("morning render: %v", err)
}
}
// morningView — what /morning renders: today's plan on top, the checklist
// state under it. PlanErr is set instead of Plan when the core could not build
// a plan, so the page says so rather than showing an empty day.
type morningView struct {
Plan *ipc.DayPlan
PlanErr string
Routines []ipc.MorningRoutineStatus
}
func handleVoice(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := voiceTmpl.Execute(w, nil); err != nil {
+20 -2
View File
@@ -1,9 +1,27 @@
{{template "shellTop" "morning"}}
<h1>Today</h1>
{{with .Plan}}
<div class=hint>{{.Date.Format "02.01.2006"}}</div>
{{if not .Items}}
<div class=hint>nothing planned</div>
{{else}}
<div class=scroll><table class=mono>
<tr><th>at<th>kind<th>what</tr>
{{range .Items}}<tr>
<td>{{.At.Format "15:04"}}</td>
<td class=gray>{{.Kind}}</td>
<td>{{if .Uncertain}}<span class=hint title="relayed notification, not a calendar read">похоже,</span> {{end}}{{.Text}}</td>
</tr>{{end}}
</table></div>
{{end}}
{{end}}
{{if .PlanErr}}<div class=hint>plan unavailable: {{.PlanErr}}</div>{{end}}
<h1>Morning Routines</h1>
{{if not .}}
{{if not .Routines}}
<div class=hint>no morning routines configured</div>
{{else}}
{{range .}}
{{range .Routines}}
<div class="mb-4">
<div><strong>{{.Name}}</strong>
<span class={{if .Active}}green{{else}}gray{{end}}>{{if .Active}}active now{{else}}outside window{{end}}</span>
+76
View File
@@ -0,0 +1,76 @@
{{template "shellTop" "tasks"}}
<h1>Tasks</h1>
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
{{if .Err}}<div class="msg msg-err">{{.Err}}</div>{{end}}
<section class=card>
<h2 class=card-title>add</h2>
<form method=post action=/tasks class=inline-form>
<input type=hidden name=action value=add>
<input type=text name=text placeholder="что нужно сделать" size=44 required>
<input type=date name=due title="due date (optional)">
<button class=btn>add</button>
</form>
</section>
{{if .Candidates}}
<section class=card>
<h2 class=card-title>found, not confirmed <span class=badge>{{len .Candidates}}</span></h2>
<div class=hint>maven derived these from something she read. nothing counts as your work until you confirm it.</div>
<div class=scroll><table>
<tr><th>task</th><th>where from</th><th>due</th><th>captured</th><th></th><th></th></tr>
{{range .Candidates}}<tr>
<td class=text-max>{{.Text}}</td>
<td class=hint>{{.Source}}{{if .Evidence}} — {{.Evidence}}{{end}}</td>
<td>{{.Due}}</td>
<td class=muted>{{.Created}}</td>
<td><form method=post action=/tasks class=inline-form>
<input type=hidden name=id value="{{.ID}}">
<input type=hidden name=action value=confirm>
<button class=btn>confirm</button></form></td>
<td><form method=post action=/tasks class=inline-form>
<input type=hidden name=id value="{{.ID}}">
<input type=hidden name=action value=drop>
<button class="btn btn-muted">drop</button></form></td>
</tr>{{end}}</table></div>
</section>
{{end}}
<section class=card>
<h2 class=card-title>open <span class=badge>{{len .Open}}</span></h2>
{{if .Open}}<div class=scroll><table>
<tr><th>task</th><th>from</th><th>due</th><th>captured</th><th></th><th></th></tr>
{{range .Open}}<tr>
<td class=text-max>{{.Text}}</td>
<td class=hint>{{.Source}}</td>
<td>{{.Due}}</td>
<td class=muted>{{.Created}}</td>
<td><form method=post action=/tasks class=inline-form>
<input type=hidden name=id value="{{.ID}}">
<input type=hidden name=action value=done>
<button class=btn>done</button></form></td>
<td><form method=post action=/tasks class=inline-form>
<input type=hidden name=id value="{{.ID}}">
<input type=hidden name=action value=drop>
<button class="btn btn-muted">drop</button></form></td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-grid"/></svg>
<div>no open tasks</div>
<div class=hint>add one above, or tell maven "добавь в задачи …"</div>
</div>{{end}}
</section>
{{if .Resolved}}
<section class=card>
<h2 class=card-title>resolved <span class=badge>{{len .Resolved}}</span></h2>
<div class=scroll><table>
<tr><th>task</th><th>status</th><th>when</th></tr>
{{range .Resolved}}<tr>
<td class=text-max>{{.Text}}</td>
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
<td class=muted>{{.Resolved}}</td>
</tr>{{end}}</table></div>
</section>
{{end}}
{{template "shellBottom"}}
+166
View File
@@ -0,0 +1,166 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
)
// fakeTaskCore serves the /tasks handler: a canned list plus a log of the
// writes the page made.
type fakeTaskCore struct {
ipc.UnimplementedCoreAPI
tasks []ipc.Task
listErr error
captured []ipc.CaptureTaskReq
created bool
captureErr error
statusID int64
statusVal string
statusErr error
}
func (f *fakeTaskCore) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
if f.listErr != nil {
return nil, f.listErr
}
return f.tasks, nil
}
func (f *fakeTaskCore) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
f.captured = append(f.captured, req)
if f.captureErr != nil {
return ipc.CaptureTaskResp{}, f.captureErr
}
return ipc.CaptureTaskResp{ID: 7, Created: f.created}, nil
}
func (f *fakeTaskCore) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time) error {
f.statusID, f.statusVal = id, status
return f.statusErr
}
func TestHandleTasksSplitsCandidatesFromOpen(t *testing.T) {
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
resolved := now.Add(time.Hour)
core := &fakeTaskCore{tasks: []ipc.Task{
{ID: 1, Text: "купить молоко", Source: "tap:voice", Status: "open", CreatedTs: now},
{ID: 2, Text: "продлить страховку", Source: "email:kami", Evidence: "полис истекает", Status: "candidate", CreatedTs: now},
{ID: 3, Text: "полить цветы", Source: "tap:web", Status: "done", CreatedTs: now, Resolved: &resolved},
}}
rec := httptest.NewRecorder()
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"купить молоко", "продлить страховку", "полить цветы",
"полис истекает", // the evidence trail is visible for review
"found, not confirmed", // candidates get their own section
} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q", want)
}
}
// The candidate must offer confirm, and the open task must not.
if !strings.Contains(body, "value=confirm") {
t.Error("candidate row has no confirm action")
}
}
func TestHandleTasksAddCaptures(t *testing.T) {
core := &fakeTaskCore{created: true}
form := url.Values{"action": {"add"}, "text": {" позвонить в банк "}, "due": {"2026-08-05"}}
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handleTasks(rec, req, core)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if len(core.captured) != 1 {
t.Fatalf("captured %d requests, want 1", len(core.captured))
}
got := core.captured[0]
if got.Text != "позвонить в банк" {
t.Errorf("text = %q, want trimmed", got.Text)
}
if got.Source != "tap:web" {
t.Errorf("source = %q, want tap:web", got.Source)
}
if got.Status != "open" {
t.Errorf("status = %q — a task he typed himself is open, not a candidate", got.Status)
}
if got.Due == nil || got.Due.Format("2006-01-02") != "2026-08-05" {
t.Errorf("due = %v", got.Due)
}
if !strings.Contains(rec.Body.String(), "added task") {
t.Error("no confirmation message")
}
}
func TestHandleTasksAddSaysAlreadyOnTheList(t *testing.T) {
core := &fakeTaskCore{created: false}
form := url.Values{"action": {"add"}, "text": {"купить молоко"}}
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handleTasks(rec, req, core)
if !strings.Contains(rec.Body.String(), "already on the list") {
t.Error("a deduped capture must not claim it saved something new")
}
}
func TestHandleTasksStatusActions(t *testing.T) {
for _, tc := range []struct{ action, want string }{
{"confirm", "open"},
{"done", "done"},
{"drop", "dropped"},
} {
core := &fakeTaskCore{}
form := url.Values{"action": {tc.action}, "id": {"42"}}
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
handleTasks(httptest.NewRecorder(), req, core)
if core.statusID != 42 || core.statusVal != tc.want {
t.Errorf("%s → SetTaskStatus(%d, %q), want (42, %q)", tc.action, core.statusID, core.statusVal, tc.want)
}
}
}
func TestHandleTasksRejectsBadPost(t *testing.T) {
core := &fakeTaskCore{}
form := url.Values{"action": {"explode"}, "id": {"1"}}
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handleTasks(rec, req, core)
// The page still renders, with the error inline — and nothing was written.
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if core.statusVal != "" || len(core.captured) != 0 {
t.Error("an unknown action must write nothing")
}
if !strings.Contains(rec.Body.String(), "unknown action") {
t.Error("error not surfaced on the page")
}
}
func TestHandleTasksNoCore(t *testing.T) {
rec := httptest.NewRecorder()
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), nil)
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("status = %d, want 503", rec.Code)
}
}
+12
View File
@@ -8,6 +8,18 @@
#
# Maven's own compose joins this same network (add `ecosystem` as an external
# network there) to reach nexus:9740 / praxis:8989 / hexis:9741 directly.
#
# NO RELEASE PINNING (Vikunja #354): each `build:` below points at a sibling
# WORKING TREE, so `up --build` ships whatever is checked out there, including
# uncommitted edits. Before bringing this up, check what you are about to
# deploy:
#
# for r in nexus praxis hexis; do git -C ../../../$r status --short; \
# git -C ../../../$r log -1 --oneline; done
#
# The host nginx that fronts these is deploy/ecosystem/nginx.conf — it binds
# the wg and LAN addresses only, with allow/deny. Keep it that way: none of
# these containers has auth of its own.
name: ecosystem
services:
+48 -3
View File
@@ -1,6 +1,7 @@
# Reverse-proxy the three sibling admin UIs. Drop into your nginx sites (or the
# nginx-panel app) and reload. Assumes the compose publishes each service on
# 127.0.0.1:<port>. Add TLS (certbot / your existing cert block) per server.
# Reverse-proxy Maven's own web UI plus the three sibling admin UIs. Drop into
# your nginx sites (or the nginx-panel app) and reload. Assumes the compose
# publishes each service on 127.0.0.1:<port>. Add TLS (certbot / your existing
# cert block) per server.
#
# NOTE: hexis.<domain> previously pointed at the MCP tool — repoint that
# elsewhere first (the app now owns hexis.*).
@@ -12,6 +13,50 @@
# Do NOT "fix" a failed bind by reverting to `listen 80` (all interfaces) —
# that removes the only access control these containers have.
# maven.<domain> → mavweb (docker-compose.yml publishes it on 127.0.0.1:9201).
# Same bind + ACL as the siblings, and for a stronger reason: mavweb serves
# POST /tools, which defines argv that internal/tool EXECUTES, plus POST
# /routines, /api/revert and /api/chat (Vikunja #317). Without
# -webauthn-origin/-webauthn-rpid mavweb has no auth of its own, so this block
# is the auth. If you add TLS and a basic-auth/oauth2-proxy layer, keep the
# allow/deny anyway — belt and braces on an RCE surface.
#
# WebSocket upgrade matters here: /ws carries push-to-talk audio, so the
# Upgrade/Connection headers below are required, not decoration. The map keeps
# `Connection: upgrade` off plain requests; it sits in the http context, which
# is where sites-available files are included — if your nginx already defines
# $connection_upgrade, drop this block.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 10.42.0.1:80;
listen 192.168.1.104:80;
server_name maven.kvmx.ru;
allow 10.42.0.0/24;
allow 192.168.1.0/24;
deny all;
# push-to-talk uploads raw PCM; the default 1m is enough for a short
# utterance but not for a long one.
client_max_body_size 32m;
location / {
proxy_pass http://127.0.0.1:9201;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s; # an LLM turn can take minutes on the iGPU
}
}
server {
listen 10.42.0.1:80;
listen 192.168.1.104:80;
+5
View File
@@ -26,6 +26,11 @@
"severity_ceiling": 2
},
"pattern_proposals": {
"notify": false,
"cooldown": "24h"
},
"nexus": { "url": "http://nexus:9740" },
"praxis": { "url": "http://praxis:8989" },
"hexis": { "url": "http://hexis:9741" },
+43
View File
@@ -25,3 +25,46 @@
6. Add `/eval` API method to `ipc.CoreAPI` (or reuse `Chat` with system context) so mavweb can show evaluation history
7. Add `memory_eval` block to `deploy/mavend.json`
8. Test with synthetic store state — verify observations match expected patterns
---
## Status 2026-08-01 — foundation shipped (Vikunja #248)
**Shipped:** `internal/memeval` (not `internal/memory/eval.go``internal/store`
imports `internal/memory` for the vector backend, so an evaluator that reads
`store.Fact` there would close an import cycle). `Evaluator.Evaluate` reads
`RecentFacts` / `RecentNotes` / `RecentNudges`, prompts the resident model under
a GBNF grammar for at most three `{observation, confidence, suggested_action}`
objects, drops anything under `min_confidence`, deduplicates against what earlier
evaluations wrote, and records the rest as notes with source `infer:memory-eval`.
Driver: `cmd/mavend/memoryeval.go`, its own goroutine on its own ticker. Config:
the `memory_eval` block — **absent ⇒ the loop does not run**. Visibility: `/dash`
already renders notes with their source, so evaluation output is visible with no
UI change.
**Deliberately not shipped — this is policy, not an unfinished edge:**
- *Dispatching observations as care nudges (plan step 4).* An hourly LLM loop
with permission to speak is a machine for generating interruptions, and the
content is model-generated text about his own life. The evaluator has no
dispatcher reference at all, so it cannot reach a channel by accident. Wiring
it to `delivery.Dispatcher` is a separate decision with its own opt-in.
- *Acting on `suggested_action`.* It is recorded inside the note text and
interpreted by nobody. No reminder, routine or fact is created.
- *Writing observation embeddings.* Notes are written with a nil embedding, so
they stay out of the RAG recall pool. Feeding generated text back into the pool
it came from is how a small model starts citing its own guesses as evidence.
**Deferred, wants a decision or another capability:**
- *Plan step 6, the `/eval` IPC method and an evaluation-history view.* `/dash`
covers reading the output; a dedicated trace surface is worth building once
there is real output to look at, and it should probably show the prompt too.
- *`RecentEvents`.* The plan lists it; the evaluator reads facts, notes and
nudges. Detected action/object events already drive pattern proposals (#43), and
duplicating them here would mostly re-derive that.
- *Output quality is unmeasured.* There is no fixture for "did she notice
something true". The tests cover the machinery — empty store, confidence floor,
dedupe, own-notes exclusion, error handling — not the observations. Until
someone reads a week of real output on `/dash`, treat the wording and the
`min_confidence` default as unvalidated.
+33
View File
@@ -27,3 +27,36 @@
6. Wire voice query — `"что я обычно делаю?"` routes to `IntentQuery` → behavior profile lookup → LLM-phrased answer
7. Add IPC read method `MethodGetBehaviorProfile` so mavweb can display it on `/dash`
8. Test with synthetic fact history — verify weekly schedule is correctly inferred
---
## Status (2026-08-01) — partially shipped, deliberately narrowed
Shipped on `overnight/behavior-profile`:
- `internal/memory/behavior.go``BuildProfile` counts habits per weekday out of
self-facts: distinct-day counts (`MinHabitDays = 2`), a median time-of-day, and
`FormatWeekdayRU` / `FormatOverallRU` for the spoken answer.
- `internal/router/habit.go``ParseHabitQuery`, which requires a habit marker
("обычно", "каждую", "привычки", …) and parses the weekday deterministically.
- `cmd/mavend/actions_query.go` — a `habits` query source, so "что я обычно делаю
по вторникам?" is answered.
**Not shipped, and not to be shipped as written:**
- *Step 3, LLM-generated profile stored as a fact.* The profile is COUNTED, not
generated. A 1.7B asked to summarise a year of habits produces fluent claims
about the owner's life that no row supports, and a wrong claim about him is the
most expensive kind of wrong maven can be. Counting is verifiable and cheap.
- *Step 5, incremental updates on fact write.* There is no cache to keep fresh —
the profile is recomputed on the question, so a new fact is already in the next
answer. A cached profile that can disagree with its own rows is two truths.
- *Step 4, proactive daily plan proposals via the dispatcher.* Maven is not a nag,
and a nudge at 08:00 every day proposing the day is the definition of one. The
sanctioned path from "she noticed a pattern" to "she acts on it" already exists:
`internal/pattern/detector.go` proposes a routine, and the owner accepts it on
`/routines`. It goes through him.
Still open, if wanted later: `MethodGetBehaviorProfile` + a `/dash` panel (step 7).
The counted profile needs no new IPC method to be *asked* about — the query source
reads `RecentFacts` over the existing surface — so this is a display concern only.
+10 -1
View File
@@ -65,7 +65,16 @@ func Requirement(m ipc.Method) Authority {
ipc.MethodCreateReminder,
ipc.MethodMarkReminder,
ipc.MethodRecordNudge,
ipc.MethodResolveNudge:
ipc.MethodResolveNudge,
// Task capture (Vikunja #130). Listed explicitly rather than left to
// the default so the intent is on the record: capturing a task is a
// module write, not an allowlist mutation and not a new standing reason
// for Maven to speak — nothing in the tick loop reads tasks. It stays
// at AuthRead, the same rung as CreateReminder, which is the closest
// existing analogue.
ipc.MethodCaptureTask,
ipc.MethodListTasks,
ipc.MethodSetTaskStatus:
return AuthRead
}
// Unknown method ⇒ AuthRead, but ipc.dispatch returns ErrUnknownMethod
+221
View File
@@ -0,0 +1,221 @@
package calendar
import (
"strings"
"time"
"unicode"
)
// Ambient events — the work calendar read (Vikunja #126).
//
// The work calendar is not read by holding a work credential. A corp mail or
// calendar session living on the homelab ties the box's blast radius to the
// employer's data, which is the thing the task exists to refuse. What maven
// reads instead is the SIGNAL: an Android notification-listener on the owner's
// phone relays meeting notifications over wg/LAN, and maven turns the ones that
// clearly describe a meeting into calendar events.
//
// That makes the provenance honest. A notification is evidence about an event,
// not a reading of the calendar, so it is stored under SourceAmbient at
// AmbientConfidence — never indistinguishable from a real CalDAV read, and the
// query path hedges when it recites one.
//
// The parse is deliberately conservative. A notification with no recognisable
// clock reading produces nothing at all: maven is not a guesser-of-truth, and a
// mailbox full of noise turned into invented events is worse than a gap. Mail
// as a notification signal, not a mailbox.
// Notification — one relayed Android notification. Package is the posting app
// (for the log and for the owner to see where a wrong event came from), Title
// and Text are the notification's two text lines, Posted is when the phone
// showed it. Nothing else off the notification is kept.
type Notification struct {
Package string `json:"package"`
Title string `json:"title"`
Text string `json:"text"`
Posted time.Time `json:"posted_at"`
}
// EventFromNotification turns a notification into the event it describes, or
// reports false when it does not clearly describe one.
//
// It needs two things: a clock reading, and a summary that is not just that
// clock reading. Everything else is defaulted — the date is Posted's day (a
// meeting notification is about today or it would not be firing now), and a
// bare start time gets DefaultReminderDuration.
func EventFromNotification(n Notification) (Event, bool) {
if n.Posted.IsZero() {
return Event{}, false
}
line := strings.TrimSpace(n.Title + " " + n.Text)
start, end, ok := parseTimeRange(line)
if !ok {
return Event{}, false
}
summary := notificationSummary(n)
if summary == "" {
return Event{}, false
}
y, m, d := n.Posted.Date()
loc := n.Posted.Location()
s := time.Date(y, m, d, start.hour, start.min, 0, 0, loc)
var e time.Time
if end != nil {
e = time.Date(y, m, d, end.hour, end.min, 0, 0, loc)
// A range that ends before it starts crossed midnight.
if !e.After(s) {
e = e.AddDate(0, 0, 1)
}
} else {
e = s.Add(DefaultReminderDuration)
}
return Event{Summary: summary, Start: s, End: e}, true
}
// notificationSummary picks the text that names the meeting: the title when it
// carries words, otherwise the body. The clock reading is stripped out — it
// already lives in the times, and FactValue renders it again.
func notificationSummary(n Notification) string {
for _, cand := range []string{n.Title, n.Text} {
s := strings.TrimSpace(stripClock(cand))
s = strings.Trim(s, " \t-–—,;:@|·")
s = strings.Join(strings.Fields(s), " ")
if hasLetters(s) {
return s
}
}
return ""
}
type clock struct{ hour, min int }
// parseTimeRange finds the first clock reading in s, and a second one if the
// text spells a range. Accepted separators between hours and minutes are ":"
// and "."; between the two ends of a range, "-", "", "—" or "до".
//
// Bare hours ("в 14") are NOT accepted. Loose digits in a notification are far
// more often a count, a date or an unread badge than a meeting time, and an
// invented event is worse than no event.
func parseTimeRange(s string) (start clock, end *clock, ok bool) {
first, _, firstEnd, ok := nextClock(s, 0)
if !ok {
return clock{}, nil, false
}
sep := strings.TrimLeft(s[firstEnd:], " \t")
for _, p := range []string{"-", "", "—", "до "} {
if !strings.HasPrefix(sep, p) {
continue
}
if second, _, _, ok2 := nextClock(strings.TrimPrefix(sep, p), 0); ok2 {
return first, &second, true
}
break
}
return first, nil, true
}
// nextClock scans s from byte offset `from` for the first HH:MM (or HH.MM) and
// returns it with the byte range it occupied. Digits and separators are ASCII,
// so byte offsets are safe over Cyrillic text.
func nextClock(s string, from int) (c clock, start, end int, ok bool) {
for i := from; i < len(s); i++ {
if !isDigit(s[i]) {
continue
}
j := i
for j < len(s) && isDigit(s[j]) {
j++
}
// A run longer than two digits is a year, an id or an unread count.
if j-i > 2 {
i = j
continue
}
if j >= len(s) || (s[j] != ':' && s[j] != '.') {
i = j
continue
}
k := j + 1
for k < len(s) && isDigit(s[k]) {
k++
}
if k-(j+1) != 2 {
i = j
continue
}
// Reject a group that is a link in a longer dotted or colon chain:
// "2026.08.15" would otherwise offer "08.15" as 08:15, and a deadline
// date invented as a meeting time is exactly the wrong kind of guess.
// A trailing ":ss" is fine — that is a time with seconds.
if i > 0 && (s[i-1] == '.' || s[i-1] == ':' || isDigit(s[i-1])) {
i = k
continue
}
if k < len(s) && s[k] == '.' && k+1 < len(s) && isDigit(s[k+1]) {
i = k
continue
}
hour, min := atoi(s[i:j]), atoi(s[j+1:k])
if hour > 23 || min > 59 {
i = k
continue
}
return clock{hour, min}, i, k, true
}
return clock{}, 0, 0, false
}
func isDigit(b byte) bool { return b >= '0' && b <= '9' }
func atoi(s string) int {
n := 0
for i := 0; i < len(s); i++ {
n = n*10 + int(s[i]-'0')
}
return n
}
// stripClock removes every clock reading from a summary candidate, along with
// the preposition or separator that introduced it.
func stripClock(s string) string {
for {
_, start, end, ok := nextClock(s, 0)
if !ok {
return s
}
head := trimTrailingPreposition(strings.TrimRight(s[:start], "0123456789:.-–— \t"))
s = strings.TrimSpace(strings.TrimSpace(head) + " " + strings.TrimSpace(s[end:]))
}
}
// trimTrailingPreposition drops the word that introduced a clock reading, so
// "Встреча в 14:00" becomes "Встреча" and "с 11:30 до 12:15 Созвон" does not
// keep a dangling "с". It repeats, because a range has two of them.
func trimTrailingPreposition(s string) string {
preps := []string{"в", "с", "до", "от", "at", "from", "to"}
for again := true; again; {
again = false
s = strings.TrimRight(s, " \t")
for _, p := range preps {
if s == p {
return ""
}
if strings.HasSuffix(s, " "+p) {
s = s[:len(s)-len(p)-1]
again = true
break
}
}
}
return s
}
func hasLetters(s string) bool {
for _, r := range s {
if unicode.IsLetter(r) {
return true
}
}
return false
}
+148
View File
@@ -0,0 +1,148 @@
package calendar
import (
"testing"
"time"
)
func TestEventFromNotification(t *testing.T) {
posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.FixedZone("+04", 4*3600))
tests := []struct {
name string
title, text string
wantOK bool
wantSummary string
wantStart string // "15:04"
wantEnd string
}{
{
name: "range in the body",
title: "Планёрка",
text: "10:00-10:30",
wantOK: true,
wantSummary: "Планёрка",
wantStart: "10:00", wantEnd: "10:30",
},
{
name: "russian preposition and single time",
title: "Встреча с подрядчиком в 14:00",
wantOK: true,
wantSummary: "Встреча с подрядчиком",
wantStart: "14:00", wantEnd: "14:30",
},
{
name: "en dash range",
title: "Sprint review",
text: "Today 16:00 17:00, Meet",
wantOK: true,
wantSummary: "Sprint review",
wantStart: "16:00", wantEnd: "17:00",
},
{
name: "до as a range separator",
title: "Созвон",
text: "с 11:30 до 12:15",
wantOK: true,
wantSummary: "Созвон",
wantStart: "11:30", wantEnd: "12:15",
},
{
name: "dotted clock",
title: "Обед 13.00",
wantOK: true,
wantSummary: "Обед",
wantStart: "13:00", wantEnd: "13:30",
},
{
name: "range crossing midnight",
title: "Ночной релиз",
text: "23:30-00:30",
wantOK: true,
wantSummary: "Ночной релиз",
wantStart: "23:30", wantEnd: "00:30",
},
// The conservative half: no clock reading, no event.
{name: "no time at all", title: "3 новых письма", wantOK: false},
{name: "bare hour is not a time", title: "Планёрка в 14", wantOK: false},
{name: "unread count", title: "Входящие", text: "12 непрочитанных", wantOK: false},
{name: "a date is not a clock", title: "Отчёт", text: "срок 2026.08.15", wantOK: false},
{name: "time but nothing named", title: "10:00-10:30", wantOK: false},
{name: "impossible clock", title: "Смена 99:99", wantOK: false},
{name: "empty", wantOK: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev, ok := EventFromNotification(Notification{
Package: "com.google.android.gm",
Title: tt.title,
Text: tt.text,
Posted: posted,
})
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
}
if !ok {
return
}
if ev.Summary != tt.wantSummary {
t.Errorf("summary = %q, want %q", ev.Summary, tt.wantSummary)
}
if got := ev.Start.Format("15:04"); got != tt.wantStart {
t.Errorf("start = %s, want %s", got, tt.wantStart)
}
if got := ev.End.Format("15:04"); got != tt.wantEnd {
t.Errorf("end = %s, want %s", got, tt.wantEnd)
}
if !ev.End.After(ev.Start) {
t.Errorf("end %v must be after start %v", ev.End, ev.Start)
}
// The event lands on the day the phone showed it, in the phone's
// location — not shifted into UTC.
if ev.Start.Location() != posted.Location() {
t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location())
}
if y, m, d := ev.Start.Date(); y != 2026 || m != time.August || d != 3 {
t.Errorf("date = %d-%02d-%02d, want 2026-08-03", y, m, d)
}
})
}
}
func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
if _, ok := EventFromNotification(Notification{Title: "Планёрка 10:00"}); ok {
t.Error("a notification with no posted_at has no date to sit on")
}
}
// An ambient event must never be indistinguishable from a calendar read.
func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) {
ev, ok := EventFromNotification(Notification{
Title: "Планёрка 10:00-10:30",
Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC),
})
if !ok {
t.Fatal("expected an event")
}
if FactKey(ev) == "" || FactValue(ev) == "" {
t.Fatal("ambient events must use the shared fact encoding")
}
if AmbientConfidence >= 1.0 {
t.Fatal("ambient confidence must be below a calendar read's")
}
}
func TestStripClock(t *testing.T) {
tests := []struct{ in, want string }{
{"Встреча в 14:00", "Встреча"},
{"Планёрка 10:00-10:30", "Планёрка"},
{"с 11:30 до 12:15 Созвон", "Созвон"},
{"Ничего", "Ничего"},
}
for _, tt := range tests {
if got := stripClock(tt.in); got != tt.want {
t.Errorf("stripClock(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
+129
View File
@@ -0,0 +1,129 @@
// Package calendar is the one calendar data model the rest of maven shares:
// an Event, the iCal text it is parsed from and rendered to, and the fact
// encoding that puts it in the store.
//
// It exists because three separate features read or write the same events and
// must agree on their shape: the CalDAV read side (cmd/mavcaldav, Vikunja
// #126/#127), the write-only render target that publishes maven's own
// reminders as a calendar (#127), and the day plan that recites them (#128).
// Before this package the parse lived inline in cmd/mavcaldav and the fact key
// format was a Sprintf in two places.
//
// The package is pure: no HTTP, no store, no clock of its own. Callers own the
// impurity, the way internal/morning and internal/loop do.
package calendar
import (
"fmt"
"sort"
"strings"
"time"
)
// Fact sources. A calendar event reaches the store as a
// `facts (kind=env, key=calendar_event_..., source=<one of these>)` row, and
// the source is the whole provenance story:
//
// - SourcePersonal — maven's own Radicale, read AND rendered to. Canonical
// state stays in sqlite; the calendar is a render target (#127).
// - SourceWork — a work calendar, read-only by definition (#126). Nothing in
// maven ever writes to it: no code path pairs this source with a PUT.
// - SourceAmbient — inferred from an Android notification-listener relay
// rather than read from a server (#126). Confidence is below 1.0 because a
// notification is a signal about an event, not the event.
const (
SourcePersonal = "poll:caldav"
SourceWork = "poll:caldav:work"
SourceAmbient = "ambient:notif"
)
// AmbientConfidence — the confidence a notification-derived event is stored
// with. A parsed notification line is evidence, not a reading of the calendar,
// so it must never be indistinguishable from one (#126).
const AmbientConfidence = 0.6
// Sources lists every source a calendar event may legitimately carry, for the
// store query that reads the calendar back out. Ordered from most to least
// trusted.
func Sources() []string {
return []string{SourcePersonal, SourceWork, SourceAmbient}
}
// ReadOnlySource reports whether events from this source may never be written
// back. The work calendar is read-only by definition — see #126: maven holding
// a credential that can write to an employer's calendar is the thing the task
// exists to avoid.
func ReadOnlySource(source string) bool {
return source == SourceWork || source == SourceAmbient
}
// Event — one calendar entry. UID is the iCal UID when the event was parsed
// from a server and the identity maven renders under when it publishes one;
// Start/End are instants. All-day events are not modelled: the busy gate and
// the day plan both need a time of day, and an all-day marker answers neither.
type Event struct {
UID string
Summary string
Start time.Time
End time.Time
}
// FactKey is the store key for an event: one key per day per summary, stable
// across polls so re-reading an unchanged calendar rewrites nothing.
//
// The date prefix is load-bearing — store.CalendarEvents selects a day range
// by key prefix, not by a timestamp column.
func FactKey(e Event) string {
return fmt.Sprintf("calendar_event_%s_%s", e.Start.Format("20060102"), safeKey(e.Summary))
}
// FactValue is the human-readable rendering stored as the fact value, and the
// string the day plan and the query path read back.
func FactValue(e Event) string {
return fmt.Sprintf("%s @ %s-%s", e.Summary, e.Start.Format("15:04"), e.End.Format("15:04"))
}
// KeyPrefixForDay is the fact-key prefix covering one calendar day. The store
// range-scans between two of these.
func KeyPrefixForDay(day time.Time) string {
return fmt.Sprintf("calendar_event_%s", day.Format("20060102"))
}
// Busy reports whether any event covers the instant now — the read the loop
// gate uses to suppress nudges during a meeting.
func Busy(events []Event, now time.Time) bool {
for _, e := range events {
if !now.Before(e.Start) && now.Before(e.End) {
return true
}
}
return false
}
// Overlapping returns the events intersecting [from, to), sorted by start.
func Overlapping(events []Event, from, to time.Time) []Event {
var out []Event
for _, e := range events {
if e.End.After(from) && e.Start.Before(to) {
out = append(out, e)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Start.Before(out[j].Start) })
return out
}
// safeKey makes a summary safe to use inside a fact key (ASCII alphanumerics
// and dashes). Non-Latin summaries collapse to their punctuation, which is why
// the day prefix carries the identity and this only disambiguates within a day.
func safeKey(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-':
b.WriteRune(r)
case r == ' ' || r == '_':
b.WriteRune('-')
}
}
return b.String()
}
+202
View File
@@ -0,0 +1,202 @@
package calendar
import (
"strings"
"testing"
"time"
)
func TestParseICalDayKeepsOnlyToday(t *testing.T) {
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
body := []byte(`BEGIN:VCALENDAR
BEGIN:VEVENT
UID:a@example
DTSTART:20260703T090000Z
DTEND:20260703T100000Z
SUMMARY:Morning standup
END:VEVENT
BEGIN:VEVENT
DTSTART:20260703T140000Z
DTEND:20260703T150000Z
SUMMARY:Team sync
END:VEVENT
BEGIN:VEVENT
DTSTART:20260702T140000Z
DTEND:20260702T150000Z
SUMMARY:Yesterday retro
END:VEVENT
BEGIN:VEVENT
DTSTART:20260704T090000Z
DTEND:20260704T100000Z
SUMMARY:Tomorrow standup
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20260704
DTEND;VALUE=DATE:20260705
SUMMARY:All-day event
END:VEVENT
END:VCALENDAR`)
events := ParseICalDay(body, now)
if len(events) != 2 {
t.Fatalf("got %d events, want 2 (today only, no all-day/past/future)", len(events))
}
if events[0].Summary != "Morning standup" || events[0].UID != "a@example" {
t.Errorf("events[0] = %+v", events[0])
}
if !events[0].Start.Equal(time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC)) {
t.Errorf("events[0].Start = %v", events[0].Start)
}
if !events[0].End.Equal(time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC)) {
t.Errorf("events[0].End = %v", events[0].End)
}
if events[1].Summary != "Team sync" {
t.Errorf("events[1].Summary = %q", events[1].Summary)
}
}
// Regression: "today" is the owner's day, in the owner's location. Taking the
// day number off a local clock but building the boundaries in UTC made the
// evening fall outside the window on any box east of Greenwich.
func TestParseICalDayUsesOwnersDay(t *testing.T) {
plus4 := time.FixedZone("+04", 4*60*60)
// 01:00 on Aug 1 local is 21:00 on Jul 31 UTC.
now := time.Date(2026, 8, 1, 1, 0, 0, 0, plus4)
body := []byte("BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
"DTSTART:20260731T195406Z\nDTEND:20260731T235406Z\nSUMMARY:Current meeting\n" +
"END:VEVENT\nEND:VCALENDAR")
events := ParseICalDay(body, now)
if len(events) != 1 {
t.Fatalf("got %d events, want the in-progress one", len(events))
}
if !Busy(events, now.UTC()) {
t.Error("an event in progress right now must read as busy")
}
}
func TestParseVEVENT(t *testing.T) {
block := "DTSTART;TZID=Europe/Moscow:20260703T130000\nDTEND:20260703T140000Z\nSUMMARY:Stand up meeting"
e, ok := parseVEVENT(block)
if !ok {
t.Fatal("expected a parsed event")
}
if !e.Start.Equal(time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)) {
t.Errorf("start = %v", e.Start)
}
if !e.End.Equal(time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)) {
t.Errorf("end = %v", e.End)
}
if e.Summary != "Stand up meeting" {
t.Errorf("summary = %q", e.Summary)
}
allDay := "DTSTART;VALUE=DATE:20260703\nDTEND;VALUE=DATE:20260704\nSUMMARY:All-day"
if _, ok := parseVEVENT(allDay); ok {
t.Error("all-day event should be rejected")
}
}
func TestParseDT(t *testing.T) {
tests := []struct {
name string
line string
want time.Time
wantOK bool
}{
{"UTC", "DTEND:20260703T100000Z", time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true},
{"local", "DTSTART;TZID=Europe/Moscow:20260703T130000", time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC), true},
{"all-day", "DTSTART;VALUE=DATE:20260703", time.Time{}, false},
{"garbage", "DTSTART:garbage", time.Time{}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := parseDT(tt.line)
if ok != tt.wantOK {
t.Errorf("ok = %v, want %v", ok, tt.wantOK)
}
if !got.Equal(tt.want) {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}
func TestSafeKey(t *testing.T) {
tests := []struct{ in, want string }{
{"Stand up meeting", "Stand-up-meeting"},
{"Hello_World", "Hello-World"},
{"special@#$chars!!", "specialchars"},
{"ALL_CAPS_123", "ALL-CAPS-123"},
}
for _, tt := range tests {
if got := safeKey(tt.in); got != tt.want {
t.Errorf("safeKey(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestFactKeyAndValue(t *testing.T) {
e := Event{
Summary: "Team sync",
Start: time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC),
End: time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC),
}
if got, want := FactKey(e), "calendar_event_20260703_Team-sync"; got != want {
t.Errorf("FactKey = %q, want %q", got, want)
}
if got, want := FactValue(e), "Team sync @ 14:00-15:00"; got != want {
t.Errorf("FactValue = %q, want %q", got, want)
}
if got, want := KeyPrefixForDay(e.Start), "calendar_event_20260703"; got != want {
t.Errorf("KeyPrefixForDay = %q, want %q", got, want)
}
if !strings.HasPrefix(FactKey(e), KeyPrefixForDay(e.Start)) {
t.Error("FactKey must start with the day prefix the store range-scans on")
}
}
func TestBusyAndOverlapping(t *testing.T) {
base := time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)
events := []Event{
{Summary: "late", Start: base.Add(15 * time.Hour), End: base.Add(16 * time.Hour)},
{Summary: "early", Start: base.Add(9 * time.Hour), End: base.Add(10 * time.Hour)},
}
if !Busy(events, base.Add(9*time.Hour+30*time.Minute)) {
t.Error("should be busy inside the early event")
}
if Busy(events, base.Add(12*time.Hour)) {
t.Error("should be free at noon")
}
// Half-open: the end instant is free.
if Busy(events, base.Add(10*time.Hour)) {
t.Error("the end instant should not count as busy")
}
got := Overlapping(events, base.Add(8*time.Hour), base.Add(11*time.Hour))
if len(got) != 1 || got[0].Summary != "early" {
t.Fatalf("Overlapping = %+v", got)
}
all := Overlapping(events, base, base.AddDate(0, 0, 1))
if len(all) != 2 || all[0].Summary != "early" {
t.Fatalf("Overlapping must sort by start: %+v", all)
}
}
func TestSourceTrust(t *testing.T) {
if ReadOnlySource(SourcePersonal) {
t.Error("the personal calendar is the one maven may render to")
}
if !ReadOnlySource(SourceWork) {
t.Error("the work calendar must be read-only")
}
if !ReadOnlySource(SourceAmbient) {
t.Error("an ambient notification is not a writable calendar")
}
if AmbientConfidence >= 1.0 {
t.Error("ambient events must be less trusted than a calendar read")
}
if len(Sources()) != 3 {
t.Errorf("Sources() = %v", Sources())
}
}
+145
View File
@@ -0,0 +1,145 @@
package calendar
import (
"fmt"
"strings"
"time"
)
// ParseICal scans iCal text for VEVENT components and returns the events
// overlapping [from, to). All-day events are skipped: parseDT reports no time
// for a VALUE=DATE value, and an event with no clock reading answers neither
// the busy gate nor the day plan.
func ParseICal(body []byte, from, to time.Time) []Event {
var events []Event
text := string(body)
for {
i := strings.Index(text, "BEGIN:VEVENT")
if i < 0 {
break
}
text = text[i+len("BEGIN:VEVENT"):]
j := strings.Index(text, "END:VEVENT")
if j < 0 {
break
}
block := text[:j]
text = text[j+len("END:VEVENT"):]
e, ok := parseVEVENT(block)
if !ok {
continue
}
if e.End.After(from) && e.Start.Before(to) {
events = append(events, e)
}
}
return events
}
// ParseICalDay is ParseICal over the calendar day containing now, in now's own
// location — the window cmd/mavcaldav polls.
//
// The location matters. The old inline version took the day number off a local
// clock reading but built the boundaries in UTC, so east of Greenwich the
// window was shifted by the offset and part of the evening fell outside
// "today": on a +04 box after 20:00 UTC the poller saw an empty calendar. The
// owner's day is the day the day plan and the busy gate mean.
func ParseICalDay(body []byte, now time.Time) []Event {
y, m, d := now.Date()
start := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
return ParseICal(body, start, start.AddDate(0, 0, 1))
}
// parseVEVENT extracts UID, start, end and summary from a VEVENT block.
// Reports false for all-day events and parse failures.
func parseVEVENT(block string) (Event, bool) {
var e Event
for _, line := range strings.Split(block, "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "DTSTART"):
if t, ok := parseDT(line); ok {
e.Start = t
}
case strings.HasPrefix(line, "DTEND"):
if t, ok := parseDT(line); ok {
e.End = t
}
case strings.HasPrefix(line, "SUMMARY"):
e.Summary = afterColon(line)
case strings.HasPrefix(line, "UID"):
e.UID = afterColon(line)
}
}
if e.Start.IsZero() || e.End.IsZero() {
return Event{}, false
}
return e, true
}
func afterColon(line string) string {
if i := strings.Index(line, ":"); i >= 0 {
return strings.TrimSpace(line[i+1:])
}
return ""
}
// parseDT parses a DTSTART/DTEND value:
//
// - UTC: DTEND:20260703T100000Z
// - Local: DTSTART;TZID=Europe/Moscow:20260703T130000
// - All-day: DTSTART;VALUE=DATE:20260703 (rejected)
//
// A local time is read as UTC, the behaviour cmd/mavcaldav has always had: the
// CalDAV server and the poller run in the same timezone, and the busy gate only
// needs busy/not-busy to be right.
func parseDT(line string) (time.Time, bool) {
if strings.Contains(line, "VALUE=DATE:") {
return time.Time{}, false
}
i := strings.LastIndex(line, ":")
if i < 0 {
return time.Time{}, false
}
val := strings.TrimSuffix(strings.TrimSpace(line[i+1:]), "Z")
t, err := time.Parse("20060102T150405", val)
if err != nil {
return time.Time{}, false
}
return t.UTC(), true
}
// RenderICal wraps events in a VCALENDAR body suitable for PUTting to a CalDAV
// collection. One event per file is the CalDAV convention, so callers normally
// pass a single event.
//
// This is the write half of #127 and it only ever renders: the canonical state
// is sqlite, the calendar is a view of it. Nothing reads a rendered file back.
func RenderICal(events []Event) string {
var b strings.Builder
b.WriteString("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//maven//local calendar//RU\r\n")
for _, e := range events {
b.WriteString("BEGIN:VEVENT\r\n")
fmt.Fprintf(&b, "UID:%s\r\n", escapeText(e.UID))
fmt.Fprintf(&b, "DTSTAMP:%s\r\n", e.Start.UTC().Format("20060102T150405Z"))
fmt.Fprintf(&b, "DTSTART:%s\r\n", e.Start.UTC().Format("20060102T150405Z"))
fmt.Fprintf(&b, "DTEND:%s\r\n", e.End.UTC().Format("20060102T150405Z"))
fmt.Fprintf(&b, "SUMMARY:%s\r\n", escapeText(e.Summary))
b.WriteString("END:VEVENT\r\n")
}
b.WriteString("END:VCALENDAR\r\n")
return b.String()
}
// escapeText applies RFC 5545 TEXT escaping and strips the line breaks that
// would otherwise let a reminder payload inject iCal properties.
func escapeText(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, ";", "\\;")
s = strings.ReplaceAll(s, ",", "\\,")
s = strings.ReplaceAll(s, "\r\n", "\\n")
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "\r", "\\n")
return s
}
+69
View File
@@ -0,0 +1,69 @@
package calendar
import (
"strings"
"testing"
"time"
)
func TestRenderICalRoundTrips(t *testing.T) {
e := ReminderEvent(7, time.Date(2026, 8, 1, 18, 30, 0, 0, time.UTC), "позвонить маме", 0)
if e.UID != "maven-reminder-7" {
t.Errorf("UID = %q", e.UID)
}
if got := e.End.Sub(e.Start); got != DefaultReminderDuration {
t.Errorf("duration = %v, want %v", got, DefaultReminderDuration)
}
if got, want := ReminderPath(7), "maven-reminder-7.ics"; got != want {
t.Errorf("ReminderPath = %q, want %q", got, want)
}
body := RenderICal([]Event{e})
if !strings.HasPrefix(body, "BEGIN:VCALENDAR\r\n") || !strings.HasSuffix(body, "END:VCALENDAR\r\n") {
t.Fatalf("not a VCALENDAR body:\n%s", body)
}
back := ParseICal([]byte(body), e.Start.Add(-time.Hour), e.Start.Add(time.Hour))
if len(back) != 1 {
t.Fatalf("got %d events back, want 1:\n%s", len(back), body)
}
if back[0].UID != e.UID || back[0].Summary != e.Summary {
t.Errorf("round trip lost identity: %+v", back[0])
}
if !back[0].Start.Equal(e.Start) || !back[0].End.Equal(e.End) {
t.Errorf("round trip lost times: %+v", back[0])
}
}
func TestRenderICalIsDeterministic(t *testing.T) {
e := ReminderEvent(1, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), "выпить воды", 0)
if RenderICal([]Event{e}) != RenderICal([]Event{e}) {
t.Error("the same reminder must render byte-identically, or every poll re-PUTs it")
}
}
// A reminder payload is owner-supplied text. It must not be able to close the
// VEVENT and inject properties of its own.
func TestRenderICalEscapesInjection(t *testing.T) {
e := ReminderEvent(2, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC),
"обед\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nSUMMARY:injected", 0)
body := RenderICal([]Event{e})
// Count line-initial occurrences: the escaped text still contains the
// characters "BEGIN:VEVENT", it just can no longer start a line.
if n := strings.Count(body, "\r\nBEGIN:VEVENT\r\n"); n != 1 {
t.Fatalf("payload injected a second VEVENT (%d):\n%s", n, body)
}
if n := strings.Count(body, "\r\nEND:VEVENT\r\n"); n != 1 {
t.Fatalf("payload closed the VEVENT early (%d):\n%s", n, body)
}
if !strings.Contains(body, `SUMMARY:обед\nEND:VEVENT`) {
t.Errorf("newlines should be escaped, not dropped:\n%s", body)
}
}
func TestReminderEventEmptyPayload(t *testing.T) {
e := ReminderEvent(3, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), " ", 0)
if e.Summary != "напоминание" {
t.Errorf("Summary = %q, want the neutral RU fallback", e.Summary)
}
}
+45
View File
@@ -0,0 +1,45 @@
package calendar
import (
"fmt"
"strings"
"time"
)
// ReminderUIDPrefix namespaces every event maven publishes. Two reasons it is a
// fixed prefix and not a random UUID: the render is idempotent (the same
// reminder always lands on the same UID, so re-rendering overwrites instead of
// duplicating), and everything maven owns in the target collection is
// identifiable at a glance — she never touches a file she did not create.
const ReminderUIDPrefix = "maven-reminder-"
// DefaultReminderDuration — how long a rendered reminder occupies. A reminder
// is an instant, a calendar entry is a span, so one has to be invented; 30
// minutes reads as a block in a calendar app without swallowing the afternoon.
const DefaultReminderDuration = 30 * time.Minute
// ReminderEvent maps a reminder to the event that represents it. id and fire
// come from the store; payload is the RU text as the owner said it, rendered
// verbatim as the summary — the calendar is a view of sqlite, not a place to
// rephrase.
func ReminderEvent(id int64, fire time.Time, payload string, dur time.Duration) Event {
if dur <= 0 {
dur = DefaultReminderDuration
}
summary := strings.TrimSpace(payload)
if summary == "" {
summary = "напоминание"
}
return Event{
UID: fmt.Sprintf("%s%d", ReminderUIDPrefix, id),
Summary: summary,
Start: fire,
End: fire.Add(dur),
}
}
// ReminderPath is the collection-relative filename for a rendered reminder.
// One event per resource, per the CalDAV convention.
func ReminderPath(id int64) string {
return fmt.Sprintf("%s%d.ics", ReminderUIDPrefix, id)
}
+87
View File
@@ -140,6 +140,16 @@ type Config struct {
// item. See internal/morning for the evaluation engine. Empty ⇒ disabled.
MorningRoutines []MorningRoutineConfig `json:"morning_routines,omitempty"`
// PatternProposals — whether a routine the digestion tick inferred on its
// own may be announced, and how often. nil / absent ⇒ silent detection
// only: proposals are written for /routines and never announced. See
// PatternProposalConfig.
PatternProposals *PatternProposalConfig `json:"pattern_proposals,omitempty"`
// MemoryEval — background memory evaluation (internal/memeval). nil /
// absent ⇒ no evaluation loop at all. See MemoryEvalConfig.
MemoryEval *MemoryEvalConfig `json:"memory_eval,omitempty"`
// Praxis — the ecosystem attention-state service. When configured, maven
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
// Maven never touches Praxis's database directly (ecosystem invariant: no
@@ -352,6 +362,62 @@ type DigestConfig struct {
SeverityCeiling int `json:"severity_ceiling,omitempty"` // max sev batched
}
// PatternProposalConfig — announcement policy for routines the digestion tick
// inferred by itself (Vikunja #247, #43).
//
// Detection is always on and always silent by default: the tick writes a
// proposed_routines row and the /routines page shows it. Notify is what turns
// "she noticed" into "she said something", and it is OFF unless configured —
// Maven is not a nag and not autonomous, so a behaviour that speaks without
// being asked has to be switched on deliberately, like weather and telegram.
//
// When Notify is on, the announcement is still heavily restrained:
// - at most one proposal per tick, however many were detected;
// - at most one per Cooldown across all pairs (not per pair), so a batch of
// freshly-detected patterns cannot turn into a queue of interruptions;
// - through the ordinary care-class gate (quiet hours / away / snooze), at
// sev1 — the lowest severity there is. A proposal is the least urgent
// thing Maven can say.
//
// A pair is only ever announced once, because it is only ever proposed once:
// proposed_routines is UNIQUE(action, object) and the row survives dismissal.
type PatternProposalConfig struct {
// Notify — announce newly inferred routines. Default false.
Notify bool `json:"notify,omitempty"`
// Cooldown — minimum spacing between two proposal announcements. 0 ⇒
// DefaultProposalCooldown (24h).
Cooldown Duration `json:"cooldown,omitempty"`
}
// AnnounceProposals reports whether inferred routines may be announced. Safe
// on a nil receiver — an absent config block means silent detection.
func (p *PatternProposalConfig) AnnounceProposals() bool {
return p != nil && p.Notify
}
// MemoryEvalConfig — the background memory-evaluation loop (Vikunja #248).
// Absent ⇒ off, like every other capability that costs something the owner did
// not ask for. Each evaluation is a full LLM round-trip on the one resident
// model, which is the same model answering him; running it hourly by default
// would put a multi-second stall in front of an occasional voice turn for a
// feature he may not want.
//
// The loop only ever writes notes (source infer:memory-eval, visible on
// /dash). It cannot speak — see internal/memeval.
type MemoryEvalConfig struct {
// Interval — how often to evaluate. 0 ⇒ DefaultMemoryEvalInterval.
Interval Duration `json:"interval,omitempty"`
// MaxItems — recent facts / notes / nudges fed into one evaluation.
// 0 ⇒ memeval.DefaultMaxItems.
MaxItems int `json:"max_items,omitempty"`
// MinConfidence — observations the model scores below this are dropped.
// 0 ⇒ memeval.DefaultMinConfidence.
MinConfidence float64 `json:"min_confidence,omitempty"`
}
// PhraserConfig — the LLM-backed phraser seam. The daemon spawns llama-server
// as a managed subprocess and sends chat-completion requests to phrase nudge
// and reminder messages. nil ⇒ the template-based Stub is used instead.
@@ -448,6 +514,15 @@ const (
DefaultLLMRouter = true
DefaultFactEnrichmentInterval = 30 * time.Second
// DefaultProposalCooldown — one inferred-routine announcement per day at
// most. A proposal is never urgent; if two patterns surface in the same
// hour, the second one waits, and the /routines page has it either way.
DefaultProposalCooldown = 24 * time.Hour
// DefaultMemoryEvalInterval — the plan's cadence (1h) for the memory
// evaluation loop, applied only when the block is present at all.
DefaultMemoryEvalInterval = time.Hour
)
// Load reads the JSON config at path and applies defaults. A missing file is
@@ -520,6 +595,18 @@ func (c *Config) applyDefaults() {
c.Digest.SeverityCeiling = 2
}
// Absent block stays nil (⇒ silent detection). Present-but-partial gets the
// cooldown default, so `{"notify": true}` is enough to switch it on.
if c.PatternProposals != nil && c.PatternProposals.Cooldown <= 0 {
c.PatternProposals.Cooldown = Duration(DefaultProposalCooldown)
}
// Same rule: absent stays nil (⇒ no evaluation loop), present gets defaults
// so `{}` is a valid "on with the plan's cadence".
if c.MemoryEval != nil && c.MemoryEval.Interval <= 0 {
c.MemoryEval.Interval = Duration(DefaultMemoryEvalInterval)
}
if c.Voice != nil {
if c.Voice.RouterThreshold <= 0 {
c.Voice.RouterThreshold = DefaultRouterThreshold
+50
View File
@@ -243,3 +243,53 @@ func TestDurationRoundTrip(t *testing.T) {
t.Errorf("round-trip = %v, want %v", d2, d)
}
}
// Both new opt-in capabilities follow the same rule: absent block ⇒ nil ⇒ the
// behaviour does not exist. Presence is the enable act, so a bare `{}` block is
// valid and gets the defaults filled in.
func TestOptInBlocksAbsentStayNil(t *testing.T) {
c, err := Load(writeConfig(t, `{}`))
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.PatternProposals != nil {
t.Errorf("pattern_proposals absent but got %+v", c.PatternProposals)
}
if c.PatternProposals.AnnounceProposals() {
t.Error("AnnounceProposals() true with no config block")
}
if c.MemoryEval != nil {
t.Errorf("memory_eval absent but got %+v", c.MemoryEval)
}
}
func TestOptInBlocksGetDefaultsWhenPresent(t *testing.T) {
c, err := Load(writeConfig(t, `{"pattern_proposals":{"notify":true},"memory_eval":{}}`))
if err != nil {
t.Fatalf("Load: %v", err)
}
if !c.PatternProposals.AnnounceProposals() {
t.Error("notify:true did not enable announcements")
}
if time.Duration(c.PatternProposals.Cooldown) != DefaultProposalCooldown {
t.Errorf("proposal cooldown = %v, want %v", c.PatternProposals.Cooldown, DefaultProposalCooldown)
}
if time.Duration(c.MemoryEval.Interval) != DefaultMemoryEvalInterval {
t.Errorf("memory eval interval = %v, want %v", c.MemoryEval.Interval, DefaultMemoryEvalInterval)
}
}
// Notify is off even when the block exists — the block is where you tune it,
// notify:true is the act that lets her speak.
func TestPatternProposalNotifyDefaultsOff(t *testing.T) {
c, err := Load(writeConfig(t, `{"pattern_proposals":{"cooldown":"6h"}}`))
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.PatternProposals.AnnounceProposals() {
t.Error("notify defaulted to on")
}
if time.Duration(c.PatternProposals.Cooldown) != 6*time.Hour {
t.Errorf("cooldown = %v, want 6h", c.PatternProposals.Cooldown)
}
}
+97
View File
@@ -93,6 +93,63 @@ type WriteFactReq struct {
Subject string `json:"subject,omitempty"`
}
// Task — one captured piece of work (Vikunja #130). Status is
// "candidate" (Maven derived it and it is unconfirmed), "open" (his work),
// "done" or "dropped". Source is provenance in the facts vocabulary:
// "tap:voice", "tap:web", "email:<account>". Evidence is the trail a derived
// task came from, empty for anything he stated himself.
type Task struct {
ID int64 `json:"id"`
CreatedTs time.Time `json:"created_ts"`
Text string `json:"text"`
Source string `json:"source"`
Evidence string `json:"evidence,omitempty"`
Status string `json:"status"`
Due *time.Time `json:"due,omitempty"`
Weight int `json:"weight,omitempty"`
Resolved *time.Time `json:"resolved,omitempty"`
}
// CaptureTaskReq — THE INTAKE SEAM. Everything that captures a task goes
// through this one shape: the voice path, the web form, and (Vikunja #246) the
// email reader, which has not been built yet.
//
// An extractor that reads mail sets Source "email:<account>", Status
// "candidate", and Evidence to whatever makes the task reviewable (the subject
// line). It must NOT set Status "open" — work Maven inferred from something she
// read is a suggestion until the owner confirms it on the /tasks page. Capture
// is idempotent on normalised text among live tasks, so re-reading the same
// mailbox is free.
type CaptureTaskReq struct {
Text string `json:"text"`
Source string `json:"source"`
Evidence string `json:"evidence,omitempty"`
Status string `json:"status,omitempty"` // "" ⇒ open
Due *time.Time `json:"due,omitempty"`
Weight int `json:"weight,omitempty"`
Ts time.Time `json:"ts"`
}
// CaptureTaskResp — Created is false when the same live task already existed,
// in which case ID is the existing row. A caller tells the owner "уже в
// списке" rather than claiming it saved something new.
type CaptureTaskResp struct {
ID int64 `json:"id"`
Created bool `json:"created"`
}
type listTasksReq struct {
Status string `json:"status"` // "" all | "live" | candidate|open|done|dropped
}
type listTasksResp struct {
Tasks []Task `json:"tasks"`
}
type setTaskStatusReq struct {
ID int64 `json:"id"`
Status string `json:"status"`
Ts time.Time `json:"ts"`
}
// idReq — methods keyed by a single id.
type idReq struct {
ID int64 `json:"id"`
@@ -294,6 +351,18 @@ type CoreAPI interface {
// loop takes the schedule from there — no reminder is created (Vikunja #366).
AcceptProposedRoutine(ctx context.Context, id int64) error
// CaptureTask records a task. See CaptureTaskReq — this is the single
// intake seam for the voice path, the web form and the future email
// extractor. Idempotent per live normalised text; the response says
// whether a row was actually created.
CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error)
// ListTasks returns tasks in one status, newest first. "" is every row,
// "live" is candidate + open (outstanding work).
ListTasks(ctx context.Context, status string) ([]Task, error)
// SetTaskStatus moves a task forward once: candidate→open|dropped,
// open→done|dropped. Any other move is refused.
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error
// TickTrace returns the most recent tick's rule trace. The daemon caches
// this after every tick; the store adapter returns an error (trace is not
// persisted — it's a daemon-level cache).
@@ -306,6 +375,14 @@ type CoreAPI interface {
// TickTrace.
MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error)
// DayPlan returns today's ordered plan — calendar events, pending
// reminders and any morning checklist still outstanding (see
// internal/morning.BuildPlan) — plus the spoken RU rendering of it.
// Read-only: asking for the plan never dispatches or schedules anything.
// The store adapter returns an error (the plan needs the daemon's routine
// config) — same shape as TickTrace and MorningStatus.
DayPlan(ctx context.Context) (DayPlan, error)
// Chat routes a text utterance through the reactive handler's core path
// (router → dialogue → action → replier) and returns the reply text.
// No audio or stt/tts — for text channels (mavweb, telegram).
@@ -359,6 +436,26 @@ type MorningRoutineStatus struct {
Items []MorningRoutineItem `json:"items"`
}
// DayPlanItem — one line of the day plan. Kind is "event", "reminder" or
// "checklist"; Uncertain marks an item whose provenance is below a full
// calendar read (a meeting relayed off a phone notification), so a UI can hedge
// the same way the spoken form does.
type DayPlanItem struct {
At time.Time `json:"at"`
Text string `json:"text"`
Kind string `json:"kind"`
Uncertain bool `json:"uncertain,omitempty"`
}
// DayPlan — the plan for one calendar day. Spoken is the RU sentence maven
// says when asked, rendered core-side so the voice reply and the web view can
// never drift apart.
type DayPlan struct {
Date time.Time `json:"date"`
Items []DayPlanItem `json:"items"`
Spoken string `json:"spoken"`
}
// storeEncryptionKeyReq — passkey credential public key for wrapping the store
// encryption key at enrollment time. Called by mavweb after RegisterFinish.
type storeEncryptionKeyReq struct {
+30
View File
@@ -69,8 +69,10 @@ var readOnlyMethods = map[Method]bool{
MethodLookupTool: true,
MethodListTools: true,
MethodListProposedRoutines: true,
MethodListTasks: true,
MethodTickTrace: true,
MethodMorningStatus: true,
MethodDayPlan: true,
}
// Dial connects to a core socket at path and returns a Client. The module
@@ -426,6 +428,26 @@ func (c *Client) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, e
return r.Routines, 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 {
return CaptureTaskResp{}, err
}
return r, nil
}
func (c *Client) ListTasks(ctx context.Context, status string) ([]Task, error) {
var r listTasksResp
if err := c.call(ctx, MethodListTasks, listTasksReq{Status: status}, &r); err != nil {
return nil, err
}
return r.Tasks, nil
}
func (c *Client) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
return c.call(ctx, MethodSetTaskStatus, setTaskStatusReq{ID: id, Status: status, Ts: ts}, nil)
}
func (c *Client) DismissProposedRoutine(ctx context.Context, id int64) error {
return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil)
}
@@ -458,6 +480,14 @@ func (c *Client) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, err
return s, nil
}
func (c *Client) DayPlan(ctx context.Context) (DayPlan, error) {
var p DayPlan
if err := c.call(ctx, MethodDayPlan, nil, &p); err != nil {
return DayPlan{}, err
}
return p, nil
}
func (c *Client) RevertFact(ctx context.Context, key string) (int64, error) {
var result struct {
NewID int64 `json:"new_id"`
+65
View File
@@ -211,6 +211,10 @@ func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, e
return nil, errors.New("store: morning status not available via direct store API")
}
func (a *storeAPI) DayPlan(ctx context.Context) (DayPlan, error) {
return DayPlan{}, errors.New("store: day plan not available via direct store API")
}
func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error) {
ts, err := a.s.ListTools(ctx, status)
if err != nil {
@@ -227,6 +231,48 @@ func (a *storeAPI) DeleteTool(ctx context.Context, name string) error {
return mapErr(a.s.DeleteTool(ctx, name))
}
func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
id, created, err := a.s.CaptureTask(ctx, store.Task{
CreatedTs: req.Ts,
Text: req.Text,
Source: req.Source,
Evidence: req.Evidence,
Status: req.Status,
Due: req.Due,
Weight: req.Weight,
})
if err != nil {
return CaptureTaskResp{}, mapErr(err)
}
return CaptureTaskResp{ID: id, Created: created}, nil
}
func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
ts, err := a.s.ListTasks(ctx, status)
if err != nil {
return nil, mapErr(err)
}
out := make([]Task, len(ts))
for i, t := range ts {
out[i] = Task{
ID: t.ID,
CreatedTs: t.CreatedTs,
Text: t.Text,
Source: t.Source,
Evidence: t.Evidence,
Status: t.Status,
Due: t.Due,
Weight: t.Weight,
Resolved: t.ResolvedTs,
}
}
return out, nil
}
func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts))
}
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
rs, err := a.s.ListProposedRoutines(ctx)
if err != nil {
@@ -696,6 +742,22 @@ var methodTable = map[Method]handlerFunc{
MethodDeleteTool: withParamsVoid(func(ctx context.Context, api CoreAPI, p disableToolReq) error {
return api.DeleteTool(ctx, p.Name)
}),
MethodCaptureTask: withParams(func(ctx context.Context, api CoreAPI, p CaptureTaskReq) (CaptureTaskResp, error) {
return api.CaptureTask(ctx, p)
}),
MethodListTasks: withParams(func(ctx context.Context, api CoreAPI, p listTasksReq) (listTasksResp, error) {
out, err := api.ListTasks(ctx, p.Status)
if err != nil {
return listTasksResp{}, err
}
if out == nil {
out = []Task{}
}
return listTasksResp{Tasks: out}, nil
}),
MethodSetTaskStatus: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskStatusReq) error {
return api.SetTaskStatus(ctx, p.ID, p.Status, p.Ts)
}),
MethodListProposedRoutines: withoutParams(func(ctx context.Context, api CoreAPI) (listProposedRoutinesResp, error) {
out, err := api.ListProposedRoutines(ctx)
if err != nil {
@@ -729,6 +791,9 @@ var methodTable = map[Method]handlerFunc{
// MorningStatus intentionally has no nil→[]T{} normalization here — the
// pre-table arm marshaled api.MorningStatus's result as-is (a nil slice
// serializes as JSON null), and this preserves that exact wire shape.
MethodDayPlan: withoutParams(func(ctx context.Context, api CoreAPI) (DayPlan, error) {
return api.DayPlan(ctx)
}),
MethodMorningStatus: withoutParams(func(ctx context.Context, api CoreAPI) ([]MorningRoutineStatus, error) {
return api.MorningStatus(ctx)
}),
+12
View File
@@ -89,6 +89,15 @@ func (UnimplementedCoreAPI) DisableTool(ctx context.Context, name string) error
func (UnimplementedCoreAPI) DeleteTool(ctx context.Context, name string) error {
return ErrNotImplemented
}
func (UnimplementedCoreAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
return CaptureTaskResp{}, ErrNotImplemented
}
func (UnimplementedCoreAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
return nil, ErrNotImplemented
}
func (UnimplementedCoreAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
return ErrNotImplemented
}
func (UnimplementedCoreAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
return nil, ErrNotImplemented
}
@@ -113,6 +122,9 @@ func (UnimplementedCoreAPI) TickTrace(ctx context.Context) (TickTrace, error) {
func (UnimplementedCoreAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
return nil, ErrNotImplemented
}
func (UnimplementedCoreAPI) DayPlan(ctx context.Context) (DayPlan, error) {
return DayPlan{}, ErrNotImplemented
}
func (UnimplementedCoreAPI) Chat(ctx context.Context, text string) (string, error) {
return "", ErrNotImplemented
}
+4
View File
@@ -45,7 +45,11 @@ const (
MethodRevertFact Method = "revert_fact"
MethodTickTrace Method = "tick_trace"
MethodMorningStatus Method = "morning_status"
MethodDayPlan Method = "day_plan"
MethodChat Method = "chat"
MethodCaptureTask Method = "capture_task"
MethodListTasks Method = "list_tasks"
MethodSetTaskStatus Method = "set_task_status"
)
// Request — one frame from module to core. Params is the JSON-encoded argument
+357
View File
@@ -0,0 +1,357 @@
// Package memeval is background memory evaluation (Vikunja #248,
// docs/plans/03-memory-evaluation.md).
//
// It lives beside internal/memory rather than inside it because
// internal/store imports internal/memory for the vector-store backend, and an
// evaluator has to read store.Fact / store.Note / store.Nudge — putting it in
// internal/memory would close that import cycle.
//
// Every so often Maven reads back her own recent memory — facts, notes, the
// nudges she sent — and asks the resident model what it notices: a habit that
// stopped, a gap, something worth saying later. What comes back is written as
// notes with source EvalNoteSource and nothing else happens. That restraint is
// the design, not an unfinished edge:
//
// - She does not speak here. There is no dispatcher, no channel, no nudge.
// An observation is a thought she wrote down; he reads it on /dash when he
// wants to. "Not a nag, not autonomous" (CLAUDE.md) is easy to violate with
// exactly this feature — an hourly loop with an LLM in it and permission to
// talk is a machine for generating interruptions — so the loop has no way
// to reach him at all. Turning observations into nudges is a separate
// decision with a separate opt-in, and it is deliberately NOT in this file.
// - She does not act. No reminder is created, no routine proposed, no fact
// written. The model's suggested_action is recorded as text inside the note
// and interpreted by nobody.
// - She says nothing about an empty store. No memory ⇒ no LLM call ⇒ no
// "observations" invented out of two facts. A 1.7B asked to find a pattern
// will always find one; the defence is not asking.
//
// Everything the evaluator writes is attributable: source is EvalNoteSource, so
// an inferred observation can never be mistaken for something he said, and the
// whole batch is one SQL delete away if the output turns out to be noise.
package memeval
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/kami/maven/internal/llm"
"github.com/kami/maven/internal/persona"
"github.com/kami/maven/internal/store"
)
// EvalNoteSource — the source stamped on every note the evaluator writes.
// Same infer:* convention as the rest of the derived facts.
const EvalNoteSource = "infer:memory-eval"
// DefaultMinConfidence — an observation below this is dropped. The model is
// asked for its own confidence and small models are badly calibrated, so this
// is a coarse filter, not a probability: it exists to throw away the guesses
// the model itself hedged on.
const DefaultMinConfidence = 0.7
// DefaultMaxItems — how much recent memory goes into one evaluation, per
// store. 30 facts + 30 notes + 30 nudges is a few thousand tokens of the 4096
// context the resident Thinking model runs with, which leaves room for its
// reasoning tokens. Raising this trades reasoning room for history.
const DefaultMaxItems = 30
// MaxObservations — the model may return at most this many observations per
// evaluation, enforced by the grammar. A cap here is also a noise cap: an
// evaluation that "notices" ten things has noticed nothing.
const MaxObservations = 3
// Observation — one thing the evaluator noticed.
type Observation struct {
Text string `json:"observation"`
Conf float64 `json:"confidence"`
// Action — what the model thinks should happen with this. Recorded, never
// executed: see the file comment. One of "note", "propose", "notify".
Action string `json:"suggested_action"`
}
// Completer — the llama-server seam, same shape router.Completer uses so the
// one resident model serves this caller too.
type Completer interface {
Complete(ctx context.Context, r llm.Req) (string, error)
}
// Reader — the slice of the store an evaluation reads. Narrow on purpose: the
// evaluator gets recent memory and nothing else. No entity graph, no presence,
// no config facts.
type Reader interface {
RecentFacts(ctx context.Context, n int) ([]store.Fact, error)
RecentNotes(ctx context.Context, n int) ([]store.Note, error)
RecentNudges(ctx context.Context, n int) ([]store.Nudge, error)
}
// NoteWriter — where observations land. Embeddings are passed nil: an
// observation is written for a human to read on /dash, not to be recalled by
// similarity. Feeding LLM-generated text back into the RAG pool it was
// generated from is how a small model starts citing its own guesses as
// evidence.
type NoteWriter interface {
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
}
// Config — evaluator tuning. Zero values are replaced by the Default*
// constants, so the zero Config is the sane one.
type Config struct {
MaxItems int
MinConfidence float64
// ContextBlock — the shared persona block (internal/persona), re-evaluated
// per call so the clock in it is current. Prepended to the system prompt so
// observations come out in Maven's voice: feminine self-reference, informal
// "ты". nil is allowed; the base prompt still carries the address rules.
ContextBlock func() string
}
// Evaluator reads recent memory and records what the model notices.
type Evaluator struct {
read Reader
write NoteWriter
llm Completer
cfg Config
}
func NewEvaluator(r Reader, w NoteWriter, c Completer, cfg Config) *Evaluator {
if cfg.MaxItems <= 0 {
cfg.MaxItems = DefaultMaxItems
}
if cfg.MinConfidence <= 0 {
cfg.MinConfidence = DefaultMinConfidence
}
return &Evaluator{read: r, write: w, llm: c, cfg: cfg}
}
// evalGrammar — GBNF pinning the reply to a bounded JSON array of fixed-shape
// observations. Same reasoning as the router's routeGrammar: the enum and the
// length bound are what stop a small model from drifting into free text or
// filling the token budget with one repeated field.
const evalGrammar = `
root ::= "[" ws (obs ("," ws obs){0,2})? ws "]"
obs ::= "{" ws "\"observation\"" ws ":" ws text "," ws "\"confidence\"" ws ":" ws conf "," ws "\"suggested_action\"" ws ":" ws act ws "}"
text ::= "\"" ([^"\\] | "\\" .){1,200} "\""
conf ::= "0" "." [0-9]{1,2} | "1" ("." "0")?
act ::= "\"note\"" | "\"propose\"" | "\"notify\""
ws ::= [ \t\n]*
`
// evalSystem — the evaluation prompt. Two things it insists on, both learned
// from the phraser: state the observation as something she noticed rather than
// an instruction, and say nothing when there is nothing (the model is given an
// explicit way to return an empty array, because a model with no exit returns
// filler).
const evalSystem = `Ты просматриваешь свою собственную память: недавние факты, заметки и напоминания, которые ты отправляла.
Найди то, что действительно заметно: привычка, которая прервалась; пробел в записях; повторяющаяся закономерность.
Правила:
- Отвечай ТОЛЬКО массивом JSON. Каждый элемент: {"observation": "...", "confidence": 0.0-1.0, "suggested_action": "note"|"propose"|"notify"}.
- observation — короткая фраза по-русски о том, что ты заметила. О себе — в женском роде ("я заметила"). К нему — на "ты".
- Не выдумывай. Если в памяти нет ничего заметного, верни пустой массив [].
- Не давай советов и не приказывай. Ты замечаешь, а не требуешь.
- confidence — насколько ты уверена, что это настоящая закономерность, а не совпадение.
- Максимум три наблюдения. Лучше одно точное, чем три общих.`
// Evaluate runs one evaluation and returns the observations it recorded.
//
// Returns (nil, nil) — not an error — for every ordinary "nothing to say"
// outcome: an empty store, an empty array from the model, everything below the
// confidence floor, or every observation already recorded earlier. Only a real
// read/LLM/write failure is an error, and the caller (a background ticker) logs
// it and waits for the next interval.
func (e *Evaluator) Evaluate(ctx context.Context, now time.Time) ([]Observation, error) {
snap, err := e.snapshot(ctx)
if err != nil {
return nil, err
}
if snap == "" {
return nil, nil // nothing recorded ⇒ nothing to notice, and no LLM call
}
raw, err := e.llm.Complete(ctx, llm.Req{
System: persona.Prepend(e.cfg.ContextBlock, evalSystem),
User: snap,
Grammar: evalGrammar,
MaxTokens: 512,
RepeatPenalty: 1.1,
})
if err != nil {
return nil, fmt.Errorf("memory eval: complete: %w", err)
}
obs, err := parseObservations(raw)
if err != nil {
return nil, fmt.Errorf("memory eval: parse %q: %w", truncate(raw, 120), err)
}
// Dedupe against what earlier evaluations already wrote. Without this an
// hourly loop over a slowly-changing store writes the same sentence every
// hour until /dash is nothing but the evaluator talking to itself.
seen, err := e.recordedTexts(ctx)
if err != nil {
return nil, err
}
var kept []Observation
for _, o := range obs {
o.Text = strings.TrimSpace(o.Text)
if o.Text == "" || o.Conf < e.cfg.MinConfidence {
continue
}
norm := normalizeObservation(o.Text)
if seen[norm] {
continue
}
seen[norm] = true
if _, err := e.write.WriteNote(ctx, now, formatNote(o), nil, EvalNoteSource); err != nil {
return kept, fmt.Errorf("memory eval: write note: %w", err)
}
kept = append(kept, o)
}
return kept, nil
}
// formatNote — the stored text. The suggested action is kept as a visible
// suffix rather than a column: it is the model's opinion about what to do next,
// and the only consumer is a human reading /dash.
func formatNote(o Observation) string {
if o.Action == "" {
return o.Text
}
return fmt.Sprintf("%s [%s]", o.Text, o.Action)
}
// recordedTexts — the normalized text of every observation earlier evaluations
// wrote, for dedupe. Reads a wider window than MaxItems because the point is to
// remember saying it, not to summarize it.
func (e *Evaluator) recordedTexts(ctx context.Context) (map[string]bool, error) {
notes, err := e.read.RecentNotes(ctx, 200)
if err != nil {
return nil, fmt.Errorf("memory eval: recent notes: %w", err)
}
seen := make(map[string]bool, len(notes))
for _, n := range notes {
if n.Source != EvalNoteSource {
continue
}
text := n.Text
// Strip the "[action]" suffix formatNote appended.
if i := strings.LastIndex(text, " ["); i > 0 && strings.HasSuffix(text, "]") {
text = text[:i]
}
seen[normalizeObservation(text)] = true
}
return seen, nil
}
// normalizeObservation — dedupe key. Case- and whitespace-insensitive, which
// catches the realistic repeat (the model re-emitting the same sentence with a
// different comma) without pretending to do semantic dedupe.
func normalizeObservation(s string) string {
return strings.Join(strings.Fields(strings.ToLower(s)), " ")
}
// snapshot renders recent memory as the user turn. Returns "" when there is
// nothing in any store — the caller treats that as "do not ask the model".
//
// Notes written by earlier evaluations are excluded. Feeding her own
// observations back in is how "я заметила, что ты не записывал еду" becomes
// evidence for noticing it again, three evaluations deep.
func (e *Evaluator) snapshot(ctx context.Context) (string, error) {
n := e.cfg.MaxItems
facts, err := e.read.RecentFacts(ctx, n)
if err != nil {
return "", fmt.Errorf("memory eval: recent facts: %w", err)
}
notes, err := e.read.RecentNotes(ctx, n)
if err != nil {
return "", fmt.Errorf("memory eval: recent notes: %w", err)
}
nudges, err := e.read.RecentNudges(ctx, n)
if err != nil {
return "", fmt.Errorf("memory eval: recent nudges: %w", err)
}
var b strings.Builder
wrote := false
if len(facts) > 0 {
b.WriteString("Факты:\n")
for _, f := range facts {
fmt.Fprintf(&b, "- %s %s=%s (%s)\n", f.Ts.Format("2006-01-02 15:04"), f.Key, truncate(f.Value, 80), f.Source)
wrote = true
}
}
own := 0
var noteLines []string
for _, nt := range notes {
if nt.Source == EvalNoteSource {
own++
continue
}
noteLines = append(noteLines, fmt.Sprintf("- %s %s\n", nt.Ts.Format("2006-01-02 15:04"), truncate(nt.Text, 160)))
}
if len(noteLines) > 0 {
b.WriteString("\nЗаметки:\n")
for _, l := range noteLines {
b.WriteString(l)
wrote = true
}
}
if len(nudges) > 0 {
b.WriteString("\nНапоминания, которые ты отправляла:\n")
for _, nd := range nudges {
outcome := nd.Outcome
if outcome == "" {
outcome = "?"
}
fmt.Fprintf(&b, "- %s %s → %s (%s)\n", nd.Ts.Format("2006-01-02 15:04"), nd.Rule, outcome, nd.Channel)
wrote = true
}
}
if !wrote {
// Only her own past observations, or nothing at all. Either way there is
// no new memory to evaluate.
return "", nil
}
b.WriteString("\nЧто ты замечаешь?")
return b.String(), nil
}
// parseObservations reads the model's array. Tolerates the leading/trailing
// prose a Thinking model sometimes emits around JSON by taking the outermost
// bracketed span, the same tolerance the router's parser has.
func parseObservations(raw string) ([]Observation, error) {
s := strings.TrimSpace(raw)
if i := strings.Index(s, "["); i >= 0 {
if j := strings.LastIndex(s, "]"); j > i {
s = s[i : j+1]
}
}
if s == "" {
return nil, nil
}
var obs []Observation
if err := json.Unmarshal([]byte(s), &obs); err != nil {
return nil, err
}
if len(obs) > MaxObservations {
// The grammar bounds this; a grammar-less server or a future prompt
// change must not be able to flood /dash.
sort.SliceStable(obs, func(i, j int) bool { return obs[i].Conf > obs[j].Conf })
obs = obs[:MaxObservations]
}
return obs, nil
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
+271
View File
@@ -0,0 +1,271 @@
package memeval
import (
"context"
"database/sql"
"errors"
"path/filepath"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/llm"
"github.com/kami/maven/internal/store"
)
// fakeLLM — canned replies, one per call, and a record of what it was asked.
type fakeLLM struct {
replies []string
calls []llm.Req
err error
}
func (f *fakeLLM) Complete(_ context.Context, r llm.Req) (string, error) {
f.calls = append(f.calls, r)
if f.err != nil {
return "", f.err
}
if len(f.replies) == 0 {
return "[]", nil
}
out := f.replies[0]
f.replies = f.replies[1:]
return out, nil
}
func newTestStore(t *testing.T) *store.Store {
t.Helper()
st, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "memeval_test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
func refNow() time.Time { return time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) }
// seedMemory writes a little of everything the evaluator reads.
func seedMemory(t *testing.T, st *store.Store, ctx context.Context, now time.Time) {
t.Helper()
for i := 0; i < 3; i++ {
ts := now.Add(-time.Duration(i+1) * 24 * time.Hour)
if _, err := st.WriteFact(ctx, ts, store.KindSelf, "water_ml", "500", "tap:desk", 1.0, sql.NullInt64{}); err != nil {
t.Fatalf("write fact: %v", err)
}
}
if _, err := st.WriteNote(ctx, now.Add(-2*time.Hour), "купить корм для кота", nil, "tap:voice"); err != nil {
t.Fatalf("write note: %v", err)
}
if _, err := st.RecordNudge(ctx, "water", "voice", "пора выпить воды", now.Add(-time.Hour)); err != nil {
t.Fatalf("record nudge: %v", err)
}
}
// TestEvaluateEmptyStoreAsksNothing — the "shuts up when uncertain" floor. An
// empty store must not even reach the model: a small model asked to find a
// pattern in nothing will invent one.
func TestEvaluateEmptyStoreAsksNothing(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
f := &fakeLLM{}
ev := NewEvaluator(st, st, f, Config{})
obs, err := ev.Evaluate(ctx, refNow())
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if len(obs) != 0 {
t.Fatalf("observations on an empty store = %d, want 0", len(obs))
}
if len(f.calls) != 0 {
t.Fatalf("LLM called %d times on an empty store, want 0", len(f.calls))
}
}
// TestEvaluateWritesHighConfidenceObservations — the happy path. Confident
// observations are written as notes stamped infer:memory-eval, and the low
// ones are dropped.
func TestEvaluateWritesHighConfidenceObservations(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedMemory(t, st, ctx, now)
f := &fakeLLM{replies: []string{`[
{"observation":"ты три дня не записывал еду","confidence":0.9,"suggested_action":"notify"},
{"observation":"может быть, ты стал меньше пить воды","confidence":0.3,"suggested_action":"note"}
]`}}
ev := NewEvaluator(st, st, f, Config{})
obs, err := ev.Evaluate(ctx, now)
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if len(obs) != 1 {
t.Fatalf("kept %d observations, want 1 (the 0.3 one is below the floor): %+v", len(obs), obs)
}
if obs[0].Text != "ты три дня не записывал еду" {
t.Errorf("kept the wrong observation: %q", obs[0].Text)
}
notes, err := st.RecentNotes(ctx, 50)
if err != nil {
t.Fatalf("RecentNotes: %v", err)
}
var written []store.Note
for _, n := range notes {
if n.Source == EvalNoteSource {
written = append(written, n)
}
}
if len(written) != 1 {
t.Fatalf("notes with source %s = %d, want 1", EvalNoteSource, len(written))
}
if !strings.Contains(written[0].Text, "ты три дня не записывал еду") {
t.Errorf("note text = %q", written[0].Text)
}
if !strings.Contains(written[0].Text, "[notify]") {
t.Errorf("note text = %q, want the suggested action recorded", written[0].Text)
}
// The prompt must carry the memory it is evaluating, and must not carry a
// grammar-free request.
if len(f.calls) != 1 {
t.Fatalf("LLM calls = %d, want 1", len(f.calls))
}
if !strings.Contains(f.calls[0].User, "water_ml") {
t.Errorf("prompt does not mention the seeded facts:\n%s", f.calls[0].User)
}
if f.calls[0].Grammar == "" {
t.Error("evaluation ran without a grammar")
}
}
// TestEvaluateDeduplicatesAcrossRuns — the failure mode that would make this
// feature unusable: an hourly loop over a store that barely changes writing the
// same sentence every hour until /dash is nothing but the evaluator.
func TestEvaluateDeduplicatesAcrossRuns(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedMemory(t, st, ctx, now)
same := `[{"observation":"ты три дня не записывал еду","confidence":0.9,"suggested_action":"note"}]`
spaced := `[{"observation":"Ты три дня не записывал еду","confidence":0.95,"suggested_action":"note"}]`
f := &fakeLLM{replies: []string{same, same, spaced}}
ev := NewEvaluator(st, st, f, Config{})
for i := 0; i < 3; i++ {
if _, err := ev.Evaluate(ctx, now.Add(time.Duration(i)*time.Hour)); err != nil {
t.Fatalf("Evaluate %d: %v", i, err)
}
}
notes, err := st.RecentNotes(ctx, 50)
if err != nil {
t.Fatalf("RecentNotes: %v", err)
}
n := 0
for _, nt := range notes {
if nt.Source == EvalNoteSource {
n++
}
}
if n != 1 {
t.Fatalf("eval notes after three identical evaluations = %d, want 1", n)
}
}
// TestEvaluateIgnoresOwnNotes — her own observations must not become input.
// Otherwise "я заметила X" is evidence for noticing X again, three evaluations
// deep. With nothing but eval notes in the store there is no new memory, so the
// model is not asked at all.
func TestEvaluateIgnoresOwnNotes(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
if _, err := st.WriteNote(ctx, now.Add(-time.Hour), "я заметила, что ты мало пьёшь [note]", nil, EvalNoteSource); err != nil {
t.Fatalf("write note: %v", err)
}
f := &fakeLLM{}
ev := NewEvaluator(st, st, f, Config{})
obs, err := ev.Evaluate(ctx, now)
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if len(obs) != 0 || len(f.calls) != 0 {
t.Fatalf("observations=%d llm calls=%d, want 0/0 — own notes are not memory to evaluate", len(obs), len(f.calls))
}
}
// TestEvaluateEmptyArrayIsNotAnError — "nothing to say" is the expected outcome
// most of the time and must not be logged as a failure.
func TestEvaluateEmptyArrayIsNotAnError(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedMemory(t, st, ctx, now)
ev := NewEvaluator(st, st, &fakeLLM{replies: []string{"[]"}}, Config{})
obs, err := ev.Evaluate(ctx, now)
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if len(obs) != 0 {
t.Fatalf("observations = %d, want 0", len(obs))
}
}
// TestEvaluateLLMErrorIsReported — a broken llama-server is an error the caller
// logs; it must not silently write anything.
func TestEvaluateLLMErrorIsReported(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedMemory(t, st, ctx, now)
ev := NewEvaluator(st, st, &fakeLLM{err: errors.New("connection refused")}, Config{})
if _, err := ev.Evaluate(ctx, now); err == nil {
t.Fatal("want an error when the model is unreachable")
}
notes, err := st.RecentNotes(ctx, 50)
if err != nil {
t.Fatalf("RecentNotes: %v", err)
}
for _, n := range notes {
if n.Source == EvalNoteSource {
t.Fatalf("wrote a note despite an LLM failure: %q", n.Text)
}
}
}
// TestParseObservationsTolerantAndBounded — Thinking models wrap JSON in prose,
// and no reply may exceed MaxObservations even if the grammar is bypassed.
func TestParseObservationsTolerantAndBounded(t *testing.T) {
obs, err := parseObservations(`<think>hmm</think> вот: [{"observation":"a","confidence":0.9,"suggested_action":"note"}] всё`)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(obs) != 1 || obs[0].Text != "a" {
t.Fatalf("got %+v, want one observation 'a'", obs)
}
var b strings.Builder
b.WriteString("[")
for i := 0; i < MaxObservations+3; i++ {
if i > 0 {
b.WriteString(",")
}
b.WriteString(`{"observation":"x","confidence":0.5,"suggested_action":"note"}`)
}
b.WriteString("]")
obs, err = parseObservations(b.String())
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(obs) != MaxObservations {
t.Fatalf("parsed %d observations, want the %d cap", len(obs), MaxObservations)
}
}
+254
View File
@@ -0,0 +1,254 @@
package memory
import (
"fmt"
"sort"
"strings"
"time"
)
// Behavioural memory — "what do I usually do?" (Vikunja #254).
//
// The profile is COUNTED, not generated. docs/plans/09-behavioral-memory.md
// asks for an LLM to write a behaviour profile daily and store it as a fact;
// this does not do that, on purpose. A 1.7B asked to summarise a year of habits
// will produce fluent claims about the owner's life that no row in the store
// supports, and a wrong claim about him is the most expensive kind of wrong
// maven can be. Counting distinct days per weekday is verifiable, cheap enough
// to run on the question, and cannot invent a habit he does not have.
//
// Recomputed on read rather than cached as a fact for the same reason the store
// is append-only: a cached profile can disagree with the rows it came from, and
// then there are two truths. The plan's step 5 ("profile updates on fact write")
// exists to keep a cache fresh; there is no cache, so a new fact is already in
// the next answer.
//
// It is also read-only and unprompted-free. The plan's step 4 — a morning
// dispatcher nudge proposing the day — is deliberately NOT here: maven is not a
// nag, and proposing plans at 08:00 every day is the definition of one. Pattern
// inference that leads to a routine the owner accepts already exists in
// internal/pattern with the proposal queue on /routines; that is the sanctioned
// path from "she noticed" to "she acts", and it goes through him.
// Observation — one thing the owner was recorded doing, reduced to what a habit
// needs: when, and what. Facts arrive as store/ipc rows; the caller maps them
// so this package stays free of both.
type Observation struct {
At time.Time
Key string
Kind string // "self" | "env" | "config"
}
// Activity — one recurring thing, as counted. Days is the number of DISTINCT
// days it was observed on, which is the number that decides whether something
// is a habit; Count can be inflated by one busy day.
//
// TypicalAt is the median time of day it happens at, rounded to the minute — a
// median and not a mean, so one 03:00 outlier does not move "он обычно пьёт
// воду утром" into the night.
type Activity struct {
Key string
Days int
Count int
TypicalAt time.Duration
}
// Profile — the counted behaviour model. Weekly holds the activities that
// recur on a given weekday, Overall the ones that recur at all.
type Profile struct {
Since time.Time
Until time.Time
Weekly map[time.Weekday][]Activity
All []Activity
}
// MinHabitDays — how many distinct days an activity must appear on before maven
// will call it usual. Two is the smallest number that can distinguish a habit
// from a one-off; below that she says she does not know yet, which is true.
const MinHabitDays = 2
// nonBehaviouralKeyPrefixes — keys that are machinery or one-shot records, not
// behaviour. Calendar events carry the day in the key so they can never repeat;
// cooldown and quiet rows are maven's own tuning state, not his habits.
var nonBehaviouralKeyPrefixes = []string{
"calendar_event_",
"cooldown:",
"quiet",
"behavior_profile",
}
// BuildProfile counts habits out of observations. now bounds the window's upper
// end and supplies the location every day boundary is taken in — a habit is
// "on Tuesdays" in the owner's timezone or it is nothing.
//
// Only self-facts count. An env row is the world (weather, a relayed meeting),
// and a config row is maven's own state; neither says anything about what he
// usually does.
func BuildProfile(obs []Observation, now time.Time) Profile {
loc := now.Location()
p := Profile{Until: now, Weekly: map[time.Weekday][]Activity{}}
type bucket struct {
days map[string]struct{}
count int
mins []int
}
// key → bucket, and (weekday, key) → bucket.
all := map[string]*bucket{}
weekly := map[time.Weekday]map[string]*bucket{}
for _, o := range obs {
if o.Kind != "self" {
continue
}
key := strings.TrimSpace(o.Key)
if key == "" || nonBehavioural(key) {
continue
}
at := o.At.In(loc)
if at.IsZero() || at.After(now) {
continue
}
if p.Since.IsZero() || at.Before(p.Since) {
p.Since = at
}
day := at.Format("2006-01-02")
minute := at.Hour()*60 + at.Minute()
bump := func(m map[string]*bucket) {
b := m[key]
if b == nil {
b = &bucket{days: map[string]struct{}{}}
m[key] = b
}
b.days[day] = struct{}{}
b.count++
b.mins = append(b.mins, minute)
}
bump(all)
wd := at.Weekday()
if weekly[wd] == nil {
weekly[wd] = map[string]*bucket{}
}
bump(weekly[wd])
}
harvest := func(m map[string]*bucket) []Activity {
var out []Activity
for key, b := range m {
if len(b.days) < MinHabitDays {
continue
}
out = append(out, Activity{
Key: key,
Days: len(b.days),
Count: b.count,
TypicalAt: time.Duration(medianInt(b.mins)) * time.Minute,
})
}
// Most-established first, then earliest in the day, then by key so the
// same history always reads back the same way.
sort.Slice(out, func(i, j int) bool {
if out[i].Days != out[j].Days {
return out[i].Days > out[j].Days
}
if out[i].TypicalAt != out[j].TypicalAt {
return out[i].TypicalAt < out[j].TypicalAt
}
return out[i].Key < out[j].Key
})
return out
}
p.All = harvest(all)
for wd, m := range weekly {
if acts := harvest(m); len(acts) > 0 {
p.Weekly[wd] = acts
}
}
return p
}
func nonBehavioural(key string) bool {
for _, p := range nonBehaviouralKeyPrefixes {
if strings.HasPrefix(key, p) {
return true
}
}
return false
}
// medianInt — the middle value, averaging the two middles on an even count.
func medianInt(xs []int) int {
if len(xs) == 0 {
return 0
}
s := make([]int, len(xs))
copy(s, xs)
sort.Ints(s)
mid := len(s) / 2
if len(s)%2 == 1 {
return s[mid]
}
return (s[mid-1] + s[mid]) / 2
}
// weekdayRU — accusative, as "по вторникам" and "в среду" both need it read
// back. Index is time.Weekday.
var weekdayRU = [...]string{"воскресеньям", "понедельникам", "вторникам", "средам", "четвергам", "пятницам", "субботам"}
// activityRU glosses the loop's known fact keys. An unknown key is read back
// verbatim: it is what the store holds, and inventing a Russian phrase for a key
// maven does not recognise would be putting words in his mouth.
var activityRU = map[string]string{
"water": "пьёшь воду",
"meal": "ешь",
"sleep": "спишь",
"break": "делаешь перерыв",
"shower": "принимаешь душ",
"walk": "гуляешь",
"pills": "пьёшь витамины",
"workout": "тренируешься",
}
// FormatWeekdayRU reads back what he usually does on a given weekday.
// Second person singular and informal, as she speaks TO him.
func (p Profile) FormatWeekdayRU(wd time.Weekday) string {
acts := p.Weekly[wd]
day := weekdayRU[int(wd)%7]
if len(acts) == 0 {
return fmt.Sprintf("по %s у меня пока нет ничего постоянного.", day)
}
return fmt.Sprintf("по %s ты обычно %s.", day, joinActivities(acts))
}
// FormatOverallRU reads back the habits that hold across the whole week.
func (p Profile) FormatOverallRU() string {
if len(p.All) == 0 {
return "я ещё не набрала достаточно записей, чтобы говорить о привычках."
}
return fmt.Sprintf("обычно ты %s.", joinActivities(p.All))
}
// maxRecited bounds a spoken profile. A list of fifteen habits read aloud is
// not an answer; the most established few are.
const maxRecited = 5
func joinActivities(acts []Activity) string {
if len(acts) > maxRecited {
acts = acts[:maxRecited]
}
parts := make([]string, len(acts))
for i, a := range acts {
gloss, ok := activityRU[a.Key]
if !ok {
gloss = a.Key
}
parts[i] = fmt.Sprintf("%s около %02d:%02d", gloss,
int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60)
}
if len(parts) == 1 {
return parts[0]
}
return strings.Join(parts[:len(parts)-1], ", ") + " и " + parts[len(parts)-1]
}
+163
View File
@@ -0,0 +1,163 @@
package memory
import (
"strings"
"testing"
"time"
"unicode"
)
// habitHistory — n weeks of the same weekday, at the given local time.
func habitHistory(key string, wd time.Weekday, hh, mm, weeks int, from time.Time) []Observation {
var out []Observation
d := from
for d.Weekday() != wd {
d = d.AddDate(0, 0, -1)
}
for i := 0; i < weeks; i++ {
day := d.AddDate(0, 0, -7*i)
out = append(out, Observation{
At: time.Date(day.Year(), day.Month(), day.Day(), hh, mm, 0, 0, from.Location()),
Key: key,
Kind: "self",
})
}
return out
}
func behaviorNow() time.Time {
// A Monday, so "по вторникам" is a past weekday and not today.
return time.Date(2026, 8, 3, 20, 0, 0, 0, time.UTC)
}
func TestBuildProfileCountsWeekdayHabits(t *testing.T) {
now := behaviorNow()
obs := append(
habitHistory("workout", time.Tuesday, 19, 0, 4, now),
habitHistory("water", time.Tuesday, 9, 0, 3, now)...,
)
p := BuildProfile(obs, now)
tue := p.Weekly[time.Tuesday]
if len(tue) != 2 {
t.Fatalf("got %d tuesday activities, want 2: %+v", len(tue), tue)
}
// Most-established first.
if tue[0].Key != "workout" || tue[0].Days != 4 {
t.Errorf("first = %+v, want workout on 4 days", tue[0])
}
if tue[0].TypicalAt != 19*time.Hour {
t.Errorf("typical at %v, want 19:00", tue[0].TypicalAt)
}
if len(p.Weekly[time.Wednesday]) != 0 {
t.Errorf("wednesday must be empty: %+v", p.Weekly[time.Wednesday])
}
if len(p.All) != 2 {
t.Errorf("the week-wide list should hold both: %+v", p.All)
}
}
// A one-off is not a habit. Saying "ты обычно X" off a single row is a
// confidently wrong claim about his life.
func TestBuildProfileNeedsMoreThanOneDay(t *testing.T) {
now := behaviorNow()
obs := habitHistory("workout", time.Tuesday, 19, 0, 1, now)
// Three rows, same day — a busy Tuesday, not a habit.
obs = append(obs, Observation{At: obs[0].At.Add(time.Hour), Key: "workout", Kind: "self"})
obs = append(obs, Observation{At: obs[0].At.Add(2 * time.Hour), Key: "workout", Kind: "self"})
p := BuildProfile(obs, now)
if len(p.All) != 0 || len(p.Weekly) != 0 {
t.Fatalf("one day of rows must produce no habit: %+v / %+v", p.All, p.Weekly)
}
if got := p.FormatOverallRU(); !strings.Contains(got, "не набрала достаточно") {
t.Errorf("empty profile reads %q", got)
}
}
// Only self-facts describe him. Env rows are the world and config rows are
// maven's own tuning state; counting either as a habit would be a category
// error the owner would then be told about.
func TestBuildProfileIgnoresNonSelfAndMachineryKeys(t *testing.T) {
now := behaviorNow()
var obs []Observation
for _, o := range habitHistory("water", time.Tuesday, 9, 0, 3, now) {
o.Kind = "env"
obs = append(obs, o)
}
for _, o := range habitHistory("cooldown:water", time.Tuesday, 9, 0, 3, now) {
obs = append(obs, o) // kind=self, but a machinery key
}
for _, o := range habitHistory("calendar_event_20260804_standup", time.Tuesday, 10, 0, 3, now) {
obs = append(obs, o)
}
if p := BuildProfile(obs, now); len(p.All) != 0 {
t.Fatalf("nothing here is a habit of his: %+v", p.All)
}
}
// The median, not the mean: one 03:00 outlier must not move a morning habit
// into the night.
func TestBuildProfileTypicalTimeIsMedian(t *testing.T) {
now := behaviorNow()
obs := habitHistory("water", time.Tuesday, 9, 0, 4, now)
obs = append(obs, Observation{At: obs[0].At.AddDate(0, 0, -28).Add(-6 * time.Hour), Key: "water", Kind: "self"})
p := BuildProfile(obs, now)
if len(p.All) != 1 {
t.Fatalf("got %+v", p.All)
}
if p.All[0].TypicalAt != 9*time.Hour {
t.Errorf("typical at %v, want 09:00 despite the outlier", p.All[0].TypicalAt)
}
}
func TestProfileFormatRUPersona(t *testing.T) {
now := behaviorNow()
obs := append(
habitHistory("workout", time.Tuesday, 19, 0, 4, now),
habitHistory("water", time.Tuesday, 9, 5, 3, now)...,
)
p := BuildProfile(obs, now)
got := p.FormatWeekdayRU(time.Tuesday)
want := "по вторникам ты обычно тренируешься около 19:00 и пьёшь воду около 09:05."
if got != want {
t.Errorf("got %q\nwant %q", got, want)
}
if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "ничего постоянного") {
t.Errorf("an unknown weekday reads %q", empty)
}
// Persona: she addresses him informally, never in the masculine about
// herself, and never with a pet name.
for _, s := range []string{got, p.FormatOverallRU(), p.FormatWeekdayRU(time.Thursday)} {
// Whole words: "ничего" contains "его", and a substring test would
// call a correct sentence a persona violation.
for _, tok := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
return !unicode.IsLetter(r)
}) {
switch tok {
case "рад", "понял", "вы", "ваш", "ваши", "милый", "дорогой", "он", "его":
t.Errorf("%q uses %q", s, tok)
}
}
}
}
// An unrecognised key is read back verbatim rather than glossed into something
// maven made up.
func TestProfileUnknownKeyReadBackVerbatim(t *testing.T) {
now := behaviorNow()
p := BuildProfile(habitHistory("починил кран", time.Tuesday, 12, 0, 2, now), now)
if got := p.FormatOverallRU(); !strings.Contains(got, "починил кран") {
t.Errorf("got %q", got)
}
}
// A future-dated row is a clock problem, not a habit.
func TestBuildProfileIgnoresFutureRows(t *testing.T) {
now := behaviorNow()
obs := habitHistory("water", time.Tuesday, 9, 0, 3, now.AddDate(0, 2, 0))
if p := BuildProfile(obs, now); len(p.All) != 0 {
t.Fatalf("future rows counted: %+v", p.All)
}
}
+165
View File
@@ -0,0 +1,165 @@
package morning
import (
"fmt"
"sort"
"strings"
"time"
"github.com/kami/maven/internal/store"
)
// The day plan (Vikunja #128).
//
// It lives here, with the morning routine engine, because it is the same
// question asked at a different scale: the routine knows what is still missing
// from a window, the plan knows what the whole day holds. A parallel system
// would have to re-read the same facts and re-decide what "today" means.
//
// It is pure, like the rest of this package: the daemon reads the calendar,
// the reminders and the checklist facts, and BuildPlan puts them in order.
//
// It is also NOT a nag. A plan she can recite when asked is the whole feature;
// nothing here fires, schedules or announces. Unprompted delivery stays with
// the existing morning nudge and the dispatcher's policy.
// PlanKind — where a plan line came from. It survives into the reply and the
// web view because the three read differently: an event is something happening
// to the owner, a reminder is something he asked for, a checklist item is
// something he has not done yet.
type PlanKind string
const (
PlanEvent PlanKind = "event"
PlanReminder PlanKind = "reminder"
PlanChecklist PlanKind = "checklist"
)
// PlanEntry — one timed thing on the day, as the daemon read it out of the
// store. Text is rendered verbatim; the plan does not rephrase.
//
// Uncertain marks provenance below a full-confidence read — a work meeting
// relayed off a phone notification (#126). It travels through to the reply so
// she hedges instead of reciting a guess as fact.
type PlanEntry struct {
At time.Time
Text string
Kind PlanKind
Uncertain bool
}
// Plan — the ordered day. Date is the calendar day it describes.
type Plan struct {
Date time.Time
Items []PlanEntry
}
// BuildPlan orders everything known about the day Now falls on: calendar
// events, pending reminders, and one line per morning routine that still has
// unfinished items.
//
// Entries outside that calendar day are dropped — a plan for today that
// includes tomorrow's meeting is wrong in a way that is worse than terse.
// Ordering is by time, then by kind, then by text, so the same day always reads
// the same way.
func BuildPlan(routines []Routine, facts map[string]store.Fact, events, reminders []PlanEntry, now time.Time) Plan {
y, m, d := now.Date()
dayStart := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
dayEnd := dayStart.AddDate(0, 0, 1)
p := Plan{Date: dayStart}
for _, group := range [][]PlanEntry{events, reminders} {
for _, e := range group {
at := e.At.In(now.Location())
if at.Before(dayStart) || !at.Before(dayEnd) {
continue
}
if strings.TrimSpace(e.Text) == "" {
continue
}
e.At = at
p.Items = append(p.Items, e)
}
}
p.Items = append(p.Items, checklistEntries(routines, facts, now)...)
sort.SliceStable(p.Items, func(i, j int) bool {
a, b := p.Items[i], p.Items[j]
if !a.At.Equal(b.At) {
return a.At.Before(b.At)
}
if a.Kind != b.Kind {
return a.Kind < b.Kind
}
return a.Text < b.Text
})
return p
}
// checklistEntries renders one line per routine with work left in it, placed at
// the routine's nudge time — where the checklist actually matters in the day.
// A routine that does not apply today, is not in its window, or is already
// complete contributes nothing: the plan says what is left, not what was done.
func checklistEntries(routines []Routine, facts map[string]store.Fact, now time.Time) []PlanEntry {
var out []PlanEntry
for _, r := range routines {
st := Evaluate(r, facts, now)
if !st.Active || len(st.Missing) == 0 {
continue
}
labels := make([]string, 0, len(st.Missing))
for _, it := range st.Missing {
label := it.Label
if label == "" {
label = it.Key
}
labels = append(labels, label)
}
at := r.NudgeAt
if at == "" {
at = r.WindowEnd
}
when, ok := todayAt(at, now)
if !ok {
continue
}
out = append(out, PlanEntry{
At: when,
Text: fmt.Sprintf("%s — осталось: %s", r.Name, strings.Join(labels, ", ")),
Kind: PlanChecklist,
})
}
return out
}
// After returns the part of the plan that has not happened yet — the answer to
// "что дальше?" as opposed to "какие планы на сегодня?". The Date is kept, so an
// empty result still knows which day it is empty for.
func (p Plan) After(now time.Time) Plan {
out := Plan{Date: p.Date}
for _, it := range p.Items {
if it.At.Before(now) {
continue
}
out.Items = append(out.Items, it)
}
return out
}
// FormatRU renders the plan as maven says it. Feminine self-reference,
// informal address, no pet names — and no exhortation: she reads the day back,
// she does not tell him to get on with it.
func (p Plan) FormatRU() string {
if len(p.Items) == 0 {
return fmt.Sprintf("на %s ничего не запланировано.", p.Date.Format("02.01.2006"))
}
parts := make([]string, len(p.Items))
for i, it := range p.Items {
line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text)
if it.Uncertain {
line = "похоже, " + line
}
parts[i] = line
}
return fmt.Sprintf("план на %s: %s.", p.Date.Format("02.01.2006"), strings.Join(parts, "; "))
}
+169
View File
@@ -0,0 +1,169 @@
package morning
import (
"strings"
"testing"
"time"
"github.com/kami/maven/internal/store"
)
// planAt is at() for the plan tests' day (2026-08-03, a Monday); the existing
// at() in morning_test.go is pinned to a different date.
func planAt(now time.Time, hh, mm int) time.Time {
y, m, d := now.Date()
return time.Date(y, m, d, hh, mm, 0, 0, now.Location())
}
func planFixture(t *testing.T) (Plan, time.Time) {
t.Helper()
now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC)
routines := []Routine{{
Name: "утро",
WindowStart: "07:00",
WindowEnd: "11:00",
NudgeAt: "10:30",
Items: []Item{
{Key: "water", FactKey: "drank_water", Label: "выпить воды"},
{Key: "pills", FactKey: "took_pills", Label: "витамины"},
},
}}
facts := map[string]store.Fact{
"drank_water": {Ts: planAt(now, 8, 0)},
}
events := []PlanEntry{
{At: planAt(now, 14, 0), Text: "Планёрка @ 14:00-14:30", Kind: PlanEvent, Uncertain: true},
{At: planAt(now, 10, 0), Text: "Standup @ 10:00-10:30", Kind: PlanEvent},
}
reminders := []PlanEntry{
{At: planAt(now, 18, 30), Text: "позвонить маме", Kind: PlanReminder},
}
return BuildPlan(routines, facts, events, reminders, now), now
}
func TestBuildPlanOrdersTheDay(t *testing.T) {
p, now := planFixture(t)
if !p.Date.Equal(planAt(now, 0, 0)) {
t.Errorf("Date = %v, want midnight of now's day", p.Date)
}
want := []struct {
hhmm string
kind PlanKind
}{
{"10:00", PlanEvent},
{"10:30", PlanChecklist},
{"14:00", PlanEvent},
{"18:30", PlanReminder},
}
if len(p.Items) != len(want) {
t.Fatalf("got %d items, want %d: %+v", len(p.Items), len(want), p.Items)
}
for i, w := range want {
if got := p.Items[i].At.Format("15:04"); got != w.hhmm {
t.Errorf("item %d at %s, want %s", i, got, w.hhmm)
}
if p.Items[i].Kind != w.kind {
t.Errorf("item %d kind %q, want %q", i, p.Items[i].Kind, w.kind)
}
}
}
// The checklist line says what is LEFT. An item already evidenced today must
// not be read back as outstanding.
func TestBuildPlanChecklistListsOnlyMissing(t *testing.T) {
p, _ := planFixture(t)
var line string
for _, it := range p.Items {
if it.Kind == PlanChecklist {
line = it.Text
}
}
if line == "" {
t.Fatal("no checklist line in the plan")
}
if !strings.Contains(line, "витамины") {
t.Errorf("missing item not listed: %q", line)
}
if strings.Contains(line, "выпить воды") {
t.Errorf("a completed item must not be read back as outstanding: %q", line)
}
if !strings.HasPrefix(line, "утро — осталось:") {
t.Errorf("line = %q", line)
}
}
func TestBuildPlanSkipsCompleteAndInactiveRoutines(t *testing.T) {
now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC)
routines := []Routine{
{
Name: "утро", WindowStart: "07:00", WindowEnd: "11:00",
Items: []Item{{Key: "water", FactKey: "drank_water", Label: "выпить воды"}},
},
{
// Not in its window at 09:00.
Name: "вечер", WindowStart: "20:00", WindowEnd: "23:00",
Items: []Item{{Key: "walk", FactKey: "walked", Label: "прогулка"}},
},
}
facts := map[string]store.Fact{"drank_water": {Ts: planAt(now, 8, 0)}}
p := BuildPlan(routines, facts, nil, nil, now)
if len(p.Items) != 0 {
t.Fatalf("a complete routine and an out-of-window one must contribute nothing: %+v", p.Items)
}
if got, want := p.FormatRU(), "на 03.08.2026 ничего не запланировано."; got != want {
t.Errorf("got %q\nwant %q", got, want)
}
}
// A plan for today that includes tomorrow's meeting is worse than terse.
func TestBuildPlanDropsOtherDays(t *testing.T) {
now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC)
events := []PlanEntry{
{At: planAt(now, 10, 0), Text: "today", Kind: PlanEvent},
{At: planAt(now, 10, 0).AddDate(0, 0, 1), Text: "tomorrow", Kind: PlanEvent},
{At: planAt(now, 10, 0).AddDate(0, 0, -1), Text: "yesterday", Kind: PlanEvent},
{At: planAt(now, 12, 0), Text: " ", Kind: PlanEvent},
}
p := BuildPlan(nil, nil, events, nil, now)
if len(p.Items) != 1 || p.Items[0].Text != "today" {
t.Fatalf("got %+v", p.Items)
}
}
func TestPlanFormatRU(t *testing.T) {
p, _ := planFixture(t)
got := p.FormatRU()
want := "план на 03.08.2026: 10:00 — Standup @ 10:00-10:30; " +
"10:30 — утро — осталось: витамины; " +
"похоже, 14:00 — Планёрка @ 14:00-14:30; " +
"18:30 — позвонить маме."
if got != want {
t.Errorf("got %q\nwant %q", got, want)
}
// Persona: she recites, she does not exhort, and she never speaks of
// herself in the masculine or addresses him formally.
for _, bad := range []string{"рад ", "понял", "вы ", "ваш", "милый", "дорогой", "давай же", "не забудь"} {
if strings.Contains(strings.ToLower(got), bad) {
t.Errorf("plan text contains %q: %q", bad, got)
}
}
}
func TestPlanAfter(t *testing.T) {
p, now := planFixture(t)
rest := p.After(planAt(now, 11, 0))
if len(rest.Items) != 2 {
t.Fatalf("got %d items, want the 14:00 and 18:30 ones: %+v", len(rest.Items), rest.Items)
}
if !rest.Date.Equal(p.Date) {
t.Error("After must keep the date, so an empty rest-of-day still knows which day")
}
empty := p.After(planAt(now, 23, 0))
if len(empty.Items) != 0 {
t.Errorf("got %+v", empty.Items)
}
if !strings.Contains(empty.FormatRU(), "ничего не запланировано") {
t.Errorf("empty plan reads %q", empty.FormatRU())
}
}
+13 -3
View File
@@ -20,9 +20,19 @@ type ProposedRoutine struct {
const MaxIntervalRatio = 1.5
// MinEvents is the minimum number of events needed to detect a pattern.
// With N events, there are N-1 intervals; we need at least 2 intervals
// before proposing anything.
const MinEvents = 3
// With N events there are N-1 intervals, so 4 events means 3 intervals.
//
// This used to be 3 (two intervals), which is not a pattern — it is a
// coincidence with a mean. Two gaps of similar length happen constantly:
// water the plants on a Sunday, again the next Sunday, once more the Sunday
// after, and a detector with a ±50% band calls that a weekly routine. The
// cost of being wrong is asymmetric now that the digestion tick scans all of
// history on its own schedule and can announce what it finds: a false
// positive is something the owner has to read and dismiss, and a dismissal
// is permanent, so one bad guess burns that action+object pair forever.
// Three intervals is the cheapest bar that makes a run distinguishable from
// a repeat. False negatives cost one more observation and nothing else.
const MinEvents = 4
// Detect checks whether a sequence of events for the same action+object
// forms a stable recurring pattern. Returns a ProposedRoutine when:
+27 -15
View File
@@ -6,12 +6,13 @@ import (
)
func TestDetectEnoughEvents(t *testing.T) {
// 3 events with 7-day intervals → stable pattern
// MinEvents events with 7-day intervals → stable pattern
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
events := []Event{
{Action: "refill", Object: "cat_water", Ts: base},
{Action: "refill", Object: "cat_water", Ts: base.Add(7 * 24 * time.Hour)},
{Action: "refill", Object: "cat_water", Ts: base.Add(14 * 24 * time.Hour)},
{Action: "refill", Object: "cat_water", Ts: base.Add(21 * 24 * time.Hour)},
}
r, err := Detect(events)
@@ -24,8 +25,8 @@ func TestDetectEnoughEvents(t *testing.T) {
if r.Action != "refill" || r.Object != "cat_water" {
t.Fatalf("action/object: want refill/cat_water, got %s/%s", r.Action, r.Object)
}
if r.N != 3 {
t.Fatalf("want N=3, got %d", r.N)
if r.N != 4 {
t.Fatalf("want N=4, got %d", r.N)
}
// ~7 days
if r.IntervalDays < 6.9 || r.IntervalDays > 7.1 {
@@ -33,19 +34,28 @@ func TestDetectEnoughEvents(t *testing.T) {
}
}
// TestDetectNotEnoughEvents — two intervals are a coincidence, not a routine
// (Vikunja #43). Three same-day-of-week events used to be enough to propose a
// weekly reminder; MinEvents is 4 now so a repeat has to happen a third time
// before Maven calls it a pattern.
func TestDetectNotEnoughEvents(t *testing.T) {
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
events := []Event{
{Action: "refill", Object: "cat_water", Ts: base},
{Action: "refill", Object: "cat_water", Ts: base.Add(7 * 24 * time.Hour)},
}
r, err := Detect(events)
if err != nil {
t.Fatalf("Detect: %v", err)
}
if r != nil {
t.Fatal("want nil for <3 events")
for _, n := range []int{1, 2, MinEvents - 1} {
events := make([]Event, n)
for i := range events {
events[i] = Event{
Action: "refill",
Object: "cat_water",
Ts: base.Add(time.Duration(i) * 7 * 24 * time.Hour),
}
}
r, err := Detect(events)
if err != nil {
t.Fatalf("Detect(%d events): %v", n, err)
}
if r != nil {
t.Fatalf("Detect(%d events) proposed %+v, want nil below MinEvents=%d", n, r, MinEvents)
}
}
}
@@ -68,12 +78,13 @@ func TestDetectEmpty(t *testing.T) {
}
func TestDetectIrregularRejects(t *testing.T) {
// 3 events but wildly irregular: 1 day, then 14 days → ratio 14 > 1.5
// wildly irregular: 1 day, then 14 days → ratio 14 > 1.5
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
events := []Event{
{Action: "refill", Object: "cat_water", Ts: base},
{Action: "refill", Object: "cat_water", Ts: base.Add(1 * 24 * time.Hour)},
{Action: "refill", Object: "cat_water", Ts: base.Add(15 * 24 * time.Hour)},
{Action: "refill", Object: "cat_water", Ts: base.Add(16 * 24 * time.Hour)},
}
r, err := Detect(events)
@@ -117,6 +128,7 @@ func TestDetectSameTimestamp(t *testing.T) {
{Action: "refill", Object: "cat_water", Ts: base},
{Action: "refill", Object: "cat_water", Ts: base},
{Action: "refill", Object: "cat_water", Ts: base.Add(7 * 24 * time.Hour)},
{Action: "refill", Object: "cat_water", Ts: base.Add(14 * 24 * time.Hour)},
}
r, err := Detect(events)
+100 -4
View File
@@ -4,16 +4,112 @@ import (
"fmt"
"strings"
"time"
"unicode"
)
// CalendarEventFormatter formats calendar events into a Russian reply string.
type CalendarEventFormatter struct{}
// Format returns a Russian reply for the given calendar events on the given date.
func (CalendarEventFormatter) Format(events []string, date time.Time) string {
// CalendarEntry — one event to recite. Uncertain marks an event maven did not
// read off a calendar server: the work calendar arrives as relayed phone
// notifications (Vikunja #126), stored below full confidence, and she says so
// rather than reciting a guess as fact.
type CalendarEntry struct {
Text string
Uncertain bool
}
// dayPlanWords — the tokens that ask for the day as a whole rather than for a
// calendar listing. Whole words, not substrings: "планёрка" is a MEETING, and a
// notification about one must not be mistaken for a request for the plan.
var dayPlanWords = []string{
"план", "плана", "плану", "плане", "планом",
"планы", "планов", "планам", "планах",
"расписание", "расписании", "распорядок", "распорядке",
"plan", "plans", "schedule", "agenda",
}
// otherDayWords — a day that is not today. The plan is built for the clock's
// own day only, so an utterance naming another one belongs to the calendar
// listing instead. Claiming it here would answer the wrong day, which is worse
// than answering more tersely.
var otherDayWords = []string{
"завтра", "послезавтра", "вчера", "позавчера",
"tomorrow", "yesterday",
}
// IsDayPlanQuery reports whether an utterance asks for today's plan (Vikunja
// #128) — "какие планы на сегодня?", "что у меня по плану?", "что дальше?".
//
// Deliberately narrow. The calendar listing already answers "что у меня
// сегодня?" and a plan that hijacks every date-bearing question would bury the
// events under checklist lines. Only a plan-shaped ask, and only about today.
func IsDayPlanQuery(text string) bool {
toks := planTokens(text)
for _, t := range toks {
for _, w := range otherDayWords {
if t == w {
return false
}
}
}
for _, t := range toks {
for _, w := range dayPlanWords {
if t == w {
return true
}
}
}
// "что дальше?" / "what's next?" — the rest of the day, with no plan word
// in it. Both tokens rather than adjacency, because "what's" splits into
// "what" and "s" and because "и что потом дальше" is the same question.
return (hasTok(toks, "что") && hasTok(toks, "дальше")) ||
(hasTok(toks, "what") && hasTok(toks, "next"))
}
func hasTok(toks []string, w string) bool {
for _, t := range toks {
if t == w {
return true
}
}
return false
}
// planTokens lowercases and splits on everything that is not a letter or a
// digit, so "планы?" and "что-дальше" tokenize like the plain words do.
func planTokens(text string) []string {
return strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
}
// Format returns a Russian reply for the given calendar events on the given
// date. Every event is treated as certain — use FormatEntries when provenance
// differs between them.
func (f CalendarEventFormatter) Format(events []string, date time.Time) string {
entries := make([]CalendarEntry, len(events))
for i, e := range events {
entries[i] = CalendarEntry{Text: e}
}
return f.FormatEntries(entries, date)
}
// FormatEntries returns a Russian reply, hedging the entries maven is not sure
// about. "похоже" and not "возможно": the notification did arrive, what is
// uncertain is whether it describes the meeting correctly.
func (CalendarEventFormatter) FormatEntries(entries []CalendarEntry, date time.Time) string {
dateStr := date.Format("02.01.2006")
if len(events) == 0 {
if len(entries) == 0 {
return fmt.Sprintf("на %s ничего нет.", dateStr)
}
return fmt.Sprintf("на %s: %s", dateStr, strings.Join(events, "; "))
parts := make([]string, len(entries))
for i, e := range entries {
if e.Uncertain {
parts[i] = "похоже, " + e.Text
continue
}
parts[i] = e.Text
}
return fmt.Sprintf("на %s: %s", dateStr, strings.Join(parts, "; "))
}
+57
View File
@@ -24,3 +24,60 @@ func TestCalendarEventFormatter(t *testing.T) {
t.Errorf("multiple: got %q", got)
}
}
func TestCalendarEventFormatterHedgesUncertainEntries(t *testing.T) {
f := CalendarEventFormatter{}
date := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)
// An event relayed off a phone notification is not a calendar read, and she
// says so instead of reciting a guess as fact.
got := f.FormatEntries([]CalendarEntry{
{Text: "Standup @ 10:00-10:30"},
{Text: "Планёрка @ 14:00-14:30", Uncertain: true},
}, date)
want := "на 06.07.2026: Standup @ 10:00-10:30; похоже, Планёрка @ 14:00-14:30"
if got != want {
t.Errorf("got %q\nwant %q", got, want)
}
// Format is FormatEntries with everything certain.
if got := f.FormatEntries(nil, date); got != "на 06.07.2026 ничего нет." {
t.Errorf("empty: got %q", got)
}
}
func TestIsDayPlanQuery(t *testing.T) {
yes := []string{
"какие планы на сегодня?",
"что у меня по плану",
"расскажи план",
"мой распорядок на сегодня",
"расписание?",
"что дальше?",
"what's next",
"what is my plan today",
}
for _, s := range yes {
if !IsDayPlanQuery(s) {
t.Errorf("IsDayPlanQuery(%q) = false, want true", s)
}
}
no := []string{
// The calendar listing owns these.
"что у меня сегодня?",
"какие планы на завтра?",
"план на послезавтра",
"что было вчера",
// "планёрка" is a meeting, not a request for the plan.
"когда планёрка?",
"запиши планёрку на 14:00",
"какая погода?",
"",
}
for _, s := range no {
if IsDayPlanQuery(s) {
t.Errorf("IsDayPlanQuery(%q) = true, want false", s)
}
}
}
+73
View File
@@ -0,0 +1,73 @@
package router
import "time"
// Habit queries — "что я обычно делаю по вторникам?" (Vikunja #254).
//
// Deterministic matching, like the calendar and plan matchers: the LLM router
// classifies the intent, but WHICH weekday was asked about is a lookup, not a
// generation. A model that answers "по вторникам" for a question about Thursday
// gives a confidently wrong account of the owner's own life.
// HabitQuery — a parsed "what do I usually do" question. Weekday is set only
// when the utterance names one; otherwise the answer covers the whole week.
type HabitQuery struct {
Weekday time.Weekday
HasWeekday bool
}
// habitMarkers — the words that make a question about habit rather than about
// today. Without one of these, "что я делаю" is a question about right now, and
// the recall path owns it.
var habitMarkers = []string{
"обычно", "обычное", "чаще", "постоянно", "привычки", "привычка", "привычках",
"регулярно", "каждый", "каждую", "каждое", "usually", "habits", "habit",
"typically", "normally",
}
// weekdayWords — every form of a weekday name maven needs to recognise,
// including the "по …ам" plural the question is usually phrased in.
var weekdayWords = map[string]time.Weekday{
"понедельник": time.Monday, "понедельникам": time.Monday,
"вторник": time.Tuesday, "вторникам": time.Tuesday,
"среда": time.Wednesday, "среду": time.Wednesday, "средам": time.Wednesday,
"четверг": time.Thursday, "четвергам": time.Thursday,
"пятница": time.Friday, "пятницу": time.Friday, "пятницам": time.Friday,
"суббота": time.Saturday, "субботу": time.Saturday, "субботам": time.Saturday,
"воскресенье": time.Sunday, "воскресеньям": time.Sunday,
"monday": time.Monday, "mondays": time.Monday,
"tuesday": time.Tuesday, "tuesdays": time.Tuesday,
"wednesday": time.Wednesday, "wednesdays": time.Wednesday,
"thursday": time.Thursday, "thursdays": time.Thursday,
"friday": time.Friday, "fridays": time.Friday,
"saturday": time.Saturday, "saturdays": time.Saturday,
"sunday": time.Sunday, "sundays": time.Sunday,
}
// ParseHabitQuery reports whether an utterance asks what the owner usually
// does, and on which weekday if it names one.
//
// A habit marker is required. "что я делаю в среду?" without one is a question
// about this coming Wednesday — the calendar's job — and answering it with a
// statistical average would be answering a different question.
func ParseHabitQuery(text string) (HabitQuery, bool) {
toks := planTokens(text)
marked := false
for _, t := range toks {
for _, m := range habitMarkers {
if t == m {
marked = true
break
}
}
}
if !marked {
return HabitQuery{}, false
}
for _, t := range toks {
if wd, ok := weekdayWords[t]; ok {
return HabitQuery{Weekday: wd, HasWeekday: true}, true
}
}
return HabitQuery{}, true
}
+45
View File
@@ -0,0 +1,45 @@
package router
import (
"testing"
"time"
)
func TestParseHabitQuery(t *testing.T) {
tests := []struct {
in string
ok bool
wd time.Weekday
hasWD bool
}{
{"что я обычно делаю по вторникам?", true, time.Tuesday, true},
{"что я обычно делаю?", true, 0, false},
{"какие у меня привычки", true, 0, false},
{"что я каждую пятницу делаю", true, time.Friday, true},
{"what do i usually do on mondays?", true, time.Monday, true},
// No habit marker: this is a question about the coming Wednesday, and
// the calendar owns it. Answering with an average answers the wrong
// question.
{"что я делаю в среду?", false, 0, false},
{"что у меня сегодня?", false, 0, false},
{"какие планы на сегодня?", false, 0, false},
{"", false, 0, false},
}
for _, tt := range tests {
q, ok := ParseHabitQuery(tt.in)
if ok != tt.ok {
t.Errorf("ParseHabitQuery(%q) ok = %v, want %v", tt.in, ok, tt.ok)
continue
}
if !ok {
continue
}
if q.HasWeekday != tt.hasWD {
t.Errorf("ParseHabitQuery(%q) hasWeekday = %v, want %v", tt.in, q.HasWeekday, tt.hasWD)
continue
}
if q.HasWeekday && q.Weekday != tt.wd {
t.Errorf("ParseHabitQuery(%q) weekday = %v, want %v", tt.in, q.Weekday, tt.wd)
}
}
}
+128
View File
@@ -0,0 +1,128 @@
package router
import "strings"
// Task capture and task listing, matched deterministically (Vikunja #130).
//
// No new intent. The router's intent enum is a contract shared with the
// relabelling prompt in the training workspace (`llm/check_prompt_parity.py`
// enforces it), so adding an eighth intent would mean retraining before a task
// could be captured at all. A task phrased out loud is a note-shaped or
// query-shaped utterance with an explicit marker in it, and the marker is a
// lookup — the same reasoning the calendar, plan and habit matchers already
// follow. What the model classifies is unchanged; what these functions decide
// is which store the turn lands in.
// taskCapturePrefixes — the leading phrases that mean "put this on the list".
// A prefix, not a keyword anywhere in the sentence: "добавь в задачи купить
// молоко" is a capture, "я не добавил молоко в список" is him talking, and only
// position tells them apart.
//
// Everything here is an explicit instruction. There is deliberately no entry
// for "надо" / "нужно" — "надо бы поспать" is a thing he says, not a task he
// files, and a capture path that guesses would fill the list with his moods.
var taskCapturePrefixes = []string{
"добавь в задачи",
"добавь в список задач",
"добавь в список дел",
"добавь в список",
"добавь задачу",
"запиши в задачи",
"запиши задачу",
"новая задача",
"в задачи",
"add a task",
"add task",
"add to my tasks",
"add to tasks",
"new task",
}
// ParseTaskCapture reports whether an utterance explicitly files a task, and
// returns the task text with the marker stripped. A marker with nothing after it
// is not a capture (there is no task in "добавь в задачи") — the caller falls
// through to whatever it would otherwise have done with the turn.
func ParseTaskCapture(text string) (string, bool) {
trimmed := strings.TrimSpace(text)
lower := strings.ToLower(trimmed)
best := ""
for _, p := range taskCapturePrefixes {
if strings.HasPrefix(lower, p) && len(p) > len(best) {
best = p
}
}
if best == "" {
return "", false
}
// Cut on the rune length of the matched prefix. ToLower does not change the
// byte length of Russian or English letters, so the index carries over.
rest := strings.TrimSpace(trimmed[len(best):])
rest = strings.TrimLeft(rest, ":—- ")
rest = strings.TrimSpace(rest)
rest = strings.TrimRight(rest, ".!")
if rest == "" {
return "", false
}
return rest, true
}
// taskListWords — the nouns that make a question be about the task list.
var taskListWords = []string{"задачи", "задачах", "задач", "задачам", "дела", "делах", "дел", "tasks", "todo", "todos"}
// taskListVerbs — the asks that pair with those nouns. "что мне нужно сделать?"
// has no task noun in it at all, so it is matched as a phrase below.
var taskListWordsShortcut = []string{"задачи", "задач", "tasks"}
// IsTaskListQuery reports whether an utterance asks for the outstanding task
// list — "какие у меня задачи?", "что мне нужно сделать?", "список дел".
//
// Narrow on purpose. "как дела?" is a greeting, not a query about work, and it
// contains a task noun; it is excluded explicitly. Anything that mentions a
// task noun without asking for the list falls through to ordinary recall.
func IsTaskListQuery(text string) bool {
toks := planTokens(text)
if len(toks) == 0 {
return false
}
// "как дела" — the greeting. Excluded before anything else matches.
if hasTok(toks, "как") && (hasTok(toks, "дела") || hasTok(toks, "делишки")) {
return false
}
// "что мне нужно сделать" / "что мне делать" — no task noun at all.
if (hasTok(toks, "что") || hasTok(toks, "чем")) &&
(hasTok(toks, "сделать") || hasTok(toks, "заняться")) {
return true
}
if hasTok(toks, "what") && hasTok(toks, "do") {
return true
}
hasNoun := false
for _, t := range toks {
for _, w := range taskListWords {
if t == w {
hasNoun = true
}
}
}
if !hasNoun {
return false
}
// A task noun plus any of: a question word, "список", or a bare
// one/two-word ask ("задачи", "мои задачи").
if hasTok(toks, "какие") || hasTok(toks, "какая") || hasTok(toks, "что") ||
hasTok(toks, "сколько") || hasTok(toks, "список") || hasTok(toks, "покажи") ||
hasTok(toks, "напомни") || hasTok(toks, "my") || hasTok(toks, "list") ||
hasTok(toks, "show") {
return true
}
if len(toks) <= 2 {
for _, t := range toks {
for _, w := range taskListWordsShortcut {
if t == w {
return true
}
}
}
}
return false
}
+60
View File
@@ -0,0 +1,60 @@
package router
import "testing"
func TestParseTaskCapture(t *testing.T) {
cases := []struct {
in string
text string
ok bool
}{
{"добавь в задачи купить молоко", "купить молоко", true},
{"Добавь в список дел: позвонить в банк", "позвонить в банк", true},
{"запиши задачу починить кран.", "починить кран", true},
{"новая задача — оплатить интернет", "оплатить интернет", true},
{"add a task buy milk", "buy milk", true},
// A marker with nothing after it files nothing.
{"добавь в задачи", "", false},
{"новая задача", "", false},
// Not a capture: he is talking, not filing.
{"надо бы поспать", "", false},
{"я не добавил молоко в список", "", false},
{"какие у меня задачи?", "", false},
{"", "", false},
}
for _, c := range cases {
text, ok := ParseTaskCapture(c.in)
if ok != c.ok || text != c.text {
t.Errorf("ParseTaskCapture(%q) = (%q, %v), want (%q, %v)", c.in, text, ok, c.text, c.ok)
}
}
}
func TestIsTaskListQuery(t *testing.T) {
yes := []string{
"какие у меня задачи?",
"что мне нужно сделать?",
"покажи список дел",
"сколько у меня задач?",
"задачи",
"мои задачи",
"what should I do",
}
for _, s := range yes {
if !IsTaskListQuery(s) {
t.Errorf("IsTaskListQuery(%q) = false, want true", s)
}
}
no := []string{
"как дела?",
"какая погода?",
"напомни мне позвонить маме в шесть",
"я сделал зарядку",
"",
}
for _, s := range no {
if IsTaskListQuery(s) {
t.Errorf("IsTaskListQuery(%q) = true, want false", s)
}
}
}
+25 -5
View File
@@ -6,7 +6,10 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/kami/maven/internal/calendar"
)
// WriteFact appends a fact row. confidence must be 1.0 for taps and (0,1) for
@@ -79,17 +82,29 @@ func (s *Store) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
return out, rows.Err()
}
// CalendarEvents returns caldav facts whose key date falls within [from, to).
// CalendarEvents returns calendar facts whose key date falls within [from, to).
// Calendar event keys have the format calendar_event_YYYYMMDD_<summary>.
//
// Every calendar source is included, not just the personal CalDAV poll: the work
// calendar arrives as ambient:notif notifications (Vikunja #126) and belongs in
// the same answer. The source stays on each Fact, along with its confidence, so
// the caller can hedge a reading it did not get from a calendar server —
// filtering by source here would have thrown that judgement away.
func (s *Store) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
prefixFrom := fmt.Sprintf("calendar_event_%s", from.Format("20060102"))
prefixTo := fmt.Sprintf("calendar_event_%s", to.Format("20060102"))
prefixFrom := calendar.KeyPrefixForDay(from)
prefixTo := calendar.KeyPrefixForDay(to)
sources := calendar.Sources()
args := make([]any, 0, len(sources)+2)
for _, src := range sources {
args = append(args, src)
}
args = append(args, prefixFrom, prefixTo)
rows, err := s.db.QueryContext(ctx, `
SELECT id, ts, kind, key, value, source, confidence, voids_id
FROM facts
WHERE source = 'poll:caldav'
WHERE source IN (`+placeholders(len(sources))+`)
AND key >= ? AND key < ?
ORDER BY key`, prefixFrom, prefixTo)
ORDER BY key`, args...)
if err != nil {
return nil, fmt.Errorf("calendar events: %w", err)
}
@@ -254,3 +269,8 @@ func scanFact(r rowScanner) (Fact, error) {
f.VoidsID = voids
return f, nil
}
// placeholders renders n comma-separated SQL bind markers.
func placeholders(n int) string {
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
}
+29
View File
@@ -131,6 +131,35 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
expires_ts INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_digest_entries_status ON digest_entries (status);`,
// #14 — the task capture store (Vikunja #130). Deliberately NOT facts:
// a fact is a claim about the world that gets superseded, a task is a
// piece of work with a lifecycle (captured → open → done), and the
// prioritiser needs to read the live set cheaply.
//
// status: 'candidate' is a task Maven derived from something she read
// (mail, later) and has NOT been confirmed by the owner; 'open' is a task
// he actually stated (or confirmed). Nothing schedules or announces off
// this table — capture is not a nag.
//
// norm is the normalised dedupe key. The unique index is PARTIAL, over
// live rows only: re-capturing "купить молоко" after last week's one is
// done must work, while the same mail arriving twice must not produce two
// rows.
`CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_ts INTEGER NOT NULL,
text TEXT NOT NULL,
norm TEXT NOT NULL,
source TEXT NOT NULL,
evidence TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('candidate','open','done','dropped')),
due_ts INTEGER,
weight INTEGER NOT NULL DEFAULT 0,
resolved_ts INTEGER
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_live_norm ON tasks (norm) WHERE status IN ('candidate','open');
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks (status, created_ts DESC);`,
}
// migrate applies every migration with a number greater than the DB's current
+46
View File
@@ -8,6 +8,8 @@ import (
"path/filepath"
"testing"
"time"
"github.com/kami/maven/internal/calendar"
)
func newTestStore(t *testing.T) *Store {
@@ -378,3 +380,47 @@ func TestCalendarEvents(t *testing.T) {
t.Fatalf("expected 0 events on July 8, got %d", len(events))
}
}
// The work calendar arrives as relayed phone notifications, not a CalDAV read
// (Vikunja #126). Those events belong in the same day's answer, and their
// provenance has to survive the query so the caller can hedge them.
func TestCalendarEventsIncludesAmbientSource(t *testing.T) {
store := newTestStore(t)
defer store.Close()
ctx := context.Background()
day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC)
store.WriteFact(ctx, day.Add(10*time.Hour), KindEnv, "calendar_event_20260803_Aaa-personal",
`"Aaa personal @ 10:00-10:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{})
store.WriteFact(ctx, day.Add(14*time.Hour), KindEnv, "calendar_event_20260803_Bbb-work",
`"Bbb work @ 14:00-14:30"`, calendar.SourceAmbient, calendar.AmbientConfidence, sql.NullInt64{})
// A fact that merely looks like one must still be excluded by source.
store.WriteFact(ctx, day.Add(16*time.Hour), KindEnv, "calendar_event_20260803_Ccc-forged",
`"Ccc forged @ 16:00-16:30"`, "tap:voice", 1.0, sql.NullInt64{})
events, err := store.CalendarEvents(ctx, day, day.AddDate(0, 0, 1))
if err != nil {
t.Fatalf("CalendarEvents: %v", err)
}
if len(events) != 2 {
t.Fatalf("got %d events, want the personal and the ambient one: %+v", len(events), events)
}
bySource := map[string]Fact{}
for _, e := range events {
bySource[e.Source] = e
}
if _, ok := bySource[calendar.SourcePersonal]; !ok {
t.Error("the personal CalDAV event is missing")
}
amb, ok := bySource[calendar.SourceAmbient]
if !ok {
t.Fatal("the ambient work event is missing")
}
if amb.Confidence >= 1.0 {
t.Errorf("ambient confidence = %v, must stay below a calendar read's", amb.Confidence)
}
if _, ok := bySource["tap:voice"]; ok {
t.Error("a non-calendar source must not be read as a calendar event")
}
}
+281
View File
@@ -0,0 +1,281 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"unicode"
)
// Tasks — the capture store (Vikunja #130). One row per piece of work, with a
// lifecycle instead of a valid-time: captured, then either done or dropped.
//
// Why not facts: a fact is a claim about the world and a correction supersedes
// it (append-only, voids_id). A task is not a claim — it is work, it has a
// state that moves forward once, and the read the prioritiser needs is "every
// live task right now", which over an append-only log would mean replaying
// history on every question.
//
// Nothing in this file schedules, fires or announces anything. Capture is a
// store, not a trigger: a task exists to be answered when asked about, and the
// owner's reminders remain the only thing that speaks unprompted.
const (
// TaskCandidate — Maven derived this task from something she read (mail,
// once the email reader exists) and the owner has not confirmed it. A
// candidate is inert: it is listed as a candidate and never counted as work
// he agreed to.
TaskCandidate = "candidate"
// TaskOpen — work the owner stated himself, or a candidate he confirmed.
TaskOpen = "open"
// TaskDone — finished.
TaskDone = "done"
// TaskDropped — declined, or a candidate rejected. Kept for provenance, so
// the same mail cannot resurrect it silently; nothing re-proposes a
// dropped task.
TaskDropped = "dropped"
)
// Task — one captured piece of work.
//
// Source is provenance in the same vocabulary facts use: "tap:voice" for
// something he said, "tap:web" for the review page, "email:<account>" for a
// mail-derived candidate. Evidence is the free-text trail a derived task came
// from (a subject line), empty for anything he stated himself — it is what
// makes a candidate reviewable instead of mysterious.
//
// Due is optional. Weight is an explicit importance hint (0 = none), which the
// prioritiser reads; capture never invents one.
type Task struct {
ID int64
CreatedTs time.Time
Text string
Source string
Evidence string
Status string
Due *time.Time
Weight int
ResolvedTs *time.Time
}
var (
ErrTaskNotFound = errors.New("store: task not found")
ErrTaskEmpty = errors.New("store: task text is empty")
ErrTaskStatus = errors.New("store: invalid task status")
)
// liveTaskStatuses — the two statuses that count as outstanding work.
var liveTaskStatuses = []string{TaskCandidate, TaskOpen}
// CaptureTask inserts a task, or returns the existing live task when the same
// work is already outstanding. created reports which happened, so a caller can
// tell the owner "уже в списке" instead of pretending it wrote something.
//
// Dedupe is on the normalised text among LIVE rows only (see the partial unique
// index in migration #14): a weekly errand can be captured again once the last
// one is done, but a mail that gets re-read produces no second row. This is the
// property the email intake depends on — it may call CaptureTask for every
// message it extracts from, as often as it likes, without growing the list.
func (s *Store) CaptureTask(ctx context.Context, t Task) (id int64, created bool, err error) {
text := strings.TrimSpace(t.Text)
if text == "" {
return 0, false, ErrTaskEmpty
}
status := t.Status
if status == "" {
status = TaskOpen
}
if status != TaskCandidate && status != TaskOpen {
// Capturing straight into a resolved state is meaningless — a task is
// captured live and moved later.
return 0, false, fmt.Errorf("%w: capture status %q", ErrTaskStatus, status)
}
norm := NormalizeTaskText(text)
created2 := t.CreatedTs
if created2.IsZero() {
created2 = time.Now()
}
var due sql.NullInt64
if t.Due != nil {
due = sql.NullInt64{Int64: t.Due.UnixMilli(), Valid: true}
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO tasks (created_ts, text, norm, source, evidence, status, due_ts, weight)
VALUES (?,?,?,?,?,?,?,?)
ON CONFLICT (norm) WHERE status IN ('candidate','open') DO NOTHING`,
created2.UnixMilli(), text, norm, t.Source, t.Evidence, status, due, t.Weight)
if err != nil {
return 0, false, fmt.Errorf("capture task: %w", err)
}
if n, err := res.RowsAffected(); err != nil {
return 0, false, fmt.Errorf("capture task: rows affected: %w", err)
} else if n > 0 {
id, err := res.LastInsertId()
if err != nil {
return 0, false, fmt.Errorf("capture task: last insert id: %w", err)
}
return id, true, nil
}
// Already live — hand back the row that won.
existing, err := s.lookupLiveTaskByNorm(ctx, norm)
if err != nil {
return 0, false, err
}
return existing.ID, false, nil
}
// lookupLiveTaskByNorm finds the outstanding task with this normalised text.
func (s *Store) lookupLiveTaskByNorm(ctx context.Context, norm string) (Task, error) {
row := s.db.QueryRowContext(ctx, taskSelect+`
WHERE norm = ? AND status IN ('candidate','open')`, norm)
t, err := scanTask(row)
if errors.Is(err, sql.ErrNoRows) {
return Task{}, ErrTaskNotFound
}
if err != nil {
return Task{}, fmt.Errorf("lookup live task: %w", err)
}
return t, nil
}
const taskSelect = `SELECT id, created_ts, text, source, evidence, status, due_ts, weight, resolved_ts FROM tasks`
// LookupTask returns one task by id.
func (s *Store) LookupTask(ctx context.Context, id int64) (Task, error) {
row := s.db.QueryRowContext(ctx, taskSelect+` WHERE id = ?`, id)
t, err := scanTask(row)
if errors.Is(err, sql.ErrNoRows) {
return Task{}, fmt.Errorf("%w: id=%d", ErrTaskNotFound, id)
}
if err != nil {
return Task{}, fmt.Errorf("lookup task: %w", err)
}
return t, nil
}
// ListTasks returns tasks in one status, newest first. An empty status returns
// every row; "live" returns candidate + open, which is what every read path
// that means "outstanding work" wants.
func (s *Store) ListTasks(ctx context.Context, status string) ([]Task, error) {
q := taskSelect
var args []any
switch status {
case "":
case "live":
q += ` WHERE status IN (?,?)`
args = append(args, liveTaskStatuses[0], liveTaskStatuses[1])
default:
q += ` WHERE status = ?`
args = append(args, status)
}
q += ` ORDER BY created_ts DESC, id DESC`
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("list tasks: %w", err)
}
defer rows.Close()
var out []Task
for rows.Next() {
t, err := scanTask(rows)
if err != nil {
return nil, fmt.Errorf("list tasks: %w", err)
}
out = append(out, t)
}
return out, rows.Err()
}
// SetTaskStatus moves a task once, forward. The legal moves are:
//
// candidate → open (the owner confirms a derived task)
// candidate → dropped (he rejects it)
// open → done (finished)
// open → dropped (abandoned)
//
// Anything else — including re-resolving a resolved task — is refused with
// ErrTaskNotFound-wrapped detail, the same one-way shape proposed_routines and
// tools use: an answered question is not answered twice.
//
// Resolving frees the dedupe key, which is the point: the work can recur.
func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
var from []string
switch status {
case TaskOpen:
from = []string{TaskCandidate}
case TaskDone:
from = []string{TaskOpen}
case TaskDropped:
from = []string{TaskCandidate, TaskOpen}
default:
return fmt.Errorf("%w: %q", ErrTaskStatus, status)
}
// resolved_ts is only meaningful for a terminal state; confirming a
// candidate leaves it null (the task is still live).
var resolved sql.NullInt64
if status == TaskDone || status == TaskDropped {
resolved = sql.NullInt64{Int64: ts.UnixMilli(), Valid: true}
}
q := `UPDATE tasks SET status = ?, resolved_ts = ? WHERE id = ? AND status IN (?` +
strings.Repeat(",?", len(from)-1) + `)`
args := []any{status, resolved, id}
for _, f := range from {
args = append(args, f)
}
res, err := s.db.ExecContext(ctx, q, args...)
if err != nil {
return fmt.Errorf("set task status: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("set task status: rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("%w: id=%d not in %v", ErrTaskNotFound, id, from)
}
return nil
}
// NormalizeTaskText is the dedupe key: lowercased, punctuation dropped,
// whitespace collapsed. Exported because the intake seam (and its tests) needs
// to reason about what will and will not be treated as the same task.
//
// Deliberately shallow — no stemming, no synonyms. Russian morphology would
// need a real lemmatiser to do better, and a normaliser that guesses would
// silently swallow two different tasks. This only catches the case that
// actually happens: the same sentence arriving twice with different casing or
// punctuation.
func NormalizeTaskText(s string) string {
var b strings.Builder
space := true // leading space collapses to nothing
for _, r := range strings.ToLower(s) {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r):
b.WriteRune(r)
space = false
case !space:
b.WriteRune(' ')
space = true
}
}
return strings.TrimSpace(b.String())
}
func scanTask(sc scanner) (Task, error) {
var t Task
var created int64
var due, resolved sql.NullInt64
if err := sc.Scan(&t.ID, &created, &t.Text, &t.Source, &t.Evidence, &t.Status, &due, &t.Weight, &resolved); err != nil {
return Task{}, err
}
t.CreatedTs = time.UnixMilli(created).UTC()
t.Due = millisToTime(due)
t.ResolvedTs = millisToTime(resolved)
return t, nil
}
+221
View File
@@ -0,0 +1,221 @@
package store
import (
"context"
"errors"
"testing"
"time"
)
func TestCaptureTaskDedupesLiveWork(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
id, created, err := st.CaptureTask(ctx, Task{Text: "купить молоко", Source: "tap:voice", CreatedTs: now})
if err != nil {
t.Fatal(err)
}
if !created {
t.Fatal("first capture must create a row")
}
// Same work, different casing and punctuation — one task, not two.
again, created, err := st.CaptureTask(ctx, Task{Text: "Купить молоко!", Source: "email:kami", CreatedTs: now})
if err != nil {
t.Fatal(err)
}
if created {
t.Error("second capture of the same live work must not create a row")
}
if again != id {
t.Errorf("dedupe returned id %d, want the existing %d", again, id)
}
live, err := st.ListTasks(ctx, "live")
if err != nil {
t.Fatal(err)
}
if len(live) != 1 {
t.Fatalf("live tasks = %d, want 1", len(live))
}
}
func TestCaptureTaskAfterDoneIsANewTask(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
id, _, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now})
if err != nil {
t.Fatal(err)
}
if err := st.SetTaskStatus(ctx, id, TaskDone, now.Add(time.Hour)); err != nil {
t.Fatal(err)
}
// The dedupe key is free again: a recurring errand must be capturable.
id2, created, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now.AddDate(0, 0, 7)})
if err != nil {
t.Fatal(err)
}
if !created || id2 == id {
t.Fatalf("re-capture after done: created=%v id=%d (previous %d)", created, id2, id)
}
live, err := st.ListTasks(ctx, "live")
if err != nil {
t.Fatal(err)
}
if len(live) != 1 || live[0].ID != id2 {
t.Fatalf("live = %+v, want only the new task %d", live, id2)
}
}
func TestCaptureTaskCandidateKeepsEvidence(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
due := now.Add(48 * time.Hour)
id, _, err := st.CaptureTask(ctx, Task{
Text: "продлить страховку",
Source: "email:kami",
Evidence: "Re: страховой полис истекает",
Status: TaskCandidate,
Due: &due,
Weight: 2,
CreatedTs: now,
})
if err != nil {
t.Fatal(err)
}
got, err := st.LookupTask(ctx, id)
if err != nil {
t.Fatal(err)
}
if got.Status != TaskCandidate {
t.Errorf("status = %q, want candidate", got.Status)
}
if got.Evidence != "Re: страховой полис истекает" {
t.Errorf("evidence = %q", got.Evidence)
}
if got.Due == nil || !got.Due.Equal(due.UTC()) {
t.Errorf("due = %v, want %v", got.Due, due.UTC())
}
if got.Weight != 2 {
t.Errorf("weight = %d, want 2", got.Weight)
}
if got.ResolvedTs != nil {
t.Errorf("resolved_ts = %v on a live task, want nil", got.ResolvedTs)
}
}
func TestSetTaskStatusMovesOnceForwardOnly(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
cand, _, err := st.CaptureTask(ctx, Task{Text: "записаться к врачу", Source: "email:kami", Status: TaskCandidate, CreatedTs: now})
if err != nil {
t.Fatal(err)
}
// candidate → done is not a legal move: he has to confirm it first.
if err := st.SetTaskStatus(ctx, cand, TaskDone, now); !errors.Is(err, ErrTaskNotFound) {
t.Errorf("candidate→done err = %v, want ErrTaskNotFound", err)
}
if err := st.SetTaskStatus(ctx, cand, TaskOpen, now); err != nil {
t.Fatal(err)
}
if err := st.SetTaskStatus(ctx, cand, TaskDone, now.Add(time.Hour)); err != nil {
t.Fatal(err)
}
// Already resolved — a second resolve must not move it again.
if err := st.SetTaskStatus(ctx, cand, TaskDropped, now.Add(2*time.Hour)); !errors.Is(err, ErrTaskNotFound) {
t.Errorf("second resolve err = %v, want ErrTaskNotFound", err)
}
got, err := st.LookupTask(ctx, cand)
if err != nil {
t.Fatal(err)
}
if got.Status != TaskDone {
t.Errorf("status = %q, want done", got.Status)
}
if got.ResolvedTs == nil || !got.ResolvedTs.Equal(now.Add(time.Hour).UTC()) {
t.Errorf("resolved_ts = %v, want %v", got.ResolvedTs, now.Add(time.Hour).UTC())
}
}
func TestSetTaskStatusRejectsUnknownStatus(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
id, _, err := st.CaptureTask(ctx, Task{Text: "что-то", Source: "tap:web"})
if err != nil {
t.Fatal(err)
}
if err := st.SetTaskStatus(ctx, id, "candidate", time.Now()); !errors.Is(err, ErrTaskStatus) {
t.Errorf("→candidate err = %v, want ErrTaskStatus", err)
}
if err := st.SetTaskStatus(ctx, id, "urgent", time.Now()); !errors.Is(err, ErrTaskStatus) {
t.Errorf("→urgent err = %v, want ErrTaskStatus", err)
}
}
func TestCaptureTaskRejectsEmptyText(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
if _, _, err := st.CaptureTask(ctx, Task{Text: " ", Source: "tap:voice"}); !errors.Is(err, ErrTaskEmpty) {
t.Errorf("err = %v, want ErrTaskEmpty", err)
}
}
func TestListTasksFiltersByStatus(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
open1, _, _ := st.CaptureTask(ctx, Task{Text: "первая", Source: "tap:voice", CreatedTs: now})
_, _, _ = st.CaptureTask(ctx, Task{Text: "вторая", Source: "email:kami", Status: TaskCandidate, CreatedTs: now.Add(time.Minute)})
done, _, _ := st.CaptureTask(ctx, Task{Text: "третья", Source: "tap:voice", CreatedTs: now.Add(2 * time.Minute)})
if err := st.SetTaskStatus(ctx, done, TaskDone, now.Add(time.Hour)); err != nil {
t.Fatal(err)
}
cands, err := st.ListTasks(ctx, TaskCandidate)
if err != nil {
t.Fatal(err)
}
if len(cands) != 1 || cands[0].Text != "вторая" {
t.Fatalf("candidates = %+v", cands)
}
opens, err := st.ListTasks(ctx, TaskOpen)
if err != nil {
t.Fatal(err)
}
if len(opens) != 1 || opens[0].ID != open1 {
t.Fatalf("open = %+v", opens)
}
all, err := st.ListTasks(ctx, "")
if err != nil {
t.Fatal(err)
}
if len(all) != 3 {
t.Fatalf("all = %d, want 3", len(all))
}
// Newest first.
if all[0].Text != "третья" {
t.Errorf("first = %q, want newest ('третья')", all[0].Text)
}
}
func TestNormalizeTaskText(t *testing.T) {
cases := []struct{ in, want string }{
{"Купить молоко!", "купить молоко"},
{" купить МОЛОКО ", "купить молоко"},
{"позвонить в банк (важно)", "позвонить в банк важно"},
{"", ""},
}
for _, c := range cases {
if got := NormalizeTaskText(c.in); got != c.want {
t.Errorf("NormalizeTaskText(%q) = %q, want %q", c.in, got, c.want)
}
}
}