pi-system/extensions/session-header.ts

170 lines
5.3 KiB
TypeScript
Raw Normal View History

/**
* Session Header Extension
*
* Zeigt den erkannten Sitzungszweck (Intention) als gelbe Überschrift
* oberhalb des Editors an. Nutzt das LLM zur Zusammenfassung der ersten
* Benutzereingabe, statt sie 1:1 zu transkribieren.
*
* Farbe: Gelb (warning), damit es sich von anderen UI-Elementen abhebt.
* Fallback: Gekürzter Originaltext, wenn die LLM-Zusammenfassung fehlschlägt.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { complete, getModel } from "@earendil-works/pi-ai";
const MAX_TITLE_LENGTH = 160;
const MAX_SUMMARY_TOKENS = 60;
function truncate(text: string): string {
const trimmed = text.trim();
if (trimmed.length <= MAX_TITLE_LENGTH) return trimmed;
return trimmed.slice(0, MAX_TITLE_LENGTH - 3) + "\u2026";
}
/**
* Ruft das aktuelle LLM auf, um aus der Benutzereingabe eine kurze
* Intention-Zusammenfassung (512 Wörter) zu extrahieren.
*/
async function generateIntentionSummary(
input: string,
ctx: any,
): Promise<string | null> {
try {
const provider = ctx.model?.provider;
const modelId = ctx.model?.id;
if (!provider || !modelId) return null;
const model = getModel(provider, modelId);
if (!model) return null;
const registry = ctx.modelRegistry;
const auth = registry
? await registry.getApiKeyAndHeaders(model)
: null;
if (!auth?.ok || !auth.apiKey) return null;
const prompt = [
"Du extrahierst aus der folgenden Benutzereingabe maximal 510 Wörter",
"als präzisen Session-Titel. Fasse die KERN-Intention zusammen,",
"nicht die wörtliche Formulierung. Antworte NUR mit dem Titel,",
"keinem weiteren Text.",
"",
"Eingabe:",
input,
].join("\n");
const response = await complete(
model,
{
messages: [
{
role: "user",
content: [{ type: "text" as const, text: prompt }],
timestamp: Date.now(),
},
],
},
{
apiKey: auth.apiKey,
headers: auth.headers,
maxTokens: MAX_SUMMARY_TOKENS,
},
);
const textParts = response.content
.filter((c: any): c is { type: "text"; text: string } => c.type === "text")
.map((c: any) => c.text)
.join(" ")
.trim();
if (!textParts) return null;
// Entferne mögliche Anführungszeichen, die das LLM setzt
return textParts.replace(/^["']|["']$/g, "").substring(0, MAX_TITLE_LENGTH);
} catch (err) {
// Bei Fehler (z.B. API nicht erreichbar, Modell nicht gefunden): Fallback auf truncate
return null;
}
}
function makeWidget(title: string | null, theme: any) {
return {
render(_width: number): string[] {
if (!title) {
return [theme.fg("warning", "\u25C6 Sitzung: (warte auf erste Eingabe\u2026)")];
}
const label = theme.fg("warning", theme.bold("\u25C6"));
const text = theme.fg("warning", ` ${title}`);
return [`${label}${text}`];
},
invalidate() {},
};
}
export default function (pi: ExtensionAPI) {
let sessionTitle: string | null = null;
pi.on("session_start", async (_event, ctx) => {
if (!ctx.hasUI) return;
// Bestehende Session: ersten User-Eintrag finden und zusammenfassen
const entries = ctx.sessionManager.getEntries();
for (const entry of entries) {
if (entry.type === "message" && (entry as any).message?.role === "user") {
const text = (entry as any).message.content
.filter((c: any) => c.type === "text")
.map((c: any) => c.text)
.join(" ");
if (text.trim()) {
// Bei Wiederaufnahme: versuche Zusammenfassung asynchron
generateIntentionSummary(text, ctx).then((summary) => {
if (summary) {
sessionTitle = summary;
pi.setSessionName(sessionTitle);
ctx.ui.setWidget(
"session-header",
(_ui: any, theme: any) => makeWidget(sessionTitle, theme),
);
}
}).catch(() => {
// Silent fail — truncate reicht als Fallback
});
// Interim: gekürzten Originaltext anzeigen, bis Summary da ist
sessionTitle = truncate(text);
pi.setSessionName(sessionTitle);
break;
}
}
}
ctx.ui.setWidget("session-header", (_ui: any, theme: any) => makeWidget(sessionTitle, theme));
});
// Erste Benutzereingabe: Intention erkennen statt 1:1 übernehmen
pi.on("input", async (event, ctx) => {
if (!ctx.hasUI) return;
if (sessionTitle) return { action: "continue" };
// Interim: sofort gekürzten Originaltext anzeigen
sessionTitle = truncate(event.text);
pi.setSessionName(sessionTitle);
ctx.ui.setWidget("session-header", (_ui: any, theme: any) => makeWidget(sessionTitle, theme));
// Async: LLM-Zusammenfassung nachladen
generateIntentionSummary(event.text, ctx).then((summary) => {
if (summary) {
sessionTitle = summary;
pi.setSessionName(sessionTitle);
ctx.ui.setWidget("session-header", (_ui: any, theme: any) => makeWidget(sessionTitle, theme));
}
}).catch(() => {
// Silent fail — truncate reicht als Fallback
});
return { action: "continue" };
});
pi.on("session_before_switch", async () => {
sessionTitle = null;
});
}