@diegopetrucci/pi-extensions 0.1.73 → 0.1.76

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 (93) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +9 -4
  3. package/extensions/agent-workflow-audit/index.ts +1398 -1009
  4. package/extensions/agent-workflow-audit/package.json +1 -1
  5. package/extensions/annotate-git-diff/README.md +3 -1
  6. package/extensions/annotate-git-diff/clipboard.ts +109 -100
  7. package/extensions/annotate-git-diff/git.ts +853 -767
  8. package/extensions/annotate-git-diff/glimpseui.d.ts +87 -78
  9. package/extensions/annotate-git-diff/index.ts +712 -671
  10. package/extensions/annotate-git-diff/package.json +1 -1
  11. package/extensions/annotate-git-diff/prompt.ts +45 -45
  12. package/extensions/annotate-git-diff/quiet-glimpse.ts +161 -155
  13. package/extensions/annotate-git-diff/review-server.ts +337 -311
  14. package/extensions/annotate-git-diff/types.ts +132 -132
  15. package/extensions/annotate-git-diff/ui.ts +27 -27
  16. package/extensions/annotate-git-diff/watch.ts +74 -74
  17. package/extensions/annotate-git-diff/web/app.js +1947 -1884
  18. package/extensions/annotate-git-diff/web/index.html +5 -6
  19. package/extensions/annotate-last-message/README.md +1 -1
  20. package/extensions/annotate-last-message/glimpseui.d.ts +87 -78
  21. package/extensions/annotate-last-message/index.ts +210 -206
  22. package/extensions/annotate-last-message/package.json +1 -1
  23. package/extensions/annotate-last-message/prompt.ts +87 -66
  24. package/extensions/annotate-last-message/quiet-glimpse.ts +161 -155
  25. package/extensions/annotate-last-message/session.ts +94 -89
  26. package/extensions/annotate-last-message/theme.ts +199 -193
  27. package/extensions/annotate-last-message/types.ts +23 -23
  28. package/extensions/annotate-last-message/ui.ts +27 -25
  29. package/extensions/annotate-last-message/web/app.js +4 -7
  30. package/extensions/annotate-last-message/web/md-renderer.js +1 -1
  31. package/extensions/brrr/README.md +1 -1
  32. package/extensions/brrr/index.ts +213 -191
  33. package/extensions/brrr/package.json +1 -1
  34. package/extensions/claude-fast/README.md +5 -79
  35. package/extensions/claude-fast/index.ts +31 -289
  36. package/extensions/claude-fast/package.json +2 -3
  37. package/extensions/code-reviewer/README.md +1 -1
  38. package/extensions/code-reviewer/index.ts +1598 -1350
  39. package/extensions/code-reviewer/package.json +1 -1
  40. package/extensions/confirm-destructive/README.md +1 -1
  41. package/extensions/confirm-destructive/index.ts +55 -51
  42. package/extensions/confirm-destructive/package.json +1 -1
  43. package/extensions/context-cap/index.ts +312 -304
  44. package/extensions/context-cap/package.json +1 -1
  45. package/extensions/context-inspector/index.ts +1211 -1064
  46. package/extensions/context-inspector/package.json +1 -1
  47. package/extensions/contrarian/index.ts +1854 -1735
  48. package/extensions/contrarian/package.json +1 -1
  49. package/extensions/dirty-repo-guard/index.ts +41 -41
  50. package/extensions/dynamic-context-pruning/README.md +6 -38
  51. package/extensions/dynamic-context-pruning/index.ts +2124 -1980
  52. package/extensions/dynamic-context-pruning/package.json +1 -1
  53. package/extensions/fast/README.md +71 -21
  54. package/extensions/fast/index.ts +513 -313
  55. package/extensions/fast/package.json +1 -1
  56. package/extensions/git-footer/README.md +1 -1
  57. package/extensions/git-footer/index.ts +458 -411
  58. package/extensions/git-footer/package.json +2 -3
  59. package/extensions/gnosis/index.ts +180 -130
  60. package/extensions/gnosis/package.json +1 -1
  61. package/extensions/inline-bash/index.ts +88 -88
  62. package/extensions/inline-bash/package.json +1 -1
  63. package/extensions/librarian/index.ts +1669 -1419
  64. package/extensions/librarian/package.json +1 -1
  65. package/extensions/minimal-footer/index.ts +994 -1012
  66. package/extensions/minimal-footer/openai-usage.ts +195 -190
  67. package/extensions/notify/index.ts +222 -230
  68. package/extensions/notify/package.json +1 -1
  69. package/extensions/openai-fast/README.md +5 -73
  70. package/extensions/openai-fast/index.ts +32 -272
  71. package/extensions/openai-fast/package.json +2 -3
  72. package/extensions/oracle/index.ts +1731 -1625
  73. package/extensions/oracle/package.json +1 -1
  74. package/extensions/permission-gate/index.ts +658 -608
  75. package/extensions/permission-gate/package.json +1 -1
  76. package/extensions/permission-gate/powershell-safety.ts +840 -793
  77. package/extensions/project-mcp-json/.pi-fleet-tested-version +1 -0
  78. package/extensions/project-mcp-json/README.md +199 -0
  79. package/extensions/project-mcp-json/config.ts +564 -0
  80. package/extensions/project-mcp-json/index.ts +119 -0
  81. package/extensions/project-mcp-json/package.json +38 -0
  82. package/extensions/quiet-tools/index.ts +282 -264
  83. package/extensions/review/README.md +2 -2
  84. package/extensions/review/index.ts +1745 -1872
  85. package/extensions/review/package.json +1 -1
  86. package/extensions/todo/index.ts +284 -265
  87. package/extensions/triage-comments/README.md +2 -2
  88. package/extensions/triage-comments/index.ts +2294 -1882
  89. package/extensions/triage-comments/package.json +1 -1
  90. package/package.json +6 -3
  91. package/assets/social-preview.png +0 -0
  92. package/extensions/claude-fast/claude-fast.example.json +0 -4
  93. package/extensions/openai-fast/openai-fast.example.json +0 -4
@@ -2,1219 +2,1362 @@ import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
- import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ CONFIG_DIR_NAME,
7
+ type ExtensionAPI,
8
+ type ExtensionCommandContext,
9
+ } from "@earendil-works/pi-coding-agent";
6
10
 
7
- const EXTENSION_ID = "context-inspector";
8
11
  const IMAGE_TOKEN_ESTIMATE = 1200;
9
12
  const DETAIL_TEXT_LIMIT = 16_000;
10
13
  const PREVIEW_TEXT_LIMIT = 700;
11
- const COMPACTION_SUMMARY_PREFIX = "The conversation history before this point was compacted into the following summary:\n\n<summary>\n";
14
+ const COMPACTION_SUMMARY_PREFIX =
15
+ "The conversation history before this point was compacted into the following summary:\n\n<summary>\n";
12
16
  const COMPACTION_SUMMARY_SUFFIX = "\n</summary>";
13
- const BRANCH_SUMMARY_PREFIX = "The following is a summary of a branch that this conversation came back from:\n\n<summary>\n";
17
+ const BRANCH_SUMMARY_PREFIX =
18
+ "The following is a summary of a branch that this conversation came back from:\n\n<summary>\n";
14
19
  const BRANCH_SUMMARY_SUFFIX = "</summary>";
15
20
 
16
21
  const CATEGORY_META = {
17
- system: {
18
- label: "System prompt",
19
- shortLabel: "System",
20
- color: "#8b5cf6",
21
- description: "Current system prompt, project instructions, loaded guidance, and extension-added prompt text.",
22
- },
23
- toolSchemas: {
24
- label: "Tool schemas",
25
- shortLabel: "Tools",
26
- color: "#06b6d4",
27
- description: "Active tool definitions, descriptions, and JSON schemas sent to the provider.",
28
- },
29
- user: {
30
- label: "User messages",
31
- shortLabel: "User",
32
- color: "#22c55e",
33
- description: "Your prompts and user-role text that stays in the active conversation.",
34
- },
35
- assistant: {
36
- label: "Assistant responses",
37
- shortLabel: "Assistant",
38
- color: "#60a5fa",
39
- description: "Visible assistant text responses kept in context.",
40
- },
41
- thinking: {
42
- label: "Assistant thinking",
43
- shortLabel: "Thinking",
44
- color: "#f97316",
45
- description: "Reasoning/thinking blocks returned by reasoning models when present in the session.",
46
- },
47
- toolCalls: {
48
- label: "Tool calls",
49
- shortLabel: "Calls",
50
- color: "#facc15",
51
- description: "Assistant tool-call arguments, including paths, commands, and edit payloads.",
52
- },
53
- toolResults: {
54
- label: "Tool results",
55
- shortLabel: "Results",
56
- color: "#ef4444",
57
- description: "Tool output returned to the model, usually the largest contributor in coding sessions.",
58
- },
59
- bash: {
60
- label: "User bash",
61
- shortLabel: "Bash",
62
- color: "#a3e635",
63
- description: "User-run ! bash commands and their outputs when included in model context.",
64
- },
65
- summaries: {
66
- label: "Summaries",
67
- shortLabel: "Summaries",
68
- color: "#ec4899",
69
- description: "Compaction and branch summaries replacing earlier history.",
70
- },
71
- custom: {
72
- label: "Custom context",
73
- shortLabel: "Custom",
74
- color: "#14b8a6",
75
- description: "Extension-injected custom messages that participate in context.",
76
- },
77
- images: {
78
- label: "Images",
79
- shortLabel: "Images",
80
- color: "#f59e0b",
81
- description: "Image blocks, estimated conservatively because providers tokenize images differently.",
82
- },
83
- providerDelta: {
84
- label: "Provider / serialization delta",
85
- shortLabel: "Delta",
86
- color: "#64748b",
87
- description: "Unattributed difference between local estimates and pi's footer-compatible provider total.",
88
- },
22
+ system: {
23
+ label: "System prompt",
24
+ shortLabel: "System",
25
+ color: "#8b5cf6",
26
+ description:
27
+ "Current system prompt, project instructions, loaded guidance, and extension-added prompt text.",
28
+ },
29
+ toolSchemas: {
30
+ label: "Tool schemas",
31
+ shortLabel: "Tools",
32
+ color: "#06b6d4",
33
+ description: "Active tool definitions, descriptions, and JSON schemas sent to the provider.",
34
+ },
35
+ user: {
36
+ label: "User messages",
37
+ shortLabel: "User",
38
+ color: "#22c55e",
39
+ description: "Your prompts and user-role text that stays in the active conversation.",
40
+ },
41
+ assistant: {
42
+ label: "Assistant responses",
43
+ shortLabel: "Assistant",
44
+ color: "#60a5fa",
45
+ description: "Visible assistant text responses kept in context.",
46
+ },
47
+ thinking: {
48
+ label: "Assistant thinking",
49
+ shortLabel: "Thinking",
50
+ color: "#f97316",
51
+ description:
52
+ "Reasoning/thinking blocks returned by reasoning models when present in the session.",
53
+ },
54
+ toolCalls: {
55
+ label: "Tool calls",
56
+ shortLabel: "Calls",
57
+ color: "#facc15",
58
+ description: "Assistant tool-call arguments, including paths, commands, and edit payloads.",
59
+ },
60
+ toolResults: {
61
+ label: "Tool results",
62
+ shortLabel: "Results",
63
+ color: "#ef4444",
64
+ description:
65
+ "Tool output returned to the model, usually the largest contributor in coding sessions.",
66
+ },
67
+ bash: {
68
+ label: "User bash",
69
+ shortLabel: "Bash",
70
+ color: "#a3e635",
71
+ description: "User-run ! bash commands and their outputs when included in model context.",
72
+ },
73
+ summaries: {
74
+ label: "Summaries",
75
+ shortLabel: "Summaries",
76
+ color: "#ec4899",
77
+ description: "Compaction and branch summaries replacing earlier history.",
78
+ },
79
+ custom: {
80
+ label: "Custom context",
81
+ shortLabel: "Custom",
82
+ color: "#14b8a6",
83
+ description: "Extension-injected custom messages that participate in context.",
84
+ },
85
+ images: {
86
+ label: "Images",
87
+ shortLabel: "Images",
88
+ color: "#f59e0b",
89
+ description:
90
+ "Image blocks, estimated conservatively because providers tokenize images differently.",
91
+ },
92
+ providerDelta: {
93
+ label: "Provider / serialization delta",
94
+ shortLabel: "Delta",
95
+ color: "#64748b",
96
+ description:
97
+ "Unattributed difference between local estimates and pi's footer-compatible provider total.",
98
+ },
89
99
  } as const;
90
100
 
91
101
  type CategoryId = keyof typeof CATEGORY_META;
92
102
 
93
103
  type CommandOptions = {
94
- open: boolean;
95
- keep: boolean;
96
- redact: boolean;
97
- defaultDataset: DatasetId;
98
- help: boolean;
104
+ open: boolean;
105
+ keep: boolean;
106
+ redact: boolean;
107
+ defaultDataset: DatasetId;
108
+ help: boolean;
99
109
  };
100
110
 
101
111
  type DatasetId = "current" | "full";
102
112
 
103
113
  type MinimalContentBlock = {
104
- type?: string;
105
- text?: string;
106
- thinking?: string;
107
- name?: string;
108
- id?: string;
109
- arguments?: Record<string, unknown>;
110
- data?: string;
111
- mimeType?: string;
112
- source?: {
113
- type?: string;
114
- mediaType?: string;
115
- data?: string;
116
- };
114
+ type?: string;
115
+ text?: string;
116
+ thinking?: string;
117
+ name?: string;
118
+ id?: string;
119
+ arguments?: Record<string, unknown>;
120
+ data?: string;
121
+ mimeType?: string;
122
+ source?: {
123
+ type?: string;
124
+ mediaType?: string;
125
+ data?: string;
126
+ };
117
127
  };
118
128
 
119
129
  type MinimalMessage = {
120
- role?: string;
121
- content?: unknown;
122
- timestamp?: number;
123
- provider?: string;
124
- model?: string;
125
- api?: string;
126
- stopReason?: string;
127
- usage?: unknown;
128
- toolCallId?: string;
129
- toolName?: string;
130
- details?: unknown;
131
- /** Pi keeps nested tool calls here as bounded metadata; they are not transcript content. */
132
- nestedCalls?: unknown;
133
- customType?: string;
134
- display?: boolean;
135
- summary?: string;
136
- fromId?: string;
137
- tokensBefore?: number;
138
- command?: string;
139
- output?: string;
140
- exitCode?: number;
141
- cancelled?: boolean;
142
- truncated?: boolean;
143
- fullOutputPath?: string;
144
- excludeFromContext?: boolean;
130
+ role?: string;
131
+ content?: unknown;
132
+ timestamp?: number;
133
+ provider?: string;
134
+ model?: string;
135
+ api?: string;
136
+ stopReason?: string;
137
+ usage?: unknown;
138
+ toolCallId?: string;
139
+ toolName?: string;
140
+ details?: unknown;
141
+ /** Pi keeps nested tool calls here as bounded metadata; they are not transcript content. */
142
+ nestedCalls?: unknown;
143
+ customType?: string;
144
+ display?: boolean;
145
+ summary?: string;
146
+ fromId?: string;
147
+ tokensBefore?: number;
148
+ command?: string;
149
+ output?: string;
150
+ exitCode?: number;
151
+ cancelled?: boolean;
152
+ truncated?: boolean;
153
+ fullOutputPath?: string;
154
+ excludeFromContext?: boolean;
145
155
  };
146
156
 
147
157
  type MinimalEntry = {
148
- type?: string;
149
- id?: string;
150
- parentId?: string | null;
151
- timestamp?: string;
152
- message?: MinimalMessage;
153
- customType?: string;
154
- content?: unknown;
155
- display?: boolean;
156
- details?: unknown;
157
- summary?: string;
158
- fromId?: string;
159
- tokensBefore?: number;
160
- firstKeptEntryId?: string;
158
+ type?: string;
159
+ id?: string;
160
+ parentId?: string | null;
161
+ timestamp?: string;
162
+ message?: MinimalMessage;
163
+ customType?: string;
164
+ content?: unknown;
165
+ display?: boolean;
166
+ details?: unknown;
167
+ summary?: string;
168
+ fromId?: string;
169
+ tokensBefore?: number;
170
+ firstKeptEntryId?: string;
161
171
  };
162
172
 
163
173
  type MinimalProjectionEntry = {
164
- sourceEntry: MinimalEntry;
165
- messages: MinimalMessage[];
174
+ sourceEntry: MinimalEntry;
175
+ messages: MinimalMessage[];
166
176
  };
167
177
 
168
178
  type MinimalSessionProjection = {
169
- entries: MinimalProjectionEntry[];
170
- messages?: MinimalMessage[];
179
+ entries: MinimalProjectionEntry[];
180
+ messages?: MinimalMessage[];
171
181
  };
172
182
 
173
183
  type AnalysisResult = {
174
- segments: Segment[];
175
- messageCount: number;
184
+ segments: Segment[];
185
+ messageCount: number;
176
186
  };
177
187
 
178
188
  type Segment = {
179
- id: string;
180
- category: CategoryId;
181
- label: string;
182
- source: string;
183
- role: string;
184
- tokens: number;
185
- chars: number;
186
- turn: number;
187
- sequence: number;
188
- entryId?: string;
189
- timestamp?: string;
190
- toolName?: string;
191
- toolCallId?: string;
192
- path?: string;
193
- command?: string;
194
- excluded?: boolean;
195
- displayOnly?: boolean;
196
- preview: string;
197
- detail: string;
198
- note?: string;
189
+ id: string;
190
+ category: CategoryId;
191
+ label: string;
192
+ source: string;
193
+ role: string;
194
+ tokens: number;
195
+ chars: number;
196
+ turn: number;
197
+ sequence: number;
198
+ entryId?: string;
199
+ timestamp?: string;
200
+ toolName?: string;
201
+ toolCallId?: string;
202
+ path?: string;
203
+ command?: string;
204
+ excluded?: boolean;
205
+ displayOnly?: boolean;
206
+ preview: string;
207
+ detail: string;
208
+ note?: string;
199
209
  };
200
210
 
201
211
  type CategoryStat = {
202
- id: CategoryId;
203
- label: string;
204
- shortLabel: string;
205
- color: string;
206
- description: string;
207
- tokens: number;
208
- displayTokens: number;
209
- percent: number;
210
- segments: number;
212
+ id: CategoryId;
213
+ label: string;
214
+ shortLabel: string;
215
+ color: string;
216
+ description: string;
217
+ tokens: number;
218
+ displayTokens: number;
219
+ percent: number;
220
+ segments: number;
211
221
  };
212
222
 
213
223
  type AggregateStat = {
214
- key: string;
215
- label: string;
216
- tokens: number;
217
- segments: number;
218
- percent: number;
224
+ key: string;
225
+ label: string;
226
+ tokens: number;
227
+ segments: number;
228
+ percent: number;
219
229
  };
220
230
 
221
231
  type DatasetStats = {
222
- tokens: number;
223
- rawTokens: number;
224
- visibleRawTokens: number;
225
- excludedTokens: number;
226
- providerDeltaTokens: number;
227
- estimatorOverageTokens: number;
228
- reconciliationScale: number;
229
- segmentCount: number;
230
- messageCount: number;
231
- categories: CategoryStat[];
232
- topSegments: Segment[];
233
- topTools: AggregateStat[];
234
- topPaths: AggregateStat[];
235
- topTurns: AggregateStat[];
232
+ tokens: number;
233
+ rawTokens: number;
234
+ visibleRawTokens: number;
235
+ excludedTokens: number;
236
+ providerDeltaTokens: number;
237
+ estimatorOverageTokens: number;
238
+ reconciliationScale: number;
239
+ segmentCount: number;
240
+ messageCount: number;
241
+ categories: CategoryStat[];
242
+ topSegments: Segment[];
243
+ topTools: AggregateStat[];
244
+ topPaths: AggregateStat[];
245
+ topTurns: AggregateStat[];
236
246
  };
237
247
 
238
248
  type Dataset = {
239
- id: DatasetId;
240
- label: string;
241
- description: string;
242
- segments: Segment[];
243
- stats: DatasetStats;
249
+ id: DatasetId;
250
+ label: string;
251
+ description: string;
252
+ segments: Segment[];
253
+ stats: DatasetStats;
244
254
  };
245
255
 
246
256
  type RoutedModel = {
247
- provider: string;
248
- id: string;
257
+ provider: string;
258
+ id: string;
249
259
  };
250
260
 
251
261
  type ReportData = {
252
- generatedAt: string;
253
- cwd: string;
254
- session: {
255
- id?: string;
256
- name?: string;
257
- file?: string;
258
- };
259
- model: {
260
- provider?: string;
261
- id?: string;
262
- contextWindow?: number;
263
- thinkingLevel?: string;
264
- virtual?: boolean;
265
- routed?: RoutedModel;
266
- };
267
- contextUsage: {
268
- tokens: number | null;
269
- contextWindow: number | null;
270
- percent: number | null;
271
- };
272
- options: {
273
- redacted: boolean;
274
- defaultDataset: DatasetId;
275
- };
276
- categoryMeta: typeof CATEGORY_META;
277
- datasets: Record<DatasetId, Dataset>;
278
- notes: string[];
262
+ generatedAt: string;
263
+ cwd: string;
264
+ session: {
265
+ id?: string;
266
+ name?: string;
267
+ file?: string;
268
+ };
269
+ model: {
270
+ provider?: string;
271
+ id?: string;
272
+ contextWindow?: number;
273
+ thinkingLevel?: string;
274
+ virtual?: boolean;
275
+ routed?: RoutedModel;
276
+ };
277
+ contextUsage: {
278
+ tokens: number | null;
279
+ contextWindow: number | null;
280
+ percent: number | null;
281
+ };
282
+ options: {
283
+ redacted: boolean;
284
+ defaultDataset: DatasetId;
285
+ };
286
+ categoryMeta: typeof CATEGORY_META;
287
+ datasets: Record<DatasetId, Dataset>;
288
+ notes: string[];
279
289
  };
280
290
 
281
291
  type AnalyzerState = {
282
- segments: Segment[];
283
- sequence: number;
284
- turn: number;
285
- redact: boolean;
292
+ segments: Segment[];
293
+ sequence: number;
294
+ turn: number;
295
+ redact: boolean;
286
296
  };
287
297
 
288
298
  function parseArgs(args: string): CommandOptions {
289
- const options: CommandOptions = {
290
- open: true,
291
- keep: false,
292
- redact: false,
293
- defaultDataset: "current",
294
- help: false,
295
- };
296
-
297
- for (const token of args.split(/\s+/).map((part) => part.trim()).filter(Boolean)) {
298
- switch (token) {
299
- case "--no-open":
300
- case "--no-browser":
301
- options.open = false;
302
- break;
303
- case "--keep":
304
- options.keep = true;
305
- break;
306
- case "--redact":
307
- options.redact = true;
308
- break;
309
- case "--full":
310
- options.defaultDataset = "full";
311
- break;
312
- case "--current":
313
- options.defaultDataset = "current";
314
- break;
315
- case "--help":
316
- case "-h":
317
- helpOption(options);
318
- break;
319
- }
320
- }
321
-
322
- return options;
299
+ const options: CommandOptions = {
300
+ open: true,
301
+ keep: false,
302
+ redact: false,
303
+ defaultDataset: "current",
304
+ help: false,
305
+ };
306
+
307
+ for (const token of args
308
+ .split(/\s+/)
309
+ .map((part) => part.trim())
310
+ .filter(Boolean)) {
311
+ switch (token) {
312
+ case "--no-open":
313
+ case "--no-browser":
314
+ options.open = false;
315
+ break;
316
+ case "--keep":
317
+ options.keep = true;
318
+ break;
319
+ case "--redact":
320
+ options.redact = true;
321
+ break;
322
+ case "--full":
323
+ options.defaultDataset = "full";
324
+ break;
325
+ case "--current":
326
+ options.defaultDataset = "current";
327
+ break;
328
+ case "--help":
329
+ case "-h":
330
+ helpOption(options);
331
+ break;
332
+ }
333
+ }
334
+
335
+ return options;
323
336
  }
324
337
 
325
338
  function helpOption(options: CommandOptions): void {
326
- options.help = true;
339
+ options.help = true;
327
340
  }
328
341
 
329
342
  function usageText(): string {
330
- return [
331
- "Usage: /context [--no-open] [--keep] [--redact] [--full]",
332
- "",
333
- "Opens a local HTML report showing where the current session context is going.",
334
- "",
335
- "Options:",
336
- " --no-open Write the report but do not open a browser.",
337
- " --keep Save under " + CONFIG_DIR_NAME + "/context-reports/ instead of the OS temp directory.",
338
- " --redact Hide message/tool contents while keeping token attribution.",
339
- " --full Open the report on the full active branch tab instead of current context.",
340
- ].join("\n");
343
+ return [
344
+ "Usage: /context [--no-open] [--keep] [--redact] [--full]",
345
+ "",
346
+ "Opens a local HTML report showing where the current session context is going.",
347
+ "",
348
+ "Options:",
349
+ " --no-open Write the report but do not open a browser.",
350
+ " --keep Save under " +
351
+ CONFIG_DIR_NAME +
352
+ "/context-reports/ instead of the OS temp directory.",
353
+ " --redact Hide message/tool contents while keeping token attribution.",
354
+ " --full Open the report on the full active branch tab instead of current context.",
355
+ ].join("\n");
341
356
  }
342
357
 
343
358
  function formatTokens(tokens: number | null | undefined): string {
344
- if (tokens == null || !Number.isFinite(tokens)) return "?";
345
- const rounded = Math.round(tokens);
346
- if (Math.abs(rounded) < 1000) return rounded.toLocaleString();
347
- if (Math.abs(rounded) < 10_000) return `${(rounded / 1000).toFixed(1)}k`;
348
- if (Math.abs(rounded) < 1_000_000) return `${Math.round(rounded / 1000).toLocaleString()}k`;
349
- return `${(rounded / 1_000_000).toFixed(1)}M`;
359
+ if (tokens == null || !Number.isFinite(tokens)) return "?";
360
+ const rounded = Math.round(tokens);
361
+ if (Math.abs(rounded) < 1000) return rounded.toLocaleString();
362
+ if (Math.abs(rounded) < 10_000) return `${(rounded / 1000).toFixed(1)}k`;
363
+ if (Math.abs(rounded) < 1_000_000) return `${Math.round(rounded / 1000).toLocaleString()}k`;
364
+ return `${(rounded / 1_000_000).toFixed(1)}M`;
350
365
  }
351
366
 
352
367
  function estimateTextTokens(text: string): number {
353
- if (!text) return 0;
354
- return Math.max(1, Math.ceil(text.length / 4));
368
+ if (!text) return 0;
369
+ return Math.max(1, Math.ceil(text.length / 4));
355
370
  }
356
371
 
357
372
  function makeDetail(text: string, redact: boolean): string {
358
- if (redact) return text ? `[redacted ${text.length.toLocaleString()} characters]` : "";
359
- if (text.length <= DETAIL_TEXT_LIMIT) return text;
360
- return `${text.slice(0, DETAIL_TEXT_LIMIT)}\n\n[… ${(
361
- text.length - DETAIL_TEXT_LIMIT
362
- ).toLocaleString()} more characters omitted from the report]`;
373
+ if (redact) return text ? `[redacted ${text.length.toLocaleString()} characters]` : "";
374
+ if (text.length <= DETAIL_TEXT_LIMIT) return text;
375
+ return `${text.slice(0, DETAIL_TEXT_LIMIT)}\n\n[… ${(
376
+ text.length - DETAIL_TEXT_LIMIT
377
+ ).toLocaleString()} more characters omitted from the report]`;
363
378
  }
364
379
 
365
380
  function makePreview(text: string, redact: boolean): string {
366
- if (redact) return text ? `[redacted ${text.length.toLocaleString()} chars]` : "";
367
- const compact = text.replace(/\s+/g, " ").trim();
368
- if (compact.length <= PREVIEW_TEXT_LIMIT) return compact;
369
- return `${compact.slice(0, PREVIEW_TEXT_LIMIT)}…`;
381
+ if (redact) return text ? `[redacted ${text.length.toLocaleString()} chars]` : "";
382
+ const compact = text.replace(/\s+/g, " ").trim();
383
+ if (compact.length <= PREVIEW_TEXT_LIMIT) return compact;
384
+ return `${compact.slice(0, PREVIEW_TEXT_LIMIT)}…`;
370
385
  }
371
386
 
372
387
  function safeJson(value: unknown): string {
373
- try {
374
- return JSON.stringify(value, null, 2) ?? "";
375
- } catch {
376
- return String(value);
377
- }
388
+ try {
389
+ return JSON.stringify(value, null, 2) ?? "";
390
+ } catch {
391
+ return String(value);
392
+ }
378
393
  }
379
394
 
380
395
  function isRecord(value: unknown): value is Record<string, unknown> {
381
- return typeof value === "object" && value !== null;
396
+ return typeof value === "object" && value !== null;
382
397
  }
383
398
 
384
399
  function isKnownPositiveNumber(value: unknown): value is number {
385
- return typeof value === "number" && Number.isFinite(value) && value > 0;
400
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
386
401
  }
387
402
 
388
403
  function isKnownNonNegativeNumber(value: unknown): value is number {
389
- return typeof value === "number" && Number.isFinite(value) && value >= 0;
404
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
390
405
  }
391
406
 
392
407
  function isVirtualModel(model: unknown): boolean {
393
- return isRecord(model) && model.api === "pi-virtual";
408
+ return isRecord(model) && model.api === "pi-virtual";
394
409
  }
395
410
 
396
411
  function getContentBlocks(content: unknown): MinimalContentBlock[] {
397
- if (typeof content === "string") return [{ type: "text", text: content }];
398
- if (!Array.isArray(content)) return [];
399
- return content.filter(isRecord) as MinimalContentBlock[];
412
+ if (typeof content === "string") return [{ type: "text", text: content }];
413
+ if (!Array.isArray(content)) return [];
414
+ return content.filter(isRecord) as MinimalContentBlock[];
400
415
  }
401
416
 
402
417
  function contentText(content: unknown): string {
403
- if (typeof content === "string") return content;
404
- if (!Array.isArray(content)) return "";
405
- const parts: string[] = [];
406
- for (const block of content) {
407
- if (!isRecord(block)) continue;
408
- if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
409
- }
410
- return parts.join("\n");
418
+ if (typeof content === "string") return content;
419
+ if (!Array.isArray(content)) return "";
420
+ const parts: string[] = [];
421
+ for (const block of content) {
422
+ if (!isRecord(block)) continue;
423
+ if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
424
+ }
425
+ return parts.join("\n");
411
426
  }
412
427
 
413
428
  function isoTimestamp(message: MinimalMessage, entry?: MinimalEntry): string | undefined {
414
- if (typeof message.timestamp === "number" && Number.isFinite(message.timestamp)) {
415
- return new Date(message.timestamp).toISOString();
416
- }
417
- return entry?.timestamp;
429
+ if (typeof message.timestamp === "number" && Number.isFinite(message.timestamp)) {
430
+ return new Date(message.timestamp).toISOString();
431
+ }
432
+ return entry?.timestamp;
418
433
  }
419
434
 
420
435
  function extractLikelyPath(args: Record<string, unknown> | undefined): string | undefined {
421
- if (!args) return undefined;
422
- for (const key of ["path", "file", "filePath", "target", "cwd", "glob"] as const) {
423
- const value = args[key];
424
- if (typeof value === "string" && value.trim()) return value;
425
- }
426
- return undefined;
436
+ if (!args) return undefined;
437
+ for (const key of ["path", "file", "filePath", "target", "cwd", "glob"] as const) {
438
+ const value = args[key];
439
+ if (typeof value === "string" && value.trim()) return value;
440
+ }
441
+ return undefined;
427
442
  }
428
443
 
429
444
  function extractLikelyCommand(args: Record<string, unknown> | undefined): string | undefined {
430
- const value = args?.command;
431
- return typeof value === "string" && value.trim() ? value : undefined;
445
+ const value = args?.command;
446
+ return typeof value === "string" && value.trim() ? value : undefined;
432
447
  }
433
448
 
434
449
  function addSegment(
435
- state: AnalyzerState,
436
- segment: Omit<Segment, "id" | "sequence" | "preview" | "detail"> & { text: string },
450
+ state: AnalyzerState,
451
+ segment: Omit<Segment, "id" | "sequence" | "preview" | "detail"> & { text: string },
437
452
  ): void {
438
- const sequence = state.sequence++;
439
- const detail = makeDetail(segment.text, state.redact);
440
- const preview = makePreview(segment.text, state.redact);
441
- const { text: _text, ...rest } = segment;
442
- const safeRest = state.redact ? redactSegmentMetadata(rest) : rest;
443
- state.segments.push({
444
- ...safeRest,
445
- id: `seg-${sequence}`,
446
- sequence,
447
- preview,
448
- detail,
449
- });
450
- }
451
-
452
- function redactSegmentMetadata<T extends Omit<Segment, "id" | "sequence" | "preview" | "detail">>(segment: T): T {
453
- return {
454
- ...segment,
455
- entryId: segment.entryId ? "[redacted entry]" : undefined,
456
- timestamp: segment.timestamp ? "[redacted timestamp]" : undefined,
457
- toolCallId: segment.toolCallId ? "[redacted tool call]" : undefined,
458
- path: segment.path ? "[redacted path]" : undefined,
459
- command: segment.command ? "[redacted command]" : undefined,
460
- note: segment.note ? "[redacted note]" : undefined,
461
- } as T;
453
+ const sequence = state.sequence++;
454
+ const detail = makeDetail(segment.text, state.redact);
455
+ const preview = makePreview(segment.text, state.redact);
456
+ const { text: _text, ...rest } = segment;
457
+ const safeRest = state.redact ? redactSegmentMetadata(rest) : rest;
458
+ state.segments.push({
459
+ ...safeRest,
460
+ id: `seg-${sequence}`,
461
+ sequence,
462
+ preview,
463
+ detail,
464
+ });
465
+ }
466
+
467
+ function redactSegmentMetadata<T extends Omit<Segment, "id" | "sequence" | "preview" | "detail">>(
468
+ segment: T,
469
+ ): T {
470
+ return {
471
+ ...segment,
472
+ entryId: segment.entryId ? "[redacted entry]" : undefined,
473
+ timestamp: segment.timestamp ? "[redacted timestamp]" : undefined,
474
+ toolCallId: segment.toolCallId ? "[redacted tool call]" : undefined,
475
+ path: segment.path ? "[redacted path]" : undefined,
476
+ command: segment.command ? "[redacted command]" : undefined,
477
+ note: segment.note ? "[redacted note]" : undefined,
478
+ } as T;
462
479
  }
463
480
 
464
481
  function addTextSegment(
465
- state: AnalyzerState,
466
- category: CategoryId,
467
- label: string,
468
- text: string,
469
- base: {
470
- source: string;
471
- role: string;
472
- entryId?: string;
473
- timestamp?: string;
474
- toolName?: string;
475
- toolCallId?: string;
476
- path?: string;
477
- command?: string;
478
- excluded?: boolean;
479
- displayOnly?: boolean;
480
- note?: string;
481
- turn?: number;
482
- },
482
+ state: AnalyzerState,
483
+ category: CategoryId,
484
+ label: string,
485
+ text: string,
486
+ base: {
487
+ source: string;
488
+ role: string;
489
+ entryId?: string;
490
+ timestamp?: string;
491
+ toolName?: string;
492
+ toolCallId?: string;
493
+ path?: string;
494
+ command?: string;
495
+ excluded?: boolean;
496
+ displayOnly?: boolean;
497
+ note?: string;
498
+ turn?: number;
499
+ },
483
500
  ): void {
484
- const chars = text.length;
485
- const tokens = estimateTextTokens(text);
486
- if (tokens <= 0 && chars <= 0) return;
487
- addSegment(state, {
488
- category,
489
- label,
490
- source: base.source,
491
- role: base.role,
492
- tokens,
493
- chars,
494
- turn: base.turn ?? state.turn,
495
- entryId: base.entryId,
496
- timestamp: base.timestamp,
497
- toolName: base.toolName,
498
- toolCallId: base.toolCallId,
499
- path: base.path,
500
- command: base.command,
501
- excluded: base.excluded,
502
- displayOnly: base.displayOnly,
503
- note: base.note,
504
- text,
505
- });
501
+ const chars = text.length;
502
+ const tokens = estimateTextTokens(text);
503
+ if (tokens <= 0 && chars <= 0) return;
504
+ addSegment(state, {
505
+ category,
506
+ label,
507
+ source: base.source,
508
+ role: base.role,
509
+ tokens,
510
+ chars,
511
+ turn: base.turn ?? state.turn,
512
+ entryId: base.entryId,
513
+ timestamp: base.timestamp,
514
+ toolName: base.toolName,
515
+ toolCallId: base.toolCallId,
516
+ path: base.path,
517
+ command: base.command,
518
+ excluded: base.excluded,
519
+ displayOnly: base.displayOnly,
520
+ note: base.note,
521
+ text,
522
+ });
506
523
  }
507
524
 
508
525
  function addImageSegment(
509
- state: AnalyzerState,
510
- label: string,
511
- base: {
512
- source: string;
513
- role: string;
514
- entryId?: string;
515
- timestamp?: string;
516
- toolName?: string;
517
- toolCallId?: string;
518
- excluded?: boolean;
519
- turn?: number;
520
- mimeType?: string;
521
- },
526
+ state: AnalyzerState,
527
+ label: string,
528
+ base: {
529
+ source: string;
530
+ role: string;
531
+ entryId?: string;
532
+ timestamp?: string;
533
+ toolName?: string;
534
+ toolCallId?: string;
535
+ excluded?: boolean;
536
+ turn?: number;
537
+ mimeType?: string;
538
+ },
522
539
  ): void {
523
- addSegment(state, {
524
- category: "images",
525
- label,
526
- source: base.source,
527
- role: base.role,
528
- tokens: IMAGE_TOKEN_ESTIMATE,
529
- chars: IMAGE_TOKEN_ESTIMATE * 4,
530
- turn: base.turn ?? state.turn,
531
- entryId: base.entryId,
532
- timestamp: base.timestamp,
533
- toolName: base.toolName,
534
- toolCallId: base.toolCallId,
535
- excluded: base.excluded,
536
- note: base.mimeType ? `Image block (${base.mimeType}); token count is a conservative estimate.` : "Image block; token count is a conservative estimate.",
537
- text: base.mimeType ? `[image: ${base.mimeType}]` : "[image]",
538
- });
539
- }
540
-
541
- function analyzeUserContent(state: AnalyzerState, message: MinimalMessage, entry?: MinimalEntry): void {
542
- state.turn++;
543
- const timestamp = isoTimestamp(message, entry);
544
- const entryId = entry?.id;
545
- const blocks = getContentBlocks(message.content);
546
- for (const block of blocks) {
547
- if (block.type === "text" && typeof block.text === "string") {
548
- addTextSegment(state, "user", "User message", block.text, {
549
- source: "message",
550
- role: "user",
551
- entryId,
552
- timestamp,
553
- });
554
- } else if (block.type === "image") {
555
- addImageSegment(state, "User image", {
556
- source: "message",
557
- role: "user",
558
- entryId,
559
- timestamp,
560
- mimeType: block.mimeType ?? block.source?.mediaType,
561
- });
562
- }
563
- }
564
- }
565
-
566
- function analyzeAssistantContent(state: AnalyzerState, message: MinimalMessage, entry?: MinimalEntry): void {
567
- const timestamp = isoTimestamp(message, entry);
568
- const entryId = entry?.id;
569
- const blocks = getContentBlocks(message.content);
570
- for (const block of blocks) {
571
- if (block.type === "text" && typeof block.text === "string") {
572
- addTextSegment(state, "assistant", "Assistant response", block.text, {
573
- source: "message",
574
- role: "assistant",
575
- entryId,
576
- timestamp,
577
- });
578
- } else if (block.type === "thinking" && typeof block.thinking === "string") {
579
- addTextSegment(state, "thinking", "Assistant thinking", block.thinking, {
580
- source: "message",
581
- role: "assistant",
582
- entryId,
583
- timestamp,
584
- });
585
- } else if (block.type === "toolCall" && typeof block.name === "string") {
586
- const args = isRecord(block.arguments) ? block.arguments : {};
587
- const toolCallText = `${block.name}(${safeJson(args)})`;
588
- addTextSegment(state, "toolCalls", `Tool call: ${block.name}`, toolCallText, {
589
- source: "tool-call",
590
- role: "assistant",
591
- entryId,
592
- timestamp,
593
- toolName: block.name,
594
- toolCallId: block.id,
595
- path: extractLikelyPath(args),
596
- command: extractLikelyCommand(args),
597
- });
598
- }
599
- }
540
+ addSegment(state, {
541
+ category: "images",
542
+ label,
543
+ source: base.source,
544
+ role: base.role,
545
+ tokens: IMAGE_TOKEN_ESTIMATE,
546
+ chars: IMAGE_TOKEN_ESTIMATE * 4,
547
+ turn: base.turn ?? state.turn,
548
+ entryId: base.entryId,
549
+ timestamp: base.timestamp,
550
+ toolName: base.toolName,
551
+ toolCallId: base.toolCallId,
552
+ excluded: base.excluded,
553
+ note: base.mimeType
554
+ ? `Image block (${base.mimeType}); token count is a conservative estimate.`
555
+ : "Image block; token count is a conservative estimate.",
556
+ text: base.mimeType ? `[image: ${base.mimeType}]` : "[image]",
557
+ });
558
+ }
559
+
560
+ function analyzeUserContent(
561
+ state: AnalyzerState,
562
+ message: MinimalMessage,
563
+ entry?: MinimalEntry,
564
+ ): void {
565
+ state.turn++;
566
+ const timestamp = isoTimestamp(message, entry);
567
+ const entryId = entry?.id;
568
+ const blocks = getContentBlocks(message.content);
569
+ for (const block of blocks) {
570
+ if (block.type === "text" && typeof block.text === "string") {
571
+ addTextSegment(state, "user", "User message", block.text, {
572
+ source: "message",
573
+ role: "user",
574
+ entryId,
575
+ timestamp,
576
+ });
577
+ } else if (block.type === "image") {
578
+ addImageSegment(state, "User image", {
579
+ source: "message",
580
+ role: "user",
581
+ entryId,
582
+ timestamp,
583
+ mimeType: block.mimeType ?? block.source?.mediaType,
584
+ });
585
+ }
586
+ }
587
+ }
588
+
589
+ function analyzeAssistantContent(
590
+ state: AnalyzerState,
591
+ message: MinimalMessage,
592
+ entry?: MinimalEntry,
593
+ ): void {
594
+ const timestamp = isoTimestamp(message, entry);
595
+ const entryId = entry?.id;
596
+ const blocks = getContentBlocks(message.content);
597
+ for (const block of blocks) {
598
+ if (block.type === "text" && typeof block.text === "string") {
599
+ addTextSegment(state, "assistant", "Assistant response", block.text, {
600
+ source: "message",
601
+ role: "assistant",
602
+ entryId,
603
+ timestamp,
604
+ });
605
+ } else if (block.type === "thinking" && typeof block.thinking === "string") {
606
+ addTextSegment(state, "thinking", "Assistant thinking", block.thinking, {
607
+ source: "message",
608
+ role: "assistant",
609
+ entryId,
610
+ timestamp,
611
+ });
612
+ } else if (block.type === "toolCall" && typeof block.name === "string") {
613
+ const args = isRecord(block.arguments) ? block.arguments : {};
614
+ const toolCallText = `${block.name}(${safeJson(args)})`;
615
+ addTextSegment(state, "toolCalls", `Tool call: ${block.name}`, toolCallText, {
616
+ source: "tool-call",
617
+ role: "assistant",
618
+ entryId,
619
+ timestamp,
620
+ toolName: block.name,
621
+ toolCallId: block.id,
622
+ path: extractLikelyPath(args),
623
+ command: extractLikelyCommand(args),
624
+ });
625
+ }
626
+ }
600
627
  }
601
628
 
602
629
  function nestedCallsNote(message: MinimalMessage): string | undefined {
603
- const nested = message.nestedCalls;
604
- if (!isRecord(nested) || !Array.isArray(nested.calls)) return undefined;
605
- const count = nested.calls.length;
606
- if (nested.complete === false) {
607
- return `Pi recorded ${count} bounded nested tool call${count === 1 ? "" : "s"} incompletely; nested results are not transcript context and are excluded from attribution.`;
608
- }
609
- return `Pi recorded ${count} bounded nested tool call${count === 1 ? "" : "s"}; nested results are not transcript context and are excluded from attribution.`;
610
- }
611
-
612
- function analyzeToolResult(state: AnalyzerState, message: MinimalMessage, entry?: MinimalEntry): void {
613
- const timestamp = isoTimestamp(message, entry);
614
- const entryId = entry?.id;
615
- const toolName = message.toolName ?? "tool";
616
- const blocks = getContentBlocks(message.content);
617
- const nestedNote = nestedCallsNote(message);
618
- for (const block of blocks) {
619
- if (block.type === "text" && typeof block.text === "string") {
620
- const noteParts: string[] = [];
621
- if (message.details && isRecord(message.details)) {
622
- if (isRecord(message.details.truncation) && message.details.truncation.truncated) noteParts.push("Result was truncated before entering context.");
623
- if (typeof message.details.fullOutputPath === "string") noteParts.push(`Full output: ${message.details.fullOutputPath}`);
624
- }
625
- if (nestedNote) noteParts.push(nestedNote);
626
- addTextSegment(state, "toolResults", `Tool result: ${toolName}`, block.text, {
627
- source: "tool-result",
628
- role: "toolResult",
629
- entryId,
630
- timestamp,
631
- toolName,
632
- toolCallId: message.toolCallId,
633
- note: noteParts.join(" ") || undefined,
634
- });
635
- } else if (block.type === "image") {
636
- addImageSegment(state, `Tool image: ${toolName}`, {
637
- source: "tool-result",
638
- role: "toolResult",
639
- entryId,
640
- timestamp,
641
- toolName,
642
- toolCallId: message.toolCallId,
643
- mimeType: block.mimeType ?? block.source?.mediaType,
644
- });
645
- }
646
- }
630
+ const nested = message.nestedCalls;
631
+ if (!isRecord(nested) || !Array.isArray(nested.calls)) return undefined;
632
+ const count = nested.calls.length;
633
+ if (nested.complete === false) {
634
+ return `Pi recorded ${count} bounded nested tool call${count === 1 ? "" : "s"} incompletely; nested results are not transcript context and are excluded from attribution.`;
635
+ }
636
+ return `Pi recorded ${count} bounded nested tool call${count === 1 ? "" : "s"}; nested results are not transcript context and are excluded from attribution.`;
637
+ }
638
+
639
+ function analyzeToolResult(
640
+ state: AnalyzerState,
641
+ message: MinimalMessage,
642
+ entry?: MinimalEntry,
643
+ ): void {
644
+ const timestamp = isoTimestamp(message, entry);
645
+ const entryId = entry?.id;
646
+ const toolName = message.toolName ?? "tool";
647
+ const blocks = getContentBlocks(message.content);
648
+ const nestedNote = nestedCallsNote(message);
649
+ for (const block of blocks) {
650
+ if (block.type === "text" && typeof block.text === "string") {
651
+ const noteParts: string[] = [];
652
+ if (message.details && isRecord(message.details)) {
653
+ if (isRecord(message.details.truncation) && message.details.truncation.truncated)
654
+ noteParts.push("Result was truncated before entering context.");
655
+ if (typeof message.details.fullOutputPath === "string")
656
+ noteParts.push(`Full output: ${message.details.fullOutputPath}`);
657
+ }
658
+ if (nestedNote) noteParts.push(nestedNote);
659
+ addTextSegment(state, "toolResults", `Tool result: ${toolName}`, block.text, {
660
+ source: "tool-result",
661
+ role: "toolResult",
662
+ entryId,
663
+ timestamp,
664
+ toolName,
665
+ toolCallId: message.toolCallId,
666
+ note: noteParts.join(" ") || undefined,
667
+ });
668
+ } else if (block.type === "image") {
669
+ addImageSegment(state, `Tool image: ${toolName}`, {
670
+ source: "tool-result",
671
+ role: "toolResult",
672
+ entryId,
673
+ timestamp,
674
+ toolName,
675
+ toolCallId: message.toolCallId,
676
+ mimeType: block.mimeType ?? block.source?.mediaType,
677
+ });
678
+ }
679
+ }
647
680
  }
648
681
 
649
682
  function bashExecutionToModelText(message: MinimalMessage): string {
650
- let text = `Ran \`${message.command ?? ""}\`\n`;
651
- if (message.output) text += `\`\`\`\n${message.output}\n\`\`\``;
652
- else text += "(no output)";
653
- if (message.cancelled) text += "\n\n(command cancelled)";
654
- else if (message.exitCode !== null && message.exitCode !== undefined && message.exitCode !== 0) {
655
- text += `\n\nCommand exited with code ${message.exitCode}`;
656
- }
657
- if (message.truncated && message.fullOutputPath) {
658
- text += `\n\n[Output truncated. Full output: ${message.fullOutputPath}]`;
659
- }
660
- return text;
661
- }
662
-
663
- function analyzeBashExecution(state: AnalyzerState, message: MinimalMessage, entry?: MinimalEntry): void {
664
- state.turn++;
665
- const timestamp = isoTimestamp(message, entry);
666
- const text = bashExecutionToModelText(message);
667
- const excluded = message.excludeFromContext === true;
668
- addTextSegment(state, "bash", "User bash execution", text, {
669
- source: "bash-execution",
670
- role: "bashExecution",
671
- entryId: entry?.id,
672
- timestamp,
673
- command: message.command,
674
- excluded,
675
- note: excluded
676
- ? "This was marked excludeFromContext by pi (!! command). It is shown as session-only context."
677
- : undefined,
678
- });
679
- }
680
-
681
- function analyzeCustomMessage(state: AnalyzerState, message: MinimalMessage, entry?: MinimalEntry): void {
682
- state.turn++;
683
- const timestamp = isoTimestamp(message, entry);
684
- const text = contentText(message.content);
685
- addTextSegment(state, "custom", message.customType ? `Custom context: ${message.customType}` : "Custom context", text, {
686
- source: "custom-message",
687
- role: "custom",
688
- entryId: entry?.id,
689
- timestamp,
690
- displayOnly: message.display === false,
691
- note: message.display === false ? "Hidden in TUI, but sent to the model as custom context." : undefined,
692
- });
693
- for (const block of getContentBlocks(message.content)) {
694
- if (block.type === "image") {
695
- addImageSegment(state, message.customType ? `Custom image: ${message.customType}` : "Custom image", {
696
- source: "custom-message",
697
- role: "custom",
698
- entryId: entry?.id,
699
- timestamp,
700
- mimeType: block.mimeType ?? block.source?.mediaType,
701
- });
702
- }
703
- }
704
- }
705
-
706
- function analyzeSummaryMessage(state: AnalyzerState, message: MinimalMessage, entry?: MinimalEntry): void {
707
- const isCompaction = message.role === "compactionSummary" || entry?.type === "compaction";
708
- const label = isCompaction ? "Compaction summary" : "Branch summary";
709
- const summary = message.summary ?? entry?.summary ?? "";
710
- const modelText = isCompaction
711
- ? `${COMPACTION_SUMMARY_PREFIX}${summary}${COMPACTION_SUMMARY_SUFFIX}`
712
- : `${BRANCH_SUMMARY_PREFIX}${summary}${BRANCH_SUMMARY_SUFFIX}`;
713
- addTextSegment(state, "summaries", label, modelText, {
714
- source: isCompaction ? "compaction" : "branch-summary",
715
- role: message.role ?? (isCompaction ? "compactionSummary" : "branchSummary"),
716
- entryId: entry?.id,
717
- timestamp: isoTimestamp(message, entry),
718
- note: isCompaction && typeof message.tokensBefore === "number"
719
- ? `This summary replaced about ${formatTokens(message.tokensBefore)} earlier tokens before compaction.`
720
- : undefined,
721
- });
683
+ let text = `Ran \`${message.command ?? ""}\`\n`;
684
+ if (message.output) text += `\`\`\`\n${message.output}\n\`\`\``;
685
+ else text += "(no output)";
686
+ if (message.cancelled) text += "\n\n(command cancelled)";
687
+ else if (message.exitCode !== null && message.exitCode !== undefined && message.exitCode !== 0) {
688
+ text += `\n\nCommand exited with code ${message.exitCode}`;
689
+ }
690
+ if (message.truncated && message.fullOutputPath) {
691
+ text += `\n\n[Output truncated. Full output: ${message.fullOutputPath}]`;
692
+ }
693
+ return text;
694
+ }
695
+
696
+ function analyzeBashExecution(
697
+ state: AnalyzerState,
698
+ message: MinimalMessage,
699
+ entry?: MinimalEntry,
700
+ ): void {
701
+ state.turn++;
702
+ const timestamp = isoTimestamp(message, entry);
703
+ const text = bashExecutionToModelText(message);
704
+ const excluded = message.excludeFromContext === true;
705
+ addTextSegment(state, "bash", "User bash execution", text, {
706
+ source: "bash-execution",
707
+ role: "bashExecution",
708
+ entryId: entry?.id,
709
+ timestamp,
710
+ command: message.command,
711
+ excluded,
712
+ note: excluded
713
+ ? "This was marked excludeFromContext by pi (!! command). It is shown as session-only context."
714
+ : undefined,
715
+ });
716
+ }
717
+
718
+ function analyzeCustomMessage(
719
+ state: AnalyzerState,
720
+ message: MinimalMessage,
721
+ entry?: MinimalEntry,
722
+ ): void {
723
+ state.turn++;
724
+ const timestamp = isoTimestamp(message, entry);
725
+ const text = contentText(message.content);
726
+ addTextSegment(
727
+ state,
728
+ "custom",
729
+ message.customType ? `Custom context: ${message.customType}` : "Custom context",
730
+ text,
731
+ {
732
+ source: "custom-message",
733
+ role: "custom",
734
+ entryId: entry?.id,
735
+ timestamp,
736
+ displayOnly: message.display === false,
737
+ note:
738
+ message.display === false
739
+ ? "Hidden in TUI, but sent to the model as custom context."
740
+ : undefined,
741
+ },
742
+ );
743
+ for (const block of getContentBlocks(message.content)) {
744
+ if (block.type === "image") {
745
+ addImageSegment(
746
+ state,
747
+ message.customType ? `Custom image: ${message.customType}` : "Custom image",
748
+ {
749
+ source: "custom-message",
750
+ role: "custom",
751
+ entryId: entry?.id,
752
+ timestamp,
753
+ mimeType: block.mimeType ?? block.source?.mediaType,
754
+ },
755
+ );
756
+ }
757
+ }
758
+ }
759
+
760
+ function analyzeSummaryMessage(
761
+ state: AnalyzerState,
762
+ message: MinimalMessage,
763
+ entry?: MinimalEntry,
764
+ ): void {
765
+ const isCompaction = message.role === "compactionSummary" || entry?.type === "compaction";
766
+ const label = isCompaction ? "Compaction summary" : "Branch summary";
767
+ const summary = message.summary ?? entry?.summary ?? "";
768
+ const modelText = isCompaction
769
+ ? `${COMPACTION_SUMMARY_PREFIX}${summary}${COMPACTION_SUMMARY_SUFFIX}`
770
+ : `${BRANCH_SUMMARY_PREFIX}${summary}${BRANCH_SUMMARY_SUFFIX}`;
771
+ addTextSegment(state, "summaries", label, modelText, {
772
+ source: isCompaction ? "compaction" : "branch-summary",
773
+ role: message.role ?? (isCompaction ? "compactionSummary" : "branchSummary"),
774
+ entryId: entry?.id,
775
+ timestamp: isoTimestamp(message, entry),
776
+ note:
777
+ isCompaction && typeof message.tokensBefore === "number"
778
+ ? `This summary replaced about ${formatTokens(message.tokensBefore)} earlier tokens before compaction.`
779
+ : undefined,
780
+ });
722
781
  }
723
782
 
724
783
  function analyzeMessage(state: AnalyzerState, message: MinimalMessage, entry?: MinimalEntry): void {
725
- switch (message.role) {
726
- case "user":
727
- analyzeUserContent(state, message, entry);
728
- break;
729
- case "assistant":
730
- analyzeAssistantContent(state, message, entry);
731
- break;
732
- case "toolResult":
733
- analyzeToolResult(state, message, entry);
734
- break;
735
- case "bashExecution":
736
- analyzeBashExecution(state, message, entry);
737
- break;
738
- case "custom":
739
- analyzeCustomMessage(state, message, entry);
740
- break;
741
- case "branchSummary":
742
- case "compactionSummary":
743
- analyzeSummaryMessage(state, message, entry);
744
- break;
745
- }
784
+ switch (message.role) {
785
+ case "user":
786
+ analyzeUserContent(state, message, entry);
787
+ break;
788
+ case "assistant":
789
+ analyzeAssistantContent(state, message, entry);
790
+ break;
791
+ case "toolResult":
792
+ analyzeToolResult(state, message, entry);
793
+ break;
794
+ case "bashExecution":
795
+ analyzeBashExecution(state, message, entry);
796
+ break;
797
+ case "custom":
798
+ analyzeCustomMessage(state, message, entry);
799
+ break;
800
+ case "branchSummary":
801
+ case "compactionSummary":
802
+ analyzeSummaryMessage(state, message, entry);
803
+ break;
804
+ }
746
805
  }
747
806
 
748
807
  function contextEntryToMessage(entry: MinimalEntry): MinimalMessage | undefined {
749
- if (entry.type === "message") return entry.message;
750
- if (entry.type === "custom_message") {
751
- return {
752
- role: "custom",
753
- customType: entry.customType,
754
- content: entry.content,
755
- display: entry.display,
756
- details: entry.details,
757
- timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : undefined,
758
- };
759
- }
760
- if (entry.type === "branch_summary") {
761
- return {
762
- role: "branchSummary",
763
- summary: entry.summary,
764
- fromId: entry.fromId,
765
- timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : undefined,
766
- };
767
- }
768
- if (entry.type === "compaction") {
769
- return {
770
- role: "compactionSummary",
771
- summary: entry.summary,
772
- tokensBefore: entry.tokensBefore,
773
- timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : undefined,
774
- };
775
- }
776
- return undefined;
808
+ if (entry.type === "message") return entry.message;
809
+ if (entry.type === "custom_message") {
810
+ return {
811
+ role: "custom",
812
+ customType: entry.customType,
813
+ content: entry.content,
814
+ display: entry.display,
815
+ details: entry.details,
816
+ timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : undefined,
817
+ };
818
+ }
819
+ if (entry.type === "branch_summary") {
820
+ return {
821
+ role: "branchSummary",
822
+ summary: entry.summary,
823
+ fromId: entry.fromId,
824
+ timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : undefined,
825
+ };
826
+ }
827
+ if (entry.type === "compaction") {
828
+ return {
829
+ role: "compactionSummary",
830
+ summary: entry.summary,
831
+ tokensBefore: entry.tokensBefore,
832
+ timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : undefined,
833
+ };
834
+ }
835
+ return undefined;
777
836
  }
778
837
 
779
838
  function isContextEntry(entry: MinimalEntry): boolean {
780
- return entry.type === "message" || entry.type === "custom_message" || entry.type === "branch_summary";
839
+ return (
840
+ entry.type === "message" || entry.type === "custom_message" || entry.type === "branch_summary"
841
+ );
781
842
  }
782
843
 
783
844
  function collectCurrentContextEntries(branchEntries: MinimalEntry[]): MinimalEntry[] {
784
- let compactionIndex = -1;
785
- for (let i = branchEntries.length - 1; i >= 0; i--) {
786
- if (branchEntries[i]?.type === "compaction") {
787
- compactionIndex = i;
788
- break;
789
- }
790
- }
791
-
792
- if (compactionIndex === -1) return branchEntries.filter(isContextEntry);
793
-
794
- const result: MinimalEntry[] = [branchEntries[compactionIndex]];
795
- const compaction = branchEntries[compactionIndex];
796
- let foundFirstKept = false;
797
- for (let i = 0; i < compactionIndex; i++) {
798
- const entry = branchEntries[i];
799
- if (entry?.id === compaction?.firstKeptEntryId) foundFirstKept = true;
800
- if (foundFirstKept && isContextEntry(entry)) result.push(entry);
801
- }
802
- for (let i = compactionIndex + 1; i < branchEntries.length; i++) {
803
- const entry = branchEntries[i];
804
- if (isContextEntry(entry)) result.push(entry);
805
- }
806
- return result;
845
+ let compactionIndex = -1;
846
+ for (let i = branchEntries.length - 1; i >= 0; i--) {
847
+ if (branchEntries[i]?.type === "compaction") {
848
+ compactionIndex = i;
849
+ break;
850
+ }
851
+ }
852
+
853
+ if (compactionIndex === -1) return branchEntries.filter(isContextEntry);
854
+
855
+ const result: MinimalEntry[] = [branchEntries[compactionIndex]];
856
+ const compaction = branchEntries[compactionIndex];
857
+ let foundFirstKept = false;
858
+ for (let i = 0; i < compactionIndex; i++) {
859
+ const entry = branchEntries[i];
860
+ if (entry?.id === compaction?.firstKeptEntryId) foundFirstKept = true;
861
+ if (foundFirstKept && isContextEntry(entry)) result.push(entry);
862
+ }
863
+ for (let i = compactionIndex + 1; i < branchEntries.length; i++) {
864
+ const entry = branchEntries[i];
865
+ if (isContextEntry(entry)) result.push(entry);
866
+ }
867
+ return result;
807
868
  }
808
869
 
809
870
  function isProjectionSourceEntry(value: unknown): value is MinimalEntry {
810
- return isRecord(value)
811
- && typeof value.type === "string"
812
- && value.type.length > 0
813
- && typeof value.id === "string"
814
- && value.id.length > 0;
871
+ return (
872
+ isRecord(value) &&
873
+ typeof value.type === "string" &&
874
+ value.type.length > 0 &&
875
+ typeof value.id === "string" &&
876
+ value.id.length > 0
877
+ );
815
878
  }
816
879
 
817
880
  function isProjectionMessage(value: unknown): value is MinimalMessage {
818
- return isRecord(value) && typeof value.role === "string" && value.role.length > 0;
819
- }
820
-
821
- function resolveSessionProjection(ctx: ExtensionCommandContext): MinimalSessionProjection | undefined {
822
- const sessionManager = ctx.sessionManager as unknown as { buildSessionProjection?: () => unknown };
823
- const buildProjection = sessionManager.buildSessionProjection;
824
- if (typeof buildProjection !== "function") return undefined;
825
-
826
- const projection = safeCall(() => buildProjection.call(sessionManager));
827
- if (!isRecord(projection) || !Array.isArray(projection.entries)) return undefined;
828
-
829
- const entries: MinimalProjectionEntry[] = [];
830
- for (const candidate of projection.entries) {
831
- if (!isRecord(candidate) || !isProjectionSourceEntry(candidate.sourceEntry) || !Array.isArray(candidate.messages)) return undefined;
832
- if (!candidate.messages.every(isProjectionMessage)) return undefined;
833
- entries.push({
834
- sourceEntry: candidate.sourceEntry,
835
- messages: candidate.messages,
836
- });
837
- }
838
- // `entries` is the provenance-bearing canonical projection. Keep validating
839
- // the optional flattened field for compatibility, but derive the list used by
840
- // the report from entries so a stale/invented flattened list cannot reintroduce
841
- // omitted history or nested metadata as transcript messages.
842
- if ("messages" in projection && (!Array.isArray(projection.messages) || !projection.messages.every(isProjectionMessage))) return undefined;
843
- return {
844
- entries,
845
- messages: entries.flatMap((entry) => entry.messages),
846
- };
847
- }
848
-
849
- function resolveCurrentContextEntries(ctx: ExtensionCommandContext, branchEntries: MinimalEntry[]): MinimalEntry[] {
850
- const sessionManager = ctx.sessionManager as unknown as { buildContextEntries?: () => unknown };
851
- const buildEntries = sessionManager.buildContextEntries;
852
- const currentEntries = typeof buildEntries === "function"
853
- ? safeCall(() => buildEntries.call(sessionManager))
854
- : undefined;
855
- if (Array.isArray(currentEntries)) return currentEntries as MinimalEntry[];
856
- return collectCurrentContextEntries(branchEntries);
881
+ return isRecord(value) && typeof value.role === "string" && value.role.length > 0;
882
+ }
883
+
884
+ function resolveSessionProjection(
885
+ ctx: ExtensionCommandContext,
886
+ ): MinimalSessionProjection | undefined {
887
+ const sessionManager = ctx.sessionManager as unknown as {
888
+ buildSessionProjection?: () => unknown;
889
+ };
890
+ const buildProjection = sessionManager.buildSessionProjection;
891
+ if (typeof buildProjection !== "function") return undefined;
892
+
893
+ const projection = safeCall(() => buildProjection.call(sessionManager));
894
+ if (!isRecord(projection) || !Array.isArray(projection.entries)) return undefined;
895
+
896
+ const entries: MinimalProjectionEntry[] = [];
897
+ for (const candidate of projection.entries) {
898
+ if (
899
+ !isRecord(candidate) ||
900
+ !isProjectionSourceEntry(candidate.sourceEntry) ||
901
+ !Array.isArray(candidate.messages)
902
+ )
903
+ return undefined;
904
+ if (!candidate.messages.every(isProjectionMessage)) return undefined;
905
+ entries.push({
906
+ sourceEntry: candidate.sourceEntry,
907
+ messages: candidate.messages,
908
+ });
909
+ }
910
+ // `entries` is the provenance-bearing canonical projection. Keep validating
911
+ // the optional flattened field for compatibility, but derive the list used by
912
+ // the report from entries so a stale/invented flattened list cannot reintroduce
913
+ // omitted history or nested metadata as transcript messages.
914
+ if (
915
+ "messages" in projection &&
916
+ (!Array.isArray(projection.messages) || !projection.messages.every(isProjectionMessage))
917
+ )
918
+ return undefined;
919
+ return {
920
+ entries,
921
+ messages: entries.flatMap((entry) => entry.messages),
922
+ };
923
+ }
924
+
925
+ function resolveCurrentContextEntries(
926
+ ctx: ExtensionCommandContext,
927
+ branchEntries: MinimalEntry[],
928
+ ): MinimalEntry[] {
929
+ const sessionManager = ctx.sessionManager as unknown as { buildContextEntries?: () => unknown };
930
+ const buildEntries = sessionManager.buildContextEntries;
931
+ const currentEntries =
932
+ typeof buildEntries === "function"
933
+ ? safeCall(() => buildEntries.call(sessionManager))
934
+ : undefined;
935
+ if (Array.isArray(currentEntries)) return currentEntries as MinimalEntry[];
936
+ return collectCurrentContextEntries(branchEntries);
857
937
  }
858
938
 
859
939
  function analyzeEntries(entries: MinimalEntry[], redact: boolean): AnalysisResult {
860
- const state: AnalyzerState = { segments: [], sequence: 0, turn: 0, redact };
861
- let messageCount = 0;
862
- for (const entry of entries) {
863
- const message = contextEntryToMessage(entry);
864
- if (!message) continue;
865
- messageCount++;
866
- analyzeMessage(state, message, entry);
867
- }
868
- return { segments: state.segments, messageCount };
940
+ const state: AnalyzerState = { segments: [], sequence: 0, turn: 0, redact };
941
+ let messageCount = 0;
942
+ for (const entry of entries) {
943
+ const message = contextEntryToMessage(entry);
944
+ if (!message) continue;
945
+ messageCount++;
946
+ analyzeMessage(state, message, entry);
947
+ }
948
+ return { segments: state.segments, messageCount };
869
949
  }
870
950
 
871
951
  function analyzeProjection(projection: MinimalSessionProjection, redact: boolean): AnalysisResult {
872
- const state: AnalyzerState = { segments: [], sequence: 0, turn: 0, redact };
873
- let messageCount = 0;
874
- for (const projectedEntry of projection.entries) {
875
- for (const message of projectedEntry.messages) {
876
- // Pi's projection may carry the persisted system message on a compaction
877
- // entry; the active system prompt is accounted for separately as overhead.
878
- if (message.role === "system") continue;
879
- messageCount++;
880
- analyzeMessage(state, message, projectedEntry.sourceEntry);
881
- }
882
- }
883
- return { segments: state.segments, messageCount };
884
- }
885
-
886
- function resolveCurrentContextAnalysis(
887
- ctx: ExtensionCommandContext,
888
- branchEntries: MinimalEntry[],
889
- redact: boolean,
890
- ): AnalysisResult {
891
- const projection = resolveSessionProjection(ctx);
892
- if (projection) return analyzeProjection(projection, redact);
893
- return analyzeEntries(resolveCurrentContextEntries(ctx, branchEntries), redact);
952
+ const state: AnalyzerState = { segments: [], sequence: 0, turn: 0, redact };
953
+ let messageCount = 0;
954
+ for (const projectedEntry of projection.entries) {
955
+ for (const message of projectedEntry.messages) {
956
+ // Pi's projection may carry the persisted system message on a compaction
957
+ // entry; the active system prompt is accounted for separately as overhead.
958
+ if (message.role === "system") continue;
959
+ messageCount++;
960
+ analyzeMessage(state, message, projectedEntry.sourceEntry);
961
+ }
962
+ }
963
+ return { segments: state.segments, messageCount };
894
964
  }
895
965
 
896
966
  function collectToolSchemaText(pi: ExtensionAPI): string {
897
- try {
898
- const activeToolNames = new Set(pi.getActiveTools());
899
- const tools = pi.getAllTools().filter((tool) => activeToolNames.has(tool.name));
900
- return tools
901
- .map((tool) => safeJson({
902
- name: tool.name,
903
- description: tool.description,
904
- parameters: tool.parameters,
905
- source: tool.sourceInfo,
906
- }))
907
- .join("\n\n");
908
- } catch {
909
- return "";
910
- }
911
- }
912
-
913
- function buildOverheadSegments(pi: ExtensionAPI, ctx: ExtensionCommandContext, redact: boolean): Segment[] {
914
- const state: AnalyzerState = { segments: [], sequence: -2, turn: 0, redact };
915
- const systemPrompt = safeCall(() => ctx.getSystemPrompt()) ?? "";
916
- addTextSegment(state, "system", "Current system prompt", systemPrompt, {
917
- source: "system-prompt",
918
- role: "system",
919
- turn: 0,
920
- });
921
- const toolSchemaText = collectToolSchemaText(pi);
922
- addTextSegment(state, "toolSchemas", "Active tool schemas", toolSchemaText, {
923
- source: "tool-schema",
924
- role: "system",
925
- turn: 0,
926
- });
927
- return state.segments.map((segment, index) => ({ ...segment, id: `overhead-${index}`, sequence: -2 + index }));
967
+ try {
968
+ const activeToolNames = new Set(pi.getActiveTools());
969
+ const tools = pi.getAllTools().filter((tool) => activeToolNames.has(tool.name));
970
+ return tools
971
+ .map((tool) =>
972
+ safeJson({
973
+ name: tool.name,
974
+ description: tool.description,
975
+ parameters: tool.parameters,
976
+ source: tool.sourceInfo,
977
+ }),
978
+ )
979
+ .join("\n\n");
980
+ } catch {
981
+ return "";
982
+ }
983
+ }
984
+
985
+ function buildOverheadSegments(
986
+ pi: ExtensionAPI,
987
+ ctx: ExtensionCommandContext,
988
+ redact: boolean,
989
+ ): Segment[] {
990
+ const state: AnalyzerState = { segments: [], sequence: -2, turn: 0, redact };
991
+ const systemPrompt = safeCall(() => ctx.getSystemPrompt()) ?? "";
992
+ addTextSegment(state, "system", "Current system prompt", systemPrompt, {
993
+ source: "system-prompt",
994
+ role: "system",
995
+ turn: 0,
996
+ });
997
+ const toolSchemaText = collectToolSchemaText(pi);
998
+ addTextSegment(state, "toolSchemas", "Active tool schemas", toolSchemaText, {
999
+ source: "tool-schema",
1000
+ role: "system",
1001
+ turn: 0,
1002
+ });
1003
+ return state.segments.map((segment, index) => ({
1004
+ ...segment,
1005
+ id: `overhead-${index}`,
1006
+ sequence: -2 + index,
1007
+ }));
928
1008
  }
929
1009
 
930
1010
  function safeCall<T>(fn: () => T): T | undefined {
931
- try {
932
- return fn();
933
- } catch {
934
- return undefined;
935
- }
1011
+ try {
1012
+ return fn();
1013
+ } catch {
1014
+ return undefined;
1015
+ }
936
1016
  }
937
1017
 
938
1018
  function aggregateBy(
939
- segments: Segment[],
940
- keyFn: (segment: Segment) => string | undefined,
941
- labelFn: (key: string) => string = (key) => key,
1019
+ segments: Segment[],
1020
+ keyFn: (segment: Segment) => string | undefined,
1021
+ labelFn: (key: string) => string = (key) => key,
942
1022
  ): AggregateStat[] {
943
- const map = new Map<string, { tokens: number; segments: number }>();
944
- for (const segment of segments) {
945
- if (segment.excluded) continue;
946
- const key = keyFn(segment);
947
- if (!key) continue;
948
- const current = map.get(key) ?? { tokens: 0, segments: 0 };
949
- current.tokens += segment.tokens;
950
- current.segments++;
951
- map.set(key, current);
952
- }
953
- const total = Array.from(map.values()).reduce((sum, stat) => sum + stat.tokens, 0);
954
- return Array.from(map.entries())
955
- .map(([key, stat]) => ({
956
- key,
957
- label: labelFn(key),
958
- tokens: Math.round(stat.tokens),
959
- segments: stat.segments,
960
- percent: total > 0 ? (stat.tokens / total) * 100 : 0,
961
- }))
962
- .sort((a, b) => b.tokens - a.tokens)
963
- .slice(0, 12);
1023
+ const map = new Map<string, { tokens: number; segments: number }>();
1024
+ for (const segment of segments) {
1025
+ if (segment.excluded) continue;
1026
+ const key = keyFn(segment);
1027
+ if (!key) continue;
1028
+ const current = map.get(key) ?? { tokens: 0, segments: 0 };
1029
+ current.tokens += segment.tokens;
1030
+ current.segments++;
1031
+ map.set(key, current);
1032
+ }
1033
+ const total = Array.from(map.values()).reduce((sum, stat) => sum + stat.tokens, 0);
1034
+ return Array.from(map.entries())
1035
+ .map(([key, stat]) => ({
1036
+ key,
1037
+ label: labelFn(key),
1038
+ tokens: Math.round(stat.tokens),
1039
+ segments: stat.segments,
1040
+ percent: total > 0 ? (stat.tokens / total) * 100 : 0,
1041
+ }))
1042
+ .sort((a, b) => b.tokens - a.tokens)
1043
+ .slice(0, 12);
964
1044
  }
965
1045
 
966
1046
  function finalizeDataset(
967
- id: DatasetId,
968
- label: string,
969
- description: string,
970
- segments: Segment[],
971
- messageCount: number,
972
- authoritativeTokens: number | null,
1047
+ id: DatasetId,
1048
+ label: string,
1049
+ description: string,
1050
+ segments: Segment[],
1051
+ messageCount: number,
1052
+ authoritativeTokens: number | null,
973
1053
  ): Dataset {
974
- const visibleSegments = segments.filter((segment) => !segment.excluded);
975
- const visibleRawTokens = visibleSegments.reduce((sum, segment) => sum + segment.tokens, 0);
976
- const excludedTokens = segments.filter((segment) => segment.excluded).reduce((sum, segment) => sum + segment.tokens, 0);
977
- const providerDeltaTokens = authoritativeTokens != null ? Math.max(0, Math.round(authoritativeTokens - visibleRawTokens)) : 0;
978
- const estimatorOverageTokens = authoritativeTokens != null ? Math.max(0, Math.round(visibleRawTokens - authoritativeTokens)) : 0;
979
- const reconciliationScale = authoritativeTokens != null && visibleRawTokens > authoritativeTokens && visibleRawTokens > 0
980
- ? authoritativeTokens / visibleRawTokens
981
- : 1;
982
- const displayTotal = authoritativeTokens ?? visibleRawTokens;
983
-
984
- const byCategory = new Map<CategoryId, { tokens: number; segments: number }>();
985
- for (const segment of visibleSegments) {
986
- const current = byCategory.get(segment.category) ?? { tokens: 0, segments: 0 };
987
- current.tokens += segment.tokens;
988
- current.segments++;
989
- byCategory.set(segment.category, current);
990
- }
991
- if (providerDeltaTokens > 0) {
992
- byCategory.set("providerDelta", { tokens: providerDeltaTokens, segments: 1 });
993
- }
994
-
995
- const categories = (Object.keys(CATEGORY_META) as CategoryId[])
996
- .map((categoryId) => {
997
- const current = byCategory.get(categoryId) ?? { tokens: 0, segments: 0 };
998
- const rawTokens = current.tokens;
999
- const displayTokens = categoryId === "providerDelta"
1000
- ? rawTokens
1001
- : Math.round(rawTokens * reconciliationScale);
1002
- const meta = CATEGORY_META[categoryId];
1003
- return {
1004
- id: categoryId,
1005
- label: meta.label,
1006
- shortLabel: meta.shortLabel,
1007
- color: meta.color,
1008
- description: meta.description,
1009
- tokens: Math.round(rawTokens),
1010
- displayTokens,
1011
- percent: displayTotal > 0 ? (displayTokens / displayTotal) * 100 : 0,
1012
- segments: current.segments,
1013
- };
1014
- })
1015
- .filter((stat) => stat.tokens > 0 || stat.displayTokens > 0)
1016
- .sort((a, b) => b.displayTokens - a.displayTokens);
1017
-
1018
- return {
1019
- id,
1020
- label,
1021
- description,
1022
- segments,
1023
- stats: {
1024
- tokens: Math.round(displayTotal),
1025
- rawTokens: Math.round(segments.reduce((sum, segment) => sum + segment.tokens, 0)),
1026
- visibleRawTokens: Math.round(visibleRawTokens),
1027
- excludedTokens: Math.round(excludedTokens),
1028
- providerDeltaTokens,
1029
- estimatorOverageTokens,
1030
- reconciliationScale,
1031
- segmentCount: segments.length,
1032
- messageCount,
1033
- categories,
1034
- topSegments: [...visibleSegments].sort((a, b) => b.tokens - a.tokens).slice(0, 24),
1035
- topTools: aggregateBy(visibleSegments, (segment) => segment.toolName),
1036
- topPaths: aggregateBy(visibleSegments, (segment) => segment.path),
1037
- topTurns: aggregateBy(visibleSegments, (segment) => segment.turn > 0 ? String(segment.turn) : undefined, (key) => `Turn ${key}`),
1038
- },
1039
- };
1054
+ const visibleSegments = segments.filter((segment) => !segment.excluded);
1055
+ const visibleRawTokens = visibleSegments.reduce((sum, segment) => sum + segment.tokens, 0);
1056
+ const excludedTokens = segments
1057
+ .filter((segment) => segment.excluded)
1058
+ .reduce((sum, segment) => sum + segment.tokens, 0);
1059
+ const providerDeltaTokens =
1060
+ authoritativeTokens != null
1061
+ ? Math.max(0, Math.round(authoritativeTokens - visibleRawTokens))
1062
+ : 0;
1063
+ const estimatorOverageTokens =
1064
+ authoritativeTokens != null
1065
+ ? Math.max(0, Math.round(visibleRawTokens - authoritativeTokens))
1066
+ : 0;
1067
+ const reconciliationScale =
1068
+ authoritativeTokens != null && visibleRawTokens > authoritativeTokens && visibleRawTokens > 0
1069
+ ? authoritativeTokens / visibleRawTokens
1070
+ : 1;
1071
+ const displayTotal = authoritativeTokens ?? visibleRawTokens;
1072
+
1073
+ const byCategory = new Map<CategoryId, { tokens: number; segments: number }>();
1074
+ for (const segment of visibleSegments) {
1075
+ const current = byCategory.get(segment.category) ?? { tokens: 0, segments: 0 };
1076
+ current.tokens += segment.tokens;
1077
+ current.segments++;
1078
+ byCategory.set(segment.category, current);
1079
+ }
1080
+ if (providerDeltaTokens > 0) {
1081
+ byCategory.set("providerDelta", { tokens: providerDeltaTokens, segments: 1 });
1082
+ }
1083
+
1084
+ const categories = (Object.keys(CATEGORY_META) as CategoryId[])
1085
+ .map((categoryId) => {
1086
+ const current = byCategory.get(categoryId) ?? { tokens: 0, segments: 0 };
1087
+ const rawTokens = current.tokens;
1088
+ const displayTokens =
1089
+ categoryId === "providerDelta" ? rawTokens : Math.round(rawTokens * reconciliationScale);
1090
+ const meta = CATEGORY_META[categoryId];
1091
+ return {
1092
+ id: categoryId,
1093
+ label: meta.label,
1094
+ shortLabel: meta.shortLabel,
1095
+ color: meta.color,
1096
+ description: meta.description,
1097
+ tokens: Math.round(rawTokens),
1098
+ displayTokens,
1099
+ percent: displayTotal > 0 ? (displayTokens / displayTotal) * 100 : 0,
1100
+ segments: current.segments,
1101
+ };
1102
+ })
1103
+ .filter((stat) => stat.tokens > 0 || stat.displayTokens > 0)
1104
+ .sort((a, b) => b.displayTokens - a.displayTokens);
1105
+
1106
+ return {
1107
+ id,
1108
+ label,
1109
+ description,
1110
+ segments,
1111
+ stats: {
1112
+ tokens: Math.round(displayTotal),
1113
+ rawTokens: Math.round(segments.reduce((sum, segment) => sum + segment.tokens, 0)),
1114
+ visibleRawTokens: Math.round(visibleRawTokens),
1115
+ excludedTokens: Math.round(excludedTokens),
1116
+ providerDeltaTokens,
1117
+ estimatorOverageTokens,
1118
+ reconciliationScale,
1119
+ segmentCount: segments.length,
1120
+ messageCount,
1121
+ categories,
1122
+ topSegments: [...visibleSegments].sort((a, b) => b.tokens - a.tokens).slice(0, 24),
1123
+ topTools: aggregateBy(visibleSegments, (segment) => segment.toolName),
1124
+ topPaths: aggregateBy(visibleSegments, (segment) => segment.path),
1125
+ topTurns: aggregateBy(
1126
+ visibleSegments,
1127
+ (segment) => (segment.turn > 0 ? String(segment.turn) : undefined),
1128
+ (key) => `Turn ${key}`,
1129
+ ),
1130
+ },
1131
+ };
1040
1132
  }
1041
1133
 
1042
1134
  function getSessionName(ctx: ExtensionCommandContext): string | undefined {
1043
- return safeCall(() => ctx.sessionManager.getSessionName()) ?? safeCall(() => (ctx as unknown as { getSessionName?: () => string | undefined }).getSessionName?.());
1135
+ return safeCall(() => ctx.sessionManager.getSessionName());
1044
1136
  }
1045
1137
 
1046
1138
  function latestRoutedModel(messages: MinimalMessage[]): RoutedModel | undefined {
1047
- for (let i = messages.length - 1; i >= 0; i--) {
1048
- const message = messages[i];
1049
- if (message?.role !== "assistant") continue;
1050
- if (message.stopReason === "error" || message.stopReason === "aborted") continue;
1051
- if (message.api === "pi-virtual") continue;
1052
- if (typeof message.provider !== "string" || !message.provider || typeof message.model !== "string" || !message.model) continue;
1053
- return { provider: message.provider, id: message.model };
1054
- }
1055
- return undefined;
1056
- }
1057
-
1058
- function buildReportData(pi: ExtensionAPI, ctx: ExtensionCommandContext, options: CommandOptions): ReportData {
1059
- const branchEntries = ctx.sessionManager.getBranch() as MinimalEntry[];
1060
- const projection = resolveSessionProjection(ctx);
1061
- const overhead = buildOverheadSegments(pi, ctx, options.redact);
1062
- const currentAnalysis = projection
1063
- ? analyzeProjection(projection, options.redact)
1064
- : analyzeEntries(resolveCurrentContextEntries(ctx, branchEntries), options.redact);
1065
- const fullAnalysis = analyzeEntries(branchEntries, options.redact);
1066
- const usage = ctx.getContextUsage();
1067
- const contextTokens = isKnownNonNegativeNumber(usage?.tokens) ? usage.tokens : null;
1068
- const reportedContextWindow = isKnownPositiveNumber(usage?.contextWindow) ? usage.contextWindow : undefined;
1069
- const selectedContextWindow = isKnownPositiveNumber(ctx.model?.contextWindow) ? ctx.model.contextWindow : undefined;
1070
- const virtualSelection = isVirtualModel(ctx.model);
1071
- const contextWindow = reportedContextWindow ?? (virtualSelection ? null : selectedContextWindow ?? null);
1072
- const reportedPercent = isKnownNonNegativeNumber(usage?.percent) ? usage.percent : undefined;
1073
- const contextPercent = reportedPercent ?? (contextTokens != null && contextWindow ? (contextTokens / contextWindow) * 100 : null);
1074
- const projectedMessages = projection
1075
- ? projection.entries.flatMap((entry) => entry.messages)
1076
- : branchEntries.flatMap((entry) => {
1077
- const message = contextEntryToMessage(entry);
1078
- return message ? [message] : [];
1079
- });
1080
- const routed = virtualSelection ? latestRoutedModel(projectedMessages) : undefined;
1081
-
1082
- const currentSegments = [...overhead, ...currentAnalysis.segments];
1083
- const fullSegments = [...overhead, ...fullAnalysis.segments];
1084
- const currentDataset = finalizeDataset(
1085
- "current",
1086
- "Current model context",
1087
- "What pi is expected to send to the model on the next turn: latest compaction summary plus unsummarized messages, system prompt, and active tool schemas.",
1088
- currentSegments,
1089
- currentAnalysis.messageCount,
1090
- contextTokens,
1091
- );
1092
- const fullDataset = finalizeDataset(
1093
- "full",
1094
- "Full active branch history",
1095
- "Every context-bearing entry on the active branch, including pre-compaction history that may no longer be sent verbatim.",
1096
- fullSegments,
1097
- fullAnalysis.messageCount,
1098
- null,
1099
- );
1100
-
1101
- const notes: string[] = [
1102
- "Per-component token counts are local estimates based mostly on characters/4. Providers expose aggregate usage, not exact per-message attribution.",
1103
- "The current-context chart reconciles to pi's footer-compatible context total when that total is known.",
1104
- ];
1105
- if (options.redact) {
1106
- notes.push("Redaction is enabled: message contents, paths, commands, session identifiers, and timestamps are hidden in this report.");
1107
- }
1108
- if (contextTokens == null) {
1109
- notes.push("Current context usage is unknown, usually because compaction just ran and no model response has arrived yet.");
1110
- }
1111
- if (virtualSelection) {
1112
- const selected = [ctx.model?.provider, ctx.model?.id].filter(Boolean).join("/") || "virtual selection";
1113
- if (routed) {
1114
- notes.push(`Model selection ${selected} is virtual. Latest routed physical response: ${routed.provider}/${routed.id}. The context window shown below is provider-reported usage when available; it is not inferred from the virtual selection.`);
1115
- } else {
1116
- notes.push(`Model selection ${selected} is virtual. Pi has not exposed a routed physical response here, so no provider context window is inferred.`);
1117
- }
1118
- } else if (contextWindow == null) {
1119
- notes.push("The selected model's context window is unknown because pi did not provide a finite positive limit.");
1120
- }
1121
- if (currentDataset.stats.estimatorOverageTokens > 0) {
1122
- notes.push(`Local component estimates exceed pi's footer total by ${formatTokens(currentDataset.stats.estimatorOverageTokens)} tokens, so chart slices are scaled down proportionally.`);
1123
- }
1124
- if (currentDataset.stats.providerDeltaTokens > 0) {
1125
- notes.push(`${formatTokens(currentDataset.stats.providerDeltaTokens)} tokens are unattributed provider/serialization delta: system serialization, provider tokenization, cache accounting, or schema overhead not explained by local estimates.`);
1126
- }
1127
-
1128
- return {
1129
- generatedAt: new Date().toISOString(),
1130
- cwd: options.redact ? "[redacted cwd]" : ctx.cwd,
1131
- session: {
1132
- id: options.redact ? "[redacted session]" : safeCall(() => ctx.sessionManager.getSessionId()),
1133
- name: options.redact ? "[redacted session name]" : getSessionName(ctx),
1134
- file: options.redact ? "[redacted session file]" : safeCall(() => ctx.sessionManager.getSessionFile()),
1135
- },
1136
- model: {
1137
- provider: ctx.model?.provider,
1138
- id: ctx.model?.id,
1139
- contextWindow: selectedContextWindow,
1140
- thinkingLevel: safeCall(() => pi.getThinkingLevel()),
1141
- virtual: virtualSelection,
1142
- routed,
1143
- },
1144
- contextUsage: {
1145
- tokens: contextTokens,
1146
- contextWindow,
1147
- percent: contextPercent,
1148
- },
1149
- options: {
1150
- redacted: options.redact,
1151
- defaultDataset: options.defaultDataset,
1152
- },
1153
- categoryMeta: CATEGORY_META,
1154
- datasets: {
1155
- current: currentDataset,
1156
- full: fullDataset,
1157
- },
1158
- notes,
1159
- };
1139
+ for (let i = messages.length - 1; i >= 0; i--) {
1140
+ const message = messages[i];
1141
+ if (message?.role !== "assistant") continue;
1142
+ if (message.stopReason === "error" || message.stopReason === "aborted") continue;
1143
+ if (message.api === "pi-virtual") continue;
1144
+ if (
1145
+ typeof message.provider !== "string" ||
1146
+ !message.provider ||
1147
+ typeof message.model !== "string" ||
1148
+ !message.model
1149
+ )
1150
+ continue;
1151
+ return { provider: message.provider, id: message.model };
1152
+ }
1153
+ return undefined;
1154
+ }
1155
+
1156
+ function buildReportData(
1157
+ pi: ExtensionAPI,
1158
+ ctx: ExtensionCommandContext,
1159
+ options: CommandOptions,
1160
+ ): ReportData {
1161
+ const branchEntries = ctx.sessionManager.getBranch() as MinimalEntry[];
1162
+ const projection = resolveSessionProjection(ctx);
1163
+ const overhead = buildOverheadSegments(pi, ctx, options.redact);
1164
+ const currentAnalysis = projection
1165
+ ? analyzeProjection(projection, options.redact)
1166
+ : analyzeEntries(resolveCurrentContextEntries(ctx, branchEntries), options.redact);
1167
+ const fullAnalysis = analyzeEntries(branchEntries, options.redact);
1168
+ const usage = ctx.getContextUsage();
1169
+ const contextTokens = isKnownNonNegativeNumber(usage?.tokens) ? usage.tokens : null;
1170
+ const reportedContextWindow = isKnownPositiveNumber(usage?.contextWindow)
1171
+ ? usage.contextWindow
1172
+ : undefined;
1173
+ const selectedContextWindow = isKnownPositiveNumber(ctx.model?.contextWindow)
1174
+ ? ctx.model.contextWindow
1175
+ : undefined;
1176
+ const virtualSelection = isVirtualModel(ctx.model);
1177
+ const contextWindow =
1178
+ reportedContextWindow ?? (virtualSelection ? null : (selectedContextWindow ?? null));
1179
+ const reportedPercent = isKnownNonNegativeNumber(usage?.percent) ? usage.percent : undefined;
1180
+ const contextPercent =
1181
+ reportedPercent ??
1182
+ (contextTokens != null && contextWindow ? (contextTokens / contextWindow) * 100 : null);
1183
+ const projectedMessages = projection
1184
+ ? projection.entries.flatMap((entry) => entry.messages)
1185
+ : branchEntries.flatMap((entry) => {
1186
+ const message = contextEntryToMessage(entry);
1187
+ return message ? [message] : [];
1188
+ });
1189
+ const routed = virtualSelection ? latestRoutedModel(projectedMessages) : undefined;
1190
+
1191
+ const currentSegments = [...overhead, ...currentAnalysis.segments];
1192
+ const fullSegments = [...overhead, ...fullAnalysis.segments];
1193
+ const currentDataset = finalizeDataset(
1194
+ "current",
1195
+ "Current model context",
1196
+ "What pi is expected to send to the model on the next turn: latest compaction summary plus unsummarized messages, system prompt, and active tool schemas.",
1197
+ currentSegments,
1198
+ currentAnalysis.messageCount,
1199
+ contextTokens,
1200
+ );
1201
+ const fullDataset = finalizeDataset(
1202
+ "full",
1203
+ "Full active branch history",
1204
+ "Every context-bearing entry on the active branch, including pre-compaction history that may no longer be sent verbatim.",
1205
+ fullSegments,
1206
+ fullAnalysis.messageCount,
1207
+ null,
1208
+ );
1209
+
1210
+ const notes: string[] = [
1211
+ "Per-component token counts are local estimates based mostly on characters/4. Providers expose aggregate usage, not exact per-message attribution.",
1212
+ "The current-context chart reconciles to pi's footer-compatible context total when that total is known.",
1213
+ ];
1214
+ if (options.redact) {
1215
+ notes.push(
1216
+ "Redaction is enabled: message contents, paths, commands, session identifiers, and timestamps are hidden in this report.",
1217
+ );
1218
+ }
1219
+ if (contextTokens == null) {
1220
+ notes.push(
1221
+ "Current context usage is unknown, usually because compaction just ran and no model response has arrived yet.",
1222
+ );
1223
+ }
1224
+ if (virtualSelection) {
1225
+ const selected =
1226
+ [ctx.model?.provider, ctx.model?.id].filter(Boolean).join("/") || "virtual selection";
1227
+ if (routed) {
1228
+ notes.push(
1229
+ `Model selection ${selected} is virtual. Latest routed physical response: ${routed.provider}/${routed.id}. The context window shown below is provider-reported usage when available; it is not inferred from the virtual selection.`,
1230
+ );
1231
+ } else {
1232
+ notes.push(
1233
+ `Model selection ${selected} is virtual. Pi has not exposed a routed physical response here, so no provider context window is inferred.`,
1234
+ );
1235
+ }
1236
+ } else if (contextWindow == null) {
1237
+ notes.push(
1238
+ "The selected model's context window is unknown because pi did not provide a finite positive limit.",
1239
+ );
1240
+ }
1241
+ if (currentDataset.stats.estimatorOverageTokens > 0) {
1242
+ notes.push(
1243
+ `Local component estimates exceed pi's footer total by ${formatTokens(currentDataset.stats.estimatorOverageTokens)} tokens, so chart slices are scaled down proportionally.`,
1244
+ );
1245
+ }
1246
+ if (currentDataset.stats.providerDeltaTokens > 0) {
1247
+ notes.push(
1248
+ `${formatTokens(currentDataset.stats.providerDeltaTokens)} tokens are unattributed provider/serialization delta: system serialization, provider tokenization, cache accounting, or schema overhead not explained by local estimates.`,
1249
+ );
1250
+ }
1251
+
1252
+ return {
1253
+ generatedAt: new Date().toISOString(),
1254
+ cwd: options.redact ? "[redacted cwd]" : ctx.cwd,
1255
+ session: {
1256
+ id: options.redact ? "[redacted session]" : safeCall(() => ctx.sessionManager.getSessionId()),
1257
+ name: options.redact ? "[redacted session name]" : getSessionName(ctx),
1258
+ file: options.redact
1259
+ ? "[redacted session file]"
1260
+ : safeCall(() => ctx.sessionManager.getSessionFile()),
1261
+ },
1262
+ model: {
1263
+ provider: ctx.model?.provider,
1264
+ id: ctx.model?.id,
1265
+ contextWindow: selectedContextWindow,
1266
+ thinkingLevel: safeCall(() => pi.getThinkingLevel()),
1267
+ virtual: virtualSelection,
1268
+ routed,
1269
+ },
1270
+ contextUsage: {
1271
+ tokens: contextTokens,
1272
+ contextWindow,
1273
+ percent: contextPercent,
1274
+ },
1275
+ options: {
1276
+ redacted: options.redact,
1277
+ defaultDataset: options.defaultDataset,
1278
+ },
1279
+ categoryMeta: CATEGORY_META,
1280
+ datasets: {
1281
+ current: currentDataset,
1282
+ full: fullDataset,
1283
+ },
1284
+ notes,
1285
+ };
1160
1286
  }
1161
1287
 
1162
1288
  function sanitizeFilePart(value: string): string {
1163
- return value.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 80) || "session";
1289
+ return value.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 80) || "session";
1164
1290
  }
1165
1291
 
1166
1292
  function reportPath(ctx: ExtensionCommandContext, options: CommandOptions): string {
1167
- const rawSessionId = options.redact ? "redacted" : (safeCall(() => ctx.sessionManager.getSessionId()) ?? "session");
1168
- const fileName = `pi-context-${sanitizeFilePart(rawSessionId)}-${Date.now()}.html`;
1169
- if (options.keep) {
1170
- const dir = join(ctx.cwd, CONFIG_DIR_NAME, "context-reports");
1171
- mkdirSync(dir, { recursive: true, mode: 0o700 });
1172
- try { chmodSync(dir, 0o700); } catch { /* best effort */ }
1173
- return join(dir, fileName);
1174
- }
1175
- const dir = mkdtempSync(join(tmpdir(), "pi-context-"));
1176
- try { chmodSync(dir, 0o700); } catch { /* best effort */ }
1177
- return join(dir, fileName);
1178
- }
1179
-
1180
- async function openReport(pi: ExtensionAPI, filePath: string): Promise<{ ok: boolean; error?: string }> {
1181
- const url = pathToFileURL(filePath).href;
1182
- try {
1183
- let result: { code?: number | null; stderr?: string };
1184
- if (process.platform === "darwin") {
1185
- result = await pi.exec("open", [url], { timeout: 5000 });
1186
- } else if (process.platform === "win32") {
1187
- result = await pi.exec("cmd", ["/c", "start", "", url], { timeout: 5000 });
1188
- } else {
1189
- result = await pi.exec("xdg-open", [url], { timeout: 5000 });
1190
- }
1191
- if (result.code === 0) return { ok: true };
1192
- return { ok: false, error: result.stderr || `open command exited with code ${result.code}` };
1193
- } catch (error) {
1194
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
1195
- }
1196
- }
1197
-
1198
- function notify(ctx: ExtensionCommandContext, message: string, type: "info" | "warning" | "error" = "info"): void {
1199
- if (ctx.hasUI) ctx.ui.notify(message, type);
1200
- else console.log(message);
1293
+ const rawSessionId = options.redact
1294
+ ? "redacted"
1295
+ : (safeCall(() => ctx.sessionManager.getSessionId()) ?? "session");
1296
+ const fileName = `pi-context-${sanitizeFilePart(rawSessionId)}-${Date.now()}.html`;
1297
+ if (options.keep) {
1298
+ const dir = join(ctx.cwd, CONFIG_DIR_NAME, "context-reports");
1299
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
1300
+ try {
1301
+ chmodSync(dir, 0o700);
1302
+ } catch {
1303
+ /* best effort */
1304
+ }
1305
+ return join(dir, fileName);
1306
+ }
1307
+ const dir = mkdtempSync(join(tmpdir(), "pi-context-"));
1308
+ try {
1309
+ chmodSync(dir, 0o700);
1310
+ } catch {
1311
+ /* best effort */
1312
+ }
1313
+ return join(dir, fileName);
1314
+ }
1315
+
1316
+ async function openReport(
1317
+ pi: ExtensionAPI,
1318
+ filePath: string,
1319
+ ): Promise<{ ok: boolean; error?: string }> {
1320
+ const url = pathToFileURL(filePath).href;
1321
+ try {
1322
+ let result: { code?: number | null; stderr?: string };
1323
+ if (process.platform === "darwin") {
1324
+ result = await pi.exec("open", [url], { timeout: 5000 });
1325
+ } else if (process.platform === "win32") {
1326
+ result = await pi.exec("cmd", ["/c", "start", "", url], { timeout: 5000 });
1327
+ } else {
1328
+ result = await pi.exec("xdg-open", [url], { timeout: 5000 });
1329
+ }
1330
+ if (result.code === 0) return { ok: true };
1331
+ return { ok: false, error: result.stderr || `open command exited with code ${result.code}` };
1332
+ } catch (error) {
1333
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1334
+ }
1335
+ }
1336
+
1337
+ function notify(
1338
+ ctx: ExtensionCommandContext,
1339
+ message: string,
1340
+ type: "info" | "warning" | "error" = "info",
1341
+ ): void {
1342
+ if (ctx.hasUI) ctx.ui.notify(message, type);
1343
+ else console.log(message);
1201
1344
  }
1202
1345
 
1203
1346
  function writeReport(data: ReportData, filePath: string): void {
1204
- writeFileSync(filePath, buildHtml(data), { encoding: "utf8", mode: 0o600 });
1347
+ writeFileSync(filePath, buildHtml(data), { encoding: "utf8", mode: 0o600 });
1205
1348
  }
1206
1349
 
1207
1350
  function scriptJson(data: ReportData): string {
1208
- return JSON.stringify(data)
1209
- .replace(/</g, "\\u003c")
1210
- .replace(/>/g, "\\u003e")
1211
- .replace(/&/g, "\\u0026")
1212
- .replace(/\u2028/g, "\\u2028")
1213
- .replace(/\u2029/g, "\\u2029");
1351
+ return JSON.stringify(data)
1352
+ .replace(/</g, "\\u003c")
1353
+ .replace(/>/g, "\\u003e")
1354
+ .replace(/&/g, "\\u0026")
1355
+ .replace(/\u2028/g, "\\u2028")
1356
+ .replace(/\u2029/g, "\\u2029");
1214
1357
  }
1215
1358
 
1216
1359
  function buildHtml(data: ReportData): string {
1217
- return `<!doctype html>
1360
+ return `<!doctype html>
1218
1361
  <html lang="en">
1219
1362
  <head>
1220
1363
  <meta charset="utf-8" />
@@ -1614,43 +1757,47 @@ pre { margin: 0; white-space: pre-wrap; word-break: break-word; color: #cbd5e1;
1614
1757
  }
1615
1758
 
1616
1759
  export const __testing = {
1617
- analyzeEntries,
1618
- buildReportData,
1760
+ analyzeEntries,
1761
+ buildReportData,
1619
1762
  };
1620
1763
 
1621
1764
  export default function contextInspectorExtension(pi: ExtensionAPI) {
1622
- pi.registerCommand("context", {
1623
- description: "Open a local HTML breakdown of where this session's context is going",
1624
- getArgumentCompletions: (prefix) => {
1625
- const flags = ["--no-open", "--keep", "--redact", "--full", "--current", "--help"];
1626
- const trimmed = prefix.trimStart();
1627
- const last = trimmed.split(/\s+/).pop() ?? "";
1628
- const matches = flags.filter((flag) => flag.startsWith(last));
1629
- return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
1630
- },
1631
- handler: async (args, ctx) => {
1632
- const options = parseArgs(args);
1633
- if (options.help) {
1634
- notify(ctx, usageText(), "info");
1635
- return;
1636
- }
1637
-
1638
- await ctx.waitForIdle();
1639
- const data = buildReportData(pi, ctx, options);
1640
- const filePath = reportPath(ctx, options);
1641
- writeReport(data, filePath);
1642
-
1643
- if (!options.open) {
1644
- notify(ctx, `Context report written: ${filePath}`, "info");
1645
- return;
1646
- }
1647
-
1648
- const result = await openReport(pi, filePath);
1649
- if (result.ok) {
1650
- notify(ctx, `Opened context report: ${filePath}`, "info");
1651
- } else {
1652
- notify(ctx, `Context report written, but browser open failed: ${filePath}\n${result.error ?? ""}`, "warning");
1653
- }
1654
- },
1655
- });
1765
+ pi.registerCommand("context", {
1766
+ description: "Open a local HTML breakdown of where this session's context is going",
1767
+ getArgumentCompletions: (prefix) => {
1768
+ const flags = ["--no-open", "--keep", "--redact", "--full", "--current", "--help"];
1769
+ const trimmed = prefix.trimStart();
1770
+ const last = trimmed.split(/\s+/).pop() ?? "";
1771
+ const matches = flags.filter((flag) => flag.startsWith(last));
1772
+ return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
1773
+ },
1774
+ handler: async (args, ctx) => {
1775
+ const options = parseArgs(args);
1776
+ if (options.help) {
1777
+ notify(ctx, usageText(), "info");
1778
+ return;
1779
+ }
1780
+
1781
+ await ctx.waitForIdle();
1782
+ const data = buildReportData(pi, ctx, options);
1783
+ const filePath = reportPath(ctx, options);
1784
+ writeReport(data, filePath);
1785
+
1786
+ if (!options.open) {
1787
+ notify(ctx, `Context report written: ${filePath}`, "info");
1788
+ return;
1789
+ }
1790
+
1791
+ const result = await openReport(pi, filePath);
1792
+ if (result.ok) {
1793
+ notify(ctx, `Opened context report: ${filePath}`, "info");
1794
+ } else {
1795
+ notify(
1796
+ ctx,
1797
+ `Context report written, but browser open failed: ${filePath}\n${result.error ?? ""}`,
1798
+ "warning",
1799
+ );
1800
+ }
1801
+ },
1802
+ });
1656
1803
  }