@phnx-labs/agents-cli 1.22.21 → 1.22.23

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 (105) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/README.md +8 -2
  3. package/dist/commands/doctor.js +15 -7
  4. package/dist/commands/exec.js +20 -6
  5. package/dist/commands/focus.d.ts +76 -4
  6. package/dist/commands/focus.js +219 -40
  7. package/dist/commands/fork.d.ts +20 -2
  8. package/dist/commands/fork.js +91 -64
  9. package/dist/commands/go.d.ts +25 -0
  10. package/dist/commands/go.js +63 -2
  11. package/dist/commands/harness-wizard.d.ts +206 -0
  12. package/dist/commands/harness-wizard.js +403 -0
  13. package/dist/commands/harness.d.ts +12 -0
  14. package/dist/commands/harness.js +97 -101
  15. package/dist/commands/insights.d.ts +32 -0
  16. package/dist/commands/insights.js +478 -0
  17. package/dist/commands/resume.js +10 -3
  18. package/dist/commands/secrets.js +25 -30
  19. package/dist/commands/sessions-picker.d.ts +2 -0
  20. package/dist/commands/sessions-picker.js +1 -0
  21. package/dist/commands/sessions-resume.d.ts +21 -3
  22. package/dist/commands/sessions-resume.js +50 -11
  23. package/dist/commands/sessions.d.ts +81 -5
  24. package/dist/commands/sessions.js +331 -66
  25. package/dist/commands/watchdog.js +13 -2
  26. package/dist/index.js +3 -1
  27. package/dist/lib/agents.d.ts +1 -1
  28. package/dist/lib/agents.js +132 -0
  29. package/dist/lib/codex-policy.d.ts +17 -0
  30. package/dist/lib/codex-policy.js +48 -0
  31. package/dist/lib/crabbox/lease.d.ts +25 -0
  32. package/dist/lib/crabbox/lease.js +62 -0
  33. package/dist/lib/daemon.js +70 -0
  34. package/dist/lib/exec.d.ts +4 -0
  35. package/dist/lib/exec.js +88 -54
  36. package/dist/lib/feed-broadcast.d.ts +1 -20
  37. package/dist/lib/feed-broadcast.js +31 -1
  38. package/dist/lib/hooks.js +12 -2
  39. package/dist/lib/hosts/passthrough.js +1 -0
  40. package/dist/lib/mcp.js +44 -0
  41. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +1 -5
  42. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  43. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -15
  44. package/dist/lib/models.d.ts +0 -5
  45. package/dist/lib/models.js +48 -0
  46. package/dist/lib/picker.d.ts +45 -0
  47. package/dist/lib/picker.js +75 -6
  48. package/dist/lib/plugin-marketplace.js +9 -0
  49. package/dist/lib/pricing/prices.json +119 -92
  50. package/dist/lib/pricing/table.js +13 -0
  51. package/dist/lib/remote-agents-json.d.ts +29 -1
  52. package/dist/lib/remote-agents-json.js +47 -10
  53. package/dist/lib/resources/mcp.js +2 -0
  54. package/dist/lib/resources/permissions.js +3 -0
  55. package/dist/lib/resources/types.d.ts +2 -1
  56. package/dist/lib/runner.js +28 -19
  57. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  58. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -2
  59. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  60. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -13
  61. package/dist/lib/secrets/index.d.ts +15 -1
  62. package/dist/lib/secrets/index.js +118 -49
  63. package/dist/lib/secrets/reaper.d.ts +87 -0
  64. package/dist/lib/secrets/reaper.js +184 -0
  65. package/dist/lib/secrets/remote.d.ts +29 -0
  66. package/dist/lib/secrets/remote.js +37 -1
  67. package/dist/lib/session/active.d.ts +36 -1
  68. package/dist/lib/session/active.js +60 -19
  69. package/dist/lib/session/actor-sidecar.d.ts +14 -0
  70. package/dist/lib/session/actor-sidecar.js +67 -4
  71. package/dist/lib/session/db.d.ts +35 -1
  72. package/dist/lib/session/db.js +132 -1
  73. package/dist/lib/session/digest.d.ts +3 -0
  74. package/dist/lib/session/digest.js +3 -3
  75. package/dist/lib/session/discover.js +168 -0
  76. package/dist/lib/session/insights.d.ts +126 -0
  77. package/dist/lib/session/insights.js +330 -0
  78. package/dist/lib/session/parse.d.ts +29 -2
  79. package/dist/lib/session/parse.js +111 -5
  80. package/dist/lib/session/remote-list.d.ts +10 -1
  81. package/dist/lib/session/remote-list.js +2 -8
  82. package/dist/lib/session/remote.d.ts +57 -3
  83. package/dist/lib/session/remote.js +90 -26
  84. package/dist/lib/session/resume-command.d.ts +6 -0
  85. package/dist/lib/session/resume-command.js +8 -0
  86. package/dist/lib/session/session-cache.d.ts +173 -0
  87. package/dist/lib/session/session-cache.js +399 -0
  88. package/dist/lib/session/types.d.ts +2 -2
  89. package/dist/lib/session/types.js +1 -1
  90. package/dist/lib/session/width.d.ts +1 -1
  91. package/dist/lib/session/width.js +12 -2
  92. package/dist/lib/shims.d.ts +2 -2
  93. package/dist/lib/shims.js +40 -5
  94. package/dist/lib/staleness/writers/hooks.js +1 -1
  95. package/dist/lib/startup/command-registry.d.ts +1 -0
  96. package/dist/lib/startup/command-registry.js +2 -0
  97. package/dist/lib/types.d.ts +1 -1
  98. package/dist/lib/usage.d.ts +13 -0
  99. package/dist/lib/usage.js +254 -3
  100. package/dist/lib/versions.js +13 -2
  101. package/package.json +1 -1
  102. package/dist/bin/agents +0 -0
  103. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  104. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  105. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
@@ -26,7 +26,7 @@ const DB_PATH = getSessionsDbPath();
26
26
  /** Current schema version; bumped when migrations are added. Exported so tests
27
27
  * assert against the constant instead of hardcoding a number that every bump
28
28
  * then has to chase (docs/05-sessions.md calls the constant the source of truth). */
29
- export const SCHEMA_VERSION = 33;
29
+ export const SCHEMA_VERSION = 34;
30
30
  /**
31
31
  * Bump to force `agents sessions backfill resources` to re-derive every
32
32
  * session's skill/slash-command tallies on its next run (resource_scan_ledger
@@ -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
@@ -753,6 +779,37 @@ function migrateSchema(db, fromVersion) {
753
779
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_account_key ON sessions(account_key)`);
754
780
  backfillClaudeAccounts(db);
755
781
  }
782
+ if (fromVersion < 34) {
783
+ // v33 -> v34: claude-opus-5 and claude-sonnet-5 were missing from the pricing
784
+ // table. `getModelPricing` matches on a dash-bounded prefix, so neither could fall
785
+ // back to its Claude 4 entry: both resolved to null and every session using them
786
+ // priced to $0 -- silently, because an unpriced model contributes nothing rather
787
+ // than raising. On one real index that was 526 sessions, 478 of them the current
788
+ // default model.
789
+ //
790
+ // Adding the prices alone fixes nothing already indexed: cost_usd is computed at
791
+ // scan time, and the scanner skips any transcript whose (file_mtime_ms, file_size)
792
+ // is unchanged. Nor can those rows be repaired in place -- the row stores
793
+ // token_count and output_tokens, not the uncached-input / cache-read / cache-write
794
+ // split the price table needs -- so the figure has to come from re-reading the
795
+ // transcript.
796
+ //
797
+ // Flush ONLY the affected transcripts, not the whole ledger. A blanket
798
+ // `DELETE FROM scan_ledger` (what v5 -> v6 did when cost was introduced) would
799
+ // re-parse every session on the next scan and break the contract the other
800
+ // migrations here are tested against: adding a column must not invalidate warm
801
+ // session ledgers. Scoping it to the rows that actually mispriced keeps every
802
+ // other ledger entry warm and still guarantees the numbers correct themselves.
803
+ db.exec(`
804
+ DELETE FROM scan_ledger
805
+ WHERE file_path IN (
806
+ SELECT file_path FROM sessions
807
+ WHERE cost_usd IS NULL
808
+ AND file_path IS NOT NULL AND file_path <> ''
809
+ AND (model LIKE 'claude-opus-5%' OR model LIKE 'claude-sonnet-5%')
810
+ );
811
+ `);
812
+ }
756
813
  }
757
814
  /**
758
815
  * Stamp `account_key` / `account_org` / `account` on every Claude row from its
@@ -2105,6 +2162,80 @@ export function countSessions(options = {}) {
2105
2162
  const row = db.prepare(sql).get(...params);
2106
2163
  return row ? row.n : 0;
2107
2164
  }
2165
+ /** What to group a usage rollup by. */
2166
+ /**
2167
+ * Read cached facets for the given sessions, dropping any row that is stale.
2168
+ *
2169
+ * Staleness is decided in SQL against the session's own `file_mtime_ms` / `file_size`,
2170
+ * the same pair the scanner maintains — so the cache cannot disagree with the index,
2171
+ * `IS` rather than `=` so a source with no statable file — NULL on both sides — is a
2172
+ * cache HIT rather than a permanent miss that re-parses it on every run.
2173
+ */
2174
+ export function readSessionInsights(ids) {
2175
+ const db = getDB();
2176
+ const out = new Map();
2177
+ if (ids.length === 0)
2178
+ return out;
2179
+ const CHUNK = 400; // chunk.length + 1 binds, well under SQLite's 999-variable limit
2180
+ for (let i = 0; i < ids.length; i += CHUNK) {
2181
+ const chunk = ids.slice(i, i + CHUNK);
2182
+ const phs = chunk.map(() => '?').join(',');
2183
+ const rows = db.prepare(`
2184
+ SELECT si.session_id AS id, si.facets AS facets
2185
+ FROM session_insights si
2186
+ JOIN sessions s ON s.id = si.session_id
2187
+ WHERE si.session_id IN (${phs})
2188
+ AND si.extractor_version = ?
2189
+ AND si.file_mtime_ms IS s.file_mtime_ms
2190
+ AND si.file_size IS s.file_size
2191
+ `).all(...chunk, INSIGHTS_EXTRACTOR_VERSION);
2192
+ for (const row of rows) {
2193
+ try {
2194
+ out.set(row.id, JSON.parse(row.facets));
2195
+ }
2196
+ catch {
2197
+ // A corrupt cache row is not a reason to fail the report; recompute it.
2198
+ }
2199
+ }
2200
+ }
2201
+ return out;
2202
+ }
2203
+ /**
2204
+ * Persist freshly computed facets against the stamp of the bytes actually parsed.
2205
+ *
2206
+ * The caller passes the stat it observed when it read the file. Re-reading the stamp
2207
+ * from the sessions table inside this INSERT would race: a concurrent rescan between
2208
+ * the parse and the write (the cold path flushes in batches, so the window is minutes
2209
+ * wide, and this module treats concurrent access as a design assumption) stamps NEW
2210
+ * bytes onto OLD facets — a permanent false cache hit until the file changes again.
2211
+ * tool-index.ts sets the precedent: stat at parse time, carry the stamp into the write.
2212
+ */
2213
+ export function writeSessionInsights(entries) {
2214
+ if (entries.length === 0)
2215
+ return;
2216
+ const db = getDB();
2217
+ const stmt = db.prepare(`
2218
+ INSERT INTO session_insights
2219
+ (session_id, file_mtime_ms, file_size, extractor_version, computed_at, facets)
2220
+ VALUES (?, ?, ?, ?, ?, ?)
2221
+ ON CONFLICT(session_id) DO UPDATE SET
2222
+ file_mtime_ms = excluded.file_mtime_ms,
2223
+ file_size = excluded.file_size,
2224
+ extractor_version = excluded.extractor_version,
2225
+ computed_at = excluded.computed_at,
2226
+ facets = excluded.facets
2227
+ `);
2228
+ const now = Date.now();
2229
+ db.transaction(() => {
2230
+ for (const e of entries) {
2231
+ stmt.run(e.id, e.fileMtimeMs, e.fileSize, INSIGHTS_EXTRACTOR_VERSION, now, JSON.stringify(e.facets));
2232
+ }
2233
+ })();
2234
+ }
2235
+ /** Drop every cached facet row. Backs `agents insights --refresh`. */
2236
+ export function clearSessionInsights() {
2237
+ getDB().exec(`DELETE FROM session_insights`);
2238
+ }
2108
2239
  export function queryAffinityRollup(options) {
2109
2240
  const db = getDB();
2110
2241
  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
@@ -47,6 +47,8 @@ const HOME = os.homedir();
47
47
  const VERSIONS_ROOTS = [getHistoryDir(), getAgentsDir()];
48
48
  const RUSH_SESSIONS_DIR = path.join(HOME, '.rush', 'sessions');
49
49
  const HERMES_SESSIONS_DIR = path.join(HOME, '.hermes', 'sessions');
50
+ /** Muse Code sessions: ~/.local/share/muse/sessions/YYYY/MM/DD/<uuid>/session.jsonl */
51
+ const MUSE_SESSIONS_DIR = path.join(HOME, '.local', 'share', 'muse', 'sessions');
50
52
  /** How long OpenClaw channel/cron snapshots stay valid before we re-shell-out. */
51
53
  const OPENCLAW_TTL_MS = 60_000;
52
54
  const ACTIVE_APPEND_RESCAN_DEBOUNCE_MS = 5_000;
@@ -303,6 +305,7 @@ function dispatchAgentScan(agent, onProgress) {
303
305
  case 'droid': return scanDroidIncremental(onProgress);
304
306
  case 'grok': return scanGrokIncremental(onProgress);
305
307
  case 'cursor': return scanCursorIncremental(onProgress);
308
+ case 'muse': return scanMuseIncremental(onProgress);
306
309
  default: return Promise.resolve();
307
310
  }
308
311
  }
@@ -2274,6 +2277,171 @@ function readHermesMeta(filePath) {
2274
2277
  };
2275
2278
  return { meta, content: userTexts.join('\n') };
2276
2279
  }
2280
+ /**
2281
+ * Muse Code stores one session per directory under
2282
+ * ~/.local/share/muse/sessions/YYYY/MM/DD/<uuid>/session.jsonl (event-sourced
2283
+ * JSONL). Walk the tree for session.jsonl files and index each one.
2284
+ *
2285
+ * Also scan version homes (`versions/muse/<v>/home/.local/share/muse/sessions`)
2286
+ * so managed/isolated runs that rewrite HOME still show up in `agents sessions`.
2287
+ */
2288
+ function scanMuseIncremental(onProgress) {
2289
+ const roots = [];
2290
+ if (fs.existsSync(MUSE_SESSIONS_DIR))
2291
+ roots.push(MUSE_SESSIONS_DIR);
2292
+ for (const root of VERSIONS_ROOTS) {
2293
+ const versionsBase = path.join(root, 'versions', 'muse');
2294
+ if (!fs.existsSync(versionsBase))
2295
+ continue;
2296
+ try {
2297
+ for (const version of fs.readdirSync(versionsBase)) {
2298
+ const dir = path.join(versionsBase, version, 'home', '.local', 'share', 'muse', 'sessions');
2299
+ if (fs.existsSync(dir))
2300
+ roots.push(dir);
2301
+ }
2302
+ }
2303
+ catch {
2304
+ /* unreadable */
2305
+ }
2306
+ }
2307
+ if (roots.length === 0)
2308
+ return Promise.resolve();
2309
+ const filePaths = [];
2310
+ const walk = (dir) => {
2311
+ let entries;
2312
+ try {
2313
+ entries = fs.readdirSync(dir, { withFileTypes: true });
2314
+ }
2315
+ catch {
2316
+ return;
2317
+ }
2318
+ for (const ent of entries) {
2319
+ const full = path.join(dir, ent.name);
2320
+ if (ent.isDirectory()) {
2321
+ // Skip subagent child sessions — nested under subagent/<id>/
2322
+ if (ent.name === 'subagent')
2323
+ continue;
2324
+ walk(full);
2325
+ }
2326
+ else if (ent.name === 'session.jsonl') {
2327
+ filePaths.push(full);
2328
+ }
2329
+ }
2330
+ };
2331
+ for (const root of roots)
2332
+ walk(root);
2333
+ const changed = filterChangedFiles(filePaths);
2334
+ if (changed.length === 0)
2335
+ return Promise.resolve();
2336
+ onProgress?.({ agent: 'muse', parsed: 0, total: changed.length });
2337
+ const scanEntries = [];
2338
+ const touched = [];
2339
+ const seen = new Set();
2340
+ let parsed = 0;
2341
+ for (const { filePath, scan } of changed) {
2342
+ try {
2343
+ const result = readMuseMeta(filePath);
2344
+ if (result && !seen.has(result.meta.id)) {
2345
+ seen.add(result.meta.id);
2346
+ scanEntries.push({ meta: result.meta, content: result.content, scan });
2347
+ }
2348
+ else {
2349
+ touched.push({ filePath, scan });
2350
+ }
2351
+ }
2352
+ catch {
2353
+ touched.push({ filePath, scan });
2354
+ }
2355
+ parsed++;
2356
+ onProgress?.({ agent: 'muse', parsed, total: changed.length });
2357
+ }
2358
+ upsertSessionsBatch(scanEntries);
2359
+ recordScans(touched);
2360
+ return Promise.resolve();
2361
+ }
2362
+ /** Parse a Muse session.jsonl for session metadata + first user prompt text. */
2363
+ function readMuseMeta(filePath) {
2364
+ // Path shape: .../sessions/YYYY/MM/DD/<uuid>/session.jsonl
2365
+ const sessionId = path.basename(path.dirname(filePath));
2366
+ if (!/^[0-9a-f-]{36}$/i.test(sessionId))
2367
+ return null;
2368
+ let content;
2369
+ try {
2370
+ content = fs.readFileSync(filePath, 'utf-8');
2371
+ }
2372
+ catch {
2373
+ return null;
2374
+ }
2375
+ const userTexts = [];
2376
+ let topic;
2377
+ let messageCount = 0;
2378
+ let model;
2379
+ let project;
2380
+ let firstTs;
2381
+ let lastTs;
2382
+ for (const line of content.split('\n')) {
2383
+ if (!line.trim())
2384
+ continue;
2385
+ let raw;
2386
+ try {
2387
+ raw = JSON.parse(line);
2388
+ }
2389
+ catch {
2390
+ continue;
2391
+ }
2392
+ if (typeof raw.recorded_at === 'number') {
2393
+ const v = raw.recorded_at;
2394
+ // Muse logs use microseconds; values < 1e14 are treated as milliseconds.
2395
+ const ms = v > 1e14 ? Math.floor(v / 1000) : v;
2396
+ const ts = new Date(ms).toISOString();
2397
+ if (!firstTs)
2398
+ firstTs = ts;
2399
+ lastTs = ts;
2400
+ }
2401
+ const payloadType = raw.payload_type;
2402
+ const payload = raw.payload;
2403
+ if (payloadType === 'runtime.session.metadata') {
2404
+ const record = payload?.record;
2405
+ if (typeof record?.workspace_root === 'string')
2406
+ project = record.workspace_root;
2407
+ }
2408
+ if (payloadType === 'runtime.command_intake.received') {
2409
+ const cmd = payload?.record?.command;
2410
+ if (cmd?.kind === 'turn_submit' && typeof cmd.prompt === 'string' && cmd.prompt.trim()) {
2411
+ messageCount++;
2412
+ userTexts.push(cmd.prompt.trim());
2413
+ if (!topic)
2414
+ topic = extractSessionTopic(cmd.prompt.trim());
2415
+ }
2416
+ }
2417
+ const event = payload?.event ?? payload;
2418
+ if (event?.kind === 'assistant_message_committed' && typeof event.text === 'string') {
2419
+ messageCount++;
2420
+ }
2421
+ if (event?.kind === 'model_request_configured' && typeof event.model === 'string') {
2422
+ model = event.model;
2423
+ }
2424
+ if (typeof event?.model === 'string' && !model)
2425
+ model = event.model;
2426
+ }
2427
+ const stat = safeStatSync(filePath);
2428
+ const timestamp = lastTs
2429
+ || firstTs
2430
+ || (stat ? stat.mtime.toISOString() : new Date().toISOString());
2431
+ const shortId = deriveShortId(sessionId);
2432
+ const meta = {
2433
+ id: sessionId,
2434
+ shortId,
2435
+ agent: 'muse',
2436
+ timestamp,
2437
+ project,
2438
+ filePath,
2439
+ model,
2440
+ topic,
2441
+ messageCount: messageCount || undefined,
2442
+ };
2443
+ return { meta, content: userTexts.join('\n') };
2444
+ }
2277
2445
  /** Extract plain text from a Hermes message content field (string or list of parts). */
2278
2446
  function extractHermesMessageText(content) {
2279
2447
  if (typeof content === 'string')
@@ -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
+ }>;