context-mode 1.0.166 → 1.0.167

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 (52) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  5. package/.openclaw-plugin/package.json +1 -1
  6. package/README.md +6 -4
  7. package/build/adapters/codex/usage.d.ts +107 -0
  8. package/build/adapters/codex/usage.js +227 -0
  9. package/build/adapters/gemini-cli/hooks.d.ts +7 -1
  10. package/build/adapters/gemini-cli/hooks.js +9 -1
  11. package/build/adapters/gemini-cli/index.js +11 -0
  12. package/build/adapters/kimi/paths.d.ts +20 -0
  13. package/build/adapters/kimi/paths.js +41 -1
  14. package/build/adapters/kimi/usage.d.ts +82 -0
  15. package/build/adapters/kimi/usage.js +217 -0
  16. package/build/adapters/omp/plugin.d.ts +6 -0
  17. package/build/adapters/omp/plugin.js +87 -2
  18. package/build/adapters/omp/usage.d.ts +49 -0
  19. package/build/adapters/omp/usage.js +110 -0
  20. package/build/adapters/openclaw/plugin.d.ts +10 -0
  21. package/build/adapters/openclaw/plugin.js +57 -0
  22. package/build/adapters/openclaw/usage.d.ts +34 -0
  23. package/build/adapters/openclaw/usage.js +52 -0
  24. package/build/adapters/opencode/plugin.d.ts +17 -0
  25. package/build/adapters/opencode/plugin.js +40 -1
  26. package/build/adapters/pi/extension.js +34 -1
  27. package/build/adapters/qwen-code/index.js +23 -1
  28. package/build/adapters/qwen-code/usage.d.ts +90 -0
  29. package/build/adapters/qwen-code/usage.js +222 -0
  30. package/build/session/db.d.ts +11 -0
  31. package/build/session/db.js +33 -0
  32. package/build/session/extract.d.ts +208 -0
  33. package/build/session/extract.js +670 -43
  34. package/build/session/model-prices.json +429 -0
  35. package/build/session/pricing.d.ts +64 -0
  36. package/build/session/pricing.js +151 -0
  37. package/cli.bundle.mjs +62 -62
  38. package/configs/antigravity-cli/plugin.json +1 -1
  39. package/configs/copilot-cli/.github/plugin/plugin.json +1 -1
  40. package/configs/gemini-cli/settings.json +11 -0
  41. package/hooks/codex/stop.mjs +91 -4
  42. package/hooks/gemini-cli/aftermodel.mjs +70 -0
  43. package/hooks/kimi/stop.mjs +74 -3
  44. package/hooks/qwen-code/platform.mjs +1 -0
  45. package/hooks/qwen-code/stop.mjs +168 -0
  46. package/hooks/session-db.bundle.mjs +7 -7
  47. package/hooks/session-extract.bundle.mjs +3 -2
  48. package/hooks/session-loaders.mjs +9 -1
  49. package/hooks/stop.mjs +35 -2
  50. package/openclaw.plugin.json +1 -1
  51. package/package.json +1 -1
  52. package/server.bundle.mjs +90 -90
@@ -0,0 +1,52 @@
1
+ /**
2
+ * adapters/openclaw/usage — per-turn token + cost capture handler.
3
+ *
4
+ * openclaw emits a first-class `model.usage` diagnostic event once per turn
5
+ * (`DiagnosticUsageEvent`, refs/platforms/openclaw/src/infra/diagnostic-events.ts:18-47),
6
+ * carrying the full usage breakdown {input, output, cacheRead, cacheWrite} plus
7
+ * a PRE-COMPUTED `costUsd` (estimateUsageCost, agent-runner.ts:1995). Consumers
8
+ * subscribe via `onDiagnosticEvent(listener)` (diagnostic-events.ts:1156) — the
9
+ * exact bus the first-party diagnostics-otel / diagnostics-prometheus extensions
10
+ * read.
11
+ *
12
+ * This module is the parse→build→insert handler the plugin's diagnostic-event
13
+ * listener invokes. It is deliberately decoupled from the openclaw plugin SDK so
14
+ * it stays unit-testable: the caller passes the raw payload and an `insert`
15
+ * callback (the plugin hands it `db.insertEvent`-bound-to-sessionId). The handler
16
+ * never throws — a usage-capture failure must never break the agent turn.
17
+ *
18
+ * Capture surface: the diagnostic-event bus, NOT the tool-call hook. The native
19
+ * before_tool_call / after_tool_call relay carries only approval/policy data and
20
+ * NO token usage (matrix §4) — so usage cannot be captured from after_tool_call.
21
+ */
22
+ import { parseOpenclawUsage, buildAgentUsageEvent } from "../../session/extract.js";
23
+ /**
24
+ * Handle one openclaw `model.usage` diagnostic payload: parse the per-turn usage
25
+ * (NOT lastCallUsage), build the structured `agent_usage` event with openclaw's
26
+ * native `costUsd` (preferred over the pricing catalog), and insert it.
27
+ *
28
+ * Returns the inserted event (for tests / callers that want to forward) or null
29
+ * when the payload is not a usage event, carries no usage, or sums to zero.
30
+ * Best-effort: swallows any insert failure.
31
+ */
32
+ export function handleOpenclawUsageEvent(payload, insert) {
33
+ // parseOpenclawUsage maps cacheWrite→cache_creation_tokens,
34
+ // cacheRead→cache_read_tokens, costUsd→native_cost_usd, and reads ONLY the
35
+ // per-turn `usage` total — never the lastCallUsage delta.
36
+ const counts = parseOpenclawUsage(payload);
37
+ if (!counts)
38
+ return null;
39
+ // native_cost_usd (openclaw's pre-computed costUsd) is preferred over the
40
+ // catalog inside buildAgentUsageEvent.
41
+ const event = buildAgentUsageEvent(counts);
42
+ if (!event)
43
+ return null;
44
+ try {
45
+ insert(event);
46
+ }
47
+ catch {
48
+ // Usage capture must never break the agent turn.
49
+ return null;
50
+ }
51
+ return event;
52
+ }
@@ -88,6 +88,22 @@ interface AfterHookOutput {
88
88
  output: string;
89
89
  metadata: any;
90
90
  }
91
+ /**
92
+ * OpenCode generic bus `event` hook — single parameter.
93
+ * The plugin SDK delivers every bus Event here (refs/platforms/opencode/
94
+ * packages/plugin/src/index.ts:224). We narrow to `message.updated`, whose
95
+ * `properties.info` is the full assistant Message carrying tokens/cost/modelID.
96
+ */
97
+ interface EventHookInput {
98
+ event?: {
99
+ type?: string;
100
+ properties?: {
101
+ info?: {
102
+ sessionID?: string;
103
+ } & Record<string, unknown>;
104
+ };
105
+ };
106
+ }
91
107
  /** OpenCode experimental.session.compacting — first parameter */
92
108
  interface CompactingHookInput {
93
109
  sessionID: string;
@@ -154,6 +170,7 @@ declare function createContextModePlugin(ctx: PluginContext): Promise<{
154
170
  tool: Record<string, NativeToolDefinition>;
155
171
  "tool.execute.before": (input: BeforeHookInput, output: BeforeHookOutput) => Promise<void>;
156
172
  "tool.execute.after": (input: AfterHookInput, output: AfterHookOutput) => Promise<void>;
173
+ event: (input: EventHookInput) => Promise<void>;
157
174
  "chat.message": (input: ChatMessageHookInput, output: ChatMessageHookOutput) => Promise<void>;
158
175
  "experimental.session.compacting": (input: CompactingHookInput, output: CompactingHookOutput) => Promise<string>;
159
176
  "experimental.chat.system.transform": (input: SystemTransformHookInput, output: SystemTransformHookOutput) => Promise<void>;
@@ -24,7 +24,7 @@ import { dirname, resolve, join } from "node:path";
24
24
  import { fileURLToPath, pathToFileURL } from "node:url";
25
25
  import { existsSync, readFileSync } from "node:fs";
26
26
  import { resolveSessionDbPath, SessionDB } from "../../session/db.js";
27
- import { extractEvents, extractUserEvents } from "../../session/extract.js";
27
+ import { extractEvents, extractUserEvents, parseOpencodeUsage, buildAgentUsageEvent } from "../../session/extract.js";
28
28
  import { buildResumeSnapshot } from "../../session/snapshot.js";
29
29
  import { OpenCodeAdapter } from "./index.js";
30
30
  import { PLATFORM_ENV_VARS } from "../detect.js";
@@ -346,6 +346,45 @@ async function createContextModePlugin(ctx) {
346
346
  // Silent — session capture must never break the tool call
347
347
  }
348
348
  },
349
+ // ── event: per-turn token + cost capture (paid-observability) ───
350
+ // The generic bus `event` hook (refs/platforms/opencode/packages/plugin/
351
+ // src/index.ts:224) delivers every Event; we filter `message.updated`
352
+ // (published on each assistant-message update incl. step-finish —
353
+ // session.ts:673) and read tokens/cost/modelID off properties.info
354
+ // (assistant filter via role; refs stream.transport.ts:214-216).
355
+ //
356
+ // CAVEAT (refs processor.ts:717-718): message-level `.tokens` is the LAST
357
+ // step's snapshot (overwritten per step-finish), while `.cost` is
358
+ // cumulative for the turn. parseOpencodeUsage passes `.cost` through as
359
+ // native_cost_usd so the billed $ stays exact despite the token snapshot
360
+ // being last-step only. `message.updated` fires multiple times per turn;
361
+ // because tokens are a terminal snapshot and cost is cumulative, the last
362
+ // event for a message carries the final figures — re-emitting on each
363
+ // update is idempotent at the cost column and merely refreshes the
364
+ // last-step token telemetry. db.insertEvent both persists locally AND
365
+ // forwards to the platform (the TS-plugin equivalent of the .mjs
366
+ // attributeAndInsertEvents path).
367
+ event: async (input) => {
368
+ try {
369
+ const ev = input?.event;
370
+ if (!ev || ev.type !== "message.updated")
371
+ return;
372
+ const sessionId = ev.properties?.info?.sessionID;
373
+ if (!sessionId || typeof sessionId !== "string")
374
+ return;
375
+ const counts = parseOpencodeUsage(ev);
376
+ if (!counts)
377
+ return;
378
+ const usageEvent = buildAgentUsageEvent(counts);
379
+ if (!usageEvent)
380
+ return;
381
+ db.ensureSession(sessionId, projectDir);
382
+ db.insertEvent(sessionId, usageEvent, "MessageUpdated");
383
+ }
384
+ catch {
385
+ // Silent — usage capture must never break the session.
386
+ }
387
+ },
349
388
  // ── chat.message: User-prompt capture (OC-2 / Z2) ───
350
389
  // SDK signature verified at refs/platforms/opencode/packages/plugin/src/
351
390
  // index.ts:233. Orchestrator reference at refs/plugin-examples/opencode/
@@ -16,7 +16,7 @@ import { homedir } from "node:os";
16
16
  import { join, resolve, dirname } from "node:path";
17
17
  import { fileURLToPath, pathToFileURL } from "node:url";
18
18
  import { resolveSessionDbPath, SessionDB } from "../../session/db.js";
19
- import { extractEvents, extractUserEvents } from "../../session/extract.js";
19
+ import { extractEvents, extractUserEvents, parsePiUsage, buildAgentUsageEvent } from "../../session/extract.js";
20
20
  import { buildResumeSnapshot } from "../../session/snapshot.js";
21
21
  import { bootstrapMCPTools, makeBridgeDiag, isForegroundSession } from "./mcp-bridge.js";
22
22
  import { PiAdapter } from "./index.js";
@@ -705,6 +705,39 @@ export default function piExtension(pi) {
705
705
  // best effort — never break provider response
706
706
  }
707
707
  });
708
+ // ── 4c. turn_end — per-turn token + native-USD cost capture ───
709
+ //
710
+ // Pi delivers per-turn usage on TurnEndEvent.message (an AssistantMessage):
711
+ // usage.{input,output,cacheRead,cacheWrite} + native usage.cost.total in USD,
712
+ // with model on .model. Usage is per-turn incremental, so each turn_end maps
713
+ // to exactly one structured `agent_usage` (category "cost") event — the same
714
+ // shape the Claude Code Stop path emits via buildAgentUsageEvent. We pass
715
+ // Pi's native cost as native_cost_usd so the builder trusts the source over
716
+ // the local price table (cost_confidence: HIGH — no price-table maintenance).
717
+ //
718
+ // Refs: adapter-matrix/pi.md @320261f — shared-events.ts:204-209 (TurnEndEvent),
719
+ // ai/src/types.ts:510/521 (model/usage), catalog/src/types.ts:100-145 (Usage).
720
+ // Best-effort: parse is null-safe and the handler never throws (a telemetry
721
+ // forwarder must never break the agent turn).
722
+ pi.on("turn_end", (event) => {
723
+ try {
724
+ if (!_sessionId)
725
+ return;
726
+ const counts = parsePiUsage(event);
727
+ if (!counts)
728
+ return; // non-assistant turn or all-zero usage
729
+ const ev = buildAgentUsageEvent(counts);
730
+ if (!ev)
731
+ return;
732
+ // db.insertEvent is the extension-side analog of the .mjs hooks'
733
+ // attributeAndInsertEvents (insert + project attribution). The MCP
734
+ // server forwards persisted agent_usage events to the platform.
735
+ db.insertEvent(_sessionId, ev, "Stop", _attribution);
736
+ }
737
+ catch {
738
+ // best effort — never break the agent turn
739
+ }
740
+ });
708
741
  // ── 5. session_before_compact — Build resume snapshot ──
709
742
  pi.on("session_before_compact", () => {
710
743
  try {
@@ -101,6 +101,19 @@ export class QwenCodeAdapter extends ClaudeCodeBaseAdapter {
101
101
  ],
102
102
  },
103
103
  ],
104
+ // Stop fires at end-of-turn. The qwen-specific stop hook records a
105
+ // turn_end marker AND captures per-turn token cost by tailing the session
106
+ // chats JSONL (~/.qwen/tmp/<hash>/chats/<sessionId>.jsonl) — usage is not
107
+ // reachable through hook stdin (usage.ts matrix §4). Points at the
108
+ // qwen-code/ hook dir (not the shared root) so it sets the qwen platform.
109
+ Stop: [
110
+ {
111
+ matcher: "",
112
+ hooks: [
113
+ { type: "command", command: buildHookRuntimeCommand(`${pluginRoot}/hooks/qwen-code/stop.mjs`) },
114
+ ],
115
+ },
116
+ ],
104
117
  };
105
118
  }
106
119
  // ── Settings read/write ────────────────────────────────
@@ -126,7 +139,7 @@ export class QwenCodeAdapter extends ClaudeCodeBaseAdapter {
126
139
  const results = [];
127
140
  const settings = this.readSettings();
128
141
  const hooks = (settings?.hooks ?? {});
129
- for (const hookName of ["PreToolUse", "PostToolUse", "SessionStart", "PreCompact", "UserPromptSubmit"]) {
142
+ for (const hookName of ["PreToolUse", "PostToolUse", "SessionStart", "PreCompact", "UserPromptSubmit", "Stop"]) {
130
143
  const configured = Array.isArray(hooks[hookName]) && hooks[hookName].length > 0;
131
144
  results.push({
132
145
  check: `${hookName} hook`,
@@ -275,6 +288,15 @@ export class QwenCodeAdapter extends ClaudeCodeBaseAdapter {
275
288
  script: "userpromptsubmit.mjs",
276
289
  matcher: "",
277
290
  },
291
+ {
292
+ // Stop captures per-turn token cost by tailing the session chats JSONL
293
+ // (usage is unreachable through hook stdin). Routes to the qwen-code/
294
+ // hook dir so it sets the qwen platform — keep in sync with
295
+ // generateHookConfig above.
296
+ name: "Stop",
297
+ script: "qwen-code/stop.mjs",
298
+ matcher: "",
299
+ },
278
300
  ];
279
301
  for (const { name, script, matcher } of hookTypes) {
280
302
  const entry = {
@@ -0,0 +1,90 @@
1
+ /**
2
+ * adapters/qwen-code/usage — per-turn token capture from the session JSONL.
3
+ *
4
+ * Qwen Code is a Gemini-CLI fork and normalizes EVERY backend (Gemini-native,
5
+ * OpenAI-compat/DashScope, Anthropic) to the same canonical token shape:
6
+ * `GenerateContentResponseUsageMetadata` { promptTokenCount, candidatesTokenCount,
7
+ * cachedContentTokenCount, thoughtsTokenCount, totalTokenCount }
8
+ * (matrix §1: turn.ts:96,417 + converter.ts:1145-1148). That metadata is
9
+ * persisted, per API call, into the session record file as a `ChatRecord`
10
+ * carrying `.usageMetadata` + `.model`
11
+ * (refs: packages/core/src/services/chatRecordingService.ts:259,261,919 file at
12
+ * ~/.qwen/tmp/<project_id>/chats/<sessionId>.jsonl — :451 location comment,
13
+ * :600,628-629 path build).
14
+ *
15
+ * CRITICAL (matrix §4): qwen-code's hook payloads carry tool I/O ONLY — token
16
+ * usage is unreachable through the hook stream (grep of hookEventHandler.ts /
17
+ * hookSystem.ts / toolHookTriggers.ts for token|usageMetadata|usage → zero
18
+ * matches). The ONLY live capture path is a tail of the session JSONL. This
19
+ * module is therefore the JSONL-tail counterpart to claude-code's
20
+ * `extractTranscriptUsageSince` (src/session/extract.ts) — same cursor-gated,
21
+ * char-algorithmic, NO-regex parse, same `buildAgentUsageEvent` emission path.
22
+ *
23
+ * Per matrix §3 each ChatRecord.usageMetadata is INCREMENTAL per API call
24
+ * (cumulative session totals are derived downstream via += in
25
+ * uiTelemetry.ts:237-241), so summing the NEW records since the cursor yields
26
+ * the exact billed delta with no double-count.
27
+ *
28
+ * No native USD — cost_usd is derived from the pricing catalog inside
29
+ * buildAgentUsageEvent (native_cost_usd omitted). Pure, null-safe, NO regex.
30
+ */
31
+ import { type AgentUsageCounts, type SessionEvent } from "../../session/extract.js";
32
+ /**
33
+ * Parse ONE qwen `ChatRecord` into the `buildAgentUsageEvent` input shape, or
34
+ * null when the record carries no usage / sums to zero.
35
+ *
36
+ * Mapping → builder shape (AgentUsageCounts):
37
+ * promptTokenCount → input_tokens
38
+ * candidatesTokenCount → output_tokens
39
+ * thoughtsTokenCount → ADDED into output_tokens (Gemini-lineage bills
40
+ * reasoning/thoughts as output — same fold as
41
+ * parseGeminiUsage in src/session/extract.ts)
42
+ * cachedContentTokenCount → cache_read_tokens (when present)
43
+ * model_id → ChatRecord.model
44
+ *
45
+ * No native cost — native_cost_usd omitted (catalog-derived). NO regex.
46
+ */
47
+ export declare function parseQwenUsage(record: unknown): AgentUsageCounts | null;
48
+ /**
49
+ * Cursor-aware tail of the qwen session JSONL. Emits one priced `agent_usage`
50
+ * event PER distinct model across the records NEW since `cursor`, so re-reading
51
+ * the (append-only, ever-growing) JSONL each Stop never double-counts.
52
+ *
53
+ * - cursor null/empty → process ALL records.
54
+ * - cursor found → process records STRICTLY AFTER it.
55
+ * - cursor set but NOT found → compaction/rotation dropped it: bounded
56
+ * fallback processes ONLY THE LAST record (never re-emit full history).
57
+ *
58
+ * `cursor` returns the id of the LAST id-bearing record seen (whether or not it
59
+ * carried usage), so the next call resumes exactly past it. When no record
60
+ * carries an id, the input cursor is returned unchanged.
61
+ *
62
+ * One linear walk, JSON.parse per line, NO regex — mirrors
63
+ * extractTranscriptUsageSince's structure exactly.
64
+ */
65
+ export declare function extractQwenUsageSince(jsonlText: string, cursor: string | null): {
66
+ events: SessionEvent[];
67
+ cursor: string | null;
68
+ };
69
+ /**
70
+ * Hash a project root into qwen-code's `<project_id>` directory segment.
71
+ *
72
+ * EXACT port of qwen's `getProjectHash`
73
+ * (refs/platforms/qwen-code/packages/core/src/utils/paths.ts:262 —
74
+ * `crypto.createHash('sha256').update(normalizedPath).digest('hex')`). On
75
+ * Windows qwen lowercases the path first (case-insensitive FS); we mirror that
76
+ * so a hook running on win32 resolves the same tmp dir qwen itself wrote.
77
+ * Pure, deterministic, NO regex.
78
+ */
79
+ export declare function qwenProjectHash(projectRoot: string): string;
80
+ /**
81
+ * Build the canonical session JSONL path qwen-code writes its ChatRecords to:
82
+ * <qwenHome>/tmp/<sha256(projectRoot)>/chats/<sessionId>.jsonl
83
+ * (refs chatRecordingService.ts:451 location + storage.ts:316-320
84
+ * getProjectTempDir → getGlobalTempDir(<qwenHome>/tmp) + getProjectHash).
85
+ *
86
+ * `qwenHome` is normally `<homedir>/.qwen`. Pure path join — does NOT touch the
87
+ * FS, so it is fully unit-testable; existence probing + the glob fallback live
88
+ * in the Stop hook (which cannot import this TS at runtime). NO regex.
89
+ */
90
+ export declare function qwenChatJsonlPath(qwenHome: string, projectRoot: string, sessionId: string): string;
@@ -0,0 +1,222 @@
1
+ /**
2
+ * adapters/qwen-code/usage — per-turn token capture from the session JSONL.
3
+ *
4
+ * Qwen Code is a Gemini-CLI fork and normalizes EVERY backend (Gemini-native,
5
+ * OpenAI-compat/DashScope, Anthropic) to the same canonical token shape:
6
+ * `GenerateContentResponseUsageMetadata` { promptTokenCount, candidatesTokenCount,
7
+ * cachedContentTokenCount, thoughtsTokenCount, totalTokenCount }
8
+ * (matrix §1: turn.ts:96,417 + converter.ts:1145-1148). That metadata is
9
+ * persisted, per API call, into the session record file as a `ChatRecord`
10
+ * carrying `.usageMetadata` + `.model`
11
+ * (refs: packages/core/src/services/chatRecordingService.ts:259,261,919 file at
12
+ * ~/.qwen/tmp/<project_id>/chats/<sessionId>.jsonl — :451 location comment,
13
+ * :600,628-629 path build).
14
+ *
15
+ * CRITICAL (matrix §4): qwen-code's hook payloads carry tool I/O ONLY — token
16
+ * usage is unreachable through the hook stream (grep of hookEventHandler.ts /
17
+ * hookSystem.ts / toolHookTriggers.ts for token|usageMetadata|usage → zero
18
+ * matches). The ONLY live capture path is a tail of the session JSONL. This
19
+ * module is therefore the JSONL-tail counterpart to claude-code's
20
+ * `extractTranscriptUsageSince` (src/session/extract.ts) — same cursor-gated,
21
+ * char-algorithmic, NO-regex parse, same `buildAgentUsageEvent` emission path.
22
+ *
23
+ * Per matrix §3 each ChatRecord.usageMetadata is INCREMENTAL per API call
24
+ * (cumulative session totals are derived downstream via += in
25
+ * uiTelemetry.ts:237-241), so summing the NEW records since the cursor yields
26
+ * the exact billed delta with no double-count.
27
+ *
28
+ * No native USD — cost_usd is derived from the pricing catalog inside
29
+ * buildAgentUsageEvent (native_cost_usd omitted). Pure, null-safe, NO regex.
30
+ */
31
+ import { createHash } from "node:crypto";
32
+ import { join } from "node:path";
33
+ import { platform } from "node:os";
34
+ import { buildAgentUsageEvent } from "../../session/extract.js";
35
+ /** Floor-and-clamp a token field to a non-negative integer (mirrors omp/usage). */
36
+ function tokenNum(v) {
37
+ if (typeof v !== "number" || !Number.isFinite(v))
38
+ return 0;
39
+ const n = Math.floor(v);
40
+ return n > 0 ? n : 0;
41
+ }
42
+ /**
43
+ * Parse ONE qwen `ChatRecord` into the `buildAgentUsageEvent` input shape, or
44
+ * null when the record carries no usage / sums to zero.
45
+ *
46
+ * Mapping → builder shape (AgentUsageCounts):
47
+ * promptTokenCount → input_tokens
48
+ * candidatesTokenCount → output_tokens
49
+ * thoughtsTokenCount → ADDED into output_tokens (Gemini-lineage bills
50
+ * reasoning/thoughts as output — same fold as
51
+ * parseGeminiUsage in src/session/extract.ts)
52
+ * cachedContentTokenCount → cache_read_tokens (when present)
53
+ * model_id → ChatRecord.model
54
+ *
55
+ * No native cost — native_cost_usd omitted (catalog-derived). NO regex.
56
+ */
57
+ export function parseQwenUsage(record) {
58
+ if (!record || typeof record !== "object" || Array.isArray(record))
59
+ return null;
60
+ const rec = record;
61
+ const um = rec.usageMetadata;
62
+ if (!um || typeof um !== "object")
63
+ return null;
64
+ const usage = um;
65
+ const input = tokenNum(usage.promptTokenCount);
66
+ const candidates = tokenNum(usage.candidatesTokenCount);
67
+ const thoughts = tokenNum(usage.thoughtsTokenCount);
68
+ const cached = tokenNum(usage.cachedContentTokenCount);
69
+ // Gemini-lineage bills reasoning (thoughts) as output tokens — fold into output.
70
+ const output = candidates + thoughts;
71
+ // All token fields zero → not a billable record. buildAgentUsageEvent would
72
+ // also reject this, but short-circuit keeps the contract explicit.
73
+ if (input <= 0 && output <= 0 && cached <= 0)
74
+ return null;
75
+ const model_id = typeof rec.model === "string" ? rec.model : "";
76
+ return {
77
+ model_id,
78
+ input_tokens: input,
79
+ output_tokens: output,
80
+ cache_creation_tokens: 0, // qwen exposes no cache-creation field
81
+ cache_read_tokens: cached,
82
+ native_cost_usd: null, // catalog-derived (no native cost on qwen records)
83
+ };
84
+ }
85
+ /** Stable cursor identity for a ChatRecord: prefer `id`, fall back to `messageId`. */
86
+ function recordId(rec) {
87
+ if (typeof rec.id === "string" && rec.id.length > 0)
88
+ return rec.id;
89
+ if (typeof rec.messageId === "string" && rec.messageId.length > 0)
90
+ return rec.messageId;
91
+ return null;
92
+ }
93
+ /**
94
+ * Cursor-aware tail of the qwen session JSONL. Emits one priced `agent_usage`
95
+ * event PER distinct model across the records NEW since `cursor`, so re-reading
96
+ * the (append-only, ever-growing) JSONL each Stop never double-counts.
97
+ *
98
+ * - cursor null/empty → process ALL records.
99
+ * - cursor found → process records STRICTLY AFTER it.
100
+ * - cursor set but NOT found → compaction/rotation dropped it: bounded
101
+ * fallback processes ONLY THE LAST record (never re-emit full history).
102
+ *
103
+ * `cursor` returns the id of the LAST id-bearing record seen (whether or not it
104
+ * carried usage), so the next call resumes exactly past it. When no record
105
+ * carries an id, the input cursor is returned unchanged.
106
+ *
107
+ * One linear walk, JSON.parse per line, NO regex — mirrors
108
+ * extractTranscriptUsageSince's structure exactly.
109
+ */
110
+ export function extractQwenUsageSince(jsonlText, cursor) {
111
+ const inputCursor = typeof cursor === "string" && cursor.length > 0 ? cursor : null;
112
+ if (typeof jsonlText !== "string" || jsonlText.length === 0) {
113
+ return { events: [], cursor: inputCursor };
114
+ }
115
+ const rows = [];
116
+ let start = 0;
117
+ for (let i = 0; i <= jsonlText.length; i++) {
118
+ if (i !== jsonlText.length && jsonlText.charCodeAt(i) !== 10 /* \n */)
119
+ continue;
120
+ const line = jsonlText.slice(start, i).trim();
121
+ start = i + 1;
122
+ if (line.length === 0)
123
+ continue;
124
+ let obj;
125
+ try {
126
+ const p = JSON.parse(line);
127
+ if (!p || typeof p !== "object" || Array.isArray(p))
128
+ continue;
129
+ obj = p;
130
+ }
131
+ catch {
132
+ continue;
133
+ }
134
+ rows.push({ id: recordId(obj), counts: parseQwenUsage(obj) });
135
+ }
136
+ if (rows.length === 0)
137
+ return { events: [], cursor: inputCursor };
138
+ // Cursor always advances to the last id-bearing record's id (or stays as the
139
+ // input cursor when no record carries an id).
140
+ let lastId = inputCursor;
141
+ for (let i = rows.length - 1; i >= 0; i--) {
142
+ if (rows[i].id !== null) {
143
+ lastId = rows[i].id;
144
+ break;
145
+ }
146
+ }
147
+ // Select the slice to sum.
148
+ let slice;
149
+ if (inputCursor === null) {
150
+ slice = rows; // all records
151
+ }
152
+ else {
153
+ let foundAt = -1;
154
+ for (let i = 0; i < rows.length; i++) {
155
+ if (rows[i].id === inputCursor) {
156
+ foundAt = i;
157
+ break;
158
+ }
159
+ }
160
+ if (foundAt >= 0) {
161
+ slice = rows.slice(foundAt + 1); // strictly after the cursor
162
+ }
163
+ else {
164
+ // Compaction/rotation: cursor fell off the front. Bounded fallback — last
165
+ // record only. Never re-emit the whole history.
166
+ slice = rows.slice(rows.length - 1);
167
+ }
168
+ }
169
+ // Sum the selected records per model, then emit via the shared builder.
170
+ const sums = new Map();
171
+ for (const row of slice) {
172
+ const c = row.counts;
173
+ if (!c)
174
+ continue;
175
+ const cur = sums.get(c.model_id) ?? { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 };
176
+ cur.input += c.input_tokens;
177
+ cur.output += c.output_tokens;
178
+ cur.cacheCreate += c.cache_creation_tokens;
179
+ cur.cacheRead += c.cache_read_tokens;
180
+ sums.set(c.model_id, cur);
181
+ }
182
+ const events = [];
183
+ for (const [model_id, s] of sums) {
184
+ const ev = buildAgentUsageEvent({
185
+ model_id,
186
+ input_tokens: s.input,
187
+ output_tokens: s.output,
188
+ cache_creation_tokens: s.cacheCreate,
189
+ cache_read_tokens: s.cacheRead,
190
+ });
191
+ if (ev)
192
+ events.push(ev);
193
+ }
194
+ return { events, cursor: lastId };
195
+ }
196
+ /**
197
+ * Hash a project root into qwen-code's `<project_id>` directory segment.
198
+ *
199
+ * EXACT port of qwen's `getProjectHash`
200
+ * (refs/platforms/qwen-code/packages/core/src/utils/paths.ts:262 —
201
+ * `crypto.createHash('sha256').update(normalizedPath).digest('hex')`). On
202
+ * Windows qwen lowercases the path first (case-insensitive FS); we mirror that
203
+ * so a hook running on win32 resolves the same tmp dir qwen itself wrote.
204
+ * Pure, deterministic, NO regex.
205
+ */
206
+ export function qwenProjectHash(projectRoot) {
207
+ const normalized = platform() === "win32" ? projectRoot.toLowerCase() : projectRoot;
208
+ return createHash("sha256").update(normalized).digest("hex");
209
+ }
210
+ /**
211
+ * Build the canonical session JSONL path qwen-code writes its ChatRecords to:
212
+ * <qwenHome>/tmp/<sha256(projectRoot)>/chats/<sessionId>.jsonl
213
+ * (refs chatRecordingService.ts:451 location + storage.ts:316-320
214
+ * getProjectTempDir → getGlobalTempDir(<qwenHome>/tmp) + getProjectHash).
215
+ *
216
+ * `qwenHome` is normally `<homedir>/.qwen`. Pure path join — does NOT touch the
217
+ * FS, so it is fully unit-testable; existence probing + the glob fallback live
218
+ * in the Stop hook (which cannot import this TS at runtime). NO regex.
219
+ */
220
+ export function qwenChatJsonlPath(qwenHome, projectRoot, sessionId) {
221
+ return join(qwenHome, "tmp", qwenProjectHash(projectRoot), "chats", `${sessionId}.jsonl`);
222
+ }
@@ -380,6 +380,17 @@ export declare class SessionDB extends SQLiteBase {
380
380
  * Increment the compact_count for a session (tracks snapshot rebuilds).
381
381
  */
382
382
  incrementCompactCount(sessionId: string): void;
383
+ /**
384
+ * Read the per-session usage high-water cursor — the uuid of the last
385
+ * assistant turn already emitted by the Stop hook's main-turn capture.
386
+ * Returns null when unset (first Stop) or the session row is absent.
387
+ */
388
+ getUsageCursor(sessionId: string): string | null;
389
+ /**
390
+ * Advance the per-session usage high-water cursor to `uuid`. No-op when the
391
+ * session_meta row does not exist yet (callers ensureSession first).
392
+ */
393
+ setUsageCursor(sessionId: string, uuid: string): void;
383
394
  /**
384
395
  * Upsert a resume snapshot for a session. Resets consumed flag on update.
385
396
  */
@@ -481,6 +481,8 @@ const S = {
481
481
  getMaxFileEdits: "getMaxFileEdits",
482
482
  getLatestCommitMessage: "getLatestCommitMessage",
483
483
  incrementCompactCount: "incrementCompactCount",
484
+ getUsageCursor: "getUsageCursor",
485
+ setUsageCursor: "setUsageCursor",
484
486
  upsertResume: "upsertResume",
485
487
  getResume: "getResume",
486
488
  markResumeConsumed: "markResumeConsumed",
@@ -662,6 +664,19 @@ export class SessionDB extends SQLiteBase {
662
664
  catch {
663
665
  // best-effort migration only
664
666
  }
667
+ // Migration: per-session usage high-water cursor for the Stop hook's
668
+ // cursor-aware main-turn capture (extractTranscriptUsageSince). Stores the
669
+ // uuid of the last assistant turn already emitted so the next Stop forwards
670
+ // only NEW spend. Idempotent — guarded by a table_xinfo column check.
671
+ try {
672
+ const metaCols = this.db.pragma("table_xinfo(session_meta)");
673
+ if (!metaCols.some((c) => c.name === "usage_cursor")) {
674
+ this.db.exec("ALTER TABLE session_meta ADD COLUMN usage_cursor TEXT");
675
+ }
676
+ }
677
+ catch {
678
+ // best-effort migration only
679
+ }
665
680
  }
666
681
  prepareStatements() {
667
682
  this.stmts = new Map();
@@ -759,6 +774,8 @@ export class SessionDB extends SQLiteBase {
759
774
  ORDER BY id DESC
760
775
  LIMIT 1`);
761
776
  p(S.incrementCompactCount, `UPDATE session_meta SET compact_count = compact_count + 1 WHERE session_id = ?`);
777
+ p(S.getUsageCursor, `SELECT usage_cursor FROM session_meta WHERE session_id = ?`);
778
+ p(S.setUsageCursor, `UPDATE session_meta SET usage_cursor = ? WHERE session_id = ?`);
762
779
  // ── Resume ──
763
780
  p(S.upsertResume, `INSERT INTO session_resume (session_id, snapshot, event_count)
764
781
  VALUES (?, ?, ?)
@@ -1127,6 +1144,22 @@ export class SessionDB extends SQLiteBase {
1127
1144
  incrementCompactCount(sessionId) {
1128
1145
  this.stmt(S.incrementCompactCount).run(sessionId);
1129
1146
  }
1147
+ /**
1148
+ * Read the per-session usage high-water cursor — the uuid of the last
1149
+ * assistant turn already emitted by the Stop hook's main-turn capture.
1150
+ * Returns null when unset (first Stop) or the session row is absent.
1151
+ */
1152
+ getUsageCursor(sessionId) {
1153
+ const row = this.stmt(S.getUsageCursor).get(sessionId);
1154
+ return row?.usage_cursor ?? null;
1155
+ }
1156
+ /**
1157
+ * Advance the per-session usage high-water cursor to `uuid`. No-op when the
1158
+ * session_meta row does not exist yet (callers ensureSession first).
1159
+ */
1160
+ setUsageCursor(sessionId, uuid) {
1161
+ this.stmt(S.setUsageCursor).run(uuid, sessionId);
1162
+ }
1130
1163
  // ═══════════════════════════════════════════
1131
1164
  // Resume
1132
1165
  // ═══════════════════════════════════════════