import type { APIRoute } from "astro"; import { readdir, stat, open, readFile } from "node:fs/promises"; import { join, basename } from "node:path"; import { existsSync } from "node:fs"; const ROOT = process.env.REPO_ROOT!; const TAIL_BYTES = 32000; const SEP = "─"; const SEP_RE = /─{20,}/; interface LogLine { file: string; channel: string; ts: string; type: string; content: string; } const ENTRY_RE = /^\[([^\]]+)\]\s+(SESSION START|TEXT|TOOL CALL:\s*\S+|TOOL RESULT|RESULT\s*\|.*|USER)/; function parseChunks(raw: string, fileName: string): LogLine[] { const chunks = raw.split(SEP_RE); const channel = fileName.split("-")[0]; const lines: LogLine[] = []; for (const chunk of chunks) { const trimmed = chunk.trim(); if (!trimmed) continue; const m = trimmed.match(ENTRY_RE); if (!m) continue; lines.push({ file: fileName, channel, ts: m[1], type: m[2], content: trimmed.slice(m[0].length).trim() }); } return lines; } async function tailFile(filePath: string, size: number): Promise { const fh = await open(filePath, "r"); const start = Math.max(0, size - TAIL_BYTES); const buf = Buffer.alloc(Math.min(TAIL_BYTES, size)); await fh.read(buf, 0, buf.length, start); await fh.close(); return buf.toString("utf-8"); } async function readSessionHeader(filePath: string): Promise { const MAX_HEADER = 512 * 1024; const fh = await open(filePath, "r"); const fSize = (await fh.stat()).size; const readSize = Math.min(MAX_HEADER, fSize); const buf = Buffer.alloc(readSize); await fh.read(buf, 0, readSize, 0); await fh.close(); const raw = buf.toString("utf-8"); const sepIdx = raw.search(SEP_RE); if (sepIdx === -1) return null; const firstChunk = raw.slice(0, sepIdx).trim(); const m = firstChunk.match(ENTRY_RE); if (!m) return null; const fileName = basename(filePath); return { file: fileName, channel: fileName.split("-")[0], ts: m[1], type: m[2], content: firstChunk.slice(m[0].length).trim(), }; } function resolveTranscript(sessionFile: string): string | null { const m = sessionFile.match(/^[a-z]+-(.+)\.log$/); if (!m) return null; const sessionId = m[1]; const p = join(process.env.HOME || "", `.claude/projects/-Users-ace-palacering/${sessionId}.jsonl`); return existsSync(p) ? p : null; } function parseTranscriptLines(raw: string, fileName: string): LogLine[] { const channel = fileName.split("-")[0]; const lines: LogLine[] = []; for (const line of raw.split("\n")) { if (!line.trim()) continue; let obj: any; try { obj = JSON.parse(line); } catch { continue; } const ts = obj.timestamp || ""; const fmtTs = ts ? new Date(ts).toLocaleString("en-US", { timeZone: "America/Los_Angeles", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }) : ""; if (obj.type === "user" && obj.message?.content) { const blocks = Array.isArray(obj.message.content) ? obj.message.content : []; for (const b of blocks) { if (typeof b === "object" && b.type === "text" && b.text) { lines.push({ file: fileName, channel, ts: fmtTs, type: "USER", content: b.text }); } else if (typeof b === "object" && b.type === "tool_result") { const text = Array.isArray(b.content) ? b.content.filter((c: any) => c.type === "text").map((c: any) => c.text).join("\n") : typeof b.content === "string" ? b.content : ""; lines.push({ file: fileName, channel, ts: fmtTs, type: "TOOL RESULT", content: text }); } } } else if (obj.type === "assistant" && obj.message?.content) { const blocks = Array.isArray(obj.message.content) ? obj.message.content : []; for (const b of blocks) { if (typeof b === "object" && b.type === "text" && b.text?.trim()) { lines.push({ file: fileName, channel, ts: fmtTs, type: "TEXT", content: b.text.trim() }); } else if (typeof b === "object" && b.type === "tool_use") { lines.push({ file: fileName, channel, ts: fmtTs, type: `TOOL CALL: ${b.name}`, content: JSON.stringify(b.input || {}, null, 2) }); } } } else if (obj.type === "result") { const tokIn = (obj.inputTokens || 0) + (obj.cacheReadInputTokens || 0) + (obj.cacheCreationInputTokens || 0); const cost = obj.costUSD || 0; lines.push({ file: fileName, channel, ts: fmtTs, type: `RESULT | turns: ${obj.numTurns || 0} | cost: $${cost.toFixed(4)} | ${tokIn}in / ${obj.outputTokens || 0}out`, content: "" }); } } return lines; } export const GET: APIRoute = async ({ url }) => { const headers = { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }; try { const sessDir = join(ROOT, "code/palaceplatform/channels/logs/sessions"); const count = parseInt(url.searchParams.get("n") || "10"); const fileParam = url.searchParams.get("file"); const source = url.searchParams.get("source"); if (fileParam) { const safeName = fileParam.replace(/[^a-zA-Z0-9._-]/g, ""); const filePath = join(sessDir, safeName); if (source === "transcript") { const transcriptPath = resolveTranscript(safeName); if (!transcriptPath) { return new Response(JSON.stringify({ error: "transcript not found" }), { status: 404, headers }); } const header = await readSessionHeader(filePath).catch(() => null); const raw = await readFile(transcriptPath, "utf-8"); const tLines = parseTranscriptLines(raw, safeName); const recent = tLines.slice(-count); const result = header ? [header, ...recent] : recent; return new Response(JSON.stringify(result), { headers }); } const fileExists = await stat(filePath).then(() => true).catch(() => false); if (fileExists) { const header = await readSessionHeader(filePath).catch(() => null); const fStat = await stat(filePath); const raw = await tailFile(filePath, fStat.size); const tailLines = parseChunks(raw, safeName).filter(l => l.type !== "SESSION START"); const recent = tailLines.slice(-count); const lines = header ? [header, ...recent] : recent; return new Response(JSON.stringify(lines), { headers }); } // Session log file missing — fall back to transcript const transcriptPath = resolveTranscript(safeName); if (transcriptPath) { const raw = await readFile(transcriptPath, "utf-8"); const tLines = parseTranscriptLines(raw, safeName); const recent = tLines.slice(-count); return new Response(JSON.stringify(recent), { headers }); } return new Response(JSON.stringify([]), { headers }); } const files = await readdir(sessDir); const logFiles = files.filter((f) => f.endsWith(".log")); const withStats = await Promise.all( logFiles.map(async (f) => { const s = await stat(join(sessDir, f)); return { name: f, mtime: s.mtimeMs, size: s.size }; }) ); withStats.sort((a, b) => b.mtime - a.mtime); const allLines: LogLine[] = []; for (const f of withStats.slice(0, 5)) { const raw = await tailFile(join(sessDir, f.name), f.size); allLines.push(...parseChunks(raw, f.name)); } allLines.sort((a, b) => { const ta = new Date(a.ts).getTime() || 0; const tb = new Date(b.ts).getTime() || 0; return tb - ta; }); return new Response(JSON.stringify(allLines.slice(0, count)), { headers }); } catch (err: any) { return new Response(JSON.stringify({ error: err.message }), { status: 500, headers }); } };