Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
14 KiB
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:
// 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— theSourceinterface and theProfilevalue type.file.go— theFileSourceadapter (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.
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.
pathif non-empty, elses.defaultPath, else the built-in default~/.antidrift/knowledge.md.~is expanded. The resolved absolute path is returned inProfile.Pathregardless of outcome, so the indicator can always show where it looked. - Missing file (
os.IsNotExist) →Profile{Path: resolved}with emptyTextand 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 KiBto 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, likeSetTasks. A nil source turns the feature off (no indicator, ungrounded coach).- New
Controllerfields, 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, andknowledgeGen int(the generation counter). EnterPlanning()callsstartKnowledgeFetchLocked()right afterstartTasksFetchLocked(): bumpknowledgeGen, setpending, launch a goroutine that callsLoad(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 setsknowledgeStatustoready(orabsentwhenText == ""), cachesknowledgeText/knowledgePath/knowledgeChars, andnotify()s. Knowledge is never loaded on the synchronousState()path.State()projects a*KnowledgeViewonly while planning, beside the existingCoachViewandTasksView. 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).
// 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:
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/pathwith apathfield, which callsctrl.SetKnowledgePathand 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.gogains a knowledge-adapter block parallel to thetasksblock: readANTIDRIFT_KNOWLEDGE_FILE, constructknowledge.NewFileSource(...), callctrl.SetKnowledge(...), and log one line. Construction never fails; a bad path only surfaces (asabsent/error) at load time.
6. Testing
knowledgepackage: adapter tests against temp files — a present file (text + resolved path returned), an absent file (emptyText, no error, path still reported), an explicitpathoverride 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. stdlibtestingonly; no process spawned.aipackage:buildPromptincludes the## About the userblock 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 newCoachsignature (grounding""), staying green.sessionpackage: with a fakeSource, assertknowledgeStatustransitions (pending→ready,pending→absenton empty text,pending→erroron failure) and thatState().Knowledgereflects them while planning and is absent otherwise / with a nil source. AssertRequestCoachpasses the cachedknowledgeTextto 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 thatSetKnowledgePathre-fetches. A nil source yields noKnowledgeViewand an ungrounded coach.webpackage: 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 thatPOST /knowledge/pathupdates the selected path and triggers a re-load.go vet ./... && go test -race ./...stays clean;knowledgestays a leaf package (imports onlycontext+ stdlibos/io/path/strings/unicode— nothing fromdomain/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 oram-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.