Skip to content

Commit fb38d44

Browse files
authored
Merge pull request #514 from Opencode-DCP/dev
merge dev into master
2 parents 9860112 + e00bd5b commit fb38d44

10 files changed

Lines changed: 312 additions & 4 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ Each level overrides the previous, so project settings take priority over global
6464
"$schema": "https://raw.githubusercontent.com/Opencode-DCP/opencode-dynamic-context-pruning/master/dcp.schema.json",
6565
// Enable or disable the plugin
6666
"enabled": true,
67+
// Automatically update npm-installed DCP when a newer npm latest is available.
68+
// Version-locked plugin specs are not updated.
69+
"autoUpdate": true,
6770
// Enable debug logging to ~/.config/opencode/logs/dcp/
6871
"debug": false,
6972
// Notification display: "off", "minimal", or "detailed"

dcp.schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
"default": true,
1616
"description": "Enable or disable the DCP plugin"
1717
},
18+
"autoUpdate": {
19+
"type": "boolean",
20+
"default": true,
21+
"description": "Automatically update npm-installed DCP when a newer npm latest version is available. Version-locked plugin specs are not updated."
22+
},
1823
"debug": {
1924
"type": "boolean",
2025
"default": false,

index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@ import {
1717
createTextCompleteHandler,
1818
} from "./lib/hooks"
1919
import { configureClientAuth, isSecureMode } from "./lib/auth"
20-
21-
const id = "opencode-dynamic-context-pruning"
20+
import { startAutoUpdate } from "./lib/update"
2221

2322
const server: Plugin = (async (ctx) => {
2423
const config = getConfig(ctx)
@@ -44,6 +43,8 @@ const server: Plugin = (async (ctx) => {
4443
strategies: config.strategies,
4544
})
4645

46+
startAutoUpdate(ctx, config.autoUpdate)
47+
4748
const compressToolContext = {
4849
client: ctx.client,
4950
state,

lib/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export interface ExperimentalConfig {
5656

5757
export interface PluginConfig {
5858
enabled: boolean
59+
autoUpdate: boolean
5960
debug: boolean
6061
pruneNotification: "off" | "minimal" | "detailed"
6162
pruneNotificationType: "chat" | "toast"
@@ -91,6 +92,7 @@ const COMPRESS_DEFAULT_PROTECTED_TOOLS = ["task", "skill", "todowrite", "todorea
9192
export const VALID_CONFIG_KEYS = new Set([
9293
"$schema",
9394
"enabled",
95+
"autoUpdate",
9496
"debug",
9597
"showUpdateToasts",
9698
"pruneNotification",
@@ -168,6 +170,10 @@ export function validateConfigTypes(config: Record<string, any>): ValidationErro
168170
errors.push({ key: "enabled", expected: "boolean", actual: typeof config.enabled })
169171
}
170172

173+
if (config.autoUpdate !== undefined && typeof config.autoUpdate !== "boolean") {
174+
errors.push({ key: "autoUpdate", expected: "boolean", actual: typeof config.autoUpdate })
175+
}
176+
171177
if (config.debug !== undefined && typeof config.debug !== "boolean") {
172178
errors.push({ key: "debug", expected: "boolean", actual: typeof config.debug })
173179
}
@@ -639,6 +645,7 @@ function showConfigWarnings(
639645

640646
const defaultConfig: PluginConfig = {
641647
enabled: true,
648+
autoUpdate: true,
642649
debug: false,
643650
pruneNotification: "detailed",
644651
pruneNotificationType: "chat",
@@ -913,6 +920,7 @@ function deepCloneConfig(config: PluginConfig): PluginConfig {
913920
function mergeLayer(config: PluginConfig, data: Record<string, any>): PluginConfig {
914921
return {
915922
enabled: data.enabled ?? config.enabled,
923+
autoUpdate: data.autoUpdate ?? config.autoUpdate,
916924
debug: data.debug ?? config.debug,
917925
pruneNotification: data.pruneNotification ?? config.pruneNotification,
918926
pruneNotificationType: data.pruneNotificationType ?? config.pruneNotificationType,

lib/hooks.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
stripHallucinationsFromString,
1414
stripStaleMetadata,
1515
syncCompressionBlocks,
16+
computeInputBudget,
1617
} from "./messages"
1718
import { renderSystemPrompt, type PromptStore } from "./prompts"
1819
import { buildProtectedToolsExtension } from "./prompts/extensions/system"
@@ -42,6 +43,7 @@ import { cacheSystemPromptTokens } from "./ui/utils"
4243
const INTERNAL_AGENT_SIGNATURES = [
4344
"You are a title generator",
4445
"You are a helpful AI assistant tasked with summarizing conversations",
46+
"You are an anchored context summarization assistant for coding sessions",
4547
"Summarize what was done in this conversation",
4648
]
4749

@@ -52,11 +54,17 @@ export function createSystemPromptHandler(
5254
prompts: PromptStore,
5355
) {
5456
return async (
55-
input: { sessionID?: string; model: { limit: { context: number } } },
57+
input: {
58+
sessionID?: string
59+
model: { limit: { context: number; input?: number; output?: number } }
60+
},
5661
output: { system: string[] },
5762
) => {
5863
if (input.model?.limit?.context) {
59-
state.modelContextLimit = input.model.limit.context
64+
const inputBudget = computeInputBudget(input.model.limit)
65+
if (inputBudget !== undefined) {
66+
state.modelContextLimit = inputBudget
67+
}
6068
logger.debug("Cached model context limit", { limit: state.modelContextLimit })
6169
}
6270

lib/messages/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { prune } from "./prune"
22
export { syncCompressionBlocks } from "./sync"
33
export { injectCompressNudges } from "./inject/inject"
4+
export { computeInputBudget } from "./inject/utils"
45
export { injectMessageIds } from "./inject/inject"
56
export { injectExtendedSubAgentResults } from "./inject/subagent-results"
67
export { stripStaleMetadata } from "./reasoning-strip"

lib/messages/inject/utils.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,20 @@ export interface LastNonIgnoredMessage {
3434
index: number
3535
}
3636

37+
interface ModelLimit {
38+
context: number
39+
input?: number
40+
output?: number
41+
}
42+
43+
export function computeInputBudget(limit: ModelLimit): number | undefined {
44+
if (!limit.context) {
45+
return undefined
46+
}
47+
48+
return limit.input ?? Math.max(0, limit.context - (limit.output ?? 0))
49+
}
50+
3751
export function getNudgeFrequency(config: PluginConfig): number {
3852
return Math.max(1, Math.floor(config.compress.nudgeFrequency || 1))
3953
}

lib/update.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { readFile, rm } from "node:fs/promises"
2+
import { basename, dirname, join } from "node:path"
3+
import { fileURLToPath } from "node:url"
4+
import type { PluginInput } from "@opencode-ai/plugin"
5+
6+
type PackageJson = {
7+
name?: string
8+
version?: string
9+
dependencies?: Record<string, string>
10+
}
11+
12+
type UpdateResult =
13+
| { updated: true; name: string; current: string; latest: string }
14+
| { updated: false; error: "remove_failed"; name: string; current: string; latest: string }
15+
| { updated: false }
16+
17+
const PACKAGE_NAME = "@tarquinen/opencode-dcp"
18+
19+
export function startAutoUpdate(ctx: PluginInput, enabled: boolean): void {
20+
if (!enabled) return
21+
22+
const controller = new AbortController()
23+
const timeout = setTimeout(() => controller.abort(), 10_000)
24+
void checkAutoUpdate(controller.signal)
25+
.then((result) => {
26+
if (!result.updated) return
27+
setTimeout(() => {
28+
ctx.client.tui.showToast({
29+
body: {
30+
title: "DCP update ready",
31+
message: `Updated ${result.name} from ${result.current} to ${result.latest}. Restart OpenCode to finish.`,
32+
variant: "info",
33+
duration: 7000,
34+
},
35+
})
36+
}, 5000)
37+
})
38+
.catch(() => {})
39+
.finally(() => clearTimeout(timeout))
40+
}
41+
42+
export async function checkAutoUpdate(signal: AbortSignal): Promise<UpdateResult> {
43+
const packageDir = await findPackageDir(PACKAGE_NAME)
44+
if (!packageDir) return { updated: false }
45+
46+
const pkg = await readPackageJson(join(packageDir, "package.json"))
47+
if (!pkg?.name || !pkg.version) return { updated: false }
48+
49+
const latest = await fetchLatestVersion(pkg.name, signal)
50+
if (!latest || !isVersionNewer(latest, pkg.version)) return { updated: false }
51+
52+
const removeDir = await updateRemoveDir(packageDir, pkg.name)
53+
if (!removeDir) return { updated: false }
54+
55+
try {
56+
await rm(removeDir, { recursive: true, force: true })
57+
} catch {
58+
return {
59+
updated: false,
60+
error: "remove_failed",
61+
name: pkg.name,
62+
current: pkg.version,
63+
latest,
64+
}
65+
}
66+
67+
return { updated: true, name: pkg.name, current: pkg.version, latest }
68+
}
69+
70+
async function findPackageDir(name: string) {
71+
let dir = dirname(fileURLToPath(import.meta.url))
72+
for (;;) {
73+
const pkg = await readPackageJson(join(dir, "package.json"))
74+
if (pkg?.name === name) return dir
75+
76+
const parent = dirname(dir)
77+
if (parent === dir) return undefined
78+
dir = parent
79+
}
80+
}
81+
82+
export async function updateRemoveDir(packageDir: string, name: string) {
83+
const packageParent = dirname(packageDir)
84+
const nodeModulesDir = basename(packageParent).startsWith("@")
85+
? dirname(packageParent)
86+
: packageParent
87+
if (basename(nodeModulesDir) !== "node_modules") return undefined
88+
89+
const wrapperDir = dirname(nodeModulesDir)
90+
const wrapperPkg = await readPackageJson(join(wrapperDir, "package.json"))
91+
const spec = wrapperPkg?.dependencies?.[name]
92+
if (!spec || !isAutoUpdatableSpec(spec)) return undefined
93+
94+
return wrapperDir
95+
}
96+
97+
export function isAutoUpdatableSpec(spec: string) {
98+
const value = spec.trim()
99+
if (!value) return false
100+
if (value === "latest" || value === "*") return true
101+
if (/^[~^]/.test(value)) return true
102+
if (/^(?:>=|>|<=|<)/.test(value)) return true
103+
if (/\s+(?:\|\||-|[<>=])\s+/.test(value)) return true
104+
return false
105+
}
106+
107+
async function readPackageJson(path: string): Promise<PackageJson | undefined> {
108+
try {
109+
const data = JSON.parse(await readFile(path, "utf-8"))
110+
return data && typeof data === "object" ? (data as PackageJson) : undefined
111+
} catch {
112+
return undefined
113+
}
114+
}
115+
116+
async function fetchLatestVersion(name: string, signal: AbortSignal) {
117+
try {
118+
const response = await fetch(
119+
`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`,
120+
{
121+
signal,
122+
},
123+
)
124+
if (!response.ok) return undefined
125+
const data: unknown = await response.json()
126+
if (!data || typeof data !== "object") return undefined
127+
const version = (data as { version?: unknown }).version
128+
return typeof version === "string" ? version : undefined
129+
} catch {
130+
return undefined
131+
}
132+
}
133+
134+
export function isVersionNewer(latest: string, current: string) {
135+
const next = parseVersion(latest)
136+
const prev = parseVersion(current)
137+
if (!next || !prev) return false
138+
139+
for (let i = 0; i < 3; i++) {
140+
if (next.parts[i] !== prev.parts[i]) return next.parts[i] > prev.parts[i]
141+
}
142+
143+
if (!next.pre.length && prev.pre.length) return true
144+
if (next.pre.length && !prev.pre.length) return false
145+
146+
for (let i = 0; i < Math.max(next.pre.length, prev.pre.length); i++) {
147+
const a = next.pre[i]
148+
const b = prev.pre[i]
149+
if (a === undefined) return false
150+
if (b === undefined) return true
151+
if (a === b) continue
152+
153+
const aNumber = /^\d+$/.test(a) ? Number(a) : undefined
154+
const bNumber = /^\d+$/.test(b) ? Number(b) : undefined
155+
if (aNumber !== undefined && bNumber !== undefined) return aNumber > bNumber
156+
if (aNumber !== undefined) return false
157+
if (bNumber !== undefined) return true
158+
return a > b
159+
}
160+
161+
return false
162+
}
163+
164+
function parseVersion(version: string) {
165+
const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.+)?$/)
166+
if (!match) return undefined
167+
return {
168+
parts: [Number(match[1]), Number(match[2]), Number(match[3])],
169+
pre: match[4]?.split(".") ?? [],
170+
}
171+
}

tests/input-budget.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import assert from "node:assert/strict"
2+
import test from "node:test"
3+
import { computeInputBudget } from "../lib/messages/inject/utils"
4+
5+
test("computeInputBudget uses limit.input when defined (split-budget OpenAI models)", () => {
6+
// gpt-5.4-mini, gpt-5.5: 400K context, 272K input, 128K output
7+
assert.equal(computeInputBudget({ context: 400000, input: 272000, output: 128000 }), 272000)
8+
// gpt-5.4: 1.05M context, 922K input, 128K output
9+
assert.equal(computeInputBudget({ context: 1050000, input: 922000, output: 128000 }), 922000)
10+
})
11+
12+
test("computeInputBudget subtracts output from context when limit.input is undefined (shared-pool models)", () => {
13+
// claude-opus-4-7: 1M context, 128K output, no explicit input limit
14+
assert.equal(computeInputBudget({ context: 1000000, output: 128000 }), 872000)
15+
// claude-haiku-4-5: 200K context, 64K output
16+
assert.equal(computeInputBudget({ context: 200000, output: 64000 }), 136000)
17+
// gpt-4o: 128K context, 16384 output
18+
assert.equal(computeInputBudget({ context: 128000, output: 16384 }), 111616)
19+
})
20+
21+
test("computeInputBudget treats missing output as 0", () => {
22+
assert.equal(computeInputBudget({ context: 200000 }), 200000)
23+
})
24+
25+
test("computeInputBudget returns undefined when context is unknown", () => {
26+
assert.equal(computeInputBudget({ context: 0, input: 100, output: 50 }), undefined)
27+
})
28+
29+
test("computeInputBudget never returns negative when output exceeds context", () => {
30+
assert.equal(computeInputBudget({ context: 100, output: 200 }), 0)
31+
})
32+
33+
test("computeInputBudget prefers explicit input over the context-minus-output fallback", () => {
34+
// If both `input` and `output` are present, `input` wins regardless of what
35+
// `context - output` would compute to. Defensive against models where the
36+
// numbers don't satisfy `input + output = context`.
37+
assert.equal(computeInputBudget({ context: 1000, input: 500, output: 200 }), 500)
38+
})

0 commit comments

Comments
 (0)