2026-06-02 11:53:37 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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)
|
2026-06-02 15:47:39 +02:00
|
|
|
|
ctx.ui.setWidget("session-index", (_ui: any, theme: any) => makeIndexWidget(sessions, theme));
|
2026-06-02 11:53:37 +02:00
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|