Compare commits
6 Commits
9452e54867
...
5342da2755
| Author | SHA1 | Date | |
|---|---|---|---|
| 5342da2755 | |||
| b1b807590c | |||
| 8bd37ed46d | |||
| 0f1790c8d5 | |||
| 09061f8e30 | |||
| 9e03add01b |
@@ -23,6 +23,17 @@ go test ./...
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
|
M6 (knowledge port): the planning coach now sharpens intents against who you
|
||||||
|
actually are. A single profile file (`~/.antidrift/knowledge.md`, overridable
|
||||||
|
via `ANTIDRIFT_KNOWLEDGE_FILE`) holds your standing context — priorities,
|
||||||
|
values, what counts as good work — and the daemon loads it asynchronously on
|
||||||
|
entering planning, mirroring the tasks fetch. The cached text grounds the AI
|
||||||
|
coach prompt only; the drift judge and nudge are untouched, and the profile text
|
||||||
|
never crosses the wire. A subtle planning-screen indicator shows whether the
|
||||||
|
profile loaded and from where, with a "change" affordance to repoint at another
|
||||||
|
file. It degrades gracefully — a missing, blank, or unreadable file just leaves
|
||||||
|
the coach ungrounded, and planning still works.
|
||||||
|
|
||||||
M3.5 (semantic nudge): the drift interceptor catches the *wrong app*, but not
|
M3.5 (semantic nudge): the drift interceptor catches the *wrong app*, but not
|
||||||
the *wrong work inside a right app*. M3.5 adds a third, ambient AI role that —
|
the *wrong work inside a right app*. M3.5 adds a third, ambient AI role that —
|
||||||
only while you are on-task in an allowed app — periodically reads your recent
|
only while you are on-task in an allowed app — periodically reads your recent
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"antidrift/internal/ai"
|
"antidrift/internal/ai"
|
||||||
"antidrift/internal/evidence"
|
"antidrift/internal/evidence"
|
||||||
|
"antidrift/internal/knowledge"
|
||||||
"antidrift/internal/session"
|
"antidrift/internal/session"
|
||||||
"antidrift/internal/statusfile"
|
"antidrift/internal/statusfile"
|
||||||
"antidrift/internal/store"
|
"antidrift/internal/store"
|
||||||
@@ -54,6 +55,13 @@ func main() {
|
|||||||
ctrl.SetTasks(tasks.NewMarvin(os.Getenv("ANTIDRIFT_MARVIN_CMD")))
|
ctrl.SetTasks(tasks.NewMarvin(os.Getenv("ANTIDRIFT_MARVIN_CMD")))
|
||||||
log.Printf("tasks: marvin adapter (am)")
|
log.Printf("tasks: marvin adapter (am)")
|
||||||
|
|
||||||
|
// Wire the Knowledge port: a single profile file grounding the coach. The
|
||||||
|
// default path is overridable with ANTIDRIFT_KNOWLEDGE_FILE; unset falls back
|
||||||
|
// to ~/.antidrift/knowledge.md. A missing/unreadable file degrades to an
|
||||||
|
// ungrounded coach at load time — planning still works.
|
||||||
|
ctrl.SetKnowledge(knowledge.NewFileSource(os.Getenv("ANTIDRIFT_KNOWLEDGE_FILE")))
|
||||||
|
log.Printf("knowledge: file adapter")
|
||||||
|
|
||||||
// Mirror runtime status to ~/.antidrift_status for a window-manager bar.
|
// Mirror runtime status to ~/.antidrift_status for a window-manager bar.
|
||||||
// A misconfigured home dir degrades to no status file, not a failed start.
|
// A misconfigured home dir degrades to no status file, not a failed start.
|
||||||
if statusPath, err := statusfile.DefaultPath(); err != nil {
|
if statusPath, err := statusfile.DefaultPath(); err != nil {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,272 @@
|
|||||||
|
# M6 — Knowledge Port Design
|
||||||
|
|
||||||
|
**Goal:** Add the `knowledge.Source` port — answering "who am I; what are my
|
||||||
|
priorities?" — with a single-config-file adapter (`~/.antidrift/knowledge.md`).
|
||||||
|
The profile text is loaded on entering planning and threaded into the AI
|
||||||
|
**coach** prompt as grounding, so "sharpen this intent" reflects who the user is
|
||||||
|
and what matters to them. A subtle planning-screen indicator shows whether the
|
||||||
|
profile loaded and from where, and lets the user point at a different file.
|
||||||
|
Read-only, graceful degradation.
|
||||||
|
|
||||||
|
**Status:** Design draft 2026-06-01. Implements the deferred `knowledge` port
|
||||||
|
named in `2026-05-31-go-focus-os-design.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Direction
|
||||||
|
|
||||||
|
The Knowledge port is the fourth real port, after Activity (`evidence`),
|
||||||
|
Advisor (`ai`), and Tasks (`tasks`). It follows the same pattern: a small
|
||||||
|
leaf-package interface, a single adapter, and a fake for tests. It returns
|
||||||
|
primitives only, so it imports nothing from `domain` or `session`.
|
||||||
|
|
||||||
|
Its one job is to answer "who am I; what are my priorities?" — the standing
|
||||||
|
context that does not change session to session (role, current projects, what
|
||||||
|
counts as important, how the user likes to work). Where the Tasks port answers
|
||||||
|
*what should I be doing right now*, the Knowledge port answers *what kind of
|
||||||
|
person, with what priorities, is doing it*. That context exists to make the
|
||||||
|
advisor's judgment less generic.
|
||||||
|
|
||||||
|
**Coach-only grounding (this milestone).** The profile feeds exactly one
|
||||||
|
advisor role: the planning **coach**. Planning is the moment a vague intent is
|
||||||
|
turned into a concrete commitment, it happens at most a few times a day, and the
|
||||||
|
coach call already runs off the hot path — so grounding it is the highest-value,
|
||||||
|
lowest-cost place to start. The live drift judge and the ambient nudge are
|
||||||
|
deliberately left ungrounded in M6: they run on the hot path (debounced/cached
|
||||||
|
per window, every few minutes) and adding profile text to those prompts would
|
||||||
|
raise their token cost for marginal benefit. Extending grounding to those roles
|
||||||
|
is a clean follow-up once M6 proves the wiring (see Out of Scope).
|
||||||
|
|
||||||
|
The profile is **grounding, not instruction**: it informs the coach's proposal
|
||||||
|
but never forces a transition, exactly as the architecture's cortex layer
|
||||||
|
requires. Nothing is written back; the file is read-only.
|
||||||
|
|
||||||
|
## 2. The Port
|
||||||
|
|
||||||
|
New package `internal/knowledge`, a leaf package like `tasks` and `ai`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Package knowledge is the Knowledge port: it answers "who am I; what are my
|
||||||
|
// priorities?" by loading the user's standing profile. It imports nothing from
|
||||||
|
// the rest of the app, so it stays a leaf package.
|
||||||
|
package knowledge
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// Profile is the user's standing context. Primitives only, so knowledge stays a
|
||||||
|
// leaf package.
|
||||||
|
type Profile struct {
|
||||||
|
Text string // grounding text; "" when no profile is available
|
||||||
|
Path string // resolved source location, for display
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source answers "who am I; what are my priorities?" — the user's standing
|
||||||
|
// profile that grounds the advisor.
|
||||||
|
type Source interface {
|
||||||
|
// Load returns the user's profile. path selects an explicit location; ""
|
||||||
|
// means the adapter's configured default. A missing source is NOT an error:
|
||||||
|
// it yields an empty-Text Profile so the caller degrades to ungrounded.
|
||||||
|
// Only a real read failure (permissions, unreadable) returns an error.
|
||||||
|
Load(ctx context.Context, path string) (Profile, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `path` parameter (rather than the adapter owning a single fixed path) keeps
|
||||||
|
the adapter stateless and lets the controller own the *selected* path — which
|
||||||
|
the UI can change at runtime — without a mutable field or a type assertion. An
|
||||||
|
empty `path` falls back to the adapter's configured default, and the resolved
|
||||||
|
location comes back in `Profile.Path` for the indicator to display.
|
||||||
|
|
||||||
|
Files under `internal/knowledge/`:
|
||||||
|
|
||||||
|
- `knowledge.go` — the `Source` interface and the `Profile` value type.
|
||||||
|
- `file.go` — the `FileSource` adapter (reads one file) and the small
|
||||||
|
truncation helper.
|
||||||
|
- `file_test.go` — adapter tests against temp files: present, absent, explicit
|
||||||
|
path override, oversize truncation, default-path resolution.
|
||||||
|
|
||||||
|
## 3. The File Adapter
|
||||||
|
|
||||||
|
`FileSource` reads one Markdown/plain-text file and returns its contents as the
|
||||||
|
profile. It is the knowledge analogue of the Marvin adapter, minus the
|
||||||
|
sub-process: a thin, testable wrapper around a single file read.
|
||||||
|
|
||||||
|
```go
|
||||||
|
type FileSource struct {
|
||||||
|
defaultPath string // used when Load is called with path == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFileSource(defaultPath string) *FileSource
|
||||||
|
func (s *FileSource) Load(ctx context.Context, path string) (knowledge.Profile, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Behaviour:
|
||||||
|
|
||||||
|
- **Path resolution.** `path` if non-empty, else `s.defaultPath`, else the
|
||||||
|
built-in default `~/.antidrift/knowledge.md`. `~` is expanded. The resolved
|
||||||
|
absolute path is returned in `Profile.Path` regardless of outcome, so the
|
||||||
|
indicator can always show *where it looked*.
|
||||||
|
- **Missing file** (`os.IsNotExist`) → `Profile{Path: resolved}` with empty
|
||||||
|
`Text` and **no error**. This is the expected steady state for a user who has
|
||||||
|
not written a profile; it must not look like a failure.
|
||||||
|
- **Read error** (permissions, is-a-directory, I/O) → wrapped error, same
|
||||||
|
`fmt.Errorf("knowledge: ...: %w", err)` shape the other adapters use.
|
||||||
|
- **Size cap.** The text is capped at `maxProfileBytes = 6 KiB` to bound the
|
||||||
|
coach prompt's token cost, truncated on a UTF-8 rune boundary with a trailing
|
||||||
|
`\n…(truncated)` marker. A profile that long is already an outlier; the cap is
|
||||||
|
a guard, not a feature.
|
||||||
|
- **Whitespace-only** file → treated as empty `Text` (absent), so a file of
|
||||||
|
blank lines does not produce a meaningless grounding block.
|
||||||
|
|
||||||
|
**Configuration.** Mirrors `ANTIDRIFT_AI_BACKEND` / `ANTIDRIFT_MARVIN_CMD`. The
|
||||||
|
environment variable `ANTIDRIFT_KNOWLEDGE_FILE` sets the default path; unset or
|
||||||
|
empty falls back to `~/.antidrift/knowledge.md`. This is the **durable** way to
|
||||||
|
choose the file. The UI selector (§5) is a convenient **session-only** override
|
||||||
|
on top of it. A missing file or read error never fails startup — the daemon
|
||||||
|
logs one line and proceeds ungrounded.
|
||||||
|
|
||||||
|
## 4. Controller Wiring
|
||||||
|
|
||||||
|
The wiring mirrors the planning coach and the tasks fetch: an async load on
|
||||||
|
entering planning, generation-guarded against stale results, projected into
|
||||||
|
`State` only while planning. The one new seam is that the loaded text is also
|
||||||
|
**cached for the coach to consume**, since grounding flows into the coach call.
|
||||||
|
|
||||||
|
- `SetKnowledge(s knowledge.Source)` injects the source, like `SetTasks`. A nil
|
||||||
|
source turns the feature off (no indicator, ungrounded coach).
|
||||||
|
- New `Controller` fields, alongside the tasks fields:
|
||||||
|
`knowledge knowledge.Source`, `knowledgeStatus string`
|
||||||
|
(`idle`/`pending`/`ready`/`absent`/`error`), `knowledgeText string` (the
|
||||||
|
cached grounding the coach reads), `knowledgePath string` (the currently
|
||||||
|
selected path — `""` means the adapter default), `knowledgeChars int`, and
|
||||||
|
`knowledgeGen int` (the generation counter).
|
||||||
|
- `EnterPlanning()` calls `startKnowledgeFetchLocked()` right after
|
||||||
|
`startTasksFetchLocked()`: bump `knowledgeGen`, set `pending`, launch a
|
||||||
|
goroutine that calls `Load(ctx, c.knowledgePath)`, then on completion
|
||||||
|
re-acquire the lock and **discard if the generation is stale or the runtime
|
||||||
|
has left planning**. On success it sets `knowledgeStatus` to `ready` (or
|
||||||
|
`absent` when `Text == ""`), caches `knowledgeText`/`knowledgePath`/
|
||||||
|
`knowledgeChars`, and `notify()`s. Knowledge is **never** loaded on the
|
||||||
|
synchronous `State()` path.
|
||||||
|
- `State()` projects a `*KnowledgeView` **only while planning**, beside the
|
||||||
|
existing `CoachView` and `TasksView`. The view carries status, the resolved
|
||||||
|
path, and a character count — **not the profile text** (it stays server-side;
|
||||||
|
the browser never needs the body, and keeping it off the wire avoids leaking
|
||||||
|
personal context into the SSE payload and the broadcaster).
|
||||||
|
|
||||||
|
```go
|
||||||
|
// KnowledgeView projects the ephemeral planning knowledge state (the standing
|
||||||
|
// profile that grounds the coach). The profile text is intentionally omitted —
|
||||||
|
// only its presence, source path, and size are surfaced.
|
||||||
|
type KnowledgeView struct {
|
||||||
|
Status string `json:"status"` // idle|pending|ready|absent|error
|
||||||
|
Path string `json:"path,omitempty"` // resolved source path, for display + the selector
|
||||||
|
Chars int `json:"chars,omitempty"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Threading grounding into the coach.** `RequestCoach` captures
|
||||||
|
`grounding := c.knowledgeText` under the lock (next to `coach := c.coach`) and
|
||||||
|
passes it to the coach call. If the knowledge fetch is still in flight when the
|
||||||
|
user presses Sharpen, `knowledgeText` is simply empty for that one call and the
|
||||||
|
coach runs ungrounded — the same graceful-degradation contract as a missing
|
||||||
|
file. No awaiting, no blocking.
|
||||||
|
|
||||||
|
This requires a **signature change** to the `ai.Coach` interface — the one
|
||||||
|
non-additive change in M6:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Coach interface {
|
||||||
|
Coach(ctx context.Context, intent, grounding string) (Proposal, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Service.Coach` passes `grounding` to `buildPrompt(intent, grounding)`, which
|
||||||
|
prepends an `## About the user` section **only when grounding is non-empty**, so
|
||||||
|
an ungrounded call produces a byte-for-byte unchanged prompt. The ripple is
|
||||||
|
small and mechanical: the impl in `ai/coach.go`, the call site in
|
||||||
|
`session.go`, and the coach tests/fakes that implement the interface. `DriftJudge`
|
||||||
|
and `Nudger` are untouched.
|
||||||
|
|
||||||
|
**Explicit file selection.** `SetKnowledgePath(path string)` stores the selected
|
||||||
|
path and, while planning, kicks off a fresh `startKnowledgeFetchLocked()` so the
|
||||||
|
indicator and cached grounding update immediately. It is session-only — not
|
||||||
|
persisted to the snapshot — so a restart returns to the
|
||||||
|
`ANTIDRIFT_KNOWLEDGE_FILE`/default. (Persisting the selection is a snapshot-schema
|
||||||
|
change deferred to a later milestone; the env var is the durable knob for now.)
|
||||||
|
|
||||||
|
No new runtime states, no new transitions, no change to the state machine, no
|
||||||
|
change to persisted snapshot shape.
|
||||||
|
|
||||||
|
## 5. Web / UI
|
||||||
|
|
||||||
|
The profile load rides in the existing SSE state payload during planning, beside
|
||||||
|
coach and tasks. M6 adds **one** small POST route — the file selector — which is
|
||||||
|
the single deliberate server write in this milestone.
|
||||||
|
|
||||||
|
- **Indicator.** The planning render gains a quiet line under the intent band
|
||||||
|
(near `coachStatus`): `ready` → `grounded by <basename(path)>`; `absent` →
|
||||||
|
`no profile (~/.antidrift/knowledge.md)`; `error` → `profile unreadable`;
|
||||||
|
`pending` → `loading profile…`; nil source / `idle` → nothing. It is
|
||||||
|
ambient and non-blocking, matching the tasks "loading…" treatment — never a
|
||||||
|
button to fight.
|
||||||
|
- **Selector.** A small "change" affordance next to the indicator reveals an
|
||||||
|
input pre-filled with the resolved path; submitting POSTs
|
||||||
|
`/knowledge/path` with a `path` field, which calls `ctrl.SetKnowledgePath`
|
||||||
|
and re-loads. This is the *only* new endpoint. It mutates session-only config,
|
||||||
|
not commitment state, so it sits outside the state machine. Submitting an
|
||||||
|
empty path resets to the default. The control is intentionally minimal; if it
|
||||||
|
proves more than needed, it can ship behind the env var alone (the indicator
|
||||||
|
is the core; the selector is the "maybe").
|
||||||
|
- `main.go` gains a knowledge-adapter block parallel to the `tasks` block: read
|
||||||
|
`ANTIDRIFT_KNOWLEDGE_FILE`, construct `knowledge.NewFileSource(...)`, call
|
||||||
|
`ctrl.SetKnowledge(...)`, and log one line. Construction never fails; a bad
|
||||||
|
path only surfaces (as `absent`/`error`) at load time.
|
||||||
|
|
||||||
|
## 6. Testing
|
||||||
|
|
||||||
|
- **`knowledge` package:** adapter tests against temp files — a present file
|
||||||
|
(text + resolved path returned), an absent file (empty `Text`, no error,
|
||||||
|
path still reported), an explicit `path` override beating the default, an
|
||||||
|
oversize file truncated on a rune boundary with the marker, a whitespace-only
|
||||||
|
file treated as absent, and a permission/read error wrapped. `~` expansion
|
||||||
|
covered with a synthesized home. stdlib `testing` only; no process spawned.
|
||||||
|
- **`ai` package:** `buildPrompt` includes the `## About the user` block when
|
||||||
|
grounding is non-empty and is byte-identical to the pre-M6 prompt when
|
||||||
|
grounding is empty (a table test pins both). Existing coach/drift/nudge tests
|
||||||
|
updated for the new `Coach` signature (grounding `""`), staying green.
|
||||||
|
- **`session` package:** with a fake `Source`, assert `knowledgeStatus`
|
||||||
|
transitions (`pending` → `ready`, `pending` → `absent` on empty text,
|
||||||
|
`pending` → `error` on failure) and that `State().Knowledge` reflects them
|
||||||
|
while planning and is absent otherwise / with a nil source. Assert
|
||||||
|
`RequestCoach` passes the cached `knowledgeText` to a recording fake coach,
|
||||||
|
and passes `""` when the fetch has not completed. Assert the generation guard
|
||||||
|
discards a load that returns after leaving planning, and that
|
||||||
|
`SetKnowledgePath` re-fetches. A nil source yields no `KnowledgeView` and an
|
||||||
|
ungrounded coach.
|
||||||
|
- **`web` package:** existing tests stay green (markup-agnostic). Add one
|
||||||
|
assertion that planning-state JSON carries the knowledge object (status +
|
||||||
|
path, no text) when a source is set, and one that `POST /knowledge/path`
|
||||||
|
updates the selected path and triggers a re-load.
|
||||||
|
- `go vet ./... && go test -race ./...` stays clean; `knowledge` stays a leaf
|
||||||
|
package (imports only `context` + stdlib `os`/`io`/`path`/`strings`/`unicode`
|
||||||
|
— nothing from `domain`/`session`/`evidence`/`ai`/`web`).
|
||||||
|
|
||||||
|
## 7. Out of Scope
|
||||||
|
|
||||||
|
- **Grounding the drift judge and nudge.** Coach-only in M6. Extending profile
|
||||||
|
grounding to the hot-path roles is a deliberate follow-up, gated on whether
|
||||||
|
the token cost is worth it.
|
||||||
|
- **PKM-directory and CLI adapters.** M6 ships exactly one file adapter. The
|
||||||
|
`path`-parameter port shape leaves room for a directory or `am`-style CLI
|
||||||
|
adapter later without an interface change, but we do not build or abstract for
|
||||||
|
them now (YAGNI).
|
||||||
|
- **Persisting the selected path** across restarts (a snapshot-schema change).
|
||||||
|
The env var is the durable knob; the UI override is session-only.
|
||||||
|
- **Live file watching / auto-reload.** The profile is re-read on each entry to
|
||||||
|
planning (and on explicit reselection); no inotify, no polling.
|
||||||
|
- **Editing the profile from the UI**, structured profile fields (parsing the
|
||||||
|
Markdown into sections), or per-project knowledge. The file is an opaque
|
||||||
|
grounding blob.
|
||||||
|
- **Shipping the profile text to the browser.** Only presence, path, and size
|
||||||
|
cross the wire.
|
||||||
+13
-8
@@ -2,9 +2,10 @@ package ai
|
|||||||
|
|
||||||
import "context"
|
import "context"
|
||||||
|
|
||||||
// Coach turns a free-text intent into a validated Proposal.
|
// Coach turns a free-text intent into a validated Proposal. grounding is
|
||||||
|
// optional standing context about the user (the knowledge port); "" means none.
|
||||||
type Coach interface {
|
type Coach interface {
|
||||||
Coach(ctx context.Context, intent string) (Proposal, error)
|
Coach(ctx context.Context, intent, grounding string) (Proposal, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backend is one way to reach an LLM CLI. Adapters differ only in the command
|
// Backend is one way to reach an LLM CLI. Adapters differ only in the command
|
||||||
@@ -21,16 +22,16 @@ type Service struct {
|
|||||||
|
|
||||||
func NewService(b Backend) *Service { return &Service{backend: b} }
|
func NewService(b Backend) *Service { return &Service{backend: b} }
|
||||||
|
|
||||||
func (s *Service) Coach(ctx context.Context, intent string) (Proposal, error) {
|
func (s *Service) Coach(ctx context.Context, intent, grounding string) (Proposal, error) {
|
||||||
out, err := s.backend.Run(ctx, buildPrompt(intent))
|
out, err := s.backend.Run(ctx, buildPrompt(intent, grounding))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Proposal{}, err
|
return Proposal{}, err
|
||||||
}
|
}
|
||||||
return parseProposal(out)
|
return parseProposal(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildPrompt(intent string) string {
|
func buildPrompt(intent, grounding string) string {
|
||||||
return `You are a focus coach. The user gives a rough intent for a work session. Turn it into ONE concrete, actionable commitment.
|
preamble := `You are a focus coach. The user gives a rough intent for a work session. Turn it into ONE concrete, actionable commitment.
|
||||||
|
|
||||||
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
||||||
{"next_action": "<one concrete action to start now>", "success_condition": "<observable, verifiable done state>", "timebox_minutes": <integer, typically 15-50>, "allowed_window_classes": ["<app/window class that is on-task, e.g. code, firefox>"]}
|
{"next_action": "<one concrete action to start now>", "success_condition": "<observable, verifiable done state>", "timebox_minutes": <integer, typically 15-50>, "allowed_window_classes": ["<app/window class that is on-task, e.g. code, firefox>"]}
|
||||||
@@ -39,9 +40,13 @@ Rules:
|
|||||||
- next_action: a single concrete imperative action, doable now.
|
- next_action: a single concrete imperative action, doable now.
|
||||||
- success_condition: observable and verifiable; how you'd know it is done.
|
- success_condition: observable and verifiable; how you'd know it is done.
|
||||||
- timebox_minutes: a realistic integer number of minutes for the action.
|
- timebox_minutes: a realistic integer number of minutes for the action.
|
||||||
- allowed_window_classes: a short list of window/application class names that count as on-task for this action (lowercase, e.g. "code", "firefox"). May be empty.
|
- allowed_window_classes: a short list of window/application class names that count as on-task for this action (lowercase, e.g. "code", "firefox"). May be empty.`
|
||||||
|
|
||||||
User intent: ` + intent
|
about := ""
|
||||||
|
if grounding != "" {
|
||||||
|
about = "\n\n## About the user\n" + grounding + "\nUse this standing context to make the commitment fit who they are and what matters to them."
|
||||||
|
}
|
||||||
|
return preamble + about + "\n\nUser intent: " + intent
|
||||||
}
|
}
|
||||||
|
|
||||||
// DriftJudge decides whether the current window is on-task for a commitment.
|
// DriftJudge decides whether the current window is on-task for a commitment.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func (f *fakeBackend) Name() string { return "fake" }
|
|||||||
func TestServiceCoachSuccess(t *testing.T) {
|
func TestServiceCoachSuccess(t *testing.T) {
|
||||||
fb := &fakeBackend{out: `here you go {"next_action":"Write tests","success_condition":"green","timebox_minutes":30}`}
|
fb := &fakeBackend{out: `here you go {"next_action":"Write tests","success_condition":"green","timebox_minutes":30}`}
|
||||||
svc := NewService(fb)
|
svc := NewService(fb)
|
||||||
p, err := svc.Coach(context.Background(), "write the tests")
|
p, err := svc.Coach(context.Background(), "write the tests", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("coach: %v", err)
|
t.Fatalf("coach: %v", err)
|
||||||
}
|
}
|
||||||
@@ -36,14 +36,34 @@ func TestServiceCoachSuccess(t *testing.T) {
|
|||||||
|
|
||||||
func TestServiceCoachBackendError(t *testing.T) {
|
func TestServiceCoachBackendError(t *testing.T) {
|
||||||
fb := &fakeBackend{err: errors.New("boom")}
|
fb := &fakeBackend{err: errors.New("boom")}
|
||||||
if _, err := NewService(fb).Coach(context.Background(), "x"); err == nil {
|
if _, err := NewService(fb).Coach(context.Background(), "x", ""); err == nil {
|
||||||
t.Fatal("want backend error")
|
t.Fatal("want backend error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServiceCoachUnparseable(t *testing.T) {
|
func TestServiceCoachUnparseable(t *testing.T) {
|
||||||
fb := &fakeBackend{out: "I cannot help with that."}
|
fb := &fakeBackend{out: "I cannot help with that."}
|
||||||
if _, err := NewService(fb).Coach(context.Background(), "x"); !errors.Is(err, ErrNoJSON) {
|
if _, err := NewService(fb).Coach(context.Background(), "x", ""); !errors.Is(err, ErrNoJSON) {
|
||||||
t.Fatalf("want ErrNoJSON, got %v", err)
|
t.Fatalf("want ErrNoJSON, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoachPromptIncludesGrounding(t *testing.T) {
|
||||||
|
fb := &fakeBackend{out: `{"next_action":"a","success_condition":"b","timebox_minutes":20}`}
|
||||||
|
if _, err := NewService(fb).Coach(context.Background(), "ship it", "I value small diffs."); err != nil {
|
||||||
|
t.Fatalf("coach: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(fb.gotPrompt, "About the user") || !strings.Contains(fb.gotPrompt, "small diffs") {
|
||||||
|
t.Fatalf("prompt missing grounding block: %s", fb.gotPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoachEmptyGroundingUnchanged(t *testing.T) {
|
||||||
|
withFb := &fakeBackend{out: `{"next_action":"a","success_condition":"b","timebox_minutes":20}`}
|
||||||
|
_, _ = NewService(withFb).Coach(context.Background(), "ship it", "")
|
||||||
|
// The empty-grounding prompt must equal buildPrompt(intent) of the old shape:
|
||||||
|
// it must NOT contain the grounding header.
|
||||||
|
if strings.Contains(withFb.gotPrompt, "About the user") {
|
||||||
|
t.Fatalf("empty grounding leaked a header: %s", withFb.gotPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package knowledge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxProfileBytes caps the grounding text so the coach prompt stays bounded.
|
||||||
|
const maxProfileBytes = 6 * 1024
|
||||||
|
|
||||||
|
// FileSource reads the user's profile from a single file. It is stateless: the
|
||||||
|
// selected path is passed to Load, so the controller can repoint it at runtime
|
||||||
|
// without a mutable field.
|
||||||
|
type FileSource struct {
|
||||||
|
defaultPath string // used when Load is called with path == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileSource builds the adapter. defaultPath is used when Load receives an
|
||||||
|
// empty path; if it too is empty, Load falls back to ~/.antidrift/knowledge.md.
|
||||||
|
func NewFileSource(defaultPath string) *FileSource {
|
||||||
|
return &FileSource{defaultPath: defaultPath}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads the profile at path (or the default). A missing file yields an
|
||||||
|
// empty-Text Profile and no error; only a real read failure errors. The
|
||||||
|
// resolved absolute path is always returned for display.
|
||||||
|
func (s *FileSource) Load(ctx context.Context, path string) (Profile, error) {
|
||||||
|
resolved := s.resolve(path)
|
||||||
|
data, err := os.ReadFile(resolved)
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return Profile{Path: resolved}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Profile{Path: resolved}, fmt.Errorf("knowledge: read %s: %w", resolved, err)
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(string(data))
|
||||||
|
if text == "" {
|
||||||
|
return Profile{Path: resolved}, nil
|
||||||
|
}
|
||||||
|
return Profile{Text: truncate(text, maxProfileBytes), Path: resolved}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve picks path, else the default, else ~/.antidrift/knowledge.md; expands
|
||||||
|
// a leading ~; and makes the result absolute for stable display.
|
||||||
|
func (s *FileSource) resolve(path string) string {
|
||||||
|
p := path
|
||||||
|
if p == "" {
|
||||||
|
p = s.defaultPath
|
||||||
|
}
|
||||||
|
if p == "" {
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
p = filepath.Join(home, ".antidrift", "knowledge.md")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p = expandTilde(p)
|
||||||
|
if abs, err := filepath.Abs(p); err == nil {
|
||||||
|
return abs
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandTilde(p string) string {
|
||||||
|
if p != "~" && !strings.HasPrefix(p, "~/") {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
if p == "~" {
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
return filepath.Join(home, p[2:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncate clips s to max bytes on a UTF-8 rune boundary and appends a marker.
|
||||||
|
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,98 @@
|
|||||||
|
package knowledge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadPresentFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "knowledge.md")
|
||||||
|
if err := os.WriteFile(p, []byte(" I am a focus-tool author.\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := NewFileSource(p).Load(context.Background(), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if got.Text != "I am a focus-tool author." {
|
||||||
|
t.Errorf("Text = %q (whitespace should be trimmed)", got.Text)
|
||||||
|
}
|
||||||
|
if got.Path != p {
|
||||||
|
t.Errorf("Path = %q, want %q", got.Path, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAbsentFileIsNotError(t *testing.T) {
|
||||||
|
p := filepath.Join(t.TempDir(), "missing.md")
|
||||||
|
got, err := NewFileSource(p).Load(context.Background(), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("absent file must not error: %v", err)
|
||||||
|
}
|
||||||
|
if got.Text != "" {
|
||||||
|
t.Errorf("Text = %q, want empty for absent file", got.Text)
|
||||||
|
}
|
||||||
|
if got.Path != p {
|
||||||
|
t.Errorf("Path = %q, want %q even when absent", got.Path, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadExplicitPathBeatsDefault(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
def := filepath.Join(dir, "default.md")
|
||||||
|
other := filepath.Join(dir, "other.md")
|
||||||
|
if err := os.WriteFile(def, []byte("default"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(other, []byte("explicit"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := NewFileSource(def).Load(context.Background(), other)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if got.Text != "explicit" || got.Path != other {
|
||||||
|
t.Fatalf("explicit path ignored: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadTruncatesOversize(t *testing.T) {
|
||||||
|
p := filepath.Join(t.TempDir(), "big.md")
|
||||||
|
if err := os.WriteFile(p, []byte(strings.Repeat("a", maxProfileBytes+500)), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := NewFileSource(p).Load(context.Background(), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if len(got.Text) > maxProfileBytes+len("\n…(truncated)") {
|
||||||
|
t.Errorf("Text not truncated: %d bytes", len(got.Text))
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(got.Text, "(truncated)") {
|
||||||
|
t.Errorf("missing truncation marker: %q", got.Text[len(got.Text)-20:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadWhitespaceOnlyIsAbsent(t *testing.T) {
|
||||||
|
p := filepath.Join(t.TempDir(), "blank.md")
|
||||||
|
if err := os.WriteFile(p, []byte(" \n\t\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := NewFileSource(p).Load(context.Background(), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if got.Text != "" {
|
||||||
|
t.Errorf("whitespace-only should be empty, got %q", got.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadReadErrorIsError(t *testing.T) {
|
||||||
|
dir := t.TempDir() // a directory is not readable as a file
|
||||||
|
if _, err := NewFileSource(dir).Load(context.Background(), ""); err == nil {
|
||||||
|
t.Fatal("reading a directory should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Package knowledge is the Knowledge port: it answers "who am I; what are my
|
||||||
|
// priorities?" by loading the user's standing profile. It imports nothing from
|
||||||
|
// the rest of the app, so it stays a leaf package.
|
||||||
|
package knowledge
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// Profile is the user's standing context. Primitives only, so knowledge stays a
|
||||||
|
// leaf package.
|
||||||
|
type Profile struct {
|
||||||
|
Text string // grounding text; "" when no profile is available
|
||||||
|
Path string // resolved source location, for display
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source answers "who am I; what are my priorities?" — the user's standing
|
||||||
|
// profile that grounds the advisor.
|
||||||
|
type Source interface {
|
||||||
|
// Load returns the user's profile. path selects an explicit location; ""
|
||||||
|
// means the adapter's configured default. A missing source is NOT an error:
|
||||||
|
// it yields an empty-Text Profile so the caller degrades to ungrounded. Only
|
||||||
|
// a real read failure (permissions, unreadable) returns an error.
|
||||||
|
Load(ctx context.Context, path string) (Profile, error)
|
||||||
|
}
|
||||||
+111
-1
@@ -17,6 +17,7 @@ import (
|
|||||||
"antidrift/internal/ai"
|
"antidrift/internal/ai"
|
||||||
"antidrift/internal/domain"
|
"antidrift/internal/domain"
|
||||||
"antidrift/internal/evidence"
|
"antidrift/internal/evidence"
|
||||||
|
"antidrift/internal/knowledge"
|
||||||
"antidrift/internal/statemachine"
|
"antidrift/internal/statemachine"
|
||||||
"antidrift/internal/store"
|
"antidrift/internal/store"
|
||||||
"antidrift/internal/tasks"
|
"antidrift/internal/tasks"
|
||||||
@@ -47,6 +48,16 @@ const (
|
|||||||
tasksError = "error"
|
tasksError = "error"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const knowledgeTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
const (
|
||||||
|
knowledgeIdle = "idle"
|
||||||
|
knowledgePending = "pending"
|
||||||
|
knowledgeReady = "ready"
|
||||||
|
knowledgeAbsent = "absent"
|
||||||
|
knowledgeError = "error"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
driftDebounce = 10 * time.Second
|
driftDebounce = 10 * time.Second
|
||||||
driftTimeout = 30 * time.Second
|
driftTimeout = 30 * time.Second
|
||||||
@@ -107,6 +118,13 @@ type Controller struct {
|
|||||||
tasksList []tasks.Task
|
tasksList []tasks.Task
|
||||||
tasksGen int
|
tasksGen int
|
||||||
|
|
||||||
|
knowledgeSrc knowledge.Source
|
||||||
|
knowledgeStatus string
|
||||||
|
knowledgeText string // cached grounding the coach reads
|
||||||
|
knowledgePath string // selected path; "" = adapter default
|
||||||
|
knowledgeChars int
|
||||||
|
knowledgeGen int
|
||||||
|
|
||||||
allowedClasses []string // durable: the active session's allowed window classes
|
allowedClasses []string // durable: the active session's allowed window classes
|
||||||
judge ai.DriftJudge
|
judge ai.DriftJudge
|
||||||
driftStatus string
|
driftStatus string
|
||||||
@@ -167,6 +185,15 @@ type TasksView struct {
|
|||||||
Tasks []TaskView `json:"tasks,omitempty"`
|
Tasks []TaskView `json:"tasks,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KnowledgeView projects the ephemeral planning knowledge state (the standing
|
||||||
|
// profile that grounds the coach). The profile text is intentionally omitted —
|
||||||
|
// only its presence, source path, and size cross the wire.
|
||||||
|
type KnowledgeView struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Chars int `json:"chars,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// WindowView / BucketView / EvidenceView are the evidence projection.
|
// WindowView / BucketView / EvidenceView are the evidence projection.
|
||||||
type WindowView struct {
|
type WindowView struct {
|
||||||
Class string `json:"class"`
|
Class string `json:"class"`
|
||||||
@@ -194,6 +221,7 @@ type State struct {
|
|||||||
Evidence *EvidenceView `json:"evidence"`
|
Evidence *EvidenceView `json:"evidence"`
|
||||||
Coach *CoachView `json:"coach,omitempty"`
|
Coach *CoachView `json:"coach,omitempty"`
|
||||||
Tasks *TasksView `json:"tasks,omitempty"`
|
Tasks *TasksView `json:"tasks,omitempty"`
|
||||||
|
Knowledge *KnowledgeView `json:"knowledge,omitempty"`
|
||||||
Drift *DriftView `json:"drift,omitempty"`
|
Drift *DriftView `json:"drift,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,6 +347,13 @@ func (c *Controller) stateLocked() State {
|
|||||||
}
|
}
|
||||||
st.Tasks = tv
|
st.Tasks = tv
|
||||||
}
|
}
|
||||||
|
if c.knowledgeSrc != nil {
|
||||||
|
kstatus := c.knowledgeStatus
|
||||||
|
if kstatus == "" {
|
||||||
|
kstatus = knowledgeIdle
|
||||||
|
}
|
||||||
|
st.Knowledge = &KnowledgeView{Status: kstatus, Path: c.knowledgePath, Chars: c.knowledgeChars}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if c.runtimeState == domain.RuntimeActive {
|
if c.runtimeState == domain.RuntimeActive {
|
||||||
status := c.driftStatus
|
status := c.driftStatus
|
||||||
@@ -366,6 +401,7 @@ func (c *Controller) EnterPlanning() error {
|
|||||||
c.runtimeState = next
|
c.runtimeState = next
|
||||||
c.resetCoachLocked()
|
c.resetCoachLocked()
|
||||||
c.startTasksFetchLocked()
|
c.startTasksFetchLocked()
|
||||||
|
c.startKnowledgeFetchLocked()
|
||||||
return c.persistLocked()
|
return c.persistLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +465,79 @@ func (c *Controller) startTasksFetchLocked() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetKnowledge injects the Knowledge source. Mirrors SetTasks. A nil source
|
||||||
|
// keeps the planning knowledge view absent and the coach ungrounded.
|
||||||
|
func (c *Controller) SetKnowledge(s knowledge.Source) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.knowledgeSrc = s
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetKnowledgePath selects an explicit profile path (session-only; not
|
||||||
|
// persisted). While planning, it re-loads immediately so the indicator and the
|
||||||
|
// cached grounding update. An empty path resets to the adapter default.
|
||||||
|
func (c *Controller) SetKnowledgePath(path string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.knowledgePath = strings.TrimSpace(path)
|
||||||
|
planning := c.runtimeState == domain.RuntimePlanning
|
||||||
|
if planning {
|
||||||
|
c.startKnowledgeFetchLocked()
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
if planning {
|
||||||
|
c.notify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startKnowledgeFetchLocked kicks off an asynchronous Load when a source is set.
|
||||||
|
// Mirrors startTasksFetchLocked: generation-guarded, discards stale or
|
||||||
|
// post-planning results, and notifies on completion. The loaded text is cached
|
||||||
|
// in knowledgeText for the coach to read. Caller holds mu.
|
||||||
|
func (c *Controller) startKnowledgeFetchLocked() {
|
||||||
|
c.knowledgeText = ""
|
||||||
|
c.knowledgeChars = 0
|
||||||
|
if c.knowledgeSrc == nil {
|
||||||
|
c.knowledgeStatus = knowledgeIdle
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.knowledgeGen++
|
||||||
|
gen := c.knowledgeGen
|
||||||
|
c.knowledgeStatus = knowledgePending
|
||||||
|
src := c.knowledgeSrc
|
||||||
|
path := c.knowledgePath
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), knowledgeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
prof, err := src.Load(ctx, path)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
if gen != c.knowledgeGen || c.runtimeState != domain.RuntimePlanning {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return // stale or left planning: discard
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.knowledgeStatus = knowledgeError
|
||||||
|
c.knowledgeText = ""
|
||||||
|
c.knowledgeChars = 0
|
||||||
|
if prof.Path != "" {
|
||||||
|
c.knowledgePath = prof.Path
|
||||||
|
}
|
||||||
|
} else if prof.Text == "" {
|
||||||
|
c.knowledgeStatus = knowledgeAbsent
|
||||||
|
c.knowledgeText = ""
|
||||||
|
c.knowledgeChars = 0
|
||||||
|
c.knowledgePath = prof.Path
|
||||||
|
} else {
|
||||||
|
c.knowledgeStatus = knowledgeReady
|
||||||
|
c.knowledgeText = prof.Text
|
||||||
|
c.knowledgeChars = len(prof.Text)
|
||||||
|
c.knowledgePath = prof.Path
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.notify()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
// AllowedClassesForTest exposes the session allowed classes for tests.
|
// AllowedClassesForTest exposes the session allowed classes for tests.
|
||||||
func (c *Controller) AllowedClassesForTest() []string {
|
func (c *Controller) AllowedClassesForTest() []string {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
@@ -545,13 +654,14 @@ func (c *Controller) RequestCoach(intent string) error {
|
|||||||
c.coachErr = ""
|
c.coachErr = ""
|
||||||
c.coachProposal = nil
|
c.coachProposal = nil
|
||||||
coach := c.coach
|
coach := c.coach
|
||||||
|
grounding := c.knowledgeText
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
c.notify()
|
c.notify()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), coachTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), coachTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
prop, err := coach.Coach(ctx, intent)
|
prop, err := coach.Coach(ctx, intent, grounding)
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
if gen != c.coachGen || c.runtimeState != domain.RuntimePlanning {
|
if gen != c.coachGen || c.runtimeState != domain.RuntimePlanning {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"antidrift/internal/ai"
|
"antidrift/internal/ai"
|
||||||
"antidrift/internal/domain"
|
"antidrift/internal/domain"
|
||||||
"antidrift/internal/evidence"
|
"antidrift/internal/evidence"
|
||||||
|
"antidrift/internal/knowledge"
|
||||||
"antidrift/internal/store"
|
"antidrift/internal/store"
|
||||||
"antidrift/internal/tasks"
|
"antidrift/internal/tasks"
|
||||||
)
|
)
|
||||||
@@ -246,15 +248,27 @@ type fakeCoach struct {
|
|||||||
prop ai.Proposal
|
prop ai.Proposal
|
||||||
err error
|
err error
|
||||||
gate chan struct{} // if non-nil, Coach blocks until it receives
|
gate chan struct{} // if non-nil, Coach blocks until it receives
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
gotGrounding string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeCoach) Coach(ctx context.Context, intent string) (ai.Proposal, error) {
|
func (f *fakeCoach) Coach(ctx context.Context, intent, grounding string) (ai.Proposal, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.gotGrounding = grounding
|
||||||
|
f.mu.Unlock()
|
||||||
if f.gate != nil {
|
if f.gate != nil {
|
||||||
<-f.gate
|
<-f.gate
|
||||||
}
|
}
|
||||||
return f.prop, f.err
|
return f.prop, f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeCoach) grounding() string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.gotGrounding
|
||||||
|
}
|
||||||
|
|
||||||
// waitCoachStatus polls until the coach view reaches want, or fails after 2s.
|
// waitCoachStatus polls until the coach view reaches want, or fails after 2s.
|
||||||
func waitCoachStatus(t *testing.T, c *Controller, want string) State {
|
func waitCoachStatus(t *testing.T, c *Controller, want string) State {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -918,3 +932,100 @@ func TestStaleTasksFetchDiscardedAfterLeavingPlanning(t *testing.T) {
|
|||||||
t.Fatalf("stale fetch result clobbered the fresh tasks: %+v", st.Tasks)
|
t.Fatalf("stale fetch result clobbered the fresh tasks: %+v", st.Tasks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeSource struct {
|
||||||
|
profile knowledge.Profile
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSource) Load(ctx context.Context, path string) (knowledge.Profile, error) {
|
||||||
|
return f.profile, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitKnowledgeStatus polls until the knowledge view reaches want, or fails after 2s.
|
||||||
|
func waitKnowledgeStatus(t *testing.T, c *Controller, want string) State {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
st := c.State()
|
||||||
|
if st.Knowledge != nil && st.Knowledge.Status == want {
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("knowledge status never reached %q (last: %+v)", want, c.State().Knowledge)
|
||||||
|
return State{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnterPlanningLoadsKnowledge(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/p/knowledge.md"}})
|
||||||
|
if err := c.EnterPlanning(); err != nil {
|
||||||
|
t.Fatalf("enter planning: %v", err)
|
||||||
|
}
|
||||||
|
st := waitKnowledgeStatus(t, c, "ready")
|
||||||
|
if st.Knowledge.Path != "/p/knowledge.md" || st.Knowledge.Chars != len("I value small diffs.") {
|
||||||
|
t.Fatalf("knowledge view wrong: %+v", st.Knowledge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKnowledgeAbsentWhenEmptyText(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Path: "/p/missing.md"}})
|
||||||
|
if err := c.EnterPlanning(); err != nil {
|
||||||
|
t.Fatalf("enter planning: %v", err)
|
||||||
|
}
|
||||||
|
st := waitKnowledgeStatus(t, c, "absent")
|
||||||
|
if st.Knowledge.Path != "/p/missing.md" {
|
||||||
|
t.Fatalf("absent view should still carry the path: %+v", st.Knowledge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKnowledgeLoadError(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
c.SetKnowledge(&fakeSource{err: errors.New("permission denied")})
|
||||||
|
if err := c.EnterPlanning(); err != nil {
|
||||||
|
t.Fatalf("enter planning: %v", err)
|
||||||
|
}
|
||||||
|
waitKnowledgeStatus(t, c, "error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoSourceNoKnowledgeView(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
if err := c.EnterPlanning(); err != nil {
|
||||||
|
t.Fatalf("enter planning: %v", err)
|
||||||
|
}
|
||||||
|
if c.State().Knowledge != nil {
|
||||||
|
t.Fatalf("nil source should yield no knowledge view: %+v", c.State().Knowledge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKnowledgeViewAbsentOutsidePlanning(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Text: "x"}})
|
||||||
|
if c.State().Knowledge != nil {
|
||||||
|
t.Fatalf("no knowledge view while Locked: %+v", c.State().Knowledge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoachReceivesCachedGrounding(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
fc := &fakeCoach{prop: ai.Proposal{NextAction: "a", SuccessCondition: "b", TimeboxSecs: 1200}}
|
||||||
|
c.SetCoach(fc)
|
||||||
|
c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/p"}})
|
||||||
|
if err := c.EnterPlanning(); err != nil {
|
||||||
|
t.Fatalf("enter planning: %v", err)
|
||||||
|
}
|
||||||
|
waitKnowledgeStatus(t, c, "ready")
|
||||||
|
if err := c.RequestCoach("ship it"); err != nil {
|
||||||
|
t.Fatalf("request coach: %v", err)
|
||||||
|
}
|
||||||
|
// Poll until the coach has run (proposal ready), then assert grounding flowed.
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) && fc.grounding() == "" {
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if fc.grounding() != "I value small diffs." {
|
||||||
|
t.Fatalf("coach grounding = %q, want the cached profile", fc.grounding())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -116,3 +116,10 @@ input:focus { outline: 0; border-color: var(--accent); }
|
|||||||
cursor: pointer; text-align: left;
|
cursor: pointer; text-align: left;
|
||||||
}
|
}
|
||||||
.task-chip:hover { border-color: var(--accent); color: var(--accent); }
|
.task-chip:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
/* Planning: standing-profile grounding indicator */
|
||||||
|
.knowline { opacity: 0.85; }
|
||||||
|
.link {
|
||||||
|
background: none; border: none; padding: 0; font: inherit; font-size: 12px;
|
||||||
|
color: var(--accent); cursor: pointer; text-decoration: underline;
|
||||||
|
}
|
||||||
|
|||||||
@@ -157,12 +157,35 @@ function updatePlanningTasks(tasks) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updatePlanningKnowledge renders the standing-profile grounding indicator and
|
||||||
|
// a small "change" affordance to repoint the file. idle/nil renders nothing;
|
||||||
|
// pending/ready/absent/error each show one quiet line. The profile text never
|
||||||
|
// reaches the browser — only status, path, and size.
|
||||||
|
function updatePlanningKnowledge(k) {
|
||||||
|
const el = document.getElementById('knowBand');
|
||||||
|
if (!el) return;
|
||||||
|
if (!k || k.status === 'idle') { el.innerHTML = ''; return; }
|
||||||
|
const base = (k.path || '').split('/').pop() || k.path || '';
|
||||||
|
let line;
|
||||||
|
if (k.status === 'pending') line = 'loading profile…';
|
||||||
|
else if (k.status === 'ready') line = `grounded by ${base} · ${k.chars} chars`;
|
||||||
|
else if (k.status === 'absent') line = `no profile (${k.path || 'unset'})`;
|
||||||
|
else line = 'profile unreadable';
|
||||||
|
el.innerHTML =
|
||||||
|
`<span class="knowline meta">${line}</span> <button type="button" class="link" id="knowChange">change</button>`;
|
||||||
|
document.getElementById('knowChange').onclick = () => {
|
||||||
|
const next = prompt('Profile file path (blank = default):', k.path || '');
|
||||||
|
if (next !== null) post('/knowledge/path', { path: next.trim() });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function render(state) {
|
function render(state) {
|
||||||
setStateAttr(state);
|
setStateAttr(state);
|
||||||
const rs = state.runtime_state;
|
const rs = state.runtime_state;
|
||||||
if (rs === 'planning' && renderedState === 'planning') {
|
if (rs === 'planning' && renderedState === 'planning') {
|
||||||
updatePlanningCoach(state.coach);
|
updatePlanningCoach(state.coach);
|
||||||
updatePlanningTasks(state.tasks);
|
updatePlanningTasks(state.tasks);
|
||||||
|
updatePlanningKnowledge(state.knowledge);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (rs === 'active' && renderedState === 'active') {
|
if (rs === 'active' && renderedState === 'active') {
|
||||||
@@ -189,6 +212,7 @@ function render(state) {
|
|||||||
<div id="coachStatus" class="meta"></div>
|
<div id="coachStatus" class="meta"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="band" id="tasksBand"></div>
|
<div class="band" id="tasksBand"></div>
|
||||||
|
<div class="band" id="knowBand"></div>
|
||||||
<div class="band">
|
<div class="band">
|
||||||
<label>Next action</label><input id="na" placeholder="What exactly will you do?">
|
<label>Next action</label><input id="na" placeholder="What exactly will you do?">
|
||||||
<label>Success condition</label><input id="sc" placeholder="How do you know it's done?">
|
<label>Success condition</label><input id="sc" placeholder="How do you know it's done?">
|
||||||
@@ -214,6 +238,7 @@ function render(state) {
|
|||||||
};
|
};
|
||||||
updatePlanningCoach(state.coach);
|
updatePlanningCoach(state.coach);
|
||||||
updatePlanningTasks(state.tasks);
|
updatePlanningTasks(state.tasks);
|
||||||
|
updatePlanningKnowledge(state.knowledge);
|
||||||
|
|
||||||
} else if (rs === 'active') {
|
} else if (rs === 'active') {
|
||||||
const c = state.commitment || {};
|
const c = state.commitment || {};
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ func (s *Server) Router() *gin.Engine {
|
|||||||
r.POST("/end", s.handleEnd)
|
r.POST("/end", s.handleEnd)
|
||||||
r.POST("/refocus", s.handleRefocus)
|
r.POST("/refocus", s.handleRefocus)
|
||||||
r.POST("/ontask", s.handleOnTask)
|
r.POST("/ontask", s.handleOnTask)
|
||||||
|
r.POST("/knowledge/path", s.handleKnowledgePath)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +125,24 @@ func (s *Server) handleCommitment(c *gin.Context) {
|
|||||||
s.respond(c, err)
|
s.respond(c, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type knowledgePathRequest struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleKnowledgePath repoints the profile file at runtime (session-only). It
|
||||||
|
// mutates config, not commitment state, so it never returns a transition error;
|
||||||
|
// it just sets the path, re-loads, and broadcasts the refreshed state.
|
||||||
|
func (s *Server) handleKnowledgePath(c *gin.Context) {
|
||||||
|
var req knowledgePathRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.ctrl.SetKnowledgePath(req.Path)
|
||||||
|
s.broadcast()
|
||||||
|
c.Data(http.StatusOK, "application/json", []byte(s.stateJSON()))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleComplete(c *gin.Context) {
|
func (s *Server) handleComplete(c *gin.Context) {
|
||||||
s.cancelExpiry()
|
s.cancelExpiry()
|
||||||
s.respond(c, s.ctrl.Complete())
|
s.respond(c, s.ctrl.Complete())
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"antidrift/internal/ai"
|
"antidrift/internal/ai"
|
||||||
"antidrift/internal/domain"
|
"antidrift/internal/domain"
|
||||||
"antidrift/internal/evidence"
|
"antidrift/internal/evidence"
|
||||||
|
"antidrift/internal/knowledge"
|
||||||
"antidrift/internal/session"
|
"antidrift/internal/session"
|
||||||
"antidrift/internal/tasks"
|
"antidrift/internal/tasks"
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ type stubCoach struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s stubCoach) Coach(ctx context.Context, intent string) (ai.Proposal, error) {
|
func (s stubCoach) Coach(ctx context.Context, intent, grounding string) (ai.Proposal, error) {
|
||||||
return s.prop, s.err
|
return s.prop, s.err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,3 +282,57 @@ func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) {
|
|||||||
t.Fatalf("refocus while Active: want 200, got %d", w.Code)
|
t.Fatalf("refocus while Active: want 200, got %d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type stubSource struct {
|
||||||
|
profile knowledge.Profile
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stubSource) Load(ctx context.Context, path string) (knowledge.Profile, error) {
|
||||||
|
if path != "" {
|
||||||
|
return knowledge.Profile{Text: "from " + path, Path: path}, nil
|
||||||
|
}
|
||||||
|
return s.profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanningStatePayloadCarriesKnowledge(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/home/u/.antidrift/knowledge.md"}})
|
||||||
|
r := s.Router()
|
||||||
|
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("/planning code %d", w.Code)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if kv := s.ctrl.State().Knowledge; kv != nil && kv.Status == "ready" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
js := s.stateJSON()
|
||||||
|
if !strings.Contains(js, `"knowledge"`) || !strings.Contains(js, `"status":"ready"`) {
|
||||||
|
t.Fatalf("planning payload missing knowledge object: %s", js)
|
||||||
|
}
|
||||||
|
if strings.Contains(js, "small diffs") {
|
||||||
|
t.Fatalf("profile text must NOT cross the wire: %s", js)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKnowledgePathSelectionReloads(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}})
|
||||||
|
r := s.Router()
|
||||||
|
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("/planning code %d", w.Code)
|
||||||
|
}
|
||||||
|
if w := post(t, r, "/knowledge/path", `{"path":"/custom/profile.md"}`); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("/knowledge/path code %d body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if kv := s.ctrl.State().Knowledge; kv != nil && kv.Path == "/custom/profile.md" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("path selection did not reload: %+v", s.ctrl.State().Knowledge)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user