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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +337 -0
  3. package/examples/commit-tracker.ts +389 -0
  4. package/examples/cost-per-feature.ts +182 -0
  5. package/examples/match-git-commits.ts +171 -0
  6. package/examples/model-costs.ts +131 -0
  7. package/examples/pipe-match.ts +177 -0
  8. package/examples/prompt-history.ts +119 -0
  9. package/examples/session-digest.ts +89 -0
  10. package/examples/timesheet.ts +127 -0
  11. package/examples/work-patterns.ts +124 -0
  12. package/package.json +41 -0
  13. package/src/agent-optic.ts +325 -0
  14. package/src/aggregations/daily.ts +90 -0
  15. package/src/aggregations/project.ts +71 -0
  16. package/src/aggregations/time.ts +44 -0
  17. package/src/aggregations/tools.ts +60 -0
  18. package/src/claude-optic.ts +7 -0
  19. package/src/cli/index.ts +407 -0
  20. package/src/index.ts +69 -0
  21. package/src/parsers/content-blocks.ts +58 -0
  22. package/src/parsers/session-detail.ts +323 -0
  23. package/src/parsers/tool-categories.ts +86 -0
  24. package/src/pricing.ts +62 -0
  25. package/src/privacy/config.ts +67 -0
  26. package/src/privacy/redact.ts +99 -0
  27. package/src/readers/codex-rollout-reader.ts +145 -0
  28. package/src/readers/history-reader.ts +205 -0
  29. package/src/readers/plan-reader.ts +60 -0
  30. package/src/readers/project-reader.ts +101 -0
  31. package/src/readers/session-reader.ts +280 -0
  32. package/src/readers/skill-reader.ts +28 -0
  33. package/src/readers/stats-reader.ts +12 -0
  34. package/src/readers/task-reader.ts +117 -0
  35. package/src/types/aggregations.ts +47 -0
  36. package/src/types/plan.ts +6 -0
  37. package/src/types/privacy.ts +18 -0
  38. package/src/types/project.ts +13 -0
  39. package/src/types/provider.ts +9 -0
  40. package/src/types/session.ts +56 -0
  41. package/src/types/stats.ts +15 -0
  42. package/src/types/task.ts +16 -0
  43. package/src/types/transcript.ts +36 -0
  44. package/src/utils/dates.ts +40 -0
  45. package/src/utils/jsonl.ts +83 -0
  46. package/src/utils/paths.ts +57 -0
  47. 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
+ }