diff --git a/src/resources/extensions/genai-proxy/proxy-command.ts b/src/resources/extensions/genai-proxy/proxy-command.ts index 15e382bbc..bac62a433 100644 --- a/src/resources/extensions/genai-proxy/proxy-command.ts +++ b/src/resources/extensions/genai-proxy/proxy-command.ts @@ -15,14 +15,19 @@ export function registerProxyCommands(pi: ExtensionAPI): void { ctx.ui.notify("GenAI Proxy is already running.", "info"); return; } - await server.startProxy(port, (msg) => { - if (ctx.hasUI) { - ctx.ui.notify(msg, "info"); - } else { - process.stderr.write(`[genai-proxy] ${msg}\n`); - } - }); - ctx.ui.notify(`GenAI Proxy started on port ${port}`, "success"); + try { + await server.startProxy(port, (msg) => { + if (ctx.hasUI) { + ctx.ui.notify(msg, "info"); + } else { + process.stderr.write(`[genai-proxy] ${msg}\n`); + } + }); + ctx.ui.notify(`GenAI Proxy started on port ${port}`, "success"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ctx.ui.notify(message, "error"); + } break; case "stop": diff --git a/src/resources/extensions/genai-proxy/proxy-server.ts b/src/resources/extensions/genai-proxy/proxy-server.ts index eb323d2ac..93358a54d 100644 --- a/src/resources/extensions/genai-proxy/proxy-server.ts +++ b/src/resources/extensions/genai-proxy/proxy-server.ts @@ -1,282 +1,16 @@ -import express from "express"; -import type { Server } from "http"; -import { - streamGoogleGeminiCli, - type Context, - type GoogleGeminiCliOptions, - type Message, - type Model, - getModels, -} from "@sf-run/pi-ai"; +const GENAI_PROXY_DISABLED_ERROR_MESSAGE = + "GenAI proxy is disabled at startup because no supported auth bootstrap is available from this package boundary."; -let server: Server | null = null; -let oauth: { token: string; projectId: string } | null = null; - -type GoogleGeminiCliModel = Model<"google-gemini-cli">; -type JsonRecord = Record; -type GooglePart = { text?: string }; -type GoogleContent = { role?: string; parts?: GooglePart[] }; -type OpenAiMessage = { - role?: string; - content?: string | Array<{ type?: string; text?: string }>; -}; - -function buildGeminiCliModel(modelId: string): GoogleGeminiCliModel { - return { - id: modelId, - api: "google-gemini-cli", - provider: "google", - name: modelId, - baseUrl: "", - envVar: "", - input: "text", - reasoning: false, - promptCache: false, - maxOutputTokens: 0, - } as unknown as GoogleGeminiCliModel; -} - -function normalizeGoogleContents(contents: unknown): Message[] { - if (!Array.isArray(contents)) return []; - return contents.map((content) => { - const entry = content as GoogleContent; - const role = entry.role === "user" ? "user" : "assistant"; - const text = Array.isArray(entry.parts) - ? entry.parts.map((part) => part.text ?? "").join("") - : ""; - return { - role, - content: [{ type: "text", text }], - } as Message; - }); -} - -function normalizeOpenAiMessages(messages: unknown): { - systemPrompt: string | undefined; - messages: Message[]; -} { - if (!Array.isArray(messages)) return { systemPrompt: undefined, messages: [] }; - - const typedMessages = messages as OpenAiMessage[]; - const systemMessage = typedMessages.find((message) => message.role === "system"); - const nonSystemMessages = typedMessages.filter((message) => message.role !== "system"); - - return { - systemPrompt: typeof systemMessage?.content === "string" ? systemMessage.content : undefined, - messages: nonSystemMessages.map((message) => { - const text = typeof message.content === "string" - ? message.content - : Array.isArray(message.content) - ? message.content.map((part) => part.text ?? "").join("") - : ""; - return { - role: message.role === "user" ? "user" : "assistant", - content: [{ type: "text", text }], - } as Message; - }), - }; -} - -function buildOptions( - generationConfig: JsonRecord | undefined, - oauthState: { token: string; projectId: string }, -): GoogleGeminiCliOptions { - return { - apiKey: JSON.stringify(oauthState), - temperature: typeof generationConfig?.temperature === "number" ? generationConfig.temperature : undefined, - maxTokens: typeof generationConfig?.maxOutputTokens === "number" ? generationConfig.maxOutputTokens : undefined, - }; -} +let running = false; export function isRunning(): boolean { - return server !== null; + return running; } -export async function startProxy(port: number, onLog: (msg: string) => void): Promise { - if (server) return; - - const app = express(); - app.use(express.json()); - - app.get("/login", async (_req, res) => { - try { - const message = - "OAuth login is not available from the extension package boundary yet. " + - "Provide cached credentials through the hosting environment instead."; - onLog(message); - res.status(501).send(message); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - onLog(`Login failed: ${message}`); - res.status(500).send(message); - } - }); - - // 2. Models listing endpoints - app.get(["/v1/models", "/v1beta/models"], (req, res) => { - const providers = ["google", "google-gemini-cli", "google-vertex"] as const; - const allModels = providers.flatMap((p) => getModels(p as any)); - - const formatted = allModels.map((m) => ({ - id: m.id, - object: "model", - created: 1677610602, - owned_by: "google", - name: m.name, - capabilities: m.capabilities, - })); - - if (req.path.startsWith("/v1beta")) { - res.json({ models: formatted }); - } else { - res.json({ data: formatted, object: "list" }); - } - }); - - app.post("/v1beta/models/:modelPath", async (req, res) => { - if (!oauth) { - return res.status(401).json({ error: "Not authenticated. Visit /login first." }); - } - - const params = req.params as Record; - const modelPath = params.modelPath ?? ""; - const modelId = modelPath.replace(/:streamGenerateContent$/, ""); - const body = req.body as JsonRecord; - const contents = body.contents; - const systemInstruction = body.systemInstruction as JsonRecord | undefined; - const generationConfig = body.generationConfig as JsonRecord | undefined; - - try { - const model = buildGeminiCliModel(modelId); - const context: Context = { - messages: normalizeGoogleContents(contents), - systemPrompt: typeof systemInstruction?.parts === "object" - ? ((systemInstruction.parts as GooglePart[] | undefined)?.[0]?.text) - : undefined, - }; - const options = buildOptions(generationConfig, oauth); - const stream = streamGoogleGeminiCli(model, context, options); - - res.setHeader("Content-Type", "application/json"); - for await (const event of stream) { - if (event.type === "text_delta") { - res.write(JSON.stringify({ - candidates: [{ content: { parts: [{ text: event.delta }] } }], - }) + "\n"); - } else if (event.type === "error") { - onLog(`Stream error: ${event.error.errorMessage}`); - if (!res.headersSent) { - res.status(500).json({ error: event.error.errorMessage }); - } - return; - } - } - res.end(); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - onLog(`Proxy error: ${message}`); - res.status(500).json({ error: message }); - } - }); - - app.post("/v1/chat/completions", async (req, res) => { - if (!oauth) { - return res.status(401).json({ error: "Not authenticated. Visit /login first." }); - } - - const body = req.body as JsonRecord; - const modelId = typeof body.model === "string" ? body.model : "gemini-2.5-flash"; - const isStreaming = body.stream === true; - const temperature = typeof body.temperature === "number" ? body.temperature : undefined; - const maxTokens = typeof body.max_tokens === "number" ? body.max_tokens : undefined; - const normalized = normalizeOpenAiMessages(body.messages); - - try { - const model = buildGeminiCliModel(modelId); - const context: Context = { - messages: normalized.messages, - systemPrompt: normalized.systemPrompt, - }; - const options: GoogleGeminiCliOptions = { - apiKey: JSON.stringify(oauth), - temperature, - maxTokens, - }; - const stream = streamGoogleGeminiCli(model, context, options); - - if (isStreaming) { - res.setHeader("Content-Type", "text/event-stream"); - res.setHeader("Cache-Control", "no-cache"); - res.setHeader("Connection", "keep-alive"); - - for await (const event of stream) { - if (event.type === "text_delta") { - const chunk = { - id: `chatcmpl-${Date.now()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: modelId, - choices: [{ - index: 0, - delta: { content: event.delta }, - finish_reason: null, - }], - }; - res.write(`data: ${JSON.stringify(chunk)}\n\n`); - } else if (event.type === "error") { - onLog(`OpenAI stream error: ${event.error.errorMessage}`); - if (!res.headersSent) { - res.status(500).json({ error: event.error.errorMessage }); - } - return; - } - } - - res.write("data: [DONE]\n\n"); - res.end(); - return; - } - - let fullContent = ""; - for await (const event of stream) { - if (event.type === "text_delta") { - fullContent += event.delta; - } else if (event.type === "error") { - onLog(`OpenAI stream error: ${event.error.errorMessage}`); - res.status(500).json({ error: event.error.errorMessage }); - return; - } - } - - res.json({ - id: `chatcmpl-${Date.now()}`, - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model: modelId, - choices: [{ - index: 0, - message: { role: "assistant", content: fullContent }, - finish_reason: "stop", - }], - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - onLog(`OpenAI proxy error: ${message}`); - res.status(500).json({ error: message }); - } - }); - - await new Promise((resolve) => { - server = app.listen(port, () => { - onLog(`GenAI Proxy Server running on http://localhost:${port}`); - resolve(); - }); - }); +export async function startProxy(_port: number, _onLog: (msg: string) => void): Promise { + throw new Error(GENAI_PROXY_DISABLED_ERROR_MESSAGE); } export function stopProxy(): void { - if (server) { - server.close(); - server = null; - } + running = false; } diff --git a/src/resources/extensions/prompt-history/index.ts b/src/resources/extensions/prompt-history/index.ts new file mode 100644 index 000000000..9c1c3f1da --- /dev/null +++ b/src/resources/extensions/prompt-history/index.ts @@ -0,0 +1,228 @@ +/** + * Prompt History Extension — stash and recall prompts + * + * Features: + * - Automatically stashes every prompt sent to the agent + * - Ctrl+Alt+H opens the stash overlay + * - Navigate with ↑/↓ or j/k, Enter to insert, Esc to cancel + */ + +import type { ExtensionAPI, ExtensionContext, Theme } from "@sf-run/pi-coding-agent"; +import { truncateToWidth, visibleWidth, matchesKey, Key } from "@sf-run/pi-tui"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { homedir } from "node:os"; + +const STASH_LIMIT = 20; + +interface StashData { + version: number; + history: string[]; +} + +function getStashPath(): string { + return join(homedir(), ".sf", "agent", "prompt-history.json"); +} + +function readStash(): string[] { + const path = getStashPath(); + try { + if (!existsSync(path)) return []; + const data = JSON.parse(readFileSync(path, "utf-8")) as StashData; + if (!data || !Array.isArray(data.history)) return []; + return data.history.filter((h): h is string => typeof h === "string" && h.trim().length > 0); + } catch { + return []; + } +} + +function writeStash(history: string[]): void { + const path = getStashPath(); + try { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync( + path, + JSON.stringify({ version: 1, history: history.slice(0, STASH_LIMIT) }, null, 2) + "\n", + "utf-8" + ); + } catch { /* non-fatal */ } +} + +function pushStash(history: string[], text: string): void { + const trimmed = text.trim(); + if (!trimmed) return; + if (history[0] === trimmed) return; + history.unshift(trimmed); + if (history.length > STASH_LIMIT) { + history.length = STASH_LIMIT; + } +} + +function buildPreview(text: string, maxWidth: number): string { + const compact = text.replace(/\s+/g, " ").trim(); + if (!compact) return "(empty)"; + return truncateToWidth(compact, maxWidth, "…"); +} + +class PromptHistoryOverlay { + private tui: { requestRender: () => void }; + private theme: Theme; + private onClose: (selected: string | null) => void; + private items: string[]; + private selected = 0; + private cachedWidth = 0; + private cachedLines: string[] = []; + + constructor( + tui: { requestRender: () => void }, + theme: Theme, + items: string[], + onClose: (selected: string | null) => void + ) { + this.tui = tui; + this.theme = theme; + this.items = items; + this.onClose = onClose; + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) { + this.onClose(null); + return; + } + if (matchesKey(data, Key.return) || matchesKey(data, Key.enter)) { + this.onClose(this.items[this.selected] ?? null); + return; + } + if (matchesKey(data, Key.down) || data === "j") { + this.selected = Math.min(this.items.length - 1, this.selected + 1); + this.invalidate(); + this.tui.requestRender(); + return; + } + if (matchesKey(data, Key.up) || data === "k") { + this.selected = Math.max(0, this.selected - 1); + this.invalidate(); + this.tui.requestRender(); + return; + } + } + + invalidate(): void { + this.cachedWidth = 0; + } + + render(width: number): string[] { + if (this.cachedWidth === width) { + return this.cachedLines; + } + + const th = this.theme; + const boxWidth = Math.min(80, width - 4); + const innerWidth = boxWidth - 4; + + const padLine = (line: string): string => { + const len = visibleWidth(line); + return line + " ".repeat(Math.max(0, width - len)); + }; + + const boxLine = (content: string): string => { + const len = visibleWidth(content); + const padding = Math.max(0, boxWidth - 2 - len); + return th.fg("dim", "│ ") + content + " ".repeat(padding) + th.fg("dim", " │"); + }; + + const lines: string[] = []; + lines.push(""); + lines.push(padLine(th.fg("dim", "╭" + "─".repeat(boxWidth) + "╮"))); + lines.push(padLine(boxLine(th.bold(th.fg("accent", "📜 Prompt History"))))); + lines.push(padLine(th.fg("dim", "├" + "─".repeat(boxWidth) + "┤"))); + lines.push(padLine(boxLine(th.fg("dim", "↑/jk navigate • Enter insert • Esc cancel")))); + lines.push(padLine(boxLine(""))); + + for (let i = 0; i < this.items.length; i++) { + const item = this.items[i]!; + const preview = buildPreview(item, innerWidth - 6); + const pointer = i === this.selected ? th.fg("accent", "❯ ") : " "; + const num = i < 9 ? th.fg("dim", `${i + 1}`) : " "; + const label = i === this.selected ? th.fg("accent", preview) : preview; + lines.push(padLine(boxLine(`${pointer}${num}. ${label}`))); + } + + lines.push(padLine(boxLine(""))); + lines.push(padLine(th.fg("dim", "├" + "─".repeat(boxWidth) + "┤"))); + lines.push(padLine(boxLine(th.fg("dim", `${this.items.length} stashed prompts`)))); + lines.push(padLine(th.fg("dim", "╰" + "─".repeat(boxWidth) + "╯"))); + lines.push(""); + + this.cachedLines = lines; + this.cachedWidth = width; + return lines; + } +} + +async function openPromptHistoryOverlay(ctx: ExtensionContext): Promise { + if (!ctx.hasUI) { + ctx.ui.notify("Prompt history requires interactive mode", "error"); + return; + } + + const items = readStash(); + if (items.length === 0) { + ctx.ui.notify("No stashed prompts yet. Send a message to build history.", "info"); + return; + } + + const selected = await ctx.ui.custom( + (tui, theme, _kb, done) => { + const overlay = new PromptHistoryOverlay(tui, theme, items, (sel) => done(sel)); + return { + render: (w) => overlay.render(w), + invalidate: () => overlay.invalidate(), + handleInput: (data) => overlay.handleInput(data), + }; + }, + { + overlay: true, + overlayOptions: { + width: "90%", + minWidth: 60, + maxHeight: "80%", + anchor: "center", + backdrop: true, + }, + } + ); + + if (selected) { + ctx.ui.setEditorText(selected); + ctx.ui.notify("Inserted prompt from history", "info"); + } +} + +export default function promptHistory(pi: ExtensionAPI) { + const stash = readStash(); + + pi.on("session_start", async (_event, ctx) => { + if (!ctx.hasUI) return; + + pi.registerShortcut(Key.ctrlAlt("h"), { + description: "Open prompt history stash", + handler: openPromptHistoryOverlay, + }); + + // Fallback for terminals where Ctrl+Alt chords are not forwarded + pi.registerShortcut(Key.ctrlShift("h"), { + description: "Open prompt history stash (fallback)", + handler: openPromptHistoryOverlay, + }); + }); + + pi.on("before_agent_start", async (event) => { + const prompt = event.prompt?.trim(); + if (prompt) { + pushStash(stash, prompt); + writeStash(stash); + } + }); +}