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.
package/dist/server.js CHANGED
@@ -20,7 +20,7 @@ import { ipcQuery } from './ipc-client.js';
20
20
  import { setDebugLog, dlog } from './debug-log.js';
21
21
  import { aidSource } from './sources/aid.js';
22
22
  import { msgSource } from './sources/msg.js';
23
- import { sessionSource, buildBindMap } from './sources/session.js';
23
+ import { sessionSource, buildBindMap, readClaudeSessionResult } from './sources/session.js';
24
24
  import { cacheSource } from './sources/cache.js';
25
25
  import { systemSource } from './sources/system.js';
26
26
  import { triggersSource } from './sources/triggers.js';
@@ -34,6 +34,7 @@ import { relationRolesSource, handleRelationRolesApi, handlePeerRoleApi } from '
34
34
  import { roleDefinitionsSource, handleRoleDefinitionsApi } from './sources/role-definitions.js';
35
35
  import { handleModelsApi } from './sources/models.js';
36
36
  import { detectBaseAgents } from './sources/baseagent-detector.js';
37
+ import { readCodexSessionResult } from './sources/session-codex.js';
37
38
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
38
39
  const STATIC_DIR = path.join(__dirname, 'static');
39
40
  const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30天(滑动窗口:每次有效访问刷新 lastActive 自动续期)
@@ -66,10 +67,26 @@ const ECWEB_VERSION = readEcwebVersion();
66
67
  const MIME = {
67
68
  '.html': 'text/html; charset=utf-8',
68
69
  '.js': 'text/javascript; charset=utf-8',
70
+ '.mjs': 'text/javascript; charset=utf-8',
69
71
  '.css': 'text/css; charset=utf-8',
70
72
  '.json': 'application/json; charset=utf-8',
73
+ '.webmanifest': 'application/manifest+json; charset=utf-8',
71
74
  '.svg': 'image/svg+xml',
75
+ '.png': 'image/png',
76
+ '.jpg': 'image/jpeg',
77
+ '.jpeg': 'image/jpeg',
78
+ '.gif': 'image/gif',
79
+ '.webp': 'image/webp',
80
+ '.avif': 'image/avif',
81
+ '.ico': 'image/x-icon',
82
+ '.woff': 'font/woff',
83
+ '.woff2': 'font/woff2',
84
+ '.ttf': 'font/ttf',
85
+ '.otf': 'font/otf',
86
+ '.wasm': 'application/wasm',
72
87
  };
88
+ const HTML_CACHE_CONTROL = 'no-cache';
89
+ const STATIC_CACHE_CONTROL = 'public, max-age=0, must-revalidate';
73
90
  function tokenStorePath() {
74
91
  const dir = resolvePaths().instanceDir;
75
92
  const current = path.join(dir, 'ecweb-tokens.json');
@@ -141,7 +158,32 @@ function injectBaseHref(html, prefix) {
141
158
  return html.replace(/<base\b[^>]*>/i, tag);
142
159
  return html.replace(/<head[^>]*>/i, (m) => `${m}\n ${tag}`);
143
160
  }
144
- function serveStatic(req, res) {
161
+ function createEtag(content) {
162
+ return `"${crypto.createHash('sha256').update(content).digest('hex')}"`;
163
+ }
164
+ function matchesIfNoneMatch(req, etag) {
165
+ const header = req.headers['if-none-match'];
166
+ const value = Array.isArray(header) ? header.join(',') : header;
167
+ if (!value)
168
+ return false;
169
+ // GET uses weak comparison, so a client may send the validator with W/.
170
+ return value.split(',').some((candidate) => {
171
+ const normalized = candidate.trim().replace(/^W\//, '');
172
+ return normalized === '*' || normalized === etag;
173
+ });
174
+ }
175
+ function respondWithValidator(req, res, body, contentType, cacheControl, extraHeaders = {}) {
176
+ const etag = createEtag(body);
177
+ const headers = { 'Content-Type': contentType, 'Cache-Control': cacheControl, ETag: etag, ...extraHeaders };
178
+ if (matchesIfNoneMatch(req, etag)) {
179
+ res.writeHead(304, headers);
180
+ res.end();
181
+ return;
182
+ }
183
+ res.writeHead(200, headers);
184
+ res.end(body);
185
+ }
186
+ export function serveStatic(req, res) {
145
187
  let urlPath = (req.url || '/').split('?')[0];
146
188
  if (urlPath === '/')
147
189
  urlPath = '/index.html';
@@ -161,12 +203,13 @@ function serveStatic(req, res) {
161
203
  if (ext === '.html') {
162
204
  const prefix = forwardedPrefix(req);
163
205
  const html = injectBaseHref(data.toString('utf-8'), prefix);
164
- res.writeHead(200, { 'Content-Type': MIME[ext] });
165
- res.end(html);
206
+ // The proxy prefix changes the representation, so shared caches must key on it.
207
+ respondWithValidator(req, res, html, MIME[ext], HTML_CACHE_CONTROL, { Vary: 'X-Forwarded-Prefix' });
166
208
  return;
167
209
  }
168
- res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
169
- res.end(data);
210
+ // Static files have stable ETags, including fonts, images, manifests, and vendored libraries.
211
+ // Their fixed URLs must revalidate so deployments never leave clients on an old asset.
212
+ respondWithValidator(req, res, data, MIME[ext] || 'application/octet-stream', STATIC_CACHE_CONTROL);
170
213
  });
171
214
  }
172
215
  // ── Helpers ──
@@ -511,8 +554,14 @@ function handleConnection(ws, req, log) {
511
554
  params.aid = msg.aid;
512
555
  if (msg.peer)
513
556
  params.peer = msg.peer;
557
+ if (msg.before != null)
558
+ params.before = msg.before;
514
559
  if (msg.sessionId)
515
560
  params.sessionId = msg.sessionId;
561
+ if (msg.turnBefore != null)
562
+ params.turnBefore = msg.turnBefore;
563
+ if (msg.turnLimit != null)
564
+ params.turnLimit = msg.turnLimit;
516
565
  if (msg.project)
517
566
  params.project = msg.project;
518
567
  if (msg.agent)
@@ -527,6 +576,14 @@ function handleConnection(ws, req, log) {
527
576
  await switchSubscription(msg.view, params);
528
577
  return;
529
578
  }
579
+ if (msg.type === 'session.result') {
580
+ const params = msg.params && typeof msg.params === 'object' ? msg.params : {};
581
+ const result = params.baseagent === 'codex'
582
+ ? readCodexSessionResult(params)
583
+ : readClaudeSessionResult(params);
584
+ send({ type: 'session.result', requestId: msg.requestId, data: result == null ? null : { text: result } });
585
+ return;
586
+ }
530
587
  if (msg.type === 'menu' && msg.payload) {
531
588
  const p = resolvePaths();
532
589
  const timeoutMs = Number.isFinite(Number(msg.timeoutMs)) && Number(msg.timeoutMs) > 0
@@ -6,11 +6,17 @@
6
6
  */
7
7
  import fs from 'fs';
8
8
  import path from 'path';
9
+ import { ipcQuery } from '../ipc-client.js';
9
10
  import { resolvePaths } from '../paths.js';
10
11
  import { resolveParentDistModule, toFileUrl } from './parent-package.js';
11
12
  import { isTransientProtocolMessage, readAllJsonlLines, readJsonFile, } from '../fs-utils.js';
12
13
  let contextModulesPromise = null;
13
14
  const sessionAdapterPromises = new Map();
15
+ let peerDisplayResolverPromise = null;
16
+ const aunGroupNameCache = new Map();
17
+ const aunGroupNameRequests = new Map();
18
+ let agentDisplayNamesCache = null;
19
+ const messageFileCache = new Map();
14
20
  function getContextModules() {
15
21
  if (!contextModulesPromise) {
16
22
  contextModulesPromise = Promise.all([
@@ -21,6 +27,159 @@ function getContextModules() {
21
27
  }
22
28
  return contextModulesPromise;
23
29
  }
30
+ function getPeerDisplayResolver() {
31
+ if (!peerDisplayResolverPromise) {
32
+ peerDisplayResolverPromise = Promise.resolve()
33
+ .then(() => import(toFileUrl(resolveParentDistModule('config', 'contact-book.js'))))
34
+ .then(mod => typeof mod.resolvePeerDisplayLabel === 'function' ? mod.resolvePeerDisplayLabel : null)
35
+ .catch(() => null);
36
+ }
37
+ return peerDisplayResolverPromise;
38
+ }
39
+ export function resolveContactPeerName(resolver, identity, peerId, fallback) {
40
+ if (!resolver)
41
+ return fallback;
42
+ try {
43
+ return resolver(identity.selfAID, identity.channelType, peerId, identity.channel) || fallback;
44
+ }
45
+ catch {
46
+ return fallback;
47
+ }
48
+ }
49
+ export function aunGroupIdVariants(groupId) {
50
+ const id = String(groupId || '').trim();
51
+ if (!id)
52
+ return [];
53
+ const variants = new Set([id]);
54
+ const legacy = /^group\.agentid\.pub\/([^/]+)$/i.exec(id);
55
+ if (legacy)
56
+ variants.add(`${legacy[1]}.agentid.pub`);
57
+ const current = /^([^.]+)\.agentid\.pub$/i.exec(id);
58
+ if (current)
59
+ variants.add(`group.agentid.pub/${current[1]}`);
60
+ return [...variants];
61
+ }
62
+ export function resolveAunGroupName(names, groupId, fallback) {
63
+ for (const id of aunGroupIdVariants(groupId || '')) {
64
+ const name = names.get(id);
65
+ if (name)
66
+ return name;
67
+ }
68
+ return fallback;
69
+ }
70
+ async function fetchAunGroupNames(aid) {
71
+ const names = new Map();
72
+ let cursor = null;
73
+ let page = 0;
74
+ do {
75
+ page += 1;
76
+ const response = await ipcQuery(resolvePaths().socket, {
77
+ type: 'menu.exec',
78
+ payload: {
79
+ type: 'menu.query',
80
+ name: 'connect',
81
+ cmd: '/connect',
82
+ agent: aid,
83
+ id: `ecweb-msg-groups-${Date.now()}-${page}`,
84
+ args: { self: aid, view: 'groups', limit: 200, ...(cursor ? { cursor } : {}) },
85
+ },
86
+ auth: { source: 'ecweb', localDirect: true },
87
+ }, 10_000);
88
+ const data = response?.ok && !response.response?.error ? response.response?.data : null;
89
+ if (!data || !Array.isArray(data.items))
90
+ break;
91
+ for (const item of data.items) {
92
+ const groupId = String(item?.groupId || '').trim();
93
+ const displayName = String(item?.displayName || '').trim();
94
+ if (!groupId || !displayName)
95
+ continue;
96
+ for (const variant of aunGroupIdVariants(groupId))
97
+ names.set(variant, displayName);
98
+ }
99
+ cursor = typeof data.nextCursor === 'string' && data.nextCursor ? data.nextCursor : null;
100
+ } while (cursor && page < 20);
101
+ return names;
102
+ }
103
+ function getAunGroupNames(aid) {
104
+ const cached = aunGroupNameCache.get(aid);
105
+ if (cached && cached.expiresAt > Date.now())
106
+ return Promise.resolve(cached.names);
107
+ const pending = aunGroupNameRequests.get(aid);
108
+ if (pending)
109
+ return pending;
110
+ const request = fetchAunGroupNames(aid)
111
+ .then(names => {
112
+ aunGroupNameCache.set(aid, {
113
+ names,
114
+ expiresAt: Date.now() + (names.size ? 5 * 60_000 : 15_000),
115
+ });
116
+ return names;
117
+ })
118
+ .catch(() => new Map())
119
+ .finally(() => aunGroupNameRequests.delete(aid));
120
+ aunGroupNameRequests.set(aid, request);
121
+ return request;
122
+ }
123
+ async function hydrateAunGroupNames(chats, waitForRefresh = false) {
124
+ const aids = [...new Set(chats
125
+ .filter(chat => chat.channelType === 'aun' && chat.chatType === 'group')
126
+ .map(chat => chat.selfAID))];
127
+ if (!aids.length)
128
+ return;
129
+ const directories = new Map();
130
+ if (waitForRefresh) {
131
+ for (const [aid, names] of await Promise.all(aids.map(async (aid) => [aid, await getAunGroupNames(aid)]))) {
132
+ directories.set(aid, names);
133
+ }
134
+ }
135
+ else {
136
+ for (const aid of aids) {
137
+ const cached = aunGroupNameCache.get(aid);
138
+ if (cached)
139
+ directories.set(aid, cached.names);
140
+ if (!cached || cached.expiresAt <= Date.now())
141
+ void getAunGroupNames(aid);
142
+ }
143
+ }
144
+ for (const chat of chats) {
145
+ if (chat.channelType !== 'aun' || chat.chatType !== 'group')
146
+ continue;
147
+ const groupName = resolveAunGroupName(directories.get(chat.selfAID) || new Map(), chat.groupId, chat.groupName);
148
+ if (!groupName)
149
+ continue;
150
+ chat.groupName = groupName;
151
+ chat.peerName = groupName;
152
+ }
153
+ }
154
+ export function resolveSessionBindDisplayName(bind, resolver, aunGroupNames = new Map()) {
155
+ const isGroup = bind.chatType === 'group' || !!bind.groupId;
156
+ if (bind.channelType === 'aun' && isGroup) {
157
+ return resolveAunGroupName(aunGroupNames, bind.groupId || bind.channelId, bind.groupName || null);
158
+ }
159
+ if (isGroup)
160
+ return bind.groupName || null;
161
+ return resolveContactPeerName(resolver, {
162
+ selfAID: bind.selfAID,
163
+ channelType: bind.channelType,
164
+ channel: bind.channelKey || bind.channelType,
165
+ channelId: bind.channelId,
166
+ }, bind.peerId || bind.channelId, bind.peerName);
167
+ }
168
+ /** 为会话页绑定标签补齐联系人名或 AUN 群名,不改变原始 channelId。 */
169
+ export async function hydrateSessionBindDisplayNames(binds) {
170
+ const items = [...binds];
171
+ if (!items.length)
172
+ return;
173
+ const resolver = await getPeerDisplayResolver();
174
+ const groupAids = [...new Set(items
175
+ .filter(bind => bind.channelType === 'aun' && (bind.chatType === 'group' || !!bind.groupId))
176
+ .map(bind => bind.selfAID)
177
+ .filter(Boolean))];
178
+ const groupDirectories = new Map(await Promise.all(groupAids.map(async (aid) => [aid, await getAunGroupNames(aid)])));
179
+ for (const bind of items) {
180
+ bind.displayName = resolveSessionBindDisplayName(bind, resolver, groupDirectories.get(bind.selfAID) || new Map());
181
+ }
182
+ }
24
183
  function getSessionAdapter(baseagent) {
25
184
  if (!sessionAdapterPromises.has(baseagent)) {
26
185
  const classNames = {
@@ -63,6 +222,131 @@ function findMessageFiles(root) {
63
222
  visit(root);
64
223
  return out;
65
224
  }
225
+ function fileSignature(file) {
226
+ try {
227
+ const stat = fs.statSync(file);
228
+ return `${stat.mtimeMs}:${stat.size}`;
229
+ }
230
+ catch {
231
+ return '';
232
+ }
233
+ }
234
+ function chatMetadataSignature(dirPath) {
235
+ const parts = [
236
+ `active:${fileSignature(path.join(dirPath, 'active.json'))}`,
237
+ ];
238
+ let rootEntries = [];
239
+ try {
240
+ rootEntries = fs.readdirSync(dirPath, { withFileTypes: true });
241
+ }
242
+ catch { }
243
+ for (const entry of rootEntries) {
244
+ if (entry.isFile() && entry.name.startsWith('meta_') && entry.name.endsWith('.jsonl')) {
245
+ parts.push(`${entry.name}:${fileSignature(path.join(dirPath, entry.name))}`);
246
+ }
247
+ }
248
+ const threadsDir = path.join(dirPath, '_threads');
249
+ let threadEntries = [];
250
+ try {
251
+ threadEntries = fs.readdirSync(threadsDir, { withFileTypes: true });
252
+ }
253
+ catch { }
254
+ for (const entry of threadEntries) {
255
+ if (entry.isFile() && (entry.name === 'thread-index.json' || entry.name.endsWith('.jsonl'))) {
256
+ parts.push(`threads/${entry.name}:${fileSignature(path.join(threadsDir, entry.name))}`);
257
+ }
258
+ }
259
+ return parts.sort().join('|');
260
+ }
261
+ function appendMessageRows(file, cached, metrics) {
262
+ let stat;
263
+ try {
264
+ stat = fs.statSync(file);
265
+ }
266
+ catch {
267
+ return false;
268
+ }
269
+ if (stat.size < cached.offset || (stat.size === cached.offset && cached.mtimeMs && stat.mtimeMs !== cached.mtimeMs)) {
270
+ cached.offset = 0;
271
+ cached.messages = [];
272
+ }
273
+ if (stat.size === cached.offset)
274
+ return false;
275
+ const bytes = stat.size - cached.offset;
276
+ const buffer = Buffer.allocUnsafe(bytes);
277
+ let fd = null;
278
+ try {
279
+ fd = fs.openSync(file, 'r');
280
+ fs.readSync(fd, buffer, 0, bytes, cached.offset);
281
+ }
282
+ catch {
283
+ return false;
284
+ }
285
+ finally {
286
+ if (fd != null)
287
+ try {
288
+ fs.closeSync(fd);
289
+ }
290
+ catch { }
291
+ }
292
+ let completeBytes = buffer.length;
293
+ const lastBreak = buffer.lastIndexOf(0x0a);
294
+ if (lastBreak !== buffer.length - 1) {
295
+ const trailing = buffer.subarray(lastBreak + 1).toString('utf8').trim();
296
+ try {
297
+ JSON.parse(trailing);
298
+ }
299
+ catch {
300
+ completeBytes = lastBreak + 1;
301
+ }
302
+ }
303
+ if (completeBytes <= 0)
304
+ return false;
305
+ let parsedLines = 0;
306
+ for (const line of buffer.subarray(0, completeBytes).toString('utf8').split(/\r?\n/)) {
307
+ if (!line)
308
+ continue;
309
+ try {
310
+ const message = JSON.parse(line);
311
+ parsedLines++;
312
+ if (!isTransientProtocolMessage(message))
313
+ cached.messages.push(message);
314
+ }
315
+ catch { }
316
+ }
317
+ cached.offset += completeBytes;
318
+ cached.mtimeMs = stat.mtimeMs;
319
+ metrics.changedFiles++;
320
+ metrics.parsedLines += parsedLines;
321
+ metrics.parsedBytes += completeBytes;
322
+ return true;
323
+ }
324
+ function cachedMessageFile(file, metrics) {
325
+ let cached = messageFileCache.get(file);
326
+ if (!cached) {
327
+ cached = {
328
+ offset: 0,
329
+ mtimeMs: 0,
330
+ messages: [],
331
+ active: {},
332
+ threadSessions: [],
333
+ mainSessionIds: new Set(),
334
+ metadataSignature: '',
335
+ };
336
+ messageFileCache.set(file, cached);
337
+ }
338
+ appendMessageRows(file, cached, metrics);
339
+ const dirPath = path.dirname(file);
340
+ const metadataSignature = chatMetadataSignature(dirPath);
341
+ if (metadataSignature !== cached.metadataSignature) {
342
+ cached.active = readJsonFile(path.join(dirPath, 'active.json')) ?? {};
343
+ cached.threadSessions = loadThreadSessions(dirPath);
344
+ cached.mainSessionIds = loadMainSessionIds(dirPath, cached.active);
345
+ cached.metadataSignature = metadataSignature;
346
+ metrics.metadataRefreshes++;
347
+ }
348
+ return cached;
349
+ }
66
350
  function readLastJsonlObject(file) {
67
351
  const rows = readAllJsonlLines(file);
68
352
  return rows.length ? rows[rows.length - 1] : null;
@@ -173,6 +457,37 @@ function listConfiguredAgentAids(root) {
173
457
  }
174
458
  return aids;
175
459
  }
460
+ function shortAid(value) {
461
+ return String(value || '').split('.')[0] || String(value || '');
462
+ }
463
+ /** Returns local Agent display names, preferring the runtime selfName. */
464
+ export async function getAgentSelfNames() {
465
+ if (agentDisplayNamesCache && agentDisplayNamesCache.expiresAt > Date.now()) {
466
+ return agentDisplayNamesCache.names;
467
+ }
468
+ const names = new Map();
469
+ try {
470
+ const [agentsResp, statsResp] = await Promise.all([
471
+ ipcQuery(resolvePaths().socket, { type: 'evolagent.list' }, 4000),
472
+ ipcQuery(resolvePaths().socket, { type: 'aun-aid-stats' }, 4000),
473
+ ]);
474
+ const statsByAid = new Map((statsResp?.stats || []).map(item => [String(item?.aid || ''), item]));
475
+ for (const agent of agentsResp?.agents || []) {
476
+ const aid = String(agent?.aid || '').trim();
477
+ if (!aid)
478
+ continue;
479
+ const stats = statsByAid.get(aid);
480
+ const displayName = String(stats?.selfName || agent?.displayName || shortAid(aid)).trim();
481
+ if (displayName)
482
+ names.set(aid, displayName);
483
+ }
484
+ }
485
+ catch {
486
+ // daemon unavailable: leave the map empty and use the AID fallback below
487
+ }
488
+ agentDisplayNamesCache = { expiresAt: Date.now() + 5000, names };
489
+ return names;
490
+ }
176
491
  function decodeDirSegment(seg) {
177
492
  return seg.replace(/%([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
178
493
  }
@@ -292,17 +607,22 @@ function chatDisplayId(active, messages) {
292
607
  groupName: null,
293
608
  };
294
609
  }
295
- function loadChats() {
610
+ function loadChats(peerDisplayResolver, metrics) {
296
611
  const paths = resolvePaths();
297
612
  const sessionsDir = paths.sessionsDir;
298
613
  const configuredAids = listConfiguredAgentAids(paths.root);
299
614
  const files = findMessageFiles(sessionsDir);
615
+ const liveFiles = new Set(files);
616
+ for (const file of messageFileCache.keys()) {
617
+ if (!liveFiles.has(file))
618
+ messageFileCache.delete(file);
619
+ }
620
+ metrics.files = files.length;
300
621
  const chats = [];
301
622
  for (const file of files) {
302
623
  const dirPath = path.dirname(file);
303
- const active = readJsonFile(path.join(dirPath, 'active.json')) ?? {};
304
- const messages = readAllJsonlLines(file)
305
- .filter(m => !isTransientProtocolMessage(m));
624
+ const cached = cachedMessageFile(file, metrics);
625
+ const { active, messages } = cached;
306
626
  if (!messages.length)
307
627
  continue;
308
628
  const identity = normalizeChatIdentity(sessionsDir, dirPath, active, messages);
@@ -311,9 +631,12 @@ function loadChats() {
311
631
  continue;
312
632
  const chatType = String(active.chatType || messages.find(m => m.chatType)?.chatType || 'private');
313
633
  const display = chatDisplayId({ ...active, chatType, channelId }, messages);
314
- const threadSessions = loadThreadSessions(dirPath);
634
+ const peerName = chatType === 'group'
635
+ ? display.peerName
636
+ : resolveContactPeerName(peerDisplayResolver, identity, display.peerId, display.peerName);
637
+ const threadSessions = cached.threadSessions;
315
638
  const threadById = new Map(threadSessions.map(item => [item.threadId, item]));
316
- const mainSessionIds = loadMainSessionIds(dirPath, active);
639
+ const mainSessionIds = cached.mainSessionIds;
317
640
  for (const item of partitionMessages(messages, threadSessions, mainSessionIds)) {
318
641
  if (!item.messages.length)
319
642
  continue;
@@ -331,7 +654,7 @@ function loadChats() {
331
654
  selfAID,
332
655
  chatType,
333
656
  peerId: display.peerId,
334
- peerName: display.peerName,
657
+ peerName,
335
658
  groupId: display.groupId,
336
659
  groupName: display.groupName,
337
660
  partition: item.partition,
@@ -353,7 +676,7 @@ function scopeId(chat) {
353
676
  function legacyChannelScopeId(chat) {
354
677
  return safeId(`${chat.selfAID}\n${chat.channelType}\n${chat.channel}`);
355
678
  }
356
- function buildScopes(chats) {
679
+ function buildScopes(chats, agentDisplayNames = new Map()) {
357
680
  const map = new Map();
358
681
  const seenChats = new Map();
359
682
  const seenChannels = new Map();
@@ -366,6 +689,7 @@ function buildScopes(chats) {
366
689
  id,
367
690
  aid: id,
368
691
  selfAID: chat.selfAID,
692
+ displayName: agentDisplayNames.get(chat.selfAID) || shortAid(chat.selfAID),
369
693
  totalIn: 0,
370
694
  totalOut: 0,
371
695
  peerCount: 0,
@@ -471,7 +795,7 @@ function selectedChats(chats, scope) {
471
795
  return [];
472
796
  return chats.filter(chat => scopeId(chat) === scope);
473
797
  }
474
- function messagesFor(chats, peer) {
798
+ function messagesFor(chats, peer, before, limit) {
475
799
  const selected = peer
476
800
  ? chats.filter(chat => chatKey(chat) === peer)
477
801
  : chats;
@@ -486,7 +810,13 @@ function messagesFor(chats, peer) {
486
810
  threadName: chat.threadName,
487
811
  })));
488
812
  messages.sort((a, b) => (a.ts || 0) - (b.ts || 0));
489
- return messages.length > 1000 ? messages.slice(-1000) : messages;
813
+ const end = before == null ? messages.length : Math.min(Math.max(before, 0), messages.length);
814
+ const start = Math.max(0, end - limit);
815
+ return {
816
+ messages: messages.slice(start, end),
817
+ nextBefore: start > 0 ? start : null,
818
+ hasMore: start > 0,
819
+ };
490
820
  }
491
821
  function latestInbound(chat) {
492
822
  for (let i = chat.messages.length - 1; i >= 0; i--) {
@@ -566,8 +896,21 @@ async function buildSessionContext(chat) {
566
896
  };
567
897
  }
568
898
  async function buildSnapshot(params) {
569
- const chats = loadChats();
570
- const scopes = buildScopes(chats);
899
+ const startedAt = Date.now();
900
+ const metrics = {
901
+ files: 0,
902
+ changedFiles: 0,
903
+ parsedLines: 0,
904
+ parsedBytes: 0,
905
+ metadataRefreshes: 0,
906
+ scanMs: 0,
907
+ totalMs: 0,
908
+ };
909
+ const peerDisplayResolver = await getPeerDisplayResolver();
910
+ const scanStartedAt = Date.now();
911
+ const chats = loadChats(peerDisplayResolver, metrics);
912
+ metrics.scanMs = Date.now() - scanStartedAt;
913
+ const scopes = buildScopes(chats, await getAgentSelfNames());
571
914
  const requestedScope = params.scope || params.aid || null;
572
915
  const scope = requestedScope && !scopes.some(s => s.id === requestedScope)
573
916
  ? (chats.find(chat => legacyChannelScopeId(chat) === requestedScope)
@@ -576,19 +919,33 @@ async function buildSnapshot(params) {
576
919
  : requestedScope;
577
920
  const peer = params.peer || null;
578
921
  const inScope = selectedChats(chats, scope);
922
+ if (scope)
923
+ await hydrateAunGroupNames(inScope);
579
924
  const peers = scope ? mergePeerInfo(inScope) : [];
580
- const messages = scope ? messagesFor(inScope, peer) : [];
925
+ const requestedLimit = Number(params.limit);
926
+ const limit = Number.isFinite(requestedLimit) ? Math.min(Math.max(Math.trunc(requestedLimit), 20), 200) : 100;
927
+ const requestedBefore = params.before == null ? Number.NaN : Number(params.before);
928
+ const before = Number.isFinite(requestedBefore) && requestedBefore >= 0 ? Math.trunc(requestedBefore) : null;
929
+ const page = scope && peer ? messagesFor(inScope, peer, before, limit) : { messages: [], nextBefore: null, hasMore: false };
581
930
  const currentChat = peer ? inScope.find(chat => chatKey(chat) === peer) : undefined;
582
931
  const sessionContext = await buildSessionContext(currentChat);
932
+ metrics.totalMs = Date.now() - startedAt;
583
933
  return {
584
934
  scopes,
585
935
  aids: scopes,
586
936
  peers,
587
- messages,
937
+ messages: page.messages,
938
+ messagePage: {
939
+ limit,
940
+ before,
941
+ nextBefore: page.nextBefore,
942
+ hasMore: page.hasMore,
943
+ },
588
944
  scope,
589
945
  aid: scope,
590
946
  peer,
591
947
  sessionContext,
948
+ metrics,
592
949
  };
593
950
  }
594
951
  export const msgSource = {