diff --git a/docs/superpowers/plans/2026-10-30-ai.md b/docs/superpowers/plans/2026-10-30-ai.md index 6578ce5..14c457d 100644 --- a/docs/superpowers/plans/2026-10-30-ai.md +++ b/docs/superpowers/plans/2026-10-30-ai.md @@ -44,8 +44,8 @@ Inherited from `docs/superpowers/plans/2026-10-30-stcourses.md` (Bun only, plain - `createSecretBox(secret: string): { encrypt(plain: string): Promise; decrypt(token: string): Promise }`. HKDF-SHA-256 (salt `"stcourses/byok"`, info `"aes-gcm-256/v1"`) → AES-GCM-256; token `v1..`. - `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`. +- [x] Tests: round trip; two encryptions differ (random IV); a box with another secret rejects; a tampered token rejects. +- [x] Implement; commit `feat(ai): package scaffold and AES-GCM secret box`. ### Task 2: Settings repository @@ -59,8 +59,8 @@ Inherited from `docs/superpowers/plans/2026-10-30-stcourses.md` (Bun only, plain - `getUser(userId): Promise<{ byok: (ProviderConfig & { hasApiKey }) | null; mentorOff: Record }>`, `saveUserKey(userId, cfg & { apiKey })`, `removeUserKey(userId)`, `userApiKey(userId, kind)`, `setMentorOff(userId, courseId, off)`, `isMentorOff(userId, courseId)`. - Storage: `ai_settings` row `('server','')` holds `{ provider, apiKeyCipher, mcpServers }`; row `('user', userId)` holds `{ byok, mentorOff }`. The per-course flag lives in `config_json` because the frozen primary key is `(scope, owner_id)`; the `mentor_off` column mirrors "off for every course" (always 0 for now). Contract request filed. Keys go in `user_api_keys` encrypted. Upserts are select-then-insert-or-update in a transaction. -- [ ] Tests (both DBs): empty server config is `null` provider; saving a key stores ciphertext only (raw row does not contain the key) and `serverApiKey()` returns it; omitting `apiKey` on 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`. +- [x] Tests (both DBs): empty server config is `null` provider; saving a key stores ciphertext only (raw row does not contain the key) and `serverApiKey()` returns it; omitting `apiKey` on save keeps the old key; BYOK save/remove; mentor toggle per course is independent. +- [x] Implement; commit `feat(ai): settings repository for server config, BYOK keys and mentor toggles`. ### Task 3: SSE parsing and the Anthropic provider @@ -74,8 +74,8 @@ Inherited from `docs/superpowers/plans/2026-10-30-stcourses.md` (Bun only, plain - 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 }` per `text_delta`, `{ toolCall }` per finished `tool_use` block; HTTP errors and `error` events throw with the API message; `stop_reason: "refusal"` yields a plain sentence. -- [ ] Tests with a fake `fetch` that returns canned SSE: text streams in order; a tool_use with split `input_json_delta` yields one parsed toolCall; request body has model, system, tools as `input_schema`, no `thinking`/`temperature`; a second call after a tool round replays the thinking block with its signature unchanged before `tool_result`; non-2xx throws with the error message. -- [ ] Implement; commit `feat(ai): Anthropic provider over fetch with streaming tool use`. +- [x] Tests with a fake `fetch` that returns canned SSE: text streams in order; a tool_use with split `input_json_delta` yields one parsed toolCall; request body has model, system, tools as `input_schema`, no `thinking`/`temperature`; a second call after a tool round replays the thinking block with its signature unchanged before `tool_result`; non-2xx throws with the error message. +- [x] Implement; commit `feat(ai): Anthropic provider over fetch with streaming tool use`. ### Task 4: OpenAI-compatible provider @@ -85,8 +85,8 @@ Inherited from `docs/superpowers/plans/2026-10-30-stcourses.md` (Bun only, plain - `createOpenAiProvider({ baseUrl, apiKey?, model, fetch? }): AiProvider`. POST `/chat/completions`, `stream: true`, tools as `{ type: "function", function: { name, description, parameters } }`. Assistant tool-call messages map to `tool_calls`; `tool` messages to `{ role: "tool", tool_call_id }`. Accumulates `delta.tool_calls` by index and emits on finish or `[DONE]`. - `createProvider(cfg: ProviderConfig, apiKey: string | null, fetchImpl?): AiProvider`. -- [ ] Tests: text deltas; fragmented tool-call arguments; no `Authorization` header without a key (Ollama); request mapping of a tool round. -- [ ] Implement; commit `feat(ai): OpenAI-compatible provider (covers Ollama)`. +- [x] Tests: text deltas; fragmented tool-call arguments; no `Authorization` header without a key (Ollama); request mapping of a tool round. +- [x] Implement; commit `feat(ai): OpenAI-compatible provider (covers Ollama)`. ### Task 5: Retrieval @@ -100,8 +100,8 @@ Inherited from `docs/superpowers/plans/2026-10-30-stcourses.md` (Bun only, plain - Sources: every `.md` under 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/.json` text under `/cache//`. Markdown splits at headings; long chunks split at ~1 500 chars. Paths are mapped to lessons through `file`, `transcript` and `hints.file` (anchor heading when it matches). - Never indexed: `browser.solution` files and any path segment `solutions`, `solution`, `reference` under `expansion/`, or `reference/` in Systemic-Design style modules (see `isSolutionPath`). -- [ ] Tests on the `sample` fixture: "sum of its arguments" hits `lessons/04-exercise.md` with lessonId `m2-text` or `m2-ex-add`; `exercises/add/solution.js` never appears; a deck text file in a temp `cache/sample/decks/d1/page-1.txt` is found with kind `deck`; note chunks passed as `extra` rank. -- [ ] Implement; commit `feat(ai): BM25 course retrieval over lessons, transcripts, code, deck text and notes`. +- [x] Tests on the `sample` fixture: "sum of its arguments" hits `lessons/04-exercise.md` with lessonId `m2-text` or `m2-ex-add`; `exercises/add/solution.js` never appears; a deck text file in a temp `cache/sample/decks/d1/page-1.txt` is found with kind `deck`; note chunks passed as `extra` rank. +- [x] Implement; commit `feat(ai): BM25 course retrieval over lessons, transcripts, code, deck text and notes`. ### Task 6: MCP client @@ -112,8 +112,8 @@ Inherited from `docs/superpowers/plans/2026-10-30-stcourses.md` (Bun only, plain - JSON-RPC 2.0: `initialize` (protocolVersion `2025-06-18`), `notifications/initialized`, `tools/list`, `tools/call`. stdio = newline-delimited JSON over `Bun.spawn`; streamable HTTP = POST with `Accept: application/json, text/event-stream`, JSON or SSE replies, `Mcp-Session-Id` echoed. - `mcpServersFor(courseId, servers)` selects global servers plus ones listing the course. -- [ ] Tests: the fake server over stdio lists `echo` and `tools/call` returns its text; the same over HTTP (fake via `Bun.serve` on port 0, replying with SSE for `tools/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`. +- [x] Tests: the fake server over stdio lists `echo` and `tools/call` returns its text; the same over HTTP (fake via `Bun.serve` on port 0, replying with SSE for `tools/call`) with the session id echoed; a server that exits gives a rejected promise, not a hang. +- [x] Implement; commit `feat(ai): MCP client over stdio and streamable HTTP`. ### Task 7: Assistant core @@ -125,8 +125,8 @@ Inherited from `docs/superpowers/plans/2026-10-30-stcourses.md` (Bun only, plain - `runAssistant({ provider, messages, tools, signal, maxSteps = 6 }): AsyncIterable` where `AssistantEvent = { type: "text", text } | { type: "tool", name } | { type: "error", message } | { type: "done", text }`. Tool calls become assistant messages with `toolCallId`/`name` and `tool` result 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.md` text in the system prompt; mentor off omits it and states full answers; a fake provider that calls `search_course` then answers gets the tool result and the loop ends with text; `read_lesson` on the exercise returns markdown; solution paths are refused. -- [ ] Implement; commit `feat(ai): assistant prompt, course tools and tool loop`. +- [x] Tests: mentor on puts the sample `MENTOR.md` text in the system prompt; mentor off omits it and states full answers; a fake provider that calls `search_course` then answers gets the tool result and the loop ends with text; `read_lesson` on the exercise returns markdown; solution paths are refused. +- [x] Implement; commit `feat(ai): assistant prompt, course tools and tool loop`. ### Task 8: Generator and draft sink @@ -140,8 +140,8 @@ Inherited from `docs/superpowers/plans/2026-10-30-stcourses.md` (Bun only, plain - `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 `/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`. +- [x] 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 `/drafts/sample/…` and nothing changes in the course folder; missing tiers give warnings. +- [x] Implement; commit `feat(ai): house-style generator writing drafts through a DraftSink`. ### Task 9: Feature routes and jobs @@ -157,8 +157,8 @@ Inherited from `docs/superpowers/plans/2026-10-30-stcourses.md` (Bun only, plain - `GET /api/ai/studio/sources?courseId=`, `POST /api/ai/studio/generate` (enqueues job `ai.generate`), `GET /api/ai/studio/jobs/:id`. - Job `ai.generate` runs `generateDraft` with the requesting user's provider. -- [ ] Tests through `createApp` with a fake `AuthService`: 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 and `meta.citations` include `lessons/04-exercise.md`; system prompt holds mentor rules, and not after `PUT mentor {off:true}`; anonymous on a restricted course gets 404; generate as a learner gets 403; the job handler writes drafts under `/drafts/sample`. -- [ ] Implement; commit `feat(ai): HTTP routes, chat streaming and generation job`. +- [x] Tests through `createApp` with a fake `AuthService`: 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 and `meta.citations` include `lessons/04-exercise.md`; system prompt holds mentor rules, and not after `PUT mentor {off:true}`; anonymous on a restricted course gets 404; generate as a learner gets 403; the job handler writes drafts under `/drafts/sample`. +- [x] Implement; commit `feat(ai): HTTP routes, chat streaming and generation job`. ### Task 10: Web UI @@ -169,10 +169,10 @@ Inherited from `docs/superpowers/plans/2026-10-30-stcourses.md` (Bun only, plain - 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: `readNdjson` splits across chunk boundaries; `renderToString` of the banner component shows the exact sentence when off and nothing when on. -- [ ] Implement, check in headless Chrome via `packages/feature-ai/scripts/dev.ts` on port 3464; commit `feat(ai): assistant panel, AI settings and studio generator UI`. +- [x] Tests: `readNdjson` splits across chunk boundaries; `renderToString` of the banner component shows the exact sentence when off and nothing when on. +- [x] Implement, check in headless Chrome via `packages/feature-ai/scripts/dev.ts` on port 3464; commit `feat(ai): assistant panel, AI settings and studio generator UI`. ### Task 11: Contract requests and report -- [ ] `docs/contract-requests/ai.md`: feature factories in `startServer`; tool-call fields on `ChatMessage`; per-course `ai_settings` key; studio `createDraftWriter` wiring; cache layout for deck text and transcripts; learner lesson URL. -- [ ] Run `bun test`, `bun run typecheck`, the Postgres run; write `wp-ai-report.md`. +- [x] `docs/contract-requests/ai.md`: feature factories in `startServer`; tool-call fields on `ChatMessage`; per-course `ai_settings` key; studio `createDraftWriter` wiring; cache layout for deck text and transcripts; learner lesson URL. +- [x] Run `bun test`, `bun run typecheck`, the Postgres run; write `wp-ai-report.md`.