diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..79c137e --- /dev/null +++ b/PLAN.md @@ -0,0 +1,290 @@ +# Plan: Ask Feature 10x + +## Overview + +Four improvements to the post-deployment `ask` feature: + +1. **LLM Synthesis** — replace deterministic `pickAnswer()` with Claude on the server side +2. **Session Transcript at Deploy** — automatically capture and upload the Claude Code session context when deploying +3. **Git/JJ Diff at Deploy** — capture actual VCS diff in the CLI and store it with the deployment +4. **Real AST Parsing** — replace regex symbol extraction with `acorn` + `acorn-typescript` + +--- + +## Feature 1: LLM Synthesis + +Replace the heuristic `pickAnswer()` in `ask-project.ts` with a real Claude API call. + +### What changes + +**`apps/control-plane/src/types.ts`** +- Add `ANTHROPIC_API_KEY?: string` to `Bindings` + +**`apps/control-plane/wrangler.toml`** +- Add comment: `# - ANTHROPIC_API_KEY # Anthropic API key for ask_project synthesis` + +**`apps/control-plane/package.json`** +- Add `@anthropic-ai/sdk` dependency + +**`apps/control-plane/src/ask-project.ts`** +- Add `synthesizeWithLLM(evidence, question, opts?)` function: + - Formats evidence as a structured markdown block for Claude + - Includes session transcript excerpt (if available) and VCS diff stat (if available) + - Calls `claude-haiku-4-5-20251001` via Anthropic SDK + - Returns `{ answer: string, root_cause?: string, suggested_fix?: string, confidence: "high" | "medium" | "low" }` + - Falls back to `pickAnswer()` if `ANTHROPIC_API_KEY` is not set or the call fails +- Update `answerProjectQuestion()` to call `synthesizeWithLLM()` instead of `pickAnswer()` +- Keep `pickAnswer()` as the fallback +- Extend API response shape to include `root_cause?`, `suggested_fix?`, `confidence` + +### Prompt structure for Claude + +``` +You are a debugging assistant for deployed Cloudflare Workers projects. +Given evidence collected about a project, answer the user's question concisely. + +Question: + +Evidence: + + +[If session transcript available]: +Context from deploy session (last 30 messages): + + +[If VCS diff available]: +Code changed in this deployment: + + +Respond with JSON: +{ + "answer": "...", + "root_cause": "..." | null, + "suggested_fix": "..." | null, + "confidence": "high" | "medium" | "low" +} +``` + +--- + +## Feature 2: Session Transcript at Deploy + +Automatically capture the Claude Code session transcript at deploy time and store it as a deployment artifact. The `ask` feature then uses it as context. + +### Mechanism + +Two paths, both automatic (no agent opt-in): + +**Path A — CLI deploy** (`jack deploy` run as a Bash tool by Claude Code): +- Extend the existing `SessionStart` hook in `installClaudeCodeHooks()` to also export `CLAUDE_TRANSCRIPT_PATH` via `CLAUDE_ENV_FILE` +- After a successful deploy, `jack deploy` checks for `CLAUDE_TRANSCRIPT_PATH` env var and uploads the transcript + +**Path B — MCP deploy** (`deploy_project` MCP tool called by Claude Code): +- Add a `PostToolUse` hook in `installClaudeCodeHooks()` that fires when `tool_name === "deploy_project"` +- Hook reads `transcript_path` and `tool_response` from stdin +- Extracts `deploymentId` and `projectId` from tool response JSON +- Calls `jack _internal upload-session-transcript --project --deployment --transcript-path ` + +### What changes + +**`apps/cli/src/lib/claude-hooks-installer.ts`** +- Extend `installClaudeCodeHooks()` to also install: + - Updated `SessionStart` hook command that exports `CLAUDE_TRANSCRIPT_PATH` and `CLAUDE_SESSION_ID` via `CLAUDE_ENV_FILE` + - New `PostToolUse` hook entry with `matcher: "deploy_project"` that runs `jack _internal upload-session-transcript ...` +- Keep existing hook deduplication logic + +**`apps/cli/src/lib/session-transcript.ts`** (new file) +- `readAndTruncateTranscript(path: string): Promise`: + - Reads JSONL file from `transcript_path` + - Keeps only `type: "user" | "assistant"` lines + - Truncates to last 200 messages or 100KB, whichever is smaller + - Returns truncated JSONL string +- `uploadSessionTranscript(opts: { projectId, deploymentId, transcriptPath, authToken, baseUrl })`: + - Calls `readAndTruncateTranscript()` + - PUTs to `/v1/projects/:projectId/deployments/:deploymentId/session-transcript` + +**`apps/cli/src/commands/internal.ts`** (new command or extend existing) +- Add `jack _internal upload-session-transcript` subcommand +- Reads `--project-id`, `--deployment-id`, `--transcript-path` args +- Calls `uploadSessionTranscript()` using saved auth token +- Silent: exits 0 even on failure (hook errors must not block the user) + +**`apps/cli/src/lib/managed-deploy.ts`** (or wherever deploy completes) +- After successful deploy: check `process.env.CLAUDE_TRANSCRIPT_PATH` +- If set, call `uploadSessionTranscript()` asynchronously (fire-and-forget, silent) + +**`apps/control-plane/src/index.ts`** +- New endpoint: `PUT /v1/projects/:projectId/deployments/:deploymentId/session-transcript` + - Auth: same JWT org membership check + - Body: raw text (JSONL) + - Stores to R2: `projects/{projectId}/deployments/{deploymentId}/session-transcript.jsonl` + - Updates `deployments SET has_session_transcript = 1 WHERE id = ?` + +**`apps/control-plane/migrations/0031_add_deployment_context.sql`** (new) +```sql +ALTER TABLE deployments ADD COLUMN has_session_transcript INTEGER DEFAULT 0; +ALTER TABLE deployments ADD COLUMN vcs_type TEXT; +ALTER TABLE deployments ADD COLUMN vcs_sha TEXT; +ALTER TABLE deployments ADD COLUMN vcs_message TEXT; +ALTER TABLE deployments ADD COLUMN vcs_diff_stat TEXT; +ALTER TABLE deployments ADD COLUMN vcs_diff TEXT; +``` + +**`apps/control-plane/src/ask-project.ts`** +- Before calling `synthesizeWithLLM()`: if `deployment.has_session_transcript`, fetch from R2 and pass last 30 messages as context +- Add `"session_transcript"` evidence type — summary says e.g. "Deploy session had 47 turns. Agent was working on: ..." + +--- + +## Feature 3: Git/JJ Diff at Deploy + +Capture the actual VCS diff in the CLI at deploy time and store it with the deployment. + +### What changes + +**`apps/cli/src/lib/vcs.ts`** (new file) +```typescript +interface VcsDiff { + vcs: "git" | "jj"; + sha: string; + message: string; // commit/change description + diff_stat: string; // max 2KB + diff: string; // max 15KB, truncated with notice if over +} + +async function captureVcsDiff(projectDir: string): Promise +``` + +- Detects VCS by checking for `.git/` (git) or `.jj/` (jj) in `projectDir` and parents +- Git commands: + - `git -C rev-parse HEAD` → sha + - `git -C log -1 --pretty=%s` → message + - `git -C diff HEAD~1 --stat` → diff_stat (if no parent commit, diff against empty tree) + - `git -C diff HEAD~1 --unified=3` → diff (truncated to 15KB) +- JJ commands: + - `jj --no-pager log -r @ --no-graph --template 'commit_id.short()'` → sha + - `jj --no-pager log -r @ --no-graph --template 'description.first_line()'` → message + - `jj --no-pager diff --stat` → diff_stat + - `jj --no-pager diff` → diff (truncated to 15KB) +- Silent fallback: catches all errors, returns `null` if anything fails or VCS not found + +**`apps/cli/src/lib/managed-deploy.ts`** (or deploy request builder) +- Call `captureVcsDiff(projectDir)` before deploy request +- Include `vcs` field in request body if non-null + +**`apps/control-plane/src/index.ts`** (deploy endpoint) +- Accept optional `vcs?: VcsDiff` in deploy request body +- Pass through to `createCodeDeployment()` + +**`apps/control-plane/src/deployment-service.ts`** +- `createCodeDeployment()` accepts optional `vcs` field +- Stores `vcs_type`, `vcs_sha`, `vcs_message`, `vcs_diff_stat`, `vcs_diff` on deployment record + +**`apps/control-plane/src/ask-project.ts`** +- For "what changed" questions: pull `vcs_diff_stat` + `vcs_sha` + `vcs_message` from deployment +- Add `"vcs_diff"` evidence type with summary like "Deployed from commit abc1234: 'fix auth middleware'. Changed 3 files, +47 -12 lines." +- Pass `vcs_diff` to `synthesizeWithLLM()` for full diff context + +--- + +## Feature 4: Real AST Parsing + +Replace regex-based symbol extraction with a proper parser to get accurate symbols, imports, and a lightweight call graph. + +### Parser choice: `acorn` + `acorn-walk` + `acorn-typescript` + +- Pure JS, ~300KB total — no Wasm, works in Cloudflare Workers +- Handles JS, JSX, TS, TSX (TypeScript stripped before parse via lightweight type stripping) +- Acorn is battle-tested and used by many JS tools + +Alternative considered: `tree-sitter` Wasm (more accurate but 2-5MB Wasm bundle per language, cold start cost). Skip for now. + +### What changes + +**`apps/control-plane/package.json`** +- Add `acorn`, `acorn-walk`, `acorn-typescript` + +**`apps/control-plane/src/ask-code-index.ts`** +- Replace `jsTsAdapter` regex implementation with AST-based extraction +- New/improved symbol extraction: + +| Kind | Before (regex) | After (AST) | +|------|---------------|-------------| +| `route` | basic `app.get("/x")` pattern | + object router `{ GET: handler }`, `pathname === "/x"` chained routes | +| `function` | name only | name + param count + async/generator in signature | +| `class` | name only | name + method names listed in signature | +| `export` | any `export` keyword | distinguishes named/default/re-export | +| `env_binding` | `env.UPPER_SNAKE` | same, but accurate (no false positives in strings/comments) | +| `sql_ref` | SQL keyword match | same, more accurate (avoids matches in comments) | +| `import` | not extracted | **new**: `from` module path + imported names | +| `interface` | not extracted | **new**: TypeScript interface names | +| `type_alias` | not extracted | **new**: TypeScript `type X = ...` | + +- New `meta` column on symbol rows (stored as JSON string) holds: + - For `function`: `{ params: string[], async: bool, callees: string[] }` — lightweight call graph + - For `import`: `{ from: string, names: string[] }` + - For `class`: `{ methods: string[] }` + +**`apps/control-plane/migrations/0031_add_deployment_context.sql`** (same migration as above) +```sql +ALTER TABLE ask_code_symbols_latest ADD COLUMN meta TEXT; +ALTER TABLE ask_code_index_runs ADD COLUMN parser_version TEXT; +``` + +- Bump `PARSER_VERSION` constant in `ask-code-index.ts` to trigger re-index on next deploy + +--- + +## Migration summary + +One new migration file: `0031_add_deployment_context.sql` + +```sql +-- Deployment context columns +ALTER TABLE deployments ADD COLUMN has_session_transcript INTEGER DEFAULT 0; +ALTER TABLE deployments ADD COLUMN vcs_type TEXT; +ALTER TABLE deployments ADD COLUMN vcs_sha TEXT; +ALTER TABLE deployments ADD COLUMN vcs_message TEXT; +ALTER TABLE deployments ADD COLUMN vcs_diff_stat TEXT; +ALTER TABLE deployments ADD COLUMN vcs_diff TEXT; + +-- AST parser meta column +ALTER TABLE ask_code_symbols_latest ADD COLUMN meta TEXT; +``` + +--- + +## New files + +| File | Purpose | +|------|---------| +| `apps/cli/src/lib/vcs.ts` | Git/JJ diff capture | +| `apps/cli/src/lib/session-transcript.ts` | Transcript read, truncate, upload | +| `apps/control-plane/migrations/0031_add_deployment_context.sql` | Schema additions | + +--- + +## Modified files + +| File | Change | +|------|--------| +| `apps/control-plane/src/types.ts` | Add `ANTHROPIC_API_KEY?` to Bindings | +| `apps/control-plane/wrangler.toml` | Add secret comment | +| `apps/control-plane/package.json` | Add `@anthropic-ai/sdk`, `acorn`, `acorn-walk`, `acorn-typescript` | +| `apps/control-plane/src/ask-project.ts` | Add `synthesizeWithLLM()`, load transcript + VCS diff context | +| `apps/control-plane/src/ask-code-index.ts` | Replace regex with AST parser | +| `apps/control-plane/src/index.ts` | New `PUT .../session-transcript` endpoint; accept `vcs` in deploy | +| `apps/control-plane/src/deployment-service.ts` | Store VCS fields on deployment | +| `apps/cli/src/lib/claude-hooks-installer.ts` | Add SessionStart env export + PostToolUse hook | +| `apps/cli/src/lib/managed-deploy.ts` | Capture VCS diff + upload transcript after deploy | +| `apps/cli/src/commands/` | Add `jack _internal upload-session-transcript` | + +--- + +## Open questions (no blockers) + +1. **Codex CLI**: no hooks API exists yet (open issue #2765). Transcript capture for Codex is deferred — the `vcs` diff path will still work for Codex users since it's CLI-side. + +2. **Model for LLM synthesis**: plan uses `claude-haiku-4-5-20251001`. If response quality is insufficient, swap to `claude-sonnet-4-5-20250929` — just a one-line constant change. + +3. **PostToolUse hook `tool_response` parsing**: the `deploy_project` MCP tool returns `{ deploymentId, projectId, ... }` wrapped in `formatSuccessResponse`. The hook script will parse the text content JSON. If the shape changes, the hook silently fails (no user impact — transcript upload is always best-effort). diff --git a/apps/cli/src/commands/internal.ts b/apps/cli/src/commands/internal.ts new file mode 100644 index 0000000..4efd8a7 --- /dev/null +++ b/apps/cli/src/commands/internal.ts @@ -0,0 +1,117 @@ +import { appendFile } from "node:fs/promises"; +import { readProjectLink } from "../lib/project-link.ts"; +import { uploadSessionTranscript } from "../lib/session-transcript.ts"; + +/** + * Internal commands used by Claude Code hooks. + * Not exposed in the help text — these are implementation details. + */ +export default async function internal(subcommand?: string): Promise { + if (subcommand === "session-start") { + await handleSessionStart(); + return; + } + + if (subcommand === "post-deploy") { + await handlePostDeploy(); + return; + } + + // Unknown subcommand — exit silently (hooks must never error visibly) + process.exit(0); +} + +/** + * SessionStart hook handler. + * + * Claude Code passes JSON via stdin: + * { session_id, transcript_path, cwd, ... } + * + * We write CLAUDE_TRANSCRIPT_PATH to $CLAUDE_ENV_FILE so that subsequent + * Bash tool calls (e.g. `jack deploy`) can find the transcript and upload it. + */ +async function handleSessionStart(): Promise { + try { + const raw = await readStdin(); + if (!raw) return; + + const payload = JSON.parse(raw) as Record; + const transcriptPath = payload.transcript_path as string | undefined; + const envFile = process.env.CLAUDE_ENV_FILE; + + if (transcriptPath && envFile) { + await appendFile(envFile, `export CLAUDE_TRANSCRIPT_PATH='${transcriptPath}'\n`); + } + } catch { + // Never surface errors from hooks + } +} + +/** + * PostToolUse hook handler for deploy_project. + * + * Claude Code passes JSON via stdin: + * { hook_event_name, tool_name, tool_input, tool_response, transcript_path, cwd, session_id } + * + * We extract the deployment_id + project_id and upload the transcript. + */ +async function handlePostDeploy(): Promise { + try { + const raw = await readStdin(); + if (!raw) return; + + const payload = JSON.parse(raw) as Record; + + // Only handle deploy_project tool calls + if (payload.tool_name !== "deploy_project") return; + + const transcriptPath = payload.transcript_path as string | undefined; + if (!transcriptPath) return; + + // Parse the tool response to get deploymentId + const toolResponse = payload.tool_response as string | undefined; + if (!toolResponse) return; + + let deploymentId: string | undefined; + try { + const parsed = JSON.parse(toolResponse) as Record; + // Response shape: { success, data: { deploymentId, ... }, meta } + const data = parsed.data as Record | undefined; + deploymentId = data?.deploymentId as string | undefined; + } catch { + return; + } + if (!deploymentId) return; + + // Get project_id from the project link in the working directory + const cwd = (payload.cwd as string | undefined) ?? process.cwd(); + const projectPath = + (payload.tool_input as Record | undefined)?.project_path as + | string + | undefined; + const resolvedPath = projectPath ?? cwd; + + const link = await readProjectLink(resolvedPath).catch(() => null); + if (!link || link.deploy_mode !== "managed") return; + + await uploadSessionTranscript({ + projectId: link.project_id, + deploymentId, + transcriptPath, + }); + } catch { + // Never surface errors from hooks + } +} + +async function readStdin(): Promise { + try { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks).toString("utf8").trim() || null; + } catch { + return null; + } +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index baaa6e3..29ace31 100755 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -547,6 +547,12 @@ try { await withTelemetry("skills", skills, { subcommand: args[0] })(args[0], args.slice(1)); break; } + case "_internal": { + // Internal commands used by Claude Code hooks — not shown in help + const { default: internal } = await import("./commands/internal.ts"); + await internal(args[0]); + break; + } default: { // No command provided - show interactive picker if TTY, else help if (!command) { diff --git a/apps/cli/src/lib/claude-hooks-installer.ts b/apps/cli/src/lib/claude-hooks-installer.ts index aa8cad1..a7e740e 100644 --- a/apps/cli/src/lib/claude-hooks-installer.ts +++ b/apps/cli/src/lib/claude-hooks-installer.ts @@ -2,11 +2,18 @@ import { existsSync } from "node:fs"; import { mkdir } from "node:fs/promises"; import { join } from "node:path"; -const HOOK_COMMAND = "jack mcp context 2>/dev/null || true"; +const MCP_CONTEXT_COMMAND = "jack mcp context 2>/dev/null || true"; +const SESSION_START_COMMAND = "jack _internal session-start 2>/dev/null || true"; +const POST_DEPLOY_COMMAND = "jack _internal post-deploy 2>/dev/null || true"; /** - * Install a Claude Code SessionStart hook to the project's .claude/settings.json. - * Only fires when Claude Code is opened in this project directory. + * Install Claude Code hooks to the project's .claude/settings.json: + * + * - SessionStart: runs `jack mcp context` (project context) + `jack _internal session-start` + * (exports CLAUDE_TRANSCRIPT_PATH via CLAUDE_ENV_FILE so `jack deploy` can read it) + * - PostToolUse(deploy_project): runs `jack _internal post-deploy` to upload the session + * transcript to the control plane after an MCP-triggered deploy + * * Non-destructive: preserves existing hooks and deduplicates. */ export async function installClaudeCodeHooks(projectPath: string): Promise { @@ -29,22 +36,49 @@ export async function installClaudeCodeHooks(projectPath: string): Promise) ?? {}; + + // --- SessionStart hooks --- const sessionStart = (hooks.SessionStart as Array>) ?? []; - // Check if jack hook is already installed - for (const entry of sessionStart) { + const hasMcpContext = sessionStart.some((entry) => { const entryHooks = entry.hooks as Array> | undefined; - if (entryHooks?.some((h) => h.command?.includes("jack mcp context"))) { - return true; - } + return entryHooks?.some((h) => h.command?.includes("jack mcp context")); + }); + if (!hasMcpContext) { + sessionStart.push({ + matcher: "", + hooks: [{ type: "command", command: MCP_CONTEXT_COMMAND }], + }); } - sessionStart.push({ - matcher: "", - hooks: [{ type: "command", command: HOOK_COMMAND }], + const hasSessionStart = sessionStart.some((entry) => { + const entryHooks = entry.hooks as Array> | undefined; + return entryHooks?.some((h) => h.command?.includes("jack _internal session-start")); }); + if (!hasSessionStart) { + sessionStart.push({ + matcher: "", + hooks: [{ type: "command", command: SESSION_START_COMMAND }], + }); + } hooks.SessionStart = sessionStart; + + // --- PostToolUse hooks --- + const postToolUse = (hooks.PostToolUse as Array>) ?? []; + + const hasPostDeploy = postToolUse.some((entry) => { + const entryHooks = entry.hooks as Array> | undefined; + return entryHooks?.some((h) => h.command?.includes("jack _internal post-deploy")); + }); + if (!hasPostDeploy) { + postToolUse.push({ + matcher: "deploy_project", + hooks: [{ type: "command", command: POST_DEPLOY_COMMAND }], + }); + } + + hooks.PostToolUse = postToolUse; settings.hooks = hooks; await Bun.write(settingsPath, JSON.stringify(settings, null, 2)); diff --git a/apps/cli/src/lib/managed-deploy.ts b/apps/cli/src/lib/managed-deploy.ts index 36d83ed..3766cf8 100644 --- a/apps/cli/src/lib/managed-deploy.ts +++ b/apps/cli/src/lib/managed-deploy.ts @@ -18,6 +18,7 @@ import { formatSize } from "./format.ts"; import { createFileCountProgress, createUploadProgress } from "./progress.ts"; import type { OperationReporter } from "./project-operations.ts"; import { getProjectTags } from "./tags.ts"; +import { uploadSessionTranscript } from "./session-transcript.ts"; import { Events, track, trackActivationIfFirst } from "./telemetry.ts"; import { findWranglerConfig } from "./wrangler-config.ts"; import { packageForDeploy } from "./zip-packager.ts"; @@ -225,6 +226,17 @@ export async function deployCodeToManagedProject( // Source snapshot for forking is now derived from deployment artifacts on the control plane. // No separate upload needed — clone/fork reads from the latest live deployment's source.zip. + // Fire-and-forget: upload Claude Code session transcript if running under Claude Code. + // CLAUDE_TRANSCRIPT_PATH is exported by the SessionStart hook via CLAUDE_ENV_FILE. + const transcriptPath = process.env.CLAUDE_TRANSCRIPT_PATH; + if (transcriptPath) { + void uploadSessionTranscript({ + projectId, + deploymentId: result.id, + transcriptPath, + }); + } + return { deploymentId: result.id, status: result.status, diff --git a/apps/cli/src/lib/mcp-config.ts b/apps/cli/src/lib/mcp-config.ts index a533202..ba622fe 100644 --- a/apps/cli/src/lib/mcp-config.ts +++ b/apps/cli/src/lib/mcp-config.ts @@ -1,6 +1,6 @@ import { existsSync } from "node:fs"; import { mkdir } from "node:fs/promises"; -import { homedir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { platform } from "node:os"; import { dirname, join } from "node:path"; import { CONFIG_DIR } from "./config.ts"; @@ -86,6 +86,9 @@ export function getJackMcpConfig(): McpServerConfig { args: ["mcp", "serve"], env: { PATH: defaultPaths.join(":"), + HOME: homedir(), + TMPDIR: tmpdir(), + ...(process.env.USER && { USER: process.env.USER }), }, }; } diff --git a/apps/cli/src/lib/project-operations.ts b/apps/cli/src/lib/project-operations.ts index 39e700c..9442add 100644 --- a/apps/cli/src/lib/project-operations.ts +++ b/apps/cli/src/lib/project-operations.ts @@ -72,7 +72,7 @@ import { applySchema, getD1Bindings, getD1DatabaseName, hasD1Config } from "./sc import { getSavedSecrets, saveSecrets } from "./secrets.ts"; import { getProjectNameFromDir, getRemoteManifest } from "./storage/index.ts"; import { Events, track, trackActivationIfFirst } from "./telemetry.ts"; -import { findWranglerConfig, hasWranglerConfig } from "./wrangler-config.ts"; +import { findWranglerConfig, hasWranglerConfig, updateWranglerConfigName } from "./wrangler-config.ts"; // ============================================================================ // Type Definitions @@ -644,6 +644,14 @@ async function runAutoDetectFlow( usePrebuilt: false, }); + // Sync wrangler config name with cloud slug if they differ + if (remoteResult.projectSlug !== projectName) { + const configPath = findWranglerConfig(projectPath); + if (configPath) { + await updateWranglerConfigName(configPath, remoteResult.projectSlug); + } + } + // Link project locally (include username for correct URL display) await linkProject(projectPath, remoteResult.projectId, "managed", ownerUsername ?? undefined); await registerPath(remoteResult.projectId, projectPath); @@ -651,7 +659,7 @@ async function runAutoDetectFlow( track(Events.AUTO_DETECT_SUCCESS, { type: detection.type }); return { - projectName, + projectName: remoteResult.projectSlug, projectId: remoteResult.projectId, deployMode: "managed", }; @@ -723,6 +731,14 @@ async function runAutoDetectFlow( usePrebuilt: false, }); + // Sync wrangler config name with cloud slug if they differ + if (remoteResult.projectSlug !== slugifiedName) { + const configPath = findWranglerConfig(projectPath); + if (configPath) { + await updateWranglerConfigName(configPath, remoteResult.projectSlug); + } + } + // Link project locally (include username for correct URL display) await linkProject(projectPath, remoteResult.projectId, "managed", ownerUsername ?? undefined); await registerPath(remoteResult.projectId, projectPath); @@ -730,7 +746,7 @@ async function runAutoDetectFlow( track(Events.AUTO_DETECT_SUCCESS, { type: detection.type }); return { - projectName: slugifiedName, + projectName: remoteResult.projectSlug, projectId: remoteResult.projectId, deployMode: "managed", }; @@ -1243,6 +1259,15 @@ export async function createProject( }, }); remoteResult = result.remoteResult; + + // Sync wrangler config name with cloud slug if they differ + if (remoteResult && remoteResult.projectSlug !== projectName) { + const configPath = findWranglerConfig(targetDir); + if (configPath) { + await updateWranglerConfigName(configPath, remoteResult.projectSlug); + } + } + timings.push({ label: "Parallel setup", duration: timerEnd("parallel-setup") }); reporter.stop(); if (urlShownEarly) { @@ -1620,6 +1645,15 @@ export async function deployProject(options: DeployOptions = {}): Promise r.resource_type === "d1"); dbName = d1?.resource_name || null; @@ -2037,7 +2079,8 @@ export async function getProjectStatus( lastDeployMessage = latest.message; } } catch { - // Silent fail — supplementary data + // Fallback to local data if cloud is unreachable + workerUrl = await buildManagedUrl(projectName, link.owner_username, resolvedPath); } } else if (localExists) { // For BYO, parse from wrangler config diff --git a/apps/cli/src/lib/session-transcript.ts b/apps/cli/src/lib/session-transcript.ts new file mode 100644 index 0000000..f8d29bd --- /dev/null +++ b/apps/cli/src/lib/session-transcript.ts @@ -0,0 +1,103 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { authFetch } from "./auth/index.ts"; +import { getControlApiUrl } from "./control-plane.ts"; + +// Keep last N messages and cap at MAX_BYTES to stay well under the 1MB server limit +const MAX_MESSAGES = 200; +const MAX_BYTES = 800_000; + +interface TranscriptLine { + type?: string; + [key: string]: unknown; +} + +/** + * Read a Claude Code session JSONL file, keep only user/assistant messages, + * truncate to MAX_MESSAGES from the end, and cap at MAX_BYTES. + */ +export async function readAndTruncateTranscript(transcriptPath: string): Promise { + if (!existsSync(transcriptPath)) { + return null; + } + + let raw: string; + try { + raw = await readFile(transcriptPath, "utf8"); + } catch { + return null; + } + + const lines = raw + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + + // Keep only conversation turns (skip summary/metadata lines) + const turns: string[] = []; + for (const line of lines) { + try { + const parsed = JSON.parse(line) as TranscriptLine; + if (parsed.type === "user" || parsed.type === "assistant") { + turns.push(line); + } + } catch { + // Skip malformed lines + } + } + + // Take the last MAX_MESSAGES turns + const recent = turns.slice(-MAX_MESSAGES); + + // Build output, trimming from the front if over MAX_BYTES + let output = recent.join("\n"); + if (new TextEncoder().encode(output).length > MAX_BYTES) { + // Drop oldest lines until under limit + let i = 0; + while (i < recent.length) { + const candidate = recent.slice(i).join("\n"); + if (new TextEncoder().encode(candidate).length <= MAX_BYTES) { + output = candidate; + break; + } + i++; + } + if (i >= recent.length) { + return null; // Nothing fits + } + } + + return output || null; +} + +/** + * Upload a session transcript to the control plane for a given deployment. + * Silent on failure — transcript upload is best-effort and must never block a deploy. + */ +export async function uploadSessionTranscript(opts: { + projectId: string; + deploymentId: string; + transcriptPath: string; +}): Promise { + const { projectId, deploymentId, transcriptPath } = opts; + + try { + const transcript = await readAndTruncateTranscript(transcriptPath); + if (!transcript) { + return; + } + + const url = `${getControlApiUrl()}/v1/projects/${projectId}/deployments/${deploymentId}/session-transcript`; + const response = await authFetch(url, { + method: "PUT", + headers: { "Content-Type": "application/x-ndjson" }, + body: transcript, + }); + + if (!response.ok) { + // Silent — best-effort + } + } catch { + // Never surface transcript errors to the user + } +} diff --git a/apps/cli/src/lib/wrangler-config.ts b/apps/cli/src/lib/wrangler-config.ts index 93133e1..46fe39b 100644 --- a/apps/cli/src/lib/wrangler-config.ts +++ b/apps/cli/src/lib/wrangler-config.ts @@ -42,6 +42,30 @@ export function hasWranglerConfig(projectDir: string): boolean { return findWranglerConfig(projectDir) !== null; } +// ============================================================================ +// Wrangler Config Name Updates +// ============================================================================ + +/** + * Update the "name" field in a wrangler config file. + * Uses text replacement to preserve comments and formatting. + * Supports both JSONC/JSON ("name": "value") and TOML (name = "value"). + * + * @returns true if the name was updated, false if unchanged or not found + */ +export async function updateWranglerConfigName(configPath: string, newName: string): Promise { + const content = await Bun.file(configPath).text(); + let updated: string; + if (configPath.endsWith(".toml")) { + updated = content.replace(/^name\s*=\s*"[^"]*"/m, `name = "${newName}"`); + } else { + updated = content.replace(/"name"\s*:\s*"[^"]*"/, `"name": "${newName}"`); + } + if (updated === content) return false; + await Bun.write(configPath, updated); + return true; +} + // ============================================================================ // D1 Binding Config // ============================================================================ diff --git a/apps/cli/src/mcp/utils.ts b/apps/cli/src/mcp/utils.ts index c6d0ee8..6118cfd 100644 --- a/apps/cli/src/mcp/utils.ts +++ b/apps/cli/src/mcp/utils.ts @@ -27,12 +27,18 @@ export function formatErrorResponse(error: unknown, startTime: number): McpToolR ? (error.suggestion ?? getSuggestionForError(code)) : getSuggestionForError(code); + // Surface subprocess stderr so AI agents can see actual build/deploy errors + const details = isJackError(error) && error.meta?.stderr + ? String(error.meta.stderr) + : undefined; + return { success: false, error: { code, message, suggestion, + ...(details && { details }), }, meta: { duration_ms: Date.now() - startTime, diff --git a/apps/control-plane/migrations/0031_add_session_transcript.sql b/apps/control-plane/migrations/0031_add_session_transcript.sql new file mode 100644 index 0000000..cff0688 --- /dev/null +++ b/apps/control-plane/migrations/0031_add_session_transcript.sql @@ -0,0 +1,4 @@ +-- Migration: Add session transcript support to deployments +-- Description: Store Claude Code session transcript captured at deploy time + +ALTER TABLE deployments ADD COLUMN has_session_transcript INTEGER DEFAULT 0; diff --git a/apps/control-plane/migrations/0032_add_session_digest.sql b/apps/control-plane/migrations/0032_add_session_digest.sql new file mode 100644 index 0000000..9cfceff --- /dev/null +++ b/apps/control-plane/migrations/0032_add_session_digest.sql @@ -0,0 +1,4 @@ +-- Migration: Add session digest column to deployments +-- Description: Store a short LLM-generated summary of the deploy session transcript + +ALTER TABLE deployments ADD COLUMN session_digest TEXT; diff --git a/apps/control-plane/package.json b/apps/control-plane/package.json index 96135af..83f6adf 100644 --- a/apps/control-plane/package.json +++ b/apps/control-plane/package.json @@ -17,7 +17,7 @@ "stripe": "^17.5.0" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20241205.0", + "@cloudflare/workers-types": "^4.20260218.0", "typescript": "^5.0.0" } } diff --git a/apps/control-plane/src/ask-project.ts b/apps/control-plane/src/ask-project.ts index 56216cb..5927308 100644 --- a/apps/control-plane/src/ask-project.ts +++ b/apps/control-plane/src/ask-project.ts @@ -8,7 +8,6 @@ import { CloudflareClient } from "./cloudflare-api"; import { DeploymentService } from "./deployment-service"; import { ProvisioningService } from "./provisioning"; import type { Bindings, Deployment } from "./types"; - export interface AskProjectHints { endpoint?: string; method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; @@ -30,7 +29,8 @@ export interface AskProjectEvidence { | "env_snapshot" | "code_chunk" | "code_symbol" - | "index_status"; + | "index_status" + | "session_transcript"; source: string; summary: string; timestamp: string; @@ -41,6 +41,9 @@ export interface AskProjectEvidence { export interface AskProjectResponse { answer: string; evidence: AskProjectEvidence[]; + root_cause?: string; + suggested_fix?: string; + confidence?: "high" | "medium" | "low"; } interface AskProjectInput { @@ -220,6 +223,160 @@ function summarizeDeploymentMessage(deployment: Deployment): string { return `Deployment ${deployment.id} (${deployment.status}) at ${createdAt} has no deploy message`; } +/** + * Parse raw JSONL transcript into redacted conversation turns. + * Shared between the digest generator and the fallback read path. + */ +export function parseTranscriptTurns(raw: string): string[] { + const lines = raw.split("\n").filter((l) => l.trim().length > 0); + const turns: string[] = []; + for (const line of lines) { + try { + const parsed = JSON.parse(line) as Record; + if (parsed.type !== "user" && parsed.type !== "assistant") continue; + const msg = parsed.message as Record | undefined; + if (!msg) continue; + const content = msg.content; + let text = ""; + if (typeof content === "string") { + text = content; + } else if (Array.isArray(content)) { + text = content + .filter( + (b): b is { type: "text"; text: string } => + typeof b === "object" && b !== null && (b as Record).type === "text", + ) + .map((b) => b.text) + .join(" "); + } + if (text.trim()) { + turns.push(`[${parsed.type as string}]: ${redactText(text).slice(0, 500)}`); + } + } catch { + // skip malformed lines + } + } + return turns; +} + +/** + * Generate a prose digest of a session transcript using Workers AI. + * Called via waitUntil at upload time — failures are silent. + * Writes the result to the session_digest column on the deployment. + */ +export async function generateSessionDigest( + env: Bindings, + deploymentId: string, + rawTranscript: string, +): Promise { + try { + const turns = parseTranscriptTurns(rawTranscript); + if (turns.length === 0) return; + + // Take last 50 turns — recent context matters most for debugging + const recentTurns = turns.slice(-50).join("\n\n"); + + const response = await env.AI.run("@cf/glm4/glm-4.7-flash" as keyof AiModels, { + messages: [ + { + role: "system", + content: + "You summarize coding session transcripts. Be concise (3-8 sentences). Focus on: problems encountered and how they were resolved, key decisions or trade-offs made, workarounds applied, and anything that could break later. Skip pleasantries, tool invocations, and routine file edits. If the session is trivial (a quick fix with no complications), say so in one sentence.", + }, + { + role: "user", + content: `Summarize this coding session transcript:\n\n${recentTurns}`, + }, + ], + max_tokens: 400, + }); + + const text = + typeof response === "object" && response !== null && "response" in response + ? String((response as { response: unknown }).response).trim() + : ""; + + if (!text) return; + + await env.DB.prepare("UPDATE deployments SET session_digest = ? WHERE id = ?") + .bind(text, deploymentId) + .run(); + } catch (err) { + console.error("generateSessionDigest failed:", err); + } +} + +async function synthesizeAnswer( + env: Bindings, + question: string, + evidence: AskProjectEvidence[], + sessionExcerpt: string | null, +): Promise | null> { + const evidenceBlock = evidence + .map( + (e) => `- [${e.id}] type=${e.type} source=${e.source} relation=${e.relation}: ${e.summary}`, + ) + .join("\n"); + + const transcriptBlock = sessionExcerpt + ? `\n\n## Deploy Session Context (last 30 turns)\n\n${sessionExcerpt}` + : ""; + + const prompt = `You are a debugging assistant for a Cloudflare Workers deployment platform. Answer the user's question about their deployed project based only on the collected evidence below. + +## Question +${question} + +## Evidence +${evidenceBlock}${transcriptBlock} + +Respond with only valid JSON (no markdown fences): +{ + "answer": "Concise direct answer (2-4 sentences)", + "root_cause": "Specific root cause or null", + "suggested_fix": "Concrete fix or null", + "confidence": "high" | "medium" | "low" +}`; + + const response = await env.AI.run("@cf/glm4/glm-4.7-flash" as keyof AiModels, { + messages: [ + { + role: "system", + content: "You are a concise debugging assistant. Respond with only valid JSON.", + }, + { role: "user", content: prompt }, + ], + max_tokens: 512, + }); + + const text = + typeof response === "object" && response !== null && "response" in response + ? String((response as { response: unknown }).response) + : ""; + + if (!text) return null; + + try { + const parsed = JSON.parse(text) as Record; + return { + answer: typeof parsed.answer === "string" ? parsed.answer : text.slice(0, 800), + root_cause: typeof parsed.root_cause === "string" ? parsed.root_cause : undefined, + suggested_fix: typeof parsed.suggested_fix === "string" ? parsed.suggested_fix : undefined, + confidence: + parsed.confidence === "high" || + parsed.confidence === "medium" || + parsed.confidence === "low" + ? parsed.confidence + : "low", + }; + } catch { + return { answer: text.slice(0, 800), confidence: "low" }; + } +} + function pickAnswer(params: { question: string; latestDeployment: Deployment | null; @@ -607,8 +764,20 @@ export async function answerProjectQuestion(input: AskProjectInput): Promise e.relation === "gap"); - const answer = pickAnswer({ + const pickAnswerParams = { question, latestDeployment, questionMatchedDeployment, @@ -619,10 +788,22 @@ export async function answerProjectQuestion(input: AskProjectInput): Promise { + console.error("ask_project: LLM synthesis failed, falling back to pickAnswer:", err); + return null; }); + const synthesis = synthesized ?? { answer: pickAnswer(pickAnswerParams) }; + return { - answer, + ...synthesis, evidence: evidence.values(), }; } diff --git a/apps/control-plane/src/index.ts b/apps/control-plane/src/index.ts index 07bdf3b..ee3fb97 100644 --- a/apps/control-plane/src/index.ts +++ b/apps/control-plane/src/index.ts @@ -4,6 +4,17 @@ import { unzipSync } from "fflate"; import { Hono } from "hono"; import { cors } from "hono/cors"; import type Stripe from "stripe"; +import { indexLatestDeploymentSource } from "./ask-code-index"; +import { + type AskIndexQueueMessage, + consumeAskIndexBatch, + enqueueAskIndexJob, +} from "./ask-index-queue"; +import { + type AskProjectRequest, + answerProjectQuestion, + generateSessionDigest, +} from "./ask-project"; import { BillingService } from "./billing-service"; import { type AIUsageByModel, @@ -21,13 +32,6 @@ import { DaimoBillingService } from "./daimo-billing-service"; import { DeploymentService, validateManifest } from "./deployment-service"; import { getDoEnforcementStatus, processDoMetering } from "./do-metering"; import { REFERRAL_CAP, TIER_LIMITS, computeLimits } from "./entitlements-config"; -import { indexLatestDeploymentSource } from "./ask-code-index"; -import { - type AskIndexQueueMessage, - consumeAskIndexBatch, - enqueueAskIndexJob, -} from "./ask-index-queue"; -import { answerProjectQuestion, type AskProjectRequest } from "./ask-project"; import { ProvisioningService, normalizeSlug, validateSlug } from "./provisioning"; import { ProjectCacheService } from "./repositories/project-cache-service"; import { validateReadOnly } from "./sql-utils"; @@ -182,7 +186,8 @@ function inferAskSource(c: { req: { header: (name: string) => string | undefined const userAgent = c.req.header("user-agent")?.toLowerCase() ?? ""; if (userAgent.includes("mozilla")) return "web"; if (userAgent.includes("curl")) return "api"; - if (userAgent.includes("jack") || userAgent.includes("bun") || userAgent.includes("node")) return "cli"; + if (userAgent.includes("jack") || userAgent.includes("bun") || userAgent.includes("node")) + return "cli"; return "api"; } @@ -3709,6 +3714,64 @@ api.post("/projects/:projectId/deployments/upload", async (c) => { } }); +// Upload session transcript for a deployment +api.put("/projects/:projectId/deployments/:deploymentId/session-transcript", async (c) => { + const auth = c.get("auth"); + const projectId = c.req.param("projectId"); + const deploymentId = c.req.param("deploymentId"); + const provisioning = new ProvisioningService(c.env); + + const project = await provisioning.getProject(projectId); + if (!project) { + return c.json({ error: "not_found", message: "Project not found" }, 404); + } + + const membership = await c.env.DB.prepare( + "SELECT 1 FROM org_memberships WHERE org_id = ? AND user_id = ?", + ) + .bind(project.org_id, auth.userId) + .first(); + if (!membership) { + return c.json({ error: "not_found", message: "Project not found" }, 404); + } + + // Verify deployment belongs to this project + const deployment = await c.env.DB.prepare( + "SELECT id FROM deployments WHERE id = ? AND project_id = ?", + ) + .bind(deploymentId, projectId) + .first(); + if (!deployment) { + return c.json({ error: "not_found", message: "Deployment not found" }, 404); + } + + const body = await c.req.text(); + if (!body) { + return c.json({ error: "invalid_request", message: "Empty body" }, 400); + } + + // Limit to 1MB to avoid unbounded storage + const MAX_TRANSCRIPT_BYTES = 1_000_000; + const bodyBytes = new TextEncoder().encode(body).length; + if (bodyBytes > MAX_TRANSCRIPT_BYTES) { + return c.json({ error: "payload_too_large", message: "Transcript exceeds 1MB limit" }, 413); + } + + const r2Key = `projects/${projectId}/deployments/${deploymentId}/session-transcript.jsonl`; + await c.env.CODE_BUCKET.put(r2Key, body, { + httpMetadata: { contentType: "application/x-ndjson" }, + }); + + await c.env.DB.prepare("UPDATE deployments SET has_session_transcript = 1 WHERE id = ?") + .bind(deploymentId) + .run(); + + // Generate a prose digest in the background — never blocks the response + c.executionCtx.waitUntil(generateSessionDigest(c.env, deploymentId, body)); + + return c.json({ ok: true }); +}); + // Secrets endpoints - never stores secrets in D1, passes directly to Cloudflare api.post("/projects/:projectId/secrets", async (c) => { const auth = c.get("auth"); diff --git a/apps/control-plane/src/types.ts b/apps/control-plane/src/types.ts index 9631ca1..cdb442c 100644 --- a/apps/control-plane/src/types.ts +++ b/apps/control-plane/src/types.ts @@ -30,6 +30,8 @@ export type Bindings = { // Optional PostHog server-side capture key for control-plane events POSTHOG_API_KEY?: string; POSTHOG_HOST?: string; + // Workers AI binding for LLM synthesis in ask_project + AI: Ai; }; // Project status enum @@ -114,6 +116,8 @@ export interface Deployment { worker_version_id: string | null; error_message: string | null; message: string | null; + has_session_transcript: number; // SQLite boolean (0 or 1) + session_digest: string | null; created_at: string; updated_at: string; } diff --git a/apps/control-plane/wrangler.toml b/apps/control-plane/wrangler.toml index 960de56..d84fa37 100644 --- a/apps/control-plane/wrangler.toml +++ b/apps/control-plane/wrangler.toml @@ -2,6 +2,9 @@ name = "jack-control" main = "src/index.ts" compatibility_date = "2024-12-01" +[ai] +binding = "AI" + routes = [ { pattern = "control.getjack.org", custom_domain = true } ] diff --git a/bun.lock b/bun.lock index 8b7d127..7b683e6 100644 --- a/bun.lock +++ b/bun.lock @@ -38,7 +38,7 @@ }, "apps/cli": { "name": "@getjack/jack", - "version": "0.1.34", + "version": "0.1.36", "bin": { "jack": "./src/index.ts", }, @@ -70,7 +70,7 @@ "stripe": "^17.5.0", }, "devDependencies": { - "@cloudflare/workers-types": "^4.20241205.0", + "@cloudflare/workers-types": "^4.20260218.0", "typescript": "^5.0.0", }, }, @@ -229,7 +229,7 @@ "@cloudflare/workers-oauth-provider": ["@cloudflare/workers-oauth-provider@0.2.3", "", {}, "sha512-86a5eJZR+kXoRBWVYEf47RmHM4FjqETOtJSBv7MpzmkcfHU27XhahhwVOO4i4OQDNIPGOOubVcbWDVDLydfD7g=="], - "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251221.0", "", {}, "sha512-VVTEhY29TtwIwjBjpRrdT51Oqu0JlXijc5TiEKFCjwouUSn+5VhzoTSaz7UBjVOu4vfvcQmjqt/dzwBUR7c95w=="], + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260218.0", "", {}, "sha512-E28uJNJb9J9pca3RaxjXm1JxAjp8td9/cudkY+IT8rio71NlshN7NKMe2Cr/6GN+RufbSnp+N3ZKP74xgUaL0A=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -1855,8 +1855,20 @@ "@chevrotain/gast/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], + "@getjack/auth-worker/@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251221.0", "", {}, "sha512-VVTEhY29TtwIwjBjpRrdT51Oqu0JlXijc5TiEKFCjwouUSn+5VhzoTSaz7UBjVOu4vfvcQmjqt/dzwBUR7c95w=="], + + "@getjack/binding-proxy-worker/@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251221.0", "", {}, "sha512-VVTEhY29TtwIwjBjpRrdT51Oqu0JlXijc5TiEKFCjwouUSn+5VhzoTSaz7UBjVOu4vfvcQmjqt/dzwBUR7c95w=="], + + "@getjack/dispatch-worker/@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251221.0", "", {}, "sha512-VVTEhY29TtwIwjBjpRrdT51Oqu0JlXijc5TiEKFCjwouUSn+5VhzoTSaz7UBjVOu4vfvcQmjqt/dzwBUR7c95w=="], + "@getjack/jack/bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], + "@getjack/log-worker/@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251221.0", "", {}, "sha512-VVTEhY29TtwIwjBjpRrdT51Oqu0JlXijc5TiEKFCjwouUSn+5VhzoTSaz7UBjVOu4vfvcQmjqt/dzwBUR7c95w=="], + + "@getjack/mcp-worker/@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251221.0", "", {}, "sha512-VVTEhY29TtwIwjBjpRrdT51Oqu0JlXijc5TiEKFCjwouUSn+5VhzoTSaz7UBjVOu4vfvcQmjqt/dzwBUR7c95w=="], + + "@getjack/telemetry-worker/@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251221.0", "", {}, "sha512-VVTEhY29TtwIwjBjpRrdT51Oqu0JlXijc5TiEKFCjwouUSn+5VhzoTSaz7UBjVOu4vfvcQmjqt/dzwBUR7c95w=="], + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], diff --git a/scripts/curl-ask-examples.sh b/scripts/curl-ask-examples.sh new file mode 100755 index 0000000..fe52313 --- /dev/null +++ b/scripts/curl-ask-examples.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Quick curl examples for ask feature endpoints +# Set: JACK_API_TOKEN, PROJECT_ID, DEPLOYMENT_ID, CONTROL_URL + +CONTROL_URL="${CONTROL_URL:-https://control.getjack.org}" + +# Upload a session transcript +curl -X PUT \ + -H "Authorization: Bearer $JACK_API_TOKEN" \ + -H "Content-Type: application/x-ndjson" \ + --data-binary '{"type":"user","message":{"role":"user","content":"why is /api broken?"},"sessionId":"s1","timestamp":"2026-01-01T00:00:00.000Z"}' \ + "$CONTROL_URL/v1/projects/$PROJECT_ID/deployments/$DEPLOYMENT_ID/session-transcript" + +echo "" + +# Ask a question +curl -X POST \ + -H "Authorization: Bearer $JACK_API_TOKEN" \ + -H "Content-Type: application/json" \ + --data '{"question":"Why did my endpoint break?","hints":{"endpoint":"/api/hello"}}' \ + "$CONTROL_URL/v1/projects/$PROJECT_ID/ask" | jq . diff --git a/scripts/test-ask-e2e.sh b/scripts/test-ask-e2e.sh new file mode 100755 index 0000000..b735b49 --- /dev/null +++ b/scripts/test-ask-e2e.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# E2E test: session transcript upload + ask endpoint +# +# Usage: +# JACK_API_TOKEN=jkt_xxx PROJECT_ID=proj_xxx DEPLOYMENT_ID=dep_xxx bash scripts/test-ask-e2e.sh +# +# Optional: +# CONTROL_URL=https://control.getjack.org (default) +# +# How to get IDs: +# PROJECT_ID: jack info --json | jq -r '.project.id' +# DEPLOYMENT_ID: jack deploys --json | jq -r '.[0].id' + +set -euo pipefail + +CONTROL_URL="${CONTROL_URL:-https://control.getjack.org}" +PASS=0 +FAIL=0 + +# ── helpers ────────────────────────────────────────────────────────────────── + +check() { + local label="$1" + local result="$2" + if [[ "$result" == "true" ]]; then + echo " PASS $label" + PASS=$((PASS + 1)) + else + echo " FAIL $label" + FAIL=$((FAIL + 1)) + fi +} + +require_var() { + if [[ -z "${!1:-}" ]]; then + echo "ERROR: $1 is required. See usage at the top of this script." + exit 1 + fi +} + +# ── preflight ───────────────────────────────────────────────────────────────── + +require_var JACK_API_TOKEN +require_var PROJECT_ID +require_var DEPLOYMENT_ID + +echo "" +echo "Control URL : $CONTROL_URL" +echo "Project : $PROJECT_ID" +echo "Deployment : $DEPLOYMENT_ID" +echo "" + +# ── test 1: upload session transcript ──────────────────────────────────────── + +echo "[ 1 ] Upload session transcript" + +# Minimal valid Claude Code JSONL (two turns) +TRANSCRIPT_BODY='{"type":"user","message":{"role":"user","content":"Why did the /api/hello endpoint break after deploy?"},"sessionId":"test","timestamp":"2026-01-01T00:00:00.000Z"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"The error is likely a missing D1 table. Check your schema migrations."}]},"sessionId":"test","timestamp":"2026-01-01T00:00:01.000Z"}' + +UPLOAD_RESP=$(curl -s -w "\n%{http_code}" \ + -X PUT \ + -H "Authorization: Bearer ${JACK_API_TOKEN}" \ + -H "Content-Type: application/x-ndjson" \ + --data-binary "${TRANSCRIPT_BODY}" \ + "${CONTROL_URL}/v1/projects/${PROJECT_ID}/deployments/${DEPLOYMENT_ID}/session-transcript") + +UPLOAD_STATUS=$(echo "$UPLOAD_RESP" | tail -1) +UPLOAD_BODY=$(echo "$UPLOAD_RESP" | head -1) + +check "PUT /session-transcript returns 200" "$([[ "$UPLOAD_STATUS" == "200" ]] && echo true || echo false)" +check "Response body contains ok:true" "$(echo "$UPLOAD_BODY" | grep -qc '"ok":true' && echo true || echo false)" + +echo " Response: $UPLOAD_BODY (HTTP $UPLOAD_STATUS)" + +# ── test 2: upload idempotency ──────────────────────────────────────────────── + +echo "" +echo "[ 2 ] Upload again (idempotency)" + +UPLOAD2_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X PUT \ + -H "Authorization: Bearer ${JACK_API_TOKEN}" \ + -H "Content-Type: application/x-ndjson" \ + --data-binary "${TRANSCRIPT_BODY}" \ + "${CONTROL_URL}/v1/projects/${PROJECT_ID}/deployments/${DEPLOYMENT_ID}/session-transcript") + +check "Second upload also returns 200" "$([[ "$UPLOAD2_STATUS" == "200" ]] && echo true || echo false)" + +# ── test 3: payload too large ───────────────────────────────────────────────── + +echo "" +echo "[ 3 ] Reject oversized payload (>1MB)" + +# Generate ~1.1MB of text +BIG_BODY=$(python3 -c "print('x' * 1_100_000)" 2>/dev/null || dd if=/dev/urandom bs=1100000 count=1 2>/dev/null | base64) + +OVERSIZE_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X PUT \ + -H "Authorization: Bearer ${JACK_API_TOKEN}" \ + -H "Content-Type: application/x-ndjson" \ + --data-binary "${BIG_BODY}" \ + "${CONTROL_URL}/v1/projects/${PROJECT_ID}/deployments/${DEPLOYMENT_ID}/session-transcript") + +check "Oversized payload returns 413" "$([[ "$OVERSIZE_STATUS" == "413" ]] && echo true || echo false)" + +# ── test 4: ask endpoint returns answer + evidence ─────────────────────────── + +echo "" +echo "[ 4 ] Ask endpoint — basic response shape" + +ASK_RESP=$(curl -s \ + -X POST \ + -H "Authorization: Bearer ${JACK_API_TOKEN}" \ + -H "Content-Type: application/json" \ + --data "{\"question\":\"Why did the api endpoint break?\",\"hints\":{\"deployment_id\":\"${DEPLOYMENT_ID}\"}}" \ + "${CONTROL_URL}/v1/projects/${PROJECT_ID}/ask") + +echo " Response: $(echo "$ASK_RESP" | head -c 300)..." + +check "ask response has 'answer' field" "$(echo "$ASK_RESP" | grep -qc '"answer"' && echo true || echo false)" +check "ask response has 'evidence' field" "$(echo "$ASK_RESP" | grep -qc '"evidence"' && echo true || echo false)" + +# ── test 5: session_transcript evidence is present ─────────────────────────── + +echo "" +echo "[ 5 ] Ask evidence includes session_transcript entry" + +check "Evidence contains session_transcript type" "$(echo "$ASK_RESP" | grep -qc '"session_transcript"' && echo true || echo false)" + +# ── test 6: LLM synthesis fields (present when ANTHROPIC_API_KEY is set) ───── + +echo "" +echo "[ 6 ] LLM synthesis fields (optional — only present when ANTHROPIC_API_KEY is configured)" + +HAS_CONFIDENCE=$(echo "$ASK_RESP" | grep -c '"confidence"' || true) +if [[ "$HAS_CONFIDENCE" -gt 0 ]]; then + check "confidence field present (LLM synthesis active)" "true" + check "confidence is valid value" "$(echo "$ASK_RESP" | grep -qE '"confidence":\s*"(high|medium|low)"' && echo true || echo false)" + check "root_cause field present" "$(echo "$ASK_RESP" | grep -qc '"root_cause"' && echo true || echo false)" +else + echo " SKIP confidence/root_cause fields — ANTHROPIC_API_KEY not set, using fallback pickAnswer" +fi + +# ── test 7: unauthorized request is rejected ───────────────────────────────── + +echo "" +echo "[ 7 ] Unauthorized upload is rejected" + +UNAUTH_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X PUT \ + -H "Authorization: Bearer bad_token" \ + -H "Content-Type: application/x-ndjson" \ + --data-binary "${TRANSCRIPT_BODY}" \ + "${CONTROL_URL}/v1/projects/${PROJECT_ID}/deployments/${DEPLOYMENT_ID}/session-transcript") + +check "Unauthorized request returns 401 or 403" "$([[ "$UNAUTH_STATUS" == "401" || "$UNAUTH_STATUS" == "403" ]] && echo true || echo false)" + +# ── summary ─────────────────────────────────────────────────────────────────── + +echo "" +echo "──────────────────────────────" +echo " $PASS passed / $FAIL failed" +echo "──────────────────────────────" + +if [[ "$FAIL" -gt 0 ]]; then + exit 1 +fi diff --git a/vocs.config.tsx b/vocs.config.tsx index 0ee4c6a..3076d54 100644 --- a/vocs.config.tsx +++ b/vocs.config.tsx @@ -25,6 +25,61 @@ export default defineConfig({ }, iconUrl: "/jack-logo.png", ogImageUrl: "/og.png", + font: { + default: { google: "Inter" }, + mono: { google: "JetBrains Mono" }, + }, + theme: { + accentColor: "oklch(0.72 0.15 220)", + colorScheme: "dark", + variables: { + color: { + // Background hierarchy + background: "oklch(0.06 0.01 250)", + background2: "oklch(0.10 0.012 250)", + background3: "oklch(0.10 0.012 250)", + background4: "oklch(0.14 0.013 250)", + background5: "oklch(0.18 0.015 250)", + backgroundDark: "oklch(0.06 0.01 250)", + backgroundDarkTint: "oklch(0.10 0.012 250)", + + // Text hierarchy + heading: "oklch(0.95 0.008 250)", + title: "oklch(0.95 0.008 250)", + text: "oklch(0.60 0.015 250)", + text2: "oklch(0.55 0.015 250)", + text3: "oklch(0.45 0.015 250)", + text4: "oklch(0.35 0.015 250)", + textHover: "oklch(0.95 0.008 250)", + + // Borders + border: "oklch(0.25 0.015 250)", + border2: "oklch(0.30 0.015 250)", + + // Accent (blue primary) + backgroundAccent: "oklch(0.72 0.15 220)", + backgroundAccentHover: "oklch(0.65 0.15 220)", + backgroundAccentText: "oklch(0.06 0.01 250)", + textAccent: "oklch(0.72 0.15 220)", + textAccentHover: "oklch(0.65 0.15 220)", + borderAccent: "oklch(0.72 0.15 220)", + + // Green (brand accent) + textGreen: "oklch(0.72 0.18 150)", + textGreenHover: "oklch(0.65 0.18 150)", + + // Kill shadows + shadow: "transparent", + shadow2: "transparent", + + // Inverted for buttons + inverted: "oklch(0.95 0.008 250)", + }, + // Code block & inline code (semantic) + // codeBlockBackground → card bg + // codeInlineBackground → muted bg + }, + }, topNav: [ { text: "Quickstart", link: "/quickstart" }, { text: "Templates", link: "/templates" },