tau-all-agent 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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/extensions/answer.ts +601 -0
  4. package/extensions/branch-term.ts +405 -0
  5. package/extensions/btw.ts +444 -0
  6. package/extensions/ghostty.ts +301 -0
  7. package/extensions/git-diff-stats.ts +277 -0
  8. package/extensions/git-pr-status.ts +286 -0
  9. package/extensions/insights.ts +2367 -0
  10. package/extensions/interlude.ts +144 -0
  11. package/extensions/loop.ts +529 -0
  12. package/extensions/memory.ts +1889 -0
  13. package/extensions/notify.ts +161 -0
  14. package/extensions/openai-fast.ts +227 -0
  15. package/extensions/openai-verbosity.ts +223 -0
  16. package/extensions/review.ts +4347 -0
  17. package/extensions/sandbox/index.ts +2578 -0
  18. package/extensions/telegram/README.md +81 -0
  19. package/extensions/telegram/daemon.mjs +1942 -0
  20. package/extensions/telegram/index.ts +1073 -0
  21. package/extensions/telegram/message-text.d.mts +1 -0
  22. package/extensions/telegram/message-text.mjs +16 -0
  23. package/extensions/usage/anthropic.ts +198 -0
  24. package/extensions/usage/github-copilot.ts +204 -0
  25. package/extensions/usage/google-gemini-cli.ts +232 -0
  26. package/extensions/usage/index.ts +2310 -0
  27. package/extensions/usage/minimax.ts +208 -0
  28. package/extensions/usage/openai-codex.ts +180 -0
  29. package/extensions/usage/openrouter.ts +168 -0
  30. package/extensions/usage/providers.ts +26 -0
  31. package/extensions/usage/shared.ts +156 -0
  32. package/extensions/usage/types.ts +53 -0
  33. package/extensions/usage/zai.ts +186 -0
  34. package/extensions/websearch/README.md +68 -0
  35. package/extensions/websearch/browser/chromium.ts +330 -0
  36. package/extensions/websearch/browser/discovery.ts +75 -0
  37. package/extensions/websearch/browser/firefox.ts +150 -0
  38. package/extensions/websearch/browser/sqlite.ts +38 -0
  39. package/extensions/websearch/config.ts +76 -0
  40. package/extensions/websearch/index.ts +312 -0
  41. package/extensions/websearch/normalize.ts +155 -0
  42. package/extensions/websearch/providers/anthropic.pi.ts +139 -0
  43. package/extensions/websearch/providers/gemini.browser.ts +185 -0
  44. package/extensions/websearch/providers/gemini.pi.ts +108 -0
  45. package/extensions/websearch/providers/openai-codex.browser.ts +77 -0
  46. package/extensions/websearch/providers/openai-codex.pi.ts +49 -0
  47. package/extensions/websearch/providers/openai-codex.shared.ts +123 -0
  48. package/extensions/websearch/providers/pi-model.shared.ts +86 -0
  49. package/extensions/websearch/providers/search-prompt.shared.ts +17 -0
  50. package/extensions/websearch/providers/shared.ts +76 -0
  51. package/extensions/websearch/types.ts +52 -0
  52. package/extensions/worktree.ts +2223 -0
  53. package/package.json +76 -0
  54. package/skills/browser-tools/SKILL.md +253 -0
  55. package/skills/browser-tools/scripts/browser-content.js +100 -0
  56. package/skills/browser-tools/scripts/browser-cookies.js +33 -0
  57. package/skills/browser-tools/scripts/browser-dismiss-cookies.js +455 -0
  58. package/skills/browser-tools/scripts/browser-eval.js +75 -0
  59. package/skills/browser-tools/scripts/browser-logs-tail.js +89 -0
  60. package/skills/browser-tools/scripts/browser-nav.js +28 -0
  61. package/skills/browser-tools/scripts/browser-net-summary.js +115 -0
  62. package/skills/browser-tools/scripts/browser-pick.js +133 -0
  63. package/skills/browser-tools/scripts/browser-screenshot.js +18 -0
  64. package/skills/browser-tools/scripts/browser-start.js +281 -0
  65. package/skills/browser-tools/scripts/browser-watch.js +321 -0
  66. package/skills/browser-tools/scripts/utils.js +60 -0
  67. package/skills/git-clean-history/SKILL.md +45 -0
  68. package/skills/git-commit/SKILL.md +55 -0
  69. package/skills/homeassistant-ops/SKILL.md +184 -0
  70. package/skills/homeassistant-ops/references/api.md +64 -0
  71. package/skills/homeassistant-ops/references/id_conventions.md +71 -0
  72. package/skills/homeassistant-ops/references/playbook.md +150 -0
  73. package/skills/homeassistant-ops/scripts/ha_ops.js +2942 -0
  74. package/skills/openscad/SKILL.md +149 -0
  75. package/skills/openscad/examples/parametric_box.scad +92 -0
  76. package/skills/openscad/examples/phone_stand.scad +95 -0
  77. package/skills/openscad/scripts/common.sh +49 -0
  78. package/skills/openscad/scripts/export-stl.sh +56 -0
  79. package/skills/openscad/scripts/extract-params.sh +147 -0
  80. package/skills/openscad/scripts/multi-preview.sh +68 -0
  81. package/skills/openscad/scripts/preview.sh +74 -0
  82. package/skills/openscad/scripts/render-with-params.sh +91 -0
  83. package/skills/openscad/scripts/validate.sh +46 -0
  84. package/skills/oracle/SKILL.md +64 -0
  85. package/skills/oracle/scripts/oracle +249 -0
  86. package/skills/oracle/scripts/oracle-bundle +227 -0
  87. package/skills/sentry/SKILL.md +198 -0
  88. package/skills/sentry/lib/auth.js +99 -0
  89. package/skills/sentry/scripts/fetch-event.js +325 -0
  90. package/skills/sentry/scripts/fetch-issue.js +368 -0
  91. package/skills/sentry/scripts/list-issues.js +245 -0
  92. package/skills/sentry/scripts/search-events.js +291 -0
  93. package/skills/sentry/scripts/search-logs.js +234 -0
  94. package/skills/update-changelog/SKILL.md +135 -0
  95. package/skills/web-design/SKILL.md +117 -0
@@ -0,0 +1,2367 @@
1
+ import { complete, type Api, type Model, type UserMessage } from "@mariozechner/pi-ai";
2
+ import {
3
+ BorderedLoader,
4
+ SessionManager,
5
+ buildSessionContext,
6
+ getAgentDir,
7
+ getLanguageFromPath,
8
+ getMarkdownTheme,
9
+ migrateSessionEntries,
10
+ parseSessionEntries,
11
+ type ExtensionAPI,
12
+ type ExtensionCommandContext,
13
+ type SessionEntry,
14
+ type SessionHeader,
15
+ type Theme,
16
+ } from "@mariozechner/pi-coding-agent";
17
+ import {
18
+ Key,
19
+ Markdown,
20
+ matchesKey,
21
+ truncateToWidth,
22
+ type Component,
23
+ type TUI,
24
+ visibleWidth,
25
+ wrapTextWithAnsi,
26
+ } from "@mariozechner/pi-tui";
27
+ import { createHash, randomBytes } from "node:crypto";
28
+ import fs from "node:fs/promises";
29
+ import path from "node:path";
30
+
31
+ // --- Constants ---
32
+
33
+ const INSIGHTS_META_SCHEMA_VERSION = 1;
34
+ const SESSION_FINGERPRINT_VERSION = 1;
35
+ const INSIGHTS_FACET_SCHEMA_VERSION = 1;
36
+ const FACET_PROMPT_VERSION = 1;
37
+ const SYNTHESIS_PROMPT_VERSION = 1;
38
+
39
+ const MIN_MEANINGFUL_USER_MESSAGES = 2;
40
+ const MIN_DURATION_MINUTES = 1;
41
+
42
+ const FULL_TRANSCRIPT_MAX_CHARS = 30_000;
43
+ const REDUCED_TRANSCRIPT_HEAD_CHARS = 12_000;
44
+ const REDUCED_TRANSCRIPT_TAIL_CHARS = 12_000;
45
+ const REDUCED_TRANSCRIPT_MAX_CHARS = FULL_TRANSCRIPT_MAX_CHARS;
46
+
47
+ const AGENT_ROOT = getAgentDir();
48
+ const CACHE_ROOT = path.join(AGENT_ROOT, "insights");
49
+ const META_CACHE_DIR = path.join(CACHE_ROOT, "session-meta");
50
+ const FACET_CACHE_DIR = path.join(CACHE_ROOT, "session-facets");
51
+
52
+ const GOAL_CATEGORIES = [
53
+ "debug_investigate",
54
+ "implement_feature",
55
+ "fix_bug",
56
+ "refactor_code",
57
+ "write_tests",
58
+ "write_docs",
59
+ "analyze_data",
60
+ "understand_codebase",
61
+ "configure_system",
62
+ "automation_workflow",
63
+ "quick_question",
64
+ ] as const;
65
+
66
+ const FRICTION_CATEGORIES = [
67
+ "misunderstood_request",
68
+ "wrong_approach",
69
+ "buggy_code",
70
+ "too_much_change",
71
+ "tool_failed",
72
+ "environment_issue",
73
+ "context_missing",
74
+ "branch_confusion",
75
+ ] as const;
76
+
77
+ const FACET_SYSTEM_PROMPT = `You analyze one Pi coding session and extract structured JSON.
78
+
79
+ Return valid JSON only. No markdown. No prose outside JSON.
80
+
81
+ Schema:
82
+ {
83
+ "underlyingGoal": string,
84
+ "goalCategories": string[],
85
+ "outcome": "achieved" | "partial" | "blocked" | "unclear",
86
+ "frictionCategories": string[],
87
+ "frictionDetail": string,
88
+ "briefSummary": string,
89
+ "explicitInstructionsToRemember": string[],
90
+ "repeatedWorkflowHints": string[]
91
+ }
92
+
93
+ Allowed goalCategories:
94
+ ${GOAL_CATEGORIES.map((item) => `- ${item}`).join("\n")}
95
+
96
+ Allowed frictionCategories:
97
+ ${FRICTION_CATEGORIES.map((item) => `- ${item}`).join("\n")}
98
+
99
+ Rules:
100
+ - Count only what the user actually wanted.
101
+ - Pick 1-3 goal categories when the session is clear. Use [] only if the goal is genuinely unclear.
102
+ - Outcome should reflect whether the user's requested result was achieved.
103
+ - Friction should stay concrete and session-specific. Use [] when there was no meaningful friction.
104
+ - frictionDetail should be concise and empty when there was no meaningful friction.
105
+ - briefSummary should be at most 2 sentences.
106
+ - explicitInstructionsToRemember should contain only direct, reusable user instructions worth remembering later.
107
+ - repeatedWorkflowHints should capture reusable workflows or collaboration patterns, not one-off task details.
108
+ - Keep arrays short, deduplicated, and high-signal.
109
+ - Self-check that JSON.parse(output) would succeed before responding.`;
110
+
111
+ const SYNTHESIS_SYSTEM_PROMPT = `You are writing a Pi /insights report.
112
+
113
+ Return Markdown only.
114
+
115
+ Use exactly these sections in this order:
116
+ 1. ## At a glance
117
+ 2. ## What you use Pi for
118
+ 3. ## How you tend to work with Pi
119
+ 4. ## Repeated instructions worth moving into AGENTS.md
120
+ 5. ## Good prompt template candidates
121
+ 6. ## Good skill candidates
122
+ 7. ## Good extension candidates
123
+ 8. ## Where things go wrong
124
+ 9. ## Quick wins
125
+
126
+ Rules:
127
+ - Be concrete and direct.
128
+ - Avoid fluff.
129
+ - Prefer fewer, stronger recommendations over long weak lists.
130
+ - Base recommendations on repeated evidence, not one-off anecdotes.
131
+ - For every AGENTS.md / prompt template / skill / extension recommendation, explain why it belongs in that bucket.
132
+ - If evidence is weak for a section, say so briefly instead of inventing material.
133
+ - Do not mention implementation details of this analytics pipeline.`;
134
+
135
+ // --- Types ---
136
+
137
+ type InsightScope = "current" | "project" | "all";
138
+
139
+ type ConfiguredModelSelection = {
140
+ model: Model<Api>;
141
+ apiKey?: string;
142
+ headers?: Record<string, string>;
143
+ };
144
+
145
+ type ReadonlySessionManager = Pick<
146
+ SessionManager,
147
+ | "getEntries"
148
+ | "getBranch"
149
+ | "getLeafId"
150
+ | "getHeader"
151
+ | "getSessionDir"
152
+ | "getSessionId"
153
+ | "getSessionFile"
154
+ >;
155
+
156
+ type SessionTarget = {
157
+ path?: string;
158
+ manager?: ReadonlySessionManager;
159
+ };
160
+
161
+ type SessionRuntime = {
162
+ manager: ReadonlySessionManager;
163
+ transcript?: NormalizedTranscript;
164
+ };
165
+
166
+ type SessionCacheInfo = {
167
+ cacheKey?: string;
168
+ fingerprint?: string;
169
+ };
170
+
171
+ type SessionMeta = {
172
+ schemaVersion: number;
173
+ sessionFile: string;
174
+ sessionId?: string;
175
+ cwd?: string;
176
+ fingerprint: string;
177
+ startedAt?: string;
178
+ endedAt?: string;
179
+ durationMinutes: number;
180
+ totalEntries: number;
181
+ pathEntries: number;
182
+ branchCount: number;
183
+ compactionCount: number;
184
+ branchSummaryCount: number;
185
+ meaningfulUserMessages: number;
186
+ assistantMessages: number;
187
+ toolResultMessages: number;
188
+ totalInputTokens: number;
189
+ totalOutputTokens: number;
190
+ totalCost?: number;
191
+ toolCounts: Record<string, number>;
192
+ toolsWithErrors: Record<string, number>;
193
+ modifiedFiles: string[];
194
+ languages: Record<string, number>;
195
+ firstUserPrompt?: string;
196
+ transcriptCharLength: number;
197
+ isTrivial: boolean;
198
+ };
199
+
200
+ type TranscriptBlock = {
201
+ kind:
202
+ | "user"
203
+ | "assistant"
204
+ | "toolCall"
205
+ | "toolResult"
206
+ | "bashExecution"
207
+ | "compactionSummary"
208
+ | "branchSummary";
209
+ text: string;
210
+ isSummary?: boolean;
211
+ };
212
+
213
+ type NormalizedTranscript = {
214
+ sessionFile: string;
215
+ fingerprint: string;
216
+ blockCount: number;
217
+ charLength: number;
218
+ text?: string;
219
+ blocks: TranscriptBlock[];
220
+ headerText: string;
221
+ };
222
+
223
+ type PreparedTranscript = {
224
+ text: string;
225
+ fullCharLength: number;
226
+ usedReducedTranscript: boolean;
227
+ };
228
+
229
+ type SessionFacet = {
230
+ schemaVersion: number;
231
+ promptVersion: number;
232
+ fingerprint: string;
233
+ underlyingGoal: string;
234
+ goalCategories: string[];
235
+ outcome: "achieved" | "partial" | "blocked" | "unclear";
236
+ frictionCategories: string[];
237
+ frictionDetail?: string;
238
+ briefSummary: string;
239
+ explicitInstructionsToRemember: string[];
240
+ repeatedWorkflowHints: string[];
241
+ };
242
+
243
+ type SessionAnalysis = {
244
+ meta: SessionMeta;
245
+ facet?: SessionFacet;
246
+ };
247
+
248
+ type RepeatedEvidence = {
249
+ text: string;
250
+ count: number;
251
+ };
252
+
253
+ type InsightsAggregate = {
254
+ scope: InsightScope;
255
+ sessionsConsidered: number;
256
+ sessionsAnalyzed: number;
257
+ sessionsWithFacets: number;
258
+ sessionsSkipped: number;
259
+ dateRange: {
260
+ start?: string;
261
+ end?: string;
262
+ };
263
+ projects: Record<string, number>;
264
+ goalCategories: Record<string, number>;
265
+ frictionCategories: Record<string, number>;
266
+ toolCounts: Record<string, number>;
267
+ languages: Record<string, number>;
268
+ totalInputTokens: number;
269
+ totalOutputTokens: number;
270
+ totalCost?: number;
271
+ repeatedInstructions: RepeatedEvidence[];
272
+ repeatedWorkflows: RepeatedEvidence[];
273
+ representativeSessions: Array<{
274
+ sessionFile: string;
275
+ cwd?: string;
276
+ startedAt?: string;
277
+ summary: string;
278
+ goal: string;
279
+ outcome: SessionFacet["outcome"];
280
+ }>;
281
+ };
282
+
283
+ type InsightsResult = {
284
+ scope: InsightScope;
285
+ generatedAt: string;
286
+ modelId?: string;
287
+ aggregate: InsightsAggregate;
288
+ reportMarkdown: string;
289
+ };
290
+
291
+ type ProgressStage = "resolve" | "metadata" | "facets" | "aggregate" | "synthesize";
292
+
293
+ type ProgressState = {
294
+ stage: ProgressStage;
295
+ current: number;
296
+ total: number;
297
+ metaCacheHits: number;
298
+ facetCacheHits: number;
299
+ };
300
+
301
+ class AbortError extends Error {
302
+ constructor() {
303
+ super("Aborted");
304
+ this.name = "AbortError";
305
+ }
306
+ }
307
+
308
+ class InsightsReportComponent implements Component {
309
+ private readonly markdown: Markdown;
310
+ private scrollOffset = 0;
311
+ private cachedWidth?: number;
312
+ private cachedLines?: string[];
313
+ private cachedBodyWidth?: number;
314
+ private cachedBodyLines?: string[];
315
+
316
+ constructor(
317
+ private readonly result: InsightsResult,
318
+ private readonly tui: TUI,
319
+ private readonly theme: Theme,
320
+ private readonly onDone: () => void,
321
+ ) {
322
+ this.markdown = new Markdown(result.reportMarkdown, 0, 0, getMarkdownTheme());
323
+ }
324
+
325
+ invalidate(): void {
326
+ this.cachedWidth = undefined;
327
+ this.cachedLines = undefined;
328
+ this.cachedBodyWidth = undefined;
329
+ this.cachedBodyLines = undefined;
330
+ this.markdown.invalidate();
331
+ }
332
+
333
+ handleInput(data: string): void {
334
+ if (
335
+ matchesKey(data, Key.enter) ||
336
+ matchesKey(data, Key.escape) ||
337
+ matchesKey(data, Key.ctrl("c")) ||
338
+ data.toLowerCase() === "q"
339
+ ) {
340
+ this.onDone();
341
+ return;
342
+ }
343
+
344
+ const bodyHeight = this.getBodyHeight();
345
+ const boxWidth = this.getBoxWidth(this.tui.terminal.columns);
346
+ const bodyLines = this.getBodyLines(this.getContentWidth(boxWidth));
347
+ const maxScroll = Math.max(0, bodyLines.length - bodyHeight);
348
+
349
+ if (matchesKey(data, Key.up) || data.toLowerCase() === "k") {
350
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
351
+ } else if (matchesKey(data, Key.down) || data.toLowerCase() === "j") {
352
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
353
+ } else if (matchesKey(data, Key.pageUp)) {
354
+ this.scrollOffset = Math.max(0, this.scrollOffset - Math.max(4, bodyHeight - 2));
355
+ } else if (matchesKey(data, Key.pageDown)) {
356
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + Math.max(4, bodyHeight - 2));
357
+ } else if (matchesKey(data, Key.home)) {
358
+ this.scrollOffset = 0;
359
+ } else if (matchesKey(data, Key.end)) {
360
+ this.scrollOffset = maxScroll;
361
+ } else {
362
+ return;
363
+ }
364
+
365
+ this.invalidateFrame();
366
+ this.tui.requestRender();
367
+ }
368
+
369
+ render(width: number): string[] {
370
+ if (this.cachedWidth === width && this.cachedLines) {
371
+ return this.cachedLines;
372
+ }
373
+
374
+ const boxWidth = this.getBoxWidth(width);
375
+ const contentWidth = this.getContentWidth(boxWidth);
376
+ const bodyLines = this.getBodyLines(contentWidth);
377
+ const bodyHeight = this.getBodyHeight();
378
+ const maxScroll = Math.max(0, bodyLines.length - bodyHeight);
379
+ this.scrollOffset = clamp(this.scrollOffset, 0, maxScroll);
380
+
381
+ const title = this.theme.fg("accent", this.theme.bold("Insights"));
382
+ const metadata = [
383
+ `${this.result.aggregate.scope} scope · ${formatCount(this.result.aggregate.sessionsAnalyzed)} analyzed · ${formatCount(this.result.aggregate.sessionsWithFacets)} classified`,
384
+ `${this.result.modelId ?? "current model"} · generated ${formatIsoShort(this.result.generatedAt)}`,
385
+ ];
386
+
387
+ const lines: string[] = [];
388
+ lines.push(this.borderLine("╭", "╮", boxWidth));
389
+ lines.push(this.boxLine(title, boxWidth));
390
+ for (const line of metadata) {
391
+ for (const wrapped of wrapTextWithAnsi(this.theme.fg("muted", line), contentWidth)) {
392
+ lines.push(this.boxLine(wrapped, boxWidth));
393
+ }
394
+ }
395
+ lines.push(this.separatorLine(boxWidth));
396
+
397
+ const visibleBody = bodyLines.slice(this.scrollOffset, this.scrollOffset + bodyHeight);
398
+ for (const line of visibleBody) {
399
+ lines.push(this.boxLine(line, boxWidth));
400
+ }
401
+ for (let i = visibleBody.length; i < bodyHeight; i++) {
402
+ lines.push(this.boxLine("", boxWidth));
403
+ }
404
+
405
+ lines.push(this.separatorLine(boxWidth));
406
+ const scrollText = `${formatCount(Math.min(bodyLines.length, this.scrollOffset + 1))}-${formatCount(Math.min(bodyLines.length, this.scrollOffset + visibleBody.length))}/${formatCount(bodyLines.length)}`;
407
+ const controls = `${this.theme.fg("dim", "↑↓ scroll · PgUp/PgDn jump · Home/End · Enter/Esc close")} ${this.theme.fg("muted", scrollText)}`;
408
+ lines.push(this.boxLine(truncateToWidth(controls, contentWidth), boxWidth));
409
+ lines.push(this.borderLine("╰", "╯", boxWidth));
410
+
411
+ this.cachedWidth = width;
412
+ this.cachedLines = lines;
413
+ return lines;
414
+ }
415
+
416
+ private getBodyHeight(): number {
417
+ return Math.max(10, this.tui.terminal.rows - 10);
418
+ }
419
+
420
+ private invalidateFrame(): void {
421
+ this.cachedWidth = undefined;
422
+ this.cachedLines = undefined;
423
+ }
424
+
425
+ private getBodyLines(contentWidth: number): string[] {
426
+ if (this.cachedBodyWidth === contentWidth && this.cachedBodyLines) {
427
+ return this.cachedBodyLines;
428
+ }
429
+
430
+ const lines = this.markdown.render(contentWidth);
431
+ this.cachedBodyWidth = contentWidth;
432
+ this.cachedBodyLines = lines;
433
+ return lines;
434
+ }
435
+
436
+ private getBoxWidth(width: number): number {
437
+ return Math.max(40, Math.min(width - 2, 140));
438
+ }
439
+
440
+ private getContentWidth(boxWidth: number): number {
441
+ return Math.max(10, boxWidth - 4);
442
+ }
443
+
444
+ private borderLine(left: string, right: string, width: number): string {
445
+ return this.theme.fg("borderMuted", `${left}${"─".repeat(width - 2)}${right}`);
446
+ }
447
+
448
+ private separatorLine(width: number): string {
449
+ return this.theme.fg("borderMuted", `├${"─".repeat(width - 2)}┤`);
450
+ }
451
+
452
+ private boxLine(content: string, width: number): string {
453
+ const padded = ` ${content}`;
454
+ const visible = visibleWidth(padded);
455
+ const rightPad = Math.max(0, width - 2 - visible);
456
+ return `${this.theme.fg("borderMuted", "│")}${padded}${" ".repeat(rightPad)}${this.theme.fg("borderMuted", "│")}`;
457
+ }
458
+ }
459
+
460
+ // --- Helpers ---
461
+
462
+ export default function insightsExtension(pi: ExtensionAPI): void {
463
+ pi.registerCommand("insights", {
464
+ description:
465
+ "Analyze Pi sessions and suggest reusable instructions, templates, skills, and extensions",
466
+ getArgumentCompletions: (prefix) => getScopeCompletions(prefix),
467
+ handler: async (args, ctx) => {
468
+ await runInsightsCommand(args, ctx);
469
+ },
470
+ });
471
+ }
472
+
473
+ async function runInsightsCommand(
474
+ args: string | undefined,
475
+ ctx: ExtensionCommandContext,
476
+ ): Promise<void> {
477
+ const parsed = parseArgs(args);
478
+ if ("error" in parsed) {
479
+ ctx.ui.notify(parsed.error, "warning");
480
+ return;
481
+ }
482
+
483
+ if (!ctx.hasUI) {
484
+ ctx.ui.notify("/insights currently requires interactive mode", "warning");
485
+ return;
486
+ }
487
+
488
+ let aborted = false;
489
+ let caughtError: unknown;
490
+ const selectionPromise = getConfiguredModelSelection(ctx);
491
+
492
+ const result = await ctx.ui.custom<InsightsResult | null>((tui, theme, _kb, done) => {
493
+ const loader = new BorderedLoader(tui, theme, "Analyzing sessions...");
494
+ const progress: ProgressState = {
495
+ stage: "resolve",
496
+ current: 0,
497
+ total: 0,
498
+ metaCacheHits: 0,
499
+ facetCacheHits: 0,
500
+ };
501
+
502
+ const startedAt = Date.now();
503
+ let interval: NodeJS.Timeout | null = null;
504
+
505
+ const renderMessage = (): string => {
506
+ const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
507
+ return renderProgressMessage(progress, elapsed);
508
+ };
509
+
510
+ const stopTicker = () => {
511
+ if (interval) {
512
+ clearInterval(interval);
513
+ interval = null;
514
+ }
515
+ };
516
+
517
+ setBorderedLoaderMessage(loader, renderMessage());
518
+ interval = setInterval(() => {
519
+ setBorderedLoaderMessage(loader, renderMessage());
520
+ }, 1000);
521
+
522
+ loader.onAbort = () => {
523
+ aborted = true;
524
+ stopTicker();
525
+ done(null);
526
+ };
527
+
528
+ void (async () => {
529
+ try {
530
+ const selection = await selectionPromise;
531
+ const runResult = await runInsightsPipeline(
532
+ parsed.scope,
533
+ ctx,
534
+ selection,
535
+ loader.signal,
536
+ (update) => {
537
+ Object.assign(progress, update);
538
+ },
539
+ );
540
+ stopTicker();
541
+ if (!aborted) done(runResult);
542
+ } catch (error) {
543
+ stopTicker();
544
+ caughtError = error;
545
+ if (!aborted) done(null);
546
+ }
547
+ })();
548
+
549
+ return loader;
550
+ });
551
+
552
+ if (!result) {
553
+ if (aborted || isAbortError(caughtError)) {
554
+ ctx.ui.notify("Cancelled", "info");
555
+ return;
556
+ }
557
+
558
+ const message =
559
+ caughtError instanceof Error ? caughtError.message : "Failed to analyze sessions";
560
+ ctx.ui.notify(message, "error");
561
+ return;
562
+ }
563
+
564
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
565
+ return new InsightsReportComponent(result, tui, theme, done);
566
+ });
567
+ }
568
+
569
+ async function runInsightsPipeline(
570
+ scope: InsightScope,
571
+ ctx: ExtensionCommandContext,
572
+ selection: ConfiguredModelSelection | null,
573
+ signal: AbortSignal,
574
+ onProgress: (update: Partial<ProgressState>) => void,
575
+ ): Promise<InsightsResult> {
576
+ if (!selection) {
577
+ throw new Error("No configured Pi model is available for /insights");
578
+ }
579
+
580
+ const targets = await listTargets(scope, ctx, signal, onProgress);
581
+ throwIfAborted(signal);
582
+
583
+ const analyses: SessionAnalysis[] = [];
584
+ let metaCacheHits = 0;
585
+ let facetCacheHits = 0;
586
+ let facetAttempts = 0;
587
+ let facetFailures = 0;
588
+ let lastFacetError: unknown;
589
+
590
+ for (let index = 0; index < targets.length; index++) {
591
+ throwIfAborted(signal);
592
+ onProgress({
593
+ stage: "metadata",
594
+ current: index + 1,
595
+ total: targets.length,
596
+ metaCacheHits,
597
+ facetCacheHits,
598
+ });
599
+
600
+ const target = targets[index]!;
601
+ try {
602
+ const loaded = await loadOrExtractMeta(target, signal);
603
+ if (loaded.cacheHit) metaCacheHits++;
604
+ onProgress({ metaCacheHits, facetCacheHits });
605
+ if (!loaded.meta || loaded.meta.isTrivial) continue;
606
+
607
+ let facet: SessionFacet | undefined;
608
+ facetAttempts++;
609
+ onProgress({
610
+ stage: "facets",
611
+ current: index + 1,
612
+ total: targets.length,
613
+ metaCacheHits,
614
+ facetCacheHits,
615
+ });
616
+
617
+ try {
618
+ const classified = await loadOrExtractFacet(
619
+ target,
620
+ loaded.meta,
621
+ loaded.cacheInfo,
622
+ loaded.runtime,
623
+ selection,
624
+ signal,
625
+ );
626
+ if (classified.cacheHit) {
627
+ facetCacheHits++;
628
+ onProgress({ facetCacheHits });
629
+ }
630
+ facet = classified.facet;
631
+ } catch (error) {
632
+ if (isAbortError(error)) throw error;
633
+ facetFailures++;
634
+ lastFacetError = error;
635
+ } finally {
636
+ if (loaded.runtime) {
637
+ loaded.runtime.transcript = undefined;
638
+ }
639
+ }
640
+
641
+ analyses.push(facet ? { meta: loaded.meta, facet } : { meta: loaded.meta });
642
+ } catch (error) {
643
+ if (isAbortError(error)) throw error;
644
+ // Skip malformed or unreadable sessions.
645
+ continue;
646
+ }
647
+ }
648
+
649
+ if (analyses.length === 0) {
650
+ const aggregate = buildAggregate(scope, targets.length, analyses);
651
+ return {
652
+ scope,
653
+ generatedAt: new Date().toISOString(),
654
+ modelId: selection.model.id,
655
+ aggregate,
656
+ reportMarkdown: buildDeterministicReport(aggregate),
657
+ };
658
+ }
659
+
660
+ if (facetAttempts > 0 && facetFailures === facetAttempts && lastFacetError) {
661
+ throw lastFacetError;
662
+ }
663
+
664
+ throwIfAborted(signal);
665
+ onProgress({ stage: "aggregate", current: 1, total: 1, metaCacheHits, facetCacheHits });
666
+ const aggregate = buildAggregate(scope, targets.length, analyses);
667
+
668
+ throwIfAborted(signal);
669
+ onProgress({ stage: "synthesize", current: 1, total: 1, metaCacheHits, facetCacheHits });
670
+
671
+ let reportMarkdown = buildDeterministicReport(aggregate);
672
+ if (aggregate.sessionsWithFacets > 0) {
673
+ try {
674
+ reportMarkdown = await synthesizeReport(aggregate, selection, signal);
675
+ } catch (error) {
676
+ if (isAbortError(error)) throw error;
677
+ reportMarkdown = prependReportNotice(
678
+ buildDeterministicReport(aggregate),
679
+ "Final synthesis failed. Showing deterministic fallback.",
680
+ );
681
+ }
682
+ }
683
+
684
+ return {
685
+ scope,
686
+ generatedAt: new Date().toISOString(),
687
+ modelId: selection.model.id,
688
+ aggregate,
689
+ reportMarkdown,
690
+ };
691
+ }
692
+
693
+ function parseArgs(args: string | undefined): { scope: InsightScope } | { error: string } {
694
+ const trimmed = args?.trim() ?? "";
695
+ if (!trimmed) return { scope: "project" };
696
+
697
+ const eqMatch = trimmed.match(/^scope=(current|project|all)$/);
698
+ if (eqMatch) {
699
+ return { scope: eqMatch[1] as InsightScope };
700
+ }
701
+
702
+ return { error: "Usage: /insights [scope=current|project|all]" };
703
+ }
704
+
705
+ function getScopeCompletions(
706
+ prefix: string,
707
+ ): Array<{ value: string; label: string; description: string }> | null {
708
+ const trimmed = prefix.trimStart();
709
+ const options: InsightScope[] = ["current", "project", "all"];
710
+
711
+ if (!trimmed) {
712
+ return [{ value: "scope=", label: "scope=", description: "Choose current, project, or all" }];
713
+ }
714
+
715
+ if (trimmed === "scope=") {
716
+ return options.map((value) => ({
717
+ value: `scope=${value}`,
718
+ label: value,
719
+ description: describeScope(value),
720
+ }));
721
+ }
722
+
723
+ const eqMatch = trimmed.match(/^scope=(\S*)$/);
724
+ if (!eqMatch) return null;
725
+
726
+ const valuePrefix = eqMatch[1] ?? "";
727
+ const items = options
728
+ .filter((value) => value.startsWith(valuePrefix))
729
+ .map((value) => ({ value: `scope=${value}`, label: value, description: describeScope(value) }));
730
+ return items.length > 0 ? items : null;
731
+ }
732
+
733
+ function describeScope(scope: InsightScope): string {
734
+ switch (scope) {
735
+ case "current":
736
+ return "Analyze only the active session";
737
+ case "project":
738
+ return "Analyze sessions for the current cwd";
739
+ case "all":
740
+ return "Analyze all Pi sessions";
741
+ }
742
+ }
743
+
744
+ async function getConfiguredModelSelection(
745
+ ctx: Pick<ExtensionCommandContext, "model" | "modelRegistry">,
746
+ ): Promise<ConfiguredModelSelection | null> {
747
+ if (!ctx.model) return null;
748
+
749
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
750
+ if (!auth.ok) return null;
751
+ return { model: ctx.model, apiKey: auth.apiKey, headers: auth.headers };
752
+ }
753
+
754
+ async function listTargets(
755
+ scope: InsightScope,
756
+ ctx: ExtensionCommandContext,
757
+ signal: AbortSignal,
758
+ onProgress: (update: Partial<ProgressState>) => void,
759
+ ): Promise<SessionTarget[]> {
760
+ throwIfAborted(signal);
761
+
762
+ if (scope === "current") {
763
+ onProgress({ stage: "resolve", current: 1, total: 1 });
764
+ return [{ path: ctx.sessionManager.getSessionFile(), manager: ctx.sessionManager }];
765
+ }
766
+
767
+ const progress = (current: number, total: number): void => {
768
+ onProgress({ stage: "resolve", current, total });
769
+ };
770
+
771
+ const sessionFiles =
772
+ scope === "project"
773
+ ? await listProjectSessionFiles(ctx.cwd, ctx.sessionManager.getSessionDir(), progress)
774
+ : await listAllSessionFiles(progress);
775
+
776
+ throwIfAborted(signal);
777
+ return sessionFiles.map((sessionFile) => ({ path: sessionFile }));
778
+ }
779
+
780
+ async function listProjectSessionFiles(
781
+ cwd: string,
782
+ sessionDir: string,
783
+ onProgress: (current: number, total: number) => void,
784
+ ): Promise<string[]> {
785
+ const currentCwd = path.resolve(cwd);
786
+ const sessions = await SessionManager.list(currentCwd, sessionDir, onProgress);
787
+ return sessions
788
+ .filter((session) => path.resolve(session.cwd || currentCwd) === currentCwd)
789
+ .map((session) => session.path);
790
+ }
791
+
792
+ async function listAllSessionFiles(
793
+ onProgress: (current: number, total: number) => void,
794
+ ): Promise<string[]> {
795
+ const sessions = await SessionManager.listAll(onProgress);
796
+ return sessions.map((session) => session.path);
797
+ }
798
+
799
+ async function loadOrExtractMeta(
800
+ target: SessionTarget,
801
+ signal: AbortSignal,
802
+ ): Promise<{
803
+ meta: SessionMeta | null;
804
+ cacheInfo: SessionCacheInfo;
805
+ cacheHit: boolean;
806
+ runtime?: SessionRuntime;
807
+ }> {
808
+ const cacheInfo = await getCacheInfo(target.path);
809
+
810
+ if (cacheInfo.cacheKey && cacheInfo.fingerprint) {
811
+ const cached = await readMetaCache(cacheInfo.cacheKey, cacheInfo.fingerprint);
812
+ if (cached) {
813
+ return { meta: cached, cacheInfo, cacheHit: true };
814
+ }
815
+ }
816
+
817
+ throwIfAborted(signal);
818
+ const manager = await openTargetSession(target);
819
+ const extracted = extractSessionArtifacts(manager, target, cacheInfo.fingerprint);
820
+
821
+ if (cacheInfo.cacheKey && cacheInfo.fingerprint) {
822
+ try {
823
+ await writeMetaCache(cacheInfo.cacheKey, extracted.meta, signal);
824
+ } catch {
825
+ // Cache writes are best-effort.
826
+ }
827
+ }
828
+
829
+ return {
830
+ meta: extracted.meta,
831
+ cacheInfo,
832
+ cacheHit: false,
833
+ runtime: extracted.transcript ? { manager, transcript: extracted.transcript } : undefined,
834
+ };
835
+ }
836
+
837
+ async function loadOrExtractFacet(
838
+ target: SessionTarget,
839
+ meta: SessionMeta,
840
+ cacheInfo: SessionCacheInfo,
841
+ runtime: SessionRuntime | undefined,
842
+ selection: ConfiguredModelSelection,
843
+ signal: AbortSignal,
844
+ ): Promise<{ facet?: SessionFacet; cacheHit: boolean }> {
845
+ if (cacheInfo.cacheKey) {
846
+ const cached = await readFacetCache(cacheInfo.cacheKey, meta.fingerprint);
847
+ if (cached) {
848
+ return { facet: cached, cacheHit: true };
849
+ }
850
+ }
851
+
852
+ throwIfAborted(signal);
853
+ const manager = runtime?.manager ?? (await openTargetSession(target));
854
+ const transcript =
855
+ runtime?.transcript ??
856
+ buildNormalizedTranscript(manager.getEntries(), manager.getLeafId(), meta);
857
+ const preparedTranscript = prepareTranscriptForFacet(transcript);
858
+ const facet = await extractFacet(meta, preparedTranscript, selection, signal);
859
+
860
+ if (cacheInfo.cacheKey) {
861
+ try {
862
+ await writeFacetCache(cacheInfo.cacheKey, facet, signal);
863
+ } catch {
864
+ // Cache writes are best-effort.
865
+ }
866
+ }
867
+
868
+ return { facet, cacheHit: false };
869
+ }
870
+
871
+ async function openTargetSession(target: SessionTarget): Promise<ReadonlySessionManager> {
872
+ if (target.manager) return target.manager;
873
+ if (!target.path) {
874
+ throw new Error("Session has no file path");
875
+ }
876
+ return await readOnlyOpenSession(target.path);
877
+ }
878
+
879
+ async function readOnlyOpenSession(sessionFile: string): Promise<ReadonlySessionManager> {
880
+ const content = await fs.readFile(sessionFile, "utf8");
881
+ const fileEntries = parseSessionEntries(content);
882
+ if (fileEntries.length === 0) {
883
+ throw new Error(`Session file is empty or invalid: ${sessionFile}`);
884
+ }
885
+
886
+ migrateSessionEntries(fileEntries);
887
+ const header = fileEntries.find((entry): entry is SessionHeader => entry.type === "session");
888
+ if (!header) {
889
+ throw new Error(`Session file has no header: ${sessionFile}`);
890
+ }
891
+
892
+ const entries = fileEntries.filter((entry): entry is SessionEntry => entry.type !== "session");
893
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
894
+ const leafId = entries.at(-1)?.id ?? null;
895
+ const sessionDir = path.dirname(sessionFile);
896
+
897
+ return {
898
+ getEntries() {
899
+ return entries.slice();
900
+ },
901
+ getBranch(fromId?: string) {
902
+ const startId = fromId ?? leafId;
903
+ if (!startId) return [];
904
+ const pathEntries: SessionEntry[] = [];
905
+ let current = byId.get(startId);
906
+ while (current) {
907
+ pathEntries.unshift(current);
908
+ current = current.parentId ? byId.get(current.parentId) : undefined;
909
+ }
910
+ return pathEntries;
911
+ },
912
+ getLeafId() {
913
+ return leafId;
914
+ },
915
+ getHeader() {
916
+ return header;
917
+ },
918
+ getSessionDir() {
919
+ return sessionDir;
920
+ },
921
+ getSessionId() {
922
+ return header.id;
923
+ },
924
+ getSessionFile() {
925
+ return sessionFile;
926
+ },
927
+ } satisfies ReadonlySessionManager;
928
+ }
929
+
930
+ function extractSessionArtifacts(
931
+ manager: ReadonlySessionManager,
932
+ target: SessionTarget,
933
+ fingerprint: string | undefined,
934
+ ): { meta: SessionMeta; transcript?: NormalizedTranscript } {
935
+ const header = manager.getHeader();
936
+ const entries = manager.getEntries();
937
+ const leafId = manager.getLeafId();
938
+ const branchEntries = manager.getBranch();
939
+ const normalizedCwd = normalizeOptionalText(header?.cwd);
940
+
941
+ const startedAt = firstTimestamp(branchEntries) ?? header?.timestamp;
942
+ const endedAt = lastTimestamp(branchEntries) ?? header?.timestamp;
943
+ const durationMinutes = computeDurationMinutes(startedAt, endedAt);
944
+
945
+ const toolCounts: Record<string, number> = {};
946
+ const toolsWithErrors: Record<string, number> = {};
947
+ const modifiedFiles = new Set<string>();
948
+
949
+ let meaningfulUserMessages = 0;
950
+ let assistantMessages = 0;
951
+ let toolResultMessages = 0;
952
+ let totalInputTokens = 0;
953
+ let totalOutputTokens = 0;
954
+ let totalCost = 0;
955
+ let firstUserPrompt: string | undefined;
956
+
957
+ for (const entry of branchEntries) {
958
+ collectModifiedFilesFromEntry(entry, modifiedFiles);
959
+
960
+ if (entry.type !== "message") continue;
961
+ const message = entry.message as unknown as Record<string, unknown>;
962
+ const role = typeof message.role === "string" ? message.role : "";
963
+
964
+ if (role === "user") {
965
+ if (isMeaningfulUserMessage(message)) {
966
+ meaningfulUserMessages++;
967
+ if (!firstUserPrompt) {
968
+ firstUserPrompt = cleanOneLine(extractMessageText(message.content, true), 240);
969
+ }
970
+ }
971
+ continue;
972
+ }
973
+
974
+ if (role === "assistant") {
975
+ assistantMessages++;
976
+ const usage = isRecord(message.usage) ? message.usage : undefined;
977
+ totalInputTokens +=
978
+ numberValue(usage?.input) + numberValue(usage?.cacheRead) + numberValue(usage?.cacheWrite);
979
+ totalOutputTokens += numberValue(usage?.output);
980
+ totalCost += numberValue(isRecord(usage?.cost) ? usage?.cost.total : undefined);
981
+
982
+ const toolCalls = extractToolCalls(message.content);
983
+ for (const toolCall of toolCalls) {
984
+ incrementRecord(toolCounts, toolCall.name);
985
+ collectModifiedFilesFromToolCall(toolCall.name, toolCall.arguments, modifiedFiles);
986
+ }
987
+ continue;
988
+ }
989
+
990
+ if (role === "toolResult") {
991
+ toolResultMessages++;
992
+ if (message.isError === true && typeof message.toolName === "string") {
993
+ incrementRecord(toolsWithErrors, message.toolName);
994
+ }
995
+ }
996
+ }
997
+
998
+ const resolvedFingerprint =
999
+ fingerprint ??
1000
+ fallbackFingerprint(
1001
+ entries.length,
1002
+ manager.getSessionId(),
1003
+ leafId,
1004
+ lastTimestamp(branchEntries),
1005
+ target.path,
1006
+ );
1007
+
1008
+ const baseMeta: Omit<SessionMeta, "transcriptCharLength" | "isTrivial"> = {
1009
+ schemaVersion: INSIGHTS_META_SCHEMA_VERSION,
1010
+ sessionFile: normalizeSessionFile(target.path),
1011
+ sessionId: header?.id,
1012
+ cwd: normalizedCwd,
1013
+ fingerprint: resolvedFingerprint,
1014
+ startedAt,
1015
+ endedAt,
1016
+ durationMinutes,
1017
+ totalEntries: entries.length,
1018
+ pathEntries: branchEntries.length,
1019
+ branchCount: countBranchPoints(entries),
1020
+ compactionCount: entries.filter((entry: SessionEntry) => entry.type === "compaction").length,
1021
+ branchSummaryCount: entries.filter((entry: SessionEntry) => entry.type === "branch_summary")
1022
+ .length,
1023
+ meaningfulUserMessages,
1024
+ assistantMessages,
1025
+ toolResultMessages,
1026
+ totalInputTokens,
1027
+ totalOutputTokens,
1028
+ totalCost: totalCost > 0 ? roundNumber(totalCost, 6) : undefined,
1029
+ toolCounts,
1030
+ toolsWithErrors,
1031
+ modifiedFiles: [...modifiedFiles].sort(),
1032
+ languages: buildLanguageSummary(modifiedFiles),
1033
+ firstUserPrompt,
1034
+ };
1035
+
1036
+ if (
1037
+ meaningfulUserMessages < MIN_MEANINGFUL_USER_MESSAGES ||
1038
+ durationMinutes < MIN_DURATION_MINUTES
1039
+ ) {
1040
+ return {
1041
+ meta: {
1042
+ ...baseMeta,
1043
+ transcriptCharLength: 0,
1044
+ isTrivial: true,
1045
+ },
1046
+ };
1047
+ }
1048
+
1049
+ const transcript = buildNormalizedTranscript(entries, leafId, {
1050
+ sessionFile: normalizeSessionFile(target.path),
1051
+ sessionId: header?.id,
1052
+ cwd: normalizedCwd,
1053
+ fingerprint: resolvedFingerprint,
1054
+ startedAt,
1055
+ durationMinutes,
1056
+ });
1057
+
1058
+ return {
1059
+ meta: {
1060
+ ...baseMeta,
1061
+ transcriptCharLength: transcript.charLength,
1062
+ isTrivial: transcript.blockCount === 0,
1063
+ },
1064
+ transcript,
1065
+ };
1066
+ }
1067
+
1068
+ function buildNormalizedTranscript(
1069
+ entries: SessionEntry[],
1070
+ leafId: string | null,
1071
+ meta: Pick<
1072
+ SessionMeta,
1073
+ "sessionFile" | "sessionId" | "cwd" | "startedAt" | "durationMinutes" | "fingerprint"
1074
+ >,
1075
+ ): NormalizedTranscript {
1076
+ const context = buildSessionContext(entries, leafId);
1077
+ const headerLines = [
1078
+ `Session: ${meta.sessionId ?? path.basename(meta.sessionFile)}`,
1079
+ `CWD: ${meta.cwd ?? "(unknown)"}`,
1080
+ `Started: ${meta.startedAt ?? "(unknown)"}`,
1081
+ `Duration: ${formatDuration(meta.durationMinutes)}`,
1082
+ ];
1083
+ const headerText = headerLines.join("\n");
1084
+
1085
+ const blocks: TranscriptBlock[] = [];
1086
+ let bodyCharLength = 0;
1087
+ const pushBlock = (block: TranscriptBlock): void => {
1088
+ bodyCharLength += block.text.length;
1089
+ if (blocks.length > 0) bodyCharLength += 2;
1090
+ blocks.push(block);
1091
+ };
1092
+
1093
+ for (const rawMessage of context.messages) {
1094
+ const message = rawMessage as unknown as Record<string, unknown>;
1095
+ const role = typeof message.role === "string" ? message.role : "";
1096
+
1097
+ if (role === "user") {
1098
+ const text = extractMessageText(message.content, true).trim();
1099
+ if (!text) continue;
1100
+ pushBlock({ kind: "user", text: `[User]\n${text}` });
1101
+ continue;
1102
+ }
1103
+
1104
+ if (role === "assistant") {
1105
+ const assistantText = extractAssistantText(message.content).trim();
1106
+ if (assistantText) {
1107
+ pushBlock({ kind: "assistant", text: `[Assistant]\n${assistantText}` });
1108
+ }
1109
+ for (const toolCall of extractToolCalls(message.content)) {
1110
+ pushBlock({
1111
+ kind: "toolCall",
1112
+ text: `[ToolCall ${toolCall.name}]\n${summarizeToolCall(toolCall.name, toolCall.arguments)}`,
1113
+ });
1114
+ }
1115
+ continue;
1116
+ }
1117
+
1118
+ if (role === "toolResult") {
1119
+ const summary = summarizeToolResult(message);
1120
+ if (summary) {
1121
+ const toolName = typeof message.toolName === "string" ? message.toolName : "tool";
1122
+ pushBlock({ kind: "toolResult", text: `[ToolResult ${toolName}]\n${summary}` });
1123
+ }
1124
+ continue;
1125
+ }
1126
+
1127
+ if (role === "custom") {
1128
+ const customType =
1129
+ typeof message.customType === "string" ? message.customType.trim() : "custom";
1130
+ const customText = extractMessageTextPreview(message.content, true, 4_000).trim();
1131
+ if (!customText) continue;
1132
+ pushBlock({ kind: "assistant", text: `[Custom ${customType}]\n${customText}` });
1133
+ continue;
1134
+ }
1135
+
1136
+ if (role === "bashExecution") {
1137
+ if (message.excludeFromContext === true) continue;
1138
+ const command = typeof message.command === "string" ? message.command.trim() : "";
1139
+ if (!command) continue;
1140
+ const output =
1141
+ typeof message.output === "string" ? summarizeBoundedFreeText(message.output, 220) : "";
1142
+ const exitCode = typeof message.exitCode === "number" ? message.exitCode : undefined;
1143
+ const parts = [`command: ${command}`];
1144
+ if (typeof exitCode === "number") parts.push(`exit: ${exitCode}`);
1145
+ if (output) parts.push(`summary: ${output}`);
1146
+ pushBlock({ kind: "bashExecution", text: `[UserBash]\n${parts.join("\n")}` });
1147
+ continue;
1148
+ }
1149
+
1150
+ if (role === "compactionSummary") {
1151
+ const summary = typeof message.summary === "string" ? message.summary.trim() : "";
1152
+ if (!summary) continue;
1153
+ pushBlock({
1154
+ kind: "compactionSummary",
1155
+ isSummary: true,
1156
+ text: `[CompactionSummary]\n${summary}`,
1157
+ });
1158
+ continue;
1159
+ }
1160
+
1161
+ if (role === "branchSummary") {
1162
+ const summary = typeof message.summary === "string" ? message.summary.trim() : "";
1163
+ if (!summary) continue;
1164
+ pushBlock({ kind: "branchSummary", isSummary: true, text: `[BranchSummary]\n${summary}` });
1165
+ }
1166
+ }
1167
+
1168
+ const charLength = headerText.length + (blocks.length > 0 ? bodyCharLength + 2 : 0);
1169
+ const text =
1170
+ charLength <= FULL_TRANSCRIPT_MAX_CHARS
1171
+ ? [headerText, blocks.map((block) => block.text).join("\n\n")]
1172
+ .filter(Boolean)
1173
+ .join("\n\n")
1174
+ .trim()
1175
+ : undefined;
1176
+
1177
+ return {
1178
+ sessionFile: meta.sessionFile,
1179
+ fingerprint: meta.fingerprint,
1180
+ blockCount: blocks.length,
1181
+ charLength,
1182
+ text,
1183
+ blocks,
1184
+ headerText,
1185
+ };
1186
+ }
1187
+
1188
+ function prepareTranscriptForFacet(transcript: NormalizedTranscript): PreparedTranscript {
1189
+ if (transcript.charLength <= FULL_TRANSCRIPT_MAX_CHARS) {
1190
+ return {
1191
+ text:
1192
+ transcript.text ??
1193
+ [transcript.headerText, transcript.blocks.map((block) => block.text).join("\n\n")]
1194
+ .filter(Boolean)
1195
+ .join("\n\n")
1196
+ .trim(),
1197
+ fullCharLength: transcript.charLength,
1198
+ usedReducedTranscript: false,
1199
+ };
1200
+ }
1201
+
1202
+ const selected = new Set<number>();
1203
+ let headChars = 0;
1204
+ for (let index = 0; index < transcript.blocks.length; index++) {
1205
+ if (headChars >= REDUCED_TRANSCRIPT_HEAD_CHARS) break;
1206
+ selected.add(index);
1207
+ headChars += transcript.blocks[index]!.text.length + 2;
1208
+ }
1209
+
1210
+ let tailChars = 0;
1211
+ for (let index = transcript.blocks.length - 1; index >= 0; index--) {
1212
+ if (tailChars >= REDUCED_TRANSCRIPT_TAIL_CHARS) break;
1213
+ if (selected.has(index)) continue;
1214
+ selected.add(index);
1215
+ tailChars += transcript.blocks[index]!.text.length + 2;
1216
+ }
1217
+
1218
+ for (let index = 0; index < transcript.blocks.length; index++) {
1219
+ if (transcript.blocks[index]!.isSummary) {
1220
+ selected.add(index);
1221
+ }
1222
+ }
1223
+
1224
+ const ordered = [...selected].sort((a, b) => a - b);
1225
+ const prefix = [
1226
+ transcript.headerText,
1227
+ `[Transcript reduced due to length. Full size: ${formatCount(transcript.charLength)} chars.]`,
1228
+ ]
1229
+ .filter(Boolean)
1230
+ .join("\n\n")
1231
+ .trim();
1232
+
1233
+ const reducedBlocks: string[] = [];
1234
+ let previous = -1;
1235
+ let bodyLength = 0;
1236
+ const maxBodyLength = Math.max(0, REDUCED_TRANSCRIPT_MAX_CHARS - prefix.length - 2);
1237
+
1238
+ for (const index of ordered) {
1239
+ if (previous >= 0 && index > previous + 1) {
1240
+ const gapText = `[... ${index - previous - 1} blocks omitted ...]`;
1241
+ const appendedGap = appendReducedBlock(reducedBlocks, gapText, maxBodyLength, bodyLength);
1242
+ bodyLength = appendedGap.bodyLength;
1243
+ if (!appendedGap.didAppend) break;
1244
+ }
1245
+
1246
+ const appendedBlock = appendReducedBlock(
1247
+ reducedBlocks,
1248
+ transcript.blocks[index]!.text,
1249
+ maxBodyLength,
1250
+ bodyLength,
1251
+ );
1252
+ bodyLength = appendedBlock.bodyLength;
1253
+ if (!appendedBlock.didAppend) break;
1254
+ previous = index;
1255
+ }
1256
+
1257
+ if (previous >= 0 && previous < transcript.blocks.length - 1) {
1258
+ const appendedTailGap = appendReducedBlock(
1259
+ reducedBlocks,
1260
+ `[... ${transcript.blocks.length - previous - 1} blocks omitted ...]`,
1261
+ maxBodyLength,
1262
+ bodyLength,
1263
+ );
1264
+ bodyLength = appendedTailGap.bodyLength;
1265
+ }
1266
+
1267
+ const reducedText = [prefix, reducedBlocks.join("\n\n")]
1268
+ .filter(Boolean)
1269
+ .join("\n\n")
1270
+ .slice(0, REDUCED_TRANSCRIPT_MAX_CHARS)
1271
+ .trim();
1272
+
1273
+ return {
1274
+ text: reducedText,
1275
+ fullCharLength: transcript.charLength,
1276
+ usedReducedTranscript: true,
1277
+ };
1278
+ }
1279
+
1280
+ function appendReducedBlock(
1281
+ blocks: string[],
1282
+ block: string,
1283
+ maxBodyLength: number,
1284
+ currentBodyLength: number,
1285
+ ): { bodyLength: number; didAppend: boolean } {
1286
+ if (maxBodyLength <= currentBodyLength) {
1287
+ return { bodyLength: currentBodyLength, didAppend: false };
1288
+ }
1289
+
1290
+ const separatorLength = blocks.length > 0 ? 2 : 0;
1291
+ const remaining = maxBodyLength - currentBodyLength - separatorLength;
1292
+ if (remaining <= 0) {
1293
+ return { bodyLength: currentBodyLength, didAppend: false };
1294
+ }
1295
+
1296
+ const nextBlock = truncateChars(block, remaining);
1297
+ if (!nextBlock) {
1298
+ return { bodyLength: currentBodyLength, didAppend: false };
1299
+ }
1300
+
1301
+ blocks.push(nextBlock);
1302
+ return {
1303
+ bodyLength: currentBodyLength + separatorLength + nextBlock.length,
1304
+ didAppend: true,
1305
+ };
1306
+ }
1307
+
1308
+ async function extractFacet(
1309
+ meta: SessionMeta,
1310
+ transcript: PreparedTranscript,
1311
+ selection: ConfiguredModelSelection,
1312
+ signal: AbortSignal,
1313
+ ): Promise<SessionFacet> {
1314
+ throwIfAborted(signal);
1315
+
1316
+ const userPrompt = [
1317
+ "Session metadata:",
1318
+ formatMetaForFacetPrompt(meta),
1319
+ "",
1320
+ transcript.usedReducedTranscript
1321
+ ? `Transcript: reduced from ${formatCount(transcript.fullCharLength)} chars to fit the model budget.`
1322
+ : "Transcript: full session transcript.",
1323
+ "",
1324
+ "<session>",
1325
+ transcript.text,
1326
+ "</session>",
1327
+ ].join("\n");
1328
+
1329
+ const userMessage: UserMessage = {
1330
+ role: "user",
1331
+ content: [{ type: "text", text: userPrompt }],
1332
+ timestamp: Date.now(),
1333
+ };
1334
+
1335
+ const response = await complete(
1336
+ selection.model,
1337
+ {
1338
+ systemPrompt: FACET_SYSTEM_PROMPT,
1339
+ messages: [userMessage],
1340
+ },
1341
+ {
1342
+ apiKey: selection.apiKey,
1343
+ headers: selection.headers,
1344
+ signal,
1345
+ },
1346
+ );
1347
+
1348
+ if (response.stopReason === "aborted") {
1349
+ throw new AbortError();
1350
+ }
1351
+
1352
+ if (response.stopReason === "error") {
1353
+ throw new Error("Session facet extraction failed");
1354
+ }
1355
+
1356
+ const rawText = response.content
1357
+ .filter((item): item is { type: "text"; text: string } => item.type === "text")
1358
+ .map((item) => item.text)
1359
+ .join("\n")
1360
+ .trim();
1361
+
1362
+ if (!rawText) {
1363
+ throw new Error("Session facet extraction returned no text");
1364
+ }
1365
+
1366
+ return validateFacet(parsePossiblyWrappedJson(rawText), meta.fingerprint);
1367
+ }
1368
+
1369
+ function buildAggregate(
1370
+ scope: InsightScope,
1371
+ sessionsConsidered: number,
1372
+ analyses: SessionAnalysis[],
1373
+ ): InsightsAggregate {
1374
+ const projects: Record<string, number> = {};
1375
+ const goalCategories: Record<string, number> = {};
1376
+ const frictionCategories: Record<string, number> = {};
1377
+ const toolCounts: Record<string, number> = {};
1378
+ const languages: Record<string, number> = {};
1379
+
1380
+ let totalInputTokens = 0;
1381
+ let totalOutputTokens = 0;
1382
+ let totalCost = 0;
1383
+ let dateRangeStart: string | undefined;
1384
+ let dateRangeEnd: string | undefined;
1385
+
1386
+ const repeatedInstructions = new Map<string, { text: string; sessions: Set<string> }>();
1387
+ const repeatedWorkflows = new Map<string, { text: string; sessions: Set<string> }>();
1388
+ const representativeSessions: InsightsAggregate["representativeSessions"] = [];
1389
+
1390
+ for (const analysis of analyses) {
1391
+ const meta = analysis.meta;
1392
+ incrementRecord(projects, meta.cwd ?? "(unknown)");
1393
+ totalInputTokens += meta.totalInputTokens;
1394
+ totalOutputTokens += meta.totalOutputTokens;
1395
+ totalCost += meta.totalCost ?? 0;
1396
+
1397
+ mergeNumberRecord(toolCounts, meta.toolCounts);
1398
+ mergeNumberRecord(languages, meta.languages);
1399
+
1400
+ if (meta.startedAt && (!dateRangeStart || meta.startedAt < dateRangeStart)) {
1401
+ dateRangeStart = meta.startedAt;
1402
+ }
1403
+ if (meta.endedAt && (!dateRangeEnd || meta.endedAt > dateRangeEnd)) {
1404
+ dateRangeEnd = meta.endedAt;
1405
+ }
1406
+
1407
+ if (!analysis.facet) continue;
1408
+
1409
+ for (const category of analysis.facet.goalCategories) {
1410
+ incrementRecord(goalCategories, category);
1411
+ }
1412
+ for (const category of analysis.facet.frictionCategories) {
1413
+ incrementRecord(frictionCategories, category);
1414
+ }
1415
+
1416
+ const sessionRef = sessionRefForMeta(meta);
1417
+ addRepeatedEvidence(
1418
+ repeatedInstructions,
1419
+ analysis.facet.explicitInstructionsToRemember,
1420
+ sessionRef,
1421
+ );
1422
+ addRepeatedEvidence(repeatedWorkflows, analysis.facet.repeatedWorkflowHints, sessionRef);
1423
+
1424
+ representativeSessions.push({
1425
+ sessionFile: meta.sessionFile,
1426
+ cwd: meta.cwd,
1427
+ startedAt: meta.startedAt,
1428
+ summary: analysis.facet.briefSummary,
1429
+ goal: analysis.facet.underlyingGoal,
1430
+ outcome: analysis.facet.outcome,
1431
+ });
1432
+ }
1433
+
1434
+ representativeSessions.sort((a, b) => (b.startedAt ?? "").localeCompare(a.startedAt ?? ""));
1435
+
1436
+ return {
1437
+ scope,
1438
+ sessionsConsidered,
1439
+ sessionsAnalyzed: analyses.length,
1440
+ sessionsWithFacets: analyses.filter((analysis) => Boolean(analysis.facet)).length,
1441
+ sessionsSkipped: Math.max(0, sessionsConsidered - analyses.length),
1442
+ dateRange: {
1443
+ start: dateRangeStart,
1444
+ end: dateRangeEnd,
1445
+ },
1446
+ projects,
1447
+ goalCategories,
1448
+ frictionCategories,
1449
+ toolCounts,
1450
+ languages,
1451
+ totalInputTokens,
1452
+ totalOutputTokens,
1453
+ totalCost: totalCost > 0 ? roundNumber(totalCost, 6) : undefined,
1454
+ repeatedInstructions: finalizeRepeatedEvidence(repeatedInstructions),
1455
+ repeatedWorkflows: finalizeRepeatedEvidence(repeatedWorkflows),
1456
+ representativeSessions: representativeSessions.slice(0, 12),
1457
+ };
1458
+ }
1459
+
1460
+ async function synthesizeReport(
1461
+ aggregate: InsightsAggregate,
1462
+ selection: ConfiguredModelSelection,
1463
+ signal: AbortSignal,
1464
+ ): Promise<string> {
1465
+ throwIfAborted(signal);
1466
+
1467
+ const payload = {
1468
+ schemaVersion: SYNTHESIS_PROMPT_VERSION,
1469
+ scope: aggregate.scope,
1470
+ sessionsConsidered: aggregate.sessionsConsidered,
1471
+ sessionsAnalyzed: aggregate.sessionsAnalyzed,
1472
+ sessionsWithFacets: aggregate.sessionsWithFacets,
1473
+ sessionsSkipped: aggregate.sessionsSkipped,
1474
+ dateRange: aggregate.dateRange,
1475
+ topProjects: topEntries(aggregate.projects, 8),
1476
+ topGoalCategories: topEntries(aggregate.goalCategories, 10),
1477
+ topFrictionCategories: topEntries(aggregate.frictionCategories, 10),
1478
+ topTools: topEntries(aggregate.toolCounts, 12),
1479
+ topLanguages: topEntries(aggregate.languages, 10),
1480
+ totals: {
1481
+ inputTokens: aggregate.totalInputTokens,
1482
+ outputTokens: aggregate.totalOutputTokens,
1483
+ cost: aggregate.totalCost,
1484
+ },
1485
+ repeatedInstructions: aggregate.repeatedInstructions.slice(0, 10),
1486
+ repeatedWorkflows: aggregate.repeatedWorkflows.slice(0, 10),
1487
+ representativeSessions: aggregate.representativeSessions.slice(0, 12),
1488
+ bucketRubric: {
1489
+ agentsMd: "explicit, repeated, stable, instruction-like guidance",
1490
+ promptTemplate: "mostly prompt framing, lightweight, user-triggered, not stateful",
1491
+ skill: "multi-step, procedural, reusable, mostly LLM-driven",
1492
+ extension: "stateful, event-driven, custom tooling/UI, persistent behavior",
1493
+ },
1494
+ };
1495
+
1496
+ const userMessage: UserMessage = {
1497
+ role: "user",
1498
+ content: [
1499
+ {
1500
+ type: "text",
1501
+ text: `Analyze this aggregate Pi session data and write the report.\n\n${JSON.stringify(payload, null, 2)}`,
1502
+ },
1503
+ ],
1504
+ timestamp: Date.now(),
1505
+ };
1506
+
1507
+ const response = await complete(
1508
+ selection.model,
1509
+ {
1510
+ systemPrompt: SYNTHESIS_SYSTEM_PROMPT,
1511
+ messages: [userMessage],
1512
+ },
1513
+ {
1514
+ apiKey: selection.apiKey,
1515
+ headers: selection.headers,
1516
+ signal,
1517
+ },
1518
+ );
1519
+
1520
+ if (response.stopReason === "aborted") {
1521
+ throw new AbortError();
1522
+ }
1523
+
1524
+ if (response.stopReason === "error") {
1525
+ throw new Error("Final report synthesis failed");
1526
+ }
1527
+
1528
+ const markdown = response.content
1529
+ .filter((item): item is { type: "text"; text: string } => item.type === "text")
1530
+ .map((item) => item.text)
1531
+ .join("\n")
1532
+ .trim();
1533
+
1534
+ if (!markdown) {
1535
+ throw new Error("Final report synthesis returned no text");
1536
+ }
1537
+
1538
+ return markdown;
1539
+ }
1540
+
1541
+ function buildDeterministicReport(aggregate: InsightsAggregate): string {
1542
+ const lines: string[] = [];
1543
+ lines.push("# Insights");
1544
+ lines.push("");
1545
+ lines.push("## At a glance");
1546
+ lines.push(`- Scope: ${aggregate.scope}`);
1547
+ lines.push(`- Sessions considered: ${formatCount(aggregate.sessionsConsidered)}`);
1548
+
1549
+ if (aggregate.sessionsAnalyzed === 0) {
1550
+ lines.push("- No sessions met the minimum threshold for analysis.");
1551
+ lines.push("");
1552
+ lines.push("## Quick wins");
1553
+ if (aggregate.scope === "current") {
1554
+ lines.push("- `scope=current` only analyzes the active session.");
1555
+ lines.push(
1556
+ "- If you started a fresh Pi session just to test `/insights`, this result is expected.",
1557
+ );
1558
+ lines.push(
1559
+ "- To test `current`, first `/resume` a substantial session or exchange a few real prompts, then run `/insights scope=current`.",
1560
+ );
1561
+ } else {
1562
+ lines.push("- Try /insights again after a few longer sessions in this scope.");
1563
+ lines.push(
1564
+ "- The current filter skips sessions with fewer than 2 meaningful user messages or under 1 minute of activity.",
1565
+ );
1566
+ }
1567
+ return lines.join("\n");
1568
+ }
1569
+
1570
+ lines.push(`- Sessions analyzed: ${formatCount(aggregate.sessionsAnalyzed)}`);
1571
+ lines.push(`- Sessions classified: ${formatCount(aggregate.sessionsWithFacets)}`);
1572
+ if (aggregate.dateRange.start || aggregate.dateRange.end) {
1573
+ lines.push(
1574
+ `- Date range: ${formatDateRange(aggregate.dateRange.start, aggregate.dateRange.end)}`,
1575
+ );
1576
+ }
1577
+ lines.push(`- Input tokens: ${formatCount(aggregate.totalInputTokens)}`);
1578
+ lines.push(`- Output tokens: ${formatCount(aggregate.totalOutputTokens)}`);
1579
+ if (aggregate.totalCost) {
1580
+ lines.push(`- Estimated cost: ${formatUsd(aggregate.totalCost)}`);
1581
+ }
1582
+ lines.push("");
1583
+
1584
+ lines.push("## What you use Pi for");
1585
+ lines.push(
1586
+ ...formatTopRecordAsBullets(
1587
+ aggregate.goalCategories,
1588
+ 5,
1589
+ "No strong recurring goal categories yet.",
1590
+ ),
1591
+ );
1592
+ lines.push("");
1593
+
1594
+ lines.push("## How you tend to work with Pi");
1595
+ lines.push(...formatTopRecordAsBullets(aggregate.toolCounts, 6, "No strong tool pattern yet."));
1596
+ lines.push(
1597
+ ...formatTopRecordAsBullets(aggregate.languages, 6, "No strong language pattern yet."),
1598
+ );
1599
+ lines.push("");
1600
+
1601
+ lines.push("## Repeated instructions worth moving into AGENTS.md");
1602
+ lines.push(
1603
+ ...formatRepeatedEvidence(
1604
+ aggregate.repeatedInstructions,
1605
+ "No repeated instruction-style guidance stood out yet.",
1606
+ ),
1607
+ );
1608
+ lines.push("");
1609
+
1610
+ lines.push("## Good prompt template candidates");
1611
+ lines.push(
1612
+ ...formatRepeatedEvidence(
1613
+ aggregate.repeatedWorkflows,
1614
+ "No repeated prompt-shaped workflow stood out yet.",
1615
+ ),
1616
+ );
1617
+ lines.push("");
1618
+
1619
+ lines.push("## Good skill candidates");
1620
+ if (aggregate.repeatedWorkflows.length > 0) {
1621
+ for (const workflow of aggregate.repeatedWorkflows.slice(0, 5)) {
1622
+ lines.push(
1623
+ `- ${workflow.text} — repeated in ${workflow.count} sessions. This looks procedural enough for a reusable skill.`,
1624
+ );
1625
+ }
1626
+ } else {
1627
+ lines.push("- No strong multi-step workflow pattern yet.");
1628
+ }
1629
+ lines.push("");
1630
+
1631
+ lines.push("## Good extension candidates");
1632
+ if (aggregate.repeatedInstructions.length > 0) {
1633
+ lines.push(
1634
+ "- Look for repeated instructions that feel like enforcement or runtime behavior. Those are stronger extension candidates than prompt-only guidance.",
1635
+ );
1636
+ } else {
1637
+ lines.push("- No obviously stateful or enforcement-heavy pattern stood out yet.");
1638
+ }
1639
+ lines.push("");
1640
+
1641
+ lines.push("## Where things go wrong");
1642
+ lines.push(
1643
+ ...formatTopRecordAsBullets(
1644
+ aggregate.frictionCategories,
1645
+ 5,
1646
+ "No strong recurring friction category yet.",
1647
+ ),
1648
+ );
1649
+ lines.push("");
1650
+
1651
+ lines.push("## Quick wins");
1652
+ lines.push("- Move stable, repeated instructions into AGENTS.md.");
1653
+ lines.push("- Turn repeated procedural workflows into a skill before building an extension.");
1654
+ lines.push("- Reserve extension work for patterns that need state, tooling, or enforcement.");
1655
+
1656
+ return lines.join("\n");
1657
+ }
1658
+
1659
+ function prependReportNotice(markdown: string, notice: string): string {
1660
+ return markdown.replace("# Insights\n", `# Insights\n\n> ${notice}\n`);
1661
+ }
1662
+
1663
+ function renderProgressMessage(progress: ProgressState, elapsedSeconds: string): string {
1664
+ const cacheSuffix = ` · meta cache ${formatCount(progress.metaCacheHits)} · facet cache ${formatCount(progress.facetCacheHits)}`;
1665
+
1666
+ if (progress.stage === "resolve") {
1667
+ return `Analyzing sessions · resolving (${formatCount(progress.current)}/${formatCount(progress.total)}) · ${elapsedSeconds}s`;
1668
+ }
1669
+
1670
+ if (progress.stage === "metadata") {
1671
+ return `Analyzing sessions · metadata (${formatCount(progress.current)}/${formatCount(progress.total)})${cacheSuffix} · ${elapsedSeconds}s`;
1672
+ }
1673
+
1674
+ if (progress.stage === "facets") {
1675
+ return `Analyzing sessions · facets (${formatCount(progress.current)}/${formatCount(progress.total)})${cacheSuffix} · ${elapsedSeconds}s`;
1676
+ }
1677
+
1678
+ if (progress.stage === "aggregate") {
1679
+ return `Analyzing sessions · aggregating${cacheSuffix} · ${elapsedSeconds}s`;
1680
+ }
1681
+
1682
+ return `Analyzing sessions · synthesizing report${cacheSuffix} · ${elapsedSeconds}s`;
1683
+ }
1684
+
1685
+ function setBorderedLoaderMessage(loader: BorderedLoader, message: string): void {
1686
+ const inner = (loader as unknown as { loader?: { setMessage?: (message: string) => void } })
1687
+ .loader;
1688
+ if (inner?.setMessage) {
1689
+ inner.setMessage(message);
1690
+ }
1691
+ }
1692
+
1693
+ async function getCacheInfo(sessionFile: string | undefined): Promise<SessionCacheInfo> {
1694
+ if (!sessionFile) return {};
1695
+
1696
+ const cacheKey = hashText(sessionFile);
1697
+ try {
1698
+ const stats = await fs.stat(sessionFile);
1699
+ const fingerprint = hashText(
1700
+ JSON.stringify({
1701
+ sessionFile,
1702
+ mtimeMs: stats.mtimeMs,
1703
+ size: stats.size,
1704
+ schemaVersion: SESSION_FINGERPRINT_VERSION,
1705
+ }),
1706
+ );
1707
+ return { cacheKey, fingerprint };
1708
+ } catch {
1709
+ return { cacheKey };
1710
+ }
1711
+ }
1712
+
1713
+ async function readMetaCache(cacheKey: string, fingerprint: string): Promise<SessionMeta | null> {
1714
+ const raw = await readJsonFile(path.join(META_CACHE_DIR, `${cacheKey}.json`));
1715
+ if (!raw || !isRecord(raw)) return null;
1716
+ if (raw.schemaVersion !== INSIGHTS_META_SCHEMA_VERSION) return null;
1717
+ if (raw.fingerprint !== fingerprint) return null;
1718
+ if (typeof raw.sessionFile !== "string") return null;
1719
+ return raw as unknown as SessionMeta;
1720
+ }
1721
+
1722
+ async function writeMetaCache(
1723
+ cacheKey: string,
1724
+ meta: SessionMeta,
1725
+ signal: AbortSignal,
1726
+ ): Promise<void> {
1727
+ await writeJsonAtomic(path.join(META_CACHE_DIR, `${cacheKey}.json`), meta, signal);
1728
+ }
1729
+
1730
+ async function readFacetCache(
1731
+ cacheKey: string | undefined,
1732
+ fingerprint: string,
1733
+ ): Promise<SessionFacet | null> {
1734
+ if (!cacheKey) return null;
1735
+ const raw = await readJsonFile(path.join(FACET_CACHE_DIR, `${cacheKey}.json`));
1736
+ if (!raw || !isRecord(raw)) return null;
1737
+ if (raw.schemaVersion !== INSIGHTS_FACET_SCHEMA_VERSION) return null;
1738
+ if (raw.promptVersion !== FACET_PROMPT_VERSION) return null;
1739
+ if (raw.fingerprint !== fingerprint) return null;
1740
+ return raw as unknown as SessionFacet;
1741
+ }
1742
+
1743
+ async function writeFacetCache(
1744
+ cacheKey: string,
1745
+ facet: SessionFacet,
1746
+ signal: AbortSignal,
1747
+ ): Promise<void> {
1748
+ await writeJsonAtomic(path.join(FACET_CACHE_DIR, `${cacheKey}.json`), facet, signal);
1749
+ }
1750
+
1751
+ async function readJsonFile(filePath: string): Promise<unknown | null> {
1752
+ try {
1753
+ const raw = await fs.readFile(filePath, "utf8");
1754
+ return JSON.parse(raw) as unknown;
1755
+ } catch {
1756
+ return null;
1757
+ }
1758
+ }
1759
+
1760
+ async function writeJsonAtomic(
1761
+ filePath: string,
1762
+ value: unknown,
1763
+ signal: AbortSignal,
1764
+ ): Promise<void> {
1765
+ throwIfAborted(signal);
1766
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
1767
+ const tempPath = `${filePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
1768
+ const payload = `${JSON.stringify(value, null, 2)}\n`;
1769
+ try {
1770
+ await fs.writeFile(tempPath, payload, "utf8");
1771
+ throwIfAborted(signal);
1772
+ await fs.rename(tempPath, filePath);
1773
+ } catch (error) {
1774
+ void fs.rm(tempPath, { force: true }).catch(() => undefined);
1775
+ throw error;
1776
+ }
1777
+ }
1778
+
1779
+ function validateFacet(parsed: unknown, fingerprint: string): SessionFacet {
1780
+ if (!isRecord(parsed)) {
1781
+ throw new Error("Facet output must be a JSON object");
1782
+ }
1783
+
1784
+ const underlyingGoal = cleanSentence(parsed.underlyingGoal, "Unclear goal");
1785
+ const goalCategories = sanitizeEnumArray(parsed.goalCategories, GOAL_CATEGORIES);
1786
+ const frictionCategories = sanitizeEnumArray(parsed.frictionCategories, FRICTION_CATEGORIES);
1787
+ const outcome = sanitizeOutcome(parsed.outcome);
1788
+ const frictionDetail = optionalCleanSentence(parsed.frictionDetail);
1789
+ const briefSummary = cleanSentence(parsed.briefSummary, underlyingGoal);
1790
+ const explicitInstructionsToRemember = sanitizeStringArray(
1791
+ parsed.explicitInstructionsToRemember,
1792
+ 8,
1793
+ 220,
1794
+ );
1795
+ const repeatedWorkflowHints = sanitizeStringArray(parsed.repeatedWorkflowHints, 8, 220);
1796
+
1797
+ return {
1798
+ schemaVersion: INSIGHTS_FACET_SCHEMA_VERSION,
1799
+ promptVersion: FACET_PROMPT_VERSION,
1800
+ fingerprint,
1801
+ underlyingGoal,
1802
+ goalCategories,
1803
+ outcome,
1804
+ frictionCategories,
1805
+ frictionDetail,
1806
+ briefSummary,
1807
+ explicitInstructionsToRemember,
1808
+ repeatedWorkflowHints,
1809
+ };
1810
+ }
1811
+
1812
+ function parsePossiblyWrappedJson(raw: string): unknown {
1813
+ const trimmed = raw.trim();
1814
+ if (!trimmed) throw new Error("Empty output");
1815
+
1816
+ const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)?.[1]?.trim();
1817
+ const candidate = fenced || trimmed;
1818
+
1819
+ try {
1820
+ return JSON.parse(candidate);
1821
+ } catch {
1822
+ const firstBrace = candidate.indexOf("{");
1823
+ const lastBrace = candidate.lastIndexOf("}");
1824
+ if (firstBrace >= 0 && lastBrace > firstBrace) {
1825
+ return JSON.parse(candidate.slice(firstBrace, lastBrace + 1));
1826
+ }
1827
+ throw new Error("Output is not valid JSON");
1828
+ }
1829
+ }
1830
+
1831
+ function formatMetaForFacetPrompt(meta: SessionMeta): string {
1832
+ const fields = [
1833
+ `session_file: ${meta.sessionFile}`,
1834
+ `cwd: ${meta.cwd ?? "(unknown)"}`,
1835
+ `started_at: ${meta.startedAt ?? "(unknown)"}`,
1836
+ `duration_minutes: ${meta.durationMinutes}`,
1837
+ `meaningful_user_messages: ${meta.meaningfulUserMessages}`,
1838
+ `assistant_messages: ${meta.assistantMessages}`,
1839
+ `tool_result_messages: ${meta.toolResultMessages}`,
1840
+ `tool_counts: ${JSON.stringify(topEntries(meta.toolCounts, 10))}`,
1841
+ `tools_with_errors: ${JSON.stringify(topEntries(meta.toolsWithErrors, 10))}`,
1842
+ `modified_files: ${JSON.stringify(meta.modifiedFiles.slice(0, 20))}`,
1843
+ `languages: ${JSON.stringify(topEntries(meta.languages, 10))}`,
1844
+ ];
1845
+ if (meta.firstUserPrompt) {
1846
+ fields.push(`first_user_prompt: ${meta.firstUserPrompt}`);
1847
+ }
1848
+ return fields.join("\n");
1849
+ }
1850
+
1851
+ function isMeaningfulUserMessage(message: Record<string, unknown>): boolean {
1852
+ const text = extractMessageText(message.content, true).trim();
1853
+ if (text) return true;
1854
+ if (!Array.isArray(message.content)) return false;
1855
+ return message.content.some((item) => isRecord(item) && item.type === "image");
1856
+ }
1857
+
1858
+ function extractMessageText(content: unknown, includeImagePlaceholders: boolean): string {
1859
+ if (typeof content === "string") return content;
1860
+ if (!Array.isArray(content)) return "";
1861
+
1862
+ return content
1863
+ .map((item) => {
1864
+ if (!isRecord(item)) return "";
1865
+ if (item.type === "text" && typeof item.text === "string") return item.text;
1866
+ if (includeImagePlaceholders && item.type === "image") return "[image attachment]";
1867
+ return "";
1868
+ })
1869
+ .filter(Boolean)
1870
+ .join("\n");
1871
+ }
1872
+
1873
+ function extractAssistantText(content: unknown): string {
1874
+ if (!Array.isArray(content)) return "";
1875
+ return content
1876
+ .map((item) => {
1877
+ if (!isRecord(item) || item.type !== "text" || typeof item.text !== "string") return "";
1878
+ return item.text;
1879
+ })
1880
+ .filter(Boolean)
1881
+ .join("\n")
1882
+ .trim();
1883
+ }
1884
+
1885
+ function extractToolCalls(
1886
+ content: unknown,
1887
+ ): Array<{ name: string; arguments: Record<string, unknown> }> {
1888
+ if (!Array.isArray(content)) return [];
1889
+
1890
+ const calls: Array<{ name: string; arguments: Record<string, unknown> }> = [];
1891
+ for (const item of content) {
1892
+ if (!isRecord(item) || item.type !== "toolCall") continue;
1893
+ if (typeof item.name !== "string") continue;
1894
+ calls.push({
1895
+ name: item.name,
1896
+ arguments: isRecord(item.arguments) ? item.arguments : {},
1897
+ });
1898
+ }
1899
+ return calls;
1900
+ }
1901
+
1902
+ function summarizeToolCall(name: string, args: Record<string, unknown>): string {
1903
+ const lines: string[] = [];
1904
+ const command = typeof args.command === "string" ? args.command.trim() : "";
1905
+ const targetPath = typeof args.path === "string" ? args.path.trim() : "";
1906
+
1907
+ if (command) lines.push(`command: ${command}`);
1908
+ if (targetPath) lines.push(`path: ${targetPath}`);
1909
+
1910
+ if (name === "write") {
1911
+ const contentLength = typeof args.content === "string" ? args.content.length : undefined;
1912
+ if (typeof contentLength === "number") lines.push(`content_chars: ${contentLength}`);
1913
+ }
1914
+
1915
+ if (name === "edit") {
1916
+ const editBlocks = Array.isArray(args.edits)
1917
+ ? args.edits.filter((edit): edit is Record<string, unknown> => isRecord(edit))
1918
+ : [];
1919
+ const oldTextLength = editBlocks.reduce(
1920
+ (sum, edit) => sum + (typeof edit.oldText === "string" ? edit.oldText.length : 0),
1921
+ typeof args.oldText === "string" ? args.oldText.length : 0,
1922
+ );
1923
+ const newTextLength = editBlocks.reduce(
1924
+ (sum, edit) => sum + (typeof edit.newText === "string" ? edit.newText.length : 0),
1925
+ typeof args.newText === "string" ? args.newText.length : 0,
1926
+ );
1927
+ if (editBlocks.length > 0) lines.push(`edit_blocks: ${editBlocks.length}`);
1928
+ if (oldTextLength > 0) lines.push(`old_text_chars: ${oldTextLength}`);
1929
+ if (newTextLength > 0) lines.push(`new_text_chars: ${newTextLength}`);
1930
+ }
1931
+
1932
+ if (!command && !targetPath) {
1933
+ const previewEntries = Object.entries(args)
1934
+ .filter(
1935
+ ([, value]) =>
1936
+ typeof value === "string" || typeof value === "number" || typeof value === "boolean",
1937
+ )
1938
+ .slice(0, 4)
1939
+ .map(([key, value]) => `${key}: ${String(value)}`);
1940
+ lines.push(...previewEntries);
1941
+ }
1942
+
1943
+ return lines.length > 0 ? lines.join("\n") : "(no salient arguments)";
1944
+ }
1945
+
1946
+ function summarizeToolResult(message: Record<string, unknown>): string {
1947
+ const contentText = extractMessageTextPreview(message.content, false, 260);
1948
+ const lines: string[] = [];
1949
+ if (message.isError === true) {
1950
+ lines.push("error: true");
1951
+ } else {
1952
+ lines.push("error: false");
1953
+ }
1954
+ if (contentText) {
1955
+ lines.push(`summary: ${contentText}`);
1956
+ }
1957
+ return lines.join("\n");
1958
+ }
1959
+
1960
+ function extractMessageTextPreview(
1961
+ content: unknown,
1962
+ includeImagePlaceholders: boolean,
1963
+ maxChars: number,
1964
+ ): string {
1965
+ if (typeof content === "string") {
1966
+ return summarizeBoundedFreeText(content, maxChars);
1967
+ }
1968
+ if (!Array.isArray(content)) return "";
1969
+
1970
+ let buffer = "";
1971
+ const budget = Math.max(maxChars * 4, maxChars + 32);
1972
+ for (const item of content) {
1973
+ let nextChunk = "";
1974
+ if (isRecord(item) && item.type === "text" && typeof item.text === "string") {
1975
+ nextChunk = item.text;
1976
+ } else if (includeImagePlaceholders && isRecord(item) && item.type === "image") {
1977
+ nextChunk = "[image attachment]";
1978
+ }
1979
+
1980
+ if (!nextChunk) continue;
1981
+ buffer += `${buffer ? "\n" : ""}${nextChunk}`;
1982
+ if (buffer.length >= budget) break;
1983
+ }
1984
+
1985
+ return summarizeBoundedFreeText(buffer, maxChars);
1986
+ }
1987
+
1988
+ function summarizeFreeText(text: string, maxChars: number): string {
1989
+ const compact = text.replace(/\s+/g, " ").trim();
1990
+ if (!compact) return "";
1991
+ return compact.length > maxChars ? `${compact.slice(0, maxChars - 3)}...` : compact;
1992
+ }
1993
+
1994
+ function summarizeBoundedFreeText(text: string, maxChars: number): string {
1995
+ return summarizeFreeText(text.slice(0, Math.max(maxChars * 4, maxChars + 32)), maxChars);
1996
+ }
1997
+
1998
+ function truncateChars(text: string, maxChars: number): string {
1999
+ if (maxChars <= 0) return "";
2000
+ if (text.length <= maxChars) return text;
2001
+ if (maxChars <= 3) return text.slice(0, maxChars);
2002
+ return `${text.slice(0, maxChars - 3)}...`;
2003
+ }
2004
+
2005
+ function collectModifiedFilesFromEntry(entry: SessionEntry, modifiedFiles: Set<string>): void {
2006
+ if ((entry.type === "compaction" || entry.type === "branch_summary") && isRecord(entry.details)) {
2007
+ const details = entry.details;
2008
+ const files = Array.isArray(details.modifiedFiles) ? details.modifiedFiles : [];
2009
+ for (const file of files) {
2010
+ if (typeof file === "string" && file.trim()) {
2011
+ modifiedFiles.add(file.trim());
2012
+ }
2013
+ }
2014
+ }
2015
+ }
2016
+
2017
+ function collectModifiedFilesFromToolCall(
2018
+ name: string,
2019
+ args: Record<string, unknown>,
2020
+ modifiedFiles: Set<string>,
2021
+ ): void {
2022
+ if ((name === "write" || name === "edit") && typeof args.path === "string" && args.path.trim()) {
2023
+ modifiedFiles.add(args.path.trim());
2024
+ }
2025
+ }
2026
+
2027
+ function buildLanguageSummary(modifiedFiles: Iterable<string>): Record<string, number> {
2028
+ const languages: Record<string, number> = {};
2029
+ for (const file of modifiedFiles) {
2030
+ incrementRecord(languages, languageFromPath(file));
2031
+ }
2032
+ return languages;
2033
+ }
2034
+
2035
+ function languageFromPath(filePath: string): string {
2036
+ const ext = path.extname(filePath).toLowerCase();
2037
+ if (ext === ".vue") return "Vue";
2038
+ if (ext === ".svelte") return "Svelte";
2039
+ if (ext === ".astro") return "Astro";
2040
+
2041
+ const language = getLanguageFromPath(filePath);
2042
+ if (!language) return "other";
2043
+
2044
+ switch (language) {
2045
+ case "typescript":
2046
+ return "TypeScript";
2047
+ case "javascript":
2048
+ return "JavaScript";
2049
+ case "python":
2050
+ return "Python";
2051
+ case "rust":
2052
+ return "Rust";
2053
+ case "go":
2054
+ return "Go";
2055
+ case "java":
2056
+ return "Java";
2057
+ case "kotlin":
2058
+ return "Kotlin";
2059
+ case "swift":
2060
+ return "Swift";
2061
+ case "ruby":
2062
+ return "Ruby";
2063
+ case "php":
2064
+ return "PHP";
2065
+ case "csharp":
2066
+ return "C#";
2067
+ case "markdown":
2068
+ return "Markdown";
2069
+ case "json":
2070
+ return "JSON";
2071
+ case "yaml":
2072
+ return "YAML";
2073
+ case "toml":
2074
+ return "TOML";
2075
+ case "bash":
2076
+ return "Shell";
2077
+ case "css":
2078
+ case "scss":
2079
+ case "sass":
2080
+ case "less":
2081
+ return "CSS";
2082
+ case "html":
2083
+ return "HTML";
2084
+ case "sql":
2085
+ return "SQL";
2086
+ case "graphql":
2087
+ return "GraphQL";
2088
+ case "dockerfile":
2089
+ return "Dockerfile";
2090
+ case "makefile":
2091
+ return "Makefile";
2092
+ case "cpp":
2093
+ return "C++";
2094
+ case "c":
2095
+ return "C";
2096
+ case "powershell":
2097
+ return "PowerShell";
2098
+ case "fish":
2099
+ return "Fish";
2100
+ default:
2101
+ return language.charAt(0).toUpperCase() + language.slice(1);
2102
+ }
2103
+ }
2104
+
2105
+ function countBranchPoints(entries: SessionEntry[]): number {
2106
+ const childCounts = new Map<string | null, number>();
2107
+ for (const entry of entries) {
2108
+ childCounts.set(entry.parentId, (childCounts.get(entry.parentId) ?? 0) + 1);
2109
+ }
2110
+
2111
+ let branchPoints = 0;
2112
+ for (const count of childCounts.values()) {
2113
+ if (count > 1) branchPoints += count - 1;
2114
+ }
2115
+ return branchPoints;
2116
+ }
2117
+
2118
+ function firstTimestamp(entries: SessionEntry[]): string | undefined {
2119
+ for (const entry of entries) {
2120
+ if (entry.timestamp) return entry.timestamp;
2121
+ }
2122
+ return undefined;
2123
+ }
2124
+
2125
+ function lastTimestamp(entries: SessionEntry[]): string | undefined {
2126
+ for (let index = entries.length - 1; index >= 0; index--) {
2127
+ const timestamp = entries[index]?.timestamp;
2128
+ if (timestamp) return timestamp;
2129
+ }
2130
+ return undefined;
2131
+ }
2132
+
2133
+ function computeDurationMinutes(startedAt?: string, endedAt?: string): number {
2134
+ const start = startedAt ? new Date(startedAt).getTime() : NaN;
2135
+ const end = endedAt ? new Date(endedAt).getTime() : NaN;
2136
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return 0;
2137
+ return Math.max(0, Math.floor((end - start) / 60_000));
2138
+ }
2139
+
2140
+ function fallbackFingerprint(
2141
+ entryCount: number,
2142
+ sessionId: string,
2143
+ leafId: string | null,
2144
+ branchLastTimestamp: string | undefined,
2145
+ sessionPath: string | undefined,
2146
+ ): string {
2147
+ return hashText(
2148
+ JSON.stringify({
2149
+ path: sessionPath,
2150
+ sessionId,
2151
+ leafId,
2152
+ entryCount,
2153
+ lastTimestamp: branchLastTimestamp,
2154
+ }),
2155
+ );
2156
+ }
2157
+
2158
+ function sessionRefForMeta(meta: SessionMeta): string {
2159
+ const base = path.basename(meta.sessionFile);
2160
+ const suffix = meta.fingerprint.slice(0, 6);
2161
+ return `${base}:${suffix}`;
2162
+ }
2163
+
2164
+ function addRepeatedEvidence(
2165
+ store: Map<string, { text: string; sessions: Set<string> }>,
2166
+ values: string[],
2167
+ sessionRef: string,
2168
+ ): void {
2169
+ const seen = new Set<string>();
2170
+ for (const value of values) {
2171
+ const cleaned = cleanOneLine(value, 220);
2172
+ if (!cleaned) continue;
2173
+ const key = normalizeEvidenceKey(cleaned);
2174
+ if (!key || seen.has(key)) continue;
2175
+ seen.add(key);
2176
+
2177
+ const existing = store.get(key);
2178
+ if (existing) {
2179
+ existing.sessions.add(sessionRef);
2180
+ if (cleaned.length < existing.text.length) {
2181
+ existing.text = cleaned;
2182
+ }
2183
+ } else {
2184
+ store.set(key, { text: cleaned, sessions: new Set([sessionRef]) });
2185
+ }
2186
+ }
2187
+ }
2188
+
2189
+ function finalizeRepeatedEvidence(
2190
+ store: Map<string, { text: string; sessions: Set<string> }>,
2191
+ ): RepeatedEvidence[] {
2192
+ return [...store.values()]
2193
+ .map((value) => ({
2194
+ text: value.text,
2195
+ count: value.sessions.size,
2196
+ }))
2197
+ .filter((value) => value.count >= 2)
2198
+ .sort((a, b) => b.count - a.count || a.text.localeCompare(b.text));
2199
+ }
2200
+
2201
+ function normalizeEvidenceKey(text: string): string {
2202
+ return text
2203
+ .toLowerCase()
2204
+ .replace(/[`'"“”]/g, "")
2205
+ .replace(/\s+/g, " ")
2206
+ .replace(/[.:;!?]+$/g, "")
2207
+ .trim();
2208
+ }
2209
+
2210
+ function topEntries(
2211
+ record: Record<string, number>,
2212
+ limit: number,
2213
+ ): Array<{ key: string; value: number }> {
2214
+ return Object.entries(record)
2215
+ .map(([key, value]) => ({ key, value }))
2216
+ .sort((a, b) => b.value - a.value || a.key.localeCompare(b.key))
2217
+ .slice(0, limit);
2218
+ }
2219
+
2220
+ function formatTopRecordAsBullets(
2221
+ record: Record<string, number>,
2222
+ limit: number,
2223
+ emptyMessage: string,
2224
+ ): string[] {
2225
+ const entries = topEntries(record, limit);
2226
+ if (entries.length === 0) return [`- ${emptyMessage}`];
2227
+ return entries.map((entry) => `- ${entry.key}: ${formatCount(entry.value)}`);
2228
+ }
2229
+
2230
+ function formatRepeatedEvidence(values: RepeatedEvidence[], emptyMessage: string): string[] {
2231
+ if (values.length === 0) return [`- ${emptyMessage}`];
2232
+ return values.slice(0, 6).map((value) => `- ${value.text} — repeated in ${value.count} sessions`);
2233
+ }
2234
+
2235
+ function mergeNumberRecord(target: Record<string, number>, source: Record<string, number>): void {
2236
+ for (const [key, value] of Object.entries(source)) {
2237
+ target[key] = (target[key] ?? 0) + value;
2238
+ }
2239
+ }
2240
+
2241
+ function incrementRecord(record: Record<string, number>, key: string, amount = 1): void {
2242
+ record[key] = (record[key] ?? 0) + amount;
2243
+ }
2244
+
2245
+ function sanitizeEnumArray<T extends readonly string[]>(value: unknown, allowed: T): T[number][] {
2246
+ if (!Array.isArray(value)) return [];
2247
+ const allowedSet = new Set(allowed);
2248
+ const cleaned: T[number][] = [];
2249
+ for (const item of value) {
2250
+ if (typeof item !== "string") continue;
2251
+ if (!allowedSet.has(item as T[number])) continue;
2252
+ if (!cleaned.includes(item as T[number])) cleaned.push(item as T[number]);
2253
+ }
2254
+ return cleaned;
2255
+ }
2256
+
2257
+ function sanitizeOutcome(value: unknown): SessionFacet["outcome"] {
2258
+ return value === "achieved" || value === "partial" || value === "blocked" || value === "unclear"
2259
+ ? value
2260
+ : "unclear";
2261
+ }
2262
+
2263
+ function sanitizeStringArray(value: unknown, maxItems: number, maxChars: number): string[] {
2264
+ if (!Array.isArray(value)) return [];
2265
+ const cleaned: string[] = [];
2266
+ for (const item of value) {
2267
+ if (typeof item !== "string") continue;
2268
+ const normalized = cleanSentence(item, "").slice(0, maxChars).trim();
2269
+ if (!normalized) continue;
2270
+ if (!cleaned.includes(normalized)) cleaned.push(normalized);
2271
+ if (cleaned.length >= maxItems) break;
2272
+ }
2273
+ return cleaned;
2274
+ }
2275
+
2276
+ function cleanSentence(value: unknown, fallback: string): string {
2277
+ if (typeof value !== "string") return fallback;
2278
+ const cleaned = value.replace(/\s+/g, " ").trim();
2279
+ return cleaned || fallback;
2280
+ }
2281
+
2282
+ function optionalCleanSentence(value: unknown): string | undefined {
2283
+ const cleaned = cleanSentence(value, "");
2284
+ return cleaned || undefined;
2285
+ }
2286
+
2287
+ function cleanOneLine(value: string, maxChars: number): string {
2288
+ const cleaned = value.replace(/\s+/g, " ").trim();
2289
+ if (!cleaned) return "";
2290
+ return cleaned.length > maxChars ? `${cleaned.slice(0, maxChars - 3)}...` : cleaned;
2291
+ }
2292
+
2293
+ function normalizeOptionalText(value: string | undefined): string | undefined {
2294
+ const cleaned = value?.trim();
2295
+ return cleaned ? cleaned : undefined;
2296
+ }
2297
+
2298
+ function normalizeSessionFile(sessionFile: string | undefined): string {
2299
+ return sessionFile ?? "(current session)";
2300
+ }
2301
+
2302
+ function formatCount(value: number): string {
2303
+ if (!Number.isFinite(value)) return "0";
2304
+ return Math.round(value).toLocaleString("en-US");
2305
+ }
2306
+
2307
+ function formatDuration(minutes: number): string {
2308
+ if (minutes <= 0) return "0 min";
2309
+ if (minutes < 60) return `${minutes} min`;
2310
+ const hours = Math.floor(minutes / 60);
2311
+ const remaining = minutes % 60;
2312
+ return remaining > 0 ? `${hours}h ${remaining}m` : `${hours}h`;
2313
+ }
2314
+
2315
+ function formatIsoShort(value: string): string {
2316
+ const date = new Date(value);
2317
+ if (!Number.isFinite(date.getTime())) return value;
2318
+ return date.toISOString().replace(/\.\d{3}Z$/, "Z");
2319
+ }
2320
+
2321
+ function formatDateRange(start?: string, end?: string): string {
2322
+ if (start && end) return `${formatIsoShort(start)} → ${formatIsoShort(end)}`;
2323
+ return start ?? end ?? "(unknown)";
2324
+ }
2325
+
2326
+ function formatUsd(value: number): string {
2327
+ if (!Number.isFinite(value)) return "$0.00";
2328
+ if (value >= 1) return `$${value.toFixed(2)}`;
2329
+ if (value >= 0.1) return `$${value.toFixed(3)}`;
2330
+ return `$${value.toFixed(4)}`;
2331
+ }
2332
+
2333
+ function roundNumber(value: number, digits: number): number {
2334
+ const factor = 10 ** digits;
2335
+ return Math.round(value * factor) / factor;
2336
+ }
2337
+
2338
+ function numberValue(value: unknown): number {
2339
+ if (typeof value === "number") return Number.isFinite(value) ? value : 0;
2340
+ if (typeof value === "string") {
2341
+ const parsed = Number(value);
2342
+ return Number.isFinite(parsed) ? parsed : 0;
2343
+ }
2344
+ return 0;
2345
+ }
2346
+
2347
+ function hashText(value: string): string {
2348
+ return createHash("sha256").update(value).digest("hex");
2349
+ }
2350
+
2351
+ function clamp(value: number, min: number, max: number): number {
2352
+ return Math.max(min, Math.min(max, value));
2353
+ }
2354
+
2355
+ function throwIfAborted(signal: AbortSignal | undefined): void {
2356
+ if (signal?.aborted) {
2357
+ throw new AbortError();
2358
+ }
2359
+ }
2360
+
2361
+ function isAbortError(error: unknown): boolean {
2362
+ return error instanceof AbortError || (error instanceof Error && error.name === "AbortError");
2363
+ }
2364
+
2365
+ function isRecord(value: unknown): value is Record<string, any> {
2366
+ return typeof value === "object" && value !== null;
2367
+ }