From f96758da0a376a79921f1bfada01c4e3fef3aee6 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 24 Mar 2026 00:44:54 -0700 Subject: [PATCH] =?UTF-8?q?feat(pi):=20complete=20Pi=20server=20rewrite=20?= =?UTF-8?q?=E2=80=94=20modular=20architecture,=20full=20Bun=20parity,=20sh?= =?UTF-8?q?ared=20code=20extraction=20(#382)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(pi): add missing endpoints to plan, review, and annotate servers Phase 1-3 of Pi endpoint parity: Plan server: image, upload, draft, editor-annotations, agents, favicon, linked documents, Obsidian vaults/files/doc, file browser, VS Code diff Annotate server: image, upload, draft, favicon, linked documents, file browser Review server: extract shared handlers, add favicon Shared utilities extracted from review server inline code into reusable functions (handleImageRequest, handleUploadRequest, handleDraftRequest, handleFavicon). Reference handlers (doc, Obsidian, file browser) implemented using Node.js fs APIs replacing Bun.Glob/Bun.file. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(pi): add PR review endpoints and Node.js PR runtime adapter Phase 4 of Pi endpoint parity: - Node.js PRRuntime using child_process.spawn (matches Bun adapter pattern) - GET /api/pr-context — fetch PR summary, comments, checks - POST /api/pr-action — submit review to GitHub/GitLab - PR mode guards on /api/diff/switch and /api/git-add - /api/diff response includes prMetadata and platformUser in PR mode - /api/file-content fetches from platform API in PR mode - Build script copies pr-provider, pr-github, pr-gitlab from shared Co-Authored-By: Claude Opus 4.6 (1M context) * feat(pi): wire AI backbone with Node.js Pi SDK provider Phase 5 of Pi endpoint parity: - Create packages/ai/providers/pi-sdk-node.ts — PiProcessNode class using child_process.spawn instead of Bun.spawn, same RPC protocol - Register 4 AI providers in Pi review server (claude-agent-sdk, codex-sdk, pi-sdk-node, opencode-sdk) with graceful degradation - Route /api/ai/* endpoints through createAIEndpoints handlers - Pipe Web Response → node:http response with ReadableStream support for SSE streaming - Dispose AI sessions and registry on server stop Co-Authored-By: Claude Opus 4.6 (1M context) * fix(pi): address parity audit findings across all three servers Plan server: - /api/plan: add repoInfo and projectRoot to response - /api/approve: pass agentSwitch and permissionMode in decision - Update decision promise type to include agentSwitch, permissionMode Review server: - /api/diff/switch: pass gitContext.cwd to runGitDiff - /api/file-content: pass gitContext.cwd to getFileContentsForDiffCore - /api/git-add: add fallback to gitContext.cwd when worktree parse fails Annotate server: - /api/plan: add repoInfo and projectRoot to response - /api/feedback: capture annotations array (was silently dropped) - Update decision promise type to include annotations Co-Authored-By: Claude Opus 4.6 (1M context) * feat(pi): complete parity — integrations, planSave, save-notes Ports all remaining missing functionality: - Node.js versions of saveToObsidian, saveToBear, saveToOctarine (Bun.write → writeFileSync, Bun.$ → spawn) - Node.js detectProjectNameSync (Bun.$ → execSync) - extractTags, generateFrontmatter, generateFilename, extractTitle - POST /api/save-notes — decoupled note saving - POST /api/approve — full implementation: note integrations, planSave snapshots, saveAnnotations, saveFinalSnapshot - POST /api/deny — planSave snapshots on denial - Import saveAnnotations, saveFinalSnapshot from storage.js Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(pi): wire domain module imports and fix type errors - Add all missing imports from ./server/* domain modules to server.ts - Export interfaces from integrations.ts (ObsidianConfig, BearConfig, etc.) - Move toWebRequest to helpers.ts, remove duplicate from handlers.ts - Add git() helper to project.ts (was in server.ts, needed by getRepoInfo) - Fix os default import → named imports in handlers.ts and network.ts - Fix readdirSync Dirent type in reference.ts - Fix Headers.entries() → forEach for Node compat in AI endpoint piping - Fix ReadableStream type cast in AI SSE streaming - Fix matchAll iterator compat in integrations.ts (use while + exec) - Cast pi-sdk provider config to any (PiSDKConfig not in base union) Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(pi): move generated shared files to generated/ directory Moves all build-time copied shared files (feedback-templates, review-core, storage, draft, project, pr-provider, pr-github, pr-gitlab) from the pi-extension root into generated/ subdirectory. Updates build script to output there. Updates all imports in server.ts, index.ts, and server/ domain modules to use ./generated/ paths. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(pi): replace hand-maintained utils.ts with generated checklist utils.ts was a manual copy of parseChecklist, extractDoneSteps, and markCompletedSteps from packages/shared/checklist.ts. Add checklist to the build-time copy list and import from generated/checklist.js. Delete the redundant utils.ts. Co-Authored-By: Claude Opus 4.6 (1M context) * chore(pi): gitignore generated/ and built HTML files These are build artifacts created by `bun run build:pi`. Untrack them and add .gitignore to prevent re-adding. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(pi): split server.ts into domain-organized modules - server.ts is now a barrel re-exporting from server/ modules - server/serverPlan.ts — plan review server - server/serverReview.ts — code review server - server/serverAnnotate.ts — annotate server - server/helpers.ts — add requestUrl() to eliminate non-null assertions - server/project.ts — linter fix (sanitizeTag import path) - packages/ai/package.json — add pi-sdk-node export entry - index.ts — fix waitForDone non-null assertion with guard check, update imports for generated/checklist.js Co-Authored-By: Claude Opus 4.6 (1M context) * fix(pi): parity audit fixes + shared code extraction Systematic side-by-side audit of Pi vs Bun servers (A1-A22, B1-B2 complete). Fixes found during audit: - PlanServerResult.waitForDecision missing savedPath/agentSwitch/permissionMode - Missing permissionMode option and /api/plan response field - editorAnnotations created unnecessarily in archive mode - repoInfo called per-request instead of cached at init - Approve handler missing effectivePermissionMode fallback - Deny handler missing savedPath in decision resolution - Archive /api/plan response had extra pasteApiUrl - Missing GET method guards on archive/plans, archive/plan, doc, obsidian/files, obsidian/doc, reference/files - Review server had stray pasteApiUrl option/response field - AI getCwd missing worktree support Shared code extraction: - packages/shared/favicon.ts — single source for favicon SVG - packages/shared/integrations-common.ts — note app pure functions - packages/shared/reference-common.ts — file tree building - packages/shared/repo.ts — git remote parsing - Updated all consumers to import from shared sources For provenance purposes, this commit was AI assisted. * fix: parity audit B3-C10 — review + annotate server fixes Review server (B3-B17): - diff/switch missing try/catch error handling - git-add parseBody outside try/catch - feedback missing try/catch error handling - Unknown /api/ai/* paths now return 404 (both Bun and Pi) Annotate server (C1-C10): - Bun annotate server missing pasteApiUrl (short URL sharing broken) - Added pasteApiUrl to Bun options, response, and both hook callers - Pi repoInfo called per-request instead of cached at init - Pi feedback missing try/catch error handling - Missing GET method guards on doc and reference/files For provenance purposes, this commit was AI assisted. * fix: parity audit D3-D5 — draft error handling, editor annotations, resolve-file extraction D3: Pi draft save handler missing error handling — added .catch() with 500 + console.error D4: Pi editor annotation POST missing try/catch — added with "Invalid JSON" 400 D5: Extracted resolveMarkdownFile to packages/shared/resolve-file.ts - Replaced Bun.Glob with runtime-agnostic walkMarkdownFiles (readdirSync) - Made function sync (no longer async) - Pi handleDocRequest now uses shared resolveMarkdownFile instead of inline resolution - Gains Windows path normalization, isWithinProjectRoot security check - Deleted packages/server/resolve-file.ts re-export, consumers import from shared - Cleaned up stale await calls in hook entry, reference handler, and tests - All 19 resolve-file tests pass For provenance purposes, this commit was AI assisted. * fix: parity audit D6-D10 — integrations, PR naming, shared modules D6: Fixed broken detectProjectNameSync — was using require() for non-existent exports. Now uses basename + sanitizeTag directly. D7: Renamed checkAuth → checkPRAuth, getUser → getPRUser across Bun server, hook, and OpenCode plugin to match Pi naming. Also fixed stale resolve-file import in OpenCode plugin. D8-D10: Verified clean — ide, project detection, network. For provenance purposes, this commit was AI assisted. * update openpackage.yml * fix: bump Pi git-add test timeout to 15s for parallel suite stability For provenance purposes, this commit was AI assisted. * test: add route parity test — Bun ↔ Pi server route drift detection For provenance purposes, this commit was AI assisted. * fix(ci): update Pi generate step to use generated/ directory with full file list The Pi extension was refactored to use generated/ subdirectory but the CI generate step still used the old flat layout with a subset of files. For provenance purposes, this commit was AI assisted. * fix(ci): update release workflow Pi generate step to match new layout Same stale generate step as test.yml — old flat layout, missing files. For provenance purposes, this commit was AI assisted. * fix(pi): update files array for modular server layout The files array still referenced the old flat layout (server.ts monolith, root-level generated files, deleted utils.ts). npm publish would have produced a broken package missing server/ and generated/ directories. For provenance purposes, this commit was AI assisted. * feat: add TypeScript type-checking to CI pipeline - Fix broken barrel export: buildFileTree/VaultNode re-exported from @plannotator/shared instead of reference-handlers (P1 bug) - Fix server.port type narrowing in all 3 servers - Fix AI provider type errors (claude-agent-sdk, codex-sdk, opencode-sdk, pi-sdk) - Extract mapPiEvent to pi-events.ts to break Bun→Node type chain - Add tsconfig.json to packages/shared, packages/ai, packages/server, apps/pi-extension - Add `typecheck` script to root package.json - Add type-check step to test.yml and release.yml CI workflows For provenance purposes, this commit was AI assisted. * fix(ci): use bun-types instead of @types/node for typecheck CI environment has bun-types (includes Node types) but not @types/node as a standalone package. For provenance purposes, this commit was AI assisted. * fix(ci): add @types/node for Node-runtime type checks Pi extension and packages/shared run on Node, not Bun — they should type-check against @types/node, not bun-types. Added @types/node as a dev dependency so CI resolves it. For provenance purposes, this commit was AI assisted. * fix: cast Uint8Array.buffer to ArrayBuffer for TS 5.9 compat crypto.subtle.importKey expects BufferSource, but TS 5.9 is stricter about Uint8Array.buffer being ArrayBufferLike (includes SharedArrayBuffer) vs ArrayBuffer. Explicit cast resolves the overload mismatch. Astro pulls in TS 5.9 transitively, so CI resolves a different TypeScript version than local dev. This fix works on both 5.8 and 5.9. For provenance purposes, this commit was AI assisted. * fix(ci): add bun-types as explicit devDependency CI's bun install doesn't hoist bun-types to root node_modules when it's only a transitive dep of @types/bun. Adding it as a direct devDependency guarantees tsc can resolve it. For provenance purposes, this commit was AI assisted. * fix(ci): remove Pi extension from typecheck Pi extension depends on @mariozechner/pi-* peer dependencies that aren't installed in CI. Type-checking it requires Pi's runtime environment. The three packages we check (shared, ai, server) are sufficient to catch barrel export bugs and type errors. Pi extension coverage comes from route parity tests and bun test. For provenance purposes, this commit was AI assisted. --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/release.yml | 8 +- .github/workflows/test.yml | 8 +- apps/hook/server/index.ts | 10 +- apps/opencode-plugin/commands.ts | 6 +- apps/pi-extension/.gitignore | 3 + apps/pi-extension/index.ts | 1504 ++++++++++---------- apps/pi-extension/package.json | 12 +- apps/pi-extension/server.test.ts | 2 +- apps/pi-extension/server.ts | 931 +----------- apps/pi-extension/server/annotations.ts | 85 ++ apps/pi-extension/server/handlers.ts | 210 +++ apps/pi-extension/server/helpers.ts | 78 + apps/pi-extension/server/ide.ts | 46 + apps/pi-extension/server/integrations.ts | 195 +++ apps/pi-extension/server/network.ts | 151 ++ apps/pi-extension/server/pr.ts | 91 ++ apps/pi-extension/server/project.ts | 64 + apps/pi-extension/server/reference.ts | 206 +++ apps/pi-extension/server/serverAnnotate.ts | 119 ++ apps/pi-extension/server/serverPlan.ts | 426 ++++++ apps/pi-extension/server/serverReview.ts | 516 +++++++ apps/pi-extension/tsconfig.json | 16 + apps/pi-extension/utils.ts | 57 - bun.lock | 34 +- openpackage.yml | 6 +- package.json | 7 +- packages/ai/package.json | 3 +- packages/ai/providers/claude-agent-sdk.ts | 8 +- packages/ai/providers/codex-sdk.ts | 3 +- packages/ai/providers/opencode-sdk.ts | 2 +- packages/ai/providers/pi-events.ts | 110 ++ packages/ai/providers/pi-sdk-node.ts | 376 +++++ packages/ai/providers/pi-sdk.ts | 117 +- packages/ai/tsconfig.json | 15 + packages/server/annotate.ts | 11 +- packages/server/index.ts | 9 +- packages/server/integrations.ts | 470 ++---- packages/server/package.json | 1 - packages/server/pr.ts | 4 +- packages/server/reference-handlers.ts | 361 +++-- packages/server/repo.ts | 134 +- packages/server/resolve-file.test.ts | 30 +- packages/server/resolve-file.ts | 195 --- packages/server/review.ts | 16 +- packages/server/shared-handlers.ts | 7 +- packages/server/tsconfig.json | 15 + packages/shared/crypto.ts | 2 +- packages/shared/favicon.ts | 5 + packages/shared/integrations-common.ts | 243 ++++ packages/shared/package.json | 7 +- packages/shared/reference-common.ts | 81 ++ packages/shared/repo.ts | 38 + packages/shared/resolve-file.ts | 219 +++ packages/shared/tsconfig.json | 15 + tests/UI-TESTING-CHECKLIST.md | 248 ---- tests/UI-TESTING.md | 18 +- tests/parity/route-parity.test.ts | 120 ++ 57 files changed, 4767 insertions(+), 2907 deletions(-) create mode 100644 apps/pi-extension/.gitignore create mode 100644 apps/pi-extension/server/annotations.ts create mode 100644 apps/pi-extension/server/handlers.ts create mode 100644 apps/pi-extension/server/helpers.ts create mode 100644 apps/pi-extension/server/ide.ts create mode 100644 apps/pi-extension/server/integrations.ts create mode 100644 apps/pi-extension/server/network.ts create mode 100644 apps/pi-extension/server/pr.ts create mode 100644 apps/pi-extension/server/project.ts create mode 100644 apps/pi-extension/server/reference.ts create mode 100644 apps/pi-extension/server/serverAnnotate.ts create mode 100644 apps/pi-extension/server/serverPlan.ts create mode 100644 apps/pi-extension/server/serverReview.ts create mode 100644 apps/pi-extension/tsconfig.json delete mode 100644 apps/pi-extension/utils.ts create mode 100644 packages/ai/providers/pi-events.ts create mode 100644 packages/ai/providers/pi-sdk-node.ts create mode 100644 packages/ai/tsconfig.json delete mode 100644 packages/server/resolve-file.ts create mode 100644 packages/server/tsconfig.json create mode 100644 packages/shared/favicon.ts create mode 100644 packages/shared/integrations-common.ts create mode 100644 packages/shared/reference-common.ts create mode 100644 packages/shared/repo.ts create mode 100644 packages/shared/resolve-file.ts create mode 100644 packages/shared/tsconfig.json delete mode 100644 tests/UI-TESTING-CHECKLIST.md create mode 100644 tests/parity/route-parity.test.ts 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); + }); +});