Add M6 knowledge-port design spec and implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user