18 KiB
WP-D AI Implementation Plan
For agentic workers: executed inline by the WP-D agent with TDD (failing test first), one commit per task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Opt-in AI for STCourses: a course-aware learner assistant (retrieval, course tools, MCP tools, mentor mode) and a studio generator that writes house-style drafts. AI is off until an admin configures a server provider or a user saves their own key (BYOK).
Architecture:
packages/feature-aiexportsaiFeature(ctx, opts?): Feature,aiWeb: WebFeatureandmigrationsDirs.- Providers implement the frozen
AiProvidercontract with plainfetch(no SDK): Anthropic Messages API (streaming SSE, tool use, default modelclaude-opus-5-5) and OpenAI-compatible chat completions (covers Ollama). - Settings live in the existing
ai_settingsanduser_api_keystables (no new tables). Secrets are AES-GCM encrypted with a key derived by HKDF-SHA-256 fromctx.config.secret; they never leave the server. - Retrieval is an in-process BM25 index per loaded course (course Markdown, transcripts, code, deck/transcript text under
<dataDir>/cache/<courseId>/), plus the requesting user's notes at query time. Solution files are never indexed. - The assistant runs a small tool loop over
search_course,read_lesson,list_notesand admin-configured MCP servers (stdio and streamable HTTP), and streams NDJSON events to the browser. - The generator builds house-style prompts (Pure Snake module shape, Systemic Design beats), parses
=== FILE: … ===blocks from the model output, and hands them to aDraftSink. The default sink writes to<dataDir>/drafts/<courseId>/<runId>/; Phase 2 swaps in the studio'screateDraftWriter(ctx).
Tech Stack: Bun 1.3, TypeScript strict, WebCrypto, Bun.SQL, Bun.spawn, React 19 + React Router 6, @stc/ui primitives.
Spec: sections 9.2, 7, 2 (anti-goals). Brief: .superpowers/sdd/2026-10-30-stcourses/phase-1-brief.md (WP-D).
Global Constraints
Inherited from docs/superpowers/plans/2026-10-30-stcourses.md (Bun only, plain CSS with tokens, both databases, AGPL, never write into the reference course repos, never commit licensed content, keyboard rules, commit trailer). Package-specific:
- AI is off by default. No provider configured and no BYOK key means every AI route answers
enabled: false/ 409, and the UI says how to turn it on. - Keys never go to the client. API responses carry
hasApiKey: boolean, never ciphertext or plaintext. - No real network in tests. Providers take an injectable
fetch; MCP tests use a local fake server (stdio child process andBun.serveon port 0). - Mentor mode is on by default and its rules go into the system prompt; when off, the UI shows "Mentor mode off: I'll give full answers".
- Generated material is never published. The generator only writes through a
DraftSink.
Review Focus
- Secrets: BYOK and server keys are encrypted at rest (AES-GCM, random IV, HKDF key from
STC_SECRET), a wrong secret fails to decrypt, and no route returns key material. Tests: Tasks 1, 2, 9. - Mentor anti-goal: mentor rules are in the system prompt unless the learner switched them off for that course; solution files (
browser.solution,solutions/,reference/,expansion/solution/) are never retrieved or readable by tools. Tests: Tasks 5, 7. - Generation never publishes: drafts land only under the sink location, paths are sanitised (no
.., no absolute paths). Test: Task 8. - Access control: assistant routes require that the user can view the course (
canView); generator routes require owner, theauthorrole or acourse_authorsrow; admin routes require owner. Test: Task 9. - Thinking blocks: the Anthropic provider replays an assistant turn's raw content blocks (thinking, text, tool_use) unchanged inside one tool loop. Test: Task 3.
Task 1: Package scaffold and secret box
Files: packages/feature-ai/{package.json,src/index.ts,src/crypto.ts}, test packages/feature-ai/test/crypto.test.ts
Interfaces (Produces):
-
createSecretBox(secret: string): { encrypt(plain: string): Promise<string>; decrypt(token: string): Promise<string> }. HKDF-SHA-256 (salt"stcourses/byok", info"aes-gcm-256/v1") → AES-GCM-256; tokenv1.<b64 iv>.<b64 ciphertext>. -
migrationsDirs: string[](empty: the package adds no tables). -
Tests: round trip; two encryptions differ (random IV); a box with another secret rejects; a tampered token rejects.
-
Implement; commit
feat(ai): package scaffold and AES-GCM secret box.
Task 2: Settings repository
Files: src/settings.ts, test test/settings.test.ts (uses forEachDb from packages/db/test/helpers.ts)
Interfaces:
-
ProviderKind = "anthropic" | "openai";ProviderConfig = { kind, model?, baseUrl?, effort? }. -
McpServerConfig = { id, name, transport: "stdio" | "http", command?, args?, env?, url?, headers?, courseIds?: string[] }(nocourseIds= global). -
aiSettingsRepo(db, box):getServer(): Promise<{ provider: (ProviderConfig & { hasApiKey }) | null; mcpServers }>,saveServer({ provider: (ProviderConfig & { apiKey?: string; clearApiKey?: boolean }) | null, mcpServers }),serverApiKey(): Promise<string | null>.getUser(userId): Promise<{ byok: (ProviderConfig & { hasApiKey }) | null; mentorOff: Record<courseId, boolean> }>,saveUserKey(userId, cfg & { apiKey }),removeUserKey(userId),userApiKey(userId, kind),setMentorOff(userId, courseId, off),isMentorOff(userId, courseId).
-
Storage:
ai_settingsrow('server','')holds{ provider, apiKeyCipher, mcpServers }; row('user', userId)holds{ byok, mentorOff }. The per-course flag lives inconfig_jsonbecause the frozen primary key is(scope, owner_id); thementor_offcolumn mirrors "off for every course" (always 0 for now). Contract request filed. Keys go inuser_api_keysencrypted. Upserts are select-then-insert-or-update in a transaction. -
Tests (both DBs): empty server config is
nullprovider; saving a key stores ciphertext only (raw row does not contain the key) andserverApiKey()returns it; omittingapiKeyon save keeps the old key; BYOK save/remove; mentor toggle per course is independent. -
Implement; commit
feat(ai): settings repository for server config, BYOK keys and mentor toggles.
Task 3: SSE parsing and the Anthropic provider
Files: src/providers/sse.ts, src/providers/anthropic.ts, test test/anthropic.test.ts
Interfaces:
-
readSse(body: ReadableStream<Uint8Array>): AsyncIterable<{ event: string; data: string }>. -
createAnthropicProvider({ apiKey, model = "claude-opus-5-5", baseUrl = "https://api.anthropic.com", effort?, maxTokens = 64000, fetch? }): AiProvider.- POST
/v1/messageswithstream: true, headersx-api-key,anthropic-version: 2023-06-01. Nothinking,temperatureor forcedtool_choice(Opus 5.5 rejects them). On the first-party endpoint,fallbacks: "default"with beta headerserver-side-fallback-2026-07-01. - Message mapping:
systemmessages join intosystem; an assistantChatMessagewithtoolCallIdandnameis a tool call (content = JSON input);toolmessages becometool_resultblocks; consecutive same-role blocks merge. - The provider remembers each response's full content blocks (thinking with signature, text, tool_use) by tool_use id and replays them unchanged for the matching assistant tool-call messages.
- Streams
{ text }pertext_delta,{ toolCall }per finishedtool_useblock; HTTP errors anderrorevents throw with the API message;stop_reason: "refusal"yields a plain sentence.
- POST
-
Tests with a fake
fetchthat returns canned SSE: text streams in order; a tool_use with splitinput_json_deltayields one parsed toolCall; request body has model, system, tools asinput_schema, nothinking/temperature; a second call after a tool round replays the thinking block with its signature unchanged beforetool_result; non-2xx throws with the error message. -
Implement; commit
feat(ai): Anthropic provider over fetch with streaming tool use.
Task 4: OpenAI-compatible provider
Files: src/providers/openai.ts, src/providers/index.ts, test test/openai.test.ts
Interfaces:
-
createOpenAiProvider({ baseUrl, apiKey?, model, fetch? }): AiProvider. POST<baseUrl>/chat/completions,stream: true, tools as{ type: "function", function: { name, description, parameters } }. Assistant tool-call messages map totool_calls;toolmessages to{ role: "tool", tool_call_id }. Accumulatesdelta.tool_callsby index and emits on finish or[DONE]. -
createProvider(cfg: ProviderConfig, apiKey: string | null, fetchImpl?): AiProvider. -
Tests: text deltas; fragmented tool-call arguments; no
Authorizationheader without a key (Ollama); request mapping of a tool round. -
Implement; commit
feat(ai): OpenAI-compatible provider (covers Ollama).
Task 5: Retrieval
Files: src/retrieval/{bm25.ts,course-index.ts}, test test/retrieval.test.ts
Interfaces:
-
Bm25<T>withadd(text, item),search(query, k): { item, score }[]. -
Chunk = { path, kind: "lesson" | "transcript" | "markdown" | "code" | "deck" | "note", text, heading?, lessonId?, lessonTitle? }. -
buildCourseIndex(course: LoadedCourse, dataDir: string): Promise<CourseIndex>;CourseIndex.search(query, k, extra?: Chunk[]): Chunk[];isSolutionPath(course, rel): boolean. -
getCourseIndex(course, dataDir)caches perLoadedCourseobject (a repository reload yields new objects) with a 5-minute TTL. -
Sources: every
.mdunder the course root (depth-limited, skipping.git,node_modules), manifest code dirs and common code extensions (size cap 200 KB per file, 2 000 files per course),.md/.txt/.vtt/.srt/.jsontext under<dataDir>/cache/<courseId>/. Markdown splits at headings; long chunks split at ~1 500 chars. Paths are mapped to lessons throughfile,transcriptandhints.file(anchor heading when it matches). -
Never indexed:
browser.solutionfiles and any path segmentsolutions,solution,referenceunderexpansion/, orreference/in Systemic-Design style modules (seeisSolutionPath). -
Tests on the
samplefixture: "sum of its arguments" hitslessons/04-exercise.mdwith lessonIdm2-textorm2-ex-add;exercises/add/solution.jsnever appears; a deck text file in a tempcache/sample/decks/d1/page-1.txtis found with kinddeck; note chunks passed asextrarank. -
Implement; commit
feat(ai): BM25 course retrieval over lessons, transcripts, code, deck text and notes.
Task 6: MCP client
Files: src/mcp/client.ts, test test/mcp.test.ts, fixture test/fixtures/fake-mcp.ts
Interfaces:
-
connectMcp(cfg: McpServerConfig, opts?: { fetch?, timeoutMs? }): Promise<McpClient>;McpClient = { tools(): Promise<{ name, description, inputSchema }[]>; call(name, args): Promise<string>; close(): Promise<void> }. -
JSON-RPC 2.0:
initialize(protocolVersion2025-06-18),notifications/initialized,tools/list,tools/call. stdio = newline-delimited JSON overBun.spawn; streamable HTTP = POST withAccept: application/json, text/event-stream, JSON or SSE replies,Mcp-Session-Idechoed. -
mcpServersFor(courseId, servers)selects global servers plus ones listing the course. -
Tests: the fake server over stdio lists
echoandtools/callreturns its text; the same over HTTP (fake viaBun.serveon port 0, replying with SSE fortools/call) with the session id echoed; a server that exits gives a rejected promise, not a hang. -
Implement; commit
feat(ai): MCP client over stdio and streamable HTTP.
Task 7: Assistant core
Files: src/assistant/{prompt.ts,tools.ts,loop.ts}, test test/assistant.test.ts
Interfaces:
-
buildSystemPrompt({ courseTitle, mentorText: string | null, mentorOn, sources: Chunk[], lessonTitle? }): string. With mentor on and text present: a## Course mentor rulessection with the file's text. With mentor off: a line saying full answers are allowed. Sources are numbered[n] path (lesson title)with their text; the prompt asks for citations by path. -
courseTools({ ctx, course, userId, index }):ToolSpec[]plusrun(name, input)forsearch_course,read_lesson(refuses solution files) andlist_notes. -
runAssistant({ provider, messages, tools, signal, maxSteps = 6 }): AsyncIterable<AssistantEvent>whereAssistantEvent = { type: "text", text } | { type: "tool", name } | { type: "error", message } | { type: "done", text }. Tool calls become assistant messages withtoolCallId/nameandtoolresult messages; unknown tools and tool errors are fed back as error text. -
Citations:
citationsFor(chunks): { path, lessonId?, lessonTitle?, kind }[]deduplicated. -
Tests: mentor on puts the sample
MENTOR.mdtext in the system prompt; mentor off omits it and states full answers; a fake provider that callssearch_coursethen answers gets the tool result and the loop ends with text;read_lessonon the exercise returns markdown; solution paths are refused. -
Implement; commit
feat(ai): assistant prompt, course tools and tool loop.
Task 8: Generator and draft sink
Files: src/generator/{templates.ts,parse.ts,generate.ts,draft-sink.ts}, test test/generator.test.ts
Interfaces:
-
DraftSink = { writeDraftFiles(courseId, authorId, files: { path, content }[], message): Promise<{ location: string; paths: string[] }> };createFsDraftSink(dataDir)writes to<dataDir>/drafts/<courseId>/<runId>/plusDRAFT.json(author, message, time). Same signature as the studio'screateDraftWriter(ctx).writeDraftFiles. -
GenerateRequest = { courseId, kind: "document" | "exercise" | "questions", style: "pure-snake" | "systemic-design", topic, sources: ({ kind: "lesson", lessonId } | { kind: "file", path } | { kind: "text", label, text })[], instructions? }. -
buildGeneratorPrompt(req, material, course): house-style system prompt (module shape: why, objectives, concepts, puzzle with "Think about it first" fold, everyday-programming bridge, exercises, hints as nudge / stronger hint / near-solution folds, check your understanding, in production, further reading; or Observe → Predict (+Discussion fold) → Spec → Build (Hint 1 — nudge / Hint 2 — concept / Hint 3 — pseudo-code) → Name it → Expansion Drop (+what this drop measures) → Further reading). Exercises:README.md,starter.js,tests.js(export const testsper theExerciseTestcontract),solution.js, shared fixture JSON across tracks when the course has tracks. Questions: check-your-understanding list with at least one predict and one "what would break if". -
parseFiles(text): { files, warnings }reads=== FILE: <path> ===…=== END FILE ===; rejects absolute and..paths. -
checkHouseStyle(req, files): string[]warnings for missing beats/tiers. -
generateDraft({ provider, req, course, ctx, sink, authorId }): Promise<{ location, paths, warnings }>; every Markdown file starts with a draft marker comment. -
Tests: prompt for each style contains its beats; parse handles two files and rejects
../x; fake provider output written by the fs sink lands under<dataDir>/drafts/sample/…and nothing changes in the course folder; missing tiers give warnings. -
Implement; commit
feat(ai): house-style generator writing drafts through a DraftSink.
Task 9: Feature routes and jobs
Files: src/feature.ts, test test/feature.test.ts
Interfaces: aiFeature(ctx, opts?: { fetch?; draftSink?; providerFactory?(cfg, key): AiProvider }): Feature with:
-
GET /api/ai/status?courseId=→{ enabled, source: "byok" | "server" | null, mentor?: { available, on } }. -
GET|PUT /api/ai/admin/config(owner),POST /api/ai/admin/mcp/test(owner; lists a server's tools). -
GET /api/ai/account,PUT /api/ai/account/key,DELETE /api/ai/account/key(signed in). -
PUT /api/ai/courses/:courseId/mentor{ off }. -
POST /api/ai/courses/:courseId/chat{ message, conversationId? }→application/x-ndjsonstream:{type:"meta", conversationId, mentorOn, citations},text,tool,done,error. Stores user and assistant turns inai_conversations/ai_messages. -
GET /api/ai/courses/:courseId/conversations,GET|DELETE /api/ai/conversations/:id. -
GET /api/ai/studio/sources?courseId=,POST /api/ai/studio/generate(enqueues jobai.generate),GET /api/ai/studio/jobs/:id. -
Job
ai.generaterunsgenerateDraftwith the requesting user's provider. -
Tests through
createAppwith a fakeAuthService: status disabled by default; admin config hides the key; non-owner gets 403; BYOK enables the assistant for that user only; chat with a fake provider streams text andmeta.citationsincludelessons/04-exercise.md; system prompt holds mentor rules, and not afterPUT mentor {off:true}; anonymous on a restricted course gets 404; generate as a learner gets 403; the job handler writes drafts under<dataDir>/drafts/sample. -
Implement; commit
feat(ai): HTTP routes, chat streaming and generation job.
Task 10: Web UI
Files: src/web/{index.ts,api.ts,AssistantPage.tsx,AdminAiPage.tsx,AccountAiPage.tsx,GeneratePage.tsx,ai.css}, apps/web/src/features.ts (one line), test test/web.test.tsx
-
aiWeb: WebFeatureroutes/c/:courseId/assistant,/admin/ai(nav admin "AI"),/account/ai,/studio/generate. -
Assistant: course name, mentor segmented toggle, the plain banner "Mentor mode off: I'll give full answers" when off, conversation rows, sources as rows with paths, composer with a primary Send pill. Disabled state explains how to enable AI.
-
Admin: provider kind, model, base URL, write-only key, MCP server rows. Account: BYOK form and remove. Generate: course, kind, style, sources checklist, topic, instructions, job progress, list of draft files with location.
-
readNdjson(stream)client helper. -
Tests:
readNdjsonsplits across chunk boundaries;renderToStringof the banner component shows the exact sentence when off and nothing when on. -
Implement, check in headless Chrome via
packages/feature-ai/scripts/dev.tson port 3464; commitfeat(ai): assistant panel, AI settings and studio generator UI.
Task 11: Contract requests and report
docs/contract-requests/ai.md: feature factories instartServer; tool-call fields onChatMessage; per-courseai_settingskey; studiocreateDraftWriterwiring; cache layout for deck text and transcripts; learner lesson URL.- Run
bun test,bun run typecheck, the Postgres run; writewp-ai-report.md.