opencode-metrics-plugin 0.2.1 → 0.3.0

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +80 -53
  3. package/assets/agc-apiclient.json +10 -0
  4. package/dist/metrics/configLoader.d.ts +20 -0
  5. package/dist/metrics/configLoader.js +99 -0
  6. package/dist/metrics/dirs.d.ts +2 -0
  7. package/dist/metrics/dirs.js +1 -0
  8. package/dist/metrics/engine/engine.d.ts +8 -4
  9. package/dist/metrics/engine/engine.js +105 -27
  10. package/dist/metrics/engine/state.d.ts +17 -0
  11. package/dist/metrics/engine/state.js +2 -0
  12. package/dist/metrics/index.d.ts +3 -0
  13. package/dist/metrics/index.js +1 -0
  14. package/dist/metrics/runtime.d.ts +4 -0
  15. package/dist/metrics/runtime.js +10 -0
  16. package/dist/metrics/snapshot/flush.d.ts +17 -0
  17. package/dist/metrics/snapshot/flush.js +34 -4
  18. package/dist/metrics/snapshot/merge.d.ts +10 -2
  19. package/dist/metrics/snapshot/merge.js +47 -14
  20. package/dist/metrics/snapshot/steps.d.ts +15 -1
  21. package/dist/metrics/snapshot/steps.js +112 -48
  22. package/dist/metrics/upload/agcUploader.d.ts +65 -0
  23. package/dist/metrics/upload/agcUploader.js +244 -0
  24. package/dist/metrics/upload/packageArchiver.d.ts +34 -0
  25. package/dist/metrics/upload/packageArchiver.js +213 -0
  26. package/dist/metrics/upload/scenarios.d.ts +20 -0
  27. package/dist/metrics/upload/scenarios.js +43 -0
  28. package/dist/metrics/upload/types.d.ts +111 -0
  29. package/dist/metrics/upload/types.js +43 -0
  30. package/dist/metrics/upload/uploadManager.d.ts +25 -0
  31. package/dist/metrics/upload/uploadManager.js +252 -0
  32. package/dist/metrics/upload/uploadQueue.d.ts +34 -0
  33. package/dist/metrics/upload/uploadQueue.js +77 -0
  34. package/dist/metrics/upload/workspaceResolver.d.ts +75 -0
  35. package/dist/metrics/upload/workspaceResolver.js +275 -0
  36. package/dist/plugin.d.ts +8 -5
  37. package/dist/plugin.js +12 -7
  38. package/dist/shared/log.d.ts +0 -1
  39. package/dist/shared/log.js +0 -3
  40. package/package.json +4 -2
package/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0(未发布)
4
+
5
+ - 新增会话产物上传:`git commit` / 会话结束自动打包(快照 + 事件日志 + 工作区产物)上传 AGC 云存储,安卓工程自动剔除,失败指数退避重试。
6
+ - 新增 checkpoint 实时落盘(默认 2s 冷却),会话中途退出不再丢指标。
7
+ - 新增独立配置文件 `.opencode/opencode-metrics.json(.jsonc)`(支持注释),存在即完全接管内联配置。
8
+
9
+ ## 0.2.0
10
+
11
+ - 重构为纯快照 JSON 输出:移除 sqlite/backfill 链路,src 按 engine / snapshot / eventlog / analysis 域重组。
12
+ - 插件接管 systemPrompts 记录;发布渠道迁移至 npm 公共源,包名定为 opencode-metrics-plugin。
13
+
14
+ ## 0.1.x
15
+
16
+ - 初始版本:opencode 会话事件采集、快照落盘与历史会话补录(GitHub Packages scoped 发布)。
package/README.md CHANGED
@@ -1,82 +1,111 @@
1
1
  # opencode-metrics-plugin
2
2
 
3
- **opencode 会话指标插件**:挂上即自动记录每个会话的完整指标(tokens / rounds / steps 全文 / 子代理 / 工具调用 / hvigor 构建统计),每轮对话结束落盘一个快照 JSON。
3
+ **opencode 会话指标插件**:挂上即自动记录每个会话的完整指标(tokens / rounds / steps 全文 / 子代理 / 工具调用 / hvigor 构建统计),并支持把会话快照与工作区产物自动上传到云存储。
4
4
 
5
5
  - **开箱即用**:宿主只需在 `opencode.json` 加一行,无需任何配置
6
- - **全量记录**:集成时刻起的所有会话(含子代理会话,自动并入父会话)
7
- - **增量 merge**:同会话多轮持续追加,进程重启后 rounds/steps 不丢
8
- - **dispose 兜底**:未走 `session.idle` 的会话在插件卸载时也会落盘
9
- - **可编程 API**:`createMetricsEngine` / `createMetricsRuntime` 可脱离插件契约单独使用
10
-
11
- ## 总览
12
-
13
- ```
14
- opencode 事件流
15
- │ plugin event hook
16
-
17
- ┌─ runtime(事件记录 + 指标引擎)────────────────────────────┐
18
- │ events\<sessionId>.log 原始事件流(steps 全文数据源) │
19
- │ │
20
- │ 每轮 session.idle / dispose 兜底 │
21
- │ ├─→ <metricsDir>\<sessionId>.json(快照,增量 merge) │
22
- │ └─→ onFlush(sessionId, snapshot)(宿主自定义消费) │
23
- └─────────────────────────────────────────────────────────────┘
24
-
25
-
26
- session-viewer 扫描 metricsDir 解析展示(完全解耦)
27
- ```
6
+ - **实时落盘**:会话进行中每 2s 增量写盘,中途退出不丢数据
7
+ - **产物上传**(可选):`git commit` 或会话结束时自动打包上传,安卓源码工程自动剔除
8
+ - **独立配置文件**:`.opencode/opencode-metrics.json(.jsonc)` 支持注释,存在即完全接管内联配置
9
+ - **零打扰**:不修改工作区、不执行 clean;上传默认关闭,不配置零开销
28
10
 
29
11
  ## 快速开始
30
12
 
13
+ ### 第 1 步:注册插件
14
+
31
15
  宿主项目 `opencode.json`:
32
16
 
33
17
  ```jsonc
34
- // 最简
35
18
  { "plugin": ["opencode-metrics-plugin"] }
19
+ ```
20
+
21
+ 仅此一步即完成指标采集,无需其他配置。
22
+
23
+ ### 第 2 步(可选):独立配置文件
36
24
 
37
- // 带配置
25
+ 在项目 `.opencode/` 目录下创建 `opencode-metrics.json` 或 `opencode-metrics.jsonc`(支持注释与尾逗号)。文件存在时**完全接管**内联第二参,推荐用这种方式配置:
26
+
27
+ ```jsonc
28
+ // .opencode/opencode-metrics.jsonc —— 最小上传配置
38
29
  {
39
- "plugin": [
40
- ["opencode-metrics-plugin", { "enabled": true, "eventLogging": true }]
41
- ]
30
+ "upload": {
31
+ "enabled": true
32
+ }
42
33
  }
43
34
  ```
44
35
 
45
- 配置项(全部可选):
36
+ 上传开启后默认使用包内 AGC 凭证,无需额外配置。
46
37
 
47
- | 选项 | 类型 | 默认 | 说明 |
48
- |---|---|---|---|
49
- | `enabled` | `boolean` | `true` | 总开关,`false` 时不记录任何会话 |
50
- | `eventLogging` | `boolean` | `true` | 原始事件写盘(`events/<sessionId>.log`);关闭后快照仍生成,但 `steps[].text/reasoning` 为空 |
51
- | `dirs` | `Partial<MetricsDirs>` | env-paths | 自定义 `metricsDir` / `eventsDir` / `logFile`(多实例同用时建议各传各的) |
52
- | `onFlush` | `(sessionId, snapshot) => void` | — | 快照落盘后回调(可在此上报自有系统) |
38
+ ### 3 步(可选):验证
53
39
 
54
- ## 输出
40
+ - 指标:会话进行几轮后,查看输出目录(见下)出现 `<sessionId>.json` 与 `events/<sessionId>.log`
41
+ - 上传:会话内执行一次 `git commit`,插件日志出现 `上传完成`,云存储桶出现 `YYYYMMDD/<项目名>-<sessionId>/` 目录
55
42
 
56
- 默认目录(appName `opencode-metrics-plugin`,env-paths 规范):
43
+ ## 采集内容
44
+
45
+ 每个会话一个 `<sessionId>.json` 快照(`MetricsOutput` 结构):
46
+
47
+ | 字段 | 内容 |
48
+ |---|---|
49
+ | `header` | sessionId / 工作目录 / agent / model 及切换分布 |
50
+ | `systemPrompts` | 各模型 system prompt(子代理跳过) |
51
+ | `rounds[]` | 每轮 duration、首 token 延迟、tokens、工具调用数、用户消息 |
52
+ | `steps[]` | 每步全文(text / reasoning)、tokens、cost、工具明细 |
53
+ | `subagents[]` | 子代理(task/explore 等)steps、tokens、工具统计,自动并入父会话 |
54
+ | `codeStats` | hvigorw 构建统计(错误码 / 警告 / 模块耗时 / 修复周期;非 HarmonyOS 会话为空) |
55
+ | `planning` | todowrite 规划统计 |
56
+
57
+ 配套可视化:[session-viewer](../session-viewer) 扫描 metricsDir 直接解析展示,无需服务端。
58
+
59
+ ## 产物上传
60
+
61
+ 开启 `upload.enabled` 后:
62
+
63
+ - **触发场景**:bash 执行 `git commit`(label=`git`)、会话结束/插件退出(label=`session-end`);可自定义场景(命令正则或事件名)
64
+ - **打包内容**:时点快照 JSON + 事件日志(恒含)+ 会话动过的工作区产物根(鸿蒙工程标记探测,≤3 个)
65
+ - **自动剔除**:黑名单目录(`oh_modules` / `build` / `node_modules` 等 8 项);安卓工程(根级 + 子树级,gradle 信号 ≥2 判定,可用 `excludeAndroidProjects: false` 关闭)
66
+ - **云端路径**:`YYYYMMDD/<安卓包名或项目名>-<sessionId>/<agent缩写>-<触发>-v<n>/<agent缩写>-<触发>-v<n>.tar.gz` 与同名 `.json`(一个版本一个目录,双文件归拢)
67
+ - **可靠性**:串行队列 + 指数退避重试(默认 5 次,4xx 不重试);上传成功/终态失败后自动清理本地暂存
68
+
69
+ ## 输出目录
70
+
71
+ 默认按 env-paths 规范落在用户目录(可用 `dirs` 自定义):
57
72
 
58
73
  | 平台 | 路径 |
59
74
  |---|---|
60
- | Windows | `%LOCALAPPDATA%\opencode-metrics-plugin\Log\metrics\`(events 同级) |
61
- | Linux | `~/.local/state/opencode-metrics-plugin/metrics/` |
62
- | macOS | `~/Library/Logs/opencode-metrics-plugin/metrics/` |
75
+ | Windows | `%LOCALAPPDATA%\opencode-metrics-plugin\Log\{metrics,events,plugin.log}`、`...\Data\upload-staging` |
76
+ | Linux | `~/.local/state/opencode-metrics-plugin/{metrics,events}` |
77
+ | macOS | `~/Library/Logs/opencode-metrics-plugin/{metrics,events}` |
78
+
79
+ ## 配置项速查
63
80
 
64
- 每个会话一个 `<sessionId>.json`(`MetricsOutput` 结构),核心字段:
81
+ 全部可选;独立文件与内联第二参字段相同。
65
82
 
66
- - `header`:sessionId / 工作目录 / agent / model 及切换分布
67
- - `systemPrompts`:各模型 system prompt(经 `experimental.chat.system.transform` hook 记录,子代理跳过)
68
- - `rounds[]`:每轮 duration、首 token 延迟、tokens、工具调用数、用户消息文本
69
- - `steps[]`:每步全文(text / reasoning 来自事件日志)、tokens、cost、工具明细
70
- - `subagents[]`:子代理(task/explore 等)steps、tokens、工具统计
71
- - `codeStats`:hvigorw 构建统计(错误码 / 警告 / 模块耗时 / 修复周期;非 HarmonyOS 会话为空)
72
- - `planning`:todowrite 规划统计
83
+ ### 顶层
73
84
 
74
- 配套可视化:[session-viewer](../session-viewer) 扫描 metricsDir 直接解析展示,无需任何服务端支持。
85
+ | 选项 | 类型 | 默认 | 说明 |
86
+ |---|---|---|---|
87
+ | `enabled` | boolean | `true` | 总开关,`false` 时不记录任何会话 |
88
+ | `eventLogging` | boolean | `true` | 原始事件写盘;关闭后快照仍生成,但 `steps[].text/reasoning` 为空 |
89
+ | `checkpointFlushIntervalMs` | number | `2000` | 实时落盘冷却窗口(ms),`0` 关闭退回只在会话空闲时落盘 |
90
+ | `dirs` | object | env-paths | `metricsDir` / `eventsDir` / `logFile` / `uploadStagingDir` |
91
+
92
+ ### upload 块
93
+
94
+ | 选项 | 类型 | 默认 | 说明 |
95
+ |---|---|---|---|
96
+ | `upload.enabled` | boolean | `false` | 上传总开关;不配置本块零开销 |
97
+ | `upload.uploader.agcConfigPath` | string | 包内凭证 | `agc-apiclient.json` 绝对路径(client_id/secret/project_id/region/bucket) |
98
+ | `upload.scenarios` | object | 内置两场景 | 按 key 合并:`enabled:false` 禁用内置;自定义场景提供 `commands`(正则)或 `events`(事件名)与 `label`(进文件名) |
99
+ | `upload.appendArtifacts` | array | — | 追加产物规则:`path`(绝对或相对会话工作目录)+ `matchBy`(`session-activity` 会话动过才纳入 / `existence` 存在即纳入)+ `dirMarkers`/`fileMarkers` |
100
+ | `upload.blacklist` | string[] | 默认 8 项 | 打包排除的目录/文件名(追加,不替换) |
101
+ | `upload.excludeAndroidProjects` | boolean | `true` | 剔除安卓工程(根级 + 根内子树) |
102
+ | `upload.maxArtifactBytes` | number | `524288000` | 单包体积上限(默认 500MB),超限该次标失败 |
103
+ | `upload.retry` | object | `{5, 1000}` | `maxAttempts` 最大尝试次数;`backoffMs` 退避基数(1s/4s/16s/64s/256s) |
75
104
 
76
105
  ## 可编程 API
77
106
 
78
107
  ```ts
79
- import { createMetricsRuntime, configureDirs } from 'opencode-metrics-plugin/api'
108
+ import { createMetricsRuntime } from 'opencode-metrics-plugin/api'
80
109
 
81
110
  const rt = createMetricsRuntime({
82
111
  dirs: { metricsDir: '/data/metrics', eventsDir: '/data/events' },
@@ -97,12 +126,10 @@ rt.engine.dispose()
97
126
 
98
127
  ```bash
99
128
  npm run typecheck # tsc --noEmit
100
- npm test # 冒烟测试(example/smoke.mts,27 断言)
129
+ npm test # 冒烟测试(example/smoke.mts,53 断言)
101
130
  npm run build # 清空 dist 后 tsc
102
131
  ```
103
132
 
104
- 结构:`src/plugin.ts`(插件入口)→ `src/metrics/`(域:engine / snapshot / eventlog / analysis / runtime)→ `src/shared/log.ts`。`@opencode-ai/plugin` 仅作类型依赖(peer),运行时零依赖(除 env-paths)。
105
-
106
133
  ## License
107
134
 
108
135
  MIT
@@ -0,0 +1,10 @@
1
+ {
2
+ "type": "project_client_id",
3
+ "developer_id": "2850086000535724929",
4
+ "project_id": "101653523864996148",
5
+ "client_id": "2036342510879556160",
6
+ "client_secret": "D6F8DA190967ABF81A2E17F0AE7C96A9DB7094BB9870230D7D701E5153338F04",
7
+ "configuration_version": "3.0",
8
+ "region": "CN",
9
+ "bucket_name": "d2h-oekjj"
10
+ }
@@ -0,0 +1,20 @@
1
+ import type { MetricsRuntimeOptions } from "./runtime.js";
2
+ /** 插件独立配置文件(项目 .opencode 目录下;.json 优先于 .jsonc) */
3
+ export declare const CONFIG_FILE_NAMES: readonly string[];
4
+ export interface ResolvedPluginConfig {
5
+ /** 生效配置(独立文件内容或内联第二参) */
6
+ config: MetricsRuntimeOptions;
7
+ /** 配置来源:file = 独立文件接管,inline = opencode.json 内联 */
8
+ source: "file" | "inline";
9
+ }
10
+ /**
11
+ * 解析插件生效配置:项目 .opencode 目录下存在 opencode-metrics.json / .jsonc
12
+ * (按此顺序查找,内容容忍 JSONC 注释与尾逗号)且为合法对象时完全接管
13
+ * (opencode.json 内联第二参全部忽略),否则回退内联。
14
+ * 文件缺失静默回退;内容非法记日志回退,不抛异常。
15
+ *
16
+ * @param projectDirectory opencode 启动的项目根(PluginInput.directory)
17
+ * @param inlineOptions opencode.json 插件内联第二参
18
+ * @returns 生效配置与来源
19
+ */
20
+ export declare function resolvePluginConfig(projectDirectory: string, inlineOptions?: MetricsRuntimeOptions): ResolvedPluginConfig;
@@ -0,0 +1,99 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { log } from "../shared/log.js";
4
+ /** 插件独立配置文件(项目 .opencode 目录下;.json 优先于 .jsonc) */
5
+ export const CONFIG_FILE_NAMES = [
6
+ ".opencode/opencode-metrics.json",
7
+ ".opencode/opencode-metrics.jsonc",
8
+ ];
9
+ /**
10
+ * 解析插件生效配置:项目 .opencode 目录下存在 opencode-metrics.json / .jsonc
11
+ * (按此顺序查找,内容容忍 JSONC 注释与尾逗号)且为合法对象时完全接管
12
+ * (opencode.json 内联第二参全部忽略),否则回退内联。
13
+ * 文件缺失静默回退;内容非法记日志回退,不抛异常。
14
+ *
15
+ * @param projectDirectory opencode 启动的项目根(PluginInput.directory)
16
+ * @param inlineOptions opencode.json 插件内联第二参
17
+ * @returns 生效配置与来源
18
+ */
19
+ export function resolvePluginConfig(projectDirectory, inlineOptions = {}) {
20
+ if (!projectDirectory)
21
+ return { config: inlineOptions, source: "inline" };
22
+ let configPath = "";
23
+ let raw;
24
+ for (const name of CONFIG_FILE_NAMES) {
25
+ const candidate = path.join(projectDirectory, name);
26
+ try {
27
+ raw = fs.readFileSync(candidate, "utf-8");
28
+ configPath = candidate;
29
+ break;
30
+ }
31
+ catch {
32
+ // 该名字的文件不存在,尝试下一个
33
+ }
34
+ }
35
+ if (raw === undefined)
36
+ return { config: inlineOptions, source: "inline" };
37
+ let parsed;
38
+ try {
39
+ parsed = JSON.parse(stripJsoncSyntax(raw));
40
+ }
41
+ catch (err) {
42
+ log.error("[Metrics] 配置文件内容非法,回退内联配置", { configPath, error: String(err) });
43
+ return { config: inlineOptions, source: "inline" };
44
+ }
45
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
46
+ log.error("[Metrics] 配置文件顶层必须是对象,回退内联配置", { configPath });
47
+ return { config: inlineOptions, source: "inline" };
48
+ }
49
+ log.info("[Metrics] 使用独立配置文件", { configPath });
50
+ return { config: parsed, source: "file" };
51
+ }
52
+ /**
53
+ * 去除 JSONC 语法(行注释 / 块注释 / 尾逗号),字符串内的 // 与 /* 原样保留。
54
+ *
55
+ * @param text 原始文件内容
56
+ * @returns 可交给 JSON.parse 的纯 JSON 文本
57
+ */
58
+ function stripJsoncSyntax(text) {
59
+ let result = "";
60
+ let inString = false;
61
+ let index = 0;
62
+ while (index < text.length) {
63
+ const char = text[index];
64
+ const next = text[index + 1];
65
+ if (inString) {
66
+ result += char;
67
+ if (char === "\\") {
68
+ result += next ?? "";
69
+ index += 2;
70
+ continue;
71
+ }
72
+ if (char === "\"")
73
+ inString = false;
74
+ index++;
75
+ continue;
76
+ }
77
+ if (char === "\"") {
78
+ inString = true;
79
+ result += char;
80
+ index++;
81
+ continue;
82
+ }
83
+ if (char === "/" && next === "/") {
84
+ while (index < text.length && text[index] !== "\n")
85
+ index++;
86
+ continue;
87
+ }
88
+ if (char === "/" && next === "*") {
89
+ index += 2;
90
+ while (index < text.length && !(text[index] === "*" && text[index + 1] === "/"))
91
+ index++;
92
+ index += 2;
93
+ continue;
94
+ }
95
+ result += char;
96
+ index++;
97
+ }
98
+ return result.replace(/,(\s*[}\]])/g, "$1");
99
+ }
@@ -6,6 +6,8 @@ export interface MetricsDirs {
6
6
  eventsDir: string;
7
7
  /** 运行日志文件(可选;logger 无文件权限时回退 console) */
8
8
  logFile: string;
9
+ /** 上传暂存目录(tar.gz 产物包输出位置;默认 <envPaths.data>/upload-staging) */
10
+ uploadStagingDir: string;
9
11
  }
10
12
  export declare function defaultDirs(appName?: string): MetricsDirs;
11
13
  /**
@@ -7,6 +7,7 @@ export function defaultDirs(appName = 'opencode-metrics-plugin') {
7
7
  metricsDir: path.join(base.log, 'metrics'),
8
8
  eventsDir: path.join(base.log, 'events'),
9
9
  logFile: path.join(base.log, 'plugin.log'),
10
+ uploadStagingDir: path.join(base.data, 'upload-staging'),
10
11
  };
11
12
  }
12
13
  let _dirs = defaultDirs();
@@ -1,15 +1,19 @@
1
1
  import type { MetricsEngine, MetricsOutput } from "../types.js";
2
2
  import type { MetricsDirs } from "../dirs.js";
3
+ /** 两次 checkpoint 落盘之间的默认最小间隔(ms) */
4
+ export declare const DEFAULT_CHECKPOINT_FLUSH_INTERVAL_MS = 2000;
3
5
  export type { TokenUsage, ToolStats, StageType, StageInfo, MessageEntry, ToolCallEntry, SessionMeta, CompileStats, SkillCallEntry, SkillSearchEntry, StepData, RoundSnapshot, SubAgentOutput, MetricsOutput, MetricsEngine, } from "../types.js";
4
6
  export interface MetricsEngineOptions {
5
7
  enabled?: boolean;
6
- /** 本引擎输出目录(多引擎各自独立);缺省回退进程级默认(configureDirs),构造时解析固化 */
8
+ /** 本引擎的输出目录;缺省用进程级默认目录 */
7
9
  dirs?: Partial<MetricsDirs>;
8
- /** flush 会话快照后回调(宿主可在此把快照上报自有系统) */
10
+ /** 每次快照落盘后的回调 */
9
11
  onFlush?: (sessionId: string, snapshot: MetricsOutput) => void;
12
+ /** 两次 checkpoint 落盘之间的最小间隔(ms);0 = 关闭 checkpoint,只在 idle/退出时落盘 */
13
+ checkpointFlushIntervalMs?: number;
10
14
  }
11
15
  /**
12
- * 创建指标引擎:订阅事件流,按会话累计状态,idle/flush/dispose 产出快照 JSON。
13
- * 0.2.0 起移除 scope 门控——记录全部会话;子会话(parentID)自动并入父会话。
16
+ * 创建指标引擎:订阅事件流,按会话累计指标,落盘快照 JSON。
17
+ * 子会话(parentID)自动并入父会话。
14
18
  */
15
19
  export declare function createMetricsEngine(opts?: MetricsEngineOptions): MetricsEngine;
@@ -2,19 +2,25 @@ import { TRACKED_EVENT_TYPES } from "../types.js";
2
2
  import { createSessionMetrics } from "./state.js";
3
3
  import { handlePartUpdated, handleSessionUpdated, handleMessageUpdated, handleSubAgentParts } from "./handlers.js";
4
4
  import { handleSessionIdle, flushMetrics } from "../snapshot/flush.js";
5
+ import { clearStepContentCache } from "../snapshot/steps.js";
5
6
  import { mergeChildMetrics } from "../snapshot/merge.js";
6
7
  import { resolveDirs } from "../dirs.js";
7
8
  import { log } from "../../shared/log.js";
9
+ /** 两次 checkpoint 落盘之间的默认最小间隔(ms) */
10
+ export const DEFAULT_CHECKPOINT_FLUSH_INTERVAL_MS = 2000;
8
11
  /**
9
- * 创建指标引擎:订阅事件流,按会话累计状态,idle/flush/dispose 产出快照 JSON。
10
- * 0.2.0 起移除 scope 门控——记录全部会话;子会话(parentID)自动并入父会话。
12
+ * 创建指标引擎:订阅事件流,按会话累计指标,落盘快照 JSON。
13
+ * 子会话(parentID)自动并入父会话。
11
14
  */
12
15
  export function createMetricsEngine(opts = {}) {
13
16
  const enabled = opts.enabled !== false;
14
17
  const instanceDirs = opts.dirs ? resolveDirs(opts.dirs) : undefined;
18
+ const checkpointIntervalMs = opts.checkpointFlushIntervalMs ?? DEFAULT_CHECKPOINT_FLUSH_INTERVAL_MS;
15
19
  const sessions = new Map();
16
20
  const childToParent = new Map();
17
21
  const childSessionIds = new Set();
22
+ // 上次 checkpoint 落盘时刻(引擎级,所有会话共享冷却窗口)
23
+ let lastCheckpointAt = 0;
18
24
  function resolveSessionId(rawId) {
19
25
  return childToParent.get(rawId) ?? rawId;
20
26
  }
@@ -50,14 +56,22 @@ export function createMetricsEngine(opts = {}) {
50
56
  log.info("[Metrics] extractSessionId: no sessionID found", { type: event.type });
51
57
  return undefined;
52
58
  }
59
+ /**
60
+ * 获取会话状态,不存在则创建。
61
+ *
62
+ * @param sessionId 会话 id
63
+ * @returns 会话状态,及是否为本次新建
64
+ */
53
65
  function getOrCreateSession(sessionId) {
54
66
  let state = sessions.get(sessionId);
55
67
  if (!state) {
56
68
  state = createSessionMetrics(sessionId);
57
69
  sessions.set(sessionId, state);
70
+ return { state, created: true };
58
71
  }
59
- return state;
72
+ return { state, created: false };
60
73
  }
74
+ /** 调用 onFlush 回调,回调抛错只记日志不中断 */
61
75
  function emitFlush(sessionId, snapshot) {
62
76
  if (snapshot && opts.onFlush) {
63
77
  try {
@@ -68,6 +82,56 @@ export function createMetricsEngine(opts = {}) {
68
82
  }
69
83
  }
70
84
  }
85
+ /**
86
+ * 结束会话:把进行中的 stage 和未完结的修复周期收尾入账,写出最终 JSON。
87
+ *
88
+ * @param sessionId 会话 id
89
+ * @param state 会话状态(就地结算)
90
+ * @param now 结束时刻(ms)
91
+ */
92
+ function finalizeAndFlush(sessionId, state, now) {
93
+ if (state.currentStage) {
94
+ state.currentStage.endTime = now;
95
+ state.currentStage.tokens = { ...state._stageTokens };
96
+ if (state.currentStage.stage === "build") {
97
+ state.currentStage.hvigorwCalls = state.compileStats.hvigorwCalls;
98
+ }
99
+ state.stages.push(state.currentStage);
100
+ state.currentStage = null;
101
+ }
102
+ state.lastStageEndTime = now;
103
+ if (state.compileStats._pendingFix) {
104
+ state.compileStats.fixCycles.push({
105
+ failTime: state.compileStats._pendingFix.failTime,
106
+ successTime: 0,
107
+ attempts: state.compileStats._pendingFix.attempts,
108
+ codeChanges: state.compileStats._pendingFix.codeChanges,
109
+ });
110
+ state.compileStats._pendingFix = null;
111
+ }
112
+ state.endTime = now;
113
+ state._checkpointDirty = false;
114
+ emitFlush(sessionId, flushMetrics(sessionId, state, now, instanceDirs));
115
+ }
116
+ /**
117
+ * checkpoint 判定:会话有新数据且冷却期已过时,把当前状态落盘。
118
+ *
119
+ * @param sessionId 会话 id
120
+ * @param state 会话状态(读取并清除 _checkpointDirty)
121
+ */
122
+ function evaluateCheckpoint(sessionId, state) {
123
+ if (checkpointIntervalMs <= 0)
124
+ return;
125
+ if (!state._checkpointDirty)
126
+ return;
127
+ const now = Date.now();
128
+ if (now - lastCheckpointAt < checkpointIntervalMs)
129
+ return;
130
+ lastCheckpointAt = now;
131
+ state._checkpointDirty = false;
132
+ log.info("[Metrics] checkpoint flush", { sessionId });
133
+ emitFlush(sessionId, flushMetrics(sessionId, state, now, instanceDirs));
134
+ }
71
135
  return {
72
136
  ingest(event) {
73
137
  if (!enabled)
@@ -88,7 +152,7 @@ export function createMetricsEngine(opts = {}) {
88
152
  const agentName = parts[3] || "";
89
153
  const title = parts[4] || "";
90
154
  sessionId = parentId;
91
- const parentState = getOrCreateSession(parentId);
155
+ const { state: parentState } = getOrCreateSession(parentId);
92
156
  const childState = sessions.get(childId);
93
157
  if (childState) {
94
158
  log.info("[Metrics] Migration: merging existing child state", { childId, parentId });
@@ -114,19 +178,37 @@ export function createMetricsEngine(opts = {}) {
114
178
  }
115
179
  }
116
180
  }
117
- const state = getOrCreateSession(sessionId);
181
+ const { state, created } = getOrCreateSession(sessionId);
118
182
  // 子代理事件路由:原始事件来自子会话时进入对应 subAgents 条目
119
183
  const originalSessionId = props?.sessionID || "";
120
184
  if (childSessionIds.has(originalSessionId)) {
121
185
  const sub = state.subAgents.get(originalSessionId);
122
186
  if (sub) {
123
187
  handleSubAgentParts(sub, event);
188
+ // 子代理的完整回复/单步结束:置脏父会话
189
+ if (event.type === "message.updated" ||
190
+ (event.type === "message.part.updated" &&
191
+ props?.part?.type === "step-finish")) {
192
+ state._checkpointDirty = true;
193
+ }
194
+ evaluateCheckpoint(sessionId, state);
124
195
  return;
125
196
  }
126
197
  else {
127
198
  log.info("[Metrics] Sub-agent routing MISS — entry not found", { eventType: event.type, originalSessionId, subAgentsKeys: [...state.subAgents.keys()] });
128
199
  }
129
200
  }
201
+ // 会话被删除:最终落盘后清掉内存态与解析缓存
202
+ if (event.type === "session.deleted") {
203
+ finalizeAndFlush(sessionId, state, Date.now());
204
+ sessions.delete(sessionId);
205
+ clearStepContentCache(sessionId, instanceDirs?.eventsDir);
206
+ return;
207
+ }
208
+ // 新会话的首个事件:置脏让快照文件尽早出现
209
+ if (created || event.type === "session.created") {
210
+ state._checkpointDirty = true;
211
+ }
130
212
  // 提取工作目录
131
213
  if (event.type === "session.created" && props) {
132
214
  const info = props.info;
@@ -143,7 +225,13 @@ export function createMetricsEngine(opts = {}) {
143
225
  handlePartUpdated(state, props);
144
226
  }
145
227
  if (event.type === "session.updated" && props) {
228
+ const attributionBefore = `${state.agent.current}|${state.model.current}|${state.sessionMeta.workingDirectory}`;
146
229
  handleSessionUpdated(state, props);
230
+ const attributionAfter = `${state.agent.current}|${state.model.current}|${state.sessionMeta.workingDirectory}`;
231
+ // agent/model/目录任一变化:置脏
232
+ if (attributionBefore !== attributionAfter) {
233
+ state._checkpointDirty = true;
234
+ }
147
235
  }
148
236
  if (event.type === "session.idle") {
149
237
  emitFlush(sessionId, handleSessionIdle(state, sessionId, instanceDirs));
@@ -156,6 +244,12 @@ export function createMetricsEngine(opts = {}) {
156
244
  }
157
245
  if (event.type === "message.updated" && props) {
158
246
  handleMessageUpdated(state, props);
247
+ const info = props.info;
248
+ const role = info?.role;
249
+ // 用户消息(轮开始)或 assistant 完整回复结束:置脏
250
+ if (role === "user" || (role === "assistant" && info?.finish)) {
251
+ state._checkpointDirty = true;
252
+ }
159
253
  }
160
254
  if (event.type === "permission.asked" ||
161
255
  event.type === "permission.replied" ||
@@ -165,11 +259,13 @@ export function createMetricsEngine(opts = {}) {
165
259
  state.anomaly.events.push(event.type);
166
260
  }
167
261
  }
262
+ // 统一的 checkpoint 判定点
263
+ evaluateCheckpoint(sessionId, state);
168
264
  },
169
265
  ingestPrompt(sessionId, modelId, system) {
170
266
  if (!enabled)
171
267
  return;
172
- const state = getOrCreateSession(resolveSessionId(sessionId));
268
+ const { state } = getOrCreateSession(resolveSessionId(sessionId));
173
269
  state.systemPrompts.set(modelId, system);
174
270
  },
175
271
  isSubAgent(sessionId) {
@@ -183,32 +279,14 @@ export function createMetricsEngine(opts = {}) {
183
279
  state.endTime = now;
184
280
  emitFlush(sessionId, flushMetrics(sessionId, state, now, instanceDirs));
185
281
  },
282
+ /** 结束引擎:把尚未落盘的会话逐一落盘,并清空全部内存 */
186
283
  dispose() {
187
284
  const now = Date.now();
188
285
  for (const [sessionId, state] of sessions) {
189
286
  if (state._idleFlushed)
190
287
  continue;
191
- if (state.currentStage) {
192
- state.currentStage.endTime = now;
193
- state.currentStage.tokens = { ...state._stageTokens };
194
- if (state.currentStage.stage === "build") {
195
- state.currentStage.hvigorwCalls = state.compileStats.hvigorwCalls;
196
- }
197
- state.stages.push(state.currentStage);
198
- state.currentStage = null;
199
- }
200
- state.lastStageEndTime = now;
201
- if (state.compileStats._pendingFix) {
202
- state.compileStats.fixCycles.push({
203
- failTime: state.compileStats._pendingFix.failTime,
204
- successTime: 0,
205
- attempts: state.compileStats._pendingFix.attempts,
206
- codeChanges: state.compileStats._pendingFix.codeChanges,
207
- });
208
- state.compileStats._pendingFix = null;
209
- }
210
- state.endTime = now;
211
- emitFlush(sessionId, flushMetrics(sessionId, state, now, instanceDirs));
288
+ finalizeAndFlush(sessionId, state, now);
289
+ clearStepContentCache(sessionId, instanceDirs?.eventsDir);
212
290
  }
213
291
  sessions.clear();
214
292
  childToParent.clear();
@@ -10,6 +10,19 @@ export interface SubAgentState {
10
10
  _userMessageIds: Set<string>;
11
11
  _pendingUserMessage: string[];
12
12
  }
13
+ /**
14
+ * 上次 flush 时各累加字段的累计值,用于本次 flush 计算差量、避免重复累计。
15
+ */
16
+ export interface FlushBaseline {
17
+ errorCodeCounts: Record<string, number>;
18
+ warningCounts: Record<string, number>;
19
+ warningEntriesTotal: number;
20
+ moduleTimings: Record<string, {
21
+ totalDuration: number;
22
+ taskCount: number;
23
+ }>;
24
+ fixCycleCount: number;
25
+ }
13
26
  export interface SessionMetricsState {
14
27
  sessionId: string;
15
28
  startTime: number;
@@ -58,6 +71,10 @@ export interface SessionMetricsState {
58
71
  _roundFirstBuildTracked: boolean;
59
72
  _roundIdleProcessed: boolean;
60
73
  _idleFlushed: boolean;
74
+ /** 有未落盘的新数据,待下次 checkpoint 判定时落盘 */
75
+ _checkpointDirty: boolean;
76
+ /** 上次 flush 的累加字段基线;null = 尚未 flush 过 */
77
+ _flushBaseline: FlushBaseline | null;
61
78
  messageMap: Map<string, MessageEntry>;
62
79
  toolCallMap: Map<string, ToolCallEntry>;
63
80
  textLengthMap: Map<string, number>;
@@ -78,6 +78,8 @@ export function createSessionMetrics(sessionId) {
78
78
  _roundFirstBuildTracked: false,
79
79
  _roundIdleProcessed: false,
80
80
  _idleFlushed: false,
81
+ _checkpointDirty: false,
82
+ _flushBaseline: null,
81
83
  planningCalls: [],
82
84
  systemPrompts: new Map(),
83
85
  };
@@ -6,4 +6,7 @@ export { createEventLogger } from './eventlog/event-logger.js';
6
6
  export type { EventLogger, EventLoggerOptions } from './eventlog/event-logger.js';
7
7
  export { createMetricsRuntime } from './runtime.js';
8
8
  export type { MetricsRuntime, MetricsRuntimeOptions } from './runtime.js';
9
+ export { createUploadManager } from './upload/uploadManager.js';
10
+ export type { UploadManager, UploadManagerOptions } from './upload/uploadManager.js';
11
+ export type { UploadConfig, ScenarioConfig, ArtifactRule, UploadPackage } from './upload/types.js';
9
12
  export * from './types.js';
@@ -3,4 +3,5 @@ export { createMetricsEngine } from './engine/engine.js';
3
3
  export { configureDirs, defaultDirs, getDirs, resolveDirs, getMetricsDir, getEventsDir, getLogFile } from './dirs.js';
4
4
  export { createEventLogger } from './eventlog/event-logger.js';
5
5
  export { createMetricsRuntime } from './runtime.js';
6
+ export { createUploadManager } from './upload/uploadManager.js';
6
7
  export * from './types.js';