@vanillagreen/pi-qol 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -70
- package/extensions/qol/agent-end.ts +48 -0
- package/extensions/qol/agent-statusline.ts +52 -0
- package/extensions/qol/ansi.ts +115 -0
- package/extensions/qol/bridges.ts +78 -0
- package/extensions/qol/compaction.ts +264 -0
- package/extensions/qol/constants.ts +107 -0
- package/extensions/qol/context-usage.ts +492 -0
- package/extensions/qol/editor.ts +183 -0
- package/extensions/qol/handoff.ts +109 -0
- package/extensions/qol/images.ts +123 -0
- package/extensions/qol/notifications.ts +269 -0
- package/extensions/qol/pending-queue.ts +79 -0
- package/extensions/qol/permission-gate.ts +139 -0
- package/extensions/qol/session-rename.ts +359 -0
- package/extensions/qol/session-search/cache.ts +273 -0
- package/extensions/qol/session-search/component.ts +937 -0
- package/extensions/qol/session-search/context.ts +152 -0
- package/extensions/qol/session-search/index.ts +171 -0
- package/extensions/qol/session-search/search.ts +288 -0
- package/extensions/qol/session-search/types.ts +96 -0
- package/extensions/qol/settings.ts +74 -0
- package/extensions/qol/status-message.ts +41 -0
- package/extensions/qol/statusline.ts +219 -0
- package/extensions/qol/thinking-timer.ts +97 -0
- package/extensions/qol/util.ts +7 -0
- package/extensions/qol.ts +191 -4437
- package/package.json +15 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { stripAnsi } from "./ansi.js";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_PERMISSION_GATE_COMMANDS,
|
|
5
|
+
DEFAULT_PERMISSION_GATE_PREVIEW_CHARS,
|
|
6
|
+
DEFAULT_PERMISSION_GATE_PREVIEW_LINES,
|
|
7
|
+
DEFAULT_PERMISSION_GATE_PREVIEW_LINE_WIDTH,
|
|
8
|
+
} from "./constants.js";
|
|
9
|
+
import { boundedSettingNumber, settingStringAllowEmpty } from "./settings.js";
|
|
10
|
+
import { escapeRegex } from "./util.js";
|
|
11
|
+
|
|
12
|
+
interface PermissionGateMatcher {
|
|
13
|
+
label: string;
|
|
14
|
+
pattern: RegExp;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function splitPermissionGateCommands(raw: string): string[] {
|
|
18
|
+
return raw
|
|
19
|
+
.split(",")
|
|
20
|
+
.map((entry) => entry.trim())
|
|
21
|
+
.filter(Boolean);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function regexFromSlashPattern(entry: string): RegExp | undefined {
|
|
25
|
+
if (!entry.startsWith("/")) return undefined;
|
|
26
|
+
const end = entry.lastIndexOf("/");
|
|
27
|
+
if (end <= 0) return undefined;
|
|
28
|
+
const source = entry.slice(1, end);
|
|
29
|
+
const flags = entry.slice(end + 1);
|
|
30
|
+
try {
|
|
31
|
+
return new RegExp(source, flags || "i");
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function literalCommandPattern(entry: string): RegExp | undefined {
|
|
38
|
+
const source = entry.split(/\s+/).map(escapeRegex).join("\\s+");
|
|
39
|
+
if (!source) return undefined;
|
|
40
|
+
try {
|
|
41
|
+
return new RegExp(source, "i");
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function permissionGateCommands(cwd?: string): string[] {
|
|
48
|
+
return splitPermissionGateCommands(settingStringAllowEmpty("permissionGate.commands", DEFAULT_PERMISSION_GATE_COMMANDS, cwd));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function permissionGateMatchers(cwd?: string): PermissionGateMatcher[] {
|
|
52
|
+
return permissionGateCommands(cwd)
|
|
53
|
+
.map((entry) => ({ label: entry, pattern: regexFromSlashPattern(entry) ?? literalCommandPattern(entry) }))
|
|
54
|
+
.filter((matcher): matcher is PermissionGateMatcher => matcher.pattern instanceof RegExp);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function permissionGateMatch(command: string, cwd?: string): string | undefined {
|
|
58
|
+
for (const matcher of permissionGateMatchers(cwd)) {
|
|
59
|
+
matcher.pattern.lastIndex = 0;
|
|
60
|
+
if (matcher.pattern.test(command)) return matcher.label;
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatCount(count: number, label: string): string {
|
|
66
|
+
return `${count.toLocaleString()} ${label}${count === 1 ? "" : "s"}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sanitizePermissionGatePreview(command: string): string {
|
|
70
|
+
return stripAnsi(command)
|
|
71
|
+
.replace(/\r\n/g, "\n")
|
|
72
|
+
.replace(/\r/g, "\n")
|
|
73
|
+
.replace(/\t/g, " ")
|
|
74
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "\ufffd");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function permissionGateCommandPreview(command: string, cwd?: string): { text: string; totalChars: number; totalLines: number; truncated: boolean } {
|
|
78
|
+
const maxLines = boundedSettingNumber("permissionGate.previewLines", DEFAULT_PERMISSION_GATE_PREVIEW_LINES, 4, 40, cwd);
|
|
79
|
+
const maxChars = boundedSettingNumber("permissionGate.previewChars", DEFAULT_PERMISSION_GATE_PREVIEW_CHARS, 200, 5000, cwd);
|
|
80
|
+
const maxLineWidth = boundedSettingNumber("permissionGate.previewLineWidth", DEFAULT_PERMISSION_GATE_PREVIEW_LINE_WIDTH, 40, 240, cwd);
|
|
81
|
+
const safeCommand = sanitizePermissionGatePreview(command);
|
|
82
|
+
const commandLines = safeCommand.split("\n");
|
|
83
|
+
let selectedLines = commandLines;
|
|
84
|
+
let omittedLines = 0;
|
|
85
|
+
|
|
86
|
+
if (commandLines.length > maxLines) {
|
|
87
|
+
const headCount = Math.max(1, Math.ceil((maxLines - 1) * 0.65));
|
|
88
|
+
const tailCount = Math.max(1, maxLines - headCount - 1);
|
|
89
|
+
omittedLines = Math.max(0, commandLines.length - headCount - tailCount);
|
|
90
|
+
selectedLines = [
|
|
91
|
+
...commandLines.slice(0, headCount),
|
|
92
|
+
`… ${formatCount(omittedLines, "line")} omitted …`,
|
|
93
|
+
...commandLines.slice(-tailCount),
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let widthTruncated = false;
|
|
98
|
+
const previewLines = selectedLines.map((line) => {
|
|
99
|
+
if (/^… \d[\d,]* lines? omitted …$/.test(line)) return line;
|
|
100
|
+
if (visibleWidth(line) > maxLineWidth) widthTruncated = true;
|
|
101
|
+
return truncateToWidth(line, maxLineWidth, "…");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
let text = previewLines.join("\n").trimEnd();
|
|
105
|
+
let charTruncated = false;
|
|
106
|
+
if (text.length > maxChars) {
|
|
107
|
+
const marker = `\n… preview clipped to ${formatCount(maxChars, "char")} …\n`;
|
|
108
|
+
const budget = Math.max(0, maxChars - marker.length);
|
|
109
|
+
const headChars = Math.ceil(budget * 0.6);
|
|
110
|
+
const tailChars = Math.max(0, budget - headChars);
|
|
111
|
+
const tail = tailChars > 0 ? text.slice(-tailChars).trimStart() : "";
|
|
112
|
+
text = `${text.slice(0, headChars).trimEnd()}${marker}${tail}`;
|
|
113
|
+
charTruncated = true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
text: text || "(empty command)",
|
|
118
|
+
totalChars: command.length,
|
|
119
|
+
totalLines: commandLines.length,
|
|
120
|
+
truncated: omittedLines > 0 || widthTruncated || charTruncated,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function permissionGatePrompt(matched: string, command: string, cwd?: string): string {
|
|
125
|
+
const preview = permissionGateCommandPreview(command, cwd);
|
|
126
|
+
const matchedLabel = truncateToWidth(sanitizePermissionGatePreview(matched).replace(/\n+/g, " ").trim() || "configured pattern", DEFAULT_PERMISSION_GATE_PREVIEW_LINE_WIDTH, "…");
|
|
127
|
+
const commandStats = `${formatCount(preview.totalLines, "line")}, ${formatCount(preview.totalChars, "char")}`;
|
|
128
|
+
return [
|
|
129
|
+
`Permission gate matched: ${matchedLabel}`,
|
|
130
|
+
"",
|
|
131
|
+
`Bash command (${commandStats}${preview.truncated ? "; compact preview" : ""}):`,
|
|
132
|
+
"```sh",
|
|
133
|
+
preview.text,
|
|
134
|
+
"```",
|
|
135
|
+
...(preview.truncated ? ["Full command is unchanged; only this approval preview was shortened."] : []),
|
|
136
|
+
"",
|
|
137
|
+
"Allow this bash command?",
|
|
138
|
+
].join("\n");
|
|
139
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { complete, type Message } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { textFromContent } from "./agent-end.js";
|
|
4
|
+
import { oneLine } from "./ansi.js";
|
|
5
|
+
import { modelLabel, resolveConfiguredModel } from "./compaction.js";
|
|
6
|
+
import {
|
|
7
|
+
AUTO_RENAME_SYSTEM_PROMPT,
|
|
8
|
+
DEFAULT_AUTO_RENAME_FALLBACK_MODEL,
|
|
9
|
+
DEFAULT_AUTO_RENAME_INPUT_CHARS,
|
|
10
|
+
DEFAULT_AUTO_RENAME_MAX_TOKENS,
|
|
11
|
+
DEFAULT_AUTO_RENAME_MODEL,
|
|
12
|
+
DEFAULT_AUTO_RENAME_NAME_CHARS,
|
|
13
|
+
DEFAULT_AUTO_RENAME_PROMPT,
|
|
14
|
+
DEFAULT_AUTO_RENAME_TIMEOUT_MS,
|
|
15
|
+
} from "./constants.js";
|
|
16
|
+
import { settingBoolean, settingNumber, settingString, settingStringAllowEmpty } from "./settings.js";
|
|
17
|
+
import { stringifyError } from "./util.js";
|
|
18
|
+
|
|
19
|
+
type AutoRenameFallbackMode = "none" | "truncate" | "words";
|
|
20
|
+
|
|
21
|
+
interface AutoRenameAuth {
|
|
22
|
+
apiKey?: string;
|
|
23
|
+
headers?: Record<string, string>;
|
|
24
|
+
label: string;
|
|
25
|
+
model: any;
|
|
26
|
+
source: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function autoRenameEnabled(cwd?: string): boolean {
|
|
30
|
+
return settingBoolean("sessionAutoRename.enabled", true, cwd);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// pi raises this exact prefix when a captured ExtensionContext is touched
|
|
34
|
+
// after newSession / fork / switchSession / reload. Recognising it lets us
|
|
35
|
+
// abort gracefully instead of crashing the host process.
|
|
36
|
+
export function isStaleCtxError(error: unknown): boolean {
|
|
37
|
+
const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
|
38
|
+
return /extension ctx is stale/i.test(message);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function autoRenameCtxAlive(ctx: ExtensionContext): boolean {
|
|
42
|
+
// Auto-rename runs from a deferred timer / async path, so the captured
|
|
43
|
+
// extension context can be invalidated mid-flight by newSession / fork /
|
|
44
|
+
// switchSession / reload. Touching ctx.hasUI on a stale ctx throws and
|
|
45
|
+
// kills the host pi process, which has surfaced as bg subagents 'failing'
|
|
46
|
+
// at exit code 1 even after their work completed cleanly. Treat any
|
|
47
|
+
// access error as 'no UI available' and silently skip the notification.
|
|
48
|
+
try {
|
|
49
|
+
return ctx.hasUI;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Read a ctx property without crashing if the ctx has been replaced. Used
|
|
56
|
+
// for the cwd-derived setting reads that downstream helpers depend on.
|
|
57
|
+
function readCtxCwd(ctx: ExtensionContext): string | undefined {
|
|
58
|
+
try {
|
|
59
|
+
return ctx.cwd;
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function autoRenameDebug(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
|
|
66
|
+
if (!autoRenameCtxAlive(ctx)) return;
|
|
67
|
+
if (settingBoolean("sessionAutoRename.debug", false, ctx.cwd)) ctx.ui.notify(`[auto-rename] ${message}`, level);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function autoRenameNotify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info", force = false): void {
|
|
71
|
+
if (!autoRenameCtxAlive(ctx)) return;
|
|
72
|
+
if (force || settingBoolean("sessionAutoRename.notify", false, ctx.cwd) || settingBoolean("sessionAutoRename.debug", false, ctx.cwd)) ctx.ui.notify(`[auto-rename] ${message}`, level);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function firstUserMessageText(branch: SessionEntry[]): string | undefined {
|
|
76
|
+
for (const entry of branch) {
|
|
77
|
+
if (entry.type !== "message" || entry.message?.role !== "user") continue;
|
|
78
|
+
const text = textFromContent(entry.message.content).trim();
|
|
79
|
+
if (text) return text;
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function conversationTranscriptText(branch: SessionEntry[], maxChars: number): string | undefined {
|
|
85
|
+
const lines: string[] = [];
|
|
86
|
+
for (const entry of branch) {
|
|
87
|
+
if (entry.type !== "message") continue;
|
|
88
|
+
const role = entry.message?.role;
|
|
89
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
90
|
+
const text = textFromContent(entry.message.content).trim();
|
|
91
|
+
if (!text) continue;
|
|
92
|
+
lines.push(`${role === "user" ? "User" : "Assistant"}: ${text}`);
|
|
93
|
+
}
|
|
94
|
+
const transcript = lines.join("\n\n").trim();
|
|
95
|
+
return transcript ? truncateMiddle(transcript, maxChars) : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function truncateMiddle(text: string, maxChars: number): string {
|
|
99
|
+
const max = Math.max(200, Math.floor(maxChars));
|
|
100
|
+
if (text.length <= max) return text;
|
|
101
|
+
const marker = "\n[...truncated...]\n";
|
|
102
|
+
const budget = max - marker.length;
|
|
103
|
+
if (budget <= 0) return text.slice(0, max);
|
|
104
|
+
const headBudget = Math.ceil(budget * 0.6);
|
|
105
|
+
const tailBudget = budget - headBudget;
|
|
106
|
+
let head = text.slice(0, headBudget);
|
|
107
|
+
const headSpace = head.lastIndexOf(" ");
|
|
108
|
+
if (headSpace > headBudget * 0.6) head = head.slice(0, headSpace);
|
|
109
|
+
let tail = text.slice(text.length - tailBudget);
|
|
110
|
+
const tailSpace = tail.indexOf(" ");
|
|
111
|
+
if (tailSpace >= 0 && tailSpace < tailBudget * 0.4) tail = tail.slice(tailSpace + 1);
|
|
112
|
+
return `${head}${marker}${tail}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function clampAutoRenameName(name: string, maxChars: number): string {
|
|
116
|
+
const max = Math.max(20, Math.floor(maxChars));
|
|
117
|
+
let cleaned = oneLine(name).replace(/[.!?:;,]+$/g, "").trim();
|
|
118
|
+
if (cleaned.length <= max) return cleaned;
|
|
119
|
+
const truncated = cleaned.slice(0, max).trimEnd();
|
|
120
|
+
const lastSpace = truncated.lastIndexOf(" ");
|
|
121
|
+
cleaned = lastSpace > max * 0.45 ? truncated.slice(0, lastSpace) : truncated;
|
|
122
|
+
return cleaned.replace(/[,;:\s]+$/g, "").trim();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function stripAutoRenameThinkTags(text: string): string {
|
|
126
|
+
return text
|
|
127
|
+
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
|
128
|
+
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
|
|
129
|
+
.replace(/<think>[\s\S]*/gi, "")
|
|
130
|
+
.replace(/<thinking>[\s\S]*/gi, "")
|
|
131
|
+
.trim();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function normalizeAutoRenameCandidate(line: string): string {
|
|
135
|
+
return line
|
|
136
|
+
.replace(/<[^>]+>/g, " ")
|
|
137
|
+
.replace(/^\s*(?:final\s+)?(?:title|name|session\s+name)\s*[:\-]\s*/i, "")
|
|
138
|
+
.replace(/^[\s\-*>#•]+/, "")
|
|
139
|
+
.replace(/^\d+[.)]\s*/, "")
|
|
140
|
+
.replace(/\*\*/g, "")
|
|
141
|
+
.replace(/`/g, "")
|
|
142
|
+
.replace(/^["'“”‘’]+|["'“”‘’]+$/g, "")
|
|
143
|
+
.replace(/\s+/g, " ")
|
|
144
|
+
.trim();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function looksLikeAutoRenameReasoning(line: string): boolean {
|
|
148
|
+
if (!line) return true;
|
|
149
|
+
if (/\b(here'?s|i would|best title|candidate|option|reasoning|analysis|thinking)\b/i.test(line)) return true;
|
|
150
|
+
if (/[{}<>|]/.test(line)) return true;
|
|
151
|
+
const words = line.split(/\s+/).filter(Boolean);
|
|
152
|
+
return words.length === 0 || words.length > 12;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function autoRenameResponseText(response: any): string {
|
|
156
|
+
const blocks = Array.isArray(response?.content) ? response.content : [];
|
|
157
|
+
const text = blocks
|
|
158
|
+
.filter((block: any) => block?.type === "text" && typeof block.text === "string")
|
|
159
|
+
.map((block: any) => block.text)
|
|
160
|
+
.join("\n")
|
|
161
|
+
.trim();
|
|
162
|
+
if (text) return text;
|
|
163
|
+
return blocks
|
|
164
|
+
.filter((block: any) => block?.type === "thinking" && typeof block.thinking === "string")
|
|
165
|
+
.map((block: any) => block.thinking)
|
|
166
|
+
.join("\n")
|
|
167
|
+
.trim();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function sanitizeAutoRenameName(raw: string, maxChars: number): string | undefined {
|
|
171
|
+
const stripped = stripAutoRenameThinkTags(raw);
|
|
172
|
+
const lines = stripped
|
|
173
|
+
.split(/\r?\n/)
|
|
174
|
+
.map(normalizeAutoRenameCandidate)
|
|
175
|
+
.filter(Boolean);
|
|
176
|
+
let name = "";
|
|
177
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
178
|
+
if (!looksLikeAutoRenameReasoning(lines[index]!)) {
|
|
179
|
+
name = lines[index]!;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (!name && lines.length > 0) name = [...lines].sort((a, b) => a.length - b.length)[0]!;
|
|
184
|
+
name = clampAutoRenameName(name, maxChars);
|
|
185
|
+
return name && !looksLikeAutoRenameReasoning(name) ? name : undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function titleCaseWord(word: string): string {
|
|
189
|
+
return word ? `${word.charAt(0).toUpperCase()}${word.slice(1).toLowerCase()}` : word;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function autoRenameFallbackMode(cwd?: string): AutoRenameFallbackMode {
|
|
193
|
+
const configured = settingString("sessionAutoRename.fallback", "words", cwd).toLowerCase();
|
|
194
|
+
return configured === "none" || configured === "truncate" ? configured : "words";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function deterministicAutoRenameName(query: string, cwd?: string): string | undefined {
|
|
198
|
+
const mode = autoRenameFallbackMode(cwd);
|
|
199
|
+
if (mode === "none") return undefined;
|
|
200
|
+
const maxChars = settingNumber("sessionAutoRename.maxNameChars", DEFAULT_AUTO_RENAME_NAME_CHARS, cwd);
|
|
201
|
+
const cleaned = oneLine(query)
|
|
202
|
+
.replace(/[`"'“”‘’]/g, "")
|
|
203
|
+
.replace(/[^\w\s./-]/g, " ")
|
|
204
|
+
.replace(/\s+/g, " ")
|
|
205
|
+
.trim();
|
|
206
|
+
if (!cleaned) return undefined;
|
|
207
|
+
if (mode === "truncate") return clampAutoRenameName(cleaned, Math.min(maxChars, 50));
|
|
208
|
+
const words = cleaned.split(/\s+/).filter(Boolean).slice(0, 6).map(titleCaseWord);
|
|
209
|
+
return clampAutoRenameName(words.join(" "), maxChars);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function autoRenamePrompt(query: string, cwd?: string): string {
|
|
213
|
+
const maxChars = Math.max(200, Math.floor(settingNumber("sessionAutoRename.maxInputChars", DEFAULT_AUTO_RENAME_INPUT_CHARS, cwd)));
|
|
214
|
+
const message = truncateMiddle(query, maxChars);
|
|
215
|
+
const configured = settingStringAllowEmpty("sessionAutoRename.prompt", "", cwd);
|
|
216
|
+
const template = configured || DEFAULT_AUTO_RENAME_PROMPT;
|
|
217
|
+
if (template.includes("{{message}}")) return template.split("{{message}}").join(message);
|
|
218
|
+
return `${template.trim()}\n\nFirst user message:\n${message}`.trim();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function disabledModelSetting(value: string): boolean {
|
|
222
|
+
const normalized = value.trim().toLowerCase();
|
|
223
|
+
return !normalized || normalized === "none" || normalized === "off" || normalized === "disabled";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function autoRenameModelSettings(cwd?: string): string[] {
|
|
227
|
+
const primary = settingString("sessionAutoRename.model", DEFAULT_AUTO_RENAME_MODEL, cwd);
|
|
228
|
+
const fallback = settingStringAllowEmpty("sessionAutoRename.fallbackModel", DEFAULT_AUTO_RENAME_FALLBACK_MODEL, cwd);
|
|
229
|
+
const candidates = [primary];
|
|
230
|
+
if (primary === DEFAULT_AUTO_RENAME_MODEL) candidates.push("openai-codex/gpt-5.3-codex-spark");
|
|
231
|
+
candidates.push(fallback);
|
|
232
|
+
return candidates
|
|
233
|
+
.map((value) => value.trim())
|
|
234
|
+
.filter((value) => !disabledModelSetting(value))
|
|
235
|
+
.filter((value, index, all) => all.findIndex((candidate) => candidate.toLowerCase() === value.toLowerCase()) === index);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function headerRecord(headers: unknown): Record<string, string> | undefined {
|
|
239
|
+
if (!headers || typeof headers !== "object" || Array.isArray(headers)) return undefined;
|
|
240
|
+
const entries = Object.entries(headers as Record<string, unknown>)
|
|
241
|
+
.filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].length > 0);
|
|
242
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function modelCost(model: any): number {
|
|
246
|
+
const cost = model?.cost;
|
|
247
|
+
return Number(cost?.input ?? 0) + Number(cost?.output ?? 0);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function cheapestAvailableAutoRenameModel(ctx: ExtensionContext): Promise<AutoRenameAuth | undefined> {
|
|
251
|
+
let registry: any;
|
|
252
|
+
try {
|
|
253
|
+
registry = ctx.modelRegistry as any;
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (isStaleCtxError(error)) return undefined;
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
const rawModels = typeof registry.getAvailable === "function" ? registry.getAvailable() : typeof registry.getAll === "function" ? registry.getAll() : [];
|
|
259
|
+
const models = Array.isArray(rawModels) ? rawModels.filter((model) => model?.input?.includes?.("text") ?? true) : [];
|
|
260
|
+
models.sort((a, b) => modelCost(a) - modelCost(b) || modelLabel(a).localeCompare(modelLabel(b)));
|
|
261
|
+
for (const model of models) {
|
|
262
|
+
try {
|
|
263
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
264
|
+
const headers = headerRecord(auth.headers);
|
|
265
|
+
if (auth.ok && (auth.apiKey || headers)) return { apiKey: auth.apiKey, headers, label: modelLabel(model), model, source: "cheapest" };
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (isStaleCtxError(error)) return undefined;
|
|
268
|
+
// Try the next available model.
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function resolveAutoRenameModel(ctx: ExtensionContext, configured: string): Promise<AutoRenameAuth | undefined> {
|
|
275
|
+
try {
|
|
276
|
+
if (configured.trim().toLowerCase() === "cheapest") return await cheapestAvailableAutoRenameModel(ctx);
|
|
277
|
+
const model = resolveConfiguredModel(ctx, configured);
|
|
278
|
+
if (!model) {
|
|
279
|
+
autoRenameDebug(ctx, `model not found: ${configured}`, "warning");
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
284
|
+
const headers = headerRecord(auth.headers);
|
|
285
|
+
if (!auth.ok) {
|
|
286
|
+
autoRenameDebug(ctx, `auth unavailable for ${modelLabel(model)}: ${auth.error}`, "warning");
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
if (!auth.apiKey && !headers) {
|
|
290
|
+
autoRenameDebug(ctx, `no auth for ${modelLabel(model)}; use /login or models.json`, "warning");
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
return { apiKey: auth.apiKey, headers, label: modelLabel(model), model, source: configured };
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (isStaleCtxError(error)) return undefined;
|
|
296
|
+
autoRenameDebug(ctx, `auth failed for ${modelLabel(model)}: ${stringifyError(error)}`, "warning");
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (isStaleCtxError(error)) return undefined;
|
|
301
|
+
throw error;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function generateAutoRenameName(query: string, ctx: ExtensionContext, fullConversation = false): Promise<{ name?: string; source: string }> {
|
|
306
|
+
// Snapshot cwd once. If the ctx has already gone stale, fall through with
|
|
307
|
+
// undefined cwd — downstream settings reads handle that and the rename
|
|
308
|
+
// will abort with "none".
|
|
309
|
+
const cwd = readCtxCwd(ctx);
|
|
310
|
+
const maxNameChars = Math.max(20, Math.floor(settingNumber("sessionAutoRename.maxNameChars", DEFAULT_AUTO_RENAME_NAME_CHARS, cwd)));
|
|
311
|
+
const maxTokens = Math.max(16, Math.floor(settingNumber("sessionAutoRename.maxTokens", DEFAULT_AUTO_RENAME_MAX_TOKENS, cwd)));
|
|
312
|
+
const prompt = fullConversation
|
|
313
|
+
? `${AUTO_RENAME_SYSTEM_PROMPT}\n\nName this session using the conversation transcript below. Return only the title.\n\n${query}`
|
|
314
|
+
: autoRenamePrompt(query, cwd);
|
|
315
|
+
const message: Message = {
|
|
316
|
+
content: [{ text: prompt, type: "text" }],
|
|
317
|
+
role: "user",
|
|
318
|
+
timestamp: Date.now(),
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
for (const configured of autoRenameModelSettings(cwd)) {
|
|
322
|
+
const resolved = await resolveAutoRenameModel(ctx, configured);
|
|
323
|
+
if (!resolved) continue;
|
|
324
|
+
try {
|
|
325
|
+
const controller = new AbortController();
|
|
326
|
+
const timeoutMs = Math.max(1000, Math.floor(settingNumber("sessionAutoRename.timeoutMs", DEFAULT_AUTO_RENAME_TIMEOUT_MS, cwd)));
|
|
327
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
328
|
+
timeout.unref?.();
|
|
329
|
+
try {
|
|
330
|
+
const response = await complete(
|
|
331
|
+
resolved.model,
|
|
332
|
+
{ messages: [message], systemPrompt: AUTO_RENAME_SYSTEM_PROMPT },
|
|
333
|
+
{ apiKey: resolved.apiKey, headers: resolved.headers, maxTokens, signal: controller.signal },
|
|
334
|
+
);
|
|
335
|
+
if (response.stopReason === "error") {
|
|
336
|
+
autoRenameDebug(ctx, `${resolved.label} failed: ${response.errorMessage ?? "unknown error"}`, "warning");
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const name = sanitizeAutoRenameName(autoRenameResponseText(response), maxNameChars);
|
|
340
|
+
if (name) return { name, source: resolved.label };
|
|
341
|
+
autoRenameDebug(ctx, `${resolved.label} returned no usable title`, "warning");
|
|
342
|
+
} finally {
|
|
343
|
+
clearTimeout(timeout);
|
|
344
|
+
}
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (isStaleCtxError(error)) return { source: "none" };
|
|
347
|
+
autoRenameDebug(ctx, `${resolved.label} failed: ${stringifyError(error)}`, "warning");
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const deterministic = deterministicAutoRenameName(query, cwd);
|
|
352
|
+
return deterministic ? { name: deterministic, source: `fallback:${autoRenameFallbackMode(cwd)}` } : { source: "none" };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function withAutoRenamePrefix(name: string, cwd?: string): string {
|
|
356
|
+
const maxNameChars = Math.max(20, Math.floor(settingNumber("sessionAutoRename.maxNameChars", DEFAULT_AUTO_RENAME_NAME_CHARS, cwd)));
|
|
357
|
+
const prefix = clampAutoRenameName(settingStringAllowEmpty("sessionAutoRename.prefix", "", cwd), 40);
|
|
358
|
+
return clampAutoRenameName(prefix ? `${prefix}: ${name}` : name, maxNameChars);
|
|
359
|
+
}
|