From 916f28b4663d60862578ef5a04384d872f4f9b41 Mon Sep 17 00:00:00 2001 From: Julian <1041324235@qq.com> Date: Mon, 13 Jul 2026 17:27:53 +0800 Subject: [PATCH 1/6] docs: design Grok parser fix for issue 43 --- .../specs/2026-07-13-grok-parser-design.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-13-grok-parser-design.md diff --git a/docs/superpowers/specs/2026-07-13-grok-parser-design.md b/docs/superpowers/specs/2026-07-13-grok-parser-design.md new file mode 100644 index 0000000..80bf08e --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-grok-parser-design.md @@ -0,0 +1,49 @@ +# Grok Build 日志解析修复设计 + +## 背景与目标 + +AIUsage 已能发现 Grok Build 的默认会话目录,但 `Aggregator` 没有注册 Grok 解析器,导致发现到的日志全部返回空结果。Grok Build 当前的 `updates.jsonl` 使用 JSON-RPC 更新格式,并通过累计的 `totalTokens` 暴露 token 使用量,不能直接套用通用 JSONL 解析器。 + +本次修复应让自动解析和 `aiusage parse --tool grok` 都能导入既有及新增 Grok Build 会话,同时避免累计 token 重复计数,并自动恢复此前被错误水位跳过的历史日志。 + +## 解析架构 + +在 `@aiusage/core` 中新增专用的状态化 `GrokParser`,由 `Aggregator` 为 `grok` 注册。解析器只接受 Grok Build `updates.jsonl` 中的 JSON-RPC 更新,跟踪当前文件内的模型、最近累计 token、当前 turn 的基线与最大累计值。 + +每遇到新的 `user_message_chunk`,解析器结束上一个 turn;文件结束时通过 `finalize()` 输出最后一个 turn。单个 turn 的 token 使用量为其最大累计值减去 turn 开始前的累计基线。重复或回退的累计值不产生负数或重复记录。由于当前日志没有稳定的 input/output/cache 拆分,正向增量统一记录到 `inputTokens`,其他 token 分类记为 0。 + +解析器在 `finalize()` 后清空文件级状态,避免多个会话互相污染。若日志没有明确 turn 边界但存在有效累计 token,则生成一个会话级回退记录,保证已有数据不会继续静默丢失。 + +## 字段提取 + +- session ID:优先使用 `params.sessionId`,否则使用 `updates.jsonl` 的父目录名。 +- 模型:优先使用 `params.update._meta.modelId`,并兼容 `params._meta.modelId`;缺失时使用 `grok-unknown`。 +- token:兼容 `params._meta.totalTokens` 和 `params.update._meta.totalTokens`,只接受非负有限整数。 +- 时间戳:优先读取 `agentTimestampMs`,兼容常见 timestamp 字段,最后回退到解析上下文时间。 +- 工作目录:从 `sessions///updates.jsonl` 的项目目录解码;无法可靠解码时不写入 `cwd`。 +- provider 与 cost:provider 由现有模型推断逻辑得到 `xai`;价格存在时复用现有计价逻辑,否则 cost 为 0、来源为 `unknown`。 + +## 文件发现与水位迁移 + +Grok 发现逻辑收窄为只返回名为 `updates.jsonl` 的文件,排除 `events.jsonl`、`chat_history.jsonl` 等非用量来源。 + +水位状态增加 Grok 解析器版本。读取旧水位文件时,如果版本低于当前版本,则仅清空 `files.grok` 并写入当前版本;其他工具水位保持不变。这样修复后的首次解析会自动重扫历史 Grok 日志,之后继续使用正常的字节偏移增量解析。 + +增量解析时,水位之前的行仍会送入状态化解析器以重建累计基线,但不会重复写入记录;只有水位之后产生的 turn 结果会被导入。 + +## 错误处理 + +无效 JSON、缺少用量字段、零增量、重复累计值和回退累计值均被安全忽略,不中断同文件后续行。文件级读取错误沿用现有 `parseLogs` 错误收集机制。迁移只操作 Grok 水位,不删除数据库中的现有记录。 + +## 测试策略 + +- Core 单元测试:新版 JSON-RPC 格式、模型/会话/时间戳提取、累计增量、重复与回退值、无 turn 边界回退、多文件状态隔离。 +- Aggregator 测试:确认 `grok` 已注册且可解析记录。 +- Discovery 测试:Windows 默认目录与 `GROK_HOME`,并确认只发现 `updates.jsonl`。 +- Watermark 测试:旧状态仅重置 Grok 水位、保留其他工具水位,并持久化解析器版本。 +- CLI 集成测试:已有 Grok 水位会自动重扫,首次导入历史记录;再次运行不重复导入;不同会话保持独立。 +- 最终验证:运行 core/cli 相关测试、完整测试、lint 与 build。 + +## 非目标 + +本次不从 `signals.json` 或 `summary.json` 补齐压缩前 token,不估算 input/output 比例,也不新增 Grok 专属价格。若后续日志提供稳定的分类 token 或汇总字段,可在专用解析器中独立扩展。 From 4331a77abbca930d6245fa26d08e1f34095c52b0 Mon Sep 17 00:00:00 2001 From: Julian <1041324235@qq.com> Date: Mon, 13 Jul 2026 17:30:59 +0800 Subject: [PATCH 2/6] docs: plan Grok parser implementation --- .../plans/2026-07-13-grok-parser.md | 413 ++++++++++++++++++ 1 file changed, 413 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-13-grok-parser.md diff --git a/docs/superpowers/plans/2026-07-13-grok-parser.md b/docs/superpowers/plans/2026-07-13-grok-parser.md new file mode 100644 index 0000000..125bc5f --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-grok-parser.md @@ -0,0 +1,413 @@ +# Grok Build Parser Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Parse Grok Build `updates.jsonl` usage reliably, rescan files skipped by the old implementation, and preserve incremental/idempotent imports. + +**Architecture:** Add a dedicated stateful parser in `@aiusage/core` that converts cumulative Grok token counters into stable per-turn deltas. Narrow discovery to `updates.jsonl`, derive Grok session/workspace metadata in the parser, and version the Grok file watermark so existing incorrectly-consumed logs are replayed once. + +**Tech Stack:** TypeScript, Node.js, pnpm workspaces, Vitest, better-sqlite3 + +--- + +## File map + +- Create `packages/core/src/parsers/grok.ts`: Grok JSON-RPC parsing, per-file state, stable record construction, and reset on finalize. +- Create `packages/core/tests/grok.test.ts`: parser behavior and state-isolation regression tests. +- Modify `packages/core/src/parsers/index.ts`: export `GrokParser`. +- Modify `packages/core/src/aggregator.ts`: register `GrokParser` for the `grok` tool. +- Modify `packages/core/tests/aggregator.test.ts`: assert Grok routes to the parser. +- Modify `packages/cli/src/discovery.ts`: limit Grok discovery to `updates.jsonl`. +- Modify `packages/cli/tests/discovery.test.ts`: cover Windows/GROK_HOME paths and file filtering. +- Modify `packages/cli/src/watermark.ts`: migrate old Grok file watermarks once with a parser-version marker. +- Modify `packages/cli/tests/watermark.test.ts`: prove migration scope and persistence. +- Create `packages/cli/tests/commands/parse-grok.test.ts`: end-to-end historical replay, idempotency, metadata, and session isolation. + +### Task 1: Specify the Grok parser behavior + +**Files:** +- Create: `packages/core/tests/grok.test.ts` +- Create: `packages/core/src/parsers/grok.ts` + +- [ ] **Step 1: Write failing parser tests** + +Create a shared context builder and tests with representative JSON-RPC rows: + +```ts +const context = (sourceFile = '/home/test/.grok/sessions/%2Fwork%2Fapp/session-1/updates.jsonl', lineOffset = 0): ParseContext => ({ + tool: 'grok', + sourceFile, + lineOffset, + sessionId: 'unknown', + now: 1_800_000_000_000, + device: 'test-device', + deviceInstanceId: 'device-1', +}) + +const row = (sessionUpdate: string, totalTokens?: number, timestamp = 1_700_000_000_000) => JSON.stringify({ + method: 'session/update', + params: { + sessionId: 'session-1', + update: { + sessionUpdate, + _meta: { modelId: 'grok-composer-2.5-fast' }, + }, + _meta: { totalTokens, agentTimestampMs: timestamp }, + }, +}) +``` + +Cover these assertions: + +```ts +it('emits positive cumulative token deltas per turn with stable metadata', () => { + // baseline 100, first turn reaches 300, second reaches 450 + // expect two records with inputTokens 200 and 150 + // expect xai provider, session-1, decoded cwd /work/app, model and timestamps +}) + +it('ignores duplicate and decreasing cumulative counters', () => { + // 100 -> 150 -> 150 -> 120 -> 200 produces 100, never negative or duplicated +}) + +it('emits an aggregate fallback without a user-message boundary', () => { + // a single counter of 120 produces one grok-unknown record +}) + +it('uses stable turn offsets so an appended turn replaces its previous snapshot', () => { + // finalize at 150, replay plus append to 200; both records use the user row offset +}) + +it('clears parser state between finalized files', () => { + // second source starts from zero and uses its own session/model/cwd +}) +``` + +- [ ] **Step 2: Run the tests and verify RED** + +Run: + +```bash +pnpm --filter @aiusage/core test -- grok.test.ts +``` + +Expected: FAIL because `../src/parsers/grok.js` does not exist. + +- [ ] **Step 3: Implement the minimal stateful parser** + +Implement `GrokParser implements Parser` with these private state values: + +```ts +private currentModel = 'grok-unknown' +private lastTotal: number | null = null +private activeTurn: ActiveTurn | null = null +private fallback: ActiveTurn | null = null +``` + +`ActiveTurn` stores `baselineTotal`, `maxTotal`, `lineOffset`, `timestamp`, `model`, and the latest `ParseContext`. `parseLine()` must: + +1. Ignore invalid JSON and non-`session/update` rows. +2. Update model from `params.update._meta.modelId` or `params._meta.modelId`. +3. On `user_message_chunk`, emit the prior active turn, then start a new turn from `lastTotal ?? 0` using that row's stable line offset. +4. Read non-negative finite cumulative totals from supported paths. +5. Ignore totals lower than `lastTotal`; update a turn only when the total increases. +6. Use `params.sessionId` over the incoming context session ID. +7. Decode the grandparent directory of `updates.jsonl` with `decodeURIComponent` for `cwd`. +8. Build records with `generateRecordId(deviceInstanceId, sourceFile, turn.lineOffset)`, `inferProvider`, `resolvePrice`, and `calculateCost`. +9. Return the final active/fallback turn from `finalize()` and reset every state field before returning. + +Use this token mapping: + +```ts +const usage = { + inputTokens: delta, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + thinkingTokens: 0, +} +``` + +- [ ] **Step 4: Run parser tests and verify GREEN** + +Run: + +```bash +pnpm --filter @aiusage/core test -- grok.test.ts +``` + +Expected: all Grok parser tests pass. + +### Task 2: Register Grok in the aggregator + +**Files:** +- Modify: `packages/core/src/parsers/index.ts` +- Modify: `packages/core/src/aggregator.ts` +- Modify: `packages/core/tests/aggregator.test.ts` + +- [ ] **Step 1: Add a failing aggregator routing test** + +```ts +it('processes Grok Build updates through the Grok parser', () => { + const aggregator = new Aggregator() + const context = aggregator.createContext({ + tool: 'grok', + sourceFile: '/tmp/project/session-1/updates.jsonl', + lineOffset: 0, + sessionId: 'session-1', + device: 'dev', + deviceInstanceId: 'dev-123', + }) + aggregator.parseLine(JSON.stringify({ + method: 'session/update', + params: { sessionId: 'session-1', _meta: { totalTokens: 42, agentTimestampMs: 1_700_000_000_000 } }, + }), context) + const [result] = aggregator.finalize() + expect(result.record?.tool).toBe('grok') + expect(result.record?.inputTokens).toBe(42) +}) +``` + +- [ ] **Step 2: Run the test and verify RED** + +Run: + +```bash +pnpm --filter @aiusage/core test -- aggregator.test.ts +``` + +Expected: FAIL because no parser is registered for `grok`. + +- [ ] **Step 3: Export and register `GrokParser`** + +Add: + +```ts +export { GrokParser } from './grok.js' +``` + +Import it in `aggregator.ts` and add: + +```ts +['grok', new GrokParser()], +``` + +- [ ] **Step 4: Run core tests** + +Run: + +```bash +pnpm --filter @aiusage/core test +``` + +Expected: all core tests pass. + +- [ ] **Step 5: Commit the core parser** + +```bash +git add packages/core/src/parsers/grok.ts packages/core/src/parsers/index.ts packages/core/src/aggregator.ts packages/core/tests/grok.test.ts packages/core/tests/aggregator.test.ts +git commit -m "fix: parse Grok Build usage updates" +``` + +### Task 3: Restrict Grok discovery to usage files + +**Files:** +- Modify: `packages/cli/tests/discovery.test.ts` +- Modify: `packages/cli/src/discovery.ts` + +- [ ] **Step 1: Add failing discovery tests** + +Create `%USERPROFILE%/.grok/sessions///` with `updates.jsonl`, `events.jsonl`, and `chat_history.jsonl`. Assert on `win32` that: + +```ts +expect(discoverTools().find((tool) => tool.sourceKey === 'grok')?.status).toBe('found') +expect(discoverLogFiles().find((result) => result.tool === 'grok')?.paths).toEqual([updatesPath]) +``` + +Add a second test with `process.env.GROK_HOME = customRoot` and assert discovery uses `customRoot/sessions/.../updates.jsonl`. Add `GROK_HOME` cleanup in `afterEach`. + +- [ ] **Step 2: Run discovery tests and verify RED** + +Run: + +```bash +pnpm --filter @juliantanx/aiusage test -- discovery.test.ts +``` + +Expected: FAIL because Grok currently returns all `.jsonl` files. + +- [ ] **Step 3: Narrow the Grok filter** + +Change the source entry to: + +```ts +{ tool: 'grok', path: probeGrok(ctx), filter: (p) => basename(p) === 'updates.jsonl' }, +``` + +- [ ] **Step 4: Run discovery tests and verify GREEN** + +Run the same command and expect all discovery tests to pass. + +### Task 4: Migrate stale Grok watermarks once + +**Files:** +- Modify: `packages/cli/tests/watermark.test.ts` +- Modify: `packages/cli/src/watermark.ts` + +- [ ] **Step 1: Add failing migration tests** + +Write an envelope containing both Grok and Claude entries but no Grok parser version: + +```ts +writeFileSync(watermarkPath, JSON.stringify({ + files: { + grok: { '/old/updates.jsonl': { offset: 100, size: 100, mtime: 1 } }, + 'claude-code': { '/claude/session.jsonl': { offset: 200, size: 200, mtime: 2 } }, + }, +})) +``` + +Assert that a new manager returns `null` for the Grok entry, preserves Claude, then after `save()` the JSON contains `grokParserVersion: 1`. Add a second test with version 1 proving a current Grok entry is preserved. + +- [ ] **Step 2: Run watermark tests and verify RED** + +Run: + +```bash +pnpm --filter @juliantanx/aiusage test -- watermark.test.ts +``` + +Expected: FAIL because the version/migration does not exist. + +- [ ] **Step 3: Implement scoped migration** + +Add: + +```ts +const CURRENT_GROK_PARSER_VERSION = 1 +``` + +Extend `WatermarkState` with `grokParserVersion?: number`. Normalize both legacy-flat and envelope states, and when the loaded version is older: + +```ts +state.files.grok = {} +state.grokParserVersion = CURRENT_GROK_PARSER_VERSION +``` + +Do not reset a newly-created empty state, and preserve all unrelated cursor fields during envelope loading. + +- [ ] **Step 4: Run watermark tests and verify GREEN** + +Run the same command and expect all watermark tests to pass. + +- [ ] **Step 5: Commit discovery and migration** + +```bash +git add packages/cli/src/discovery.ts packages/cli/tests/discovery.test.ts packages/cli/src/watermark.ts packages/cli/tests/watermark.test.ts +git commit -m "fix: rediscover historical Grok usage logs" +``` + +### Task 5: Prove end-to-end import and idempotency + +**Files:** +- Create: `packages/cli/tests/commands/parse-grok.test.ts` + +- [ ] **Step 1: Add an end-to-end CLI test** + +Mock `homedir()` to a temporary directory, create `.aiusage/config.json`, two Grok session directories, and an old watermark that marks both `updates.jsonl` files fully consumed without `grokParserVersion`. + +Use counters `100 -> 300 -> 450` in session A and `20 -> 70` in session B. Then: + +```ts +const first = await runParse(cacheDb, 'grok') +expect(first.parsedCount).toBe(3) + +const rows = cacheDb.prepare(` + SELECT session_id, model, input_tokens, cwd + FROM records + WHERE tool = 'grok' + ORDER BY session_id, line_offset +`).all() + +expect(rows).toEqual([ + // session A: 200 and 150, decoded cwd + // session B: 50, isolated from session A +]) + +const second = await runParse(cacheDb, 'grok') +expect(second.parsedCount).toBe(0) +expect(cacheDb.prepare("SELECT COUNT(*) AS count FROM records WHERE tool = 'grok'").get()).toEqual({ count: 3 }) +``` + +- [ ] **Step 2: Run the test and verify RED if integration gaps remain** + +Run: + +```bash +pnpm --filter @aiusage/core build +pnpm --filter @juliantanx/aiusage test -- parse-grok.test.ts +``` + +Expected before any integration correction: the test identifies any remaining metadata, replay, or stable-ID issue. + +- [ ] **Step 3: Make only the minimal integration correction** + +If necessary, adjust Grok parser stable offsets/reset behavior or watermark normalization. Do not broaden parsing to other Grok files or add unrelated telemetry sources. + +- [ ] **Step 4: Run the focused integration suite** + +Run: + +```bash +pnpm --filter @aiusage/core build +pnpm --filter @juliantanx/aiusage test -- parse-grok.test.ts discovery.test.ts watermark.test.ts +``` + +Expected: all focused tests pass. + +- [ ] **Step 5: Commit the integration regression test** + +```bash +git add packages/cli/tests/commands/parse-grok.test.ts packages/core/src/parsers/grok.ts packages/cli/src/watermark.ts +git commit -m "test: cover Grok historical import" +``` + +### Task 6: Full verification and handoff + +**Files:** +- Verify all files changed by Tasks 1-5. + +- [ ] **Step 1: Run formatting/whitespace validation** + +```bash +git diff --check main...HEAD +``` + +Expected: no output and exit 0. + +- [ ] **Step 2: Run all tests** + +```bash +pnpm test +``` + +Expected: all workspace test suites pass with zero failures. + +- [ ] **Step 3: Run lint and build** + +```bash +pnpm lint +pnpm build +``` + +Expected: both commands exit 0. + +- [ ] **Step 4: Review scope and branch state** + +```bash +git status --short --branch +git diff --stat main...HEAD +git log --oneline main..HEAD +``` + +Expected: clean `fix/issue-43-grok-parser` branch containing only the design, plan, parser, discovery, watermark, and tests for issue #43. From ee28004db33680a160adc156284b83572c9f8681 Mon Sep 17 00:00:00 2001 From: Julian <1041324235@qq.com> Date: Mon, 13 Jul 2026 17:33:52 +0800 Subject: [PATCH 3/6] fix: parse Grok Build usage updates --- packages/core/src/aggregator.ts | 2 + packages/core/src/parsers/grok.ts | 199 +++++++++++++++++++++++++ packages/core/src/parsers/index.ts | 1 + packages/core/tests/aggregator.test.ts | 22 +++ packages/core/tests/grok.test.ts | 138 +++++++++++++++++ 5 files changed, 362 insertions(+) create mode 100644 packages/core/src/parsers/grok.ts create mode 100644 packages/core/tests/grok.test.ts diff --git a/packages/core/src/aggregator.ts b/packages/core/src/aggregator.ts index e402855..b9e2f82 100644 --- a/packages/core/src/aggregator.ts +++ b/packages/core/src/aggregator.ts @@ -7,6 +7,7 @@ import { OpenClawParser } from './parsers/openclaw.js' import { QoderParser } from './parsers/qoder.js' import { CopilotParser } from './parsers/copilot.js' import { GenericJsonlParser } from './parsers/generic-jsonl.js' +import { GrokParser } from './parsers/grok.js' export interface CreateContextOptions { tool: Tool @@ -34,6 +35,7 @@ export class Aggregator { ['gemini', new GenericJsonlParser('gemini', 'gemini-unknown')], ['kimi', new GenericJsonlParser('kimi', 'kimi-for-coding')], ['codebuddy', new GenericJsonlParser('codebuddy', 'codebuddy-unknown')], + ['grok', new GrokParser()], ['antigravity', new GenericJsonlParser('antigravity', 'antigravity-unknown')], ['omp', new GenericJsonlParser('omp', 'omp-unknown')], ['pi', new GenericJsonlParser('pi', 'pi-unknown')], diff --git a/packages/core/src/parsers/grok.ts b/packages/core/src/parsers/grok.ts new file mode 100644 index 0000000..ecc9bbf --- /dev/null +++ b/packages/core/src/parsers/grok.ts @@ -0,0 +1,199 @@ +import type { ParseContext, ParseResult, Parser, StatsRecord } from '../types.js' +import { generateRecordId } from '../record-id.js' +import { inferProvider } from '../provider.js' +import { calculateCost, resolvePrice } from '../pricing.js' + +const UNKNOWN_MODEL = 'grok-unknown' + +interface ActiveTurn { + baselineTotal: number + maxTotal: number + lineOffset: number + timestamp: number + model: string + sessionId: string + context: ParseContext +} + +function nonNegativeInteger(value: unknown): number | null { + const parsed = Number(value) + if (!Number.isFinite(parsed) || parsed < 0) return null + return Math.floor(parsed) +} + +function timestamp(value: unknown, fallback: number): number { + if (typeof value === 'number' && Number.isFinite(value)) { + return value < 1e12 ? value * 1000 : value + } + if (typeof value === 'string' && value.trim()) { + const numeric = Number(value) + if (Number.isFinite(numeric)) return numeric < 1e12 ? numeric * 1000 : numeric + const parsed = new Date(value).getTime() + if (Number.isFinite(parsed)) return parsed + } + return fallback +} + +function stringValue(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null +} + +function sessionIdFromPath(sourceFile: string): string | null { + const parts = sourceFile.replace(/\\/g, '/').split('/').filter(Boolean) + return parts.length >= 2 ? parts[parts.length - 2] : null +} + +function cwdFromPath(sourceFile: string): string | undefined { + const parts = sourceFile.replace(/\\/g, '/').split('/').filter(Boolean) + if (parts.length < 3 || parts[parts.length - 1] !== 'updates.jsonl') return undefined + try { + const decoded = decodeURIComponent(parts[parts.length - 3]) + return decoded || undefined + } catch { + return undefined + } +} + +export class GrokParser implements Parser { + readonly tool = 'grok' as const + private currentModel = UNKNOWN_MODEL + private lastTotal: number | null = null + private activeTurn: ActiveTurn | null = null + private fallback: ActiveTurn | null = null + + parseLine(line: string, context: ParseContext): ParseResult | null { + let parsed: any + try { + parsed = JSON.parse(line) + } catch { + return null + } + + if (parsed?.method !== 'session/update' || !parsed?.params) return null + + const params = parsed.params + const update = params.update + const model = stringValue(update?._meta?.modelId) + ?? stringValue(params?._meta?.modelId) + if (model) this.currentModel = model + + const recordTimestamp = timestamp( + params?._meta?.agentTimestampMs + ?? update?._meta?.agentTimestampMs + ?? params?.timestamp + ?? parsed?.timestamp + ?? parsed?.ts, + context.now, + ) + const sessionId = stringValue(params.sessionId) + ?? sessionIdFromPath(context.sourceFile) + ?? context.sessionId + + let completed: ParseResult | null = null + if (update?.sessionUpdate === 'user_message_chunk') { + if (this.activeTurn) completed = this.buildResult(this.activeTurn) + this.fallback = null + this.activeTurn = { + baselineTotal: this.lastTotal ?? 0, + maxTotal: this.lastTotal ?? 0, + lineOffset: context.lineOffset, + timestamp: recordTimestamp, + model: this.currentModel, + sessionId, + context, + } + } + + const total = nonNegativeInteger( + params?._meta?.totalTokens + ?? update?._meta?.totalTokens + ?? update?.totalTokens + ?? params?.totalTokens, + ) + if (total == null) return completed + if (this.lastTotal != null && total < this.lastTotal) return completed + + if (this.activeTurn) { + if (total > this.activeTurn.maxTotal) { + this.activeTurn.maxTotal = total + this.activeTurn.timestamp = recordTimestamp + this.activeTurn.context = context + this.activeTurn.sessionId = sessionId + } + if (this.activeTurn.model === UNKNOWN_MODEL && this.currentModel !== UNKNOWN_MODEL) { + this.activeTurn.model = this.currentModel + } + } else { + if (!this.fallback) { + this.fallback = { + baselineTotal: 0, + maxTotal: total, + lineOffset: context.lineOffset, + timestamp: recordTimestamp, + model: this.currentModel, + sessionId, + context, + } + } else if (total > this.fallback.maxTotal) { + this.fallback.maxTotal = total + this.fallback.timestamp = recordTimestamp + this.fallback.context = context + this.fallback.sessionId = sessionId + } + if (this.fallback.model === UNKNOWN_MODEL && this.currentModel !== UNKNOWN_MODEL) { + this.fallback.model = this.currentModel + } + } + + if (this.lastTotal == null || total > this.lastTotal) this.lastTotal = total + return completed + } + + finalize(): ParseResult[] { + const turn = this.activeTurn ?? this.fallback + const result = turn ? this.buildResult(turn) : null + this.reset() + return result ? [result] : [] + } + + private buildResult(turn: ActiveTurn): ParseResult | null { + const inputTokens = turn.maxTotal - turn.baselineTotal + if (inputTokens <= 0) return null + + const usage = { + inputTokens, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + thinkingTokens: 0, + } + const hasPrice = resolvePrice(turn.model) != null + const record: StatsRecord = { + id: generateRecordId(turn.context.deviceInstanceId, turn.context.sourceFile, turn.lineOffset), + ts: turn.timestamp, + ingestedAt: turn.context.now, + updatedAt: turn.context.now, + lineOffset: turn.lineOffset, + tool: this.tool, + model: turn.model, + provider: inferProvider(turn.model), + ...usage, + cost: hasPrice ? calculateCost(turn.model, usage, turn.context.exchangeRate) : 0, + costSource: hasPrice ? 'pricing' : 'unknown', + sessionId: turn.sessionId, + sourceFile: turn.context.sourceFile, + cwd: cwdFromPath(turn.context.sourceFile), + device: turn.context.device, + deviceInstanceId: turn.context.deviceInstanceId, + platform: turn.context.platform, + } + return { record, toolCalls: [] } + } + + private reset(): void { + this.currentModel = UNKNOWN_MODEL + this.lastTotal = null + this.activeTurn = null + this.fallback = null + } +} diff --git a/packages/core/src/parsers/index.ts b/packages/core/src/parsers/index.ts index 5ef45d4..2ec3869 100644 --- a/packages/core/src/parsers/index.ts +++ b/packages/core/src/parsers/index.ts @@ -5,3 +5,4 @@ export { CodeFuseParser } from './codefuse.js' export { OpenClawParser } from './openclaw.js' export { QoderParser } from './qoder.js' export { CopilotParser } from './copilot.js' +export { GrokParser } from './grok.js' diff --git a/packages/core/tests/aggregator.test.ts b/packages/core/tests/aggregator.test.ts index 47a7e8f..465acd7 100644 --- a/packages/core/tests/aggregator.test.ts +++ b/packages/core/tests/aggregator.test.ts @@ -51,6 +51,28 @@ describe('Aggregator', () => { expect(result).toBeNull() }) + it('processes Grok Build updates through the Grok parser', () => { + const context = aggregator.createContext({ + tool: 'grok', + sourceFile: '/tmp/project/session-1/updates.jsonl', + lineOffset: 0, + sessionId: 'session-1', + device: 'dev', + deviceInstanceId: 'dev-123', + }) + aggregator.parseLine(JSON.stringify({ + method: 'session/update', + params: { + sessionId: 'session-1', + _meta: { totalTokens: 42, agentTimestampMs: 1_700_000_000_000 }, + }, + }), context) + + const [result] = aggregator.finalize() + expect(result.record?.tool).toBe('grok') + expect(result.record?.inputTokens).toBe(42) + }) + it('finalizes Codex parser and returns orphan tool calls', () => { // Simulate Codex function_call without subsequent token_count const functionCallLine = '{"event_msg":{"type":"event","payload":{"type":"function_call","function":{"name":"Read"}},"timestamp":1234567890}}' diff --git a/packages/core/tests/grok.test.ts b/packages/core/tests/grok.test.ts new file mode 100644 index 0000000..48423b1 --- /dev/null +++ b/packages/core/tests/grok.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import { GrokParser } from '../src/parsers/grok.js' +import type { ParseContext, ParseResult } from '../src/types.js' + +function context( + sourceFile = '/home/test/.grok/sessions/%2Fwork%2Fapp/session-1/updates.jsonl', + lineOffset = 0, +): ParseContext { + return { + tool: 'grok', + sourceFile, + lineOffset, + sessionId: 'unknown', + now: 1_800_000_000_000, + device: 'test-device', + deviceInstanceId: 'device-1', + } +} + +function row(options: { + sessionUpdate?: string + totalTokens?: number + timestamp?: number + modelId?: string + sessionId?: string +}): string { + const { + sessionUpdate = 'agent_message_chunk', + totalTokens, + timestamp = 1_700_000_000_000, + modelId, + sessionId = 'session-1', + } = options + return JSON.stringify({ + method: 'session/update', + params: { + sessionId, + update: { + sessionUpdate, + ...(modelId ? { _meta: { modelId } } : {}), + }, + _meta: { + ...(totalTokens == null ? {} : { totalTokens }), + agentTimestampMs: timestamp, + }, + }, + }) +} + +function records(results: Array): ParseResult[] { + return results.filter((result): result is ParseResult => result?.record != null) +} + +describe('GrokParser', () => { + it('emits positive cumulative token deltas per turn with stable metadata', () => { + const parser = new GrokParser() + const emitted = records([ + parser.parseLine(row({ totalTokens: 100 }), context(undefined, 0)), + parser.parseLine(row({ sessionUpdate: 'user_message_chunk', modelId: 'grok-composer-2.5-fast', timestamp: 1_700_000_001_000 }), context(undefined, 100)), + parser.parseLine(row({ totalTokens: 250, timestamp: 1_700_000_002_000 }), context(undefined, 200)), + parser.parseLine(row({ totalTokens: 300, timestamp: 1_700_000_003_000 }), context(undefined, 300)), + parser.parseLine(row({ sessionUpdate: 'user_message_chunk', modelId: 'grok-composer-2.5-fast', timestamp: 1_700_000_004_000 }), context(undefined, 400)), + parser.parseLine(row({ totalTokens: 450, timestamp: 1_700_000_005_000 }), context(undefined, 500)), + ...parser.finalize(), + ]) + + expect(emitted).toHaveLength(2) + expect(emitted.map(result => result.record?.inputTokens)).toEqual([200, 150]) + expect(emitted.map(result => result.record?.lineOffset)).toEqual([100, 400]) + expect(emitted.map(result => result.record?.ts)).toEqual([1_700_000_003_000, 1_700_000_005_000]) + expect(emitted[0].record).toMatchObject({ + tool: 'grok', + model: 'grok-composer-2.5-fast', + provider: 'xai', + sessionId: 'session-1', + cwd: '/work/app', + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + thinkingTokens: 0, + }) + }) + + it('ignores duplicate and decreasing cumulative counters', () => { + const parser = new GrokParser() + parser.parseLine(row({ totalTokens: 100 }), context(undefined, 0)) + parser.parseLine(row({ sessionUpdate: 'user_message_chunk', modelId: 'grok-composer-2.5-fast' }), context(undefined, 100)) + parser.parseLine(row({ totalTokens: 150 }), context(undefined, 200)) + parser.parseLine(row({ totalTokens: 150 }), context(undefined, 300)) + parser.parseLine(row({ totalTokens: 120 }), context(undefined, 400)) + parser.parseLine(row({ totalTokens: 200, timestamp: 1_700_000_005_000 }), context(undefined, 500)) + + const [result] = parser.finalize() + expect(result.record?.inputTokens).toBe(100) + expect(result.record?.ts).toBe(1_700_000_005_000) + }) + + it('emits an aggregate fallback without a user-message boundary', () => { + const parser = new GrokParser() + parser.parseLine(row({ totalTokens: 120, modelId: undefined }), context(undefined, 25)) + + const [result] = parser.finalize() + expect(result.record).toMatchObject({ + model: 'grok-unknown', + inputTokens: 120, + lineOffset: 25, + }) + }) + + it('uses stable turn offsets when an appended turn replaces its previous snapshot', () => { + const parser = new GrokParser() + parser.parseLine(row({ sessionUpdate: 'user_message_chunk', modelId: 'grok-composer-2.5-fast' }), context(undefined, 10)) + parser.parseLine(row({ totalTokens: 150 }), context(undefined, 100)) + const [first] = parser.finalize() + + parser.parseLine(row({ sessionUpdate: 'user_message_chunk', modelId: 'grok-composer-2.5-fast' }), context(undefined, 10)) + parser.parseLine(row({ totalTokens: 150 }), context(undefined, 100)) + parser.parseLine(row({ totalTokens: 200 }), context(undefined, 200)) + const [second] = parser.finalize() + + expect(first.record?.id).toBe(second.record?.id) + expect(first.record?.inputTokens).toBe(150) + expect(second.record?.inputTokens).toBe(200) + }) + + it('clears parser state between finalized files', () => { + const parser = new GrokParser() + parser.parseLine(row({ totalTokens: 100, sessionId: 'session-1', modelId: 'grok-one' }), context(undefined, 0)) + const [first] = parser.finalize() + + const secondSource = '/home/test/.grok/sessions/%2Fwork%2Fother/session-2/updates.jsonl' + parser.parseLine(row({ totalTokens: 40, sessionId: 'session-2', modelId: 'grok-two' }), context(secondSource, 0)) + const [second] = parser.finalize() + + expect(first.record).toMatchObject({ sessionId: 'session-1', model: 'grok-one', inputTokens: 100, cwd: '/work/app' }) + expect(second.record).toMatchObject({ sessionId: 'session-2', model: 'grok-two', inputTokens: 40, cwd: '/work/other' }) + }) +}) From f7d94194c6b0b71d37dcad9667d946a1e0477359 Mon Sep 17 00:00:00 2001 From: Julian <1041324235@qq.com> Date: Mon, 13 Jul 2026 17:34:55 +0800 Subject: [PATCH 4/6] fix: rediscover historical Grok usage logs --- packages/cli/src/discovery.ts | 2 +- packages/cli/src/watermark.ts | 33 ++++++++++++++++++++++++---- packages/cli/tests/discovery.test.ts | 30 +++++++++++++++++++++++++ packages/cli/tests/watermark.test.ts | 31 +++++++++++++++++++++++++- 4 files changed, 90 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/discovery.ts b/packages/cli/src/discovery.ts index bf68e31..ce8c756 100644 --- a/packages/cli/src/discovery.ts +++ b/packages/cli/src/discovery.ts @@ -900,7 +900,7 @@ export function discoverLogFiles(env: NodeJS.ProcessEnv = process.env): { tool: { tool: 'kiro', path: kiroDevDataDir(ctx), filter: (p) => extname(p) === '.jsonl' || extname(p) === '.json' }, { tool: 'kiro', path: probeKiroOverridePath(ctx), filter: (p) => extname(p) === '.jsonl' || extname(p) === '.json' }, { tool: 'kiro', path: kiroWorkspaceSessionsDir(ctx), filter: (p) => extname(p) === '.json' && basename(p) !== 'sessions.json' }, - { tool: 'grok', path: probeGrok(ctx), filter: (p) => extname(p) === '.jsonl' }, + { tool: 'grok', path: probeGrok(ctx), filter: (p) => basename(p) === 'updates.jsonl' }, { tool: 'antigravity', path: probeAntigravity(ctx) }, { tool: 'omp', path: probeOmp(ctx) }, { tool: 'pi', path: probePi(ctx) }, diff --git a/packages/cli/src/watermark.ts b/packages/cli/src/watermark.ts index fc49353..1997402 100644 --- a/packages/cli/src/watermark.ts +++ b/packages/cli/src/watermark.ts @@ -1,6 +1,8 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs' import type { Tool } from '@aiusage/core' +const CURRENT_GROK_PARSER_VERSION = 1 + export interface WatermarkEntry { offset: number size: number @@ -45,6 +47,7 @@ export type FileWatermarkData = Record> export interface WatermarkState { files: FileWatermarkData + grokParserVersion?: number opencode?: OpenCodeCursor | null hermes?: HermesCursor | null qoder?: QoderCursor | null @@ -103,18 +106,40 @@ export class WatermarkManager { private load(): WatermarkState { if (!existsSync(this.path)) { - return { files: defaultFileData() } + return { files: defaultFileData(), grokParserVersion: CURRENT_GROK_PARSER_VERSION } } try { const content = readFileSync(this.path, 'utf-8') const parsed = JSON.parse(content) + let state: WatermarkState // Handle legacy format (flat Record without 'files' key) if (parsed && typeof parsed === 'object' && !('files' in parsed)) { - return { files: { ...defaultFileData(), ...parsed } } + state = { files: { ...defaultFileData(), ...parsed } } + } else { + state = { + files: { ...defaultFileData(), ...(parsed.files ?? {}) }, + grokParserVersion: parsed.grokParserVersion, + opencode: parsed.opencode ?? null, + hermes: parsed.hermes ?? null, + qoder: parsed.qoder ?? null, + cursor: parsed.cursor ?? null, + goose: parsed.goose ?? null, + zed: parsed.zed ?? null, + kiro: parsed.kiro ?? null, + zcode: parsed.zcode ?? null, + zcodeTools: parsed.zcodeTools ?? null, + trae: parsed.trae ?? null, + codebuddyIde: parsed.codebuddyIde ?? null, + } + } + + if ((state.grokParserVersion ?? 0) < CURRENT_GROK_PARSER_VERSION) { + state.files.grok = {} + state.grokParserVersion = CURRENT_GROK_PARSER_VERSION } - return { files: { ...defaultFileData(), ...(parsed.files ?? {}) }, opencode: parsed.opencode ?? null, hermes: parsed.hermes ?? null, qoder: parsed.qoder ?? null, cursor: parsed.cursor ?? null, goose: parsed.goose ?? null, zed: parsed.zed ?? null, kiro: parsed.kiro ?? null, zcode: parsed.zcode ?? null, zcodeTools: parsed.zcodeTools ?? null, trae: parsed.trae ?? null, codebuddyIde: parsed.codebuddyIde ?? null } + return state } catch { - return { files: defaultFileData() } + return { files: defaultFileData(), grokParserVersion: CURRENT_GROK_PARSER_VERSION } } } diff --git a/packages/cli/tests/discovery.test.ts b/packages/cli/tests/discovery.test.ts index a503640..b230b54 100644 --- a/packages/cli/tests/discovery.test.ts +++ b/packages/cli/tests/discovery.test.ts @@ -37,6 +37,7 @@ describe('discovery path resolution', () => { delete process.env.AIUSAGE_KELIVO_PATH delete process.env.AIUSAGE_CODEFUSE_PATH delete process.env.CODEFUSE_HOME + delete process.env.GROK_HOME for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }) } @@ -132,6 +133,35 @@ describe('discovery path resolution', () => { expect(discoverLogFiles().find((result) => result.tool === 'codex')?.paths).toEqual([archivedFile]) }) + it('discovers only Grok Build updates.jsonl from the Windows default path', async () => { + const home = makeHome() + const sessionDir = join(home, '.grok', 'sessions', '%43%3A%5Cworkspace', 'session-1') + mkdirSync(sessionDir, { recursive: true }) + const updatesPath = join(sessionDir, 'updates.jsonl') + writeFileSync(updatesPath, '{}\n') + writeFileSync(join(sessionDir, 'events.jsonl'), '{}\n') + writeFileSync(join(sessionDir, 'chat_history.jsonl'), '{}\n') + + const { discoverLogFiles, discoverTools } = await loadDiscovery({ home, platform: 'win32' }) + + expect(discoverTools().find((tool) => tool.sourceKey === 'grok')?.status).toBe('found') + expect(discoverLogFiles().find((result) => result.tool === 'grok')?.paths).toEqual([updatesPath]) + }) + + it('honors GROK_HOME when discovering Grok Build updates', async () => { + const home = makeHome() + const grokHome = join(home, 'custom-grok') + const sessionDir = join(grokHome, 'sessions', '%2Fworkspace', 'session-2') + mkdirSync(sessionDir, { recursive: true }) + const updatesPath = join(sessionDir, 'updates.jsonl') + writeFileSync(updatesPath, '{}\n') + process.env.GROK_HOME = grokHome + + const { discoverLogFiles } = await loadDiscovery({ home, platform: 'linux' }) + + expect(discoverLogFiles().find((result) => result.tool === 'grok')?.paths).toEqual([updatesPath]) + }) + it('counts Codex archived sessions when regular sessions are absent', async () => { const home = makeHome() const archivedDir = join(home, '.codex', 'archived_sessions', '2026', '06', '03') diff --git a/packages/cli/tests/watermark.test.ts b/packages/cli/tests/watermark.test.ts index aac49f2..1100fd8 100644 --- a/packages/cli/tests/watermark.test.ts +++ b/packages/cli/tests/watermark.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs' +import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs' import { WatermarkManager } from '../src/watermark.js' describe('WatermarkManager', () => { @@ -113,6 +113,35 @@ describe('WatermarkManager', () => { expect(wm2.getEntry('claude-code', '/path/file.jsonl')!.offset).toBe(500) }) + it('clears only stale Grok entries when upgrading the Grok parser', () => { + writeFileSync(watermarkPath, JSON.stringify({ + files: { + grok: { '/old/updates.jsonl': { offset: 100, size: 100, mtime: 1 } }, + 'claude-code': { '/claude/session.jsonl': { offset: 200, size: 200, mtime: 2 } }, + }, + }), 'utf-8') + + const wm = new WatermarkManager(watermarkPath) + expect(wm.getEntry('grok', '/old/updates.jsonl')).toBeNull() + expect(wm.getEntry('claude-code', '/claude/session.jsonl')?.offset).toBe(200) + + wm.save() + const saved = JSON.parse(readFileSync(watermarkPath, 'utf-8')) + expect(saved.grokParserVersion).toBe(1) + }) + + it('preserves Grok entries written by the current parser version', () => { + writeFileSync(watermarkPath, JSON.stringify({ + files: { + grok: { '/current/updates.jsonl': { offset: 300, size: 300, mtime: 3 } }, + }, + grokParserVersion: 1, + }), 'utf-8') + + const wm = new WatermarkManager(watermarkPath) + expect(wm.getEntry('grok', '/current/updates.jsonl')?.offset).toBe(300) + }) + it('returns null when no opencode cursor has been set', () => { const wm = new WatermarkManager(watermarkPath) expect(wm.getOpenCodeCursor()).toBeNull() From b7e311ca74cab3c4a640283c994b08397c93e6c3 Mon Sep 17 00:00:00 2001 From: Julian <1041324235@qq.com> Date: Mon, 13 Jul 2026 17:36:28 +0800 Subject: [PATCH 5/6] test: cover Grok historical import --- .../cli/tests/commands/parse-grok.test.ts | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 packages/cli/tests/commands/parse-grok.test.ts diff --git a/packages/cli/tests/commands/parse-grok.test.ts b/packages/cli/tests/commands/parse-grok.test.ts new file mode 100644 index 0000000..08cebf5 --- /dev/null +++ b/packages/cli/tests/commands/parse-grok.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import Database from 'better-sqlite3' +import { mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { initializeDatabase } from '../../src/db/index.js' + +const { testDir } = vi.hoisted(() => ({ + testDir: '/tmp/aiusage-parse-grok-test', +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { + ...actual, + homedir: () => testDir, + } +}) + +const { runParse } = await import('../../src/commands/parse.js') + +function update(options: { + sessionId: string + totalTokens?: number + sessionUpdate?: string + modelId?: string + timestamp: number +}): Record { + return { + method: 'session/update', + params: { + sessionId: options.sessionId, + update: { + sessionUpdate: options.sessionUpdate ?? 'agent_message_chunk', + ...(options.modelId ? { _meta: { modelId: options.modelId } } : {}), + }, + _meta: { + ...(options.totalTokens == null ? {} : { totalTokens: options.totalTokens }), + agentTimestampMs: options.timestamp, + }, + }, + } +} + +function writeJsonl(filePath: string, rows: Record[]): void { + writeFileSync(filePath, `${rows.map(row => JSON.stringify(row)).join('\n')}\n`) +} + +describe('runParse with Grok Build data', () => { + let cacheDb: Database.Database + + beforeEach(() => { + rmSync(testDir, { recursive: true, force: true }) + mkdirSync(join(testDir, '.aiusage'), { recursive: true }) + cacheDb = new Database(':memory:') + initializeDatabase(cacheDb) + }) + + afterEach(() => { + cacheDb.close() + rmSync(testDir, { recursive: true, force: true }) + }) + + it('replays stale watermarks once and keeps Grok sessions isolated', async () => { + const sessionsRoot = join(testDir, '.grok', 'sessions') + const sessionADir = join(sessionsRoot, '%43%3A%5Cworkspace%5Capp', 'session-a') + const sessionBDir = join(sessionsRoot, '%2Fworkspace%2Fother', 'session-b') + mkdirSync(sessionADir, { recursive: true }) + mkdirSync(sessionBDir, { recursive: true }) + const sessionAPath = join(sessionADir, 'updates.jsonl') + const sessionBPath = join(sessionBDir, 'updates.jsonl') + + writeJsonl(sessionAPath, [ + update({ sessionId: 'session-a', totalTokens: 100, timestamp: 1_700_000_000_000 }), + update({ sessionId: 'session-a', sessionUpdate: 'user_message_chunk', modelId: 'grok-composer-2.5-fast', timestamp: 1_700_000_001_000 }), + update({ sessionId: 'session-a', totalTokens: 300, timestamp: 1_700_000_002_000 }), + update({ sessionId: 'session-a', sessionUpdate: 'user_message_chunk', modelId: 'grok-composer-2.5-fast', timestamp: 1_700_000_003_000 }), + update({ sessionId: 'session-a', totalTokens: 450, timestamp: 1_700_000_004_000 }), + ]) + writeJsonl(sessionBPath, [ + update({ sessionId: 'session-b', totalTokens: 20, timestamp: 1_700_000_010_000 }), + update({ sessionId: 'session-b', sessionUpdate: 'user_message_chunk', modelId: 'grok-3', timestamp: 1_700_000_011_000 }), + update({ sessionId: 'session-b', totalTokens: 70, timestamp: 1_700_000_012_000 }), + ]) + + writeFileSync(join(testDir, '.aiusage', 'config.json'), JSON.stringify({ + sources: { grok: sessionsRoot }, + })) + writeFileSync(join(testDir, '.aiusage', 'watermark.json'), JSON.stringify({ + files: { + grok: { + [sessionAPath]: { + offset: statSync(sessionAPath).size, + size: statSync(sessionAPath).size, + mtime: statSync(sessionAPath).mtimeMs, + }, + [sessionBPath]: { + offset: statSync(sessionBPath).size, + size: statSync(sessionBPath).size, + mtime: statSync(sessionBPath).mtimeMs, + }, + }, + }, + })) + + const first = await runParse(cacheDb, 'grok') + expect(first.errors).toEqual([]) + expect(first.parsedCount).toBe(3) + + const rows = cacheDb.prepare(` + SELECT session_id, model, input_tokens, cwd + FROM records + WHERE tool = 'grok' + ORDER BY session_id, line_offset + `).all() + expect(rows).toEqual([ + { session_id: 'session-a', model: 'grok-composer-2.5-fast', input_tokens: 200, cwd: 'C:\\workspace\\app' }, + { session_id: 'session-a', model: 'grok-composer-2.5-fast', input_tokens: 150, cwd: 'C:\\workspace\\app' }, + { session_id: 'session-b', model: 'grok-3', input_tokens: 50, cwd: '/workspace/other' }, + ]) + + const second = await runParse(cacheDb, 'grok') + expect(second.errors).toEqual([]) + expect(second.parsedCount).toBe(0) + expect(cacheDb.prepare("SELECT COUNT(*) AS count FROM records WHERE tool = 'grok'").get()).toEqual({ count: 3 }) + }) +}) From 72285c6ae12a3adb3669a248ecf3b22156ee20f4 Mon Sep 17 00:00:00 2001 From: Julian <1041324235@qq.com> Date: Mon, 13 Jul 2026 17:38:45 +0800 Subject: [PATCH 6/6] fix: count only Grok usage logs --- packages/cli/src/discovery.ts | 2 ++ packages/cli/tests/discovery.test.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/discovery.ts b/packages/cli/src/discovery.ts index ce8c756..2d3bfab 100644 --- a/packages/cli/src/discovery.ts +++ b/packages/cli/src/discovery.ts @@ -773,6 +773,8 @@ export function discoverTools(env: NodeJS.ProcessEnv = process.env): DetectedToo + findZipFiles(detectedPath).length } else if (entry.sourceKey === 'kiro') { fileCount += unique([...findJsonlFiles(detectedPath), ...findJsonFiles(detectedPath)]).length + } else if (entry.sourceKey === 'grok') { + fileCount += findJsonlFiles(detectedPath).filter((p) => basename(p) === 'updates.jsonl').length } else { fileCount += findJsonlFiles(detectedPath).length } diff --git a/packages/cli/tests/discovery.test.ts b/packages/cli/tests/discovery.test.ts index b230b54..d0c7a23 100644 --- a/packages/cli/tests/discovery.test.ts +++ b/packages/cli/tests/discovery.test.ts @@ -144,7 +144,9 @@ describe('discovery path resolution', () => { const { discoverLogFiles, discoverTools } = await loadDiscovery({ home, platform: 'win32' }) - expect(discoverTools().find((tool) => tool.sourceKey === 'grok')?.status).toBe('found') + const detected = discoverTools().find((tool) => tool.sourceKey === 'grok') + expect(detected?.status).toBe('found') + expect(detected?.fileCount).toBe(1) expect(discoverLogFiles().find((result) => result.tool === 'grok')?.paths).toEqual([updatesPath]) })