@phnx-labs/agents-cli 1.21.1 → 1.21.3

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.
Files changed (111) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/README.md +1 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/computer-actions.d.ts +4 -0
  5. package/dist/commands/computer-actions.js +34 -0
  6. package/dist/commands/computer.js +4 -2
  7. package/dist/commands/doctor.js +5 -2
  8. package/dist/commands/feed.js +28 -19
  9. package/dist/commands/hooks.js +9 -45
  10. package/dist/commands/menubar.js +24 -24
  11. package/dist/commands/message.js +23 -3
  12. package/dist/commands/perf.d.ts +29 -0
  13. package/dist/commands/perf.js +90 -23
  14. package/dist/commands/projects.d.ts +21 -0
  15. package/dist/commands/projects.js +169 -23
  16. package/dist/commands/routines.js +46 -1
  17. package/dist/commands/sessions-picker.js +17 -2
  18. package/dist/commands/sessions.d.ts +5 -0
  19. package/dist/commands/sessions.js +14 -0
  20. package/dist/commands/ssh.js +69 -0
  21. package/dist/commands/trends.d.ts +2 -0
  22. package/dist/commands/trends.js +158 -0
  23. package/dist/commands/usage.d.ts +4 -4
  24. package/dist/commands/view.d.ts +6 -0
  25. package/dist/commands/view.js +90 -45
  26. package/dist/index.js +22 -1
  27. package/dist/lib/agents.js +2 -2
  28. package/dist/lib/analytics/dashboard.d.ts +11 -0
  29. package/dist/lib/analytics/dashboard.js +31 -0
  30. package/dist/lib/analytics/recipes.d.ts +32 -0
  31. package/dist/lib/analytics/recipes.js +316 -0
  32. package/dist/lib/analytics/usage-db.d.ts +84 -0
  33. package/dist/lib/analytics/usage-db.js +301 -0
  34. package/dist/lib/browser/service.js +31 -0
  35. package/dist/lib/cli-resources.d.ts +20 -0
  36. package/dist/lib/cli-resources.js +48 -1
  37. package/dist/lib/computer/dispatch.d.ts +3 -1
  38. package/dist/lib/computer/dispatch.js +10 -2
  39. package/dist/lib/daemon.js +51 -14
  40. package/dist/lib/devices/health-report.d.ts +5 -0
  41. package/dist/lib/devices/health-report.js +3 -0
  42. package/dist/lib/event-stream.d.ts +2 -0
  43. package/dist/lib/event-stream.js +3 -0
  44. package/dist/lib/events.d.ts +3 -1
  45. package/dist/lib/events.js +4 -2
  46. package/dist/lib/feed-broadcast.d.ts +52 -7
  47. package/dist/lib/feed-broadcast.js +125 -18
  48. package/dist/lib/fleet-cache.d.ts +37 -0
  49. package/dist/lib/fleet-cache.js +40 -0
  50. package/dist/lib/fleet-status.d.ts +53 -0
  51. package/dist/lib/fleet-status.js +120 -0
  52. package/dist/lib/friction-heuristics.d.ts +32 -0
  53. package/dist/lib/friction-heuristics.js +47 -0
  54. package/dist/lib/git.d.ts +14 -0
  55. package/dist/lib/git.js +36 -0
  56. package/dist/lib/hooks/cache.js +28 -6
  57. package/dist/lib/hooks/profile.d.ts +8 -0
  58. package/dist/lib/hooks/profile.js +15 -18
  59. package/dist/lib/hooks.js +72 -17
  60. package/dist/lib/linear-cache.d.ts +63 -0
  61. package/dist/lib/linear-cache.js +146 -0
  62. package/dist/lib/linear-project-counts.d.ts +35 -5
  63. package/dist/lib/linear-project-counts.js +61 -16
  64. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  65. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +3 -1
  66. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  67. package/dist/lib/menubar/install-menubar.d.ts +7 -0
  68. package/dist/lib/menubar/install-menubar.js +36 -6
  69. package/dist/lib/percentile.d.ts +12 -0
  70. package/dist/lib/percentile.js +24 -0
  71. package/dist/lib/perf/db.d.ts +7 -3
  72. package/dist/lib/perf/db.js +37 -19
  73. package/dist/lib/perf/types.d.ts +10 -0
  74. package/dist/lib/plugins.js +12 -1
  75. package/dist/lib/project-doctor.d.ts +36 -0
  76. package/dist/lib/project-doctor.js +45 -0
  77. package/dist/lib/project-import.d.ts +11 -1
  78. package/dist/lib/project-import.js +17 -3
  79. package/dist/lib/project-status.d.ts +25 -5
  80. package/dist/lib/project-status.js +48 -6
  81. package/dist/lib/resources.d.ts +16 -0
  82. package/dist/lib/resources.js +25 -14
  83. package/dist/lib/rotate.d.ts +27 -0
  84. package/dist/lib/rotate.js +44 -17
  85. package/dist/lib/routines.d.ts +16 -0
  86. package/dist/lib/routines.js +26 -0
  87. package/dist/lib/runner.js +41 -0
  88. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  89. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  90. package/dist/lib/secrets/usage-db.d.ts +3 -63
  91. package/dist/lib/secrets/usage-db.js +46 -186
  92. package/dist/lib/session/db.d.ts +17 -1
  93. package/dist/lib/session/db.js +240 -6
  94. package/dist/lib/session/discover.d.ts +31 -0
  95. package/dist/lib/session/discover.js +50 -1
  96. package/dist/lib/session/highlights.d.ts +24 -4
  97. package/dist/lib/session/highlights.js +52 -7
  98. package/dist/lib/session/parse.js +19 -3
  99. package/dist/lib/session/prompt.d.ts +19 -0
  100. package/dist/lib/session/prompt.js +43 -0
  101. package/dist/lib/session/types.d.ts +43 -0
  102. package/dist/lib/startup/command-registry.d.ts +1 -0
  103. package/dist/lib/startup/command-registry.js +2 -0
  104. package/dist/lib/state.d.ts +31 -3
  105. package/dist/lib/state.js +53 -10
  106. package/dist/lib/types.d.ts +22 -4
  107. package/dist/lib/usage-refresh.d.ts +106 -0
  108. package/dist/lib/usage-refresh.js +238 -0
  109. package/dist/lib/usage.d.ts +152 -17
  110. package/dist/lib/usage.js +393 -79
  111. package/package.json +1 -1
@@ -0,0 +1,316 @@
1
+ import Database from '../sqlite.js';
2
+ import { getSessionsDbPath } from '../state.js';
3
+ import { topNamesByKind, kindMix, countUsage, listUsageKindsWithData, } from './usage-db.js';
4
+ export function trendsWindow(days) {
5
+ const d = Number.isFinite(days) && days > 0 ? days : 7;
6
+ const since = new Date(Date.now() - d * 24 * 60 * 60 * 1000);
7
+ return { days: d, sinceIso: since.toISOString() };
8
+ }
9
+ function openSessions() {
10
+ try {
11
+ const db = new Database(getSessionsDbPath());
12
+ db.pragma('busy_timeout = 2000');
13
+ return db;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ function percentile(sorted, p) {
20
+ if (sorted.length === 0)
21
+ return 0;
22
+ if (sorted.length === 1)
23
+ return sorted[0];
24
+ const rank = (p / 100) * (sorted.length - 1);
25
+ const lo = Math.floor(rank);
26
+ const hi = Math.ceil(rank);
27
+ if (lo === hi)
28
+ return sorted[lo];
29
+ const frac = rank - lo;
30
+ return sorted[lo] * (1 - frac) + sorted[hi] * frac;
31
+ }
32
+ export function recipeHarnessMix(win) {
33
+ const db = openSessions();
34
+ const id = 'harness-mix';
35
+ const title = 'Harness mix';
36
+ if (!db)
37
+ return { id, title, store: 'sessions', rows: [], empty: true };
38
+ try {
39
+ const rows = db.prepare(`SELECT agent AS name, COUNT(*) AS n
40
+ FROM sessions WHERE timestamp >= ?
41
+ GROUP BY agent ORDER BY n DESC`).all(win.sinceIso);
42
+ return {
43
+ id,
44
+ title,
45
+ store: 'sessions',
46
+ rows: rows.map((r) => ({ harness: r.name, sessions: r.n })),
47
+ empty: rows.length === 0,
48
+ };
49
+ }
50
+ catch {
51
+ return { id, title, store: 'sessions', rows: [], empty: true };
52
+ }
53
+ finally {
54
+ try {
55
+ db.close();
56
+ }
57
+ catch { /* ignore */ }
58
+ }
59
+ }
60
+ export function recipeModelMix(win) {
61
+ const db = openSessions();
62
+ const id = 'model-mix';
63
+ const title = 'Model mix';
64
+ if (!db)
65
+ return { id, title, store: 'sessions', rows: [], empty: true };
66
+ try {
67
+ const rows = db.prepare(`SELECT COALESCE(NULLIF(TRIM(model), ''), '(unrecorded)') AS name, COUNT(*) AS n
68
+ FROM sessions WHERE timestamp >= ?
69
+ GROUP BY 1 ORDER BY n DESC LIMIT 25`).all(win.sinceIso);
70
+ return {
71
+ id,
72
+ title,
73
+ store: 'sessions',
74
+ rows: rows.map((r) => ({ model: r.name, sessions: r.n })),
75
+ empty: rows.length === 0,
76
+ };
77
+ }
78
+ catch {
79
+ return { id, title, store: 'sessions', rows: [], empty: true };
80
+ }
81
+ finally {
82
+ try {
83
+ db.close();
84
+ }
85
+ catch { /* ignore */ }
86
+ }
87
+ }
88
+ export function recipeToolsPerSession(win) {
89
+ const db = openSessions();
90
+ const id = 'tools-per-session';
91
+ const title = 'Tools per session';
92
+ if (!db)
93
+ return { id, title, store: 'sessions', rows: [], empty: true };
94
+ try {
95
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
96
+ if (!cols.some((c) => c.name === 'tool_call_count')) {
97
+ return { id, title, store: 'sessions', rows: [], empty: true };
98
+ }
99
+ const byAgent = db.prepare(`SELECT agent, tool_call_count AS n
100
+ FROM sessions
101
+ WHERE timestamp >= ? AND tool_call_count IS NOT NULL`).all(win.sinceIso);
102
+ if (byAgent.length === 0)
103
+ return { id, title, store: 'sessions', rows: [], empty: true };
104
+ const groups = new Map();
105
+ const all = [];
106
+ for (const r of byAgent) {
107
+ all.push(r.n);
108
+ const list = groups.get(r.agent) ?? [];
109
+ list.push(r.n);
110
+ groups.set(r.agent, list);
111
+ }
112
+ const roll = (label, vals) => {
113
+ const sorted = [...vals].sort((a, b) => a - b);
114
+ const sum = sorted.reduce((a, b) => a + b, 0);
115
+ return {
116
+ scope: label,
117
+ n: sorted.length,
118
+ avg: Math.round(sum / sorted.length),
119
+ p50: Math.round(percentile(sorted, 50)),
120
+ p99: Math.round(percentile(sorted, 99)),
121
+ };
122
+ };
123
+ const rows = [roll('(all)', all)];
124
+ for (const [agent, vals] of [...groups.entries()].sort((a, b) => b[1].length - a[1].length)) {
125
+ rows.push(roll(agent, vals));
126
+ }
127
+ return { id, title, store: 'sessions', rows, empty: false };
128
+ }
129
+ catch {
130
+ return { id, title, store: 'sessions', rows: [], empty: true };
131
+ }
132
+ finally {
133
+ try {
134
+ db.close();
135
+ }
136
+ catch { /* ignore */ }
137
+ }
138
+ }
139
+ export function recipeTokenRatio(win) {
140
+ const db = openSessions();
141
+ const id = 'token-ratio';
142
+ const title = 'Token read→write ratio';
143
+ if (!db)
144
+ return { id, title, store: 'sessions', rows: [], empty: true };
145
+ try {
146
+ const rows = db.prepare(`SELECT agent,
147
+ COUNT(*) AS sessions,
148
+ IFNULL(SUM(token_count), 0) AS tokenIn,
149
+ IFNULL(SUM(output_tokens), 0) AS tokenOut
150
+ FROM sessions
151
+ WHERE timestamp >= ?
152
+ AND (token_count IS NOT NULL OR output_tokens IS NOT NULL)
153
+ GROUP BY agent
154
+ ORDER BY sessions DESC`).all(win.sinceIso);
155
+ if (rows.length === 0)
156
+ return { id, title, store: 'sessions', rows: [], empty: true };
157
+ const totalIn = rows.reduce((a, r) => a + r.tokenIn, 0);
158
+ const totalOut = rows.reduce((a, r) => a + r.tokenOut, 0);
159
+ const totalSessions = rows.reduce((a, r) => a + r.sessions, 0);
160
+ const fmt = (inn, out) => (out > 0 ? (inn / out).toFixed(2) : out === 0 && inn > 0 ? '∞' : '—');
161
+ const outRows = [{
162
+ scope: '(all)',
163
+ sessions: totalSessions,
164
+ token_in: totalIn,
165
+ token_out: totalOut,
166
+ ratio: fmt(totalIn, totalOut),
167
+ avg_in: totalSessions ? Math.round(totalIn / totalSessions) : 0,
168
+ avg_out: totalSessions ? Math.round(totalOut / totalSessions) : 0,
169
+ }];
170
+ for (const r of rows) {
171
+ outRows.push({
172
+ scope: r.agent,
173
+ sessions: r.sessions,
174
+ token_in: r.tokenIn,
175
+ token_out: r.tokenOut,
176
+ ratio: fmt(r.tokenIn, r.tokenOut),
177
+ avg_in: Math.round(r.tokenIn / r.sessions),
178
+ avg_out: Math.round(r.tokenOut / r.sessions),
179
+ });
180
+ }
181
+ return { id, title, store: 'sessions', rows: outRows, empty: false };
182
+ }
183
+ catch {
184
+ return { id, title, store: 'sessions', rows: [], empty: true };
185
+ }
186
+ finally {
187
+ try {
188
+ db.close();
189
+ }
190
+ catch { /* ignore */ }
191
+ }
192
+ }
193
+ export function recipeSessionVolume(win) {
194
+ const db = openSessions();
195
+ const id = 'session-volume';
196
+ const title = 'Session volume';
197
+ if (!db)
198
+ return { id, title, store: 'sessions', rows: [], empty: true };
199
+ try {
200
+ const rows = db.prepare(`SELECT COALESCE(NULLIF(TRIM(machine), ''), '(unknown)') AS machine,
201
+ COUNT(*) AS sessions,
202
+ IFNULL(SUM(duration_ms), 0) AS durationMs
203
+ FROM sessions WHERE timestamp >= ?
204
+ GROUP BY 1 ORDER BY sessions DESC`).all(win.sinceIso);
205
+ return {
206
+ id,
207
+ title,
208
+ store: 'sessions',
209
+ rows: rows.map((r) => ({
210
+ machine: r.machine,
211
+ sessions: r.sessions,
212
+ duration_min: Math.round(r.durationMs / 60000),
213
+ })),
214
+ empty: rows.length === 0,
215
+ };
216
+ }
217
+ catch {
218
+ return { id, title, store: 'sessions', rows: [], empty: true };
219
+ }
220
+ finally {
221
+ try {
222
+ db.close();
223
+ }
224
+ catch { /* ignore */ }
225
+ }
226
+ }
227
+ export function recipeSecretsHot(win) {
228
+ const id = 'secrets-hot';
229
+ const title = 'Hottest secrets';
230
+ const rows = topNamesByKind('secret', win.sinceIso, 15);
231
+ return {
232
+ id,
233
+ title,
234
+ store: 'usage',
235
+ rows: rows.map((r) => ({ bundle: r.name, accesses: r.n, last: r.last })),
236
+ empty: rows.length === 0,
237
+ };
238
+ }
239
+ export function recipeBrowserActivity(win) {
240
+ const id = 'browser-activity';
241
+ const title = 'Browser activity';
242
+ const rows = topNamesByKind('browser', win.sinceIso, 15);
243
+ return {
244
+ id,
245
+ title,
246
+ store: 'usage',
247
+ rows: rows.map((r) => ({ profile: r.name, events: r.n, last: r.last })),
248
+ empty: rows.length === 0,
249
+ };
250
+ }
251
+ export function recipeResourceMix(win) {
252
+ const id = 'resource-mix';
253
+ const title = 'Resource usage mix';
254
+ const rows = kindMix(win.sinceIso);
255
+ return {
256
+ id,
257
+ title,
258
+ store: 'usage',
259
+ rows: rows.map((r) => ({ kind: r.kind, events: r.n })),
260
+ empty: rows.length < 2,
261
+ };
262
+ }
263
+ export const RECIPE_IDS = [
264
+ 'harness-mix',
265
+ 'model-mix',
266
+ 'tools-per-session',
267
+ 'token-ratio',
268
+ 'session-volume',
269
+ 'secrets-hot',
270
+ 'browser-activity',
271
+ 'resource-mix',
272
+ ];
273
+ const RUNNERS = {
274
+ 'harness-mix': recipeHarnessMix,
275
+ 'model-mix': recipeModelMix,
276
+ 'tools-per-session': recipeToolsPerSession,
277
+ 'token-ratio': recipeTokenRatio,
278
+ 'session-volume': recipeSessionVolume,
279
+ 'secrets-hot': recipeSecretsHot,
280
+ 'browser-activity': recipeBrowserActivity,
281
+ 'resource-mix': recipeResourceMix,
282
+ };
283
+ export function runRecipe(id, win) {
284
+ return RUNNERS[id](win);
285
+ }
286
+ export function listRecipes() {
287
+ const win = trendsWindow(7);
288
+ return RECIPE_IDS.map((id) => {
289
+ const s = RUNNERS[id](win);
290
+ return { id, title: s.title, store: s.store };
291
+ });
292
+ }
293
+ export function hasSessionsInWindow(win) {
294
+ const db = openSessions();
295
+ if (!db)
296
+ return false;
297
+ try {
298
+ const row = db.prepare(`SELECT 1 AS ok FROM sessions WHERE timestamp >= ? LIMIT 1`).get(win.sinceIso);
299
+ return Boolean(row);
300
+ }
301
+ catch {
302
+ return false;
303
+ }
304
+ finally {
305
+ try {
306
+ db.close();
307
+ }
308
+ catch { /* ignore */ }
309
+ }
310
+ }
311
+ export function usageKindsPresent(win) {
312
+ return listUsageKindsWithData(win.sinceIso);
313
+ }
314
+ export function usageEventCount(kind, win) {
315
+ return countUsage({ kind, sinceIso: win.sinceIso });
316
+ }
@@ -0,0 +1,84 @@
1
+ export type UsageKind = 'secret' | 'agent' | 'skill' | 'plugin' | 'browser' | 'computer';
2
+ export declare const USAGE_KINDS: readonly UsageKind[];
3
+ export interface RecordUsageParams {
4
+ kind: UsageKind;
5
+ name: string;
6
+ event: string;
7
+ agent?: string;
8
+ sessionId?: string;
9
+ machine?: string;
10
+ actor?: string;
11
+ source?: string;
12
+ status?: 'success' | 'error' | string;
13
+ meta?: Record<string, unknown>;
14
+ ts?: string;
15
+ }
16
+ export interface UsageEventRow {
17
+ ts: string;
18
+ kind: UsageKind;
19
+ name: string;
20
+ event: string;
21
+ agent: string | null;
22
+ sessionId: string | null;
23
+ machine: string | null;
24
+ actor: string | null;
25
+ source: string | null;
26
+ status: string | null;
27
+ metaJson: string | null;
28
+ }
29
+ export declare function recordUsage(p: RecordUsageParams): void;
30
+ export declare function usageDbPath(): string;
31
+ export declare function analyticsDir(): string;
32
+ export declare function listUsageKindsWithData(sinceIso: string): UsageKind[];
33
+ export declare function countUsage(opts: {
34
+ kind?: UsageKind;
35
+ sinceIso: string;
36
+ name?: string;
37
+ event?: string;
38
+ }): number;
39
+ export declare function queryUsage(opts: {
40
+ kind?: UsageKind;
41
+ name?: string;
42
+ event?: string;
43
+ sinceIso: string;
44
+ limit?: number;
45
+ }): UsageEventRow[];
46
+ export interface NameCountRow {
47
+ name: string;
48
+ n: number;
49
+ last: string | null;
50
+ }
51
+ export declare function topNamesByKind(kind: UsageKind, sinceIso: string, limit?: number): NameCountRow[];
52
+ export interface KindCountRow {
53
+ kind: string;
54
+ n: number;
55
+ }
56
+ export declare function kindMix(sinceIso: string): KindCountRow[];
57
+ export declare function getSecretBundleRollup(bundle: string): Array<{
58
+ event: string;
59
+ n: number;
60
+ last: string | null;
61
+ first: string | null;
62
+ }>;
63
+ export declare function getSecretBundleAgents(bundle: string): Array<{
64
+ agent: string;
65
+ n: number;
66
+ }>;
67
+ export declare function getAllSecretBundleRollups(): Array<{
68
+ name: string;
69
+ event: string;
70
+ n: number;
71
+ last: string | null;
72
+ first: string | null;
73
+ }>;
74
+ export declare function getSecretHistory(bundle: string | undefined, limit?: number): Array<{
75
+ ts: string;
76
+ bundle: string;
77
+ event: string;
78
+ agent: string | null;
79
+ host: string | null;
80
+ source: string | null;
81
+ status: string | null;
82
+ keyCount: number | null;
83
+ }>;
84
+ export declare function closeUsageDb(): void;
@@ -0,0 +1,301 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import Database from '../sqlite.js';
4
+ import { getUsageDbPath, getSecretsDbPath, getAnalyticsDir } from '../state.js';
5
+ import { localMachineId } from '../session/origin-machine.js';
6
+ export const USAGE_KINDS = [
7
+ 'secret',
8
+ 'agent',
9
+ 'skill',
10
+ 'plugin',
11
+ 'browser',
12
+ 'computer',
13
+ ];
14
+ const EVENT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
15
+ const SCHEMA = `
16
+ CREATE TABLE IF NOT EXISTS usage_events (
17
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ ts TEXT NOT NULL,
19
+ kind TEXT NOT NULL,
20
+ name TEXT NOT NULL,
21
+ event TEXT NOT NULL,
22
+ agent TEXT,
23
+ session_id TEXT,
24
+ machine TEXT,
25
+ actor TEXT,
26
+ source TEXT,
27
+ status TEXT,
28
+ meta_json TEXT
29
+ );
30
+ CREATE INDEX IF NOT EXISTS idx_analytics_usage_ts ON usage_events(ts DESC);
31
+ CREATE INDEX IF NOT EXISTS idx_analytics_usage_kind_ts ON usage_events(kind, ts DESC);
32
+ CREATE INDEX IF NOT EXISTS idx_analytics_usage_kind_name ON usage_events(kind, name);
33
+ CREATE INDEX IF NOT EXISTS idx_analytics_usage_kind_event ON usage_events(kind, event);
34
+ CREATE INDEX IF NOT EXISTS idx_analytics_usage_machine_ts ON usage_events(machine, ts DESC);
35
+ CREATE INDEX IF NOT EXISTS idx_analytics_usage_session ON usage_events(session_id);
36
+ CREATE TABLE IF NOT EXISTS meta (
37
+ key TEXT PRIMARY KEY,
38
+ value TEXT NOT NULL
39
+ );
40
+ `;
41
+ let cached = null;
42
+ function isDisabled() {
43
+ const v = process.env.AGENTS_NO_USAGE_TRACK;
44
+ return v === '1' || v === 'true';
45
+ }
46
+ function open() {
47
+ if (isDisabled())
48
+ return null;
49
+ const dbPath = getUsageDbPath();
50
+ if (cached && cached.path === dbPath)
51
+ return cached.db;
52
+ if (cached) {
53
+ try {
54
+ cached.db.close();
55
+ }
56
+ catch { /* ignore */ }
57
+ cached = null;
58
+ }
59
+ try {
60
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true, mode: 0o700 });
61
+ const db = new Database(dbPath);
62
+ db.pragma('journal_mode = WAL');
63
+ db.pragma('busy_timeout = 2000');
64
+ db.exec(SCHEMA);
65
+ try {
66
+ db.prepare(`DELETE FROM usage_events WHERE ts < ?`).run(new Date(Date.now() - EVENT_RETENTION_MS).toISOString());
67
+ }
68
+ catch { /* prune best-effort */ }
69
+ migrateSecretsUsageOnce(db);
70
+ cached = { path: dbPath, db };
71
+ return db;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ function migrateSecretsUsageOnce(db) {
78
+ try {
79
+ const done = db.prepare(`SELECT value FROM meta WHERE key = 'migrate_secrets_v1'`).get();
80
+ if (done?.value === '1')
81
+ return;
82
+ const secretsPath = getSecretsDbPath();
83
+ if (fs.existsSync(secretsPath)) {
84
+ const legacy = new Database(secretsPath);
85
+ try {
86
+ const rows = legacy.prepare(`SELECT ts, bundle, event, agent, host, source, status, key_count FROM usage_events`).all();
87
+ const insert = db.prepare(`INSERT INTO usage_events (ts, kind, name, event, agent, session_id, machine, actor, source, status, meta_json)
88
+ VALUES (?, 'secret', ?, ?, ?, NULL, ?, NULL, ?, ?, ?)`);
89
+ const machine = localMachineId();
90
+ const txn = db.transaction((items) => {
91
+ for (const r of items) {
92
+ if (!r.bundle)
93
+ continue;
94
+ const meta = r.key_count != null ? JSON.stringify({ keyCount: r.key_count, host: r.host }) : (r.host ? JSON.stringify({ host: r.host }) : null);
95
+ insert.run(r.ts, r.bundle, r.event, r.agent, machine, r.source, r.status, meta);
96
+ }
97
+ });
98
+ txn(rows);
99
+ }
100
+ finally {
101
+ try {
102
+ legacy.close();
103
+ }
104
+ catch { /* ignore */ }
105
+ }
106
+ }
107
+ db.prepare(`INSERT OR REPLACE INTO meta(key, value) VALUES ('migrate_secrets_v1', '1')`).run();
108
+ }
109
+ catch {
110
+ /* migrate is best-effort */
111
+ }
112
+ }
113
+ export function recordUsage(p) {
114
+ if (isDisabled())
115
+ return;
116
+ if (!p.kind || !p.name || !p.event)
117
+ return;
118
+ const db = open();
119
+ if (!db)
120
+ return;
121
+ try {
122
+ db.prepare(`INSERT INTO usage_events (ts, kind, name, event, agent, session_id, machine, actor, source, status, meta_json)
123
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(p.ts ?? new Date().toISOString(), p.kind, p.name, p.event, p.agent ?? null, p.sessionId ?? null, p.machine ?? localMachineId(), p.actor ?? null, p.source ?? null, p.status ?? 'success', p.meta != null ? JSON.stringify(p.meta) : null);
124
+ }
125
+ catch {
126
+ /* telemetry must never break callers */
127
+ }
128
+ }
129
+ export function usageDbPath() {
130
+ return getUsageDbPath();
131
+ }
132
+ export function analyticsDir() {
133
+ return getAnalyticsDir();
134
+ }
135
+ export function listUsageKindsWithData(sinceIso) {
136
+ const db = open();
137
+ if (!db)
138
+ return [];
139
+ try {
140
+ const rows = db.prepare(`SELECT DISTINCT kind FROM usage_events WHERE ts >= ?`).all(sinceIso);
141
+ return rows.map((r) => r.kind).filter((k) => USAGE_KINDS.includes(k));
142
+ }
143
+ catch {
144
+ return [];
145
+ }
146
+ }
147
+ export function countUsage(opts) {
148
+ const db = open();
149
+ if (!db)
150
+ return 0;
151
+ try {
152
+ const clauses = ['ts >= ?'];
153
+ const args = [opts.sinceIso];
154
+ if (opts.kind) {
155
+ clauses.push('kind = ?');
156
+ args.push(opts.kind);
157
+ }
158
+ if (opts.name) {
159
+ clauses.push('name = ?');
160
+ args.push(opts.name);
161
+ }
162
+ if (opts.event) {
163
+ clauses.push('event = ?');
164
+ args.push(opts.event);
165
+ }
166
+ const row = db.prepare(`SELECT COUNT(*) AS n FROM usage_events WHERE ${clauses.join(' AND ')}`).get(...args);
167
+ return row?.n ?? 0;
168
+ }
169
+ catch {
170
+ return 0;
171
+ }
172
+ }
173
+ export function queryUsage(opts) {
174
+ const db = open();
175
+ if (!db)
176
+ return [];
177
+ try {
178
+ const clauses = ['ts >= ?'];
179
+ const args = [opts.sinceIso];
180
+ if (opts.kind) {
181
+ clauses.push('kind = ?');
182
+ args.push(opts.kind);
183
+ }
184
+ if (opts.name) {
185
+ clauses.push('name = ?');
186
+ args.push(opts.name);
187
+ }
188
+ if (opts.event) {
189
+ clauses.push('event = ?');
190
+ args.push(opts.event);
191
+ }
192
+ const limit = opts.limit && opts.limit > 0 ? opts.limit : 100;
193
+ args.push(limit);
194
+ const rows = db.prepare(`SELECT ts, kind, name, event, agent, session_id AS sessionId, machine, actor, source, status, meta_json AS metaJson
195
+ FROM usage_events WHERE ${clauses.join(' AND ')}
196
+ ORDER BY ts DESC, id DESC LIMIT ?`).all(...args);
197
+ return rows;
198
+ }
199
+ catch {
200
+ return [];
201
+ }
202
+ }
203
+ export function topNamesByKind(kind, sinceIso, limit = 20) {
204
+ const db = open();
205
+ if (!db)
206
+ return [];
207
+ try {
208
+ return db.prepare(`SELECT name, COUNT(*) AS n, MAX(ts) AS last
209
+ FROM usage_events WHERE kind = ? AND ts >= ?
210
+ GROUP BY name ORDER BY n DESC LIMIT ?`).all(kind, sinceIso, limit);
211
+ }
212
+ catch {
213
+ return [];
214
+ }
215
+ }
216
+ export function kindMix(sinceIso) {
217
+ const db = open();
218
+ if (!db)
219
+ return [];
220
+ try {
221
+ return db.prepare(`SELECT kind, COUNT(*) AS n FROM usage_events WHERE ts >= ? GROUP BY kind ORDER BY n DESC`).all(sinceIso);
222
+ }
223
+ catch {
224
+ return [];
225
+ }
226
+ }
227
+ export function getSecretBundleRollup(bundle) {
228
+ const db = open();
229
+ if (!db)
230
+ return [];
231
+ try {
232
+ return db.prepare(`SELECT event, COUNT(*) AS n, MAX(ts) AS last, MIN(ts) AS first
233
+ FROM usage_events WHERE kind = 'secret' AND name = ? GROUP BY event`).all(bundle);
234
+ }
235
+ catch {
236
+ return [];
237
+ }
238
+ }
239
+ export function getSecretBundleAgents(bundle) {
240
+ const db = open();
241
+ if (!db)
242
+ return [];
243
+ try {
244
+ return db.prepare(`SELECT agent, COUNT(*) AS n FROM usage_events
245
+ WHERE kind = 'secret' AND name = ? AND agent IS NOT NULL
246
+ GROUP BY agent ORDER BY n DESC`).all(bundle);
247
+ }
248
+ catch {
249
+ return [];
250
+ }
251
+ }
252
+ export function getAllSecretBundleRollups() {
253
+ const db = open();
254
+ if (!db)
255
+ return [];
256
+ try {
257
+ return db.prepare(`SELECT name, event, COUNT(*) AS n, MAX(ts) AS last, MIN(ts) AS first
258
+ FROM usage_events WHERE kind = 'secret' GROUP BY name, event`).all();
259
+ }
260
+ catch {
261
+ return [];
262
+ }
263
+ }
264
+ export function getSecretHistory(bundle, limit = 20) {
265
+ const db = open();
266
+ if (!db)
267
+ return [];
268
+ try {
269
+ const sql = bundle
270
+ ? `SELECT ts, name AS bundle, event, agent, source, status, meta_json
271
+ FROM usage_events WHERE kind = 'secret' AND name = ? ORDER BY ts DESC, id DESC LIMIT ?`
272
+ : `SELECT ts, name AS bundle, event, agent, source, status, meta_json
273
+ FROM usage_events WHERE kind = 'secret' ORDER BY ts DESC, id DESC LIMIT ?`;
274
+ const rows = (bundle ? db.prepare(sql).all(bundle, limit) : db.prepare(sql).all(limit));
275
+ return rows.map((r) => {
276
+ let host = null;
277
+ let keyCount = null;
278
+ if (r.meta_json) {
279
+ try {
280
+ const m = JSON.parse(r.meta_json);
281
+ host = m.host ?? null;
282
+ keyCount = typeof m.keyCount === 'number' ? m.keyCount : null;
283
+ }
284
+ catch { /* ignore */ }
285
+ }
286
+ return { ts: r.ts, bundle: r.bundle, event: r.event, agent: r.agent, host, source: r.source, status: r.status, keyCount };
287
+ });
288
+ }
289
+ catch {
290
+ return [];
291
+ }
292
+ }
293
+ export function closeUsageDb() {
294
+ if (cached) {
295
+ try {
296
+ cached.db.close();
297
+ }
298
+ catch { /* ignore */ }
299
+ cached = null;
300
+ }
301
+ }