@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 narumiruna
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,205 @@
1
+ # ๐Ÿ“ˆ pi-analytics โ€” Local Analytics for Pi
2
+
3
+ [![npm](https://img.shields.io/npm/v/@narumitw/pi-analytics)](https://www.npmjs.com/package/@narumitw/pi-analytics) [![Pi extension](https://img.shields.io/badge/Pi-extension-blue)](https://pi.dev) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
4
+
5
+ > [!WARNING]
6
+ > This extension is experimental. Its metrics, storage format, and dashboard may change between releases.
7
+
8
+ `@narumitw/pi-analytics` is a local-first [Pi coding agent](https://pi.dev) extension that counts model calls, skill activations, tool activity, and observed provider errors without storing conversation or tool content.
9
+
10
+ ## โœจ Features
11
+
12
+ - Starts collecting settled Pi response cycles after installation with no configuration.
13
+ - Breaks skill activations down by explicit user invocation, model loading, provider, and model.
14
+ - Counts tool calls, failures, average duration, and model attribution.
15
+ - Reports logical LLM calls per response with average, median, P95, maximum, and distribution buckets.
16
+ - Separates HTTP 429/5xx responses, conservative connection-error categories, recovered errors, and terminal provider failures.
17
+ - Offers Today, rolling 7-day, rolling 30-day, and all-time views through one `/analytics` TUI/RPC dashboard.
18
+ - Stores only content-free metadata in one private local Turso Database.
19
+ - Uses forward-only, checksummed, transactional schema migrations and fails closed on unknown newer schemas.
20
+ - Never starts a server, contacts Turso Cloud, or sends analytics anywhere.
21
+
22
+ ## ๐Ÿ“ฆ Install
23
+
24
+ Install persistently:
25
+
26
+ ```bash
27
+ pi install npm:@narumitw/pi-analytics
28
+ ```
29
+
30
+ Try the published package without installing:
31
+
32
+ ```bash
33
+ pi -e npm:@narumitw/pi-analytics
34
+ ```
35
+
36
+ Try a local checkout from the repository root:
37
+
38
+ ```bash
39
+ pi -e ./experimental/pi-analytics
40
+ ```
41
+
42
+ ### Supported platforms
43
+
44
+ The embedded `@tursodatabase/database` dependency currently publishes native binaries for:
45
+
46
+ - Linux x64 and arm64 with glibc;
47
+ - macOS arm64; and
48
+ - Windows x64.
49
+
50
+ On another platform, Pi still loads the extension and `/analytics` remains available, but collection is disabled and the dashboard explains the supported platform boundary. The database engine is pre-1.0. Analytics are treated as non-critical derived metadata. If the history itself matters, stop every Pi process using the extension and back up both `pi-analytics.db` and `pi-analytics.db-wal`; copying only the main file is not a complete backup.
51
+
52
+ ## ๐Ÿš€ Quick start
53
+
54
+ Complete at least one Pi response, then run:
55
+
56
+ ```text
57
+ /analytics
58
+ ```
59
+
60
+ The default overview covers the last seven rolling days:
61
+
62
+ ```text
63
+ Analytics ยท Last 7 days
64
+
65
+ Response cycles 83
66
+ LLM calls 192
67
+ Calls per response 2.31 ยท P95 6
68
+ Tool calls 414
69
+ Tool errors 7
70
+ Skill activations 31
71
+ Provider errors 4
72
+ Recovered errors 3
73
+ ```
74
+
75
+ Use the menu to change the time range or browse Skills, Tools, Provider reliability, Response cycles, and Data & privacy. Only fully settled cycles are included; active work is omitted until Pi settles.
76
+
77
+ ## ๐Ÿ“ Metric definitions
78
+
79
+ ### Response cycles and LLM calls
80
+
81
+ A **response cycle** starts when Pi begins agent work and ends at `agent_settled`. Automatic retries, overflow-compaction recovery, tool follow-ups, and queued continuations before settlement stay in that cycle.
82
+
83
+ An **LLM call** is one logical provider generation. A provider may make several HTTP attempts inside it, so `429 โ†’ 429 โ†’ 200` is one LLM call, three observed HTTP responses, two provider errors, and a recovered generation.
84
+
85
+ ### Skills
86
+
87
+ An activation is **User initiated** when an observed interactive or RPC `/skill:<name>` input is associated with an active or subsequently started response cycle. This includes skill commands queued while Pi is streaming. It is **Model initiated** when the built-in `read` tool successfully loads the exact canonical `SKILL.md` path Pi discovered. A skill is counted at most once per response cycle, and explicit user use takes precedence.
88
+
89
+ Pi does not expose a first-class skill-invocation event or a post-chain acceptance event for input observers. Non-standard loading such as `bash` plus `cat SKILL.md`, unsuccessful reads, and provider behavior invisible to Pi are not counted; an explicit skill input intercepted later by another extension while a response is active may still be observed.
90
+
91
+ ### Tools
92
+
93
+ A tool call starts at Pi's `tool_execution_start` event and finishes at `tool_execution_end`. The extension stores the tool name, model attribution, timing, completion state, and final error flag. It cannot reliably distinguish another extension blocking a call from every other tool error, so the MVP reports both as errors rather than claiming a separate blocked count.
94
+
95
+ ### Provider reliability
96
+
97
+ Pi exposes HTTP responses and final assistant failures, not every provider-SDK transport retry. The dashboard therefore labels these values as **observed provider errors**. It reports:
98
+
99
+ - HTTP 429 and 5xx counts;
100
+ - DNS, timeout, connection-refused, connection-reset, TLS, other-network, and other-provider categories;
101
+ - recovered errors; and
102
+ - terminal failures.
103
+
104
+ Error messages are classified in memory and discarded. Raw error text is never stored.
105
+
106
+ ## ๐Ÿ’ฌ Command
107
+
108
+ ```text
109
+ /analytics
110
+ ```
111
+
112
+ The command accepts no arguments. TUI mode uses the full dashboard; RPC mode adapts the same standard screens to dialogs. Print and JSON modes reject the interactive command observably instead of writing ad hoc protocol output.
113
+
114
+ The root menu contains seven actions:
115
+
116
+ ```text
117
+ Change time range
118
+ Skills
119
+ Tools
120
+ Provider reliability
121
+ Response cycles
122
+ Data & privacy
123
+ Close
124
+ ```
125
+
126
+ Skills and Tools are searchable browse views with details and model breakdowns. Escape goes Back from nested screens and closes the root. Ctrl+C closes the menu. Cancelling data deletion has no side effects.
127
+
128
+ ## ๐Ÿ” Local data and privacy
129
+
130
+ The database is stored at:
131
+
132
+ ```text
133
+ <pi-agent-directory>/pi-analytics.db
134
+ ```
135
+
136
+ On Unix, the extension pre-creates and restricts the database and WAL files to mode `0600`, repairs their permissions after migration, and refuses linked database files.
137
+
138
+ Stored fields are limited to:
139
+
140
+ - timestamps and durations;
141
+ - provider/model IDs and thinking level;
142
+ - tool and skill names;
143
+ - user/model skill source;
144
+ - counts, outcomes, and completion states;
145
+ - HTTP status codes; and
146
+ - classified provider-error categories.
147
+
148
+ The extension does **not** store:
149
+
150
+ - prompts, responses, or thinking content;
151
+ - tool arguments or results;
152
+ - raw error messages or HTTP headers;
153
+ - cwd, project names, file paths, session names, or Pi session IDs; or
154
+ - credentials.
155
+
156
+ It imports only the local embedded database package. It does not install `@tursodatabase/sync`, request Turso credentials, contact Turso Cloud, or perform any other remote telemetry.
157
+
158
+ Choose **Data & privacy โ†’ Clear analytics dataโ€ฆ** to transactionally remove all currently committed response, model, skill, tool, and reliability observations. Migration history remains so the valid schema can continue to be used. Another running Pi process may commit a newly settled response after the clear operation.
159
+
160
+ ## ๐Ÿงฑ Database migrations and recovery
161
+
162
+ Schema migrations are numbered, immutable, contiguous, and checksummed. Pending migrations recheck and apply inside an exclusive transaction with bounded conflict retry, so two Pi processes cannot publish half of a migration. Runtime schema changes follow additive-first compatibility: add nullable columns or tables before considering any later contraction.
163
+
164
+ The extension never downgrades, repairs, deletes, or recreates a database automatically. If a migration fails, an applied checksum differs, or the database was created by a newer extension version, collection fails closed and existing bytes remain in place. Update the extension or restore a user-managed backup before retrying.
165
+
166
+ Settled response publication is one atomic transaction. A failed write leaves prior data valid and is retained for bounded retry while that Pi session remains open. Graceful shutdown drains pending writes and closes the session-owned connection.
167
+
168
+ ## ๐Ÿšง MVP limitations
169
+
170
+ - There are no retention settings; records remain until explicitly cleared.
171
+ - Prometheus, JSON/CSV export, Turso Cloud sync, browser dashboards, token/cost reporting, and project attribution are not included.
172
+ - Statistics cover only events visible through Pi's public extension API.
173
+ - Clearing rows is logical deletion, not a secure-erasure guarantee for underlying storage media.
174
+
175
+ ## ๐Ÿ—‚๏ธ Package layout
176
+
177
+ ```text
178
+ experimental/pi-analytics/
179
+ โ”œโ”€โ”€ src/
180
+ โ”‚ โ”œโ”€โ”€ index.ts # Thin Pi entrypoint
181
+ โ”‚ โ”œโ”€โ”€ analytics.ts # Pi lifecycle, command, and session ownership
182
+ โ”‚ โ”œโ”€โ”€ collector.ts # Content-free response-cycle state machine
183
+ โ”‚ โ”œโ”€โ”€ errors.ts # Conservative error classification
184
+ โ”‚ โ”œโ”€โ”€ skills.ts # Explicit and model skill detection
185
+ โ”‚ โ”œโ”€โ”€ menu.ts # TUI/RPC analytics dashboard
186
+ โ”‚ โ”œโ”€โ”€ types.ts # Observation records
187
+ โ”‚ โ””โ”€โ”€ storage/
188
+ โ”‚ โ”œโ”€โ”€ database.ts # Dynamic driver startup and private file lifecycle
189
+ โ”‚ โ”œโ”€โ”€ migrations.ts # Transactional checksummed schema history
190
+ โ”‚ โ”œโ”€โ”€ queries.ts # Indexed aggregate projections
191
+ โ”‚ โ””โ”€โ”€ store.ts # Atomic writes, retries, queries, and clear
192
+ โ”œโ”€โ”€ test/
193
+ โ”œโ”€โ”€ README.md
194
+ โ”œโ”€โ”€ LICENSE
195
+ โ”œโ”€โ”€ package.json
196
+ โ””โ”€โ”€ tsconfig.json
197
+ ```
198
+
199
+ ## ๐Ÿ”Ž Keywords
200
+
201
+ Pi extension, Pi coding agent, local analytics, agent skills, tool usage, model calls, provider reliability, Turso Database, SQLite-compatible metrics.
202
+
203
+ ## ๐Ÿ“„ License
204
+
205
+ MIT. See [`LICENSE`](./LICENSE).
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@narumitw/pi-analytics",
3
+ "version": "0.45.0",
4
+ "description": "Local-first usage analytics for Pi models, skills, tools, and reliability.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "private": false,
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi-extension",
11
+ "pi",
12
+ "analytics",
13
+ "metrics",
14
+ "turso"
15
+ ],
16
+ "files": [
17
+ "src",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "pi": {
22
+ "extensions": [
23
+ "./src/index.ts"
24
+ ]
25
+ },
26
+ "scripts": {
27
+ "check": "biome check --vcs-use-ignore-file=false src test package.json tsconfig.json README.md && npm run typecheck",
28
+ "format": "biome check --write --vcs-use-ignore-file=false src test package.json tsconfig.json README.md",
29
+ "typecheck": "tsc --noEmit"
30
+ },
31
+ "dependencies": {
32
+ "@narumitw/pi-tui-kit": "^0.45.0",
33
+ "@tursodatabase/database": "^0.7.2"
34
+ },
35
+ "peerDependencies": {
36
+ "@earendil-works/pi-coding-agent": "*"
37
+ },
38
+ "devDependencies": {
39
+ "@biomejs/biome": "2.5.6",
40
+ "@earendil-works/pi-coding-agent": "0.83.0",
41
+ "@types/node": "26.1.2",
42
+ "typescript": "7.0.2"
43
+ },
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "https://github.com/narumiruna/pi-extensions",
47
+ "directory": "experimental/pi-analytics"
48
+ }
49
+ }
@@ -0,0 +1,406 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import path from "node:path";
3
+ import {
4
+ type ExtensionAPI,
5
+ type ExtensionContext,
6
+ getAgentDir,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { ResponseCollector } from "./collector.js";
9
+ import { type AnalyticsMenuDataSource, showAnalyticsMenu } from "./menu.js";
10
+ import { SkillTracker } from "./skills.js";
11
+ import { AnalyticsDatabaseOpenError, openAnalyticsDatabase } from "./storage/database.js";
12
+ import {
13
+ ChecksumMismatchError,
14
+ MigrationFailedError,
15
+ NewerSchemaError,
16
+ } from "./storage/migrations.js";
17
+ import type { AnalyticsSnapshot, TimeRange } from "./storage/queries.js";
18
+ import { AnalyticsStore } from "./storage/store.js";
19
+ import type { ModelIdentity, SettledRun, TriggerSource } from "./types.js";
20
+
21
+ const EXPERIMENTAL_WARNING = "pi-analytics is experimental; its metrics and dashboard may change.";
22
+
23
+ export interface AnalyticsStorePort {
24
+ readonly path: string;
25
+ recordRun(run: SettledRun): Promise<void>;
26
+ getSnapshot(range: TimeRange): Promise<AnalyticsSnapshot>;
27
+ clearAll(): Promise<number>;
28
+ close(): Promise<void>;
29
+ }
30
+
31
+ interface AnalyticsDependencies {
32
+ openStore(path: string): Promise<AnalyticsStorePort>;
33
+ getAgentDir(): string;
34
+ now(): number;
35
+ createId(): string;
36
+ platform(): string;
37
+ }
38
+
39
+ export function createAnalyticsExtension(
40
+ dependencies: Partial<AnalyticsDependencies> = {},
41
+ ): (pi: ExtensionAPI) => void {
42
+ const deps: AnalyticsDependencies = {
43
+ openStore:
44
+ dependencies.openStore ??
45
+ (async (databasePath) =>
46
+ new AnalyticsStore(await openAnalyticsDatabase({ path: databasePath }))),
47
+ getAgentDir: dependencies.getAgentDir ?? getAgentDir,
48
+ now: dependencies.now ?? Date.now,
49
+ createId: dependencies.createId ?? randomUUID,
50
+ platform: dependencies.platform ?? runtimePlatform,
51
+ };
52
+
53
+ return function analyticsExtension(pi: ExtensionAPI): void {
54
+ let sessionGeneration = 0;
55
+ let sessionController = new AbortController();
56
+ let collector = new ResponseCollector();
57
+ let skillTracker: SkillTracker | undefined;
58
+ let store: AnalyticsStorePort | undefined;
59
+ let storageFailure: string | undefined;
60
+ let startupTask: Promise<void> | undefined;
61
+ let writeFailureActive = false;
62
+ let pendingTriggerSource: TriggerSource = "unknown";
63
+ let pendingAttemptWithoutRun = false;
64
+
65
+ pi.registerCommand("analytics", {
66
+ description: "Open local Pi usage analytics",
67
+ handler: async (args, ctx) => {
68
+ if (args.trim()) {
69
+ if (!ctx.hasUI || (ctx.mode !== "tui" && ctx.mode !== "rpc")) {
70
+ throw new Error("/analytics does not accept arguments.");
71
+ }
72
+ ctx.ui.notify("/analytics does not accept arguments.", "warning");
73
+ return;
74
+ }
75
+ if (!ctx.hasUI || (ctx.mode !== "tui" && ctx.mode !== "rpc")) {
76
+ throw new Error("/analytics requires Pi TUI or RPC mode.");
77
+ }
78
+ const generation = sessionGeneration;
79
+ const owner = sessionController;
80
+ const source = menuSource(generation, owner.signal);
81
+ await showAnalyticsMenu(ctx, source, {
82
+ signal: owner.signal,
83
+ isCurrent: () => generation === sessionGeneration && !owner.signal.aborted,
84
+ });
85
+ },
86
+ });
87
+
88
+ pi.on("session_start", async (_event, ctx) => {
89
+ const generation = ++sessionGeneration;
90
+ if (ctx.hasUI) ctx.ui.notify(EXPERIMENTAL_WARNING, "warning");
91
+ sessionController.abort(new DOMException("Analytics session replaced", "AbortError"));
92
+ sessionController = new AbortController();
93
+ collector = new ResponseCollector();
94
+ skillTracker = new SkillTracker(ctx.cwd);
95
+ store = undefined;
96
+ storageFailure = undefined;
97
+ writeFailureActive = false;
98
+ pendingTriggerSource = "unknown";
99
+ pendingAttemptWithoutRun = false;
100
+ const databasePath = path.join(deps.getAgentDir(), "pi-analytics.db");
101
+ const task = (async () => {
102
+ try {
103
+ const opened = await deps.openStore(databasePath);
104
+ if (generation !== sessionGeneration) {
105
+ await opened.close();
106
+ return;
107
+ }
108
+ store = opened;
109
+ } catch (error) {
110
+ if (generation !== sessionGeneration) return;
111
+ storageFailure = storageFailureMessage(error, deps.platform());
112
+ safeNotify(ctx, storageFailure, "warning");
113
+ }
114
+ })();
115
+ startupTask = task;
116
+ await task;
117
+ if (startupTask === task) startupTask = undefined;
118
+ });
119
+
120
+ pi.on("input", (event, ctx) => {
121
+ const now = deps.now();
122
+ const tracker = skillTracker;
123
+ tracker?.observeInput(event.text, event.source, now);
124
+ if (event.source !== "extension") pendingTriggerSource = event.source;
125
+ if (!tracker || !collector.hasActiveRun()) return;
126
+ const explicit = tracker.consumeExplicitSkill();
127
+ if (!explicit || !tracker.hasAvailableSkill(explicit.name)) return;
128
+ collector.activateSkill({
129
+ name: explicit.name,
130
+ initiatedBy: "user",
131
+ now: explicit.observedAtMs,
132
+ model: modelIdentity(ctx, pi),
133
+ });
134
+ });
135
+
136
+ pi.on("before_agent_start", async (event, ctx) => {
137
+ const generation = sessionGeneration;
138
+ const tracker = skillTracker;
139
+ if (!tracker) return;
140
+ const skills = availableSkills(pi, event.systemPromptOptions.skills ?? []);
141
+ await tracker.setAvailableSkills(skills);
142
+ if (generation !== sessionGeneration || tracker !== skillTracker) return;
143
+ const explicit = tracker.consumeExplicitSkill();
144
+ const interrupted = collector.begin({
145
+ id: deps.createId(),
146
+ now: deps.now(),
147
+ triggerSource: explicit?.source ?? pendingTriggerSource,
148
+ model: modelIdentity(ctx, pi),
149
+ });
150
+ pendingTriggerSource = "unknown";
151
+ if (interrupted) await persistRun(interrupted, ctx, generation);
152
+ if (explicit && skills.some(({ name }) => name === explicit.name)) {
153
+ collector.activateSkill({
154
+ name: explicit.name,
155
+ initiatedBy: "user",
156
+ now: explicit.observedAtMs,
157
+ model: modelIdentity(ctx, pi),
158
+ });
159
+ }
160
+ });
161
+
162
+ pi.on("agent_start", () => {
163
+ if (collector.hasActiveRun()) collector.beginAttempt();
164
+ else pendingAttemptWithoutRun = true;
165
+ });
166
+
167
+ pi.on("turn_start", (_event, ctx) => {
168
+ ensureRun(ctx, "extension");
169
+ });
170
+
171
+ pi.on("before_provider_request", (_event, ctx) => {
172
+ ensureRun(ctx, "extension");
173
+ collector.beginGeneration({
174
+ id: deps.createId(),
175
+ now: deps.now(),
176
+ model: modelIdentity(ctx, pi),
177
+ });
178
+ });
179
+
180
+ pi.on("after_provider_response", (event) => {
181
+ collector.recordProviderResponse({ status: event.status, now: deps.now() });
182
+ });
183
+
184
+ pi.on("message_end", (event) => {
185
+ if (event.message.role !== "assistant") return;
186
+ collector.finishGeneration({
187
+ now: deps.now(),
188
+ stopReason: event.message.stopReason,
189
+ errorMessage: event.message.errorMessage,
190
+ });
191
+ });
192
+
193
+ pi.on("tool_execution_start", (event, ctx) => {
194
+ ensureRun(ctx, "extension");
195
+ collector.beginTool({
196
+ id: event.toolCallId,
197
+ name: event.toolName,
198
+ now: deps.now(),
199
+ model: modelIdentity(ctx, pi),
200
+ });
201
+ });
202
+
203
+ pi.on("tool_result", async (event, ctx) => {
204
+ if (event.toolName === "read" && !isBuiltinReadTool(pi)) return;
205
+ const generation = sessionGeneration;
206
+ const tracker = skillTracker;
207
+ if (!tracker) return;
208
+ const name = await tracker.matchSuccessfulRead({
209
+ toolName: event.toolName,
210
+ input: event.input,
211
+ isError: event.isError,
212
+ });
213
+ if (!name || generation !== sessionGeneration || tracker !== skillTracker) return;
214
+ collector.activateSkill({
215
+ name,
216
+ initiatedBy: "model",
217
+ now: deps.now(),
218
+ model: modelIdentity(ctx, pi),
219
+ });
220
+ });
221
+
222
+ pi.on("tool_execution_end", (event) => {
223
+ collector.finishTool({ id: event.toolCallId, now: deps.now(), isError: event.isError });
224
+ });
225
+
226
+ pi.on("agent_settled", async (_event, ctx) => {
227
+ const generation = sessionGeneration;
228
+ const run = collector.settle(deps.now());
229
+ pendingAttemptWithoutRun = false;
230
+ pendingTriggerSource = "unknown";
231
+ skillTracker?.clearPending();
232
+ if (run) await persistRun(run, ctx, generation);
233
+ });
234
+
235
+ pi.on("session_shutdown", async (_event, ctx) => {
236
+ const activeStore = store;
237
+ const activeStartup = startupTask;
238
+ ++sessionGeneration;
239
+ sessionController.abort(new DOMException("Analytics session shut down", "AbortError"));
240
+ skillTracker?.clearPending();
241
+ skillTracker = undefined;
242
+ store = undefined;
243
+ const run = collector.interrupt(deps.now());
244
+ if (run && activeStore) {
245
+ await activeStore.recordRun(run).catch(() => {
246
+ safeNotify(ctx, "Analytics could not save the interrupted response cycle.", "warning");
247
+ });
248
+ }
249
+ await activeStartup?.catch(() => undefined);
250
+ await activeStore?.close().catch(() => {
251
+ safeNotify(
252
+ ctx,
253
+ "Analytics storage shutdown was incomplete; some pending metrics may not have been saved.",
254
+ "warning",
255
+ );
256
+ });
257
+ });
258
+
259
+ function ensureRun(ctx: ExtensionContext, triggerSource: TriggerSource): void {
260
+ if (collector.hasActiveRun()) return;
261
+ collector.begin({
262
+ id: deps.createId(),
263
+ now: deps.now(),
264
+ triggerSource,
265
+ model: modelIdentity(ctx, pi),
266
+ });
267
+ if (pendingAttemptWithoutRun) {
268
+ pendingAttemptWithoutRun = false;
269
+ collector.beginAttempt();
270
+ }
271
+ }
272
+
273
+ async function persistRun(
274
+ run: SettledRun,
275
+ ctx: ExtensionContext,
276
+ generation: number,
277
+ ): Promise<void> {
278
+ const activeStore = store;
279
+ if (!activeStore) return;
280
+ try {
281
+ await activeStore.recordRun(run);
282
+ if (generation !== sessionGeneration || activeStore !== store) return;
283
+ if (writeFailureActive) {
284
+ writeFailureActive = false;
285
+ safeNotify(ctx, "Local analytics storage recovered.", "info");
286
+ }
287
+ } catch {
288
+ if (generation !== sessionGeneration || activeStore !== store || writeFailureActive) return;
289
+ writeFailureActive = true;
290
+ safeNotify(
291
+ ctx,
292
+ "Analytics could not save a response cycle; it will retry while this Pi session remains open.",
293
+ "warning",
294
+ );
295
+ }
296
+ }
297
+
298
+ function menuSource(generation: number, signal: AbortSignal): AnalyticsMenuDataSource {
299
+ return {
300
+ path: store?.path ?? path.join(deps.getAgentDir(), "pi-analytics.db"),
301
+ async load(range) {
302
+ assertCurrent(generation, signal);
303
+ const activeStore = store;
304
+ if (!activeStore) {
305
+ return {
306
+ kind: "unavailable",
307
+ message: storageFailure ?? unavailableMessage(deps.platform()),
308
+ };
309
+ }
310
+ const snapshot = await activeStore.getSnapshot(range);
311
+ assertCurrent(generation, signal);
312
+ return { kind: "ready", snapshot };
313
+ },
314
+ async clearAll() {
315
+ assertCurrent(generation, signal);
316
+ const activeStore = store;
317
+ if (!activeStore) return 0;
318
+ const count = await activeStore.clearAll();
319
+ assertCurrent(generation, signal);
320
+ return count;
321
+ },
322
+ };
323
+ }
324
+
325
+ function assertCurrent(generation: number, signal: AbortSignal): void {
326
+ if (generation !== sessionGeneration || signal.aborted) {
327
+ throw new DOMException("Analytics interaction replaced", "AbortError");
328
+ }
329
+ }
330
+ };
331
+ }
332
+
333
+ function modelIdentity(ctx: ExtensionContext, pi: ExtensionAPI): ModelIdentity | undefined {
334
+ if (!ctx.model) return undefined;
335
+ return {
336
+ provider: ctx.model.provider,
337
+ model: ctx.model.id,
338
+ thinkingLevel: pi.getThinkingLevel(),
339
+ };
340
+ }
341
+
342
+ export function isBuiltinReadTool(pi: ExtensionAPI): boolean {
343
+ const read = pi.getAllTools().find(({ name }) => name === "read");
344
+ return read?.sourceInfo.source === "builtin";
345
+ }
346
+
347
+ function availableSkills(
348
+ pi: ExtensionAPI,
349
+ systemSkills: ReadonlyArray<{ name: string; filePath: string }>,
350
+ ): Array<{ name: string; filePath: string }> {
351
+ const result = [...systemSkills];
352
+ const seen = new Set(result.map(({ name }) => name));
353
+ const getCommands = (pi as ExtensionAPI & { getCommands?: ExtensionAPI["getCommands"] })
354
+ .getCommands;
355
+ for (const command of typeof getCommands === "function" ? getCommands.call(pi) : []) {
356
+ if (command.source !== "skill" || seen.has(command.name.replace(/^skill:/u, ""))) continue;
357
+ const name = command.name.replace(/^skill:/u, "");
358
+ seen.add(name);
359
+ result.push({ name, filePath: command.sourceInfo.path });
360
+ }
361
+ return result;
362
+ }
363
+
364
+ function runtimePlatform(): string {
365
+ if (process.platform !== "linux") return `${process.platform}-${process.arch}`;
366
+ const report = process.report?.getReport() as
367
+ | { header?: { glibcVersionRuntime?: unknown } }
368
+ | undefined;
369
+ const glibc = report?.header?.glibcVersionRuntime;
370
+ return `linux-${process.arch}-${glibc ? "glibc" : "non-glibc"}`;
371
+ }
372
+
373
+ function storageFailureMessage(error: unknown, platform: string): string {
374
+ if (error instanceof AnalyticsDatabaseOpenError) {
375
+ return `${error.message}\nExisting files were not replaced. Repair or restore them before retrying.\nNo analytics are being collected.`;
376
+ }
377
+ if (error instanceof NewerSchemaError) {
378
+ return `${error.message}\nUpdate pi-analytics before using this database.\nNo analytics are being collected.`;
379
+ }
380
+ if (error instanceof ChecksumMismatchError) {
381
+ return `${error.message}\nRestore a known database backup or update pi-analytics.\nNo analytics are being collected.`;
382
+ }
383
+ if (error instanceof MigrationFailedError) {
384
+ return `${error.message}\nThe previous valid schema was preserved.\nNo analytics are being collected.`;
385
+ }
386
+ return unavailableMessage(platform);
387
+ }
388
+
389
+ function unavailableMessage(platform: string): string {
390
+ return [
391
+ "Analytics storage is unavailable.",
392
+ `Platform: ${platform}`,
393
+ "Supported: Linux glibc x64/arm64, macOS arm64, Windows x64.",
394
+ "No analytics are being collected.",
395
+ ].join("\n");
396
+ }
397
+
398
+ function safeNotify(ctx: ExtensionContext, message: string, level: "info" | "warning"): void {
399
+ try {
400
+ ctx.ui.notify(message, level);
401
+ } catch {
402
+ // A replaced Pi context cannot receive lifecycle feedback.
403
+ }
404
+ }
405
+
406
+ export default createAnalyticsExtension();