@tarquinen/opencode-dcp 3.2.5-beta0 → 3.2.6-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.
- package/index.ts +141 -0
- package/lib/analysis/tokens.ts +225 -0
- package/lib/auth.ts +37 -0
- package/lib/commands/compression-targets.ts +137 -0
- package/lib/commands/context.ts +132 -0
- package/lib/commands/decompress.ts +275 -0
- package/lib/commands/help.ts +76 -0
- package/lib/commands/index.ts +11 -0
- package/lib/commands/manual.ts +125 -0
- package/lib/commands/recompress.ts +224 -0
- package/lib/commands/stats.ts +148 -0
- package/lib/commands/sweep.ts +268 -0
- package/lib/compress/index.ts +3 -0
- package/lib/compress/message-utils.ts +250 -0
- package/lib/compress/message.ts +137 -0
- package/lib/compress/pipeline.ts +106 -0
- package/lib/compress/protected-content.ts +154 -0
- package/lib/compress/range-utils.ts +308 -0
- package/lib/compress/range.ts +180 -0
- package/lib/compress/search.ts +267 -0
- package/lib/compress/state.ts +268 -0
- package/lib/compress/timing.ts +77 -0
- package/lib/compress/types.ts +108 -0
- package/lib/compress-permission.ts +25 -0
- package/lib/config.ts +1071 -0
- package/lib/hooks.ts +378 -0
- package/lib/host-permissions.ts +101 -0
- package/lib/logger.ts +235 -0
- package/lib/message-ids.ts +172 -0
- package/lib/messages/index.ts +8 -0
- package/lib/messages/inject/inject.ts +215 -0
- package/lib/messages/inject/subagent-results.ts +82 -0
- package/lib/messages/inject/utils.ts +374 -0
- package/lib/messages/priority.ts +102 -0
- package/lib/messages/prune.ts +238 -0
- package/lib/messages/query.ts +56 -0
- package/lib/messages/reasoning-strip.ts +40 -0
- package/lib/messages/sync.ts +124 -0
- package/lib/messages/utils.ts +187 -0
- package/lib/prompts/compress-message.ts +42 -0
- package/lib/prompts/compress-range.ts +60 -0
- package/lib/prompts/context-limit-nudge.ts +18 -0
- package/lib/prompts/extensions/nudge.ts +43 -0
- package/lib/prompts/extensions/system.ts +32 -0
- package/lib/prompts/extensions/tool.ts +35 -0
- package/lib/prompts/index.ts +29 -0
- package/lib/prompts/iteration-nudge.ts +6 -0
- package/lib/prompts/store.ts +467 -0
- package/lib/prompts/system.ts +33 -0
- package/lib/prompts/turn-nudge.ts +10 -0
- package/lib/protected-patterns.ts +128 -0
- package/lib/state/index.ts +4 -0
- package/lib/state/persistence.ts +256 -0
- package/lib/state/state.ts +190 -0
- package/lib/state/tool-cache.ts +98 -0
- package/lib/state/types.ts +112 -0
- package/lib/state/utils.ts +334 -0
- package/lib/strategies/deduplication.ts +127 -0
- package/lib/strategies/index.ts +2 -0
- package/lib/strategies/purge-errors.ts +88 -0
- package/lib/subagents/subagent-results.ts +74 -0
- package/lib/token-utils.ts +162 -0
- package/lib/ui/notification.ts +346 -0
- package/lib/ui/utils.ts +287 -0
- package/package.json +9 -2
- package/tui/data/context.ts +177 -0
- package/tui/index.tsx +34 -0
- package/tui/routes/summary.tsx +175 -0
- package/tui/shared/names.ts +9 -0
- package/tui/shared/theme.ts +58 -0
- package/tui/shared/types.ts +38 -0
- package/tui/slots/sidebar-content.tsx +502 -0
package/lib/ui/utils.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { SessionState, ToolParameterEntry, WithParts } from "../state"
|
|
2
|
+
import { countTokens } from "../token-utils"
|
|
3
|
+
import { isIgnoredUserMessage } from "../messages/query"
|
|
4
|
+
|
|
5
|
+
function extractParameterKey(tool: string, parameters: any): string {
|
|
6
|
+
if (!parameters) return ""
|
|
7
|
+
|
|
8
|
+
if (tool === "read" && parameters.filePath) {
|
|
9
|
+
const offset = parameters.offset
|
|
10
|
+
const limit = parameters.limit
|
|
11
|
+
if (offset !== undefined && limit !== undefined) {
|
|
12
|
+
return `${parameters.filePath} (lines ${offset}-${offset + limit})`
|
|
13
|
+
}
|
|
14
|
+
if (offset !== undefined) {
|
|
15
|
+
return `${parameters.filePath} (lines ${offset}+)`
|
|
16
|
+
}
|
|
17
|
+
if (limit !== undefined) {
|
|
18
|
+
return `${parameters.filePath} (lines 0-${limit})`
|
|
19
|
+
}
|
|
20
|
+
return parameters.filePath
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if ((tool === "write" || tool === "edit" || tool === "multiedit") && parameters.filePath) {
|
|
24
|
+
return parameters.filePath
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (tool === "apply_patch" && typeof parameters.patchText === "string") {
|
|
28
|
+
const pathRegex = /\*\*\* (?:Add|Delete|Update) File: ([^\n\r]+)/g
|
|
29
|
+
const paths: string[] = []
|
|
30
|
+
let match
|
|
31
|
+
while ((match = pathRegex.exec(parameters.patchText)) !== null) {
|
|
32
|
+
paths.push(match[1].trim())
|
|
33
|
+
}
|
|
34
|
+
if (paths.length > 0) {
|
|
35
|
+
const uniquePaths = [...new Set(paths)]
|
|
36
|
+
const count = uniquePaths.length
|
|
37
|
+
const plural = count > 1 ? "s" : ""
|
|
38
|
+
if (count === 1) return uniquePaths[0]
|
|
39
|
+
if (count === 2) return uniquePaths.join(", ")
|
|
40
|
+
return `${count} file${plural}: ${uniquePaths[0]}, ${uniquePaths[1]}...`
|
|
41
|
+
}
|
|
42
|
+
return "patch"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (tool === "list") {
|
|
46
|
+
return parameters.path || "(current directory)"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (tool === "glob") {
|
|
50
|
+
if (parameters.pattern) {
|
|
51
|
+
const pathInfo = parameters.path ? ` in ${parameters.path}` : ""
|
|
52
|
+
return `"${parameters.pattern}"${pathInfo}`
|
|
53
|
+
}
|
|
54
|
+
return "(unknown pattern)"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (tool === "grep") {
|
|
58
|
+
if (parameters.pattern) {
|
|
59
|
+
const pathInfo = parameters.path ? ` in ${parameters.path}` : ""
|
|
60
|
+
return `"${parameters.pattern}"${pathInfo}`
|
|
61
|
+
}
|
|
62
|
+
return "(unknown pattern)"
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (tool === "bash") {
|
|
66
|
+
if (parameters.description) return parameters.description
|
|
67
|
+
if (parameters.command) {
|
|
68
|
+
return parameters.command.length > 50
|
|
69
|
+
? parameters.command.substring(0, 50) + "..."
|
|
70
|
+
: parameters.command
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (tool === "webfetch" && parameters.url) {
|
|
75
|
+
return parameters.url
|
|
76
|
+
}
|
|
77
|
+
if (tool === "websearch" && parameters.query) {
|
|
78
|
+
return `"${parameters.query}"`
|
|
79
|
+
}
|
|
80
|
+
if (tool === "codesearch" && parameters.query) {
|
|
81
|
+
return `"${parameters.query}"`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (tool === "todowrite") {
|
|
85
|
+
return `${parameters.todos?.length || 0} todos`
|
|
86
|
+
}
|
|
87
|
+
if (tool === "todoread") {
|
|
88
|
+
return "read todo list"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (tool === "task" && parameters.description) {
|
|
92
|
+
return parameters.description
|
|
93
|
+
}
|
|
94
|
+
if (tool === "skill" && parameters.name) {
|
|
95
|
+
return parameters.name
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (tool === "lsp") {
|
|
99
|
+
const op = parameters.operation || "lsp"
|
|
100
|
+
const path = parameters.filePath || ""
|
|
101
|
+
const line = parameters.line
|
|
102
|
+
const char = parameters.character
|
|
103
|
+
if (path && line !== undefined && char !== undefined) {
|
|
104
|
+
return `${op} ${path}:${line}:${char}`
|
|
105
|
+
}
|
|
106
|
+
if (path) {
|
|
107
|
+
return `${op} ${path}`
|
|
108
|
+
}
|
|
109
|
+
return op
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (tool === "question") {
|
|
113
|
+
const questions = parameters.questions
|
|
114
|
+
if (Array.isArray(questions) && questions.length > 0) {
|
|
115
|
+
const headers = questions
|
|
116
|
+
.map((q: any) => q.header || "")
|
|
117
|
+
.filter(Boolean)
|
|
118
|
+
.slice(0, 3)
|
|
119
|
+
|
|
120
|
+
const count = questions.length
|
|
121
|
+
const plural = count > 1 ? "s" : ""
|
|
122
|
+
|
|
123
|
+
if (headers.length > 0) {
|
|
124
|
+
const suffix = count > 3 ? ` (+${count - 3} more)` : ""
|
|
125
|
+
return `${count} question${plural}: ${headers.join(", ")}${suffix}`
|
|
126
|
+
}
|
|
127
|
+
return `${count} question${plural}`
|
|
128
|
+
}
|
|
129
|
+
return "question"
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const paramStr = JSON.stringify(parameters)
|
|
133
|
+
if (paramStr === "{}" || paramStr === "[]" || paramStr === "null") {
|
|
134
|
+
return ""
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return paramStr.substring(0, 50)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function formatStatsHeader(totalTokensSaved: number, pruneTokenCounter: number): string {
|
|
141
|
+
const totalTokensSavedStr = `~${formatTokenCount(totalTokensSaved + pruneTokenCounter)}`
|
|
142
|
+
return [`▣ DCP | ${totalTokensSavedStr} saved total`].join("\n")
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function formatTokenCount(tokens: number, compact?: boolean): string {
|
|
146
|
+
const suffix = compact ? "" : " tokens"
|
|
147
|
+
if (tokens >= 1000) {
|
|
148
|
+
return `${(tokens / 1000).toFixed(1)}K`.replace(".0K", "K") + suffix
|
|
149
|
+
}
|
|
150
|
+
return tokens.toString() + suffix
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function truncate(str: string, maxLen: number = 60): string {
|
|
154
|
+
if (str.length <= maxLen) return str
|
|
155
|
+
return str.slice(0, maxLen - 3) + "..."
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function formatProgressBar(
|
|
159
|
+
messageIds: string[],
|
|
160
|
+
prunedMessages: Map<string, number>,
|
|
161
|
+
recentMessageIds: string[],
|
|
162
|
+
width: number = 50,
|
|
163
|
+
): string {
|
|
164
|
+
const ACTIVE = "█"
|
|
165
|
+
const PRUNED = "░"
|
|
166
|
+
const RECENT = "⣿"
|
|
167
|
+
const recentSet = new Set(recentMessageIds)
|
|
168
|
+
|
|
169
|
+
const total = messageIds.length
|
|
170
|
+
if (total === 0) return `│${PRUNED.repeat(width)}│`
|
|
171
|
+
|
|
172
|
+
const bar = new Array(width).fill(ACTIVE)
|
|
173
|
+
|
|
174
|
+
for (let m = 0; m < total; m++) {
|
|
175
|
+
const msgId = messageIds[m]
|
|
176
|
+
const start = Math.floor((m / total) * width)
|
|
177
|
+
const end = Math.floor(((m + 1) / total) * width)
|
|
178
|
+
|
|
179
|
+
if (recentSet.has(msgId)) {
|
|
180
|
+
for (let i = start; i < end; i++) {
|
|
181
|
+
bar[i] = RECENT
|
|
182
|
+
}
|
|
183
|
+
} else if (prunedMessages.has(msgId)) {
|
|
184
|
+
for (let i = start; i < end; i++) {
|
|
185
|
+
bar[i] = PRUNED
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return `│${bar.join("")}│`
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function cacheSystemPromptTokens(state: SessionState, messages: WithParts[]): void {
|
|
194
|
+
let firstInputTokens = 0
|
|
195
|
+
for (const msg of messages) {
|
|
196
|
+
if (msg.info.role !== "assistant") {
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
const info = msg.info as any
|
|
200
|
+
const input = info?.tokens?.input || 0
|
|
201
|
+
const cacheRead = info?.tokens?.cache?.read || 0
|
|
202
|
+
const cacheWrite = info?.tokens?.cache?.write || 0
|
|
203
|
+
if (input > 0 || cacheRead > 0 || cacheWrite > 0) {
|
|
204
|
+
firstInputTokens = input + cacheRead + cacheWrite
|
|
205
|
+
break
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (firstInputTokens <= 0) {
|
|
210
|
+
state.systemPromptTokens = undefined
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let firstUserText = ""
|
|
215
|
+
for (const msg of messages) {
|
|
216
|
+
if (msg.info.role !== "user" || isIgnoredUserMessage(msg)) {
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
const parts = Array.isArray(msg.parts) ? msg.parts : []
|
|
220
|
+
for (const part of parts) {
|
|
221
|
+
if (part.type === "text" && !(part as any).ignored) {
|
|
222
|
+
firstUserText += part.text
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
break
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const estimatedSystemTokens = Math.max(0, firstInputTokens - countTokens(firstUserText))
|
|
229
|
+
state.systemPromptTokens = estimatedSystemTokens > 0 ? estimatedSystemTokens : undefined
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function shortenPath(input: string, workingDirectory?: string): string {
|
|
233
|
+
const inPathMatch = input.match(/^(.+) in (.+)$/)
|
|
234
|
+
if (inPathMatch) {
|
|
235
|
+
const prefix = inPathMatch[1]
|
|
236
|
+
const pathPart = inPathMatch[2]
|
|
237
|
+
const shortenedPath = shortenSinglePath(pathPart, workingDirectory)
|
|
238
|
+
return `${prefix} in ${shortenedPath}`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return shortenSinglePath(input, workingDirectory)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function shortenSinglePath(path: string, workingDirectory?: string): string {
|
|
245
|
+
if (workingDirectory) {
|
|
246
|
+
if (path.startsWith(workingDirectory + "/")) {
|
|
247
|
+
return path.slice(workingDirectory.length + 1)
|
|
248
|
+
}
|
|
249
|
+
if (path === workingDirectory) {
|
|
250
|
+
return "."
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return path
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function formatPrunedItemsList(
|
|
258
|
+
pruneToolIds: string[],
|
|
259
|
+
toolMetadata: Map<string, ToolParameterEntry>,
|
|
260
|
+
workingDirectory?: string,
|
|
261
|
+
): string[] {
|
|
262
|
+
const lines: string[] = []
|
|
263
|
+
|
|
264
|
+
for (const id of pruneToolIds) {
|
|
265
|
+
const metadata = toolMetadata.get(id)
|
|
266
|
+
|
|
267
|
+
if (metadata) {
|
|
268
|
+
const paramKey = extractParameterKey(metadata.tool, metadata.parameters)
|
|
269
|
+
if (paramKey) {
|
|
270
|
+
// Use 60 char limit to match notification style
|
|
271
|
+
const displayKey = truncate(shortenPath(paramKey, workingDirectory), 60)
|
|
272
|
+
lines.push(`→ ${metadata.tool}: ${displayKey}`)
|
|
273
|
+
} else {
|
|
274
|
+
lines.push(`→ ${metadata.tool}`)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const knownCount = pruneToolIds.filter((id) => toolMetadata.has(id)).length
|
|
280
|
+
const unknownCount = pruneToolIds.length - knownCount
|
|
281
|
+
|
|
282
|
+
if (unknownCount > 0) {
|
|
283
|
+
lines.push(`→ (${unknownCount} tool${unknownCount > 1 ? "s" : ""} with unknown metadata)`)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return lines
|
|
287
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@tarquinen/opencode-dcp",
|
|
4
|
-
"version": "3.2.
|
|
4
|
+
"version": "3.2.6-beta0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context",
|
|
7
7
|
"main": "./dist/index.js",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"./tui": {
|
|
15
15
|
"types": "./dist/tui/index.d.ts",
|
|
16
|
-
"import": "./
|
|
16
|
+
"import": "./tui/index.tsx"
|
|
17
17
|
}
|
|
18
18
|
},
|
|
19
19
|
"oc-plugin": [
|
|
@@ -22,6 +22,13 @@
|
|
|
22
22
|
],
|
|
23
23
|
"files": [
|
|
24
24
|
"dist/",
|
|
25
|
+
"index.ts",
|
|
26
|
+
"lib/**/*.ts",
|
|
27
|
+
"tui/index.tsx",
|
|
28
|
+
"tui/data/*.ts",
|
|
29
|
+
"tui/routes/*.tsx",
|
|
30
|
+
"tui/shared/*.ts",
|
|
31
|
+
"tui/slots/*.tsx",
|
|
25
32
|
"README.md",
|
|
26
33
|
"LICENSE",
|
|
27
34
|
"dcp.schema.json"
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { Logger } from "../../lib/logger"
|
|
2
|
+
import {
|
|
3
|
+
createSessionState,
|
|
4
|
+
loadSessionState,
|
|
5
|
+
type SessionState,
|
|
6
|
+
type WithParts,
|
|
7
|
+
} from "../../lib/state"
|
|
8
|
+
import {
|
|
9
|
+
findLastCompactionTimestamp,
|
|
10
|
+
getActiveSummaryTokenUsage,
|
|
11
|
+
loadPruneMap,
|
|
12
|
+
loadPruneMessagesState,
|
|
13
|
+
} from "../../lib/state/utils"
|
|
14
|
+
import { loadAllSessionStats } from "../../lib/state/persistence"
|
|
15
|
+
import { analyzeTokens, emptyBreakdown } from "../../lib/analysis/tokens"
|
|
16
|
+
import type { DcpContextSnapshot, DcpTuiClient } from "../shared/types"
|
|
17
|
+
|
|
18
|
+
const snapshotCache = new Map<string, DcpContextSnapshot>()
|
|
19
|
+
const inflightSnapshots = new Map<string, Promise<DcpContextSnapshot>>()
|
|
20
|
+
const CACHE_TTL_MS = 5000
|
|
21
|
+
|
|
22
|
+
export const createPlaceholderContextSnapshot = (
|
|
23
|
+
sessionID?: string,
|
|
24
|
+
notes: string[] = [],
|
|
25
|
+
): DcpContextSnapshot => ({
|
|
26
|
+
sessionID,
|
|
27
|
+
breakdown: emptyBreakdown(),
|
|
28
|
+
activeSummaryTokens: 0,
|
|
29
|
+
persisted: {
|
|
30
|
+
available: false,
|
|
31
|
+
activeBlockCount: 0,
|
|
32
|
+
activeBlocks: [],
|
|
33
|
+
},
|
|
34
|
+
messageStatuses: [],
|
|
35
|
+
allTimeStats: { totalTokensSaved: 0, sessionCount: 0 },
|
|
36
|
+
notes,
|
|
37
|
+
loadedAt: Date.now(),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
function cleanBlockSummary(raw: string): string {
|
|
41
|
+
return raw
|
|
42
|
+
.replace(/^\s*\[Compressed conversation section\]\s*/i, "")
|
|
43
|
+
.replace(/(?:\r?\n)*<dcp-message-id>b\d+<\/dcp-message-id>\s*$/i, "")
|
|
44
|
+
.trim()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const buildState = async (
|
|
48
|
+
sessionID: string,
|
|
49
|
+
messages: WithParts[],
|
|
50
|
+
logger: Logger,
|
|
51
|
+
): Promise<{ state: SessionState; persisted: Awaited<ReturnType<typeof loadSessionState>> }> => {
|
|
52
|
+
const state = createSessionState()
|
|
53
|
+
const persisted = await loadSessionState(sessionID, logger)
|
|
54
|
+
|
|
55
|
+
state.sessionId = sessionID
|
|
56
|
+
state.lastCompaction = findLastCompactionTimestamp(messages)
|
|
57
|
+
state.stats.pruneTokenCounter = 0
|
|
58
|
+
state.stats.totalPruneTokens = persisted?.stats?.totalPruneTokens || 0
|
|
59
|
+
state.prune.tools = loadPruneMap(persisted?.prune?.tools)
|
|
60
|
+
state.prune.messages = loadPruneMessagesState(persisted?.prune?.messages)
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
state,
|
|
64
|
+
persisted,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const loadContextSnapshot = async (
|
|
69
|
+
client: DcpTuiClient,
|
|
70
|
+
logger: Logger,
|
|
71
|
+
sessionID?: string,
|
|
72
|
+
): Promise<DcpContextSnapshot> => {
|
|
73
|
+
if (!sessionID) {
|
|
74
|
+
return createPlaceholderContextSnapshot(undefined, ["No active session."])
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const messagesResult = await client.session.messages({ sessionID })
|
|
78
|
+
const rawMessages =
|
|
79
|
+
messagesResult && typeof messagesResult === "object" && "data" in messagesResult
|
|
80
|
+
? messagesResult.data
|
|
81
|
+
: messagesResult
|
|
82
|
+
const messages = Array.isArray(rawMessages) ? (rawMessages as WithParts[]) : ([] as WithParts[])
|
|
83
|
+
|
|
84
|
+
const { state, persisted } = await buildState(sessionID, messages, logger)
|
|
85
|
+
const [{ breakdown, messageStatuses }, aggregated] = await Promise.all([
|
|
86
|
+
Promise.resolve(analyzeTokens(state, messages)),
|
|
87
|
+
loadAllSessionStats(logger),
|
|
88
|
+
])
|
|
89
|
+
|
|
90
|
+
const allBlocks = Array.from(state.prune.messages.activeBlockIds)
|
|
91
|
+
.map((blockID) => state.prune.messages.blocksById.get(blockID))
|
|
92
|
+
.filter((block): block is NonNullable<typeof block> => !!block && !!block.topic)
|
|
93
|
+
.map((block) => ({ topic: block.topic, summary: cleanBlockSummary(block.summary) }))
|
|
94
|
+
|
|
95
|
+
const notes: string[] = []
|
|
96
|
+
if (persisted) {
|
|
97
|
+
notes.push("Using live session messages plus persisted DCP state.")
|
|
98
|
+
} else {
|
|
99
|
+
notes.push("No saved DCP state found for this session yet.")
|
|
100
|
+
}
|
|
101
|
+
if (messages.length === 0) {
|
|
102
|
+
notes.push("This session does not have any messages yet.")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
sessionID,
|
|
107
|
+
breakdown,
|
|
108
|
+
activeSummaryTokens: getActiveSummaryTokenUsage(state),
|
|
109
|
+
persisted: {
|
|
110
|
+
available: !!persisted,
|
|
111
|
+
activeBlockCount: state.prune.messages.activeBlockIds.size,
|
|
112
|
+
activeBlocks: allBlocks,
|
|
113
|
+
lastUpdated: persisted?.lastUpdated,
|
|
114
|
+
},
|
|
115
|
+
messageStatuses,
|
|
116
|
+
allTimeStats: {
|
|
117
|
+
totalTokensSaved: aggregated.totalTokens,
|
|
118
|
+
sessionCount: aggregated.sessionCount,
|
|
119
|
+
},
|
|
120
|
+
notes,
|
|
121
|
+
loadedAt: Date.now(),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const peekContextSnapshot = (sessionID?: string): DcpContextSnapshot | undefined => {
|
|
126
|
+
if (!sessionID) return undefined
|
|
127
|
+
return snapshotCache.get(sessionID)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const invalidateContextSnapshot = (sessionID?: string) => {
|
|
131
|
+
if (!sessionID) {
|
|
132
|
+
snapshotCache.clear()
|
|
133
|
+
inflightSnapshots.clear()
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
snapshotCache.delete(sessionID)
|
|
137
|
+
inflightSnapshots.delete(sessionID)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const loadContextSnapshotCached = async (
|
|
141
|
+
client: DcpTuiClient,
|
|
142
|
+
logger: Logger,
|
|
143
|
+
sessionID?: string,
|
|
144
|
+
): Promise<DcpContextSnapshot> => {
|
|
145
|
+
if (!sessionID) {
|
|
146
|
+
return createPlaceholderContextSnapshot(undefined, ["No active session."])
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const cached = snapshotCache.get(sessionID)
|
|
150
|
+
if (cached && Date.now() - cached.loadedAt < CACHE_TTL_MS) {
|
|
151
|
+
return cached
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const inflight = inflightSnapshots.get(sessionID)
|
|
155
|
+
if (inflight) {
|
|
156
|
+
return inflight
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const request = loadContextSnapshot(client, logger, sessionID)
|
|
160
|
+
.then((snapshot) => {
|
|
161
|
+
snapshotCache.set(sessionID, snapshot)
|
|
162
|
+
return snapshot
|
|
163
|
+
})
|
|
164
|
+
.catch((error) => {
|
|
165
|
+
logger.error("Failed to load TUI context snapshot", {
|
|
166
|
+
sessionID,
|
|
167
|
+
error: error instanceof Error ? error.message : String(error),
|
|
168
|
+
})
|
|
169
|
+
throw error
|
|
170
|
+
})
|
|
171
|
+
.finally(() => {
|
|
172
|
+
inflightSnapshots.delete(sessionID)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
inflightSnapshots.set(sessionID, request)
|
|
176
|
+
return request
|
|
177
|
+
}
|
package/tui/index.tsx
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import type { TuiPlugin } from "@opencode-ai/plugin/tui"
|
|
3
|
+
import { getConfigForDirectory } from "../lib/config"
|
|
4
|
+
import { Logger } from "../lib/logger"
|
|
5
|
+
import { createSidebarContentSlot } from "./slots/sidebar-content"
|
|
6
|
+
import { createSummaryRoute } from "./routes/summary"
|
|
7
|
+
import { NAMES } from "./shared/names"
|
|
8
|
+
|
|
9
|
+
const tui: TuiPlugin = async (api) => {
|
|
10
|
+
const config = getConfigForDirectory(api.state.path.directory, (title, message) => {
|
|
11
|
+
api.ui.toast({
|
|
12
|
+
title,
|
|
13
|
+
message,
|
|
14
|
+
variant: "warning",
|
|
15
|
+
duration: 7000,
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
if (!config.enabled) return
|
|
19
|
+
|
|
20
|
+
const logger = new Logger(config.tui.debug, "tui")
|
|
21
|
+
|
|
22
|
+
api.route.register([createSummaryRoute(api)])
|
|
23
|
+
|
|
24
|
+
if (config.tui.sidebar) {
|
|
25
|
+
api.slots.register(createSidebarContentSlot(api, NAMES, logger))
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const id = "opencode-dynamic-context-pruning"
|
|
30
|
+
|
|
31
|
+
export default {
|
|
32
|
+
id,
|
|
33
|
+
tui,
|
|
34
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { createMemo, createSignal, For, Show } from "solid-js"
|
|
3
|
+
import { useKeyboard } from "@opentui/solid"
|
|
4
|
+
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
|
5
|
+
import { getPalette, type DcpPalette } from "../shared/theme"
|
|
6
|
+
import { LABEL, NAMES } from "../shared/names"
|
|
7
|
+
|
|
8
|
+
const SINGLE_BORDER = { type: "single" } as any
|
|
9
|
+
|
|
10
|
+
interface SummaryRouteParams {
|
|
11
|
+
topic?: string
|
|
12
|
+
summary?: string
|
|
13
|
+
sessionID?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface CollapsibleSection {
|
|
17
|
+
label: string
|
|
18
|
+
content: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ParsedSummary {
|
|
22
|
+
body: string
|
|
23
|
+
sections: CollapsibleSection[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// These patterns must stay in sync with the exact headings produced by
|
|
27
|
+
// lib/compress (compactSummary output). If compression wording changes,
|
|
28
|
+
// update these patterns accordingly.
|
|
29
|
+
const SECTION_HEADINGS: { pattern: RegExp; label: string }[] = [
|
|
30
|
+
{
|
|
31
|
+
pattern: /\n*The following user messages were sent in this conversation verbatim:/,
|
|
32
|
+
label: "Protected User Messages",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
pattern: /\n*The following protected tools were used in this conversation as well:/,
|
|
36
|
+
label: "Protected Tools",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
pattern:
|
|
40
|
+
/\n*The following previously compressed summaries were also part of this conversation section:/,
|
|
41
|
+
label: "Included Compressed Summaries",
|
|
42
|
+
},
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
function parseSummary(raw: string): ParsedSummary {
|
|
46
|
+
if (!raw) return { body: "", sections: [] }
|
|
47
|
+
|
|
48
|
+
const matches: { index: number; length: number; label: string }[] = []
|
|
49
|
+
for (const heading of SECTION_HEADINGS) {
|
|
50
|
+
const match = raw.match(heading.pattern)
|
|
51
|
+
if (match && match.index !== undefined) {
|
|
52
|
+
matches.push({ index: match.index, length: match[0].length, label: heading.label })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (matches.length === 0) {
|
|
57
|
+
return { body: raw, sections: [] }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
matches.sort((a, b) => a.index - b.index)
|
|
61
|
+
|
|
62
|
+
const body = raw.slice(0, matches[0].index).trimEnd()
|
|
63
|
+
const sections: CollapsibleSection[] = []
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < matches.length; i++) {
|
|
66
|
+
const start = matches[i].index + matches[i].length
|
|
67
|
+
const end = i + 1 < matches.length ? matches[i + 1].index : raw.length
|
|
68
|
+
const content = raw.slice(start, end).trim()
|
|
69
|
+
if (content) {
|
|
70
|
+
sections.push({ label: matches[i].label, content })
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { body, sections }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function CollapsibleSectionRow(props: { section: CollapsibleSection; palette: DcpPalette }) {
|
|
78
|
+
const [expanded, setExpanded] = createSignal(false)
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<box flexDirection="column" width="100%" marginTop={1}>
|
|
82
|
+
<box flexDirection="row" width="100%" height={1}>
|
|
83
|
+
<box
|
|
84
|
+
backgroundColor={props.palette.base}
|
|
85
|
+
height={1}
|
|
86
|
+
onMouseUp={() => setExpanded(!expanded())}
|
|
87
|
+
>
|
|
88
|
+
<text fg={props.palette.accent}>{expanded() ? " ▼ " : " ▶ "}</text>
|
|
89
|
+
</box>
|
|
90
|
+
<box height={1} paddingLeft={1}>
|
|
91
|
+
<text fg={props.palette.muted} onMouseUp={() => setExpanded(!expanded())}>
|
|
92
|
+
{props.section.label}
|
|
93
|
+
</text>
|
|
94
|
+
</box>
|
|
95
|
+
</box>
|
|
96
|
+
<Show when={expanded()}>
|
|
97
|
+
<box width="100%" marginTop={1} paddingLeft={2} flexDirection="column">
|
|
98
|
+
<text fg={props.palette.text}>{props.section.content}</text>
|
|
99
|
+
</box>
|
|
100
|
+
</Show>
|
|
101
|
+
</box>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function SummaryScreen(props: { api: TuiPluginApi; params: SummaryRouteParams }) {
|
|
106
|
+
const palette = createMemo(() => getPalette(props.api.theme.current as Record<string, unknown>))
|
|
107
|
+
const parsed = createMemo(() => parseSummary(props.params.summary || ""))
|
|
108
|
+
|
|
109
|
+
const keys = props.api.keybind.create({ close: "escape" })
|
|
110
|
+
|
|
111
|
+
useKeyboard((evt: any) => {
|
|
112
|
+
if (props.api.route.current.name !== NAMES.routes.summary) return
|
|
113
|
+
if (props.api.ui.dialog.open) return
|
|
114
|
+
const matched = keys.match("close", evt)
|
|
115
|
+
if (!matched) return
|
|
116
|
+
evt.preventDefault()
|
|
117
|
+
evt.stopPropagation()
|
|
118
|
+
const sessionID = props.params.sessionID
|
|
119
|
+
if (sessionID) {
|
|
120
|
+
props.api.route.navigate("session", { sessionID })
|
|
121
|
+
} else {
|
|
122
|
+
props.api.route.navigate("home")
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<box
|
|
128
|
+
flexDirection="column"
|
|
129
|
+
width="100%"
|
|
130
|
+
height="100%"
|
|
131
|
+
padding={1}
|
|
132
|
+
backgroundColor={palette().surface}
|
|
133
|
+
>
|
|
134
|
+
<box flexDirection="row" gap={1} alignItems="center">
|
|
135
|
+
<box paddingLeft={1} paddingRight={1} backgroundColor={palette().accent}>
|
|
136
|
+
<text fg={palette().panel}>
|
|
137
|
+
<b>{LABEL}</b>
|
|
138
|
+
</text>
|
|
139
|
+
</box>
|
|
140
|
+
<text fg={palette().accent}>
|
|
141
|
+
<b>{props.params.topic || "Compression Summary"}</b>
|
|
142
|
+
</text>
|
|
143
|
+
</box>
|
|
144
|
+
|
|
145
|
+
<box
|
|
146
|
+
flexGrow={1}
|
|
147
|
+
width="100%"
|
|
148
|
+
marginTop={1}
|
|
149
|
+
border={SINGLE_BORDER}
|
|
150
|
+
borderColor={palette().border}
|
|
151
|
+
padding={1}
|
|
152
|
+
flexDirection="column"
|
|
153
|
+
>
|
|
154
|
+
<text fg={palette().text}>{parsed().body || "(no summary available)"}</text>
|
|
155
|
+
|
|
156
|
+
<For each={parsed().sections}>
|
|
157
|
+
{(section) => <CollapsibleSectionRow section={section} palette={palette()} />}
|
|
158
|
+
</For>
|
|
159
|
+
</box>
|
|
160
|
+
|
|
161
|
+
<box marginTop={1}>
|
|
162
|
+
<text {...({ dim: true } as any)} fg={palette().muted}>
|
|
163
|
+
Press Escape to return
|
|
164
|
+
</text>
|
|
165
|
+
</box>
|
|
166
|
+
</box>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const createSummaryRoute = (api: TuiPluginApi) => ({
|
|
171
|
+
name: NAMES.routes.summary,
|
|
172
|
+
render: (input: { params?: Record<string, unknown> }) => (
|
|
173
|
+
<SummaryScreen api={api} params={(input.params ?? {}) as SummaryRouteParams} />
|
|
174
|
+
),
|
|
175
|
+
})
|