agent-optic 0.2.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/LICENSE +21 -0
- package/README.md +337 -0
- package/examples/commit-tracker.ts +389 -0
- package/examples/cost-per-feature.ts +182 -0
- package/examples/match-git-commits.ts +171 -0
- package/examples/model-costs.ts +131 -0
- package/examples/pipe-match.ts +177 -0
- package/examples/prompt-history.ts +119 -0
- package/examples/session-digest.ts +89 -0
- package/examples/timesheet.ts +127 -0
- package/examples/work-patterns.ts +124 -0
- package/package.json +41 -0
- package/src/agent-optic.ts +325 -0
- package/src/aggregations/daily.ts +90 -0
- package/src/aggregations/project.ts +71 -0
- package/src/aggregations/time.ts +44 -0
- package/src/aggregations/tools.ts +60 -0
- package/src/claude-optic.ts +7 -0
- package/src/cli/index.ts +407 -0
- package/src/index.ts +69 -0
- package/src/parsers/content-blocks.ts +58 -0
- package/src/parsers/session-detail.ts +323 -0
- package/src/parsers/tool-categories.ts +86 -0
- package/src/pricing.ts +62 -0
- package/src/privacy/config.ts +67 -0
- package/src/privacy/redact.ts +99 -0
- package/src/readers/codex-rollout-reader.ts +145 -0
- package/src/readers/history-reader.ts +205 -0
- package/src/readers/plan-reader.ts +60 -0
- package/src/readers/project-reader.ts +101 -0
- package/src/readers/session-reader.ts +280 -0
- package/src/readers/skill-reader.ts +28 -0
- package/src/readers/stats-reader.ts +12 -0
- package/src/readers/task-reader.ts +117 -0
- package/src/types/aggregations.ts +47 -0
- package/src/types/plan.ts +6 -0
- package/src/types/privacy.ts +18 -0
- package/src/types/project.ts +13 -0
- package/src/types/provider.ts +9 -0
- package/src/types/session.ts +56 -0
- package/src/types/stats.ts +15 -0
- package/src/types/task.ts +16 -0
- package/src/types/transcript.ts +36 -0
- package/src/utils/dates.ts +40 -0
- package/src/utils/jsonl.ts +83 -0
- package/src/utils/paths.ts +57 -0
- package/src/utils/providers.ts +30 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ContentBlock } from "../types/transcript.js";
|
|
2
|
+
import type { ToolCallSummary } from "../types/session.js";
|
|
3
|
+
import { categorizeToolName, toolDisplayName } from "./tool-categories.js";
|
|
4
|
+
|
|
5
|
+
/** Extract text content from message content (string or ContentBlock[]). */
|
|
6
|
+
export function extractText(content: string | ContentBlock[] | undefined): string {
|
|
7
|
+
if (!content) return "";
|
|
8
|
+
if (typeof content === "string") return content;
|
|
9
|
+
return content
|
|
10
|
+
.filter((b): b is ContentBlock & { text: string } => b.type === "text" && !!b.text)
|
|
11
|
+
.map((b) => b.text)
|
|
12
|
+
.join("\n");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Extract tool call summaries from content blocks. */
|
|
16
|
+
export function extractToolCalls(content: string | ContentBlock[] | undefined): ToolCallSummary[] {
|
|
17
|
+
if (!content || typeof content === "string") return [];
|
|
18
|
+
|
|
19
|
+
return content
|
|
20
|
+
.filter((b): b is ContentBlock & { name: string } => b.type === "tool_use" && !!b.name)
|
|
21
|
+
.map((b) => ({
|
|
22
|
+
name: b.name,
|
|
23
|
+
displayName: toolDisplayName(b.name, b.input as Record<string, unknown> | undefined),
|
|
24
|
+
category: categorizeToolName(b.name),
|
|
25
|
+
target: extractToolTarget(b),
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Extract file paths referenced in tool use blocks. */
|
|
30
|
+
export function extractFilePaths(content: string | ContentBlock[] | undefined): string[] {
|
|
31
|
+
if (!content || typeof content === "string") return [];
|
|
32
|
+
|
|
33
|
+
const paths: string[] = [];
|
|
34
|
+
for (const block of content) {
|
|
35
|
+
if (block.type !== "tool_use" || !block.input) continue;
|
|
36
|
+
const input = block.input as Record<string, string>;
|
|
37
|
+
if (input.file_path) paths.push(input.file_path);
|
|
38
|
+
if (input.notebook_path) paths.push(input.notebook_path);
|
|
39
|
+
}
|
|
40
|
+
return paths;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Count thinking blocks in content. */
|
|
44
|
+
export function countThinkingBlocks(content: string | ContentBlock[] | undefined): number {
|
|
45
|
+
if (!content || typeof content === "string") return 0;
|
|
46
|
+
return content.filter((b) => b.type === "thinking").length;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function extractToolTarget(block: ContentBlock): string | undefined {
|
|
50
|
+
const input = block.input as Record<string, string> | undefined;
|
|
51
|
+
if (!input) return undefined;
|
|
52
|
+
if (input.file_path) return input.file_path;
|
|
53
|
+
if (input.notebook_path) return input.notebook_path;
|
|
54
|
+
if (input.command) return input.command.split(" ")[0];
|
|
55
|
+
if (input.pattern) return input.pattern;
|
|
56
|
+
if (input.query) return input.query.slice(0, 80);
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { PrivacyConfig } from "../types/privacy.js";
|
|
3
|
+
import type { Provider } from "../types/provider.js";
|
|
4
|
+
import type { SessionDetail, SessionInfo, ToolCallSummary } from "../types/session.js";
|
|
5
|
+
import type { TranscriptEntry } from "../types/transcript.js";
|
|
6
|
+
import { encodeProjectPath } from "../utils/paths.js";
|
|
7
|
+
import { canonicalProvider } from "../utils/providers.js";
|
|
8
|
+
import { filterTranscriptEntry, redactString } from "../privacy/redact.js";
|
|
9
|
+
import { extractText, extractToolCalls, extractFilePaths, countThinkingBlocks } from "./content-blocks.js";
|
|
10
|
+
import { categorizeToolName, toolDisplayName } from "./tool-categories.js";
|
|
11
|
+
import {
|
|
12
|
+
findRolloutFile,
|
|
13
|
+
parseCodexMessageText,
|
|
14
|
+
parseCodexToolArguments,
|
|
15
|
+
} from "../readers/codex-rollout-reader.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse a full session JSONL file into a SessionDetail.
|
|
19
|
+
* This is the "full" tier — reads and parses every line.
|
|
20
|
+
*/
|
|
21
|
+
export async function parseSessionDetail(
|
|
22
|
+
provider: Provider,
|
|
23
|
+
session: SessionInfo,
|
|
24
|
+
paths: { projectsDir: string; sessionsDir: string },
|
|
25
|
+
privacy: PrivacyConfig,
|
|
26
|
+
): Promise<SessionDetail> {
|
|
27
|
+
const normalized = canonicalProvider(provider);
|
|
28
|
+
if (normalized === "codex") {
|
|
29
|
+
return parseCodexSessionDetail(session, paths.sessionsDir, privacy);
|
|
30
|
+
}
|
|
31
|
+
return parseClaudeSessionDetail(session, paths.projectsDir, privacy);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function parseClaudeSessionDetail(
|
|
35
|
+
session: SessionInfo,
|
|
36
|
+
projectsDir: string,
|
|
37
|
+
privacy: PrivacyConfig,
|
|
38
|
+
): Promise<SessionDetail> {
|
|
39
|
+
const detail: SessionDetail = {
|
|
40
|
+
...session,
|
|
41
|
+
totalInputTokens: 0,
|
|
42
|
+
totalOutputTokens: 0,
|
|
43
|
+
cacheCreationInputTokens: 0,
|
|
44
|
+
cacheReadInputTokens: 0,
|
|
45
|
+
messageCount: 0,
|
|
46
|
+
assistantSummaries: [],
|
|
47
|
+
toolCalls: [],
|
|
48
|
+
filesReferenced: [],
|
|
49
|
+
planReferenced: false,
|
|
50
|
+
thinkingBlockCount: 0,
|
|
51
|
+
hasSidechains: false,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const encoded = encodeProjectPath(session.project);
|
|
55
|
+
const filePath = join(projectsDir, encoded, `${session.sessionId}.jsonl`);
|
|
56
|
+
const file = Bun.file(filePath);
|
|
57
|
+
|
|
58
|
+
if (!(await file.exists())) return detail;
|
|
59
|
+
|
|
60
|
+
const text = await file.text();
|
|
61
|
+
const lines = text.split("\n");
|
|
62
|
+
|
|
63
|
+
const toolCallSet = new Map<string, ToolCallSummary>();
|
|
64
|
+
const fileSet = new Set<string>();
|
|
65
|
+
let gitBranch: string | undefined;
|
|
66
|
+
let model: string | undefined;
|
|
67
|
+
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
if (!line.trim()) continue;
|
|
70
|
+
|
|
71
|
+
let entry: TranscriptEntry;
|
|
72
|
+
try {
|
|
73
|
+
entry = JSON.parse(line);
|
|
74
|
+
} catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Apply privacy filtering
|
|
79
|
+
const filtered = filterTranscriptEntry(entry, privacy);
|
|
80
|
+
if (!filtered) continue;
|
|
81
|
+
|
|
82
|
+
// Track sidechains
|
|
83
|
+
if (filtered.isSidechain) {
|
|
84
|
+
detail.hasSidechains = true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Extract git branch
|
|
88
|
+
if (!gitBranch && filtered.gitBranch && filtered.gitBranch !== "HEAD") {
|
|
89
|
+
gitBranch = filtered.gitBranch;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Track plan references
|
|
93
|
+
if ((filtered as { planContent?: string }).planContent) {
|
|
94
|
+
detail.planReferenced = true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!filtered.message) continue;
|
|
98
|
+
|
|
99
|
+
const { role, content, model: msgModel, usage } = filtered.message;
|
|
100
|
+
|
|
101
|
+
// Extract model
|
|
102
|
+
if (msgModel && !model) {
|
|
103
|
+
model = msgModel;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Accumulate tokens
|
|
107
|
+
if (usage) {
|
|
108
|
+
detail.totalInputTokens += usage.input_tokens ?? 0;
|
|
109
|
+
detail.totalOutputTokens += usage.output_tokens ?? 0;
|
|
110
|
+
detail.cacheCreationInputTokens += usage.cache_creation_input_tokens ?? 0;
|
|
111
|
+
detail.cacheReadInputTokens += usage.cache_read_input_tokens ?? 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Count messages
|
|
115
|
+
if (role === "user" || role === "assistant") {
|
|
116
|
+
detail.messageCount++;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Process assistant messages
|
|
120
|
+
if (role === "assistant" && content) {
|
|
121
|
+
const text = extractText(content);
|
|
122
|
+
if (text && text.length > 20) {
|
|
123
|
+
detail.assistantSummaries.push(
|
|
124
|
+
text.slice(0, 200) + (text.length > 200 ? "..." : ""),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const tc of extractToolCalls(content)) {
|
|
129
|
+
toolCallSet.set(tc.displayName, tc);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const fp of extractFilePaths(content)) {
|
|
133
|
+
fileSet.add(fp);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
detail.thinkingBlockCount += countThinkingBlocks(content);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
detail.toolCalls = [...toolCallSet.values()];
|
|
141
|
+
detail.filesReferenced = [...fileSet];
|
|
142
|
+
detail.gitBranch = gitBranch;
|
|
143
|
+
detail.model = model;
|
|
144
|
+
|
|
145
|
+
// Limit summaries
|
|
146
|
+
detail.assistantSummaries = detail.assistantSummaries.slice(0, 10);
|
|
147
|
+
|
|
148
|
+
return detail;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function parseCodexSessionDetail(
|
|
152
|
+
session: SessionInfo,
|
|
153
|
+
sessionsDir: string,
|
|
154
|
+
privacy: PrivacyConfig,
|
|
155
|
+
): Promise<SessionDetail> {
|
|
156
|
+
const detail: SessionDetail = {
|
|
157
|
+
...session,
|
|
158
|
+
totalInputTokens: 0,
|
|
159
|
+
totalOutputTokens: 0,
|
|
160
|
+
cacheCreationInputTokens: 0,
|
|
161
|
+
cacheReadInputTokens: 0,
|
|
162
|
+
messageCount: 0,
|
|
163
|
+
assistantSummaries: [],
|
|
164
|
+
toolCalls: [],
|
|
165
|
+
filesReferenced: [],
|
|
166
|
+
planReferenced: false,
|
|
167
|
+
thinkingBlockCount: 0,
|
|
168
|
+
hasSidechains: false,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const rolloutPath = await findRolloutFile(sessionsDir, session.sessionId);
|
|
172
|
+
if (!rolloutPath) return detail;
|
|
173
|
+
|
|
174
|
+
const file = Bun.file(rolloutPath);
|
|
175
|
+
if (!(await file.exists())) return detail;
|
|
176
|
+
|
|
177
|
+
const toolCallSet = new Map<string, ToolCallSummary>();
|
|
178
|
+
const fileSet = new Set<string>();
|
|
179
|
+
let gitBranch: string | undefined;
|
|
180
|
+
let model: string | undefined;
|
|
181
|
+
|
|
182
|
+
const text = await file.text();
|
|
183
|
+
for (const line of text.split("\n")) {
|
|
184
|
+
if (!line.trim()) continue;
|
|
185
|
+
|
|
186
|
+
let entry: any;
|
|
187
|
+
try {
|
|
188
|
+
entry = JSON.parse(line);
|
|
189
|
+
} catch {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (entry.type === "session_meta") {
|
|
194
|
+
const branch = entry.payload?.git?.branch;
|
|
195
|
+
if (!gitBranch && typeof branch === "string" && branch !== "HEAD") {
|
|
196
|
+
gitBranch = branch;
|
|
197
|
+
}
|
|
198
|
+
} else if (entry.type === "turn_context") {
|
|
199
|
+
const m = entry.payload?.model;
|
|
200
|
+
if (!model && typeof m === "string") {
|
|
201
|
+
model = m;
|
|
202
|
+
}
|
|
203
|
+
} else if (entry.type === "event_msg") {
|
|
204
|
+
if (entry.payload?.type === "token_count") {
|
|
205
|
+
const usage = entry.payload?.info?.last_token_usage;
|
|
206
|
+
if (usage && typeof usage === "object") {
|
|
207
|
+
detail.totalInputTokens += Number(usage.input_tokens ?? 0);
|
|
208
|
+
detail.totalOutputTokens += Number(usage.output_tokens ?? 0);
|
|
209
|
+
detail.cacheReadInputTokens += Number(usage.cached_input_tokens ?? 0);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (
|
|
214
|
+
entry.payload?.type === "user_message" ||
|
|
215
|
+
entry.payload?.type === "agent_message"
|
|
216
|
+
) {
|
|
217
|
+
detail.messageCount++;
|
|
218
|
+
}
|
|
219
|
+
} else if (entry.type === "response_item") {
|
|
220
|
+
if (entry.payload?.type === "reasoning") {
|
|
221
|
+
detail.thinkingBlockCount++;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (
|
|
225
|
+
entry.payload?.type === "message" &&
|
|
226
|
+
entry.payload?.role === "assistant"
|
|
227
|
+
) {
|
|
228
|
+
const summary = parseCodexMessageText(entry.payload?.content);
|
|
229
|
+
if (summary && summary.length > 20) {
|
|
230
|
+
const redacted =
|
|
231
|
+
privacy.redactPatterns.length > 0 || privacy.redactHomeDir
|
|
232
|
+
? redactString(summary, privacy)
|
|
233
|
+
: summary;
|
|
234
|
+
detail.assistantSummaries.push(
|
|
235
|
+
redacted.slice(0, 200) + (redacted.length > 200 ? "..." : ""),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (
|
|
241
|
+
entry.payload?.type === "function_call" &&
|
|
242
|
+
typeof entry.payload?.name === "string"
|
|
243
|
+
) {
|
|
244
|
+
const name = entry.payload.name;
|
|
245
|
+
const input = parseCodexToolArguments(entry.payload.arguments);
|
|
246
|
+
const displayName = toolDisplayName(name, input);
|
|
247
|
+
const target = extractCodexToolTarget(name, input);
|
|
248
|
+
const summary: ToolCallSummary = {
|
|
249
|
+
name,
|
|
250
|
+
displayName,
|
|
251
|
+
category: categorizeToolName(name),
|
|
252
|
+
target,
|
|
253
|
+
};
|
|
254
|
+
toolCallSet.set(displayName, summary);
|
|
255
|
+
|
|
256
|
+
const filePath = extractCodexFilePath(input);
|
|
257
|
+
if (filePath) fileSet.add(filePath);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
detail.toolCalls = [...toolCallSet.values()];
|
|
263
|
+
detail.filesReferenced = [...fileSet];
|
|
264
|
+
detail.gitBranch = gitBranch;
|
|
265
|
+
detail.model = model;
|
|
266
|
+
detail.assistantSummaries = detail.assistantSummaries.slice(0, 10);
|
|
267
|
+
return detail;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function extractCodexFilePath(input: Record<string, unknown> | undefined): string | undefined {
|
|
271
|
+
if (!input) return undefined;
|
|
272
|
+
const candidates = ["file_path", "path", "target_file", "notebook_path"];
|
|
273
|
+
for (const key of candidates) {
|
|
274
|
+
const value = input[key];
|
|
275
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
276
|
+
}
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function extractCodexToolTarget(
|
|
281
|
+
name: string,
|
|
282
|
+
input: Record<string, unknown> | undefined,
|
|
283
|
+
): string | undefined {
|
|
284
|
+
const filePath = extractCodexFilePath(input);
|
|
285
|
+
if (filePath) return filePath;
|
|
286
|
+
|
|
287
|
+
if (!input) return undefined;
|
|
288
|
+
for (const key of ["command", "cmd", "pattern", "query"]) {
|
|
289
|
+
const value = input[key];
|
|
290
|
+
if (typeof value === "string" && value.length > 0) {
|
|
291
|
+
return key === "command" || key === "cmd" ? value.split(" ")[0] : value;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (name === "exec_command" && typeof input.cmd === "string") {
|
|
296
|
+
return input.cmd.split(" ")[0];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Parse multiple sessions, splitting into detailed (3+ prompts) and short.
|
|
304
|
+
*/
|
|
305
|
+
export async function parseSessions(
|
|
306
|
+
provider: Provider,
|
|
307
|
+
sessions: SessionInfo[],
|
|
308
|
+
paths: { projectsDir: string; sessionsDir: string },
|
|
309
|
+
privacy: PrivacyConfig,
|
|
310
|
+
): Promise<{ detailed: SessionDetail[]; short: SessionInfo[] }> {
|
|
311
|
+
const detailed: SessionDetail[] = [];
|
|
312
|
+
const short: SessionInfo[] = [];
|
|
313
|
+
|
|
314
|
+
for (const session of sessions) {
|
|
315
|
+
if (session.prompts.length >= 3) {
|
|
316
|
+
detailed.push(await parseSessionDetail(provider, session, paths, privacy));
|
|
317
|
+
} else {
|
|
318
|
+
short.push(session);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return { detailed, short };
|
|
323
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ToolCategory } from "../types/session.js";
|
|
2
|
+
|
|
3
|
+
const TOOL_CATEGORY_MAP: Record<string, ToolCategory> = {
|
|
4
|
+
// File reading
|
|
5
|
+
Read: "file_read",
|
|
6
|
+
Glob: "file_read",
|
|
7
|
+
Grep: "file_read",
|
|
8
|
+
ListMcpResourcesTool: "file_read",
|
|
9
|
+
ReadMcpResourceTool: "file_read",
|
|
10
|
+
|
|
11
|
+
// File writing
|
|
12
|
+
Write: "file_write",
|
|
13
|
+
Edit: "file_write",
|
|
14
|
+
NotebookEdit: "file_write",
|
|
15
|
+
|
|
16
|
+
// Shell
|
|
17
|
+
Bash: "shell",
|
|
18
|
+
|
|
19
|
+
// Search
|
|
20
|
+
WebSearch: "search",
|
|
21
|
+
WebFetch: "web",
|
|
22
|
+
|
|
23
|
+
// Task/agent management
|
|
24
|
+
Task: "task",
|
|
25
|
+
TaskCreate: "task",
|
|
26
|
+
TaskUpdate: "task",
|
|
27
|
+
TaskGet: "task",
|
|
28
|
+
TaskList: "task",
|
|
29
|
+
TaskStop: "task",
|
|
30
|
+
TaskOutput: "task",
|
|
31
|
+
EnterPlanMode: "task",
|
|
32
|
+
ExitPlanMode: "task",
|
|
33
|
+
AskUserQuestion: "task",
|
|
34
|
+
Skill: "task",
|
|
35
|
+
|
|
36
|
+
// Codex tools
|
|
37
|
+
exec_command: "shell",
|
|
38
|
+
shell_command: "shell",
|
|
39
|
+
write_stdin: "shell",
|
|
40
|
+
apply_patch: "file_write",
|
|
41
|
+
update_plan: "task",
|
|
42
|
+
request_user_input: "task",
|
|
43
|
+
view_image: "file_read",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function categorizeToolName(name: string): ToolCategory {
|
|
47
|
+
// Check direct mapping
|
|
48
|
+
if (name in TOOL_CATEGORY_MAP) return TOOL_CATEGORY_MAP[name];
|
|
49
|
+
|
|
50
|
+
// MCP tools with known patterns
|
|
51
|
+
if (name.startsWith("mcp__")) {
|
|
52
|
+
if (name.includes("search")) return "search";
|
|
53
|
+
if (name.includes("fetch") || name.includes("read")) return "file_read";
|
|
54
|
+
if (name.includes("write") || name.includes("create") || name.includes("edit")) return "file_write";
|
|
55
|
+
return "other";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return "other";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Create a human-readable display name for a tool call. */
|
|
62
|
+
export function toolDisplayName(
|
|
63
|
+
name: string,
|
|
64
|
+
input?: Record<string, unknown>,
|
|
65
|
+
): string {
|
|
66
|
+
const shortName = name.startsWith("mcp__")
|
|
67
|
+
? name.split("__").pop() || name
|
|
68
|
+
: name;
|
|
69
|
+
|
|
70
|
+
if (input?.file_path && typeof input.file_path === "string") {
|
|
71
|
+
const parts = input.file_path.split("/");
|
|
72
|
+
const short = parts.slice(-2).join("/");
|
|
73
|
+
return `${shortName}(${short})`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (input?.command && typeof input.command === "string") {
|
|
77
|
+
const cmd = input.command.split(" ")[0];
|
|
78
|
+
return `${shortName}(${cmd})`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (input?.pattern && typeof input.pattern === "string") {
|
|
82
|
+
return `${shortName}(${input.pattern})`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return shortName;
|
|
86
|
+
}
|
package/src/pricing.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { SessionMeta } from "./types/session.js";
|
|
2
|
+
|
|
3
|
+
/** Per-million-token pricing in USD. */
|
|
4
|
+
export interface ModelPricing {
|
|
5
|
+
input: number;
|
|
6
|
+
output: number;
|
|
7
|
+
cacheWrite: number;
|
|
8
|
+
cacheRead: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Default model pricing (USD per million tokens). */
|
|
12
|
+
export const MODEL_PRICING: Record<string, ModelPricing> = {
|
|
13
|
+
// Opus 4.5+ ($5/$25)
|
|
14
|
+
"claude-opus-4-6": { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 },
|
|
15
|
+
"claude-opus-4-5-20250514": { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 },
|
|
16
|
+
|
|
17
|
+
// Opus 4.0–4.1 ($15/$75)
|
|
18
|
+
"claude-opus-4-1-20250514": { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 },
|
|
19
|
+
"claude-opus-4-0-20250514": { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 },
|
|
20
|
+
|
|
21
|
+
// Sonnet ($3/$15)
|
|
22
|
+
"claude-sonnet-4-5-20250929": { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
|
|
23
|
+
"claude-sonnet-4-5-20250514": { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
|
|
24
|
+
"claude-sonnet-4-0-20250514": { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
|
|
25
|
+
|
|
26
|
+
// Haiku 4.5 ($1/$5)
|
|
27
|
+
"claude-haiku-4-5-20251001": { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 },
|
|
28
|
+
|
|
29
|
+
// Legacy
|
|
30
|
+
"claude-haiku-3-5-20241022": { input: 0.8, output: 4, cacheWrite: 1, cacheRead: 0.08 },
|
|
31
|
+
"claude-3-5-sonnet-20241022": { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
|
|
32
|
+
"claude-3-5-sonnet-20240620": { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
|
|
33
|
+
"claude-3-5-haiku-20241022": { input: 0.8, output: 4, cacheWrite: 1, cacheRead: 0.08 },
|
|
34
|
+
"claude-3-opus-20240229": { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const FALLBACK_PRICING: ModelPricing = { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 };
|
|
38
|
+
|
|
39
|
+
let activePricing: Record<string, ModelPricing> = MODEL_PRICING;
|
|
40
|
+
|
|
41
|
+
/** Override or extend the pricing table. Merges with built-in defaults. */
|
|
42
|
+
export function setPricing(overrides: Record<string, ModelPricing>): void {
|
|
43
|
+
activePricing = { ...MODEL_PRICING, ...overrides };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Look up pricing for a model, falling back to Sonnet rates. */
|
|
47
|
+
export function getModelPricing(model?: string): ModelPricing {
|
|
48
|
+
if (!model) return FALLBACK_PRICING;
|
|
49
|
+
return activePricing[model] ?? FALLBACK_PRICING;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Estimate USD cost of a session based on token counts and model. */
|
|
53
|
+
export function estimateCost(session: SessionMeta): number {
|
|
54
|
+
const pricing = getModelPricing(session.model);
|
|
55
|
+
const m = 1_000_000;
|
|
56
|
+
return (
|
|
57
|
+
(session.totalInputTokens / m) * pricing.input +
|
|
58
|
+
(session.totalOutputTokens / m) * pricing.output +
|
|
59
|
+
(session.cacheCreationInputTokens / m) * pricing.cacheWrite +
|
|
60
|
+
(session.cacheReadInputTokens / m) * pricing.cacheRead
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { PrivacyConfig, PrivacyProfile } from "../types/privacy.js";
|
|
2
|
+
|
|
3
|
+
// Credential patterns: match common API key/token/secret formats
|
|
4
|
+
const CREDENTIAL_PATTERNS = [
|
|
5
|
+
// Generic: "key"/"token"/"secret"/"password" followed by a long alphanumeric string
|
|
6
|
+
/(?:key|token|secret|password|api_key|apikey|auth)["\s:=]+[A-Za-z0-9+/=_\-]{20,}/gi,
|
|
7
|
+
// Bearer tokens
|
|
8
|
+
/Bearer\s+[A-Za-z0-9+/=_\-\.]{20,}/g,
|
|
9
|
+
// AWS-style keys
|
|
10
|
+
/(?:AKIA|ABIA|ACCA|ASIA)[A-Z0-9]{16}/g,
|
|
11
|
+
// GitHub tokens
|
|
12
|
+
/gh[pousr]_[A-Za-z0-9_]{36,}/g,
|
|
13
|
+
// Generic hex secrets (32+ chars)
|
|
14
|
+
/(?:secret|token|key)["\s:=]+[0-9a-f]{32,}/gi,
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const EMAIL_PATTERN = /[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g;
|
|
18
|
+
const IP_PATTERN = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g;
|
|
19
|
+
|
|
20
|
+
const LOCAL_PROFILE: PrivacyConfig = {
|
|
21
|
+
redactPrompts: false,
|
|
22
|
+
redactAbsolutePaths: false,
|
|
23
|
+
redactHomeDir: false,
|
|
24
|
+
stripThinking: true,
|
|
25
|
+
stripToolResults: true,
|
|
26
|
+
redactPatterns: [],
|
|
27
|
+
excludeProjects: [],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const SHAREABLE_PROFILE: PrivacyConfig = {
|
|
31
|
+
...LOCAL_PROFILE,
|
|
32
|
+
redactAbsolutePaths: true,
|
|
33
|
+
redactHomeDir: true,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const STRICT_PROFILE: PrivacyConfig = {
|
|
37
|
+
...SHAREABLE_PROFILE,
|
|
38
|
+
redactPrompts: true,
|
|
39
|
+
redactPatterns: [...CREDENTIAL_PATTERNS, EMAIL_PATTERN, IP_PATTERN],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const PRIVACY_PROFILES: Record<PrivacyProfile, PrivacyConfig> = {
|
|
43
|
+
local: LOCAL_PROFILE,
|
|
44
|
+
shareable: SHAREABLE_PROFILE,
|
|
45
|
+
strict: STRICT_PROFILE,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function resolvePrivacyConfig(
|
|
49
|
+
profile?: PrivacyProfile,
|
|
50
|
+
overrides?: Partial<PrivacyConfig>,
|
|
51
|
+
): PrivacyConfig {
|
|
52
|
+
const base = PRIVACY_PROFILES[profile ?? "local"];
|
|
53
|
+
if (!overrides) return { ...base };
|
|
54
|
+
return {
|
|
55
|
+
...base,
|
|
56
|
+
...overrides,
|
|
57
|
+
// Merge arrays rather than replace
|
|
58
|
+
redactPatterns: [
|
|
59
|
+
...base.redactPatterns,
|
|
60
|
+
...(overrides.redactPatterns ?? []),
|
|
61
|
+
],
|
|
62
|
+
excludeProjects: [
|
|
63
|
+
...base.excludeProjects,
|
|
64
|
+
...(overrides.excludeProjects ?? []),
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import type { PrivacyConfig } from "../types/privacy.js";
|
|
3
|
+
import type { ContentBlock, TranscriptEntry } from "../types/transcript.js";
|
|
4
|
+
|
|
5
|
+
const home = homedir();
|
|
6
|
+
|
|
7
|
+
/** Apply all configured redaction patterns to a string. */
|
|
8
|
+
export function redactString(text: string, config: PrivacyConfig): string {
|
|
9
|
+
let result = text;
|
|
10
|
+
|
|
11
|
+
if (config.redactHomeDir) {
|
|
12
|
+
result = result.replaceAll(home, "~");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (config.redactAbsolutePaths) {
|
|
16
|
+
// Replace absolute paths with just the last 2 segments
|
|
17
|
+
result = result.replace(/\/(?:Users|home)\/[^\s"',;)}\]]+/g, (match) => {
|
|
18
|
+
const parts = match.split("/");
|
|
19
|
+
return parts.length > 2 ? parts.slice(-2).join("/") : match;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (const pattern of config.redactPatterns) {
|
|
24
|
+
// Clone the regex to reset lastIndex for global patterns
|
|
25
|
+
const re = new RegExp(pattern.source, pattern.flags);
|
|
26
|
+
result = result.replace(re, "[REDACTED]");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Filter content blocks according to privacy config. */
|
|
33
|
+
function filterContentBlocks(
|
|
34
|
+
blocks: ContentBlock[],
|
|
35
|
+
config: PrivacyConfig,
|
|
36
|
+
): ContentBlock[] {
|
|
37
|
+
const filtered: ContentBlock[] = [];
|
|
38
|
+
|
|
39
|
+
for (const block of blocks) {
|
|
40
|
+
if (config.stripThinking && block.type === "thinking") continue;
|
|
41
|
+
if (config.stripToolResults && block.type === "tool_result") continue;
|
|
42
|
+
|
|
43
|
+
if (block.text && config.redactPatterns.length > 0) {
|
|
44
|
+
filtered.push({ ...block, text: redactString(block.text, config) });
|
|
45
|
+
} else {
|
|
46
|
+
filtered.push(block);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return filtered;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Filter a transcript entry according to privacy config. Returns null to skip entirely. */
|
|
54
|
+
export function filterTranscriptEntry(
|
|
55
|
+
entry: TranscriptEntry,
|
|
56
|
+
config: PrivacyConfig,
|
|
57
|
+
): TranscriptEntry | null {
|
|
58
|
+
// Skip toolUseResult entries
|
|
59
|
+
if (config.stripToolResults && entry.toolUseResult !== undefined) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!entry.message) return entry;
|
|
64
|
+
|
|
65
|
+
const { role, content } = entry.message;
|
|
66
|
+
|
|
67
|
+
// Redact user prompts
|
|
68
|
+
if (config.redactPrompts && role === "user") {
|
|
69
|
+
if (typeof content === "string") {
|
|
70
|
+
return {
|
|
71
|
+
...entry,
|
|
72
|
+
message: { ...entry.message, content: "[redacted]" },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Filter assistant content blocks
|
|
78
|
+
if (role === "assistant" && Array.isArray(content)) {
|
|
79
|
+
const filtered = filterContentBlocks(content as ContentBlock[], config);
|
|
80
|
+
return {
|
|
81
|
+
...entry,
|
|
82
|
+
message: { ...entry.message, content: filtered },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return entry;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Check if a project should be excluded. */
|
|
90
|
+
export function isProjectExcluded(
|
|
91
|
+
projectPath: string,
|
|
92
|
+
config: PrivacyConfig,
|
|
93
|
+
): boolean {
|
|
94
|
+
if (config.excludeProjects.length === 0) return false;
|
|
95
|
+
const lower = projectPath.toLowerCase();
|
|
96
|
+
return config.excludeProjects.some(
|
|
97
|
+
(p) => lower.includes(p.toLowerCase()),
|
|
98
|
+
);
|
|
99
|
+
}
|