@vanillagreen/pi-qol 1.2.0 → 1.3.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 +1 -0
- 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 +323 -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 +93 -4408
- package/package.json +1 -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,323 @@
|
|
|
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
|
+
function autoRenameCtxAlive(ctx: ExtensionContext): boolean {
|
|
34
|
+
// Auto-rename runs from a deferred timer / async path, so the captured
|
|
35
|
+
// extension context can be invalidated mid-flight by newSession / fork /
|
|
36
|
+
// switchSession / reload. Touching ctx.hasUI on a stale ctx throws and
|
|
37
|
+
// kills the host pi process, which has surfaced as bg subagents 'failing'
|
|
38
|
+
// at exit code 1 even after their work completed cleanly. Treat any
|
|
39
|
+
// access error as 'no UI available' and silently skip the notification.
|
|
40
|
+
try {
|
|
41
|
+
return ctx.hasUI;
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function autoRenameDebug(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
|
|
48
|
+
if (!autoRenameCtxAlive(ctx)) return;
|
|
49
|
+
if (settingBoolean("sessionAutoRename.debug", false, ctx.cwd)) ctx.ui.notify(`[auto-rename] ${message}`, level);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function autoRenameNotify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info", force = false): void {
|
|
53
|
+
if (!autoRenameCtxAlive(ctx)) return;
|
|
54
|
+
if (force || settingBoolean("sessionAutoRename.notify", false, ctx.cwd) || settingBoolean("sessionAutoRename.debug", false, ctx.cwd)) ctx.ui.notify(`[auto-rename] ${message}`, level);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function firstUserMessageText(branch: SessionEntry[]): string | undefined {
|
|
58
|
+
for (const entry of branch) {
|
|
59
|
+
if (entry.type !== "message" || entry.message?.role !== "user") continue;
|
|
60
|
+
const text = textFromContent(entry.message.content).trim();
|
|
61
|
+
if (text) return text;
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function conversationTranscriptText(branch: SessionEntry[], maxChars: number): string | undefined {
|
|
67
|
+
const lines: string[] = [];
|
|
68
|
+
for (const entry of branch) {
|
|
69
|
+
if (entry.type !== "message") continue;
|
|
70
|
+
const role = entry.message?.role;
|
|
71
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
72
|
+
const text = textFromContent(entry.message.content).trim();
|
|
73
|
+
if (!text) continue;
|
|
74
|
+
lines.push(`${role === "user" ? "User" : "Assistant"}: ${text}`);
|
|
75
|
+
}
|
|
76
|
+
const transcript = lines.join("\n\n").trim();
|
|
77
|
+
return transcript ? truncateMiddle(transcript, maxChars) : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function truncateMiddle(text: string, maxChars: number): string {
|
|
81
|
+
const max = Math.max(200, Math.floor(maxChars));
|
|
82
|
+
if (text.length <= max) return text;
|
|
83
|
+
const marker = "\n[...truncated...]\n";
|
|
84
|
+
const budget = max - marker.length;
|
|
85
|
+
if (budget <= 0) return text.slice(0, max);
|
|
86
|
+
const headBudget = Math.ceil(budget * 0.6);
|
|
87
|
+
const tailBudget = budget - headBudget;
|
|
88
|
+
let head = text.slice(0, headBudget);
|
|
89
|
+
const headSpace = head.lastIndexOf(" ");
|
|
90
|
+
if (headSpace > headBudget * 0.6) head = head.slice(0, headSpace);
|
|
91
|
+
let tail = text.slice(text.length - tailBudget);
|
|
92
|
+
const tailSpace = tail.indexOf(" ");
|
|
93
|
+
if (tailSpace >= 0 && tailSpace < tailBudget * 0.4) tail = tail.slice(tailSpace + 1);
|
|
94
|
+
return `${head}${marker}${tail}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function clampAutoRenameName(name: string, maxChars: number): string {
|
|
98
|
+
const max = Math.max(20, Math.floor(maxChars));
|
|
99
|
+
let cleaned = oneLine(name).replace(/[.!?:;,]+$/g, "").trim();
|
|
100
|
+
if (cleaned.length <= max) return cleaned;
|
|
101
|
+
const truncated = cleaned.slice(0, max).trimEnd();
|
|
102
|
+
const lastSpace = truncated.lastIndexOf(" ");
|
|
103
|
+
cleaned = lastSpace > max * 0.45 ? truncated.slice(0, lastSpace) : truncated;
|
|
104
|
+
return cleaned.replace(/[,;:\s]+$/g, "").trim();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function stripAutoRenameThinkTags(text: string): string {
|
|
108
|
+
return text
|
|
109
|
+
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
|
110
|
+
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
|
|
111
|
+
.replace(/<think>[\s\S]*/gi, "")
|
|
112
|
+
.replace(/<thinking>[\s\S]*/gi, "")
|
|
113
|
+
.trim();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeAutoRenameCandidate(line: string): string {
|
|
117
|
+
return line
|
|
118
|
+
.replace(/<[^>]+>/g, " ")
|
|
119
|
+
.replace(/^\s*(?:final\s+)?(?:title|name|session\s+name)\s*[:\-]\s*/i, "")
|
|
120
|
+
.replace(/^[\s\-*>#•]+/, "")
|
|
121
|
+
.replace(/^\d+[.)]\s*/, "")
|
|
122
|
+
.replace(/\*\*/g, "")
|
|
123
|
+
.replace(/`/g, "")
|
|
124
|
+
.replace(/^["'“”‘’]+|["'“”‘’]+$/g, "")
|
|
125
|
+
.replace(/\s+/g, " ")
|
|
126
|
+
.trim();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function looksLikeAutoRenameReasoning(line: string): boolean {
|
|
130
|
+
if (!line) return true;
|
|
131
|
+
if (/\b(here'?s|i would|best title|candidate|option|reasoning|analysis|thinking)\b/i.test(line)) return true;
|
|
132
|
+
if (/[{}<>|]/.test(line)) return true;
|
|
133
|
+
const words = line.split(/\s+/).filter(Boolean);
|
|
134
|
+
return words.length === 0 || words.length > 12;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function autoRenameResponseText(response: any): string {
|
|
138
|
+
const blocks = Array.isArray(response?.content) ? response.content : [];
|
|
139
|
+
const text = blocks
|
|
140
|
+
.filter((block: any) => block?.type === "text" && typeof block.text === "string")
|
|
141
|
+
.map((block: any) => block.text)
|
|
142
|
+
.join("\n")
|
|
143
|
+
.trim();
|
|
144
|
+
if (text) return text;
|
|
145
|
+
return blocks
|
|
146
|
+
.filter((block: any) => block?.type === "thinking" && typeof block.thinking === "string")
|
|
147
|
+
.map((block: any) => block.thinking)
|
|
148
|
+
.join("\n")
|
|
149
|
+
.trim();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function sanitizeAutoRenameName(raw: string, maxChars: number): string | undefined {
|
|
153
|
+
const stripped = stripAutoRenameThinkTags(raw);
|
|
154
|
+
const lines = stripped
|
|
155
|
+
.split(/\r?\n/)
|
|
156
|
+
.map(normalizeAutoRenameCandidate)
|
|
157
|
+
.filter(Boolean);
|
|
158
|
+
let name = "";
|
|
159
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
160
|
+
if (!looksLikeAutoRenameReasoning(lines[index]!)) {
|
|
161
|
+
name = lines[index]!;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (!name && lines.length > 0) name = [...lines].sort((a, b) => a.length - b.length)[0]!;
|
|
166
|
+
name = clampAutoRenameName(name, maxChars);
|
|
167
|
+
return name && !looksLikeAutoRenameReasoning(name) ? name : undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function titleCaseWord(word: string): string {
|
|
171
|
+
return word ? `${word.charAt(0).toUpperCase()}${word.slice(1).toLowerCase()}` : word;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function autoRenameFallbackMode(cwd?: string): AutoRenameFallbackMode {
|
|
175
|
+
const configured = settingString("sessionAutoRename.fallback", "words", cwd).toLowerCase();
|
|
176
|
+
return configured === "none" || configured === "truncate" ? configured : "words";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function deterministicAutoRenameName(query: string, cwd?: string): string | undefined {
|
|
180
|
+
const mode = autoRenameFallbackMode(cwd);
|
|
181
|
+
if (mode === "none") return undefined;
|
|
182
|
+
const maxChars = settingNumber("sessionAutoRename.maxNameChars", DEFAULT_AUTO_RENAME_NAME_CHARS, cwd);
|
|
183
|
+
const cleaned = oneLine(query)
|
|
184
|
+
.replace(/[`"'“”‘’]/g, "")
|
|
185
|
+
.replace(/[^\w\s./-]/g, " ")
|
|
186
|
+
.replace(/\s+/g, " ")
|
|
187
|
+
.trim();
|
|
188
|
+
if (!cleaned) return undefined;
|
|
189
|
+
if (mode === "truncate") return clampAutoRenameName(cleaned, Math.min(maxChars, 50));
|
|
190
|
+
const words = cleaned.split(/\s+/).filter(Boolean).slice(0, 6).map(titleCaseWord);
|
|
191
|
+
return clampAutoRenameName(words.join(" "), maxChars);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function autoRenamePrompt(query: string, cwd?: string): string {
|
|
195
|
+
const maxChars = Math.max(200, Math.floor(settingNumber("sessionAutoRename.maxInputChars", DEFAULT_AUTO_RENAME_INPUT_CHARS, cwd)));
|
|
196
|
+
const message = truncateMiddle(query, maxChars);
|
|
197
|
+
const configured = settingStringAllowEmpty("sessionAutoRename.prompt", "", cwd);
|
|
198
|
+
const template = configured || DEFAULT_AUTO_RENAME_PROMPT;
|
|
199
|
+
if (template.includes("{{message}}")) return template.split("{{message}}").join(message);
|
|
200
|
+
return `${template.trim()}\n\nFirst user message:\n${message}`.trim();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function disabledModelSetting(value: string): boolean {
|
|
204
|
+
const normalized = value.trim().toLowerCase();
|
|
205
|
+
return !normalized || normalized === "none" || normalized === "off" || normalized === "disabled";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function autoRenameModelSettings(cwd?: string): string[] {
|
|
209
|
+
const primary = settingString("sessionAutoRename.model", DEFAULT_AUTO_RENAME_MODEL, cwd);
|
|
210
|
+
const fallback = settingStringAllowEmpty("sessionAutoRename.fallbackModel", DEFAULT_AUTO_RENAME_FALLBACK_MODEL, cwd);
|
|
211
|
+
const candidates = [primary];
|
|
212
|
+
if (primary === DEFAULT_AUTO_RENAME_MODEL) candidates.push("openai-codex/gpt-5.3-codex-spark");
|
|
213
|
+
candidates.push(fallback);
|
|
214
|
+
return candidates
|
|
215
|
+
.map((value) => value.trim())
|
|
216
|
+
.filter((value) => !disabledModelSetting(value))
|
|
217
|
+
.filter((value, index, all) => all.findIndex((candidate) => candidate.toLowerCase() === value.toLowerCase()) === index);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function headerRecord(headers: unknown): Record<string, string> | undefined {
|
|
221
|
+
if (!headers || typeof headers !== "object" || Array.isArray(headers)) return undefined;
|
|
222
|
+
const entries = Object.entries(headers as Record<string, unknown>)
|
|
223
|
+
.filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].length > 0);
|
|
224
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function modelCost(model: any): number {
|
|
228
|
+
const cost = model?.cost;
|
|
229
|
+
return Number(cost?.input ?? 0) + Number(cost?.output ?? 0);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function cheapestAvailableAutoRenameModel(ctx: ExtensionContext): Promise<AutoRenameAuth | undefined> {
|
|
233
|
+
const registry = ctx.modelRegistry as any;
|
|
234
|
+
const rawModels = typeof registry.getAvailable === "function" ? registry.getAvailable() : typeof registry.getAll === "function" ? registry.getAll() : [];
|
|
235
|
+
const models = Array.isArray(rawModels) ? rawModels.filter((model) => model?.input?.includes?.("text") ?? true) : [];
|
|
236
|
+
models.sort((a, b) => modelCost(a) - modelCost(b) || modelLabel(a).localeCompare(modelLabel(b)));
|
|
237
|
+
for (const model of models) {
|
|
238
|
+
try {
|
|
239
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
240
|
+
const headers = headerRecord(auth.headers);
|
|
241
|
+
if (auth.ok && (auth.apiKey || headers)) return { apiKey: auth.apiKey, headers, label: modelLabel(model), model, source: "cheapest" };
|
|
242
|
+
} catch {
|
|
243
|
+
// Try the next available model.
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function resolveAutoRenameModel(ctx: ExtensionContext, configured: string): Promise<AutoRenameAuth | undefined> {
|
|
250
|
+
if (configured.trim().toLowerCase() === "cheapest") return cheapestAvailableAutoRenameModel(ctx);
|
|
251
|
+
const model = resolveConfiguredModel(ctx, configured);
|
|
252
|
+
if (!model) {
|
|
253
|
+
autoRenameDebug(ctx, `model not found: ${configured}`, "warning");
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
258
|
+
const headers = headerRecord(auth.headers);
|
|
259
|
+
if (!auth.ok) {
|
|
260
|
+
autoRenameDebug(ctx, `auth unavailable for ${modelLabel(model)}: ${auth.error}`, "warning");
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
if (!auth.apiKey && !headers) {
|
|
264
|
+
autoRenameDebug(ctx, `no auth for ${modelLabel(model)}; use /login or models.json`, "warning");
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
return { apiKey: auth.apiKey, headers, label: modelLabel(model), model, source: configured };
|
|
268
|
+
} catch (error) {
|
|
269
|
+
autoRenameDebug(ctx, `auth failed for ${modelLabel(model)}: ${stringifyError(error)}`, "warning");
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function generateAutoRenameName(query: string, ctx: ExtensionContext, fullConversation = false): Promise<{ name?: string; source: string }> {
|
|
275
|
+
const maxNameChars = Math.max(20, Math.floor(settingNumber("sessionAutoRename.maxNameChars", DEFAULT_AUTO_RENAME_NAME_CHARS, ctx.cwd)));
|
|
276
|
+
const maxTokens = Math.max(16, Math.floor(settingNumber("sessionAutoRename.maxTokens", DEFAULT_AUTO_RENAME_MAX_TOKENS, ctx.cwd)));
|
|
277
|
+
const prompt = fullConversation
|
|
278
|
+
? `${AUTO_RENAME_SYSTEM_PROMPT}\n\nName this session using the conversation transcript below. Return only the title.\n\n${query}`
|
|
279
|
+
: autoRenamePrompt(query, ctx.cwd);
|
|
280
|
+
const message: Message = {
|
|
281
|
+
content: [{ text: prompt, type: "text" }],
|
|
282
|
+
role: "user",
|
|
283
|
+
timestamp: Date.now(),
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
for (const configured of autoRenameModelSettings(ctx.cwd)) {
|
|
287
|
+
const resolved = await resolveAutoRenameModel(ctx, configured);
|
|
288
|
+
if (!resolved) continue;
|
|
289
|
+
try {
|
|
290
|
+
const controller = new AbortController();
|
|
291
|
+
const timeoutMs = Math.max(1000, Math.floor(settingNumber("sessionAutoRename.timeoutMs", DEFAULT_AUTO_RENAME_TIMEOUT_MS, ctx.cwd)));
|
|
292
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
293
|
+
timeout.unref?.();
|
|
294
|
+
try {
|
|
295
|
+
const response = await complete(
|
|
296
|
+
resolved.model,
|
|
297
|
+
{ messages: [message], systemPrompt: AUTO_RENAME_SYSTEM_PROMPT },
|
|
298
|
+
{ apiKey: resolved.apiKey, headers: resolved.headers, maxTokens, signal: controller.signal },
|
|
299
|
+
);
|
|
300
|
+
if (response.stopReason === "error") {
|
|
301
|
+
autoRenameDebug(ctx, `${resolved.label} failed: ${response.errorMessage ?? "unknown error"}`, "warning");
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const name = sanitizeAutoRenameName(autoRenameResponseText(response), maxNameChars);
|
|
305
|
+
if (name) return { name, source: resolved.label };
|
|
306
|
+
autoRenameDebug(ctx, `${resolved.label} returned no usable title`, "warning");
|
|
307
|
+
} finally {
|
|
308
|
+
clearTimeout(timeout);
|
|
309
|
+
}
|
|
310
|
+
} catch (error) {
|
|
311
|
+
autoRenameDebug(ctx, `${resolved.label} failed: ${stringifyError(error)}`, "warning");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const deterministic = deterministicAutoRenameName(query, ctx.cwd);
|
|
316
|
+
return deterministic ? { name: deterministic, source: `fallback:${autoRenameFallbackMode(ctx.cwd)}` } : { source: "none" };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function withAutoRenamePrefix(name: string, cwd?: string): string {
|
|
320
|
+
const maxNameChars = Math.max(20, Math.floor(settingNumber("sessionAutoRename.maxNameChars", DEFAULT_AUTO_RENAME_NAME_CHARS, cwd)));
|
|
321
|
+
const prefix = clampAutoRenameName(settingStringAllowEmpty("sessionAutoRename.prefix", "", cwd), 40);
|
|
322
|
+
return clampAutoRenameName(prefix ? `${prefix}: ${name}` : name, maxNameChars);
|
|
323
|
+
}
|