opencode-metrics-plugin 0.1.5 → 0.2.1
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/README.md +108 -390
- package/dist/api.d.ts +3 -0
- package/dist/api.js +4 -0
- package/dist/index.d.ts +2 -51
- package/dist/index.js +4 -61
- package/dist/metrics/dirs.d.ts +23 -0
- package/dist/{dirs.js → metrics/dirs.js} +4 -10
- package/dist/metrics/engine/engine.d.ts +15 -0
- package/dist/{metrics-engine.js → metrics/engine/engine.js} +33 -146
- package/dist/{metrics-handlers.d.ts → metrics/engine/handlers.d.ts} +1 -1
- package/dist/{metrics-handlers.js → metrics/engine/handlers.js} +2 -2
- package/dist/metrics/engine/state.d.ts +79 -0
- package/dist/{metrics-types.js → metrics/engine/state.js} +1 -19
- package/dist/{event-logger.d.ts → metrics/eventlog/event-logger.d.ts} +0 -5
- package/dist/{event-logger.js → metrics/eventlog/event-logger.js} +2 -18
- package/dist/metrics/index.d.ts +9 -0
- package/dist/metrics/index.js +6 -0
- package/dist/metrics/runtime.d.ts +25 -0
- package/dist/metrics/runtime.js +28 -0
- package/dist/metrics/snapshot/flush.d.ts +6 -0
- package/dist/{metrics-output.js → metrics/snapshot/flush.js} +6 -290
- package/dist/metrics/snapshot/merge.d.ts +9 -0
- package/dist/metrics/snapshot/merge.js +282 -0
- package/dist/{metrics-steps.d.ts → metrics/snapshot/steps.d.ts} +1 -1
- package/dist/{metrics-steps.js → metrics/snapshot/steps.js} +1 -1
- package/dist/{metrics-types.d.ts → metrics/types.d.ts} +3 -81
- package/dist/metrics/types.js +20 -0
- package/dist/plugin.d.ts +22 -0
- package/dist/plugin.js +49 -0
- package/dist/{logger.d.ts → shared/log.d.ts} +2 -0
- package/dist/{logger.js → shared/log.js} +9 -2
- package/package.json +12 -4
- package/dist/backfill-cli.d.ts +0 -1
- package/dist/backfill-cli.js +0 -74
- package/dist/backfill.d.ts +0 -58
- package/dist/backfill.js +0 -550
- package/dist/dirs.d.ts +0 -34
- package/dist/metrics-engine.d.ts +0 -37
- package/dist/metrics-output.d.ts +0 -11
- package/dist/summary-store.d.ts +0 -150
- package/dist/summary-store.js +0 -560
- /package/dist/{compile-analyzer.d.ts → metrics/analysis/hvigor.d.ts} +0 -0
- /package/dist/{compile-analyzer.js → metrics/analysis/hvigor.js} +0 -0
package/dist/summary-store.js
DELETED
|
@@ -1,560 +0,0 @@
|
|
|
1
|
-
import * as fs from "fs";
|
|
2
|
-
import * as path from "path";
|
|
3
|
-
import { createRequire } from "node:module";
|
|
4
|
-
import { resolveDirs, getSummaryFile } from "./dirs.js";
|
|
5
|
-
import { log } from "./logger.js";
|
|
6
|
-
const require_ = createRequire(import.meta.url);
|
|
7
|
-
let sqliteUnavailable = false;
|
|
8
|
-
function loadSqlite() {
|
|
9
|
-
if (sqliteUnavailable)
|
|
10
|
-
return null;
|
|
11
|
-
try {
|
|
12
|
-
return require_("node:sqlite");
|
|
13
|
-
}
|
|
14
|
-
catch {
|
|
15
|
-
sqliteUnavailable = true;
|
|
16
|
-
log.info("[summary] node:sqlite 不可用(需 Node >= 22.5),摘要/详情库停用");
|
|
17
|
-
return null;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
// ─── DDL(镜像 tnnotix sqlite 方言,幂等)───────────────────────────────────
|
|
21
|
-
const DDL = `
|
|
22
|
-
CREATE TABLE IF NOT EXISTS session_summaries (
|
|
23
|
-
session_id TEXT PRIMARY KEY,
|
|
24
|
-
task_id TEXT,
|
|
25
|
-
agent TEXT,
|
|
26
|
-
model TEXT,
|
|
27
|
-
directory TEXT,
|
|
28
|
-
title TEXT,
|
|
29
|
-
first_user_message TEXT,
|
|
30
|
-
step_count INTEGER NOT NULL DEFAULT 0,
|
|
31
|
-
tool_calls INTEGER NOT NULL DEFAULT 0,
|
|
32
|
-
total_tokens_input BIGINT NOT NULL DEFAULT 0,
|
|
33
|
-
total_tokens_output BIGINT NOT NULL DEFAULT 0,
|
|
34
|
-
total_tokens_cache_read BIGINT NOT NULL DEFAULT 0,
|
|
35
|
-
total_tokens_cache_write BIGINT NOT NULL DEFAULT 0,
|
|
36
|
-
total_tokens_reasoning BIGINT NOT NULL DEFAULT 0,
|
|
37
|
-
total_tokens BIGINT NOT NULL DEFAULT 0,
|
|
38
|
-
duration BIGINT NOT NULL DEFAULT 0,
|
|
39
|
-
created_at TEXT,
|
|
40
|
-
source_file TEXT UNIQUE,
|
|
41
|
-
ingested_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
42
|
-
);
|
|
43
|
-
|
|
44
|
-
CREATE TABLE IF NOT EXISTS session_details (
|
|
45
|
-
session_id TEXT PRIMARY KEY,
|
|
46
|
-
full_data TEXT
|
|
47
|
-
);
|
|
48
|
-
|
|
49
|
-
CREATE INDEX IF NOT EXISTS idx_session_summaries_created_at ON session_summaries(created_at);
|
|
50
|
-
CREATE INDEX IF NOT EXISTS idx_session_summaries_task_id ON session_summaries(task_id);
|
|
51
|
-
CREATE INDEX IF NOT EXISTS idx_session_summaries_model ON session_summaries(model);
|
|
52
|
-
`;
|
|
53
|
-
// ─── 连接缓存 ────────────────────────────────────────────────────────────────
|
|
54
|
-
const dbCache = new Map();
|
|
55
|
-
/** 打开(或复用)摘要库连接:建目录 + WAL + busy_timeout + 幂等建表;不可用返回 null。 */
|
|
56
|
-
export function openSummaryDb(summaryFile) {
|
|
57
|
-
const cached = dbCache.get(summaryFile);
|
|
58
|
-
if (cached)
|
|
59
|
-
return cached;
|
|
60
|
-
const sqlite = loadSqlite();
|
|
61
|
-
if (!sqlite)
|
|
62
|
-
return null;
|
|
63
|
-
try {
|
|
64
|
-
fs.mkdirSync(path.dirname(summaryFile), { recursive: true });
|
|
65
|
-
const db = new sqlite.DatabaseSync(summaryFile);
|
|
66
|
-
db.exec("PRAGMA journal_mode=WAL");
|
|
67
|
-
db.exec("PRAGMA busy_timeout=5000");
|
|
68
|
-
db.exec(DDL);
|
|
69
|
-
dbCache.set(summaryFile, db);
|
|
70
|
-
return db;
|
|
71
|
-
}
|
|
72
|
-
catch (err) {
|
|
73
|
-
log.info("[summary] 打开摘要库失败", { summaryFile, error: String(err) });
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
/** 关闭全部缓存的连接(宿主 dispose 时可选调用)。 */
|
|
78
|
-
export function closeSummaryDbs() {
|
|
79
|
-
for (const [file, db] of dbCache) {
|
|
80
|
-
try {
|
|
81
|
-
db.close();
|
|
82
|
-
}
|
|
83
|
-
catch { }
|
|
84
|
-
dbCache.delete(file);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
// ─── 快照 → 摘要列提取(与 tnnotix extractSummaryFields 同口径)──────────────
|
|
88
|
-
/** 归一化 created_at(number 时间戳 / ISO 字符串 / Date)为 ISO 字符串。 */
|
|
89
|
-
export function toIsoString(value) {
|
|
90
|
-
if (value == null)
|
|
91
|
-
return null;
|
|
92
|
-
const d = value instanceof Date ? value : new Date(value);
|
|
93
|
-
return isNaN(d.getTime()) ? null : d.toISOString();
|
|
94
|
-
}
|
|
95
|
-
/** 从 rounds 提取首条用户消息(兼容 string 与 string[] 形态)。 */
|
|
96
|
-
export function firstUserMessageOf(rounds) {
|
|
97
|
-
const raw = rounds?.find((r) => r.userMessage)?.userMessage;
|
|
98
|
-
if (raw == null)
|
|
99
|
-
return undefined;
|
|
100
|
-
return Array.isArray(raw) ? raw[0] : raw;
|
|
101
|
-
}
|
|
102
|
-
/** 摘要表一行所需的全部字段(文件→表共用的提取逻辑)。 */
|
|
103
|
-
export function extractSummaryFields(m, filePath) {
|
|
104
|
-
const tokens = m.tokens ?? { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
|
|
105
|
-
return {
|
|
106
|
-
sessionId: m.sessionId,
|
|
107
|
-
taskId: null,
|
|
108
|
-
agent: m.header?.agent ?? "",
|
|
109
|
-
model: m.header?.model ?? "",
|
|
110
|
-
directory: m.header?.workingDirectory ?? "",
|
|
111
|
-
title: m.header?.title ?? null,
|
|
112
|
-
firstUserMessage: firstUserMessageOf(m.rounds) ?? null,
|
|
113
|
-
stepCount: m.steps?.length ?? 0,
|
|
114
|
-
toolCalls: m.tools?.totalCalls ?? 0,
|
|
115
|
-
totalTokensInput: tokens.input ?? 0,
|
|
116
|
-
totalTokensOutput: tokens.output ?? 0,
|
|
117
|
-
totalTokensCacheRead: tokens.cacheRead ?? 0,
|
|
118
|
-
totalTokensCacheWrite: tokens.cacheWrite ?? 0,
|
|
119
|
-
totalTokensReasoning: tokens.reasoning ?? 0,
|
|
120
|
-
totalTokens: tokens.total ?? 0,
|
|
121
|
-
duration: m.duration ?? 0,
|
|
122
|
-
createdAt: toIsoString(m.header?.startTime),
|
|
123
|
-
sourceFile: filePath,
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
function summaryValues(f) {
|
|
127
|
-
return [
|
|
128
|
-
f.sessionId, f.taskId, f.agent, f.model, f.directory, f.title, f.firstUserMessage,
|
|
129
|
-
f.stepCount, f.toolCalls,
|
|
130
|
-
f.totalTokensInput, f.totalTokensOutput,
|
|
131
|
-
f.totalTokensCacheRead, f.totalTokensCacheWrite,
|
|
132
|
-
f.totalTokensReasoning, f.totalTokens,
|
|
133
|
-
f.duration, f.createdAt, f.sourceFile,
|
|
134
|
-
];
|
|
135
|
-
}
|
|
136
|
-
// ─── upsert(force = 总是刷新;soft = 既有 total_tokens>0 不覆盖)────────────
|
|
137
|
-
const SUMMARY_INSERT = `INSERT INTO session_summaries
|
|
138
|
-
(session_id, task_id, agent, model, directory, title, first_user_message,
|
|
139
|
-
step_count, tool_calls,
|
|
140
|
-
total_tokens_input, total_tokens_output,
|
|
141
|
-
total_tokens_cache_read, total_tokens_cache_write,
|
|
142
|
-
total_tokens_reasoning, total_tokens,
|
|
143
|
-
duration, created_at, source_file)
|
|
144
|
-
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`;
|
|
145
|
-
/**
|
|
146
|
-
* 冲突更新 SET 子句(按 mode 构建):
|
|
147
|
-
* - 计数列恒刷新;first_user_message/title 用 COALESCE 只治愈不清空(防 compact 会话丢已采值);
|
|
148
|
-
* - force 额外全量刷新展示列(model/directory/agent/created_at/source_file),
|
|
149
|
-
* 口径升级(如模型短名化)后能治愈存量行,不再停留在历史插入值。
|
|
150
|
-
*/
|
|
151
|
-
const summaryUpdateSet = (mode) => `
|
|
152
|
-
ON CONFLICT (session_id) DO UPDATE SET
|
|
153
|
-
step_count = excluded.step_count,
|
|
154
|
-
tool_calls = excluded.tool_calls,
|
|
155
|
-
total_tokens_input = excluded.total_tokens_input,
|
|
156
|
-
total_tokens_output = excluded.total_tokens_output,
|
|
157
|
-
total_tokens_cache_read = excluded.total_tokens_cache_read,
|
|
158
|
-
total_tokens_cache_write = excluded.total_tokens_cache_write,
|
|
159
|
-
total_tokens_reasoning = excluded.total_tokens_reasoning,
|
|
160
|
-
total_tokens = excluded.total_tokens,
|
|
161
|
-
duration = excluded.duration,
|
|
162
|
-
first_user_message = COALESCE(excluded.first_user_message, session_summaries.first_user_message),
|
|
163
|
-
title = COALESCE(excluded.title, session_summaries.title)${mode === "force" ? `,
|
|
164
|
-
model = excluded.model,
|
|
165
|
-
directory = excluded.directory,
|
|
166
|
-
agent = excluded.agent,
|
|
167
|
-
created_at = excluded.created_at,
|
|
168
|
-
source_file = excluded.source_file` : ""},
|
|
169
|
-
ingested_at = CURRENT_TIMESTAMP`;
|
|
170
|
-
const DETAIL_SOFT_GUARD = `
|
|
171
|
-
WHERE session_details.full_data IS NULL OR
|
|
172
|
-
CAST(json_extract(session_details.full_data, '$.tokens.total') AS INTEGER) IS NULL OR
|
|
173
|
-
CAST(json_extract(session_details.full_data, '$.tokens.total') AS INTEGER) = 0`;
|
|
174
|
-
/** 快照双表入库(事务内 summary + detail;source_file 保留首见值)。返回是否成功。 */
|
|
175
|
-
export function upsertSessionSnapshot(summaryFile, snapshot, filePath, mode = "force") {
|
|
176
|
-
const db = openSummaryDb(summaryFile);
|
|
177
|
-
if (!db)
|
|
178
|
-
return false;
|
|
179
|
-
const fields = extractSummaryFields(snapshot, filePath);
|
|
180
|
-
const payload = JSON.stringify(snapshot, null, 2);
|
|
181
|
-
const summarySql = SUMMARY_INSERT + summaryUpdateSet(mode) + (mode === "soft" ? "\n WHERE session_summaries.total_tokens = 0" : "");
|
|
182
|
-
const detailSql = `INSERT INTO session_details (session_id, full_data)
|
|
183
|
-
VALUES (?, ?)
|
|
184
|
-
ON CONFLICT (session_id) DO UPDATE SET full_data = excluded.full_data` + (mode === "soft" ? DETAIL_SOFT_GUARD : "");
|
|
185
|
-
try {
|
|
186
|
-
db.exec("BEGIN");
|
|
187
|
-
try {
|
|
188
|
-
db.prepare(summarySql).run(...summaryValues(fields));
|
|
189
|
-
db.prepare(detailSql).run(fields.sessionId, payload);
|
|
190
|
-
db.exec("COMMIT");
|
|
191
|
-
}
|
|
192
|
-
catch (err) {
|
|
193
|
-
try {
|
|
194
|
-
db.exec("ROLLBACK");
|
|
195
|
-
}
|
|
196
|
-
catch { }
|
|
197
|
-
throw err;
|
|
198
|
-
}
|
|
199
|
-
return true;
|
|
200
|
-
}
|
|
201
|
-
catch (err) {
|
|
202
|
-
log.info("[summary] 快照入库失败", { sessionId: fields.sessionId, mode, error: String(err) });
|
|
203
|
-
return false;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
/** 逐元素累加 token 用量。 */
|
|
207
|
-
export function sumTokens(items) {
|
|
208
|
-
return items.reduce((acc, t) => ({
|
|
209
|
-
input: acc.input + (t.input ?? 0),
|
|
210
|
-
output: acc.output + (t.output ?? 0),
|
|
211
|
-
reasoning: acc.reasoning + (t.reasoning ?? 0),
|
|
212
|
-
cacheRead: acc.cacheRead + (t.cacheRead ?? 0),
|
|
213
|
-
cacheWrite: acc.cacheWrite + (t.cacheWrite ?? 0),
|
|
214
|
-
total: acc.total + (t.total ?? 0),
|
|
215
|
-
}), { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 });
|
|
216
|
-
}
|
|
217
|
-
/** a 减去 b(用于剥离子代理 token 得 main tokens)。 */
|
|
218
|
-
export function subtractTokens(a, b) {
|
|
219
|
-
return {
|
|
220
|
-
input: (a.input ?? 0) - (b.input ?? 0),
|
|
221
|
-
output: (a.output ?? 0) - (b.output ?? 0),
|
|
222
|
-
reasoning: (a.reasoning ?? 0) - (b.reasoning ?? 0),
|
|
223
|
-
cacheRead: (a.cacheRead ?? 0) - (b.cacheRead ?? 0),
|
|
224
|
-
cacheWrite: (a.cacheWrite ?? 0) - (b.cacheWrite ?? 0),
|
|
225
|
-
total: (a.total ?? 0) - (b.total ?? 0),
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
/** 快照 → 列表摘要(含 subagent 拆分出的 main/subagent tokens)。 */
|
|
229
|
-
export function snapshotToSummary(m, filePath) {
|
|
230
|
-
const subagents = m.subagents ?? [];
|
|
231
|
-
const subagentTokens = subagents.length > 0 ? sumTokens(subagents.map((s) => s.tokens)) : undefined;
|
|
232
|
-
const mainTokens = subagentTokens ? subtractTokens(m.tokens, subagentTokens) : undefined;
|
|
233
|
-
return {
|
|
234
|
-
id: m.sessionId,
|
|
235
|
-
directory: m.header?.workingDirectory ?? "",
|
|
236
|
-
model: m.header?.model ?? "",
|
|
237
|
-
agent: m.header?.agent ?? "",
|
|
238
|
-
title: m.header?.title,
|
|
239
|
-
stepCount: m.steps?.length ?? 0,
|
|
240
|
-
totalTokens: m.tokens,
|
|
241
|
-
mainTokens,
|
|
242
|
-
subagentTokens,
|
|
243
|
-
subagentCount: subagents.length > 0 ? subagents.length : undefined,
|
|
244
|
-
totalCost: 0,
|
|
245
|
-
duration: m.duration ?? 0,
|
|
246
|
-
createdAt: m.header?.startTime ?? "",
|
|
247
|
-
startTime: m.startTime,
|
|
248
|
-
endTime: m.endTime,
|
|
249
|
-
toolCalls: m.tools?.totalCalls ?? 0,
|
|
250
|
-
successRate: m.tools?.successRate ?? 0,
|
|
251
|
-
firstUserMessage: firstUserMessageOf(m.rounds),
|
|
252
|
-
filePath,
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
/** 快照 → 详情;默认子代理摘要化(steps 抽为 stepCount,完整 steps 走 getSubagentSteps 懒加载)。 */
|
|
256
|
-
export function detailFromRaw(raw, opts = {}) {
|
|
257
|
-
const detail = {
|
|
258
|
-
...snapshotToSummary(raw),
|
|
259
|
-
steps: raw.steps ?? [],
|
|
260
|
-
tools: raw.tools,
|
|
261
|
-
codeStats: raw.codeStats,
|
|
262
|
-
rounds: raw.rounds ?? [],
|
|
263
|
-
subagents: raw.subagents,
|
|
264
|
-
skills: raw.skills,
|
|
265
|
-
planning: raw.planning,
|
|
266
|
-
systemPrompts: raw.systemPrompts,
|
|
267
|
-
};
|
|
268
|
-
if (Array.isArray(detail.subagents) && !opts.keepSubagentSteps) {
|
|
269
|
-
detail.subagents = detail.subagents.map((s) => ({
|
|
270
|
-
sessionId: s.sessionId,
|
|
271
|
-
agent: s.agent,
|
|
272
|
-
title: s.title,
|
|
273
|
-
tokens: s.tokens,
|
|
274
|
-
tools: s.tools,
|
|
275
|
-
...(s.userMessages ? { userMessages: s.userMessages } : {}),
|
|
276
|
-
stepCount: s.steps?.length ?? s.stepCount ?? 0,
|
|
277
|
-
}));
|
|
278
|
-
}
|
|
279
|
-
return detail;
|
|
280
|
-
}
|
|
281
|
-
/** 保留完整子代理 steps 的详情(供直接渲染子代理树的宿主使用)。 */
|
|
282
|
-
export function detailFullFromRaw(raw) {
|
|
283
|
-
return detailFromRaw(raw, { keepSubagentSteps: true });
|
|
284
|
-
}
|
|
285
|
-
function buildWhere(f) {
|
|
286
|
-
const clauses = [];
|
|
287
|
-
const params = [];
|
|
288
|
-
if (f.agent) {
|
|
289
|
-
clauses.push("agent = ?");
|
|
290
|
-
params.push(f.agent);
|
|
291
|
-
}
|
|
292
|
-
if (f.agents && f.agents.length > 0) {
|
|
293
|
-
clauses.push(`agent IN (${f.agents.map(() => "?").join(",")})`);
|
|
294
|
-
params.push(...f.agents);
|
|
295
|
-
}
|
|
296
|
-
if (f.taskId) {
|
|
297
|
-
clauses.push("task_id = ?");
|
|
298
|
-
params.push(f.taskId);
|
|
299
|
-
}
|
|
300
|
-
if (f.model) {
|
|
301
|
-
clauses.push("model = ?");
|
|
302
|
-
params.push(f.model);
|
|
303
|
-
}
|
|
304
|
-
if (f.sessionIds && f.sessionIds.length > 0) {
|
|
305
|
-
clauses.push(`session_id IN (${f.sessionIds.map(() => "?").join(",")})`);
|
|
306
|
-
params.push(...f.sessionIds);
|
|
307
|
-
}
|
|
308
|
-
if (f.directoryPrefix) {
|
|
309
|
-
clauses.push("lower(replace(directory, '\\', '/')) LIKE ?");
|
|
310
|
-
params.push(f.directoryPrefix.replace(/\\/g, "/").toLowerCase() + "%");
|
|
311
|
-
}
|
|
312
|
-
const sinceIso = toIsoString(f.since);
|
|
313
|
-
if (sinceIso) {
|
|
314
|
-
clauses.push("created_at >= ?");
|
|
315
|
-
params.push(sinceIso);
|
|
316
|
-
}
|
|
317
|
-
const untilIso = toIsoString(f.until);
|
|
318
|
-
if (untilIso) {
|
|
319
|
-
clauses.push("created_at <= ?");
|
|
320
|
-
params.push(untilIso);
|
|
321
|
-
}
|
|
322
|
-
return { where: clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "", params };
|
|
323
|
-
}
|
|
324
|
-
const SUMMARY_LIST_COLS = `session_id AS "id", task_id AS "taskId", agent, model, directory, title,
|
|
325
|
-
first_user_message AS "firstUserMessage", step_count AS "stepCount",
|
|
326
|
-
tool_calls AS "toolCalls",
|
|
327
|
-
total_tokens_input, total_tokens_output,
|
|
328
|
-
total_tokens_cache_read, total_tokens_cache_write,
|
|
329
|
-
total_tokens_reasoning, total_tokens,
|
|
330
|
-
duration, created_at AS "createdAt", source_file AS "filePath"`;
|
|
331
|
-
function toInt(v) {
|
|
332
|
-
const n = typeof v === "number" ? v : parseInt(String(v), 10);
|
|
333
|
-
return Number.isFinite(n) ? n : 0;
|
|
334
|
-
}
|
|
335
|
-
function rowToSummary(r) {
|
|
336
|
-
return {
|
|
337
|
-
id: r.id,
|
|
338
|
-
directory: r.directory ?? "",
|
|
339
|
-
model: r.model ?? "",
|
|
340
|
-
agent: r.agent ?? "",
|
|
341
|
-
title: r.title ? r.title : undefined,
|
|
342
|
-
stepCount: toInt(r.stepCount),
|
|
343
|
-
toolCalls: toInt(r.toolCalls),
|
|
344
|
-
totalTokens: {
|
|
345
|
-
input: toInt(r.total_tokens_input),
|
|
346
|
-
output: toInt(r.total_tokens_output),
|
|
347
|
-
cacheRead: toInt(r.total_tokens_cache_read),
|
|
348
|
-
cacheWrite: toInt(r.total_tokens_cache_write),
|
|
349
|
-
reasoning: toInt(r.total_tokens_reasoning),
|
|
350
|
-
total: toInt(r.total_tokens),
|
|
351
|
-
},
|
|
352
|
-
duration: toInt(r.duration),
|
|
353
|
-
createdAt: r.createdAt ?? "",
|
|
354
|
-
firstUserMessage: r.firstUserMessage ? r.firstUserMessage : undefined,
|
|
355
|
-
filePath: r.filePath ? r.filePath : undefined,
|
|
356
|
-
totalCost: 0,
|
|
357
|
-
successRate: 0,
|
|
358
|
-
};
|
|
359
|
-
}
|
|
360
|
-
function resolveSummaryFile(dirs) {
|
|
361
|
-
return resolveDirs(dirs).summaryFile;
|
|
362
|
-
}
|
|
363
|
-
/** 摘要筛选查询(created_at 倒序;目录/筛选参数归一后走 SQL)。同步函数。 */
|
|
364
|
-
export function querySummaries(filter = {}, dirs) {
|
|
365
|
-
const db = openSummaryDb(resolveSummaryFile(dirs));
|
|
366
|
-
if (!db)
|
|
367
|
-
return [];
|
|
368
|
-
const { where, params } = buildWhere(filter);
|
|
369
|
-
let limitClause = "";
|
|
370
|
-
if (filter.limit != null || filter.offset != null) {
|
|
371
|
-
limitClause = " LIMIT ? OFFSET ?";
|
|
372
|
-
params.push(filter.limit ?? -1, filter.offset ?? 0);
|
|
373
|
-
}
|
|
374
|
-
const sql = `SELECT ${SUMMARY_LIST_COLS} FROM session_summaries${where} ORDER BY created_at DESC${limitClause}`;
|
|
375
|
-
const rows = db.prepare(sql).all(...params);
|
|
376
|
-
return rows.map(rowToSummary);
|
|
377
|
-
}
|
|
378
|
-
/** 按同一筛选条件计数(分页用)。 */
|
|
379
|
-
export function countSummaries(filter = {}, dirs) {
|
|
380
|
-
const db = openSummaryDb(resolveSummaryFile(dirs));
|
|
381
|
-
if (!db)
|
|
382
|
-
return 0;
|
|
383
|
-
const { where, params } = buildWhere(filter);
|
|
384
|
-
const row = db.prepare(`SELECT COUNT(*) AS n FROM session_summaries${where}`).get(...params);
|
|
385
|
-
return toInt(row?.n);
|
|
386
|
-
}
|
|
387
|
-
// ─── 详情读取(full_data 优先 → 快照文件回退)──────────────────────────────
|
|
388
|
-
const SESSION_ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
389
|
-
function readSnapshotFile(metricsDir, sessionId) {
|
|
390
|
-
if (!SESSION_ID_RE.test(sessionId))
|
|
391
|
-
return null;
|
|
392
|
-
const filePath = path.join(metricsDir, `${sessionId}.json`);
|
|
393
|
-
try {
|
|
394
|
-
const raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
395
|
-
if (!raw?.sessionId || !raw.header)
|
|
396
|
-
return null;
|
|
397
|
-
return { raw, filePath };
|
|
398
|
-
}
|
|
399
|
-
catch {
|
|
400
|
-
return null;
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
function loadRaw(sessionId, dirs) {
|
|
404
|
-
const db = openSummaryDb(resolveSummaryFile(dirs));
|
|
405
|
-
if (db) {
|
|
406
|
-
try {
|
|
407
|
-
const row = db.prepare("SELECT full_data FROM session_details WHERE session_id = ?").get(sessionId);
|
|
408
|
-
if (row?.full_data) {
|
|
409
|
-
const raw = JSON.parse(row.full_data);
|
|
410
|
-
if (raw?.sessionId)
|
|
411
|
-
return { raw, fromDb: true };
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
catch { /* 回退文件 */ }
|
|
415
|
-
}
|
|
416
|
-
const file = readSnapshotFile(resolveDirs(dirs).metricsDir, sessionId);
|
|
417
|
-
return file ? { raw: file.raw, filePath: file.filePath, fromDb: false } : null;
|
|
418
|
-
}
|
|
419
|
-
/** 单会话详情(DB full_data → 文件回退);默认子代理摘要化,fullSubagents=true 保留完整 steps。 */
|
|
420
|
-
export function getDetail(sessionId, dirs, opts = {}) {
|
|
421
|
-
const found = loadRaw(sessionId, dirs);
|
|
422
|
-
if (!found)
|
|
423
|
-
return null;
|
|
424
|
-
const detail = detailFromRaw(found.raw, { keepSubagentSteps: opts.fullSubagents === true });
|
|
425
|
-
if (found.filePath)
|
|
426
|
-
detail.filePath = found.filePath;
|
|
427
|
-
return detail;
|
|
428
|
-
}
|
|
429
|
-
/** 子代理完整 steps 懒加载(DB full_data → 文件回退)。 */
|
|
430
|
-
export function getSubagentSteps(sessionId, index, dirs) {
|
|
431
|
-
const found = loadRaw(sessionId, dirs);
|
|
432
|
-
const sa = found?.raw.subagents?.[index];
|
|
433
|
-
if (!sa)
|
|
434
|
-
return null;
|
|
435
|
-
return sa.steps ?? [];
|
|
436
|
-
}
|
|
437
|
-
/** 原始快照 JSON 字符串(文件优先 → DB full_data 回退;等价"下载"能力)。 */
|
|
438
|
-
export function getSessionRaw(sessionId, dirs) {
|
|
439
|
-
if (!SESSION_ID_RE.test(sessionId))
|
|
440
|
-
return null;
|
|
441
|
-
try {
|
|
442
|
-
return fs.readFileSync(path.join(resolveDirs(dirs).metricsDir, `${sessionId}.json`), "utf-8");
|
|
443
|
-
}
|
|
444
|
-
catch { /* 回退 DB */ }
|
|
445
|
-
const db = openSummaryDb(resolveSummaryFile(dirs));
|
|
446
|
-
if (db) {
|
|
447
|
-
try {
|
|
448
|
-
const row = db.prepare("SELECT full_data FROM session_details WHERE session_id = ?").get(sessionId);
|
|
449
|
-
if (row?.full_data)
|
|
450
|
-
return row.full_data;
|
|
451
|
-
}
|
|
452
|
-
catch { }
|
|
453
|
-
}
|
|
454
|
-
return null;
|
|
455
|
-
}
|
|
456
|
-
/** 给会话标记任务分组(宿主任务关联)。返回是否命中行。 */
|
|
457
|
-
export function setSessionTaskId(sessionId, taskId, dirs) {
|
|
458
|
-
const db = openSummaryDb(resolveSummaryFile(dirs));
|
|
459
|
-
if (!db)
|
|
460
|
-
return false;
|
|
461
|
-
const r = db.prepare("UPDATE session_summaries SET task_id = ? WHERE session_id = ?").run(taskId, sessionId);
|
|
462
|
-
return Number(r.changes) > 0;
|
|
463
|
-
}
|
|
464
|
-
/**
|
|
465
|
-
* 扫描 metricsDir 现有快照 JSON 重建摘要行(soft:live 已有数据不覆盖)。
|
|
466
|
-
* 摘要库缺省为全局共享库(多宿主聚合);显式 summaryFile 可指向隔离库。
|
|
467
|
-
* prune=true 时清理 source_file 已不存在于磁盘的孤儿行(双表同删)——
|
|
468
|
-
* 共享库语义下会清理**全库**的孤儿行(含其他宿主目录已消失的快照),属预期行为。
|
|
469
|
-
*/
|
|
470
|
-
export function reindexDir(metricsDir, opts = {}) {
|
|
471
|
-
const summaryFile = opts.summaryFile ?? getSummaryFile();
|
|
472
|
-
const result = { scanned: 0, upserted: 0, pruned: 0 };
|
|
473
|
-
let files = [];
|
|
474
|
-
try {
|
|
475
|
-
files = fs.readdirSync(metricsDir).filter((f) => f.endsWith(".json"));
|
|
476
|
-
}
|
|
477
|
-
catch {
|
|
478
|
-
return result;
|
|
479
|
-
}
|
|
480
|
-
for (const f of files) {
|
|
481
|
-
const filePath = path.join(metricsDir, f);
|
|
482
|
-
result.scanned++;
|
|
483
|
-
try {
|
|
484
|
-
const raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
485
|
-
if (!raw?.sessionId || !raw.header)
|
|
486
|
-
continue;
|
|
487
|
-
if (upsertSessionSnapshot(summaryFile, raw, filePath, "soft"))
|
|
488
|
-
result.upserted++;
|
|
489
|
-
}
|
|
490
|
-
catch { /* 跳过坏文件 */ }
|
|
491
|
-
}
|
|
492
|
-
if (opts.prune) {
|
|
493
|
-
const db = openSummaryDb(summaryFile);
|
|
494
|
-
if (db) {
|
|
495
|
-
try {
|
|
496
|
-
const rows = db.prepare("SELECT session_id, source_file FROM session_summaries").all();
|
|
497
|
-
db.exec("BEGIN");
|
|
498
|
-
try {
|
|
499
|
-
const delSummary = db.prepare("DELETE FROM session_summaries WHERE session_id = ?");
|
|
500
|
-
const delDetail = db.prepare("DELETE FROM session_details WHERE session_id = ?");
|
|
501
|
-
for (const r of rows) {
|
|
502
|
-
if (!r.source_file || !fs.existsSync(r.source_file)) {
|
|
503
|
-
delSummary.run(r.session_id);
|
|
504
|
-
delDetail.run(r.session_id);
|
|
505
|
-
result.pruned++;
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
db.exec("COMMIT");
|
|
509
|
-
}
|
|
510
|
-
catch (err) {
|
|
511
|
-
try {
|
|
512
|
-
db.exec("ROLLBACK");
|
|
513
|
-
}
|
|
514
|
-
catch { }
|
|
515
|
-
throw err;
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
catch (err) {
|
|
519
|
-
log.info("[summary] prune 失败", { summaryFile, error: String(err) });
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
return result;
|
|
524
|
-
}
|
|
525
|
-
/**
|
|
526
|
-
* 删除双表中的会话行(宿主删除链路用)。
|
|
527
|
-
* 返回是否删除及被删行的 source_file;库不存在/行不存在时 deleted=false。
|
|
528
|
-
*/
|
|
529
|
-
export function removeSession(sessionId, dirs) {
|
|
530
|
-
const db = openSummaryDb(resolveSummaryFile(dirs));
|
|
531
|
-
if (!db)
|
|
532
|
-
return { deleted: false };
|
|
533
|
-
try {
|
|
534
|
-
const row = db.prepare("SELECT source_file FROM session_summaries WHERE session_id = ?")
|
|
535
|
-
.get(sessionId);
|
|
536
|
-
db.exec("BEGIN");
|
|
537
|
-
let changes = 0;
|
|
538
|
-
try {
|
|
539
|
-
changes = Number(db.prepare("DELETE FROM session_summaries WHERE session_id = ?").run(sessionId).changes);
|
|
540
|
-
db.prepare("DELETE FROM session_details WHERE session_id = ?").run(sessionId);
|
|
541
|
-
db.exec("COMMIT");
|
|
542
|
-
}
|
|
543
|
-
catch (err) {
|
|
544
|
-
try {
|
|
545
|
-
db.exec("ROLLBACK");
|
|
546
|
-
}
|
|
547
|
-
catch { }
|
|
548
|
-
throw err;
|
|
549
|
-
}
|
|
550
|
-
return { deleted: changes > 0, sourceFile: row?.source_file ?? undefined };
|
|
551
|
-
}
|
|
552
|
-
catch (err) {
|
|
553
|
-
log.info("[summary] 删除会话行失败", { sessionId, error: String(err) });
|
|
554
|
-
return { deleted: false };
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
/** 进程级默认摘要库路径(与 getMetricsDir/getEventsDir 对称的便捷取法)。 */
|
|
558
|
-
export function getDefaultSummaryFile() {
|
|
559
|
-
return getSummaryFile();
|
|
560
|
-
}
|
|
File without changes
|
|
File without changes
|