pi-system/extensions/session-index.ts

279 lines
9 KiB
TypeScript
Raw Normal View History

/**
* 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
}
});
}