fapony 0.1.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 +473 -0
- package/fapony.ts +78 -0
- package/package.json +42 -0
- package/skill/git-commit-conventional/SKILL.md +68 -0
- package/skill/git-ship/SKILL.md +144 -0
- package/skill/move-to-done/SKILL.md +126 -0
- package/skill/plan-with-pony/SKILL.md +263 -0
- package/skill/review-pony/SKILL.md +254 -0
- package/src/analyze.ts +517 -0
- package/src/context/index.ts +11 -0
- package/src/context/projectHealth.ts +359 -0
- package/src/conventions-seed.ts +420 -0
- package/src/db/defaults.ts +26 -0
- package/src/db/getters.ts +33 -0
- package/src/db/index.ts +7 -0
- package/src/db/load.ts +57 -0
- package/src/db/store.ts +286 -0
- package/src/db/types.ts +79 -0
- package/src/debt.ts +667 -0
- package/src/digest/cli.ts +75 -0
- package/src/digest/collect.ts +625 -0
- package/src/digest/html.ts +208 -0
- package/src/digest/text.ts +191 -0
- package/src/gate.ts +153 -0
- package/src/gates.ts +194 -0
- package/src/hook.ts +436 -0
- package/src/init-mem.ts +71 -0
- package/src/init.ts +237 -0
- package/src/install/claude.ts +361 -0
- package/src/install/codex.ts +61 -0
- package/src/install/cursor.ts +167 -0
- package/src/install/detect.ts +78 -0
- package/src/install/opencode.ts +234 -0
- package/src/install/skills.ts +106 -0
- package/src/install/types.ts +69 -0
- package/src/install/utils.ts +29 -0
- package/src/install/zcode.ts +120 -0
- package/src/install.ts +176 -0
- package/src/lint-baseline.ts +260 -0
- package/src/map.ts +320 -0
- package/src/math.ts +13 -0
- package/src/mcp/evidence.ts +332 -0
- package/src/mcp/primitives.ts +316 -0
- package/src/mcp/tools/check.ts +243 -0
- package/src/mcp/tools/collect.ts +157 -0
- package/src/mcp/tools/context.ts +66 -0
- package/src/mcp/tools/index.ts +309 -0
- package/src/mcp/tools/mem.ts +95 -0
- package/src/mcp/tools/plans.ts +255 -0
- package/src/mcp/tools/report.ts +285 -0
- package/src/mcp/tools/stats.ts +96 -0
- package/src/mcp/tools/usage.ts +211 -0
- package/src/mcp/tools/verdict.ts +148 -0
- package/src/mcp/transport.ts +241 -0
- package/src/mcp/types.ts +54 -0
- package/src/mcp/worktree.ts +27 -0
- package/src/memory.ts +264 -0
- package/src/parse.ts +71 -0
- package/src/plan-seed.ts +599 -0
- package/src/price/fetch.ts +146 -0
- package/src/price/index.ts +8 -0
- package/src/price/resolve.ts +213 -0
- package/src/report/cli.ts +92 -0
- package/src/report/format.ts +37 -0
- package/src/report/index.ts +4 -0
- package/src/report/render.ts +206 -0
- package/src/review-seed.ts +932 -0
- package/src/safety.ts +18 -0
- package/src/session/activeSession.ts +153 -0
- package/src/session/claude-code.ts +412 -0
- package/src/session/codex.ts +347 -0
- package/src/session/findModel.ts +376 -0
- package/src/session/helpers.ts +640 -0
- package/src/session/index.ts +31 -0
- package/src/session/opencode.ts +167 -0
- package/src/session/registry.ts +45 -0
- package/src/session/types.ts +128 -0
- package/src/session/zcode.ts +151 -0
- package/src/setup.ts +242 -0
- package/src/stats/cli.ts +44 -0
- package/src/stats/data.ts +1019 -0
- package/src/stats/format.ts +584 -0
- package/src/stats/index.ts +19 -0
- package/src/telemetry.ts +364 -0
- package/src/test.ts +2 -0
- package/src/update.ts +212 -0
- package/src/usage/cache.ts +125 -0
- package/src/usage/cli.ts +120 -0
- package/src/usage/format.ts +29 -0
- package/src/usage/index.ts +4 -0
- package/src/usage/render.ts +523 -0
- package/src/usage/scan.ts +161 -0
- package/src/util.ts +32 -0
- package/src/web/html.ts +33 -0
- package/templates/PLAN.md +90 -0
- package/templates/SPEC.md +30 -0
- package/templates/mem/commands/plan.ts +360 -0
- package/templates/mem/commands/read.ts +194 -0
- package/templates/mem/commands/rotate.ts +59 -0
- package/templates/mem/commands/selftest.ts +450 -0
- package/templates/mem/commands/write.ts +214 -0
- package/templates/mem/mem.ts +68 -0
- package/templates/mem/render.ts +63 -0
- package/templates/mem/selectors.ts +144 -0
- package/templates/mem/store.ts +285 -0
package/src/safety.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// src/safety.ts — deny-list for dangerous git commands.
|
|
2
|
+
// Rule: assertSafe() must be called on every command before spawn, including
|
|
3
|
+
// ones built from config templates (memory claim/close/add, evidence
|
|
4
|
+
// collector commands, install's `claude mcp add`).
|
|
5
|
+
// Patterns come from config.safety.deny (regex sources); built-in default
|
|
6
|
+
// covers the 4 known-destructive git invocations.
|
|
7
|
+
|
|
8
|
+
import { DEFAULT_SAFETY_DENY } from "./db/index.js";
|
|
9
|
+
|
|
10
|
+
export function assertSafe(argv: string[], denySources?: string[]): void {
|
|
11
|
+
const sources = denySources ?? DEFAULT_SAFETY_DENY;
|
|
12
|
+
const joined = argv.join(" ");
|
|
13
|
+
for (const src of sources) {
|
|
14
|
+
if (new RegExp(src).test(joined)) {
|
|
15
|
+
throw new Error(`refusing to run dangerous command: ${joined}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// src/session/activeSession.ts — infer which client session was live at a
|
|
2
|
+
// point in time, so a verdict submitted without session_id still gets model
|
|
3
|
+
// attribution.
|
|
4
|
+
//
|
|
5
|
+
// Why inference instead of asking for session_id: an optional field nobody
|
|
6
|
+
// fills collects nothing (17 gate events on this machine, 2 with a session
|
|
7
|
+
// id). Every client already records, per session, the directory it ran in
|
|
8
|
+
// and when it was active — that is enough to answer "who was working in this
|
|
9
|
+
// worktree at 14:32" without asking the caller for anything.
|
|
10
|
+
//
|
|
11
|
+
// Read-time only: nothing here is written to the db, so it attributes
|
|
12
|
+
// history retroactively and stays consistent with how session_id-based
|
|
13
|
+
// attribution already resolves (gates.ts). The cost is the same too — when
|
|
14
|
+
// a client prunes its session log, attribution for those rows goes with it.
|
|
15
|
+
//
|
|
16
|
+
// Codex is deliberately absent: its logs are nested per-date with cwd inside
|
|
17
|
+
// the payload, so spans need a directory walk, not a query. Add it when
|
|
18
|
+
// Codex is actually in the rotation.
|
|
19
|
+
|
|
20
|
+
import { Database } from "bun:sqlite";
|
|
21
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import type { SessionClient } from "./findModel.js";
|
|
25
|
+
|
|
26
|
+
export interface SessionSpan {
|
|
27
|
+
sessionId: string;
|
|
28
|
+
client: SessionClient;
|
|
29
|
+
/** Directory the client ran in, as the client recorded it. */
|
|
30
|
+
worktree: string;
|
|
31
|
+
startMs: number;
|
|
32
|
+
endMs: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sqliteSpans(dbPath: string, client: SessionClient): SessionSpan[] {
|
|
36
|
+
if (!existsSync(dbPath)) return [];
|
|
37
|
+
let db: Database | null = null;
|
|
38
|
+
try {
|
|
39
|
+
db = new Database(dbPath, { readonly: true });
|
|
40
|
+
db.run("PRAGMA query_only = ON");
|
|
41
|
+
const rows = db
|
|
42
|
+
.prepare(
|
|
43
|
+
`SELECT id, directory, time_created, time_updated
|
|
44
|
+
FROM session
|
|
45
|
+
WHERE directory IS NOT NULL AND directory != ''`,
|
|
46
|
+
)
|
|
47
|
+
.all() as {
|
|
48
|
+
id: string;
|
|
49
|
+
directory: string;
|
|
50
|
+
time_created: number;
|
|
51
|
+
time_updated: number;
|
|
52
|
+
}[];
|
|
53
|
+
return rows.map((r) => ({
|
|
54
|
+
sessionId: r.id,
|
|
55
|
+
client,
|
|
56
|
+
worktree: r.directory,
|
|
57
|
+
startMs: r.time_created,
|
|
58
|
+
endMs: Math.max(r.time_updated, r.time_created),
|
|
59
|
+
}));
|
|
60
|
+
} catch {
|
|
61
|
+
return []; // schema drift or a locked db is "no signal", never an error
|
|
62
|
+
} finally {
|
|
63
|
+
db?.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Claude Code's per-project dir name: every non-alphanumeric byte becomes '-'. */
|
|
68
|
+
export function claudeProjectSlug(worktree: string): string {
|
|
69
|
+
return worktree.replace(/[^a-zA-Z0-9]/g, "-");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function claudeCodeSpans(worktree: string): SessionSpan[] {
|
|
73
|
+
const dir = join(
|
|
74
|
+
homedir(),
|
|
75
|
+
".claude",
|
|
76
|
+
"projects",
|
|
77
|
+
claudeProjectSlug(worktree),
|
|
78
|
+
);
|
|
79
|
+
if (!existsSync(dir)) return [];
|
|
80
|
+
let names: string[];
|
|
81
|
+
try {
|
|
82
|
+
names = readdirSync(dir).filter((n) => n.endsWith(".jsonl"));
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
const out: SessionSpan[] = [];
|
|
87
|
+
for (const name of names) {
|
|
88
|
+
const path = join(dir, name);
|
|
89
|
+
try {
|
|
90
|
+
// Append-only transcripts: birthtime→mtime is the session's live span.
|
|
91
|
+
const st = statSync(path);
|
|
92
|
+
out.push({
|
|
93
|
+
sessionId: path, // Claude Code's session id IS the file path
|
|
94
|
+
client: "claude-code",
|
|
95
|
+
worktree,
|
|
96
|
+
startMs: st.birthtimeMs || st.mtimeMs,
|
|
97
|
+
endMs: st.mtimeMs,
|
|
98
|
+
});
|
|
99
|
+
} catch {
|
|
100
|
+
// vanished mid-scan — skip
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Every session span that ran in `worktree`, across clients.
|
|
108
|
+
* Scoped by worktree on purpose: Claude Code stores one directory per
|
|
109
|
+
* project, so scanning globally would mean reading every project's folder.
|
|
110
|
+
*/
|
|
111
|
+
export function loadSessionSpans(worktree: string): SessionSpan[] {
|
|
112
|
+
const openCodeDb =
|
|
113
|
+
process.env.FAPONY_OPENCODE_DB ??
|
|
114
|
+
join(homedir(), ".local", "share", "opencode", "opencode.db");
|
|
115
|
+
const zcodeDb =
|
|
116
|
+
process.env.FAPONY_ZCODE_DB ??
|
|
117
|
+
join(homedir(), ".zcode", "cli", "db", "db.sqlite");
|
|
118
|
+
return [
|
|
119
|
+
...sqliteSpans(openCodeDb, "opencode").filter(
|
|
120
|
+
(s) => s.worktree === worktree,
|
|
121
|
+
),
|
|
122
|
+
...sqliteSpans(zcodeDb, "zcode").filter((s) => s.worktree === worktree),
|
|
123
|
+
...claudeCodeSpans(worktree),
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The session that was live at `atMs`, or null when none was.
|
|
129
|
+
*
|
|
130
|
+
* Containment, not recency: a span must actually cover the timestamp. When
|
|
131
|
+
* several do (two clients open in the same worktree), the narrowest wins —
|
|
132
|
+
* the more specific claim on that moment. This is a guess either way, so
|
|
133
|
+
* callers must label it inferred and never treat it as the caller's own
|
|
134
|
+
* declared session_id.
|
|
135
|
+
*/
|
|
136
|
+
export function findSessionAt(
|
|
137
|
+
spans: SessionSpan[],
|
|
138
|
+
atMs: number,
|
|
139
|
+
graceMs = 120_000,
|
|
140
|
+
): SessionSpan | null {
|
|
141
|
+
let best: SessionSpan | null = null;
|
|
142
|
+
let bestWidth = Number.POSITIVE_INFINITY;
|
|
143
|
+
for (const s of spans) {
|
|
144
|
+
// grace on the end: a verdict lands a beat before the transcript flushes.
|
|
145
|
+
if (atMs < s.startMs || atMs > s.endMs + graceMs) continue;
|
|
146
|
+
const width = s.endMs - s.startMs;
|
|
147
|
+
if (width < bestWidth) {
|
|
148
|
+
best = s;
|
|
149
|
+
bestWidth = width;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return best;
|
|
153
|
+
}
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
// src/session/claude-code.ts — Claude Code passive usage reader
|
|
2
|
+
//
|
|
3
|
+
// Reads ~/.claude/projects/<encoded-cwd>/*.jsonl (one file per session).
|
|
4
|
+
// Each line is a JSON object; usage data lives in message.usage on
|
|
5
|
+
// assistant-response lines. No cost field — total_cost is always 0.
|
|
6
|
+
//
|
|
7
|
+
// No detail (tool/step breakdown) in v1 — JSONL has no part-table equivalent.
|
|
8
|
+
|
|
9
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { parseTimeMs, summarizeTiming, type TimingInput } from "./helpers.js";
|
|
13
|
+
import {
|
|
14
|
+
EMPTY_RESULT,
|
|
15
|
+
type ModelBreakdown,
|
|
16
|
+
type PassiveUsageResult,
|
|
17
|
+
type SessionDetail,
|
|
18
|
+
type UsageDetail,
|
|
19
|
+
} from "./types.js";
|
|
20
|
+
|
|
21
|
+
const PROJECTS_DIR = join(homedir(), ".claude", "projects");
|
|
22
|
+
|
|
23
|
+
function resolveProjectsDir(optDir?: string): string {
|
|
24
|
+
return optDir ?? process.env.FAPONY_CLAUDE_PROJECTS_DIR ?? PROJECTS_DIR;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Encode a file path the same way Claude Code does: `/` → `-`. */
|
|
28
|
+
function encodePath(p: string): string {
|
|
29
|
+
return p.replace(/\//g, "-");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface UsageLine {
|
|
33
|
+
message?: {
|
|
34
|
+
model?: string;
|
|
35
|
+
content?: Array<{
|
|
36
|
+
type?: string;
|
|
37
|
+
id?: string;
|
|
38
|
+
name?: string;
|
|
39
|
+
tool_use_id?: string;
|
|
40
|
+
input?: { file_path?: string };
|
|
41
|
+
}>;
|
|
42
|
+
usage?: {
|
|
43
|
+
input_tokens?: number;
|
|
44
|
+
output_tokens?: number;
|
|
45
|
+
cache_creation_input_tokens?: number;
|
|
46
|
+
cache_read_input_tokens?: number;
|
|
47
|
+
output_tokens_details?: {
|
|
48
|
+
thinking_tokens?: number;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
timestamp?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ModelAcc {
|
|
56
|
+
provider: string;
|
|
57
|
+
session_count: number;
|
|
58
|
+
tokens_input: number;
|
|
59
|
+
tokens_output: number;
|
|
60
|
+
tokens_reasoning: number;
|
|
61
|
+
tokens_cache_read: number;
|
|
62
|
+
tokens_cache_write: number;
|
|
63
|
+
cost: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read passive usage from Claude Code JSONL session files.
|
|
68
|
+
* Returns EMPTY_RESULT when the projects dir or matching project subdir doesn't exist.
|
|
69
|
+
*
|
|
70
|
+
* detail:true adds a UsageDetail: tool_breakdown from tool_use blocks,
|
|
71
|
+
* steps = usage-line count, per-file by_session, plus timing (turn gaps as
|
|
72
|
+
* step durations, tool_use→tool_result latency matched by tool_use_id,
|
|
73
|
+
* per-turn usage as per-step tokens). Averages only — never summed.
|
|
74
|
+
*/
|
|
75
|
+
export function readClaudeCodeUsage(
|
|
76
|
+
worktree?: string,
|
|
77
|
+
since?: number,
|
|
78
|
+
until?: number,
|
|
79
|
+
detail?: boolean,
|
|
80
|
+
): PassiveUsageResult {
|
|
81
|
+
const projectsDir = resolveProjectsDir();
|
|
82
|
+
|
|
83
|
+
// Determine which project dirs to scan.
|
|
84
|
+
let projectDirs: string[];
|
|
85
|
+
if (worktree) {
|
|
86
|
+
const encoded = encodePath(worktree);
|
|
87
|
+
const dir = join(projectsDir, encoded);
|
|
88
|
+
try {
|
|
89
|
+
if (!statSync(dir).isDirectory()) return EMPTY_RESULT;
|
|
90
|
+
} catch {
|
|
91
|
+
return EMPTY_RESULT;
|
|
92
|
+
}
|
|
93
|
+
projectDirs = [dir];
|
|
94
|
+
} else {
|
|
95
|
+
// Scan all project subdirectories.
|
|
96
|
+
try {
|
|
97
|
+
projectDirs = readdirSync(projectsDir)
|
|
98
|
+
.map((name) => join(projectsDir, name))
|
|
99
|
+
.filter((p) => {
|
|
100
|
+
try {
|
|
101
|
+
return statSync(p).isDirectory();
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
} catch {
|
|
107
|
+
return EMPTY_RESULT;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (projectDirs.length === 0) return EMPTY_RESULT;
|
|
112
|
+
|
|
113
|
+
// Aggregate across all project dirs and session files.
|
|
114
|
+
const models = new Map<string, ModelAcc>();
|
|
115
|
+
let totalSessions = 0;
|
|
116
|
+
let totalInput = 0;
|
|
117
|
+
let totalOutput = 0;
|
|
118
|
+
let totalReasoning = 0;
|
|
119
|
+
let totalCacheRead = 0;
|
|
120
|
+
let totalCacheWrite = 0;
|
|
121
|
+
|
|
122
|
+
// Detail accumulators (only filled when detail:true).
|
|
123
|
+
const toolBreakdown: Record<string, number> = {};
|
|
124
|
+
const bytesByTool: Record<string, number> = {};
|
|
125
|
+
const bySession: SessionDetail[] = [];
|
|
126
|
+
// file -> {reads, sessions that read it} — for stale_reads (PLAN-loop-and-savings step 10).
|
|
127
|
+
const fileReads = new Map<string, { reads: number; sessions: Set<string> }>();
|
|
128
|
+
const filesEverEdited = new Set<string>();
|
|
129
|
+
const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
130
|
+
const timingInput: TimingInput = {
|
|
131
|
+
durationsMs: [],
|
|
132
|
+
stepTokens: [],
|
|
133
|
+
toolLatencies: [],
|
|
134
|
+
steps: 0,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Same since/until gate as the main loop — detail must match totals. */
|
|
138
|
+
function inRange(ts: number | null): boolean {
|
|
139
|
+
if (ts === null) return true;
|
|
140
|
+
const s = ts / 1000;
|
|
141
|
+
if (since !== undefined && s < since) return false;
|
|
142
|
+
if (until !== undefined && s > until) return false;
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const projectDir of projectDirs) {
|
|
147
|
+
let files: string[];
|
|
148
|
+
try {
|
|
149
|
+
files = readdirSync(projectDir)
|
|
150
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
151
|
+
.map((f) => join(projectDir, f));
|
|
152
|
+
} catch {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const filePath of files) {
|
|
157
|
+
// ponytail: skip files untouched since `since` before reading them —
|
|
158
|
+
// a JSONL session file is append-only, so mtime < since means every
|
|
159
|
+
// line in it is out of range. Avoids paying full-file read+parse cost
|
|
160
|
+
// (the real bottleneck) on old history when a time window is given.
|
|
161
|
+
if (since !== undefined) {
|
|
162
|
+
try {
|
|
163
|
+
if (statSync(filePath).mtimeMs / 1000 < since) continue;
|
|
164
|
+
} catch {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
let content: string;
|
|
169
|
+
try {
|
|
170
|
+
content = readFileSync(filePath, "utf-8");
|
|
171
|
+
} catch {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let fileSessions = 0;
|
|
176
|
+
const lines = content.split("\n");
|
|
177
|
+
for (const line of lines) {
|
|
178
|
+
if (!line?.includes("input_tokens")) continue;
|
|
179
|
+
|
|
180
|
+
let parsed: UsageLine;
|
|
181
|
+
try {
|
|
182
|
+
parsed = JSON.parse(line) as UsageLine;
|
|
183
|
+
} catch {
|
|
184
|
+
continue; // malformed line — skip, don't abort
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const usage = parsed.message?.usage;
|
|
188
|
+
if (!usage) continue;
|
|
189
|
+
|
|
190
|
+
// Timestamp filtering.
|
|
191
|
+
if (parsed.timestamp) {
|
|
192
|
+
const ts = new Date(parsed.timestamp).getTime() / 1000;
|
|
193
|
+
if (since !== undefined && ts < since) continue;
|
|
194
|
+
if (until !== undefined && ts > until) continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const model = parsed.message?.model ?? "(unknown)";
|
|
198
|
+
const input = usage.input_tokens ?? 0;
|
|
199
|
+
const output = usage.output_tokens ?? 0;
|
|
200
|
+
const reasoning = usage.output_tokens_details?.thinking_tokens ?? 0;
|
|
201
|
+
const cacheRead = usage.cache_read_input_tokens ?? 0;
|
|
202
|
+
const cacheWrite = usage.cache_creation_input_tokens ?? 0;
|
|
203
|
+
|
|
204
|
+
// Only count the first usage line per file as a session.
|
|
205
|
+
fileSessions++;
|
|
206
|
+
|
|
207
|
+
let acc = models.get(model);
|
|
208
|
+
if (!acc) {
|
|
209
|
+
acc = {
|
|
210
|
+
provider: "anthropic",
|
|
211
|
+
session_count: 0,
|
|
212
|
+
tokens_input: 0,
|
|
213
|
+
tokens_output: 0,
|
|
214
|
+
tokens_reasoning: 0,
|
|
215
|
+
tokens_cache_read: 0,
|
|
216
|
+
tokens_cache_write: 0,
|
|
217
|
+
cost: 0,
|
|
218
|
+
};
|
|
219
|
+
models.set(model, acc);
|
|
220
|
+
}
|
|
221
|
+
acc.tokens_input += input;
|
|
222
|
+
acc.tokens_output += output;
|
|
223
|
+
acc.tokens_reasoning += reasoning;
|
|
224
|
+
acc.tokens_cache_read += cacheRead;
|
|
225
|
+
acc.tokens_cache_write += cacheWrite;
|
|
226
|
+
|
|
227
|
+
totalInput += input;
|
|
228
|
+
totalOutput += output;
|
|
229
|
+
totalReasoning += reasoning;
|
|
230
|
+
totalCacheRead += cacheRead;
|
|
231
|
+
totalCacheWrite += cacheWrite;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (fileSessions > 0) {
|
|
235
|
+
totalSessions++;
|
|
236
|
+
// Attribute 1 session to each model that appeared in this file.
|
|
237
|
+
const seenModels = new Set<string>();
|
|
238
|
+
for (const line of lines) {
|
|
239
|
+
if (!line.includes("input_tokens")) continue;
|
|
240
|
+
try {
|
|
241
|
+
const parsed = JSON.parse(line) as UsageLine;
|
|
242
|
+
const m = parsed.message?.model;
|
|
243
|
+
if (m && !seenModels.has(m)) {
|
|
244
|
+
seenModels.add(m);
|
|
245
|
+
const acc = models.get(m);
|
|
246
|
+
if (acc) acc.session_count++;
|
|
247
|
+
}
|
|
248
|
+
} catch {
|
|
249
|
+
/* skip malformed */
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Detail pass: tool_use blocks, per-turn usage, tool_use→tool_result
|
|
255
|
+
// latency (matched by tool_use_id), turn gaps as step durations.
|
|
256
|
+
if (detail && fileSessions > 0) {
|
|
257
|
+
const fileTools: Record<string, number> = {};
|
|
258
|
+
const fileBytesByTool: Record<string, number> = {};
|
|
259
|
+
const turnTs: Array<number | null> = [];
|
|
260
|
+
const useTs = new Map<string, { name: string; ts: number | null }>();
|
|
261
|
+
let fileSteps = 0;
|
|
262
|
+
let fileModel: string | null = null;
|
|
263
|
+
for (const line of lines) {
|
|
264
|
+
if (!line) continue;
|
|
265
|
+
let parsed: UsageLine;
|
|
266
|
+
try {
|
|
267
|
+
parsed = JSON.parse(line) as UsageLine;
|
|
268
|
+
} catch {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const ts = parseTimeMs(parsed.timestamp ?? null);
|
|
272
|
+
if (!inRange(ts)) continue;
|
|
273
|
+
const blocks = parsed.message?.content;
|
|
274
|
+
if (Array.isArray(blocks)) {
|
|
275
|
+
for (const b of blocks) {
|
|
276
|
+
if (b?.type === "tool_use" && typeof b.id === "string") {
|
|
277
|
+
const name =
|
|
278
|
+
typeof b.name === "string" && b.name ? b.name : "(unknown)";
|
|
279
|
+
useTs.set(b.id, { name, ts });
|
|
280
|
+
fileTools[name] = (fileTools[name] ?? 0) + 1;
|
|
281
|
+
toolBreakdown[name] = (toolBreakdown[name] ?? 0) + 1;
|
|
282
|
+
const fp = b.input?.file_path;
|
|
283
|
+
if (typeof fp === "string" && fp) {
|
|
284
|
+
if (name === "Read") {
|
|
285
|
+
let acc = fileReads.get(fp);
|
|
286
|
+
if (!acc) {
|
|
287
|
+
acc = { reads: 0, sessions: new Set() };
|
|
288
|
+
fileReads.set(fp, acc);
|
|
289
|
+
}
|
|
290
|
+
acc.reads++;
|
|
291
|
+
acc.sessions.add(filePath);
|
|
292
|
+
} else if (EDIT_TOOLS.has(name)) {
|
|
293
|
+
filesEverEdited.add(fp);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
} else if (
|
|
297
|
+
b?.type === "tool_result" &&
|
|
298
|
+
typeof b.tool_use_id === "string"
|
|
299
|
+
) {
|
|
300
|
+
const use = useTs.get(b.tool_use_id);
|
|
301
|
+
if (use && use.ts !== null && ts !== null && ts >= use.ts)
|
|
302
|
+
timingInput.toolLatencies.push({
|
|
303
|
+
tool: use.name,
|
|
304
|
+
ms: ts - use.ts,
|
|
305
|
+
});
|
|
306
|
+
// Measure context bytes consumed by this tool_result.
|
|
307
|
+
// JSON.stringify length is a proxy for token count — ratio
|
|
308
|
+
// error cancels when comparing tool-to-tool (same encoding).
|
|
309
|
+
if (use) {
|
|
310
|
+
const bytes = JSON.stringify(b).length;
|
|
311
|
+
fileBytesByTool[use.name] =
|
|
312
|
+
(fileBytesByTool[use.name] ?? 0) + bytes;
|
|
313
|
+
bytesByTool[use.name] = (bytesByTool[use.name] ?? 0) + bytes;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const usage = parsed.message?.usage;
|
|
319
|
+
if (usage) {
|
|
320
|
+
fileSteps++;
|
|
321
|
+
turnTs.push(ts);
|
|
322
|
+
timingInput.steps++;
|
|
323
|
+
timingInput.stepTokens.push({
|
|
324
|
+
input:
|
|
325
|
+
typeof usage.input_tokens === "number"
|
|
326
|
+
? usage.input_tokens
|
|
327
|
+
: null,
|
|
328
|
+
output:
|
|
329
|
+
typeof usage.output_tokens === "number"
|
|
330
|
+
? usage.output_tokens
|
|
331
|
+
: null,
|
|
332
|
+
cost: null, // Claude Code JSONL has no cost field
|
|
333
|
+
});
|
|
334
|
+
if (!fileModel && typeof parsed.message?.model === "string")
|
|
335
|
+
fileModel = parsed.message.model;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// Turn gaps within this file only (cross-file gaps are meaningless).
|
|
339
|
+
for (let i = 1; i < turnTs.length; i++) {
|
|
340
|
+
const a = turnTs[i - 1];
|
|
341
|
+
const b = turnTs[i];
|
|
342
|
+
timingInput.durationsMs.push(
|
|
343
|
+
a !== null && b !== null && b >= a ? b - a : null,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
if (fileSteps > 0) {
|
|
347
|
+
bySession.push({
|
|
348
|
+
// Use project-relative path as session_id to avoid collisions
|
|
349
|
+
// across project directories with same-named files.
|
|
350
|
+
session_id: filePath.slice(projectDir.length + 1),
|
|
351
|
+
model: fileModel ?? "(unknown)",
|
|
352
|
+
steps: fileSteps,
|
|
353
|
+
tools: fileTools,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (totalSessions === 0) return EMPTY_RESULT;
|
|
361
|
+
|
|
362
|
+
// Files read in 2+ sessions and never edited in this window — candidates
|
|
363
|
+
// for CLAUDE.md instead of a Read every session.
|
|
364
|
+
const staleReads = [...fileReads.entries()]
|
|
365
|
+
.filter(([fp, acc]) => acc.sessions.size >= 2 && !filesEverEdited.has(fp))
|
|
366
|
+
.map(([file, acc]) => ({
|
|
367
|
+
file,
|
|
368
|
+
sessions: acc.sessions.size,
|
|
369
|
+
reads: acc.reads,
|
|
370
|
+
}))
|
|
371
|
+
.sort((a, b) => b.sessions - a.sessions)
|
|
372
|
+
.slice(0, 20);
|
|
373
|
+
|
|
374
|
+
const by_model: ModelBreakdown[] = [...models.entries()]
|
|
375
|
+
.map(([model, acc]) => ({
|
|
376
|
+
provider: acc.provider,
|
|
377
|
+
model,
|
|
378
|
+
session_count: acc.session_count,
|
|
379
|
+
tokens_input: acc.tokens_input,
|
|
380
|
+
tokens_output: acc.tokens_output,
|
|
381
|
+
tokens_reasoning: acc.tokens_reasoning,
|
|
382
|
+
tokens_cache_read: acc.tokens_cache_read,
|
|
383
|
+
tokens_cache_write: acc.tokens_cache_write,
|
|
384
|
+
cost: acc.cost,
|
|
385
|
+
}))
|
|
386
|
+
.sort((a, b) => b.cost - a.cost || b.tokens_input - a.tokens_input);
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
total_tokens_input: totalInput,
|
|
390
|
+
total_tokens_output: totalOutput,
|
|
391
|
+
total_tokens_reasoning: totalReasoning,
|
|
392
|
+
total_tokens_cache_read: totalCacheRead,
|
|
393
|
+
total_tokens_cache_write: totalCacheWrite,
|
|
394
|
+
total_cost: 0, // Claude Code JSONL has no cost field
|
|
395
|
+
session_count: totalSessions,
|
|
396
|
+
by_model,
|
|
397
|
+
...(detail
|
|
398
|
+
? {
|
|
399
|
+
detail: {
|
|
400
|
+
tool_breakdown: toolBreakdown,
|
|
401
|
+
bytes_by_tool:
|
|
402
|
+
Object.keys(bytesByTool).length > 0 ? bytesByTool : undefined,
|
|
403
|
+
steps: timingInput.steps,
|
|
404
|
+
by_session: bySession.sort((a, b) => b.steps - a.steps),
|
|
405
|
+
stale_reads: staleReads.length > 0 ? staleReads : undefined,
|
|
406
|
+
note: "per-turn usage overlaps like per-step tokens — steps is a count only",
|
|
407
|
+
timing: summarizeTiming(timingInput),
|
|
408
|
+
} satisfies UsageDetail,
|
|
409
|
+
}
|
|
410
|
+
: {}),
|
|
411
|
+
};
|
|
412
|
+
}
|