@tarquinen/opencode-dcp 3.2.5-beta0 → 3.2.6-beta0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/index.ts +141 -0
  2. package/lib/analysis/tokens.ts +225 -0
  3. package/lib/auth.ts +37 -0
  4. package/lib/commands/compression-targets.ts +137 -0
  5. package/lib/commands/context.ts +132 -0
  6. package/lib/commands/decompress.ts +275 -0
  7. package/lib/commands/help.ts +76 -0
  8. package/lib/commands/index.ts +11 -0
  9. package/lib/commands/manual.ts +125 -0
  10. package/lib/commands/recompress.ts +224 -0
  11. package/lib/commands/stats.ts +148 -0
  12. package/lib/commands/sweep.ts +268 -0
  13. package/lib/compress/index.ts +3 -0
  14. package/lib/compress/message-utils.ts +250 -0
  15. package/lib/compress/message.ts +137 -0
  16. package/lib/compress/pipeline.ts +106 -0
  17. package/lib/compress/protected-content.ts +154 -0
  18. package/lib/compress/range-utils.ts +308 -0
  19. package/lib/compress/range.ts +180 -0
  20. package/lib/compress/search.ts +267 -0
  21. package/lib/compress/state.ts +268 -0
  22. package/lib/compress/timing.ts +77 -0
  23. package/lib/compress/types.ts +108 -0
  24. package/lib/compress-permission.ts +25 -0
  25. package/lib/config.ts +1071 -0
  26. package/lib/hooks.ts +378 -0
  27. package/lib/host-permissions.ts +101 -0
  28. package/lib/logger.ts +235 -0
  29. package/lib/message-ids.ts +172 -0
  30. package/lib/messages/index.ts +8 -0
  31. package/lib/messages/inject/inject.ts +215 -0
  32. package/lib/messages/inject/subagent-results.ts +82 -0
  33. package/lib/messages/inject/utils.ts +374 -0
  34. package/lib/messages/priority.ts +102 -0
  35. package/lib/messages/prune.ts +238 -0
  36. package/lib/messages/query.ts +56 -0
  37. package/lib/messages/reasoning-strip.ts +40 -0
  38. package/lib/messages/sync.ts +124 -0
  39. package/lib/messages/utils.ts +187 -0
  40. package/lib/prompts/compress-message.ts +42 -0
  41. package/lib/prompts/compress-range.ts +60 -0
  42. package/lib/prompts/context-limit-nudge.ts +18 -0
  43. package/lib/prompts/extensions/nudge.ts +43 -0
  44. package/lib/prompts/extensions/system.ts +32 -0
  45. package/lib/prompts/extensions/tool.ts +35 -0
  46. package/lib/prompts/index.ts +29 -0
  47. package/lib/prompts/iteration-nudge.ts +6 -0
  48. package/lib/prompts/store.ts +467 -0
  49. package/lib/prompts/system.ts +33 -0
  50. package/lib/prompts/turn-nudge.ts +10 -0
  51. package/lib/protected-patterns.ts +128 -0
  52. package/lib/state/index.ts +4 -0
  53. package/lib/state/persistence.ts +256 -0
  54. package/lib/state/state.ts +190 -0
  55. package/lib/state/tool-cache.ts +98 -0
  56. package/lib/state/types.ts +112 -0
  57. package/lib/state/utils.ts +334 -0
  58. package/lib/strategies/deduplication.ts +127 -0
  59. package/lib/strategies/index.ts +2 -0
  60. package/lib/strategies/purge-errors.ts +88 -0
  61. package/lib/subagents/subagent-results.ts +74 -0
  62. package/lib/token-utils.ts +162 -0
  63. package/lib/ui/notification.ts +346 -0
  64. package/lib/ui/utils.ts +287 -0
  65. package/package.json +9 -2
  66. package/tui/data/context.ts +177 -0
  67. package/tui/index.tsx +34 -0
  68. package/tui/routes/summary.tsx +175 -0
  69. package/tui/shared/names.ts +9 -0
  70. package/tui/shared/theme.ts +58 -0
  71. package/tui/shared/types.ts +38 -0
  72. package/tui/slots/sidebar-content.tsx +502 -0
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
+ }
@@ -0,0 +1,101 @@
1
+ export type PermissionAction = "ask" | "allow" | "deny"
2
+
3
+ export type PermissionValue = PermissionAction | Record<string, PermissionAction>
4
+
5
+ export type PermissionConfig = Record<string, PermissionValue> | undefined
6
+
7
+ export interface HostPermissionSnapshot {
8
+ global: PermissionConfig
9
+ agents: Record<string, PermissionConfig>
10
+ }
11
+
12
+ type PermissionRule = {
13
+ permission: string
14
+ pattern: string
15
+ action: PermissionAction
16
+ }
17
+
18
+ const findLastMatchingRule = (
19
+ rules: PermissionRule[],
20
+ predicate: (rule: PermissionRule) => boolean,
21
+ ): PermissionRule | undefined => {
22
+ for (let index = rules.length - 1; index >= 0; index -= 1) {
23
+ const rule = rules[index]
24
+ if (rule && predicate(rule)) {
25
+ return rule
26
+ }
27
+ }
28
+
29
+ return undefined
30
+ }
31
+
32
+ const wildcardMatch = (value: string, pattern: string): boolean => {
33
+ const normalizedValue = value.replaceAll("\\", "/")
34
+ let escaped = pattern
35
+ .replaceAll("\\", "/")
36
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
37
+ .replace(/\*/g, ".*")
38
+ .replace(/\?/g, ".")
39
+
40
+ if (escaped.endsWith(" .*")) {
41
+ escaped = escaped.slice(0, -3) + "( .*)?"
42
+ }
43
+
44
+ const flags = process.platform === "win32" ? "si" : "s"
45
+ return new RegExp(`^${escaped}$`, flags).test(normalizedValue)
46
+ }
47
+
48
+ const getPermissionRules = (permissionConfigs: PermissionConfig[]): PermissionRule[] => {
49
+ const rules: PermissionRule[] = []
50
+ for (const permissionConfig of permissionConfigs) {
51
+ if (!permissionConfig) {
52
+ continue
53
+ }
54
+
55
+ for (const [permission, value] of Object.entries(permissionConfig)) {
56
+ if (value === "ask" || value === "allow" || value === "deny") {
57
+ rules.push({ permission, pattern: "*", action: value })
58
+ continue
59
+ }
60
+
61
+ for (const [pattern, action] of Object.entries(value)) {
62
+ if (action === "ask" || action === "allow" || action === "deny") {
63
+ rules.push({ permission, pattern, action })
64
+ }
65
+ }
66
+ }
67
+ }
68
+ return rules
69
+ }
70
+
71
+ export const compressDisabledByOpencode = (...permissionConfigs: PermissionConfig[]): boolean => {
72
+ const match = findLastMatchingRule(getPermissionRules(permissionConfigs), (rule) =>
73
+ wildcardMatch("compress", rule.permission),
74
+ )
75
+
76
+ return match?.pattern === "*" && match.action === "deny"
77
+ }
78
+
79
+ export const resolveEffectiveCompressPermission = (
80
+ basePermission: PermissionAction,
81
+ hostPermissions: HostPermissionSnapshot,
82
+ agentName?: string,
83
+ ): PermissionAction => {
84
+ if (basePermission === "deny") {
85
+ return "deny"
86
+ }
87
+
88
+ return compressDisabledByOpencode(
89
+ hostPermissions.global,
90
+ agentName ? hostPermissions.agents[agentName] : undefined,
91
+ )
92
+ ? "deny"
93
+ : basePermission
94
+ }
95
+
96
+ export const hasExplicitToolPermission = (
97
+ permissionConfig: PermissionConfig,
98
+ tool: string,
99
+ ): boolean => {
100
+ return permissionConfig ? Object.prototype.hasOwnProperty.call(permissionConfig, tool) : false
101
+ }