ec-web 0.0.5 → 0.0.7

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/fs-utils.js CHANGED
@@ -14,72 +14,104 @@ export function encodeSegment(s) {
14
14
  export function decodeDirSegment(seg) {
15
15
  return seg.replace(/%([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
16
16
  }
17
+ function encodeChannelSessionComponent(value) {
18
+ const encoded = encodeURIComponent(value).replace(/\*/g, '%2A');
19
+ return encoded.endsWith('.') ? `${encoded.slice(0, -1)}%2E` : encoded;
20
+ }
21
+ function formatChannelSessionKeyParts(channelType, channelName, channelId) {
22
+ if (!channelType || !channelName || !channelId)
23
+ throw new Error('channel session key components must not be empty');
24
+ const key = [channelType, channelName, channelId].map(encodeChannelSessionComponent).join('#');
25
+ if (Buffer.byteLength(key, 'utf8') > 255)
26
+ throw new Error('channel session key exceeds the 255-byte filesystem limit');
27
+ return key;
28
+ }
29
+ export function formatChannelSessionKey(channelType, channelId, channelKey) {
30
+ const parts = channelKey?.split('#');
31
+ const channelName = parts?.length === 3 && parts[0] === channelType && parts[2]?.trim()
32
+ ? parts[2].trim()
33
+ : 'main';
34
+ return formatChannelSessionKeyParts(channelType, channelName, channelId);
35
+ }
36
+ export function tryParseChannelSessionKey(key) {
37
+ const parts = key.split('#');
38
+ if (parts.length !== 3 || parts.some(part => !part))
39
+ return undefined;
40
+ try {
41
+ const parsed = {
42
+ channelType: decodeURIComponent(parts[0]),
43
+ channelName: decodeURIComponent(parts[1]),
44
+ channelId: decodeURIComponent(parts[2]),
45
+ };
46
+ if (!parsed.channelType || !parsed.channelName || !parsed.channelId)
47
+ return undefined;
48
+ return formatChannelSessionKeyParts(parsed.channelType, parsed.channelName, parsed.channelId) === key
49
+ ? parsed
50
+ : undefined;
51
+ }
52
+ catch {
53
+ return undefined;
54
+ }
55
+ }
17
56
  /** 与 evolcore cross-platform.ts encodePath 一致(CC transcript 目录名) */
18
57
  export function encodePath(p) {
19
58
  const isWindows = process.platform === 'win32';
20
59
  const norm = isWindows ? p.replace(/\\/g, '/') : p;
21
60
  return encodeURIComponent(norm).replace(/%2F/gi, '-').replace(/%3A/gi, '-').replace(/^-/, '');
22
61
  }
62
+ function hasSessionPayload(dirPath) {
63
+ try {
64
+ return fs.readdirSync(dirPath, { withFileTypes: true }).some(entry => (entry.isFile() && (entry.name === 'active.json'
65
+ || entry.name === 'messages.jsonl'
66
+ || (entry.name.startsWith('meta_') && entry.name.endsWith('.jsonl')))) || (entry.isDirectory() && entry.name === '_threads'));
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
23
72
  export function scanChatDirs(sessionsDir) {
24
73
  const results = [];
25
- let channelTypes;
74
+ let agents;
26
75
  try {
27
- channelTypes = fs.readdirSync(sessionsDir, { withFileTypes: true });
76
+ agents = fs.readdirSync(sessionsDir, { withFileTypes: true });
28
77
  }
29
78
  catch {
30
79
  return results;
31
80
  }
32
- for (const ct of channelTypes) {
33
- if (!ct.isDirectory())
81
+ for (const agent of agents) {
82
+ if (!agent.isDirectory())
34
83
  continue;
35
- const channelType = ct.name;
36
- const ctDir = path.join(sessionsDir, channelType);
37
- let level2;
84
+ const selfAID = decodeDirSegment(agent.name);
85
+ const agentSessions = path.join(sessionsDir, agent.name, 'sessions');
86
+ let sessions;
38
87
  try {
39
- level2 = fs.readdirSync(ctDir, { withFileTypes: true });
88
+ sessions = fs.readdirSync(agentSessions, { withFileTypes: true });
40
89
  }
41
90
  catch {
42
91
  continue;
43
92
  }
44
- if (channelType === 'aun') {
45
- for (const selfEnc of level2) {
46
- if (!selfEnc.isDirectory())
47
- continue;
48
- const selfAID = decodeDirSegment(selfEnc.name);
49
- const selfDir = path.join(ctDir, selfEnc.name);
50
- let peers;
51
- try {
52
- peers = fs.readdirSync(selfDir, { withFileTypes: true });
53
- }
54
- catch {
55
- continue;
56
- }
57
- for (const peerEnc of peers) {
58
- if (!peerEnc.isDirectory() || peerEnc.name.startsWith('_'))
59
- continue;
60
- results.push({
61
- dirPath: path.join(selfDir, peerEnc.name),
62
- channelType,
63
- channelId: decodeDirSegment(peerEnc.name),
64
- selfAID,
65
- });
66
- }
67
- }
68
- }
69
- else {
70
- for (const chanEnc of level2) {
71
- if (!chanEnc.isDirectory())
72
- continue;
73
- results.push({
74
- dirPath: path.join(ctDir, chanEnc.name),
75
- channelType,
76
- channelId: decodeDirSegment(chanEnc.name),
77
- selfAID: '',
78
- });
79
- }
93
+ for (const session of sessions) {
94
+ if (!session.isDirectory())
95
+ continue;
96
+ const parsed = tryParseChannelSessionKey(session.name);
97
+ if (!parsed)
98
+ continue;
99
+ const dirPath = path.join(agentSessions, session.name);
100
+ if (!hasSessionPayload(dirPath))
101
+ continue;
102
+ results.push({
103
+ dirPath,
104
+ channelType: parsed.channelType,
105
+ channelKey: parsed.channelType === 'daemon'
106
+ ? 'daemon'
107
+ : `${parsed.channelType}#${selfAID}#${parsed.channelName}`,
108
+ channelId: parsed.channelId,
109
+ channelSessionKey: session.name,
110
+ selfAID,
111
+ });
80
112
  }
81
113
  }
82
- return results;
114
+ return results.sort((left, right) => left.dirPath < right.dirPath ? -1 : left.dirPath > right.dirPath ? 1 : 0);
83
115
  }
84
116
  export function readJsonFile(filePath) {
85
117
  try {
@@ -123,34 +155,26 @@ export function isTransientProtocolMessage(entry) {
123
155
  return transient(entry.payloadType) || transient(entry.msgType);
124
156
  }
125
157
  export function getSessionsAunDir() {
126
- return path.join(resolvePaths().sessionsDir, 'aun');
158
+ return resolvePaths().sessionsDir;
127
159
  }
128
160
  export function listLocalAids(aunDir) {
129
- try {
130
- return fs.readdirSync(aunDir, { withFileTypes: true })
131
- .filter(e => e.isDirectory())
132
- .map(e => decodeDirSegment(e.name));
133
- }
134
- catch {
135
- return [];
136
- }
161
+ return [...new Set(scanChatDirs(aunDir)
162
+ .filter(chat => chat.channelType === 'aun')
163
+ .map(chat => chat.selfAID))].sort();
137
164
  }
138
165
  export function listPeers(aunDir, localAid) {
139
- const aidDir = path.join(aunDir, encodeSegment(localAid));
140
- try {
141
- return fs.readdirSync(aidDir, { withFileTypes: true })
142
- .filter(e => e.isDirectory() && !e.name.startsWith('_'))
143
- .map(e => decodeDirSegment(e.name));
144
- }
145
- catch {
146
- return [];
147
- }
166
+ return [...new Set(scanChatDirs(aunDir)
167
+ .filter(chat => chat.channelType === 'aun' && chat.selfAID === localAid)
168
+ .map(chat => chat.channelId))].sort();
169
+ }
170
+ function aunChatDir(aunDir, localAid, peerId) {
171
+ return path.join(aunDir, encodeSegment(localAid), 'sessions', formatChannelSessionKey('aun', peerId, `aun#${localAid}#main`));
148
172
  }
149
173
  export function readMessages(aunDir, localAid, peerId) {
150
- return readAllJsonlLines(path.join(aunDir, encodeSegment(localAid), encodeSegment(peerId), 'messages.jsonl')).filter(m => !isTransientProtocolMessage(m));
174
+ return readAllJsonlLines(path.join(aunChatDir(aunDir, localAid, peerId), 'messages.jsonl')).filter(m => !isTransientProtocolMessage(m));
151
175
  }
152
176
  function readPeerName(aunDir, localAid, peerId) {
153
- return readJsonFile(path.join(aunDir, encodeSegment(localAid), encodeSegment(peerId), 'active.json'))?.metadata?.peerName ?? null;
177
+ return readJsonFile(path.join(aunChatDir(aunDir, localAid, peerId), 'active.json'))?.metadata?.peerName ?? null;
154
178
  }
155
179
  export function loadAidInfo(aunDir, aid) {
156
180
  const peers = listPeers(aunDir, aid);
package/dist/paths.js CHANGED
@@ -42,7 +42,7 @@ export function resolvePaths() {
42
42
  const root = resolveRoot();
43
43
  return {
44
44
  root,
45
- sessionsDir: path.join(root, 'data', 'sessions'),
45
+ sessionsDir: path.join(root, 'agents'),
46
46
  instanceDir: path.join(root, 'data', 'instance'),
47
47
  dataDir: path.join(root, 'data'),
48
48
  logs: path.join(root, 'logs'),
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 自动续期)
@@ -553,8 +554,14 @@ function handleConnection(ws, req, log) {
553
554
  params.aid = msg.aid;
554
555
  if (msg.peer)
555
556
  params.peer = msg.peer;
557
+ if (msg.before != null)
558
+ params.before = msg.before;
556
559
  if (msg.sessionId)
557
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;
558
565
  if (msg.project)
559
566
  params.project = msg.project;
560
567
  if (msg.agent)
@@ -569,6 +576,14 @@ function handleConnection(ws, req, log) {
569
576
  await switchSubscription(msg.view, params);
570
577
  return;
571
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
+ }
572
587
  if (msg.type === 'menu' && msg.payload) {
573
588
  const p = resolvePaths();
574
589
  const timeoutMs = Number.isFinite(Number(msg.timeoutMs)) && Number(msg.timeoutMs) > 0
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 消息数据源:扫描 data/sessions 下所有渠道的 messages.jsonl。
2
+ * 消息数据源:扫描 agents/<aid>/sessions 下所有渠道的 messages.jsonl。
3
3
  *
4
4
  * 左列按 agent 聚合;中列列出该 agent 下所有渠道的私聊/群聊会话。
5
5
  * 兼容旧参数名 aid/peer:前端的 aid 实际上传 scope id。
@@ -9,13 +9,14 @@ import path from 'path';
9
9
  import { ipcQuery } from '../ipc-client.js';
10
10
  import { resolvePaths } from '../paths.js';
11
11
  import { resolveParentDistModule, toFileUrl } from './parent-package.js';
12
- import { isTransientProtocolMessage, readAllJsonlLines, readJsonFile, } from '../fs-utils.js';
12
+ import { decodeDirSegment, isTransientProtocolMessage, readAllJsonlLines, readJsonFile, scanChatDirs, tryParseChannelSessionKey, } from '../fs-utils.js';
13
13
  let contextModulesPromise = null;
14
14
  const sessionAdapterPromises = new Map();
15
15
  let peerDisplayResolverPromise = null;
16
16
  const aunGroupNameCache = new Map();
17
17
  const aunGroupNameRequests = new Map();
18
18
  let agentDisplayNamesCache = null;
19
+ const messageFileCache = new Map();
19
20
  function getContextModules() {
20
21
  if (!contextModulesPromise) {
21
22
  contextModulesPromise = Promise.all([
@@ -119,13 +120,27 @@ function getAunGroupNames(aid) {
119
120
  aunGroupNameRequests.set(aid, request);
120
121
  return request;
121
122
  }
122
- async function hydrateAunGroupNames(chats) {
123
+ async function hydrateAunGroupNames(chats, waitForRefresh = false) {
123
124
  const aids = [...new Set(chats
124
125
  .filter(chat => chat.channelType === 'aun' && chat.chatType === 'group')
125
126
  .map(chat => chat.selfAID))];
126
127
  if (!aids.length)
127
128
  return;
128
- const directories = new Map(await Promise.all(aids.map(async (aid) => [aid, await getAunGroupNames(aid)])));
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
+ }
129
144
  for (const chat of chats) {
130
145
  if (chat.channelType !== 'aun' || chat.chatType !== 'group')
131
146
  continue;
@@ -186,26 +201,135 @@ function getSessionAdapter(baseagent) {
186
201
  function safeId(value) {
187
202
  return Buffer.from(value, 'utf8').toString('base64url');
188
203
  }
189
- function findMessageFiles(root) {
190
- const out = [];
191
- const visit = (dir) => {
192
- let entries;
204
+ export function findMessageFiles(root) {
205
+ return scanChatDirs(root)
206
+ .map(chat => path.join(chat.dirPath, 'messages.jsonl'))
207
+ .filter(file => fs.existsSync(file));
208
+ }
209
+ function fileSignature(file) {
210
+ try {
211
+ const stat = fs.statSync(file);
212
+ return `${stat.mtimeMs}:${stat.size}`;
213
+ }
214
+ catch {
215
+ return '';
216
+ }
217
+ }
218
+ function chatMetadataSignature(dirPath) {
219
+ const parts = [
220
+ `active:${fileSignature(path.join(dirPath, 'active.json'))}`,
221
+ ];
222
+ let rootEntries = [];
223
+ try {
224
+ rootEntries = fs.readdirSync(dirPath, { withFileTypes: true });
225
+ }
226
+ catch { }
227
+ for (const entry of rootEntries) {
228
+ if (entry.isFile() && entry.name.startsWith('meta_') && entry.name.endsWith('.jsonl')) {
229
+ parts.push(`${entry.name}:${fileSignature(path.join(dirPath, entry.name))}`);
230
+ }
231
+ }
232
+ const threadsDir = path.join(dirPath, '_threads');
233
+ let threadEntries = [];
234
+ try {
235
+ threadEntries = fs.readdirSync(threadsDir, { withFileTypes: true });
236
+ }
237
+ catch { }
238
+ for (const entry of threadEntries) {
239
+ if (entry.isFile() && (entry.name === 'thread-index.json' || entry.name.endsWith('.jsonl'))) {
240
+ parts.push(`threads/${entry.name}:${fileSignature(path.join(threadsDir, entry.name))}`);
241
+ }
242
+ }
243
+ return parts.sort().join('|');
244
+ }
245
+ function appendMessageRows(file, cached, metrics) {
246
+ let stat;
247
+ try {
248
+ stat = fs.statSync(file);
249
+ }
250
+ catch {
251
+ return false;
252
+ }
253
+ if (stat.size < cached.offset || (stat.size === cached.offset && cached.mtimeMs && stat.mtimeMs !== cached.mtimeMs)) {
254
+ cached.offset = 0;
255
+ cached.messages = [];
256
+ }
257
+ if (stat.size === cached.offset)
258
+ return false;
259
+ const bytes = stat.size - cached.offset;
260
+ const buffer = Buffer.allocUnsafe(bytes);
261
+ let fd = null;
262
+ try {
263
+ fd = fs.openSync(file, 'r');
264
+ fs.readSync(fd, buffer, 0, bytes, cached.offset);
265
+ }
266
+ catch {
267
+ return false;
268
+ }
269
+ finally {
270
+ if (fd != null)
271
+ try {
272
+ fs.closeSync(fd);
273
+ }
274
+ catch { }
275
+ }
276
+ let completeBytes = buffer.length;
277
+ const lastBreak = buffer.lastIndexOf(0x0a);
278
+ if (lastBreak !== buffer.length - 1) {
279
+ const trailing = buffer.subarray(lastBreak + 1).toString('utf8').trim();
193
280
  try {
194
- entries = fs.readdirSync(dir, { withFileTypes: true });
281
+ JSON.parse(trailing);
195
282
  }
196
283
  catch {
197
- return;
284
+ completeBytes = lastBreak + 1;
198
285
  }
199
- for (const entry of entries) {
200
- const full = path.join(dir, entry.name);
201
- if (entry.isDirectory())
202
- visit(full);
203
- else if (entry.isFile() && entry.name === 'messages.jsonl')
204
- out.push(full);
286
+ }
287
+ if (completeBytes <= 0)
288
+ return false;
289
+ let parsedLines = 0;
290
+ for (const line of buffer.subarray(0, completeBytes).toString('utf8').split(/\r?\n/)) {
291
+ if (!line)
292
+ continue;
293
+ try {
294
+ const message = JSON.parse(line);
295
+ parsedLines++;
296
+ if (!isTransientProtocolMessage(message))
297
+ cached.messages.push(message);
205
298
  }
206
- };
207
- visit(root);
208
- return out;
299
+ catch { }
300
+ }
301
+ cached.offset += completeBytes;
302
+ cached.mtimeMs = stat.mtimeMs;
303
+ metrics.changedFiles++;
304
+ metrics.parsedLines += parsedLines;
305
+ metrics.parsedBytes += completeBytes;
306
+ return true;
307
+ }
308
+ function cachedMessageFile(file, metrics) {
309
+ let cached = messageFileCache.get(file);
310
+ if (!cached) {
311
+ cached = {
312
+ offset: 0,
313
+ mtimeMs: 0,
314
+ messages: [],
315
+ active: {},
316
+ threadSessions: [],
317
+ mainSessionIds: new Set(),
318
+ metadataSignature: '',
319
+ };
320
+ messageFileCache.set(file, cached);
321
+ }
322
+ appendMessageRows(file, cached, metrics);
323
+ const dirPath = path.dirname(file);
324
+ const metadataSignature = chatMetadataSignature(dirPath);
325
+ if (metadataSignature !== cached.metadataSignature) {
326
+ cached.active = readJsonFile(path.join(dirPath, 'active.json')) ?? {};
327
+ cached.threadSessions = loadThreadSessions(dirPath);
328
+ cached.mainSessionIds = loadMainSessionIds(dirPath, cached.active);
329
+ cached.metadataSignature = metadataSignature;
330
+ metrics.metadataRefreshes++;
331
+ }
332
+ return cached;
209
333
  }
210
334
  function readLastJsonlObject(file) {
211
335
  const rows = readAllJsonlLines(file);
@@ -348,9 +472,6 @@ export async function getAgentSelfNames() {
348
472
  agentDisplayNamesCache = { expiresAt: Date.now() + 5000, names };
349
473
  return names;
350
474
  }
351
- function decodeDirSegment(seg) {
352
- return seg.replace(/%([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
353
- }
354
475
  function parseChannelKey(value) {
355
476
  if (typeof value !== 'string')
356
477
  return null;
@@ -375,10 +496,38 @@ function parseLegacySessionKey(value, channelType) {
375
496
  return null;
376
497
  return { type: parts[0], selfAID: parts[1], name: parts[2] || 'main' };
377
498
  }
378
- function pathIdentity(sessionsDir, dirPath) {
499
+ export function pathIdentity(sessionsDir, dirPath) {
379
500
  const relParts = path.relative(sessionsDir, dirPath).split(path.sep).filter(Boolean);
380
501
  if (!relParts.length)
381
502
  return {};
503
+ // Current layout: agents/<aid>/sessions/<channelSessionKey>/
504
+ if (relParts.length === 3 && relParts[1] === 'sessions') {
505
+ const selfAID = decodeDirSegment(relParts[0]);
506
+ const parsed = tryParseChannelSessionKey(relParts[2]);
507
+ if (parsed) {
508
+ return {
509
+ channelType: parsed.channelType,
510
+ channel: parsed.channelType === 'daemon'
511
+ ? 'daemon'
512
+ : `${parsed.channelType}#${selfAID}#${parsed.channelName}`,
513
+ channelId: parsed.channelId,
514
+ selfAID,
515
+ };
516
+ }
517
+ }
518
+ // Immediately preceding Agent layout, retained for inspecting backups.
519
+ if (relParts.length >= 4 && relParts[1] === 'sessions') {
520
+ const selfAID = decodeDirSegment(relParts[0]);
521
+ const channelKey = decodeDirSegment(relParts[2]);
522
+ const parsed = parseChannelKey(channelKey);
523
+ return {
524
+ channelType: parsed?.type || channelKey,
525
+ channel: channelKey,
526
+ channelId: decodeDirSegment(relParts[3]),
527
+ selfAID: parsed?.selfAID || selfAID,
528
+ };
529
+ }
530
+ // Pre-migration path fallback for viewing a backup directory only.
382
531
  const firstKey = parseChannelKey(decodeDirSegment(relParts[0]));
383
532
  if (firstKey) {
384
533
  return {
@@ -467,17 +616,22 @@ function chatDisplayId(active, messages) {
467
616
  groupName: null,
468
617
  };
469
618
  }
470
- function loadChats(peerDisplayResolver) {
619
+ function loadChats(peerDisplayResolver, metrics) {
471
620
  const paths = resolvePaths();
472
621
  const sessionsDir = paths.sessionsDir;
473
622
  const configuredAids = listConfiguredAgentAids(paths.root);
474
623
  const files = findMessageFiles(sessionsDir);
624
+ const liveFiles = new Set(files);
625
+ for (const file of messageFileCache.keys()) {
626
+ if (!liveFiles.has(file))
627
+ messageFileCache.delete(file);
628
+ }
629
+ metrics.files = files.length;
475
630
  const chats = [];
476
631
  for (const file of files) {
477
632
  const dirPath = path.dirname(file);
478
- const active = readJsonFile(path.join(dirPath, 'active.json')) ?? {};
479
- const messages = readAllJsonlLines(file)
480
- .filter(m => !isTransientProtocolMessage(m));
633
+ const cached = cachedMessageFile(file, metrics);
634
+ const { active, messages } = cached;
481
635
  if (!messages.length)
482
636
  continue;
483
637
  const identity = normalizeChatIdentity(sessionsDir, dirPath, active, messages);
@@ -489,9 +643,9 @@ function loadChats(peerDisplayResolver) {
489
643
  const peerName = chatType === 'group'
490
644
  ? display.peerName
491
645
  : resolveContactPeerName(peerDisplayResolver, identity, display.peerId, display.peerName);
492
- const threadSessions = loadThreadSessions(dirPath);
646
+ const threadSessions = cached.threadSessions;
493
647
  const threadById = new Map(threadSessions.map(item => [item.threadId, item]));
494
- const mainSessionIds = loadMainSessionIds(dirPath, active);
648
+ const mainSessionIds = cached.mainSessionIds;
495
649
  for (const item of partitionMessages(messages, threadSessions, mainSessionIds)) {
496
650
  if (!item.messages.length)
497
651
  continue;
@@ -650,7 +804,7 @@ function selectedChats(chats, scope) {
650
804
  return [];
651
805
  return chats.filter(chat => scopeId(chat) === scope);
652
806
  }
653
- function messagesFor(chats, peer) {
807
+ function messagesFor(chats, peer, before, limit) {
654
808
  const selected = peer
655
809
  ? chats.filter(chat => chatKey(chat) === peer)
656
810
  : chats;
@@ -665,7 +819,13 @@ function messagesFor(chats, peer) {
665
819
  threadName: chat.threadName,
666
820
  })));
667
821
  messages.sort((a, b) => (a.ts || 0) - (b.ts || 0));
668
- return messages.length > 1000 ? messages.slice(-1000) : messages;
822
+ const end = before == null ? messages.length : Math.min(Math.max(before, 0), messages.length);
823
+ const start = Math.max(0, end - limit);
824
+ return {
825
+ messages: messages.slice(start, end),
826
+ nextBefore: start > 0 ? start : null,
827
+ hasMore: start > 0,
828
+ };
669
829
  }
670
830
  function latestInbound(chat) {
671
831
  for (let i = chat.messages.length - 1; i >= 0; i--) {
@@ -745,8 +905,20 @@ async function buildSessionContext(chat) {
745
905
  };
746
906
  }
747
907
  async function buildSnapshot(params) {
908
+ const startedAt = Date.now();
909
+ const metrics = {
910
+ files: 0,
911
+ changedFiles: 0,
912
+ parsedLines: 0,
913
+ parsedBytes: 0,
914
+ metadataRefreshes: 0,
915
+ scanMs: 0,
916
+ totalMs: 0,
917
+ };
748
918
  const peerDisplayResolver = await getPeerDisplayResolver();
749
- const chats = loadChats(peerDisplayResolver);
919
+ const scanStartedAt = Date.now();
920
+ const chats = loadChats(peerDisplayResolver, metrics);
921
+ metrics.scanMs = Date.now() - scanStartedAt;
750
922
  const scopes = buildScopes(chats, await getAgentSelfNames());
751
923
  const requestedScope = params.scope || params.aid || null;
752
924
  const scope = requestedScope && !scopes.some(s => s.id === requestedScope)
@@ -759,18 +931,30 @@ async function buildSnapshot(params) {
759
931
  if (scope)
760
932
  await hydrateAunGroupNames(inScope);
761
933
  const peers = scope ? mergePeerInfo(inScope) : [];
762
- const messages = scope ? messagesFor(inScope, peer) : [];
934
+ const requestedLimit = Number(params.limit);
935
+ const limit = Number.isFinite(requestedLimit) ? Math.min(Math.max(Math.trunc(requestedLimit), 20), 200) : 100;
936
+ const requestedBefore = params.before == null ? Number.NaN : Number(params.before);
937
+ const before = Number.isFinite(requestedBefore) && requestedBefore >= 0 ? Math.trunc(requestedBefore) : null;
938
+ const page = scope && peer ? messagesFor(inScope, peer, before, limit) : { messages: [], nextBefore: null, hasMore: false };
763
939
  const currentChat = peer ? inScope.find(chat => chatKey(chat) === peer) : undefined;
764
940
  const sessionContext = await buildSessionContext(currentChat);
941
+ metrics.totalMs = Date.now() - startedAt;
765
942
  return {
766
943
  scopes,
767
944
  aids: scopes,
768
945
  peers,
769
- messages,
946
+ messages: page.messages,
947
+ messagePage: {
948
+ limit,
949
+ before,
950
+ nextBefore: page.nextBefore,
951
+ hasMore: page.hasMore,
952
+ },
770
953
  scope,
771
954
  aid: scope,
772
955
  peer,
773
956
  sessionContext,
957
+ metrics,
774
958
  };
775
959
  }
776
960
  export const msgSource = {