@tarquinen/opencode-dcp 3.2.4-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/dcp.schema.json +329 -0
- package/dist/lib/config.js +2 -2
- package/dist/lib/config.js.map +1 -1
- package/index.ts +141 -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-permission.ts +25 -0
- package/lib/config.ts +2 -2
- package/lib/hooks.ts +378 -0
- package/lib/host-permissions.ts +101 -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/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/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/ui/notification.ts +346 -0
- package/lib/ui/utils.ts +287 -0
- package/package.json +14 -19
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
* Deduplication strategy - prunes older tool calls that have identical
|
|
13
|
+
* tool name and parameters, keeping only the most recent occurrence.
|
|
14
|
+
* Modifies the session state in place to add pruned tool call IDs.
|
|
15
|
+
*/
|
|
16
|
+
export const deduplicate = (
|
|
17
|
+
state: SessionState,
|
|
18
|
+
logger: Logger,
|
|
19
|
+
config: PluginConfig,
|
|
20
|
+
messages: WithParts[],
|
|
21
|
+
): void => {
|
|
22
|
+
if (state.manualMode && !config.manualMode.automaticStrategies) {
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!config.strategies.deduplication.enabled) {
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const allToolIds = state.toolIdList
|
|
31
|
+
if (allToolIds.length === 0) {
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Filter out IDs already pruned
|
|
36
|
+
const unprunedIds = allToolIds.filter((id) => !state.prune.tools.has(id))
|
|
37
|
+
|
|
38
|
+
if (unprunedIds.length === 0) {
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const protectedTools = config.strategies.deduplication.protectedTools
|
|
43
|
+
|
|
44
|
+
// Group by signature (tool name + normalized parameters)
|
|
45
|
+
const signatureMap = new Map<string, string[]>()
|
|
46
|
+
|
|
47
|
+
for (const id of unprunedIds) {
|
|
48
|
+
const metadata = state.toolParameters.get(id)
|
|
49
|
+
if (!metadata) {
|
|
50
|
+
// logger.warn(`Missing metadata for tool call ID: ${id}`)
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Skip protected tools
|
|
55
|
+
if (isToolNameProtected(metadata.tool, protectedTools)) {
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const filePaths = getFilePathsFromParameters(metadata.tool, metadata.parameters)
|
|
60
|
+
if (isFilePathProtected(filePaths, config.protectedFilePatterns)) {
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const signature = createToolSignature(metadata.tool, metadata.parameters)
|
|
65
|
+
if (!signatureMap.has(signature)) {
|
|
66
|
+
signatureMap.set(signature, [])
|
|
67
|
+
}
|
|
68
|
+
const ids = signatureMap.get(signature)
|
|
69
|
+
if (ids) {
|
|
70
|
+
ids.push(id)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Find duplicates - keep only the most recent (last) in each group
|
|
75
|
+
const newPruneIds: string[] = []
|
|
76
|
+
|
|
77
|
+
for (const [, ids] of signatureMap.entries()) {
|
|
78
|
+
if (ids.length > 1) {
|
|
79
|
+
// All except last (most recent) should be pruned
|
|
80
|
+
const idsToRemove = ids.slice(0, -1)
|
|
81
|
+
newPruneIds.push(...idsToRemove)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
state.stats.totalPruneTokens += getTotalToolTokens(state, newPruneIds)
|
|
86
|
+
|
|
87
|
+
if (newPruneIds.length > 0) {
|
|
88
|
+
for (const id of newPruneIds) {
|
|
89
|
+
const entry = state.toolParameters.get(id)
|
|
90
|
+
state.prune.tools.set(id, entry?.tokenCount ?? 0)
|
|
91
|
+
}
|
|
92
|
+
logger.debug(`Marked ${newPruneIds.length} duplicate tool calls for pruning`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function createToolSignature(tool: string, parameters?: any): string {
|
|
97
|
+
if (!parameters) {
|
|
98
|
+
return tool
|
|
99
|
+
}
|
|
100
|
+
const normalized = normalizeParameters(parameters)
|
|
101
|
+
const sorted = sortObjectKeys(normalized)
|
|
102
|
+
return `${tool}::${JSON.stringify(sorted)}`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeParameters(params: any): any {
|
|
106
|
+
if (typeof params !== "object" || params === null) return params
|
|
107
|
+
if (Array.isArray(params)) return params
|
|
108
|
+
|
|
109
|
+
const normalized: any = {}
|
|
110
|
+
for (const [key, value] of Object.entries(params)) {
|
|
111
|
+
if (value !== undefined && value !== null) {
|
|
112
|
+
normalized[key] = value
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return normalized
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function sortObjectKeys(obj: any): any {
|
|
119
|
+
if (typeof obj !== "object" || obj === null) return obj
|
|
120
|
+
if (Array.isArray(obj)) return obj.map(sortObjectKeys)
|
|
121
|
+
|
|
122
|
+
const sorted: any = {}
|
|
123
|
+
for (const key of Object.keys(obj).sort()) {
|
|
124
|
+
sorted[key] = sortObjectKeys(obj[key])
|
|
125
|
+
}
|
|
126
|
+
return sorted
|
|
127
|
+
}
|
|
@@ -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,346 @@
|
|
|
1
|
+
import type { Logger } from "../logger"
|
|
2
|
+
import type { SessionState } from "../state"
|
|
3
|
+
import {
|
|
4
|
+
formatPrunedItemsList,
|
|
5
|
+
formatProgressBar,
|
|
6
|
+
formatStatsHeader,
|
|
7
|
+
formatTokenCount,
|
|
8
|
+
} from "./utils"
|
|
9
|
+
import { ToolParameterEntry } from "../state"
|
|
10
|
+
import { PluginConfig } from "../config"
|
|
11
|
+
import { getActiveSummaryTokenUsage } from "../state/utils"
|
|
12
|
+
|
|
13
|
+
export type PruneReason = "completion" | "noise" | "extraction"
|
|
14
|
+
export const PRUNE_REASON_LABELS: Record<PruneReason, string> = {
|
|
15
|
+
completion: "Task Complete",
|
|
16
|
+
noise: "Noise Removal",
|
|
17
|
+
extraction: "Extraction",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface CompressionNotificationEntry {
|
|
21
|
+
blockId: number
|
|
22
|
+
runId: number
|
|
23
|
+
summary: string
|
|
24
|
+
summaryTokens: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function buildMinimalMessage(state: SessionState, reason: PruneReason | undefined): string {
|
|
28
|
+
const reasonSuffix = reason ? ` — ${PRUNE_REASON_LABELS[reason]}` : ""
|
|
29
|
+
return (
|
|
30
|
+
formatStatsHeader(state.stats.totalPruneTokens, state.stats.pruneTokenCounter) +
|
|
31
|
+
reasonSuffix
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildDetailedMessage(
|
|
36
|
+
state: SessionState,
|
|
37
|
+
reason: PruneReason | undefined,
|
|
38
|
+
pruneToolIds: string[],
|
|
39
|
+
toolMetadata: Map<string, ToolParameterEntry>,
|
|
40
|
+
workingDirectory: string,
|
|
41
|
+
): string {
|
|
42
|
+
let message = formatStatsHeader(state.stats.totalPruneTokens, state.stats.pruneTokenCounter)
|
|
43
|
+
|
|
44
|
+
if (pruneToolIds.length > 0) {
|
|
45
|
+
const pruneTokenCounterStr = `~${formatTokenCount(state.stats.pruneTokenCounter)}`
|
|
46
|
+
const reasonLabel = reason ? ` — ${PRUNE_REASON_LABELS[reason]}` : ""
|
|
47
|
+
message += `\n\n▣ Pruning (${pruneTokenCounterStr})${reasonLabel}`
|
|
48
|
+
|
|
49
|
+
const itemLines = formatPrunedItemsList(pruneToolIds, toolMetadata, workingDirectory)
|
|
50
|
+
message += "\n" + itemLines.join("\n")
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return message.trim()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const TOAST_BODY_MAX_LINES = 12
|
|
57
|
+
const TOAST_SUMMARY_MAX_CHARS = 600
|
|
58
|
+
|
|
59
|
+
function truncateToastBody(body: string, maxLines: number = TOAST_BODY_MAX_LINES): string {
|
|
60
|
+
const lines = body.split("\n")
|
|
61
|
+
if (lines.length <= maxLines) {
|
|
62
|
+
return body
|
|
63
|
+
}
|
|
64
|
+
const kept = lines.slice(0, maxLines - 1)
|
|
65
|
+
const remaining = lines.length - maxLines + 1
|
|
66
|
+
return kept.join("\n") + `\n... and ${remaining} more`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function truncateToastSummary(summary: string, maxChars: number = TOAST_SUMMARY_MAX_CHARS): string {
|
|
70
|
+
if (summary.length <= maxChars) {
|
|
71
|
+
return summary
|
|
72
|
+
}
|
|
73
|
+
return summary.slice(0, maxChars - 3) + "..."
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function truncateExtractedSection(
|
|
77
|
+
message: string,
|
|
78
|
+
maxChars: number = TOAST_SUMMARY_MAX_CHARS,
|
|
79
|
+
): string {
|
|
80
|
+
const marker = "\n\n▣ Extracted"
|
|
81
|
+
const index = message.indexOf(marker)
|
|
82
|
+
if (index === -1) {
|
|
83
|
+
return message
|
|
84
|
+
}
|
|
85
|
+
const extracted = message.slice(index)
|
|
86
|
+
if (extracted.length <= maxChars) {
|
|
87
|
+
return message
|
|
88
|
+
}
|
|
89
|
+
return message.slice(0, index) + truncateToastSummary(extracted, maxChars)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function sendUnifiedNotification(
|
|
93
|
+
client: any,
|
|
94
|
+
logger: Logger,
|
|
95
|
+
config: PluginConfig,
|
|
96
|
+
state: SessionState,
|
|
97
|
+
sessionId: string,
|
|
98
|
+
pruneToolIds: string[],
|
|
99
|
+
toolMetadata: Map<string, ToolParameterEntry>,
|
|
100
|
+
reason: PruneReason | undefined,
|
|
101
|
+
params: any,
|
|
102
|
+
workingDirectory: string,
|
|
103
|
+
): Promise<boolean> {
|
|
104
|
+
const hasPruned = pruneToolIds.length > 0
|
|
105
|
+
if (!hasPruned) {
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (config.pruneNotification === "off") {
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const message =
|
|
114
|
+
config.pruneNotification === "minimal"
|
|
115
|
+
? buildMinimalMessage(state, reason)
|
|
116
|
+
: buildDetailedMessage(state, reason, pruneToolIds, toolMetadata, workingDirectory)
|
|
117
|
+
|
|
118
|
+
if (config.pruneNotificationType === "toast") {
|
|
119
|
+
let toastMessage = truncateExtractedSection(message)
|
|
120
|
+
toastMessage =
|
|
121
|
+
config.pruneNotification === "minimal" ? toastMessage : truncateToastBody(toastMessage)
|
|
122
|
+
|
|
123
|
+
await client.tui.showToast({
|
|
124
|
+
body: {
|
|
125
|
+
title: "DCP: Compress Notification",
|
|
126
|
+
message: toastMessage,
|
|
127
|
+
variant: "info",
|
|
128
|
+
duration: 5000,
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
return true
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await sendIgnoredMessage(client, sessionId, message, params, logger)
|
|
135
|
+
return true
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function buildCompressionSummary(
|
|
139
|
+
entries: CompressionNotificationEntry[],
|
|
140
|
+
state: SessionState,
|
|
141
|
+
): string {
|
|
142
|
+
if (entries.length === 1) {
|
|
143
|
+
return entries[0]?.summary ?? ""
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return entries
|
|
147
|
+
.map((entry) => {
|
|
148
|
+
const topic =
|
|
149
|
+
state.prune.messages.blocksById.get(entry.blockId)?.topic ?? "(unknown topic)"
|
|
150
|
+
return `### ${topic}\n${entry.summary}`
|
|
151
|
+
})
|
|
152
|
+
.join("\n\n")
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function getCompressionLabel(entries: CompressionNotificationEntry[]): string {
|
|
156
|
+
const runId = entries[0]?.runId
|
|
157
|
+
if (runId === undefined) {
|
|
158
|
+
return "Compression"
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return `Compression #${runId}`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function formatCompressionMetrics(removedTokens: number, summaryTokens: number): string {
|
|
165
|
+
const metrics = [`-${formatTokenCount(removedTokens, true)} removed`]
|
|
166
|
+
if (summaryTokens > 0) {
|
|
167
|
+
metrics.push(`+${formatTokenCount(summaryTokens, true)} summary`)
|
|
168
|
+
}
|
|
169
|
+
return metrics.join(", ")
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function sendCompressNotification(
|
|
173
|
+
client: any,
|
|
174
|
+
logger: Logger,
|
|
175
|
+
config: PluginConfig,
|
|
176
|
+
state: SessionState,
|
|
177
|
+
sessionId: string,
|
|
178
|
+
entries: CompressionNotificationEntry[],
|
|
179
|
+
batchTopic: string | undefined,
|
|
180
|
+
sessionMessageIds: string[],
|
|
181
|
+
params: any,
|
|
182
|
+
): Promise<boolean> {
|
|
183
|
+
if (config.pruneNotification === "off") {
|
|
184
|
+
return false
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (entries.length === 0) {
|
|
188
|
+
return false
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let message: string
|
|
192
|
+
const compressionLabel = getCompressionLabel(entries)
|
|
193
|
+
const summary = buildCompressionSummary(entries, state)
|
|
194
|
+
const summaryTokens = entries.reduce((total, entry) => total + entry.summaryTokens, 0)
|
|
195
|
+
const summaryTokensStr = formatTokenCount(summaryTokens)
|
|
196
|
+
const compressedTokens = entries.reduce((total, entry) => {
|
|
197
|
+
const compressionBlock = state.prune.messages.blocksById.get(entry.blockId)
|
|
198
|
+
if (!compressionBlock) {
|
|
199
|
+
logger.error("Compression block missing for notification", {
|
|
200
|
+
compressionId: entry.blockId,
|
|
201
|
+
sessionId,
|
|
202
|
+
})
|
|
203
|
+
return total
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return total + compressionBlock.compressedTokens
|
|
207
|
+
}, 0)
|
|
208
|
+
|
|
209
|
+
const newlyCompressedMessageIds: string[] = []
|
|
210
|
+
const newlyCompressedToolIds: string[] = []
|
|
211
|
+
const seenMessageIds = new Set<string>()
|
|
212
|
+
const seenToolIds = new Set<string>()
|
|
213
|
+
|
|
214
|
+
for (const entry of entries) {
|
|
215
|
+
const compressionBlock = state.prune.messages.blocksById.get(entry.blockId)
|
|
216
|
+
if (!compressionBlock) {
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
for (const messageId of compressionBlock.directMessageIds) {
|
|
221
|
+
if (seenMessageIds.has(messageId)) {
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
seenMessageIds.add(messageId)
|
|
225
|
+
newlyCompressedMessageIds.push(messageId)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const toolId of compressionBlock.directToolIds) {
|
|
229
|
+
if (seenToolIds.has(toolId)) {
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
seenToolIds.add(toolId)
|
|
233
|
+
newlyCompressedToolIds.push(toolId)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const topic =
|
|
238
|
+
batchTopic ??
|
|
239
|
+
(entries.length === 1
|
|
240
|
+
? (state.prune.messages.blocksById.get(entries[0]?.blockId ?? -1)?.topic ??
|
|
241
|
+
"(unknown topic)")
|
|
242
|
+
: "(unknown topic)")
|
|
243
|
+
|
|
244
|
+
const totalActiveSummaryTkns = getActiveSummaryTokenUsage(state)
|
|
245
|
+
const totalGross = state.stats.totalPruneTokens + state.stats.pruneTokenCounter
|
|
246
|
+
const notificationHeader = `▣ DCP | ${formatCompressionMetrics(totalGross, totalActiveSummaryTkns)}`
|
|
247
|
+
|
|
248
|
+
if (config.pruneNotification === "minimal") {
|
|
249
|
+
message = `${notificationHeader} — ${compressionLabel}`
|
|
250
|
+
} else {
|
|
251
|
+
message = notificationHeader
|
|
252
|
+
const activePrunedMessages = new Map<string, number>()
|
|
253
|
+
for (const [messageId, entry] of state.prune.messages.byMessageId) {
|
|
254
|
+
if (entry.activeBlockIds.length > 0) {
|
|
255
|
+
activePrunedMessages.set(messageId, entry.tokenCount)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const progressBar = formatProgressBar(
|
|
259
|
+
sessionMessageIds,
|
|
260
|
+
activePrunedMessages,
|
|
261
|
+
newlyCompressedMessageIds,
|
|
262
|
+
50,
|
|
263
|
+
)
|
|
264
|
+
message += `\n\n${progressBar}`
|
|
265
|
+
message += `\n▣ ${compressionLabel} ${formatCompressionMetrics(compressedTokens, summaryTokens)}`
|
|
266
|
+
message += `\n→ Topic: ${topic}`
|
|
267
|
+
message += `\n→ Items: ${newlyCompressedMessageIds.length} messages`
|
|
268
|
+
if (newlyCompressedToolIds.length > 0) {
|
|
269
|
+
message += ` and ${newlyCompressedToolIds.length} tools compressed`
|
|
270
|
+
} else {
|
|
271
|
+
message += ` compressed`
|
|
272
|
+
}
|
|
273
|
+
if (config.compress.showCompression) {
|
|
274
|
+
message += `\n→ Compression (~${summaryTokensStr}): ${summary}`
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (config.pruneNotificationType === "toast") {
|
|
279
|
+
let toastMessage = message
|
|
280
|
+
if (config.compress.showCompression) {
|
|
281
|
+
const truncatedSummary = truncateToastSummary(summary)
|
|
282
|
+
if (truncatedSummary !== summary) {
|
|
283
|
+
toastMessage = toastMessage.replace(
|
|
284
|
+
`\n→ Compression (~${summaryTokensStr}): ${summary}`,
|
|
285
|
+
`\n→ Compression (~${summaryTokensStr}): ${truncatedSummary}`,
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
toastMessage =
|
|
290
|
+
config.pruneNotification === "minimal" ? toastMessage : truncateToastBody(toastMessage)
|
|
291
|
+
|
|
292
|
+
await client.tui.showToast({
|
|
293
|
+
body: {
|
|
294
|
+
title: "DCP: Compress Notification",
|
|
295
|
+
message: toastMessage,
|
|
296
|
+
variant: "info",
|
|
297
|
+
duration: 5000,
|
|
298
|
+
},
|
|
299
|
+
})
|
|
300
|
+
return true
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
await sendIgnoredMessage(client, sessionId, message, params, logger)
|
|
304
|
+
return true
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function sendIgnoredMessage(
|
|
308
|
+
client: any,
|
|
309
|
+
sessionID: string,
|
|
310
|
+
text: string,
|
|
311
|
+
params: any,
|
|
312
|
+
logger: Logger,
|
|
313
|
+
): Promise<void> {
|
|
314
|
+
const agent = params.agent || undefined
|
|
315
|
+
const variant = params.variant || undefined
|
|
316
|
+
const model =
|
|
317
|
+
params.providerId && params.modelId
|
|
318
|
+
? {
|
|
319
|
+
providerID: params.providerId,
|
|
320
|
+
modelID: params.modelId,
|
|
321
|
+
}
|
|
322
|
+
: undefined
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
await client.session.prompt({
|
|
326
|
+
path: {
|
|
327
|
+
id: sessionID,
|
|
328
|
+
},
|
|
329
|
+
body: {
|
|
330
|
+
noReply: true,
|
|
331
|
+
agent: agent,
|
|
332
|
+
model: model,
|
|
333
|
+
variant: variant,
|
|
334
|
+
parts: [
|
|
335
|
+
{
|
|
336
|
+
type: "text",
|
|
337
|
+
text: text,
|
|
338
|
+
ignored: true,
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
},
|
|
342
|
+
})
|
|
343
|
+
} catch (error: any) {
|
|
344
|
+
logger.error("Failed to send notification", { error: error.message })
|
|
345
|
+
}
|
|
346
|
+
}
|