context-mode 1.0.166 → 1.0.168
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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/README.md +6 -4
- package/build/adapters/codex/usage.d.ts +107 -0
- package/build/adapters/codex/usage.js +227 -0
- package/build/adapters/gemini-cli/hooks.d.ts +7 -1
- package/build/adapters/gemini-cli/hooks.js +9 -1
- package/build/adapters/gemini-cli/index.js +11 -0
- package/build/adapters/kimi/paths.d.ts +20 -0
- package/build/adapters/kimi/paths.js +41 -1
- package/build/adapters/kimi/usage.d.ts +82 -0
- package/build/adapters/kimi/usage.js +217 -0
- package/build/adapters/omp/plugin.d.ts +6 -0
- package/build/adapters/omp/plugin.js +87 -2
- package/build/adapters/omp/usage.d.ts +49 -0
- package/build/adapters/omp/usage.js +110 -0
- package/build/adapters/openclaw/plugin.d.ts +10 -0
- package/build/adapters/openclaw/plugin.js +57 -0
- package/build/adapters/openclaw/usage.d.ts +34 -0
- package/build/adapters/openclaw/usage.js +52 -0
- package/build/adapters/opencode/plugin.d.ts +17 -0
- package/build/adapters/opencode/plugin.js +40 -1
- package/build/adapters/pi/extension.js +34 -1
- package/build/adapters/qwen-code/index.js +23 -1
- package/build/adapters/qwen-code/usage.d.ts +90 -0
- package/build/adapters/qwen-code/usage.js +222 -0
- package/build/session/analytics.js +30 -0
- package/build/session/db.d.ts +11 -0
- package/build/session/db.js +33 -0
- package/build/session/extract.d.ts +224 -0
- package/build/session/extract.js +705 -62
- package/build/session/model-prices.json +429 -0
- package/build/session/pricing.d.ts +64 -0
- package/build/session/pricing.js +151 -0
- package/cli.bundle.mjs +177 -170
- package/configs/antigravity-cli/plugin.json +1 -1
- package/configs/copilot-cli/.github/plugin/plugin.json +1 -1
- package/configs/gemini-cli/settings.json +11 -0
- package/hooks/codex/stop.mjs +91 -4
- package/hooks/gemini-cli/aftermodel.mjs +70 -0
- package/hooks/kimi/stop.mjs +74 -3
- package/hooks/qwen-code/platform.mjs +1 -0
- package/hooks/qwen-code/stop.mjs +168 -0
- package/hooks/session-db.bundle.mjs +7 -7
- package/hooks/session-extract.bundle.mjs +3 -2
- package/hooks/session-loaders.mjs +16 -1
- package/hooks/stop.mjs +35 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server.bundle.mjs +108 -101
|
@@ -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
|
+
}
|
|
@@ -900,6 +900,18 @@ export function getRealBytesStats(opts) {
|
|
|
900
900
|
snapshotBytes += Number(snap.bytes);
|
|
901
901
|
}
|
|
902
902
|
catch { /* old schema */ }
|
|
903
|
+
try {
|
|
904
|
+
// "With context-mode" = the bytes the model paid to ACCESS the
|
|
905
|
+
// kept-out content: ctx_search (query the index) + ctx_fetch_and_index
|
|
906
|
+
// (fetch + index a URL). Sandbox compute (ctx_execute/batch/file) is
|
|
907
|
+
// work-output the model would see regardless — NOT redirect savings —
|
|
908
|
+
// so it is excluded; folding it crushed the bar to a false ~43%.
|
|
909
|
+
const tc = sdb.prepare(`SELECT COALESCE(SUM(bytes_returned), 0) AS bytes FROM tool_calls
|
|
910
|
+
WHERE session_id = ? AND tool IN ('ctx_search', 'ctx_fetch_and_index')`).get(opts.sessionId);
|
|
911
|
+
if (tc?.bytes)
|
|
912
|
+
bytesReturned += Number(tc.bytes);
|
|
913
|
+
}
|
|
914
|
+
catch { /* old schema: no tool_calls table */ }
|
|
903
915
|
}
|
|
904
916
|
else if (opts.projectDir) {
|
|
905
917
|
// Bug E+F: META-scoped aggregation. Take every session_id whose
|
|
@@ -930,6 +942,17 @@ export function getRealBytesStats(opts) {
|
|
|
930
942
|
snapshotBytes += Number(snap.bytes);
|
|
931
943
|
}
|
|
932
944
|
catch { /* old schema */ }
|
|
945
|
+
try {
|
|
946
|
+
const tc = sdb.prepare(`SELECT COALESCE(SUM(bytes_returned), 0) AS bytes
|
|
947
|
+
FROM tool_calls
|
|
948
|
+
WHERE session_id IN (
|
|
949
|
+
SELECT session_id FROM session_meta WHERE project_dir = ?
|
|
950
|
+
)
|
|
951
|
+
AND tool IN ('ctx_search', 'ctx_fetch_and_index')`).get(opts.projectDir);
|
|
952
|
+
if (tc?.bytes)
|
|
953
|
+
bytesReturned += Number(tc.bytes);
|
|
954
|
+
}
|
|
955
|
+
catch { /* old schema: no tool_calls table */ }
|
|
933
956
|
}
|
|
934
957
|
else {
|
|
935
958
|
const row = sdb.prepare(`SELECT
|
|
@@ -948,6 +971,13 @@ export function getRealBytesStats(opts) {
|
|
|
948
971
|
snapshotBytes += Number(snap.bytes);
|
|
949
972
|
}
|
|
950
973
|
catch { /* old schema */ }
|
|
974
|
+
try {
|
|
975
|
+
const tc = sdb.prepare(`SELECT COALESCE(SUM(bytes_returned), 0) AS bytes FROM tool_calls
|
|
976
|
+
WHERE tool IN ('ctx_search', 'ctx_fetch_and_index')`).get();
|
|
977
|
+
if (tc?.bytes)
|
|
978
|
+
bytesReturned += Number(tc.bytes);
|
|
979
|
+
}
|
|
980
|
+
catch { /* old schema: no tool_calls table */ }
|
|
951
981
|
}
|
|
952
982
|
}
|
|
953
983
|
finally {
|
package/build/session/db.d.ts
CHANGED
|
@@ -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
|
*/
|