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/README.md +17 -1
- package/docs/code-reference.md +44 -0
- package/docs/history-source-handling.md +43 -43
- package/docs/release.md +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/archive.js +2 -0
- package/src/cli.js +679 -54
- package/src/config.js +37 -1
- package/src/slack-notify.js +732 -0
- package/src/supervisor.js +37 -1
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">
|
|
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
|
|
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 & 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 (
|
|
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(
|
|
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
|
|
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 (
|
|
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
|
|
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(
|
|
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 ||
|
|
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
|
|
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[
|
|
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
|
-
|
|
16348
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
16521
|
-
const
|
|
16522
|
-
const
|
|
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
|
|
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',
|
|
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
|
|
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
|
|
16559
|
-
const
|
|
16560
|
-
|
|
16561
|
-
|
|
16562
|
-
|
|
16563
|
-
|
|
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
|
|
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(', ')
|
|
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 =
|
|
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
|
|