import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; import { SessionManager, type SessionEntry, type SessionInfo } from "@mariozechner/pi-coding-agent"; import { complete, type Api, type Model, type UserMessage } from "@mariozechner/pi-ai"; import { existsSync, readFileSync } from "node:fs"; import { basename, join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; /** * pi-retune * * Happy path: * - /retune -> auto rename sessions in repo scope * - /retune again -> revert those names (only if untouched since retune) */ const EXT_ID = "pi-retune"; const CMD = "retune"; type Scope = "cwd" | "repo" | "all"; type TemplateData = Record; type ConfigV1 = { version: 1; template?: string; maxTitleLen?: number; scopeDefault?: Scope; /** Default: true. Only rename sessions with no current display name. */ onlyUnnamed?: boolean; /** Default: false. If true, allow renaming already-named sessions. */ overwrite?: boolean; model?: { enabled?: boolean; /** provider/modelId (e.g. "openai/gpt-4.1-mini"). When empty, model calls are skipped. */ cheapModel?: string; maxChars?: number; minConfidence?: number; maxCalls?: number; }; /** Optional template overrides by repo folder name */ overrides?: Array<{ repo: string; template: string }>; }; type ResolvedConfig = { version: 1; template: string; maxTitleLen: number; scopeDefault: Scope; onlyUnnamed: boolean; overwrite: boolean; model: { enabled: boolean; cheapModel: string; maxChars: number; minConfidence: number; maxCalls: number; }; overrides: Array<{ repo: string; template: string }>; }; type Flags = { help: boolean; scope: Scope; limit: number; since: string | undefined; template: string | undefined; review: boolean; dry: boolean; noModel: boolean; all: boolean; }; type AuditAction = "apply" | "restore"; type Audit = { action: AuditAction; ts: string; prevName: string; newName: string; template?: string; }; type PlanRow = { id: string; path: string; from: string; to: string; reason?: string; }; function nowIso(): string { return new Date().toISOString(); } function setStatusSafe(ctx: Pick, text: string | undefined): void { try { ctx.ui?.setStatus?.(EXT_ID, text); } catch { // ignore } } function notifySafe(ctx: Pick, message: string, type: "info" | "warning" | "error" = "info"): void { try { ctx.ui?.notify?.(message, type); } catch { // ignore } } function clamp(n: number, lo: number, hi: number): number { return Math.max(lo, Math.min(hi, n)); } function sanitizeInline(s: string): string { return String(s ?? "") .replace(/[\r\n\t]+/g, " ") .replace(/\s+/g, " ") .trim(); } function truncateWithEllipsis(s: string, maxLen: number): string { const text = String(s ?? ""); if (maxLen <= 0) return ""; if (text.length <= maxLen) return text; if (maxLen === 1) return "…"; return text.slice(0, Math.max(1, maxLen - 1)) + "…"; } function kebab(s: string): string { const raw = sanitizeInline(s).toLowerCase(); return raw .replace(/[^a-z0-9]+/g, "-") .replace(/^-+/, "") .replace(/-+$/, "") .replace(/-+/g, "-"); } function snake(s: string): string { const raw = sanitizeInline(s).toLowerCase(); return raw .replace(/[^a-z0-9]+/g, "_") .replace(/^_+/, "") .replace(/_+$/, "") .replace(/_+/g, "_"); } function titleCase(s: string): string { const raw = sanitizeInline(s).toLowerCase(); return raw.replace(/\b[a-z]/g, (m) => m.toUpperCase()); } // --- caveman style --- // Keep titles short + information-dense: "fix tests", "make retune toggle", "clean config". const CAVEMAN_STOPWORDS = new Set( ( "a an the to for of and or in on at with without from into onto over under about around via using " + "as is are was were be been being do does did done can could would should may might must will " + "i me my mine we us our ours you your yours it its this that these those there here please pls " + "want wants wanted need needs needed lets let's help thanks thank s t" ) .split(/\s+/) .filter(Boolean), ); const CAVEMAN_WORD_MAP: Record = { implement: "make", implements: "make", implementing: "make", create: "make", creating: "make", build: "make", building: "make", add: "add", adds: "add", adding: "add", update: "update", updates: "update", updating: "update", improve: "improve", improves: "improve", improving: "improve", refactor: "clean", refactoring: "clean", cleanup: "clean", cleaning: "clean", rename: "rename", renaming: "rename", restore: "restore", restoring: "restore", revert: "revert", reverting: "revert", remove: "remove", removing: "remove", delete: "remove", deleting: "remove", debug: "fix", debugging: "fix", investigate: "look", investigating: "look", analyze: "look", analysing: "look", analyzing: "look", optimize: "speed", optimizing: "speed", performance: "speed", }; function cavemanize(input: string, opts?: { maxWords?: number }): string { const maxWords = Math.max(1, Math.min(10, Math.floor(opts?.maxWords ?? 5))); let s = sanitizeInline(input).toLowerCase(); if (!s) return ""; // Strip common polite / helper lead-ins. s = s .replace(/^(please|pls)\b\s*/i, "") .replace(/^(can|could|would|will)\s+you\b\s*/i, "") .replace(/^(i|we)\s+(want|need)\s+to\b\s*/i, "") .replace(/^(lets|let's)\b\s*/i, ""); // Keep only simple word characters + separators. s = s.replace(/[^a-z0-9\-_/ #]+/g, " "); let toks = s .split(/\s+/) .map((t) => t.trim()) .filter(Boolean) .map((t) => t.replace(/^[^a-z0-9#]+|[^a-z0-9]+$/g, "")) .filter(Boolean); // De-fang filler words. toks = toks.filter((t) => !CAVEMAN_STOPWORDS.has(t)); if (toks.length === 0) return ""; // Normalize common verbs/words. toks = toks.map((t) => CAVEMAN_WORD_MAP[t] ?? t); // Drop trivial dupes (keep order). const seen = new Set(); toks = toks.filter((t) => { if (seen.has(t)) return false; seen.add(t); return true; }); return toks.slice(0, maxWords).join(" "); } function applyModifiers(valueIn: string, modifiers: string[]): string { let value = String(valueIn ?? ""); for (const mod of modifiers) { const [nameRaw, argRaw] = mod.split(/:(.*)/, 2); const name = String(nameRaw ?? "").trim().toLowerCase(); const arg = argRaw === undefined ? undefined : String(argRaw); if (name === "lower") value = value.toLowerCase(); else if (name === "upper") value = value.toUpperCase(); else if (name === "title") value = titleCase(value); else if (name === "kebab") value = kebab(value); else if (name === "snake") value = snake(value); else if (name === "sanitize") value = sanitizeInline(value); else if (name === "caveman") value = cavemanize(value); else if (name === "truncate") { const n = arg ? Number(arg) : NaN; if (Number.isFinite(n)) value = truncateWithEllipsis(value, Math.max(0, Math.floor(n))); } else if (name === "default") { if (!sanitizeInline(value)) value = String(arg ?? ""); } } return value; } function renderTemplate(template: string, data: TemplateData, opts: { maxLen: number; strict: boolean }): { ok: true; text: string } | { ok: false; error: string } { const strict = opts.strict; const maxLen = Math.max(1, opts.maxLen); let t = String(template ?? ""); // brace escaping t = t.replace(/\{\{/g, "\uE000").replace(/\}\}/g, "\uE001"); const re = /\{([a-zA-Z][a-zA-Z0-9_]*)(\|[^}]+)?\}/g; let out = ""; let last = 0; let m: RegExpExecArray | null; while ((m = re.exec(t))) { out += t.slice(last, m.index); last = m.index + m[0].length; const key = m[1] ?? ""; const modsRaw = (m[2] ?? "").trim(); const mods = modsRaw ? modsRaw .split("|") .map((s) => s.trim()) .filter(Boolean) : []; if (!(key in data)) { if (strict) return { ok: false, error: `Unknown placeholder: {${key}}` }; out += ""; continue; } const raw = data[key]; let value = raw === null || raw === undefined ? "" : String(raw); value = applyModifiers(value, mods); out += value; } out += t.slice(last); out = out.replace(/\uE000/g, "{").replace(/\uE001/g, "}"); out = sanitizeInline(out); out = truncateWithEllipsis(out, maxLen); return { ok: true, text: out }; } function parseArgs(input: string): string[] { const out: string[] = []; const re = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g; let m: RegExpExecArray | null; while ((m = re.exec(String(input ?? "")))) { const token = m[1] ?? m[2] ?? m[3] ?? ""; out.push(token.replace(/\\"/g, '"').replace(/\\'/g, "'")); } return out; } function parseFlags(argv: string[], config: ResolvedConfig): Flags { const flags: Flags = { help: false, scope: config.scopeDefault, limit: 200, since: undefined, template: undefined, review: false, dry: false, noModel: false, all: false, }; // Allow: "help" or "--help" if ((argv[0] ?? "").trim().toLowerCase() === "help") flags.help = true; for (let i = 0; i < argv.length; i++) { const a = argv[i] ?? ""; if (a === "--help" || a === "-h") flags.help = true; else if (a === "--review") flags.review = true; else if (a === "--dry") flags.dry = true; else if (a === "--no-model") flags.noModel = true; else if (a === "--all") flags.all = true; else if (a === "--scope") { const v = argv[i + 1]; if (v && !v.startsWith("--")) { const s = v.toLowerCase(); if (s === "repo" || s === "cwd" || s === "all") flags.scope = s; i++; } } else if (a === "--limit") { const v = argv[i + 1]; if (v && !v.startsWith("--")) { const n = Number(v); if (Number.isFinite(n)) flags.limit = Math.max(1, Math.min(2000, Math.floor(n))); i++; } } else if (a === "--since") { const v = argv[i + 1]; if (v && !v.startsWith("--")) { flags.since = v; i++; } } else if (a === "--template") { const v = argv[i + 1]; if (v && !v.startsWith("--")) { flags.template = v; i++; } } } return flags; } function runGit(cwd: string, args: string[], timeoutMs = 1500): { ok: boolean; stdout: string } { try { if (!cwd || !existsSync(cwd)) return { ok: false, stdout: "" }; const res = spawnSync("git", args, { cwd, encoding: "utf8", timeout: timeoutMs, windowsHide: true }); return { ok: res.status === 0, stdout: String(res.stdout ?? "") }; } catch { return { ok: false, stdout: "" }; } } function findRepoRoot(cwd: string): string { const top = runGit(cwd, ["rev-parse", "--show-toplevel"], 1200); if (top.ok) { const p = top.stdout.trim(); if (p) return p; } return cwd; } function safePathKey(p: string): string { return resolve(p).toLowerCase(); } function repoNameFromRoot(repoRoot: string): string { const name = basename(repoRoot); return name || "repo"; } function getHomeDir(): string { return String(process.env.HOME ?? process.env.USERPROFILE ?? ""); } function loadJsonFile(path: string): T | null { try { if (!existsSync(path)) return null; const raw = readFileSync(path, "utf-8"); return JSON.parse(raw) as T; } catch { return null; } } function resolveConfig(repoRoot: string): ResolvedConfig { const defaults: ResolvedConfig = { version: 1, template: "#{moduleOrRepo} - {primaryTask|caveman|title|truncate:28}", maxTitleLen: 72, scopeDefault: "repo", onlyUnnamed: true, overwrite: false, model: { enabled: true, cheapModel: "", maxChars: 2400, minConfidence: 0.7, maxCalls: 25, }, overrides: [], }; const home = getHomeDir(); // Prefer short config names, keep backward-compatible fallbacks. const globalPath = home ? join(home, ".pi", "agent", "retune.json") : ""; const globalPathLegacy = home ? join(home, ".pi", "agent", "pi-retune.json") : ""; const projectPath = join(repoRoot, ".pi", "retune.json"); const projectPathLegacy = join(repoRoot, ".pi", "pi-retune.json"); const g = globalPath ? loadJsonFile>(globalPath) : null; const gLegacy = !g && globalPathLegacy ? loadJsonFile>(globalPathLegacy) : null; const p = loadJsonFile>(projectPath) ?? loadJsonFile>(projectPathLegacy); const gg = g ?? gLegacy; const merged: any = { ...defaults, ...(gg ?? {}), ...(p ?? {}) }; merged.model = { ...defaults.model, ...((gg as any)?.model ?? {}), ...((p as any)?.model ?? {}) }; merged.overrides = ([] as any[]).concat((gg as any)?.overrides ?? [], (p as any)?.overrides ?? []); merged.template = typeof merged.template === "string" && merged.template.trim() ? merged.template.trim() : defaults.template; merged.maxTitleLen = Math.max(12, Math.min(120, Number(merged.maxTitleLen ?? defaults.maxTitleLen))); merged.scopeDefault = merged.scopeDefault === "all" || merged.scopeDefault === "cwd" || merged.scopeDefault === "repo" ? merged.scopeDefault : defaults.scopeDefault; merged.onlyUnnamed = merged.onlyUnnamed !== false; merged.overwrite = merged.overwrite === true; merged.model.enabled = merged.model.enabled !== false; merged.model.cheapModel = String(merged.model.cheapModel ?? "").trim(); merged.model.maxChars = Math.max(400, Math.min(8000, Number(merged.model.maxChars ?? defaults.model.maxChars))); merged.model.minConfidence = clamp(Number(merged.model.minConfidence ?? defaults.model.minConfidence), 0, 1); merged.model.maxCalls = Math.max(0, Math.min(250, Number(merged.model.maxCalls ?? defaults.model.maxCalls))); return merged as ResolvedConfig; } function applyTemplateOverride(config: ResolvedConfig, repoName: string): ResolvedConfig { for (const ov of config.overrides ?? []) { if (!ov?.repo || !ov?.template) continue; if (String(ov.repo).trim().toLowerCase() === repoName.toLowerCase()) { return { ...config, template: String(ov.template) }; } } return config; } function parseSince(since: string | undefined): number | null { const raw = String(since ?? "").trim(); if (!raw) return null; // ISO date const t = Date.parse(raw); if (Number.isFinite(t)) return t; // 30d, 2w, 12h const m = raw.match(/^(\d+)([smhdw])$/i); if (!m) return null; const n = Number(m[1]); const unit = String(m[2] ?? "").toLowerCase(); const mult = unit === "s" ? 1000 : unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : unit === "d" ? 86_400_000 : unit === "w" ? 7 * 86_400_000 : 0; if (!mult) return null; return Date.now() - n * mult; } function parseModelRef(ref: string | undefined): { provider: string; modelId: string } | null { const r = String(ref ?? "").trim(); if (!r) return null; const parts = r.split("/"); if (parts.length < 2) return null; const provider = parts[0]?.trim(); const modelId = parts.slice(1).join("/").trim(); if (!provider || !modelId) return null; return { provider, modelId }; } function extractJsonObject(raw: string): any | null { const s = String(raw ?? ""); const a = s.indexOf("{"); const b = s.lastIndexOf("}"); if (a === -1 || b === -1 || b <= a) return null; try { return JSON.parse(s.slice(a, b + 1)); } catch { return null; } } function normalizeConfidence(v: any): number | undefined { if (typeof v !== "number" || !Number.isFinite(v)) return undefined; if (v > 1.01) return clamp(v / 100, 0, 1); return clamp(v, 0, 1); } function extractTicket(text: string): string | undefined { const t = sanitizeInline(text); const jira = t.match(/\b([A-Z][A-Z0-9]{1,10}-\d{1,7})\b/); if (jira?.[1]) return jira[1]; const gh = t.match(/(^|\s)(#\d{1,7})\b/); if (gh?.[2]) return gh[2]; return undefined; } function extractTaskType(text: string): string { const t = sanitizeInline(text).toLowerCase(); if (/(^|\b)(fix|bug|hotfix)\b/.test(t)) return "fix"; if (/(^|\b)(refactor|cleanup)\b/.test(t)) return "refactor"; if (/(^|\b)(docs|readme|documentation)\b/.test(t)) return "docs"; if (/(^|\b)(test|tests|testing)\b/.test(t)) return "test"; if (/(^|\b)(chore|deps|dependency|dependencies|bump)\b/.test(t)) return "chore"; return "feat"; } function heuristicPrimaryTask(session: SessionInfo): string { let t = sanitizeInline(session.firstMessage); if (!t || t === "(no messages)") return "work"; const ticket = extractTicket(t); if (ticket) t = t.replace(ticket, ""); const caveman = cavemanize(t, { maxWords: 6 }); return truncateWithEllipsis(caveman || "work", 60); } function computeModuleOrRepo(moduleName: string | undefined, repoName: string, sessionCwd: string): string { if (moduleName) return moduleName; const norm = String(sessionCwd ?? "").replace(/\\/g, "/"); const m = norm.match(/\/(packages|package|pkg)\/([^\/]+)(\/|$)/i); if (m?.[2]) return String(m[2]); return repoName; } async function listSessionsByScope( ctx: ExtensionCommandContext, scope: Scope, repoRoot: string, limit: number, sinceMs: number | null, ): Promise<{ sessions: SessionInfo[]; warnings: string[] }> { const warnings: string[] = []; let sessions: SessionInfo[] = []; if (scope === "cwd") { sessions = await SessionManager.list(ctx.cwd); } else { let all: SessionInfo[] = []; let lastTick = 0; try { all = await SessionManager.listAll((loaded, total) => { const now = Date.now(); if (now - lastTick < 250) return; lastTick = now; setStatusSafe(ctx, `sessions: ${loaded}/${total}`); }); } finally { setStatusSafe(ctx, undefined); } if (scope === "all") { sessions = all; } else { const targetKey = safePathKey(repoRoot); const repoCache = new Map(); const repoRootFor = (cwd: string): string => { const key = safePathKey(cwd); const cached = repoCache.get(key); if (cached !== undefined) return cached; const root = findRepoRoot(cwd); repoCache.set(key, root); return root; }; sessions = all.filter((s) => { const cwd = String(s.cwd ?? ""); if (!cwd) return false; return safePathKey(repoRootFor(cwd)) === targetKey; }); } } if (sinceMs !== null) sessions = sessions.filter((s) => s.modified.getTime() >= sinceMs); if (limit > 0) sessions = sessions.slice(0, limit); if (scope === "repo" && sessions.length === 0) warnings.push("No sessions matched repo scope. Try: /retune --scope all"); return { sessions, warnings }; } function getLatestAudit(entries: SessionEntry[]): Audit | null { for (let i = entries.length - 1; i >= 0; i--) { const e: any = entries[i]; if (!e || e.type !== "custom") continue; if (e.customType !== EXT_ID) continue; const data = e.data ?? {}; const action = data.action === "apply" || data.action === "restore" ? (data.action as AuditAction) : null; if (!action) continue; return { action, ts: String(data.ts ?? e.timestamp ?? ""), prevName: String(data.prevName ?? ""), newName: String(data.newName ?? ""), template: typeof data.template === "string" ? data.template : undefined, }; } return null; } async function inferWithModel( ctx: ExtensionCommandContext, model: Model, session: SessionInfo, repoHint: string, maxChars: number, ): Promise<{ moduleName?: string; primaryTask?: string; confidence?: number } | null> { const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); if (!auth.ok || !auth.apiKey) throw new Error(auth.ok ? `No API key for ${model.provider}` : auth.error); const first = truncateWithEllipsis(sanitizeInline(session.firstMessage), 500); const tail = truncateWithEllipsis(sanitizeInline(session.allMessagesText), maxChars); const systemPrompt = "Return STRICT JSON only with keys: moduleName (string), primaryTask (string), confidence (number 0-1). " + "moduleName: short slug (no spaces). primaryTask: caveman style, 2-5 short words, lowercase, no filler. " + "If unsure: low confidence, empty strings."; const user: UserMessage = { role: "user", content: [ { type: "text", text: `REPO_HINT: ${repoHint}\nFIRST_MESSAGE: ${first || "(none)"}\n\nSESSION_TEXT: ${tail || "(none)"}`, }, ], timestamp: Date.now(), }; const response = await complete( model, { systemPrompt, messages: [user] }, { apiKey: auth.apiKey, headers: auth.headers, signal: ctx.signal }, ); if (response.stopReason === "aborted") return null; const rawText = Array.isArray(response.content) ? response.content .filter((p: any) => p && p.type === "text" && typeof p.text === "string") .map((p: any) => p.text) .join("") : String((response as any).content ?? ""); const obj = extractJsonObject(rawText); if (!obj) return null; const moduleName = sanitizeInline(obj.moduleName ?? ""); const primaryTask = cavemanize(obj.primaryTask ?? "", { maxWords: 6 }); const confidence = normalizeConfidence(obj.confidence); return { moduleName: moduleName || undefined, primaryTask: primaryTask || undefined, confidence }; } async function buildApplyPlan( ctx: ExtensionCommandContext, config: ResolvedConfig, repoRoot: string, repoName: string, flags: Flags, ): Promise<{ rows: PlanRow[]; warnings: string[] }> { const sinceMs = parseSince(flags.since); const { sessions, warnings } = await listSessionsByScope(ctx, flags.scope, repoRoot, flags.limit, sinceMs); const onlyUnnamed = flags.all ? false : config.onlyUnnamed; const overwrite = flags.all ? true : config.overwrite; const template = flags.template ?? config.template; const strictPlaceholders = true; const needsBranch = /\{branch\b/.test(template); const needsPrimaryTask = /\{primaryTask\b/.test(template); const needsModuleName = /\{moduleName\b/.test(template); const needsModel = needsPrimaryTask || needsModuleName; const cheapModelRef = flags.noModel ? null : parseModelRef(config.model.cheapModel); const cheapModel = cheapModelRef ? ctx.modelRegistry.find(cheapModelRef.provider, cheapModelRef.modelId) : undefined; if (config.model.enabled && needsModel && !flags.noModel && config.model.cheapModel && !cheapModel) { warnings.push(`cheap model not found: ${config.model.cheapModel}`); } let modelCalls = 0; const rows: PlanRow[] = []; let sessionIndex = 0; let lastPlanTick = 0; for (const s of sessions) { sessionIndex++; const now = Date.now(); if (now - lastPlanTick > 250) { lastPlanTick = now; setStatusSafe(ctx, `retune: plan ${sessionIndex}/${sessions.length}`); } const curName = sanitizeInline(s.name ?? ""); const hasName = Boolean(curName); if (onlyUnnamed && hasName) continue; if (!overwrite && hasName) continue; const ticket = extractTicket(s.firstMessage) ?? extractTicket(s.allMessagesText); const taskType = extractTaskType(s.firstMessage); const id8 = String(s.id ?? "").slice(0, 8); let branch = ""; if (needsBranch) { const b = runGit(s.cwd, ["rev-parse", "--abbrev-ref", "HEAD"], 1200); const bn = b.ok ? b.stdout.trim() : ""; branch = bn && bn !== "HEAD" ? bn : ""; } let moduleName: string | undefined; let primaryTask: string | undefined; let confidence: number | undefined; if (needsPrimaryTask) primaryTask = heuristicPrimaryTask(s); if (needsModel && config.model.enabled && !flags.noModel && cheapModel && modelCalls < config.model.maxCalls) { try { modelCalls++; const inferred = await inferWithModel(ctx, cheapModel as Model, s, repoName, config.model.maxChars); if (inferred) { moduleName = inferred.moduleName; if (inferred.primaryTask) primaryTask = cavemanize(inferred.primaryTask, { maxWords: 6 }); confidence = inferred.confidence; } } catch (e: any) { warnings.push(`model call failed for ${id8}: ${String(e?.message ?? e).slice(0, 140)}`); } } if (needsPrimaryTask && !sanitizeInline(primaryTask ?? "")) { primaryTask = heuristicPrimaryTask(s); } if (needsModel && confidence !== undefined && confidence < config.model.minConfidence) { continue; } const moduleOrRepo = computeModuleOrRepo(moduleName, repoName, s.cwd); const data: TemplateData = { repo: repoName, cwdBase: s.cwd ? basename(s.cwd) : "", created: s.created.toISOString().slice(0, 10), modified: s.modified.toISOString().slice(0, 10), messages: s.messageCount, id8, ticket, taskType, name: s.name ?? "", branch, moduleName, primaryTask, moduleOrRepo, }; const rendered = renderTemplate(template, data, { maxLen: config.maxTitleLen, strict: strictPlaceholders }); if (!rendered.ok) { warnings.push(`${id8}: ${rendered.error}`); continue; } const to = rendered.text; if (!to) continue; const from = curName || sanitizeInline(s.firstMessage); if (sanitizeInline(from) === sanitizeInline(to)) continue; const reasonParts: string[] = []; if (ticket) reasonParts.push(`ticket=${ticket}`); if (branch) reasonParts.push(`branch=${branch}`); if (moduleName) reasonParts.push(`module=${moduleName}`); if (confidence !== undefined) reasonParts.push(`conf=${Math.round(confidence * 100)}%`); rows.push({ id: s.id, path: s.path, from, to, reason: reasonParts.join(" ") }); } setStatusSafe(ctx, undefined); // Dedupe collisions in this plan const seen = new Map(); for (const r of rows) { const k = r.to; const n = (seen.get(k) ?? 0) + 1; seen.set(k, n); if (n > 1) { const suffix = ` [${String(r.id).slice(0, 8)}]`; const baseMax = Math.max(1, config.maxTitleLen - suffix.length); r.to = truncateWithEllipsis(r.to, baseMax) + suffix; } } return { rows, warnings }; } function formatPlanMarkdown(kind: "apply" | "restore", repoName: string, scope: Scope, template: string, rows: PlanRow[], warnings: string[]): string { const lines: string[] = []; lines.push(`# ${EXT_ID} plan (${kind})`); lines.push(""); lines.push(`- repo: ${repoName}`); lines.push(`- scope: ${scope}`); lines.push(`- template: ${template}`); lines.push(`- generated_at: ${nowIso()}`); lines.push(""); lines.push("Instructions:"); lines.push("- Keep `[x]` to apply. Change `to` to edit the target name."); lines.push("- Delete a line or change to `[ ]` to skip."); lines.push(""); lines.push(`## Items (${rows.length})`); for (const row of rows) { lines.push(`- [x] ${JSON.stringify(row)}`); } if (warnings.length) { lines.push(""); lines.push("## Warnings"); for (const w of warnings.slice(0, 20)) lines.push(`- ${w}`); if (warnings.length > 20) lines.push(`- … ${warnings.length - 20} more`); } lines.push(""); return lines.join("\n") + "\n"; } function parsePlanSelections(planText: string): Array<{ path: string; id: string; to: string }> { const out: Array<{ path: string; id: string; to: string }> = []; for (const line of String(planText ?? "").split(/\r?\n/)) { const m = line.match(/^\s*-\s*\[(x|X)\]\s*(\{.*\})\s*$/); if (!m) continue; let obj: any; try { obj = JSON.parse(m[2] ?? ""); } catch { continue; } const path = String(obj.path ?? ""); const id = String(obj.id ?? ""); const to = sanitizeInline(String(obj.to ?? "")); if (!path || !id || !to) continue; out.push({ path, id, to }); } return out; } async function applyPlan( ctx: ExtensionCommandContext, selections: Array<{ path: string; id: string; to: string }>, templateUsed: string, action: AuditAction, ): Promise<{ applied: number; failed: number; failures: string[] }> { let applied = 0; let failed = 0; const failures: string[] = []; const total = selections.length; let lastTick = 0; setStatusSafe(ctx, `${action}: 0/${total}`); try { for (let i = 0; i < selections.length; i++) { const sel = selections[i]!; const now = Date.now(); if (now - lastTick > 250) { lastTick = now; setStatusSafe(ctx, `${action}: ${i + 1}/${total}`); } try { const sm = SessionManager.open(sel.path); const current = sanitizeInline(sm.getSessionName() ?? ""); sm.appendCustomEntry(EXT_ID, { action, ts: nowIso(), prevName: current, newName: sel.to, template: templateUsed, }); sm.appendSessionInfo(sel.to); applied++; } catch (e: any) { failed++; failures.push(`${String(sel.id ?? "").slice(0, 8)}: ${String(e?.message ?? e).slice(0, 200)}`); } } } finally { setStatusSafe(ctx, undefined); } return { applied, failed, failures }; } async function buildRestorePlan( ctx: ExtensionCommandContext, repoRoot: string, scope: Scope, limit: number, sinceMs: number | null, ): Promise<{ rows: PlanRow[]; warnings: string[] }> { const { sessions, warnings } = await listSessionsByScope(ctx, scope, repoRoot, limit, sinceMs); const rows: PlanRow[] = []; let sessionIndex = 0; let lastTick = 0; for (const s of sessions) { sessionIndex++; const now = Date.now(); if (now - lastTick > 250) { lastTick = now; setStatusSafe(ctx, `retune: restore plan ${sessionIndex}/${sessions.length}`); } try { const sm = SessionManager.open(s.path); const audit = getLatestAudit(sm.getEntries()); if (!audit || audit.action !== "apply") continue; const currentName = sanitizeInline(sm.getSessionName() ?? ""); // Safety: only restore if the name is still what we set. if (sanitizeInline(audit.newName) && sanitizeInline(audit.newName) !== currentName) continue; rows.push({ id: s.id, path: s.path, from: currentName || sanitizeInline(s.firstMessage), to: sanitizeInline(audit.prevName ?? ""), reason: `retuned_at=${audit.ts}`, }); } catch { // ignore } } setStatusSafe(ctx, undefined); return { rows, warnings }; } function buildHelp(config: ResolvedConfig, repoName: string): string { return ( "pi-retune\n\n" + "Happy path:\n" + " /retune retune session names in repo scope\n" + " /retune again restore original names (only if untouched since retune)\n\n" + "Options:\n" + " --review edit/approve the plan before applying\n" + " --dry preview only (no writes)\n" + " --scope repo|cwd|all\n" + " --limit N\n" + " --since 30d|2026-01-01\n" + " --template \"...\"\n" + " --no-model never call a model\n" + " --all include already-named sessions (overwrite)\n\n" + "Config files (merged global -> project):\n" + " ~/.pi/agent/retune.json\n" + " /.pi/retune.json\n" + " (legacy: pi-retune.json is also supported)\n\n" + "Defaults:\n" + ` repo: ${repoName}\n` + ` scope: ${config.scopeDefault}\n` + ` template: ${config.template}\n` + ` onlyUnnamed: ${config.onlyUnnamed ? "true" : "false"}\n` + ` overwrite: ${config.overwrite ? "true" : "false"}\n` + ` model.enabled: ${config.model.enabled ? "true" : "false"}\n` + ` model.cheapModel: ${config.model.cheapModel || "(not set)"}\n` ); } export default function piRetuneExtension(pi: ExtensionAPI): void { pi.registerCommand(CMD, { description: "Toggle auto-retuning of session names (repo-scoped)", handler: async (rawArgs, ctx) => { const repoRoot = findRepoRoot(ctx.cwd); const repoName = repoNameFromRoot(repoRoot); let config = resolveConfig(repoRoot); config = applyTemplateOverride(config, repoName); const argv = parseArgs(String(rawArgs ?? "")); const flags = parseFlags(argv, config); const templateUsed = flags.template ?? config.template; const sinceMs = parseSince(flags.since); if (flags.help) { ctx.ui.notify(buildHelp(config, repoName), "info"); return; } // Decide toggle direction: if any session appears currently-retuned, do restore. let shouldRestore = false; { // Toggle detection: scan a bounded number of recent sessions for an active retune audit. const probeLimit = Math.min(flags.limit, 300); const { sessions } = await listSessionsByScope(ctx, flags.scope, repoRoot, probeLimit, sinceMs); for (const s of sessions) { try { const sm = SessionManager.open(s.path); const audit = getLatestAudit(sm.getEntries()); if (!audit || audit.action !== "apply") continue; const currentName = sanitizeInline(sm.getSessionName() ?? ""); if (sanitizeInline(audit.newName) && sanitizeInline(audit.newName) !== currentName) continue; shouldRestore = true; break; } catch { // ignore } } } if (shouldRestore) { const { rows, warnings } = await buildRestorePlan(ctx, repoRoot, flags.scope, flags.limit, sinceMs); for (const w of warnings.slice(0, 4)) notifySafe(ctx, w, "warning"); if (rows.length === 0) { notifySafe(ctx, "retune: nothing to restore", "info"); return; } if (flags.review && !ctx.hasUI) { notifySafe(ctx, "retune: --review ignored (no UI)", "warning"); } let selections = rows.map((r) => ({ path: r.path, id: r.id, to: r.to })); const needsPlan = flags.review || flags.dry; let planText = needsPlan ? formatPlanMarkdown("restore", repoName, flags.scope, templateUsed, rows, warnings) : ""; if (flags.review && ctx.hasUI) { const edited = await ctx.ui.editor("retune restore (edit & keep [x])", planText); if (edited === undefined) { notifySafe(ctx, "retune: restore cancelled", "info"); return; } selections = parsePlanSelections(edited); planText = edited; } if (flags.dry) { if (!flags.review) { if (ctx.hasUI) await ctx.ui.editor("retune restore plan", planText); else notifySafe(ctx, planText.slice(0, 1800) + (planText.length > 1800 ? "\n…" : ""), "info"); } notifySafe(ctx, "retune: dry-run", "info"); return; } const res = await applyPlan(ctx, selections, templateUsed, "restore"); notifySafe( ctx, `retune restored: ${res.applied}/${selections.length}${res.failed ? ` (failed ${res.failed})` : ""}`, res.failed ? "warning" : "info", ); for (const f of res.failures.slice(0, 6)) notifySafe(ctx, `- ${f}`, "warning"); notifySafe(ctx, "retune: run /retune to retune again", "info"); return; } // Apply retune const { rows, warnings } = await buildApplyPlan(ctx, config, repoRoot, repoName, flags); for (const w of warnings.slice(0, 4)) notifySafe(ctx, w, "warning"); if (rows.length === 0) { notifySafe(ctx, "retune: no sessions eligible", "info"); if (config.model.enabled && !flags.noModel && !config.model.cheapModel) { notifySafe(ctx, "tip: set model.cheapModel in ~/.pi/agent/retune.json", "info"); } return; } if (flags.review && !ctx.hasUI) { notifySafe(ctx, "retune: --review ignored (no UI)", "warning"); } let selections = rows.map((r) => ({ path: r.path, id: r.id, to: r.to })); const needsPlan = flags.review || flags.dry; let planText = needsPlan ? formatPlanMarkdown("apply", repoName, flags.scope, templateUsed, rows, warnings) : ""; if (flags.review && ctx.hasUI) { const edited = await ctx.ui.editor("retune apply (edit & keep [x])", planText); if (edited === undefined) { notifySafe(ctx, "retune: apply cancelled", "info"); return; } selections = parsePlanSelections(edited); planText = edited; } if (flags.dry) { if (!flags.review) { if (ctx.hasUI) await ctx.ui.editor("retune apply plan", planText); else notifySafe(ctx, planText.slice(0, 1800) + (planText.length > 1800 ? "\n…" : ""), "info"); } notifySafe(ctx, "retune: dry-run", "info"); return; } const res = await applyPlan(ctx, selections, templateUsed, "apply"); notifySafe( ctx, `retune applied: ${res.applied}/${selections.length}${res.failed ? ` (failed ${res.failed})` : ""}`, res.failed ? "warning" : "info", ); for (const f of res.failures.slice(0, 6)) notifySafe(ctx, `- ${f}`, "warning"); notifySafe(ctx, "retune: run /retune again to restore", "info"); }, }); }