diff --git a/docs/superpowers/plans/2026-10-30-v11-ai.md b/docs/superpowers/plans/2026-10-30-v11-ai.md new file mode 100644 index 0000000..845f6d4 --- /dev/null +++ b/docs/superpowers/plans/2026-10-30-v11-ai.md @@ -0,0 +1,94 @@ +# STCourses v1.1 — Work package A: deferred AI contract requests 3–5 + +> **For agentic workers:** executed inline with superpowers:executing-plans and TDD. Steps use checkbox (`- [ ]`) syntax. One commit per task. + +**Goal:** settle AI contract requests 3, 4 and 5 (`docs/contract-requests/ai.md`) and the v1 deferred minor "MCP `env`/`headers` in plaintext": +- (3) structured tool calls on `ChatMessage`, with the Anthropic and OpenAI providers mapping them natively; +- (4) per-course mentor toggles in their own table, migrated from `ai_settings.config_json`; +- (5) a citations column on `ai_messages`, migrated from the v1 `sources` rows; +- MCP `env` and `headers` encrypted at rest (same AES-GCM/HKDF box as BYOK keys) and only ever returned masked. + +**Spec:** section 9.2. **Parent plan:** `docs/superpowers/plans/2026-10-30-v1-1.md` (Global Constraints, package A). + +## Global Constraints + +- Everything in `CLAUDE.md` "Project rules" and the v1.1 Global Constraints applies. +- Every DB change is a new portable migration in `packages/feature-ai/migrations/` that runs unchanged on SQLite and Postgres, and upgrades a database already on the v1 schema. File names carry the `ai` prefix (core `migrate()` requires unique names across packages). +- An upgrade test seeds v1 rows (core `0001_init.sql` only), runs the AI migrations and the data upgrade, and asserts the result, on SQLite and on Postgres (`STC_TEST_PG_URL`). +- v1 safeguards that must keep holding (each already has tests; they stay green and unchanged in meaning): + - BYOK keys only reach the official endpoints or allowlisted origins, without redirects; + - error bodies from BYOK calls are never echoed to the user; + - stdio MCP processes get PATH, HOME, LANG plus exactly the configured variables; + - the solution guard works on canonical paths; + - the banner text is exactly "Mentor mode off: I'll give full answers"; + - tests use fakes only, no network. +- Gates per task: `bun test`, `bun run typecheck`, and the Postgres run with `STC_TEST_PG_URL=postgres://postgres:stc@127.0.0.1:55433/postgres`. +- Manual check: dev server on port 3493 with `STC_DATA_DIR=/tmp/stc-v11-ai`. Never port 3480 or `/home/user/STCourses/data`. + +## Design decisions + +1. **Schema vs data migrations.** Pure-SQL migrations cover what portable SQL can do: the new table, the new column, and folding v1 `sources` rows into `citations_json` (a correlated subquery that runs on both engines). Two upgrades cannot be portable SQL: reading `config_json.mentorOff` needs engine-specific JSON functions, and encrypting MCP secrets needs `STC_SECRET`. These run in code, in `upgradeAiData(db, box)`: one transaction, idempotent, recorded in `schema_migrations` under the name `ai_data_v11` (no `.sql` suffix, so it cannot clash with a file). The settings repo awaits it once (memoised; retried after a failure) before its first read or write, and `aiFeature` starts it at construction so plaintext does not linger until the first request. +2. **Citations column name.** `ai_messages.citations_json TEXT NULL` (the parent plan asks for "a citations column"; a typed name beats a generic `meta_json`). v1 `sources` rows are folded into the assistant row immediately before them (by id order, the order v1 reads in) and then deleted. +3. **Mentor table.** `ai_mentor_prefs (user_id, course_id, off INTEGER, updated_at, PK (user_id, course_id))`. A row exists only while the mentor is off (turning it back on deletes the row). `ai_settings.mentor_off` stays reserved for "off everywhere" and is not read. The upgrade copies every `mentorOff[courseId] === true` and removes the `mentorOff` key from the user's `config_json`. +4. **Tool calls on `ChatMessage`.** Contract change in `@stc/contracts`: + - `ToolCall = { id, name, input }`; + - `ChatMessage.toolCalls?: ToolCall[]` (assistant turn) and `ChatMessage.providerData?: unknown` (opaque, round-tripped by the provider that produced it); + - `AiProvider.chat` may also yield `{ providerData }` once per turn. + The assistant loop appends one assistant message per model turn (`content` = the turn's text, `toolCalls`, `providerData`), then one `tool` message per call (`toolCallId`, `name`). The v1 one-message-per-call encoding (`assistant` + `toolCallId`) is removed. + - Anthropic: `providerData = { anthropic: }` (thinking with signature, text, tool_use). An assistant message carrying it is replayed verbatim; otherwise it maps to text + `tool_use` blocks. The in-memory `rawByToolId` map goes away, so a provider instance holds no conversation state. + - OpenAI: assistant `toolCalls` map to `tool_calls` (`function.arguments` = JSON of input) with `content` = text or null. No `providerData`. +5. **MCP secrets at rest.** In `config_json` each MCP server keeps its non-secret fields; `env` and `headers` move into `secretCipher` = `box.encrypt(JSON.stringify({ env, headers }))`. The admin GET returns `env` and `headers` with the keys and masked values: `••••` plus the last 4 characters when the value has at least 12 characters, otherwise `••••••••`. On save, a posted value equal to the mask of the stored value for the same server id and key keeps the stored value; any other value starting with `••••` is refused with 400 ("re-enter the value"), so a mask is never stored as a secret. The MCP test endpoint resolves masks the same way. Only server code gets full values (`mcpServersWithSecrets()`). If the secret cannot be decrypted (changed `STC_SECRET`), the server is listed without `env`/`headers` and the client starts it without them. + +## Tasks + +### Task 1: AI migrations folder, test helper and citations column + +**Files:** +- Create: `packages/feature-ai/migrations/0001_ai_message_citations.sql` +- Modify: `packages/feature-ai/src/index.ts` (`migrationsDirs`), `packages/feature-ai/scripts/dev.ts` (migrate with the AI folder), `packages/feature-ai/src/feature.ts` (write/read `citations_json`, no more `sources` rows) +- Test: `packages/feature-ai/test/upgrade.test.ts` (new), `test/helpers.ts` (`forEachAiDb`), `test/feature.test.ts` + +- [ ] Write `upgrade.test.ts`: on a v1 database (core migrations only) seed a conversation with user, assistant, sources, user, assistant (no sources), and an orphan sources row; run `migrate(db, { "feature-ai": migrationsDirs })`; assert the first assistant row has the citations JSON, the second has NULL, no `sources` rows remain, and a second migrate applies nothing. Add a feature test that a stored assistant message has `citations_json` set and no `sources` row is written. +- [ ] Run: fails (no migrations folder). +- [ ] Implement the SQL (`ALTER TABLE ai_messages ADD COLUMN citations_json TEXT`; correlated `UPDATE` picking the next message by id when it is a `sources` row; `DELETE ... WHERE role = 'sources'`), export the folder, switch `feature.ts` to the column, switch AI tests that touch the DB to `forEachAiDb`. +- [ ] Run tests on SQLite and Postgres; typecheck; commit `feat(ai): citations column on ai_messages (contract request 5)`. + +### Task 2: Mentor preferences table and the data upgrade runner + +**Files:** +- Create: `packages/feature-ai/migrations/0002_ai_mentor_prefs.sql`, `packages/feature-ai/src/upgrade.ts` +- Modify: `packages/feature-ai/src/settings.ts`, `src/feature.ts`, `src/index.ts` +- Test: `test/upgrade.test.ts`, `test/settings.test.ts` + +- [ ] Tests: v1 user rows with `mentorOff: { sample: true, other: false }` plus a BYOK provider become one `ai_mentor_prefs` row (`u1`, `sample`) and a `config_json` without `mentorOff` but with `byok` intact; `isMentorOff` reads the table; running the upgrade twice changes nothing; a row with invalid JSON is left alone. Settings test: toggling writes and deletes table rows and never touches `config_json.mentorOff`. +- [ ] Run: fails. +- [ ] Implement table SQL, `upgradeAiData(db, box)` (mentor part plus the `ai_data_v11` marker), repo `ready()` memo, mentor methods on the table, `getUser().mentorOff` from the table. +- [ ] Gates; commit `feat(ai): per-course mentor preferences table (contract request 4)`. + +### Task 3: Encrypt MCP env and headers at rest, masked in the UI + +**Files:** +- Modify: `src/settings.ts`, `src/upgrade.ts`, `src/feature.ts` (admin PUT 400 on stale masks, chat uses `mcpServersWithSecrets`, MCP test route resolves masks), `src/web/AdminAiPage.tsx` (one-line hint) +- Test: `test/upgrade.test.ts`, `test/settings.test.ts`, `test/feature.test.ts` + +- [ ] Tests: v1 server row with plaintext `env`/`headers` is encrypted by the upgrade (raw `config_json` no longer contains the values; `mcpServersWithSecrets()` returns them); `getServer()` returns masked values only; saving the masked values back keeps the stored secrets; saving a new value replaces it; a stray `••••` value is refused; the admin config API never contains a secret value; the MCP test route with masked values reaches the server with the real header (fake fetch). +- [ ] Run: fails. +- [ ] Implement. +- [ ] Gates; commit `feat(ai): encrypt MCP env and headers at rest and mask them in the admin API`. + +### Task 4: Structured tool calls on ChatMessage + +**Files:** +- Modify: `packages/contracts/src/ai.ts`, `packages/contracts/test/types.test.ts`, `packages/feature-ai/src/assistant/loop.ts`, `src/providers/anthropic.ts`, `src/providers/openai.ts` +- Test: `test/anthropic.test.ts`, `test/openai.test.ts`, `test/assistant.test.ts`, `test/helpers.ts` + +- [ ] Tests: the loop appends one assistant message with `toolCalls` (two calls in one turn) and the provider's `providerData`, then a tool message per call; Anthropic replays `providerData` blocks verbatim (thinking signature intact) and maps a message without it to text + `tool_use` blocks; a fresh provider instance replays history it did not produce (no in-memory state); OpenAI maps `toolCalls` to one assistant message with `tool_calls` and text content. +- [ ] Run: fails. +- [ ] Implement contract fields, provider mapping, loop change; remove `rawByToolId` and the legacy encoding. +- [ ] Gates; commit `feat(ai): structured tool calls on ChatMessage (contract request 3)`. + +### Task 5: Docs and manual check + +- [ ] Mark requests 3–5 settled in `docs/contract-requests/ai.md`; update `settings.ts`/`index.ts` comments. +- [ ] Dev server on 3493 with `STC_DATA_DIR=/tmp/stc-v11-ai`: migrations apply on a fresh DB, admin config shows masked MCP values. +- [ ] Full gates; commit `docs(ai): contract requests 3-5 settled in v1.1`.