opencode-metrics-plugin 0.3.5 → 0.4.2

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.
@@ -6,6 +6,12 @@ export interface MetricsRuntimeOptions extends Omit<MetricsEngineOptions, 'enabl
6
6
  enabled?: boolean;
7
7
  /** 事件写盘(events/<sessionId>.log;steps 全文的数据源) */
8
8
  eventLogging?: boolean;
9
+ /**
10
+ * 会话 agent 白名单(通配符 `*`,如 ["android-to-hmos-*", "build"]):
11
+ * 根会话的 agent 命中任一模式才记录(快照/事件日志/上传全不产生),子会话随父判定;
12
+ * 缺省/空数组 = 不过滤,记录全部会话(向后兼容)。
13
+ */
14
+ agents?: string[];
9
15
  /** 上传子系统配置(DESIGN.md §13.8);enabled:true 才会创建管理器 */
10
16
  upload?: UploadConfig;
11
17
  }
@@ -18,6 +24,11 @@ export declare function createMetricsRuntime(opts?: MetricsRuntimeOptions): {
18
24
  eventLogger: import("./eventlog/event-logger.js").EventLogger | null;
19
25
  engine: import("./types.js").MetricsEngine;
20
26
  uploadManager: import("./upload/uploadManager.js").UploadManager | null;
27
+ sessionFilter: {
28
+ active: boolean;
29
+ shouldTrackEvent: (event: import("./sessionFilter.js").SessionFilterEvent) => boolean;
30
+ shouldTrackSession: (sessionId: string) => boolean;
31
+ };
21
32
  /** opencode event hook:`async ({ event }) => { runtime.event({ event }) }` */
22
33
  event(input: {
23
34
  event: {
@@ -1,5 +1,6 @@
1
1
  import { createMetricsEngine } from './engine/engine.js';
2
2
  import { createEventLogger } from './eventlog/event-logger.js';
3
+ import { createSessionFilter } from './sessionFilter.js';
3
4
  import { resolveDirs } from './dirs.js';
4
5
  import { createUploadManager } from './upload/uploadManager.js';
5
6
  /**
@@ -17,6 +18,8 @@ export function createMetricsRuntime(opts = {}) {
17
18
  const eventLogger = (opts.eventLogging ?? true)
18
19
  ? createEventLogger(true, false, { eventsDir: dirs.eventsDir })
19
20
  : null;
21
+ // 会话 agent 过滤:event 入口统一拦截,事件日志/引擎/上传三个子系统一次挡住
22
+ const sessionFilter = createSessionFilter(opts.agents);
20
23
  // 上传管理器旁路挂在 runtime.event 上(在 engine.ingest 之后,保证触发时快照已落盘)
21
24
  const uploadManager = createUploadManager({
22
25
  config: opts.upload,
@@ -32,8 +35,11 @@ export function createMetricsRuntime(opts = {}) {
32
35
  eventLogger,
33
36
  engine,
34
37
  uploadManager,
38
+ sessionFilter,
35
39
  /** opencode event hook:`async ({ event }) => { runtime.event({ event }) }` */
36
40
  event(input) {
41
+ if (!sessionFilter.shouldTrackEvent(input.event))
42
+ return;
37
43
  eventLogger?.log(input.event);
38
44
  engine.ingest(input.event);
39
45
  uploadManager?.observe(input.event);
@@ -0,0 +1,12 @@
1
+ export interface SessionFilterEvent {
2
+ type: string;
3
+ properties?: Record<string, unknown>;
4
+ }
5
+ /** 通配符转正则:* → 任意串,其余字符按字面匹配;整串匹配,大小写敏感 */
6
+ export declare function compileAgentPatterns(patterns: readonly string[]): RegExp[];
7
+ export declare function createSessionFilter(agents?: readonly string[]): {
8
+ active: boolean;
9
+ shouldTrackEvent: (event: SessionFilterEvent) => boolean;
10
+ shouldTrackSession: (sessionId: string) => boolean;
11
+ };
12
+ export type SessionFilter = ReturnType<typeof createSessionFilter>;
@@ -0,0 +1,104 @@
1
+ // 会话 agent 过滤器:按根会话的 agent 通配匹配决定是否记录,子会话随父判定。
2
+ //
3
+ // 设计要点:
4
+ // - 判定信号:session.created / session.updated 的 properties.info.agent(实测会话首个事件即携带);
5
+ // message.updated 的 info.agent 作兜底(session 事件缺 agent 时)。agent 首次非空即终判,不再翻案。
6
+ // - fail-open:agent 始终未知的会话默认记录,防止 opencode 事件结构变化导致漏采。
7
+ // - 子会话(info.parentID)不独立判定,解析到根会话后跟随其判定;父未判定时同样放行。
8
+ // - 未配置 agents(缺省/空数组/全空串)时过滤器不激活,所有事件直通(向后兼容)。
9
+ /** 通配符转正则:* → 任意串,其余字符按字面匹配;整串匹配,大小写敏感 */
10
+ export function compileAgentPatterns(patterns) {
11
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
12
+ return patterns.map((p) => new RegExp(`^${p.split("*").map(escape).join(".*")}$`));
13
+ }
14
+ export function createSessionFilter(agents) {
15
+ const patterns = (agents ?? []).filter((p) => typeof p === "string" && p.length > 0);
16
+ const active = patterns.length > 0;
17
+ const regexes = active ? compileAgentPatterns(patterns) : [];
18
+ const decisions = new Map();
19
+ const childToParent = new Map();
20
+ function matchesAgent(agent) {
21
+ return regexes.some((re) => re.test(agent));
22
+ }
23
+ /** 沿 parentID 链向上找根会话(防环,最多 10 层) */
24
+ function resolveRoot(sessionId) {
25
+ let root = sessionId;
26
+ for (let i = 0; i < 10; i++) {
27
+ const parent = childToParent.get(root);
28
+ if (!parent)
29
+ break;
30
+ root = parent;
31
+ }
32
+ return root;
33
+ }
34
+ function decide(rootId, agent) {
35
+ const existing = decisions.get(rootId);
36
+ if (existing === "in" || existing === "out")
37
+ return;
38
+ decisions.set(rootId, matchesAgent(agent) ? "in" : "out");
39
+ }
40
+ /** 会话级判定:供 system.transform 等按 sessionId 查询的入口 */
41
+ function shouldTrackSession(sessionId) {
42
+ if (!active)
43
+ return true;
44
+ return decisions.get(resolveRoot(sessionId)) !== "out";
45
+ }
46
+ function extractSessionId(props) {
47
+ const sessionID = props.sessionID;
48
+ if (typeof sessionID === "string" && sessionID)
49
+ return sessionID;
50
+ const info = props.info;
51
+ const id = info?.id;
52
+ if (typeof id === "string" && id)
53
+ return id;
54
+ return undefined;
55
+ }
56
+ /**
57
+ * 事件级判定 + 状态机推进;返回 false 表示该事件所属会话不在记录范围。
58
+ * 无法归属会话的事件放行(引擎侧自会丢弃)。
59
+ */
60
+ function shouldTrackEvent(event) {
61
+ if (!active)
62
+ return true;
63
+ const props = event.properties;
64
+ if (!props)
65
+ return true;
66
+ if (event.type === "session.created" || event.type === "session.updated") {
67
+ const info = props.info;
68
+ const id = info?.id;
69
+ if (typeof id === "string" && id) {
70
+ const parentID = info.parentID;
71
+ if (typeof parentID === "string" && parentID) {
72
+ childToParent.set(id, parentID);
73
+ }
74
+ else {
75
+ const agent = info.agent;
76
+ if (typeof agent === "string" && agent)
77
+ decide(id, agent);
78
+ }
79
+ }
80
+ }
81
+ else if (event.type === "message.updated") {
82
+ // 兜底:session 事件一直没带 agent 时,用消息的 agent 定案(仅根会话)
83
+ const sessionID = props.sessionID;
84
+ const info = props.info;
85
+ const agent = info?.agent;
86
+ if (typeof sessionID === "string" && sessionID && typeof agent === "string" && agent && !childToParent.has(sessionID)) {
87
+ decide(resolveRoot(sessionID), agent);
88
+ }
89
+ }
90
+ else if (event.type === "session.deleted") {
91
+ // 清理:根会话删除即移除判定;子会话删除移除映射
92
+ const sid = extractSessionId(props);
93
+ if (sid) {
94
+ childToParent.delete(sid);
95
+ decisions.delete(sid);
96
+ }
97
+ }
98
+ const sid = extractSessionId(props);
99
+ if (!sid)
100
+ return true;
101
+ return shouldTrackSession(sid);
102
+ }
103
+ return { active, shouldTrackEvent, shouldTrackSession };
104
+ }
@@ -2,15 +2,12 @@ import type { MetricsDirs } from "../dirs.js";
2
2
  import type { SessionMetricsState } from "../engine/state.js";
3
3
  import type { RoundSnapshot, MetricsOutput } from "../types.js";
4
4
  /**
5
- * 把会话当前状态落盘为快照文件(读旧文件 → 合并 → 原子覆盖写)。
6
- * 可重复调用,不会造成数据重复累计。
5
+ * 刷新会话的工程代码行多维统计(30s 节流;候选根集合变化立即重扫)。
6
+ * 扫描口径见 analysis/codelines.ts;找不到鸿蒙工程根时全维度保持 0。
7
7
  *
8
- * @param sessionId 会话 id,快照文件名为 `<sessionId>.json`
9
- * @param state 会话状态,写盘成功后原地刷新差量基线
10
- * @param now 本次快照的结束时间(ms)
11
- * @param dirs 输出目录;缺省用进程级默认目录
12
- * @returns 本次由内存态构建的快照(未与旧文件合并的那份)
8
+ * @param state 会话状态(结果原地写入 state.codeLines)
13
9
  */
10
+ export declare function refreshCodeLineStats(state: SessionMetricsState): void;
14
11
  declare function flushMetrics(sessionId: string, state: SessionMetricsState, now: number, dirs?: Partial<MetricsDirs>): MetricsOutput;
15
12
  /**
16
13
  * 处理一轮会话结束:保存本轮 round 快照并落盘。
@@ -19,6 +16,15 @@ declare function flushMetrics(sessionId: string, state: SessionMetricsState, now
19
16
  * @param sessionId 会话 id
20
17
  * @param dirs 输出目录;缺省用进程级默认目录
21
18
  */
19
+ /**
20
+ * 轮闭合前的等待结算(0.4.1 活跃口径):把仍挂起的 question/permission/retry
21
+ * 截断入本轮累加器,并把挂起计时重锚到闭合时刻——等待若延续到下一轮,
22
+ * 只在新轮计尾段,跨轮拆分总量不丢、不双计。
23
+ *
24
+ * @param state 会话状态(就地结算累加器与挂起计时)
25
+ * @param now 轮闭合时刻(ms)
26
+ */
27
+ export declare function finalizeRoundWaits(state: SessionMetricsState, now: number): void;
22
28
  /**
23
29
  * 从当前轮累计态构建轮快照;空轮(无 token 且无工具调用)返回 null(天然防重)。
24
30
  *
@@ -26,7 +32,7 @@ declare function flushMetrics(sessionId: string, state: SessionMetricsState, now
26
32
  * @param now 结算时刻(ms)
27
33
  */
28
34
  export declare function buildRoundSnapshot(state: SessionMetricsState, now: number): RoundSnapshot | null;
29
- /** 重置轮累计态,开始新轮(不改动 stage 相关状态)。 */
35
+ /** 重置轮累计态,开始新轮(不改动 stage 相关状态;等待挂起计时由 finalizeRoundWaits 结算)。 */
30
36
  export declare function resetRoundState(state: SessionMetricsState, now: number): void;
31
37
  declare function handleSessionIdle(state: SessionMetricsState, sessionId: string, dirs?: Partial<MetricsDirs>): MetricsOutput | undefined;
32
38
  export { flushMetrics, handleSessionIdle };
@@ -5,6 +5,7 @@ import { createTokenUsage } from "../types.js";
5
5
  import { getMetricsDir } from "../dirs.js";
6
6
  import { buildSteps, extractStepContent } from "./steps.js";
7
7
  import { mergeMetricsOutput } from "./merge.js";
8
+ import { discoverHarmonyRoots, scanCodeLines } from "../analysis/codelines.js";
8
9
  function filterByKeys(source, keys) {
9
10
  const result = new Map();
10
11
  for (const key of keys) {
@@ -24,7 +25,40 @@ function filterByKeys(source, keys) {
24
25
  * @param dirs 输出目录;缺省用进程级默认目录
25
26
  * @returns 本次由内存态构建的快照(未与旧文件合并的那份)
26
27
  */
28
+ /** 代码行扫描节流间隔:活跃会话 checkpoint 频率远高于此,避免重复扫盘 */
29
+ const CODE_SCAN_THROTTLE_MS = 30_000;
30
+ /**
31
+ * 刷新会话的工程代码行多维统计(30s 节流;候选根集合变化立即重扫)。
32
+ * 扫描口径见 analysis/codelines.ts;找不到鸿蒙工程根时全维度保持 0。
33
+ *
34
+ * @param state 会话状态(结果原地写入 state.codeLines)
35
+ */
36
+ export function refreshCodeLineStats(state) {
37
+ try {
38
+ const candidates = [state.sessionMeta.workingDirectory, ...state.codeRootCandidates]
39
+ .filter((dir) => typeof dir === "string" && dir.length > 0);
40
+ const rootsKey = [...candidates]
41
+ .map((dir) => (process.platform === "win32" ? dir.toLowerCase() : dir))
42
+ .sort()
43
+ .join("|");
44
+ const now = Date.now();
45
+ if (state._lastCodeScanAt > 0 &&
46
+ now - state._lastCodeScanAt < CODE_SCAN_THROTTLE_MS &&
47
+ state._codeScanRootsKey === rootsKey) {
48
+ return;
49
+ }
50
+ const roots = discoverHarmonyRoots(candidates, state.startTime);
51
+ state.codeLines = scanCodeLines(roots);
52
+ state._lastCodeScanAt = now;
53
+ state._codeScanRootsKey = rootsKey;
54
+ }
55
+ catch {
56
+ // 扫描失败保持上次结果(或全 0),不影响快照其余字段
57
+ }
58
+ }
27
59
  function flushMetrics(sessionId, state, now, dirs) {
60
+ // 代码行多维统计(节流扫描,见 refreshCodeLineStats)
61
+ refreshCodeLineStats(state);
28
62
  // Finalize agent usage for current agent
29
63
  if (state.agent.current) {
30
64
  const elapsed = now - state.agent.lastSwitchTime;
@@ -36,6 +70,16 @@ function flushMetrics(sessionId, state, now, dirs) {
36
70
  }
37
71
  const endTime = now;
38
72
  const duration = endTime - state.startTime;
73
+ // AI 活跃统计(0.4.1 起):Σ 已闭合轮 activeDuration + 等待明细(进行中的开轮不计,闭合后下次 flush 补齐)
74
+ const aiActiveDuration = state.rounds.reduce((sum, r) => sum + (r.activeDuration ?? r.duration ?? 0), 0);
75
+ const roundsDuration = state.rounds.reduce((sum, r) => sum + (r.duration || 0), 0);
76
+ const waitBreakdown = {
77
+ questionMs: state.rounds.reduce((sum, r) => sum + (r.questionMs ?? 0), 0),
78
+ permissionMs: state.rounds.reduce((sum, r) => sum + (r.permissionMs ?? 0), 0),
79
+ retryMs: state.rounds.reduce((sum, r) => sum + (r.retryMs ?? 0), 0),
80
+ retryCount: state.rounds.reduce((sum, r) => sum + (r.retryCount ?? 0), 0),
81
+ otherIdleMs: Math.max(0, duration - roundsDuration),
82
+ };
39
83
  // Calculate cache hit rate
40
84
  const denom = state.tokens.input + state.tokens.cacheRead;
41
85
  const cacheHitRate = denom > 0 ? state.tokens.cacheRead / denom : 0;
@@ -223,6 +267,9 @@ function flushMetrics(sessionId, state, now, dirs) {
223
267
  startTime: state.startTime,
224
268
  endTime,
225
269
  duration,
270
+ aiActiveDuration,
271
+ aiActiveRatio: duration > 0 ? aiActiveDuration / duration : 0,
272
+ waitBreakdown,
226
273
  systemPrompts,
227
274
  rounds: state.rounds,
228
275
  tokens: (() => {
@@ -260,7 +307,11 @@ function flushMetrics(sessionId, state, now, dirs) {
260
307
  })),
261
308
  header,
262
309
  codeStats: {
263
- etsLines: state.compileStats.etsLines,
310
+ etsLines: state.codeLines.etsLines,
311
+ cLines: state.codeLines.cLines,
312
+ cppLines: state.codeLines.cppLines,
313
+ testLines: state.codeLines.testLines,
314
+ businessCodeLines: state.codeLines.businessCodeLines,
264
315
  buildSuccess: state.compileStats.lastBuildSuccess,
265
316
  fixCompileCount: state.compileStats.hvigorwCalls,
266
317
  totalCompileErrors: state.compileStats.hvigorwErrors,
@@ -394,6 +445,33 @@ function flushMetrics(sessionId, state, now, dirs) {
394
445
  * @param sessionId 会话 id
395
446
  * @param dirs 输出目录;缺省用进程级默认目录
396
447
  */
448
+ /**
449
+ * 轮闭合前的等待结算(0.4.1 活跃口径):把仍挂起的 question/permission/retry
450
+ * 截断入本轮累加器,并把挂起计时重锚到闭合时刻——等待若延续到下一轮,
451
+ * 只在新轮计尾段,跨轮拆分总量不丢、不双计。
452
+ *
453
+ * @param state 会话状态(就地结算累加器与挂起计时)
454
+ * @param now 轮闭合时刻(ms)
455
+ */
456
+ export function finalizeRoundWaits(state, now) {
457
+ // 未答的权限询问:截断入本轮并移除(后续 replied 到达时无条目即忽略,防跨轮双计)
458
+ for (const [id, pending] of state._pendingPermissionAsks) {
459
+ state._roundPermissionMs += Math.max(0, now - pending.askedAt);
460
+ state._pendingPermissionAsks.delete(id);
461
+ }
462
+ // 挂起中的限流重试:截断入本轮,起点重锚(计数已在 retry 事件时入账,此处只补时长)
463
+ if (state._retryStartedAt !== null) {
464
+ state._roundRetryMs += Math.max(0, now - state._retryStartedAt);
465
+ state._retryStartedAt = now;
466
+ }
467
+ // 仍在等待回答的 question 工具:截断入本轮,startMs 重锚(completed 事件只补尾段)
468
+ for (const tc of state.toolCallMap.values()) {
469
+ if (tc.tool === "question" && tc.status === "running" && tc.startMs > 0 && tc.startMs < now) {
470
+ state._roundQuestionMs += now - tc.startMs;
471
+ tc.startMs = now;
472
+ }
473
+ }
474
+ }
397
475
  /**
398
476
  * 从当前轮累计态构建轮快照;空轮(无 token 且无工具调用)返回 null(天然防重)。
399
477
  *
@@ -401,16 +479,34 @@ function flushMetrics(sessionId, state, now, dirs) {
401
479
  * @param now 结算时刻(ms)
402
480
  */
403
481
  export function buildRoundSnapshot(state, now) {
404
- const isEmptyRound = state._roundTokens.total === 0 && state._roundToolCalls === 0;
482
+ // 空轮判定:无 token、无工具调用且无任何等待(限流/审批/提问)才视为空轮丢弃。
483
+ // 仅含等待的轮(如纯限流挂起)仍占用墙钟,须保留以维持 activeDuration 扣减恒等式。
484
+ const isEmptyRound = state._roundTokens.total === 0
485
+ && state._roundToolCalls === 0
486
+ && state._roundQuestionMs === 0
487
+ && state._roundPermissionMs === 0
488
+ && state._roundRetryMs === 0
489
+ && state._roundRetryCount === 0;
405
490
  if (isEmptyRound)
406
491
  return null;
407
492
  const firstTokenLatency = state._hasTextPart && state._firstEventTime > 0
408
493
  ? state._firstTextTime - state._firstEventTime
409
494
  : 0;
495
+ const duration = now - state._roundStartTime;
496
+ // 活跃口径(0.4.1 起):轮墙钟减已知等待,各项 clamp 到 [0, duration],合计下限 0
497
+ const clampWait = (v) => Math.max(0, Math.min(v, duration));
498
+ const questionMs = clampWait(state._roundQuestionMs);
499
+ const permissionMs = clampWait(state._roundPermissionMs);
500
+ const retryMs = clampWait(state._roundRetryMs);
410
501
  return {
411
502
  startTime: state._roundStartTime,
412
503
  roundIndex: state.rounds.length,
413
- duration: now - state._roundStartTime,
504
+ duration,
505
+ activeDuration: Math.max(0, duration - questionMs - permissionMs - retryMs),
506
+ questionMs,
507
+ permissionMs,
508
+ retryMs,
509
+ retryCount: state._roundRetryCount,
414
510
  firstTokenLatency,
415
511
  tokens: { ...state._roundTokens },
416
512
  toolCalls: state._roundToolCalls,
@@ -418,7 +514,7 @@ export function buildRoundSnapshot(state, now) {
418
514
  userMessage: state._pendingUserMessage.length > 0 ? [...state._pendingUserMessage] : undefined,
419
515
  };
420
516
  }
421
- /** 重置轮累计态,开始新轮(不改动 stage 相关状态)。 */
517
+ /** 重置轮累计态,开始新轮(不改动 stage 相关状态;等待挂起计时由 finalizeRoundWaits 结算)。 */
422
518
  export function resetRoundState(state, now) {
423
519
  state._roundStartTime = now;
424
520
  state._firstEventTime = 0;
@@ -428,6 +524,10 @@ export function resetRoundState(state, now) {
428
524
  state._roundTokens = createTokenUsage();
429
525
  state._roundToolCalls = 0;
430
526
  state._roundErrors = 0;
527
+ state._roundQuestionMs = 0;
528
+ state._roundPermissionMs = 0;
529
+ state._roundRetryMs = 0;
530
+ state._roundRetryCount = 0;
431
531
  state._pendingUserMessage = [];
432
532
  state._roundFirstBuildTracked = false;
433
533
  }
@@ -437,6 +537,7 @@ function handleSessionIdle(state, sessionId, dirs) {
437
537
  if (state._roundIdleProcessed) {
438
538
  return;
439
539
  }
540
+ finalizeRoundWaits(state, now);
440
541
  const round = buildRoundSnapshot(state, now);
441
542
  if (round)
442
543
  state.rounds.push(round);
@@ -57,7 +57,6 @@ function mergeChildMetrics(parent, child, meta) {
57
57
  // Merge compileStats (additive)
58
58
  parent.compileStats.hvigorwCalls += child.compileStats.hvigorwCalls;
59
59
  parent.compileStats.hvigorwErrors += child.compileStats.hvigorwErrors;
60
- parent.compileStats.etsLines += child.compileStats.etsLines;
61
60
  // lastBuildSuccess: take child's value if child had any builds
62
61
  if (child.compileStats.hvigorwCalls > 0 || child.compileStats.hvigorwErrors > 0) {
63
62
  parent.compileStats.lastBuildSuccess = child.compileStats.lastBuildSuccess;
@@ -207,6 +206,18 @@ export function mergeMetricsOutput(existing, fresh, baseline) {
207
206
  ...fresh.rounds.filter(r => !existingRoundIndices.has(r.roundIndex)),
208
207
  ].sort((a, b) => a.roundIndex - b.roundIndex);
209
208
  })();
209
+ // AI 活跃统计(0.4.1 起):从合并后的轮数组重算——fresh 重启后可能只含新段,
210
+ // 不能直接展开 fresh 汇总;旧轮(0.4.0 快照)缺 activeDuration 回退按 duration 计(视为全活跃),
211
+ // 等待明细缺省按 0。与 planning 同思路:合并方从合并后明细重新推导。
212
+ const aiActiveDuration = rounds.reduce((sum, r) => sum + (r.activeDuration ?? r.duration ?? 0), 0);
213
+ const roundsDuration = rounds.reduce((sum, r) => sum + (r.duration || 0), 0);
214
+ const waitBreakdown = {
215
+ questionMs: rounds.reduce((sum, r) => sum + (r.questionMs ?? 0), 0),
216
+ permissionMs: rounds.reduce((sum, r) => sum + (r.permissionMs ?? 0), 0),
217
+ retryMs: rounds.reduce((sum, r) => sum + (r.retryMs ?? 0), 0),
218
+ retryCount: rounds.reduce((sum, r) => sum + (r.retryCount ?? 0), 0),
219
+ otherIdleMs: Math.max(0, duration - roundsDuration),
220
+ };
210
221
  // Steps:进程重启后 orderCounter 从 0 重编,fresh 的 index 与磁盘 existing 撞车,
211
222
  // 且时间上更晚的新步骤被排到列表头部;resume 重灌轮的时间戳全新,index 与时间戳都无法跨进程关联。
212
223
  // 统一策略:按 startTime:endTime 去重(防同进程重放,fresh wins)后,
@@ -494,18 +505,24 @@ export function mergeMetricsOutput(existing, fresh, baseline) {
494
505
  };
495
506
  })();
496
507
  // codeStats 顶层分两类处理:累计型(fixCompileCount/totalCompileErrors)按差量并入;
497
- // 快照型(etsLines/buildSuccess/firstBuildPassRate/firstBuildPerRound)在 fresh 无构建迹象
498
- // (全零/空)时保留 existing——重启进程没跑过 hvigorw,fresh 全零是"不知道"而非"清零"。
508
+ // 快照型(代码行五维/buildSuccess/firstBuildPassRate/firstBuildPerRound)在 fresh
509
+ // 有值(>0)时取 fresh——工程全量扫描的时效值,fresh 无值保留 existing
510
+ // (重启进程未扫到工程是"不知道"而非"清零")。
499
511
  const fixCompileCount = existing.codeStats.fixCompileCount
500
512
  + Math.max(0, fresh.codeStats.fixCompileCount - (baseline?.fixCompileCount ?? 0));
501
513
  const totalCompileErrors = existing.codeStats.totalCompileErrors
502
514
  + Math.max(0, fresh.codeStats.totalCompileErrors - (baseline?.totalCompileErrors ?? 0));
503
- const freshHasBuild = fresh.codeStats.etsLines > 0
515
+ const freshHasBuild = fresh.codeStats.businessCodeLines > 0
504
516
  || fresh.codeStats.fixCompileCount > (baseline?.fixCompileCount ?? 0)
505
517
  || totalCompileErrors > existing.codeStats.totalCompileErrors
506
518
  || fresh.codeStats.firstBuildPerRound.length > 0;
519
+ const pickLineCount = (freshValue, existingValue) => (freshValue ?? 0) > 0 ? freshValue : (existingValue ?? 0);
507
520
  const codeStats = {
508
- etsLines: fresh.codeStats.etsLines > 0 ? fresh.codeStats.etsLines : existing.codeStats.etsLines,
521
+ etsLines: pickLineCount(fresh.codeStats.etsLines, existing.codeStats.etsLines),
522
+ cLines: pickLineCount(fresh.codeStats.cLines, existing.codeStats.cLines),
523
+ cppLines: pickLineCount(fresh.codeStats.cppLines, existing.codeStats.cppLines),
524
+ testLines: pickLineCount(fresh.codeStats.testLines, existing.codeStats.testLines),
525
+ businessCodeLines: pickLineCount(fresh.codeStats.businessCodeLines, existing.codeStats.businessCodeLines),
509
526
  buildSuccess: freshHasBuild ? fresh.codeStats.buildSuccess : existing.codeStats.buildSuccess,
510
527
  fixCompileCount,
511
528
  totalCompileErrors,
@@ -526,6 +543,9 @@ export function mergeMetricsOutput(existing, fresh, baseline) {
526
543
  startTime,
527
544
  endTime: fresh.endTime,
528
545
  duration,
546
+ aiActiveDuration,
547
+ aiActiveRatio: duration > 0 ? aiActiveDuration / duration : 0,
548
+ waitBreakdown,
529
549
  systemPrompts,
530
550
  rounds,
531
551
  tokens,
@@ -85,7 +85,6 @@ export interface CompileStats {
85
85
  hvigorwCalls: number;
86
86
  hvigorwErrors: number;
87
87
  lastBuildSuccess: boolean;
88
- etsLines: number;
89
88
  firstBuildPerRound: boolean[];
90
89
  errorCodes: Map<string, {
91
90
  count: number;
@@ -163,12 +162,35 @@ export interface RoundSnapshot {
163
162
  startTime?: number;
164
163
  roundIndex: number;
165
164
  duration: number;
165
+ /** AI 活跃时长(0.4.1 起)= duration − questionMs − permissionMs − retryMs(下限 0);旧快照缺省,合并回退按 duration 计 */
166
+ activeDuration?: number;
167
+ /** question 工具等待用户回答累计(ms,0.4.1 起) */
168
+ questionMs?: number;
169
+ /** 权限审批等待累计(ms,0.4.1 起) */
170
+ permissionMs?: number;
171
+ /** 限流重试挂起累计(ms,0.4.1 起) */
172
+ retryMs?: number;
173
+ /** 限流重试次数(0.4.1 起) */
174
+ retryCount?: number;
166
175
  firstTokenLatency: number;
167
176
  tokens: TokenUsage;
168
177
  toolCalls: number;
169
178
  errors: number;
170
179
  userMessage?: string[];
171
180
  }
181
+ /** 会话级等待扣减明细(0.4.1 起):duration = aiActiveDuration + 各等待项之和 */
182
+ export interface WaitBreakdown {
183
+ /** question 工具等待用户回答累计(ms) */
184
+ questionMs: number;
185
+ /** 权限审批等待累计(ms) */
186
+ permissionMs: number;
187
+ /** 限流重试挂起累计(ms) */
188
+ retryMs: number;
189
+ /** 限流重试次数 */
190
+ retryCount: number;
191
+ /** 轮间空闲:墙钟未被任何已闭合轮覆盖的部分(ms) */
192
+ otherIdleMs: number;
193
+ }
172
194
  export interface SubAgentOutput {
173
195
  sessionId: string;
174
196
  agent: string;
@@ -201,6 +223,12 @@ export interface MetricsOutput {
201
223
  startTime: number;
202
224
  endTime: number;
203
225
  duration: number;
226
+ /** AI 活跃时长(0.4.1 起):Σ 轮 activeDuration(轮墙钟减去已知等待,step 间死时间计入活跃) */
227
+ aiActiveDuration?: number;
228
+ /** AI 活跃占比 = aiActiveDuration / duration(0-1,0.4.1 起) */
229
+ aiActiveRatio?: number;
230
+ /** 等待扣减明细(0.4.1 起) */
231
+ waitBreakdown?: WaitBreakdown;
204
232
  systemPrompts: Record<string, string[]>;
205
233
  rounds: RoundSnapshot[];
206
234
  tokens: TokenUsage & {
@@ -235,7 +263,16 @@ export interface MetricsOutput {
235
263
  }>;
236
264
  header: SessionMeta;
237
265
  codeStats: {
266
+ /** 排除测试的 .ets 行数(0.4.2 起为工程全量扫描口径,非 write 工具累计) */
238
267
  etsLines: number;
268
+ /** 排除测试的 C(.c/.h)行数 */
269
+ cLines: number;
270
+ /** 排除测试的 C++(.cpp/.cc/.cxx/.hpp 等)行数 */
271
+ cppLines: number;
272
+ /** 测试代码行数(ohosTest、test 等目录子树与 .test.、.spec. 命名文件,任意语言) */
273
+ testLines: number;
274
+ /** 实际业务代码总量 = ets + C + C++ + 测试(= 全量;构建/配置文件不计入) */
275
+ businessCodeLines: number;
239
276
  buildSuccess: boolean;
240
277
  fixCompileCount: number;
241
278
  totalCompileErrors: number;
@@ -6,7 +6,9 @@ const BUILTIN_SCENARIOS = {
6
6
  events: ["dispose", "session.deleted"],
7
7
  tools: ["task"],
8
8
  },
9
- "git-commit": { label: "git", commands: ["\\bgit\\s+commit\\b"] },
9
+ // gitcommit 之间允许 ≤4 个选项(如 -c user.name=x、-C <path>、--no-verify),
10
+ // 但选项必须以 - 开头——避免把 "git log --grep=commit" 一类误判为提交
11
+ "git-commit": { label: "git", commands: ["\\bgit\\s+(?:-{1,2}[\\w.-]+(?:[ =][^\\s]+)?\\s+){0,4}commit\\b"] },
10
12
  };
11
13
  /**
12
14
  * 合并内置场景与 opencode.json 配置:内置 key 深合并(label/commands/events/tools
@@ -1,5 +1,5 @@
1
1
  import type { MetricsDirs } from "../dirs.js";
2
- import type { UploadConfig } from "./types.js";
2
+ import type { UploadConfig, UploadPackage } from "./types.js";
3
3
  export interface UploadManagerOptions {
4
4
  config?: UploadConfig;
5
5
  /** 实例目录(metricsDir/eventsDir/uploadStagingDir) */
@@ -23,6 +23,21 @@ export interface UploadManager {
23
23
  * @param options 配置与目录
24
24
  */
25
25
  export declare function createUploadManager(options: UploadManagerOptions): UploadManager | null;
26
+ /**
27
+ * 启动清扫暂存目录顶层:崩溃遗留的完整三件套(tar.gz + json + meta.json)
28
+ * 移入 failed/ 同构目录并入队重传;孤立文件与组装目录直接删除。
29
+ *
30
+ * 两遍扫描:第一遍只处理 .tar.gz(三件套校验 → rescue 或删 tar.gz,并标记
31
+ * 三个文件名已处理)。字母序下 .json/.meta.json 先于 .tar.gz 迭代,若单遍
32
+ * 边迭代边删 .json,会在 tar.gz 校验前破坏三件套——完整崩溃遗留包必然被
33
+ * 误判"不完整"而销毁(曾连续销毁终态包)。第二遍才清理无 tar.gz 对应的
34
+ * 孤儿 .json/meta 与组装残留。
35
+ *
36
+ * @param stagingDir 暂存目录
37
+ * @param rescueEnabled 失败保留开关(false 时完整三件套也直接删除,旧行为)
38
+ * @returns 可补偿重传的包列表(顶层遗留部分)
39
+ */
40
+ export declare function sweepStagingDirectory(stagingDir: string, rescueEnabled: boolean): UploadPackage[];
26
41
  /**
27
42
  * 记录会话终态包已成功上云。
28
43
  * 快照原件(metrics/<sessionId>.json)会永久保留为本地记录,
@@ -41,7 +56,10 @@ export interface OrphanSnapshot {
41
56
  }
42
57
  /**
43
58
  * 扫描目录选出孤儿快照候选:mtime 静默 [idleMs, maxAgeMs] 且不在活跃集的快照。
44
- * 上传成功后快照即被删除,"文件仍在"天然等价于"终态未传出"。
59
+ * 快照原件永久保留为本地记录,"已传标记"是防重复上传的依据。
60
+ * 时效判断:标记只证明"标记时刻之前的快照已上云"——会话在中途终态触发
61
+ * (task 完成/session.deleted)上传后仍可能继续运行并更新快照,因此快照
62
+ * mtime 晚于标记时间(超出 60s 缓冲)仍视为孤儿,补传最终态。
45
63
  *
46
64
  * @param metricsDir 快照目录
47
65
  * @param idleMs 静默下限(毫秒)