@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/src/skills.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { InputSource } from "@earendil-works/pi-coding-agent";
4
+
5
+ export interface AvailableSkill {
6
+ name: string;
7
+ filePath: string;
8
+ }
9
+
10
+ export interface PendingExplicitSkill {
11
+ name: string;
12
+ observedAtMs: number;
13
+ source: "interactive" | "rpc";
14
+ }
15
+
16
+ export function explicitSkillName(text: string): string | undefined {
17
+ return text.match(/^\/skill:([a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)(?:\s|$)/u)?.[1];
18
+ }
19
+
20
+ export class SkillTracker {
21
+ private pending: PendingExplicitSkill | undefined;
22
+ private readonly skillByPath = new Map<string, string>();
23
+ private readonly availableNames = new Set<string>();
24
+
25
+ constructor(private readonly cwd: string) {}
26
+
27
+ observeInput(text: string, source: InputSource, now: number): void {
28
+ if (source === "extension") return;
29
+ const name = explicitSkillName(text);
30
+ this.pending = name ? { name, observedAtMs: now, source } : undefined;
31
+ }
32
+
33
+ consumeExplicitSkill(): PendingExplicitSkill | undefined {
34
+ const pending = this.pending;
35
+ this.pending = undefined;
36
+ return pending;
37
+ }
38
+
39
+ clearPending(): void {
40
+ this.pending = undefined;
41
+ }
42
+
43
+ hasAvailableSkill(name: string): boolean {
44
+ return this.availableNames.has(name);
45
+ }
46
+
47
+ async setAvailableSkills(skills: readonly AvailableSkill[]): Promise<void> {
48
+ this.skillByPath.clear();
49
+ this.availableNames.clear();
50
+ const seenNames = new Set<string>();
51
+ for (const skill of skills) {
52
+ if (seenNames.has(skill.name)) continue;
53
+ seenNames.add(skill.name);
54
+ this.availableNames.add(skill.name);
55
+ const canonical = await canonicalPath(skill.filePath).catch(() =>
56
+ path.resolve(this.cwd, skill.filePath),
57
+ );
58
+ this.skillByPath.set(canonical, skill.name);
59
+ }
60
+ }
61
+
62
+ async matchSuccessfulRead(input: {
63
+ toolName: string;
64
+ input: unknown;
65
+ isError: boolean;
66
+ }): Promise<string | undefined> {
67
+ if (input.toolName !== "read" || input.isError || !isRecord(input.input)) return undefined;
68
+ const rawPath = input.input.path;
69
+ if (typeof rawPath !== "string" || rawPath.length === 0) return undefined;
70
+ const normalized = rawPath.startsWith("@") ? rawPath.slice(1) : rawPath;
71
+ const absolute = path.resolve(this.cwd, normalized);
72
+ const canonical = await canonicalPath(absolute).catch(() => absolute);
73
+ return this.skillByPath.get(canonical);
74
+ }
75
+ }
76
+
77
+ function canonicalPath(filePath: string): Promise<string> {
78
+ return realpath(filePath);
79
+ }
80
+
81
+ function isRecord(value: unknown): value is Record<string, unknown> {
82
+ return typeof value === "object" && value !== null && !Array.isArray(value);
83
+ }
@@ -0,0 +1,126 @@
1
+ import { chmod, lstat, mkdir, open } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { Database } from "@tursodatabase/database";
4
+ import {
5
+ ChecksumMismatchError,
6
+ MigrationFailedError,
7
+ migrateDatabase,
8
+ NewerSchemaError,
9
+ } from "./migrations.js";
10
+
11
+ export interface TursoModule {
12
+ connect(
13
+ path: string,
14
+ options?: { timeout?: number; defaultQueryTimeout?: number },
15
+ ): Promise<Database>;
16
+ }
17
+
18
+ export class AnalyticsStorageUnavailableError extends Error {
19
+ constructor(cause: unknown) {
20
+ super("Local analytics storage is unavailable on this runtime.", { cause });
21
+ this.name = "AnalyticsStorageUnavailableError";
22
+ }
23
+ }
24
+
25
+ export class AnalyticsDatabaseOpenError extends Error {
26
+ constructor(cause: unknown) {
27
+ super("The local analytics database could not be opened safely.", { cause });
28
+ this.name = "AnalyticsDatabaseOpenError";
29
+ }
30
+ }
31
+
32
+ export interface OpenedAnalyticsDatabase {
33
+ readonly connection: Database;
34
+ readonly path: string;
35
+ close(): Promise<void>;
36
+ }
37
+
38
+ export async function openAnalyticsDatabase(options: {
39
+ path: string;
40
+ loadModule?: () => Promise<TursoModule>;
41
+ connectionTimeoutMs?: number;
42
+ queryTimeoutMs?: number;
43
+ }): Promise<OpenedAnalyticsDatabase> {
44
+ const loadModule = options.loadModule ?? defaultModuleLoader;
45
+ let module: TursoModule;
46
+ try {
47
+ module = await loadModule();
48
+ } catch (error) {
49
+ throw new AnalyticsStorageUnavailableError(error);
50
+ }
51
+
52
+ let database: Database | undefined;
53
+ try {
54
+ await mkdir(path.dirname(options.path), { recursive: true, mode: 0o700 });
55
+ await preparePrivateFile(options.path);
56
+ await preparePrivateFile(`${options.path}-wal`);
57
+ database = await module.connect(options.path, {
58
+ timeout: options.connectionTimeoutMs ?? 5_000,
59
+ defaultQueryTimeout: options.queryTimeoutMs ?? 5_000,
60
+ });
61
+ await migrateDatabase(database);
62
+ await protectDatabaseFiles(options.path);
63
+ let closed = false;
64
+ return {
65
+ connection: database,
66
+ path: options.path,
67
+ async close() {
68
+ if (closed) return;
69
+ closed = true;
70
+ await database?.close();
71
+ },
72
+ };
73
+ } catch (error) {
74
+ await database?.close().catch(() => undefined);
75
+ if (isMigrationError(error)) throw error;
76
+ throw new AnalyticsDatabaseOpenError(error);
77
+ }
78
+ }
79
+
80
+ async function protectDatabaseFiles(databasePath: string): Promise<void> {
81
+ for (const filePath of [databasePath, `${databasePath}-wal`, `${databasePath}-shm`]) {
82
+ await preparePrivateFile(filePath, filePath.endsWith("-shm"));
83
+ }
84
+ }
85
+
86
+ async function preparePrivateFile(filePath: string, optional = false): Promise<void> {
87
+ if (optional) {
88
+ try {
89
+ await lstat(filePath);
90
+ } catch (error) {
91
+ if (isNodeError(error) && error.code === "ENOENT") return;
92
+ throw error;
93
+ }
94
+ } else {
95
+ try {
96
+ const handle = await open(filePath, "wx", 0o600);
97
+ await handle.close();
98
+ } catch (error) {
99
+ if (!isNodeError(error) || error.code !== "EEXIST") throw error;
100
+ }
101
+ }
102
+ const metadata = await lstat(filePath);
103
+ if (!metadata.isFile() || metadata.isSymbolicLink()) {
104
+ throw new Error("Analytics database files must be regular files, not links.");
105
+ }
106
+ if (process.platform !== "win32") await chmod(filePath, 0o600);
107
+ }
108
+
109
+ function isMigrationError(
110
+ error: unknown,
111
+ ): error is ChecksumMismatchError | MigrationFailedError | NewerSchemaError {
112
+ return (
113
+ error instanceof ChecksumMismatchError ||
114
+ error instanceof MigrationFailedError ||
115
+ error instanceof NewerSchemaError
116
+ );
117
+ }
118
+
119
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
120
+ return error instanceof Error && "code" in error;
121
+ }
122
+
123
+ async function defaultModuleLoader(): Promise<TursoModule> {
124
+ const specifier = "@tursodatabase/database";
125
+ return import(specifier);
126
+ }
@@ -0,0 +1,257 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { Database, Transaction } from "@tursodatabase/database";
3
+
4
+ export interface SchemaMigration {
5
+ version: number;
6
+ name: string;
7
+ statements: readonly string[];
8
+ }
9
+
10
+ export class ChecksumMismatchError extends Error {
11
+ constructor(version: number) {
12
+ super(`Analytics migration v${version} no longer matches its applied checksum.`);
13
+ this.name = "ChecksumMismatchError";
14
+ }
15
+ }
16
+
17
+ export class MigrationFailedError extends Error {
18
+ readonly version: number;
19
+ readonly migrationName: string;
20
+
21
+ constructor(migration: SchemaMigration, cause: unknown) {
22
+ super(`Analytics migration v${migration.version} (${migration.name}) failed.`, { cause });
23
+ this.name = "MigrationFailedError";
24
+ this.version = migration.version;
25
+ this.migrationName = migration.name;
26
+ }
27
+ }
28
+
29
+ export class NewerSchemaError extends Error {
30
+ constructor(databaseVersion: number, supportedVersion: number) {
31
+ super(
32
+ `Analytics database schema v${databaseVersion} is newer than supported v${supportedVersion}.`,
33
+ );
34
+ this.name = "NewerSchemaError";
35
+ }
36
+ }
37
+
38
+ const CREATE_MIGRATIONS_TABLE = `
39
+ CREATE TABLE IF NOT EXISTS schema_migrations (
40
+ version INTEGER PRIMARY KEY,
41
+ name TEXT NOT NULL,
42
+ checksum TEXT NOT NULL,
43
+ applied_at_ms INTEGER NOT NULL
44
+ )`;
45
+
46
+ export const ANALYTICS_MIGRATIONS: readonly SchemaMigration[] = [
47
+ {
48
+ version: 1,
49
+ name: "initial-analytics-schema",
50
+ statements: [
51
+ `CREATE TABLE response_runs (
52
+ id TEXT PRIMARY KEY,
53
+ started_at_ms INTEGER NOT NULL,
54
+ finished_at_ms INTEGER NOT NULL,
55
+ duration_ms INTEGER NOT NULL,
56
+ trigger_source TEXT NOT NULL,
57
+ initial_provider TEXT,
58
+ initial_model TEXT,
59
+ outcome TEXT NOT NULL,
60
+ attempt_count INTEGER NOT NULL,
61
+ generation_count INTEGER NOT NULL,
62
+ tool_call_count INTEGER NOT NULL,
63
+ tool_error_count INTEGER NOT NULL,
64
+ skill_activation_count INTEGER NOT NULL,
65
+ provider_error_count INTEGER NOT NULL,
66
+ recovered_error_count INTEGER NOT NULL
67
+ )`,
68
+ `CREATE TABLE model_generations (
69
+ id TEXT PRIMARY KEY,
70
+ run_id TEXT NOT NULL,
71
+ ordinal INTEGER NOT NULL,
72
+ provider TEXT,
73
+ model TEXT,
74
+ thinking_level TEXT,
75
+ started_at_ms INTEGER NOT NULL,
76
+ finished_at_ms INTEGER,
77
+ duration_ms INTEGER,
78
+ stop_reason TEXT,
79
+ outcome TEXT NOT NULL,
80
+ UNIQUE(run_id, ordinal)
81
+ )`,
82
+ `CREATE TABLE provider_responses (
83
+ generation_id TEXT NOT NULL,
84
+ ordinal INTEGER NOT NULL,
85
+ occurred_at_ms INTEGER NOT NULL,
86
+ status INTEGER NOT NULL,
87
+ PRIMARY KEY(generation_id, ordinal)
88
+ )`,
89
+ `CREATE TABLE provider_errors (
90
+ id TEXT PRIMARY KEY,
91
+ run_id TEXT NOT NULL,
92
+ generation_id TEXT,
93
+ occurred_at_ms INTEGER NOT NULL,
94
+ provider TEXT,
95
+ model TEXT,
96
+ category TEXT NOT NULL,
97
+ recovered INTEGER NOT NULL,
98
+ terminal INTEGER NOT NULL
99
+ )`,
100
+ `CREATE TABLE tool_calls (
101
+ id TEXT PRIMARY KEY,
102
+ run_id TEXT NOT NULL,
103
+ ordinal INTEGER NOT NULL,
104
+ tool_name TEXT NOT NULL,
105
+ provider TEXT,
106
+ model TEXT,
107
+ started_at_ms INTEGER NOT NULL,
108
+ finished_at_ms INTEGER,
109
+ duration_ms INTEGER,
110
+ is_error INTEGER NOT NULL,
111
+ completion_state TEXT NOT NULL,
112
+ UNIQUE(run_id, ordinal)
113
+ )`,
114
+ `CREATE TABLE skill_activations (
115
+ id TEXT PRIMARY KEY,
116
+ run_id TEXT NOT NULL,
117
+ occurred_at_ms INTEGER NOT NULL,
118
+ skill_name TEXT NOT NULL,
119
+ initiated_by TEXT NOT NULL,
120
+ provider TEXT,
121
+ model TEXT,
122
+ UNIQUE(run_id, skill_name)
123
+ )`,
124
+ "CREATE INDEX response_runs_by_time ON response_runs(started_at_ms)",
125
+ "CREATE INDEX generations_by_model_time ON model_generations(provider, model, started_at_ms)",
126
+ "CREATE INDEX tools_by_name_time ON tool_calls(tool_name, started_at_ms)",
127
+ "CREATE INDEX skills_by_name_time ON skill_activations(skill_name, occurred_at_ms)",
128
+ "CREATE INDEX skills_by_model_time ON skill_activations(provider, model, occurred_at_ms)",
129
+ "CREATE INDEX provider_errors_by_category_time ON provider_errors(category, occurred_at_ms)",
130
+ "CREATE INDEX provider_responses_by_time ON provider_responses(occurred_at_ms)",
131
+ ],
132
+ },
133
+ ];
134
+
135
+ export async function migrateDatabase(
136
+ database: Database,
137
+ options: {
138
+ migrations?: readonly SchemaMigration[];
139
+ retryAttempts?: number;
140
+ retryDelayMs?: number;
141
+ } = {},
142
+ ): Promise<void> {
143
+ const migrations = options.migrations ?? ANALYTICS_MIGRATIONS;
144
+ validateRegistry(migrations);
145
+ const attempts = options.retryAttempts ?? 8;
146
+ const retryDelayMs = options.retryDelayMs ?? 10;
147
+ await withConflictRetry(() => database.exec(CREATE_MIGRATIONS_TABLE), attempts, retryDelayMs);
148
+ await withConflictRetry(() => applyMigrations(database, migrations), attempts, retryDelayMs);
149
+ }
150
+
151
+ function applyMigrations(
152
+ database: Database,
153
+ migrations: readonly SchemaMigration[],
154
+ ): Promise<void> {
155
+ const apply = database.transactionAsync(async (transaction) => {
156
+ const applied = (await transaction.all(
157
+ "SELECT version, name, checksum FROM schema_migrations ORDER BY version",
158
+ )) as Array<{ version: number; name: string; checksum: string }>;
159
+ const supportedVersion = migrations.at(-1)?.version ?? 0;
160
+ const databaseVersion = applied.at(-1)?.version ?? 0;
161
+ if (databaseVersion > supportedVersion) {
162
+ throw new NewerSchemaError(databaseVersion, supportedVersion);
163
+ }
164
+ for (const [index, row] of applied.entries()) {
165
+ const migration = migrations[index];
166
+ if (!migration || row.version !== migration.version) {
167
+ throw new Error("Analytics migration history is not contiguous.");
168
+ }
169
+ if (row.name !== migration.name || row.checksum !== migrationChecksum(migration)) {
170
+ throw new ChecksumMismatchError(row.version);
171
+ }
172
+ }
173
+ for (const migration of migrations.slice(applied.length)) {
174
+ await applyOne(transaction, migration);
175
+ }
176
+ });
177
+ return apply.exclusive();
178
+ }
179
+
180
+ async function applyOne(transaction: Transaction, migration: SchemaMigration): Promise<void> {
181
+ try {
182
+ for (const statement of migration.statements) await transaction.exec(statement);
183
+ await transaction.run(
184
+ "INSERT INTO schema_migrations(version, name, checksum, applied_at_ms) VALUES (?, ?, ?, ?)",
185
+ migration.version,
186
+ migration.name,
187
+ migrationChecksum(migration),
188
+ Date.now(),
189
+ );
190
+ } catch (error) {
191
+ throw new MigrationFailedError(migration, error);
192
+ }
193
+ }
194
+
195
+ export function migrationChecksum(migration: SchemaMigration): string {
196
+ return createHash("sha256")
197
+ .update(
198
+ JSON.stringify({
199
+ version: migration.version,
200
+ name: migration.name,
201
+ statements: migration.statements,
202
+ }),
203
+ )
204
+ .digest("hex");
205
+ }
206
+
207
+ function validateRegistry(migrations: readonly SchemaMigration[]): void {
208
+ for (const [index, migration] of migrations.entries()) {
209
+ const expected = index + 1;
210
+ if (migration.version !== expected) {
211
+ throw new Error(
212
+ `Analytics migration versions must be contiguous; expected v${expected}, received v${migration.version}.`,
213
+ );
214
+ }
215
+ if (!migration.name.trim()) throw new Error(`Analytics migration v${expected} has no name.`);
216
+ if (migration.statements.length === 0) {
217
+ throw new Error(`Analytics migration v${expected} has no statements.`);
218
+ }
219
+ }
220
+ }
221
+
222
+ async function withConflictRetry<T>(
223
+ operation: () => Promise<T>,
224
+ attempts: number,
225
+ delayMs: number,
226
+ ): Promise<T> {
227
+ let lastError: unknown;
228
+ for (let attempt = 0; attempt < Math.max(1, attempts); attempt += 1) {
229
+ try {
230
+ return await operation();
231
+ } catch (error) {
232
+ lastError = error;
233
+ if (!isTransactionConflict(error) || attempt + 1 >= attempts) throw error;
234
+ await delay(delayMs * (attempt + 1));
235
+ }
236
+ }
237
+ throw lastError;
238
+ }
239
+
240
+ function isTransactionConflict(error: unknown): boolean {
241
+ const message =
242
+ error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
243
+ if (
244
+ message.includes("statement was interrupted") ||
245
+ message.includes("database is locked") ||
246
+ message.includes("database is busy")
247
+ ) {
248
+ return true;
249
+ }
250
+ return error instanceof Error && error.cause !== undefined
251
+ ? isTransactionConflict(error.cause)
252
+ : false;
253
+ }
254
+
255
+ function delay(milliseconds: number): Promise<void> {
256
+ return new Promise((resolve) => setTimeout(resolve, Math.max(0, milliseconds)));
257
+ }