smart-context-mcp 1.0.3 → 1.0.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.
@@ -0,0 +1,424 @@
1
+ import { persistMetrics } from '../metrics.js';
2
+ import { countTokens } from '../tokenCounter.js';
3
+ import { smartSummary } from '../tools/smart-summary.js';
4
+ import { smartTurn } from '../tools/smart-turn.js';
5
+ import {
6
+ deleteHookTurnState,
7
+ getHookTurnState,
8
+ setHookTurnState,
9
+ } from '../storage/sqlite.js';
10
+
11
+ const HOOK_CLIENT = 'claude';
12
+ const START_MAX_TOKENS = 350;
13
+ const STOP_MAX_TOKENS = 300;
14
+ const MAX_CONTEXT_LINES = 5;
15
+ const MAX_CONTEXT_CHARS = 420;
16
+ const MAX_PROMPT_PREVIEW = 160;
17
+ const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
18
+ const SIGNIFICANT_RESPONSE_LENGTH = 140;
19
+
20
+ const normalizeWhitespace = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
21
+
22
+ const truncate = (value, maxLength = MAX_PROMPT_PREVIEW) => {
23
+ const normalized = normalizeWhitespace(value);
24
+ if (normalized.length <= maxLength) {
25
+ return normalized;
26
+ }
27
+
28
+ if (maxLength <= 3) {
29
+ return '';
30
+ }
31
+
32
+ return `${normalized.slice(0, maxLength - 3)}...`;
33
+ };
34
+
35
+ const countPromptTerms = (value) =>
36
+ normalizeWhitespace(value)
37
+ .split(/[^a-z0-9_.-]+/i)
38
+ .map((term) => term.trim())
39
+ .filter((term) => term.length >= 3)
40
+ .length;
41
+
42
+ const isMeaningfulPrompt = (value) => {
43
+ const normalized = normalizeWhitespace(value);
44
+ return normalized.length >= 20 && countPromptTerms(normalized) >= 4;
45
+ };
46
+
47
+ const uniq = (values) => [...new Set(values.filter((value) => typeof value === 'string' && value.trim().length > 0))];
48
+
49
+ const buildHookKey = ({ sessionId, agentId = null }) =>
50
+ agentId ? `${HOOK_CLIENT}:subagent:${sessionId}:${agentId}` : `${HOOK_CLIENT}:main:${sessionId}`;
51
+
52
+ const buildAdditionalContext = ({ result, sessionStart = false }) => {
53
+ const lines = [];
54
+ const repoSafety = result?.repoSafety;
55
+ const summary = result?.summary;
56
+ const continuityState = result?.continuity?.state;
57
+
58
+ if (result?.found && summary) {
59
+ const label = sessionStart ? 'resume' : continuityState ?? 'resume';
60
+ lines.push(`devctx ${label}: session ${result.sessionId}`);
61
+
62
+ if (summary.goal) {
63
+ lines.push(`goal: ${truncate(summary.goal, 110)}`);
64
+ }
65
+
66
+ if (summary.currentFocus) {
67
+ lines.push(`focus: ${truncate(summary.currentFocus, 110)}`);
68
+ }
69
+
70
+ if (summary.nextStep) {
71
+ lines.push(`next: ${truncate(summary.nextStep, 110)}`);
72
+ }
73
+ } else if (result?.continuity?.state === 'ambiguous_resume') {
74
+ lines.push('devctx: multiple persisted sessions matched this prompt.');
75
+ if (result?.recommendedSessionId) {
76
+ lines.push(`recommended session: ${result.recommendedSessionId}`);
77
+ }
78
+ } else if (result?.autoCreated && summary?.goal) {
79
+ lines.push(`devctx new task session: ${truncate(summary.goal, 110)}`);
80
+ }
81
+
82
+ if (repoSafety?.isTracked || repoSafety?.isStaged) {
83
+ const reasons = [];
84
+ if (repoSafety.isTracked) {
85
+ reasons.push('tracked');
86
+ }
87
+ if (repoSafety.isStaged) {
88
+ reasons.push('staged');
89
+ }
90
+ lines.push(`repo safety: .devctx/state.sqlite is ${reasons.join(' and ')}; context writes are blocked.`);
91
+ }
92
+
93
+ const clipped = lines.slice(0, MAX_CONTEXT_LINES).join('\n').slice(0, MAX_CONTEXT_CHARS).trim();
94
+ return clipped || null;
95
+ };
96
+
97
+ const buildHookContextResponse = (hookEventName, additionalContext) => {
98
+ if (!additionalContext) {
99
+ return null;
100
+ }
101
+
102
+ return {
103
+ hookSpecificOutput: {
104
+ hookEventName,
105
+ additionalContext,
106
+ },
107
+ };
108
+ };
109
+
110
+ const recordHookMetrics = async ({
111
+ action,
112
+ sessionId,
113
+ additionalContext = '',
114
+ blocked = false,
115
+ autoAppended = false,
116
+ continuityState = null,
117
+ } = {}) => {
118
+ const overheadTokens = additionalContext ? countTokens(additionalContext) : 0;
119
+
120
+ await persistMetrics({
121
+ tool: 'claude_hook',
122
+ action,
123
+ sessionId,
124
+ rawTokens: 0,
125
+ compressedTokens: 0,
126
+ savedTokens: 0,
127
+ savingsPct: 0,
128
+ metadata: {
129
+ isContextOverhead: overheadTokens > 0,
130
+ overheadTokens,
131
+ blocked,
132
+ autoAppended,
133
+ continuityState,
134
+ },
135
+ timestamp: new Date().toISOString(),
136
+ });
137
+ };
138
+
139
+ const isSmartTurnTool = (toolName) => /^mcp__.+__smart_turn$/i.test(toolName ?? '');
140
+ const isSmartSummaryTool = (toolName) => /^mcp__.+__smart_summary$/i.test(toolName ?? '');
141
+
142
+ const isCheckpointToolUse = ({ toolName, toolInput }) => {
143
+ if (isSmartTurnTool(toolName)) {
144
+ return toolInput?.phase === 'end'
145
+ ? { matched: true, event: toolInput?.event ?? 'manual' }
146
+ : { matched: false, event: null };
147
+ }
148
+
149
+ if (isSmartSummaryTool(toolName)) {
150
+ const action = toolInput?.action;
151
+ if (action === 'checkpoint') {
152
+ return { matched: true, event: toolInput?.event ?? 'manual' };
153
+ }
154
+
155
+ if (action === 'append' || action === 'auto_append' || action === 'update') {
156
+ return { matched: true, event: action };
157
+ }
158
+ }
159
+
160
+ return { matched: false, event: null };
161
+ };
162
+
163
+ const extractTouchedFiles = ({ toolName, toolInput, toolResponse }) => {
164
+ if (!WRITE_TOOLS.has(toolName)) {
165
+ return [];
166
+ }
167
+
168
+ return uniq([
169
+ toolInput?.file_path,
170
+ toolInput?.filePath,
171
+ toolResponse?.file_path,
172
+ toolResponse?.filePath,
173
+ ]);
174
+ };
175
+
176
+ const extractNextStep = (message) => {
177
+ const normalized = normalizeWhitespace(message);
178
+ if (!normalized) {
179
+ return '';
180
+ }
181
+
182
+ const explicitMatch = normalized.match(/(?:next step|siguiente paso)\s*[:\-]\s*([^.;\n]{12,180})/i);
183
+ if (explicitMatch?.[1]) {
184
+ return truncate(explicitMatch[1], 150);
185
+ }
186
+
187
+ return '';
188
+ };
189
+
190
+ const buildCarryoverUpdate = (state, lastAssistantMessage) => {
191
+ const promptPreview = truncate(state.promptPreview, 140);
192
+ const nextStep = extractNextStep(lastAssistantMessage);
193
+ const pinnedContext = promptPreview ? [`Uncheckpointed turn: ${promptPreview}`] : [];
194
+
195
+ return {
196
+ ...(promptPreview ? { currentFocus: promptPreview } : {}),
197
+ ...(pinnedContext.length > 0 ? { pinnedContext } : {}),
198
+ ...(state.touchedFiles.length > 0 ? { touchedFiles: state.touchedFiles } : {}),
199
+ ...(nextStep ? { nextStep } : {}),
200
+ };
201
+ };
202
+
203
+ const computeStopEnforcement = (state, lastAssistantMessage) => {
204
+ const nextStep = extractNextStep(lastAssistantMessage);
205
+ const responseLength = normalizeWhitespace(lastAssistantMessage).length;
206
+ let score = 0;
207
+
208
+ if (state.meaningfulWriteCount > 0) {
209
+ score += 3;
210
+ }
211
+
212
+ if (state.touchedFiles.length > 0) {
213
+ score += 1;
214
+ }
215
+
216
+ if (nextStep) {
217
+ score += 2;
218
+ }
219
+
220
+ if (responseLength >= SIGNIFICANT_RESPONSE_LENGTH) {
221
+ score += 1;
222
+ }
223
+
224
+ if (state.continuityState === 'task_switch' || state.continuityState === 'possible_shift') {
225
+ score += 1;
226
+ }
227
+
228
+ return {
229
+ shouldBlock: score >= 3,
230
+ score,
231
+ nextStep,
232
+ };
233
+ };
234
+
235
+ const maybeTrackTurn = async ({
236
+ hookKey,
237
+ claudeSessionId,
238
+ projectSessionId,
239
+ prompt,
240
+ continuityState,
241
+ }) => {
242
+ const promptMeaningful = isMeaningfulPrompt(prompt);
243
+ const shouldTrack = Boolean(projectSessionId) && promptMeaningful;
244
+
245
+ if (!shouldTrack) {
246
+ await deleteHookTurnState({ hookKey });
247
+ return null;
248
+ }
249
+
250
+ return setHookTurnState({
251
+ hookKey,
252
+ state: {
253
+ client: HOOK_CLIENT,
254
+ claudeSessionId,
255
+ projectSessionId,
256
+ turnId: `${claudeSessionId}:${Date.now()}`,
257
+ promptPreview: truncate(prompt),
258
+ continuityState,
259
+ requireCheckpoint: true,
260
+ promptMeaningful,
261
+ checkpointed: false,
262
+ checkpointEvent: null,
263
+ touchedFiles: [],
264
+ meaningfulWriteCount: 0,
265
+ },
266
+ });
267
+ };
268
+
269
+ const handleSessionStart = async (input) => {
270
+ const result = await smartTurn({
271
+ phase: 'start',
272
+ maxTokens: START_MAX_TOKENS,
273
+ });
274
+ const additionalContext = buildAdditionalContext({ result, sessionStart: true });
275
+ await recordHookMetrics({
276
+ action: 'SessionStart',
277
+ sessionId: result.sessionId ?? null,
278
+ additionalContext,
279
+ continuityState: result.continuity?.state ?? null,
280
+ });
281
+ return buildHookContextResponse('SessionStart', additionalContext);
282
+ };
283
+
284
+ const handleUserPromptSubmit = async (input) => {
285
+ const result = await smartTurn({
286
+ phase: 'start',
287
+ prompt: input.prompt,
288
+ ensureSession: true,
289
+ maxTokens: START_MAX_TOKENS,
290
+ });
291
+
292
+ const trackedState = await maybeTrackTurn({
293
+ hookKey: buildHookKey({ sessionId: input.session_id }),
294
+ claudeSessionId: input.session_id,
295
+ projectSessionId: result.sessionId ?? null,
296
+ prompt: input.prompt,
297
+ continuityState: result.continuity?.state ?? '',
298
+ });
299
+ const additionalContext = buildAdditionalContext({ result });
300
+ await recordHookMetrics({
301
+ action: 'UserPromptSubmit',
302
+ sessionId: trackedState?.projectSessionId ?? result.sessionId ?? null,
303
+ additionalContext,
304
+ continuityState: result.continuity?.state ?? null,
305
+ });
306
+ return buildHookContextResponse('UserPromptSubmit', additionalContext);
307
+ };
308
+
309
+ const handlePostToolUse = async (input) => {
310
+ const hookKey = buildHookKey({ sessionId: input.session_id });
311
+ const existing = await getHookTurnState({ hookKey });
312
+ if (!existing) {
313
+ return null;
314
+ }
315
+
316
+ const checkpoint = isCheckpointToolUse({
317
+ toolName: input.tool_name,
318
+ toolInput: input.tool_input,
319
+ });
320
+ const touchedFiles = extractTouchedFiles({
321
+ toolName: input.tool_name,
322
+ toolInput: input.tool_input,
323
+ toolResponse: input.tool_response,
324
+ });
325
+
326
+ const nextState = {
327
+ ...existing,
328
+ checkpointed: checkpoint.matched ? true : existing.checkpointed,
329
+ checkpointEvent: checkpoint.matched ? checkpoint.event : existing.checkpointEvent,
330
+ touchedFiles: uniq([...existing.touchedFiles, ...touchedFiles]),
331
+ meaningfulWriteCount: existing.meaningfulWriteCount + touchedFiles.length,
332
+ updatedAt: new Date().toISOString(),
333
+ };
334
+
335
+ await setHookTurnState({ hookKey, state: nextState });
336
+ if (checkpoint.matched || touchedFiles.length > 0) {
337
+ await recordHookMetrics({
338
+ action: 'PostToolUse',
339
+ sessionId: existing.projectSessionId,
340
+ additionalContext: '',
341
+ continuityState: existing.continuityState,
342
+ });
343
+ }
344
+ return null;
345
+ };
346
+
347
+ const handleStop = async (input) => {
348
+ const hookKey = buildHookKey({ sessionId: input.session_id });
349
+ const state = await getHookTurnState({ hookKey });
350
+ if (!state) {
351
+ return null;
352
+ }
353
+
354
+ const enforcement = computeStopEnforcement(state, input.last_assistant_message);
355
+ const shouldEnforce = (state.requireCheckpoint || state.meaningfulWriteCount > 0) && enforcement.shouldBlock;
356
+ if (!shouldEnforce || state.checkpointed) {
357
+ await recordHookMetrics({
358
+ action: 'Stop',
359
+ sessionId: state.projectSessionId,
360
+ additionalContext: '',
361
+ blocked: false,
362
+ continuityState: state.continuityState,
363
+ });
364
+ await deleteHookTurnState({ hookKey });
365
+ return null;
366
+ }
367
+
368
+ if (input.stop_hook_active) {
369
+ const update = buildCarryoverUpdate(state, input.last_assistant_message);
370
+ if (state.projectSessionId && Object.keys(update).length > 0) {
371
+ await smartSummary({
372
+ action: 'auto_append',
373
+ sessionId: state.projectSessionId,
374
+ update,
375
+ maxTokens: STOP_MAX_TOKENS,
376
+ });
377
+ }
378
+
379
+ await recordHookMetrics({
380
+ action: 'Stop',
381
+ sessionId: state.projectSessionId,
382
+ additionalContext: '',
383
+ blocked: false,
384
+ autoAppended: true,
385
+ continuityState: state.continuityState,
386
+ });
387
+ await deleteHookTurnState({ hookKey });
388
+ return null;
389
+ }
390
+
391
+ await recordHookMetrics({
392
+ action: 'Stop',
393
+ sessionId: state.projectSessionId,
394
+ additionalContext: '',
395
+ blocked: true,
396
+ continuityState: state.continuityState,
397
+ });
398
+ return {
399
+ decision: 'block',
400
+ reason: `Persist this turn with mcp__devctx__smart_turn phase=end before stopping.${state.touchedFiles.length > 0 ? ' Include touchedFiles and the nextStep.' : ' Include the nextStep.'}`,
401
+ };
402
+ };
403
+
404
+ export const handleClaudeHookEvent = async (input = {}) => {
405
+ const eventName = input.hook_event_name;
406
+
407
+ if (eventName === 'SessionStart') {
408
+ return handleSessionStart(input);
409
+ }
410
+
411
+ if (eventName === 'UserPromptSubmit') {
412
+ return handleUserPromptSubmit(input);
413
+ }
414
+
415
+ if (eventName === 'PostToolUse') {
416
+ return handlePostToolUse(input);
417
+ }
418
+
419
+ if (eventName === 'Stop') {
420
+ return handleStop(input);
421
+ }
422
+
423
+ return null;
424
+ };
package/src/metrics.js CHANGED
@@ -1,14 +1,27 @@
1
1
  import fs from 'node:fs/promises';
2
+ import fsSync from 'node:fs';
2
3
  import path from 'node:path';
4
+ import { enforceRepoSafety } from './repo-safety.js';
3
5
  import { countTokens } from './tokenCounter.js';
4
- import { devctxRoot, projectRoot } from './utils/paths.js';
6
+ import { projectRoot } from './utils/paths.js';
7
+ import {
8
+ ACTIVE_SESSION_SCOPE,
9
+ getStateDbPath,
10
+ importLegacyState,
11
+ insertMetricEvent,
12
+ withStateDb,
13
+ withStateDbSnapshot,
14
+ } from './storage/sqlite.js';
5
15
 
6
16
  const defaultMetricsDir = () => path.join(projectRoot, '.devctx');
7
17
  const defaultMetricsFile = () => path.join(defaultMetricsDir(), 'metrics.jsonl');
8
- const legacyMetricsFile = path.join(devctxRoot, '.devctx', 'metrics.jsonl');
18
+ const resolveEnvMetricsFile = () => process.env.DEVCTX_METRICS_FILE?.trim() || null;
19
+ const HARD_BLOCK_REPO_SAFETY_REASONS = [
20
+ ['tracked', 'isTracked'],
21
+ ['staged', 'isStaged'],
22
+ ];
9
23
 
10
- export const getMetricsFilePath = () => process.env.DEVCTX_METRICS_FILE ?? defaultMetricsFile();
11
- export const getLegacyMetricsFilePath = () => legacyMetricsFile;
24
+ export const getMetricsFilePath = () => resolveEnvMetricsFile() ?? defaultMetricsFile();
12
25
 
13
26
  let lastEnsuredDir = null;
14
27
 
@@ -36,6 +49,25 @@ export const buildMetrics = ({ tool, target, rawText, compressedText }) => {
36
49
  };
37
50
  };
38
51
 
52
+ export const getCompressedTokens = (entry) => Number(entry.compressedTokens ?? entry.finalTokens ?? 0);
53
+
54
+ export const getSavedTokens = (entry, compressedTokens = getCompressedTokens(entry)) => {
55
+ if (entry.savedTokens !== undefined) {
56
+ return Number(entry.savedTokens ?? 0);
57
+ }
58
+
59
+ return Math.max(0, Number(entry.rawTokens ?? 0) - compressedTokens);
60
+ };
61
+
62
+ export const getEntrySavingsPct = (
63
+ entry,
64
+ compressedTokens = getCompressedTokens(entry),
65
+ savedTokens = getSavedTokens(entry, compressedTokens),
66
+ ) => {
67
+ const rawTokens = Number(entry.rawTokens ?? 0);
68
+ return rawTokens > 0 ? Number(((savedTokens / rawTokens) * 100).toFixed(2)) : 0;
69
+ };
70
+
39
71
  export const MAX_METRICS_BYTES = 1024 * 1024;
40
72
  export const KEEP_LINES_AFTER_ROTATION = 500;
41
73
 
@@ -53,13 +85,191 @@ const rotateIfNeeded = async (filePath) => {
53
85
  }
54
86
  };
55
87
 
88
+ const readActiveSessionIdFromDb = (db) =>
89
+ db.prepare('SELECT session_id FROM active_session WHERE scope = ?').get(ACTIVE_SESSION_SCOPE)?.session_id ?? null;
90
+
91
+ const getSqliteSafetyPolicy = () => {
92
+ const repoSafety = enforceRepoSafety();
93
+ const reasons = HARD_BLOCK_REPO_SAFETY_REASONS
94
+ .filter(([, field]) => repoSafety[field])
95
+ .map(([reason]) => reason);
96
+
97
+ return {
98
+ repoSafety,
99
+ shouldBlock: reasons.length > 0,
100
+ reasons,
101
+ };
102
+ };
103
+
104
+ export const getActiveSessionId = async () => {
105
+ const safety = getSqliteSafetyPolicy();
106
+ const stateDbPath = getStateDbPath();
107
+ if (safety.shouldBlock) {
108
+ if (!fsSync.existsSync(stateDbPath)) {
109
+ return null;
110
+ }
111
+
112
+ return withStateDbSnapshot((db) => readActiveSessionIdFromDb(db), { filePath: stateDbPath });
113
+ }
114
+
115
+ await importLegacyState();
116
+ return withStateDb((db) => readActiveSessionIdFromDb(db));
117
+ };
118
+
119
+ const appendLegacyMetricsFile = async (entry) => {
120
+ const envFile = resolveEnvMetricsFile();
121
+ if (!envFile) {
122
+ return;
123
+ }
124
+
125
+ const filePath = path.resolve(envFile);
126
+ await ensureMetricsDir(filePath);
127
+ await fs.appendFile(filePath, `${JSON.stringify(entry)}\n`, 'utf8');
128
+ await rotateIfNeeded(filePath);
129
+ };
130
+
56
131
  export const persistMetrics = async (entry) => {
57
132
  try {
58
- const filePath = getMetricsFilePath();
59
- await ensureMetricsDir(filePath);
60
- await fs.appendFile(filePath, `${JSON.stringify(entry)}\n`, 'utf8');
61
- await rotateIfNeeded(filePath);
133
+ const resolvedInput = resolveMetricsInput();
134
+ let enrichedEntry = entry;
135
+ const safety = getSqliteSafetyPolicy();
136
+
137
+ if (!safety.shouldBlock) {
138
+ await importLegacyState();
139
+
140
+ await withStateDb((db) => {
141
+ const sessionId = entry.sessionId ?? readActiveSessionIdFromDb(db);
142
+ enrichedEntry = sessionId ? { ...entry, sessionId } : entry;
143
+
144
+ if (resolvedInput.kind === 'sqlite') {
145
+ insertMetricEvent(db, enrichedEntry);
146
+ }
147
+ });
148
+ }
149
+
150
+ await appendLegacyMetricsFile(enrichedEntry);
62
151
  } catch {
63
152
  // best-effort — never fail a tool call for metrics
64
153
  }
65
154
  };
155
+
156
+ export const resolveMetricsInput = ({ file } = {}) => {
157
+ if (file) {
158
+ return {
159
+ kind: 'file',
160
+ storagePath: path.resolve(file),
161
+ filePath: path.resolve(file),
162
+ source: 'explicit',
163
+ };
164
+ }
165
+
166
+ const envFile = resolveEnvMetricsFile();
167
+ if (envFile) {
168
+ const filePath = path.resolve(envFile);
169
+ return {
170
+ kind: 'file',
171
+ storagePath: filePath,
172
+ filePath,
173
+ source: 'env_file',
174
+ };
175
+ }
176
+
177
+ const storagePath = getStateDbPath();
178
+ return {
179
+ kind: 'sqlite',
180
+ storagePath,
181
+ filePath: storagePath,
182
+ source: 'sqlite',
183
+ };
184
+ };
185
+
186
+ export const readMetricsEntries = (filePath) => {
187
+ if (!fsSync.existsSync(filePath)) {
188
+ throw new Error(`No metrics file found at ${filePath}`);
189
+ }
190
+
191
+ const lines = fsSync.readFileSync(filePath, 'utf8')
192
+ .split('\n')
193
+ .map((line) => line.trim())
194
+ .filter(Boolean);
195
+
196
+ const entries = [];
197
+ const invalidLines = [];
198
+
199
+ lines.forEach((line, index) => {
200
+ try {
201
+ entries.push(JSON.parse(line));
202
+ } catch {
203
+ invalidLines.push(index + 1);
204
+ }
205
+ });
206
+
207
+ return { entries, invalidLines };
208
+ };
209
+
210
+ export const aggregateMetrics = (entries) => {
211
+ const byTool = new Map();
212
+ const overheadByTool = new Map();
213
+ let rawTokens = 0;
214
+ let compressedTokens = 0;
215
+ let savedTokens = 0;
216
+ let overheadTokens = 0;
217
+
218
+ for (const entry of entries) {
219
+ const tool = entry.tool ?? 'unknown';
220
+ const compressedTokensForEntry = getCompressedTokens(entry);
221
+ const savedTokensForEntry = getSavedTokens(entry, compressedTokensForEntry);
222
+ const overheadTokensForEntry = Math.max(0, Number(entry.metadata?.overheadTokens ?? 0));
223
+ const current = byTool.get(tool) ?? {
224
+ tool,
225
+ count: 0,
226
+ rawTokens: 0,
227
+ compressedTokens: 0,
228
+ savedTokens: 0,
229
+ };
230
+
231
+ current.count += 1;
232
+ current.rawTokens += Number(entry.rawTokens ?? 0);
233
+ current.compressedTokens += compressedTokensForEntry;
234
+ current.savedTokens += savedTokensForEntry;
235
+ byTool.set(tool, current);
236
+
237
+ const overheadCurrent = overheadByTool.get(tool) ?? {
238
+ tool,
239
+ count: 0,
240
+ overheadTokens: 0,
241
+ };
242
+ if (overheadTokensForEntry > 0) {
243
+ overheadCurrent.count += 1;
244
+ overheadCurrent.overheadTokens += overheadTokensForEntry;
245
+ overheadByTool.set(tool, overheadCurrent);
246
+ }
247
+
248
+ rawTokens += Number(entry.rawTokens ?? 0);
249
+ compressedTokens += compressedTokensForEntry;
250
+ savedTokens += savedTokensForEntry;
251
+ overheadTokens += overheadTokensForEntry;
252
+ }
253
+
254
+ const tools = [...byTool.values()]
255
+ .map((item) => ({
256
+ ...item,
257
+ savingsPct: item.rawTokens > 0 ? Number(((item.savedTokens / item.rawTokens) * 100).toFixed(2)) : 0,
258
+ }))
259
+ .sort((a, b) => b.savedTokens - a.savedTokens || b.count - a.count || a.tool.localeCompare(b.tool));
260
+
261
+ const overheadTools = [...overheadByTool.values()]
262
+ .sort((a, b) => b.overheadTokens - a.overheadTokens || b.count - a.count || a.tool.localeCompare(b.tool));
263
+
264
+ return {
265
+ count: entries.length,
266
+ rawTokens,
267
+ compressedTokens,
268
+ savedTokens,
269
+ savingsPct: rawTokens > 0 ? Number(((savedTokens / rawTokens) * 100).toFixed(2)) : 0,
270
+ tools,
271
+ overheadTokens,
272
+ overheadPctOfRaw: rawTokens > 0 ? Number(((overheadTokens / rawTokens) * 100).toFixed(2)) : 0,
273
+ overheadTools,
274
+ };
275
+ };