agent-optic 0.2.0 → 0.3.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 +119 -0
- package/examples/branch-report.ts +566 -0
- package/examples/commit-tracker.ts +155 -34
- package/examples/cost-per-feature.ts +107 -57
- package/examples/match-git-commits.ts +5 -5
- package/examples/model-costs.ts +3 -3
- 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 +13 -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 +8 -36
- package/src/parsers/session-detail.ts +11 -6
- package/src/parsers/tool-categories.ts +8 -0
- package/src/readers/history-reader.ts +7 -0
- package/src/readers/pi-session-reader.ts +466 -0
- package/src/readers/project-reader.ts +13 -4
- package/src/readers/session-reader.ts +15 -1
- package/src/readers/task-reader.ts +0 -7
- package/src/types/provider.ts +1 -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 +22 -4
- package/src/utils/providers.ts +1 -4
- package/src/claude-optic.ts +0 -7
- package/src/utils/jsonl.ts +0 -83
package/src/index.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// Main factory
|
|
2
|
-
export { createHistory
|
|
3
|
-
export type { History, HistoryConfig
|
|
2
|
+
export { createHistory } from "./agent-optic.js";
|
|
3
|
+
export type { History, HistoryConfig } from "./agent-optic.js";
|
|
4
4
|
|
|
5
|
-
// Provider types
|
|
5
|
+
// Provider types
|
|
6
6
|
export type { Provider } from "./types/provider.js";
|
|
7
7
|
export { SUPPORTED_PROVIDERS } from "./types/provider.js";
|
|
8
8
|
|
|
9
|
-
//
|
|
9
|
+
// Domain types
|
|
10
10
|
export type {
|
|
11
11
|
HistoryEntry,
|
|
12
12
|
SessionInfo,
|
|
@@ -15,17 +15,11 @@ export type {
|
|
|
15
15
|
ToolCategory,
|
|
16
16
|
ToolCallSummary,
|
|
17
17
|
} from "./types/session.js";
|
|
18
|
-
|
|
19
18
|
export type { ContentBlock, TranscriptEntry } from "./types/transcript.js";
|
|
20
|
-
|
|
21
19
|
export type { TaskInfo, TodoItem } from "./types/task.js";
|
|
22
|
-
|
|
23
20
|
export type { PlanInfo } from "./types/plan.js";
|
|
24
|
-
|
|
25
21
|
export type { ProjectInfo, ProjectMemory } from "./types/project.js";
|
|
26
|
-
|
|
27
22
|
export type { StatsCache } from "./types/stats.js";
|
|
28
|
-
|
|
29
23
|
export type {
|
|
30
24
|
DailySummary,
|
|
31
25
|
ProjectSummary,
|
|
@@ -33,37 +27,15 @@ export type {
|
|
|
33
27
|
DateFilter,
|
|
34
28
|
SessionListFilter,
|
|
35
29
|
} from "./types/aggregations.js";
|
|
36
|
-
|
|
37
30
|
export type { PrivacyConfig, PrivacyProfile } from "./types/privacy.js";
|
|
38
31
|
|
|
39
|
-
// Privacy
|
|
32
|
+
// Privacy
|
|
40
33
|
export { PRIVACY_PROFILES, resolvePrivacyConfig } from "./privacy/config.js";
|
|
41
34
|
|
|
42
|
-
//
|
|
43
|
-
export {
|
|
44
|
-
|
|
45
|
-
decodeProjectPath,
|
|
46
|
-
projectName,
|
|
47
|
-
providerPaths,
|
|
48
|
-
claudePaths,
|
|
49
|
-
} from "./utils/paths.js";
|
|
50
|
-
export {
|
|
51
|
-
DEFAULT_PROVIDER,
|
|
52
|
-
defaultProviderDir,
|
|
53
|
-
providerHomeDirName,
|
|
54
|
-
isProvider,
|
|
55
|
-
} from "./utils/providers.js";
|
|
56
|
-
export { toLocalDate, today, formatTime, resolveDateRange } from "./utils/dates.js";
|
|
57
|
-
export { parseJsonl, streamJsonl, peekJsonl, readJsonl } from "./utils/jsonl.js";
|
|
58
|
-
|
|
59
|
-
// Parsers (for advanced users building custom pipelines)
|
|
60
|
-
export { parseSessionDetail, parseSessions } from "./parsers/session-detail.js";
|
|
61
|
-
export { categorizeToolName, toolDisplayName } from "./parsers/tool-categories.js";
|
|
62
|
-
export { extractText, extractToolCalls, extractFilePaths, countThinkingBlocks } from "./parsers/content-blocks.js";
|
|
35
|
+
// Small public utilities
|
|
36
|
+
export { projectName } from "./utils/paths.js";
|
|
37
|
+
export { toLocalDate, today } from "./utils/dates.js";
|
|
63
38
|
|
|
64
39
|
// Pricing
|
|
65
40
|
export type { ModelPricing } from "./pricing.js";
|
|
66
41
|
export { MODEL_PRICING, getModelPricing, estimateCost, setPricing } from "./pricing.js";
|
|
67
|
-
|
|
68
|
-
// Readers (for advanced users)
|
|
69
|
-
export { readProjectMemories } from "./readers/project-reader.js";
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
parseCodexMessageText,
|
|
14
14
|
parseCodexToolArguments,
|
|
15
15
|
} from "../readers/codex-rollout-reader.js";
|
|
16
|
+
import { parsePiSessionDetail } from "../readers/pi-session-reader.js";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Parse a full session JSONL file into a SessionDetail.
|
|
@@ -25,6 +26,9 @@ export async function parseSessionDetail(
|
|
|
25
26
|
privacy: PrivacyConfig,
|
|
26
27
|
): Promise<SessionDetail> {
|
|
27
28
|
const normalized = canonicalProvider(provider);
|
|
29
|
+
if (normalized === "pi") {
|
|
30
|
+
return parsePiSessionDetail(session, paths.sessionsDir, privacy);
|
|
31
|
+
}
|
|
28
32
|
if (normalized === "codex") {
|
|
29
33
|
return parseCodexSessionDetail(session, paths.sessionsDir, privacy);
|
|
30
34
|
}
|
|
@@ -111,8 +115,13 @@ async function parseClaudeSessionDetail(
|
|
|
111
115
|
detail.cacheReadInputTokens += usage.cache_read_input_tokens ?? 0;
|
|
112
116
|
}
|
|
113
117
|
|
|
114
|
-
// Count messages
|
|
115
|
-
if (
|
|
118
|
+
// Count messages (skip meta-only, synthetic errors, tool result carriers)
|
|
119
|
+
if (
|
|
120
|
+
(role === "user" || role === "assistant") &&
|
|
121
|
+
!filtered.isMeta &&
|
|
122
|
+
filtered.message?.model !== "<synthetic>" &&
|
|
123
|
+
entry.toolUseResult === undefined
|
|
124
|
+
) {
|
|
116
125
|
detail.messageCount++;
|
|
117
126
|
}
|
|
118
127
|
|
|
@@ -292,10 +301,6 @@ function extractCodexToolTarget(
|
|
|
292
301
|
}
|
|
293
302
|
}
|
|
294
303
|
|
|
295
|
-
if (name === "exec_command" && typeof input.cmd === "string") {
|
|
296
|
-
return input.cmd.split(" ")[0];
|
|
297
|
-
}
|
|
298
|
-
|
|
299
304
|
return undefined;
|
|
300
305
|
}
|
|
301
306
|
|
|
@@ -33,6 +33,14 @@ const TOOL_CATEGORY_MAP: Record<string, ToolCategory> = {
|
|
|
33
33
|
AskUserQuestion: "task",
|
|
34
34
|
Skill: "task",
|
|
35
35
|
|
|
36
|
+
// Pi tools (lowercase variants)
|
|
37
|
+
bash: "shell",
|
|
38
|
+
read: "file_read",
|
|
39
|
+
write: "file_write",
|
|
40
|
+
edit: "file_write",
|
|
41
|
+
glob: "file_read",
|
|
42
|
+
grep: "file_read",
|
|
43
|
+
|
|
36
44
|
// Codex tools
|
|
37
45
|
exec_command: "shell",
|
|
38
46
|
shell_command: "shell",
|
|
@@ -7,6 +7,7 @@ import { projectName } from "../utils/paths.js";
|
|
|
7
7
|
import { canonicalProvider } from "../utils/providers.js";
|
|
8
8
|
import { isProjectExcluded, redactString } from "../privacy/redact.js";
|
|
9
9
|
import { readCodexSessionHeader } from "./codex-rollout-reader.js";
|
|
10
|
+
import { readPiHistory } from "./pi-session-reader.js";
|
|
10
11
|
|
|
11
12
|
interface ClaudeHistoryEntry {
|
|
12
13
|
display: string;
|
|
@@ -37,6 +38,12 @@ export async function readHistory(
|
|
|
37
38
|
},
|
|
38
39
|
): Promise<SessionInfo[]> {
|
|
39
40
|
const provider = canonicalProvider(options?.provider ?? "claude");
|
|
41
|
+
if (provider === "pi") {
|
|
42
|
+
return readPiHistory(
|
|
43
|
+
options?.sessionsDir ?? join(dirname(historyFile), "sessions"),
|
|
44
|
+
from, to, privacy,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
40
47
|
if (provider === "codex") {
|
|
41
48
|
return readCodexHistory(
|
|
42
49
|
historyFile,
|
|
@@ -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
|
}
|