diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 98571b33..928bedb6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,11 +38,15 @@ jobs: - name: Generate Pi extension shared copies run: | cd apps/pi-extension - for f in feedback-templates review-core storage draft project; do + mkdir -p generated + for f in feedback-templates review-core storage draft project pr-provider pr-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file; do src="../../packages/shared/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" done + - name: Type check + run: bun run typecheck + - name: Run tests run: bun test diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8823d7fe..f8c34a09 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,10 +25,14 @@ jobs: - name: Generate Pi extension shared copies run: | cd apps/pi-extension - for f in feedback-templates review-core storage draft project; do + mkdir -p generated + for f in feedback-templates review-core storage draft project pr-provider pr-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file; do src="../../packages/shared/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" done + - name: Type check + run: bun run typecheck + - name: Run tests run: bun test diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 209cab13..dfe29be2 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -49,9 +49,9 @@ import { handleAnnotateServerReady, } from "@plannotator/server/annotate"; import { getGitContext, runGitDiff } from "@plannotator/server/git"; -import { parsePRUrl, checkAuth, fetchPR, getCliName, getCliInstallUrl, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr"; +import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr"; import { writeRemoteShareLink } from "@plannotator/server/share-url"; -import { resolveMarkdownFile } from "@plannotator/server/resolve-file"; +import { resolveMarkdownFile } from "@plannotator/shared/resolve-file"; import { registerSession, unregisterSession, listSessions } from "@plannotator/server/sessions"; import { openBrowser } from "@plannotator/server/browser"; import { detectProjectName } from "@plannotator/server/project"; @@ -165,7 +165,7 @@ if (args[0] === "sessions") { const cliUrl = getCliInstallUrl(prRef); try { - await checkAuth(prRef); + await checkPRAuth(prRef); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("not found") || msg.includes("ENOENT")) { @@ -274,7 +274,7 @@ if (args[0] === "sessions") { } // Smart file resolution: exact path, case-insensitive relative, or bare filename search - const resolved = await resolveMarkdownFile(filePath, projectRoot); + const resolved = resolveMarkdownFile(filePath, projectRoot); if (resolved.kind === "ambiguous") { console.error(`Ambiguous filename "${resolved.input}" — found ${resolved.matches.length} matches:`); @@ -301,6 +301,7 @@ if (args[0] === "sessions") { origin: "claude-code", sharingEnabled, shareBaseUrl, + pasteApiUrl, htmlContent: planHtmlContent, onReady: async (url, isRemote, port) => { handleAnnotateServerReady(url, isRemote, port); @@ -418,6 +419,7 @@ if (args[0] === "sessions") { mode: "annotate-last", sharingEnabled, shareBaseUrl, + pasteApiUrl, htmlContent: planHtmlContent, onReady: async (url, isRemote, port) => { handleAnnotateServerReady(url, isRemote, port); diff --git a/apps/opencode-plugin/commands.ts b/apps/opencode-plugin/commands.ts index 7f68f529..9b309568 100644 --- a/apps/opencode-plugin/commands.ts +++ b/apps/opencode-plugin/commands.ts @@ -14,8 +14,8 @@ import { handleAnnotateServerReady, } from "@plannotator/server/annotate"; import { getGitContext, runGitDiffWithContext } from "@plannotator/server/git"; -import { parsePRUrl, checkAuth, fetchPR, getCliName, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr"; -import { resolveMarkdownFile } from "@plannotator/server/resolve-file"; +import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr"; +import { resolveMarkdownFile } from "@plannotator/shared/resolve-file"; /** Shared dependencies injected by the plugin */ export interface CommandDeps { @@ -53,7 +53,7 @@ export async function handleReviewCommand( client.app.log({ level: "info", message: `Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...` }); try { - await checkAuth(prRef); + await checkPRAuth(prRef); } catch (err) { const cliName = getCliName(prRef); client.app.log({ level: "error", message: err instanceof Error ? err.message : `${cliName} auth check failed` }); diff --git a/apps/pi-extension/.gitignore b/apps/pi-extension/.gitignore new file mode 100644 index 00000000..775021bf --- /dev/null +++ b/apps/pi-extension/.gitignore @@ -0,0 +1,3 @@ +generated/ +plannotator.html +review-editor.html diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index acc5b25d..e3ba3b3d 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -18,35 +18,42 @@ * - /plannotator-annotate command for markdown annotation */ -import { readFileSync, existsSync } from "node:fs"; -import { resolve, dirname } from "node:path"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, TextContent } from "@mariozechner/pi-ai"; import { Type } from "@mariozechner/pi-ai"; -import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import type { + ExtensionAPI, + ExtensionContext, +} from "@mariozechner/pi-coding-agent"; import { Key } from "@mariozechner/pi-tui"; -import { markCompletedSteps, parseChecklist, type ChecklistItem } from "./utils.js"; import { - type AnnotateServerResult, - startPlanReviewServer, - type PlanServerResult, - startReviewServer, - type ReviewServerResult, - startAnnotateServer, - getGitContext, - runGitDiff, - openBrowser, + type ChecklistItem, + markCompletedSteps, + parseChecklist, +} from "./generated/checklist.js"; +import { planDenyFeedback } from "./generated/feedback-templates.js"; +import { openBrowser } from "./server/network.js"; +import { + type AnnotateServerResult, + getGitContext, + type PlanServerResult, + type ReviewServerResult, + runGitDiff, + startAnnotateServer, + startPlanReviewServer, + startReviewServer, } from "./server.js"; -import { planDenyFeedback } from "./feedback-templates.js"; // ── Types ────────────────────────────────────────────────────────────── /** Common interface for servers that can wait for a user decision in a browser. */ interface DecisionServer { - url: string; - stop: () => void; - waitForDecision: () => Promise; + url: string; + stop: () => void; + waitForDecision: () => Promise; } // Load HTML at runtime (jiti doesn't support import attributes) @@ -54,14 +61,20 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); let planHtmlContent = ""; let reviewHtmlContent = ""; try { - planHtmlContent = readFileSync(resolve(__dirname, "plannotator.html"), "utf-8"); + planHtmlContent = readFileSync( + resolve(__dirname, "plannotator.html"), + "utf-8", + ); } catch { - // HTML not built yet — browser features will be unavailable + // HTML not built yet — browser features will be unavailable } try { - reviewHtmlContent = readFileSync(resolve(__dirname, "review-editor.html"), "utf-8"); + reviewHtmlContent = readFileSync( + resolve(__dirname, "review-editor.html"), + "utf-8", + ); } catch { - // HTML not built yet — review feature will be unavailable + // HTML not built yet — review feature will be unavailable } /** Extra tools to ensure are available during planning (on top of whatever is already active). */ @@ -70,14 +83,14 @@ const PLANNING_EXTRA_TOOLS = ["grep", "find", "ls", "exit_plan_mode"]; type Phase = "idle" | "planning" | "executing"; function isAssistantMessage(m: AgentMessage): m is AssistantMessage { - return m.role === "assistant" && Array.isArray(m.content); + return m.role === "assistant" && Array.isArray(m.content); } function getTextContent(message: AssistantMessage): string { - return message.content - .filter((block): block is TextContent => block.type === "text") - .map((block) => block.text) - .join("\n"); + return message.content + .filter((block): block is TextContent => block.type === "text") + .map((block) => block.text) + .join("\n"); } /** @@ -85,564 +98,618 @@ function getTextContent(message: AssistantMessage): string { * Handles remote session notification automatically. */ async function runBrowserReview( - server: DecisionServer, - ctx: ExtensionContext, + server: DecisionServer, + ctx: ExtensionContext, ): Promise { - const browserResult = openBrowser(server.url); - if (browserResult.isRemote) { - ctx.ui.notify(`Remote session. Open manually: ${browserResult.url}`, "info"); - } + const browserResult = openBrowser(server.url); + if (browserResult.isRemote) { + ctx.ui.notify( + `Remote session. Open manually: ${browserResult.url}`, + "info", + ); + } - const result = await server.waitForDecision(); - await new Promise((r) => setTimeout(r, 1500)); - server.stop(); - return result; + const result = await server.waitForDecision(); + await new Promise((r) => setTimeout(r, 1500)); + server.stop(); + return result; } function getStartupErrorMessage(err: unknown): string { - return err instanceof Error ? err.message : "Unknown error"; + return err instanceof Error ? err.message : "Unknown error"; } export default function plannotator(pi: ExtensionAPI): void { - let phase: Phase = "idle"; - let planFilePath = "PLAN.md"; - let checklistItems: ChecklistItem[] = []; - let preplanTools: string[] | null = null; - - // ── Flags ──────────────────────────────────────────────────────────── - - pi.registerFlag("plan", { - description: "Start in plan mode (restricted exploration and planning)", - type: "boolean", - default: false, - }); - - pi.registerFlag("plan-file", { - description: "Plan file path (default: PLAN.md)", - type: "string", - default: "PLAN.md", - }); - - // ── Helpers ────────────────────────────────────────────────────────── - - function resolvePlanPath(cwd: string): string { - return resolve(cwd, planFilePath); - } - - function updateStatus(ctx: ExtensionContext): void { - if (phase === "executing" && checklistItems.length > 0) { - const completed = checklistItems.filter((t) => t.completed).length; - ctx.ui.setStatus("plannotator", ctx.ui.theme.fg("accent", `📋 ${completed}/${checklistItems.length}`)); - } else if (phase === "planning") { - ctx.ui.setStatus("plannotator", ctx.ui.theme.fg("warning", "⏸ plan")); - } else { - ctx.ui.setStatus("plannotator", undefined); - } - } - - function updateWidget(ctx: ExtensionContext): void { - if (phase === "executing" && checklistItems.length > 0) { - const lines = checklistItems.map((item) => { - if (item.completed) { - return ( - ctx.ui.theme.fg("success", "☑ ") + - ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text)) - ); - } - return `${ctx.ui.theme.fg("muted", "☐ ")}${item.text}`; - }); - ctx.ui.setWidget("plannotator-progress", lines); - } else { - ctx.ui.setWidget("plannotator-progress", undefined); - } - } - - function persistState(): void { - pi.appendEntry("plannotator", { phase, planFilePath }); - } - - /** Apply tool visibility for the current phase, preserving tools from other extensions. */ - function applyToolsForPhase(): void { - if (phase === "planning") { - const base = preplanTools ?? pi.getActiveTools(); - const toolSet = new Set(base); - for (const t of PLANNING_EXTRA_TOOLS) toolSet.add(t); - pi.setActiveTools([...toolSet]); - } else if (preplanTools) { - // Restore pre-plan tool set (removes exit_plan_mode, etc.) - pi.setActiveTools(preplanTools); - preplanTools = null; - } - // If no preplanTools (e.g. session restore to executing/idle), leave tools as-is - } - - function enterPlanning(ctx: ExtensionContext): void { - phase = "planning"; - checklistItems = []; - preplanTools = pi.getActiveTools(); - applyToolsForPhase(); - updateStatus(ctx); - updateWidget(ctx); - persistState(); - ctx.ui.notify(`Plannotator: planning mode enabled. Write your plan to ${planFilePath}.`); - } - - function exitToIdle(ctx: ExtensionContext): void { - phase = "idle"; - checklistItems = []; - applyToolsForPhase(); - updateStatus(ctx); - updateWidget(ctx); - persistState(); - ctx.ui.notify("Plannotator: disabled. Full access restored."); - } - - function togglePlanMode(ctx: ExtensionContext): void { - if (phase === "idle") { - enterPlanning(ctx); - } else { - exitToIdle(ctx); - } - } - - // ── Commands & Shortcuts ───────────────────────────────────────────── - - pi.registerCommand("plannotator", { - description: "Toggle plannotator (file-based plan mode)", - handler: async (args, ctx) => { - if (phase !== "idle") { - exitToIdle(ctx); - return; - } - - // Accept path as argument: /plannotator plans/auth.md - let targetPath = args?.trim() || undefined; - - // No arg — prompt for file path interactively - if (!targetPath && ctx.hasUI) { - targetPath = await ctx.ui.input("Plan file path", planFilePath); - if (targetPath === undefined) return; // cancelled - } - - if (targetPath) planFilePath = targetPath; - enterPlanning(ctx); - }, - }); - - pi.registerCommand("plannotator-status", { - description: "Show plannotator status", - handler: async (_args, ctx) => { - const parts = [`Phase: ${phase}`, `Plan file: ${planFilePath}`]; - if (checklistItems.length > 0) { - const done = checklistItems.filter((t) => t.completed).length; - parts.push(`Progress: ${done}/${checklistItems.length}`); - } - ctx.ui.notify(parts.join("\n"), "info"); - }, - }); - - pi.registerCommand("plannotator-set-file", { - description: "Change the plan file path", - handler: async (args, ctx) => { - let targetPath = args?.trim() || undefined; - - if (!targetPath && ctx.hasUI) { - targetPath = await ctx.ui.input("Plan file path", planFilePath); - if (targetPath === undefined) return; // cancelled - } - - if (!targetPath) { - ctx.ui.notify(`Current plan file: ${planFilePath}`, "info"); - return; - } - - planFilePath = targetPath; - persistState(); - ctx.ui.notify(`Plan file changed to: ${planFilePath}`); - }, - }); - - pi.registerCommand("plannotator-review", { - description: "Open interactive code review for current changes", - handler: async (_args, ctx) => { - if (!reviewHtmlContent) { - ctx.ui.notify("Review UI not available. Run 'bun run build' in the pi-extension directory.", "error"); - return; - } - - ctx.ui.notify("Opening code review UI...", "info"); - - let server: ReviewServerResult; - try { - const gitCtx = await getGitContext(); - const { - patch: rawPatch, - label: gitRef, - error, - } = await runGitDiff("uncommitted", gitCtx.defaultBranch); - - server = await startReviewServer({ - rawPatch, - gitRef, - error, - origin: "pi", - diffType: "uncommitted", - gitContext: gitCtx, - htmlContent: reviewHtmlContent, - sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled", - shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined, - pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined, - }); - } catch (err) { - ctx.ui.notify(`Failed to start code review UI: ${getStartupErrorMessage(err)}`, "error"); - return; - } - - const result = await runBrowserReview(server, ctx); - - if (result.feedback) { - if (result.approved) { - pi.sendUserMessage(`# Code Review\n\nCode review completed — no changes requested.`); - } else { - pi.sendUserMessage(`${result.feedback}\n\nPlease address this feedback.`); - } - } else { - ctx.ui.notify("Code review closed (no feedback).", "info"); - } - }, - }); - - pi.registerCommand("plannotator-annotate", { - description: "Open markdown file in annotation UI", - handler: async (args, ctx) => { - const filePath = args?.trim(); - if (!filePath) { - ctx.ui.notify("Usage: /plannotator-annotate ", "error"); - return; - } - if (!planHtmlContent) { - ctx.ui.notify("Annotation UI not available. Run 'bun run build' in the pi-extension directory.", "error"); - return; - } - - const absolutePath = resolve(ctx.cwd, filePath); - if (!existsSync(absolutePath)) { - ctx.ui.notify(`File not found: ${absolutePath}`, "error"); - return; - } - - ctx.ui.notify(`Opening annotation UI for ${filePath}...`, "info"); - - const markdown = readFileSync(absolutePath, "utf-8"); - let server: AnnotateServerResult; - try { - server = await startAnnotateServer({ - markdown, - filePath: absolutePath, - origin: "pi", - htmlContent: planHtmlContent, - sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled", - shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined, - pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined, - }); - } catch (err) { - ctx.ui.notify(`Failed to start annotation UI: ${getStartupErrorMessage(err)}`, "error"); - return; - } - - const result = await runBrowserReview(server, ctx); - - if (result.feedback) { - pi.sendUserMessage( - `# Markdown Annotations\n\nFile: ${absolutePath}\n\n${result.feedback}\n\nPlease address the annotation feedback above.`, - ); - } else { - ctx.ui.notify("Annotation closed (no feedback).", "info"); - } - }, - }); - - pi.registerCommand("plannotator-last", { - description: "Annotate the last assistant message", - handler: async (args, ctx) => { - if (!planHtmlContent) { - ctx.ui.notify("Annotation UI not available. Run 'bun run build' in the pi-extension directory.", "error"); - return; - } - - const entries = ctx.sessionManager.getEntries(); - let lastText: string | null = null; - for (let i = entries.length - 1; i >= 0; i--) { - const entry = entries[i] as { type: string; message?: AgentMessage }; - if (entry.type === "message" && entry.message && isAssistantMessage(entry.message)) { - const text = getTextContent(entry.message); - if (text.trim()) { - lastText = text; - break; - } - } - } - - if (!lastText) { - ctx.ui.notify("No assistant message found in session.", "error"); - return; - } - - ctx.ui.notify("Opening annotation UI for last message...", "info"); - - let server: AnnotateServerResult; - try { - server = await startAnnotateServer({ - markdown: lastText, - filePath: "last-message", - origin: "pi", - mode: "annotate-last", - htmlContent: planHtmlContent, - sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled", - shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined, - pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined, - }); - } catch (err) { - ctx.ui.notify(`Failed to start annotation UI: ${getStartupErrorMessage(err)}`, "error"); - return; - } - - const result = await runBrowserReview(server, ctx); - - if (result.feedback) { - pi.sendUserMessage( - `# Message Annotations\n\n${result.feedback}\n\nPlease address the annotation feedback above.`, - ); - } else { - ctx.ui.notify("Annotation closed (no feedback).", "info"); - } - }, - }); - - pi.registerCommand("plannotator-archive", { - description: "Browse saved plan decisions", - handler: async (_args, ctx) => { - if (!planHtmlContent) { - ctx.ui.notify("Archive UI not available. Run 'bun run build' in the pi-extension directory.", "error"); - return; - } - - ctx.ui.notify("Opening plan archive...", "info"); - - let server: PlanServerResult; - try { - server = await startPlanReviewServer({ - plan: "", - htmlContent: planHtmlContent, - origin: "pi", - mode: "archive", - sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled", - shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined, - pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined, - }); - } catch (err) { - ctx.ui.notify(`Failed to start archive: ${getStartupErrorMessage(err)}`, "error"); - return; - } - - const browserResult = openBrowser(server.url); - if (browserResult.isRemote) { - ctx.ui.notify(`Remote session. Open manually: ${browserResult.url}`, "info"); - } - - await server.waitForDone!(); - await new Promise((r) => setTimeout(r, 1500)); - server.stop(); - ctx.ui.notify("Archive browser closed.", "info"); - }, - }); - - pi.registerShortcut(Key.ctrlAlt("p"), { - description: "Toggle plannotator", - handler: async (ctx) => togglePlanMode(ctx), - }); - - // ── exit_plan_mode Tool ────────────────────────────────────────────── - - pi.registerTool({ - name: "exit_plan_mode", - label: "Exit Plan Mode", - description: - "Submit your plan for user review. " + - "Call this after drafting or revising your plan file. " + - "The user will review the plan in a visual browser UI and can approve, deny with feedback, or annotate it. " + - "If denied, use the edit tool to make targeted revisions (not write), then call this again.", - parameters: Type.Object({ - summary: Type.Optional( - Type.String({ description: "Brief summary of the plan for the user's review" }), - ), - }), - - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - // Guard: must be in planning phase - if (phase !== "planning") { - return { - content: [{ type: "text", text: "Error: Not in plan mode. Use /plannotator to enter planning mode first." }], - details: { approved: false }, - }; - } - - // Read plan file - const fullPath = resolvePlanPath(ctx.cwd); - let planContent: string; - try { - planContent = readFileSync(fullPath, "utf-8"); - } catch { - return { - content: [ - { - type: "text", - text: `Error: ${planFilePath} does not exist. Write your plan using the write tool first, then call exit_plan_mode again.`, - }, - ], - details: { approved: false }, - }; - } - - if (planContent.trim().length === 0) { - return { - content: [ - { - type: "text", - text: `Error: ${planFilePath} is empty. Write your plan first, then call exit_plan_mode again.`, - }, - ], - details: { approved: false }, - }; - } - - // Parse checklist items - checklistItems = parseChecklist(planContent); - - // Non-interactive or no HTML: auto-approve - if (!ctx.hasUI || !planHtmlContent) { - phase = "executing"; - applyToolsForPhase(); - persistState(); - return { - content: [ - { - type: "text", - text: "Plan auto-approved (non-interactive mode). Execute the plan now.", - }, - ], - details: { approved: true }, - }; - } - - // Start browser-based plan review server - let server: PlanServerResult; - try { - server = await startPlanReviewServer({ - plan: planContent, - htmlContent: planHtmlContent, - origin: "pi", - sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled", - shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined, - pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined, - }); - } catch (err) { - const message = `Failed to start plan review UI: ${getStartupErrorMessage(err)}`; - ctx.ui.notify(message, "error"); - return { - content: [{ type: "text", text: message }], - details: { approved: false }, - }; - } - - const result = await runBrowserReview(server, ctx); - - if (result.approved) { - phase = "executing"; - applyToolsForPhase(); - updateStatus(ctx); - updateWidget(ctx); - persistState(); - - pi.appendEntry("plannotator-execute", { planFilePath }); - - const doneMsg = checklistItems.length > 0 - ? `After completing each step, include [DONE:n] in your response where n is the step number.` - : ""; - - if (result.feedback) { - return { - content: [ - { - type: "text", - text: `Plan approved with notes! You now have full tool access (read, bash, edit, write). Execute the plan in ${planFilePath}. ${doneMsg}\n\n## Implementation Notes\n\nThe user approved your plan but added the following notes to consider during implementation:\n\n${result.feedback}\n\nProceed with implementation, incorporating these notes where applicable.`, - }, - ], - details: { approved: true, feedback: result.feedback }, - }; - } - - return { - content: [ - { - type: "text", - text: `Plan approved. You now have full tool access (read, bash, edit, write). Execute the plan in ${planFilePath}. ${doneMsg}`, - }, - ], - details: { approved: true }, - }; - } - - // Denied - const feedbackText = result.feedback || "Plan rejected. Please revise."; - return { - content: [ - { - type: "text", - text: planDenyFeedback(feedbackText, "exit_plan_mode", { planFilePath }), - }, - ], - details: { approved: false, feedback: feedbackText }, - }; - }, - }); - - // ── Event Handlers ─────────────────────────────────────────────────── - - // Gate writes during planning - pi.on("tool_call", async (event, ctx) => { - if (phase !== "planning") return; - - if (event.toolName === "write") { - const targetPath = resolve(ctx.cwd, event.input.path as string); - const allowedPath = resolvePlanPath(ctx.cwd); - if (targetPath !== allowedPath) { - return { - block: true, - reason: `Plannotator: writes are restricted to ${planFilePath} during planning. Blocked: ${event.input.path}`, - }; - } - } - - if (event.toolName === "edit") { - const targetPath = resolve(ctx.cwd, event.input.path as string); - const allowedPath = resolvePlanPath(ctx.cwd); - if (targetPath !== allowedPath) { - return { - block: true, - reason: `Plannotator: edits are restricted to ${planFilePath} during planning. Blocked: ${event.input.path}`, - }; - } - } - }); - - // Inject phase-specific context - pi.on("before_agent_start", async (_event, ctx) => { - if (phase === "planning") { - return { - message: { - customType: "plannotator-context", - content: `[PLANNOTATOR - PLANNING PHASE] + let phase: Phase = "idle"; + let planFilePath = "PLAN.md"; + let checklistItems: ChecklistItem[] = []; + let preplanTools: string[] | null = null; + + // ── Flags ──────────────────────────────────────────────────────────── + + pi.registerFlag("plan", { + description: "Start in plan mode (restricted exploration and planning)", + type: "boolean", + default: false, + }); + + pi.registerFlag("plan-file", { + description: "Plan file path (default: PLAN.md)", + type: "string", + default: "PLAN.md", + }); + + // ── Helpers ────────────────────────────────────────────────────────── + + function resolvePlanPath(cwd: string): string { + return resolve(cwd, planFilePath); + } + + function updateStatus(ctx: ExtensionContext): void { + if (phase === "executing" && checklistItems.length > 0) { + const completed = checklistItems.filter((t) => t.completed).length; + ctx.ui.setStatus( + "plannotator", + ctx.ui.theme.fg("accent", `📋 ${completed}/${checklistItems.length}`), + ); + } else if (phase === "planning") { + ctx.ui.setStatus("plannotator", ctx.ui.theme.fg("warning", "⏸ plan")); + } else { + ctx.ui.setStatus("plannotator", undefined); + } + } + + function updateWidget(ctx: ExtensionContext): void { + if (phase === "executing" && checklistItems.length > 0) { + const lines = checklistItems.map((item) => { + if (item.completed) { + return ( + ctx.ui.theme.fg("success", "☑ ") + + ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text)) + ); + } + return `${ctx.ui.theme.fg("muted", "☐ ")}${item.text}`; + }); + ctx.ui.setWidget("plannotator-progress", lines); + } else { + ctx.ui.setWidget("plannotator-progress", undefined); + } + } + + function persistState(): void { + pi.appendEntry("plannotator", { phase, planFilePath }); + } + + /** Apply tool visibility for the current phase, preserving tools from other extensions. */ + function applyToolsForPhase(): void { + if (phase === "planning") { + const base = preplanTools ?? pi.getActiveTools(); + const toolSet = new Set(base); + for (const t of PLANNING_EXTRA_TOOLS) toolSet.add(t); + pi.setActiveTools([...toolSet]); + } else if (preplanTools) { + // Restore pre-plan tool set (removes exit_plan_mode, etc.) + pi.setActiveTools(preplanTools); + preplanTools = null; + } + // If no preplanTools (e.g. session restore to executing/idle), leave tools as-is + } + + function enterPlanning(ctx: ExtensionContext): void { + phase = "planning"; + checklistItems = []; + preplanTools = pi.getActiveTools(); + applyToolsForPhase(); + updateStatus(ctx); + updateWidget(ctx); + persistState(); + ctx.ui.notify( + `Plannotator: planning mode enabled. Write your plan to ${planFilePath}.`, + ); + } + + function exitToIdle(ctx: ExtensionContext): void { + phase = "idle"; + checklistItems = []; + applyToolsForPhase(); + updateStatus(ctx); + updateWidget(ctx); + persistState(); + ctx.ui.notify("Plannotator: disabled. Full access restored."); + } + + function togglePlanMode(ctx: ExtensionContext): void { + if (phase === "idle") { + enterPlanning(ctx); + } else { + exitToIdle(ctx); + } + } + + // ── Commands & Shortcuts ───────────────────────────────────────────── + + pi.registerCommand("plannotator", { + description: "Toggle plannotator (file-based plan mode)", + handler: async (args, ctx) => { + if (phase !== "idle") { + exitToIdle(ctx); + return; + } + + // Accept path as argument: /plannotator plans/auth.md + let targetPath = args?.trim() || undefined; + + // No arg — prompt for file path interactively + if (!targetPath && ctx.hasUI) { + targetPath = await ctx.ui.input("Plan file path", planFilePath); + if (targetPath === undefined) return; // cancelled + } + + if (targetPath) planFilePath = targetPath; + enterPlanning(ctx); + }, + }); + + pi.registerCommand("plannotator-status", { + description: "Show plannotator status", + handler: async (_args, ctx) => { + const parts = [`Phase: ${phase}`, `Plan file: ${planFilePath}`]; + if (checklistItems.length > 0) { + const done = checklistItems.filter((t) => t.completed).length; + parts.push(`Progress: ${done}/${checklistItems.length}`); + } + ctx.ui.notify(parts.join("\n"), "info"); + }, + }); + + pi.registerCommand("plannotator-set-file", { + description: "Change the plan file path", + handler: async (args, ctx) => { + let targetPath = args?.trim() || undefined; + + if (!targetPath && ctx.hasUI) { + targetPath = await ctx.ui.input("Plan file path", planFilePath); + if (targetPath === undefined) return; // cancelled + } + + if (!targetPath) { + ctx.ui.notify(`Current plan file: ${planFilePath}`, "info"); + return; + } + + planFilePath = targetPath; + persistState(); + ctx.ui.notify(`Plan file changed to: ${planFilePath}`); + }, + }); + + pi.registerCommand("plannotator-review", { + description: "Open interactive code review for current changes", + handler: async (_args, ctx) => { + if (!reviewHtmlContent) { + ctx.ui.notify( + "Review UI not available. Run 'bun run build' in the pi-extension directory.", + "error", + ); + return; + } + + ctx.ui.notify("Opening code review UI...", "info"); + + let server: ReviewServerResult; + try { + const gitCtx = await getGitContext(); + const { + patch: rawPatch, + label: gitRef, + error, + } = await runGitDiff("uncommitted", gitCtx.defaultBranch); + + server = await startReviewServer({ + rawPatch, + gitRef, + error, + origin: "pi", + diffType: "uncommitted", + gitContext: gitCtx, + htmlContent: reviewHtmlContent, + sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled", + shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined, + }); + } catch (err) { + ctx.ui.notify( + `Failed to start code review UI: ${getStartupErrorMessage(err)}`, + "error", + ); + return; + } + + const result = await runBrowserReview(server, ctx); + + if (result.feedback) { + if (result.approved) { + pi.sendUserMessage( + `# Code Review\n\nCode review completed — no changes requested.`, + ); + } else { + pi.sendUserMessage( + `${result.feedback}\n\nPlease address this feedback.`, + ); + } + } else { + ctx.ui.notify("Code review closed (no feedback).", "info"); + } + }, + }); + + pi.registerCommand("plannotator-annotate", { + description: "Open markdown file in annotation UI", + handler: async (args, ctx) => { + const filePath = args?.trim(); + if (!filePath) { + ctx.ui.notify("Usage: /plannotator-annotate ", "error"); + return; + } + if (!planHtmlContent) { + ctx.ui.notify( + "Annotation UI not available. Run 'bun run build' in the pi-extension directory.", + "error", + ); + return; + } + + const absolutePath = resolve(ctx.cwd, filePath); + if (!existsSync(absolutePath)) { + ctx.ui.notify(`File not found: ${absolutePath}`, "error"); + return; + } + + ctx.ui.notify(`Opening annotation UI for ${filePath}...`, "info"); + + const markdown = readFileSync(absolutePath, "utf-8"); + let server: AnnotateServerResult; + try { + server = await startAnnotateServer({ + markdown, + filePath: absolutePath, + origin: "pi", + htmlContent: planHtmlContent, + sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled", + shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined, + pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined, + }); + } catch (err) { + ctx.ui.notify( + `Failed to start annotation UI: ${getStartupErrorMessage(err)}`, + "error", + ); + return; + } + + const result = await runBrowserReview(server, ctx); + + if (result.feedback) { + pi.sendUserMessage( + `# Markdown Annotations\n\nFile: ${absolutePath}\n\n${result.feedback}\n\nPlease address the annotation feedback above.`, + ); + } else { + ctx.ui.notify("Annotation closed (no feedback).", "info"); + } + }, + }); + + pi.registerCommand("plannotator-last", { + description: "Annotate the last assistant message", + handler: async (_args, ctx) => { + if (!planHtmlContent) { + ctx.ui.notify( + "Annotation UI not available. Run 'bun run build' in the pi-extension directory.", + "error", + ); + return; + } + + const entries = ctx.sessionManager.getEntries(); + let lastText: string | null = null; + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i] as { type: string; message?: AgentMessage }; + if ( + entry.type === "message" && + entry.message && + isAssistantMessage(entry.message) + ) { + const text = getTextContent(entry.message); + if (text.trim()) { + lastText = text; + break; + } + } + } + + if (!lastText) { + ctx.ui.notify("No assistant message found in session.", "error"); + return; + } + + ctx.ui.notify("Opening annotation UI for last message...", "info"); + + let server: AnnotateServerResult; + try { + server = await startAnnotateServer({ + markdown: lastText, + filePath: "last-message", + origin: "pi", + mode: "annotate-last", + htmlContent: planHtmlContent, + sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled", + shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined, + pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined, + }); + } catch (err) { + ctx.ui.notify( + `Failed to start annotation UI: ${getStartupErrorMessage(err)}`, + "error", + ); + return; + } + + const result = await runBrowserReview(server, ctx); + + if (result.feedback) { + pi.sendUserMessage( + `# Message Annotations\n\n${result.feedback}\n\nPlease address the annotation feedback above.`, + ); + } else { + ctx.ui.notify("Annotation closed (no feedback).", "info"); + } + }, + }); + + pi.registerCommand("plannotator-archive", { + description: "Browse saved plan decisions", + handler: async (_args, ctx) => { + if (!planHtmlContent) { + ctx.ui.notify( + "Archive UI not available. Run 'bun run build' in the pi-extension directory.", + "error", + ); + return; + } + + ctx.ui.notify("Opening plan archive...", "info"); + + let server: PlanServerResult; + try { + server = await startPlanReviewServer({ + plan: "", + htmlContent: planHtmlContent, + origin: "pi", + mode: "archive", + sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled", + shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined, + pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined, + }); + } catch (err) { + ctx.ui.notify( + `Failed to start archive: ${getStartupErrorMessage(err)}`, + "error", + ); + return; + } + + const browserResult = openBrowser(server.url); + if (browserResult.isRemote) { + ctx.ui.notify( + `Remote session. Open manually: ${browserResult.url}`, + "info", + ); + } + + if (server.waitForDone) { + await server.waitForDone(); + } + await new Promise((r) => setTimeout(r, 1500)); + server.stop(); + ctx.ui.notify("Archive browser closed.", "info"); + }, + }); + + pi.registerShortcut(Key.ctrlAlt("p"), { + description: "Toggle plannotator", + handler: async (ctx) => togglePlanMode(ctx), + }); + + // ── exit_plan_mode Tool ────────────────────────────────────────────── + + pi.registerTool({ + name: "exit_plan_mode", + label: "Exit Plan Mode", + description: + "Submit your plan for user review. " + + "Call this after drafting or revising your plan file. " + + "The user will review the plan in a visual browser UI and can approve, deny with feedback, or annotate it. " + + "If denied, use the edit tool to make targeted revisions (not write), then call this again.", + parameters: Type.Object({ + summary: Type.Optional( + Type.String({ + description: "Brief summary of the plan for the user's review", + }), + ), + }), + + async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { + // Guard: must be in planning phase + if (phase !== "planning") { + return { + content: [ + { + type: "text", + text: "Error: Not in plan mode. Use /plannotator to enter planning mode first.", + }, + ], + details: { approved: false }, + }; + } + + // Read plan file + const fullPath = resolvePlanPath(ctx.cwd); + let planContent: string; + try { + planContent = readFileSync(fullPath, "utf-8"); + } catch { + return { + content: [ + { + type: "text", + text: `Error: ${planFilePath} does not exist. Write your plan using the write tool first, then call exit_plan_mode again.`, + }, + ], + details: { approved: false }, + }; + } + + if (planContent.trim().length === 0) { + return { + content: [ + { + type: "text", + text: `Error: ${planFilePath} is empty. Write your plan first, then call exit_plan_mode again.`, + }, + ], + details: { approved: false }, + }; + } + + // Parse checklist items + checklistItems = parseChecklist(planContent); + + // Non-interactive or no HTML: auto-approve + if (!ctx.hasUI || !planHtmlContent) { + phase = "executing"; + applyToolsForPhase(); + persistState(); + return { + content: [ + { + type: "text", + text: "Plan auto-approved (non-interactive mode). Execute the plan now.", + }, + ], + details: { approved: true }, + }; + } + + // Start browser-based plan review server + let server: PlanServerResult; + try { + server = await startPlanReviewServer({ + plan: planContent, + htmlContent: planHtmlContent, + origin: "pi", + sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled", + shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined, + pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined, + }); + } catch (err) { + const message = `Failed to start plan review UI: ${getStartupErrorMessage(err)}`; + ctx.ui.notify(message, "error"); + return { + content: [{ type: "text", text: message }], + details: { approved: false }, + }; + } + + const result = await runBrowserReview(server, ctx); + + if (result.approved) { + phase = "executing"; + applyToolsForPhase(); + updateStatus(ctx); + updateWidget(ctx); + persistState(); + + pi.appendEntry("plannotator-execute", { planFilePath }); + + const doneMsg = + checklistItems.length > 0 + ? `After completing each step, include [DONE:n] in your response where n is the step number.` + : ""; + + if (result.feedback) { + return { + content: [ + { + type: "text", + text: `Plan approved with notes! You now have full tool access (read, bash, edit, write). Execute the plan in ${planFilePath}. ${doneMsg}\n\n## Implementation Notes\n\nThe user approved your plan but added the following notes to consider during implementation:\n\n${result.feedback}\n\nProceed with implementation, incorporating these notes where applicable.`, + }, + ], + details: { approved: true, feedback: result.feedback }, + }; + } + + return { + content: [ + { + type: "text", + text: `Plan approved. You now have full tool access (read, bash, edit, write). Execute the plan in ${planFilePath}. ${doneMsg}`, + }, + ], + details: { approved: true }, + }; + } + + // Denied + const feedbackText = result.feedback || "Plan rejected. Please revise."; + return { + content: [ + { + type: "text", + text: planDenyFeedback(feedbackText, "exit_plan_mode", { + planFilePath, + }), + }, + ], + details: { approved: false, feedback: feedbackText }, + }; + }, + }); + + // ── Event Handlers ─────────────────────────────────────────────────── + + // Gate writes during planning + pi.on("tool_call", async (event, ctx) => { + if (phase !== "planning") return; + + if (event.toolName === "write") { + const targetPath = resolve(ctx.cwd, event.input.path as string); + const allowedPath = resolvePlanPath(ctx.cwd); + if (targetPath !== allowedPath) { + return { + block: true, + reason: `Plannotator: writes are restricted to ${planFilePath} during planning. Blocked: ${event.input.path}`, + }; + } + } + + if (event.toolName === "edit") { + const targetPath = resolve(ctx.cwd, event.input.path as string); + const allowedPath = resolvePlanPath(ctx.cwd); + if (targetPath !== allowedPath) { + return { + block: true, + reason: `Plannotator: edits are restricted to ${planFilePath} during planning. Blocked: ${event.input.path}`, + }; + } + } + }); + + // Inject phase-specific context + pi.on("before_agent_start", async (_event, ctx) => { + if (phase === "planning") { + return { + message: { + customType: "plannotator-context", + content: `[PLANNOTATOR - PLANNING PHASE] You are in plan mode. You MUST NOT make any changes to the codebase — no edits, no commits, no installs, no destructive commands. The ONLY file you may write to or edit is the plan file: ${planFilePath}. Available tools: read, bash, grep, find, ls, write (${planFilePath} only), edit (${planFilePath} only), exit_plan_mode @@ -704,166 +771,175 @@ Your turn should only end by either: - Calling exit_plan_mode when the plan is ready for review. Do not end your turn without doing one of these two things.`, - display: false, - }, - }; - } + display: false, + }, + }; + } - if (phase === "executing" && checklistItems.length > 0) { - // Re-read from disk each turn to stay current - const fullPath = resolvePlanPath(ctx.cwd); - let planContent = ""; - try { - planContent = readFileSync(fullPath, "utf-8"); - checklistItems = parseChecklist(planContent); - } catch { - // File deleted during execution — degrade gracefully - } + if (phase === "executing" && checklistItems.length > 0) { + // Re-read from disk each turn to stay current + const fullPath = resolvePlanPath(ctx.cwd); + let planContent = ""; + try { + planContent = readFileSync(fullPath, "utf-8"); + checklistItems = parseChecklist(planContent); + } catch { + // File deleted during execution — degrade gracefully + } - const remaining = checklistItems.filter((t) => !t.completed); - if (remaining.length > 0) { - const todoList = remaining.map((t) => `- [ ] ${t.step}. ${t.text}`).join("\n"); - return { - message: { - customType: "plannotator-context", - content: `[PLANNOTATOR - EXECUTING PLAN] + const remaining = checklistItems.filter((t) => !t.completed); + if (remaining.length > 0) { + const todoList = remaining + .map((t) => `- [ ] ${t.step}. ${t.text}`) + .join("\n"); + return { + message: { + customType: "plannotator-context", + content: `[PLANNOTATOR - EXECUTING PLAN] Full tool access is enabled. Execute the plan from ${planFilePath}. Remaining steps: ${todoList} Execute each step in order. After completing a step, include [DONE:n] in your response where n is the step number.`, - display: false, - }, - }; - } - } - }); + display: false, + }, + }; + } + } + }); - // Filter stale context when idle - pi.on("context", async (event) => { - if (phase !== "idle") return; + // Filter stale context when idle + pi.on("context", async (event) => { + if (phase !== "idle") return; - return { - messages: event.messages.filter((m) => { - const msg = m as AgentMessage & { customType?: string }; - if (msg.customType === "plannotator-context") return false; - if (msg.role !== "user") return true; + return { + messages: event.messages.filter((m) => { + const msg = m as AgentMessage & { customType?: string }; + if (msg.customType === "plannotator-context") return false; + if (msg.role !== "user") return true; - const content = msg.content; - if (typeof content === "string") { - return !content.includes("[PLANNOTATOR -"); - } - if (Array.isArray(content)) { - return !content.some( - (c) => c.type === "text" && (c as TextContent).text?.includes("[PLANNOTATOR -"), - ); - } - return true; - }), - }; - }); + const content = msg.content; + if (typeof content === "string") { + return !content.includes("[PLANNOTATOR -"); + } + if (Array.isArray(content)) { + return !content.some( + (c) => + c.type === "text" && + (c as TextContent).text?.includes("[PLANNOTATOR -"), + ); + } + return true; + }), + }; + }); - // Track execution progress - pi.on("turn_end", async (event, ctx) => { - if (phase !== "executing" || checklistItems.length === 0) return; - if (!isAssistantMessage(event.message)) return; + // Track execution progress + pi.on("turn_end", async (event, ctx) => { + if (phase !== "executing" || checklistItems.length === 0) return; + if (!isAssistantMessage(event.message)) return; - const text = getTextContent(event.message); - if (markCompletedSteps(text, checklistItems) > 0) { - updateStatus(ctx); - updateWidget(ctx); - } - persistState(); - }); + const text = getTextContent(event.message); + if (markCompletedSteps(text, checklistItems) > 0) { + updateStatus(ctx); + updateWidget(ctx); + } + persistState(); + }); - // Detect execution completion - pi.on("agent_end", async (_event, ctx) => { - if (phase !== "executing" || checklistItems.length === 0) return; + // Detect execution completion + pi.on("agent_end", async (_event, ctx) => { + if (phase !== "executing" || checklistItems.length === 0) return; - if (checklistItems.every((t) => t.completed)) { - const completedList = checklistItems.map((t) => `- [x] ~~${t.text}~~`).join("\n"); - pi.sendMessage( - { - customType: "plannotator-complete", - content: `**Plan Complete!** ✓\n\n${completedList}`, - display: true, - }, - { triggerTurn: false }, - ); - phase = "idle"; - checklistItems = []; - applyToolsForPhase(); - updateStatus(ctx); - updateWidget(ctx); - persistState(); - } - }); + if (checklistItems.every((t) => t.completed)) { + const completedList = checklistItems + .map((t) => `- [x] ~~${t.text}~~`) + .join("\n"); + pi.sendMessage( + { + customType: "plannotator-complete", + content: `**Plan Complete!** ✓\n\n${completedList}`, + display: true, + }, + { triggerTurn: false }, + ); + phase = "idle"; + checklistItems = []; + applyToolsForPhase(); + updateStatus(ctx); + updateWidget(ctx); + persistState(); + } + }); - // Restore state on session start/resume - pi.on("session_start", async (_event, ctx) => { - // Resolve plan file path from flag - const flagPlanFile = pi.getFlag("plan-file") as string; - if (flagPlanFile) { - planFilePath = flagPlanFile; - } + // Restore state on session start/resume + pi.on("session_start", async (_event, ctx) => { + // Resolve plan file path from flag + const flagPlanFile = pi.getFlag("plan-file") as string; + if (flagPlanFile) { + planFilePath = flagPlanFile; + } - // Check --plan flag - if (pi.getFlag("plan") === true) { - phase = "planning"; - } + // Check --plan flag + if (pi.getFlag("plan") === true) { + phase = "planning"; + } - // Restore persisted state - const entries = ctx.sessionManager.getEntries(); - const stateEntry = entries - .filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plannotator") - .pop() as { data?: { phase: Phase; planFilePath?: string } } | undefined; + // Restore persisted state + const entries = ctx.sessionManager.getEntries(); + const stateEntry = entries + .filter( + (e: { type: string; customType?: string }) => + e.type === "custom" && e.customType === "plannotator", + ) + .pop() as { data?: { phase: Phase; planFilePath?: string } } | undefined; - if (stateEntry?.data) { - phase = stateEntry.data.phase ?? phase; - planFilePath = stateEntry.data.planFilePath ?? planFilePath; - } + if (stateEntry?.data) { + phase = stateEntry.data.phase ?? phase; + planFilePath = stateEntry.data.planFilePath ?? planFilePath; + } - // Rebuild execution state from disk + session messages - if (phase === "executing") { - const fullPath = resolvePlanPath(ctx.cwd); - if (existsSync(fullPath)) { - const content = readFileSync(fullPath, "utf-8"); - checklistItems = parseChecklist(content); + // Rebuild execution state from disk + session messages + if (phase === "executing") { + const fullPath = resolvePlanPath(ctx.cwd); + if (existsSync(fullPath)) { + const content = readFileSync(fullPath, "utf-8"); + checklistItems = parseChecklist(content); - // Find last execution marker and scan messages after it for [DONE:n] - let executeIndex = -1; - for (let i = entries.length - 1; i >= 0; i--) { - const entry = entries[i] as { type: string; customType?: string }; - if (entry.customType === "plannotator-execute") { - executeIndex = i; - break; - } - } + // Find last execution marker and scan messages after it for [DONE:n] + let executeIndex = -1; + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i] as { type: string; customType?: string }; + if (entry.customType === "plannotator-execute") { + executeIndex = i; + break; + } + } - for (let i = executeIndex + 1; i < entries.length; i++) { - const entry = entries[i]; - if ( - entry.type === "message" && - "message" in entry && - isAssistantMessage(entry.message as AgentMessage) - ) { - const text = getTextContent(entry.message as AssistantMessage); - markCompletedSteps(text, checklistItems); - } - } - } else { - // Plan file gone — fall back to idle - phase = "idle"; - } - } + for (let i = executeIndex + 1; i < entries.length; i++) { + const entry = entries[i]; + if ( + entry.type === "message" && + "message" in entry && + isAssistantMessage(entry.message as AgentMessage) + ) { + const text = getTextContent(entry.message as AssistantMessage); + markCompletedSteps(text, checklistItems); + } + } + } else { + // Plan file gone — fall back to idle + phase = "idle"; + } + } - // Apply tool restrictions for current phase - if (phase === "planning") { - applyToolsForPhase(); - } + // Apply tool restrictions for current phase + if (phase === "planning") { + applyToolsForPhase(); + } - updateStatus(ctx); - updateWidget(ctx); - }); + updateStatus(ctx); + updateWidget(ctx); + }); } diff --git a/apps/pi-extension/package.json b/apps/pi-extension/package.json index 0c91df03..2e702554 100644 --- a/apps/pi-extension/package.json +++ b/apps/pi-extension/package.json @@ -21,18 +21,14 @@ "files": [ "index.ts", "server.ts", - "review-core.ts", - "utils.ts", + "server/", + "generated/", "README.md", "plannotator.html", - "review-editor.html", - "feedback-templates.ts", - "storage.ts", - "draft.ts", - "project.ts" + "review-editor.html" ], "scripts": { - "build": "cp ../hook/dist/index.html plannotator.html && cp ../hook/dist/review.html review-editor.html && for f in feedback-templates review-core storage draft project; do src=\"../../packages/shared/$f.ts\"; printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\\n' \"$f\" | cat - \"$src\" > \"$f.ts\"; done", + "build": "cp ../hook/dist/index.html plannotator.html && cp ../hook/dist/review.html review-editor.html && mkdir -p generated && for f in feedback-templates review-core storage draft project pr-provider pr-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file; do src=\"../../packages/shared/$f.ts\"; printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\\n' \"$f\" | cat - \"$src\" > \"generated/$f.ts\"; done", "prepublishOnly": "cd ../.. && bun run build:pi" }, "peerDependencies": { diff --git a/apps/pi-extension/server.test.ts b/apps/pi-extension/server.test.ts index 3ec3efdf..763b3fb6 100644 --- a/apps/pi-extension/server.test.ts +++ b/apps/pi-extension/server.test.ts @@ -267,5 +267,5 @@ describe("pi review server", () => { } finally { server.stop(); } - }); + }, 15_000); }); diff --git a/apps/pi-extension/server.ts b/apps/pi-extension/server.ts index 974db37a..420fd380 100644 --- a/apps/pi-extension/server.ts +++ b/apps/pi-extension/server.ts @@ -6,915 +6,22 @@ * each UI needs — plan review, code review, and markdown annotation. */ -import { createServer, type IncomingMessage, type Server } from "node:http"; -import { execSync, spawn, spawnSync } from "node:child_process"; -import os from "node:os"; -import { randomUUID } from "node:crypto"; -import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs"; -import { join, basename, resolve as resolvePath } from "node:path"; -import { Readable } from "node:stream"; -import { - type DiffOption, - type DiffType, - type GitCommandResult, - type GitContext, - type ReviewGitRuntime, - getFileContentsForDiff as getFileContentsForDiffCore, - getGitContext as getGitContextCore, - gitAddFile as gitAddFileCore, - gitResetFile as gitResetFileCore, - parseWorktreeDiffType, - runGitDiff as runGitDiffCore, - validateFilePath, -} from "./review-core.js"; -import { - generateSlug, - saveToHistory, - getPlanVersion, - getVersionCount, - listVersions, - listArchivedPlans, - readArchivedPlan, - type ArchivedPlan, -} from "./storage.js"; -import { contentHash, saveDraft, loadDraft, deleteDraft } from "./draft.js"; -import { sanitizeTag } from "./project.js"; - -// ── Helpers ────────────────────────────────────────────────────────────── - -function parseBody(req: IncomingMessage): Promise> { - return new Promise((resolve) => { - let data = ""; - req.on("data", (chunk: string) => (data += chunk)); - req.on("end", () => { - try { - resolve(JSON.parse(data)); - } catch { - resolve({}); - } - }); - }); -} - -function json(res: import("node:http").ServerResponse, data: unknown, status = 200): void { - res.writeHead(status, { "Content-Type": "application/json" }); - res.end(JSON.stringify(data)); -} - -function html(res: import("node:http").ServerResponse, content: string): void { - res.writeHead(200, { "Content-Type": "text/html" }); - res.end(content); -} - -function send( - res: import("node:http").ServerResponse, - body: string | Buffer, - status = 200, - headers: Record = {}, -): void { - res.writeHead(status, headers); - res.end(body); -} - -interface EditorAnnotation { - id: string; - filePath: string; - selectedText: string; - lineStart: number; - lineEnd: number; - comment?: string; - createdAt: number; -} - -function createEditorAnnotationHandler() { - const annotations: EditorAnnotation[] = []; - - return { - async handle(req: IncomingMessage, res: import("node:http").ServerResponse, url: URL): Promise { - if (url.pathname === "/api/editor-annotations" && req.method === "GET") { - json(res, { annotations }); - return true; - } - - if (url.pathname === "/api/editor-annotation" && req.method === "POST") { - const body = await parseBody(req); - if (!body.filePath || !body.selectedText || !body.lineStart || !body.lineEnd) { - json(res, { error: "Missing required fields" }, 400); - return true; - } - - const annotation: EditorAnnotation = { - id: randomUUID(), - filePath: String(body.filePath), - selectedText: String(body.selectedText), - lineStart: Number(body.lineStart), - lineEnd: Number(body.lineEnd), - comment: typeof body.comment === "string" ? body.comment : undefined, - createdAt: Date.now(), - }; - - annotations.push(annotation); - json(res, { id: annotation.id }); - return true; - } - - if (url.pathname === "/api/editor-annotation" && req.method === "DELETE") { - const id = url.searchParams.get("id"); - if (!id) { - json(res, { error: "Missing id parameter" }, 400); - return true; - } - const idx = annotations.findIndex((annotation) => annotation.id === id); - if (idx !== -1) { - annotations.splice(idx, 1); - } - json(res, { ok: true }); - return true; - } - - return false; - }, - }; -} - -const ALLOWED_IMAGE_EXTENSIONS = new Set([ - "png", - "jpg", - "jpeg", - "gif", - "webp", - "svg", - "bmp", - "ico", - "tiff", - "tif", - "avif", -]); - -const IMAGE_CONTENT_TYPES: Record = { - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - gif: "image/gif", - webp: "image/webp", - svg: "image/svg+xml", - bmp: "image/bmp", - ico: "image/x-icon", - tiff: "image/tiff", - tif: "image/tiff", - avif: "image/avif", -}; - -const UPLOAD_DIR = join(os.tmpdir(), "plannotator"); - -function getExtension(filePath: string): string { - const lastDot = filePath.lastIndexOf("."); - if (lastDot === -1) return ""; - return filePath.slice(lastDot + 1).toLowerCase(); -} - -function validateImagePath(rawPath: string): { - valid: boolean; - resolved: string; - error?: string; -} { - const resolved = resolvePath(rawPath); - const ext = getExtension(resolved); - - if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) { - return { - valid: false, - resolved, - error: "Path does not point to a supported image file", - }; - } - - return { valid: true, resolved }; -} - -function validateUploadExtension(fileName: string): { - valid: boolean; - ext: string; - error?: string; -} { - const ext = getExtension(fileName) || "png"; - if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) { - return { - valid: false, - ext, - error: `File extension ".${ext}" is not a supported image type`, - }; - } - - return { valid: true, ext }; -} - -function getImageContentType(filePath: string): string { - return IMAGE_CONTENT_TYPES[getExtension(filePath)] || "application/octet-stream"; -} - -function toWebRequest(req: IncomingMessage): Request { - const headers = new Headers(); - for (const [key, value] of Object.entries(req.headers)) { - if (value === undefined) continue; - if (Array.isArray(value)) { - for (const item of value) headers.append(key, item); - } else { - headers.set(key, value); - } - } - - const init: RequestInit & { duplex?: "half" } = { - method: req.method, - headers, - }; - - if (req.method !== "GET" && req.method !== "HEAD") { - init.body = Readable.toWeb(req) as unknown as BodyInit; - init.duplex = "half"; - } - - return new Request(`http://localhost${req.url ?? "/"}`, init); -} - -const DEFAULT_REMOTE_PORT = 19432; - -/** - * Check if running in a remote session (SSH, devcontainer, etc.) - * Honors PLANNOTATOR_REMOTE env var, or detects SSH_TTY/SSH_CONNECTION. - */ -function isRemoteSession(): boolean { - const remote = process.env.PLANNOTATOR_REMOTE; - if (remote === "1" || remote?.toLowerCase() === "true") { - return true; - } - // Legacy SSH detection - if (process.env.SSH_TTY || process.env.SSH_CONNECTION) { - return true; - } - return false; -} - -/** - * Get the server port to use. - * - PLANNOTATOR_PORT env var takes precedence - * - Remote sessions default to 19432 (for port forwarding) - * - Local sessions use random port - * Returns { port, portSource } so caller can notify user if needed. - */ -function getServerPort(): { port: number; portSource: "env" | "remote-default" | "random" } { - const envPort = process.env.PLANNOTATOR_PORT; - if (envPort) { - const parsed = parseInt(envPort, 10); - if (!isNaN(parsed) && parsed > 0 && parsed < 65536) { - return { port: parsed, portSource: "env" }; - } - // Invalid port - fall back silently, caller can check env var themselves - } - if (isRemoteSession()) { - return { port: DEFAULT_REMOTE_PORT, portSource: "remote-default" }; - } - return { port: 0, portSource: "random" }; -} - -const MAX_RETRIES = 5; -const RETRY_DELAY_MS = 500; - -async function listenOnPort(server: Server): Promise<{ port: number; portSource: "env" | "remote-default" | "random" }> { - const result = getServerPort(); - - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - try { - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(result.port, isRemoteSession() ? "0.0.0.0" : "127.0.0.1", () => { - server.removeListener("error", reject); - resolve(); - }); - }); - const addr = server.address() as { port: number }; - return { port: addr.port, portSource: result.portSource }; - } catch (err: unknown) { - const isAddressInUse = err instanceof Error && err.message.includes("EADDRINUSE"); - if (isAddressInUse && attempt < MAX_RETRIES) { - await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); - continue; - } - if (isAddressInUse) { - const hint = isRemoteSession() ? " (set PLANNOTATOR_PORT to use a different port)" : ""; - throw new Error(`Port ${result.port} in use after ${MAX_RETRIES} retries${hint}`); - } - throw err; - } - } - - // Unreachable, but satisfies TypeScript - throw new Error("Failed to bind port"); -} - -/** - * Open URL in system browser (Node-compatible, no Bun $ dependency). - * Honors PLANNOTATOR_BROWSER and BROWSER env vars, matching packages/server/browser.ts. - * Returns { opened: true } if browser was opened, { opened: false, isRemote: true, url } if remote session. - */ -export function openBrowser(url: string): { opened: boolean; isRemote?: boolean; url?: string } { - const browser = process.env.PLANNOTATOR_BROWSER || process.env.BROWSER; - if (isRemoteSession() && !browser) { - return { opened: false, isRemote: true, url }; - } - - try { - const platform = process.platform; - const wsl = platform === "linux" && os.release().toLowerCase().includes("microsoft"); - - let cmd: string; - let args: string[]; - - if (browser) { - if (process.env.PLANNOTATOR_BROWSER && platform === "darwin") { - cmd = "open"; - args = ["-a", browser, url]; - } else if (platform === "win32" || wsl) { - cmd = "cmd.exe"; - args = ["/c", "start", "", browser, url]; - } else { - cmd = browser; - args = [url]; - } - } else if (platform === "win32" || wsl) { - cmd = "cmd.exe"; - args = ["/c", "start", "", url]; - } else if (platform === "darwin") { - cmd = "open"; - args = [url]; - } else { - cmd = "xdg-open"; - args = [url]; - } - - const child = spawn(cmd, args, { detached: true, stdio: "ignore" }); - child.once("error", () => {}); - child.unref(); - return { opened: true }; - } catch { - return { opened: false }; - } -} - -// ── Pi-specific helpers ────────────────────────────────────────────────── - -function detectProjectName(): string { - try { - const toplevel = execSync("git rev-parse --show-toplevel", { - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); - const name = basename(toplevel); - return sanitizeTag(name) ?? "_unknown"; - } catch { - // Not a git repo — fall back to cwd - } - try { - const name = basename(process.cwd()); - return sanitizeTag(name) ?? "_unknown"; - } catch { - return "_unknown"; - } -} - -function parseRemoteUrl(url: string): string | null { - if (!url) return null; - - const sshMatch = url.match(/:([^/]+\/[^/]+?)(?:\.git)?$/); - if (sshMatch) return sshMatch[1]; - - const httpsMatch = url.match(/\/([^/]+\/[^/]+?)(?:\.git)?$/); - if (httpsMatch) return httpsMatch[1]; - - return null; -} - -function getDirName(path: string): string | null { - if (!path) return null; - const trimmed = path.trim().replace(/\/+$/, ""); - const parts = trimmed.split("/"); - return parts[parts.length - 1] || null; -} - -function getRepoInfo(): { display: string; branch?: string } | null { - const branch = git("rev-parse --abbrev-ref HEAD"); - const safeBranch = branch && branch !== "HEAD" ? branch : undefined; - - const originUrl = git("remote get-url origin"); - const orgRepo = parseRemoteUrl(originUrl); - if (orgRepo) { - return { display: orgRepo, branch: safeBranch }; - } - - const topLevel = git("rev-parse --show-toplevel"); - const repoName = getDirName(topLevel); - if (repoName) { - return { display: repoName, branch: safeBranch }; - } - - const cwdName = getDirName(process.cwd()); - if (cwdName) { - return { display: cwdName }; - } - - return null; -} - -// ── Plan Review Server ────────────────────────────────────────────────── - -export interface PlanServerResult { - port: number; - portSource: "env" | "remote-default" | "random"; - url: string; - waitForDecision: () => Promise<{ approved: boolean; feedback?: string }>; - waitForDone?: () => Promise; - stop: () => void; -} - -export async function startPlanReviewServer(options: { - plan: string; - htmlContent: string; - origin?: string; - sharingEnabled?: boolean; - shareBaseUrl?: string; - pasteApiUrl?: string; - mode?: "archive"; - customPlanPath?: string | null; -}): Promise { - const sharingEnabled = - options.sharingEnabled ?? process.env.PLANNOTATOR_SHARE !== "disabled"; - const shareBaseUrl = - (options.shareBaseUrl ?? process.env.PLANNOTATOR_SHARE_URL) || undefined; - const pasteApiUrl = - (options.pasteApiUrl ?? process.env.PLANNOTATOR_PASTE_URL) || undefined; - - // --- Archive mode setup --- - let archivePlans: ArchivedPlan[] = []; - let initialArchivePlan = ""; - let resolveDone: (() => void) | undefined; - let donePromise: Promise | undefined; - - if (options.mode === "archive") { - archivePlans = listArchivedPlans(options.customPlanPath ?? undefined); - initialArchivePlan = archivePlans.length > 0 - ? readArchivedPlan(archivePlans[0].filename, options.customPlanPath ?? undefined) ?? "" - : ""; - donePromise = new Promise((resolve) => { resolveDone = resolve; }); - } - - // --- Plan review mode setup (skip in archive mode) --- - const slug = options.mode !== "archive" ? generateSlug(options.plan) : ""; - const project = options.mode !== "archive" ? detectProjectName() : ""; - const historyResult = options.mode !== "archive" - ? saveToHistory(project, slug, options.plan) - : { version: 0, path: "", isNew: false }; - const previousPlan = options.mode !== "archive" && historyResult.version > 1 - ? getPlanVersion(project, slug, historyResult.version - 1) - : null; - const versionInfo = options.mode !== "archive" - ? { version: historyResult.version, totalVersions: getVersionCount(project, slug), project } - : null; - - let resolveDecision!: (result: { approved: boolean; feedback?: string }) => void; - const decisionPromise = new Promise<{ approved: boolean; feedback?: string }>((r) => { - resolveDecision = r; - }); - - // Lazy cache for in-session archive tab - let cachedArchivePlans: ArchivedPlan[] | null = null; - - const server = createServer(async (req, res) => { - const url = new URL(req.url!, `http://localhost`); - - if (url.pathname === "/api/done" && req.method === "POST") { - resolveDone?.(); - json(res, { ok: true }); - } else if (url.pathname === "/api/archive/plans") { - const customPath = url.searchParams.get("customPath") || undefined; - if (!cachedArchivePlans) cachedArchivePlans = listArchivedPlans(customPath); - json(res, { plans: cachedArchivePlans }); - } else if (url.pathname === "/api/archive/plan") { - const filename = url.searchParams.get("filename"); - const customPath = url.searchParams.get("customPath") || undefined; - if (!filename) { json(res, { error: "Missing filename" }, 400); return; } - const markdown = readArchivedPlan(filename, customPath); - if (!markdown) { json(res, { error: "Not found" }, 404); return; } - json(res, { markdown, filepath: filename }); - } else if (url.pathname === "/api/plan/version") { - const vParam = url.searchParams.get("v"); - if (!vParam) { - json(res, { error: "Missing v parameter" }, 400); - return; - } - const v = parseInt(vParam, 10); - if (isNaN(v) || v < 1) { - json(res, { error: "Invalid version number" }, 400); - return; - } - const content = getPlanVersion(project, slug, v); - if (content === null) { - json(res, { error: "Version not found" }, 404); - return; - } - json(res, { plan: content, version: v }); - } else if (url.pathname === "/api/plan/versions") { - json(res, { project, slug, versions: listVersions(project, slug) }); - } else if (url.pathname === "/api/plan") { - if (options.mode === "archive") { - json(res, { - plan: initialArchivePlan, origin: options.origin ?? "pi", - mode: "archive", archivePlans, sharingEnabled, shareBaseUrl, pasteApiUrl, - }); - } else { - json(res, { plan: options.plan, origin: options.origin ?? "pi", previousPlan, versionInfo, sharingEnabled, shareBaseUrl, pasteApiUrl }); - } - } else if (url.pathname === "/api/approve" && req.method === "POST") { - const body = await parseBody(req); - resolveDecision({ approved: true, feedback: body.feedback as string | undefined }); - json(res, { ok: true }); - } else if (url.pathname === "/api/deny" && req.method === "POST") { - const body = await parseBody(req); - resolveDecision({ approved: false, feedback: (body.feedback as string) || "Plan rejected" }); - json(res, { ok: true }); - } else { - html(res, options.htmlContent); - } - }); - - const { port, portSource } = await listenOnPort(server); - - return { - port, - portSource, - url: `http://localhost:${port}`, - waitForDecision: () => decisionPromise, - ...(donePromise && { waitForDone: () => donePromise }), - stop: () => server.close(), - }; -} - -export type { DiffType, DiffOption, GitContext } from "./review-core.js"; - -export interface ReviewServerResult { - port: number; - portSource: "env" | "remote-default" | "random"; - url: string; - waitForDecision: () => Promise<{ - approved: boolean; - feedback: string; - annotations: unknown[]; - agentSwitch?: string; - }>; - stop: () => void; -} - -/** Run a git command and return stdout (empty string on error). */ -function git(cmd: string): string { - try { - return execSync(`git ${cmd}`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); - } catch { - return ""; - } -} - -const reviewRuntime: ReviewGitRuntime = { - async runGit(args: string[], options?: { cwd?: string }): Promise { - const result = spawnSync("git", args, { - cwd: options?.cwd, - encoding: "utf-8", - }); - return { - stdout: result.stdout ?? "", - stderr: result.stderr ?? "", - exitCode: result.status ?? (result.error ? 1 : 0), - }; - }, - - async readTextFile(path: string): Promise { - try { - return readFileSync(path, "utf-8"); - } catch { - return null; - } - }, -}; - -export function getGitContext(): Promise { - return getGitContextCore(reviewRuntime); -} - -export function runGitDiff( - diffType: DiffType, - defaultBranch = "main", -): Promise<{ patch: string; label: string; error?: string }> { - return runGitDiffCore(reviewRuntime, diffType, defaultBranch); -} - -export async function startReviewServer(options: { - rawPatch: string; - gitRef: string; - htmlContent: string; - origin?: string; - diffType?: DiffType; - gitContext?: GitContext; - error?: string; - sharingEnabled?: boolean; - shareBaseUrl?: string; - pasteApiUrl?: string; -}): Promise { - const draftKey = contentHash(options.rawPatch); - const repoInfo = getRepoInfo(); - const editorAnnotations = createEditorAnnotationHandler(); - let currentPatch = options.rawPatch; - let currentGitRef = options.gitRef; - let currentDiffType: DiffType = options.diffType || "uncommitted"; - let currentError = options.error; - const sharingEnabled = - options.sharingEnabled ?? process.env.PLANNOTATOR_SHARE !== "disabled"; - const shareBaseUrl = - (options.shareBaseUrl ?? process.env.PLANNOTATOR_SHARE_URL) || undefined; - const pasteApiUrl = - (options.pasteApiUrl ?? process.env.PLANNOTATOR_PASTE_URL) || undefined; - - let resolveDecision!: (result: { - approved: boolean; - feedback: string; - annotations: unknown[]; - agentSwitch?: string; - }) => void; - const decisionPromise = new Promise<{ - approved: boolean; - feedback: string; - annotations: unknown[]; - agentSwitch?: string; - }>((r) => { - resolveDecision = r; - }); - - const server = createServer(async (req, res) => { - const url = new URL(req.url!, `http://localhost`); - - if (url.pathname === "/api/diff" && req.method === "GET") { - json(res, { - rawPatch: currentPatch, - gitRef: currentGitRef, - origin: options.origin ?? "pi", - diffType: currentDiffType, - gitContext: options.gitContext, - sharingEnabled, - shareBaseUrl, - pasteApiUrl, - repoInfo, - ...(currentError ? { error: currentError } : {}), - }); - } else if (url.pathname === "/api/diff/switch" && req.method === "POST") { - const body = await parseBody(req); - const newType = body.diffType as DiffType; - if (!newType) { - json(res, { error: "Missing diffType" }, 400); - return; - } - const defaultBranch = options.gitContext?.defaultBranch || "main"; - const result = await runGitDiff(newType, defaultBranch); - currentPatch = result.patch; - currentGitRef = result.label; - currentDiffType = newType; - currentError = result.error; - json(res, { - rawPatch: currentPatch, - gitRef: currentGitRef, - diffType: currentDiffType, - ...(currentError ? { error: currentError } : {}), - }); - } else if (url.pathname === "/api/file-content" && req.method === "GET") { - const filePath = url.searchParams.get("path"); - if (!filePath) { - json(res, { error: "Missing path" }, 400); - return; - } - try { - validateFilePath(filePath); - } catch { - json(res, { error: "Invalid path" }, 400); - return; - } - const oldPath = url.searchParams.get("oldPath") || undefined; - if (oldPath) { - try { - validateFilePath(oldPath); - } catch { - json(res, { error: "Invalid path" }, 400); - return; - } - } - const defaultBranch = options.gitContext?.defaultBranch || "main"; - const result = await getFileContentsForDiffCore( - reviewRuntime, - currentDiffType, - defaultBranch, - filePath, - oldPath, - ); - json(res, result); - } else if (url.pathname === "/api/image") { - const imagePath = url.searchParams.get("path"); - if (!imagePath) { - send(res, "Missing path parameter", 400, { "Content-Type": "text/plain" }); - return; - } - - const tryServePath = (candidate: string): boolean => { - const validation = validateImagePath(candidate); - if (!validation.valid) { - return false; - } - try { - if (!existsSync(validation.resolved)) { - return false; - } - const data = readFileSync(validation.resolved); - send(res, data, 200, { "Content-Type": getImageContentType(validation.resolved) }); - return true; - } catch { - return false; - } - }; - - if (tryServePath(imagePath)) return; - - const base = url.searchParams.get("base"); - if (base && !imagePath.startsWith("/") && tryServePath(resolvePath(base, imagePath))) { - return; - } - - const validation = validateImagePath(imagePath); - if (!validation.valid) { - send(res, validation.error || "Invalid image path", 403, { "Content-Type": "text/plain" }); - return; - } - - send(res, "File not found", 404, { "Content-Type": "text/plain" }); - } else if (url.pathname === "/api/upload" && req.method === "POST") { - try { - const request = toWebRequest(req); - const formData = await request.formData(); - const file = formData.get("file"); - if (!file || typeof file !== "object" || !("arrayBuffer" in file) || !("name" in file)) { - json(res, { error: "No file provided" }, 400); - return; - } - - const upload = file as File; - const extResult = validateUploadExtension(upload.name); - if (!extResult.valid) { - json(res, { error: extResult.error }, 400); - return; - } - - mkdirSync(UPLOAD_DIR, { recursive: true }); - const tempPath = join(UPLOAD_DIR, `${randomUUID()}.${extResult.ext}`); - const bytes = Buffer.from(await upload.arrayBuffer()); - writeFileSync(tempPath, bytes); - json(res, { path: tempPath, originalName: upload.name }); - } catch (err) { - const message = err instanceof Error ? err.message : "Upload failed"; - json(res, { error: message }, 500); - } - } else if (url.pathname === "/api/agents" && req.method === "GET") { - json(res, { agents: [] }); - } else if (url.pathname === "/api/git-add" && req.method === "POST") { - const body = await parseBody(req); - const filePath = body.filePath as string | undefined; - if (!filePath) { - json(res, { error: "Missing filePath" }, 400); - return; - } - try { - let cwd: string | undefined; - if (currentDiffType.startsWith("worktree:")) { - const parsed = parseWorktreeDiffType(currentDiffType); - if (parsed) cwd = parsed.path; - } - if (body.undo) { - await gitResetFileCore(reviewRuntime, filePath, cwd); - } else { - await gitAddFileCore(reviewRuntime, filePath, cwd); - } - json(res, { ok: true }); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to git add"; - json(res, { error: message }, 500); - } - } else if (url.pathname === "/api/draft") { - if (req.method === "POST") { - const body = await parseBody(req); - saveDraft(draftKey, body); - json(res, { ok: true }); - } else if (req.method === "DELETE") { - deleteDraft(draftKey); - json(res, { ok: true }); - } else { - const draft = loadDraft(draftKey); - if (!draft) { - json(res, { found: false }, 404); - return; - } - json(res, draft); - } - } else if (await editorAnnotations.handle(req, res, url)) { - return; - } else if (url.pathname === "/api/feedback" && req.method === "POST") { - const body = await parseBody(req); - deleteDraft(draftKey); - resolveDecision({ - approved: (body.approved as boolean) ?? false, - feedback: (body.feedback as string) || "", - annotations: (body.annotations as unknown[]) || [], - agentSwitch: body.agentSwitch as string | undefined, - }); - json(res, { ok: true }); - } else { - html(res, options.htmlContent); - } - }); - - const { port, portSource } = await listenOnPort(server); - - return { - port, - portSource, - url: `http://localhost:${port}`, - waitForDecision: () => decisionPromise, - stop: () => server.close(), - }; -} - -// ── Annotate Server ───────────────────────────────────────────────────── - -export interface AnnotateServerResult { - port: number; - portSource: "env" | "remote-default" | "random"; - url: string; - waitForDecision: () => Promise<{ feedback: string }>; - stop: () => void; -} - -export async function startAnnotateServer(options: { - markdown: string; - filePath: string; - htmlContent: string; - origin?: string; - mode?: string; - sharingEnabled?: boolean; - shareBaseUrl?: string; - pasteApiUrl?: string; -}): Promise { - const sharingEnabled = - options.sharingEnabled ?? process.env.PLANNOTATOR_SHARE !== "disabled"; - const shareBaseUrl = - (options.shareBaseUrl ?? process.env.PLANNOTATOR_SHARE_URL) || undefined; - const pasteApiUrl = - (options.pasteApiUrl ?? process.env.PLANNOTATOR_PASTE_URL) || undefined; - - let resolveDecision!: (result: { feedback: string }) => void; - const decisionPromise = new Promise<{ feedback: string }>((r) => { - resolveDecision = r; - }); - - const server = createServer(async (req, res) => { - const url = new URL(req.url!, `http://localhost`); - - if (url.pathname === "/api/plan" && req.method === "GET") { - json(res, { - plan: options.markdown, - origin: options.origin ?? "pi", - mode: options.mode || "annotate", - filePath: options.filePath, - sharingEnabled, - shareBaseUrl, - pasteApiUrl, - }); - } else if (url.pathname === "/api/feedback" && req.method === "POST") { - const body = await parseBody(req); - resolveDecision({ feedback: (body.feedback as string) || "" }); - json(res, { ok: true }); - } else { - html(res, options.htmlContent); - } - }); - - const { port, portSource } = await listenOnPort(server); - - return { - port, - portSource, - url: `http://localhost:${port}`, - waitForDecision: () => decisionPromise, - stop: () => server.close(), - }; -} +export type { + DiffOption, + DiffType, + GitContext, +} from "./generated/review-core.js"; +export { + type AnnotateServerResult, + startAnnotateServer, +} from "./server/serverAnnotate.js"; +export { + type PlanServerResult, + startPlanReviewServer, +} from "./server/serverPlan.js"; +export { + getGitContext, + type ReviewServerResult, + runGitDiff, + startReviewServer, +} from "./server/serverReview.js"; diff --git a/apps/pi-extension/server/annotations.ts b/apps/pi-extension/server/annotations.ts new file mode 100644 index 00000000..193d4385 --- /dev/null +++ b/apps/pi-extension/server/annotations.ts @@ -0,0 +1,85 @@ +/** + * Editor annotation handler (in-memory store for VS Code integration). + * EditorAnnotation type, createEditorAnnotationHandler + */ + +import { randomUUID } from "node:crypto"; +import type { IncomingMessage } from "node:http"; +import { json, parseBody } from "./helpers"; + +interface EditorAnnotation { + id: string; + filePath: string; + selectedText: string; + lineStart: number; + lineEnd: number; + comment?: string; + createdAt: number; +} + +export function createEditorAnnotationHandler() { + const annotations: EditorAnnotation[] = []; + + return { + async handle( + req: IncomingMessage, + res: import("node:http").ServerResponse, + url: URL, + ): Promise { + if (url.pathname === "/api/editor-annotations" && req.method === "GET") { + json(res, { annotations }); + return true; + } + + if (url.pathname === "/api/editor-annotation" && req.method === "POST") { + try { + const body = await parseBody(req); + if ( + !body.filePath || + !body.selectedText || + !body.lineStart || + !body.lineEnd + ) { + json(res, { error: "Missing required fields" }, 400); + return true; + } + + const annotation: EditorAnnotation = { + id: randomUUID(), + filePath: String(body.filePath), + selectedText: String(body.selectedText), + lineStart: Number(body.lineStart), + lineEnd: Number(body.lineEnd), + comment: typeof body.comment === "string" ? body.comment : undefined, + createdAt: Date.now(), + }; + + annotations.push(annotation); + json(res, { id: annotation.id }); + } catch { + json(res, { error: "Invalid JSON" }, 400); + } + return true; + } + + if ( + url.pathname === "/api/editor-annotation" && + req.method === "DELETE" + ) { + const id = url.searchParams.get("id"); + if (!id) { + json(res, { error: "Missing id parameter" }, 400); + return true; + } + const idx = annotations.findIndex((annotation) => annotation.id === id); + if (idx !== -1) { + annotations.splice(idx, 1); + } + json(res, { ok: true }); + return true; + } + + return false; + }, + }; +} diff --git a/apps/pi-extension/server/handlers.ts b/apps/pi-extension/server/handlers.ts new file mode 100644 index 00000000..6a87f186 --- /dev/null +++ b/apps/pi-extension/server/handlers.ts @@ -0,0 +1,210 @@ +/** + * Shared request handlers reused across plan, review, and annotate servers. + * handleImageRequest, handleUploadRequest, handleDraftRequest, handleFavicon + */ + +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import type { IncomingMessage } from "node:http"; +import { tmpdir } from "node:os"; +import { join, resolve as resolvePath } from "node:path"; +import { saveDraft, loadDraft, deleteDraft } from "../generated/draft.js"; +import { FAVICON_SVG } from "../generated/favicon.js"; + +import { json, parseBody, send, toWebRequest } from "./helpers"; + +type Res = import("node:http").ServerResponse; + +const ALLOWED_IMAGE_EXTENSIONS = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "svg", + "bmp", + "ico", + "tiff", + "tif", + "avif", +]); + +const IMAGE_CONTENT_TYPES: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + bmp: "image/bmp", + ico: "image/x-icon", + tiff: "image/tiff", + tif: "image/tiff", + avif: "image/avif", +}; + +const UPLOAD_DIR = join(tmpdir(), "plannotator"); + +function getExtension(filePath: string): string { + const lastDot = filePath.lastIndexOf("."); + if (lastDot === -1) return ""; + return filePath.slice(lastDot + 1).toLowerCase(); +} + +function validateImagePath(rawPath: string): { + valid: boolean; + resolved: string; + error?: string; +} { + const resolved = resolvePath(rawPath); + const ext = getExtension(resolved); + + if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) { + return { + valid: false, + resolved, + error: "Path does not point to a supported image file", + }; + } + + return { valid: true, resolved }; +} + +function validateUploadExtension(fileName: string): { + valid: boolean; + ext: string; + error?: string; +} { + const ext = getExtension(fileName) || "png"; + if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) { + return { + valid: false, + ext, + error: `File extension ".${ext}" is not a supported image type`, + }; + } + + return { valid: true, ext }; +} + +function getImageContentType(filePath: string): string { + return ( + IMAGE_CONTENT_TYPES[getExtension(filePath)] || "application/octet-stream" + ); +} + +export function handleImageRequest(res: Res, url: URL): void { + const imagePath = url.searchParams.get("path"); + if (!imagePath) { + send(res, "Missing path parameter", 400, { "Content-Type": "text/plain" }); + return; + } + + const tryServePath = (candidate: string): boolean => { + const validation = validateImagePath(candidate); + if (!validation.valid) return false; + try { + if (!existsSync(validation.resolved)) return false; + const data = readFileSync(validation.resolved); + send(res, data, 200, { + "Content-Type": getImageContentType(validation.resolved), + }); + return true; + } catch { + return false; + } + }; + + if (tryServePath(imagePath)) return; + + const base = url.searchParams.get("base"); + if ( + base && + !imagePath.startsWith("/") && + tryServePath(resolvePath(base, imagePath)) + ) { + return; + } + + const validation = validateImagePath(imagePath); + if (!validation.valid) { + send(res, validation.error || "Invalid image path", 403, { + "Content-Type": "text/plain", + }); + return; + } + + send(res, "File not found", 404, { "Content-Type": "text/plain" }); +} + +export async function handleUploadRequest( + req: IncomingMessage, + res: Res, +): Promise { + try { + const request = toWebRequest(req); + const formData = await request.formData(); + const file = formData.get("file"); + if ( + !file || + typeof file !== "object" || + !("arrayBuffer" in file) || + !("name" in file) + ) { + json(res, { error: "No file provided" }, 400); + return; + } + + const upload = file as File; + const extResult = validateUploadExtension(upload.name); + if (!extResult.valid) { + json(res, { error: extResult.error }, 400); + return; + } + + mkdirSync(UPLOAD_DIR, { recursive: true }); + const tempPath = join(UPLOAD_DIR, `${randomUUID()}.${extResult.ext}`); + const bytes = Buffer.from(await upload.arrayBuffer()); + writeFileSync(tempPath, bytes); + json(res, { path: tempPath, originalName: upload.name }); + } catch (err) { + const message = err instanceof Error ? err.message : "Upload failed"; + json(res, { error: message }, 500); + } +} + +export function handleDraftRequest( + req: IncomingMessage, + res: Res, + draftKey: string, +): Promise | void { + if (req.method === "POST") { + return parseBody(req) + .then((body) => { + saveDraft(draftKey, body); + json(res, { ok: true }); + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : "Failed to save draft"; + console.error(`[draft] save failed: ${message}`); + json(res, { error: message }, 500); + }); + } else if (req.method === "DELETE") { + deleteDraft(draftKey); + json(res, { ok: true }); + } else { + const draft = loadDraft(draftKey); + if (!draft) { + json(res, { found: false }, 404); + return; + } + json(res, draft); + } +} + +export function handleFavicon(res: Res): void { + send(res, FAVICON_SVG, 200, { + "Content-Type": "image/svg+xml", + "Cache-Control": "public, max-age=86400", + }); +} diff --git a/apps/pi-extension/server/helpers.ts b/apps/pi-extension/server/helpers.ts new file mode 100644 index 00000000..9bfcc785 --- /dev/null +++ b/apps/pi-extension/server/helpers.ts @@ -0,0 +1,78 @@ +/** + * Core HTTP helpers for Pi extension servers. + * parseBody, json, html, send, toWebRequest + */ + +import type { IncomingMessage } from "node:http"; +import { Readable } from "node:stream"; + +export function parseBody( + req: IncomingMessage, +): Promise> { + return new Promise((resolve) => { + let data = ""; + req.on("data", (chunk: string) => (data += chunk)); + req.on("end", () => { + try { + resolve(JSON.parse(data)); + } catch { + resolve({}); + } + }); + }); +} + +export function json( + res: import("node:http").ServerResponse, + data: unknown, + status = 200, +): void { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data)); +} + +export function html( + res: import("node:http").ServerResponse, + content: string, +): void { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(content); +} + +export function send( + res: import("node:http").ServerResponse, + body: string | Buffer, + status = 200, + headers: Record = {}, +): void { + res.writeHead(status, headers); + res.end(body); +} + +export function requestUrl(req: IncomingMessage): URL { + return new URL(req.url ?? "/", "http://localhost"); +} + +export function toWebRequest(req: IncomingMessage): Request { + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const item of value) headers.append(key, item); + } else { + headers.set(key, value); + } + } + + const init: RequestInit & { duplex?: "half" } = { + method: req.method, + headers, + }; + + if (req.method !== "GET" && req.method !== "HEAD") { + init.body = Readable.toWeb(req) as unknown as BodyInit; + init.duplex = "half"; + } + + return new Request(`http://localhost${req.url ?? "/"}`, init); +} diff --git a/apps/pi-extension/server/ide.ts b/apps/pi-extension/server/ide.ts new file mode 100644 index 00000000..c349e314 --- /dev/null +++ b/apps/pi-extension/server/ide.ts @@ -0,0 +1,46 @@ +/** + * IDE integration — open plan diffs in VS Code. + * Node.js equivalent of packages/server/ide.ts. + */ + +import { spawn } from "node:child_process"; + +/** Open two files in VS Code's diff viewer. Node.js equivalent of packages/server/ide.ts */ +export function openEditorDiff( + oldPath: string, + newPath: string, +): Promise<{ ok: true } | { error: string }> { + return new Promise((resolve) => { + const proc = spawn("code", ["--diff", oldPath, newPath], { + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + proc.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + proc.on("error", (err) => { + if (err.message.includes("ENOENT")) { + resolve({ + error: + "VS Code CLI not found. Run 'Shell Command: Install code command in PATH' from the VS Code command palette.", + }); + } else { + resolve({ error: err.message }); + } + }); + proc.on("close", (code) => { + if (code !== 0) { + if (stderr.includes("not found") || stderr.includes("ENOENT")) { + resolve({ + error: + "VS Code CLI not found. Run 'Shell Command: Install code command in PATH' from the VS Code command palette.", + }); + } else { + resolve({ error: `code --diff exited with ${code}: ${stderr}` }); + } + } else { + resolve({ ok: true }); + } + }); + }); +} diff --git a/apps/pi-extension/server/integrations.ts b/apps/pi-extension/server/integrations.ts new file mode 100644 index 00000000..19fda7d6 --- /dev/null +++ b/apps/pi-extension/server/integrations.ts @@ -0,0 +1,195 @@ +/** + * Note-taking app integrations (Obsidian, Bear, Octarine). + * Node.js equivalents of packages/server/integrations.ts. + * Config types, save functions, tag extraction, filename generation + */ + +import { execSync, spawn } from "node:child_process"; +import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs"; +import { basename, join } from "node:path"; + +import { + type ObsidianConfig, + type BearConfig, + type OctarineConfig, + type IntegrationResult, + extractTitle, + generateFrontmatter, + generateFilename, + generateOctarineFrontmatter, + stripH1, + buildHashtags, + buildBearContent, + detectObsidianVaults, +} from "../generated/integrations-common.js"; +import { sanitizeTag } from "../generated/project.js"; + +export type { ObsidianConfig, BearConfig, OctarineConfig, IntegrationResult }; +export { + extractTitle, + generateFrontmatter, + generateFilename, + generateOctarineFrontmatter, + stripH1, + buildHashtags, + buildBearContent, + detectObsidianVaults, +}; + +/** Detect project name from git or cwd (sync). Used by extractTags for note integrations. */ +function detectProjectNameSync(): string | null { + try { + const toplevel = execSync("git rev-parse --show-toplevel", { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + if (toplevel) { + const name = sanitizeTag(basename(toplevel)); + if (name) return name; + } + } catch { + /* not in a git repo */ + } + try { + return sanitizeTag(basename(process.cwd())) ?? null; + } catch { + return null; + } +} + +export async function extractTags(markdown: string): Promise { + const tags = new Set(["plannotator"]); + const projectName = detectProjectNameSync(); + if (projectName) tags.add(projectName); + const stopWords = new Set([ + "the", + "and", + "for", + "with", + "this", + "that", + "from", + "into", + "plan", + "implementation", + "overview", + "phase", + "step", + "steps", + ]); + const h1Match = markdown.match( + /^#\s+(?:Implementation\s+Plan:|Plan:)?\s*(.+)$/im, + ); + if (h1Match) { + h1Match[1] + .toLowerCase() + .replace(/[^\w\s-]/g, " ") + .split(/\s+/) + .filter((w) => w.length > 2 && !stopWords.has(w)) + .slice(0, 3) + .forEach((w) => tags.add(w)); + } + const seenLangs = new Set(); + let langMatch: RegExpExecArray | null; + const langRegex = /```(\w+)/g; + while ((langMatch = langRegex.exec(markdown)) !== null) { + const lang = langMatch[1]; + const n = lang.toLowerCase(); + if ( + !seenLangs.has(n) && + !["json", "yaml", "yml", "text", "txt", "markdown", "md"].includes(n) + ) { + seenLangs.add(n); + tags.add(n); + } + } + return Array.from(tags).slice(0, 7); +} + +export async function saveToObsidian( + config: ObsidianConfig, +): Promise { + try { + const { vaultPath, folder, plan } = config; + let normalizedVault = vaultPath.trim(); + if (normalizedVault.startsWith("~")) { + const home = process.env.HOME || process.env.USERPROFILE || ""; + normalizedVault = join(home, normalizedVault.slice(1)); + } + if (!existsSync(normalizedVault)) + return { + success: false, + error: `Vault path does not exist: ${normalizedVault}`, + }; + if (!statSync(normalizedVault).isDirectory()) + return { + success: false, + error: `Vault path is not a directory: ${normalizedVault}`, + }; + const folderName = folder.trim() || "plannotator"; + const targetFolder = join(normalizedVault, folderName); + if (!existsSync(targetFolder)) mkdirSync(targetFolder, { recursive: true }); + const filename = generateFilename( + plan, + config.filenameFormat, + config.filenameSeparator, + ); + const filePath = join(targetFolder, filename); + const tags = await extractTags(plan); + const frontmatter = generateFrontmatter(tags); + const content = `${frontmatter}\n\n[[Plannotator Plans]]\n\n${plan}`; + writeFileSync(filePath, content); + return { success: true, path: filePath }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Unknown error", + }; + } +} + +export async function saveToBear( + config: BearConfig, +): Promise { + try { + const { plan, customTags, tagPosition = "append" } = config; + const title = extractTitle(plan); + const body = stripH1(plan); + const tags = customTags?.trim() ? undefined : await extractTags(plan); + const hashtags = buildHashtags(customTags, tags ?? []); + const content = buildBearContent(body, hashtags, tagPosition); + const url = `bear://x-callback-url/create?title=${encodeURIComponent(title)}&text=${encodeURIComponent(content)}&open_note=no`; + spawn("open", [url], { stdio: "ignore" }); + return { success: true }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Unknown error", + }; + } +} + +export async function saveToOctarine( + config: OctarineConfig, +): Promise { + try { + const { plan } = config; + const workspace = config.workspace.trim(); + if (!workspace) return { success: false, error: "Workspace is required" }; + const folder = config.folder.trim() || "plannotator"; + const filename = generateFilename(plan); + const base = filename.replace(/\.md$/, ""); + const path = folder ? `${folder}/${base}` : base; + const tags = await extractTags(plan); + const frontmatter = generateOctarineFrontmatter(tags); + const content = `${frontmatter}\n\n${plan}`; + const url = `octarine://create?path=${encodeURIComponent(path)}&content=${encodeURIComponent(content)}&workspace=${encodeURIComponent(workspace)}&fresh=true&openAfter=false`; + spawn("open", [url], { stdio: "ignore" }); + return { success: true, path }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Unknown error", + }; + } +} diff --git a/apps/pi-extension/server/network.ts b/apps/pi-extension/server/network.ts new file mode 100644 index 00000000..07d81dd0 --- /dev/null +++ b/apps/pi-extension/server/network.ts @@ -0,0 +1,151 @@ +/** + * Network utilities — remote detection, port binding, browser opening. + * isRemoteSession, getServerPort, listenOnPort, openBrowser + */ + +import { spawn } from "node:child_process"; +import type { Server } from "node:http"; +import { release } from "node:os"; + +const DEFAULT_REMOTE_PORT = 19432; + +/** + * Check if running in a remote session (SSH, devcontainer, etc.) + * Honors PLANNOTATOR_REMOTE env var, or detects SSH_TTY/SSH_CONNECTION. + */ +function isRemoteSession(): boolean { + const remote = process.env.PLANNOTATOR_REMOTE; + if (remote === "1" || remote?.toLowerCase() === "true") { + return true; + } + // Legacy SSH detection + if (process.env.SSH_TTY || process.env.SSH_CONNECTION) { + return true; + } + return false; +} + +/** + * Get the server port to use. + * - PLANNOTATOR_PORT env var takes precedence + * - Remote sessions default to 19432 (for port forwarding) + * - Local sessions use random port + * Returns { port, portSource } so caller can notify user if needed. + */ +function getServerPort(): { + port: number; + portSource: "env" | "remote-default" | "random"; +} { + const envPort = process.env.PLANNOTATOR_PORT; + if (envPort) { + const parsed = parseInt(envPort, 10); + if (!Number.isNaN(parsed) && parsed > 0 && parsed < 65536) { + return { port: parsed, portSource: "env" }; + } + // Invalid port - fall back silently, caller can check env var themselves + } + if (isRemoteSession()) { + return { port: DEFAULT_REMOTE_PORT, portSource: "remote-default" }; + } + return { port: 0, portSource: "random" }; +} + +const MAX_RETRIES = 5; +const RETRY_DELAY_MS = 500; + +export async function listenOnPort( + server: Server, +): Promise<{ port: number; portSource: "env" | "remote-default" | "random" }> { + const result = getServerPort(); + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen( + result.port, + isRemoteSession() ? "0.0.0.0" : "127.0.0.1", + () => { + server.removeListener("error", reject); + resolve(); + }, + ); + }); + const addr = server.address() as { port: number }; + return { port: addr.port, portSource: result.portSource }; + } catch (err: unknown) { + const isAddressInUse = + err instanceof Error && err.message.includes("EADDRINUSE"); + if (isAddressInUse && attempt < MAX_RETRIES) { + await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); + continue; + } + if (isAddressInUse) { + const hint = isRemoteSession() + ? " (set PLANNOTATOR_PORT to use a different port)" + : ""; + throw new Error( + `Port ${result.port} in use after ${MAX_RETRIES} retries${hint}`, + ); + } + throw err; + } + } + + // Unreachable, but satisfies TypeScript + throw new Error("Failed to bind port"); +} + +/** + * Open URL in system browser (Node-compatible, no Bun $ dependency). + * Honors PLANNOTATOR_BROWSER and BROWSER env vars, matching packages/server/browser.ts. + * Returns { opened: true } if browser was opened, { opened: false, isRemote: true, url } if remote session. + */ +export function openBrowser(url: string): { + opened: boolean; + isRemote?: boolean; + url?: string; +} { + const browser = process.env.PLANNOTATOR_BROWSER || process.env.BROWSER; + if (isRemoteSession() && !browser) { + return { opened: false, isRemote: true, url }; + } + + try { + const platform = process.platform; + const wsl = + platform === "linux" && release().toLowerCase().includes("microsoft"); + + let cmd: string; + let args: string[]; + + if (browser) { + if (process.env.PLANNOTATOR_BROWSER && platform === "darwin") { + cmd = "open"; + args = ["-a", browser, url]; + } else if (platform === "win32" || wsl) { + cmd = "cmd.exe"; + args = ["/c", "start", "", browser, url]; + } else { + cmd = browser; + args = [url]; + } + } else if (platform === "win32" || wsl) { + cmd = "cmd.exe"; + args = ["/c", "start", "", url]; + } else if (platform === "darwin") { + cmd = "open"; + args = [url]; + } else { + cmd = "xdg-open"; + args = [url]; + } + + const child = spawn(cmd, args, { detached: true, stdio: "ignore" }); + child.once("error", () => {}); + child.unref(); + return { opened: true }; + } catch { + return { opened: false }; + } +} diff --git a/apps/pi-extension/server/pr.ts b/apps/pi-extension/server/pr.ts new file mode 100644 index 00000000..01cf9f15 --- /dev/null +++ b/apps/pi-extension/server/pr.ts @@ -0,0 +1,91 @@ +/** + * PR/MR provider for Node.js runtime. + * Node.js PRRuntime + bound dispatch functions from shared pr-provider. + */ + +import { spawn } from "node:child_process"; + +import { + checkAuth as checkAuthCore, + fetchPRContext as fetchPRContextCore, + fetchPR as fetchPRCore, + fetchPRFileContent as fetchPRFileContentCore, + getUser as getUserCore, + type PRRef, + type PRReviewFileComment, + type PRRuntime, + parsePRUrl as parsePRUrlCore, + submitPRReview as submitPRReviewCore, +} from "../generated/pr-provider.js"; + +const prRuntime: PRRuntime = { + async runCommand(cmd, args) { + return new Promise((resolve, reject) => { + const proc = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + proc.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + proc.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + proc.on("error", reject); + proc.on("close", (exitCode) => { + resolve({ stdout, stderr, exitCode: exitCode ?? 1 }); + }); + }); + }, + async runCommandWithInput(cmd, args, input) { + return new Promise((resolve, reject) => { + const proc = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + proc.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + proc.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + proc.on("error", reject); + proc.on("close", (exitCode) => { + resolve({ stdout, stderr, exitCode: exitCode ?? 1 }); + }); + proc.stdin?.write(input); + proc.stdin?.end(); + }); + }, +}; + +export const parsePRUrl = parsePRUrlCore; +export function checkPRAuth(ref: PRRef) { + return checkAuthCore(prRuntime, ref); +} +export function getPRUser(ref: PRRef) { + return getUserCore(prRuntime, ref); +} +export function fetchPR(ref: PRRef) { + return fetchPRCore(prRuntime, ref); +} +export function fetchPRContext(ref: PRRef) { + return fetchPRContextCore(prRuntime, ref); +} +export function fetchPRFileContent(ref: PRRef, sha: string, filePath: string) { + return fetchPRFileContentCore(prRuntime, ref, sha, filePath); +} +export function submitPRReview( + ref: PRRef, + headSha: string, + action: "approve" | "comment", + body: string, + fileComments: PRReviewFileComment[], +) { + return submitPRReviewCore( + prRuntime, + ref, + headSha, + action, + body, + fileComments, + ); +} diff --git a/apps/pi-extension/server/project.ts b/apps/pi-extension/server/project.ts new file mode 100644 index 00000000..2a05009d --- /dev/null +++ b/apps/pi-extension/server/project.ts @@ -0,0 +1,64 @@ +/** + * Project detection — repo info, project name, remote URL parsing. + * detectProjectName, getRepoInfo, parseRemoteUrl + */ + +import { execSync } from "node:child_process"; +import { basename } from "node:path"; +import { sanitizeTag } from "../generated/project.js"; +import { parseRemoteUrl, getDirName } from "../generated/repo.js"; + +/** Run a git command and return stdout (empty string on error). */ +function git(cmd: string): string { + try { + return execSync(`git ${cmd}`, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + } catch { + return ""; + } +} + +export function detectProjectName(): string { + try { + const toplevel = execSync("git rev-parse --show-toplevel", { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + const name = basename(toplevel); + return sanitizeTag(name) ?? "_unknown"; + } catch { + // Not a git repo — fall back to cwd + } + try { + const name = basename(process.cwd()); + return sanitizeTag(name) ?? "_unknown"; + } catch { + return "_unknown"; + } +} + +export function getRepoInfo(): { display: string; branch?: string } | null { + const branch = git("rev-parse --abbrev-ref HEAD"); + const safeBranch = branch && branch !== "HEAD" ? branch : undefined; + + const originUrl = git("remote get-url origin"); + const orgRepo = parseRemoteUrl(originUrl); + if (orgRepo) { + return { display: orgRepo, branch: safeBranch }; + } + + const topLevel = git("rev-parse --show-toplevel"); + const repoName = getDirName(topLevel); + if (repoName) { + return { display: repoName, branch: safeBranch }; + } + + const cwdName = getDirName(process.cwd()); + if (cwdName) { + return { display: cwdName }; + } + + return null; +} diff --git a/apps/pi-extension/server/reference.ts b/apps/pi-extension/server/reference.ts new file mode 100644 index 00000000..89a965a7 --- /dev/null +++ b/apps/pi-extension/server/reference.ts @@ -0,0 +1,206 @@ +/** + * Document and reference handlers (Node.js equivalents of packages/server/reference-handlers.ts). + * VaultNode, buildFileTree, walkMarkdownFiles, handleDocRequest, + * detectObsidianVaults, handleObsidian*, handleFileBrowserRequest + */ + +import { + existsSync, + readdirSync, + readFileSync, + statSync, + type Dirent, +} from "node:fs"; +import type { ServerResponse } from "node:http"; +import { join, resolve as resolvePath } from "node:path"; + +import { json } from "./helpers"; + +import { + type VaultNode, + buildFileTree, + FILE_BROWSER_EXCLUDED, +} from "../generated/reference-common.js"; +import { detectObsidianVaults } from "../generated/integrations-common.js"; +import { resolveMarkdownFile } from "../generated/resolve-file.js"; + +type Res = ServerResponse; + +/** Recursively walk a directory collecting markdown files, skipping ignored dirs. */ +function walkMarkdownFiles(dir: string, root: string, results: string[]): void { + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }) as Dirent[]; + } catch { + return; + } + for (const entry of entries) { + if (entry.isDirectory()) { + if (FILE_BROWSER_EXCLUDED.includes(entry.name + "/")) continue; + walkMarkdownFiles(join(dir, entry.name), root, results); + } else if (entry.isFile() && /\.mdx?$/i.test(entry.name)) { + const relative = join(dir, entry.name) + .slice(root.length + 1) + .replace(/\\/g, "/"); + results.push(relative); + } + } +} + +/** Serve a linked markdown document. Uses shared resolveMarkdownFile for parity with Bun server. */ +export function handleDocRequest(res: Res, url: URL): void { + const requestedPath = url.searchParams.get("path"); + if (!requestedPath) { + json(res, { error: "Missing path parameter" }, 400); + return; + } + + // Try resolving relative to base directory first (used by annotate mode) + const base = url.searchParams.get("base"); + if ( + base && + !requestedPath.startsWith("/") && + /\.mdx?$/i.test(requestedPath) + ) { + const fromBase = resolvePath(base, requestedPath); + try { + if (existsSync(fromBase)) { + const markdown = readFileSync(fromBase, "utf-8"); + json(res, { markdown, filepath: fromBase }); + return; + } + } catch { + /* fall through to standard resolution */ + } + } + + const projectRoot = process.cwd(); + const result = resolveMarkdownFile(requestedPath, projectRoot); + + if (result.kind === "ambiguous") { + json( + res, + { + error: `Ambiguous filename '${result.input}': found ${result.matches.length} matches`, + matches: result.matches, + }, + 400, + ); + return; + } + + if (result.kind === "not_found") { + json(res, { error: `File not found: ${result.input}` }, 404); + return; + } + + try { + const markdown = readFileSync(result.path, "utf-8"); + json(res, { markdown, filepath: result.path }); + } catch { + json(res, { error: "Failed to read file" }, 500); + } +} + +export function handleObsidianVaultsRequest(res: Res): void { + json(res, { vaults: detectObsidianVaults() }); +} + +export function handleObsidianFilesRequest(res: Res, url: URL): void { + const vaultPath = url.searchParams.get("vaultPath"); + if (!vaultPath) { + json(res, { error: "Missing vaultPath parameter" }, 400); + return; + } + const resolvedVault = resolvePath(vaultPath); + if (!existsSync(resolvedVault) || !statSync(resolvedVault).isDirectory()) { + json(res, { error: "Invalid vault path" }, 400); + return; + } + try { + const files: string[] = []; + walkMarkdownFiles(resolvedVault, resolvedVault, files); + files.sort(); + json(res, { tree: buildFileTree(files) }); + } catch { + json(res, { error: "Failed to list vault files" }, 500); + } +} + +export function handleObsidianDocRequest(res: Res, url: URL): void { + const vaultPath = url.searchParams.get("vaultPath"); + const filePath = url.searchParams.get("path"); + if (!vaultPath || !filePath) { + json(res, { error: "Missing vaultPath or path parameter" }, 400); + return; + } + if (!/\.mdx?$/i.test(filePath)) { + json(res, { error: "Only markdown files are supported" }, 400); + return; + } + const resolvedVault = resolvePath(vaultPath); + let resolvedFile = resolvePath(resolvedVault, filePath); + + // Bare filename search within vault + if (!existsSync(resolvedFile) && !filePath.includes("/")) { + const files: string[] = []; + walkMarkdownFiles(resolvedVault, resolvedVault, files); + const matches = files.filter( + (f) => f.split("/").pop()!.toLowerCase() === filePath.toLowerCase(), + ); + if (matches.length === 1) { + resolvedFile = resolvePath(resolvedVault, matches[0]); + } else if (matches.length > 1) { + json( + res, + { + error: `Ambiguous filename '${filePath}': found ${matches.length} matches`, + matches, + }, + 400, + ); + return; + } + } + + // Security: must be within vault + if ( + !resolvedFile.startsWith(resolvedVault + "/") && + resolvedFile !== resolvedVault + ) { + json(res, { error: "Access denied: path is outside vault" }, 403); + return; + } + + if (!existsSync(resolvedFile)) { + json(res, { error: `File not found: ${filePath}` }, 404); + return; + } + try { + const markdown = readFileSync(resolvedFile, "utf-8"); + json(res, { markdown, filepath: resolvedFile }); + } catch { + json(res, { error: "Failed to read file" }, 500); + } +} + +export function handleFileBrowserRequest(res: Res, url: URL): void { + const dirPath = url.searchParams.get("dirPath"); + if (!dirPath) { + json(res, { error: "Missing dirPath parameter" }, 400); + return; + } + const resolvedDir = resolvePath(dirPath); + if (!existsSync(resolvedDir) || !statSync(resolvedDir).isDirectory()) { + json(res, { error: "Invalid directory path" }, 400); + return; + } + try { + const files: string[] = []; + walkMarkdownFiles(resolvedDir, resolvedDir, files); + files.sort(); + json(res, { tree: buildFileTree(files) }); + } catch { + json(res, { error: "Failed to list directory files" }, 500); + } +} diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts new file mode 100644 index 00000000..bc8edfb8 --- /dev/null +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -0,0 +1,119 @@ +import { createServer } from "node:http"; +import { dirname, resolve as resolvePath } from "node:path"; + +import { contentHash, deleteDraft } from "../generated/draft.js"; + +import { + handleDraftRequest, + handleFavicon, + handleImageRequest, + handleUploadRequest, +} from "./handlers.js"; +import { html, json, parseBody, requestUrl } from "./helpers.js"; + +import { listenOnPort } from "./network.js"; + +import { getRepoInfo } from "./project.js"; +import { handleDocRequest, handleFileBrowserRequest } from "./reference.js"; + +export interface AnnotateServerResult { + port: number; + portSource: "env" | "remote-default" | "random"; + url: string; + waitForDecision: () => Promise<{ feedback: string; annotations: unknown[] }>; + stop: () => void; +} + +export async function startAnnotateServer(options: { + markdown: string; + filePath: string; + htmlContent: string; + origin?: string; + mode?: string; + sharingEnabled?: boolean; + shareBaseUrl?: string; + pasteApiUrl?: string; +}): Promise { + const sharingEnabled = + options.sharingEnabled ?? process.env.PLANNOTATOR_SHARE !== "disabled"; + const shareBaseUrl = + (options.shareBaseUrl ?? process.env.PLANNOTATOR_SHARE_URL) || undefined; + const pasteApiUrl = + (options.pasteApiUrl ?? process.env.PLANNOTATOR_PASTE_URL) || undefined; + + let resolveDecision!: (result: { + feedback: string; + annotations: unknown[]; + }) => void; + const decisionPromise = new Promise<{ + feedback: string; + annotations: unknown[]; + }>((r) => { + resolveDecision = r; + }); + + // Draft key for annotation persistence + const draftKey = contentHash(options.markdown); + + // Detect repo info (cached for this session) + const repoInfo = getRepoInfo(); + + const server = createServer(async (req, res) => { + const url = requestUrl(req); + + if (url.pathname === "/api/plan" && req.method === "GET") { + json(res, { + plan: options.markdown, + origin: options.origin ?? "pi", + mode: options.mode || "annotate", + filePath: options.filePath, + sharingEnabled, + shareBaseUrl, + pasteApiUrl, + repoInfo, + projectRoot: process.cwd(), + }); + } else if (url.pathname === "/api/image") { + handleImageRequest(res, url); + } else if (url.pathname === "/api/upload" && req.method === "POST") { + await handleUploadRequest(req, res); + } else if (url.pathname === "/api/draft") { + await handleDraftRequest(req, res, draftKey); + } else if (url.pathname === "/api/doc" && req.method === "GET") { + // Inject source file's directory as base for relative path resolution + if (!url.searchParams.has("base") && options.filePath) { + url.searchParams.set("base", dirname(resolvePath(options.filePath))); + } + handleDocRequest(res, url); + } else if (url.pathname === "/api/reference/files" && req.method === "GET") { + handleFileBrowserRequest(res, url); + } else if (url.pathname === "/favicon.svg") { + handleFavicon(res); + } else if (url.pathname === "/api/feedback" && req.method === "POST") { + try { + const body = await parseBody(req); + deleteDraft(draftKey); + resolveDecision({ + feedback: (body.feedback as string) || "", + annotations: (body.annotations as unknown[]) || [], + }); + json(res, { ok: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to process feedback"; + json(res, { error: message }, 500); + } + } else { + html(res, options.htmlContent); + } + }); + + const { port, portSource } = await listenOnPort(server); + + return { + port, + portSource, + url: `http://localhost:${port}`, + waitForDecision: () => decisionPromise, + stop: () => server.close(), + }; +} diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts new file mode 100644 index 00000000..e3051538 --- /dev/null +++ b/apps/pi-extension/server/serverPlan.ts @@ -0,0 +1,426 @@ +import { createServer } from "node:http"; + +import { contentHash, deleteDraft } from "../generated/draft.js"; +import { + type ArchivedPlan, + generateSlug, + getPlanVersion, + getPlanVersionPath, + getVersionCount, + listArchivedPlans, + listVersions, + readArchivedPlan, + saveAnnotations, + saveFinalSnapshot, + saveToHistory, +} from "../generated/storage.js"; +import { createEditorAnnotationHandler } from "./annotations.js"; +import { + handleDraftRequest, + handleFavicon, + handleImageRequest, + handleUploadRequest, +} from "./handlers.js"; +import { html, json, parseBody, requestUrl } from "./helpers.js"; +import { openEditorDiff } from "./ide.js"; +import { + type BearConfig, + type IntegrationResult, + type ObsidianConfig, + type OctarineConfig, + saveToBear, + saveToObsidian, + saveToOctarine, +} from "./integrations.js"; +import { listenOnPort } from "./network.js"; + +import { detectProjectName, getRepoInfo } from "./project.js"; +import { + handleDocRequest, + handleFileBrowserRequest, + handleObsidianDocRequest, + handleObsidianFilesRequest, + handleObsidianVaultsRequest, +} from "./reference.js"; + +export interface PlanServerResult { + port: number; + portSource: "env" | "remote-default" | "random"; + url: string; + waitForDecision: () => Promise<{ approved: boolean; feedback?: string; savedPath?: string; agentSwitch?: string; permissionMode?: string }>; + waitForDone?: () => Promise; + stop: () => void; +} + +export async function startPlanReviewServer(options: { + plan: string; + htmlContent: string; + origin?: string; + permissionMode?: string; + sharingEnabled?: boolean; + shareBaseUrl?: string; + pasteApiUrl?: string; + mode?: "archive"; + customPlanPath?: string | null; +}): Promise { + const sharingEnabled = + options.sharingEnabled ?? process.env.PLANNOTATOR_SHARE !== "disabled"; + const shareBaseUrl = + (options.shareBaseUrl ?? process.env.PLANNOTATOR_SHARE_URL) || undefined; + const pasteApiUrl = + (options.pasteApiUrl ?? process.env.PLANNOTATOR_PASTE_URL) || undefined; + + // --- Archive mode setup --- + let archivePlans: ArchivedPlan[] = []; + let initialArchivePlan = ""; + let resolveDone: (() => void) | undefined; + let donePromise: Promise | undefined; + + if (options.mode === "archive") { + archivePlans = listArchivedPlans(options.customPlanPath ?? undefined); + initialArchivePlan = + archivePlans.length > 0 + ? (readArchivedPlan( + archivePlans[0].filename, + options.customPlanPath ?? undefined, + ) ?? "") + : ""; + donePromise = new Promise((resolve) => { + resolveDone = resolve; + }); + } + + // --- Plan review mode setup (skip in archive mode) --- + const repoInfo = options.mode !== "archive" ? getRepoInfo() : null; + const slug = options.mode !== "archive" ? generateSlug(options.plan) : ""; + const project = options.mode !== "archive" ? detectProjectName() : ""; + const historyResult = + options.mode !== "archive" + ? saveToHistory(project, slug, options.plan) + : { version: 0, path: "", isNew: false }; + const previousPlan = + options.mode !== "archive" && historyResult.version > 1 + ? getPlanVersion(project, slug, historyResult.version - 1) + : null; + const versionInfo = + options.mode !== "archive" + ? { + version: historyResult.version, + totalVersions: getVersionCount(project, slug), + project, + } + : null; + + let resolveDecision!: (result: { + approved: boolean; + feedback?: string; + savedPath?: string; + agentSwitch?: string; + permissionMode?: string; + }) => void; + const decisionPromise = new Promise<{ + approved: boolean; + feedback?: string; + savedPath?: string; + agentSwitch?: string; + permissionMode?: string; + }>((r) => { + resolveDecision = r; + }); + + // Draft key for annotation persistence + const draftKey = options.mode !== "archive" ? contentHash(options.plan) : ""; + + // Editor annotations (in-memory, VS Code integration — skip in archive mode) + const editorAnnotations = options.mode !== "archive" ? createEditorAnnotationHandler() : null; + + // Lazy cache for in-session archive tab + let cachedArchivePlans: ArchivedPlan[] | null = null; + + const server = createServer(async (req, res) => { + const url = requestUrl(req); + + if (url.pathname === "/api/done" && req.method === "POST") { + resolveDone?.(); + json(res, { ok: true }); + } else if (url.pathname === "/api/archive/plans" && req.method === "GET") { + const customPath = url.searchParams.get("customPath") || undefined; + if (!cachedArchivePlans) + cachedArchivePlans = listArchivedPlans(customPath); + json(res, { plans: cachedArchivePlans }); + } else if (url.pathname === "/api/archive/plan" && req.method === "GET") { + const filename = url.searchParams.get("filename"); + const customPath = url.searchParams.get("customPath") || undefined; + if (!filename) { + json(res, { error: "Missing filename" }, 400); + return; + } + const markdown = readArchivedPlan(filename, customPath); + if (!markdown) { + json(res, { error: "Not found" }, 404); + return; + } + json(res, { markdown, filepath: filename }); + } else if (url.pathname === "/api/plan/version") { + const vParam = url.searchParams.get("v"); + if (!vParam) { + json(res, { error: "Missing v parameter" }, 400); + return; + } + const v = parseInt(vParam, 10); + if (Number.isNaN(v) || v < 1) { + json(res, { error: "Invalid version number" }, 400); + return; + } + const content = getPlanVersion(project, slug, v); + if (content === null) { + json(res, { error: "Version not found" }, 404); + return; + } + json(res, { plan: content, version: v }); + } else if (url.pathname === "/api/plan/versions") { + json(res, { project, slug, versions: listVersions(project, slug) }); + } else if (url.pathname === "/api/plan") { + if (options.mode === "archive") { + json(res, { + plan: initialArchivePlan, + origin: options.origin ?? "pi", + mode: "archive", + archivePlans, + sharingEnabled, + shareBaseUrl, + }); + } else { + json(res, { + plan: options.plan, + origin: options.origin ?? "pi", + permissionMode: options.permissionMode, + previousPlan, + versionInfo, + sharingEnabled, + shareBaseUrl, + pasteApiUrl, + repoInfo, + projectRoot: process.cwd(), + }); + } + } else if (url.pathname === "/api/image") { + handleImageRequest(res, url); + } else if (url.pathname === "/api/upload" && req.method === "POST") { + await handleUploadRequest(req, res); + } else if (url.pathname === "/api/draft") { + await handleDraftRequest(req, res, draftKey); + } else if (editorAnnotations && (await editorAnnotations.handle(req, res, url))) { + return; + } else if (url.pathname === "/api/doc" && req.method === "GET") { + handleDocRequest(res, url); + } else if (url.pathname === "/api/obsidian/vaults") { + handleObsidianVaultsRequest(res); + } else if (url.pathname === "/api/reference/obsidian/files" && req.method === "GET") { + handleObsidianFilesRequest(res, url); + } else if (url.pathname === "/api/reference/obsidian/doc" && req.method === "GET") { + handleObsidianDocRequest(res, url); + } else if (url.pathname === "/api/reference/files" && req.method === "GET") { + handleFileBrowserRequest(res, url); + } else if ( + url.pathname === "/api/plan/vscode-diff" && + req.method === "POST" + ) { + try { + const body = await parseBody(req); + const baseVersion = body.baseVersion as number; + if (!baseVersion) { + json(res, { error: "Missing baseVersion" }, 400); + return; + } + const basePath = getPlanVersionPath(project, slug, baseVersion); + if (!basePath) { + json(res, { error: `Version ${baseVersion} not found` }, 404); + return; + } + const result = await openEditorDiff(basePath, historyResult.path); + if ("error" in result) { + json(res, { error: result.error }, 500); + return; + } + json(res, { ok: true }); + } catch (err) { + json( + res, + { + error: + err instanceof Error + ? err.message + : "Failed to open VS Code diff", + }, + 500, + ); + } + } else if (url.pathname === "/api/agents" && req.method === "GET") { + json(res, { agents: [] }); + } else if (url.pathname === "/favicon.svg") { + handleFavicon(res); + } else if (url.pathname === "/api/save-notes" && req.method === "POST") { + const results: { + obsidian?: IntegrationResult; + bear?: IntegrationResult; + octarine?: IntegrationResult; + } = {}; + try { + const body = await parseBody(req); + const promises: Promise[] = []; + const obsConfig = body.obsidian as ObsidianConfig | undefined; + const bearConfig = body.bear as BearConfig | undefined; + const octConfig = body.octarine as OctarineConfig | undefined; + if (obsConfig?.vaultPath && obsConfig?.plan) { + promises.push( + saveToObsidian(obsConfig).then((r) => { + results.obsidian = r; + }), + ); + } + if (bearConfig?.plan) { + promises.push( + saveToBear(bearConfig).then((r) => { + results.bear = r; + }), + ); + } + if (octConfig?.plan && octConfig?.workspace) { + promises.push( + saveToOctarine(octConfig).then((r) => { + results.octarine = r; + }), + ); + } + await Promise.allSettled(promises); + for (const [name, result] of Object.entries(results)) { + if (!result?.success && result) + console.error(`[${name}] Save failed: ${result.error}`); + } + } catch (err) { + console.error(`[Save Notes] Error:`, err); + json(res, { error: "Save failed" }, 500); + return; + } + json(res, { ok: true, results }); + } else if (url.pathname === "/api/approve" && req.method === "POST") { + let feedback: string | undefined; + let agentSwitch: string | undefined; + let requestedPermissionMode: string | undefined; + let planSaveEnabled = true; + let planSaveCustomPath: string | undefined; + try { + const body = await parseBody(req); + if (body.feedback) feedback = body.feedback as string; + if (body.agentSwitch) agentSwitch = body.agentSwitch as string; + if (body.permissionMode) + requestedPermissionMode = body.permissionMode as string; + if (body.planSave !== undefined) { + const ps = body.planSave as { enabled: boolean; customPath?: string }; + planSaveEnabled = ps.enabled; + planSaveCustomPath = ps.customPath; + } + // Run note integrations in parallel + const integrationResults: Record = {}; + const integrationPromises: Promise[] = []; + const obsConfig = body.obsidian as ObsidianConfig | undefined; + const bearConfig = body.bear as BearConfig | undefined; + const octConfig = body.octarine as OctarineConfig | undefined; + if (obsConfig?.vaultPath && obsConfig?.plan) { + integrationPromises.push( + saveToObsidian(obsConfig).then((r) => { + integrationResults.obsidian = r; + }), + ); + } + if (bearConfig?.plan) { + integrationPromises.push( + saveToBear(bearConfig).then((r) => { + integrationResults.bear = r; + }), + ); + } + if (octConfig?.plan && octConfig?.workspace) { + integrationPromises.push( + saveToOctarine(octConfig).then((r) => { + integrationResults.octarine = r; + }), + ); + } + await Promise.allSettled(integrationPromises); + for (const [name, result] of Object.entries(integrationResults)) { + if (!result?.success && result) + console.error(`[${name}] Save failed: ${result.error}`); + } + } catch (err) { + console.error(`[Integration] Error:`, err); + } + // Save annotations and final snapshot + let savedPath: string | undefined; + if (planSaveEnabled) { + const annotations = feedback || ""; + if (annotations) saveAnnotations(slug, annotations, planSaveCustomPath); + savedPath = saveFinalSnapshot( + slug, + "approved", + options.plan, + annotations, + planSaveCustomPath, + ); + } + deleteDraft(draftKey); + const effectivePermissionMode = requestedPermissionMode || options.permissionMode; + resolveDecision({ + approved: true, + feedback, + savedPath, + agentSwitch, + permissionMode: effectivePermissionMode, + }); + json(res, { ok: true, savedPath }); + } else if (url.pathname === "/api/deny" && req.method === "POST") { + let feedback = "Plan rejected by user"; + let planSaveEnabled = true; + let planSaveCustomPath: string | undefined; + try { + const body = await parseBody(req); + feedback = (body.feedback as string) || feedback; + if (body.planSave !== undefined) { + const ps = body.planSave as { enabled: boolean; customPath?: string }; + planSaveEnabled = ps.enabled; + planSaveCustomPath = ps.customPath; + } + } catch { + /* use default feedback */ + } + let savedPath: string | undefined; + if (planSaveEnabled) { + saveAnnotations(slug, feedback, planSaveCustomPath); + savedPath = saveFinalSnapshot( + slug, + "denied", + options.plan, + feedback, + planSaveCustomPath, + ); + } + deleteDraft(draftKey); + resolveDecision({ approved: false, feedback, savedPath }); + json(res, { ok: true, savedPath }); + } else { + html(res, options.htmlContent); + } + }); + + const { port, portSource } = await listenOnPort(server); + + return { + port, + portSource, + url: `http://localhost:${port}`, + waitForDecision: () => decisionPromise, + ...(donePromise && { waitForDone: () => donePromise }), + stop: () => server.close(), + }; +} diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts new file mode 100644 index 00000000..7d575d1b --- /dev/null +++ b/apps/pi-extension/server/serverReview.ts @@ -0,0 +1,516 @@ +import { execSync, spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { createServer } from "node:http"; + +import { Readable } from "node:stream"; + +import { contentHash, deleteDraft } from "../generated/draft.js"; + +export type { + DiffOption, + DiffType, + GitContext, +} from "../generated/review-core.js"; + +import { + getDisplayRepo, + getMRLabel, + getMRNumberLabel, + type PRMetadata, + type PRReviewFileComment, + prRefFromMetadata, +} from "../generated/pr-provider.js"; +import { + type DiffType, + type GitCommandResult, + type GitContext, + getFileContentsForDiff as getFileContentsForDiffCore, + getGitContext as getGitContextCore, + gitAddFile as gitAddFileCore, + gitResetFile as gitResetFileCore, + parseWorktreeDiffType, + type ReviewGitRuntime, + runGitDiff as runGitDiffCore, + validateFilePath, +} from "../generated/review-core.js"; + +import { createEditorAnnotationHandler } from "./annotations.js"; +import { + handleDraftRequest, + handleFavicon, + handleImageRequest, + handleUploadRequest, +} from "./handlers.js"; +import { html, json, parseBody, requestUrl, toWebRequest } from "./helpers.js"; + +import { listenOnPort } from "./network.js"; +import { + fetchPRContext, + fetchPRFileContent, + getPRUser, + submitPRReview, +} from "./pr.js"; +import { getRepoInfo } from "./project.js"; + +export interface ReviewServerResult { + port: number; + portSource: "env" | "remote-default" | "random"; + url: string; + waitForDecision: () => Promise<{ + approved: boolean; + feedback: string; + annotations: unknown[]; + agentSwitch?: string; + }>; + stop: () => void; +} + +const reviewRuntime: ReviewGitRuntime = { + async runGit( + args: string[], + options?: { cwd?: string }, + ): Promise { + const result = spawnSync("git", args, { + cwd: options?.cwd, + encoding: "utf-8", + }); + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + exitCode: result.status ?? (result.error ? 1 : 0), + }; + }, + + async readTextFile(path: string): Promise { + try { + return readFileSync(path, "utf-8"); + } catch { + return null; + } + }, +}; + +export function getGitContext(): Promise { + return getGitContextCore(reviewRuntime); +} + +export function runGitDiff( + diffType: DiffType, + defaultBranch = "main", + cwd?: string, +): Promise<{ patch: string; label: string; error?: string }> { + return runGitDiffCore(reviewRuntime, diffType, defaultBranch, cwd); +} + +export async function startReviewServer(options: { + rawPatch: string; + gitRef: string; + htmlContent: string; + origin?: string; + diffType?: DiffType; + gitContext?: GitContext; + error?: string; + sharingEnabled?: boolean; + shareBaseUrl?: string; + prMetadata?: PRMetadata; +}): Promise { + const draftKey = contentHash(options.rawPatch); + const prMeta = options.prMetadata; + const isPRMode = !!prMeta; + const prRef = prMeta ? prRefFromMetadata(prMeta) : null; + const platformUser = prRef ? await getPRUser(prRef) : null; + const repoInfo = prMeta + ? { + display: getDisplayRepo(prMeta), + branch: `${getMRLabel(prMeta)} ${getMRNumberLabel(prMeta)}`, + } + : getRepoInfo(); + const editorAnnotations = createEditorAnnotationHandler(); + let currentPatch = options.rawPatch; + let currentGitRef = options.gitRef; + let currentDiffType: DiffType = options.diffType || "uncommitted"; + let currentError = options.error; + const sharingEnabled = + options.sharingEnabled ?? process.env.PLANNOTATOR_SHARE !== "disabled"; + const shareBaseUrl = + (options.shareBaseUrl ?? process.env.PLANNOTATOR_SHARE_URL) || undefined; + let resolveDecision!: (result: { + approved: boolean; + feedback: string; + annotations: unknown[]; + agentSwitch?: string; + }) => void; + const decisionPromise = new Promise<{ + approved: boolean; + feedback: string; + annotations: unknown[]; + agentSwitch?: string; + }>((r) => { + resolveDecision = r; + }); + + // AI provider setup (graceful — AI features degrade if SDK unavailable) + // Types are `any` because @plannotator/ai is a dynamic import + let aiEndpoints: Record Promise> | null = + null; + let aiSessionManager: { disposeAll: () => void } | null = null; + let aiRegistry: { disposeAll: () => void } | null = null; + try { + const ai = await import("@plannotator/ai"); + const registry = new ai.ProviderRegistry(); + const sessionManager = new ai.SessionManager(); + + // which() helper for Node.js + const whichCmd = (cmd: string): string | null => { + try { + return ( + execSync(`which ${cmd}`, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim() || null + ); + } catch { + return null; + } + }; + + // Claude Agent SDK + try { + // @ts-ignore — dynamic import; Bun-only types resolved at runtime + await import("@plannotator/ai/providers/claude-agent-sdk"); + const claudePath = whichCmd("claude"); + const provider = await ai.createProvider({ + type: "claude-agent-sdk", + cwd: process.cwd(), + ...(claudePath && { claudeExecutablePath: claudePath }), + }); + registry.register(provider); + } catch { + /* Claude SDK not available */ + } + + // Codex SDK + try { + // @ts-ignore — dynamic import; Bun-only types resolved at runtime + await import("@plannotator/ai/providers/codex-sdk"); + await import("@openai/codex-sdk"); + const codexPath = whichCmd("codex"); + const provider = await ai.createProvider({ + type: "codex-sdk", + cwd: process.cwd(), + ...(codexPath && { codexExecutablePath: codexPath }), + }); + registry.register(provider); + } catch { + /* Codex SDK not available */ + } + + // Pi SDK (Node.js variant) + try { + await import("@plannotator/ai/providers/pi-sdk-node"); + const piPath = whichCmd("pi"); + if (piPath) { + const provider = await ai.createProvider({ + type: "pi-sdk", + cwd: process.cwd(), + piExecutablePath: piPath, + } as any); + if (provider && "fetchModels" in provider) { + await ( + provider as { fetchModels: () => Promise } + ).fetchModels(); + } + registry.register(provider); + } + } catch { + /* Pi not available */ + } + + // OpenCode SDK + try { + // @ts-ignore — dynamic import; Bun-only types resolved at runtime + await import("@plannotator/ai/providers/opencode-sdk"); + const opencodePath = whichCmd("opencode"); + if (opencodePath) { + const provider = await ai.createProvider({ + type: "opencode-sdk", + cwd: process.cwd(), + }); + if (provider && "fetchModels" in provider) { + await ( + provider as { fetchModels: () => Promise } + ).fetchModels(); + } + registry.register(provider); + } + } catch { + /* OpenCode not available */ + } + + if (registry.size > 0) { + aiEndpoints = ai.createAIEndpoints({ + registry, + sessionManager, + getCwd: () => { + if (currentDiffType.startsWith("worktree:")) { + const parsed = parseWorktreeDiffType(currentDiffType); + if (parsed) return parsed.path; + } + return options.gitContext?.cwd ?? process.cwd(); + }, + }); + aiSessionManager = sessionManager; + aiRegistry = registry; + } + } catch { + /* AI backbone not available */ + } + + const server = createServer(async (req, res) => { + const url = requestUrl(req); + + if (url.pathname === "/api/diff" && req.method === "GET") { + json(res, { + rawPatch: currentPatch, + gitRef: currentGitRef, + origin: options.origin ?? "pi", + diffType: isPRMode ? undefined : currentDiffType, + gitContext: isPRMode ? undefined : options.gitContext, + sharingEnabled, + shareBaseUrl, + repoInfo, + ...(isPRMode && { prMetadata: prMeta, platformUser }), + ...(currentError ? { error: currentError } : {}), + }); + } else if (url.pathname === "/api/diff/switch" && req.method === "POST") { + if (isPRMode) { + json(res, { error: "Not available for PR reviews" }, 400); + return; + } + try { + const body = await parseBody(req); + const newType = body.diffType as DiffType; + if (!newType) { + json(res, { error: "Missing diffType" }, 400); + return; + } + const defaultBranch = options.gitContext?.defaultBranch || "main"; + const defaultCwd = options.gitContext?.cwd; + const result = await runGitDiff(newType, defaultBranch, defaultCwd); + currentPatch = result.patch; + currentGitRef = result.label; + currentDiffType = newType; + currentError = result.error; + json(res, { + rawPatch: currentPatch, + gitRef: currentGitRef, + diffType: currentDiffType, + ...(currentError ? { error: currentError } : {}), + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to switch diff"; + json(res, { error: message }, 500); + } + } else if (url.pathname === "/api/pr-context" && req.method === "GET") { + if (!isPRMode || !prRef) { + json(res, { error: "Not in PR mode" }, 400); + return; + } + try { + const context = await fetchPRContext(prRef); + json(res, context); + } catch (err) { + json( + res, + { + error: + err instanceof Error ? err.message : "Failed to fetch PR context", + }, + 500, + ); + } + } else if (url.pathname === "/api/pr-action" && req.method === "POST") { + if (!isPRMode || !prMeta || !prRef) { + json(res, { error: "Not in PR mode" }, 400); + return; + } + try { + const body = await parseBody(req); + await submitPRReview( + prRef, + prMeta.headSha, + body.action as "approve" | "comment", + body.body as string, + (body.fileComments as PRReviewFileComment[]) || [], + ); + json(res, { ok: true, prUrl: prMeta.url }); + } catch (err) { + json( + res, + { + error: + err instanceof Error ? err.message : "Failed to submit PR review", + }, + 500, + ); + } + } else if (url.pathname === "/api/file-content" && req.method === "GET") { + const filePath = url.searchParams.get("path"); + if (!filePath) { + json(res, { error: "Missing path" }, 400); + return; + } + try { + validateFilePath(filePath); + } catch { + json(res, { error: "Invalid path" }, 400); + return; + } + const oldPath = url.searchParams.get("oldPath") || undefined; + if (oldPath) { + try { + validateFilePath(oldPath); + } catch { + json(res, { error: "Invalid path" }, 400); + return; + } + } + + if (isPRMode && prRef && prMeta) { + try { + const [oldContent, newContent] = await Promise.all([ + fetchPRFileContent(prRef, prMeta.baseSha, oldPath || filePath), + fetchPRFileContent(prRef, prMeta.headSha, filePath), + ]); + json(res, { oldContent, newContent }); + } catch (err) { + json( + res, + { + error: + err instanceof Error + ? err.message + : "Failed to fetch file content", + }, + 500, + ); + } + return; + } + + const defaultBranch = options.gitContext?.defaultBranch || "main"; + const defaultCwd = options.gitContext?.cwd; + const result = await getFileContentsForDiffCore( + reviewRuntime, + currentDiffType, + defaultBranch, + filePath, + oldPath, + defaultCwd, + ); + json(res, result); + } else if (url.pathname === "/api/image") { + handleImageRequest(res, url); + } else if (url.pathname === "/api/upload" && req.method === "POST") { + await handleUploadRequest(req, res); + } else if (url.pathname === "/api/agents" && req.method === "GET") { + json(res, { agents: [] }); + } else if (url.pathname === "/api/git-add" && req.method === "POST") { + if (isPRMode) { + json(res, { error: "Not available for PR reviews" }, 400); + return; + } + try { + const body = await parseBody(req); + const filePath = body.filePath as string | undefined; + if (!filePath) { + json(res, { error: "Missing filePath" }, 400); + return; + } + let cwd: string | undefined; + if (currentDiffType.startsWith("worktree:")) { + const parsed = parseWorktreeDiffType(currentDiffType); + if (parsed) cwd = parsed.path; + } + if (!cwd) { + cwd = options.gitContext?.cwd; + } + if (body.undo) { + await gitResetFileCore(reviewRuntime, filePath, cwd); + } else { + await gitAddFileCore(reviewRuntime, filePath, cwd); + } + json(res, { ok: true }); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to git add"; + json(res, { error: message }, 500); + } + } else if (url.pathname === "/api/draft") { + await handleDraftRequest(req, res, draftKey); + } else if (url.pathname === "/favicon.svg") { + handleFavicon(res); + } else if (await editorAnnotations.handle(req, res, url)) { + return; + } else if (aiEndpoints && url.pathname.startsWith("/api/ai/")) { + const handler = aiEndpoints[url.pathname]; + if (handler) { + try { + const webReq = toWebRequest(req); + const webRes = await handler(webReq); + // Pipe Web Response → node:http response + const headers: Record = {}; + webRes.headers.forEach((v, k) => { + headers[k] = v; + }); + res.writeHead(webRes.status, headers); + if (webRes.body) { + const nodeStream = Readable.fromWeb(webRes.body as any); + nodeStream.pipe(res); + } else { + res.end(); + } + } catch (err) { + json( + res, + { error: err instanceof Error ? err.message : "AI endpoint error" }, + 500, + ); + } + return; + } + json(res, { error: "Not found" }, 404); + } else if (url.pathname === "/api/feedback" && req.method === "POST") { + try { + const body = await parseBody(req); + deleteDraft(draftKey); + resolveDecision({ + approved: (body.approved as boolean) ?? false, + feedback: (body.feedback as string) || "", + annotations: (body.annotations as unknown[]) || [], + agentSwitch: body.agentSwitch as string | undefined, + }); + json(res, { ok: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to process feedback"; + json(res, { error: message }, 500); + } + } else { + html(res, options.htmlContent); + } + }); + + const { port, portSource } = await listenOnPort(server); + + return { + port, + portSource, + url: `http://localhost:${port}`, + waitForDecision: () => decisionPromise, + stop: () => { + aiSessionManager?.disposeAll(); + aiRegistry?.disposeAll(); + server.close(); + }, + }; +} diff --git a/apps/pi-extension/tsconfig.json b/apps/pi-extension/tsconfig.json new file mode 100644 index 00000000..23c4fc09 --- /dev/null +++ b/apps/pi-extension/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "types": ["node"] + }, + "include": ["index.ts", "server.ts", "server/**/*.ts"], + "exclude": ["**/*.test.ts"] +} diff --git a/apps/pi-extension/utils.ts b/apps/pi-extension/utils.ts deleted file mode 100644 index f3f6f41d..00000000 --- a/apps/pi-extension/utils.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Plannotator Pi extension utilities. - * - * Checklist parsing and progress tracking helpers. - * (No access to pi-mono's plan-mode/utils at runtime.) - */ - -// ── Checklist Parsing ──────────────────────────────────────────────────── - -export interface ChecklistItem { - /** 1-based step number, compatible with markCompletedSteps/extractDoneSteps. */ - step: number; - text: string; - completed: boolean; -} - -/** - * Parse standard markdown checkboxes from file content. - * - * Matches lines like: - * - [ ] Step description - * - [x] Completed step - * * [ ] Alternative bullet - */ -export function parseChecklist(content: string): ChecklistItem[] { - const items: ChecklistItem[] = []; - const pattern = /^[-*]\s*\[([ xX])\]\s+(.+)$/gm; - - for (const match of content.matchAll(pattern)) { - const completed = match[1] !== " "; - const text = match[2].trim(); - if (text.length > 0) { - items.push({ step: items.length + 1, text, completed }); - } - } - return items; -} - -// ── Progress Tracking ──────────────────────────────────────────────────── - -export function extractDoneSteps(message: string): number[] { - const steps: number[] = []; - for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) { - const step = Number(match[1]); - if (Number.isFinite(step)) steps.push(step); - } - return steps; -} - -export function markCompletedSteps(text: string, items: ChecklistItem[]): number { - const doneSteps = extractDoneSteps(text); - for (const step of doneSteps) { - const item = items.find((t) => t.step === step); - if (item) item.completed = true; - } - return doneSteps.length; -} diff --git a/bun.lock b/bun.lock index 5c79397d..3d5a140a 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,8 @@ }, "devDependencies": { "@types/dompurify": "^3.2.0", + "@types/node": "^25.5.0", + "bun-types": "^1.3.11", }, }, "apps/hook": { @@ -983,7 +985,7 @@ "@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="], - "@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], @@ -1123,7 +1125,7 @@ "bun": ["bun@1.3.5", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.5", "@oven/bun-darwin-x64": "1.3.5", "@oven/bun-darwin-x64-baseline": "1.3.5", "@oven/bun-linux-aarch64": "1.3.5", "@oven/bun-linux-aarch64-musl": "1.3.5", "@oven/bun-linux-x64": "1.3.5", "@oven/bun-linux-x64-baseline": "1.3.5", "@oven/bun-linux-x64-musl": "1.3.5", "@oven/bun-linux-x64-musl-baseline": "1.3.5", "@oven/bun-windows-x64": "1.3.5", "@oven/bun-windows-x64-baseline": "1.3.5" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-c1YHIGUfgvYPJmLug5QiLzNWlX2Dg7X/67JWu1Va+AmMXNXzC/KQn2lgQ7rD+n1u1UqDpJMowVGGxTNpbPydNw=="], - "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -2269,7 +2271,7 @@ "undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "unenv": ["unenv@2.0.0-rc.14", "", { "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.1", "ohash": "^2.0.10", "pathe": "^2.0.3", "ufo": "^1.5.4" } }, "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q=="], @@ -2455,6 +2457,12 @@ "@opencode-ai/plugin/@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.25", "", {}, "sha512-mWUX489ArEF2ICg3iZsx2VQaGS3Z2j/dwAJDacao9t7dGDzjOIaacPw2weZ10zld7XmT9V9C0PM/A5lDZ52J+w=="], + "@plannotator/hooks/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], + + "@plannotator/portal/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], + + "@plannotator/review/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], @@ -2477,6 +2485,10 @@ "@textlint/linter-formatter/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + + "@types/sax/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], + "@vscode/vsce/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@vscode/vsce/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], @@ -2569,6 +2581,8 @@ "parse5-parser-stream/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "protobufjs/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], + "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], "read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], @@ -2633,10 +2647,20 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@plannotator/hooks/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "@plannotator/portal/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "@plannotator/review/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@textlint/linter-formatter/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "@textlint/linter-formatter/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@types/bun/bun-types/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], + + "@types/sax/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@vscode/vsce/glob/minimatch": ["minimatch@10.2.2", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw=="], "@vscode/vsce/hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], @@ -2723,6 +2747,8 @@ "parse5-parser-stream/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "protobufjs/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "rimraf/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "rimraf/glob/minimatch": ["minimatch@9.0.6", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ=="], @@ -2881,6 +2907,8 @@ "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers/@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.993.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" } }, "sha512-j6vioBeRZ4eHX4SWGvGPpwGg/xSOcK7f1GL0VM+rdf3ZFTIsUEhCFmD78B+5r2PgztcECSzEfvHQX01k8dPQPw=="], + "@types/bun/bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@vscode/vsce/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/openpackage.yml b/openpackage.yml index 68e29f9e..af66700a 100644 --- a/openpackage.yml +++ b/openpackage.yml @@ -1,8 +1,8 @@ name: plannotator -version: 0.8.0 -description: Interactive Plan Review for Claude Code. Annotate plans visually, share with team, and send feedback to agents. +version: 0.14.5 +description: Annotate Claude Code and other agent plans and review code visually. Share with your team, and send feedback to your agent with one click author: backnotprop -license: MIT +license: MIT/Apache2.0 includes: - apps/hook/**/* diff --git a/package.json b/package.json index ff8b7f4f..3dc677fe 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "dev:vscode": "bun run --cwd apps/vscode-extension watch", "build:vscode": "bun run --cwd apps/vscode-extension build", "package:vscode": "bun run --cwd apps/vscode-extension package", - "test": "bun test" + "test": "bun test", + "typecheck": "tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.81", @@ -41,6 +42,8 @@ "marked": "^17.0.5" }, "devDependencies": { - "@types/dompurify": "^3.2.0" + "@types/dompurify": "^3.2.0", + "@types/node": "^25.5.0", + "bun-types": "^1.3.11" } } diff --git a/packages/ai/package.json b/packages/ai/package.json index 674a6a62..6b303372 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -13,7 +13,8 @@ "./providers/claude-agent-sdk": "./providers/claude-agent-sdk.ts", "./providers/codex-sdk": "./providers/codex-sdk.ts", "./providers/pi-sdk": "./providers/pi-sdk.ts", - "./providers/opencode-sdk": "./providers/opencode-sdk.ts" + "./providers/opencode-sdk": "./providers/opencode-sdk.ts", + "./providers/pi-sdk-node": "./providers/pi-sdk-node.ts" }, "dependencies": {} } diff --git a/packages/ai/providers/claude-agent-sdk.ts b/packages/ai/providers/claude-agent-sdk.ts index a62732a0..9cf800cc 100644 --- a/packages/ai/providers/claude-agent-sdk.ts +++ b/packages/ai/providers/claude-agent-sdk.ts @@ -141,14 +141,15 @@ export class ClaudeAgentSDKProvider implements AIProvider { // SDK import cache — resolve once, reuse across all queries // --------------------------------------------------------------------------- -let sdkQueryFn: ((args: { prompt: string; options: unknown }) => AsyncIterable>) | null = null; +// biome-ignore lint/suspicious/noExplicitAny: SDK types resolved at runtime via dynamic import +let sdkQueryFn: ((...args: any[]) => any) | null = null; async function getSDKQuery() { if (!sdkQueryFn) { const sdk = await import("@anthropic-ai/claude-agent-sdk"); sdkQueryFn = sdk.query; } - return sdkQueryFn; + return sdkQueryFn!; } // --------------------------------------------------------------------------- @@ -199,7 +200,8 @@ class ClaudeAgentSDKSession extends BaseSession { ); const options = this.buildQueryOptions(); - const stream = queryFn({ prompt: queryPrompt, options }); + const stream = queryFn({ prompt: queryPrompt, options }) as + AsyncIterable> & { streamInput: (iter: AsyncIterable) => Promise }; this._activeQuery = stream; this._firstQuerySent = true; diff --git a/packages/ai/providers/codex-sdk.ts b/packages/ai/providers/codex-sdk.ts index 1745c269..0c6da35c 100644 --- a/packages/ai/providers/codex-sdk.ts +++ b/packages/ai/providers/codex-sdk.ts @@ -105,7 +105,8 @@ let CodexClass: any = null; async function getCodexClass() { if (!CodexClass) { - const mod = await import("@openai/codex-sdk"); + // biome-ignore lint/suspicious/noExplicitAny: SDK exports vary between versions + const mod = await import("@openai/codex-sdk") as any; CodexClass = mod.default ?? mod.Codex; } return CodexClass; diff --git a/packages/ai/providers/opencode-sdk.ts b/packages/ai/providers/opencode-sdk.ts index 1d98d8f4..12e8d9c7 100644 --- a/packages/ai/providers/opencode-sdk.ts +++ b/packages/ai/providers/opencode-sdk.ts @@ -82,7 +82,7 @@ export class OpenCodeProvider implements AIProvider { }); this.client = createOpencodeClient({ - baseUrl: this.server.url, + baseUrl: this.server!.url, directory: this.config.cwd ?? process.cwd(), }); } diff --git a/packages/ai/providers/pi-events.ts b/packages/ai/providers/pi-events.ts new file mode 100644 index 00000000..7e4c7bf0 --- /dev/null +++ b/packages/ai/providers/pi-events.ts @@ -0,0 +1,110 @@ +/** + * Pi event mapping — shared between Bun and Node.js Pi providers. + * + * Pure function, no runtime-specific dependencies. + */ + +import type { AIMessage } from "../types.ts"; + +/** + * Map a Pi AgentEvent (received as JSONL) to AIMessage[]. + * + * Pi event hierarchy: + * agent_start > turn_start > message_start > message_update* > message_end + * > tool_execution_start > tool_execution_end > turn_end > agent_end + * + * We extract: + * - text_delta from message_update.assistantMessageEvent + * - tool_use from toolcall_end + * - tool_result from tool_execution_end + * - result from agent_end + */ +export function mapPiEvent( + event: Record, + sessionId: string, +): AIMessage[] { + const eventType = event.type as string; + + switch (eventType) { + case "message_update": { + const ame = event.assistantMessageEvent as + | Record + | undefined; + if (!ame) return []; + + const subType = ame.type as string; + + switch (subType) { + case "text_delta": + return [{ type: "text_delta", delta: ame.delta as string }]; + + case "toolcall_end": { + const tc = ame.toolCall as Record; + if (!tc) return []; + return [ + { + type: "tool_use", + toolName: tc.name as string, + toolInput: (tc.arguments as Record) ?? {}, + toolUseId: tc.id as string, + }, + ]; + } + + case "error": { + const partial = ame.error as Record | undefined; + const errorMessage = + (partial?.errorMessage as string) ?? "Stream error"; + return [ + { type: "error", error: errorMessage, code: "pi_stream_error" }, + ]; + } + + default: + return []; + } + } + + case "tool_execution_end": { + const result = event.result; + const isError = event.isError as boolean; + const resultStr = + result == null + ? "" + : typeof result === "string" + ? result + : JSON.stringify(result); + + return [ + { + type: "tool_result", + toolUseId: event.toolCallId as string, + result: isError + ? `[Error] ${resultStr || "Tool execution failed"}` + : resultStr, + }, + ]; + } + + case "agent_end": + return [ + { + type: "result", + sessionId, + success: true, + }, + ]; + + case "process_exited": + return [ + { + type: "error", + error: "Pi process exited unexpectedly.", + code: "pi_process_exit", + }, + ]; + + default: + return []; + } +} diff --git a/packages/ai/providers/pi-sdk-node.ts b/packages/ai/providers/pi-sdk-node.ts new file mode 100644 index 00000000..b4df836f --- /dev/null +++ b/packages/ai/providers/pi-sdk-node.ts @@ -0,0 +1,376 @@ +/** + * Pi SDK provider — Node.js variant. + * + * Identical to pi-sdk.ts except PiProcess uses child_process.spawn() + * instead of Bun.spawn(). Everything else (PiSDKProvider, PiSDKSession, + * mapPiEvent) is re-exported from the Bun version unchanged. + * + * Used by the Pi extension which runs under jiti (Node.js). + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import { BaseSession } from "../base-session.ts"; +import { buildEffectivePrompt, buildSystemPrompt } from "../context.ts"; +import type { + AIMessage, + AIProvider, + AIProviderCapabilities, + CreateSessionOptions, + PiSDKConfig, +} from "../types.ts"; +import { registerProviderFactory } from "../provider.ts"; + +// Re-export mapPiEvent from shared (runtime-agnostic) +export { mapPiEvent } from "./pi-events.ts"; + +const PROVIDER_NAME = "pi-sdk"; + +// --------------------------------------------------------------------------- +// JSONL subprocess wrapper (Node.js) +// --------------------------------------------------------------------------- + +type EventListener = (event: Record) => void; + +class PiProcessNode { + private proc: ChildProcess | null = null; + private listeners: EventListener[] = []; + private pendingRequests = new Map< + string, + { + resolve: (data: Record) => void; + reject: (err: Error) => void; + } + >(); + private nextId = 0; + private buffer = ""; + private _alive = false; + + async spawn(piPath: string, cwd: string): Promise { + this.proc = spawn(piPath, ["--mode", "rpc"], { + cwd, + stdio: ["pipe", "pipe", "pipe"], + }); + this._alive = true; + + this.readStream(); + + this.proc.on("exit", () => { + this._alive = false; + for (const [, pending] of this.pendingRequests) { + pending.reject(new Error("Pi process exited unexpectedly")); + } + this.pendingRequests.clear(); + for (const listener of this.listeners) { + listener({ type: "process_exited" }); + } + }); + } + + private readStream(): void { + if (!this.proc?.stdout) return; + + this.proc.stdout.on("data", (chunk: Buffer) => { + this.buffer += chunk.toString(); + const lines = this.buffer.split("\n"); + this.buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.replace(/\r$/, ""); + if (!trimmed) continue; + try { + const parsed = JSON.parse(trimmed); + this.routeMessage(parsed); + } catch { + // Ignore malformed lines + } + } + }); + } + + private routeMessage(msg: Record): void { + if (msg.type === "response" && typeof msg.id === "string") { + const pending = this.pendingRequests.get(msg.id); + if (pending) { + this.pendingRequests.delete(msg.id); + if (msg.success === false) { + pending.reject(new Error((msg.error as string) ?? "RPC error")); + } else { + pending.resolve((msg.data as Record) ?? {}); + } + return; + } + } + + for (const listener of this.listeners) { + listener(msg); + } + } + + send(command: Record): void { + if (!this.proc?.stdin || this.proc.stdin.destroyed) return; + this.proc.stdin.write(`${JSON.stringify(command)}\n`); + } + + sendAndWait( + command: Record, + ): Promise> { + const id = `req_${++this.nextId}`; + return new Promise((resolve, reject) => { + this.pendingRequests.set(id, { resolve, reject }); + this.send({ ...command, id }); + }); + } + + onEvent(listener: EventListener): () => void { + this.listeners.push(listener); + return () => { + const idx = this.listeners.indexOf(listener); + if (idx >= 0) this.listeners.splice(idx, 1); + }; + } + + get alive(): boolean { + return this._alive; + } + + kill(): void { + this._alive = false; + if (this.proc) { + this.proc.kill(); + this.proc = null; + } + this.listeners.length = 0; + for (const [, pending] of this.pendingRequests) { + pending.reject(new Error("Process killed")); + } + this.pendingRequests.clear(); + } +} + +// --------------------------------------------------------------------------- +// Provider (identical to pi-sdk.ts, using PiProcessNode) +// --------------------------------------------------------------------------- + +export class PiSDKNodeProvider implements AIProvider { + readonly name = PROVIDER_NAME; + readonly capabilities: AIProviderCapabilities = { + fork: false, + resume: false, + streaming: true, + tools: true, + }; + models?: Array<{ id: string; label: string; default?: boolean }>; + + private config: PiSDKConfig; + private sessions = new Map(); + + constructor(config: PiSDKConfig) { + this.config = config; + } + + async createSession(options: CreateSessionOptions): Promise { + const session = new PiSDKNodeSession({ + systemPrompt: buildSystemPrompt(options.context), + cwd: options.cwd ?? this.config.cwd ?? process.cwd(), + parentSessionId: null, + piExecutablePath: this.config.piExecutablePath ?? "pi", + model: options.model ?? this.config.model, + }); + this.sessions.set(session.id, session); + return session; + } + + async forkSession(): Promise { + throw new Error( + "Pi does not support session forking. " + + "The endpoint layer should fall back to createSession().", + ); + } + + async resumeSession(): Promise { + throw new Error("Pi does not support session resuming."); + } + + dispose(): void { + for (const session of this.sessions.values()) { + session.killProcess(); + } + this.sessions.clear(); + } + + async fetchModels(): Promise { + const piPath = this.config.piExecutablePath ?? "pi"; + let proc: PiProcessNode | undefined; + try { + proc = new PiProcessNode(); + await proc.spawn(piPath, this.config.cwd ?? process.cwd()); + const data = await Promise.race([ + proc.sendAndWait({ type: "get_available_models" }), + new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout")), 10_000), + ), + ]); + const rawModels = ( + data as { models?: Array<{ provider: string; id: string; name?: string }> } + ).models; + if (rawModels && rawModels.length > 0) { + this.models = rawModels.map((m, i) => ({ + id: `${m.provider}/${m.id}`, + label: m.name ?? m.id, + ...(i === 0 && { default: true }), + })); + } + } catch { + // Pi not configured or no models available + } finally { + proc?.kill(); + } + } +} + +// --------------------------------------------------------------------------- +// Session (identical to pi-sdk.ts, using PiProcessNode) +// --------------------------------------------------------------------------- + +interface SessionConfig { + systemPrompt: string; + cwd: string; + parentSessionId: string | null; + piExecutablePath: string; + model?: string; +} + +class PiSDKNodeSession extends BaseSession { + private config: SessionConfig; + private process: PiProcessNode | null = null; + + constructor(config: SessionConfig) { + super({ parentSessionId: config.parentSessionId }); + this.config = config; + } + + async *query(prompt: string): AsyncIterable { + const { mapPiEvent } = await import("./pi-events.ts"); + + const started = this.startQuery(); + if (!started) { + yield BaseSession.BUSY_ERROR; + return; + } + const { gen } = started; + + try { + if (!this.process || !this.process.alive) { + this.process = new PiProcessNode(); + await this.process.spawn(this.config.piExecutablePath, this.config.cwd); + + if (this.config.model) { + const [provider, ...rest] = this.config.model.split("/"); + const modelId = rest.join("/"); + if (provider && modelId) { + try { + await this.process.sendAndWait({ type: "set_model", provider, modelId }); + } catch { /* Continue with Pi's default model */ } + } + } + + try { + const state = await this.process.sendAndWait({ type: "get_state" }); + if (typeof state.sessionId === "string") { + this.resolveId(state.sessionId); + } + } catch { /* Continue with placeholder ID */ } + + if (!this.process.alive) { + yield { + type: "error", + error: "Pi process exited during startup. Check that Pi is configured correctly (API keys, models).", + code: "pi_startup_error", + }; + return; + } + } + + const effectivePrompt = buildEffectivePrompt( + prompt, + this.config.systemPrompt, + this._firstQuerySent, + ); + + const queue: AIMessage[] = []; + let resolve: (() => void) | null = null; + let done = false; + + const push = (msg: AIMessage) => { queue.push(msg); resolve?.(); }; + const finish = () => { done = true; resolve?.(); }; + + const unsubscribe = this.process.onEvent((event) => { + const mapped = mapPiEvent(event, this.id); + for (const msg of mapped) { + push(msg); + if ( + msg.type === "result" || + (msg.type === "error" && (event.type === "agent_end" || event.type === "process_exited")) + ) { + finish(); + } + } + }); + + try { + await this.process.sendAndWait({ type: "prompt", message: effectivePrompt }); + } catch (err) { + unsubscribe(); + yield { + type: "error", + error: `Pi rejected prompt: ${err instanceof Error ? err.message : String(err)}`, + code: "pi_prompt_rejected", + }; + return; + } + this._firstQuerySent = true; + + try { + while (!done || queue.length > 0) { + if (queue.length > 0) { + yield queue.shift()!; + } else { + await new Promise((r) => { resolve = r; }); + resolve = null; + } + } + } finally { + unsubscribe(); + } + } catch (err) { + yield { + type: "error", + error: err instanceof Error ? err.message : String(err), + code: "provider_error", + }; + } finally { + this.endQuery(gen); + } + } + + abort(): void { + if (this.process?.alive) { + this.process.send({ type: "abort" }); + } + super.abort(); + } + + killProcess(): void { + this.process?.kill(); + this.process = null; + } +} + +// --------------------------------------------------------------------------- +// Factory registration +// --------------------------------------------------------------------------- + +registerProviderFactory( + PROVIDER_NAME, + async (config) => new PiSDKNodeProvider(config as PiSDKConfig), +); diff --git a/packages/ai/providers/pi-sdk.ts b/packages/ai/providers/pi-sdk.ts index ae9e2273..7cfdc33f 100644 --- a/packages/ai/providers/pi-sdk.ts +++ b/packages/ai/providers/pi-sdk.ts @@ -68,8 +68,8 @@ class PiProcess { } private async readStream(): Promise { - if (!this.proc?.stdout) return; - const reader = this.proc.stdout.getReader(); + if (!this.proc?.stdout || typeof this.proc.stdout === "number") return; + const reader = (this.proc.stdout as ReadableStream).getReader(); const decoder = new TextDecoder(); try { @@ -120,10 +120,11 @@ class PiProcess { /** Send a command without waiting for a response. */ send(command: Record): void { - if (!this.proc?.stdin) return; + if (!this.proc?.stdin || typeof this.proc.stdin === "number") return; // Bun.spawn stdin is a FileSink with .write(), not a WritableStream - this.proc.stdin.write(`${JSON.stringify(command)}\n`); - this.proc.stdin.flush(); + const sink = this.proc.stdin as { write(data: string): void; flush(): void }; + sink.write(`${JSON.stringify(command)}\n`); + sink.flush(); } /** Send a command and wait for the correlated response. */ @@ -422,111 +423,11 @@ class PiSDKSession extends BaseSession { } // --------------------------------------------------------------------------- -// Event mapping +// Event mapping — shared with pi-sdk-node.ts // --------------------------------------------------------------------------- -/** - * Map a Pi AgentEvent (received as JSONL) to AIMessage[]. - * - * Pi event hierarchy: - * agent_start > turn_start > message_start > message_update* > message_end - * > tool_execution_start > tool_execution_end > turn_end > agent_end - * - * We extract: - * - text_delta from message_update.assistantMessageEvent - * - tool_use from toolcall_end - * - tool_result from tool_execution_end - * - result from agent_end - */ -export function mapPiEvent( - event: Record, - sessionId: string, -): AIMessage[] { - const eventType = event.type as string; - - switch (eventType) { - case "message_update": { - const ame = event.assistantMessageEvent as - | Record - | undefined; - if (!ame) return []; - - const subType = ame.type as string; - - switch (subType) { - case "text_delta": - return [{ type: "text_delta", delta: ame.delta as string }]; - - case "toolcall_end": { - const tc = ame.toolCall as Record; - if (!tc) return []; - return [ - { - type: "tool_use", - toolName: tc.name as string, - toolInput: (tc.arguments as Record) ?? {}, - toolUseId: tc.id as string, - }, - ]; - } - - case "error": { - const partial = ame.error as Record | undefined; - const errorMessage = - (partial?.errorMessage as string) ?? "Stream error"; - return [ - { type: "error", error: errorMessage, code: "pi_stream_error" }, - ]; - } - - default: - return []; - } - } - - case "tool_execution_end": { - const result = event.result; - const isError = event.isError as boolean; - const resultStr = - result == null - ? "" - : typeof result === "string" - ? result - : JSON.stringify(result); - - return [ - { - type: "tool_result", - toolUseId: event.toolCallId as string, - result: isError - ? `[Error] ${resultStr || "Tool execution failed"}` - : resultStr, - }, - ]; - } - - case "agent_end": - return [ - { - type: "result", - sessionId, - success: true, - }, - ]; - - case "process_exited": - return [ - { - type: "error", - error: "Pi process exited unexpectedly.", - code: "pi_process_exit", - }, - ]; - - default: - return []; - } -} +import { mapPiEvent } from "./pi-events.ts"; +export { mapPiEvent } from "./pi-events.ts"; // --------------------------------------------------------------------------- // Factory registration diff --git a/packages/ai/tsconfig.json b/packages/ai/tsconfig.json new file mode 100644 index 00000000..6f08d955 --- /dev/null +++ b/packages/ai/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "types": ["bun-types"] + }, + "exclude": ["**/*.test.ts"] +} diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index 3c7a9dcf..90f96671 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -40,6 +40,8 @@ export interface AnnotateServerOptions { sharingEnabled?: boolean; /** Custom base URL for share links */ shareBaseUrl?: string; + /** Base URL of the paste service API for short URL sharing */ + pasteApiUrl?: string; /** Called when server starts with the URL, remote status, and port */ onReady?: (url: string, isRemote: boolean, port: number) => void; } @@ -84,6 +86,7 @@ export async function startAnnotateServer( mode = "annotate", sharingEnabled = true, shareBaseUrl, + pasteApiUrl, onReady, } = options; @@ -126,6 +129,7 @@ export async function startAnnotateServer( filePath, sharingEnabled, shareBaseUrl, + pasteApiUrl, repoInfo, projectRoot: process.cwd(), }); @@ -225,15 +229,16 @@ export async function startAnnotateServer( throw new Error("Failed to start server"); } - const serverUrl = `http://localhost:${server.port}`; + const port = server.port!; + const serverUrl = `http://localhost:${port}`; // Notify caller that server is ready if (onReady) { - onReady(serverUrl, isRemote, server.port); + onReady(serverUrl, isRemote, port); } return { - port: server.port, + port, url: serverUrl, isRemote, waitForDecision: () => decisionPromise, diff --git a/packages/server/index.ts b/packages/server/index.ts index 38d1eeb7..909576d0 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -48,7 +48,7 @@ export { openBrowser } from "./browser"; export * from "./integrations"; export * from "./storage"; export { handleServerReady } from "./shared-handlers"; -export { type VaultNode, buildFileTree } from "./reference-handlers"; +export { type VaultNode, buildFileTree } from "@plannotator/shared/reference-common"; // --- Types --- @@ -529,15 +529,16 @@ export async function startPlannotatorServer( throw new Error("Failed to start server"); } - const serverUrl = `http://localhost:${server.port}`; + const port = server.port!; + const serverUrl = `http://localhost:${port}`; // Notify caller that server is ready if (onReady) { - onReady(serverUrl, isRemote, server.port); + onReady(serverUrl, isRemote, port); } return { - port: server.port, + port, url: serverUrl, isRemote, waitForDecision: () => decisionPromise, diff --git a/packages/server/integrations.ts b/packages/server/integrations.ts index a1f95f50..08c396e6 100644 --- a/packages/server/integrations.ts +++ b/packages/server/integrations.ts @@ -4,369 +4,213 @@ import { $ } from "bun"; import { join } from "path"; -import { mkdirSync, existsSync, statSync, readFileSync } from "fs"; +import { mkdirSync, existsSync, statSync } from "fs"; import { detectProjectName } from "./project"; -// --- Types --- +import { + type ObsidianConfig, + type BearConfig, + type OctarineConfig, + type IntegrationResult, + extractTitle, + generateFrontmatter, + generateFilename, + generateOctarineFrontmatter, + stripH1, + buildHashtags, + buildBearContent, + detectObsidianVaults, +} from "@plannotator/shared/integrations-common"; -export interface ObsidianConfig { - vaultPath: string; - folder: string; - plan: string; - filenameFormat?: string; // Custom format string, e.g. '{YYYY}-{MM}-{DD} - {title}' - filenameSeparator?: 'space' | 'dash' | 'underscore'; // Replace spaces in filename -} - -export interface BearConfig { - plan: string; - customTags?: string; - tagPosition?: 'prepend' | 'append'; -} - -export interface OctarineConfig { - plan: string; - workspace: string; - folder: string; -} - -export interface IntegrationResult { - success: boolean; - error?: string; - path?: string; -} - -// --- Tag Extraction --- +export type { ObsidianConfig, BearConfig, OctarineConfig, IntegrationResult }; +export { detectObsidianVaults, extractTitle, generateFrontmatter, generateFilename, generateOctarineFrontmatter, stripH1, buildHashtags, buildBearContent }; /** * Extract tags from markdown content using simple heuristics * Includes project name detection (git repo or directory name) */ export async function extractTags(markdown: string): Promise { - const tags = new Set(["plannotator"]); + const tags = new Set(["plannotator"]); - // Add project name tag (git repo name or directory fallback) - const projectName = await detectProjectName(); - if (projectName) { - tags.add(projectName); - } + // Add project name tag (git repo name or directory fallback) + const projectName = await detectProjectName(); + if (projectName) { + tags.add(projectName); + } - const stopWords = new Set([ - "the", "and", "for", "with", "this", "that", "from", "into", - "plan", "implementation", "overview", "phase", "step", "steps", - ]); + const stopWords = new Set([ + "the", + "and", + "for", + "with", + "this", + "that", + "from", + "into", + "plan", + "implementation", + "overview", + "phase", + "step", + "steps", + ]); - // Extract from first H1 title - const h1Match = markdown.match(/^#\s+(?:Implementation\s+Plan:|Plan:)?\s*(.+)$/im); - if (h1Match) { - const titleWords = h1Match[1] - .toLowerCase() - .replace(/[^\w\s-]/g, " ") - .split(/\s+/) - .filter((word) => word.length > 2 && !stopWords.has(word)); - titleWords.slice(0, 3).forEach((word) => tags.add(word)); - } + // Extract from first H1 title + const h1Match = markdown.match( + /^#\s+(?:Implementation\s+Plan:|Plan:)?\s*(.+)$/im, + ); + if (h1Match) { + const titleWords = h1Match[1] + .toLowerCase() + .replace(/[^\w\s-]/g, " ") + .split(/\s+/) + .filter((word) => word.length > 2 && !stopWords.has(word)); + titleWords.slice(0, 3).forEach((word) => tags.add(word)); + } - // Extract code fence languages - const langMatches = markdown.matchAll(/```(\w+)/g); - const seenLangs = new Set(); - for (const [, lang] of langMatches) { - const normalizedLang = lang.toLowerCase(); - if ( - !seenLangs.has(normalizedLang) && - !["json", "yaml", "yml", "text", "txt", "markdown", "md"].includes(normalizedLang) - ) { - seenLangs.add(normalizedLang); - tags.add(normalizedLang); - } - } + // Extract code fence languages + const langMatches = markdown.matchAll(/```(\w+)/g); + const seenLangs = new Set(); + for (const [, lang] of langMatches) { + const normalizedLang = lang.toLowerCase(); + if ( + !seenLangs.has(normalizedLang) && + !["json", "yaml", "yml", "text", "txt", "markdown", "md"].includes( + normalizedLang, + ) + ) { + seenLangs.add(normalizedLang); + tags.add(normalizedLang); + } + } - return Array.from(tags).slice(0, 7); -} - -// --- Frontmatter and Filename Generation --- - -/** - * Generate frontmatter for the note - */ -export function generateFrontmatter(tags: string[]): string { - const now = new Date().toISOString(); - const tagList = tags.map((t) => t.toLowerCase()).join(", "); - return `--- -created: ${now} -source: plannotator -tags: [${tagList}] ----`; -} - -/** - * Extract title from markdown (first H1 heading) - */ -export function extractTitle(markdown: string): string { - const h1Match = markdown.match(/^#\s+(?:Implementation\s+Plan:|Plan:)?\s*(.+)$/im); - if (h1Match) { - // Clean up the title for use as filename - return h1Match[1] - .trim() - .replace(/[<>:"/\\|?*(){}\[\]#~`]/g, '') // Remove invalid/problematic filename chars - .replace(/\s+/g, ' ') // Normalize whitespace - .trim() // Re-trim after stripping - .slice(0, 50); // Limit length - } - return 'Plan'; -} - -/** Default filename format matching original behavior */ -export const DEFAULT_FILENAME_FORMAT = '{title} - {Mon} {D}, {YYYY} {h}-{mm}{ampm}'; - -/** - * Generate filename from a format string with variable substitution. - * - * Supported variables: - * {title} - Plan title from first H1 heading - * {YYYY} - 4-digit year - * {MM} - 2-digit month (01-12) - * {DD} - 2-digit day (01-31) - * {Mon} - Abbreviated month name (Jan, Feb, ...) - * {D} - Day without leading zero - * {HH} - 2-digit hour, 24h (00-23) - * {h} - Hour without leading zero, 12h - * {hh} - 2-digit hour, 12h (01-12) - * {mm} - 2-digit minutes (00-59) - * {ss} - 2-digit seconds (00-59) - * {ampm} - am/pm - * - * Default format: '{title} - {Mon} {D}, {YYYY} {h}-{mm}{ampm}' - * Example output: 'User Authentication - Jan 2, 2026 2-30pm.md' - */ -export function generateFilename(markdown: string, format?: string, separator?: 'space' | 'dash' | 'underscore'): string { - const title = extractTitle(markdown); - const now = new Date(); - - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - - const hour24 = now.getHours(); - const hour12 = hour24 % 12 || 12; - const ampm = hour24 >= 12 ? 'pm' : 'am'; - - const vars: Record = { - title, - YYYY: String(now.getFullYear()), - MM: String(now.getMonth() + 1).padStart(2, '0'), - DD: String(now.getDate()).padStart(2, '0'), - Mon: months[now.getMonth()], - D: String(now.getDate()), - HH: String(hour24).padStart(2, '0'), - h: String(hour12), - hh: String(hour12).padStart(2, '0'), - mm: String(now.getMinutes()).padStart(2, '0'), - ss: String(now.getSeconds()).padStart(2, '0'), - ampm, - }; - - const template = format?.trim() || DEFAULT_FILENAME_FORMAT; - const result = template.replace(/\{(\w+)\}/g, (match, key) => vars[key] ?? match); - - // Sanitize: remove characters invalid in filenames - let sanitized = result.replace(/[<>:"/\\|?*]/g, '').replace(/\s+/g, ' ').trim(); - - // Apply separator preference (replace spaces with dash or underscore) - if (separator === 'dash') { - sanitized = sanitized.replace(/ /g, '-'); - } else if (separator === 'underscore') { - sanitized = sanitized.replace(/ /g, '_'); - } - - return sanitized.endsWith('.md') ? sanitized : `${sanitized}.md`; + return Array.from(tags).slice(0, 7); } // --- Obsidian Integration --- -/** - * Detect Obsidian vaults by reading Obsidian's config file - * Returns array of vault paths found on the system - */ -export function detectObsidianVaults(): string[] { - try { - const home = process.env.HOME || process.env.USERPROFILE || ""; - let configPath: string; - - // Platform-specific config locations - if (process.platform === "darwin") { - configPath = join(home, "Library/Application Support/obsidian/obsidian.json"); - } else if (process.platform === "win32") { - const appData = process.env.APPDATA || join(home, "AppData/Roaming"); - configPath = join(appData, "obsidian/obsidian.json"); - } else { - // Linux - configPath = join(home, ".config/obsidian/obsidian.json"); - } - - if (!existsSync(configPath)) { - return []; - } - - const configContent = readFileSync(configPath, "utf-8"); - const config = JSON.parse(configContent); - - if (!config.vaults || typeof config.vaults !== "object") { - return []; - } - - // Extract vault paths, filter to ones that exist - const vaults: string[] = []; - for (const vaultId of Object.keys(config.vaults)) { - const vault = config.vaults[vaultId]; - if (vault.path && existsSync(vault.path)) { - vaults.push(vault.path); - } - } - - return vaults; - } catch { - return []; - } -} - /** * Save plan to Obsidian vault with cross-platform path handling */ -export async function saveToObsidian(config: ObsidianConfig): Promise { - try { - const { vaultPath, folder, plan } = config; +export async function saveToObsidian( + config: ObsidianConfig, +): Promise { + try { + const { vaultPath, folder, plan } = config; - // Normalize path (handle ~ on Unix, forward/back slashes) - let normalizedVault = vaultPath.trim(); + // Normalize path (handle ~ on Unix, forward/back slashes) + let normalizedVault = vaultPath.trim(); - // Expand ~ to home directory (Unix/macOS) - if (normalizedVault.startsWith("~")) { - const home = process.env.HOME || process.env.USERPROFILE || ""; - normalizedVault = join(home, normalizedVault.slice(1)); - } + // Expand ~ to home directory (Unix/macOS) + if (normalizedVault.startsWith("~")) { + const home = process.env.HOME || process.env.USERPROFILE || ""; + normalizedVault = join(home, normalizedVault.slice(1)); + } - // Validate vault path exists and is a directory - if (!existsSync(normalizedVault)) { - return { success: false, error: `Vault path does not exist: ${normalizedVault}` }; - } + // Validate vault path exists and is a directory + if (!existsSync(normalizedVault)) { + return { + success: false, + error: `Vault path does not exist: ${normalizedVault}`, + }; + } - const vaultStat = statSync(normalizedVault); - if (!vaultStat.isDirectory()) { - return { success: false, error: `Vault path is not a directory: ${normalizedVault}` }; - } + const vaultStat = statSync(normalizedVault); + if (!vaultStat.isDirectory()) { + return { + success: false, + error: `Vault path is not a directory: ${normalizedVault}`, + }; + } - // Build target folder path - const folderName = folder.trim() || "plannotator"; - const targetFolder = join(normalizedVault, folderName); + // Build target folder path + const folderName = folder.trim() || "plannotator"; + const targetFolder = join(normalizedVault, folderName); - // Create folder if it doesn't exist (guard for Bun mkdirSync regression) - if (!existsSync(targetFolder)) { - mkdirSync(targetFolder, { recursive: true }); - } + // Create folder if it doesn't exist (guard for Bun mkdirSync regression) + if (!existsSync(targetFolder)) { + mkdirSync(targetFolder, { recursive: true }); + } - // Generate filename and full path - const filename = generateFilename(plan, config.filenameFormat, config.filenameSeparator); - const filePath = join(targetFolder, filename); + // Generate filename and full path + const filename = generateFilename( + plan, + config.filenameFormat, + config.filenameSeparator, + ); + const filePath = join(targetFolder, filename); - // Generate content with frontmatter and backlink - const tags = await extractTags(plan); - const frontmatter = generateFrontmatter(tags); - const content = `${frontmatter}\n\n[[Plannotator Plans]]\n\n${plan}`; + // Generate content with frontmatter and backlink + const tags = await extractTags(plan); + const frontmatter = generateFrontmatter(tags); + const content = `${frontmatter}\n\n[[Plannotator Plans]]\n\n${plan}`; - // Write file - await Bun.write(filePath, content); + // Write file + await Bun.write(filePath, content); - return { success: true, path: filePath }; - } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - return { success: false, error: message }; - } -} - -// --- Bear Integration --- - -export function stripH1(plan: string): string { - return plan.replace(/^#\s+.+\n?/m, '').trimStart(); -} - -export function buildHashtags(customTags: string | undefined, autoTags: string[]): string { - if (customTags?.trim()) { - return customTags.split(',').map(t => `#${t.trim()}`).filter(t => t !== '#').join(' '); - } - return autoTags.map(t => `#${t}`).join(' '); -} - -export function buildBearContent(body: string, hashtags: string, tagPosition: 'prepend' | 'append'): string { - return tagPosition === 'prepend' - ? `${hashtags}\n\n${body}` - : `${body}\n\n${hashtags}`; + return { success: true, path: filePath }; + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return { success: false, error: message }; + } } /** * Save plan to Bear using x-callback-url */ -export async function saveToBear(config: BearConfig): Promise { - try { - const { plan, customTags, tagPosition = 'append' } = config; +export async function saveToBear( + config: BearConfig, +): Promise { + try { + const { plan, customTags, tagPosition = "append" } = config; - const title = extractTitle(plan); - const body = stripH1(plan); + const title = extractTitle(plan); + const body = stripH1(plan); - const tags = customTags?.trim() - ? undefined - : await extractTags(plan); - const hashtags = buildHashtags(customTags, tags ?? []); + const tags = customTags?.trim() ? undefined : await extractTags(plan); + const hashtags = buildHashtags(customTags, tags ?? []); - const content = buildBearContent(body, hashtags, tagPosition); + const content = buildBearContent(body, hashtags, tagPosition); - const url = `bear://x-callback-url/create?title=${encodeURIComponent(title)}&text=${encodeURIComponent(content)}&open_note=no`; + const url = `bear://x-callback-url/create?title=${encodeURIComponent(title)}&text=${encodeURIComponent(content)}&open_note=no`; - await $`open ${url}`.quiet(); + await $`open ${url}`.quiet(); - return { success: true }; - } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - return { success: false, error: message }; - } -} - -// --- Octarine Integration --- - -/** - * Generate YAML frontmatter for an Octarine note. - * Uses Octarine's property format (list-style tags, Status, Author, Last Edited). - */ -export function generateOctarineFrontmatter(tags: string[]): string { - const now = new Date().toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM - const tagLines = tags.map(t => ` - ${t.toLowerCase()}`).join('\n'); - return `---\ntags:\n${tagLines}\nStatus: Draft\nAuthor: plannotator\nLast Edited: ${now}\n---`; + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return { success: false, error: message }; + } } /** * Save plan to Octarine using octarine:// URI scheme */ -export async function saveToOctarine(config: OctarineConfig): Promise { - try { - const { plan } = config; - const workspace = config.workspace.trim(); - if (!workspace) return { success: false, error: "Workspace is required" }; - const folder = config.folder.trim() || 'plannotator'; +export async function saveToOctarine( + config: OctarineConfig, +): Promise { + try { + const { plan } = config; + const workspace = config.workspace.trim(); + if (!workspace) return { success: false, error: "Workspace is required" }; + const folder = config.folder.trim() || "plannotator"; - const filename = generateFilename(plan); - // Strip .md — Octarine auto-adds it - const basename = filename.replace(/\.md$/, ''); - const path = folder ? `${folder}/${basename}` : basename; + const filename = generateFilename(plan); + // Strip .md — Octarine auto-adds it + const basename = filename.replace(/\.md$/, ""); + const path = folder ? `${folder}/${basename}` : basename; - const tags = await extractTags(plan); - const frontmatter = generateOctarineFrontmatter(tags); - const content = `${frontmatter}\n\n${plan}`; + const tags = await extractTags(plan); + const frontmatter = generateOctarineFrontmatter(tags); + const content = `${frontmatter}\n\n${plan}`; - const url = `octarine://create?path=${encodeURIComponent(path)}&content=${encodeURIComponent(content)}&workspace=${encodeURIComponent(workspace)}&fresh=true&openAfter=false`; + const url = `octarine://create?path=${encodeURIComponent(path)}&content=${encodeURIComponent(content)}&workspace=${encodeURIComponent(workspace)}&fresh=true&openAfter=false`; - await $`open ${url}`.quiet(); + await $`open ${url}`.quiet(); - return { success: true, path }; - } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - return { success: false, error: message }; - } + return { success: true, path }; + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return { success: false, error: message }; + } } diff --git a/packages/server/package.json b/packages/server/package.json index b5a7f61b..156c1a25 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -15,7 +15,6 @@ "./git": "./git.ts", "./repo": "./repo.ts", "./share-url": "./share-url.ts", - "./resolve-file": "./resolve-file.ts", "./sessions": "./sessions.ts", "./project": "./project.ts", "./pr": "./pr.ts" diff --git a/packages/server/pr.ts b/packages/server/pr.ts index ba7780a2..9b29b0b8 100644 --- a/packages/server/pr.ts +++ b/packages/server/pr.ts @@ -68,11 +68,11 @@ const runtime: PRRuntime = { export const parsePRUrl = parsePRUrlCore; -export function checkAuth(ref: PRRef): Promise { +export function checkPRAuth(ref: PRRef): Promise { return checkAuthCore(runtime, ref); } -export function getUser(ref: PRRef): Promise { +export function getPRUser(ref: PRRef): Promise { return getUserCore(runtime, ref); } diff --git a/packages/server/reference-handlers.ts b/packages/server/reference-handlers.ts index 41191fe4..d5264237 100644 --- a/packages/server/reference-handlers.ts +++ b/packages/server/reference-handlers.ts @@ -7,228 +7,219 @@ import { existsSync, statSync } from "fs"; import { resolve } from "path"; +import { buildFileTree, FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; import { detectObsidianVaults } from "./integrations"; -import { resolveMarkdownFile } from "./resolve-file"; - -// --- Vault file tree helpers --- - -export interface VaultNode { - name: string; - path: string; // relative path within vault - type: "file" | "folder"; - children?: VaultNode[]; -} - -/** - * Build a nested file tree from a sorted list of relative paths. - * Folders are sorted before files at each level. - */ -export function buildFileTree(relativePaths: string[]): VaultNode[] { - const root: VaultNode[] = []; - - for (const filePath of relativePaths) { - const parts = filePath.split("/"); - let current = root; - let pathSoFar = ""; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - pathSoFar = pathSoFar ? `${pathSoFar}/${part}` : part; - const isFile = i === parts.length - 1; - - let node = current.find((n) => n.name === part && n.type === (isFile ? "file" : "folder")); - if (!node) { - node = { name: part, path: pathSoFar, type: isFile ? "file" : "folder" }; - if (!isFile) node.children = []; - current.push(node); - } - if (!isFile) { - current = node.children!; - } - } - } - - // Sort: folders first (alphabetical), then files (alphabetical) - const sortNodes = (nodes: VaultNode[]) => { - nodes.sort((a, b) => { - if (a.type !== b.type) return a.type === "folder" ? -1 : 1; - return a.name.localeCompare(b.name); - }); - for (const node of nodes) { - if (node.children) sortNodes(node.children); - } - }; - sortNodes(root); - - return root; -} +import { resolveMarkdownFile } from "@plannotator/shared/resolve-file"; // --- Route handlers --- /** Serve a linked markdown document. Resolves absolute, relative, or bare filename paths. */ export async function handleDoc(req: Request): Promise { - const url = new URL(req.url); - const requestedPath = url.searchParams.get("path"); - if (!requestedPath) { - return Response.json({ error: "Missing path parameter" }, { status: 400 }); - } + const url = new URL(req.url); + const requestedPath = url.searchParams.get("path"); + if (!requestedPath) { + return Response.json({ error: "Missing path parameter" }, { status: 400 }); + } - // If a base directory is provided, try resolving relative to it first - // (used by annotate mode to resolve paths relative to the source file) - const base = url.searchParams.get("base"); - if (base && !requestedPath.startsWith("/") && /\.mdx?$/i.test(requestedPath)) { - const fromBase = resolve(base, requestedPath); - try { - const file = Bun.file(fromBase); - if (await file.exists()) { - const markdown = await file.text(); - return Response.json({ markdown, filepath: fromBase }); - } - } catch { /* fall through to standard resolution */ } - } + // If a base directory is provided, try resolving relative to it first + // (used by annotate mode to resolve paths relative to the source file) + const base = url.searchParams.get("base"); + if ( + base && + !requestedPath.startsWith("/") && + /\.mdx?$/i.test(requestedPath) + ) { + const fromBase = resolve(base, requestedPath); + try { + const file = Bun.file(fromBase); + if (await file.exists()) { + const markdown = await file.text(); + return Response.json({ markdown, filepath: fromBase }); + } + } catch { + /* fall through to standard resolution */ + } + } - const projectRoot = process.cwd(); - const result = await resolveMarkdownFile(requestedPath, projectRoot); + const projectRoot = process.cwd(); + const result = resolveMarkdownFile(requestedPath, projectRoot); - if (result.kind === "ambiguous") { - return Response.json( - { error: `Ambiguous filename '${result.input}': found ${result.matches.length} matches`, matches: result.matches }, - { status: 400 }, - ); - } + if (result.kind === "ambiguous") { + return Response.json( + { + error: `Ambiguous filename '${result.input}': found ${result.matches.length} matches`, + matches: result.matches, + }, + { status: 400 }, + ); + } - if (result.kind === "not_found") { - return Response.json({ error: `File not found: ${result.input}` }, { status: 404 }); - } + if (result.kind === "not_found") { + return Response.json( + { error: `File not found: ${result.input}` }, + { status: 404 }, + ); + } - try { - const markdown = await Bun.file(result.path).text(); - return Response.json({ markdown, filepath: result.path }); - } catch { - return Response.json({ error: "Failed to read file" }, { status: 500 }); - } + try { + const markdown = await Bun.file(result.path).text(); + return Response.json({ markdown, filepath: result.path }); + } catch { + return Response.json({ error: "Failed to read file" }, { status: 500 }); + } } /** Detect available Obsidian vaults. */ export function handleObsidianVaults(): Response { - const vaults = detectObsidianVaults(); - return Response.json({ vaults }); + const vaults = detectObsidianVaults(); + return Response.json({ vaults }); } /** List Obsidian vault files as a nested tree. */ export async function handleObsidianFiles(req: Request): Promise { - const url = new URL(req.url); - const vaultPath = url.searchParams.get("vaultPath"); - if (!vaultPath) { - return Response.json({ error: "Missing vaultPath parameter" }, { status: 400 }); - } + const url = new URL(req.url); + const vaultPath = url.searchParams.get("vaultPath"); + if (!vaultPath) { + return Response.json( + { error: "Missing vaultPath parameter" }, + { status: 400 }, + ); + } - const resolvedVault = resolve(vaultPath); - if (!existsSync(resolvedVault) || !statSync(resolvedVault).isDirectory()) { - return Response.json({ error: "Invalid vault path" }, { status: 400 }); - } + const resolvedVault = resolve(vaultPath); + if (!existsSync(resolvedVault) || !statSync(resolvedVault).isDirectory()) { + return Response.json({ error: "Invalid vault path" }, { status: 400 }); + } - try { - const glob = new Bun.Glob("**/*.md"); - const files: string[] = []; - for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) { - if (match.includes(".obsidian/") || match.includes(".trash/")) continue; - files.push(match); - } - files.sort(); + try { + const glob = new Bun.Glob("**/*.md"); + const files: string[] = []; + for await (const match of glob.scan({ + cwd: resolvedVault, + onlyFiles: true, + })) { + if (match.includes(".obsidian/") || match.includes(".trash/")) continue; + files.push(match); + } + files.sort(); - const tree = buildFileTree(files); - return Response.json({ tree }); - } catch { - return Response.json({ error: "Failed to list vault files" }, { status: 500 }); - } + const tree = buildFileTree(files); + return Response.json({ tree }); + } catch { + return Response.json( + { error: "Failed to list vault files" }, + { status: 500 }, + ); + } } /** Read an Obsidian vault document. Supports direct path and bare filename search. */ export async function handleObsidianDoc(req: Request): Promise { - const url = new URL(req.url); - const vaultPath = url.searchParams.get("vaultPath"); - const filePath = url.searchParams.get("path"); - if (!vaultPath || !filePath) { - return Response.json({ error: "Missing vaultPath or path parameter" }, { status: 400 }); - } - if (!/\.mdx?$/i.test(filePath)) { - return Response.json({ error: "Only markdown files are supported" }, { status: 400 }); - } + const url = new URL(req.url); + const vaultPath = url.searchParams.get("vaultPath"); + const filePath = url.searchParams.get("path"); + if (!vaultPath || !filePath) { + return Response.json( + { error: "Missing vaultPath or path parameter" }, + { status: 400 }, + ); + } + if (!/\.mdx?$/i.test(filePath)) { + return Response.json( + { error: "Only markdown files are supported" }, + { status: 400 }, + ); + } - const resolvedVault = resolve(vaultPath); - let resolvedFile = resolve(resolvedVault, filePath); + const resolvedVault = resolve(vaultPath); + let resolvedFile = resolve(resolvedVault, filePath); - // If direct path doesn't exist and it's a bare filename, search the vault - if (!existsSync(resolvedFile) && !filePath.includes("/")) { - const glob = new Bun.Glob(`**/${filePath}`); - const matches: string[] = []; - for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) { - if (match.includes(".obsidian/") || match.includes(".trash/")) continue; - matches.push(resolve(resolvedVault, match)); - } - if (matches.length === 1) { - resolvedFile = matches[0]; - } else if (matches.length > 1) { - const relativePaths = matches.map((m) => m.replace(resolvedVault + "/", "")); - return Response.json( - { error: `Ambiguous filename '${filePath}': found ${matches.length} matches`, matches: relativePaths }, - { status: 400 }, - ); - } - } + // If direct path doesn't exist and it's a bare filename, search the vault + if (!existsSync(resolvedFile) && !filePath.includes("/")) { + const glob = new Bun.Glob(`**/${filePath}`); + const matches: string[] = []; + for await (const match of glob.scan({ + cwd: resolvedVault, + onlyFiles: true, + })) { + if (match.includes(".obsidian/") || match.includes(".trash/")) continue; + matches.push(resolve(resolvedVault, match)); + } + if (matches.length === 1) { + resolvedFile = matches[0]; + } else if (matches.length > 1) { + const relativePaths = matches.map((m) => + m.replace(resolvedVault + "/", ""), + ); + return Response.json( + { + error: `Ambiguous filename '${filePath}': found ${matches.length} matches`, + matches: relativePaths, + }, + { status: 400 }, + ); + } + } - // Security: must be within vault - if (!resolvedFile.startsWith(resolvedVault + "/")) { - return Response.json({ error: "Access denied: path is outside vault" }, { status: 403 }); - } + // Security: must be within vault + if (!resolvedFile.startsWith(resolvedVault + "/")) { + return Response.json( + { error: "Access denied: path is outside vault" }, + { status: 403 }, + ); + } - try { - const file = Bun.file(resolvedFile); - if (!(await file.exists())) { - return Response.json({ error: `File not found: ${filePath}` }, { status: 404 }); - } - const markdown = await file.text(); - return Response.json({ markdown, filepath: resolvedFile }); - } catch { - return Response.json({ error: "Failed to read file" }, { status: 500 }); - } + try { + const file = Bun.file(resolvedFile); + if (!(await file.exists())) { + return Response.json( + { error: `File not found: ${filePath}` }, + { status: 404 }, + ); + } + const markdown = await file.text(); + return Response.json({ markdown, filepath: resolvedFile }); + } catch { + return Response.json({ error: "Failed to read file" }, { status: 500 }); + } } // --- File Browser --- -const FILE_BROWSER_EXCLUDED = [ - "node_modules/", ".git/", "dist/", "build/", ".next/", - "__pycache__/", ".obsidian/", ".trash/", ".venv/", "vendor/", - "target/", ".cache/", "coverage/", ".turbo/", ".svelte-kit/", - ".nuxt/", ".output/", ".parcel-cache/", ".webpack/", ".expo/", -]; - /** List markdown files in a directory as a nested tree. */ export async function handleFileBrowserFiles(req: Request): Promise { - const url = new URL(req.url); - const dirPath = url.searchParams.get("dirPath"); - if (!dirPath) { - return Response.json({ error: "Missing dirPath parameter" }, { status: 400 }); - } + const url = new URL(req.url); + const dirPath = url.searchParams.get("dirPath"); + if (!dirPath) { + return Response.json( + { error: "Missing dirPath parameter" }, + { status: 400 }, + ); + } - const resolvedDir = resolve(dirPath); - if (!existsSync(resolvedDir) || !statSync(resolvedDir).isDirectory()) { - return Response.json({ error: "Invalid directory path" }, { status: 400 }); - } + const resolvedDir = resolve(dirPath); + if (!existsSync(resolvedDir) || !statSync(resolvedDir).isDirectory()) { + return Response.json({ error: "Invalid directory path" }, { status: 400 }); + } - try { - const glob = new Bun.Glob("**/*.md"); - const files: string[] = []; - for await (const match of glob.scan({ cwd: resolvedDir, onlyFiles: true })) { - if (FILE_BROWSER_EXCLUDED.some((dir) => match.includes(dir))) continue; - files.push(match); - } - files.sort(); + try { + const glob = new Bun.Glob("**/*.md"); + const files: string[] = []; + for await (const match of glob.scan({ + cwd: resolvedDir, + onlyFiles: true, + })) { + if (FILE_BROWSER_EXCLUDED.some((dir) => match.includes(dir))) continue; + files.push(match); + } + files.sort(); - const tree = buildFileTree(files); - return Response.json({ tree }); - } catch { - return Response.json({ error: "Failed to list directory files" }, { status: 500 }); - } + const tree = buildFileTree(files); + return Response.json({ tree }); + } catch { + return Response.json( + { error: "Failed to list directory files" }, + { status: 500 }, + ); + } } diff --git a/packages/server/repo.ts b/packages/server/repo.ts index 88ec876a..780be964 100644 --- a/packages/server/repo.ts +++ b/packages/server/repo.ts @@ -7,59 +7,23 @@ import { $ } from "bun"; -export interface RepoInfo { - /** Display string (e.g., "backnotprop/plannotator" or "my-project") */ - display: string; - /** Current git branch (if in a git repo) */ - branch?: string; -} - -/** - * Parse org/repo from a git remote URL - * - * Handles: - * - SSH: git@github.com:org/repo.git - * - HTTPS: https://github.com/org/repo.git - * - SSH with port: ssh://git@github.com:22/org/repo.git - */ -function parseRemoteUrl(url: string): string | null { - if (!url) return null; - - // SSH format: git@github.com:org/repo.git - const sshMatch = url.match(/:([^/]+\/[^/]+?)(?:\.git)?$/); - if (sshMatch) return sshMatch[1]; - - // HTTPS format: https://github.com/org/repo.git - const httpsMatch = url.match(/\/([^/]+\/[^/]+?)(?:\.git)?$/); - if (httpsMatch) return httpsMatch[1]; - - return null; -} - -/** - * Get directory name from path - */ -function getDirName(path: string): string | null { - if (!path) return null; - const trimmed = path.trim().replace(/\/+$/, ""); - const parts = trimmed.split("/"); - return parts[parts.length - 1] || null; -} +import type { RepoInfo } from "@plannotator/shared/repo"; +import { parseRemoteUrl, getDirName } from "@plannotator/shared/repo"; /** * Get current git branch */ async function getCurrentBranch(): Promise { - try { - const result = await $`git rev-parse --abbrev-ref HEAD`.quiet().nothrow(); - if (result.exitCode === 0) { - const branch = result.stdout.toString().trim(); - return branch && branch !== "HEAD" ? branch : undefined; - } - } catch { - // Not in a git repo - } - return undefined; + try { + const result = await $`git rev-parse --abbrev-ref HEAD`.quiet().nothrow(); + if (result.exitCode === 0) { + const branch = result.stdout.toString().trim(); + return branch && branch !== "HEAD" ? branch : undefined; + } + } catch { + // Not in a git repo + } + return undefined; } /** @@ -70,45 +34,45 @@ async function getCurrentBranch(): Promise { * 3. Fall back to current working directory name */ export async function getRepoInfo(): Promise { - let branch: string | undefined; + let branch: string | undefined; - // Try git remote URL first - try { - const result = await $`git remote get-url origin`.quiet().nothrow(); - if (result.exitCode === 0) { - const orgRepo = parseRemoteUrl(result.stdout.toString().trim()); - if (orgRepo) { - branch = await getCurrentBranch(); - return { display: orgRepo, branch }; - } - } - } catch { - // Git not available - } + // Try git remote URL first + try { + const result = await $`git remote get-url origin`.quiet().nothrow(); + if (result.exitCode === 0) { + const orgRepo = parseRemoteUrl(result.stdout.toString().trim()); + if (orgRepo) { + branch = await getCurrentBranch(); + return { display: orgRepo, branch }; + } + } + } catch { + // Git not available + } - // Fallback: git repo root name - try { - const result = await $`git rev-parse --show-toplevel`.quiet().nothrow(); - if (result.exitCode === 0) { - const repoName = getDirName(result.stdout.toString()); - if (repoName) { - branch = await getCurrentBranch(); - return { display: repoName, branch }; - } - } - } catch { - // Not in a git repo - } + // Fallback: git repo root name + try { + const result = await $`git rev-parse --show-toplevel`.quiet().nothrow(); + if (result.exitCode === 0) { + const repoName = getDirName(result.stdout.toString()); + if (repoName) { + branch = await getCurrentBranch(); + return { display: repoName, branch }; + } + } + } catch { + // Not in a git repo + } - // Final fallback: current directory (no branch - not a git repo) - try { - const dirName = getDirName(process.cwd()); - if (dirName) { - return { display: dirName }; - } - } catch { - // Shouldn't happen - } + // Final fallback: current directory (no branch - not a git repo) + try { + const dirName = getDirName(process.cwd()); + if (dirName) { + return { display: dirName }; + } + } catch { + // Shouldn't happen + } - return null; + return null; } diff --git a/packages/server/resolve-file.test.ts b/packages/server/resolve-file.test.ts index 0cbced96..bab3a750 100644 --- a/packages/server/resolve-file.test.ts +++ b/packages/server/resolve-file.test.ts @@ -12,7 +12,7 @@ import { isAbsoluteMarkdownPath, normalizeMarkdownPathInput, resolveMarkdownFile, -} from "./resolve-file"; +} from "@plannotator/shared/resolve-file"; const tempDirs: string[] = []; @@ -76,13 +76,13 @@ describe("resolveMarkdownFile", () => { test("resolves absolute path to existing file", async () => { const root = createTempProject({ "plan.md": "# Plan" }); const absPath = resolve(root, "plan.md"); - const result = await resolveMarkdownFile(absPath, root); + const result = resolveMarkdownFile(absPath, root); expect(result).toEqual({ kind: "found", path: absPath }); }); test("returns not_found for absolute path that doesn't exist", async () => { const root = createTempProject(); - const result = await resolveMarkdownFile("/nonexistent/path.md", root); + const result = resolveMarkdownFile("/nonexistent/path.md", root); expect(result.kind).toBe("not_found"); }); @@ -90,7 +90,7 @@ describe("resolveMarkdownFile", () => { test("resolves exact relative path", async () => { const root = createTempProject({ "docs/guide.md": "# Guide" }); - const result = await resolveMarkdownFile("docs/guide.md", root); + const result = resolveMarkdownFile("docs/guide.md", root); expect(result).toEqual({ kind: "found", path: resolve(root, "docs/guide.md"), @@ -99,7 +99,7 @@ describe("resolveMarkdownFile", () => { test("resolves bare filename in root", async () => { const root = createTempProject({ "README.md": "# Hello" }); - const result = await resolveMarkdownFile("README.md", root); + const result = resolveMarkdownFile("README.md", root); expect(result).toEqual({ kind: "found", path: resolve(root, "README.md"), @@ -108,7 +108,7 @@ describe("resolveMarkdownFile", () => { test("resolves relative paths with Windows separators", async () => { const root = createTempProject({ "docs/test-plan.md": "# Test plan\n" }); - const result = await resolveMarkdownFile("docs\\test-plan.md", root); + const result = resolveMarkdownFile("docs\\test-plan.md", root); expect(result).toEqual({ kind: "found", path: resolve(root, "docs/test-plan.md"), @@ -119,7 +119,7 @@ describe("resolveMarkdownFile", () => { test("finds bare filenames case-insensitively", async () => { const root = createTempProject({ "notes/Architecture.MD": "# Architecture\n" }); - const result = await resolveMarkdownFile("architecture.md", root); + const result = resolveMarkdownFile("architecture.md", root); expect(result).toEqual({ kind: "found", path: resolve(root, "notes/Architecture.MD"), @@ -128,7 +128,7 @@ describe("resolveMarkdownFile", () => { test("finds relative paths case-insensitively", async () => { const root = createTempProject({ "Docs/Specs/Design.MDX": "# Design\n" }); - const result = await resolveMarkdownFile("docs/specs/design.mdx", root); + const result = resolveMarkdownFile("docs/specs/design.mdx", root); expect(result.kind).toBe("found"); if (result.kind === "found") { expect(await Bun.file(result.path).text()).toBe("# Design\n"); @@ -140,7 +140,7 @@ describe("resolveMarkdownFile", () => { "docs/plan.md": "# Plan 1", "api/plan.md": "# Plan 2", }); - const result = await resolveMarkdownFile("plan.md", root); + const result = resolveMarkdownFile("plan.md", root); expect(result.kind).toBe("ambiguous"); if (result.kind === "ambiguous") { expect(result.matches).toHaveLength(2); @@ -153,7 +153,7 @@ describe("resolveMarkdownFile", () => { const root = createTempProject({ "node_modules/pkg/README.md": "# Pkg", }); - const result = await resolveMarkdownFile("readme.md", root); + const result = resolveMarkdownFile("readme.md", root); expect(result.kind).toBe("not_found"); }); @@ -161,7 +161,7 @@ describe("resolveMarkdownFile", () => { const root = createTempProject({ ".git/hooks/pre-commit.md": "# hook", }); - const result = await resolveMarkdownFile("pre-commit.md", root); + const result = resolveMarkdownFile("pre-commit.md", root); expect(result.kind).toBe("not_found"); }); @@ -169,13 +169,13 @@ describe("resolveMarkdownFile", () => { test("rejects non-markdown files", async () => { const root = createTempProject({ "script.ts": "export {}" }); - const result = await resolveMarkdownFile("script.ts", root); + const result = resolveMarkdownFile("script.ts", root); expect(result.kind).toBe("not_found"); }); test("accepts .mdx files", async () => { const root = createTempProject({ "page.mdx": "# Page" }); - const result = await resolveMarkdownFile("page.mdx", root); + const result = resolveMarkdownFile("page.mdx", root); expect(result).toEqual({ kind: "found", path: resolve(root, "page.mdx"), @@ -186,7 +186,7 @@ describe("resolveMarkdownFile", () => { test("returns not_found for nonexistent file", async () => { const root = createTempProject(); - const result = await resolveMarkdownFile("nope.md", root); + const result = resolveMarkdownFile("nope.md", root); expect(result.kind).toBe("not_found"); }); @@ -194,7 +194,7 @@ describe("resolveMarkdownFile", () => { const root = createTempProject({ "a/b/c/d/deep.md": "# Deep", }); - const result = await resolveMarkdownFile("deep.md", root); + const result = resolveMarkdownFile("deep.md", root); expect(result).toEqual({ kind: "found", path: resolve(root, "a/b/c/d/deep.md"), diff --git a/packages/server/resolve-file.ts b/packages/server/resolve-file.ts deleted file mode 100644 index c1aa1cb6..00000000 --- a/packages/server/resolve-file.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * Smart markdown file resolution. - * - * Resolves a user-provided path to an absolute file path using three strategies: - * 1. Exact path (absolute or relative to cwd) - * 2. Case-insensitive relative path search within project root - * 3. Case-insensitive bare filename search within project root - * - * Used by both the CLI (`plannotator annotate`) and the `/api/doc` endpoint. - */ - -import { isAbsolute, resolve, win32 } from "path"; -import { existsSync } from "fs"; - -const MARKDOWN_PATH_REGEX = /\.mdx?$/i; -const WINDOWS_DRIVE_PATH_PATTERNS = [ - /^\/cygdrive\/([a-zA-Z])\/(.+)$/, - /^\/([a-zA-Z])\/(.+)$/, -]; - -const IGNORED_DIRS = [ - "node_modules/", - ".git/", - "dist/", - "build/", - ".next/", - "__pycache__/", - ".obsidian/", - ".trash/", -]; - -export type ResolveResult = - | { kind: "found"; path: string } - | { kind: "not_found"; input: string } - | { kind: "ambiguous"; input: string; matches: string[] }; - -function normalizeSeparators(input: string): string { - return input.replace(/\\/g, "/"); -} - -function stripTrailingSlashes(input: string): string { - return input.replace(/\/+$/, ""); -} - -function normalizeComparablePath(input: string): string { - return stripTrailingSlashes(normalizeSeparators(resolve(input))); -} - -function isWithinProjectRoot(candidate: string, projectRoot: string): boolean { - const normalizedCandidate = normalizeComparablePath(candidate); - const normalizedProjectRoot = normalizeComparablePath(projectRoot); - return ( - normalizedCandidate === normalizedProjectRoot || - normalizedCandidate.startsWith(`${normalizedProjectRoot}/`) - ); -} - -function getLowercaseBasename(input: string): string { - const normalizedInput = normalizeSeparators(input); - return normalizedInput.split("/").pop()!.toLowerCase(); -} - -function getLookupKey(input: string, isBareFilename: boolean): string { - return isBareFilename ? getLowercaseBasename(input) : input.toLowerCase(); -} - -function resolveAbsolutePath(input: string, platform = process.platform): string { - // Use win32.resolve for Windows paths regardless of reported platform - return (platform === "win32" || hasWindowsDriveLetter(input)) - ? win32.resolve(input) - : resolve(input); -} - -function isSearchableMarkdownPath(input: string): boolean { - return MARKDOWN_PATH_REGEX.test(input.trim()); -} - -/** Check if a path looks like a Windows absolute path (e.g. C:\ or C:/) */ -function hasWindowsDriveLetter(input: string): boolean { - return /^[a-zA-Z]:[/\\]/.test(input); -} - -/** Cross-platform file existence check using Node fs (more reliable than Bun.file in compiled exes) */ -function fileExists(filePath: string): boolean { - try { - return existsSync(filePath); - } catch { - return false; - } -} - -export function normalizeMarkdownPathInput( - input: string, - platform = process.platform, -): string { - if (platform !== "win32") { - return input; - } - - for (const pattern of WINDOWS_DRIVE_PATH_PATTERNS) { - const match = input.match(pattern); - if (!match) { - continue; - } - - const [, driveLetter, rest] = match; - return `${driveLetter.toUpperCase()}:/${rest}`; - } - - return input; -} - -export function isAbsoluteMarkdownPath( - input: string, - platform = process.platform, -): boolean { - const normalizedInput = normalizeMarkdownPathInput(input, platform); - // Always check for Windows drive letters (handles compiled Bun exes where - // process.platform may not reflect the actual OS correctly) - if (hasWindowsDriveLetter(normalizedInput)) { - return true; - } - return platform === "win32" - ? win32.isAbsolute(normalizedInput) - : isAbsolute(normalizedInput); -} - -/** - * Resolve a markdown file path within a project root. - * - * @param input - User-provided path (absolute, relative, or bare filename) - * @param projectRoot - Project root directory to search within - */ -export async function resolveMarkdownFile( - input: string, - projectRoot: string, -): Promise { - // Trim whitespace/CR that may leak from Windows shell pipelines - input = input.trim(); - const normalizedInput = normalizeMarkdownPathInput(input); - const searchInput = normalizeSeparators(normalizedInput); - const isBareFilename = !searchInput.includes("/"); - const targetLookupKey = getLookupKey(searchInput, isBareFilename); - - // Restrict to markdown files - if (!isSearchableMarkdownPath(normalizedInput)) { - return { kind: "not_found", input }; - } - - // 1. Absolute path — use as-is (no project root restriction; - // the user explicitly typed the full path) - if (isAbsoluteMarkdownPath(normalizedInput)) { - const absolutePath = resolveAbsolutePath(normalizedInput); - if (fileExists(absolutePath)) { - return { kind: "found", path: absolutePath }; - } - return { kind: "not_found", input }; - } - - // 2. Exact relative path from project root - const fromRoot = resolve(projectRoot, searchInput); - if (isWithinProjectRoot(fromRoot, projectRoot) && fileExists(fromRoot)) { - return { kind: "found", path: fromRoot }; - } - - // 3. Case-insensitive search (only scan markdown files) - const glob = new Bun.Glob("**/*.[mM][dD]{,[xX]}"); - const matches: string[] = []; - - for await (const match of glob.scan({ cwd: projectRoot, onlyFiles: true })) { - const normalizedMatch = normalizeSeparators(match); - - if (IGNORED_DIRS.some((dir) => normalizedMatch.includes(dir))) continue; - - const matchLookupKey = getLookupKey(normalizedMatch, isBareFilename); - - if (matchLookupKey === targetLookupKey) { - const full = resolve(projectRoot, normalizedMatch); - if (isWithinProjectRoot(full, projectRoot)) { - matches.push(full); - } - } - } - - if (matches.length === 1) { - return { kind: "found", path: matches[0] }; - } - if (matches.length > 1) { - const projectRootPrefix = `${normalizeComparablePath(projectRoot)}/`; - const relative = matches.map((match) => normalizeComparablePath(match).replace(projectRootPrefix, "")); - return { kind: "ambiguous", input, matches: relative }; - } - - return { kind: "not_found", input }; -} diff --git a/packages/server/review.ts b/packages/server/review.ts index d0fbccd6..1a1c40c9 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -15,8 +15,8 @@ import { getRepoInfo } from "./repo"; import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleFavicon, type OpencodeClient } from "./shared-handlers"; import { contentHash, deleteDraft } from "./draft"; import { createEditorAnnotationHandler } from "./editor-annotations"; -import { type PRMetadata, type PRReviewFileComment, fetchPRFileContent, fetchPRContext, submitPRReview, getUser, prRefFromMetadata, getDisplayRepo, getMRLabel, getMRNumberLabel } from "./pr"; -import { createAIEndpoints, ProviderRegistry, SessionManager, createProvider, type AIEndpoints } from "@plannotator/ai"; +import { type PRMetadata, type PRReviewFileComment, fetchPRFileContent, fetchPRContext, submitPRReview, getPRUser, prRefFromMetadata, getDisplayRepo, getMRLabel, getMRNumberLabel } from "./pr"; +import { createAIEndpoints, ProviderRegistry, SessionManager, createProvider, type AIEndpoints, type PiSDKConfig } from "@plannotator/ai"; // Re-export utilities export { isRemoteSession, getServerPort } from "./remote"; @@ -144,7 +144,7 @@ export async function startReviewServer( type: "pi-sdk", cwd: process.cwd(), piExecutablePath: piPath, - }); + } as PiSDKConfig); if (provider instanceof PiSDKProvider) { await provider.fetchModels(); } @@ -198,7 +198,7 @@ export async function startReviewServer( // Fetch current platform user (for own-PR/MR detection) const prRef = isPRMode ? prRefFromMetadata(prMetadata) : null; - const platformUser = prRef ? await getUser(prRef) : null; + const platformUser = prRef ? await getPRUser(prRef) : null; // Decision promise let resolveDecision: (result: { @@ -463,6 +463,7 @@ export async function startReviewServer( if (aiEndpoints && url.pathname.startsWith("/api/ai/")) { const handler = aiEndpoints[url.pathname as keyof AIEndpoints]; if (handler) return handler(req); + return Response.json({ error: "Not found" }, { status: 404 }); } // Favicon @@ -498,15 +499,16 @@ export async function startReviewServer( throw new Error("Failed to start server"); } - const serverUrl = `http://localhost:${server.port}`; + const port = server.port!; + const serverUrl = `http://localhost:${port}`; // Notify caller that server is ready if (onReady) { - onReady(serverUrl, isRemote, server.port); + onReady(serverUrl, isRemote, port); } return { - port: server.port, + port, url: serverUrl, isRemote, waitForDecision: () => decisionPromise, diff --git a/packages/server/shared-handlers.ts b/packages/server/shared-handlers.ts index 0c995e92..5b702c77 100644 --- a/packages/server/shared-handlers.ts +++ b/packages/server/shared-handlers.ts @@ -10,6 +10,7 @@ import { mkdirSync } from "fs"; import { openBrowser } from "./browser"; import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image"; import { saveDraft, loadDraft, deleteDraft } from "./draft"; +import { FAVICON_SVG } from "@plannotator/shared/favicon"; /** Serve images from local paths or temp uploads. Used by all 3 servers. */ export async function handleImage(req: Request): Promise { @@ -125,11 +126,7 @@ export function handleDraftDelete(contentKey: string): Response { return Response.json({ ok: true }); } -const FAVICON_SVG = ` - - - P -`; + /** Serve the app favicon. Used by all 3 servers. */ export function handleFavicon(): Response { diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 00000000..6f08d955 --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "types": ["bun-types"] + }, + "exclude": ["**/*.test.ts"] +} diff --git a/packages/shared/crypto.ts b/packages/shared/crypto.ts index 27c76712..0161e6dc 100644 --- a/packages/shared/crypto.ts +++ b/packages/shared/crypto.ts @@ -61,7 +61,7 @@ export async function decrypt( const cryptoKey = await crypto.subtle.importKey( 'raw', - rawKey, + rawKey.buffer as ArrayBuffer, { name: 'AES-GCM', length: 256 }, false, ['decrypt'] diff --git a/packages/shared/favicon.ts b/packages/shared/favicon.ts new file mode 100644 index 00000000..c857b841 --- /dev/null +++ b/packages/shared/favicon.ts @@ -0,0 +1,5 @@ +export const FAVICON_SVG = ` + + + P +`; diff --git a/packages/shared/integrations-common.ts b/packages/shared/integrations-common.ts new file mode 100644 index 00000000..50dd6a64 --- /dev/null +++ b/packages/shared/integrations-common.ts @@ -0,0 +1,243 @@ +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; + +// --- Types --- + +export interface ObsidianConfig { + vaultPath: string; + folder: string; + plan: string; + filenameFormat?: string; // Custom format string, e.g. '{YYYY}-{MM}-{DD} - {title}' + filenameSeparator?: "space" | "dash" | "underscore"; // Replace spaces in filename +} + +export interface BearConfig { + plan: string; + customTags?: string; + tagPosition?: "prepend" | "append"; +} + +export interface OctarineConfig { + plan: string; + workspace: string; + folder: string; +} + +export interface IntegrationResult { + success: boolean; + error?: string; + path?: string; +} + +/** + * Detect Obsidian vaults by reading Obsidian's config file + * Returns array of vault paths found on the system + */ +export function detectObsidianVaults(): string[] { + try { + const home = process.env.HOME || process.env.USERPROFILE || ""; + let configPath: string; + + // Platform-specific config locations + if (process.platform === "darwin") { + configPath = join( + home, + "Library/Application Support/obsidian/obsidian.json", + ); + } else if (process.platform === "win32") { + const appData = process.env.APPDATA || join(home, "AppData/Roaming"); + configPath = join(appData, "obsidian/obsidian.json"); + } else { + // Linux + configPath = join(home, ".config/obsidian/obsidian.json"); + } + + if (!existsSync(configPath)) { + return []; + } + + const configContent = readFileSync(configPath, "utf-8"); + const config = JSON.parse(configContent); + + if (!config.vaults || typeof config.vaults !== "object") { + return []; + } + + // Extract vault paths, filter to ones that exist + const vaults: string[] = []; + for (const vaultId of Object.keys(config.vaults)) { + const vault = config.vaults[vaultId]; + if (vault.path && existsSync(vault.path)) { + vaults.push(vault.path); + } + } + + return vaults; + } catch { + return []; + } +} + +// --- Frontmatter and Filename Generation --- + +/** + * Generate frontmatter for the note + */ +export function generateFrontmatter(tags: string[]): string { + const now = new Date().toISOString(); + const tagList = tags.map((t) => t.toLowerCase()).join(", "); + return `--- +created: ${now} +source: plannotator +tags: [${tagList}] +---`; +} + +/** + * Extract title from markdown (first H1 heading) + */ +export function extractTitle(markdown: string): string { + const h1Match = markdown.match( + /^#\s+(?:Implementation\s+Plan:|Plan:)?\s*(.+)$/im, + ); + if (h1Match) { + // Clean up the title for use as filename + return h1Match[1] + .trim() + .replace(/[<>:"/\\|?*(){}\[\]#~`]/g, "") // Remove invalid/problematic filename chars + .replace(/\s+/g, " ") // Normalize whitespace + .trim() // Re-trim after stripping + .slice(0, 50); // Limit length + } + return "Plan"; +} + +/** Default filename format matching original behavior */ +export const DEFAULT_FILENAME_FORMAT = + "{title} - {Mon} {D}, {YYYY} {h}-{mm}{ampm}"; + +/** + * Generate filename from a format string with variable substitution. + * + * Supported variables: + * {title} - Plan title from first H1 heading + * {YYYY} - 4-digit year + * {MM} - 2-digit month (01-12) + * {DD} - 2-digit day (01-31) + * {Mon} - Abbreviated month name (Jan, Feb, ...) + * {D} - Day without leading zero + * {HH} - 2-digit hour, 24h (00-23) + * {h} - Hour without leading zero, 12h + * {hh} - 2-digit hour, 12h (01-12) + * {mm} - 2-digit minutes (00-59) + * {ss} - 2-digit seconds (00-59) + * {ampm} - am/pm + * + * Default format: '{title} - {Mon} {D}, {YYYY} {h}-{mm}{ampm}' + * Example output: 'User Authentication - Jan 2, 2026 2-30pm.md' + */ +export function generateFilename( + markdown: string, + format?: string, + separator?: "space" | "dash" | "underscore", +): string { + const title = extractTitle(markdown); + const now = new Date(); + + const months = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; + + const hour24 = now.getHours(); + const hour12 = hour24 % 12 || 12; + const ampm = hour24 >= 12 ? "pm" : "am"; + + const vars: Record = { + title, + YYYY: String(now.getFullYear()), + MM: String(now.getMonth() + 1).padStart(2, "0"), + DD: String(now.getDate()).padStart(2, "0"), + Mon: months[now.getMonth()], + D: String(now.getDate()), + HH: String(hour24).padStart(2, "0"), + h: String(hour12), + hh: String(hour12).padStart(2, "0"), + mm: String(now.getMinutes()).padStart(2, "0"), + ss: String(now.getSeconds()).padStart(2, "0"), + ampm, + }; + + const template = format?.trim() || DEFAULT_FILENAME_FORMAT; + const result = template.replace( + /\{(\w+)\}/g, + (match, key) => vars[key] ?? match, + ); + + // Sanitize: remove characters invalid in filenames + let sanitized = result + .replace(/[<>:"/\\|?*]/g, "") + .replace(/\s+/g, " ") + .trim(); + + // Apply separator preference (replace spaces with dash or underscore) + if (separator === "dash") { + sanitized = sanitized.replace(/ /g, "-"); + } else if (separator === "underscore") { + sanitized = sanitized.replace(/ /g, "_"); + } + + return sanitized.endsWith(".md") ? sanitized : `${sanitized}.md`; +} + +// --- Bear Integration --- + +export function stripH1(plan: string): string { + return plan.replace(/^#\s+.+\n?/m, "").trimStart(); +} + +export function buildHashtags( + customTags: string | undefined, + autoTags: string[], +): string { + if (customTags?.trim()) { + return customTags + .split(",") + .map((t) => `#${t.trim()}`) + .filter((t) => t !== "#") + .join(" "); + } + return autoTags.map((t) => `#${t}`).join(" "); +} + +export function buildBearContent( + body: string, + hashtags: string, + tagPosition: "prepend" | "append", +): string { + return tagPosition === "prepend" + ? `${hashtags}\n\n${body}` + : `${body}\n\n${hashtags}`; +} + +// --- Octarine Integration --- + +/** + * Generate YAML frontmatter for an Octarine note. + * Uses Octarine's property format (list-style tags, Status, Author, Last Edited). + */ +export function generateOctarineFrontmatter(tags: string[]): string { + const now = new Date().toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM + const tagLines = tags.map((t) => ` - ${t.toLowerCase()}`).join("\n"); + return `---\ntags:\n${tagLines}\nStatus: Draft\nAuthor: plannotator\nLast Edited: ${now}\n---`; +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 0aeea1ce..8a129fe4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -12,6 +12,11 @@ "./pr-provider": "./pr-provider.ts", "./project": "./project.ts", "./storage": "./storage.ts", - "./draft": "./draft.ts" + "./draft": "./draft.ts", + "./integrations-common": "./integrations-common.ts", + "./repo": "./repo.ts", + "./reference-common": "./reference-common.ts", + "./favicon": "./favicon.ts", + "./resolve-file": "./resolve-file.ts" } } diff --git a/packages/shared/reference-common.ts b/packages/shared/reference-common.ts new file mode 100644 index 00000000..214ed989 --- /dev/null +++ b/packages/shared/reference-common.ts @@ -0,0 +1,81 @@ +// --- Vault file tree helpers --- + +export const FILE_BROWSER_EXCLUDED = [ + "node_modules/", + ".git/", + "dist/", + "build/", + ".next/", + "__pycache__/", + ".obsidian/", + ".trash/", + ".venv/", + "vendor/", + "target/", + ".cache/", + "coverage/", + ".turbo/", + ".svelte-kit/", + ".nuxt/", + ".output/", + ".parcel-cache/", + ".webpack/", + ".expo/", +]; + +export interface VaultNode { + name: string; + path: string; // relative path within vault + type: "file" | "folder"; + children?: VaultNode[]; +} + +/** + * Build a nested file tree from a sorted list of relative paths. + * Folders are sorted before files at each level. + */ +export function buildFileTree(relativePaths: string[]): VaultNode[] { + const root: VaultNode[] = []; + + for (const filePath of relativePaths) { + const parts = filePath.split("/"); + let current = root; + let pathSoFar = ""; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + pathSoFar = pathSoFar ? `${pathSoFar}/${part}` : part; + const isFile = i === parts.length - 1; + + let node = current.find( + (n) => n.name === part && n.type === (isFile ? "file" : "folder"), + ); + if (!node) { + node = { + name: part, + path: pathSoFar, + type: isFile ? "file" : "folder", + }; + if (!isFile) node.children = []; + current.push(node); + } + if (!isFile) { + current = node.children!; + } + } + } + + // Sort: folders first (alphabetical), then files (alphabetical) + const sortNodes = (nodes: VaultNode[]) => { + nodes.sort((a, b) => { + if (a.type !== b.type) return a.type === "folder" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + for (const node of nodes) { + if (node.children) sortNodes(node.children); + } + }; + sortNodes(root); + + return root; +} diff --git a/packages/shared/repo.ts b/packages/shared/repo.ts new file mode 100644 index 00000000..dfc34dd4 --- /dev/null +++ b/packages/shared/repo.ts @@ -0,0 +1,38 @@ +export interface RepoInfo { + /** Display string (e.g., "backnotprop/plannotator" or "my-project") */ + display: string; + /** Current git branch (if in a git repo) */ + branch?: string; +} + +/** + * Parse org/repo from a git remote URL + * + * Handles: + * - SSH: git@github.com:org/repo.git + * - HTTPS: https://github.com/org/repo.git + * - SSH with port: ssh://git@github.com:22/org/repo.git + */ +export function parseRemoteUrl(url: string): string | null { + if (!url) return null; + + // SSH format: git@github.com:org/repo.git + const sshMatch = url.match(/:([^/]+\/[^/]+?)(?:\.git)?$/); + if (sshMatch) return sshMatch[1]; + + // HTTPS format: https://github.com/org/repo.git + const httpsMatch = url.match(/\/([^/]+\/[^/]+?)(?:\.git)?$/); + if (httpsMatch) return httpsMatch[1]; + + return null; +} + +/** + * Get directory name from path + */ +export function getDirName(path: string): string | null { + if (!path) return null; + const trimmed = path.trim().replace(/\/+$/, ""); + const parts = trimmed.split("/"); + return parts[parts.length - 1] || null; +} diff --git a/packages/shared/resolve-file.ts b/packages/shared/resolve-file.ts new file mode 100644 index 00000000..7ba54b36 --- /dev/null +++ b/packages/shared/resolve-file.ts @@ -0,0 +1,219 @@ +/** + * Smart markdown file resolution. + * + * Resolves a user-provided path to an absolute file path using three strategies: + * 1. Exact path (absolute or relative to cwd) + * 2. Case-insensitive relative path search within project root + * 3. Case-insensitive bare filename search within project root + * + * Used by both the CLI (`plannotator annotate`) and the `/api/doc` endpoint. + */ + +import { isAbsolute, join, resolve, win32 } from "path"; +import { existsSync, readdirSync, type Dirent } from "fs"; + +const MARKDOWN_PATH_REGEX = /\.mdx?$/i; +const WINDOWS_DRIVE_PATH_PATTERNS = [ + /^\/cygdrive\/([a-zA-Z])\/(.+)$/, + /^\/([a-zA-Z])\/(.+)$/, +]; + +const IGNORED_DIRS = [ + "node_modules/", + ".git/", + "dist/", + "build/", + ".next/", + "__pycache__/", + ".obsidian/", + ".trash/", +]; + +export type ResolveResult = + | { kind: "found"; path: string } + | { kind: "not_found"; input: string } + | { kind: "ambiguous"; input: string; matches: string[] }; + +function normalizeSeparators(input: string): string { + return input.replace(/\\/g, "/"); +} + +function stripTrailingSlashes(input: string): string { + return input.replace(/\/+$/, ""); +} + +function normalizeComparablePath(input: string): string { + return stripTrailingSlashes(normalizeSeparators(resolve(input))); +} + +function isWithinProjectRoot(candidate: string, projectRoot: string): boolean { + const normalizedCandidate = normalizeComparablePath(candidate); + const normalizedProjectRoot = normalizeComparablePath(projectRoot); + return ( + normalizedCandidate === normalizedProjectRoot || + normalizedCandidate.startsWith(`${normalizedProjectRoot}/`) + ); +} + +function getLowercaseBasename(input: string): string { + const normalizedInput = normalizeSeparators(input); + return normalizedInput.split("/").pop()!.toLowerCase(); +} + +function getLookupKey(input: string, isBareFilename: boolean): string { + return isBareFilename ? getLowercaseBasename(input) : input.toLowerCase(); +} + +function resolveAbsolutePath( + input: string, + platform = process.platform, +): string { + // Use win32.resolve for Windows paths regardless of reported platform + return platform === "win32" || hasWindowsDriveLetter(input) + ? win32.resolve(input) + : resolve(input); +} + +function isSearchableMarkdownPath(input: string): boolean { + return MARKDOWN_PATH_REGEX.test(input.trim()); +} + +/** Check if a path looks like a Windows absolute path (e.g. C:\ or C:/) */ +function hasWindowsDriveLetter(input: string): boolean { + return /^[a-zA-Z]:[/\\]/.test(input); +} + +/** Cross-platform file existence check using Node fs (more reliable than Bun.file in compiled exes) */ +function fileExists(filePath: string): boolean { + try { + return existsSync(filePath); + } catch { + return false; + } +} + +/** Recursively walk a directory collecting markdown files, skipping ignored dirs. */ +function walkMarkdownFiles(dir: string, root: string, results: string[], ignoredDirs: string[]): void { + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }) as Dirent[]; + } catch { + return; + } + for (const entry of entries) { + if (entry.isDirectory()) { + if (ignoredDirs.some((d) => d === entry.name + "/")) continue; + walkMarkdownFiles(join(dir, entry.name), root, results, ignoredDirs); + } else if (entry.isFile() && /\.mdx?$/i.test(entry.name)) { + const relative = join(dir, entry.name) + .slice(root.length + 1) + .replace(/\\/g, "/"); + results.push(relative); + } + } +} + +export function normalizeMarkdownPathInput( + input: string, + platform = process.platform, +): string { + if (platform !== "win32") { + return input; + } + + for (const pattern of WINDOWS_DRIVE_PATH_PATTERNS) { + const match = input.match(pattern); + if (!match) { + continue; + } + + const [, driveLetter, rest] = match; + return `${driveLetter.toUpperCase()}:/${rest}`; + } + + return input; +} + +export function isAbsoluteMarkdownPath( + input: string, + platform = process.platform, +): boolean { + const normalizedInput = normalizeMarkdownPathInput(input, platform); + // Always check for Windows drive letters (handles compiled Bun exes where + // process.platform may not reflect the actual OS correctly) + if (hasWindowsDriveLetter(normalizedInput)) { + return true; + } + return platform === "win32" + ? win32.isAbsolute(normalizedInput) + : isAbsolute(normalizedInput); +} + +/** + * Resolve a markdown file path within a project root. + * + * @param input - User-provided path (absolute, relative, or bare filename) + * @param projectRoot - Project root directory to search within + */ +export function resolveMarkdownFile( + input: string, + projectRoot: string, +): ResolveResult { + // Trim whitespace/CR that may leak from Windows shell pipelines + input = input.trim(); + const normalizedInput = normalizeMarkdownPathInput(input); + const searchInput = normalizeSeparators(normalizedInput); + const isBareFilename = !searchInput.includes("/"); + const targetLookupKey = getLookupKey(searchInput, isBareFilename); + + // Restrict to markdown files + if (!isSearchableMarkdownPath(normalizedInput)) { + return { kind: "not_found", input }; + } + + // 1. Absolute path — use as-is (no project root restriction; + // the user explicitly typed the full path) + if (isAbsoluteMarkdownPath(normalizedInput)) { + const absolutePath = resolveAbsolutePath(normalizedInput); + if (fileExists(absolutePath)) { + return { kind: "found", path: absolutePath }; + } + return { kind: "not_found", input }; + } + + // 2. Exact relative path from project root + const fromRoot = resolve(projectRoot, searchInput); + if (isWithinProjectRoot(fromRoot, projectRoot) && fileExists(fromRoot)) { + return { kind: "found", path: fromRoot }; + } + + // 3. Case-insensitive search (only scan markdown files) + const allFiles: string[] = []; + walkMarkdownFiles(projectRoot, projectRoot, allFiles, IGNORED_DIRS); + const matches: string[] = []; + + for (const match of allFiles) { + const normalizedMatch = normalizeSeparators(match); + const matchLookupKey = getLookupKey(normalizedMatch, isBareFilename); + + if (matchLookupKey === targetLookupKey) { + const full = resolve(projectRoot, normalizedMatch); + if (isWithinProjectRoot(full, projectRoot)) { + matches.push(full); + } + } + } + + if (matches.length === 1) { + return { kind: "found", path: matches[0] }; + } + if (matches.length > 1) { + const projectRootPrefix = `${normalizeComparablePath(projectRoot)}/`; + const relative = matches.map((match) => + normalizeComparablePath(match).replace(projectRootPrefix, ""), + ); + return { kind: "ambiguous", input, matches: relative }; + } + + return { kind: "not_found", input }; +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 00000000..6556c64f --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "types": ["node"] + }, + "exclude": ["**/*.test.ts"] +} diff --git a/tests/UI-TESTING-CHECKLIST.md b/tests/UI-TESTING-CHECKLIST.md deleted file mode 100644 index 75f84eaf..00000000 --- a/tests/UI-TESTING-CHECKLIST.md +++ /dev/null @@ -1,248 +0,0 @@ -# UI Feature Testing Checklists - -Use these checklists to verify features work correctly before submitting a PR. - -## Table of Contents (TOC) Feature - -The TOC sidebar provides navigation for long documents. - -**Visual & Layout:** - -- [ ] TOC visible on left sidebar on desktop (≥1024px width) -- [ ] TOC hidden on mobile/tablet (<1024px) -- [ ] Width is 240px on desktop -- [ ] Background: semi-transparent card with backdrop blur -- [ ] Border on right side separates TOC from content - -**Hierarchy & Structure:** - -- [ ] Shows H1-H3 headings in hierarchical structure -- [ ] H1 headings have no left indent -- [ ] H2 headings indented one level (pl-4) -- [ ] H3 headings indented two levels (pl-6) -- [ ] Nested items appear under parent headings - -**Active Section Highlighting:** - -- [ ] Active section highlights as you scroll -- [ ] Highlight uses primary color -- [ ] Left border (2px) appears on active item -- [ ] Background tint (primary/10) on active item -- [ ] Only one item active at a time - -**Navigation:** - -- [ ] Clicking TOC item scrolls smoothly to that section -- [ ] Scroll accounts for sticky header offset (no overlap) -- [ ] Smooth scroll animation (behavior: smooth) -- [ ] Navigation works for all heading levels - -**Annotation Badges:** - -- [ ] Annotation count badges show correct numbers -- [ ] Badges only appear when annotations exist in section -- [ ] Badge style: rounded-full with accent color -- [ ] Badge position: right side of TOC item - -**Collapse/Expand:** - -- [ ] Chevron button appears for headings with children -- [ ] Chevron rotates correctly (down = expanded, right = collapsed) -- [ ] Clicking chevron toggles child visibility -- [ ] Expand/collapse state persists while scrolling -- [ ] Multiple sections can be collapsed independently - -**Interactions:** - -- [ ] Hover state shows background change -- [ ] Long heading text wraps properly (line-clamp-2) -- [ ] Text remains readable when truncated -- [ ] Hover shows full text via title attribute (if truncated) - -**Keyboard Navigation:** - -- [ ] Tab key focuses TOC items -- [ ] Enter key navigates to section -- [ ] Space key also navigates to section -- [ ] Focus indicators visible on all items -- [ ] Chevron buttons keyboard accessible - -**Scrolling & Performance:** - -- [ ] TOC scrolls independently from main content -- [ ] Sticky positioning works (stays visible while scrolling) -- [ ] No horizontal scrollbar appears -- [ ] No lag with 100+ headings -- [ ] Active section updates smoothly while scrolling - -## Annotation Features - -**Text Selection & Toolbar:** - -- [ ] Selecting text shows annotation toolbar -- [ ] Toolbar appears near selection (above or below) -- [ ] Toolbar has correct z-index (above other content) -- [ ] Toolbar shows all annotation type options - -**Annotation Types:** - -- [ ] **DELETION:** Red highlight, text appears crossed out in export -- [ ] **REPLACEMENT:** Shows old text → new text -- [ ] **COMMENT:** Comment bubble/tooltip appears -- [ ] **INSERTION:** New text shown at cursor position with insertion point -- [ ] **GLOBAL_COMMENT:** Accessible from sticky header button - -**Annotation Panel:** - -- [ ] Panel lists all annotations -- [ ] Annotations grouped correctly -- [ ] Can click annotation in panel to highlight in document -- [ ] Can edit annotation text -- [ ] Can delete annotations -- [ ] Deletion count shows in panel header - -**Export & Formatting:** - -- [ ] Export shows annotations in readable format -- [ ] Format follows: "- **DELETION** (line X): [original text]" -- [ ] Global comments appear at top of export -- [ ] All annotation types exported correctly - -**Code Blocks:** - -- [ ] Can annotate text in code blocks -- [ ] Code block annotations use manual mark wrapping -- [ ] Syntax highlighting preserved after annotation -- [ ] Multi-line code annotations work - -**Images:** - -- [ ] Can attach images to annotations -- [ ] Image annotation tools available (pen, arrow, circle) -- [ ] Annotated images display in review UI -- [ ] Can remove image attachments - -## Sticky Header - -**Position & Behavior:** - -- [ ] Header stays at top while scrolling (position: sticky) -- [ ] Header has proper z-index (z-20) -- [ ] Header appears above content but below annotation toolbar (z-100) -- [ ] Sticky behavior works on all screen sizes - -**Buttons & Controls:** - -- [ ] Global comment button always accessible -- [ ] Global comment button opens annotation input -- [ ] Theme toggle works (dark/light/system) -- [ ] Mode switcher works (selection mode / redline mode) -- [ ] Share button generates valid URL -- [ ] Settings button opens modal -- [ ] All buttons have proper hover states - -**Settings Modal:** - -- [ ] Settings modal opens correctly -- [ ] Identity settings save (name/email) -- [ ] Plan save settings persist (enabled/path) -- [ ] Obsidian settings work (vault selection) -- [ ] Bear settings work (enable/disable) -- [ ] Settings persist across sessions (stored in cookies) - -**Visual Polish:** - -- [ ] Header background: semi-transparent with backdrop blur -- [ ] Border on bottom separates header from content -- [ ] Header height: 48px (h-12) -- [ ] Content doesn't overlap with header when scrolling - -## Responsive Design - -**Desktop (≥1024px):** - -- [ ] TOC visible on left (240px width) -- [ ] Main content area uses remaining width -- [ ] Document centered with max-width constraint -- [ ] All buttons and controls accessible -- [ ] Layout doesn't feel cramped - -**Tablet (768-1023px):** - -- [ ] TOC hidden completely -- [ ] Document uses full width -- [ ] Touch targets sized appropriately -- [ ] No horizontal scrolling - -**Mobile (<768px):** - -- [ ] TOC hidden completely -- [ ] Content optimized for small screens -- [ ] Font sizes readable (not too small) -- [ ] Touch targets minimum 44x44px -- [ ] Buttons don't overlap -- [ ] Modal dialogs fit on screen -- [ ] No pinch-zoom required to read text - -**General Responsive:** - -- [ ] No horizontal scrolling on any screen size -- [ ] Touch gestures work (scroll, tap, swipe) -- [ ] Viewport meta tag configured correctly -- [ ] Layout reflows smoothly when resizing - -## Accessibility - -**Keyboard Navigation:** - -- [ ] All interactive elements keyboard accessible (Tab, Enter, Space) -- [ ] Tab order logical (top to bottom, left to right) -- [ ] Escape key closes modals/dialogs -- [ ] Arrow keys work in appropriate contexts - -**Focus Indicators:** - -- [ ] Focus indicators visible on all interactive elements -- [ ] Focus indicator has sufficient contrast -- [ ] Focus indicator not hidden by CSS - -**Screen Readers:** - -- [ ] Headings announce with correct levels (H1, H2, H3) -- [ ] Links and buttons have descriptive labels -- [ ] Icon-only buttons have aria-label -- [ ] Images have alt text or aria-label -- [ ] Form inputs have associated labels - -**ARIA Attributes:** - -- [ ] `aria-current="location"` on active TOC item -- [ ] `aria-label` on icon-only buttons -- [ ] `aria-expanded` on collapsible elements -- [ ] `role="navigation"` on TOC (implicit with nav element) - -**Color & Contrast:** - -- [ ] Color contrast meets WCAG AA (4.5:1 for text, 3:1 for UI) -- [ ] Information not conveyed by color alone -- [ ] Links distinguishable from text (not just color) -- [ ] Dark mode has sufficient contrast - -## Performance & Browser Compatibility - -**Performance:** - -- [ ] Large documents (100+ headings, 10+ pages) load quickly (<2s) -- [ ] Scrolling is smooth (60fps) -- [ ] No jank when highlighting active section -- [ ] Annotation creation is instant (no lag) -- [ ] No memory leaks (check DevTools Memory tab) -- [ ] Page load time acceptable (<3s on fast connection) - -**Browser Compatibility:** - -- [ ] Works in Chrome (latest) -- [ ] Works in Firefox (latest) -- [ ] Works in Safari (latest) -- [ ] Works in Edge (latest) -- [ ] Fallbacks for unsupported features (if any) diff --git a/tests/UI-TESTING.md b/tests/UI-TESTING.md index 59763bef..e01879fa 100644 --- a/tests/UI-TESTING.md +++ b/tests/UI-TESTING.md @@ -8,8 +8,7 @@ steps to ensure your changes work correctly. 1. [Development Setup](#development-setup) 2. [Development Workflow](#development-workflow) 3. [Quick Testing Guide](#quick-testing-guide) -4. [UI Feature Testing Checklists](#ui-feature-testing-checklists) -5. [Debugging Common Issues](#debugging-common-issues) +4. [Debugging Common Issues](#debugging-common-issues) --- @@ -221,21 +220,6 @@ See [tests/README.md](../tests/README.md) for additional integration and utility --- -## UI Feature Testing Checklists - -For detailed feature testing checklists, see **[UI-TESTING-CHECKLIST.md](./UI-TESTING-CHECKLIST.md)**. - -The checklist covers: - -- Table of Contents (TOC) feature -- Annotation features -- Sticky header -- Responsive design -- Accessibility -- Performance & browser compatibility - ---- - ## Debugging Common Issues ### Browser DevTools diff --git a/tests/parity/route-parity.test.ts b/tests/parity/route-parity.test.ts new file mode 100644 index 00000000..c64ad0f3 --- /dev/null +++ b/tests/parity/route-parity.test.ts @@ -0,0 +1,120 @@ +/** + * Route Parity Test + * + * Extracts all API routes from Bun and Pi server files and asserts + * they are identical per server (plan, review, annotate) plus shared + * delegated handlers (editor annotations, AI endpoints). + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; + +const ROOT = join(import.meta.dir, "../.."); + +// --- Route extraction --- + +/** Extract url.pathname === "/path" and url.pathname.startsWith("/path") */ +function extractInlineRoutes(filePath: string): string[] { + const src = readFileSync(filePath, "utf-8"); + const routes: string[] = []; + const re = /url\.pathname\s*(?:===|\.startsWith\()\s*["'`]([^"'`]+)["'`]/g; + let m: RegExpExecArray | null; + while ((m = re.exec(src)) !== null) { + routes.push(m[1]); + } + return routes; +} + +/** Extract AI endpoint object keys: "/api/ai/foo": async ... */ +function extractAIEndpointKeys(filePath: string): string[] { + const src = readFileSync(filePath, "utf-8"); + const routes: string[] = []; + const re = /"(\/api\/ai\/[^"]+)"\s*:\s*async/g; + let m: RegExpExecArray | null; + while ((m = re.exec(src)) !== null) { + routes.push(m[1]); + } + return routes; +} + +function unique(routes: string[]): string[] { + return [...new Set(routes)].sort(); +} + +// --- File paths --- + +const bun = { + plan: join(ROOT, "packages/server/index.ts"), + review: join(ROOT, "packages/server/review.ts"), + annotate: join(ROOT, "packages/server/annotate.ts"), + editorAnnotations: join(ROOT, "packages/server/editor-annotations.ts"), +}; + +const pi = { + plan: join(ROOT, "apps/pi-extension/server/serverPlan.ts"), + review: join(ROOT, "apps/pi-extension/server/serverReview.ts"), + annotate: join(ROOT, "apps/pi-extension/server/serverAnnotate.ts"), + editorAnnotations: join(ROOT, "apps/pi-extension/server/annotations.ts"), +}; + +const aiEndpointsFile = join(ROOT, "packages/ai/endpoints.ts"); + +// --- Tests --- + +describe("route parity: Bun ↔ Pi", () => { + test("plan server routes match", () => { + const bunRoutes = unique(extractInlineRoutes(bun.plan)); + const piRoutes = unique(extractInlineRoutes(pi.plan)); + expect(piRoutes).toEqual(bunRoutes); + }); + + test("review server routes match", () => { + const bunRoutes = unique(extractInlineRoutes(bun.review)); + const piRoutes = unique(extractInlineRoutes(pi.review)); + expect(piRoutes).toEqual(bunRoutes); + }); + + test("annotate server routes match", () => { + const bunRoutes = unique(extractInlineRoutes(bun.annotate)); + const piRoutes = unique(extractInlineRoutes(pi.annotate)); + expect(piRoutes).toEqual(bunRoutes); + }); + + test("editor annotation routes match", () => { + const bunRoutes = unique(extractInlineRoutes(bun.editorAnnotations)); + const piRoutes = unique(extractInlineRoutes(pi.editorAnnotations)); + expect(piRoutes).toEqual(bunRoutes); + }); + + test("AI endpoint keys are present (shared file)", () => { + const routes = extractAIEndpointKeys(aiEndpointsFile); + expect(routes.length).toBeGreaterThan(0); + expect(routes).toContain("/api/ai/capabilities"); + expect(routes).toContain("/api/ai/session"); + expect(routes).toContain("/api/ai/query"); + expect(routes).toContain("/api/ai/abort"); + expect(routes).toContain("/api/ai/permission"); + expect(routes).toContain("/api/ai/sessions"); + }); + + test("all routes across all servers match", () => { + const bunAll = unique([ + ...extractInlineRoutes(bun.plan), + ...extractInlineRoutes(bun.review), + ...extractInlineRoutes(bun.annotate), + ...extractInlineRoutes(bun.editorAnnotations), + ...extractAIEndpointKeys(aiEndpointsFile), + ]); + + const piAll = unique([ + ...extractInlineRoutes(pi.plan), + ...extractInlineRoutes(pi.review), + ...extractInlineRoutes(pi.annotate), + ...extractInlineRoutes(pi.editorAnnotations), + ...extractAIEndpointKeys(aiEndpointsFile), + ]); + + expect(piAll).toEqual(bunAll); + }); +});