From 373120a4b06bd78b415b320433e24bba69c9e95e Mon Sep 17 00:00:00 2001 From: Madi <29486218+jadmadi@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:42:35 +0300 Subject: [PATCH] fix: apply the context budget through the model transform OpenCode 2.0.7 removed the catalog domain. Register the budget through ctx.model.transform, reload with ctx.model.reload(), and read windows through ctx.model.list(). The tests, spec, README, and API notes follow the same move. --- AGENTS.md | 16 ++++---- README.md | 4 +- context-limit.test.ts | 62 ++++++++++++------------------ context-limit.ts | 34 ++++++++-------- docs/compose/spec/context-limit.md | 18 ++++----- package.json | 2 +- 6 files changed, 60 insertions(+), 76 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f33bdfb..f499554 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ Guidance for agents working in this repository. ## What this is An OpenCode V2 plugin (`context-limit.ts`) that sets a per-model working context -budget by lowering the model's `limit.context` through a catalog transform. No +budget by lowering the model's `limit.context` through a model transform. No build step, no dependencies, MIT. ## Local development @@ -24,9 +24,9 @@ grep context-limit ~/.local/share/opencode/log/opencode.log | tail ## Spike result (T0) -A catalog transform can lower a model's context window at runtime. A probe set +A model transform can lower a model's context window at runtime. A probe set `deepseek/deepseek-flash` from 1,000,000 to 123,456 through -`ctx.catalog.transform`, and a re-read showed 123,456. Compaction's default +`ctx.model.transform`, and a re-read showed 123,456. Compaction's default threshold follows the model's usable input budget, so this is the mechanism. No config edit is needed. @@ -40,9 +40,9 @@ config edit is needed. ## API notes -- `ctx.catalog.transform((catalog) => catalog.model.update(providerID, modelID, (model) => { model.limit = { ...model.limit, context: n } }))` - lowers a window. Call `ctx.catalog.reload()` after changing the rules. -- Model entries from `ctx.catalog.model.list()` carry `providerID`, `id`, and +- `ctx.model.transform((editor) => editor.update(providerID, modelID, (model) => { model.limit = { ...model.limit, context: n } }))` + lowers a window. Call `ctx.model.reload()` after changing the rules. +- Model entries from `ctx.model.list()` carry `providerID`, `id`, and `limit.context`. - Rules live in `ctx.storage` under `context-limit`. @@ -50,9 +50,9 @@ config edit is needed. - `parseBudget` - parses tokens, `128K`, `1M`, and `50%`, with clamping. - `matchPattern`, `longestMatch`, `resolveBudget` - rule matching. -- `applyBudget` - the catalog transform body, exported for tests. +- `applyBudget` - the model transform body, exported for tests. - `setup` - registers the command and the transform. -- `context-limit.test.ts` - tests with a fake catalog and ctx. +- `context-limit.test.ts` - tests with a fake model editor and ctx. ## Releasing diff --git a/README.md b/README.md index 5881c0f..de10f88 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,9 @@ matches everything. The longest matching pattern wins. ## How it works -The plugin registers a catalog transform that lowers the matched models' +The plugin registers a model transform that lowers the matched models' `limit.context`. Compaction's default threshold follows the model's usable input -budget, so compaction fires earlier. A change calls `ctx.catalog.reload()` and +budget, so compaction fires earlier. A change calls `ctx.model.reload()` and applies at once. Nothing is written to `opencode.json`. ## Tests diff --git a/context-limit.test.ts b/context-limit.test.ts index 16b6868..5d3c732 100644 --- a/context-limit.test.ts +++ b/context-limit.test.ts @@ -1,28 +1,21 @@ import { describe, expect, test } from "bun:test" import plugin, { applyBudget, longestMatch, matchPattern, parseBudget, resolveBudget, VERSION } from "./context-limit.ts" -function makeCatalog(models: Array<{ providerID: string; id: string; context: number }>) { +function makeEditor(models: Array<{ providerID: string; id: string; context: number }>) { const entries = models.map((model) => ({ providerID: model.providerID, id: model.id, limit: { context: model.context, output: 1000 }, })) - const providers = new Map }>() - for (const entry of entries) { - if (!providers.has(entry.providerID)) providers.set(entry.providerID, { providerID: entry.providerID, models: new Map() }) - providers.get(entry.providerID)!.models.set(entry.id, entry) - } - const catalog: any = { - provider: { list: () => [...providers.values()] }, - model: { - update: (providerID: string, modelID: string, change: (model: any) => void) => { - const model = providers.get(providerID)?.models.get(modelID) - if (model) change(model) - }, + const editor: any = { + list: () => entries, + update: (providerID: string, modelID: string, change: (model: any) => void) => { + const model = entries.find((entry) => entry.providerID === providerID && entry.id === modelID) + if (model) change(model) }, entries, } - return catalog + return editor } function makeCtx(options: { model?: any; models?: any[] } = {}) { @@ -36,22 +29,15 @@ function makeCtx(options: { model?: any; models?: any[] } = {}) { ] ).map((model) => structuredClone(model)) - // Rebuild the catalog from the registered transforms on every read, the way - // the runtime replays transforms onto a fresh value. + // Rebuild the model list from the registered transforms on every read, the + // way the runtime replays transforms onto a fresh value. const rebuild = () => { const entries = structuredClone(baseModels) - const providers = new Map }>() - for (const entry of entries) { - if (!providers.has(entry.providerID)) providers.set(entry.providerID, { providerID: entry.providerID, models: new Map() }) - providers.get(entry.providerID)!.models.set(entry.id, entry) - } const editor = { - provider: { list: () => [...providers.values()] }, - model: { - update: (providerID: string, modelID: string, change: (model: any) => void) => { - const model = providers.get(providerID)?.models.get(modelID) - if (model) change(model) - }, + list: () => entries, + update: (providerID: string, modelID: string, change: (model: any) => void) => { + const model = entries.find((entry) => entry.providerID === providerID && entry.id === modelID) + if (model) change(model) }, } for (const transform of transforms) transform(editor) @@ -66,10 +52,10 @@ function makeCtx(options: { model?: any; models?: any[] } = {}) { session: { get: async () => ({ model: options.model ?? { providerID: "opencode-go", id: "deepseek-v4.1-flash" } }), }, - catalog: { + model: { transform: async (callback: any) => void transforms.push(callback), reload: async () => void (reloads += 1), - model: { list: async () => ({ data: rebuild() }) }, + list: async () => ({ data: rebuild() }), }, command: { transform: (callback: any) => callback({ add: (definition: any) => commands.push(definition) }) }, } @@ -128,20 +114,20 @@ describe("longestMatch and resolveBudget", () => { describe("applyBudget", () => { test("lowers only matched models", () => { - const catalog = makeCatalog([ + const editor = makeEditor([ { providerID: "opencode-go", id: "x", context: 1_000_000 }, { providerID: "deepseek", id: "y", context: 1_000_000 }, ]) - applyBudget(catalog, [{ pattern: "opencode-go/*", value: 128_000, unit: "tokens" }]) - const byKey = Object.fromEntries(catalog.entries.map((entry: any) => [`${entry.providerID}/${entry.id}`, entry.limit.context])) + applyBudget(editor, [{ pattern: "opencode-go/*", value: 128_000, unit: "tokens" }]) + const byKey = Object.fromEntries(editor.entries.map((entry: any) => [`${entry.providerID}/${entry.id}`, entry.limit.context])) expect(byKey["opencode-go/x"]).toBe(128_000) expect(byKey["deepseek/y"]).toBe(1_000_000) }) test("does nothing without rules", () => { - const catalog = makeCatalog([{ providerID: "a", id: "b", context: 1000 }]) - applyBudget(catalog, []) - expect(catalog.entries[0].limit.context).toBe(1000) + const editor = makeEditor([{ providerID: "a", id: "b", context: 1000 }]) + applyBudget(editor, []) + expect(editor.entries[0].limit.context).toBe(1000) }) }) @@ -208,9 +194,9 @@ describe("setup", () => { expect(commands.map((entry) => entry.name)).toEqual(["context-limit"]) expect(transforms).toHaveLength(1) - const catalog = makeCatalog([{ providerID: "opencode-go", id: "x", context: 1_000_000 }]) - transforms[0](catalog) - expect(catalog.entries[0].limit.context).toBe(128_000) + const editor = makeEditor([{ providerID: "opencode-go", id: "x", context: 1_000_000 }]) + transforms[0](editor) + expect(editor.entries[0].limit.context).toBe(128_000) }) }) diff --git a/context-limit.ts b/context-limit.ts index 982dce5..4fa6dbe 100644 --- a/context-limit.ts +++ b/context-limit.ts @@ -1,14 +1,14 @@ // OpenCode V2 context-limit plugin. // // Sets a working context budget per model by lowering the model's -// `limit.context` through a catalog transform. Compaction's default threshold +// `limit.context` through a model transform. Compaction's default threshold // follows the model's usable input budget, so a lower window makes compaction // fire earlier. A budget can only lower a window, never raise it. // // The runtime does not resolve @opencode/plugin, so this file exports a plain // { id, setup } object. -const VERSION = "0.1.2" +const VERSION = "0.1.3" type Unit = "tokens" | "percent" @@ -60,19 +60,17 @@ function resolveBudget(rules: Rule[], key: string, catalogContext: number): numb return rule ? budgetFor(rule, catalogContext) : undefined } -// The catalog transform body. `catalog` is a catalog editor: it exposes -// provider.list() and model.update(providerID, modelID, change). -function applyBudget(catalog: any, rules: Rule[]): void { +// The model transform body. `editor` is a model editor: it exposes +// list() and update(providerID, modelID, change). +function applyBudget(editor: any, rules: Rule[]): void { if (rules.length === 0) return - for (const record of catalog.provider.list()) { - for (const model of record.models.values()) { - const key = `${model.providerID}/${model.id}` - const budget = resolveBudget(rules, key, model.limit.context) - if (budget === undefined) continue - catalog.model.update(model.providerID, model.id, (entry: any) => { - entry.limit = { ...entry.limit, context: budget } - }) - } + for (const model of editor.list()) { + const key = `${model.providerID}/${model.id}` + const budget = resolveBudget(rules, key, model.limit.context) + if (budget === undefined) continue + editor.update(model.providerID, model.id, (entry: any) => { + entry.limit = { ...entry.limit, context: budget } + }) } } @@ -86,7 +84,7 @@ async function saveRules(ctx: any, rules: Rule[]): Promise { } async function modelContext(ctx: any, key: string): Promise { - const result = await ctx.catalog.model.list() + const result = await ctx.model.list() const data: any[] = Array.isArray(result) ? result : (result?.data ?? []) const found = data.find((model) => `${model.providerID}/${model.id}` === key) return found?.limit?.context @@ -104,7 +102,7 @@ const plugin = { async setup(ctx: any) { let rules = await loadRules(ctx) - await ctx.catalog.transform((catalog: any) => applyBudget(catalog, rules)) + await ctx.model.transform((editor: any) => applyBudget(editor, rules)) await ctx.command.transform((editor: any) => { editor.add({ @@ -163,7 +161,7 @@ const plugin = { if (clears) { rules = rules.filter((rule) => rule.pattern !== pattern) await saveRules(ctx, rules) - await ctx.catalog.reload() + await ctx.model.reload() return } if (!parsed || (parsed.unit === "tokens" && parsed.value <= 0)) { @@ -172,7 +170,7 @@ const plugin = { rules = rules.filter((rule) => rule.pattern !== pattern) rules.push({ pattern, value: parsed.value, unit: parsed.unit }) await saveRules(ctx, rules) - await ctx.catalog.reload() + await ctx.model.reload() }, }) }) diff --git a/docs/compose/spec/context-limit.md b/docs/compose/spec/context-limit.md index a661b48..dc8da2c 100644 --- a/docs/compose/spec/context-limit.md +++ b/docs/compose/spec/context-limit.md @@ -10,11 +10,11 @@ commits: 5e865ff..4647039 ## Report -**T0 spike result** - The mechanism is a catalog transform: +**T0 spike result** - The mechanism is a model transform: ```ts -ctx.catalog.transform((catalog) => - catalog.model.update(providerID, modelID, (model) => { +ctx.model.transform((editor) => + editor.update(providerID, modelID, (model) => { model.limit = { ...model.limit, context: n } }), ) @@ -27,9 +27,9 @@ usable input budget, so this is the mechanism, and no config edit is needed. **What was built** - A single-file OpenCode V2 plugin that sets a working context budget per model. `/context-limit` shows the budget, `/context-limit 128K` or `50%` sets it for the current model, `/context-limit -` sets a rule, and `0` clears. Rules live in storage. A catalog transform +` sets a rule, and `0` clears. Rules live in storage. A model transform lowers the matched models' `limit.context`, and a change calls -`ctx.catalog.reload()`. Budgets clamp to the catalog window and never raise it. +`ctx.model.reload()`. Budgets clamp to the catalog window and never raise it. **Verification** - `bun test`: 14 pass, 0 fail, 42 assertions. Live: `128K` lowered the effective window to 128000; `50%` reported window 500000 with @@ -38,14 +38,14 @@ blocking items plus a medium and lows; all are resolved. **Journey log** -1. The spike proved the mechanism: a catalog transform lowered +1. The spike proved the mechanism: a model transform lowered `deepseek/deepseek-flash` from 1,000,000 to 123,456, so no config edit was needed. 2. The show path re-resolved a percent rule against the already-lowered window, so a 50% rule printed Budget 250000. It now reports the stored rule, and a token rule above the window prints the clamped number. -3. The show test used a fixed catalog, which hid that bug. The fake now rebuilds - the catalog from the registered transforms, the way the runtime replays them. +3. The show test used a fixed model list, which hid that bug. The fake now rebuilds + the list from the registered transforms, the way the runtime replays them. ## [S1] Problem @@ -68,7 +68,7 @@ compaction. such as `opencode-go/*` or `*`, and the longest matching pattern wins. The map lives in `opencode.json` under `compaction.max_context` when the config supports it, otherwise in plugin storage. -- A spike task decides where the budget can take effect: a catalog transform on +- A spike task decides where the budget can take effect: a model transform on the model limit, or the config compaction threshold. The chosen path is recorded in the spec before the command is built. diff --git a/package.json b/package.json index 1ce1689..0c1f5f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-context-limit", - "version": "0.1.2", + "version": "0.1.3", "description": "OpenCode V2 plugin that sets a per-model working context budget", "type": "module", "license": "MIT",