@royalcat/opencode-dcp-rc 4.0.0 → 4.0.2

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 (55) hide show
  1. package/README.md +21 -1
  2. package/dist/index.js +1402 -27
  3. package/dist/index.js.map +1 -1
  4. package/dist/lib/commands/stats.d.ts +3 -1
  5. package/dist/lib/commands/stats.d.ts.map +1 -1
  6. package/dist/lib/compress/rc.d.ts.map +1 -1
  7. package/dist/lib/compress/summary.d.ts +11 -1
  8. package/dist/lib/compress/summary.d.ts.map +1 -1
  9. package/dist/lib/compress/types.d.ts +10 -1
  10. package/dist/lib/compress/types.d.ts.map +1 -1
  11. package/dist/lib/compress/usage.d.ts +45 -0
  12. package/dist/lib/compress/usage.d.ts.map +1 -0
  13. package/dist/lib/config.d.ts +2 -0
  14. package/dist/lib/config.d.ts.map +1 -1
  15. package/dist/lib/prompts/context-limit-nudge.d.ts +1 -1
  16. package/dist/lib/prompts/context-limit-nudge.d.ts.map +1 -1
  17. package/dist/lib/prompts/extensions/system.d.ts +2 -2
  18. package/dist/lib/prompts/extensions/system.d.ts.map +1 -1
  19. package/dist/lib/prompts/iteration-nudge.d.ts +1 -1
  20. package/dist/lib/prompts/iteration-nudge.d.ts.map +1 -1
  21. package/dist/lib/prompts/turn-nudge.d.ts +1 -1
  22. package/dist/lib/prompts/turn-nudge.d.ts.map +1 -1
  23. package/dist/lib/state/persistence.d.ts +2 -0
  24. package/dist/lib/state/persistence.d.ts.map +1 -1
  25. package/dist/lib/state/state.d.ts.map +1 -1
  26. package/dist/lib/state/types.d.ts +3 -0
  27. package/dist/lib/state/types.d.ts.map +1 -1
  28. package/dist/lib/tui/dialogs.d.ts.map +1 -1
  29. package/dist/lib/v2/index.d.ts.map +1 -1
  30. package/dist/lib/v2/rpc.d.ts +20 -0
  31. package/dist/lib/v2/rpc.d.ts.map +1 -1
  32. package/dist/lib/v2/scrub.d.ts +94 -0
  33. package/dist/lib/v2/scrub.d.ts.map +1 -0
  34. package/dist/lib/v2/usage.d.ts +45 -0
  35. package/dist/lib/v2/usage.d.ts.map +1 -0
  36. package/lib/commands/stats.ts +21 -0
  37. package/lib/compress/rc.ts +43 -7
  38. package/lib/compress/summary.ts +28 -1
  39. package/lib/compress/types.ts +14 -1
  40. package/lib/compress/usage.ts +168 -0
  41. package/lib/config.ts +30 -0
  42. package/lib/prompts/context-limit-nudge.ts +2 -0
  43. package/lib/prompts/extensions/system.ts +4 -0
  44. package/lib/prompts/iteration-nudge.ts +2 -0
  45. package/lib/prompts/system.ts +1 -1
  46. package/lib/prompts/turn-nudge.ts +2 -0
  47. package/lib/state/persistence.ts +18 -2
  48. package/lib/state/state.ts +4 -0
  49. package/lib/state/types.ts +3 -0
  50. package/lib/tui/dialogs.tsx +24 -0
  51. package/lib/v2/index.ts +58 -5
  52. package/lib/v2/rpc.ts +12 -0
  53. package/lib/v2/scrub.ts +954 -0
  54. package/lib/v2/usage.ts +450 -0
  55. package/package.json +1 -1
@@ -0,0 +1,450 @@
1
+ /**
2
+ * Captures token usage of the hidden compression (summary) requests.
3
+ *
4
+ * OpenCode V2 `session.generate` returns only the reply text, so provider
5
+ * usage is observed in two complementary ways: wrapping the resolved language
6
+ * model through the `aisdk.language` hook (AI SDK packages) and reading the
7
+ * raw response body through the session `http.response` hook (native
8
+ * `@opencode/ai` packages, whose model never passes through the core AISDK
9
+ * service). The session `generate` hook records a local input estimate as a
10
+ * fallback for runtimes where neither capture path is available. All state is
11
+ * kept in memory and keyed by the per-request call id embedded in the hidden
12
+ * prompt marker.
13
+ */
14
+
15
+ import type { Plugin } from "@opencode/plugin"
16
+ import type { Logger } from "../logger"
17
+ import { countTokens } from "../token-utils"
18
+ import { extractSummaryCallId } from "../compress/summary"
19
+ import {
20
+ estimatedCompressionUsage,
21
+ normalizeProviderUsage,
22
+ type CompressionUsage,
23
+ } from "../compress/usage"
24
+ import {
25
+ AisdkPartScrubber,
26
+ outputScrubEnabled,
27
+ stripAisdkResultContent,
28
+ type OutputScrubConfig,
29
+ } from "./scrub"
30
+
31
+ interface PendingUsage {
32
+ provider?: CompressionUsage
33
+ estimatedInput?: number
34
+ }
35
+
36
+ export interface CompressionUsageTracker {
37
+ recordProvider(callId: string, usage: CompressionUsage): void
38
+ recordEstimatedInput(callId: string, inputTokens: number): void
39
+ /** Provider usage when captured, otherwise a tokenizer estimate, otherwise undefined. */
40
+ resolve(callId: string, outputText: string): CompressionUsage | undefined
41
+ discard(callId: string): void
42
+ }
43
+
44
+ export function createCompressionUsageTracker(): CompressionUsageTracker {
45
+ const pending = new Map<string, PendingUsage>()
46
+
47
+ const entry = (callId: string): PendingUsage => {
48
+ let current = pending.get(callId)
49
+ if (!current) {
50
+ current = {}
51
+ pending.set(callId, current)
52
+ }
53
+ return current
54
+ }
55
+
56
+ return {
57
+ recordProvider(callId, usage) {
58
+ entry(callId).provider = usage
59
+ },
60
+ recordEstimatedInput(callId, inputTokens) {
61
+ if (Number.isFinite(inputTokens) && inputTokens > 0) {
62
+ entry(callId).estimatedInput = Math.round(inputTokens)
63
+ }
64
+ },
65
+ resolve(callId, outputText) {
66
+ const current = pending.get(callId)
67
+ if (!current) {
68
+ return undefined
69
+ }
70
+ if (current.provider) {
71
+ return current.provider
72
+ }
73
+ if (current.estimatedInput === undefined) {
74
+ return undefined
75
+ }
76
+ return estimatedCompressionUsage(current.estimatedInput, countTokens(outputText))
77
+ },
78
+ discard(callId) {
79
+ pending.delete(callId)
80
+ },
81
+ }
82
+ }
83
+
84
+ /** Depth-first search of the AI SDK call options for a summary marker. */
85
+ function findSummaryCallId(options: unknown): string | undefined {
86
+ const texts: string[] = []
87
+ collectTexts((options as { prompt?: unknown } | undefined)?.prompt, texts)
88
+ for (const text of texts) {
89
+ const callId = extractSummaryCallId(text)
90
+ if (callId) {
91
+ return callId
92
+ }
93
+ }
94
+ return undefined
95
+ }
96
+
97
+ function collectTexts(value: unknown, out: string[], depth = 0): void {
98
+ if (depth > 8 || out.length > 200) {
99
+ return
100
+ }
101
+ if (typeof value === "string") {
102
+ out.push(value)
103
+ return
104
+ }
105
+ if (Array.isArray(value)) {
106
+ for (const item of value) {
107
+ collectTexts(item, out, depth + 1)
108
+ }
109
+ return
110
+ }
111
+ if (value && typeof value === "object") {
112
+ const record = value as Record<string, unknown>
113
+ if (typeof record.text === "string") {
114
+ out.push(record.text)
115
+ }
116
+ if (typeof record.content === "string") {
117
+ out.push(record.content)
118
+ } else if (Array.isArray(record.content)) {
119
+ collectTexts(record.content, out, depth + 1)
120
+ }
121
+ }
122
+ }
123
+
124
+ function wrapLanguageModel(
125
+ model: any,
126
+ tracker: CompressionUsageTracker,
127
+ logger: Logger,
128
+ scrub?: OutputScrubConfig,
129
+ ): any {
130
+ return new Proxy(model, {
131
+ get(target, property, receiver) {
132
+ if (property === "doStream") {
133
+ return (options: unknown) => trackedStream(target, options, tracker, logger, scrub)
134
+ }
135
+ if (property === "doGenerate") {
136
+ return (options: unknown) =>
137
+ trackedGenerate(target, options, tracker, logger, scrub)
138
+ }
139
+ return Reflect.get(target, property, receiver)
140
+ },
141
+ })
142
+ }
143
+
144
+ async function trackedStream(
145
+ target: any,
146
+ options: unknown,
147
+ tracker: CompressionUsageTracker,
148
+ logger: Logger,
149
+ scrub?: OutputScrubConfig,
150
+ ): Promise<any> {
151
+ const result = await target.doStream(options)
152
+ const callId = findSummaryCallId(options)
153
+ const scrubEnabled = !!scrub && outputScrubEnabled(scrub)
154
+ logger.debug("Compression usage stream call", { callId: callId ?? null })
155
+ const TransformStreamImpl = (globalThis as any).TransformStream
156
+ if ((!callId && !scrubEnabled) || typeof result?.stream?.pipeThrough !== "function") {
157
+ return result
158
+ }
159
+ if (typeof TransformStreamImpl !== "function") {
160
+ return result
161
+ }
162
+
163
+ const partScrubber = scrubEnabled ? new AisdkPartScrubber(scrub!) : undefined
164
+ let recorded = false
165
+ const transform = new TransformStreamImpl({
166
+ transform(part: any, controller: any) {
167
+ try {
168
+ if (callId && !recorded && part?.type === "finish" && part.usage) {
169
+ const usage = normalizeProviderUsage(part.usage)
170
+ if (usage) {
171
+ recorded = true
172
+ tracker.recordProvider(callId, usage)
173
+ }
174
+ }
175
+ } catch {
176
+ // Never disturb the model stream because of usage accounting.
177
+ }
178
+ const parts = partScrubber ? partScrubber.process(part) : [part]
179
+ for (const next of parts) {
180
+ controller.enqueue(next)
181
+ }
182
+ },
183
+ })
184
+ return { ...result, stream: result.stream.pipeThrough(transform) }
185
+ }
186
+
187
+ async function trackedGenerate(
188
+ target: any,
189
+ options: unknown,
190
+ tracker: CompressionUsageTracker,
191
+ logger: Logger,
192
+ scrub?: OutputScrubConfig,
193
+ ): Promise<any> {
194
+ const result = await target.doGenerate(options)
195
+ const callId = findSummaryCallId(options)
196
+ logger.debug("Compression usage generate call", { callId: callId ?? null })
197
+ if (scrub && outputScrubEnabled(scrub)) {
198
+ try {
199
+ stripAisdkResultContent(result, scrub)
200
+ } catch {
201
+ // Never disturb the model result because of output scrubbing.
202
+ }
203
+ }
204
+ if (!callId) {
205
+ return result
206
+ }
207
+ const usage = normalizeProviderUsage(result?.usage)
208
+ if (usage) {
209
+ tracker.recordProvider(callId, usage)
210
+ }
211
+ return result
212
+ }
213
+
214
+ /**
215
+ * Best-effort registration of the language-model wrapper. Runtimes without the
216
+ * `aisdk` hook simply rely on the local estimate fallback.
217
+ */
218
+ export async function installCompressionUsageHook(
219
+ ctx: Plugin.Context,
220
+ tracker: CompressionUsageTracker,
221
+ logger: Logger,
222
+ scrub?: OutputScrubConfig,
223
+ ): Promise<void> {
224
+ const aisdk = (ctx as any)?.aisdk
225
+ if (!aisdk || typeof aisdk.hook !== "function") {
226
+ logger.debug("aisdk hook unavailable; compression usage will be estimated")
227
+ return
228
+ }
229
+
230
+ try {
231
+ await aisdk.hook("language", (event: any) => {
232
+ try {
233
+ const isModel = (candidate: unknown) =>
234
+ !!candidate && typeof (candidate as any).doStream === "function"
235
+ logger.debug("Compression usage hook event", {
236
+ modelID: event?.model?.modelID ?? event?.model?.id ?? null,
237
+ hasLanguage: isModel(event?.language),
238
+ hasFactory: typeof event?.sdk?.languageModel === "function",
239
+ })
240
+ // Another hook may already have resolved a model; otherwise fall
241
+ // back to the provider SDK factory the core would use itself.
242
+ let model: unknown = isModel(event?.language) ? event.language : undefined
243
+ if (!model && typeof event?.sdk?.languageModel === "function") {
244
+ model = event.sdk.languageModel(
245
+ event?.model?.modelID ?? event?.model?.id,
246
+ ) as unknown
247
+ }
248
+ if (!isModel(model)) {
249
+ return
250
+ }
251
+ event.language = wrapLanguageModel(model, tracker, logger, scrub)
252
+ } catch (error: any) {
253
+ logger.debug("Compression usage hook could not wrap language model", {
254
+ error: error?.message,
255
+ })
256
+ }
257
+ })
258
+ logger.debug("Compression usage hook installed")
259
+ } catch (error: any) {
260
+ logger.warn("Compression usage hook registration failed", {
261
+ error: error?.message,
262
+ })
263
+ }
264
+ }
265
+
266
+ interface UsageAccumulator {
267
+ input: number
268
+ output: number
269
+ cacheRead: number
270
+ cacheWrite: number
271
+ reasoning: number
272
+ /** Anthropic reports cache tokens in addition to `input_tokens`. */
273
+ exclusiveInput: boolean
274
+ }
275
+
276
+ function emptyUsageAccumulator(): UsageAccumulator {
277
+ return {
278
+ input: 0,
279
+ output: 0,
280
+ cacheRead: 0,
281
+ cacheWrite: 0,
282
+ reasoning: 0,
283
+ exclusiveInput: false,
284
+ }
285
+ }
286
+
287
+ type UsageField = "input" | "output" | "cacheRead" | "cacheWrite" | "reasoning"
288
+
289
+ function usageField(key: string): UsageField | undefined {
290
+ switch (key) {
291
+ case "input_tokens":
292
+ case "prompt_tokens":
293
+ case "promptTokenCount":
294
+ return "input"
295
+ case "output_tokens":
296
+ case "completion_tokens":
297
+ case "candidatesTokenCount":
298
+ return "output"
299
+ case "cache_read_input_tokens":
300
+ case "cache_creation_input_tokens":
301
+ case "cached_tokens":
302
+ case "cachedContentTokenCount":
303
+ return key === "cache_creation_input_tokens" ? "cacheWrite" : "cacheRead"
304
+ case "reasoning_tokens":
305
+ case "thoughtsTokenCount":
306
+ return "reasoning"
307
+ default:
308
+ return undefined
309
+ }
310
+ }
311
+
312
+ /** Merge every usage-like object found in a provider payload. */
313
+ function scanUsage(value: unknown, acc: UsageAccumulator, depth: number): void {
314
+ if (depth > 8 || !value || typeof value !== "object") {
315
+ return
316
+ }
317
+ if (Array.isArray(value)) {
318
+ for (const item of value) {
319
+ scanUsage(item, acc, depth + 1)
320
+ }
321
+ return
322
+ }
323
+ for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
324
+ const field = usageField(key)
325
+ if (field && typeof raw === "number" && Number.isFinite(raw)) {
326
+ acc[field] = Math.max(acc[field], raw)
327
+ if (key === "cache_read_input_tokens" || key === "cache_creation_input_tokens") {
328
+ acc.exclusiveInput = true
329
+ }
330
+ continue
331
+ }
332
+ if (raw && typeof raw === "object") {
333
+ scanUsage(raw, acc, depth + 1)
334
+ }
335
+ }
336
+ }
337
+
338
+ function usageFromAccumulator(acc: UsageAccumulator): CompressionUsage | undefined {
339
+ const input = acc.exclusiveInput ? acc.input + acc.cacheRead + acc.cacheWrite : acc.input
340
+ const usage: CompressionUsage = {
341
+ inputTokens: Math.max(0, Math.round(input)),
342
+ outputTokens: Math.max(0, Math.round(acc.output)),
343
+ cacheReadTokens: Math.max(0, Math.round(acc.cacheRead)),
344
+ cacheWriteTokens: Math.max(0, Math.round(acc.cacheWrite)),
345
+ reasoningTokens: Math.max(0, Math.round(acc.reasoning)),
346
+ source: "provider",
347
+ }
348
+ const total =
349
+ usage.inputTokens +
350
+ usage.outputTokens +
351
+ usage.cacheReadTokens +
352
+ usage.cacheWriteTokens +
353
+ usage.reasoningTokens
354
+ return total > 0 ? usage : undefined
355
+ }
356
+
357
+ /**
358
+ * Parse provider usage from a raw model response body. Handles plain JSON
359
+ * bodies and SSE streams from the common provider protocols (OpenAI
360
+ * responses/chat, Anthropic, Google) by merging every usage-like object.
361
+ */
362
+ export function parseProviderUsageFromBody(body: string): CompressionUsage | undefined {
363
+ const acc = emptyUsageAccumulator()
364
+ const trimmed = body.trim()
365
+ let parsedJson = false
366
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
367
+ try {
368
+ scanUsage(JSON.parse(trimmed), acc, 0)
369
+ parsedJson = true
370
+ } catch {
371
+ // Not a single JSON document; treat the body as an SSE stream.
372
+ }
373
+ }
374
+ if (!parsedJson) {
375
+ for (const line of body.split(/\r?\n/)) {
376
+ const match = /^\s*data:\s*(.*)$/.exec(line)
377
+ if (!match) {
378
+ continue
379
+ }
380
+ const payload = match[1].trim()
381
+ if (!payload || payload === "[DONE]") {
382
+ continue
383
+ }
384
+ try {
385
+ scanUsage(JSON.parse(payload), acc, 0)
386
+ } catch {
387
+ // Ignore malformed events.
388
+ }
389
+ }
390
+ }
391
+ return usageFromAccumulator(acc)
392
+ }
393
+
394
+ /**
395
+ * Best-effort capture of provider usage from the raw HTTP response of the
396
+ * hidden summary request. Needed for native `@opencode/ai` provider packages,
397
+ * which bypass the core AISDK service entirely. The hook reads clones of the
398
+ * request/response, so the real traffic is never disturbed, and any failure
399
+ * falls back to the local estimate recorded by the `generate` hook.
400
+ */
401
+ export async function installHttpUsageHook(
402
+ ctx: Plugin.Context,
403
+ tracker: CompressionUsageTracker,
404
+ logger: Logger,
405
+ ): Promise<void> {
406
+ const session = (ctx as any)?.session
407
+ if (!session || typeof session.hook !== "function") {
408
+ return
409
+ }
410
+
411
+ try {
412
+ await session.hook("http.response", async (event: any) => {
413
+ if (event?.kind !== "generate") {
414
+ return
415
+ }
416
+ try {
417
+ const request = event.request
418
+ const response = event.response
419
+ if (!request || typeof request.clone !== "function") {
420
+ return
421
+ }
422
+ const callId = extractSummaryCallId(await request.clone().text())
423
+ if (!callId) {
424
+ return
425
+ }
426
+ if (!response || typeof response.clone !== "function") {
427
+ return
428
+ }
429
+ const usage = parseProviderUsageFromBody(await response.clone().text())
430
+ if (usage) {
431
+ tracker.recordProvider(callId, usage)
432
+ logger.debug("Compression usage captured from http response", {
433
+ callId,
434
+ inputTokens: usage.inputTokens,
435
+ outputTokens: usage.outputTokens,
436
+ })
437
+ }
438
+ } catch (error: any) {
439
+ logger.debug("Compression usage http capture failed", {
440
+ error: error?.message,
441
+ })
442
+ }
443
+ })
444
+ logger.debug("Compression usage http hook installed")
445
+ } catch (error: any) {
446
+ logger.warn("Compression usage http hook registration failed", {
447
+ error: error?.message,
448
+ })
449
+ }
450
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@royalcat/opencode-dcp-rc",
4
- "version": "4.0.0",
4
+ "version": "4.0.2",
5
5
  "type": "module",
6
6
  "workspaces": [
7
7
  "tests/logger"