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.
@@ -12,7 +12,10 @@ import path from 'path';
12
12
  import os from 'os';
13
13
  import { createRequire } from 'module';
14
14
  import { resolvePaths } from '../paths.js';
15
- import { encodePath, scanChatDirs, readJsonFile } from '../fs-utils.js';
15
+ import { encodePath } from '../fs-utils.js';
16
+ import { getAgentSelfNames } from './msg.js';
17
+ import { parseTranscriptResultKey, previewLargeToolResults, windowTranscriptTurns } from './transcript-window.js';
18
+ import { buildSessionBindMap, classifyTranscriptSource, isEvolcoreRoutedMessage, isSyntheticCliPrompt, resolveSessionBindSelfName, } from './session-origin.js';
16
19
  const requireFromHere = createRequire(import.meta.url);
17
20
  let _dlog = null;
18
21
  export function setDebugLog(log) { _dlog = log; }
@@ -107,8 +110,10 @@ function listProjects() {
107
110
  }
108
111
  }
109
112
  // ── 会话列表 ──
110
- const CACHE_VERSION = 1;
113
+ const CACHE_VERSION = 2;
111
114
  const _metaCache = new Map();
115
+ const _detailCache = new Map();
116
+ const MAX_DETAIL_CACHE_ENTRIES = 6;
112
117
  function cacheDir(encoded) {
113
118
  const dataDir = resolvePaths().dataDir;
114
119
  return path.join(dataDir, 'ecweb-cache', 'codex', encoded);
@@ -132,6 +137,16 @@ function writeDiskCache(encoded, id, mtime, size, meta) {
132
137
  }
133
138
  catch { }
134
139
  }
140
+ function cacheTranscriptDetail(file, mtime, size, detail) {
141
+ _detailCache.delete(file);
142
+ _detailCache.set(file, { mtime, size, detail });
143
+ while (_detailCache.size > MAX_DETAIL_CACHE_ENTRIES) {
144
+ const oldest = _detailCache.keys().next().value;
145
+ if (!oldest)
146
+ break;
147
+ _detailCache.delete(oldest);
148
+ }
149
+ }
135
150
  function pruneCacheFiles(encoded, validIds) {
136
151
  const dir = cacheDir(encoded);
137
152
  try {
@@ -170,6 +185,7 @@ function extractMetaFromRollout(file, id) {
170
185
  }
171
186
  catch { }
172
187
  let title = '', firstUser = '', gitBranch = '', version = '', userMsgs = 0, totalMsgs = 0;
188
+ let hasRealUserInput = false;
173
189
  const lines = raw.split('\n');
174
190
  for (const line of lines) {
175
191
  if (!line.trim())
@@ -187,6 +203,8 @@ function extractMetaFromRollout(file, id) {
187
203
  if (event.type === 'event_msg' && event.payload?.type === 'user_message' && event.payload.message) {
188
204
  totalMsgs++;
189
205
  userMsgs++;
206
+ if (!isSyntheticCliPrompt(event.payload.message, 'codex'))
207
+ hasRealUserInput = true;
190
208
  if (!firstUser) {
191
209
  const text = event.payload.message.trim().replace(/\s+/g, ' ');
192
210
  firstUser = text.substring(0, 120);
@@ -216,15 +234,34 @@ function extractMetaFromRollout(file, id) {
216
234
  totalMsgs,
217
235
  sizeKB: Math.round(stat.size / 1024),
218
236
  lastActivity: stat.mtimeMs,
237
+ hasRealUserInput,
238
+ backendSource: '',
239
+ threadSource: '',
240
+ evolcoreRouted: false,
219
241
  };
220
242
  }
243
+ function withThreadSource(meta, row) {
244
+ return {
245
+ ...meta,
246
+ backendSource: typeof row.source === 'string' ? row.source : '',
247
+ threadSource: typeof row.thread_source === 'string' ? row.thread_source : '',
248
+ evolcoreRouted: isEvolcoreRoutedMessage(row.first_user_message),
249
+ };
250
+ }
251
+ function isCodexCliCandidate(meta) {
252
+ if (meta.threadSource === 'subagent')
253
+ return false;
254
+ if (meta.backendSource !== 'cli' && meta.backendSource !== 'vscode')
255
+ return false;
256
+ return meta.hasRealUserInput && !meta.evolcoreRouted;
257
+ }
221
258
  function listTranscripts(encoded, cwd) {
222
259
  const db = getDb();
223
260
  if (!db)
224
261
  return [];
225
262
  try {
226
263
  const rows = db.prepare(`
227
- SELECT id, title, first_user_message, updated_at, rollout_path
264
+ SELECT id, title, first_user_message, updated_at, rollout_path, source, thread_source
228
265
  FROM threads
229
266
  WHERE cwd = ? AND archived = 0
230
267
  ORDER BY updated_at DESC
@@ -248,6 +285,10 @@ function listTranscripts(encoded, cwd) {
248
285
  totalMsgs: 0,
249
286
  sizeKB: 0,
250
287
  lastActivity: (row.updated_at || 0) * 1000,
288
+ hasRealUserInput: !!firstUser && !isSyntheticCliPrompt(firstUser, 'codex'),
289
+ backendSource: typeof row.source === 'string' ? row.source : '',
290
+ threadSource: typeof row.thread_source === 'string' ? row.thread_source : '',
291
+ evolcoreRouted: isEvolcoreRoutedMessage(row.first_user_message),
251
292
  });
252
293
  continue;
253
294
  }
@@ -261,21 +302,21 @@ function listTranscripts(encoded, cwd) {
261
302
  const mtime = stat.mtimeMs;
262
303
  const mem = _metaCache.get(rolloutPath);
263
304
  if (mem && mem.mtime === mtime) {
264
- out.push(mem.meta);
305
+ out.push(withThreadSource(mem.meta, row));
265
306
  hitMem++;
266
307
  continue;
267
308
  }
268
309
  const disk = readDiskCache(encoded, id, mtime, stat.size);
269
310
  if (disk) {
270
311
  _metaCache.set(rolloutPath, { mtime, meta: disk });
271
- out.push(disk);
312
+ out.push(withThreadSource(disk, row));
272
313
  hitDisk++;
273
314
  continue;
274
315
  }
275
316
  const meta = extractMetaFromRollout(rolloutPath, id);
276
317
  _metaCache.set(rolloutPath, { mtime, meta });
277
318
  writeDiskCache(encoded, id, mtime, stat.size, meta);
278
- out.push(meta);
319
+ out.push(withThreadSource(meta, row));
279
320
  parsed++;
280
321
  }
281
322
  dlog(`[codex] listTranscripts ${cwd.slice(-20)}: ${rows.length} threads (mem=${hitMem} disk=${hitDisk} parsed=${parsed})`);
@@ -288,25 +329,7 @@ function listTranscripts(encoded, cwd) {
288
329
  }
289
330
  // ── 绑定关系 ──
290
331
  export function buildBindMap() {
291
- const map = new Map();
292
- try {
293
- const p = resolvePaths();
294
- for (const dir of scanChatDirs(p.sessionsDir)) {
295
- const active = readJsonFile(path.join(dir.dirPath, 'active.json'));
296
- if (!active || !active.agentSessionId)
297
- continue;
298
- map.set(active.agentSessionId, {
299
- channelType: active.channelType || dir.channelType,
300
- channelId: active.channelId || dir.channelId,
301
- selfAID: active.selfAID || dir.selfAID,
302
- peerName: (active.metadata && active.metadata.peerName) || null,
303
- name: active.name,
304
- updatedAt: active.updatedAt || 0,
305
- });
306
- }
307
- }
308
- catch { }
309
- return map;
332
+ return buildSessionBindMap();
310
333
  }
311
334
  // ── 项目解析 ──
312
335
  function resolveProject(params, projects) {
@@ -452,7 +475,7 @@ export function parseCodexToolEvent(event) {
452
475
  }
453
476
  return null;
454
477
  }
455
- function readTranscriptFile(threadId, cwd) {
478
+ function readTranscriptFile(threadId, cwd, readStats) {
456
479
  const empty = {
457
480
  turns: [],
458
481
  totalTurns: 0,
@@ -485,6 +508,21 @@ function readTranscriptFile(threadId, cwd) {
485
508
  }
486
509
  if (!fs.existsSync(rolloutPath))
487
510
  return empty;
511
+ let stat;
512
+ try {
513
+ stat = fs.statSync(rolloutPath);
514
+ }
515
+ catch {
516
+ return empty;
517
+ }
518
+ const cached = _detailCache.get(rolloutPath);
519
+ if (cached && cached.mtime === stat.mtimeMs && cached.size === stat.size) {
520
+ _detailCache.delete(rolloutPath);
521
+ _detailCache.set(rolloutPath, cached);
522
+ if (readStats)
523
+ readStats.detailCacheHit = true;
524
+ return { ...cached.detail, title: empty.title || cached.detail.title, cwd };
525
+ }
488
526
  let raw;
489
527
  try {
490
528
  raw = fs.readFileSync(rolloutPath, 'utf-8');
@@ -592,9 +630,8 @@ function readTranscriptFile(threadId, cwd) {
592
630
  }
593
631
  }
594
632
  }
595
- const shown = turns.length > 500 ? turns.slice(-500) : turns;
596
- return {
597
- turns: shown,
633
+ const detail = {
634
+ turns,
598
635
  totalTurns: turns.length,
599
636
  userMsgs,
600
637
  totalMsgs,
@@ -609,13 +646,41 @@ function readTranscriptFile(threadId, cwd) {
609
646
  title: empty.title,
610
647
  cwd,
611
648
  };
649
+ cacheTranscriptDetail(rolloutPath, stat.mtimeMs, stat.size, detail);
650
+ return detail;
651
+ }
652
+ export function readCodexSessionResult(params) {
653
+ const projects = listProjects();
654
+ const project = resolveProject(params, projects);
655
+ const sessionId = typeof params.sessionId === 'string' ? params.sessionId : '';
656
+ const key = parseTranscriptResultKey(params.resultKey);
657
+ if (!project || !sessionId || !key || !/^[A-Za-z0-9-]+$/.test(sessionId))
658
+ return null;
659
+ const detail = readTranscriptFile(sessionId, project.cwd);
660
+ const block = detail.turns[key[0]]?.blocks?.[key[1]];
661
+ return block?.kind === 'tool_result' && typeof block.text === 'string' ? block.text : null;
612
662
  }
613
663
  // ── Snapshot ──
614
- function buildSnapshot(params) {
664
+ async function buildSnapshot(params) {
665
+ const startedAt = Date.now();
666
+ const metrics = {
667
+ baseagent: 'codex',
668
+ projectMs: 0,
669
+ bindingsMs: 0,
670
+ transcriptListMs: 0,
671
+ detailMs: 0,
672
+ totalMs: 0,
673
+ detailCacheHit: false,
674
+ };
675
+ const projectStartedAt = Date.now();
615
676
  const projects = listProjects();
677
+ metrics.projectMs = Date.now() - projectStartedAt;
678
+ const bindingsStartedAt = Date.now();
616
679
  const bindMap = buildBindMap();
680
+ metrics.bindingsMs = Date.now() - bindingsStartedAt;
617
681
  const project = resolveProject(params, projects);
618
682
  if (!project) {
683
+ metrics.totalMs = Date.now() - startedAt;
619
684
  return {
620
685
  baseagent: 'codex',
621
686
  projects: [],
@@ -623,16 +688,22 @@ function buildSnapshot(params) {
623
688
  transcripts: [],
624
689
  turns: [],
625
690
  sessionId: null,
691
+ metrics,
626
692
  };
627
693
  }
694
+ const transcriptListStartedAt = Date.now();
628
695
  const metas = listTranscripts(project.encoded, project.cwd);
696
+ metrics.transcriptListMs = Date.now() - transcriptListStartedAt;
697
+ const selfNames = await getAgentSelfNames();
629
698
  const transcripts = metas.map(m => {
630
699
  const bind = bindMap.get(m.id);
700
+ metrics.totalMs = Date.now() - startedAt;
631
701
  return {
632
702
  ...m,
703
+ sourceType: classifyTranscriptSource(bind, isCodexCliCandidate(m)),
633
704
  bound: !!bind,
634
705
  boundChannel: bind?.channelType ?? null,
635
- boundPeer: bind ? (bind.peerName || bind.channelId) : null,
706
+ boundSelf: resolveSessionBindSelfName(bind, selfNames),
636
707
  online: bind ? (Date.now() - bind.updatedAt < 5 * 60 * 1000) : false,
637
708
  };
638
709
  });
@@ -651,10 +722,15 @@ function buildSnapshot(params) {
651
722
  transcripts,
652
723
  turns: [],
653
724
  sessionId: null,
725
+ metrics,
654
726
  };
655
727
  }
656
- const detail = readTranscriptFile(sessionId, project.cwd);
728
+ const detailStartedAt = Date.now();
729
+ const detail = readTranscriptFile(sessionId, project.cwd, metrics);
730
+ metrics.detailMs = Date.now() - detailStartedAt;
731
+ const page = windowTranscriptTurns(detail.turns, params.turnLimit, params.turnBefore);
657
732
  const bind = bindMap.get(sessionId);
733
+ const transcript = metas.find(meta => meta.id === sessionId);
658
734
  const header = {
659
735
  sessionId,
660
736
  title: detail.title,
@@ -670,19 +746,23 @@ function buildSnapshot(params) {
670
746
  outputTokens: detail.outputTokens,
671
747
  contextTokens: detail.contextTokens,
672
748
  costUsd: detail.costUsd,
749
+ sourceType: classifyTranscriptSource(bind, transcript ? isCodexCliCandidate(transcript) : false),
673
750
  bound: !!bind,
674
751
  boundChannel: bind?.channelType ?? null,
675
- boundPeer: bind ? (bind.peerName || bind.channelId) : null,
752
+ boundSelf: resolveSessionBindSelfName(bind, selfNames),
676
753
  online: bind ? (Date.now() - bind.updatedAt < 5 * 60 * 1000) : false,
677
754
  };
755
+ metrics.totalMs = Date.now() - startedAt;
678
756
  return {
679
757
  baseagent: 'codex',
680
758
  projects: projList,
681
759
  project: project.encoded,
682
760
  transcripts,
683
- turns: detail.turns,
761
+ turns: previewLargeToolResults(page.turns, page.turnPage.start),
762
+ turnPage: page.turnPage,
684
763
  sessionId,
685
764
  header,
765
+ metrics,
686
766
  };
687
767
  }
688
768
  // ── Export ──
@@ -699,7 +779,7 @@ export function subscribeCodex(params, push) {
699
779
  clearTimeout(debounce);
700
780
  debounce = setTimeout(() => {
701
781
  try {
702
- push(buildSnapshot(params));
782
+ void buildSnapshot(params).then(push).catch(() => { });
703
783
  }
704
784
  catch { }
705
785
  }, 150);
@@ -0,0 +1,195 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { resolvePaths } from '../paths.js';
4
+ import { readJsonFile, scanChatDirs } from '../fs-utils.js';
5
+ /** Resolve the local Agent's display name for a session binding. */
6
+ export function resolveSessionBindSelfName(bind, names) {
7
+ if (!bind?.selfAID)
8
+ return null;
9
+ return names.get(bind.selfAID) || bind.selfAID.split('.')[0] || bind.selfAID;
10
+ }
11
+ const bindingFileCache = new Map();
12
+ const SHARED_SYNTHETIC_CLI_PREFIXES = [
13
+ 'Use Bash exactly once to run:',
14
+ 'Use the Bash tool now to execute',
15
+ 'Review this change for security vulnerabilities',
16
+ 'You previously flagged these candidate vulnerabilities',
17
+ ];
18
+ const CLAUDE_SYNTHETIC_CLI_PREFIXES = ['Base directory for this skill:'];
19
+ const CLAUDE_SYNTHETIC_TAG_RE = /^<\/?(?:local-command|command)-[a-z]+>/i;
20
+ const CLAUDE_SYNTHETIC_SLASH_RE = /^\/(?:resume|clear|compact|model|init|review|cost|config|status|help|agents?|context|memory|export|hooks?|mcp|ide|login|logout|doctor|bug|release-notes|vim|terminal-setup|add-dir|permissions)(?:\s|$)/i;
21
+ const EVOLCORE_ROUTE_PREFIX_RE = /^‹[^›]*·\s*from:[^›]*→[^›]*self:[^›]*›\s*/;
22
+ function normalizePeerType(value) {
23
+ if (typeof value !== 'string' || !value.trim())
24
+ return undefined;
25
+ const key = value.trim().toLowerCase();
26
+ if (key === 'human' || key === 'person' || key === 'user')
27
+ return 'human';
28
+ if (key === 'system' || key === 'trigger' || key === 'scheduler')
29
+ return 'system';
30
+ if (key === 'service' || key === 'daemon' || key === 'tool')
31
+ return 'service';
32
+ if (key === 'unknown')
33
+ return 'unknown';
34
+ return 'agent';
35
+ }
36
+ function classifyBinding(session) {
37
+ const channelType = String(session.channelType || '').toLowerCase();
38
+ const channelId = String(session.channelId || '');
39
+ const threadId = String(session.threadId || '');
40
+ if (channelType === 'daemon') {
41
+ return channelId.startsWith('trigger:') || threadId.startsWith('trigger:') ? 'trigger' : 'unknown';
42
+ }
43
+ if (channelType === 'aun') {
44
+ if (session.chatType === 'group')
45
+ return 'user';
46
+ const peerKind = normalizePeerType(session.metadata?.peerType);
47
+ if (peerKind === 'agent')
48
+ return 'agent';
49
+ if (peerKind === 'human')
50
+ return 'user';
51
+ return 'unknown';
52
+ }
53
+ return channelType ? 'user' : 'unknown';
54
+ }
55
+ function toBinding(session, fallback, currentlyBound = false) {
56
+ if (!session.agentSessionId)
57
+ return null;
58
+ const channelType = session.channelType || fallback.channelType;
59
+ const channelId = session.channelId || fallback.channelId;
60
+ const chatType = session.chatType || 'private';
61
+ return {
62
+ evolSessionId: session.id || null,
63
+ channelType,
64
+ channelId,
65
+ channelKey: session.channel || session.metadata?.channelKey || channelType,
66
+ selfAID: session.selfAID || fallback.selfAID,
67
+ chatType,
68
+ peerId: session.metadata?.peerId || channelId,
69
+ peerName: session.metadata?.peerName || null,
70
+ peerType: session.metadata?.peerType || null,
71
+ ...(chatType === 'group' ? { groupId: session.metadata?.groupId || channelId } : {}),
72
+ groupName: session.metadata?.groupName || null,
73
+ displayName: null,
74
+ name: session.name || null,
75
+ threadId: session.threadId || '',
76
+ updatedAt: session.updatedAt || 0,
77
+ currentlyBound,
78
+ sourceType: classifyBinding({ ...session, channelType, channelId, chatType }),
79
+ };
80
+ }
81
+ function parseBindingJsonl(file, fallback) {
82
+ let stat;
83
+ try {
84
+ stat = fs.statSync(file);
85
+ }
86
+ catch {
87
+ bindingFileCache.delete(file);
88
+ return [];
89
+ }
90
+ const cached = bindingFileCache.get(file);
91
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size)
92
+ return cached.bindings;
93
+ const byAgentSessionId = new Map();
94
+ let finalAgentSessionId = null;
95
+ try {
96
+ const lines = fs.readFileSync(file, 'utf8').trimEnd().split(/\r?\n/);
97
+ for (const line of lines) {
98
+ if (!line.trim())
99
+ continue;
100
+ let session;
101
+ try {
102
+ session = JSON.parse(line);
103
+ }
104
+ catch {
105
+ continue;
106
+ }
107
+ const agentSessionId = session.agentSessionId;
108
+ finalAgentSessionId = agentSessionId || null;
109
+ if (!agentSessionId)
110
+ continue;
111
+ const binding = toBinding(session, fallback);
112
+ if (!binding)
113
+ continue;
114
+ const existing = byAgentSessionId.get(agentSessionId);
115
+ if (!existing || binding.updatedAt >= existing.updatedAt)
116
+ byAgentSessionId.set(agentSessionId, binding);
117
+ }
118
+ }
119
+ catch { }
120
+ const bindings = Array.from(byAgentSessionId, ([agentSessionId, binding]) => ({
121
+ agentSessionId,
122
+ binding: { ...binding, currentlyBound: agentSessionId === finalAgentSessionId },
123
+ }));
124
+ bindingFileCache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, bindings });
125
+ return bindings;
126
+ }
127
+ function bindingFiles(dirPath) {
128
+ const files = [];
129
+ for (const dir of [dirPath, path.join(dirPath, '_threads')]) {
130
+ let entries;
131
+ try {
132
+ entries = fs.readdirSync(dir, { withFileTypes: true });
133
+ }
134
+ catch {
135
+ continue;
136
+ }
137
+ for (const entry of entries) {
138
+ if (entry.isFile() && /^meta_.+\.jsonl$/.test(entry.name))
139
+ files.push(path.join(dir, entry.name));
140
+ }
141
+ }
142
+ return files;
143
+ }
144
+ export function buildSessionBindMap(sessionsDir = resolvePaths().sessionsDir) {
145
+ const map = new Map();
146
+ const liveFiles = new Set();
147
+ for (const dir of scanChatDirs(sessionsDir)) {
148
+ const fallback = { channelType: dir.channelType, channelId: dir.channelId, selfAID: dir.selfAID };
149
+ const active = readJsonFile(path.join(dir.dirPath, 'active.json'));
150
+ const activeBinding = active ? toBinding(active, fallback, true) : null;
151
+ if (activeBinding && active?.agentSessionId)
152
+ map.set(active.agentSessionId, activeBinding);
153
+ for (const file of bindingFiles(dir.dirPath)) {
154
+ liveFiles.add(file);
155
+ for (const { agentSessionId, binding } of parseBindingJsonl(file, fallback)) {
156
+ const existing = map.get(agentSessionId);
157
+ if (!existing
158
+ || (binding.currentlyBound && !existing.currentlyBound)
159
+ || binding.currentlyBound === existing.currentlyBound && binding.updatedAt >= existing.updatedAt) {
160
+ map.set(agentSessionId, binding);
161
+ }
162
+ }
163
+ }
164
+ }
165
+ for (const file of bindingFileCache.keys()) {
166
+ if (path.resolve(file).startsWith(`${path.resolve(sessionsDir)}${path.sep}`) && !liveFiles.has(file)) {
167
+ bindingFileCache.delete(file);
168
+ }
169
+ }
170
+ return map;
171
+ }
172
+ export function isSyntheticCliPrompt(text, backend) {
173
+ if (typeof text !== 'string' || !text.trim())
174
+ return false;
175
+ const trimmed = text.trimStart();
176
+ const prefixes = backend === 'claude'
177
+ ? SHARED_SYNTHETIC_CLI_PREFIXES.concat(CLAUDE_SYNTHETIC_CLI_PREFIXES)
178
+ : SHARED_SYNTHETIC_CLI_PREFIXES;
179
+ if (prefixes.some(prefix => trimmed.startsWith(prefix)))
180
+ return true;
181
+ return backend === 'claude'
182
+ && (CLAUDE_SYNTHETIC_TAG_RE.test(trimmed) || CLAUDE_SYNTHETIC_SLASH_RE.test(trimmed));
183
+ }
184
+ export function isEvolcoreRoutedMessage(text) {
185
+ return typeof text === 'string' && EVOLCORE_ROUTE_PREFIX_RE.test(text.trimStart());
186
+ }
187
+ export function classifyTranscriptSource(binding, cliCandidate) {
188
+ if (binding?.currentlyBound)
189
+ return binding.sourceType;
190
+ if (cliCandidate)
191
+ return 'cli';
192
+ if (binding)
193
+ return binding.sourceType;
194
+ return 'unknown';
195
+ }