pi-sessions 0.2.2 → 0.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.
@@ -1,10 +1,4 @@
1
- import {
2
- type Api,
3
- completeSimple,
4
- type Model,
5
- type TextContent,
6
- type UserMessage,
7
- } from "@earendil-works/pi-ai";
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
8
2
  import type {
9
3
  ExtensionAPI,
10
4
  ExtensionCommandContext,
@@ -13,27 +7,19 @@ import type {
13
7
  } from "@earendil-works/pi-coding-agent";
14
8
  import { SessionManager } from "@earendil-works/pi-coding-agent";
15
9
  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";
10
+ import { buildAutoTitleContext } from "./context.js";
11
+ import type { AutoTitleTriggerPlan, SessionAutoTitleController } from "./controller.js";
22
12
  import {
23
- AUTO_TITLE_SYSTEM_PROMPT,
24
- buildAutoTitlePrompt,
25
- normalizeGeneratedAutoTitle,
26
- } from "./prompt.js";
13
+ type AutoTitleGenerationResult,
14
+ createAutoTitleFailure,
15
+ generateAutoTitle,
16
+ } from "./generate.js";
27
17
  import {
28
18
  AUTO_TITLE_STATE_CUSTOM_TYPE,
29
19
  type AutoTitlePersistedState,
30
- type AutoTitleTrigger,
31
20
  createAutoTitleState,
32
21
  } from "./state.js";
33
22
 
34
- const AUTO_TITLE_REQUEST_TIMEOUT_MS = 15_000;
35
- const AUTO_TITLE_MAX_TOKENS = 64;
36
-
37
23
  export interface RetitleScopeScan {
38
24
  scope: Exclude<RetitleScope, "this">;
39
25
  sessions: SessionInfo[];
@@ -71,6 +57,7 @@ export async function runBulkRetitle(
71
57
  scan: RetitleScopeScan,
72
58
  mode: RetitleMode,
73
59
  getSessionEpoch: () => number,
60
+ systemPrompt: string,
74
61
  ): Promise<BulkRetitleResult> {
75
62
  const result: BulkRetitleResult = {
76
63
  attempted: 0,
@@ -97,6 +84,7 @@ export async function runBulkRetitle(
97
84
  ctx,
98
85
  model,
99
86
  isManual: true,
87
+ systemPrompt,
100
88
  getSessionEpoch,
101
89
  notifyOnSuccess: false,
102
90
  });
@@ -104,7 +92,7 @@ export async function runBulkRetitle(
104
92
  continue;
105
93
  }
106
94
 
107
- const outcome = await retitleStoredSession(ctx, model, session.path);
95
+ const outcome = await retitleStoredSession(ctx, model, session.path, systemPrompt);
108
96
  result[outcome] += 1;
109
97
  }
110
98
 
@@ -176,23 +164,17 @@ export interface RetitlePlanOptions {
176
164
  ctx: ExtensionContext;
177
165
  model: Model<Api> | undefined;
178
166
  isManual: boolean;
167
+ systemPrompt: string;
179
168
  existingPlan?: AutoTitleTriggerPlan;
180
169
  getSessionEpoch?: () => number;
181
170
  notifyOnSuccess?: boolean;
182
171
  }
183
172
 
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;
173
+ export async function runRetitlePlan(
174
+ options: RetitlePlanOptions,
175
+ ): Promise<AutoTitleGenerationResult> {
176
+ const { pi, controller, ctx, model, isManual, systemPrompt, existingPlan, getSessionEpoch } =
177
+ options;
196
178
  const notifyOnSuccess = options.notifyOnSuccess ?? isManual;
197
179
 
198
180
  const plan = existingPlan ?? controller.handleManualRetitle(ctx);
@@ -215,7 +197,21 @@ export async function runRetitlePlan(options: RetitlePlanOptions): Promise<Retit
215
197
  }
216
198
 
217
199
  const currentEpoch = getSessionEpoch?.();
218
- const generatedTitle = await generateAutoTitle(ctx, plan, model);
200
+ const titleContext = buildAutoTitleContext(
201
+ ctx.sessionManager.getEntries(),
202
+ ctx.sessionManager.getLeafId(),
203
+ {
204
+ cwd: ctx.cwd,
205
+ currentTitle: plan.currentTitle,
206
+ },
207
+ );
208
+ const generatedTitle = await generateAutoTitle(
209
+ ctx,
210
+ model,
211
+ titleContext,
212
+ plan.reason,
213
+ systemPrompt,
214
+ );
219
215
  if (!generatedTitle.ok) {
220
216
  return generatedTitle;
221
217
  }
@@ -260,6 +256,7 @@ async function retitleStoredSession(
260
256
  ctx: ExtensionContext,
261
257
  model: Model<Api>,
262
258
  sessionPath: string,
259
+ systemPrompt: string,
263
260
  ): Promise<"retitled" | "unchanged" | "failed"> {
264
261
  const sessionManager = SessionManager.open(sessionPath);
265
262
  const currentTitle = sessionManager.getSessionName();
@@ -276,7 +273,13 @@ async function retitleStoredSession(
276
273
  userTurnCount: titleContext.userTurnCount,
277
274
  currentTitle,
278
275
  };
279
- const generatedTitle = await generateAutoTitleFromContext(ctx, plan, model, titleContext);
276
+ const generatedTitle = await generateAutoTitle(
277
+ ctx,
278
+ model,
279
+ titleContext,
280
+ plan.reason,
281
+ systemPrompt,
282
+ );
280
283
  if (!generatedTitle.ok) {
281
284
  return "failed";
282
285
  }
@@ -300,342 +303,3 @@ async function retitleStoredSession(
300
303
  function hasSessionTitle(name: string | undefined): boolean {
301
304
  return Boolean(name?.trim());
302
305
  }
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
- }
@@ -8,12 +8,8 @@ import {
8
8
  import type { Focusable, TUI } from "@earendil-works/pi-tui";
9
9
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
10
  import type { RetitleCommandOutcome, RetitleMode, RetitleScope } from "./command.js";
11
- import {
12
- type AutoRetitleStatus,
13
- type AutoTitleFailure,
14
- formatAutoTitleFailureSummary,
15
- type SessionAutoTitleController,
16
- } from "./controller.js";
11
+ import type { AutoRetitleStatus, SessionAutoTitleController } from "./controller.js";
12
+ import type { AutoTitleFailure } from "./generate.js";
17
13
  import {
18
14
  buildBulkRetitleMessage,
19
15
  buildRetitleScopeScan,
@@ -60,6 +56,7 @@ export async function showRetitleWizard(
60
56
  ctx: ExtensionCommandContext,
61
57
  model: Model<Api> | undefined,
62
58
  getSessionEpoch: () => number,
59
+ systemPrompt: string,
63
60
  options?: RetitleWizardOptions,
64
61
  ): Promise<RetitleCommandOutcome> {
65
62
  return ctx.ui.custom<RetitleCommandOutcome>(
@@ -72,6 +69,7 @@ export async function showRetitleWizard(
72
69
  ctx,
73
70
  model,
74
71
  getSessionEpoch,
72
+ systemPrompt,
75
73
  done,
76
74
  options,
77
75
  ),
@@ -100,6 +98,7 @@ class RetitleWizardPanel implements Focusable {
100
98
  private readonly ctx: ExtensionCommandContext,
101
99
  private readonly model: Model<Api> | undefined,
102
100
  private readonly getSessionEpoch: () => number,
101
+ private readonly systemPrompt: string,
103
102
  private readonly done: (result: RetitleCommandOutcome) => void,
104
103
  options?: RetitleWizardOptions,
105
104
  ) {
@@ -250,6 +249,7 @@ class RetitleWizardPanel implements Focusable {
250
249
  ctx: this.ctx,
251
250
  model: this.model,
252
251
  isManual: true,
252
+ systemPrompt: this.systemPrompt,
253
253
  getSessionEpoch: this.getSessionEpoch,
254
254
  });
255
255
  if (result.ok) {
@@ -309,6 +309,7 @@ class RetitleWizardPanel implements Focusable {
309
309
  scan,
310
310
  mode,
311
311
  this.getSessionEpoch,
312
+ this.systemPrompt,
312
313
  );
313
314
  notifyBulkRetitleResult(this.ctx, scan, mode, result);
314
315
  this.done(
@@ -468,11 +469,7 @@ class RetitleWizardPanel implements Focusable {
468
469
  return this.renderRow(innerWidth, left);
469
470
  }
470
471
 
471
- const summaryText = truncateToWidth(
472
- formatAutoTitleFailureSummary(failure),
473
- maxSummaryWidth,
474
- "…",
475
- );
472
+ const summaryText = truncateToWidth(failure.message, maxSummaryWidth, "…");
476
473
  const right = ` ${this.theme.fg("dim", summaryText)}`;
477
474
  const gap = Math.max(1, innerWidth - visibleWidth(left) - visibleWidth(right));
478
475
  return this.renderRow(innerWidth, `${left}${" ".repeat(gap)}${right}`);
@@ -523,7 +520,6 @@ function formatAutoRetitleStatusLine(theme: Theme, status: AutoRetitleStatus): s
523
520
  function formatFailureClipboardText(failure: AutoTitleFailure): string {
524
521
  const lines = [
525
522
  `model: ${failure.model}`,
526
- ...(failure.status !== undefined ? [`status: ${failure.status}`] : []),
527
523
  `trigger: ${failure.trigger}`,
528
524
  `time: ${failure.at}`,
529
525
  `error: ${failure.message}`,
@@ -14,7 +14,6 @@ import {
14
14
  } from "./session-auto-title/command.js";
15
15
  import {
16
16
  createSessionAutoTitleController,
17
- formatAutoTitleFailureSummary,
18
17
  type SessionAutoTitleController,
19
18
  } from "./session-auto-title/controller.js";
20
19
  import { resolveAutoTitleModel } from "./session-auto-title/model.js";
@@ -74,6 +73,7 @@ export default function sessionAutoTitleExtension(pi: ExtensionAPI): void {
74
73
  ctx,
75
74
  model,
76
75
  invocation,
76
+ settings.autoTitle.prompt,
77
77
  );
78
78
  },
79
79
  ),
@@ -102,6 +102,7 @@ export default function sessionAutoTitleExtension(pi: ExtensionAPI): void {
102
102
  existingPlan: result.plan,
103
103
  getSessionEpoch: () => sessionEpoch,
104
104
  notifyOnSuccess: false,
105
+ systemPrompt: settings.autoTitle.prompt,
105
106
  })
106
107
  .then((outcome) => {
107
108
  if (outcome.ok) {
@@ -111,7 +112,7 @@ export default function sessionAutoTitleExtension(pi: ExtensionAPI): void {
111
112
  const shouldNotify = controller.handleTitleFailed(ctx, outcome.failure);
112
113
  if (shouldNotify && ctx.hasUI) {
113
114
  ctx.ui.notify(
114
- `Auto-title failed: ${formatAutoTitleFailureSummary(outcome.failure)}. Open /title for details.`,
115
+ `Auto-title failed: ${outcome.failure.message}. Open /title for details.`,
115
116
  "warning",
116
117
  );
117
118
  }
@@ -136,6 +137,7 @@ async function handleTitleInvocation(
136
137
  ctx: ExtensionCommandContext,
137
138
  model: Model<Api> | undefined,
138
139
  invocation: RetitleCommandInvocation,
140
+ systemPrompt: string,
139
141
  ): Promise<RetitleCommandOutcome> {
140
142
  const retitleOpts = {
141
143
  pi,
@@ -143,6 +145,7 @@ async function handleTitleInvocation(
143
145
  ctx,
144
146
  model,
145
147
  isManual: true,
148
+ systemPrompt,
146
149
  getSessionEpoch: state.getSessionEpoch,
147
150
  };
148
151
 
@@ -161,7 +164,7 @@ async function handleTitleInvocation(
161
164
  return retitleCurrentSession();
162
165
  }
163
166
 
164
- return showRetitleWizard(pi, state.controller, ctx, model, state.getSessionEpoch);
167
+ return showRetitleWizard(pi, state.controller, ctx, model, state.getSessionEpoch, systemPrompt);
165
168
  }
166
169
 
167
170
  if (invocation.scope === "this") {
@@ -169,12 +172,20 @@ async function handleTitleInvocation(
169
172
  }
170
173
 
171
174
  if (ctx.hasUI && !invocation.force) {
172
- return showRetitleWizard(pi, state.controller, ctx, model, state.getSessionEpoch, {
173
- initialInvocation: {
174
- scope: invocation.scope,
175
- mode: invocation.mode ?? "backfill",
175
+ return showRetitleWizard(
176
+ pi,
177
+ state.controller,
178
+ ctx,
179
+ model,
180
+ state.getSessionEpoch,
181
+ systemPrompt,
182
+ {
183
+ initialInvocation: {
184
+ scope: invocation.scope,
185
+ mode: invocation.mode ?? "backfill",
186
+ },
176
187
  },
177
- });
188
+ );
178
189
  }
179
190
 
180
191
  const scan = await buildRetitleScopeScan(ctx, invocation.scope);
@@ -188,6 +199,7 @@ async function handleTitleInvocation(
188
199
  scan,
189
200
  mode,
190
201
  state.getSessionEpoch,
202
+ systemPrompt,
191
203
  );
192
204
  notifyBulkRetitleResult(ctx, scan, mode, result);
193
205
  return "success";