mobygate 0.8.4 → 0.9.4

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/bin/mobygate.js CHANGED
@@ -950,6 +950,12 @@ Usage:
950
950
  changing the active model.
951
951
  mobygate disconnect Remove moby entries from a client config.
952
952
  Usage: mobygate disconnect <client-id>
953
+ mobygate captures Search the captures index. Subcommands:
954
+ query [text] Filter + list captures
955
+ show <id> Print a capture's summary
956
+ stats Aggregate stats over the index
957
+ rebuild Backfill index from disk captures
958
+ Run \`mobygate captures query --help\` for filters.
953
959
  mobygate uninstall Remove installed services
954
960
  mobygate version Print version + install mode + path
955
961
 
@@ -958,6 +964,213 @@ Repo: ${REPO_ROOT}
958
964
  `);
959
965
  }
960
966
 
967
+ // ---------- captures ----------
968
+
969
+ function capturesUsage() {
970
+ print(`mobygate captures — search the SQLite-indexed capture log
971
+
972
+ Usage:
973
+ mobygate captures query [text] [filters]
974
+ mobygate captures show <request_id>
975
+ mobygate captures stats
976
+ mobygate captures rebuild
977
+
978
+ Query filters (all optional, combinable):
979
+ --since <dur> 1h, 24h, 7d, 30d (default: all)
980
+ --model <substr> claude-opus-4-7, sonnet, etc. (LIKE match)
981
+ --session <key> exact session_key match
982
+ --status <s> ok | client_disconnect | error
983
+ --stop <reason> end_turn | tool_use | max_tokens | ...
984
+ --min-duration <ms> only requests slower than N ms
985
+ --max-duration <ms> only requests faster than N ms
986
+ --has-tools only requests that declared tools
987
+ --limit <n> default 20, max 1000
988
+ --json emit rows as JSON instead of a table
989
+
990
+ Text (positional) does a LIKE %text% over first/last user message + session_key.
991
+
992
+ Examples:
993
+ mobygate captures query --since 1h --status client_disconnect
994
+ mobygate captures query "webflow" --model opus --limit 5
995
+ mobygate captures query --has-tools --min-duration 5000
996
+ mobygate captures show 809ba4e41301425d8587ed64
997
+ `);
998
+ }
999
+
1000
+ function parseDuration(s) {
1001
+ if (!s) return null;
1002
+ const m = String(s).match(/^(\d+)\s*([smhd])?$/);
1003
+ if (!m) return null;
1004
+ const n = parseInt(m[1], 10);
1005
+ const unit = m[2] || 's';
1006
+ const mult = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[unit];
1007
+ return n * mult;
1008
+ }
1009
+
1010
+ function takeFlag(args, name) {
1011
+ const i = args.indexOf(name);
1012
+ if (i === -1) return null;
1013
+ const val = args[i + 1];
1014
+ args.splice(i, 2);
1015
+ return val;
1016
+ }
1017
+
1018
+ function takeBool(args, name) {
1019
+ const i = args.indexOf(name);
1020
+ if (i === -1) return false;
1021
+ args.splice(i, 1);
1022
+ return true;
1023
+ }
1024
+
1025
+ function truncate(s, n) {
1026
+ if (s == null) return '';
1027
+ s = String(s).replace(/\s+/g, ' ').trim();
1028
+ return s.length > n ? s.slice(0, n - 1) + '…' : s;
1029
+ }
1030
+
1031
+ function fmtDuration(ms) {
1032
+ if (ms == null) return '-';
1033
+ if (ms < 1000) return `${ms}ms`;
1034
+ return `${(ms / 1000).toFixed(1)}s`;
1035
+ }
1036
+
1037
+ function fmtTokens(n) {
1038
+ if (n == null) return '-';
1039
+ if (n < 1000) return String(n);
1040
+ if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
1041
+ return `${(n / 1_000_000).toFixed(2)}M`;
1042
+ }
1043
+
1044
+ async function cmdCaptures() {
1045
+ const sub = process.argv[3];
1046
+ if (!sub || sub === '--help' || sub === '-h' || sub === 'help') {
1047
+ capturesUsage();
1048
+ return;
1049
+ }
1050
+
1051
+ const idx = await import('../lib/captures-index.js');
1052
+
1053
+ if (sub === 'query') {
1054
+ const args = process.argv.slice(4);
1055
+ if (args.includes('--help') || args.includes('-h')) { capturesUsage(); return; }
1056
+
1057
+ const filters = {};
1058
+ const since = takeFlag(args, '--since');
1059
+ if (since) filters.sinceMs = parseDuration(since);
1060
+ const model = takeFlag(args, '--model');
1061
+ if (model) filters.model = model;
1062
+ const session = takeFlag(args, '--session');
1063
+ if (session) filters.sessionKey = session;
1064
+ const status = takeFlag(args, '--status');
1065
+ if (status) filters.status = status;
1066
+ const stop = takeFlag(args, '--stop');
1067
+ if (stop) filters.stopReason = stop;
1068
+ const minDur = takeFlag(args, '--min-duration');
1069
+ if (minDur) filters.minDurationMs = parseInt(minDur, 10);
1070
+ const maxDur = takeFlag(args, '--max-duration');
1071
+ if (maxDur) filters.maxDurationMs = parseInt(maxDur, 10);
1072
+ if (takeBool(args, '--has-tools')) filters.hasTools = true;
1073
+ const limit = takeFlag(args, '--limit');
1074
+ if (limit) filters.limit = parseInt(limit, 10);
1075
+ const asJson = takeBool(args, '--json');
1076
+ // Remaining positional args are the search text.
1077
+ const text = args.filter((a) => !a.startsWith('--')).join(' ');
1078
+ if (text) filters.text = text;
1079
+
1080
+ const { rows, error } = await idx.queryCaptures(filters);
1081
+ if (error) die(`captures query failed: ${error}`);
1082
+
1083
+ if (asJson) { print(JSON.stringify(rows, null, 2)); return; }
1084
+
1085
+ if (!rows.length) {
1086
+ print(c.dim('(no captures match)'));
1087
+ return;
1088
+ }
1089
+
1090
+ // Header
1091
+ print(
1092
+ c.bold('TIME '.padEnd(17)) +
1093
+ c.bold('REQUEST_ID '.padEnd(16)) +
1094
+ c.bold('STATUS '.padEnd(15)) +
1095
+ c.bold('STOP '.padEnd(12)) +
1096
+ c.bold('DUR '.padEnd(8)) +
1097
+ c.bold('IN '.padEnd(6)) +
1098
+ c.bold('OUT '.padEnd(6)) +
1099
+ c.bold('CACHE '.padEnd(7)) +
1100
+ c.bold('PREVIEW')
1101
+ );
1102
+ print(c.dim('─'.repeat(110)));
1103
+ for (const r of rows) {
1104
+ const ts = (r.ts || '').slice(5, 19).replace('T', ' ');
1105
+ const idShort = (r.request_id || '').slice(0, 14);
1106
+ const stColor = r.status === 'ok' ? c.green : r.status === 'client_disconnect' ? c.yell : r.status ? c.red : c.dim;
1107
+ const status = stColor((r.status || '-').padEnd(13));
1108
+ const stop = (r.stop_reason || '-').padEnd(10);
1109
+ const dur = fmtDuration(r.duration_ms).padEnd(6);
1110
+ const inT = fmtTokens(r.total_input_tokens).padEnd(4);
1111
+ const outT = fmtTokens(r.output_tokens).padEnd(4);
1112
+ const hit = r.cache_hit_pct != null ? `${r.cache_hit_pct.toFixed(0)}%`.padEnd(5) : '- ';
1113
+ const preview = truncate(r.first_user_text || r.last_user_text || '', 50);
1114
+ print(`${ts.padEnd(17)}${idShort.padEnd(16)}${status} ${stop} ${dur} ${inT} ${outT} ${hit} ${c.dim(preview)}`);
1115
+ }
1116
+ print(c.dim(`\n${rows.length} row${rows.length === 1 ? '' : 's'}`));
1117
+ return;
1118
+ }
1119
+
1120
+ if (sub === 'show') {
1121
+ const id = process.argv[4];
1122
+ if (!id) die('usage: mobygate captures show <request_id>');
1123
+ const row = await idx.getCapture(id);
1124
+ if (!row) die(`no capture found for request_id=${id}`);
1125
+ if (row.summary_path && existsSync(row.summary_path)) {
1126
+ print(readFileSync(row.summary_path, 'utf8'));
1127
+ } else {
1128
+ print(JSON.stringify(row, null, 2));
1129
+ }
1130
+ return;
1131
+ }
1132
+
1133
+ if (sub === 'stats') {
1134
+ const s = await idx.captureStats();
1135
+ if (s.error) die(`stats failed: ${s.error}`);
1136
+ print(c.bold(`Captures index: ${s.total} rows`));
1137
+ print('');
1138
+ print(c.bold('By status:'));
1139
+ for (const r of s.byStatus) print(` ${(r.status || '(none)').padEnd(20)} ${r.n}`);
1140
+ print('');
1141
+ print(c.bold('Top models:'));
1142
+ for (const r of s.byModel) print(` ${(r.m || '(none)').padEnd(28)} ${r.n}`);
1143
+ print('');
1144
+ print(c.bold('By stop_reason:'));
1145
+ for (const r of s.byStop) print(` ${(r.stop_reason || '(none)').padEnd(20)} ${r.n}`);
1146
+ print('');
1147
+ print(c.bold('Token totals:'));
1148
+ print(` input (uncached): ${fmtTokens(s.tokens.in_t)}`);
1149
+ print(` cache_read: ${fmtTokens(s.tokens.cr_t)}`);
1150
+ print(` cache_create: ${fmtTokens(s.tokens.cc_t)}`);
1151
+ print(` output: ${fmtTokens(s.tokens.out_t)}`);
1152
+ print(` avg cache hit: ${s.tokens.avg_hit ? s.tokens.avg_hit.toFixed(1) + '%' : '-'}`);
1153
+ print(` avg duration: ${fmtDuration(Math.round(s.tokens.avg_ms || 0))}`);
1154
+ return;
1155
+ }
1156
+
1157
+ if (sub === 'rebuild') {
1158
+ info('Backfilling captures index from ~/.mobygate/captures/ — this scans every .json file.');
1159
+ const start = Date.now();
1160
+ const result = await idx.rebuildFromFilesystem((p) => {
1161
+ process.stdout.write(`\r indexed ${p.indexed}/${p.scanned} ...`);
1162
+ });
1163
+ process.stdout.write('\r');
1164
+ if (result.error) die(`rebuild failed: ${result.error}`);
1165
+ const ms = Date.now() - start;
1166
+ ok(`Indexed ${result.indexed} captures (${result.errors} errors, ${ms}ms).`);
1167
+ return;
1168
+ }
1169
+
1170
+ capturesUsage();
1171
+ process.exit(1);
1172
+ }
1173
+
961
1174
  // ---------- dispatch ----------
962
1175
 
963
1176
  const cmd = process.argv[2];
@@ -965,6 +1178,7 @@ const COMMANDS = {
965
1178
  init: cmdInit,
966
1179
  connect: cmdConnect,
967
1180
  disconnect: cmdDisconnect,
1181
+ captures: cmdCaptures,
968
1182
  update: cmdUpdate,
969
1183
  upgrade: cmdUpdate,
970
1184
  doctor: cmdDoctor,
package/dashboard.css ADDED
@@ -0,0 +1 @@
1
+ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.visible{visibility:visible}.fixed{position:fixed}.relative{position:relative}.inset-0{inset:0}.z-50{z-index:50}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.-ml-3{margin-left:-.75rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-auto{margin-top:auto}.inline-block{display:inline-block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.h-1\.5{height:.375rem}.h-2{height:.5rem}.h-3{height:.75rem}.h-\[110px\]{height:110px}.h-\[22px\]{height:22px}.h-full{height:100%}.max-h-\[180px\]{max-height:180px}.max-h-\[240px\]{max-height:240px}.max-h-\[40vh\]{max-height:40vh}.max-h-\[52vh\]{max-height:52vh}.max-h-\[70vh\]{max-height:70vh}.min-h-screen{min-height:100vh}.w-1\.5{width:.375rem}.w-2{width:.5rem}.w-\[100px\]{width:100px}.w-\[110px\]{width:110px}.w-\[160px\]{width:160px}.w-\[180px\]{width:180px}.w-\[20\%\]{width:20%}.w-\[60px\]{width:60px}.w-\[6px\]{width:6px}.w-\[70px\]{width:70px}.w-\[72px\]{width:72px}.w-\[80px\]{width:80px}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.max-w-3xl{max-width:48rem}.max-w-\[1440px\]{max-width:1440px}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.basis-0{flex-basis:0px}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-pointer{cursor:pointer}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.flex-col{flex-direction:column}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-\[22px\]{gap:22px}.gap-\[3px\]{gap:3px}.gap-\[5px\]{gap:5px}.overflow-auto{overflow:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.rounded-r-md{border-top-right-radius:.375rem;border-bottom-right-radius:.375rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l-2{border-left-width:2px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-\[\#1A1A15\]{--tw-border-opacity:1;border-color:rgb(26 26 21/var(--tw-border-opacity,1))}.border-\[\#2A2A1F\]{--tw-border-opacity:1;border-color:rgb(42 42 31/var(--tw-border-opacity,1))}.border-\[\#B7E56D\]{--tw-border-opacity:1;border-color:rgb(183 229 109/var(--tw-border-opacity,1))}.border-l-\[\#4EA4C4\]{--tw-border-opacity:1;border-left-color:rgb(78 164 196/var(--tw-border-opacity,1))}.border-l-\[\#B7E56D\]{--tw-border-opacity:1;border-left-color:rgb(183 229 109/var(--tw-border-opacity,1))}.border-l-\[\#E89B2E\]{--tw-border-opacity:1;border-left-color:rgb(232 155 46/var(--tw-border-opacity,1))}.bg-\[\#0B0B09\]{--tw-bg-opacity:1;background-color:rgb(11 11 9/var(--tw-bg-opacity,1))}.bg-\[\#121210\]{--tw-bg-opacity:1;background-color:rgb(18 18 16/var(--tw-bg-opacity,1))}.bg-\[\#1A1A15\]{--tw-bg-opacity:1;background-color:rgb(26 26 21/var(--tw-bg-opacity,1))}.bg-\[\#2A2A1F\]{--tw-bg-opacity:1;background-color:rgb(42 42 31/var(--tw-bg-opacity,1))}.bg-\[\#4EA4C4\]{--tw-bg-opacity:1;background-color:rgb(78 164 196/var(--tw-bg-opacity,1))}.bg-\[\#5A5F54\]{--tw-bg-opacity:1;background-color:rgb(90 95 84/var(--tw-bg-opacity,1))}.bg-\[\#B7E56D1A\]{background-color:#b7e56d1a}.bg-\[\#B7E56D\]{--tw-bg-opacity:1;background-color:rgb(183 229 109/var(--tw-bg-opacity,1))}.bg-\[\#E89B2E\]{--tw-bg-opacity:1;background-color:rgb(232 155 46/var(--tw-bg-opacity,1))}.bg-black\/70{background-color:rgba(0,0,0,.7)}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-12{padding-left:3rem;padding-right:3rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-10{padding-top:2.5rem;padding-bottom:2.5rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-\[14px\]{padding-top:14px;padding-bottom:14px}.py-\[18px\]{padding-top:18px;padding-bottom:18px}.py-\[22px\]{padding-top:22px;padding-bottom:22px}.py-\[3px\]{padding-top:3px;padding-bottom:3px}.pb-2{padding-bottom:.5rem}.pb-7{padding-bottom:1.75rem}.pr-1{padding-right:.25rem}.pt-2{padding-top:.5rem}.pt-8{padding-top:2rem}.text-center{text-align:center}.text-right{text-align:right}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[64px\]{font-size:64px}.text-\[9px\]{font-size:9px}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.uppercase{text-transform:uppercase}.leading-3{line-height:.75rem}.leading-4{line-height:1rem}.leading-8{line-height:2rem}.leading-\[14px\]{line-height:14px}.leading-\[15px\]{line-height:15px}.leading-\[16px\]{line-height:16px}.leading-\[18px\]{line-height:18px}.leading-\[56px\]{line-height:56px}.tracking-\[0\.04em\]{letter-spacing:.04em}.tracking-\[0\.06em\]{letter-spacing:.06em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.12em\]{letter-spacing:.12em}.tracking-\[0\.14em\]{letter-spacing:.14em}.tracking-\[0\.18em\]{letter-spacing:.18em}.tracking-\[0\.22em\]{letter-spacing:.22em}.tracking-widest{letter-spacing:.1em}.text-\[\#0B0B09\]{--tw-text-opacity:1;color:rgb(11 11 9/var(--tw-text-opacity,1))}.text-\[\#4EA4C4\]{--tw-text-opacity:1;color:rgb(78 164 196/var(--tw-text-opacity,1))}.text-\[\#5A5F54\]{--tw-text-opacity:1;color:rgb(90 95 84/var(--tw-text-opacity,1))}.text-\[\#8A9A6A\]{--tw-text-opacity:1;color:rgb(138 154 106/var(--tw-text-opacity,1))}.text-\[\#B7E56D\]{--tw-text-opacity:1;color:rgb(183 229 109/var(--tw-text-opacity,1))}.text-\[\#C9D9A8\]{--tw-text-opacity:1;color:rgb(201 217 168/var(--tw-text-opacity,1))}.text-\[\#E89B2E\]{--tw-text-opacity:1;color:rgb(232 155 46/var(--tw-text-opacity,1))}.text-\[\#F3EFE4\]{--tw-text-opacity:1;color:rgb(243 239 228/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.accent-\[\#4EA4C4\]{accent-color:#4ea4c4}.opacity-50{opacity:.5}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.hover\:border-\[\#5A5F54\]:hover{--tw-border-opacity:1;border-color:rgb(90 95 84/var(--tw-border-opacity,1))}.hover\:bg-\[\#1A1F12\]:hover{--tw-bg-opacity:1;background-color:rgb(26 31 18/var(--tw-bg-opacity,1))}.hover\:text-\[\#C9D9A8\]:hover{--tw-text-opacity:1;color:rgb(201 217 168/var(--tw-text-opacity,1))}.hover\:brightness-110:hover{--tw-brightness:brightness(1.1);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}
package/index.html CHANGED
@@ -7,21 +7,7 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=VT323&display=swap" rel="stylesheet">
10
- <script src="https://cdn.tailwindcss.com"></script>
11
- <script>
12
- /* Design tokens transcribed from the Paper artboard C1-0.
13
- Using arbitrary Tailwind values for exact fidelity with the design. */
14
- tailwind.config = {
15
- theme: {
16
- extend: {
17
- fontFamily: {
18
- display: ['VT323', 'system-ui', 'monospace'],
19
- mono: ['"JetBrains Mono"', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'],
20
- },
21
- },
22
- },
23
- };
24
- </script>
10
+ <link rel="stylesheet" href="/dashboard.css">
25
11
  <style>
26
12
  html, body { background: #0B0B09; color: #F3EFE4; font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace; }
27
13
  .whale-ascii {
package/inspector.html CHANGED
@@ -121,12 +121,61 @@
121
121
  ::-webkit-scrollbar-track { background: var(--bg); }
122
122
  ::-webkit-scrollbar-thumb { background: var(--border); }
123
123
  ::-webkit-scrollbar-thumb:hover { background: var(--muted); }
124
+
125
+ /* Tabs */
126
+ .tabs { display: flex; gap: 0; }
127
+ .tab {
128
+ padding: 6px 14px; border: 1px solid var(--border); background: var(--bg);
129
+ cursor: pointer; color: var(--dim); font-size: 12px; border-right: none;
130
+ }
131
+ .tab:last-child { border-right: 1px solid var(--border); }
132
+ .tab.active { background: var(--bg-3); color: var(--accent); border-color: var(--accent); }
133
+ .tab:hover { color: var(--fg); }
134
+
135
+ .view { display: none; }
136
+ .view.active { display: grid; grid-template-columns: 460px 1fr; height: calc(100vh - 53px); }
137
+ .view-full { display: none; }
138
+ .view-full.active { display: block; height: calc(100vh - 53px); overflow-y: auto; padding: 20px 28px; }
139
+
140
+ /* Sessions view */
141
+ .totals-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px; }
142
+ .totals-grid .stat { padding: 12px 14px; border: 1px solid var(--border); background: var(--bg-2); }
143
+ .bucket-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 24px; }
144
+ .bucket-cell { padding: 10px 12px; border: 1px solid var(--border); background: var(--bg-2); }
145
+ .bucket-cell .lbl { color: var(--dim); font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
146
+ .bucket-cell .pct { font-family: 'VT323', monospace; font-size: 22px; line-height: 1; margin-top: 2px; }
147
+ .bucket-cell .pct.bleed { color: var(--crit); }
148
+ .bucket-cell .pct.warm { color: var(--hit); }
149
+ .bucket-cell .sub { color: var(--dim); font-size: 11px; margin-top: 2px; }
150
+
151
+ .sessions-table { width: 100%; border-collapse: collapse; font-size: 11px; }
152
+ .sessions-table th {
153
+ text-align: left; padding: 8px 10px; color: var(--dim); font-weight: 500;
154
+ border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg);
155
+ text-transform: uppercase; font-size: 10px; letter-spacing: 0.05em;
156
+ }
157
+ .sessions-table td {
158
+ padding: 8px 10px; border-bottom: 1px solid var(--border); color: var(--fg);
159
+ }
160
+ .sessions-table tr:hover td { background: var(--bg-2); }
161
+ .sessions-table .num { text-align: right; font-variant-numeric: tabular-nums; }
162
+ .sessions-table .cost-high { color: var(--crit); font-weight: 500; }
163
+ .sessions-table .cost-med { color: var(--warn); }
164
+ .sessions-table .bucket-singleton { color: var(--crit); }
165
+ .sessions-table .bucket-short { color: var(--warn); }
166
+ .sessions-table .bucket-medium { color: var(--dim); }
167
+ .sessions-table .bucket-warm { color: var(--hit); }
168
+ .sessions-table .preview { color: var(--dim); max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
169
+ .sessions-table .sk { font-family: 'JetBrains Mono', monospace; color: var(--dim); }
124
170
  </style>
125
171
  </head>
126
172
  <body>
127
173
  <header>
128
174
  <div class="title display">mobygate :: inspector</div>
129
- <div class="breadcrumb">~/.mobygate/captures/</div>
175
+ <div class="tabs">
176
+ <div class="tab active" data-tab="captures">Captures</div>
177
+ <div class="tab" data-tab="sessions">Sessions</div>
178
+ </div>
130
179
  <div class="spacer"></div>
131
180
  <button id="toggle" class="toggle">
132
181
  <span class="dot"></span>
@@ -135,7 +184,8 @@
135
184
  <a href="/">← dashboard</a>
136
185
  </header>
137
186
 
138
- <div class="layout">
187
+ <!-- Captures tab (existing two-pane layout) -->
188
+ <div class="view active" id="view-captures">
139
189
  <div class="list-pane" id="list-pane">
140
190
  <div class="list-stats" id="list-stats">loading…</div>
141
191
  <div id="list"></div>
@@ -145,6 +195,13 @@
145
195
  </div>
146
196
  </div>
147
197
 
198
+ <!-- Sessions tab (new in v0.8.5) -->
199
+ <div class="view-full" id="view-sessions">
200
+ <div id="sessions-content">
201
+ <div style="padding:40px;color:var(--dim);text-align:center">loading session costs…</div>
202
+ </div>
203
+ </div>
204
+
148
205
  <script>
149
206
  let selectedFilename = null;
150
207
  let captureCache = []; // last fetch result, used to compute deltas
@@ -412,11 +469,151 @@
412
469
  return head + `<div class="placeholder">… ${msgs.length - 15} messages collapsed …</div>` + tail;
413
470
  }
414
471
 
472
+ // ──────────── tab switching ────────────
473
+
474
+ let activeTab = 'captures';
475
+ let sessionsLoaded = false;
476
+
477
+ document.querySelectorAll('.tab').forEach(t => {
478
+ t.addEventListener('click', () => {
479
+ const tab = t.dataset.tab;
480
+ document.querySelectorAll('.tab').forEach(x => x.classList.toggle('active', x.dataset.tab === tab));
481
+ document.querySelectorAll('.view').forEach(v => v.classList.toggle('active', v.id === `view-${tab}`));
482
+ document.querySelectorAll('.view-full').forEach(v => v.classList.toggle('active', v.id === `view-${tab}`));
483
+ activeTab = tab;
484
+ if (tab === 'sessions') refreshSessions();
485
+ });
486
+ });
487
+
488
+ // ──────────── sessions view ────────────
489
+
490
+ async function refreshSessions() {
491
+ try {
492
+ const r = await fetch('/dashboard/session-costs');
493
+ const data = await r.json();
494
+ renderSessions(data);
495
+ sessionsLoaded = true;
496
+ } catch (e) {
497
+ document.getElementById('sessions-content').innerHTML =
498
+ `<div class="placeholder">failed: ${esc(e.message)}</div>`;
499
+ }
500
+ }
501
+
502
+ function renderSessions(data) {
503
+ const sessions = data.sessions || [];
504
+ const buckets = data.buckets || {};
505
+
506
+ // Header stats
507
+ const totalsHtml = `
508
+ <div class="totals-grid">
509
+ <div class="stat">
510
+ <div class="label">total cost</div>
511
+ <div class="val accent">$${(data.total_cost_usd || 0).toFixed(2)}</div>
512
+ <div class="sub">across all tracked turns</div>
513
+ </div>
514
+ <div class="stat">
515
+ <div class="label">sessions</div>
516
+ <div class="val">${data.session_count || 0}</div>
517
+ <div class="sub">distinct session keys</div>
518
+ </div>
519
+ <div class="stat">
520
+ <div class="label">turns</div>
521
+ <div class="val">${fmt(data.total_turns || 0)}</div>
522
+ <div class="sub">non-tool-use end_turns only</div>
523
+ </div>
524
+ <div class="stat">
525
+ <div class="label">avg / turn</div>
526
+ <div class="val ${(data.total_cost_usd / Math.max(data.total_turns,1)) > 0.15 ? 'warn' : ''}">$${((data.total_cost_usd || 0) / Math.max(data.total_turns, 1)).toFixed(3)}</div>
527
+ <div class="sub">lower = better cache discipline</div>
528
+ </div>
529
+ </div>
530
+ `;
531
+
532
+ // Bucket breakdown
533
+ const order = ['warm', 'medium', 'short', 'singleton'];
534
+ const labels = {
535
+ warm: { name: '11+ turns (warm)', desc: 'cache amortized', cls: 'warm' },
536
+ medium: { name: '4-10 turns (medium)', desc: 'partial amortize', cls: '' },
537
+ short: { name: '2-3 turns (short)', desc: 'limited amortize', cls: '' },
538
+ singleton: { name: '1 turn (singleton)', desc: 'cache_creation tax — bleed', cls: 'bleed' },
539
+ };
540
+ const bucketsHtml = order.map(k => {
541
+ const b = buckets[k] || { sessions: 0, cost: 0, pct_of_total: 0 };
542
+ const lbl = labels[k];
543
+ return `
544
+ <div class="bucket-cell">
545
+ <div class="lbl">${lbl.name}</div>
546
+ <div class="pct ${lbl.cls}">${b.pct_of_total}%</div>
547
+ <div class="sub">${b.sessions} sessions · $${b.cost.toFixed(2)} · ${lbl.desc}</div>
548
+ </div>
549
+ `;
550
+ }).join('');
551
+
552
+ // Session table
553
+ const rowsHtml = sessions.map(s => {
554
+ const costClass = s.cost_usd > 1.0 ? 'cost-high' : s.cost_usd > 0.3 ? 'cost-med' : '';
555
+ const lastSeen = s.last_seen ? s.last_seen.replace(/_/, ' ').replace(/-/g, ':').slice(0, 16) : '?';
556
+ return `
557
+ <tr>
558
+ <td><span class="sk">${esc(s.session_key.slice(0, 22))}</span></td>
559
+ <td class="bucket-${s.bucket}">${s.bucket}</td>
560
+ <td class="num">${s.turns}</td>
561
+ <td class="num ${costClass}">$${s.cost_usd.toFixed(4)}</td>
562
+ <td class="num">$${s.per_turn_usd.toFixed(4)}</td>
563
+ <td>${esc(s.model || '?')}</td>
564
+ <td>${esc((s.path || '').replace('/v1/', ''))}</td>
565
+ <td class="preview">${esc(s.first_user || '(no first message captured)')}</td>
566
+ </tr>
567
+ `;
568
+ }).join('');
569
+
570
+ document.getElementById('sessions-content').innerHTML = `
571
+ <h2 style="font-family:'VT323',monospace;color:var(--accent);font-size:24px;margin:0 0 4px">Sessions cost breakdown</h2>
572
+ <div style="color:var(--dim);font-size:11px;margin-bottom:20px">
573
+ Aggregated from <code>[model-billed]</code> log lines. Updated each request. Generated ${new Date(data.generatedAt).toLocaleTimeString()}.
574
+ </div>
575
+
576
+ ${totalsHtml}
577
+
578
+ <h3 style="color:var(--accent);font-size:12px;text-transform:uppercase;letter-spacing:0.05em;margin:16px 0 10px">Cost by session warmth</h3>
579
+ <div class="bucket-grid">${bucketsHtml}</div>
580
+
581
+ <h3 style="color:var(--accent);font-size:12px;text-transform:uppercase;letter-spacing:0.05em;margin:24px 0 10px">All sessions (by cost, descending)</h3>
582
+ <table class="sessions-table">
583
+ <thead>
584
+ <tr>
585
+ <th>session_key</th>
586
+ <th>bucket</th>
587
+ <th class="num">turns</th>
588
+ <th class="num">cost</th>
589
+ <th class="num">$/turn</th>
590
+ <th>model</th>
591
+ <th>shape</th>
592
+ <th>first user message</th>
593
+ </tr>
594
+ </thead>
595
+ <tbody>${rowsHtml}</tbody>
596
+ </table>
597
+
598
+ <div style="color:var(--dim);font-size:11px;margin-top:24px;line-height:1.5">
599
+ <b>How to read this:</b><br>
600
+ • <span style="color:var(--hit)">Warm</span> sessions = cache working, low $/turn — these are the wins.<br>
601
+ • <span style="color:var(--crit)">Singleton</span> sessions = sessions that fired once and never got cache amortization. High % here means money is bleeding to cache_creation tax on idle channels.<br>
602
+ • If a singleton is &gt; $0.50, it's typically a one-shot from a rarely-visited Discord channel.<br>
603
+ • <b>Mitigation:</b> consolidate channels, or set up an OpenClaw cron to ping high-value channels every 4 min to keep their wire cache warm.
604
+ </div>
605
+ `;
606
+ }
607
+
415
608
  // ──────────── kickoff ────────────
416
609
 
417
610
  refreshToggleState();
418
611
  refreshList();
419
- setInterval(() => { refreshList(); refreshToggleState(); }, 3000);
612
+ setInterval(() => {
613
+ if (activeTab === 'captures') { refreshList(); }
614
+ if (activeTab === 'sessions' && sessionsLoaded) { refreshSessions(); }
615
+ refreshToggleState();
616
+ }, 5000);
420
617
  </script>
421
618
  </body>
422
619
  </html>
package/lib/anthropic.js CHANGED
@@ -46,7 +46,12 @@ import { v4 as uuidv4 } from 'uuid';
46
46
  */
47
47
  export function extractSdkUsage(message) {
48
48
  if (!message) return { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, modelUsage: null };
49
- const u = message.usage || message;
49
+ // Order: explicit `.usage` (SDK result message), inner Anthropic
50
+ // Message `.message.usage` (assistant turn — present on every turn the
51
+ // SDK emits, so we can capture usage even when the loop is aborted on
52
+ // tool_use before the SDK delivers its final `result`), then flat fields
53
+ // on the message itself as a last-resort fallback.
54
+ const u = message.usage || message.message?.usage || message;
50
55
  return {
51
56
  input_tokens: u.input_tokens || 0,
52
57
  output_tokens: u.output_tokens || 0,