@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,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DCP Sweep command handler.
|
|
3
|
+
* Prunes tool outputs since the last user message, or the last N tools.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* /dcp sweep - Prune all tools since the previous user message
|
|
7
|
+
* /dcp sweep 10 - Prune the last 10 tools
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Logger } from "../logger"
|
|
11
|
+
import type { SessionState, WithParts, ToolParameterEntry } from "../state"
|
|
12
|
+
import type { PluginConfig } from "../config"
|
|
13
|
+
import { sendIgnoredMessage } from "../ui/notification"
|
|
14
|
+
import { formatPrunedItemsList } from "../ui/utils"
|
|
15
|
+
import { getCurrentParams, getTotalToolTokens } from "../token-utils"
|
|
16
|
+
import { isIgnoredUserMessage } from "../messages/query"
|
|
17
|
+
import { buildToolIdList } from "../messages/utils"
|
|
18
|
+
import { saveSessionState } from "../state/persistence"
|
|
19
|
+
import { isMessageCompacted } from "../state/utils"
|
|
20
|
+
import {
|
|
21
|
+
getFilePathsFromParameters,
|
|
22
|
+
isFilePathProtected,
|
|
23
|
+
isToolNameProtected,
|
|
24
|
+
} from "../protected-patterns"
|
|
25
|
+
import { syncToolCache } from "../state/tool-cache"
|
|
26
|
+
|
|
27
|
+
export interface SweepCommandContext {
|
|
28
|
+
client: any
|
|
29
|
+
state: SessionState
|
|
30
|
+
config: PluginConfig
|
|
31
|
+
logger: Logger
|
|
32
|
+
sessionId: string
|
|
33
|
+
messages: WithParts[]
|
|
34
|
+
args: string[]
|
|
35
|
+
workingDirectory: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function findLastUserMessageIndex(messages: WithParts[]): number {
|
|
39
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
40
|
+
const msg = messages[i]
|
|
41
|
+
if (msg.info.role === "user" && !isIgnoredUserMessage(msg)) {
|
|
42
|
+
return i
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return -1
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function collectToolIdsAfterIndex(
|
|
50
|
+
state: SessionState,
|
|
51
|
+
messages: WithParts[],
|
|
52
|
+
afterIndex: number,
|
|
53
|
+
): string[] {
|
|
54
|
+
const toolIds: string[] = []
|
|
55
|
+
|
|
56
|
+
for (let i = afterIndex + 1; i < messages.length; i++) {
|
|
57
|
+
const msg = messages[i]
|
|
58
|
+
if (isMessageCompacted(state, msg)) {
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
const parts = Array.isArray(msg.parts) ? msg.parts : []
|
|
62
|
+
if (parts.length > 0) {
|
|
63
|
+
for (const part of parts) {
|
|
64
|
+
if (part.type === "tool" && part.callID && part.tool) {
|
|
65
|
+
toolIds.push(part.callID)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return toolIds
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function formatNoUserMessage(): string {
|
|
75
|
+
const lines: string[] = []
|
|
76
|
+
|
|
77
|
+
lines.push("╭───────────────────────────────────────────────────────────╮")
|
|
78
|
+
lines.push("│ DCP Sweep │")
|
|
79
|
+
lines.push("╰───────────────────────────────────────────────────────────╯")
|
|
80
|
+
lines.push("")
|
|
81
|
+
lines.push("Nothing swept: no user message found.")
|
|
82
|
+
|
|
83
|
+
return lines.join("\n")
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function formatSweepMessage(
|
|
87
|
+
toolCount: number,
|
|
88
|
+
tokensSaved: number,
|
|
89
|
+
mode: "since-user" | "last-n",
|
|
90
|
+
toolIds: string[],
|
|
91
|
+
toolMetadata: Map<string, ToolParameterEntry>,
|
|
92
|
+
workingDirectory?: string,
|
|
93
|
+
skippedProtected?: number,
|
|
94
|
+
): string {
|
|
95
|
+
const lines: string[] = []
|
|
96
|
+
|
|
97
|
+
lines.push("╭───────────────────────────────────────────────────────────╮")
|
|
98
|
+
lines.push("│ DCP Sweep │")
|
|
99
|
+
lines.push("╰───────────────────────────────────────────────────────────╯")
|
|
100
|
+
lines.push("")
|
|
101
|
+
|
|
102
|
+
if (toolCount === 0) {
|
|
103
|
+
if (mode === "since-user") {
|
|
104
|
+
lines.push("No tools found since the previous user message.")
|
|
105
|
+
} else {
|
|
106
|
+
lines.push(`No tools found to sweep.`)
|
|
107
|
+
}
|
|
108
|
+
if (skippedProtected && skippedProtected > 0) {
|
|
109
|
+
lines.push(`(${skippedProtected} protected tool(s) skipped)`)
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
if (mode === "since-user") {
|
|
113
|
+
lines.push(`Swept ${toolCount} tool(s) since the previous user message.`)
|
|
114
|
+
} else {
|
|
115
|
+
lines.push(`Swept the last ${toolCount} tool(s).`)
|
|
116
|
+
}
|
|
117
|
+
lines.push(`Tokens saved: ~${tokensSaved.toLocaleString()}`)
|
|
118
|
+
if (skippedProtected && skippedProtected > 0) {
|
|
119
|
+
lines.push(`(${skippedProtected} protected tool(s) skipped)`)
|
|
120
|
+
}
|
|
121
|
+
lines.push("")
|
|
122
|
+
const itemLines = formatPrunedItemsList(toolIds, toolMetadata, workingDirectory)
|
|
123
|
+
lines.push(...itemLines)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return lines.join("\n")
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function handleSweepCommand(ctx: SweepCommandContext): Promise<void> {
|
|
130
|
+
const { client, state, config, logger, sessionId, messages, args, workingDirectory } = ctx
|
|
131
|
+
|
|
132
|
+
const params = getCurrentParams(state, messages, logger)
|
|
133
|
+
const protectedTools = config.commands.protectedTools
|
|
134
|
+
|
|
135
|
+
syncToolCache(state, config, logger, messages)
|
|
136
|
+
buildToolIdList(state, messages)
|
|
137
|
+
|
|
138
|
+
// Parse optional numeric argument
|
|
139
|
+
const numArg = args[0] ? parseInt(args[0], 10) : null
|
|
140
|
+
const isLastNMode = numArg !== null && !isNaN(numArg) && numArg > 0
|
|
141
|
+
|
|
142
|
+
let toolIdsToSweep: string[]
|
|
143
|
+
let mode: "since-user" | "last-n"
|
|
144
|
+
|
|
145
|
+
if (isLastNMode) {
|
|
146
|
+
// Mode: Sweep last N tools
|
|
147
|
+
mode = "last-n"
|
|
148
|
+
const startIndex = Math.max(0, state.toolIdList.length - numArg!)
|
|
149
|
+
toolIdsToSweep = state.toolIdList.slice(startIndex)
|
|
150
|
+
logger.info(`Sweep command: last ${numArg} mode, found ${toolIdsToSweep.length} tools`)
|
|
151
|
+
} else {
|
|
152
|
+
// Mode: Sweep since last user message
|
|
153
|
+
mode = "since-user"
|
|
154
|
+
const lastUserMsgIndex = findLastUserMessageIndex(messages)
|
|
155
|
+
|
|
156
|
+
if (lastUserMsgIndex === -1) {
|
|
157
|
+
// No user message found - show message and return
|
|
158
|
+
const message = formatNoUserMessage()
|
|
159
|
+
await sendIgnoredMessage(client, sessionId, message, params, logger)
|
|
160
|
+
logger.info("Sweep command: no user message found")
|
|
161
|
+
return
|
|
162
|
+
} else {
|
|
163
|
+
toolIdsToSweep = collectToolIdsAfterIndex(state, messages, lastUserMsgIndex)
|
|
164
|
+
logger.info(
|
|
165
|
+
`Sweep command: found last user at index ${lastUserMsgIndex}, sweeping ${toolIdsToSweep.length} tools`,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Filter out already-pruned tools, protected tools, and protected file paths
|
|
171
|
+
const newToolIds = toolIdsToSweep.filter((id) => {
|
|
172
|
+
if (state.prune.tools.has(id)) {
|
|
173
|
+
return false
|
|
174
|
+
}
|
|
175
|
+
const entry = state.toolParameters.get(id)
|
|
176
|
+
if (!entry) {
|
|
177
|
+
return true
|
|
178
|
+
}
|
|
179
|
+
if (isToolNameProtected(entry.tool, protectedTools)) {
|
|
180
|
+
logger.debug(`Sweep: skipping protected tool ${entry.tool} (${id})`)
|
|
181
|
+
return false
|
|
182
|
+
}
|
|
183
|
+
const filePaths = getFilePathsFromParameters(entry.tool, entry.parameters)
|
|
184
|
+
if (isFilePathProtected(filePaths, config.protectedFilePatterns)) {
|
|
185
|
+
logger.debug(`Sweep: skipping protected file path(s) ${filePaths.join(", ")} (${id})`)
|
|
186
|
+
return false
|
|
187
|
+
}
|
|
188
|
+
return true
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// Count how many were skipped due to protection
|
|
192
|
+
const skippedProtected = toolIdsToSweep.filter((id) => {
|
|
193
|
+
const entry = state.toolParameters.get(id)
|
|
194
|
+
if (!entry) {
|
|
195
|
+
return false
|
|
196
|
+
}
|
|
197
|
+
if (isToolNameProtected(entry.tool, protectedTools)) {
|
|
198
|
+
return true
|
|
199
|
+
}
|
|
200
|
+
const filePaths = getFilePathsFromParameters(entry.tool, entry.parameters)
|
|
201
|
+
if (isFilePathProtected(filePaths, config.protectedFilePatterns)) {
|
|
202
|
+
return true
|
|
203
|
+
}
|
|
204
|
+
return false
|
|
205
|
+
}).length
|
|
206
|
+
|
|
207
|
+
if (newToolIds.length === 0) {
|
|
208
|
+
const message = formatSweepMessage(
|
|
209
|
+
0,
|
|
210
|
+
0,
|
|
211
|
+
mode,
|
|
212
|
+
[],
|
|
213
|
+
new Map(),
|
|
214
|
+
workingDirectory,
|
|
215
|
+
skippedProtected,
|
|
216
|
+
)
|
|
217
|
+
await sendIgnoredMessage(client, sessionId, message, params, logger)
|
|
218
|
+
logger.info("Sweep command: no new tools to sweep", { skippedProtected })
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const tokensSaved = getTotalToolTokens(state, newToolIds)
|
|
223
|
+
|
|
224
|
+
// Add to prune list
|
|
225
|
+
for (const id of newToolIds) {
|
|
226
|
+
const entry = state.toolParameters.get(id)
|
|
227
|
+
state.prune.tools.set(id, entry?.tokenCount ?? 0)
|
|
228
|
+
}
|
|
229
|
+
state.stats.pruneTokenCounter += tokensSaved
|
|
230
|
+
state.stats.totalPruneTokens += state.stats.pruneTokenCounter
|
|
231
|
+
state.stats.pruneTokenCounter = 0
|
|
232
|
+
|
|
233
|
+
// Collect metadata for logging
|
|
234
|
+
const toolMetadata: Map<string, ToolParameterEntry> = new Map()
|
|
235
|
+
for (const id of newToolIds) {
|
|
236
|
+
const entry = state.toolParameters.get(id)
|
|
237
|
+
if (entry) {
|
|
238
|
+
toolMetadata.set(id, entry)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Persist state
|
|
243
|
+
saveSessionState(state, logger).catch((err) =>
|
|
244
|
+
logger.error("Failed to persist state after sweep", { error: err.message }),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
const message = formatSweepMessage(
|
|
248
|
+
newToolIds.length,
|
|
249
|
+
tokensSaved,
|
|
250
|
+
mode,
|
|
251
|
+
newToolIds,
|
|
252
|
+
toolMetadata,
|
|
253
|
+
workingDirectory,
|
|
254
|
+
skippedProtected,
|
|
255
|
+
)
|
|
256
|
+
await sendIgnoredMessage(client, sessionId, message, params, logger)
|
|
257
|
+
|
|
258
|
+
logger.info("Sweep command completed", {
|
|
259
|
+
toolsSwept: newToolIds.length,
|
|
260
|
+
tokensSaved,
|
|
261
|
+
skippedProtected,
|
|
262
|
+
mode,
|
|
263
|
+
tools: Array.from(toolMetadata.entries()).map(([id, entry]) => ({
|
|
264
|
+
id,
|
|
265
|
+
tool: entry.tool,
|
|
266
|
+
})),
|
|
267
|
+
})
|
|
268
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { PluginConfig } from "./config"
|
|
2
|
+
import { type HostPermissionSnapshot, resolveEffectiveCompressPermission } from "./host-permissions"
|
|
3
|
+
import type { SessionState, WithParts } from "./state"
|
|
4
|
+
import { getLastUserMessage } from "./messages/query"
|
|
5
|
+
|
|
6
|
+
export const compressPermission = (
|
|
7
|
+
state: SessionState,
|
|
8
|
+
config: PluginConfig,
|
|
9
|
+
): "ask" | "allow" | "deny" => {
|
|
10
|
+
return state.compressPermission ?? config.compress.permission
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const syncCompressPermissionState = (
|
|
14
|
+
state: SessionState,
|
|
15
|
+
config: PluginConfig,
|
|
16
|
+
hostPermissions: HostPermissionSnapshot,
|
|
17
|
+
messages: WithParts[],
|
|
18
|
+
): void => {
|
|
19
|
+
const activeAgent = getLastUserMessage(messages)?.info.agent
|
|
20
|
+
state.compressPermission = resolveEffectiveCompressPermission(
|
|
21
|
+
config.compress.permission,
|
|
22
|
+
hostPermissions,
|
|
23
|
+
activeAgent,
|
|
24
|
+
)
|
|
25
|
+
}
|
package/lib/config.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from "fs"
|
|
2
2
|
import { join, dirname } from "path"
|
|
3
3
|
import { homedir } from "os"
|
|
4
|
-
import
|
|
4
|
+
import * as jsoncParser from "jsonc-parser"
|
|
5
5
|
import type { PluginInput } from "@opencode-ai/plugin"
|
|
6
6
|
|
|
7
7
|
type Permission = "ask" | "allow" | "deny"
|
|
@@ -824,7 +824,7 @@ function loadConfigFile(configPath: string): ConfigLoadResult {
|
|
|
824
824
|
}
|
|
825
825
|
|
|
826
826
|
try {
|
|
827
|
-
const parsed = parse(fileContent, undefined, { allowTrailingComma: true })
|
|
827
|
+
const parsed = jsoncParser.parse(fileContent, undefined, { allowTrailingComma: true })
|
|
828
828
|
if (parsed === undefined || parsed === null) {
|
|
829
829
|
return { data: null, parseError: "Config file is empty or invalid" }
|
|
830
830
|
}
|
package/lib/hooks.ts
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import type { SessionState, WithParts } from "./state"
|
|
2
|
+
import type { Logger } from "./logger"
|
|
3
|
+
import type { PluginConfig } from "./config"
|
|
4
|
+
import { assignMessageRefs } from "./message-ids"
|
|
5
|
+
import {
|
|
6
|
+
buildPriorityMap,
|
|
7
|
+
buildToolIdList,
|
|
8
|
+
injectCompressNudges,
|
|
9
|
+
injectExtendedSubAgentResults,
|
|
10
|
+
injectMessageIds,
|
|
11
|
+
prune,
|
|
12
|
+
stripHallucinations,
|
|
13
|
+
stripHallucinationsFromString,
|
|
14
|
+
stripStaleMetadata,
|
|
15
|
+
syncCompressionBlocks,
|
|
16
|
+
} from "./messages"
|
|
17
|
+
import { renderSystemPrompt, type PromptStore } from "./prompts"
|
|
18
|
+
import { buildProtectedToolsExtension } from "./prompts/extensions/system"
|
|
19
|
+
import {
|
|
20
|
+
applyPendingCompressionDurations,
|
|
21
|
+
buildCompressionTimingKey,
|
|
22
|
+
consumeCompressionStart,
|
|
23
|
+
resolveCompressionDuration,
|
|
24
|
+
} from "./compress/timing"
|
|
25
|
+
import {
|
|
26
|
+
applyPendingManualTrigger,
|
|
27
|
+
handleContextCommand,
|
|
28
|
+
handleDecompressCommand,
|
|
29
|
+
handleHelpCommand,
|
|
30
|
+
handleManualToggleCommand,
|
|
31
|
+
handleManualTriggerCommand,
|
|
32
|
+
handleRecompressCommand,
|
|
33
|
+
handleStatsCommand,
|
|
34
|
+
handleSweepCommand,
|
|
35
|
+
} from "./commands"
|
|
36
|
+
import { type HostPermissionSnapshot } from "./host-permissions"
|
|
37
|
+
import { compressPermission, syncCompressPermissionState } from "./compress-permission"
|
|
38
|
+
import { checkSession, ensureSessionInitialized, saveSessionState, syncToolCache } from "./state"
|
|
39
|
+
import { cacheSystemPromptTokens } from "./ui/utils"
|
|
40
|
+
|
|
41
|
+
const INTERNAL_AGENT_SIGNATURES = [
|
|
42
|
+
"You are a title generator",
|
|
43
|
+
"You are a helpful AI assistant tasked with summarizing conversations",
|
|
44
|
+
"Summarize what was done in this conversation",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
export function createSystemPromptHandler(
|
|
48
|
+
state: SessionState,
|
|
49
|
+
logger: Logger,
|
|
50
|
+
config: PluginConfig,
|
|
51
|
+
prompts: PromptStore,
|
|
52
|
+
) {
|
|
53
|
+
return async (
|
|
54
|
+
input: { sessionID?: string; model: { limit: { context: number } } },
|
|
55
|
+
output: { system: string[] },
|
|
56
|
+
) => {
|
|
57
|
+
if (input.model?.limit?.context) {
|
|
58
|
+
state.modelContextLimit = input.model.limit.context
|
|
59
|
+
logger.debug("Cached model context limit", { limit: state.modelContextLimit })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (state.isSubAgent && !config.experimental.allowSubAgents) {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const systemText = output.system.join("\n")
|
|
67
|
+
if (INTERNAL_AGENT_SIGNATURES.some((sig) => systemText.includes(sig))) {
|
|
68
|
+
logger.info("Skipping DCP system prompt injection for internal agent")
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const effectivePermission =
|
|
73
|
+
input.sessionID && state.sessionId === input.sessionID
|
|
74
|
+
? compressPermission(state, config)
|
|
75
|
+
: config.compress.permission
|
|
76
|
+
|
|
77
|
+
if (effectivePermission === "deny") {
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
prompts.reload()
|
|
82
|
+
const runtimePrompts = prompts.getRuntimePrompts()
|
|
83
|
+
const newPrompt = renderSystemPrompt(
|
|
84
|
+
runtimePrompts,
|
|
85
|
+
buildProtectedToolsExtension(config.compress.protectedTools),
|
|
86
|
+
!!state.manualMode,
|
|
87
|
+
state.isSubAgent && config.experimental.allowSubAgents,
|
|
88
|
+
)
|
|
89
|
+
if (output.system.length > 0) {
|
|
90
|
+
output.system[output.system.length - 1] += "\n\n" + newPrompt
|
|
91
|
+
} else {
|
|
92
|
+
output.system.push(newPrompt)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createChatMessageTransformHandler(
|
|
98
|
+
client: any,
|
|
99
|
+
state: SessionState,
|
|
100
|
+
logger: Logger,
|
|
101
|
+
config: PluginConfig,
|
|
102
|
+
prompts: PromptStore,
|
|
103
|
+
hostPermissions: HostPermissionSnapshot,
|
|
104
|
+
) {
|
|
105
|
+
return async (input: {}, output: { messages: WithParts[] }) => {
|
|
106
|
+
await checkSession(client, state, logger, output.messages, config.manualMode.enabled)
|
|
107
|
+
|
|
108
|
+
syncCompressPermissionState(state, config, hostPermissions, output.messages)
|
|
109
|
+
|
|
110
|
+
if (state.isSubAgent && !config.experimental.allowSubAgents) {
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
stripHallucinations(output.messages)
|
|
115
|
+
cacheSystemPromptTokens(state, output.messages)
|
|
116
|
+
assignMessageRefs(state, output.messages)
|
|
117
|
+
syncCompressionBlocks(state, logger, output.messages)
|
|
118
|
+
syncToolCache(state, config, logger, output.messages)
|
|
119
|
+
buildToolIdList(state, output.messages)
|
|
120
|
+
prune(state, logger, config, output.messages)
|
|
121
|
+
await injectExtendedSubAgentResults(
|
|
122
|
+
client,
|
|
123
|
+
state,
|
|
124
|
+
logger,
|
|
125
|
+
output.messages,
|
|
126
|
+
config.experimental.allowSubAgents,
|
|
127
|
+
)
|
|
128
|
+
const compressionPriorities = buildPriorityMap(config, state, output.messages)
|
|
129
|
+
prompts.reload()
|
|
130
|
+
injectCompressNudges(
|
|
131
|
+
state,
|
|
132
|
+
config,
|
|
133
|
+
logger,
|
|
134
|
+
output.messages,
|
|
135
|
+
prompts.getRuntimePrompts(),
|
|
136
|
+
compressionPriorities,
|
|
137
|
+
)
|
|
138
|
+
injectMessageIds(state, config, output.messages, compressionPriorities)
|
|
139
|
+
applyPendingManualTrigger(state, output.messages, logger)
|
|
140
|
+
stripStaleMetadata(output.messages)
|
|
141
|
+
|
|
142
|
+
if (state.sessionId) {
|
|
143
|
+
await logger.saveContext(state.sessionId, output.messages)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function createCommandExecuteHandler(
|
|
149
|
+
client: any,
|
|
150
|
+
state: SessionState,
|
|
151
|
+
logger: Logger,
|
|
152
|
+
config: PluginConfig,
|
|
153
|
+
workingDirectory: string,
|
|
154
|
+
hostPermissions: HostPermissionSnapshot,
|
|
155
|
+
) {
|
|
156
|
+
return async (
|
|
157
|
+
input: { command: string; sessionID: string; arguments: string },
|
|
158
|
+
output: { parts: any[] },
|
|
159
|
+
) => {
|
|
160
|
+
if (!config.commands.enabled) {
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (input.command === "dcp") {
|
|
165
|
+
const messagesResponse = await client.session.messages({
|
|
166
|
+
path: { id: input.sessionID },
|
|
167
|
+
})
|
|
168
|
+
const messages = (messagesResponse.data || messagesResponse) as WithParts[]
|
|
169
|
+
|
|
170
|
+
await ensureSessionInitialized(
|
|
171
|
+
client,
|
|
172
|
+
state,
|
|
173
|
+
input.sessionID,
|
|
174
|
+
logger,
|
|
175
|
+
messages,
|
|
176
|
+
config.manualMode.enabled,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
syncCompressPermissionState(state, config, hostPermissions, messages)
|
|
180
|
+
|
|
181
|
+
const effectivePermission = compressPermission(state, config)
|
|
182
|
+
if (effectivePermission === "deny") {
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const args = (input.arguments || "").trim().split(/\s+/).filter(Boolean)
|
|
187
|
+
const subcommand = args[0]?.toLowerCase() || ""
|
|
188
|
+
const subArgs = args.slice(1)
|
|
189
|
+
|
|
190
|
+
const commandCtx = {
|
|
191
|
+
client,
|
|
192
|
+
state,
|
|
193
|
+
config,
|
|
194
|
+
logger,
|
|
195
|
+
sessionId: input.sessionID,
|
|
196
|
+
messages,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (subcommand === "context") {
|
|
200
|
+
await handleContextCommand(commandCtx)
|
|
201
|
+
throw new Error("__DCP_CONTEXT_HANDLED__")
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (subcommand === "stats") {
|
|
205
|
+
await handleStatsCommand(commandCtx)
|
|
206
|
+
throw new Error("__DCP_STATS_HANDLED__")
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (subcommand === "sweep") {
|
|
210
|
+
await handleSweepCommand({
|
|
211
|
+
...commandCtx,
|
|
212
|
+
args: subArgs,
|
|
213
|
+
workingDirectory,
|
|
214
|
+
})
|
|
215
|
+
throw new Error("__DCP_SWEEP_HANDLED__")
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (subcommand === "manual") {
|
|
219
|
+
await handleManualToggleCommand(commandCtx, subArgs[0]?.toLowerCase())
|
|
220
|
+
throw new Error("__DCP_MANUAL_HANDLED__")
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (subcommand === "compress") {
|
|
224
|
+
const userFocus = subArgs.join(" ").trim()
|
|
225
|
+
const prompt = await handleManualTriggerCommand(commandCtx, "compress", userFocus)
|
|
226
|
+
if (!prompt) {
|
|
227
|
+
throw new Error("__DCP_MANUAL_TRIGGER_BLOCKED__")
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
state.manualMode = "compress-pending"
|
|
231
|
+
state.pendingManualTrigger = {
|
|
232
|
+
sessionId: input.sessionID,
|
|
233
|
+
prompt,
|
|
234
|
+
}
|
|
235
|
+
const rawArgs = (input.arguments || "").trim()
|
|
236
|
+
output.parts.length = 0
|
|
237
|
+
output.parts.push({
|
|
238
|
+
type: "text",
|
|
239
|
+
text: rawArgs ? `/dcp ${rawArgs}` : `/dcp ${subcommand}`,
|
|
240
|
+
})
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (subcommand === "decompress") {
|
|
245
|
+
await handleDecompressCommand({
|
|
246
|
+
...commandCtx,
|
|
247
|
+
args: subArgs,
|
|
248
|
+
})
|
|
249
|
+
throw new Error("__DCP_DECOMPRESS_HANDLED__")
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (subcommand === "recompress") {
|
|
253
|
+
await handleRecompressCommand({
|
|
254
|
+
...commandCtx,
|
|
255
|
+
args: subArgs,
|
|
256
|
+
})
|
|
257
|
+
throw new Error("__DCP_RECOMPRESS_HANDLED__")
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
await handleHelpCommand(commandCtx)
|
|
261
|
+
throw new Error("__DCP_HELP_HANDLED__")
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function createTextCompleteHandler() {
|
|
267
|
+
return async (
|
|
268
|
+
_input: { sessionID: string; messageID: string; partID: string },
|
|
269
|
+
output: { text: string },
|
|
270
|
+
) => {
|
|
271
|
+
output.text = stripHallucinationsFromString(output.text)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function createEventHandler(state: SessionState, logger: Logger) {
|
|
276
|
+
return async (input: { event: any }) => {
|
|
277
|
+
const eventTime =
|
|
278
|
+
typeof input.event?.time === "number" && Number.isFinite(input.event.time)
|
|
279
|
+
? input.event.time
|
|
280
|
+
: typeof input.event?.properties?.time === "number" &&
|
|
281
|
+
Number.isFinite(input.event.properties.time)
|
|
282
|
+
? input.event.properties.time
|
|
283
|
+
: undefined
|
|
284
|
+
|
|
285
|
+
if (input.event.type !== "message.part.updated") {
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const part = input.event.properties?.part
|
|
290
|
+
if (part?.type !== "tool" || part.tool !== "compress") {
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (part.state.status === "pending") {
|
|
295
|
+
if (typeof part.callID !== "string" || typeof part.messageID !== "string") {
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const startedAt = eventTime ?? Date.now()
|
|
300
|
+
const key = buildCompressionTimingKey(part.messageID, part.callID)
|
|
301
|
+
if (state.compressionTiming.startsByCallId.has(key)) {
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
state.compressionTiming.startsByCallId.set(key, startedAt)
|
|
305
|
+
logger.debug("Recorded compression start", {
|
|
306
|
+
messageID: part.messageID,
|
|
307
|
+
callID: part.callID,
|
|
308
|
+
startedAt,
|
|
309
|
+
})
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (part.state.status === "completed") {
|
|
314
|
+
if (typeof part.callID !== "string" || typeof part.messageID !== "string") {
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const key = buildCompressionTimingKey(part.messageID, part.callID)
|
|
319
|
+
const start = consumeCompressionStart(state, part.messageID, part.callID)
|
|
320
|
+
const durationMs = resolveCompressionDuration(start, eventTime, part.state.time)
|
|
321
|
+
if (typeof durationMs !== "number") {
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
state.compressionTiming.pendingByCallId.set(key, {
|
|
326
|
+
messageId: part.messageID,
|
|
327
|
+
callId: part.callID,
|
|
328
|
+
durationMs,
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
const updates = applyPendingCompressionDurations(state)
|
|
332
|
+
if (updates === 0) {
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
await saveSessionState(state, logger)
|
|
337
|
+
|
|
338
|
+
logger.info("Attached compression time to blocks", {
|
|
339
|
+
messageID: part.messageID,
|
|
340
|
+
callID: part.callID,
|
|
341
|
+
blocks: updates,
|
|
342
|
+
durationMs,
|
|
343
|
+
})
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (part.state.status === "running") {
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (typeof part.callID === "string" && typeof part.messageID === "string") {
|
|
352
|
+
state.compressionTiming.startsByCallId.delete(
|
|
353
|
+
buildCompressionTimingKey(part.messageID, part.callID),
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function createChatMessageHandler(
|
|
360
|
+
state: SessionState,
|
|
361
|
+
logger: Logger,
|
|
362
|
+
_config: PluginConfig,
|
|
363
|
+
_hostPermissions: HostPermissionSnapshot,
|
|
364
|
+
) {
|
|
365
|
+
return async (
|
|
366
|
+
input: {
|
|
367
|
+
sessionID: string
|
|
368
|
+
agent?: string
|
|
369
|
+
model?: { providerID: string; modelID: string }
|
|
370
|
+
messageID?: string
|
|
371
|
+
variant?: string
|
|
372
|
+
},
|
|
373
|
+
_output: any,
|
|
374
|
+
) => {
|
|
375
|
+
state.variant = input.variant
|
|
376
|
+
logger.debug("Cached variant from chat.message hook", { variant: input.variant })
|
|
377
|
+
}
|
|
378
|
+
}
|