initial commit

This commit is contained in:
kami
2026-07-03 00:32:48 +02:00
commit 612583d59a
92 changed files with 14521 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
)
// WriteFact appends a fact row. confidence must be 1.0 for taps and (0,1) for
// inferences; the caller is responsible for that provenance discipline.
//
// If voidsID is Valid, this row voids (cancels) the referenced fact. The
// caller must have verified voidsID points at an existing fact — we check it
// here too and refuse to write a dangling voids pointer (the audit trail must
// stay coherent).
func (s *Store) WriteFact(ctx context.Context, ts time.Time, kind FactKind, key, value, source string, confidence float64, voidsID sql.NullInt64) (int64, error) {
if confidence <= 0.0 || confidence > 1.0 {
return 0, fmt.Errorf("%w: %f", ErrConfidence, confidence)
}
if voidsID.Valid {
var ok int64
err := s.db.QueryRowContext(ctx, "SELECT 1 FROM facts WHERE id = ?", voidsID.Int64).Scan(&ok)
if errors.Is(err, sql.ErrNoRows) {
return 0, fmt.Errorf("%w: id=%d", ErrVoidsMissing, voidsID.Int64)
}
if err != nil {
return 0, fmt.Errorf("voids lookup: %w", err)
}
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO facts (ts, kind, key, value, source, confidence, voids_id) VALUES (?,?,?,?,?,?,?)`,
ts.UnixMilli(), string(kind), key, value, source, confidence, voidsID)
if err != nil {
return 0, fmt.Errorf("write fact: %w", err)
}
id, _ := res.LastInsertId()
return id, nil
}
// LatestFact returns the latest non-voided fact for key, or ErrNoFact.
// "Non-voided" = no later row has voids_id pointing at it. We resolve this by
// taking the newest row whose id is not referenced by any voids_id.
func (s *Store) LatestFact(ctx context.Context, key string) (Fact, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, ts, kind, key, value, source, confidence, voids_id
FROM facts
WHERE key = ?
AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
ORDER BY ts DESC, id DESC
LIMIT 1`, key)
return scanFact(row)
}
// RecentFacts — the newest n facts across all keys, for the monitoring dash.
// Includes voided rows (the audit trail is the point: you want to SEE a
// correction, not have it hidden). Newest first.
func (s *Store) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, ts, kind, key, value, source, confidence, voids_id
FROM facts ORDER BY ts DESC, id DESC LIMIT ?`, n)
if err != nil {
return nil, fmt.Errorf("recent facts: %w", err)
}
defer rows.Close()
var out []Fact
for rows.Next() {
f, err := scanFact(rows)
if err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
// LatestFactBySource — provenance-scoped. A rule on `service_down` trusts only
// source=poll:healthcheck; a compromised poller can't forge a trigger. Use this
// from rules, not LatestFact, whenever the rule's source contract matters.
func (s *Store) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, ts, kind, key, value, source, confidence, voids_id
FROM facts
WHERE key = ? AND source = ?
AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
ORDER BY ts DESC, id DESC
LIMIT 1`, key, source)
return scanFact(row)
}
// Since returns how long ago the latest non-voided fact for key landed, or
// (0, ErrNoFact). Implements the `since(key)==null → don't fire` guard from
// the spec — silence on no-data is "shuts up when uncertain".
func (s *Store) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) {
f, err := s.LatestFact(ctx, key)
if err != nil {
return 0, err
}
if now.Before(f.Ts) {
return 0, nil
}
return now.Sub(f.Ts), nil
}
// SetValue is a convenience for writing a structured (json) value at confidence 1.0
// from a tap. Self-taps (water, meal, shower) land here. Caller supplies source
// like "tap:water"; we serialize the value.
func (s *Store) SetValue(ctx context.Context, kind FactKind, key, source string, value any, ts time.Time) (int64, error) {
raw, err := json.Marshal(value)
if err != nil {
return 0, fmt.Errorf("marshal value: %w", err)
}
return s.WriteFact(ctx, ts, kind, key, string(raw), source, 1.0, sql.NullInt64{})
}
// CorrectValue voids the latest non-voided fact for key and writes a replacement
// in one transaction. Use this for "you corrected a bad fact" feedback — keeps
// the audit trail, supersedes the wrong value.
func (s *Store) CorrectValue(ctx context.Context, key, source string, value any, ts time.Time) (newID int64, err error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
var oldID int64
err = tx.QueryRowContext(ctx, `
SELECT id FROM facts
WHERE key = ?
AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
ORDER BY ts DESC, id DESC LIMIT 1`, key).Scan(&oldID)
if errors.Is(err, sql.ErrNoRows) {
// nothing to correct; write as a plain new fact instead — no voiding needed.
} else if err != nil {
return 0, fmt.Errorf("correct: find old: %w", err)
}
raw, merr := json.Marshal(value)
if merr != nil {
return 0, fmt.Errorf("marshal value: %w", merr)
}
var voids sql.NullInt64
if oldID != 0 {
voids = sql.NullInt64{Int64: oldID, Valid: true}
}
res, err := tx.ExecContext(ctx,
`INSERT INTO facts (ts, kind, key, value, source, confidence, voids_id) VALUES (?,?,?,?,?,?,?)`,
ts.UnixMilli(), string(KindSelf), key, string(raw), source, 1.0, voids)
if err != nil {
return 0, fmt.Errorf("write corrected: %w", err)
}
if err := tx.Commit(); err != nil {
return 0, err
}
newID, _ = res.LastInsertId()
return newID, nil
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanFact(r rowScanner) (Fact, error) {
var f Fact
var tsMilli int64
var kind string
var voids sql.NullInt64
if err := r.Scan(&f.ID, &tsMilli, &kind, &f.Key, &f.Value, &f.Source, &f.Confidence, &voids); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Fact{}, ErrNoFact
}
return Fact{}, err
}
f.Ts = time.UnixMilli(tsMilli).UTC()
f.Kind = FactKind(kind)
f.VoidsID = voids
return f, nil
}
+131
View File
@@ -0,0 +1,131 @@
package store
import (
"context"
"encoding/binary"
"fmt"
"math"
"sort"
"time"
)
// Note — a recall/preference item. No predicate reads it (facts are for that);
// query-answering ranks notes by embedding cosine. Score is set by QueryNotes.
type Note struct {
ID int64
Ts time.Time
Text string
Source string
Score float64
}
// WriteNote appends a note with its embedding (stored as a little-endian
// float32 BLOB). Source is provenance (tap:voice, etc.).
func (s *Store) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO notes (ts, text, embedding, source) VALUES (?,?,?,?)`,
ts.UnixMilli(), text, floatsToBlob(embedding), source)
if err != nil {
return 0, fmt.Errorf("write note: %w", err)
}
id, _ := res.LastInsertId()
return id, nil
}
// QueryNotes returns the top-k notes by cosine similarity to embedding, highest
// first (ties broken newest-first). Fewer than k notes ⇒ returns what exists.
//
// ponytail: brute-force O(n) cosine over every note each query. Add sqlite-vec
// or an ANN index only when note count or latency actually bites — at personal
// scale (hundredsthousands) a full scan is sub-millisecond.
func (s *Store) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, ts, text, embedding, source FROM notes`)
if err != nil {
return nil, fmt.Errorf("query notes: %w", err)
}
defer rows.Close()
var out []Note
for rows.Next() {
var n Note
var tsMilli int64
var blob []byte
if err := rows.Scan(&n.ID, &tsMilli, &n.Text, &blob, &n.Source); err != nil {
return nil, err
}
n.Ts = time.UnixMilli(tsMilli).UTC()
n.Score = cosine(embedding, blobToFloats(blob))
out = append(out, n)
}
if err := rows.Err(); err != nil {
return nil, err
}
sort.Slice(out, func(i, j int) bool {
if out[i].Score != out[j].Score {
return out[i].Score > out[j].Score
}
return out[i].Ts.After(out[j].Ts) // newest breaks ties
})
if k > 0 && len(out) > k {
out = out[:k]
}
return out, nil
}
// RecentNotes returns the newest n notes, newest first — a browse view (no
// embedding math; Score stays 0). This is the read surface for /dash: notes
// captured by voice are otherwise only reachable through semantic query.
func (s *Store) RecentNotes(ctx context.Context, n int) ([]Note, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, ts, text, source FROM notes ORDER BY ts DESC LIMIT ?`, n)
if err != nil {
return nil, fmt.Errorf("recent notes: %w", err)
}
defer rows.Close()
var out []Note
for rows.Next() {
var nt Note
var tsMilli int64
if err := rows.Scan(&nt.ID, &tsMilli, &nt.Text, &nt.Source); err != nil {
return nil, err
}
nt.Ts = time.UnixMilli(tsMilli).UTC()
out = append(out, nt)
}
return out, rows.Err()
}
// cosine similarity. Embedder vectors are L2-normalized, so this is just the
// dot product — but normalize defensively in case a caller passes a raw vector.
func cosine(a, b []float32) float64 {
if len(a) != len(b) || len(a) == 0 {
return 0
}
var dot, na, nb float64
for i := range a {
dot += float64(a[i]) * float64(b[i])
na += float64(a[i]) * float64(a[i])
nb += float64(b[i]) * float64(b[i])
}
if na == 0 || nb == 0 {
return 0
}
return dot / (math.Sqrt(na) * math.Sqrt(nb))
}
func floatsToBlob(v []float32) []byte {
b := make([]byte, 4*len(v))
for i, f := range v {
binary.LittleEndian.PutUint32(b[4*i:], math.Float32bits(f))
}
return b
}
func blobToFloats(b []byte) []float32 {
v := make([]float32, len(b)/4)
for i := range v {
v[i] = math.Float32frombits(binary.LittleEndian.Uint32(b[4*i:]))
}
return v
}
+38
View File
@@ -0,0 +1,38 @@
package store
import (
"context"
"testing"
"time"
)
func TestQueryNotesRanksByCosine(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Now()
// 3-dim vectors along distinct axes; query aligns with the "backups" note.
if _, err := st.WriteNote(ctx, now, "prefer backups at 3am", []float32{1, 0, 0}, "tap:voice"); err != nil {
t.Fatal(err)
}
if _, err := st.WriteNote(ctx, now, "gpu driver fixed the flicker", []float32{0, 1, 0}, "tap:voice"); err != nil {
t.Fatal(err)
}
if _, err := st.WriteNote(ctx, now, "cat likes the window", []float32{0, 0, 1}, "tap:voice"); err != nil {
t.Fatal(err)
}
got, err := st.QueryNotes(ctx, []float32{0.9, 0.1, 0}, 2)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("want 2 notes, got %d", len(got))
}
if got[0].Text != "prefer backups at 3am" {
t.Errorf("nearest = %q, want backups note (score %.3f)", got[0].Text, got[0].Score)
}
if got[0].Score <= got[1].Score {
t.Errorf("scores not descending: %.3f then %.3f", got[0].Score, got[1].Score)
}
}
+178
View File
@@ -0,0 +1,178 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// Nudge — one proactive send, with deferred outcome. outcomes are: pending |
// acted | snoozed | ignored. outcome_ts set when resolved. the outcome column
// IS the restraint-memory signal — no separate table for the feedback loop.
type Nudge struct {
ID int64
Ts time.Time // sent ts
Rule string
Channel string
Message string
Outcome string
OutcomeTs sql.NullInt64
}
const (
NudgePending = "pending"
NudgeActed = "acted"
NudgeSnoozed = "snoozed"
NudgeIgnored = "ignored"
)
var (
ErrNudgeNotFound = errors.New("store: nudge not found")
ErrNudgeOutcome = errors.New("store: nudge already resolved")
)
// RecordNudge inserts a pending nudge (sent). returns the id. the loop writes
// one row per proactive send per tick (one nudge per tick, max severity).
func (s *Store) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO nudges (ts, rule, channel, message, outcome) VALUES (?,?,?,?, 'pending')`,
ts.UnixMilli(), rule, channel, message)
if err != nil {
return 0, fmt.Errorf("record nudge: %w", err)
}
id, _ := res.LastInsertId()
return id, nil
}
// ResolveNudge sets the outcome of a still-pending nudge. acted | snoozed |
// ignored — caller decides by user response (or lack of it). idempotency is
// rejected here: a nudge can only be resolved once, by design — re-resolving
// would silently corrupt the feedback signal (the table IS the learning input).
func (s *Store) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error {
switch outcome {
case NudgeActed, NudgeSnoozed, NudgeIgnored:
default:
return fmt.Errorf("store: unknown outcome %q", outcome)
}
res, err := s.db.ExecContext(ctx,
`UPDATE nudges SET outcome = ?, outcome_ts = ? WHERE id = ? AND outcome = 'pending'`,
outcome, ts.UnixMilli(), id)
if err != nil {
return fmt.Errorf("resolve nudge: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
// either no such row, or it was already resolved — distinguish so callers
// can tell a bug from a race.
var cur string
err := s.db.QueryRowContext(ctx, "SELECT outcome FROM nudges WHERE id = ?", id).Scan(&cur)
if errors.Is(err, sql.ErrNoRows) {
return ErrNudgeNotFound
}
if err != nil {
return err
}
return fmt.Errorf("%w: currently %s", ErrNudgeOutcome, cur)
}
return nil
}
// RecentOutcomes returns the last N outcomes (in reverse chronological order)
// for a given rule — the feedback loop's only input. used to compute
// ignored_rate → cooldown sizing. dead simple at mvp: a ratio over last N.
func (s *Store) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT outcome FROM nudges
WHERE rule = ? AND outcome != 'pending'
ORDER BY ts DESC, id DESC LIMIT ?`, rule, n)
if err != nil {
return nil, fmt.Errorf("recent outcomes: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var o string
if err := rows.Scan(&o); err != nil {
return nil, err
}
out = append(out, o)
}
return out, rows.Err()
}
// UnackedTelegramRules returns rule names that have at least one still-pending
// (un-acked) nudge sent over the telegram channel. the dispatcher's
// RepeatUnacked re-sends these each tick until MarkAcked.
//
// telegram is the sev4-away channel by routing-table construction
// (ChannelsFor sends sev4 away → telegram, nothing else to telegram), so
// `channel='telegram' AND outcome='pending'` already implies sev4 ops-hard —
// no severity column on the nudges table, and none needed: the routing table
// is the authority. grouped by rule so the repeat stream is one-per-rule
// (the ack key IS the rule name).
func (s *Store) UnackedTelegramRules(ctx context.Context) ([]string, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT rule FROM nudges
WHERE channel = 'telegram' AND outcome = 'pending'
GROUP BY rule ORDER BY rule`)
if err != nil {
return nil, fmt.Errorf("unacked telegram rules: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var r string
if err := rows.Scan(&r); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// RecentNudges — the newest n nudges across all rules, with outcomes, for the
// monitoring dash. Newest first.
func (s *Store) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, ts, rule, channel, message, outcome, outcome_ts
FROM nudges ORDER BY ts DESC, id DESC LIMIT ?`, n)
if err != nil {
return nil, fmt.Errorf("recent nudges: %w", err)
}
defer rows.Close()
var out []Nudge
for rows.Next() {
var ng Nudge
var tsMilli int64
if err := rows.Scan(&ng.ID, &tsMilli, &ng.Rule, &ng.Channel, &ng.Message, &ng.Outcome, &ng.OutcomeTs); err != nil {
return nil, err
}
ng.Ts = time.UnixMilli(tsMilli).UTC()
out = append(out, ng)
}
return out, rows.Err()
}
// LastNudge — newest nudge for a rule regardless of outcome. used by the gate
// for cooldown enforcement. returns ErrNudgeNotFound if the rule has never fired.
func (s *Store) LastNudge(ctx context.Context, rule string) (Nudge, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, ts, rule, channel, message, outcome, outcome_ts
FROM nudges WHERE rule = ? ORDER BY ts DESC, id DESC LIMIT 1`, rule)
var n Nudge
var tsMilli int64
var outcomeTs sql.NullInt64
if err := row.Scan(&n.ID, &tsMilli, &n.Rule, &n.Channel, &n.Message, &n.Outcome, &outcomeTs); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Nudge{}, ErrNudgeNotFound
}
return Nudge{}, err
}
n.Ts = time.UnixMilli(tsMilli).UTC()
if outcomeTs.Valid {
n.OutcomeTs = outcomeTs
}
return n, nil
}
+113
View File
@@ -0,0 +1,113 @@
// Package store — presence.
//
// Presence is a pure function over recent facts, computed each tick; decaying
// confidence over multiple weak signals, never one authoritative source;
// hysteresis (a Schmitt trigger) to stop flapping.
//
// `presenceScore` and `resolve` are deliberately pure: no I/O, no time.Now.
// The loop supplies `now` and the Signal readings (gathered under the state
// lock) as inputs — the functions here are the unit-testable core.
//
// Signals and their hand-tuned weights/τ:
//
// (desk_active, 0.90, 8 min) input = human at keyboard
// (page_heartbeat,0.60, 4 min) a surface you use is open + alive; pings ~30s
// (wg_handshake, 0.40, 20 min) device on tunnel; coarse, three-rooms-away
//
// Combiner is noisy-OR: P = 1 Π (1 p_i) with p_i = weight_i · exp(-Δt_i / τ_i).
// Diminishing returns on stacking weak signals, never exceeds 1.0.
// Signals with no fact for that key drop out of the product (not zero).
//
// Hysteresis (Schmitt trigger):
//
// ENTER (away → present): P >= 0.55
// EXIT (present → away): P < 0.30
// cold start: away (fail-closed; same instinct as since(key)==null)
//
// Boundaries are HAND-tuned, NOT feedback-tuned — keep presence numbers out of
// the auto-tuner or a weird week drifts you silently invisible.
package store
import (
"math"
"time"
)
// Signal — one presence signal with hand-tuned fresh-weight and decay τ.
type Signal struct {
Key string
Weight float64
TauMin float64
}
// PresenceSignals — the three signals. Iterate in stable order.
var PresenceSignals = []Signal{
{Key: "desk_active", Weight: 0.90, TauMin: 8.0},
{Key: "page_heartbeat", Weight: 0.60, TauMin: 4.0},
{Key: "wg_handshake", Weight: 0.40, TauMin: 20.0},
}
// PresenceEnter — the ENTER threshold of the Schmitt trigger.
const PresenceEnter = 0.55
// PresenceExit — the EXIT threshold.
const PresenceExit = 0.30
// SignalProbe — the loop's reading of one signal at tick time. All that
// presence needs is, per signal key, the timestamp of the latest non-voided
// fact for that key (or nil if no fact — it then drops out of the product).
//
// The loop fills this by calling LatestFact for each Signal.Key under the
// state lock; presence itself does no I/O.
type SignalProbe struct {
Key string
LastTs *time.Time // nil → no data; </> SignalProbe drops out of the product
}
// PresenceScore — pure. returns the noisy-OR combined score in [0,1).
// If every signal has no data the product collapses to 1.0 and score = 0.
// (Cold boot: away, by construction.)
func PresenceScore(now time.Time, probes []SignalProbe) float64 {
var pAway = 1.0
byKey := make(map[string]*time.Time, len(probes))
for i := range probes {
byKey[probes[i].Key] = probes[i].LastTs
}
for _, s := range PresenceSignals {
last, ok := byKey[s.Key]
if !ok || last == nil {
continue // no data → drops out of the product
}
dtMin := now.Sub(*last).Minutes()
if dtMin < 0 {
dtMin = 0 // clock skew shouldn't gift us a p > weight
}
p := s.Weight * math.Exp(-dtMin/s.TauMin)
pAway *= (1.0 - p)
}
return 1.0 - pAway
}
// Resolve — the Schmitt trigger. Pure.
//
// last == present: stay present while P >= 0.30; flip to away below.
// last == away: stay away while P < 0.55; enter present at 0.55 or above.
//
// Cold start (no prior bucket) → away, fail-closed.
func Resolve(score float64, last Bucket) Bucket {
if last == Present {
if score < PresenceExit {
return Away
}
return Present
}
// last == Away or cold-start
if score >= PresenceEnter {
return Present
}
return Away
}
// ResolveCold — convenience for the very first tick after daemon cold-start.
// presence_state has no row; we begin as Away, the fail-closed outcome.
func ResolveCold(score float64) Bucket { return Resolve(score, Away) }
+61
View File
@@ -0,0 +1,61 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// LoadPresenceState — returns the singleton hysteresis row, or a cold-start
// default (away, score 0) when no row exists yet. fail-closed.
func (s *Store) LoadPresenceState(ctx context.Context) (bucket Bucket, score float64, updated time.Time, err error) {
var b string
var updMilli int64
row := s.db.QueryRowContext(ctx,
`SELECT last_bucket, last_score, updated_ts FROM presence_state WHERE id = 1`)
err = row.Scan(&b, &score, &updMilli)
if errors.Is(err, sql.ErrNoRows) {
return Away, 0.0, time.Time{}, nil
}
if err != nil {
return "", 0, time.Time{}, fmt.Errorf("load presence_state: %w", err)
}
return Bucket(b), score, time.UnixMilli(updMilli).UTC(), nil
}
// SavePresenceState — upsert the singleton row. called every tick after resolve hysteresis.
func (s *Store) SavePresenceState(ctx context.Context, bucket Bucket, score float64, now time.Time) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO presence_state (id, last_bucket, last_score, updated_ts) VALUES (1, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET last_bucket = excluded.last_bucket,
last_score = excluded.last_score,
updated_ts = excluded.updated_ts`,
string(bucket), score, now.UnixMilli())
if err != nil {
return fmt.Errorf("save presence_state: %w", err)
}
return nil
}
// PresenceProbes — gather the latest non-voided fact ts per signal key.
// Returns a SignalProbe slice aligned with PresenceSignals. nil LastTs where
// the key has no data. This is the only I/O presence needs; the actual score
// computation happens in the pure PresenceScore() function.
func (s *Store) PresenceProbes(ctx context.Context) ([]SignalProbe, error) {
probes := make([]SignalProbe, 0, len(PresenceSignals))
for _, sig := range PresenceSignals {
f, err := s.LatestFact(ctx, sig.Key)
if errors.Is(err, ErrNoFact) {
probes = append(probes, SignalProbe{Key: sig.Key, LastTs: nil})
continue
}
if err != nil {
return nil, err
}
t := f.Ts
probes = append(probes, SignalProbe{Key: sig.Key, LastTs: &t})
}
return probes, nil
}
+145
View File
@@ -0,0 +1,145 @@
package store
import (
"math"
"testing"
"time"
)
func refTime() time.Time {
return time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)
}
func TestPresenceScoreColdBoot(t *testing.T) {
// No probes at all. Product collapses to 1.0; score = 0. Fail-closed.
got := PresenceScore(refTime(), nil)
if math.Abs(got-0.0) > 1e-9 {
t.Fatalf("cold boot: want 0, got %f", got)
}
if b := ResolveCold(got); b != Away {
t.Fatalf("cold boot: want Away, got %s", b)
}
}
func TestPresenceScoreAllSignalsDroppedOnNoFact(t *testing.T) {
// Every probe present but LastTs nil → drops out → score 0.
probes := []SignalProbe{
{Key: "desk_active", LastTs: nil},
{Key: "page_heartbeat", LastTs: nil},
{Key: "wg_handshake", LastTs: nil},
}
if got := PresenceScore(refTime(), probes); got != 0.0 {
t.Fatalf("all-nil probes: want 0, got %f", got)
}
}
func TestPresenceScoreAtDeskTyping(t *testing.T) {
// Fresh (<1s) desk_active + heartbeat → very high. Spec table: ~0.90.
now := refTime()
justNow := now.Add(-1 * time.Second)
probes := []SignalProbe{
{Key: "desk_active", LastTs: &justNow},
{Key: "page_heartbeat", LastTs: &justNow},
}
got := PresenceScore(now, probes)
if got < 0.85 {
t.Fatalf("at desk typing: want >= 0.85, got %f", got)
}
if b := Resolve(got, Away); b != Present {
t.Fatalf("away→present at P=%f: want Present, got %s", got, b)
}
}
func TestPresenceScoreFreshWGAloneCannotEnter(t *testing.T) {
// A lone fresh wg (weight 0.40) is below ENTER (0.55). Cannot declare present.
now := refTime()
justNow := now.Add(-1 * time.Second)
probes := []SignalProbe{{Key: "wg_handshake", LastTs: &justNow}}
got := PresenceScore(now, probes)
if got > PresenceEnter {
t.Fatalf("fresh wg alone: want <= ENTER, got %f", got)
}
if b := Resolve(got, Away); b != Away {
t.Fatalf("wg alone enters present: want Away, got %s (P=%f)", b, got)
}
}
func TestPresenceDeskOnlyDecaysToAway(t *testing.T) {
// Desk-only, zero input for ~9min → <0.30 → Away. Spec table row 3.
now := refTime()
deskThen := now.Add(-9 * time.Minute)
probes := []SignalProbe{{Key: "desk_active", LastTs: &deskThen}}
got := PresenceScore(now, probes)
if got >= PresenceExit {
t.Fatalf("9min-stale desk-only: want < EXIT(%f), got %f", PresenceExit, got)
}
if b := Resolve(got, Present); b != Away {
t.Fatalf("present→away at P=%f (9min stale desk): want Away, got %s", got, b)
}
}
func TestPresenceHysteresisHoldsWhileDecaying(t *testing.T) {
// Already present. WG alone decayed—but a fresh-ish wg_holding(0.40) while
// present must HOLD present even though it could not ENTER present. Band.
now := refTime()
wgThen := now.Add(-1 * time.Minute) // dt=1min; p ≈ 0.40 * exp(-1/20) ≈ 0.381
probes := []SignalProbe{{Key: "wg_handshake", LastTs: &wgThen}}
got := PresenceScore(now, probes)
// Sanity: above EXIT, below ENTER — sitting in the hold band.
if got <= PresenceExit || got >= PresenceEnter {
t.Fatalf("decaying wg in hold band: want (%f, %f), got %f", PresenceExit, PresenceEnter, got)
}
if b := Resolve(got, Present); b != Present {
t.Fatalf("hold during decay: want Present, got %s (P=%f, last=Present)", b, got)
}
// But the exact same reading from Away must NOT enter.
if b := Resolve(got, Away); b != Away {
t.Fatalf("from Away the same P must not enter: want Away, got %s (P=%f)", b, got)
}
}
func TestPresenceCouchPhoneOpen(t *testing.T) {
// Couch, phone page open, no desk → ~0.60, just present (≥0.55).
now := refTime()
justNow := now.Add(-1 * time.Second)
probes := []SignalProbe{{Key: "page_heartbeat", LastTs: &justNow}}
got := PresenceScore(now, probes)
// weight 0.60, decay≈0 → near 0.60. Should ENTER from away.
if got < PresenceEnter {
t.Fatalf("couch+phone: want >= ENTER(%f), got %f", PresenceEnter, got)
}
if b := Resolve(got, Away); b != Present {
t.Fatalf("couch+phone enters: want Present, got %s (P=%f)", b, got)
}
}
func TestPresenceUnequalWeightsSumDiminishingReturns(t *testing.T) {
// Noisy-OR: stacking adds diminishing returns; never exceeds 1.0.
// At t=0 all three fresh: P = 1 - (0.1)(0.4)(0.6) = 1 - 0.024 = 0.976.
now := refTime()
t0 := now
probes := []SignalProbe{
{Key: "desk_active", LastTs: &t0},
{Key: "page_heartbeat", LastTs: &t0},
{Key: "wg_handshake", LastTs: &t0},
}
got := PresenceScore(now, probes)
if got >= 1.0 {
t.Fatalf("stacked fresh: want < 1.0, got %f", got)
}
if math.Abs(got-0.976) > 1e-3 {
t.Fatalf("stacked fresh: want ≈ 0.976, got %f", got)
}
}
func TestNegativeClockSkewClampsToFresh(t *testing.T) {
// last is in the future relative to now (clock skew). Should clamp dt to 0
// rather than gifting a p > weight via exp(-negative/τ) > 1.
now := refTime()
future := now.Add(5 * time.Minute)
probes := []SignalProbe{{Key: "desk_active", LastTs: &future}}
got := PresenceScore(now, probes)
if got > 0.90+1e-9 {
t.Fatalf("clock skew clamps: want <= weight(0.90), got %f", got)
}
}
+87
View File
@@ -0,0 +1,87 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// Reminder — user-stated future intent. fires once. relative→absolute happens
// at capture ("in 4h" → store now+4h, never the string).
type Reminder struct {
ID int64
CreatedTs time.Time
FireTs time.Time
Payload string // raw json
Status string // pending | fired | cancelled
}
var (
ErrReminderNotFound = errors.New("store: reminder not found")
ErrReminderState = errors.New("store: reminder not in a mutable state")
)
// CreateReminder persists a reminder with a resolved absolute fire time.
// The caller (router/capture path) MUST have already converted "in 4h" → now+4h.
// We do not accept strings here.
func (s *Store) CreateReminder(ctx context.Context, fire time.Time, payload string) (int64, error) {
now := time.Now().UTC()
res, err := s.db.ExecContext(ctx,
`INSERT INTO reminders (created_ts, fire_ts, payload, status) VALUES (?,?,?, 'pending')`,
now.UnixMilli(), fire.UnixMilli(), payload)
if err != nil {
return 0, fmt.Errorf("create reminder: %w", err)
}
id, _ := res.LastInsertId()
return id, nil
}
// DueReminders returns pending reminders with fire_ts <= now, oldest first.
// This is the predicate input from the loop side: `fire_ts <= now AND status='pending'`.
func (s *Store) DueReminders(ctx context.Context, now time.Time) ([]Reminder, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, created_ts, fire_ts, payload, status
FROM reminders
WHERE status = 'pending' AND fire_ts <= ?
ORDER BY fire_ts ASC`, now.UnixMilli())
if err != nil {
return nil, fmt.Errorf("due reminders: %w", err)
}
defer rows.Close()
var out []Reminder
for rows.Next() {
var r Reminder
var created, fire int64
if err := rows.Scan(&r.ID, &created, &fire, &r.Payload, &r.Status); err != nil {
return nil, err
}
r.CreatedTs = time.UnixMilli(created).UTC()
r.FireTs = time.UnixMilli(fire).UTC()
out = append(out, r)
}
return out, rows.Err()
}
// MarkReminder sets a reminder's status. Only valid transitions: pending→fired,
// pending→cancelled. Anything else is a programming error.
func (s *Store) MarkReminder(ctx context.Context, id int64, status string) error {
if status != "fired" && status != "cancelled" {
return fmt.Errorf("%w: %s", ErrReminderState, status)
}
// pending → fired|cancelled only.
var current string
err := s.db.QueryRowContext(ctx, "SELECT status FROM reminders WHERE id = ?", id).Scan(&current)
if errors.Is(err, sql.ErrNoRows) {
return ErrReminderNotFound
}
if err != nil {
return err
}
if current != "pending" {
return fmt.Errorf("%w: currently %s", ErrReminderState, current)
}
_, err = s.db.ExecContext(ctx, "UPDATE reminders SET status = ? WHERE id = ?", status, id)
return err
}
+84
View File
@@ -0,0 +1,84 @@
-- maven core schema — append-only, three shapes.
-- a wrong fact is superseded, never overwritten. current value = latest non-voided row for a key.
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA foreign_keys=ON;
PRAGMA busy_timeout=5000;
-- facts — substrate, all observations (self + env + config).
-- ts = valid-time (true-as-of), not insert-time.
-- source: tap:* | infer:* | poll:* | ambient | promote | feedback
-- confidence = 1.0 for taps only; <1 for inferred.
-- voids_id points at the fact this one cancels (correction).
CREATE TABLE IF NOT EXISTS facts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL, -- unix epoch millis (valid-time)
kind TEXT NOT NULL CHECK (kind IN ('self','env','config')),
key TEXT NOT NULL,
value TEXT NOT NULL, -- json if structured
source TEXT NOT NULL,
confidence REAL NOT NULL DEFAULT 1.0 CHECK (confidence > 0.0 AND confidence <= 1.0),
voids_id INTEGER REFERENCES facts(id)
);
CREATE INDEX IF NOT EXISTS idx_facts_key_ts ON facts (key, ts DESC);
CREATE INDEX IF NOT EXISTS idx_facts_voids ON facts (voids_id);
-- reminders — user intent, fires once.
-- relative→absolute happens at capture ("in 4h" → store now+4h, never the string).
CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_ts INTEGER NOT NULL, -- epoch millis
fire_ts INTEGER NOT NULL, -- epoch millis
payload TEXT NOT NULL, -- json
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','fired','cancelled'))
);
CREATE INDEX IF NOT EXISTS idx_reminders_fire ON reminders (fire_ts) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_reminders_status ON reminders (status);
-- nudges — every proactive send + outcome. this table IS the restraint memory.
CREATE TABLE IF NOT EXISTS nudges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL, -- sent ts, epoch millis
rule TEXT NOT NULL,
channel TEXT NOT NULL,
message TEXT NOT NULL,
outcome TEXT NOT NULL DEFAULT 'pending' CHECK (outcome IN ('pending','acted','snoozed','ignored')),
outcome_ts INTEGER
);
CREATE INDEX IF NOT EXISTS idx_nudges_rule_ts ON nudges (rule, ts DESC);
CREATE INDEX IF NOT EXISTS idx_nudges_outcome ON nudges (outcome);
-- notes — semantic recall/preference store. no predicate reads these (that's
-- what facts are for); query-answering does brute-force cosine over embedding.
-- embedding is a little-endian float32 BLOB. no index — personal-scale scan.
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL, -- epoch millis
text TEXT NOT NULL,
embedding BLOB NOT NULL, -- little-endian float32[]
source TEXT NOT NULL
);
-- tools — the act allowlist maven executes. proposed rows are scaffolds maven
-- drafts when she hits an act she can't run; enabling (filling cmd + flipping
-- status) is a human act through an authed surface, NEVER the voice path. a
-- proposed row drives nothing — the executor only runs status='enabled' rows.
CREATE TABLE IF NOT EXISTS tools (
name TEXT PRIMARY KEY, -- the spoken verb ("restart")
cmd TEXT NOT NULL DEFAULT '[]', -- json argv prefix; args appended at run
destructive INTEGER NOT NULL DEFAULT 0, -- 1 ⇒ needs a confirm turn before it runs
status TEXT NOT NULL DEFAULT 'proposed' CHECK (status IN ('proposed','enabled')),
utterance TEXT NOT NULL DEFAULT '', -- the utterance that scaffolded a proposal (provenance)
created_ts INTEGER NOT NULL,
updated_ts INTEGER NOT NULL
);
-- presence_state — the only stateful bit of presence (a pure function otherwise).
-- hysteresis bucket; updated each tick after resolve().
CREATE TABLE IF NOT EXISTS presence_state (
id INTEGER PRIMARY KEY CHECK (id = 1), -- singleton row
last_bucket TEXT NOT NULL CHECK (last_bucket IN ('present','away')),
last_score REAL NOT NULL,
updated_ts INTEGER NOT NULL
);
+96
View File
@@ -0,0 +1,96 @@
// Package store is maven's persistent state layer.
//
// The layer is append-only: a wrong fact is superseded, not overwritten.
// Current value for a key = the latest non-voided fact row.
//
// Schema lives in schema.sql and is applied idempotently on Open.
package store
import (
"context"
"database/sql"
_ "embed" // schema.sql
"errors"
"fmt"
"time"
_ "modernc.org/sqlite"
)
//go:embed schema.sql
var schemaSQL string
// FactKind — self | env | config. The loop only evaluates predicates against
// `self` and `config` rows; `env` feeds env predicates (calendar/weather/health).
type FactKind string
const (
KindSelf FactKind = "self"
KindEnv FactKind = "env"
KindConfig FactKind = "config"
)
// Fact — one observation. ts is valid-time (true-as-of), not insert-time.
type Fact struct {
ID int64
Ts time.Time
Kind FactKind
Key string
Value string // raw json if structured
Source string // tap:* | infer:* | poll:* | ambient | promote | feedback
Confidence float64
VoidsID sql.NullInt64
}
// Bucket — presence hysteresis state.
type Bucket string
const (
Present Bucket = "present"
Away Bucket = "away"
)
// Store is the persistent state layer. All writes are append-only; nothing
// here performs an UPDATE of a fact value (status-flips on reminders/nudges
// are the documented exceptions — they mutate small state-machine columns).
type Store struct {
db *sql.DB
}
// Open opens or creates the sqlite database at path and applies the schema.
// Pragmas (WAL, NORMAL, FK on, busy_timeout) are set in schema.sql and re-applied
// per connection on open via the modernc driver DSN.
func Open(ctx context.Context, path string) (*Store, error) {
// `_pragma=busy_timeout(5000)` etc. embed cleanly; schema.sql sets them too.
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open %s: %w", path, err)
}
// single writer expected; the daemon is the only process touching the db.
db.SetMaxOpenConns(1)
if _, err := db.ExecContext(ctx, schemaSQL); err != nil {
_ = db.Close()
return nil, fmt.Errorf("apply schema: %w", err)
}
return &Store{db: db}, nil
}
// Close releases the database handle.
func (s *Store) Close() error { return s.db.Close() }
// DB exposes the underlying handle for internal read-only snapshots.
// Used by the loop to take a consistent read under a single transaction.
// Modules never receive this handle — core mediates.
func (s *Store) DB(ctx context.Context) (*sql.Tx, error) {
return s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
}
var (
// ErrNoFact — no non-voided row exists for this key.
ErrNoFact = errors.New("store: no fact for key")
// ErrConfidence — a write attempted to use a confidence outside (0,1.0].
ErrConfidence = errors.New("store: confidence must be in (0.0, 1.0]")
// ErrVoidsMissing — a correction pointed at a nonexistent fact.
ErrVoidsMissing = errors.New("store: voids_id does not reference an existing fact")
)
+290
View File
@@ -0,0 +1,290 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"path/filepath"
"testing"
"time"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "maven_test.db")
s, err := Open(context.Background(), path)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
func TestWriteAndLatestFact(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
// ts is stored as unix millis; round to ms to match the roundtrip.
now := time.Now().UTC().Truncate(time.Millisecond)
if _, err := s.SetValue(ctx, KindSelf, "water", "tap:water", map[string]int{"ml": 250}, now); err != nil {
t.Fatalf("SetValue: %v", err)
}
f, err := s.LatestFact(ctx, "water")
if err != nil {
t.Fatalf("LatestFact: %v", err)
}
if f.Key != "water" || f.Source != "tap:water" || f.Confidence != 1.0 {
t.Fatalf("got %+v", f)
}
var v map[string]int
if err := json.Unmarshal([]byte(f.Value), &v); err != nil || v["ml"] != 250 {
t.Fatalf("value roundtrip: %v (%s)", err, f.Value)
}
if !f.Ts.Equal(now) {
t.Fatalf("ts roundtrip: want %s got %s", now, f.Ts)
}
}
func TestAppendOnlySupersedeNotOverwrite(t *testing.T) {
// Two facts for the same key: LatestFact returns the newer one, the older
// row is still there (append-only audit trail).
s := newTestStore(t)
ctx := context.Background()
t1 := time.Now().UTC().Add(-5 * time.Minute)
t2 := time.Now().UTC()
if _, err := s.SetValue(ctx, KindSelf, "meal", "tap:meal", "pasta", t1); err != nil {
t.Fatal(err)
}
if _, err := s.SetValue(ctx, KindSelf, "meal", "tap:meal", "salad", t2); err != nil {
t.Fatal(err)
}
f, err := s.LatestFact(ctx, "meal")
if err != nil {
t.Fatal(err)
}
if f.Value != `"salad"` {
t.Fatalf("latest value: want salad, got %s", f.Value)
}
// audit trail still has both rows
var n int
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM facts WHERE key = 'meal'").Scan(&n); err != nil {
t.Fatal(err)
}
if n != 2 {
t.Fatalf("append-only: want 2 rows, got %d", n)
}
}
func TestCorrectValueVoidsAndSupersedes(t *testing.T) {
// User corrects a bad fact → latest row voids the previous; LatestFact
// now returns the corrected one; voids_id points back at the old row.
s := newTestStore(t)
ctx := context.Background()
old := time.Now().UTC().Add(-2 * time.Minute)
if _, err := s.SetValue(ctx, KindSelf, "sleep", "tap:sleep", "8h", old); err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
newID, err := s.CorrectValue(ctx, "sleep", "feedback", "6h", now)
if err != nil {
t.Fatalf("CorrectValue: %v", err)
}
f, err := s.LatestFact(ctx, "sleep")
if err != nil {
t.Fatal(err)
}
if f.ID != newID {
t.Fatalf("latest should be corrected row: want id=%d got=%d", newID, f.ID)
}
if !f.VoidsID.Valid || f.VoidsID.Int64 == 0 {
t.Fatalf("corrected row should void the old one: %+v", f.VoidsID)
}
// old row should NOT come back from LatestFact
if f.Value != `"6h"` {
t.Fatalf("value: want 6h got %s", f.Value)
}
// audit trail: 2 rows; one of them voids the other
var voidedCount int
if err := s.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM facts WHERE key='sleep' AND voids_id IS NOT NULL").Scan(&voidedCount); err != nil {
t.Fatal(err)
}
if voidedCount != 1 {
t.Fatalf("exactly one voiding row, got %d", voidedCount)
}
}
func TestSinceNoFactReturnsErrNoFact(t *testing.T) {
// silence on no-data = "shuts up when uncertain"
s := newTestStore(t)
ctx := context.Background()
if _, err := s.Since(ctx, "never_observed", time.Now().UTC()); !errors.Is(err, ErrNoFact) {
t.Fatalf("Since on missing key: want ErrNoFact, got %v", err)
}
}
func TestConfidenceBounds(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
for _, c := range []float64{0.0, -0.1, 1.5} {
if _, err := s.WriteFact(ctx, time.Now().UTC(), KindSelf, "x", "v", "tap", c, sql.NullInt64{}); !errors.Is(err, ErrConfidence) {
t.Fatalf("confidence %f: want ErrConfidence, got %v", c, err)
}
}
}
func TestProvenanceScopedLookup(t *testing.T) {
// A compensating fact from a non-authoritative source should NOT override the
// authoritative one when the rule uses LatestFactBySource.
s := newTestStore(t)
ctx := context.Background()
now := time.Now().UTC()
if _, err := s.SetValue(ctx, KindEnv, "service_nginx", "poll:healthcheck", "down", now); err != nil {
t.Fatal(err)
}
if _, err := s.SetValue(ctx, KindEnv, "service_nginx", "ambient", "down", now.Add(time.Second)); err != nil {
t.Fatal(err)
}
// unscoped latest = ambient (newer)
if f, _ := s.LatestFact(ctx, "service_nginx"); f.Source != "ambient" {
t.Fatalf("LatestFact: want ambient, got %s", f.Source)
}
// source-scoped = poll:healthcheck
f, err := s.LatestFactBySource(ctx, "service_nginx", "poll:healthcheck")
if err != nil || f.Source != "poll:healthcheck" {
t.Fatalf("LatestFactBySource: want poll:healthcheck, got %+v / %v", f, err)
}
// missing source → ErrNoFact (a compromised poller can't forge a trigger)
if _, err := s.LatestFactBySource(ctx, "service_nginx", "poll:bogus"); !errors.Is(err, ErrNoFact) {
t.Fatalf("bogus source: want ErrNoFact, got %v", err)
}
}
func TestRemindersRelativeResolvedAtCapture(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
// capture path (router) converts "in 4h" → absolute. store just takes fire_ts.
fire := time.Now().UTC().Add(4 * time.Hour)
id, err := s.CreateReminder(ctx, fire, `{"text":"wake me"}`)
if err != nil {
t.Fatal(err)
}
// not due yet
if due, err := s.DueReminders(ctx, time.Now().UTC()); err != nil || len(due) != 0 {
t.Fatalf("before fire: want 0 due, got %d (%v)", len(due), err)
}
// due once past fire_ts
if due, err := s.DueReminders(ctx, fire.Add(time.Second)); err != nil || len(due) != 1 || due[0].ID != id {
t.Fatalf("after fire: want 1 due (%d), got %d (%v)", id, len(due), err)
}
// mark fired → not due again (fires once)
if err := s.MarkReminder(ctx, id, "fired"); err != nil {
t.Fatalf("MarkReminder: %v", err)
}
if due, err := s.DueReminders(ctx, fire.Add(2*time.Second)); err != nil || len(due) != 0 {
t.Fatalf("after fired: want 0 due, got %d", len(due))
}
// can't fire again
if err := s.MarkReminder(ctx, id, "fired"); !errors.Is(err, ErrReminderState) {
t.Fatalf("re-fire: want ErrReminderState, got %v", err)
}
}
func TestNudgeOnceAndFeedbackOutcomes(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Now().UTC()
id, err := s.RecordNudge(ctx, "water", "voice", "drink some water", now)
if err != nil {
t.Fatal(err)
}
// pending state: not yet in outcomes
if os, _ := s.RecentOutcomes(ctx, "water", 5); len(os) != 0 {
t.Fatalf("pending should not count as outcome: got %v", os)
}
// resolve once → ok
if err := s.ResolveNudge(ctx, id, "acted", now.Add(time.Minute)); err != nil {
t.Fatalf("ResolveNudge: %v", err)
}
// re-resolve rejected — feedback signal must not be silently corruptable
if err := s.ResolveNudge(ctx, id, "ignored", now.Add(2*time.Minute)); !errors.Is(err, ErrNudgeOutcome) {
t.Fatalf("re-resolve: want ErrNudgeOutcome, got %v", err)
}
// outcomes feed back: 1 acted in last N
if os, _ := s.RecentOutcomes(ctx, "water", 5); len(os) != 1 || os[0] != "acted" {
t.Fatalf("outcomes: want [acted], got %v", os)
}
}
func TestUnackedTelegramRules(t *testing.T) {
// the dispatcher's RepeatUnacked reads this to know which sev4 telegram
// sends are still un-acked. telegram is the sev4-away channel by routing
// construction, so channel+outcome is the full filter.
s := newTestStore(t)
ctx := context.Background()
now := time.Now().UTC()
// none → empty (not nil-iff-not-set is fine; empty slice is the contract)
if got, err := s.UnackedTelegramRules(ctx); err != nil || len(got) != 0 {
t.Fatalf("cold: want [] err=nil, got %v %v", got, err)
}
// a pending telegram nudge → its rule appears.
if _, err := s.RecordNudge(ctx, "service_down", "telegram", "homesrv down", now); err != nil {
t.Fatal(err)
}
got, err := s.UnackedTelegramRules(ctx)
if err != nil || len(got) != 1 || got[0] != "service_down" {
t.Fatalf("after send: want [service_down], got %v %v", got, err)
}
// a pending nudge on a different channel (voice) must NOT appear — the
// repeat-til-ack path is telegram-only.
if _, err := s.RecordNudge(ctx, "water", "voice", "drink water", now); err != nil {
t.Fatal(err)
}
if got, err := s.UnackedTelegramRules(ctx); err != nil || len(got) != 1 || got[0] != "service_down" {
t.Fatalf("voice must not appear: want [service_down], got %v %v", got, err)
}
// a second pending telegram nudge for a different rule → both appear,
// sorted by rule name (deterministic for the daemon).
if _, err := s.RecordNudge(ctx, "disk_full", "telegram", "disk 99%", now); err != nil {
t.Fatal(err)
}
if got, err := s.UnackedTelegramRules(ctx); err != nil || len(got) != 2 || got[0] != "disk_full" || got[1] != "service_down" {
t.Fatalf("two rules: want [disk_full service_down], got %v %v", got, err)
}
// resolving one (the user acked disk_full) → only the other remains.
// RecordNudge returned disk_full's id; re-query to get it here.
id, err := s.LastNudge(ctx, "disk_full")
if err != nil {
t.Fatalf("LastNudge disk_full: %v", err)
}
if err := s.ResolveNudge(ctx, id.ID, "acted", now.Add(time.Minute)); err != nil {
t.Fatalf("ResolveNudge: %v", err)
}
if got, err := s.UnackedTelegramRules(ctx); err != nil || len(got) != 1 || got[0] != "service_down" {
t.Fatalf("after ack disk_full: want [service_down], got %v %v", got, err)
}
}
func TestPresenceStateSingletonRoundtrip(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
// cold start → away, 0
b, score, _, err := s.LoadPresenceState(ctx)
if err != nil || b != Away || score != 0 {
t.Fatalf("cold: want Away/0, got %s/%f (%v)", b, score, err)
}
// save → reload
now := time.Now().UTC()
if err := s.SavePresenceState(ctx, Present, 0.83, now); err != nil {
t.Fatal(err)
}
if b, score, _, err := s.LoadPresenceState(ctx); err != nil || b != Present || score != 0.83 {
t.Fatalf("after save: want Present/0.83, got %s/%f (%v)", b, score, err)
}
}
+133
View File
@@ -0,0 +1,133 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
)
// Tool — one act in the allowlist. Cmd is the fixed argv prefix run with the
// utterance's args appended (no shell). Status 'proposed' is a scaffold that
// drives nothing; 'enabled' is the human-flipped, runnable form.
type Tool struct {
Name string
Cmd []string
Destructive bool
Status string // proposed | enabled
Utterance string // provenance: the utterance that scaffolded a proposal
CreatedTs time.Time
UpdatedTs time.Time
}
var (
// ErrToolNotFound — no tool row with this name.
ErrToolNotFound = errors.New("store: tool not found")
// ErrToolCmd — an enable supplied an empty argv (an enabled tool must run something).
ErrToolCmd = errors.New("store: enabled tool needs a non-empty cmd")
)
// ProposeTool inserts a 'proposed' scaffold for name (provenance = utterance)
// if no row for name exists yet. Returns true when a new proposal was written,
// false when a row (proposed or enabled) already existed. maven calls this when
// she classifies an act whose verb isn't on the enabled allowlist — she drafts
// the registration; a human enables it. Never overwrites an enabled tool.
func (s *Store) ProposeTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error) {
res, err := s.db.ExecContext(ctx, `
INSERT INTO tools (name, cmd, destructive, status, utterance, created_ts, updated_ts)
VALUES (?, '[]', 0, 'proposed', ?, ?, ?)
ON CONFLICT(name) DO NOTHING`,
name, utterance, ts.UnixMilli(), ts.UnixMilli())
if err != nil {
return false, fmt.Errorf("propose tool: %w", err)
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// EnableTool fills cmd + destructive and flips status to 'enabled'. This is the
// human "enable" act (the authed surface calls it); it upserts so enabling a
// name that was never proposed still works. An empty cmd is refused — an
// enabled tool that runs nothing is a footgun, not a tool.
func (s *Store) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, ts time.Time) error {
if len(cmd) == 0 {
return ErrToolCmd
}
raw, err := json.Marshal(cmd)
if err != nil {
return fmt.Errorf("enable tool: %w", err)
}
d := 0
if destructive {
d = 1
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO tools (name, cmd, destructive, status, utterance, created_ts, updated_ts)
VALUES (?, ?, ?, 'enabled', '', ?, ?)
ON CONFLICT(name) DO UPDATE SET cmd=excluded.cmd, destructive=excluded.destructive,
status='enabled', updated_ts=excluded.updated_ts`,
name, string(raw), d, ts.UnixMilli(), ts.UnixMilli())
if err != nil {
return fmt.Errorf("enable tool: %w", err)
}
return nil
}
// LookupTool returns the tool by name. ErrToolNotFound when absent.
func (s *Store) LookupTool(ctx context.Context, name string) (Tool, error) {
row := s.db.QueryRowContext(ctx, `
SELECT name, cmd, destructive, status, utterance, created_ts, updated_ts
FROM tools WHERE name = ?`, name)
t, err := scanTool(row)
if errors.Is(err, sql.ErrNoRows) {
return Tool{}, ErrToolNotFound
}
return t, err
}
// ListTools returns tools filtered by status ("" ⇒ all), name-sorted.
func (s *Store) ListTools(ctx context.Context, status string) ([]Tool, error) {
q := `SELECT name, cmd, destructive, status, utterance, created_ts, updated_ts FROM tools`
var args []any
if status != "" {
q += ` WHERE status = ?`
args = append(args, status)
}
q += ` ORDER BY name ASC`
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("list tools: %w", err)
}
defer rows.Close()
var out []Tool
for rows.Next() {
t, err := scanTool(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// scanner is the shared shape of *sql.Row and *sql.Rows.
type scanner interface{ Scan(...any) error }
func scanTool(sc scanner) (Tool, error) {
var t Tool
var cmdJSON string
var d int
var created, updated int64
if err := sc.Scan(&t.Name, &cmdJSON, &d, &t.Status, &t.Utterance, &created, &updated); err != nil {
return Tool{}, err
}
if err := json.Unmarshal([]byte(cmdJSON), &t.Cmd); err != nil {
return Tool{}, fmt.Errorf("scan tool %q cmd: %w", t.Name, err)
}
t.Destructive = d != 0
t.CreatedTs = time.UnixMilli(created).UTC()
t.UpdatedTs = time.UnixMilli(updated).UTC()
return t, nil
}