Enthält alle Pi-Orchestrator-Infrastrukturkomponenten: - bin/Sub* Skripte (SubAgenten, SubStatus, SubWatcher, SubConfirm) - extensions/ (arbeitsweise-guard, confirm-deletion, etc.) - memory/ (arbeitsweise, subagent-autocheck) - agent/AGENTS.md mit SubConfirm-Reaktionslogik - install.sh: deterministisches, idempotentes Setup für neue Maschinen SubConfirm (neu): Stasis-Detektor der alle 30s tmux-Sessions prüft. Bei unverändertem Output sendet er den vollständigen Pane-Inhalt an die Alert-Datei — der Orchestrator beurteilt selbst ob Handlung nötig. Kein Keyword-Matching.
278 lines
9 KiB
TypeScript
278 lines
9 KiB
TypeScript
/**
|
||
* 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
|
||
}
|
||
});
|
||
}
|