ec-web 0.0.26 → 0.0.28
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/index.js +1 -1
- package/dist/paths.js +4 -0
- package/dist/server.js +35 -20
- package/dist/sources/msg.js +21 -4
- package/dist/sources/role-definitions.js +53 -29
- package/dist/sources/session-codex.js +23 -5
- package/dist/sources/session-ecagent.js +665 -0
- package/dist/sources/session-origin.js +16 -0
- package/dist/sources/session.js +69 -165
- package/dist/sources/system.js +1 -33
- package/dist/sources/triggers.js +1 -1
- package/dist/static/app.js +340 -98
- package/dist/static/basic-settings.js +3 -3
- package/dist/static/index.html +1 -1
- package/dist/static/style.css +37 -0
- package/package.json +1 -1
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EC Agent 会话数据源 — EvolCore 内置基座(ecagent)的转录展示。
|
|
3
|
+
*
|
|
4
|
+
* 数据源:
|
|
5
|
+
* - $EVOLCORE_HOME/agents/<aid>/sessions/--<encoded-project>--/<ts>_<sessionId>.jsonl
|
|
6
|
+
*
|
|
7
|
+
* 文件格式(ecagent JsonlSessionRepo):
|
|
8
|
+
* 第一行 { type: 'session', version, id, timestamp, cwd }
|
|
9
|
+
* 其余行 { type: 'message', id, parentId, timestamp, message }
|
|
10
|
+
* message.role ∈ user | assistant | toolResult
|
|
11
|
+
*
|
|
12
|
+
* 与 Claude / Codex session source 接口对齐,返回相同的数据结构。
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import { resolvePaths } from '../paths.js';
|
|
17
|
+
import { getAgentSelfNames } from './msg.js';
|
|
18
|
+
import { parseTranscriptResultKey, previewLargeToolResults, windowTranscriptTurns } from './transcript-window.js';
|
|
19
|
+
import { buildSessionBindMap, readRunnerTaskTurns, classifyTranscriptSource, isEvolcoreRoutedMessage, resolveSessionBindSelfName, } from './session-origin.js';
|
|
20
|
+
let _dlog = null;
|
|
21
|
+
export function setDebugLog(log) { _dlog = log; }
|
|
22
|
+
function dlog(line) { if (_dlog)
|
|
23
|
+
try {
|
|
24
|
+
_dlog(line);
|
|
25
|
+
}
|
|
26
|
+
catch { } }
|
|
27
|
+
// ── 目录/文件解析 ──
|
|
28
|
+
/** 与 EcagentRunner.sessionsRoot()/projectDirectory 保持一致的目录命名。 */
|
|
29
|
+
function projectDirName(projectPath) {
|
|
30
|
+
const encoded = path.resolve(projectPath).replace(/^[/\\]/, '').replace(/[/\\:]/g, '-');
|
|
31
|
+
return `--${encoded}--`;
|
|
32
|
+
}
|
|
33
|
+
function isProjectDirName(name) {
|
|
34
|
+
return name.startsWith('--') && name.endsWith('--') && name.length > 4;
|
|
35
|
+
}
|
|
36
|
+
function agentsRoot() {
|
|
37
|
+
return resolvePaths().sessionsDir;
|
|
38
|
+
}
|
|
39
|
+
function listAgentSessionsDirs() {
|
|
40
|
+
const root = agentsRoot();
|
|
41
|
+
let entries;
|
|
42
|
+
try {
|
|
43
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
if (!entry.isDirectory())
|
|
51
|
+
continue;
|
|
52
|
+
out.push({ agentAid: entry.name, sessionsDir: path.join(root, entry.name, 'sessions') });
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
function readHeader(file) {
|
|
57
|
+
try {
|
|
58
|
+
const fd = fs.openSync(file, 'r');
|
|
59
|
+
const buf = Buffer.alloc(16384);
|
|
60
|
+
const n = fs.readSync(fd, buf, 0, 16384, 0);
|
|
61
|
+
fs.closeSync(fd);
|
|
62
|
+
const firstLine = buf.toString('utf-8', 0, n).split('\n', 1)[0];
|
|
63
|
+
if (!firstLine.trim())
|
|
64
|
+
return null;
|
|
65
|
+
const parsed = JSON.parse(firstLine);
|
|
66
|
+
if (parsed?.type !== 'session' || typeof parsed.id !== 'string')
|
|
67
|
+
return null;
|
|
68
|
+
return {
|
|
69
|
+
id: parsed.id,
|
|
70
|
+
cwd: typeof parsed.cwd === 'string' ? parsed.cwd : '',
|
|
71
|
+
timestamp: typeof parsed.timestamp === 'string' ? parsed.timestamp : '',
|
|
72
|
+
version: parsed.version == null ? '' : String(parsed.version),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** 文件名形如 <ISO 时间戳(冒号替换为 -)>_<sessionId>.jsonl。 */
|
|
80
|
+
function sessionIdFromFileName(name) {
|
|
81
|
+
if (!name.endsWith('.jsonl'))
|
|
82
|
+
return null;
|
|
83
|
+
const base = name.slice(0, -'.jsonl'.length);
|
|
84
|
+
const sep = base.indexOf('_');
|
|
85
|
+
if (sep < 0)
|
|
86
|
+
return null;
|
|
87
|
+
const id = base.slice(sep + 1);
|
|
88
|
+
return id || null;
|
|
89
|
+
}
|
|
90
|
+
function listSessionFiles(agentAid, dir) {
|
|
91
|
+
let names;
|
|
92
|
+
try {
|
|
93
|
+
names = fs.readdirSync(dir).filter(name => name.endsWith('.jsonl'));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const name of names) {
|
|
100
|
+
const id = sessionIdFromFileName(name);
|
|
101
|
+
if (!id)
|
|
102
|
+
continue;
|
|
103
|
+
out.push({ agentAid, dir, file: path.join(dir, name), id });
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
// ── 项目列表 ──
|
|
108
|
+
function listProjects() {
|
|
109
|
+
const byCwd = new Map();
|
|
110
|
+
for (const { agentAid, sessionsDir } of listAgentSessionsDirs()) {
|
|
111
|
+
let dirs;
|
|
112
|
+
try {
|
|
113
|
+
dirs = fs.readdirSync(sessionsDir, { withFileTypes: true });
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
for (const dirEntry of dirs) {
|
|
119
|
+
if (!dirEntry.isDirectory() || !isProjectDirName(dirEntry.name))
|
|
120
|
+
continue;
|
|
121
|
+
const dir = path.join(sessionsDir, dirEntry.name);
|
|
122
|
+
const files = listSessionFiles(agentAid, dir);
|
|
123
|
+
if (!files.length)
|
|
124
|
+
continue;
|
|
125
|
+
let lastActivity = 0;
|
|
126
|
+
let newest = null;
|
|
127
|
+
for (const ref of files) {
|
|
128
|
+
try {
|
|
129
|
+
const mtime = fs.statSync(ref.file).mtimeMs;
|
|
130
|
+
if (mtime > lastActivity) {
|
|
131
|
+
lastActivity = mtime;
|
|
132
|
+
newest = ref;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch { }
|
|
136
|
+
}
|
|
137
|
+
const cwd = (newest ? readHeader(newest.file)?.cwd : '') || '';
|
|
138
|
+
if (!cwd)
|
|
139
|
+
continue;
|
|
140
|
+
const existing = byCwd.get(cwd);
|
|
141
|
+
if (existing) {
|
|
142
|
+
existing.count += files.length;
|
|
143
|
+
if (lastActivity > existing.lastActivity) {
|
|
144
|
+
existing.lastActivity = lastActivity;
|
|
145
|
+
existing.encoded = dirEntry.name;
|
|
146
|
+
}
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
byCwd.set(cwd, {
|
|
150
|
+
encoded: dirEntry.name,
|
|
151
|
+
cwd,
|
|
152
|
+
label: cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() || dirEntry.name,
|
|
153
|
+
count: files.length,
|
|
154
|
+
lastActivity,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return [...byCwd.values()].sort((a, b) => b.lastActivity - a.lastActivity);
|
|
159
|
+
}
|
|
160
|
+
function resolveProject(params, projects) {
|
|
161
|
+
if (params.project) {
|
|
162
|
+
const found = projects.find(p => p.encoded === params.project);
|
|
163
|
+
if (found)
|
|
164
|
+
return found;
|
|
165
|
+
}
|
|
166
|
+
const cwdProject = projects.find(p => projectDirName(p.cwd) === projectDirName(process.cwd()));
|
|
167
|
+
if (cwdProject)
|
|
168
|
+
return cwdProject;
|
|
169
|
+
return projects[0] || null;
|
|
170
|
+
}
|
|
171
|
+
// ── 条目解析 ──
|
|
172
|
+
function parseEntries(file) {
|
|
173
|
+
let raw;
|
|
174
|
+
try {
|
|
175
|
+
raw = fs.readFileSync(file, 'utf-8');
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
const out = [];
|
|
181
|
+
for (const line of raw.split('\n')) {
|
|
182
|
+
if (!line.trim())
|
|
183
|
+
continue;
|
|
184
|
+
try {
|
|
185
|
+
out.push(JSON.parse(line));
|
|
186
|
+
}
|
|
187
|
+
catch { }
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
function contentText(content) {
|
|
192
|
+
if (typeof content === 'string')
|
|
193
|
+
return content;
|
|
194
|
+
if (!Array.isArray(content))
|
|
195
|
+
return '';
|
|
196
|
+
return content
|
|
197
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
198
|
+
.map(block => block.text)
|
|
199
|
+
.join('\n');
|
|
200
|
+
}
|
|
201
|
+
function userText(message) {
|
|
202
|
+
const content = message?.content;
|
|
203
|
+
if (!Array.isArray(content))
|
|
204
|
+
return typeof content === 'string' ? content : '';
|
|
205
|
+
return content
|
|
206
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
207
|
+
.map((block) => block.text)
|
|
208
|
+
.join('\n');
|
|
209
|
+
}
|
|
210
|
+
function preview(text, limit = 120) {
|
|
211
|
+
return text.replace(/\s+/g, ' ').trim().slice(0, limit);
|
|
212
|
+
}
|
|
213
|
+
function sessionName(entries) {
|
|
214
|
+
let name = '';
|
|
215
|
+
for (const entry of entries) {
|
|
216
|
+
if (entry?.type === 'session_info' && typeof entry.name === 'string' && entry.name.trim())
|
|
217
|
+
name = entry.name.trim();
|
|
218
|
+
}
|
|
219
|
+
return name;
|
|
220
|
+
}
|
|
221
|
+
// ── 会话列表 ──
|
|
222
|
+
const CACHE_VERSION = 1;
|
|
223
|
+
const _metaCache = new Map();
|
|
224
|
+
const _detailCache = new Map();
|
|
225
|
+
const MAX_DETAIL_CACHE_ENTRIES = 6;
|
|
226
|
+
function cacheDir(encoded) {
|
|
227
|
+
return path.join(resolvePaths().dataDir, 'ecweb-cache', 'ecagent', encoded);
|
|
228
|
+
}
|
|
229
|
+
function readDiskCache(encoded, id, mtime, size) {
|
|
230
|
+
try {
|
|
231
|
+
const rec = JSON.parse(fs.readFileSync(path.join(cacheDir(encoded), `${id}.json`), 'utf-8'));
|
|
232
|
+
if (rec.v === CACHE_VERSION && rec.mtime === mtime && rec.size === size && rec.meta)
|
|
233
|
+
return rec.meta;
|
|
234
|
+
}
|
|
235
|
+
catch { }
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
function writeDiskCache(encoded, id, mtime, size, meta) {
|
|
239
|
+
try {
|
|
240
|
+
const dir = cacheDir(encoded);
|
|
241
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
242
|
+
const tmp = path.join(dir, `${id}.json.tmp`);
|
|
243
|
+
fs.writeFileSync(tmp, JSON.stringify({ v: CACHE_VERSION, mtime, size, meta }));
|
|
244
|
+
fs.renameSync(tmp, path.join(dir, `${id}.json`));
|
|
245
|
+
}
|
|
246
|
+
catch { }
|
|
247
|
+
}
|
|
248
|
+
function cacheTranscriptDetail(file, mtime, size, detail) {
|
|
249
|
+
_detailCache.delete(file);
|
|
250
|
+
_detailCache.set(file, { mtime, size, detail });
|
|
251
|
+
while (_detailCache.size > MAX_DETAIL_CACHE_ENTRIES) {
|
|
252
|
+
const oldest = _detailCache.keys().next().value;
|
|
253
|
+
if (!oldest)
|
|
254
|
+
break;
|
|
255
|
+
_detailCache.delete(oldest);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function pruneCacheFiles(encoded, validIds) {
|
|
259
|
+
const dir = cacheDir(encoded);
|
|
260
|
+
try {
|
|
261
|
+
for (const name of fs.readdirSync(dir)) {
|
|
262
|
+
const match = name.match(/^(.+)\.json(?:\.tmp)?$/);
|
|
263
|
+
if (!match || validIds.has(match[1]))
|
|
264
|
+
continue;
|
|
265
|
+
try {
|
|
266
|
+
fs.unlinkSync(path.join(dir, name));
|
|
267
|
+
}
|
|
268
|
+
catch { }
|
|
269
|
+
}
|
|
270
|
+
if (fs.readdirSync(dir).length === 0)
|
|
271
|
+
fs.rmdirSync(dir);
|
|
272
|
+
}
|
|
273
|
+
catch { }
|
|
274
|
+
}
|
|
275
|
+
function pruneCacheProjects(validProjects) {
|
|
276
|
+
const root = path.join(resolvePaths().dataDir, 'ecweb-cache', 'ecagent');
|
|
277
|
+
try {
|
|
278
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
279
|
+
if (!entry.isDirectory() || validProjects.has(entry.name))
|
|
280
|
+
continue;
|
|
281
|
+
try {
|
|
282
|
+
fs.rmSync(path.join(root, entry.name), { recursive: true, force: true });
|
|
283
|
+
}
|
|
284
|
+
catch { }
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
catch { }
|
|
288
|
+
}
|
|
289
|
+
function extractMeta(ref, stat) {
|
|
290
|
+
const entries = parseEntries(ref.file);
|
|
291
|
+
let firstUser = '', userMsgs = 0, totalMsgs = 0, hasRealUserInput = false, evolcoreRouted = false;
|
|
292
|
+
for (const entry of entries) {
|
|
293
|
+
if (entry?.type !== 'message')
|
|
294
|
+
continue;
|
|
295
|
+
const role = entry.message?.role;
|
|
296
|
+
if (role !== 'user' && role !== 'assistant' && role !== 'toolResult')
|
|
297
|
+
continue;
|
|
298
|
+
totalMsgs++;
|
|
299
|
+
if (role !== 'user')
|
|
300
|
+
continue;
|
|
301
|
+
userMsgs++;
|
|
302
|
+
const text = userText(entry.message);
|
|
303
|
+
if (!text.trim())
|
|
304
|
+
continue;
|
|
305
|
+
if (!firstUser)
|
|
306
|
+
firstUser = preview(text);
|
|
307
|
+
if (isEvolcoreRoutedMessage(text))
|
|
308
|
+
evolcoreRouted = true;
|
|
309
|
+
else
|
|
310
|
+
hasRealUserInput = true;
|
|
311
|
+
}
|
|
312
|
+
const title = sessionName(entries) || preview(firstUser, 50);
|
|
313
|
+
return {
|
|
314
|
+
id: ref.id,
|
|
315
|
+
title: title || 'Untitled',
|
|
316
|
+
firstUser,
|
|
317
|
+
gitBranch: '',
|
|
318
|
+
version: '',
|
|
319
|
+
userMsgs,
|
|
320
|
+
totalMsgs,
|
|
321
|
+
sizeKB: Math.round(stat.size / 1024),
|
|
322
|
+
lastActivity: stat.mtimeMs,
|
|
323
|
+
hasRealUserInput,
|
|
324
|
+
evolcoreRouted,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
function listTranscripts(encoded, cwd) {
|
|
328
|
+
const refs = [];
|
|
329
|
+
for (const { agentAid, sessionsDir } of listAgentSessionsDirs()) {
|
|
330
|
+
const dir = path.join(sessionsDir, encoded);
|
|
331
|
+
for (const ref of listSessionFiles(agentAid, dir)) {
|
|
332
|
+
if (readHeader(ref.file)?.cwd !== cwd)
|
|
333
|
+
continue;
|
|
334
|
+
refs.push(ref);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
pruneCacheFiles(encoded, new Set(refs.map(ref => ref.id)));
|
|
338
|
+
const out = [];
|
|
339
|
+
let parsed = 0, hitMem = 0, hitDisk = 0;
|
|
340
|
+
for (const ref of refs) {
|
|
341
|
+
let stat;
|
|
342
|
+
try {
|
|
343
|
+
stat = fs.statSync(ref.file);
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const mtime = stat.mtimeMs;
|
|
349
|
+
const mem = _metaCache.get(ref.file);
|
|
350
|
+
if (mem && mem.mtime === mtime) {
|
|
351
|
+
out.push(mem.meta);
|
|
352
|
+
hitMem++;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const disk = readDiskCache(encoded, ref.id, mtime, stat.size);
|
|
356
|
+
if (disk) {
|
|
357
|
+
_metaCache.set(ref.file, { mtime, meta: disk });
|
|
358
|
+
out.push(disk);
|
|
359
|
+
hitDisk++;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const meta = extractMeta(ref, stat);
|
|
363
|
+
_metaCache.set(ref.file, { mtime, meta });
|
|
364
|
+
writeDiskCache(encoded, ref.id, mtime, stat.size, meta);
|
|
365
|
+
out.push(meta);
|
|
366
|
+
parsed++;
|
|
367
|
+
}
|
|
368
|
+
dlog(`[ecagent] listTranscripts ${encoded.slice(-20)}: ${refs.length} sessions (mem=${hitMem} disk=${hitDisk} parsed=${parsed})`);
|
|
369
|
+
out.sort((a, b) => b.lastActivity - a.lastActivity);
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
function isEcagentCliCandidate(meta) {
|
|
373
|
+
return meta.hasRealUserInput && !meta.evolcoreRouted;
|
|
374
|
+
}
|
|
375
|
+
// ── 会话详情 ──
|
|
376
|
+
function emptyDetail(cwd = '') {
|
|
377
|
+
return {
|
|
378
|
+
turns: [], totalTurns: 0, userMsgs: 0, totalMsgs: 0,
|
|
379
|
+
counts: { userInput: 0, modelOutput: 0, toolCall: 0, toolResult: 0, msgSend: 0 },
|
|
380
|
+
inputTokens: 0, outputTokens: 0, contextTokens: 0, costUsd: 0,
|
|
381
|
+
model: '', gitBranch: '', version: '', title: '', cwd,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
function findSessionFile(sessionId, cwd, encoded) {
|
|
385
|
+
for (const { agentAid, sessionsDir } of listAgentSessionsDirs()) {
|
|
386
|
+
const dirs = encoded
|
|
387
|
+
? [path.join(sessionsDir, encoded)]
|
|
388
|
+
: (() => {
|
|
389
|
+
try {
|
|
390
|
+
return fs.readdirSync(sessionsDir, { withFileTypes: true })
|
|
391
|
+
.filter(entry => entry.isDirectory() && isProjectDirName(entry.name))
|
|
392
|
+
.map(entry => path.join(sessionsDir, entry.name));
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return [];
|
|
396
|
+
}
|
|
397
|
+
})();
|
|
398
|
+
for (const dir of dirs) {
|
|
399
|
+
for (const ref of listSessionFiles(agentAid, dir)) {
|
|
400
|
+
if (ref.id !== sessionId)
|
|
401
|
+
continue;
|
|
402
|
+
if (cwd && readHeader(ref.file)?.cwd !== cwd)
|
|
403
|
+
continue;
|
|
404
|
+
return ref;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
function readTranscriptFile(ref, readStats) {
|
|
411
|
+
const empty = emptyDetail();
|
|
412
|
+
let stat;
|
|
413
|
+
try {
|
|
414
|
+
stat = fs.statSync(ref.file);
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
return empty;
|
|
418
|
+
}
|
|
419
|
+
const cached = _detailCache.get(ref.file);
|
|
420
|
+
if (cached && cached.mtime === stat.mtimeMs && cached.size === stat.size) {
|
|
421
|
+
_detailCache.delete(ref.file);
|
|
422
|
+
_detailCache.set(ref.file, cached);
|
|
423
|
+
if (readStats)
|
|
424
|
+
readStats.detailCacheHit = true;
|
|
425
|
+
return cached.detail;
|
|
426
|
+
}
|
|
427
|
+
const entries = parseEntries(ref.file);
|
|
428
|
+
const header = entries.find(entry => entry?.type === 'session');
|
|
429
|
+
const detail = emptyDetail(typeof header?.cwd === 'string' ? header.cwd : '');
|
|
430
|
+
detail.version = header?.version == null ? '' : String(header.version);
|
|
431
|
+
detail.title = sessionName(entries);
|
|
432
|
+
let inTok = 0, outTok = 0, contextTokens = 0, costUsd = 0, model = '';
|
|
433
|
+
let firstUser = '';
|
|
434
|
+
for (const entry of entries) {
|
|
435
|
+
if (entry?.type !== 'message')
|
|
436
|
+
continue;
|
|
437
|
+
const message = entry.message || {};
|
|
438
|
+
const role = message.role;
|
|
439
|
+
const ts = entry.timestamp ? Date.parse(entry.timestamp) : (Number(message.timestamp) || 0);
|
|
440
|
+
const blocks = [];
|
|
441
|
+
if (role === 'user') {
|
|
442
|
+
detail.totalMsgs++;
|
|
443
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
444
|
+
for (const block of content) {
|
|
445
|
+
if (block?.type === 'text' && typeof block.text === 'string' && block.text) {
|
|
446
|
+
blocks.push({ kind: 'text', text: block.text });
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (!blocks.length)
|
|
450
|
+
continue;
|
|
451
|
+
detail.userMsgs++;
|
|
452
|
+
if (!firstUser) {
|
|
453
|
+
const text = userText(message);
|
|
454
|
+
if (text.trim())
|
|
455
|
+
firstUser = preview(text);
|
|
456
|
+
}
|
|
457
|
+
detail.counts.userInput++;
|
|
458
|
+
detail.turns.push({ role: 'user', ts, uuid: entry.id, category: 'user_input', blocks });
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (role === 'assistant') {
|
|
462
|
+
detail.totalMsgs++;
|
|
463
|
+
let hasToolUse = false;
|
|
464
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
465
|
+
for (const block of content) {
|
|
466
|
+
if (block?.type === 'text' && typeof block.text === 'string' && block.text) {
|
|
467
|
+
blocks.push({ kind: 'text', text: block.text });
|
|
468
|
+
}
|
|
469
|
+
else if (block?.type === 'thinking' && typeof block.thinking === 'string' && block.thinking) {
|
|
470
|
+
blocks.push({ kind: 'thinking', text: block.thinking });
|
|
471
|
+
}
|
|
472
|
+
else if (block?.type === 'toolCall') {
|
|
473
|
+
const input = block.arguments && typeof block.arguments === 'object' ? block.arguments : {};
|
|
474
|
+
blocks.push({
|
|
475
|
+
kind: 'tool_use',
|
|
476
|
+
name: block.name || '',
|
|
477
|
+
input,
|
|
478
|
+
inputStr: JSON.stringify(input, null, 2),
|
|
479
|
+
});
|
|
480
|
+
hasToolUse = true;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (message.model)
|
|
484
|
+
model = String(message.model);
|
|
485
|
+
const usage = message.usage || {};
|
|
486
|
+
if (usage.input || usage.output || usage.cacheRead || usage.cacheWrite) {
|
|
487
|
+
inTok += Number(usage.input) || 0;
|
|
488
|
+
outTok += Number(usage.output) || 0;
|
|
489
|
+
contextTokens = (Number(usage.input) || 0) + (Number(usage.cacheRead) || 0) + (Number(usage.cacheWrite) || 0);
|
|
490
|
+
costUsd += Number(usage.cost?.total) || 0;
|
|
491
|
+
}
|
|
492
|
+
if (!blocks.length)
|
|
493
|
+
continue;
|
|
494
|
+
const category = hasToolUse ? 'tool_call' : 'model_output';
|
|
495
|
+
if (category === 'tool_call')
|
|
496
|
+
detail.counts.toolCall++;
|
|
497
|
+
else
|
|
498
|
+
detail.counts.modelOutput++;
|
|
499
|
+
detail.turns.push({ role: 'assistant', ts, uuid: entry.id, category, blocks });
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (role === 'toolResult') {
|
|
503
|
+
detail.totalMsgs++;
|
|
504
|
+
const text = contentText(message.content);
|
|
505
|
+
detail.counts.toolResult++;
|
|
506
|
+
detail.turns.push({
|
|
507
|
+
role: 'user',
|
|
508
|
+
ts,
|
|
509
|
+
uuid: entry.id,
|
|
510
|
+
category: 'tool_result',
|
|
511
|
+
blocks: [{ kind: 'tool_result', text, isError: message.isError === true }],
|
|
512
|
+
});
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
detail.inputTokens = inTok;
|
|
517
|
+
detail.outputTokens = outTok;
|
|
518
|
+
detail.contextTokens = contextTokens;
|
|
519
|
+
detail.costUsd = costUsd;
|
|
520
|
+
detail.model = model;
|
|
521
|
+
if (!detail.title)
|
|
522
|
+
detail.title = preview(firstUser, 50);
|
|
523
|
+
cacheTranscriptDetail(ref.file, stat.mtimeMs, stat.size, detail);
|
|
524
|
+
return detail;
|
|
525
|
+
}
|
|
526
|
+
// ── 轮次聚合(转录 + runner 子任务)──
|
|
527
|
+
async function collectTurns(sessionId, cwd, encoded, bind, readStats) {
|
|
528
|
+
const ref = findSessionFile(sessionId, cwd, encoded);
|
|
529
|
+
const detail = ref ? readTranscriptFile(ref, readStats) : emptyDetail(cwd);
|
|
530
|
+
const taskTurns = bind?.evolSessionId ? await readRunnerTaskTurns(resolvePaths().logs, bind.evolSessionId) : [];
|
|
531
|
+
const turns = detail.turns.concat(taskTurns).sort((a, b) => (a.ts || 0) - (b.ts || 0));
|
|
532
|
+
return { detail, turns, ref };
|
|
533
|
+
}
|
|
534
|
+
// ── Snapshot ──
|
|
535
|
+
async function buildSnapshot(params) {
|
|
536
|
+
const projects = listProjects();
|
|
537
|
+
const project = resolveProject(params, projects);
|
|
538
|
+
if (!project) {
|
|
539
|
+
return { baseagent: 'ecagent', projects: [], project: null, transcripts: [], turns: [], sessionId: null };
|
|
540
|
+
}
|
|
541
|
+
const bindMap = buildSessionBindMap();
|
|
542
|
+
const metas = listTranscripts(project.encoded, project.cwd);
|
|
543
|
+
const selfNames = await getAgentSelfNames();
|
|
544
|
+
const transcripts = metas.map(meta => {
|
|
545
|
+
const bind = bindMap.get(meta.id);
|
|
546
|
+
return {
|
|
547
|
+
...meta,
|
|
548
|
+
title: bind?.name?.trim() || meta.title,
|
|
549
|
+
sourceType: classifyTranscriptSource(bind, isEcagentCliCandidate(meta)),
|
|
550
|
+
bound: !!bind,
|
|
551
|
+
boundChannel: bind?.channelType ?? null,
|
|
552
|
+
boundSelf: resolveSessionBindSelfName(bind, selfNames),
|
|
553
|
+
online: bind ? (Date.now() - bind.updatedAt < 5 * 60 * 1000) : false,
|
|
554
|
+
};
|
|
555
|
+
});
|
|
556
|
+
const projList = projects.map(p => ({ encoded: p.encoded, label: p.label, cwd: p.cwd, count: p.count }));
|
|
557
|
+
const sessionId = params.sessionId || null;
|
|
558
|
+
if (!sessionId) {
|
|
559
|
+
return { baseagent: 'ecagent', projects: projList, project: project.encoded, transcripts, turns: [], sessionId: null };
|
|
560
|
+
}
|
|
561
|
+
const bind = bindMap.get(sessionId);
|
|
562
|
+
const { detail, turns } = await collectTurns(sessionId, project.cwd, project.encoded, bind);
|
|
563
|
+
const page = windowTranscriptTurns(turns, params.turnLimit, params.turnBefore);
|
|
564
|
+
const counts = { ...detail.counts, subtask: turns.length - detail.turns.length };
|
|
565
|
+
const transcript = metas.find(meta => meta.id === sessionId);
|
|
566
|
+
const header = {
|
|
567
|
+
sessionId,
|
|
568
|
+
title: bind?.name?.trim() || detail.title || transcript?.title || '',
|
|
569
|
+
model: detail.model,
|
|
570
|
+
gitBranch: detail.gitBranch,
|
|
571
|
+
version: detail.version,
|
|
572
|
+
cwd: detail.cwd || project.cwd,
|
|
573
|
+
totalTurns: detail.totalTurns,
|
|
574
|
+
userMsgs: detail.userMsgs,
|
|
575
|
+
totalMsgs: detail.totalMsgs,
|
|
576
|
+
counts,
|
|
577
|
+
inputTokens: detail.inputTokens,
|
|
578
|
+
outputTokens: detail.outputTokens,
|
|
579
|
+
contextTokens: detail.contextTokens,
|
|
580
|
+
costUsd: detail.costUsd,
|
|
581
|
+
sourceType: classifyTranscriptSource(bind, transcript ? isEcagentCliCandidate(transcript) : false),
|
|
582
|
+
bound: !!bind,
|
|
583
|
+
boundChannel: bind?.channelType ?? null,
|
|
584
|
+
boundSelf: resolveSessionBindSelfName(bind, selfNames),
|
|
585
|
+
online: bind ? (Date.now() - bind.updatedAt < 5 * 60 * 1000) : false,
|
|
586
|
+
};
|
|
587
|
+
return {
|
|
588
|
+
baseagent: 'ecagent',
|
|
589
|
+
projects: projList,
|
|
590
|
+
project: project.encoded,
|
|
591
|
+
transcripts,
|
|
592
|
+
turns: previewLargeToolResults(page.turns, page.turnPage.start),
|
|
593
|
+
turnPage: page.turnPage,
|
|
594
|
+
sessionId,
|
|
595
|
+
header,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
export async function readEcagentSessionResult(params) {
|
|
599
|
+
const projects = listProjects();
|
|
600
|
+
const project = resolveProject(params, projects);
|
|
601
|
+
const sessionId = typeof params.sessionId === 'string' ? params.sessionId : '';
|
|
602
|
+
const key = parseTranscriptResultKey(params.resultKey);
|
|
603
|
+
if (!project || !sessionId || !key)
|
|
604
|
+
return null;
|
|
605
|
+
const bind = buildSessionBindMap().get(sessionId);
|
|
606
|
+
const { turns } = await collectTurns(sessionId, project.cwd, project.encoded, bind);
|
|
607
|
+
const block = turns[key[0]]?.blocks?.[key[1]];
|
|
608
|
+
return block?.kind === 'tool_result' && typeof block.text === 'string' ? block.text : null;
|
|
609
|
+
}
|
|
610
|
+
// ── Export ──
|
|
611
|
+
export async function snapshotEcagent(params = {}) {
|
|
612
|
+
return buildSnapshot(params);
|
|
613
|
+
}
|
|
614
|
+
export function subscribeEcagent(params, push) {
|
|
615
|
+
const p = resolvePaths();
|
|
616
|
+
const watchers = [];
|
|
617
|
+
let debounce = null;
|
|
618
|
+
const fire = () => {
|
|
619
|
+
if (debounce)
|
|
620
|
+
clearTimeout(debounce);
|
|
621
|
+
debounce = setTimeout(() => {
|
|
622
|
+
void buildSnapshot(params).then(push).catch(() => { });
|
|
623
|
+
}, 300);
|
|
624
|
+
};
|
|
625
|
+
const project = resolveProject(params, listProjects());
|
|
626
|
+
for (const { sessionsDir } of listAgentSessionsDirs()) {
|
|
627
|
+
if (project) {
|
|
628
|
+
const dir = path.join(sessionsDir, project.encoded);
|
|
629
|
+
try {
|
|
630
|
+
watchers.push(fs.watch(dir, (_event, filename) => {
|
|
631
|
+
if (!filename || String(filename).endsWith('.jsonl'))
|
|
632
|
+
fire();
|
|
633
|
+
}));
|
|
634
|
+
}
|
|
635
|
+
catch { }
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
try {
|
|
639
|
+
watchers.push(fs.watch(p.sessionsDir, { recursive: true }, (_event, filename) => {
|
|
640
|
+
const name = filename ? String(filename) : '';
|
|
641
|
+
if (!name || name.endsWith('.jsonl') || name.endsWith('active.json'))
|
|
642
|
+
fire();
|
|
643
|
+
}));
|
|
644
|
+
}
|
|
645
|
+
catch { }
|
|
646
|
+
if (params.sessionId) {
|
|
647
|
+
try {
|
|
648
|
+
watchers.push(fs.watch(p.logs, (_event, filename) => {
|
|
649
|
+
if (!filename || /^events(?:-.+)?\.log$/.test(String(filename)))
|
|
650
|
+
fire();
|
|
651
|
+
}));
|
|
652
|
+
}
|
|
653
|
+
catch { }
|
|
654
|
+
}
|
|
655
|
+
return () => {
|
|
656
|
+
for (const watcher of watchers) {
|
|
657
|
+
try {
|
|
658
|
+
watcher.close();
|
|
659
|
+
}
|
|
660
|
+
catch { }
|
|
661
|
+
}
|
|
662
|
+
if (debounce)
|
|
663
|
+
clearTimeout(debounce);
|
|
664
|
+
};
|
|
665
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import { resolveParentDistModule, toFileUrl } from './parent-package.js';
|
|
3
4
|
import { resolvePaths } from '../paths.js';
|
|
4
5
|
import { readJsonFile, scanChatDirs } from '../fs-utils.js';
|
|
5
6
|
/** Resolve the local Agent's display name for a session binding. */
|
|
@@ -193,3 +194,18 @@ export function classifyTranscriptSource(binding, cliCandidate) {
|
|
|
193
194
|
return binding.sourceType;
|
|
194
195
|
return 'unknown';
|
|
195
196
|
}
|
|
197
|
+
let historyModulePromise = null;
|
|
198
|
+
async function historyModule() {
|
|
199
|
+
if (!historyModulePromise) {
|
|
200
|
+
historyModulePromise = import(toFileUrl(resolveParentDistModule('core', 'session', 'runner-task-history.js')));
|
|
201
|
+
}
|
|
202
|
+
return historyModulePromise;
|
|
203
|
+
}
|
|
204
|
+
export async function readRunnerTaskTurns(logsDir, evolSessionId) {
|
|
205
|
+
const module = await historyModule();
|
|
206
|
+
return module.readRunnerTaskHistory(logsDir, evolSessionId).map((task) => ({
|
|
207
|
+
role: 'system', ts: task.startedAt,
|
|
208
|
+
uuid: `runner-task:${evolSessionId}:${task.taskId}`,
|
|
209
|
+
category: 'subtask', blocks: [{ kind: 'subtask', ...task }],
|
|
210
|
+
}));
|
|
211
|
+
}
|