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 +1 -1
- package/src/web/public/terminal.js +23 -3
- package/src/web/server.js +233 -185
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
404
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
2960
|
-
const
|
|
2961
|
-
|
|
2962
|
-
|
|
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
|
|
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;
|
|
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
|
|
3031
|
+
entries.push({ session, workspace, costData: cached.result, stat });
|
|
3010
3032
|
} else {
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3146
|
+
costResults.push(cached.result);
|
|
3114
3147
|
} else {
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3127
|
-
|
|
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
|
-
|
|
3133
|
-
|
|
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
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
3295
|
-
const
|
|
3296
|
-
const
|
|
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;
|
|
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
|
|
3360
|
+
sessionEntries.push({ session, workspace, costData: cached.result });
|
|
3329
3361
|
} else {
|
|
3330
|
-
|
|
3331
|
-
const
|
|
3332
|
-
|
|
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
|
-
|
|
3336
|
-
|
|
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
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
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
|
-
|
|
3347
|
-
|
|
3348
|
-
workspaceAgg[workspace.id].sessionCount++;
|
|
3393
|
+
for (const { session, workspace, costData } of sessionEntries) {
|
|
3394
|
+
if (!costData) continue;
|
|
3349
3395
|
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
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
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
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
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
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
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
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
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
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
|
|
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 },
|