@zigai/pi-autoname-session 1.1.4
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/LICENSE +21 -0
- package/README.md +73 -0
- package/config.schema.json +163 -0
- package/package.json +95 -0
- package/src/index.ts +751 -0
- package/src/picker-request.ts +104 -0
- package/src/session-naming.ts +711 -0
- package/src/settings-input.ts +164 -0
- package/src/settings.prevalidated.ts +31 -0
- package/src/settings.ts +121 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
2
|
+
import type {
|
|
3
|
+
Api,
|
|
4
|
+
AssistantMessage,
|
|
5
|
+
Model,
|
|
6
|
+
SimpleStreamOptions,
|
|
7
|
+
TextContent,
|
|
8
|
+
} from "@earendil-works/pi-ai";
|
|
9
|
+
import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { preparePickerPayload } from "./picker-request.ts";
|
|
11
|
+
import {
|
|
12
|
+
formatPickerModelReference,
|
|
13
|
+
loadAutonameSessionSettings,
|
|
14
|
+
type ExtensionSettings,
|
|
15
|
+
type PickerModelReference,
|
|
16
|
+
} from "./settings.ts";
|
|
17
|
+
import {
|
|
18
|
+
AUTONAME_STATE_ENTRY_TYPE,
|
|
19
|
+
buildConversationContext,
|
|
20
|
+
buildRepositoryContext,
|
|
21
|
+
createSessionNamingState,
|
|
22
|
+
getNamingRequest,
|
|
23
|
+
hasReachedTrigger,
|
|
24
|
+
isOpaqueNamingPrompt,
|
|
25
|
+
markSessionNamingComplete,
|
|
26
|
+
measureSession,
|
|
27
|
+
normalizeSessionName,
|
|
28
|
+
parseStoredSessionNamingState,
|
|
29
|
+
renderNamingPrompt,
|
|
30
|
+
type NamingPhase,
|
|
31
|
+
type SessionMetrics,
|
|
32
|
+
type SessionNamingState,
|
|
33
|
+
type StoredSessionNamingStateResult,
|
|
34
|
+
} from "./session-naming.ts";
|
|
35
|
+
|
|
36
|
+
/** Outcome of a picker attempt, classified so the caller can apply policy. */
|
|
37
|
+
type PickSessionNameOutcome =
|
|
38
|
+
| { readonly type: "picked"; readonly name: string }
|
|
39
|
+
| { readonly type: "cancelled" }
|
|
40
|
+
| { readonly type: "modelUnavailable"; readonly diagnostic: string }
|
|
41
|
+
| { readonly type: "authenticationUnavailable"; readonly diagnostic: string }
|
|
42
|
+
| { readonly type: "requestFailed"; readonly diagnostic: string }
|
|
43
|
+
| { readonly type: "timeout"; readonly diagnostic: string }
|
|
44
|
+
| { readonly type: "invalidOutput"; readonly diagnostic: string };
|
|
45
|
+
|
|
46
|
+
type ActiveNamingAttempt = {
|
|
47
|
+
readonly controller: AbortController;
|
|
48
|
+
readonly sessionGeneration: number;
|
|
49
|
+
readonly nameRevision: number;
|
|
50
|
+
readonly nameAtStart: string | undefined;
|
|
51
|
+
readonly leafIdAtStart: string | null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type PickSessionNameOptions = {
|
|
55
|
+
readonly settings: ExtensionSettings;
|
|
56
|
+
readonly phase: NamingPhase;
|
|
57
|
+
readonly entries: readonly SessionEntry[];
|
|
58
|
+
readonly pendingPrompt: string | undefined;
|
|
59
|
+
readonly ctx: ExtensionContext;
|
|
60
|
+
readonly signal: AbortSignal;
|
|
61
|
+
readonly repositoryContext: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type NamingCheckpoint =
|
|
65
|
+
| { readonly type: "prompt"; readonly prompt: string }
|
|
66
|
+
| { readonly type: "settled" };
|
|
67
|
+
|
|
68
|
+
type AuthenticationResolution =
|
|
69
|
+
| {
|
|
70
|
+
readonly type: "resolved";
|
|
71
|
+
|
|
72
|
+
readonly auth: Awaited<
|
|
73
|
+
ReturnType<ExtensionContext["modelRegistry"]["getApiKeyAndHeaders"]>
|
|
74
|
+
>;
|
|
75
|
+
}
|
|
76
|
+
| { readonly type: "cancelled" }
|
|
77
|
+
| { readonly type: "failed" };
|
|
78
|
+
|
|
79
|
+
type RestoredNamingState = {
|
|
80
|
+
readonly state: SessionNamingState;
|
|
81
|
+
readonly storedStateIssue: "invalid" | "unsupportedVersion" | undefined;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function resolvePickerModel(
|
|
85
|
+
reference: PickerModelReference,
|
|
86
|
+
ctx: ExtensionContext,
|
|
87
|
+
): Model<Api> | undefined {
|
|
88
|
+
if (reference.type === "current") {
|
|
89
|
+
if (ctx.model === undefined) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return ctx.modelRegistry.find(ctx.model.provider, ctx.model.id);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return ctx.modelRegistry.find(reference.provider, reference.id);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Inspect the latest stored naming state on the active branch. The newest
|
|
101
|
+
* matching entry owns recovery: invalid or future-version state is surfaced
|
|
102
|
+
* instead of silently reviving an older baseline.
|
|
103
|
+
*/
|
|
104
|
+
function maybeFindStoredNamingState(
|
|
105
|
+
entries: readonly SessionEntry[],
|
|
106
|
+
): StoredSessionNamingStateResult | { readonly type: "notFound" } {
|
|
107
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
108
|
+
const entry = entries[index];
|
|
109
|
+
if (entry?.type !== "custom" || entry.customType !== AUTONAME_STATE_ENTRY_TYPE) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return parseStoredSessionNamingState(entry.data);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { type: "notFound" };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function createZeroMetrics(): SessionMetrics {
|
|
120
|
+
return {
|
|
121
|
+
messages: 0,
|
|
122
|
+
turns: 0,
|
|
123
|
+
toolCalls: 0,
|
|
124
|
+
tokens: 0,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function timeoutDiagnostic(timeoutMs: number): string {
|
|
129
|
+
const seconds = timeoutMs / 1000;
|
|
130
|
+
return `Session naming timed out after ${seconds} second${seconds === 1 ? "" : "s"}.`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function restoreNamingState(
|
|
134
|
+
entries: readonly SessionEntry[],
|
|
135
|
+
currentName: string | undefined,
|
|
136
|
+
missingStateBaseline: "current" | "zero",
|
|
137
|
+
nowMs: number,
|
|
138
|
+
): RestoredNamingState {
|
|
139
|
+
const currentMetrics = measureSession(entries);
|
|
140
|
+
const stored = maybeFindStoredNamingState(entries);
|
|
141
|
+
if (stored.type === "found") {
|
|
142
|
+
if (stored.state.initialNameSet === (currentName !== undefined)) {
|
|
143
|
+
return { state: stored.state, storedStateIssue: undefined };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
state: createSessionNamingState({
|
|
148
|
+
initialNameSet: currentName !== undefined,
|
|
149
|
+
baseline: currentMetrics,
|
|
150
|
+
baselineAtMs: nowMs,
|
|
151
|
+
}),
|
|
152
|
+
storedStateIssue: undefined,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const baseline =
|
|
157
|
+
currentName !== undefined || missingStateBaseline === "current"
|
|
158
|
+
? currentMetrics
|
|
159
|
+
: createZeroMetrics();
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
state: createSessionNamingState({
|
|
163
|
+
initialNameSet: currentName !== undefined,
|
|
164
|
+
baseline,
|
|
165
|
+
baselineAtMs: nowMs,
|
|
166
|
+
}),
|
|
167
|
+
storedStateIssue:
|
|
168
|
+
stored.type === "invalid" || stored.type === "unsupportedVersion"
|
|
169
|
+
? stored.type
|
|
170
|
+
: undefined,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Wait for Pi's non-cancellable authentication lookup within the caller's
|
|
176
|
+
* cancellation lifetime. The late dependency promise remains observed so a
|
|
177
|
+
* post-cancellation rejection cannot become unhandled.
|
|
178
|
+
*/
|
|
179
|
+
async function resolveAuthentication(
|
|
180
|
+
ctx: ExtensionContext,
|
|
181
|
+
model: Model<Api>,
|
|
182
|
+
signal: AbortSignal,
|
|
183
|
+
): Promise<AuthenticationResolution> {
|
|
184
|
+
if (signal.aborted) {
|
|
185
|
+
return { type: "cancelled" };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return new Promise((resolve) => {
|
|
189
|
+
let completed = false;
|
|
190
|
+
const handleAbort = (): void => {
|
|
191
|
+
if (completed) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
completed = true;
|
|
196
|
+
resolve({ type: "cancelled" });
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const finish = (resolution: AuthenticationResolution): void => {
|
|
200
|
+
if (completed) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
completed = true;
|
|
205
|
+
signal.removeEventListener("abort", handleAbort);
|
|
206
|
+
resolve(resolution);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
signal.addEventListener("abort", handleAbort, { once: true });
|
|
210
|
+
|
|
211
|
+
let authenticationPromise: ReturnType<
|
|
212
|
+
ExtensionContext["modelRegistry"]["getApiKeyAndHeaders"]
|
|
213
|
+
>;
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
authenticationPromise = ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
217
|
+
} catch {
|
|
218
|
+
finish({ type: "failed" });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
void authenticationPromise.then(
|
|
223
|
+
(auth) => finish({ type: "resolved", auth }),
|
|
224
|
+
() => finish({ type: "failed" }),
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
interface ModelRuntimeComplete {
|
|
230
|
+
completeSimple(
|
|
231
|
+
model: Model<Api>,
|
|
232
|
+
context: {
|
|
233
|
+
messages: {
|
|
234
|
+
role: "user";
|
|
235
|
+
content: TextContent[];
|
|
236
|
+
timestamp: number;
|
|
237
|
+
}[];
|
|
238
|
+
},
|
|
239
|
+
options?: SimpleStreamOptions,
|
|
240
|
+
): Promise<AssistantMessage>;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function hasCompleteSimple(value: unknown): value is ModelRuntimeComplete {
|
|
244
|
+
return (
|
|
245
|
+
typeof value === "object" &&
|
|
246
|
+
value !== null &&
|
|
247
|
+
"completeSimple" in value &&
|
|
248
|
+
typeof value.completeSimple === "function"
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function completePickerModel(
|
|
253
|
+
ctx: ExtensionContext,
|
|
254
|
+
model: Model<Api>,
|
|
255
|
+
prompt: string,
|
|
256
|
+
streamOptions: SimpleStreamOptions,
|
|
257
|
+
): Promise<AssistantMessage> {
|
|
258
|
+
const context = {
|
|
259
|
+
messages: [
|
|
260
|
+
{
|
|
261
|
+
role: "user" as const,
|
|
262
|
+
content: [{ type: "text" as const, text: prompt }],
|
|
263
|
+
timestamp: Date.now(),
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
};
|
|
267
|
+
const registryObj: object = ctx.modelRegistry;
|
|
268
|
+
if ("runtime" in registryObj && hasCompleteSimple(registryObj.runtime)) {
|
|
269
|
+
return registryObj.runtime.completeSimple(model, context, streamOptions);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return completeSimple(model, context, streamOptions);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function pickSessionName(options: PickSessionNameOptions): Promise<PickSessionNameOutcome> {
|
|
276
|
+
const { settings, phase, entries, pendingPrompt, ctx, signal, repositoryContext } = options;
|
|
277
|
+
const timeoutSignal = AbortSignal.timeout(settings.timeoutMs);
|
|
278
|
+
const operationSignal = AbortSignal.any([signal, timeoutSignal]);
|
|
279
|
+
const model = resolvePickerModel(settings.model, ctx);
|
|
280
|
+
if (model === undefined) {
|
|
281
|
+
return {
|
|
282
|
+
type: "modelUnavailable",
|
|
283
|
+
diagnostic: `Session naming skipped: picker model "${formatPickerModelReference(settings.model)}" is not available.`,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const authentication = await resolveAuthentication(ctx, model, operationSignal);
|
|
288
|
+
if (authentication.type === "cancelled") {
|
|
289
|
+
return timeoutSignal.aborted
|
|
290
|
+
? { type: "timeout", diagnostic: timeoutDiagnostic(settings.timeoutMs) }
|
|
291
|
+
: { type: "cancelled" };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (authentication.type === "failed") {
|
|
295
|
+
return {
|
|
296
|
+
type: "authenticationUnavailable",
|
|
297
|
+
diagnostic:
|
|
298
|
+
"Session naming skipped because picker model authentication could not be resolved.",
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const { auth } = authentication;
|
|
303
|
+
|
|
304
|
+
if (operationSignal.aborted) {
|
|
305
|
+
return timeoutSignal.aborted
|
|
306
|
+
? { type: "timeout", diagnostic: timeoutDiagnostic(settings.timeoutMs) }
|
|
307
|
+
: { type: "cancelled" };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!auth.ok) {
|
|
311
|
+
return {
|
|
312
|
+
type: "authenticationUnavailable",
|
|
313
|
+
diagnostic: `Session naming skipped: picker model "${formatPickerModelReference(settings.model)}" has no available authentication.`,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// auth.ok may legitimately carry only headers, or no credentials at all
|
|
318
|
+
// (header-only and credential-free local providers); apiKey is optional
|
|
319
|
+
// in the model registry contract.
|
|
320
|
+
|
|
321
|
+
const prompt = renderNamingPrompt(
|
|
322
|
+
settings.prompt,
|
|
323
|
+
{
|
|
324
|
+
repositoryContext,
|
|
325
|
+
conversation: buildConversationContext(entries, {
|
|
326
|
+
phase,
|
|
327
|
+
scope: settings.conversationScope,
|
|
328
|
+
pendingPrompt,
|
|
329
|
+
}),
|
|
330
|
+
currentName: ctx.sessionManager.getSessionName() ?? "(unnamed)",
|
|
331
|
+
cwd: ctx.cwd,
|
|
332
|
+
reason: phase,
|
|
333
|
+
},
|
|
334
|
+
settings.nameConstraints.minLength,
|
|
335
|
+
settings.nameConstraints.maxLength,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
const streamOptions: SimpleStreamOptions = {};
|
|
340
|
+
if (auth.apiKey !== undefined) {
|
|
341
|
+
streamOptions.apiKey = auth.apiKey;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (auth.headers !== undefined) {
|
|
345
|
+
streamOptions.headers = auth.headers;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (auth.env !== undefined) {
|
|
349
|
+
streamOptions.env = auth.env;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (settings.reasoningEffort !== "off") {
|
|
353
|
+
streamOptions.reasoning = settings.reasoningEffort;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Request headers decide whether the picker model requires the
|
|
357
|
+
// Responses Lite payload shape, so the resolved auth headers must
|
|
358
|
+
// accompany every payload inspection.
|
|
359
|
+
streamOptions.onPayload = (payload) => preparePickerPayload(model, payload, auth.headers);
|
|
360
|
+
streamOptions.signal = operationSignal;
|
|
361
|
+
|
|
362
|
+
const response = await completePickerModel(ctx, model, prompt, streamOptions);
|
|
363
|
+
|
|
364
|
+
if (response.stopReason === "aborted") {
|
|
365
|
+
if (timeoutSignal.aborted) {
|
|
366
|
+
return { type: "timeout", diagnostic: timeoutDiagnostic(settings.timeoutMs) };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { type: "cancelled" };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (response.stopReason === "error") {
|
|
373
|
+
return {
|
|
374
|
+
type: "requestFailed",
|
|
375
|
+
diagnostic:
|
|
376
|
+
"Session naming failed because the picker model could not complete its request.",
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const rawName = response.content
|
|
381
|
+
.filter((block): block is TextContent => block.type === "text")
|
|
382
|
+
.map((block) => block.text)
|
|
383
|
+
.join("\n");
|
|
384
|
+
const name = normalizeSessionName(
|
|
385
|
+
rawName,
|
|
386
|
+
settings.nameConstraints.minLength,
|
|
387
|
+
settings.nameConstraints.maxLength,
|
|
388
|
+
);
|
|
389
|
+
if (name === undefined) {
|
|
390
|
+
return {
|
|
391
|
+
type: "invalidOutput",
|
|
392
|
+
diagnostic:
|
|
393
|
+
"Session naming skipped because the picker model returned an unusable name.",
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return { type: "picked", name };
|
|
398
|
+
} catch (cause: unknown) {
|
|
399
|
+
console.error("PICKER CAUGHT ERROR:", cause);
|
|
400
|
+
if (timeoutSignal.aborted) {
|
|
401
|
+
return { type: "timeout", diagnostic: timeoutDiagnostic(settings.timeoutMs) };
|
|
402
|
+
}
|
|
403
|
+
if (signal.aborted) {
|
|
404
|
+
return { type: "cancelled" };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
type: "requestFailed",
|
|
409
|
+
diagnostic:
|
|
410
|
+
"Session naming failed because the picker model request could not be completed.",
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Register the Pi Autoname Session Pi extension. */
|
|
416
|
+
export default function extension(pi: ExtensionAPI): void {
|
|
417
|
+
let settings: ExtensionSettings | undefined;
|
|
418
|
+
let namingState: SessionNamingState | undefined;
|
|
419
|
+
let sessionGeneration = 0;
|
|
420
|
+
let nameRevision = 0;
|
|
421
|
+
let sessionAbortController: AbortController | undefined;
|
|
422
|
+
let activeAttempt: ActiveNamingAttempt | undefined;
|
|
423
|
+
let pickerDiagnosticShown = false;
|
|
424
|
+
let storedStateDiagnosticShown = false;
|
|
425
|
+
let pendingAutoName: string | undefined;
|
|
426
|
+
let namingBlockedReason: "modelUnavailable" | undefined;
|
|
427
|
+
let lastFailedAttempt: { readonly metrics: SessionMetrics; readonly atMs: number } | undefined;
|
|
428
|
+
const backgroundNamingTasks = new Set<Promise<void>>();
|
|
429
|
+
|
|
430
|
+
const invalidateActiveAttempt = (): void => {
|
|
431
|
+
activeAttempt?.controller.abort();
|
|
432
|
+
activeAttempt = undefined;
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
pi.on("session_start", (_event, ctx) => {
|
|
436
|
+
sessionAbortController?.abort();
|
|
437
|
+
sessionAbortController = new AbortController();
|
|
438
|
+
invalidateActiveAttempt();
|
|
439
|
+
sessionGeneration += 1;
|
|
440
|
+
nameRevision = 0;
|
|
441
|
+
pickerDiagnosticShown = false;
|
|
442
|
+
storedStateDiagnosticShown = false;
|
|
443
|
+
pendingAutoName = undefined;
|
|
444
|
+
namingBlockedReason = undefined;
|
|
445
|
+
lastFailedAttempt = undefined;
|
|
446
|
+
|
|
447
|
+
const loaded = loadAutonameSessionSettings(ctx);
|
|
448
|
+
settings = loaded.settings;
|
|
449
|
+
|
|
450
|
+
if (ctx.hasUI) {
|
|
451
|
+
for (const diagnostic of loaded.diagnostics) {
|
|
452
|
+
ctx.ui.notify(diagnostic.message, diagnostic.severity);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const restored = restoreNamingState(
|
|
457
|
+
ctx.sessionManager.getBranch(),
|
|
458
|
+
ctx.sessionManager.getSessionName(),
|
|
459
|
+
"zero",
|
|
460
|
+
Date.now(),
|
|
461
|
+
);
|
|
462
|
+
namingState = restored.state;
|
|
463
|
+
|
|
464
|
+
if (restored.storedStateIssue !== undefined && ctx.hasUI) {
|
|
465
|
+
storedStateDiagnosticShown = true;
|
|
466
|
+
ctx.ui.notify(
|
|
467
|
+
restored.storedStateIssue === "unsupportedVersion"
|
|
468
|
+
? "Session naming state was written by an unsupported version and was ignored."
|
|
469
|
+
: "Invalid session naming state was ignored.",
|
|
470
|
+
"warning",
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
pi.on("session_info_changed", (event, ctx) => {
|
|
476
|
+
if (pendingAutoName !== undefined && event.name === pendingAutoName) {
|
|
477
|
+
pendingAutoName = undefined;
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
pendingAutoName = undefined;
|
|
482
|
+
|
|
483
|
+
// A user-initiated rename invalidates any in-flight naming attempt:
|
|
484
|
+
// the picked name was computed against the previous name state, and
|
|
485
|
+
// applying it would overwrite the user's change.
|
|
486
|
+
nameRevision += 1;
|
|
487
|
+
invalidateActiveAttempt();
|
|
488
|
+
|
|
489
|
+
const currentMetrics = measureSession(ctx.sessionManager.getBranch());
|
|
490
|
+
namingState = createSessionNamingState({
|
|
491
|
+
initialNameSet: event.name !== undefined,
|
|
492
|
+
baseline: currentMetrics,
|
|
493
|
+
baselineAtMs: Date.now(),
|
|
494
|
+
});
|
|
495
|
+
pi.appendEntry(AUTONAME_STATE_ENTRY_TYPE, namingState);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
499
|
+
// Branch navigation moves to a different conversation: invalidate any
|
|
500
|
+
// in-flight attempt (its name would apply to a branch it never
|
|
501
|
+
// inspected), then restore state from the newly active branch. A
|
|
502
|
+
// branch without usable state is rebased onto its current activity.
|
|
503
|
+
invalidateActiveAttempt();
|
|
504
|
+
lastFailedAttempt = undefined;
|
|
505
|
+
|
|
506
|
+
const restored = restoreNamingState(
|
|
507
|
+
ctx.sessionManager.getBranch(),
|
|
508
|
+
ctx.sessionManager.getSessionName(),
|
|
509
|
+
"current",
|
|
510
|
+
Date.now(),
|
|
511
|
+
);
|
|
512
|
+
namingState = restored.state;
|
|
513
|
+
|
|
514
|
+
if (restored.storedStateIssue !== undefined && !storedStateDiagnosticShown && ctx.hasUI) {
|
|
515
|
+
storedStateDiagnosticShown = true;
|
|
516
|
+
ctx.ui.notify(
|
|
517
|
+
restored.storedStateIssue === "unsupportedVersion"
|
|
518
|
+
? "Session naming state was written by an unsupported version and was ignored."
|
|
519
|
+
: "Invalid session naming state was ignored.",
|
|
520
|
+
"warning",
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
pi.appendEntry(AUTONAME_STATE_ENTRY_TYPE, namingState);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
pi.on("model_select", () => {
|
|
528
|
+
// A model change may make a previously unavailable picker model usable.
|
|
529
|
+
namingBlockedReason = undefined;
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
const maybeNameSession = async (
|
|
533
|
+
ctx: ExtensionContext,
|
|
534
|
+
checkpoint: NamingCheckpoint,
|
|
535
|
+
): Promise<void> => {
|
|
536
|
+
if (
|
|
537
|
+
settings === undefined ||
|
|
538
|
+
namingState === undefined ||
|
|
539
|
+
!settings.enabled ||
|
|
540
|
+
activeAttempt !== undefined
|
|
541
|
+
) {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (namingBlockedReason !== undefined) {
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const nowMs = Date.now();
|
|
550
|
+
const measuredMetrics = measureSession(ctx.sessionManager.getBranch());
|
|
551
|
+
const currentMetrics =
|
|
552
|
+
checkpoint.type === "prompt"
|
|
553
|
+
? { ...measuredMetrics, messages: measuredMetrics.messages + 1 }
|
|
554
|
+
: measuredMetrics;
|
|
555
|
+
const request = getNamingRequest(settings, namingState, currentMetrics, nowMs);
|
|
556
|
+
if (
|
|
557
|
+
request === undefined ||
|
|
558
|
+
sessionAbortController === undefined ||
|
|
559
|
+
(checkpoint.type === "prompt" &&
|
|
560
|
+
(request.phase !== "initial" || settings.initialNaming.timing !== "prompt"))
|
|
561
|
+
) {
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// After a failed attempt, retry only when the trigger is reached again
|
|
566
|
+
// relative to the failed attempt (fresh activity or elapsed minutes).
|
|
567
|
+
// This bounds repeated paid picker requests after persistent failures.
|
|
568
|
+
if (lastFailedAttempt !== undefined) {
|
|
569
|
+
const threshold =
|
|
570
|
+
request.phase === "initial"
|
|
571
|
+
? settings.initialNaming.threshold
|
|
572
|
+
: settings.refreshNaming.threshold;
|
|
573
|
+
if (
|
|
574
|
+
!hasReachedTrigger(
|
|
575
|
+
request.trigger,
|
|
576
|
+
threshold,
|
|
577
|
+
currentMetrics,
|
|
578
|
+
lastFailedAttempt.metrics,
|
|
579
|
+
lastFailedAttempt.atMs,
|
|
580
|
+
nowMs,
|
|
581
|
+
)
|
|
582
|
+
) {
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const sessionAbort = sessionAbortController;
|
|
588
|
+
const attempt: ActiveNamingAttempt = {
|
|
589
|
+
controller: new AbortController(),
|
|
590
|
+
sessionGeneration,
|
|
591
|
+
nameRevision,
|
|
592
|
+
nameAtStart: pi.getSessionName(),
|
|
593
|
+
leafIdAtStart: ctx.sessionManager.getLeafId(),
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
activeAttempt = attempt;
|
|
597
|
+
|
|
598
|
+
const entries = ctx.sessionManager.buildContextEntries();
|
|
599
|
+
const operationSignal = AbortSignal.any([sessionAbort.signal, attempt.controller.signal]);
|
|
600
|
+
let repositoryContext = buildRepositoryContext(ctx.cwd);
|
|
601
|
+
const visibleInitialPrompt =
|
|
602
|
+
checkpoint.type === "prompt"
|
|
603
|
+
? checkpoint.prompt
|
|
604
|
+
: buildConversationContext(entries, {
|
|
605
|
+
phase: "initial",
|
|
606
|
+
scope: "minimized",
|
|
607
|
+
}).replace(/^USER:\n/u, "");
|
|
608
|
+
if (request.phase === "initial" && isOpaqueNamingPrompt(visibleInitialPrompt)) {
|
|
609
|
+
try {
|
|
610
|
+
const gitStatus = await pi.exec(
|
|
611
|
+
"git",
|
|
612
|
+
["status", "--short", "--branch", "--untracked-files=normal"],
|
|
613
|
+
{ cwd: ctx.cwd, signal: operationSignal, timeout: 2_000 },
|
|
614
|
+
);
|
|
615
|
+
if (gitStatus.code === 0) {
|
|
616
|
+
repositoryContext = buildRepositoryContext(ctx.cwd, gitStatus.stdout);
|
|
617
|
+
}
|
|
618
|
+
} catch {
|
|
619
|
+
// Workspace metadata is optional. Naming continues with the
|
|
620
|
+
// repository identity when git is absent, slow, or cancelled.
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
try {
|
|
625
|
+
const outcome = await pickSessionName({
|
|
626
|
+
settings,
|
|
627
|
+
phase: request.phase,
|
|
628
|
+
entries,
|
|
629
|
+
pendingPrompt: checkpoint.type === "prompt" ? checkpoint.prompt : undefined,
|
|
630
|
+
ctx,
|
|
631
|
+
signal: operationSignal,
|
|
632
|
+
repositoryContext,
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
const branchStillContainsAttempt =
|
|
636
|
+
attempt.leafIdAtStart === null ||
|
|
637
|
+
ctx.sessionManager.getBranch().some((entry) => entry.id === attempt.leafIdAtStart);
|
|
638
|
+
const attemptIsCurrent =
|
|
639
|
+
activeAttempt === attempt &&
|
|
640
|
+
attempt.sessionGeneration === sessionGeneration &&
|
|
641
|
+
attempt.nameRevision === nameRevision &&
|
|
642
|
+
attempt.nameAtStart === pi.getSessionName() &&
|
|
643
|
+
branchStillContainsAttempt &&
|
|
644
|
+
!attempt.controller.signal.aborted &&
|
|
645
|
+
!sessionAbort.signal.aborted;
|
|
646
|
+
if (!attemptIsCurrent) {
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if ("diagnostic" in outcome && !pickerDiagnosticShown) {
|
|
651
|
+
pickerDiagnosticShown = true;
|
|
652
|
+
if (ctx.hasUI) {
|
|
653
|
+
ctx.ui.notify(outcome.diagnostic, "warning");
|
|
654
|
+
} else {
|
|
655
|
+
console.error("AUTONAME DIAGNOSTIC:", outcome.diagnostic);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (outcome.type === "picked") {
|
|
660
|
+
if (outcome.name !== pi.getSessionName()) {
|
|
661
|
+
pendingAutoName = outcome.name;
|
|
662
|
+
|
|
663
|
+
try {
|
|
664
|
+
pi.setSessionName(outcome.name);
|
|
665
|
+
} catch (cause: unknown) {
|
|
666
|
+
pendingAutoName = undefined;
|
|
667
|
+
throw cause;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
namingState = markSessionNamingComplete(currentMetrics, Date.now());
|
|
672
|
+
pi.appendEntry(AUTONAME_STATE_ENTRY_TYPE, namingState);
|
|
673
|
+
lastFailedAttempt = undefined;
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
switch (outcome.type) {
|
|
678
|
+
case "cancelled":
|
|
679
|
+
break;
|
|
680
|
+
case "modelUnavailable":
|
|
681
|
+
// Deterministic within a session: settings and the model
|
|
682
|
+
// registry did not change. Suppress further attempts until
|
|
683
|
+
// a model or settings change (model_select or session_start).
|
|
684
|
+
namingBlockedReason = outcome.type;
|
|
685
|
+
lastFailedAttempt = undefined;
|
|
686
|
+
break;
|
|
687
|
+
case "authenticationUnavailable":
|
|
688
|
+
case "requestFailed":
|
|
689
|
+
case "timeout":
|
|
690
|
+
case "invalidOutput":
|
|
691
|
+
lastFailedAttempt = { metrics: currentMetrics, atMs: Date.now() };
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
} finally {
|
|
695
|
+
if (activeAttempt === attempt) {
|
|
696
|
+
activeAttempt = undefined;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const startBackgroundNaming = (ctx: ExtensionContext, checkpoint: NamingCheckpoint): void => {
|
|
702
|
+
const taskGeneration = sessionGeneration;
|
|
703
|
+
const task = maybeNameSession(ctx, checkpoint);
|
|
704
|
+
backgroundNamingTasks.add(task);
|
|
705
|
+
void task
|
|
706
|
+
.catch(() => {
|
|
707
|
+
if (
|
|
708
|
+
taskGeneration === sessionGeneration &&
|
|
709
|
+
sessionAbortController?.signal.aborted === false &&
|
|
710
|
+
!pickerDiagnosticShown &&
|
|
711
|
+
ctx.hasUI
|
|
712
|
+
) {
|
|
713
|
+
pickerDiagnosticShown = true;
|
|
714
|
+
ctx.ui.notify("Session naming failed unexpectedly.", "warning");
|
|
715
|
+
}
|
|
716
|
+
})
|
|
717
|
+
.finally(() => {
|
|
718
|
+
backgroundNamingTasks.delete(task);
|
|
719
|
+
});
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
723
|
+
const imageSummary =
|
|
724
|
+
event.images === undefined || event.images.length === 0
|
|
725
|
+
? ""
|
|
726
|
+
: `\n[${event.images.length} image${event.images.length === 1 ? "" : "s"} attached]`;
|
|
727
|
+
|
|
728
|
+
startBackgroundNaming(ctx, {
|
|
729
|
+
type: "prompt",
|
|
730
|
+
prompt: event.prompt + imageSummary,
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
735
|
+
await maybeNameSession(ctx, { type: "settled" });
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
pi.on("session_shutdown", async () => {
|
|
739
|
+
sessionAbortController?.abort();
|
|
740
|
+
sessionAbortController = undefined;
|
|
741
|
+
invalidateActiveAttempt();
|
|
742
|
+
namingState = undefined;
|
|
743
|
+
settings = undefined;
|
|
744
|
+
sessionGeneration += 1;
|
|
745
|
+
pendingAutoName = undefined;
|
|
746
|
+
namingBlockedReason = undefined;
|
|
747
|
+
lastFailedAttempt = undefined;
|
|
748
|
+
await Promise.allSettled(backgroundNamingTasks);
|
|
749
|
+
backgroundNamingTasks.clear();
|
|
750
|
+
});
|
|
751
|
+
}
|