opencode-metrics-plugin 0.1.5

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.
@@ -0,0 +1,161 @@
1
+ /**
2
+ * compile-analyzer.ts — Pure-function module for parsing hvigorw build output
3
+ * and extracting compile metrics (errors, warnings, module timings).
4
+ */
5
+ // ─── Duration parsing ───────────────────────────────────────────────────────
6
+ function parseDuration(text) {
7
+ let ms = 0;
8
+ const secMatch = text.match(/(\d+)\s*s\b/);
9
+ if (secMatch)
10
+ ms += parseInt(secMatch[1], 10) * 1000;
11
+ const msMatch = text.match(/(\d+)\s*ms/);
12
+ if (msMatch)
13
+ ms += parseInt(msMatch[1], 10);
14
+ return ms;
15
+ }
16
+ // ─── stripAnsi ──────────────────────────────────────────────────────────────
17
+ export function stripAnsi(raw) {
18
+ return raw.replace(/\x1b\[[0-9;]*m/g, "").replace(/\r\n/g, "\n");
19
+ }
20
+ // ─── extractErrorCodes ──────────────────────────────────────────────────────
21
+ export function extractErrorCodes(stripped) {
22
+ const errors = [];
23
+ const seen = new Set();
24
+ // Strict match: N ERROR: code Type\nError Message: ... At File: path:line:col
25
+ const strictRegex = /(\d+)\s+ERROR:\s+(\d+)\s+(.+?)\nError Message:\s+(.+?)\s+At [Ff]ile:\s+(.+?):(\d+)(?::(\d+))?/g;
26
+ let match;
27
+ while ((match = strictRegex.exec(stripped)) !== null) {
28
+ errors.push({
29
+ code: match[2],
30
+ type: match[3],
31
+ message: match[4],
32
+ file: match[5],
33
+ line: parseInt(match[6], 10),
34
+ col: match[7] ? parseInt(match[7], 10) : 0,
35
+ });
36
+ seen.add(match[1]);
37
+ }
38
+ // Fallback: match N ERROR: code Type without requiring Error Message line
39
+ const fallbackRegex = /(\d+)\s+ERROR:\s+(\d+)\s+(.+?)(?:\r?\n|$)/g;
40
+ while ((match = fallbackRegex.exec(stripped)) !== null) {
41
+ if (!seen.has(match[1])) {
42
+ errors.push({
43
+ code: match[2],
44
+ type: match[3].trim(),
45
+ message: "",
46
+ file: "",
47
+ line: 0,
48
+ col: 0,
49
+ });
50
+ }
51
+ }
52
+ return errors;
53
+ }
54
+ // ─── extractWarnings ────────────────────────────────────────────────────────
55
+ function classifyWarning(message) {
56
+ const lower = message.toLowerCase();
57
+ if (lower.includes("deprecated"))
58
+ return "deprecated_api";
59
+ if (lower.includes("conflict"))
60
+ return "resource_conflict";
61
+ if (/\bsign\b/.test(lower) || lower.includes("signingconfigs"))
62
+ return "signing";
63
+ if (lower.includes("obfuscation"))
64
+ return "obfuscation";
65
+ return "other";
66
+ }
67
+ export function extractWarnings(stripped) {
68
+ const warnings = [];
69
+ // ArkTS warnings with file location
70
+ const arktsRegex = /WARN:\s+ArkTS:WARN\s+File:\s+(.+?):(\d+):\d+\n\s+(.+)/g;
71
+ let match;
72
+ while ((match = arktsRegex.exec(stripped)) !== null) {
73
+ const message = match[3].trim();
74
+ warnings.push({
75
+ type: classifyWarning(message),
76
+ message,
77
+ file: match[1],
78
+ line: parseInt(match[2], 10),
79
+ });
80
+ }
81
+ // Build-system warnings (WARN: or Warning:)
82
+ const buildRegex = /^(?:WARN:\s+|Warning:\s+)(.+)$/gm;
83
+ while ((match = buildRegex.exec(stripped)) !== null) {
84
+ // Skip lines already captured as ArkTS warnings
85
+ if (/ArkTS:WARN/.test(match[0]))
86
+ continue;
87
+ const message = match[1].trim();
88
+ warnings.push({
89
+ type: classifyWarning(message),
90
+ message,
91
+ });
92
+ }
93
+ return warnings;
94
+ }
95
+ // ─── extractModuleTimings ───────────────────────────────────────────────────
96
+ export function extractModuleTimings(stripped) {
97
+ const timings = new Map();
98
+ // Module tasks: Finished :moduleName:default@taskName... after X ms
99
+ const moduleRegex = /Finished\s+:(\w+):\S+?@(\w+)\.\.\.\s+after\s+(.+?)$/gm;
100
+ let match;
101
+ while ((match = moduleRegex.exec(stripped)) !== null) {
102
+ const moduleName = match[1];
103
+ const taskName = match[2];
104
+ const duration = parseDuration(match[3]);
105
+ addTiming(timings, moduleName, taskName, duration);
106
+ }
107
+ // Root module: Finished ::default... after X ms
108
+ const rootRegex = /Finished\s+::(\w+)\.\.\.\s+after\s+(.+?)$/gm;
109
+ while ((match = rootRegex.exec(stripped)) !== null) {
110
+ const taskName = match[1];
111
+ const duration = parseDuration(match[2]);
112
+ addTiming(timings, "(root)", taskName, duration);
113
+ }
114
+ return timings;
115
+ }
116
+ function addTiming(timings, moduleName, taskName, duration) {
117
+ const existing = timings.get(moduleName);
118
+ if (existing) {
119
+ existing.totalDuration += duration;
120
+ existing.taskCount += 1;
121
+ if (duration > existing.slowestTask.duration) {
122
+ existing.slowestTask = { name: taskName, duration };
123
+ }
124
+ }
125
+ else {
126
+ timings.set(moduleName, {
127
+ totalDuration: duration,
128
+ taskCount: 1,
129
+ slowestTask: { name: taskName, duration },
130
+ });
131
+ }
132
+ }
133
+ // ─── parseHvigorwOutput ─────────────────────────────────────────────────────
134
+ export function parseHvigorwOutput(raw) {
135
+ const stripped = stripAnsi(raw);
136
+ // BUILD result
137
+ let success = false;
138
+ let duration = 0;
139
+ const buildMatch = stripped.match(/BUILD\s+(SUCCESSFUL|FAILED)\s+in\s+(.+?)$/m);
140
+ if (buildMatch) {
141
+ success = buildMatch[1] === "SUCCESSFUL";
142
+ duration = parseDuration(buildMatch[2]);
143
+ }
144
+ // COMPILE RESULT
145
+ let errorCount = 0;
146
+ let warnCount = 0;
147
+ const compileMatch = stripped.match(/COMPILE RESULT:(?:FAIL|SUCCESS)\s+\{ERROR:(\d+)\s+WARN:(\d+)\}/);
148
+ if (compileMatch) {
149
+ errorCount = parseInt(compileMatch[1], 10);
150
+ warnCount = parseInt(compileMatch[2], 10);
151
+ }
152
+ return {
153
+ success,
154
+ duration,
155
+ errorCount,
156
+ warnCount,
157
+ errors: extractErrorCodes(stripped),
158
+ warnings: extractWarnings(stripped),
159
+ moduleTimings: extractModuleTimings(stripped),
160
+ };
161
+ }
package/dist/dirs.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ /** metrics/events 写入目录集。host 可自定义;默认按应用名 env paths(与 session-viewer-plugin 约定一致)。 */
2
+ export interface MetricsDirs {
3
+ /** 会话快照 JSON 目录(<sessionId>.json 直接落这里;各宿主必须隔离,避免多写者冲突) */
4
+ metricsDir: string;
5
+ /** events 逐会话日志目录 */
6
+ eventsDir: string;
7
+ /** 运行日志文件(可选;logger 无文件权限时回退 console) */
8
+ logFile: string;
9
+ /**
10
+ * 摘要/详情双表 sqlite 落点。缺省为包级全局路径:本机多宿主共享同一个 summary.db,
11
+ * 查询一处即可获得全部宿主的会话摘要。显式传入本字段的宿主可获得独立隔离库。
12
+ * (快照 JSON 目录仍按宿主隔离,summary 双表在库层聚合。)
13
+ */
14
+ summaryFile: string;
15
+ }
16
+ export declare function defaultDirs(appName?: string): MetricsDirs;
17
+ /**
18
+ * 供 host 设定进程级默认目录(未显式传 dirs 的引擎/事件记录器使用)。
19
+ * 注意:多引擎场景请优先在 createMetricsEngine/createEventLogger 实例级传 dirs,
20
+ * 进程级配置仅作默认兜底,避免多宿主互相覆盖。
21
+ * summaryFile 未显式配置时保持包级全局路径(跨宿主聚合语义)。
22
+ */
23
+ export declare function configureDirs(dirs: Partial<MetricsDirs>): void;
24
+ /** 当前进程级默认目录(defaultDirs 与 configureDirs 的合并结果)。 */
25
+ export declare function getDirs(): MetricsDirs;
26
+ /**
27
+ * 解析实例目录:defaultDirs → 进程级默认(configureDirs)→ 实例传入,三层合并。
28
+ * summaryFile 不随 metricsDir 派生:缺省恒为全局共享库(实例/进程级显式指定才能改写)。
29
+ */
30
+ export declare function resolveDirs(partial?: Partial<MetricsDirs>): MetricsDirs;
31
+ export declare function getMetricsDir(): string;
32
+ export declare function getEventsDir(): string;
33
+ export declare function getLogFile(): string;
34
+ export declare function getSummaryFile(): string;
package/dist/dirs.js ADDED
@@ -0,0 +1,44 @@
1
+ import envPaths from 'env-paths';
2
+ import path from 'path';
3
+ export function defaultDirs(appName = 'opencode-metrics-plugin') {
4
+ const base = envPaths(appName, { suffix: '' });
5
+ return {
6
+ metricsDir: path.join(base.log, 'metrics'),
7
+ eventsDir: path.join(base.log, 'events'),
8
+ logFile: path.join(base.log, 'plugin.log'),
9
+ summaryFile: path.join(base.log, 'summary.db'),
10
+ };
11
+ }
12
+ let _dirs = defaultDirs();
13
+ /**
14
+ * 供 host 设定进程级默认目录(未显式传 dirs 的引擎/事件记录器使用)。
15
+ * 注意:多引擎场景请优先在 createMetricsEngine/createEventLogger 实例级传 dirs,
16
+ * 进程级配置仅作默认兜底,避免多宿主互相覆盖。
17
+ * summaryFile 未显式配置时保持包级全局路径(跨宿主聚合语义)。
18
+ */
19
+ export function configureDirs(dirs) {
20
+ _dirs = { ...defaultDirs(), ...dirs };
21
+ }
22
+ /** 当前进程级默认目录(defaultDirs 与 configureDirs 的合并结果)。 */
23
+ export function getDirs() {
24
+ return _dirs;
25
+ }
26
+ /**
27
+ * 解析实例目录:defaultDirs → 进程级默认(configureDirs)→ 实例传入,三层合并。
28
+ * summaryFile 不随 metricsDir 派生:缺省恒为全局共享库(实例/进程级显式指定才能改写)。
29
+ */
30
+ export function resolveDirs(partial) {
31
+ return partial ? { ...getDirs(), ...partial } : { ...getDirs() };
32
+ }
33
+ export function getMetricsDir() {
34
+ return _dirs.metricsDir;
35
+ }
36
+ export function getEventsDir() {
37
+ return _dirs.eventsDir;
38
+ }
39
+ export function getLogFile() {
40
+ return _dirs.logFile;
41
+ }
42
+ export function getSummaryFile() {
43
+ return _dirs.summaryFile;
44
+ }
@@ -0,0 +1,20 @@
1
+ export interface EventLogger {
2
+ log(event: {
3
+ type: string;
4
+ properties?: Record<string, unknown>;
5
+ }): void;
6
+ logPrompt(sessionId: string, type: string, content: string[]): void;
7
+ isSubAgent(sessionId: string): boolean;
8
+ flush(): void;
9
+ dispose(): void;
10
+ }
11
+ export interface EventLoggerOptions {
12
+ /** 本实例事件日志目录(多引擎场景各自独立);缺省回退进程级默认(configureDirs) */
13
+ eventsDir?: string;
14
+ /** 会话归属门控:返回 false 的事件不写入本实例日志(与引擎共用同一 scope 判定) */
15
+ isTracked?: (sessionId: string, event: {
16
+ type: string;
17
+ properties?: Record<string, unknown>;
18
+ }) => boolean;
19
+ }
20
+ export declare function createEventLogger(enabled: boolean, cleanupEnabled?: boolean, opts?: EventLoggerOptions): EventLogger;
@@ -0,0 +1,329 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { getEventsDir } from "./dirs.js";
4
+ import { TRACKED_EVENT_TYPES } from "./metrics-types.js";
5
+ // Rotation constants
6
+ const MAX_LOG_SIZE_BYTES = 20 * 1024 * 1024;
7
+ const MAX_LOG_AGE_DAYS = 7;
8
+ const MAX_ROTATED_FILES = 5;
9
+ const FLUSH_INTERVAL_MS = 500;
10
+ const BUFFER_SIZE_LIMIT = 50;
11
+ const SEPARATOR = "=".repeat(72);
12
+ // 鈹€鈹€鈹€ Helpers 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
13
+ function formatTimestamp(date) {
14
+ const pad = (n) => String(n).padStart(2, "0");
15
+ const ms = String(Math.floor(date.getMilliseconds() / 10)).padStart(2, "0");
16
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${ms}`;
17
+ }
18
+ function formatDuration(ms) {
19
+ const seconds = Math.floor(ms / 1000);
20
+ const m = Math.floor(seconds / 60);
21
+ const s = seconds % 60;
22
+ return m > 0 ? `${m}m ${s}s` : `${s}s`;
23
+ }
24
+ // Maps child sessionID → parent sessionID for sub-agent sessions(实例级,见 createEventLogger 闭包)
25
+ function getLogFile(sessionId, eventsDir) {
26
+ return path.join(eventsDir, `${sessionId}.log`);
27
+ }
28
+ function safeJson(obj, indent = 2) {
29
+ try {
30
+ return JSON.stringify(obj, null, indent);
31
+ }
32
+ catch {
33
+ return String(obj);
34
+ }
35
+ }
36
+ // 鈹€鈹€鈹€ Rotation 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
37
+ /** Delete all event log files older than MAX_LOG_AGE_DAYS */
38
+ function cleanupStaleLogs(eventsDir) {
39
+ try {
40
+ if (!fs.existsSync(eventsDir))
41
+ return;
42
+ const entries = fs.readdirSync(eventsDir);
43
+ const now = Date.now();
44
+ const maxAgeMs = MAX_LOG_AGE_DAYS * 24 * 60 * 60 * 1000;
45
+ for (const file of entries) {
46
+ if (!file.endsWith(".log"))
47
+ continue;
48
+ const full = path.join(eventsDir, file);
49
+ try {
50
+ if (now - fs.statSync(full).mtimeMs > maxAgeMs) {
51
+ fs.unlinkSync(full);
52
+ }
53
+ }
54
+ catch { }
55
+ }
56
+ }
57
+ catch { }
58
+ }
59
+ function cleanupRotatedLogs(sessionId, eventsDir) {
60
+ try {
61
+ const entries = fs.readdirSync(eventsDir);
62
+ const prefix = `${sessionId}.`;
63
+ const rotated = entries
64
+ .filter((f) => f.startsWith(prefix) && f.endsWith(".log") && f !== `${sessionId}.log`)
65
+ .map((f) => {
66
+ const full = path.join(eventsDir, f);
67
+ try {
68
+ return { file: full, mtimeMs: fs.statSync(full).mtimeMs };
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ })
74
+ .filter((e) => e !== null);
75
+ const now = Date.now();
76
+ const maxAgeMs = MAX_LOG_AGE_DAYS * 24 * 60 * 60 * 1000;
77
+ for (const entry of rotated) {
78
+ if (now - entry.mtimeMs > maxAgeMs) {
79
+ try {
80
+ fs.unlinkSync(entry.file);
81
+ }
82
+ catch { }
83
+ }
84
+ }
85
+ const surviving = rotated
86
+ .filter((e) => now - e.mtimeMs <= maxAgeMs)
87
+ .sort((a, b) => a.mtimeMs - b.mtimeMs);
88
+ if (surviving.length > MAX_ROTATED_FILES) {
89
+ const doomed = surviving.slice(0, surviving.length - MAX_ROTATED_FILES);
90
+ for (const entry of doomed) {
91
+ try {
92
+ fs.unlinkSync(entry.file);
93
+ }
94
+ catch { }
95
+ }
96
+ }
97
+ }
98
+ catch { }
99
+ }
100
+ function rotateIfNeeded(sessionId, eventsDir) {
101
+ try {
102
+ const logFile = getLogFile(sessionId, eventsDir);
103
+ const stat = fs.statSync(logFile);
104
+ if (stat.size >= MAX_LOG_SIZE_BYTES) {
105
+ const now = new Date();
106
+ const pad = (n) => String(n).padStart(2, "0");
107
+ const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
108
+ fs.renameSync(logFile, path.join(eventsDir, `${sessionId}.${ts}.log`));
109
+ }
110
+ }
111
+ catch { }
112
+ }
113
+ // 鈹€鈹€鈹€ Log Formatting 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
114
+ function formatEventBlock(ts, eventType, properties, subAgent) {
115
+ const lines = [];
116
+ const tag = subAgent ? ` [sub-agent:${subAgent.childId}]` : "";
117
+ lines.push(`[${ts}] EVENT:${tag} ${eventType}`);
118
+ if (properties && Object.keys(properties).length > 0) {
119
+ lines.push(` DATA: ${safeJson(properties)}`);
120
+ }
121
+ lines.push("");
122
+ return lines.join("\n");
123
+ }
124
+ function formatSessionSummary(startTime, roundCount) {
125
+ const duration = Date.now() - startTime;
126
+ const lines = [];
127
+ lines.push("");
128
+ lines.push(SEPARATOR);
129
+ lines.push(" SESSION SUMMARY");
130
+ lines.push(SEPARATOR);
131
+ lines.push("");
132
+ lines.push(` Duration: ${formatDuration(duration)}`);
133
+ lines.push(` Rounds: ${roundCount}`);
134
+ lines.push(` End Time: ${formatTimestamp(new Date())}`);
135
+ lines.push(SEPARATOR);
136
+ lines.push("");
137
+ return lines.join("\n");
138
+ }
139
+ export function createEventLogger(enabled, cleanupEnabled = false, opts = {}) {
140
+ const sessionBuffers = new Map();
141
+ // 子代理父子映射(实例级,多引擎互不共享)
142
+ const childToParent = new Map();
143
+ const childSessionIds = new Set();
144
+ // 实例目录优先,未配置时动态回退进程级默认(保持 configureDirs 后生效的旧行为)
145
+ const evDir = () => opts.eventsDir ?? getEventsDir();
146
+ function resolveSessionId(rawId) {
147
+ return childToParent.get(rawId) ?? rawId;
148
+ }
149
+ function extractSessionId(event) {
150
+ const props = event.properties;
151
+ if (!props)
152
+ return undefined;
153
+ // session.created/updated — detect sub-agent and record parent mapping
154
+ if (event.type === "session.created" || event.type === "session.updated") {
155
+ const info = props.info;
156
+ if (info) {
157
+ const id = info.id;
158
+ const parentID = info.parentID;
159
+ if (id && parentID) {
160
+ childToParent.set(id, parentID);
161
+ childSessionIds.add(id);
162
+ // Return both childId (for buffer migration) and parentId (for logging)
163
+ return `__migrate__:${id}:${parentID}`;
164
+ }
165
+ if (id)
166
+ return id;
167
+ }
168
+ }
169
+ const sessionID = props.sessionID;
170
+ if (sessionID)
171
+ return resolveSessionId(sessionID);
172
+ const info = props.info;
173
+ if (info?.id)
174
+ return resolveSessionId(info.id);
175
+ return undefined;
176
+ }
177
+ if (enabled) {
178
+ try {
179
+ fs.mkdirSync(evDir(), { recursive: true });
180
+ }
181
+ catch { }
182
+ if (cleanupEnabled) {
183
+ setImmediate(() => cleanupStaleLogs(evDir()));
184
+ }
185
+ }
186
+ function flushSession(sessionId, buf) {
187
+ if (buf.lines.length === 0)
188
+ return;
189
+ const data = buf.lines.join("");
190
+ buf.lines = [];
191
+ try {
192
+ fs.appendFileSync(getLogFile(sessionId, evDir()), data);
193
+ rotateIfNeeded(sessionId, evDir());
194
+ }
195
+ catch { }
196
+ }
197
+ function scheduleFlush(sessionId, buf) {
198
+ if (buf.flushTimer)
199
+ return;
200
+ buf.flushTimer = setTimeout(() => {
201
+ buf.flushTimer = null;
202
+ flushSession(sessionId, buf);
203
+ }, FLUSH_INTERVAL_MS);
204
+ }
205
+ function write(sessionId, content) {
206
+ const buf = sessionBuffers.get(sessionId);
207
+ if (!buf)
208
+ return;
209
+ buf.lines.push(content);
210
+ if (buf.lines.length >= BUFFER_SIZE_LIMIT) {
211
+ flushSession(sessionId, buf);
212
+ }
213
+ else {
214
+ scheduleFlush(sessionId, buf);
215
+ }
216
+ }
217
+ function getOrCreateBuffer(sessionId) {
218
+ let buf = sessionBuffers.get(sessionId);
219
+ if (!buf) {
220
+ buf = { lines: [], flushTimer: null, startTime: Date.now(), roundCount: 0 };
221
+ sessionBuffers.set(sessionId, buf);
222
+ if (cleanupEnabled) {
223
+ setImmediate(() => cleanupRotatedLogs(sessionId, evDir()));
224
+ }
225
+ }
226
+ return buf;
227
+ }
228
+ return {
229
+ log(event) {
230
+ if (!enabled)
231
+ return;
232
+ if (!TRACKED_EVENT_TYPES.has(event.type))
233
+ return;
234
+ let sessionId = extractSessionId(event);
235
+ if (!sessionId)
236
+ return;
237
+ const props = event.properties;
238
+ // Handle buffer migration when sub-agent relationship is discovered
239
+ if (sessionId.startsWith("__migrate__:")) {
240
+ const [, childId, parentId] = sessionId.split(":");
241
+ sessionId = parentId;
242
+ // 子会话归属随父:父未纳入统计时丢弃子缓冲,不产生日志文件
243
+ if (opts.isTracked && !opts.isTracked(parentId, event)) {
244
+ const dropBuf = sessionBuffers.get(childId);
245
+ if (dropBuf) {
246
+ if (dropBuf.flushTimer) {
247
+ clearTimeout(dropBuf.flushTimer);
248
+ dropBuf.flushTimer = null;
249
+ }
250
+ sessionBuffers.delete(childId);
251
+ }
252
+ return;
253
+ }
254
+ // Move child's existing buffer to parent
255
+ const childBuf = sessionBuffers.get(childId);
256
+ if (childBuf) {
257
+ const parentBuf = getOrCreateBuffer(parentId);
258
+ parentBuf.lines = parentBuf.lines.concat(childBuf.lines);
259
+ childBuf.lines = [];
260
+ parentBuf.roundCount += childBuf.roundCount;
261
+ if (childBuf.flushTimer) {
262
+ clearTimeout(childBuf.flushTimer);
263
+ childBuf.flushTimer = null;
264
+ }
265
+ sessionBuffers.delete(childId);
266
+ }
267
+ }
268
+ // Detect sub-agent for tag (skip if no child sessions exist)
269
+ let subAgent;
270
+ if (childSessionIds.size > 0) {
271
+ const rawSessionId = props?.sessionID ?? props?.info?.id;
272
+ if (rawSessionId && childSessionIds.has(rawSessionId)) {
273
+ subAgent = { childId: rawSessionId };
274
+ }
275
+ }
276
+ // 归属门控:未纳入统计范围的会话不记录事件
277
+ if (opts.isTracked && !opts.isTracked(sessionId, event))
278
+ return;
279
+ const buf = getOrCreateBuffer(sessionId);
280
+ const ts = formatTimestamp(new Date());
281
+ // Write formatted event block
282
+ write(sessionId, formatEventBlock(ts, event.type, props, subAgent));
283
+ // Track round count on step-finish
284
+ if (event.type === "message.part.updated") {
285
+ const part = props?.part;
286
+ if (part && part.type === "step-finish") {
287
+ buf.roundCount++;
288
+ }
289
+ }
290
+ // Round ended 鈥?write summary as a round marker, keep logging if conversation continues
291
+ if (event.type === "session.idle") {
292
+ write(sessionId, formatSessionSummary(buf.startTime, buf.roundCount));
293
+ flushSession(sessionId, buf);
294
+ }
295
+ },
296
+ logPrompt(sessionId, type, content) {
297
+ if (!enabled)
298
+ return;
299
+ const buf = getOrCreateBuffer(sessionId);
300
+ const ts = formatTimestamp(new Date());
301
+ const lines = [];
302
+ lines.push(`[${ts}] PROMPT:${type} [session:${sessionId}]`);
303
+ lines.push(` ${safeJson(content)}`);
304
+ lines.push("");
305
+ write(sessionId, lines.join("\n"));
306
+ },
307
+ isSubAgent(sessionId) {
308
+ return childSessionIds.has(sessionId);
309
+ },
310
+ flush() {
311
+ for (const [sessionId, buf] of sessionBuffers) {
312
+ flushSession(sessionId, buf);
313
+ }
314
+ },
315
+ dispose() {
316
+ for (const [sessionId, buf] of sessionBuffers) {
317
+ if (buf.flushTimer) {
318
+ clearTimeout(buf.flushTimer);
319
+ buf.flushTimer = null;
320
+ }
321
+ write(sessionId, formatSessionSummary(buf.startTime, buf.roundCount));
322
+ flushSession(sessionId, buf);
323
+ }
324
+ sessionBuffers.clear();
325
+ childToParent.clear();
326
+ childSessionIds.clear();
327
+ },
328
+ };
329
+ }
@@ -0,0 +1,51 @@
1
+ import type { MetricsEngineOptions } from "./metrics-engine.js";
2
+ import type { EventLoggerOptions } from "./event-logger.js";
3
+ import type { MetricsDirs } from "./dirs.js";
4
+ export { createMetricsEngine } from "./metrics-engine.js";
5
+ export type { MetricsEngineOptions, MetricsScope, MetricsScopeMode, MetricsEngine } from "./metrics-engine.js";
6
+ export { configureDirs, defaultDirs, getDirs, resolveDirs, getSummaryFile } from "./dirs.js";
7
+ export type { MetricsDirs } from "./dirs.js";
8
+ export { createEventLogger } from "./event-logger.js";
9
+ export type { EventLogger, EventLoggerOptions } from "./event-logger.js";
10
+ export { backfillFromOpencode } from "./backfill.js";
11
+ export type { BackfillOptions, BackfillEngineRule, BackfillFilter, BackfillResult, BackfillEngineStat, BackfillMetricsOutput, } from "./backfill.js";
12
+ export { querySummaries, countSummaries, getDetail, getSubagentSteps, getSessionRaw, setSessionTaskId, reindexDir, upsertSessionSnapshot, openSummaryDb, closeSummaryDbs, removeSession, extractSummaryFields, snapshotToSummary, detailFromRaw, detailFullFromRaw, sumTokens, subtractTokens, toIsoString, firstUserMessageOf, } from "./summary-store.js";
13
+ export type { SummaryFilter, SessionSummary, SessionDetail, SubagentView, ReindexResult, UpsertMode, SummaryFields, RemoveSessionResult, } from "./summary-store.js";
14
+ export type * from "./metrics-types.js";
15
+ export interface MetricsRuntimeOptions extends Omit<MetricsEngineOptions, "enabled"> {
16
+ dirs?: Partial<MetricsDirs>;
17
+ enabled?: boolean;
18
+ /** 事件写盘(events/<sessionId>.log);仅记录归属命中本引擎 scope 的会话 */
19
+ eventLogging?: boolean;
20
+ /** 事件记录器额外选项(如外部传入的 isTracked 门控,一般由 runtime 自动注入无需配置) */
21
+ eventLoggerOptions?: Omit<EventLoggerOptions, "eventsDir" | "isTracked">;
22
+ /**
23
+ * 启动时增量回填本引擎归属的历史会话(读 opencode.db,水位存于 metricsDir/.backfill-state.json)。
24
+ * 仅当 scope 可静态判定(mode all,或配置了 agents/cwdPrefixes)时生效,用于补齐"插件未运行期间"的间隙会话。
25
+ */
26
+ backfillOnStart?: boolean;
27
+ }
28
+ /**
29
+ * 组装一整套"通用统计插件"的运行时:事件记录 + 指标引擎。
30
+ * 目录为实例级(不产生全局副作用):defaultDirs → 进程级默认(configureDirs)→ opts.dirs 三层合并。
31
+ * 同一进程可创建多个 runtime(不同插件各配各目录),scope 门控同时作用于引擎计数与事件写盘。
32
+ */
33
+ export declare function createMetricsRuntime(opts?: MetricsRuntimeOptions): {
34
+ dirs: MetricsDirs;
35
+ eventLogger: import("./event-logger.js").EventLogger | null;
36
+ engine: import("./metrics-types.js").MetricsEngine & {
37
+ claimSession: (sessionId: string, meta?: {
38
+ agent?: string;
39
+ }) => void;
40
+ scopeOf: (sessionId: string) => boolean;
41
+ shouldTrack: (sessionId: string, eventType: string, props: Record<string, unknown> | undefined) => boolean;
42
+ };
43
+ /** opencode event hook:`async ({ event }) => { runtime.event({ event }) }` */
44
+ event(input: {
45
+ event: {
46
+ type: string;
47
+ properties?: Record<string, unknown>;
48
+ };
49
+ }): void;
50
+ };
51
+ export type MetricsRuntime = ReturnType<typeof createMetricsRuntime>;