@faithfulalabi/agent-lens 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,15 +55,27 @@ only by the archive.
55
55
  agent-lens schedule install
56
56
  ```
57
57
 
58
- On macOS that sets up a launchd job that runs `agent-lens archive` every 15 minutes and logs each
59
- pass where `agent-lens doctor` reads it. Run it again any time — it replaces its own job rather
60
- than duplicating it. `agent-lens schedule status` reports the job and its last successful pass;
61
- `agent-lens schedule disable` turns it off cleanly, leaving the archive and its logs untouched. On
62
- other platforms, run `agent-lens archive` every ~15 minutes yourself via a systemd timer or cron.
58
+ That sets up a recurring job that runs `agent-lens archive` every 15 minutes and logs each pass
59
+ where `agent-lens doctor` reads it. Both supported platforms are handled for you: macOS gets a
60
+ launchd agent, Linux gets a systemd **user** timer under `~/.config/systemd/user`. Run it again any
61
+ time — it replaces its own job rather than duplicating it. `agent-lens schedule status` reports the
62
+ job and its last successful pass; `agent-lens schedule disable` turns it off cleanly, leaving the
63
+ archive and its logs untouched. On any other platform, run `agent-lens archive` every ~15 minutes
64
+ yourself from whatever scheduler you have, such as a cron entry.
63
65
 
64
66
  One caveat worth knowing before you rely on an interval: a wall-clock schedule does not fire while
65
67
  the machine is asleep. Treat the interval as a bound on _wake_ time, not on elapsed time.
66
68
 
69
+ One more on Linux, and it is the difference between a green report and a real archive:
70
+
71
+ > **a systemd user timer does not run while you are logged out unless lingering is on for your user**
72
+
73
+ `agent-lens schedule status` reports which state your user is in, and `schedule install` prints the
74
+ one command that changes it — `loginctl enable-linger <uid>`. The command is printed and never run:
75
+ it writes outside everything this tool owns. The two platforms also differ deliberately on catch-up.
76
+ launchd runs a pass at load; the systemd timer uses `OnCalendar` with `Persistent=true`, so a slot
77
+ missed while the machine was off runs once on resume.
78
+
67
79
  The pass is safe to run often: an advisory lock means a second concurrent pass copies nothing and
68
80
  exits 0, and an unchanged corpus copies zero bytes. When a source has been rewritten, the archived
69
81
  bytes are **kept** and the file is marked `diverged` rather than overwritten. `agent-lens archive
@@ -53,6 +53,18 @@ export function resolveLaunchAgentsDir(homeDir = homedir()) {
53
53
  export function resolvePlistPath(label, homeDir = homedir()) {
54
54
  return join(resolveLaunchAgentsDir(homeDir), `${label}.plist`);
55
55
  }
56
+ export function resolveSystemdUserDir(homeDir = homedir(), configHome) {
57
+ const base = configHome !== undefined && configHome !== '' && isAbsolute(configHome)
58
+ ? configHome
59
+ : join(homeDir, '.config');
60
+ return join(base, 'systemd', 'user');
61
+ }
62
+ export function resolveSystemdUnitPath(name, homeDir, configHome) {
63
+ return join(resolveSystemdUserDir(homeDir, configHome), name);
64
+ }
65
+ export function resolveSystemdWantsPath(name, homeDir, configHome) {
66
+ return join(resolveSystemdUserDir(homeDir, configHome), 'timers.target.wants', name);
67
+ }
56
68
  export function resolveScheduleWrapperPath(dataDir) {
57
69
  return join(resolveDataDir(dataDir), 'schedule', 'archive.sh');
58
70
  }
@@ -3,23 +3,40 @@ import { chmodSync, existsSync, mkdirSync, renameSync, rmdirSync, unlinkSync, wr
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { assertNotUnderRoot, lstatSafe, resolveCronLogPath, resolveDataDir, resolvePlistPath, resolveScheduleWrapperPath, resolveTranscriptRoot, TRANSCRIPT_ROOT_LABEL, } from '../../archive/paths.js';
6
+ import { assertNotUnderRoot, lstatSafe, resolveCronLogPath, resolveDataDir, resolvePlistPath, resolveScheduleWrapperPath, resolveSystemdUnitPath, resolveSystemdUserDir, resolveSystemdWantsPath, resolveTranscriptRoot, TRANSCRIPT_ROOT_LABEL, } from '../../archive/paths.js';
7
7
  import { parseStringFlag } from './archive.js';
8
8
  export const SCHEDULE_LABEL = 'com.agent-lens.archive';
9
9
  export const LEGACY_LABEL = 'com.faithful.agent-lens.archive';
10
10
  const START_INTERVAL_SECONDS = 900;
11
11
  export const CRON_STAMP_FORMAT = '+%Y-%m-%dT%H:%M:%S%z';
12
12
  export const WAKE_TIME_CAVEAT = 'a wall-clock schedule does not fire while the machine is asleep — treat the interval as a bound on wake time, not on elapsed time';
13
+ export const LINGER_CAVEAT = 'a systemd user timer does not run while you are logged out unless lingering is on for your user';
14
+ export const SYSTEMD_UNIT_BASE = 'agent-lens-archive';
15
+ const SYSTEMD_SERVICE = `${SYSTEMD_UNIT_BASE}.service`;
16
+ const SYSTEMD_TIMER = `${SYSTEMD_UNIT_BASE}.timer`;
17
+ const SYSTEMD_CALENDAR = '*:0/15';
18
+ const SYSTEMD_TIMER_PROPERTIES = [
19
+ 'ActiveState',
20
+ 'UnitFileState',
21
+ 'NextElapseUSecRealtime',
22
+ 'LastTriggerUSec',
23
+ 'Result',
24
+ ];
13
25
  const ACTIONS = 'install | status | disable';
14
- const NON_MACOS_POINTER = 'the recurring job uses launchd, which is macOS-only. Elsewhere, run `agent-lens archive` ' +
15
- 'every ~15 minutes yourself — a systemd timer or cron entry — per the README section ' +
16
- '"Keeping the archive current".';
17
- function realLaunchctl(args) {
18
- const result = spawnSync('launchctl', args, { encoding: 'utf8' });
19
- return {
20
- status: result.status,
21
- stdout: result.stdout ?? '',
22
- stderr: result.error === undefined ? (result.stderr ?? '') : String(result.error.message),
26
+ const UNSUPPORTED_PLATFORM_POINTER = 'the recurring job uses launchd on macOS and a systemd user timer on Linux, and this platform ' +
27
+ 'is neither. Run `agent-lens archive` every ~15 minutes from whatever scheduler it does have — ' +
28
+ 'a cron entry, for instance — per the README section "Keeping the archive current". No cron ' +
29
+ 'fallback ships here on purpose: the two backends above are the two this command sets up for you.';
30
+ const BUS_HINT = ' hint: run this from a logged-in session — `systemctl --user` needs a user D-Bus, with ' +
31
+ 'XDG_RUNTIME_DIR pointing at /run/user/<uid>';
32
+ function realRunner(bin) {
33
+ return (args) => {
34
+ const result = spawnSync(bin, args, { encoding: 'utf8' });
35
+ return {
36
+ status: result.status,
37
+ stdout: result.stdout ?? '',
38
+ stderr: result.error === undefined ? (result.stderr ?? '') : String(result.error.message),
39
+ };
23
40
  };
24
41
  }
25
42
  function realDeps() {
@@ -29,7 +46,10 @@ function realDeps() {
29
46
  homeDir: homedir(),
30
47
  uid: process.getuid?.() ?? 0,
31
48
  moduleUrl: import.meta.url,
32
- launchctl: realLaunchctl,
49
+ launchctl: realRunner('launchctl'),
50
+ systemctl: realRunner('systemctl'),
51
+ loginctl: realRunner('loginctl'),
52
+ configHome: process.env.XDG_CONFIG_HOME,
33
53
  now: Date.now,
34
54
  };
35
55
  }
@@ -184,6 +204,56 @@ export function buildPlist(opts) {
184
204
  </plist>
185
205
  `;
186
206
  }
207
+ export function escapeUnitValue(value) {
208
+ if (/[\n\r]/.test(value)) {
209
+ throw new Error('refusing to write a systemd unit: a line break in a unit value would split the ' +
210
+ `directive — ${JSON.stringify(value)}`);
211
+ }
212
+ return value
213
+ .replace(/\\/g, '\\\\')
214
+ .replace(/"/g, '\\"')
215
+ .replace(/%/g, '%%')
216
+ .replace(/\$/g, '$$$$');
217
+ }
218
+ export function buildSystemdService(opts) {
219
+ return `[Unit]
220
+ Description=agent-lens archive — unattended pass, every ${START_INTERVAL_SECONDS / 60} minutes
221
+ #
222
+ # GENERATED by \`agent-lens schedule\` — do not edit. Turning the job on again
223
+ # rewrites this file in full, which is what keeps it current with the package
224
+ # that shipped it.
225
+ #
226
+ # Coverage is wake-time-bounded: ${escapeUnitValue(WAKE_TIME_CAVEAT)}.
227
+ #
228
+ # Exit codes are the archive command's own: 0 clean, 1 usage error / crash,
229
+ # 3 archive-side errors. 2 is RESERVED product-wide and must never appear.
230
+
231
+ [Service]
232
+ Type=oneshot
233
+ ExecStart=/bin/sh "${escapeUnitValue(opts.wrapperPath)}"
234
+ `;
235
+ }
236
+ export function buildSystemdTimer() {
237
+ return `[Unit]
238
+ Description=agent-lens archive — timer for ${escapeUnitValue(SYSTEMD_SERVICE)}
239
+ #
240
+ # GENERATED by \`agent-lens schedule\` — do not edit. Turning the job on again
241
+ # rewrites this file in full.
242
+ #
243
+ # Coverage is wake-time-bounded: ${escapeUnitValue(WAKE_TIME_CAVEAT)}.
244
+ #
245
+ # And ${escapeUnitValue(LINGER_CAVEAT)} — \`agent-lens schedule status\` reports which.
246
+
247
+ [Timer]
248
+ OnCalendar=${escapeUnitValue(SYSTEMD_CALENDAR)}
249
+ Persistent=true
250
+ AccuracySec=1min
251
+ Unit=${escapeUnitValue(SYSTEMD_SERVICE)}
252
+
253
+ [Install]
254
+ WantedBy=timers.target
255
+ `;
256
+ }
187
257
  function ensureDirOutsideCorpus(dir, mode, transcriptRoot) {
188
258
  assertNotUnderRoot(dir, transcriptRoot, TRANSCRIPT_ROOT_LABEL);
189
259
  mkdirSync(dir, { recursive: true, mode });
@@ -202,12 +272,22 @@ function removeIfPresent(path, transcriptRoot) {
202
272
  unlinkSync(path);
203
273
  return true;
204
274
  }
275
+ function removeEmptyDir(dir, transcriptRoot) {
276
+ assertNotUnderRoot(dir, transcriptRoot, TRANSCRIPT_ROOT_LABEL);
277
+ try {
278
+ rmdirSync(dir);
279
+ }
280
+ catch {
281
+ }
282
+ }
205
283
  function gui(deps, label) {
206
284
  return `gui/${deps.uid}/${label}`;
207
285
  }
208
286
  function install(dataDirFlag, deps) {
287
+ if (deps.platform === 'linux')
288
+ return installSystemd(dataDirFlag, deps);
209
289
  if (deps.platform !== 'darwin') {
210
- console.error(`agent-lens schedule: nothing was set up — ${NON_MACOS_POINTER}`);
290
+ console.error(`agent-lens schedule: nothing was set up — ${UNSUPPORTED_PLATFORM_POINTER}`);
211
291
  return 1;
212
292
  }
213
293
  const dataDir = resolveDataDir(dataDirFlag);
@@ -247,8 +327,10 @@ function install(dataDirFlag, deps) {
247
327
  return 0;
248
328
  }
249
329
  async function status(dataDirFlag, deps) {
330
+ if (deps.platform === 'linux')
331
+ return statusSystemd(dataDirFlag, deps);
250
332
  if (deps.platform !== 'darwin') {
251
- console.log(`agent-lens schedule: ${NON_MACOS_POINTER}`);
333
+ console.log(`agent-lens schedule: ${UNSUPPORTED_PLATFORM_POINTER}`);
252
334
  console.log(`note: ${WAKE_TIME_CAVEAT}`);
253
335
  return 0;
254
336
  }
@@ -275,8 +357,10 @@ async function status(dataDirFlag, deps) {
275
357
  return 0;
276
358
  }
277
359
  function disable(dataDirFlag, deps) {
360
+ if (deps.platform === 'linux')
361
+ return disableSystemd(dataDirFlag, deps);
278
362
  if (deps.platform !== 'darwin') {
279
- console.error(`agent-lens schedule: nothing was removed — ${NON_MACOS_POINTER}`);
363
+ console.error(`agent-lens schedule: nothing was removed — ${UNSUPPORTED_PLATFORM_POINTER}`);
280
364
  return 1;
281
365
  }
282
366
  const transcriptRoot = resolveTranscriptRoot();
@@ -291,11 +375,7 @@ function disable(dataDirFlag, deps) {
291
375
  removed.push(plistPath);
292
376
  if (removeIfPresent(wrapperPath, transcriptRoot))
293
377
  removed.push(wrapperPath);
294
- try {
295
- rmdirSync(dirname(wrapperPath));
296
- }
297
- catch {
298
- }
378
+ removeEmptyDir(dirname(wrapperPath), transcriptRoot);
299
379
  if (removed.length === 0) {
300
380
  console.log('recurring archive job: not installed — nothing to remove');
301
381
  }
@@ -305,6 +385,155 @@ function disable(dataDirFlag, deps) {
305
385
  }
306
386
  return 0;
307
387
  }
388
+ function systemdPaths(deps) {
389
+ return {
390
+ unitDir: resolveSystemdUserDir(deps.homeDir, deps.configHome),
391
+ servicePath: resolveSystemdUnitPath(SYSTEMD_SERVICE, deps.homeDir, deps.configHome),
392
+ timerPath: resolveSystemdUnitPath(SYSTEMD_TIMER, deps.homeDir, deps.configHome),
393
+ wantsPath: resolveSystemdWantsPath(SYSTEMD_TIMER, deps.homeDir, deps.configHome),
394
+ };
395
+ }
396
+ function detailOf(result) {
397
+ return (result.stderr || result.stdout).trim() || `systemctl exited ${String(result.status)}`;
398
+ }
399
+ function parseProperties(stdout) {
400
+ const properties = new Map();
401
+ for (const line of stdout.split('\n')) {
402
+ const split = line.indexOf('=');
403
+ if (split > 0)
404
+ properties.set(line.slice(0, split).trim(), line.slice(split + 1).trim());
405
+ }
406
+ return properties;
407
+ }
408
+ function reported(value) {
409
+ return value === undefined || value === '' ? 'unknown — systemd reported nothing' : value;
410
+ }
411
+ function readLingerState(deps) {
412
+ const result = deps.loginctl(['show-user', String(deps.uid), '--property=Linger']);
413
+ if (result.status !== 0)
414
+ return 'unknown';
415
+ const value = parseProperties(result.stdout).get('Linger');
416
+ if (value === 'yes')
417
+ return 'on';
418
+ if (value === 'no')
419
+ return 'off';
420
+ return 'unknown';
421
+ }
422
+ function lingerLines(deps) {
423
+ const state = readLingerState(deps);
424
+ if (state === 'on') {
425
+ return [' lingering: on — the timer keeps running while you are logged out'];
426
+ }
427
+ return [
428
+ ` lingering: ${state} — ${LINGER_CAVEAT}`,
429
+ ` turn lingering on with: loginctl enable-linger ${deps.uid}`,
430
+ ];
431
+ }
432
+ function installSystemd(dataDirFlag, deps) {
433
+ const dataDir = resolveDataDir(dataDirFlag);
434
+ const transcriptRoot = resolveTranscriptRoot();
435
+ const wrapperPath = resolveScheduleWrapperPath(dataDir);
436
+ const cronLogPath = resolveCronLogPath(dataDir);
437
+ const { unitDir, servicePath, timerPath } = systemdPaths(deps);
438
+ for (const path of [wrapperPath, cronLogPath, unitDir, servicePath, timerPath]) {
439
+ assertNotUnderRoot(path, transcriptRoot, TRANSCRIPT_ROOT_LABEL);
440
+ }
441
+ const invocation = resolveArchiveInvocation(deps.moduleUrl);
442
+ const wrapperText = buildWrapperScript({
443
+ nodePath: deps.execPath,
444
+ invocation,
445
+ dataDir,
446
+ cronLogPath,
447
+ });
448
+ const serviceText = buildSystemdService({ wrapperPath });
449
+ const timerText = buildSystemdTimer();
450
+ ensureDirOutsideCorpus(dirname(wrapperPath), 0o700, transcriptRoot);
451
+ ensureDirOutsideCorpus(dirname(cronLogPath), 0o700, transcriptRoot);
452
+ ensureDirOutsideCorpus(unitDir, 0o700, transcriptRoot);
453
+ atomicWrite(wrapperPath, wrapperText, 0o700, transcriptRoot);
454
+ atomicWrite(servicePath, serviceText, 0o644, transcriptRoot);
455
+ atomicWrite(timerPath, timerText, 0o644, transcriptRoot);
456
+ const armed = [
457
+ ['--user', 'daemon-reload'],
458
+ ['--user', 'enable', SYSTEMD_TIMER],
459
+ ['--user', 'restart', SYSTEMD_TIMER],
460
+ ];
461
+ for (const argv of armed) {
462
+ const result = deps.systemctl(argv);
463
+ if (result.status !== 0) {
464
+ console.error(`agent-lens schedule: \`systemctl ${argv.join(' ')}\` failed — ${detailOf(result)}`);
465
+ if (/Failed to connect to bus/.test(result.stderr))
466
+ console.error(BUS_HINT);
467
+ return 1;
468
+ }
469
+ }
470
+ const firstPass = deps.systemctl(['--user', 'start', '--no-block', SYSTEMD_SERVICE]);
471
+ const lines = [];
472
+ if (firstPass.status !== 0) {
473
+ lines.push(`warning: the first pass could not be started — ${detailOf(firstPass)}`);
474
+ }
475
+ lines.push(`recurring archive job on — ${SYSTEMD_TIMER}, every ` +
476
+ `${START_INTERVAL_SECONDS / 60} minutes (${invocation.kind} layout)`, ` timer ${timerPath}`, ` service ${servicePath}`, ` wrapper ${wrapperPath}`, ` passes ${cronLogPath}`, ` unit log journalctl --user -u ${SYSTEMD_SERVICE}`, `note: ${WAKE_TIME_CAVEAT}`, ...lingerLines(deps));
477
+ console.log(lines.join('\n'));
478
+ return 0;
479
+ }
480
+ async function statusSystemd(dataDirFlag, deps) {
481
+ const { servicePath, timerPath } = systemdPaths(deps);
482
+ const lines = [];
483
+ if (lstatSafe(timerPath)?.isFile() === true) {
484
+ lines.push(`recurring archive job: installed — ${timerPath}`);
485
+ lines.push(` service ${servicePath}`);
486
+ const shown = deps.systemctl([
487
+ '--user',
488
+ 'show',
489
+ SYSTEMD_TIMER,
490
+ `--property=${SYSTEMD_TIMER_PROPERTIES.join(',')}`,
491
+ ]);
492
+ const properties = parseProperties(shown.stdout);
493
+ lines.push(` timer state: ${reported(properties.get('ActiveState'))}`, ` unit file: ${reported(properties.get('UnitFileState'))}`, ` next trigger: ${reported(properties.get('NextElapseUSecRealtime'))}`, ` last trigger: ${reported(properties.get('LastTriggerUSec'))}`, ` last result: ${reported(properties.get('Result'))}`);
494
+ }
495
+ else {
496
+ lines.push('recurring archive job: not installed');
497
+ lines.push(' turn it on with: agent-lens schedule install');
498
+ }
499
+ lines.push(...lingerLines(deps));
500
+ const [{ formatLastPassSection }, { readCronLogStatus }] = await Promise.all([
501
+ import('./doctor.js'),
502
+ import('../../archive/cron-log.js'),
503
+ ]);
504
+ lines.push(...formatLastPassSection(readCronLogStatus(dataDirFlag), deps.now()));
505
+ lines.push('', `note: ${WAKE_TIME_CAVEAT}`);
506
+ console.log(lines.join('\n'));
507
+ return 0;
508
+ }
509
+ function disableSystemd(dataDirFlag, deps) {
510
+ const transcriptRoot = resolveTranscriptRoot();
511
+ const wrapperPath = resolveScheduleWrapperPath(resolveDataDir(dataDirFlag));
512
+ const { servicePath, timerPath, wantsPath } = systemdPaths(deps);
513
+ const wasInstalled = lstatSafe(timerPath) !== undefined || lstatSafe(servicePath) !== undefined;
514
+ const stopped = deps.systemctl(['--user', 'stop', SYSTEMD_TIMER]);
515
+ const disabled = deps.systemctl(['--user', 'disable', SYSTEMD_TIMER]);
516
+ const removed = [];
517
+ for (const path of [timerPath, servicePath, wantsPath, wrapperPath]) {
518
+ if (removeIfPresent(path, transcriptRoot))
519
+ removed.push(path);
520
+ }
521
+ deps.systemctl(['--user', 'daemon-reload']);
522
+ removeEmptyDir(dirname(wrapperPath), transcriptRoot);
523
+ const lines = [];
524
+ const refused = [stopped, disabled].find((result) => result.status !== 0);
525
+ if (refused !== undefined && wasInstalled) {
526
+ lines.push(`warning: systemctl --user could not stop the timer — ${detailOf(refused)}`, ' the unit files were removed anyway, so nothing is left to fire');
527
+ }
528
+ if (removed.length === 0) {
529
+ lines.push('recurring archive job: not installed — nothing to remove');
530
+ }
531
+ else {
532
+ lines.push('recurring archive job off — removed:', ...removed.map((path) => ` ${path}`), ' cron.log and the archive itself are untouched');
533
+ }
534
+ console.log(lines.join('\n'));
535
+ return 0;
536
+ }
308
537
  export async function schedule(args = [], deps = realDeps()) {
309
538
  const action = parseScheduleAction(args);
310
539
  const dataDir = parseStringFlag(args, 'dataDir');
@@ -18,20 +18,28 @@ const SORT_EXPRESSIONS = {
18
18
  tokens: '(tokens_in + tokens_out)',
19
19
  errors: 'error_count',
20
20
  };
21
- const SESSION_LIST_COLUMNS = `id, title, preview, project_path, git_branch, model, harness_version,
21
+ const SESSION_LIST_COLUMNS = `id, title, preview, project_path, git_branch, model, models, harness_version,
22
22
  started_at, last_activity_at, turn_count, tool_call_count, error_count,
23
23
  tokens_in, tokens_out, tokens_cache_read, tokens_cache_write, est_cost,
24
24
  agent_count, sub_tool_call_count, sub_error_count, sub_tokens_in, sub_tokens_out,
25
- sub_tokens_cache_read, sub_tokens_cache_write, sub_est_cost, rollup_state, drift_json`;
25
+ sub_tokens_cache_read, sub_tokens_cache_write, sub_est_cost, sub_models, rollup_state, drift_json`;
26
26
  const TOP_LEVEL_ONLY = 'parent_session_id IS NULL';
27
27
  const NO_DRIFT = '{}';
28
28
  const Q_PREDICATE = "(COALESCE(title,'') || ' ' || COALESCE(preview,'') || ' ' || project_path) LIKE ? ESCAPE '\\'";
29
29
  function likeContains(q) {
30
30
  return `%${q.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
31
31
  }
32
+ function modelIdsOf(text) {
33
+ return JSON.parse(text).map(([id]) => id);
34
+ }
32
35
  function toSessionRow(row) {
33
- const { drift_json, ...rest } = row;
34
- return { ...rest, has_drift: drift_json !== NO_DRIFT };
36
+ const { drift_json, models, sub_models, ...rest } = row;
37
+ return {
38
+ ...rest,
39
+ models: modelIdsOf(models),
40
+ sub_models: modelIdsOf(sub_models),
41
+ has_drift: drift_json !== NO_DRIFT,
42
+ };
35
43
  }
36
44
  export function buildSessionListSql(query) {
37
45
  const where = [TOP_LEVEL_ONLY];
@@ -1,4 +1,4 @@
1
- export const SCHEMA_VERSION = 2;
1
+ export const SCHEMA_VERSION = 3;
2
2
  export const SCHEMA_DDL = `-- ~/.agent-lens/cache.db
3
3
  --
4
4
  -- THIS FILE IS A CACHE. Every byte is a pure function of ~/.agent-lens/archive/** --
@@ -21,7 +21,7 @@ export const SCHEMA_DDL = `-- ~/.agent-lens/cache.db
21
21
  PRAGMA journal_mode = WAL;
22
22
  PRAGMA synchronous = NORMAL;
23
23
  PRAGMA foreign_keys = OFF; -- projections are dropped wholesale, per session, by hand
24
- PRAGMA user_version = 2; -- SCHEMA_VERSION; mismatch => unlink + recreate
24
+ PRAGMA user_version = 3; -- SCHEMA_VERSION; mismatch => unlink + recreate
25
25
 
26
26
  -- ===================================================================== sessions
27
27
  -- The file index AND the session-list row AND the projection freshness header,
@@ -73,6 +73,8 @@ CREATE TABLE sessions (
73
73
  project_path TEXT NOT NULL, -- cwd; the list groups and filters on it
74
74
  git_branch TEXT,
75
75
  model TEXT, -- the model named on the most lines; NOT the last
76
+ models TEXT NOT NULL DEFAULT '[]', -- own-file distinct models, [[id, calls]], calls DESC,
77
+ -- first-seen tiebreak; <synthetic>/NULL excluded
76
78
  harness_version TEXT, -- \`version\`; transcript-only, groups the drift report
77
79
  title TEXT, -- LAST type:'ai-title' line
78
80
  preview TEXT, -- first human prompt, 200 chars
@@ -102,6 +104,8 @@ CREATE TABLE sessions (
102
104
  sub_tokens_cache_read INTEGER NOT NULL DEFAULT 0,
103
105
  sub_tokens_cache_write INTEGER NOT NULL DEFAULT 0,
104
106
  sub_est_cost REAL,
107
+ sub_models TEXT NOT NULL DEFAULT '[]', -- transitive over sidecars, [[id, calls]], written by
108
+ -- recomputeSubagentRollups; '[]' until wave 2
105
109
  rollup_state TEXT NOT NULL DEFAULT 'own', -- 'own' | 'complete'
106
110
 
107
111
  -- sidecar linkage (NULL on a top-level session)
@@ -3,6 +3,7 @@ import { linkSubagents, } from '../project/subagents.js';
3
3
  import { resolvePersistedOutput } from '../transcript/spill.js';
4
4
  import { PROJECTOR_VERSION } from '../transcript/version.js';
5
5
  import { estimateCost } from '../shared/pricing.js';
6
+ import { SYNTHETIC_MODEL } from '../shared/model-id.js';
6
7
  const UPSERT_INDEX_SQL = `INSERT INTO sessions
7
8
  (id, source_path, archive_path, file_mtime_ms, file_size,
8
9
  project_path, started_at, last_activity_at)
@@ -243,7 +244,13 @@ const ROLLUP_SQL = `UPDATE sessions SET
243
244
  WHERE id = :id`;
244
245
  export function recomputeSessionRollups(db, id) {
245
246
  db.prepare(ROLLUP_SQL).run({ id });
247
+ const rows = db.prepare(OWN_MODELS_SQL).all(id, SYNTHETIC_MODEL);
248
+ const models = rows.map((row) => [row.model, row.calls]);
249
+ db.prepare('UPDATE sessions SET models = ? WHERE id = ?').run(JSON.stringify(models), id);
246
250
  }
251
+ const OWN_MODELS_SQL = `SELECT model, count(*) AS calls FROM events
252
+ WHERE session_id = ? AND model IS NOT NULL AND model <> ?
253
+ GROUP BY model ORDER BY calls DESC, min(seq) ASC`;
247
254
  const SUBAGENT_ROLLUP_SQL = `UPDATE sessions SET
248
255
  agent_count = COALESCE((SELECT sum(1 + c.agent_count) FROM sessions c WHERE c.parent_session_id = :id), 0),
249
256
  sub_tool_call_count = COALESCE((SELECT sum(c.tool_call_count + c.sub_tool_call_count) FROM sessions c WHERE c.parent_session_id = :id), 0),
@@ -267,6 +274,19 @@ const SUBAGENT_ROLLUP_SQL = `UPDATE sessions SET
267
274
  WHERE id = :id`;
268
275
  export function recomputeSubagentRollups(db, id) {
269
276
  db.prepare(SUBAGENT_ROLLUP_SQL).run({ id });
277
+ const children = db
278
+ .prepare('SELECT models, sub_models FROM sessions WHERE parent_session_id = ?')
279
+ .all(id);
280
+ const calls = new Map();
281
+ for (const child of children) {
282
+ for (const column of [child.models, child.sub_models]) {
283
+ for (const [model, n] of JSON.parse(column)) {
284
+ calls.set(model, (calls.get(model) ?? 0) + n);
285
+ }
286
+ }
287
+ }
288
+ const subModels = [...calls].sort(([a, an], [b, bn]) => bn - an || (a < b ? -1 : a > b ? 1 : 0));
289
+ db.prepare('UPDATE sessions SET sub_models = ? WHERE id = ?').run(JSON.stringify(subModels), id);
270
290
  }
271
291
  export function markRollupComplete(db, id) {
272
292
  db.prepare(`UPDATE sessions SET rollup_state = 'complete' WHERE id = ?`).run(id);
@@ -0,0 +1,7 @@
1
+ export const SYNTHETIC_MODEL = '<synthetic>';
2
+ export const BUILD_SUFFIX = /-\d{8}$/;
3
+ export const CONTEXT_TAG = /\[[^\]]*\]$/;
4
+ export const VENDOR_PREFIX = /^(?:[a-z]{2,4}\.)?anthropic\./;
5
+ export function stripModelId(raw) {
6
+ return raw.replace(CONTEXT_TAG, '').replace(VENDOR_PREFIX, '').replace(BUILD_SUFFIX, '');
7
+ }
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { BUILD_SUFFIX, CONTEXT_TAG, VENDOR_PREFIX } from './model-id.js';
2
3
  function canonicalJson(value) {
3
4
  return JSON.stringify(sortKeys(value));
4
5
  }
@@ -39,9 +40,6 @@ export const PRICING_VERSION = `${PRICING_TABLE_DATE}+${createHash('sha256')
39
40
  .update(canonicalJson(PRICING_TABLE), 'utf8')
40
41
  .digest('hex')
41
42
  .slice(0, 8)}`;
42
- const BUILD_SUFFIX = /-\d{8}$/;
43
- const CONTEXT_TAG = /\[[^\]]*\]$/;
44
- const VENDOR_PREFIX = /^(?:[a-z]{2,4}\.)?anthropic\./;
45
43
  export function normalizeModelKey(raw) {
46
44
  const model = raw.replace(CONTEXT_TAG, '');
47
45
  const withoutVendor = model.replace(VENDOR_PREFIX, '');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faithfulalabi/agent-lens",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Local-first agentic tracing platform: inspect everything your agent did in a session.",
5
5
  "type": "module",
6
6
  "license": "MIT",