@tarquinen/opencode-dcp 3.2.5-beta0 → 3.2.7-beta0

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 (74) hide show
  1. package/dist/lib/token-utils.js +2 -2
  2. package/dist/lib/token-utils.js.map +1 -1
  3. package/index.ts +141 -0
  4. package/lib/analysis/tokens.ts +225 -0
  5. package/lib/auth.ts +37 -0
  6. package/lib/commands/compression-targets.ts +137 -0
  7. package/lib/commands/context.ts +132 -0
  8. package/lib/commands/decompress.ts +275 -0
  9. package/lib/commands/help.ts +76 -0
  10. package/lib/commands/index.ts +11 -0
  11. package/lib/commands/manual.ts +125 -0
  12. package/lib/commands/recompress.ts +224 -0
  13. package/lib/commands/stats.ts +148 -0
  14. package/lib/commands/sweep.ts +268 -0
  15. package/lib/compress/index.ts +3 -0
  16. package/lib/compress/message-utils.ts +250 -0
  17. package/lib/compress/message.ts +137 -0
  18. package/lib/compress/pipeline.ts +106 -0
  19. package/lib/compress/protected-content.ts +154 -0
  20. package/lib/compress/range-utils.ts +308 -0
  21. package/lib/compress/range.ts +180 -0
  22. package/lib/compress/search.ts +267 -0
  23. package/lib/compress/state.ts +268 -0
  24. package/lib/compress/timing.ts +77 -0
  25. package/lib/compress/types.ts +108 -0
  26. package/lib/compress-permission.ts +25 -0
  27. package/lib/config.ts +1071 -0
  28. package/lib/hooks.ts +378 -0
  29. package/lib/host-permissions.ts +101 -0
  30. package/lib/logger.ts +235 -0
  31. package/lib/message-ids.ts +172 -0
  32. package/lib/messages/index.ts +8 -0
  33. package/lib/messages/inject/inject.ts +215 -0
  34. package/lib/messages/inject/subagent-results.ts +82 -0
  35. package/lib/messages/inject/utils.ts +374 -0
  36. package/lib/messages/priority.ts +102 -0
  37. package/lib/messages/prune.ts +238 -0
  38. package/lib/messages/query.ts +56 -0
  39. package/lib/messages/reasoning-strip.ts +40 -0
  40. package/lib/messages/sync.ts +124 -0
  41. package/lib/messages/utils.ts +187 -0
  42. package/lib/prompts/compress-message.ts +42 -0
  43. package/lib/prompts/compress-range.ts +60 -0
  44. package/lib/prompts/context-limit-nudge.ts +18 -0
  45. package/lib/prompts/extensions/nudge.ts +43 -0
  46. package/lib/prompts/extensions/system.ts +32 -0
  47. package/lib/prompts/extensions/tool.ts +35 -0
  48. package/lib/prompts/index.ts +29 -0
  49. package/lib/prompts/iteration-nudge.ts +6 -0
  50. package/lib/prompts/store.ts +467 -0
  51. package/lib/prompts/system.ts +33 -0
  52. package/lib/prompts/turn-nudge.ts +10 -0
  53. package/lib/protected-patterns.ts +128 -0
  54. package/lib/state/index.ts +4 -0
  55. package/lib/state/persistence.ts +256 -0
  56. package/lib/state/state.ts +190 -0
  57. package/lib/state/tool-cache.ts +98 -0
  58. package/lib/state/types.ts +112 -0
  59. package/lib/state/utils.ts +334 -0
  60. package/lib/strategies/deduplication.ts +127 -0
  61. package/lib/strategies/index.ts +2 -0
  62. package/lib/strategies/purge-errors.ts +88 -0
  63. package/lib/subagents/subagent-results.ts +74 -0
  64. package/lib/token-utils.ts +162 -0
  65. package/lib/ui/notification.ts +346 -0
  66. package/lib/ui/utils.ts +287 -0
  67. package/package.json +12 -3
  68. package/tui/data/context.ts +177 -0
  69. package/tui/index.tsx +34 -0
  70. package/tui/routes/summary.tsx +175 -0
  71. package/tui/shared/names.ts +9 -0
  72. package/tui/shared/theme.ts +58 -0
  73. package/tui/shared/types.ts +38 -0
  74. package/tui/slots/sidebar-content.tsx +502 -0
@@ -0,0 +1,502 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { createEffect, createMemo, createSignal, on, onCleanup, untrack } from "solid-js"
3
+ import type { TuiSlotPlugin } from "@opencode-ai/plugin/tui"
4
+ import { Logger } from "../../lib/logger"
5
+ import {
6
+ createPlaceholderContextSnapshot,
7
+ invalidateContextSnapshot,
8
+ loadContextSnapshotCached,
9
+ peekContextSnapshot,
10
+ } from "../data/context"
11
+ import { getPalette, toneColor, type DcpColor, type DcpPalette } from "../shared/theme"
12
+ import { LABEL, type DcpRouteNames } from "../shared/names"
13
+ import type { DcpActiveBlockInfo, DcpMessageStatus, DcpTuiApi } from "../shared/types"
14
+
15
+ const SINGLE_BORDER = { type: "single" } as any
16
+ const DIM_TEXT = { dim: true } as any
17
+
18
+ const REFRESH_DEBOUNCE_MS = 100
19
+ const MAX_TOPIC_LEN = 30
20
+
21
+ const truncateTopic = (topic: string): string =>
22
+ topic.length > MAX_TOPIC_LEN ? topic.slice(0, MAX_TOPIC_LEN - 3) + "..." : topic
23
+
24
+ const compactTokenCount = (value: number): string => {
25
+ if (value >= 1_000_000) {
26
+ const m = (value / 1_000_000).toFixed(2)
27
+ return `${m}M`
28
+ }
29
+ if (value >= 100_000) return `${Math.round(value / 1000)}K`
30
+ if (value >= 1_000) {
31
+ const k = (value / 1000).toFixed(1)
32
+ return k.endsWith(".0") ? `${Math.round(value / 1000)}K` : `${k}K`
33
+ }
34
+ const d = Math.round(value / 100)
35
+ if (d >= 10) return `${Math.round(value / 1000)}K`
36
+ if (d > 0) return `.${d}K`
37
+ return "0"
38
+ }
39
+
40
+ const buildMessageRuns = (
41
+ statuses: DcpMessageStatus[],
42
+ ): { count: number; status: DcpMessageStatus }[] => {
43
+ if (statuses.length === 0) return []
44
+
45
+ // Group consecutive same-status messages into runs
46
+ const runs: { count: number; status: DcpMessageStatus }[] = []
47
+ let runStart = 0
48
+ for (let i = 1; i <= statuses.length; i++) {
49
+ if (i === statuses.length || statuses[i] !== statuses[runStart]) {
50
+ runs.push({ count: i - runStart, status: statuses[runStart] })
51
+ runStart = i
52
+ }
53
+ }
54
+ return runs
55
+ }
56
+
57
+ const SummaryRow = (props: {
58
+ palette: DcpPalette
59
+ label: string
60
+ value: string
61
+ tone?: "text" | "muted" | "accent" | "success" | "warning"
62
+ swatch?: DcpColor
63
+ marginTop?: number
64
+ }) => {
65
+ return (
66
+ <box
67
+ width="100%"
68
+ flexDirection="row"
69
+ justifyContent="space-between"
70
+ marginTop={props.marginTop}
71
+ >
72
+ <box flexDirection="row">
73
+ {props.swatch && <text fg={props.swatch}>{"█ "}</text>}
74
+ <text fg={props.palette.text}>{props.label}</text>
75
+ </box>
76
+ <text fg={toneColor(props.palette, props.tone)}>
77
+ <b>{props.value}</b>
78
+ </text>
79
+ </box>
80
+ )
81
+ }
82
+
83
+ const SidebarContextBar = (props: {
84
+ palette: DcpPalette
85
+ label: string
86
+ value: number
87
+ total: number
88
+ tone?: "text" | "muted" | "accent" | "success" | "warning"
89
+ }) => {
90
+ const percent = createMemo(() =>
91
+ props.total > 0 ? `${Math.round((props.value / props.total) * 100)}%` : "0%",
92
+ )
93
+ const label = createMemo(() => props.label.padEnd(9, " "))
94
+ return (
95
+ <box width="100%" flexDirection="row">
96
+ <text fg={props.palette.text}>
97
+ {label()}
98
+ {` ${percent().padStart(4, " ")} |`}
99
+ </text>
100
+ <box flexGrow={1} flexDirection="row" height={1}>
101
+ {props.value > 0 && (
102
+ <box
103
+ flexGrow={props.value}
104
+ backgroundColor={toneColor(props.palette, props.tone)}
105
+ />
106
+ )}
107
+ {props.total > props.value && <box flexGrow={props.total - props.value} />}
108
+ </box>
109
+ <text fg={props.palette.text}>
110
+ {`| ${compactTokenCount(props.value).padStart(5, " ")}`}
111
+ </text>
112
+ </box>
113
+ )
114
+ }
115
+
116
+ const SidebarContext = (props: {
117
+ api: DcpTuiApi
118
+ names: DcpRouteNames
119
+ palette: DcpPalette
120
+ sessionID: () => string
121
+ logger: Logger
122
+ }) => {
123
+ const initialSnapshot = peekContextSnapshot(props.sessionID())
124
+ const [snapshot, setSnapshot] = createSignal(
125
+ initialSnapshot ?? createPlaceholderContextSnapshot(props.sessionID()),
126
+ )
127
+ const [loading, setLoading] = createSignal(!initialSnapshot)
128
+ const [error, setError] = createSignal<string>()
129
+ let requestVersion = 0
130
+ let renderTimeout: ReturnType<typeof setTimeout> | undefined
131
+
132
+ const requestRender = () => {
133
+ if (renderTimeout) clearTimeout(renderTimeout)
134
+ renderTimeout = setTimeout(() => {
135
+ renderTimeout = undefined
136
+ try {
137
+ props.api.renderer.requestRender()
138
+ } catch (error) {
139
+ props.logger.warn("Failed to request TUI render", {
140
+ error: error instanceof Error ? error.message : String(error),
141
+ })
142
+ }
143
+ }, 0)
144
+ }
145
+
146
+ onCleanup(() => {
147
+ if (renderTimeout) clearTimeout(renderTimeout)
148
+ })
149
+
150
+ const refreshSnapshot = async (
151
+ sessionID: string,
152
+ options?: { invalidate?: boolean; preserveSnapshot?: boolean },
153
+ ) => {
154
+ if (options?.invalidate) {
155
+ invalidateContextSnapshot(sessionID)
156
+ }
157
+
158
+ const cached = peekContextSnapshot(sessionID)
159
+ let silentRefresh = false
160
+ if (cached) {
161
+ setSnapshot(cached)
162
+ setLoading(false)
163
+ } else {
164
+ const current = untrack(snapshot)
165
+ if (options?.preserveSnapshot && current?.sessionID === sessionID) {
166
+ silentRefresh = true
167
+ } else {
168
+ setSnapshot(createPlaceholderContextSnapshot(sessionID, ["Loading DCP context..."]))
169
+ setLoading(true)
170
+ }
171
+ }
172
+ setError(undefined)
173
+ if (!silentRefresh) {
174
+ requestRender()
175
+ }
176
+
177
+ const currentRequest = ++requestVersion
178
+
179
+ try {
180
+ const value = await loadContextSnapshotCached(props.api.client, props.logger, sessionID)
181
+ if (currentRequest !== requestVersion || props.sessionID() !== sessionID) {
182
+ return
183
+ }
184
+ setSnapshot(value)
185
+ setLoading(false)
186
+ requestRender()
187
+ } catch (cause) {
188
+ if (currentRequest !== requestVersion || props.sessionID() !== sessionID) {
189
+ return
190
+ }
191
+ props.logger.warn("Failed to refresh sidebar snapshot", {
192
+ sessionID,
193
+ error: cause instanceof Error ? cause.message : String(cause),
194
+ })
195
+ setError(cause instanceof Error ? cause.message : String(cause))
196
+ setLoading(false)
197
+ requestRender()
198
+ }
199
+ }
200
+
201
+ createEffect(
202
+ on(
203
+ props.sessionID,
204
+ (sessionID) => {
205
+ void refreshSnapshot(sessionID)
206
+ },
207
+ { defer: false },
208
+ ),
209
+ )
210
+
211
+ createEffect(
212
+ on(
213
+ props.sessionID,
214
+ (sessionID) => {
215
+ let timeout: ReturnType<typeof setTimeout> | undefined
216
+
217
+ const scheduleRefresh = () => {
218
+ if (!sessionID) return
219
+ if (timeout) clearTimeout(timeout)
220
+ timeout = setTimeout(() => {
221
+ timeout = undefined
222
+ void refreshSnapshot(sessionID, {
223
+ invalidate: true,
224
+ preserveSnapshot: true,
225
+ })
226
+ }, REFRESH_DEBOUNCE_MS)
227
+ }
228
+
229
+ const unsubs = [
230
+ props.api.event.on("message.updated", (event) => {
231
+ if (event.properties.info.sessionID !== sessionID) return
232
+ scheduleRefresh()
233
+ }),
234
+ props.api.event.on("message.removed", (event) => {
235
+ if (event.properties.sessionID !== sessionID) return
236
+ scheduleRefresh()
237
+ }),
238
+ props.api.event.on("message.part.updated", (event) => {
239
+ if (event.properties.part.sessionID !== sessionID) return
240
+ scheduleRefresh()
241
+ }),
242
+ props.api.event.on("message.part.delta", (event) => {
243
+ if (event.properties.sessionID !== sessionID) return
244
+ scheduleRefresh()
245
+ }),
246
+ props.api.event.on("message.part.removed", (event) => {
247
+ if (event.properties.sessionID !== sessionID) return
248
+ scheduleRefresh()
249
+ }),
250
+ props.api.event.on("session.updated", (event) => {
251
+ if (event.properties.info.id !== sessionID) return
252
+ scheduleRefresh()
253
+ }),
254
+ props.api.event.on("session.deleted", (event) => {
255
+ if (event.properties.info.id !== sessionID) return
256
+ scheduleRefresh()
257
+ }),
258
+ props.api.event.on("session.diff", (event) => {
259
+ if (event.properties.sessionID !== sessionID) return
260
+ scheduleRefresh()
261
+ }),
262
+ props.api.event.on("session.error", (event) => {
263
+ if (event.properties.sessionID !== sessionID) return
264
+ scheduleRefresh()
265
+ }),
266
+ props.api.event.on("session.status", (event) => {
267
+ if (event.properties.sessionID !== sessionID) return
268
+ scheduleRefresh()
269
+ }),
270
+ ]
271
+
272
+ onCleanup(() => {
273
+ if (timeout) clearTimeout(timeout)
274
+ for (const unsub of unsubs) {
275
+ unsub()
276
+ }
277
+ })
278
+ },
279
+ { defer: false },
280
+ ),
281
+ )
282
+
283
+ const TOPIC_LIMIT = 3
284
+ const allBlocks = createMemo(() => snapshot().persisted.activeBlocks)
285
+ const [topicsExpanded, setTopicsExpanded] = createSignal(false)
286
+ const blocks = createMemo(() =>
287
+ topicsExpanded() ? allBlocks() : allBlocks().slice(0, TOPIC_LIMIT),
288
+ )
289
+ const topicOverflow = createMemo(() => allBlocks().length - TOPIC_LIMIT)
290
+
291
+ const navigateToSummary = (block: DcpActiveBlockInfo) => {
292
+ props.api.route.navigate(props.names.routes.summary, {
293
+ topic: block.topic,
294
+ summary: block.summary,
295
+ sessionID: props.sessionID(),
296
+ })
297
+ }
298
+ const fallbackNote = createMemo(() => snapshot().notes[0] ?? "")
299
+
300
+ const messageBarRuns = createMemo(() => buildMessageRuns(snapshot().messageStatuses))
301
+
302
+ const status = createMemo(() => {
303
+ if (error() && snapshot().breakdown.total > 0)
304
+ return { label: "cached", tone: "warning" as const }
305
+ if (error()) return { label: "error", tone: "warning" as const }
306
+ if (loading() && snapshot().breakdown.total > 0)
307
+ return { label: "refreshing", tone: "warning" as const }
308
+ if (loading()) return { label: "loading", tone: "warning" as const }
309
+ return { label: "loaded", tone: "success" as const }
310
+ })
311
+
312
+ return (
313
+ <box
314
+ width="100%"
315
+ flexDirection="column"
316
+ backgroundColor={props.palette.surface}
317
+ border={SINGLE_BORDER}
318
+ borderColor={props.palette.accent}
319
+ paddingTop={1}
320
+ paddingBottom={1}
321
+ paddingLeft={1}
322
+ paddingRight={1}
323
+ >
324
+ <box flexDirection="row" justifyContent="space-between" alignItems="center">
325
+ <box flexDirection="row" gap={1} alignItems="center">
326
+ <box paddingLeft={1} paddingRight={1} backgroundColor={props.palette.accent}>
327
+ <text fg={props.palette.panel}>
328
+ <b>{LABEL}</b>
329
+ </text>
330
+ </box>
331
+ </box>
332
+ <text fg={toneColor(props.palette, status().tone)}>{status().label}</text>
333
+ </box>
334
+
335
+ <SummaryRow
336
+ palette={props.palette}
337
+ label="Current Messages"
338
+ value={`~${compactTokenCount(snapshot().breakdown.total)}`}
339
+ tone="accent"
340
+ swatch={props.palette.accent}
341
+ marginTop={1}
342
+ />
343
+ <SummaryRow
344
+ palette={props.palette}
345
+ label="Compressed Messages"
346
+ value={`~${compactTokenCount(snapshot().breakdown.prunedTokens)}`}
347
+ tone="accent"
348
+ swatch={props.palette.muted}
349
+ />
350
+
351
+ {snapshot().messageStatuses.length > 0 && (
352
+ <box width="100%" flexDirection="row" height={1} marginTop={1}>
353
+ {messageBarRuns().map((run) => (
354
+ <box
355
+ flexGrow={run.count}
356
+ backgroundColor={
357
+ run.status === "active" ? props.palette.accent : props.palette.muted
358
+ }
359
+ />
360
+ ))}
361
+ </box>
362
+ )}
363
+
364
+ <box width="100%" flexDirection="column" paddingTop={1}>
365
+ <SidebarContextBar
366
+ palette={props.palette}
367
+ label="System"
368
+ value={snapshot().breakdown.system}
369
+ total={snapshot().breakdown.total}
370
+ tone="accent"
371
+ />
372
+ <SidebarContextBar
373
+ palette={props.palette}
374
+ label="User"
375
+ value={snapshot().breakdown.user}
376
+ total={snapshot().breakdown.total}
377
+ tone="accent"
378
+ />
379
+ <SidebarContextBar
380
+ palette={props.palette}
381
+ label="Assistant"
382
+ value={snapshot().breakdown.assistant}
383
+ total={snapshot().breakdown.total}
384
+ tone="accent"
385
+ />
386
+ <SidebarContextBar
387
+ palette={props.palette}
388
+ label="Tools"
389
+ value={snapshot().breakdown.tools}
390
+ total={snapshot().breakdown.total}
391
+ tone="accent"
392
+ />
393
+ </box>
394
+
395
+ <box width="100%" flexDirection="column" gap={0} paddingTop={1}>
396
+ {blocks().length > 0 ? (
397
+ <>
398
+ <box width="100%" flexDirection="row" justifyContent="space-between">
399
+ <text fg={props.palette.text}>
400
+ <b>Compressed Summaries</b>
401
+ </text>
402
+ <text fg={toneColor(props.palette, "accent")}>
403
+ <b>{`~${compactTokenCount(snapshot().activeSummaryTokens)}`}</b>
404
+ </text>
405
+ </box>
406
+ {blocks().map((block) => (
407
+ <box flexDirection="row" width="100%" height={1}>
408
+ <box flexGrow={1} flexShrink={1} overflow="hidden" height={1}>
409
+ <text fg={props.palette.muted}>
410
+ {truncateTopic(block.topic)}
411
+ </text>
412
+ </box>
413
+ <box flexShrink={0} height={1} paddingLeft={1}>
414
+ <box
415
+ backgroundColor={props.palette.base}
416
+ height={1}
417
+ onMouseUp={() => navigateToSummary(block)}
418
+ >
419
+ <text fg={props.palette.accent}> ▶ </text>
420
+ </box>
421
+ </box>
422
+ </box>
423
+ ))}
424
+ {topicOverflow() > 0 ? (
425
+ <box flexDirection="row" width="100%" height={1}>
426
+ <box flexGrow={1} flexShrink={1} height={1}>
427
+ <text {...DIM_TEXT} fg={props.palette.muted}>
428
+ {topicsExpanded()
429
+ ? `showing all ${allBlocks().length} topics`
430
+ : `... ${topicOverflow()} more topics`}
431
+ </text>
432
+ </box>
433
+ <box flexShrink={0} height={1} paddingLeft={1}>
434
+ <box
435
+ backgroundColor={props.palette.base}
436
+ height={1}
437
+ onMouseUp={() => setTopicsExpanded(!topicsExpanded())}
438
+ >
439
+ <text fg={props.palette.accent}>
440
+ {topicsExpanded() ? " ▲ " : " ▼ "}
441
+ </text>
442
+ </box>
443
+ </box>
444
+ </box>
445
+ ) : null}
446
+ </>
447
+ ) : fallbackNote() ? (
448
+ <text fg={props.palette.muted}>{fallbackNote()}</text>
449
+ ) : null}
450
+ </box>
451
+
452
+ {snapshot().allTimeStats.sessionCount > 0 && (
453
+ <box width="100%" flexDirection="column" paddingTop={1}>
454
+ <text fg={props.palette.text}>
455
+ <b>All Time</b>
456
+ </text>
457
+ <SummaryRow
458
+ palette={props.palette}
459
+ label="Tokens Saved"
460
+ value={`~${compactTokenCount(snapshot().allTimeStats.totalTokensSaved)}`}
461
+ tone="accent"
462
+ />
463
+ <SummaryRow
464
+ palette={props.palette}
465
+ label="Sessions"
466
+ value={`${snapshot().allTimeStats.sessionCount}`}
467
+ tone="accent"
468
+ />
469
+ </box>
470
+ )}
471
+ </box>
472
+ )
473
+ }
474
+
475
+ export const createSidebarContentSlot = (
476
+ api: DcpTuiApi,
477
+ names: DcpRouteNames,
478
+ logger: Logger,
479
+ ): TuiSlotPlugin => {
480
+ const renderSidebar = (
481
+ ctx: { theme: { current: Record<string, unknown> } },
482
+ value: { session_id: string },
483
+ ) => {
484
+ const palette = createMemo(() => getPalette(ctx.theme.current as Record<string, unknown>))
485
+ return (
486
+ <SidebarContext
487
+ api={api}
488
+ names={names}
489
+ palette={palette()}
490
+ sessionID={() => value.session_id}
491
+ logger={logger}
492
+ />
493
+ )
494
+ }
495
+
496
+ return {
497
+ order: 90,
498
+ slots: {
499
+ sidebar_content: renderSidebar,
500
+ },
501
+ }
502
+ }