diff --git a/routes/sessionStoreReader.js b/routes/sessionStoreReader.js new file mode 100644 index 0000000..d7773b9 --- /dev/null +++ b/routes/sessionStoreReader.js @@ -0,0 +1,193 @@ +/** + * Session store reader -- enriches session discovery with metadata from + * Copilot CLI's local SQLite databases. + * + * Reads: + * ~/.copilot/session-store.db -- summary, repo, branch, host_type, turns + * ~/.copilot/data.db -- model, reasoning_effort, custom title + * + * Gracefully returns empty results if databases do not exist. + */ + +import path from "path"; +import fs from "fs"; + +var _DatabaseSync = null; + +function getDatabase() { + if (_DatabaseSync !== null) return _DatabaseSync; + try { + _DatabaseSync = require("node:sqlite").DatabaseSync; + } catch (e) { + _DatabaseSync = false; + } + return _DatabaseSync; +} + +function openDB(dbPath) { + var DB = getDatabase(); + if (!DB) return null; + try { + if (!fs.existsSync(dbPath)) return null; + return new DB(dbPath, { readOnly: true }); + } catch (e) { + return null; + } +} + +function safeClose(db) { + if (!db) return; + try { db.close(); } catch (e) {} +} + +/** + * Read session metadata from session-store.db. + * Returns a Map keyed by session ID. + */ +export function readSessionStoreMetadata(homeDir) { + var result = new Map(); + var dbPath = path.join(homeDir, ".copilot", "session-store.db"); + var db = openDB(dbPath); + if (!db) return result; + + try { + var rows = db.prepare( + "SELECT s.id, s.cwd, s.repository, s.branch, s.summary, s.host_type, s.created_at, s.updated_at, " + + "(SELECT COUNT(*) FROM turns t WHERE t.session_id = s.id) as turn_count " + + "FROM sessions s ORDER BY s.updated_at DESC LIMIT 500" + ).all(); + + for (var i = 0; i < rows.length; i++) { + var row = rows[i]; + result.set(row.id, { + summary: row.summary || null, + repository: row.repository || null, + branch: row.branch || null, + cwd: row.cwd || null, + hostType: row.host_type || null, + turnCount: row.turn_count || 0, + createdAt: row.created_at || null, + updatedAt: row.updated_at || null, + }); + } + } catch (e) { + // Database may have different schema version + } + + safeClose(db); + return result; +} + +/** + * Read session metadata from data.db (Copilot CLI app database). + * Returns a Map keyed by session ID. + */ +export function readAppDbMetadata(homeDir) { + var result = new Map(); + var dbPath = path.join(homeDir, ".copilot", "data.db"); + var db = openDB(dbPath); + if (!db) return result; + + try { + var rows = db.prepare( + "SELECT id, title, model, reasoning_effort, session_type, mode FROM sessions ORDER BY updated_at DESC LIMIT 500" + ).all(); + + for (var i = 0; i < rows.length; i++) { + var row = rows[i]; + result.set(row.id, { + title: row.title || null, + model: row.model || null, + reasoningEffort: row.reasoning_effort || null, + sessionType: row.session_type || null, + mode: row.mode || null, + }); + } + } catch (e) { + // Database may have different schema version + } + + safeClose(db); + return result; +} + +/** + * Merge store metadata into a list of session results (mutates in place). + * Also returns store-only sessions (in store but not in results). + */ +export function enrichSessionResults(results, homeDir) { + var storeData = readSessionStoreMetadata(homeDir); + var appData = readAppDbMetadata(homeDir); + + if (storeData.size === 0 && appData.size === 0) return []; + + var seenIds = new Set(); + + for (var i = 0; i < results.length; i++) { + var entry = results[i]; + if (entry.format !== "copilot-cli" || !entry.sessionId) continue; + + seenIds.add(entry.sessionId); + + var store = storeData.get(entry.sessionId); + if (store) { + // Prefer store summary over workspace.yaml/first-message fallback + if (store.summary && (!entry.summary || entry.summary.length < store.summary.length)) { + entry.summary = store.summary; + entry.project = store.summary; + } + if (store.repository && !entry.repository) entry.repository = store.repository; + if (store.branch && !entry.branch) entry.branch = store.branch; + if (store.hostType) entry.hostType = store.hostType; + if (store.turnCount) entry.turnCount = store.turnCount; + } + + var app = appData.get(entry.sessionId); + if (app) { + if (app.title) entry.customTitle = app.title; + if (app.model) entry.model = app.model; + if (app.reasoningEffort) entry.reasoningEffort = app.reasoningEffort; + if (app.sessionType) entry.sessionType = app.sessionType; + if (app.mode) entry.mode = app.mode; + } + } + + // Find store-only sessions (have events.jsonl but not in scan results) + var extras = []; + var copilotRoot = path.join(homeDir, ".copilot", "session-state"); + + storeData.forEach(function (store, sessionId) { + if (seenIds.has(sessionId)) return; + + var eventsFile = path.join(copilotRoot, sessionId, "events.jsonl"); + try { + var stat = fs.statSync(eventsFile); + var app = appData.get(sessionId) || {}; + extras.push({ + id: "copilot-cli:" + sessionId + ":events.jsonl", + path: eventsFile, + filename: "events.jsonl", + file: store.summary || "events.jsonl", + project: store.summary || sessionId.substring(0, 8), + projectDir: sessionId, + sessionId: sessionId, + repository: store.repository || null, + branch: store.branch || null, + summary: store.summary || null, + format: "copilot-cli", + source: "store", + size: stat.size, + mtime: stat.mtime.toISOString(), + hostType: store.hostType || null, + turnCount: store.turnCount || 0, + model: app.model || null, + reasoningEffort: app.reasoningEffort || null, + customTitle: app.title || null, + }); + } catch (e) { + // events.jsonl doesn't exist -- skip + } + }); + + return extras; +} diff --git a/routes/sessions.js b/routes/sessions.js index c25aea3..d51c1c3 100644 --- a/routes/sessions.js +++ b/routes/sessions.js @@ -2,8 +2,9 @@ * Session discovery, file serving, and SSE streaming routes. * * Handles: - * GET /api/sessions -- discover Claude Code, Codex, VS Code, & Copilot CLI sessions + * GET /api/sessions -- discover Claude Code, Codex, VS Code, Copilot CLI, & shared sessions * GET /api/session -- serve a single session file from HOME + * GET /api/session/shared -- serve a shared session markdown or gist * GET /api/file -- serve the active watched session file * GET /api/meta -- return filename & live status * GET /api/stream -- SSE endpoint for live session updates @@ -11,6 +12,8 @@ import fs from "fs"; import path from "path"; +import { enrichSessionResults } from "./sessionStoreReader.js"; +import { findSharedSessionFiles, findSharedSessionGists, parseSharedSessionMarkdown } from "./sharedSessions.js"; function decodeProjectDir(dirName) { return (dirName || "").replace(/^-/, "").replace(/-/g, "/"); @@ -332,6 +335,11 @@ function isCodexSessionPath(resolvedSessionPath, homeDir) { && /^rollout-.+\.jsonl$/.test(parts[3]); } +export function isAllowedSharedPath(resolvedPath, resolvedCwd) { + if (!resolvedCwd) return false; + return isPathInsideRoot(resolvedCwd, resolvedPath); +} + export function isAllowedSessionPath(resolvedSessionPath, homeDir) { if (!homeDir) return false; @@ -501,12 +509,78 @@ export function handle(pathname, req, res, ctx) { } catch (e) {} }); + // Enrich Copilot CLI sessions with metadata from session-store.db and data.db + var storeExtras = []; + try { + storeExtras = enrichSessionResults(results, homeDir); + } catch (e) {} + if (storeExtras.length > 0) { + results = results.concat(storeExtras); + } + + // Discover shared sessions (local markdown + gists) + var cwd = process.cwd(); + try { + var sharedFiles = findSharedSessionFiles(cwd); + results = results.concat(sharedFiles); + } catch (e) {} + + try { + var sharedGists = findSharedSessionGists(); + results = results.concat(sharedGists); + } catch (e) {} + results.sort(function (a, b) { return new Date(b.mtime) - new Date(a.mtime); }); res.writeHead(200); res.end(JSON.stringify(results.slice(0, 200))); return true; } + if (pathname === "/api/session/shared") { + res.setHeader("Content-Type", "application/json"); + if (req.method !== "GET") { res.writeHead(405); res.end(JSON.stringify({ error: "Method not allowed" })); return true; } + + var sharedPath = ctx.parsed.query.path; + var gistId = ctx.parsed.query.gist; + + if (sharedPath) { + // Local shared markdown file + try { + var resolvedShared = fs.realpathSync(path.resolve(sharedPath)); + if (!resolvedShared.endsWith(".md")) { res.writeHead(400); res.end(JSON.stringify({ error: "Only .md files" })); return true; } + var resolvedCwdShared = fs.realpathSync(process.cwd()); + if (!isAllowedSharedPath(resolvedShared, resolvedCwdShared)) { res.writeHead(403); res.end(JSON.stringify({ error: "Forbidden" })); return true; } + var mdText = fs.readFileSync(resolvedShared, "utf8"); + var parsed = parseSharedSessionMarkdown(mdText); + res.writeHead(200); + res.end(JSON.stringify({ source: "file", raw: mdText, parsed: parsed })); + } catch (e) { + res.writeHead(404); + res.end(JSON.stringify({ error: "Not found" })); + } + return true; + } + + if (gistId) { + // Download gist content + try { + var { execFileSync } = require("child_process"); + var gistOutput = execFileSync("gh", ["gist", "view", gistId, "--raw"], { timeout: 15000, encoding: "utf8" }); + var parsedGist = parseSharedSessionMarkdown(gistOutput); + res.writeHead(200); + res.end(JSON.stringify({ source: "gist", raw: gistOutput, parsed: parsedGist })); + } catch (e) { + res.writeHead(404); + res.end(JSON.stringify({ error: "Gist not found or gh CLI unavailable" })); + } + return true; + } + + res.writeHead(400); + res.end(JSON.stringify({ error: "Provide path or gist query param" })); + return true; + } + if (pathname === "/api/session") { res.setHeader("Content-Type", "text/plain; charset=utf-8"); if (req.method !== "GET") { res.writeHead(405); res.end("Method not allowed"); return true; } diff --git a/routes/sharedSessions.js b/routes/sharedSessions.js new file mode 100644 index 0000000..7f71898 --- /dev/null +++ b/routes/sharedSessions.js @@ -0,0 +1,224 @@ +/** + * Shared session discovery -- finds Copilot CLI sessions exported via + * /share file (local markdown) and /share gist (GitHub gists). + */ + +import fs from "fs"; +import path from "path"; +import { execFileSync } from "child_process"; + +/** + * Discover copilot-session-*.md files in a directory. + */ +export function findSharedSessionFiles(searchDir) { + var results = []; + if (!searchDir) return results; + + try { + var files = fs.readdirSync(searchDir); + for (var i = 0; i < files.length; i++) { + var fname = files[i]; + if (!fname.startsWith("copilot-session-") || !fname.endsWith(".md")) continue; + var filePath = path.join(searchDir, fname); + try { + var stat = fs.statSync(filePath); + if (!stat.isFile() || stat.size < 50) continue; + + var sessionId = fname.replace("copilot-session-", "").replace(".md", ""); + var preview = readSharedSessionPreview(filePath); + + results.push({ + id: "shared-file:" + fname, + path: filePath, + filename: fname, + file: preview.title || fname, + project: preview.title || "Shared session", + sessionId: sessionId, + summary: preview.title || null, + format: "shared-md", + source: "shared-file", + size: stat.size, + mtime: stat.mtime.toISOString(), + duration: preview.duration || null, + startedAt: preview.startedAt || null, + }); + } catch (e) {} + } + } catch (e) {} + + return results; +} + +/** + * Read the header of a shared session markdown file to extract metadata. + * + * Format: + * # Copilot CLI Session + * > **Session ID:** `` + * > **Started:** + * > **Duration:**