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
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// src/session/opencode.ts — OpenCode passive usage reader
|
|
2
|
+
//
|
|
3
|
+
// Reads ~/.local/share/opencode/opencode.db (SQLite).
|
|
4
|
+
// Schema: session + project + part tables.
|
|
5
|
+
|
|
6
|
+
import { Database } from "bun:sqlite";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import {
|
|
11
|
+
beginSnapshot,
|
|
12
|
+
buildWhereClause,
|
|
13
|
+
endSnapshot,
|
|
14
|
+
readDetailFromDb,
|
|
15
|
+
readTimingFromDb,
|
|
16
|
+
} from "./helpers.js";
|
|
17
|
+
import {
|
|
18
|
+
EMPTY_RESULT,
|
|
19
|
+
type ModelBreakdown,
|
|
20
|
+
type PassiveUsageResult,
|
|
21
|
+
} from "./types.js";
|
|
22
|
+
|
|
23
|
+
const DB_PATH = join(homedir(), ".local", "share", "opencode", "opencode.db");
|
|
24
|
+
|
|
25
|
+
function resolveDbPath(optDbPath?: string): string {
|
|
26
|
+
return optDbPath ?? process.env.FAPONY_OPENCODE_DB ?? DB_PATH;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read passive usage from OpenCode's session database.
|
|
31
|
+
* Returns EMPTY_RESULT when the DB file doesn't exist.
|
|
32
|
+
*/
|
|
33
|
+
export function readPassiveUsage(
|
|
34
|
+
worktree?: string,
|
|
35
|
+
since?: number,
|
|
36
|
+
until?: number,
|
|
37
|
+
detailOrOpts?:
|
|
38
|
+
| boolean
|
|
39
|
+
| { detail?: boolean; dbPath?: string; full?: boolean },
|
|
40
|
+
): PassiveUsageResult {
|
|
41
|
+
const detail =
|
|
42
|
+
typeof detailOrOpts === "boolean" ? detailOrOpts : !!detailOrOpts?.detail;
|
|
43
|
+
const full = typeof detailOrOpts === "object" && !!detailOrOpts?.full;
|
|
44
|
+
const dbPath =
|
|
45
|
+
typeof detailOrOpts === "object"
|
|
46
|
+
? resolveDbPath(detailOrOpts.dbPath)
|
|
47
|
+
: resolveDbPath();
|
|
48
|
+
if (!existsSync(dbPath)) {
|
|
49
|
+
return EMPTY_RESULT;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let db: Database | null = null;
|
|
53
|
+
let snapshot = false;
|
|
54
|
+
try {
|
|
55
|
+
db = new Database(dbPath, { readonly: true });
|
|
56
|
+
db.run("PRAGMA query_only = ON");
|
|
57
|
+
snapshot = beginSnapshot(db);
|
|
58
|
+
|
|
59
|
+
// OpenCode's `project.worktree` is the repo ROOT, so a git worktree under it
|
|
60
|
+
// (…/fapony/wt-fapony) never matches and every per-project query came back
|
|
61
|
+
// empty. `session.directory` is the actual cwd — the same value runs.worktree
|
|
62
|
+
// holds. ponytail: filter on the session row, keep the join for by-model.
|
|
63
|
+
const filter = buildWhereClause(
|
|
64
|
+
"s.directory",
|
|
65
|
+
worktree,
|
|
66
|
+
since,
|
|
67
|
+
until,
|
|
68
|
+
"WHERE",
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const totalsRow = db
|
|
72
|
+
.prepare(
|
|
73
|
+
`
|
|
74
|
+
SELECT
|
|
75
|
+
COALESCE(SUM(s.tokens_input), 0) AS total_tokens_input,
|
|
76
|
+
COALESCE(SUM(s.tokens_output), 0) AS total_tokens_output,
|
|
77
|
+
COALESCE(SUM(s.tokens_reasoning), 0) AS total_tokens_reasoning,
|
|
78
|
+
COALESCE(SUM(s.tokens_cache_read), 0) AS total_tokens_cache_read,
|
|
79
|
+
COALESCE(SUM(s.tokens_cache_write), 0) AS total_tokens_cache_write,
|
|
80
|
+
COALESCE(SUM(s.cost), 0) AS total_cost,
|
|
81
|
+
COUNT(*) AS session_count
|
|
82
|
+
FROM session s
|
|
83
|
+
JOIN project p ON s.project_id = p.id
|
|
84
|
+
${filter.clause}
|
|
85
|
+
`,
|
|
86
|
+
)
|
|
87
|
+
.get(...filter.params) as {
|
|
88
|
+
total_tokens_input: number;
|
|
89
|
+
total_tokens_output: number;
|
|
90
|
+
total_tokens_reasoning: number;
|
|
91
|
+
total_tokens_cache_read: number;
|
|
92
|
+
total_tokens_cache_write: number;
|
|
93
|
+
total_cost: number;
|
|
94
|
+
session_count: number;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (!totalsRow || totalsRow.session_count === 0) {
|
|
98
|
+
return EMPTY_RESULT;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const byModelRows = db
|
|
102
|
+
.prepare(
|
|
103
|
+
`
|
|
104
|
+
SELECT
|
|
105
|
+
CASE WHEN json_valid(s.model) THEN COALESCE(json_extract(s.model, '$.providerID'), '') ELSE '' END AS provider,
|
|
106
|
+
CASE WHEN json_valid(s.model) THEN COALESCE(json_extract(s.model, '$.id'), s.model) ELSE s.model END AS model,
|
|
107
|
+
COUNT(*) AS session_count,
|
|
108
|
+
SUM(s.tokens_input) AS tokens_input,
|
|
109
|
+
SUM(s.tokens_output) AS tokens_output,
|
|
110
|
+
SUM(s.tokens_reasoning) AS tokens_reasoning,
|
|
111
|
+
SUM(s.tokens_cache_read) AS tokens_cache_read,
|
|
112
|
+
SUM(s.tokens_cache_write) AS tokens_cache_write,
|
|
113
|
+
SUM(s.cost) AS cost
|
|
114
|
+
FROM session s
|
|
115
|
+
JOIN project p ON s.project_id = p.id
|
|
116
|
+
${filter.clause}
|
|
117
|
+
GROUP BY CASE WHEN json_valid(s.model) THEN COALESCE(json_extract(s.model, '$.providerID'), '') ELSE '' END,
|
|
118
|
+
CASE WHEN json_valid(s.model) THEN COALESCE(json_extract(s.model, '$.id'), s.model) ELSE s.model END
|
|
119
|
+
ORDER BY (tokens_input + tokens_output + tokens_reasoning + tokens_cache_read + tokens_cache_write) DESC
|
|
120
|
+
`,
|
|
121
|
+
)
|
|
122
|
+
.all(...filter.params) as ModelBreakdown[];
|
|
123
|
+
|
|
124
|
+
const result: PassiveUsageResult = {
|
|
125
|
+
total_tokens_input: totalsRow.total_tokens_input,
|
|
126
|
+
total_tokens_output: totalsRow.total_tokens_output,
|
|
127
|
+
total_tokens_reasoning: totalsRow.total_tokens_reasoning,
|
|
128
|
+
total_tokens_cache_read: totalsRow.total_tokens_cache_read,
|
|
129
|
+
total_tokens_cache_write: totalsRow.total_tokens_cache_write,
|
|
130
|
+
total_cost: totalsRow.total_cost,
|
|
131
|
+
session_count: totalsRow.session_count,
|
|
132
|
+
by_model: byModelRows,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
if (detail) {
|
|
136
|
+
result.detail = readDetailFromDb(
|
|
137
|
+
db,
|
|
138
|
+
"s.directory",
|
|
139
|
+
"s.model",
|
|
140
|
+
worktree,
|
|
141
|
+
since,
|
|
142
|
+
until,
|
|
143
|
+
);
|
|
144
|
+
try {
|
|
145
|
+
result.detail.timing = readTimingFromDb(db, "s.directory", {
|
|
146
|
+
worktree,
|
|
147
|
+
since,
|
|
148
|
+
until,
|
|
149
|
+
limit: full ? false : undefined,
|
|
150
|
+
});
|
|
151
|
+
} catch {
|
|
152
|
+
// Timing is additive signal — a part-table shape mismatch must
|
|
153
|
+
// never break totals/detail that already succeeded. Say so instead of
|
|
154
|
+
// letting a silent null read as "this client records no timing".
|
|
155
|
+
result.detail.timing = null;
|
|
156
|
+
result.error = "opencode_timing_read_failed";
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return result;
|
|
161
|
+
} catch {
|
|
162
|
+
return { ...EMPTY_RESULT, error: "opencode_read_failed" };
|
|
163
|
+
} finally {
|
|
164
|
+
if (db) endSnapshot(db, snapshot);
|
|
165
|
+
db?.close();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// src/session/registry.ts — single source of truth for supported clients.
|
|
2
|
+
//
|
|
3
|
+
// Adding a client: write its reader (src/session/<name>.ts, same
|
|
4
|
+
// PassiveUsageResult shape as an existing one — SQLite or JSONL, whatever the
|
|
5
|
+
// client actually stores), then add one entry below. usage-scan and
|
|
6
|
+
// fapony_usage both iterate this list; neither needs any other change.
|
|
7
|
+
//
|
|
8
|
+
// This does not remove the real work of a new client (reverse-engineering
|
|
9
|
+
// its storage format) — it only removes the "wire it into 2 call sites"
|
|
10
|
+
// step that used to come after.
|
|
11
|
+
|
|
12
|
+
import { readClaudeCodeUsage } from "./claude-code.js";
|
|
13
|
+
import { readCodexUsage } from "./codex.js";
|
|
14
|
+
import { readPassiveUsage } from "./opencode.js";
|
|
15
|
+
import type { PassiveUsageReader } from "./types.js";
|
|
16
|
+
import { readZcodeUsage } from "./zcode.js";
|
|
17
|
+
|
|
18
|
+
export interface ClientAdapter {
|
|
19
|
+
/** cache `client` field and fapony_usage JSON key. */
|
|
20
|
+
key: string;
|
|
21
|
+
/** usage-scan progress label — defaults to `key`. */
|
|
22
|
+
scanLabel?: string;
|
|
23
|
+
/** fapony_usage text-report section label — defaults to `key`. */
|
|
24
|
+
reportLabel?: string;
|
|
25
|
+
/** The one client whose totals lead the report as the unlabeled top-level summary. */
|
|
26
|
+
primary?: boolean;
|
|
27
|
+
read: PassiveUsageReader;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const CLIENTS: ClientAdapter[] = [
|
|
31
|
+
{
|
|
32
|
+
key: "opencode",
|
|
33
|
+
primary: true,
|
|
34
|
+
read: (worktree, since, until, detail, full) =>
|
|
35
|
+
readPassiveUsage(worktree, since, until, { detail, full }),
|
|
36
|
+
},
|
|
37
|
+
{ key: "zcode", read: readZcodeUsage },
|
|
38
|
+
{
|
|
39
|
+
key: "claude_code",
|
|
40
|
+
scanLabel: "claude-code",
|
|
41
|
+
reportLabel: "claude code",
|
|
42
|
+
read: readClaudeCodeUsage,
|
|
43
|
+
},
|
|
44
|
+
{ key: "codex", read: readCodexUsage },
|
|
45
|
+
];
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// src/session/types.ts — shared types + constants for session usage providers
|
|
2
|
+
|
|
3
|
+
export interface ModelBreakdown {
|
|
4
|
+
provider: string;
|
|
5
|
+
model: string;
|
|
6
|
+
session_count: number;
|
|
7
|
+
tokens_input: number;
|
|
8
|
+
tokens_output: number;
|
|
9
|
+
tokens_reasoning: number;
|
|
10
|
+
tokens_cache_read: number;
|
|
11
|
+
tokens_cache_write: number;
|
|
12
|
+
cost: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SessionDetail {
|
|
16
|
+
session_id: string;
|
|
17
|
+
model: string;
|
|
18
|
+
steps: number;
|
|
19
|
+
tools: Record<string, number>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ToolLatencyStat {
|
|
23
|
+
count: number;
|
|
24
|
+
avgMs: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface StepTimingSummary {
|
|
28
|
+
/** step-finish row count (mirrors UsageDetail.steps for SQLite providers). */
|
|
29
|
+
steps: number;
|
|
30
|
+
/** Mean per-part duration from embedded data.time (row-timestamp fallback when absent). */
|
|
31
|
+
avgStepMs: number | null;
|
|
32
|
+
/** How many durations were actually measured (vs. steps with no time signal). */
|
|
33
|
+
stepSamples: number;
|
|
34
|
+
/** Mean per-step tokens — averages only, never summed (sums overlap). */
|
|
35
|
+
avgStepInput: number | null;
|
|
36
|
+
avgStepOutput: number | null;
|
|
37
|
+
avgStepCost: number | null;
|
|
38
|
+
/** Mean tool latency grouped by tool name (from data.state.time). */
|
|
39
|
+
toolLatencyMsByType: Record<string, ToolLatencyStat>;
|
|
40
|
+
note: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface StaleReadFile {
|
|
44
|
+
file: string;
|
|
45
|
+
/** How many distinct sessions read this file (diversity, not just count). */
|
|
46
|
+
sessions: number;
|
|
47
|
+
/** Total Read calls across those sessions. */
|
|
48
|
+
reads: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface UsageDetail {
|
|
52
|
+
/** Global tool-call counts across the filtered sessions (activity signal, not quality). */
|
|
53
|
+
tool_breakdown: Record<string, number>;
|
|
54
|
+
/** Context bytes per tool — size of tool_result content blocks. */
|
|
55
|
+
bytes_by_tool?: Record<string, number>;
|
|
56
|
+
/** Total step-finish parts across the filtered sessions. */
|
|
57
|
+
steps: number;
|
|
58
|
+
/** Per-session breakdown (SQL-aggregated, never raw part rows). */
|
|
59
|
+
by_session: SessionDetail[];
|
|
60
|
+
/**
|
|
61
|
+
* Files read across 2+ sessions in this window and never edited/written —
|
|
62
|
+
* candidates for pasting into CLAUDE.md instead of re-reading every
|
|
63
|
+
* session. Top 20 by session count. Needs a wide `since` window (many
|
|
64
|
+
* sessions) before the pattern means anything — noise below that.
|
|
65
|
+
*/
|
|
66
|
+
stale_reads?: StaleReadFile[];
|
|
67
|
+
/**
|
|
68
|
+
* Step-token sums are NOT reported: per-step tokens overlap (each step
|
|
69
|
+
* carries the full context window), so SUM(step tokens) >> session tokens.
|
|
70
|
+
* Verified on real data — see testSessionDetailStepTokensNotSummed.
|
|
71
|
+
*/
|
|
72
|
+
note: string;
|
|
73
|
+
/** Per-step timing/token/latency signal — present only when detail:true was requested. */
|
|
74
|
+
timing?: StepTimingSummary | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface PassiveUsageResult {
|
|
78
|
+
total_tokens_input: number;
|
|
79
|
+
total_tokens_output: number;
|
|
80
|
+
total_tokens_reasoning: number;
|
|
81
|
+
total_tokens_cache_read: number;
|
|
82
|
+
total_tokens_cache_write: number;
|
|
83
|
+
total_cost: number;
|
|
84
|
+
session_count: number;
|
|
85
|
+
by_model: ModelBreakdown[];
|
|
86
|
+
/** Present only when detail:true was requested (additive, default absent). */
|
|
87
|
+
detail?: UsageDetail | null;
|
|
88
|
+
/** ZCode sessions, when available. Absent when ZCode DB not found. */
|
|
89
|
+
zcode?: PassiveUsageResult | null;
|
|
90
|
+
/** Claude Code sessions, when available. Absent when projects dir not found. */
|
|
91
|
+
claude_code?: PassiveUsageResult | null;
|
|
92
|
+
/**
|
|
93
|
+
* Why this read came back short, when it did. Absent on success.
|
|
94
|
+
*
|
|
95
|
+
* Zeros with no `error` mean "no sessions matched"; zeros WITH an error mean
|
|
96
|
+
* "could not read the client's log" — most often schema drift after the
|
|
97
|
+
* client updated. The two used to be indistinguishable: a renamed column
|
|
98
|
+
* made the query throw, the catch-all swallowed it, and the numbers just
|
|
99
|
+
* quietly went missing.
|
|
100
|
+
*/
|
|
101
|
+
error?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const EMPTY_RESULT: PassiveUsageResult = {
|
|
105
|
+
total_tokens_input: 0,
|
|
106
|
+
total_tokens_output: 0,
|
|
107
|
+
total_tokens_reasoning: 0,
|
|
108
|
+
total_tokens_cache_read: 0,
|
|
109
|
+
total_tokens_cache_write: 0,
|
|
110
|
+
total_cost: 0,
|
|
111
|
+
session_count: 0,
|
|
112
|
+
by_model: [],
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** Common shape every client reader implements — see registry.ts. */
|
|
116
|
+
export type PassiveUsageReader = (
|
|
117
|
+
worktree?: string,
|
|
118
|
+
since?: number,
|
|
119
|
+
until?: number,
|
|
120
|
+
detail?: boolean,
|
|
121
|
+
full?: boolean,
|
|
122
|
+
) => PassiveUsageResult;
|
|
123
|
+
|
|
124
|
+
export const STEP_TOKENS_NOTE =
|
|
125
|
+
"step tokens overlap (per-step context window) — SUM(step tokens) != session tokens; steps is a count only";
|
|
126
|
+
|
|
127
|
+
export const TIMING_NOTE =
|
|
128
|
+
"timing from embedded part fields only (data.time/data.state.time/step-finish tokens) with row-timestamp fallback; averages, never raw I/O";
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// src/session/zcode.ts — ZCode passive usage reader
|
|
2
|
+
//
|
|
3
|
+
// Reads ~/.zcode/cli/db/db.sqlite (SQLite).
|
|
4
|
+
// Schema: session + model_usage + part tables (different from OpenCode).
|
|
5
|
+
// Worktree lives on session.directory, not a separate project table.
|
|
6
|
+
|
|
7
|
+
import { Database } from "bun:sqlite";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
beginSnapshot,
|
|
13
|
+
buildWhereClause,
|
|
14
|
+
endSnapshot,
|
|
15
|
+
readDetailFromDb,
|
|
16
|
+
readTimingFromDb,
|
|
17
|
+
} from "./helpers.js";
|
|
18
|
+
import {
|
|
19
|
+
EMPTY_RESULT,
|
|
20
|
+
type ModelBreakdown,
|
|
21
|
+
type PassiveUsageResult,
|
|
22
|
+
} from "./types.js";
|
|
23
|
+
|
|
24
|
+
const ZCODE_DB_PATH = join(homedir(), ".zcode", "cli", "db", "db.sqlite");
|
|
25
|
+
|
|
26
|
+
function resolveZcodeDbPath(optDbPath?: string): string {
|
|
27
|
+
return optDbPath ?? process.env.FAPONY_ZCODE_DB ?? ZCODE_DB_PATH;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read passive usage from ZCode's own session DB.
|
|
32
|
+
* Returns EMPTY_RESULT when the DB file doesn't exist (ZCode never started).
|
|
33
|
+
*/
|
|
34
|
+
export function readZcodeUsage(
|
|
35
|
+
worktree?: string,
|
|
36
|
+
since?: number,
|
|
37
|
+
until?: number,
|
|
38
|
+
detail?: boolean,
|
|
39
|
+
full?: boolean,
|
|
40
|
+
): PassiveUsageResult {
|
|
41
|
+
const dbPath = resolveZcodeDbPath();
|
|
42
|
+
if (!existsSync(dbPath)) return EMPTY_RESULT;
|
|
43
|
+
|
|
44
|
+
let db: Database | null = null;
|
|
45
|
+
let snapshot = false;
|
|
46
|
+
try {
|
|
47
|
+
db = new Database(dbPath, { readonly: true });
|
|
48
|
+
db.run("PRAGMA query_only = ON");
|
|
49
|
+
snapshot = beginSnapshot(db);
|
|
50
|
+
|
|
51
|
+
const filter = buildWhereClause("s.directory", worktree, since, until);
|
|
52
|
+
|
|
53
|
+
const totalsRow = db
|
|
54
|
+
.prepare(
|
|
55
|
+
`
|
|
56
|
+
SELECT
|
|
57
|
+
COALESCE(SUM(mu.input_tokens), 0) AS total_tokens_input,
|
|
58
|
+
COALESCE(SUM(mu.output_tokens), 0) AS total_tokens_output,
|
|
59
|
+
COALESCE(SUM(mu.reasoning_tokens), 0) AS total_tokens_reasoning,
|
|
60
|
+
COALESCE(SUM(mu.cache_read_input_tokens), 0) AS total_tokens_cache_read,
|
|
61
|
+
COALESCE(SUM(mu.cache_creation_input_tokens), 0) AS total_tokens_cache_write,
|
|
62
|
+
0 AS total_cost,
|
|
63
|
+
COUNT(DISTINCT s.id) AS session_count
|
|
64
|
+
FROM model_usage mu
|
|
65
|
+
JOIN session s ON mu.session_id = s.id
|
|
66
|
+
${filter.clause}
|
|
67
|
+
`,
|
|
68
|
+
)
|
|
69
|
+
.get(...filter.params) as {
|
|
70
|
+
total_tokens_input: number;
|
|
71
|
+
total_tokens_output: number;
|
|
72
|
+
total_tokens_reasoning: number;
|
|
73
|
+
total_tokens_cache_read: number;
|
|
74
|
+
total_tokens_cache_write: number;
|
|
75
|
+
total_cost: number;
|
|
76
|
+
session_count: number;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (!totalsRow || totalsRow.session_count === 0) return EMPTY_RESULT;
|
|
80
|
+
|
|
81
|
+
const byModelRows = db
|
|
82
|
+
.prepare(
|
|
83
|
+
`
|
|
84
|
+
SELECT
|
|
85
|
+
'' AS provider,
|
|
86
|
+
mu.model_id AS model,
|
|
87
|
+
COUNT(DISTINCT s.id) AS session_count,
|
|
88
|
+
SUM(mu.input_tokens) AS tokens_input,
|
|
89
|
+
SUM(mu.output_tokens) AS tokens_output,
|
|
90
|
+
SUM(mu.reasoning_tokens) AS tokens_reasoning,
|
|
91
|
+
SUM(mu.cache_read_input_tokens) AS tokens_cache_read,
|
|
92
|
+
SUM(mu.cache_creation_input_tokens) AS tokens_cache_write,
|
|
93
|
+
0 AS cost
|
|
94
|
+
FROM model_usage mu
|
|
95
|
+
JOIN session s ON mu.session_id = s.id
|
|
96
|
+
${filter.clause}
|
|
97
|
+
GROUP BY mu.model_id
|
|
98
|
+
ORDER BY SUM(mu.computed_total_tokens) DESC
|
|
99
|
+
`,
|
|
100
|
+
)
|
|
101
|
+
.all(...filter.params) as ModelBreakdown[];
|
|
102
|
+
|
|
103
|
+
const result: PassiveUsageResult = {
|
|
104
|
+
total_tokens_input: totalsRow.total_tokens_input,
|
|
105
|
+
total_tokens_output: totalsRow.total_tokens_output,
|
|
106
|
+
total_tokens_reasoning: totalsRow.total_tokens_reasoning,
|
|
107
|
+
total_tokens_cache_read: totalsRow.total_tokens_cache_read,
|
|
108
|
+
total_tokens_cache_write: 0, // ZCode doesn't separately track cache write
|
|
109
|
+
total_cost: totalsRow.total_cost,
|
|
110
|
+
session_count: totalsRow.session_count,
|
|
111
|
+
by_model: byModelRows,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (detail) {
|
|
115
|
+
result.detail = readDetailFromDb(
|
|
116
|
+
db,
|
|
117
|
+
"s.directory",
|
|
118
|
+
"s.directory",
|
|
119
|
+
worktree,
|
|
120
|
+
since,
|
|
121
|
+
until,
|
|
122
|
+
);
|
|
123
|
+
// ZCode part table has no time_updated column (spec §1: verify before
|
|
124
|
+
// coding) — embedded data.time only, row fallback disabled.
|
|
125
|
+
// ASSUMPTION: ZCode part.data.type uses the same vocabulary as OpenCode
|
|
126
|
+
// ("tool", "step-finish", "reasoning"). If ZCode uses different type
|
|
127
|
+
// strings, timing silently returns empty (graceful degradation, no error).
|
|
128
|
+
try {
|
|
129
|
+
result.detail.timing = readTimingFromDb(db, "s.directory", {
|
|
130
|
+
worktree,
|
|
131
|
+
since,
|
|
132
|
+
until,
|
|
133
|
+
hasTimeUpdated: false,
|
|
134
|
+
limit: full ? false : undefined,
|
|
135
|
+
});
|
|
136
|
+
} catch {
|
|
137
|
+
// Timing is additive signal — never break totals/detail. The code
|
|
138
|
+
// distinguishes "ZCode records no timing" from "we could not read it".
|
|
139
|
+
result.detail.timing = null;
|
|
140
|
+
result.error = "zcode_timing_read_failed";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return result;
|
|
145
|
+
} catch {
|
|
146
|
+
return { ...EMPTY_RESULT, error: "zcode_read_failed" };
|
|
147
|
+
} finally {
|
|
148
|
+
if (db) endSnapshot(db, snapshot);
|
|
149
|
+
db?.close();
|
|
150
|
+
}
|
|
151
|
+
}
|