Compare commits
29 Commits
7d69a1f320
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cb6adf7ef | |||
| b36964a427 | |||
| 6c81a0d4f8 | |||
| aaed1ad265 | |||
| fa178a1fd2 | |||
| ef845ce463 | |||
| 4b2cb4242f | |||
| 7507cc6d28 | |||
| c963895599 | |||
| af3a0e2ac8 | |||
| b37ee40dd2 | |||
| 2690fdd513 | |||
| 703218658b | |||
| 607ad74def | |||
| aac3173874 | |||
| 9d7786c3e6 | |||
| 682eb603fa | |||
| 6d296bf743 | |||
| c855731c39 | |||
| bd2b8a41fd | |||
| 16b44c9e90 | |||
| 5bdb98c1a7 | |||
| 67f91442a4 | |||
| 3a4f498f86 | |||
| 7d4f9ca4a8 | |||
| 582af0d68b | |||
| b422b6deb6 | |||
| 3386405c7c | |||
| 730ffe33ce |
+69
-7
@@ -14,13 +14,17 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"keel/internal/ai"
|
"keel/internal/ai"
|
||||||
|
"keel/internal/ambient"
|
||||||
|
"keel/internal/aw"
|
||||||
"keel/internal/enforce"
|
"keel/internal/enforce"
|
||||||
"keel/internal/evidence"
|
"keel/internal/evidence"
|
||||||
"keel/internal/harness"
|
"keel/internal/harness"
|
||||||
"keel/internal/knowledge"
|
"keel/internal/knowledge"
|
||||||
|
"keel/internal/memory"
|
||||||
"keel/internal/mode"
|
"keel/internal/mode"
|
||||||
"keel/internal/mode/focus"
|
"keel/internal/mode/focus"
|
||||||
"keel/internal/mode/offscreen"
|
"keel/internal/mode/offscreen"
|
||||||
|
"keel/internal/notify"
|
||||||
"keel/internal/settings"
|
"keel/internal/settings"
|
||||||
"keel/internal/statusfile"
|
"keel/internal/statusfile"
|
||||||
"keel/internal/tasks"
|
"keel/internal/tasks"
|
||||||
@@ -63,11 +67,28 @@ func main() {
|
|||||||
return harness.Services{}, fmt.Errorf("%w: %v", settings.ErrInvalidBackend, err)
|
return harness.Services{}, fmt.Errorf("%w: %v", settings.ErrInvalidBackend, err)
|
||||||
}
|
}
|
||||||
svc := ai.NewService(backend)
|
svc := ai.NewService(backend)
|
||||||
|
|
||||||
|
// Defend against a hand-blanked aw_url: SeedFromEnv defaults this only on
|
||||||
|
// first run, but Load returns whatever the JSON file holds on later starts.
|
||||||
|
awURL := s.AWURL
|
||||||
|
if awURL == "" {
|
||||||
|
awURL = "http://localhost:5600"
|
||||||
|
}
|
||||||
|
var mem memory.Store = memory.NewNop()
|
||||||
|
awc := aw.New(awURL)
|
||||||
|
if err := awc.EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil {
|
||||||
|
log.Printf("memory: AW unavailable at %s (%v); running without memory", awURL, err)
|
||||||
|
} else {
|
||||||
|
mem = memory.NewAWStore(awc, "keel.events")
|
||||||
|
log.Printf("memory: keel.events ready at %s", awURL)
|
||||||
|
}
|
||||||
|
|
||||||
return harness.Services{
|
return harness.Services{
|
||||||
AI: svc,
|
AI: svc,
|
||||||
Tasks: tasks.NewMarvin(s.MarvinCmd),
|
Tasks: tasks.NewMarvin(s.MarvinCmd),
|
||||||
Knowledge: knowledge.NewFileSource(s.KnowledgePath),
|
Knowledge: knowledge.NewFileSource(s.KnowledgePath),
|
||||||
Enforce: enforce.NewGuard(),
|
Enforce: enforce.NewGuard(),
|
||||||
|
Memory: mem,
|
||||||
Clock: time.Now,
|
Clock: time.Now,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -120,20 +141,58 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ambient drift coach: an always-on sentinel beside the harness. It reuses the
|
||||||
|
// base ports (AI, knowledge, tasks, memory, clock) and stays silent whenever a
|
||||||
|
// mode is active. Backend/port changes need a daemon restart to reach it; live
|
||||||
|
// settings only re-dial its cadence and mode (mirrors "active mode keeps its
|
||||||
|
// services").
|
||||||
|
ambMode := cfg.AmbientMode
|
||||||
|
if ambMode == "" {
|
||||||
|
ambMode = settings.AmbientNotify
|
||||||
|
}
|
||||||
|
// Compute the effective cadence so the log reflects what the sentinel will
|
||||||
|
// actually use: New clamps a non-positive value (e.g. a settings.json that
|
||||||
|
// predates these fields) to a default, mirrored here for the log line.
|
||||||
|
ambCadence := time.Duration(cfg.AmbientCadenceSecs) * time.Second
|
||||||
|
if ambCadence <= 0 {
|
||||||
|
ambCadence = 5 * time.Minute
|
||||||
|
}
|
||||||
|
sentinel := ambient.New(ambient.Deps{
|
||||||
|
AI: base.AI,
|
||||||
|
Knowledge: base.Knowledge,
|
||||||
|
Tasks: base.Tasks,
|
||||||
|
Notifier: notify.NewNotifier(),
|
||||||
|
Memory: base.Memory,
|
||||||
|
Clock: time.Now,
|
||||||
|
ActiveMode: func() string { return h.State().ActiveMode },
|
||||||
|
}, ambCadence, ambMode)
|
||||||
|
log.Printf("ambient: mode=%s cadence=%s", ambMode, ambCadence)
|
||||||
|
|
||||||
srv := web.NewServer(h)
|
srv := web.NewServer(h)
|
||||||
srv.Init() // re-arm or expire a restored deadline-bearing session
|
srv.Init() // re-arm or expire a restored deadline-bearing session
|
||||||
|
|
||||||
|
srv.SetAmbient(sentinel.Line, sentinel.Snooze)
|
||||||
|
sentinel.AddOnChange(srv.Broadcast)
|
||||||
|
|
||||||
// applyFn re-wires the running daemon from a new Settings value: it rebuilds a
|
// applyFn re-wires the running daemon from a new Settings value: it rebuilds a
|
||||||
// fresh harness.Services and installs it via SetServices. Per the harness
|
// fresh harness.Services and installs it via SetServices. Per the harness
|
||||||
// contract this affects the NEXT mode start; an already-active mode keeps the
|
// contract this affects the NEXT mode start; an already-active mode keeps the
|
||||||
// services it was built with. It validates the backend before mutating, so
|
// services it was built with. It validates the backend before mutating, so
|
||||||
// POST /settings can reject an invalid backend atomically.
|
// POST /settings can reject an invalid backend atomically.
|
||||||
applyFn := func(s settings.Settings) error {
|
applyFn := func(s settings.Settings) error {
|
||||||
|
if s.AmbientMode != "" && !settings.ValidAmbientMode(s.AmbientMode) {
|
||||||
|
return fmt.Errorf("%w: %q", settings.ErrInvalidAmbientMode, s.AmbientMode)
|
||||||
|
}
|
||||||
next, err := buildServices(s)
|
next, err := buildServices(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
h.SetServices(next)
|
h.SetServices(next)
|
||||||
|
mode := s.AmbientMode
|
||||||
|
if mode == "" {
|
||||||
|
mode = settings.AmbientNotify
|
||||||
|
}
|
||||||
|
sentinel.SetConfig(time.Duration(s.AmbientCadenceSecs)*time.Second, mode)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
srv.SetSettings(settingsPath, cfg, applyFn)
|
srv.SetSettings(settingsPath, cfg, applyFn)
|
||||||
@@ -143,19 +202,22 @@ func main() {
|
|||||||
if statusPath, err := statusfile.DefaultPath(); err != nil {
|
if statusPath, err := statusfile.DefaultPath(); err != nil {
|
||||||
log.Printf("status file disabled: %v", err)
|
log.Printf("status file disabled: %v", err)
|
||||||
} else {
|
} else {
|
||||||
writer := statusfile.NewWriter(statusPath, h.State)
|
writer := statusfile.NewWriter(statusPath, h.State, sentinel.Line)
|
||||||
// Re-render the bar on every harness change, not just the coarse tick, so a
|
|
||||||
// drift transition reaches the status bar as fast as it reaches the web UI.
|
|
||||||
h.AddOnChange(writer.Wake)
|
h.AddOnChange(writer.Wake)
|
||||||
|
sentinel.AddOnChange(writer.Wake)
|
||||||
go writer.Run(context.Background())
|
go writer.Run(context.Background())
|
||||||
log.Printf("status: writing %s", statusPath)
|
log.Printf("status: writing %s", statusPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Feed the window sensor stream into the harness, which forwards it to the
|
// Feed the window sensor stream into BOTH the harness (for the active mode)
|
||||||
// active mode iff that mode consumes evidence. On a headless box the source is
|
// and the ambient sentinel (always-on). On a headless box the source is a
|
||||||
// a no-op, so this degrades gracefully without new X11 requirements.
|
// no-op, so both degrade gracefully without new X11 requirements.
|
||||||
src := evidence.NewSource()
|
src := evidence.NewSource()
|
||||||
go src.Watch(context.Background(), h.RecordWindow)
|
go src.Watch(context.Background(), func(w evidence.WindowSnapshot) {
|
||||||
|
h.RecordWindow(w)
|
||||||
|
sentinel.OnWindow(w)
|
||||||
|
})
|
||||||
|
go sentinel.Run(context.Background())
|
||||||
|
|
||||||
go openBrowser("http://" + addr)
|
go openBrowser("http://" + addr)
|
||||||
|
|
||||||
|
|||||||
@@ -185,8 +185,9 @@ mode would just route a thought. The runtime state machine becomes the loop in
|
|||||||
|
|
||||||
Off-screen mode, from the phone, end to end — **this is what ships today**:
|
Off-screen mode, from the phone, end to end — **this is what ships today**:
|
||||||
1. **Collect** — today's Marvin tasks (`am --json`) + the `~/owc/goals-2026.md`
|
1. **Collect** — today's Marvin tasks (`am --json`) + the `~/owc/goals-2026.md`
|
||||||
goals + the life-domain `~/owc/resources/bug-*.md` notes, assembled into a
|
goals + the life-domain `~/owc/resources/bug-*.md` notes — plus recent
|
||||||
single budgeted brief.
|
proposals and their outcomes recalled from the `keel.events` AW bucket —
|
||||||
|
assembled into a single budgeted brief.
|
||||||
2. **Brain** — the chosen brain (`ai.Proposer`) returns one worthwhile off-screen
|
2. **Brain** — the chosen brain (`ai.Proposer`) returns one worthwhile off-screen
|
||||||
action plus a short rationale.
|
action plus a short rationale.
|
||||||
3. **Surface** — the phone-first web UI renders a proposal card and asks Felix to
|
3. **Surface** — the phone-first web UI renders a proposal card and asks Felix to
|
||||||
@@ -196,10 +197,13 @@ Off-screen mode, from the phone, end to end — **this is what ships today**:
|
|||||||
|
|
||||||
No new store touched. The brain was rented for one call. Everything else was Keel.
|
No new store touched. The brain was rented for one call. Everything else was Keel.
|
||||||
|
|
||||||
**Next increment:** memory. Off-screen does not yet read or write Keel's AW
|
**Shipped:** memory. Off-screen now writes its decisions to the `keel.events` AW
|
||||||
buckets, so it cannot remember what it proposed last time. Adding a *remember*
|
bucket (`proposal_made` / `action_taken` / `proposal_dismissed`, correlated by a
|
||||||
step (read `keel.state` for prior proposals) and an *act* event
|
random `proposal_id`) and reads its recent history back into the brief — so a
|
||||||
(`proposal_made`/`action_taken` to `keel.events`) closes the loop per §7.
|
proposal can avoid repeating itself and follow up on what was dismissed or left
|
||||||
|
undone. Storage sits behind a thin `memory.Store` port (AW-backed, or a nop when
|
||||||
|
AW is down), the seam every later mode reuses; see §7. The `keel.state` bucket and
|
||||||
|
the trend/spiral detection that reads this data remain future increments.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,298 @@
|
|||||||
|
# Ambient Drift Coach — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-05
|
||||||
|
**Status:** Approved, ready for implementation planning
|
||||||
|
**Scope:** Give Keel an always-on presence. Today Keel only coaches *inside* a
|
||||||
|
declared focus session; the moment you are just working, the harness goes idle,
|
||||||
|
drops every window snapshot, and the status bar reads `idle`. This slice adds an
|
||||||
|
**ambient drift coach**: a sentinel that watches the window stream continuously
|
||||||
|
(even when no mode is active), judges your recent activity against the broad
|
||||||
|
`~/owc` frame (goals, values, life-domains) plus today's Marvin tasks, and —
|
||||||
|
when you have clearly slid off what matters — surfaces a single coaching line to
|
||||||
|
the status bar, a `notify-send` toast, and a web-UI banner you can act on.
|
||||||
|
|
||||||
|
This builds the **Control loop / scheduler** component from
|
||||||
|
`docs/keel-architecture.md` §3, which today has zero implementation, and is the
|
||||||
|
first thing that makes Keel *show up unprompted* (SOUL: "when my partner drifts,
|
||||||
|
I name it").
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The focus mode is already a capable proactive coach — local on-task matching, an
|
||||||
|
LLM drift judge, a semantic "nudge" over recent window titles, surfaced to the
|
||||||
|
status bar and web UI, all sensibly debounced. But every bit of it hangs off a
|
||||||
|
**declared `Commitment`**. You must open the UI and start a session first. With
|
||||||
|
no session:
|
||||||
|
|
||||||
|
- `harness.RecordWindow` finds no active mode and silently drops the snapshot.
|
||||||
|
- `statusfile.Render` of an empty envelope returns `idle`.
|
||||||
|
- No brain call ever happens; Keel is blind and mute.
|
||||||
|
|
||||||
|
So the common case — Felix working without ceremonially declaring a task — gets
|
||||||
|
nothing. The fix is an observer that runs regardless of the active mode and is
|
||||||
|
grounded in the standing frame instead of a per-session commitment.
|
||||||
|
|
||||||
|
## Approach (and the one real fork)
|
||||||
|
|
||||||
|
The ambient coach is **not a mode**. It is an always-on **sentinel** that runs
|
||||||
|
*beside* the one-mode harness. Two harness invariants make "ambient as a mode"
|
||||||
|
the wrong shape:
|
||||||
|
|
||||||
|
1. The harness hosts exactly one mode and drops evidence when idle — an ambient
|
||||||
|
watcher must run continuously, including when idle.
|
||||||
|
2. `harness.Start` returns `ErrBusy` if a different mode is active — an ambient
|
||||||
|
mode would either block you from starting focus, or need a special
|
||||||
|
auto-yield carve-out that erodes the single-mode contract.
|
||||||
|
|
||||||
|
A sentinel sidesteps both and matches §3, where the control loop is a distinct
|
||||||
|
component beside the harness. **Coexistence rule:** when any harness mode is
|
||||||
|
active (`h.State().ActiveMode != ""`), the sentinel stays silent — focus mode
|
||||||
|
already owns coaching during a declared session, so there is never double
|
||||||
|
coaching.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
Each unit has one responsibility, a defined interface, and is testable alone.
|
||||||
|
|
||||||
|
### 1. `internal/notify` — notification effector (new)
|
||||||
|
|
||||||
|
The OS-level "interrupt me" primitive, mirroring `enforce.Guard`.
|
||||||
|
|
||||||
|
- **Interface:** `Notifier interface { Notify(ctx context.Context, title, body string) error }`
|
||||||
|
- **Linux adapter:** shells `notify-send <title> <body>`. Best-effort: returns
|
||||||
|
an error for diagnostics; callers never block on it.
|
||||||
|
- **Constructor:** `NewNotifier() Notifier` returns the real adapter when the
|
||||||
|
`notify-send` binary is found on `PATH` (via `exec.LookPath`), else a nop. On
|
||||||
|
non-Linux it is a nop. This means a missing binary degrades to "no toast,"
|
||||||
|
never a failure.
|
||||||
|
- **Nop:** `Notify` returns nil, does nothing.
|
||||||
|
- **Depends on:** `os/exec` only. Leaf package.
|
||||||
|
|
||||||
|
### 2. `internal/frame` — shared frame assembler (new; DRY extraction)
|
||||||
|
|
||||||
|
The "what matters to Felix" brief, today duplicated inside off-screen's
|
||||||
|
`collect.go`. Extracting it removes the duplication and gives the sentinel the
|
||||||
|
same grounding off-screen uses.
|
||||||
|
|
||||||
|
- **Interface:** `Assemble(ctx context.Context, know knowledge.Source, tasks tasks.Provider) string`
|
||||||
|
returns a bounded markdown brief with three sections: **Today's tasks**
|
||||||
|
(from `tasks.Today`), **Goals** (`~/owc/goals-2026.md` via `knowledge.Load`),
|
||||||
|
and **Life domains** (every `~/owc/resources/bug-*.md` via `knowledge.Load`).
|
||||||
|
- **Best-effort:** never panics, never errors; tolerates nil ports and missing
|
||||||
|
files, degrading to a short, mostly-empty brief. Same truncation cap
|
||||||
|
(`maxBriefBytes`) and rune-safe clipping off-screen uses today.
|
||||||
|
- **Refactor:** off-screen's `briefTasks` / `briefGoals` / `briefLifeDomains`
|
||||||
|
are replaced by a call to `frame.Assemble`; off-screen keeps its own
|
||||||
|
proposal-history section (`briefHistory`) prepended around it. Off-screen's
|
||||||
|
existing tests must stay green.
|
||||||
|
- **Depends on:** `knowledge`, `tasks`. Imports nothing from `ai`/`mode`.
|
||||||
|
|
||||||
|
### 3. `internal/ai` — `AmbientDrift` method (new; mirrors `Nudge`)
|
||||||
|
|
||||||
|
The brain call grounded in the frame, not a commitment.
|
||||||
|
|
||||||
|
- **Method:** `func (s *Service) AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error)`
|
||||||
|
returns `""` when the activity plausibly serves the frame (on-track), and a
|
||||||
|
one-sentence coaching line when it is a slide.
|
||||||
|
- **Prompt:** an "ambient coach" prompt stating the user has **not** declared a
|
||||||
|
task; it is handed the frame and the recent window-title sequence and asked
|
||||||
|
whether the activity serves **any** of what matters, **forgivingly** —
|
||||||
|
legitimate breaks, rest, and unplanned-but-useful work are on-track. Output is
|
||||||
|
`ONLY` JSON: `{"drifting": <bool>, "message": "<one sentence; REQUIRED when drifting>"}`.
|
||||||
|
- **Parse (`parseAmbientDrift`):** reuses `extractJSON` and the shared
|
||||||
|
`ErrEmptyResponse` / `ErrNoJSON` sentinels. `drifting:false` → `""`.
|
||||||
|
`drifting:true` with a message → the message. `drifting:true` with no message →
|
||||||
|
`""` (silence) — the ambient signal is advisory, so the safe degenerate is to
|
||||||
|
say nothing, exactly like `parseNudge`.
|
||||||
|
- **Depends on:** the existing `Backend`. `ai` stays a leaf (primitives only).
|
||||||
|
|
||||||
|
### 4. `internal/ambient` — the Sentinel (new; the meat)
|
||||||
|
|
||||||
|
Always-on observer: consume evidence → tick on cadence → judge → surface.
|
||||||
|
|
||||||
|
**Construction:** `New(deps)` where deps carry `*ai.Service`, `knowledge.Source`,
|
||||||
|
`tasks.Provider`, `notify.Notifier`, `memory.Store`, `clock func() time.Time`,
|
||||||
|
and `activeMode func() string` (reads `h.State().ActiveMode` for the coexistence
|
||||||
|
rule). Config: `cadence time.Duration` and `mode` (`off` | `status` | `notify`).
|
||||||
|
|
||||||
|
**State (under one mutex):** `recentTitles []string` (consecutive-dedup ring,
|
||||||
|
capped like focus's `recentTitlesMax`), `lastWindow evidence.WindowSnapshot`,
|
||||||
|
`line string` (current drift line; `""` when on-track), `lastEvalTitles`
|
||||||
|
(snapshot of the ring at the last brain call), `wasDrifting bool`,
|
||||||
|
`episodeNotified bool`, `snoozeUntil time.Time`, `gen int`, and `onChange []func()`.
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
|
||||||
|
- `OnWindow(snap evidence.WindowSnapshot)` — called from the evidence fan-out.
|
||||||
|
Updates `lastWindow`, appends the title to the ring (skip consecutive dup,
|
||||||
|
cap). Cheap and lock-bounded; fires no brain call.
|
||||||
|
- `Run(ctx context.Context)` — cadence loop: a ticker every `cadence` calls
|
||||||
|
`evaluate()`; returns on ctx cancel.
|
||||||
|
- `evaluate()` — the decision (see Trigger discipline below).
|
||||||
|
- `Line() string` — current ambient line for the surfaces (`""` when on-track,
|
||||||
|
quiet, or snoozed).
|
||||||
|
- `Snooze(d time.Duration)` — sets `snoozeUntil = clock()+d`; clears the line and
|
||||||
|
episode flags; fires `onChange`. While snoozed, `evaluate()` returns early.
|
||||||
|
- `SetConfig(cadence time.Duration, mode string)` — live settings update.
|
||||||
|
- `AddOnChange(f func())` — register a surface refresh (status wake, web
|
||||||
|
broadcast). Fired whenever the line changes.
|
||||||
|
|
||||||
|
**Depends on:** `ai`, `frame`, `notify`, `memory`, `evidence`, and a
|
||||||
|
`activeMode` closure. It does **not** import `harness` (closure injection keeps
|
||||||
|
it decoupled and testable).
|
||||||
|
|
||||||
|
### 5. Wiring — `cmd/keeld/main.go`
|
||||||
|
|
||||||
|
- **Build** the notifier (`notify.NewNotifier()`) and the sentinel from settings
|
||||||
|
(cadence, mode) and the shared ports (the same `ai.Service`, `knowledge`,
|
||||||
|
`tasks`, `memory`, clock used to build `harness.Services`), with
|
||||||
|
`activeMode: func() string { return h.State().ActiveMode }`.
|
||||||
|
- **Fan the evidence stream to both** the harness and the sentinel:
|
||||||
|
```go
|
||||||
|
go src.Watch(ctx, func(w evidence.WindowSnapshot) {
|
||||||
|
h.RecordWindow(w)
|
||||||
|
sentinel.OnWindow(w)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
- **Run** the sentinel: `go sentinel.Run(ctx)`.
|
||||||
|
- **Surface refresh:** register both the status writer's `Wake` and the web
|
||||||
|
broadcast with `sentinel.AddOnChange(...)`, so a new ambient line re-renders
|
||||||
|
the bar and pushes over SSE promptly.
|
||||||
|
- **Live settings:** `applyFn` (the existing POST `/settings` re-wire) also calls
|
||||||
|
`sentinel.SetConfig(...)` from the new settings, so the dial takes effect
|
||||||
|
without a restart.
|
||||||
|
|
||||||
|
## Trigger discipline (aggressive, but not trigger-happy)
|
||||||
|
|
||||||
|
A `notify-send` toast on a false positive is far more irritating than a stale
|
||||||
|
status line, so `evaluate()` is conservative and brain-call-thrifty:
|
||||||
|
|
||||||
|
1. If `mode == off` → return.
|
||||||
|
2. If snoozed (`clock() < snoozeUntil`) → return.
|
||||||
|
3. If `activeMode() != ""` → a mode is coaching; clear the line (if set), fire
|
||||||
|
`onChange`, return. (Coexistence rule.)
|
||||||
|
4. **Cheap gate:** if the ring is unchanged since `lastEvalTitles` **and** we are
|
||||||
|
currently on-track (`line == ""`) → skip the brain call. This naturally
|
||||||
|
pauses calls during steady deep work *and* while AFK (no new titles arrive in
|
||||||
|
either case), while still re-checking an unresolved drift.
|
||||||
|
5. Assemble the frame (`frame.Assemble`) and call
|
||||||
|
`ai.AmbientDrift(ctx, frame, recentTitles)` under a timeout.
|
||||||
|
6. Apply the result:
|
||||||
|
- **On-track (`""`):** if a line was set, clear it and reset
|
||||||
|
`wasDrifting=false`, `episodeNotified=false`; fire `onChange`. Otherwise no-op.
|
||||||
|
- **Drifting (`msg`):**
|
||||||
|
- **New episode** (`!wasDrifting`): set `line=msg`, `wasDrifting=true`,
|
||||||
|
`episodeNotified=false`; fire `onChange`. The **status bar / banner show
|
||||||
|
immediately**, but **no toast yet**.
|
||||||
|
- **Sustained** (already `wasDrifting` on this consecutive eval): record an
|
||||||
|
`ambient_nudge` event to `memory` (`{ "message": msg, "title":
|
||||||
|
lastWindow.Title, "class": lastWindow.Class }`), and — if `mode == notify`
|
||||||
|
and `!episodeNotified` — fire exactly one `notify-send` toast and set
|
||||||
|
`episodeNotified=true`. Further sustained ticks do not re-toast.
|
||||||
|
- On a brain **error**, leave the prior line intact and log — never fabricate
|
||||||
|
drift (same discipline as `Nudge`).
|
||||||
|
7. Update `lastEvalTitles` to the current ring.
|
||||||
|
|
||||||
|
Net effect with the default ~5-minute cadence: a 40-second glance at Twitter
|
||||||
|
never pops a toast (it resolves before the second tick); a sustained ~10-minute
|
||||||
|
slide shows on the bar at the first tick and pops one toast at the second.
|
||||||
|
Memory records only *sustained* drifts, independent of the surface mode.
|
||||||
|
|
||||||
|
## Surfaces
|
||||||
|
|
||||||
|
- **Status bar (`internal/statusfile`):** `Render` gains the current ambient line
|
||||||
|
(passed in by the `Writer`, which reads `sentinel.Line`). When the envelope is
|
||||||
|
idle (`ActiveMode == ""`): a non-empty line renders `⚠ <line>`; an empty line
|
||||||
|
renders `idle` as today. Non-idle envelopes are unchanged. The `Writer` adds an
|
||||||
|
`ambientLine func() string` source and `sentinel.AddOnChange(writer.Wake)`
|
||||||
|
drives prompt re-render; the existing minute ticker still applies.
|
||||||
|
- **Web UI (`internal/web` + `static/app.js`):** the served state gains an
|
||||||
|
`"ambient": "<line>"` field (read from `sentinel.Line`), pushed over the
|
||||||
|
existing SSE channel via `sentinel.AddOnChange(broadcast)`. `app.js` renders a
|
||||||
|
**banner** when the field is non-empty: the coaching line plus two controls —
|
||||||
|
**Snooze 1h** (`POST /ambient/snooze`) and **Start focus** (reuses the existing
|
||||||
|
start-session affordance so a nudge converts into a declared session). The
|
||||||
|
banner clears automatically when the line goes empty.
|
||||||
|
- **New endpoint:** `POST /ambient/snooze` → `sentinel.Snooze(1 * time.Hour)`.
|
||||||
|
Returns 204. No body needed in v1.
|
||||||
|
|
||||||
|
## Config (the agency-vs-cage dial, §8)
|
||||||
|
|
||||||
|
`settings.Settings` gains two fields (env-seedable, mirroring the existing ones):
|
||||||
|
|
||||||
|
- `ambient_mode` (`KEEL_AMBIENT_MODE`): `off` | `status` | `notify`. Default
|
||||||
|
`notify`. Validated in the settings applier (like `ai_backend`); an unknown
|
||||||
|
value is rejected with a wrapped sentinel so POST `/settings` can map it to 400.
|
||||||
|
- `ambient_cadence_secs` (`KEEL_AMBIENT_CADENCE_SECS`): integer seconds between
|
||||||
|
evaluations. Default `300`. A non-positive value falls back to the default.
|
||||||
|
|
||||||
|
The web settings form gains a select (`ambient_mode`) and a number input
|
||||||
|
(`ambient_cadence_secs`); `applyFn` pushes both into `sentinel.SetConfig`.
|
||||||
|
|
||||||
|
## Error handling & graceful degradation
|
||||||
|
|
||||||
|
- **No `notify-send` binary / non-Linux:** nop notifier; status line and banner
|
||||||
|
still work.
|
||||||
|
- **Brain error / timeout:** `AmbientDrift` returns an error; the sentinel logs
|
||||||
|
and leaves the prior line intact. Never fabricates drift.
|
||||||
|
- **AW down:** `memory` is already the nop store; `ambient_nudge` writes are
|
||||||
|
dropped silently.
|
||||||
|
- **`knowledge` / `tasks` errors:** `frame.Assemble` degrades to a partial brief
|
||||||
|
(best-effort), never errors.
|
||||||
|
- **Headless / no X11:** the evidence source is a no-op, so `OnWindow` is never
|
||||||
|
called, the ring stays empty, the cheap gate skips every brain call, and the
|
||||||
|
sentinel is silent. No new X11 requirement.
|
||||||
|
- **Nothing in the sentinel can crash the daemon** — every external call is
|
||||||
|
best-effort and lock-bounded.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **`internal/ambient`** (fake clock, fake `ai` with scriptable `AmbientDrift`
|
||||||
|
returns and a call counter, fake `notify.Notifier` recording calls, fake
|
||||||
|
`tasks`/`knowledge`, `activeMode` stub, `memory.Fake`):
|
||||||
|
- on-track → no line, no toast, no memory.
|
||||||
|
- single drift tick → line set, **no** toast, no memory yet.
|
||||||
|
- two consecutive drift ticks → line set, **exactly one** toast, one
|
||||||
|
`ambient_nudge` event.
|
||||||
|
- third consecutive drift tick → still one toast (once per episode).
|
||||||
|
- back on-track → line cleared, flags reset.
|
||||||
|
- `activeMode() != ""` → no eval, line cleared.
|
||||||
|
- `mode == status` → drift sets the line but never toasts (memory still records
|
||||||
|
the sustained drift).
|
||||||
|
- `mode == off` → nothing happens, no brain call.
|
||||||
|
- snooze → no eval / no line during the window.
|
||||||
|
- cheap gate → unchanged ring + on-track → assert the fake `ai` call count does
|
||||||
|
**not** increase.
|
||||||
|
- **`internal/notify`:** `NewNotifier` with `notify-send` absent → nop; `Notify`
|
||||||
|
returns nil, no panic. (The real shell path is not unit-tested.)
|
||||||
|
- **`internal/ai`:** `parseAmbientDrift` — on-track→`""`, drift+msg→msg, drift
|
||||||
|
with no msg→`""`, empty→`ErrEmptyResponse`, no-JSON→`ErrNoJSON`.
|
||||||
|
- **`internal/frame`:** `Assemble` with fake ports — sections present; nil ports
|
||||||
|
and missing files degrade without error; truncation honored. Off-screen's
|
||||||
|
existing tests stay green after the refactor.
|
||||||
|
- **`internal/statusfile`:** idle + ambient line → `⚠ …`; idle + empty line →
|
||||||
|
`idle`; non-idle envelopes unaffected.
|
||||||
|
- **`internal/web`:** served state includes `ambient`; `POST /ambient/snooze`
|
||||||
|
routes to `Snooze` and returns 204.
|
||||||
|
- **`internal/settings`:** `ambient_mode` validation (good values accepted,
|
||||||
|
unknown rejected with the sentinel); cadence default on non-positive.
|
||||||
|
|
||||||
|
## Out of scope (deliberate, for a later slice)
|
||||||
|
|
||||||
|
- **AFK precision via the AW afk bucket.** v1 uses the cheap-gate heuristic
|
||||||
|
(no new titles ⇒ no call), which covers both AFK and deep-focus well enough.
|
||||||
|
Reading AW's input-derived afk bucket to *prove* presence is a future collector.
|
||||||
|
- **Ambient enforcement** (window-minimize). The sentinel is advisory only;
|
||||||
|
minimizing windows with no declared session would be far too cagey. Enforcement
|
||||||
|
stays a focus-session decision.
|
||||||
|
- **Trend / spiral detection** over the accumulating `ambient_nudge` history
|
||||||
|
(§7). This slice *produces* that history; reading it for cross-time patterns is
|
||||||
|
the next increment.
|
||||||
|
- **Richer banner actions** beyond snooze + start-focus (e.g. "I'm on it",
|
||||||
|
per-domain mute).
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **Default cadence.** 300 s is a starting guess; may want tuning after living
|
||||||
|
with it. Configurable, so cheap to change.
|
||||||
|
- **Snooze duration.** Fixed 1 h in v1; a picker is a later refinement.
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# Off-screen Memory — AW-backed Recall — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-05
|
||||||
|
**Status:** Approved, ready for implementation planning
|
||||||
|
**Scope:** Give Keel its first durable, cross-run memory by wiring **one** mode
|
||||||
|
(off-screen) to a real ActivityWatch bucket. Off-screen records its own
|
||||||
|
decisions (`proposal_made` / `action_taken` / `proposal_dismissed`) to
|
||||||
|
`keel.events` and reads its recent history back into the next brief, so
|
||||||
|
proposals stay fresh and follow up on what was not acted on. The memory layer
|
||||||
|
is introduced as a thin reusable port (`memory.Store`) behind which AW, HTTP,
|
||||||
|
and bucket names are sealed — the seam every later mode reuses.
|
||||||
|
|
||||||
|
This realizes the "Next increment: memory" note in
|
||||||
|
`docs/keel-architecture.md` §5 and the storage design in §7, at the smallest
|
||||||
|
honest vertical.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Keel has no durable memory. Mode state dies in `~/.keel/.../state.json`; the
|
||||||
|
off-screen mode cannot remember what it proposed last time, so it can repeat
|
||||||
|
itself and can never follow up ("you said you'd walk before the call — did
|
||||||
|
you?"). The architecture (§7) resolves storage to **ActivityWatch buckets**
|
||||||
|
(no new DB), because AW is an append-only, timestamped, queryable event store —
|
||||||
|
the substrate that later unlocks trend/spiral detection no single tool can do.
|
||||||
|
Today, zero AW client code exists in the repo and no `keel.*` bucket exists.
|
||||||
|
|
||||||
|
This design builds the first real slice of that memory, and only that slice.
|
||||||
|
|
||||||
|
## Decisions (locked in brainstorming 2026-06-05)
|
||||||
|
|
||||||
|
1. **Slice width = thin port + one consumer.** Add a generic `memory.Store`
|
||||||
|
port to `harness.Services` (write an event, query recent), backed by AW, but
|
||||||
|
wire exactly one real behavior: off-screen remembers. Focus and other event
|
||||||
|
kinds come later.
|
||||||
|
2. **Behavior = fresh + follow-up.** The brief includes recent proposals *and*
|
||||||
|
their outcome (confirmed / dismissed / unactioned). The brain is told to
|
||||||
|
avoid blindly repeating and to follow up on a recent unactioned/dismissed one
|
||||||
|
when it still fits.
|
||||||
|
3. **Brain-only (no UI this slice).** Memory shapes the proposal; nothing new is
|
||||||
|
rendered. A "recently proposed" UI panel is a trivial later follow-up once the
|
||||||
|
data is flowing.
|
||||||
|
4. **AW from the start, behind a port.** Not the existing JSON `store` package —
|
||||||
|
§3/§7 lock storage to AW precisely for queryability. Modes never touch HTTP.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Two new leaf packages, matching the existing ports-and-adapters style
|
||||||
|
(`evidence.Source`, `knowledge.Source`, `tasks.Provider`, `ai.Backend`,
|
||||||
|
`enforce.Guard`):
|
||||||
|
|
||||||
|
### `internal/aw` — thin AW REST client
|
||||||
|
|
||||||
|
No Keel concepts; just buckets and events. Grounded in the live API
|
||||||
|
(`/api/0`, AW server v0.13.2): events are `{id, timestamp, duration, data{}}`,
|
||||||
|
and `GET .../events?limit=N` returns newest-first.
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Event struct {
|
||||||
|
Timestamp time.Time
|
||||||
|
Duration float64
|
||||||
|
Data map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct { /* baseURL, *http.Client with timeout */ }
|
||||||
|
|
||||||
|
func New(baseURL string) *Client
|
||||||
|
func (c *Client) EnsureBucket(ctx context.Context, id, eventType, client string) error // idempotent
|
||||||
|
func (c *Client) Insert(ctx context.Context, bucketID string, e Event) error // POST .../events
|
||||||
|
func (c *Client) Recent(ctx context.Context, bucketID string, limit int) ([]Event, error) // GET .../events?limit=N
|
||||||
|
```
|
||||||
|
|
||||||
|
- `EnsureBucket` is idempotent: creating an existing bucket is treated as
|
||||||
|
success (AW returns a benign "already exists" response).
|
||||||
|
- `Insert` posts a single event (`duration: 0` for discrete derived events).
|
||||||
|
- Any transport/HTTP-status error is returned; callers treat memory as
|
||||||
|
best-effort.
|
||||||
|
|
||||||
|
### `internal/memory` — Keel-facing port + adapters
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Event struct {
|
||||||
|
Kind string // "proposal_made", "action_taken", "proposal_dismissed"
|
||||||
|
At time.Time
|
||||||
|
Data map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store interface {
|
||||||
|
Record(ctx context.Context, e Event) error
|
||||||
|
Recent(ctx context.Context, kind string, n int) ([]Event, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `awStore` implements `Store` over `*aw.Client`, bucket id `keel.events`. It
|
||||||
|
maps `memory.Event ↔ aw.Event`, carrying `Kind` inside `data._kind` and the
|
||||||
|
rest of `Data` alongside. Because `keel.events` interleaves all kinds,
|
||||||
|
`Recent(kind, n)` **over-fetches** a bounded window of recent bucket events
|
||||||
|
(a fixed cap, e.g. 200), filters by `_kind` client-side, and returns up to `n`
|
||||||
|
newest. Events older than that window are not seen — acceptable for "recent",
|
||||||
|
and revisited if/when the event volume grows.
|
||||||
|
- `nopStore` — `Record`/`Recent` no-ops; used when AW is unreachable/disabled.
|
||||||
|
- `fake` — in-memory `Store` for tests (records appended in memory, `Recent`
|
||||||
|
filters and returns newest-first).
|
||||||
|
|
||||||
|
### Wiring
|
||||||
|
|
||||||
|
- Add `Memory memory.Store` to `harness.Services`.
|
||||||
|
- `cmd/keeld.buildServices` constructs the AW-backed store: base URL from a new
|
||||||
|
`KEEL_AW_URL` setting (default `http://localhost:5600`), then
|
||||||
|
`EnsureBucket("keel.events", "keel.event", "keel")` **once at startup**. If
|
||||||
|
that fails, log once and fall back to `nopStore` so a missing/down AW never
|
||||||
|
breaks boot.
|
||||||
|
- `offscreen.New` / `offscreen.Factory` receive `svc.Memory`.
|
||||||
|
|
||||||
|
Modes depend only on the `memory.Store` interface; AW, HTTP, and bucket names
|
||||||
|
stay sealed inside the adapter — same shape as every other port.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
`keel.events` is an append-only log of Keel's **derived** events. This slice
|
||||||
|
writes three kinds, correlated by a `proposal_id` (short random hex minted when
|
||||||
|
a proposal is made; `crypto/rand`, in the daemon, not the sandbox):
|
||||||
|
|
||||||
|
| Kind | When | `data` |
|
||||||
|
|---|---|---|
|
||||||
|
| `proposal_made` | brain returns a proposal | `{proposal_id, mode:"offscreen", next_action, rationale}` |
|
||||||
|
| `action_taken` | confirm succeeds | `{proposal_id, mode:"offscreen", next_action}` |
|
||||||
|
| `proposal_dismissed` | dismiss | `{proposal_id, mode:"offscreen"}` |
|
||||||
|
|
||||||
|
**Reference, don't duplicate (§7):** `next_action` is Keel's *own* output, not
|
||||||
|
another tool's source of truth — recording it logs Keel's decision, it does not
|
||||||
|
duplicate Marvin. The Marvin task id is *not* captured because
|
||||||
|
`tasks.Provider.Create` returns only `error`; surfacing the real id is a future
|
||||||
|
enhancement gated on that signature and is out of scope here.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
### Write path (all best-effort)
|
||||||
|
|
||||||
|
A memory write failure is logged and swallowed; it never fails the command.
|
||||||
|
|
||||||
|
- Proposal produced (async `onComplete`, status → `proposed`): mint a
|
||||||
|
`proposal_id`, stash it on the mode, `Record(proposal_made)`.
|
||||||
|
- `confirm()`: after `tasks.Create` succeeds, `Record(action_taken)`.
|
||||||
|
- `dismiss()`: `Record(proposal_dismissed)`.
|
||||||
|
|
||||||
|
### Read path
|
||||||
|
|
||||||
|
`offscreen.assembleBrief()` gains a `## Recently proposed` section:
|
||||||
|
|
||||||
|
1. `Recent("proposal_made", 5)` (newest-first); drop entries older than ~7 days.
|
||||||
|
2. For each, resolve outcome by matching `proposal_id` against recent
|
||||||
|
`action_taken` / `proposal_dismissed` → label `confirmed` / `dismissed` /
|
||||||
|
`unactioned`, with a relative age.
|
||||||
|
3. Render lines, e.g. `- "Walk before the call" (confirmed, 2h ago)`.
|
||||||
|
4. `buildProposePrompt` gains a rule: *don't repeat these; if a recent one was
|
||||||
|
dismissed or unactioned and still fits, follow up on it instead of inventing
|
||||||
|
new.*
|
||||||
|
|
||||||
|
If `Memory` is nil or `Recent` errors, the section is omitted — the proposal
|
||||||
|
still happens, ungrounded by history.
|
||||||
|
|
||||||
|
## Error handling / degradation
|
||||||
|
|
||||||
|
Memory is never on the critical path; it degrades exactly like the `knowledge`
|
||||||
|
and `tasks` ports already do in this mode.
|
||||||
|
|
||||||
|
- AW down at **startup** → `EnsureBucket` fails → log once, use `nopStore`.
|
||||||
|
Off-screen works, unremembering.
|
||||||
|
- AW down **mid-run** → `Record`/`Recent` error → logged, swallowed.
|
||||||
|
Propose / confirm / dismiss all still succeed.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `internal/aw` — against an `httptest` server: asserts the POST event body and
|
||||||
|
the `GET ?limit=N` round-trip; an error status surfaces as an error.
|
||||||
|
- `internal/memory` — `awStore` against `httptest` (the `_kind` mapping;
|
||||||
|
`Recent` filters by kind); `nopStore` trivial; the `fake` exercised directly.
|
||||||
|
- `internal/mode/offscreen` — using the `fake` store: (a) `proposal_made`
|
||||||
|
written on propose, (b) `action_taken` on confirm, (c) `proposal_dismissed`
|
||||||
|
on dismiss, (d) brief contains recent proposals with outcome labels, (e)
|
||||||
|
everything still works with a nil/erroring store. Existing offscreen tests get
|
||||||
|
the `fake` (or nil) injected — no behavior regressions.
|
||||||
|
|
||||||
|
## Out of scope (named to prevent scope creep)
|
||||||
|
|
||||||
|
- No `keel.state` bucket — only `keel.events`.
|
||||||
|
- No focus-mode events; no `mode_started` / `feedback_given` / `spiral_flagged`
|
||||||
|
/ `coach_line`.
|
||||||
|
- No UI rendering of history (brain-only).
|
||||||
|
- No spiral/trend detection — the *reason* for AW, but a later slice that reads
|
||||||
|
this data.
|
||||||
|
- No cross-device AW sync, no Marvin-id capture, no assembler privacy-filter
|
||||||
|
rework.
|
||||||
|
|
||||||
|
## What ships
|
||||||
|
|
||||||
|
Off-screen mode that writes its decisions to a real AW bucket and reads its own
|
||||||
|
history back into the next proposal — fresher proposals that follow up on what
|
||||||
|
was not done. The first durable-memory vertical, end to end, and the seam every
|
||||||
|
later mode reuses.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// internal/ai/ambient.go
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AmbientDriftJudge judges whether current activity serves the user's standing
|
||||||
|
// frame when NO task is declared. Like Nudge it takes primitives, not domain
|
||||||
|
// types, so ai stays a leaf. The returned string is a one-sentence advisory, or
|
||||||
|
// "" when the activity is plausibly on-track.
|
||||||
|
type AmbientDriftJudge interface {
|
||||||
|
AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrInvalidAmbientDrift marks a parseable-but-unusable ambient response.
|
||||||
|
var ErrInvalidAmbientDrift = errors.New("ai: invalid ambient drift")
|
||||||
|
|
||||||
|
// AmbientDrift makes Service satisfy AmbientDriftJudge over the same backend.
|
||||||
|
func (s *Service) AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error) {
|
||||||
|
out, err := s.backend.Run(ctx, buildAmbientPrompt(frame, recentTitles))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return parseAmbientDrift(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAmbientPrompt(frame string, recentTitles []string) string {
|
||||||
|
return `You are an ambient coach. The user has NOT declared a task. Below is their standing frame — goals, values, and life-domains — and today's tasks, then the recent sequence of window titles. Decide whether the recent activity plausibly serves ANY of what matters to them, or whether it is a slide into drift.
|
||||||
|
|
||||||
|
Be forgiving: legitimate breaks, rest, admin, and unplanned-but-useful work are ON-TRACK. Only call drift when the activity clearly serves none of what matters.
|
||||||
|
|
||||||
|
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
||||||
|
{"drifting": <true or false>, "message": "<short explanation, one sentence>"}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- drifting: true only if the recent activity serves none of the frame; false otherwise.
|
||||||
|
- message: one short sentence naming the drift. REQUIRED when drifting is true.
|
||||||
|
|
||||||
|
## Frame
|
||||||
|
` + frame + `
|
||||||
|
|
||||||
|
## Recent window titles (oldest to newest)
|
||||||
|
` + strings.Join(recentTitles, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawAmbientDrift struct {
|
||||||
|
Drifting bool `json:"drifting"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAmbientDrift extracts the advisory from raw CLI output. On-track yields
|
||||||
|
// "". A drift with no message degrades to "" (silence) rather than an error:
|
||||||
|
// the ambient signal is advisory, so the safe degenerate is to say nothing —
|
||||||
|
// exactly like parseNudge.
|
||||||
|
func parseAmbientDrift(s string) (string, error) {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return "", ErrEmptyResponse
|
||||||
|
}
|
||||||
|
if strings.IndexByte(s, '{') < 0 {
|
||||||
|
return "", ErrNoJSON
|
||||||
|
}
|
||||||
|
jsonStr, err := extractJSON(s)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%w: %v", ErrInvalidAmbientDrift, err)
|
||||||
|
}
|
||||||
|
var raw rawAmbientDrift
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
|
||||||
|
return "", fmt.Errorf("%w: %v", ErrInvalidAmbientDrift, err)
|
||||||
|
}
|
||||||
|
if !raw.Drifting {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(raw.Message), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// internal/ai/ambient_test.go
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServiceAmbientDriftSuccess(t *testing.T) {
|
||||||
|
fb := &fakeBackend{out: `{"drifting": true, "message": "deep in YouTube, none of today's goals"}`}
|
||||||
|
line, err := NewService(fb).AmbientDrift(context.Background(), "## Goals\nship keel", []string{"YouTube - Brave"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ambient drift: %v", err)
|
||||||
|
}
|
||||||
|
if line == "" {
|
||||||
|
t.Fatal("expected a non-empty drift line")
|
||||||
|
}
|
||||||
|
if !strings.Contains(fb.gotPrompt, "ship keel") {
|
||||||
|
t.Fatalf("prompt should embed the frame, got: %s", fb.gotPrompt)
|
||||||
|
}
|
||||||
|
if !strings.Contains(fb.gotPrompt, "YouTube - Brave") {
|
||||||
|
t.Fatalf("prompt should embed the recent titles, got: %s", fb.gotPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceAmbientDriftBackendError(t *testing.T) {
|
||||||
|
fb := &fakeBackend{err: errors.New("boom")}
|
||||||
|
if _, err := NewService(fb).AmbientDrift(context.Background(), "frame", []string{"x"}); err == nil {
|
||||||
|
t.Fatal("want backend error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAmbientDrift(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{"on track yields empty", `{"drifting": false, "message": ""}`, "", nil},
|
||||||
|
{"drift returns message", `{"drifting": true, "message": "watching unrelated videos"}`, "watching unrelated videos", nil},
|
||||||
|
{"drift is trimmed", `{"drifting": true, "message": " slid "}`, "slid", nil},
|
||||||
|
{"drift without message degrades to silence", `{"drifting": true, "message": ""}`, "", nil},
|
||||||
|
{"json embedded in prose", `ok: {"drifting": true, "message": "off track"} done`, "off track", nil},
|
||||||
|
{"empty response", " ", "", ErrEmptyResponse},
|
||||||
|
{"no json", "no braces here", "", ErrNoJSON},
|
||||||
|
{"malformed json", `{"drifting": true, "message":`, "", ErrInvalidAmbientDrift},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := parseAmbientDrift(tt.in)
|
||||||
|
if tt.wantErr != nil {
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Fatalf("err = %v, want %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("got %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ Respond with ONLY a JSON object, no prose and no code fences, exactly this shape
|
|||||||
Rules:
|
Rules:
|
||||||
- next_action: a single concrete off-screen action, doable now.
|
- next_action: a single concrete off-screen action, doable now.
|
||||||
- rationale: one short sentence naming the goal or value it serves.
|
- rationale: one short sentence naming the goal or value it serves.
|
||||||
|
- If the brief lists recently proposed actions, do not simply repeat them; if a recent one was dismissed or not done and still fits, follow up on it instead of inventing something new.
|
||||||
|
|
||||||
## Brief
|
## Brief
|
||||||
` + brief
|
` + brief
|
||||||
|
|||||||
@@ -41,3 +41,10 @@ func TestProposePromptIncludesBrief(t *testing.T) {
|
|||||||
t.Fatalf("prompt should embed the brief, got: %s", fb.gotPrompt)
|
t.Fatalf("prompt should embed the brief, got: %s", fb.gotPrompt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProposePromptHasFollowUpRule(t *testing.T) {
|
||||||
|
p := buildProposePrompt("brief")
|
||||||
|
if !strings.Contains(p, "do not simply repeat") {
|
||||||
|
t.Fatalf("prompt missing follow-up rule:\n%s", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
// internal/ambient/sentinel.go
|
||||||
|
|
||||||
|
// Package ambient is Keel's always-on drift coach: a Sentinel that watches the
|
||||||
|
// window stream beside the one-mode harness and, when no mode is active, judges
|
||||||
|
// recent activity against the ~/owc frame and surfaces drift. It is not a mode —
|
||||||
|
// it runs continuously, including when the harness is idle.
|
||||||
|
package ambient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/evidence"
|
||||||
|
"keel/internal/frame"
|
||||||
|
"keel/internal/knowledge"
|
||||||
|
"keel/internal/memory"
|
||||||
|
"keel/internal/notify"
|
||||||
|
"keel/internal/tasks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recentTitlesMax bounds the title ring (matches the focus mode's history cap).
|
||||||
|
const recentTitlesMax = 10
|
||||||
|
|
||||||
|
// evalTimeout bounds a single ambient brain call.
|
||||||
|
const evalTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// defaultCadence is used when the configured cadence is non-positive.
|
||||||
|
const defaultCadence = 5 * time.Minute
|
||||||
|
|
||||||
|
// kindAmbientNudge is the memory event recorded for a sustained ambient drift.
|
||||||
|
const kindAmbientNudge = "ambient_nudge"
|
||||||
|
|
||||||
|
// Ambient mode dial values (mirror settings.Ambient*).
|
||||||
|
const (
|
||||||
|
modeOff = "off"
|
||||||
|
modeStatus = "status"
|
||||||
|
modeNotify = "notify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AmbientDriftJudge is the brain call the sentinel needs. *ai.Service satisfies
|
||||||
|
// it; declaring it here keeps ambient decoupled from the ai package.
|
||||||
|
type AmbientDriftJudge interface {
|
||||||
|
AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deps are the ports the sentinel depends on. Knowledge and Tasks may be nil
|
||||||
|
// (frame.Assemble guards). Memory and Notifier may be nil (best-effort guards).
|
||||||
|
// ActiveMode reports the harness's active mode kind ("" = idle); nil counts as
|
||||||
|
// always-idle. Clock defaults to time.Now.
|
||||||
|
type Deps struct {
|
||||||
|
AI AmbientDriftJudge
|
||||||
|
Knowledge knowledge.Source
|
||||||
|
Tasks tasks.Provider
|
||||||
|
Notifier notify.Notifier
|
||||||
|
Memory memory.Store
|
||||||
|
Clock func() time.Time
|
||||||
|
ActiveMode func() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sentinel watches activity and surfaces ambient drift.
|
||||||
|
type Sentinel struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
deps Deps
|
||||||
|
cadence time.Duration
|
||||||
|
mode string
|
||||||
|
|
||||||
|
recent []string
|
||||||
|
lastWindow evidence.WindowSnapshot
|
||||||
|
line string
|
||||||
|
lastEvalTitles string
|
||||||
|
drifting bool
|
||||||
|
committed bool
|
||||||
|
snoozeUntil time.Time
|
||||||
|
onChange []func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a sentinel. A non-positive cadence falls back to defaultCadence; an
|
||||||
|
// empty mode falls back to notify.
|
||||||
|
func New(d Deps, cadence time.Duration, mode string) *Sentinel {
|
||||||
|
if d.Clock == nil {
|
||||||
|
d.Clock = time.Now
|
||||||
|
}
|
||||||
|
if cadence <= 0 {
|
||||||
|
cadence = defaultCadence
|
||||||
|
}
|
||||||
|
return &Sentinel{deps: d, cadence: cadence, mode: normalizeMode(mode)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMode(m string) string {
|
||||||
|
if m == "" {
|
||||||
|
return modeNotify
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnWindow ingests one sensor observation: it records the latest window and
|
||||||
|
// appends the title to the dedup-capped ring. Cheap and lock-bounded; it fires
|
||||||
|
// no brain call.
|
||||||
|
func (s *Sentinel) OnWindow(w evidence.WindowSnapshot) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.lastWindow = w
|
||||||
|
t := strings.TrimSpace(w.Title)
|
||||||
|
if t == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n := len(s.recent); n > 0 && s.recent[n-1] == t {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.recent = append(s.recent, t)
|
||||||
|
if len(s.recent) > recentTitlesMax {
|
||||||
|
s.recent = s.recent[len(s.recent)-recentTitlesMax:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line returns the current ambient coaching line ("" when on-track, quiet, or
|
||||||
|
// snoozed) for the surfaces.
|
||||||
|
func (s *Sentinel) Line() string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.line
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddOnChange registers a surface refresh fired when the line changes.
|
||||||
|
func (s *Sentinel) AddOnChange(f func()) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.onChange = append(s.onChange, f)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// fireOnChange notifies registered surfaces off the lock.
|
||||||
|
func (s *Sentinel) fireOnChange() {
|
||||||
|
s.mu.Lock()
|
||||||
|
fs := append([]func(){}, s.onChange...)
|
||||||
|
s.mu.Unlock()
|
||||||
|
for _, f := range fs {
|
||||||
|
if f != nil {
|
||||||
|
f()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// recentForTest returns a copy of the ring (test-only).
|
||||||
|
func (s *Sentinel) recentForTest() []string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return append([]string(nil), s.recent...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run drives the cadence loop: every cadence it evaluates, until ctx is
|
||||||
|
// cancelled. It re-reads the cadence each cycle so SetConfig takes effect on the
|
||||||
|
// next tick.
|
||||||
|
func (s *Sentinel) Run(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(s.currentCadence()):
|
||||||
|
s.evaluate(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sentinel) currentCadence() time.Duration {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.cadence
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetConfig updates the cadence and mode live (next tick).
|
||||||
|
func (s *Sentinel) SetConfig(cadence time.Duration, mode string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
if cadence > 0 {
|
||||||
|
s.cadence = cadence
|
||||||
|
}
|
||||||
|
s.mode = normalizeMode(mode)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snooze mutes the sentinel for d: it clears the current line and episode and
|
||||||
|
// suppresses evaluation until the window elapses.
|
||||||
|
func (s *Sentinel) Snooze(d time.Duration) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.snoozeUntil = s.deps.Clock().Add(d)
|
||||||
|
changed := s.line != ""
|
||||||
|
s.line = ""
|
||||||
|
s.drifting = false
|
||||||
|
s.committed = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
if changed {
|
||||||
|
s.fireOnChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluate runs one decision cycle. See the spec's "Trigger discipline."
|
||||||
|
func (s *Sentinel) evaluate(ctx context.Context) {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.mode == modeOff {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.deps.Clock().Before(s.snoozeUntil) {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.activeModeLocked() != "" {
|
||||||
|
// A mode is coaching; stay silent and clear any stale line.
|
||||||
|
changed := s.clearLocked()
|
||||||
|
s.mu.Unlock()
|
||||||
|
if changed {
|
||||||
|
s.fireOnChange()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
titles := append([]string(nil), s.recent...)
|
||||||
|
joined := strings.Join(titles, "\n")
|
||||||
|
// Cheap gate: nothing new to judge and we are on-track -> skip the brain call.
|
||||||
|
if joined == s.lastEvalTitles && s.line == "" {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
win := s.lastWindow
|
||||||
|
mode := s.mode
|
||||||
|
judge := s.deps.AI
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if judge == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, evalTimeout)
|
||||||
|
fr := frame.Assemble(cctx, s.deps.Knowledge, s.deps.Tasks)
|
||||||
|
msg, err := judge.AmbientDrift(cctx, fr, titles)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
// A mode may have activated during the (off-lock) brain call; re-check so a
|
||||||
|
// result computed while idle is not surfaced over an now-active mode.
|
||||||
|
if s.activeModeLocked() != "" {
|
||||||
|
changed := s.clearLocked()
|
||||||
|
s.mu.Unlock()
|
||||||
|
if changed {
|
||||||
|
s.fireOnChange()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.lastEvalTitles = joined
|
||||||
|
if err != nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
log.Printf("ambient: drift judge failed: %v", err)
|
||||||
|
return // never fabricate drift; leave the prior line intact
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg == "" { // on-track
|
||||||
|
changed := s.clearLocked()
|
||||||
|
s.mu.Unlock()
|
||||||
|
if changed {
|
||||||
|
s.fireOnChange()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.drifting { // new episode: show the line, no toast yet
|
||||||
|
s.line = msg
|
||||||
|
s.drifting = true
|
||||||
|
s.committed = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.fireOnChange()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sustained drift. Commit once: record memory, and toast in notify mode.
|
||||||
|
changed := s.line != msg
|
||||||
|
s.line = msg
|
||||||
|
if s.committed {
|
||||||
|
s.mu.Unlock()
|
||||||
|
if changed {
|
||||||
|
s.fireOnChange()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.committed = true
|
||||||
|
now := s.deps.Clock()
|
||||||
|
mem := s.deps.Memory
|
||||||
|
notifier := s.deps.Notifier
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if mem != nil {
|
||||||
|
ev := memory.Event{Kind: kindAmbientNudge, At: now, Data: map[string]any{
|
||||||
|
"message": msg, "title": win.Title, "class": win.Class,
|
||||||
|
}}
|
||||||
|
if e := mem.Record(ctx, ev); e != nil {
|
||||||
|
log.Printf("ambient: memory record: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode == modeNotify && notifier != nil {
|
||||||
|
if e := notifier.Notify(ctx, "Keel — drift", msg); e != nil {
|
||||||
|
log.Printf("ambient: notify: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.fireOnChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearLocked resets line + episode state and reports whether the line changed.
|
||||||
|
// Caller holds mu.
|
||||||
|
func (s *Sentinel) clearLocked() bool {
|
||||||
|
changed := s.line != ""
|
||||||
|
s.line = ""
|
||||||
|
s.drifting = false
|
||||||
|
s.committed = false
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeModeLocked reads the active-mode kind, treating a nil hook as idle.
|
||||||
|
// Caller holds mu (the hook does no locking on the sentinel).
|
||||||
|
func (s *Sentinel) activeModeLocked() string {
|
||||||
|
if s.deps.ActiveMode == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.deps.ActiveMode()
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
// internal/ambient/sentinel_test.go
|
||||||
|
package ambient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/evidence"
|
||||||
|
"keel/internal/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
func snap(title, class string) evidence.WindowSnapshot {
|
||||||
|
return evidence.WindowSnapshot{Title: title, Class: class, Health: evidence.EvidenceHealth{Available: true}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLineEmpty(t *testing.T) {
|
||||||
|
s := New(Deps{}, 0, "notify")
|
||||||
|
if s.Line() != "" {
|
||||||
|
t.Fatalf("fresh sentinel line = %q, want empty", s.Line())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnWindowRingDedupesAndCaps(t *testing.T) {
|
||||||
|
s := New(Deps{}, 0, "notify")
|
||||||
|
s.OnWindow(snap("a", "x"))
|
||||||
|
s.OnWindow(snap("a", "x")) // consecutive dup ignored
|
||||||
|
s.OnWindow(snap("b", "x"))
|
||||||
|
if got := s.recentForTest(); len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||||
|
t.Fatalf("ring = %v, want [a b]", got)
|
||||||
|
}
|
||||||
|
for i := 0; i < recentTitlesMax+5; i++ {
|
||||||
|
s.OnWindow(snap(string(rune('A'+i)), "x"))
|
||||||
|
}
|
||||||
|
if got := s.recentForTest(); len(got) != recentTitlesMax {
|
||||||
|
t.Fatalf("ring len = %d, want %d", len(got), recentTitlesMax)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnWindowSkipsEmptyTitle(t *testing.T) {
|
||||||
|
s := New(Deps{}, 0, "notify")
|
||||||
|
s.OnWindow(snap("", "x"))
|
||||||
|
if got := s.recentForTest(); len(got) != 0 {
|
||||||
|
t.Fatalf("empty title must not enter ring, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = context.Background
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
type fakeJudge struct {
|
||||||
|
out []string // queued returns, consumed front-to-back
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeJudge) AmbientDrift(_ context.Context, _ string, _ []string) (string, error) {
|
||||||
|
f.calls++
|
||||||
|
if f.err != nil {
|
||||||
|
return "", f.err
|
||||||
|
}
|
||||||
|
if len(f.out) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
r := f.out[0]
|
||||||
|
f.out = f.out[1:]
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeNotifier struct {
|
||||||
|
calls int
|
||||||
|
body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeNotifier) Notify(_ context.Context, _, body string) error {
|
||||||
|
f.calls++
|
||||||
|
f.body = body
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestSentinel(j *fakeJudge, n *fakeNotifier, mem *memoryFake, mode string, active *string) *Sentinel {
|
||||||
|
return New(Deps{
|
||||||
|
AI: j,
|
||||||
|
Notifier: n,
|
||||||
|
Memory: mem,
|
||||||
|
Clock: func() time.Time { return time.Unix(1_700_000_000, 0) },
|
||||||
|
ActiveMode: func() string { return *active },
|
||||||
|
}, time.Minute, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// memoryFake is a minimal memory.Store recorder.
|
||||||
|
type memoryFake struct {
|
||||||
|
events []memory.Event
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryFake) Record(_ context.Context, e memory.Event) error {
|
||||||
|
m.events = append(m.events, e)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *memoryFake) Recent(context.Context, string, int) ([]memory.Event, error) { return nil, nil }
|
||||||
|
|
||||||
|
// --- behavioural matrix ---
|
||||||
|
|
||||||
|
func TestEvaluateOnTrackIsSilent(t *testing.T) {
|
||||||
|
j := &fakeJudge{out: []string{""}}
|
||||||
|
n := &fakeNotifier{}
|
||||||
|
mem := &memoryFake{}
|
||||||
|
active := ""
|
||||||
|
s := newTestSentinel(j, n, mem, modeNotify, &active)
|
||||||
|
s.OnWindow(snap("main.go - code", "code"))
|
||||||
|
s.evaluate(context.Background())
|
||||||
|
if s.Line() != "" || n.calls != 0 || len(mem.events) != 0 {
|
||||||
|
t.Fatalf("on-track must be silent: line=%q toasts=%d mem=%d", s.Line(), n.calls, len(mem.events))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateSingleDriftShowsLineNoToast(t *testing.T) {
|
||||||
|
j := &fakeJudge{out: []string{"deep in YouTube"}}
|
||||||
|
n := &fakeNotifier{}
|
||||||
|
mem := &memoryFake{}
|
||||||
|
active := ""
|
||||||
|
s := newTestSentinel(j, n, mem, modeNotify, &active)
|
||||||
|
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||||
|
s.evaluate(context.Background())
|
||||||
|
if s.Line() == "" {
|
||||||
|
t.Fatal("first drift must set the status line")
|
||||||
|
}
|
||||||
|
if n.calls != 0 {
|
||||||
|
t.Fatalf("first drift must NOT toast, got %d", n.calls)
|
||||||
|
}
|
||||||
|
if len(mem.events) != 0 {
|
||||||
|
t.Fatalf("first drift must not record memory yet, got %d", len(mem.events))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateSustainedDriftTostsOnceAndRecords(t *testing.T) {
|
||||||
|
j := &fakeJudge{out: []string{"deep in YouTube", "still in YouTube", "yet more YouTube"}}
|
||||||
|
n := &fakeNotifier{}
|
||||||
|
mem := &memoryFake{}
|
||||||
|
active := ""
|
||||||
|
s := newTestSentinel(j, n, mem, modeNotify, &active)
|
||||||
|
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||||
|
|
||||||
|
s.evaluate(context.Background()) // first: line, no toast
|
||||||
|
s.evaluate(context.Background()) // second: sustained -> one toast + one memory
|
||||||
|
s.evaluate(context.Background()) // third: still drift -> NO second toast
|
||||||
|
|
||||||
|
if n.calls != 1 {
|
||||||
|
t.Fatalf("sustained drift must toast exactly once, got %d", n.calls)
|
||||||
|
}
|
||||||
|
if len(mem.events) != 1 {
|
||||||
|
t.Fatalf("sustained drift must record exactly one ambient_nudge, got %d", len(mem.events))
|
||||||
|
}
|
||||||
|
if mem.events[0].Kind != kindAmbientNudge {
|
||||||
|
t.Fatalf("memory kind = %q, want %q", mem.events[0].Kind, kindAmbientNudge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateReturnsToOnTrackClearsLine(t *testing.T) {
|
||||||
|
j := &fakeJudge{out: []string{"drift", "drift", ""}}
|
||||||
|
n := &fakeNotifier{}
|
||||||
|
active := ""
|
||||||
|
s := newTestSentinel(j, n, &memoryFake{}, modeNotify, &active)
|
||||||
|
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||||
|
s.evaluate(context.Background())
|
||||||
|
s.evaluate(context.Background())
|
||||||
|
s.evaluate(context.Background()) // on-track now
|
||||||
|
if s.Line() != "" {
|
||||||
|
t.Fatalf("return to on-track must clear the line, got %q", s.Line())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateSilentWhenModeActive(t *testing.T) {
|
||||||
|
j := &fakeJudge{out: []string{"drift"}}
|
||||||
|
active := "focus"
|
||||||
|
s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active)
|
||||||
|
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||||
|
s.evaluate(context.Background())
|
||||||
|
if j.calls != 0 {
|
||||||
|
t.Fatalf("an active mode must suppress the brain call, got %d", j.calls)
|
||||||
|
}
|
||||||
|
if s.Line() != "" {
|
||||||
|
t.Fatalf("an active mode must keep the line empty, got %q", s.Line())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateStatusModeNeverToasts(t *testing.T) {
|
||||||
|
j := &fakeJudge{out: []string{"drift", "drift"}}
|
||||||
|
n := &fakeNotifier{}
|
||||||
|
mem := &memoryFake{}
|
||||||
|
active := ""
|
||||||
|
s := newTestSentinel(j, n, mem, modeStatus, &active)
|
||||||
|
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||||
|
s.evaluate(context.Background())
|
||||||
|
s.evaluate(context.Background())
|
||||||
|
if n.calls != 0 {
|
||||||
|
t.Fatalf("status mode must never toast, got %d", n.calls)
|
||||||
|
}
|
||||||
|
if s.Line() == "" {
|
||||||
|
t.Fatal("status mode must still show the line")
|
||||||
|
}
|
||||||
|
if len(mem.events) != 1 {
|
||||||
|
t.Fatalf("status mode must still record the sustained drift, got %d", len(mem.events))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateOffModeDoesNothing(t *testing.T) {
|
||||||
|
j := &fakeJudge{out: []string{"drift"}}
|
||||||
|
active := ""
|
||||||
|
s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeOff, &active)
|
||||||
|
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||||
|
s.evaluate(context.Background())
|
||||||
|
if j.calls != 0 {
|
||||||
|
t.Fatalf("off mode must not call the brain, got %d", j.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateCheapGateSkipsUnchangedOnTrack(t *testing.T) {
|
||||||
|
j := &fakeJudge{out: []string{""}} // one on-track answer available
|
||||||
|
active := ""
|
||||||
|
s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active)
|
||||||
|
s.OnWindow(snap("main.go - code", "code"))
|
||||||
|
s.evaluate(context.Background()) // calls=1, on-track
|
||||||
|
s.evaluate(context.Background()) // unchanged ring + on-track -> skip
|
||||||
|
if j.calls != 1 {
|
||||||
|
t.Fatalf("cheap gate must skip the second call, got %d", j.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flippingJudge simulates a harness mode activating DURING the brain call: it
|
||||||
|
// returns a drift verdict but flips the active-mode flag as a side effect, so by
|
||||||
|
// the time evaluate re-acquires the lock a mode is active.
|
||||||
|
type flippingJudge struct{ active *string }
|
||||||
|
|
||||||
|
func (f *flippingJudge) AmbientDrift(_ context.Context, _ string, _ []string) (string, error) {
|
||||||
|
*f.active = "focus"
|
||||||
|
return "deep in YouTube", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mode activating mid-evaluation must suppress the surface: no line, no toast,
|
||||||
|
// no memory record (coexistence rule holds across the unlock-for-I/O window).
|
||||||
|
func TestEvaluateModeActivatingDuringJudgeStaysSilent(t *testing.T) {
|
||||||
|
active := ""
|
||||||
|
n := &fakeNotifier{}
|
||||||
|
mem := &memoryFake{}
|
||||||
|
s := New(Deps{
|
||||||
|
AI: &flippingJudge{active: &active},
|
||||||
|
Notifier: n,
|
||||||
|
Memory: mem,
|
||||||
|
Clock: func() time.Time { return time.Unix(1_700_000_000, 0) },
|
||||||
|
ActiveMode: func() string { return active },
|
||||||
|
}, time.Minute, modeNotify)
|
||||||
|
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||||
|
s.evaluate(context.Background())
|
||||||
|
if s.Line() != "" {
|
||||||
|
t.Fatalf("mode active after judge must leave line empty, got %q", s.Line())
|
||||||
|
}
|
||||||
|
if n.calls != 0 {
|
||||||
|
t.Fatalf("mode active after judge must not toast, got %d", n.calls)
|
||||||
|
}
|
||||||
|
if len(mem.events) != 0 {
|
||||||
|
t.Fatalf("mode active after judge must not record memory, got %d", len(mem.events))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnoozeMutesAndClears(t *testing.T) {
|
||||||
|
j := &fakeJudge{out: []string{"drift", "drift"}}
|
||||||
|
active := ""
|
||||||
|
s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active)
|
||||||
|
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||||
|
s.evaluate(context.Background()) // line set
|
||||||
|
s.Snooze(time.Hour)
|
||||||
|
if s.Line() != "" {
|
||||||
|
t.Fatalf("snooze must clear the line, got %q", s.Line())
|
||||||
|
}
|
||||||
|
callsBefore := j.calls
|
||||||
|
s.evaluate(context.Background()) // snoozed -> no call
|
||||||
|
if j.calls != callsBefore {
|
||||||
|
t.Fatalf("snooze must suppress evaluation, calls %d -> %d", callsBefore, j.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// Package aw is a thin ActivityWatch REST client: it creates buckets and reads
|
||||||
|
// and writes events. It holds no Keel concepts, so it stays a leaf package.
|
||||||
|
package aw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event is one ActivityWatch event. Duration is seconds; for Keel's discrete
|
||||||
|
// derived events it is 0.
|
||||||
|
type Event struct {
|
||||||
|
Timestamp time.Time
|
||||||
|
Duration float64
|
||||||
|
Data map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client talks to an aw-server over its /api/0 REST surface.
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
hc *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a client for the given base URL (e.g. http://localhost:5600).
|
||||||
|
func New(baseURL string) *Client {
|
||||||
|
return &Client{
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
hc: &http.Client{Timeout: 5 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureBucket creates the bucket if absent. It is idempotent: an already-exists
|
||||||
|
// response (304) is success.
|
||||||
|
func (c *Client) EnsureBucket(ctx context.Context, id, eventType, client string) error {
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"client": client,
|
||||||
|
"type": eventType,
|
||||||
|
"hostname": hostname(),
|
||||||
|
})
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
c.baseURL+"/api/0/buckets/"+id, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := c.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusOK, http.StatusCreated, http.StatusNoContent, http.StatusNotModified:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("aw: create bucket %s: status %d", id, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostname() string {
|
||||||
|
if h, err := os.Hostname(); err == nil && h != "" {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
return "keel"
|
||||||
|
}
|
||||||
|
|
||||||
|
type eventJSON struct {
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Duration float64 `json:"duration"`
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert posts a single event to the bucket. The AW events endpoint takes a
|
||||||
|
// JSON array, so the event is wrapped in a one-element list.
|
||||||
|
func (c *Client) Insert(ctx context.Context, bucketID string, e Event) error {
|
||||||
|
body, _ := json.Marshal([]eventJSON{{Timestamp: e.Timestamp, Duration: e.Duration, Data: e.Data}})
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
c.baseURL+"/api/0/buckets/"+bucketID+"/events", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := c.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("aw: insert into %s: status %d", bucketID, resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent returns up to limit events from the bucket, newest first (the AW
|
||||||
|
// default ordering for the events endpoint).
|
||||||
|
func (c *Client) Recent(ctx context.Context, bucketID string, limit int) ([]Event, error) {
|
||||||
|
endpoint := fmt.Sprintf("%s/api/0/buckets/%s/events?limit=%d", c.baseURL, bucketID, limit)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := c.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("aw: read %s: status %d", bucketID, resp.StatusCode)
|
||||||
|
}
|
||||||
|
var raw []eventJSON
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]Event, len(raw))
|
||||||
|
for i, e := range raw {
|
||||||
|
out[i] = Event{Timestamp: e.Timestamp, Duration: e.Duration, Data: e.Data}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package aw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnsureBucketPostsCreate(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
var gotBody map[string]string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("method = %q, want POST", r.Method)
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(b, &gotBody)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(srv.URL)
|
||||||
|
if err := c.EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil {
|
||||||
|
t.Fatalf("EnsureBucket: %v", err)
|
||||||
|
}
|
||||||
|
if gotPath != "/api/0/buckets/keel.events" {
|
||||||
|
t.Fatalf("path = %q", gotPath)
|
||||||
|
}
|
||||||
|
if gotBody["type"] != "keel.event" || gotBody["client"] != "keel" {
|
||||||
|
t.Fatalf("body = %+v", gotBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureBucketTreatsExistsAsOK(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotModified) // AW returns 304 when the bucket exists
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
if err := New(srv.URL).EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil {
|
||||||
|
t.Fatalf("existing bucket should be OK, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureBucketErrorsOnServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
if err := New(srv.URL).EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err == nil {
|
||||||
|
t.Fatal("expected error on 500")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInsertPostsEventArray(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
var gotEvents []map[string]any
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("method = %q, want POST", r.Method)
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(b, &gotEvents)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(srv.URL)
|
||||||
|
ev := Event{Timestamp: time.Unix(1000, 0).UTC(), Data: map[string]any{"_kind": "proposal_made"}}
|
||||||
|
if err := c.Insert(context.Background(), "keel.events", ev); err != nil {
|
||||||
|
t.Fatalf("Insert: %v", err)
|
||||||
|
}
|
||||||
|
if gotPath != "/api/0/buckets/keel.events/events" {
|
||||||
|
t.Fatalf("path = %q", gotPath)
|
||||||
|
}
|
||||||
|
if len(gotEvents) != 1 || gotEvents[0]["data"].(map[string]any)["_kind"] != "proposal_made" {
|
||||||
|
t.Fatalf("events = %+v", gotEvents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecentDecodesNewestFirst(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
t.Errorf("method = %q, want GET", r.Method)
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("limit"); got != "5" {
|
||||||
|
t.Errorf("limit = %q, want 5", got)
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, `[
|
||||||
|
{"id":2,"timestamp":"2026-06-05T10:00:00+00:00","duration":0,"data":{"_kind":"proposal_made"}},
|
||||||
|
{"id":1,"timestamp":"2026-06-04T10:00:00+00:00","duration":0,"data":{"_kind":"action_taken"}}
|
||||||
|
]`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
got, err := New(srv.URL).Recent(context.Background(), "keel.events", 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recent: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0].Data["_kind"] != "proposal_made" {
|
||||||
|
t.Fatalf("got = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,8 +24,9 @@ type WindowSnapshot struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Source is the activity port. Watch runs until ctx is cancelled, invoking
|
// Source is the activity port. Watch runs until ctx is cancelled, invoking
|
||||||
// onChange on every active-window change, and once immediately with the
|
// onChange once immediately with the current window and then on every change of
|
||||||
// current window.
|
// the active window or its title (a tab switch changes the title but not the
|
||||||
|
// active window).
|
||||||
type Source interface {
|
type Source interface {
|
||||||
Watch(ctx context.Context, onChange func(WindowSnapshot))
|
Watch(ctx context.Context, onChange func(WindowSnapshot))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package evidence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pollLoop samples the active window via read and forwards snapshots through
|
||||||
|
// onChange, but only when the observation changes — a different window OR a
|
||||||
|
// different title. The title case is the one that matters: switching a browser
|
||||||
|
// tab changes the window title without changing the active window, so a sensor
|
||||||
|
// that re-reads only on active-window events never sees it and credits the stale
|
||||||
|
// title. Polling re-reads on a fixed cadence, so a tab switch is caught within
|
||||||
|
// one interval.
|
||||||
|
//
|
||||||
|
// It emits once immediately, then samples every interval, until ctx is
|
||||||
|
// cancelled. WindowSnapshot is comparable, so the change check is a plain
|
||||||
|
// equality. Factoring the loop here keeps the change-dedup identical across
|
||||||
|
// sensors and lets it be tested without a display. (The Windows sensor predates
|
||||||
|
// this and keeps its own equivalent loop.)
|
||||||
|
func pollLoop(ctx context.Context, interval time.Duration, read func() WindowSnapshot, onChange func(WindowSnapshot)) {
|
||||||
|
var last WindowSnapshot
|
||||||
|
var haveLast bool
|
||||||
|
emit := func() {
|
||||||
|
s := read()
|
||||||
|
if haveLast && s == last {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
last, haveLast = s, true
|
||||||
|
onChange(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit() // immediate current window
|
||||||
|
t := time.NewTicker(interval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package evidence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPollLoopEmitsOnTitleChangeWithinSameWindow is the regression for the bug
|
||||||
|
// where the X11 sensor only re-read the window on _NET_ACTIVE_WINDOW changes and
|
||||||
|
// so missed a browser tab switch — same window class, new title — crediting all
|
||||||
|
// the time to the stale title. The poll loop must emit when only the title
|
||||||
|
// changes.
|
||||||
|
func TestPollLoopEmitsOnTitleChangeWithinSameWindow(t *testing.T) {
|
||||||
|
// Same window (class) throughout; the title changes mid-stream, like a tab
|
||||||
|
// switch. The repeated identical reads must NOT re-emit; the title change must.
|
||||||
|
titles := []string{"Keel - Brave", "Keel - Brave", "Consume - Brave", "Consume - Brave"}
|
||||||
|
var i int
|
||||||
|
read := func() WindowSnapshot {
|
||||||
|
idx := i
|
||||||
|
if idx >= len(titles) {
|
||||||
|
idx = len(titles) - 1
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
return WindowSnapshot{Class: "Brave-browser", Title: titles[idx], Health: EvidenceHealth{Available: true}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var got []WindowSnapshot
|
||||||
|
onChange := func(s WindowSnapshot) {
|
||||||
|
mu.Lock()
|
||||||
|
got = append(got, s)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { pollLoop(ctx, time.Millisecond, read, onChange); close(done) }()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
mu.Lock()
|
||||||
|
n := len(got)
|
||||||
|
mu.Unlock()
|
||||||
|
if n >= 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
cancel()
|
||||||
|
<-done
|
||||||
|
t.Fatalf("expected >=2 emits (one per distinct title), got %d", n)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
<-done
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if got[0].Title != "Keel - Brave" {
|
||||||
|
t.Fatalf("first emit title = %q, want %q", got[0].Title, "Keel - Brave")
|
||||||
|
}
|
||||||
|
if got[1].Title != "Consume - Brave" {
|
||||||
|
t.Fatalf("second emit title = %q, want %q (a title-only change must emit)", got[1].Title, "Consume - Brave")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPollLoopDedupesIdenticalObservations confirms an unchanged observation is
|
||||||
|
// not re-emitted, so redundant samples never churn the consumer.
|
||||||
|
func TestPollLoopDedupesIdenticalObservations(t *testing.T) {
|
||||||
|
read := func() WindowSnapshot {
|
||||||
|
return WindowSnapshot{Class: "code", Title: "main.go", Health: EvidenceHealth{Available: true}}
|
||||||
|
}
|
||||||
|
var mu sync.Mutex
|
||||||
|
var n int
|
||||||
|
onChange := func(WindowSnapshot) {
|
||||||
|
mu.Lock()
|
||||||
|
n++
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { pollLoop(ctx, time.Millisecond, read, onChange); close(done) }()
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
<-done
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("identical observations must emit once, got %d emits", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-37
@@ -4,24 +4,29 @@ package evidence
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jezek/xgb/xproto"
|
|
||||||
"github.com/jezek/xgbutil"
|
"github.com/jezek/xgbutil"
|
||||||
"github.com/jezek/xgbutil/ewmh"
|
"github.com/jezek/xgbutil/ewmh"
|
||||||
"github.com/jezek/xgbutil/icccm"
|
"github.com/jezek/xgbutil/icccm"
|
||||||
"github.com/jezek/xgbutil/xevent"
|
|
||||||
"github.com/jezek/xgbutil/xprop"
|
|
||||||
"github.com/jezek/xgbutil/xwindow"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// pollInterval is how often the X11 sensor samples the active window. ~1s
|
||||||
|
// latency on a switch is immaterial for a focus tracker, and polling is what
|
||||||
|
// catches a title change inside the same window — e.g. switching a browser tab,
|
||||||
|
// which changes the title but not the active window, so an _NET_ACTIVE_WINDOW
|
||||||
|
// event subscription would miss it and credit the stale title. Mirrors the
|
||||||
|
// Windows sensor's polling cadence.
|
||||||
|
const pollInterval = 750 * time.Millisecond
|
||||||
|
|
||||||
// NewSource returns the real X11 active-window sensor.
|
// NewSource returns the real X11 active-window sensor.
|
||||||
func NewSource() Source { return &x11Source{} }
|
func NewSource() Source { return &x11Source{} }
|
||||||
|
|
||||||
type x11Source struct{}
|
type x11Source struct{}
|
||||||
|
|
||||||
// Watch opens one long-lived X connection, subscribes to _NET_ACTIVE_WINDOW
|
// Watch opens one long-lived X connection and samples the active window every
|
||||||
// changes on the root window, and emits a snapshot immediately plus on every
|
// pollInterval, emitting a snapshot whenever the active window OR its title
|
||||||
// change. Any failure degrades to an Unavailable snapshot; it never panics the
|
// changes. Any failure degrades to an Unavailable snapshot; it never panics the
|
||||||
// daemon.
|
// daemon.
|
||||||
func (s *x11Source) Watch(ctx context.Context, onChange func(WindowSnapshot)) {
|
func (s *x11Source) Watch(ctx context.Context, onChange func(WindowSnapshot)) {
|
||||||
X, err := xgbutil.NewConn()
|
X, err := xgbutil.NewConn()
|
||||||
@@ -32,38 +37,11 @@ func (s *x11Source) Watch(ctx context.Context, onChange func(WindowSnapshot)) {
|
|||||||
}
|
}
|
||||||
defer X.Conn().Close()
|
defer X.Conn().Close()
|
||||||
|
|
||||||
root := X.RootWin()
|
pollLoop(ctx, pollInterval, func() WindowSnapshot { return snapshot(X) }, onChange)
|
||||||
activeAtom, err := xprop.Atm(X, "_NET_ACTIVE_WINDOW")
|
|
||||||
if err != nil {
|
|
||||||
onChange(unavailable("no _NET_ACTIVE_WINDOW atom: " + err.Error()))
|
|
||||||
<-ctx.Done()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen for property changes on the root window.
|
|
||||||
if err := xwindow.New(X, root).Listen(xproto.EventMaskPropertyChange); err != nil {
|
|
||||||
onChange(unavailable("cannot listen on root window: " + err.Error()))
|
|
||||||
<-ctx.Done()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
emit := func() { onChange(snapshot(X)) }
|
|
||||||
emit() // immediate current window
|
|
||||||
|
|
||||||
xevent.PropertyNotifyFun(func(_ *xgbutil.XUtil, ev xevent.PropertyNotifyEvent) {
|
|
||||||
if ev.Atom == activeAtom {
|
|
||||||
emit()
|
|
||||||
}
|
|
||||||
}).Connect(X, root)
|
|
||||||
|
|
||||||
// Run the event loop until ctx is cancelled.
|
|
||||||
go func() {
|
|
||||||
<-ctx.Done()
|
|
||||||
xevent.Quit(X)
|
|
||||||
}()
|
|
||||||
xevent.Main(X)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// snapshot reads the current active window's title and class. A missing active
|
||||||
|
// window (or read error) yields an Unavailable snapshot.
|
||||||
func snapshot(X *xgbutil.XUtil) WindowSnapshot {
|
func snapshot(X *xgbutil.XUtil) WindowSnapshot {
|
||||||
active, err := ewmh.ActiveWindowGet(X)
|
active, err := ewmh.ActiveWindowGet(X)
|
||||||
if err != nil || active == 0 {
|
if err != nil || active == 0 {
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// internal/frame/frame.go
|
||||||
|
|
||||||
|
// Package frame assembles the standing "what matters to Felix" brief — today's
|
||||||
|
// tasks plus the ~/owc goals and life-domain notes — that grounds a brain call.
|
||||||
|
// It is shared by the off-screen mode (which adds proposal history) and the
|
||||||
|
// ambient sentinel. Best-effort throughout: it never errors and tolerates nil
|
||||||
|
// ports and missing files.
|
||||||
|
package frame
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"keel/internal/knowledge"
|
||||||
|
"keel/internal/tasks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// goalsPath is the standing-goals note loaded for the brief.
|
||||||
|
const goalsPath = "~/owc/goals-2026.md"
|
||||||
|
|
||||||
|
// bugGlob matches the life-domain ("life-bug") notes under ~/owc/resources.
|
||||||
|
const bugGlob = "bug-*.md"
|
||||||
|
|
||||||
|
// MaxBriefBytes caps the assembled brief so the prompt stays bounded.
|
||||||
|
const MaxBriefBytes = 8 * 1024
|
||||||
|
|
||||||
|
// Assemble builds the frame brief from today's tasks, the standing goals note,
|
||||||
|
// and the life-domain notes. It never panics and never errors; nil ports and
|
||||||
|
// missing files degrade to a short, mostly-empty brief.
|
||||||
|
func Assemble(ctx context.Context, know knowledge.Source, tasksProvider tasks.Provider) string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
b.WriteString("## Today's tasks\n")
|
||||||
|
b.WriteString(briefTasks(ctx, tasksProvider))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
if goals := briefGoals(ctx, know); goals != "" {
|
||||||
|
b.WriteString("## Goals\n")
|
||||||
|
b.WriteString(goals)
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if domains := briefLifeDomains(ctx, know); domains != "" {
|
||||||
|
b.WriteString("## Life domains\n")
|
||||||
|
b.WriteString(domains)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return Truncate(b.String(), MaxBriefBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// briefTasks lists today's task titles, or "(none)" when the port is absent,
|
||||||
|
// errors, or returns nothing.
|
||||||
|
func briefTasks(ctx context.Context, tp tasks.Provider) string {
|
||||||
|
if tp == nil {
|
||||||
|
return "(none)\n"
|
||||||
|
}
|
||||||
|
list, err := tp.Today(ctx)
|
||||||
|
if err != nil || len(list) == 0 {
|
||||||
|
return "(none)\n"
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, t := range list {
|
||||||
|
title := strings.TrimSpace(t.Title)
|
||||||
|
if title == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString("- ")
|
||||||
|
b.WriteString(title)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
if b.Len() == 0 {
|
||||||
|
return "(none)\n"
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// briefGoals returns the goals note text, or "" when the port is absent or the
|
||||||
|
// file is missing/empty.
|
||||||
|
func briefGoals(ctx context.Context, know knowledge.Source) string {
|
||||||
|
if know == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
p, err := know.Load(ctx, goalsPath)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(p.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// briefLifeDomains loads every ~/owc/resources/bug-*.md note via the knowledge
|
||||||
|
// port (so reads honor its truncation) and concatenates their text. Returns ""
|
||||||
|
// when the port is absent or no notes are readable.
|
||||||
|
func briefLifeDomains(ctx context.Context, know knowledge.Source) string {
|
||||||
|
if know == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
matches, err := filepath.Glob(filepath.Join(home, "owc", "resources", bugGlob))
|
||||||
|
if err != nil || len(matches) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, path := range matches {
|
||||||
|
p, err := know.Load(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(p.Text)
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString("### ")
|
||||||
|
b.WriteString(filepath.Base(path))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(text)
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
return strings.TrimRight(b.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate clips s to at most max bytes with a marker, backing up to a rune
|
||||||
|
// boundary so it never splits a multibyte rune.
|
||||||
|
func Truncate(s string, max int) string {
|
||||||
|
if len(s) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
cut := max
|
||||||
|
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||||
|
cut--
|
||||||
|
}
|
||||||
|
return s[:cut] + "\n…(truncated)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// internal/frame/frame_test.go
|
||||||
|
package frame
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"keel/internal/knowledge"
|
||||||
|
"keel/internal/tasks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeTasks is a tasks.Provider returning a fixed list.
|
||||||
|
type fakeTasks struct{ list []tasks.Task }
|
||||||
|
|
||||||
|
// emptyKnowledge is a knowledge.Source that returns no text for any path, so
|
||||||
|
// Assemble emits neither a Goals nor a Life-domains section regardless of what
|
||||||
|
// files exist on the host.
|
||||||
|
type emptyKnowledge struct{}
|
||||||
|
|
||||||
|
func (emptyKnowledge) Load(context.Context, string) (knowledge.Profile, error) {
|
||||||
|
return knowledge.Profile{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeTasks) Today(context.Context) ([]tasks.Task, error) { return f.list, nil }
|
||||||
|
func (f fakeTasks) Create(context.Context, tasks.Task) error { return nil }
|
||||||
|
|
||||||
|
func TestAssembleListsTodaysTasks(t *testing.T) {
|
||||||
|
tp := fakeTasks{list: []tasks.Task{{Title: "write the keel spec"}}}
|
||||||
|
got := Assemble(context.Background(), nil, tp)
|
||||||
|
if !strings.Contains(got, "## Today's tasks") {
|
||||||
|
t.Fatalf("missing tasks header:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "write the keel spec") {
|
||||||
|
t.Fatalf("missing task title:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no knowledge source and no tasks, Assemble degrades to a short brief and
|
||||||
|
// never panics — both ports may be nil.
|
||||||
|
func TestAssembleToleratesNilPorts(t *testing.T) {
|
||||||
|
got := Assemble(context.Background(), nil, nil)
|
||||||
|
if !strings.Contains(got, "## Today's tasks") {
|
||||||
|
t.Fatalf("expected a tasks header even with no ports:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "(none)") {
|
||||||
|
t.Fatalf("expected (none) for absent tasks:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembleWithEmptyKnowledge(t *testing.T) {
|
||||||
|
got := Assemble(context.Background(), emptyKnowledge{}, fakeTasks{})
|
||||||
|
if strings.Contains(got, "## Goals") {
|
||||||
|
t.Fatalf("empty knowledge should not emit a Goals section:\n%s", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "## Life domains") {
|
||||||
|
t.Fatalf("empty knowledge should not emit a Life domains section:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateClipsWithMarker(t *testing.T) {
|
||||||
|
in := strings.Repeat("x", MaxBriefBytes+100)
|
||||||
|
got := Truncate(in, MaxBriefBytes)
|
||||||
|
if len(got) > MaxBriefBytes+len("\n…(truncated)") {
|
||||||
|
t.Fatalf("truncate did not clip: len=%d", len(got))
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(got, "(truncated)") {
|
||||||
|
t.Fatalf("missing truncation marker: %q", got[len(got)-20:])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,18 +6,21 @@ import (
|
|||||||
"keel/internal/ai"
|
"keel/internal/ai"
|
||||||
"keel/internal/enforce"
|
"keel/internal/enforce"
|
||||||
"keel/internal/knowledge"
|
"keel/internal/knowledge"
|
||||||
|
"keel/internal/memory"
|
||||||
"keel/internal/tasks"
|
"keel/internal/tasks"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Services is the shared infrastructure every mode receives from the harness.
|
// Services is the shared infrastructure every mode receives from the harness.
|
||||||
// Dir is the mode's namespaced persistence directory (~/.keel/modes/<kind>);
|
// Dir is the mode's namespaced persistence directory (~/.keel/modes/<kind>);
|
||||||
// modes build their own file paths under it. Notify fires the harness change
|
// modes build their own file paths under it. Notify fires the harness change
|
||||||
// listeners (web SSE + status bar).
|
// listeners (web SSE + status bar). Memory is Keel's durable cross-run event
|
||||||
|
// store (AW-backed, or a nop when AW is down).
|
||||||
type Services struct {
|
type Services struct {
|
||||||
AI *ai.Service
|
AI *ai.Service
|
||||||
Tasks tasks.Provider
|
Tasks tasks.Provider
|
||||||
Knowledge knowledge.Source
|
Knowledge knowledge.Source
|
||||||
Enforce enforce.Guard
|
Enforce enforce.Guard
|
||||||
|
Memory memory.Store
|
||||||
Clock func() time.Time
|
Clock func() time.Time
|
||||||
Dir string
|
Dir string
|
||||||
Notify func()
|
Notify func()
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Package memory is Keel's durable, cross-run memory port. Modes Record derived
|
||||||
|
// events and read them back with Recent; the AW-backed adapter persists them to
|
||||||
|
// an ActivityWatch bucket. Event-kind names are owned by the modes that write
|
||||||
|
// them — this package stays a generic event store.
|
||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/aw"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event is one derived event Keel chooses to remember. Kind is the event type
|
||||||
|
// (e.g. "proposal_made"); Data carries pointers and small values, not copies of
|
||||||
|
// other tools' source of truth.
|
||||||
|
type Event struct {
|
||||||
|
Kind string
|
||||||
|
At time.Time
|
||||||
|
Data map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store records derived events and reads recent ones back by kind.
|
||||||
|
type Store interface {
|
||||||
|
Record(ctx context.Context, e Event) error
|
||||||
|
Recent(ctx context.Context, kind string, n int) ([]Event, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nopStore is the no-memory fallback used when AW is unreachable or disabled.
|
||||||
|
type nopStore struct{}
|
||||||
|
|
||||||
|
// NewNop returns a Store that drops writes and returns no history.
|
||||||
|
func NewNop() Store { return nopStore{} }
|
||||||
|
|
||||||
|
func (nopStore) Record(context.Context, Event) error { return nil }
|
||||||
|
func (nopStore) Recent(context.Context, string, int) ([]Event, error) { return nil, nil }
|
||||||
|
|
||||||
|
// Fake is an in-memory Store for tests.
|
||||||
|
type Fake struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
events []Event
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFake returns an empty in-memory Store.
|
||||||
|
func NewFake() *Fake { return &Fake{} }
|
||||||
|
|
||||||
|
func (f *Fake) Record(_ context.Context, e Event) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.events = append(f.events, e)
|
||||||
|
f.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) Recent(_ context.Context, kind string, n int) ([]Event, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []Event
|
||||||
|
for i := len(f.events) - 1; i >= 0 && len(out) < n; i-- {
|
||||||
|
if f.events[i].Kind == kind {
|
||||||
|
out = append(out, f.events[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events returns a snapshot of everything recorded, for test assertions.
|
||||||
|
func (f *Fake) Events() []Event {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return append([]Event(nil), f.events...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// overfetch bounds how many recent bucket events awStore pulls before filtering
|
||||||
|
// by kind, because keel.events interleaves all kinds. Events older than this
|
||||||
|
// window are not seen — acceptable for "recent".
|
||||||
|
const overfetch = 200
|
||||||
|
|
||||||
|
// kindKey is the data field carrying the Event.Kind inside an AW event.
|
||||||
|
const kindKey = "_kind"
|
||||||
|
|
||||||
|
// AWClient is the subset of *aw.Client that awStore needs. Declaring it here
|
||||||
|
// lets tests inject a fake without HTTP; *aw.Client satisfies it structurally.
|
||||||
|
type AWClient interface {
|
||||||
|
Insert(ctx context.Context, bucketID string, e aw.Event) error
|
||||||
|
Recent(ctx context.Context, bucketID string, limit int) ([]aw.Event, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type awStore struct {
|
||||||
|
c AWClient
|
||||||
|
bucketID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAWStore returns a Store that persists events to the given AW bucket.
|
||||||
|
func NewAWStore(c AWClient, bucketID string) Store {
|
||||||
|
return &awStore{c: c, bucketID: bucketID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *awStore) Record(ctx context.Context, e Event) error {
|
||||||
|
data := make(map[string]any, len(e.Data)+1)
|
||||||
|
for k, v := range e.Data {
|
||||||
|
data[k] = v
|
||||||
|
}
|
||||||
|
data[kindKey] = e.Kind
|
||||||
|
return s.c.Insert(ctx, s.bucketID, aw.Event{Timestamp: e.At, Duration: 0, Data: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *awStore) Recent(ctx context.Context, kind string, n int) ([]Event, error) {
|
||||||
|
if n <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
raw, err := s.c.Recent(ctx, s.bucketID, overfetch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []Event
|
||||||
|
for _, ev := range raw {
|
||||||
|
if len(out) >= n {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if k, _ := ev.Data[kindKey].(string); k == kind {
|
||||||
|
data := make(map[string]any, len(ev.Data))
|
||||||
|
for dk, dv := range ev.Data {
|
||||||
|
if dk != kindKey {
|
||||||
|
data[dk] = dv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, Event{Kind: kind, At: ev.Timestamp, Data: data})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/aw"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFakeRecordThenRecentNewestFirstByKind(t *testing.T) {
|
||||||
|
f := NewFake()
|
||||||
|
ctx := context.Background()
|
||||||
|
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(1, 0), Data: map[string]any{"n": "a"}})
|
||||||
|
_ = f.Record(ctx, Event{Kind: "action_taken", At: time.Unix(2, 0)})
|
||||||
|
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(3, 0), Data: map[string]any{"n": "b"}})
|
||||||
|
|
||||||
|
got, err := f.Recent(ctx, "proposal_made", 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recent: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0].Data["n"] != "b" {
|
||||||
|
t.Fatalf("want newest-first [b,a], got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFakeRecentRespectsLimit(t *testing.T) {
|
||||||
|
f := NewFake()
|
||||||
|
ctx := context.Background()
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(int64(i), 0)})
|
||||||
|
}
|
||||||
|
got, _ := f.Recent(ctx, "proposal_made", 2)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("limit not honored: %d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNopStoreIsInert(t *testing.T) {
|
||||||
|
s := NewNop()
|
||||||
|
if err := s.Record(context.Background(), Event{Kind: "x"}); err != nil {
|
||||||
|
t.Fatalf("nop Record: %v", err)
|
||||||
|
}
|
||||||
|
got, err := s.Recent(context.Background(), "x", 5)
|
||||||
|
if err != nil || len(got) != 0 {
|
||||||
|
t.Fatalf("nop Recent = %+v, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeAWClient is the AWClient subset, recording inserts and replaying a fixed
|
||||||
|
// recent list so we can test mapping and over-fetch filtering without HTTP.
|
||||||
|
type fakeAWClient struct {
|
||||||
|
inserted []aw.Event
|
||||||
|
recent []aw.Event // newest-first, mixed kinds
|
||||||
|
lastLimit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAWClient) Insert(_ context.Context, _ string, e aw.Event) error {
|
||||||
|
f.inserted = append(f.inserted, e)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeAWClient) Recent(_ context.Context, _ string, limit int) ([]aw.Event, error) {
|
||||||
|
f.lastLimit = limit
|
||||||
|
return f.recent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWStoreRecordMapsKindIntoData(t *testing.T) {
|
||||||
|
fc := &fakeAWClient{}
|
||||||
|
s := NewAWStore(fc, "keel.events")
|
||||||
|
at := time.Unix(1000, 0).UTC()
|
||||||
|
if err := s.Record(context.Background(), Event{Kind: "proposal_made", At: at, Data: map[string]any{"proposal_id": "x"}}); err != nil {
|
||||||
|
t.Fatalf("Record: %v", err)
|
||||||
|
}
|
||||||
|
if len(fc.inserted) != 1 {
|
||||||
|
t.Fatalf("inserted %d", len(fc.inserted))
|
||||||
|
}
|
||||||
|
got := fc.inserted[0]
|
||||||
|
if !got.Timestamp.Equal(at) || got.Data["_kind"] != "proposal_made" || got.Data["proposal_id"] != "x" {
|
||||||
|
t.Fatalf("mapped event = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWStoreRecentOverfetchesAndFiltersByKind(t *testing.T) {
|
||||||
|
fc := &fakeAWClient{recent: []aw.Event{
|
||||||
|
{Timestamp: time.Unix(3, 0), Data: map[string]any{"_kind": "proposal_made", "n": "b"}},
|
||||||
|
{Timestamp: time.Unix(2, 0), Data: map[string]any{"_kind": "action_taken"}},
|
||||||
|
{Timestamp: time.Unix(1, 0), Data: map[string]any{"_kind": "proposal_made", "n": "a"}},
|
||||||
|
}}
|
||||||
|
s := NewAWStore(fc, "keel.events")
|
||||||
|
got, err := s.Recent(context.Background(), "proposal_made", 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recent: %v", err)
|
||||||
|
}
|
||||||
|
if fc.lastLimit != overfetch {
|
||||||
|
t.Fatalf("over-fetch limit = %d, want %d", fc.lastLimit, overfetch)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0].Kind != "proposal_made" || got[0].Data["n"] != "b" {
|
||||||
|
t.Fatalf("filtered = %+v", got)
|
||||||
|
}
|
||||||
|
if _, leaked := got[0].Data["_kind"]; leaked {
|
||||||
|
t.Fatalf("_kind leaked into returned Data: %+v", got[0].Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWStoreRecentCapsAtN(t *testing.T) {
|
||||||
|
var recent []aw.Event
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
recent = append(recent, aw.Event{Timestamp: time.Unix(int64(i), 0), Data: map[string]any{"_kind": "proposal_made"}})
|
||||||
|
}
|
||||||
|
s := NewAWStore(&fakeAWClient{recent: recent}, "keel.events")
|
||||||
|
got, _ := s.Recent(context.Background(), "proposal_made", 2)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("cap not applied: %d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,131 +1,104 @@
|
|||||||
|
// internal/mode/offscreen/collect.go
|
||||||
package offscreen
|
package offscreen
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"fmt"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/frame"
|
||||||
)
|
)
|
||||||
|
|
||||||
// goalsPath is the standing-goals note loaded for the off-screen brief.
|
const (
|
||||||
const goalsPath = "~/owc/goals-2026.md"
|
recentProposals = 5 // how many recent proposals to show
|
||||||
|
outcomeScan = 50 // how many outcome events to scan for correlation
|
||||||
|
recentWindow = 7 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
// maxBriefBytes caps the assembled brief so the propose prompt stays bounded.
|
// assembleBrief builds the off-screen prompt context: the shared frame brief
|
||||||
const maxBriefBytes = 8 * 1024
|
// (today's tasks + ~/owc goals and life-domains) plus off-screen's own recent
|
||||||
|
// proposal history. Best-effort: never panics, never errors, tolerates nil
|
||||||
// bugGlob matches the life-domain ("life-bug") notes under ~/owc/resources.
|
// ports and missing files.
|
||||||
const bugGlob = "bug-*.md"
|
|
||||||
|
|
||||||
// assembleBrief builds the off-screen prompt context from today's tasks, the
|
|
||||||
// standing goals note, and the life-domain notes. It is best-effort: it never
|
|
||||||
// panics and never returns an error, and it tolerates nil ports and missing
|
|
||||||
// files (degrading to a short, mostly-empty brief).
|
|
||||||
func (m *Mode) assembleBrief() string {
|
func (m *Mode) assembleBrief() string {
|
||||||
var b strings.Builder
|
base := frame.Assemble(context.Background(), m.know, m.tasks)
|
||||||
|
|
||||||
b.WriteString("## Today's tasks\n")
|
hist := m.briefHistory()
|
||||||
b.WriteString(m.briefTasks())
|
if hist == "" {
|
||||||
b.WriteString("\n")
|
return base
|
||||||
|
|
||||||
if goals := m.briefGoals(); goals != "" {
|
|
||||||
b.WriteString("## Goals\n")
|
|
||||||
b.WriteString(goals)
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if domains := m.briefLifeDomains(); domains != "" {
|
var b strings.Builder
|
||||||
b.WriteString("## Life domains\n")
|
b.WriteString(base)
|
||||||
b.WriteString(domains)
|
if !strings.HasSuffix(base, "\n") {
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
b.WriteString("\n## Recently proposed\n")
|
||||||
return truncateBrief(b.String(), maxBriefBytes)
|
b.WriteString(hist)
|
||||||
|
return frame.Truncate(b.String(), frame.MaxBriefBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// briefTasks lists today's task titles, or "(none)" when the port is absent,
|
// briefHistory renders recent proposals with their outcome, newest first and
|
||||||
// errors, or returns nothing.
|
// within recentWindow. Returns "" when there is no memory or no recent history.
|
||||||
func (m *Mode) briefTasks() string {
|
func (m *Mode) briefHistory() string {
|
||||||
if m.tasks == nil {
|
if m.mem == nil {
|
||||||
return "(none)\n"
|
return ""
|
||||||
}
|
}
|
||||||
list, err := m.tasks.Today(context.Background())
|
ctx := context.Background()
|
||||||
if err != nil || len(list) == 0 {
|
proposals, err := m.mem.Recent(ctx, kindProposalMade, recentProposals)
|
||||||
return "(none)\n"
|
if err != nil || len(proposals) == 0 {
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
taken := m.idSet(ctx, kindActionTaken)
|
||||||
|
dismissed := m.idSet(ctx, kindProposalDismissed)
|
||||||
|
now := m.now()
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for _, t := range list {
|
for _, ev := range proposals {
|
||||||
title := strings.TrimSpace(t.Title)
|
if now.Sub(ev.At) > recentWindow {
|
||||||
if title == "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
b.WriteString("- ")
|
id, _ := ev.Data["proposal_id"].(string)
|
||||||
b.WriteString(title)
|
action, _ := ev.Data["next_action"].(string)
|
||||||
b.WriteString("\n")
|
if action == "" {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if b.Len() == 0 {
|
outcome := "unactioned"
|
||||||
return "(none)\n"
|
if taken[id] {
|
||||||
|
outcome = "confirmed"
|
||||||
|
} else if dismissed[id] {
|
||||||
|
outcome = "dismissed"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "- %q (%s, %s)\n", action, outcome, relativeAge(now.Sub(ev.At)))
|
||||||
}
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// briefGoals returns the goals note text, or "" when the port is absent or the
|
// idSet reads recent events of a kind and returns their proposal_ids as a set.
|
||||||
// file is missing/empty.
|
func (m *Mode) idSet(ctx context.Context, kind string) map[string]bool {
|
||||||
func (m *Mode) briefGoals() string {
|
out := map[string]bool{}
|
||||||
if m.know == nil {
|
evs, err := m.mem.Recent(ctx, kind, outcomeScan)
|
||||||
return ""
|
|
||||||
}
|
|
||||||
p, err := m.know.Load(context.Background(), goalsPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return out
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(p.Text)
|
for _, e := range evs {
|
||||||
|
if id, _ := e.Data["proposal_id"].(string); id != "" {
|
||||||
|
out[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// briefLifeDomains loads every ~/owc/resources/bug-*.md note via the knowledge
|
// relativeAge renders a coarse human age for a duration.
|
||||||
// port (so reads honor its truncation) and concatenates their text under one
|
func relativeAge(d time.Duration) string {
|
||||||
// section. Returns "" when the port is absent or no notes are readable. File
|
switch {
|
||||||
// access stays behind the port; when the port is nil it is skipped entirely.
|
case d < time.Minute:
|
||||||
func (m *Mode) briefLifeDomains() string {
|
return "just now"
|
||||||
if m.know == nil {
|
case d < time.Hour:
|
||||||
return ""
|
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||||
|
case d < 24*time.Hour:
|
||||||
|
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
||||||
}
|
}
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
matches, err := filepath.Glob(filepath.Join(home, "owc", "resources", bugGlob))
|
|
||||||
if err != nil || len(matches) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var b strings.Builder
|
|
||||||
for _, path := range matches {
|
|
||||||
p, err := m.know.Load(context.Background(), path)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
text := strings.TrimSpace(p.Text)
|
|
||||||
if text == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
b.WriteString("### ")
|
|
||||||
b.WriteString(filepath.Base(path))
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(text)
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
}
|
|
||||||
return strings.TrimRight(b.String(), "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// truncateBrief clips s to at most max bytes with a marker, keeping the prompt
|
|
||||||
// bounded. It backs up to a rune boundary so it never splits a multibyte rune.
|
|
||||||
func truncateBrief(s string, max int) string {
|
|
||||||
if len(s) <= max {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
cut := max
|
|
||||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
|
||||||
cut--
|
|
||||||
}
|
|
||||||
return s[:cut] + "\n…(truncated)"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,19 @@ package offscreen
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"keel/internal/ai"
|
"keel/internal/ai"
|
||||||
"keel/internal/harness"
|
"keel/internal/harness"
|
||||||
"keel/internal/knowledge"
|
"keel/internal/knowledge"
|
||||||
|
"keel/internal/memory"
|
||||||
"keel/internal/mode"
|
"keel/internal/mode"
|
||||||
"keel/internal/tasks"
|
"keel/internal/tasks"
|
||||||
)
|
)
|
||||||
@@ -28,6 +32,12 @@ const (
|
|||||||
statusDone = "done"
|
statusDone = "done"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
kindProposalMade = "proposal_made"
|
||||||
|
kindActionTaken = "action_taken"
|
||||||
|
kindProposalDismissed = "proposal_dismissed"
|
||||||
|
)
|
||||||
|
|
||||||
// Mode is the one-shot off-screen advisor: collect → propose → confirm/dismiss.
|
// Mode is the one-shot off-screen advisor: collect → propose → confirm/dismiss.
|
||||||
type Mode struct {
|
type Mode struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -35,12 +45,15 @@ type Mode struct {
|
|||||||
ai *ai.Service
|
ai *ai.Service
|
||||||
tasks tasks.Provider
|
tasks tasks.Provider
|
||||||
know knowledge.Source // may be nil; assembleBrief must guard
|
know knowledge.Source // may be nil; assembleBrief must guard
|
||||||
|
mem memory.Store // may be nil; record helpers guard
|
||||||
|
now func() time.Time // event timestamps; never nil after New
|
||||||
|
|
||||||
status string
|
status string
|
||||||
gen int
|
gen int
|
||||||
proposal *ai.OffscreenProposal
|
proposal *ai.OffscreenProposal
|
||||||
errMsg string
|
errMsg string
|
||||||
done bool
|
done bool
|
||||||
|
proposalID string
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ mode.Mode = (*Mode)(nil)
|
var _ mode.Mode = (*Mode)(nil)
|
||||||
@@ -48,10 +61,16 @@ var _ mode.Mode = (*Mode)(nil)
|
|||||||
// New builds an off-screen mode from the shared services. It does not start the
|
// New builds an off-screen mode from the shared services. It does not start the
|
||||||
// brain; call Command(ctx, "start", nil) (or use Factory) to kick off a brief.
|
// brain; call Command(ctx, "start", nil) (or use Factory) to kick off a brief.
|
||||||
func New(svc harness.Services) *Mode {
|
func New(svc harness.Services) *Mode {
|
||||||
|
now := svc.Clock
|
||||||
|
if now == nil {
|
||||||
|
now = time.Now
|
||||||
|
}
|
||||||
m := &Mode{
|
m := &Mode{
|
||||||
ai: svc.AI,
|
ai: svc.AI,
|
||||||
tasks: svc.Tasks,
|
tasks: svc.Tasks,
|
||||||
know: svc.Knowledge,
|
know: svc.Knowledge,
|
||||||
|
mem: svc.Memory,
|
||||||
|
now: now,
|
||||||
status: statusPending,
|
status: statusPending,
|
||||||
}
|
}
|
||||||
m.async = harness.NewAsync(&m.mu, svc.Notify)
|
m.async = harness.NewAsync(&m.mu, svc.Notify)
|
||||||
@@ -78,8 +97,14 @@ func (m *Mode) Command(ctx context.Context, name string, _ json.RawMessage) erro
|
|||||||
return m.confirm(ctx)
|
return m.confirm(ctx)
|
||||||
case "dismiss":
|
case "dismiss":
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
id := m.proposalID
|
||||||
m.done = true
|
m.done = true
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
m.recordSync(memory.Event{
|
||||||
|
Kind: kindProposalDismissed,
|
||||||
|
At: m.now(),
|
||||||
|
Data: map[string]any{"proposal_id": id, "mode": "offscreen"},
|
||||||
|
})
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("offscreen: unknown command %q", name)
|
return fmt.Errorf("offscreen: unknown command %q", name)
|
||||||
@@ -112,6 +137,18 @@ func (m *Mode) start() error {
|
|||||||
}
|
}
|
||||||
m.status = statusProposed
|
m.status = statusProposed
|
||||||
m.proposal = &p
|
m.proposal = &p
|
||||||
|
id := newProposalID()
|
||||||
|
m.proposalID = id
|
||||||
|
m.recordAsync(memory.Event{
|
||||||
|
Kind: kindProposalMade,
|
||||||
|
At: m.now(),
|
||||||
|
Data: map[string]any{
|
||||||
|
"proposal_id": id,
|
||||||
|
"mode": "offscreen",
|
||||||
|
"next_action": p.NextAction,
|
||||||
|
"rationale": p.Rationale,
|
||||||
|
},
|
||||||
|
})
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -129,9 +166,15 @@ func (m *Mode) confirm(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
id := m.proposalID
|
||||||
m.status = statusDone
|
m.status = statusDone
|
||||||
m.done = true
|
m.done = true
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
m.recordSync(memory.Event{
|
||||||
|
Kind: kindActionTaken,
|
||||||
|
At: m.now(),
|
||||||
|
Data: map[string]any{"proposal_id": id, "mode": "offscreen", "next_action": p.NextAction},
|
||||||
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,3 +199,37 @@ func Factory(svc harness.Services) mode.Mode {
|
|||||||
_ = m.start()
|
_ = m.start()
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordAsync persists an event off the mode's lock, best-effort. Used on the
|
||||||
|
// propose path, which runs under the mode mutex (async apply).
|
||||||
|
func (m *Mode) recordAsync(ev memory.Event) {
|
||||||
|
if m.mem == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
if err := m.mem.Record(context.Background(), ev); err != nil {
|
||||||
|
log.Printf("offscreen: memory record %s: %v", ev.Kind, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordSync persists an event inline, best-effort. Used on confirm/dismiss,
|
||||||
|
// which already do synchronous work.
|
||||||
|
func (m *Mode) recordSync(ev memory.Event) {
|
||||||
|
if m.mem == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := m.mem.Record(context.Background(), ev); err != nil {
|
||||||
|
log.Printf("offscreen: memory record %s: %v", ev.Kind, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newProposalID returns a short random hex id correlating a proposal with its
|
||||||
|
// outcome event.
|
||||||
|
func newProposalID() string {
|
||||||
|
var b [8]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "proposal"
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package offscreen
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"keel/internal/ai"
|
"keel/internal/ai"
|
||||||
"keel/internal/harness"
|
"keel/internal/harness"
|
||||||
|
"keel/internal/memory"
|
||||||
"keel/internal/tasks"
|
"keel/internal/tasks"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -138,3 +140,109 @@ func TestUnknownCommandErrors(t *testing.T) {
|
|||||||
t.Fatal("unknown command should error")
|
t.Fatal("unknown command should error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newTestModeMem is newTestMode with an injected in-memory Store.
|
||||||
|
func newTestModeMem(t *testing.T, ft *fakeTasks, backend ai.Backend, mem *memory.Fake) *Mode {
|
||||||
|
t.Helper()
|
||||||
|
svc := harness.Services{
|
||||||
|
AI: ai.NewService(backend),
|
||||||
|
Tasks: ft,
|
||||||
|
Memory: mem,
|
||||||
|
Clock: func() time.Time { return time.Unix(1000, 0) },
|
||||||
|
Notify: func() {},
|
||||||
|
}
|
||||||
|
return New(svc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitEvent polls the fake until an event of kind appears, or fails after 2s.
|
||||||
|
func waitEvent(t *testing.T, f *memory.Fake, kind string) memory.Event {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
for _, e := range f.Events() {
|
||||||
|
if e.Kind == kind {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("event %q never recorded (events: %+v)", kind, f.Events())
|
||||||
|
return memory.Event{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProposeRecordsProposalMade(t *testing.T) {
|
||||||
|
mem := memory.NewFake()
|
||||||
|
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
|
||||||
|
if err := m.Command(context.Background(), "start", nil); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
waitStatus(t, m, statusProposed)
|
||||||
|
|
||||||
|
ev := waitEvent(t, mem, "proposal_made")
|
||||||
|
if ev.Data["next_action"] != "call mum" || ev.Data["proposal_id"] == "" {
|
||||||
|
t.Fatalf("proposal_made data = %+v", ev.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmRecordsActionTakenWithSameID(t *testing.T) {
|
||||||
|
mem := memory.NewFake()
|
||||||
|
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
|
||||||
|
_ = m.Command(context.Background(), "start", nil)
|
||||||
|
waitStatus(t, m, statusProposed)
|
||||||
|
made := waitEvent(t, mem, "proposal_made")
|
||||||
|
|
||||||
|
if err := m.Command(context.Background(), "confirm", nil); err != nil {
|
||||||
|
t.Fatalf("confirm: %v", err)
|
||||||
|
}
|
||||||
|
taken := waitEvent(t, mem, "action_taken")
|
||||||
|
if taken.Data["proposal_id"] != made.Data["proposal_id"] {
|
||||||
|
t.Fatalf("action_taken id %v != proposal_made id %v", taken.Data["proposal_id"], made.Data["proposal_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDismissRecordsProposalDismissed(t *testing.T) {
|
||||||
|
mem := memory.NewFake()
|
||||||
|
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
|
||||||
|
_ = m.Command(context.Background(), "start", nil)
|
||||||
|
waitStatus(t, m, statusProposed)
|
||||||
|
_ = waitEvent(t, mem, "proposal_made")
|
||||||
|
|
||||||
|
if err := m.Command(context.Background(), "dismiss", nil); err != nil {
|
||||||
|
t.Fatalf("dismiss: %v", err)
|
||||||
|
}
|
||||||
|
_ = waitEvent(t, mem, "proposal_dismissed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBriefIncludesRecentProposalsWithOutcome(t *testing.T) {
|
||||||
|
mem := memory.NewFake()
|
||||||
|
ctx := context.Background()
|
||||||
|
// at = the test clock (Unix 1000); confirmed walk, dismissed stretch.
|
||||||
|
_ = mem.Record(ctx, memory.Event{Kind: "proposal_made", At: time.Unix(1000, 0),
|
||||||
|
Data: map[string]any{"proposal_id": "w", "next_action": "walk"}})
|
||||||
|
_ = mem.Record(ctx, memory.Event{Kind: "action_taken", At: time.Unix(1000, 0),
|
||||||
|
Data: map[string]any{"proposal_id": "w"}})
|
||||||
|
_ = mem.Record(ctx, memory.Event{Kind: "proposal_made", At: time.Unix(1000, 0),
|
||||||
|
Data: map[string]any{"proposal_id": "s", "next_action": "stretch"}})
|
||||||
|
_ = mem.Record(ctx, memory.Event{Kind: "proposal_dismissed", At: time.Unix(1000, 0),
|
||||||
|
Data: map[string]any{"proposal_id": "s"}})
|
||||||
|
|
||||||
|
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
|
||||||
|
brief := m.assembleBrief()
|
||||||
|
|
||||||
|
if !strings.Contains(brief, "Recently proposed") {
|
||||||
|
t.Fatalf("brief missing history section:\n%s", brief)
|
||||||
|
}
|
||||||
|
if !strings.Contains(brief, "walk") || !strings.Contains(brief, "confirmed") {
|
||||||
|
t.Fatalf("brief missing confirmed walk:\n%s", brief)
|
||||||
|
}
|
||||||
|
if !strings.Contains(brief, "stretch") || !strings.Contains(brief, "dismissed") {
|
||||||
|
t.Fatalf("brief missing dismissed stretch:\n%s", brief)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBriefOmitsHistoryWhenNoMemory(t *testing.T) {
|
||||||
|
m := newTestMode(t, &fakeTasks{}, okBackend()) // no Memory injected
|
||||||
|
if strings.Contains(m.assembleBrief(), "Recently proposed") {
|
||||||
|
t.Fatal("history section should be absent without memory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// internal/notify/notify.go
|
||||||
|
|
||||||
|
// Package notify is Keel's desktop-notification effector: a best-effort
|
||||||
|
// "interrupt the user" primitive behind a Notifier port. On Linux with
|
||||||
|
// notify-send installed it pops a toast; everywhere else it is a silent nop.
|
||||||
|
// Mirrors internal/enforce: a pure OS primitive, all policy lives in callers.
|
||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notifier shows a desktop notification. Best-effort: it returns an error for
|
||||||
|
// diagnostics, but callers never block on it and treat failure as "no toast."
|
||||||
|
type Notifier interface {
|
||||||
|
Notify(ctx context.Context, title, body string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNotifier returns the notify-send adapter when that binary is on PATH, else
|
||||||
|
// a nop. This makes a missing binary (or a non-Linux host) degrade to silence
|
||||||
|
// rather than an error.
|
||||||
|
func NewNotifier() Notifier {
|
||||||
|
if path, err := exec.LookPath("notify-send"); err == nil {
|
||||||
|
return sendNotifier{bin: path}
|
||||||
|
}
|
||||||
|
return nopNotifier{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendNotifier shells out to notify-send. The app name groups Keel's toasts.
|
||||||
|
type sendNotifier struct{ bin string }
|
||||||
|
|
||||||
|
func (s sendNotifier) Notify(ctx context.Context, title, body string) error {
|
||||||
|
cmd := exec.CommandContext(ctx, s.bin, "--app-name=Keel", title, body)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("notify: notify-send: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// nopNotifier is the silent fallback.
|
||||||
|
type nopNotifier struct{}
|
||||||
|
|
||||||
|
func (nopNotifier) Notify(context.Context, string, string) error { return nil }
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// internal/notify/notify_test.go
|
||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// nopNotifier must be returned when notify-send is absent, and must never error.
|
||||||
|
func TestNopNotifierIsSilentAndSafe(t *testing.T) {
|
||||||
|
n := nopNotifier{}
|
||||||
|
if err := n.Notify(context.Background(), "title", "body"); err != nil {
|
||||||
|
t.Fatalf("nop Notify must return nil, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNotifier always returns a usable Notifier (never nil), whatever the host.
|
||||||
|
func TestNewNotifierNeverNil(t *testing.T) {
|
||||||
|
if NewNotifier() == nil {
|
||||||
|
t.Fatal("NewNotifier returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrInvalidBackend marks a settings value whose ai_backend is not a known
|
// ErrInvalidBackend marks a settings value whose ai_backend is not a known
|
||||||
@@ -16,12 +17,37 @@ import (
|
|||||||
// without importing the ai package.
|
// without importing the ai package.
|
||||||
var ErrInvalidBackend = errors.New("settings: invalid ai backend")
|
var ErrInvalidBackend = errors.New("settings: invalid ai backend")
|
||||||
|
|
||||||
|
// ErrInvalidAmbientMode marks a settings value whose ambient_mode is not a
|
||||||
|
// known mode. The applier returns it (wrapped) so the HTTP layer can map it to
|
||||||
|
// 400 without importing the ambient package.
|
||||||
|
var ErrInvalidAmbientMode = errors.New("settings: invalid ambient mode")
|
||||||
|
|
||||||
|
// Ambient mode dial values.
|
||||||
|
const (
|
||||||
|
AmbientOff = "off"
|
||||||
|
AmbientStatus = "status"
|
||||||
|
AmbientNotify = "notify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidAmbientMode reports whether m is a known ambient mode constant.
|
||||||
|
func ValidAmbientMode(m string) bool {
|
||||||
|
switch m {
|
||||||
|
case AmbientOff, AmbientStatus, AmbientNotify:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Settings is the user-editable configuration. Field names mirror the env vars
|
// Settings is the user-editable configuration. Field names mirror the env vars
|
||||||
// they replace: KEEL_AI_BACKEND, KEEL_MARVIN_CMD, KEEL_KNOWLEDGE_FILE.
|
// they replace: KEEL_AI_BACKEND, KEEL_MARVIN_CMD, KEEL_KNOWLEDGE_FILE, KEEL_AW_URL,
|
||||||
|
// KEEL_AMBIENT_MODE, KEEL_AMBIENT_CADENCE_SECS.
|
||||||
type Settings struct {
|
type Settings struct {
|
||||||
AIBackend string `json:"ai_backend"`
|
AIBackend string `json:"ai_backend"`
|
||||||
MarvinCmd string `json:"marvin_cmd"`
|
MarvinCmd string `json:"marvin_cmd"`
|
||||||
KnowledgePath string `json:"knowledge_path"`
|
KnowledgePath string `json:"knowledge_path"`
|
||||||
|
AWURL string `json:"aw_url"`
|
||||||
|
AmbientMode string `json:"ambient_mode"`
|
||||||
|
AmbientCadenceSecs int `json:"ambient_cadence_secs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultPath returns ~/.keel/settings.json.
|
// DefaultPath returns ~/.keel/settings.json.
|
||||||
@@ -72,9 +98,26 @@ func SeedFromEnv() Settings {
|
|||||||
if backend == "" {
|
if backend == "" {
|
||||||
backend = "claude"
|
backend = "claude"
|
||||||
}
|
}
|
||||||
|
awURL := os.Getenv("KEEL_AW_URL")
|
||||||
|
if awURL == "" {
|
||||||
|
awURL = "http://localhost:5600"
|
||||||
|
}
|
||||||
|
ambientMode := os.Getenv("KEEL_AMBIENT_MODE")
|
||||||
|
if ambientMode == "" {
|
||||||
|
ambientMode = AmbientNotify
|
||||||
|
}
|
||||||
|
ambientCadenceSecs := 300
|
||||||
|
if v := os.Getenv("KEEL_AMBIENT_CADENCE_SECS"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
ambientCadenceSecs = n
|
||||||
|
}
|
||||||
|
}
|
||||||
return Settings{
|
return Settings{
|
||||||
AIBackend: backend,
|
AIBackend: backend,
|
||||||
MarvinCmd: os.Getenv("KEEL_MARVIN_CMD"),
|
MarvinCmd: os.Getenv("KEEL_MARVIN_CMD"),
|
||||||
KnowledgePath: os.Getenv("KEEL_KNOWLEDGE_FILE"),
|
KnowledgePath: os.Getenv("KEEL_KNOWLEDGE_FILE"),
|
||||||
|
AWURL: awURL,
|
||||||
|
AmbientMode: ambientMode,
|
||||||
|
AmbientCadenceSecs: ambientCadenceSecs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
func TestSaveLoadRoundTrip(t *testing.T) {
|
func TestSaveLoadRoundTrip(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "settings.json")
|
path := filepath.Join(t.TempDir(), "settings.json")
|
||||||
want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md"}
|
want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md", AWURL: "http://aw.local:5600"}
|
||||||
if err := Save(path, want); err != nil {
|
if err := Save(path, want); err != nil {
|
||||||
t.Fatalf("save: %v", err)
|
t.Fatalf("save: %v", err)
|
||||||
}
|
}
|
||||||
@@ -32,8 +32,11 @@ func TestSeedFromEnvReadsVars(t *testing.T) {
|
|||||||
t.Setenv("KEEL_AI_BACKEND", "codex")
|
t.Setenv("KEEL_AI_BACKEND", "codex")
|
||||||
t.Setenv("KEEL_MARVIN_CMD", "uv run am")
|
t.Setenv("KEEL_MARVIN_CMD", "uv run am")
|
||||||
t.Setenv("KEEL_KNOWLEDGE_FILE", "/tmp/k.md")
|
t.Setenv("KEEL_KNOWLEDGE_FILE", "/tmp/k.md")
|
||||||
|
t.Setenv("KEEL_AW_URL", "http://aw.local:5600")
|
||||||
|
t.Setenv("KEEL_AMBIENT_MODE", "")
|
||||||
|
t.Setenv("KEEL_AMBIENT_CADENCE_SECS", "")
|
||||||
got := SeedFromEnv()
|
got := SeedFromEnv()
|
||||||
want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md"}
|
want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md", AWURL: "http://aw.local:5600", AmbientMode: AmbientNotify, AmbientCadenceSecs: 300}
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("SeedFromEnv = %+v, want %+v", got, want)
|
t.Errorf("SeedFromEnv = %+v, want %+v", got, want)
|
||||||
}
|
}
|
||||||
@@ -58,3 +61,52 @@ func TestSaveCreatesDir(t *testing.T) {
|
|||||||
t.Fatalf("file not written: %v", err)
|
t.Fatalf("file not written: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSeedFromEnvAWURL(t *testing.T) {
|
||||||
|
t.Setenv("KEEL_AW_URL", "http://aw.example:9999")
|
||||||
|
if got := SeedFromEnv().AWURL; got != "http://aw.example:9999" {
|
||||||
|
t.Fatalf("AWURL = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedFromEnvAWURLDefault(t *testing.T) {
|
||||||
|
t.Setenv("KEEL_AW_URL", "")
|
||||||
|
if got := SeedFromEnv().AWURL; got != "http://localhost:5600" {
|
||||||
|
t.Fatalf("default AWURL = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidAmbientMode(t *testing.T) {
|
||||||
|
for _, m := range []string{AmbientOff, AmbientStatus, AmbientNotify} {
|
||||||
|
if !ValidAmbientMode(m) {
|
||||||
|
t.Fatalf("%q should be valid", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ValidAmbientMode("loud") {
|
||||||
|
t.Fatal("loud should be invalid")
|
||||||
|
}
|
||||||
|
if ValidAmbientMode("") {
|
||||||
|
t.Fatal("empty should be invalid (callers default before validating)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedFromEnvAmbientDefaults(t *testing.T) {
|
||||||
|
t.Setenv("KEEL_AMBIENT_MODE", "")
|
||||||
|
t.Setenv("KEEL_AMBIENT_CADENCE_SECS", "")
|
||||||
|
s := SeedFromEnv()
|
||||||
|
if s.AmbientMode != AmbientNotify {
|
||||||
|
t.Fatalf("ambient_mode default = %q, want %q", s.AmbientMode, AmbientNotify)
|
||||||
|
}
|
||||||
|
if s.AmbientCadenceSecs != 300 {
|
||||||
|
t.Fatalf("ambient_cadence_secs default = %d, want 300", s.AmbientCadenceSecs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedFromEnvAmbientFromEnv(t *testing.T) {
|
||||||
|
t.Setenv("KEEL_AMBIENT_MODE", "status")
|
||||||
|
t.Setenv("KEEL_AMBIENT_CADENCE_SECS", "120")
|
||||||
|
s := SeedFromEnv()
|
||||||
|
if s.AmbientMode != "status" || s.AmbientCadenceSecs != 120 {
|
||||||
|
t.Fatalf("env not honored: mode=%q cadence=%d", s.AmbientMode, s.AmbientCadenceSecs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,12 +34,15 @@ func DefaultPath() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render produces the single status line for the harness envelope, dispatching
|
// Render produces the single status line for the harness envelope, dispatching
|
||||||
// on the active mode. An idle harness ("") renders "idle"; an unknown kind
|
// on the active mode. An idle harness ("") renders the ambient coaching line as
|
||||||
// renders a generic fallback so a future mode degrades gracefully rather than
|
// "⚠ <line>" when one is present, else "idle"; an unknown kind renders a generic
|
||||||
// rendering nothing.
|
// fallback so a future mode degrades gracefully.
|
||||||
func Render(env mode.Envelope, now time.Time) string {
|
func Render(env mode.Envelope, ambientLine string, now time.Time) string {
|
||||||
switch env.ActiveMode {
|
switch env.ActiveMode {
|
||||||
case "":
|
case "":
|
||||||
|
if line := strings.TrimSpace(ambientLine); line != "" {
|
||||||
|
return "⚠ " + line
|
||||||
|
}
|
||||||
return "idle"
|
return "idle"
|
||||||
case "focus":
|
case "focus":
|
||||||
st, ok := env.Mode.(focus.State)
|
st, ok := env.Mode.(focus.State)
|
||||||
@@ -121,11 +124,12 @@ func remaining(st focus.State, now time.Time) string {
|
|||||||
return fmt.Sprintf("%dm", mins)
|
return fmt.Sprintf("%dm", mins)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Writer periodically renders the harness envelope and writes it to a file,
|
// Writer periodically renders the harness envelope (plus the ambient line) and
|
||||||
// rewriting only when the line changes.
|
// writes it to a file, rewriting only when the line changes.
|
||||||
type Writer struct {
|
type Writer struct {
|
||||||
path string
|
path string
|
||||||
state func() mode.Envelope
|
state func() mode.Envelope
|
||||||
|
ambient func() string
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
wake chan struct{}
|
wake chan struct{}
|
||||||
|
|
||||||
@@ -133,10 +137,11 @@ type Writer struct {
|
|||||||
wrote bool
|
wrote bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWriter builds a Writer for path, reading the harness envelope via the given
|
// NewWriter builds a Writer for path, reading the harness envelope via state and
|
||||||
// accessor (typically harness.State).
|
// the ambient coaching line via ambient (either may be supplied; a nil ambient
|
||||||
func NewWriter(path string, state func() mode.Envelope) *Writer {
|
// is treated as "" by write).
|
||||||
return &Writer{path: path, state: state, now: time.Now, wake: make(chan struct{}, 1)}
|
func NewWriter(path string, state func() mode.Envelope, ambient func() string) *Writer {
|
||||||
|
return &Writer{path: path, state: state, ambient: ambient, now: time.Now, wake: make(chan struct{}, 1)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wake asks the writer to re-render now rather than at the next tick. It is the
|
// Wake asks the writer to re-render now rather than at the next tick. It is the
|
||||||
@@ -174,14 +179,18 @@ func (w *Writer) Run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *Writer) write() {
|
func (w *Writer) write() {
|
||||||
line := Render(w.state(), w.now())
|
line := ""
|
||||||
if w.wrote && line == w.last {
|
if w.ambient != nil {
|
||||||
|
line = w.ambient()
|
||||||
|
}
|
||||||
|
rendered := Render(w.state(), line, w.now())
|
||||||
|
if w.wrote && rendered == w.last {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(w.path, []byte(line), 0o644); err != nil {
|
if err := os.WriteFile(w.path, []byte(rendered), 0o644); err != nil {
|
||||||
log.Printf("statusfile: write %s: %v", w.path, err)
|
log.Printf("statusfile: write %s: %v", w.path, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.last = line
|
w.last = rendered
|
||||||
w.wrote = true
|
w.wrote = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func TestRender(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
if got := Render(tc.env, now); got != tc.want {
|
if got := Render(tc.env, "", now); got != tc.want {
|
||||||
t.Errorf("Render() = %q, want %q", got, tc.want)
|
t.Errorf("Render() = %q, want %q", got, tc.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -77,7 +77,7 @@ func TestWriterWritesAndDedups(t *testing.T) {
|
|||||||
path := filepath.Join(t.TempDir(), "status")
|
path := filepath.Join(t.TempDir(), "status")
|
||||||
now := time.Unix(1_000_000, 0)
|
now := time.Unix(1_000_000, 0)
|
||||||
env := focusEnv(focusState(domain.RuntimePlanning))
|
env := focusEnv(focusState(domain.RuntimePlanning))
|
||||||
w := NewWriter(path, func() mode.Envelope { return env })
|
w := NewWriter(path, func() mode.Envelope { return env }, nil)
|
||||||
w.now = func() time.Time { return now }
|
w.now = func() time.Time { return now }
|
||||||
|
|
||||||
w.write()
|
w.write()
|
||||||
@@ -107,7 +107,7 @@ func TestWriterRemovesFileOnCancel(t *testing.T) {
|
|||||||
path := filepath.Join(t.TempDir(), "status")
|
path := filepath.Join(t.TempDir(), "status")
|
||||||
w := NewWriter(path, func() mode.Envelope {
|
w := NewWriter(path, func() mode.Envelope {
|
||||||
return focusEnv(focusState(domain.RuntimePlanning))
|
return focusEnv(focusState(domain.RuntimePlanning))
|
||||||
})
|
}, nil)
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() { w.Run(ctx); close(done) }()
|
go func() { w.Run(ctx); close(done) }()
|
||||||
@@ -131,7 +131,7 @@ func TestWriterWakeRendersPromptly(t *testing.T) {
|
|||||||
|
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
st := activeState(in10m, "ontask", "")
|
st := activeState(in10m, "ontask", "")
|
||||||
w := NewWriter(path, func() mode.Envelope { mu.Lock(); defer mu.Unlock(); return focusEnv(st) })
|
w := NewWriter(path, func() mode.Envelope { mu.Lock(); defer mu.Unlock(); return focusEnv(st) }, nil)
|
||||||
w.now = func() time.Time { return now }
|
w.now = func() time.Time { return now }
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
@@ -148,6 +148,27 @@ func TestWriterWakeRendersPromptly(t *testing.T) {
|
|||||||
waitFor(t, func() bool { return fileEquals(path, "⚠ DRIFT 10m") })
|
waitFor(t, func() bool { return fileEquals(path, "⚠ DRIFT 10m") })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderIdleShowsAmbientLine(t *testing.T) {
|
||||||
|
got := Render(mode.Envelope{ActiveMode: ""}, "deep in YouTube, no goal", time.Unix(0, 0))
|
||||||
|
if got != "⚠ deep in YouTube, no goal" {
|
||||||
|
t.Fatalf("idle+ambient render = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderIdleNoAmbientLineIsIdle(t *testing.T) {
|
||||||
|
if got := Render(mode.Envelope{ActiveMode: ""}, "", time.Unix(0, 0)); got != "idle" {
|
||||||
|
t.Fatalf("idle+no-ambient render = %q, want idle", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderActiveModeIgnoresAmbientLine(t *testing.T) {
|
||||||
|
// A non-idle envelope renders its own mode, never the ambient line.
|
||||||
|
got := Render(mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "proposed", "next_action": "walk"}}, "ambient ignored", time.Unix(0, 0))
|
||||||
|
if got == "ambient ignored" || got == "⚠ ambient ignored" {
|
||||||
|
t.Fatalf("active mode must not render the ambient line, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func fileEquals(path, want string) bool {
|
func fileEquals(path, want string) bool {
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
return err == nil && string(b) == want
|
return err == nil && string(b) == want
|
||||||
|
|||||||
@@ -45,6 +45,19 @@ func (s *Server) handlePostSettings(c *gin.Context) {
|
|||||||
s.settingsMu.Lock()
|
s.settingsMu.Lock()
|
||||||
apply := s.applyFn
|
apply := s.applyFn
|
||||||
path := s.settingsPath
|
path := s.settingsPath
|
||||||
|
// aw_url is not exposed in the settings form; preserve the persisted value
|
||||||
|
// so a UI save never blanks a configured AW URL.
|
||||||
|
if req.AWURL == "" {
|
||||||
|
req.AWURL = s.settings.AWURL
|
||||||
|
}
|
||||||
|
// Likewise preserve ambient fields when a caller omits them, so a partial
|
||||||
|
// POST never clobbers a configured ambient dial.
|
||||||
|
if req.AmbientMode == "" {
|
||||||
|
req.AmbientMode = s.settings.AmbientMode
|
||||||
|
}
|
||||||
|
if req.AmbientCadenceSecs <= 0 {
|
||||||
|
req.AmbientCadenceSecs = s.settings.AmbientCadenceSecs
|
||||||
|
}
|
||||||
s.settingsMu.Unlock()
|
s.settingsMu.Unlock()
|
||||||
if apply == nil {
|
if apply == nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "settings not wired"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "settings not wired"})
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"keel/internal/harness"
|
||||||
"keel/internal/settings"
|
"keel/internal/settings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -111,6 +113,37 @@ func TestPostSettingsInvalidJSONIs400(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPostSettingsPreservesAWURL(t *testing.T) {
|
||||||
|
s, _ := newTestServer(t)
|
||||||
|
path := filepath.Join(t.TempDir(), "settings.json")
|
||||||
|
fa := &fakeApplier{}
|
||||||
|
cur := settings.Settings{AIBackend: "claude", AWURL: "http://aw.remote:9999"}
|
||||||
|
s.SetSettings(path, cur, fa.apply)
|
||||||
|
r := s.Router()
|
||||||
|
|
||||||
|
// POST a body that omits aw_url (mirrors what the real UI form sends).
|
||||||
|
body := `{"ai_backend":"claude","marvin_cmd":"","knowledge_path":""}`
|
||||||
|
w := post(t, r, "/settings", body)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (body %s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if fa.called != 1 {
|
||||||
|
t.Fatalf("applier called %d times, want 1", fa.called)
|
||||||
|
}
|
||||||
|
// (a) applyFn must have received the preserved AWURL.
|
||||||
|
if fa.last.AWURL != "http://aw.remote:9999" {
|
||||||
|
t.Errorf("applied AWURL = %q, want http://aw.remote:9999", fa.last.AWURL)
|
||||||
|
}
|
||||||
|
// (b) persisted file must also preserve the AWURL.
|
||||||
|
saved, err := settings.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("settings file not loadable: %v", err)
|
||||||
|
}
|
||||||
|
if saved.AWURL != "http://aw.remote:9999" {
|
||||||
|
t.Errorf("saved AWURL = %q, want http://aw.remote:9999", saved.AWURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBrowseListsDirsAndMarkdown(t *testing.T) {
|
func TestBrowseListsDirsAndMarkdown(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
|
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||||
@@ -189,3 +222,30 @@ func TestBrowseBadDirIs400(t *testing.T) {
|
|||||||
t.Fatalf("status = %d, want 400", w.Code)
|
t.Fatalf("status = %d, want 400", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPostSettingsPreservesBlankAmbientFields(t *testing.T) {
|
||||||
|
h := harness.New(harness.Services{}, map[string]harness.Factory{})
|
||||||
|
s := NewServer(h)
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "settings.json")
|
||||||
|
current := settings.Settings{AIBackend: "claude", AmbientMode: "status", AmbientCadenceSecs: 120}
|
||||||
|
s.SetSettings(path, current, func(settings.Settings) error { return nil })
|
||||||
|
|
||||||
|
// A POST that omits the ambient fields must not blank them.
|
||||||
|
body := `{"ai_backend":"claude"}`
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/settings", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
s.Router().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
saved, err := settings.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if saved.AmbientMode != "status" || saved.AmbientCadenceSecs != 120 {
|
||||||
|
t.Fatalf("ambient fields not preserved: mode=%q cadence=%d", saved.AmbientMode, saved.AmbientCadenceSecs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -190,3 +190,18 @@ input[type="checkbox"] {
|
|||||||
}
|
}
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
|
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
|
||||||
|
|
||||||
|
.ambient-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 8px 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #3a2a00;
|
||||||
|
color: #ffd479;
|
||||||
|
border: 1px solid #6b4f00;
|
||||||
|
}
|
||||||
|
.ambient-banner .ambient-line { font-weight: 600; }
|
||||||
|
.ambient-banner .ambient-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||||
|
|||||||
@@ -381,10 +381,27 @@ function renderOffscreen(m) {
|
|||||||
if (retryBtn) retryBtn.onclick = () => post('/mode/offscreen/start');
|
if (retryBtn) retryBtn.onclick = () => post('/mode/offscreen/start');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateAmbient shows the always-on drift banner whenever the server reports an
|
||||||
|
// ambient coaching line, regardless of the active mode. It clears when empty.
|
||||||
|
function updateAmbient(env) {
|
||||||
|
const el = document.getElementById('ambientBanner');
|
||||||
|
const line = (env && env.ambient) || '';
|
||||||
|
if (!line) { el.hidden = true; el.innerHTML = ''; return; }
|
||||||
|
el.hidden = false;
|
||||||
|
el.innerHTML = `<span class="ambient-line">⚠ ${esc(line)}</span>
|
||||||
|
<span class="ambient-actions">
|
||||||
|
<button type="button" class="btn btn-ghost" id="ambientSnooze">Snooze 1h</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="ambientFocus">Start focus</button>
|
||||||
|
</span>`;
|
||||||
|
document.getElementById('ambientSnooze').onclick = () => post('/ambient/snooze');
|
||||||
|
document.getElementById('ambientFocus').onclick = () => post('/mode/focus/start');
|
||||||
|
}
|
||||||
|
|
||||||
// route dispatches the state envelope to the active mode's renderer. On a mode
|
// route dispatches the state envelope to the active mode's renderer. On a mode
|
||||||
// switch it resets the focus in-place trackers so stale DOM never leaks across.
|
// switch it resets the focus in-place trackers so stale DOM never leaks across.
|
||||||
function route(env) {
|
function route(env) {
|
||||||
const am = (env && env.active_mode) || '';
|
const am = (env && env.active_mode) || '';
|
||||||
|
updateAmbient(env);
|
||||||
if (am !== renderedMode) {
|
if (am !== renderedMode) {
|
||||||
renderedMode = am;
|
renderedMode = am;
|
||||||
renderedState = null;
|
renderedState = null;
|
||||||
@@ -420,6 +437,14 @@ function openSettings() {
|
|||||||
<button type="button" class="btn btn-ghost" id="setBrowse">Browse…</button>
|
<button type="button" class="btn btn-ghost" id="setBrowse">Browse…</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="browsePane" class="browse" hidden></div>
|
<div id="browsePane" class="browse" hidden></div>
|
||||||
|
<label>Ambient coach</label>
|
||||||
|
<select id="setAmbient">
|
||||||
|
<option value="notify">notify (status + toast)</option>
|
||||||
|
<option value="status">status only</option>
|
||||||
|
<option value="off">off</option>
|
||||||
|
</select>
|
||||||
|
<label>Ambient check every (seconds)</label>
|
||||||
|
<input id="setAmbientCadence" type="number" min="30" placeholder="300">
|
||||||
<div id="setError" class="set-error"></div>
|
<div id="setError" class="set-error"></div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="btn btn-ghost" id="setCancel">Cancel</button>
|
<button type="button" class="btn btn-ghost" id="setCancel">Cancel</button>
|
||||||
@@ -429,6 +454,8 @@ function openSettings() {
|
|||||||
document.getElementById('setBackend').value = s.ai_backend || 'claude';
|
document.getElementById('setBackend').value = s.ai_backend || 'claude';
|
||||||
document.getElementById('setMarvin').value = s.marvin_cmd || '';
|
document.getElementById('setMarvin').value = s.marvin_cmd || '';
|
||||||
document.getElementById('setKnow').value = s.knowledge_path || '';
|
document.getElementById('setKnow').value = s.knowledge_path || '';
|
||||||
|
document.getElementById('setAmbient').value = s.ambient_mode || 'notify';
|
||||||
|
document.getElementById('setAmbientCadence').value = s.ambient_cadence_secs || 300;
|
||||||
ov.hidden = false;
|
ov.hidden = false;
|
||||||
document.getElementById('setCancel').onclick = closeSettings;
|
document.getElementById('setCancel').onclick = closeSettings;
|
||||||
document.getElementById('setSave').onclick = saveSettings;
|
document.getElementById('setSave').onclick = saveSettings;
|
||||||
@@ -454,6 +481,8 @@ function saveSettings() {
|
|||||||
ai_backend: document.getElementById('setBackend').value,
|
ai_backend: document.getElementById('setBackend').value,
|
||||||
marvin_cmd: document.getElementById('setMarvin').value.trim(),
|
marvin_cmd: document.getElementById('setMarvin').value.trim(),
|
||||||
knowledge_path: document.getElementById('setKnow').value.trim(),
|
knowledge_path: document.getElementById('setKnow').value.trim(),
|
||||||
|
ambient_mode: document.getElementById('setAmbient').value,
|
||||||
|
ambient_cadence_secs: parseInt(document.getElementById('setAmbientCadence').value, 10) || 300,
|
||||||
};
|
};
|
||||||
post('/settings', body).then(r => {
|
post('/settings', body).then(r => {
|
||||||
if (r.ok) { closeSettings(); return; }
|
if (r.ok) { closeSettings(); return; }
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<main id="app">
|
<main id="app">
|
||||||
<h1>Keel <button id="gear" type="button" class="gear" title="Settings" aria-label="Settings">⚙</button></h1>
|
<h1>Keel <button id="gear" type="button" class="gear" title="Settings" aria-label="Settings">⚙</button></h1>
|
||||||
|
<div id="ambientBanner" class="ambient-banner" hidden></div>
|
||||||
<div class="card" id="view">connecting…</div>
|
<div class="card" id="view">connecting…</div>
|
||||||
<div id="settingsOverlay" class="overlay" hidden></div>
|
<div id="settingsOverlay" class="overlay" hidden></div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+33
-1
@@ -14,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"keel/internal/harness"
|
"keel/internal/harness"
|
||||||
|
"keel/internal/mode"
|
||||||
"keel/internal/mode/focus/statemachine"
|
"keel/internal/mode/focus/statemachine"
|
||||||
"keel/internal/settings"
|
"keel/internal/settings"
|
||||||
|
|
||||||
@@ -28,6 +29,9 @@ type Server struct {
|
|||||||
h *harness.Harness
|
h *harness.Harness
|
||||||
bcast *Broadcaster
|
bcast *Broadcaster
|
||||||
|
|
||||||
|
ambientLine func() string
|
||||||
|
ambientSnooze func(time.Duration)
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
timer *time.Timer
|
timer *time.Timer
|
||||||
|
|
||||||
@@ -77,14 +81,42 @@ func (s *Server) Router() *gin.Engine {
|
|||||||
r.GET("/settings", s.handleGetSettings)
|
r.GET("/settings", s.handleGetSettings)
|
||||||
r.POST("/settings", s.handlePostSettings)
|
r.POST("/settings", s.handlePostSettings)
|
||||||
r.GET("/fs/browse", s.handleBrowse)
|
r.GET("/fs/browse", s.handleBrowse)
|
||||||
|
r.POST("/ambient/snooze", s.handleAmbientSnooze)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) stateJSON() string {
|
func (s *Server) stateJSON() string {
|
||||||
data, _ := json.Marshal(s.h.State())
|
line := ""
|
||||||
|
if s.ambientLine != nil {
|
||||||
|
line = s.ambientLine()
|
||||||
|
}
|
||||||
|
payload := struct {
|
||||||
|
mode.Envelope
|
||||||
|
Ambient string `json:"ambient"`
|
||||||
|
}{Envelope: s.h.State(), Ambient: line}
|
||||||
|
data, _ := json.Marshal(payload)
|
||||||
return string(data)
|
return string(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetAmbient injects the ambient sentinel's line accessor and snooze action,
|
||||||
|
// wired by main. Called once at startup before serving.
|
||||||
|
func (s *Server) SetAmbient(line func() string, snooze func(time.Duration)) {
|
||||||
|
s.ambientLine = line
|
||||||
|
s.ambientSnooze = snooze
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast pushes the current state envelope to SSE subscribers. Exposed so the
|
||||||
|
// ambient sentinel's change signal can refresh the UI alongside harness changes.
|
||||||
|
func (s *Server) Broadcast() { s.broadcast() }
|
||||||
|
|
||||||
|
// handleAmbientSnooze mutes the ambient coach for an hour.
|
||||||
|
func (s *Server) handleAmbientSnooze(c *gin.Context) {
|
||||||
|
if s.ambientSnooze != nil {
|
||||||
|
s.ambientSnooze(time.Hour)
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) broadcast() {
|
func (s *Server) broadcast() {
|
||||||
s.bcast.Publish(s.stateJSON())
|
s.bcast.Publish(s.stateJSON())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -417,6 +417,34 @@ func TestReflectionFlowsToReviewThenPlanning(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStateJSONIncludesAmbient(t *testing.T) {
|
||||||
|
h := harness.New(harness.Services{}, map[string]harness.Factory{})
|
||||||
|
s := NewServer(h)
|
||||||
|
s.SetAmbient(func() string { return "deep in YouTube" }, func(time.Duration) {})
|
||||||
|
js := s.stateJSON()
|
||||||
|
if !strings.Contains(js, `"ambient":"deep in YouTube"`) {
|
||||||
|
t.Fatalf("state JSON missing ambient field: %s", js)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAmbientSnoozeRoute(t *testing.T) {
|
||||||
|
h := harness.New(harness.Services{}, map[string]harness.Factory{})
|
||||||
|
s := NewServer(h)
|
||||||
|
var snoozed time.Duration
|
||||||
|
s.SetAmbient(func() string { return "" }, func(d time.Duration) { snoozed = d })
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ambient/snooze", nil)
|
||||||
|
s.Router().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("snooze status = %d, want 204", w.Code)
|
||||||
|
}
|
||||||
|
if snoozed != time.Hour {
|
||||||
|
t.Fatalf("snooze duration = %v, want 1h", snoozed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type stubJudge struct{ verdict ai.Verdict }
|
type stubJudge struct{ verdict ai.Verdict }
|
||||||
|
|
||||||
func (j stubJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) {
|
func (j stubJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user