initial commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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 (hundreds–thousands) 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(¤t)
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user