openrecall 0.2.2 → 0.4.0

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