agent-optic 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +40 -27
  2. package/examples/annotate-commits.ts +138 -0
  3. package/examples/branch-report.ts +561 -0
  4. package/examples/commit-tracker.ts +159 -47
  5. package/examples/cost-per-feature.ts +108 -63
  6. package/examples/git-helpers.ts +66 -0
  7. package/examples/match-git-commits.ts +18 -14
  8. package/examples/model-costs.ts +11 -15
  9. package/examples/pipe-match.ts +3 -3
  10. package/examples/prompt-history.ts +3 -3
  11. package/examples/session-digest.ts +2 -2
  12. package/examples/timesheet.ts +3 -3
  13. package/examples/ubiquitous-language.ts +184 -0
  14. package/examples/work-patterns.ts +2 -2
  15. package/package.json +14 -7
  16. package/skills/agent-optic/SKILL.md +302 -0
  17. package/src/agent-optic.ts +4 -25
  18. package/src/aggregations/daily.ts +1 -1
  19. package/src/aggregations/project.ts +0 -1
  20. package/src/aggregations/tools.ts +1 -1
  21. package/src/cli/index.ts +2 -2
  22. package/src/index.ts +11 -36
  23. package/src/parsers/session-detail.ts +15 -6
  24. package/src/parsers/tool-categories.ts +8 -0
  25. package/src/pricing.ts +66 -4
  26. package/src/readers/copilot-session-reader.ts +432 -0
  27. package/src/readers/history-reader.ts +14 -0
  28. package/src/readers/pi-session-reader.ts +466 -0
  29. package/src/readers/project-reader.ts +13 -4
  30. package/src/readers/session-reader.ts +23 -1
  31. package/src/readers/task-reader.ts +0 -7
  32. package/src/types/provider.ts +2 -0
  33. package/src/types/session.ts +1 -0
  34. package/src/types/transcript.ts +17 -0
  35. package/src/utils/dates.ts +4 -8
  36. package/src/utils/paths.ts +37 -4
  37. package/src/utils/providers.ts +37 -4
  38. package/src/claude-optic.ts +0 -7
  39. package/src/utils/jsonl.ts +0 -83
@@ -0,0 +1,466 @@
1
+ import { join } from "node:path";
2
+ import type { PrivacyConfig } from "../types/privacy.js";
3
+ import type { SessionDetail, SessionInfo, SessionMeta, ToolCallSummary } from "../types/session.js";
4
+ import type { ContentBlock, TranscriptEntry } from "../types/transcript.js";
5
+ import { projectName } from "../utils/paths.js";
6
+ import { isProjectExcluded, redactString, filterTranscriptEntry } from "../privacy/redact.js";
7
+ import { extractText, extractFilePaths, countThinkingBlocks } from "../parsers/content-blocks.js";
8
+ import { categorizeToolName, toolDisplayName } from "../parsers/tool-categories.js";
9
+
10
+ // Pi filenames: {ISO-timestamp}_{uuid}.jsonl
11
+ // e.g. 2026-02-05T20-05-58-927Z_05f61a6d-20f8-4c57-917b-df7906fe952f.jsonl
12
+ function parsePiFilename(
13
+ filename: string,
14
+ ): { date: string; sessionId: string; timestamp: string } | null {
15
+ const m = filename.match(
16
+ /^(\d{4}-\d{2}-\d{2})T[\d-]+Z_([0-9a-f-]{36})\.jsonl$/,
17
+ );
18
+ return m ? { date: m[1], sessionId: m[2], timestamp: m[1] } : null;
19
+ }
20
+
21
+ const piIndexCache = new Map<string, Promise<Map<string, string>>>();
22
+
23
+ async function buildPiIndex(sessionsDir: string): Promise<Map<string, string>> {
24
+ const index = new Map<string, string>();
25
+ const glob = new Bun.Glob("**/*.jsonl");
26
+ for await (const path of glob.scan({ cwd: sessionsDir, absolute: false })) {
27
+ const filename = path.split("/").pop()!;
28
+ const parsed = parsePiFilename(filename);
29
+ if (parsed) index.set(parsed.sessionId, join(sessionsDir, path));
30
+ }
31
+ return index;
32
+ }
33
+
34
+ async function getPiIndex(sessionsDir: string): Promise<Map<string, string>> {
35
+ let promise = piIndexCache.get(sessionsDir);
36
+ if (!promise) {
37
+ promise = buildPiIndex(sessionsDir);
38
+ piIndexCache.set(sessionsDir, promise);
39
+ }
40
+ return promise;
41
+ }
42
+
43
+ /** Find a Pi session file by session ID. */
44
+ async function findPiSessionFile(
45
+ sessionsDir: string,
46
+ sessionId: string,
47
+ ): Promise<string | null> {
48
+ const index = await getPiIndex(sessionsDir);
49
+ const cached = index.get(sessionId);
50
+ if (cached) return cached;
51
+
52
+ // Fallback for newly created files
53
+ const glob = new Bun.Glob(`**/*_${sessionId}.jsonl`);
54
+ for await (const path of glob.scan({ cwd: sessionsDir, absolute: false })) {
55
+ const fullPath = join(sessionsDir, path);
56
+ index.set(sessionId, fullPath);
57
+ return fullPath;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ /** Read all Pi sessions by scanning directory tree (no history.jsonl). */
63
+ export async function readPiHistory(
64
+ sessionsDir: string,
65
+ from: string,
66
+ to: string,
67
+ privacy: PrivacyConfig,
68
+ ): Promise<SessionInfo[]> {
69
+ const sessions: SessionInfo[] = [];
70
+ const glob = new Bun.Glob("**/*.jsonl");
71
+
72
+ for await (const path of glob.scan({ cwd: sessionsDir, absolute: false })) {
73
+ const filename = path.split("/").pop()!;
74
+ const parsed = parsePiFilename(filename);
75
+ if (!parsed) continue;
76
+
77
+ // Filter by date from filename before reading file
78
+ if (parsed.date < from || parsed.date > to) continue;
79
+
80
+ const fullPath = join(sessionsDir, path);
81
+ const file = Bun.file(fullPath);
82
+ if (!(await file.exists())) continue;
83
+
84
+ let cwd: string | undefined;
85
+ let firstPrompt: string | undefined;
86
+ let sessionTimestamp: number | undefined;
87
+
88
+ try {
89
+ const text = await file.text();
90
+ for (const line of text.split("\n")) {
91
+ if (!line.trim()) continue;
92
+ let entry: any;
93
+ try {
94
+ entry = JSON.parse(line);
95
+ } catch {
96
+ continue;
97
+ }
98
+
99
+ if (entry.type === "session") {
100
+ cwd = entry.cwd;
101
+ sessionTimestamp = new Date(entry.timestamp).getTime();
102
+ }
103
+
104
+ if (
105
+ entry.type === "message" &&
106
+ entry.message?.role === "user" &&
107
+ !firstPrompt
108
+ ) {
109
+ const content = entry.message.content;
110
+ if (typeof content === "string") {
111
+ firstPrompt = content;
112
+ } else if (Array.isArray(content)) {
113
+ const textBlock = content.find(
114
+ (b: any) => b.type === "text" && typeof b.text === "string",
115
+ );
116
+ if (textBlock) firstPrompt = textBlock.text;
117
+ }
118
+ }
119
+
120
+ if (cwd && firstPrompt) break;
121
+ }
122
+ } catch {
123
+ continue;
124
+ }
125
+
126
+ if (!cwd) continue;
127
+ if (isProjectExcluded(cwd, privacy)) continue;
128
+
129
+ const ts = sessionTimestamp ?? new Date(parsed.date).getTime();
130
+ const prompt = firstPrompt
131
+ ? privacy.redactPrompts
132
+ ? "[redacted]"
133
+ : privacy.redactPatterns.length > 0
134
+ ? redactString(firstPrompt, privacy)
135
+ : firstPrompt
136
+ : "(no prompt)";
137
+
138
+ sessions.push({
139
+ sessionId: parsed.sessionId,
140
+ project: cwd,
141
+ projectName: projectName(cwd),
142
+ prompts: [prompt],
143
+ promptTimestamps: [ts],
144
+ timeRange: { start: ts, end: ts },
145
+ });
146
+ }
147
+
148
+ sessions.sort((a, b) => a.timeRange.start - b.timeRange.start);
149
+ return sessions;
150
+ }
151
+
152
+ /** Peek Pi session metadata (model, tokens, cost). */
153
+ export async function peekPiSession(
154
+ session: SessionInfo,
155
+ sessionsDir: string,
156
+ ): Promise<SessionMeta> {
157
+ const meta: SessionMeta = {
158
+ ...session,
159
+ totalInputTokens: 0,
160
+ totalOutputTokens: 0,
161
+ cacheCreationInputTokens: 0,
162
+ cacheReadInputTokens: 0,
163
+ messageCount: 0,
164
+ };
165
+
166
+ const filePath = await findPiSessionFile(sessionsDir, session.sessionId);
167
+ if (!filePath) return meta;
168
+
169
+ const file = Bun.file(filePath);
170
+ if (!(await file.exists())) return meta;
171
+
172
+ let totalCost = 0;
173
+
174
+ try {
175
+ const text = await file.text();
176
+ for (const line of text.split("\n")) {
177
+ if (!line.trim()) continue;
178
+ let entry: any;
179
+ try {
180
+ entry = JSON.parse(line);
181
+ } catch {
182
+ continue;
183
+ }
184
+
185
+ if (entry.type === "model_change") {
186
+ if (!meta.model && typeof entry.modelId === "string") {
187
+ meta.model = entry.modelId;
188
+ }
189
+ }
190
+
191
+ if (entry.type === "message" && entry.message) {
192
+ const msg = entry.message;
193
+
194
+ if (msg.role === "user" || msg.role === "assistant") {
195
+ meta.messageCount++;
196
+ }
197
+
198
+ if (msg.usage && typeof msg.usage === "object") {
199
+ meta.totalInputTokens += Number(msg.usage.input ?? 0);
200
+ meta.totalOutputTokens += Number(msg.usage.output ?? 0);
201
+ meta.cacheReadInputTokens += Number(msg.usage.cacheRead ?? 0);
202
+ meta.cacheCreationInputTokens += Number(msg.usage.cacheWrite ?? 0);
203
+ }
204
+
205
+ if (msg.usage?.cost && typeof msg.usage.cost.total === "number") {
206
+ totalCost += msg.usage.cost.total;
207
+ }
208
+ }
209
+ }
210
+ } catch {
211
+ // file unreadable
212
+ }
213
+
214
+ if (totalCost > 0) meta.totalCost = totalCost;
215
+ return meta;
216
+ }
217
+
218
+ /** Parse full Pi session detail. */
219
+ export async function parsePiSessionDetail(
220
+ session: SessionInfo,
221
+ sessionsDir: string,
222
+ privacy: PrivacyConfig,
223
+ ): Promise<SessionDetail> {
224
+ const detail: SessionDetail = {
225
+ ...session,
226
+ totalInputTokens: 0,
227
+ totalOutputTokens: 0,
228
+ cacheCreationInputTokens: 0,
229
+ cacheReadInputTokens: 0,
230
+ messageCount: 0,
231
+ assistantSummaries: [],
232
+ toolCalls: [],
233
+ filesReferenced: [],
234
+ planReferenced: false,
235
+ thinkingBlockCount: 0,
236
+ hasSidechains: false,
237
+ };
238
+
239
+ const filePath = await findPiSessionFile(sessionsDir, session.sessionId);
240
+ if (!filePath) return detail;
241
+
242
+ const file = Bun.file(filePath);
243
+ if (!(await file.exists())) return detail;
244
+
245
+ const toolCallSet = new Map<string, ToolCallSummary>();
246
+ const fileSet = new Set<string>();
247
+ let model: string | undefined;
248
+ let totalCost = 0;
249
+
250
+ try {
251
+ const text = await file.text();
252
+ for (const line of text.split("\n")) {
253
+ if (!line.trim()) continue;
254
+ let entry: any;
255
+ try {
256
+ entry = JSON.parse(line);
257
+ } catch {
258
+ continue;
259
+ }
260
+
261
+ if (entry.type === "model_change") {
262
+ if (!model && typeof entry.modelId === "string") {
263
+ model = entry.modelId;
264
+ }
265
+ }
266
+
267
+ if (entry.type !== "message" || !entry.message) continue;
268
+ const msg = entry.message;
269
+
270
+ if (msg.role === "user" || msg.role === "assistant") {
271
+ detail.messageCount++;
272
+ }
273
+
274
+ if (msg.usage && typeof msg.usage === "object") {
275
+ detail.totalInputTokens += Number(msg.usage.input ?? 0);
276
+ detail.totalOutputTokens += Number(msg.usage.output ?? 0);
277
+ detail.cacheReadInputTokens += Number(msg.usage.cacheRead ?? 0);
278
+ detail.cacheCreationInputTokens += Number(msg.usage.cacheWrite ?? 0);
279
+ }
280
+
281
+ if (msg.usage?.cost && typeof msg.usage.cost.total === "number") {
282
+ totalCost += msg.usage.cost.total;
283
+ }
284
+
285
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
286
+ // Map Pi content blocks to our ContentBlock format for extraction
287
+ const blocks: ContentBlock[] = [];
288
+ for (const block of msg.content) {
289
+ if (block.type === "text" && typeof block.text === "string") {
290
+ blocks.push({ type: "text", text: block.text });
291
+ } else if (block.type === "thinking" && typeof block.thinking === "string") {
292
+ blocks.push({ type: "thinking", thinking: block.thinking });
293
+ } else if (block.type === "toolCall" && typeof block.name === "string") {
294
+ const input = block.arguments && typeof block.arguments === "object"
295
+ ? block.arguments
296
+ : undefined;
297
+ blocks.push({ type: "tool_use", name: block.name, input });
298
+
299
+ const displayName = toolDisplayName(block.name, input);
300
+ const target = extractPiToolTarget(block.name, input);
301
+ toolCallSet.set(displayName, {
302
+ name: block.name,
303
+ displayName,
304
+ category: categorizeToolName(block.name),
305
+ target,
306
+ });
307
+
308
+ const fp = extractPiFilePath(input);
309
+ if (fp) fileSet.add(fp);
310
+ }
311
+ }
312
+
313
+ const textContent = extractText(blocks);
314
+ if (textContent && textContent.length > 20) {
315
+ const redacted =
316
+ privacy.redactPatterns.length > 0 || privacy.redactHomeDir
317
+ ? redactString(textContent, privacy)
318
+ : textContent;
319
+ detail.assistantSummaries.push(
320
+ redacted.slice(0, 200) + (redacted.length > 200 ? "..." : ""),
321
+ );
322
+ }
323
+
324
+ for (const fp of extractFilePaths(blocks)) {
325
+ fileSet.add(fp);
326
+ }
327
+
328
+ detail.thinkingBlockCount += countThinkingBlocks(blocks);
329
+ }
330
+ }
331
+ } catch {
332
+ // file unreadable
333
+ }
334
+
335
+ detail.toolCalls = [...toolCallSet.values()];
336
+ detail.filesReferenced = [...fileSet];
337
+ detail.model = model;
338
+ if (totalCost > 0) detail.totalCost = totalCost;
339
+ detail.assistantSummaries = detail.assistantSummaries.slice(0, 10);
340
+ return detail;
341
+ }
342
+
343
+ /** Stream Pi transcript entries with privacy filtering. */
344
+ export async function* streamPiTranscript(
345
+ sessionId: string,
346
+ sessionsDir: string,
347
+ privacy: PrivacyConfig,
348
+ ): AsyncGenerator<TranscriptEntry> {
349
+ const filePath = await findPiSessionFile(sessionsDir, sessionId);
350
+ if (!filePath) return;
351
+
352
+ const file = Bun.file(filePath);
353
+ if (!(await file.exists())) return;
354
+
355
+ let currentModel: string | undefined;
356
+
357
+ const text = await file.text();
358
+ for (const line of text.split("\n")) {
359
+ if (!line.trim()) continue;
360
+ let raw: any;
361
+ try {
362
+ raw = JSON.parse(line);
363
+ } catch {
364
+ continue;
365
+ }
366
+
367
+ if (raw.type === "model_change" && typeof raw.modelId === "string") {
368
+ currentModel = raw.modelId;
369
+ continue;
370
+ }
371
+
372
+ // Skip non-message events
373
+ if (raw.type !== "message" || !raw.message) continue;
374
+
375
+ const msg = raw.message;
376
+ let mapped: TranscriptEntry | null = null;
377
+
378
+ if (msg.role === "user") {
379
+ const content = Array.isArray(msg.content)
380
+ ? msg.content
381
+ .filter((b: any) => b.type === "text" && typeof b.text === "string")
382
+ .map((b: any) => b.text)
383
+ .join("\n")
384
+ : typeof msg.content === "string"
385
+ ? msg.content
386
+ : "";
387
+ mapped = {
388
+ timestamp: raw.timestamp,
389
+ message: { role: "user", content },
390
+ };
391
+ } else if (msg.role === "assistant" && Array.isArray(msg.content)) {
392
+ const blocks: ContentBlock[] = [];
393
+ for (const block of msg.content) {
394
+ if (block.type === "text" && typeof block.text === "string") {
395
+ blocks.push({ type: "text", text: block.text });
396
+ } else if (block.type === "thinking" && typeof block.thinking === "string") {
397
+ // Strip signature
398
+ blocks.push({ type: "thinking", thinking: block.thinking });
399
+ } else if (block.type === "toolCall" && typeof block.name === "string") {
400
+ const input = block.arguments && typeof block.arguments === "object"
401
+ ? block.arguments
402
+ : undefined;
403
+ blocks.push({ type: "tool_use", name: block.name, id: block.id, input });
404
+ }
405
+ }
406
+ mapped = {
407
+ timestamp: raw.timestamp,
408
+ message: {
409
+ role: "assistant",
410
+ model: currentModel ?? msg.model,
411
+ content: blocks,
412
+ usage: msg.usage
413
+ ? {
414
+ input_tokens: msg.usage.input,
415
+ output_tokens: msg.usage.output,
416
+ cache_read_input_tokens: msg.usage.cacheRead,
417
+ cache_creation_input_tokens: msg.usage.cacheWrite,
418
+ }
419
+ : undefined,
420
+ },
421
+ };
422
+ } else if (msg.role === "toolResult") {
423
+ const output = Array.isArray(msg.content)
424
+ ? msg.content
425
+ .filter((b: any) => b.type === "text" && typeof b.text === "string")
426
+ .map((b: any) => b.text)
427
+ .join("\n")
428
+ : typeof msg.content === "string"
429
+ ? msg.content
430
+ : undefined;
431
+ mapped = {
432
+ timestamp: raw.timestamp,
433
+ toolUseResult: output,
434
+ };
435
+ }
436
+
437
+ if (!mapped) continue;
438
+ const filtered = filterTranscriptEntry(mapped, privacy);
439
+ if (filtered) yield filtered;
440
+ }
441
+ }
442
+
443
+ function extractPiFilePath(input: Record<string, unknown> | undefined): string | undefined {
444
+ if (!input) return undefined;
445
+ for (const key of ["file_path", "path", "target_file", "notebook_path"]) {
446
+ const value = input[key];
447
+ if (typeof value === "string" && value.length > 0) return value;
448
+ }
449
+ return undefined;
450
+ }
451
+
452
+ function extractPiToolTarget(
453
+ name: string,
454
+ input: Record<string, unknown> | undefined,
455
+ ): string | undefined {
456
+ const filePath = extractPiFilePath(input);
457
+ if (filePath) return filePath;
458
+ if (!input) return undefined;
459
+ for (const key of ["command", "pattern", "query"]) {
460
+ const value = input[key];
461
+ if (typeof value === "string" && value.length > 0) {
462
+ return key === "command" ? value.split(" ")[0] : value;
463
+ }
464
+ }
465
+ return undefined;
466
+ }
@@ -1,7 +1,8 @@
1
1
  import { readdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import type { ProjectInfo, ProjectMemory } from "../types/project.js";
4
- import { decodeProjectPath } from "../utils/paths.js";
4
+ import type { Provider } from "../types/provider.js";
5
+ import { decodeProjectPath, decodePiProjectPath } from "../utils/paths.js";
5
6
  import type { PrivacyConfig } from "../types/privacy.js";
6
7
  import { isProjectExcluded } from "../privacy/redact.js";
7
8
 
@@ -9,6 +10,7 @@ import { isProjectExcluded } from "../privacy/redact.js";
9
10
  export async function readProjects(
10
11
  projectsDir: string,
11
12
  privacy: PrivacyConfig,
13
+ provider?: Provider,
12
14
  ): Promise<ProjectInfo[]> {
13
15
  const projects: ProjectInfo[] = [];
14
16
 
@@ -22,7 +24,10 @@ export async function readProjects(
22
24
  for (const encodedPath of entries) {
23
25
  if (encodedPath.startsWith(".")) continue;
24
26
 
25
- const decodedPath = decodeProjectPath(encodedPath);
27
+ const isPiDir = provider === "pi" && encodedPath.startsWith("--") && encodedPath.endsWith("--");
28
+ const decodedPath = isPiDir
29
+ ? decodePiProjectPath(encodedPath)
30
+ : decodeProjectPath(encodedPath);
26
31
 
27
32
  if (isProjectExcluded(decodedPath, privacy)) continue;
28
33
 
@@ -60,8 +65,11 @@ export async function readProjects(
60
65
  export async function readProjectMemory(
61
66
  projectPath: string,
62
67
  projectsDir: string,
68
+ provider?: Provider,
63
69
  ): Promise<ProjectMemory | null> {
64
- const encoded = projectPath.replace(/\//g, "-");
70
+ const encoded = provider === "pi"
71
+ ? "--" + projectPath.slice(1).replace(/\//g, "-") + "--"
72
+ : projectPath.replace(/\//g, "-");
65
73
  const memoryPath = join(projectsDir, encoded, "memory", "MEMORY.md");
66
74
 
67
75
  try {
@@ -84,13 +92,14 @@ export async function readProjectMemory(
84
92
  export async function readProjectMemories(
85
93
  projectPaths: string[],
86
94
  projectsDir: string,
95
+ provider?: Provider,
87
96
  ): Promise<Map<string, string>> {
88
97
  const memory = new Map<string, string>();
89
98
  const unique = [...new Set(projectPaths)];
90
99
 
91
100
  await Promise.all(
92
101
  unique.map(async (projectPath) => {
93
- const result = await readProjectMemory(projectPath, projectsDir);
102
+ const result = await readProjectMemory(projectPath, projectsDir, provider);
94
103
  if (result) {
95
104
  memory.set(result.projectName, result.content);
96
105
  }
@@ -11,6 +11,8 @@ import {
11
11
  parseCodexMessageText,
12
12
  parseCodexToolArguments,
13
13
  } from "./codex-rollout-reader.js";
14
+ import { peekPiSession, streamPiTranscript } from "./pi-session-reader.js";
15
+ import { peekCopilotSession, streamCopilotTranscript } from "./copilot-session-reader.js";
14
16
 
15
17
  /**
16
18
  * Peek session metadata from a session JSONL file.
@@ -24,6 +26,12 @@ export async function peekSession(
24
26
  privacy: PrivacyConfig,
25
27
  ): Promise<SessionMeta> {
26
28
  const normalized = canonicalProvider(provider);
29
+ if (normalized === "pi") {
30
+ return peekPiSession(session, paths.sessionsDir);
31
+ }
32
+ if (normalized === "copilot") {
33
+ return peekCopilotSession(session, paths.sessionsDir);
34
+ }
27
35
  if (normalized === "codex") {
28
36
  return peekCodexSession(session, paths.sessionsDir);
29
37
  }
@@ -75,7 +83,13 @@ async function peekClaudeSession(
75
83
  }
76
84
 
77
85
  // Count messages (user + assistant only)
78
- if (entry.message?.role === "user" || entry.message?.role === "assistant") {
86
+ // Skip: meta-only entries, synthetic error messages, tool result carriers
87
+ if (
88
+ (entry.message?.role === "user" || entry.message?.role === "assistant") &&
89
+ !entry.isMeta &&
90
+ entry.message?.model !== "<synthetic>" &&
91
+ entry.toolUseResult === undefined
92
+ ) {
79
93
  meta.messageCount++;
80
94
  }
81
95
  } catch {
@@ -167,6 +181,14 @@ export async function* streamTranscript(
167
181
  privacy: PrivacyConfig,
168
182
  ): AsyncGenerator<TranscriptEntry> {
169
183
  const normalized = canonicalProvider(provider);
184
+ if (normalized === "pi") {
185
+ yield* streamPiTranscript(sessionId, paths.sessionsDir, privacy);
186
+ return;
187
+ }
188
+ if (normalized === "copilot") {
189
+ yield* streamCopilotTranscript(sessionId, paths.sessionsDir, privacy);
190
+ return;
191
+ }
170
192
  if (normalized === "codex") {
171
193
  yield* streamCodexTranscript(sessionId, paths.sessionsDir, privacy);
172
194
  return;
@@ -2,13 +2,6 @@ import { readdir, stat } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import type { TaskInfo, TodoItem } from "../types/task.js";
4
4
 
5
- function isSameDate(fileDate: Date, targetDate: string): boolean {
6
- const year = fileDate.getFullYear();
7
- const month = String(fileDate.getMonth() + 1).padStart(2, "0");
8
- const day = String(fileDate.getDate()).padStart(2, "0");
9
- return `${year}-${month}-${day}` === targetDate;
10
- }
11
-
12
5
  function isInDateRange(fileDate: Date, from: string, to: string): boolean {
13
6
  const year = fileDate.getFullYear();
14
7
  const month = String(fileDate.getMonth() + 1).padStart(2, "0");
@@ -4,6 +4,8 @@ export const SUPPORTED_PROVIDERS = [
4
4
  "openai",
5
5
  "cursor",
6
6
  "windsurf",
7
+ "pi",
8
+ "copilot",
7
9
  ] as const;
8
10
 
9
11
  export type Provider = (typeof SUPPORTED_PROVIDERS)[number];
@@ -26,6 +26,7 @@ export interface SessionMeta extends SessionInfo {
26
26
  cacheCreationInputTokens: number;
27
27
  cacheReadInputTokens: number;
28
28
  messageCount: number;
29
+ totalCost?: number;
29
30
  }
30
31
 
31
32
  /** Full session detail from parsing the entire session JSONL file. */
@@ -13,6 +13,7 @@ export interface ContentBlock {
13
13
  /** Raw line from a session JSONL file. Union of all possible shapes. */
14
14
  export interface TranscriptEntry {
15
15
  type?: "user" | "assistant" | "progress" | "file-history-snapshot";
16
+ subtype?: "turn_duration" | string;
16
17
  message?: {
17
18
  role?: "user" | "assistant";
18
19
  content?: string | ContentBlock[];
@@ -33,4 +34,20 @@ export interface TranscriptEntry {
33
34
  parentUuid?: string;
34
35
  uuid?: string;
35
36
  toolUseResult?: unknown;
37
+ /** Metadata-only entry (e.g. image paste) — no real message content */
38
+ isMeta?: boolean;
39
+ /** Wall-clock duration of this turn in milliseconds */
40
+ durationMs?: number;
41
+ /** Error message from API failures */
42
+ error?: string;
43
+ /** Whether this entry represents an API error (rate limit, prompt too long, etc.) */
44
+ isApiErrorMessage?: boolean;
45
+ /** Claude Code version string */
46
+ version?: string;
47
+ /** Human-readable session name */
48
+ slug?: string;
49
+ /** Subagent identifier */
50
+ agentId?: string;
51
+ /** User type, e.g. "external" */
52
+ userType?: string;
36
53
  }
@@ -12,14 +12,6 @@ export function today(): string {
12
12
  return toLocalDate(Date.now());
13
13
  }
14
14
 
15
- /** Format a timestamp to HH:MM (24h). */
16
- export function formatTime(timestamp: number): string {
17
- const d = new Date(timestamp);
18
- const h = String(d.getHours()).padStart(2, "0");
19
- const m = String(d.getMinutes()).padStart(2, "0");
20
- return `${h}:${m}`;
21
- }
22
-
23
15
  /** Resolve a DateFilter to concrete from/to strings. */
24
16
  export function resolveDateRange(filter?: {
25
17
  date?: string;
@@ -35,6 +27,10 @@ export function resolveDateRange(filter?: {
35
27
  if (filter?.from) {
36
28
  return { from: filter.from, to: today() };
37
29
  }
30
+ if (filter?.to) {
31
+ return { from: "1970-01-01", to: filter.to };
32
+ }
33
+
38
34
  const t = today();
39
35
  return { from: t, to: t };
40
36
  }