Files

1 line
78 KiB
JSON

{"repo": "chen-yingfa/jiading", "files": {"src/server.ts": {"37b2abb9f2edf1db719b464781f63783e0854ef6": {"t": 1785745575.6289425, "n": 26581, "h": "5272df73034f9834", "c": "import crypto from \"node:crypto\";\nimport fs from \"node:fs/promises\";\nimport http from \"node:http\";\nimport os from \"node:os\";\nimport path from \"node:path\";\nimport cookieParser from \"cookie-parser\";\nimport express from \"express\";\nimport rateLimit from \"express-rate-limit\";\nimport helmet from \"helmet\";\nimport { WebSocketServer, type WebSocket } from \"ws\";\nimport { z } from \"zod\";\nimport { AgentRunner, type AgentSessionUpdate, type AgentStreamEvent } from \"./agentRunner.js\";\nimport { config } from \"./config.js\";\nimport { ConversationStore, type Conversation, type StoredMessage, type StoredStreamEvent } from \"./conversationStore.js\";\nimport { SessionStore } from \"./sessionStore.js\";\nimport { isAgentProvider, normalizeAgentProvider, type AgentProvider } from \"./types/agentProvider.js\";\n\nconst COOKIE_NAME = \"agent_session\";\nconst SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 30;\nconst MAX_IMAGE_BYTES = 8 * 1024 * 1024;\nconst IMAGE_EXTENSIONS = new Set([\".jpg\", \".jpeg\", \".png\", \".gif\", \".webp\"]);\nconst CURSOR_MODEL_CODEX_53_HIGH = \"gpt-5.3-codex-high\";\nconst SUPPORTED_CURSOR_MODELS = [{ id: CURSOR_MODEL_CODEX_53_HIGH, label: \"Codex 5.3 High\" }] as const;\n\nconst messageSchema = z.discriminatedUnion(\"type\", [\n z.object({\n type: z.literal(\"prompt\"),\n prompt: z.string().trim().min(1).max(12000),\n attachments: z\n .array(\n z.object({\n name: z.string().min(1).max(180),\n mimeType: z.enum([\"image/jpeg\", \"image/png\", \"image/gif\", \"image/webp\"]),\n data: z.string().min(1),\n }),\n )\n .max(4)\n .default([]),\n }),\n z.object({\n type: z.literal(\"reset\"),\n }),\n z.object({\n type: z.literal(\"new-chat\"),\n workspace: z.string().trim().min(1).max(4096).optional(),\n agentProvider: z.enum([\"cursor\", \"claude-code\"]).optional(),\n }),\n]);\n\nconst app = express();\nconst server = http.createServer(app);\nconst sessionStore = new SessionStore(path.join(config.DATA_DIR, \"session.json\"));\nconst conversationStore = new ConversationStore(path.join(config.DATA_DIR, \"conversations\"));\nconst agentRunner = new AgentRunner(sessionStore);\n\nasync function bootstrap(): Promise<void> {\n await conversationStore.init();\n await migrateLegacySession();\n}\n\napp.set(\"trust proxy\", 1);\napp.disable(\"x-powered-by\");\napp.use(\n helmet({\n contentSecurityPolicy: {\n directives: {\n defaultSrc: [\"'self'\"],\n connectSrc: [\"'self'\", \"ws:\", \"wss:\"],\n scriptSrc: [\"'self'\"],\n styleSrc: [\"'self'\"],\n imgSrc: [\"'self'\", \"data:\", \"https:\"],\n },\n },\n }),\n);\napp.use(express.json({ limit: \"40mb\" }));\napp.use(cookieParser());\napp.use((req, res, next) => {\n if (\n req.path.startsWith(\"/api/\") &&\n req.path !== \"/api/login\" &&\n req.path !== \"/api/health\" &&\n isRequestAuthenticated(req.cookies?.[COOKIE_NAME])\n ) {\n res.cookie(COOKIE_NAME, createSessionCookie(), cookieOptions(req));\n }\n\n next();\n});\n\nconst authLimiter = rateLimit({\n windowMs: 15 * 60 * 1000,\n limit: 20,\n standardHeaders: true,\n legacyHeaders: false,\n});\n\napp.get(\"/api/health\", (_req, res) => {\n res.json({ ok: true });\n});\n\napp.get(\"/api/config\", requireAuth, (_req, res) => {\n res.json({\n defaultWorkspace: config.AGENT_WORKSPACE,\n defaultAgentProvider: config.DEFAULT_AGENT_PROVIDER,\n agentProviders: [\n { id: \"cursor\", label: \"Cursor Agent\" },\n { id: \"claude-code\", label: \"Claude Code (DeepSeek)\" },\n ],\n claudeCodeAvailable: Boolean(config.DEEPSEEK_API_KEY),\n cursorModels: SUPPORTED_CURSOR_MODELS,\n });\n});\n\napp.get(\"/api/workspace/browse\", requireAuth, async (req, res, next) => {\n try {\n const inputPath = typeof req.query.path === \"string\" ? req.query.path : undefined;\n res.json(await browseWorkspace(inputPath));\n } catch (error) {\n next(error);\n }\n});\n\napp.get(\"/api/me\", (req, res) => {\n res.json({ authenticated: isRequestAuthenticated(req.cookies?.[COOKIE_NAME]) });\n});\n\napp.post(\"/api/login\", authLimiter, async (req, res, next) => {\n const password = typeof req.body?.password === \"string\" ? req.body.password : \"\";\n\n if (!isPasswordValid(password)) {\n res.status(401).json({ error: \"Invalid password\" });\n return;\n }\n\n try {\n res.cookie(COOKIE_NAME, createSessionCookie(), cookieOptions(req));\n const conversation = await ensureActiveConversation();\n res.json({\n ok: true,\n session: toSessionPayload(conversation),\n messages: toClientMessages(conversation?.messages ?? []),\n });\n } catch (error) {\n next(error);\n }\n});\n\napp.post(\"/api/logout\", requireAuth, (req, res) => {\n res.clearCookie(COOKIE_NAME, cookieOptions(req));\n res.json({ ok: true });\n});\n\napp.get(\"/api/session\", requireAuth, async (_req, res, next) => {\n try {\n const conversation = await ensureActiveConversation();\n res.json({\n session: toSessionPayload(conversation),\n messages: toClientMessages(conversation?.messages ?? []),\n });\n } catch (error) {\n next(error);\n }\n});\n\napp.get(\"/api/conversations\", requireAuth, async (_req, res, next) => {\n try {\n const conversation = await ensureActiveConversation();\n const conversations = await conversationStore.list();\n res.json({\n conversations,\n activeConversationId: conversation?.id ?? null,\n session: toSessionPayload(conversation),\n messages: toClientMessages(conversation?.messages ?? []),\n });\n } catch (error) {\n next(error);\n }\n});\n\napp.get(\"/api/conversations/:id\", requireAuth, async (req, res, next) => {\n try {\n const conversation = await conversationStore.get(String(req.params.id));\n\n if (!conversation) {\n res.status(404).json({ error: \"Conversation not found\" });\n return;\n }\n\n res.json({ conversation });\n } catch (error) {\n next(error);\n }\n});\n\napp.post(\"/api/conversations\", requireAuth, async (req, res, next) => {\n try {\n const workspaceInput = typeof req.body?.workspace === \"string\" ? req.body.workspace : undefined;\n const agentProvider = parseAgentProvider(req.body?.agentProvider);\n const workspace = await resolveWorkspacePath(workspaceInput);\n const conversation = await startNewConversation(workspace, agentProvider);\n res.json({ conversation, session: toSessionPayload(conversation) });\n } catch (error) {\n next(error);\n }\n});\n\napp.patch(\"/api/conversations/:id\", requireAuth, async (req, res, next) => {\n try {\n const title = typeof req.body?.title === \"string\" ? req.body.title.trim() : \"\";\n\n if (!title) {\n res.status(400).json({ error: \"Title is required\" });\n return;\n }\n\n if (title.length > 120) {\n res.status(400).json({ error: \"Title must be 120 characters or fewer\" });\n return;\n }\n\n const conversation = await conversationStore.updateMeta(String(req.params.id), { title });\n\n if (!conversation) {\n res.status(404).json({ error: \"Conversation not found\" });\n return;\n }\n\n res.json({ conversation, session: toSessionPayload(conversation) });\n } catch (error) {\n next(error);\n }\n});\n\napp.post(\"/api/conversations/:id/model\", requireAuth, async (req, res, next) => {\n try {\n const model = typeof req.body?.model === \"string\" ? req.body.model.trim() : \"\";\n\n if (!model) {\n res.status(400).json({ error: \"Model is required\" });\n return;\n }\n\n if (!SUPPORTED_CURSOR_MODELS.some((entry) => entry.id === model)) {\n res.status(400).json({ error: \"Unsupported model\" });\n return;\n }\n\n const current = await conversationStore.get(String(req.params.id));\n\n if (!current) {\n res.status(404).json({ error: \"Conversation not found\" });\n return;\n }\n\n if (current.agentProvider !== \"cursor\") {\n res.status(400).json({ error: \"Model switching is only available for Cursor sessions\" });\n return;\n }\n\n const conversation = await conversationStore.updateMeta(String(req.params.id), { model });\n\n if (!conversation) {\n res.status(404).json({ error: \"Conversation not found\" });\n return;\n }\n\n res.json({ conversation, session: toSessionPayload(conversation) });\n } catch (error) {\n next(error);\n }\n});\n\napp.post(\"/api/conversations/:id/activate\", requireAuth, async (req, res, next) => {\n try {\n const conversation = await conversationStore.get(String(req.params.id));\n\n if (!conversation) {\n res.status(404).json({ error: \"Conversation not found\" });\n return;\n }\n\n await conversationStore.setActive(String(conversation.id));\n await agentRunner.useChatId(conversation.chatId);\n await agentRunner.setActiveConversation(conversation.id);\n res.json({ conversation, session: toSessionPayload(conversation) });\n } catch (error) {\n next(error);\n }\n});\n\napp.post(\"/api/reset\", requireAuth, async (_req, res, next) => {\n try {\n const conversation = await startNewConversation();\n res.json({ ok: true, conversation, session: toSessionPayload(conversation) });\n } catch (error) {\n next(error);\n }\n});\n\napp.get(\"/api/image\", requireAuth, async (req, res, next) => {\n try {\n const imagePath = typeof req.query.path === \"string\" ? req.query.path : \"\";\n const resolved = resolveAllowedImagePath(imagePath);\n\n if (!resolved) {\n res.status(404).end();\n return;\n }\n\n res.sendFile(resolved);\n } catch (error) {\n next(error);\n }\n});\n\napp.use(\"/vendor\", express.static(path.join(config.rootDir, \"node_modules\")));\napp.use(express.static(config.publicDir, { extensions: [\"html\"] }));\n\napp.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {\n const message = error instanceof Error ? error.message : \"Unexpected server error\";\n res.status(500).json({ error: message });\n});\n\nconst wss = new WebSocketServer({ noServer: true });\n\nserver.on(\"upgrade\", (request, socket, head) => {\n if (request.url !== \"/ws\" || !isRequestAuthenticated(request.headers.cookie)) {\n socket.write(\"HTTP/1.1 401 Unauthorized\\r\\n\\r\\n\");\n socket.destroy();\n return;\n }\n\n wss.handleUpgrade(request, socket, head, (ws) => {\n wss.emit(\"connection\", ws, request);\n });\n});\n\nwss.on(\"connection\", async (ws) => {\n try {\n const conversation = await ensureActiveConversation();\n send(ws, {\n type: \"ready\",\n session: toSessionPayload(conversation),\n messages: toClientMessages(conversation?.messages ?? []),\n });\n } catch {\n send(ws, { type: \"ready\", session: toSessionPayload(null), messages: [] });\n }\n\n ws.on(\"message\", async (raw) => {\n const parsed = messageSchema.safeParse(parseJson(raw.toString()));\n\n if (!parsed.success) {\n send(ws, { type: \"error\", error: \"Invalid message\" });\n return;\n }\n\n if (parsed.data.type === \"reset\") {\n const conversation = await startNewConversation(config.AGENT_WORKSPACE, config.DEFAULT_AGENT_PROVIDER);\n send(ws, { type: \"new-chat\", session: toSessionPayload(conversation), messages: [] });\n return;\n }\n\n if (parsed.data.type === \"new-chat\") {\n try {\n const workspace = await resolveWorkspacePath(parsed.data.workspace);\n const agentProvider = parseAgentProvider(parsed.data.agentProvider);\n const conversation = await startNewConversation(workspace, agentProvider);\n send(ws, { type: \"new-chat\", session: toSessionPayload(conversation), messages: [] });\n } catch (error) {\n const message = error instanceof Error ? error.message : \"Could not start new chat\";\n send(ws, { type: \"error\", error: message });\n }\n\n return;\n }\n\n send(ws, { type: \"start\" });\n\n try {\n const conversation = await ensureConversationForPrompt();\n const attachments = await saveAttachments(parsed.data.attachments);\n\n await conversationStore.appendMessage(conversation.id, {\n role: \"user\",\n text: parsed.data.prompt,\n imagePaths: attachments.map((attachment) => attachment.path),\n });\n\n let agentReply = \"\";\n const streamEvents: StoredStreamEvent[] = [];\n const streamEventIndexByCallId = new Map<string, number>();\n\n await agentRunner.prompt(\n parsed.data.prompt,\n {\n onText: (text) => {\n agentReply += text;\n send(ws, { type: \"delta\", text });\n },\n onStreamEvent: (event) => {\n send(ws, { type: \"stream-event\", event });\n collectStreamEvent(streamEvents, streamEventIndexByCallId, event);\n },\n onSessionUpdate: (update) => {\n void applySessionUpdate(conversation.id, update).then((updated) => {\n if (updated) {\n send(ws, { type: \"session-update\", session: toSessionPayload(updated) });\n }\n });\n },\n onEvent: (event) => {\n if (process.env.DEBUG_AGENT_EVENTS === \"true\") {\n send(ws, { type: \"event\", event });\n }\n },\n },\n attachments,\n conversation.chatId,\n conversation.workspace,\n conversation.agentProvider,\n conversation.model,\n );\n\n const updated = await conversationStore.appendMessage(conversation.id, {\n role: \"agent\",\n text: agentReply,\n events: streamEvents.length > 0 ? streamEvents : undefined,\n });\n\n send(ws, { type: \"done\", session: toSessionPayload(updated) });\n } catch (error) {\n const message = error instanceof Error ? error.message : \"Agent request failed\";\n send(ws, { type: \"error\", error: message });\n }\n });\n});\n\nbootstrap()\n .then(() => {\n server.listen(config.PORT, config.HOST, () => {\n console.log(`Agent phone bridge listening on http://${config.HOST}:${config.PORT}`);\n });\n })\n .catch((error) => {\n console.error(error);\n process.exit(1);\n });\n\nfunction requireAuth(req: express.Request, res: express.Response, next: express.NextFunction): void {\n if (!isRequestAuthenticated(req.cookies?.[COOKIE_NAME])) {\n res.status(401).json({ error: \"Unauthorized\" });\n return;\n }\n\n next();\n}\n\nfunction isPasswordValid(candidate: string): boolean {\n const actualHash = crypto.createHash(\"sha256\").update(config.APP_PASSWORD).digest();\n const candidateHash = crypto.createHash(\"sha256\").update(candidate).digest();\n return crypto.timingSafeEqual(actualHash, candidateHash);\n}\n\nfunction createSessionCookie(): string {\n const payload = Buffer.from(\n JSON.stringify({\n exp: Date.now() + SESSION_TTL_MS,\n nonce: crypto.randomBytes(16).toString(\"hex\"),\n }),\n \"utf8\",\n ).toString(\"base64url\");\n const signature = sign(payload);\n\n return `${payload}.${signature}`;\n}\n\nfunction isRequestAuthenticated(rawCookie: string | undefined): boolean {\n const token = rawCookie?.includes(\"=\") ? parseCookie(rawCookie)[COOKIE_NAME] : rawCookie;\n\n if (!token) {\n return false;\n }\n\n const [payload, signature] = token.split(\".\");\n\n if (!payload || !signature || !safeEqual(signature, sign(payload))) {\n return false;\n }\n\n try {\n const decoded = JSON.parse(Buffer.from(payload, \"base64url\").toString(\"utf8\")) as { exp?: unknown };\n return typeof decoded.exp === \"number\" && decoded.exp > Date.now();\n } catch {\n return false;\n }\n}\n\nfunction sign(payload: string): string {\n return crypto.createHmac(\"sha256\", config.SESSION_SECRET).update(payload).digest(\"base64url\");\n}\n\nfunction safeEqual(a: string, b: string): boolean {\n const aBuffer = Buffer.from(a);\n const bBuffer = Buffer.from(b);\n\n if (aBuffer.length !== bBuffer.length) {\n return false;\n }\n\n return crypto.timingSafeEqual(aBuffer, bBuffer);\n}\n\nfunction parseCookie(header: string): Record<string, string> {\n return Object.fromEntries(\n header.split(\";\").map((part) => {\n const [key, ...value] = part.trim().split(\"=\");\n return [key, decodeURIComponent(value.join(\"=\"))];\n }),\n );\n}\n\nfunction parseJson(raw: string): unknown {\n try {\n return JSON.parse(raw);\n } catch {\n return null;\n }\n}\n\nfunction send(ws: WebSocket, payload: unknown): void {\n if (ws.readyState === ws.OPEN) {\n ws.send(JSON.stringify(payload));\n }\n}\n\nasync function migrateLegacySession(): Promise<void> {\n await ensureActiveConversation();\n}\n\nasync function ensureActiveConversation(): Promise<Conversation | null> {\n let active = await conversationStore.getActive();\n\n if (active) {\n await agentRunner.useChatId(active.chatId);\n await agentRunner.setActiveConversation(active.id);\n return active;\n }\n\n const session = await sessionStore.read();\n\n if (session.chatId) {\n const conversation = await conversationStore.adoptLegacyChat(session.chatId);\n await agentRunner.useChatId(conversation.chatId);\n await agentRunner.setActiveConversation(conversation.id);\n return conversation;\n }\n\n const conversations = await conversationStore.list();\n\n if (conversations.length === 0) {\n return null;\n }\n\n const conversation = await conversationStore.get(conversations[0].id);\n\n if (!conversation) {\n return null;\n }\n\n await conversationStore.setActive(conversation.id);\n await agentRunner.useChatId(conversation.chatId);\n await agentRunner.setActiveConversation(conversation.id);\n return conversation;\n}\n\nfunction isSecureRequest(req: express.Request): boolean {\n if (config.isProduction) {\n return true;\n }\n\n const forwarded = req.headers[\"x-forwarded-proto\"];\n\n if (typeof forwarded === \"string\" && forwarded.split(\",\")[0]?.trim() === \"https\") {\n return true;\n }\n\n return req.secure;\n}\n\nfunction cookieOptions(req: express.Request) {\n return {\n httpOnly: true,\n sameSite: \"lax\" as const,\n secure: isSecureRequest(req),\n maxAge: SESSION_TTL_MS,\n path: \"/\",\n };\n}\n\nasync function ensureConversationForPrompt(): Promise<Conversation> {\n const active = await conversationStore.getActive();\n\n if (active) {\n await agentRunner.useChatId(active.chatId);\n await agentRunner.setActiveConversation(active.id);\n return active;\n }\n\n const session = await sessionStore.read();\n\n if (session.chatId) {\n const conversation = await conversationStore.adoptLegacyChat(session.chatId);\n await agentRunner.useChatId(conversation.chatId);\n await agentRunner.setActiveConversation(conversation.id);\n return conversation;\n }\n\n return startNewConversation();\n}\n\nasync function startNewConversation(\n workspace = config.AGENT_WORKSPACE,\n agentProvider: AgentProvider = config.DEFAULT_AGENT_PROVIDER,\n): Promise<Conversation> {\n const chatId = await agentRunner.createChat(workspace, agentProvider);\n const conversation = await conversationStore.create(chatId, { workspace, agentProvider });\n await agentRunner.useChatId(chatId);\n await agentRunner.setActiveConversation(conversation.id);\n return conversation;\n}\n\nfunction parseAgentProvider(value: unknown): AgentProvider {\n if (isAgentProvider(value)) {\n if (value === \"claude-code\" && !config.DEEPSEEK_API_KEY) {\n throw new Error(\"Claude Code is not configured. Set DEEPSEEK_API_KEY in .env.\");\n }\n\n return value;\n }\n\n return config.DEFAULT_AGENT_PROVIDER;\n}\n\nasync function resolveWorkspacePath(input?: string): Promise<string> {\n const raw = (input?.trim() || config.AGENT_WORKSPACE).replace(/\\//g, path.sep);\n const resolved = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(config.AGENT_WORKSPACE, raw);\n\n try {\n const stat = await fs.stat(resolved);\n\n if (!stat.isDirectory()) {\n throw new Error(\"Workspace path must be a directory\");\n }\n } catch (error) {\n if (error instanceof Error && error.message === \"Workspace path must be a directory\") {\n throw error;\n }\n\n throw new Error(`Workspace path does not exist: ${resolved}`);\n }\n\n return resolved;\n}\n\ninterface WorkspaceBrowseEntry {\n name: string;\n path: string;\n type: \"drive\" | \"directory\";\n}\n\nasync function browseWorkspace(inputPath?: string): Promise<{\n path: string | null;\n parent: string | null;\n entries: WorkspaceBrowseEntry[];\n}> {\n if (!inputPath?.trim()) {\n return {\n path: null,\n parent: null,\n entries: await listWorkspaceRoots(),\n };\n }\n\n const resolved = await resolveWorkspacePath(inputPath);\n const parent = workspaceParentPath(resolved);\n\n return {\n path: resolved,\n parent,\n entries: await listWorkspaceDirectories(resolved),\n };\n}\n\nasync function listWorkspaceRoots(): Promise<WorkspaceBrowseEntry[]> {\n const roots: WorkspaceBrowseEntry[] = [];\n\n if (process.platform === \"win32\") {\n for (const letter of \"ABCDEFGHIJKLMNOPQRSTUVWXYZ\") {\n const drive = `${letter}:\\\\`;\n\n try {\n const stat = await fs.stat(drive);\n\n if (stat.isDirectory()) {\n roots.push({ name: `${letter}:`, path: drive, type: \"drive\" });\n }\n } catch {\n // Ignore unavailable drives.\n }\n }\n\n return roots;\n }\n\n const home = os.homedir();\n roots.push({ name: \"Home\", path: home, type: \"directory\" });\n roots.push({ name: \"Root\", path: path.parse(home).root, type: \"directory\" });\n\n const defaultWorkspace = path.resolve(config.AGENT_WORKSPACE);\n roots.push({\n name: path.basename(defaultWorkspace) || \"Workspace\",\n path: defaultWorkspace,\n type: \"directory\",\n });\n\n return roots;\n}\n\nasync function listWorkspaceDirectories(dir: string): Promise<WorkspaceBrowseEntry[]> {\n let entries;\n\n try {\n entries = await fs.readdir(dir, { withFileTypes: true });\n } catch {\n return [];\n }\n\n return entries\n .filter((entry) => entry.isDirectory() && !entry.name.startsWith(\".\"))\n .map((entry) => ({\n name: entry.name,\n path: path.join(dir, entry.name),\n type: \"directory\" as const,\n }))\n .sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: \"base\" }));\n}\n\nfunction workspaceParentPath(current: string): string | null {\n const parent = path.dirname(current);\n\n if (parent === current) {\n return null;\n }\n\n return parent;\n}\n\nasync function applySessionUpdate(\n conversationId: string,\n update: AgentSessionUpdate,\n): Promise<Conversation | null> {\n const conversation = await conversationStore.get(conversationId);\n\n if (!conversation) {\n return null;\n }\n\n return conversationStore.updateMeta(conversationId, {\n model: update.model ?? conversation.model,\n workspace: update.workspace ?? conversation.workspace,\n chatId: update.chatId ?? conversation.chatId,\n contextUsagePercent:\n update.contextUsagePercent === undefined\n ? conversation.contextUsagePercent\n : update.contextUsagePercent,\n });\n}\n\nfunction toSessionPayload(conversation: Conversation | null) {\n const agentProvider = normalizeAgentProvider(conversation?.agentProvider, config.DEFAULT_AGENT_PROVIDER);\n\n return {\n conversationId: conversation?.id ?? null,\n title: conversation?.title ?? \"New chat\",\n model: conversation?.model ?? (agentProvider === \"claude-code\" ? config.CLAUDE_CODE_MODEL : config.AGENT_MODEL ?? \"auto\"),\n workspace: conversation?.workspace ?? config.AGENT_WORKSPACE,\n agentProvider,\n contextUsagePercent: conversation?.contextUsagePercent ?? null,\n };\n}\n\nfunction toClientMessages(messages: StoredMessage[]) {\n return messages.map((message) => ({\n role: message.role,\n text: message.text,\n imagePaths: message.imagePaths ?? [],\n events: message.events ?? [],\n createdAt: message.createdAt,\n }));\n}\n\nfunction toStoredStreamEvent(event: AgentStreamEvent): StoredStreamEvent {\n return {\n kind: event.kind,\n title: event.title,\n text: event.text,\n callId: event.callId,\n subtype: event.subtype,\n toolName: event.toolName,\n };\n}\n\nfunction collectStreamEvent(\n events: StoredStreamEvent[],\n indexByCallId: Map<string, number>,\n event: AgentStreamEvent,\n): void {\n const rawCallId =\n event.callId ??\n (event.raw && typeof event.raw === \"object\" && !Array.isArray(event.raw)\n ? (event.raw as Record<string, unknown>).call_id\n : undefined);\n const callId = typeof rawCallId === \"string\" && rawCallId.length > 0 ? rawCallId : undefined;\n const stored = toStoredStreamEvent(event);\n\n if (callId && indexByCallId.has(callId)) {\n events[indexByCallId.get(callId)!] = stored;\n return;\n }\n\n if (callId) {\n indexByCallId.set(callId, events.length);\n }\n\n events.push(stored);\n}\n\nasync function saveAttachments(\n attachments: Array<{ name: string; mimeType: \"image/jpeg\" | \"image/png\" | \"image/gif\" | \"image/webp\"; data: string }>,\n) {\n await fs.mkdir(config.UPLOAD_DIR, { recursive: true });\n\n return Promise.all(\n attachments.map(async (attachment) => {\n const bytes = decodeDataUrl(attachment.data, attachment.mimeType);\n\n if (bytes.byteLength > MAX_IMAGE_BYTES) {\n throw new Error(`${attachment.name} is larger than ${MAX_IMAGE_BYTES / 1024 / 1024}MB`);\n }\n\n const extension = extensionForMimeType(attachment.mimeType);\n const safeBaseName = attachment.name.replace(/\\.[^.]+$/, \"\").replace(/[^a-zA-Z0-9._-]/g, \"_\").slice(0, 80);\n const fileName = `${new Date().toISOString().replace(/[:.]/g, \"-\")}-${crypto.randomBytes(4).toString(\"hex\")}-${safeBaseName}${extension}`;\n const filePath = path.join(config.UPLOAD_DIR, fileName);\n\n await fs.writeFile(filePath, bytes);\n\n return {\n originalName: attachment.name,\n mimeType: attachment.mimeType,\n path: filePath,\n };\n }),\n );\n}\n\nfunction decodeDataUrl(data: string, expectedMimeType: string): Buffer {\n const match = data.match(/^data:([^;]+);base64,(.+)$/);\n\n if (!match || match[1] !== expectedMimeType) {\n throw new Error(\"Invalid image attachment\");\n }\n\n return Buffer.from(match[2], \"base64\");\n}\n\nfunction extensionForMimeType(mimeType: string): string {\n if (mimeType === \"image/jpeg\") {\n return \".jpg\";\n }\n\n if (mimeType === \"image/png\") {\n return \".png\";\n }\n\n if (mimeType === \"image/gif\") {\n return \".gif\";\n }\n\n return \".webp\";\n}\n\nfunction resolveAllowedImagePath(input: string): string | null {\n if (!input) {\n return null;\n }\n\n const decoded = decodeURIComponent(input);\n const candidate = path.isAbsolute(decoded) ? decoded : path.resolve(config.AGENT_WORKSPACE, decoded);\n const resolved = path.resolve(candidate);\n const extension = path.extname(resolved).toLowerCase();\n\n if (!IMAGE_EXTENSIONS.has(extension)) {\n return null;\n }\n\n const allowedRoots = [config.UPLOAD_DIR, config.AGENT_WORKSPACE, config.OBSIDIAN_VAULT_PATH].map((root) =>\n path.resolve(root).toLowerCase(),\n );\n const normalized = resolved.toLowerCase();\n const isAllowed = allowedRoots.some((root) => normalized === root || normalized.startsWith(`${root}${path.sep}`));\n\n return isAllowed ? resolved : null;\n}\n"}, "3e3ae6ba8aa649e742e47815be5f1b5f22b2644d": {"t": 1785754387.5366437, "n": 25254, "h": "e276f90dcbbd1161", "c": "import crypto from \"node:crypto\";\nimport fs from \"node:fs/promises\";\nimport http from \"node:http\";\nimport os from \"node:os\";\nimport path from \"node:path\";\nimport cookieParser from \"cookie-parser\";\nimport express from \"express\";\nimport rateLimit from \"express-rate-limit\";\nimport helmet from \"helmet\";\nimport { WebSocketServer, type WebSocket } from \"ws\";\nimport { z } from \"zod\";\nimport { AgentRunner, type AgentSessionUpdate, type AgentStreamEvent } from \"./agentRunner.js\";\nimport { config } from \"./config.js\";\nimport { ConversationStore, type Conversation, type StoredMessage, type StoredStreamEvent } from \"./conversationStore.js\";\nimport { SessionStore } from \"./sessionStore.js\";\nimport { isAgentProvider, normalizeAgentProvider, type AgentProvider } from \"./types/agentProvider.js\";\n\nconst COOKIE_NAME = \"agent_session\";\nconst SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 30;\nconst MAX_IMAGE_BYTES = 8 * 1024 * 1024;\nconst IMAGE_EXTENSIONS = new Set([\".jpg\", \".jpeg\", \".png\", \".gif\", \".webp\"]);\n\nconst messageSchema = z.discriminatedUnion(\"type\", [\n z.object({\n type: z.literal(\"prompt\"),\n prompt: z.string().trim().min(1).max(12000),\n attachments: z\n .array(\n z.object({\n name: z.string().min(1).max(180),\n mimeType: z.enum([\"image/jpeg\", \"image/png\", \"image/gif\", \"image/webp\"]),\n data: z.string().min(1),\n }),\n )\n .max(4)\n .default([]),\n }),\n z.object({\n type: z.literal(\"reset\"),\n }),\n z.object({\n type: z.literal(\"new-chat\"),\n workspace: z.string().trim().min(1).max(4096).optional(),\n agentProvider: z.enum([\"cursor\", \"claude-code\"]).optional(),\n }),\n]);\n\nconst app = express();\nconst server = http.createServer(app);\nconst sessionStore = new SessionStore(path.join(config.DATA_DIR, \"session.json\"));\nconst conversationStore = new ConversationStore(path.join(config.DATA_DIR, \"conversations\"));\nconst agentRunner = new AgentRunner(sessionStore);\n\nasync function bootstrap(): Promise<void> {\n await conversationStore.init();\n await migrateLegacySession();\n}\n\napp.set(\"trust proxy\", 1);\napp.disable(\"x-powered-by\");\napp.use(\n helmet({\n contentSecurityPolicy: {\n directives: {\n defaultSrc: [\"'self'\"],\n connectSrc: [\"'self'\", \"ws:\", \"wss:\"],\n scriptSrc: [\"'self'\"],\n styleSrc: [\"'self'\"],\n imgSrc: [\"'self'\", \"data:\", \"https:\"],\n },\n },\n }),\n);\napp.use(express.json({ limit: \"40mb\" }));\napp.use(cookieParser());\napp.use((req, res, next) => {\n if (\n req.path.startsWith(\"/api/\") &&\n req.path !== \"/api/login\" &&\n req.path !== \"/api/health\" &&\n isRequestAuthenticated(req.cookies?.[COOKIE_NAME])\n ) {\n res.cookie(COOKIE_NAME, createSessionCookie(), cookieOptions(req));\n }\n\n next();\n});\n\nconst authLimiter = rateLimit({\n windowMs: 15 * 60 * 1000,\n limit: 20,\n standardHeaders: true,\n legacyHeaders: false,\n});\n\napp.get(\"/api/health\", (_req, res) => {\n res.json({ ok: true });\n});\n\napp.get(\"/api/config\", requireAuth, (_req, res) => {\n res.json({\n defaultWorkspace: config.AGENT_WORKSPACE,\n defaultAgentProvider: config.DEFAULT_AGENT_PROVIDER,\n agentProviders: [\n { id: \"cursor\", label: \"Cursor Agent\" },\n { id: \"claude-code\", label: \"Claude Code (DeepSeek)\" },\n ],\n claudeCodeAvailable: Boolean(config.DEEPSEEK_API_KEY),\n });\n});\n\napp.get(\"/api/workspace/browse\", requireAuth, async (req, res, next) => {\n try {\n const inputPath = typeof req.query.path === \"string\" ? req.query.path : undefined;\n res.json(await browseWorkspace(inputPath));\n } catch (error) {\n next(error);\n }\n});\n\napp.get(\"/api/me\", (req, res) => {\n res.json({ authenticated: isRequestAuthenticated(req.cookies?.[COOKIE_NAME]) });\n});\n\napp.post(\"/api/login\", authLimiter, async (req, res, next) => {\n const password = typeof req.body?.password === \"string\" ? req.body.password : \"\";\n\n if (!isPasswordValid(password)) {\n res.status(401).json({ error: \"Invalid password\" });\n return;\n }\n\n try {\n res.cookie(COOKIE_NAME, createSessionCookie(), cookieOptions(req));\n const conversation = await ensureActiveConversation();\n res.json({\n ok: true,\n session: toSessionPayload(conversation),\n messages: toClientMessages(conversation?.messages ?? []),\n });\n } catch (error) {\n next(error);\n }\n});\n\napp.post(\"/api/logout\", requireAuth, (req, res) => {\n res.clearCookie(COOKIE_NAME, cookieOptions(req));\n res.json({ ok: true });\n});\n\napp.get(\"/api/session\", requireAuth, async (_req, res, next) => {\n try {\n const conversation = await ensureActiveConversation();\n res.json({\n session: toSessionPayload(conversation),\n messages: toClientMessages(conversation?.messages ?? []),\n });\n } catch (error) {\n next(error);\n }\n});\n\napp.get(\"/api/conversations\", requireAuth, async (_req, res, next) => {\n try {\n const conversation = await ensureActiveConversation();\n const conversations = await conversationStore.list();\n res.json({\n conversations,\n activeConversationId: conversation?.id ?? null,\n session: toSessionPayload(conversation),\n messages: toClientMessages(conversation?.messages ?? []),\n });\n } catch (error) {\n next(error);\n }\n});\n\napp.get(\"/api/conversations/:id\", requireAuth, async (req, res, next) => {\n try {\n const conversation = await conversationStore.get(String(req.params.id));\n\n if (!conversation) {\n res.status(404).json({ error: \"Conversation not found\" });\n return;\n }\n\n res.json({ conversation });\n } catch (error) {\n next(error);\n }\n});\n\napp.post(\"/api/conversations\", requireAuth, async (req, res, next) => {\n try {\n const workspaceInput = typeof req.body?.workspace === \"string\" ? req.body.workspace : undefined;\n const agentProvider = parseAgentProvider(req.body?.agentProvider);\n const workspace = await resolveWorkspacePath(workspaceInput);\n const conversation = await startNewConversation(workspace, agentProvider);\n res.json({ conversation, session: toSessionPayload(conversation) });\n } catch (error) {\n next(error);\n }\n});\n\napp.patch(\"/api/conversations/:id\", requireAuth, async (req, res, next) => {\n try {\n const title = typeof req.body?.title === \"string\" ? req.body.title.trim() : \"\";\n\n if (!title) {\n res.status(400).json({ error: \"Title is required\" });\n return;\n }\n\n if (title.length > 120) {\n res.status(400).json({ error: \"Title must be 120 characters or fewer\" });\n return;\n }\n\n const conversation = await conversationStore.updateMeta(String(req.params.id), { title });\n\n if (!conversation) {\n res.status(404).json({ error: \"Conversation not found\" });\n return;\n }\n\n res.json({ conversation, session: toSessionPayload(conversation) });\n } catch (error) {\n next(error);\n }\n});\n\napp.post(\"/api/conversations/:id/activate\", requireAuth, async (req, res, next) => {\n try {\n const conversation = await conversationStore.get(String(req.params.id));\n\n if (!conversation) {\n res.status(404).json({ error: \"Conversation not found\" });\n return;\n }\n\n await conversationStore.setActive(String(conversation.id));\n await agentRunner.useChatId(conversation.chatId);\n await agentRunner.setActiveConversation(conversation.id);\n res.json({ conversation, session: toSessionPayload(conversation) });\n } catch (error) {\n next(error);\n }\n});\n\napp.post(\"/api/reset\", requireAuth, async (_req, res, next) => {\n try {\n const conversation = await startNewConversation();\n res.json({ ok: true, conversation, session: toSessionPayload(conversation) });\n } catch (error) {\n next(error);\n }\n});\n\napp.get(\"/api/image\", requireAuth, async (req, res, next) => {\n try {\n const imagePath = typeof req.query.path === \"string\" ? req.query.path : \"\";\n const resolved = resolveAllowedImagePath(imagePath);\n\n if (!resolved) {\n res.status(404).end();\n return;\n }\n\n res.sendFile(resolved);\n } catch (error) {\n next(error);\n }\n});\n\napp.use(\"/vendor\", express.static(path.join(config.rootDir, \"node_modules\")));\napp.use(express.static(config.publicDir, { extensions: [\"html\"] }));\n\napp.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {\n const message = error instanceof Error ? error.message : \"Unexpected server error\";\n res.status(500).json({ error: message });\n});\n\nconst wss = new WebSocketServer({ noServer: true });\n\nserver.on(\"upgrade\", (request, socket, head) => {\n if (request.url !== \"/ws\" || !isRequestAuthenticated(request.headers.cookie)) {\n socket.write(\"HTTP/1.1 401 Unauthorized\\r\\n\\r\\n\");\n socket.destroy();\n return;\n }\n\n wss.handleUpgrade(request, socket, head, (ws) => {\n wss.emit(\"connection\", ws, request);\n });\n});\n\nwss.on(\"connection\", async (ws) => {\n try {\n const conversation = await ensureActiveConversation();\n send(ws, {\n type: \"ready\",\n session: toSessionPayload(conversation),\n messages: toClientMessages(conversation?.messages ?? []),\n });\n } catch {\n send(ws, { type: \"ready\", session: toSessionPayload(null), messages: [] });\n }\n\n ws.on(\"message\", async (raw) => {\n const parsed = messageSchema.safeParse(parseJson(raw.toString()));\n\n if (!parsed.success) {\n send(ws, { type: \"error\", error: \"Invalid message\" });\n return;\n }\n\n if (parsed.data.type === \"reset\") {\n const conversation = await startNewConversation(config.AGENT_WORKSPACE, config.DEFAULT_AGENT_PROVIDER);\n send(ws, { type: \"new-chat\", session: toSessionPayload(conversation), messages: [] });\n return;\n }\n\n if (parsed.data.type === \"new-chat\") {\n try {\n const workspace = await resolveWorkspacePath(parsed.data.workspace);\n const agentProvider = parseAgentProvider(parsed.data.agentProvider);\n const conversation = await startNewConversation(workspace, agentProvider);\n send(ws, { type: \"new-chat\", session: toSessionPayload(conversation), messages: [] });\n } catch (error) {\n const message = error instanceof Error ? error.message : \"Could not start new chat\";\n send(ws, { type: \"error\", error: message });\n }\n\n return;\n }\n\n send(ws, { type: \"start\" });\n\n try {\n const conversation = await ensureConversationForPrompt();\n const attachments = await saveAttachments(parsed.data.attachments);\n\n await conversationStore.appendMessage(conversation.id, {\n role: \"user\",\n text: parsed.data.prompt,\n imagePaths: attachments.map((attachment) => attachment.path),\n });\n\n let agentReply = \"\";\n const streamEvents: StoredStreamEvent[] = [];\n const streamEventIndexByCallId = new Map<string, number>();\n\n await agentRunner.prompt(\n parsed.data.prompt,\n {\n onText: (text) => {\n agentReply += text;\n send(ws, { type: \"delta\", text });\n },\n onStreamEvent: (event) => {\n send(ws, { type: \"stream-event\", event });\n collectStreamEvent(streamEvents, streamEventIndexByCallId, event);\n },\n onSessionUpdate: (update) => {\n void applySessionUpdate(conversation.id, update).then((updated) => {\n if (updated) {\n send(ws, { type: \"session-update\", session: toSessionPayload(updated) });\n }\n });\n },\n onEvent: (event) => {\n if (process.env.DEBUG_AGENT_EVENTS === \"true\") {\n send(ws, { type: \"event\", event });\n }\n },\n },\n attachments,\n conversation.chatId,\n conversation.workspace,\n conversation.agentProvider,\n );\n\n const updated = await conversationStore.appendMessage(conversation.id, {\n role: \"agent\",\n text: agentReply,\n events: streamEvents.length > 0 ? streamEvents : undefined,\n });\n\n send(ws, { type: \"done\", session: toSessionPayload(updated) });\n } catch (error) {\n const message = error instanceof Error ? error.message : \"Agent request failed\";\n send(ws, { type: \"error\", error: message });\n }\n });\n});\n\nbootstrap()\n .then(() => {\n server.listen(config.PORT, config.HOST, () => {\n console.log(`Agent phone bridge listening on http://${config.HOST}:${config.PORT}`);\n });\n })\n .catch((error) => {\n console.error(error);\n process.exit(1);\n });\n\nfunction requireAuth(req: express.Request, res: express.Response, next: express.NextFunction): void {\n if (!isRequestAuthenticated(req.cookies?.[COOKIE_NAME])) {\n res.status(401).json({ error: \"Unauthorized\" });\n return;\n }\n\n next();\n}\n\nfunction isPasswordValid(candidate: string): boolean {\n const actualHash = crypto.createHash(\"sha256\").update(config.APP_PASSWORD).digest();\n const candidateHash = crypto.createHash(\"sha256\").update(candidate).digest();\n return crypto.timingSafeEqual(actualHash, candidateHash);\n}\n\nfunction createSessionCookie(): string {\n const payload = Buffer.from(\n JSON.stringify({\n exp: Date.now() + SESSION_TTL_MS,\n nonce: crypto.randomBytes(16).toString(\"hex\"),\n }),\n \"utf8\",\n ).toString(\"base64url\");\n const signature = sign(payload);\n\n return `${payload}.${signature}`;\n}\n\nfunction isRequestAuthenticated(rawCookie: string | undefined): boolean {\n const token = rawCookie?.includes(\"=\") ? parseCookie(rawCookie)[COOKIE_NAME] : rawCookie;\n\n if (!token) {\n return false;\n }\n\n const [payload, signature] = token.split(\".\");\n\n if (!payload || !signature || !safeEqual(signature, sign(payload))) {\n return false;\n }\n\n try {\n const decoded = JSON.parse(Buffer.from(payload, \"base64url\").toString(\"utf8\")) as { exp?: unknown };\n return typeof decoded.exp === \"number\" && decoded.exp > Date.now();\n } catch {\n return false;\n }\n}\n\nfunction sign(payload: string): string {\n return crypto.createHmac(\"sha256\", config.SESSION_SECRET).update(payload).digest(\"base64url\");\n}\n\nfunction safeEqual(a: string, b: string): boolean {\n const aBuffer = Buffer.from(a);\n const bBuffer = Buffer.from(b);\n\n if (aBuffer.length !== bBuffer.length) {\n return false;\n }\n\n return crypto.timingSafeEqual(aBuffer, bBuffer);\n}\n\nfunction parseCookie(header: string): Record<string, string> {\n return Object.fromEntries(\n header.split(\";\").map((part) => {\n const [key, ...value] = part.trim().split(\"=\");\n return [key, decodeURIComponent(value.join(\"=\"))];\n }),\n );\n}\n\nfunction parseJson(raw: string): unknown {\n try {\n return JSON.parse(raw);\n } catch {\n return null;\n }\n}\n\nfunction send(ws: WebSocket, payload: unknown): void {\n if (ws.readyState === ws.OPEN) {\n ws.send(JSON.stringify(payload));\n }\n}\n\nasync function migrateLegacySession(): Promise<void> {\n await ensureActiveConversation();\n}\n\nasync function ensureActiveConversation(): Promise<Conversation | null> {\n let active = await conversationStore.getActive();\n\n if (active) {\n await agentRunner.useChatId(active.chatId);\n await agentRunner.setActiveConversation(active.id);\n return active;\n }\n\n const session = await sessionStore.read();\n\n if (session.chatId) {\n const conversation = await conversationStore.adoptLegacyChat(session.chatId);\n await agentRunner.useChatId(conversation.chatId);\n await agentRunner.setActiveConversation(conversation.id);\n return conversation;\n }\n\n const conversations = await conversationStore.list();\n\n if (conversations.length === 0) {\n return null;\n }\n\n const conversation = await conversationStore.get(conversations[0].id);\n\n if (!conversation) {\n return null;\n }\n\n await conversationStore.setActive(conversation.id);\n await agentRunner.useChatId(conversation.chatId);\n await agentRunner.setActiveConversation(conversation.id);\n return conversation;\n}\n\nfunction isSecureRequest(req: express.Request): boolean {\n if (config.isProduction) {\n return true;\n }\n\n const forwarded = req.headers[\"x-forwarded-proto\"];\n\n if (typeof forwarded === \"string\" && forwarded.split(\",\")[0]?.trim() === \"https\") {\n return true;\n }\n\n return req.secure;\n}\n\nfunction cookieOptions(req: express.Request) {\n return {\n httpOnly: true,\n sameSite: \"lax\" as const,\n secure: isSecureRequest(req),\n maxAge: SESSION_TTL_MS,\n path: \"/\",\n };\n}\n\nasync function ensureConversationForPrompt(): Promise<Conversation> {\n const active = await conversationStore.getActive();\n\n if (active) {\n await agentRunner.useChatId(active.chatId);\n await agentRunner.setActiveConversation(active.id);\n return active;\n }\n\n const session = await sessionStore.read();\n\n if (session.chatId) {\n const conversation = await conversationStore.adoptLegacyChat(session.chatId);\n await agentRunner.useChatId(conversation.chatId);\n await agentRunner.setActiveConversation(conversation.id);\n return conversation;\n }\n\n return startNewConversation();\n}\n\nasync function startNewConversation(\n workspace = config.AGENT_WORKSPACE,\n agentProvider: AgentProvider = config.DEFAULT_AGENT_PROVIDER,\n): Promise<Conversation> {\n const chatId = await agentRunner.createChat(workspace, agentProvider);\n const conversation = await conversationStore.create(chatId, { workspace, agentProvider });\n await agentRunner.useChatId(chatId);\n await agentRunner.setActiveConversation(conversation.id);\n return conversation;\n}\n\nfunction parseAgentProvider(value: unknown): AgentProvider {\n if (isAgentProvider(value)) {\n if (value === \"claude-code\" && !config.DEEPSEEK_API_KEY) {\n throw new Error(\"Claude Code is not configured. Set DEEPSEEK_API_KEY in .env.\");\n }\n\n return value;\n }\n\n return config.DEFAULT_AGENT_PROVIDER;\n}\n\nasync function resolveWorkspacePath(input?: string): Promise<string> {\n const raw = (input?.trim() || config.AGENT_WORKSPACE).replace(/\\//g, path.sep);\n const resolved = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(config.AGENT_WORKSPACE, raw);\n\n try {\n const stat = await fs.stat(resolved);\n\n if (!stat.isDirectory()) {\n throw new Error(\"Workspace path must be a directory\");\n }\n } catch (error) {\n if (error instanceof Error && error.message === \"Workspace path must be a directory\") {\n throw error;\n }\n\n throw new Error(`Workspace path does not exist: ${resolved}`);\n }\n\n return resolved;\n}\n\ninterface WorkspaceBrowseEntry {\n name: string;\n path: string;\n type: \"drive\" | \"directory\";\n}\n\nasync function browseWorkspace(inputPath?: string): Promise<{\n path: string | null;\n parent: string | null;\n entries: WorkspaceBrowseEntry[];\n}> {\n if (!inputPath?.trim()) {\n return {\n path: null,\n parent: null,\n entries: await listWorkspaceRoots(),\n };\n }\n\n const resolved = await resolveWorkspacePath(inputPath);\n const parent = workspaceParentPath(resolved);\n\n return {\n path: resolved,\n parent,\n entries: await listWorkspaceDirectories(resolved),\n };\n}\n\nasync function listWorkspaceRoots(): Promise<WorkspaceBrowseEntry[]> {\n const roots: WorkspaceBrowseEntry[] = [];\n\n if (process.platform === \"win32\") {\n for (const letter of \"ABCDEFGHIJKLMNOPQRSTUVWXYZ\") {\n const drive = `${letter}:\\\\`;\n\n try {\n const stat = await fs.stat(drive);\n\n if (stat.isDirectory()) {\n roots.push({ name: `${letter}:`, path: drive, type: \"drive\" });\n }\n } catch {\n // Ignore unavailable drives.\n }\n }\n\n return roots;\n }\n\n const home = os.homedir();\n roots.push({ name: \"Home\", path: home, type: \"directory\" });\n roots.push({ name: \"Root\", path: path.parse(home).root, type: \"directory\" });\n\n const defaultWorkspace = path.resolve(config.AGENT_WORKSPACE);\n roots.push({\n name: path.basename(defaultWorkspace) || \"Workspace\",\n path: defaultWorkspace,\n type: \"directory\",\n });\n\n return roots;\n}\n\nasync function listWorkspaceDirectories(dir: string): Promise<WorkspaceBrowseEntry[]> {\n let entries;\n\n try {\n entries = await fs.readdir(dir, { withFileTypes: true });\n } catch {\n return [];\n }\n\n return entries\n .filter((entry) => entry.isDirectory() && !entry.name.startsWith(\".\"))\n .map((entry) => ({\n name: entry.name,\n path: path.join(dir, entry.name),\n type: \"directory\" as const,\n }))\n .sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: \"base\" }));\n}\n\nfunction workspaceParentPath(current: string): string | null {\n const parent = path.dirname(current);\n\n if (parent === current) {\n return null;\n }\n\n return parent;\n}\n\nasync function applySessionUpdate(\n conversationId: string,\n update: AgentSessionUpdate,\n): Promise<Conversation | null> {\n const conversation = await conversationStore.get(conversationId);\n\n if (!conversation) {\n return null;\n }\n\n return conversationStore.updateMeta(conversationId, {\n model: update.model ?? conversation.model,\n workspace: update.workspace ?? conversation.workspace,\n chatId: update.chatId ?? conversation.chatId,\n contextUsagePercent:\n update.contextUsagePercent === undefined\n ? conversation.contextUsagePercent\n : update.contextUsagePercent,\n });\n}\n\nfunction toSessionPayload(conversation: Conversation | null) {\n const agentProvider = normalizeAgentProvider(conversation?.agentProvider, config.DEFAULT_AGENT_PROVIDER);\n\n return {\n conversationId: conversation?.id ?? null,\n title: conversation?.title ?? \"New chat\",\n model: conversation?.model ?? (agentProvider === \"claude-code\" ? config.CLAUDE_CODE_MODEL : config.AGENT_MODEL ?? \"auto\"),\n workspace: conversation?.workspace ?? config.AGENT_WORKSPACE,\n agentProvider,\n contextUsagePercent: conversation?.contextUsagePercent ?? null,\n };\n}\n\nfunction toClientMessages(messages: StoredMessage[]) {\n return messages.map((message) => ({\n role: message.role,\n text: message.text,\n imagePaths: message.imagePaths ?? [],\n events: message.events ?? [],\n createdAt: message.createdAt,\n }));\n}\n\nfunction toStoredStreamEvent(event: AgentStreamEvent): StoredStreamEvent {\n return {\n kind: event.kind,\n title: event.title,\n text: event.text,\n callId: event.callId,\n subtype: event.subtype,\n toolName: event.toolName,\n };\n}\n\nfunction collectStreamEvent(\n events: StoredStreamEvent[],\n indexByCallId: Map<string, number>,\n event: AgentStreamEvent,\n): void {\n const rawCallId =\n event.callId ??\n (event.raw && typeof event.raw === \"object\" && !Array.isArray(event.raw)\n ? (event.raw as Record<string, unknown>).call_id\n : undefined);\n const callId = typeof rawCallId === \"string\" && rawCallId.length > 0 ? rawCallId : undefined;\n const stored = toStoredStreamEvent(event);\n\n if (callId && indexByCallId.has(callId)) {\n events[indexByCallId.get(callId)!] = stored;\n return;\n }\n\n if (callId) {\n indexByCallId.set(callId, events.length);\n }\n\n events.push(stored);\n}\n\nasync function saveAttachments(\n attachments: Array<{ name: string; mimeType: \"image/jpeg\" | \"image/png\" | \"image/gif\" | \"image/webp\"; data: string }>,\n) {\n await fs.mkdir(config.UPLOAD_DIR, { recursive: true });\n\n return Promise.all(\n attachments.map(async (attachment) => {\n const bytes = decodeDataUrl(attachment.data, attachment.mimeType);\n\n if (bytes.byteLength > MAX_IMAGE_BYTES) {\n throw new Error(`${attachment.name} is larger than ${MAX_IMAGE_BYTES / 1024 / 1024}MB`);\n }\n\n const extension = extensionForMimeType(attachment.mimeType);\n const safeBaseName = attachment.name.replace(/\\.[^.]+$/, \"\").replace(/[^a-zA-Z0-9._-]/g, \"_\").slice(0, 80);\n const fileName = `${new Date().toISOString().replace(/[:.]/g, \"-\")}-${crypto.randomBytes(4).toString(\"hex\")}-${safeBaseName}${extension}`;\n const filePath = path.join(config.UPLOAD_DIR, fileName);\n\n await fs.writeFile(filePath, bytes);\n\n return {\n originalName: attachment.name,\n mimeType: attachment.mimeType,\n path: filePath,\n };\n }),\n );\n}\n\nfunction decodeDataUrl(data: string, expectedMimeType: string): Buffer {\n const match = data.match(/^data:([^;]+);base64,(.+)$/);\n\n if (!match || match[1] !== expectedMimeType) {\n throw new Error(\"Invalid image attachment\");\n }\n\n return Buffer.from(match[2], \"base64\");\n}\n\nfunction extensionForMimeType(mimeType: string): string {\n if (mimeType === \"image/jpeg\") {\n return \".jpg\";\n }\n\n if (mimeType === \"image/png\") {\n return \".png\";\n }\n\n if (mimeType === \"image/gif\") {\n return \".gif\";\n }\n\n return \".webp\";\n}\n\nfunction resolveAllowedImagePath(input: string): string | null {\n if (!input) {\n return null;\n }\n\n const decoded = decodeURIComponent(input);\n const candidate = path.isAbsolute(decoded) ? decoded : path.resolve(config.AGENT_WORKSPACE, decoded);\n const resolved = path.resolve(candidate);\n const extension = path.extname(resolved).toLowerCase();\n\n if (!IMAGE_EXTENSIONS.has(extension)) {\n return null;\n }\n\n const allowedRoots = [config.UPLOAD_DIR, config.AGENT_WORKSPACE, config.OBSIDIAN_VAULT_PATH].map((root) =>\n path.resolve(root).toLowerCase(),\n );\n const normalized = resolved.toLowerCase();\n const isAllowed = allowedRoots.some((root) => normalized === root || normalized.startsWith(`${root}${path.sep}`));\n\n return isAllowed ? resolved : null;\n}\n"}, "7b9b3b28d9f35a34160d7adcab02beb72595bab1": {"t": 1785754389.0197458, "n": 23962, "h": "7100be05266ac8d3", "c": "import crypto from \"node:crypto\";\nimport fs from \"node:fs/promises\";\nimport http from \"node:http\";\nimport os from \"node:os\";\nimport path from \"node:path\";\nimport cookieParser from \"cookie-parser\";\nimport express from \"express\";\nimport rateLimit from \"express-rate-limit\";\nimport helmet from \"helmet\";\nimport { WebSocketServer, type WebSocket } from \"ws\";\nimport { z } from \"zod\";\nimport { AgentRunner, type AgentSessionUpdate } from \"./agentRunner.js\";\nimport { config } from \"./config.js\";\nimport { ConversationStore, type Conversation, type StoredMessage } from \"./conversationStore.js\";\nimport { SessionStore } from \"./sessionStore.js\";\nimport { isAgentProvider, normalizeAgentProvider, type AgentProvider } from \"./types/agentProvider.js\";\n\nconst COOKIE_NAME = \"agent_session\";\nconst SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 30;\nconst MAX_IMAGE_BYTES = 8 * 1024 * 1024;\nconst IMAGE_EXTENSIONS = new Set([\".jpg\", \".jpeg\", \".png\", \".gif\", \".webp\"]);\n\nconst messageSchema = z.discriminatedUnion(\"type\", [\n z.object({\n type: z.literal(\"prompt\"),\n prompt: z.string().trim().min(1).max(12000),\n attachments: z\n .array(\n z.object({\n name: z.string().min(1).max(180),\n mimeType: z.enum([\"image/jpeg\", \"image/png\", \"image/gif\", \"image/webp\"]),\n data: z.string().min(1),\n }),\n )\n .max(4)\n .default([]),\n }),\n z.object({\n type: z.literal(\"reset\"),\n }),\n z.object({\n type: z.literal(\"new-chat\"),\n workspace: z.string().trim().min(1).max(4096).optional(),\n agentProvider: z.enum([\"cursor\", \"claude-code\"]).optional(),\n }),\n]);\n\nconst app = express();\nconst server = http.createServer(app);\nconst sessionStore = new SessionStore(path.join(config.DATA_DIR, \"session.json\"));\nconst conversationStore = new ConversationStore(path.join(config.DATA_DIR, \"conversations\"));\nconst agentRunner = new AgentRunner(sessionStore);\n\nasync function bootstrap(): Promise<void> {\n await conversationStore.init();\n await migrateLegacySession();\n}\n\napp.set(\"trust proxy\", 1);\napp.disable(\"x-powered-by\");\napp.use(\n helmet({\n contentSecurityPolicy: {\n directives: {\n defaultSrc: [\"'self'\"],\n connectSrc: [\"'self'\", \"ws:\", \"wss:\"],\n scriptSrc: [\"'self'\"],\n styleSrc: [\"'self'\"],\n imgSrc: [\"'self'\", \"data:\", \"https:\"],\n },\n },\n }),\n);\napp.use(express.json({ limit: \"40mb\" }));\napp.use(cookieParser());\napp.use((req, res, next) => {\n if (\n req.path.startsWith(\"/api/\") &&\n req.path !== \"/api/login\" &&\n req.path !== \"/api/health\" &&\n isRequestAuthenticated(req.cookies?.[COOKIE_NAME])\n ) {\n res.cookie(COOKIE_NAME, createSessionCookie(), cookieOptions(req));\n }\n\n next();\n});\n\nconst authLimiter = rateLimit({\n windowMs: 15 * 60 * 1000,\n limit: 20,\n standardHeaders: true,\n legacyHeaders: false,\n});\n\napp.get(\"/api/health\", (_req, res) => {\n res.json({ ok: true });\n});\n\napp.get(\"/api/config\", requireAuth, (_req, res) => {\n res.json({\n defaultWorkspace: config.AGENT_WORKSPACE,\n defaultAgentProvider: config.DEFAULT_AGENT_PROVIDER,\n agentProviders: [\n { id: \"cursor\", label: \"Cursor Agent\" },\n { id: \"claude-code\", label: \"Claude Code (DeepSeek)\" },\n ],\n claudeCodeAvailable: Boolean(config.DEEPSEEK_API_KEY),\n });\n});\n\napp.get(\"/api/workspace/browse\", requireAuth, async (req, res, next) => {\n try {\n const inputPath = typeof req.query.path === \"string\" ? req.query.path : undefined;\n res.json(await browseWorkspace(inputPath));\n } catch (error) {\n next(error);\n }\n});\n\napp.get(\"/api/me\", (req, res) => {\n res.json({ authenticated: isRequestAuthenticated(req.cookies?.[COOKIE_NAME]) });\n});\n\napp.post(\"/api/login\", authLimiter, async (req, res, next) => {\n const password = typeof req.body?.password === \"string\" ? req.body.password : \"\";\n\n if (!isPasswordValid(password)) {\n res.status(401).json({ error: \"Invalid password\" });\n return;\n }\n\n try {\n res.cookie(COOKIE_NAME, createSessionCookie(), cookieOptions(req));\n const conversation = await ensureActiveConversation();\n res.json({\n ok: true,\n session: toSessionPayload(conversation),\n messages: toClientMessages(conversation?.messages ?? []),\n });\n } catch (error) {\n next(error);\n }\n});\n\napp.post(\"/api/logout\", requireAuth, (req, res) => {\n res.clearCookie(COOKIE_NAME, cookieOptions(req));\n res.json({ ok: true });\n});\n\napp.get(\"/api/session\", requireAuth, async (_req, res, next) => {\n try {\n const conversation = await ensureActiveConversation();\n res.json({\n session: toSessionPayload(conversation),\n messages: toClientMessages(conversation?.messages ?? []),\n });\n } catch (error) {\n next(error);\n }\n});\n\napp.get(\"/api/conversations\", requireAuth, async (_req, res, next) => {\n try {\n const conversation = await ensureActiveConversation();\n const conversations = await conversationStore.list();\n res.json({\n conversations,\n activeConversationId: conversation?.id ?? null,\n session: toSessionPayload(conversation),\n messages: toClientMessages(conversation?.messages ?? []),\n });\n } catch (error) {\n next(error);\n }\n});\n\napp.get(\"/api/conversations/:id\", requireAuth, async (req, res, next) => {\n try {\n const conversation = await conversationStore.get(String(req.params.id));\n\n if (!conversation) {\n res.status(404).json({ error: \"Conversation not found\" });\n return;\n }\n\n res.json({ conversation });\n } catch (error) {\n next(error);\n }\n});\n\napp.post(\"/api/conversations\", requireAuth, async (req, res, next) => {\n try {\n const workspaceInput = typeof req.body?.workspace === \"string\" ? req.body.workspace : undefined;\n const agentProvider = parseAgentProvider(req.body?.agentProvider);\n const workspace = await resolveWorkspacePath(workspaceInput);\n const conversation = await startNewConversation(workspace, agentProvider);\n res.json({ conversation, session: toSessionPayload(conversation) });\n } catch (error) {\n next(error);\n }\n});\n\napp.patch(\"/api/conversations/:id\", requireAuth, async (req, res, next) => {\n try {\n const title = typeof req.body?.title === \"string\" ? req.body.title.trim() : \"\";\n\n if (!title) {\n res.status(400).json({ error: \"Title is required\" });\n return;\n }\n\n if (title.length > 120) {\n res.status(400).json({ error: \"Title must be 120 characters or fewer\" });\n return;\n }\n\n const conversation = await conversationStore.updateMeta(String(req.params.id), { title });\n\n if (!conversation) {\n res.status(404).json({ error: \"Conversation not found\" });\n return;\n }\n\n res.json({ conversation, session: toSessionPayload(conversation) });\n } catch (error) {\n next(error);\n }\n});\n\napp.post(\"/api/conversations/:id/activate\", requireAuth, async (req, res, next) => {\n try {\n const conversation = await conversationStore.get(String(req.params.id));\n\n if (!conversation) {\n res.status(404).json({ error: \"Conversation not found\" });\n return;\n }\n\n await conversationStore.setActive(String(conversation.id));\n await agentRunner.useChatId(conversation.chatId);\n await agentRunner.setActiveConversation(conversation.id);\n res.json({ conversation, session: toSessionPayload(conversation) });\n } catch (error) {\n next(error);\n }\n});\n\napp.post(\"/api/reset\", requireAuth, async (_req, res, next) => {\n try {\n const conversation = await startNewConversation();\n res.json({ ok: true, conversation, session: toSessionPayload(conversation) });\n } catch (error) {\n next(error);\n }\n});\n\napp.get(\"/api/image\", requireAuth, async (req, res, next) => {\n try {\n const imagePath = typeof req.query.path === \"string\" ? req.query.path : \"\";\n const resolved = resolveAllowedImagePath(imagePath);\n\n if (!resolved) {\n res.status(404).end();\n return;\n }\n\n res.sendFile(resolved);\n } catch (error) {\n next(error);\n }\n});\n\napp.use(\"/vendor\", express.static(path.join(config.rootDir, \"node_modules\")));\napp.use(express.static(config.publicDir, { extensions: [\"html\"] }));\n\napp.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {\n const message = error instanceof Error ? error.message : \"Unexpected server error\";\n res.status(500).json({ error: message });\n});\n\nconst wss = new WebSocketServer({ noServer: true });\n\nserver.on(\"upgrade\", (request, socket, head) => {\n if (request.url !== \"/ws\" || !isRequestAuthenticated(request.headers.cookie)) {\n socket.write(\"HTTP/1.1 401 Unauthorized\\r\\n\\r\\n\");\n socket.destroy();\n return;\n }\n\n wss.handleUpgrade(request, socket, head, (ws) => {\n wss.emit(\"connection\", ws, request);\n });\n});\n\nwss.on(\"connection\", async (ws) => {\n try {\n const conversation = await ensureActiveConversation();\n send(ws, {\n type: \"ready\",\n session: toSessionPayload(conversation),\n messages: toClientMessages(conversation?.messages ?? []),\n });\n } catch {\n send(ws, { type: \"ready\", session: toSessionPayload(null), messages: [] });\n }\n\n ws.on(\"message\", async (raw) => {\n const parsed = messageSchema.safeParse(parseJson(raw.toString()));\n\n if (!parsed.success) {\n send(ws, { type: \"error\", error: \"Invalid message\" });\n return;\n }\n\n if (parsed.data.type === \"reset\") {\n const conversation = await startNewConversation(config.AGENT_WORKSPACE, config.DEFAULT_AGENT_PROVIDER);\n send(ws, { type: \"new-chat\", session: toSessionPayload(conversation), messages: [] });\n return;\n }\n\n if (parsed.data.type === \"new-chat\") {\n try {\n const workspace = await resolveWorkspacePath(parsed.data.workspace);\n const agentProvider = parseAgentProvider(parsed.data.agentProvider);\n const conversation = await startNewConversation(workspace, agentProvider);\n send(ws, { type: \"new-chat\", session: toSessionPayload(conversation), messages: [] });\n } catch (error) {\n const message = error instanceof Error ? error.message : \"Could not start new chat\";\n send(ws, { type: \"error\", error: message });\n }\n\n return;\n }\n\n send(ws, { type: \"start\" });\n\n try {\n const conversation = await ensureConversationForPrompt();\n const attachments = await saveAttachments(parsed.data.attachments);\n\n await conversationStore.appendMessage(conversation.id, {\n role: \"user\",\n text: parsed.data.prompt,\n imagePaths: attachments.map((attachment) => attachment.path),\n });\n\n let agentReply = \"\";\n\n await agentRunner.prompt(\n parsed.data.prompt,\n {\n onText: (text) => {\n agentReply += text;\n send(ws, { type: \"delta\", text });\n },\n onStreamEvent: (event) => send(ws, { type: \"stream-event\", event }),\n onSessionUpdate: (update) => {\n void applySessionUpdate(conversation.id, update).then((updated) => {\n if (updated) {\n send(ws, { type: \"session-update\", session: toSessionPayload(updated) });\n }\n });\n },\n onEvent: (event) => {\n if (process.env.DEBUG_AGENT_EVENTS === \"true\") {\n send(ws, { type: \"event\", event });\n }\n },\n },\n attachments,\n conversation.chatId,\n conversation.workspace,\n conversation.agentProvider,\n );\n\n const updated = await conversationStore.appendMessage(conversation.id, {\n role: \"agent\",\n text: agentReply,\n });\n\n send(ws, { type: \"done\", session: toSessionPayload(updated) });\n } catch (error) {\n const message = error instanceof Error ? error.message : \"Agent request failed\";\n send(ws, { type: \"error\", error: message });\n }\n });\n});\n\nbootstrap()\n .then(() => {\n server.listen(config.PORT, config.HOST, () => {\n console.log(`Agent phone bridge listening on http://${config.HOST}:${config.PORT}`);\n });\n })\n .catch((error) => {\n console.error(error);\n process.exit(1);\n });\n\nfunction requireAuth(req: express.Request, res: express.Response, next: express.NextFunction): void {\n if (!isRequestAuthenticated(req.cookies?.[COOKIE_NAME])) {\n res.status(401).json({ error: \"Unauthorized\" });\n return;\n }\n\n next();\n}\n\nfunction isPasswordValid(candidate: string): boolean {\n const actualHash = crypto.createHash(\"sha256\").update(config.APP_PASSWORD).digest();\n const candidateHash = crypto.createHash(\"sha256\").update(candidate).digest();\n return crypto.timingSafeEqual(actualHash, candidateHash);\n}\n\nfunction createSessionCookie(): string {\n const payload = Buffer.from(\n JSON.stringify({\n exp: Date.now() + SESSION_TTL_MS,\n nonce: crypto.randomBytes(16).toString(\"hex\"),\n }),\n \"utf8\",\n ).toString(\"base64url\");\n const signature = sign(payload);\n\n return `${payload}.${signature}`;\n}\n\nfunction isRequestAuthenticated(rawCookie: string | undefined): boolean {\n const token = rawCookie?.includes(\"=\") ? parseCookie(rawCookie)[COOKIE_NAME] : rawCookie;\n\n if (!token) {\n return false;\n }\n\n const [payload, signature] = token.split(\".\");\n\n if (!payload || !signature || !safeEqual(signature, sign(payload))) {\n return false;\n }\n\n try {\n const decoded = JSON.parse(Buffer.from(payload, \"base64url\").toString(\"utf8\")) as { exp?: unknown };\n return typeof decoded.exp === \"number\" && decoded.exp > Date.now();\n } catch {\n return false;\n }\n}\n\nfunction sign(payload: string): string {\n return crypto.createHmac(\"sha256\", config.SESSION_SECRET).update(payload).digest(\"base64url\");\n}\n\nfunction safeEqual(a: string, b: string): boolean {\n const aBuffer = Buffer.from(a);\n const bBuffer = Buffer.from(b);\n\n if (aBuffer.length !== bBuffer.length) {\n return false;\n }\n\n return crypto.timingSafeEqual(aBuffer, bBuffer);\n}\n\nfunction parseCookie(header: string): Record<string, string> {\n return Object.fromEntries(\n header.split(\";\").map((part) => {\n const [key, ...value] = part.trim().split(\"=\");\n return [key, decodeURIComponent(value.join(\"=\"))];\n }),\n );\n}\n\nfunction parseJson(raw: string): unknown {\n try {\n return JSON.parse(raw);\n } catch {\n return null;\n }\n}\n\nfunction send(ws: WebSocket, payload: unknown): void {\n if (ws.readyState === ws.OPEN) {\n ws.send(JSON.stringify(payload));\n }\n}\n\nasync function migrateLegacySession(): Promise<void> {\n await ensureActiveConversation();\n}\n\nasync function ensureActiveConversation(): Promise<Conversation | null> {\n let active = await conversationStore.getActive();\n\n if (active) {\n await agentRunner.useChatId(active.chatId);\n await agentRunner.setActiveConversation(active.id);\n return active;\n }\n\n const session = await sessionStore.read();\n\n if (session.chatId) {\n const conversation = await conversationStore.adoptLegacyChat(session.chatId);\n await agentRunner.useChatId(conversation.chatId);\n await agentRunner.setActiveConversation(conversation.id);\n return conversation;\n }\n\n const conversations = await conversationStore.list();\n\n if (conversations.length === 0) {\n return null;\n }\n\n const conversation = await conversationStore.get(conversations[0].id);\n\n if (!conversation) {\n return null;\n }\n\n await conversationStore.setActive(conversation.id);\n await agentRunner.useChatId(conversation.chatId);\n await agentRunner.setActiveConversation(conversation.id);\n return conversation;\n}\n\nfunction isSecureRequest(req: express.Request): boolean {\n if (config.isProduction) {\n return true;\n }\n\n const forwarded = req.headers[\"x-forwarded-proto\"];\n\n if (typeof forwarded === \"string\" && forwarded.split(\",\")[0]?.trim() === \"https\") {\n return true;\n }\n\n return req.secure;\n}\n\nfunction cookieOptions(req: express.Request) {\n return {\n httpOnly: true,\n sameSite: \"lax\" as const,\n secure: isSecureRequest(req),\n maxAge: SESSION_TTL_MS,\n path: \"/\",\n };\n}\n\nasync function ensureConversationForPrompt(): Promise<Conversation> {\n const active = await conversationStore.getActive();\n\n if (active) {\n await agentRunner.useChatId(active.chatId);\n await agentRunner.setActiveConversation(active.id);\n return active;\n }\n\n const session = await sessionStore.read();\n\n if (session.chatId) {\n const conversation = await conversationStore.adoptLegacyChat(session.chatId);\n await agentRunner.useChatId(conversation.chatId);\n await agentRunner.setActiveConversation(conversation.id);\n return conversation;\n }\n\n return startNewConversation();\n}\n\nasync function startNewConversation(\n workspace = config.AGENT_WORKSPACE,\n agentProvider: AgentProvider = config.DEFAULT_AGENT_PROVIDER,\n): Promise<Conversation> {\n const chatId = await agentRunner.createChat(workspace, agentProvider);\n const conversation = await conversationStore.create(chatId, { workspace, agentProvider });\n await agentRunner.useChatId(chatId);\n await agentRunner.setActiveConversation(conversation.id);\n return conversation;\n}\n\nfunction parseAgentProvider(value: unknown): AgentProvider {\n if (isAgentProvider(value)) {\n if (value === \"claude-code\" && !config.DEEPSEEK_API_KEY) {\n throw new Error(\"Claude Code is not configured. Set DEEPSEEK_API_KEY in .env.\");\n }\n\n return value;\n }\n\n return config.DEFAULT_AGENT_PROVIDER;\n}\n\nasync function resolveWorkspacePath(input?: string): Promise<string> {\n const raw = (input?.trim() || config.AGENT_WORKSPACE).replace(/\\//g, path.sep);\n const resolved = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(config.AGENT_WORKSPACE, raw);\n\n try {\n const stat = await fs.stat(resolved);\n\n if (!stat.isDirectory()) {\n throw new Error(\"Workspace path must be a directory\");\n }\n } catch (error) {\n if (error instanceof Error && error.message === \"Workspace path must be a directory\") {\n throw error;\n }\n\n throw new Error(`Workspace path does not exist: ${resolved}`);\n }\n\n return resolved;\n}\n\ninterface WorkspaceBrowseEntry {\n name: string;\n path: string;\n type: \"drive\" | \"directory\";\n}\n\nasync function browseWorkspace(inputPath?: string): Promise<{\n path: string | null;\n parent: string | null;\n entries: WorkspaceBrowseEntry[];\n}> {\n if (!inputPath?.trim()) {\n return {\n path: null,\n parent: null,\n entries: await listWorkspaceRoots(),\n };\n }\n\n const resolved = await resolveWorkspacePath(inputPath);\n const parent = workspaceParentPath(resolved);\n\n return {\n path: resolved,\n parent,\n entries: await listWorkspaceDirectories(resolved),\n };\n}\n\nasync function listWorkspaceRoots(): Promise<WorkspaceBrowseEntry[]> {\n const roots: WorkspaceBrowseEntry[] = [];\n\n if (process.platform === \"win32\") {\n for (const letter of \"ABCDEFGHIJKLMNOPQRSTUVWXYZ\") {\n const drive = `${letter}:\\\\`;\n\n try {\n const stat = await fs.stat(drive);\n\n if (stat.isDirectory()) {\n roots.push({ name: `${letter}:`, path: drive, type: \"drive\" });\n }\n } catch {\n // Ignore unavailable drives.\n }\n }\n\n return roots;\n }\n\n const home = os.homedir();\n roots.push({ name: \"Home\", path: home, type: \"directory\" });\n roots.push({ name: \"Root\", path: path.parse(home).root, type: \"directory\" });\n\n const defaultWorkspace = path.resolve(config.AGENT_WORKSPACE);\n roots.push({\n name: path.basename(defaultWorkspace) || \"Workspace\",\n path: defaultWorkspace,\n type: \"directory\",\n });\n\n return roots;\n}\n\nasync function listWorkspaceDirectories(dir: string): Promise<WorkspaceBrowseEntry[]> {\n let entries;\n\n try {\n entries = await fs.readdir(dir, { withFileTypes: true });\n } catch {\n return [];\n }\n\n return entries\n .filter((entry) => entry.isDirectory() && !entry.name.startsWith(\".\"))\n .map((entry) => ({\n name: entry.name,\n path: path.join(dir, entry.name),\n type: \"directory\" as const,\n }))\n .sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: \"base\" }));\n}\n\nfunction workspaceParentPath(current: string): string | null {\n const parent = path.dirname(current);\n\n if (parent === current) {\n return null;\n }\n\n return parent;\n}\n\nasync function applySessionUpdate(\n conversationId: string,\n update: AgentSessionUpdate,\n): Promise<Conversation | null> {\n const conversation = await conversationStore.get(conversationId);\n\n if (!conversation) {\n return null;\n }\n\n return conversationStore.updateMeta(conversationId, {\n model: update.model ?? conversation.model,\n workspace: update.workspace ?? conversation.workspace,\n chatId: update.chatId ?? conversation.chatId,\n contextUsagePercent:\n update.contextUsagePercent === undefined\n ? conversation.contextUsagePercent\n : update.contextUsagePercent,\n });\n}\n\nfunction toSessionPayload(conversation: Conversation | null) {\n const agentProvider = normalizeAgentProvider(conversation?.agentProvider, config.DEFAULT_AGENT_PROVIDER);\n\n return {\n conversationId: conversation?.id ?? null,\n title: conversation?.title ?? \"New chat\",\n model: conversation?.model ?? (agentProvider === \"claude-code\" ? config.CLAUDE_CODE_MODEL : config.AGENT_MODEL ?? \"auto\"),\n workspace: conversation?.workspace ?? config.AGENT_WORKSPACE,\n agentProvider,\n contextUsagePercent: conversation?.contextUsagePercent ?? null,\n };\n}\n\nfunction toClientMessages(messages: StoredMessage[]) {\n return messages.map((message) => ({\n role: message.role,\n text: message.text,\n imagePaths: message.imagePaths ?? [],\n createdAt: message.createdAt,\n }));\n}\n\nasync function saveAttachments(\n attachments: Array<{ name: string; mimeType: \"image/jpeg\" | \"image/png\" | \"image/gif\" | \"image/webp\"; data: string }>,\n) {\n await fs.mkdir(config.UPLOAD_DIR, { recursive: true });\n\n return Promise.all(\n attachments.map(async (attachment) => {\n const bytes = decodeDataUrl(attachment.data, attachment.mimeType);\n\n if (bytes.byteLength > MAX_IMAGE_BYTES) {\n throw new Error(`${attachment.name} is larger than ${MAX_IMAGE_BYTES / 1024 / 1024}MB`);\n }\n\n const extension = extensionForMimeType(attachment.mimeType);\n const safeBaseName = attachment.name.replace(/\\.[^.]+$/, \"\").replace(/[^a-zA-Z0-9._-]/g, \"_\").slice(0, 80);\n const fileName = `${new Date().toISOString().replace(/[:.]/g, \"-\")}-${crypto.randomBytes(4).toString(\"hex\")}-${safeBaseName}${extension}`;\n const filePath = path.join(config.UPLOAD_DIR, fileName);\n\n await fs.writeFile(filePath, bytes);\n\n return {\n originalName: attachment.name,\n mimeType: attachment.mimeType,\n path: filePath,\n };\n }),\n );\n}\n\nfunction decodeDataUrl(data: string, expectedMimeType: string): Buffer {\n const match = data.match(/^data:([^;]+);base64,(.+)$/);\n\n if (!match || match[1] !== expectedMimeType) {\n throw new Error(\"Invalid image attachment\");\n }\n\n return Buffer.from(match[2], \"base64\");\n}\n\nfunction extensionForMimeType(mimeType: string): string {\n if (mimeType === \"image/jpeg\") {\n return \".jpg\";\n }\n\n if (mimeType === \"image/png\") {\n return \".png\";\n }\n\n if (mimeType === \"image/gif\") {\n return \".gif\";\n }\n\n return \".webp\";\n}\n\nfunction resolveAllowedImagePath(input: string): string | null {\n if (!input) {\n return null;\n }\n\n const decoded = decodeURIComponent(input);\n const candidate = path.isAbsolute(decoded) ? decoded : path.resolve(config.AGENT_WORKSPACE, decoded);\n const resolved = path.resolve(candidate);\n const extension = path.extname(resolved).toLowerCase();\n\n if (!IMAGE_EXTENSIONS.has(extension)) {\n return null;\n }\n\n const allowedRoots = [config.UPLOAD_DIR, config.AGENT_WORKSPACE, config.OBSIDIAN_VAULT_PATH].map((root) =>\n path.resolve(root).toLowerCase(),\n );\n const normalized = resolved.toLowerCase();\n const isAllowed = allowedRoots.some((root) => normalized === root || normalized.startsWith(`${root}${path.sep}`));\n\n return isAllowed ? resolved : null;\n}\n"}}}}