agentel 0.3.0 → 0.3.1

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/src/cli.js CHANGED
@@ -24,6 +24,7 @@ const { HISTORY_PROVIDER_OPTIONS, IMPORT_SOURCE_ORDER } = require("./sources");
24
24
  const { ensureBaseDirs, ensureDir, paths, readJson, writeJson } = require("./paths");
25
25
  const { PRICING_SOURCE, PRICING_VERSION, pricingForSession } = require("./pricing");
26
26
  const { runSupervisorForeground, startSupervisorDetached, stopSupervisor, supervisorStatus } = require("./supervisor");
27
+ const { postSlackMessage, queuePath: slackQueuePath, runSlackNotify, slackAppCreationUrl, slackAuthTest, slackNotifySettings, statePath: slackStatePath } = require("./slack-notify");
27
28
  const { configureRemoteFromFlags, hasRemoteTarget, listRemoteSnapshots, replaceRemoteArchive, snapshotArchive, syncArchive, wipeRemoteArchive } = require("./sync");
28
29
  const {
29
30
  CLAUDE_CODE_REPAIR_COMMAND,
@@ -88,6 +89,8 @@ async function main(argv = process.argv.slice(2), env = process.env) {
88
89
  return stopCommand(env);
89
90
  case "watcher":
90
91
  return watcherCommand(positionals.slice(1), flags, env);
92
+ case "notify":
93
+ return notifyCommand(positionals.slice(1), flags, env);
91
94
  case "status":
92
95
  return statusCommand(flags, env);
93
96
  case "logs":
@@ -263,6 +266,22 @@ async function initCommand(flags, env) {
263
266
  printCheck("Login watcher", status.file ? `activated (${fullPath(status.file)})` : `activated (${fullPath(autostartFile)})`);
264
267
  }
265
268
 
269
+ const slackConfigured = Boolean(loadConfig(env).notify?.slack?.enabled);
270
+ if (!flags.yes && process.stdin.isTTY && !slackConfigured) {
271
+ printSection("Slack Notifications");
272
+ printMuted("Optional: post session summaries (and a live firehose) to Slack channels.");
273
+ const slackAnswer = (await ask("Set up the Slack sidecar now? [y/N]: ")).trim().toLowerCase();
274
+ if (slackAnswer === "y" || slackAnswer === "yes") {
275
+ try {
276
+ await notifyCommand(["slack", "setup"], {}, env);
277
+ } catch (error) {
278
+ printState("Slack setup", `${error.message} — run \`agentlog notify slack setup\` to retry`, "warn");
279
+ }
280
+ } else {
281
+ printMuted("Run `agentlog notify slack setup` anytime to connect Slack.");
282
+ }
283
+ }
284
+
266
285
  printNextSteps({ remoteConfigured: hasRemoteTarget(loadConfig(env), env), autostartEnabled: deferredAutostart });
267
286
  }
268
287
 
@@ -289,6 +308,223 @@ function watcherCommand(args, flags, env) {
289
308
  throw new Error("usage: agentlog watcher <start|stop|status|logs|login> [args]");
290
309
  }
291
310
 
311
+ const NOTIFY_USAGE =
312
+ "usage: agentlog notify slack <setup|enable|disable|status|test> [--summaries on|off] [--summary-channel <id>] [--quiet-minutes <n>] [--firehose on|off] [--firehose-channel <id>] [--batch-seconds <n>] [--token <xoxb-…>] [--repos <a,b>] [--dry-run]";
313
+
314
+ function onOffFlag(value, label) {
315
+ if (value === undefined) return undefined;
316
+ const normalized = String(value).toLowerCase();
317
+ if (["on", "true", "yes", "1"].includes(normalized)) return true;
318
+ if (["off", "false", "no", "0"].includes(normalized)) return false;
319
+ throw new Error(`${label} expects on or off, got: ${value}`);
320
+ }
321
+
322
+ async function notifyCommand(args, flags, env) {
323
+ if ((args[0] || "slack") !== "slack") throw new Error(NOTIFY_USAGE);
324
+ const action = args[1] || "status";
325
+ const title = "agentlog notify slack";
326
+ const cfg = loadConfig(env);
327
+
328
+ if (action === "setup") {
329
+ printPageTitle(title, "notify");
330
+ printMuted("This creates a personal Slack app (chat:write only) and connects it to a channel.");
331
+ const defaultName = `agentlog (${os.userInfo().username || cfg.device?.slug || "me"})`.slice(0, 35);
332
+ const nameAnswer = (await ask(`Bot name [${defaultName}]: `)).trim();
333
+ const appName = nameAnswer || defaultName;
334
+ const url = slackAppCreationUrl(appName);
335
+
336
+ printSection("Create The App");
337
+ printMuted("Your browser opens Slack's app creation pre-filled from a manifest.");
338
+ printMuted("");
339
+ printMuted(" 1. Pick your workspace → click \"Create\".");
340
+ printMuted(" 2. Slack lands on \"Basic Information\". You need a different page:");
341
+ printMuted(" in the LEFT SIDEBAR click \"OAuth & Permissions\".");
342
+ printMuted(" (Or: https://api.slack.com/apps → click your app → OAuth & Permissions)");
343
+ printMuted(" 3. At the top of that page click \"Install to Workspace\" → \"Allow\".");
344
+ printMuted(" 4. Copy the \"Bot User OAuth Token\" (xoxb-…) now shown at the top.");
345
+ printMuted("");
346
+ printMuted(" Nothing on Basic Information is needed — do not copy the Client ID,");
347
+ printMuted(" Client Secret, or Signing Secret.");
348
+ if (process.stdout.isTTY) {
349
+ try {
350
+ openUrl(url);
351
+ printMuted("If the browser did not open, use the link below.");
352
+ } catch {}
353
+ }
354
+ printMuted(`Creation link: ${url}`);
355
+ printMuted("");
356
+ printMuted("Stuck finding the token? https://api.slack.com/apps → your app →");
357
+ printMuted("OAuth & Permissions → Bot User OAuth Token.");
358
+
359
+ const token = (await ask("Paste the bot token (xoxb-…): ")).trim();
360
+ if (!token) throw new Error("setup cancelled: no token provided");
361
+ const probe = slackNotifySettings({ notify: { slack: { botToken: token } } }, env);
362
+ const identity = await slackAuthTest(probe);
363
+ printCheck("Slack auth", `${identity.team || "workspace"} as ${identity.user || appName}`);
364
+
365
+ printSection("Connect A Channel");
366
+ printMuted(`In Slack: invite the bot with /invite @${appName}, then copy the channel ID`);
367
+ printMuted("(click the channel name → About tab → the C… value at the bottom).");
368
+ const channel = (await ask("Summary channel ID (C…): ")).trim();
369
+ if (!channel) throw new Error("setup cancelled: no channel provided");
370
+
371
+ printMuted("The firehose streams turns live into a per-session thread as agents work.");
372
+ const firehoseAnswer = (await ask("Enable the live firehose too? [y/N]: ")).trim().toLowerCase();
373
+ const firehoseOn = firehoseAnswer === "y" || firehoseAnswer === "yes";
374
+ let firehoseChannel = channel;
375
+ if (firehoseOn) {
376
+ const separate = (await ask(`Firehose channel ID [${channel}]: `)).trim();
377
+ if (separate) firehoseChannel = separate;
378
+ if (firehoseChannel !== channel) printMuted(`Remember to /invite @${appName} in that channel too.`);
379
+ }
380
+
381
+ const slack = { ...cfg.notify.slack, enabled: true, botToken: token };
382
+ delete slack.channel;
383
+ delete slack.quietMinutes;
384
+ delete slack.mode;
385
+ slack.summary = { ...cfg.notify.slack.summary, enabled: true, channel };
386
+ slack.stream = { ...cfg.notify.slack.stream, enabled: firehoseOn, channel: firehoseOn ? firehoseChannel : cfg.notify.slack.stream.channel };
387
+ cfg.notify = { ...cfg.notify, slack };
388
+ saveConfig(cfg, env);
389
+
390
+ printSection("Verify");
391
+ const verifyChannels = [...new Set([channel, ...(firehoseOn ? [firehoseChannel] : [])])];
392
+ for (const target of verifyChannels) {
393
+ try {
394
+ await postSlackMessage(slackNotifySettings(cfg, env), {
395
+ channel: target,
396
+ text: `:wave: agentlog connected — posts from ${cfg.device?.name || "this device"} will land here.`,
397
+ unfurl_links: false
398
+ });
399
+ printCheck("Test post", `sent to ${target}`);
400
+ } catch (error) {
401
+ printState("Test post", `${target}: ${error.message} — invite the bot to the channel, then run \`agentlog notify slack test\``, "warn");
402
+ }
403
+ }
404
+ printCheck("Status", "enabled");
405
+ printNotifySettings(slackNotifySettings(cfg, env));
406
+ printMuted("Adjust anytime: agentlog notify slack enable --summaries on|off --firehose on|off --quiet-minutes N --batch-seconds N");
407
+ // Piped stdin would otherwise keep the process alive after the last
408
+ // prompt; nothing reads input past this point.
409
+ if (typeof process.stdin.unref === "function") process.stdin.unref();
410
+ return;
411
+ }
412
+
413
+ if (action === "enable" || action === "set") {
414
+ const current = slackNotifySettings(cfg, env);
415
+ const summary = { ...cfg.notify.slack.summary, channel: current.summary.channel, quietMinutes: current.summary.quietMinutes };
416
+ const stream = { ...cfg.notify.slack.stream };
417
+ const slack = { ...cfg.notify.slack, summary, stream };
418
+ // The pre-split keys are rewritten into the summary block on save.
419
+ delete slack.channel;
420
+ delete slack.quietMinutes;
421
+ delete slack.mode;
422
+
423
+ const summariesFlag = onOffFlag(flags.summaries, "--summaries");
424
+ if (summariesFlag !== undefined) summary.enabled = summariesFlag;
425
+ if (flags["summary-channel"] || flags.channel) summary.channel = String(flags["summary-channel"] || flags.channel);
426
+ if (flags["quiet-minutes"]) summary.quietMinutes = Number(flags["quiet-minutes"]);
427
+ const firehoseFlag = onOffFlag(flags.firehose ?? flags.stream, "--firehose");
428
+ if (firehoseFlag !== undefined) stream.enabled = firehoseFlag;
429
+ if (flags["firehose-channel"] || flags["stream-channel"]) stream.channel = String(flags["firehose-channel"] || flags["stream-channel"]);
430
+ if (flags["batch-seconds"]) stream.batchSeconds = Number(flags["batch-seconds"]);
431
+ if (flags.token) slack.botToken = String(flags.token);
432
+ if (flags.repos) slack.repos = String(flags.repos).split(",").map((repo) => repo.trim()).filter(Boolean);
433
+
434
+ if (summary.enabled && !summary.channel && !stream.channel) {
435
+ throw new Error("a channel is required: agentlog notify slack enable --summary-channel C0123456789");
436
+ }
437
+ if (stream.enabled && !stream.channel && !summary.channel) {
438
+ throw new Error("firehose needs a channel: --firehose-channel C0123456789");
439
+ }
440
+ slack.enabled = true;
441
+ cfg.notify = { ...cfg.notify, slack };
442
+ saveConfig(cfg, env);
443
+ const settings = slackNotifySettings(cfg, env);
444
+ printPageTitle(title, "notify");
445
+ printCheck("Status", "enabled");
446
+ printNotifySettings(settings);
447
+ if (settings.botToken) {
448
+ try {
449
+ const identity = await slackAuthTest(settings);
450
+ printCheck("Slack auth", `${identity.team || "workspace"} as ${identity.user || "bot"}`);
451
+ } catch (error) {
452
+ printState("Slack auth", error.message, "warn");
453
+ }
454
+ }
455
+ printMuted("Run `agentlog notify slack test` to verify the channel(s).");
456
+ return;
457
+ }
458
+
459
+ if (action === "disable") {
460
+ cfg.notify = { ...cfg.notify, slack: { ...cfg.notify.slack, enabled: false } };
461
+ saveConfig(cfg, env);
462
+ printPageTitle(title, "notify");
463
+ printCheck("Status", "disabled");
464
+ return;
465
+ }
466
+
467
+ if (action === "status") {
468
+ const settings = slackNotifySettings(cfg, env);
469
+ const state = readJson(slackStatePath(env), {});
470
+ const tracked = Object.keys(state.sessions || {});
471
+ let queued = 0;
472
+ try {
473
+ queued = fs.readFileSync(slackQueuePath(env), "utf8").split("\n").filter((line) => line.trim()).length;
474
+ } catch {}
475
+ printPageTitle(title, "notify");
476
+ printCheck("Status", settings.enabled ? "enabled" : "disabled");
477
+ printNotifySettings(settings);
478
+ printCheck("Tracked sessions", String(tracked.length));
479
+ printCheck("Queued writes", String(queued));
480
+ return;
481
+ }
482
+
483
+ if (action === "test") {
484
+ const settings = slackNotifySettings(cfg, env);
485
+ const channels = [...new Set([settings.summary.enabled ? settings.summary.channel : "", settings.stream.enabled ? settings.stream.channel : ""].filter(Boolean))];
486
+ if (!channels.length) throw new Error("no channel configured: agentlog notify slack enable --summary-channel C0123456789");
487
+ const text = `:wave: agentlog notify test from ${cfg.device?.name || cfg.device?.slug || "this device"}`;
488
+ printPageTitle(title, "notify");
489
+ if (flags["dry-run"]) {
490
+ for (const channel of channels) printCheck("Channel", channel);
491
+ printCheck("Message", text);
492
+ printMuted("Dry run: nothing was posted.");
493
+ return;
494
+ }
495
+ if (!settings.botToken) throw new Error("no Slack bot token (set notify.slack.botToken or SLACK_BOT_TOKEN)");
496
+ for (const channel of channels) {
497
+ await postSlackMessage(settings, { channel, text, unfurl_links: false });
498
+ printCheck("Status", `test message posted to ${channel}`);
499
+ }
500
+ return;
501
+ }
502
+
503
+ throw new Error(NOTIFY_USAGE);
504
+ }
505
+
506
+ function printNotifySettings(settings) {
507
+ printCheck(
508
+ "Summaries",
509
+ settings.summary.enabled
510
+ ? `on → ${settings.summary.channel || "no channel set"} (after ${settings.summary.quietMinutes}m quiet)`
511
+ : "off"
512
+ );
513
+ printCheck(
514
+ "Firehose",
515
+ settings.stream.enabled
516
+ ? `on → ${settings.stream.channel || "no channel set"} (batched every ${settings.stream.batchSeconds}s)`
517
+ : "off"
518
+ );
519
+ printCheck("Repos", settings.repos.length ? settings.repos.join(", ") : "all repos");
520
+ printCheck("Token", settings.botToken ? maskSlackToken(settings.botToken) : "none (set notify.slack.botToken or SLACK_BOT_TOKEN)");
521
+ }
522
+
523
+ function maskSlackToken(token) {
524
+ const value = String(token);
525
+ return value.length > 9 ? `${value.slice(0, 5)}…${value.slice(-4)}` : "set";
526
+ }
527
+
292
528
  function stopCommand(env, title = "agentlog stop") {
293
529
  const status = supervisorStatus(env);
294
530
  const stopped = stopSupervisor(env);
@@ -3993,8 +4229,8 @@ function statsPayloadForSessions(sessions, options = {}) {
3993
4229
  memory_writes: positiveStatsNumber(buckets.memory_writes),
3994
4230
  memory_loads: positiveStatsNumber(buckets.memory_loads),
3995
4231
  providers: compactStatsBreakdownMap(buckets.providers, { includeSpend: true }),
3996
- companies: compactStatsBreakdownMap(buckets.companies),
3997
- models: compactStatsBreakdownMap(buckets.models)
4232
+ companies: compactStatsBreakdownMap(buckets.companies, { includeSpend: true }),
4233
+ models: compactStatsBreakdownMap(buckets.models, { includeSpend: true })
3998
4234
  }))
3999
4235
  .sort((a, b) => a.date.localeCompare(b.date));
4000
4236
  const dailyRepos = Array.from(dailyRepoMap.entries())
@@ -9116,6 +9352,8 @@ body.project-view .stats-project-controls{display:flex}
9116
9352
  .stats-chart-frame .stats-axis-label{font-size:9px;font-weight:500;fill:var(--ui-text-subtle)}
9117
9353
  .stats-chart-frame.stats-empty{height:232px;min-height:232px;padding:0 18px}
9118
9354
  .stats-share-area{transition:opacity .12s ease}
9355
+ .stats-momentum-line{fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
9356
+ .stats-momentum-zero{stroke:var(--ui-text-subtle);stroke-width:1.2}
9119
9357
  .stats-empty{display:flex;align-items:center;justify-content:center;min-height:200px;color:var(--ui-text-subtle);font-size:12px;line-height:1.4;text-align:center;padding:0 18px}
9120
9358
  .stats-legend{display:flex;flex-wrap:wrap;gap:8px;font-size:11px;color:var(--ui-text-secondary);letter-spacing:.01em}
9121
9359
  .stats-legend-item{display:inline-flex;align-items:center;gap:5px}
@@ -9618,6 +9856,17 @@ mark.search-match.search-match-current{background:var(--ui-search-strong-bg);col
9618
9856
  .inline-empty{padding:22px;color:var(--ui-text-muted)}
9619
9857
  .conversation.empty .session-header{display:none}
9620
9858
  .conversation.empty .detail-scroll{display:flex;align-items:center;justify-content:center}
9859
+ .session-outline{position:absolute;z-index:21;right:0;top:50%;transform:translateY(-50%);display:flex;align-items:center}
9860
+ .session-outline-ticks{display:flex;flex-direction:column;gap:7px;padding:12px 12px 12px 16px}
9861
+ .session-outline-tick{width:16px;height:2px;border-radius:999px;background:var(--ui-border);transition:background .12s ease}
9862
+ .session-outline-tick.active{background:var(--ui-text-strong)}
9863
+ .session-outline-panel{position:absolute;right:38px;top:50%;transform:translateY(-50%);width:min(360px,56vw);max-height:min(440px,72vh);overflow-y:auto;padding:6px;border:1px solid var(--ui-border);border-radius:12px;background:var(--ui-surface-raised);box-shadow:0 10px 30px var(--ui-shadow-sm);opacity:0;visibility:hidden;transition:opacity .12s ease,visibility .12s ease}
9864
+ .session-outline:hover .session-outline-panel,.session-outline:focus-within .session-outline-panel{opacity:1;visibility:visible}
9865
+ .session-outline-item{display:block;width:100%;padding:8px 10px;border:0;border-radius:8px;background:transparent;color:var(--ui-text-body);font-size:12.5px;line-height:1.35;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
9866
+ .session-outline-item:hover{background:var(--ui-surface-hover)}
9867
+ .session-outline-item.active{background:var(--ui-surface-subtle);color:var(--ui-text-strong);font-weight:600}
9868
+ .session-outline-note{padding:7px 10px;color:var(--ui-text-subtle);font-size:11px}
9869
+ @media (max-width:950px){.session-outline{display:none}}
9621
9870
  .conversation.empty #readableView.inline-empty{padding:0;color:var(--ui-text-muted);text-align:center}
9622
9871
  @media (max-width:1180px){.toolbar{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;align-items:end}.search-wrap{grid-column:1;grid-row:1}.toolbar-actions{grid-column:2;grid-row:1}.filters{display:grid;grid-column:1 / -1;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}.filter select,.filter input,.select-field,.filter.path-filter input{width:100%}}
9623
9872
  @media (max-width:950px){html,body{overflow:auto}body{height:auto}.app-head{align-items:center}main{display:block}aside{height:42vh;border-right:0;border-bottom:1px solid var(--line)}.splitter{display:none}.sidebar-collapse-button{display:none}.conversation{height:58vh}.bubble{max-width:94%}#readableView,#markdownView{padding:16px 14px 40px}}
@@ -9776,6 +10025,10 @@ mark.search-match.search-match-current{background:var(--ui-search-strong-bg);col
9776
10025
  <pre id="markdownView"></pre>
9777
10026
  </div>
9778
10027
  <button id="jumpEnd" class="jump-end" type="button" title="Jump to end" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14"/><path d="m19 12-7 7-7-7"/></svg></button>
10028
+ <div id="sessionOutline" class="session-outline" hidden>
10029
+ <div id="sessionOutlineTicks" class="session-outline-ticks" aria-hidden="true"></div>
10030
+ <div id="sessionOutlinePanel" class="session-outline-panel" role="menu" aria-label="Jump to user message"></div>
10031
+ </div>
9779
10032
  </section>
9780
10033
  </main>
9781
10034
  <div id="sessionModal" class="session-modal" hidden role="dialog" aria-modal="true" aria-labelledby="sessionModalTitle">
@@ -9875,7 +10128,7 @@ mark.search-match.search-match-current{background:var(--ui-search-strong-bg);col
9875
10128
  <div class="stats-section-head">
9876
10129
  <div class="stats-section-heading">
9877
10130
  <div class="stats-section-title">Usage</div>
9878
- <div id="statsUsageSub" class="stats-section-sub">Recent activity window and overall token mix.</div>
10131
+ <div id="statsUsageSub" class="stats-section-sub">Last 30 days summary and token mix for the selected range.</div>
9879
10132
  </div>
9880
10133
  </div>
9881
10134
  <div class="stats-window" id="statsUsageWindow" hidden></div>
@@ -9919,7 +10172,8 @@ mark.search-match.search-match-current{background:var(--ui-search-strong-bg);col
9919
10172
  <div id="statsLegend" class="stats-legend"></div>
9920
10173
  </div>
9921
10174
  <div class="stats-grid">
9922
- <div class="stats-card stats-card--wide"><div class="stats-card-title" id="chartTokenSharePerDayTitle">Daily token share</div><div class="stats-card-meta" id="chartTokenSharePerDayMeta">Selected tokens by provider</div><div id="chartTokenSharePerDay" class="stats-empty stats-chart-frame">No token share yet.</div></div>
10175
+ <div class="stats-card"><div class="stats-card-title" id="chartTokenSharePerDayTitle">Daily token share</div><div class="stats-card-meta" id="chartTokenSharePerDayMeta">Selected tokens by provider</div><div id="chartTokenSharePerDay" class="stats-empty stats-chart-frame">No token share yet.</div></div>
10176
+ <div class="stats-card"><div class="stats-card-title" id="chartMonthlyMomentumTitle">Monthly momentum</div><div class="stats-card-meta" id="chartMonthlyMomentumMeta">Month-over-month change</div><div id="chartMonthlyMomentum" class="stats-empty stats-chart-frame">No monthly momentum yet.</div></div>
9923
10177
  <div class="stats-card"><div class="stats-card-title">Tokens per day</div><div id="chartTokensPerDay" class="stats-empty">No data yet.</div></div>
9924
10178
  <div class="stats-card"><div class="stats-card-title" id="chartActivityPerDayTitle">User messages per day</div><div id="chartSessionsPerDay" class="stats-empty">No data yet.</div></div>
9925
10179
  </div>
@@ -9928,7 +10182,7 @@ mark.search-match.search-match-current{background:var(--ui-search-strong-bg);col
9928
10182
  <div class="stats-section-head">
9929
10183
  <div class="stats-section-heading">
9930
10184
  <div class="stats-section-title">Tokens &amp; messages per folder</div>
9931
- <div class="stats-section-sub">Top folders by the selected breakdown above.</div>
10185
+ <div id="statsFoldersSub" class="stats-section-sub">Top folders by the selected breakdown above.</div>
9932
10186
  </div>
9933
10187
  </div>
9934
10188
  <div class="stats-grid">
@@ -9962,18 +10216,18 @@ mark.search-match.search-match-current{background:var(--ui-search-strong-bg);col
9962
10216
  </div>
9963
10217
  </div>
9964
10218
  <div class="stats-grid stats-grid--mix">
9965
- <div class="stats-card stats-card--mix"><div class="stats-card-title">Session time per month</div><div id="chartSessionTimePerMonth" class="stats-empty">No session time yet.</div></div>
9966
- <div class="stats-card stats-card--mix"><div class="stats-card-title">Most used tools</div><div id="chartTopTools" class="stats-empty">No tool calls yet.</div></div>
9967
- <div class="stats-card stats-card--mix"><div class="stats-card-title">Tools by category</div><div id="chartToolCategories" class="stats-empty">No tool calls yet.</div></div>
9968
- <div class="stats-card stats-card--mix"><div class="stats-card-title">Skills used</div><div id="chartTopSkills" class="stats-empty">No skill calls yet.</div></div>
9969
- <div class="stats-card stats-card--mix"><div class="stats-card-title">MCP servers</div><div id="chartMcpServers" class="stats-empty">No MCP calls yet.</div></div>
10219
+ <div class="stats-card stats-card--mix"><div class="stats-card-title">Session time per month</div><div class="stats-card-meta" id="chartSessionTimePerMonthMeta">Selected range</div><div id="chartSessionTimePerMonth" class="stats-empty">No session time yet.</div></div>
10220
+ <div class="stats-card stats-card--mix"><div class="stats-card-title">Most used tools</div><div class="stats-card-meta">All time</div><div id="chartTopTools" class="stats-empty">No tool calls yet.</div></div>
10221
+ <div class="stats-card stats-card--mix"><div class="stats-card-title">Tools by category</div><div class="stats-card-meta">All time</div><div id="chartToolCategories" class="stats-empty">No tool calls yet.</div></div>
10222
+ <div class="stats-card stats-card--mix"><div class="stats-card-title">Skills used</div><div class="stats-card-meta">All time</div><div id="chartTopSkills" class="stats-empty">No skill calls yet.</div></div>
10223
+ <div class="stats-card stats-card--mix"><div class="stats-card-title">MCP servers</div><div class="stats-card-meta">All time</div><div id="chartMcpServers" class="stats-empty">No MCP calls yet.</div></div>
9970
10224
  </div>
9971
10225
  </div>
9972
10226
  <div class="stats-section">
9973
10227
  <div class="stats-section-head">
9974
10228
  <div class="stats-section-heading">
9975
10229
  <div class="stats-section-title">Provider insights</div>
9976
- <div id="statsProviderInsightsSub" class="stats-section-sub">Per-provider highlights.</div>
10230
+ <div id="statsProviderInsightsSub" class="stats-section-sub">Per-provider highlights · all time.</div>
9977
10231
  </div>
9978
10232
  </div>
9979
10233
  <div id="statsProviderInsights" class="stats-grid stats-grid--insights stats-empty">No provider activity yet.</div>
@@ -11277,7 +11531,7 @@ function shouldTrustModelForSession(model, sessionProvider) {
11277
11531
  if (!m) return false;
11278
11532
  const p = String(sessionProvider || '').toLowerCase();
11279
11533
  if (p.includes('claude') || p === 'anthropic') {
11280
- if (/\bgpt-|^gpt|openai|o1-|o3\b|o4|4o-mini|\b4o\b|davinci|text-davinci/i.test(m)) return false;
11534
+ if (/\\bgpt-|^gpt|openai|o1-|o3\\b|o4|4o-mini|\\b4o\\b|davinci|text-davinci/i.test(m)) return false;
11281
11535
  }
11282
11536
  if (p === 'codex' && /claude-/i.test(m) && !/via claude/i.test(m)) return false;
11283
11537
  return true;
@@ -11380,7 +11634,7 @@ function sessionDisplayTitle(payload) {
11380
11634
  return m && m.role === 'user';
11381
11635
  });
11382
11636
  const fullContent = firstUser && firstUser.content != null ? String(firstUser.content) : '';
11383
- const collapsed = fullContent.replace(/\s+/g, ' ').trim();
11637
+ const collapsed = fullContent.replace(/\\s+/g, ' ').trim();
11384
11638
  const snippet = collapsed.slice(0, 76);
11385
11639
  const junkTitle = !raw || looksLikeModelListTitle(raw);
11386
11640
  if (!junkTitle) return raw;
@@ -11593,6 +11847,7 @@ function clearLoadingSessionTimer() {
11593
11847
 
11594
11848
  function paintLoadingSession(id) {
11595
11849
  renderMessagesSerial += 1;
11850
+ hideSessionOutline();
11596
11851
  document.querySelector('.conversation').classList.remove('empty');
11597
11852
  currentSessionPayload = null;
11598
11853
  sessionTitle.textContent = 'Loading session...';
@@ -11613,6 +11868,7 @@ function paintLoadingSession(id) {
11613
11868
  'Session info — copy IDs, paths, and dates for agentlog recall or pasting into another agent.'
11614
11869
  );
11615
11870
  jumpEnd.hidden = true;
11871
+ hideSessionOutline();
11616
11872
  }
11617
11873
 
11618
11874
  function setEmptySession(message) {
@@ -11637,6 +11893,7 @@ function setEmptySession(message) {
11637
11893
  'Session info — copy IDs, paths, and dates for agentlog recall or pasting into another agent.'
11638
11894
  );
11639
11895
  jumpEnd.hidden = true;
11896
+ hideSessionOutline();
11640
11897
  }
11641
11898
 
11642
11899
  function resetSessionHeaderChips() {
@@ -11902,6 +12159,7 @@ function memorySourceLabel(value) {
11902
12159
 
11903
12160
  function renderMessages(messages, sessionSummary) {
11904
12161
  const renderSerial = ++renderMessagesSerial;
12162
+ hideSessionOutline();
11905
12163
  readableView.className = '';
11906
12164
  readableView.innerHTML = '';
11907
12165
  const summaryText = sessionSummaryText(sessionSummary);
@@ -11949,6 +12207,7 @@ function renderMessages(messages, sessionSummary) {
11949
12207
  button: showButton ? earlierButton : null,
11950
12208
  anchor: null
11951
12209
  };
12210
+ rebuildSessionOutline();
11952
12211
  let previousTimestamp = '';
11953
12212
  let previousModel = '';
11954
12213
  let index = windowStart;
@@ -11967,6 +12226,7 @@ function renderMessages(messages, sessionSummary) {
11967
12226
  previousModel = itemModel;
11968
12227
  }
11969
12228
  const node = messageElement(message, item);
12229
+ item._domNode = node;
11970
12230
  if (windowedRenderState && windowedRenderState.serial === renderSerial && !windowedRenderState.anchor) {
11971
12231
  windowedRenderState.anchor = node;
11972
12232
  }
@@ -11982,7 +12242,9 @@ function renderMessages(messages, sessionSummary) {
11982
12242
  if (activeSearchTerm) highlightSearchMatches(readableView, activeSearchTerm);
11983
12243
  if (typeof updateJumpEndVisibility === 'function') {
11984
12244
  scheduleNext(() => {
11985
- if (renderSerial === renderMessagesSerial) updateJumpEndVisibility();
12245
+ if (renderSerial !== renderMessagesSerial) return;
12246
+ updateJumpEndVisibility();
12247
+ updateSessionOutlineActive();
11986
12248
  });
11987
12249
  }
11988
12250
  };
@@ -12048,6 +12310,7 @@ function prependRenderItemsToView(renderItems) {
12048
12310
  const gap = renderTimeGap(previousTimestamp, message.timestamp);
12049
12311
  if (gap) fragment.appendChild(gap);
12050
12312
  const node = messageElement(message, item);
12313
+ item._domNode = node;
12051
12314
  if (!firstNode) firstNode = gap || node;
12052
12315
  fragment.appendChild(node);
12053
12316
  previousTimestamp = item.lastTimestamp || message.timestamp || previousTimestamp;
@@ -12079,6 +12342,139 @@ function loadEarlierInMemory() {
12079
12342
  else removeEarlierButton(state);
12080
12343
  }
12081
12344
 
12345
+ // Conversation outline: a rail of tick marks (one per user message) on the
12346
+ // right edge of the transcript. Hover expands it into a jump list; click
12347
+ // scrolls to the message, revealing earlier windowed batches when needed.
12348
+ const sessionOutlineEl = document.getElementById('sessionOutline');
12349
+ const sessionOutlineTicksEl = document.getElementById('sessionOutlineTicks');
12350
+ const sessionOutlinePanelEl = document.getElementById('sessionOutlinePanel');
12351
+ const SESSION_OUTLINE_MAX_TICKS = 28;
12352
+ let sessionOutlineEntries = [];
12353
+ let sessionOutlineTicks = [];
12354
+ let sessionOutlineScrollPending = false;
12355
+
12356
+ function hideSessionOutline() {
12357
+ sessionOutlineEntries = [];
12358
+ sessionOutlineTicks = [];
12359
+ if (sessionOutlineTicksEl) sessionOutlineTicksEl.innerHTML = '';
12360
+ if (sessionOutlinePanelEl) sessionOutlinePanelEl.innerHTML = '';
12361
+ if (sessionOutlineEl) sessionOutlineEl.hidden = true;
12362
+ }
12363
+
12364
+ function sessionOutlineLabel(message) {
12365
+ const collapsed = String(message.content || '').replace(/<[^>]+>/g, ' ').replace(/\\s+/g, ' ').trim();
12366
+ if (collapsed) return collapsed.length > 160 ? collapsed.slice(0, 159) + '\u2026' : collapsed;
12367
+ const attachments = Array.isArray(message.attachments) ? message.attachments.length : 0;
12368
+ return attachments ? '(attachment)' : '(empty message)';
12369
+ }
12370
+
12371
+ function rebuildSessionOutline() {
12372
+ if (!sessionOutlineEl || !sessionOutlineTicksEl || !sessionOutlinePanelEl) return;
12373
+ const state = windowedRenderState;
12374
+ const entries = [];
12375
+ if (state && state.serial === renderMessagesSerial) {
12376
+ for (const item of state.items) {
12377
+ if (item.type && item.type !== 'message') continue;
12378
+ const message = item.message;
12379
+ if (!message || String(message.role || '').toLowerCase() !== 'user') continue;
12380
+ if (message.metadata && message.metadata._inlineThinkingOnly) continue;
12381
+ if (message.metadata && message.metadata.summaryKind === 'conversation_summary') continue;
12382
+ if (generatedContextForMessage(message)) continue;
12383
+ entries.push({ item, label: sessionOutlineLabel(message), button: null });
12384
+ }
12385
+ }
12386
+ sessionOutlineEntries = entries;
12387
+ if (entries.length < 2 || viewMode !== 'readable') {
12388
+ sessionOutlineEl.hidden = true;
12389
+ return;
12390
+ }
12391
+ sessionOutlineEl.hidden = false;
12392
+ const win = currentSessionPayload && currentSessionPayload.transcript_window;
12393
+ sessionOutlinePanelEl.innerHTML = win && win.has_more_before
12394
+ ? '<div class="session-outline-note">Earlier messages are not loaded yet.</div>'
12395
+ : '';
12396
+ for (const entry of entries) {
12397
+ const button = document.createElement('button');
12398
+ button.type = 'button';
12399
+ button.className = 'session-outline-item';
12400
+ button.setAttribute('role', 'menuitem');
12401
+ button.textContent = entry.label;
12402
+ button.title = entry.label;
12403
+ button.onclick = function () { jumpToOutlineEntry(entry); };
12404
+ entry.button = button;
12405
+ sessionOutlinePanelEl.appendChild(button);
12406
+ }
12407
+ sessionOutlineTicksEl.innerHTML = '';
12408
+ sessionOutlineTicks = [];
12409
+ const tickCount = Math.min(entries.length, SESSION_OUTLINE_MAX_TICKS);
12410
+ for (let t = 0; t < tickCount; t += 1) {
12411
+ const entryIndex = Math.round((t * (entries.length - 1)) / (tickCount - 1));
12412
+ const tick = document.createElement('div');
12413
+ tick.className = 'session-outline-tick';
12414
+ sessionOutlineTicksEl.appendChild(tick);
12415
+ sessionOutlineTicks.push({ tick, entryIndex });
12416
+ }
12417
+ updateSessionOutlineActive();
12418
+ }
12419
+
12420
+ function jumpToOutlineEntry(entry) {
12421
+ const state = windowedRenderState;
12422
+ if (!state || state.serial !== renderMessagesSerial) return;
12423
+ const targetIndex = state.items.indexOf(entry.item);
12424
+ if (targetIndex < 0) return;
12425
+ // Reveal buffered batches until the target message has a DOM node.
12426
+ let guard = 0;
12427
+ while (state.windowStart > targetIndex && state.windowStart > 0 && guard < 500) {
12428
+ loadEarlierInMemory();
12429
+ guard += 1;
12430
+ }
12431
+ const node = entry.item._domNode;
12432
+ if (node && node.isConnected) {
12433
+ node.scrollIntoView({ block: 'start' });
12434
+ if (detailScrollEl) detailScrollEl.scrollTop -= 12;
12435
+ updateSessionOutlineActive();
12436
+ }
12437
+ }
12438
+
12439
+ function updateSessionOutlineActive() {
12440
+ if (!sessionOutlineEl || sessionOutlineEl.hidden || !detailScrollEl) return;
12441
+ if (sessionOutlineScrollPending) return;
12442
+ sessionOutlineScrollPending = true;
12443
+ requestAnimationFrame(function () {
12444
+ sessionOutlineScrollPending = false;
12445
+ if (!sessionOutlineEntries.length || sessionOutlineEl.hidden) return;
12446
+ const threshold = detailScrollEl.getBoundingClientRect().top + detailScrollEl.clientHeight * 0.4;
12447
+ let activeIndex = -1;
12448
+ let firstRendered = -1;
12449
+ for (let i = 0; i < sessionOutlineEntries.length; i += 1) {
12450
+ const node = sessionOutlineEntries[i].item._domNode;
12451
+ if (!node || !node.isConnected) continue;
12452
+ if (firstRendered < 0) firstRendered = i;
12453
+ if (node.getBoundingClientRect().top <= threshold) activeIndex = i;
12454
+ else if (activeIndex >= 0) break;
12455
+ }
12456
+ if (activeIndex < 0) activeIndex = firstRendered;
12457
+ sessionOutlineEntries.forEach(function (entry, i) {
12458
+ if (entry.button) entry.button.classList.toggle('active', i === activeIndex);
12459
+ });
12460
+ let activeTick = -1;
12461
+ for (let t = 0; t < sessionOutlineTicks.length; t += 1) {
12462
+ if (sessionOutlineTicks[t].entryIndex <= activeIndex) activeTick = t;
12463
+ else break;
12464
+ }
12465
+ sessionOutlineTicks.forEach(function (tickEntry, t) {
12466
+ tickEntry.tick.classList.toggle('active', t === activeTick);
12467
+ });
12468
+ });
12469
+ }
12470
+
12471
+ if (sessionOutlineEl) {
12472
+ sessionOutlineEl.addEventListener('mouseenter', function () {
12473
+ const active = sessionOutlinePanelEl && sessionOutlinePanelEl.querySelector('.session-outline-item.active');
12474
+ if (active) active.scrollIntoView({ block: 'nearest' });
12475
+ });
12476
+ }
12477
+
12082
12478
  // Fetch the previous server page and append it to the in-memory buffer WITHOUT
12083
12479
  // painting all of it. We then reveal a single batch so a click that crosses a
12084
12480
  // page boundary stays as snappy as an in-memory reveal.
@@ -12127,6 +12523,7 @@ async function loadEarlierServerWindow() {
12127
12523
  if (grew) {
12128
12524
  // Reveal a single batch from the freshly-grown buffer (re-labels the button).
12129
12525
  loadEarlierInMemory();
12526
+ rebuildSessionOutline();
12130
12527
  } else if (state.button) {
12131
12528
  // Fetch failed or bailed: restore a clickable button so the user can retry.
12132
12529
  const cur = currentSessionPayload && currentSessionPayload.transcript_window;
@@ -12348,7 +12745,7 @@ function toolResultDisplayScore(result) {
12348
12745
  if (rawCategory === 'function_call_output' || rawCategory === 'custom_tool_call_output') score -= 1000;
12349
12746
  if (category && category !== 'function') score += 250;
12350
12747
  if (kind && !kind.startsWith('call_')) score += 150;
12351
- if (/^\$\s/.test(String(result?.detail || result?.output || ''))) score += 250;
12748
+ if (/^\\$\\s/.test(String(result?.detail || result?.output || ''))) score += 250;
12352
12749
  score += Math.min(200, String(result?.output || '').length / 200);
12353
12750
  return score;
12354
12751
  }
@@ -13003,7 +13400,7 @@ function legacyContextKind(provider, content) {
13003
13400
  const first = firstLine(content);
13004
13401
  if (provider === 'codex') {
13005
13402
  if (root === 'environment_context') return 'environment';
13006
- if (/^# AGENTS\.md instructions for\b/.test(first)) return 'project_instructions';
13403
+ if (/^# AGENTS\\.md instructions for\\b/.test(first)) return 'project_instructions';
13007
13404
  if (/^# Files mentioned by the user:/.test(first)) return 'attachment_context';
13008
13405
  if (root === 'subagent_notification') return 'task_notification';
13009
13406
  if (root === 'turn_aborted') return 'turn_aborted';
@@ -13117,7 +13514,7 @@ function rootXmlTag(text) {
13117
13514
  const end = value.indexOf('>');
13118
13515
  if (end < 2) return '';
13119
13516
  const head = value.slice(1, end).trim();
13120
- const tag = head.split(/\s+/)[0] || '';
13517
+ const tag = head.split(/\\s+/)[0] || '';
13121
13518
  return /^[A-Za-z][A-Za-z0-9_-]*$/.test(tag) ? tag : '';
13122
13519
  }
13123
13520
 
@@ -13861,7 +14258,7 @@ function toolSubjectFallback(card) {
13861
14258
 
13862
14259
  function compactPathLabel(value) {
13863
14260
  const text = String(value || '').trim();
13864
- if (!text || /\s/.test(text) || !text.includes('/')) return text;
14261
+ if (!text || /\\s/.test(text) || !text.includes('/')) return text;
13865
14262
  return text.split('/').filter(Boolean).pop() || text;
13866
14263
  }
13867
14264
 
@@ -14799,6 +15196,7 @@ function setView(mode, options) {
14799
15196
  markdownButton.setAttribute('aria-pressed', markdown ? 'true' : 'false');
14800
15197
  readableView.style.display = markdown ? 'none' : 'block';
14801
15198
  markdownView.style.display = markdown ? 'block' : 'none';
15199
+ if (sessionOutlineEl) sessionOutlineEl.hidden = markdown || sessionOutlineEntries.length < 2;
14802
15200
  const markdownLoad = markdown
14803
15201
  ? ensureMarkdownLoaded().catch((error) => {
14804
15202
  markdownView.textContent = error.message || 'Markdown could not be loaded.';
@@ -15664,7 +16062,7 @@ async function saveMemoryEdit(id) {
15664
16062
  }
15665
16063
 
15666
16064
  async function loadStats(options = {}) {
15667
- const elements = ['statsUsageWindow', 'chartTokensByProvider', 'chartTokensByCompany', 'chartTokensByModel', 'chartSpendPerDay', 'chartSpendPerMonth', 'chartSpendByProvider', 'chartSpendByModel', 'chartOutputTokenWork', 'chartMeaningfulRatios', 'chartSessionTimePerMonth', 'chartTopTools', 'chartToolCategories', 'chartTopSkills', 'chartMcpServers', 'statsProviderInsights', 'chartTokenSharePerDay', 'chartTokensPerDay', 'chartSessionsPerDay', 'chartTokensPerRepo', 'chartSessionsPerRepo', 'statsAgentHeatmap', 'statsChatHeatmap', 'statsSdkHeatmap'].map((id) => document.getElementById(id));
16065
+ const elements = ['statsUsageWindow', 'chartTokensByProvider', 'chartTokensByCompany', 'chartTokensByModel', 'chartSpendPerDay', 'chartSpendPerMonth', 'chartSpendByProvider', 'chartSpendByModel', 'chartOutputTokenWork', 'chartMeaningfulRatios', 'chartSessionTimePerMonth', 'chartTopTools', 'chartToolCategories', 'chartTopSkills', 'chartMcpServers', 'statsProviderInsights', 'chartTokenSharePerDay', 'chartMonthlyMomentum', 'chartTokensPerDay', 'chartSessionsPerDay', 'chartTokensPerRepo', 'chartSessionsPerRepo', 'statsAgentHeatmap', 'statsChatHeatmap', 'statsSdkHeatmap'].map((id) => document.getElementById(id));
15668
16066
  const query = statsParams();
15669
16067
  const paramsKey = query.toString();
15670
16068
  const hasCachedPayload = Boolean(globalStatsPayload && lastStatsParamsKey === paramsKey);
@@ -16064,7 +16462,7 @@ function emptyProjectStatsPayload() {
16064
16462
  }
16065
16463
 
16066
16464
  async function loadProjectStats() {
16067
- const elements = ['statsUsageWindow', 'chartTokensByProvider', 'chartTokensByCompany', 'chartTokensByModel', 'chartSpendPerDay', 'chartSpendPerMonth', 'chartSpendByProvider', 'chartSpendByModel', 'chartOutputTokenWork', 'chartMeaningfulRatios', 'chartSessionTimePerMonth', 'chartTopTools', 'chartToolCategories', 'chartTopSkills', 'chartMcpServers', 'statsProviderInsights', 'chartTokenSharePerDay', 'chartTokensPerDay', 'chartSessionsPerDay', 'chartTokensPerRepo', 'chartSessionsPerRepo', 'statsAgentHeatmap', 'statsChatHeatmap', 'statsSdkHeatmap'].map((id) => document.getElementById(id));
16465
+ const elements = ['statsUsageWindow', 'chartTokensByProvider', 'chartTokensByCompany', 'chartTokensByModel', 'chartSpendPerDay', 'chartSpendPerMonth', 'chartSpendByProvider', 'chartSpendByModel', 'chartOutputTokenWork', 'chartMeaningfulRatios', 'chartSessionTimePerMonth', 'chartTopTools', 'chartToolCategories', 'chartTopSkills', 'chartMcpServers', 'statsProviderInsights', 'chartTokenSharePerDay', 'chartMonthlyMomentum', 'chartTokensPerDay', 'chartSessionsPerDay', 'chartTokensPerRepo', 'chartSessionsPerRepo', 'statsAgentHeatmap', 'statsChatHeatmap', 'statsSdkHeatmap'].map((id) => document.getElementById(id));
16068
16466
  const clearLoading = scheduleStatsLoading(elements, 'Loading\u2026');
16069
16467
  if (projectStatsControls) projectStatsControls.hidden = false;
16070
16468
  const metaLoadingTimer = window.setTimeout(() => {
@@ -16310,12 +16708,6 @@ function addStatsTotals(target, entry) {
16310
16708
  target.user_messages += Number(entry.user_messages || 0);
16311
16709
  }
16312
16710
 
16313
- function statsBreakdownEntry(group, totals) {
16314
- if (statsBreakdownMode === 'model') return { model: group, ...totals };
16315
- if (statsBreakdownMode === 'company') return { company: group, ...totals };
16316
- return { provider: group, ...totals };
16317
- }
16318
-
16319
16711
  function statsDateInChartRange(date) {
16320
16712
  const start = statsChartStartYm + '-01';
16321
16713
  const end = chartRangeEndIso(statsChartEndYm);
@@ -16331,21 +16723,23 @@ function statsBreakdownSort(a, b) {
16331
16723
  || String(statsBreakdownGroupId(a)).localeCompare(String(statsBreakdownGroupId(b)));
16332
16724
  }
16333
16725
 
16334
- function statsBreakdownRangeTotals(payload) {
16335
- const groupKey = statsBreakdownGroupKey();
16726
+ function statsRangeTotalsForKey(payload, bucketKey, idKey) {
16336
16727
  const totals = new Map();
16337
16728
  const daily = Array.isArray(payload?.daily) ? payload.daily : [];
16338
16729
  for (const day of daily) {
16339
16730
  if (!statsDateInChartRange(day?.date)) continue;
16340
- const buckets = day[groupKey] || {};
16731
+ const buckets = day[bucketKey] || {};
16341
16732
  for (const [group, entry] of Object.entries(buckets)) {
16342
16733
  if (!totals.has(group)) totals.set(group, emptyStatsTotals());
16343
16734
  addStatsTotals(totals.get(group), entry);
16344
16735
  }
16345
16736
  }
16346
- return Array.from(totals.entries())
16347
- .map(([group, totalsEntry]) => statsBreakdownEntry(group, totalsEntry))
16348
- .sort(statsBreakdownSort);
16737
+ return Array.from(totals.entries()).map(([group, totalsEntry]) => ({ [idKey]: group, ...totalsEntry }));
16738
+ }
16739
+
16740
+ function statsBreakdownRangeTotals(payload) {
16741
+ const idKey = statsBreakdownMode === 'model' ? 'model' : statsBreakdownMode === 'company' ? 'company' : 'provider';
16742
+ return statsRangeTotalsForKey(payload, statsBreakdownGroupKey(), idKey).sort(statsBreakdownSort);
16349
16743
  }
16350
16744
 
16351
16745
  function statsBreakdownGroupsList(payload, totalsOverride) {
@@ -16415,15 +16809,18 @@ function renderStatsDailyCharts() {
16415
16809
  const densified = densifyDailyInMonthRange(lastStatsPayload.daily || [], statsChartStartYm, statsChartEndYm, statsBreakdownGroupKey());
16416
16810
  if (!densified.length || !groups.length) {
16417
16811
  const emptyShare = document.getElementById('chartTokenSharePerDay');
16812
+ const emptyMomentum = document.getElementById('chartMonthlyMomentum');
16418
16813
  const empty = document.getElementById('chartTokensPerDay');
16419
16814
  const empty2 = document.getElementById('chartSessionsPerDay');
16420
16815
  if (emptyShare) { emptyShare.classList.add('stats-empty'); emptyShare.textContent = 'No token share in this range.'; }
16816
+ if (emptyMomentum) { emptyMomentum.classList.add('stats-empty'); emptyMomentum.textContent = 'No monthly momentum in this range.'; }
16421
16817
  if (empty) { empty.classList.add('stats-empty'); empty.textContent = 'No days in this range.'; }
16422
16818
  if (empty2) { empty2.classList.add('stats-empty'); empty2.textContent = 'No days in this range.'; }
16423
16819
  return;
16424
16820
  }
16425
16821
  const canonicalGroups = statsCanonicalOrderedGroups(groups);
16426
16822
  renderDailyShareChart('chartTokenSharePerDay', densified, canonicalGroups, usageMetric);
16823
+ renderMonthlyMomentumChart('chartMonthlyMomentum', lastStatsPayload.daily || [], groups, usageMetric);
16427
16824
  renderDailyChart('chartTokensPerDay', densified, canonicalGroups, usageMetric, 336);
16428
16825
  renderDailyChart('chartSessionsPerDay', densified, canonicalGroups, activityMetric, 336);
16429
16826
  }
@@ -16445,6 +16842,8 @@ function renderStats(payload) {
16445
16842
  const groups = statsBreakdownGroupsList(payload, breakdownTotals);
16446
16843
  renderStatsLegend(groups, breakdownTotals);
16447
16844
  renderStatsDailyCharts();
16845
+ const foldersSub = document.getElementById('statsFoldersSub');
16846
+ if (foldersSub) foldersSub.textContent = 'Top folders by the selected breakdown above · ' + statsChartRangeLabel();
16448
16847
  const repoRows = statsRepoRowsForRange(payload);
16449
16848
  const canonicalGroupsForRepo = statsCanonicalOrderedGroups(groups);
16450
16849
  renderRepoChart('chartTokensPerRepo', repoRows, canonicalGroupsForRepo, statsTokenMetric());
@@ -16494,20 +16893,20 @@ function isoDayOffset(day, deltaDays) {
16494
16893
  function renderStatsMixCharts(payload) {
16495
16894
  const sub = document.getElementById('statsMixSub');
16496
16895
  const metric = statsTokenMetric();
16497
- if (sub) sub.textContent = statsTokenMixLabel(metric);
16498
- renderDonutChart('chartTokensByProvider', payload?.byProvider || [], {
16896
+ if (sub) sub.textContent = statsTokenMixLabel(metric) + ' · ' + statsChartRangeLabel();
16897
+ renderDonutChart('chartTokensByProvider', statsRangeTotalsForKey(payload, 'providers', 'provider'), {
16499
16898
  key: 'provider',
16500
16899
  metric,
16501
16900
  label: providerLabel,
16502
16901
  color: providerColor
16503
16902
  });
16504
- renderDonutChart('chartTokensByCompany', payload?.byCompany || [], {
16903
+ renderDonutChart('chartTokensByCompany', statsRangeTotalsForKey(payload, 'companies', 'company'), {
16505
16904
  key: 'company',
16506
16905
  metric,
16507
16906
  label: companyLabel,
16508
16907
  color: companyColor
16509
16908
  });
16510
- renderDonutChart('chartTokensByModel', payload?.byModelBreakdown || payload?.byModel || [], {
16909
+ renderDonutChart('chartTokensByModel', statsRangeTotalsForKey(payload, 'models', 'model'), {
16511
16910
  key: 'model',
16512
16911
  metric,
16513
16912
  label: modelLabel,
@@ -16517,15 +16916,16 @@ function renderStatsMixCharts(payload) {
16517
16916
 
16518
16917
  function renderStatsSpendCharts(payload) {
16519
16918
  const sub = document.getElementById('statsSpendSub');
16520
- const spend = Number(payload?.spend_usd || 0);
16521
- const pricedTokens = Number(payload?.spend_priced_tokens || 0);
16522
- const unpricedTokens = Number(payload?.spend_unpriced_tokens || 0);
16919
+ const providerRows = statsRangeTotalsForKey(payload, 'providers', 'provider');
16920
+ const spend = providerRows.reduce((sum, row) => sum + Number(row.spend_usd || 0), 0);
16921
+ const pricedTokens = providerRows.reduce((sum, row) => sum + Number(row.spend_priced_tokens || 0), 0);
16922
+ const unpricedTokens = providerRows.reduce((sum, row) => sum + Number(row.spend_unpriced_tokens || 0), 0);
16523
16923
  const coverageTotal = pricedTokens + unpricedTokens;
16524
16924
  const coverage = coverageTotal ? Math.round((pricedTokens / coverageTotal) * 100) : 0;
16525
16925
  if (sub) {
16526
16926
  sub.textContent = spend
16527
- ? formatUsd(spend) + ' estimated · ' + coverage + '% token coverage'
16528
- : 'No priced model/token splits yet.';
16927
+ ? formatUsd(spend) + ' estimated · ' + coverage + '% token coverage · ' + statsChartRangeLabel()
16928
+ : 'No priced model/token splits in this range.';
16529
16929
  }
16530
16930
  renderStatsDailySpendChart('chartSpendPerDay', payload);
16531
16931
  renderMonthlyStackedChart('chartSpendPerMonth', payload, 'providers', 'spend_usd', {
@@ -16534,7 +16934,7 @@ function renderStatsSpendCharts(payload) {
16534
16934
  groupColor: providerColor,
16535
16935
  emptyText: 'No priced spend in this range.'
16536
16936
  });
16537
- renderDonutChart('chartSpendByProvider', payload?.byProvider || [], {
16937
+ renderDonutChart('chartSpendByProvider', providerRows, {
16538
16938
  key: 'provider',
16539
16939
  metric: 'spend_usd',
16540
16940
  label: providerLabel,
@@ -16542,7 +16942,7 @@ function renderStatsSpendCharts(payload) {
16542
16942
  formatValue: formatUsd,
16543
16943
  unitLabel: 'est.'
16544
16944
  });
16545
- renderDonutChart('chartSpendByModel', payload?.byModelBreakdown || payload?.byModel || [], {
16945
+ renderDonutChart('chartSpendByModel', statsRangeTotalsForKey(payload, 'models', 'model'), {
16546
16946
  key: 'model',
16547
16947
  metric: 'spend_usd',
16548
16948
  label: modelLabel,
@@ -16555,12 +16955,17 @@ function renderStatsSpendCharts(payload) {
16555
16955
  function renderStatsDailySpendChart(elementId, payload) {
16556
16956
  const el = document.getElementById(elementId);
16557
16957
  if (!el) return;
16558
- const activity = (Array.isArray(payload?.daily_activity) ? payload.daily_activity : []).filter((row) => row && row.date);
16559
- const empty = () => { el.classList.add('stats-empty'); el.textContent = 'No priced spend yet.'; };
16560
- if (!activity.length) { empty(); return; }
16561
- const byDate = new Map(activity.map((row) => [row.date, Number(row.spend_usd || 0)]));
16562
- const startDate = activity[0].date;
16563
- const endDate = activity[activity.length - 1].date;
16958
+ const empty = () => { el.classList.add('stats-empty'); el.textContent = 'No priced spend in this range.'; };
16959
+ const byDate = new Map();
16960
+ for (const row of Array.isArray(payload?.daily) ? payload.daily : []) {
16961
+ if (!row || !statsDateInChartRange(row.date)) continue;
16962
+ const spend = Object.values(row.providers || {}).reduce((sum, entry) => sum + Number(entry?.spend_usd || 0), 0);
16963
+ if (spend > 0) byDate.set(row.date, spend);
16964
+ }
16965
+ if (!byDate.size) { empty(); return; }
16966
+ const dates = Array.from(byDate.keys()).sort();
16967
+ const startDate = dates[0];
16968
+ const endDate = dates[dates.length - 1];
16564
16969
  const rows = [];
16565
16970
  let cursor = startDate;
16566
16971
  let guard = 0;
@@ -16623,8 +17028,8 @@ function renderStatsOutputWorkCharts(payload) {
16623
17028
  if (sub) {
16624
17029
  const coverage = totals.total ? Math.round((totals.known / totals.total) * 100) : 0;
16625
17030
  sub.textContent = totals.total
16626
- ? formatCompactNumber(totals.total) + ' output tokens classified · ' + coverage + '% known'
16627
- : 'No output job-mix metadata yet.';
17031
+ ? formatCompactNumber(totals.total) + ' output tokens classified · ' + coverage + '% known · ' + statsChartRangeLabel()
17032
+ : 'No output job-mix metadata in this range.';
16628
17033
  }
16629
17034
  renderOutputWorkChart('chartOutputTokenWork', payload);
16630
17035
  renderMeaningfulRatios('chartMeaningfulRatios', payload);
@@ -16782,8 +17187,10 @@ function renderStatsWorkCharts(payload) {
16782
17187
  const parts = [];
16783
17188
  if (totalDuration) parts.push(formatDurationMs(totalDuration) + ' session time');
16784
17189
  if (toolCalls) parts.push(formatFullNumber(toolCalls) + ' tool calls');
16785
- sub.textContent = parts.join(', ') || 'No session time or tool calls yet.';
17190
+ sub.textContent = parts.length ? parts.join(', ') + ' · all time' : 'No session time or tool calls yet.';
16786
17191
  }
17192
+ const sessionTimeMeta = document.getElementById('chartSessionTimePerMonthMeta');
17193
+ if (sessionTimeMeta) sessionTimeMeta.textContent = statsChartRangeLabel();
16787
17194
  renderMonthlySingleMetricChart('chartSessionTimePerMonth', payload?.daily_activity || [], 'session_duration_ms', {
16788
17195
  valueLabel: formatDurationMs,
16789
17196
  emptyText: 'No session time in this range.',
@@ -16823,7 +17230,7 @@ function renderProviderInsights(payload) {
16823
17230
  const rows = (Array.isArray(payload?.byProviderInsights) ? payload.byProviderInsights : [])
16824
17231
  .filter((row) => Number(row.conversations || 0) + Number(row.subagent_runs || 0) > 0);
16825
17232
  const sub = document.getElementById('statsProviderInsightsSub');
16826
- if (sub) sub.textContent = rows.length ? 'Highlights across ' + rows.length + ' provider' + (rows.length === 1 ? '' : 's') + '.' : 'Per-provider highlights.';
17233
+ if (sub) sub.textContent = rows.length ? 'Highlights across ' + rows.length + ' provider' + (rows.length === 1 ? '' : 's') + ' · all time.' : 'Per-provider highlights · all time.';
16827
17234
  if (!rows.length) {
16828
17235
  el.classList.add('stats-empty');
16829
17236
  el.textContent = 'No provider activity yet.';
@@ -17306,12 +17713,16 @@ function syncStatsBreakdownLabels() {
17306
17713
  const title = document.getElementById('statsBreakdownTitle');
17307
17714
  const shareTitle = document.getElementById('chartTokenSharePerDayTitle');
17308
17715
  const shareMeta = document.getElementById('chartTokenSharePerDayMeta');
17716
+ const momentumTitle = document.getElementById('chartMonthlyMomentumTitle');
17717
+ const momentumMeta = document.getElementById('chartMonthlyMomentumMeta');
17309
17718
  const providerBtn = document.getElementById('statsBreakdownProviderToggle');
17310
17719
  const companyBtn = document.getElementById('statsBreakdownCompanyToggle');
17311
17720
  const modelBtn = document.getElementById('statsBreakdownModelToggle');
17312
17721
  if (title) title.textContent = isModel ? 'Model breakdown' : isCompany ? 'Company breakdown' : 'Provider breakdown';
17313
17722
  if (shareTitle) shareTitle.textContent = isModel ? 'Daily token share by model' : isCompany ? 'Daily token share by company' : 'Daily token share by provider';
17314
17723
  if (shareMeta) shareMeta.textContent = statsTokenMixLabel(statsTokenMetric());
17724
+ if (momentumTitle) momentumTitle.textContent = isModel ? 'Monthly momentum by model' : isCompany ? 'Monthly momentum by company' : 'Monthly momentum by provider';
17725
+ if (momentumMeta) momentumMeta.textContent = statsTokenMixLabel(statsTokenMetric()) + ' \u00b7 % change vs prior month';
17315
17726
  if (providerBtn) {
17316
17727
  const active = !isModel && !isCompany;
17317
17728
  providerBtn.classList.toggle('active', active);
@@ -18110,7 +18521,7 @@ function renderDailyShareChart(elementId, daily, groups, metric) {
18110
18521
  return;
18111
18522
  }
18112
18523
  el.classList.remove('stats-empty');
18113
- const width = 760;
18524
+ const width = 600;
18114
18525
  const height = 232;
18115
18526
  const padding = { top: 16, right: 18, bottom: 36, left: 54 };
18116
18527
  const innerW = width - padding.left - padding.right;
@@ -18215,6 +18626,170 @@ function formatStatsIsoDayTick(iso) {
18215
18626
  return monthAbbrev(month) + ' ' + String(day).padStart(2, '0');
18216
18627
  }
18217
18628
 
18629
+ function statsPrevYearMonth(ym) {
18630
+ const y = Number(String(ym).slice(0, 4));
18631
+ const m = Number(String(ym).slice(5, 7));
18632
+ if (!Number.isFinite(y) || !Number.isFinite(m)) return '';
18633
+ const d = new Date(y, m - 2, 1);
18634
+ return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
18635
+ }
18636
+
18637
+ function statsMonthlyGroupTotals(dailySparse, metric) {
18638
+ const groupKey = statsBreakdownGroupKey();
18639
+ const months = new Map();
18640
+ for (const row of Array.isArray(dailySparse) ? dailySparse : []) {
18641
+ const ym = String(row?.date || '').slice(0, 7);
18642
+ if (ym.length !== 7) continue;
18643
+ let bucket = months.get(ym);
18644
+ if (!bucket) { bucket = { total: 0, groups: new Map() }; months.set(ym, bucket); }
18645
+ for (const [group, entry] of Object.entries(row[groupKey] || {})) {
18646
+ const value = statsMetricValue(entry, metric);
18647
+ if (value <= 0) continue;
18648
+ bucket.total += value;
18649
+ bucket.groups.set(group, (bucket.groups.get(group) || 0) + value);
18650
+ }
18651
+ }
18652
+ return months;
18653
+ }
18654
+
18655
+ function formatMomentumPercent(pct) {
18656
+ if (pct == null) return 'new';
18657
+ const rounded = Math.abs(pct) >= 10 ? Math.round(pct) : Math.round(pct * 10) / 10;
18658
+ return (rounded > 0 ? '+' : '') + formatFullNumber(rounded) + '%';
18659
+ }
18660
+
18661
+ function renderMonthlyMomentumChart(elementId, dailySparse, usageSortedGroups, metric) {
18662
+ const el = document.getElementById(elementId);
18663
+ if (!el) return;
18664
+ const months = statsMonthlyGroupTotals(dailySparse, metric);
18665
+ const lineGroups = statsCanonicalOrderedGroups(usageSortedGroups.slice(0, 6));
18666
+ const currentYm = isoDayLocal(new Date()).slice(0, 7);
18667
+ const emptyBucket = { total: 0, groups: new Map() };
18668
+ const rows = Array.from(months.keys())
18669
+ .filter((ym) => ym >= statsChartStartYm && ym <= statsChartEndYm)
18670
+ .sort()
18671
+ .map((ym) => {
18672
+ const cur = months.get(ym) || emptyBucket;
18673
+ const prevBucket = months.get(statsPrevYearMonth(ym)) || emptyBucket;
18674
+ const groupRows = lineGroups.map((group) => {
18675
+ const value = cur.groups.get(group) || 0;
18676
+ const prev = prevBucket.groups.get(group) || 0;
18677
+ return { group, value, prev, pct: prev > 0 ? ((value - prev) / prev) * 100 : null };
18678
+ });
18679
+ const totalPct = prevBucket.total > 0 ? ((cur.total - prevBucket.total) / prevBucket.total) * 100 : null;
18680
+ return { ym, partial: ym === currentYm, total: cur.total, prevTotal: prevBucket.total, totalPct, groups: groupRows };
18681
+ })
18682
+ .filter((row) => row.total > 0 || row.prevTotal > 0);
18683
+ const hasPoint = rows.some((row) => row.groups.some((g) => g.pct != null));
18684
+ if (!rows.length || !hasPoint) {
18685
+ el.classList.add('stats-empty');
18686
+ el.textContent = 'Not enough monthly history yet.';
18687
+ return;
18688
+ }
18689
+ el.classList.remove('stats-empty');
18690
+ const clampPct = (pct) => Math.max(-100, Math.min(300, pct));
18691
+ let minV = 0;
18692
+ let maxV = 0;
18693
+ for (const row of rows) {
18694
+ for (const g of row.groups) {
18695
+ if (g.pct == null) continue;
18696
+ const v = clampPct(g.pct);
18697
+ if (v < minV) minV = v;
18698
+ if (v > maxV) maxV = v;
18699
+ }
18700
+ }
18701
+ if (maxV - minV < 20) { maxV += 10; minV -= 10; }
18702
+ const span = maxV - minV;
18703
+ const stepOptions = [5, 10, 20, 25, 50, 100, 150, 200];
18704
+ const step = stepOptions.find((s) => span / s <= 5) || 200;
18705
+ const tickMin = Math.floor(minV / step) * step;
18706
+ const tickMax = Math.ceil(maxV / step) * step;
18707
+ const ticks = [];
18708
+ for (let v = tickMin; v <= tickMax; v += step) ticks.push(v);
18709
+ const width = 600;
18710
+ const height = 232;
18711
+ const padding = { top: 14, right: 16, bottom: 36, left: 54 };
18712
+ const innerW = width - padding.left - padding.right;
18713
+ const innerH = height - padding.top - padding.bottom;
18714
+ const xFor = (idx) => rows.length === 1
18715
+ ? padding.left + innerW / 2
18716
+ : padding.left + (idx / (rows.length - 1)) * innerW;
18717
+ const yFor = (pct) => padding.top + innerH * (1 - (clampPct(pct) - tickMin) / (tickMax - tickMin || 1));
18718
+ const yTicks = ticks.map((value) => {
18719
+ const y = yFor(value);
18720
+ const lineClass = value === 0 ? 'stats-momentum-zero' : 'stats-axis-line';
18721
+ return '<line class="' + lineClass + '" x1="' + padding.left + '" x2="' + (padding.left + innerW) + '" y1="' + y.toFixed(2) + '" y2="' + y.toFixed(2) + '"/>'
18722
+ + '<text class="stats-axis-label" x="' + (padding.left - 7) + '" y="' + (y + 3).toFixed(2) + '" text-anchor="end">' + esc((value > 0 ? '+' : '') + value + '%') + '</text>';
18723
+ }).join('');
18724
+ const axisTitle = '<text class="stats-axis-label" transform="rotate(-90 15 ' + (padding.top + innerH / 2).toFixed(2) + ')" x="15" y="' + (padding.top + innerH / 2).toFixed(2) + '" text-anchor="middle">MoM %</text>';
18725
+ const shapes = [];
18726
+ lineGroups.forEach((group, groupIdx) => {
18727
+ const color = statsBreakdownColor(group);
18728
+ for (let idx = 0; idx < rows.length; idx += 1) {
18729
+ const point = rows[idx].groups[groupIdx];
18730
+ if (point.pct == null) continue;
18731
+ const x = xFor(idx);
18732
+ const y = yFor(point.pct);
18733
+ const prevPoint = idx > 0 ? rows[idx - 1].groups[groupIdx] : null;
18734
+ if (prevPoint && prevPoint.pct != null) {
18735
+ const fade = rows[idx].partial ? ' stroke-opacity="0.45"' : '';
18736
+ shapes.push('<line class="stats-momentum-line" x1="' + xFor(idx - 1).toFixed(2) + '" y1="' + yFor(prevPoint.pct).toFixed(2) + '" x2="' + x.toFixed(2) + '" y2="' + y.toFixed(2) + '" stroke="' + esc(color) + '"' + fade + '/>');
18737
+ }
18738
+ const title = statsBreakdownLabel(group) + ' \u00b7 ' + formatStatsYearMonth(rows[idx].ym) + ' \u00b7 ' + formatMomentumPercent(point.pct);
18739
+ shapes.push('<circle pointer-events="none" cx="' + x.toFixed(2) + '" cy="' + y.toFixed(2) + '" r="2.6" fill="' + esc(color) + '"><title>' + esc(title) + '</title></circle>');
18740
+ }
18741
+ });
18742
+ const maxLabels = Math.max(2, Math.floor(innerW / 58));
18743
+ const labelStep = Math.max(1, Math.ceil(rows.length / maxLabels));
18744
+ const lastLabelIdx = rows.length - 1;
18745
+ const xLabels = rows
18746
+ .map((row, idx) => ((idx % labelStep === 0 && lastLabelIdx - idx >= Math.ceil(labelStep / 2)) || idx === lastLabelIdx)
18747
+ ? '<text class="stats-axis-label" x="' + xFor(idx).toFixed(2) + '" y="' + (padding.top + innerH + 18) + '" text-anchor="middle">' + esc(formatChartMonthTick(row.ym + '-01')) + '</text>'
18748
+ : '')
18749
+ .join('');
18750
+ const hits = rows.map((row, idx) => {
18751
+ const prevX = idx === 0 ? padding.left : (xFor(idx - 1) + xFor(idx)) / 2;
18752
+ const nextX = idx === rows.length - 1 ? padding.left + innerW : (xFor(idx) + xFor(idx + 1)) / 2;
18753
+ return '<rect class="stats-momentum-hit" pointer-events="all" x="' + prevX.toFixed(2) + '" y="' + padding.top + '" width="' + Math.max(1, nextX - prevX).toFixed(2) + '" height="' + innerH.toFixed(2) + '" fill="transparent" data-month-index="' + idx + '"/>';
18754
+ }).join('');
18755
+ el.innerHTML = '<svg viewBox="0 0 ' + width + ' ' + height + '" preserveAspectRatio="xMidYMid meet" width="100%" height="100%" aria-label="Monthly momentum">' + yTicks + axisTitle + shapes.join('') + xLabels + hits + '</svg>';
18756
+ el.__momentumRows = rows;
18757
+ el.__momentumMetric = metric;
18758
+ bindMomentumChartHover(el);
18759
+ }
18760
+
18761
+ function formatMomentumTooltipHtml(row, metric) {
18762
+ const head = formatStatsYearMonth(row.ym) + (row.partial ? ' \u00b7 month to date' : '');
18763
+ const unit = statsMetricUnit(metric, row.total);
18764
+ const totalPctText = row.prevTotal > 0 ? formatMomentumPercent(row.totalPct) : (row.total > 0 ? 'new' : '\u2014');
18765
+ const detailLines = [];
18766
+ for (const g of row.groups) {
18767
+ if (g.value <= 0 && g.prev <= 0) continue;
18768
+ const pctText = g.prev > 0 ? formatMomentumPercent(g.pct) : (g.value > 0 ? 'new' : '\u2014');
18769
+ detailLines.push(esc(statsBreakdownLabel(g.group)) + ': ' + esc(pctText) + ' \u00b7 ' + esc(formatFullNumber(g.value)) + ' ' + esc(unit) + ' (prev ' + esc(formatFullNumber(g.prev)) + ')');
18770
+ }
18771
+ let html = '<strong>' + esc(head) + '</strong><br><strong>Total: ' + esc(totalPctText) + ' \u00b7 ' + esc(formatFullNumber(row.total)) + ' ' + esc(unit) + '</strong>';
18772
+ if (detailLines.length) html += '<br><span class="stats-tip-muted">' + detailLines.join('<br>') + '</span>';
18773
+ return html;
18774
+ }
18775
+
18776
+ function bindMomentumChartHover(chartEl) {
18777
+ if (!chartEl || chartEl.dataset.statsMomentumHover === '1') return;
18778
+ chartEl.dataset.statsMomentumHover = '1';
18779
+ chartEl.addEventListener('mousemove', function (e) {
18780
+ const hit = e.target && e.target.closest && e.target.closest('.stats-momentum-hit');
18781
+ if (!hit || !chartEl.contains(hit)) {
18782
+ hideStatsTooltip();
18783
+ return;
18784
+ }
18785
+ const idx = Number(hit.getAttribute('data-month-index'));
18786
+ const rows = chartEl.__momentumRows;
18787
+ if (!rows || !Number.isFinite(idx) || !rows[idx]) return;
18788
+ showStatsTooltipHtml(formatMomentumTooltipHtml(rows[idx], chartEl.__momentumMetric), e.clientX, e.clientY);
18789
+ });
18790
+ chartEl.addEventListener('mouseleave', hideStatsTooltip);
18791
+ }
18792
+
18218
18793
  function renderDailyChart(elementId, daily, providers, metric, height) {
18219
18794
  const el = document.getElementById(elementId);
18220
18795
  if (!el) return;
@@ -18685,6 +19260,7 @@ if (sessionHeaderEl && detailScrollEl) {
18685
19260
  detailScrollEl.addEventListener('scroll', () => {
18686
19261
  sessionHeaderEl.classList.toggle('scrolled', detailScrollEl.scrollTop > 0);
18687
19262
  updateJumpEndVisibility();
19263
+ updateSessionOutlineActive();
18688
19264
  }, { passive: true });
18689
19265
  }
18690
19266
  setupKeyboardShortcuts();
@@ -18827,6 +19403,9 @@ Browse and recall:
18827
19403
  Runtime and maintenance:
18828
19404
  watcher <start|stop|status|logs> manage the background watcher
18829
19405
  watcher login <enable|disable|status> manage login startup
19406
+ notify slack setup guided Slack app creation + channel connect
19407
+ notify slack <enable|disable|status|test>
19408
+ post session summaries to a Slack channel
18830
19409
  mcp serve run the MCP recall server over stdio
18831
19410
  update [--since all] reimport from saved preferences after package updates
18832
19411
  repair claude-code-backups explicitly restore repairable Claude Code raw backups
@@ -18943,6 +19522,52 @@ Details:
18943
19522
  `;
18944
19523
 
18945
19524
  const HELP_TOPICS = {
19525
+ notify: `
19526
+ agentlog notify slack
19527
+
19528
+ Post session summaries to a Slack channel. Off by default; posting publishes
19529
+ session content beyond this machine. Only redacted archive output is posted.
19530
+
19531
+ Usage:
19532
+ agentlog notify slack setup
19533
+ agentlog notify slack enable [--summaries on|off] [--summary-channel C…] [--quiet-minutes 10]
19534
+ [--firehose on|off] [--firehose-channel C…] [--batch-seconds 45]
19535
+ [--token xoxb-…] [--repos github.com/org/repo,…]
19536
+ agentlog notify slack disable
19537
+ agentlog notify slack status
19538
+ agentlog notify slack test [--dry-run]
19539
+
19540
+ Setup:
19541
+ \`setup\` is the guided path: it opens Slack's app creation page pre-filled
19542
+ from a manifest (one personal app, chat:write only), verifies the pasted
19543
+ bot token, connects channels, and sends test posts. \`enable\` is the
19544
+ manual/scriptable path and also updates individual settings in place.
19545
+
19546
+ Behavior:
19547
+ Two independent surfaces, each with its own channel:
19548
+ - Summaries post one recap per session after it goes quiet for
19549
+ --quiet-minutes (default 10).
19550
+ - The firehose mirrors the conversation into one thread per session: each
19551
+ user and agent turn posts whole as its own message under its speaker's
19552
+ identity (requires the chat:write.customize scope; without it, turns
19553
+ post as the plain bot). Passes are batched every --batch-seconds
19554
+ (default 45). Tool calls are skipped unless notify.slack.stream
19555
+ .includeTools is true; the recap closes the thread out.
19556
+ The bot token comes from notify.slack.botToken, AGENTLOG_SLACK_BOT_TOKEN,
19557
+ or SLACK_BOT_TOKEN. An empty --repos list posts for all repos.
19558
+
19559
+ Threads:
19560
+ A session that goes quiet has its firehose thread closed (the recap is the
19561
+ closer when summaries are on). Picking the session back up later opens a
19562
+ fresh thread marked "(resumed)" that streams only the new turns.
19563
+
19564
+ Offline:
19565
+ Going offline pauses posting, never archiving. Failed posts keep their
19566
+ place (cursors and summaries are not marked done) and retry about once a
19567
+ minute, so everything drains on reconnect. One guard: a backlog larger
19568
+ than 20 turns for one session posts only the most recent 20 plus a
19569
+ skipped-count note instead of flooding the channel.
19570
+ `,
18946
19571
  help: `
18947
19572
  agentlog help
18948
19573