pi-system/extensions/session-index.ts
Raimund Bauer 1f2c3ecae8 fix/extensions: setWidget erwartet Factory-Funktion, nicht Widget-Objekt
Pi's setWidget-API ruft den zweiten Parameter als content(ui, theme) auf.
session-header.ts und session-index.ts übergaben direkt das Widget-Objekt
— jetzt als (_ui, theme) => makeWidget(...) gewrappt.
2026-06-02 15:47:39 +02:00

278 lines
9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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", (_ui: any, theme: any) => makeIndexWidget(sessions, 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
}
});
}