@yxzpro/agent-acta 2.14.4
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/LICENSE +21 -0
- package/LOGFORMATS.md +960 -0
- package/README.md +238 -0
- package/REFERENCE.md +1428 -0
- package/agent-acta-page.html +1998 -0
- package/agent-acta-server.mjs +1066 -0
- package/agent-acta-widget.html +724 -0
- package/archive.mjs +335 -0
- package/core/log.mjs +20 -0
- package/core/service.mjs +4285 -0
- package/hooks/cursor-usage-hook.mjs +89 -0
- package/mcp-server.mjs +372 -0
- package/package.json +68 -0
- package/page/dialog-add-agent.js +65 -0
- package/page/dialog-alert.js +159 -0
- package/page/dialog-archive.js +342 -0
- package/page/dialog-compare.js +238 -0
- package/page/dialog-ctxw.js +101 -0
- package/page/dialog-diag.js +72 -0
- package/page/dialog-heatmap.js +215 -0
- package/page/dialog-selftest.js +202 -0
- package/page/dialog-sess-timeline.js +108 -0
- package/page/dialog-stats.js +694 -0
- package/page/dialog-usage.js +158 -0
- package/page/log-card.js +277 -0
- package/page/log-view.js +273 -0
- package/page/sess-view.js +382 -0
- package/page/shared.js +408 -0
- package/page/sidebar.js +129 -0
- package/page/style.css +973 -0
- package/page/topbar.js +61 -0
- package/parsers/atomcode.mjs +321 -0
- package/parsers/buddy.mjs +272 -0
- package/parsers/buddyext.mjs +582 -0
- package/parsers/claude.mjs +496 -0
- package/parsers/cline.mjs +468 -0
- package/parsers/codearts.mjs +96 -0
- package/parsers/codex.mjs +188 -0
- package/parsers/comate.mjs +195 -0
- package/parsers/copilot.mjs +308 -0
- package/parsers/cursor.mjs +493 -0
- package/parsers/devindb.mjs +358 -0
- package/parsers/discovery.mjs +1230 -0
- package/parsers/doubao.mjs +406 -0
- package/parsers/dsh.mjs +377 -0
- package/parsers/gemini.mjs +267 -0
- package/parsers/generic.mjs +494 -0
- package/parsers/hermes.mjs +398 -0
- package/parsers/kilo.mjs +42 -0
- package/parsers/kimi.mjs +249 -0
- package/parsers/mimocode.mjs +531 -0
- package/parsers/minimax.mjs +503 -0
- package/parsers/openclaw.mjs +490 -0
- package/parsers/opencode.mjs +465 -0
- package/parsers/shared.mjs +418 -0
- package/parsers/trace.mjs +66 -0
- package/parsers/tracecode.mjs +348 -0
- package/parsers/traedb.mjs +528 -0
- package/parsers/zcode.mjs +356 -0
- package/plugin/client.js +51 -0
- package/plugin/cordis.patch.yml +5 -0
- package/plugin/plugin.mjs +220 -0
- package/search.mjs +554 -0
- package/tools/grab-trae-key.ps1 +265 -0
- package/vendor/element-plus-icons.js +3 -0
- package/vendor/element-plus.css +1 -0
- package/vendor/element-plus.dark.css +1 -0
- package/vendor/element-plus.full.min.js +65 -0
- package/vendor/element-plus.zh-cn.js +2 -0
- package/vendor/svg/antgroup-color.svg +1 -0
- package/vendor/svg/bytedance-color.svg +1 -0
- package/vendor/svg/chatglm-color.svg +1 -0
- package/vendor/svg/claude-color.svg +1 -0
- package/vendor/svg/claudecode-color.svg +1 -0
- package/vendor/svg/cline.svg +1 -0
- package/vendor/svg/codebuddy-color.svg +1 -0
- package/vendor/svg/codebuddy.svg +1 -0
- package/vendor/svg/codex-color.svg +1 -0
- package/vendor/svg/copilot.svg +1 -0
- package/vendor/svg/cursor.svg +1 -0
- package/vendor/svg/deepseek-color.svg +1 -0
- package/vendor/svg/deepseek-harness-whale.svg +8 -0
- package/vendor/svg/doubao-color.svg +1 -0
- package/vendor/svg/gemini-color.svg +1 -0
- package/vendor/svg/glmv-color.svg +1 -0
- package/vendor/svg/grok.svg +1 -0
- package/vendor/svg/hermesagent.svg +1 -0
- package/vendor/svg/huawei-color.svg +1 -0
- package/vendor/svg/hunyuan-color.svg +1 -0
- package/vendor/svg/kilocode-avatar.svg +1 -0
- package/vendor/svg/kilocode.svg +1 -0
- package/vendor/svg/kimi-color.svg +1 -0
- package/vendor/svg/kimi.svg +1 -0
- package/vendor/svg/logo-AtomGit-G-red.svg +3 -0
- package/vendor/svg/logo-AtomGit-dark.svg +4 -0
- package/vendor/svg/logo-AtomGit-light.svg +4 -0
- package/vendor/svg/meta-color.svg +1 -0
- package/vendor/svg/mimo.svg +66 -0
- package/vendor/svg/minimax-code-logo.svg +8 -0
- package/vendor/svg/minimax-color.svg +1 -0
- package/vendor/svg/moonshot.svg +1 -0
- package/vendor/svg/nex-color.svg +1 -0
- package/vendor/svg/nex.svg +1 -0
- package/vendor/svg/nvidia-color.svg +1 -0
- package/vendor/svg/openai.svg +1 -0
- package/vendor/svg/openclaw-color.svg +1 -0
- package/vendor/svg/openclaw.svg +1 -0
- package/vendor/svg/opencode.svg +1 -0
- package/vendor/svg/openrouter-color.svg +1 -0
- package/vendor/svg/qoder-color.svg +1 -0
- package/vendor/svg/qwen-color.svg +1 -0
- package/vendor/svg/sensenova-color.svg +1 -0
- package/vendor/svg/stepfun.svg +1 -0
- package/vendor/svg/trae-color.svg +1 -0
- package/vendor/svg/traework-v2.svg +1 -0
- package/vendor/svg/wenxin-color.svg +1 -0
- package/vendor/svg/windsurf.svg +1 -0
- package/vendor/svg/workbuddy-color.svg +29 -0
- package/vendor/svg/xiaomimimo.svg +1 -0
- package/vendor/svg/yuanbao-color.svg +1 -0
- package/vendor/svg/yuanbao.svg +1 -0
- package/vendor/svg/zai.svg +1 -0
- package/vendor/svg/zhipu-color.svg +1 -0
- package/vendor/vue.global.prod.js +9 -0
- package/widget/main.js +202 -0
- package/widget/package.json +7 -0
- package/widget/preload.js +11 -0
package/core/service.mjs
ADDED
|
@@ -0,0 +1,4285 @@
|
|
|
1
|
+
// agent-acta 的服务侧(2026-09-29 从 agent-acta-server.mjs 拆出,**纯搬家零逻辑改动**,见 claude-step.md §4)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么要拆:I11 要把服务做成 DSH 插件,插件壳 import 的应该是服务侧,而不是 CLI 入口 —— 老入口里的
|
|
4
|
+
// argv 解析、hook 安装、--stop/--status/--doctor 那一整套跟插件毫无关系,混在一个文件里就只能靠 LIB
|
|
5
|
+
// 布尔硬挡,而挡不住 hosted 模式要的第三种组合(见 claude-step.md §0.5 更正 1 与 §5)。
|
|
6
|
+
//
|
|
7
|
+
// 搬过来时**必须显式处理**的三处(claude-step.md §2,都是 import.meta.url 派生值:搬错不报错,只改语义):
|
|
8
|
+
// ① ROOT —— 本文件在 core/ 下,包根要显式取上一级,否则 PAGE_FILE / VENDOR_DIR 全指错、页面与 vendor 全 404
|
|
9
|
+
// ② LIB —— 判据从「是不是本文件」改成「是不是包根下的入口 agent-acta-server.mjs」;不改的话跑 CLI 时
|
|
10
|
+
// argv[1] ≠ 本文件 ⇒ LIB 恒为真 ⇒ 服务静默变成只读、一个字都不落盘
|
|
11
|
+
// ③ BUILD —— 口径改为哈希整个服务面(入口 + core/*.mjs);只哈希本文件的话,改入口时指纹不变,
|
|
12
|
+
// 「端口上是另一版」那句提示就在半数改动上瞎掉
|
|
13
|
+
// 另外两处本文件不能自己解决的,都在下方就近注释里:ENSURE_FLAG(CLI flag 泄漏,§5 点名,Step 2 清)
|
|
14
|
+
// 与 setClientSpawner(/api/client 要唤起悬浮卡片,而那段必须留在入口)。
|
|
15
|
+
//
|
|
16
|
+
// **Step 2(2026-09-29)**:布尔 LIB 已扩成三态 MODE,副作用全部收进 start() / stop():
|
|
17
|
+
// · 'cli' —— 今天默认分支那一整套(listen / 写 pid / 空闲自停 / 真 process.exit)
|
|
18
|
+
// · 'lib' —— MCP 的只读库模式,**行为与 Step 1 逐字一致**(不扫描落盘、不 listen、不挂定时器)
|
|
19
|
+
// · 'hosted' —— 宿主内插件:写路径全开(扫描 / 落索引 / SSE / 归档轮 / 搜索轮),但
|
|
20
|
+
// listen:false、不写 pid、不挂 checkIdle、stop() 只停循环不退进程
|
|
21
|
+
// 三态在模块加载时按「argv[1] 是不是入口」定成 cli/lib(与原来一模一样),hosted 由宿主调
|
|
22
|
+
// start({ mode:'hosted' }) 切过去。状态保持模块级单例,不做闭包化(§5 第 3 条已定理由)。
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import http from 'node:http';
|
|
27
|
+
import net from 'node:net';
|
|
28
|
+
import os from 'node:os';
|
|
29
|
+
import zlib from 'node:zlib';
|
|
30
|
+
import crypto from 'node:crypto';
|
|
31
|
+
import { spawn, execSync, spawnSync } from 'node:child_process';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
import { createRequire } from 'node:module';
|
|
34
|
+
|
|
35
|
+
// ---------------- 诊断出口(见 core/log.mjs 开头:为什么不是改全局 console) ----------------
|
|
36
|
+
import { log, routeLogsToStderr } from './log.mjs';
|
|
37
|
+
|
|
38
|
+
// ---------------- 归档(历史条目冻结,见 archive.mjs 开头的三条硬设计) ----------------
|
|
39
|
+
import { initArchive, archiveRun, archiveIndex, archiveEntries, archiveEntry, pruneArchive, archiveRoot, fmtBytes } from '../archive.mjs';
|
|
40
|
+
|
|
41
|
+
// ---------------- 全文搜索索引(R33 / 候选池 I10,见 search.mjs 开头的四条硬设计) ----------------
|
|
42
|
+
import { initSearch, loadSearch, searchRun, searchEntries, searchStatus, searchRoot, foreignPath, toolFailsOf, searchBuilding } from '../search.mjs';
|
|
43
|
+
|
|
44
|
+
// ---------------- 解析器(parsers/,2026-09-17 从本文件拆出,纯搬家零逻辑改动) ----------------
|
|
45
|
+
import { files, entries, srcs, pidCwd, dirty, removedIds, agentNotices, kindDirty, sorted,
|
|
46
|
+
OFF_KINDS, PARSER_REV, CTX_WINDOW_KINDS, CTX_WINDOWS_DEFAULT, ctxUser, ctxWindows,
|
|
47
|
+
loadCtxWindows, setSaveHook, markKind, modelName, modelList, windowOf, toText, trunc,
|
|
48
|
+
listDirCached, sqliteMod, isDir, isFile, loadModelDisplayMap } from '../parsers/shared.mjs';
|
|
49
|
+
import { scanAtomcodeDir, atomcodeDirs, atomcodeEntryContent } from '../parsers/atomcode.mjs';
|
|
50
|
+
import { scanTraces, resolveCwd, setAgentConfs } from '../parsers/trace.mjs';
|
|
51
|
+
import { scanClaude, claudeEntryContent } from '../parsers/claude.mjs';
|
|
52
|
+
import { scanDoubao, doubaoEntryContent } from '../parsers/doubao.mjs';
|
|
53
|
+
import { scanBuddy, buddyEntryContent } from '../parsers/buddy.mjs';
|
|
54
|
+
import { scanCodex, codexEntryContent } from '../parsers/codex.mjs';
|
|
55
|
+
import { scanKimi, kimiEntryContent } from '../parsers/kimi.mjs';
|
|
56
|
+
import { scanComate, comateEntryContent } from '../parsers/comate.mjs';
|
|
57
|
+
import { scanDsh, DSH_ZSTD_OK } from '../parsers/dsh.mjs';
|
|
58
|
+
import { scanCodearts } from '../parsers/codearts.mjs';
|
|
59
|
+
import { scanGemini } from '../parsers/gemini.mjs';
|
|
60
|
+
import { scanCursor, cursorTranscriptFiles, CURSOR_NO_USAGE_NOTE, CURSOR_MODEL_NOTE, CURSOR_HOOK_NOTE } from '../parsers/cursor.mjs';
|
|
61
|
+
import { scanZcode, zcodeScanErr } from '../parsers/zcode.mjs';
|
|
62
|
+
import { scanOpencode, opencodeScanErr, opencodeEntryContent } from '../parsers/opencode.mjs';
|
|
63
|
+
import { scanHermes, hermesScanErr, hermesEntryContent } from '../parsers/hermes.mjs';
|
|
64
|
+
import { scanDevinDb, devinDbErr, devinDbEntryContent } from '../parsers/devindb.mjs';
|
|
65
|
+
import { scanMavis, mavisEntryContent } from '../parsers/minimax.mjs';
|
|
66
|
+
import { scanOpenclaw, openclawEntryContent, openclawUserText } from '../parsers/openclaw.mjs';
|
|
67
|
+
import { scanCline, clineScanErr, clineEntryContent } from '../parsers/cline.mjs';
|
|
68
|
+
// buddyext(CodeBuddy 扩展版:VSCode / CodeBuddyIDE / JetBrains 共用的 genie 扩展,
|
|
69
|
+
// %LOCALAPPDATA%\CodeBuddyExtension\Data)—— 与 parsers/buddy.mjs 那个 CodeBuddy **CLI**(~/.codebuddy)
|
|
70
|
+
// 是两个产品、两份落盘,故独立成 kind=buddyext、独立成侧栏 agent=codebuddy-ext。
|
|
71
|
+
import { scanBuddyExt, buddyExtScanErr, buddyExtEntryContent } from '../parsers/buddyext.mjs';
|
|
72
|
+
// ↑ kind=buddyext 的会话**并到 codebuddy 这一个 agent 名下**(同产品的第二份落盘,不是第二个产品),
|
|
73
|
+
// 所以侧栏没有 codebuddy-ext 这一行;根从 buddyExtDataRoots() 每轮重算,挂在 codebuddy.sessionsExtra 上。
|
|
74
|
+
import { scanTraecode, scanTraework, traeEntryContent } from '../parsers/tracecode.mjs';
|
|
75
|
+
import { scanTraeDb, traeDbErr, traeDbEntryContent } from '../parsers/traedb.mjs';
|
|
76
|
+
import { scanCopilot, copilotScanErr, copilotTurnStart } from '../parsers/copilot.mjs';
|
|
77
|
+
import { scanGeneric, genericEntryContent, validateGenericRules, genericRulesHash, genericTurnStart } from '../parsers/generic.mjs';
|
|
78
|
+
import { scanMimocode, mimocodeEntryContent } from '../parsers/mimocode.mjs';
|
|
79
|
+
import { scanKilo, kiloScanErr, kiloEntryContent } from '../parsers/kilo.mjs';
|
|
80
|
+
// I8:单轮「复现包」的原始日志片段提取(复用各解析器的开轮判定,保证与扫描口径一致,不另写一份)。
|
|
81
|
+
// 只针对「单文件逐行 jsonl」的 kind 做提取;其余 kind(数据库 / 内存 / 多文件)诚实标不可提取。
|
|
82
|
+
import { readCompleteLines } from '../parsers/shared.mjs';
|
|
83
|
+
import { claudeUserText, claudeIsToolResultOnly, claudeIsCommandText } from '../parsers/claude.mjs';
|
|
84
|
+
import { buddyUserText } from '../parsers/buddy.mjs';
|
|
85
|
+
import { mavisUserText } from '../parsers/minimax.mjs';
|
|
86
|
+
import { sniffBase, candidateBases, probeBases, kimiDesktopSessionsRoots, kimiWires, kimiWireIn, isKimiWire,
|
|
87
|
+
buddyExtDataRoots,
|
|
88
|
+
dshSessionFile, hasDshSessions, zcodeDbVerify, zcodeDbFile,
|
|
89
|
+
opencodeDbFile, opencodeDbVerify,
|
|
90
|
+
traeDbFile, traeLogKindOf, traeLogsFromDb,
|
|
91
|
+
hasCodeartsLogs, hasTraceFiles, hasCodexRollouts, hasClaudeJsonl, hasBuddyJsonl,
|
|
92
|
+
hasGeminiSessions, hasCursorTranscripts, hasKimiSessions, hasTraeRendererLogs } from '../parsers/discovery.mjs';
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
// ---------------- 载体模式 MODE:cli / lib / hosted ----------------
|
|
96
|
+
// 'lib'(R31:给同目录的 mcp-server.mjs import 用):本文件被 **import** 而不是作为入口脚本跑时,
|
|
97
|
+
// 它只是一份「日志内存 + 查询函数」的库:绝不能有服务侧副作用 —— 常驻服务正在同一个 DATA_DIR 上写
|
|
98
|
+
// `index/` 与 `config.json`,第二个进程跟着写就是互相覆盖丢条目(谁后落盘谁赢,前一份白扫)。
|
|
99
|
+
// 判据用「是不是入口脚本」而不是加一个环境变量开关:常驻那条路(直接跑 / npm bin)argv[1] 就是入口文件,
|
|
100
|
+
// 天然不受影响;而漏设标志的失败方向是「静默双写」,正是要防的那类事故,不能留给人记住。
|
|
101
|
+
//
|
|
102
|
+
// 'hosted'(I11 Step 2):宿主内插件要的既不是「只读」也不是「独占端口」,而是第三种组合 ——
|
|
103
|
+
// 写路径全开、但不 listen / 不写 pid / 不空闲自停 / 不退出宿主进程。布尔 LIB 装不下它(claude-step §0.5
|
|
104
|
+
// 更正 1),所以在这里定成三态。**加载时的判据一个字没改**,只是结果从 true/false 变成 'lib'/'cli';
|
|
105
|
+
// 'hosted' 只能由 start({ mode:'hosted' }) 切过去 —— 那意味着「import 本文件」依旧是零副作用的,
|
|
106
|
+
// 宿主必须显式开口。MODE 是 let 而不是 const,正是为了这一处切换。
|
|
107
|
+
//
|
|
108
|
+
// 包根:页面 / vendor / widget / package.json 都相对它定位。**必须显式取上一级** —— 2026-09-29 本文件
|
|
109
|
+
// 搬进 core/ 之后,path.dirname(import.meta.url) 得到的是 core/,于是 PAGE_FILE / PAGE_DIR /
|
|
110
|
+
// VENDOR_DIR / WIDGET_FILE 全指错、页面与 vendor 全 404(claude-step.md §2 第 1 行)。
|
|
111
|
+
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
112
|
+
const sameModule = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
113
|
+
// 入口是**包根下**的 agent-acta-server.mjs。判据不能再拿 import.meta.url 去比:那比的是 core/service.mjs,
|
|
114
|
+
// 于是正常跑 CLI 时 argv[1] ≠ 它 ⇒ 判成 'lib' ⇒ 服务静默变成只读、一个字都不落盘。
|
|
115
|
+
const CLI_ENTRY = path.resolve(path.join(ROOT, 'agent-acta-server.mjs'));
|
|
116
|
+
let MODE = sameModule(path.resolve(process.argv[1] || ''), CLI_ENTRY) ? 'cli' : 'lib';
|
|
117
|
+
// 诊断行的去向在这里定一次,之后全库一律走 log()(core/log.mjs):
|
|
118
|
+
// cli → stdout —— 终端体验与搬家前一致(--status / --doctor 的球与文字走原来的流)
|
|
119
|
+
// 其余 → stderr —— lib 的 stdout 是 importer 的**协议通道**(MCP over stdio),混进一行
|
|
120
|
+
// 「[index] loaded 1234 states」就是一条解析不了的坏消息;hosted 的 stdout 归宿主,
|
|
121
|
+
// 插件往里写诊断行同样是污染。两种情况的日志在 stderr 里一行不少。
|
|
122
|
+
// 在 import 时判一次就够:MODE 之后只可能从 'lib' 变成 'hosted',而这两者对去向的要求相同。
|
|
123
|
+
//
|
|
124
|
+
// 2026-09-29:这里原先是 `console.log = (...a) => console.error(...a)` —— 改的是**整个进程**的
|
|
125
|
+
// console。MCP 里没事(独立进程),但 DSH 插件加载后**宿主自己的** console.log 也被我们改道到
|
|
126
|
+
// stderr,宿主的日志采集器因此少掉全部 info 行。越界的副作用,换成模块内的 log();原始那个函数
|
|
127
|
+
// 留给宿主,一个字节都不碰。
|
|
128
|
+
routeLogsToStderr(MODE !== 'cli');
|
|
129
|
+
|
|
130
|
+
const HOME = os.homedir();
|
|
131
|
+
const QODER_RUNS_DIR = path.join(HOME, '.qoder-cn', 'logs', 'runs'); // qodercli.log 所在,模型名映射来源
|
|
132
|
+
const PORT = Number(process.env.AGENT_LOG_PORT || 14570);
|
|
133
|
+
// ROOT 在上面 LIB 那一段就定好了(判据要用到它),别在这里重定义 —— 重定义 = 「两处各算一遍」。
|
|
134
|
+
// 用户数据目录:~/.agent-acta —— 跟 ~/.claude / ~/.codex / ~/.kimi-code 同一层规则,
|
|
135
|
+
// 与代码位置无关(换机器、重装、升级都不冲配置),也**不依赖任何别的产品的安装目录**。
|
|
136
|
+
// 2026-09-18 之前它固定在 ~/.atomcode/agent-log/:那时它还只是个「看 atomcode 每轮 token」的小工具,
|
|
137
|
+
// 名字顺着宿主的窝叫;现在它聚合十几个 agent,「放在 atomcode 目录里」既误导、又会被 atomcode 的
|
|
138
|
+
// 卸载程序连锅端走(配置里那些手工 agent 与 traeKey 是用户唯一带不走的东西)。老数据由 migrateDataDir 自动搬。
|
|
139
|
+
const DATA_DIR = path.join(HOME, '.agent-acta');
|
|
140
|
+
// 旧位置(按「越新越靠前」排):只读来搬迁与兼容,**绝不再往里写**。
|
|
141
|
+
// 每搬一次家就往这里加一条 —— 跨代升级(比如从没装过 1.9.0、数据还躺在 ~/.atomcode/agent-log 的机器)
|
|
142
|
+
// 靠它一步到位,不必先经过中间那一代。
|
|
143
|
+
const LEGACY_DATA_DIRS = [
|
|
144
|
+
path.join(HOME, '.agent-log'), // 1.9.0 ~ 1.x:命令还叫 agentlog 的那个时代
|
|
145
|
+
path.join(HOME, '.atomcode', 'agent-log'), // 1.9.0 之前:顺着 atomcode 的窝叫
|
|
146
|
+
];
|
|
147
|
+
const LEGACY_CONFIG_NAME = 'agent-log-config.json'; // 旧配置文件名(新名是 config.json)
|
|
148
|
+
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
|
149
|
+
const INDEX_DIR = path.join(DATA_DIR, 'index');
|
|
150
|
+
// 「某个目录里有没有**真**配置」。判据盯的是配置文件本身、不是目录在不在 —— 空的新目录到处都是
|
|
151
|
+
// (用户手工建的、上次搬到一半留下的),把它们当成「已经搬好了」,loadConfig 就会装载默认预置,
|
|
152
|
+
// 首次 saveConfig 把用户的 agent 列表 / traeKey / ctxWindows 顶掉(唯一不可逆的损失)。
|
|
153
|
+
// 2026-09-29 随搬家从「数据目录搬迁」那一段挪进来:服务侧的 saveConfig(:519) 与 loadConfig(:697)
|
|
154
|
+
// 都要用它,而 core 不能反向 import 入口 —— 所以它属于服务侧,搬迁那边只是共用同一个判据。
|
|
155
|
+
function hasRealConfig(dir) {
|
|
156
|
+
return isFile(path.join(dir, 'config.json')) || isFile(path.join(dir, LEGACY_CONFIG_NAME));
|
|
157
|
+
}
|
|
158
|
+
// 第一个「有真配置」的旧位置。多条 legacy 时以新者为准 —— 那份配置一定是搬迁后又被改过的,
|
|
159
|
+
// 比老的更接近用户意图;老的那条原样留着,不动它(红线②:搬迁永不删旧东西)。
|
|
160
|
+
function legacyWithConfig() {
|
|
161
|
+
for (const d of LEGACY_DATA_DIRS) if (hasRealConfig(d)) return d;
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
// --stop 的兜底线索;判活仍以端口为准。按端口分文件:换 AGENT_LOG_PORT 起第二个实例时不会互相覆盖
|
|
165
|
+
const PID_FILE = path.join(DATA_DIR, 'server-' + PORT + '.pid');
|
|
166
|
+
const PAGE_FILE = path.join(ROOT, 'agent-acta-page.html');
|
|
167
|
+
// R25 批 0:主页面拆出的静态文件(shared.js / style.css / 后续各组件)。路由见 '/page/' 分支,
|
|
168
|
+
// 指纹见 pageBuild(),启动自检见 server.listen 回调。
|
|
169
|
+
const PAGE_DIR = path.join(ROOT, 'page');
|
|
170
|
+
// 桌面小卡片页(/widget):与主页同一进程伺候、同一套指纹注入,但**不引共享片段**——
|
|
171
|
+
// 卡片不做项目筛选,用不上 projKey;依赖越少,这张小页挂掉的面越小。
|
|
172
|
+
const WIDGET_FILE = path.join(ROOT, 'agent-acta-widget.html');
|
|
173
|
+
const VENDOR_DIR = path.join(ROOT, 'vendor');
|
|
174
|
+
|
|
175
|
+
// 本进程跑的到底是哪一版代码 —— 用命令来辨认(版本号 + 服务脚本内容指纹)。
|
|
176
|
+
//
|
|
177
|
+
// 为什么非要有这个东西:`npm i -g <新版 tgz>` 换掉的是**磁盘上的文件**,换不掉**已经在跑的进程**。
|
|
178
|
+
// 而服务是从磁盘现读 agent-acta-page.html 的,于是升级后会出现最难查的一种状态:
|
|
179
|
+
// **页面是新的、接口是旧的** —— 页面点「用量统计」请求 /api/daily,旧服务不认识这条路由,
|
|
180
|
+
// 落到兜底 404 返回纯文本 'not found',页面再 r.json() 就报「Unexpected token 'o'」;
|
|
181
|
+
// 同一台机器上 .cursor 也扫不到、手动加也加不上(旧服务的 sniffBase 里根本没有 cursor 分支)。
|
|
182
|
+
// 现象看着是三件不相干的事,根子是同一个:旧进程占着端口。
|
|
183
|
+
//
|
|
184
|
+
// 指纹取脚本内容而不是 package.json 的版本号:版本号要人记得改,改了也可能只动页面不动服务端;
|
|
185
|
+
// 内容指纹不需要任何人维护,改了哪个字节数都对不上。12 位 SHA1 前缀的碰撞概率在这里可以忽略。
|
|
186
|
+
const VERSION = (() => {
|
|
187
|
+
try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version || '0.0.0'; }
|
|
188
|
+
catch { return '0.0.0'; } // 源码目录/被裁剪的安装:拿不到版本号不影响指纹判等
|
|
189
|
+
})();
|
|
190
|
+
// 服务面指纹(claude-step.md §2 第 3 行):哈希的是**整个服务面**,不是本文件一个 —— 口径与 pageBuild()
|
|
191
|
+
// 同一套(按文件名排序、名字也进哈希,改名同样算变)。
|
|
192
|
+
// 为什么必须在本步改:本文件搬进 core/ 之后「只哈希自己」就等于「只哈希 core/service.mjs」,
|
|
193
|
+
// 改 CLI 入口(agent-acta-server.mjs)时指纹**不变** —— 于是「端口上是另一版」那句提示在半数改动上
|
|
194
|
+
// 瞎掉,而那正是它存在的唯一理由。agent-acta-server.mjs + core/*.mjs 合起来才是「服务是哪一版」。
|
|
195
|
+
// 范围**不含** archive.mjs / search.mjs / mcp-server.mjs:搬家前也只哈希入口文件一个,本步只补回
|
|
196
|
+
// 「因搬家而变窄」的那部分,不顺手扩面(扩面会让每次改搜索/归档都触发一次换班提示)。
|
|
197
|
+
const BUILD = (() => {
|
|
198
|
+
try {
|
|
199
|
+
const coreDir = path.join(ROOT, 'core');
|
|
200
|
+
const names = ['agent-acta-server.mjs',
|
|
201
|
+
...(fs.existsSync(coreDir) ? fs.readdirSync(coreDir).filter(n => /\.mjs$/.test(n)).sort().map(n => 'core/' + n) : [])];
|
|
202
|
+
const parts = names.map(n => n + '\x00' + fs.readFileSync(path.join(ROOT, n), 'utf8'));
|
|
203
|
+
return crypto.createHash('sha1').update(parts.join('\x00')).digest('hex').slice(0, 12);
|
|
204
|
+
} catch { return 'unknown'; }
|
|
205
|
+
})();
|
|
206
|
+
// 页面文件指纹(R25 批 0)= 主页面 html + page/ 下所有 .js/.css(按文件名排序,名字也进哈希 —— 改名同样算变)。
|
|
207
|
+
// 与 BUILD 的分工:BUILD 只哈希服务端脚本,页面拆出 /page/ 之后「只改页面文件」不会让它变;
|
|
208
|
+
// 页面侧比对 /api/version 里的这一枚,抓的是「浏览器里跑着旧页面、磁盘上已是新页面文件」。
|
|
209
|
+
// 每次请求现算(几笔小文件,开销可忽略):想让「编辑页面文件后 reload」立刻见效、且不改服务就不必重启。
|
|
210
|
+
function pageBuild() {
|
|
211
|
+
try {
|
|
212
|
+
const parts = [fs.readFileSync(PAGE_FILE, 'utf8')];
|
|
213
|
+
for (const f of fs.readdirSync(PAGE_DIR).filter(n => /\.(js|css)$/.test(n)).sort())
|
|
214
|
+
parts.push(f + '\x00' + fs.readFileSync(path.join(PAGE_DIR, f), 'utf8'));
|
|
215
|
+
return crypto.createHash('sha1').update(parts.join('\x00')).digest('hex').slice(0, 12);
|
|
216
|
+
} catch { return 'unknown'; }
|
|
217
|
+
}
|
|
218
|
+
const SNAPSHOT_LIMIT = 2000;
|
|
219
|
+
const DEFAULT_SCAN_MS = 3000; // 前端可在 UI 上调;0 = 暂停定时扫描(手动刷新仍可用)
|
|
220
|
+
const SCAN_CHOICES = [0, 1000, 2000, 3000, 5000, 10000];
|
|
221
|
+
let scanMs = DEFAULT_SCAN_MS; // 当前扫描节奏(config 持久化)
|
|
222
|
+
// 空闲自停:页面全关 + 没有任何新日志持续这么久,服务自己退出(0 = 永不,默认关)。
|
|
223
|
+
// 挂着 `--ensure` hook 的情况下退出无副作用——下次开会话又会把它拉起来。
|
|
224
|
+
const IDLE_CHOICES = [0, 1800000, 7200000, 21600000]; // 关 / 30min / 2h / 6h
|
|
225
|
+
// AGENT_LOG_IDLE_MS:临时覆盖(不落盘、不受白名单限制),调试用——不然验一次要等半小时
|
|
226
|
+
const IDLE_ENV = Number(process.env.AGENT_LOG_IDLE_MS || 0) > 0 ? Number(process.env.AGENT_LOG_IDLE_MS) : 0;
|
|
227
|
+
let idleExitMs = 0;
|
|
228
|
+
|
|
229
|
+
// ---------------- 异常巡检(R9) ----------------
|
|
230
|
+
// 可配置规则,命中后由页面提醒并可一键筛出命中条目。规则与阈值按 scanMs / idleExitMs 同一套
|
|
231
|
+
// 「顶层设置 + config 持久化」机制落盘(见 loadConfig / saveConfig)。
|
|
232
|
+
// tokens: 单轮 token(total = tin+tout+tcache)超过 val 即命中;
|
|
233
|
+
// durMs: 单轮耗时超过 val(毫秒)即命中;
|
|
234
|
+
// fail: 按 agent 统计最近 window 轮里失败(status != 'ok')占比超过 val%(0-100)。
|
|
235
|
+
// 默认全关:用户没配过就是「绝不打扰」,不凭空报一堆异常。
|
|
236
|
+
// 命中集合 alertHits = Map(id -> {agent, rules:[key...], value}),随广播重算并推给前端。
|
|
237
|
+
// alertHits 只记「当前在内存里的命中条目」;条目被 LRU 淘汰或删除后由重算自然清除。
|
|
238
|
+
const ALERT_DEFAULT = { tokens: { on: false, val: 0 }, durMs: { on: false, val: 0 }, fail: { on: false, val: 0, window: 50 } };
|
|
239
|
+
let alertRules = JSON.parse(JSON.stringify(ALERT_DEFAULT));
|
|
240
|
+
const alertHits = new Map(); // id -> {agent, rules:[...],value}
|
|
241
|
+
let alertDirty = true; // 有新日志/改规则后置脏,下次广播前重算
|
|
242
|
+
|
|
243
|
+
const MAX_ENTRIES = 20000; // 内存条目上限(LRU 淘汰最旧)
|
|
244
|
+
const EVICT_BATCH = 2000; // 触发淘汰时一次降到上限 - EVICT_BATCH
|
|
245
|
+
const KEEP_MIN_PER_AGENT = 200; // 淘汰时每个 agent 至少保留的最近条数
|
|
246
|
+
|
|
247
|
+
// 归档(见 archive.mjs):这里只放「服务内自动跑」的两条策略 —— 开关与保留上限,都是用户意图、
|
|
248
|
+
// 随 config 落盘。默认永久保留:归档本身就是对抗产品删日志,自动清理等于自己又制造一次空档。
|
|
249
|
+
// skip 是**按 agent** 的开关(名字数组):有些 agent 的日志本来就不值得留,冻了纯占地方。
|
|
250
|
+
// 语义只说一半会被误解,这里写死:**只停未来的归档,绝不删已冻好的文件** ——
|
|
251
|
+
// 想清已有归档用 maxDays / maxMB(也是删整天文件),或自己去归档目录删。
|
|
252
|
+
const ARCHIVE_DEFAULT = { enabled: true, maxDays: 0, maxMB: 0, skip: [] };
|
|
253
|
+
let archiveCfg = { ...ARCHIVE_DEFAULT, skip: [] };
|
|
254
|
+
|
|
255
|
+
// 默认 agent(原"内置"):全部走 config,可禁用、可覆盖路径;builtin:true 不可删除只能禁用
|
|
256
|
+
// kind=atomcode: sessions/<hash>/*.meta + *.jsonl
|
|
257
|
+
// kind=trace: sessions/<pid>.json + traces/<pid>/trace_*.json
|
|
258
|
+
// kind=buddyjsonl: projects/<项目slug>/<sessionId>.jsonl(codebuddy / workbuddy 的会话正文)
|
|
259
|
+
// kind=gemini: tmp/<项目slug>/{chats/session-*.json 会话正文, logs.json 提示词流水}
|
|
260
|
+
// kind=cursor: projects/<项目slug>/agent-transcripts/<会话uuid>/<同名>.jsonl(纯对话,无 token/时间戳)
|
|
261
|
+
// 注:codebuddy / workbuddy 以前用 kind=trace,读到的只是会话注册表 + 每条 trace 的空壳记录
|
|
262
|
+
// (tin/tout/preview 全空),真正的逐轮 token 与内容在 projects/ 下的 jsonl 里,故改用它。
|
|
263
|
+
const DEFAULT_AGENTS = {
|
|
264
|
+
atomcode: { kind: 'atomcode', sessions: path.join(HOME, '.atomcode', 'sessions') },
|
|
265
|
+
// codebuddy 一家有**两份互不相干、格式也不同**的落盘,侧栏合成一行(见 discoverAgents 里的并源注释):
|
|
266
|
+
// sessions = CLI @tencent-ai/codebuddy-code 的 ~/.codebuddy/projects/**/*.jsonl(kind buddyjsonl)
|
|
267
|
+
// sessionsExtra = genie 扩展的 %LOCALAPPDATA%\CodeBuddyExtension\Data(kind buddyext,每轮重算)
|
|
268
|
+
codebuddy: { kind: 'buddyjsonl', sessions: path.join(HOME, '.codebuddy', 'projects') },
|
|
269
|
+
workbuddy: { kind: 'buddyjsonl', sessions: path.join(HOME, '.workbuddy', 'projects') },
|
|
270
|
+
};
|
|
271
|
+
// 启动/扫描时自动发现名单(命中 candidateBases+sniffBase 即注册 source:auto),保留扩展位
|
|
272
|
+
// traework 与 trae 都在名单里:装在同一台机器上的两个产品(Trae CN / Trae Work / Trae SOLO)
|
|
273
|
+
// 各自走 NAME_ALIASES 中的不同别名,被 sniffBase 区分成 tracecode 与 tracework 两个 kind。
|
|
274
|
+
// kilo 与 opencode 也是各自独立的一行:装了 KiloCode 的机器上两家库会同时在(名字不同、kind 不同)。
|
|
275
|
+
const KNOWN_AGENTS = ['claude', 'codex', 'cursor', 'trae', 'traework', 'qoder', 'opencode', 'gemini', 'copilot', 'windsurf', 'codearts', 'kimi', 'dsh', 'zcode', 'doubao', 'hermes', 'devin', 'minimax', 'mimocode', 'kilo', 'openclaw', 'comate', 'cline'];
|
|
276
|
+
|
|
277
|
+
const agentConfs = new Map(); // name -> {kind, sessions, sessionsExtra?, traces, builtin, enabled, source, missing}
|
|
278
|
+
|
|
279
|
+
// ---------------- 自检结果(/api/selftest,R32 / I5)----------------
|
|
280
|
+
// 只把 test/selftest.mjs 写下的那份结果**读出来**,服务端不 spawn 任何测试子进程:
|
|
281
|
+
// 一轮默认清单要串行起停二十来个服务、跑几分钟,这种副作用不能挂在一个 HTTP 处理器上
|
|
282
|
+
// (刷新一下页面就重跑一遍 = 谁都不敢刷新)。跑的动作留在终端里。
|
|
283
|
+
// 版本章是这里唯一真正新增的判断:结果文件里盖着跑它那时的 build,与当前 build 不同就说明
|
|
284
|
+
// 「这份绿不代表现在这版代码」——不提示的话,一页绿字比没有自检更危险。
|
|
285
|
+
// 落点在数据目录而不是仓库的 test/:`npm pack` 的 files 不含 test/,常驻服务跑的是全局安装那一份
|
|
286
|
+
// (见 README 的 --where),它仓库里根本没有 test/ 目录 —— 路径跟着代码走的话,页面永远只会报
|
|
287
|
+
// 「还没跑过自检」。跟着 HOME 走才对:源码树里的 runner 和全局包的服务读的是同一个文件。
|
|
288
|
+
function selftestReport() {
|
|
289
|
+
const fp = path.join(DATA_DIR, 'selftest.json');
|
|
290
|
+
const now = { version: VERSION, build: BUILD, pageBuild: pageBuild(), parserRev: PARSER_REV };
|
|
291
|
+
let j = null;
|
|
292
|
+
try { j = JSON.parse(fs.readFileSync(fp, 'utf8')); } catch (e) {
|
|
293
|
+
return { ok: false, file: fp, server: now, error: e.code === 'ENOENT' ? '还没跑过自检' : '结果文件读不动:' + e.message, hint: 'node test/selftest.mjs' };
|
|
294
|
+
}
|
|
295
|
+
// 时刻读不出就退到文件修改时间:年龄这一栏宁可偏一点,也不能给出「一万年」或者 NaN。
|
|
296
|
+
let atMs = Date.parse(j.at);
|
|
297
|
+
if (!Number.isFinite(atMs)) { try { atMs = fs.statSync(fp).mtimeMs; } catch (e) { atMs = 0; } }
|
|
298
|
+
const ageMs = Math.max(0, Date.now() - atMs);
|
|
299
|
+
// 口径比对只看「有哪些 kind、各是什么值」,不看键的顺序:JSON.stringify 那种写法会把「对象换了键序」
|
|
300
|
+
// 判成「解析器变了」,而这一栏的作用是决定页面要不要警告,误报一次就少信一分。
|
|
301
|
+
const sameRev = (a, b) => {
|
|
302
|
+
const ka = Object.keys(a || {}), kb = Object.keys(b || {});
|
|
303
|
+
return ka.length === kb.length && ka.every(k => a[k] === b[k]);
|
|
304
|
+
};
|
|
305
|
+
return {
|
|
306
|
+
ok: true, file: fp, server: now, ageMs,
|
|
307
|
+
sameBuild: !!(j.self && j.self.build === BUILD),
|
|
308
|
+
sameParserRev: !!(j.self && sameRev(j.self.parserRev, PARSER_REV)),
|
|
309
|
+
...j,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 一个 agent 的**全部**日志根。配置模型原本只有 `sessions` 一个根,但 kimi 是反例:
|
|
314
|
+
// kimi-code CLI 的 `~/.kimi-code` 与 Kimi 桌面版内嵌的 `runtime/kimi-code/home` 是两个
|
|
315
|
+
// 完全独立的目录(本机一个在 C 盘、一个在 E 盘),只认一个就等于**静默丢一半数据**。
|
|
316
|
+
// 所以加 `sessionsExtra` 承载「额外的、同格式的根」:**只增不改** —— sessions 仍是主根,
|
|
317
|
+
// 其余 8 个 agent 不写这个字段,行为与从前逐字一致(rootsOf 对单根返回只含一项的数组)。
|
|
318
|
+
// 没有做成「sessions 直接支持数组」,是因为 conf.sessions 在配置读写 / 目录占用判定 / 诊断
|
|
319
|
+
// 等十几处被当作字符串用,全改成数组的爆炸半径太大;多一个可选字段是能一眼看明白的最小改法。
|
|
320
|
+
function rootsOf(c) {
|
|
321
|
+
if (!c) return [];
|
|
322
|
+
const extra = Array.isArray(c.sessionsExtra) ? c.sessionsExtra : [];
|
|
323
|
+
return [...new Set([c.sessions, ...extra].filter(Boolean))];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function agentsList() {
|
|
327
|
+
return [...agentConfs].map(([name, c]) => {
|
|
328
|
+
const o = {
|
|
329
|
+
name, kind: c.kind, sessions: c.sessions || null, traces: c.traces || null,
|
|
330
|
+
sessionsExtra: c.sessionsExtra || null,
|
|
331
|
+
builtin: !!c.builtin, enabled: c.enabled !== false, source: c.source || (c.builtin ? 'builtin' : 'manual'),
|
|
332
|
+
missing: !!c.missing,
|
|
333
|
+
};
|
|
334
|
+
// traedb(trae/traework)的密钥状态:页面靠这三个字段决定「解密」按钮的形态与提示。
|
|
335
|
+
// 只回「有没有 / 读不读得动 / 能不能抓」,**永远不回密钥本身**(接口不必外泄)。
|
|
336
|
+
if (c.kind === 'traedb') {
|
|
337
|
+
o.hasKey = !!c.traeKey;
|
|
338
|
+
o.traeErr = traeDbErr.get(name) || null;
|
|
339
|
+
o.captureSupported = traeCaptureSupported();
|
|
340
|
+
}
|
|
341
|
+
// cursor 的逐轮 token 只能靠 stop 钩子采(转录与 state.vscdb 都没有),所以这一行多带一份
|
|
342
|
+
// 「hook 注了没」—— 页面据此决定那个图标按钮是「去注入」还是「已注入」。
|
|
343
|
+
if (c.kind === 'cursor') o.hook = cursorHookStatus();
|
|
344
|
+
return o;
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
let configMissingWithLegacy = false; // 见 loadConfig 里的保险丝说明;saveConfig 据此拒绝落盘
|
|
349
|
+
|
|
350
|
+
// 归档配置:坏值一律回落默认(同 scanMs 的白名单思路)—— maxDays: "abc" 或 -1 不能被当成
|
|
351
|
+
// 「保留 -1 天」而把归档全删了,认不出的值等于没设。
|
|
352
|
+
// skip 是 agent 名数组:只收非空字符串、去重、按名排序(落盘后 diff 稳定,不会因为顺序抖动反复重写配置)。
|
|
353
|
+
function loadArchiveCfg(j) {
|
|
354
|
+
if (!j || typeof j !== 'object') return;
|
|
355
|
+
const pos = v => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : 0; };
|
|
356
|
+
const skip = Array.isArray(j.skip)
|
|
357
|
+
? [...new Set(j.skip.map(s => String(s == null ? '' : s).trim()).filter(Boolean))].sort()
|
|
358
|
+
: [];
|
|
359
|
+
archiveCfg = { enabled: j.enabled !== false, maxDays: pos(j.maxDays), maxMB: pos(j.maxMB), skip };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// 异常巡检规则(R9):坏值一律回落默认(同 scanMs 白名单思路 —— 阈值写成 "abc" / -1 不算数)。
|
|
363
|
+
// tokens: {val} > 0 才算启用,单位 token;durMs: {val} > 0 才算启用,单位毫秒;
|
|
364
|
+
// fail: {val} 取 1..100 的百分比,{window} 取 >=1 的轮数,val 越界即回落。
|
|
365
|
+
function loadAlertRules(j) {
|
|
366
|
+
if (!j || typeof j !== 'object') return;
|
|
367
|
+
const clone = () => JSON.parse(JSON.stringify(ALERT_DEFAULT));
|
|
368
|
+
const next = clone();
|
|
369
|
+
const tr = j.tokens; if (tr && typeof tr === 'object') {
|
|
370
|
+
const v = Number(tr.val); if (Number.isFinite(v) && v > 0) next.tokens = { on: true, val: Math.round(v) };
|
|
371
|
+
}
|
|
372
|
+
const dr = j.durMs; if (dr && typeof dr === 'object') {
|
|
373
|
+
const v = Number(dr.val); if (Number.isFinite(v) && v > 0) next.durMs = { on: true, val: Math.round(v) };
|
|
374
|
+
}
|
|
375
|
+
const fr = j.fail; if (fr && typeof fr === 'object') {
|
|
376
|
+
const v = Number(fr.val); const w = Number(fr.window);
|
|
377
|
+
if (Number.isFinite(v) && v >= 1 && v <= 100) {
|
|
378
|
+
next.fail = { on: true, val: Math.round(v), window: Number.isFinite(w) && w >= 1 ? Math.round(w) : 50 };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
alertRules = next;
|
|
382
|
+
alertDirty = true;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// 按当前规则重算命中条目。每次广播前调用(有新增日志或规则变化时),保证「新产生的异常条目
|
|
386
|
+
// 无需人工翻找即可被提示」。遍历 entries 是 O(n * 规则数),n 上限 20000、规则又是简单数值比较,
|
|
387
|
+
// 只发生在 broadcast 附近,代价可接受。命中结果不落 index(纯内存派生,重扫后自然重算)。
|
|
388
|
+
const ALERT_KEYS = ['tokens', 'durMs', 'fail'];
|
|
389
|
+
function recomputeAlerts() {
|
|
390
|
+
alertHits.clear();
|
|
391
|
+
if (!(alertRules.tokens.on || alertRules.durMs.on || alertRules.fail.on)) { alertDirty = false; return; }
|
|
392
|
+
let list = sortedEntries();
|
|
393
|
+
// 失败率规则按 agent 分批:先算出每个 agent 的「最近 window 轮里失败占比」,再回填命中。
|
|
394
|
+
// 只在 fail 规则启用时才做这份分组计算;两个「单轮」规则直接在遍历里判,不分组。
|
|
395
|
+
const failAgents = new Map(); // agent -> {fail, total, threshold}
|
|
396
|
+
if (alertRules.fail.on) {
|
|
397
|
+
const perAgent = new Map();
|
|
398
|
+
for (const e of list) { if (!perAgent.has(e.agent)) perAgent.set(e.agent, []); perAgent.get(e.agent).push(e); }
|
|
399
|
+
const win = alertRules.fail.window;
|
|
400
|
+
for (const [agent, arr] of perAgent) {
|
|
401
|
+
// 窗口取「最近 window 轮」:list 已按时间降序,取前 window 条(不再翻更早的历史,
|
|
402
|
+
// 否则挂机/隔夜那半天会把正常窗口全部稀释成低失败率,规则就失灵了)。
|
|
403
|
+
const recent = arr.slice(0, win);
|
|
404
|
+
const failed = recent.filter(e => e.status !== 'ok').length;
|
|
405
|
+
failAgents.set(agent, {
|
|
406
|
+
fail: failed, total: recent.length,
|
|
407
|
+
threshold: alertRules.fail.val,
|
|
408
|
+
hits: failed / (recent.length || 1) * 100,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
for (const e of list) {
|
|
413
|
+
const rules = [];
|
|
414
|
+
let value = null;
|
|
415
|
+
if (alertRules.tokens.on && (e.total || 0) > alertRules.tokens.val) { rules.push('tokens'); value = e.total; }
|
|
416
|
+
if (alertRules.durMs.on && (e.dur || 0) > alertRules.durMs.val) { rules.push('durMs'); value = e.dur; }
|
|
417
|
+
if (alertRules.fail.on && e.status !== 'ok') {
|
|
418
|
+
// 失败率规则命中的是「构成失败的那几轮」—— 只有当该 agent 的失败占比超阈值时,
|
|
419
|
+
// 这批失败轮才算异常;不超的话失败轮再多也是正常波动,不报。
|
|
420
|
+
const f = failAgents.get(e.agent);
|
|
421
|
+
if (f && f.hits >= f.threshold) { rules.push('fail'); value = value == null ? e.status : value; }
|
|
422
|
+
}
|
|
423
|
+
if (rules.length) alertHits.set(e.id, { agent: e.agent, rules, value });
|
|
424
|
+
}
|
|
425
|
+
alertDirty = false;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function alertSummary() {
|
|
429
|
+
const byAgent = new Map();
|
|
430
|
+
for (const h of alertHits.values()) {
|
|
431
|
+
if (!byAgent.has(h.agent)) byAgent.set(h.agent, { count: 0, rules: new Set() });
|
|
432
|
+
const o = byAgent.get(h.agent); o.count++; for (const r of h.rules) o.rules.add(r);
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
rules: alertRules,
|
|
436
|
+
count: alertHits.size,
|
|
437
|
+
agents: [...byAgent].map(([agent, o]) => ({ agent, count: o.count, rules: [...o.rules] })),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// 页面上的 per-agent 归档开关(POST /api/archive/skip 的后端):只认**已配置**的 agent 名 ——
|
|
442
|
+
// 页面将来要是传了个拼错的名字进来,那种名字会永久躺在 config.json 里,以后排查看不出出处。
|
|
443
|
+
// 语义与 config.archive.skip 完全一致:只停未来的归档,不删已冻好的文件。
|
|
444
|
+
function setArchiveSkip(agent, skip) {
|
|
445
|
+
const name = String(agent == null ? '' : agent);
|
|
446
|
+
if (!name || !agentConfs.has(name)) return { ok: false, error: '没有这个 agent:' + (name || '(空)') };
|
|
447
|
+
const set = new Set(archiveCfg.skip);
|
|
448
|
+
if (skip) set.add(name); else set.delete(name);
|
|
449
|
+
archiveCfg = { ...archiveCfg, skip: [...set].sort() };
|
|
450
|
+
saveConfig();
|
|
451
|
+
return { ok: true, skip: archiveCfg.skip };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// 保存异常巡检规则(R9 的后端):整组替换,坏值回落默认,保存后立刻重算命中并广播。
|
|
455
|
+
// 规则入口由 loadAlertRules 那套白名单守住 —— fetch 前先用它对 rules 做一次「归一化加载」,
|
|
456
|
+
// 保证命中的值都是干净整数。
|
|
457
|
+
function setAlertRules(rules) {
|
|
458
|
+
const canned = { tokens: rules && rules.tokens, durMs: rules && rules.durMs, fail: rules && rules.fail };
|
|
459
|
+
loadAlertRules(canned); // 复用白名单校验 + 回落逻辑,把用户 payload 归一化成合法规则
|
|
460
|
+
saveConfig();
|
|
461
|
+
recomputeAlerts();
|
|
462
|
+
broadcast(); // 规则变化本身就是 user intent(lastActivity 已含),推 alert 事件
|
|
463
|
+
return { ok: true, rules: alertRules, summary: alertSummary() };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function saveConfig() {
|
|
467
|
+
// 只读库模式:一个字节都不写(R31 的 MCP 进程与常驻服务共用 DATA_DIR,双写就是互相覆盖)。
|
|
468
|
+
// 内存里的 agentConfs 照改不误 —— 本进程要用它;只是不落盘,下次启动重新发现,代价几毫秒。
|
|
469
|
+
if (MODE === 'lib') return;
|
|
470
|
+
// 保险丝:本次一条配置都没读到,而老配置还躺在旧位置 —— 说明数据目录没搬完(或搬岔了)。
|
|
471
|
+
// 这时落盘 = 把默认预置写成用户的配置:手工加的 agent、traeKey、ctxWindows 全被顶掉,
|
|
472
|
+
// 用户刻意删掉的 agent 还会复活(loadConfig 开头那条红线)。宁可这一次不落盘。
|
|
473
|
+
if (configMissingWithLegacy) {
|
|
474
|
+
console.error('[config] 拒绝写盘:本次没读到任何配置,而老配置还在 ' + (legacyWithConfig() || LEGACY_DATA_DIRS.join(' / ')) +
|
|
475
|
+
' —— 多半是数据目录没搬完。把那里的配置拷到 ' + CONFIG_FILE + ' 后再启动服务。');
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const agents = {};
|
|
479
|
+
for (const [n, c] of agentConfs) {
|
|
480
|
+
agents[n] = {
|
|
481
|
+
kind: c.kind, sessions: c.sessions || null, traces: c.traces || null,
|
|
482
|
+
builtin: !!c.builtin, enabled: c.enabled !== false, source: c.source || (c.builtin ? 'builtin' : 'manual'),
|
|
483
|
+
};
|
|
484
|
+
if (c.sessionsExtra && c.sessionsExtra.length) agents[n].sessionsExtra = c.sessionsExtra;
|
|
485
|
+
// traedb 的解密密钥(64 位十六进制)。属于**用户意图**:库换个新版本/新机器时用户唯一要带过来的东西。
|
|
486
|
+
// 只在有值时落盘(别的 agent 配置里不该凭空多一行);agentsList() 不返回它 —— 接口不必外泄密钥。
|
|
487
|
+
if (c.traeKey) agents[n].traeKey = c.traeKey;
|
|
488
|
+
// R18 generic-jsonl:声明式规则、目录摆法与口径版本属于**用户意图**,随 config 落盘。
|
|
489
|
+
// rulesHash 不是用户写的 —— 是 loadConfig 校验时算好挂上来的,这里照原样带回去,
|
|
490
|
+
// 下次启动拿它判断「rules 改了没」(改了就 bump rulesRev → 旧 index 状态整份失效重扫)。
|
|
491
|
+
if (c.kind === 'generic-jsonl') {
|
|
492
|
+
if (c.rules) agents[n].rules = c.rules;
|
|
493
|
+
if (c.sniff) agents[n].sniff = c.sniff;
|
|
494
|
+
agents[n].rulesRev = c.rulesRev || 1;
|
|
495
|
+
if (c.rulesHash) agents[n].rulesHash = c.rulesHash;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
500
|
+
// scanMs / idleExitMs 是顶层设置(与 agents 同级),同样属于用户意图,重启后保持
|
|
501
|
+
// ctxWindows 只写**用户显式声明**的那几条,不写内置默认(否则以后改默认值会被旧配置压住);
|
|
502
|
+
// 空表干脆不写这个键,免得用户没改过窗口表、配置文件里却凭空多出一行
|
|
503
|
+
const cfg = { v: 3, scanMs, idleExitMs, agents };
|
|
504
|
+
if (Object.keys(ctxUser).length) cfg.ctxWindows = ctxUser;
|
|
505
|
+
// 归档默认值不写盘(同 ctxWindows):用户没改过就别让配置文件凭空多出一行,
|
|
506
|
+
// 以后改默认值时也不会被旧配置压住。
|
|
507
|
+
if (archiveCfg.enabled !== ARCHIVE_DEFAULT.enabled || archiveCfg.maxDays || archiveCfg.maxMB || archiveCfg.skip.length)
|
|
508
|
+
cfg.archive = { ...archiveCfg };
|
|
509
|
+
// 异常巡检规则:与默认全关不同就落盘(同 ctxWindows 思路——用户没配过不让配置凭空多一行)
|
|
510
|
+
if (JSON.stringify(alertRules) !== JSON.stringify(ALERT_DEFAULT)) cfg.alert = { ...alertRules };
|
|
511
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2));
|
|
512
|
+
} catch (e) { console.error('[config] save failed', e.message); }
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// v2 的旧预置(codebuddy/workbuddy 走 trace)。v3 起改用 buddyjsonl(projects 正文)。
|
|
516
|
+
// 迁移只动「一字未改的旧预置」:kind 与两条路径都与旧默认一致、且新目录真实存在;用户改过的一律不碰。
|
|
517
|
+
const LEGACY_PRESETS = {
|
|
518
|
+
codebuddy: { kind: 'trace', sessions: path.join(HOME, '.codebuddy', 'sessions'), traces: path.join(HOME, '.codebuddy', 'traces') },
|
|
519
|
+
workbuddy: { kind: 'trace', sessions: path.join(HOME, '.workbuddy', 'sessions'), traces: path.join(HOME, '.workbuddy', 'traces') },
|
|
520
|
+
};
|
|
521
|
+
const samePath = (a, b) => (a || '') === (b || '');
|
|
522
|
+
|
|
523
|
+
function migratePresets() {
|
|
524
|
+
const moved = [];
|
|
525
|
+
for (const [name, oldP] of Object.entries(LEGACY_PRESETS)) {
|
|
526
|
+
const c = agentConfs.get(name);
|
|
527
|
+
if (!c || c.kind !== oldP.kind || !samePath(c.sessions, oldP.sessions) || !samePath(c.traces, oldP.traces)) continue;
|
|
528
|
+
const next = DEFAULT_AGENTS[name];
|
|
529
|
+
if (!next || !isDir(next.sessions)) continue; // 新版目录不存在(旧版产品):保持原样,别把能用的配置改废
|
|
530
|
+
agentConfs.set(name, { ...c, kind: next.kind, sessions: next.sessions, traces: next.traces || null });
|
|
531
|
+
moved.push(name);
|
|
532
|
+
}
|
|
533
|
+
if (moved.length) log('[config] migrated v2 -> v3 presets:', moved.join(', '));
|
|
534
|
+
return moved.length;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// R15:历史手动添加的 kimi-code 配置归一化为 kimi(需求书验收——不分裂为两个 agent)。
|
|
538
|
+
// 规则:有 kimi-code、无 kimi → 整条改名(保留 enabled/source 等全部字段);
|
|
539
|
+
// 两者都在 → kimi 已被自动发现接管,删掉 kimi-code 避免并列。
|
|
540
|
+
// 复用 migratePresets 的「启动时一次性迁移 + 落盘」模式;迁移后索引里的旧 agent 名条目
|
|
541
|
+
// 会在下次扫描时因 agent 名对不上而被 evictStale/重扫自然淘汰,无需手搬数据。
|
|
542
|
+
function migrateKimiRename() {
|
|
543
|
+
let moved = false;
|
|
544
|
+
const old = agentConfs.get('kimi-code');
|
|
545
|
+
if (old) {
|
|
546
|
+
agentConfs.delete('kimi-code');
|
|
547
|
+
if (!agentConfs.has('kimi')) agentConfs.set('kimi', old);
|
|
548
|
+
moved = true;
|
|
549
|
+
}
|
|
550
|
+
// 已改过名的也可能带着旧 kind(上一版迁移只改名没纠正 kind):sessions 指向 .kimi-code 的
|
|
551
|
+
// 一律按 kimi 的 wire.jsonl 结构重认,kind 本来就是 kimi 的不动
|
|
552
|
+
const cur = agentConfs.get('kimi');
|
|
553
|
+
if (cur && cur.kind !== 'kimi' && cur.sessions && /kimi/i.test(String(cur.sessions))) {
|
|
554
|
+
agentConfs.set('kimi', { ...cur, kind: 'kimi', sessions: path.join(HOME, '.kimi-code', 'sessions') });
|
|
555
|
+
moved = true;
|
|
556
|
+
}
|
|
557
|
+
if (moved) log('[config] migrated kimi-code -> kimi (kind=' + agentConfs.get('kimi')?.kind + ')');
|
|
558
|
+
return moved;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// 上一版把 genie 扩展注册成了独立一行 `codebuddy-ext`,现在它的会话并到 `codebuddy` 名下
|
|
562
|
+
// (同一个 CodeBuddy 拆两行,会把一个项目的历史劈成两半 —— 按 agent 筛就漏一边)。
|
|
563
|
+
// 这里**只删那条 conf**:根由 discoverAgents 每轮从 buddyExtDataRoots() 重算挂回
|
|
564
|
+
// codebuddy.sessionsExtra,所以不搬 sessions(搬了会和重算结果打架)。
|
|
565
|
+
// 条目也不用搬:buddyext 不在 OFF_KINDS,它的条目每次启动都是重建的,重启后自然挂在新 agent 名下。
|
|
566
|
+
function migrateCodebuddyMerge() {
|
|
567
|
+
if (!agentConfs.has('codebuddy-ext')) return false;
|
|
568
|
+
agentConfs.delete('codebuddy-ext');
|
|
569
|
+
log('[config] codebuddy-ext 已并入 codebuddy(genie 扩展成为它的第二个根)');
|
|
570
|
+
return true;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function migrateCopilotKind() {
|
|
574
|
+
const cur = agentConfs.get('copilot');
|
|
575
|
+
if (!cur) return false;
|
|
576
|
+
for (const base of candidateBases('copilot')) {
|
|
577
|
+
const conf = sniffBase(base);
|
|
578
|
+
if (!conf || conf.kind !== 'copilot') continue;
|
|
579
|
+
const same = cur.kind === conf.kind && samePath(cur.sessions, conf.sessions) && samePath(cur.traces, conf.traces);
|
|
580
|
+
if (same) return false;
|
|
581
|
+
agentConfs.set('copilot', { ...cur, kind: conf.kind, sessions: conf.sessions, traces: conf.traces || null });
|
|
582
|
+
log('[config] migrated copilot kind/path:', cur.kind + ' -> ' + conf.kind, '(' + conf.sessions + ')');
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Copilot 初接入前可能已有一个自动项被旧的通用探测误接到错误父目录;候选路径命中真实格式时纠正它。
|
|
589
|
+
function migrateCopilotKindAndPath() {
|
|
590
|
+
return migrateCopilotKind();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// dsh 接入前,本机那条 dsh 是**手工加的**、kind 填的是 atomcode(当时没有解析器,这是唯一办法)。
|
|
594
|
+
// 现在有了真解析器,这些老配置必须纠正过来,否则它们挡在自动发现前面(discoverAgents 见名字已存在
|
|
595
|
+
// 就不接管),侧栏会一直是 0 条 —— 正是 kimi 当年「升级过的机器上完全看不出来」的翻版。
|
|
596
|
+
//
|
|
597
|
+
// 判据取**内容**而不是名字/路径:这个条目指向的目录(或它下面的 sessions/)里确实躺着 dsh 的
|
|
598
|
+
// 会话文件,才算它是 dsh。这样两种历史填法都能覆盖 —— 填 sessions 目录本身(本机就是这样)、
|
|
599
|
+
// 填 .dsh 基目录、以及用户把它命名成了别的名字。不看名字也顺带避免了「同名但不是 dsh」的误伤:
|
|
600
|
+
// 目录里没有 dsh 会话文件就一律不动。
|
|
601
|
+
// 安全性:一个目录里真有 dsh 会话文件时,原来的 kind 本来就读不出任何东西(否则也不用纠正了),
|
|
602
|
+
// 所以这个纠正不会让任何**原本能用**的配置变坏。
|
|
603
|
+
function migrateDshKind() {
|
|
604
|
+
let moved = false;
|
|
605
|
+
for (const [name, conf] of [...agentConfs]) {
|
|
606
|
+
if (conf.kind === 'dsh' || !conf.sessions) continue;
|
|
607
|
+
const s = String(conf.sessions);
|
|
608
|
+
if (!hasDshSessions(s) && !hasDshSessions(path.join(s, 'sessions'))) continue;
|
|
609
|
+
agentConfs.set(name, { ...conf, kind: 'dsh' });
|
|
610
|
+
log('[config] migrated dsh kind:', name, conf.kind, '-> dsh');
|
|
611
|
+
moved = true;
|
|
612
|
+
}
|
|
613
|
+
return moved;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Trae 系解析器升级:SQLCipher 库现在能读了(见 parsers/traedb.mjs),老配置(tracecode/tracework,
|
|
617
|
+
// sessions 指向 logs 目录)要改接 database.db —— 库里有 token/工具/正文,renderer.log 只有事件骨架。
|
|
618
|
+
// 与 discoverAgents 的重嗅探是同一件事的两条路:source:auto 的条目靠重嗅探自愈,这里专门兜
|
|
619
|
+
// **手工添加**的(source:manual 不参与重嗅探,不迁移就是永远读不到库里的东西)。
|
|
620
|
+
// 判据取**实际文件**(traeDbFile 沿候选路径找 database.db),不看配置里怎么命名;
|
|
621
|
+
// 密钥没配的场景由主文件自动回退 renderer.log —— 迁过来至少不会比现在差。
|
|
622
|
+
function migrateTraeKind() {
|
|
623
|
+
let moved = false;
|
|
624
|
+
for (const [name, conf] of [...agentConfs]) {
|
|
625
|
+
if (conf.kind !== 'tracecode' && conf.kind !== 'tracework') continue;
|
|
626
|
+
if (!conf.sessions) continue;
|
|
627
|
+
const db = traeDbFile(String(conf.sessions)) || traeDbFile(path.dirname(String(conf.sessions)));
|
|
628
|
+
if (!db) continue;
|
|
629
|
+
agentConfs.set(name, { ...conf, kind: 'traedb', sessions: db });
|
|
630
|
+
log('[config] migrated trae kind:', name, conf.kind, '-> traedb', '(' + db + ')');
|
|
631
|
+
moved = true;
|
|
632
|
+
}
|
|
633
|
+
return moved;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function loadConfig() {
|
|
637
|
+
agentConfs.clear();
|
|
638
|
+
let j = null;
|
|
639
|
+
try { j = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch {
|
|
640
|
+
// 回退:**同目录**下的旧文件名。覆盖两种人 —— 手工把老目录拷到新路径的,以及老版本刚写过的。
|
|
641
|
+
// 读到就顺手改名为 config.json(与 loadIndex 把旧单文件索引改名成 .bak 同一套路):
|
|
642
|
+
// 否则下次还走回退,而且会出现「读的是旧名、写的是新名」两份配置。
|
|
643
|
+
try {
|
|
644
|
+
const old = path.join(DATA_DIR, LEGACY_CONFIG_NAME);
|
|
645
|
+
j = JSON.parse(fs.readFileSync(old, 'utf8'));
|
|
646
|
+
// 改名是**写**:只读库模式只读不改名(不改名的后果只是下次还走这条回退,几毫秒)
|
|
647
|
+
if (MODE !== 'lib') try { fs.renameSync(old, CONFIG_FILE); log('[config] 配置文件名 ' + LEGACY_CONFIG_NAME + ' 已改为 config.json'); } catch {}
|
|
648
|
+
} catch {}
|
|
649
|
+
}
|
|
650
|
+
// 保险丝(见 saveConfig 开头):读到配置就一切照常;一条都没读到、而老配置还在旧位置 → 禁止落盘。
|
|
651
|
+
// 它防的不是「搬迁逻辑写错了」,而是「搬迁逻辑将来被改错了」—— 那条链一旦走通是不可逆的。
|
|
652
|
+
configMissingWithLegacy = !j && !!legacyWithConfig();
|
|
653
|
+
// 预置只在「没有配置」时发生(全新安装 / v1 老配置迁移)。
|
|
654
|
+
// v2/v3 配置就是用户意图的完整快照:删过的 agent 不能再被这里重新塞回来(否则又是删不掉)。
|
|
655
|
+
if (!j || j.v === 1) {
|
|
656
|
+
for (const [n, c] of Object.entries(DEFAULT_AGENTS)) agentConfs.set(n, { ...c, builtin: true, enabled: true, source: 'builtin' });
|
|
657
|
+
}
|
|
658
|
+
if (j && (j.v === 2 || j.v === 3)) {
|
|
659
|
+
// 扫描节奏:只接受白名单值,坏值回落到默认(0 = 不实时更新/暂停定时扫描)
|
|
660
|
+
if (SCAN_CHOICES.includes(j.scanMs)) scanMs = j.scanMs;
|
|
661
|
+
if (IDLE_CHOICES.includes(j.idleExitMs)) idleExitMs = j.idleExitMs;
|
|
662
|
+
loadAlertRules(j.alert); // 异常巡检规则(R9;默认全关,坏值回落默认)
|
|
663
|
+
loadCtxWindows(j.ctxWindows); // 上下文窗口表:模型名(或前缀)-> 容量,认不出的留 0
|
|
664
|
+
loadArchiveCfg(j.archive); // 归档开关与保留上限(见 archive.mjs)
|
|
665
|
+
for (const [n, c] of Object.entries(j.agents || {})) {
|
|
666
|
+
// builtin 仅作展示标记(预置项),不再决定能否删除
|
|
667
|
+
const isPreset = Object.prototype.hasOwnProperty.call(DEFAULT_AGENTS, n);
|
|
668
|
+
agentConfs.set(n, { ...c, builtin: isPreset, enabled: c.enabled !== false });
|
|
669
|
+
}
|
|
670
|
+
if (j.v === 2 && migratePresets()) saveConfig();
|
|
671
|
+
} else if (j && j.v === 1) {
|
|
672
|
+
// v1 无损迁移:custom -> agents,source:manual
|
|
673
|
+
for (const [n, c] of Object.entries(j.custom || {})) agentConfs.set(n, { ...c, builtin: false, enabled: true, source: 'manual' });
|
|
674
|
+
log('[config] migrated v1 -> v2');
|
|
675
|
+
saveConfig();
|
|
676
|
+
}
|
|
677
|
+
// R15:无论哪个版本,都把历史 kimi-code 改名为 kimi(改了就落盘)
|
|
678
|
+
const migrated = migrateKimiRename();
|
|
679
|
+
const migratedCopilot = migrateCopilotKindAndPath();
|
|
680
|
+
const migratedBx = migrateCodebuddyMerge();
|
|
681
|
+
// dsh 同理:手工加的那条要把 kind 从 atomcode 纠正过来(见 migrateDshKind)
|
|
682
|
+
// trae 系同理:tracecode/tracework 的老配置改接 SQLCipher 库(见 migrateTraeKind)
|
|
683
|
+
// R18:generic-jsonl 的规则校验 + 口径版本管理(loadIndex 之前必须做完 —— 恢复 index 要拿
|
|
684
|
+
// rulesRev/rulesHash 逐文件比对;这里必须在首次 loadIndex 前把 rulesHash 算好挂到 conf 上)
|
|
685
|
+
const genericChanged = validateGenericConfigs();
|
|
686
|
+
if (migrateDshKind() || migrateTraeKind() || migrated || migratedCopilot || migratedBx || genericChanged) saveConfig();
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// R18:generic-jsonl 声明式规则的集中校验与版本管理。每个 kind=generic-jsonl 的 agent:
|
|
690
|
+
// 1) rules 过 validateGenericRules —— 校验失败回落 enabled:false,错误明细挂在 rulesErrors 上
|
|
691
|
+
// (诊断页据此说出**具体哪条 rule 坏了**,不静默扫出 0 条;修好规则后错误清除,
|
|
692
|
+
// enabled 仍要用户手动恢复 —— 避免「改对一个字段就让停用的 agent 自己上线」的意外);
|
|
693
|
+
// 2) rulesHash 变了(= 落盘 turns 口径变了)自动 bump rulesRev。不靠人记 bump:
|
|
694
|
+
// index 恢复按 rr/rh 逐文件比对,版本对不上整份丢弃重扫,不会出现「半新半旧」。
|
|
695
|
+
function validateGenericConfigs() {
|
|
696
|
+
let changed = false;
|
|
697
|
+
for (const [name, c] of agentConfs) {
|
|
698
|
+
if (c.kind !== 'generic-jsonl') continue;
|
|
699
|
+
const errs = validateGenericRules(c.rules);
|
|
700
|
+
const hash = genericRulesHash(c.rules || {});
|
|
701
|
+
if (!c.rulesRev) c.rulesRev = 1; // 首次声明
|
|
702
|
+
else if (c.rulesHash && c.rulesHash !== hash) c.rulesRev++; // rules 变了
|
|
703
|
+
if (c.rulesHash !== hash) changed = true;
|
|
704
|
+
c.rulesHash = hash;
|
|
705
|
+
if (errs.length) {
|
|
706
|
+
if (c.enabled !== false) {
|
|
707
|
+
console.error('[config] generic-jsonl 规则校验失败,回落禁用:' + name + ' —— ' +
|
|
708
|
+
errs.map(e => e.rule + ':' + e.msg).join(';'));
|
|
709
|
+
}
|
|
710
|
+
c.enabled = false;
|
|
711
|
+
c.rulesErrors = errs;
|
|
712
|
+
} else if (c.rulesErrors && c.rulesErrors.length) {
|
|
713
|
+
delete c.rulesErrors;
|
|
714
|
+
changed = true;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return changed;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// ---------------- 环境诊断(/api/diagnose) ----------------
|
|
721
|
+
// 给每个候选 agent 逐个候选目录报「在不在 / 认成什么格式 / 没认出来是为什么 / 接没接上」。
|
|
722
|
+
// 目的:把「目录就在那、但格式没识别」这种静默情况显式暴露出来,补解析器有的放矢(见需求书 §3.6)。
|
|
723
|
+
// 只在用户点开诊断时调用,不做缓存。
|
|
724
|
+
const DB_RE = /\.(db|sqlite|sqlite3|vscdb)$/i;
|
|
725
|
+
function dirHint(base) {
|
|
726
|
+
let names; try { names = fs.readdirSync(base).slice(0, 60); } catch { return null; }
|
|
727
|
+
if (!names.length) return '空目录';
|
|
728
|
+
const sq = names.filter(n => DB_RE.test(n));
|
|
729
|
+
// 数据库常常放在子目录里(cursor 的就是 <base>/ai-tracking/ai-code-tracking.db),
|
|
730
|
+
// 只扫顶层会把「这里其实有 sqlite」漏掉——而 A3 的决策正需要看到它。往下探一层,限量扫。
|
|
731
|
+
for (const n of names.slice(0, 12)) {
|
|
732
|
+
if (sq.length >= 3) break;
|
|
733
|
+
if (!isDir(path.join(base, n))) continue;
|
|
734
|
+
let sub; try { sub = fs.readdirSync(path.join(base, n)).slice(0, 40); } catch { continue; }
|
|
735
|
+
for (const s of sub) if (DB_RE.test(s)) sq.push(n + '/' + s);
|
|
736
|
+
}
|
|
737
|
+
const head = names.slice(0, 8).map(n => (isDir(path.join(base, n)) ? n + '/' : n)).join(' ');
|
|
738
|
+
let out = '顶层:' + head + (names.length > 8 ? ' …' : '') +
|
|
739
|
+
(sq.length ? ';含 sqlite 数据库(' + sq.slice(0, 3).join(', ') + ')——零依赖原则下读不了,需先决策' : '');
|
|
740
|
+
// cursor 的常见「路径明明对、却一条也扫不到」:它的 projects/<slug>/ 里,**只有跑过 Agent 模式的项目**
|
|
741
|
+
// 才有 agent-transcripts/;只聊过天的项目只有 mcps/ 和 terminals/。
|
|
742
|
+
// 这不是解析器坏了,也不是路径写错了 —— 直接说出来,省得往那两个方向查。
|
|
743
|
+
const projDir = path.join(base, 'projects');
|
|
744
|
+
if (isDir(projDir)) {
|
|
745
|
+
try {
|
|
746
|
+
let withT = 0, without = 0;
|
|
747
|
+
for (const s of fs.readdirSync(projDir).slice(0, 60)) {
|
|
748
|
+
if (!isDir(path.join(projDir, s))) continue;
|
|
749
|
+
if (isDir(path.join(projDir, s, 'agent-transcripts'))) withT++; else without++;
|
|
750
|
+
}
|
|
751
|
+
if (!withT && without) out += ';projects/ 下有 ' + without + ' 个项目目录,但没有一个含 agent-transcripts —— Cursor 的 Chat 不落这个目录,只有 Agent 模式的会话才写,说明这台机器上还没跑过 Agent 会话';
|
|
752
|
+
else if (withT) out += ';projects/ 下有 ' + withT + ' 个项目含 agent-transcripts(有 Agent 会话)';
|
|
753
|
+
} catch {}
|
|
754
|
+
}
|
|
755
|
+
return out;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function diagnose() {
|
|
759
|
+
const cnt = counts().agents;
|
|
760
|
+
const rows = [];
|
|
761
|
+
const describe = (name, conf, cands) => {
|
|
762
|
+
const row = {
|
|
763
|
+
name, registered: !!conf, enabled: conf ? conf.enabled !== false : null,
|
|
764
|
+
kind: conf?.kind || null, sessions: conf?.sessions || null, traces: conf?.traces || null,
|
|
765
|
+
// 额外根要在诊断页看得见,否则「一个 agent 名下有两份落盘」(kimi 的桌面版根、codebuddy 的
|
|
766
|
+
// genie 扩展根)这件事只剩 note 里一句话,排查时点进来看不到根清单。
|
|
767
|
+
sessionsExtra: conf?.sessionsExtra || null,
|
|
768
|
+
source: conf?.source || null, missing: !!(conf && conf.missing), entries: cnt[name] || 0,
|
|
769
|
+
ownedBy: conf ? (dirTaken(conf, name) || null) : null, candidates: cands,
|
|
770
|
+
ruleErrors: conf?.rulesErrors || null, // R18 generic-jsonl:坏规则明细(诊断页要能说出哪条 rule 坏了)
|
|
771
|
+
};
|
|
772
|
+
const hit = cands.find(c => c.kind && !c.probe); // 探路路径不算「认出来了」,见 PROBE_ONLY_BASES
|
|
773
|
+
if (!conf) {
|
|
774
|
+
// 认出来了但没注册给它:多半是同一份目录已经被别的 agent 接了(如手工加的 codeart 与
|
|
775
|
+
// 自动发现名单里的 codearts 指向同一个 codearts-agent 目录),别误报成「需要写新解析器」
|
|
776
|
+
const owner = hit ? dirTaken({ sessions: hit.sessions, traces: null }, name) : null;
|
|
777
|
+
if (hit && owner) {
|
|
778
|
+
row.state = 'owned';
|
|
779
|
+
row.stateText = '已由 ' + owner + ' 接入';
|
|
780
|
+
row.note = '同一份目录(' + hit.path + '),避免重复扫描所以不让 ' + name + ' 再接一次';
|
|
781
|
+
} else if (hit) {
|
|
782
|
+
row.state = 'unrecognized';
|
|
783
|
+
row.stateText = '目录在但未识别';
|
|
784
|
+
row.note = hit.path + ' —— 嗅探没认出来,需要新增解析器';
|
|
785
|
+
} else {
|
|
786
|
+
// 「目录在但整棵子树都没认出来」和「压根没装」必须分开:前者正是要补解析器的目标
|
|
787
|
+
// (cursor/copilot 就是这一类),混进灰色「未安装」里就等于又静默了。
|
|
788
|
+
const any = cands.find(c => c.exists);
|
|
789
|
+
if (any) {
|
|
790
|
+
row.state = 'unrecognized';
|
|
791
|
+
row.stateText = '目录在但未识别';
|
|
792
|
+
row.note = any.path + ' —— ' + (any.probe
|
|
793
|
+
? '这是尚未接入的变体(探路路径,不参与自动发现),需要先拿到它的日志样本再写解析器'
|
|
794
|
+
: '目录在,但整棵子树都没被认成已知格式,需要新增解析器');
|
|
795
|
+
} else {
|
|
796
|
+
row.state = 'absent'; row.stateText = '未安装'; row.note = '候选目录都不存在';
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
} else if (!row.enabled) {
|
|
800
|
+
row.state = 'disabled'; row.stateText = '已禁用';
|
|
801
|
+
// generic-jsonl 的「被禁用」多半不是手关的:rules 校验失败在 loadConfig 阶段回落 enabled:false。
|
|
802
|
+
// 不把坏的是哪条说清楚,用户面对一片空白只会以为路径写错了 —— 这里把 rule 名和原因逐条列出。
|
|
803
|
+
row.note = (row.ruleErrors && row.ruleErrors.length)
|
|
804
|
+
? '声明式规则校验失败,已回落禁用(修好 config 里的 rules 后再手动启用):' +
|
|
805
|
+
row.ruleErrors.map(e => e.rule + ' —— ' + e.msg).join(';')
|
|
806
|
+
: '在页面上被手动关掉了,不参与扫描';
|
|
807
|
+
} else if (row.missing) {
|
|
808
|
+
row.state = 'missing'; row.stateText = '目录消失'; row.note = '配置里的目录(' + (row.sessions || row.traces) + ')现在不存在';
|
|
809
|
+
} else if (!row.entries) {
|
|
810
|
+
row.state = 'empty'; row.stateText = '已接入 · 0 条';
|
|
811
|
+
// dsh 的 zstd 需要 Node 22.15+。这一条必须**单独说**:笼统的「格式细节对不上」会把排查方向
|
|
812
|
+
// 指向「去看日志里长什么样」,而真正该做的是升级 Node —— 换个版本的 Node 就全好了。
|
|
813
|
+
row.note = (row.kind === 'dsh' && !DSH_ZSTD_OK)
|
|
814
|
+
? '目录认出来了,但 dsh 的会话是 zstd 压缩的,解压接口要 Node 22.15+(当前 ' + process.version +
|
|
815
|
+
' 的 zlib 里没有 zstdDecompressSync)——升级 Node 即可,不是路径或格式的问题'
|
|
816
|
+
: (row.kind === 'zcode' && zcodeScanErr.get(name))
|
|
817
|
+
? '数据库认出来了,但最近一次读取失败:' + zcodeScanErr.get(name) +
|
|
818
|
+
'(库被别的进程独占锁定、文件损坏或 schema 变了都会走到这里;面板会保留上一轮读到的数据,恢复后自动跟上)'
|
|
819
|
+
: (row.kind === 'opencode' && opencodeScanErr.get(name))
|
|
820
|
+
? 'OpenCode 库认出来了,但最近一次读取失败:' + opencodeScanErr.get(name) +
|
|
821
|
+
'(库被别的进程独占锁定、文件损坏或 schema 变了都会走到这里;面板会保留上一轮读到的数据,恢复后自动跟上)'
|
|
822
|
+
: (row.kind === 'kilo' && kiloScanErr.get(name))
|
|
823
|
+
? 'KiloCode 库认出来了,但最近一次读取失败:' + kiloScanErr.get(name) +
|
|
824
|
+
'(库被别的进程独占锁定、文件损坏或 schema 变了都会走到这里;面板会保留上一轮读到的数据,恢复后自动跟上)'
|
|
825
|
+
: (row.kind === 'hermes' && hermesScanErr.get(name))
|
|
826
|
+
? 'Hermes 库认出来了,但最近一次读取失败:' + hermesScanErr.get(name) +
|
|
827
|
+
'(库被别的进程独占锁定、文件损坏或 schema 变了都会走到这里;面板会保留上一轮读到的数据,恢复后自动跟上)'
|
|
828
|
+
: (row.kind === 'devindb' && devinDbErr.get(name))
|
|
829
|
+
? 'Devin 库认出来了,但最近一次读取失败:' + devinDbErr.get(name) +
|
|
830
|
+
'(库被别的进程独占锁定、文件损坏或 schema 变了都会走到这里;面板会保留上一轮读到的数据,恢复后自动跟上)'
|
|
831
|
+
: (row.kind === 'traedb' && traeDbErr.get(name))
|
|
832
|
+
? 'SQLCipher 库认出来了,但读不出来:' + traeDbErr.get(name) +
|
|
833
|
+
'(库里是 Trae 的完整会话记录;没配 traeKey 时只能用 renderer.log 的骨架数据,' +
|
|
834
|
+
'密钥的获取办法见 REFERENCE.md「Trae 的 SQLCipher 库」一节)'
|
|
835
|
+
: (row.kind === 'copilot' && copilotScanErr.get(name))
|
|
836
|
+
? 'Copilot 目录认出来了,但最近一次读取用量库失败:' + copilotScanErr.get(name) +
|
|
837
|
+
'(正文仍会从 session-state/*/events.jsonl 解析;用量库恢复后自动补齐)'
|
|
838
|
+
: (row.kind === 'cline' && clineScanErr.get(name))
|
|
839
|
+
? 'Cline 目录认出来了,但最近一份会话文件读不动:' + clineScanErr.get(name) +
|
|
840
|
+
'(messages.json 是**整份 JSON**、会话进行中每次整体重写 —— 写到一半被读到、文件坏了、' +
|
|
841
|
+
'或新版换了字段形状都会走到这里;下一轮扫描会自动重来,不必重启)'
|
|
842
|
+
: ((row.kind === 'buddyext' || row.kind === 'buddyjsonl') && buddyExtScanErr.get(name))
|
|
843
|
+
? 'CodeBuddy 扩展目录认出来了,但最近一份会话 index.json 读不动:' + buddyExtScanErr.get(name) +
|
|
844
|
+
'(会话 index 是**整份 JSON**、进行中每次整体重写 —— 写到一半被读到、文件坏了、' +
|
|
845
|
+
'或新版换了 requests/messages 形状都会走到这里;下一轮扫描会自动重来,不必重启。' +
|
|
846
|
+
'另:CodeBuddy 家的 CLI 在 ~/.codebuddy、IDE 壳在 %APPDATA%\\CodeBuddy CN,都不在这份数据里)'
|
|
847
|
+
: (row.kind === 'traedb' )
|
|
848
|
+
? '库认出来了、也能解密,但一条会话都没解析出来(库可能是空的,或 schema 与已知版本不同)'
|
|
849
|
+
: '目录认出来了、也在扫,但一条都没解析出来——多半是格式细节对不上';
|
|
850
|
+
} else {
|
|
851
|
+
row.state = 'ok'; row.stateText = '已接入'; row.note = row.kind + ' · ' + row.entries + ' 条';
|
|
852
|
+
// 库读不出来、回退 renderer.log 时条目是有的(所以落在 ok 行),但必须把「为什么是日志数据」说清:
|
|
853
|
+
// 不写的话这一行看着完全正常,用户不会知道卡片上少了 token 与工具明细是因为密钥没配。
|
|
854
|
+
if (row.kind === 'traedb' && traeDbErr.get(name)) {
|
|
855
|
+
const active = traeLogKindOf(row.sessions || '');
|
|
856
|
+
row.note += '(已回退 ' + active + ' 日志解析:' + traeDbErr.get(name) + ')';
|
|
857
|
+
}
|
|
858
|
+
// codebuddy 名下有两份落盘(CLI 的 projects/**/*.jsonl + genie 扩展的 CodeBuddyExtension/Data),
|
|
859
|
+
// 合成一行就必须让这一行说得出「两边各有多少、另一边是不是坏了」:
|
|
860
|
+
// 不写的话扩展那半读不动时这行看着完全正常(CLI 那半还有条目撑着条数),
|
|
861
|
+
// 用户只会觉得「应用里的会话怎么少了一半」—— 与 trae 回退 renderer.log 同一类要说明的事。
|
|
862
|
+
if (row.kind === 'buddyjsonl' && (row.sessionsExtra || []).length) {
|
|
863
|
+
let bx = 0;
|
|
864
|
+
for (const [id, e] of entries) if (e.agent === name && srcs.get(id)?.kind === 'buddyext') bx++;
|
|
865
|
+
row.note += '(含 genie 扩展 ' + bx + ' 条)';
|
|
866
|
+
if (buddyExtScanErr.get(name)) row.note += '(genie 扩展那半最近一次读不动:' + buddyExtScanErr.get(name) + ',下一轮自动重来)';
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
// 「两个 agent 指向同一个根」在这种重复被拦住之前就可能已经落进配置里了。它不会多出一份数据,
|
|
870
|
+
// 只会让两边的条数随扫描先后互相抢(条目 id 不含 agent 名)—— 抢输的那行看着像坏了。
|
|
871
|
+
// 这条必须说破,否则用户只会去怀疑解析器。删掉哪一条都行,所以两个名字都给出来。
|
|
872
|
+
if (row.ownedBy && (row.state === 'ok' || row.state === 'empty')) {
|
|
873
|
+
row.note = (row.note ? row.note + ' ' : '') + '⚠ 这个根同时也由「' + row.ownedBy + '」在扫:条目归属会被后扫的一方抢走(不是两份数据),两条里删掉一条即可';
|
|
874
|
+
row.state = 'warn'; row.stateText = '重复的根';
|
|
875
|
+
}
|
|
876
|
+
return row;
|
|
877
|
+
};
|
|
878
|
+
for (const name of KNOWN_AGENTS) {
|
|
879
|
+
const cands = [];
|
|
880
|
+
// 探路路径排在**最后**:它们只用来「让人看见这台机器上有没有这个变体、里面长什么样」,
|
|
881
|
+
// 不参与自动发现(原因见 PROBE_ONLY_BASES),所以就算被 sniffBase 兜底认成别的 kind,
|
|
882
|
+
// 也不会盖过前面那些真候选 —— describe() 里 hit 取的是第一个 kind 非空的,就是为这个排序。
|
|
883
|
+
// kimi 已注册时,把它**实际在扫的全部根**也列进来(桌面版那份 home 不在任何候选路径里,
|
|
884
|
+
// 是从 daimon-storage.json 推出来的)——「我明明装了桌面版,怎么没数据」靠这行自查。
|
|
885
|
+
// ⚠️ 这些是 **sessions 目录本身**,不是「基目录」,不能再丢给 sniffBase:
|
|
886
|
+
// 它会把 basename==='sessions' 的路径按兜底分支判成 atomcode(本轮就踩到过)。
|
|
887
|
+
// 所以直接按已知的 kind 报,不嗅探。
|
|
888
|
+
const actual = name === 'kimi' ? rootsOf(agentConfs.get(name)) : [];
|
|
889
|
+
for (const p of actual) {
|
|
890
|
+
cands.push({
|
|
891
|
+
path: p, exists: isDir(p), kind: agentConfs.get(name)?.kind || null, sessions: p,
|
|
892
|
+
probe: false, known: true, hint: null,
|
|
893
|
+
reason: isDir(p) ? null : '目录不存在(配置里还记着,下次扫描会摘掉)',
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
const real = candidateBases(name).filter(p => !actual.includes(p)).map(p => ({ path: p, probe: false }));
|
|
897
|
+
const probe = probeBases(name).map(p => ({ path: p, probe: true }));
|
|
898
|
+
for (const { path: base, probe: isProbe } of [...real, ...probe]) {
|
|
899
|
+
const exists = isDir(base);
|
|
900
|
+
let conf = null, err = null;
|
|
901
|
+
if (exists) { try { conf = sniffBase(base); } catch (e) { err = e.message; } }
|
|
902
|
+
let hint = null;
|
|
903
|
+
if (exists && !conf) { try { hint = dirHint(base); } catch {} }
|
|
904
|
+
cands.push({
|
|
905
|
+
path: base, exists, kind: conf ? conf.kind : null, sessions: conf ? conf.sessions : null,
|
|
906
|
+
probe: isProbe, // 探路路径:诊断照列,自动发现不碰(见 PROBE_ONLY_BASES)
|
|
907
|
+
reason: err ? ('嗅探报错:' + err) : (!exists ? '目录不存在' : (conf ? null : '目录存在但未识别到已知日志结构')),
|
|
908
|
+
hint,
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
rows.push(describe(name, agentConfs.get(name), cands));
|
|
912
|
+
}
|
|
913
|
+
// 名单外的手工项(如 codeart)也列出来,否则「我加的 agent 跑哪去了」没法自查
|
|
914
|
+
for (const [name, conf] of agentConfs) {
|
|
915
|
+
if (KNOWN_AGENTS.includes(name)) continue;
|
|
916
|
+
rows.push(describe(name, conf, [{ path: conf.sessions || conf.traces || '', exists: confAvailable(conf), kind: conf.kind, sessions: conf.sessions || null, reason: null, hint: null }]));
|
|
917
|
+
}
|
|
918
|
+
rows.sort((a, b) => (b.entries - a.entries) || a.name.localeCompare(b.name));
|
|
919
|
+
// version/build 一起报出去:「我装的是新版、为什么没有新功能」这类问题,第一步就得能看出**正在伺候你的服务**是哪一版
|
|
920
|
+
// platform 一起报出去:这份诊断经常是**别的机器**(mac / Linux / 鸿蒙 PC)上截的图,
|
|
921
|
+
// 而候选路径是按 process.platform 分支展开的 —— 不写平台,收到截图的人没法判断
|
|
922
|
+
// 「这条路径不存在」是在哪套约定下不存在的,也没法确认鸿蒙 PC 的 Node 到底报什么值。
|
|
923
|
+
return { home: HOME, platform: process.platform, port: PORT, dataDir: DATA_DIR, version: VERSION, build: BUILD, known: KNOWN_AGENTS, agents: rows };
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// ---------------- 磁盘占用(/api/usage,R30) ----------------
|
|
927
|
+
// 回答的是「哪个 agent 的日志 / 索引 / 归档最占地方,清理时该动谁」——这些数散在三个地方
|
|
928
|
+
// (源日志目录、index/ 分片、archive/ 清单),没有任何一处能一次看全。
|
|
929
|
+
//
|
|
930
|
+
// 遍历必须**带预算**:整个服务只有一个线程,扫描与 SSE 推送都在上面,而日志目录动辄几万个文件
|
|
931
|
+
// (本机 .claude/projects 一个项目就 86 个会话文件)。所以一次请求最多走 USAGE_MAX_FILES 个目录项、
|
|
932
|
+
// 最多花 USAGE_MAX_MS 毫秒,超了就返回已统计的部分并标 partial(页面上明说「统计到一半」,
|
|
933
|
+
// 不假装是完整数字 —— 报小了比报不出来更坏)。
|
|
934
|
+
const USAGE_MAX_FILES = Number(process.env.AGENT_LOG_USAGE_MAX_FILES || 60000);
|
|
935
|
+
const USAGE_MAX_MS = Number(process.env.AGENT_LOG_USAGE_MAX_MS || 4000);
|
|
936
|
+
const USAGE_TTL_MS = 60000; // 结果缓存:磁盘占用不是要实时的数,反复点开别把机器走一遍
|
|
937
|
+
let usageCache = { at: 0, data: null };
|
|
938
|
+
|
|
939
|
+
// 一个文件有多大。Dirent 自己**没有** statSync(Node 到 v23 才挂上懒取属性),必须按路径 stat;
|
|
940
|
+
// 早先在这里写 e.statSync() 会静默抛错,被 catch 吞掉的结果是整棵目录树报 0 字节(test/usage-test.mjs 拦这条)。
|
|
941
|
+
function sizeOfEnt(base, name) {
|
|
942
|
+
try { return fs.statSync(path.join(base, name)).size; } catch { return -1; }
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// 一次统计**一批根**,返回与入参同序的 [{bytes,files,dirs,links,partial}]。
|
|
946
|
+
// 三条要紧的做法:
|
|
947
|
+
// · **按目录轮转**(队列广度优先,不是走完一个根再走下一个):预算不够时每个根都分到一点,
|
|
948
|
+
// 表上不会出现「只有第一行有数、其余全 0」—— 那种截断会把「谁最占地方」的排名彻底带偏。
|
|
949
|
+
// · **不跟随符号链接 / junction**:把日志根链到别处是常见做法(测试环境就这么造),跟着走要么
|
|
950
|
+
// 重复计数、要么直接转圈。
|
|
951
|
+
// · 库文件根(zcode / traedb / opencode / kilo / hermes / devin 的 sessions 是 .db 路径)连 WAL / SHM 一起算 ——
|
|
952
|
+
// 那两个常常比主文件还大,只算主文件会把占用报小一半。
|
|
953
|
+
function rootsUsage(roots, budget) {
|
|
954
|
+
const outs = roots.map(() => ({ bytes: 0, files: 0, dirs: 0, links: 0, partial: false }));
|
|
955
|
+
const queue = [];
|
|
956
|
+
roots.forEach((r, i) => {
|
|
957
|
+
if (isFile(r)) {
|
|
958
|
+
for (const s of [r, r + '-wal', r + '-shm']) {
|
|
959
|
+
try { const st = fs.statSync(s); if (st.isFile()) { outs[i].bytes += st.size; outs[i].files++; } } catch {}
|
|
960
|
+
}
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
queue.push([r, i]);
|
|
964
|
+
});
|
|
965
|
+
const out = () => { for (const [, i] of queue) outs[i].partial = true; }; // 还在排队的都是没走完的
|
|
966
|
+
while (queue.length) {
|
|
967
|
+
const [dir, i] = queue.shift();
|
|
968
|
+
if (budget.visited >= USAGE_MAX_FILES || Date.now() > budget.until) { outs[i].partial = true; out(); break; }
|
|
969
|
+
let ents;
|
|
970
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
971
|
+
for (const e of ents) {
|
|
972
|
+
// 先判后加:写成 `budget.visited++ >= MAX` 会让**被拒的那一次**也计进 visited,
|
|
973
|
+
// 报出来的「走了多少个目录项」就比预算还大一个(页面把它当账目展示,账必须对得上)
|
|
974
|
+
if (budget.visited >= USAGE_MAX_FILES || Date.now() > budget.until) { outs[i].partial = true; out(); queue.length = 0; break; }
|
|
975
|
+
budget.visited++;
|
|
976
|
+
if (e.isSymbolicLink()) { outs[i].links++; continue; }
|
|
977
|
+
const base = e.path || dir;
|
|
978
|
+
if (e.isDirectory()) { outs[i].dirs++; queue.push([path.join(base, e.name), i]); continue; }
|
|
979
|
+
if (!e.isFile()) continue;
|
|
980
|
+
const sz = sizeOfEnt(base, e.name);
|
|
981
|
+
if (sz < 0) continue;
|
|
982
|
+
outs[i].bytes += sz; outs[i].files++;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
return outs;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function usageReport(force) {
|
|
989
|
+
if (!force && usageCache.data && Date.now() - usageCache.at < USAGE_TTL_MS)
|
|
990
|
+
return { ...usageCache.data, cached: true };
|
|
991
|
+
const budget = { visited: 0, until: Date.now() + USAGE_MAX_MS };
|
|
992
|
+
const cnt = counts().agents; // 一次算好:counts() 是遍历全部条目的,别在循环里调
|
|
993
|
+
const arch = archiveIndex();
|
|
994
|
+
const archBy = {};
|
|
995
|
+
for (const a of arch.agents) archBy[a.agent] = a;
|
|
996
|
+
|
|
997
|
+
const rows = [];
|
|
998
|
+
// 同一份目录被两个 agent 指着是可能的(手工加了 codex、又留着 copilot 的旧配置指向同一处)。
|
|
999
|
+
// 行内照实各报各的(这一行回答的是「这个 agent 的根有多大」),但**合计去重** ——
|
|
1000
|
+
// 不然 totals 比磁盘上真实占的还大,而这一页的存在理由恰恰是「要清多少地方」。
|
|
1001
|
+
const normRoot = p => { try { return process.platform === 'win32' ? path.resolve(p).toLowerCase() : path.resolve(p); } catch { return String(p); } };
|
|
1002
|
+
const rootOwner = new Map();
|
|
1003
|
+
let logBytesDedup = 0;
|
|
1004
|
+
for (const [agent, conf] of agentConfs) {
|
|
1005
|
+
const roots = [...rootsOf(conf), conf.traces].filter(Boolean);
|
|
1006
|
+
let bytes = 0, files = 0, partial = false, sharedBy = '';
|
|
1007
|
+
const us = rootsUsage(roots, budget);
|
|
1008
|
+
roots.forEach((r, ri) => {
|
|
1009
|
+
const u = us[ri], key = normRoot(r);
|
|
1010
|
+
bytes += u.bytes; files += u.files; partial = partial || u.partial;
|
|
1011
|
+
if (rootOwner.has(key)) sharedBy = sharedBy || rootOwner.get(key);
|
|
1012
|
+
else { rootOwner.set(key, agent); logBytesDedup += u.bytes; }
|
|
1013
|
+
});
|
|
1014
|
+
const a = archBy[agent] || { days: 0, count: 0, bytes: 0 };
|
|
1015
|
+
rows.push({
|
|
1016
|
+
agent, kind: conf.kind || 'atomcode', enabled: conf.enabled !== false, roots,
|
|
1017
|
+
logBytes: bytes, logFiles: files, partial, sharedBy,
|
|
1018
|
+
indexKind: OFF_KINDS.has(conf.kind) ? conf.kind : null, // 索引按 kind 分片,见下面 indexShards
|
|
1019
|
+
archiveBytes: a.bytes, archiveDays: a.days, archiveCount: a.unknown ? -1 : a.count,
|
|
1020
|
+
entries: cnt[agent] || 0,
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
rows.forEach(r => { r.totalBytes = r.logBytes + r.archiveBytes; });
|
|
1024
|
+
rows.sort((a, b) => b.totalBytes - a.totalBytes || a.agent.localeCompare(b.agent));
|
|
1025
|
+
|
|
1026
|
+
// 索引片单独一列:一片可能同时供好几个 agent(traedb 的 agent 用的是 tracecode.json),
|
|
1027
|
+
// 塞进 agent 行里相加就是重复计数。
|
|
1028
|
+
const indexShards = [];
|
|
1029
|
+
let indexBytes = 0;
|
|
1030
|
+
try {
|
|
1031
|
+
for (const fn of fs.readdirSync(INDEX_DIR).filter(n => n.endsWith('.json')).sort()) {
|
|
1032
|
+
let st = null; try { st = fs.statSync(path.join(INDEX_DIR, fn)); } catch {}
|
|
1033
|
+
const kind = fn.slice(0, -5);
|
|
1034
|
+
indexShards.push({ kind, bytes: st ? st.size : 0, rev: PARSER_REV[kind] == null ? null : PARSER_REV[kind], persisted: PARSER_REV[kind] != null });
|
|
1035
|
+
indexBytes += st ? st.size : 0;
|
|
1036
|
+
}
|
|
1037
|
+
} catch {}
|
|
1038
|
+
|
|
1039
|
+
// 数据目录构成:清理残留时要能一句句报出来(widget-runtime 那份 Electron 运行时就能占 ~100MB)。
|
|
1040
|
+
// 与上面同样一次 rootsUsage:目录之间轮转,预算截断时每项都分到一点,而不是第一项吃完其余全 0。
|
|
1041
|
+
const dataItems = [];
|
|
1042
|
+
let dataBytes = 0;
|
|
1043
|
+
try {
|
|
1044
|
+
const items = [];
|
|
1045
|
+
for (const e of fs.readdirSync(DATA_DIR, { withFileTypes: true })) {
|
|
1046
|
+
if (e.isSymbolicLink()) { items.push({ name: e.name, path: path.join(DATA_DIR, e.name), skip: true }); continue; }
|
|
1047
|
+
items.push({ name: e.name + (e.isDirectory() ? '/' : ''), path: path.join(DATA_DIR, e.name), dir: e.isDirectory() });
|
|
1048
|
+
}
|
|
1049
|
+
const us2 = rootsUsage(items.map(i => i.path), budget);
|
|
1050
|
+
items.forEach((it, i) => {
|
|
1051
|
+
const u = us2[i];
|
|
1052
|
+
// 顶层的链接项不跟随(也不 stat),但要在构成里露出来 —— 看不见就会以为「这里怎么少了东西」
|
|
1053
|
+
if (it.skip) { dataItems.push({ name: it.name, dir: false, bytes: 0, files: 0, partial: false, link: true }); return; }
|
|
1054
|
+
dataItems.push({ name: it.name, dir: it.dir, bytes: u.bytes, files: u.files, partial: u.partial });
|
|
1055
|
+
dataBytes += u.bytes;
|
|
1056
|
+
});
|
|
1057
|
+
dataItems.sort((a, b) => b.bytes - a.bytes || a.name.localeCompare(b.name));
|
|
1058
|
+
} catch {}
|
|
1059
|
+
|
|
1060
|
+
const data = {
|
|
1061
|
+
ok: true, at: Date.now(), dataDir: DATA_DIR, port: PORT,
|
|
1062
|
+
budget: { maxFiles: USAGE_MAX_FILES, maxMs: USAGE_MAX_MS, visited: budget.visited },
|
|
1063
|
+
partial: rows.some(r => r.partial) || dataItems.some(i => i.partial), // 以「哪一段真被截断」为准,不看 visited
|
|
1064
|
+
agents: rows,
|
|
1065
|
+
indexShards,
|
|
1066
|
+
archive: { root: arch.root, agents: arch.totals.agents, days: arch.totals.days, count: arch.totals.count, bytes: arch.totals.bytes },
|
|
1067
|
+
dataItems,
|
|
1068
|
+
totals: {
|
|
1069
|
+
// logBytes 去重、logBytesRows 是各行相加:两个数不等就说明确有 agent 共用目录(页脚说明白)
|
|
1070
|
+
logBytes: logBytesDedup, logBytesRows: rows.reduce((s, r) => s + r.logBytes, 0),
|
|
1071
|
+
archiveBytes: arch.totals.bytes, indexBytes, dataBytes,
|
|
1072
|
+
agents: rows.length, logFiles: rows.reduce((s, r) => s + r.logFiles, 0),
|
|
1073
|
+
},
|
|
1074
|
+
};
|
|
1075
|
+
usageCache = { at: data.at, data };
|
|
1076
|
+
return { ...data, cached: false };
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
// 自动发现:KNOWN_AGENTS 名单逐个探测;已注册的 source:auto 条目目录消失/被他人占用则自动摘除(不报错)
|
|
1081
|
+
// 同一份日志目录只允许一个 agent 扫:用户手工加的 codeart 和自动发现的 codearts 指向同一个
|
|
1082
|
+
// codearts-agent 目录,两个都注册会重复扫/重复灌条目,自动发现这边让位(手工配置优先)
|
|
1083
|
+
// (重组脚本误删后对照备份补回)
|
|
1084
|
+
function dirTaken(conf, self) {
|
|
1085
|
+
const norm = p => (p ? (process.platform === 'win32' ? path.resolve(p).toLowerCase() : path.resolve(p)) : '');
|
|
1086
|
+
for (const [n, c] of agentConfs) {
|
|
1087
|
+
if (n === self) continue;
|
|
1088
|
+
if (conf.sessions && rootsOf(c).some(p => norm(p) === norm(conf.sessions))) return n;
|
|
1089
|
+
if (conf.traces && norm(c.traces) === norm(conf.traces)) return n;
|
|
1090
|
+
}
|
|
1091
|
+
return null;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// 配置里的根是否真的存在(missing 判定);重组脚本误删后对照备份补回
|
|
1095
|
+
function confAvailable(c) {
|
|
1096
|
+
// zcode / traedb / opencode / kilo / hermes / devin 的根是**数据库文件**而不是目录(sessions 存的是 db 路径)
|
|
1097
|
+
if (c.kind === 'zcode' || c.kind === 'traedb' || c.kind === 'opencode' || c.kind === 'kilo' || c.kind === 'hermes' || c.kind === 'devindb') return !!(c.sessions && isFile(c.sessions));
|
|
1098
|
+
return rootsOf(c).some(isDir) || (c.traces && isDir(c.traces));
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// 这几个 agent 的 conf.sessions **本身就是根目录**,不是「根目录下的某个子目录」:
|
|
1102
|
+
// copilot = <base>/session-state
|
|
1103
|
+
// minimax = <base>/v2/sessions
|
|
1104
|
+
// mimocode = <base>(mimocode 应用根,库在它下面)
|
|
1105
|
+
// openclaw = <base>(OpenClaw stateDir,转录在它下面的 agents/<id>/sessions/)
|
|
1106
|
+
// comate = <base>(百度 Comate 的 store 根,chat_session_* 平铺在它下面)
|
|
1107
|
+
// ⚠️ 漏了 comate 的实测病征:**每轮扫描 0 条 ↔ 3 条来回闪**。它的 sessions 是
|
|
1108
|
+
// ~/.comate-engine/store,取 dirname 爬到 ~/.comate-engine —— 那底下 browser/ log/ machine/ 里
|
|
1109
|
+
// 有 *.jsonl,被 claude 的兜底判据 hasClaudeJsonl(base) 接走 → kind 被改写成 claude 并 purgeAgent
|
|
1110
|
+
// → 下一轮 candidateBases 又把它找回来注册成 comate → 无限翻转(条目一会儿有一会儿没)。
|
|
1111
|
+
// 重新嗅探时若对它们取 path.dirname 会爬到**上层**,从而认成隔壁 agent(mimocode 的真实事故)
|
|
1112
|
+
// 或退化成 atomcode。按 name 判断,不按 kind —— kind 正是可能被改坏的那个字段。
|
|
1113
|
+
const ROOT_SESSIONS_AGENTS = new Set(['copilot', 'minimax', 'mimocode', 'openclaw', 'comate']);
|
|
1114
|
+
|
|
1115
|
+
// 只用**本 agent 自己的**候选路径重新推导配置(candidateBases + sniffBase)。
|
|
1116
|
+
// 用途:配置里的 sessions 被别的 agent 抢走 / 被改错时,回到本源把它找回来。
|
|
1117
|
+
// 为什么不复用下面那段「爬一层重嗅探」:那段是从**已被改坏的 sessions** 出发的,
|
|
1118
|
+
// 已被改坏时它只会再次返回对方的 kind(mimocode 顶着 opencode.db 时正是如此),永远自愈不了。
|
|
1119
|
+
//
|
|
1120
|
+
// 挑选规则(两条都不能省):
|
|
1121
|
+
// ① 跳过被**别的 agent** 占着的候选(dirTaken)—— 否则 ~/.local/share 这种上层路径
|
|
1122
|
+
// 会认成 opencode,等于把抢来的东西又认一遍;
|
|
1123
|
+
// ② kind 与 name **同名**的优先(mimocode / opencode 这类一一对应的),
|
|
1124
|
+
// 没有同名命中时(qoder→claude、trae→traedb、traework→tracework 这类多对一)退回第一份未被占的。
|
|
1125
|
+
function ownConfFor(name) {
|
|
1126
|
+
const cands = [];
|
|
1127
|
+
for (const base of candidateBases(name)) {
|
|
1128
|
+
const conf = sniffBase(base);
|
|
1129
|
+
if (!conf) continue;
|
|
1130
|
+
if (dirTaken(conf, name)) continue; // ①
|
|
1131
|
+
cands.push(conf);
|
|
1132
|
+
if (conf.kind === name) return conf; // ②
|
|
1133
|
+
}
|
|
1134
|
+
return cands[0] || null;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function discoverAgents() {
|
|
1138
|
+
for (const name of KNOWN_AGENTS) {
|
|
1139
|
+
const cur = agentConfs.get(name);
|
|
1140
|
+
if (cur) {
|
|
1141
|
+
const owner = cur.source === 'auto' ? dirTaken(cur, name) : null;
|
|
1142
|
+
if (cur.source === 'auto' && (!confAvailable(cur) || owner)) {
|
|
1143
|
+
purgeAgent(name);
|
|
1144
|
+
agentConfs.delete(name);
|
|
1145
|
+
saveConfig();
|
|
1146
|
+
agentNotices.push({ removed: name });
|
|
1147
|
+
log('[discover] removed (' + (owner ? 'dir owned by ' + owner : 'dir gone') + '):', name);
|
|
1148
|
+
continue;
|
|
1149
|
+
}
|
|
1150
|
+
// source:auto 的 kind 可能因解析器升级而过时(如 codex 早期被当成 atomcode),重新嗅探自愈
|
|
1151
|
+
if (cur.source === 'auto' && cur.sessions) {
|
|
1152
|
+
// ⚠️ 先处理「被抢」这一种:cur 指向的那份 sessions 同时被**另一个 agent** 声称。
|
|
1153
|
+
// 本机真实事故:mimocode 的 kind 被改写成 opencode、sessions 被改成 opencode.db,
|
|
1154
|
+
// 于是两个 agent 的 sessions 完全相同 —— 而 dirTaken 是**对称**判据,两边互指对方是 owner,
|
|
1155
|
+
// 谁先被判谁先被删(opencode 排在 KNOWN_AGENTS 前面,于是被删的是 opencode),
|
|
1156
|
+
// 剩下 mimocode 顶着 opencode 的 kind 跑 → 恒 0 条,且**永远不会自愈**。
|
|
1157
|
+
// 所以这里不能只做「爬一层重嗅探」:必须回到**本 agent 自己的候选路径**上重新推导。
|
|
1158
|
+
const poachedBy = dirTaken(cur, name);
|
|
1159
|
+
if (poachedBy) {
|
|
1160
|
+
const own = ownConfFor(name);
|
|
1161
|
+
if (own) {
|
|
1162
|
+
const old = cur.kind + ' @ ' + cur.sessions;
|
|
1163
|
+
cur.kind = own.kind; cur.sessions = own.sessions; cur.traces = own.traces;
|
|
1164
|
+
purgeAgent(name);
|
|
1165
|
+
saveConfig();
|
|
1166
|
+
log('[discover] reclaimed:', name, old, '->', own.kind, own.sessions, '(曾被 ' + poachedBy + ' 占用)');
|
|
1167
|
+
} else {
|
|
1168
|
+
// 自己的候选路径一个都认不出来,那这份 sessions 确实该归对方 —— 原样让位
|
|
1169
|
+
purgeAgent(name);
|
|
1170
|
+
agentConfs.delete(name);
|
|
1171
|
+
saveConfig();
|
|
1172
|
+
agentNotices.push({ removed: name });
|
|
1173
|
+
log('[discover] removed (dir owned by ' + poachedBy + '):', name);
|
|
1174
|
+
}
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
// copilot / minimax / mimocode / openclaw 的 sessions 本身就是「根目录」(copilot=session-state、
|
|
1178
|
+
// minimax=v2/sessions、mimocode=应用根、openclaw=stateDir),dirname 会爬到更上层导致重新嗅探认错:
|
|
1179
|
+
// ⚠️ mimocode 踩过一次真实事故 —— 它的应用根是 ~/.local/share/mimocode,dirname 爬到
|
|
1180
|
+
// ~/.local/share,而 opencode 的相对候选里正好有 `share/opencode/opencode.db`,
|
|
1181
|
+
// 于是 kind 被改写成 opencode(上半段 reclaimed 分支负责把它救回来)。
|
|
1182
|
+
// ⚠️ 这里按 **name** 判断而不是按 cur.kind:cur.kind 正是可能被改坏的那个字段
|
|
1183
|
+
// (mimocode 顶着 opencode 时按 kind 判断会走 dirname 分支,等于又嗅一遍 opencode)。
|
|
1184
|
+
const base = ROOT_SESSIONS_AGENTS.has(name) ? cur.sessions : path.dirname(cur.sessions);
|
|
1185
|
+
const re = sniffBase(base);
|
|
1186
|
+
if (re && !dirTaken(re, name) && (re.kind !== cur.kind || re.sessions !== cur.sessions)) {
|
|
1187
|
+
const old = cur.kind;
|
|
1188
|
+
cur.kind = re.kind; cur.sessions = re.sessions; cur.traces = re.traces;
|
|
1189
|
+
purgeAgent(name);
|
|
1190
|
+
saveConfig();
|
|
1191
|
+
log('[discover] kind updated:', name, old + ' -> ' + re.kind);
|
|
1192
|
+
} else {
|
|
1193
|
+
// 上面那条从**当前 sessions** 出发,sessions 本身被改坏时它只会再认一次对方。
|
|
1194
|
+
// 兜底:回到本 agent 自己的候选路径重新推导(跳过被别的 agent 占着的),
|
|
1195
|
+
// 认出来跟现状不同就采纳 —— 这是「mimocode 顶着 opencode.db」能自愈的关键一步。
|
|
1196
|
+
const own = ownConfFor(name);
|
|
1197
|
+
if (own && (own.kind !== cur.kind || own.sessions !== cur.sessions)) {
|
|
1198
|
+
const old = cur.kind + ' @ ' + cur.sessions;
|
|
1199
|
+
cur.kind = own.kind; cur.sessions = own.sessions; cur.traces = own.traces;
|
|
1200
|
+
purgeAgent(name);
|
|
1201
|
+
saveConfig();
|
|
1202
|
+
log('[discover] kind updated:', name, old, '->', own.kind, own.sessions);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
for (const base of candidateBases(name)) {
|
|
1209
|
+
const conf = sniffBase(base);
|
|
1210
|
+
if (!conf) continue;
|
|
1211
|
+
const owner = dirTaken(conf, name);
|
|
1212
|
+
if (owner) { log('[discover] skip', name, '(dir already served by', owner + ')'); break; }
|
|
1213
|
+
agentConfs.set(name, { ...conf, builtin: false, enabled: true, source: 'auto' });
|
|
1214
|
+
saveConfig();
|
|
1215
|
+
agentNotices.push({ added: name });
|
|
1216
|
+
log('[discover] found:', name, '->', conf.kind, conf.sessions || conf.traces);
|
|
1217
|
+
break;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
// kimi 的桌面版根要单独处理两件事 —— 它**不在 candidateBases 里**(得先读桌面版的
|
|
1221
|
+
// daimon-storage.json 才知道数据根在哪),所以上面那个候选循环碰不到它:
|
|
1222
|
+
// (a) 机器上**只有桌面版**、没装 kimi-code CLI 时必须靠这里注册
|
|
1223
|
+
// —— 否则「装了桌面版却一条都看不到」,正是需求书要避免的那种静默;
|
|
1224
|
+
// (b) 两者都有时,把桌面版那份挂成额外根。
|
|
1225
|
+
// 两者都每轮**重算**:桌面版卸载了、或用户把数据根挪到别的盘,死路径会自己掉出去。
|
|
1226
|
+
const kimiRoots = () => kimiDesktopSessionsRoots().filter(r => !dirTaken({ sessions: r, traces: null }, 'kimi'));
|
|
1227
|
+
const kimi = agentConfs.get('kimi');
|
|
1228
|
+
if (!kimi) {
|
|
1229
|
+
const roots = kimiRoots();
|
|
1230
|
+
if (roots.length) {
|
|
1231
|
+
agentConfs.set('kimi', {
|
|
1232
|
+
kind: 'kimi', sessions: roots[0], sessionsExtra: roots.slice(1), traces: null,
|
|
1233
|
+
builtin: false, enabled: true, source: 'auto',
|
|
1234
|
+
});
|
|
1235
|
+
saveConfig();
|
|
1236
|
+
agentNotices.push({ added: 'kimi' });
|
|
1237
|
+
log('[discover] found (desktop): kimi ->', roots.join(' | '));
|
|
1238
|
+
}
|
|
1239
|
+
} else {
|
|
1240
|
+
const extra = kimiRoots().filter(r => r !== kimi.sessions);
|
|
1241
|
+
if ((kimi.sessionsExtra || []).join('\n') !== extra.join('\n')) {
|
|
1242
|
+
// 根集合变了就整份重扫:否则旧根的条目会一直留着(evictIfNeeded 只管 LRU 上限,不清死路径)
|
|
1243
|
+
purgeAgent('kimi');
|
|
1244
|
+
kimi.sessionsExtra = extra;
|
|
1245
|
+
saveConfig();
|
|
1246
|
+
log('[discover] kimi roots:', [kimi.sessions, ...extra].join(' | '));
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
// codebuddy 的**第二份落盘**:genie 扩展(VSCode 插件 / JetBrains 插件 / CodeBuddy CN 应用内部共用同一个)
|
|
1250
|
+
// 把会话写到 %LOCALAPPDATA%\CodeBuddyExtension\Data,既不在 ~/.codebuddy(那是 CLI),也不在
|
|
1251
|
+
// %APPDATA%\CodeBuddy CN(那只是 IDE 壳的 Electron 缓存 + state.vscdb,**没有会话正文**)。
|
|
1252
|
+
// 上一版把它注册成了独立一行 codebuddy-ext —— 用户反馈那在他们心智里就是同一个 CodeBuddy,
|
|
1253
|
+
// 拆两行会把同一个项目的历史劈成两半(按 agent 筛就漏一边),所以合到一个 agent 名下:
|
|
1254
|
+
// 主根 buddyjsonl(CLI) + 额外根 buddyext(扩展),两个 kind 各自解析、条目同一个 agent。
|
|
1255
|
+
// ⚠️ 与 trae 那种「两个源覆盖同一批会话」不同,这里两份是**互不重叠的会话**(CLI 用 uuid v7、
|
|
1256
|
+
// 扩展用 md5 串,会话 id 形状都不一样),所以不需要 purgeTraeOtherSide 那样的去重。
|
|
1257
|
+
// 每轮重算:扩展卸载了 / 数据根被挪走,死路径自己掉出去。
|
|
1258
|
+
const bxRoots = buddyExtDataRoots().filter(r => !dirTaken({ sessions: r, traces: null }, 'codebuddy'));
|
|
1259
|
+
const cb = agentConfs.get('codebuddy');
|
|
1260
|
+
if (cb && (cb.sessionsExtra || []).join('\n') !== bxRoots.join('\n')) {
|
|
1261
|
+
purgeAgent('codebuddy');
|
|
1262
|
+
cb.sessionsExtra = bxRoots;
|
|
1263
|
+
saveConfig();
|
|
1264
|
+
log('[discover] codebuddy 扩展根:', bxRoots.join(' | ') || '(这台机器上没有)');
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// 给「没认出来」的路径配一句**原因**。
|
|
1269
|
+
//
|
|
1270
|
+
// 为什么必须配:手动添加失败时,原来只回一串路径,而**光看路径什么都判断不出来** ——
|
|
1271
|
+
// 是目录压根不存在?还是目录在、只是里面不是我们认识的结构?这两种情况的下一步动作完全不同
|
|
1272
|
+
// (前者让用户确认路径,后者要去看目录里到底是什么),但提示长得一模一样,只能来回猜。
|
|
1273
|
+
// 措辞与 diagnose() 里那份保持一致(同一套判断,别两处各写一句不一样的话)。
|
|
1274
|
+
function sniffWhyNot(base, conf) {
|
|
1275
|
+
if (conf) return '识别为 ' + conf.kind; // 正常走不到(认出来就不会问原因)
|
|
1276
|
+
if (!isDir(base)) return '目录不存在';
|
|
1277
|
+
try { const h = dirHint(base); return '目录在,但没认出已知结构' + (h ? ':' + h : ''); }
|
|
1278
|
+
catch { return '目录在,但没认出已知结构'; }
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// 同一个根被两个 agent 扫,**不是「多一份数据」而是「抢一份数据」**:条目 id 只由「文件路径 + 轮」
|
|
1282
|
+
// 决定(不含 agent 名),所以后加入的那条扫描会把已有条目的 agent 标签整个改写成自己的名字,
|
|
1283
|
+
// 原先那行反而变成 0 条 —— 谁后扫谁赢,页面上一会儿一边有一会儿一边没。
|
|
1284
|
+
// 自动发现那条路早就有 dirTaken 拦着;手动添加这条路以前**没拦**,于是「CodeBuddy 看不到吗?
|
|
1285
|
+
// 那我把 %LOCALAPPDATA%\CodeBuddyExtension 手填一遍」这种完全正常的自救动作会静默把 codebuddy
|
|
1286
|
+
// (它的额外根正是这里)的行掏空。并源之后这种撞法变常见,所以在这里挡住。
|
|
1287
|
+
// ⚠️ 这里**不用** dirTaken 的「根相等」判据,而是管到**包含关系**:手填某一条会话的目录时
|
|
1288
|
+
// 根字符串和自动项不相等(比如 cline 的 <sessions>/<会话 id>),但那目录底下扫的还是同一批文件、
|
|
1289
|
+
// 算出来的还是同一批 id —— 相等判据放它过去,就照样偷。只给 addAgent 用这一份强判据:
|
|
1290
|
+
// 自动发现那边不动(kimi / codebuddy 自己就有多根,改成包含判据会把合法的并列根当成冲突剔掉)。
|
|
1291
|
+
function rootOverlap(name, conf) {
|
|
1292
|
+
const norm = p => (p ? (process.platform === 'win32' ? path.resolve(p).toLowerCase() : path.resolve(p)) : '');
|
|
1293
|
+
const a = norm(conf.sessions);
|
|
1294
|
+
if (!a) return null;
|
|
1295
|
+
const inside = (x, y) => x === y || x.startsWith(y + path.sep);
|
|
1296
|
+
for (const [n, c] of agentConfs) {
|
|
1297
|
+
if (n === name) continue;
|
|
1298
|
+
for (const rp of rootsOf(c)) {
|
|
1299
|
+
const b = norm(rp);
|
|
1300
|
+
if (!b) continue;
|
|
1301
|
+
if (inside(a, b) || inside(b, a)) return n;
|
|
1302
|
+
}
|
|
1303
|
+
const t = norm(c.traces), ct = norm(conf.traces);
|
|
1304
|
+
if (t && ct && (inside(ct, t) || inside(t, ct))) return n;
|
|
1305
|
+
}
|
|
1306
|
+
return null;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function dupRootError(name, conf) {
|
|
1310
|
+
const owner = rootOverlap(name, conf);
|
|
1311
|
+
if (!owner) return null;
|
|
1312
|
+
return {
|
|
1313
|
+
ok: false, ownedBy: owner, kind: conf.kind, sessions: conf.sessions,
|
|
1314
|
+
error: '这个目录(或它底下的某一层)已经由 agent「' + owner + '」在扫了,不能再加一遍 —— 条目是按「文件路径 + 轮」寻址的、' +
|
|
1315
|
+
'不认 agent 名,再加一条只会把那批条目的归属抢到新车名底下,「' + owner + '」那一行反而变空。' +
|
|
1316
|
+
'要看这一份数据请直接找「' + owner + '」;想分开看就在页面里按项目/会话筛,' +
|
|
1317
|
+
'或者指一个**真正不同**的目录(比如另一台机器/另一个盘上的另一批日志)。',
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function addAgent(rawName, manualPath) {
|
|
1322
|
+
const name = String(rawName || '').trim().toLowerCase();
|
|
1323
|
+
if (!/^[\w][\w-]{0,31}$/.test(name)) return { ok: false, error: '名称需为 1-32 位字母/数字/下划线/中划线' };
|
|
1324
|
+
if (agentConfs.has(name)) return { ok: false, error: '已存在同名 agent' };
|
|
1325
|
+
if (manualPath) {
|
|
1326
|
+
const p = String(manualPath).trim();
|
|
1327
|
+
if (p.startsWith('\\\\') || p.startsWith('//')) return { ok: false, error: '不支持网络盘 / UNC 路径' };
|
|
1328
|
+
const abs = path.resolve(p);
|
|
1329
|
+
const conf = sniffBase(abs);
|
|
1330
|
+
if (!conf) {
|
|
1331
|
+
return {
|
|
1332
|
+
ok: false,
|
|
1333
|
+
error: '指定路径下未识别到日志结构 —— 原因见下面这行(该目录可以是 agent 根目录,也可以是 sessions / projects 目录本身)',
|
|
1334
|
+
tried: [p + ' —— ' + sniffWhyNot(abs, null)],
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
const dup = dupRootError(name, conf);
|
|
1338
|
+
if (dup) return dup;
|
|
1339
|
+
agentConfs.set(name, { ...conf, builtin: false, enabled: true, source: 'manual' });
|
|
1340
|
+
return { ok: true, name, ...conf, source: abs };
|
|
1341
|
+
}
|
|
1342
|
+
const tried = [];
|
|
1343
|
+
for (const base of candidateBases(name)) {
|
|
1344
|
+
const conf = sniffBase(base);
|
|
1345
|
+
if (conf) {
|
|
1346
|
+
const dup = dupRootError(name, conf);
|
|
1347
|
+
if (dup) return dup;
|
|
1348
|
+
agentConfs.set(name, { ...conf, builtin: false, enabled: true, source: 'manual' });
|
|
1349
|
+
return { ok: true, name, ...conf, source: base };
|
|
1350
|
+
}
|
|
1351
|
+
tried.push(base + ' —— ' + sniffWhyNot(base, null));
|
|
1352
|
+
}
|
|
1353
|
+
return { ok: false, error: '未找到该 agent 的日志目录。下面是逐个试过的路径与各自的原因' + (manualPath ? '' : '(也可以直接在「日志路径」里手动指一个)'), tried };
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// 清除某 agent 的全部内存状态(files/entries/srcs/pidCwd),并排队 SSE remove 事件
|
|
1357
|
+
function purgeAgent(name) {
|
|
1358
|
+
for (const [p, f] of files) if (f.agent === name) files.delete(p);
|
|
1359
|
+
for (const [id, e] of entries) if (e.agent === name) { entries.delete(id); srcs.delete(id); removedIds.push(id); }
|
|
1360
|
+
for (const k of [...pidCwd.keys()]) if (k.startsWith(name + '|')) pidCwd.delete(k);
|
|
1361
|
+
sorted.cache = null;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
function removeAgent(name) {
|
|
1365
|
+
const conf = agentConfs.get(name);
|
|
1366
|
+
if (!conf) return { ok: false, error: '不存在该 agent' };
|
|
1367
|
+
// 预置项同样可删:v2 配置是用户意图的完整快照,删掉后 loadConfig 不会再把它塞回来
|
|
1368
|
+
purgeAgent(name);
|
|
1369
|
+
agentConfs.delete(name);
|
|
1370
|
+
saveConfig();
|
|
1371
|
+
return { ok: true };
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function toggleAgent(name, enabled) {
|
|
1375
|
+
const conf = agentConfs.get(name);
|
|
1376
|
+
if (!conf) return { ok: false, error: '不存在该 agent' };
|
|
1377
|
+
enabled = !!enabled;
|
|
1378
|
+
if ((conf.enabled !== false) === enabled) return { ok: true, enabled };
|
|
1379
|
+
conf.enabled = enabled;
|
|
1380
|
+
if (!enabled) purgeAgent(name);
|
|
1381
|
+
saveConfig();
|
|
1382
|
+
return { ok: true, enabled };
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// ---------------- index 持久化(按 kind 分片,仅 OFF_KINDS) ----------------
|
|
1386
|
+
function loadIndex() {
|
|
1387
|
+
let n = 0;
|
|
1388
|
+
const restore = (arr, rev) => {
|
|
1389
|
+
for (const [p, f] of arr || []) {
|
|
1390
|
+
if (!OFF_KINDS.has(f.kind)) continue;
|
|
1391
|
+
// 解析器改过口径的 kind:老 rev 的 state 直接丢弃(让它重扫),否则恢复出来的是旧口径条目。
|
|
1392
|
+
// kind 级编号查的是「共用解析器代码」,对所有 OFF_KINDS 一视同仁,包括 generic-jsonl;
|
|
1393
|
+
// generic-jsonl 另外还要按 agent 比对 rulesRev/rulesHash(rules 口径),两道都不过关才恢复。
|
|
1394
|
+
const need = PARSER_REV[f.kind];
|
|
1395
|
+
if (need != null && rev !== need) continue;
|
|
1396
|
+
if (f.kind === 'generic-jsonl') {
|
|
1397
|
+
const gc = agentConfs.get(f.agent);
|
|
1398
|
+
if (!gc || gc.kind !== 'generic-jsonl') continue;
|
|
1399
|
+
if (f.rr !== gc.rulesRev || f.rh !== gc.rulesHash) continue;
|
|
1400
|
+
}
|
|
1401
|
+
// 恢复前校验 mtime/size 与磁盘一致,不一致丢弃让其重扫
|
|
1402
|
+
let st; try { st = fs.statSync(p); } catch { continue; }
|
|
1403
|
+
if (st.mtimeMs !== f.m || st.size !== f.s) continue;
|
|
1404
|
+
if ((f.kind === 'claude' || f.kind === 'buddyjsonl') && f.data?.turns) for (const t of f.data.turns) if (t.mids && !Array.isArray(t.mids)) t.mids = [];
|
|
1405
|
+
files.set(p, f); n++;
|
|
1406
|
+
}
|
|
1407
|
+
};
|
|
1408
|
+
// 旧版单文件迁移:agent-log-index.json (v2)
|
|
1409
|
+
try {
|
|
1410
|
+
const legacy = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'agent-log-index.json'), 'utf8'));
|
|
1411
|
+
if (legacy.v === 2) { restore(legacy.files); kindDirty.add('jsonl'); kindDirty.add('claude'); kindDirty.add('buddyjsonl'); kindDirty.add('codearts'); }
|
|
1412
|
+
// 改名是写盘:只读库模式照样**读**这份旧索引(不读就少一半数据),但不替常驻服务改名
|
|
1413
|
+
if (MODE !== 'lib') {
|
|
1414
|
+
fs.renameSync(path.join(DATA_DIR, 'agent-log-index.json'), path.join(DATA_DIR, 'agent-log-index.json.bak'));
|
|
1415
|
+
log('[index] migrated legacy single-file index');
|
|
1416
|
+
}
|
|
1417
|
+
} catch {}
|
|
1418
|
+
if (MODE === 'lib' && kindDirty.size) kindDirty.clear(); // 只读进程不去补这次迁移,别把脏标记留着让人以为有东西要写
|
|
1419
|
+
|
|
1420
|
+
try {
|
|
1421
|
+
for (const fn of fs.readdirSync(INDEX_DIR)) {
|
|
1422
|
+
if (!fn.endsWith('.json')) continue;
|
|
1423
|
+
try {
|
|
1424
|
+
const j = JSON.parse(fs.readFileSync(path.join(INDEX_DIR, fn), 'utf8'));
|
|
1425
|
+
restore(j.files, j.rev);
|
|
1426
|
+
} catch {}
|
|
1427
|
+
}
|
|
1428
|
+
} catch {}
|
|
1429
|
+
if (n) log('[index] loaded', n, 'states');
|
|
1430
|
+
}
|
|
1431
|
+
let saveTimer = null;
|
|
1432
|
+
function scheduleSave() { if (!saveTimer) saveTimer = after(5000, () => { saveTimer = null; saveIndex(); }); }
|
|
1433
|
+
function saveIndex() {
|
|
1434
|
+
if (MODE === 'lib') return; // 只读库模式:索引分片只有常驻服务那一份写者(见 setSaveHook 与 saveConfig 同一条理由)
|
|
1435
|
+
if (!kindDirty.size) return;
|
|
1436
|
+
try {
|
|
1437
|
+
// POSIX 权限(R34):索引分片里带着条目的元信息与文件路径,是内部状态、没有共享的用法,
|
|
1438
|
+
// 所以按私有建(目录 0700 / 文件 0600;win32 上 mode 无副作用)。只对新文件生效,
|
|
1439
|
+
// 老分片保持原样 —— 要收紧就 chmod -R go-rwx ~/.agent-acta(与 search.mjs / archive.mjs 同一套)。
|
|
1440
|
+
const posix = process.platform !== 'win32';
|
|
1441
|
+
const mkOpts = { recursive: true };
|
|
1442
|
+
if (posix) mkOpts.mode = 0o700;
|
|
1443
|
+
fs.mkdirSync(INDEX_DIR, mkOpts);
|
|
1444
|
+
for (const kind of kindDirty) {
|
|
1445
|
+
const arr = [...files.entries()].filter(([, f]) => f.kind === kind);
|
|
1446
|
+
const fp = path.join(INDEX_DIR, kind + '.json');
|
|
1447
|
+
fs.writeFileSync(fp + '.tmp', JSON.stringify({ v: 2, rev: PARSER_REV[kind], files: arr }), posix ? { mode: 0o600 } : undefined);
|
|
1448
|
+
fs.renameSync(fp + '.tmp', fp);
|
|
1449
|
+
}
|
|
1450
|
+
kindDirty.clear();
|
|
1451
|
+
} catch (e) { console.error('[index] save failed', e.message); }
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// 解析器统一走 shared 的 markKind(shared 里 no-op),这里把「该落盘了」接回本文件的调度:
|
|
1455
|
+
// 只读库模式挂空函数:markKind 照常往 kindDirty 里记(内存口径不变),但没人去落盘。
|
|
1456
|
+
// **装配点在下面 assemble()**(Step 2 把 setSaveHook / initArchive / initSearch 三处合成一个入口):
|
|
1457
|
+
// 它按 MODE 定 readOnly 与写钩子,且 start() 切 mode 之后还要再装一遍。这里只剩一句说明,
|
|
1458
|
+
// 免得有人以为「解析器一 import 就接上了落盘」—— 装配发生在模块加载的末尾,早于任何扫描。
|
|
1459
|
+
// trace 解析器需要读 agentConfs 拿 sessions 目录(resolveCwd)
|
|
1460
|
+
setAgentConfs(agentConfs);
|
|
1461
|
+
|
|
1462
|
+
// ---------------- scan loop ----------------
|
|
1463
|
+
// 并发闸门:同步时代「一次 scanAll 跑到底」天然互斥;首扫分片让出事件循环之后,
|
|
1464
|
+
// 定时扫描 / 手动刷新 / 增删 agent 随时可能插进半路,两个扫描同时写同一份 entries Map。
|
|
1465
|
+
// 忙的时候分两种处理:
|
|
1466
|
+
// queueIfBusy=true(手动刷新 / 增删 agent):记一个补扫,扫完再补一轮 —— 这类请求必须真的扫到,
|
|
1467
|
+
// 暂停定时扫描时更是唯一一次机会;
|
|
1468
|
+
// queueIfBusy=false(定时轮):直接跳过 —— 它自己会再排下一次,此刻补一轮纯属浪费。
|
|
1469
|
+
let scanning = false;
|
|
1470
|
+
let rescanQueued = false;
|
|
1471
|
+
const yieldToLoop = () => new Promise(r => setImmediate(r));
|
|
1472
|
+
|
|
1473
|
+
function scanAll(queueIfBusy) {
|
|
1474
|
+
if (scanning) { if (queueIfBusy) rescanQueued = true; return; }
|
|
1475
|
+
scanning = true;
|
|
1476
|
+
try { scanAllSync(); } finally { scanning = false; }
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
function scanAllSync() {
|
|
1480
|
+
try {
|
|
1481
|
+
// qoder 模型名映射增量刷新(只读新出现的 runs 目录):服务启动后用户换的新模型不用重启就对上名
|
|
1482
|
+
loadModelDisplayMap(QODER_RUNS_DIR);
|
|
1483
|
+
discoverAgents();
|
|
1484
|
+
for (const [agent, conf] of agentConfs) scanOneAgent(agent, conf);
|
|
1485
|
+
evictIfNeeded();
|
|
1486
|
+
broadcast();
|
|
1487
|
+
} catch (e) { console.error('[scan]', e.message); }
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// 禁用 / 目录消失:两种情况都直接跳过(manual/builtin 目录消失标 missing 保留;source:auto 的已在 discoverAgents 摘除)
|
|
1491
|
+
function agentScannable(conf) {
|
|
1492
|
+
if (conf.enabled === false) return false;
|
|
1493
|
+
conf.missing = !confAvailable(conf);
|
|
1494
|
+
return !conf.missing;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// 「这个 agent 该走哪条扫描分支」集中成一处:scanOneAgent 与分片版各写一份 if/else 迟早走偏
|
|
1498
|
+
function scanBranch(conf) {
|
|
1499
|
+
switch (conf.kind) {
|
|
1500
|
+
case 'trace': return { kind: 'trace', list: conf.traces };
|
|
1501
|
+
case 'codearts': return { kind: 'codearts', list: conf.sessions };
|
|
1502
|
+
case 'claude': return { kind: 'claude', list: conf.sessions };
|
|
1503
|
+
// doubao(豆包 Work 智能体):list = .sessions 根,解析器内部枚举 <会话>/agents/<agent>/system/trajectory.jsonl
|
|
1504
|
+
case 'doubao': return { kind: 'doubao', list: conf.sessions };
|
|
1505
|
+
// minimax(Mavis 本地运行时):list = v2/sessions 根,解析器内部按 y/m/d/session_<id> 四层下探
|
|
1506
|
+
case 'minimax': return { kind: 'minimax', list: conf.sessions };
|
|
1507
|
+
// openclaw(OpenClaw):list = stateDir(默认 ~/.openclaw),解析器内部枚举 agents/<id>/sessions/*.jsonl
|
|
1508
|
+
case 'openclaw': return { kind: 'openclaw', list: conf.sessions };
|
|
1509
|
+
case 'generic-jsonl': return { kind: 'generic-jsonl', list: conf.sessions };
|
|
1510
|
+
case 'buddyjsonl': return { kind: 'buddyjsonl', list: conf.sessions };
|
|
1511
|
+
case 'codex': return { kind: 'codex', list: conf.sessions };
|
|
1512
|
+
case 'gemini': return { kind: 'gemini', list: conf.sessions };
|
|
1513
|
+
case 'cursor': return { kind: 'cursor', list: conf.sessions };
|
|
1514
|
+
case 'dsh': return { kind: 'dsh', list: conf.sessions };
|
|
1515
|
+
case 'copilot': return { kind: 'copilot', list: conf.sessions };
|
|
1516
|
+
case 'zcode': return { kind: 'zcode', list: conf.sessions }; // list = db.sqlite 文件路径(不是目录)
|
|
1517
|
+
case 'opencode': return { kind: 'opencode', list: conf.sessions }; // list = opencode.db 文件路径(不是目录)
|
|
1518
|
+
case 'kilo': return { kind: 'kilo', list: conf.sessions }; // list = kilo.db 文件路径(不是目录)
|
|
1519
|
+
case 'hermes': return { kind: 'hermes', list: conf.sessions }; // list = state.db 文件路径(不是目录)
|
|
1520
|
+
case 'devindb': return { kind: 'devindb', list: conf.sessions }; // list = sessions.db 文件路径(不是目录)
|
|
1521
|
+
case 'traedb': return { kind: 'traedb', list: conf.sessions }; // list = database.db 文件路径(不是目录)
|
|
1522
|
+
// list 只用来判「有没有根可扫」;真正的遍历在 scanOneAgent 里按 rootsOf(conf) 逐个走
|
|
1523
|
+
case 'kimi': return { kind: 'kimi', list: rootsOf(conf)[0] || null };
|
|
1524
|
+
// comate(百度 Comate):list = ~/.comate-engine/store 根,解析器内部枚举平铺的 chat_session_* 文件
|
|
1525
|
+
case 'comate': return { kind: 'comate', list: conf.sessions };
|
|
1526
|
+
// cline(Cline CLI):list = <data>/sessions 根,解析器内部枚举 <会话 id>/<会话 id>.messages.json
|
|
1527
|
+
case 'cline': return { kind: 'cline', list: conf.sessions };
|
|
1528
|
+
// buddyext(CodeBuddy 扩展版):list = <Data> 根,解析器内部枚举
|
|
1529
|
+
// <acct>/<Host>/(<acct>/)?history/<wsKey>/<会话 id>/index.json(两种账号形状都在内部处理)
|
|
1530
|
+
case 'buddyext': return { kind: 'buddyext', list: conf.sessions };
|
|
1531
|
+
// tracecode / tracework:sessions 存的是 logs 目录(不是 sessions 子目录),
|
|
1532
|
+
// 解析器内部枚举 <timestamp>/window1/renderer.log
|
|
1533
|
+
case 'tracecode': return { kind: 'tracecode', list: conf.sessions };
|
|
1534
|
+
case 'tracework': return { kind: 'tracework', list: conf.sessions };
|
|
1535
|
+
// mimocode(记忆/检查点类 agent):list = memory 根目录,解析器内部枚举 sessions/<id> 与 projects/<pid>/MEMORY.md
|
|
1536
|
+
case 'mimocode': return { kind: 'mimocode', list: conf.sessions };
|
|
1537
|
+
default: return { kind: 'atomcode', list: conf.sessions }; // 未分类 kind = 原子码式「hash 会话目录」
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
// Trae 的两个数据源(SQLCipher 库 / renderer.log)覆盖同一批会话,同时上线就是每轮两张重复卡片,
|
|
1542
|
+
// 所以任何时刻只保留一边的条目:扫完当前这一边后,把这个 agent 名下**另一边的**条目清掉。
|
|
1543
|
+
// 必须按 entries 里的实际存在清,不能只看内存 files —— tracecode 在 OFF_KINDS 里,它的文件状态
|
|
1544
|
+
// 会从 index 恢复,光看内存会漏掉「上一版进程留下的条目」。
|
|
1545
|
+
function purgeTraeOtherSide(agent, keepKind) {
|
|
1546
|
+
for (const [id, e] of entries) {
|
|
1547
|
+
if (e.agent !== agent) continue;
|
|
1548
|
+
const k = srcs.get(id)?.kind;
|
|
1549
|
+
if ((k === 'traedb' || k === 'tracecode' || k === 'tracework') && k !== keepKind) {
|
|
1550
|
+
entries.delete(id); srcs.delete(id); removedIds.push(id);
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
sorted.cache = null;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// traedb 的取数编排:库优先,读不出来回退 renderer.log(同一个 agent 名下接着用,不另起 agent)。
|
|
1557
|
+
// 库能读(ok / empty)→ 用库,清掉日志那边的旧条目;
|
|
1558
|
+
// 库读不出来(nokey / fail)→ 回退日志(kind 与 logs 目录都由 db 路径反推),清掉库这边的旧条目。
|
|
1559
|
+
// 为什么回退而不是只报错:没配密钥的人升级后看到的应与从前一样(renderer.log 那套),而不是一片空白;
|
|
1560
|
+
// 加密参数变了/库暂时被写坏时也是同理 —— 有骨架看总好过没有,诊断页同时写着原因(traeDbErr)。
|
|
1561
|
+
// codebuddy 的第二份落盘(genie 扩展,kind buddyext)—— 见 discoverAgents 里的并源注释。
|
|
1562
|
+
// 它**不参与按文件分片**:那是「一会话一份整份重写的 index.json」,冷扫实测 0.65s、热扫 ~10ms,
|
|
1563
|
+
// 切了只是白添调用(与 cline 同一个判断)。所以:
|
|
1564
|
+
// · 同步扫描(定时/手动补扫)在 buddyjsonl 分支里顺带扫一次;
|
|
1565
|
+
// · 首扫分片只切主根,切完再补这一趟。
|
|
1566
|
+
function scanBuddyExtExtras(agent, conf) {
|
|
1567
|
+
for (const r of (conf.sessionsExtra || []).filter(x => x && x !== conf.sessions)) scanBuddyExt(agent, r, null);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
function scanTraeAgent(agent, conf, onlyFile) {
|
|
1571
|
+
const r = scanTraeDb(agent, conf.sessions, conf.traeKey);
|
|
1572
|
+
if (r === 'ok' || r === 'empty') { purgeTraeOtherSide(agent, 'traedb'); return; }
|
|
1573
|
+
const logKind = traeLogKindOf(conf.sessions);
|
|
1574
|
+
const logs = traeLogsFromDb(conf.sessions);
|
|
1575
|
+
if (!logs) return; // 库读不出来、日志也没有:交给诊断页说原因,这里没有别的可做
|
|
1576
|
+
if (logKind === 'tracework') scanTraework(agent, logs, onlyFile);
|
|
1577
|
+
else scanTraecode(agent, logs, onlyFile);
|
|
1578
|
+
purgeTraeOtherSide(agent, logKind);
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
function scanOneAgent(agent, conf, onlyFile) {
|
|
1582
|
+
try {
|
|
1583
|
+
if (!agentScannable(conf)) return;
|
|
1584
|
+
const b = scanBranch(conf);
|
|
1585
|
+
if (!b.list) return;
|
|
1586
|
+
if (b.kind === 'trace') scanTraces(agent, b.list);
|
|
1587
|
+
else if (b.kind === 'codearts') scanCodearts(agent, b.list, onlyFile);
|
|
1588
|
+
else if (b.kind === 'claude') scanClaude(agent, b.list, onlyFile);
|
|
1589
|
+
// doubao(豆包 Work 智能体):trajectory.jsonl 逐行 off 增量,轮时间与 assignment.md 对齐
|
|
1590
|
+
else if (b.kind === 'doubao') scanDoubao(agent, b.list, onlyFile);
|
|
1591
|
+
// generic-jsonl(R18):声明式接入的 claude 同族逐行 jsonl,rules/sniff 全在 conf 上
|
|
1592
|
+
else if (b.kind === 'generic-jsonl') scanGeneric(agent, conf, b.list, onlyFile);
|
|
1593
|
+
else if (b.kind === 'buddyjsonl') { scanBuddy(agent, b.list, onlyFile); if (!onlyFile) scanBuddyExtExtras(agent, conf); }
|
|
1594
|
+
else if (b.kind === 'codex') scanCodex(agent, b.list);
|
|
1595
|
+
else if (b.kind === 'gemini') scanGemini(agent, b.list);
|
|
1596
|
+
else if (b.kind === 'cursor') scanCursor(agent, b.list);
|
|
1597
|
+
else if (b.kind === 'dsh') scanDsh(agent, b.list);
|
|
1598
|
+
// Copilot:正文在 session-state/*/events.jsonl,用量由同根 session-store.db 补齐;ide/*.lock 不是会话正文。
|
|
1599
|
+
else if (b.kind === 'copilot') scanCopilot(agent, b.list);
|
|
1600
|
+
else if (b.kind === 'zcode') scanZcode(agent, b.list);
|
|
1601
|
+
// OpenCode:会话全在一个明文 SQLite 库里(不需要解密),按 db+wal 签名整库只读重读
|
|
1602
|
+
else if (b.kind === 'opencode') scanOpencode(agent, b.list);
|
|
1603
|
+
// KiloCode(Kilo CLI):opencode 的 fork,库同构(kilo.db + -wal、同样的 message.data.tokens 口径),
|
|
1604
|
+
// 复用 opencode 的扫描核心(parsers/kilo.mjs 薄壳)
|
|
1605
|
+
else if (b.kind === 'kilo') scanKilo(agent, b.list);
|
|
1606
|
+
// hermes(Hermes Agent):会话全在 state.db(明文 SQLite),逐次用量在旁路 agent.log(见 parsers/hermes.mjs)
|
|
1607
|
+
else if (b.kind === 'hermes') scanHermes(agent, b.list);
|
|
1608
|
+
// devin(Devin CLI):会话全在 sessions.db(明文 SQLite + WAL),消息是 node_id/parent_node_id
|
|
1609
|
+
// 连成的森林 —— 只沿 main_chain_id 回溯主线,逐次 token/工具明细都在库里(见 parsers/devindb.mjs)
|
|
1610
|
+
else if (b.kind === 'devindb') scanDevinDb(agent, b.list);
|
|
1611
|
+
// minimax(Mavis 本地运行时):v2/sessions/YYYY/MM/DD/<session-id>/messages.jsonl,逐行 off 增量
|
|
1612
|
+
// 续读;usage/model/stopReason 都在消息行内、不必依赖旁路日志(见 parsers/minimax.mjs)。
|
|
1613
|
+
else if (b.kind === 'minimax') scanMavis(agent, b.list);
|
|
1614
|
+
// openclaw(OpenClaw):<stateDir>/agents/<id>/sessions/<uuid>.jsonl,追加式会话树,逐行 off 增量续读;
|
|
1615
|
+
// token/模型在 assistant 消息行的 usage 里(input **不含** cache),不必依赖旁路日志(见 parsers/openclaw.mjs)。
|
|
1616
|
+
else if (b.kind === 'openclaw') scanOpenclaw(agent, b.list, onlyFile);
|
|
1617
|
+
// traedb:库优先 + 读不出来回退 renderer.log(两边的取舍见 scanTraeAgent)
|
|
1618
|
+
else if (b.kind === 'traedb') scanTraeAgent(agent, conf, onlyFile);
|
|
1619
|
+
// kimi 可能有多个根(CLI 的 ~/.kimi-code + 桌面版的 runtime/kimi-code/home),逐个扫
|
|
1620
|
+
else if (b.kind === 'kimi') for (const root of rootsOf(conf)) scanKimi(agent, root);
|
|
1621
|
+
// comate(百度 Comate):chat_session_<uuid> 整份 JSON 原地重写,整份重解析 + 签名跳过(见 parsers/comate.mjs)
|
|
1622
|
+
else if (b.kind === 'comate') scanComate(agent, b.list);
|
|
1623
|
+
// cline(Cline CLI):<data>/sessions/<会话 id>/<会话 id>.messages.json 是**整份 JSON、每次落盘整体重写**,
|
|
1624
|
+
// 按 {mtime,size} 签名整份重解析(不进 OFF_KINDS);逐次用量在每条 assistant 的 metrics 里,
|
|
1625
|
+
// 且 inputTokens **含**缓存(与 openclaw 相反,见 parsers/cline.mjs 头)
|
|
1626
|
+
else if (b.kind === 'cline') scanCline(agent, b.list, onlyFile);
|
|
1627
|
+
// buddyext(CodeBuddy 扩展版):每会话一份 index.json **整份重写**(不进 OFF_KINDS)+
|
|
1628
|
+
// messages/<消息 id>.json 逐条正文;增量靠「会话 index 签名变了才重算聚合 + 逐条消息 {mtime,size}
|
|
1629
|
+
// 投影缓存」两层,轮 = requests[] 的一项,token 的 inputTokens **含**缓存(见 parsers/buddyext.mjs 头)
|
|
1630
|
+
else if (b.kind === 'buddyext') scanBuddyExt(agent, b.list, onlyFile);
|
|
1631
|
+
// tracecode / tracework:sessions 是 logs 根目录,解析器内部枚举 <timestamp>/window1/renderer.log
|
|
1632
|
+
else if (b.kind === 'tracecode') scanTraecode(agent, b.list, onlyFile);
|
|
1633
|
+
else if (b.kind === 'tracework') scanTraework(agent, b.list, onlyFile);
|
|
1634
|
+
else if (b.kind === 'mimocode') scanMimocode(agent, b.list);
|
|
1635
|
+
else for (const dir of atomcodeDirs(b.list)) {
|
|
1636
|
+
const names = listDirCached(dir);
|
|
1637
|
+
if (!names) continue;
|
|
1638
|
+
scanAtomcodeDir(agent, names, dir);
|
|
1639
|
+
}
|
|
1640
|
+
} catch (e) { console.error('[' + agent + ']', e.message); }
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// 分片粒度:这三个 kind 在真实数据上一整段就有 0.7~1.3s(本机实测 claude 1.16s / buddyjsonl 1.02s /
|
|
1644
|
+
// codearts 0.70s),而它们慢的原因都是「一个目录里堆了几十个上百个会话文件」
|
|
1645
|
+
// (.claude/projects 里一个项目 86 个、.codebuddy/projects 里一个项目 100 个、codearts 20 个 .log 共 69MB),
|
|
1646
|
+
// 所以按目录切还不够,要切到**单个文件**。其余 kind 一段几十 ms,切了只是白添调用,整段同步扫完。
|
|
1647
|
+
const SLICE_BY_FILE = new Set(['claude', 'buddyjsonl', 'codearts']);
|
|
1648
|
+
|
|
1649
|
+
// 切片清单 = 这些 kind 下面要挨个扫的文件。非目标文件也列进去无所谓:
|
|
1650
|
+
// 扫描器自己会按扩展名/正则跳过,多出来的是一次几十微秒的空调用。
|
|
1651
|
+
// ⚠️ I16:claude 除了 <slug>/*.jsonl,还要列 **<slug>/<会话>/subagents/*.jsonl** —— 子 agent 转录
|
|
1652
|
+
// 是「按会话目录再下一层」摆的,只列第一层的话首扫分片会把它们整批漏掉(同步扫描没这问题,
|
|
1653
|
+
// 但首扫走的是分片路径,漏了就是「冷启动看不到子 agent、等下一轮补扫才冒出来」)。多列一层带来的
|
|
1654
|
+
// 额外开销是「非会话目录也去 statSync 一个不存在的 subagents/」——listDirCached 对不存在的目录
|
|
1655
|
+
// 直接返回 null(一次必然失败的 statSync),本机 9 个 slug ≈ 98 次,可忽略。
|
|
1656
|
+
function sliceFiles(kind, root) {
|
|
1657
|
+
const out = [];
|
|
1658
|
+
if (kind === 'codearts') {
|
|
1659
|
+
for (const n of listDirCached(root) || []) out.push(path.join(root, n));
|
|
1660
|
+
return out;
|
|
1661
|
+
}
|
|
1662
|
+
for (const slug of listDirCached(root) || []) {
|
|
1663
|
+
const dir = path.join(root, slug);
|
|
1664
|
+
for (const n of listDirCached(dir) || []) {
|
|
1665
|
+
out.push(path.join(dir, n));
|
|
1666
|
+
// I16:会话子目录里的 subagents/*.jsonl(claude 才有这一层;别的目录下探不到就跳过)
|
|
1667
|
+
const subDir = path.join(dir, n, 'subagents');
|
|
1668
|
+
for (const sn of listDirCached(subDir) || []) out.push(path.join(subDir, sn));
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
return out;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
// 原子码的切片:一个 hash 目录里可能堆着几千个会话文件(本机实测 2200 个,单这一坨就 ~0.6s),
|
|
1675
|
+
// 按目录切不够,要按「同一目录下的一批会话」再切。
|
|
1676
|
+
// 关键约束:一个会话的 .jsonl 和 .meta 必须落在同一批 —— scanAtomcodeDir 用同批的 jsonls
|
|
1677
|
+
// 给 meta 补轮次时间戳/预览,拆开首扫就会全变成无预览条目(且下次 meta 没变不再重扫,不会自愈)。
|
|
1678
|
+
// 所以按「去掉扩展名后的会话名」分组,再按组切批,而不是直接切文件列表。
|
|
1679
|
+
function atomcodeBatches(names, size) {
|
|
1680
|
+
const groups = new Map(); // 会话名 -> 它的文件(.jsonl + .meta)
|
|
1681
|
+
for (const n of names) {
|
|
1682
|
+
const base = n.replace(/\.(jsonl|meta)$/, '');
|
|
1683
|
+
if (!groups.has(base)) groups.set(base, []);
|
|
1684
|
+
groups.get(base).push(n);
|
|
1685
|
+
}
|
|
1686
|
+
const out = [];
|
|
1687
|
+
let cur = [];
|
|
1688
|
+
for (const files of groups.values()) {
|
|
1689
|
+
for (const f of files) cur.push(f);
|
|
1690
|
+
if (cur.length >= size) { out.push(cur); cur = []; }
|
|
1691
|
+
}
|
|
1692
|
+
if (cur.length) out.push(cur);
|
|
1693
|
+
return out;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// 让出的粒度按**时间预算**而不是「每个单位让一次」。
|
|
1697
|
+
// 本机实测:Windows 上单次 setImmediate 往返约 1.5ms,按文件让(几百次)会凭空多花 ~0.4s,
|
|
1698
|
+
// 热启动的整轮重扫从 554ms 涨到 943ms —— 页面就得在「扫描中」多待半秒。
|
|
1699
|
+
// 改成「攒够 ~40ms 才让一次」:最坏请求延迟仍被压在几十 ms,让出次数降到个位数。
|
|
1700
|
+
const SLICE_BUDGET_MS = 40;
|
|
1701
|
+
function makeSlicer() {
|
|
1702
|
+
let deadline = Date.now() + SLICE_BUDGET_MS;
|
|
1703
|
+
return async function tick() {
|
|
1704
|
+
if (Date.now() < deadline) return;
|
|
1705
|
+
await yieldToLoop();
|
|
1706
|
+
deadline = Date.now() + SLICE_BUDGET_MS;
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
// 首扫专用:单个 agent 的分片扫描。原子码按「一批会话文件」拆,claude/buddy/codearts 按单个会话文件拆,
|
|
1711
|
+
// 其余 kind 单个体量小,直接交给同步版 —— 免得把简单路径也改成 async 引新 bug。
|
|
1712
|
+
async function scanAgentChunked(agent, conf) {
|
|
1713
|
+
if (!agentScannable(conf)) return;
|
|
1714
|
+
const b = scanBranch(conf);
|
|
1715
|
+
if (!b.list) return;
|
|
1716
|
+
const tick = makeSlicer();
|
|
1717
|
+
if (b.kind === 'atomcode') {
|
|
1718
|
+
for (const dir of atomcodeDirs(b.list)) {
|
|
1719
|
+
const names = listDirCached(dir);
|
|
1720
|
+
if (!names) continue;
|
|
1721
|
+
for (const batch of atomcodeBatches(names, 100)) {
|
|
1722
|
+
scanAtomcodeDir(agent, batch, dir);
|
|
1723
|
+
await tick();
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
if (!SLICE_BY_FILE.has(b.kind)) { scanOneAgent(agent, conf); return; }
|
|
1729
|
+
for (const fp of sliceFiles(b.kind, b.list)) {
|
|
1730
|
+
scanOneAgent(agent, conf, fp);
|
|
1731
|
+
await tick();
|
|
1732
|
+
}
|
|
1733
|
+
// buddyjsonl 的第二份落盘(genie 扩展)不切片,切完主根补一趟 —— 见 scanBuddyExtExtras
|
|
1734
|
+
if (b.kind === 'buddyjsonl' && (conf.sessionsExtra || []).length) { scanBuddyExtExtras(agent, conf); await tick(); }
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// 首扫分片版:每扫完一个 agent 让出一次事件循环 —— 这是本任务的关键。
|
|
1738
|
+
// 同步跑几秒会把事件循环占死,页面连不上(TCP backlog 里躺着)、进度事件发出去也没人收。
|
|
1739
|
+
// 只有首扫走这里;定时/手动补扫仍走同步版(一轮就几十~几百 ms,拆开只会让「扫描中」每个周期闪一次)。
|
|
1740
|
+
async function scanAllChunked() {
|
|
1741
|
+
if (scanning) { rescanQueued = true; return; }
|
|
1742
|
+
scanning = true;
|
|
1743
|
+
scanState = { done: 0, total: agentConfs.size, agent: '' };
|
|
1744
|
+
try {
|
|
1745
|
+
loadModelDisplayMap(QODER_RUNS_DIR);
|
|
1746
|
+
discoverAgents();
|
|
1747
|
+
scanState.total = agentConfs.size;
|
|
1748
|
+
scanEvent('start');
|
|
1749
|
+
let done = 0;
|
|
1750
|
+
for (const [agent, conf] of agentConfs) {
|
|
1751
|
+
scanState.agent = agent;
|
|
1752
|
+
scanEvent('progress'); // 即将扫谁(done 是「已完成」数)
|
|
1753
|
+
await yieldToLoop(); // 让出:页面能连上、旧索引数据能查、hook 的 ping 能应答
|
|
1754
|
+
await scanAgentChunked(agent, conf);
|
|
1755
|
+
scanState.done = ++done;
|
|
1756
|
+
scanState.agent = '';
|
|
1757
|
+
broadcast(); // 每段扫完推一次 —— 数据逐步出现,不是等全部扫完才一起冒出来
|
|
1758
|
+
scanEvent('progress');
|
|
1759
|
+
}
|
|
1760
|
+
evictIfNeeded();
|
|
1761
|
+
broadcast();
|
|
1762
|
+
} catch (e) { console.error('[scan]', e.message); }
|
|
1763
|
+
finally {
|
|
1764
|
+
scanning = false;
|
|
1765
|
+
scanState = null;
|
|
1766
|
+
scanEvent('done');
|
|
1767
|
+
if (rescanQueued) { rescanQueued = false; scanAll(true); } // 首扫期间被挡下的补扫(增删 agent / 手动刷新)
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
// 当前扫描进度(null = 没在扫)。挂给 /api/events 的 hello:页面中途连上时靠它拿到进度,
|
|
1772
|
+
// 否则要等到下一个分段才有事件(冷启动第一段就是 ~1.5s)。
|
|
1773
|
+
// 进度不是「脏条目」,不进 dirty —— broadcast() 在没客户端时会把 dirty 清空,所以直接写给当前连着的人。
|
|
1774
|
+
let scanState = null;
|
|
1775
|
+
function scanEvent(phase) {
|
|
1776
|
+
if (!sseClients.size) return;
|
|
1777
|
+
const msg = { type: 'scanning', seq: ++bseq, phase, done: scanState ? scanState.done : 0, total: scanState ? scanState.total : 0, agent: scanState ? scanState.agent : '' };
|
|
1778
|
+
for (const res of [...sseClients]) sseWrite(res, msg);
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
// entries LRU:超上限淘汰最旧,但每个 agent 至少保留最近 KEEP_MIN_PER_AGENT 条
|
|
1782
|
+
function evictIfNeeded() {
|
|
1783
|
+
if (entries.size <= MAX_ENTRIES) return;
|
|
1784
|
+
const perAgent = {};
|
|
1785
|
+
for (const e of entries.values()) perAgent[e.agent] = (perAgent[e.agent] || 0) + 1;
|
|
1786
|
+
const asc = [...entries.values()].sort((a, b) => (a.time || 0) - (b.time || 0));
|
|
1787
|
+
const target = MAX_ENTRIES - EVICT_BATCH;
|
|
1788
|
+
for (const e of asc) {
|
|
1789
|
+
if (entries.size <= target) break;
|
|
1790
|
+
if ((perAgent[e.agent] || 0) <= KEEP_MIN_PER_AGENT) continue;
|
|
1791
|
+
entries.delete(e.id); srcs.delete(e.id); removedIds.push(e.id); perAgent[e.agent]--;
|
|
1792
|
+
}
|
|
1793
|
+
sorted.cache = null;
|
|
1794
|
+
log('[lru] evicted to', entries.size);
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
function entryContent(id, full) {
|
|
1799
|
+
const src = srcs.get(id);
|
|
1800
|
+
if (!src) return null;
|
|
1801
|
+
const fstate = files.get(src.file);
|
|
1802
|
+
const version = fstate ? fstate.m + ':' + (fstate.off || 0) : null; // 前端据此判断进行中轮是否需重取
|
|
1803
|
+
try {
|
|
1804
|
+
if (src.kind === 'atomcode') return atomcodeEntryContent(src, full, entries.get(id));
|
|
1805
|
+
if (src.kind === 'claude') return claudeEntryContent(src, full);
|
|
1806
|
+
// doubao(豆包 Work 智能体):重读 trajectory.jsonl 按 src.turn 过滤,工具按 tool_call_id 回填
|
|
1807
|
+
if (src.kind === 'doubao') return doubaoEntryContent(src, full);
|
|
1808
|
+
// R18 generic-jsonl:声明式详情 —— 用同一套 rules 重读转录重建正文(工具输出回填不做,
|
|
1809
|
+
// 见 parsers/generic.mjs 的能力声明)。
|
|
1810
|
+
if (src.kind === 'generic-jsonl') {
|
|
1811
|
+
const e0 = entries.get(id);
|
|
1812
|
+
const conf = e0 ? agentConfs.get(e0.agent) : null;
|
|
1813
|
+
if (!conf || conf.kind !== 'generic-jsonl' || !conf.rules) {
|
|
1814
|
+
return { user: '', assistant: '', tools: [], calls: [], v: version, note: '该 agent 的声明式规则当前不可用(见环境诊断)' };
|
|
1815
|
+
}
|
|
1816
|
+
const out = genericEntryContent(src.file, src.turn, full, conf);
|
|
1817
|
+
out.v = version;
|
|
1818
|
+
return out;
|
|
1819
|
+
}
|
|
1820
|
+
if (src.kind === 'buddyjsonl') return buddyEntryContent(src, full);
|
|
1821
|
+
if (src.kind === 'codex') return codexEntryContent(src, full);
|
|
1822
|
+
if (src.kind === 'kimi') return kimiEntryContent(src, full);
|
|
1823
|
+
// comate(百度 Comate):重读会话文件按 src.turn 切,扫描与详情同一份解析(见 parsers/comate.mjs)
|
|
1824
|
+
if (src.kind === 'comate') return comateEntryContent(src, full);
|
|
1825
|
+
// cline:整份 JSON 重读一遍、按 src.turn 切那一轮(开轮判据与扫描侧同一个函数,见 parsers/cline.mjs)
|
|
1826
|
+
if (src.kind === 'cline') return clineEntryContent(src, full);
|
|
1827
|
+
// buddyext:重读会话 index、按 src.turn 取那一轮,再只读那一轮引用的几十条消息文件
|
|
1828
|
+
// (轮的选择与扫描侧共用 buddyExtTurns,见 parsers/buddyext.mjs)
|
|
1829
|
+
if (src.kind === 'buddyext') return buddyExtEntryContent(src, full);
|
|
1830
|
+
if (src.kind === 'gemini') {
|
|
1831
|
+
// 扫描时已把整轮(用户文本 / 回复正文 / 工具 / 逐次调用)解析进内存:直接读,不重解析文件。
|
|
1832
|
+
// 扫描与详情共用同一份数据,天然不存在「快照与详情对不上」的口径问题。
|
|
1833
|
+
const st = files.get(src.file);
|
|
1834
|
+
const t = st?.data?.turns?.[src.turn];
|
|
1835
|
+
if (!t) return null;
|
|
1836
|
+
const tools = t.tools.map(x => {
|
|
1837
|
+
const fi = {}, fo = {};
|
|
1838
|
+
return {
|
|
1839
|
+
name: x.name, tid: x.tid, error: x.error,
|
|
1840
|
+
input: trunc(x.input, full, fi), inputTrunc: !!fi.t,
|
|
1841
|
+
output: trunc(x.output, full, fo), outputTrunc: !!fo.t,
|
|
1842
|
+
};
|
|
1843
|
+
});
|
|
1844
|
+
// 逐次正文(callList[].text)在扫描时就存好了,这里只按同一套 800 字截断过一遍:
|
|
1845
|
+
// 缓存里的那份必须保持原文,否则 full=1 二次拉取拿到的还是截断过的(同 tools 的处理)
|
|
1846
|
+
const calls = t.callList.map(c => {
|
|
1847
|
+
if (!c.text) return c;
|
|
1848
|
+
const ft = {};
|
|
1849
|
+
const s = trunc(c.text, full, ft);
|
|
1850
|
+
return ft.t ? { ...c, text: s, textTrunc: true } : c;
|
|
1851
|
+
});
|
|
1852
|
+
return { user: t.user, assistant: t.assistant, tools, calls, v: 'g' + (st.data.rev || 0) };
|
|
1853
|
+
}
|
|
1854
|
+
if (src.kind === 'cursor') {
|
|
1855
|
+
// 同 gemini:扫描时已把整轮解析进内存,直接读,扫描与详情不可能对不上
|
|
1856
|
+
const st = files.get(src.file);
|
|
1857
|
+
const t = st?.data?.turns?.[src.turn];
|
|
1858
|
+
if (!t) return null;
|
|
1859
|
+
// 详情里必须说清「为什么这张卡片上全是 0」,否则用户只会看到一屏的 0、合理地去怀疑解析器坏了。
|
|
1860
|
+
// 这不是解析缺口 —— 转录文件里真的只有 role + text(见 CURSOR_NO_USAGE_NOTE)。
|
|
1861
|
+
// 说哪一段按「手上到底有什么」分三档:采到用量 → 讲钩子那套口径;只有模型名 → 讲模型名从哪来;
|
|
1862
|
+
// 都没有 → 解释为什么连模型名都没有(并顺带指出那个注入 hook 的按钮)。
|
|
1863
|
+
const note = t.usage ? CURSOR_HOOK_NOTE
|
|
1864
|
+
: ((t.models && t.models.length) ? CURSOR_MODEL_NOTE : CURSOR_NO_USAGE_NOTE);
|
|
1865
|
+
return { user: t.user, assistant: t.assistant, tools: [], calls: [], callsNote: note, v: 'r' + (st.data.rev || 0) };
|
|
1866
|
+
}
|
|
1867
|
+
if (src.kind === 'copilot') {
|
|
1868
|
+
const st = files.get(src.file);
|
|
1869
|
+
const t = st?.data?.turns?.[src.turn];
|
|
1870
|
+
if (!t) return null;
|
|
1871
|
+
const tools = (t.tools || []).map(x => {
|
|
1872
|
+
const fi = {}, fo = {};
|
|
1873
|
+
return {
|
|
1874
|
+
name: x.name, tid: x.tid, error: x.error, dur: x.dur || 0,
|
|
1875
|
+
input: trunc(x.input, full, fi), inputTrunc: !!fi.t,
|
|
1876
|
+
output: trunc(x.output, full, fo), outputTrunc: !!fo.t,
|
|
1877
|
+
};
|
|
1878
|
+
});
|
|
1879
|
+
const calls = (t.callList || []).map(c => ({ ...c }));
|
|
1880
|
+
const out = {
|
|
1881
|
+
user: t.user || '', assistant: (t.texts || []).join('\n\n---\n\n'),
|
|
1882
|
+
tools, calls, v: 'c' + (st.data.rev || 0), events: [],
|
|
1883
|
+
};
|
|
1884
|
+
if (t.err && t.errMsg) out.callsNote = '这一轮没有成功拿到模型回复:' + t.errMsg;
|
|
1885
|
+
return out;
|
|
1886
|
+
}
|
|
1887
|
+
if (src.kind === 'dsh') {
|
|
1888
|
+
// 同 gemini / cursor:扫描时已把整轮(用户输入 / 正文 / 工具 / 逐次调用)解析进内存,直接读。
|
|
1889
|
+
// 扫描与详情共用同一份数据,天然不存在「快照与详情对不上」的口径问题。
|
|
1890
|
+
const st = files.get(src.file);
|
|
1891
|
+
const t = st?.data?.turns?.[src.turn];
|
|
1892
|
+
if (!t) return null;
|
|
1893
|
+
const tools = t.tools.map(x => {
|
|
1894
|
+
const fi = {}, fo = {};
|
|
1895
|
+
return {
|
|
1896
|
+
name: x.name, tid: x.tid, error: x.error,
|
|
1897
|
+
input: trunc(x.input, full, fi), inputTrunc: !!fi.t,
|
|
1898
|
+
output: trunc(x.output, full, fo), outputTrunc: !!fo.t,
|
|
1899
|
+
};
|
|
1900
|
+
});
|
|
1901
|
+
const out = {
|
|
1902
|
+
user: t.user, assistant: t.texts.join('\n\n---\n\n'),
|
|
1903
|
+
tools, calls: t.callList, v: 'd' + (st.data.rev || 0),
|
|
1904
|
+
// 轨迹(R6):本轮按真实时间顺序的事件流。call/result 的 i 与上面 tools[] 同下标,
|
|
1905
|
+
// 所以页面可以直接复用 tools[i] 的入参/返回(含 800 字截断标记与 full=1 的不截断),
|
|
1906
|
+
// 不必给事件再写一套正文通道。
|
|
1907
|
+
events: t.events || [],
|
|
1908
|
+
};
|
|
1909
|
+
// 失败轮必须说清「为什么全 0」:dsh 的失败调用不落用量,卡片上会是 0 次调用 + token 全 0,
|
|
1910
|
+
// 看着像解析器没接上。把源头给的错误原文带出来,方向就明确了(本机那个会话 4 轮全是网关 405)。
|
|
1911
|
+
if (t.err && t.errMsg) out.callsNote = '这一轮没有成功拿到模型回复:' + t.errMsg;
|
|
1912
|
+
return out;
|
|
1913
|
+
}
|
|
1914
|
+
if (src.kind === 'zcode') {
|
|
1915
|
+
// 同 gemini / dsh:扫描时已把整轮(用户输入 / 正文 / 工具 / 逐次调用 / 事件流)解析进内存,直接读。
|
|
1916
|
+
// 扫描与详情共用同一份数据,天然不存在「快照与详情对不上」的口径问题。
|
|
1917
|
+
const st = files.get(src.file);
|
|
1918
|
+
const t = st?.data?.turns?.[src.turn];
|
|
1919
|
+
if (!t) return null;
|
|
1920
|
+
const tools = t.tools.map(x => {
|
|
1921
|
+
const fi = {}, fo = {};
|
|
1922
|
+
return {
|
|
1923
|
+
name: x.name, tid: x.tid, error: x.error, dur: x.dur || 0,
|
|
1924
|
+
input: trunc(x.input, full, fi), inputTrunc: !!fi.t,
|
|
1925
|
+
output: trunc(x.output, full, fo), outputTrunc: !!fo.t,
|
|
1926
|
+
};
|
|
1927
|
+
});
|
|
1928
|
+
// 逐次正文(callList[].text)与 dsh/gemini 同口径:缓存里保持原文,输出时按 800 字截断
|
|
1929
|
+
const calls = t.callList.map(c => {
|
|
1930
|
+
if (!c.text) return c;
|
|
1931
|
+
const ft = {};
|
|
1932
|
+
const s = trunc(c.text, full, ft);
|
|
1933
|
+
return ft.t ? { ...c, text: s, textTrunc: true } : c;
|
|
1934
|
+
});
|
|
1935
|
+
const out = {
|
|
1936
|
+
user: t.user, assistant: t.texts.join('\n\n---\n\n'),
|
|
1937
|
+
tools, calls, v: 'z' + (st.data.rev || 0),
|
|
1938
|
+
// 逐事件时间线(EVENT_AGENTS 里有 zcode):call/result 的 i 与上面 tools[] 同下标
|
|
1939
|
+
events: t.events || [],
|
|
1940
|
+
// 非文本 part(状态事件 / 推理长度锚点 / 未知类型的结构化 JSON)——未知类型不静默丢弃
|
|
1941
|
+
others: (t.others || []).map(o => ({ type: o.type, t: o.t, json: full ? o.json : o.json.slice(0, 800) })),
|
|
1942
|
+
};
|
|
1943
|
+
if (t.err && t.errMsg) out.callsNote = '这一轮没有成功拿到模型回复:' + t.errMsg;
|
|
1944
|
+
return out;
|
|
1945
|
+
}
|
|
1946
|
+
// opencode:同 zcode —— 扫描时已把整轮(用户输入 / 正文 / 工具 / 逐次调用 / 事件流)解析进内存,直接读
|
|
1947
|
+
if (src.kind === 'opencode') return opencodeEntryContent(src, full);
|
|
1948
|
+
// kilo:与 opencode 同一条路(复用同一份核心,只按 kilo 的 id 前缀 / 详情版本号取内存 turns)
|
|
1949
|
+
if (src.kind === 'kilo') return kiloEntryContent(src, full);
|
|
1950
|
+
// hermes:同 opencode(DB 主源 + agent.log 逐次行都在扫描时并进了内存 turns)
|
|
1951
|
+
if (src.kind === 'hermes') return hermesEntryContent(src, full);
|
|
1952
|
+
// devindb:同 opencode/hermes(整轮解析都在扫描时做完,直接读内存 turns)
|
|
1953
|
+
if (src.kind === 'devindb') return devinDbEntryContent(src, full);
|
|
1954
|
+
if (src.kind === 'codearts') {
|
|
1955
|
+
// 扫描时已把每轮的用户文本与逐次调用存进 files 数据,直接读内存
|
|
1956
|
+
const t = files.get(src.file)?.data?.turns?.[src.turn];
|
|
1957
|
+
return t ? { user: t.user || '', assistant: '', tools: [], calls: t.calls || [], v: version } : null;
|
|
1958
|
+
}
|
|
1959
|
+
if (src.kind === 'trace') {
|
|
1960
|
+
const j = JSON.parse(fs.readFileSync(src.file, 'utf8'));
|
|
1961
|
+
const t = j.trace || {};
|
|
1962
|
+
const tools = (j.spans || []).filter(sp => sp.toolName).slice(0, 100).map(sp => {
|
|
1963
|
+
const fi = {}, fo = {};
|
|
1964
|
+
return {
|
|
1965
|
+
name: sp.toolName, dur: sp.duration || 0,
|
|
1966
|
+
error: sp.error || sp.status !== 'ok' ? (sp.error || sp.status) : null,
|
|
1967
|
+
input: trunc(sp.toolInput, full, fi), inputTrunc: !!fi.t, output: trunc(sp.toolOutput, full, fo), outputTrunc: !!fo.t,
|
|
1968
|
+
};
|
|
1969
|
+
});
|
|
1970
|
+
// generation span = 每次 LLM 调用;源头只有耗时/状态,无单次 token
|
|
1971
|
+
const calls = (j.spans || []).filter(sp => sp.type === 'generation').slice(0, 100).map(sp => ({
|
|
1972
|
+
model: '', dur: sp.duration || 0,
|
|
1973
|
+
error: sp.error || sp.status !== 'ok' ? (sp.error || sp.status) : null,
|
|
1974
|
+
}));
|
|
1975
|
+
// 按 parentId 组装两层(workflow -> span),平铺输出带 depth 供前端缩进
|
|
1976
|
+
const spans = j.spans || [];
|
|
1977
|
+
const byParent = new Map();
|
|
1978
|
+
for (const sp of spans) {
|
|
1979
|
+
const p = sp.parentId || '';
|
|
1980
|
+
if (!byParent.has(p)) byParent.set(p, []);
|
|
1981
|
+
byParent.get(p).push(sp);
|
|
1982
|
+
}
|
|
1983
|
+
const tree = [];
|
|
1984
|
+
const walk = (pid, depth) => {
|
|
1985
|
+
for (const sp of byParent.get(pid) || []) {
|
|
1986
|
+
if (tree.length >= 200) return;
|
|
1987
|
+
tree.push({
|
|
1988
|
+
depth, name: String(sp.name || sp.toolName || sp.type || 'span').slice(0, 120),
|
|
1989
|
+
type: sp.type || '', dur: sp.duration || 0,
|
|
1990
|
+
error: sp.error || (sp.status && sp.status !== 'ok' ? sp.status : null),
|
|
1991
|
+
});
|
|
1992
|
+
if (depth < 3 && (sp.spanId || sp.id)) walk(sp.spanId || sp.id, depth + 1); // span 的唯一键是 spanId(不是 id)
|
|
1993
|
+
}
|
|
1994
|
+
};
|
|
1995
|
+
walk('', 0);
|
|
1996
|
+
// parentId 指向不存在根时的兜底(防整棵丢失)
|
|
1997
|
+
if (!tree.length && spans.length) for (const sp of spans.slice(0, 200)) tree.push({ depth: 0, name: String(sp.name || sp.type || 'span').slice(0, 120), type: sp.type || '', dur: sp.duration || 0, error: sp.error || null });
|
|
1998
|
+
return { user: String(t.prompt || ''), assistant: '', tools, calls, spans: tree, v: version };
|
|
1999
|
+
}
|
|
2000
|
+
// traedb:同 zcode —— 扫描时已把整轮(用户输入 / 正文 / 工具 / 逐次调用 / 事件流)解析进内存,直接读
|
|
2001
|
+
if (src.kind === 'traedb') return traeDbEntryContent(src, full);
|
|
2002
|
+
// minimax(Mavis 本地运行时):详情不走内存——重读 messages.jsonl 按 src.turn 过滤,
|
|
2003
|
+
// 正文分段、工具按 toolCallId 回填、calls 逐次明细(usage 取 assistant 行 message.usage)。
|
|
2004
|
+
if (src.kind === 'minimax') return mavisEntryContent(src, full);
|
|
2005
|
+
// openclaw:同 minimax —— 重读转录按 src.turn 过滤,工具按 toolCallId 回填,calls 带逐次 usage
|
|
2006
|
+
if (src.kind === 'openclaw') return openclawEntryContent(src, full);
|
|
2007
|
+
// tracecode / tracework:详情从 renderer.log 重解析指定 sessionId 的全部事件
|
|
2008
|
+
//(扫描只存了 entry 概要,详情页要逐事件展开;与 codearts/codex 同款「重读文件按 turn 过滤」)
|
|
2009
|
+
if (src.kind === 'tracecode' || src.kind === 'tracework') {
|
|
2010
|
+
return traeEntryContent(src, full) || { user: '', assistant: '', tools: [] };
|
|
2011
|
+
}
|
|
2012
|
+
if (src.kind === 'mimocode') return mimocodeEntryContent(src, full);
|
|
2013
|
+
} catch (e) { return { user: '[内容读取失败: ' + e.message + ']', assistant: '', tools: [] }; }
|
|
2014
|
+
return null;
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
// ---------------- I8 单轮「复现包」原始日志片段提取 ----------------
|
|
2018
|
+
// 复现包要把「这一轮在原始日志里长什么样」一并导出。能精确切出原始行的前提是:source 是**单文件逐行 jsonl**,
|
|
2019
|
+
// 且「哪一行属于这一轮」的判定能复用解析器的开轮逻辑(见下方 turnStartIdx)。其余 kind 的源不是单文本文件
|
|
2020
|
+
// (SQLite / 多帧 zstd / 内存转录 / 多文件),无法切出「该轮片段」——那就诚实标 extractable:false 并附原始路径,
|
|
2021
|
+
// 解析结果(entryContent)仍是完整、可信的。绝不为了「都有片段」而编造。
|
|
2022
|
+
// 返回:{ kind, file, extractable, lines|null, note }。lines 是**原始文本行数组**(trim 后非空、未解析),
|
|
2023
|
+
// 下标与磁盘行号对齐(1-based 行号 = 下标+1),复现时直接 cp 这一段即可。
|
|
2024
|
+
const REPRO_JSONL_KINDS = new Set(['claude', 'codex', 'buddyjsonl', 'doubao', 'minimax', 'kimi', 'copilot', 'generic-jsonl', 'openclaw']);
|
|
2025
|
+
function reproRaw(src, e) {
|
|
2026
|
+
const out = { kind: src.kind, file: src.file || null, extractable: false, lines: null, note: '' };
|
|
2027
|
+
const fstate = files.get(src.file);
|
|
2028
|
+
if (!fstate) {
|
|
2029
|
+
out.note = '源文件未载入内存(可能尚未扫描或已被淘汰):无法切出原始片段;原始路径见 file,完整解析结果见 parsed.json';
|
|
2030
|
+
return out;
|
|
2031
|
+
}
|
|
2032
|
+
if (!REPRO_JSONL_KINDS.has(src.kind)) {
|
|
2033
|
+
out.note = src.kind === 'cline'
|
|
2034
|
+
? 'Cline 的源是**一份整份 JSON**(每次落盘整体重写),不是「一个轮一行」——按行切不出轮边界;' +
|
|
2035
|
+
'复现请给 file 字段那整个 messages.json,本轮解析结果见 parsed.json。' +
|
|
2036
|
+
'⚠️ 那份文件里是完整对话正文 + system_prompt,对外贴之前先自己过一眼。'
|
|
2037
|
+
: src.kind === 'buddyext'
|
|
2038
|
+
? 'CodeBuddy 扩展的一个轮 = 会话 index.json 里 requests[] 的**一项**,正文散在同一目录的 ' +
|
|
2039
|
+
'messages/<消息 id>.json 里(一条一个文件),不是「一个轮一行」——按行切不出轮边界;' +
|
|
2040
|
+
'复现请给 file 那整份 index.json 加它同目录 messages/ 下被该轮 requests[].messages 引用的那些文件' +
|
|
2041
|
+
'(本轮的消息 id 见 parsed.json 的 calls/tools)。' +
|
|
2042
|
+
'⚠️ 那些文件里是完整对话正文,对外贴之前先自己过一眼。'
|
|
2043
|
+
: '该 agent(' + src.kind + ')的源不是单文件 JSONL 文本日志(数据库 / 多帧压缩 / 内存转录 / 多文件),' +
|
|
2044
|
+
'无法切出「该轮原始片段」;完整解析结果见 parsed.json(file 字段给出原始路径)。';
|
|
2045
|
+
return out;
|
|
2046
|
+
}
|
|
2047
|
+
let text;
|
|
2048
|
+
try { text = fs.readFileSync(src.file, 'utf8'); } catch (e) { out.note = '读取源文件失败:' + e.message; return out; }
|
|
2049
|
+
// 复用 readCompleteLines:只处理「完整行」,与扫描口径一致(避免把半截尾行算进来)。
|
|
2050
|
+
const rd = readCompleteLines(src.file, 0, text.length);
|
|
2051
|
+
const rawLines = rd ? rd.lines : text.split('\n');
|
|
2052
|
+
// 找到这一轮的起止行([start, end) 下标,end 不含)。turn 在 src.turn 里是轮序号 idx(整数)。
|
|
2053
|
+
const span = turnStartIdx(src.kind, rawLines, src.turn, e);
|
|
2054
|
+
if (!span) { out.note = '按开轮判定未能在该源文件中定位到第 ' + src.turn + ' 轮(可能源已轮转/截断):完整解析结果见 parsed.json'; return out; }
|
|
2055
|
+
const lines = [];
|
|
2056
|
+
for (let i = span.start; i < span.end; i++) if (rawLines[i].trim()) lines.push(rawLines[i]);
|
|
2057
|
+
out.extractable = true;
|
|
2058
|
+
out.lines = lines;
|
|
2059
|
+
out.note = '本文件第 ' + (span.start + 1) + '–' + span.end + ' 行(行号 1-based,与磁盘一致;空行已跳过);' +
|
|
2060
|
+
'这些行按「与解析器完全相同的开轮判定」归到这一轮(从这一轮的用户输入起到下一轮开始前,' +
|
|
2061
|
+
'含该轮内模型响应、工具调用与 tool_result 等全部交互),可直接贴出复现。';
|
|
2062
|
+
return out;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// 给定 kind + 原始行数组 + 目标轮 idx,返回该轮在 rawLines 中的 [start, end) 下标。
|
|
2066
|
+
// 复用各解析器的开轮判定函数(claudeUserText / codexUserText / …),保证切出来的片段与扫描归到的轮一一对应。
|
|
2067
|
+
// 仅在 REPRO_JSONL_KINDS 内被调用。
|
|
2068
|
+
function turnStartIdx(kind, lines, targetIdx, e) {
|
|
2069
|
+
const starts = []; // 每个开轮行在 lines 中的下标
|
|
2070
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2071
|
+
const line = lines[i];
|
|
2072
|
+
if (!line.trim()) continue;
|
|
2073
|
+
let j; try { j = JSON.parse(line); } catch { continue; }
|
|
2074
|
+
if (isTurnStart(kind, j, e)) starts.push(i);
|
|
2075
|
+
}
|
|
2076
|
+
if (!starts.length || targetIdx < 0 || targetIdx >= starts.length) return null;
|
|
2077
|
+
const s = starts[targetIdx];
|
|
2078
|
+
const e2 = targetIdx + 1 < starts.length ? starts[targetIdx + 1] : lines.length;
|
|
2079
|
+
return { start: s, end: e2 };
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// 与解析器「开轮」完全同一套判定(不含命令/工具结果这类跳过,仅用于定位轮起点)。
|
|
2083
|
+
function isTurnStart(kind, j, e) {
|
|
2084
|
+
if (kind === 'claude') {
|
|
2085
|
+
if (j.type !== 'user') return false;
|
|
2086
|
+
const c = j.message && j.message.content;
|
|
2087
|
+
if (claudeIsToolResultOnly(c)) return false;
|
|
2088
|
+
const t0 = claudeUserText(c);
|
|
2089
|
+
if (!t0) return false;
|
|
2090
|
+
return !claudeIsCommandText(t0);
|
|
2091
|
+
}
|
|
2092
|
+
if (kind === 'codex') return !!(j.payload && j.payload.type === 'task_started');
|
|
2093
|
+
if (kind === 'buddyjsonl') return j.type === 'message' && j.role === 'user' && !!buddyUserText(j.content);
|
|
2094
|
+
if (kind === 'doubao') return j.role === 'user' && !!toText(j.content);
|
|
2095
|
+
if (kind === 'minimax') {
|
|
2096
|
+
const msg = j.message || {};
|
|
2097
|
+
const role = msg.role || (msg.toolCallId ? 'toolResult' : null);
|
|
2098
|
+
return role === 'user' && !!mavisUserText(msg.content);
|
|
2099
|
+
}
|
|
2100
|
+
// openclaw:追加式会话树,开轮行 = type:"message" 且 message.role="user" 且正文非空
|
|
2101
|
+
if (kind === 'openclaw') {
|
|
2102
|
+
if (j.type !== 'message') return false;
|
|
2103
|
+
const msg = j.message || {};
|
|
2104
|
+
return msg.role === 'user' && !!openclawUserText(msg.content);
|
|
2105
|
+
}
|
|
2106
|
+
if (kind === 'kimi') return j.type === 'turn.prompt';
|
|
2107
|
+
if (kind === 'copilot') return j.type === 'user.message' && copilotTurnStart(j);
|
|
2108
|
+
if (kind === 'generic-jsonl') {
|
|
2109
|
+
const agent = e && e.agent;
|
|
2110
|
+
const conf = agent && agentConfs.get(agent);
|
|
2111
|
+
return genericTurnStart(j, conf && conf.rules);
|
|
2112
|
+
}
|
|
2113
|
+
return false;
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
// ---------------- 项目归一化 + 时间窗口(跨端共享,需求书 R12) ----------------
|
|
2117
|
+
// 同一套口径要在两处跑:服务端(按项目/范围过滤窗口)与页面(项目下拉分组、直播增量该不该进窗)。
|
|
2118
|
+
// 页面是单文件 HTML、共享不了模块 —— 以前两边各抄一份、靠注释提醒人工同步,漂了就是静默的
|
|
2119
|
+
// 筛选错位(点项目 0 条、「今天」各算各的)。现在**唯一来源是下面这段字符串**(R12):
|
|
2120
|
+
// 服务端自己 eval 一份用;GET / 时把它原样注入页面的 __AGENT_LOG_SHARED__ 占位符,页面不再持有副本。
|
|
2121
|
+
// 于是「改了一处忘了另一处」在结构上不可能 —— 只剩一处。test/shared-snippet-test.mjs 守住
|
|
2122
|
+
// 「页面不许再出现本地副本」这条;改本段代码等于同时改两端,无需同步、也无 rev 义务(不落盘)。
|
|
2123
|
+
const SHARED_JS = `
|
|
2124
|
+
// 同一个项目会被各家 agent 写成不同样子(\`F:\\centos\\next-admin\` / \`f:\\centos\\next-admin\` /
|
|
2125
|
+
// cursor 那种盘符与分隔符全退化成 \`-\` 的 slug)。去掉所有非字母数字再比就全都归一了,
|
|
2126
|
+
// 不必去猜 slug 的原路径。条目不落盘原文改写 —— 归一只作用在「项目」这个筛选维度。
|
|
2127
|
+
const projNorm = p => String(p || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
2128
|
+
const projKey = p => projNorm(p) || String(p); // 名字里一个字母数字都没有时退回原名
|
|
2129
|
+
// 符号 range(today/7d/30d)→ 窗口起点的 ms(本地时区自然日 0 点);没传/不认识 → null(不限,
|
|
2130
|
+
// 宁可全给,也不要静默返回一张空表)。页面传符号而不是算好的时间戳:标签页挂过午夜后「今天」得跟着走,
|
|
2131
|
+
// 每次用到现算。用 setDate 递减而不是减 86400000 —— 有夏令时的时区里一天不是恒等于 24 小时。
|
|
2132
|
+
const rangeDayStart = range => {
|
|
2133
|
+
const n = { today: 1, '7d': 7, '30d': 30 }[range];
|
|
2134
|
+
if (!n) return null;
|
|
2135
|
+
const d = new Date(); d.setHours(0, 0, 0, 0);
|
|
2136
|
+
d.setDate(d.getDate() - (n - 1));
|
|
2137
|
+
return d.getTime();
|
|
2138
|
+
};
|
|
2139
|
+
`;
|
|
2140
|
+
// 服务端自己的一份:与注入页面的是同一个字符串,不存在第二份实现。
|
|
2141
|
+
// 语法写错了会在这里当场抛出来(启动即失败)—— 这就是 R12 要的「主动发现」。
|
|
2142
|
+
const { projNorm, projKey, rangeDayStart } = new Function(SHARED_JS + '\nreturn { projNorm, projKey, rangeDayStart };')();
|
|
2143
|
+
|
|
2144
|
+
// ---------------- 时间范围(C2) ----------------
|
|
2145
|
+
// 页面传的是**符号**(today / 7d / 30d)而不是算好的时间戳:标签页挂过午夜之后,「今天」得跟着走。
|
|
2146
|
+
// 每次请求现算边界,于是「页面上写的范围」和「服务端实际过滤的范围」永远是同一个(传时间戳就会错开)。
|
|
2147
|
+
// 边界一律取**本地时区的自然日**:今天 = 当地 0 点 → 现在;近 7 天 = 今天 + 前 6 个自然日(含今天共 7 天)。
|
|
2148
|
+
// 窗口起点的算法在共享片段 rangeDayStart 里(见上,与页面同一份代码);这里只补上界与展示标签。
|
|
2149
|
+
const RANGE_LABEL = { today: '今天', '7d': '近 7 天', '30d': '近 30 天' };
|
|
2150
|
+
function rangeBounds(range) {
|
|
2151
|
+
const from = rangeDayStart(range);
|
|
2152
|
+
if (from == null) return null; // 不认识的取值 = 不限(宁可全给,也不要静默返回一张空表)
|
|
2153
|
+
return { from, to: Date.now(), label: RANGE_LABEL[range] };
|
|
2154
|
+
}
|
|
2155
|
+
// 显式日期区间(YYYY-MM-DD,本地自然日):页面「用量统计」的 from→to 选择器走这里。
|
|
2156
|
+
// 与符号 range 同一套本地自然日口径;两者都给时以显式区间为准(符号只是「没选区间时的默认」)。
|
|
2157
|
+
function customBounds(from, to) {
|
|
2158
|
+
const p = s => /^(\d{4})-(\d{2})-(\d{2})$/.exec(s || '');
|
|
2159
|
+
const a = p(from), b = p(to);
|
|
2160
|
+
if (!a || !b) return null;
|
|
2161
|
+
const dayStart = m => new Date(+m[1], +m[2] - 1, +m[3]).getTime();
|
|
2162
|
+
const dayEnd = m => { const d = new Date(+m[1], +m[2] - 1, +m[3]); d.setDate(d.getDate() + 1); return d.getTime() - 1; };
|
|
2163
|
+
const lo = Math.min(dayStart(a), dayStart(b)), hi = Math.max(dayEnd(a), dayEnd(b)); // 起止选反了也容错
|
|
2164
|
+
const loS = lo === dayStart(a) ? from : to, hiS = lo === dayStart(a) ? to : from;
|
|
2165
|
+
return { from: lo, to: hi, label: loS + ' ~ ' + hiS };
|
|
2166
|
+
}
|
|
2167
|
+
// 按天分桶用**同一套本地自然日**口径(否则「今天」这个桶会跨到昨天去,柱状图与范围筛选对不上)
|
|
2168
|
+
const dayKey = t => { const d = new Date(t); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); };
|
|
2169
|
+
// I16/D2:**时间未知**的轮(`timeUnknown === true`,time 落 0)不进任何按时间的口径。
|
|
2170
|
+
// 它们原先(回落 updated_at 时)是「假装有一个时间」,现在如实为 0 —— 若不管,1970-01-01 会冒出
|
|
2171
|
+
// 一个上千条的巨大桶、把按天柱状图的横轴拉成 56 年,而那个桶里的轮其实分布在好几个月里。
|
|
2172
|
+
// · 时间范围筛选(rangeBounds / customBounds)下**天然落到区间外**,无需特判;
|
|
2173
|
+
// · 按天分桶要显式跳过(否则 1970 那个桶);
|
|
2174
|
+
// · 排序统一用 `time || 0` 之后它们自然沉底(页面如实标「时间未知」)。
|
|
2175
|
+
const hasTime = e => (e.time || 0) > 0 && !e.timeUnknown;
|
|
2176
|
+
|
|
2177
|
+
// ---------------- 归档(调度与 CLI 入口,核心在 archive.mjs) ----------------
|
|
2178
|
+
// 装配:entries / srcs 是扫描链的共享 Map,dayKey 与 entryContent 都在本文件(详情要回读源文件),
|
|
2179
|
+
// 由这里注入 —— archive.mjs 不反向 import 主文件,避免循环依赖。
|
|
2180
|
+
|
|
2181
|
+
// ---------------- 全文搜索(调度与 CLI 入口,核心在 search.mjs) ----------------
|
|
2182
|
+
// 装配同归档:files 是扫描链的共享 Map(索引靠它判「源变没变」,**不自己 statSync** ——
|
|
2183
|
+
// cursor / gemini 的源标识是伪路径,stat 必然抛,见 search.mjs 里 srcState 那段);
|
|
2184
|
+
// maxChars:每条轮次索引的正文上限(默认 8000)。只做环境变量覆盖,不进 config.json ——
|
|
2185
|
+
// 它不是用户意图,是「这台机器内存吃不吃得消」的运维旋钮,调它的人本来就在改启动参数。
|
|
2186
|
+
const SEARCH_MAX_CHARS = Number(process.env.AGENT_LOG_SEARCH_MAX_CHARS || 0) > 0 ? Number(process.env.AGENT_LOG_SEARCH_MAX_CHARS) : 8000;
|
|
2187
|
+
|
|
2188
|
+
// ---------------- 装配(Step 2 收口:三处注入合成一个入口,按 MODE 定写闸) ----------------
|
|
2189
|
+
// 三处注入式装配(archive / search / 解析器的落盘钩子)原先散在模块级,且读的是加载时就定死的 LIB。
|
|
2190
|
+
// hosted 模式要在加载**之后**才切过来,那时 readOnly 与写钩子必须跟着翻 —— 所以合成一个函数,
|
|
2191
|
+
// start() 切完 MODE 再调一遍。两个 init 都是 `DEPS = d` 的一句赋值,重复调用是无副作用的。
|
|
2192
|
+
//
|
|
2193
|
+
// 写闸的判据(三态各一行,**不要合并简化**):
|
|
2194
|
+
// lib (MCP) —— readOnly:true、落盘钩子空:第二个读者绝不跟常驻服务抢索引分片的写者身份
|
|
2195
|
+
// cli/hosted —— readOnly:false、钩子接回 scheduleSave:写路径全开
|
|
2196
|
+
function assemble() {
|
|
2197
|
+
initArchive({ dataDir: DATA_DIR, entries, srcs, entryContent, dayKey, parserRev: PARSER_REV });
|
|
2198
|
+
initSearch({ dataDir: DATA_DIR, entries, srcs, files, entryContent, parserRev: PARSER_REV, maxChars: SEARCH_MAX_CHARS, readOnly: MODE === 'lib' });
|
|
2199
|
+
setSaveHook(MODE === 'lib' ? () => {} : scheduleSave);
|
|
2200
|
+
}
|
|
2201
|
+
// 模块加载时装一遍。**lib 模式就到这儿为止**(它不经过 start()),而这一遍的结果与 Step 1 前逐字一致:
|
|
2202
|
+
// 同一组 DEPS、同样 readOnly:true、同样空钩子。cli 模式这一遍也照旧,start() 里那遍是幂等的重装。
|
|
2203
|
+
assemble();
|
|
2204
|
+
|
|
2205
|
+
// ---------------- 常驻定时器登记表(Step 2 副作用收口) ----------------
|
|
2206
|
+
// 服务里所有「挂上就再也不停」的定时器都登记在这里,stop() 一口气全撤。
|
|
2207
|
+
// 为什么不止登记 §5 点名的那两个(saveIndex / checkIdle):hosted 的 stop() 语义是**只停循环、不退进程**
|
|
2208
|
+
// (退出权在宿主手里),漏掉扫描自续期 / 归档轮 / 搜索轮里的任何一个,宿主那边「已经停掉的服务」
|
|
2209
|
+
// 就还在后台扫盘写索引 —— 正是这次收口要消灭的那类副作用。登记面 = 全部常驻定时器。
|
|
2210
|
+
// 语义与裸 setInterval/setTimeout 完全一致(同一个定时器池、同样的回调),多出来的只是「记一笔好撤销」。
|
|
2211
|
+
const timers = new Set();
|
|
2212
|
+
// 一次性定时器**自撤登记**:回调一进来先把自己从表里摘掉。不这么做的话,scanTimer 那种 3 秒自续期的
|
|
2213
|
+
// 一次性定时器会在表里留下一串跑完的句柄 —— 那不是收口,是换个地方漏(一天攒两万条)。
|
|
2214
|
+
function after(ms, fn) { const t = setTimeout(() => { timers.delete(t); fn(); }, ms); timers.add(t); return t; }
|
|
2215
|
+
function every(fn, ms) { const t = setInterval(fn, ms); timers.add(t); return t; }
|
|
2216
|
+
function cancel(t) { if (!t) return; try { clearTimeout(t); clearInterval(t); } catch {} timers.delete(t); }
|
|
2217
|
+
function clearTimers() { for (const t of [...timers]) cancel(t); timers.clear(); if (saveTimer) saveTimer = null; }
|
|
2218
|
+
|
|
2219
|
+
// 服务内的后台增量建索引。节奏与归档**刻意不同**:归档冻的是「已结束且早于今天」的历史,
|
|
2220
|
+
// 判据「今天跑过没」就够;索引跟的是**正在长的活文件**(会话每几秒追加一行),
|
|
2221
|
+
// 按天记账会让整天的新内容搜不到,所以改成「首轮 + 定时增量」两条线。
|
|
2222
|
+
// 首轮:等首扫落停再动(扫描与建索引都在读同一批源文件,撞一起是白抢 CPU)。
|
|
2223
|
+
// 之后:每 SEARCH_AUTO_CHECK_MS 增量跑一轮,只重建签名变了的源,稳态下就是「今天动过的那几个会话」。
|
|
2224
|
+
const SEARCH_AUTO_DELAY_MS = 60000;
|
|
2225
|
+
const SEARCH_AUTO_CHECK_MS = 10 * 60000;
|
|
2226
|
+
// AGENT_LOG_SEARCH_DELAY_MS:临时覆盖首轮延迟(不落盘),调试/测试用 ——
|
|
2227
|
+
// 不然验一次「服务会不会自己建索引」要等 60 秒(与 AGENT_LOG_ARCHIVE_DELAY_MS 同一套理由)。
|
|
2228
|
+
const SEARCH_AUTO_DELAY_ENV = Number(process.env.AGENT_LOG_SEARCH_DELAY_MS || 0) > 0 ? Number(process.env.AGENT_LOG_SEARCH_DELAY_MS) : 0;
|
|
2229
|
+
// 一轮最多处理多少个源文件。按**文件**封顶而不是按条目:一个文件的记录要么整体换掉、要么原样不动,
|
|
2230
|
+
// 从中间切开会把一份记录留成半新半旧,而它下一轮又因为签名已更新而跳过 —— 会永久错下去。
|
|
2231
|
+
const SEARCH_AUTO_FILES = 200;
|
|
2232
|
+
let searchRunning = false;
|
|
2233
|
+
|
|
2234
|
+
async function autoSearchTick(force, maxFiles) {
|
|
2235
|
+
if (searchRunning) return null; // 重入闸:定时轮与手动轮不叠着跑
|
|
2236
|
+
searchRunning = true;
|
|
2237
|
+
try {
|
|
2238
|
+
// 扫描还在跑就先等它。索引覆盖的是「内存里的条目」,扫到一半就开建,建出来的是一份**残缺**的
|
|
2239
|
+
// 索引:后面才扫出来的条目这一轮全漏掉,要等下一轮(10 分钟)才补得上 —— 而首轮恰恰是
|
|
2240
|
+
// 用户最可能搜的时候。踩过:首轮延迟压到 2s 时实测只覆盖 1149/3564。
|
|
2241
|
+
// 等的是同进程的状态位、不是猜时间;scanAllChunked 会主动让出事件循环,所以这个轮询不会把它卡死。
|
|
2242
|
+
// 上限 60s 兜底:万一状态位因故没落回来,宁可建一份残缺的也别永远不建。
|
|
2243
|
+
for (let i = 0; i < 120 && scanning; i++) await new Promise(r => setTimeout(r, 500));
|
|
2244
|
+
// 载入(幂等、只做一次)在 searchRun 内部自己管,这里不用管。
|
|
2245
|
+
// maxFiles 缺省用稳态那一档;首轮显式传 0 = 不限量,见 scheduleAutoSearch。
|
|
2246
|
+
const cap = maxFiles == null ? SEARCH_AUTO_FILES : maxFiles;
|
|
2247
|
+
let stat = await searchRun({ force: !!force, maxFiles: force ? 0 : cap, log: m => log('[search] ' + m) });
|
|
2248
|
+
// 补跑到稳定。首扫是**分片让出**的,而建索引取的是「此刻内存里的条目」快照 —— 两者叠在一起时,
|
|
2249
|
+
// 一轮建完往往还有一批源是这之后才扫出来的(实测首轮 389 个源,最终是 436 个),
|
|
2250
|
+
// 漏掉的那部分要等下一轮(10 分钟)才补得上,而首轮恰恰是用户最可能搜的时候。
|
|
2251
|
+
// 稳态下第二轮的 indexed 必然是 0(没有源的签名变过),这个 for 一轮就退出,不额外花时间。
|
|
2252
|
+
for (let i = 0; i < 4; i++) {
|
|
2253
|
+
const more = await searchRun({ force: false, maxFiles: 0, log: m => log('[search] ' + m) });
|
|
2254
|
+
stat = {
|
|
2255
|
+
...more, files: Math.max(stat.files, more.files),
|
|
2256
|
+
indexed: stat.indexed + more.indexed, skipped: stat.skipped + more.skipped,
|
|
2257
|
+
unreadable: stat.unreadable + more.unreadable, entries: stat.entries + more.entries,
|
|
2258
|
+
chars: stat.chars + more.chars, cut: stat.cut + more.cut, noState: stat.noState + more.noState,
|
|
2259
|
+
savedOk: more.savedOk !== false, bytes: (stat.bytes || 0) + (more.bytes || 0),
|
|
2260
|
+
};
|
|
2261
|
+
log('[search] 补跑第 ' + (i + 1) + ' 轮:' + more.files + ' 个源 / 重建 ' + more.indexed +
|
|
2262
|
+
' / 跳过 ' + more.skipped + ' / noState ' + more.noState + ' / 抽正文 ' + more.entries);
|
|
2263
|
+
if (!more.indexed) break; // 一轮下来一个源都不用建 = 追平了
|
|
2264
|
+
}
|
|
2265
|
+
if (stat.indexed || stat.skipped || stat.unreadable || stat.pruned) {
|
|
2266
|
+
log('[search] 全文索引:' + stat.files + ' 个源 / 重建 ' + stat.indexed + ' / 跳过 ' + stat.skipped +
|
|
2267
|
+
' / noState ' + stat.noState + ' / 抽正文 ' + stat.entries + ' 条' +
|
|
2268
|
+
(stat.unreadable ? ' / 读不出正文 ' + stat.unreadable : '') +
|
|
2269
|
+
// 剪枝:源不在本机的旧记录(换平台 / 换机器 / 日志被产品删了)。这条只会在换环境后的
|
|
2270
|
+
// 第一轮出现,是**正常自愈**,不是数据丢了 —— 写明白免得看见「可查条数掉了」以为坏了。
|
|
2271
|
+
(stat.pruned ? ' / 剪掉 ' + stat.pruned + ' 条源不在本机的旧记录(换平台或日志已删,下次扫到会重建)' : '') +
|
|
2272
|
+
(stat.capped ? '(本轮有截断,下一轮接着来)' : '') +
|
|
2273
|
+
',可查 ' + stat.inIndex + ' 条,' + fmtBytes(stat.bytes || 0));
|
|
2274
|
+
}
|
|
2275
|
+
return stat;
|
|
2276
|
+
} catch (e) { console.error('[search]', e.message); return null; }
|
|
2277
|
+
finally { searchRunning = false; }
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
function scheduleAutoSearch() {
|
|
2281
|
+
const delay = SEARCH_AUTO_DELAY_ENV || SEARCH_AUTO_DELAY_MS;
|
|
2282
|
+
after(delay, () => {
|
|
2283
|
+
// 首轮**不限量**(maxFiles: 0)。理由:一台刚装的机器有几百个源,按稳态那档 200/轮要跑半小时,
|
|
2284
|
+
// 而这段时间里用户搜什么都是「还没索引到」。40ms 让出一次事件循环保证页面不卡,
|
|
2285
|
+
// 所以「一次跑完那 100 多秒」是可接受的取舍(与归档首轮跑几分钟同一个量级、同一种理由)。
|
|
2286
|
+
autoSearchTick(false, 0);
|
|
2287
|
+
every(() => autoSearchTick(false, SEARCH_AUTO_FILES), SEARCH_AUTO_CHECK_MS);
|
|
2288
|
+
});
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
// 服务内的每日自动归档。为什么不是「每天定时那一下就完」:
|
|
2292
|
+
// 1) 服务可能一天被拉起很多次(hook 每次开会话都 --ensure),所以判据是「今天跑过没」而不是「距上次多久」;
|
|
2293
|
+
// 2) 首次跑要在首扫之后 —— 归档冻的是内存里的条目,扫描还没把源文件读进来时跑等于白跑。
|
|
2294
|
+
// 失败不抛:归档是附加能力,出错不能把扫描/服务带下水。
|
|
2295
|
+
// maxPerRun:后台轮一次最多 4000 条(entryContent 要逐条回读源文件),跑不完下次接着跑,
|
|
2296
|
+
// 免得一台积压几万条历史的机器上,第一次自动归档把事件循环占上几分钟。
|
|
2297
|
+
const ARCHIVE_AUTO_LIMIT = 4000;
|
|
2298
|
+
const ARCHIVE_AUTO_DELAY_MS = 90000; // 首扫通常几秒内完成,留 90s 缓冲
|
|
2299
|
+
// AGENT_LOG_ARCHIVE_DELAY_MS:临时覆盖首次自动归档的延迟(不落盘),调试/测试用 ——
|
|
2300
|
+
// 不然验一次「服务会不会自己归档」要等 90 秒(与 AGENT_LOG_IDLE_MS 同一套理由)。
|
|
2301
|
+
const ARCHIVE_AUTO_DELAY_ENV = Number(process.env.AGENT_LOG_ARCHIVE_DELAY_MS || 0) > 0 ? Number(process.env.AGENT_LOG_ARCHIVE_DELAY_MS) : 0;
|
|
2302
|
+
const ARCHIVE_AUTO_CHECK_MS = 3 * 3600e3; // 每 3h 看一眼「今天跑过没」(一天最多真正跑一次)
|
|
2303
|
+
let archiveAutoDay = ''; // 本次进程内已自动归档过的自然日
|
|
2304
|
+
let archiveRunning = false;
|
|
2305
|
+
|
|
2306
|
+
async function autoArchiveTick(force) {
|
|
2307
|
+
const today = dayKey(Date.now());
|
|
2308
|
+
if (!force && archiveAutoDay === today) return null;
|
|
2309
|
+
if (archiveRunning) return null;
|
|
2310
|
+
archiveRunning = true;
|
|
2311
|
+
archiveAutoDay = today; // 先记账再跑:跑失败了也不该每 3h 重试一次(手动 --archive 随时能补)
|
|
2312
|
+
try {
|
|
2313
|
+
const stat = await archiveRun({ maxPerRun: ARCHIVE_AUTO_LIMIT, skip: archiveCfg.skip, log: m => log('[archive] ' + m) });
|
|
2314
|
+
if (stat.failed) console.error('[archive] 有 ' + stat.failed + ' 个日期文件写失败(详见上方日志)');
|
|
2315
|
+
if (stat.archived || stat.updated) {
|
|
2316
|
+
log('[archive] 自动归档:新冻 ' + stat.archived + ' 条 / 更新 ' + stat.updated +
|
|
2317
|
+
' 条' + (stat.capped ? '(本轮有截断,下次接着跑)' : '') + ',写入 ' + fmtBytes(stat.bytes));
|
|
2318
|
+
} if (archiveCfg.maxDays || archiveCfg.maxMB) pruneArchive({ maxDays: archiveCfg.maxDays, maxMB: archiveCfg.maxMB, log: m => log('[archive] ' + m) });
|
|
2319
|
+
return stat;
|
|
2320
|
+
} catch (e) { console.error('[archive]', e.message); return null; }
|
|
2321
|
+
finally { archiveRunning = false; }
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
function scheduleAutoArchive() {
|
|
2325
|
+
if (!archiveCfg.enabled) return; // 关掉自动归档只影响这里;--archive 手动跑不受影响
|
|
2326
|
+
const delay = ARCHIVE_AUTO_DELAY_ENV || ARCHIVE_AUTO_DELAY_MS;
|
|
2327
|
+
after(delay, () => { autoArchiveTick(false); every(() => autoArchiveTick(false), ARCHIVE_AUTO_CHECK_MS); });
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
// R10:合并单值键与多值键 —— agents= / projects=(逗号分隔合集)优先;没传多值键时回落单值 agent= / project=(语义不变)。
|
|
2331
|
+
const multiParam = (u, single, multi) => {
|
|
2332
|
+
const m = (u.searchParams.get(multi) || '').trim();
|
|
2333
|
+
return m || (u.searchParams.get(single) || '');
|
|
2334
|
+
};
|
|
2335
|
+
// I15 工作指纹:从查询串里取 branch= / permMode= / cliVer=(与 agent= / project= 一样,单值键,
|
|
2336
|
+
// 多值走逗号分隔的 branchs= / permModes= / cliVers= 键;没有就回落单值键)。返回 { branch, permMode, cliVer },
|
|
2337
|
+
// 三个键都是「逗号分隔集合」的字符串或 ''。只有 claude 源带这些字段,别家筛选结果天然为空 ——
|
|
2338
|
+
// 页面按「无此维度」提示,不印 0(见 I15 条目约定)。
|
|
2339
|
+
const workParam = (u) => {
|
|
2340
|
+
const pick = (single, multi) => {
|
|
2341
|
+
const m = (u.searchParams.get(multi) || '').trim();
|
|
2342
|
+
return m || (u.searchParams.get(single) || '').trim();
|
|
2343
|
+
};
|
|
2344
|
+
return { branch: pick('branch', 'branchs'), permMode: pick('permMode', 'permModes'), cliVer: pick('cliVer', 'cliVers') };
|
|
2345
|
+
};
|
|
2346
|
+
|
|
2347
|
+
// 快照与按天聚合**共用同一套筛选口径**(agent / 项目 / 时间范围),区别只在下游:
|
|
2348
|
+
// 快照还要再切「条/页」这个显示窗口,聚合要的是**整个筛选集**——不能被 2000 条的窗口截断,
|
|
2349
|
+
// 否则「今天烧了多少」会随着条/页档位变来变去。
|
|
2350
|
+
// agent / project 筛选值兼容多值:R10 独立多选下拉传 `agents=a,b` / `projects=k1,k2`(逗号分隔),
|
|
2351
|
+
// 老的单值键 agent= / project= 语义不变(单值天然是"逗号分隔的一项")。空段忽略。
|
|
2352
|
+
// I15 追加 work 筛选:{ branch, permMode, cliVer } 三者任一非空即按对应维度收窄(AND 组合)。
|
|
2353
|
+
// 三个维度的取值都是「逗号分隔集合」(与 agents/projects 同一种多值语义);值为 undefined 时不筛。
|
|
2354
|
+
function filterEntries(agentFilter, projectFilter, range, from, to, work) {
|
|
2355
|
+
let all = sortedEntries();
|
|
2356
|
+
const aset = new Set(String(agentFilter || '').split(',').map(s => s.trim()).filter(Boolean));
|
|
2357
|
+
if (aset.size) all = all.filter(e => aset.has(e.agent));
|
|
2358
|
+
const pset = new Set(String(projectFilter || '').split(',').map(s => s.trim()).filter(Boolean));
|
|
2359
|
+
if (pset.size) all = all.filter(e => pset.has(projKey(e.project)));
|
|
2360
|
+
const w = work || {};
|
|
2361
|
+
const bset = new Set(String(w.branch || '').split(',').map(s => s.trim()).filter(Boolean));
|
|
2362
|
+
if (bset.size) all = all.filter(e => bset.has(e.branch));
|
|
2363
|
+
const mset = new Set(String(w.permMode || '').split(',').map(s => s.trim()).filter(Boolean));
|
|
2364
|
+
if (mset.size) all = all.filter(e => mset.has(e.permMode));
|
|
2365
|
+
const vset = new Set(String(w.cliVer || '').split(',').map(s => s.trim()).filter(Boolean));
|
|
2366
|
+
if (vset.size) all = all.filter(e => vset.has(e.cliVer));
|
|
2367
|
+
const rb = customBounds(from, to) || rangeBounds(range);
|
|
2368
|
+
if (rb) all = all.filter(e => e.time >= rb.from && e.time <= rb.to);
|
|
2369
|
+
return { list: all, rb };
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
// ---------------- 按天聚合(C2) ----------------
|
|
2373
|
+
function newBucket(day) {
|
|
2374
|
+
return { day, count: 0, tin: 0, tout: 0, tcache: 0, total: 0, dur: 0, maxDur: 0, calls: 0, tools: 0, agents: {} };
|
|
2375
|
+
}
|
|
2376
|
+
function dailyAgg(agentFilter, projectFilter, range, from, to, work) {
|
|
2377
|
+
const { list, rb } = filterEntries(agentFilter, projectFilter, range, from, to, work);
|
|
2378
|
+
const map = new Map();
|
|
2379
|
+
// I16/D2:时间未知的轮不进按天分桶 —— 它们 time=0,落下去就是 1970-01-01 一个巨桶,
|
|
2380
|
+
// 会把柱状图横轴从几个月拉成 56 年。它们仍躺在列表里(页面如实标「时间未知」),只是不参与按天统计。
|
|
2381
|
+
const timed = list.filter(hasTime);
|
|
2382
|
+
let skipped = list.length - timed.length;
|
|
2383
|
+
for (const e of timed) {
|
|
2384
|
+
const k = dayKey(e.time);
|
|
2385
|
+
let o = map.get(k);
|
|
2386
|
+
if (!o) { o = newBucket(k); map.set(k, o); }
|
|
2387
|
+
o.count++;
|
|
2388
|
+
o.tin += e.tin || 0; o.tout += e.tout || 0; o.tcache += e.tcache || 0;
|
|
2389
|
+
const dur = e.dur || 0;
|
|
2390
|
+
o.dur += dur;
|
|
2391
|
+
if (dur > o.maxDur) o.maxDur = dur;
|
|
2392
|
+
o.calls += e.calls || 0; o.tools += e.tools || 0;
|
|
2393
|
+
o.agents[e.agent] = (o.agents[e.agent] || 0) + 1;
|
|
2394
|
+
}
|
|
2395
|
+
// 没有日志的自然日补 0:柱状图的 x 轴必须是**连续的自然日**,把空天跳掉会把时间轴压扁、
|
|
2396
|
+
// 「隔了两周没干活」看起来和「连着两天」一样。
|
|
2397
|
+
const days = [];
|
|
2398
|
+
let capped = 0;
|
|
2399
|
+
if (timed.length || rb) {
|
|
2400
|
+
const firstT = rb ? rb.from : timed[timed.length - 1].time; // list 是按时间降序的
|
|
2401
|
+
const lastT = rb ? rb.to : timed[0].time;
|
|
2402
|
+
const cur = new Date(firstT); cur.setHours(0, 0, 0, 0);
|
|
2403
|
+
const stop = new Date(lastT); stop.setHours(0, 0, 0, 0);
|
|
2404
|
+
// 上限只是防病态跨度(几年前的日志 + 今天会拉出上千根柱子):超了丢最旧的并如实报出来,
|
|
2405
|
+
// 不静默截断 —— 悄悄少算一段历史比图难看严重得多。
|
|
2406
|
+
const MAX_DAYS = 400;
|
|
2407
|
+
if ((stop.getTime() - cur.getTime()) / 86400000 > MAX_DAYS) { cur.setTime(stop.getTime() - MAX_DAYS * 86400000); cur.setHours(0, 0, 0, 0); capped = 1; }
|
|
2408
|
+
let guard = 0;
|
|
2409
|
+
while (cur.getTime() <= stop.getTime() && guard++ < 5000) {
|
|
2410
|
+
const k = dayKey(cur.getTime());
|
|
2411
|
+
days.push(map.get(k) || newBucket(k));
|
|
2412
|
+
cur.setDate(cur.getDate() + 1);
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
const totals = newBucket('');
|
|
2416
|
+
for (const o of days) {
|
|
2417
|
+
// 合计按三项**现算**,不累加条目里的 total 字段:「输入 + 输出 + 缓存 = 合计」由构造保证,
|
|
2418
|
+
// 免得哪天某个解析器的 total 口径漂了,图表上又出现「合计 < 输入」那种算不通的数(§12 的教训)。
|
|
2419
|
+
o.total = o.tin + o.tout + o.tcache;
|
|
2420
|
+
totals.count += o.count; totals.tin += o.tin; totals.tout += o.tout; totals.tcache += o.tcache;
|
|
2421
|
+
totals.dur += o.dur; totals.calls += o.calls; totals.tools += o.tools;
|
|
2422
|
+
if (o.maxDur > totals.maxDur) totals.maxDur = o.maxDur;
|
|
2423
|
+
}
|
|
2424
|
+
totals.total = totals.tin + totals.tout + totals.tcache;
|
|
2425
|
+
return {
|
|
2426
|
+
ok: true,
|
|
2427
|
+
range: range || 'all',
|
|
2428
|
+
label: rb ? rb.label : '全部时间',
|
|
2429
|
+
from: days.length ? days[0].day : null,
|
|
2430
|
+
to: days.length ? days[days.length - 1].day : null,
|
|
2431
|
+
daysCapped: capped ? 1 : 0, // 1 = 跨度过大,只统计了最近 400 天
|
|
2432
|
+
// I16/D2:**没进这张图**的时间未知轮数,如实报出来(页面据此说明「有 N 轮时间未知、未计入」)。
|
|
2433
|
+
// 不报的话,按天合计会小于列表里看到的条数,看着像漏算 —— 那是把「不知道」读成了「丢了」。
|
|
2434
|
+
timeUnknown: skipped || 0,
|
|
2435
|
+
days, totals,
|
|
2436
|
+
};
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
// ---------------- 按模型聚合(C2 续) ----------------
|
|
2440
|
+
// 与按天聚合同一套筛选、同样不吃「条/页」窗口,只是分桶维度换成模型。
|
|
2441
|
+
//
|
|
2442
|
+
// 归一:**只按小写**(`Deepseek-V4-Flash` 与 `deepseek-v4-flash` 是同一个模型的两写法,本机 5 组)。
|
|
2443
|
+
// 为什么不做得更"聪明"(去空格/去连字符):那会把 `hy4 preview` 与 `hy4-preview` 并掉(这俩看着也该并),
|
|
2444
|
+
// 但同样的规则也会把 `gpt-5.4` 与 `gpt-54` 并掉 —— 宁可少并,也不要并错。显示名仍取组内最多的写法。
|
|
2445
|
+
//
|
|
2446
|
+
// 归属:一条轮次挂多个模型时(本机 20/4717),token **全部归给 models[0]**(数据里它就是主模型),
|
|
2447
|
+
// 并把条数回在 multi 里让页面写出来 —— 不静默挑一个。这样 Σ(各模型) == 合计 永真,能进对账脚本。
|
|
2448
|
+
function modelAgg(agentFilter, projectFilter, range, from, to, work) {
|
|
2449
|
+
const { list } = filterEntries(agentFilter, projectFilter, range, from, to, work);
|
|
2450
|
+
const map = new Map();
|
|
2451
|
+
let noModel = 0, noModelTotal = 0, noModelCredits = 0, multi = 0;
|
|
2452
|
+
for (const e of list) {
|
|
2453
|
+
const ms = e.models || [];
|
|
2454
|
+
if (!ms.length) { noModel++; noModelTotal += e.total || 0; noModelCredits += e.credits || 0; continue; } // 本机实测这些条目 total 恒为 0;但不假设,如实累出来
|
|
2455
|
+
if (ms.length > 1) multi++;
|
|
2456
|
+
const name = String(ms[0]);
|
|
2457
|
+
const k = name.toLowerCase();
|
|
2458
|
+
let o = map.get(k);
|
|
2459
|
+
if (!o) { o = { key: k, count: 0, tin: 0, tout: 0, tcache: 0, total: 0, dur: 0, calls: 0, tools: 0, credits: 0, nameCounts: {}, agents: {} }; map.set(k, o); }
|
|
2460
|
+
o.count++;
|
|
2461
|
+
o.nameCounts[name] = (o.nameCounts[name] || 0) + 1;
|
|
2462
|
+
o.tin += e.tin || 0; o.tout += e.tout || 0; o.tcache += e.tcache || 0;
|
|
2463
|
+
o.dur += e.dur || 0; o.calls += e.calls || 0; o.tools += e.tools || 0;
|
|
2464
|
+
o.credits += e.credits || 0; // qoder 的 token 恒 0、真实用量在 credits(见 parsers/claude.mjs),不进 total、单独成列
|
|
2465
|
+
o.agents[e.agent] = (o.agents[e.agent] || 0) + 1;
|
|
2466
|
+
}
|
|
2467
|
+
const models = [...map.values()];
|
|
2468
|
+
for (const o of models) {
|
|
2469
|
+
o.total = o.tin + o.tout + o.tcache; // 同按天聚合:合计现算,四个数必然自洽
|
|
2470
|
+
// 显示名取组内条目最多的写法(并列取字典序小的,保证每次算出来一致)—— 与项目下拉同一套规则
|
|
2471
|
+
const names = Object.keys(o.nameCounts);
|
|
2472
|
+
names.sort((a, b) => (o.nameCounts[b] - o.nameCounts[a]) || a.localeCompare(b));
|
|
2473
|
+
o.label = names[0];
|
|
2474
|
+
o.names = names;
|
|
2475
|
+
delete o.nameCounts;
|
|
2476
|
+
}
|
|
2477
|
+
// 排序:token 多的在前;token 并列(或都为 0)时按条目数,再按名字,保证每次算出来顺序一致
|
|
2478
|
+
models.sort((a, b) => (b.total - a.total) || (b.count - a.count) || a.label.localeCompare(b.label));
|
|
2479
|
+
const totals = models.reduce((a, o) => ({
|
|
2480
|
+
count: a.count + o.count, tin: a.tin + o.tin, tout: a.tout + o.tout, tcache: a.tcache + o.tcache,
|
|
2481
|
+
dur: a.dur + o.dur, calls: a.calls + o.calls, tools: a.tools + o.tools, credits: a.credits + o.credits,
|
|
2482
|
+
}), { count: 0, tin: 0, tout: 0, tcache: 0, total: 0, dur: 0, calls: 0, tools: 0, credits: 0 });
|
|
2483
|
+
totals.total = totals.tin + totals.tout + totals.tcache;
|
|
2484
|
+
totals.credits += noModelCredits; // credits 与 count 同一口径:Σ(各模型) + noModel = 全部
|
|
2485
|
+
// totals 覆盖**整个筛选集**(把没有模型记录的条目也算进 count),这样它与 /api/daily 的 totals 永远相等 ——
|
|
2486
|
+
// 同一屏里两个「合计条目数」差着一千多,看着就是错账。那部分 token 恒为 0,只是条目数要算上。
|
|
2487
|
+
// 于是口径是:Σ(各模型.count) + noModel == totals.count。
|
|
2488
|
+
totals.noModel = noModel;
|
|
2489
|
+
totals.count += noModel;
|
|
2490
|
+
return {
|
|
2491
|
+
ok: true, range: range || 'all', models, totals,
|
|
2492
|
+
// noModel 的条目单独计数、不进任何模型行:没有模型可归属,混进模型表只会让「条目数」对不上。
|
|
2493
|
+
// 页面把它作为**单独一行**显示出来,这样「各模型 + 无模型 = 全部」是能对得上账的。
|
|
2494
|
+
noModel, noModelTotal, multi,
|
|
2495
|
+
};
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
// ---------------- 工作指纹聚合(I15,2026-09-23) ----------------
|
|
2499
|
+
// 回答「这周我在 yxz 分支上烧了多少」「升到 2.1.263 之后失败率变了吗」「plan 模式下的轮是不是更贵」。
|
|
2500
|
+
// 三个维度(git 分支 / 权限模式 / 产品版本)各自按「值 × 条目数 / token / 耗时 / 失败轮数」分桶。
|
|
2501
|
+
// 与按模型同一套筛选口径、同样不吃「条/页」窗口;只覆盖 claude(源里带这三样)。
|
|
2502
|
+
// ⚠️ 覆盖边界(I15 条目约定):别家**没有这些维度**,一律如实标「无此维度」,不印 0 ——
|
|
2503
|
+
// 与 qoder「无 token 就不印 0」同一条约定。故返回里带 covered(哪些 agent 有数据),页面据此说明。
|
|
2504
|
+
// 另:值取「轮内最后一次声明」(见 parsers/claude.mjs 的 curBranch/curVer/curPerm 游标注释),
|
|
2505
|
+
// 未声明的轮(permissionMode 有约 302 轮没声明)落在 noVal 里单列,不冒充「default」。
|
|
2506
|
+
function workAgg(agentFilter, projectFilter, range, from, to, work) {
|
|
2507
|
+
const { list, rb } = filterEntries(agentFilter, projectFilter, range, from, to, work);
|
|
2508
|
+
// 三个维度各自的分桶
|
|
2509
|
+
const dim = () => ({ buckets: new Map(), noVal: 0, noValTotal: 0, noValDur: 0 });
|
|
2510
|
+
const D = { branch: dim(), permMode: dim(), cliVer: dim() };
|
|
2511
|
+
const covered = new Set(); // 有工作指纹数据的 agent(如实标覆盖,不替无此维度的 agent 印 0)
|
|
2512
|
+
for (const e of list) {
|
|
2513
|
+
const wf = { branch: e.branch, permMode: e.permMode, cliVer: e.cliVer };
|
|
2514
|
+
for (const k of ['branch', 'permMode', 'cliVer']) {
|
|
2515
|
+
const v = wf[k];
|
|
2516
|
+
if (v == null || v === '') {
|
|
2517
|
+
// 无此维度 / 未声明:如实单列。claude 系以外全落这里(它们没有这些字段)。
|
|
2518
|
+
D[k].noVal++; D[k].noValTotal += e.total || 0; D[k].noValDur += e.dur || 0;
|
|
2519
|
+
continue;
|
|
2520
|
+
}
|
|
2521
|
+
covered.add(e.agent);
|
|
2522
|
+
let o = D[k].buckets.get(v);
|
|
2523
|
+
if (!o) { o = { key: v, count: 0, tin: 0, tout: 0, tcache: 0, total: 0, dur: 0, calls: 0, tools: 0, err: 0, agents: {} }; D[k].buckets.set(v, o); }
|
|
2524
|
+
o.count++;
|
|
2525
|
+
o.tin += e.tin || 0; o.tout += e.tout || 0; o.tcache += e.tcache || 0;
|
|
2526
|
+
o.dur += e.dur || 0; o.calls += e.calls || 0; o.tools += e.tools || 0;
|
|
2527
|
+
if (e.status === 'error') o.err++;
|
|
2528
|
+
o.agents[e.agent] = (o.agents[e.agent] || 0) + 1;
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
const out = {};
|
|
2532
|
+
for (const k of ['branch', 'permMode', 'cliVer']) {
|
|
2533
|
+
const d = D[k];
|
|
2534
|
+
const rows = [...d.buckets.values()].map(o => {
|
|
2535
|
+
o.total = o.tin + o.tout + o.tcache; // 同按天/按模型:合计现算,四个数必然自洽
|
|
2536
|
+
return o;
|
|
2537
|
+
});
|
|
2538
|
+
// 排序:token 多的在前(token 并列按条数、再按名字),保证每次算出来顺序一致 —— 与按模型同一套
|
|
2539
|
+
rows.sort((a, b) => (b.total - a.total) || (b.count - a.count) || String(a.key).localeCompare(String(b.key)));
|
|
2540
|
+
out[k] = { rows, noVal: d.noVal, noValTotal: d.noValTotal, noValDur: d.noValDur };
|
|
2541
|
+
}
|
|
2542
|
+
const totals = list.reduce((a, e) => ({
|
|
2543
|
+
count: a.count + 1, tin: a.tin + (e.tin || 0), tout: a.tout + (e.tout || 0), tcache: a.tcache + (e.tcache || 0),
|
|
2544
|
+
dur: a.dur + (e.dur || 0), calls: a.calls + (e.calls || 0), tools: a.tools + (e.tools || 0),
|
|
2545
|
+
}), { count: 0, tin: 0, tout: 0, tcache: 0, total: 0, dur: 0, calls: 0, tools: 0 });
|
|
2546
|
+
totals.total = totals.tin + totals.tout + totals.tcache;
|
|
2547
|
+
return {
|
|
2548
|
+
ok: true, range: range || 'all', label: rb ? rb.label : '全部时间',
|
|
2549
|
+
covered: [...covered].sort(), // 有哪些 agent 真的带工作指纹数据(页面据此说明覆盖)
|
|
2550
|
+
totals, ...out,
|
|
2551
|
+
};
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
// I16:某个会话名下的**子 agent 会话**清单(只回答问题「这一坨是谁挂出来的」,不做树上卷)。
|
|
2555
|
+
// 判据与 sessionsAgg 的树完全一致:条目上的显式 `parent` 外键 + 同一 agent + 同一归一化项目
|
|
2556
|
+
//(session 值跨 agent/项目不唯一,不带上这两维就会把同名会话错挂成别人的孩子)。
|
|
2557
|
+
// 返回逐子会话一行的小结;父会话不在当前筛选集合里时**如实返回空数组**,不编。
|
|
2558
|
+
// ⚠️ seen 是**防环**用的,不是优化:源里理论上不该成环(父指向子、子指向父),但日志是外部写进来的,
|
|
2559
|
+
// 真出现环时没有这道闸就是**无限递归**(服务端会栈溢出崩掉,而不是少显示一行)。
|
|
2560
|
+
function sessionChildren(sessionKey, agent, projectK, seen) {
|
|
2561
|
+
const guard = seen || new Set([sessionKey]);
|
|
2562
|
+
const map = new Map();
|
|
2563
|
+
for (const e of entries.values()) {
|
|
2564
|
+
if (e.agent !== agent || e.parent !== sessionKey) continue;
|
|
2565
|
+
if (projKey(e.project) !== projectK) continue;
|
|
2566
|
+
const sk = e.session || '';
|
|
2567
|
+
if (!sk || guard.has(sk)) continue;
|
|
2568
|
+
let o = map.get(sk);
|
|
2569
|
+
if (!o) {
|
|
2570
|
+
o = { key: sk, name: e.name || null, sub: e.sub || null, subMode: e.subMode || null, turns: 0, total: 0, dur: 0, status: 'ok', from: e.time, to: e.time };
|
|
2571
|
+
map.set(sk, o);
|
|
2572
|
+
}
|
|
2573
|
+
o.turns++;
|
|
2574
|
+
o.total += e.total || 0;
|
|
2575
|
+
o.dur += e.dur || 0;
|
|
2576
|
+
if (e.time < o.from) o.from = e.time;
|
|
2577
|
+
if (e.time > o.to) o.to = e.time;
|
|
2578
|
+
if (e.status === 'error') o.status = 'error';
|
|
2579
|
+
if (!o.name && e.name) o.name = e.name;
|
|
2580
|
+
if (!o.sub && e.sub) o.sub = e.sub;
|
|
2581
|
+
if (!o.subMode && e.subMode) o.subMode = e.subMode;
|
|
2582
|
+
}
|
|
2583
|
+
const kids = [...map.values()].sort((a, b) => (a.from - b.from) || String(a.key).localeCompare(String(b.key)));
|
|
2584
|
+
// 子会话自己还可能挂子会话(dsh 的 delegationDepth 支持多层):逐层的后代合计算进 subKids。
|
|
2585
|
+
// 每个子会话各带一份自己的 guard 副本(不能共用同一个 Set,否则兄弟分支会互相误伤)。
|
|
2586
|
+
for (const k of kids) {
|
|
2587
|
+
const nextSeen = new Set(guard); nextSeen.add(k.key);
|
|
2588
|
+
const grand = sessionChildren(k.key, agent, projectK, nextSeen);
|
|
2589
|
+
k.childCount = grand.length;
|
|
2590
|
+
k.subKids = grand.reduce((a, c) => a + 1 + (c.subKids || 0), 0);
|
|
2591
|
+
k.subTurns = k.turns + grand.reduce((a, c) => a + (c.subTurns || c.turns || 0), 0);
|
|
2592
|
+
k.subTotal = k.total + grand.reduce((a, c) => a + (c.subTotal || c.total || 0), 0);
|
|
2593
|
+
k.subDur = k.dur + grand.reduce((a, c) => a + (c.subDur || c.dur || 0), 0);
|
|
2594
|
+
}
|
|
2595
|
+
return kids;
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
// ---------------- 按会话聚合(R6) ----------------
|
|
2599
|
+
// session 值来自各解析器落的条目字段(e.session),同一会话的多轮共享同一个值。
|
|
2600
|
+
// 返回该 session 的全部轮次(时间正序)+ 会话级汇总。轮次正文不在这里给——前端拿到 id 列表后
|
|
2601
|
+
// 仍走 /api/entry 懒加载,避免一个大会话一次性读爆。
|
|
2602
|
+
function sessionAgg(sessionKey, agentFilter, projectFilter, work) {
|
|
2603
|
+
if (!sessionKey) return null;
|
|
2604
|
+
const list = [];
|
|
2605
|
+
const w = work || {};
|
|
2606
|
+
const bset = new Set(String(w.branch || '').split(',').map(s => s.trim()).filter(Boolean));
|
|
2607
|
+
const mset = new Set(String(w.permMode || '').split(',').map(s => s.trim()).filter(Boolean));
|
|
2608
|
+
const vset = new Set(String(w.cliVer || '').split(',').map(s => s.trim()).filter(Boolean));
|
|
2609
|
+
for (const e of entries.values()) {
|
|
2610
|
+
if (e.session !== sessionKey) continue;
|
|
2611
|
+
// agent / project 是**可选**收窄:都不传 == 老行为(只按 session 匹配)。
|
|
2612
|
+
// 为什么需要能传:session 值跨 agent、跨项目**都不保证唯一** —— 各解析器口径不同
|
|
2613
|
+
// (claude/buddy 用文件名、codex/dsh/kimi/gemini/cursor 用 sessionId、atomcode 用 .meta 的 name),
|
|
2614
|
+
// 本机实测 atomcode 就有不同项目下的同名 session(取的是标题)。只按 session 匹配会把
|
|
2615
|
+
// 两个不相干的会话并成一个,汇总数也就跟着错。
|
|
2616
|
+
// 保留「不传就照旧」是为了不改坏页面上已有的会话时间线弹层。
|
|
2617
|
+
if (agentFilter && e.agent !== agentFilter) continue;
|
|
2618
|
+
// project 参数按**归一化后的 key** 比(与 /api/daily、/api/models、页面项目下拉同一口径);
|
|
2619
|
+
// 顺手也接受原始写法(projKey 幂等,两种都能命中),省得调用方纠结该传哪种。
|
|
2620
|
+
if (projectFilter) {
|
|
2621
|
+
const pk = projKey(e.project);
|
|
2622
|
+
if (pk !== projectFilter && pk !== projKey(projectFilter)) continue;
|
|
2623
|
+
}
|
|
2624
|
+
// I15 工作指纹筛选(可选):与 filterEntries 同一套「逗号分隔集合」多值语义
|
|
2625
|
+
if (bset.size && !bset.has(e.branch)) continue;
|
|
2626
|
+
if (mset.size && !mset.has(e.permMode)) continue;
|
|
2627
|
+
if (vset.size && !vset.has(e.cliVer)) continue;
|
|
2628
|
+
list.push(e);
|
|
2629
|
+
}
|
|
2630
|
+
if (!list.length) return null;
|
|
2631
|
+
list.sort((a, b) => a.time - b.time); // 时间正序:会话时间线要从头看到尾
|
|
2632
|
+
const agg = {
|
|
2633
|
+
session: sessionKey,
|
|
2634
|
+
count: list.length,
|
|
2635
|
+
tin: 0, tout: 0, tcache: 0, dur: 0, calls: 0, tools: 0,
|
|
2636
|
+
agents: {}, projects: {},
|
|
2637
|
+
from: list[0].time, to: list[list.length - 1].time,
|
|
2638
|
+
models: [],
|
|
2639
|
+
};
|
|
2640
|
+
const modelSet = new Set();
|
|
2641
|
+
for (const e of list) {
|
|
2642
|
+
// I14:会话名(各 agent 的「产品自写标题」)。会话列表走 sessionsAgg 已经能拿到,这里补一份
|
|
2643
|
+
// 是为了**只按 key 打开会话**的路径:页面 sess-view 的标题是 `sessPick.name || sessCur.session`,
|
|
2644
|
+
// 不带 sessPick 直接进来时会退回裸 UUID。名字可能只落在其中几轮上(ai-title 只写一次),
|
|
2645
|
+
// 任一轮有值即可,故取第一个命中的。
|
|
2646
|
+
if (!agg.name && e.name) agg.name = e.name;
|
|
2647
|
+
agg.tin += e.tin || 0; agg.tout += e.tout || 0; agg.tcache += e.tcache || 0;
|
|
2648
|
+
agg.dur += e.dur || 0; agg.calls += e.calls || 0; agg.tools += e.tools || 0;
|
|
2649
|
+
agg.agents[e.agent] = (agg.agents[e.agent] || 0) + 1;
|
|
2650
|
+
agg.projects[e.project] = (agg.projects[e.project] || 0) + 1;
|
|
2651
|
+
for (const m of e.models || []) modelSet.add(m);
|
|
2652
|
+
}
|
|
2653
|
+
agg.models = [...modelSet];
|
|
2654
|
+
// I16:单会话视图要能点着跳到父 / 子会话,所以把「这一坨到底是哪个 agent / 哪个项目」也直接给出,
|
|
2655
|
+
// 免得页面从 agents/projects 那两个计数对象里猜(它们是多值映射,猜错了跳转就跳飞)。
|
|
2656
|
+
agg.agent = list[0].agent; agg.project = list[0].project; agg.projectKey = projKey(list[0].project);
|
|
2657
|
+
// I16 子 agent 拓扑:这一坨轮次自己的谱系(取任一轮带值即可,解析器落在每一轮上)。
|
|
2658
|
+
// parent = 父会话 id;depth = 深度(0/undefined = 人开的);sub/subMode = 子 agent 的派活描述。
|
|
2659
|
+
for (const e of list) {
|
|
2660
|
+
if (!agg.parent && e.parent) agg.parent = e.parent;
|
|
2661
|
+
if (e.depth && (!agg.depth || e.depth > agg.depth)) agg.depth = e.depth;
|
|
2662
|
+
if (!agg.sub && e.sub) agg.sub = e.sub;
|
|
2663
|
+
if (!agg.subMode && e.subMode) agg.subMode = e.subMode;
|
|
2664
|
+
}
|
|
2665
|
+
// 子会话清单(**只在这一屏回答「这个会话下面挂了哪些子 agent」**):按同一套 agent/project 口径
|
|
2666
|
+
// 在内存条目里反查 parent === 本会话 key 的会话。与 sessionsAgg 的树同一份判据(显式外键),
|
|
2667
|
+
// 不是按时间推的。父不在筛选里时这里会空 —— 如实空着,不编。
|
|
2668
|
+
agg.children = sessionChildren(agg.session, list[0]?.agent || '', projKey(list[0]?.project));
|
|
2669
|
+
agg.subTurns = list.length;
|
|
2670
|
+
agg.subTotal = agg.tin + agg.tout + agg.tcache;
|
|
2671
|
+
agg.subDur = agg.dur;
|
|
2672
|
+
for (const c of agg.children) {
|
|
2673
|
+
agg.subTurns += c.turns; agg.subTotal += c.total; agg.subDur += c.dur;
|
|
2674
|
+
}
|
|
2675
|
+
// ctx / ctxUsed / credits / ctxRatio / 压缩画像 按需透传:
|
|
2676
|
+
// qoder 的 token 恒为 0、真实用量在积分与上下文占比(见 parsers/claude.mjs 的 emitClaudeTurns),
|
|
2677
|
+
// 会话视图的逐轮用量图靠这些字段决定画「积分模式」还是「token 模式」;普通 agent 这些键是
|
|
2678
|
+
// undefined,JSON 序列化时自动丢掉,响应体积零增量。
|
|
2679
|
+
// 压缩画像(compacts / compPre / compPost / compDrop / compMs / compAuto / compManual,I12)自
|
|
2680
|
+
// 2026-09-28 起 claude 与 qoder 都会落:会话视图的压缩红点 tooltip 与会话级压缩小结要读它们,
|
|
2681
|
+
// 所以必须在这里一起透传 —— 漏一个,图上就只剩「压过几次」、丢多少/等多久整块读不到。
|
|
2682
|
+
// ctxTbl 不走解析器、只问已落盘的 srcs.kind:CTX_WINDOW_KINDS 里那几家的窗口容量日志里根本没有,
|
|
2683
|
+
// 是分母只能查表(可在侧栏「上下文窗口」改)的 —— 占用率线据此标出「这个百分比是查表算的」,
|
|
2684
|
+
// 免得表里填错时曲线读起来像个硬事实。自带窗口的 agent 为 undefined,同样零增量。
|
|
2685
|
+
agg.entries = list.map(e => ({ id: e.id, time: e.time, dur: e.dur, status: e.status, preview: e.preview, agent: e.agent, project: e.project, nollm: e.nollm, tin: e.tin, tout: e.tout, tcache: e.tcache, total: e.total, models: e.models, calls: e.calls, tools: e.tools, ctx: e.ctx, ctxUsed: e.ctxUsed, credits: e.credits, ctxRatio: e.ctxRatio, compacts: e.compacts, compPre: e.compPre, compPost: e.compPost, compDrop: e.compDrop, compMs: e.compMs, compAuto: e.compAuto, compManual: e.compManual, branch: e.branch, permMode: e.permMode, cliVer: e.cliVer, rdur: e.rdur, rdurMsg: e.rdurMsg, timeUnknown: e.timeUnknown || undefined, ctxTbl: CTX_WINDOW_KINDS.has(srcs.get(e.id)?.kind) ? true : undefined }));
|
|
2686
|
+
return { ok: true, ...agg };
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
// ---------------- 会话列表聚合(R6) ----------------
|
|
2690
|
+
// 与 sessionAgg 的分工:那个给「**一个**会话的轮次」(页面弹层用),这个给「**会话列表** + 会话级汇总」。
|
|
2691
|
+
//
|
|
2692
|
+
// 为什么必须服务端算:会话级汇总(累计 token / 总耗时 / 起止)要覆盖**全量索引**,而页面手里
|
|
2693
|
+
// 只有最多 5000 条的窗口,客户端分组会漏会话、汇总会偏。这里与 /api/daily、/api/models 一样
|
|
2694
|
+
// **不吃「条/页」窗口** —— 聚合口径不能随显示档位变。
|
|
2695
|
+
//
|
|
2696
|
+
// 聚合键是 agent + session,**不是只用 session**:各解析器对 session 的取值口径不同
|
|
2697
|
+
// (claude/buddy 用文件名、codex/dsh/kimi/gemini/cursor 用 sessionId、atomcode 用 .meta 的 name),
|
|
2698
|
+
// 跨 agent 不保证唯一,只按 session 归并会把不同 agent 的同名会话错误合并。
|
|
2699
|
+
// 注意 sessionAgg(/api/session?key=)仍是「只按 session 匹配」的老口径 —— 本次**不动它**,
|
|
2700
|
+
// 免得改坏页面上已有的会话时间线弹层。
|
|
2701
|
+
// 「这个来源的解析器会产出逐事件轨迹(events[])」的能力名单 —— 目前有 dsh / zcode / traedb / opencode / kilo / hermes / devindb。
|
|
2702
|
+
// ⚠️ 名单里是 **kind**(条目 srcs 里存的来源 kind),不是 agent 名:两者不总是一回事
|
|
2703
|
+
// (trae 系列 agent 名是 trae/traework,DB 解析 kind 是 traedb;dsh/zcode/opencode 恰好同名)。
|
|
2704
|
+
// 页面靠 /api/sessions 的 hasTimeline 决定要不要挂「没有逐事件时间」的告警;把名单收在这里,
|
|
2705
|
+
// 是为了让「接一个新 agent」这件事只需要改**一行**,而不是去满仓库找散落的 agent === 'dsh'。
|
|
2706
|
+
const EVENT_AGENTS = new Set(['dsh', 'zcode', 'traedb', 'opencode', 'kilo', 'hermes', 'devindb']);
|
|
2707
|
+
|
|
2708
|
+
function sessionsAgg(agentFilter, projectFilter, range, from, to, limit, work) {
|
|
2709
|
+
const { list } = filterEntries(agentFilter, projectFilter, range, from, to, work);
|
|
2710
|
+
const map = new Map();
|
|
2711
|
+
for (const e of list) {
|
|
2712
|
+
const sk = e.session || '';
|
|
2713
|
+
if (!sk) continue; // 没有会话标识的条目归不了组:如实跳过,不塞进一个假会话
|
|
2714
|
+
// 聚合键 = agent + project + session:session 值跨 agent、跨项目都不保证唯一
|
|
2715
|
+
// (atomcode 的 session 取的是 .meta 的标题,本机实测不同项目下有重名),
|
|
2716
|
+
// 只用 agent+session 会把两个不相干的会话并成一个。
|
|
2717
|
+
const key = e.agent + '\u0000' + projKey(e.project) + '\u0000' + sk;
|
|
2718
|
+
let o = map.get(key);
|
|
2719
|
+
if (!o) {
|
|
2720
|
+
o = {
|
|
2721
|
+
key: sk, agent: e.agent, project: e.project, projectKey: projKey(e.project), name: e.name || null,
|
|
2722
|
+
turns: 0, tin: 0, tout: 0, tcache: 0, dur: 0, calls: 0, tools: 0,
|
|
2723
|
+
from: e.time, to: e.time, status: 'ok', modelSet: new Set(), hasTimeline: false,
|
|
2724
|
+
};
|
|
2725
|
+
map.set(key, o);
|
|
2726
|
+
}
|
|
2727
|
+
o.turns++;
|
|
2728
|
+
o.tin += e.tin || 0; o.tout += e.tout || 0; o.tcache += e.tcache || 0;
|
|
2729
|
+
o.dur += e.dur || 0; o.calls += e.calls || 0; o.tools += e.tools || 0;
|
|
2730
|
+
if (e.time < o.from) o.from = e.time;
|
|
2731
|
+
if (e.time > o.to) o.to = e.time;
|
|
2732
|
+
if (e.status === 'error') o.status = 'error'; // 会话里有失败轮就标出来,不静默藏掉
|
|
2733
|
+
if (!o.name && e.name) o.name = e.name; // 标题可能只落在其中几轮上
|
|
2734
|
+
// I16 子 agent 拓扑:谱系取**任一轮带值即可**(解析器把它落在每一轮上)。
|
|
2735
|
+
// parent = 父会话 id(源头显式外键:dsh 的 session 头 parentSession、claude 的 subagents/
|
|
2736
|
+
// 目录层级、zcode/opencode 的 session.parent_id、hermes 的 parent_session_id)。
|
|
2737
|
+
// ⚠️ 不按时间区间推父子 —— 那条路在 2026-09-28 的前置闸里被证伪(atomcode 那 11047 对
|
|
2738
|
+
// 嵌套 100% 是「time 取轮终点 + 时间未知回落 updated_at」两处缺陷叠出来的噪声)。
|
|
2739
|
+
if (!o.parent && e.parent) o.parent = e.parent;
|
|
2740
|
+
if (e.depth && (!o.depth || e.depth > o.depth)) o.depth = e.depth;
|
|
2741
|
+
if (!o.sub && e.sub) o.sub = e.sub; // 子 agent 的派活描述
|
|
2742
|
+
if (!o.subMode && e.subMode) o.subMode = e.subMode; // continuable / one-shot
|
|
2743
|
+
if (e.timeUnknown) o.timeUnknown = true; // I16/D2:时间未知如实带上
|
|
2744
|
+
// hasTimeline 按**条目来源的 kind** 判(srcs),不按 agent 名 —— 两者不总是一回事:
|
|
2745
|
+
// trae 系列 agent 的 DB 解析 kind 是 'traedb',agent 名却是 trae/traework(详见下方名单注释)。
|
|
2746
|
+
if (!o.hasTimeline && EVENT_AGENTS.has(srcs.get(e.id)?.kind)) o.hasTimeline = true;
|
|
2747
|
+
for (const m of e.models || []) o.modelSet.add(m);
|
|
2748
|
+
}
|
|
2749
|
+
const sessions = [...map.values()];
|
|
2750
|
+
for (const o of sessions) {
|
|
2751
|
+
o.total = o.tin + o.tout + o.tcache; // 同按天/按模型聚合:合计现算,四个数必然自洽
|
|
2752
|
+
o.models = [...o.modelSet];
|
|
2753
|
+
delete o.modelSet;
|
|
2754
|
+
// 逐事件轨迹的能力声明由上方 EVENT_AGENTS 名单给出(hasTimeline 已在聚合循环里按 srcs.kind 落定)。
|
|
2755
|
+
// ⚠️ 这是**能力声明**(一份名单),不是**探测**(没去翻这一轮的 events)—— 所以谁把逐事件接进
|
|
2756
|
+
// 别的解析器(claude/codex/kimi,见任务文档 §8 的「明确不在本次范围」),**必须把这个 kind 加进
|
|
2757
|
+
// EVENT_AGENTS**;否则那个 agent 会一边正常渲染轨迹、一边在顶上挂着「没有逐事件时间」的告警。
|
|
2758
|
+
// 页面侧另有一道自纠正兜底(真加载到 events 就不显示那条告警,见 agent-acta-page.html 的
|
|
2759
|
+
// sessHasEvents),所以最坏情况是「名单漏更新」,不会是「明明有数据却被藏起来」。
|
|
2760
|
+
}
|
|
2761
|
+
// ---- I16:按显式谱系上卷出树(父子 + 子树合计) ----
|
|
2762
|
+
// 三件事必须在**分页(limit)之前**做完,否则截断会把父子拆散:
|
|
2763
|
+
// ① 认父:父会话必须与子在同一 agent + 同一项目下(session 值跨 agent/项目不唯一,见上面的聚合键);
|
|
2764
|
+
// ② 认不出父的(父不在当前筛选窗口 / 源里根本没有那个父 / 父被淘汰)→ 如实**降级成根**,
|
|
2765
|
+
// 不硬造一个不存在的父节点;
|
|
2766
|
+
// ③ 子树合计(subTurns / subTotal / subDur)**含自身**,回答原来那个「这一坨一共花了多少」。
|
|
2767
|
+
const byKey = new Map(sessions.map(s => [s.agent + '\u0000' + s.projectKey + '\u0000' + s.key, s]));
|
|
2768
|
+
// ⚠️ 先给**所有**会话建好 children 数组,再挂孩子:父会话在数组里的位置是任意的(这边按
|
|
2769
|
+
// map 插入序、没有任何排序保证),边遍历边初始化会让「父排在子后面」的那些 p.children.push
|
|
2770
|
+
// 直接 TypeError —— 而 /api/sessions 一崩,整个页面连会话列表都出不来(不是少一行的问题)。
|
|
2771
|
+
for (const s of sessions) s.children = [];
|
|
2772
|
+
for (const s of sessions) {
|
|
2773
|
+
if (!s.parent) continue;
|
|
2774
|
+
const p = byKey.get(s.agent + '\u0000' + s.projectKey + '\u0000' + s.parent);
|
|
2775
|
+
// 自环(源里 parent 指回自己)也当没有父,免得后面 DFS 无限转
|
|
2776
|
+
if (p && p !== s) { s.parentKey = p.key; p.children.push(s); }
|
|
2777
|
+
}
|
|
2778
|
+
for (const s of sessions) {
|
|
2779
|
+
if (!s.children.length) delete s.children;
|
|
2780
|
+
}
|
|
2781
|
+
// 子树合计:从根往下推(先算 depth 序,父子不会成环 —— 上面的自环已排除;真出现环也只是这一支不计入)
|
|
2782
|
+
const roots = sessions.filter(s => !s.parentKey);
|
|
2783
|
+
const seen = new Set();
|
|
2784
|
+
const walk = s => {
|
|
2785
|
+
if (seen.has(s)) return null; // 防御:万一源数据成环,只算一次
|
|
2786
|
+
seen.add(s);
|
|
2787
|
+
let turns = s.turns, total = s.total, dur = s.dur, tin = s.tin, tout = s.tout, tcache = s.tcache, calls = s.calls, tools = s.tools;
|
|
2788
|
+
let kids = 0;
|
|
2789
|
+
for (const c of s.children || []) {
|
|
2790
|
+
const r = walk(c);
|
|
2791
|
+
if (!r) continue;
|
|
2792
|
+
turns += r.turns; total += r.total; dur += r.dur;
|
|
2793
|
+
tin += r.tin; tout += r.tout; tcache += r.tcache; calls += r.calls; tools += r.tools;
|
|
2794
|
+
kids += 1 + r.kids;
|
|
2795
|
+
}
|
|
2796
|
+
s.subTurns = turns; s.subTotal = total; s.subDur = dur;
|
|
2797
|
+
s.subTin = tin; s.subTout = tout; s.subTcache = tcache; s.subCalls = calls; s.subTools = tools;
|
|
2798
|
+
s.subKids = kids; // 后代(子 agent)会话数,不含自己
|
|
2799
|
+
s.subSelf = turns === s.turns && total === s.total; // true = 没有子,页面不必显示「合计」那一列
|
|
2800
|
+
return { turns, total, dur, tin, tout, tcache, calls, tools, kids };
|
|
2801
|
+
};
|
|
2802
|
+
for (const r of roots) walk(r);
|
|
2803
|
+
// 兜底:成环的会话不会从任何根走到,单独各算各的(不让它们带着 undefined 的 sub* 出去)
|
|
2804
|
+
for (const s of sessions) if (!seen.has(s)) walk(s);
|
|
2805
|
+
sessions.sort((a, b) => (b.to - a.to) || String(a.key).localeCompare(String(b.key)));
|
|
2806
|
+
const capped = sessions.length > limit;
|
|
2807
|
+
return { ok: true, range: range || 'all', count: sessions.length, capped: capped ? 1 : 0, sessions: capped ? sessions.slice(0, limit) : sessions };
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
// ---------------- 排行分析(R8) ----------------
|
|
2811
|
+
// 只做用量统计没有的两个时延维度:最慢轮 TopN(可跳回原条目)、时延 p50/p95。
|
|
2812
|
+
// 按 agent/项目/时间范围同一套筛选口径(filterEntries),不吃「条/页」窗口。
|
|
2813
|
+
// R8 剩余两项(byTools / byCacheRate)已于 v2.39 结案,见 history.md「R8 排行与分析」。
|
|
2814
|
+
// I6 追加 byToolFails(工具失败画像),数据来自搜索分片,见下面 TOOL_FAIL_TIERS 的说明。
|
|
2815
|
+
// slowest 字段与其形状保持不动,mcp-server 的 slowest_turns 与既有断言不受影响。
|
|
2816
|
+
|
|
2817
|
+
// I6:**哪些 kind 的详情带得出「结构化工具失败标志」**(`tools[].error`,源头给的就是布尔/字符串 flag,
|
|
2818
|
+
// 不是正文里的 error 字样)。与 EVENT_AGENTS 同一类东西 —— 一份**能力声明**,接新 agent 改这里一行。
|
|
2819
|
+
// 三档的差别必须让用户看得见:只有 signal 档谈得上「谁最常失败」,另外两档的画像是空的,
|
|
2820
|
+
// 而空的原因是「源头没这个信息」,不是「这个 agent 从不出错」(把 0 印成结论就是造假日绿,
|
|
2821
|
+
// 与 qoder 那边「拿不到 token 就不印 0」同一条约定)。
|
|
2822
|
+
// 各家信号出处:dsh:290 / zcode:265 / traedb:247 / opencode:127 / hermes:199 / gemini:172 /
|
|
2823
|
+
// devindb:334 / atomcode:279 / claude:219(详情回填) / kimi:243 / buddy:253 / doubao:399 / minimax:482 /
|
|
2824
|
+
// openclaw:468(详情回填 toolResult.isError) / copilot:187 / kilo**同 opencode**(复用 opencode.mjs 的
|
|
2825
|
+
// addTool,工具 state.status 的 error 判定即 opencode:127 那一处) / trace 系由 span 现算(本文件 entryContent 的 trace 支路)。
|
|
2826
|
+
// cline 的失败信号不在整块 isError 上,而是 tool_result.content[] **逐项**的 success===false / error
|
|
2827
|
+
// (一次批量调用可以只错一项),见 parsers/cline.mjs 的 toolResultText。
|
|
2828
|
+
// ⚠️ signal 档 = 「有 `tools[].error` 信号」,但**超时与软失败不在这一位里**(它们各自是另外的位,
|
|
2829
|
+
// 见下面 TOOL_TIMEOUT_SIGNAL_KINDS)。本机实测 141 份 claude 转录里 30 条 `toolUseResult.timedOutAfterMs`,
|
|
2830
|
+
// 其 `tool_result.is_error` **一律明确写着 false**(不是缺键)⇒ 只看 `error` 会把它读成"核对过、没问题"。
|
|
2831
|
+
// I13 已把这一层补上:`tf` 扩成 `{名:{e,t,s}}` 三分类(`SHARD_V` 2→3),claude 那一档现在**含超时**。
|
|
2832
|
+
// 另有 41 条第三态(`returnCodeInterpretation` 的 No matches found / Files differ)与 16 条
|
|
2833
|
+
// `staleRecovered` 实测 `is_error` 也全是 false ⇒ 它们进 `s` 单独一列,**不并进失败率**
|
|
2834
|
+
// (把"rg 没搜到"算成失败就是把最常见的正常结果读成故障)。
|
|
2835
|
+
// noFlag 那几家是查过代码才归进来的:codex 的 error 建好后从不赋值(codex.mjs:172)、
|
|
2836
|
+
// tracecode/tracework 源里明写「工具返回不落地」(tracecode.mjs:335)、generic 的 rules 没有 result 规则。
|
|
2837
|
+
const TOOL_FAIL_TIERS = {
|
|
2838
|
+
signal: new Set(['claude', 'jsonl', 'dsh', 'zcode', 'traedb', 'opencode', 'kilo', 'hermes', 'devindb', 'gemini',
|
|
2839
|
+
'kimi', 'buddyjsonl', 'doubao', 'minimax', 'copilot', 'trace', 'atomcode', 'openclaw', 'cline', 'buddyext']),
|
|
2840
|
+
noDetail: new Set(['cursor', 'codearts']),
|
|
2841
|
+
noFlag: new Set(['codex', 'tracecode', 'tracework', 'generic-jsonl']),
|
|
2842
|
+
};
|
|
2843
|
+
// I13:哪些 kind 的详情**额外**带得出超时 / 软失败的结构化标志(转录里的 `toolUseResult`)。
|
|
2844
|
+
// 与 TOOL_FAIL_TIERS 同一类东西 —— 一份**能力声明**,接新 agent 改这里一行。
|
|
2845
|
+
// v1 只有 claude 系:claude 与 qoder-cn 共用 claude 解析器、共用这个 kind,两家转录实测都带
|
|
2846
|
+
// `timedOutAfterMs` / `returnCodeInterpretation` / `staleRecovered`(claude 30 / 41 / 16,qoder-cn 80 / 72 / 29)。
|
|
2847
|
+
// ⚠️ 别家不在这个集合里 = 「源里根本没有这一层」,不是「一次没超时」⇒ 聚合侧把它们的 t/s 置 null,
|
|
2848
|
+
// 页面对这一档**不印 0**(与 noFlag / qoder「拿不到 token 就不印 0」同一条约定)。
|
|
2849
|
+
// ⚠️ 别把 `jsonl` 加进来:那是 atomcode 的解析器 kind(见 search.mjs 的 parserKindOf),不是 claude。
|
|
2850
|
+
const TOOL_TIMEOUT_SIGNAL_KINDS = new Set(['claude']);
|
|
2851
|
+
// 没点名的 kind 一律 'unknown':宁可说「口径里没登记过这个来源」,也不替它默认「有信号」。
|
|
2852
|
+
function toolFailTier(kind) {
|
|
2853
|
+
if (TOOL_FAIL_TIERS.signal.has(kind)) return 'signal';
|
|
2854
|
+
if (TOOL_FAIL_TIERS.noDetail.has(kind)) return 'noDetail';
|
|
2855
|
+
if (TOOL_FAIL_TIERS.noFlag.has(kind)) return 'noFlag';
|
|
2856
|
+
return 'unknown';
|
|
2857
|
+
}
|
|
2858
|
+
// 分母下限:一次没试过的工具与只跑过一次的偶发失败都不配上排行。实测本机样本里
|
|
2859
|
+
// `StopCommand 1/1` 与 `ExitPlanMode 1/2` 会顶着 100% 排到最前,而 `Bash` 只有 1.0% 却因量大霸榜 ——
|
|
2860
|
+
// 低于这个下限的行照样返回(带着 `lowDenom` 标记),由页面排到「样本不足」那一档,不当结论。
|
|
2861
|
+
const TOOL_FAIL_MIN_TOTAL = 10;
|
|
2862
|
+
|
|
2863
|
+
function analyzeAgg(agentFilter, projectFilter, range, from, to, topN, work) {
|
|
2864
|
+
const { list } = filterEntries(agentFilter, projectFilter, range, from, to, work);
|
|
2865
|
+
const durs = list.map(e => e.dur || 0).filter(d => d > 0).sort((a, b) => a - b);
|
|
2866
|
+
// I17 非 LLM 时间分解:dur 是墙钟(首条→末条),会混进用户离开/隔夜的 idle gap(本机实测
|
|
2867
|
+
// 虚高 2.5 倍)。有 rdur(产品自报 turn_duration.durationMs)的条目才是「真实工作时间」。
|
|
2868
|
+
// 目前只有 claude 有 rdur,别家回落墙钟 —— 页面按 aCount(有多少轮是真值)如实说明覆盖度,
|
|
2869
|
+
// 不把「没这句话」读成「实际时长 = 墙钟」(那正是 I17 要修的错误)。
|
|
2870
|
+
const adurs = list.map(e => e.rdur > 0 ? e.rdur : 0).filter(d => d > 0).sort((a, b) => a - b);
|
|
2871
|
+
const adurCount = list.filter(e => e.rdur > 0).length; // 本轮筛选里拿到真值的轮数
|
|
2872
|
+
const pct = p => durs.length ? durs[Math.min(durs.length - 1, Math.floor(durs.length * p))] : 0;
|
|
2873
|
+
const apct = p => adurs.length ? adurs[Math.min(adurs.length - 1, Math.floor(adurs.length * p))] : 0;
|
|
2874
|
+
const row = e => ({ id: e.id, time: e.time, dur: e.dur, rdur: e.rdur > 0 ? e.rdur : undefined, agent: e.agent, project: e.project, preview: e.preview, status: e.status, models: e.models, total: e.total, tcache: e.tcache, branch: e.branch, permMode: e.permMode, cliVer: e.cliVer });
|
|
2875
|
+
const slowest = [...list].sort((a, b) => (b.dur || 0) - (a.dur || 0)).slice(0, topN).map(row);
|
|
2876
|
+
const byTools = [...list].sort((a, b) => (b.tools || 0) - (a.tools || 0) || (b.dur || 0) - (a.dur || 0)).slice(0, topN)
|
|
2877
|
+
.map(e => ({ ...row(e), tools: e.tools || 0 }));
|
|
2878
|
+
// 缓存命中率:分母为 total(服务端各轮 total 恒 = tin+tout+tcache,见 dailyAgg 的口径注释)。
|
|
2879
|
+
// 分母为零 = 该轮没有任何 token 记录(cursor 转录 / 失败轮),命中率无意义,塞进去反而是噪声。
|
|
2880
|
+
const byCacheRate = list.filter(e => (e.total || 0) > 0)
|
|
2881
|
+
.map(e => ({ ...row(e), rate: (e.tcache || 0) / e.total }))
|
|
2882
|
+
.sort((a, b) => (b.rate - a.rate) || (b.tools || 0) - (a.tools || 0) || (b.time || 0) - (a.time || 0))
|
|
2883
|
+
.slice(0, topN);
|
|
2884
|
+
// ---- I6 工具失败画像 ----
|
|
2885
|
+
// 分子来自搜索分片(search.mjs 建索引那一遍**顺手**抽的 `tf`,见其 indexFailsOf),这里只是一次
|
|
2886
|
+
// Map 累加、不再读盘 —— 详情侧读一次 claude 转录实测 41ms/条,为画像单开一遍就是分钟级。
|
|
2887
|
+
// 分母走条目自带的 `toolNames`,但**只累加分子那一批同集合的轮**:没被索引到的轮连分母一起不计,
|
|
2888
|
+
// 否则率会被「根本没看过的轮」稀释成假的低值。
|
|
2889
|
+
const tfRows = new Map(); // agent\0name -> {agent, name, fail, err, timeout, soft, total, hasTs}
|
|
2890
|
+
const tfCov = new Map(); // agent\0kind -> {agent, kind, tier, tsSig, turns, known, failTurns, softTurns}
|
|
2891
|
+
const tfRow = (agent, name) => {
|
|
2892
|
+
const k = agent + '\u0000' + name;
|
|
2893
|
+
let r = tfRows.get(k);
|
|
2894
|
+
if (!r) { r = { agent, name, fail: 0, err: 0, timeout: 0, soft: 0, total: 0, hasTs: 0 }; tfRows.set(k, r); }
|
|
2895
|
+
return r;
|
|
2896
|
+
};
|
|
2897
|
+
for (const e of list) {
|
|
2898
|
+
if (!(e.tools > 0)) continue; // 没有工具调用的轮:对这个画像既不分子也不分母
|
|
2899
|
+
const kind = srcs.get(e.id)?.kind || '';
|
|
2900
|
+
const tsSig = TOOL_TIMEOUT_SIGNAL_KINDS.has(kind) ? 1 : 0; // I13:这个来源带不带超时/软失败信号
|
|
2901
|
+
const ck = e.agent + '\u0000' + kind;
|
|
2902
|
+
let c = tfCov.get(ck);
|
|
2903
|
+
if (!c) { c = { agent: e.agent, kind, tier: toolFailTier(kind), tsSig, turns: 0, known: 0, failTurns: 0, softTurns: 0 }; tfCov.set(ck, c); }
|
|
2904
|
+
c.turns++;
|
|
2905
|
+
const tf = toolFailsOf(e.id);
|
|
2906
|
+
if (tf === undefined) continue; // 未索引 / 详情读不出 = **未知**,绝不能当 0
|
|
2907
|
+
c.known++;
|
|
2908
|
+
for (const [nm, n] of Object.entries(e.toolNames || {})) {
|
|
2909
|
+
const r = tfRow(e.agent, nm);
|
|
2910
|
+
r.total += n || 0;
|
|
2911
|
+
r.hasTs = tsSig; // 行级记下「这一行的 t/s 是真值还是没信号」
|
|
2912
|
+
}
|
|
2913
|
+
if (tf === 0) continue;
|
|
2914
|
+
let hard = 0, softOnly = 0;
|
|
2915
|
+
for (const [nm, v] of Object.entries(tf)) {
|
|
2916
|
+
const r = tfRow(e.agent, nm);
|
|
2917
|
+
r.err += v.e || 0; r.timeout += v.t || 0; r.soft += v.s || 0;
|
|
2918
|
+
// fail = 出错 + 超时:I13 的定案是**超时算失败**(它就是失败)。软失败**不进** fail ——
|
|
2919
|
+
// 它是单列的一档("rg 没搜到"是正常结果),并进失败率就是把正常读成故障。
|
|
2920
|
+
const h = (v.e || 0) + (v.t || 0);
|
|
2921
|
+
r.fail += h;
|
|
2922
|
+
hard += h; softOnly += (v.s || 0);
|
|
2923
|
+
}
|
|
2924
|
+
// 轮级也照同一把尺:只有软失败的那一轮不算「含失败工具的轮」,另计 softTurns,
|
|
2925
|
+
// 否则页面上「含失败工具的轮」会比失败排行多出一批只搜了个空的轮。
|
|
2926
|
+
if (hard > 0) c.failTurns++;
|
|
2927
|
+
else if (softOnly > 0) c.softTurns++;
|
|
2928
|
+
}
|
|
2929
|
+
// 排序按**绝对失败次数**倒序 —— 「最常失败」问的就是次数;率只作为并列维度与页面第二列。
|
|
2930
|
+
// 为什么不用率排:样本 1 次的工具能排到 100%(见 TOOL_FAIL_MIN_TOTAL 上方那句实测)。
|
|
2931
|
+
const byToolFails = [...tfRows.values()]
|
|
2932
|
+
.filter(r => r.fail > 0)
|
|
2933
|
+
.map(r => ({ ...r, rate: r.total > 0 ? r.fail / r.total : null, lowDenom: r.total < TOOL_FAIL_MIN_TOTAL ? 1 : 0,
|
|
2934
|
+
// I13:这一行没有超时/软失败信号的(别家),把 t/s 置 **null 而不是 0** —— 页面据此不印 0,
|
|
2935
|
+
// 否则「这个 agent 一次没超时」会被读成结论,而真相是「源里没有这一层」。
|
|
2936
|
+
timeout: r.hasTs ? r.timeout : null, soft: r.hasTs ? r.soft : null }))
|
|
2937
|
+
.sort((a, b) => (b.fail - a.fail) || ((b.rate || 0) - (a.rate || 0)) || String(a.name).localeCompare(String(b.name)));
|
|
2938
|
+
// I13:三分类的**总数**按全量行算(不是按 topN 截断后的那些行,也不是按 fail>0 过滤后的那些)——
|
|
2939
|
+
// 否则「只有软失败、从没硬失败」的工具会被漏掉,而那正是最容易被读成"没问题"的一批。
|
|
2940
|
+
const tfTotals = [...tfRows.values()].reduce((o, r) => {
|
|
2941
|
+
o.err += r.err || 0;
|
|
2942
|
+
o.timeout += r.hasTs ? (r.timeout || 0) : 0; // 没信号的来源不计数(也不印 0)
|
|
2943
|
+
o.soft += r.hasTs ? (r.soft || 0) : 0;
|
|
2944
|
+
return o;
|
|
2945
|
+
}, { err: 0, timeout: 0, soft: 0 });
|
|
2946
|
+
return {
|
|
2947
|
+
ok: true,
|
|
2948
|
+
count: list.length,
|
|
2949
|
+
p50: pct(0.5), p95: pct(0.95),
|
|
2950
|
+
maxDur: durs.length ? durs[durs.length - 1] : 0,
|
|
2951
|
+
// I17:实际耗时分位数(有 rdur 的轮)。aCount = 本轮筛选里拿到产品自报真值的轮数
|
|
2952
|
+
//(页面据此说明「实际」覆盖到哪些轮,别家回落墙钟不冒充真值)。
|
|
2953
|
+
aP50: adurs.length ? apct(0.5) : 0, aP95: adurs.length ? apct(0.95) : 0,
|
|
2954
|
+
aMax: adurs.length ? adurs[adurs.length - 1] : 0, aCount: adurCount,
|
|
2955
|
+
slowest,
|
|
2956
|
+
byTools,
|
|
2957
|
+
byCacheRate,
|
|
2958
|
+
// topN 只截工具行;覆盖度那几张表按 agent 数收敛,不跟 topN 走(它是图例不是排行)。
|
|
2959
|
+
toolFails: {
|
|
2960
|
+
minTotal: TOOL_FAIL_MIN_TOTAL,
|
|
2961
|
+
rows: byToolFails.slice(0, topN),
|
|
2962
|
+
truncated: byToolFails.length > topN ? 1 : 0,
|
|
2963
|
+
totalKinds: byToolFails.length,
|
|
2964
|
+
// I13 三分类总数(error / timeout / soft),全量行口径,见上面 tfTotals
|
|
2965
|
+
totals: tfTotals,
|
|
2966
|
+
// 每个 agent×kind 一档:turns = 本轮筛选里带工具的轮数,known = 其中索引到的,failTurns = 有失败的
|
|
2967
|
+
// (tsSig = 这一档带不带超时/软失败信号;softTurns = 只出现过软失败的轮,不进 failTurns)
|
|
2968
|
+
// 顺序按样本量倒排(页面图例直接跟着这个顺序渲染,不再排一次)
|
|
2969
|
+
agents: [...tfCov.values()].sort((a, b) => (b.turns - a.turns) || a.agent.localeCompare(b.agent)).map(c => ({ ...c,
|
|
2970
|
+
turnRate: c.known > 0 ? c.failTurns / c.known : null,
|
|
2971
|
+
unknown: c.turns - c.known })),
|
|
2972
|
+
building: searchBuilding() ? 1 : 0,
|
|
2973
|
+
},
|
|
2974
|
+
};
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
// ---------------- broadcast ----------------
|
|
2978
|
+
// 条目被移除后侧栏计数会失真(remove 事件不带全量快照),这里给前端一份权威计数
|
|
2979
|
+
function counts() {
|
|
2980
|
+
const agents = {}, projects = {};
|
|
2981
|
+
for (const e of entries.values()) { agents[e.agent] = (agents[e.agent] || 0) + 1; projects[e.project] = (projects[e.project] || 0) + 1; }
|
|
2982
|
+
return { agents, projects };
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
function dirtyEntries() {
|
|
2986
|
+
const arr = [];
|
|
2987
|
+
for (const id of dirty) { const e = entries.get(id); if (e) arr.push(e); }
|
|
2988
|
+
dirty.clear();
|
|
2989
|
+
return arr;
|
|
2990
|
+
}
|
|
2991
|
+
const sseClients = new Set(); // 当前连接的 SSE 页面(/api/events)
|
|
2992
|
+
let bseq = 0; // SSE 事件序号
|
|
2993
|
+
let lastActivity = Date.now(); // 「有人看」或「有新日志」的活动时刻,空闲自停据此判断(见 checkIdle)
|
|
2994
|
+
let lastAlertSig = ''; // 上次推给前端的命中集合签名(用于判断是否需要发 alert 事件)
|
|
2995
|
+
let alertChanged = false; // 命中集合与上次相比变了(不论是否有 SSE 连接,状态先记下)
|
|
2996
|
+
function sseWrite(res, msg) {
|
|
2997
|
+
try { res.write('data: ' + JSON.stringify(msg) + '\n\n'); return true; }
|
|
2998
|
+
catch { sseClients.delete(res); return false; }
|
|
2999
|
+
}
|
|
3000
|
+
function broadcast() {
|
|
3001
|
+
// 有新日志就算「有活动」——哪怕页面没开,也要在 checkIdle 里重新计时,
|
|
3002
|
+
// 否则「正在跑会话但没开页面」会被误判成空闲(本机 3s 一轮,新条目是常态)。
|
|
3003
|
+
if (dirty.size || removedIds.length || agentNotices.length) lastActivity = Date.now();
|
|
3004
|
+
// 异常巡检(R9):任何一轮扫描后有新日志(dirty)或者规则被改动(alertDirty)都重算命中,
|
|
3005
|
+
// 保证「新产生的异常条目无需人工翻找即可被提示」。扫完一批就重算一次 + 推送摘要。
|
|
3006
|
+
if (dirty.size || removedIds.length || alertDirty) {
|
|
3007
|
+
recomputeAlerts();
|
|
3008
|
+
const sig = [...alertHits.keys()].sort().join('|');
|
|
3009
|
+
if (sig !== lastAlertSig) { lastAlertSig = sig; alertChanged = true; }
|
|
3010
|
+
}
|
|
3011
|
+
if (!sseClients.size) { dirty.clear(); removedIds.length = 0; agentNotices.length = 0; return; }
|
|
3012
|
+
const list = dirtyEntries();
|
|
3013
|
+
const needCounts = list.length || removedIds.length;
|
|
3014
|
+
const c = needCounts ? counts() : null; // 权威计数随事件下发,避免前端本地累加漂移
|
|
3015
|
+
if (list.length) {
|
|
3016
|
+
if (list.length > 5000) {
|
|
3017
|
+
// 单轮变化过大(如整库 rescan),整表重取比逐条补更省
|
|
3018
|
+
for (const res of [...sseClients]) sseWrite(res, { type: 'resync', seq: ++bseq });
|
|
3019
|
+
} else {
|
|
3020
|
+
// 分批推完,不截断 —— 旧版 slice(-500) 会永久丢掉溢出条目
|
|
3021
|
+
for (let i = 0; i < list.length; i += 500) {
|
|
3022
|
+
const msg = { type: 'update', seq: ++bseq, entries: list.slice(i, i + 500), agents: c.agents, projects: c.projects };
|
|
3023
|
+
for (const res of [...sseClients]) sseWrite(res, msg);
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
if (removedIds.length) {
|
|
3028
|
+
const msg = { type: 'remove', seq: ++bseq, ids: removedIds.splice(0), agents: c.agents, projects: c.projects };
|
|
3029
|
+
for (const res of [...sseClients]) sseWrite(res, msg);
|
|
3030
|
+
}
|
|
3031
|
+
while (agentNotices.length) {
|
|
3032
|
+
const n = agentNotices.shift();
|
|
3033
|
+
const msg = { type: 'agents', seq: ++bseq, ...n, agents: agentsList() };
|
|
3034
|
+
for (const res of [...sseClients]) sseWrite(res, msg);
|
|
3035
|
+
}
|
|
3036
|
+
// 异常巡检(R9):命中集合变化时单独推一个 alert 事件(带摘要 + 命中 id 列表),
|
|
3037
|
+
// 前端据它点亮徽章 / 更新「只看异常」的集合,不用重拉快照。
|
|
3038
|
+
// 这里不走 settings(那是改扫描/自停用的),另起 type 独立消费。
|
|
3039
|
+
if (alertChanged) {
|
|
3040
|
+
alertChanged = false;
|
|
3041
|
+
const msg = { type: 'alert', seq: ++bseq, ids: [...alertHits.keys()], summary: alertSummary() };
|
|
3042
|
+
for (const res of [...sseClients]) sseWrite(res, msg);
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
// 设置项(扫描节奏 / 空闲自停)是全局的,改完广播给所有标签页,避免两个页面显示不一致
|
|
3046
|
+
function broadcastSettings() {
|
|
3047
|
+
const msg = { type: 'settings', seq: ++bseq, scanMs, idleExitMs };
|
|
3048
|
+
for (const res of [...sseClients]) sseWrite(res, msg);
|
|
3049
|
+
}
|
|
3050
|
+
function setScanMs(ms) {
|
|
3051
|
+
if (!SCAN_CHOICES.includes(ms)) return { ok: false, error: '不支持的刷新频率(0/1000/2000/3000/5000/10000)' };
|
|
3052
|
+
scanMs = ms;
|
|
3053
|
+
saveConfig();
|
|
3054
|
+
scheduleNextScan(); // 立刻按新节奏重排;0 会清掉定时器 = 不实时更新
|
|
3055
|
+
broadcastSettings();
|
|
3056
|
+
return { ok: true, scanMs };
|
|
3057
|
+
}
|
|
3058
|
+
function setIdleExitMs(ms) {
|
|
3059
|
+
if (!IDLE_CHOICES.includes(ms)) return { ok: false, error: '不支持的空闲自停(0/1800000/7200000/21600000)' };
|
|
3060
|
+
idleExitMs = ms;
|
|
3061
|
+
lastActivity = Date.now(); // 改设置本身就是一次活动,别让改完立刻到期
|
|
3062
|
+
saveConfig();
|
|
3063
|
+
broadcastSettings();
|
|
3064
|
+
return { ok: true, idleExitMs };
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
// ---------------- 上下文窗口表(设置面板那一个入口) ----------------
|
|
3068
|
+
// 生效表 = 内置默认 + 用户条目;用户条目 value<=0 记作停用(off:true),内置默认也能这样关掉。
|
|
3069
|
+
function ctxWindowRows() {
|
|
3070
|
+
const rows = new Map();
|
|
3071
|
+
for (const [k, v] of Object.entries(CTX_WINDOWS_DEFAULT)) rows.set(k, { name: k, value: v, builtin: true, user: false, off: false });
|
|
3072
|
+
for (const [k, v] of Object.entries(ctxUser)) {
|
|
3073
|
+
const r = rows.get(k) || { name: k, value: 0, builtin: false, user: true, off: false };
|
|
3074
|
+
r.user = true;
|
|
3075
|
+
if (v > 0) { r.value = v; r.off = false; } else r.off = true;
|
|
3076
|
+
rows.set(k, r);
|
|
3077
|
+
}
|
|
3078
|
+
return [...rows.values()].sort((a, b) => (a.off - b.off) || (b.builtin - a.builtin) || a.name.localeCompare(b.name));
|
|
3079
|
+
}
|
|
3080
|
+
// 日志里出现过、但表里查不出窗口的模型 —— 设置面板据此列「待填」,用户不用自己去翻日志抄模型名。
|
|
3081
|
+
// 只管 CTX_WINDOW_KINDS:别的 agent 自带窗口字段(atomcode 的 .meta、cursor 的 composerData),
|
|
3082
|
+
// 这张表对它们没用、也不该去覆盖它们。
|
|
3083
|
+
function ctxUnknownModels() {
|
|
3084
|
+
const seen = new Map();
|
|
3085
|
+
for (const [id, e] of entries) {
|
|
3086
|
+
const s = srcs.get(id);
|
|
3087
|
+
if (!s || !CTX_WINDOW_KINDS.has(s.kind)) continue;
|
|
3088
|
+
for (const m of (e.models || [])) {
|
|
3089
|
+
const name = String(m || '').trim();
|
|
3090
|
+
if (!name || windowOf([name])) continue; // 命中的不算待填
|
|
3091
|
+
if (name.startsWith('<')) continue; // `<synthetic>`:Claude Code 自己造的占位模型名,不是真模型
|
|
3092
|
+
seen.set(name, (seen.get(name) || 0) + 1);
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
return [...seen].map(([model, n]) => ({ model, n })).sort((a, b) => b.n - a.n);
|
|
3096
|
+
}
|
|
3097
|
+
// 改完表要**就地重算**已有条目的 ctx:只改表不回头改条目的话,已经扫出来的卡片要等日志文件下次
|
|
3098
|
+
// 增长才会变(ctx 是在 emit 那一刻定下的),看着就像没生效。CTX_WINDOW_KINDS 里这几个的 ctx
|
|
3099
|
+
// 本来就只由这张表决定,直接覆盖即可;别的 agent 的 ctx 来自它们各自的源头,一律不碰。
|
|
3100
|
+
function applyCtxWindows() {
|
|
3101
|
+
let n = 0;
|
|
3102
|
+
for (const [id, e] of entries) {
|
|
3103
|
+
if (!CTX_WINDOW_KINDS.has(srcs.get(id)?.kind)) continue;
|
|
3104
|
+
const w = windowOf(e.models);
|
|
3105
|
+
if (w !== e.ctx) { e.ctx = w; dirty.add(id); n++; }
|
|
3106
|
+
}
|
|
3107
|
+
if (n) { sorted.cache = null; broadcast(); }
|
|
3108
|
+
return n;
|
|
3109
|
+
}
|
|
3110
|
+
// 整表替换(页面上点保存时提交的就是完整的用户表)。没有值的条目 = 停用,照常写在 ctxUser 里
|
|
3111
|
+
function setCtxWindows(list) {
|
|
3112
|
+
const next = {};
|
|
3113
|
+
for (const it of (Array.isArray(list) ? list : [])) {
|
|
3114
|
+
const name = String((it && it.name) || '').trim();
|
|
3115
|
+
if (!name || name.length > 200) continue;
|
|
3116
|
+
const v = Number(it && it.value);
|
|
3117
|
+
const val = v > 0 ? Math.round(v) : 0;
|
|
3118
|
+
// 页面提交的是**整张表**(含内置行),原封不动提交回来的内置默认不能落盘 ——
|
|
3119
|
+
// 否则「没改过」也会变成用户条目,以后改默认值会被旧配置压住,跟 saveConfig 那条约定正好相反。
|
|
3120
|
+
// 停用(0)例外:那是一次真实决定,必须存。
|
|
3121
|
+
if (val > 0 && CTX_WINDOWS_DEFAULT[name] === val) continue;
|
|
3122
|
+
next[name] = val;
|
|
3123
|
+
}
|
|
3124
|
+
loadCtxWindows(next);
|
|
3125
|
+
saveConfig();
|
|
3126
|
+
return { ok: true, changed: applyCtxWindows(), windows: ctxWindowRows(), unknown: ctxUnknownModels() };
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
// ---------------- Trae 密钥抓取(页面「解密」按钮的服务端) ----------------
|
|
3130
|
+
// traeKey 是**每台机器一把**的 32 字节随机密钥,只存在于 Trae 宿主进程的内存里(磁盘上没有、
|
|
3131
|
+
// 也推不出来,见 REFERENCE.md「Trae 的 SQLCipher 库」)。页面按钮 → 这里 spawn tools/grab-trae-key.ps1:
|
|
3132
|
+
// 脚本先普通权限扫一遍,扫不到就自己弹 UAC 提权重来 —— 提权那一半的 stdout 父进程拿不到,
|
|
3133
|
+
// 所以双方约定用「结果文件」这种最土也最可靠的方式带回 {ok, key?, error?, message?}。
|
|
3134
|
+
// 完成信号 = 子进程退出(脚本在提权接管后会等结果文件出现才退,见 ps1 里那段注释)+
|
|
3135
|
+
// 每次 tick 先试读结果文件(成功/取消提权两个快路径不用等退出)。
|
|
3136
|
+
// 抓取是**机器级**的:同机 trae/traework 共用一把钥匙,所以不区分 agent,成功后写给所有 traedb 条目。
|
|
3137
|
+
const TRAE_KEY_RE = /^[0-9a-fA-F]{64}$/;
|
|
3138
|
+
const CAPTURE_SCRIPT = path.join(ROOT, 'tools', 'grab-trae-key.ps1');
|
|
3139
|
+
const CAPTURE_MINUTES = 3; // 提权实例的扫描窗口:Trae 开过库的场景第一圈就命中;3 分钟够用户去 Trae 里发一条消息
|
|
3140
|
+
let traeCapture = { phase: 'idle', startedAt: 0, finishedAt: 0, error: '', message: '' };
|
|
3141
|
+
let traeCaptureChild = null, traeCaptureTimer = null, traeCaptureOut = '', traeCaptureChildExit = null;
|
|
3142
|
+
|
|
3143
|
+
function traeCaptureSupported() {
|
|
3144
|
+
return process.platform === 'win32' && fs.existsSync(CAPTURE_SCRIPT);
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
// 脚本的失败码 → 给用户看的话(必须带下一步动作;服务端组好整句放进 state.message,页面直接 toast)
|
|
3148
|
+
const CAPTURE_ERRS = {
|
|
3149
|
+
'no-trae-process': '没有找到 Trae 进程。先打开 Trae CN(随便发一条 AI 消息),再点一次「解密」。',
|
|
3150
|
+
'no-key': '扫描结束仍没抓到密钥。抓取过程中请在 Trae 里发一条 AI 消息(触发它打开数据库),然后重试。',
|
|
3151
|
+
'elevation-canceled': '管理员授权被拒绝(UAC 弹窗)。Trae 的宿主进程带反调试,读它的内存必须过一次 UAC —— 重试并在弹窗上点「是」。',
|
|
3152
|
+
'scan-error': '抓取脚本出错',
|
|
3153
|
+
'timeout': '抓取超时(脚本没在预期时间内写回结果)。请重试;若 Trae 刚启动,先在 Trae 里发一条消息。',
|
|
3154
|
+
'script-exit': '抓取脚本退出且没有写回结果(提权弹窗被关掉,或被安全软件/PowerShell 执行策略拦住)。重试一次并在 UAC 弹窗上点「是」;若反复出现,可照 REFERENCE.md 的手动命令自查。',
|
|
3155
|
+
};
|
|
3156
|
+
function captureErrText(code, detail) {
|
|
3157
|
+
// 未知错误码也保留原文:新脚本版本加了码但服务端还没认识时,宁可难看也别吞掉线索
|
|
3158
|
+
const base = CAPTURE_ERRS[code] || ('抓取失败(' + code + ')');
|
|
3159
|
+
return detail ? base + '(' + detail + ')' : base;
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
function traeCaptureState() {
|
|
3163
|
+
return { phase: traeCapture.phase, startedAt: traeCapture.startedAt, finishedAt: traeCapture.finishedAt,
|
|
3164
|
+
error: traeCapture.error, message: traeCapture.message, minutes: CAPTURE_MINUTES };
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
function endTraeCapture(phase, error, message) {
|
|
3168
|
+
traeCapture.phase = phase;
|
|
3169
|
+
traeCapture.finishedAt = Date.now();
|
|
3170
|
+
traeCapture.error = error || '';
|
|
3171
|
+
traeCapture.message = message || '';
|
|
3172
|
+
if (traeCaptureTimer) { clearInterval(traeCaptureTimer); traeCaptureTimer = null; }
|
|
3173
|
+
if (traeCaptureOut) {
|
|
3174
|
+
const f = traeCaptureOut;
|
|
3175
|
+
try { fs.unlinkSync(f); } catch {} // 结果文件里有明文密钥,读完/放弃即删
|
|
3176
|
+
// 放弃时提权实例可能还在扫、稍后才把文件写出来 —— 补一枪,别让密钥文件留在 %TEMP% 里过夜
|
|
3177
|
+
setTimeout(() => { try { fs.unlinkSync(f); } catch {} }, 120000).unref?.();
|
|
3178
|
+
traeCaptureOut = '';
|
|
3179
|
+
}
|
|
3180
|
+
traeCaptureChild = null;
|
|
3181
|
+
log('[trae-capture]', phase, error || '', message || '');
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
// 抓到密钥:写给**所有** traedb 条目(机器级共享一把钥匙),落盘 + 通知 + 重扫。
|
|
3185
|
+
// 重扫不可省:scanTraeDb 按 (库签名, keyHash) 增量,换了钥匙 keyHash 变了才会重解析。
|
|
3186
|
+
function applyTraeKey(keyHex) {
|
|
3187
|
+
const k = String(keyHex || '').trim().toLowerCase();
|
|
3188
|
+
if (!TRAE_KEY_RE.test(k)) return 0;
|
|
3189
|
+
let n = 0, first = '';
|
|
3190
|
+
for (const [name, c] of agentConfs) {
|
|
3191
|
+
if (c.kind !== 'traedb') continue;
|
|
3192
|
+
agentConfs.set(name, { ...c, traeKey: k });
|
|
3193
|
+
if (!first) first = name;
|
|
3194
|
+
n++;
|
|
3195
|
+
}
|
|
3196
|
+
if (n) {
|
|
3197
|
+
saveConfig();
|
|
3198
|
+
agentNotices.push({ traeKey: first });
|
|
3199
|
+
scanAll(true);
|
|
3200
|
+
}
|
|
3201
|
+
return n;
|
|
3202
|
+
}
|
|
3203
|
+
|
|
3204
|
+
function clearTraeKeys() {
|
|
3205
|
+
let n = 0;
|
|
3206
|
+
for (const [name, c] of agentConfs) {
|
|
3207
|
+
if (c.kind !== 'traedb' || !c.traeKey) continue;
|
|
3208
|
+
const { traeKey, ...rest } = c;
|
|
3209
|
+
agentConfs.set(name, rest);
|
|
3210
|
+
n++;
|
|
3211
|
+
}
|
|
3212
|
+
if (n) {
|
|
3213
|
+
saveConfig();
|
|
3214
|
+
agentNotices.push({ traeKeyCleared: 'trae' });
|
|
3215
|
+
scanAll(true); // 清掉后下一轮扫描会走回 renderer.log 回退(scanTraeDb 的 nokey 分支)
|
|
3216
|
+
}
|
|
3217
|
+
return n;
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
function startTraeCapture() {
|
|
3221
|
+
if (!traeCaptureSupported()) return { ok: false, error: '这个平台/这份安装里没有抓取脚本(tools/grab-trae-key.ps1),只能在 Windows 上从页面抓' };
|
|
3222
|
+
if (traeCapture.phase === 'running') return { ok: false, error: '已有一次抓取在进行中,等它结束(或到 Trae 里发一条消息帮它命中)' };
|
|
3223
|
+
const outFile = path.join(os.tmpdir(), 'agentacta-traekey-' + process.pid + '-' + Date.now() + '.json');
|
|
3224
|
+
let child;
|
|
3225
|
+
try {
|
|
3226
|
+
child = spawn('powershell.exe',
|
|
3227
|
+
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', CAPTURE_SCRIPT, '-Minutes', String(CAPTURE_MINUTES), '-OutFile', outFile],
|
|
3228
|
+
{ windowsHide: true, stdio: 'ignore' });
|
|
3229
|
+
} catch (e) {
|
|
3230
|
+
return { ok: false, error: '启动抓取脚本失败:' + e.message };
|
|
3231
|
+
}
|
|
3232
|
+
traeCapture = { phase: 'running', startedAt: Date.now(), finishedAt: 0, error: '', message: '' };
|
|
3233
|
+
traeCaptureChild = child;
|
|
3234
|
+
traeCaptureOut = outFile;
|
|
3235
|
+
traeCaptureChildExit = null;
|
|
3236
|
+
let exitedAt = 0;
|
|
3237
|
+
child.on('exit', code => { exitedAt = Date.now(); traeCaptureChildExit = code; });
|
|
3238
|
+
child.on('error', e => {
|
|
3239
|
+
// spawn 本身失败(不常见;正常失败都走结果文件/退出码)
|
|
3240
|
+
if (traeCapture.phase === 'running') endTraeCapture('error', 'script-exit', captureErrText('script-exit', e.message));
|
|
3241
|
+
});
|
|
3242
|
+
const startedAt = Date.now();
|
|
3243
|
+
traeCaptureTimer = setInterval(() => {
|
|
3244
|
+
if (traeCapture.phase !== 'running') return;
|
|
3245
|
+
const j = readCaptureResult();
|
|
3246
|
+
if (j) return finishFromResult(j);
|
|
3247
|
+
// 子进程退了、结果文件却还不在(给文件落盘留 2s 宽限):脚本被拦/被杀,没别的路径能带回原因了
|
|
3248
|
+
if (exitedAt && Date.now() - exitedAt > 2000) {
|
|
3249
|
+
return endTraeCapture('error', 'script-exit', captureErrText('script-exit', '退出码 ' + traeCaptureChildExit));
|
|
3250
|
+
}
|
|
3251
|
+
// 兜底保险丝:理论上子进程自己的等待窗口就有界(见 ps1),这里只防「脚本自己卡死」
|
|
3252
|
+
if (Date.now() - startedAt > (CAPTURE_MINUTES * 60 + 300) * 1000) endTraeCapture('error', 'timeout', CAPTURE_ERRS['timeout']);
|
|
3253
|
+
}, 1000);
|
|
3254
|
+
traeCaptureTimer.unref?.();
|
|
3255
|
+
return { ok: true, state: traeCaptureState() };
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
function readCaptureResult() {
|
|
3259
|
+
if (!traeCaptureOut) return null;
|
|
3260
|
+
let txt;
|
|
3261
|
+
try { txt = fs.readFileSync(traeCaptureOut, 'utf8'); } catch { return null; }
|
|
3262
|
+
// 脚本用 UTF8(无 BOM) 一次性写全;万一读到半截(别的工具写的),JSON.parse 失败就当没写完,下个 tick 再读
|
|
3263
|
+
const j = (() => { try { return JSON.parse(txt.replace(/^\uFEFF/, '')); } catch { return null; } })();
|
|
3264
|
+
return j && typeof j === 'object' ? j : null;
|
|
3265
|
+
}
|
|
3266
|
+
function finishFromResult(j) {
|
|
3267
|
+
if (j.ok && typeof j.key === 'string') {
|
|
3268
|
+
if (!TRAE_KEY_RE.test(j.key.trim())) return endTraeCapture('error', 'bad-key', '抓到的密钥格式不对(不是 64 位十六进制),未写入');
|
|
3269
|
+
const n = applyTraeKey(j.key);
|
|
3270
|
+
if (!n) return endTraeCapture('done', '', '抓到了密钥,但当前没有 traedb 类型的 agent 可写入(先把 trae 添加回来)');
|
|
3271
|
+
return endTraeCapture('done', '', '');
|
|
3272
|
+
}
|
|
3273
|
+
const code = typeof j.error === 'string' && j.error ? j.error : 'scan-error';
|
|
3274
|
+
endTraeCapture('error', code, captureErrText(code, typeof j.message === 'string' ? j.message : ''));
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
// ---------------- Cursor token 采集 hook 的注入 / 卸载(页面 cursor 行「注入」按钮的服务端) ----------------
|
|
3278
|
+
// 为什么需要它:cursor 的逐轮 token **只**出现在它的 stop 钩子载荷里 —— 转录文件只有 role + text,
|
|
3279
|
+
// state.vscdb 的 usageData 是空对象、tokenCount 恒为 0(见 parsers/cursor.mjs 的两段注释)。
|
|
3280
|
+
// 所以这里做的事很窄:往 ~/.cursor/hooks.json 的 hooks.stop 里加/删一条指向 hooks/cursor-usage-hook.mjs 的命令;
|
|
3281
|
+
// 脚本在每轮结束时把用量追加到 ~/.agent-acta/cursor-usage.jsonl,解析器再读那份日志配对到轮上。
|
|
3282
|
+
//
|
|
3283
|
+
// 三条硬约束:
|
|
3284
|
+
// 1) **绝不覆盖用户的 hooks.json**:只动 hooks.stop 里我们自己那一条,别的键/别的钩子原样保留。
|
|
3285
|
+
// 文件存在但解析不出 JSON 时报错退出,绝不「重置成默认」—— 那等于静默删掉用户自己的钩子。
|
|
3286
|
+
// 2) **认自己那条靠脚本文件名**(cursor-usage-hook.mjs),不靠绝对路径:安装目录搬过、路径写法
|
|
3287
|
+
// 正/反斜杠不同、用户手工改过命令,都还认得出来,卸载不会漏也不会误删别人的。
|
|
3288
|
+
// 3) **命令里的解释器写 process.execPath**(正跑着本服务的那个 node 的绝对路径),不写 `node`:
|
|
3289
|
+
// Cursor 子进程的 PATH 未必有 node(服务可能是被 IDE/快捷方式用绝对路径拉起来的)。
|
|
3290
|
+
const CURSOR_HOOK_SCRIPT = path.join(ROOT, 'hooks', 'cursor-usage-hook.mjs');
|
|
3291
|
+
const CURSOR_HOOK_NAME = 'cursor-usage-hook.mjs';
|
|
3292
|
+
const CURSOR_HOOK_TIMEOUT = 10; // 秒。脚本自己的 stdin 等待窗口是 5s,留一倍余量
|
|
3293
|
+
export const CURSOR_USAGE_LOG = path.join(DATA_DIR, 'cursor-usage.jsonl'); // 解析器也要读,故导出
|
|
3294
|
+
|
|
3295
|
+
// user 级 hooks 固定在这里(项目级的 <repo>/.cursor/hooks.json 是另一份,我们不碰)
|
|
3296
|
+
function cursorHooksFile() { return path.join(HOME, '.cursor', 'hooks.json'); }
|
|
3297
|
+
function isCursorHookEntry(h) {
|
|
3298
|
+
return !!h && typeof h.command === 'string' && h.command.includes(CURSOR_HOOK_NAME);
|
|
3299
|
+
}
|
|
3300
|
+
// 读出来就地补好 hooks 容器。返回 {ok, j, existed} 或 {ok:false, error}
|
|
3301
|
+
function readCursorHooks() {
|
|
3302
|
+
const fp = cursorHooksFile();
|
|
3303
|
+
let txt;
|
|
3304
|
+
try { txt = fs.readFileSync(fp, 'utf8'); }
|
|
3305
|
+
catch (e) {
|
|
3306
|
+
if (e.code === 'ENOENT') return { ok: true, j: { version: 1, hooks: {} }, existed: false };
|
|
3307
|
+
return { ok: false, error: '读不到 ' + fp + ':' + e.message };
|
|
3308
|
+
}
|
|
3309
|
+
let j;
|
|
3310
|
+
try { j = JSON.parse(txt.replace(/^\uFEFF/, '')); }
|
|
3311
|
+
catch { return { ok: false, error: fp + ' 不是合法 JSON,未做任何改动(请先手工修好,免得覆盖你自己的钩子)' }; }
|
|
3312
|
+
if (!j || typeof j !== 'object' || Array.isArray(j)) return { ok: false, error: fp + ' 的内容不是对象,未做任何改动' };
|
|
3313
|
+
if (!j.hooks || typeof j.hooks !== 'object' || Array.isArray(j.hooks)) j.hooks = {};
|
|
3314
|
+
return { ok: true, j, existed: true };
|
|
3315
|
+
}
|
|
3316
|
+
function writeCursorHooks(j) {
|
|
3317
|
+
const fp = cursorHooksFile();
|
|
3318
|
+
try {
|
|
3319
|
+
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
3320
|
+
// 2 空格缩进 + 结尾换行:Cursor 自己也是这么写的,两边交替改也看不出 diff 噪声
|
|
3321
|
+
fs.writeFileSync(fp, JSON.stringify(j, null, 2) + '\n', 'utf8');
|
|
3322
|
+
return { ok: true };
|
|
3323
|
+
} catch (e) { return { ok: false, error: '写 ' + fp + ' 失败:' + e.message }; }
|
|
3324
|
+
}
|
|
3325
|
+
function cursorHookStatus() {
|
|
3326
|
+
const script = fs.existsSync(CURSOR_HOOK_SCRIPT);
|
|
3327
|
+
const r = readCursorHooks();
|
|
3328
|
+
const base = { script, file: cursorHooksFile(), log: CURSOR_USAGE_LOG };
|
|
3329
|
+
if (!r.ok) return { ...base, injected: false, error: r.error };
|
|
3330
|
+
const stop = Array.isArray(r.j.hooks.stop) ? r.j.hooks.stop : [];
|
|
3331
|
+
const entry = stop.find(isCursorHookEntry);
|
|
3332
|
+
return { ...base, injected: !!entry, command: entry ? entry.command : '' };
|
|
3333
|
+
}
|
|
3334
|
+
// 命令里的两个参数一律加引号:路径可能带空格,而且 Cursor 是按 shell 命令跑的(官方示例带参数,
|
|
3335
|
+
// 说明不是直接 exec),引号由 shell 剥掉。
|
|
3336
|
+
function cursorHookCommand() {
|
|
3337
|
+
return '"' + process.execPath + '" "' + CURSOR_HOOK_SCRIPT + '"';
|
|
3338
|
+
}
|
|
3339
|
+
function installCursorHook() {
|
|
3340
|
+
if (!fs.existsSync(CURSOR_HOOK_SCRIPT)) return { ok: false, error: '这份安装里没有 hooks/' + CURSOR_HOOK_NAME + ',无法注入' };
|
|
3341
|
+
const r = readCursorHooks();
|
|
3342
|
+
if (!r.ok) return { ok: false, error: r.error };
|
|
3343
|
+
const j = r.j;
|
|
3344
|
+
if (!Number.isFinite(Number(j.version))) j.version = 1;
|
|
3345
|
+
if (!Array.isArray(j.hooks.stop)) j.hooks.stop = [];
|
|
3346
|
+
if (j.hooks.stop.some(isCursorHookEntry)) {
|
|
3347
|
+
// 已经在里面了:把命令刷成当前这份安装的路径(安装目录搬过、node 换过都能自愈),其余不动
|
|
3348
|
+
const cmd = cursorHookCommand();
|
|
3349
|
+
const i = j.hooks.stop.findIndex(isCursorHookEntry);
|
|
3350
|
+
if (j.hooks.stop[i].command === cmd) return { ok: true, injected: true, already: true, ...cursorHookStatus() };
|
|
3351
|
+
j.hooks.stop[i] = { ...j.hooks.stop[i], command: cmd };
|
|
3352
|
+
const w = writeCursorHooks(j);
|
|
3353
|
+
return w.ok ? { ok: true, injected: true, updated: true, ...cursorHookStatus() } : w;
|
|
3354
|
+
}
|
|
3355
|
+
j.hooks.stop.push({ command: cursorHookCommand(), timeout: CURSOR_HOOK_TIMEOUT });
|
|
3356
|
+
const w = writeCursorHooks(j);
|
|
3357
|
+
return w.ok ? { ok: true, injected: true, ...cursorHookStatus() } : w;
|
|
3358
|
+
}
|
|
3359
|
+
function uninstallCursorHook() {
|
|
3360
|
+
const r = readCursorHooks();
|
|
3361
|
+
if (!r.ok) return { ok: false, error: r.error };
|
|
3362
|
+
const j = r.j;
|
|
3363
|
+
const stop = Array.isArray(j.hooks.stop) ? j.hooks.stop : [];
|
|
3364
|
+
const keep = stop.filter(h => !isCursorHookEntry(h));
|
|
3365
|
+
const removed = stop.length - keep.length;
|
|
3366
|
+
if (!removed) return { ok: true, injected: false, removed: 0, ...cursorHookStatus() };
|
|
3367
|
+
// 数组空了就把 stop 这个键删掉(留着空数组是纯噪声);已采集的 cursor-usage.jsonl **不删** ——
|
|
3368
|
+
// 那些是真实采到的用量,卸载只是停止继续采,解析器照旧认它。
|
|
3369
|
+
if (keep.length) j.hooks.stop = keep; else delete j.hooks.stop;
|
|
3370
|
+
const w = writeCursorHooks(j);
|
|
3371
|
+
return w.ok ? { ok: true, injected: false, removed, ...cursorHookStatus() } : w;
|
|
3372
|
+
}
|
|
3373
|
+
|
|
3374
|
+
// 落盘索引 → 断开 SSE → 删 pid 文件 → 停掉全部定时器 → 按退出策略决定退不退进程。
|
|
3375
|
+
// SSE 是长连接,只 server.close() 进程退不掉。
|
|
3376
|
+
//
|
|
3377
|
+
// **Step 2 的拆点**:原先这里直接 process.exit(0),而 /api/shutdown 就挂在它上面 ——
|
|
3378
|
+
// 插件把这个服务挂进宿主 DSH 之后,「点一下页面上的停止」等于**把宿主进程关掉**(评审 §3.1 点名的根因)。
|
|
3379
|
+
// 所以退出权改成注入的:cli 传真 process.exit(今天的行为),hosted 传空函数(只停循环,命留给宿主)。
|
|
3380
|
+
// 模块级默认取**空函数**而不是 process.exit:这是失败方向的选择 —— 默认不动手,就绝不会出现
|
|
3381
|
+
// 「宿主忘了传策略 ⇒ 插件把宿主关了」;cli 那条路在 start() 里显式传进来,不受这个默认影响。
|
|
3382
|
+
let exitPolicy = () => {};
|
|
3383
|
+
let shuttingDown = false;
|
|
3384
|
+
export function stop(reason) {
|
|
3385
|
+
if (shuttingDown) return;
|
|
3386
|
+
shuttingDown = true;
|
|
3387
|
+
log('[agent-acta] shutting down: ' + reason);
|
|
3388
|
+
// 抓取中的脚本不等它了:留着它只会继续扫内存、把(含明文密钥的)结果文件写到一个没人读的路径上
|
|
3389
|
+
if (traeCaptureChild) { try { traeCaptureChild.kill(); } catch {} traeCaptureChild = null; }
|
|
3390
|
+
if (traeCaptureTimer) { clearInterval(traeCaptureTimer); traeCaptureTimer = null; }
|
|
3391
|
+
if (traeCaptureOut) { try { fs.unlinkSync(traeCaptureOut); } catch {} traeCaptureOut = ''; }
|
|
3392
|
+
try { saveIndex(); } catch (e) { console.error('[index]', e.message); }
|
|
3393
|
+
for (const res of [...sseClients]) { try { res.end(); } catch {} sseClients.delete(res); }
|
|
3394
|
+
// 只删自己那份 pid 文件:第二个实例撞 EADDRINUSE 退出时也会走到这里,别把在跑的那个的文件删了
|
|
3395
|
+
try { if (Number(fs.readFileSync(PID_FILE, 'utf8').trim()) === process.pid) fs.unlinkSync(PID_FILE); } catch {}
|
|
3396
|
+
// 定时器全撤:不等这一步的话,hosted 下 stop() 之后归档轮 / 搜索轮 / 扫描自续期还在后台扫盘写索引。
|
|
3397
|
+
clearTimers();
|
|
3398
|
+
if (server.listening) { try { server.close(); } catch {} }
|
|
3399
|
+
exitPolicy(0);
|
|
3400
|
+
}
|
|
3401
|
+
// 空闲自停:页面全关(无 SSE 连接)且这段时间没有任何新条目 → 自己退。
|
|
3402
|
+
// 计时器在 listen 时就挂上(不是等首扫):被 hook `--ensure` 拉起、之后一直没人打开页面的服务,
|
|
3403
|
+
// 根本没人发过第一个 HTTP 请求,startScanning 从未执行 —— 挂在它里面就等于永远不自停。
|
|
3404
|
+
// hosted 模式不挂它(插件里那个服务不是「没人看就自己走」的进程,命归宿主)—— 见 start() 里的 guard。
|
|
3405
|
+
function checkIdle() {
|
|
3406
|
+
const limit = IDLE_ENV || idleExitMs;
|
|
3407
|
+
if (!limit || sseClients.size) return;
|
|
3408
|
+
const idle = Date.now() - lastActivity;
|
|
3409
|
+
if (idle >= limit) stop('空闲 ' + Math.round(idle / 1000) + 's(无页面连接、无新日志)');
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
// SSE 心跳:防代理/负载均衡掐空闲连接;写失败即清理断开的 client。
|
|
3413
|
+
// **Step 2 收口**:原来是 `if (!ENSURE_FLAG && !LIB) setInterval(...)` —— 服务代码直接读 CLI flag,
|
|
3414
|
+
// 这是 §5 点名的唯一真正的泄漏。现在它只由 start({ keepAlive }) 决定,服务侧一个 CLI 旗标都不认了:
|
|
3415
|
+
// 谁要长连接(前台常驻 / 插件宿主)谁传 keepAlive:true 从 start() 里挂上;
|
|
3416
|
+
// --ensure 那个进程根本不调 start()(它只负责拉起别人),所以「不挂定时器」这件事不用再判。
|
|
3417
|
+
// 挂上之后由 stop() 的 clearTimers() 统一撤掉,不再是一挂到进程结束。
|
|
3418
|
+
let heartTimer = null;
|
|
3419
|
+
function startHeartbeat() {
|
|
3420
|
+
heartTimer = every(() => {
|
|
3421
|
+
for (const res of [...sseClients]) { try { res.write(': ping\n\n'); } catch { sseClients.delete(res); } }
|
|
3422
|
+
}, 25000);
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3425
|
+
// ---------------- http ----------------
|
|
3426
|
+
function sortedEntries() {
|
|
3427
|
+
if (!sorted.cache) sorted.cache = [...entries.values()].sort((a, b) => (b.time || 0) - (a.time || 0));
|
|
3428
|
+
return sorted.cache;
|
|
3429
|
+
}
|
|
3430
|
+
|
|
3431
|
+
function readBody(req, cb) {
|
|
3432
|
+
let b = '';
|
|
3433
|
+
req.on('data', c => { b += c; if (b.length > 1e5) req.destroy(); });
|
|
3434
|
+
req.on('end', () => cb(b));
|
|
3435
|
+
}
|
|
3436
|
+
|
|
3437
|
+
// 跨域写保护:无 CORS 头(浏览器跨域读被同源策略拦截);写操作再校验 Origin 必须同源。
|
|
3438
|
+
//
|
|
3439
|
+
// **Step 2 补丁(2026-09-29)**:判据原来硬绑在 PORT 上(`ou.port === String(PORT)`)。CLI 下那是对的,
|
|
3440
|
+
// 在宿主里是**错的** —— 面板 iframe 的 Origin 是 `http://127.0.0.1:<宿主端口>` 或 `dsh-app://app`,
|
|
3441
|
+
// 端口永远不是 14570,于是 13 处写路径(`badOrigin(req)` 的全部调用点)全 403。
|
|
3442
|
+
// 症状极具误导性:**面板读得到、任何保存都失败** —— 读路径不查 Origin,所以「连接明明是通的」,
|
|
3443
|
+
// 排障会往 CORS / 反代 / 权限上找,而根因是一个写死的端口号。
|
|
3444
|
+
//
|
|
3445
|
+
// 所以判据改成**可注入**:cli 逐字不变;hosted 默认收「环回主机名(端口不限)+ dsh-app 协议」。
|
|
3446
|
+
// 宿主想再收紧(比如只认它自己那一个 Origin)就传 start({ originIsLocal }) 给一个判据,别在插件里绕。
|
|
3447
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']); // [::1] 是 URL 里 IPv6 环回的 hostname 写法
|
|
3448
|
+
const ORIGIN_GATES = {
|
|
3449
|
+
// cli:与搬家前**逐字一致** —— 环回主机名 **且** 端口就是本服务的 PORT。
|
|
3450
|
+
// 两条断言钉着它,改这两行之前先看它们:本文件 [7] 的「cli 端口不符要 403」,
|
|
3451
|
+
// 以及 traedb-test 的「外来主机名要 403」。
|
|
3452
|
+
cli: u => (u.hostname === '127.0.0.1' || u.hostname === 'localhost') && u.port === String(PORT),
|
|
3453
|
+
// lib:只读库不经过 HTTP,这条永远不会被调用;与 cli 保持同一个表达式,
|
|
3454
|
+
// 免得有人看见第三种写法以后以为「lib 的口径不一样」。
|
|
3455
|
+
lib: u => (u.hostname === '127.0.0.1' || u.hostname === 'localhost') && u.port === String(PORT),
|
|
3456
|
+
// hosted:端口归宿主,写死任何一个端口都是错的。收「http(s) + 环回主机名(端口不限)」与 dsh-app://。
|
|
3457
|
+
// 收紧的是**主机名与协议**那一维 —— 跨站页面(evil.example)照样拒:闸门没有被改松,
|
|
3458
|
+
// 只是不再拿「端口等于 14570」当同源标识(那个标识在宿主里根本不存在)。
|
|
3459
|
+
hosted: u => u.protocol === 'dsh-app:' ||
|
|
3460
|
+
((u.protocol === 'http:' || u.protocol === 'https:') && LOOPBACK_HOSTS.has(u.hostname)),
|
|
3461
|
+
};
|
|
3462
|
+
// null = 用当前 MODE 的内置判据。传函数即覆盖;传别的一律回到内置。
|
|
3463
|
+
// start() 每次都会重置它(不传 originIsLocal 就重置成 null),免得同一进程里两次 start 之间串味。
|
|
3464
|
+
let originGate = null;
|
|
3465
|
+
export function setOriginGate(fn) { originGate = typeof fn === 'function' ? fn : null; }
|
|
3466
|
+
function badOrigin(req) {
|
|
3467
|
+
const o = req.headers.origin;
|
|
3468
|
+
if (!o) return false; // curl / 导航请求无 Origin
|
|
3469
|
+
try {
|
|
3470
|
+
const gate = originGate || ORIGIN_GATES[MODE] || ORIGIN_GATES.cli;
|
|
3471
|
+
return !gate(new URL(o));
|
|
3472
|
+
} catch { return true; } // Origin 解析不出来一律拒(与搬家前一致)
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
// /api/client 那条路由要唤起悬浮卡片壳,而那段(runClient → runEnsure → spawnServer)**必须留在 CLI 入口**:
|
|
3476
|
+
// spawnServer 里那句 fileURLToPath(import.meta.url) 得指入口文件,跟着搬进 core 就会去 spawn
|
|
3477
|
+
// core/service.mjs —— 那时 argv[1] 不是入口 ⇒ LIB 为真 ⇒ 什么都不会发生,**而且不报错**(正是 §2 那类
|
|
3478
|
+
// 静默改语义)。core 不能反向 import 入口,所以由入口启动时把 runClient 注进来。
|
|
3479
|
+
// 这是 Step 1 里**唯一**一处不是纯搬家的地方:只加了一个可空钩子,没动任何原有分支;
|
|
3480
|
+
// 入口没注入时给出话说清楚,而不是静默什么都不做。Step 2 的 start(opts) 可以把它收成一个 opts 字段。
|
|
3481
|
+
let clientSpawner = null;
|
|
3482
|
+
export function setClientSpawner(fn) { clientSpawner = fn; }
|
|
3483
|
+
|
|
3484
|
+
// ---------------- 路由表(Step 2 抽表) ----------------
|
|
3485
|
+
// 抽表不是为了好看,是三件事同时要它:
|
|
3486
|
+
// ① 守卫拿它跟 test/routes.expected.json 对账(表 ≡ 预期),「插件悄悄少挂一条」当场红;
|
|
3487
|
+
// ② 插件壳按它逐条 register(hosted 模式下 prefix 路由在真宿主上不生效,只能按表逐条来);
|
|
3488
|
+
// ③ 抽完以后路由清单只有**一份**(原来是 if 链里数一遍、清单里再数一遍,两边必然漂)。
|
|
3489
|
+
// 表是**权威**的:handle() 就按它派发,不是「声明一份、实现另一份」。
|
|
3490
|
+
//
|
|
3491
|
+
// 每条五个字段:
|
|
3492
|
+
// path u.pathname 的精确值(前缀路由在下面 ROUTE_PREFIXES,不在这张表里)
|
|
3493
|
+
// method 对外口径 —— 守卫按它挑探法、插件壳按它决定注册范围。与 routes.expected.json 的
|
|
3494
|
+
// method 字段逐字一致(守卫会断言这一点)。它**不是**方法闸:多条路由接受不止一种方法
|
|
3495
|
+
// methods 实现口径的方法闸。null = 不断方法(照单全收,与原来那批「GET 即达」的路由写法一致)
|
|
3496
|
+
// miss 方法不符时答什么:'404' = 这条压根没命中、继续往下找(原来写作 `pathname && req.method === 'POST'`);
|
|
3497
|
+
// '405' = 命中了但不认这个方法(原来写作「进分支后再判 method」)
|
|
3498
|
+
// fn 处理函数(签名 (req, res, u)),函数体是从原来那条 if 分支里**逐字搬**过来的
|
|
3499
|
+
//
|
|
3500
|
+
// ⚠ 顺序不重要(路径两两不重复),但保持与抽表前 if 链**同一个顺序** —— 这样 review 时能一行行对着看。
|
|
3501
|
+
const ROUTES = [
|
|
3502
|
+
{ path: '/api/ping', method: 'GET', methods: null, miss: '404', fn: routePing },
|
|
3503
|
+
{ path: '/api/shutdown', method: 'POST', methods: ['POST'], miss: '405', fn: routeShutdown },
|
|
3504
|
+
{ path: '/api/client', method: 'POST', methods: ['POST'], miss: '405', fn: routeClient },
|
|
3505
|
+
{ path: '/api/archive/index', method: 'GET', methods: null, miss: '404', fn: routeArchiveIndex },
|
|
3506
|
+
{ path: '/api/archive/skip', method: 'POST', methods: ['POST'], miss: '404', fn: routeArchiveSkip },
|
|
3507
|
+
{ path: '/api/archive/entries', method: 'GET', methods: null, miss: '404', fn: routeArchiveEntries },
|
|
3508
|
+
{ path: '/api/archive/entry', method: 'GET', methods: null, miss: '404', fn: routeArchiveEntry },
|
|
3509
|
+
{ path: '/api/diagnose', method: 'GET', methods: null, miss: '404', fn: routeDiagnose },
|
|
3510
|
+
{ path: '/api/usage', method: 'GET', methods: null, miss: '404', fn: routeUsage },
|
|
3511
|
+
{ path: '/api/search/status', method: 'GET', methods: null, miss: '404', fn: routeSearchStatus },
|
|
3512
|
+
{ path: '/api/search', method: 'GET', methods: null, miss: '404', fn: routeSearch },
|
|
3513
|
+
{ path: '/api/selftest', method: 'GET', methods: null, miss: '404', fn: routeSelftest },
|
|
3514
|
+
{ path: '/api/agents', method: 'GET', methods: null, miss: '404', fn: routeAgents },
|
|
3515
|
+
{ path: '/api/agents/toggle', method: 'POST', methods: ['POST'], miss: '404', fn: routeAgentsToggle },
|
|
3516
|
+
{ path: '/api/trae/capture-key', method: 'GET/POST', methods: null, miss: '404', fn: routeTraeCaptureKey },
|
|
3517
|
+
{ path: '/api/trae/clear-key', method: 'POST', methods: ['POST'], miss: '404', fn: routeTraeClearKey },
|
|
3518
|
+
{ path: '/api/cursor-hook', method: 'GET', methods: null, miss: '404', fn: routeCursorHook },
|
|
3519
|
+
{ path: '/api/settings', method: 'GET', methods: null, miss: '404', fn: routeSettings },
|
|
3520
|
+
{ path: '/api/alert', method: 'GET', methods: null, miss: '404', fn: routeAlert },
|
|
3521
|
+
{ path: '/api/ctxwindows', method: 'GET', methods: null, miss: '404', fn: routeCtxWindows },
|
|
3522
|
+
{ path: '/api/version', method: 'GET', methods: null, miss: '404', fn: routeVersion },
|
|
3523
|
+
{ path: '/', method: 'GET', methods: null, miss: '404', fn: routePage },
|
|
3524
|
+
{ path: '/widget', method: 'GET', methods: null, miss: '404', fn: routeWidget },
|
|
3525
|
+
{ path: '/api/snapshot', method: 'GET', methods: null, miss: '404', fn: routeSnapshot },
|
|
3526
|
+
{ path: '/api/daily', method: 'GET', methods: null, miss: '404', fn: routeDaily },
|
|
3527
|
+
{ path: '/api/models', method: 'GET', methods: null, miss: '404', fn: routeModels },
|
|
3528
|
+
{ path: '/api/work', method: 'GET', methods: null, miss: '404', fn: routeWork },
|
|
3529
|
+
{ path: '/api/analyze', method: 'GET', methods: null, miss: '404', fn: routeAnalyze },
|
|
3530
|
+
{ path: '/api/session', method: 'GET', methods: null, miss: '404', fn: routeSession },
|
|
3531
|
+
{ path: '/api/sessions', method: 'GET', methods: null, miss: '404', fn: routeSessions },
|
|
3532
|
+
{ path: '/api/entry', method: 'GET', methods: null, miss: '404', fn: routeEntry },
|
|
3533
|
+
{ path: '/api/repro', method: 'GET', methods: null, miss: '404', fn: routeRepro },
|
|
3534
|
+
{ path: '/api/events', method: 'GET', methods: null, miss: '404', fn: routeEvents },
|
|
3535
|
+
];
|
|
3536
|
+
// 前缀路由(2 条)。它们排在精确路由**之后**匹配,与抽表前 if 链的先后无关 ——
|
|
3537
|
+
// /vendor/ 与 /page/ 跟上面任何一条精确路径都不可能同时命中,两种排法等价。
|
|
3538
|
+
const ROUTE_PREFIXES = [
|
|
3539
|
+
{ path: '/vendor/', fn: routeVendor },
|
|
3540
|
+
{ path: '/page/', fn: routePageFile },
|
|
3541
|
+
];
|
|
3542
|
+
|
|
3543
|
+
function routePing(req, res, u) {
|
|
3544
|
+
res.end('ok');
|
|
3545
|
+
// ping 的响应已写入 socket,这里再启动首扫 —— 阻塞期间 hook 不会再等
|
|
3546
|
+
if (!scanStarted) setTimeout(startScanning, 300);
|
|
3547
|
+
return;
|
|
3548
|
+
}
|
|
3549
|
+
function routeShutdown(req, res, u) {
|
|
3550
|
+
// --stop 的优雅通道:先把响应写回 socket,再落盘索引退出(免得把还没落盘的增量偏移丢掉)。
|
|
3551
|
+
// Step 2:退出的是「进程」还是「循环」由 start() 注入的 exitPolicy 定 —— 宿主里这条不该关掉 DSH。
|
|
3552
|
+
if (badOrigin(req)) { res.statusCode = 403; res.end('{"ok":false,"error":"forbidden origin"}'); return; }
|
|
3553
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3554
|
+
res.end('{"ok":true}');
|
|
3555
|
+
setTimeout(() => stop('收到 /api/shutdown'), 50);
|
|
3556
|
+
return;
|
|
3557
|
+
}
|
|
3558
|
+
function routeClient(req, res, u) {
|
|
3559
|
+
// 从网页面板唤出桌面悬浮卡片(等价 agentacta --client):面板页没有壳进程的入口,
|
|
3560
|
+
// 卡片被 ✕ 关掉后就只能回命令行 —— 这里给页面一个按钮用的口子。
|
|
3561
|
+
// runClient 幂等:服务已在跑直接过;壳单实例,重复只是聚焦/重建卡片(见 widget/main.js 的 second-instance)。
|
|
3562
|
+
if (badOrigin(req)) { res.statusCode = 403; res.end('{"ok":false,"error":"forbidden origin"}'); return; }
|
|
3563
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3564
|
+
res.end('{"ok":true}');
|
|
3565
|
+
if (clientSpawner) clientSpawner().catch(err => console.error('[client] 唤起悬浮卡片失败:' + (err && err.message)));
|
|
3566
|
+
else console.error('[client] 本进程没装悬浮卡片唤起器(只读库模式?):请在命令行跑 agentacta --client');
|
|
3567
|
+
return;
|
|
3568
|
+
}
|
|
3569
|
+
// 归档三接口(页面「历史归档」视图专用):**独立只读数据源**,与实时条目完全不混 ——
|
|
3570
|
+
// 归档条目只在这里出现,不进 /api/snapshot、不进按天聚合、不进会话视图(见 archive.mjs 的设计约束 1)。
|
|
3571
|
+
// 不吃 whenReady:归档在磁盘上,与「首扫完了没」无关,页面一打开就能查。
|
|
3572
|
+
function routeArchiveIndex(req, res, u) {
|
|
3573
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3574
|
+
// skip 由服务端补进来(归档模块只管冻,不知道 config):页面拿它解释「这个 agent 怎么没在树里」
|
|
3575
|
+
// configAgents = 已启用的 agent 名单:开关要能管到**还没有任何归档**的 agent(树里根本没有它),
|
|
3576
|
+
// 光靠清单列不出「以后也永远不会有」的那些。
|
|
3577
|
+
try {
|
|
3578
|
+
res.end(JSON.stringify({
|
|
3579
|
+
ok: true, skip: archiveCfg.skip,
|
|
3580
|
+
configAgents: [...agentConfs].filter(([, c]) => c.enabled !== false).map(([n]) => n).sort(),
|
|
3581
|
+
...archiveIndex(),
|
|
3582
|
+
}));
|
|
3583
|
+
} catch (e) { res.statusCode = 500; res.end(JSON.stringify({ ok: false, error: e.message })); }
|
|
3584
|
+
return;
|
|
3585
|
+
}
|
|
3586
|
+
// per-agent 归档开关的写口(页面「历史归档」左下的开关列表)。写入即落盘 config.archive.skip,
|
|
3587
|
+
// 与 `agentacta --archive` / 服务内自动轮共用同一份 archiveCfg —— 不用重启就立刻生效。
|
|
3588
|
+
// 方法闸(只认 POST)在表里:原来是 `pathname === … && req.method === 'POST'`,GET 落到兜底 404。
|
|
3589
|
+
function routeArchiveSkip(req, res, u) {
|
|
3590
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3591
|
+
if (badOrigin(req)) { res.statusCode = 403; res.end('{"ok":false,"error":"forbidden origin"}'); return; }
|
|
3592
|
+
readBody(req, body => {
|
|
3593
|
+
let out;
|
|
3594
|
+
try { const j = JSON.parse(body || '{}'); out = setArchiveSkip(j.agent, !!j.skip); }
|
|
3595
|
+
catch (e) { out = { ok: false, error: e.message }; }
|
|
3596
|
+
res.statusCode = out.ok ? 200 : 400;
|
|
3597
|
+
res.end(JSON.stringify(out));
|
|
3598
|
+
});
|
|
3599
|
+
return;
|
|
3600
|
+
}
|
|
3601
|
+
function routeArchiveEntries(req, res, u) {
|
|
3602
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3603
|
+
try {
|
|
3604
|
+
res.end(JSON.stringify({ ok: true, ...archiveEntries({
|
|
3605
|
+
agent: u.searchParams.get('agent'), day: u.searchParams.get('day'),
|
|
3606
|
+
q: u.searchParams.get('q'), limit: u.searchParams.get('limit'), offset: u.searchParams.get('offset'),
|
|
3607
|
+
}) }));
|
|
3608
|
+
} catch (e) { res.statusCode = 500; res.end(JSON.stringify({ ok: false, error: e.message })); }
|
|
3609
|
+
return;
|
|
3610
|
+
}
|
|
3611
|
+
function routeArchiveEntry(req, res, u) {
|
|
3612
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3613
|
+
try {
|
|
3614
|
+
const rec = archiveEntry(u.searchParams.get('agent') || '', u.searchParams.get('day') || '',
|
|
3615
|
+
u.searchParams.get('id') || '');
|
|
3616
|
+
if (!rec) { res.statusCode = 404; res.end(JSON.stringify({ ok: false, error: 'not found' })); return; }
|
|
3617
|
+
res.end(JSON.stringify({ ok: true, entry: rec }));
|
|
3618
|
+
} catch (e) { res.statusCode = 500; res.end(JSON.stringify({ ok: false, error: e.message })); }
|
|
3619
|
+
return;
|
|
3620
|
+
}
|
|
3621
|
+
function routeDiagnose(req, res, u) {
|
|
3622
|
+
whenReady(() => {
|
|
3623
|
+
if (res.writableEnded) return;
|
|
3624
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3625
|
+
try { res.end(JSON.stringify({ ok: true, ...diagnose() })); }
|
|
3626
|
+
catch (e) { res.statusCode = 500; res.end(JSON.stringify({ ok: false, error: e.message })); }
|
|
3627
|
+
});
|
|
3628
|
+
return;
|
|
3629
|
+
}
|
|
3630
|
+
// 磁盘占用:走磁盘、可能几秒,所以只读 + 缓存 + 预算(见 usageReport 上方注释)。
|
|
3631
|
+
// 与 /api/diagnose 一样挂在 whenReady 上:agentConfs 要等首轮配置加载完才有内容。
|
|
3632
|
+
function routeUsage(req, res, u) {
|
|
3633
|
+
whenReady(() => {
|
|
3634
|
+
if (res.writableEnded) return;
|
|
3635
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3636
|
+
try { res.end(JSON.stringify(usageReport(u.searchParams.get('force') === '1'))); }
|
|
3637
|
+
catch (e) { res.statusCode = 500; res.end(JSON.stringify({ ok: false, error: e.message })); }
|
|
3638
|
+
});
|
|
3639
|
+
return;
|
|
3640
|
+
}
|
|
3641
|
+
// 全文搜索(R33 / 候选池 I10)。挂 whenReady:索引要等首扫把 entries / srcs 填起来才算数。
|
|
3642
|
+
// 两处与别家接口**刻意不同**,都写进返回让页面能如实解释:
|
|
3643
|
+
// ① 时间范围默认**不套**:不传 from/to 就没有时间条件。这是全文搜索存在的理由 ——
|
|
3644
|
+
// 「上周那次报错」本来就在页面默认的最近两天之外,套上默认区间等于把功能抹掉。
|
|
3645
|
+
// 页面那个「限定当前时间范围」开关才传这两个参数。
|
|
3646
|
+
// ② 回带索引覆盖度(已索引 A/B、是否在重建、几条读不出正文):索引是后台一点点建的,
|
|
3647
|
+
// 不报覆盖度的话「没搜到」会被读成「没有这条日志」。
|
|
3648
|
+
function routeSearchStatus(req, res, u) {
|
|
3649
|
+
whenReady(() => {
|
|
3650
|
+
if (res.writableEnded) return;
|
|
3651
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3652
|
+
try { res.end(JSON.stringify(searchStatus())); }
|
|
3653
|
+
catch (e) { res.statusCode = 500; res.end(JSON.stringify({ ok: false, error: e.message })); }
|
|
3654
|
+
});
|
|
3655
|
+
return;
|
|
3656
|
+
}
|
|
3657
|
+
function routeSearch(req, res, u) {
|
|
3658
|
+
whenReady(() => {
|
|
3659
|
+
if (res.writableEnded) return;
|
|
3660
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3661
|
+
try {
|
|
3662
|
+
const hit = searchEntries({ q: u.searchParams.get('q'), regex: u.searchParams.get('re') === '1' });
|
|
3663
|
+
// 关键词为空 / 正则不合法 → 400 带原话。**不能落到 404 兜底**:那个路径回的是纯文本 'not found',
|
|
3664
|
+
// 页面 getJSON 会把它说成「接口不存在(多半是旧版服务)」,与本意差了十万八千里。
|
|
3665
|
+
if (!hit.ok) { res.statusCode = 400; res.end(JSON.stringify({ ok: false, error: hit.error })); return; }
|
|
3666
|
+
// 筛选口径的唯一来源仍是 filterEntries(项目按 projKey 归一化、多值键 agents=/projects=)。
|
|
3667
|
+
// 文本匹配留在 search.mjs、条目筛选留在这儿,两边各管各的,谁都不写第二套口径。
|
|
3668
|
+
const { list, rb } = filterEntries(multiParam(u, 'agent', 'agents'), multiParam(u, 'project', 'projects'),
|
|
3669
|
+
u.searchParams.get('range'), u.searchParams.get('from'), u.searchParams.get('to'));
|
|
3670
|
+
const allow = new Set(list.map(e => e.id));
|
|
3671
|
+
const st = u.searchParams.get('status') || '';
|
|
3672
|
+
const out = [];
|
|
3673
|
+
for (const m of hit.matches) {
|
|
3674
|
+
if (!allow.has(m.e.id)) continue; // 求交集:命中集 ∩ 当前筛选集
|
|
3675
|
+
if (st && m.e.status !== st) continue;
|
|
3676
|
+
out.push({ ...m.e, _snip: m.snip });
|
|
3677
|
+
}
|
|
3678
|
+
const lim = Math.min(Math.max(Number(u.searchParams.get('limit')) || 200, 1), 2000);
|
|
3679
|
+
const { projects } = counts();
|
|
3680
|
+
res.end(JSON.stringify({
|
|
3681
|
+
ok: true, total: out.length, scanned: list.length, stale: hit.stale,
|
|
3682
|
+
capped: out.length > lim ? 1 : 0,
|
|
3683
|
+
entries: out.slice(0, lim),
|
|
3684
|
+
// 覆盖度的分母是**扫描链当前认识的条数**(不是命中数),页面横幅写「已索引 A/B」用的就是这两个
|
|
3685
|
+
index: searchStatus(),
|
|
3686
|
+
rangeLabel: rb ? rb.label : '全部时间',
|
|
3687
|
+
agents: agentsList(), projects,
|
|
3688
|
+
}));
|
|
3689
|
+
} catch (e) { res.statusCode = 500; res.end(JSON.stringify({ ok: false, error: e.message })); }
|
|
3690
|
+
});
|
|
3691
|
+
return;
|
|
3692
|
+
}
|
|
3693
|
+
// 不挂 whenReady:它只读一个文件,跟内存索引没关系,首扫期间也该能看上次自检结果。
|
|
3694
|
+
function routeSelftest(req, res, u) {
|
|
3695
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3696
|
+
try { res.end(JSON.stringify(selftestReport())); }
|
|
3697
|
+
catch (e) { res.statusCode = 500; res.end(JSON.stringify({ ok: false, error: e.message })); }
|
|
3698
|
+
return;
|
|
3699
|
+
}
|
|
3700
|
+
// 表里这条的 methods 是 null(不断方法):GET/POST/DELETE 三种语义都在函数体里,
|
|
3701
|
+
// 兜底 405 也在体里(原先就是「进分支之后」才判的,搬进表反而会改掉 405 响应头的形状)。
|
|
3702
|
+
function routeAgents(req, res, u) {
|
|
3703
|
+
if (req.method === 'GET') {
|
|
3704
|
+
whenReady(() => {
|
|
3705
|
+
if (res.writableEnded) return;
|
|
3706
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3707
|
+
res.end(JSON.stringify(agentsList()));
|
|
3708
|
+
});
|
|
3709
|
+
return;
|
|
3710
|
+
}
|
|
3711
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3712
|
+
if (req.method === 'POST') {
|
|
3713
|
+
if (badOrigin(req)) { res.statusCode = 403; res.end('{"ok":false,"error":"forbidden origin"}'); return; }
|
|
3714
|
+
readBody(req, body => {
|
|
3715
|
+
let out;
|
|
3716
|
+
try {
|
|
3717
|
+
const j = JSON.parse(body || '{}');
|
|
3718
|
+
out = addAgent(j.name, j.path);
|
|
3719
|
+
if (out.ok) { saveConfig(); scanAll(true); }
|
|
3720
|
+
} catch (e) { out = { ok: false, error: e.message }; }
|
|
3721
|
+
res.statusCode = out.ok ? 200 : 400;
|
|
3722
|
+
res.end(JSON.stringify(out));
|
|
3723
|
+
});
|
|
3724
|
+
return;
|
|
3725
|
+
}
|
|
3726
|
+
if (req.method === 'DELETE') {
|
|
3727
|
+
if (badOrigin(req)) { res.statusCode = 403; res.end('{"ok":false,"error":"forbidden origin"}'); return; }
|
|
3728
|
+
const out = removeAgent(u.searchParams.get('name') || '');
|
|
3729
|
+
res.statusCode = out.ok ? 200 : 400;
|
|
3730
|
+
res.end(JSON.stringify(out));
|
|
3731
|
+
return;
|
|
3732
|
+
}
|
|
3733
|
+
res.statusCode = 405; res.end('{"ok":false,"error":"method not allowed"}');
|
|
3734
|
+
return;
|
|
3735
|
+
}
|
|
3736
|
+
// 方法闸在表里(只认 POST;GET 落兜底 404 —— 原来是 `pathname && method` 同判,不是 405)
|
|
3737
|
+
function routeAgentsToggle(req, res, u) {
|
|
3738
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3739
|
+
if (badOrigin(req)) { res.statusCode = 403; res.end('{"ok":false,"error":"forbidden origin"}'); return; }
|
|
3740
|
+
readBody(req, body => {
|
|
3741
|
+
let out;
|
|
3742
|
+
try {
|
|
3743
|
+
const j = JSON.parse(body || '{}');
|
|
3744
|
+
out = toggleAgent(j.name, j.enabled);
|
|
3745
|
+
if (out.ok) scanAll(true);
|
|
3746
|
+
} catch (e) { out = { ok: false, error: e.message }; }
|
|
3747
|
+
res.statusCode = out.ok ? 200 : 400;
|
|
3748
|
+
res.end(JSON.stringify(out));
|
|
3749
|
+
});
|
|
3750
|
+
return;
|
|
3751
|
+
}
|
|
3752
|
+
// Trae 密钥抓取(页面「解密」按钮):POST 起抓取、GET 查状态。状态里**永远没有密钥** ——
|
|
3753
|
+
// 抓到的钥匙由服务端直接写进配置(saveConfig + SSE 通知 + 重扫),页面不需要也不应该看到它。
|
|
3754
|
+
// 注意 GET 只回状态(traeCaptureState),**安全的只读探法**;危险的是它的 POST(UAC 提权抓密钥)。
|
|
3755
|
+
function routeTraeCaptureKey(req, res, u) {
|
|
3756
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3757
|
+
if (req.method === 'GET') { res.end(JSON.stringify({ ok: true, state: traeCaptureState() })); return; }
|
|
3758
|
+
if (req.method === 'POST') {
|
|
3759
|
+
if (badOrigin(req)) { res.statusCode = 403; res.end('{"ok":false,"error":"forbidden origin"}'); return; }
|
|
3760
|
+
const out = startTraeCapture();
|
|
3761
|
+
res.statusCode = out.ok ? 200 : 400;
|
|
3762
|
+
res.end(JSON.stringify(out));
|
|
3763
|
+
return;
|
|
3764
|
+
}
|
|
3765
|
+
res.statusCode = 405; res.end('{"ok":false,"error":"method not allowed"}');
|
|
3766
|
+
return;
|
|
3767
|
+
}
|
|
3768
|
+
// 清除已配置的密钥(页面菜单里的「清除密钥」):删的是**配置**,下一轮扫描自动回退 renderer.log。
|
|
3769
|
+
// 方法闸在表里(只认 POST;GET 落兜底 404)。
|
|
3770
|
+
function routeTraeClearKey(req, res, u) {
|
|
3771
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3772
|
+
if (badOrigin(req)) { res.statusCode = 403; res.end('{"ok":false,"error":"forbidden origin"}'); return; }
|
|
3773
|
+
if (traeCapture.phase === 'running') { res.statusCode = 400; res.end(JSON.stringify({ ok: false, error: '抓取正在进行中,等它结束再清除' })); return; }
|
|
3774
|
+
res.end(JSON.stringify({ ok: true, cleared: clearTraeKeys() }));
|
|
3775
|
+
return;
|
|
3776
|
+
}
|
|
3777
|
+
// Cursor 逐轮 token 采集 hook(页面 cursor 行的图标按钮):GET 查状态、POST 注入/卸载。
|
|
3778
|
+
// 只改 ~/.cursor/hooks.json 里我们自己那一条 stop 钩子,用户其它的钩子一个字节都不动(见 installCursorHook)。
|
|
3779
|
+
function routeCursorHook(req, res, u) {
|
|
3780
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3781
|
+
if (req.method === 'GET') { res.end(JSON.stringify({ ok: true, ...cursorHookStatus() })); return; }
|
|
3782
|
+
if (req.method === 'POST') {
|
|
3783
|
+
if (badOrigin(req)) { res.statusCode = 403; res.end('{"ok":false,"error":"forbidden origin"}'); return; }
|
|
3784
|
+
readBody(req, body => {
|
|
3785
|
+
let out;
|
|
3786
|
+
try {
|
|
3787
|
+
const j = JSON.parse(body || '{}');
|
|
3788
|
+
out = j.action === 'uninstall' ? uninstallCursorHook() : installCursorHook();
|
|
3789
|
+
} catch (e) { out = { ok: false, error: e.message }; }
|
|
3790
|
+
res.statusCode = out.ok ? 200 : 400;
|
|
3791
|
+
res.end(JSON.stringify(out));
|
|
3792
|
+
});
|
|
3793
|
+
return;
|
|
3794
|
+
}
|
|
3795
|
+
res.statusCode = 405; res.end('{"ok":false,"error":"method not allowed"}');
|
|
3796
|
+
return;
|
|
3797
|
+
}
|
|
3798
|
+
function routeSettings(req, res, u) {
|
|
3799
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3800
|
+
if (req.method === 'GET') { res.end(JSON.stringify({ ok: true, scanMs, choices: SCAN_CHOICES, idleExitMs, idleChoices: IDLE_CHOICES })); return; }
|
|
3801
|
+
if (req.method === 'POST') {
|
|
3802
|
+
if (badOrigin(req)) { res.statusCode = 403; res.end('{"ok":false,"error":"forbidden origin"}'); return; }
|
|
3803
|
+
readBody(req, body => {
|
|
3804
|
+
let out;
|
|
3805
|
+
try {
|
|
3806
|
+
const j = JSON.parse(body || '{}');
|
|
3807
|
+
out = 'idleExitMs' in j ? setIdleExitMs(Number(j.idleExitMs)) : setScanMs(Number(j.scanMs));
|
|
3808
|
+
} catch (e) { out = { ok: false, error: e.message }; }
|
|
3809
|
+
res.statusCode = out.ok ? 200 : 400;
|
|
3810
|
+
res.end(JSON.stringify(out));
|
|
3811
|
+
});
|
|
3812
|
+
return;
|
|
3813
|
+
}
|
|
3814
|
+
res.statusCode = 405; res.end('{"ok":false,"error":"method not allowed"}');
|
|
3815
|
+
return;
|
|
3816
|
+
}
|
|
3817
|
+
// 异常巡检(R9):GET 取当前规则 + 命中摘要 + 命中条目(含 agent/规则),POST 保存规则并重算。
|
|
3818
|
+
function routeAlert(req, res, u) {
|
|
3819
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3820
|
+
if (req.method === 'GET') {
|
|
3821
|
+
if (alertDirty) recomputeAlerts();
|
|
3822
|
+
// 命中条目带完整字段,供弹层列表展示(编号直接定位回主列表用 id)。
|
|
3823
|
+
const hits = [];
|
|
3824
|
+
for (const [id, h] of alertHits) {
|
|
3825
|
+
const e = entries.get(id);
|
|
3826
|
+
if (!e) continue;
|
|
3827
|
+
hits.push({ id, ...h, time: e.time, preview: e.preview, status: e.status, dur: e.dur, total: e.total, project: e.project });
|
|
3828
|
+
}
|
|
3829
|
+
hits.sort((a, b) => (b.time || 0) - (a.time || 0));
|
|
3830
|
+
res.end(JSON.stringify({ ok: true, rules: alertRules, summary: alertSummary(), hits }));
|
|
3831
|
+
return;
|
|
3832
|
+
}
|
|
3833
|
+
if (req.method === 'POST') {
|
|
3834
|
+
if (badOrigin(req)) { res.statusCode = 403; res.end('{"ok":false,"error":"forbidden origin"}'); return; }
|
|
3835
|
+
readBody(req, body => {
|
|
3836
|
+
let out;
|
|
3837
|
+
try { out = setAlertRules(JSON.parse(body || '{}').rules); }
|
|
3838
|
+
catch (e) { out = { ok: false, error: e.message }; }
|
|
3839
|
+
res.statusCode = out.ok ? 200 : 400;
|
|
3840
|
+
res.end(JSON.stringify(out));
|
|
3841
|
+
});
|
|
3842
|
+
return;
|
|
3843
|
+
}
|
|
3844
|
+
res.statusCode = 405; res.end('{"ok":false,"error":"method not allowed"}');
|
|
3845
|
+
return;
|
|
3846
|
+
}
|
|
3847
|
+
function routeCtxWindows(req, res, u) {
|
|
3848
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3849
|
+
if (req.method === 'GET') { res.end(JSON.stringify({ ok: true, windows: ctxWindowRows(), unknown: ctxUnknownModels() })); return; }
|
|
3850
|
+
if (req.method === 'POST') {
|
|
3851
|
+
if (badOrigin(req)) { res.statusCode = 403; res.end('{"ok":false,"error":"forbidden origin"}'); return; }
|
|
3852
|
+
readBody(req, body => {
|
|
3853
|
+
let out;
|
|
3854
|
+
try { out = setCtxWindows(JSON.parse(body || '{}').windows); }
|
|
3855
|
+
catch (e) { out = { ok: false, error: e.message }; }
|
|
3856
|
+
res.statusCode = out.ok ? 200 : 400;
|
|
3857
|
+
res.end(JSON.stringify(out));
|
|
3858
|
+
});
|
|
3859
|
+
return;
|
|
3860
|
+
}
|
|
3861
|
+
res.statusCode = 405; res.end('{"ok":false,"error":"method not allowed"}');
|
|
3862
|
+
return;
|
|
3863
|
+
}
|
|
3864
|
+
function routeVersion(req, res, u) {
|
|
3865
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3866
|
+
res.end(JSON.stringify({ ok: true, version: VERSION, build: BUILD, pageBuild: pageBuild(), platform: process.platform, pid: process.pid }));
|
|
3867
|
+
return;
|
|
3868
|
+
}
|
|
3869
|
+
function routePage(req, res, u) {
|
|
3870
|
+
try {
|
|
3871
|
+
// 把本进程的指纹注入页面:页面据此比对「我这张页面」和「正在伺候我的服务」是不是同一版。
|
|
3872
|
+
// 页面必须跟着服务端走 —— 浏览器留缓存会让新页面配旧接口(或反过来),症状就是上面 BUILD 注释里那三件事。
|
|
3873
|
+
// 同时注入跨端共享片段(R12):projNorm / projKey / rangeDayStart 的唯一来源是本进程的 SHARED_JS,
|
|
3874
|
+
// 页面文件里只有 __AGENT_LOG_SHARED__ 占位符、没有第二份实现 —— 两端漂移在结构上不可能。
|
|
3875
|
+
const html = fs.readFileSync(PAGE_FILE, 'utf8')
|
|
3876
|
+
.split('__AGENT_LOG_BUILD__').join(BUILD)
|
|
3877
|
+
.split('__AGENT_LOG_PAGE_BUILD__').join(pageBuild())
|
|
3878
|
+
.split('__AGENT_LOG_SHARED__').join(SHARED_JS);
|
|
3879
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
3880
|
+
res.setHeader('Cache-Control', 'no-store'); // 本地服务,重取一次的开销可以忽略;缓存带来的错配代价大得多
|
|
3881
|
+
res.end(html);
|
|
3882
|
+
} catch { res.statusCode = 500; res.end('page file missing: ' + PAGE_FILE); }
|
|
3883
|
+
return;
|
|
3884
|
+
}
|
|
3885
|
+
function routeWidget(req, res, u) {
|
|
3886
|
+
try {
|
|
3887
|
+
// 桌面小卡片:与 '/' 同一套指纹注入与 no-store(理由见 '/' 路由注释)。
|
|
3888
|
+
// 卡片常驻桌面、几天不刷新是常态,指纹比对(hello.build ≠ 页面 meta → 自动 reload)对它更重要。
|
|
3889
|
+
const html = fs.readFileSync(WIDGET_FILE, 'utf8')
|
|
3890
|
+
.split('__AGENT_LOG_BUILD__').join(BUILD);
|
|
3891
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
3892
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
3893
|
+
res.end(html);
|
|
3894
|
+
} catch { res.statusCode = 500; res.end('widget file missing: ' + WIDGET_FILE); }
|
|
3895
|
+
return;
|
|
3896
|
+
}
|
|
3897
|
+
function routeVendor(req, res, u) {
|
|
3898
|
+
// 子目录要跟着走(svg/ 下的品牌图标就是 /vendor/svg/x.svg)。原来用 path.basename 挡穿越,
|
|
3899
|
+
// 顺手把所有子目录都拍平了 —— 图标一进目录就全 404,而且 404 是静默的(<img> 挂掉只出现空白)。
|
|
3900
|
+
// 改成相对路径 + resolve 后比对前缀:`..` 会被 resolve 吃掉,落在 VENDOR_DIR 外就 404。
|
|
3901
|
+
const rel = u.pathname.slice('/vendor/'.length);
|
|
3902
|
+
const fp = path.resolve(VENDOR_DIR, rel);
|
|
3903
|
+
const MIME = { '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.map': 'application/json', '.svg': 'image/svg+xml' };
|
|
3904
|
+
if (!fp.startsWith(VENDOR_DIR + path.sep)) { res.statusCode = 404; res.end('not found'); return; }
|
|
3905
|
+
try {
|
|
3906
|
+
const buf = fs.readFileSync(fp);
|
|
3907
|
+
res.setHeader('Content-Type', MIME[path.extname(fp)] || 'application/octet-stream');
|
|
3908
|
+
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
3909
|
+
res.end(buf);
|
|
3910
|
+
} catch { res.statusCode = 404; res.end('not found'); }
|
|
3911
|
+
return;
|
|
3912
|
+
}
|
|
3913
|
+
function routePageFile(req, res, u) {
|
|
3914
|
+
// R25 批 0:主页面拆出的静态文件。防穿越与 /vendor/ 同款(相对路径 + resolve 前缀比对)。
|
|
3915
|
+
// MIME 连 charset 一起给:组件模板与注释里有大量中文,漏了 charset 在 Edge 上有乱码风险。
|
|
3916
|
+
// Cache-Control 用 no-store(**不是** vendor 的 86400):这些文件开发期高频变更,
|
|
3917
|
+
// 与「页面本体 no-store、页面必须跟着服务端走」同一条纪律。
|
|
3918
|
+
const rel = u.pathname.slice('/page/'.length);
|
|
3919
|
+
const fp = path.resolve(PAGE_DIR, rel);
|
|
3920
|
+
const MIME = { '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' };
|
|
3921
|
+
if (!fp.startsWith(PAGE_DIR + path.sep)) { res.statusCode = 404; res.end('not found'); return; }
|
|
3922
|
+
try {
|
|
3923
|
+
const buf = fs.readFileSync(fp);
|
|
3924
|
+
res.setHeader('Content-Type', MIME[path.extname(fp)] || 'application/octet-stream');
|
|
3925
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
3926
|
+
res.end(buf);
|
|
3927
|
+
} catch { res.statusCode = 404; res.end('not found'); }
|
|
3928
|
+
return;
|
|
3929
|
+
}
|
|
3930
|
+
function routeSnapshot(req, res, u) {
|
|
3931
|
+
const limit = Math.min(Number(u.searchParams.get('limit')) || SNAPSHOT_LIMIT, 10000);
|
|
3932
|
+
// scan=1:手动刷新按钮用。暂停定时扫描时也照样先扫一轮,让「刷新」真的刷新到新日志
|
|
3933
|
+
const forceScan = u.searchParams.get('scan') === '1';
|
|
3934
|
+
// agent= / project=:把「最近 N 条」这个窗口**收窄到该筛选内部**,而不是先取全局最近 N 条、再由页面过滤。
|
|
3935
|
+
// 不收窄的话,日志整体落在窗口外的 agent / 项目(停更的、条目年代久的)点进去永远是空的,
|
|
3936
|
+
// 页面只能写一句「把条/页调大就能看到」——可它明明有数据,这个提示没道理。
|
|
3937
|
+
// project= 收到的是页面算好的归一化 key(见 projKey 上方),不是原始项目名。
|
|
3938
|
+
// R10:多选下拉走新键 agents= / projects=(逗号分隔合集),单值键仍兼容
|
|
3939
|
+
const agentFilter = multiParam(u, 'agent', 'agents');
|
|
3940
|
+
const projectFilter = multiParam(u, 'project', 'projects');
|
|
3941
|
+
// I15:工作指纹筛选(branch= / permMode= / cliVer=,多值走 branchs= / permModes= / cliVers=)
|
|
3942
|
+
const workFilter = workParam(u);
|
|
3943
|
+
// 边界由服务端每次请求现算(见 rangeBounds),页面挂过午夜也不会拿着昨天的边界过滤。
|
|
3944
|
+
// R22:也接受显式 from=/to=(YYYY-MM-DD)自然日区间 —— 与 /api/daily 同一条 filterEntries、
|
|
3945
|
+
// 同一套 customBounds 口径(两者同给时显式区间优先),页面不再传算好的时间戳。
|
|
3946
|
+
const rangeFilter = u.searchParams.get('range') || '';
|
|
3947
|
+
const fromFilter = u.searchParams.get('from') || '';
|
|
3948
|
+
const toFilter = u.searchParams.get('to') || '';
|
|
3949
|
+
whenReady(() => {
|
|
3950
|
+
if (res.writableEnded) return;
|
|
3951
|
+
if (forceScan) scanNow();
|
|
3952
|
+
const { list, rb } = filterEntries(agentFilter, projectFilter, rangeFilter, fromFilter, toFilter, workFilter);
|
|
3953
|
+
// 计数仍是全量:侧栏与项目下拉要的是全局数字,不能跟着这个窗口缩水
|
|
3954
|
+
const { agents, projects } = counts();
|
|
3955
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3956
|
+
res.end(JSON.stringify({ seq: bseq, total: entries.size, scanMs, idleExitMs, agents, projects, range: rangeFilter || 'all', rangeLabel: rb ? rb.label : '全部时间', rangeTotal: list.length, entries: list.slice(0, limit), shown: Math.min(limit, list.length) }));
|
|
3957
|
+
});
|
|
3958
|
+
return;
|
|
3959
|
+
}
|
|
3960
|
+
// 按天聚合(C2):时间范围的**同一个筛选集**上按自然日分桶,给页面「用量统计」用。
|
|
3961
|
+
// 注意它与快照的关键区别:这里不切「条/页」窗口 —— 聚合口径不能随显示档位变。
|
|
3962
|
+
function routeDaily(req, res, u) {
|
|
3963
|
+
whenReady(() => {
|
|
3964
|
+
if (res.writableEnded) return;
|
|
3965
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3966
|
+
try { res.end(JSON.stringify(dailyAgg(multiParam(u, 'agent', 'agents'), multiParam(u, 'project', 'projects'), u.searchParams.get('range') || '', u.searchParams.get('from') || '', u.searchParams.get('to') || '', workParam(u)))); }
|
|
3967
|
+
catch (e) { res.statusCode = 500; res.end(JSON.stringify({ ok: false, error: e.message })); }
|
|
3968
|
+
});
|
|
3969
|
+
return;
|
|
3970
|
+
}
|
|
3971
|
+
// 按模型聚合(C2 续):参数与 /api/daily 完全一致,同样不吃 limit
|
|
3972
|
+
function routeModels(req, res, u) {
|
|
3973
|
+
whenReady(() => {
|
|
3974
|
+
if (res.writableEnded) return;
|
|
3975
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3976
|
+
try { res.end(JSON.stringify(modelAgg(multiParam(u, 'agent', 'agents'), multiParam(u, 'project', 'projects'), u.searchParams.get('range') || '', u.searchParams.get('from') || '', u.searchParams.get('to') || '', workParam(u)))); }
|
|
3977
|
+
catch (e) { res.statusCode = 500; res.end(JSON.stringify({ ok: false, error: e.message })); }
|
|
3978
|
+
});
|
|
3979
|
+
return;
|
|
3980
|
+
}
|
|
3981
|
+
// I15 工作指纹聚合:参数与 /api/daily 一致(agent/project/range/from/to + 可选的 work 筛选本身),
|
|
3982
|
+
// 不吃「条/页」窗口。返回 branch / permMode / cliVer 三个维度的分桶 + covered(哪些 agent 有数据)。
|
|
3983
|
+
function routeWork(req, res, u) {
|
|
3984
|
+
whenReady(() => {
|
|
3985
|
+
if (res.writableEnded) return;
|
|
3986
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
3987
|
+
try { res.end(JSON.stringify(workAgg(multiParam(u, 'agent', 'agents'), multiParam(u, 'project', 'projects'), u.searchParams.get('range') || '', u.searchParams.get('from') || '', u.searchParams.get('to') || '', workParam(u)))); }
|
|
3988
|
+
catch (e) { res.statusCode = 500; res.end(JSON.stringify({ ok: false, error: e.message })); }
|
|
3989
|
+
});
|
|
3990
|
+
return;
|
|
3991
|
+
}
|
|
3992
|
+
function routeAnalyze(req, res, u) {
|
|
3993
|
+
const agent = multiParam(u, 'agent', 'agents');
|
|
3994
|
+
const project = multiParam(u, 'project', 'projects');
|
|
3995
|
+
const range = u.searchParams.get('range') || '';
|
|
3996
|
+
const from = u.searchParams.get('from') || '', to = u.searchParams.get('to') || '';
|
|
3997
|
+
const topN = Math.min(50, Math.max(1, Number(u.searchParams.get('top')) || 10));
|
|
3998
|
+
whenReady(() => {
|
|
3999
|
+
if (res.writableEnded) return;
|
|
4000
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
4001
|
+
res.end(JSON.stringify(analyzeAgg(agent, project, range, from, to, topN, workParam(u))));
|
|
4002
|
+
});
|
|
4003
|
+
return;
|
|
4004
|
+
}
|
|
4005
|
+
function routeSession(req, res, u) {
|
|
4006
|
+
const key = u.searchParams.get('key') || '';
|
|
4007
|
+
// agent / project 是**可选**参数(不传 = 老行为,只按 session 匹配)。传了才能精确命中
|
|
4008
|
+
// 重名的 session —— /api/sessions 就是这么调的,否则同一 agent 下不同项目的同名会话会对不上账。
|
|
4009
|
+
const agent = u.searchParams.get('agent') || '';
|
|
4010
|
+
const project = u.searchParams.get('project') || '';
|
|
4011
|
+
whenReady(() => {
|
|
4012
|
+
if (res.writableEnded) return;
|
|
4013
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
4014
|
+
const s = sessionAgg(key, agent, project, workParam(u));
|
|
4015
|
+
res.end(JSON.stringify(s || { ok: false, error: '未找到该会话(可能已被淘汰出索引或 session 为空)' }));
|
|
4016
|
+
});
|
|
4017
|
+
return;
|
|
4018
|
+
}
|
|
4019
|
+
// 会话列表(R6):参数与 /api/daily 一致(agent/project/range/from/to),另加 limit。
|
|
4020
|
+
// 同样不吃「条/页」窗口 —— 会话级汇总必须覆盖全量索引,否则会和逐轮累加对不上账。
|
|
4021
|
+
function routeSessions(req, res, u) {
|
|
4022
|
+
const agent = multiParam(u, 'agent', 'agents');
|
|
4023
|
+
const project = multiParam(u, 'project', 'projects');
|
|
4024
|
+
const range = u.searchParams.get('range') || '';
|
|
4025
|
+
const from = u.searchParams.get('from') || '', to = u.searchParams.get('to') || '';
|
|
4026
|
+
const limit = Math.min(2000, Math.max(1, Number(u.searchParams.get('limit')) || 200));
|
|
4027
|
+
whenReady(() => {
|
|
4028
|
+
if (res.writableEnded) return;
|
|
4029
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
4030
|
+
try { res.end(JSON.stringify(sessionsAgg(agent, project, range, from, to, limit, workParam(u)))); }
|
|
4031
|
+
catch (e) { res.statusCode = 500; res.end(JSON.stringify({ ok: false, error: e.message })); }
|
|
4032
|
+
});
|
|
4033
|
+
return;
|
|
4034
|
+
}
|
|
4035
|
+
function routeEntry(req, res, u) {
|
|
4036
|
+
const id = u.searchParams.get('id') || '', full = u.searchParams.get('full') === '1';
|
|
4037
|
+
whenReady(() => {
|
|
4038
|
+
if (res.writableEnded) return;
|
|
4039
|
+
const c = entryContent(id, full);
|
|
4040
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
4041
|
+
res.end(JSON.stringify(c || { user: '', assistant: '', tools: [] }));
|
|
4042
|
+
});
|
|
4043
|
+
return;
|
|
4044
|
+
}
|
|
4045
|
+
// I8 单轮「复现包」数据源:把一个条目连同「原始日志片段 + 解析结果 + build 指纹」一起递出。
|
|
4046
|
+
// 页面拿它在浏览器端打成 zip(零依赖、不引 zip 库)。筛选条件由页面 POST 过来(服务端没有筛选态),
|
|
4047
|
+
// 原样嵌进 meta.filters,让复现包自证「当时是按什么条件看的」。
|
|
4048
|
+
// 返回结构:{ ok, meta:{version,build,pageBuild,parserRev,generatedAt,entry,src,filters},
|
|
4049
|
+
// parsed: entryContent(full=1), raw: {kind,file,extractable,lines|null,note} }
|
|
4050
|
+
// OPTIONS(CORS 预检)那条留在函数体里 —— 它是这条路由自己的分支,不是方法闸。
|
|
4051
|
+
function routeRepro(req, res, u) {
|
|
4052
|
+
if (req.method === 'OPTIONS') { res.statusCode = 204; res.end(); return; }
|
|
4053
|
+
const id = u.searchParams.get('id') || '';
|
|
4054
|
+
whenReady(() => {
|
|
4055
|
+
if (res.writableEnded) return;
|
|
4056
|
+
try {
|
|
4057
|
+
const e = entries.get(id);
|
|
4058
|
+
const src = srcs.get(id);
|
|
4059
|
+
if (!e || !src) { res.statusCode = 404; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ ok: false, error: '找不到该条目(id 错误或条目已淘汰)' })); return; }
|
|
4060
|
+
const filters = (() => { try { return JSON.parse(u.searchParams.get('filters') || 'null'); } catch { return null; } })();
|
|
4061
|
+
const meta = {
|
|
4062
|
+
version: VERSION, build: BUILD, pageBuild: pageBuild(), parserRev: PARSER_REV,
|
|
4063
|
+
generatedAt: new Date().toISOString(),
|
|
4064
|
+
entry: e, src: { file: src.file, turn: src.turn, kind: src.kind },
|
|
4065
|
+
filters: filters || null,
|
|
4066
|
+
};
|
|
4067
|
+
const parsed = entryContent(id, true) || { user: '', assistant: '', tools: [] };
|
|
4068
|
+
const raw = reproRaw(src, e);
|
|
4069
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
4070
|
+
res.end(JSON.stringify({ ok: true, meta, parsed, raw }));
|
|
4071
|
+
} catch (err) {
|
|
4072
|
+
res.statusCode = 500; res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
4073
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
4074
|
+
}
|
|
4075
|
+
});
|
|
4076
|
+
return;
|
|
4077
|
+
}
|
|
4078
|
+
function routeEvents(req, res, u) {
|
|
4079
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
4080
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
4081
|
+
res.setHeader('Connection', 'keep-alive');
|
|
4082
|
+
sseClients.add(res);
|
|
4083
|
+
lastActivity = Date.now(); // 有人打开页面 = 有活动(空闲自停据此判断)
|
|
4084
|
+
req.on('close', () => sseClients.delete(res));
|
|
4085
|
+
whenReady(() => {
|
|
4086
|
+
if (res.writableEnded) return;
|
|
4087
|
+
// scanning:中途连上来时立刻显示进度,不用干等下一个分段(冷启动第一段 ~1.5s)
|
|
4088
|
+
const scan = scanState && { done: scanState.done, total: scanState.total, agent: scanState.agent };
|
|
4089
|
+
// build / pageBuild 一起带上:页面已经开着、服务在背后被重启过(新旧换班)时,这一条能让页面立刻发现自己配错了版本。
|
|
4090
|
+
// 两个指纹都要:BUILD 只哈希服务脚本,改了 page/ 下任何文件页面的 BUILD 是不变的(2026-09-21 R25 批 0 拆出 page/),
|
|
4091
|
+
// 只带 build 的话「编辑了页面文件 + 重启服务」这条最常见的情况页面看不见 —— 红条那条分支等于死代码。
|
|
4092
|
+
if (alertDirty) recomputeAlerts();
|
|
4093
|
+
sseWrite(res, { type: 'hello', seq: bseq, total: entries.size, scanMs, idleExitMs, build: BUILD, pageBuild: pageBuild(), agents: agentsList(), scanning: scan, alert: alertSummary(), alertIds: [...alertHits.keys()] });
|
|
4094
|
+
});
|
|
4095
|
+
return;
|
|
4096
|
+
}
|
|
4097
|
+
|
|
4098
|
+
// ---------------- handle:请求入口(具名 + 导出) ----------------
|
|
4099
|
+
// Step 2 把原来那个匿名 `http.createServer((req, res) => …)` 回调提成具名函数并导出。
|
|
4100
|
+
// 宿主内插件**不要 server 对象** —— 宿主的 webServer 才是监听者,插件只需要一个 (req,res) 处理器;
|
|
4101
|
+
// 这正是 hosted 模式的门。CLI 那条路仍然自己 createServer(handle) 来 listen。
|
|
4102
|
+
//
|
|
4103
|
+
// 派发语义与抽表前的 if 链**逐字等价**:
|
|
4104
|
+
// · 精确路由按表扫;方法闸分两种(见 ROUTES 的 miss 字段):
|
|
4105
|
+
// miss='405' → 命中但方法不对:405 + 与原来同一条 JSON 正文;
|
|
4106
|
+
// miss='404' → 这条压根不算命中(原来是 `pathname && method` 同判),**继续往下找**。
|
|
4107
|
+
// · 前缀路由在精确路由之后(两者路径不可能同时命中,与 if 链里的先后无关)。
|
|
4108
|
+
// · 都没命中 → 裸文本 404 `not found`。**这行文案是地基**:路由自己答的 404 是 JSON,
|
|
4109
|
+
// 守卫靠「裸文本」把兜底与路由分支区分开(见 test/cli-behavior-guard.mjs 的 isFallback404)。
|
|
4110
|
+
export function handle(req, res) {
|
|
4111
|
+
const u = new URL(req.url, 'http://x');
|
|
4112
|
+
for (const r of ROUTES) {
|
|
4113
|
+
if (u.pathname !== r.path) continue;
|
|
4114
|
+
if (r.methods && !r.methods.includes(req.method)) {
|
|
4115
|
+
if (r.miss === '405') { res.statusCode = 405; res.end('{"ok":false,"error":"method not allowed"}'); return; }
|
|
4116
|
+
break; // 没命中这条,落到下面继续找(最终多半是兜底 404)
|
|
4117
|
+
}
|
|
4118
|
+
return r.fn(req, res, u);
|
|
4119
|
+
}
|
|
4120
|
+
for (const p of ROUTE_PREFIXES) if (u.pathname.startsWith(p.path)) return p.fn(req, res, u);
|
|
4121
|
+
res.statusCode = 404; res.end('not found');
|
|
4122
|
+
}
|
|
4123
|
+
|
|
4124
|
+
const server = http.createServer(handle);
|
|
4125
|
+
|
|
4126
|
+
// 首扫就绪闸门:trace 格式没有增量偏移(off),冷启动必须全量重读,一次能阻塞事件循环 8~10s。
|
|
4127
|
+
// 要是把它同步放在 listen 回调(或紧邻的 setImmediate)里,端口虽然已 bind 却无法应答,
|
|
4128
|
+
// ensure 脚本的 ping 轮询就得一直等到 hook timeout 边缘才侥幸通过。
|
|
4129
|
+
// 所以:/api/ping 立刻应答(hook 只等它),首扫等 ping 响应写到 socket 之后再动;数据类接口排队等首扫完成。
|
|
4130
|
+
let scanStarted = false, scanReady = false;
|
|
4131
|
+
const readyWaiters = [];
|
|
4132
|
+
|
|
4133
|
+
// 定时扫描用「一次性定时器自续期」而不是 setInterval:
|
|
4134
|
+
// 1) 改 scanMs 能立刻生效,不用重挂 interval;
|
|
4135
|
+
// 2) scanAll 是同步阻塞的,间隔若小于单轮耗时 setInterval 会排队堆积,自续期天然不会。
|
|
4136
|
+
// scanMs === 0 表示暂停(不排下一次),手动刷新走 runScan() 仍可扫。
|
|
4137
|
+
let scanTimer = null;
|
|
4138
|
+
function scheduleNextScan() {
|
|
4139
|
+
cancel(scanTimer); scanTimer = null;
|
|
4140
|
+
if (!scanMs) return;
|
|
4141
|
+
// 定时轮:首扫分片期间撞上了就跳过(忙),下一轮照常排 —— 排队补一轮等于刚扫完立刻再全扫一遍
|
|
4142
|
+
scanTimer = after(scanMs, () => { scanTimer = null; runScan(false); scheduleNextScan(); });
|
|
4143
|
+
}
|
|
4144
|
+
function runScan(queueIfBusy) {
|
|
4145
|
+
// scanAll 内部已在末尾 broadcast(),这里不要再推一次(会把脏集合读空)
|
|
4146
|
+
try { scanAll(queueIfBusy); } catch (e) { console.error('[scan]', e.message); }
|
|
4147
|
+
}
|
|
4148
|
+
// 外部触发的即时扫描(新增/禁用 agent、手动刷新),暂停状态下也执行
|
|
4149
|
+
function scanNow() { runScan(true); scheduleNextScan(); }
|
|
4150
|
+
|
|
4151
|
+
function startScanning() {
|
|
4152
|
+
if (scanStarted) return;
|
|
4153
|
+
scanStarted = true;
|
|
4154
|
+
try { loadIndex(); } catch (e) { console.error('[index]', e.message); }
|
|
4155
|
+
discoverAgents();
|
|
4156
|
+
// 就绪闸门先放开:索引里的旧数据立刻可查,页面拿到的是「旧数据 + 扫描中」而不是「空白 + 连不上」。
|
|
4157
|
+
// scanAllChunked 第一件事是同步置 scanning 占位,之后才让出事件循环,
|
|
4158
|
+
// 所以下面 markReady() 放出的等待者(/api/snapshot、/api/events)不会撞上并发扫描。
|
|
4159
|
+
scanAllChunked().catch(e => console.error('[scan]', e.message));
|
|
4160
|
+
markReady();
|
|
4161
|
+
scheduleNextScan();
|
|
4162
|
+
// 下面三条都由登记表看着(Step 2 副作用收口):原先裸 setInterval/setTimeout,挂上就再也撤不掉;
|
|
4163
|
+
// 现在 stop() 一句 clearTimers() 全停 —— hosted 模式要的正是「停得下来」。
|
|
4164
|
+
every(saveIndex, 60000); // 周期落盘(无改动时直接跳过)
|
|
4165
|
+
scheduleAutoArchive(); // 每日自动归档(默认开;见 scheduleAutoArchive)
|
|
4166
|
+
scheduleAutoSearch(); // 后台增量建全文索引(R33;见 scheduleAutoSearch)
|
|
4167
|
+
}
|
|
4168
|
+
function markReady() {
|
|
4169
|
+
scanReady = true;
|
|
4170
|
+
for (const fn of readyWaiters.splice(0)) { try { fn(); } catch (e) { console.error('[ready]', e.message); } }
|
|
4171
|
+
}
|
|
4172
|
+
function whenReady(fn) { if (scanReady) fn(); else { readyWaiters.push(fn); startScanning(); } }
|
|
4173
|
+
|
|
4174
|
+
|
|
4175
|
+
// ---------------- 生命周期:start(opts) / stop(reason) ----------------
|
|
4176
|
+
// 载体(CLI 入口 / 插件宿主)装配服务的地方。**状态仍是模块级单例**,不做闭包化(§5 第 3 条):
|
|
4177
|
+
// 闭包化会把「搬家」变成「重写」,而插件与 CLI 天然不同进程,单例足够。
|
|
4178
|
+
//
|
|
4179
|
+
// opts(四个字段就是与插件侧的会签面,改形状要同步 claude-step.md §0.5):
|
|
4180
|
+
// mode 'cli' | 'lib' | 'hosted'(默认 'cli')—— 见文件头 MODE 那一段
|
|
4181
|
+
// listen 要不要自己 listen(默认:只有 cli 才 listen)。hosted 的流量由宿主 webServer 转进 handle()
|
|
4182
|
+
// keepAlive 要不要挂 SSE 心跳(默认 true)。原先由 CLI 旗标 ENSURE 反推,现在由调用方直说
|
|
4183
|
+
// exitPolicy stop(reason) 里怎么收场:cli 传 c => process.exit(c),hosted 传空函数(只停循环)
|
|
4184
|
+
// originIsLocal 写操作的 Origin 判据(可选,u => boolean,u 是 Origin 头解析出的 URL)。
|
|
4185
|
+
// 不传 = 用按 MODE 的内置判据(cli:环回 + 端口必须是 PORT;hosted:环回主机名
|
|
4186
|
+
// 端口不限 + dsh-app 协议)。宿主想只认自己那一个 Origin 就传这个,别去改插件外的东西。
|
|
4187
|
+
// 返回 { mode, listen, keepAlive } 供宿主自检;listen 那条路会等到真的 listening 才 resolve。
|
|
4188
|
+
const MODES = ['cli', 'lib', 'hosted'];
|
|
4189
|
+
export async function start(opts = {}) {
|
|
4190
|
+
const mode = opts.mode || 'cli';
|
|
4191
|
+
if (!MODES.includes(mode)) throw new Error('[agent-acta] start({mode}) 只认 ' + MODES.join(' / ') + ',收到:' + mode);
|
|
4192
|
+
MODE = mode;
|
|
4193
|
+
const listen = opts.listen === undefined ? mode === 'cli' : !!opts.listen;
|
|
4194
|
+
const keepAlive = opts.keepAlive === undefined ? true : !!opts.keepAlive;
|
|
4195
|
+
exitPolicy = typeof opts.exitPolicy === 'function' ? opts.exitPolicy
|
|
4196
|
+
: (mode === 'cli' ? (code => process.exit(code)) : (() => {}));
|
|
4197
|
+
// Origin 闸:**每次 start 都重新定**(不传就回到按 MODE 的内置判据)。
|
|
4198
|
+
// 这句重置是必需的:originGate 是模块级单例,不重置的话同一进程里第二次 start 会继承上一次的口径。
|
|
4199
|
+
setOriginGate(opts.originIsLocal);
|
|
4200
|
+
// 写闸按新 MODE 重装:readOnly 与落盘钩子在模块加载时就按 'lib' 定死了(hosted 那一刻 argv[1] 是宿主入口),
|
|
4201
|
+
// 不重装的话插件会得到一个「能扫不能存」的服务 —— 而且不报错。
|
|
4202
|
+
assemble();
|
|
4203
|
+
|
|
4204
|
+
// ① 数据目录:建目录 → 读配置 → 模型名映射(原 runCli 默认分支的头几行,逐字照搬)。
|
|
4205
|
+
// 注意**搬家不在这一步**:migrateDataDir 要探端口、且是「老 CLI 的数据往哪儿搬」的入口语义,
|
|
4206
|
+
// 宿主里的插件不该替用户搬(配置搬错不可逆,见 migrateDataDir 上方那段)。CLI 入口自己先 await 它。
|
|
4207
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
4208
|
+
loadConfig();
|
|
4209
|
+
loadModelDisplayMap(QODER_RUNS_DIR);
|
|
4210
|
+
|
|
4211
|
+
if (keepAlive) startHeartbeat();
|
|
4212
|
+
|
|
4213
|
+
if (!listen) {
|
|
4214
|
+
// hosted:没有自己的端口,就没有 /api/ping 来触发首扫 —— 这里直接踢第一轮。
|
|
4215
|
+
// 归档轮 / 搜索轮 / 周期落盘都挂在 startScanning 里,不主动踢等于「写路径开着但什么都不发生」。
|
|
4216
|
+
startScanning();
|
|
4217
|
+
return { mode, listen, keepAlive };
|
|
4218
|
+
}
|
|
4219
|
+
|
|
4220
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
4221
|
+
log('[agent-acta] listening on http://127.0.0.1:' + PORT + ' (data: ' + DATA_DIR + ')');
|
|
4222
|
+
checkPageAssets();
|
|
4223
|
+
// pid 文件与空闲自停只属于「自己攥着端口」的那个载体。hosted 不 listen ⇒ 这个回调压根不跑
|
|
4224
|
+
// ⇒ 写 pid / 挂 checkIdle 两个副作用一起不发生。这里再判一次 listen 是兜底:万一有人给
|
|
4225
|
+
// hosted 传了 listen:true,PID_FILE 记的也是宿主进程的 pid,「没人看就退出宿主」更不是插件该干的事。
|
|
4226
|
+
if (listen) {
|
|
4227
|
+
// 写完 pid 文件才算「本实例持有端口」;EADDRINUSE 分支在入口那边已退出,不会走到这
|
|
4228
|
+
try { fs.writeFileSync(PID_FILE, String(process.pid)); } catch {}
|
|
4229
|
+
every(checkIdle, 30000); // 空闲自停检查(阈值最短 30min,30s 精度够;没开启时直接返回)
|
|
4230
|
+
}
|
|
4231
|
+
});
|
|
4232
|
+
// 等真正 listening 再 resolve:调用方 await 完就知道「端口通了」。
|
|
4233
|
+
// 同时听 'error' 是为了**绝不挂死** —— EADDRINUSE 时入口那个 handler 会 process.exit,
|
|
4234
|
+
// 但万一换了别的载体没装 handler,这里也得把 promise 放掉。
|
|
4235
|
+
await new Promise(res => {
|
|
4236
|
+
const done = () => { server.off('listening', done); server.off('error', done); res(); };
|
|
4237
|
+
server.once('listening', done);
|
|
4238
|
+
server.once('error', done);
|
|
4239
|
+
});
|
|
4240
|
+
return { mode, listen, keepAlive };
|
|
4241
|
+
}
|
|
4242
|
+
|
|
4243
|
+
// R12 / R25 批 0 的启动自检:页面文件必须还带着共享片段占位符。占位符没了 = 页面被换成不走注入
|
|
4244
|
+
// 机制的旧版/拷贝,浏览器端会白屏(页面侧另有兜底文案)—— 在这里先喊出来,别等用户报「页面打不开」。
|
|
4245
|
+
// 原先是写在入口 listen 回调里的一段匿名代码,搬进 start() 时提成具名函数(hosted 模式也要能单独调)。
|
|
4246
|
+
function checkPageAssets() {
|
|
4247
|
+
try {
|
|
4248
|
+
const pageSrc = fs.readFileSync(PAGE_FILE, 'utf8');
|
|
4249
|
+
if (!pageSrc.includes('__AGENT_LOG_SHARED__'))
|
|
4250
|
+
console.error('[agent-acta] ⚠ 页面文件缺少 __AGENT_LOG_SHARED__ 占位符:' + PAGE_FILE + '\n' +
|
|
4251
|
+
' 共享片段(projNorm/projKey/rangeDayStart)将无法注入,页面会白屏。页面文件被换成旧版了?');
|
|
4252
|
+
// R25 批 0 自检:页面文件指纹占位符 + 页面引用的每个 /page/ 文件都得在(少一个就是白屏/样式全丢)。
|
|
4253
|
+
if (!pageSrc.includes('__AGENT_LOG_PAGE_BUILD__'))
|
|
4254
|
+
console.error('[agent-acta] ⚠ 页面文件缺少 __AGENT_LOG_PAGE_BUILD__ 占位符:' + PAGE_FILE + '\n' +
|
|
4255
|
+
' 页面文件指纹将无法注入,红条不再能发现「旧页面配新页面文件」。页面文件被换成旧版了?');
|
|
4256
|
+
for (const m of pageSrc.matchAll(/(?:src|href)="\/page\/([^"]+)"/g))
|
|
4257
|
+
if (!fs.existsSync(path.join(PAGE_DIR, m[1])))
|
|
4258
|
+
console.error('[agent-acta] ⚠ 页面引用的 /page/ 文件不存在:' + m[1] + '(应为 ' + path.join(PAGE_DIR, m[1]) + ')');
|
|
4259
|
+
} catch (e) {
|
|
4260
|
+
console.error('[agent-acta] ⚠ 读不到页面文件 ' + PAGE_FILE + ':' + e.message);
|
|
4261
|
+
}
|
|
4262
|
+
}
|
|
4263
|
+
|
|
4264
|
+
|
|
4265
|
+
// ---------------- 服务侧对外的名字(给 CLI 入口用) ----------------
|
|
4266
|
+
// 用一条显式 export 列表,而不是给每个声明加 export:这份清单就是「入口到底依赖了服务侧的什么」的答案,
|
|
4267
|
+
// 一眼能看完,搬家时也不会漏掉一个(漏了的症状是某个分支运行到才 ReferenceError)。
|
|
4268
|
+
//
|
|
4269
|
+
// **Step 2 的会签面**(claude-step.md §0.5,插件侧按这四个名字写 plugin.mjs):
|
|
4270
|
+
// handle(req, res) 请求入口 —— 宿主内只要它,不要 server 对象
|
|
4271
|
+
// ROUTES / ROUTE_PREFIXES 路由表(表 ≡ test/routes.expected.json,守卫盯着)
|
|
4272
|
+
// start(opts) 装配并起服务 { mode, keepAlive, listen, exitPolicy }
|
|
4273
|
+
// stop(reason) 停服务(退出权由 start 注入的 exitPolicy 决定)
|
|
4274
|
+
// 另有 MODE / MODES 供宿主自查现在跑在哪个载体。改这四个名字或字段就同步改 §0.5,别悄悄换。
|
|
4275
|
+
export {
|
|
4276
|
+
BUILD, CONFIG_FILE, DATA_DIR, HOME, INDEX_DIR, LEGACY_CONFIG_NAME,
|
|
4277
|
+
LEGACY_DATA_DIRS, MODE, MODES, PAGE_DIR, PAGE_FILE, PID_FILE, PORT,
|
|
4278
|
+
QODER_RUNS_DIR, ROOT, ROUTE_PREFIXES, ROUTES, SEARCH_MAX_CHARS, VERSION, agentConfs, agentsList,
|
|
4279
|
+
analyzeAgg, archiveCfg, checkIdle, confAvailable, counts, dailyAgg,
|
|
4280
|
+
diagnose, entryContent, filterEntries, hasRealConfig, legacyWithConfig, loadConfig,
|
|
4281
|
+
loadIndex, modelAgg, pageBuild, projKey, projNorm, rangeDayStart,
|
|
4282
|
+
saveConfig, saveIndex, scanAll, scanAllSync, scanBranch, scanMs,
|
|
4283
|
+
scheduleSave, server, sessionAgg, sessionsAgg, workAgg
|
|
4284
|
+
// handle / start / stop 是本文件里 `export function` 直接给的,别再列一遍(重复导出行会直接报语法错)
|
|
4285
|
+
};
|