/** * Session Index Extension * * Pflegt ein projektübergreifendes Inhaltsverzeichnis der letzten 10 Sessions * in ~/.pi/agent/SESSION_INDEX.md. * * Bei Session-Start: * - Zeigt die Tabelle als Widget über dem Editor an * - Prüft ob die Vorgänger-Session ein SESSION_HANDOVER.md hat → fragt Benutzer * * Bei Session-Shutdown: * - Aktualisiert den Index mit dem endgültigen Session-Namen */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; const AGENT_DIR = path.join(os.homedir(), ".pi", "agent"); const SESSIONS_DIR = path.join(AGENT_DIR, "sessions"); const INDEX_PATH = path.join(AGENT_DIR, "SESSION_INDEX.md"); const MAX_SESSIONS = 10; interface SessionInfo { name: string; date: string; project: string; jsonlPath: string; hasHandover: boolean; handoverPath: string | null; } // --------------------------------------------------------------------------- // Hilfsfunktionen // --------------------------------------------------------------------------- /** Alle JSONL-Session-Dateien rekursiv finden */ function findAllSessions(): string[] { const results: string[] = []; if (!fs.existsSync(SESSIONS_DIR)) return results; const dirs = fs.readdirSync(SESSIONS_DIR); for (const dir of dirs) { const dirPath = path.join(SESSIONS_DIR, dir); if (!fs.statSync(dirPath).isDirectory()) continue; const files = fs.readdirSync(dirPath); for (const file of files) { if (file.endsWith(".jsonl")) { results.push(path.join(dirPath, file)); } } } return results; } /** Session-Namen aus dem JSONL extrahieren (prüft session_info und header) */ function getSessionName(jsonlPath: string): string | null { try { const content = fs.readFileSync(jsonlPath, "utf-8"); const lines = content.split("\n"); // Zuerst nach session_info suchen (enthält den tatsächlichen Namen) for (const line of lines) { if (!line.trim()) continue; try { const entry = JSON.parse(line); if (entry.type === "session_info" && entry.name) { return entry.name; } } catch { /* next line */ } } // Fallback: header.name const firstLine = lines[0]; if (firstLine) { const header = JSON.parse(firstLine); if (header.name) return header.name; } return null; } catch { return null; } } /** Timestamp aus dem Dateinamen extrahieren (Format: YYYY-MM-DDTHH-MM-SS...) */ function extractTimestamp(jsonlPath: string): string { const basename = path.basename(jsonlPath, ".jsonl"); const match = basename.match(/^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2})/); return match ? match[1].replace("T", " ") : basename; } /** Projektname aus dem Verzeichnisnamen ableiten */ function extractProject(jsonlPath: string): string { const dirName = path.basename(path.dirname(jsonlPath)); // Entferne führende/trailing "--" return dirName.replace(/^--|--$/g, "").replace(/--/g, " / "); } /** CWD (Arbeitsverzeichnis) aus dem Session-Header der JSONL lesen */ function getSessionCwd(jsonlPath: string): string | null { try { const firstLine = fs.readFileSync(jsonlPath, "utf-8").split("\n")[0]; if (!firstLine) return null; const header = JSON.parse(firstLine); return header.cwd || null; } catch { return null; } } /** Nach SESSION_HANDOVER.md im Projektverzeichnis suchen */ function findHandover(jsonlPath: string): { hasHandover: boolean; handoverPath: string | null } { // CWD direkt aus dem JSONL-Header lesen (zuverlässig, keine Slug-Konvertierung nötig) const cwd = getSessionCwd(jsonlPath); if (cwd) { const handoverPath = path.join(cwd, "SESSION_HANDOVER.md"); if (fs.existsSync(handoverPath)) { return { hasHandover: true, handoverPath }; } } // Fallback: Versuche den neuesten JSONL im selben Projekt-Verzeichnis const dirPath = path.dirname(jsonlPath); const files = fs.readdirSync(dirPath) .filter(f => f.endsWith(".jsonl")) .sort() .reverse(); for (const f of files) { const c = getSessionCwd(path.join(dirPath, f)); if (c) { const hp = path.join(c, "SESSION_HANDOVER.md"); if (fs.existsSync(hp)) { return { hasHandover: true, handoverPath: hp }; } } } return { hasHandover: false, handoverPath: null }; } /** SESSION_INDEX.md schreiben */ function writeIndex(sessions: SessionInfo[]): void { const lines: string[] = [ "# Session-Index (letzte Sessions)", "", `> **Stand:** ${new Date().toLocaleString('sv-SE', { timeZone: 'Europe/Berlin' }).slice(0, 19)}`, `> **Pfad:** \`${INDEX_PATH}\``, `> **Quelle:** \`~/.pi/agent/extensions/session-index.ts\``, "", "| # | Datum | Projekt | Session-Name | Handover |", "|---|-------|---------|-------------|----------|", ]; for (let i = 0; i < sessions.length; i++) { const s = sessions[i]; const handover = s.hasHandover ? `[✓](${s.handoverPath})` : "–"; lines.push(`| ${i + 1} | ${s.date.slice(0, 16)} | ${s.project} | ${s.name.length > 70 ? s.name.slice(0, 67) + "..." : s.name} | ${handover} |`); } lines.push("", "---", "", "*Automatisch generiert von session-index.ts*", ""); try { fs.mkdirSync(path.dirname(INDEX_PATH), { recursive: true }); fs.writeFileSync(INDEX_PATH, lines.join("\n"), "utf-8"); } catch (err) { // Silent fail — Index ist optional, Widget reicht } } /** Sessions laden, sortieren und Top-N filtern */ function loadSessionIndex(): SessionInfo[] { const files = findAllSessions(); const sessions: SessionInfo[] = []; for (const file of files) { const name = getSessionName(file); const { hasHandover, handoverPath } = findHandover(file); sessions.push({ name: name || extractTimestamp(file) + " (unbenannt)", date: extractTimestamp(file), project: extractProject(file), jsonlPath: file, hasHandover, handoverPath, }); } // Nach Datum absteigend sortieren sessions.sort((a, b) => b.date.localeCompare(a.date)); return sessions.slice(0, MAX_SESSIONS); } // --------------------------------------------------------------------------- // Widget // --------------------------------------------------------------------------- function makeIndexWidget(sessions: SessionInfo[], theme: any) { return { render(_width: number): string[] { const lines: string[] = []; lines.push(theme.fg("warning", theme.bold("═══ Letzte Sessions ═══"))); for (let i = 0; i < sessions.length && i < 5; i++) { const s = sessions[i]; const date = s.date.slice(5, 16); // MM-DD HH:MM const handover = s.hasHandover ? theme.fg("success", " ●") : ""; const entry = theme.fg("dim", `${date}`) + ` ${s.name.length > 60 ? s.name.slice(0, 57) + "..." : s.name}${handover}`; lines.push(entry); } lines.push(theme.fg("dim", `Vollständiger Index: ${INDEX_PATH}`)); return lines; }, invalidate() {}, }; } // --------------------------------------------------------------------------- // Extension // --------------------------------------------------------------------------- export default function (pi: ExtensionAPI) { pi.on("session_start", async (_event, ctx) => { if (!ctx.hasUI) return; try { const sessions = loadSessionIndex(); // Aktuellen Session-Namen ergänzen, falls schon gesetzt const currentName = pi.getSessionName(); if (currentName && sessions.length > 0) { sessions[0].name = currentName; } // Index-Datei schreiben writeIndex(sessions); // Widget anzeigen (oberhalb des Editors) ctx.ui.setWidget("session-index", makeIndexWidget(sessions, ctx.ui.theme)); // Prüfen ob Vorgänger-Session einen Handover hat if (sessions.length >= 2 && sessions[1].hasHandover) { const prev = sessions[1]; const msg = `Handover gefunden: ${prev.name}\nPfad: ${prev.handoverPath}`; const load = await ctx.ui.confirm( "Handover laden?", `${msg}\n\nSoll der Handover der vorherigen Session in den Kontext geladen werden?`, ); if (load && prev.handoverPath) { try { const content = fs.readFileSync(prev.handoverPath, "utf-8"); // Als System-Prompt-Zusatz einfügen via Benutzernachricht ctx.ui.notify(`Handover geladen: ${path.basename(prev.handoverPath)}`, "info"); pi.sendUserMessage( `[Handover aus vorheriger Session]\n\n${content.slice(0, 3000)}`, { deliverAs: "nextTurn" }, ); } catch { ctx.ui.notify("Fehler beim Laden des Handovers", "error"); } } } } catch (err) { // Silent fail — Index ist optional } }); pi.on("session_shutdown", async (_event, _ctx) => { // Index beim Beenden nochmal aktualisieren (finaler Name) try { const sessions = loadSessionIndex(); writeIndex(sessions); } catch { // Silent fail } }); }