ec-web 0.0.3 → 0.0.5

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.
@@ -4,12 +4,12 @@
4
4
  * 功能:
5
5
  * 1. 读取 roles.json 或内置角色配置
6
6
  * 2. 提供角色定义的 CRUD API
7
- * 3. WebSocket 推送角色配置变化
7
+ * 3. WebSocket 提供按需快照
8
8
  *
9
9
  * 数据流:
10
10
  * - 读取:从 evolcore/dist/config/roles.ts 导入 readRolesConfig
11
11
  * - 写入:保存到 .evolcore/roles.json
12
- * - 推送:2秒轮询 + JSON diff
12
+ * - 刷新:进入页面、切换 Agent 或完成写操作时按需加载
13
13
  */
14
14
  import { getModelCatalogSnapshot } from './models.js';
15
15
  import { resolveParentDistModule, toFileUrl } from './parent-package.js';
@@ -116,6 +116,11 @@ async function rolePolicyRevision(aid, auth) {
116
116
  throw Object.assign(new Error(response.error.message), { menuResponse: response });
117
117
  return response.data.policyRevision;
118
118
  }
119
+ export function visibleRoleDefinitionOptions(roleOptions) {
120
+ return roleOptions
121
+ .filter(item => item?.kind === 'user' || item?.value === 'admin')
122
+ .sort((left, right) => Number(right.value === 'admin') - Number(left.value === 'admin'));
123
+ }
119
124
  async function buildMenuSnapshot(aid, agents, auth) {
120
125
  const [roleOptions, defaults] = await Promise.all([
121
126
  execRoleMenu(aid, { type: 'menu.options', id: `ecweb-role-options-${Date.now()}`, args: { option: 'roles' } }, auth),
@@ -125,8 +130,8 @@ async function buildMenuSnapshot(aid, agents, auth) {
125
130
  throw Object.assign(new Error(roleOptions.error.message), { menuResponse: roleOptions });
126
131
  if (defaults.error)
127
132
  throw Object.assign(new Error(defaults.error.message), { menuResponse: defaults });
128
- const userRoles = roleOptions.data.filter(item => item.kind === 'user');
129
- const details = await Promise.all(userRoles.map(item => execRoleMenu(aid, {
133
+ const visibleRoles = visibleRoleDefinitionOptions(roleOptions.data);
134
+ const details = await Promise.all(visibleRoles.map(item => execRoleMenu(aid, {
130
135
  type: 'menu.query', id: `ecweb-role-${item.value}-${Date.now()}`,
131
136
  args: { view: 'role', roleId: item.value },
132
137
  }, auth)));
@@ -634,31 +639,8 @@ export const roleDefinitionsSource = {
634
639
  async snapshot(params = {}) {
635
640
  return buildSnapshot(params, params.__menuAuth ?? {});
636
641
  },
637
- subscribe(_params, push) {
638
- let lastJson = '';
639
- let stopped = false;
640
- const tick = async () => {
641
- if (stopped)
642
- return;
643
- try {
644
- const snap = await buildSnapshot(_params, _params.__menuAuth ?? {});
645
- const json = JSON.stringify(snap);
646
- if (json !== lastJson) {
647
- lastJson = json;
648
- push(snap);
649
- }
650
- }
651
- catch (err) {
652
- console.error('[role-definitions] Polling error:', err);
653
- }
654
- };
655
- // 立即执行一次
656
- tick();
657
- const timer = setInterval(tick, 2000); // 2秒轮询
658
- return () => {
659
- stopped = true;
660
- clearInterval(timer);
661
- };
642
+ subscribe(_params, _push) {
643
+ return () => { };
662
644
  }
663
645
  };
664
646
  async function handleRoleMenuCompatibility(req, res, auth, aid, agents, parsedUrl) {
@@ -12,7 +12,9 @@ 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 { buildSessionBindMap, classifyTranscriptSource, isEvolcoreRoutedMessage, isSyntheticCliPrompt, resolveSessionBindSelfName, } from './session-origin.js';
16
18
  const requireFromHere = createRequire(import.meta.url);
17
19
  let _dlog = null;
18
20
  export function setDebugLog(log) { _dlog = log; }
@@ -107,7 +109,7 @@ function listProjects() {
107
109
  }
108
110
  }
109
111
  // ── 会话列表 ──
110
- const CACHE_VERSION = 1;
112
+ const CACHE_VERSION = 2;
111
113
  const _metaCache = new Map();
112
114
  function cacheDir(encoded) {
113
115
  const dataDir = resolvePaths().dataDir;
@@ -170,6 +172,7 @@ function extractMetaFromRollout(file, id) {
170
172
  }
171
173
  catch { }
172
174
  let title = '', firstUser = '', gitBranch = '', version = '', userMsgs = 0, totalMsgs = 0;
175
+ let hasRealUserInput = false;
173
176
  const lines = raw.split('\n');
174
177
  for (const line of lines) {
175
178
  if (!line.trim())
@@ -187,6 +190,8 @@ function extractMetaFromRollout(file, id) {
187
190
  if (event.type === 'event_msg' && event.payload?.type === 'user_message' && event.payload.message) {
188
191
  totalMsgs++;
189
192
  userMsgs++;
193
+ if (!isSyntheticCliPrompt(event.payload.message, 'codex'))
194
+ hasRealUserInput = true;
190
195
  if (!firstUser) {
191
196
  const text = event.payload.message.trim().replace(/\s+/g, ' ');
192
197
  firstUser = text.substring(0, 120);
@@ -216,15 +221,34 @@ function extractMetaFromRollout(file, id) {
216
221
  totalMsgs,
217
222
  sizeKB: Math.round(stat.size / 1024),
218
223
  lastActivity: stat.mtimeMs,
224
+ hasRealUserInput,
225
+ backendSource: '',
226
+ threadSource: '',
227
+ evolcoreRouted: false,
219
228
  };
220
229
  }
230
+ function withThreadSource(meta, row) {
231
+ return {
232
+ ...meta,
233
+ backendSource: typeof row.source === 'string' ? row.source : '',
234
+ threadSource: typeof row.thread_source === 'string' ? row.thread_source : '',
235
+ evolcoreRouted: isEvolcoreRoutedMessage(row.first_user_message),
236
+ };
237
+ }
238
+ function isCodexCliCandidate(meta) {
239
+ if (meta.threadSource === 'subagent')
240
+ return false;
241
+ if (meta.backendSource !== 'cli' && meta.backendSource !== 'vscode')
242
+ return false;
243
+ return meta.hasRealUserInput && !meta.evolcoreRouted;
244
+ }
221
245
  function listTranscripts(encoded, cwd) {
222
246
  const db = getDb();
223
247
  if (!db)
224
248
  return [];
225
249
  try {
226
250
  const rows = db.prepare(`
227
- SELECT id, title, first_user_message, updated_at, rollout_path
251
+ SELECT id, title, first_user_message, updated_at, rollout_path, source, thread_source
228
252
  FROM threads
229
253
  WHERE cwd = ? AND archived = 0
230
254
  ORDER BY updated_at DESC
@@ -248,6 +272,10 @@ function listTranscripts(encoded, cwd) {
248
272
  totalMsgs: 0,
249
273
  sizeKB: 0,
250
274
  lastActivity: (row.updated_at || 0) * 1000,
275
+ hasRealUserInput: !!firstUser && !isSyntheticCliPrompt(firstUser, 'codex'),
276
+ backendSource: typeof row.source === 'string' ? row.source : '',
277
+ threadSource: typeof row.thread_source === 'string' ? row.thread_source : '',
278
+ evolcoreRouted: isEvolcoreRoutedMessage(row.first_user_message),
251
279
  });
252
280
  continue;
253
281
  }
@@ -261,21 +289,21 @@ function listTranscripts(encoded, cwd) {
261
289
  const mtime = stat.mtimeMs;
262
290
  const mem = _metaCache.get(rolloutPath);
263
291
  if (mem && mem.mtime === mtime) {
264
- out.push(mem.meta);
292
+ out.push(withThreadSource(mem.meta, row));
265
293
  hitMem++;
266
294
  continue;
267
295
  }
268
296
  const disk = readDiskCache(encoded, id, mtime, stat.size);
269
297
  if (disk) {
270
298
  _metaCache.set(rolloutPath, { mtime, meta: disk });
271
- out.push(disk);
299
+ out.push(withThreadSource(disk, row));
272
300
  hitDisk++;
273
301
  continue;
274
302
  }
275
303
  const meta = extractMetaFromRollout(rolloutPath, id);
276
304
  _metaCache.set(rolloutPath, { mtime, meta });
277
305
  writeDiskCache(encoded, id, mtime, stat.size, meta);
278
- out.push(meta);
306
+ out.push(withThreadSource(meta, row));
279
307
  parsed++;
280
308
  }
281
309
  dlog(`[codex] listTranscripts ${cwd.slice(-20)}: ${rows.length} threads (mem=${hitMem} disk=${hitDisk} parsed=${parsed})`);
@@ -288,25 +316,7 @@ function listTranscripts(encoded, cwd) {
288
316
  }
289
317
  // ── 绑定关系 ──
290
318
  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;
319
+ return buildSessionBindMap();
310
320
  }
311
321
  // ── 项目解析 ──
312
322
  function resolveProject(params, projects) {
@@ -611,7 +621,7 @@ function readTranscriptFile(threadId, cwd) {
611
621
  };
612
622
  }
613
623
  // ── Snapshot ──
614
- function buildSnapshot(params) {
624
+ async function buildSnapshot(params) {
615
625
  const projects = listProjects();
616
626
  const bindMap = buildBindMap();
617
627
  const project = resolveProject(params, projects);
@@ -626,13 +636,15 @@ function buildSnapshot(params) {
626
636
  };
627
637
  }
628
638
  const metas = listTranscripts(project.encoded, project.cwd);
639
+ const selfNames = await getAgentSelfNames();
629
640
  const transcripts = metas.map(m => {
630
641
  const bind = bindMap.get(m.id);
631
642
  return {
632
643
  ...m,
644
+ sourceType: classifyTranscriptSource(bind, isCodexCliCandidate(m)),
633
645
  bound: !!bind,
634
646
  boundChannel: bind?.channelType ?? null,
635
- boundPeer: bind ? (bind.peerName || bind.channelId) : null,
647
+ boundSelf: resolveSessionBindSelfName(bind, selfNames),
636
648
  online: bind ? (Date.now() - bind.updatedAt < 5 * 60 * 1000) : false,
637
649
  };
638
650
  });
@@ -655,6 +667,7 @@ function buildSnapshot(params) {
655
667
  }
656
668
  const detail = readTranscriptFile(sessionId, project.cwd);
657
669
  const bind = bindMap.get(sessionId);
670
+ const transcript = metas.find(meta => meta.id === sessionId);
658
671
  const header = {
659
672
  sessionId,
660
673
  title: detail.title,
@@ -670,9 +683,10 @@ function buildSnapshot(params) {
670
683
  outputTokens: detail.outputTokens,
671
684
  contextTokens: detail.contextTokens,
672
685
  costUsd: detail.costUsd,
686
+ sourceType: classifyTranscriptSource(bind, transcript ? isCodexCliCandidate(transcript) : false),
673
687
  bound: !!bind,
674
688
  boundChannel: bind?.channelType ?? null,
675
- boundPeer: bind ? (bind.peerName || bind.channelId) : null,
689
+ boundSelf: resolveSessionBindSelfName(bind, selfNames),
676
690
  online: bind ? (Date.now() - bind.updatedAt < 5 * 60 * 1000) : false,
677
691
  };
678
692
  return {
@@ -699,7 +713,7 @@ export function subscribeCodex(params, push) {
699
713
  clearTimeout(debounce);
700
714
  debounce = setTimeout(() => {
701
715
  try {
702
- push(buildSnapshot(params));
716
+ void buildSnapshot(params).then(push).catch(() => { });
703
717
  }
704
718
  catch { }
705
719
  }, 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
+ }