minecodex 2.0.25 → 2.0.27

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.
@@ -4,8 +4,11 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
 
6
6
  import { sessionFacts } from "./session-log.mjs";
7
+ import { OpenCodexUsageReader } from "../opencodex/usage-log.mjs";
7
8
  import { createSessionState, applySessionStatusEvent, sessionStatusSnapshot } from "../../domain/session-stats.mjs";
8
9
 
10
+ const defaultUsageReader = new OpenCodexUsageReader();
11
+
9
12
  const THREAD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
13
  export function threadIdFromLocation(value) {
11
14
  return String(value ?? "").match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0] ?? null;
@@ -35,11 +38,12 @@ async function findLogs(directory, threadId) {
35
38
  }
36
39
 
37
40
  export class SessionStatusReader {
38
- constructor({ sessionsDir = path.join(process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"), "sessions"), now = Date.now } = {}) {
41
+ constructor({ sessionsDir = path.join(process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"), "sessions"), now = Date.now, ocxUsagePath } = {}) {
39
42
  this.sessionsDir = sessionsDir;
40
43
  this.now = now;
41
44
  this.states = new Map();
42
45
  this.missingChecks = new Map();
46
+ this.ocxUsage = ocxUsagePath === undefined ? defaultUsageReader : new OpenCodexUsageReader(ocxUsagePath);
43
47
  }
44
48
 
45
49
  retryAfterMs(threadId) {
@@ -101,6 +105,7 @@ export class SessionStatusReader {
101
105
  events.sort((left, right) => (Date.parse(left.timestamp) || 0) - (Date.parse(right.timestamp) || 0));
102
106
  for (const event of events) for (const fact of sessionFacts(event)) applySessionStatusEvent(state, fact);
103
107
  this.states.set(threadId, state);
104
- return sessionStatusSnapshot(state, threadId);
108
+ const measurements = await this.ocxUsage.measurements(threadId, state.speedMeasurements);
109
+ return sessionStatusSnapshot(state, threadId, measurements);
105
110
  }
106
111
  }
@@ -0,0 +1,80 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { stat } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ // OCX usage.jsonl 的数值投影;不读取配置、密钥或对话正文。
8
+ function usageRecord(row) {
9
+ if (typeof row.requestId !== "string" || typeof row.conversationId !== "string"
10
+ || !Number.isFinite(row.timestamp) || !Number.isFinite(row.durationMs) || row.durationMs < 0) return null;
11
+ return { id:row.requestId, conversationId:row.conversationId, start:row.timestamp,
12
+ end:row.timestamp+row.durationMs, duration:row.durationMs,
13
+ usageStatus:row.usageStatus, input:row.usage?.inputTokens, cached:row.usage?.cachedInputTokens ?? 0,
14
+ output:row.usage?.outputTokens };
15
+ }
16
+
17
+ function requestMeasurement(record) {
18
+ const unavailable = reason => ({source:"ocx",output:record.output,elapsed:null,reason});
19
+ if (record.usageStatus === "unsupported") return unavailable("usage_unsupported");
20
+ if (!Number.isFinite(record.output) || record.output <= 0) return unavailable("output_missing");
21
+ if (!Number.isFinite(record.duration) || record.duration <= 0) return unavailable("invalid_duration");
22
+ return {source:"ocx",output:record.output,elapsed:record.duration,reason:null};
23
+ }
24
+
25
+ export class OpenCodexUsageReader {
26
+ constructor(filePath=path.join(process.env.OPENCODEX_HOME?.trim() || path.join(os.homedir(),".opencodex"),"usage.jsonl")) {
27
+ this.filePath=filePath;this.offset=0;this.pending="";this.identity=null;
28
+ this.conversations=new Map();this.refreshing=null;
29
+ }
30
+
31
+ refresh() {
32
+ if (!this.refreshing) this.refreshing=this.readAppend().finally(()=>{this.refreshing=null;});
33
+ return this.refreshing;
34
+ }
35
+
36
+ async readAppend() {
37
+ let file;
38
+ try { file=await stat(this.filePath); }
39
+ catch(error) {
40
+ if (!["ENOENT","EACCES","EPERM"].includes(error.code)) throw error;
41
+ this.offset=0;this.pending="";this.identity=null;this.conversations.clear();return;
42
+ }
43
+ const identity=`${file.dev}:${file.ino}`;
44
+ if (identity !== this.identity || file.size < this.offset) {
45
+ this.offset=0;this.pending="";this.conversations.clear();this.identity=identity;
46
+ }
47
+ if (file.size === this.offset) return;
48
+ const stream=createReadStream(this.filePath,{start:this.offset,end:file.size-1,encoding:"utf8"});
49
+ for await(const chunk of stream) {
50
+ const lines=(this.pending+chunk).split("\n");this.pending=lines.pop() ?? "";
51
+ for(const line of lines) {
52
+ let record;
53
+ try {record=usageRecord(JSON.parse(line));} catch {continue;}
54
+ if (!record) continue;
55
+ let records=this.conversations.get(record.conversationId);
56
+ if (!records) this.conversations.set(record.conversationId,records=new Map());
57
+ records.set(record.id,record);
58
+ }
59
+ }
60
+ this.offset=file.size;
61
+ }
62
+
63
+ async measurements(threadId,samples) {
64
+ await this.refresh();
65
+ const conversationId=createHash("sha256").update(threadId).digest("hex").slice(0,32);
66
+ const records=[...(this.conversations.get(conversationId)?.values() ?? [])];
67
+ const candidates=new Map(),owners=new Map();
68
+ for(const sample of samples) {
69
+ // 同会话、完整用量相等且完成相邻才可关联;同一请求不能归属两个响应。
70
+ const matches=records.filter(record=>Number.isFinite(record.input) && Number.isFinite(record.output)
71
+ && record.input === sample.input && record.cached === sample.cached && record.output === sample.output
72
+ && record.start <= sample.end && Math.abs(record.end-sample.end) <= 2000);
73
+ if (matches.length !== 1) continue;
74
+ const record=matches[0];candidates.set(sample.id,record);
75
+ owners.set(record.id,(owners.get(record.id) ?? 0)+1);
76
+ }
77
+ return new Map([...candidates].filter(([,record])=>owners.get(record.id) === 1)
78
+ .map(([id,record])=>[id,requestMeasurement(record)]));
79
+ }
80
+ }
@@ -90,8 +90,9 @@ export function applySessionStatusEvent(state, event) {
90
90
  if (event.id) {
91
91
  state.responses.add(event.id);
92
92
  const output=event.last?.output;
93
- if (Number.isFinite(output) && output > 0 && state.firstOutputAt != null && Number.isFinite(event.end) && event.end > state.firstOutputAt)
94
- state.speedMeasurements.push({ turnId:state.currentTurnId,start:state.firstOutputAt,end:event.end,output,
93
+ if (Number.isFinite(output) && output > 0 && Number.isFinite(event.end))
94
+ state.speedMeasurements.push({ id:event.id,turnId:state.currentTurnId,start:state.firstOutputAt,end:event.end,output,
95
+ input:event.last.input,cached:event.last.cached,
95
96
  outputs:state.pendingOutputIntervals.slice(),tools:state.pendingToolIntervals.slice() });
96
97
  }
97
98
  reset();
@@ -102,28 +103,17 @@ export function applySessionStatusEvent(state, event) {
102
103
  if (state.total || state.turns.size) state.updatedAt=event.at ?? state.updatedAt;
103
104
  }
104
105
 
105
- export function sessionStatusSnapshot(state, threadId) {
106
- let decodeTokens = 0;
107
- let decodeMs = 0;
108
- let speedSamples = 0;
109
- const turnSpeeds = new Map();
110
- for (const sample of state.speedMeasurements) {
111
- const elapsed = sample.end - sample.start - boundedToolWaitMs(sample.tools, sample.start, sample.end, sample.outputs);
112
- if (!sample.turnId || !Number.isFinite(elapsed) || elapsed <= 0) continue;
113
- const turn = turnSpeeds.get(sample.turnId) ?? { tokens:0, ms:0, samples:0 };
114
- turn.tokens += sample.output;
115
- turn.ms += elapsed;
116
- turn.samples += 1;
117
- turnSpeeds.set(sample.turnId, turn);
118
- }
119
- // 按回合顺序取最近五个有效回合;同轮多步合并,无数据的回合不占名额。
120
- const recentTurns = [...state.turns].filter(id => turnSpeeds.has(id)).slice(-5);
121
- for (const id of recentTurns) {
122
- const turn = turnSpeeds.get(id);
123
- decodeTokens += turn.tokens;
124
- decodeMs += turn.ms;
125
- speedSamples += turn.samples;
126
- }
106
+ export function sessionStatusSnapshot(state, threadId, measurements = new Map()) {
107
+ const sample = state.speedMeasurements.at(-1);
108
+ const measurement = measurements.get(sample?.id);
109
+ const source = measurement?.source ?? "codex";
110
+ const elapsed = measurement ? measurement.elapsed : sample?.start == null ? null
111
+ : sample.end - sample.start - boundedToolWaitMs(sample.tools, sample.start, sample.end, sample.outputs);
112
+ const valid = Boolean(sample?.turnId) && Number.isFinite(elapsed) && elapsed > 0
113
+ && (source === "ocx" || elapsed >= 1000);
114
+ const output = measurement?.output ?? sample?.output;
115
+ const unavailableReason = measurement?.reason ?? (Number.isFinite(elapsed) && elapsed > 0 && elapsed < 1000
116
+ ? "decode_window_too_short" : "timing_missing");
127
117
  const total = state.total;
128
118
  const input = total?.input ?? 0;
129
119
  const cached = total?.cached ?? 0;
@@ -142,9 +132,11 @@ export function sessionStatusSnapshot(state, threadId) {
142
132
  uncachedInputTokens: total ? Math.max(0, input - cached - cacheWrite) : null,
143
133
  outputTokens: total?.output ?? null,
144
134
  cacheHitPercent: input > 0 ? Math.round(cached / input * 100) : null,
145
- observedTps: decodeMs > 0 ? Math.round(decodeTokens / (decodeMs / 1000)) : null,
146
- speedSamples,
147
- observedModelMs: decodeMs,
135
+ observedTps: valid ? Math.round(output / (elapsed / 1000)) : null,
136
+ speedSamples: valid ? 1 : 0,
137
+ speedSource: source,
138
+ speedUnavailableReason: valid ? null : unavailableReason,
139
+ observedModelMs: valid ? elapsed : 0,
148
140
  toolMs: state.toolMs,
149
141
  averageFirstTokenMs: state.ttftSamples > 0 ? Math.round(state.ttftTotalMs / state.ttftSamples) : null,
150
142
  contextUsedTokens: contextUsed,
@@ -7,20 +7,35 @@ const copyByLocale = { "zh-CN": {
7
7
  firstToken: "平均首字延迟", input: "输入", cached: "缓存输入", output: "输出",
8
8
  sum: "合计", capacity: "窗口容量", contextShare: "上下文占用", compactions: "压缩次数",
9
9
  instructions: "指令", messages: "消息", toolHistory: "工具交互", other: "未分类",
10
- speedHelp: "关于 TPS", speedInfo: "GPT 等模型不公开完整思考过程及逐 Token 生成时间。TPS 取最近 5 个有有效数据的回合,不足 5 轮时按已有数据计算。它是基于可观测事件估算的体感响应速度,仅供参考,不代表模型真实的 Token 生成速度。",
10
+ speedHelp: "关于 TPS", speedInfo: "根据会话日志估算生成速度,包含推理输出。",
11
+ speedInfoOcx: "根据 OCX 记录计算响应速度,包含推理输出和请求等待时间。",
12
+ speedWindow: "取最近一次已完成响应的数据,仅供参考。",
13
+ speedWindowOcx: "取最近一次请求的记录,与 OCX 的 TPS 一致。",
14
+ speedUnavailable: "有效计时不足,暂无法估算。",
11
15
  }, en: {
12
16
  turns: "turns", steps: "steps", speed: "TPS", cache: "Cache", cacheDetail: "Cache hit",
13
17
  statistics: "Session stats", usage: "Token usage", context: "Context", total: "Total",
14
18
  firstToken: "Avg first token", input: "Input", cached: "Cached input", output: "Output",
15
19
  sum: "Sum", capacity: "Window size", contextShare: "Context used", compactions: "Compactions",
16
20
  instructions: "Instructions", messages: "Messages", toolHistory: "Tool activity", other: "Unclassified",
17
- speedHelp: "About TPS", speedInfo: "Models such as GPT do not expose their full reasoning process or per-token generation timing. TPS uses the latest 5 turns with valid timing data, or fewer if unavailable. It estimates perceived response speed from observable events, rather than the model’s actual token generation speed.",
21
+ speedHelp: "About TPS", speedInfo: "Generation speed estimated from session logs, including reasoning output.",
22
+ speedInfoOcx: "Response speed calculated from OCX records, including reasoning output and request waiting time.",
23
+ speedWindow: "Uses the latest completed response. For reference only.",
24
+ speedWindowOcx: "Uses the latest request record, matching OCX TPS.",
25
+ speedUnavailable: "Not enough valid timing data to estimate speed.",
18
26
  } };
19
27
  function selectedCopy() {
20
28
  const language = document.documentElement.lang || navigator.languages?.[0] || navigator.language || "en";
21
29
  return String(language).toLowerCase().startsWith("zh") ? copyByLocale["zh-CN"] : copyByLocale.en;
22
30
  }
23
31
 
32
+ function speedTooltip(value, copy) {
33
+ const source = value.speedSource === "ocx" ? copy.speedInfoOcx
34
+ : copy.speedInfo;
35
+ const window = value.speedSource === "ocx" ? copy.speedWindowOcx : copy.speedWindow;
36
+ return `${source} ${value.observedTps == null ? copy.speedUnavailable : window}`;
37
+ }
38
+
24
39
  // 图标来自 Lucide Static 与 Phosphor Icons;许可见 vendor/。
25
40
  const icons = {
26
41
  // Lucide info,使用官方 SVG 路径。
@@ -365,7 +380,7 @@ function showPanel(kind) {
365
380
  const speedLabel = row(dl, copy.speed, snapshot.observedTps == null ? "—" : `${number(snapshot.observedTps)} tok/s`);
366
381
  speedLabel.className = "tps-label";
367
382
  tpsInfo.setAttribute("aria-label", copy.speedHelp);
368
- tpsInfo.setAttribute("data-minecodex-tooltip", copy.speedInfo);
383
+ tpsInfo.setAttribute("data-minecodex-tooltip", speedTooltip(snapshot, copy));
369
384
  speedLabel.append(tpsInfo);
370
385
  } else if (kind === "usage") {
371
386
  row(dl, copy.total, tokenValue(snapshot.totalTokens));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecodex",
3
- "version": "2.0.25",
3
+ "version": "2.0.27",
4
4
  "description": "Lightweight, local-first plugins for the Codex desktop app.",
5
5
  "license": "MIT",
6
6
  "type": "module",