Files
antidrift/docs/superpowers/specs/2026-06-01-m6-knowledge-port-design.md
T
2026-06-01 07:58:55 -04:00

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 — 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.

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).
// 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): readygrounded by <basename(path)>; absentno profile (~/.antidrift/knowledge.md); errorprofile unreadable; pendingloading 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 (pendingready, pendingabsent on empty text, pendingerror 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.