@phnx-labs/agents-cli 1.22.20 → 1.22.22
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/CHANGELOG.md +62 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/insights.d.ts +32 -0
- package/dist/commands/insights.js +478 -0
- package/dist/commands/secrets.js +7 -1
- package/dist/commands/sessions-picker.d.ts +2 -0
- package/dist/commands/sessions-picker.js +1 -0
- package/dist/commands/sessions.js +6 -0
- package/dist/index.js +3 -1
- package/dist/lib/hosts/passthrough.js +1 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/picker.d.ts +45 -0
- package/dist/lib/picker.js +75 -6
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/session/db.d.ts +34 -0
- package/dist/lib/session/db.js +100 -0
- package/dist/lib/session/digest.d.ts +3 -0
- package/dist/lib/session/digest.js +3 -3
- package/dist/lib/session/insights.d.ts +126 -0
- package/dist/lib/session/insights.js +330 -0
- package/dist/lib/session/parse.d.ts +19 -2
- package/dist/lib/session/parse.js +13 -5
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/usage.js +39 -3
- package/package.json +1 -1
|
Binary file
|
|
Binary file
|
package/dist/lib/picker.d.ts
CHANGED
|
@@ -19,6 +19,12 @@ export interface PickerConfig<T> {
|
|
|
19
19
|
initialSearch?: string;
|
|
20
20
|
emptyMessage?: string;
|
|
21
21
|
enterHint?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Lines the caller already printed above the Inquirer prompt (e.g. the
|
|
24
|
+
* hidden-session footer). Subtracted from the row budget so the list page is
|
|
25
|
+
* capped to keep the preview — and those notices — on screen together.
|
|
26
|
+
*/
|
|
27
|
+
linesAbovePrompt?: number;
|
|
22
28
|
}
|
|
23
29
|
/** The result returned when the user selects an item. */
|
|
24
30
|
export interface PickedItem<T> {
|
|
@@ -37,7 +43,44 @@ export interface MultiPickerConfig<T> {
|
|
|
37
43
|
initialSearch?: string;
|
|
38
44
|
emptyMessage?: string;
|
|
39
45
|
enterHint?: string;
|
|
46
|
+
/** See {@link PickerConfig.linesAbovePrompt}. */
|
|
47
|
+
linesAbovePrompt?: number;
|
|
40
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Rows the detail preview is guaranteed when it is open and a row is selected.
|
|
51
|
+
* The list page is capped so this floor always fits the viewport — without it a
|
|
52
|
+
* long list (PICKER_RECENT_COUNT = 15) consumes the whole default 24-row
|
|
53
|
+
* terminal, `availablePreviewRows` goes <= 0, and the preview silently collapses
|
|
54
|
+
* to empty (RUSH-2198).
|
|
55
|
+
*/
|
|
56
|
+
export declare const PREVIEW_MIN_ROWS = 6;
|
|
57
|
+
/** Floor for the visible list page, so a short terminal still shows a few rows. */
|
|
58
|
+
export declare const PICKER_MIN_LIST_ROWS = 3;
|
|
59
|
+
/**
|
|
60
|
+
* Cap the visible list page so an open preview keeps a guaranteed floor of rows.
|
|
61
|
+
*
|
|
62
|
+
* The picker renders header + list page + separator + preview + help. When the
|
|
63
|
+
* requested page size (e.g. 15) is large enough to fill the terminal on its own,
|
|
64
|
+
* the preview has no room left and collapses. This reserves
|
|
65
|
+
* {@link PREVIEW_MIN_ROWS} (plus its separator) for the preview and hands the
|
|
66
|
+
* list whatever remains, never below {@link PICKER_MIN_LIST_ROWS}.
|
|
67
|
+
*
|
|
68
|
+
* `chromeRows` counts the fixed non-list, non-preview lines (header, subtitle,
|
|
69
|
+
* help, any flash). `linesAbovePrompt` counts lines the caller printed above the
|
|
70
|
+
* Inquirer prompt that have scrolled the viewport but the picker cannot measure —
|
|
71
|
+
* today the session picker passes the hidden-session footer; subtracting it keeps
|
|
72
|
+
* that notice on screen alongside the preview. (The fleet browser folds its
|
|
73
|
+
* unreachable-peer warning into the header instead, so it needs no reserve here.)
|
|
74
|
+
*/
|
|
75
|
+
export declare function pickerPageSize(opts: {
|
|
76
|
+
requestedPageSize: number;
|
|
77
|
+
terminalRows: number;
|
|
78
|
+
chromeRows: number;
|
|
79
|
+
previewOpen: boolean;
|
|
80
|
+
linesAbovePrompt?: number;
|
|
81
|
+
previewMinRows?: number;
|
|
82
|
+
minListRows?: number;
|
|
83
|
+
}): number;
|
|
41
84
|
/** Clip a picker preview so the full prompt can fit in the terminal viewport. */
|
|
42
85
|
export declare function limitPreviewHeight(preview: string, maxRows: number, width: number): string;
|
|
43
86
|
/** Show an interactive fuzzy-filter picker and return the selected item, or null on cancel. */
|
|
@@ -93,6 +136,8 @@ export interface DynamicPickerConfig<T, F> {
|
|
|
93
136
|
emptyMessage?: string;
|
|
94
137
|
loadingMessage?: string;
|
|
95
138
|
enterHint?: string;
|
|
139
|
+
/** See {@link PickerConfig.linesAbovePrompt}. */
|
|
140
|
+
linesAbovePrompt?: number;
|
|
96
141
|
}
|
|
97
142
|
/**
|
|
98
143
|
* The lookup token for a hotkey: the literal character the key produced, else
|
package/dist/lib/picker.js
CHANGED
|
@@ -17,12 +17,47 @@ import chalk from 'chalk';
|
|
|
17
17
|
import { stripVTControlCharacters } from 'node:util';
|
|
18
18
|
const DEFAULT_TERMINAL_ROWS = 24;
|
|
19
19
|
const DEFAULT_TERMINAL_WIDTH = 80;
|
|
20
|
+
/**
|
|
21
|
+
* Rows the detail preview is guaranteed when it is open and a row is selected.
|
|
22
|
+
* The list page is capped so this floor always fits the viewport — without it a
|
|
23
|
+
* long list (PICKER_RECENT_COUNT = 15) consumes the whole default 24-row
|
|
24
|
+
* terminal, `availablePreviewRows` goes <= 0, and the preview silently collapses
|
|
25
|
+
* to empty (RUSH-2198).
|
|
26
|
+
*/
|
|
27
|
+
export const PREVIEW_MIN_ROWS = 6;
|
|
28
|
+
/** Floor for the visible list page, so a short terminal still shows a few rows. */
|
|
29
|
+
export const PICKER_MIN_LIST_ROWS = 3;
|
|
20
30
|
function terminalWidth() {
|
|
21
31
|
return Math.max(1, process.stdout.columns || DEFAULT_TERMINAL_WIDTH);
|
|
22
32
|
}
|
|
23
33
|
function terminalRows() {
|
|
24
34
|
return Math.max(1, process.stdout.rows || DEFAULT_TERMINAL_ROWS);
|
|
25
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Cap the visible list page so an open preview keeps a guaranteed floor of rows.
|
|
38
|
+
*
|
|
39
|
+
* The picker renders header + list page + separator + preview + help. When the
|
|
40
|
+
* requested page size (e.g. 15) is large enough to fill the terminal on its own,
|
|
41
|
+
* the preview has no room left and collapses. This reserves
|
|
42
|
+
* {@link PREVIEW_MIN_ROWS} (plus its separator) for the preview and hands the
|
|
43
|
+
* list whatever remains, never below {@link PICKER_MIN_LIST_ROWS}.
|
|
44
|
+
*
|
|
45
|
+
* `chromeRows` counts the fixed non-list, non-preview lines (header, subtitle,
|
|
46
|
+
* help, any flash). `linesAbovePrompt` counts lines the caller printed above the
|
|
47
|
+
* Inquirer prompt that have scrolled the viewport but the picker cannot measure —
|
|
48
|
+
* today the session picker passes the hidden-session footer; subtracting it keeps
|
|
49
|
+
* that notice on screen alongside the preview. (The fleet browser folds its
|
|
50
|
+
* unreachable-peer warning into the header instead, so it needs no reserve here.)
|
|
51
|
+
*/
|
|
52
|
+
export function pickerPageSize(opts) {
|
|
53
|
+
const previewMinRows = opts.previewMinRows ?? PREVIEW_MIN_ROWS;
|
|
54
|
+
const minListRows = opts.minListRows ?? PICKER_MIN_LIST_ROWS;
|
|
55
|
+
const linesAbove = Math.max(0, opts.linesAbovePrompt ?? 0);
|
|
56
|
+
// The separator line rides with the preview only when it is open.
|
|
57
|
+
const previewReserve = opts.previewOpen ? previewMinRows + 1 : 0;
|
|
58
|
+
const budget = opts.terminalRows - linesAbove - opts.chromeRows - previewReserve;
|
|
59
|
+
return Math.max(minListRows, Math.min(opts.requestedPageSize, budget));
|
|
60
|
+
}
|
|
26
61
|
function renderedRows(text, width) {
|
|
27
62
|
const normalizedWidth = Math.max(1, width);
|
|
28
63
|
return text.split('\n').reduce((rows, line) => {
|
|
@@ -157,6 +192,17 @@ export function itemPicker(config) {
|
|
|
157
192
|
: '(type to filter)';
|
|
158
193
|
const searchStr = searchTerm ? chalk.cyan(searchTerm) : chalk.gray(placeholder);
|
|
159
194
|
const header = [prefix, message, searchStr].filter(Boolean).join(' ');
|
|
195
|
+
// Cap the list page so an open preview keeps a guaranteed floor of rows.
|
|
196
|
+
// chrome = header + optional subtitle + help; the preview separator is
|
|
197
|
+
// reserved inside pickerPageSize.
|
|
198
|
+
const chromeRows = 1 + (cfg.subtitle ? 1 : 0) + 1;
|
|
199
|
+
const effectivePageSize = pickerPageSize({
|
|
200
|
+
requestedPageSize: cfg.pageSize ?? 10,
|
|
201
|
+
terminalRows: terminalRows(),
|
|
202
|
+
chromeRows,
|
|
203
|
+
previewOpen: previewOpen && Boolean(cfg.buildPreview),
|
|
204
|
+
linesAbovePrompt: cfg.linesAbovePrompt,
|
|
205
|
+
});
|
|
160
206
|
const page = usePagination({
|
|
161
207
|
items: results,
|
|
162
208
|
active,
|
|
@@ -167,7 +213,7 @@ export function itemPicker(config) {
|
|
|
167
213
|
const row = isActive ? chalk.bold(item.label) : item.label;
|
|
168
214
|
return `${cursor} ${row}`;
|
|
169
215
|
},
|
|
170
|
-
pageSize:
|
|
216
|
+
pageSize: effectivePageSize,
|
|
171
217
|
loop: false,
|
|
172
218
|
});
|
|
173
219
|
const enter = cfg.enterHint ?? 'select';
|
|
@@ -188,7 +234,7 @@ export function itemPicker(config) {
|
|
|
188
234
|
renderedRows(parts.slice(1).join('\n'), width) +
|
|
189
235
|
renderedRows(separator, width) +
|
|
190
236
|
renderedRows(help, width);
|
|
191
|
-
const availablePreviewRows = terminalRows() - fixedRows;
|
|
237
|
+
const availablePreviewRows = terminalRows() - Math.max(0, cfg.linesAbovePrompt ?? 0) - fixedRows;
|
|
192
238
|
const preview = limitPreviewHeight(cfg.buildPreview(selected.value), availablePreviewRows, width);
|
|
193
239
|
if (preview) {
|
|
194
240
|
parts.push(separator);
|
|
@@ -283,6 +329,17 @@ export function multiItemPicker(config) {
|
|
|
283
329
|
const placeholder = '(type to filter · space to toggle · enter to resume)';
|
|
284
330
|
const searchStr = searchTerm ? chalk.cyan(searchTerm) : chalk.gray(placeholder);
|
|
285
331
|
const header = [prefix, message, searchStr].filter(Boolean).join(' ');
|
|
332
|
+
// Cap the list page so an open preview keeps a guaranteed floor of rows.
|
|
333
|
+
// chrome = header + help; the preview separator is reserved inside
|
|
334
|
+
// pickerPageSize.
|
|
335
|
+
const chromeRows = 2;
|
|
336
|
+
const effectivePageSize = pickerPageSize({
|
|
337
|
+
requestedPageSize: cfg.pageSize ?? 10,
|
|
338
|
+
terminalRows: terminalRows(),
|
|
339
|
+
chromeRows,
|
|
340
|
+
previewOpen: previewOpen && Boolean(cfg.buildPreview),
|
|
341
|
+
linesAbovePrompt: cfg.linesAbovePrompt,
|
|
342
|
+
});
|
|
286
343
|
const page = usePagination({
|
|
287
344
|
items: results,
|
|
288
345
|
active,
|
|
@@ -295,7 +352,7 @@ export function multiItemPicker(config) {
|
|
|
295
352
|
const row = isActive ? chalk.bold(item.label) : item.label;
|
|
296
353
|
return `${cursor} ${box} ${row}`;
|
|
297
354
|
},
|
|
298
|
-
pageSize:
|
|
355
|
+
pageSize: effectivePageSize,
|
|
299
356
|
loop: false,
|
|
300
357
|
});
|
|
301
358
|
const enter = cfg.enterHint ?? 'resume';
|
|
@@ -312,7 +369,7 @@ export function multiItemPicker(config) {
|
|
|
312
369
|
renderedRows(parts.slice(1).join('\n'), width) +
|
|
313
370
|
renderedRows(separator, width) +
|
|
314
371
|
renderedRows(help, width);
|
|
315
|
-
const availablePreviewRows = terminalRows() - fixedRows;
|
|
372
|
+
const availablePreviewRows = terminalRows() - Math.max(0, cfg.linesAbovePrompt ?? 0) - fixedRows;
|
|
316
373
|
const preview = limitPreviewHeight(cfg.buildPreview(selected.value), availablePreviewRows, width);
|
|
317
374
|
if (preview) {
|
|
318
375
|
parts.push(separator);
|
|
@@ -553,6 +610,18 @@ export function dynamicPicker(config) {
|
|
|
553
610
|
headerBits.push(chalk.cyan('/' + query));
|
|
554
611
|
}
|
|
555
612
|
const header = headerBits.filter(Boolean).join(' ');
|
|
613
|
+
// Cap the list page so an open preview keeps a guaranteed floor of rows.
|
|
614
|
+
// chrome = header + help + optional flash line; the preview separator is
|
|
615
|
+
// reserved inside pickerPageSize. Only the loaded list steals viewport, so
|
|
616
|
+
// skip the cap while the loading placeholder is showing.
|
|
617
|
+
const chromeRows = 2 + (flash ? renderedRows(flash, terminalWidth()) : 0);
|
|
618
|
+
const effectivePageSize = pickerPageSize({
|
|
619
|
+
requestedPageSize: cfg.pageSize ?? 12,
|
|
620
|
+
terminalRows: terminalRows(),
|
|
621
|
+
chromeRows,
|
|
622
|
+
previewOpen: previewOpen && Boolean(cfg.buildPreview) && !loading,
|
|
623
|
+
linesAbovePrompt: cfg.linesAbovePrompt,
|
|
624
|
+
});
|
|
556
625
|
const page = usePagination({
|
|
557
626
|
items: results,
|
|
558
627
|
active,
|
|
@@ -563,7 +632,7 @@ export function dynamicPicker(config) {
|
|
|
563
632
|
const row = isActive ? chalk.bold(item.label) : item.label;
|
|
564
633
|
return `${cursor} ${row}`;
|
|
565
634
|
},
|
|
566
|
-
pageSize:
|
|
635
|
+
pageSize: effectivePageSize,
|
|
567
636
|
loop: false,
|
|
568
637
|
});
|
|
569
638
|
const help = chalk.gray(cfg.helpFor
|
|
@@ -590,7 +659,7 @@ export function dynamicPicker(config) {
|
|
|
590
659
|
renderedRows(separator, width) +
|
|
591
660
|
renderedRows(help, width) +
|
|
592
661
|
flashRows;
|
|
593
|
-
const availablePreviewRows = terminalRows() - fixedRows;
|
|
662
|
+
const availablePreviewRows = terminalRows() - Math.max(0, cfg.linesAbovePrompt ?? 0) - fixedRows;
|
|
594
663
|
const preview = limitPreviewHeight(cfg.buildPreview(selected.value), availablePreviewRows, width);
|
|
595
664
|
if (preview) {
|
|
596
665
|
parts.push(separator);
|
|
Binary file
|
|
Binary file
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -20,6 +20,13 @@ export declare const SCHEMA_VERSION = 33;
|
|
|
20
20
|
* TOOL_INDEX_VERSION gives the tool backfill).
|
|
21
21
|
*/
|
|
22
22
|
export declare const RESOURCE_INDEX_VERSION = 1;
|
|
23
|
+
/**
|
|
24
|
+
* Bumping this invalidates every cached facet row without touching the schema
|
|
25
|
+
* version, so a change to the extraction logic (a new metric, a corrected bucket)
|
|
26
|
+
* re-derives on the next `agents insights` instead of silently reporting stale
|
|
27
|
+
* numbers alongside fresh ones. Same role as RESOURCE_INDEX_VERSION.
|
|
28
|
+
*/
|
|
29
|
+
export declare const INSIGHTS_EXTRACTOR_VERSION = 3;
|
|
23
30
|
/** Raw row shape returned from the sessions table. */
|
|
24
31
|
export interface SessionRow {
|
|
25
32
|
id: string;
|
|
@@ -317,6 +324,33 @@ export interface UsageRollupRow {
|
|
|
317
324
|
outputTokens: number;
|
|
318
325
|
}
|
|
319
326
|
/** What to group a usage rollup by. */
|
|
327
|
+
/**
|
|
328
|
+
* Read cached facets for the given sessions, dropping any row that is stale.
|
|
329
|
+
*
|
|
330
|
+
* Staleness is decided in SQL against the session's own `file_mtime_ms` / `file_size`,
|
|
331
|
+
* the same pair the scanner maintains — so the cache cannot disagree with the index,
|
|
332
|
+
* `IS` rather than `=` so a source with no statable file — NULL on both sides — is a
|
|
333
|
+
* cache HIT rather than a permanent miss that re-parses it on every run.
|
|
334
|
+
*/
|
|
335
|
+
export declare function readSessionInsights<T>(ids: string[]): Map<string, T>;
|
|
336
|
+
/**
|
|
337
|
+
* Persist freshly computed facets against the stamp of the bytes actually parsed.
|
|
338
|
+
*
|
|
339
|
+
* The caller passes the stat it observed when it read the file. Re-reading the stamp
|
|
340
|
+
* from the sessions table inside this INSERT would race: a concurrent rescan between
|
|
341
|
+
* the parse and the write (the cold path flushes in batches, so the window is minutes
|
|
342
|
+
* wide, and this module treats concurrent access as a design assumption) stamps NEW
|
|
343
|
+
* bytes onto OLD facets — a permanent false cache hit until the file changes again.
|
|
344
|
+
* tool-index.ts sets the precedent: stat at parse time, carry the stamp into the write.
|
|
345
|
+
*/
|
|
346
|
+
export declare function writeSessionInsights<T>(entries: Array<{
|
|
347
|
+
id: string;
|
|
348
|
+
fileMtimeMs: number | null;
|
|
349
|
+
fileSize: number | null;
|
|
350
|
+
facets: T;
|
|
351
|
+
}>): void;
|
|
352
|
+
/** Drop every cached facet row. Backs `agents insights --refresh`. */
|
|
353
|
+
export declare function clearSessionInsights(): void;
|
|
320
354
|
export type UsageRollupGroup = 'agent' | 'project' | 'day' | 'account';
|
|
321
355
|
/**
|
|
322
356
|
* Smart-launch affinity priors: group sessions by origin machine, harness, or
|
package/dist/lib/session/db.js
CHANGED
|
@@ -268,7 +268,33 @@ CREATE TABLE IF NOT EXISTS resource_scan_ledger (
|
|
|
268
268
|
indexed_at INTEGER NOT NULL,
|
|
269
269
|
resource_count INTEGER NOT NULL
|
|
270
270
|
);
|
|
271
|
+
|
|
272
|
+
-- Behavioural facets per session, for "agents insights". Deliberately its own table
|
|
273
|
+
-- and deliberately NOT tied to SCHEMA_VERSION: it is created by CREATE TABLE IF NOT
|
|
274
|
+
-- EXISTS and keyed on (file_mtime_ms, file_size), so it self-heals after any future
|
|
275
|
+
-- migration that flushes a ledger, and adding it costs the hot "sessions" table
|
|
276
|
+
-- nothing. Populated lazily by the insights command, never by a normal scan --
|
|
277
|
+
-- parsing every transcript is far too expensive for the common listing path.
|
|
278
|
+
-- file_mtime_ms / file_size are NULLABLE because they are nullable on the sessions
|
|
279
|
+
-- table too (a source with no statable file indexes them as NULL). NOT NULL here made
|
|
280
|
+
-- a legitimate null-stat session throw a constraint error that took the whole batch
|
|
281
|
+
-- transaction down with it.
|
|
282
|
+
CREATE TABLE IF NOT EXISTS session_insights (
|
|
283
|
+
session_id TEXT PRIMARY KEY,
|
|
284
|
+
file_mtime_ms INTEGER,
|
|
285
|
+
file_size INTEGER,
|
|
286
|
+
extractor_version INTEGER NOT NULL,
|
|
287
|
+
computed_at INTEGER NOT NULL,
|
|
288
|
+
facets TEXT NOT NULL
|
|
289
|
+
);
|
|
271
290
|
`;
|
|
291
|
+
/**
|
|
292
|
+
* Bumping this invalidates every cached facet row without touching the schema
|
|
293
|
+
* version, so a change to the extraction logic (a new metric, a corrected bucket)
|
|
294
|
+
* re-derives on the next `agents insights` instead of silently reporting stale
|
|
295
|
+
* numbers alongside fresh ones. Same role as RESOURCE_INDEX_VERSION.
|
|
296
|
+
*/
|
|
297
|
+
export const INSIGHTS_EXTRACTOR_VERSION = 3;
|
|
272
298
|
let dbInstance = null;
|
|
273
299
|
/**
|
|
274
300
|
* Apply schema migrations from `fromVersion` → SCHEMA_VERSION. The new
|
|
@@ -2105,6 +2131,80 @@ export function countSessions(options = {}) {
|
|
|
2105
2131
|
const row = db.prepare(sql).get(...params);
|
|
2106
2132
|
return row ? row.n : 0;
|
|
2107
2133
|
}
|
|
2134
|
+
/** What to group a usage rollup by. */
|
|
2135
|
+
/**
|
|
2136
|
+
* Read cached facets for the given sessions, dropping any row that is stale.
|
|
2137
|
+
*
|
|
2138
|
+
* Staleness is decided in SQL against the session's own `file_mtime_ms` / `file_size`,
|
|
2139
|
+
* the same pair the scanner maintains — so the cache cannot disagree with the index,
|
|
2140
|
+
* `IS` rather than `=` so a source with no statable file — NULL on both sides — is a
|
|
2141
|
+
* cache HIT rather than a permanent miss that re-parses it on every run.
|
|
2142
|
+
*/
|
|
2143
|
+
export function readSessionInsights(ids) {
|
|
2144
|
+
const db = getDB();
|
|
2145
|
+
const out = new Map();
|
|
2146
|
+
if (ids.length === 0)
|
|
2147
|
+
return out;
|
|
2148
|
+
const CHUNK = 400; // chunk.length + 1 binds, well under SQLite's 999-variable limit
|
|
2149
|
+
for (let i = 0; i < ids.length; i += CHUNK) {
|
|
2150
|
+
const chunk = ids.slice(i, i + CHUNK);
|
|
2151
|
+
const phs = chunk.map(() => '?').join(',');
|
|
2152
|
+
const rows = db.prepare(`
|
|
2153
|
+
SELECT si.session_id AS id, si.facets AS facets
|
|
2154
|
+
FROM session_insights si
|
|
2155
|
+
JOIN sessions s ON s.id = si.session_id
|
|
2156
|
+
WHERE si.session_id IN (${phs})
|
|
2157
|
+
AND si.extractor_version = ?
|
|
2158
|
+
AND si.file_mtime_ms IS s.file_mtime_ms
|
|
2159
|
+
AND si.file_size IS s.file_size
|
|
2160
|
+
`).all(...chunk, INSIGHTS_EXTRACTOR_VERSION);
|
|
2161
|
+
for (const row of rows) {
|
|
2162
|
+
try {
|
|
2163
|
+
out.set(row.id, JSON.parse(row.facets));
|
|
2164
|
+
}
|
|
2165
|
+
catch {
|
|
2166
|
+
// A corrupt cache row is not a reason to fail the report; recompute it.
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
return out;
|
|
2171
|
+
}
|
|
2172
|
+
/**
|
|
2173
|
+
* Persist freshly computed facets against the stamp of the bytes actually parsed.
|
|
2174
|
+
*
|
|
2175
|
+
* The caller passes the stat it observed when it read the file. Re-reading the stamp
|
|
2176
|
+
* from the sessions table inside this INSERT would race: a concurrent rescan between
|
|
2177
|
+
* the parse and the write (the cold path flushes in batches, so the window is minutes
|
|
2178
|
+
* wide, and this module treats concurrent access as a design assumption) stamps NEW
|
|
2179
|
+
* bytes onto OLD facets — a permanent false cache hit until the file changes again.
|
|
2180
|
+
* tool-index.ts sets the precedent: stat at parse time, carry the stamp into the write.
|
|
2181
|
+
*/
|
|
2182
|
+
export function writeSessionInsights(entries) {
|
|
2183
|
+
if (entries.length === 0)
|
|
2184
|
+
return;
|
|
2185
|
+
const db = getDB();
|
|
2186
|
+
const stmt = db.prepare(`
|
|
2187
|
+
INSERT INTO session_insights
|
|
2188
|
+
(session_id, file_mtime_ms, file_size, extractor_version, computed_at, facets)
|
|
2189
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
2190
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
2191
|
+
file_mtime_ms = excluded.file_mtime_ms,
|
|
2192
|
+
file_size = excluded.file_size,
|
|
2193
|
+
extractor_version = excluded.extractor_version,
|
|
2194
|
+
computed_at = excluded.computed_at,
|
|
2195
|
+
facets = excluded.facets
|
|
2196
|
+
`);
|
|
2197
|
+
const now = Date.now();
|
|
2198
|
+
db.transaction(() => {
|
|
2199
|
+
for (const e of entries) {
|
|
2200
|
+
stmt.run(e.id, e.fileMtimeMs, e.fileSize, INSIGHTS_EXTRACTOR_VERSION, now, JSON.stringify(e.facets));
|
|
2201
|
+
}
|
|
2202
|
+
})();
|
|
2203
|
+
}
|
|
2204
|
+
/** Drop every cached facet row. Backs `agents insights --refresh`. */
|
|
2205
|
+
export function clearSessionInsights() {
|
|
2206
|
+
getDB().exec(`DELETE FROM session_insights`);
|
|
2207
|
+
}
|
|
2108
2208
|
export function queryAffinityRollup(options) {
|
|
2109
2209
|
const db = getDB();
|
|
2110
2210
|
const where = [];
|
|
@@ -13,6 +13,9 @@ export interface FileChange {
|
|
|
13
13
|
path: string;
|
|
14
14
|
op: FileOp;
|
|
15
15
|
}
|
|
16
|
+
export declare const READ_TOOLS: Set<string>;
|
|
17
|
+
export declare const WRITE_TOOLS: Set<string>;
|
|
18
|
+
export declare const EDIT_TOOLS: Set<string>;
|
|
16
19
|
/**
|
|
17
20
|
* Path-shaped noise that must never surface as a session "change": shell
|
|
18
21
|
* redirect tokens (`2>&1`), unexpanded env-var prefixes (`$WT/...`), dependency
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
import { bucketKey, classifyBashCommand } from './bash-command.js';
|
|
11
11
|
// Tool vocab mirrors parse.ts / render.ts so classification matches what those
|
|
12
12
|
// modules already recognize across Claude/Codex/others.
|
|
13
|
-
const READ_TOOLS = new Set(['Read', 'read_file', 'view_file', 'cat_file', 'get_file']);
|
|
14
|
-
const WRITE_TOOLS = new Set(['Write', 'write_file', 'create_file']);
|
|
15
|
-
const EDIT_TOOLS = new Set(['Edit', 'edit_file', 'replace', 'patch', 'MultiEdit', 'apply_patch']);
|
|
13
|
+
export const READ_TOOLS = new Set(['Read', 'read_file', 'view_file', 'cat_file', 'get_file']);
|
|
14
|
+
export const WRITE_TOOLS = new Set(['Write', 'write_file', 'create_file', 'Create']);
|
|
15
|
+
export const EDIT_TOOLS = new Set(['Edit', 'edit_file', 'replace', 'patch', 'MultiEdit', 'apply_patch']);
|
|
16
16
|
/**
|
|
17
17
|
* Path-shaped noise that must never surface as a session "change": shell
|
|
18
18
|
* redirect tokens (`2>&1`), unexpanded env-var prefixes (`$WT/...`), dependency
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behavioural facets of a coding session, and the cross-session rollup built from them.
|
|
3
|
+
*
|
|
4
|
+
* This is the engine behind `agents insights`. It answers "how do you work" rather than
|
|
5
|
+
* "what did you spend" (`agents cost`) or "what shipped" (`agents output`), and it is
|
|
6
|
+
* the only surface that splits any of it by the account that produced the work.
|
|
7
|
+
*
|
|
8
|
+
* Everything here is a pure function of a parsed `SessionEvent[]`, so it is testable
|
|
9
|
+
* without a database and cheap to re-run. The expensive part is parsing the transcript,
|
|
10
|
+
* which is why results are cached per session in `session_insights` and recomputed only
|
|
11
|
+
* when the file's (mtime, size) changes — the same staleness contract as `scan_ledger`.
|
|
12
|
+
*
|
|
13
|
+
* Prior art: Claude Code's own `/insights`, which computes a comparable set from
|
|
14
|
+
* `~/.claude/projects` alone. Two deliberate differences:
|
|
15
|
+
*
|
|
16
|
+
* - It sees ONE account's directory. This reads every indexed session, across every
|
|
17
|
+
* Claude account and every other harness, and reports them apart.
|
|
18
|
+
* - It collapses conversation branches before counting. agents-cli is file-per-session
|
|
19
|
+
* throughout (`discover.ts` keys on the transcript's basename), so session counts
|
|
20
|
+
* here will read slightly higher than `/insights` on the same machine. Reported as
|
|
21
|
+
* the raw file count rather than quietly differing.
|
|
22
|
+
*/
|
|
23
|
+
import type { SessionEvent } from './types.js';
|
|
24
|
+
/** Behavioural facets of one session. Serialized as JSON into `session_insights`. */
|
|
25
|
+
export interface InsightFacets {
|
|
26
|
+
toolCounts: Record<string, number>;
|
|
27
|
+
/** Per-model assistant turn counts. `/insights` has no model dimension at all. */
|
|
28
|
+
models: Record<string, number>;
|
|
29
|
+
languages: Record<string, number>;
|
|
30
|
+
/** Slash commands the user invoked, by name. */
|
|
31
|
+
slashCommands: Record<string, number>;
|
|
32
|
+
errorCategories: Record<string, number>;
|
|
33
|
+
/** Times the user cut a turn short — the `interrupt` event from parse.ts. */
|
|
34
|
+
interruptions: number;
|
|
35
|
+
/** Seconds between an assistant's last event and the user's next message. */
|
|
36
|
+
responseGaps: number[];
|
|
37
|
+
/** Gaps excluded for exceeding the ceiling — reported, never silently dropped. */
|
|
38
|
+
gapsOverCeiling: number;
|
|
39
|
+
/**
|
|
40
|
+
* Lines in the BEFORE and AFTER text of every edit and write — "lines touched", not
|
|
41
|
+
* a diff. An Edit whose old_string is three unchanged context lines counts them in
|
|
42
|
+
* both; git counts them zero times. Measured against a real commit the added figure
|
|
43
|
+
* ran 19% high and the removed figure 475% high, so this must never be rendered as
|
|
44
|
+
* a diffstat. Computing a true delta needs a line-level diff per edit, which is a
|
|
45
|
+
* different feature.
|
|
46
|
+
*/
|
|
47
|
+
linesTouchedBefore: number;
|
|
48
|
+
linesTouchedAfter: number;
|
|
49
|
+
/**
|
|
50
|
+
* Edit/write calls in a vocabulary we recognise. NOT a proxy for measurability:
|
|
51
|
+
* codex renames `apply_patch` to `Edit` but carries no line-bearing arguments, so it
|
|
52
|
+
* reports edit calls with zero lines. Callers decide "measurable" from the line
|
|
53
|
+
* totals themselves.
|
|
54
|
+
*/
|
|
55
|
+
editingToolCalls: number;
|
|
56
|
+
filesCreated: number;
|
|
57
|
+
filesModified: number;
|
|
58
|
+
filesDeleted: number;
|
|
59
|
+
gitCommits: number;
|
|
60
|
+
gitPushes: number;
|
|
61
|
+
/**
|
|
62
|
+
* Tool calls that carried an actual command string to search. Not every harness
|
|
63
|
+
* populates one: the codex parser sets `command` for `exec_command` but not for
|
|
64
|
+
* plain `exec`, its dominant tool, so git activity is structurally invisible there.
|
|
65
|
+
* 0 means the commit counts are unmeasurable, not observed-zero.
|
|
66
|
+
*/
|
|
67
|
+
shellCommandsSeen: number;
|
|
68
|
+
/** 24 slots, local time, indexed by hour of the user's messages. */
|
|
69
|
+
messageHours: number[];
|
|
70
|
+
userTurns: number;
|
|
71
|
+
assistantTurns: number;
|
|
72
|
+
toolCount: number;
|
|
73
|
+
errorCount: number;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Compute every behavioural facet of one session from its parsed events.
|
|
77
|
+
*
|
|
78
|
+
* Pure: no I/O, no clock, no filesystem. `timezoneOffsetMinutes` is injected rather
|
|
79
|
+
* than read from the environment so the hour histogram is deterministic in tests and
|
|
80
|
+
* can be re-bucketed for a different display timezone without re-parsing.
|
|
81
|
+
*/
|
|
82
|
+
export declare function computeInsightFacets(events: SessionEvent[], timezoneOffsetMinutes?: number): InsightFacets;
|
|
83
|
+
/** Percentile of a numeric sample, nearest-rank. Returns 0 for an empty sample. */
|
|
84
|
+
export declare function percentile(values: number[], p: number): number;
|
|
85
|
+
/** Bucket response gaps for display. Returns every bucket, including empty ones. */
|
|
86
|
+
export declare function bucketGaps(gaps: number[]): Array<{
|
|
87
|
+
bucket: string;
|
|
88
|
+
count: number;
|
|
89
|
+
}>;
|
|
90
|
+
/** A session's time span, for overlap detection. */
|
|
91
|
+
export interface SessionSpan {
|
|
92
|
+
id: string;
|
|
93
|
+
accountKey: string;
|
|
94
|
+
startMs: number;
|
|
95
|
+
endMs: number;
|
|
96
|
+
}
|
|
97
|
+
/** How much work ran concurrently, and how much of it straddled two accounts. */
|
|
98
|
+
export interface OverlapReport {
|
|
99
|
+
/** Pairs of sessions whose spans intersect. */
|
|
100
|
+
overlappingPairs: number;
|
|
101
|
+
/** Of those, pairs belonging to DIFFERENT accounts. */
|
|
102
|
+
crossAccountPairs: number;
|
|
103
|
+
/** Distinct sessions involved in any overlap. */
|
|
104
|
+
sessionsInvolved: number;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Detect concurrent sessions by interval intersection.
|
|
108
|
+
*
|
|
109
|
+
* This is the metric that makes the account split legible rather than academic: a
|
|
110
|
+
* cross-account overlap is `balanced` rotation actively running two orgs' quota at the
|
|
111
|
+
* same moment. `/insights` has a comparable "multi-clauding" count, but with one
|
|
112
|
+
* account it can only ever report the same-account case.
|
|
113
|
+
*
|
|
114
|
+
* Sweep in start order, keeping only spans that could still intersect, so this is
|
|
115
|
+
* O(n log n + pairs) rather than O(n^2) over ~3k sessions.
|
|
116
|
+
*/
|
|
117
|
+
export declare function detectOverlap(spans: SessionSpan[]): OverlapReport;
|
|
118
|
+
/** Merge a session's facets into a running total. */
|
|
119
|
+
export declare function mergeFacets(into: InsightFacets, add: InsightFacets): void;
|
|
120
|
+
/** A fresh zeroed accumulator, for callers folding many sessions together. */
|
|
121
|
+
export declare function newFacetAccumulator(): InsightFacets;
|
|
122
|
+
/** Top-N entries of a count map, highest first, ties broken by name for determinism. */
|
|
123
|
+
export declare function topEntries(counts: Record<string, number>, limit: number): Array<{
|
|
124
|
+
name: string;
|
|
125
|
+
count: number;
|
|
126
|
+
}>;
|