@tarquinen/opencode-dcp 3.1.12 → 3.1.14
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/README.md +14 -9
- package/dist/index.js +105 -39
- package/dist/index.js.map +1 -1
- package/dist/lib/commands/context.d.ts +15 -0
- package/dist/lib/commands/context.d.ts.map +1 -1
- package/dist/lib/commands/help.d.ts +1 -0
- package/dist/lib/commands/help.d.ts.map +1 -1
- package/dist/lib/commands/manual.d.ts +1 -1
- package/dist/lib/commands/manual.d.ts.map +1 -1
- package/dist/lib/commands/stats.d.ts +10 -0
- package/dist/lib/commands/stats.d.ts.map +1 -1
- package/dist/lib/compress/pipeline.d.ts.map +1 -1
- package/dist/lib/hooks.d.ts.map +1 -1
- package/dist/lib/state/persistence.d.ts +3 -0
- package/dist/lib/state/persistence.d.ts.map +1 -1
- package/dist/lib/state/state.d.ts +1 -0
- package/dist/lib/state/state.d.ts.map +1 -1
- package/dist/lib/tui/commands.d.ts +3 -0
- package/dist/lib/tui/commands.d.ts.map +1 -0
- package/dist/lib/tui/data.d.ts +14 -0
- package/dist/lib/tui/data.d.ts.map +1 -0
- package/dist/lib/tui/dialogs.d.ts +30 -0
- package/dist/lib/tui/dialogs.d.ts.map +1 -0
- package/dist/lib/tui/format.d.ts +4 -0
- package/dist/lib/tui/format.d.ts.map +1 -0
- package/dist/lib/tui/modals.d.ts +10 -0
- package/dist/lib/tui/modals.d.ts.map +1 -0
- package/dist/lib/tui/types.d.ts +15 -0
- package/dist/lib/tui/types.d.ts.map +1 -0
- package/dist/lib/tui/ui.d.ts +48 -0
- package/dist/lib/tui/ui.d.ts.map +1 -0
- package/dist/tui.d.ts +7 -0
- package/dist/tui.d.ts.map +1 -0
- package/lib/auth.ts +37 -0
- package/lib/commands/compression-targets.ts +137 -0
- package/lib/commands/context.ts +305 -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 +127 -0
- package/lib/commands/recompress.ts +224 -0
- package/lib/commands/stats.ts +159 -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 +145 -0
- package/lib/compress/pipeline.ts +108 -0
- package/lib/compress/protected-content.ts +208 -0
- package/lib/compress/range-utils.ts +308 -0
- package/lib/compress/range.ts +192 -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 +1007 -0
- package/lib/hooks.ts +375 -0
- package/lib/host-permissions.ts +101 -0
- package/lib/logger.ts +226 -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 +233 -0
- package/lib/messages/query.ts +72 -0
- package/lib/messages/reasoning-strip.ts +40 -0
- package/lib/messages/shape.ts +50 -0
- package/lib/messages/sync.ts +124 -0
- package/lib/messages/utils.ts +185 -0
- package/lib/prompts/compress-message.ts +43 -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 +305 -0
- package/lib/state/state.ts +208 -0
- package/lib/state/tool-cache.ts +98 -0
- package/lib/state/types.ts +111 -0
- package/lib/state/utils.ts +345 -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 +164 -0
- package/lib/tui/commands.ts +31 -0
- package/lib/tui/data.ts +73 -0
- package/lib/tui/dialogs.tsx +235 -0
- package/lib/tui/format.ts +25 -0
- package/lib/tui/modals.tsx +92 -0
- package/lib/tui/types.ts +16 -0
- package/lib/tui/ui.tsx +219 -0
- package/lib/ui/notification.ts +347 -0
- package/lib/ui/utils.ts +304 -0
- package/lib/update.ts +185 -0
- package/package.json +21 -5
- package/tui.tsx +26 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { PluginConfig } from "../config"
|
|
2
|
+
import { Logger } from "../logger"
|
|
3
|
+
import type { SessionState, WithParts } from "../state"
|
|
4
|
+
import {
|
|
5
|
+
getFilePathsFromParameters,
|
|
6
|
+
isFilePathProtected,
|
|
7
|
+
isToolNameProtected,
|
|
8
|
+
} from "../protected-patterns"
|
|
9
|
+
import { getTotalToolTokens } from "../token-utils"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Purge Errors strategy - prunes tool inputs for tools that errored
|
|
13
|
+
* after they are older than a configurable number of turns.
|
|
14
|
+
* The error message is preserved, but the (potentially large) inputs
|
|
15
|
+
* are removed to save context.
|
|
16
|
+
*
|
|
17
|
+
* Modifies the session state in place to add pruned tool call IDs.
|
|
18
|
+
*/
|
|
19
|
+
export const purgeErrors = (
|
|
20
|
+
state: SessionState,
|
|
21
|
+
logger: Logger,
|
|
22
|
+
config: PluginConfig,
|
|
23
|
+
messages: WithParts[],
|
|
24
|
+
): void => {
|
|
25
|
+
if (state.manualMode && !config.manualMode.automaticStrategies) {
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!config.strategies.purgeErrors.enabled) {
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const allToolIds = state.toolIdList
|
|
34
|
+
if (allToolIds.length === 0) {
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Filter out IDs already pruned
|
|
39
|
+
const unprunedIds = allToolIds.filter((id) => !state.prune.tools.has(id))
|
|
40
|
+
|
|
41
|
+
if (unprunedIds.length === 0) {
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const protectedTools = config.strategies.purgeErrors.protectedTools
|
|
46
|
+
const turnThreshold = Math.max(1, config.strategies.purgeErrors.turns)
|
|
47
|
+
|
|
48
|
+
const newPruneIds: string[] = []
|
|
49
|
+
|
|
50
|
+
for (const id of unprunedIds) {
|
|
51
|
+
const metadata = state.toolParameters.get(id)
|
|
52
|
+
if (!metadata) {
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Skip protected tools
|
|
57
|
+
if (isToolNameProtected(metadata.tool, protectedTools)) {
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const filePaths = getFilePathsFromParameters(metadata.tool, metadata.parameters)
|
|
62
|
+
if (isFilePathProtected(filePaths, config.protectedFilePatterns)) {
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Only process error tools
|
|
67
|
+
if (metadata.status !== "error") {
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Check if the tool is old enough to prune
|
|
72
|
+
const turnAge = state.currentTurn - metadata.turn
|
|
73
|
+
if (turnAge >= turnThreshold) {
|
|
74
|
+
newPruneIds.push(id)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (newPruneIds.length > 0) {
|
|
79
|
+
state.stats.totalPruneTokens += getTotalToolTokens(state, newPruneIds)
|
|
80
|
+
for (const id of newPruneIds) {
|
|
81
|
+
const entry = state.toolParameters.get(id)
|
|
82
|
+
state.prune.tools.set(id, entry?.tokenCount ?? 0)
|
|
83
|
+
}
|
|
84
|
+
logger.debug(
|
|
85
|
+
`Marked ${newPruneIds.length} error tool calls for pruning (older than ${turnThreshold} turns)`,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { WithParts } from "../state"
|
|
2
|
+
|
|
3
|
+
const SUB_AGENT_RESULT_BLOCK_REGEX = /(<task_result>\s*)([\s\S]*?)(\s*<\/task_result>)/i
|
|
4
|
+
|
|
5
|
+
export function getSubAgentId(part: any): string | null {
|
|
6
|
+
const sessionId = part?.state?.metadata?.sessionId
|
|
7
|
+
if (typeof sessionId !== "string") {
|
|
8
|
+
return null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const value = sessionId.trim()
|
|
12
|
+
return value.length > 0 ? value : null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function buildSubagentResultText(messages: WithParts[]): string {
|
|
16
|
+
const assistantMessages = messages.filter((message) => message.info.role === "assistant")
|
|
17
|
+
if (assistantMessages.length === 0) {
|
|
18
|
+
return ""
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const lastAssistant = assistantMessages[assistantMessages.length - 1]
|
|
22
|
+
const lastText = getLastTextPart(lastAssistant)
|
|
23
|
+
|
|
24
|
+
if (assistantMessages.length < 2) {
|
|
25
|
+
return lastText
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const secondToLastAssistant = assistantMessages[assistantMessages.length - 2]
|
|
29
|
+
if (!assistantMessageHasCompressTool(secondToLastAssistant)) {
|
|
30
|
+
return lastText
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const secondToLastText = getLastTextPart(secondToLastAssistant)
|
|
34
|
+
return [secondToLastText, lastText].filter((text) => text.length > 0).join("\n\n")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function mergeSubagentResult(output: string, subAgentResultText: string): string {
|
|
38
|
+
if (!subAgentResultText || typeof output !== "string") {
|
|
39
|
+
return output
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return output.replace(
|
|
43
|
+
SUB_AGENT_RESULT_BLOCK_REGEX,
|
|
44
|
+
(_match, openTag: string, _body: string, closeTag: string) =>
|
|
45
|
+
`${openTag}${subAgentResultText}${closeTag}`,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getLastTextPart(message: WithParts): string {
|
|
50
|
+
const parts = Array.isArray(message.parts) ? message.parts : []
|
|
51
|
+
for (let index = parts.length - 1; index >= 0; index--) {
|
|
52
|
+
const part = parts[index]
|
|
53
|
+
if (part.type !== "text" || typeof part.text !== "string") {
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const text = part.text.trim()
|
|
58
|
+
if (!text) {
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return text
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return ""
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function assistantMessageHasCompressTool(message: WithParts): boolean {
|
|
69
|
+
const parts = Array.isArray(message.parts) ? message.parts : []
|
|
70
|
+
return parts.some(
|
|
71
|
+
(part) =>
|
|
72
|
+
part.type === "tool" && part.tool === "compress" && part.state?.status === "completed",
|
|
73
|
+
)
|
|
74
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { SessionState, WithParts } from "./state"
|
|
2
|
+
import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2"
|
|
3
|
+
import { Logger } from "./logger"
|
|
4
|
+
import * as _anthropicTokenizer from "@anthropic-ai/tokenizer"
|
|
5
|
+
const anthropicCountTokens = (_anthropicTokenizer.countTokens ??
|
|
6
|
+
(_anthropicTokenizer as any).default?.countTokens) as typeof _anthropicTokenizer.countTokens
|
|
7
|
+
import { getLastUserMessage } from "./messages/query"
|
|
8
|
+
|
|
9
|
+
export function getCurrentTokenUsage(state: SessionState, messages: WithParts[]): number {
|
|
10
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
11
|
+
const msg = messages[i]
|
|
12
|
+
if (msg.info.role !== "assistant") {
|
|
13
|
+
continue
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const assistantInfo = msg.info as AssistantMessage
|
|
17
|
+
if ((assistantInfo.tokens?.output || 0) <= 0) {
|
|
18
|
+
continue
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (
|
|
22
|
+
state.lastCompaction > 0 &&
|
|
23
|
+
(msg.info.time.created < state.lastCompaction ||
|
|
24
|
+
(msg.info.summary === true && msg.info.time.created === state.lastCompaction))
|
|
25
|
+
) {
|
|
26
|
+
return 0
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const input = assistantInfo.tokens?.input || 0
|
|
30
|
+
const output = assistantInfo.tokens?.output || 0
|
|
31
|
+
const reasoning = assistantInfo.tokens?.reasoning || 0
|
|
32
|
+
const cacheRead = assistantInfo.tokens?.cache?.read || 0
|
|
33
|
+
const cacheWrite = assistantInfo.tokens?.cache?.write || 0
|
|
34
|
+
return input + output + reasoning + cacheRead + cacheWrite
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return 0
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getCurrentParams(
|
|
41
|
+
state: SessionState,
|
|
42
|
+
messages: WithParts[],
|
|
43
|
+
logger: Logger,
|
|
44
|
+
): {
|
|
45
|
+
providerId: string | undefined
|
|
46
|
+
modelId: string | undefined
|
|
47
|
+
agent: string | undefined
|
|
48
|
+
variant: string | undefined
|
|
49
|
+
} {
|
|
50
|
+
const userMsg = getLastUserMessage(messages)
|
|
51
|
+
if (!userMsg) {
|
|
52
|
+
logger.debug("No user message found when determining current params")
|
|
53
|
+
return {
|
|
54
|
+
providerId: undefined,
|
|
55
|
+
modelId: undefined,
|
|
56
|
+
agent: undefined,
|
|
57
|
+
variant: undefined,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const userInfo = userMsg.info as UserMessage
|
|
61
|
+
const agent: string = userInfo.agent
|
|
62
|
+
const providerId: string | undefined = userInfo.model.providerID
|
|
63
|
+
const modelId: string | undefined = userInfo.model.modelID
|
|
64
|
+
const variant: string | undefined = userInfo.model.variant
|
|
65
|
+
|
|
66
|
+
return { providerId, modelId, agent, variant }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function countTokens(text: string): number {
|
|
70
|
+
if (!text) return 0
|
|
71
|
+
try {
|
|
72
|
+
return anthropicCountTokens(text)
|
|
73
|
+
} catch {
|
|
74
|
+
return Math.round(text.length / 4)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function estimateTokensBatch(texts: string[]): number {
|
|
79
|
+
if (texts.length === 0) return 0
|
|
80
|
+
return countTokens(texts.join(" "))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const COMPACTED_TOOL_OUTPUT_PLACEHOLDER = "[Old tool result content cleared]"
|
|
84
|
+
|
|
85
|
+
function stringifyToolContent(value: unknown): string {
|
|
86
|
+
return typeof value === "string" ? value : JSON.stringify(value)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function extractCompletedToolOutput(part: any): string | undefined {
|
|
90
|
+
if (
|
|
91
|
+
part?.type !== "tool" ||
|
|
92
|
+
part.state?.status !== "completed" ||
|
|
93
|
+
part.state?.output === undefined
|
|
94
|
+
) {
|
|
95
|
+
return undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (part.state?.time?.compacted) {
|
|
99
|
+
return COMPACTED_TOOL_OUTPUT_PLACEHOLDER
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return stringifyToolContent(part.state.output)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function extractToolContent(part: any): string[] {
|
|
106
|
+
const contents: string[] = []
|
|
107
|
+
|
|
108
|
+
if (part?.type !== "tool") {
|
|
109
|
+
return contents
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (part.state?.input !== undefined) {
|
|
113
|
+
contents.push(stringifyToolContent(part.state.input))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const completedOutput = extractCompletedToolOutput(part)
|
|
117
|
+
if (completedOutput !== undefined) {
|
|
118
|
+
contents.push(completedOutput)
|
|
119
|
+
} else if (part.state?.status === "error" && part.state?.error) {
|
|
120
|
+
contents.push(stringifyToolContent(part.state.error))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return contents
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function countToolTokens(part: any): number {
|
|
127
|
+
const contents = extractToolContent(part)
|
|
128
|
+
return estimateTokensBatch(contents)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function getTotalToolTokens(state: SessionState, toolIds: string[]): number {
|
|
132
|
+
let total = 0
|
|
133
|
+
for (const id of toolIds) {
|
|
134
|
+
const entry = state.toolParameters.get(id)
|
|
135
|
+
total += entry?.tokenCount ?? 0
|
|
136
|
+
}
|
|
137
|
+
return total
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function countMessageTextTokens(msg: WithParts): number {
|
|
141
|
+
const texts: string[] = []
|
|
142
|
+
const parts = Array.isArray(msg.parts) ? msg.parts : []
|
|
143
|
+
for (const part of parts) {
|
|
144
|
+
if (part.type === "text") {
|
|
145
|
+
texts.push(part.text)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (texts.length === 0) return 0
|
|
149
|
+
return estimateTokensBatch(texts)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function countAllMessageTokens(msg: WithParts): number {
|
|
153
|
+
const parts = Array.isArray(msg.parts) ? msg.parts : []
|
|
154
|
+
const texts: string[] = []
|
|
155
|
+
for (const part of parts) {
|
|
156
|
+
if (part.type === "text") {
|
|
157
|
+
texts.push(part.text)
|
|
158
|
+
} else {
|
|
159
|
+
texts.push(...extractToolContent(part))
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (texts.length === 0) return 0
|
|
163
|
+
return estimateTokensBatch(texts)
|
|
164
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { DcpCommand, TuiApi } from "./types"
|
|
2
|
+
|
|
3
|
+
export function registerCommands(api: TuiApi, commands: DcpCommand[]) {
|
|
4
|
+
const keymap = (api as any).keymap
|
|
5
|
+
if (keymap?.registerLayer) {
|
|
6
|
+
keymap.registerLayer({
|
|
7
|
+
commands: commands.map((command) => ({
|
|
8
|
+
namespace: "palette",
|
|
9
|
+
name: command.name,
|
|
10
|
+
title: command.title,
|
|
11
|
+
desc: command.description,
|
|
12
|
+
category: "DCP",
|
|
13
|
+
slashName: command.slashName,
|
|
14
|
+
slashAliases: command.slashAliases,
|
|
15
|
+
run: command.run,
|
|
16
|
+
})),
|
|
17
|
+
})
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
api.command?.register(() =>
|
|
22
|
+
commands.map((command) => ({
|
|
23
|
+
title: command.title,
|
|
24
|
+
value: command.name,
|
|
25
|
+
description: command.description,
|
|
26
|
+
category: "DCP",
|
|
27
|
+
slash: { name: command.slashName, aliases: command.slashAliases },
|
|
28
|
+
onSelect: command.run,
|
|
29
|
+
})),
|
|
30
|
+
)
|
|
31
|
+
}
|
package/lib/tui/data.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { getConfig, type PluginConfig } from "../config"
|
|
2
|
+
import { Logger } from "../logger"
|
|
3
|
+
import { filterMessages } from "../messages/shape"
|
|
4
|
+
import { createSessionState, type SessionState, type WithParts } from "../state"
|
|
5
|
+
import { loadSessionState } from "../state/persistence"
|
|
6
|
+
import { findLastCompactionTimestamp, loadPruneMap, loadPruneMessagesState } from "../state/utils"
|
|
7
|
+
import type { TuiApi } from "./types"
|
|
8
|
+
|
|
9
|
+
export const logger = new Logger(false)
|
|
10
|
+
|
|
11
|
+
export function loadConfig(api: TuiApi): PluginConfig {
|
|
12
|
+
return getConfig({
|
|
13
|
+
client: api.client,
|
|
14
|
+
directory: api.state.path.directory,
|
|
15
|
+
worktree: api.state.path.worktree,
|
|
16
|
+
} as any)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function activeSessionID(api: TuiApi): string | undefined {
|
|
20
|
+
const current = api.route.current
|
|
21
|
+
if (current.name !== "session") return undefined
|
|
22
|
+
const sessionID = current.params?.sessionID
|
|
23
|
+
return typeof sessionID === "string" ? sessionID : undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function sessionMessages(api: TuiApi, sessionID: string): WithParts[] {
|
|
27
|
+
const messages = api.state.session.messages(sessionID)
|
|
28
|
+
return filterMessages(
|
|
29
|
+
messages.map((info) => ({
|
|
30
|
+
info,
|
|
31
|
+
parts: api.state.part(info.id),
|
|
32
|
+
})) as unknown as WithParts[],
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function buildSessionState(
|
|
37
|
+
sessionID: string,
|
|
38
|
+
messages: WithParts[],
|
|
39
|
+
config: PluginConfig,
|
|
40
|
+
): Promise<SessionState> {
|
|
41
|
+
const state = createSessionState()
|
|
42
|
+
state.sessionId = sessionID
|
|
43
|
+
state.manualMode = config.manualMode.enabled ? "active" : false
|
|
44
|
+
state.lastCompaction = findLastCompactionTimestamp(messages)
|
|
45
|
+
|
|
46
|
+
const persisted = await loadSessionState(sessionID, logger)
|
|
47
|
+
if (persisted) {
|
|
48
|
+
if (typeof persisted.manualMode === "boolean") {
|
|
49
|
+
state.manualMode = persisted.manualMode ? "active" : false
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
state.prune.tools = loadPruneMap(persisted.prune.tools)
|
|
53
|
+
state.prune.messages = loadPruneMessagesState(persisted.prune.messages)
|
|
54
|
+
state.nudges.contextLimitAnchors = new Set(persisted.nudges.contextLimitAnchors || [])
|
|
55
|
+
state.nudges.turnNudgeAnchors = new Set(persisted.nudges.turnNudgeAnchors || [])
|
|
56
|
+
state.nudges.iterationNudgeAnchors = new Set(persisted.nudges.iterationNudgeAnchors || [])
|
|
57
|
+
state.stats = {
|
|
58
|
+
pruneTokenCounter: persisted.stats?.pruneTokenCounter || 0,
|
|
59
|
+
totalPruneTokens: persisted.stats?.totalPruneTokens || 0,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return state
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function loadSessionData(api: TuiApi, config: PluginConfig) {
|
|
67
|
+
const sessionID = activeSessionID(api)
|
|
68
|
+
if (!sessionID) return undefined
|
|
69
|
+
|
|
70
|
+
const messages = sessionMessages(api, sessionID)
|
|
71
|
+
const state = await buildSessionState(sessionID, messages, config)
|
|
72
|
+
return { state, messages }
|
|
73
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
|
|
3
|
+
import { compressPermission } from "../compress-permission"
|
|
4
|
+
import { analyzeContextTokens } from "../commands/context"
|
|
5
|
+
import type { PluginConfig } from "../config"
|
|
6
|
+
import type { SessionState, WithParts } from "../state"
|
|
7
|
+
import { formatTokenCount } from "../ui/utils"
|
|
8
|
+
import { TextAttributes } from "@opentui/core"
|
|
9
|
+
import { formatDuration, formatRatio } from "./format"
|
|
10
|
+
import { ActionRow, Card, DcpFrame, Metric, Progress, PromptRow, StatusPill } from "./ui"
|
|
11
|
+
import type { StatsReport, TuiApi } from "./types"
|
|
12
|
+
|
|
13
|
+
export function StatusDialog(props: {
|
|
14
|
+
api: TuiApi
|
|
15
|
+
title: string
|
|
16
|
+
eyebrow: string
|
|
17
|
+
message: string
|
|
18
|
+
}) {
|
|
19
|
+
return (
|
|
20
|
+
<DcpFrame api={props.api} title={props.title} eyebrow={props.eyebrow}>
|
|
21
|
+
<box paddingTop={1} paddingBottom={1}>
|
|
22
|
+
<text fg={props.api.theme.current.textMuted}>{props.message}</text>
|
|
23
|
+
</box>
|
|
24
|
+
</DcpFrame>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ContextDialog(props: {
|
|
29
|
+
api: TuiApi
|
|
30
|
+
state: SessionState
|
|
31
|
+
messages: WithParts[]
|
|
32
|
+
onBack: () => void
|
|
33
|
+
}) {
|
|
34
|
+
const theme = props.api.theme.current
|
|
35
|
+
const breakdown = analyzeContextTokens(props.state, props.messages)
|
|
36
|
+
const total = Math.max(0, breakdown.total)
|
|
37
|
+
const activePruned = breakdown.prunedToolCount + breakdown.prunedMessageCount
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<DcpFrame api={props.api} title="Context" eyebrow="DCP" onBack={props.onBack}>
|
|
41
|
+
<Card theme={theme} title="Current">
|
|
42
|
+
<Metric
|
|
43
|
+
theme={theme}
|
|
44
|
+
label="Total in context"
|
|
45
|
+
value={`~${formatTokenCount(total)}`}
|
|
46
|
+
hint="tokens"
|
|
47
|
+
/>
|
|
48
|
+
<Metric
|
|
49
|
+
theme={theme}
|
|
50
|
+
label="Tools in context"
|
|
51
|
+
value={`${breakdown.toolsInContextCount}`}
|
|
52
|
+
/>
|
|
53
|
+
<Metric theme={theme} label="Active pruned targets" value={`${activePruned}`} />
|
|
54
|
+
<Metric
|
|
55
|
+
theme={theme}
|
|
56
|
+
label="Tokens pruned"
|
|
57
|
+
value={`~${formatTokenCount(breakdown.prunedTokens)}`}
|
|
58
|
+
hint="tokens"
|
|
59
|
+
/>
|
|
60
|
+
</Card>
|
|
61
|
+
<Card theme={theme} title="Breakdown">
|
|
62
|
+
<Progress
|
|
63
|
+
theme={theme}
|
|
64
|
+
label="System"
|
|
65
|
+
value={breakdown.system}
|
|
66
|
+
total={total}
|
|
67
|
+
color="primary"
|
|
68
|
+
detail={`~${formatTokenCount(breakdown.system)} tokens`}
|
|
69
|
+
/>
|
|
70
|
+
<Progress
|
|
71
|
+
theme={theme}
|
|
72
|
+
label="User"
|
|
73
|
+
value={breakdown.user}
|
|
74
|
+
total={total}
|
|
75
|
+
color="primary"
|
|
76
|
+
detail={`~${formatTokenCount(breakdown.user)} tokens`}
|
|
77
|
+
/>
|
|
78
|
+
<Progress
|
|
79
|
+
theme={theme}
|
|
80
|
+
label="Assistant"
|
|
81
|
+
value={breakdown.assistant}
|
|
82
|
+
total={total}
|
|
83
|
+
color="primary"
|
|
84
|
+
detail={`~${formatTokenCount(breakdown.assistant)} tokens`}
|
|
85
|
+
/>
|
|
86
|
+
<Progress
|
|
87
|
+
theme={theme}
|
|
88
|
+
label={`Tools (${breakdown.toolsInContextCount})`}
|
|
89
|
+
value={breakdown.tools}
|
|
90
|
+
total={total}
|
|
91
|
+
color="primary"
|
|
92
|
+
detail={`~${formatTokenCount(breakdown.tools)} tokens`}
|
|
93
|
+
/>
|
|
94
|
+
</Card>
|
|
95
|
+
</DcpFrame>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function StatsDialog(props: { api: TuiApi; report: StatsReport; onBack: () => void }) {
|
|
100
|
+
const theme = props.api.theme.current
|
|
101
|
+
const ratio = formatRatio(props.report.sessionTokens, props.report.sessionSummaryTokens)
|
|
102
|
+
return (
|
|
103
|
+
<DcpFrame api={props.api} title="Stats" eyebrow="DCP" onBack={props.onBack}>
|
|
104
|
+
<Card theme={theme} title="Session">
|
|
105
|
+
<Metric
|
|
106
|
+
theme={theme}
|
|
107
|
+
label="Tokens saved"
|
|
108
|
+
value={`~${formatTokenCount(props.report.sessionTokens)}`}
|
|
109
|
+
hint="tokens"
|
|
110
|
+
/>
|
|
111
|
+
<Metric
|
|
112
|
+
theme={theme}
|
|
113
|
+
label="Summary size"
|
|
114
|
+
value={`~${formatTokenCount(props.report.sessionSummaryTokens)}`}
|
|
115
|
+
hint="tokens"
|
|
116
|
+
/>
|
|
117
|
+
<Metric theme={theme} label="Compression ratio" value={ratio} />
|
|
118
|
+
<Metric
|
|
119
|
+
theme={theme}
|
|
120
|
+
label="Compression time"
|
|
121
|
+
value={formatDuration(props.report.sessionDurationMs)}
|
|
122
|
+
/>
|
|
123
|
+
<Metric theme={theme} label="Tools pruned" value={`${props.report.sessionTools}`} />
|
|
124
|
+
<Metric
|
|
125
|
+
theme={theme}
|
|
126
|
+
label="Messages pruned"
|
|
127
|
+
value={`${props.report.sessionMessages}`}
|
|
128
|
+
/>
|
|
129
|
+
</Card>
|
|
130
|
+
<Card theme={theme} title="All time">
|
|
131
|
+
<Metric
|
|
132
|
+
theme={theme}
|
|
133
|
+
label="Tokens saved"
|
|
134
|
+
value={`~${formatTokenCount(props.report.allTime.totalTokens)}`}
|
|
135
|
+
hint="tokens"
|
|
136
|
+
/>
|
|
137
|
+
<Metric
|
|
138
|
+
theme={theme}
|
|
139
|
+
label="Tools pruned"
|
|
140
|
+
value={`${props.report.allTime.totalTools}`}
|
|
141
|
+
/>
|
|
142
|
+
<Metric
|
|
143
|
+
theme={theme}
|
|
144
|
+
label="Messages pruned"
|
|
145
|
+
value={`${props.report.allTime.totalMessages}`}
|
|
146
|
+
/>
|
|
147
|
+
<Metric
|
|
148
|
+
theme={theme}
|
|
149
|
+
label="Sessions with DCP history"
|
|
150
|
+
value={`${props.report.allTime.sessionCount}`}
|
|
151
|
+
/>
|
|
152
|
+
</Card>
|
|
153
|
+
</DcpFrame>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function PanelDialog(props: {
|
|
158
|
+
api: TuiApi
|
|
159
|
+
state: SessionState
|
|
160
|
+
config: PluginConfig
|
|
161
|
+
onContext: () => void
|
|
162
|
+
onStats: () => void
|
|
163
|
+
onManual: (enabled: boolean) => void
|
|
164
|
+
}) {
|
|
165
|
+
const theme = props.api.theme.current
|
|
166
|
+
const canCompress = compressPermission(props.state, props.config) !== "deny"
|
|
167
|
+
return (
|
|
168
|
+
<DcpFrame api={props.api} eyebrow="DCP">
|
|
169
|
+
<Card theme={theme} title="Views">
|
|
170
|
+
<box flexDirection="column" gap={1}>
|
|
171
|
+
<ActionRow
|
|
172
|
+
theme={theme}
|
|
173
|
+
title="Context"
|
|
174
|
+
detail="Token usage"
|
|
175
|
+
onClick={props.onContext}
|
|
176
|
+
/>
|
|
177
|
+
<ActionRow
|
|
178
|
+
theme={theme}
|
|
179
|
+
title="Stats"
|
|
180
|
+
detail="Savings"
|
|
181
|
+
onClick={props.onStats}
|
|
182
|
+
/>
|
|
183
|
+
</box>
|
|
184
|
+
</Card>
|
|
185
|
+
<Card theme={theme} title="Prompt">
|
|
186
|
+
{canCompress ? (
|
|
187
|
+
<PromptRow
|
|
188
|
+
theme={theme}
|
|
189
|
+
command="/dcp-compress [focus]"
|
|
190
|
+
description="Ask the model to compress"
|
|
191
|
+
accent="primary"
|
|
192
|
+
/>
|
|
193
|
+
) : (
|
|
194
|
+
<text fg={theme.textMuted}>Compression is denied by permissions.</text>
|
|
195
|
+
)}
|
|
196
|
+
</Card>
|
|
197
|
+
<Card theme={theme} title="Session State">
|
|
198
|
+
<ManualModeToggle api={props.api} state={props.state} onToggle={props.onManual} />
|
|
199
|
+
<StatusPill
|
|
200
|
+
theme={theme}
|
|
201
|
+
label="Compression command"
|
|
202
|
+
value={canCompress ? "enabled" : "disabled"}
|
|
203
|
+
accent={canCompress ? "success" : "warning"}
|
|
204
|
+
/>
|
|
205
|
+
</Card>
|
|
206
|
+
</DcpFrame>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function ManualModeToggle(props: {
|
|
211
|
+
api: TuiApi
|
|
212
|
+
state: SessionState
|
|
213
|
+
onToggle: (enabled: boolean) => void
|
|
214
|
+
}) {
|
|
215
|
+
const theme = props.api.theme.current
|
|
216
|
+
const enabled = !!props.state.manualMode
|
|
217
|
+
const track = enabled ? theme.success : theme.error
|
|
218
|
+
return (
|
|
219
|
+
<box flexDirection="row" justifyContent="space-between" paddingLeft={1} paddingRight={1}>
|
|
220
|
+
<box width={22}>
|
|
221
|
+
<text fg={theme.primary} attributes={TextAttributes.BOLD}>
|
|
222
|
+
Manual mode
|
|
223
|
+
</text>
|
|
224
|
+
</box>
|
|
225
|
+
<box
|
|
226
|
+
backgroundColor={track}
|
|
227
|
+
paddingLeft={1}
|
|
228
|
+
paddingRight={1}
|
|
229
|
+
onMouseUp={() => props.onToggle(!enabled)}
|
|
230
|
+
>
|
|
231
|
+
<text fg={theme.background}>{enabled ? " ■" : "■ "}</text>
|
|
232
|
+
</box>
|
|
233
|
+
</box>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function formatDuration(ms: number): string {
|
|
2
|
+
const safeMs = Math.max(0, Math.round(ms))
|
|
3
|
+
if (safeMs < 1000) return `${safeMs} ms`
|
|
4
|
+
|
|
5
|
+
const totalSeconds = safeMs / 1000
|
|
6
|
+
if (totalSeconds < 60) return `${totalSeconds.toFixed(1)} s`
|
|
7
|
+
|
|
8
|
+
const wholeSeconds = Math.floor(totalSeconds)
|
|
9
|
+
const hours = Math.floor(wholeSeconds / 3600)
|
|
10
|
+
const minutes = Math.floor((wholeSeconds % 3600) / 60)
|
|
11
|
+
const seconds = wholeSeconds % 60
|
|
12
|
+
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`
|
|
13
|
+
return `${minutes}m ${seconds}s`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function formatRatio(inputTokens: number, outputTokens: number): string {
|
|
17
|
+
if (inputTokens <= 0) return "0:1"
|
|
18
|
+
if (outputTokens <= 0) return "∞:1"
|
|
19
|
+
return `${Math.max(1, Math.round(inputTokens / outputTokens))}:1`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function pct(value: number, total: number): string {
|
|
23
|
+
if (total <= 0) return "0.0%"
|
|
24
|
+
return `${((value / total) * 100).toFixed(1)}%`
|
|
25
|
+
}
|