@narumitw/pi-analytics 0.45.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 +205 -0
- package/package.json +49 -0
- package/src/analytics.ts +406 -0
- package/src/collector.ts +297 -0
- package/src/errors.ts +16 -0
- package/src/index.ts +1 -0
- package/src/menu.ts +372 -0
- package/src/skills.ts +83 -0
- package/src/storage/database.ts +126 -0
- package/src/storage/migrations.ts +257 -0
- package/src/storage/queries.ts +313 -0
- package/src/storage/store.ts +249 -0
- package/src/types.ts +102 -0
package/src/collector.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { classifyProviderError } from "./errors.js";
|
|
3
|
+
import type {
|
|
4
|
+
GenerationOutcome,
|
|
5
|
+
GenerationRecord,
|
|
6
|
+
ModelIdentity,
|
|
7
|
+
ProviderErrorRecord,
|
|
8
|
+
RunOutcome,
|
|
9
|
+
SettledRun,
|
|
10
|
+
SkillActivationRecord,
|
|
11
|
+
ToolCallRecord,
|
|
12
|
+
TriggerSource,
|
|
13
|
+
} from "./types.js";
|
|
14
|
+
|
|
15
|
+
interface ActiveRun {
|
|
16
|
+
id: string;
|
|
17
|
+
startedAtMs: number;
|
|
18
|
+
triggerSource: TriggerSource;
|
|
19
|
+
initialModel?: ModelIdentity;
|
|
20
|
+
attemptCount: number;
|
|
21
|
+
generations: GenerationRecord[];
|
|
22
|
+
generationIds: Set<string>;
|
|
23
|
+
tools: ToolCallRecord[];
|
|
24
|
+
toolIds: Set<string>;
|
|
25
|
+
skills: SkillActivationRecord[];
|
|
26
|
+
skillIndexes: Map<string, number>;
|
|
27
|
+
providerErrors: ProviderErrorRecord[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class ResponseCollector {
|
|
31
|
+
private active: ActiveRun | undefined;
|
|
32
|
+
|
|
33
|
+
hasActiveRun(): boolean {
|
|
34
|
+
return this.active !== undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
begin(input: {
|
|
38
|
+
id: string;
|
|
39
|
+
now: number;
|
|
40
|
+
triggerSource: TriggerSource;
|
|
41
|
+
model?: ModelIdentity;
|
|
42
|
+
}): SettledRun | undefined {
|
|
43
|
+
const interrupted = this.active ? this.finalize(input.now, "interrupted") : undefined;
|
|
44
|
+
this.active = {
|
|
45
|
+
id: input.id,
|
|
46
|
+
startedAtMs: input.now,
|
|
47
|
+
triggerSource: input.triggerSource,
|
|
48
|
+
initialModel: input.model,
|
|
49
|
+
attemptCount: 0,
|
|
50
|
+
generations: [],
|
|
51
|
+
generationIds: new Set(),
|
|
52
|
+
tools: [],
|
|
53
|
+
toolIds: new Set(),
|
|
54
|
+
skills: [],
|
|
55
|
+
skillIndexes: new Map(),
|
|
56
|
+
providerErrors: [],
|
|
57
|
+
};
|
|
58
|
+
return interrupted;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
beginAttempt(): void {
|
|
62
|
+
if (this.active) this.active.attemptCount += 1;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
beginGeneration(input: { id: string; now: number; model?: ModelIdentity }): void {
|
|
66
|
+
const active = this.active;
|
|
67
|
+
if (!active || active.generationIds.has(input.id)) return;
|
|
68
|
+
active.generationIds.add(input.id);
|
|
69
|
+
active.generations.push({
|
|
70
|
+
id: input.id,
|
|
71
|
+
ordinal: active.generations.length,
|
|
72
|
+
provider: input.model?.provider,
|
|
73
|
+
model: input.model?.model,
|
|
74
|
+
thinkingLevel: input.model?.thinkingLevel,
|
|
75
|
+
startedAtMs: input.now,
|
|
76
|
+
outcome: "pending",
|
|
77
|
+
responses: [],
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
recordProviderResponse(input: { status: number; now: number }): void {
|
|
82
|
+
const generation = this.latestGeneration();
|
|
83
|
+
if (generation?.outcome !== "pending") return;
|
|
84
|
+
generation.responses.push({
|
|
85
|
+
ordinal: generation.responses.length,
|
|
86
|
+
occurredAtMs: input.now,
|
|
87
|
+
status: input.status,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
finishGeneration(input: { now: number; stopReason: string; errorMessage?: string }): void {
|
|
92
|
+
const active = this.active;
|
|
93
|
+
const generation = this.latestGeneration();
|
|
94
|
+
if (!active || !generation || generation.outcome !== "pending") return;
|
|
95
|
+
generation.finishedAtMs = input.now;
|
|
96
|
+
generation.durationMs = elapsed(generation.startedAtMs, input.now);
|
|
97
|
+
generation.stopReason = input.stopReason;
|
|
98
|
+
generation.outcome = generationOutcome(input.stopReason);
|
|
99
|
+
if (generation.outcome === "error") {
|
|
100
|
+
active.providerErrors.push({
|
|
101
|
+
id: randomUUID(),
|
|
102
|
+
generationId: generation.id,
|
|
103
|
+
occurredAtMs: input.now,
|
|
104
|
+
provider: generation.provider,
|
|
105
|
+
model: generation.model,
|
|
106
|
+
category: classifyProviderError(input.errorMessage),
|
|
107
|
+
recovered: false,
|
|
108
|
+
terminal: true,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
beginTool(input: { id: string; name: string; now: number; model?: ModelIdentity }): void {
|
|
114
|
+
const active = this.active;
|
|
115
|
+
if (!active || active.toolIds.has(input.id)) return;
|
|
116
|
+
active.toolIds.add(input.id);
|
|
117
|
+
active.tools.push({
|
|
118
|
+
id: input.id,
|
|
119
|
+
ordinal: active.tools.length,
|
|
120
|
+
name: input.name,
|
|
121
|
+
provider: input.model?.provider,
|
|
122
|
+
model: input.model?.model,
|
|
123
|
+
startedAtMs: input.now,
|
|
124
|
+
isError: false,
|
|
125
|
+
completionState: "running",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
finishTool(input: { id: string; now: number; isError: boolean }): void {
|
|
130
|
+
const tool = this.active?.tools.find(({ id }) => id === input.id);
|
|
131
|
+
if (tool?.completionState !== "running") return;
|
|
132
|
+
tool.finishedAtMs = input.now;
|
|
133
|
+
tool.durationMs = elapsed(tool.startedAtMs, input.now);
|
|
134
|
+
tool.isError = input.isError;
|
|
135
|
+
tool.completionState = "finished";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
activateSkill(input: {
|
|
139
|
+
name: string;
|
|
140
|
+
initiatedBy: "user" | "model";
|
|
141
|
+
now: number;
|
|
142
|
+
model?: ModelIdentity;
|
|
143
|
+
}): void {
|
|
144
|
+
const active = this.active;
|
|
145
|
+
if (!active) return;
|
|
146
|
+
const existingIndex = active.skillIndexes.get(input.name);
|
|
147
|
+
if (existingIndex !== undefined) {
|
|
148
|
+
const existing = active.skills[existingIndex];
|
|
149
|
+
if (existing && existing.initiatedBy === "model" && input.initiatedBy === "user") {
|
|
150
|
+
existing.initiatedBy = "user";
|
|
151
|
+
existing.occurredAtMs = input.now;
|
|
152
|
+
existing.provider = input.model?.provider;
|
|
153
|
+
existing.model = input.model?.model;
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
active.skillIndexes.set(input.name, active.skills.length);
|
|
158
|
+
active.skills.push({
|
|
159
|
+
id: randomUUID(),
|
|
160
|
+
name: input.name,
|
|
161
|
+
initiatedBy: input.initiatedBy,
|
|
162
|
+
occurredAtMs: input.now,
|
|
163
|
+
provider: input.model?.provider,
|
|
164
|
+
model: input.model?.model,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
settle(now: number): SettledRun | undefined {
|
|
169
|
+
return this.finalize(now);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interrupt(now: number): SettledRun | undefined {
|
|
173
|
+
return this.finalize(now, "interrupted");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private latestGeneration(): GenerationRecord | undefined {
|
|
177
|
+
return this.active?.generations.at(-1);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private finalize(now: number, forcedOutcome?: RunOutcome): SettledRun | undefined {
|
|
181
|
+
const active = this.active;
|
|
182
|
+
if (!active) return undefined;
|
|
183
|
+
this.active = undefined;
|
|
184
|
+
|
|
185
|
+
for (const generation of active.generations) {
|
|
186
|
+
if (generation.outcome !== "pending") continue;
|
|
187
|
+
generation.outcome = "interrupted";
|
|
188
|
+
generation.finishedAtMs = now;
|
|
189
|
+
generation.durationMs = elapsed(generation.startedAtMs, now);
|
|
190
|
+
}
|
|
191
|
+
for (const tool of active.tools) {
|
|
192
|
+
if (tool.completionState !== "running") continue;
|
|
193
|
+
tool.completionState = "interrupted";
|
|
194
|
+
tool.finishedAtMs = now;
|
|
195
|
+
tool.durationMs = elapsed(tool.startedAtMs, now);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const successfulGenerationIndexes = new Set(
|
|
199
|
+
active.generations
|
|
200
|
+
.map((generation, index) => ({ generation, index }))
|
|
201
|
+
.filter(({ generation }) => isSuccessfulGeneration(generation))
|
|
202
|
+
.map(({ index }) => index),
|
|
203
|
+
);
|
|
204
|
+
let recoveredHttpErrors = 0;
|
|
205
|
+
let httpErrors = 0;
|
|
206
|
+
for (const [generationIndex, generation] of active.generations.entries()) {
|
|
207
|
+
const hasLaterSuccess = [...successfulGenerationIndexes].some(
|
|
208
|
+
(index) => index > generationIndex,
|
|
209
|
+
);
|
|
210
|
+
for (const [responseIndex, response] of generation.responses.entries()) {
|
|
211
|
+
if (response.status < 400) continue;
|
|
212
|
+
httpErrors += 1;
|
|
213
|
+
const laterSuccessInGeneration = generation.responses
|
|
214
|
+
.slice(responseIndex + 1)
|
|
215
|
+
.some(({ status }) => status >= 200 && status < 400);
|
|
216
|
+
if (laterSuccessInGeneration || hasLaterSuccess) recoveredHttpErrors += 1;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const error of active.providerErrors) {
|
|
220
|
+
const generationIndex = active.generations.findIndex(({ id }) => id === error.generationId);
|
|
221
|
+
error.recovered = [...successfulGenerationIndexes].some((index) => index > generationIndex);
|
|
222
|
+
error.terminal = !error.recovered;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const recoveredGenerationErrors = active.providerErrors.filter(
|
|
226
|
+
({ recovered }) => recovered,
|
|
227
|
+
).length;
|
|
228
|
+
const providerErrorCount = httpErrors + active.providerErrors.length;
|
|
229
|
+
const recoveredErrorCount = recoveredHttpErrors + recoveredGenerationErrors;
|
|
230
|
+
const outcome = forcedOutcome ?? deriveOutcome(active.generations, providerErrorCount);
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
id: active.id,
|
|
234
|
+
startedAtMs: active.startedAtMs,
|
|
235
|
+
finishedAtMs: now,
|
|
236
|
+
durationMs: elapsed(active.startedAtMs, now),
|
|
237
|
+
triggerSource: active.triggerSource,
|
|
238
|
+
initialProvider: active.initialModel?.provider,
|
|
239
|
+
initialModel: active.initialModel?.model,
|
|
240
|
+
outcome,
|
|
241
|
+
attemptCount: active.attemptCount,
|
|
242
|
+
generations: active.generations,
|
|
243
|
+
tools: active.tools,
|
|
244
|
+
skills: active.skills,
|
|
245
|
+
providerErrors: active.providerErrors,
|
|
246
|
+
toolErrorCount: active.tools.filter(({ isError }) => isError).length,
|
|
247
|
+
providerErrorCount,
|
|
248
|
+
recoveredErrorCount,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function elapsed(start: number, end: number): number {
|
|
254
|
+
return Math.max(0, end - start);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function generationOutcome(stopReason: string): GenerationOutcome {
|
|
258
|
+
switch (stopReason) {
|
|
259
|
+
case "stop":
|
|
260
|
+
return "stop";
|
|
261
|
+
case "toolUse":
|
|
262
|
+
return "tool_use";
|
|
263
|
+
case "error":
|
|
264
|
+
return "error";
|
|
265
|
+
case "aborted":
|
|
266
|
+
return "aborted";
|
|
267
|
+
case "length":
|
|
268
|
+
return "length";
|
|
269
|
+
default:
|
|
270
|
+
return "interrupted";
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function isSuccessfulGeneration(generation: GenerationRecord): boolean {
|
|
275
|
+
return generation.outcome === "stop" || generation.outcome === "tool_use";
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function deriveOutcome(
|
|
279
|
+
generations: readonly GenerationRecord[],
|
|
280
|
+
providerErrors: number,
|
|
281
|
+
): RunOutcome {
|
|
282
|
+
const last = generations.at(-1);
|
|
283
|
+
if (!last) return providerErrors > 0 ? "error" : "success";
|
|
284
|
+
switch (last.outcome) {
|
|
285
|
+
case "stop":
|
|
286
|
+
case "tool_use":
|
|
287
|
+
return providerErrors > 0 ? "recovered_success" : "success";
|
|
288
|
+
case "error":
|
|
289
|
+
return "error";
|
|
290
|
+
case "aborted":
|
|
291
|
+
return "aborted";
|
|
292
|
+
case "length":
|
|
293
|
+
return "length";
|
|
294
|
+
default:
|
|
295
|
+
return "interrupted";
|
|
296
|
+
}
|
|
297
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ProviderErrorCategory } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function classifyProviderError(message: string | undefined): ProviderErrorCategory {
|
|
4
|
+
const value = message?.toLowerCase() ?? "";
|
|
5
|
+
if (/\b(enotfound|eai_again|dns|getaddrinfo)\b/u.test(value)) return "dns";
|
|
6
|
+
if (/\b(etimedout|timeout|timed out)\b/u.test(value)) return "timeout";
|
|
7
|
+
if (/\b(econnrefused|connection refused)\b/u.test(value)) return "connection_refused";
|
|
8
|
+
if (/\b(econnreset|connection reset|socket hang up)\b/u.test(value)) {
|
|
9
|
+
return "connection_reset";
|
|
10
|
+
}
|
|
11
|
+
if (/\b(tls|ssl|certificate|cert_|handshake)\b/u.test(value)) return "tls";
|
|
12
|
+
if (/\b(fetch failed|network|socket|connection|transport)\b/u.test(value)) {
|
|
13
|
+
return "network_other";
|
|
14
|
+
}
|
|
15
|
+
return "provider_other";
|
|
16
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./analytics.js";
|
package/src/menu.ts
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { defineMenu, runMenu, runTask } from "@narumitw/pi-tui-kit";
|
|
3
|
+
import type {
|
|
4
|
+
AnalyticsSnapshot,
|
|
5
|
+
SkillStats,
|
|
6
|
+
TimeRange,
|
|
7
|
+
TimeRangeId,
|
|
8
|
+
ToolStats,
|
|
9
|
+
} from "./storage/queries.js";
|
|
10
|
+
import { resolveTimeRange } from "./storage/queries.js";
|
|
11
|
+
|
|
12
|
+
export type AnalyticsLoadResult =
|
|
13
|
+
| { kind: "ready"; snapshot: AnalyticsSnapshot }
|
|
14
|
+
| { kind: "unavailable"; message: string };
|
|
15
|
+
|
|
16
|
+
export interface AnalyticsMenuDataSource {
|
|
17
|
+
path: string;
|
|
18
|
+
load(range: TimeRange, signal: AbortSignal): Promise<AnalyticsLoadResult>;
|
|
19
|
+
clearAll(signal: AbortSignal): Promise<number>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AnalyticsMenuState {
|
|
23
|
+
rangeId: TimeRangeId;
|
|
24
|
+
range: TimeRange;
|
|
25
|
+
path: string;
|
|
26
|
+
result: AnalyticsLoadResult;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Screen = "main" | "range" | "skills" | "tools" | "reliability" | "responses" | "privacy";
|
|
30
|
+
type Action = "setRange" | "clearData";
|
|
31
|
+
|
|
32
|
+
const RANGE_LABELS: Record<TimeRangeId, string> = {
|
|
33
|
+
today: "Today",
|
|
34
|
+
"7d": "Last 7 days",
|
|
35
|
+
"30d": "Last 30 days",
|
|
36
|
+
all: "All time",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function createAnalyticsMenu(source: AnalyticsMenuDataSource, now: () => number = Date.now) {
|
|
40
|
+
let rangeId: TimeRangeId = "7d";
|
|
41
|
+
let cachedState: AnalyticsMenuState | undefined;
|
|
42
|
+
const loadState = async (signal: AbortSignal): Promise<AnalyticsMenuState> => {
|
|
43
|
+
if (cachedState?.rangeId === rangeId) return cachedState;
|
|
44
|
+
const range = resolveTimeRange(rangeId, now());
|
|
45
|
+
const loaded = { rangeId, range, path: source.path, result: await source.load(range, signal) };
|
|
46
|
+
if (!signal.aborted && rangeId === loaded.rangeId) cachedState = loaded;
|
|
47
|
+
return loaded;
|
|
48
|
+
};
|
|
49
|
+
const getState = ({ signal }: { signal: AbortSignal }): Promise<AnalyticsMenuState> =>
|
|
50
|
+
loadState(signal);
|
|
51
|
+
const menu = defineMenu<AnalyticsMenuState, Screen, Action>({
|
|
52
|
+
start: "main",
|
|
53
|
+
screens: {
|
|
54
|
+
main: ({ state }) => ({
|
|
55
|
+
kind: "actions",
|
|
56
|
+
title: `Analytics · ${RANGE_LABELS[state.rangeId]}`,
|
|
57
|
+
lines: overviewLines(state.result),
|
|
58
|
+
items: [
|
|
59
|
+
{ id: "range", label: "Change time range", to: "range" },
|
|
60
|
+
{ id: "skills", label: "Skills", to: "skills" },
|
|
61
|
+
{ id: "tools", label: "Tools", to: "tools" },
|
|
62
|
+
{ id: "reliability", label: "Provider reliability", to: "reliability" },
|
|
63
|
+
{ id: "responses", label: "Response cycles", to: "responses" },
|
|
64
|
+
{ id: "privacy", label: "Data & privacy", to: "privacy" },
|
|
65
|
+
{ id: "close", label: "Close", close: true },
|
|
66
|
+
],
|
|
67
|
+
hint: "close",
|
|
68
|
+
}),
|
|
69
|
+
range: ({ state }) => ({
|
|
70
|
+
kind: "choice",
|
|
71
|
+
title: "Analytics time range",
|
|
72
|
+
items: (Object.keys(RANGE_LABELS) as TimeRangeId[]).map((id) => ({
|
|
73
|
+
id,
|
|
74
|
+
label: RANGE_LABELS[id],
|
|
75
|
+
})),
|
|
76
|
+
action: "setRange",
|
|
77
|
+
currentItemId: state.rangeId,
|
|
78
|
+
initialItemId: state.rangeId,
|
|
79
|
+
hint: "back",
|
|
80
|
+
}),
|
|
81
|
+
skills: ({ state }) => skillsScreen(state.result),
|
|
82
|
+
tools: ({ state }) => toolsScreen(state.result),
|
|
83
|
+
reliability: ({ state }) => ({
|
|
84
|
+
kind: "detail",
|
|
85
|
+
title: `Provider reliability · ${RANGE_LABELS[state.rangeId]}`,
|
|
86
|
+
lines: reliabilityLines(state.result),
|
|
87
|
+
hint: "back",
|
|
88
|
+
}),
|
|
89
|
+
responses: ({ state }) => ({
|
|
90
|
+
kind: "detail",
|
|
91
|
+
title: `Response cycles · ${RANGE_LABELS[state.rangeId]}`,
|
|
92
|
+
lines: responseLines(state.result),
|
|
93
|
+
hint: "back",
|
|
94
|
+
}),
|
|
95
|
+
privacy: ({ state }) => ({
|
|
96
|
+
kind: "actions",
|
|
97
|
+
title: "Analytics data & privacy",
|
|
98
|
+
lines: privacyLines(state),
|
|
99
|
+
items: [
|
|
100
|
+
{
|
|
101
|
+
id: "clear",
|
|
102
|
+
label: "Clear analytics data…",
|
|
103
|
+
action: "clearData",
|
|
104
|
+
disabled: state.result.kind !== "ready",
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
hint: "back",
|
|
108
|
+
}),
|
|
109
|
+
},
|
|
110
|
+
actions: {
|
|
111
|
+
setRange: async ({ itemId, signal }) => {
|
|
112
|
+
if (!isRangeId(itemId)) return { kind: "rejected", error: new Error("Unknown range") };
|
|
113
|
+
rangeId = itemId;
|
|
114
|
+
cachedState = undefined;
|
|
115
|
+
await loadState(signal);
|
|
116
|
+
return signal.aborted ? { kind: "close" } : { kind: "to", screen: "main" };
|
|
117
|
+
},
|
|
118
|
+
clearData: async ({ ctx, state, signal }) => {
|
|
119
|
+
if (state.result.kind !== "ready") return { kind: "stay" };
|
|
120
|
+
const count = state.result.snapshot.overview.responseCycles;
|
|
121
|
+
const confirmed = await ctx.ui.confirm(
|
|
122
|
+
"Delete analytics data?",
|
|
123
|
+
`This will delete ${count} response cycles and their tool, skill, and reliability records from:\n\n${state.path}\n\nOther running Pi processes may add new records afterward.`,
|
|
124
|
+
{ signal },
|
|
125
|
+
);
|
|
126
|
+
if (!confirmed || signal.aborted) return { kind: "stay" };
|
|
127
|
+
const deleted = await source.clearAll(signal);
|
|
128
|
+
cachedState = undefined;
|
|
129
|
+
try {
|
|
130
|
+
ctx.ui.notify(`Deleted ${deleted} response cycles from local analytics.`, "info");
|
|
131
|
+
} catch {
|
|
132
|
+
// Session replacement can invalidate the UI after the database commit.
|
|
133
|
+
}
|
|
134
|
+
return signal.aborted ? { kind: "close" } : { kind: "to", screen: "main" };
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
return {
|
|
139
|
+
menu,
|
|
140
|
+
getState,
|
|
141
|
+
preload: (signal: AbortSignal) => loadState(signal),
|
|
142
|
+
get rangeId() {
|
|
143
|
+
return rangeId;
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function showAnalyticsMenu(
|
|
149
|
+
ctx: ExtensionCommandContext,
|
|
150
|
+
source: AnalyticsMenuDataSource,
|
|
151
|
+
options: { signal: AbortSignal; isCurrent: () => boolean },
|
|
152
|
+
): Promise<void> {
|
|
153
|
+
const controller = createAnalyticsMenu(source);
|
|
154
|
+
const loading = await runTask(ctx, {
|
|
155
|
+
label: "Loading local analytics…",
|
|
156
|
+
signal: options.signal,
|
|
157
|
+
isCurrent: options.isCurrent,
|
|
158
|
+
task: ({ signal }) => controller.preload(signal),
|
|
159
|
+
onError: () => undefined,
|
|
160
|
+
});
|
|
161
|
+
if (loading.kind !== "completed") {
|
|
162
|
+
if (loading.kind === "error") {
|
|
163
|
+
ctx.ui.notify(
|
|
164
|
+
"Analytics failed: The local analytics query could not be completed. Existing data was not changed.",
|
|
165
|
+
"error",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
await runMenu(ctx, controller.menu, {
|
|
171
|
+
getState: controller.getState,
|
|
172
|
+
signal: options.signal,
|
|
173
|
+
isCurrent: options.isCurrent,
|
|
174
|
+
onError: (_ctx, error) => {
|
|
175
|
+
ctx.ui.notify(`Analytics failed: ${safeErrorMessage(error)}`, "error");
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function overviewLines(result: AnalyticsLoadResult): string[] {
|
|
181
|
+
if (result.kind === "unavailable") {
|
|
182
|
+
return [result.message, "", "No analytics are being collected."];
|
|
183
|
+
}
|
|
184
|
+
const stats = result.snapshot.overview;
|
|
185
|
+
if (stats.responseCycles === 0) {
|
|
186
|
+
return [
|
|
187
|
+
"No analytics yet.",
|
|
188
|
+
"Collection is active. Complete one Pi response cycle, then open /analytics again.",
|
|
189
|
+
"",
|
|
190
|
+
"Includes settled response cycles only.",
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
return [
|
|
194
|
+
metric("Response cycles", stats.responseCycles),
|
|
195
|
+
metric("LLM calls", stats.llmCalls),
|
|
196
|
+
metric(
|
|
197
|
+
"Calls per response",
|
|
198
|
+
`${formatDecimal(stats.callsPerResponse)} · P95 ${stats.p95CallsPerResponse}`,
|
|
199
|
+
),
|
|
200
|
+
metric("Tool calls", stats.toolCalls),
|
|
201
|
+
metric("Tool errors", stats.toolErrors),
|
|
202
|
+
metric("Skill activations", stats.skillActivations),
|
|
203
|
+
metric("Provider errors", stats.providerErrors),
|
|
204
|
+
metric("Recovered errors", stats.recoveredErrors),
|
|
205
|
+
"",
|
|
206
|
+
"Includes settled response cycles only.",
|
|
207
|
+
];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function skillsScreen(result: AnalyticsLoadResult) {
|
|
211
|
+
if (result.kind === "unavailable") {
|
|
212
|
+
return {
|
|
213
|
+
kind: "browse" as const,
|
|
214
|
+
title: "Skills",
|
|
215
|
+
lines: [result.message],
|
|
216
|
+
items: [],
|
|
217
|
+
hint: "back" as const,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
kind: "browse" as const,
|
|
222
|
+
title: "Skills",
|
|
223
|
+
lines:
|
|
224
|
+
result.snapshot.skills.length === 0
|
|
225
|
+
? ["No skill activations detected in this time range."]
|
|
226
|
+
: undefined,
|
|
227
|
+
items: result.snapshot.skills.map(skillItem),
|
|
228
|
+
viewportSize: "adaptive" as const,
|
|
229
|
+
hint: "back" as const,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function skillItem(skill: SkillStats) {
|
|
234
|
+
return {
|
|
235
|
+
id: skill.name,
|
|
236
|
+
label: skill.name,
|
|
237
|
+
statusText: `${skill.count} · ${skill.modelInitiated} model / ${skill.userInitiated} user`,
|
|
238
|
+
searchText: skill.models.map(modelLabel).join(" "),
|
|
239
|
+
details: [
|
|
240
|
+
metric("Activations", skill.count),
|
|
241
|
+
metric("Model initiated", skill.modelInitiated),
|
|
242
|
+
metric("User initiated", skill.userInitiated),
|
|
243
|
+
"",
|
|
244
|
+
"By model",
|
|
245
|
+
...skill.models.map((model) => `${modelLabel(model)}: ${model.count}`),
|
|
246
|
+
"",
|
|
247
|
+
`Last detected: ${formatTimestamp(skill.lastOccurredAtMs)}`,
|
|
248
|
+
],
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function toolsScreen(result: AnalyticsLoadResult) {
|
|
253
|
+
if (result.kind === "unavailable") {
|
|
254
|
+
return {
|
|
255
|
+
kind: "browse" as const,
|
|
256
|
+
title: "Tools",
|
|
257
|
+
lines: [result.message],
|
|
258
|
+
items: [],
|
|
259
|
+
hint: "back" as const,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
kind: "browse" as const,
|
|
264
|
+
title: "Tools",
|
|
265
|
+
lines:
|
|
266
|
+
result.snapshot.tools.length === 0
|
|
267
|
+
? ["No tool calls detected in this time range."]
|
|
268
|
+
: undefined,
|
|
269
|
+
items: result.snapshot.tools.map(toolItem),
|
|
270
|
+
viewportSize: "adaptive" as const,
|
|
271
|
+
hint: "back" as const,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function toolItem(tool: ToolStats) {
|
|
276
|
+
return {
|
|
277
|
+
id: tool.name,
|
|
278
|
+
label: tool.name,
|
|
279
|
+
statusText: `${tool.count} · ${tool.errors} errors`,
|
|
280
|
+
searchText: tool.models.map(modelLabel).join(" "),
|
|
281
|
+
details: [
|
|
282
|
+
metric("Calls", tool.count),
|
|
283
|
+
metric("Errors", tool.errors),
|
|
284
|
+
`Average duration: ${formatDecimal(tool.averageDurationMs)} ms`,
|
|
285
|
+
"",
|
|
286
|
+
"By model",
|
|
287
|
+
...tool.models.map((model) => `${modelLabel(model)}: ${model.count}`),
|
|
288
|
+
"",
|
|
289
|
+
`Last detected: ${formatTimestamp(tool.lastOccurredAtMs)}`,
|
|
290
|
+
],
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function reliabilityLines(result: AnalyticsLoadResult): string[] {
|
|
295
|
+
if (result.kind === "unavailable") return [result.message];
|
|
296
|
+
const value = result.snapshot.reliability;
|
|
297
|
+
return [
|
|
298
|
+
"Observed provider errors only; provider-internal failures may be invisible.",
|
|
299
|
+
"",
|
|
300
|
+
metric("HTTP 429", value.http429),
|
|
301
|
+
metric("HTTP 5xx", value.http5xx),
|
|
302
|
+
metric("DNS", value.categories.dns),
|
|
303
|
+
metric("Connection timeout", value.categories.timeout),
|
|
304
|
+
metric("Connection refused", value.categories.connection_refused),
|
|
305
|
+
metric("Connection reset", value.categories.connection_reset),
|
|
306
|
+
metric("TLS", value.categories.tls),
|
|
307
|
+
metric("Other network", value.categories.network_other),
|
|
308
|
+
metric("Other provider", value.categories.provider_other),
|
|
309
|
+
"",
|
|
310
|
+
metric("Recovered", value.recovered),
|
|
311
|
+
metric("Terminal failures", value.terminal),
|
|
312
|
+
];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function responseLines(result: AnalyticsLoadResult): string[] {
|
|
316
|
+
if (result.kind === "unavailable") return [result.message];
|
|
317
|
+
const value = result.snapshot.responses;
|
|
318
|
+
return [
|
|
319
|
+
metric("Cycles", value.count),
|
|
320
|
+
metric("LLM calls", value.llmCalls),
|
|
321
|
+
metric("Average", formatDecimal(value.average)),
|
|
322
|
+
metric("Median", formatDecimal(value.median)),
|
|
323
|
+
metric("P95", value.p95),
|
|
324
|
+
metric("Maximum", value.maximum),
|
|
325
|
+
"",
|
|
326
|
+
"Calls per response",
|
|
327
|
+
metric("1 call", value.distribution.one),
|
|
328
|
+
metric("2–3 calls", value.distribution.twoToThree),
|
|
329
|
+
metric("4–6 calls", value.distribution.fourToSix),
|
|
330
|
+
metric("7+ calls", value.distribution.sevenPlus),
|
|
331
|
+
];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function privacyLines(state: AnalyticsMenuState): string[] {
|
|
335
|
+
return [
|
|
336
|
+
"Local database:",
|
|
337
|
+
state.path,
|
|
338
|
+
"",
|
|
339
|
+
"Stored: timestamps, model/provider IDs, thinking level, tool and skill names, durations, counts, HTTP statuses, and classified errors.",
|
|
340
|
+
"Not stored: prompts, responses, thinking, tool arguments/results, raw errors, headers, cwd/file paths, session identity, or credentials.",
|
|
341
|
+
"",
|
|
342
|
+
"No Turso Cloud connection or other remote telemetry is used.",
|
|
343
|
+
"Turso Database is pre-1.0; analytics are non-critical derived metadata.",
|
|
344
|
+
];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function metric(label: string, value: string | number): string {
|
|
348
|
+
return `${label.padEnd(24)} ${value}`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function formatDecimal(value: number): string {
|
|
352
|
+
return Number.isInteger(value)
|
|
353
|
+
? String(value)
|
|
354
|
+
: value.toFixed(2).replace(/0+$/u, "").replace(/\.$/u, "");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function formatTimestamp(value: number): string {
|
|
358
|
+
return new Date(value).toLocaleString();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function modelLabel(model: { provider?: string; model?: string }): string {
|
|
362
|
+
if (!model.provider && !model.model) return "unknown";
|
|
363
|
+
return `${model.provider ?? "unknown"}/${model.model ?? "unknown"}`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function isRangeId(value: string): value is TimeRangeId {
|
|
367
|
+
return value === "today" || value === "7d" || value === "30d" || value === "all";
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function safeErrorMessage(_error: unknown): string {
|
|
371
|
+
return "The local analytics query could not be completed. Try again; existing data was not changed.";
|
|
372
|
+
}
|