evolclaw 3.1.6 → 3.1.8

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.
@@ -1,638 +0,0 @@
1
- /**
2
- * 会话数据源 — 展示 CC(Claude Code / Agent SDK)的会话日志。
3
- *
4
- * 列表数据源是 CC transcript 本身:~/.claude/projects/<encodedProject>/*.jsonl
5
- * 每个 .jsonl 就是一段对话历史。evolclaw 的 active.json 仅用于交叉标注
6
- * 「当前绑定 / 在线」(agentSessionId 匹配)。
7
- *
8
- * snapshot:
9
- * - 无 sessionId: 返回项目列表 + 选中项目的全部 transcript 概要
10
- * - 有 sessionId: 返回该 transcript 的结构化轮次 + 会话头统计
11
- * subscribe: 监听选中项目的 CC 目录 .jsonl 变化,防抖 150ms。
12
- */
13
- import fs from 'fs';
14
- import os from 'os';
15
- import path from 'path';
16
- import { resolvePaths } from '../../../paths.js';
17
- import { encodePath } from '../../../utils/cross-platform.js';
18
- import { scanChatDirs, readJsonFile } from '../../../core/session/session-fs-store.js';
19
- import { dlog } from '../debug-log.js';
20
- function ccProjectsDir() {
21
- return path.join(os.homedir(), '.claude', 'projects');
22
- }
23
- /** 扫描 evolclaw active.json,建 agentSessionId → 绑定信息 的映射 */
24
- function buildBindMap() {
25
- const map = new Map();
26
- try {
27
- const p = resolvePaths();
28
- for (const dir of scanChatDirs(p.sessionsDir)) {
29
- const active = readJsonFile(path.join(dir.dirPath, 'active.json'));
30
- if (!active || !active.agentSessionId)
31
- continue;
32
- map.set(active.agentSessionId, {
33
- channelType: active.channelType || dir.channelType,
34
- channelId: active.channelId || dir.channelId,
35
- selfAID: active.selfAID || dir.selfAID,
36
- peerName: (active.metadata && active.metadata.peerName) || null,
37
- name: active.name,
38
- updatedAt: active.updatedAt || 0,
39
- });
40
- }
41
- }
42
- catch { /* sessionsDir may not exist */ }
43
- return map;
44
- }
45
- /** 列出 ~/.claude/projects 下所有含 transcript 的项目 */
46
- function listProjects() {
47
- const base = ccProjectsDir();
48
- let dirs;
49
- try {
50
- dirs = fs.readdirSync(base, { withFileTypes: true });
51
- }
52
- catch {
53
- return [];
54
- }
55
- const out = [];
56
- for (const d of dirs) {
57
- if (!d.isDirectory())
58
- continue;
59
- const dirPath = path.join(base, d.name);
60
- let files;
61
- try {
62
- files = fs.readdirSync(dirPath).filter(f => f.endsWith('.jsonl'));
63
- }
64
- catch {
65
- continue;
66
- }
67
- if (!files.length)
68
- continue;
69
- let last = 0;
70
- for (const f of files) {
71
- try {
72
- const m = fs.statSync(path.join(dirPath, f)).mtimeMs;
73
- if (m > last)
74
- last = m;
75
- }
76
- catch { /* skip */ }
77
- }
78
- // cwd: 从最近 transcript 的首行读 cwd 字段,回退用解码目录名
79
- let cwd = '';
80
- try {
81
- const newest = files.map(f => ({ f, m: fs.statSync(path.join(dirPath, f)).mtimeMs }))
82
- .sort((a, b) => b.m - a.m)[0];
83
- if (newest)
84
- cwd = readCwdFromTranscript(path.join(dirPath, newest.f));
85
- }
86
- catch { /* ignore */ }
87
- if (!cwd)
88
- cwd = d.name;
89
- out.push({
90
- encoded: d.name,
91
- cwd,
92
- label: cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() || d.name,
93
- count: files.length,
94
- lastActivity: last,
95
- });
96
- }
97
- out.sort((a, b) => b.lastActivity - a.lastActivity);
98
- return out;
99
- }
100
- const CACHE_VERSION = 2; // 缓存格式版本,结构变更时 +1 使旧缓存失效
101
- const _metaCache = new Map();
102
- // ── 磁盘缓存:每个 CC 日志文件对应一个摘要文件 ──
103
- // 位置:$EVOLCLAW_HOME/data/watch-web-cache/<encodedProject>/<sessionId>.json
104
- // 失效判据:缓存内记录的 mtime/size 与源文件不一致,或 CACHE_VERSION 变更。
105
- function cacheDir(encoded) {
106
- return path.join(resolvePaths().dataDir, 'watch-web-cache', encoded);
107
- }
108
- function cacheFilePath(encoded, id) {
109
- return path.join(cacheDir(encoded), `${id}.json`);
110
- }
111
- function readDiskCache(encoded, id, mtime, size) {
112
- try {
113
- const raw = fs.readFileSync(cacheFilePath(encoded, id), 'utf-8');
114
- const rec = JSON.parse(raw);
115
- if (rec.v === CACHE_VERSION && rec.mtime === mtime && rec.size === size && rec.meta) {
116
- return rec.meta;
117
- }
118
- }
119
- catch { /* miss */ }
120
- return null;
121
- }
122
- function writeDiskCache(encoded, id, mtime, size, meta) {
123
- try {
124
- const dir = cacheDir(encoded);
125
- fs.mkdirSync(dir, { recursive: true });
126
- const rec = { v: CACHE_VERSION, mtime, size, meta };
127
- const tmp = cacheFilePath(encoded, id) + '.tmp';
128
- fs.writeFileSync(tmp, JSON.stringify(rec));
129
- fs.renameSync(tmp, cacheFilePath(encoded, id));
130
- }
131
- catch { /* best effort */ }
132
- }
133
- /** 读文件头部若干字节,提取首个带 cwd 字段的记录 */
134
- function readCwdFromTranscript(file) {
135
- try {
136
- const fd = fs.openSync(file, 'r');
137
- const buf = Buffer.alloc(16384);
138
- const n = fs.readSync(fd, buf, 0, 16384, 0);
139
- fs.closeSync(fd);
140
- for (const line of buf.toString('utf-8', 0, n).split('\n')) {
141
- if (!line.trim())
142
- continue;
143
- try {
144
- const o = JSON.parse(line);
145
- if (o.cwd)
146
- return o.cwd;
147
- }
148
- catch { /* 截断行跳过 */ }
149
- }
150
- }
151
- catch { /* ignore */ }
152
- return '';
153
- }
154
- /** 廉价提取单个 transcript 概要:读头部 32KB + 尾部 32KB,避免全读大文件 */
155
- /**
156
- * 全量解析 transcript,提取摘要(含精确消息数)。
157
- * 仅在缓存失效时调用一次;结果写入磁盘缓存,文件不变就不再读。
158
- */
159
- function extractMeta(file, id, stat) {
160
- let raw = '';
161
- try {
162
- raw = fs.readFileSync(file, 'utf-8');
163
- }
164
- catch { /* ignore */ }
165
- let title = '', firstUser = '', gitBranch = '', version = '';
166
- let userMsgs = 0, totalMsgs = 0;
167
- for (const line of raw.split('\n')) {
168
- if (!line)
169
- continue;
170
- // 廉价预筛:先用字符串判断类型再决定是否 JSON.parse(数消息不需要 parse)
171
- const isAsst = line.indexOf('"type":"assistant"') !== -1;
172
- const isUser = !isAsst && line.indexOf('"type":"user"') !== -1;
173
- if (isAsst)
174
- totalMsgs++;
175
- else if (isUser) {
176
- totalMsgs++;
177
- // 真实用户输入:content 不是 tool_result(工具结果也是 type:user)
178
- if (line.indexOf('"tool_result"') === -1)
179
- userMsgs++;
180
- }
181
- // 元数据只需在还没拿到时 parse
182
- if ((!gitBranch || !version || !firstUser) || isAsst === false) {
183
- // 仅对可能含元数据的行 parse:含 gitBranch/version/ai-title/首条 user
184
- if (line.indexOf('gitBranch') !== -1 || line.indexOf('"version"') !== -1 ||
185
- line.indexOf('ai-title') !== -1 || (isUser && !firstUser)) {
186
- let o;
187
- try {
188
- o = JSON.parse(line);
189
- }
190
- catch {
191
- continue;
192
- }
193
- if (!gitBranch && o.gitBranch)
194
- gitBranch = o.gitBranch;
195
- if (!version && o.version)
196
- version = o.version;
197
- if (o.type === 'ai-title' && o.title)
198
- title = o.title;
199
- if (!firstUser && o.type === 'user' && o.message) {
200
- const c = o.message.content;
201
- const t = typeof c === 'string' ? c : (Array.isArray(c) ? ((c.find((x) => x && x.type === 'text') || {}).text || '') : '');
202
- if (t && !t.startsWith('<'))
203
- firstUser = t.replace(/\s+/g, ' ').trim().slice(0, 120);
204
- }
205
- }
206
- }
207
- }
208
- return {
209
- id,
210
- title: title || '',
211
- firstUser,
212
- gitBranch,
213
- version,
214
- userMsgs,
215
- totalMsgs,
216
- sizeKB: Math.round(stat.size / 1024),
217
- lastActivity: stat.mtimeMs,
218
- };
219
- }
220
- /** 列出某项目下全部 transcript 概要(内存 + 磁盘双层缓存,按 mtime+size 失效) */
221
- function listTranscripts(encoded) {
222
- const dir = path.join(ccProjectsDir(), encoded);
223
- let files;
224
- try {
225
- files = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
226
- }
227
- catch {
228
- return [];
229
- }
230
- const out = [];
231
- let parsed = 0, hitMem = 0, hitDisk = 0;
232
- for (const f of files) {
233
- const file = path.join(dir, f);
234
- const id = f.replace(/\.jsonl$/, '');
235
- let stat;
236
- try {
237
- stat = fs.statSync(file);
238
- }
239
- catch {
240
- continue;
241
- }
242
- const mtime = stat.mtimeMs;
243
- // L1 内存缓存
244
- const mem = _metaCache.get(file);
245
- if (mem && mem.mtime === mtime) {
246
- out.push(mem.meta);
247
- hitMem++;
248
- continue;
249
- }
250
- // L2 磁盘缓存
251
- const disk = readDiskCache(encoded, id, mtime, stat.size);
252
- if (disk) {
253
- _metaCache.set(file, { mtime, meta: disk });
254
- out.push(disk);
255
- hitDisk++;
256
- continue;
257
- }
258
- // miss:全量解析 + 双写缓存
259
- const meta = extractMeta(file, id, stat);
260
- _metaCache.set(file, { mtime, meta });
261
- writeDiskCache(encoded, id, mtime, stat.size, meta);
262
- out.push(meta);
263
- parsed++;
264
- }
265
- dlog(`[session] listTranscripts ${encoded.slice(-20)}: ${files.length} files (mem=${hitMem} disk=${hitDisk} parsed=${parsed})`);
266
- out.sort((a, b) => b.lastActivity - a.lastActivity);
267
- return out;
268
- }
269
- function toolParams(name, input) {
270
- if (!input || typeof input !== 'object')
271
- return [];
272
- // 已知工具:挑最有信息量的参数优先展示
273
- const PRIMARY = {
274
- Read: ['file_path'],
275
- Write: ['file_path'],
276
- Edit: ['file_path'],
277
- MultiEdit: ['file_path'],
278
- NotebookEdit: ['notebook_path'],
279
- Bash: ['command'],
280
- Glob: ['pattern', 'path'],
281
- Grep: ['pattern', 'path', 'glob'],
282
- Task: ['description'],
283
- WebFetch: ['url'],
284
- WebSearch: ['query'],
285
- };
286
- const clip = (v, max = 400) => {
287
- let s = typeof v === 'string' ? v : JSON.stringify(v);
288
- if (s == null)
289
- return '';
290
- s = s.replace(/\s+/g, ' ').trim();
291
- return s.length > max ? s.slice(0, max) + '…' : s;
292
- };
293
- const keys = PRIMARY[name] || Object.keys(input);
294
- const params = [];
295
- for (const k of keys) {
296
- if (input[k] === undefined || input[k] === null || input[k] === '')
297
- continue;
298
- params.push({ k, v: clip(input[k]) });
299
- }
300
- return params;
301
- }
302
- /**
303
- * 检测一次 Bash 调用是否在执行 `ec msg send <self> <peer> "<text>"`
304
- * (也兼容 evolclaw / ec、group send 不算私聊对话)。
305
- * 返回解析出的发送方/对端/正文(完整文本),否则 null。
306
- */
307
- function detectMsgSend(name, input) {
308
- if (name !== 'Bash' || !input || typeof input.command !== 'string')
309
- return null;
310
- const cmd = input.command;
311
- // 必须是 msg send(私聊);排除 group send
312
- if (!/\b(ec|evolclaw)\s+msg\s+send\b/.test(cmd))
313
- return null;
314
- // 粗切:取 "msg send" 之后的部分,做简单 shell 分词(支持双引号/单引号)
315
- const after = cmd.replace(/^[\s\S]*?\bmsg\s+send\b/, '').trim();
316
- const tokens = [];
317
- const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
318
- let m;
319
- while ((m = re.exec(after)) !== null) {
320
- if (m[1] !== undefined)
321
- tokens.push(m[1].replace(/\\(["\\])/g, '$1'));
322
- else if (m[2] !== undefined)
323
- tokens.push(m[2]);
324
- else
325
- tokens.push(m[3]);
326
- }
327
- // 跳过 --flag / --opt value 形式的选项,取前两个位置参数为 self/peer,其余拼为正文
328
- const positional = [];
329
- for (let i = 0; i < tokens.length; i++) {
330
- const t = tokens[i];
331
- if (t.startsWith('-')) {
332
- if (!t.includes('=') && tokens[i + 1] && !tokens[i + 1].startsWith('-') && positional.length < 2)
333
- i++;
334
- continue;
335
- }
336
- positional.push(t);
337
- }
338
- if (positional.length < 3)
339
- return null;
340
- const self = positional[0];
341
- const peer = positional[1];
342
- const text = positional.slice(2).join(' ').trim();
343
- if (!text)
344
- return null;
345
- return { self, peer, text };
346
- }
347
- function extractBlocks(content) {
348
- if (typeof content === 'string') {
349
- return content.trim() ? [{ kind: 'text', text: content }] : [];
350
- }
351
- if (!Array.isArray(content))
352
- return [];
353
- const blocks = [];
354
- for (const block of content) {
355
- if (!block || typeof block !== 'object')
356
- continue;
357
- if (block.type === 'text' && block.text) {
358
- blocks.push({ kind: 'text', text: block.text });
359
- }
360
- else if (block.type === 'tool_use') {
361
- const chat = detectMsgSend(block.name, block.input);
362
- const b = { kind: 'tool_use', tool: block.name || '?', params: toolParams(block.name, block.input) };
363
- if (chat)
364
- b.chat = chat;
365
- blocks.push(b);
366
- }
367
- else if (block.type === 'tool_result') {
368
- const c = block.content;
369
- const txt = typeof c === 'string' ? c : Array.isArray(c) ? c.map((x) => x?.text || '').join('') : '';
370
- const clipped = txt.length > 1200 ? txt.slice(0, 1200) + `\n… (共 ${txt.length} 字符)` : txt;
371
- blocks.push({ kind: 'tool_result', text: clipped, isError: !!block.is_error });
372
- }
373
- else if (block.type === 'thinking') {
374
- blocks.push({ kind: 'thinking', text: block.thinking || '' });
375
- }
376
- }
377
- return blocks;
378
- }
379
- // 模型定价(USD per 1M tokens):[input, cacheWrite(5m), cacheRead, output]
380
- // 来源:Anthropic 官方定价 2026-06-01
381
- const PRICING = {
382
- 'claude-opus-4-8': [5, 6.25, 0.5, 25],
383
- 'claude-opus-4': [5, 6.25, 0.5, 25],
384
- 'claude-sonnet-4-6': [3, 3.75, 0.3, 15],
385
- 'claude-sonnet-4': [3, 3.75, 0.3, 15],
386
- 'claude-haiku-4-5': [0.8, 1, 0.08, 4],
387
- };
388
- function pricingFor(model) {
389
- if (!model)
390
- return PRICING['claude-opus-4-8'];
391
- for (const key of Object.keys(PRICING)) {
392
- if (model.startsWith(key))
393
- return PRICING[key];
394
- }
395
- if (model.includes('sonnet'))
396
- return PRICING['claude-sonnet-4-6'];
397
- if (model.includes('haiku'))
398
- return PRICING['claude-haiku-4-5'];
399
- return PRICING['claude-opus-4-8'];
400
- }
401
- function costForUsage(model, input, cacheRead, cacheCreate, output) {
402
- const [pi, pcw, pcr, po] = pricingFor(model);
403
- return (input * pi + cacheCreate * pcw + cacheRead * pcr + output * po) / 1_000_000;
404
- }
405
- function readTranscriptFile(file) {
406
- const empty = { turns: [], totalTurns: 0, userMsgs: 0, totalMsgs: 0, counts: { userInput: 0, modelOutput: 0, toolCall: 0, toolResult: 0, msgSend: 0 }, inputTokens: 0, outputTokens: 0, contextTokens: 0, costUsd: 0, model: '', gitBranch: '', version: '', title: '', cwd: '' };
407
- let raw;
408
- try {
409
- raw = fs.readFileSync(file, 'utf-8');
410
- }
411
- catch {
412
- return empty;
413
- }
414
- const turns = [];
415
- let inTok = 0, outTok = 0, model = '', branch = '', version = '', title = '', cwd = '';
416
- let userMsgs = 0, totalMsgs = 0;
417
- let contextTokens = 0, costUsd = 0;
418
- let lastUsageKey = ''; // CC 每条 assistant 消息写两次,按 usage 内容去重
419
- // 计数:用户输入 / 模型输出 / 工具调用 / 工具结果 / 发送消息
420
- let cUserInput = 0, cModelOutput = 0, cToolCall = 0, cToolResult = 0, cMsgSend = 0;
421
- for (const line of raw.split('\n')) {
422
- if (!line.trim())
423
- continue;
424
- // 消息计数与列表 extractMeta 口径一致:数 user/assistant,user 排除 tool_result
425
- const isAsst = line.indexOf('"type":"assistant"') !== -1;
426
- const isUser = !isAsst && line.indexOf('"type":"user"') !== -1;
427
- if (isAsst)
428
- totalMsgs++;
429
- else if (isUser) {
430
- totalMsgs++;
431
- if (line.indexOf('"tool_result"') === -1)
432
- userMsgs++;
433
- }
434
- let o;
435
- try {
436
- o = JSON.parse(line);
437
- }
438
- catch {
439
- continue;
440
- }
441
- if (!branch && o.gitBranch)
442
- branch = o.gitBranch;
443
- if (!version && o.version)
444
- version = o.version;
445
- if (!cwd && o.cwd)
446
- cwd = o.cwd;
447
- if (o.type === 'ai-title' && o.title)
448
- title = o.title;
449
- const type = o.type || 'other';
450
- if (type !== 'user' && type !== 'assistant' && type !== 'system')
451
- continue;
452
- const msg = o.message || {};
453
- const role = type === 'user' ? 'user' : type === 'assistant' ? 'assistant' : 'system';
454
- const blocks = type === 'system'
455
- ? (o.content || o.text ? [{ kind: 'text', text: String(o.content || o.text) }] : [])
456
- : extractBlocks(msg.content);
457
- if (!blocks.length)
458
- continue;
459
- // 归类(block 级,因为 CC 的 tool_result 在协议上是 type:user)
460
- const hasText = blocks.some(b => b.kind === 'text' || b.kind === 'thinking');
461
- const nToolUse = blocks.filter(b => b.kind === 'tool_use').length;
462
- const nToolResult = blocks.filter(b => b.kind === 'tool_result').length;
463
- let category;
464
- if (role === 'system')
465
- category = 'system';
466
- else if (role === 'user')
467
- category = hasText ? 'user_input' : 'tool_result';
468
- else
469
- category = (nToolUse > 0 && !hasText) ? 'tool_call' : 'model_output';
470
- if (category === 'user_input')
471
- cUserInput++;
472
- else if (category === 'model_output')
473
- cModelOutput++;
474
- cToolCall += nToolUse;
475
- cToolResult += nToolResult;
476
- cMsgSend += blocks.filter(b => b.kind === 'tool_use' && b.chat).length;
477
- const usage = msg.usage || {};
478
- if (usage.input_tokens)
479
- inTok += usage.input_tokens;
480
- if (usage.output_tokens)
481
- outTok += usage.output_tokens;
482
- if (msg.model)
483
- model = msg.model;
484
- // 上下文长度 + 费用:CC 每条 assistant 消息写两次(streaming),按 usage 内容去重
485
- if (type === 'assistant' && usage.input_tokens !== undefined) {
486
- const inp = usage.input_tokens || 0;
487
- const cr = usage.cache_read_input_tokens || 0;
488
- const cc = usage.cache_creation_input_tokens || 0;
489
- const out = usage.output_tokens || 0;
490
- contextTokens = inp + cr + cc;
491
- const key = `${inp},${cr},${cc},${out}`;
492
- if (key !== lastUsageKey) {
493
- lastUsageKey = key;
494
- costUsd += costForUsage(model || msg.model, inp, cr, cc, out);
495
- }
496
- }
497
- turns.push({
498
- role,
499
- type,
500
- category,
501
- blocks,
502
- model: msg.model,
503
- inputTokens: usage.input_tokens,
504
- outputTokens: usage.output_tokens,
505
- ts: o.timestamp ? Date.parse(o.timestamp) : 0,
506
- uuid: o.uuid || '',
507
- });
508
- }
509
- const totalTurns = turns.length;
510
- const shown = totalTurns > 500 ? turns.slice(-500) : turns;
511
- return {
512
- turns: shown, totalTurns, userMsgs, totalMsgs,
513
- counts: { userInput: cUserInput, modelOutput: cModelOutput, toolCall: cToolCall, toolResult: cToolResult, msgSend: cMsgSend },
514
- inputTokens: inTok, outputTokens: outTok, contextTokens, costUsd,
515
- model, gitBranch: branch, version, title, cwd,
516
- };
517
- }
518
- /** 解析选中的项目:params.project 是 encoded 目录名;缺省用当前 cwd 对应项目 */
519
- function resolveProject(params, projects) {
520
- if (params.project) {
521
- const found = projects.find(p => p.encoded === params.project);
522
- if (found) {
523
- dlog(`[session] resolveProject: matched param project=${params.project.slice(-24)}`);
524
- return found;
525
- }
526
- dlog(`[session] resolveProject: param project=${params.project.slice(-24)} NOT in ${projects.length} projects → falling back`);
527
- }
528
- // 默认:当前工作目录对应的项目
529
- const curEncoded = encodePath(process.cwd());
530
- const cur = projects.find(p => p.encoded === curEncoded);
531
- if (cur) {
532
- dlog(`[session] resolveProject: default to cwd project=${curEncoded.slice(-24)}`);
533
- return cur;
534
- }
535
- dlog(`[session] resolveProject: cwd project=${curEncoded.slice(-24)} not found → using first=${projects[0]?.encoded.slice(-24)}`);
536
- return projects[0] || null;
537
- }
538
- function buildSnapshot(params) {
539
- const projects = listProjects();
540
- const bindMap = buildBindMap();
541
- const project = resolveProject(params, projects);
542
- if (!project) {
543
- return { projects: [], project: null, transcripts: [], turns: [], sessionId: null };
544
- }
545
- const metas = listTranscripts(project.encoded);
546
- // 附加绑定信息
547
- const transcripts = metas.map(m => {
548
- const bind = bindMap.get(m.id);
549
- return {
550
- ...m,
551
- bound: !!bind,
552
- boundChannel: bind ? bind.channelType : null,
553
- boundPeer: bind ? (bind.peerName || bind.channelId) : null,
554
- online: bind ? (Date.now() - bind.updatedAt < 5 * 60 * 1000) : false,
555
- };
556
- });
557
- const projList = projects.map(p => ({ encoded: p.encoded, label: p.label, cwd: p.cwd, count: p.count }));
558
- const sessionId = params.sessionId || null;
559
- if (!sessionId) {
560
- return { projects: projList, project: project.encoded, transcripts, turns: [], sessionId: null };
561
- }
562
- // 详情:按 sessionId(= 文件名)读全量
563
- const file = path.join(ccProjectsDir(), project.encoded, `${sessionId}.jsonl`);
564
- const detail = readTranscriptFile(file);
565
- const bind = bindMap.get(sessionId);
566
- const header = {
567
- sessionId,
568
- title: detail.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: detail.counts,
577
- inputTokens: detail.inputTokens,
578
- outputTokens: detail.outputTokens,
579
- contextTokens: detail.contextTokens,
580
- costUsd: detail.costUsd,
581
- bound: !!bind,
582
- boundChannel: bind ? bind.channelType : null,
583
- boundPeer: bind ? (bind.peerName || bind.channelId) : null,
584
- online: bind ? (Date.now() - bind.updatedAt < 5 * 60 * 1000) : false,
585
- };
586
- return { projects: projList, project: project.encoded, transcripts, turns: detail.turns, sessionId, header };
587
- }
588
- export const sessionSource = {
589
- kind: 'session',
590
- async snapshot(params = {}) {
591
- return buildSnapshot(params);
592
- },
593
- subscribe(params, push) {
594
- const p = resolvePaths();
595
- let projectWatcher = null;
596
- let sessionWatcher = null;
597
- let debounce = null;
598
- const fire = () => {
599
- if (debounce)
600
- clearTimeout(debounce);
601
- debounce = setTimeout(() => {
602
- try {
603
- push(buildSnapshot(params));
604
- }
605
- catch { /* ignore */ }
606
- }, 150);
607
- };
608
- // 监听选中项目的 CC 目录(.jsonl 新增/变化 = 会话活动)
609
- const projects = listProjects();
610
- const project = resolveProject(params, projects);
611
- if (project) {
612
- const dir = path.join(ccProjectsDir(), project.encoded);
613
- try {
614
- projectWatcher = fs.watch(dir, (_evt, filename) => {
615
- if (filename && String(filename).endsWith('.jsonl'))
616
- fire();
617
- });
618
- }
619
- catch { /* dir may not exist */ }
620
- }
621
- // 监听 evolclaw 会话目录(active.json 变化 → 绑定/在线状态变化)
622
- try {
623
- sessionWatcher = fs.watch(p.sessionsDir, { recursive: true }, (_evt, filename) => {
624
- if (filename && String(filename).endsWith('active.json'))
625
- fire();
626
- });
627
- }
628
- catch { /* sessionsDir may not exist */ }
629
- return () => {
630
- if (projectWatcher)
631
- projectWatcher.close();
632
- if (sessionWatcher)
633
- sessionWatcher.close();
634
- if (debounce)
635
- clearTimeout(debounce);
636
- };
637
- },
638
- };
@@ -1,10 +0,0 @@
1
- /**
2
- * WatchSource — 统一的数据源抽象。
3
- *
4
- * 三个 watch 视图(aid / msg / session)各实现一遍:
5
- * - snapshot(params): 返回当前全量快照(首次订阅 / 切换选择时)
6
- * - subscribe(params, push): 注册变更回调,返回取消订阅的函数
7
- *
8
- * aid 走 IPC 轮询(无推送能力),msg/session 走 fs.watch 文件监听。
9
- */
10
- export {};