@phnx-labs/agents-cli 1.22.21 → 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.
@@ -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
@@ -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: cfg.pageSize ?? 10,
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: cfg.pageSize ?? 10,
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: cfg.pageSize ?? 12,
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);
@@ -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
@@ -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
+ }>;