Skip to content

Commit 294eca0

Browse files
DavidBabinecclaude
andcommitted
ai runtime: provider-agnostic stack, settings UI, site editor rewire
Replaces the ambient-only Claude Agent SDK handler with a unified AI runtime at `server/ai/`: - drivers: Anthropic (claude-agent-sdk), OpenAI (@openai/agents), Ollama placeholder. All API-key authed; selected via a per-call `OpenAIProvider` / `Options.env` injection — never the process env. - credentials: AES-256-GCM at-rest encryption via Bun's crypto.subtle; master key from PAGE_BUILDER_SECRET_KEY env (dev fallback to .tmp/secret.key). CredentialView is the only shape that crosses the wire (gated by ai-credentials-never-leak). - tools: site-scope toolset (port of the 22 page-builder tools) as TypeBox `AiTool[]`, scoped via `selectToolsForScope`. Anthropic driver translates TypeBox → Zod for the SDK's tool() API; OpenAI driver translates straight to JSON Schema. - conversations: ai_conversations + ai_messages tables (per user + scope), with a nightly purge tick for soft-deleted rows > 30d. - handlers: POST /admin/api/ai/chat/:scope (NDJSON stream), /tool-result, /credentials CRUD, /conversations CRUD, /providers/:id/models, /defaults/:scope. All capability-gated. - admin UI: /admin/ai workspace with Providers + Defaults tabs; site editor's AI panel now POSTs /admin/api/ai/chat/site with a lazily-created conversation row and a model/credential picker sourced from the site default. Popovers use the existing ContextMenu primitive for proper anchoring + auto-flip. - three new capabilities: ai.use, ai.providers.manage, ai.audit.read. - architecture gates: ai-driver-isolation, ai-credentials-never-leak, ai-tools-typebox-only, ai-handlers-capability-gated. The legacy `server/handlers/agent/*` and its five gate tests are deleted; their behaviour now lives in `server/ai/` + the new gates. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 0bf08c8 commit 294eca0

78 files changed

Lines changed: 9902 additions & 2346 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bun.lock

Lines changed: 146 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/plans/2026-05-26-ai-runtime-rewrite.md

Lines changed: 981 additions & 0 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,19 @@
4646
"@fontsource-variable/inter": "^5.2.8",
4747
"@lezer/highlight": "^1.2.3",
4848
"@modelcontextprotocol/sdk": "^1.29.0",
49+
"@openai/agents": "^0.11.5",
50+
"@openai/codex-sdk": "^0.134.0",
4951
"@sinclair/typebox": "^0.34.49",
52+
"@tiptap/core": "^3",
53+
"@tiptap/extension-table": "^3",
54+
"@tiptap/extension-table-cell": "^3",
55+
"@tiptap/extension-table-header": "^3",
56+
"@tiptap/extension-table-row": "^3",
57+
"@tiptap/extensions": "^3",
58+
"@tiptap/pm": "^3",
59+
"@tiptap/react": "^3",
60+
"@tiptap/starter-kit": "^3",
61+
"@tiptap/suggestion": "^3",
5062
"@use-gesture/react": "^10.3.1",
5163
"blurhash": "^2.0.5",
5264
"codemirror": "^6.0.2",

scripts/generate-secret-key.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Generate a fresh 32-byte (256-bit) base64 AES key suitable for
4+
* `PAGE_BUILDER_SECRET_KEY`.
5+
*
6+
* Usage:
7+
*
8+
* bun run scripts/generate-secret-key.ts
9+
*
10+
* Prints the key (and a setup hint) to stdout. The key is generated via
11+
* `crypto.getRandomValues`, never written to disk by this script — that's
12+
* the operator's job (env var, secret manager, etc.).
13+
*
14+
* @see server/ai/credentials/masterKey.ts
15+
* @see docs/plans/2026-05-26-ai-runtime-rewrite.md → "Encryption"
16+
*/
17+
18+
const KEY_BYTE_LENGTH = 32
19+
20+
function bytesToBase64(bytes: Uint8Array): string {
21+
let binary = ''
22+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!)
23+
return btoa(binary)
24+
}
25+
26+
const keyBytes = crypto.getRandomValues(new Uint8Array(KEY_BYTE_LENGTH))
27+
const key = bytesToBase64(keyBytes)
28+
29+
process.stdout.write(`${key}\n`)
30+
31+
if (process.stderr.isTTY) {
32+
process.stderr.write(
33+
`\nGenerated a new 256-bit master key for the AI credential store.\n` +
34+
`Add it to your environment to use it:\n\n` +
35+
` export PAGE_BUILDER_SECRET_KEY=${key}\n\n` +
36+
`Or set it in your deployment's secret manager. Without it, AI ` +
37+
`credentials cannot be decrypted (existing rows become unreadable ` +
38+
`if the key is lost).\n`,
39+
)
40+
}

server/ai/boot.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* AI runtime boot hooks.
3+
*
4+
* - `startConversationPurgeTick(db)` — registers a `setInterval` that
5+
* hard-deletes soft-deleted conversations older than 30 days.
6+
*
7+
* Called from `server/index.ts` after migrations + system role sync.
8+
*/
9+
10+
import type { DbClient } from '../db/client'
11+
import { purgeSoftDeletedOlderThan } from './conversations/store'
12+
13+
const ONE_HOUR_MS = 60 * 60 * 1000
14+
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000
15+
16+
// ---------------------------------------------------------------------------
17+
// Conversation purge tick
18+
// ---------------------------------------------------------------------------
19+
20+
let purgeTimer: ReturnType<typeof setInterval> | null = null
21+
22+
/**
23+
* Run the purge once immediately, then every hour. Safe to call repeatedly
24+
* — second-and-later calls are no-ops.
25+
*
26+
* Per-tick work is bounded: the DELETE walks only soft-deleted rows
27+
* (`ai_conv_deleted_idx` partial index). A backlog of weeks of soft-deleted
28+
* conversations would still finish well inside a single tick.
29+
*/
30+
export function startConversationPurgeTick(db: DbClient): void {
31+
if (purgeTimer) return
32+
// Fire-and-forget — never propagate the purge error to anyone.
33+
const runOnce = () => {
34+
runPurgeOnce(db).catch((err) => {
35+
console.error('[ai/boot] purge tick failed:', err)
36+
})
37+
}
38+
runOnce()
39+
purgeTimer = setInterval(runOnce, ONE_HOUR_MS)
40+
}
41+
42+
/** Test-only. */
43+
export function __stopConversationPurgeTickForTesting(): void {
44+
if (purgeTimer) {
45+
clearInterval(purgeTimer)
46+
purgeTimer = null
47+
}
48+
}
49+
50+
async function runPurgeOnce(db: DbClient): Promise<void> {
51+
const cutoff = new Date(Date.now() - THIRTY_DAYS_MS).toISOString()
52+
const count = await purgeSoftDeletedOlderThan(db, cutoff)
53+
if (count > 0) {
54+
console.log(`[ai/boot] Purged ${count} soft-deleted conversation(s) older than 30 days.`)
55+
}
56+
}

0 commit comments

Comments
 (0)