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/telemetry.ts
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// src/telemetry.ts — opt-in aggregate telemetry (P4)
|
|
2
|
+
//
|
|
3
|
+
// Schema-versioned payload with machine-observed facts only.
|
|
4
|
+
// Self-reported metadata (e.g. user tags) are separated from computed aggregates.
|
|
5
|
+
// Content fields (plan, commit message, gate note, source, diff) are NEVER included.
|
|
6
|
+
//
|
|
7
|
+
// §0 rule: every field in the payload is either a structural fact (run count,
|
|
8
|
+
// status distribution) or a computed aggregate (avg rounds, pass rate).
|
|
9
|
+
// No event/worktree content is ever serialized — the only free text on the
|
|
10
|
+
// wire is user-configured telemetry.metadata (self-reported, advisory).
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
type Config,
|
|
14
|
+
type Event,
|
|
15
|
+
loadConfig,
|
|
16
|
+
openDb,
|
|
17
|
+
type Run,
|
|
18
|
+
} from "./db/index.js";
|
|
19
|
+
import { enrichGateWindows } from "./gates.js";
|
|
20
|
+
import { avg, minutesBetween } from "./math.js";
|
|
21
|
+
import { readPassiveUsage } from "./session/index.js";
|
|
22
|
+
|
|
23
|
+
// ─── Schema version ────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Telemetry schema version — bumped on every additive change to the payload
|
|
27
|
+
* shape. Receivers must tolerate unknown fields (forward-compatible) but
|
|
28
|
+
* should reject payloads with a version they don't understand.
|
|
29
|
+
*
|
|
30
|
+
* v1 = original (raw run/event rows — DEPRECATED, removed)
|
|
31
|
+
* v2 = aggregate payload with machine-observed facts + self-reported metadata
|
|
32
|
+
* v3 = v2 + derived namespace (tool_call_counts, efficiency_scores, cost_per_quality)
|
|
33
|
+
* v4 = v3 - every declared-cost field (spawn events are no longer written;
|
|
34
|
+
* cost/avg_cost_usd/efficiency_scores/cost_per_quality are gone)
|
|
35
|
+
*/
|
|
36
|
+
export const TELEMETRY_SCHEMA_VERSION = 4;
|
|
37
|
+
|
|
38
|
+
// ─── Retention policy ──────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Retention: payload is a snapshot at send-time. No history is kept on the
|
|
42
|
+
* sender side beyond what SQLite already stores. Receivers should apply
|
|
43
|
+
* their own retention (recommended: 90 days raw, then aggregate-only).
|
|
44
|
+
*
|
|
45
|
+
* Deletion: sender can delete local runs/events anytime — the payload is
|
|
46
|
+
* already extracted. No "correction" mechanism exists on the wire; receivers
|
|
47
|
+
* should treat payloads as immutable facts.
|
|
48
|
+
*
|
|
49
|
+
* See TELEMETRY.md § Retention for the full policy.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
// ─── Machine-observed facts ────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Machine-observed: computed by fapony from DB, never typed by a human.
|
|
56
|
+
* These are the "ground truth" aggregates that receivers can compare against.
|
|
57
|
+
*/
|
|
58
|
+
export interface MachineObserved {
|
|
59
|
+
/** Total runs in the database at send-time. */
|
|
60
|
+
total_runs: number;
|
|
61
|
+
/** Status distribution: { passed: 5, stalled: 1, ... }. */
|
|
62
|
+
by_status: Record<string, number>;
|
|
63
|
+
/** Pass rate among terminal runs (passed / (passed + stopped + stalled)). */
|
|
64
|
+
pass_rate: number;
|
|
65
|
+
/** Stall rate among terminal runs. */
|
|
66
|
+
stall_rate: number;
|
|
67
|
+
/** Average rounds for passed runs. */
|
|
68
|
+
avg_rounds: number;
|
|
69
|
+
/** Average minutes from creation to last update for passed runs. */
|
|
70
|
+
avg_minutes: number;
|
|
71
|
+
/** Per-model breakdown (executor model only, from spawn events). */
|
|
72
|
+
by_model: Array<{
|
|
73
|
+
model: string;
|
|
74
|
+
gate_count: number;
|
|
75
|
+
avg_quality: number;
|
|
76
|
+
}>;
|
|
77
|
+
/** Per-grade breakdown. */
|
|
78
|
+
by_grade: Array<{
|
|
79
|
+
grade: string;
|
|
80
|
+
count: number;
|
|
81
|
+
}>;
|
|
82
|
+
/** Per-worktree breakdown (paths redacted to basename only). */
|
|
83
|
+
by_worktree: Array<{
|
|
84
|
+
worktree: string;
|
|
85
|
+
runs: number;
|
|
86
|
+
passed: number;
|
|
87
|
+
stalled: number;
|
|
88
|
+
}>;
|
|
89
|
+
/** Derived aggregates (v3+) — activity signals, not quality scores. */
|
|
90
|
+
derived?: DerivedAggregates;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Derived aggregates (v3) ─────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Derived from usage-depth queries — activity signals only.
|
|
97
|
+
* All fields are numeric aggregates; no content, no raw rows.
|
|
98
|
+
*
|
|
99
|
+
* `tool_call_counts`: tool-call counts from OpenCode sessions whose project
|
|
100
|
+
* worktree resolves from this DB's run worktree keys via
|
|
101
|
+
* `config.worktrees` — fapony-scoped, never global. Sorted desc by count.
|
|
102
|
+
*/
|
|
103
|
+
export interface DerivedAggregates {
|
|
104
|
+
/** Scoped tool-call counts (name → count), sorted desc by count. */
|
|
105
|
+
tool_call_counts: Record<string, number>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── Self-reported metadata ────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Self-reported: metadata the user/agent chose to attach. These are NOT
|
|
112
|
+
* computed by fapony and may be inaccurate. Receivers should treat them as
|
|
113
|
+
* advisory, not ground truth.
|
|
114
|
+
*
|
|
115
|
+
* Currently empty — reserved for future fields like:
|
|
116
|
+
* - task_category: "feature" | "bugfix" | "refactor"
|
|
117
|
+
* - stack: "bun" | "node" | "deno"
|
|
118
|
+
* - notes: free-text (always optional)
|
|
119
|
+
*
|
|
120
|
+
* These fields are ONLY included when the user explicitly sets them in
|
|
121
|
+
* fapony.config.json under `telemetry.metadata`.
|
|
122
|
+
*/
|
|
123
|
+
export interface SelfReported {
|
|
124
|
+
/** Task category (user-set, not inferred). */
|
|
125
|
+
task_category?: string;
|
|
126
|
+
/** Tech stack (user-set). */
|
|
127
|
+
stack?: string;
|
|
128
|
+
/** Free-text notes from the user. */
|
|
129
|
+
notes?: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Full payload ──────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
export interface TelemetryPayload {
|
|
135
|
+
/** Schema version — receivers must check this. */
|
|
136
|
+
schema_version: number;
|
|
137
|
+
/** ISO-8601 timestamp of payload generation. */
|
|
138
|
+
sent_at: string;
|
|
139
|
+
/** Machine-observed aggregates (ground truth). */
|
|
140
|
+
machine: MachineObserved;
|
|
141
|
+
/** Self-reported metadata (advisory, may be absent). */
|
|
142
|
+
self_reported?: SelfReported;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ─── Payload builder ───────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
function parseEventData(data: string | null): Record<string, unknown> {
|
|
148
|
+
if (!data) return {};
|
|
149
|
+
try {
|
|
150
|
+
return JSON.parse(data);
|
|
151
|
+
} catch {
|
|
152
|
+
return {};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Build the aggregate telemetry payload from the local SQLite database.
|
|
158
|
+
* No raw rows, no event/worktree content — only computed aggregates plus
|
|
159
|
+
* optional user-configured self-reported metadata.
|
|
160
|
+
*/
|
|
161
|
+
export function buildPayload(): TelemetryPayload {
|
|
162
|
+
const db = openDb();
|
|
163
|
+
try {
|
|
164
|
+
const runs = db.prepare("SELECT * FROM runs ORDER BY id").all() as Run[];
|
|
165
|
+
const events = db
|
|
166
|
+
.prepare("SELECT * FROM events ORDER BY run_id, id")
|
|
167
|
+
.all() as Event[];
|
|
168
|
+
|
|
169
|
+
// --- Runs summary ---
|
|
170
|
+
const byStatus: Record<string, number> = {};
|
|
171
|
+
for (const r of runs) byStatus[r.status] = (byStatus[r.status] ?? 0) + 1;
|
|
172
|
+
|
|
173
|
+
const terminal = runs.filter((r) =>
|
|
174
|
+
["passed", "stopped", "stalled"].includes(r.status),
|
|
175
|
+
);
|
|
176
|
+
const passed = runs.filter((r) => r.status === "passed");
|
|
177
|
+
|
|
178
|
+
const passRate = terminal.length ? passed.length / terminal.length : 0;
|
|
179
|
+
const stallRate = terminal.length
|
|
180
|
+
? (byStatus.stalled ?? 0) / terminal.length
|
|
181
|
+
: 0;
|
|
182
|
+
const avgRounds = passed.length
|
|
183
|
+
? passed.reduce((s, r) => s + r.round, 0) / passed.length
|
|
184
|
+
: 0;
|
|
185
|
+
const avgMinutes = passed.length
|
|
186
|
+
? passed.reduce(
|
|
187
|
+
(s, r) => s + minutesBetween(r.created_at, r.updated_at),
|
|
188
|
+
0,
|
|
189
|
+
) / passed.length
|
|
190
|
+
: 0;
|
|
191
|
+
|
|
192
|
+
// --- By model (executor spawns only, per-round gate windows) ---
|
|
193
|
+
// Windowing comes from enrichGateWindows (src/gates.ts) — the same
|
|
194
|
+
// disjoint (prevGateId, gateId) windows stats.ts uses, never cumulative.
|
|
195
|
+
const modelBuckets: Record<
|
|
196
|
+
string,
|
|
197
|
+
{ gateCount: number; qualities: number[] }
|
|
198
|
+
> = {};
|
|
199
|
+
|
|
200
|
+
for (const w of enrichGateWindows(events)) {
|
|
201
|
+
const model = w.model ?? "(unknown)";
|
|
202
|
+
if (!modelBuckets[model]) {
|
|
203
|
+
modelBuckets[model] = { gateCount: 0, qualities: [] };
|
|
204
|
+
}
|
|
205
|
+
modelBuckets[model].gateCount++;
|
|
206
|
+
if (w.quality !== null) {
|
|
207
|
+
modelBuckets[model].qualities.push(w.quality);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const byModel = Object.entries(modelBuckets)
|
|
211
|
+
.map(([model, b]) => ({
|
|
212
|
+
model,
|
|
213
|
+
gate_count: b.gateCount,
|
|
214
|
+
avg_quality: b.qualities.length ? avg(b.qualities) : 0,
|
|
215
|
+
}))
|
|
216
|
+
.sort((a, b) => b.gate_count - a.gate_count);
|
|
217
|
+
|
|
218
|
+
// --- By grade ---
|
|
219
|
+
const gradeBuckets: Record<string, number> = {};
|
|
220
|
+
for (const e of events) {
|
|
221
|
+
if (e.kind !== "gate") continue;
|
|
222
|
+
const d = parseEventData(e.data);
|
|
223
|
+
const grade = typeof d.verdict === "string" ? d.verdict : "(unknown)";
|
|
224
|
+
gradeBuckets[grade] = (gradeBuckets[grade] ?? 0) + 1;
|
|
225
|
+
}
|
|
226
|
+
const byGrade = Object.entries(gradeBuckets)
|
|
227
|
+
.map(([grade, count]) => ({ grade, count }))
|
|
228
|
+
.sort((a, b) => b.count - a.count);
|
|
229
|
+
|
|
230
|
+
// --- By worktree (paths redacted to basename) ---
|
|
231
|
+
const wtBuckets: Record<
|
|
232
|
+
string,
|
|
233
|
+
{ runs: number; passed: number; stalled: number }
|
|
234
|
+
> = {};
|
|
235
|
+
for (const r of runs) {
|
|
236
|
+
// Redact to basename only — no full paths in telemetry
|
|
237
|
+
const name = r.worktree.split("/").pop() ?? r.worktree;
|
|
238
|
+
const b = (wtBuckets[name] ??= { runs: 0, passed: 0, stalled: 0 });
|
|
239
|
+
b.runs++;
|
|
240
|
+
if (r.status === "passed") b.passed++;
|
|
241
|
+
if (r.status === "stalled") b.stalled++;
|
|
242
|
+
}
|
|
243
|
+
const byWorktree = Object.entries(wtBuckets)
|
|
244
|
+
.map(([worktree, b]) => ({ worktree, ...b }))
|
|
245
|
+
.sort((a, b) => b.runs - a.runs);
|
|
246
|
+
|
|
247
|
+
// --- Self-reported metadata (from config) ---
|
|
248
|
+
const config = loadConfig();
|
|
249
|
+
const selfReported: SelfReported | undefined = config.telemetry?.metadata
|
|
250
|
+
? {
|
|
251
|
+
...(typeof config.telemetry.metadata.task_category === "string"
|
|
252
|
+
? { task_category: config.telemetry.metadata.task_category }
|
|
253
|
+
: {}),
|
|
254
|
+
...(typeof config.telemetry.metadata.stack === "string"
|
|
255
|
+
? { stack: config.telemetry.metadata.stack }
|
|
256
|
+
: {}),
|
|
257
|
+
...(typeof config.telemetry.metadata.notes === "string"
|
|
258
|
+
? { notes: config.telemetry.metadata.notes }
|
|
259
|
+
: {}),
|
|
260
|
+
}
|
|
261
|
+
: undefined;
|
|
262
|
+
|
|
263
|
+
// --- Derived aggregates: scoped tool call counts ---
|
|
264
|
+
const derived = buildDerived(resolveTelemetryWorktrees(runs, config));
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
schema_version: TELEMETRY_SCHEMA_VERSION,
|
|
268
|
+
sent_at: new Date().toISOString(),
|
|
269
|
+
machine: {
|
|
270
|
+
total_runs: runs.length,
|
|
271
|
+
by_status: byStatus,
|
|
272
|
+
pass_rate: passRate,
|
|
273
|
+
stall_rate: stallRate,
|
|
274
|
+
avg_rounds: avgRounds,
|
|
275
|
+
avg_minutes: avgMinutes,
|
|
276
|
+
by_model: byModel,
|
|
277
|
+
by_grade: byGrade,
|
|
278
|
+
by_worktree: byWorktree,
|
|
279
|
+
...(derived ? { derived } : {}),
|
|
280
|
+
},
|
|
281
|
+
...(selfReported ? { self_reported: selfReported } : {}),
|
|
282
|
+
};
|
|
283
|
+
} finally {
|
|
284
|
+
db.close();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ─── Derived aggregates builder (v3) ─────────────────────────────────
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Run worktree keys (e.g. "wt-fapony", "mcp-external") resolve to absolute
|
|
292
|
+
* paths via config.worktrees. Only resolvable paths are queried — keys with
|
|
293
|
+
* no mapping (mcp-external, stale keys) contribute nothing, never global.
|
|
294
|
+
*/
|
|
295
|
+
function resolveTelemetryWorktrees(runs: Run[], config: Config): string[] {
|
|
296
|
+
const paths = new Set<string>();
|
|
297
|
+
for (const r of runs) {
|
|
298
|
+
const p = config.worktrees?.[r.worktree];
|
|
299
|
+
if (typeof p === "string" && p) paths.add(p);
|
|
300
|
+
}
|
|
301
|
+
return [...paths];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Build derived aggregates from scoped OpenCode usage.
|
|
306
|
+
* Returns null when there's nothing to report (no data).
|
|
307
|
+
*/
|
|
308
|
+
function buildDerived(worktreePaths: string[]): DerivedAggregates | null {
|
|
309
|
+
// --- tool_call_counts, scoped to this DB's fapony worktrees ---
|
|
310
|
+
const merged: Record<string, number> = {};
|
|
311
|
+
for (const wt of worktreePaths) {
|
|
312
|
+
const usage = readPassiveUsage(wt, undefined, undefined, true);
|
|
313
|
+
const tb = usage.detail?.tool_breakdown ?? {};
|
|
314
|
+
for (const [tool, c] of Object.entries(tb)) {
|
|
315
|
+
merged[tool] = (merged[tool] ?? 0) + c;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const tool_call_counts: Record<string, number> = {};
|
|
319
|
+
for (const [tool, c] of Object.entries(merged).sort((a, b) => b[1] - a[1])) {
|
|
320
|
+
tool_call_counts[tool] = c;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return Object.keys(tool_call_counts).length > 0 ? { tool_call_counts } : null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ─── CLI ───────────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
export async function cmdTelemetry(args: string[]): Promise<void> {
|
|
329
|
+
const sub = args[0];
|
|
330
|
+
const payload = buildPayload();
|
|
331
|
+
|
|
332
|
+
if (sub === "show" || !sub) {
|
|
333
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (sub === "send") {
|
|
338
|
+
const config = loadConfig();
|
|
339
|
+
if (!config.telemetry?.enabled) {
|
|
340
|
+
console.error(
|
|
341
|
+
'telemetry is off — set "telemetry": { "enabled": true, "endpoint": "https://..." } in fapony.config.json to turn it on',
|
|
342
|
+
);
|
|
343
|
+
process.exit(1);
|
|
344
|
+
}
|
|
345
|
+
const res = await fetch(config.telemetry.endpoint, {
|
|
346
|
+
method: "POST",
|
|
347
|
+
headers: { "content-type": "application/json" },
|
|
348
|
+
body: JSON.stringify(payload),
|
|
349
|
+
});
|
|
350
|
+
if (!res.ok) {
|
|
351
|
+
console.error(`telemetry send failed: ${res.status} ${res.statusText}`);
|
|
352
|
+
process.exit(1);
|
|
353
|
+
}
|
|
354
|
+
console.log(
|
|
355
|
+
`sent schema v${payload.schema_version} telemetry to ${config.telemetry.endpoint} ` +
|
|
356
|
+
`(${payload.machine.total_runs} runs)`,
|
|
357
|
+
);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
console.error(`fapony telemetry: unknown subcommand "${sub}"`);
|
|
362
|
+
console.error("usage: fapony telemetry <show|send>");
|
|
363
|
+
process.exit(1);
|
|
364
|
+
}
|
package/src/test.ts
ADDED
package/src/update.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// src/update.ts — self-update via git pull.
|
|
2
|
+
// ROOT must be the repo root: import.meta.dir is src/, one level below it.
|
|
3
|
+
// Shows old → new version, recent commits, and warns if uncommitted changes.
|
|
4
|
+
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { createInterface } from "node:readline";
|
|
9
|
+
import { isAffirmative } from "./util.js";
|
|
10
|
+
|
|
11
|
+
/** Repo root (parent of src/) — where package.json and bun.lock live.
|
|
12
|
+
* Exported for the tripwire test in test/update.test.ts. */
|
|
13
|
+
export const ROOT = join(import.meta.dir, "..");
|
|
14
|
+
|
|
15
|
+
function defaultGit(args: string): string {
|
|
16
|
+
return execSync(`git ${args}`, {
|
|
17
|
+
encoding: "utf-8",
|
|
18
|
+
cwd: ROOT,
|
|
19
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
20
|
+
timeout: 15_000,
|
|
21
|
+
}).trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function defaultInstall(): void {
|
|
25
|
+
// Generous cap vs the 15s git calls — bun install legitimately takes longer,
|
|
26
|
+
// but must not wedge `fapony update` forever on a hung registry.
|
|
27
|
+
execSync("bun install", { cwd: ROOT, stdio: "pipe", timeout: 300_000 });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Minimal seam for cmdUpdate — git runner (map args→result, throws on failure),
|
|
31
|
+
* prompt, exit, and bun-install. Every field is used by both the default
|
|
32
|
+
* (production) path and the test path. */
|
|
33
|
+
export interface UpdateDeps {
|
|
34
|
+
git?: (args: string) => string;
|
|
35
|
+
install?: () => void;
|
|
36
|
+
prompt?: (question: string, defaultVal?: string) => Promise<string>;
|
|
37
|
+
exit?: (code: number) => never;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Repo package.json version — exported for tests (reads the real ROOT). */
|
|
41
|
+
export function readVersion(): string {
|
|
42
|
+
const pkgPath = join(ROOT, "package.json");
|
|
43
|
+
if (!existsSync(pkgPath)) return "unknown";
|
|
44
|
+
try {
|
|
45
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
46
|
+
return pkg.version ?? "unknown";
|
|
47
|
+
} catch {
|
|
48
|
+
return "unknown";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Split `git status --porcelain` output into non-empty lines. Empty = clean. */
|
|
53
|
+
export function parseDirtyLines(porcelain: string): string[] {
|
|
54
|
+
return porcelain.split("\n").filter((l) => l.trim() !== "");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The indented dirty-file block cmdUpdate prints before asking to proceed. */
|
|
58
|
+
export function formatDirtyBlock(porcelain: string): string {
|
|
59
|
+
return parseDirtyLines(porcelain)
|
|
60
|
+
.map((l) => ` ${l}`)
|
|
61
|
+
.join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Only an affirmative answer proceeds past the dirty-tree warning. */
|
|
65
|
+
export function shouldProceedAfterDirty(answer: string): boolean {
|
|
66
|
+
return isAffirmative(answer);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Same SHA before/after pull = already up to date. */
|
|
70
|
+
export function isUpToDate(oldSha: string, newSha: string): boolean {
|
|
71
|
+
return oldSha === newSha;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function defaultPrompt(question: string, defaultVal?: string): Promise<string> {
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
const rl = createInterface({
|
|
77
|
+
input: process.stdin,
|
|
78
|
+
output: process.stdout,
|
|
79
|
+
});
|
|
80
|
+
const suffix = defaultVal !== undefined ? ` (${defaultVal})` : "";
|
|
81
|
+
rl.question(`${question}${suffix}: `, (answer) => {
|
|
82
|
+
rl.close();
|
|
83
|
+
resolve(answer.trim() || defaultVal || "");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function cmdUpdate(deps: UpdateDeps = {}): Promise<void> {
|
|
89
|
+
const git = deps.git ?? defaultGit;
|
|
90
|
+
const installFn = deps.install ?? defaultInstall;
|
|
91
|
+
const promptFn = deps.prompt ?? defaultPrompt;
|
|
92
|
+
const exitFn = deps.exit ?? ((code: number): never => process.exit(code));
|
|
93
|
+
const gitQuiet = (args: string): string | null => {
|
|
94
|
+
try {
|
|
95
|
+
return git(args);
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
console.log("\n🔄 fapony update\n");
|
|
102
|
+
|
|
103
|
+
// --- sanity: must be a git repo ---
|
|
104
|
+
const isRepo = gitQuiet("rev-parse --is-inside-work-tree");
|
|
105
|
+
if (isRepo !== "true") {
|
|
106
|
+
console.error(`❌ ${ROOT} is not a git repo — cannot self-update.`);
|
|
107
|
+
console.error(
|
|
108
|
+
" Reinstall via: git clone https://github.com/kire21b/fapony.git",
|
|
109
|
+
);
|
|
110
|
+
exitFn(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- check uncommitted changes ---
|
|
114
|
+
const dirty = git("status --porcelain");
|
|
115
|
+
if (parseDirtyLines(dirty).length > 0) {
|
|
116
|
+
console.log("⚠ You have uncommitted changes in the fapony repo:\n");
|
|
117
|
+
console.log(formatDirtyBlock(dirty));
|
|
118
|
+
console.log();
|
|
119
|
+
const proceed = await promptFn(
|
|
120
|
+
" Stash changes and pull anyway? (y/n)",
|
|
121
|
+
"n",
|
|
122
|
+
);
|
|
123
|
+
if (!shouldProceedAfterDirty(proceed)) {
|
|
124
|
+
console.log("\n Update cancelled.");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
git("stash push -m 'fapony auto-stash before update'");
|
|
128
|
+
console.log(" ✓ Changes stashed.\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- capture old version + recent commits ---
|
|
132
|
+
const oldVersion = readVersion();
|
|
133
|
+
const oldSha = gitQuiet("rev-parse --short HEAD") ?? "unknown";
|
|
134
|
+
|
|
135
|
+
// --- pull ---
|
|
136
|
+
console.log(" Pulling latest changes...");
|
|
137
|
+
const pullOutput = gitQuiet("pull --ff-only");
|
|
138
|
+
if (pullOutput === null) {
|
|
139
|
+
console.error("\n❌ git pull failed (non-fast-forward?).");
|
|
140
|
+
console.error(" Resolve manually, then run: fapony update");
|
|
141
|
+
if (dirty) {
|
|
142
|
+
const popResult = gitQuiet("stash pop");
|
|
143
|
+
if (popResult === null) {
|
|
144
|
+
console.error(
|
|
145
|
+
"\n⚠ Your changes are still stashed (auto-restore failed, conflict likely).",
|
|
146
|
+
);
|
|
147
|
+
console.error(
|
|
148
|
+
" Run `git stash pop` manually to get them back — do NOT `git stash drop`.",
|
|
149
|
+
);
|
|
150
|
+
} else {
|
|
151
|
+
console.error(" ✓ Your stashed changes were restored.");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
exitFn(1);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- capture new version ---
|
|
158
|
+
const newVersion = readVersion();
|
|
159
|
+
const newSha = gitQuiet("rev-parse --short HEAD") ?? "unknown";
|
|
160
|
+
|
|
161
|
+
// --- restore stashed changes ---
|
|
162
|
+
if (dirty) {
|
|
163
|
+
const popResult = gitQuiet("stash pop");
|
|
164
|
+
if (popResult === null) {
|
|
165
|
+
console.log(
|
|
166
|
+
"\n ⚠ Could not auto-restore your stashed changes — run `git stash pop` manually (conflict likely).",
|
|
167
|
+
);
|
|
168
|
+
} else {
|
|
169
|
+
console.log(" ✓ Restored your stashed changes.");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- show what changed ---
|
|
174
|
+
if (isUpToDate(oldSha, newSha)) {
|
|
175
|
+
console.log(`\n ✓ Already up to date (${oldVersion} @ ${oldSha}).`);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
console.log(
|
|
180
|
+
`\n ✓ Updated ${oldVersion}@${oldSha} → ${newVersion}@${newSha}`,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// --- recent commits since old SHA ---
|
|
184
|
+
const logRange = gitQuiet(`log ${oldSha}..HEAD --oneline --no-decorate`);
|
|
185
|
+
if (logRange) {
|
|
186
|
+
console.log("\n Recent changes:\n");
|
|
187
|
+
for (const line of logRange.split("\n").slice(0, 10)) {
|
|
188
|
+
console.log(` ${line}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// --- re-install dev deps if lockfile changed ---
|
|
193
|
+
const lockChanged = gitQuiet("diff --name-only HEAD@{1} HEAD -- bun.lock");
|
|
194
|
+
if (lockChanged) {
|
|
195
|
+
console.log("\n Lockfile changed — running bun install...");
|
|
196
|
+
try {
|
|
197
|
+
installFn();
|
|
198
|
+
console.log(" ✓ Dependencies updated.");
|
|
199
|
+
} catch {
|
|
200
|
+
console.log(" ⚠ bun install failed — run manually: bun install");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
console.log(`
|
|
205
|
+
┌──────────────────────────────────────────┐
|
|
206
|
+
│ Update complete! │
|
|
207
|
+
│ │
|
|
208
|
+
│ Version: ${newVersion.padEnd(31)}│
|
|
209
|
+
│ Run "fapony test" to verify. │
|
|
210
|
+
└──────────────────────────────────────────┘
|
|
211
|
+
`);
|
|
212
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// src/usage/cache.ts — JSONL usage cache
|
|
2
|
+
//
|
|
3
|
+
// One line per client (opencode / zcode / claude_code / codex).
|
|
4
|
+
// Stored in the fapony state dir (~/.config/fapony/usage-cache.jsonl).
|
|
5
|
+
// Atomic write: write to .tmp then rename.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
existsSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
renameSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { faponyDir } from "../db/load.js";
|
|
16
|
+
import type { Config } from "../db/types.js";
|
|
17
|
+
|
|
18
|
+
const CACHE_FILENAME = "usage-cache.jsonl";
|
|
19
|
+
|
|
20
|
+
export interface CacheEntry {
|
|
21
|
+
client: string;
|
|
22
|
+
/** Worktree path this entry scopes to, or undefined/null for the global aggregate. */
|
|
23
|
+
worktree?: string | null;
|
|
24
|
+
scanned_at: string;
|
|
25
|
+
session_count: number;
|
|
26
|
+
total_tokens_input: number;
|
|
27
|
+
total_tokens_output: number;
|
|
28
|
+
total_tokens_reasoning: number;
|
|
29
|
+
total_tokens_cache_read: number;
|
|
30
|
+
total_tokens_cache_write: number;
|
|
31
|
+
total_cost: number;
|
|
32
|
+
by_model: {
|
|
33
|
+
model: string;
|
|
34
|
+
provider: string;
|
|
35
|
+
session_count: number;
|
|
36
|
+
tokens_input: number;
|
|
37
|
+
tokens_output: number;
|
|
38
|
+
tokens_reasoning: number;
|
|
39
|
+
tokens_cache_read: number;
|
|
40
|
+
tokens_cache_write: number;
|
|
41
|
+
cost: number;
|
|
42
|
+
}[];
|
|
43
|
+
/**
|
|
44
|
+
* Why this client's row is empty, when it is. Absent on a healthy scan.
|
|
45
|
+
*
|
|
46
|
+
* Carried so usage-web can tell "this client was never used" apart from
|
|
47
|
+
* "this client's log could not be read" — both are zero rows otherwise.
|
|
48
|
+
*/
|
|
49
|
+
error?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface CacheMeta {
|
|
53
|
+
scanned_at: string;
|
|
54
|
+
total_sessions: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function cachePath(config?: Config): string {
|
|
58
|
+
return join(faponyDir(config), CACHE_FILENAME);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Read all entries from the JSONL cache file. Returns [] when missing or empty. */
|
|
62
|
+
export function readCache(config?: Config): CacheEntry[] {
|
|
63
|
+
const p = cachePath(config);
|
|
64
|
+
if (!existsSync(p)) return [];
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const raw = readFileSync(p, "utf-8");
|
|
68
|
+
if (!raw.trim()) return [];
|
|
69
|
+
return raw
|
|
70
|
+
.split("\n")
|
|
71
|
+
.filter((l) => l.trim())
|
|
72
|
+
.map((l) => JSON.parse(l) as CacheEntry);
|
|
73
|
+
} catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Write entries to the cache file atomically (temp + rename).
|
|
80
|
+
* Creates the state dir when missing (fresh-machine first scan).
|
|
81
|
+
*/
|
|
82
|
+
export function writeCache(entries: CacheEntry[], config?: Config): void {
|
|
83
|
+
const p = cachePath(config);
|
|
84
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
85
|
+
const tmp = `${p}.tmp`;
|
|
86
|
+
const content = `${entries.map((e) => JSON.stringify(e)).join("\n")}\n`;
|
|
87
|
+
writeFileSync(tmp, content, "utf-8");
|
|
88
|
+
renameSync(tmp, p);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Merge new entry into existing cache entries.
|
|
93
|
+
* Dedup by `client + worktree` — last write wins (newest scanned_at wins).
|
|
94
|
+
*/
|
|
95
|
+
export function mergeEntries(
|
|
96
|
+
existing: CacheEntry[],
|
|
97
|
+
updated: CacheEntry[],
|
|
98
|
+
): CacheEntry[] {
|
|
99
|
+
const byKey = new Map<string, CacheEntry>();
|
|
100
|
+
for (const e of existing) byKey.set(`${e.client}\0${e.worktree ?? ""}`, e);
|
|
101
|
+
for (const e of updated) byKey.set(`${e.client}\0${e.worktree ?? ""}`, e);
|
|
102
|
+
return Array.from(byKey.values());
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Freshness + session total from cache.
|
|
107
|
+
*
|
|
108
|
+
* Totals come from the global (worktree-null) rows only — per-worktree rows
|
|
109
|
+
* are subsets of that aggregate, so summing everything double-counts
|
|
110
|
+
* (global 100 + projects 60 + 40 reported 200). Pre-dimension caches carry
|
|
111
|
+
* no worktree field at all, so when no global row exists every entry counts.
|
|
112
|
+
*/
|
|
113
|
+
export function cacheMeta(entries: CacheEntry[]): CacheMeta | null {
|
|
114
|
+
if (entries.length === 0) return null;
|
|
115
|
+
const scoped = entries.some((e) => (e.worktree ?? null) === null)
|
|
116
|
+
? entries.filter((e) => (e.worktree ?? null) === null)
|
|
117
|
+
: entries;
|
|
118
|
+
let oldest = scoped[0].scanned_at;
|
|
119
|
+
let total = 0;
|
|
120
|
+
for (const e of scoped) {
|
|
121
|
+
if (e.scanned_at < oldest) oldest = e.scanned_at;
|
|
122
|
+
total += e.session_count;
|
|
123
|
+
}
|
|
124
|
+
return { scanned_at: oldest, total_sessions: total };
|
|
125
|
+
}
|