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,171 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* match-git-commits.ts — Correlate git commits with Claude sessions by timestamp proximity.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* bun examples/match-git-commits.ts [--repo /path/to/repo] [--days 7] [--window 30] [--all-projects]
|
|
7
|
+
*
|
|
8
|
+
* For each recent git commit, finds which Claude sessions were active around
|
|
9
|
+
* that time and estimates the token cost of producing that commit.
|
|
10
|
+
* By default, only matches sessions for the same project. Use --all-projects to match all.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createClaudeHistory, estimateCost, projectName, type SessionMeta } from "../src/index.js";
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
|
|
16
|
+
const args = process.argv.slice(2);
|
|
17
|
+
function getArg(name: string, fallback: string): string {
|
|
18
|
+
const idx = args.indexOf(name);
|
|
19
|
+
return idx !== -1 ? (args[idx + 1] ?? fallback) : fallback;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const repoPath = resolve(getArg("--repo", process.cwd()));
|
|
23
|
+
const days = parseInt(getArg("--days", "7"));
|
|
24
|
+
const windowMinutes = parseInt(getArg("--window", "30"));
|
|
25
|
+
const allProjects = args.includes("--all-projects");
|
|
26
|
+
|
|
27
|
+
interface GitCommit {
|
|
28
|
+
hash: string;
|
|
29
|
+
author: string;
|
|
30
|
+
date: string;
|
|
31
|
+
timestamp: number;
|
|
32
|
+
message: string;
|
|
33
|
+
filesChanged: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function getGitCommits(): Promise<GitCommit[]> {
|
|
37
|
+
const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
|
|
38
|
+
const proc = Bun.spawn(
|
|
39
|
+
["git", "log", `--since=${since}`, "--format=%H\t%an\t%aI\t%at\t%s", "--shortstat"],
|
|
40
|
+
{ cwd: repoPath, stdout: "pipe", stderr: "pipe" },
|
|
41
|
+
);
|
|
42
|
+
const text = await new Response(proc.stdout).text();
|
|
43
|
+
await proc.exited;
|
|
44
|
+
|
|
45
|
+
const commits: GitCommit[] = [];
|
|
46
|
+
const lines = text.trim().split("\n");
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < lines.length; i++) {
|
|
49
|
+
const line = lines[i].trim();
|
|
50
|
+
if (!line || !line.includes("\t")) continue;
|
|
51
|
+
|
|
52
|
+
const parts = line.split("\t");
|
|
53
|
+
if (parts.length < 5) continue;
|
|
54
|
+
|
|
55
|
+
const [hash, author, date, timestamp, message] = parts;
|
|
56
|
+
|
|
57
|
+
let filesChanged = 0;
|
|
58
|
+
// --shortstat puts a blank line between format and stat lines
|
|
59
|
+
for (let j = 1; j <= 2; j++) {
|
|
60
|
+
const peek = lines[i + j]?.trim() ?? "";
|
|
61
|
+
const match = peek.match(/(\d+) files? changed/);
|
|
62
|
+
if (match) {
|
|
63
|
+
filesChanged = parseInt(match[1]);
|
|
64
|
+
i += j;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
commits.push({
|
|
70
|
+
hash: hash.slice(0, 8),
|
|
71
|
+
author,
|
|
72
|
+
date,
|
|
73
|
+
timestamp: parseInt(timestamp) * 1000,
|
|
74
|
+
message: message.slice(0, 60),
|
|
75
|
+
filesChanged,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return commits;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function findMatchingSessions(commit: GitCommit, sessions: SessionMeta[]): SessionMeta[] {
|
|
83
|
+
const windowMs = windowMinutes * 60 * 1000;
|
|
84
|
+
return sessions.filter((s) => {
|
|
85
|
+
return s.timeRange.start <= commit.timestamp + windowMs && s.timeRange.end >= commit.timestamp - windowMs;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function main() {
|
|
90
|
+
const commits = await getGitCommits();
|
|
91
|
+
if (commits.length === 0) {
|
|
92
|
+
console.log(`No git commits found in the last ${days} days in ${repoPath}`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const from = new Date(Date.now() - (days + 1) * 86400000).toISOString().slice(0, 10);
|
|
97
|
+
const ch = createClaudeHistory();
|
|
98
|
+
const repoName = projectName(repoPath);
|
|
99
|
+
|
|
100
|
+
let sessions: SessionMeta[];
|
|
101
|
+
const all = await ch.sessions.listWithMeta({ from });
|
|
102
|
+
if (allProjects) {
|
|
103
|
+
sessions = all;
|
|
104
|
+
} else {
|
|
105
|
+
sessions = all.filter((s) => {
|
|
106
|
+
const sp = s.project.toLowerCase();
|
|
107
|
+
const rp = repoPath.toLowerCase();
|
|
108
|
+
return sp === rp || sp.startsWith(rp + "/") || s.projectName?.toLowerCase() === repoName.toLowerCase();
|
|
109
|
+
});
|
|
110
|
+
if (sessions.length === 0) {
|
|
111
|
+
console.log(`No sessions found for project "${repoName}" (${repoPath}).`);
|
|
112
|
+
console.log(`Use --all-projects to match across all projects.\n`);
|
|
113
|
+
sessions = all; // Fall back to all
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const sessionCommitCount = new Map<string, number>();
|
|
118
|
+
for (const commit of commits) {
|
|
119
|
+
for (const s of findMatchingSessions(commit, sessions)) {
|
|
120
|
+
sessionCommitCount.set(s.sessionId, (sessionCommitCount.get(s.sessionId) ?? 0) + 1);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const scope = allProjects ? "all projects" : repoName;
|
|
125
|
+
console.log(`Git Commits → Claude Sessions (last ${days} days, ${windowMinutes}min window, ${scope})`);
|
|
126
|
+
console.log("=".repeat(100));
|
|
127
|
+
console.log(
|
|
128
|
+
"Commit".padEnd(10),
|
|
129
|
+
"Date".padEnd(12),
|
|
130
|
+
"Message".padEnd(40),
|
|
131
|
+
"Sessions".padStart(10),
|
|
132
|
+
"Est. Cost".padStart(12),
|
|
133
|
+
"Files".padStart(8),
|
|
134
|
+
);
|
|
135
|
+
console.log("-".repeat(100));
|
|
136
|
+
|
|
137
|
+
let totalCost = 0;
|
|
138
|
+
let matchedCommits = 0;
|
|
139
|
+
|
|
140
|
+
for (const commit of commits) {
|
|
141
|
+
const matched = findMatchingSessions(commit, sessions);
|
|
142
|
+
|
|
143
|
+
const cost = matched.reduce((sum, s) => {
|
|
144
|
+
const numCommits = sessionCommitCount.get(s.sessionId) ?? 1;
|
|
145
|
+
return sum + estimateCost(s) / numCommits;
|
|
146
|
+
}, 0);
|
|
147
|
+
|
|
148
|
+
const dateStr = new Date(commit.timestamp).toLocaleDateString("en-CA");
|
|
149
|
+
|
|
150
|
+
console.log(
|
|
151
|
+
commit.hash.padEnd(10),
|
|
152
|
+
dateStr.padEnd(12),
|
|
153
|
+
commit.message.slice(0, 39).padEnd(40),
|
|
154
|
+
(matched.length > 0 ? String(matched.length) : "-").padStart(10),
|
|
155
|
+
(matched.length > 0 ? `$${cost.toFixed(2)}` : "-").padStart(12),
|
|
156
|
+
String(commit.filesChanged || "-").padStart(8),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
if (matched.length > 0) {
|
|
160
|
+
totalCost += cost;
|
|
161
|
+
matchedCommits++;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
console.log("-".repeat(100));
|
|
166
|
+
console.log(
|
|
167
|
+
`\n${commits.length} commits, ${matchedCommits} matched to sessions, est. $${totalCost.toFixed(2)} total`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* model-costs.ts — Compare model usage and costs across your Claude sessions.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* bun examples/model-costs.ts [--from YYYY-MM-DD] [--to YYYY-MM-DD]
|
|
7
|
+
*
|
|
8
|
+
* Shows which models you use most, their token consumption, and estimated costs.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createClaudeHistory, estimateCost, getModelPricing, type SessionMeta } from "../src/index.js";
|
|
12
|
+
|
|
13
|
+
const args = process.argv.slice(2);
|
|
14
|
+
function getArg(name: string, fallback: string): string {
|
|
15
|
+
const idx = args.indexOf(name);
|
|
16
|
+
return idx !== -1 ? (args[idx + 1] ?? fallback) : fallback;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const from = getArg("--from", new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10));
|
|
20
|
+
const to = getArg("--to", new Date().toISOString().slice(0, 10));
|
|
21
|
+
|
|
22
|
+
function formatTokens(n: number): string {
|
|
23
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
24
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
25
|
+
return String(n);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function shortModel(model: string): string {
|
|
29
|
+
if (model.includes("opus")) return model.replace(/claude-/, "").slice(0, 20);
|
|
30
|
+
if (model.includes("sonnet")) return model.replace(/claude-/, "").slice(0, 20);
|
|
31
|
+
if (model.includes("haiku")) return model.replace(/claude-/, "").slice(0, 20);
|
|
32
|
+
return model.slice(0, 20);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ModelStats {
|
|
36
|
+
model: string;
|
|
37
|
+
sessions: number;
|
|
38
|
+
inputTokens: number;
|
|
39
|
+
outputTokens: number;
|
|
40
|
+
cacheWriteTokens: number;
|
|
41
|
+
cacheReadTokens: number;
|
|
42
|
+
cost: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function main() {
|
|
46
|
+
const ch = createClaudeHistory();
|
|
47
|
+
const sessions = await ch.sessions.listWithMeta({ from, to });
|
|
48
|
+
|
|
49
|
+
const byModel = new Map<string, SessionMeta[]>();
|
|
50
|
+
for (const s of sessions) {
|
|
51
|
+
const model = s.model ?? "unknown";
|
|
52
|
+
const list = byModel.get(model) ?? [];
|
|
53
|
+
list.push(s);
|
|
54
|
+
byModel.set(model, list);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const stats: ModelStats[] = [];
|
|
58
|
+
for (const [model, modelSessions] of byModel) {
|
|
59
|
+
stats.push({
|
|
60
|
+
model,
|
|
61
|
+
sessions: modelSessions.length,
|
|
62
|
+
inputTokens: modelSessions.reduce((s, x) => s + x.totalInputTokens, 0),
|
|
63
|
+
outputTokens: modelSessions.reduce((s, x) => s + x.totalOutputTokens, 0),
|
|
64
|
+
cacheWriteTokens: modelSessions.reduce((s, x) => s + x.cacheCreationInputTokens, 0),
|
|
65
|
+
cacheReadTokens: modelSessions.reduce((s, x) => s + x.cacheReadInputTokens, 0),
|
|
66
|
+
cost: modelSessions.reduce((s, x) => s + estimateCost(x), 0),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
stats.sort((a, b) => b.cost - a.cost);
|
|
71
|
+
|
|
72
|
+
const totalCost = stats.reduce((s, x) => s + x.cost, 0);
|
|
73
|
+
const totalSessions = sessions.length;
|
|
74
|
+
const totalInput = stats.reduce((s, x) => s + x.inputTokens, 0);
|
|
75
|
+
const totalOutput = stats.reduce((s, x) => s + x.outputTokens, 0);
|
|
76
|
+
|
|
77
|
+
console.log(`Model Usage & Costs: ${from} → ${to}`);
|
|
78
|
+
console.log("=".repeat(100));
|
|
79
|
+
console.log(
|
|
80
|
+
"Model".padEnd(24),
|
|
81
|
+
"Sessions".padStart(10),
|
|
82
|
+
"Input".padStart(10),
|
|
83
|
+
"Output".padStart(10),
|
|
84
|
+
"Cache W".padStart(10),
|
|
85
|
+
"Cache R".padStart(10),
|
|
86
|
+
"Est. Cost".padStart(12),
|
|
87
|
+
);
|
|
88
|
+
console.log("-".repeat(100));
|
|
89
|
+
|
|
90
|
+
for (const s of stats) {
|
|
91
|
+
const pricing = getModelPricing(s.model);
|
|
92
|
+
console.log(
|
|
93
|
+
shortModel(s.model).padEnd(24),
|
|
94
|
+
String(s.sessions).padStart(10),
|
|
95
|
+
formatTokens(s.inputTokens).padStart(10),
|
|
96
|
+
formatTokens(s.outputTokens).padStart(10),
|
|
97
|
+
formatTokens(s.cacheWriteTokens).padStart(10),
|
|
98
|
+
formatTokens(s.cacheReadTokens).padStart(10),
|
|
99
|
+
`$${s.cost.toFixed(2)}`.padStart(12),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log("-".repeat(100));
|
|
104
|
+
console.log(
|
|
105
|
+
"TOTAL".padEnd(24),
|
|
106
|
+
String(totalSessions).padStart(10),
|
|
107
|
+
formatTokens(totalInput).padStart(10),
|
|
108
|
+
formatTokens(totalOutput).padStart(10),
|
|
109
|
+
"".padStart(10),
|
|
110
|
+
"".padStart(10),
|
|
111
|
+
`$${totalCost.toFixed(2)}`.padStart(12),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
console.log(`\n--- Cost Breakdown ---`);
|
|
115
|
+
for (const s of stats) {
|
|
116
|
+
const pct = totalCost > 0 ? ((s.cost / totalCost) * 100).toFixed(0) : "0";
|
|
117
|
+
const bar = "█".repeat(Math.ceil((s.cost / (stats[0]?.cost || 1)) * 30));
|
|
118
|
+
console.log(` ${shortModel(s.model).padEnd(24)} ${bar} ${pct}% ($${s.cost.toFixed(2)})`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
console.log(`\n--- Per-Session Averages ---`);
|
|
122
|
+
for (const s of stats) {
|
|
123
|
+
const avgCost = s.cost / s.sessions;
|
|
124
|
+
const avgTokens = (s.inputTokens + s.outputTokens) / s.sessions;
|
|
125
|
+
console.log(
|
|
126
|
+
` ${shortModel(s.model).padEnd(24)} $${avgCost.toFixed(3)}/session ${formatTokens(avgTokens)} tokens/session`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* pipe-match.ts — Generic stdin matcher: pipe in any JSON array with timestamps,
|
|
4
|
+
* match against Claude sessions.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* cat data.json | bun examples/pipe-match.ts [--window 30] [--field timestamp]
|
|
8
|
+
* echo '[{"timestamp":"2026-02-10T14:00:00Z","title":"Sprint planning"}]' | bun examples/pipe-match.ts
|
|
9
|
+
*
|
|
10
|
+
* Works with any JSON that has timestamps — PRs, issues, deploys, calendar events, etc.
|
|
11
|
+
*
|
|
12
|
+
* Examples with common CLIs:
|
|
13
|
+
* gh pr list --json createdAt,title | bun examples/pipe-match.ts --field createdAt
|
|
14
|
+
* gh issue list --json createdAt,title | bun examples/pipe-match.ts --field createdAt
|
|
15
|
+
* az deployment group list --query "[].{timestamp:timestamp,name:name}" -o json | bun examples/pipe-match.ts
|
|
16
|
+
*
|
|
17
|
+
* Expected input: JSON array of objects with a timestamp field.
|
|
18
|
+
* Supported timestamp formats:
|
|
19
|
+
* - ISO 8601 string: "2026-02-10T14:00:00Z"
|
|
20
|
+
* - Unix ms: 1707580800000
|
|
21
|
+
* - Unix seconds: 1707580800
|
|
22
|
+
*
|
|
23
|
+
* The script auto-detects common timestamp field names:
|
|
24
|
+
* timestamp, date, created, createdAt, created_at, time, start, startDate, closedDate, etc.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { createClaudeHistory, estimateCost, type SessionMeta } from "../src/index.js";
|
|
28
|
+
|
|
29
|
+
const args = process.argv.slice(2);
|
|
30
|
+
function getArg(name: string, fallback: string): string {
|
|
31
|
+
const idx = args.indexOf(name);
|
|
32
|
+
return idx !== -1 ? (args[idx + 1] ?? fallback) : fallback;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const windowMinutes = parseInt(getArg("--window", "30"));
|
|
36
|
+
const explicitField = getArg("--field", "");
|
|
37
|
+
|
|
38
|
+
const TIMESTAMP_FIELDS = [
|
|
39
|
+
"timestamp", "date", "time", "created", "createdAt", "created_at",
|
|
40
|
+
"updated", "updatedAt", "updated_at", "start", "end", "startDate",
|
|
41
|
+
"endDate", "closedDate", "closed_at", "completedDate", "completed_at",
|
|
42
|
+
"changedDate", "resolvedDate", "activatedDate",
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
function findTimestampField(obj: Record<string, unknown>): string | null {
|
|
46
|
+
if (explicitField && explicitField in obj) return explicitField;
|
|
47
|
+
for (const field of TIMESTAMP_FIELDS) {
|
|
48
|
+
if (field in obj) return field;
|
|
49
|
+
}
|
|
50
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
51
|
+
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) return key;
|
|
52
|
+
if (typeof value === "number" && value > 1_000_000_000) return key;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseTimestamp(value: unknown): number | null {
|
|
58
|
+
if (typeof value === "number") {
|
|
59
|
+
return value < 1e12 ? value * 1000 : value;
|
|
60
|
+
}
|
|
61
|
+
if (typeof value === "string") {
|
|
62
|
+
const ms = new Date(value).getTime();
|
|
63
|
+
return isNaN(ms) ? null : ms;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getDisplayTitle(obj: Record<string, unknown>): string {
|
|
69
|
+
for (const field of ["title", "name", "subject", "summary", "description", "message", "display"]) {
|
|
70
|
+
if (typeof obj[field] === "string") return (obj[field] as string).slice(0, 60);
|
|
71
|
+
}
|
|
72
|
+
for (const value of Object.values(obj)) {
|
|
73
|
+
if (typeof value === "string" && value.length > 5 && value.length < 200) return value.slice(0, 60);
|
|
74
|
+
}
|
|
75
|
+
return JSON.stringify(obj).slice(0, 60);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function main() {
|
|
79
|
+
const stdin = await Bun.stdin.text();
|
|
80
|
+
const trimmed = stdin.trim();
|
|
81
|
+
|
|
82
|
+
if (!trimmed || trimmed === "[]") {
|
|
83
|
+
console.log("No input data. Pipe in a JSON array with timestamped objects.");
|
|
84
|
+
console.log("\nUsage:");
|
|
85
|
+
console.log(" cat events.json | bun examples/pipe-match.ts");
|
|
86
|
+
console.log(" echo '[{\"timestamp\":\"2026-02-10T14:00:00Z\",\"title\":\"Deploy\"}]' | bun examples/pipe-match.ts");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let items: Record<string, unknown>[];
|
|
91
|
+
try {
|
|
92
|
+
items = JSON.parse(trimmed);
|
|
93
|
+
if (!Array.isArray(items)) {
|
|
94
|
+
items = [items]; // Single object → wrap in array
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
console.error("Error: Input is not valid JSON. Expected a JSON array.");
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (items.length === 0) {
|
|
102
|
+
console.log("Empty array — nothing to match.");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const tsField = findTimestampField(items[0] as Record<string, unknown>);
|
|
107
|
+
if (!tsField) {
|
|
108
|
+
console.error("Error: Could not find a timestamp field in the input objects.");
|
|
109
|
+
console.error("Available fields:", Object.keys(items[0]).join(", "));
|
|
110
|
+
console.error("Use --field <name> to specify which field contains timestamps.");
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log(`Using timestamp field: "${tsField}" (${windowMinutes}min matching window)\n`);
|
|
115
|
+
|
|
116
|
+
const parsed = items
|
|
117
|
+
.map((item) => ({
|
|
118
|
+
item: item as Record<string, unknown>,
|
|
119
|
+
timestamp: parseTimestamp((item as Record<string, unknown>)[tsField]),
|
|
120
|
+
title: getDisplayTitle(item as Record<string, unknown>),
|
|
121
|
+
}))
|
|
122
|
+
.filter((p) => p.timestamp !== null);
|
|
123
|
+
|
|
124
|
+
if (parsed.length === 0) {
|
|
125
|
+
console.error("Error: No valid timestamps found in the input data.");
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const minTs = Math.min(...parsed.map((p) => p.timestamp!));
|
|
130
|
+
const maxTs = Math.max(...parsed.map((p) => p.timestamp!));
|
|
131
|
+
const from = new Date(minTs - 86400000).toISOString().slice(0, 10);
|
|
132
|
+
const to = new Date(maxTs + 86400000).toISOString().slice(0, 10);
|
|
133
|
+
|
|
134
|
+
const ch = createClaudeHistory();
|
|
135
|
+
const sessions = await ch.sessions.listWithMeta({ from, to });
|
|
136
|
+
|
|
137
|
+
const windowMs = windowMinutes * 60 * 1000;
|
|
138
|
+
|
|
139
|
+
console.log("Matches");
|
|
140
|
+
console.log("=".repeat(100));
|
|
141
|
+
console.log(
|
|
142
|
+
"Item".padEnd(40),
|
|
143
|
+
"Date".padEnd(12),
|
|
144
|
+
"Sessions".padStart(10),
|
|
145
|
+
"Project".padEnd(25),
|
|
146
|
+
"Cost".padStart(10),
|
|
147
|
+
);
|
|
148
|
+
console.log("-".repeat(100));
|
|
149
|
+
|
|
150
|
+
let totalMatched = 0;
|
|
151
|
+
|
|
152
|
+
for (const { title, timestamp } of parsed) {
|
|
153
|
+
const ts = timestamp!;
|
|
154
|
+
const matched = sessions.filter(
|
|
155
|
+
(s) => s.timeRange.start <= ts + windowMs && s.timeRange.end >= ts - windowMs,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const date = new Date(ts).toLocaleDateString("en-CA");
|
|
159
|
+
const cost = matched.reduce((s, x) => s + estimateCost(x), 0);
|
|
160
|
+
const project = matched[0]?.projectName ?? "-";
|
|
161
|
+
|
|
162
|
+
console.log(
|
|
163
|
+
title.slice(0, 39).padEnd(40),
|
|
164
|
+
date.padEnd(12),
|
|
165
|
+
(matched.length > 0 ? String(matched.length) : "-").padStart(10),
|
|
166
|
+
project.slice(0, 24).padEnd(25),
|
|
167
|
+
(matched.length > 0 ? `$${cost.toFixed(2)}` : "-").padStart(10),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
if (matched.length > 0) totalMatched++;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
console.log("-".repeat(100));
|
|
174
|
+
console.log(`\n${parsed.length} items, ${totalMatched} matched to sessions`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* prompt-history.ts — Export sampled prompts grouped by project as JSON.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* bun examples/prompt-history.ts [--from YYYY-MM-DD] [--to YYYY-MM-DD]
|
|
7
|
+
*
|
|
8
|
+
* Groups by project, deduplicates, samples proportionally, and truncates
|
|
9
|
+
* so the output stays <10KB — small enough to pipe to `claude` or any LLM.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createClaudeHistory, toLocalDate } from "../src/index.js";
|
|
13
|
+
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
function getArg(name: string, fallback: string): string {
|
|
16
|
+
const idx = args.indexOf(name);
|
|
17
|
+
return idx !== -1 ? (args[idx + 1] ?? fallback) : fallback;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const from = getArg("--from", toLocalDate(Date.now() - 30 * 86400000));
|
|
21
|
+
const to = getArg("--to", toLocalDate(Date.now()));
|
|
22
|
+
|
|
23
|
+
const MAX_PROJECTS = 15;
|
|
24
|
+
const MAX_TOTAL_SAMPLES = 80;
|
|
25
|
+
const MAX_PROMPT_LENGTH = 120;
|
|
26
|
+
|
|
27
|
+
function clean(text: string): string {
|
|
28
|
+
return text
|
|
29
|
+
.replace(/\[Pasted text[^\]]*\]/g, "[paste]")
|
|
30
|
+
.replace(/\s+/g, " ")
|
|
31
|
+
.trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function truncate(text: string, max: number): string {
|
|
35
|
+
if (text.length <= max) return text;
|
|
36
|
+
return text.slice(0, max) + "...";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function dedupeKey(text: string): string {
|
|
40
|
+
return text.toLowerCase().replace(/\s+/g, " ").trim().slice(0, 120);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function main() {
|
|
44
|
+
const ch = createClaudeHistory();
|
|
45
|
+
const sessions = await ch.sessions.list({ from, to });
|
|
46
|
+
|
|
47
|
+
// Group prompts by project
|
|
48
|
+
const byProject = new Map<string, { sessionIds: Set<string>; prompts: string[] }>();
|
|
49
|
+
let totalPrompts = 0;
|
|
50
|
+
|
|
51
|
+
for (const s of sessions) {
|
|
52
|
+
let entry = byProject.get(s.projectName);
|
|
53
|
+
if (!entry) {
|
|
54
|
+
entry = { sessionIds: new Set(), prompts: [] };
|
|
55
|
+
byProject.set(s.projectName, entry);
|
|
56
|
+
}
|
|
57
|
+
entry.sessionIds.add(s.sessionId);
|
|
58
|
+
for (const prompt of s.prompts) {
|
|
59
|
+
const cleaned = clean(prompt);
|
|
60
|
+
if (cleaned.length === 0) continue;
|
|
61
|
+
entry.prompts.push(cleaned);
|
|
62
|
+
totalPrompts++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Deduplicate and sample per project (top N projects by prompt count)
|
|
67
|
+
const topProjects = [...byProject.entries()]
|
|
68
|
+
.sort((a, b) => b[1].prompts.length - a[1].prompts.length)
|
|
69
|
+
.slice(0, MAX_PROJECTS);
|
|
70
|
+
const numProjects = topProjects.length;
|
|
71
|
+
const perProjectCap = Math.max(2, Math.floor(MAX_TOTAL_SAMPLES / (numProjects || 1)));
|
|
72
|
+
|
|
73
|
+
const projectEntries = topProjects.map(([project, data]) => {
|
|
74
|
+
// Deduplicate
|
|
75
|
+
const seen = new Set<string>();
|
|
76
|
+
const unique: string[] = [];
|
|
77
|
+
for (const p of data.prompts) {
|
|
78
|
+
const key = dedupeKey(p);
|
|
79
|
+
if (!seen.has(key)) {
|
|
80
|
+
seen.add(key);
|
|
81
|
+
unique.push(p);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Sample evenly if over cap
|
|
86
|
+
let sampled: string[];
|
|
87
|
+
if (unique.length <= perProjectCap) {
|
|
88
|
+
sampled = unique;
|
|
89
|
+
} else {
|
|
90
|
+
const step = unique.length / perProjectCap;
|
|
91
|
+
sampled = [];
|
|
92
|
+
for (let i = 0; i < perProjectCap; i++) {
|
|
93
|
+
sampled.push(unique[Math.floor(i * step)]);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
project,
|
|
99
|
+
sessionCount: data.sessionIds.size,
|
|
100
|
+
promptCount: data.prompts.length,
|
|
101
|
+
samples: sampled.map((p) => truncate(p, MAX_PROMPT_LENGTH)),
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
console.log(
|
|
106
|
+
JSON.stringify(
|
|
107
|
+
{
|
|
108
|
+
period: { from, to },
|
|
109
|
+
totalSessions: sessions.length,
|
|
110
|
+
totalPrompts,
|
|
111
|
+
byProject: projectEntries,
|
|
112
|
+
},
|
|
113
|
+
null,
|
|
114
|
+
2,
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* session-digest.ts — Export compact session summaries as JSON.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* bun examples/session-digest.ts [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--days 7] [--top N]
|
|
7
|
+
*
|
|
8
|
+
* Uses listWithMeta() only (no detail() calls) for speed.
|
|
9
|
+
* Filters out zero-prompt sessions, sorts by most recent first.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createClaudeHistory, estimateCost, toLocalDate } from "../src/index.js";
|
|
13
|
+
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
function getArg(name: string, fallback: string): string {
|
|
16
|
+
const idx = args.indexOf(name);
|
|
17
|
+
return idx !== -1 ? (args[idx + 1] ?? fallback) : fallback;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MAX_PROMPT_LENGTH = 150;
|
|
21
|
+
const DEFAULT_TOP = 35;
|
|
22
|
+
|
|
23
|
+
const days = parseInt(getArg("--days", "7"));
|
|
24
|
+
const from = getArg("--from", toLocalDate(Date.now() - days * 86400000));
|
|
25
|
+
const to = getArg("--to", toLocalDate(Date.now()));
|
|
26
|
+
const topN = parseInt(getArg("--top", String(DEFAULT_TOP)));
|
|
27
|
+
|
|
28
|
+
function clean(text: string): string {
|
|
29
|
+
return text
|
|
30
|
+
.replace(/\[Pasted text[^\]]*\]/g, "[paste]")
|
|
31
|
+
.replace(/\s+/g, " ")
|
|
32
|
+
.trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function truncate(text: string, max: number): string {
|
|
36
|
+
if (text.length <= max) return text;
|
|
37
|
+
return text.slice(0, max) + "...";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function firstPrompt(prompts: string[]): string | null {
|
|
41
|
+
for (const p of prompts) {
|
|
42
|
+
const cleaned = clean(p);
|
|
43
|
+
if (cleaned.length > 0) return truncate(cleaned, MAX_PROMPT_LENGTH);
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function main() {
|
|
49
|
+
const ch = createClaudeHistory();
|
|
50
|
+
const sessions = await ch.sessions.listWithMeta({ from, to });
|
|
51
|
+
|
|
52
|
+
const filtered = sessions
|
|
53
|
+
.filter((s) => s.prompts.length > 0)
|
|
54
|
+
.sort((a, b) => b.timeRange.start - a.timeRange.start)
|
|
55
|
+
.slice(0, topN);
|
|
56
|
+
|
|
57
|
+
const digests = filtered.map((s) => ({
|
|
58
|
+
sessionId: s.sessionId,
|
|
59
|
+
project: s.projectName,
|
|
60
|
+
date: toLocalDate(s.timeRange.start),
|
|
61
|
+
branch: s.gitBranch ?? null,
|
|
62
|
+
model: s.model ?? null,
|
|
63
|
+
promptCount: s.prompts.length,
|
|
64
|
+
firstPrompt: firstPrompt(s.prompts),
|
|
65
|
+
messageCount: s.messageCount,
|
|
66
|
+
durationMinutes: Math.round((s.timeRange.end - s.timeRange.start) / 60000),
|
|
67
|
+
estimatedCostUsd: +estimateCost(s).toFixed(4),
|
|
68
|
+
tokens: {
|
|
69
|
+
input: s.totalInputTokens,
|
|
70
|
+
output: s.totalOutputTokens,
|
|
71
|
+
cacheWrite: s.cacheCreationInputTokens,
|
|
72
|
+
cacheRead: s.cacheReadInputTokens,
|
|
73
|
+
},
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
console.log(
|
|
77
|
+
JSON.stringify(
|
|
78
|
+
{
|
|
79
|
+
period: { from, to },
|
|
80
|
+
totalSessions: sessions.length,
|
|
81
|
+
sessions: digests,
|
|
82
|
+
},
|
|
83
|
+
null,
|
|
84
|
+
2,
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
main().catch(console.error);
|