pi-sessions 0.1.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/LICENSE +21 -0
- package/README.md +173 -0
- package/extensions/session-ask.ts +297 -0
- package/extensions/session-auto-title/command.ts +109 -0
- package/extensions/session-auto-title/context.ts +41 -0
- package/extensions/session-auto-title/controller.ts +298 -0
- package/extensions/session-auto-title/model.ts +61 -0
- package/extensions/session-auto-title/prompt.ts +54 -0
- package/extensions/session-auto-title/retitle.ts +641 -0
- package/extensions/session-auto-title/state.ts +69 -0
- package/extensions/session-auto-title/wizard.ts +583 -0
- package/extensions/session-auto-title.ts +209 -0
- package/extensions/session-handoff/extract.ts +278 -0
- package/extensions/session-handoff/metadata.ts +96 -0
- package/extensions/session-handoff/picker.ts +394 -0
- package/extensions/session-handoff/query.ts +318 -0
- package/extensions/session-handoff/refs.ts +151 -0
- package/extensions/session-handoff/review.ts +268 -0
- package/extensions/session-handoff.ts +203 -0
- package/extensions/session-hooks.ts +55 -0
- package/extensions/session-index.ts +159 -0
- package/extensions/session-search/extract.ts +997 -0
- package/extensions/session-search/hooks.ts +350 -0
- package/extensions/session-search/normalize.ts +170 -0
- package/extensions/session-search/reindex.ts +93 -0
- package/extensions/session-search.ts +390 -0
- package/extensions/shared/search-snippet.ts +40 -0
- package/extensions/shared/session-index/common.ts +222 -0
- package/extensions/shared/session-index/index.ts +5 -0
- package/extensions/shared/session-index/lineage.ts +417 -0
- package/extensions/shared/session-index/schema.ts +178 -0
- package/extensions/shared/session-index/search.ts +688 -0
- package/extensions/shared/session-index/store.ts +173 -0
- package/extensions/shared/session-ui.ts +15 -0
- package/extensions/shared/settings.ts +141 -0
- package/extensions/shared/time.ts +38 -0
- package/extensions/shared/typebox.ts +61 -0
- package/images/handoff.png +0 -0
- package/images/session-title.png +0 -0
- package/images/session_ask.png +0 -0
- package/images/session_picker.png +0 -0
- package/images/session_search.png +0 -0
- package/package.json +64 -0
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Api,
|
|
3
|
+
completeSimple,
|
|
4
|
+
type Model,
|
|
5
|
+
type TextContent,
|
|
6
|
+
type UserMessage,
|
|
7
|
+
} from "@mariozechner/pi-ai";
|
|
8
|
+
import type {
|
|
9
|
+
ExtensionAPI,
|
|
10
|
+
ExtensionCommandContext,
|
|
11
|
+
ExtensionContext,
|
|
12
|
+
SessionInfo,
|
|
13
|
+
} from "@mariozechner/pi-coding-agent";
|
|
14
|
+
import { SessionManager } from "@mariozechner/pi-coding-agent";
|
|
15
|
+
import type { RetitleMode, RetitleScope } from "./command.js";
|
|
16
|
+
import { type AutoTitleContext, buildAutoTitleContext } from "./context.js";
|
|
17
|
+
import type {
|
|
18
|
+
AutoTitleFailure,
|
|
19
|
+
AutoTitleTriggerPlan,
|
|
20
|
+
SessionAutoTitleController,
|
|
21
|
+
} from "./controller.js";
|
|
22
|
+
import {
|
|
23
|
+
AUTO_TITLE_SYSTEM_PROMPT,
|
|
24
|
+
buildAutoTitlePrompt,
|
|
25
|
+
normalizeGeneratedAutoTitle,
|
|
26
|
+
} from "./prompt.js";
|
|
27
|
+
import {
|
|
28
|
+
AUTO_TITLE_STATE_CUSTOM_TYPE,
|
|
29
|
+
type AutoTitlePersistedState,
|
|
30
|
+
type AutoTitleTrigger,
|
|
31
|
+
createAutoTitleState,
|
|
32
|
+
} from "./state.js";
|
|
33
|
+
|
|
34
|
+
const AUTO_TITLE_REQUEST_TIMEOUT_MS = 15_000;
|
|
35
|
+
const AUTO_TITLE_MAX_TOKENS = 64;
|
|
36
|
+
|
|
37
|
+
export interface RetitleScopeScan {
|
|
38
|
+
scope: Exclude<RetitleScope, "this">;
|
|
39
|
+
sessions: SessionInfo[];
|
|
40
|
+
totalCount: number;
|
|
41
|
+
untitledCount: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface BulkRetitleResult {
|
|
45
|
+
attempted: number;
|
|
46
|
+
retitled: number;
|
|
47
|
+
unchanged: number;
|
|
48
|
+
failed: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function buildRetitleScopeScan(
|
|
52
|
+
ctx: ExtensionCommandContext,
|
|
53
|
+
scope: Exclude<RetitleScope, "this">,
|
|
54
|
+
): Promise<RetitleScopeScan> {
|
|
55
|
+
const sessions =
|
|
56
|
+
scope === "folder" ? await SessionManager.list(ctx.cwd) : await SessionManager.listAll();
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
scope,
|
|
60
|
+
sessions,
|
|
61
|
+
totalCount: sessions.length,
|
|
62
|
+
untitledCount: sessions.filter((s) => !hasSessionTitle(s.name)).length,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function runBulkRetitle(
|
|
67
|
+
pi: ExtensionAPI,
|
|
68
|
+
controller: SessionAutoTitleController,
|
|
69
|
+
ctx: ExtensionCommandContext,
|
|
70
|
+
model: Model<Api> | undefined,
|
|
71
|
+
scan: RetitleScopeScan,
|
|
72
|
+
mode: RetitleMode,
|
|
73
|
+
getSessionEpoch: () => number,
|
|
74
|
+
): Promise<BulkRetitleResult> {
|
|
75
|
+
const result: BulkRetitleResult = {
|
|
76
|
+
attempted: 0,
|
|
77
|
+
retitled: 0,
|
|
78
|
+
unchanged: 0,
|
|
79
|
+
failed: 0,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
if (!model) {
|
|
83
|
+
result.failed = getEligibleSessions(scan.sessions, mode).length;
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
|
88
|
+
const eligibleSessions = getEligibleSessions(scan.sessions, mode);
|
|
89
|
+
|
|
90
|
+
for (const session of eligibleSessions) {
|
|
91
|
+
result.attempted += 1;
|
|
92
|
+
|
|
93
|
+
if (currentSessionFile && session.path === currentSessionFile) {
|
|
94
|
+
const didRetitle = await runRetitlePlan({
|
|
95
|
+
pi,
|
|
96
|
+
controller,
|
|
97
|
+
ctx,
|
|
98
|
+
model,
|
|
99
|
+
isManual: true,
|
|
100
|
+
getSessionEpoch,
|
|
101
|
+
notifyOnSuccess: false,
|
|
102
|
+
});
|
|
103
|
+
result[didRetitle.ok ? "retitled" : "failed"] += 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const outcome = await retitleStoredSession(ctx, model, session.path);
|
|
108
|
+
result[outcome] += 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function notifyBulkRetitleResult(
|
|
115
|
+
ctx: ExtensionCommandContext,
|
|
116
|
+
scan: RetitleScopeScan,
|
|
117
|
+
mode: RetitleMode,
|
|
118
|
+
result: BulkRetitleResult,
|
|
119
|
+
): void {
|
|
120
|
+
if (result.attempted === 0) {
|
|
121
|
+
const message =
|
|
122
|
+
mode === "backfill"
|
|
123
|
+
? `No untitled sessions found ${formatScopeLocation(scan.scope)}.`
|
|
124
|
+
: `No sessions found ${formatScopeLocation(scan.scope)}.`;
|
|
125
|
+
ctx.ui.notify(message, "info");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const parts = [
|
|
130
|
+
`Retitled ${result.retitled}/${result.attempted} sessions ${formatScopeLocation(scan.scope)}`,
|
|
131
|
+
];
|
|
132
|
+
if (result.unchanged > 0) {
|
|
133
|
+
parts.push(`${result.unchanged} unchanged`);
|
|
134
|
+
}
|
|
135
|
+
if (result.failed > 0) {
|
|
136
|
+
parts.push(`${result.failed} failed`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
ctx.ui.notify(parts.join(" · "), result.failed > 0 ? "warning" : "info");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function getEligibleSessions(sessions: SessionInfo[], mode: RetitleMode): SessionInfo[] {
|
|
143
|
+
if (mode === "all") {
|
|
144
|
+
return sessions;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return sessions.filter((session) => !hasSessionTitle(session.name));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function buildScopeScanMessage(scope: Exclude<RetitleScope, "this">): string {
|
|
151
|
+
return scope === "folder" ? "Scanning sessions in this folder..." : "Scanning all Pi sessions...";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function buildBulkRetitleMessage(
|
|
155
|
+
scope: Exclude<RetitleScope, "this">,
|
|
156
|
+
mode: RetitleMode,
|
|
157
|
+
): string {
|
|
158
|
+
if (scope === "folder") {
|
|
159
|
+
return mode === "all"
|
|
160
|
+
? "Retitling all sessions in this folder..."
|
|
161
|
+
: "Backfilling untitled sessions in this folder...";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return mode === "all"
|
|
165
|
+
? "Retitling all sessions across Pi..."
|
|
166
|
+
: "Backfilling untitled sessions across Pi...";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function formatScopeLocation(scope: Exclude<RetitleScope, "this">): string {
|
|
170
|
+
return scope === "folder" ? "in this folder" : "across all of Pi";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface RetitlePlanOptions {
|
|
174
|
+
pi: ExtensionAPI;
|
|
175
|
+
controller: SessionAutoTitleController;
|
|
176
|
+
ctx: ExtensionContext;
|
|
177
|
+
model: Model<Api> | undefined;
|
|
178
|
+
isManual: boolean;
|
|
179
|
+
existingPlan?: AutoTitleTriggerPlan;
|
|
180
|
+
getSessionEpoch?: () => number;
|
|
181
|
+
notifyOnSuccess?: boolean;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export type RetitlePlanResult =
|
|
185
|
+
| {
|
|
186
|
+
ok: true;
|
|
187
|
+
title: string;
|
|
188
|
+
}
|
|
189
|
+
| {
|
|
190
|
+
ok: false;
|
|
191
|
+
failure: AutoTitleFailure;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export async function runRetitlePlan(options: RetitlePlanOptions): Promise<RetitlePlanResult> {
|
|
195
|
+
const { pi, controller, ctx, model, isManual, existingPlan, getSessionEpoch } = options;
|
|
196
|
+
const notifyOnSuccess = options.notifyOnSuccess ?? isManual;
|
|
197
|
+
|
|
198
|
+
const plan = existingPlan ?? controller.handleManualRetitle(ctx);
|
|
199
|
+
if (!plan) {
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
failure: createAutoTitleFailure("manual", model, "No retitle plan available."),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!model) {
|
|
207
|
+
return {
|
|
208
|
+
ok: false,
|
|
209
|
+
failure: createAutoTitleFailure(
|
|
210
|
+
plan.reason,
|
|
211
|
+
model,
|
|
212
|
+
"No model available for auto-title generation.",
|
|
213
|
+
),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const currentEpoch = getSessionEpoch?.();
|
|
218
|
+
const generatedTitle = await generateAutoTitle(ctx, plan, model);
|
|
219
|
+
if (!generatedTitle.ok) {
|
|
220
|
+
return generatedTitle;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (currentEpoch !== undefined && currentEpoch !== getSessionEpoch?.()) {
|
|
224
|
+
return {
|
|
225
|
+
ok: false,
|
|
226
|
+
failure: createAutoTitleFailure(
|
|
227
|
+
plan.reason,
|
|
228
|
+
model,
|
|
229
|
+
"Session changed while generating a title.",
|
|
230
|
+
),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (ctx.sessionManager.getSessionName() !== generatedTitle.title) {
|
|
235
|
+
pi.setSessionName(generatedTitle.title);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const persistedState = controller.handleTitleApplied(generatedTitle.title, plan);
|
|
239
|
+
persistAutoTitleState(pi, persistedState);
|
|
240
|
+
|
|
241
|
+
if (notifyOnSuccess && isManual && ctx.hasUI) {
|
|
242
|
+
ctx.ui.notify(`Retitled session: ${generatedTitle.title}`, "info");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return generatedTitle;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function persistAutoTitleState(
|
|
249
|
+
pi: ExtensionAPI,
|
|
250
|
+
state: AutoTitlePersistedState | undefined,
|
|
251
|
+
): void {
|
|
252
|
+
if (!state) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
pi.appendEntry(AUTO_TITLE_STATE_CUSTOM_TYPE, state);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function retitleStoredSession(
|
|
260
|
+
ctx: ExtensionContext,
|
|
261
|
+
model: Model<Api>,
|
|
262
|
+
sessionPath: string,
|
|
263
|
+
): Promise<"retitled" | "unchanged" | "failed"> {
|
|
264
|
+
const sessionManager = SessionManager.open(sessionPath);
|
|
265
|
+
const currentTitle = sessionManager.getSessionName();
|
|
266
|
+
const titleContext = buildAutoTitleContext(
|
|
267
|
+
sessionManager.getEntries(),
|
|
268
|
+
sessionManager.getLeafId(),
|
|
269
|
+
{
|
|
270
|
+
cwd: sessionManager.getCwd(),
|
|
271
|
+
currentTitle,
|
|
272
|
+
},
|
|
273
|
+
);
|
|
274
|
+
const plan: AutoTitleTriggerPlan = {
|
|
275
|
+
reason: "manual",
|
|
276
|
+
userTurnCount: titleContext.userTurnCount,
|
|
277
|
+
currentTitle,
|
|
278
|
+
};
|
|
279
|
+
const generatedTitle = await generateAutoTitleFromContext(ctx, plan, model, titleContext);
|
|
280
|
+
if (!generatedTitle.ok) {
|
|
281
|
+
return "failed";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (currentTitle !== generatedTitle.title) {
|
|
285
|
+
sessionManager.appendSessionInfo(generatedTitle.title);
|
|
286
|
+
}
|
|
287
|
+
sessionManager.appendCustomEntry(
|
|
288
|
+
AUTO_TITLE_STATE_CUSTOM_TYPE,
|
|
289
|
+
createAutoTitleState({
|
|
290
|
+
mode: "active",
|
|
291
|
+
lastAutoTitle: generatedTitle.title,
|
|
292
|
+
lastAppliedUserTurnCount: plan.userTurnCount,
|
|
293
|
+
lastTrigger: plan.reason,
|
|
294
|
+
}),
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
return currentTitle === generatedTitle.title ? "unchanged" : "retitled";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function hasSessionTitle(name: string | undefined): boolean {
|
|
301
|
+
return Boolean(name?.trim());
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function generateAutoTitle(
|
|
305
|
+
ctx: ExtensionContext,
|
|
306
|
+
plan: AutoTitleTriggerPlan,
|
|
307
|
+
model: Model<Api>,
|
|
308
|
+
): Promise<RetitlePlanResult> {
|
|
309
|
+
const titleContext = buildAutoTitleContext(
|
|
310
|
+
ctx.sessionManager.getEntries(),
|
|
311
|
+
ctx.sessionManager.getLeafId(),
|
|
312
|
+
{
|
|
313
|
+
cwd: ctx.cwd,
|
|
314
|
+
currentTitle: plan.currentTitle,
|
|
315
|
+
},
|
|
316
|
+
);
|
|
317
|
+
return generateAutoTitleFromContext(ctx, plan, model, titleContext);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function generateAutoTitleFromContext(
|
|
321
|
+
ctx: ExtensionContext,
|
|
322
|
+
plan: AutoTitleTriggerPlan,
|
|
323
|
+
model: Model<Api>,
|
|
324
|
+
titleContext: AutoTitleContext,
|
|
325
|
+
): Promise<RetitlePlanResult> {
|
|
326
|
+
if (!titleContext.conversationText) {
|
|
327
|
+
return {
|
|
328
|
+
ok: false,
|
|
329
|
+
failure: createAutoTitleFailure(
|
|
330
|
+
plan.reason,
|
|
331
|
+
model,
|
|
332
|
+
"No conversation available for auto-title generation.",
|
|
333
|
+
),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
338
|
+
if (!auth.ok) {
|
|
339
|
+
return {
|
|
340
|
+
ok: false,
|
|
341
|
+
failure: createAutoTitleFailure(
|
|
342
|
+
plan.reason,
|
|
343
|
+
model,
|
|
344
|
+
"Failed to authenticate auto-title model.",
|
|
345
|
+
),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const message: UserMessage = {
|
|
350
|
+
role: "user",
|
|
351
|
+
content: [{ type: "text", text: buildAutoTitlePrompt(titleContext, plan.reason) }],
|
|
352
|
+
timestamp: Date.now(),
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const abortController = new AbortController();
|
|
356
|
+
const timeoutId = setTimeout(() => abortController.abort(), AUTO_TITLE_REQUEST_TIMEOUT_MS);
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
const response = await completeSimple(
|
|
360
|
+
model,
|
|
361
|
+
{
|
|
362
|
+
systemPrompt: AUTO_TITLE_SYSTEM_PROMPT,
|
|
363
|
+
messages: [message],
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
...(auth.apiKey && { apiKey: auth.apiKey }),
|
|
367
|
+
...(auth.headers && { headers: auth.headers }),
|
|
368
|
+
maxTokens: AUTO_TITLE_MAX_TOKENS,
|
|
369
|
+
signal: abortController.signal,
|
|
370
|
+
},
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
if (response.stopReason === "error" || response.stopReason === "aborted") {
|
|
374
|
+
const fallbackMessage =
|
|
375
|
+
response.stopReason === "aborted" ? "Request was aborted." : "Provider returned an error.";
|
|
376
|
+
const failureDetails = extractFailureDetails(
|
|
377
|
+
response.errorMessage || fallbackMessage,
|
|
378
|
+
response,
|
|
379
|
+
);
|
|
380
|
+
return {
|
|
381
|
+
ok: false,
|
|
382
|
+
failure: createAutoTitleFailure(
|
|
383
|
+
plan.reason,
|
|
384
|
+
model,
|
|
385
|
+
failureDetails.message,
|
|
386
|
+
failureDetails.status,
|
|
387
|
+
),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const responseText = response.content
|
|
392
|
+
.filter((part): part is TextContent => part.type === "text")
|
|
393
|
+
.map((part) => part.text)
|
|
394
|
+
.join("\n");
|
|
395
|
+
const normalizedTitle = normalizeGeneratedAutoTitle(responseText);
|
|
396
|
+
if (!normalizedTitle) {
|
|
397
|
+
return {
|
|
398
|
+
ok: false,
|
|
399
|
+
failure: createAutoTitleFailure(plan.reason, model, "Model returned an empty title."),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
ok: true,
|
|
405
|
+
title: normalizedTitle,
|
|
406
|
+
};
|
|
407
|
+
} catch (error) {
|
|
408
|
+
const failureDetails = extractFailureDetails(error);
|
|
409
|
+
return {
|
|
410
|
+
ok: false,
|
|
411
|
+
failure: createAutoTitleFailure(
|
|
412
|
+
plan.reason,
|
|
413
|
+
model,
|
|
414
|
+
failureDetails.message,
|
|
415
|
+
failureDetails.status,
|
|
416
|
+
),
|
|
417
|
+
};
|
|
418
|
+
} finally {
|
|
419
|
+
clearTimeout(timeoutId);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function createAutoTitleFailure(
|
|
424
|
+
trigger: AutoTitleTrigger,
|
|
425
|
+
model: Model<Api> | undefined,
|
|
426
|
+
message: string,
|
|
427
|
+
status?: number,
|
|
428
|
+
): AutoTitleFailure {
|
|
429
|
+
return {
|
|
430
|
+
at: new Date().toISOString(),
|
|
431
|
+
trigger,
|
|
432
|
+
model: formatModelLabel(model),
|
|
433
|
+
message,
|
|
434
|
+
...(status !== undefined ? { status } : {}),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function formatModelLabel(model: Model<Api> | undefined): string {
|
|
439
|
+
if (!model) {
|
|
440
|
+
return "(no model resolved)";
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return `${model.provider}/${model.id}`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function extractFailureDetails(
|
|
447
|
+
primary: unknown,
|
|
448
|
+
secondary?: unknown,
|
|
449
|
+
): { message: string; status?: number } {
|
|
450
|
+
const structured =
|
|
451
|
+
parseStructuredProviderError(primary) ??
|
|
452
|
+
parseStructuredProviderError(secondary) ??
|
|
453
|
+
parseEmbeddedErrorFromObject(primary);
|
|
454
|
+
if (structured) {
|
|
455
|
+
return structured;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const message =
|
|
459
|
+
extractStringMessage(primary) || extractStringMessage(secondary) || "Unknown provider error.";
|
|
460
|
+
const status = extractStatus(primary) ?? extractStatus(secondary);
|
|
461
|
+
return {
|
|
462
|
+
message,
|
|
463
|
+
...(status !== undefined ? { status } : {}),
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function parseEmbeddedErrorFromObject(
|
|
468
|
+
value: unknown,
|
|
469
|
+
): { message: string; status?: number } | undefined {
|
|
470
|
+
if (!isObject(value) && !(value instanceof Error)) {
|
|
471
|
+
return undefined;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const embedded = extractStringMessage(value);
|
|
475
|
+
return embedded ? parseStructuredProviderError(embedded) : undefined;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function parseStructuredProviderError(
|
|
479
|
+
value: unknown,
|
|
480
|
+
): { message: string; status?: number } | undefined {
|
|
481
|
+
const json = parseJsonObjectCandidate(value);
|
|
482
|
+
if (!json) {
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const topError = isObject(json.error) ? json.error : undefined;
|
|
487
|
+
const status =
|
|
488
|
+
readNumericStatus(json) ??
|
|
489
|
+
readNumericStatus(topError) ??
|
|
490
|
+
(typeof topError?.code === "number" ? topError.code : undefined);
|
|
491
|
+
const rawMessage =
|
|
492
|
+
readString(topError?.message) ?? readString(json.message) ?? readString(json.errorMessage);
|
|
493
|
+
if (!rawMessage) {
|
|
494
|
+
return undefined;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const labels = collectProviderErrorLabels(json, topError);
|
|
498
|
+
return {
|
|
499
|
+
message: formatStructuredErrorMessage(labels, rawMessage),
|
|
500
|
+
...(status !== undefined ? { status } : {}),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function collectProviderErrorLabels(
|
|
505
|
+
root: Record<string, unknown>,
|
|
506
|
+
nestedError: Record<string, unknown> | undefined,
|
|
507
|
+
): string[] {
|
|
508
|
+
const labels: string[] = [];
|
|
509
|
+
const candidates = [
|
|
510
|
+
readString(nestedError?.status),
|
|
511
|
+
readString(nestedError?.type),
|
|
512
|
+
readString(nestedError?.code),
|
|
513
|
+
readString(root.status),
|
|
514
|
+
readString(root.type),
|
|
515
|
+
readString(root.code),
|
|
516
|
+
];
|
|
517
|
+
|
|
518
|
+
for (const candidate of candidates) {
|
|
519
|
+
if (!candidate) {
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const normalized = candidate.trim();
|
|
524
|
+
if (!normalized || normalized === "error") {
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (!labels.includes(normalized)) {
|
|
529
|
+
labels.push(normalized);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return labels;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function formatStructuredErrorMessage(labels: string[], rawMessage: string): string {
|
|
537
|
+
const cleanedMessage = stripRedundantErrorPrefix(rawMessage);
|
|
538
|
+
return labels.length > 0 ? `${labels.join(" · ")} · ${cleanedMessage}` : cleanedMessage;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function stripRedundantErrorPrefix(message: string): string {
|
|
542
|
+
return message
|
|
543
|
+
.replace(/^Unauthorized:\s*/i, "")
|
|
544
|
+
.replace(/^Authentication (?:error|failed):\s*/i, "")
|
|
545
|
+
.trim();
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function parseJsonObjectCandidate(value: unknown): Record<string, unknown> | undefined {
|
|
549
|
+
if (isObject(value)) {
|
|
550
|
+
return value;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const text = extractStringMessage(value);
|
|
554
|
+
if (!text) {
|
|
555
|
+
return undefined;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const startIndex = text.indexOf("{");
|
|
559
|
+
const endIndex = text.lastIndexOf("}");
|
|
560
|
+
if (startIndex < 0 || endIndex <= startIndex) {
|
|
561
|
+
return undefined;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
try {
|
|
565
|
+
const parsed = JSON.parse(text.slice(startIndex, endIndex + 1));
|
|
566
|
+
return isObject(parsed) ? parsed : undefined;
|
|
567
|
+
} catch {
|
|
568
|
+
return undefined;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function extractStringMessage(value: unknown): string | undefined {
|
|
573
|
+
if (typeof value === "string" && value.trim()) {
|
|
574
|
+
return value.trim();
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (value instanceof Error && value.message.trim()) {
|
|
578
|
+
return value.message.trim();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (!isObject(value)) {
|
|
582
|
+
return undefined;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const directMessage = readString(value.message) ?? readString(value.errorMessage);
|
|
586
|
+
if (directMessage) {
|
|
587
|
+
return directMessage;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return undefined;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function extractStatus(error: unknown): number | undefined {
|
|
594
|
+
const candidates = [
|
|
595
|
+
error,
|
|
596
|
+
isObject(error) ? error.error : undefined,
|
|
597
|
+
isObject(error) ? error.response : undefined,
|
|
598
|
+
isObject(error) && isObject(error.error) ? error.error.response : undefined,
|
|
599
|
+
parseJsonObjectCandidate(error),
|
|
600
|
+
];
|
|
601
|
+
|
|
602
|
+
for (const candidate of candidates) {
|
|
603
|
+
const status = readNumericStatus(candidate);
|
|
604
|
+
if (status !== undefined) {
|
|
605
|
+
return status;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return undefined;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function readNumericStatus(value: unknown): number | undefined {
|
|
613
|
+
if (!isObject(value)) {
|
|
614
|
+
return undefined;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const directStatus = value.status;
|
|
618
|
+
if (typeof directStatus === "number") {
|
|
619
|
+
return directStatus;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const statusCode = value.statusCode;
|
|
623
|
+
if (typeof statusCode === "number") {
|
|
624
|
+
return statusCode;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const code = value.code;
|
|
628
|
+
if (typeof code === "number") {
|
|
629
|
+
return code;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return undefined;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function readString(value: unknown): string | undefined {
|
|
636
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
640
|
+
return typeof value === "object" && value !== null;
|
|
641
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { SessionEntry } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
3
|
+
import { safeParseTypeBoxValue } from "../shared/typebox.js";
|
|
4
|
+
|
|
5
|
+
export const AUTO_TITLE_STATE_CUSTOM_TYPE = "pi-sessions.auto-title";
|
|
6
|
+
export const AUTO_TITLE_STATE_VERSION = 1;
|
|
7
|
+
|
|
8
|
+
export const AUTO_TITLE_MODE_SCHEMA = Type.Union([
|
|
9
|
+
Type.Literal("active"),
|
|
10
|
+
Type.Literal("paused_manual"),
|
|
11
|
+
]);
|
|
12
|
+
export const AUTO_TITLE_TRIGGER_SCHEMA = Type.Union([
|
|
13
|
+
Type.Literal("initial"),
|
|
14
|
+
Type.Literal("periodic"),
|
|
15
|
+
Type.Literal("manual"),
|
|
16
|
+
]);
|
|
17
|
+
export const AUTO_TITLE_STATE_SCHEMA = Type.Object({
|
|
18
|
+
version: Type.Literal(AUTO_TITLE_STATE_VERSION),
|
|
19
|
+
mode: AUTO_TITLE_MODE_SCHEMA,
|
|
20
|
+
lastAutoTitle: Type.Optional(Type.String()),
|
|
21
|
+
lastAppliedUserTurnCount: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
22
|
+
lastTrigger: Type.Optional(AUTO_TITLE_TRIGGER_SCHEMA),
|
|
23
|
+
updatedAt: Type.String(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export type AutoTitleMode = Static<typeof AUTO_TITLE_MODE_SCHEMA>;
|
|
27
|
+
export type AutoTitleTrigger = Static<typeof AUTO_TITLE_TRIGGER_SCHEMA>;
|
|
28
|
+
export type AutoTitlePersistedState = Static<typeof AUTO_TITLE_STATE_SCHEMA>;
|
|
29
|
+
|
|
30
|
+
export function createAutoTitleState(options?: {
|
|
31
|
+
mode?: AutoTitleMode;
|
|
32
|
+
lastAutoTitle?: string;
|
|
33
|
+
lastAppliedUserTurnCount?: number;
|
|
34
|
+
lastTrigger?: AutoTitleTrigger;
|
|
35
|
+
updatedAt?: string;
|
|
36
|
+
}): AutoTitlePersistedState {
|
|
37
|
+
return {
|
|
38
|
+
version: AUTO_TITLE_STATE_VERSION,
|
|
39
|
+
mode: options?.mode ?? "active",
|
|
40
|
+
...(options?.lastAutoTitle ? { lastAutoTitle: options.lastAutoTitle } : {}),
|
|
41
|
+
...(options?.lastAppliedUserTurnCount
|
|
42
|
+
? { lastAppliedUserTurnCount: options.lastAppliedUserTurnCount }
|
|
43
|
+
: {}),
|
|
44
|
+
...(options?.lastTrigger ? { lastTrigger: options.lastTrigger } : {}),
|
|
45
|
+
updatedAt: options?.updatedAt ?? new Date().toISOString(),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseAutoTitleState(value: unknown): AutoTitlePersistedState | undefined {
|
|
50
|
+
return safeParseTypeBoxValue(AUTO_TITLE_STATE_SCHEMA, value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getLatestAutoTitleState(
|
|
54
|
+
entries: SessionEntry[],
|
|
55
|
+
): AutoTitlePersistedState | undefined {
|
|
56
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
57
|
+
const entry = entries[index];
|
|
58
|
+
if (entry?.type !== "custom" || entry.customType !== AUTO_TITLE_STATE_CUSTOM_TYPE) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const parsed = parseAutoTitleState(entry.data);
|
|
63
|
+
if (parsed) {
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|