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.
- package/README.md +40 -27
- package/examples/annotate-commits.ts +138 -0
- package/examples/branch-report.ts +561 -0
- package/examples/commit-tracker.ts +159 -47
- package/examples/cost-per-feature.ts +108 -63
- package/examples/git-helpers.ts +66 -0
- package/examples/match-git-commits.ts +18 -14
- package/examples/model-costs.ts +11 -15
- package/examples/pipe-match.ts +3 -3
- package/examples/prompt-history.ts +3 -3
- package/examples/session-digest.ts +2 -2
- package/examples/timesheet.ts +3 -3
- package/examples/ubiquitous-language.ts +184 -0
- package/examples/work-patterns.ts +2 -2
- package/package.json +14 -7
- package/skills/agent-optic/SKILL.md +302 -0
- package/src/agent-optic.ts +4 -25
- package/src/aggregations/daily.ts +1 -1
- package/src/aggregations/project.ts +0 -1
- package/src/aggregations/tools.ts +1 -1
- package/src/cli/index.ts +2 -2
- package/src/index.ts +11 -36
- package/src/parsers/session-detail.ts +15 -6
- package/src/parsers/tool-categories.ts +8 -0
- package/src/pricing.ts +66 -4
- package/src/readers/copilot-session-reader.ts +432 -0
- package/src/readers/history-reader.ts +14 -0
- package/src/readers/pi-session-reader.ts +466 -0
- package/src/readers/project-reader.ts +13 -4
- package/src/readers/session-reader.ts +23 -1
- package/src/readers/task-reader.ts +0 -7
- package/src/types/provider.ts +2 -0
- package/src/types/session.ts +1 -0
- package/src/types/transcript.ts +17 -0
- package/src/utils/dates.ts +4 -8
- package/src/utils/paths.ts +37 -4
- package/src/utils/providers.ts +37 -4
- package/src/claude-optic.ts +0 -7
- 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 {
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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");
|
package/src/types/provider.ts
CHANGED
package/src/types/session.ts
CHANGED
package/src/types/transcript.ts
CHANGED
|
@@ -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
|
}
|
package/src/utils/dates.ts
CHANGED
|
@@ -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
|
}
|