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.
- package/LICENSE +21 -0
- package/README.md +390 -0
- package/dist/backfill-cli.d.ts +1 -0
- package/dist/backfill-cli.js +74 -0
- package/dist/backfill.d.ts +58 -0
- package/dist/backfill.js +550 -0
- package/dist/compile-analyzer.d.ts +40 -0
- package/dist/compile-analyzer.js +161 -0
- package/dist/dirs.d.ts +34 -0
- package/dist/dirs.js +44 -0
- package/dist/event-logger.d.ts +20 -0
- package/dist/event-logger.js +329 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +61 -0
- package/dist/logger.d.ts +10 -0
- package/dist/logger.js +76 -0
- package/dist/metrics-engine.d.ts +37 -0
- package/dist/metrics-engine.js +331 -0
- package/dist/metrics-handlers.d.ts +9 -0
- package/dist/metrics-handlers.js +648 -0
- package/dist/metrics-output.d.ts +11 -0
- package/dist/metrics-output.js +673 -0
- package/dist/metrics-steps.d.ts +19 -0
- package/dist/metrics-steps.js +90 -0
- package/dist/metrics-types.d.ts +381 -0
- package/dist/metrics-types.js +102 -0
- package/dist/summary-store.d.ts +150 -0
- package/dist/summary-store.js +560 -0
- package/package.json +43 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createMetricsEngine } from "./metrics-engine.js";
|
|
2
|
+
import { createEventLogger } from "./event-logger.js";
|
|
3
|
+
import { resolveDirs } from "./dirs.js";
|
|
4
|
+
import { backfillFromOpencode } from "./backfill.js";
|
|
5
|
+
export { createMetricsEngine } from "./metrics-engine.js";
|
|
6
|
+
export { configureDirs, defaultDirs, getDirs, resolveDirs, getSummaryFile } from "./dirs.js";
|
|
7
|
+
export { createEventLogger } from "./event-logger.js";
|
|
8
|
+
export { backfillFromOpencode } from "./backfill.js";
|
|
9
|
+
export { querySummaries, countSummaries, getDetail, getSubagentSteps, getSessionRaw, setSessionTaskId, reindexDir, upsertSessionSnapshot, openSummaryDb, closeSummaryDbs, removeSession, extractSummaryFields, snapshotToSummary, detailFromRaw, detailFullFromRaw, sumTokens, subtractTokens, toIsoString, firstUserMessageOf, } from "./summary-store.js";
|
|
10
|
+
/**
|
|
11
|
+
* 组装一整套"通用统计插件"的运行时:事件记录 + 指标引擎。
|
|
12
|
+
* 目录为实例级(不产生全局副作用):defaultDirs → 进程级默认(configureDirs)→ opts.dirs 三层合并。
|
|
13
|
+
* 同一进程可创建多个 runtime(不同插件各配各目录),scope 门控同时作用于引擎计数与事件写盘。
|
|
14
|
+
*/
|
|
15
|
+
export function createMetricsRuntime(opts = {}) {
|
|
16
|
+
const dirs = resolveDirs(opts.dirs);
|
|
17
|
+
const engine = createMetricsEngine({
|
|
18
|
+
enabled: opts.enabled,
|
|
19
|
+
scope: opts.scope,
|
|
20
|
+
dirs,
|
|
21
|
+
onFlush: opts.onFlush,
|
|
22
|
+
});
|
|
23
|
+
const eventLogger = (opts.eventLogging ?? true)
|
|
24
|
+
? createEventLogger(true, false, {
|
|
25
|
+
eventsDir: dirs.eventsDir,
|
|
26
|
+
// 事件记录与引擎共用同一归属判定:引擎不统计的会话不写事件日志
|
|
27
|
+
isTracked: (sessionId, event) => engine.shouldTrack(sessionId, event.type, event.properties),
|
|
28
|
+
})
|
|
29
|
+
: null;
|
|
30
|
+
if (opts.backfillOnStart) {
|
|
31
|
+
const scope = opts.scope ?? { mode: "all" };
|
|
32
|
+
const staticallyAttributable = scope.mode === "all" || (scope.agents?.length ?? 0) > 0 || (scope.cwdPrefixes?.length ?? 0) > 0;
|
|
33
|
+
if (staticallyAttributable) {
|
|
34
|
+
setImmediate(() => {
|
|
35
|
+
backfillFromOpencode({
|
|
36
|
+
engines: [{
|
|
37
|
+
label: "self",
|
|
38
|
+
metricsDir: dirs.metricsDir,
|
|
39
|
+
agents: scope.agents,
|
|
40
|
+
cwdPrefixes: scope.cwdPrefixes,
|
|
41
|
+
summaryFile: dirs.summaryFile, // 与 live flush 同库(缺省全局共享库)
|
|
42
|
+
}],
|
|
43
|
+
skipExisting: true,
|
|
44
|
+
incremental: true,
|
|
45
|
+
}).catch((err) => {
|
|
46
|
+
console.error("[opencode-metrics-plugin] 启动增量回填失败:", err);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
dirs,
|
|
53
|
+
eventLogger,
|
|
54
|
+
engine,
|
|
55
|
+
/** opencode event hook:`async ({ event }) => { runtime.event({ event }) }` */
|
|
56
|
+
event(input) {
|
|
57
|
+
eventLogger?.log(input.event);
|
|
58
|
+
engine.ingest(input.event);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
package/dist/logger.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
2
|
+
export declare function setLogLevel(level: LogLevel): void;
|
|
3
|
+
export declare function getLogLevel(): LogLevel;
|
|
4
|
+
export declare function flushLogs(): void;
|
|
5
|
+
export declare const log: {
|
|
6
|
+
debug(...parts: unknown[]): void;
|
|
7
|
+
info(...parts: unknown[]): void;
|
|
8
|
+
warn(...parts: unknown[]): void;
|
|
9
|
+
error(...parts: unknown[]): void;
|
|
10
|
+
};
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { getLogFile } from './dirs.js';
|
|
4
|
+
const LEVEL_ORDER = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
5
|
+
let minOrder = LEVEL_ORDER.info;
|
|
6
|
+
export function setLogLevel(level) {
|
|
7
|
+
minOrder = LEVEL_ORDER[level];
|
|
8
|
+
}
|
|
9
|
+
export function getLogLevel() {
|
|
10
|
+
return Object.keys(LEVEL_ORDER).find((k) => LEVEL_ORDER[k] === minOrder) ?? 'info';
|
|
11
|
+
}
|
|
12
|
+
let buffer = [];
|
|
13
|
+
let flushTimer = null;
|
|
14
|
+
const FLUSH_INTERVAL_MS = 500;
|
|
15
|
+
const BUFFER_SIZE_LIMIT = 50;
|
|
16
|
+
function writeLine(line) {
|
|
17
|
+
const logFile = getLogFile();
|
|
18
|
+
try {
|
|
19
|
+
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
|
20
|
+
fs.appendFileSync(logFile, line + '\n');
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// 文件不可写时回退 console,避免静默吞日志
|
|
24
|
+
console.log(line);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function push(line) {
|
|
28
|
+
buffer.push(line);
|
|
29
|
+
if (buffer.length >= BUFFER_SIZE_LIMIT) {
|
|
30
|
+
const batch = buffer;
|
|
31
|
+
buffer = [];
|
|
32
|
+
batch.forEach(writeLine);
|
|
33
|
+
}
|
|
34
|
+
else if (!flushTimer) {
|
|
35
|
+
flushTimer = setTimeout(() => {
|
|
36
|
+
flushTimer = null;
|
|
37
|
+
const batch = buffer;
|
|
38
|
+
buffer = [];
|
|
39
|
+
batch.forEach(writeLine);
|
|
40
|
+
}, FLUSH_INTERVAL_MS);
|
|
41
|
+
flushTimer.unref?.();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function flushLogs() {
|
|
45
|
+
if (flushTimer) {
|
|
46
|
+
clearTimeout(flushTimer);
|
|
47
|
+
flushTimer = null;
|
|
48
|
+
}
|
|
49
|
+
const batch = buffer;
|
|
50
|
+
buffer = [];
|
|
51
|
+
batch.forEach(writeLine);
|
|
52
|
+
}
|
|
53
|
+
function fmt(level, parts) {
|
|
54
|
+
const ts = new Date().toISOString();
|
|
55
|
+
const msg = parts.map((p) => {
|
|
56
|
+
if (typeof p === 'string')
|
|
57
|
+
return p;
|
|
58
|
+
try {
|
|
59
|
+
return JSON.stringify(p);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return String(p);
|
|
63
|
+
}
|
|
64
|
+
}).join(' ');
|
|
65
|
+
return `${ts} [${level}] ${msg}`;
|
|
66
|
+
}
|
|
67
|
+
export const log = {
|
|
68
|
+
debug(...parts) { if (LEVEL_ORDER.debug >= minOrder)
|
|
69
|
+
push(fmt('debug', parts)); },
|
|
70
|
+
info(...parts) { if (LEVEL_ORDER.info >= minOrder)
|
|
71
|
+
push(fmt('info', parts)); },
|
|
72
|
+
warn(...parts) { if (LEVEL_ORDER.warn >= minOrder)
|
|
73
|
+
push(fmt('warn', parts)); },
|
|
74
|
+
error(...parts) { if (LEVEL_ORDER.error >= minOrder)
|
|
75
|
+
push(fmt('error', parts)); },
|
|
76
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { MetricsEngine, MetricsOutput } from "./metrics-types.js";
|
|
2
|
+
import type { MetricsDirs } from "./dirs.js";
|
|
3
|
+
export type { TokenUsage, ToolStats, StageType, StageInfo, MessageEntry, ToolCallEntry, SessionMeta, CompileStats, SkillCallEntry, SkillSearchEntry, StepData, RoundSnapshot, SubAgentOutput, MetricsOutput, MetricsEngine, } from "./metrics-types.js";
|
|
4
|
+
export type MetricsScopeMode = "all" | "none" | "declared" | "cwd";
|
|
5
|
+
export interface MetricsScope {
|
|
6
|
+
mode: MetricsScopeMode;
|
|
7
|
+
/** 交叉生效的 agent 白名单:非空时仅统计这些 agent 名的会话(session.created/updated 的 info.agent) */
|
|
8
|
+
agents?: string[];
|
|
9
|
+
/** mode==='cwd' 时生效:仅统计工作目录命中这些前缀的会话 */
|
|
10
|
+
cwdPrefixes?: string[];
|
|
11
|
+
}
|
|
12
|
+
export interface MetricsEngineOptions {
|
|
13
|
+
enabled?: boolean;
|
|
14
|
+
scope?: MetricsScope;
|
|
15
|
+
/** 本引擎专属输出目录(多引擎各自独立);缺省回退进程级默认(configureDirs),构造时解析固化 */
|
|
16
|
+
dirs?: Partial<MetricsDirs>;
|
|
17
|
+
/** flush 会话快照后回调(宿主可在此把快照写入 DB/上报,替代或补充文件落盘) */
|
|
18
|
+
onFlush?: (sessionId: string, snapshot: MetricsOutput) => void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 创建指标引擎。
|
|
22
|
+
* 兼容旧布尔入参:true = scope all,false = scope none。
|
|
23
|
+
* 统计范围 = 策略 + 宿主声明(claimSession)的交集判定:
|
|
24
|
+
* - mode all —— 全部(配合 agents 即只统计白名单 agent)
|
|
25
|
+
* - mode none —— 默认不统计;宿主 claimSession 才统计
|
|
26
|
+
* - mode declared—— 仅宿主 claimSession 的会话(或 agents 白名单命中)
|
|
27
|
+
* - mode cwd —— 仅工作目录命中 cwdPrefixes
|
|
28
|
+
* 子会话(parentID 归属已统计父会话)自动随父会话统计。
|
|
29
|
+
*/
|
|
30
|
+
export declare function createMetricsEngine(opts?: MetricsEngineOptions | boolean): MetricsEngine & {
|
|
31
|
+
claimSession: (sessionId: string, meta?: {
|
|
32
|
+
agent?: string;
|
|
33
|
+
}) => void;
|
|
34
|
+
scopeOf: (sessionId: string) => boolean;
|
|
35
|
+
/** 归属判定(供 event-logger 等共用同一 scope 语义;与 ingest 内部判定一致) */
|
|
36
|
+
shouldTrack: (sessionId: string, eventType: string, props: Record<string, unknown> | undefined) => boolean;
|
|
37
|
+
};
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { createSessionMetrics, TRACKED_EVENT_TYPES } from "./metrics-types.js";
|
|
2
|
+
import { handlePartUpdated, handleSessionUpdated, handleMessageUpdated, handleSubAgentParts } from "./metrics-handlers.js";
|
|
3
|
+
import { handleSessionIdle, flushMetrics, mergeChildMetrics } from "./metrics-output.js";
|
|
4
|
+
import { resolveDirs } from "./dirs.js";
|
|
5
|
+
import { log } from "./logger.js";
|
|
6
|
+
/**
|
|
7
|
+
* 创建指标引擎。
|
|
8
|
+
* 兼容旧布尔入参:true = scope all,false = scope none。
|
|
9
|
+
* 统计范围 = 策略 + 宿主声明(claimSession)的交集判定:
|
|
10
|
+
* - mode all —— 全部(配合 agents 即只统计白名单 agent)
|
|
11
|
+
* - mode none —— 默认不统计;宿主 claimSession 才统计
|
|
12
|
+
* - mode declared—— 仅宿主 claimSession 的会话(或 agents 白名单命中)
|
|
13
|
+
* - mode cwd —— 仅工作目录命中 cwdPrefixes
|
|
14
|
+
* 子会话(parentID 归属已统计父会话)自动随父会话统计。
|
|
15
|
+
*/
|
|
16
|
+
export function createMetricsEngine(opts = { enabled: true, scope: { mode: "all" } }) {
|
|
17
|
+
const options = typeof opts === "boolean"
|
|
18
|
+
? { enabled: opts, scope: { mode: opts ? "all" : "none" } }
|
|
19
|
+
: opts;
|
|
20
|
+
const enabled = options.enabled !== false;
|
|
21
|
+
const scope = options.scope ?? { mode: "all" };
|
|
22
|
+
// 实例目录:构造时按 defaultDirs → 进程级默认 → 实例传入 三层合并固化;未传则 flush 时动态回退进程级默认
|
|
23
|
+
const instanceDirs = options.dirs ? resolveDirs(options.dirs) : undefined;
|
|
24
|
+
const agents = scope.agents && scope.agents.length > 0 ? new Set(scope.agents) : null;
|
|
25
|
+
const cwdPrefixes = scope.cwdPrefixes ?? [];
|
|
26
|
+
const sessions = new Map();
|
|
27
|
+
const childToParent = new Map();
|
|
28
|
+
const childSessionIds = new Set();
|
|
29
|
+
const claimed = new Set();
|
|
30
|
+
// 已定案的会话统计与否(false 不缓存“信息未齐”的暂定,等 session.created 带 agent/dir 再定)
|
|
31
|
+
const resolved = new Map();
|
|
32
|
+
// 工作目录缓存(session.created 记录;cwd 策略与删除时反查用)
|
|
33
|
+
const sessionDirs = new Map();
|
|
34
|
+
const sessionAgents = new Map();
|
|
35
|
+
function decideFresh(id, agent, directory) {
|
|
36
|
+
if (claimed.has(id))
|
|
37
|
+
return true;
|
|
38
|
+
if (agents) {
|
|
39
|
+
if (!agent || !agents.has(agent))
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
switch (scope.mode) {
|
|
43
|
+
case "all": return true;
|
|
44
|
+
case "none": return false;
|
|
45
|
+
case "declared": return false;
|
|
46
|
+
case "cwd": return directory != null && cwdPrefixes.some((p) => directory.startsWith(p));
|
|
47
|
+
default: return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** 需等待 agent/directory 才可定案(事件带信息前先跳过,session.created 到达后定案)。 */
|
|
51
|
+
function needsInfo(id, agent, directory) {
|
|
52
|
+
if (agents && !agent)
|
|
53
|
+
return true;
|
|
54
|
+
if (scope.mode === "cwd" && directory == null)
|
|
55
|
+
return true;
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
function resolveSessionId(rawId) {
|
|
59
|
+
return childToParent.get(rawId) ?? rawId;
|
|
60
|
+
}
|
|
61
|
+
/** 判断事件目标会话是否纳入统计(子会话跟随父会话定案)。 */
|
|
62
|
+
function isTracked(id, eventType, props) {
|
|
63
|
+
let target = id;
|
|
64
|
+
if (id.startsWith("__migrate__:")) {
|
|
65
|
+
target = id.split(":")[2] ?? "";
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
target = childToParent.get(id) ?? id;
|
|
69
|
+
}
|
|
70
|
+
if (!target)
|
|
71
|
+
return false;
|
|
72
|
+
const info = (props?.info ?? {});
|
|
73
|
+
const agent = typeof info.agent === "string" ? info.agent : sessionAgents.get(target);
|
|
74
|
+
const directory = typeof info.directory === "string" ? info.directory : sessionDirs.get(target);
|
|
75
|
+
// 记录目录/agent(含迁移解析出的父子关系场景)
|
|
76
|
+
if (eventType === "session.created" || eventType === "session.updated") {
|
|
77
|
+
if (typeof info.directory === "string")
|
|
78
|
+
sessionDirs.set(target, info.directory);
|
|
79
|
+
if (typeof info.agent === "string")
|
|
80
|
+
sessionAgents.set(target, info.agent);
|
|
81
|
+
}
|
|
82
|
+
const cached = resolved.get(target);
|
|
83
|
+
if (cached !== undefined)
|
|
84
|
+
return cached;
|
|
85
|
+
if (needsInfo(target, agent, directory))
|
|
86
|
+
return false;
|
|
87
|
+
const decision = decideFresh(target, agent, directory);
|
|
88
|
+
resolved.set(target, decision);
|
|
89
|
+
return decision;
|
|
90
|
+
}
|
|
91
|
+
function extractSessionId(event) {
|
|
92
|
+
const props = event.properties;
|
|
93
|
+
if (!props) {
|
|
94
|
+
log.info("[Metrics] extractSessionId: no props", { type: event.type });
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
if (event.type === "session.created" || event.type === "session.updated") {
|
|
98
|
+
const info = props.info;
|
|
99
|
+
if (info) {
|
|
100
|
+
const id = info.id;
|
|
101
|
+
const parentID = info.parentID;
|
|
102
|
+
if (id && parentID) {
|
|
103
|
+
childToParent.set(id, parentID);
|
|
104
|
+
childSessionIds.add(id);
|
|
105
|
+
const agentName = info.agent || "";
|
|
106
|
+
const title = info.title || "";
|
|
107
|
+
log.info("[Metrics] MIGRATE detected", { type: event.type, id, parentID, agentName, title });
|
|
108
|
+
return `__migrate__:${id}:${parentID}:${agentName}:${title}`;
|
|
109
|
+
}
|
|
110
|
+
if (id)
|
|
111
|
+
return id;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const sessionID = props.sessionID;
|
|
115
|
+
if (sessionID)
|
|
116
|
+
return resolveSessionId(sessionID);
|
|
117
|
+
const info = props.info;
|
|
118
|
+
if (info?.id)
|
|
119
|
+
return resolveSessionId(info.id);
|
|
120
|
+
log.info("[Metrics] extractSessionId: no sessionID found", { type: event.type, hasSessionID: !!props.sessionID, hasInfo: !!info, hasInfoId: !!info?.id });
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
function getOrCreateSession(sessionId) {
|
|
124
|
+
let state = sessions.get(sessionId);
|
|
125
|
+
if (!state) {
|
|
126
|
+
state = createSessionMetrics(sessionId);
|
|
127
|
+
sessions.set(sessionId, state);
|
|
128
|
+
}
|
|
129
|
+
return state;
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
ingest(event) {
|
|
133
|
+
if (!enabled)
|
|
134
|
+
return;
|
|
135
|
+
if (!TRACKED_EVENT_TYPES.has(event.type))
|
|
136
|
+
return;
|
|
137
|
+
let sessionId = extractSessionId(event);
|
|
138
|
+
if (!sessionId) {
|
|
139
|
+
log.info("[Metrics] DROPPED event — no sessionId", { type: event.type });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const props = event.properties;
|
|
143
|
+
// 统计范围门:未纳入范围的会话整条丢弃(含其子代理)
|
|
144
|
+
if (!isTracked(sessionId, event.type, props))
|
|
145
|
+
return;
|
|
146
|
+
// Handle buffer migration when sub-agent relationship is discovered
|
|
147
|
+
if (sessionId.startsWith("__migrate__:")) {
|
|
148
|
+
const parts = sessionId.split(":");
|
|
149
|
+
const childId = parts[1];
|
|
150
|
+
const parentId = parts[2];
|
|
151
|
+
const agentName = parts[3] || "";
|
|
152
|
+
const title = parts[4] || "";
|
|
153
|
+
sessionId = parentId;
|
|
154
|
+
const parentState = getOrCreateSession(parentId);
|
|
155
|
+
// Merge child's existing metrics to parent
|
|
156
|
+
const childState = sessions.get(childId);
|
|
157
|
+
if (childState) {
|
|
158
|
+
log.info("[Metrics] Migration: merging existing child state", { childId, parentId });
|
|
159
|
+
mergeChildMetrics(parentState, childState, { childId, agentName, title });
|
|
160
|
+
sessions.delete(childId);
|
|
161
|
+
childToParent.delete(childId);
|
|
162
|
+
childSessionIds.delete(childId);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
if (!parentState.subAgents.has(childId)) {
|
|
166
|
+
parentState.subAgents.set(childId, {
|
|
167
|
+
sessionId: childId,
|
|
168
|
+
agentName,
|
|
169
|
+
title,
|
|
170
|
+
messageMap: new Map(),
|
|
171
|
+
toolCallMap: new Map(),
|
|
172
|
+
textLengthMap: new Map(),
|
|
173
|
+
orderCounter: 0,
|
|
174
|
+
_userMessageIds: new Set(),
|
|
175
|
+
_pendingUserMessage: [],
|
|
176
|
+
});
|
|
177
|
+
log.info("[Metrics] SubAgent entry created", { childId, subAgentsSize: parentState.subAgents.size });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const state = getOrCreateSession(sessionId);
|
|
182
|
+
// Sub-agent routing: check if the original event was from a child session
|
|
183
|
+
const originalSessionId = props?.sessionID || "";
|
|
184
|
+
if (childSessionIds.has(originalSessionId)) {
|
|
185
|
+
const sub = state.subAgents.get(originalSessionId);
|
|
186
|
+
if (sub) {
|
|
187
|
+
handleSubAgentParts(sub, event);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
log.info("[Metrics] Sub-agent routing MISS — entry not found", { eventType: event.type, originalSessionId, subAgentsKeys: [...state.subAgents.keys()] });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Extract directory from session.created
|
|
195
|
+
if (event.type === "session.created" && props) {
|
|
196
|
+
const info = props.info;
|
|
197
|
+
const directory = info?.directory;
|
|
198
|
+
if (directory) {
|
|
199
|
+
state.sessionMeta.workingDirectory = directory;
|
|
200
|
+
sessionDirs.set(sessionId, directory);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Track per-round first event time
|
|
204
|
+
if (!state._hasFirstEvent) {
|
|
205
|
+
state._hasFirstEvent = true;
|
|
206
|
+
state._firstEventTime = Date.now();
|
|
207
|
+
}
|
|
208
|
+
// Route to handler by event type
|
|
209
|
+
if (event.type === "message.part.updated" && props) {
|
|
210
|
+
handlePartUpdated(state, props);
|
|
211
|
+
}
|
|
212
|
+
if (event.type === "session.updated" && props) {
|
|
213
|
+
handleSessionUpdated(state, props);
|
|
214
|
+
}
|
|
215
|
+
if (event.type === "session.idle") {
|
|
216
|
+
const snapshot = handleSessionIdle(state, sessionId, instanceDirs);
|
|
217
|
+
if (options.onFlush && snapshot) {
|
|
218
|
+
try {
|
|
219
|
+
options.onFlush(sessionId, snapshot);
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
log.error("[Metrics] onFlush failed", { sessionId, error: String(err) });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (event.type === "session.compacted") {
|
|
227
|
+
state.compactions++;
|
|
228
|
+
}
|
|
229
|
+
if (event.type === "session.error") {
|
|
230
|
+
state._roundErrors++;
|
|
231
|
+
}
|
|
232
|
+
if (event.type === "message.updated" && props) {
|
|
233
|
+
handleMessageUpdated(state, props);
|
|
234
|
+
}
|
|
235
|
+
if (event.type === "permission.asked" ||
|
|
236
|
+
event.type === "permission.replied" ||
|
|
237
|
+
event.type === "command.executed") {
|
|
238
|
+
if (!state.anomaly.events.includes(event.type)) {
|
|
239
|
+
state.anomaly.triggered = true;
|
|
240
|
+
state.anomaly.events.push(event.type);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
ingestPrompt(sessionId, modelId, system) {
|
|
245
|
+
if (!enabled)
|
|
246
|
+
return;
|
|
247
|
+
const resolvedId = resolveSessionId(sessionId);
|
|
248
|
+
const allowed = (scope.mode === "all" && !agents) ||
|
|
249
|
+
claimed.has(resolvedId) ||
|
|
250
|
+
resolved.get(resolvedId) === true;
|
|
251
|
+
if (!allowed)
|
|
252
|
+
return;
|
|
253
|
+
const state = getOrCreateSession(resolvedId);
|
|
254
|
+
state.systemPrompts.set(modelId, system);
|
|
255
|
+
},
|
|
256
|
+
isSubAgent(sessionId) {
|
|
257
|
+
return childSessionIds.has(sessionId);
|
|
258
|
+
},
|
|
259
|
+
flush(sessionId) {
|
|
260
|
+
const state = sessions.get(sessionId);
|
|
261
|
+
if (!state)
|
|
262
|
+
return;
|
|
263
|
+
const now = Date.now();
|
|
264
|
+
state.endTime = now;
|
|
265
|
+
const snapshot = flushMetrics(sessionId, state, now, instanceDirs);
|
|
266
|
+
if (options.onFlush && snapshot) {
|
|
267
|
+
try {
|
|
268
|
+
options.onFlush(sessionId, snapshot);
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
log.error("[Metrics] onFlush failed", { sessionId, error: String(err) });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
dispose() {
|
|
276
|
+
const now = Date.now();
|
|
277
|
+
for (const [sessionId, state] of sessions) {
|
|
278
|
+
if (state._idleFlushed)
|
|
279
|
+
continue;
|
|
280
|
+
if (state.currentStage) {
|
|
281
|
+
state.currentStage.endTime = now;
|
|
282
|
+
state.currentStage.tokens = { ...state._stageTokens };
|
|
283
|
+
if (state.currentStage.stage === "build") {
|
|
284
|
+
state.currentStage.hvigorwCalls = state.compileStats.hvigorwCalls;
|
|
285
|
+
}
|
|
286
|
+
state.stages.push(state.currentStage);
|
|
287
|
+
state.currentStage = null;
|
|
288
|
+
}
|
|
289
|
+
state.lastStageEndTime = now;
|
|
290
|
+
if (state.compileStats._pendingFix) {
|
|
291
|
+
state.compileStats.fixCycles.push({
|
|
292
|
+
failTime: state.compileStats._pendingFix.failTime,
|
|
293
|
+
successTime: 0,
|
|
294
|
+
attempts: state.compileStats._pendingFix.attempts,
|
|
295
|
+
codeChanges: state.compileStats._pendingFix.codeChanges,
|
|
296
|
+
});
|
|
297
|
+
state.compileStats._pendingFix = null;
|
|
298
|
+
}
|
|
299
|
+
state.endTime = now;
|
|
300
|
+
const snapshot = flushMetrics(sessionId, state, now, instanceDirs);
|
|
301
|
+
if (options.onFlush && snapshot) {
|
|
302
|
+
try {
|
|
303
|
+
options.onFlush(sessionId, snapshot);
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
log.error("[Metrics] onFlush failed", { sessionId, error: String(err) });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
sessions.clear();
|
|
311
|
+
childToParent.clear();
|
|
312
|
+
childSessionIds.clear();
|
|
313
|
+
},
|
|
314
|
+
claimSession(sessionId, meta) {
|
|
315
|
+
claimed.add(sessionId);
|
|
316
|
+
if (meta?.agent)
|
|
317
|
+
sessionAgents.set(sessionId, meta.agent);
|
|
318
|
+
// 允许 claim 推翻早前“信息未齐暂不统计”的判定;宿主显式声明即强制纳入
|
|
319
|
+
resolved.set(sessionId, true);
|
|
320
|
+
},
|
|
321
|
+
scopeOf(sessionId) {
|
|
322
|
+
const v = resolved.get(sessionId);
|
|
323
|
+
if (v !== undefined)
|
|
324
|
+
return v;
|
|
325
|
+
return claimed.has(sessionId);
|
|
326
|
+
},
|
|
327
|
+
shouldTrack(sessionId, eventType, props) {
|
|
328
|
+
return isTracked(sessionId, eventType, props);
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SessionMetricsState, SubAgentState } from "./metrics-types.js";
|
|
2
|
+
declare function handlePartUpdated(state: SessionMetricsState, props: Record<string, unknown>): void;
|
|
3
|
+
declare function handleSessionUpdated(state: SessionMetricsState, props: Record<string, unknown>): void;
|
|
4
|
+
declare function handleMessageUpdated(state: SessionMetricsState, props: Record<string, unknown>): void;
|
|
5
|
+
declare function handleSubAgentParts(sub: SubAgentState, event: {
|
|
6
|
+
type: string;
|
|
7
|
+
properties?: Record<string, unknown>;
|
|
8
|
+
}): void;
|
|
9
|
+
export { handlePartUpdated, handleSessionUpdated, handleMessageUpdated, handleSubAgentParts };
|