opencode-metrics-plugin 0.2.0 → 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 (44) 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/api.d.ts +3 -0
  5. package/dist/api.js +4 -0
  6. package/dist/index.d.ts +0 -3
  7. package/dist/index.js +2 -3
  8. package/dist/metrics/configLoader.d.ts +20 -0
  9. package/dist/metrics/configLoader.js +99 -0
  10. package/dist/metrics/dirs.d.ts +2 -0
  11. package/dist/metrics/dirs.js +1 -0
  12. package/dist/metrics/engine/engine.d.ts +8 -4
  13. package/dist/metrics/engine/engine.js +105 -27
  14. package/dist/metrics/engine/state.d.ts +17 -0
  15. package/dist/metrics/engine/state.js +2 -0
  16. package/dist/metrics/index.d.ts +3 -0
  17. package/dist/metrics/index.js +1 -0
  18. package/dist/metrics/runtime.d.ts +4 -0
  19. package/dist/metrics/runtime.js +10 -0
  20. package/dist/metrics/snapshot/flush.d.ts +17 -0
  21. package/dist/metrics/snapshot/flush.js +34 -4
  22. package/dist/metrics/snapshot/merge.d.ts +10 -2
  23. package/dist/metrics/snapshot/merge.js +47 -14
  24. package/dist/metrics/snapshot/steps.d.ts +15 -1
  25. package/dist/metrics/snapshot/steps.js +112 -48
  26. package/dist/metrics/upload/agcUploader.d.ts +65 -0
  27. package/dist/metrics/upload/agcUploader.js +244 -0
  28. package/dist/metrics/upload/packageArchiver.d.ts +34 -0
  29. package/dist/metrics/upload/packageArchiver.js +213 -0
  30. package/dist/metrics/upload/scenarios.d.ts +20 -0
  31. package/dist/metrics/upload/scenarios.js +43 -0
  32. package/dist/metrics/upload/types.d.ts +111 -0
  33. package/dist/metrics/upload/types.js +43 -0
  34. package/dist/metrics/upload/uploadManager.d.ts +25 -0
  35. package/dist/metrics/upload/uploadManager.js +252 -0
  36. package/dist/metrics/upload/uploadQueue.d.ts +34 -0
  37. package/dist/metrics/upload/uploadQueue.js +77 -0
  38. package/dist/metrics/upload/workspaceResolver.d.ts +75 -0
  39. package/dist/metrics/upload/workspaceResolver.js +275 -0
  40. package/dist/plugin.d.ts +8 -5
  41. package/dist/plugin.js +12 -7
  42. package/dist/shared/log.d.ts +0 -1
  43. package/dist/shared/log.js +0 -3
  44. package/package.json +8 -2
@@ -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';
@@ -1,10 +1,13 @@
1
1
  import type { MetricsEngineOptions } from './engine/engine.js';
2
2
  import type { MetricsDirs } from './dirs.js';
3
+ import type { UploadConfig } from './upload/types.js';
3
4
  export interface MetricsRuntimeOptions extends Omit<MetricsEngineOptions, 'enabled'> {
4
5
  dirs?: Partial<MetricsDirs>;
5
6
  enabled?: boolean;
6
7
  /** 事件写盘(events/<sessionId>.log;steps 全文的数据源) */
7
8
  eventLogging?: boolean;
9
+ /** 上传子系统配置(DESIGN.md §13.8);enabled:true 才会创建管理器 */
10
+ upload?: UploadConfig;
8
11
  }
9
12
  /**
10
13
  * 组装一整套"通用统计插件"的运行时:事件记录 + 指标引擎。
@@ -14,6 +17,7 @@ export declare function createMetricsRuntime(opts?: MetricsRuntimeOptions): {
14
17
  dirs: MetricsDirs;
15
18
  eventLogger: import("./eventlog/event-logger.js").EventLogger | null;
16
19
  engine: import("./types.js").MetricsEngine;
20
+ uploadManager: import("./upload/uploadManager.js").UploadManager | null;
17
21
  /** opencode event hook:`async ({ event }) => { runtime.event({ event }) }` */
18
22
  event(input: {
19
23
  event: {
@@ -1,6 +1,7 @@
1
1
  import { createMetricsEngine } from './engine/engine.js';
2
2
  import { createEventLogger } from './eventlog/event-logger.js';
3
3
  import { resolveDirs } from './dirs.js';
4
+ import { createUploadManager } from './upload/uploadManager.js';
4
5
  /**
5
6
  * 组装一整套"通用统计插件"的运行时:事件记录 + 指标引擎。
6
7
  * 目录为实例级(不产生全局副作用):defaultDirs → 进程级默认(configureDirs)→ opts.dirs 三层合并。
@@ -11,18 +12,27 @@ export function createMetricsRuntime(opts = {}) {
11
12
  enabled: opts.enabled,
12
13
  dirs,
13
14
  onFlush: opts.onFlush,
15
+ checkpointFlushIntervalMs: opts.checkpointFlushIntervalMs,
14
16
  });
15
17
  const eventLogger = (opts.eventLogging ?? true)
16
18
  ? createEventLogger(true, false, { eventsDir: dirs.eventsDir })
17
19
  : null;
20
+ // 上传管理器旁路挂在 runtime.event 上(在 engine.ingest 之后,保证触发时快照已落盘)
21
+ const uploadManager = createUploadManager({
22
+ config: opts.upload,
23
+ dirs,
24
+ onBeforeArchive: () => eventLogger?.flush(),
25
+ });
18
26
  return {
19
27
  dirs,
20
28
  eventLogger,
21
29
  engine,
30
+ uploadManager,
22
31
  /** opencode event hook:`async ({ event }) => { runtime.event({ event }) }` */
23
32
  event(input) {
24
33
  eventLogger?.log(input.event);
25
34
  engine.ingest(input.event);
35
+ uploadManager?.observe(input.event);
26
36
  },
27
37
  };
28
38
  }
@@ -1,6 +1,23 @@
1
1
  import type { MetricsDirs } from "../dirs.js";
2
2
  import type { SessionMetricsState } from "../engine/state.js";
3
3
  import type { MetricsOutput } from "../types.js";
4
+ /**
5
+ * 把会话当前状态落盘为快照文件(读旧文件 → 合并 → 原子覆盖写)。
6
+ * 可重复调用,不会造成数据重复累计。
7
+ *
8
+ * @param sessionId 会话 id,快照文件名为 `<sessionId>.json`
9
+ * @param state 会话状态,写盘成功后原地刷新差量基线
10
+ * @param now 本次快照的结束时间(ms)
11
+ * @param dirs 输出目录;缺省用进程级默认目录
12
+ * @returns 本次由内存态构建的快照(未与旧文件合并的那份)
13
+ */
4
14
  declare function flushMetrics(sessionId: string, state: SessionMetricsState, now: number, dirs?: Partial<MetricsDirs>): MetricsOutput;
15
+ /**
16
+ * 处理一轮会话结束:保存本轮 round 快照并落盘。
17
+ *
18
+ * @param state 会话状态(就地结算与重置;重复 idle 直接跳过)
19
+ * @param sessionId 会话 id
20
+ * @param dirs 输出目录;缺省用进程级默认目录
21
+ */
5
22
  declare function handleSessionIdle(state: SessionMetricsState, sessionId: string, dirs?: Partial<MetricsDirs>): MetricsOutput | undefined;
6
23
  export { flushMetrics, handleSessionIdle };
@@ -14,12 +14,25 @@ function filterByKeys(source, keys) {
14
14
  }
15
15
  return result;
16
16
  }
17
+ /**
18
+ * 把会话当前状态落盘为快照文件(读旧文件 → 合并 → 原子覆盖写)。
19
+ * 可重复调用,不会造成数据重复累计。
20
+ *
21
+ * @param sessionId 会话 id,快照文件名为 `<sessionId>.json`
22
+ * @param state 会话状态,写盘成功后原地刷新差量基线
23
+ * @param now 本次快照的结束时间(ms)
24
+ * @param dirs 输出目录;缺省用进程级默认目录
25
+ * @returns 本次由内存态构建的快照(未与旧文件合并的那份)
26
+ */
17
27
  function flushMetrics(sessionId, state, now, dirs) {
18
28
  // Finalize agent usage for current agent
19
29
  if (state.agent.current) {
20
30
  const elapsed = now - state.agent.lastSwitchTime;
21
- const prev = state.agent.usage.get(state.agent.current) ?? 0;
22
- state.agent.usage.set(state.agent.current, prev + elapsed);
31
+ const agent = state.agent.current;
32
+ const prev = state.agent.usage.get(agent) ?? 0;
33
+ state.agent.usage.set(agent, prev + elapsed);
34
+ // 重置计时起点,避免下次 flush 重复累计这段时长
35
+ state.agent.lastSwitchTime = now;
23
36
  }
24
37
  const endTime = now;
25
38
  const duration = endTime - state.startTime;
@@ -318,20 +331,36 @@ function flushMetrics(sessionId, state, now, dirs) {
318
331
  try {
319
332
  const existingRaw = fs.readFileSync(filePath, "utf-8");
320
333
  const existingOutput = JSON.parse(existingRaw);
321
- merged = { ...mergeMetricsOutput(existingOutput, output), source: "live" };
334
+ // 同进程多次 flush(checkpoint)按差量并入,跨进程 baseline 为空(全量并入)
335
+ merged = { ...mergeMetricsOutput(existingOutput, output, state._flushBaseline), source: "live" };
322
336
  }
323
337
  catch {
324
- // File doesn't exist or is corrupted 鈥?use fresh output
338
+ // File doesn't exist or is corrupted use fresh output
325
339
  }
326
340
  const tmpPath = filePath + ".tmp";
327
341
  fs.writeFileSync(tmpPath, JSON.stringify(merged, null, 2));
328
342
  fs.renameSync(tmpPath, filePath);
343
+ // 写盘成功后刷新差量基线
344
+ state._flushBaseline = {
345
+ errorCodeCounts: Object.fromEntries(output.codeStats.errorCodes.map(e => [e.code, e.count])),
346
+ warningCounts: { ...output.codeStats.warnings.byType },
347
+ warningEntriesTotal: output.codeStats.warnings.entries.length,
348
+ moduleTimings: Object.fromEntries(output.codeStats.moduleTimings.map(m => [m.module, { totalDuration: m.totalDurationMs, taskCount: m.taskCount }])),
349
+ fixCycleCount: output.codeStats.fixCycles.cycles.length,
350
+ };
329
351
  }
330
352
  catch (err) {
331
353
  log.info("Failed to flush metrics", { sessionId, error: String(err) });
332
354
  }
333
355
  return output;
334
356
  }
357
+ /**
358
+ * 处理一轮会话结束:保存本轮 round 快照并落盘。
359
+ *
360
+ * @param state 会话状态(就地结算与重置;重复 idle 直接跳过)
361
+ * @param sessionId 会话 id
362
+ * @param dirs 输出目录;缺省用进程级默认目录
363
+ */
335
364
  function handleSessionIdle(state, sessionId, dirs) {
336
365
  const now = Date.now();
337
366
  // Build round snapshot
@@ -384,6 +413,7 @@ function handleSessionIdle(state, sessionId, dirs) {
384
413
  // Flush metrics to disk
385
414
  state.endTime = now;
386
415
  state._idleFlushed = true;
416
+ state._checkpointDirty = false;
387
417
  return flushMetrics(sessionId, state, now, dirs);
388
418
  }
389
419
  export { flushMetrics, handleSessionIdle };
@@ -1,9 +1,17 @@
1
1
  import type { MetricsOutput } from "../types.js";
2
- import type { SessionMetricsState } from "../engine/state.js";
2
+ import type { FlushBaseline, SessionMetricsState } from "../engine/state.js";
3
3
  declare function mergeChildMetrics(parent: SessionMetricsState, child: SessionMetricsState, meta: {
4
4
  childId: string;
5
5
  agentName: string;
6
6
  title: string;
7
7
  }): void;
8
- export declare function mergeMetricsOutput(existing: MetricsOutput | null, fresh: MetricsOutput): MetricsOutput;
8
+ /**
9
+ * 合并磁盘旧快照与内存新快照:覆盖类字段取新值,累加类字段只补差量。
10
+ *
11
+ * @param existing 磁盘上的旧快照;null 时直接返回 fresh
12
+ * @param fresh 由当前内存态构建的新快照
13
+ * @param baseline 上次 flush 时累加字段的累计值,用于计算本次差量;null 时累加字段全量并入
14
+ * @returns 合并后的快照,可安全反复写盘
15
+ */
16
+ export declare function mergeMetricsOutput(existing: MetricsOutput | null, fresh: MetricsOutput, baseline?: FlushBaseline | null): MetricsOutput;
9
17
  export { mergeChildMetrics };
@@ -123,7 +123,15 @@ function mergeChildMetrics(parent, child, meta) {
123
123
  // Merge planning data
124
124
  parent.planningCalls.push(...child.planningCalls);
125
125
  }
126
- export function mergeMetricsOutput(existing, fresh) {
126
+ /**
127
+ * 合并磁盘旧快照与内存新快照:覆盖类字段取新值,累加类字段只补差量。
128
+ *
129
+ * @param existing 磁盘上的旧快照;null 时直接返回 fresh
130
+ * @param fresh 由当前内存态构建的新快照
131
+ * @param baseline 上次 flush 时累加字段的累计值,用于计算本次差量;null 时累加字段全量并入
132
+ * @returns 合并后的快照,可安全反复写盘
133
+ */
134
+ export function mergeMetricsOutput(existing, fresh, baseline) {
127
135
  if (!existing)
128
136
  return fresh;
129
137
  // startTime: min of both
@@ -191,53 +199,78 @@ export function mergeMetricsOutput(existing, fresh) {
191
199
  for (const s of fresh.skillSearches)
192
200
  searchMap.set(`${s.query}:${s.skillPath}`, s);
193
201
  const skillSearches = [...searchMap.values()];
194
- // codeStats.errorCodes: merge by code, accumulate count, keep existing type/message
202
+ // codeStats.errorCodes: merge by code with per-code increment(差量 = fresh - baseline)
195
203
  const errorCodeMap = new Map();
196
204
  for (const e of existing.codeStats.errorCodes)
197
205
  errorCodeMap.set(e.code, { ...e });
198
206
  for (const e of fresh.codeStats.errorCodes) {
207
+ const increment = Math.max(0, e.count - (baseline?.errorCodeCounts[e.code] ?? 0));
208
+ if (increment === 0)
209
+ continue;
199
210
  const prev = errorCodeMap.get(e.code);
200
211
  if (prev) {
201
- prev.count += e.count;
212
+ prev.count += increment;
202
213
  // keep existing type/message
203
214
  }
204
215
  else {
205
- errorCodeMap.set(e.code, { ...e });
216
+ errorCodeMap.set(e.code, { ...e, count: increment });
206
217
  }
207
218
  }
208
219
  const errorCodes = [...errorCodeMap.values()];
209
- // codeStats.warnings: merge by type, accumulate count, append entries
220
+ // codeStats.warnings: merge by type with increment; entries 只并入新增尾部(fresh 侧数组只追加)
210
221
  const warnByType = { ...existing.codeStats.warnings.byType };
211
222
  const warnEntries = [...existing.codeStats.warnings.entries];
212
223
  let warnTotal = existing.codeStats.warnings.total;
213
224
  for (const [type, count] of Object.entries(fresh.codeStats.warnings.byType)) {
214
- warnByType[type] = (warnByType[type] || 0) + count;
215
- warnTotal += count;
225
+ const increment = Math.max(0, count - (baseline?.warningCounts[type] ?? 0));
226
+ if (increment === 0)
227
+ continue;
228
+ warnByType[type] = (warnByType[type] || 0) + increment;
229
+ warnTotal += increment;
230
+ }
231
+ const warnEntriesBaseline = baseline?.warningEntriesTotal ?? 0;
232
+ if (fresh.codeStats.warnings.entries.length > warnEntriesBaseline) {
233
+ warnEntries.push(...fresh.codeStats.warnings.entries.slice(warnEntriesBaseline));
216
234
  }
217
- warnEntries.push(...fresh.codeStats.warnings.entries);
218
235
  const warnings = { total: warnTotal, byType: warnByType, entries: warnEntries };
219
- // codeStats.moduleTimings: merge by module, accumulate totalDuration/taskCount, max slowestTask
236
+ // codeStats.moduleTimings: merge by module with duration/taskCount increment, max slowestTask
220
237
  const moduleMap = new Map();
221
238
  for (const m of existing.codeStats.moduleTimings)
222
239
  moduleMap.set(m.module, { ...m, slowestTask: { ...m.slowestTask } });
223
240
  for (const m of fresh.codeStats.moduleTimings) {
241
+ const base = baseline?.moduleTimings[m.module];
242
+ const durationIncrement = Math.max(0, m.totalDurationMs - (base?.totalDuration ?? 0));
243
+ const taskIncrement = Math.max(0, m.taskCount - (base?.taskCount ?? 0));
244
+ if (durationIncrement === 0 && taskIncrement === 0)
245
+ continue;
224
246
  const prev = moduleMap.get(m.module);
225
247
  if (prev) {
226
- prev.totalDurationMs += m.totalDurationMs;
227
- prev.taskCount += m.taskCount;
248
+ prev.totalDurationMs += durationIncrement;
249
+ prev.taskCount += taskIncrement;
228
250
  if (m.slowestTask.duration > prev.slowestTask.duration) {
229
251
  prev.slowestTask = { ...m.slowestTask };
230
252
  }
231
253
  }
232
254
  else {
233
- moduleMap.set(m.module, { ...m, slowestTask: { ...m.slowestTask } });
255
+ moduleMap.set(m.module, {
256
+ module: m.module,
257
+ totalDurationMs: durationIncrement,
258
+ taskCount: taskIncrement,
259
+ slowestTask: { ...m.slowestTask },
260
+ });
234
261
  }
235
262
  }
236
263
  const moduleTimings = [...moduleMap.values()];
237
- // codeStats.fixCycles: append (skip dedup)
264
+ // codeStats.fixCycles: existing 全保留 + fresh 增量尾部(fresh 侧 cycles 只追加)
265
+ const fixCycleBaseline = baseline?.fixCycleCount ?? 0;
238
266
  const fixCycles = {
239
267
  ...fresh.codeStats.fixCycles,
240
- cycles: [...existing.codeStats.fixCycles.cycles, ...fresh.codeStats.fixCycles.cycles],
268
+ cycles: [
269
+ ...existing.codeStats.fixCycles.cycles,
270
+ ...(fresh.codeStats.fixCycles.cycles.length > fixCycleBaseline
271
+ ? fresh.codeStats.fixCycles.cycles.slice(fixCycleBaseline)
272
+ : []),
273
+ ],
241
274
  };
242
275
  // codeStats.firstBuildPerRound: fresh accumulates all rounds, use fresh
243
276
  const firstBuildPerRound = fresh.codeStats.firstBuildPerRound;
@@ -8,6 +8,13 @@ declare function buildSteps(maps: {
8
8
  reasoningMap: Map<string, string>;
9
9
  toolOutputMap: Map<string, string>;
10
10
  }): StepData[];
11
+ /**
12
+ * 从会话事件日志解析全文内容(text/reasoning/工具输出),增量读取只解析新增字节。
13
+ *
14
+ * @param sessionId 会话 id,日志文件名为 `<sessionId>.log`
15
+ * @param opts eventsDir 指定日志目录;filterMsgIds 只收集这些消息
16
+ * @returns textMap/reasoningMap(按 messageID)与 toolOutputMap(按 callID);日志不存在时为空表
17
+ */
11
18
  declare function extractStepContent(sessionId: string, opts?: {
12
19
  eventsDir?: string;
13
20
  filterMsgIds?: Set<string>;
@@ -16,4 +23,11 @@ declare function extractStepContent(sessionId: string, opts?: {
16
23
  reasoningMap: Map<string, string>;
17
24
  toolOutputMap: Map<string, string>;
18
25
  };
19
- export { buildSteps, extractStepContent };
26
+ /**
27
+ * 清除事件日志的增量解析缓存。
28
+ *
29
+ * @param sessionId 只清该会话的缓存;缺省清空全部
30
+ * @param eventsDir 日志目录,须与 extractStepContent 使用的路径一致
31
+ */
32
+ declare function clearStepContentCache(sessionId?: string, eventsDir?: string): void;
33
+ export { buildSteps, extractStepContent, clearStepContentCache };
@@ -30,61 +30,125 @@ function buildSteps(maps, contentMaps) {
30
30
  };
31
31
  });
32
32
  }
33
- function extractStepContent(sessionId, opts) {
34
- const textMap = new Map();
35
- const reasoningMap = new Map();
36
- const toolOutputMap = new Map();
37
- const filterMsgIds = opts?.filterMsgIds;
38
- try {
39
- const filePath = path.join(opts?.eventsDir ?? getEventsDir(), `${sessionId}.log`);
40
- const content = fs.readFileSync(filePath, "utf-8");
41
- // Split by [timestamp] prefix (zero-width lookahead)
42
- const blocks = content.split(/^(?=\[\d{4}-\d{2}-\d{2} )/m);
43
- for (const block of blocks) {
44
- const dataIdx = block.indexOf(" DATA: ");
45
- if (dataIdx < 0)
46
- continue;
47
- const jsonStr = block.slice(dataIdx + 8);
48
- if (!jsonStr.trim())
33
+ const stepContentCaches = new Map();
34
+ /**
35
+ * 解析事件块,把 text/reasoning 累积到对应消息、工具输出累积到对应调用。
36
+ *
37
+ * @param blocks 已切分的事件块字符串数组
38
+ * @param cache 累积结果的映射表(text/reasoning/工具输出)
39
+ * @param filterMsgIds 只收集这些消息 id;缺省收集全部
40
+ */
41
+ function parseEventBlocks(blocks, cache, filterMsgIds) {
42
+ for (const block of blocks) {
43
+ const dataIdx = block.indexOf(" DATA: ");
44
+ if (dataIdx < 0)
45
+ continue;
46
+ const jsonStr = block.slice(dataIdx + 8);
47
+ if (!jsonStr.trim())
48
+ continue;
49
+ try {
50
+ const event = JSON.parse(jsonStr);
51
+ const props = event;
52
+ const part = props.part;
53
+ if (!part)
49
54
  continue;
50
- try {
51
- const event = JSON.parse(jsonStr);
52
- const props = event;
53
- const part = props.part;
54
- if (!part)
55
+ const partType = part.type;
56
+ const msgId = part.messageID;
57
+ if (partType === "text" && msgId) {
58
+ if (filterMsgIds && !filterMsgIds.has(msgId))
55
59
  continue;
56
- const partType = part.type;
57
- const msgId = part.messageID;
58
- if (partType === "text" && msgId) {
59
- if (filterMsgIds && !filterMsgIds.has(msgId))
60
- continue;
61
- const text = part.text || "";
62
- textMap.set(msgId, (textMap.get(msgId) || "") + text);
63
- }
64
- if (partType === "reasoning" && msgId) {
65
- if (filterMsgIds && !filterMsgIds.has(msgId))
66
- continue;
67
- const text = part.text || "";
68
- reasoningMap.set(msgId, (reasoningMap.get(msgId) || "") + text);
69
- }
70
- if (partType === "tool") {
71
- const callID = part.callID;
72
- const toolState = part.state;
73
- const status = toolState?.status;
74
- if (callID && (status === "completed" || status === "error")) {
75
- const output = toolState?.output || toolState?.raw || "";
76
- toolOutputMap.set(callID, output);
77
- }
60
+ const text = part.text || "";
61
+ cache.textMap.set(msgId, (cache.textMap.get(msgId) || "") + text);
62
+ }
63
+ if (partType === "reasoning" && msgId) {
64
+ if (filterMsgIds && !filterMsgIds.has(msgId))
65
+ continue;
66
+ const text = part.text || "";
67
+ cache.reasoningMap.set(msgId, (cache.reasoningMap.get(msgId) || "") + text);
68
+ }
69
+ if (partType === "tool") {
70
+ const callID = part.callID;
71
+ const toolState = part.state;
72
+ const status = toolState?.status;
73
+ if (callID && (status === "completed" || status === "error")) {
74
+ const output = toolState?.output || toolState?.raw || "";
75
+ cache.toolOutputMap.set(callID, output);
78
76
  }
79
77
  }
80
- catch {
81
- // Skip unparseable blocks
78
+ }
79
+ catch {
80
+ // Skip unparseable blocks
81
+ }
82
+ }
83
+ }
84
+ /**
85
+ * 从会话事件日志解析全文内容(text/reasoning/工具输出),增量读取只解析新增字节。
86
+ *
87
+ * @param sessionId 会话 id,日志文件名为 `<sessionId>.log`
88
+ * @param opts eventsDir 指定日志目录;filterMsgIds 只收集这些消息
89
+ * @returns textMap/reasoningMap(按 messageID)与 toolOutputMap(按 callID);日志不存在时为空表
90
+ */
91
+ function extractStepContent(sessionId, opts) {
92
+ const filePath = path.join(opts?.eventsDir ?? getEventsDir(), `${sessionId}.log`);
93
+ let cache = stepContentCaches.get(filePath);
94
+ if (!cache) {
95
+ cache = {
96
+ offset: 0,
97
+ remainder: "",
98
+ textMap: new Map(),
99
+ reasoningMap: new Map(),
100
+ toolOutputMap: new Map(),
101
+ };
102
+ stepContentCaches.set(filePath, cache);
103
+ }
104
+ try {
105
+ const stat = fs.statSync(filePath);
106
+ if (stat.size < cache.offset) {
107
+ // 轮转/截断:重置缓存全量重解析
108
+ cache.offset = 0;
109
+ cache.remainder = "";
110
+ cache.textMap.clear();
111
+ cache.reasoningMap.clear();
112
+ cache.toolOutputMap.clear();
113
+ }
114
+ if (stat.size > cache.offset) {
115
+ const length = stat.size - cache.offset;
116
+ const buffer = Buffer.alloc(length);
117
+ const fd = fs.openSync(filePath, "r");
118
+ try {
119
+ fs.readSync(fd, buffer, 0, length, cache.offset);
120
+ }
121
+ finally {
122
+ fs.closeSync(fd);
123
+ }
124
+ const content = cache.remainder + buffer.toString("utf-8");
125
+ const blocks = content.split(/^(?=\[\d{4}-\d{2}-\d{2} )/m);
126
+ // 末块无结尾换行 = 写入未完成,留到下次(写入器每个完整块必以 \n 结尾)
127
+ let holdback = "";
128
+ if (!content.endsWith("\n")) {
129
+ holdback = blocks.length > 0 ? blocks.pop() : "";
82
130
  }
131
+ parseEventBlocks(blocks, cache, opts?.filterMsgIds);
132
+ cache.remainder = holdback;
133
+ cache.offset = stat.size;
83
134
  }
84
135
  }
85
136
  catch {
86
- // File doesn't exist or read error 鈥?return empty maps
137
+ // File doesn't exist or read error return whatever cached (usually empty maps)
138
+ }
139
+ return { textMap: cache.textMap, reasoningMap: cache.reasoningMap, toolOutputMap: cache.toolOutputMap };
140
+ }
141
+ /**
142
+ * 清除事件日志的增量解析缓存。
143
+ *
144
+ * @param sessionId 只清该会话的缓存;缺省清空全部
145
+ * @param eventsDir 日志目录,须与 extractStepContent 使用的路径一致
146
+ */
147
+ function clearStepContentCache(sessionId, eventsDir) {
148
+ if (sessionId === undefined) {
149
+ stepContentCaches.clear();
150
+ return;
87
151
  }
88
- return { textMap, reasoningMap, toolOutputMap };
152
+ stepContentCaches.delete(path.join(eventsDir ?? getEventsDir(), `${sessionId}.log`));
89
153
  }
90
- export { buildSteps, extractStepContent };
154
+ export { buildSteps, extractStepContent, clearStepContentCache };
@@ -0,0 +1,65 @@
1
+ import { type UploadPackage } from "./types.js";
2
+ /** 上传结果:失败时给出原因与是否可重试(4xx 不可重试,5xx/429/网络错误可重试) */
3
+ export type UploadOutcome = {
4
+ ok: true;
5
+ } | {
6
+ ok: false;
7
+ retryable: boolean;
8
+ reason: string;
9
+ };
10
+ /** AGC 凭证与域名(agc-apiclient.json 内容,域名已按 region 解析) */
11
+ export interface AgcCredentials {
12
+ client_id: string;
13
+ client_secret: string;
14
+ project_id: string;
15
+ region: string;
16
+ bucket_name: string;
17
+ api_domain: string;
18
+ storage_domain: string;
19
+ /** token 缓存目录;默认 ~/.agc_cache(与 upload_agc.py 共用缓存),测试可覆盖 */
20
+ tokenCacheDir?: string;
21
+ }
22
+ /** AGC 凭证默认文件:包内 assets/agc-apiclient.json(opencode.json 未配置 agcConfigPath 时使用) */
23
+ export declare function defaultAgcConfigPath(): string;
24
+ export type LoadCredentialsResult = {
25
+ ok: true;
26
+ credentials: AgcCredentials;
27
+ } | {
28
+ ok: false;
29
+ reason: string;
30
+ };
31
+ /**
32
+ * 读取并校验 agc-apiclient.json:必填字段齐全、域名按 region 映射
33
+ * (显式配置的 api_domain/storage_domain 优先)。失败返回原因,不抛异常。
34
+ *
35
+ * @param configPath agc-apiclient.json 绝对路径
36
+ * @param tokenCacheDir token 缓存目录覆盖(测试用)
37
+ * @returns 成功返回完整凭证;失败返回原因
38
+ */
39
+ export declare function loadAgcCredentials(configPath: string, tokenCacheDir?: string): LoadCredentialsResult;
40
+ export interface AgcUploader {
41
+ /**
42
+ * 上传一次触发的两个文件(归档包 + 快照)到
43
+ * YYYYMMDD/<projectName>-<sessionId>/<agentName>-<trigger>-v<n>.tar.gz|.json,
44
+ * 两者都成功才算成功;任一失败返回该失败(整任务由队列重试)。
45
+ *
46
+ * @param uploadPackage 归档产物(含双文件与路径元数据)
47
+ */
48
+ uploadPackage(uploadPackage: UploadPackage): Promise<UploadOutcome>;
49
+ }
50
+ /**
51
+ * 创建 AGC 上传器(token 自动获取与缓存 + 流式 PUT)。
52
+ *
53
+ * @param credentials 已解析的凭证
54
+ */
55
+ export declare function createAgcUploader(credentials: AgcCredentials): AgcUploader;
56
+ /**
57
+ * 缩写 agent 名用于云端路径段:按 "-" 分段取各段首字母(harmonyos-convert→hc),
58
+ * 单段名取前 3 字符(explore→exp),空名兜底 agent。
59
+ *
60
+ * @param agentName 触发时会话的 agent 名
61
+ * @returns 缩写后的安全路径段
62
+ */
63
+ export declare function abbreviateAgentName(agentName: string): string;
64
+ /** 归档包云端对象完整路径(上传成功日志用) */
65
+ export declare function remoteArchiveKey(uploadPackage: UploadPackage): string;