20184874b2
Backlog item #3 (20-07-2026-BACKLOG.md). A morning routine is a checklist for a daily window: several items, each evidenced by a fact key, completed in any order, checked once near the end of the window. Modelling it as four independent reminder timers would stack into exactly the kind of noise Maven is supposed not to produce, so the engine nags at most once per day per routine and only for what is actually still missing. internal/morning follows the established pure-engine pattern (loop, routine, pattern): no store, no clock of its own. Evaluate answers "what's still missing" at any point; Due decides whether to nag. The impurity — reading facts under the store lock, holding the last-nudge map across ticks — stays in the tick driver, which calls Due each tick exactly as it does for loop.Rule and routine.Routine. Completion evidence is a fact key's latest non-voided value timestamped inside today's window, so manual ("выпил воды", voice-tapped) and inferred (another daemon writing the same key) are indistinguishable and both count. Weekdays scopes which days a routine applies to, so weekday/weekend variants are two routine rows rather than a special case in the engine. Exposed read-only: a MorningStatus RPC over ipc, and a /morning page in mavweb built on the same server-rendered shape as /trace — no live-update loop, since checklist state moves on the scale of minutes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X5JApcrCRVGmqrxnhynSik
416 lines
15 KiB
Go
416 lines
15 KiB
Go
package ipc
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
)
|
|
|
|
// DTOs — wire-level data. Decoupled from internal/store so the protocol is
|
|
// self-describing and a module never needs to import store internals (the
|
|
// boundary is the point). The store adapter maps store.* ⇔ these 1:1.
|
|
|
|
// Fact — one observation. Ts is valid-time (true-as-of), as in store.
|
|
type Fact struct {
|
|
ID int64 `json:"id"`
|
|
Ts time.Time `json:"ts"`
|
|
Kind string `json:"kind"` // "self" | "env" | "config"
|
|
Key string `json:"key"`
|
|
Value string `json:"value"` // raw json if structured
|
|
Source string `json:"source"` // tap:*|infer:*|poll:*|ambient|promote|feedback
|
|
Confidence float64 `json:"confidence"`
|
|
VoidsID *int64 `json:"voids_id,omitempty"`
|
|
}
|
|
|
|
// Bucket — presence hysteresis state: "present" | "away".
|
|
type Bucket string
|
|
|
|
const (
|
|
Present Bucket = "present"
|
|
Away Bucket = "away"
|
|
)
|
|
|
|
// Nudge — one proactive send + its outcome, for the monitoring read path.
|
|
type Nudge struct {
|
|
ID int64 `json:"id"`
|
|
Ts time.Time `json:"ts"`
|
|
Rule string `json:"rule"`
|
|
Channel string `json:"channel"`
|
|
Message string `json:"message"`
|
|
Outcome string `json:"outcome"` // pending|acted|snoozed|ignored
|
|
OutcomeTs *int64 `json:"outcome_ts,omitempty"`
|
|
}
|
|
|
|
// Note — a recall/preference item; ranked by embedding cosine on query.
|
|
// Score is set by QueryNotes (0 on the write path).
|
|
type Note struct {
|
|
ID int64 `json:"id"`
|
|
Ts time.Time `json:"ts"`
|
|
Text string `json:"text"`
|
|
Source string `json:"source"`
|
|
Score float64 `json:"score"`
|
|
}
|
|
|
|
// Reminder — user-stated future intent; fires once or recurring (if cron set).
|
|
type Reminder struct {
|
|
ID int64 `json:"id"`
|
|
CreatedTs time.Time `json:"created_ts"`
|
|
FireTs time.Time `json:"fire_ts"`
|
|
NextFireTs time.Time `json:"next_fire_ts"`
|
|
Payload string `json:"payload"`
|
|
Status string `json:"status"` // pending|fired|cancelled
|
|
Cron string `json:"cron"`
|
|
}
|
|
|
|
// Presence — the read the phraser / delivery modules need to decide channel
|
|
// routing and tone. presence = reachability, NOT wakefulness (spec). Loop
|
|
// reads probes + computes score itself; modules get the resolved snapshot.
|
|
type Presence struct {
|
|
Bucket Bucket `json:"bucket"`
|
|
Score float64 `json:"score"`
|
|
Updated time.Time `json:"updated"`
|
|
}
|
|
|
|
// WriteFactReq — the only state mutation a capture/tool module performs.
|
|
// Confidence is 1.0 for taps, (0,1) for inferences; the store enforces range.
|
|
// Source is provenance — the server-side source-scope seam (auth layer) will
|
|
// refuse a module writing under a source it doesn't own ("compromised poller
|
|
// can't forge a trigger"). Today the floor permits any local caller.
|
|
type WriteFactReq struct {
|
|
Ts time.Time `json:"ts"`
|
|
Kind string `json:"kind"`
|
|
Key string `json:"key"`
|
|
Value string `json:"value"`
|
|
Source string `json:"source"`
|
|
Confidence float64 `json:"confidence"`
|
|
VoidsID *int64 `json:"voids_id,omitempty"`
|
|
|
|
// Subject — free-text "who/what this fact is about" (e.g. "the espresso
|
|
// machine", "Kate"). Empty (the default, so old callers are unaffected)
|
|
// means the fact isn't about a resolvable entity. When set, the fact
|
|
// enrichment worker (cmd/mavend/factenrichment.go) later resolves it
|
|
// against Nexus into an entity_id — see Vikunja #279.
|
|
Subject string `json:"subject,omitempty"`
|
|
}
|
|
|
|
// idReq — methods keyed by a single id.
|
|
type idReq struct {
|
|
ID int64 `json:"id"`
|
|
}
|
|
|
|
// markReminderReq — pending→fired|cancelled.
|
|
type markReminderReq struct {
|
|
ID int64 `json:"id"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
// resolveNudgeReq — pending→acted|snoozed|ignored, once.
|
|
type resolveNudgeReq struct {
|
|
ID int64 `json:"id"`
|
|
Outcome string `json:"outcome"`
|
|
Ts time.Time `json:"ts"`
|
|
}
|
|
|
|
// keyReq / keySourceReq / sinceReq / outcomesReq — read param shapes.
|
|
type keyReq struct {
|
|
Key string `json:"key"`
|
|
}
|
|
type keySourceReq struct {
|
|
Key string `json:"key"`
|
|
Source string `json:"source"`
|
|
}
|
|
type sinceReq struct {
|
|
Key string `json:"key"`
|
|
Now time.Time `json:"now"`
|
|
}
|
|
type outcomesReq struct {
|
|
Rule string `json:"rule"`
|
|
N int `json:"n"`
|
|
}
|
|
type nReq struct {
|
|
N int `json:"n"`
|
|
}
|
|
type calendarEventsReq struct {
|
|
From time.Time `json:"from"`
|
|
To time.Time `json:"to"`
|
|
}
|
|
type revertReq struct {
|
|
Key string `json:"key"`
|
|
}
|
|
type writeNoteReq struct {
|
|
Ts time.Time `json:"ts"`
|
|
Text string `json:"text"`
|
|
Embedding []float32 `json:"embedding"`
|
|
Source string `json:"source"`
|
|
}
|
|
type queryNotesReq struct {
|
|
Embedding []float32 `json:"embedding"`
|
|
K int `json:"k"`
|
|
}
|
|
type createReminderReq struct {
|
|
Fire time.Time `json:"fire"`
|
|
Payload string `json:"payload"`
|
|
Cron string `json:"cron"`
|
|
}
|
|
type recordNudgeReq struct {
|
|
Rule string `json:"rule"`
|
|
Channel string `json:"channel"`
|
|
Message string `json:"message"`
|
|
Ts time.Time `json:"ts"`
|
|
}
|
|
|
|
// idResp / sinceResp — small scalar return wrappers.
|
|
type idResp struct {
|
|
ID int64 `json:"id"`
|
|
}
|
|
type sinceResp struct {
|
|
Dur time.Duration `json:"dur"`
|
|
}
|
|
|
|
// Tool — an act allowlist entry as core exposes it. Status 'proposed' is an
|
|
// inert scaffold; 'enabled' is runnable. The executor only runs 'enabled'.
|
|
type Tool struct {
|
|
Name string `json:"name"`
|
|
Scope string `json:"scope"`
|
|
Cmd []string `json:"cmd"`
|
|
Destructive bool `json:"destructive"`
|
|
Status string `json:"status"`
|
|
Utterance string `json:"utterance"`
|
|
Created time.Time `json:"created"`
|
|
Updated time.Time `json:"updated"`
|
|
}
|
|
|
|
// chatReq / chatResp — text chat round-trip for the IPC Chat method.
|
|
type chatReq struct {
|
|
Text string `json:"text"`
|
|
}
|
|
type chatResp struct {
|
|
Reply string `json:"reply"`
|
|
}
|
|
|
|
type proposeToolReq struct {
|
|
Name string `json:"name"`
|
|
Scope string `json:"scope"`
|
|
Utterance string `json:"utterance"`
|
|
Ts time.Time `json:"ts"`
|
|
}
|
|
type proposeToolResp struct {
|
|
Proposed bool `json:"proposed"`
|
|
}
|
|
type enableToolReq struct {
|
|
Name string `json:"name"`
|
|
Scope string `json:"scope"`
|
|
Cmd []string `json:"cmd"`
|
|
Destructive bool `json:"destructive"`
|
|
Ts time.Time `json:"ts"`
|
|
}
|
|
type disableToolReq struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type lookupToolReq struct {
|
|
Name string `json:"name"`
|
|
}
|
|
type listToolsReq struct {
|
|
Status string `json:"status"`
|
|
}
|
|
type listToolsResp struct {
|
|
Tools []Tool `json:"tools"`
|
|
}
|
|
|
|
// ProposedRoutine — a detected pattern awaiting human confirmation.
|
|
type ProposedRoutine struct {
|
|
ID int64 `json:"id"`
|
|
Action string `json:"action"`
|
|
Object string `json:"object"`
|
|
IntervalDays float64 `json:"interval_days"`
|
|
Status string `json:"status"` // proposed | accepted | dismissed
|
|
CreatedTs int64 `json:"created_ts"`
|
|
ReminderID *int64 `json:"reminder_id,omitempty"`
|
|
}
|
|
|
|
type listProposedRoutinesResp struct {
|
|
Routines []ProposedRoutine `json:"routines"`
|
|
}
|
|
|
|
type dismissProposedRoutineReq struct {
|
|
ID int64 `json:"id"`
|
|
}
|
|
|
|
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
|
|
// - the in-process store adapter (server.go storeAPI) — used by the daemon
|
|
// for modules that live in-process for now (router, delivery) and by tests,
|
|
// - the socket-backed server's dispatcher (which delegates to a CoreAPI),
|
|
// - the client proxy (client.go) — same interface, over the wire.
|
|
//
|
|
// So a module imports ipc, holds a CoreAPI, and is agnostic to whether it's
|
|
// been wired in-process (tests / daemon-embedded) or socketed (full topology).
|
|
// That swappability is the seam the auth layer will insert into without
|
|
// touching the module code.
|
|
type CoreAPI interface {
|
|
WriteFact(ctx context.Context, req WriteFactReq) (int64, error)
|
|
LatestFact(ctx context.Context, key string) (Fact, error)
|
|
LatestFactBySource(ctx context.Context, key, source string) (Fact, error)
|
|
Since(ctx context.Context, key string, now time.Time) (time.Duration, error)
|
|
Presence(ctx context.Context) (Presence, error)
|
|
CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error)
|
|
MarkReminder(ctx context.Context, id int64, status string) error
|
|
ListReminders(ctx context.Context, n int) ([]Reminder, error)
|
|
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
|
|
ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error
|
|
RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error)
|
|
RecentFacts(ctx context.Context, n int) ([]Fact, error)
|
|
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
|
|
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
|
|
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
|
|
QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error)
|
|
RecentNotes(ctx context.Context, n int) ([]Note, error)
|
|
|
|
// ProposeTool drafts an inert 'proposed' tool scaffold (maven-callable);
|
|
// returns whether a new proposal was written. EnableTool fills cmd +
|
|
// destructive and flips status to 'enabled'. DisableTool reverts an
|
|
// enabled tool back to proposed (it stays in the store, won't run).
|
|
// Enable/DisableTool gate at AuthStepUp (allowlist mutation, human-only);
|
|
// ProposeTool is maven-callable (no step-up — she has no passkey).
|
|
// LookupTool/ListTools read them.
|
|
// scope defaults to "homelab" when empty.
|
|
ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error)
|
|
EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error
|
|
DisableTool(ctx context.Context, name string) error
|
|
DeleteTool(ctx context.Context, name string) error
|
|
LookupTool(ctx context.Context, name string) (Tool, error)
|
|
ListTools(ctx context.Context, status string) ([]Tool, error)
|
|
RevertFact(ctx context.Context, key string) (int64, error)
|
|
|
|
// ListProposedRoutines returns proposed routines, newest first.
|
|
ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error)
|
|
// DismissProposedRoutine flips a proposed routine to 'dismissed'.
|
|
DismissProposedRoutine(ctx context.Context, id int64) 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).
|
|
TickTrace(ctx context.Context) (TickTrace, error)
|
|
|
|
// MorningStatus returns each configured morning routine's current
|
|
// checklist state (see internal/morning): active today/now, which items
|
|
// are done, which are still missing. The store adapter returns an error
|
|
// (morning routines are daemon-config, not persisted) — same shape as
|
|
// TickTrace.
|
|
MorningStatus(ctx context.Context) ([]MorningRoutineStatus, 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).
|
|
Chat(ctx context.Context, text string) (string, error)
|
|
}
|
|
|
|
// --- Rule trace / explanation DTOs ---
|
|
|
|
// RuleTrace — per-rule evaluation result for one tick.
|
|
type RuleTrace struct {
|
|
RuleName string `json:"rule_name"`
|
|
Severity int `json:"severity"`
|
|
PredicateResult bool `json:"predicate_result"`
|
|
GateResult bool `json:"gate_result"`
|
|
GateBlockedBy string `json:"gate_blocked_by,omitempty"`
|
|
GateDetail GateDetail `json:"gate_detail,omitempty"`
|
|
WasSelected bool `json:"was_selected"`
|
|
LostTo string `json:"lost_to,omitempty"`
|
|
}
|
|
|
|
// GateDetail — snapshot of the values the gate checked.
|
|
type GateDetail struct {
|
|
SnoozeUntil *time.Time `json:"snooze_until,omitempty"`
|
|
CooldownUntil *time.Time `json:"cooldown_until,omitempty"`
|
|
QuietHours bool `json:"quiet_hours"`
|
|
CalendarBusy bool `json:"calendar_busy"`
|
|
Presence string `json:"presence"`
|
|
InertKeysMissing []string `json:"inert_keys_missing,omitempty"`
|
|
}
|
|
|
|
// TickTrace — snapshot of one tick's rule evaluations.
|
|
type TickTrace struct {
|
|
Now time.Time `json:"now"`
|
|
Winner string `json:"winner"`
|
|
Rules []RuleTrace `json:"rules"`
|
|
}
|
|
|
|
// MorningRoutineItem — one checklist entry's current state.
|
|
type MorningRoutineItem struct {
|
|
Key string `json:"key"`
|
|
Label string `json:"label"`
|
|
Done bool `json:"done"`
|
|
}
|
|
|
|
// MorningRoutineStatus — one routine's checklist state right now.
|
|
type MorningRoutineStatus struct {
|
|
Name string `json:"name"`
|
|
Active bool `json:"active"`
|
|
WindowStart string `json:"window_start"`
|
|
WindowEnd string `json:"window_end"`
|
|
Items []MorningRoutineItem `json:"items"`
|
|
}
|
|
|
|
// storeEncryptionKeyReq — passkey credential public key for wrapping the store
|
|
// encryption key at enrollment time. Called by mavweb after RegisterFinish.
|
|
type storeEncryptionKeyReq struct {
|
|
PublicKey []byte `json:"public_key"`
|
|
}
|
|
|
|
// unlockReq — passkey credential public key for unwrapping the store
|
|
// encryption key at cold-start. mavend reads the wrapped blob from its own
|
|
// configured path; the public key is the other half needed for unwrapping.
|
|
type unlockReq struct {
|
|
PublicKey []byte `json:"public_key"`
|
|
}
|
|
|
|
// ErrToolNotFound — no tool row with this name (re-exported store sentinel for
|
|
// wire round-tripping via errors.Is).
|
|
var ErrToolNotFound = errors.New("ipc: tool not found")
|
|
|
|
// callerKey — context key for the authenticated caller. Server sets it from
|
|
// SO_PEERCRED before dispatch; in-process callers omit it (the adapter treats
|
|
// a missing Caller as "trusted same-process", the equivalent of the socket's
|
|
// 0600 floor).
|
|
type callerKey struct{}
|
|
|
|
// Caller — the peer identity as core sees it. Uid/Pid come from SO_PEERCRED
|
|
// on Linux; the future auth layer maps Uid + module enrollment → authority.
|
|
// Today only Uid is populated and used for a same-user check.
|
|
type Caller struct {
|
|
Uid int32
|
|
Pid int32
|
|
}
|
|
|
|
// WithCaller returns ctx annotated with c. Server-side use only.
|
|
func WithCaller(ctx context.Context, c Caller) context.Context {
|
|
return context.WithValue(ctx, callerKey{}, c)
|
|
}
|
|
|
|
// CallerFrom retrieves the Caller, or ok=false if absent (in-process path).
|
|
func CallerFrom(ctx context.Context) (Caller, bool) {
|
|
c, ok := ctx.Value(callerKey{}).(Caller)
|
|
return c, ok
|
|
}
|
|
|
|
// Sentinel errors. Mirror store's 1:1 so module code reads the same whether
|
|
// in-process or over the wire. The store adapter translates store.* → these.
|
|
var (
|
|
ErrNoFact = errors.New("ipc: no fact for key")
|
|
ErrConfidence = errors.New("ipc: confidence must be in (0.0, 1.0]")
|
|
ErrVoidsMissing = errors.New("ipc: voids_id does not reference an existing fact")
|
|
ErrNudgeNotFound = errors.New("ipc: nudge not found")
|
|
ErrNudgeOutcome = errors.New("ipc: nudge already resolved")
|
|
ErrReminderNotFound = errors.New("ipc: reminder not found")
|
|
ErrReminderState = errors.New("ipc: reminder not in a mutable state")
|
|
ErrUnknownMethod = errors.New("ipc: unknown method")
|
|
ErrBadParams = errors.New("ipc: bad params")
|
|
// ErrForbidden — the caller's authority doesn't cover this call. The
|
|
// auth layer's only wire-exported verdict: surface caps the layer, or a
|
|
// write was out-of-scope, or step-up was required but not asserted. The
|
|
// text ErrForbidden carries is derived during dispatch (from auth.ErrForbidden
|
|
// via fmt.Errorf %w wrapping); the wire carries codeForbidden.
|
|
ErrForbidden = errors.New("ipc: forbidden")
|
|
)
|