@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.
Files changed (46) hide show
  1. package/dcp.schema.json +329 -0
  2. package/dist/lib/config.js +2 -2
  3. package/dist/lib/config.js.map +1 -1
  4. package/index.ts +141 -0
  5. package/lib/auth.ts +37 -0
  6. package/lib/commands/compression-targets.ts +137 -0
  7. package/lib/commands/context.ts +132 -0
  8. package/lib/commands/decompress.ts +275 -0
  9. package/lib/commands/help.ts +76 -0
  10. package/lib/commands/index.ts +11 -0
  11. package/lib/commands/manual.ts +125 -0
  12. package/lib/commands/recompress.ts +224 -0
  13. package/lib/commands/stats.ts +148 -0
  14. package/lib/commands/sweep.ts +268 -0
  15. package/lib/compress-permission.ts +25 -0
  16. package/lib/config.ts +2 -2
  17. package/lib/hooks.ts +378 -0
  18. package/lib/host-permissions.ts +101 -0
  19. package/lib/messages/index.ts +8 -0
  20. package/lib/messages/inject/inject.ts +215 -0
  21. package/lib/messages/inject/subagent-results.ts +82 -0
  22. package/lib/messages/inject/utils.ts +374 -0
  23. package/lib/messages/priority.ts +102 -0
  24. package/lib/messages/prune.ts +238 -0
  25. package/lib/messages/reasoning-strip.ts +40 -0
  26. package/lib/messages/sync.ts +124 -0
  27. package/lib/messages/utils.ts +187 -0
  28. package/lib/prompts/compress-message.ts +42 -0
  29. package/lib/prompts/compress-range.ts +60 -0
  30. package/lib/prompts/context-limit-nudge.ts +18 -0
  31. package/lib/prompts/extensions/nudge.ts +43 -0
  32. package/lib/prompts/extensions/system.ts +32 -0
  33. package/lib/prompts/extensions/tool.ts +35 -0
  34. package/lib/prompts/index.ts +29 -0
  35. package/lib/prompts/iteration-nudge.ts +6 -0
  36. package/lib/prompts/store.ts +467 -0
  37. package/lib/prompts/system.ts +33 -0
  38. package/lib/prompts/turn-nudge.ts +10 -0
  39. package/lib/protected-patterns.ts +128 -0
  40. package/lib/strategies/deduplication.ts +127 -0
  41. package/lib/strategies/index.ts +2 -0
  42. package/lib/strategies/purge-errors.ts +88 -0
  43. package/lib/subagents/subagent-results.ts +74 -0
  44. package/lib/ui/notification.ts +346 -0
  45. package/lib/ui/utils.ts +287 -0
  46. 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 { parse } from "jsonc-parser"
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
+ }