ec-web 0.0.4 → 0.0.6

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.
@@ -13,8 +13,11 @@
13
13
  import fs from 'fs';
14
14
  import path from 'path';
15
15
  import { resolvePaths, ccProjectsDir } from '../paths.js';
16
- import { encodePath, scanChatDirs, readJsonFile } from '../fs-utils.js';
16
+ import { encodePath } from '../fs-utils.js';
17
17
  import { snapshotCodex, subscribeCodex, setDebugLog as setCodexDebugLog } from './session-codex.js';
18
+ import { getAgentSelfNames } from './msg.js';
19
+ import { parseTranscriptResultKey, previewLargeToolResults, windowTranscriptTurns } from './transcript-window.js';
20
+ import { buildSessionBindMap, classifyTranscriptSource, isSyntheticCliPrompt, resolveSessionBindSelfName, } from './session-origin.js';
18
21
  let _dlog = null;
19
22
  export function setDebugLog(log) {
20
23
  _dlog = log;
@@ -25,8 +28,10 @@ function dlog(line) { if (_dlog)
25
28
  _dlog(line);
26
29
  }
27
30
  catch { } }
28
- const CACHE_VERSION = 4;
31
+ const CACHE_VERSION = 5;
29
32
  const _metaCache = new Map();
33
+ const _detailCache = new Map();
34
+ const MAX_DETAIL_CACHE_ENTRIES = 6;
30
35
  function cacheDir(encoded) {
31
36
  const dataDir = resolvePaths().dataDir;
32
37
  const currentRoot = path.join(dataDir, 'ecweb-cache');
@@ -58,6 +63,16 @@ function writeDiskCache(encoded, id, mtime, size, meta) {
58
63
  }
59
64
  catch { }
60
65
  }
66
+ function cacheTranscriptDetail(file, mtime, size, detail) {
67
+ _detailCache.delete(file);
68
+ _detailCache.set(file, { mtime, size, detail });
69
+ while (_detailCache.size > MAX_DETAIL_CACHE_ENTRIES) {
70
+ const oldest = _detailCache.keys().next().value;
71
+ if (!oldest)
72
+ break;
73
+ _detailCache.delete(oldest);
74
+ }
75
+ }
61
76
  function pruneCacheFiles(encoded, validIds) {
62
77
  const dir = cacheDir(encoded);
63
78
  try {
@@ -120,7 +135,8 @@ function extractMeta(file, id, stat) {
120
135
  raw = fs.readFileSync(file, 'utf-8');
121
136
  }
122
137
  catch { }
123
- let title = '', firstUser = '', gitBranch = '', version = '', userMsgs = 0, totalMsgs = 0;
138
+ let title = '', firstUser = '', gitBranch = '', version = '', entrypoint = '', userMsgs = 0, totalMsgs = 0;
139
+ let hasRealCliMessage = false, internalSubagent = id.startsWith('agent-');
124
140
  for (const line of raw.split('\n')) {
125
141
  if (!line)
126
142
  continue;
@@ -140,8 +156,20 @@ function extractMeta(file, id, stat) {
140
156
  totalMsgs++;
141
157
  else if (isUser) {
142
158
  totalMsgs++;
143
- if (o && isCountableUserMessage(o, id))
144
- userMsgs++;
159
+ if (o) {
160
+ if (isCountableUserMessage(o, id))
161
+ userMsgs++;
162
+ if (!entrypoint && typeof o.entrypoint === 'string')
163
+ entrypoint = o.entrypoint;
164
+ if (o.isSidechain || o.agentId)
165
+ internalSubagent = true;
166
+ const content = o.message?.content;
167
+ if (!isToolResultOnly(content)) {
168
+ const text = extractMessageText(content);
169
+ if (text.trim() && !isSyntheticCliPrompt(text, 'claude'))
170
+ hasRealCliMessage = true;
171
+ }
172
+ }
145
173
  }
146
174
  if ((!gitBranch || !version || !firstUser) && (line.indexOf('gitBranch') !== -1 || line.indexOf('"version"') !== -1 || line.indexOf('ai-title') !== -1 || (isUser && !firstUser))) {
147
175
  if (!o) {
@@ -165,7 +193,12 @@ function extractMeta(file, id, stat) {
165
193
  }
166
194
  }
167
195
  }
168
- return { id, title, firstUser, gitBranch, version, userMsgs, totalMsgs, sizeKB: Math.round(stat.size / 1024), lastActivity: stat.mtimeMs };
196
+ return {
197
+ id, title, firstUser, gitBranch, version, userMsgs, totalMsgs,
198
+ sizeKB: Math.round(stat.size / 1024),
199
+ lastActivity: stat.mtimeMs,
200
+ cliCandidate: !internalSubagent && (entrypoint === 'cli' || entrypoint === 'sdk-cli') && hasRealCliMessage,
201
+ };
169
202
  }
170
203
  function listTranscripts(encoded) {
171
204
  const dir = path.join(ccProjectsDir(), encoded);
@@ -293,25 +326,162 @@ function resolveProject(params, projects) {
293
326
  return projects[0] || null;
294
327
  }
295
328
  export function buildBindMap() {
296
- const map = new Map();
329
+ return buildSessionBindMap();
330
+ }
331
+ // ── EvolCore runner 子任务事件 ──
332
+ const RUNNER_TASK_EVENT_TYPES = new Set([
333
+ 'runner:task-started',
334
+ 'runner:task-progress',
335
+ 'runner:task-notification',
336
+ ]);
337
+ const EVENT_LOG_RE = /^events(?:-.+)?\.log$/;
338
+ const _runnerLogCache = new Map();
339
+ function parseRunnerTaskLines(raw) {
340
+ const events = [];
341
+ for (const line of raw.split(/\r?\n/)) {
342
+ if (!line.includes('"type":"runner:task-'))
343
+ continue;
344
+ try {
345
+ const event = JSON.parse(line);
346
+ if (RUNNER_TASK_EVENT_TYPES.has(event.type) && event.sessionId)
347
+ events.push(event);
348
+ }
349
+ catch { }
350
+ }
351
+ return events;
352
+ }
353
+ function readRunnerLogFile(file) {
354
+ let stat;
297
355
  try {
298
- const p = resolvePaths();
299
- for (const dir of scanChatDirs(p.sessionsDir)) {
300
- const active = readJsonFile(path.join(dir.dirPath, 'active.json'));
301
- if (!active || !active.agentSessionId)
302
- continue;
303
- map.set(active.agentSessionId, {
304
- channelType: active.channelType || dir.channelType,
305
- channelId: active.channelId || dir.channelId,
306
- selfAID: active.selfAID || dir.selfAID,
307
- peerName: (active.metadata && active.metadata.peerName) || null,
308
- name: active.name,
309
- updatedAt: active.updatedAt || 0,
310
- });
356
+ stat = fs.statSync(file);
357
+ }
358
+ catch {
359
+ _runnerLogCache.delete(file);
360
+ return [];
361
+ }
362
+ let cached = _runnerLogCache.get(file);
363
+ if (!cached || stat.size < cached.offset)
364
+ cached = { offset: 0, events: [] };
365
+ if (stat.size === cached.offset) {
366
+ _runnerLogCache.set(file, cached);
367
+ return cached.events;
368
+ }
369
+ const bytes = stat.size - cached.offset;
370
+ const buffer = Buffer.allocUnsafe(bytes);
371
+ let fd = null;
372
+ try {
373
+ fd = fs.openSync(file, 'r');
374
+ fs.readSync(fd, buffer, 0, bytes, cached.offset);
375
+ }
376
+ catch {
377
+ return cached.events;
378
+ }
379
+ finally {
380
+ if (fd != null)
381
+ try {
382
+ fs.closeSync(fd);
383
+ }
384
+ catch { }
385
+ }
386
+ // 只推进到最后一个完整 JSONL 行,避免 watcher 在写入中途触发时丢事件。
387
+ const lastBreak = buffer.lastIndexOf(0x0a);
388
+ if (lastBreak < 0) {
389
+ _runnerLogCache.set(file, cached);
390
+ return cached.events;
391
+ }
392
+ const parsed = parseRunnerTaskLines(buffer.subarray(0, lastBreak + 1).toString('utf8'));
393
+ cached = {
394
+ offset: cached.offset + lastBreak + 1,
395
+ events: cached.events.concat(parsed),
396
+ };
397
+ _runnerLogCache.set(file, cached);
398
+ return cached.events;
399
+ }
400
+ function runnerEventTimestamp(event) {
401
+ const timestamp = Number(event.timestamp);
402
+ if (Number.isFinite(timestamp) && timestamp > 0)
403
+ return timestamp;
404
+ const parsed = event.ts ? Date.parse(event.ts) : 0;
405
+ return Number.isFinite(parsed) ? parsed : 0;
406
+ }
407
+ export function readRunnerTaskTurns(logsDir, evolSessionId) {
408
+ let files;
409
+ try {
410
+ files = fs.readdirSync(logsDir)
411
+ .filter(name => EVENT_LOG_RE.test(name))
412
+ .sort()
413
+ .map(name => path.join(logsDir, name));
414
+ }
415
+ catch {
416
+ return [];
417
+ }
418
+ const validFiles = new Set(files);
419
+ for (const cachedFile of _runnerLogCache.keys()) {
420
+ if (path.dirname(cachedFile) === logsDir && !validFiles.has(cachedFile))
421
+ _runnerLogCache.delete(cachedFile);
422
+ }
423
+ const events = files
424
+ .flatMap(readRunnerLogFile)
425
+ .filter(event => event.sessionId === evolSessionId)
426
+ .sort((a, b) => runnerEventTimestamp(a) - runnerEventTimestamp(b));
427
+ const tasks = new Map();
428
+ for (const event of events) {
429
+ const taskId = event.taskId || event.toolUseId;
430
+ if (!taskId)
431
+ continue;
432
+ const at = runnerEventTimestamp(event);
433
+ let task = tasks.get(taskId);
434
+ if (!task) {
435
+ task = {
436
+ taskId,
437
+ taskKind: event.taskKind || 'agent',
438
+ toolUseId: event.toolUseId || null,
439
+ description: event.description || taskId,
440
+ subagentType: event.subagentType || null,
441
+ status: 'running',
442
+ terminalReason: '',
443
+ progress: '',
444
+ lastToolName: '',
445
+ totalTokens: 0,
446
+ toolUses: 0,
447
+ durationMs: 0,
448
+ startedAt: at,
449
+ updatedAt: at,
450
+ };
451
+ tasks.set(taskId, task);
311
452
  }
453
+ if (event.type === 'runner:task-started') {
454
+ task.startedAt = task.startedAt ? Math.min(task.startedAt, at) : at;
455
+ task.status = 'running';
456
+ }
457
+ else if (event.type === 'runner:task-progress') {
458
+ task.progress = event.summary || event.description || task.progress;
459
+ }
460
+ else if (event.type === 'runner:task-notification') {
461
+ task.status = event.status || 'completed';
462
+ task.terminalReason = event.terminalReason || task.terminalReason;
463
+ }
464
+ task.updatedAt = Math.max(task.updatedAt || 0, at);
465
+ task.taskKind = event.taskKind || task.taskKind;
466
+ task.toolUseId = event.toolUseId || task.toolUseId;
467
+ task.description = event.type === 'runner:task-progress'
468
+ ? task.description
469
+ : (event.description || task.description);
470
+ task.subagentType = event.subagentType || task.subagentType;
471
+ task.lastToolName = event.lastToolName || task.lastToolName;
472
+ task.totalTokens = event.usage?.totalTokens ?? event.totalTokens ?? task.totalTokens;
473
+ task.toolUses = event.usage?.toolUses ?? event.toolUses ?? task.toolUses;
474
+ task.durationMs = event.usage?.durationMs ?? event.durationMs ?? task.durationMs;
312
475
  }
313
- catch { }
314
- return map;
476
+ return Array.from(tasks.values())
477
+ .sort((a, b) => a.startedAt - b.startedAt)
478
+ .map(task => ({
479
+ role: 'system',
480
+ ts: task.startedAt,
481
+ uuid: `runner-task:${evolSessionId}:${task.taskId}`,
482
+ category: 'subtask',
483
+ blocks: [{ kind: 'subtask', ...task }],
484
+ }));
315
485
  }
316
486
  // ── 完整 transcript 解析(省略 tool 参数/chat send 检测等细节,保留费用计算)──
317
487
  const PRICING = {
@@ -329,8 +499,23 @@ function pricingFor(model) {
329
499
  return PRICING['claude-haiku-4-5-20251001'];
330
500
  return PRICING['claude-opus-4-8'];
331
501
  }
332
- function readTranscriptFile(file) {
502
+ function readTranscriptFile(file, readStats) {
333
503
  const empty = { turns: [], totalTurns: 0, userMsgs: 0, totalMsgs: 0, counts: { userInput: 0, modelOutput: 0, toolCall: 0, toolResult: 0, msgSend: 0 }, inputTokens: 0, outputTokens: 0, contextTokens: 0, costUsd: 0, model: '', gitBranch: '', version: '', title: '', cwd: '' };
504
+ let stat;
505
+ try {
506
+ stat = fs.statSync(file);
507
+ }
508
+ catch {
509
+ return empty;
510
+ }
511
+ const cached = _detailCache.get(file);
512
+ if (cached && cached.mtime === stat.mtimeMs && cached.size === stat.size) {
513
+ _detailCache.delete(file);
514
+ _detailCache.set(file, cached);
515
+ if (readStats)
516
+ readStats.detailCacheHit = true;
517
+ return cached.detail;
518
+ }
334
519
  let raw;
335
520
  try {
336
521
  raw = fs.readFileSync(file, 'utf-8');
@@ -418,32 +603,90 @@ function readTranscriptFile(file) {
418
603
  else {
419
604
  category = hasToolUse ? 'tool_call' : 'model_output';
420
605
  }
606
+ // Claude can emit assistant events that contain usage metadata but no visible blocks.
607
+ // They are not useful transcript entries and previously rendered as empty model output.
608
+ if (o.type === 'assistant' && blocks.length === 0)
609
+ continue;
421
610
  counts[category === 'user_input' ? 'userInput' : category === 'model_output' ? 'modelOutput' : category === 'tool_call' ? 'toolCall' : 'toolResult']++;
422
611
  turns.push({ role: o.type, ts: o.timestamp ? Date.parse(o.timestamp) : 0, uuid: o.uuid, category, blocks });
423
612
  }
424
613
  }
425
- const shown = turns.length > 500 ? turns.slice(-500) : turns;
426
- return { turns: shown, totalTurns: turns.length, userMsgs, totalMsgs, counts, inputTokens: inTok, outputTokens: outTok, contextTokens, costUsd, model, gitBranch: branch, version, title, cwd };
614
+ const detail = { turns, totalTurns: turns.length, userMsgs, totalMsgs, counts, inputTokens: inTok, outputTokens: outTok, contextTokens, costUsd, model, gitBranch: branch, version, title, cwd };
615
+ cacheTranscriptDetail(file, stat.mtimeMs, stat.size, detail);
616
+ return detail;
427
617
  }
428
- function buildSnapshot(params) {
618
+ export function readClaudeSessionResult(params) {
429
619
  const projects = listProjects();
620
+ const project = resolveProject(params, projects);
621
+ const sessionId = typeof params.sessionId === 'string' ? params.sessionId : '';
622
+ const key = parseTranscriptResultKey(params.resultKey);
623
+ if (!project || !sessionId || !key || !/^[A-Za-z0-9-]+$/.test(sessionId))
624
+ return null;
625
+ const file = path.join(ccProjectsDir(), project.encoded, `${sessionId}.jsonl`);
626
+ if (!file.startsWith(`${path.join(ccProjectsDir(), project.encoded)}${path.sep}`))
627
+ return null;
628
+ const detail = readTranscriptFile(file);
629
+ const bind = buildBindMap().get(sessionId);
630
+ const taskTurns = bind?.evolSessionId ? readRunnerTaskTurns(resolvePaths().logs, bind.evolSessionId) : [];
631
+ const turns = detail.turns.concat(taskTurns).sort((a, b) => (a.ts || 0) - (b.ts || 0));
632
+ const block = turns[key[0]]?.blocks?.[key[1]];
633
+ return block?.kind === 'tool_result' && typeof block.text === 'string' ? block.text : null;
634
+ }
635
+ async function buildSnapshot(params) {
636
+ const startedAt = Date.now();
637
+ const metrics = {
638
+ baseagent: 'claude',
639
+ projectMs: 0,
640
+ bindingsMs: 0,
641
+ transcriptListMs: 0,
642
+ detailMs: 0,
643
+ totalMs: 0,
644
+ detailCacheHit: false,
645
+ };
646
+ const projectStartedAt = Date.now();
647
+ const projects = listProjects();
648
+ metrics.projectMs = Date.now() - projectStartedAt;
649
+ const bindingsStartedAt = Date.now();
430
650
  const bindMap = buildBindMap();
651
+ metrics.bindingsMs = Date.now() - bindingsStartedAt;
431
652
  const project = resolveProject(params, projects);
432
- if (!project)
433
- return { baseagent: 'claude', projects: [], project: null, transcripts: [], turns: [], sessionId: null };
653
+ if (!project) {
654
+ metrics.totalMs = Date.now() - startedAt;
655
+ return { baseagent: 'claude', projects: [], project: null, transcripts: [], turns: [], sessionId: null, metrics };
656
+ }
657
+ const transcriptListStartedAt = Date.now();
434
658
  const metas = listTranscripts(project.encoded);
659
+ metrics.transcriptListMs = Date.now() - transcriptListStartedAt;
660
+ const selfNames = await getAgentSelfNames();
435
661
  const transcripts = metas.map(m => {
436
662
  const bind = bindMap.get(m.id);
437
- return { ...m, bound: !!bind, boundChannel: bind?.channelType ?? null, boundPeer: bind ? (bind.peerName || bind.channelId) : null, online: bind ? (Date.now() - bind.updatedAt < 5 * 60 * 1000) : false };
663
+ return {
664
+ ...m,
665
+ sourceType: classifyTranscriptSource(bind, m.cliCandidate),
666
+ bound: !!bind,
667
+ boundChannel: bind?.channelType ?? null,
668
+ boundSelf: resolveSessionBindSelfName(bind, selfNames),
669
+ online: bind ? (Date.now() - bind.updatedAt < 5 * 60 * 1000) : false,
670
+ };
438
671
  });
439
672
  const projList = projects.map(p => ({ encoded: p.encoded, label: p.label, cwd: p.cwd, count: p.count }));
440
673
  const sessionId = params.sessionId || null;
441
- if (!sessionId)
442
- return { baseagent: 'claude', projects: projList, project: project.encoded, transcripts, turns: [], sessionId: null };
443
- const detail = readTranscriptFile(path.join(ccProjectsDir(), project.encoded, `${sessionId}.jsonl`));
674
+ if (!sessionId) {
675
+ metrics.totalMs = Date.now() - startedAt;
676
+ return { baseagent: 'claude', projects: projList, project: project.encoded, transcripts, turns: [], sessionId: null, metrics };
677
+ }
678
+ const detailStartedAt = Date.now();
679
+ const detail = readTranscriptFile(path.join(ccProjectsDir(), project.encoded, `${sessionId}.jsonl`), metrics);
680
+ metrics.detailMs = Date.now() - detailStartedAt;
444
681
  const bind = bindMap.get(sessionId);
445
- const header = { sessionId, title: detail.title, model: detail.model, gitBranch: detail.gitBranch, version: detail.version, cwd: detail.cwd || project.cwd, totalTurns: detail.totalTurns, userMsgs: detail.userMsgs, totalMsgs: detail.totalMsgs, counts: detail.counts, inputTokens: detail.inputTokens, outputTokens: detail.outputTokens, contextTokens: detail.contextTokens, costUsd: detail.costUsd, bound: !!bind, boundChannel: bind?.channelType ?? null, boundPeer: bind ? (bind.peerName || bind.channelId) : null, online: bind ? (Date.now() - bind.updatedAt < 5 * 60 * 1000) : false };
446
- return { baseagent: 'claude', projects: projList, project: project.encoded, transcripts, turns: detail.turns, sessionId, header };
682
+ const taskTurns = bind?.evolSessionId ? readRunnerTaskTurns(resolvePaths().logs, bind.evolSessionId) : [];
683
+ const turns = detail.turns.concat(taskTurns).sort((a, b) => (a.ts || 0) - (b.ts || 0));
684
+ const page = windowTranscriptTurns(turns, params.turnLimit, params.turnBefore);
685
+ const counts = { ...detail.counts, subtask: taskTurns.length };
686
+ const transcript = metas.find(meta => meta.id === sessionId);
687
+ const header = { sessionId, title: detail.title, model: detail.model, gitBranch: detail.gitBranch, version: detail.version, cwd: detail.cwd || project.cwd, totalTurns: detail.totalTurns, userMsgs: detail.userMsgs, totalMsgs: detail.totalMsgs, counts, inputTokens: detail.inputTokens, outputTokens: detail.outputTokens, contextTokens: detail.contextTokens, costUsd: detail.costUsd, sourceType: classifyTranscriptSource(bind, transcript?.cliCandidate === true), bound: !!bind, boundChannel: bind?.channelType ?? null, boundSelf: resolveSessionBindSelfName(bind, selfNames), online: bind ? (Date.now() - bind.updatedAt < 5 * 60 * 1000) : false };
688
+ metrics.totalMs = Date.now() - startedAt;
689
+ return { baseagent: 'claude', projects: projList, project: project.encoded, transcripts, turns: previewLargeToolResults(page.turns, page.turnPage.start), turnPage: page.turnPage, sessionId, header, metrics };
447
690
  }
448
691
  export const sessionSource = {
449
692
  kind: 'session',
@@ -460,12 +703,12 @@ export const sessionSource = {
460
703
  return subscribeCodex(params, push);
461
704
  }
462
705
  const p = resolvePaths();
463
- let projectWatcher = null, sessionWatcher = null, debounce = null;
464
- const fire = () => { if (debounce)
465
- clearTimeout(debounce); debounce = setTimeout(() => { try {
466
- push(buildSnapshot(params));
467
- }
468
- catch { } }, 150); };
706
+ let projectWatcher = null, sessionWatcher = null, logWatcher = null, debounce = null;
707
+ const fire = () => {
708
+ if (debounce)
709
+ clearTimeout(debounce);
710
+ debounce = setTimeout(() => { void buildSnapshot(params).then(push).catch(() => { }); }, 300);
711
+ };
469
712
  const projects = listProjects();
470
713
  const project = resolveProject(params, projects);
471
714
  if (project) {
@@ -481,9 +724,17 @@ export const sessionSource = {
481
724
  fire(); });
482
725
  }
483
726
  catch { }
727
+ if (params.sessionId) {
728
+ try {
729
+ logWatcher = fs.watch(p.logs, (_evt, filename) => { if (!filename || EVENT_LOG_RE.test(String(filename)))
730
+ fire(); });
731
+ }
732
+ catch { }
733
+ }
484
734
  return () => { if (projectWatcher)
485
735
  projectWatcher.close(); if (sessionWatcher)
486
- sessionWatcher.close(); if (debounce)
736
+ sessionWatcher.close(); if (logWatcher)
737
+ logWatcher.close(); if (debounce)
487
738
  clearTimeout(debounce); };
488
739
  },
489
740
  };
@@ -0,0 +1,35 @@
1
+ export function windowTranscriptTurns(turns, requestedLimit, requestedBefore, defaultLimit = 100, maxLimit = 200) {
2
+ const requested = Number(requestedLimit);
3
+ const limit = Number.isFinite(requested)
4
+ ? Math.min(Math.max(Math.trunc(requested), 20), maxLimit)
5
+ : defaultLimit;
6
+ const beforeValue = requestedBefore == null ? Number.NaN : Number(requestedBefore);
7
+ const before = Number.isFinite(beforeValue) && beforeValue >= 0 ? Math.trunc(beforeValue) : null;
8
+ const end = before == null ? turns.length : Math.min(Math.max(before, 0), turns.length);
9
+ const start = Math.max(0, end - limit);
10
+ return {
11
+ turns: turns.slice(start, end),
12
+ turnPage: { limit, before, nextBefore: start > 0 ? start : null, hasMore: start > 0, start },
13
+ };
14
+ }
15
+ export function previewLargeToolResults(turns, start, previewChars = 4_000) {
16
+ return turns.map((turn, turnOffset) => ({
17
+ ...turn,
18
+ blocks: (turn.blocks || []).map((block, blockIndex) => {
19
+ if (block.kind !== 'tool_result' || typeof block.text !== 'string' || block.text.length <= previewChars)
20
+ return block;
21
+ return {
22
+ ...block,
23
+ text: block.text.slice(0, previewChars),
24
+ resultKey: `${start + turnOffset}:${blockIndex}`,
25
+ truncated: true,
26
+ };
27
+ }),
28
+ }));
29
+ }
30
+ export function parseTranscriptResultKey(value) {
31
+ const match = /^(\d+):(\d+)$/.exec(String(value || ''));
32
+ if (!match)
33
+ return null;
34
+ return [Number(match[1]), Number(match[2])];
35
+ }