pi-extmgr 0.1.28 → 0.2.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.
@@ -7,6 +7,7 @@ import {
7
7
  type ExtensionContext,
8
8
  } from "@mariozechner/pi-coding-agent";
9
9
  import { getPackageCatalog } from "../packages/catalog.js";
10
+ import { parseChoiceByLabel } from "./command.js";
10
11
  import { logAutoUpdateConfig } from "./history.js";
11
12
  import { notify } from "./notify.js";
12
13
  import { normalizePackageIdentity } from "./package-source.js";
@@ -21,6 +22,15 @@ import {
21
22
 
22
23
  import { isTimerRunning, startTimer, stopTimer } from "./timer.js";
23
24
 
25
+ const AUTO_UPDATE_WIZARD_CHOICES = {
26
+ off: "Off",
27
+ hour: "Every hour",
28
+ daily: "Daily",
29
+ weekly: "Weekly",
30
+ custom: "Custom...",
31
+ cancel: "Cancel",
32
+ } as const;
33
+
24
34
  // Context provider for safe session handling
25
35
  export type ContextProvider = () => (ExtensionCommandContext | ExtensionContext) | undefined;
26
36
 
@@ -148,35 +158,30 @@ export async function promptAutoUpdateWizard(
148
158
  }
149
159
 
150
160
  const current = getAutoUpdateConfig(ctx);
151
- const choice = await ctx.ui.select(`Auto-update (${current.displayText})`, [
152
- "Off",
153
- "Every hour",
154
- "Daily",
155
- "Weekly",
156
- "Custom...",
157
- "Cancel",
158
- ]);
159
-
160
- if (!choice || choice === "Cancel") return;
161
-
162
- if (choice === "Off") {
163
- disableAutoUpdate(pi, ctx);
164
- return;
165
- }
166
-
167
- if (choice === "Every hour") {
168
- enableAutoUpdate(pi, ctx, 60 * 60 * 1000, "1 hour", onUpdateAvailable);
169
- return;
170
- }
171
-
172
- if (choice === "Daily") {
173
- enableAutoUpdate(pi, ctx, 24 * 60 * 60 * 1000, "daily", onUpdateAvailable);
174
- return;
175
- }
161
+ const choice = parseChoiceByLabel(
162
+ AUTO_UPDATE_WIZARD_CHOICES,
163
+ await ctx.ui.select(
164
+ `Auto-update (${current.displayText})`,
165
+ Object.values(AUTO_UPDATE_WIZARD_CHOICES)
166
+ )
167
+ );
176
168
 
177
- if (choice === "Weekly") {
178
- enableAutoUpdate(pi, ctx, 7 * 24 * 60 * 60 * 1000, "weekly", onUpdateAvailable);
179
- return;
169
+ switch (choice) {
170
+ case "off":
171
+ disableAutoUpdate(pi, ctx);
172
+ return;
173
+ case "hour":
174
+ enableAutoUpdate(pi, ctx, 60 * 60 * 1000, "1 hour", onUpdateAvailable);
175
+ return;
176
+ case "daily":
177
+ enableAutoUpdate(pi, ctx, 24 * 60 * 60 * 1000, "daily", onUpdateAvailable);
178
+ return;
179
+ case "weekly":
180
+ enableAutoUpdate(pi, ctx, 7 * 24 * 60 * 60 * 1000, "weekly", onUpdateAvailable);
181
+ return;
182
+ case "cancel":
183
+ case undefined:
184
+ return;
180
185
  }
181
186
 
182
187
  const input = await ctx.ui.input("Auto-update interval", current.displayText || "1d");
@@ -184,7 +189,7 @@ export async function promptAutoUpdateWizard(
184
189
 
185
190
  const parsed = parseDuration(input.trim());
186
191
  if (!parsed) {
187
- notify(ctx, "Invalid duration. Examples: 1h, 1d, 1w, 1m, never", "warning");
192
+ notify(ctx, "Invalid duration. Examples: 1h, 1d, 1w, 1mo, never", "warning");
188
193
  return;
189
194
  }
190
195
 
@@ -13,13 +13,27 @@ const CACHE_DIR = process.env.PI_EXTMGR_CACHE_DIR
13
13
  : join(homedir(), ".pi", "agent", ".extmgr-cache");
14
14
  const CACHE_FILE = join(CACHE_DIR, "metadata.json");
15
15
  const CURRENT_SEARCH_CACHE_STRATEGY = "npm-registry-v1-paginated";
16
+ const CACHED_PACKAGE_FIELDS = [
17
+ "description",
18
+ "version",
19
+ "author",
20
+ "keywords",
21
+ "date",
22
+ "size",
23
+ ] as const;
24
+
25
+ type CachedPackageField = (typeof CACHED_PACKAGE_FIELDS)[number];
16
26
 
17
27
  interface CachedPackageData {
18
28
  name: string;
19
29
  description?: string | undefined;
20
30
  version?: string | undefined;
31
+ author?: string | undefined;
32
+ keywords?: string[] | undefined;
33
+ date?: string | undefined;
21
34
  size?: number | undefined;
22
35
  timestamp: number;
36
+ fieldTimestamps?: Partial<Record<CachedPackageField, number>> | undefined;
23
37
  }
24
38
 
25
39
  interface CacheData {
@@ -55,17 +69,66 @@ function normalizeCachedPackageEntry(key: string, value: unknown): CachedPackage
55
69
  name,
56
70
  timestamp,
57
71
  };
72
+ const rawFieldTimestamps = isRecord(value.fieldTimestamps) ? value.fieldTimestamps : undefined;
73
+
74
+ const getFieldTimestamp = (field: CachedPackageField): number => {
75
+ const fieldTimestamp = rawFieldTimestamps?.[field];
76
+ return typeof fieldTimestamp === "number" &&
77
+ Number.isFinite(fieldTimestamp) &&
78
+ fieldTimestamp > 0
79
+ ? fieldTimestamp
80
+ : timestamp;
81
+ };
58
82
 
59
83
  if (typeof value.description === "string") {
60
84
  entry.description = value.description;
85
+ entry.fieldTimestamps = {
86
+ ...entry.fieldTimestamps,
87
+ description: getFieldTimestamp("description"),
88
+ };
61
89
  }
62
90
 
63
91
  if (typeof value.version === "string") {
64
92
  entry.version = value.version;
93
+ entry.fieldTimestamps = {
94
+ ...entry.fieldTimestamps,
95
+ version: getFieldTimestamp("version"),
96
+ };
97
+ }
98
+
99
+ if (typeof value.author === "string") {
100
+ entry.author = value.author;
101
+ entry.fieldTimestamps = {
102
+ ...entry.fieldTimestamps,
103
+ author: getFieldTimestamp("author"),
104
+ };
105
+ }
106
+
107
+ if (Array.isArray(value.keywords)) {
108
+ const keywords = value.keywords.filter((item): item is string => typeof item === "string");
109
+ if (keywords.length > 0) {
110
+ entry.keywords = keywords;
111
+ entry.fieldTimestamps = {
112
+ ...entry.fieldTimestamps,
113
+ keywords: getFieldTimestamp("keywords"),
114
+ };
115
+ }
116
+ }
117
+
118
+ if (typeof value.date === "string") {
119
+ entry.date = value.date;
120
+ entry.fieldTimestamps = {
121
+ ...entry.fieldTimestamps,
122
+ date: getFieldTimestamp("date"),
123
+ };
65
124
  }
66
125
 
67
126
  if (typeof value.size === "number" && Number.isFinite(value.size) && value.size >= 0) {
68
127
  entry.size = value.size;
128
+ entry.fieldTimestamps = {
129
+ ...entry.fieldTimestamps,
130
+ size: getFieldTimestamp("size"),
131
+ };
69
132
  }
70
133
 
71
134
  return entry;
@@ -234,11 +297,117 @@ async function enqueueCacheSave(): Promise<void> {
234
297
  return cacheWriteQueue;
235
298
  }
236
299
 
300
+ function setCachedPackageField(
301
+ data: CachedPackageData,
302
+ field: CachedPackageField,
303
+ value: CachedPackageData[CachedPackageField],
304
+ timestamp: number
305
+ ): void {
306
+ switch (field) {
307
+ case "description":
308
+ data.description = value as string | undefined;
309
+ break;
310
+ case "version":
311
+ data.version = value as string | undefined;
312
+ break;
313
+ case "author":
314
+ data.author = value as string | undefined;
315
+ break;
316
+ case "keywords":
317
+ data.keywords = value as string[] | undefined;
318
+ break;
319
+ case "date":
320
+ data.date = value as string | undefined;
321
+ break;
322
+ case "size":
323
+ data.size = value as number | undefined;
324
+ break;
325
+ }
326
+
327
+ data.fieldTimestamps = {
328
+ ...data.fieldTimestamps,
329
+ [field]: timestamp,
330
+ };
331
+ }
332
+
333
+ function getCachedFieldTimestamp(data: CachedPackageData, field: CachedPackageField): number {
334
+ return data.fieldTimestamps?.[field] ?? data.timestamp;
335
+ }
336
+
337
+ function mergeCachedPackageData(
338
+ existing: CachedPackageData | undefined,
339
+ next: Omit<CachedPackageData, "timestamp" | "fieldTimestamps">
340
+ ): CachedPackageData {
341
+ const timestamp = Date.now();
342
+ const merged: CachedPackageData = {
343
+ name: next.name || existing?.name || "",
344
+ timestamp,
345
+ };
346
+
347
+ for (const field of CACHED_PACKAGE_FIELDS) {
348
+ const nextValue = next[field];
349
+ if (nextValue !== undefined) {
350
+ setCachedPackageField(merged, field, nextValue, timestamp);
351
+ continue;
352
+ }
353
+
354
+ const existingValue = existing?.[field];
355
+ if (existingValue !== undefined && existing) {
356
+ setCachedPackageField(merged, field, existingValue, getCachedFieldTimestamp(existing, field));
357
+ }
358
+ }
359
+
360
+ return merged;
361
+ }
362
+
237
363
  /**
238
364
  * Check if cached data is still valid (within TTL)
239
365
  */
240
- function isCacheValid(timestamp: number): boolean {
241
- return Date.now() - timestamp < CACHE_LIMITS.metadataTTL;
366
+ function isCacheValid(timestamp: number | undefined): boolean {
367
+ return typeof timestamp === "number" && Date.now() - timestamp < CACHE_LIMITS.metadataTTL;
368
+ }
369
+
370
+ function getFreshCachedField(
371
+ data: CachedPackageData,
372
+ field: CachedPackageField
373
+ ): CachedPackageData[CachedPackageField] | undefined {
374
+ const value = data[field];
375
+ if (value === undefined) {
376
+ return undefined;
377
+ }
378
+
379
+ return isCacheValid(getCachedFieldTimestamp(data, field)) ? value : undefined;
380
+ }
381
+
382
+ function hasFreshCachedField(data: CachedPackageData): boolean {
383
+ return CACHED_PACKAGE_FIELDS.some((field) => {
384
+ const value = data[field];
385
+ return value !== undefined && isCacheValid(getCachedFieldTimestamp(data, field));
386
+ });
387
+ }
388
+
389
+ function toFreshCachedPackage(data: CachedPackageData | undefined): CachedPackageData | null {
390
+ if (!data) {
391
+ return null;
392
+ }
393
+
394
+ const fresh: CachedPackageData = {
395
+ name: data.name,
396
+ timestamp: data.timestamp,
397
+ };
398
+ let hasFreshField = false;
399
+
400
+ for (const field of CACHED_PACKAGE_FIELDS) {
401
+ const value = getFreshCachedField(data, field);
402
+ if (value === undefined) {
403
+ continue;
404
+ }
405
+
406
+ hasFreshField = true;
407
+ setCachedPackageField(fresh, field, value, getCachedFieldTimestamp(data, field));
408
+ }
409
+
410
+ return hasFreshField ? fresh : null;
242
411
  }
243
412
 
244
413
  /**
@@ -246,13 +415,7 @@ function isCacheValid(timestamp: number): boolean {
246
415
  */
247
416
  export async function getCachedPackage(name: string): Promise<CachedPackageData | null> {
248
417
  const cache = await loadCache();
249
- const data = cache.packages.get(name);
250
-
251
- if (!data || !isCacheValid(data.timestamp)) {
252
- return null;
253
- }
254
-
255
- return data;
418
+ return toFreshCachedPackage(cache.packages.get(name));
256
419
  }
257
420
 
258
421
  /**
@@ -260,13 +423,10 @@ export async function getCachedPackage(name: string): Promise<CachedPackageData
260
423
  */
261
424
  export async function setCachedPackage(
262
425
  name: string,
263
- data: Omit<CachedPackageData, "timestamp">
426
+ data: Omit<CachedPackageData, "timestamp" | "fieldTimestamps">
264
427
  ): Promise<void> {
265
428
  const cache = await loadCache();
266
- cache.packages.set(name, {
267
- ...data,
268
- timestamp: Date.now(),
269
- });
429
+ cache.packages.set(name, mergeCachedPackageData(cache.packages.get(name), data));
270
430
  await enqueueCacheSave();
271
431
  }
272
432
 
@@ -295,8 +455,12 @@ export async function getCachedSearch(query: string): Promise<NpmPackage[] | nul
295
455
  if (pkg) {
296
456
  packages.push({
297
457
  name: pkg.name,
298
- description: pkg.description ?? undefined,
299
- version: pkg.version ?? undefined,
458
+ description: getFreshCachedField(pkg, "description") as string | undefined,
459
+ version: getFreshCachedField(pkg, "version") as string | undefined,
460
+ author: getFreshCachedField(pkg, "author") as string | undefined,
461
+ keywords: getFreshCachedField(pkg, "keywords") as string[] | undefined,
462
+ date: getFreshCachedField(pkg, "date") as string | undefined,
463
+ size: getFreshCachedField(pkg, "size") as number | undefined,
300
464
  });
301
465
  }
302
466
  }
@@ -312,12 +476,18 @@ export async function setCachedSearch(query: string, packages: NpmPackage[]): Pr
312
476
 
313
477
  // Update cache with new packages
314
478
  for (const pkg of packages) {
315
- cache.packages.set(pkg.name, {
316
- name: pkg.name,
317
- description: pkg.description ?? undefined,
318
- version: pkg.version ?? undefined,
319
- timestamp: Date.now(),
320
- });
479
+ cache.packages.set(
480
+ pkg.name,
481
+ mergeCachedPackageData(cache.packages.get(pkg.name), {
482
+ name: pkg.name,
483
+ description: pkg.description ?? undefined,
484
+ version: pkg.version ?? undefined,
485
+ author: pkg.author ?? undefined,
486
+ keywords: pkg.keywords ?? undefined,
487
+ date: pkg.date ?? undefined,
488
+ size: pkg.size ?? undefined,
489
+ })
490
+ );
321
491
  }
322
492
 
323
493
  // Store search results
@@ -355,7 +525,7 @@ export async function getCacheStats(): Promise<{
355
525
  let expired = 0;
356
526
 
357
527
  for (const [, data] of cache.packages) {
358
- if (isCacheValid(data.timestamp)) {
528
+ if (hasFreshCachedField(data)) {
359
529
  valid++;
360
530
  } else {
361
531
  expired++;
@@ -383,8 +553,9 @@ export async function getPackageDescriptions(
383
553
  if (!npmSource?.name) continue;
384
554
 
385
555
  const cached = cache.packages.get(npmSource.name);
386
- if (cached?.description && isCacheValid(cached.timestamp)) {
387
- descriptions.set(pkg.source, cached.description);
556
+ const description = cached ? getFreshCachedField(cached, "description") : undefined;
557
+ if (typeof description === "string") {
558
+ descriptions.set(pkg.source, description);
388
559
  }
389
560
  }
390
561
 
@@ -397,12 +568,7 @@ export async function getPackageDescriptions(
397
568
  export async function getCachedPackageSize(name: string): Promise<number | undefined> {
398
569
  const cache = await loadCache();
399
570
  const data = cache.packages.get(name);
400
-
401
- if (data && isCacheValid(data.timestamp)) {
402
- return data.size;
403
- }
404
-
405
- return undefined;
571
+ return data ? (getFreshCachedField(data, "size") as number | undefined) : undefined;
406
572
  }
407
573
 
408
574
  /**
@@ -412,14 +578,19 @@ export async function setCachedPackageSize(name: string, size: number): Promise<
412
578
  const cache = await loadCache();
413
579
  const existing = cache.packages.get(name);
414
580
 
581
+ const timestamp = Date.now();
582
+
415
583
  if (existing) {
416
- existing.size = size;
417
- existing.timestamp = Date.now();
584
+ existing.timestamp = timestamp;
585
+ setCachedPackageField(existing, "size", size, timestamp);
418
586
  } else {
419
587
  cache.packages.set(name, {
420
588
  name,
421
589
  size,
422
- timestamp: Date.now(),
590
+ timestamp,
591
+ fieldTimestamps: {
592
+ size: timestamp,
593
+ },
423
594
  });
424
595
  }
425
596
 
@@ -0,0 +1,132 @@
1
+ export type DurationUnit = "minute" | "hour" | "day" | "week" | "month";
2
+
3
+ interface ParsedDuration {
4
+ ms: number;
5
+ display: string;
6
+ }
7
+
8
+ interface DurationAlias {
9
+ ms: number;
10
+ display: string;
11
+ }
12
+
13
+ interface DurationUnitDefinition {
14
+ aliases: readonly string[];
15
+ ms: number;
16
+ singular: string;
17
+ plural: string;
18
+ }
19
+
20
+ interface ParseDurationOptions {
21
+ allowedUnits: readonly DurationUnit[];
22
+ aliases?: Readonly<Record<string, DurationAlias>>;
23
+ }
24
+
25
+ const HOUR_MS = 60 * 60 * 1000;
26
+ const DAY_MS = 24 * HOUR_MS;
27
+ const WEEK_MS = 7 * DAY_MS;
28
+ const MONTH_MS = 30 * DAY_MS;
29
+
30
+ const DURATION_UNITS: Record<DurationUnit, DurationUnitDefinition> = {
31
+ minute: {
32
+ aliases: ["m", "min", "mins", "minute", "minutes"],
33
+ ms: 60 * 1000,
34
+ singular: "minute",
35
+ plural: "minutes",
36
+ },
37
+ hour: {
38
+ aliases: ["h", "hr", "hrs", "hour", "hours"],
39
+ ms: HOUR_MS,
40
+ singular: "hour",
41
+ plural: "hours",
42
+ },
43
+ day: {
44
+ aliases: ["d", "day", "days"],
45
+ ms: DAY_MS,
46
+ singular: "day",
47
+ plural: "days",
48
+ },
49
+ week: {
50
+ aliases: ["w", "wk", "wks", "week", "weeks"],
51
+ ms: WEEK_MS,
52
+ singular: "week",
53
+ plural: "weeks",
54
+ },
55
+ month: {
56
+ aliases: ["mo", "mos", "month", "months"],
57
+ ms: MONTH_MS,
58
+ singular: "month",
59
+ plural: "months",
60
+ },
61
+ };
62
+
63
+ function formatDisplay(value: number, unit: DurationUnit): string {
64
+ const definition = DURATION_UNITS[unit];
65
+ return `${value} ${value === 1 ? definition.singular : definition.plural}`;
66
+ }
67
+
68
+ function findDurationUnit(
69
+ rawUnit: string,
70
+ allowedUnits: readonly DurationUnit[]
71
+ ): DurationUnit | undefined {
72
+ return allowedUnits.find((unit) => DURATION_UNITS[unit].aliases.includes(rawUnit));
73
+ }
74
+
75
+ export function parseDurationValue(
76
+ input: string,
77
+ options: ParseDurationOptions
78
+ ): ParsedDuration | undefined {
79
+ const normalized = input.toLowerCase().trim();
80
+ if (!normalized) {
81
+ return undefined;
82
+ }
83
+
84
+ const alias = options.aliases?.[normalized];
85
+ if (alias) {
86
+ return { ...alias };
87
+ }
88
+
89
+ const match = normalized.match(/^(\d+)\s*([a-z]+)$/i);
90
+ if (!match) {
91
+ return undefined;
92
+ }
93
+
94
+ const value = Number.parseInt(match[1] ?? "", 10);
95
+ const rawUnit = match[2] ?? "";
96
+ if (!Number.isFinite(value) || value <= 0 || !rawUnit) {
97
+ return undefined;
98
+ }
99
+
100
+ const unit = findDurationUnit(rawUnit, options.allowedUnits);
101
+ if (!unit) {
102
+ return undefined;
103
+ }
104
+
105
+ return {
106
+ ms: value * DURATION_UNITS[unit].ms,
107
+ display: formatDisplay(value, unit),
108
+ };
109
+ }
110
+
111
+ const SCHEDULE_DURATION_ALIASES = {
112
+ never: { ms: 0, display: "off" },
113
+ off: { ms: 0, display: "off" },
114
+ disable: { ms: 0, display: "off" },
115
+ daily: { ms: DAY_MS, display: "daily" },
116
+ day: { ms: DAY_MS, display: "daily" },
117
+ weekly: { ms: WEEK_MS, display: "weekly" },
118
+ week: { ms: WEEK_MS, display: "weekly" },
119
+ } satisfies Record<string, DurationAlias>;
120
+
121
+ export function parseScheduleDuration(input: string): ParsedDuration | undefined {
122
+ return parseDurationValue(input, {
123
+ allowedUnits: ["hour", "day", "week", "month"],
124
+ aliases: SCHEDULE_DURATION_ALIASES,
125
+ });
126
+ }
127
+
128
+ export function parseLookbackDuration(input: string): number | undefined {
129
+ return parseDurationValue(input, {
130
+ allowedUnits: ["minute", "hour", "day", "week", "month"],
131
+ })?.ms;
132
+ }
@@ -9,36 +9,6 @@ export function truncate(text: string, maxLength: number): string {
9
9
  return `${text.slice(0, maxLength - 3)}...`;
10
10
  }
11
11
 
12
- /**
13
- * Get the terminal width, with a minimum fallback
14
- */
15
- export function getTerminalWidth(minWidth = 80): number {
16
- return Math.max(minWidth, process.stdout.columns || minWidth);
17
- }
18
-
19
- /**
20
- * Calculate available space for description based on fixed-width elements
21
- */
22
- export function getDescriptionWidth(
23
- totalWidth: number,
24
- reservedSpace: number,
25
- minDescWidth = 20
26
- ): number {
27
- return Math.max(minDescWidth, totalWidth - reservedSpace);
28
- }
29
-
30
- /**
31
- * Dynamic truncate that adapts to available terminal width
32
- * @param text - Text to truncate
33
- * @param reservedSpace - Space taken by fixed elements (icons, name, version, etc.)
34
- * @param minWidth - Minimum terminal width to consider
35
- */
36
- export function dynamicTruncate(text: string, reservedSpace: number, minWidth = 80): string {
37
- const termWidth = getTerminalWidth(minWidth);
38
- const maxDescWidth = getDescriptionWidth(termWidth, reservedSpace);
39
- return truncate(text, maxDescWidth);
40
- }
41
-
42
12
  export function formatEntry(entry: ExtensionEntry): string {
43
13
  const state = entry.state === "enabled" ? "on " : "off";
44
14
  const scope = entry.scope === "global" ? "G" : "P";
package/src/utils/fs.ts CHANGED
@@ -4,6 +4,10 @@
4
4
  import { access, readFile } from "node:fs/promises";
5
5
  import { truncate } from "./format.js";
6
6
 
7
+ function formatSummary(text: string): string {
8
+ return truncate(text.replace(/\s+/g, " ").trim(), 80);
9
+ }
10
+
7
11
  export async function fileExists(filePath: string): Promise<boolean> {
8
12
  try {
9
13
  await access(filePath);
@@ -28,7 +32,7 @@ export async function readSummary(filePath: string): Promise<string> {
28
32
  for (const pattern of descriptionPatterns) {
29
33
  const match = text.match(pattern);
30
34
  const value = match?.[1]?.trim();
31
- if (value) return truncate(value, 80);
35
+ if (value) return formatSummary(value);
32
36
  }
33
37
 
34
38
  // Look for block comments
@@ -45,7 +49,7 @@ export async function readSummary(filePath: string): Promise<string> {
45
49
  )
46
50
  .filter((s): s is string => Boolean(s));
47
51
  const firstLine = lines[0];
48
- if (firstLine) return truncate(firstLine, 80);
52
+ if (firstLine) return formatSummary(firstLine);
49
53
  }
50
54
 
51
55
  // Look for line comments
@@ -55,13 +59,13 @@ export async function readSummary(filePath: string): Promise<string> {
55
59
  .split("\n")
56
60
  .map((line) => line.replace(/^\s*\/\/\s?/, "").trim())
57
61
  .filter(Boolean)[0];
58
- if (first) return truncate(first, 80);
62
+ if (first) return formatSummary(first);
59
63
  }
60
64
 
61
65
  // First non-empty line
62
66
  for (const line of text.split("\n")) {
63
67
  const clean = line.trim();
64
- if (clean.length > 0) return truncate(clean, 80);
68
+ if (clean.length > 0) return formatSummary(clean);
65
69
  }
66
70
  } catch {
67
71
  // ignore