myrlin-workbook 0.9.10 → 0.9.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myrlin-workbook",
3
- "version": "0.9.10",
3
+ "version": "0.9.12",
4
4
  "description": "Browser-based project manager for Claude Code sessions - session discovery, multi-terminal, cost tracking, docs, and kanban board",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -257,6 +257,7 @@ class TerminalPane {
257
257
  this._writeBuf = '';
258
258
  this._activitySample = '';
259
259
  this._writeRaf = null;
260
+ this._pasteHandled = false;
260
261
  }
261
262
 
262
263
  _log(msg) {
@@ -400,10 +401,17 @@ class TerminalPane {
400
401
  const xtermTextarea = container.querySelector('.xterm-helper-textarea');
401
402
  if (xtermTextarea) {
402
403
  xtermTextarea.addEventListener('beforeinput', (e) => {
403
- // Block paste events - we handle Ctrl+V/Cmd+V ourselves via
404
- // pasteFromClipboard() to avoid xterm.js onData firing twice
404
+ // Intercept paste-via-beforeinput to prevent xterm.js onData double-send.
405
+ // Extract the pasted text and send it through our WebSocket instead.
406
+ // Set _pasteHandled flag so the paste event handler doesn't double-send.
405
407
  if (e.inputType === 'insertFromPaste') {
406
408
  e.preventDefault();
409
+ this._pasteHandled = true;
410
+ const text = e.data || (e.dataTransfer && e.dataTransfer.getData('text/plain')) || '';
411
+ if (text && this.ws && this.ws.readyState === WebSocket.OPEN) {
412
+ const bracketedText = '\x1b[200~' + text + '\x1b[201~';
413
+ this.ws.send(JSON.stringify({ type: 'input', data: bracketedText }));
414
+ }
407
415
  return;
408
416
  }
409
417
 
@@ -426,10 +434,22 @@ class TerminalPane {
426
434
  }
427
435
  }, { capture: true });
428
436
 
429
- // Also block native paste event as a fallback
437
+ // Intercept native paste events (right-click > Paste, Edit menu, touch-paste)
438
+ // and route them through our WebSocket instead of letting xterm.js double-send.
439
+ // Ctrl+V/Cmd+V is handled by the custom key handler. beforeinput may have
440
+ // already handled this paste (sets _pasteHandled), so check the flag first.
430
441
  xtermTextarea.addEventListener('paste', (e) => {
431
442
  e.preventDefault();
432
443
  e.stopPropagation();
444
+ if (this._pasteHandled) {
445
+ this._pasteHandled = false;
446
+ return;
447
+ }
448
+ const text = (e.clipboardData || window.clipboardData || '').getData('text');
449
+ if (text && this.ws && this.ws.readyState === WebSocket.OPEN) {
450
+ const bracketedText = '\x1b[200~' + text + '\x1b[201~';
451
+ this.ws.send(JSON.stringify({ type: 'input', data: bracketedText }));
452
+ }
433
453
  }, { capture: true });
434
454
  }
435
455
 
package/src/web/server.js CHANGED
@@ -2931,11 +2931,12 @@ app.get('/api/sessions/:id/cost', requireAuth, (req, res) => {
2931
2931
  * Each entry includes sessionId, totalCost, and lastActive for sidebar badge rendering.
2932
2932
  * Uses the same cache as per-session cost endpoints.
2933
2933
  */
2934
- app.get('/api/cost/batch', requireAuth, (req, res) => {
2934
+ app.get('/api/cost/batch', requireAuth, async (req, res) => {
2935
2935
  try {
2936
2936
  const store = getStore();
2937
2937
  const allWorkspaces = store.getAllWorkspacesList();
2938
2938
  const costs = {};
2939
+ const pending = []; // Async calculations to run off the main thread
2939
2940
 
2940
2941
  for (const workspace of allWorkspaces) {
2941
2942
  const sessions = store.getWorkspaceSessions(workspace.id);
@@ -2951,25 +2952,46 @@ app.get('/api/cost/batch', requireAuth, (req, res) => {
2951
2952
  const mtimeMs = stat.mtimeMs;
2952
2953
  const cached = _costCache.get(resumeSessionId);
2953
2954
  const now = Date.now();
2954
- let costData;
2955
2955
 
2956
2956
  if (cached && cached.mtimeMs === mtimeMs && (now - cached.timestamp) < COST_CACHE_TTL) {
2957
- costData = cached.result;
2957
+ // Cache hit: resolve immediately, no event loop blocking
2958
+ costs[session.id] = {
2959
+ cost: cached.result.cost ? cached.result.cost.total : 0,
2960
+ lastActive: cached.result.lastMessage || session.lastActive || null,
2961
+ };
2958
2962
  } else {
2959
- costData = calculateSessionCost(jsonlPath);
2960
- const result = { sessionId: session.id, resumeSessionId, ...costData };
2961
- _costCache.set(resumeSessionId, { mtimeMs, timestamp: now, result });
2962
- costData = result;
2963
+ // Cache miss: queue async worker calculation
2964
+ const sid = session.id;
2965
+ const lastActive = session.lastActive;
2966
+ pending.push(
2967
+ calculateSessionCostAsync(jsonlPath).then(costData => {
2968
+ const result = { sessionId: sid, resumeSessionId, ...costData };
2969
+ _costCache.set(resumeSessionId, { mtimeMs, timestamp: now, result });
2970
+ costs[sid] = {
2971
+ cost: costData.cost ? costData.cost.total : 0,
2972
+ lastActive: costData.lastMessage || lastActive || null,
2973
+ };
2974
+ }).catch(() => {
2975
+ // Fallback: sync calculation (only for this single session)
2976
+ try {
2977
+ const costData = calculateSessionCost(jsonlPath);
2978
+ const result = { sessionId: sid, resumeSessionId, ...costData };
2979
+ _costCache.set(resumeSessionId, { mtimeMs, timestamp: now, result });
2980
+ costs[sid] = {
2981
+ cost: costData.cost ? costData.cost.total : 0,
2982
+ lastActive: costData.lastMessage || lastActive || null,
2983
+ };
2984
+ } catch (_) {}
2985
+ })
2986
+ );
2963
2987
  }
2964
-
2965
- costs[session.id] = {
2966
- cost: costData.cost ? costData.cost.total : 0,
2967
- lastActive: costData.lastMessage || session.lastActive || null,
2968
- };
2969
2988
  } catch (_) {}
2970
2989
  }
2971
2990
  }
2972
2991
 
2992
+ // Wait for all async calculations to complete
2993
+ if (pending.length > 0) await Promise.all(pending);
2994
+
2973
2995
  return res.json({ costs });
2974
2996
  } catch (err) {
2975
2997
  return res.status(500).json({ error: 'Batch cost failed: ' + err.message });
@@ -2982,11 +3004,12 @@ app.get('/api/cost/batch', requireAuth, (req, res) => {
2982
3004
  * Helps identify sessions that need compaction or are consuming the most tokens.
2983
3005
  * Sorted by latestInputTokens descending (heaviest first).
2984
3006
  */
2985
- app.get('/api/quota-overview', requireAuth, (req, res) => {
3007
+ app.get('/api/quota-overview', requireAuth, async (req, res) => {
2986
3008
  try {
2987
3009
  const store = getStore();
2988
3010
  const allWorkspaces = store.getAllWorkspacesList();
2989
- const sessionQuotas = [];
3011
+ const entries = []; // { session, workspace, costData }
3012
+ const pending = [];
2990
3013
 
2991
3014
  for (const workspace of allWorkspaces) {
2992
3015
  const sessions = store.getWorkspaceSessions(workspace.id);
@@ -2999,52 +3022,60 @@ app.get('/api/quota-overview', requireAuth, (req, res) => {
2999
3022
 
3000
3023
  try {
3001
3024
  const stat = fs.statSync(jsonlPath);
3002
- if (stat.size >= 500 * 1024 * 1024) continue; // Skip >500MB files
3025
+ if (stat.size >= 500 * 1024 * 1024) continue;
3003
3026
  const mtimeMs = stat.mtimeMs;
3004
3027
  const cached = _costCache.get(resumeSessionId);
3005
3028
  const now = Date.now();
3006
- let costData;
3007
3029
 
3008
3030
  if (cached && cached.mtimeMs === mtimeMs && (now - cached.timestamp) < COST_CACHE_TTL) {
3009
- costData = cached.result;
3031
+ entries.push({ session, workspace, costData: cached.result, stat });
3010
3032
  } else {
3011
- costData = calculateSessionCost(jsonlPath);
3012
- const result = { sessionId: session.id, resumeSessionId, ...costData };
3013
- _costCache.set(resumeSessionId, { mtimeMs, timestamp: now, result });
3033
+ const idx = entries.length;
3034
+ entries.push({ session, workspace, costData: null, stat });
3035
+ pending.push(
3036
+ calculateSessionCostAsync(jsonlPath).catch(() => calculateSessionCost(jsonlPath))
3037
+ .then(costData => {
3038
+ const result = { sessionId: session.id, resumeSessionId, ...costData };
3039
+ _costCache.set(resumeSessionId, { mtimeMs, timestamp: now, result });
3040
+ entries[idx].costData = result;
3041
+ }).catch(() => {})
3042
+ );
3014
3043
  }
3015
3044
 
3016
- const latestInput = costData.quota ? costData.quota.latestInputTokens : 0;
3017
- const peakInput = costData.quota ? costData.quota.peakInputTokens : 0;
3018
- const totalCost = costData.cost ? costData.cost.total : 0;
3019
- const totalTokens = costData.tokens ? costData.tokens.total : 0;
3020
- const messages = costData.messageCount || 0;
3021
-
3022
- // Heaviness score: context usage as percentage of 200K window
3023
- const contextPct = Math.round((latestInput / 200000) * 100);
3024
- // Compaction urgency: >80% = critical, >50% = warning, else OK
3025
- const urgency = contextPct >= 80 ? 'critical' : contextPct >= 50 ? 'warning' : 'ok';
3026
-
3027
- sessionQuotas.push({
3028
- sessionId: session.id,
3029
- sessionName: session.name || session.id.substring(0, 12),
3030
- workspaceId: workspace.id,
3031
- workspaceName: workspace.name,
3032
- latestInputTokens: latestInput,
3033
- peakInputTokens: peakInput,
3034
- contextPct,
3035
- urgency,
3036
- totalTokens,
3037
- totalCost,
3038
- messageCount: messages,
3039
- fileSize: stat.size,
3040
- lastMessage: costData.lastMessage || null,
3041
- });
3042
- } catch (_) {
3043
- // Skip sessions whose JSONL files can't be read
3044
- }
3045
+ } catch (_) {}
3045
3046
  }
3046
3047
  }
3047
3048
 
3049
+ if (pending.length > 0) await Promise.all(pending);
3050
+
3051
+ const sessionQuotas = [];
3052
+ for (const { session, workspace, costData, stat } of entries) {
3053
+ if (!costData) continue;
3054
+ const latestInput = costData.quota ? costData.quota.latestInputTokens : 0;
3055
+ const peakInput = costData.quota ? costData.quota.peakInputTokens : 0;
3056
+ const totalCost = costData.cost ? costData.cost.total : 0;
3057
+ const totalTokens = costData.tokens ? costData.tokens.total : 0;
3058
+ const messages = costData.messageCount || 0;
3059
+ const contextPct = Math.round((latestInput / 200000) * 100);
3060
+ const urgency = contextPct >= 80 ? 'critical' : contextPct >= 50 ? 'warning' : 'ok';
3061
+
3062
+ sessionQuotas.push({
3063
+ sessionId: session.id,
3064
+ sessionName: session.name || session.id.substring(0, 12),
3065
+ workspaceId: workspace.id,
3066
+ workspaceName: workspace.name,
3067
+ latestInputTokens: latestInput,
3068
+ peakInputTokens: peakInput,
3069
+ contextPct,
3070
+ urgency,
3071
+ totalTokens,
3072
+ totalCost,
3073
+ messageCount: messages,
3074
+ fileSize: stat.size,
3075
+ lastMessage: costData.lastMessage || null,
3076
+ });
3077
+ }
3078
+
3048
3079
  // Sort by context window size descending (heaviest first)
3049
3080
  sessionQuotas.sort((a, b) => b.latestInputTokens - a.latestInputTokens);
3050
3081
 
@@ -3074,7 +3105,7 @@ app.get('/api/quota-overview', requireAuth, (req, res) => {
3074
3105
  * GET /api/workspaces/:id/cost
3075
3106
  * Aggregates token usage and cost across all sessions in a workspace.
3076
3107
  */
3077
- app.get('/api/workspaces/:id/cost', requireAuth, (req, res) => {
3108
+ app.get('/api/workspaces/:id/cost', requireAuth, async (req, res) => {
3078
3109
  const store = getStore();
3079
3110
  const workspace = store.getWorkspace(req.params.id);
3080
3111
 
@@ -3094,6 +3125,10 @@ app.get('/api/workspaces/:id/cost', requireAuth, (req, res) => {
3094
3125
  sessionsWithData: 0,
3095
3126
  };
3096
3127
 
3128
+ // Resolve cost data for all sessions (async for cache misses)
3129
+ const costResults = [];
3130
+ const pending = [];
3131
+
3097
3132
  for (const session of sessions) {
3098
3133
  const resumeSessionId = session.resumeSessionId;
3099
3134
  if (!resumeSessionId) continue;
@@ -3102,44 +3137,57 @@ app.get('/api/workspaces/:id/cost', requireAuth, (req, res) => {
3102
3137
  if (!jsonlPath) continue;
3103
3138
 
3104
3139
  try {
3105
- // Check cache for individual session cost
3106
3140
  const stat = fs.statSync(jsonlPath);
3107
3141
  const mtimeMs = stat.mtimeMs;
3108
3142
  const cached = _costCache.get(resumeSessionId);
3109
3143
  const now = Date.now();
3110
- let costData;
3111
3144
 
3112
3145
  if (cached && cached.mtimeMs === mtimeMs && (now - cached.timestamp) < COST_CACHE_TTL) {
3113
- costData = cached.result;
3146
+ costResults.push(cached.result);
3114
3147
  } else {
3115
- costData = calculateSessionCost(jsonlPath);
3116
- const result = { sessionId: session.id, resumeSessionId, ...costData };
3117
- _costCache.set(resumeSessionId, { mtimeMs, timestamp: now, result });
3148
+ const idx = costResults.length;
3149
+ costResults.push(null);
3150
+ pending.push(
3151
+ calculateSessionCostAsync(jsonlPath).catch(() => calculateSessionCost(jsonlPath))
3152
+ .then(costData => {
3153
+ const result = { sessionId: session.id, resumeSessionId, ...costData };
3154
+ _costCache.set(resumeSessionId, { mtimeMs, timestamp: now, result });
3155
+ costResults[idx] = result;
3156
+ }).catch(() => {})
3157
+ );
3118
3158
  }
3159
+ } catch (_) {}
3160
+ }
3119
3161
 
3120
- totals.tokens.input += costData.tokens.input;
3121
- totals.tokens.output += costData.tokens.output;
3122
- totals.tokens.cacheWrite += costData.tokens.cacheWrite;
3123
- totals.tokens.cacheRead += costData.tokens.cacheRead;
3124
- totals.tokens.total += costData.tokens.total;
3162
+ if (pending.length > 0) await Promise.all(pending);
3125
3163
 
3126
- totals.cost.input += costData.cost.input;
3127
- totals.cost.output += costData.cost.output;
3128
- totals.cost.cacheWrite += costData.cost.cacheWrite;
3129
- totals.cost.cacheRead += costData.cost.cacheRead;
3130
- totals.cost.total += costData.cost.total;
3164
+ for (const costData of costResults) {
3165
+ if (!costData || !costData.tokens || !costData.cost) continue;
3131
3166
 
3132
- totals.messageCount += costData.messageCount;
3133
- totals.sessionsWithData++;
3167
+ totals.tokens.input += costData.tokens.input;
3168
+ totals.tokens.output += costData.tokens.output;
3169
+ totals.tokens.cacheWrite += costData.tokens.cacheWrite;
3170
+ totals.tokens.cacheRead += costData.tokens.cacheRead;
3171
+ totals.tokens.total += costData.tokens.total;
3134
3172
 
3135
- if (costData.firstMessage && (!totals.firstMessage || costData.firstMessage < totals.firstMessage)) {
3136
- totals.firstMessage = costData.firstMessage;
3137
- }
3138
- if (costData.lastMessage && (!totals.lastMessage || costData.lastMessage > totals.lastMessage)) {
3139
- totals.lastMessage = costData.lastMessage;
3140
- }
3173
+ totals.cost.input += costData.cost.input;
3174
+ totals.cost.output += costData.cost.output;
3175
+ totals.cost.cacheWrite += costData.cost.cacheWrite;
3176
+ totals.cost.cacheRead += costData.cost.cacheRead;
3177
+ totals.cost.total += costData.cost.total;
3178
+
3179
+ totals.messageCount += costData.messageCount;
3180
+ totals.sessionsWithData++;
3141
3181
 
3142
- // Merge model breakdowns
3182
+ if (costData.firstMessage && (!totals.firstMessage || costData.firstMessage < totals.firstMessage)) {
3183
+ totals.firstMessage = costData.firstMessage;
3184
+ }
3185
+ if (costData.lastMessage && (!totals.lastMessage || costData.lastMessage > totals.lastMessage)) {
3186
+ totals.lastMessage = costData.lastMessage;
3187
+ }
3188
+
3189
+ // Merge model breakdowns
3190
+ if (costData.modelBreakdown) {
3143
3191
  for (const [model, breakdown] of Object.entries(costData.modelBreakdown)) {
3144
3192
  if (!totals.modelBreakdown[model]) {
3145
3193
  totals.modelBreakdown[model] = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0 };
@@ -3150,8 +3198,6 @@ app.get('/api/workspaces/:id/cost', requireAuth, (req, res) => {
3150
3198
  totals.modelBreakdown[model].cacheRead += breakdown.cacheRead;
3151
3199
  totals.modelBreakdown[model].cost += breakdown.cost;
3152
3200
  }
3153
- } catch (_) {
3154
- // Skip sessions whose JSONL files can't be read
3155
3201
  }
3156
3202
  }
3157
3203
 
@@ -3274,7 +3320,7 @@ app.get('/api/workspaces/:id/analytics', requireAuth, (req, res) => {
3274
3320
  * breakdowns, and per-session costs. Used by the Costs tab.
3275
3321
  * @param {string} [period=week] - One of: day, week, month, all
3276
3322
  */
3277
- app.get('/api/cost/dashboard', requireAuth, (req, res) => {
3323
+ app.get('/api/cost/dashboard', requireAuth, async (req, res) => {
3278
3324
  try {
3279
3325
  const period = req.query.period || 'week';
3280
3326
  const store = getStore();
@@ -3291,138 +3337,142 @@ app.get('/api/cost/dashboard', requireAuth, (req, res) => {
3291
3337
  const cutoffMs = periodMs[period] || periodMs.week;
3292
3338
  const cutoffDate = cutoffMs === Infinity ? null : new Date(now - cutoffMs).toISOString();
3293
3339
 
3294
- // Collect cost data from all sessions across all workspaces
3295
- const allSessionCosts = []; // Per-session cost records
3296
- const dailyCosts = {}; // date string -> { cost, tokens }
3297
- const modelAgg = {}; // model -> { cost, tokens }
3298
- const workspaceAgg = {}; // workspaceId -> { name, cost, sessionCount }
3299
- let totalCost = 0;
3300
- let totalTokens = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
3301
- let totalMessages = 0;
3302
- let periodCost = 0;
3340
+ // Phase 1: resolve cost data for all sessions (async for cache misses)
3341
+ const sessionEntries = []; // { session, workspace, costData }
3342
+ const pending = [];
3303
3343
 
3304
3344
  for (const workspace of allWorkspaces) {
3305
3345
  const sessions = store.getWorkspaceSessions(workspace.id);
3306
- if (!workspaceAgg[workspace.id]) {
3307
- workspaceAgg[workspace.id] = { id: workspace.id, name: workspace.name, cost: 0, sessionCount: 0 };
3308
- }
3309
-
3310
3346
  for (const session of sessions) {
3311
3347
  const resumeSessionId = session.resumeSessionId;
3312
3348
  if (!resumeSessionId) continue;
3313
-
3314
3349
  const jsonlPath = findJsonlFile(resumeSessionId);
3315
3350
  if (!jsonlPath) continue;
3316
3351
 
3317
3352
  try {
3318
3353
  const stat = fs.statSync(jsonlPath);
3319
- if (stat.size >= 500 * 1024 * 1024) continue; // Skip >500MB files
3320
-
3321
- // Check cache
3354
+ if (stat.size >= 500 * 1024 * 1024) continue;
3322
3355
  const mtimeMs = stat.mtimeMs;
3323
3356
  const cached = _costCache.get(resumeSessionId);
3324
3357
  const cacheNow = Date.now();
3325
- let costData;
3326
3358
 
3327
3359
  if (cached && cached.mtimeMs === mtimeMs && (cacheNow - cached.timestamp) < COST_CACHE_TTL) {
3328
- costData = cached.result;
3360
+ sessionEntries.push({ session, workspace, costData: cached.result });
3329
3361
  } else {
3330
- costData = calculateSessionCost(jsonlPath);
3331
- const result = { sessionId: session.id, resumeSessionId, ...costData };
3332
- _costCache.set(resumeSessionId, { mtimeMs, timestamp: cacheNow, result });
3362
+ // Queue async calculation and push a placeholder
3363
+ const idx = sessionEntries.length;
3364
+ sessionEntries.push({ session, workspace, costData: null });
3365
+ pending.push(
3366
+ calculateSessionCostAsync(jsonlPath).catch(() => {
3367
+ // Fallback to sync if worker fails
3368
+ return calculateSessionCost(jsonlPath);
3369
+ }).then(costData => {
3370
+ const result = { sessionId: session.id, resumeSessionId, ...costData };
3371
+ _costCache.set(resumeSessionId, { mtimeMs, timestamp: cacheNow, result });
3372
+ sessionEntries[idx].costData = result;
3373
+ }).catch(() => {})
3374
+ );
3333
3375
  }
3376
+ } catch (_) {}
3377
+ }
3378
+ }
3334
3379
 
3335
- const sessionCost = costData.cost ? costData.cost.total : 0;
3336
- totalCost += sessionCost;
3337
- totalMessages += costData.messageCount || 0;
3380
+ // Wait for all async calculations off the main thread
3381
+ if (pending.length > 0) await Promise.all(pending);
3338
3382
 
3339
- if (costData.tokens) {
3340
- totalTokens.input += costData.tokens.input || 0;
3341
- totalTokens.output += costData.tokens.output || 0;
3342
- totalTokens.cacheWrite += costData.tokens.cacheWrite || 0;
3343
- totalTokens.cacheRead += costData.tokens.cacheRead || 0;
3344
- }
3383
+ // Phase 2: aggregate all cost data (pure arithmetic, no I/O)
3384
+ const allSessionCosts = [];
3385
+ const dailyCosts = {};
3386
+ const modelAgg = {};
3387
+ const workspaceAgg = {};
3388
+ let totalCost = 0;
3389
+ let totalTokens = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
3390
+ let totalMessages = 0;
3391
+ let periodCost = 0;
3345
3392
 
3346
- // Workspace aggregation
3347
- workspaceAgg[workspace.id].cost += sessionCost;
3348
- workspaceAgg[workspace.id].sessionCount++;
3393
+ for (const { session, workspace, costData } of sessionEntries) {
3394
+ if (!costData) continue;
3349
3395
 
3350
- // Model aggregation
3351
- if (costData.modelBreakdown) {
3352
- for (const [model, breakdown] of Object.entries(costData.modelBreakdown)) {
3353
- if (!modelAgg[model]) {
3354
- modelAgg[model] = { model, cost: 0, tokens: 0 };
3355
- }
3356
- modelAgg[model].cost += breakdown.cost || 0;
3357
- modelAgg[model].tokens += (breakdown.input || 0) + (breakdown.output || 0) +
3358
- (breakdown.cacheWrite || 0) + (breakdown.cacheRead || 0);
3359
- }
3360
- }
3396
+ if (!workspaceAgg[workspace.id]) {
3397
+ workspaceAgg[workspace.id] = { id: workspace.id, name: workspace.name, cost: 0, sessionCount: 0 };
3398
+ }
3361
3399
 
3362
- // Daily timeline from contextSamples timestamps
3363
- const samples = costData.quota ? costData.quota.contextGrowth : [];
3364
- if (samples && samples.length > 0) {
3365
- // Group messages by day, calculate cost per message
3366
- const perMsgCost = costData.messageCount > 0
3367
- ? sessionCost / costData.messageCount : 0;
3368
-
3369
- for (const sample of samples) {
3370
- if (!sample.ts) continue;
3371
- const dayKey = sample.ts.substring(0, 10); // YYYY-MM-DD
3372
- if (!dailyCosts[dayKey]) {
3373
- dailyCosts[dayKey] = { date: dayKey, cost: 0, tokens: 0, messages: 0 };
3374
- }
3375
- dailyCosts[dayKey].cost += perMsgCost;
3376
- dailyCosts[dayKey].tokens += sample.tokens || 0;
3377
- dailyCosts[dayKey].messages++;
3378
- }
3379
- }
3400
+ const sessionCost = costData.cost ? costData.cost.total : 0;
3401
+ totalCost += sessionCost;
3402
+ totalMessages += costData.messageCount || 0;
3380
3403
 
3381
- // Apportion session cost to the period using per-message cost and
3382
- // message timestamps, not the full session cost. This ensures "Last 24h"
3383
- // only counts cost from messages sent in the last 24 hours, not the
3384
- // entire lifetime of any session that was recently active.
3385
- if (!cutoffDate) {
3386
- periodCost += sessionCost;
3387
- } else if (samples && samples.length > 0 && costData.messageCount > 0) {
3388
- const perMsgCost = sessionCost / costData.messageCount;
3389
- let periodMessages = 0;
3390
- for (const sample of samples) {
3391
- if (sample.ts && sample.ts >= cutoffDate) periodMessages++;
3392
- }
3393
- periodCost += perMsgCost * periodMessages;
3394
- } else if (costData.lastMessage && costData.lastMessage >= cutoffDate) {
3395
- // Fallback: no timestamp samples available, use full cost
3396
- periodCost += sessionCost;
3404
+ if (costData.tokens) {
3405
+ totalTokens.input += costData.tokens.input || 0;
3406
+ totalTokens.output += costData.tokens.output || 0;
3407
+ totalTokens.cacheWrite += costData.tokens.cacheWrite || 0;
3408
+ totalTokens.cacheRead += costData.tokens.cacheRead || 0;
3409
+ }
3410
+
3411
+ workspaceAgg[workspace.id].cost += sessionCost;
3412
+ workspaceAgg[workspace.id].sessionCount++;
3413
+
3414
+ if (costData.modelBreakdown) {
3415
+ for (const [model, breakdown] of Object.entries(costData.modelBreakdown)) {
3416
+ if (!modelAgg[model]) {
3417
+ modelAgg[model] = { model, cost: 0, tokens: 0 };
3397
3418
  }
3419
+ modelAgg[model].cost += breakdown.cost || 0;
3420
+ modelAgg[model].tokens += (breakdown.input || 0) + (breakdown.output || 0) +
3421
+ (breakdown.cacheWrite || 0) + (breakdown.cacheRead || 0);
3422
+ }
3423
+ }
3398
3424
 
3399
- // Determine primary model for the session
3400
- let primaryModel = 'unknown';
3401
- let maxModelCost = 0;
3402
- if (costData.modelBreakdown) {
3403
- for (const [model, breakdown] of Object.entries(costData.modelBreakdown)) {
3404
- if (breakdown.cost > maxModelCost) {
3405
- maxModelCost = breakdown.cost;
3406
- primaryModel = model;
3407
- }
3408
- }
3425
+ const samples = costData.quota ? costData.quota.contextGrowth : [];
3426
+ if (samples && samples.length > 0) {
3427
+ const perMsgCost = costData.messageCount > 0
3428
+ ? sessionCost / costData.messageCount : 0;
3429
+ for (const sample of samples) {
3430
+ if (!sample.ts) continue;
3431
+ const dayKey = sample.ts.substring(0, 10);
3432
+ if (!dailyCosts[dayKey]) {
3433
+ dailyCosts[dayKey] = { date: dayKey, cost: 0, tokens: 0, messages: 0 };
3409
3434
  }
3435
+ dailyCosts[dayKey].cost += perMsgCost;
3436
+ dailyCosts[dayKey].tokens += sample.tokens || 0;
3437
+ dailyCosts[dayKey].messages++;
3438
+ }
3439
+ }
3410
3440
 
3411
- allSessionCosts.push({
3412
- id: session.id,
3413
- name: session.name || session.id.substring(0, 12),
3414
- workspaceId: workspace.id,
3415
- workspaceName: workspace.name,
3416
- cost: Math.round(sessionCost * 1000) / 1000,
3417
- messageCount: costData.messageCount || 0,
3418
- model: primaryModel,
3419
- lastActive: costData.lastMessage || session.lastActive || null,
3420
- firstMessage: costData.firstMessage || null,
3421
- });
3422
- } catch (_) {
3423
- // Skip sessions with unreadable JSONL
3441
+ if (!cutoffDate) {
3442
+ periodCost += sessionCost;
3443
+ } else if (samples && samples.length > 0 && costData.messageCount > 0) {
3444
+ const perMsgCost = sessionCost / costData.messageCount;
3445
+ let periodMessages = 0;
3446
+ for (const sample of samples) {
3447
+ if (sample.ts && sample.ts >= cutoffDate) periodMessages++;
3448
+ }
3449
+ periodCost += perMsgCost * periodMessages;
3450
+ } else if (costData.lastMessage && costData.lastMessage >= cutoffDate) {
3451
+ periodCost += sessionCost;
3452
+ }
3453
+
3454
+ let primaryModel = 'unknown';
3455
+ let maxModelCost = 0;
3456
+ if (costData.modelBreakdown) {
3457
+ for (const [model, breakdown] of Object.entries(costData.modelBreakdown)) {
3458
+ if (breakdown.cost > maxModelCost) {
3459
+ maxModelCost = breakdown.cost;
3460
+ primaryModel = model;
3461
+ }
3424
3462
  }
3425
3463
  }
3464
+
3465
+ allSessionCosts.push({
3466
+ id: session.id,
3467
+ name: session.name || session.id.substring(0, 12),
3468
+ workspaceId: workspace.id,
3469
+ workspaceName: workspace.name,
3470
+ cost: Math.round(sessionCost * 1000) / 1000,
3471
+ messageCount: costData.messageCount || 0,
3472
+ model: primaryModel,
3473
+ lastActive: costData.lastMessage || session.lastActive || null,
3474
+ firstMessage: costData.firstMessage || null,
3475
+ });
3426
3476
  }
3427
3477
 
3428
3478
  // Build timeline sorted by date, filtered to period
@@ -3590,7 +3640,7 @@ function extractFilePaths(text) {
3590
3640
  * and token usage - ready to paste into a new session.
3591
3641
  * Protected by auth.
3592
3642
  */
3593
- app.get('/api/sessions/:id/export-context', requireAuth, (req, res) => {
3643
+ app.get('/api/sessions/:id/export-context', requireAuth, async (req, res) => {
3594
3644
  const store = getStore();
3595
3645
  const session = store.getSession(req.params.id);
3596
3646
 
@@ -3666,12 +3716,10 @@ app.get('/api/sessions/:id/export-context', requireAuth, (req, res) => {
3666
3716
  }
3667
3717
  }
3668
3718
 
3669
- // ── Count total messages by reading full file line-by-line ──
3670
- // Use the cost calculation helper which already reads the full file
3671
- // and gives us token usage, cost, and message count
3719
+ // ── Count total messages via cost calculation (off main thread) ──
3672
3720
  let costData;
3673
3721
  try {
3674
- costData = calculateSessionCost(jsonlPath);
3722
+ costData = await calculateSessionCostAsync(jsonlPath).catch(() => calculateSessionCost(jsonlPath));
3675
3723
  } catch (_) {
3676
3724
  costData = {
3677
3725
  tokens: { input: 0, output: 0, total: 0 },