copilot-debugger 0.1.0

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,1093 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import readline from 'node:readline';
6
+ import chokidar from 'chokidar';
7
+ import fg from 'fast-glob';
8
+ // debugName values for internal Copilot side-requests that are not part of the actual
9
+ // conversation (e.g. auto-generated chat tab titles). These are excluded from cost/model
10
+ // stats so they don't pollute usage numbers with models the user never actually picked.
11
+ const EXCLUDED_COST_DEBUG_NAMES = new Set(['title']);
12
+ export class VsCodeTranscriptSource {
13
+ options;
14
+ watcher;
15
+ watchedDirsKey = '';
16
+ pollTimer;
17
+ refreshTimer;
18
+ sessions = new Map();
19
+ lastRefreshAt;
20
+ error;
21
+ refreshInFlight;
22
+ constructor(options) {
23
+ this.options = options;
24
+ }
25
+ async start() {
26
+ await this.refresh();
27
+ this.pollTimer = setInterval(() => this.scheduleRefresh(), this.options.pollIntervalMs);
28
+ }
29
+ async stop() {
30
+ if (this.refreshTimer) {
31
+ clearTimeout(this.refreshTimer);
32
+ }
33
+ if (this.pollTimer) {
34
+ clearInterval(this.pollTimer);
35
+ }
36
+ await this.watcher?.close();
37
+ }
38
+ listSessions() {
39
+ return {
40
+ sessions: [...this.sessions.values()].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)),
41
+ lastRefreshAt: this.lastRefreshAt,
42
+ error: this.error
43
+ };
44
+ }
45
+ getSession(id) {
46
+ return this.sessions.get(id);
47
+ }
48
+ scheduleRefresh() {
49
+ if (this.refreshTimer) {
50
+ clearTimeout(this.refreshTimer);
51
+ }
52
+ this.refreshTimer = setTimeout(() => {
53
+ void this.refresh();
54
+ }, 350);
55
+ }
56
+ async refresh() {
57
+ if (this.refreshInFlight) {
58
+ return this.refreshInFlight;
59
+ }
60
+ this.refreshInFlight = this.performRefresh().finally(() => {
61
+ this.refreshInFlight = undefined;
62
+ });
63
+ return this.refreshInFlight;
64
+ }
65
+ async performRefresh() {
66
+ try {
67
+ const candidates = await this.findCandidateFiles();
68
+ await this.updateWatcher(candidates);
69
+ const accumulators = new Map();
70
+ for (const transcriptPath of candidates.transcripts) {
71
+ const accumulator = await this.getOrCreateAccumulatorAsync(accumulators, transcriptPath, 'transcript');
72
+ accumulator.sourcePaths.transcript = transcriptPath;
73
+ await this.consumeFileStats(accumulator, transcriptPath);
74
+ await this.readJsonl(transcriptPath, (entry) => this.consumeEntry(accumulator, entry));
75
+ }
76
+ for (const debugLogPath of candidates.debugLogs) {
77
+ const accumulator = await this.getOrCreateAccumulatorAsync(accumulators, debugLogPath, 'debugLog');
78
+ accumulator.sourcePaths.debugLog = debugLogPath;
79
+ accumulator.hasDebugLog = true;
80
+ await this.consumeFileStats(accumulator, debugLogPath);
81
+ await this.readJsonl(debugLogPath, (entry) => this.consumeEntry(accumulator, entry));
82
+ }
83
+ this.sessions = new Map([...accumulators.values()].map((session) => {
84
+ const normalized = this.normalizeAccumulator(session);
85
+ return [normalized.id, normalized];
86
+ }));
87
+ this.lastRefreshAt = new Date().toISOString();
88
+ this.error = undefined;
89
+ }
90
+ catch (error) {
91
+ this.error = error instanceof Error ? error.message : String(error);
92
+ this.lastRefreshAt = new Date().toISOString();
93
+ }
94
+ }
95
+ async findCandidateFiles() {
96
+ const roots = this.options.directCopilotSessionRoot
97
+ ? [this.options.directCopilotSessionRoot]
98
+ : [this.options.workspaceStorageRoot];
99
+ const toGlob = (p) => p.replace(/\\/g, '/');
100
+ const transcriptPatterns = roots.map((root) => toGlob(path.join(root, '**', 'GitHub.copilot-chat', 'transcripts', '*.jsonl')));
101
+ const debugPatterns = roots.flatMap((root) => [
102
+ toGlob(path.join(root, '**', 'GitHub.copilot-chat', 'debug-logs', '*', 'main.jsonl')),
103
+ toGlob(path.join(root, '**', 'GitHub.copilot-chat', 'debug-logs', '*', '*.jsonl'))
104
+ ]);
105
+ const [transcripts, debugLogs] = await Promise.all([
106
+ fg(transcriptPatterns, { onlyFiles: true, unique: true, dot: true, suppressErrors: true }),
107
+ fg(debugPatterns, { onlyFiles: true, unique: true, dot: true, suppressErrors: true })
108
+ ]);
109
+ return { transcripts, debugLogs };
110
+ }
111
+ async updateWatcher(candidates) {
112
+ const watchedDirs = [...new Set([...candidates.transcripts, ...candidates.debugLogs].map((filePath) => path.dirname(filePath)))].sort();
113
+ const nextKey = watchedDirs.join('\n');
114
+ if (nextKey === this.watchedDirsKey) {
115
+ return;
116
+ }
117
+ this.watchedDirsKey = nextKey;
118
+ await this.watcher?.close();
119
+ this.watcher = undefined;
120
+ if (watchedDirs.length === 0) {
121
+ return;
122
+ }
123
+ this.watcher = chokidar.watch(watchedDirs, {
124
+ ignoreInitial: true,
125
+ depth: 0,
126
+ awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 }
127
+ });
128
+ this.watcher.on('add', () => this.scheduleRefresh());
129
+ this.watcher.on('change', () => this.scheduleRefresh());
130
+ this.watcher.on('unlink', () => this.scheduleRefresh());
131
+ this.watcher.on('error', (error) => {
132
+ this.error = error instanceof Error ? error.message : String(error);
133
+ void this.watcher?.close().then(() => {
134
+ this.watcher = undefined;
135
+ this.watchedDirsKey = '';
136
+ });
137
+ });
138
+ }
139
+ async getOrCreateAccumulatorAsync(accumulators, filePath, kind) {
140
+ const id = this.extractSessionId(filePath, kind);
141
+ const existing = accumulators.get(id);
142
+ if (existing) {
143
+ return existing;
144
+ }
145
+ const workspaceStorageId = this.extractWorkspaceStorageId(filePath);
146
+ const workspaceName = await this.resolveWorkspaceName(workspaceStorageId);
147
+ const accumulator = {
148
+ id,
149
+ workspaceStorageId,
150
+ workspaceName,
151
+ sourcePaths: {},
152
+ producer: kind === 'debugLog' ? 'VS Code Copilot Chat debug log' : 'VS Code Copilot Chat transcript',
153
+ messageCount: 0,
154
+ userTurnCount: 0,
155
+ agents: new Set(),
156
+ tools: new Set(),
157
+ hasDebugLog: kind === 'debugLog',
158
+ cost: {
159
+ inputTokens: 0,
160
+ outputTokens: 0,
161
+ cachedTokens: 0,
162
+ requestCount: 0,
163
+ models: new Set(),
164
+ perModel: new Map()
165
+ }
166
+ };
167
+ accumulators.set(id, accumulator);
168
+ return accumulator;
169
+ }
170
+ extractSessionId(filePath, kind) {
171
+ const parts = filePath.split(path.sep);
172
+ const debugIndex = parts.lastIndexOf('debug-logs');
173
+ if (debugIndex >= 0 && parts[debugIndex + 1]) {
174
+ return parts[debugIndex + 1];
175
+ }
176
+ if (kind === 'transcript') {
177
+ return path.basename(filePath, '.jsonl');
178
+ }
179
+ return createHash('sha1').update(filePath).digest('hex').slice(0, 12);
180
+ }
181
+ extractWorkspaceStorageId(filePath) {
182
+ const relative = path.relative(this.options.workspaceStorageRoot, filePath);
183
+ if (!relative.startsWith('..')) {
184
+ return relative.split(path.sep)[0] ?? 'direct';
185
+ }
186
+ return 'direct';
187
+ }
188
+ async readJsonl(filePath, onEntry) {
189
+ try {
190
+ await fs.access(filePath);
191
+ }
192
+ catch {
193
+ return;
194
+ }
195
+ const stream = createReadStream(filePath, { encoding: 'utf8' });
196
+ const lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
197
+ for await (const line of lines) {
198
+ const trimmed = line.trim();
199
+ if (!trimmed) {
200
+ continue;
201
+ }
202
+ try {
203
+ onEntry(JSON.parse(trimmed));
204
+ }
205
+ catch {
206
+ continue;
207
+ }
208
+ }
209
+ }
210
+ async consumeFileStats(accumulator, filePath) {
211
+ try {
212
+ const stats = await fs.stat(filePath);
213
+ const modifiedAt = stats.mtime.toISOString();
214
+ accumulator.startTime = minIso(accumulator.startTime, modifiedAt);
215
+ accumulator.updatedAt = maxIso(accumulator.updatedAt, modifiedAt);
216
+ }
217
+ catch {
218
+ return;
219
+ }
220
+ }
221
+ consumeEntry(accumulator, entry) {
222
+ if (!entry || typeof entry !== 'object') {
223
+ return;
224
+ }
225
+ const record = entry;
226
+ const attrs = this.asRecord(record.attrs);
227
+ const fields = { ...attrs, ...record };
228
+ const timestamp = this.findTimestamp(fields);
229
+ if (timestamp) {
230
+ accumulator.startTime = minIso(accumulator.startTime, timestamp);
231
+ accumulator.updatedAt = maxIso(accumulator.updatedAt, timestamp);
232
+ }
233
+ this.copyString(fields, ['producer', 'source', 'type'], (value) => {
234
+ if (value.includes('Copilot') || value.includes('transcript') || value.includes('debug')) {
235
+ accumulator.producer = value;
236
+ }
237
+ });
238
+ this.copyString(fields, ['copilotVersion', 'copilotChatVersion', 'extensionVersion'], (value) => {
239
+ accumulator.copilotVersion ??= value;
240
+ });
241
+ this.copyString(fields, ['vscodeVersion', 'vscode', 'appVersion'], (value) => {
242
+ accumulator.vscodeVersion ??= value;
243
+ });
244
+ const role = this.findString(fields, ['role', 'speaker', 'participant', 'from']) ?? this.inferRole(fields);
245
+ const text = this.findMessageText(fields);
246
+ if (text) {
247
+ accumulator.messageCount += 1;
248
+ if (!accumulator.firstUserMessage && role?.toLowerCase().includes('user')) {
249
+ accumulator.firstUserMessage = text;
250
+ }
251
+ if (role?.toLowerCase().includes('user')) {
252
+ accumulator.userTurnCount += 1;
253
+ }
254
+ }
255
+ const agent = this.findString(fields, ['agent', 'agentName', 'agent_name', 'mode']);
256
+ if (agent) {
257
+ accumulator.agents.add(agent);
258
+ }
259
+ const tool = this.findString(fields, ['tool', 'toolName', 'tool_name', 'functionName', 'name']);
260
+ if (tool && this.looksLikeTool(fields, tool)) {
261
+ accumulator.tools.add(tool);
262
+ }
263
+ this.consumeCost(accumulator, fields);
264
+ }
265
+ async resolveWorkspaceName(workspaceStorageId) {
266
+ if (workspaceStorageId === 'direct')
267
+ return undefined;
268
+ try {
269
+ const workspaceJsonPath = path.join(this.options.workspaceStorageRoot, workspaceStorageId, 'workspace.json');
270
+ const raw = await fs.readFile(workspaceJsonPath, 'utf8');
271
+ const parsed = JSON.parse(raw);
272
+ const uri = (parsed.folder ?? parsed.workspace);
273
+ if (!uri)
274
+ return undefined;
275
+ let decoded = decodeURIComponent(uri.replace(/^file:\/\//, ''));
276
+ // Windows: file:///C:/... → /C:/... → C:/...
277
+ if (/^\/[A-Za-z]:\//.test(decoded))
278
+ decoded = decoded.slice(1);
279
+ return path.basename(decoded.replace(/\.code-workspace$/, ''));
280
+ }
281
+ catch {
282
+ return undefined;
283
+ }
284
+ }
285
+ normalizeAccumulator(accumulator) {
286
+ const updatedAt = accumulator.updatedAt ?? new Date(0).toISOString();
287
+ return {
288
+ id: accumulator.id,
289
+ workspaceStorageId: accumulator.workspaceStorageId,
290
+ workspaceName: accumulator.workspaceName,
291
+ sourcePaths: accumulator.sourcePaths,
292
+ startTime: accumulator.startTime,
293
+ updatedAt,
294
+ producer: accumulator.producer,
295
+ copilotVersion: accumulator.copilotVersion,
296
+ vscodeVersion: accumulator.vscodeVersion,
297
+ firstUserMessage: accumulator.firstUserMessage,
298
+ messageCount: accumulator.messageCount,
299
+ userTurnCount: accumulator.userTurnCount,
300
+ agents: [...accumulator.agents].sort(),
301
+ tools: [...accumulator.tools].sort(),
302
+ hasDebugLog: accumulator.hasDebugLog,
303
+ cost: {
304
+ aiCredits: accumulator.cost.aiCredits,
305
+ aiCreditUnit: accumulator.cost.aiCredits === undefined ? undefined : 'AIC',
306
+ aiCreditSource: accumulator.cost.aiCreditSource,
307
+ inputTokens: accumulator.cost.inputTokens || undefined,
308
+ outputTokens: accumulator.cost.outputTokens || undefined,
309
+ cachedTokens: accumulator.cost.cachedTokens || undefined,
310
+ requestCount: accumulator.cost.requestCount || undefined,
311
+ models: [...accumulator.cost.models].sort(),
312
+ perModel: [...accumulator.cost.perModel.entries()]
313
+ .map(([model, stats]) => ({
314
+ model,
315
+ aiCredits: stats.aiCredits || undefined,
316
+ inputTokens: stats.inputTokens || undefined,
317
+ outputTokens: stats.outputTokens || undefined,
318
+ cachedTokens: stats.cachedTokens || undefined,
319
+ requestCount: stats.requestCount || undefined
320
+ }))
321
+ .sort((a, b) => (b.aiCredits ?? 0) - (a.aiCredits ?? 0) || a.model.localeCompare(b.model))
322
+ }
323
+ };
324
+ }
325
+ consumeCost(accumulator, fields) {
326
+ // Skip internal side-requests (e.g. title generation) entirely, so they never
327
+ // show up as tokens/credits/models in the session stats.
328
+ const debugName = this.findString(fields, ['debugName']);
329
+ if (debugName && EXCLUDED_COST_DEBUG_NAMES.has(debugName)) {
330
+ return;
331
+ }
332
+ const nanoAiu = this.findNumber(fields, ['copilotUsageNanoAiu', 'copilotUsageNanoAIU', 'copilot_usage_nano_aiu']);
333
+ if (nanoAiu !== undefined) {
334
+ accumulator.cost.aiCredits = (accumulator.cost.aiCredits ?? 0) + nanoAiu / 1_000_000_000;
335
+ accumulator.cost.aiCreditSource ??= 'copilotUsageNanoAiu';
336
+ }
337
+ const inputTokens = this.findNumber(fields, ['usage_input_tokens', 'inputTokens', 'input_tokens', 'promptTokens', 'prompt_tokens']);
338
+ const outputTokens = this.findNumber(fields, ['usage_output_tokens', 'outputTokens', 'output_tokens', 'completionTokens', 'completion_tokens']);
339
+ const cachedTokens = this.findNumber(fields, ['cachedTokens', 'cached_tokens', 'usage_cached_tokens']);
340
+ if (inputTokens !== undefined || outputTokens !== undefined || cachedTokens !== undefined) {
341
+ accumulator.cost.requestCount += 1;
342
+ accumulator.cost.inputTokens += inputTokens ?? 0;
343
+ accumulator.cost.outputTokens += outputTokens ?? 0;
344
+ accumulator.cost.cachedTokens += cachedTokens ?? 0;
345
+ }
346
+ const model = this.findString(fields, ['usage_model', 'model', 'modelName', 'model_name']);
347
+ if (model) {
348
+ accumulator.cost.models.add(model);
349
+ if (nanoAiu !== undefined || inputTokens !== undefined || outputTokens !== undefined || cachedTokens !== undefined) {
350
+ const modelStats = accumulator.cost.perModel.get(model) ?? {
351
+ aiCredits: 0,
352
+ inputTokens: 0,
353
+ outputTokens: 0,
354
+ cachedTokens: 0,
355
+ requestCount: 0
356
+ };
357
+ if (nanoAiu !== undefined) {
358
+ modelStats.aiCredits += nanoAiu / 1_000_000_000;
359
+ }
360
+ if (inputTokens !== undefined || outputTokens !== undefined || cachedTokens !== undefined) {
361
+ modelStats.requestCount += 1;
362
+ modelStats.inputTokens += inputTokens ?? 0;
363
+ modelStats.outputTokens += outputTokens ?? 0;
364
+ modelStats.cachedTokens += cachedTokens ?? 0;
365
+ }
366
+ accumulator.cost.perModel.set(model, modelStats);
367
+ }
368
+ }
369
+ }
370
+ findTimestamp(record) {
371
+ for (const key of ['timestamp', 'time', 'createdAt', 'created_at', 'updatedAt', 'updated_at', 'date', 'ts']) {
372
+ const value = record[key];
373
+ const date = typeof value === 'number' ? new Date(value > 10_000_000_000 ? value : value * 1000) : new Date(String(value ?? ''));
374
+ if (!Number.isNaN(date.getTime())) {
375
+ return date.toISOString();
376
+ }
377
+ }
378
+ return undefined;
379
+ }
380
+ findMessageText(record) {
381
+ const direct = this.findString(record, ['text', 'message', 'content', 'userMessage', 'user_message', 'prompt']);
382
+ if (direct) {
383
+ return direct.slice(0, 280);
384
+ }
385
+ for (const value of Object.values(record)) {
386
+ if (value && typeof value === 'object') {
387
+ const nested = this.findMessageText(value);
388
+ if (nested) {
389
+ return nested;
390
+ }
391
+ }
392
+ }
393
+ return undefined;
394
+ }
395
+ findString(record, keys) {
396
+ for (const key of keys) {
397
+ const value = record[key];
398
+ if (typeof value === 'string' && value.trim()) {
399
+ return value.trim();
400
+ }
401
+ if (typeof value === 'number' || typeof value === 'boolean') {
402
+ return String(value);
403
+ }
404
+ }
405
+ return undefined;
406
+ }
407
+ findNumber(record, keys) {
408
+ for (const key of keys) {
409
+ const value = record[key];
410
+ if (typeof value === 'number' && Number.isFinite(value)) {
411
+ return value;
412
+ }
413
+ if (typeof value === 'string' && value.trim()) {
414
+ const parsed = Number(value);
415
+ if (Number.isFinite(parsed)) {
416
+ return parsed;
417
+ }
418
+ }
419
+ }
420
+ return undefined;
421
+ }
422
+ copyString(record, keys, onValue) {
423
+ const value = this.findString(record, keys);
424
+ if (value) {
425
+ onValue(value);
426
+ }
427
+ }
428
+ looksLikeTool(record, tool) {
429
+ const serialized = JSON.stringify(record).toLowerCase();
430
+ return serialized.includes('tool') || serialized.includes('function') || tool.includes('.');
431
+ }
432
+ inferRole(record) {
433
+ const type = this.findString(record, ['type', 'name'])?.toLowerCase();
434
+ if (type?.includes('user_message')) {
435
+ return 'user';
436
+ }
437
+ if (type?.includes('agent_response') || type?.includes('assistant')) {
438
+ return 'assistant';
439
+ }
440
+ return undefined;
441
+ }
442
+ asRecord(value) {
443
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
444
+ }
445
+ async getSessionTurns(id) {
446
+ const session = this.sessions.get(id);
447
+ if (!session) {
448
+ return [];
449
+ }
450
+ const debugLogPath = session.sourcePaths.debugLog;
451
+ const transcriptPath = session.sourcePaths.transcript;
452
+ // Prefer main.jsonl in the debug-logs session directory; fallback to stored path
453
+ if (debugLogPath) {
454
+ const dir = path.dirname(debugLogPath);
455
+ const mainPath = path.join(dir, 'main.jsonl');
456
+ try {
457
+ await fs.access(mainPath);
458
+ return this.parseTurns(mainPath);
459
+ }
460
+ catch {
461
+ return this.parseTurns(debugLogPath);
462
+ }
463
+ }
464
+ if (transcriptPath) {
465
+ return this.parseTurns(transcriptPath);
466
+ }
467
+ return [];
468
+ }
469
+ async getSessionOverview(id) {
470
+ const session = this.sessions.get(id);
471
+ if (!session?.sourcePaths.debugLog) {
472
+ return undefined;
473
+ }
474
+ const dir = path.dirname(session.sourcePaths.debugLog);
475
+ // Find system_prompt and tools files (use index 0 as the first turn's context)
476
+ const systemPromptFiles = await fg(path.join(dir, 'system_prompt_*.json').replace(/\\/g, '/'), {
477
+ onlyFiles: true,
478
+ suppressErrors: true
479
+ });
480
+ const toolsFiles = await fg(path.join(dir, 'tools_*.json').replace(/\\/g, '/'), {
481
+ onlyFiles: true,
482
+ suppressErrors: true
483
+ });
484
+ const systemPromptFile = systemPromptFiles.sort()[0];
485
+ const toolsFile = toolsFiles.sort()[0];
486
+ const skills = [];
487
+ const agents = [];
488
+ const attachments = [];
489
+ const toolsByName = new Map();
490
+ const addTool = (tool) => {
491
+ const name = getToolName(tool);
492
+ if (!name)
493
+ return;
494
+ const existing = toolsByName.get(name);
495
+ const isMcp = name.startsWith('mcp_');
496
+ const mcpServer = isMcp ? name.split('_').slice(1, -1).join('_') : undefined;
497
+ const description = getToolDescription(tool).slice(0, 120);
498
+ const deferred = isDeferredTool(tool);
499
+ const sizeBytes = JSON.stringify(tool).length;
500
+ toolsByName.set(name, {
501
+ name,
502
+ description: existing?.description || description,
503
+ isMcp,
504
+ deferred: existing?.deferred || deferred || undefined,
505
+ mcpServer,
506
+ sizeBytes: Math.max(existing?.sizeBytes ?? 0, sizeBytes)
507
+ });
508
+ };
509
+ let systemPromptTotalBytes = 0;
510
+ let systemPromptSkillsBytes = 0;
511
+ let systemPromptAgentsBytes = 0;
512
+ let systemPromptAttachmentsBytes = 0;
513
+ if (systemPromptFile) {
514
+ try {
515
+ const raw = await fs.readFile(systemPromptFile, 'utf8');
516
+ const outer = JSON.parse(raw);
517
+ const parts = JSON.parse(outer.content);
518
+ const text = parts.find((p) => p.type === 'text')?.content ?? '';
519
+ systemPromptTotalBytes = text.length;
520
+ // Parse <skills>
521
+ const skillsMatch = text.match(/<skills>([\s\S]*?)<\/skills>/);
522
+ if (skillsMatch) {
523
+ systemPromptSkillsBytes = skillsMatch[0].length;
524
+ const skillsText = skillsMatch[1];
525
+ const skillEntries = skillsText.matchAll(/<skill>[\s\S]*?<name>([\s\S]*?)<\/name>[\s\S]*?<description>([\s\S]*?)<\/description>[\s\S]*?<\/skill>/g);
526
+ for (const m of skillEntries) {
527
+ skills.push({ name: m[1].trim(), description: m[2].trim(), sizeBytes: m[0].length });
528
+ }
529
+ }
530
+ // Parse <agents>
531
+ const agentsMatch = text.match(/<agents>([\s\S]*?)<\/agents>/);
532
+ if (agentsMatch) {
533
+ systemPromptAgentsBytes = agentsMatch[0].length;
534
+ const agentsText = agentsMatch[1];
535
+ const agentEntries = agentsText.matchAll(/<agent>[\s\S]*?<name>([\s\S]*?)<\/name>[\s\S]*?<description>([\s\S]*?)<\/description>[\s\S]*?<\/agent>/g);
536
+ for (const m of agentEntries) {
537
+ agents.push({ name: m[1].trim(), description: m[2].trim(), sizeBytes: m[0].length });
538
+ }
539
+ }
540
+ // Parse standalone <attachment> blocks
541
+ const attachmentEntries = text.matchAll(/<attachment([^>]*)>([\s\S]*?)<\/attachment>/g);
542
+ for (const m of attachmentEntries) {
543
+ const attrs = m[1];
544
+ const idMatch = attrs.match(/\bid="([^"]*)"/);
545
+ const filePathMatch = attrs.match(/\bfilePath="([^"]*)"/);
546
+ const name = idMatch?.[1] ?? (filePathMatch ? filePathMatch[1].split('/').pop() ?? filePathMatch[1] : 'attachment');
547
+ const description = m[2].trim().slice(0, 160).replace(/\s+/g, ' ');
548
+ const sizeBytes = m[0].length;
549
+ systemPromptAttachmentsBytes += sizeBytes;
550
+ attachments.push({ name, description, sizeBytes });
551
+ }
552
+ }
553
+ catch {
554
+ // ignore parse errors
555
+ }
556
+ }
557
+ if (toolsFile) {
558
+ try {
559
+ const raw = await fs.readFile(toolsFile, 'utf8');
560
+ const outer = JSON.parse(raw);
561
+ const toolsArray = JSON.parse(outer.content);
562
+ for (const t of toolsArray) {
563
+ addTool(t);
564
+ }
565
+ }
566
+ catch {
567
+ // ignore parse errors
568
+ }
569
+ }
570
+ try {
571
+ await this.consumeProviderRequestTools(path.join(dir, 'main.jsonl'), addTool);
572
+ }
573
+ catch {
574
+ // ignore parse errors
575
+ }
576
+ const deferredToolNames = await this.collectAvailableDeferredToolNames(path.join(dir, 'main.jsonl'));
577
+ for (const name of deferredToolNames) {
578
+ const tool = toolsByName.get(name);
579
+ if (tool) {
580
+ toolsByName.set(name, { ...tool, deferred: true });
581
+ }
582
+ }
583
+ const tools = [...toolsByName.values()];
584
+ const toolsBytesTotal = tools.reduce((s, t) => s + t.sizeBytes, 0);
585
+ const userPromptBytes = session.firstUserMessage?.length ?? 0;
586
+ let maxContextTokens;
587
+ let model = session.cost.models[0];
588
+ try {
589
+ const modelsRaw = await fs.readFile(path.join(dir, 'models.json'), 'utf8');
590
+ const modelsData = JSON.parse(modelsRaw);
591
+ const modelEntry = modelsData.find((m) => m.id === model);
592
+ maxContextTokens = modelEntry?.capabilities?.limits?.max_context_window_tokens;
593
+ }
594
+ catch {
595
+ // ignore — models.json may not exist
596
+ }
597
+ const contextSizes = {
598
+ systemPromptTotalBytes,
599
+ systemPromptSkillsBytes,
600
+ systemPromptAgentsBytes,
601
+ systemPromptAttachmentsBytes,
602
+ toolsBytes: toolsBytesTotal,
603
+ userPromptBytes,
604
+ maxContextTokens,
605
+ model,
606
+ };
607
+ skills.sort((a, b) => b.sizeBytes - a.sizeBytes || a.name.localeCompare(b.name));
608
+ agents.sort((a, b) => b.sizeBytes - a.sizeBytes || a.name.localeCompare(b.name));
609
+ attachments.sort((a, b) => b.sizeBytes - a.sizeBytes || a.name.localeCompare(b.name));
610
+ tools.sort((a, b) => b.sizeBytes - a.sizeBytes || a.name.localeCompare(b.name));
611
+ return { skills, agents, attachments, tools, contextSizes };
612
+ }
613
+ async consumeProviderRequestTools(filePath, onTool) {
614
+ await this.readJsonl(filePath, (entry) => {
615
+ if (!entry || typeof entry !== 'object')
616
+ return;
617
+ const record = entry;
618
+ if (record.type !== 'llm_request')
619
+ return;
620
+ const attrs = this.asRecord(record.attrs);
621
+ for (const key of ['tools', 'request', 'requestBody', 'requestPayload', 'requestJson', 'requestJSON', 'providerRequest', 'apiRequest', 'rawRequest', 'body', 'requestOptions', 'requestShape', 'inputMessages']) {
622
+ this.consumeToolDefinitions(record[key], onTool);
623
+ this.consumeToolDefinitions(attrs[key], onTool);
624
+ }
625
+ });
626
+ }
627
+ async collectAvailableDeferredToolNames(filePath) {
628
+ const names = new Set();
629
+ await this.readJsonl(filePath, (entry) => {
630
+ if (!entry || typeof entry !== 'object')
631
+ return;
632
+ const record = entry;
633
+ const attrs = this.asRecord(record.attrs);
634
+ for (const value of [...Object.values(record), ...Object.values(attrs)]) {
635
+ for (const name of extractAvailableDeferredToolNames(value)) {
636
+ names.add(name);
637
+ }
638
+ }
639
+ });
640
+ return names;
641
+ }
642
+ consumeToolDefinitions(value, onTool, depth = 0) {
643
+ if (depth > 6)
644
+ return;
645
+ if (typeof value === 'string') {
646
+ const parsed = parseJsonValue(value);
647
+ if (parsed !== undefined) {
648
+ this.consumeToolDefinitions(parsed, onTool, depth + 1);
649
+ return;
650
+ }
651
+ for (const tool of extractToolDefinitionsFromText(value)) {
652
+ onTool(tool);
653
+ }
654
+ return;
655
+ }
656
+ const parsed = parseJsonValue(value);
657
+ if (Array.isArray(parsed)) {
658
+ for (const item of parsed) {
659
+ if (item && typeof item === 'object') {
660
+ const tool = item;
661
+ if (getToolName(tool)) {
662
+ onTool(tool);
663
+ }
664
+ else {
665
+ this.consumeToolDefinitions(item, onTool, depth + 1);
666
+ }
667
+ }
668
+ }
669
+ return;
670
+ }
671
+ if (!parsed || typeof parsed !== 'object')
672
+ return;
673
+ const record = parsed;
674
+ const tools = record.tools;
675
+ if (!Array.isArray(tools)) {
676
+ for (const nested of Object.values(record)) {
677
+ this.consumeToolDefinitions(nested, onTool, depth + 1);
678
+ }
679
+ return;
680
+ }
681
+ for (const item of tools) {
682
+ if (item && typeof item === 'object') {
683
+ onTool(item);
684
+ }
685
+ }
686
+ }
687
+ // Finds sibling runSubagent-*.jsonl files next to the given debug-log file and parses
688
+ // each into its own list of agent turns, keyed by the parentSpanId that links back to
689
+ // the 'runSubagent' tool_call in the parent log (see the 'subagent' entry at the end
690
+ // of the subagent file).
691
+ async parseSubagentFiles(filePath) {
692
+ const result = new Map();
693
+ const dir = path.dirname(filePath);
694
+ let files = [];
695
+ try {
696
+ files = await fg(path.join(dir, 'runSubagent-*.jsonl').replace(/\\/g, '/'), {
697
+ onlyFiles: true,
698
+ suppressErrors: true
699
+ });
700
+ }
701
+ catch {
702
+ return result;
703
+ }
704
+ for (const file of files) {
705
+ const llmRequests = [];
706
+ const toolCalls = [];
707
+ let linkParentSpanId;
708
+ let agentName;
709
+ await this.readJsonl(file, (entry) => {
710
+ if (!entry || typeof entry !== 'object')
711
+ return;
712
+ const rec = entry;
713
+ const type = typeof rec.type === 'string' ? rec.type : undefined;
714
+ const attrs = this.asRecord(rec.attrs);
715
+ const parentSpanId = typeof rec.parentSpanId === 'string' ? rec.parentSpanId : undefined;
716
+ const tsRaw = rec.ts;
717
+ const ts = typeof tsRaw === 'number' ? (tsRaw > 10_000_000_000 ? tsRaw : tsRaw * 1000) : Date.now();
718
+ if (type === 'llm_request') {
719
+ const fields = { ...attrs };
720
+ const model = typeof fields.model === 'string' ? fields.model : undefined;
721
+ const inputTokens = typeof fields.inputTokens === 'number' ? fields.inputTokens : 0;
722
+ const outputTokens = typeof fields.outputTokens === 'number' ? fields.outputTokens : 0;
723
+ const cachedTokens = typeof fields.cachedTokens === 'number' ? fields.cachedTokens : 0;
724
+ const nanoAiu = typeof fields.copilotUsageNanoAiu === 'number' ? fields.copilotUsageNanoAiu : 0;
725
+ llmRequests.push({ ts, model, inputTokens, outputTokens, cachedTokens, nanoAiu });
726
+ }
727
+ if (type === 'tool_call') {
728
+ const name = typeof rec.name === 'string' ? rec.name : undefined;
729
+ if (name) {
730
+ const argsRaw = serializeUnknown(attrs.args ?? {});
731
+ const resultRaw = serializeUnknown(attrs.result ?? '');
732
+ let args = {};
733
+ try {
734
+ args = JSON.parse(argsRaw);
735
+ }
736
+ catch { /* ignore */ }
737
+ toolCalls.push({ ts, name, args, inputChars: resultRaw.length, outputChars: argsRaw.length });
738
+ }
739
+ }
740
+ if (type === 'subagent') {
741
+ linkParentSpanId = parentSpanId;
742
+ agentName = typeof attrs.agentName === 'string' ? attrs.agentName : undefined;
743
+ }
744
+ });
745
+ if (!linkParentSpanId) {
746
+ continue;
747
+ }
748
+ const reqs = [...llmRequests].sort((a, b) => a.ts - b.ts);
749
+ const sortedTools = [...toolCalls].sort((a, b) => a.ts - b.ts);
750
+ const turns = reqs.map((r, turnIndex) => {
751
+ const nextTurnTs = reqs[turnIndex + 1]?.ts ?? Infinity;
752
+ const toolsForTurn = sortedTools.filter((tc) => tc.ts >= r.ts && tc.ts < nextTurnTs);
753
+ return {
754
+ index: turnIndex,
755
+ model: r.model,
756
+ inputTokens: r.inputTokens,
757
+ outputTokens: r.outputTokens,
758
+ cachedTokens: r.cachedTokens,
759
+ aiCredits: r.nanoAiu > 0 ? r.nanoAiu / 1_000_000_000 : undefined,
760
+ timestamp: new Date(r.ts).toISOString(),
761
+ toolCalls: toolsForTurn.map((tc) => ({
762
+ name: tc.name,
763
+ detail: extractToolDetail(tc.name, tc.args),
764
+ inputChars: tc.inputChars,
765
+ outputChars: tc.outputChars
766
+ }))
767
+ };
768
+ });
769
+ result.set(linkParentSpanId, { agentName, turns });
770
+ }
771
+ return result;
772
+ }
773
+ async parseTurns(filePath) {
774
+ const userMessages = [];
775
+ const llmRequests = [];
776
+ const toolCalls = [];
777
+ const browserParents = new Set();
778
+ await this.readJsonl(filePath, (entry) => {
779
+ if (!entry || typeof entry !== 'object')
780
+ return;
781
+ const rec = entry;
782
+ const type = typeof rec.type === 'string' ? rec.type : undefined;
783
+ const attrs = this.asRecord(rec.attrs);
784
+ const spanId = typeof rec.spanId === 'string' ? rec.spanId : undefined;
785
+ const parentSpanId = typeof rec.parentSpanId === 'string' ? rec.parentSpanId : undefined;
786
+ const tsRaw = rec.ts;
787
+ const ts = typeof tsRaw === 'number' ? (tsRaw > 10_000_000_000 ? tsRaw : tsRaw * 1000) : Date.now();
788
+ if (type === 'user_message' && spanId) {
789
+ const content = typeof attrs.content === 'string' ? attrs.content : undefined;
790
+ userMessages.push({ spanId, ts, content });
791
+ }
792
+ if (type === 'llm_request' && parentSpanId) {
793
+ const fields = { ...attrs };
794
+ const model = typeof fields.model === 'string' ? fields.model : undefined;
795
+ const inputTokens = typeof fields.inputTokens === 'number' ? fields.inputTokens : 0;
796
+ const outputTokens = typeof fields.outputTokens === 'number' ? fields.outputTokens : 0;
797
+ const cachedTokens = typeof fields.cachedTokens === 'number' ? fields.cachedTokens : 0;
798
+ const nanoAiu = typeof fields.copilotUsageNanoAiu === 'number' ? fields.copilotUsageNanoAiu : 0;
799
+ llmRequests.push({ parentSpanId, ts, model, inputTokens, outputTokens, cachedTokens, nanoAiu });
800
+ const userRequest = typeof fields.userRequest === 'string' ? fields.userRequest : JSON.stringify(fields.userRequest ?? '');
801
+ if (userRequest.includes('Integrated Browser')) {
802
+ browserParents.add(parentSpanId);
803
+ }
804
+ }
805
+ if (type === 'tool_call' && parentSpanId) {
806
+ const name = typeof rec.name === 'string' ? rec.name : undefined;
807
+ if (name) {
808
+ const argsRaw = serializeUnknown(attrs.args ?? {});
809
+ const resultRaw = serializeUnknown(attrs.result ?? '');
810
+ let args = {};
811
+ try {
812
+ args = JSON.parse(argsRaw);
813
+ }
814
+ catch { /* ignore */ }
815
+ toolCalls.push({
816
+ parentSpanId,
817
+ spanId,
818
+ ts,
819
+ name,
820
+ args,
821
+ inputChars: resultRaw.length,
822
+ outputChars: argsRaw.length
823
+ });
824
+ }
825
+ }
826
+ });
827
+ // A tool_call named 'runSubagent' can have its own log file (runSubagent-<label>_<id>.jsonl)
828
+ // sitting next to this one, containing the subagent's own llm_requests/tool_calls. That
829
+ // file links back via a 'subagent' entry whose parentSpanId equals this tool_call's spanId.
830
+ const subagentByToolSpanId = await this.parseSubagentFiles(filePath);
831
+ // Group llm_requests by parentSpanId
832
+ const llmByParent = new Map();
833
+ for (const req of llmRequests) {
834
+ const list = llmByParent.get(req.parentSpanId) ?? [];
835
+ list.push(req);
836
+ llmByParent.set(req.parentSpanId, list);
837
+ }
838
+ // Group tool_calls by parentSpanId (round), keeping ts so they can be bucketed into turns below
839
+ const toolsByParent = new Map();
840
+ for (const tc of toolCalls) {
841
+ const list = toolsByParent.get(tc.parentSpanId) ?? [];
842
+ list.push(tc);
843
+ toolsByParent.set(tc.parentSpanId, list);
844
+ }
845
+ const toToolCallInfo = (tc) => {
846
+ const subagent = tc.spanId ? subagentByToolSpanId.get(tc.spanId) : undefined;
847
+ return {
848
+ name: tc.name,
849
+ detail: extractToolDetail(tc.name, tc.args),
850
+ inputChars: tc.inputChars,
851
+ outputChars: tc.outputChars,
852
+ subagentName: subagent?.agentName,
853
+ subagentTurns: subagent?.turns
854
+ };
855
+ };
856
+ const turns = userMessages.map((msg, index) => {
857
+ const reqs = [...(llmByParent.get(msg.spanId) ?? [])].sort((a, b) => a.ts - b.ts);
858
+ const models = [...new Set(reqs.map((r) => r.model).filter((m) => !!m))].sort();
859
+ const inputTokens = reqs.reduce((sum, r) => sum + r.inputTokens, 0);
860
+ const outputTokens = reqs.reduce((sum, r) => sum + r.outputTokens, 0);
861
+ const cachedTokens = reqs.reduce((sum, r) => sum + r.cachedTokens, 0);
862
+ const totalNanoAiu = reqs.reduce((sum, r) => sum + r.nanoAiu, 0);
863
+ const aiCredits = totalNanoAiu > 0 ? totalNanoAiu / 1_000_000_000 : undefined;
864
+ const roundTools = [...(toolsByParent.get(msg.spanId) ?? [])].sort((a, b) => a.ts - b.ts);
865
+ // Each LLM request is one agent turn inside this round. Models can differ between
866
+ // turns within the same round (e.g. a subagent call runs on a different model),
867
+ // so expose them individually instead of only the deduped `models` summary.
868
+ // Tool calls are nested under the turn that triggered them: a tool call always
869
+ // happens after the turn that requested it and before the next turn starts, so
870
+ // bucket by "latest turn whose request started at or before the tool call".
871
+ const agentTurns = reqs.map((r, turnIndex) => {
872
+ const nextTurnTs = reqs[turnIndex + 1]?.ts ?? Infinity;
873
+ const toolsForTurn = roundTools.filter((tc) => tc.ts >= r.ts && tc.ts < nextTurnTs);
874
+ return {
875
+ index: turnIndex,
876
+ model: r.model,
877
+ inputTokens: r.inputTokens,
878
+ outputTokens: r.outputTokens,
879
+ cachedTokens: r.cachedTokens,
880
+ aiCredits: r.nanoAiu > 0 ? r.nanoAiu / 1_000_000_000 : undefined,
881
+ timestamp: new Date(r.ts).toISOString(),
882
+ toolCalls: toolsForTurn.map(toToolCallInfo)
883
+ };
884
+ });
885
+ const hasBrowserContext = browserParents.has(msg.spanId);
886
+ return {
887
+ index,
888
+ userMessage: msg.content,
889
+ timestamp: new Date(msg.ts).toISOString(),
890
+ models,
891
+ inputTokens,
892
+ outputTokens,
893
+ cachedTokens,
894
+ aiCredits,
895
+ llmRequestCount: reqs.length,
896
+ toolCalls: roundTools.map(toToolCallInfo),
897
+ turns: agentTurns,
898
+ hasBrowserContext
899
+ };
900
+ });
901
+ return turns;
902
+ }
903
+ }
904
+ function parseJsonValue(value) {
905
+ if (typeof value !== 'string') {
906
+ return value;
907
+ }
908
+ const trimmed = value.trim();
909
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
910
+ return undefined;
911
+ }
912
+ try {
913
+ return JSON.parse(trimmed);
914
+ }
915
+ catch {
916
+ return undefined;
917
+ }
918
+ }
919
+ function getToolName(tool) {
920
+ return (tool.name ?? tool.function?.name ?? '').trim();
921
+ }
922
+ function getToolDescription(tool) {
923
+ return tool.description ?? tool.function?.description ?? '';
924
+ }
925
+ function extractAvailableDeferredToolNames(value) {
926
+ if (typeof value !== 'string') {
927
+ const parsed = parseJsonValue(value);
928
+ if (!parsed || typeof parsed !== 'object')
929
+ return [];
930
+ return Object.values(parsed).flatMap(extractAvailableDeferredToolNames);
931
+ }
932
+ const text = value.replace(/\\r\\n|\\n|\\r/g, '\n');
933
+ const marker = 'Available deferred tools';
934
+ const markerIndex = text.indexOf(marker);
935
+ if (markerIndex < 0)
936
+ return [];
937
+ const blockStart = text.indexOf(':', markerIndex);
938
+ if (blockStart < 0)
939
+ return [];
940
+ const blockText = text.slice(blockStart + 1);
941
+ const names = [];
942
+ for (const rawLine of blockText.split(/\r?\n/)) {
943
+ const line = rawLine.trim();
944
+ if (!line) {
945
+ if (names.length > 0)
946
+ break;
947
+ continue;
948
+ }
949
+ if (line.startsWith('<') || line.startsWith('{') || line.startsWith('['))
950
+ break;
951
+ if (/^[A-Za-z0-9_.-]+$/.test(line)) {
952
+ names.push(line);
953
+ continue;
954
+ }
955
+ if (names.length > 0)
956
+ break;
957
+ }
958
+ return names;
959
+ }
960
+ function extractToolDefinitionsFromText(text) {
961
+ if (!text.includes('defer_loading') || !text.includes('tools')) {
962
+ return [];
963
+ }
964
+ const tools = [];
965
+ const toolsKeyPattern = /"tools"\s*:\s*\[/g;
966
+ let match;
967
+ while ((match = toolsKeyPattern.exec(text))) {
968
+ const arrayStart = text.indexOf('[', match.index);
969
+ const arrayEnd = findJsonArrayEnd(text, arrayStart);
970
+ if (arrayStart < 0 || arrayEnd < 0)
971
+ continue;
972
+ try {
973
+ const parsed = JSON.parse(text.slice(arrayStart, arrayEnd + 1));
974
+ if (Array.isArray(parsed)) {
975
+ for (const item of parsed) {
976
+ if (item && typeof item === 'object') {
977
+ tools.push(item);
978
+ }
979
+ }
980
+ }
981
+ }
982
+ catch {
983
+ // ignore embedded non-JSON snippets
984
+ }
985
+ toolsKeyPattern.lastIndex = arrayEnd + 1;
986
+ }
987
+ return tools;
988
+ }
989
+ function findJsonArrayEnd(text, start) {
990
+ if (start < 0 || text[start] !== '[')
991
+ return -1;
992
+ let depth = 0;
993
+ let inString = false;
994
+ let escaped = false;
995
+ for (let index = start; index < text.length; index += 1) {
996
+ const char = text[index];
997
+ if (escaped) {
998
+ escaped = false;
999
+ continue;
1000
+ }
1001
+ if (char === '\\') {
1002
+ escaped = true;
1003
+ continue;
1004
+ }
1005
+ if (char === '"') {
1006
+ inString = !inString;
1007
+ continue;
1008
+ }
1009
+ if (inString)
1010
+ continue;
1011
+ if (char === '[') {
1012
+ depth += 1;
1013
+ }
1014
+ else if (char === ']') {
1015
+ depth -= 1;
1016
+ if (depth === 0) {
1017
+ return index;
1018
+ }
1019
+ }
1020
+ }
1021
+ return -1;
1022
+ }
1023
+ function isDeferredTool(tool) {
1024
+ return (isTruthyToolFlag(tool.defer_loading) ||
1025
+ isTruthyToolFlag(tool.deferred) ||
1026
+ isTruthyToolFlag(tool.isDeferred) ||
1027
+ isTruthyToolFlag(tool.metadata?.defer_loading) ||
1028
+ isTruthyToolFlag(tool.metadata?.deferred) ||
1029
+ isTruthyToolFlag(tool.metadata?.isDeferred));
1030
+ }
1031
+ function isTruthyToolFlag(value) {
1032
+ return value === true || value === 'true';
1033
+ }
1034
+ function extractToolDetail(name, args) {
1035
+ const str = (key) => {
1036
+ const v = args[key];
1037
+ return typeof v === 'string' && v.trim() ? v.trim() : undefined;
1038
+ };
1039
+ const shortPath = (p) => p ? path.basename(p) : undefined;
1040
+ switch (name) {
1041
+ case 'read_file': {
1042
+ const file = shortPath(str('filePath'));
1043
+ const start = args['startLine'];
1044
+ const end = args['endLine'];
1045
+ return file ? `${file}:${start}-${end}` : undefined;
1046
+ }
1047
+ case 'grep_search':
1048
+ case 'semantic_search':
1049
+ return str('query');
1050
+ case 'file_search':
1051
+ return str('query') ?? str('pattern');
1052
+ case 'list_dir':
1053
+ return shortPath(str('path'));
1054
+ case 'run_in_terminal': {
1055
+ const goal = str('goal');
1056
+ if (goal)
1057
+ return goal;
1058
+ const cmd = str('command');
1059
+ return cmd ? cmd.slice(0, 80) : undefined;
1060
+ }
1061
+ case 'replace_string_in_file':
1062
+ case 'create_file':
1063
+ return shortPath(str('filePath'));
1064
+ case 'multi_replace_string_in_file':
1065
+ return str('explanation');
1066
+ case 'fetch_webpage': {
1067
+ const urls = args['urls'];
1068
+ if (Array.isArray(urls) && urls.length > 0)
1069
+ return String(urls[0]).slice(0, 80);
1070
+ return undefined;
1071
+ }
1072
+ case 'manage_todo_list':
1073
+ return str('operation') ?? (args['todoList'] ? 'write' : undefined);
1074
+ default:
1075
+ return undefined;
1076
+ }
1077
+ }
1078
+ function serializeUnknown(value) {
1079
+ if (typeof value === 'string')
1080
+ return value;
1081
+ try {
1082
+ return JSON.stringify(value ?? '');
1083
+ }
1084
+ catch {
1085
+ return String(value ?? '');
1086
+ }
1087
+ }
1088
+ function minIso(current, candidate) {
1089
+ return !current || candidate < current ? candidate : current;
1090
+ }
1091
+ function maxIso(current, candidate) {
1092
+ return !current || candidate > current ? candidate : current;
1093
+ }