Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/session-rename/index.ts

Raw
/**
 * session-rename — auto-generate and refine short Pi session names
 *
 * Policy:
 *   - User/manual names are never overwritten automatically.
 *   - Extension-generated names are continuously refined with model "keep or rename" judgement.
 *   - Rename cadence slows as session grows to reduce churn.
 *   - Names are 1–3 words.
 */

import type {
	ExtensionAPI,
	ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { closeDebug, dbg, span } from "./src/debug.ts";
import {
	MAX_TIMER_MS,
	parseNumberSetting,
	resolveSetting,
	type SettingDeclaration,
} from "./src/pi-ext-settings.ts";

type ContentBlock = {
	type?: string;
	text?: string;
	name?: string;
	arguments?: Record<string, unknown>;
};
type Entry = {
	id?: string;
	type: string;
	customType?: string;
	data?: unknown;
	summary?: string;
	message?: { role?: string; content?: unknown; toolName?: string };
};
type Model = NonNullable<ExtensionContext["model"]>;
type RenameReason = "load" | "turn" | "manual";

type MarkerData = {
	kind?: "auto-title" | "user-lock";
	name?: string;
	userTurnCount?: number;
	revision?: number;
	previousNames?: string[];
};

type TitleState =
	| { mode: "none"; latestMarker?: MarkerData }
	| { mode: "auto"; name: string; latestMarker: MarkerData }
	| { mode: "manual"; name?: string; latestMarker?: MarkerData };

const CUSTOM_TYPE = "session-rename";
const STATUS_KEY = "session-rename";
const IDLE_DELAY_SETTING: SettingDeclaration<number> = {
	key: "session-rename.idleDelayMs",
	env: "PI_SESSION_RENAME_IDLE_DELAY_MS",
	default: 10_000,
	parse: (raw, source) => {
		const value = parseNumberSetting(raw, source);
		if (value === undefined) return undefined;
		if (!Number.isInteger(value) || value < 0 || value > MAX_TIMER_MS)
			throw new Error(`expected an integer from 0 to ${MAX_TIMER_MS}`);
		return value;
	},
};
const LOAD_DELAY_MS = 700;
const MAX_TRANSCRIPT_CHARS = 8000;
const MAX_NAME_WORDS = 3;
const SMALL_EDIT_DISTANCE = 2;
const GENERIC_SINGLE_WORDS = new Set([
	"task",
	"work",
	"coding",
	"chat",
	"conversation",
	"session",
]);

// ── Text/session helpers ──────────────────────────────────────────────────

function extractText(content: unknown): string {
	if (typeof content === "string") return content;
	if (!Array.isArray(content)) return "";
	return (content as ContentBlock[])
		.flatMap((p) =>
			p?.type === "text" && typeof p.text === "string" ? [p.text] : [],
		)
		.join("\n");
}

function extractToolCalls(content: unknown): string[] {
	if (!Array.isArray(content)) return [];
	return (content as ContentBlock[])
		.filter((p) => p?.type === "toolCall" && typeof p.name === "string")
		.map((p) => {
			const summary = JSON.stringify(p.arguments ?? {}).slice(0, 240);
			return `Tool: ${p.name}(${summary})`;
		});
}

function countUserTurns(entries: Entry[]): number {
	let count = 0;
	for (const e of entries) {
		if (
			e.type === "message" &&
			e.message?.role === "user" &&
			extractText(e.message.content).trim()
		)
			count++;
	}
	return count;
}

function hasMeaningfulActivity(entries: Entry[]): boolean {
	let users = 0;
	let assistants = 0;
	let toolCalls = 0;
	for (const e of entries) {
		if (e.type !== "message" || !e.message?.role) continue;
		if (e.message.role === "user" && extractText(e.message.content).trim())
			users++;
		if (e.message.role === "assistant") {
			if (extractText(e.message.content).trim()) assistants++;
			toolCalls += extractToolCalls(e.message.content).length;
		}
	}
	return users > 0 && (assistants > 0 || toolCalls > 0);
}

function buildTranscript(
	entries: Entry[],
	oldName: string | undefined,
): string {
	const firstUsers: string[] = [];
	const summaries: string[] = [];
	const messageLines: string[] = [];
	const messageEntries = entries.filter(
		(e) => e.type === "message" && e.message?.role,
	);

	for (const e of entries) {
		if (
			(e.type === "compaction" || e.type === "branch_summary") &&
			typeof e.summary === "string" &&
			e.summary.trim()
		) {
			summaries.push(`${e.type}: ${e.summary.trim().slice(0, 1200)}`);
		}
		if (
			e.type === "message" &&
			e.message?.role === "user" &&
			firstUsers.length < 3
		) {
			const text = extractText(e.message.content).trim();
			if (text)
				firstUsers.push(
					`User goal ${firstUsers.length + 1}: ${text.slice(0, 1000)}`,
				);
		}
	}

	for (const e of messageEntries.slice(-10)) {
		const role = e.message?.role;
		if (role === "user") {
			const text = extractText(e.message?.content).trim();
			if (text) messageLines.push(`Recent user: ${text.slice(0, 900)}`);
		} else if (role === "assistant") {
			const text = extractText(e.message?.content).trim();
			if (text) messageLines.push(`Recent assistant: ${text.slice(0, 900)}`);
			messageLines.push(...extractToolCalls(e.message?.content).slice(0, 8));
		} else if (role === "toolResult") {
			const text = extractText(e.message?.content).trim();
			if (text)
				messageLines.push(
					`Result(${e.message?.toolName ?? "tool"}): ${text.slice(0, 300)}`,
				);
		}
	}

	const sections = [
		oldName ? `Current name: ${oldName}` : "Current name: <none>",
		firstUsers.length ? `Initial goals:\n${firstUsers.join("\n")}` : "",
		summaries.length ? `Summaries:\n${summaries.slice(-2).join("\n")}` : "",
		messageLines.length ? `Recent activity:\n${messageLines.join("\n")}` : "",
	].filter(Boolean);

	return sections.join("\n\n").slice(0, MAX_TRANSCRIPT_CHARS);
}

function currentSessionFile(ctx: ExtensionContext): string | undefined {
	try {
		return ctx.sessionManager.getSessionFile();
	} catch {
		return undefined;
	}
}

function currentName(ctx: ExtensionContext): string | undefined {
	try {
		return ctx.sessionManager.getSessionName();
	} catch {
		return undefined;
	}
}

function allEntries(ctx: ExtensionContext): Entry[] {
	try {
		return ctx.sessionManager.getEntries() as Entry[];
	} catch {
		return [];
	}
}

function branchEntries(ctx: ExtensionContext): Entry[] {
	try {
		return ctx.sessionManager.getBranch() as Entry[];
	} catch {
		return [];
	}
}

// ── Marker/manual-vs-auto state ────────────────────────────────────────────

function isMarkerData(data: unknown): data is MarkerData {
	return (
		typeof data === "object" &&
		data !== null &&
		((data as MarkerData).kind === "auto-title" ||
			(data as MarkerData).kind === "user-lock")
	);
}

function getTitleState(ctx: ExtensionContext): TitleState {
	const entries = allEntries(ctx);
	const name = currentName(ctx);
	let latestSessionInfoIndex = -1;
	let latestSessionInfoName: string | undefined;
	let latestMarkerIndex = -1;
	let latestMarker: MarkerData | undefined;

	for (let i = entries.length - 1; i >= 0; i--) {
		const e = entries[i] as Entry & { name?: string };
		if (latestSessionInfoIndex < 0 && e.type === "session_info") {
			latestSessionInfoIndex = i;
			latestSessionInfoName =
				typeof e.name === "string" ? e.name.trim() : undefined;
		}
		if (
			latestMarkerIndex < 0 &&
			e.type === "custom" &&
			e.customType === CUSTOM_TYPE &&
			isMarkerData(e.data)
		) {
			latestMarkerIndex = i;
			latestMarker = e.data;
		}
		if (latestSessionInfoIndex >= 0 && latestMarkerIndex >= 0) break;
	}

	if (
		latestMarker?.kind === "user-lock" &&
		latestMarkerIndex > latestSessionInfoIndex
	) {
		return { mode: "manual", name, latestMarker };
	}

	if (name) {
		const markerMatches =
			latestMarker?.kind === "auto-title" &&
			canonicalName(latestMarker.name ?? "") === canonicalName(name);
		if (
			latestMarker &&
			markerMatches &&
			latestMarkerIndex > latestSessionInfoIndex
		) {
			return { mode: "auto", name, latestMarker };
		}
		return { mode: "manual", name, latestMarker };
	}

	// Explicitly-cleared title counts as manual; no surprise re-title after user clears it.
	if (
		latestSessionInfoIndex >= 0 &&
		!latestSessionInfoName &&
		latestMarkerIndex < latestSessionInfoIndex
	) {
		return { mode: "manual", latestMarker };
	}

	return { mode: "none", latestMarker };
}

function appendAutoMarker(
	pi: ExtensionAPI,
	name: string,
	turnCount: number,
	previous?: MarkerData,
) {
	const priorNames = [
		...(previous?.previousNames ?? []),
		previous?.name,
	].filter((n): n is string => typeof n === "string" && n.trim().length > 0);

	const uniquePrior = Array.from(
		new Map(priorNames.map((n) => [canonicalName(n), n])).values(),
	).slice(-8);
	pi.appendEntry<MarkerData>(CUSTOM_TYPE, {
		kind: "auto-title",
		name,
		userTurnCount: turnCount,
		revision: (previous?.revision ?? 0) + 1,
		previousNames: uniquePrior,
	});
}

function appendUserLock(pi: ExtensionAPI, name: string, turnCount: number) {
	pi.appendEntry<MarkerData>(CUSTOM_TYPE, {
		kind: "user-lock",
		name,
		userTurnCount: turnCount,
	});
}

// ── Name generation/normalization ──────────────────────────────────────────

async function generateName(
	transcript: string,
	oldName: string | undefined,
	ctx: ExtensionContext,
	signal: AbortSignal,
): Promise<string | undefined> {
	const model: Model | undefined = ctx.model;
	if (!model) return undefined;

	const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
	if (!auth.ok) return undefined;

	const prompt =
		"Generate or refine a short display name for a Pi coding-agent session.\n\n" +
		`Current auto-generated name: ${oldName ? `"${oldName}"` : "<none>"}\n\n` +
		"Rules:\n" +
		"- Output exactly one line.\n" +
		"- Name must be 1-3 words.\n" +
		"- No quotes, markdown, emoji, punctuation, or trailing period.\n" +
		"- Prefer concrete nouns: project, file, feature, bug, domain.\n" +
		"- If current name is still accurate enough, output it exactly.\n" +
		"- Rename only if durable topic changed or current name is misleading/vague.\n" +
		"- Avoid synonyms, grammar tweaks, or style-only changes.\n" +
		"- Do not chase the latest incidental tool/action.\n" +
		"- Avoid generic one-word names like task, work, coding, chat, conversation, session.\n\n" +
		"<session>\n" +
		transcript +
		"\n</session>";

	const finish = span?.("name.generate");
	try {
		const response = await ctx.modelRegistry.complete(
			model,
			{
				systemPrompt:
					"You name coding-agent sessions tersely and conservatively.",
				messages: [
					{
						role: "user",
						content: [{ type: "text", text: prompt }],
						timestamp: Date.now(),
					},
				],
			},
			{
				signal,
				cacheRetention: "none",
				...(model.reasoning ? { reasoning: "medium" as const } : {}),
			},
		);

		const text = response.content
			.filter((c): c is { type: "text"; text: string } => c.type === "text")
			.map((c) => c.text)
			.join("\n")
			.trim();
		finish?.();
		return normalizeGeneratedName(text);
	} catch (error) {
		finish?.("error");
		throw error;
	}
}

function stripMarkdownWrapping(s: string): string {
	return s.replace(/^(```|`)+|(```|`)+$/g, "").trim();
}

function stripQuotes(s: string): string {
	return s.replace(/^['“”‘’"]+|['“”‘’"]+$/g, "").trim();
}

function stripLabelPrefix(s: string): string {
	return s.replace(/^(title|name|session)\s*[:=-]\s*/i, "");
}

function normalizeToAsciiWords(s: string): string {
	return s
		.normalize("NFKC")
		.toLowerCase()
		.replace(/[^\p{L}\p{N}\s]/gu, " ")
		.replace(/\s+/g, " ")
		.trim();
}

function normalizeGeneratedName(raw: string | undefined): string | undefined {
	if (!raw) return undefined;
	let s = raw.split(/\r?\n/, 1)[0] ?? "";
	s = stripMarkdownWrapping(s);
	s = stripQuotes(s);
	s = stripLabelPrefix(s);
	s = normalizeToAsciiWords(s);
	if (!s) return undefined;

	const words = s.split(" ").filter(Boolean).slice(0, MAX_NAME_WORDS);
	if (words.length === 0) return undefined;
	if (words.length === 1 && GENERIC_SINGLE_WORDS.has(words[0]))
		return undefined;
	return words.join(" ");
}

function canonicalName(name: string): string {
	return (
		normalizeGeneratedName(name) ??
		name.trim().toLowerCase().replace(/\s+/g, " ")
	);
}

function levenshtein(a: string, b: string): number {
	if (a === b) return 0;
	if (a.length === 0) return b.length;
	if (b.length === 0) return a.length;

	let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
	let curr = new Array<number>(b.length + 1);
	for (let i = 1; i <= a.length; i++) {
		curr[0] = i;
		for (let j = 1; j <= b.length; j++) {
			const cost = a[i - 1] === b[j - 1] ? 0 : 1;
			curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
		}
		[prev, curr] = [curr, prev];
	}
	return prev[b.length];
}

function shouldApplyCandidate(
	candidate: string,
	oldName: string | undefined,
	marker: MarkerData | undefined,
): boolean {
	if (!oldName) return true;
	const oldCanonical = canonicalName(oldName);
	const nextCanonical = canonicalName(candidate);
	if (!nextCanonical || nextCanonical === oldCanonical) return false;
	if (levenshtein(oldCanonical, nextCanonical) <= SMALL_EDIT_DISTANCE)
		return false;
	const previous = marker?.previousNames ?? [];
	if (previous.some((name) => canonicalName(name) === nextCanonical))
		return false;
	return true;
}

function cooldownTurns(turnCount: number): number {
	if (turnCount <= 3) return 1;
	if (turnCount <= 10) return 2;
	return 5;
}

// ── Extension ──────────────────────────────────────────────────────────────

export default function (pi: ExtensionAPI) {
	let idleTimer: ReturnType<typeof setTimeout> | undefined;
	let activeController: AbortController | undefined;
	let _currentCtx: ExtensionContext | undefined;
	let idleDelayMs = IDLE_DELAY_SETTING.default;

	const clearTimer = () => {
		if (idleTimer) {
			clearTimeout(idleTimer);
			idleTimer = undefined;
		}
	};

	const cancelActive = (ctx?: ExtensionContext) => {
		if (activeController) {
			activeController.abort();
			activeController = undefined;
		}
		if (ctx?.hasUI) ctx.ui.setStatus(STATUS_KEY, undefined);
	};

	const scheduleRename = (
		ctx: ExtensionContext,
		reason: RenameReason,
		delayMs = idleDelayMs,
	) => {
		clearTimer();
		if (!currentSessionFile(ctx)) return;
		idleTimer = setTimeout(() => {
			idleTimer = undefined;
			void generateAndMaybeApply(ctx, reason);
		}, delayMs);
	};

	const generateAndMaybeApply = async (
		ctx: ExtensionContext,
		reason: RenameReason,
		force = false,
	) => {
		const sessionFile = currentSessionFile(ctx);
		if (!sessionFile) {
			dbg?.("name.outcome", { outcome: "no_session" });
			return;
		}

		const branch = branchEntries(ctx);
		if (!hasMeaningfulActivity(branch)) {
			dbg?.("name.outcome", { outcome: "no_activity" });
			return;
		}

		const turnCount = countUserTurns(branch);
		const state = getTitleState(ctx);
		if (state.mode === "manual" && !force) {
			dbg?.("name.outcome", { outcome: "manual_lock" });
			return;
		}

		const oldName = state.mode === "auto" ? state.name : undefined;
		if (state.mode === "auto" && !force) {
			const lastTurn = state.latestMarker.userTurnCount ?? 0;
			if (turnCount - lastTurn < cooldownTurns(turnCount)) {
				dbg?.("name.outcome", { outcome: "cooldown" });
				return;
			}
		}

		const startName = currentName(ctx);
		const transcript = buildTranscript(branch, oldName);
		if (!transcript.trim()) {
			dbg?.("name.outcome", { outcome: "no_transcript" });
			return;
		}

		cancelActive(ctx);
		const controller = new AbortController();
		activeController = controller;
		if (ctx.hasUI)
			ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("dim", "󰑕 naming session…"));

		try {
			const candidate = await generateName(
				transcript,
				oldName,
				ctx,
				controller.signal,
			);
			if (!candidate) {
				dbg?.("name.outcome", { outcome: "no_candidate" });
				return;
			}
			if (controller.signal.aborted) {
				dbg?.("name.outcome", { outcome: "cancelled" });
				return;
			}
			if (currentSessionFile(ctx) !== sessionFile) {
				dbg?.("name.outcome", { outcome: "stale_session" });
				return;
			}
			if (currentName(ctx) !== startName) {
				dbg?.("name.outcome", { outcome: "name_changed" });
				return;
			}
			if (!shouldApplyCandidate(candidate, oldName, state.latestMarker)) {
				dbg?.("name.outcome", { outcome: "unchanged" });
				return;
			}

			pi.setSessionName(candidate);
			appendAutoMarker(
				pi,
				candidate,
				turnCount,
				state.mode === "auto" ? state.latestMarker : undefined,
			);
			if (ctx.hasUI && reason === "manual")
				ctx.ui.notify(`session → ${candidate}`, "info");
			dbg?.("name.outcome", { outcome: "renamed" });
		} catch (err) {
			dbg?.("name.outcome", {
				outcome: controller.signal.aborted ? "cancelled" : "failed",
			});
			if (!controller.signal.aborted && ctx.hasUI)
				ctx.ui.notify(
					`session-rename failed: ${err instanceof Error ? err.message : String(err)}`,
					"error",
				);
		} finally {
			if (activeController === controller) {
				activeController = undefined;
				if (ctx.hasUI) ctx.ui.setStatus(STATUS_KEY, undefined);
			}
		}
	};

	pi.on("session_start", async (_event, ctx) => {
		dbg?.("session.start");
		_currentCtx = ctx;
		const idleDelay = resolveSetting(pi, ctx, IDLE_DELAY_SETTING);
		idleDelayMs = idleDelay.ok ? idleDelay.value : IDLE_DELAY_SETTING.default;
		if (!idleDelay.ok)
			ctx.ui.notify(
				`${idleDelay.error}; using ${IDLE_DELAY_SETTING.default} ms`,
				"warning",
			);
		scheduleRename(ctx, "load", LOAD_DELAY_MS);
	});

	pi.on("turn_end", async (_event, ctx) => {
		_currentCtx = ctx;
		scheduleRename(ctx, "turn");
	});

	pi.on("turn_start", async (_event, ctx) => {
		clearTimer();
		cancelActive(ctx);
	});

	pi.on("input", async (_event, ctx) => {
		clearTimer();
		cancelActive(ctx);
	});

	pi.on("agent_start", async (_event, ctx) => {
		clearTimer();
		cancelActive(ctx);
	});

	pi.on("session_shutdown", async (_event, ctx) => {
		dbg?.("session.shutdown");
		clearTimer();
		cancelActive(ctx);
		_currentCtx = undefined;
		closeDebug();
	});

	pi.registerCommand("session-rename", {
		description: "Generate/refine session name, or set exact name with args",
		handler: async (args, ctx) => {
			const exact = args.trim();
			if (exact) {
				clearTimer();
				cancelActive(ctx);
				const name = exact.replace(/\s+/g, " ").trim();
				pi.setSessionName(name);
				appendUserLock(pi, name, countUserTurns(branchEntries(ctx)));
				if (ctx.hasUI) ctx.ui.notify(`session → ${name}`, "info");
				return;
			}
			await generateAndMaybeApply(ctx, "manual", true);
		},
	});
}