@tarquinen/opencode-dcp 3.1.11 → 3.1.13

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 (111) hide show
  1. package/README.md +14 -9
  2. package/dist/index.js +106 -49
  3. package/dist/index.js.map +1 -1
  4. package/dist/lib/commands/context.d.ts +15 -0
  5. package/dist/lib/commands/context.d.ts.map +1 -1
  6. package/dist/lib/commands/help.d.ts +1 -0
  7. package/dist/lib/commands/help.d.ts.map +1 -1
  8. package/dist/lib/commands/manual.d.ts +1 -1
  9. package/dist/lib/commands/manual.d.ts.map +1 -1
  10. package/dist/lib/commands/stats.d.ts +10 -0
  11. package/dist/lib/commands/stats.d.ts.map +1 -1
  12. package/dist/lib/compress/pipeline.d.ts.map +1 -1
  13. package/dist/lib/hooks.d.ts +0 -2
  14. package/dist/lib/hooks.d.ts.map +1 -1
  15. package/dist/lib/messages/index.d.ts +0 -1
  16. package/dist/lib/messages/index.d.ts.map +1 -1
  17. package/dist/lib/messages/inject/utils.d.ts +0 -7
  18. package/dist/lib/messages/inject/utils.d.ts.map +1 -1
  19. package/dist/lib/state/persistence.d.ts +3 -0
  20. package/dist/lib/state/persistence.d.ts.map +1 -1
  21. package/dist/lib/state/state.d.ts +1 -0
  22. package/dist/lib/state/state.d.ts.map +1 -1
  23. package/dist/lib/tui/commands.d.ts +3 -0
  24. package/dist/lib/tui/commands.d.ts.map +1 -0
  25. package/dist/lib/tui/data.d.ts +14 -0
  26. package/dist/lib/tui/data.d.ts.map +1 -0
  27. package/dist/lib/tui/dialogs.d.ts +30 -0
  28. package/dist/lib/tui/dialogs.d.ts.map +1 -0
  29. package/dist/lib/tui/format.d.ts +4 -0
  30. package/dist/lib/tui/format.d.ts.map +1 -0
  31. package/dist/lib/tui/modals.d.ts +10 -0
  32. package/dist/lib/tui/modals.d.ts.map +1 -0
  33. package/dist/lib/tui/types.d.ts +15 -0
  34. package/dist/lib/tui/types.d.ts.map +1 -0
  35. package/dist/lib/tui/ui.d.ts +48 -0
  36. package/dist/lib/tui/ui.d.ts.map +1 -0
  37. package/dist/tui.d.ts +7 -0
  38. package/dist/tui.d.ts.map +1 -0
  39. package/lib/auth.ts +37 -0
  40. package/lib/commands/compression-targets.ts +137 -0
  41. package/lib/commands/context.ts +305 -0
  42. package/lib/commands/decompress.ts +275 -0
  43. package/lib/commands/help.ts +76 -0
  44. package/lib/commands/index.ts +11 -0
  45. package/lib/commands/manual.ts +127 -0
  46. package/lib/commands/recompress.ts +224 -0
  47. package/lib/commands/stats.ts +159 -0
  48. package/lib/commands/sweep.ts +268 -0
  49. package/lib/compress/index.ts +3 -0
  50. package/lib/compress/message-utils.ts +250 -0
  51. package/lib/compress/message.ts +145 -0
  52. package/lib/compress/pipeline.ts +108 -0
  53. package/lib/compress/protected-content.ts +208 -0
  54. package/lib/compress/range-utils.ts +308 -0
  55. package/lib/compress/range.ts +192 -0
  56. package/lib/compress/search.ts +267 -0
  57. package/lib/compress/state.ts +268 -0
  58. package/lib/compress/timing.ts +77 -0
  59. package/lib/compress/types.ts +108 -0
  60. package/lib/compress-permission.ts +25 -0
  61. package/lib/config.ts +1007 -0
  62. package/lib/hooks.ts +375 -0
  63. package/lib/host-permissions.ts +101 -0
  64. package/lib/logger.ts +226 -0
  65. package/lib/message-ids.ts +172 -0
  66. package/lib/messages/index.ts +8 -0
  67. package/lib/messages/inject/inject.ts +215 -0
  68. package/lib/messages/inject/subagent-results.ts +82 -0
  69. package/lib/messages/inject/utils.ts +374 -0
  70. package/lib/messages/priority.ts +102 -0
  71. package/lib/messages/prune.ts +233 -0
  72. package/lib/messages/query.ts +72 -0
  73. package/lib/messages/reasoning-strip.ts +40 -0
  74. package/lib/messages/shape.ts +50 -0
  75. package/lib/messages/sync.ts +124 -0
  76. package/lib/messages/utils.ts +185 -0
  77. package/lib/prompts/compress-message.ts +43 -0
  78. package/lib/prompts/compress-range.ts +60 -0
  79. package/lib/prompts/context-limit-nudge.ts +18 -0
  80. package/lib/prompts/extensions/nudge.ts +43 -0
  81. package/lib/prompts/extensions/system.ts +32 -0
  82. package/lib/prompts/extensions/tool.ts +35 -0
  83. package/lib/prompts/index.ts +29 -0
  84. package/lib/prompts/iteration-nudge.ts +6 -0
  85. package/lib/prompts/store.ts +467 -0
  86. package/lib/prompts/system.ts +33 -0
  87. package/lib/prompts/turn-nudge.ts +10 -0
  88. package/lib/protected-patterns.ts +128 -0
  89. package/lib/state/index.ts +4 -0
  90. package/lib/state/persistence.ts +305 -0
  91. package/lib/state/state.ts +208 -0
  92. package/lib/state/tool-cache.ts +98 -0
  93. package/lib/state/types.ts +111 -0
  94. package/lib/state/utils.ts +345 -0
  95. package/lib/strategies/deduplication.ts +127 -0
  96. package/lib/strategies/index.ts +2 -0
  97. package/lib/strategies/purge-errors.ts +88 -0
  98. package/lib/subagents/subagent-results.ts +74 -0
  99. package/lib/token-utils.ts +164 -0
  100. package/lib/tui/commands.ts +31 -0
  101. package/lib/tui/data.ts +73 -0
  102. package/lib/tui/dialogs.tsx +235 -0
  103. package/lib/tui/format.ts +25 -0
  104. package/lib/tui/modals.tsx +92 -0
  105. package/lib/tui/types.ts +16 -0
  106. package/lib/tui/ui.tsx +219 -0
  107. package/lib/ui/notification.ts +347 -0
  108. package/lib/ui/utils.ts +304 -0
  109. package/lib/update.ts +185 -0
  110. package/package.json +17 -5
  111. package/tui.tsx +26 -0
package/lib/config.ts ADDED
@@ -0,0 +1,1007 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from "fs"
2
+ import { join, dirname } from "path"
3
+ import { homedir } from "os"
4
+ import { parse } from "jsonc-parser/lib/esm/main.js"
5
+ import type { PluginInput } from "@opencode-ai/plugin"
6
+
7
+ type Permission = "ask" | "allow" | "deny"
8
+ type CompressMode = "range" | "message"
9
+
10
+ export interface Deduplication {
11
+ enabled: boolean
12
+ protectedTools: string[]
13
+ }
14
+
15
+ export interface CompressConfig {
16
+ mode: CompressMode
17
+ permission: Permission
18
+ showCompression: boolean
19
+ summaryBuffer: boolean
20
+ maxContextLimit: number | `${number}%`
21
+ minContextLimit: number | `${number}%`
22
+ modelMaxLimits?: Record<string, number | `${number}%`>
23
+ modelMinLimits?: Record<string, number | `${number}%`>
24
+ nudgeFrequency: number
25
+ iterationNudgeThreshold: number
26
+ nudgeForce: "strong" | "soft"
27
+ protectedTools: string[]
28
+ protectTags: boolean
29
+ protectUserMessages: boolean
30
+ }
31
+
32
+ export interface Commands {
33
+ enabled: boolean
34
+ protectedTools: string[]
35
+ }
36
+
37
+ export interface ManualModeConfig {
38
+ enabled: boolean
39
+ automaticStrategies: boolean
40
+ }
41
+
42
+ export interface PurgeErrors {
43
+ enabled: boolean
44
+ turns: number
45
+ protectedTools: string[]
46
+ }
47
+
48
+ export interface TurnProtection {
49
+ enabled: boolean
50
+ turns: number
51
+ }
52
+
53
+ export interface ExperimentalConfig {
54
+ allowSubAgents: boolean
55
+ customPrompts: boolean
56
+ }
57
+
58
+ export interface PluginConfig {
59
+ enabled: boolean
60
+ autoUpdate: boolean
61
+ debug: boolean
62
+ pruneNotification: "off" | "minimal" | "detailed"
63
+ pruneNotificationType: "chat" | "toast"
64
+ commands: Commands
65
+ manualMode: ManualModeConfig
66
+ turnProtection: TurnProtection
67
+ experimental: ExperimentalConfig
68
+ protectedFilePatterns: string[]
69
+ compress: CompressConfig
70
+ strategies: {
71
+ deduplication: Deduplication
72
+ purgeErrors: PurgeErrors
73
+ }
74
+ }
75
+
76
+ type CompressOverride = Partial<CompressConfig>
77
+
78
+ const DEFAULT_PROTECTED_TOOLS = [
79
+ "task",
80
+ "skill",
81
+ "todowrite",
82
+ "todoread",
83
+ "compress",
84
+ "batch",
85
+ "plan_enter",
86
+ "plan_exit",
87
+ "write",
88
+ "edit",
89
+ ]
90
+
91
+ const COMPRESS_DEFAULT_PROTECTED_TOOLS = ["task", "skill", "todowrite", "todoread"]
92
+
93
+ export const VALID_CONFIG_KEYS = new Set([
94
+ "$schema",
95
+ "enabled",
96
+ "autoUpdate",
97
+ "debug",
98
+ "showUpdateToasts",
99
+ "pruneNotification",
100
+ "pruneNotificationType",
101
+ "turnProtection",
102
+ "turnProtection.enabled",
103
+ "turnProtection.turns",
104
+ "experimental",
105
+ "experimental.allowSubAgents",
106
+ "experimental.customPrompts",
107
+ "protectedFilePatterns",
108
+ "commands",
109
+ "commands.enabled",
110
+ "commands.protectedTools",
111
+ "manualMode",
112
+ "manualMode.enabled",
113
+ "manualMode.automaticStrategies",
114
+ "compress",
115
+ "compress.mode",
116
+ "compress.permission",
117
+ "compress.showCompression",
118
+ "compress.summaryBuffer",
119
+ "compress.maxContextLimit",
120
+ "compress.minContextLimit",
121
+ "compress.modelMaxLimits",
122
+ "compress.modelMinLimits",
123
+ "compress.nudgeFrequency",
124
+ "compress.iterationNudgeThreshold",
125
+ "compress.nudgeForce",
126
+ "compress.protectedTools",
127
+ "compress.protectTags",
128
+ "compress.protectUserMessages",
129
+ "strategies",
130
+ "strategies.deduplication",
131
+ "strategies.deduplication.enabled",
132
+ "strategies.deduplication.protectedTools",
133
+ "strategies.purgeErrors",
134
+ "strategies.purgeErrors.enabled",
135
+ "strategies.purgeErrors.turns",
136
+ "strategies.purgeErrors.protectedTools",
137
+ ])
138
+
139
+ function getConfigKeyPaths(obj: Record<string, any>, prefix = ""): string[] {
140
+ const keys: string[] = []
141
+ for (const key of Object.keys(obj)) {
142
+ const fullKey = prefix ? `${prefix}.${key}` : key
143
+ keys.push(fullKey)
144
+
145
+ // model*Limits are dynamic maps keyed by providerID/modelID; do not recurse into arbitrary IDs.
146
+ if (fullKey === "compress.modelMaxLimits" || fullKey === "compress.modelMinLimits") {
147
+ continue
148
+ }
149
+
150
+ if (obj[key] && typeof obj[key] === "object" && !Array.isArray(obj[key])) {
151
+ keys.push(...getConfigKeyPaths(obj[key], fullKey))
152
+ }
153
+ }
154
+ return keys
155
+ }
156
+
157
+ export function getInvalidConfigKeys(userConfig: Record<string, any>): string[] {
158
+ const userKeys = getConfigKeyPaths(userConfig)
159
+ return userKeys.filter((key) => !VALID_CONFIG_KEYS.has(key))
160
+ }
161
+
162
+ interface ValidationError {
163
+ key: string
164
+ expected: string
165
+ actual: string
166
+ }
167
+
168
+ export function validateConfigTypes(config: Record<string, any>): ValidationError[] {
169
+ const errors: ValidationError[] = []
170
+
171
+ if (config.enabled !== undefined && typeof config.enabled !== "boolean") {
172
+ errors.push({ key: "enabled", expected: "boolean", actual: typeof config.enabled })
173
+ }
174
+
175
+ if (config.autoUpdate !== undefined && typeof config.autoUpdate !== "boolean") {
176
+ errors.push({ key: "autoUpdate", expected: "boolean", actual: typeof config.autoUpdate })
177
+ }
178
+
179
+ if (config.debug !== undefined && typeof config.debug !== "boolean") {
180
+ errors.push({ key: "debug", expected: "boolean", actual: typeof config.debug })
181
+ }
182
+
183
+ if (config.pruneNotification !== undefined) {
184
+ const validValues = ["off", "minimal", "detailed"]
185
+ if (!validValues.includes(config.pruneNotification)) {
186
+ errors.push({
187
+ key: "pruneNotification",
188
+ expected: '"off" | "minimal" | "detailed"',
189
+ actual: JSON.stringify(config.pruneNotification),
190
+ })
191
+ }
192
+ }
193
+
194
+ if (config.pruneNotificationType !== undefined) {
195
+ const validValues = ["chat", "toast"]
196
+ if (!validValues.includes(config.pruneNotificationType)) {
197
+ errors.push({
198
+ key: "pruneNotificationType",
199
+ expected: '"chat" | "toast"',
200
+ actual: JSON.stringify(config.pruneNotificationType),
201
+ })
202
+ }
203
+ }
204
+
205
+ if (config.protectedFilePatterns !== undefined) {
206
+ if (!Array.isArray(config.protectedFilePatterns)) {
207
+ errors.push({
208
+ key: "protectedFilePatterns",
209
+ expected: "string[]",
210
+ actual: typeof config.protectedFilePatterns,
211
+ })
212
+ } else if (!config.protectedFilePatterns.every((v: unknown) => typeof v === "string")) {
213
+ errors.push({
214
+ key: "protectedFilePatterns",
215
+ expected: "string[]",
216
+ actual: "non-string entries",
217
+ })
218
+ }
219
+ }
220
+
221
+ if (config.turnProtection) {
222
+ if (
223
+ config.turnProtection.enabled !== undefined &&
224
+ typeof config.turnProtection.enabled !== "boolean"
225
+ ) {
226
+ errors.push({
227
+ key: "turnProtection.enabled",
228
+ expected: "boolean",
229
+ actual: typeof config.turnProtection.enabled,
230
+ })
231
+ }
232
+
233
+ if (
234
+ config.turnProtection.turns !== undefined &&
235
+ typeof config.turnProtection.turns !== "number"
236
+ ) {
237
+ errors.push({
238
+ key: "turnProtection.turns",
239
+ expected: "number",
240
+ actual: typeof config.turnProtection.turns,
241
+ })
242
+ }
243
+ if (typeof config.turnProtection.turns === "number" && config.turnProtection.turns < 1) {
244
+ errors.push({
245
+ key: "turnProtection.turns",
246
+ expected: "positive number (>= 1)",
247
+ actual: `${config.turnProtection.turns}`,
248
+ })
249
+ }
250
+ }
251
+
252
+ const experimental = config.experimental
253
+ if (experimental !== undefined) {
254
+ if (
255
+ typeof experimental !== "object" ||
256
+ experimental === null ||
257
+ Array.isArray(experimental)
258
+ ) {
259
+ errors.push({
260
+ key: "experimental",
261
+ expected: "object",
262
+ actual: typeof experimental,
263
+ })
264
+ } else {
265
+ if (
266
+ experimental.allowSubAgents !== undefined &&
267
+ typeof experimental.allowSubAgents !== "boolean"
268
+ ) {
269
+ errors.push({
270
+ key: "experimental.allowSubAgents",
271
+ expected: "boolean",
272
+ actual: typeof experimental.allowSubAgents,
273
+ })
274
+ }
275
+
276
+ if (
277
+ experimental.customPrompts !== undefined &&
278
+ typeof experimental.customPrompts !== "boolean"
279
+ ) {
280
+ errors.push({
281
+ key: "experimental.customPrompts",
282
+ expected: "boolean",
283
+ actual: typeof experimental.customPrompts,
284
+ })
285
+ }
286
+ }
287
+ }
288
+
289
+ const commands = config.commands
290
+ if (commands !== undefined) {
291
+ if (typeof commands !== "object" || commands === null || Array.isArray(commands)) {
292
+ errors.push({
293
+ key: "commands",
294
+ expected: "object",
295
+ actual: typeof commands,
296
+ })
297
+ } else {
298
+ if (commands.enabled !== undefined && typeof commands.enabled !== "boolean") {
299
+ errors.push({
300
+ key: "commands.enabled",
301
+ expected: "boolean",
302
+ actual: typeof commands.enabled,
303
+ })
304
+ }
305
+ if (commands.protectedTools !== undefined && !Array.isArray(commands.protectedTools)) {
306
+ errors.push({
307
+ key: "commands.protectedTools",
308
+ expected: "string[]",
309
+ actual: typeof commands.protectedTools,
310
+ })
311
+ }
312
+ }
313
+ }
314
+
315
+ const manualMode = config.manualMode
316
+ if (manualMode !== undefined) {
317
+ if (typeof manualMode !== "object" || manualMode === null || Array.isArray(manualMode)) {
318
+ errors.push({
319
+ key: "manualMode",
320
+ expected: "object",
321
+ actual: typeof manualMode,
322
+ })
323
+ } else {
324
+ if (manualMode.enabled !== undefined && typeof manualMode.enabled !== "boolean") {
325
+ errors.push({
326
+ key: "manualMode.enabled",
327
+ expected: "boolean",
328
+ actual: typeof manualMode.enabled,
329
+ })
330
+ }
331
+
332
+ if (
333
+ manualMode.automaticStrategies !== undefined &&
334
+ typeof manualMode.automaticStrategies !== "boolean"
335
+ ) {
336
+ errors.push({
337
+ key: "manualMode.automaticStrategies",
338
+ expected: "boolean",
339
+ actual: typeof manualMode.automaticStrategies,
340
+ })
341
+ }
342
+ }
343
+ }
344
+
345
+ const compress = config.compress
346
+ if (compress !== undefined) {
347
+ if (typeof compress !== "object" || compress === null || Array.isArray(compress)) {
348
+ errors.push({
349
+ key: "compress",
350
+ expected: "object",
351
+ actual: typeof compress,
352
+ })
353
+ } else {
354
+ if (
355
+ compress.mode !== undefined &&
356
+ compress.mode !== "range" &&
357
+ compress.mode !== "message"
358
+ ) {
359
+ errors.push({
360
+ key: "compress.mode",
361
+ expected: '"range" | "message"',
362
+ actual: JSON.stringify(compress.mode),
363
+ })
364
+ }
365
+
366
+ if (
367
+ compress.summaryBuffer !== undefined &&
368
+ typeof compress.summaryBuffer !== "boolean"
369
+ ) {
370
+ errors.push({
371
+ key: "compress.summaryBuffer",
372
+ expected: "boolean",
373
+ actual: typeof compress.summaryBuffer,
374
+ })
375
+ }
376
+
377
+ if (
378
+ compress.nudgeFrequency !== undefined &&
379
+ typeof compress.nudgeFrequency !== "number"
380
+ ) {
381
+ errors.push({
382
+ key: "compress.nudgeFrequency",
383
+ expected: "number",
384
+ actual: typeof compress.nudgeFrequency,
385
+ })
386
+ }
387
+
388
+ if (typeof compress.nudgeFrequency === "number" && compress.nudgeFrequency < 1) {
389
+ errors.push({
390
+ key: "compress.nudgeFrequency",
391
+ expected: "positive number (>= 1)",
392
+ actual: `${compress.nudgeFrequency} (will be clamped to 1)`,
393
+ })
394
+ }
395
+
396
+ if (
397
+ compress.iterationNudgeThreshold !== undefined &&
398
+ typeof compress.iterationNudgeThreshold !== "number"
399
+ ) {
400
+ errors.push({
401
+ key: "compress.iterationNudgeThreshold",
402
+ expected: "number",
403
+ actual: typeof compress.iterationNudgeThreshold,
404
+ })
405
+ }
406
+
407
+ if (
408
+ compress.nudgeForce !== undefined &&
409
+ compress.nudgeForce !== "strong" &&
410
+ compress.nudgeForce !== "soft"
411
+ ) {
412
+ errors.push({
413
+ key: "compress.nudgeForce",
414
+ expected: '"strong" | "soft"',
415
+ actual: JSON.stringify(compress.nudgeForce),
416
+ })
417
+ }
418
+
419
+ if (compress.protectedTools !== undefined && !Array.isArray(compress.protectedTools)) {
420
+ errors.push({
421
+ key: "compress.protectedTools",
422
+ expected: "string[]",
423
+ actual: typeof compress.protectedTools,
424
+ })
425
+ }
426
+
427
+ if (compress.protectTags !== undefined && typeof compress.protectTags !== "boolean") {
428
+ errors.push({
429
+ key: "compress.protectTags",
430
+ expected: "boolean",
431
+ actual: typeof compress.protectTags,
432
+ })
433
+ }
434
+
435
+ if (
436
+ compress.protectUserMessages !== undefined &&
437
+ typeof compress.protectUserMessages !== "boolean"
438
+ ) {
439
+ errors.push({
440
+ key: "compress.protectUserMessages",
441
+ expected: "boolean",
442
+ actual: typeof compress.protectUserMessages,
443
+ })
444
+ }
445
+
446
+ if (
447
+ typeof compress.iterationNudgeThreshold === "number" &&
448
+ compress.iterationNudgeThreshold < 1
449
+ ) {
450
+ errors.push({
451
+ key: "compress.iterationNudgeThreshold",
452
+ expected: "positive number (>= 1)",
453
+ actual: `${compress.iterationNudgeThreshold} (will be clamped to 1)`,
454
+ })
455
+ }
456
+
457
+ const validateLimitValue = (
458
+ key: string,
459
+ value: unknown,
460
+ actualValue: unknown = value,
461
+ ): void => {
462
+ const isValidNumber = typeof value === "number"
463
+ const isPercentString = typeof value === "string" && value.endsWith("%")
464
+
465
+ if (!isValidNumber && !isPercentString) {
466
+ errors.push({
467
+ key,
468
+ expected: 'number | "${number}%"',
469
+ actual: JSON.stringify(actualValue),
470
+ })
471
+ }
472
+ }
473
+
474
+ const validateModelLimits = (
475
+ key: "compress.modelMaxLimits" | "compress.modelMinLimits",
476
+ limits: unknown,
477
+ ): void => {
478
+ if (limits === undefined) {
479
+ return
480
+ }
481
+
482
+ if (typeof limits !== "object" || limits === null || Array.isArray(limits)) {
483
+ errors.push({
484
+ key,
485
+ expected: "Record<string, number | ${number}%>",
486
+ actual: typeof limits,
487
+ })
488
+ return
489
+ }
490
+
491
+ for (const [providerModelKey, limit] of Object.entries(limits)) {
492
+ const isValidNumber = typeof limit === "number"
493
+ const isPercentString =
494
+ typeof limit === "string" && /^\d+(?:\.\d+)?%$/.test(limit)
495
+ if (!isValidNumber && !isPercentString) {
496
+ errors.push({
497
+ key: `${key}.${providerModelKey}`,
498
+ expected: 'number | "${number}%"',
499
+ actual: JSON.stringify(limit),
500
+ })
501
+ }
502
+ }
503
+ }
504
+
505
+ if (compress.maxContextLimit !== undefined) {
506
+ validateLimitValue("compress.maxContextLimit", compress.maxContextLimit)
507
+ }
508
+
509
+ if (compress.minContextLimit !== undefined) {
510
+ validateLimitValue("compress.minContextLimit", compress.minContextLimit)
511
+ }
512
+
513
+ validateModelLimits("compress.modelMaxLimits", compress.modelMaxLimits)
514
+ validateModelLimits("compress.modelMinLimits", compress.modelMinLimits)
515
+
516
+ const validValues = ["ask", "allow", "deny"]
517
+ if (compress.permission !== undefined && !validValues.includes(compress.permission)) {
518
+ errors.push({
519
+ key: "compress.permission",
520
+ expected: '"ask" | "allow" | "deny"',
521
+ actual: JSON.stringify(compress.permission),
522
+ })
523
+ }
524
+
525
+ if (
526
+ compress.showCompression !== undefined &&
527
+ typeof compress.showCompression !== "boolean"
528
+ ) {
529
+ errors.push({
530
+ key: "compress.showCompression",
531
+ expected: "boolean",
532
+ actual: typeof compress.showCompression,
533
+ })
534
+ }
535
+ }
536
+ }
537
+
538
+ const strategies = config.strategies
539
+ if (strategies) {
540
+ if (
541
+ strategies.deduplication?.enabled !== undefined &&
542
+ typeof strategies.deduplication.enabled !== "boolean"
543
+ ) {
544
+ errors.push({
545
+ key: "strategies.deduplication.enabled",
546
+ expected: "boolean",
547
+ actual: typeof strategies.deduplication.enabled,
548
+ })
549
+ }
550
+
551
+ if (
552
+ strategies.deduplication?.protectedTools !== undefined &&
553
+ !Array.isArray(strategies.deduplication.protectedTools)
554
+ ) {
555
+ errors.push({
556
+ key: "strategies.deduplication.protectedTools",
557
+ expected: "string[]",
558
+ actual: typeof strategies.deduplication.protectedTools,
559
+ })
560
+ }
561
+
562
+ if (strategies.purgeErrors) {
563
+ if (
564
+ strategies.purgeErrors.enabled !== undefined &&
565
+ typeof strategies.purgeErrors.enabled !== "boolean"
566
+ ) {
567
+ errors.push({
568
+ key: "strategies.purgeErrors.enabled",
569
+ expected: "boolean",
570
+ actual: typeof strategies.purgeErrors.enabled,
571
+ })
572
+ }
573
+
574
+ if (
575
+ strategies.purgeErrors.turns !== undefined &&
576
+ typeof strategies.purgeErrors.turns !== "number"
577
+ ) {
578
+ errors.push({
579
+ key: "strategies.purgeErrors.turns",
580
+ expected: "number",
581
+ actual: typeof strategies.purgeErrors.turns,
582
+ })
583
+ }
584
+ // Warn if turns is 0 or negative - will be clamped to 1
585
+ if (
586
+ typeof strategies.purgeErrors.turns === "number" &&
587
+ strategies.purgeErrors.turns < 1
588
+ ) {
589
+ errors.push({
590
+ key: "strategies.purgeErrors.turns",
591
+ expected: "positive number (>= 1)",
592
+ actual: `${strategies.purgeErrors.turns} (will be clamped to 1)`,
593
+ })
594
+ }
595
+ if (
596
+ strategies.purgeErrors.protectedTools !== undefined &&
597
+ !Array.isArray(strategies.purgeErrors.protectedTools)
598
+ ) {
599
+ errors.push({
600
+ key: "strategies.purgeErrors.protectedTools",
601
+ expected: "string[]",
602
+ actual: typeof strategies.purgeErrors.protectedTools,
603
+ })
604
+ }
605
+ }
606
+ }
607
+
608
+ return errors
609
+ }
610
+
611
+ function showConfigWarnings(
612
+ ctx: PluginInput,
613
+ configPath: string,
614
+ configData: Record<string, any>,
615
+ isProject: boolean,
616
+ ): void {
617
+ const invalidKeys = getInvalidConfigKeys(configData)
618
+ const typeErrors = validateConfigTypes(configData)
619
+
620
+ if (invalidKeys.length === 0 && typeErrors.length === 0) {
621
+ return
622
+ }
623
+
624
+ const configType = isProject ? "project config" : "config"
625
+ const messages: string[] = []
626
+
627
+ if (invalidKeys.length > 0) {
628
+ const keyList = invalidKeys.slice(0, 3).join(", ")
629
+ const suffix = invalidKeys.length > 3 ? ` (+${invalidKeys.length - 3} more)` : ""
630
+ messages.push(`Unknown keys: ${keyList}${suffix}`)
631
+ }
632
+
633
+ if (typeErrors.length > 0) {
634
+ for (const err of typeErrors.slice(0, 2)) {
635
+ messages.push(`${err.key}: expected ${err.expected}, got ${err.actual}`)
636
+ }
637
+ if (typeErrors.length > 2) {
638
+ messages.push(`(+${typeErrors.length - 2} more type errors)`)
639
+ }
640
+ }
641
+
642
+ setTimeout(() => {
643
+ try {
644
+ ctx.client.tui.showToast({
645
+ body: {
646
+ title: `DCP: ${configType} warning`,
647
+ message: `${configPath}\n${messages.join("\n")}`,
648
+ variant: "warning",
649
+ duration: 7000,
650
+ },
651
+ })
652
+ } catch {}
653
+ }, 7000)
654
+ }
655
+
656
+ const defaultConfig: PluginConfig = {
657
+ enabled: true,
658
+ autoUpdate: true,
659
+ debug: false,
660
+ pruneNotification: "detailed",
661
+ pruneNotificationType: "chat",
662
+ commands: {
663
+ enabled: true,
664
+ protectedTools: [...DEFAULT_PROTECTED_TOOLS],
665
+ },
666
+ manualMode: {
667
+ enabled: false,
668
+ automaticStrategies: true,
669
+ },
670
+ turnProtection: {
671
+ enabled: false,
672
+ turns: 4,
673
+ },
674
+ experimental: {
675
+ allowSubAgents: false,
676
+ customPrompts: false,
677
+ },
678
+ protectedFilePatterns: [],
679
+ compress: {
680
+ mode: "range",
681
+ permission: "allow",
682
+ showCompression: false,
683
+ summaryBuffer: true,
684
+ maxContextLimit: 100000,
685
+ minContextLimit: 50000,
686
+ nudgeFrequency: 5,
687
+ iterationNudgeThreshold: 15,
688
+ nudgeForce: "soft",
689
+ protectedTools: [...COMPRESS_DEFAULT_PROTECTED_TOOLS],
690
+ protectTags: false,
691
+ protectUserMessages: false,
692
+ },
693
+ strategies: {
694
+ deduplication: {
695
+ enabled: true,
696
+ protectedTools: [],
697
+ },
698
+ purgeErrors: {
699
+ enabled: true,
700
+ turns: 4,
701
+ protectedTools: [],
702
+ },
703
+ },
704
+ }
705
+
706
+ const GLOBAL_CONFIG_DIR = process.env.XDG_CONFIG_HOME
707
+ ? join(process.env.XDG_CONFIG_HOME, "opencode")
708
+ : join(homedir(), ".config", "opencode")
709
+ const GLOBAL_CONFIG_PATH_JSONC = join(GLOBAL_CONFIG_DIR, "dcp.jsonc")
710
+ const GLOBAL_CONFIG_PATH_JSON = join(GLOBAL_CONFIG_DIR, "dcp.json")
711
+
712
+ function findOpencodeDir(startDir: string): string | null {
713
+ let current = startDir
714
+ while (current !== "/") {
715
+ const candidate = join(current, ".opencode")
716
+ if (existsSync(candidate) && statSync(candidate).isDirectory()) {
717
+ return candidate
718
+ }
719
+ const parent = dirname(current)
720
+ if (parent === current) {
721
+ break
722
+ }
723
+ current = parent
724
+ }
725
+ return null
726
+ }
727
+
728
+ function getConfigPaths(ctx?: PluginInput): {
729
+ global: string | null
730
+ configDir: string | null
731
+ project: string | null
732
+ } {
733
+ const global = existsSync(GLOBAL_CONFIG_PATH_JSONC)
734
+ ? GLOBAL_CONFIG_PATH_JSONC
735
+ : existsSync(GLOBAL_CONFIG_PATH_JSON)
736
+ ? GLOBAL_CONFIG_PATH_JSON
737
+ : null
738
+
739
+ let configDir: string | null = null
740
+ const opencodeConfigDir = process.env.OPENCODE_CONFIG_DIR
741
+ if (opencodeConfigDir) {
742
+ const configJsonc = join(opencodeConfigDir, "dcp.jsonc")
743
+ const configJson = join(opencodeConfigDir, "dcp.json")
744
+ configDir = existsSync(configJsonc)
745
+ ? configJsonc
746
+ : existsSync(configJson)
747
+ ? configJson
748
+ : null
749
+ }
750
+
751
+ let project: string | null = null
752
+ if (ctx?.directory) {
753
+ const opencodeDir = findOpencodeDir(ctx.directory)
754
+ if (opencodeDir) {
755
+ const projectJsonc = join(opencodeDir, "dcp.jsonc")
756
+ const projectJson = join(opencodeDir, "dcp.json")
757
+ project = existsSync(projectJsonc)
758
+ ? projectJsonc
759
+ : existsSync(projectJson)
760
+ ? projectJson
761
+ : null
762
+ }
763
+ }
764
+
765
+ return { global, configDir, project }
766
+ }
767
+
768
+ function createDefaultConfig(): void {
769
+ if (!existsSync(GLOBAL_CONFIG_DIR)) {
770
+ mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true })
771
+ }
772
+
773
+ const configContent = `{
774
+ "$schema": "https://raw.githubusercontent.com/Opencode-DCP/opencode-dynamic-context-pruning/master/dcp.schema.json"
775
+ }
776
+ `
777
+ writeFileSync(GLOBAL_CONFIG_PATH_JSONC, configContent, "utf-8")
778
+ }
779
+
780
+ interface ConfigLoadResult {
781
+ data: Record<string, any> | null
782
+ parseError?: string
783
+ }
784
+
785
+ function loadConfigFile(configPath: string): ConfigLoadResult {
786
+ let fileContent = ""
787
+ try {
788
+ fileContent = readFileSync(configPath, "utf-8")
789
+ } catch {
790
+ return { data: null }
791
+ }
792
+
793
+ try {
794
+ const parsed = parse(fileContent, undefined, { allowTrailingComma: true })
795
+ if (parsed === undefined || parsed === null) {
796
+ return { data: null, parseError: "Config file is empty or invalid" }
797
+ }
798
+ return { data: parsed }
799
+ } catch (error: any) {
800
+ return { data: null, parseError: error.message || "Failed to parse config" }
801
+ }
802
+ }
803
+
804
+ function mergeStrategies(
805
+ base: PluginConfig["strategies"],
806
+ override?: Partial<PluginConfig["strategies"]>,
807
+ ): PluginConfig["strategies"] {
808
+ if (!override) {
809
+ return base
810
+ }
811
+
812
+ return {
813
+ deduplication: {
814
+ enabled: override.deduplication?.enabled ?? base.deduplication.enabled,
815
+ protectedTools: [
816
+ ...new Set([
817
+ ...base.deduplication.protectedTools,
818
+ ...(override.deduplication?.protectedTools ?? []),
819
+ ]),
820
+ ],
821
+ },
822
+ purgeErrors: {
823
+ enabled: override.purgeErrors?.enabled ?? base.purgeErrors.enabled,
824
+ turns: override.purgeErrors?.turns ?? base.purgeErrors.turns,
825
+ protectedTools: [
826
+ ...new Set([
827
+ ...base.purgeErrors.protectedTools,
828
+ ...(override.purgeErrors?.protectedTools ?? []),
829
+ ]),
830
+ ],
831
+ },
832
+ }
833
+ }
834
+
835
+ function mergeCompress(
836
+ base: PluginConfig["compress"],
837
+ override?: CompressOverride,
838
+ ): PluginConfig["compress"] {
839
+ if (!override) {
840
+ return base
841
+ }
842
+
843
+ return {
844
+ mode: override.mode ?? base.mode,
845
+ permission: override.permission ?? base.permission,
846
+ showCompression: override.showCompression ?? base.showCompression,
847
+ summaryBuffer: override.summaryBuffer ?? base.summaryBuffer,
848
+ maxContextLimit: override.maxContextLimit ?? base.maxContextLimit,
849
+ minContextLimit: override.minContextLimit ?? base.minContextLimit,
850
+ modelMaxLimits: override.modelMaxLimits ?? base.modelMaxLimits,
851
+ modelMinLimits: override.modelMinLimits ?? base.modelMinLimits,
852
+ nudgeFrequency: override.nudgeFrequency ?? base.nudgeFrequency,
853
+ iterationNudgeThreshold: override.iterationNudgeThreshold ?? base.iterationNudgeThreshold,
854
+ nudgeForce: override.nudgeForce ?? base.nudgeForce,
855
+ protectedTools: [...new Set([...base.protectedTools, ...(override.protectedTools ?? [])])],
856
+ protectTags: override.protectTags ?? base.protectTags,
857
+ protectUserMessages: override.protectUserMessages ?? base.protectUserMessages,
858
+ }
859
+ }
860
+
861
+ function mergeCommands(
862
+ base: PluginConfig["commands"],
863
+ override?: Partial<PluginConfig["commands"]>,
864
+ ): PluginConfig["commands"] {
865
+ if (!override) {
866
+ return base
867
+ }
868
+
869
+ return {
870
+ enabled: override.enabled ?? base.enabled,
871
+ protectedTools: [...new Set([...base.protectedTools, ...(override.protectedTools ?? [])])],
872
+ }
873
+ }
874
+
875
+ function mergeManualMode(
876
+ base: PluginConfig["manualMode"],
877
+ override?: Partial<PluginConfig["manualMode"]>,
878
+ ): PluginConfig["manualMode"] {
879
+ if (override === undefined) return base
880
+
881
+ return {
882
+ enabled: override.enabled ?? base.enabled,
883
+ automaticStrategies: override.automaticStrategies ?? base.automaticStrategies,
884
+ }
885
+ }
886
+
887
+ function mergeExperimental(
888
+ base: PluginConfig["experimental"],
889
+ override?: Partial<PluginConfig["experimental"]>,
890
+ ): PluginConfig["experimental"] {
891
+ if (override === undefined) return base
892
+
893
+ return {
894
+ allowSubAgents: override.allowSubAgents ?? base.allowSubAgents,
895
+ customPrompts: override.customPrompts ?? base.customPrompts,
896
+ }
897
+ }
898
+
899
+ function deepCloneConfig(config: PluginConfig): PluginConfig {
900
+ return {
901
+ ...config,
902
+ commands: {
903
+ enabled: config.commands.enabled,
904
+ protectedTools: [...config.commands.protectedTools],
905
+ },
906
+ manualMode: {
907
+ enabled: config.manualMode.enabled,
908
+ automaticStrategies: config.manualMode.automaticStrategies,
909
+ },
910
+ turnProtection: { ...config.turnProtection },
911
+ experimental: { ...config.experimental },
912
+ protectedFilePatterns: [...config.protectedFilePatterns],
913
+ compress: {
914
+ ...config.compress,
915
+ modelMaxLimits: { ...config.compress.modelMaxLimits },
916
+ modelMinLimits: { ...config.compress.modelMinLimits },
917
+ protectedTools: [...config.compress.protectedTools],
918
+ },
919
+ strategies: {
920
+ deduplication: {
921
+ ...config.strategies.deduplication,
922
+ protectedTools: [...config.strategies.deduplication.protectedTools],
923
+ },
924
+ purgeErrors: {
925
+ ...config.strategies.purgeErrors,
926
+ protectedTools: [...config.strategies.purgeErrors.protectedTools],
927
+ },
928
+ },
929
+ }
930
+ }
931
+
932
+ function mergeLayer(config: PluginConfig, data: Record<string, any>): PluginConfig {
933
+ return {
934
+ enabled: data.enabled ?? config.enabled,
935
+ autoUpdate: data.autoUpdate ?? config.autoUpdate,
936
+ debug: data.debug ?? config.debug,
937
+ pruneNotification: data.pruneNotification ?? config.pruneNotification,
938
+ pruneNotificationType: data.pruneNotificationType ?? config.pruneNotificationType,
939
+ commands: mergeCommands(config.commands, data.commands as any),
940
+ manualMode: mergeManualMode(config.manualMode, data.manualMode as any),
941
+ turnProtection: {
942
+ enabled: data.turnProtection?.enabled ?? config.turnProtection.enabled,
943
+ turns: data.turnProtection?.turns ?? config.turnProtection.turns,
944
+ },
945
+ experimental: mergeExperimental(config.experimental, data.experimental as any),
946
+ protectedFilePatterns: [
947
+ ...new Set([...config.protectedFilePatterns, ...(data.protectedFilePatterns ?? [])]),
948
+ ],
949
+ compress: mergeCompress(config.compress, data.compress as CompressOverride),
950
+ strategies: mergeStrategies(config.strategies, data.strategies as any),
951
+ }
952
+ }
953
+
954
+ function scheduleParseWarning(ctx: PluginInput, title: string, message: string): void {
955
+ setTimeout(() => {
956
+ try {
957
+ ctx.client.tui.showToast({
958
+ body: {
959
+ title,
960
+ message,
961
+ variant: "warning",
962
+ duration: 7000,
963
+ },
964
+ })
965
+ } catch {}
966
+ }, 7000)
967
+ }
968
+
969
+ export function getConfig(ctx: PluginInput): PluginConfig {
970
+ let config = deepCloneConfig(defaultConfig)
971
+ const configPaths = getConfigPaths(ctx)
972
+
973
+ if (!configPaths.global) {
974
+ createDefaultConfig()
975
+ }
976
+
977
+ const layers: Array<{ path: string | null; name: string; isProject: boolean }> = [
978
+ { path: configPaths.global, name: "config", isProject: false },
979
+ { path: configPaths.configDir, name: "configDir config", isProject: true },
980
+ { path: configPaths.project, name: "project config", isProject: true },
981
+ ]
982
+
983
+ for (const layer of layers) {
984
+ if (!layer.path) {
985
+ continue
986
+ }
987
+
988
+ const result = loadConfigFile(layer.path)
989
+ if (result.parseError) {
990
+ scheduleParseWarning(
991
+ ctx,
992
+ `DCP: Invalid ${layer.name}`,
993
+ `${layer.path}\n${result.parseError}\nUsing previous/default values`,
994
+ )
995
+ continue
996
+ }
997
+
998
+ if (!result.data) {
999
+ continue
1000
+ }
1001
+
1002
+ showConfigWarnings(ctx, layer.path, result.data, layer.isProject)
1003
+ config = mergeLayer(config, result.data)
1004
+ }
1005
+
1006
+ return config
1007
+ }