# 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 `; `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.