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