From a096418c09f87a7b1f4d47cbfdab8399e65769b3 Mon Sep 17 00:00:00 2001 From: "Ryan V. Susman" Date: Sat, 3 Oct 2026 20:53:28 -0400 Subject: [PATCH 1/2] Enforce configurable rolling image window on session requests --- README.md | 32 ++++++ docs/architecture.md | 14 +++ docs/validation.md | 23 +++++ scripts/host-canary-v2.mjs | 72 +++++++++++-- src/commands.ts | 2 +- src/image-window.ts | 100 ++++++++++++++++++ src/install.ts | 22 +++- src/server.ts | 31 ++++-- test/adapters.test.mjs | 29 +++++- test/commands.test.mjs | 27 +++++ test/image-window.test.mjs | 201 +++++++++++++++++++++++++++++++++++++ 11 files changed, 532 insertions(+), 21 deletions(-) create mode 100644 src/image-window.ts create mode 100644 test/image-window.test.mjs diff --git a/README.md b/README.md index 5c51a16..f4ba1b3 100644 --- a/README.md +++ b/README.md @@ -226,3 +226,35 @@ The [20 sample goals](examples/) include 12 runnable coding exercises and eight `src/contract.ts` defines parsing and locking; `checks.ts` runs verifiers; `engine.ts` owns state transitions; `coordinator.ts` owns continuation admission. `server.ts` integrates the OpenCode 2 API. `tui.ts` renders read-only status. `rpc.ts` is the native sidebar's portable status contract. See [research](docs/research.md), [architecture](docs/architecture.md), [contract design](docs/contracts.md), and [contributing](CONTRIBUTING.md). MIT licensed. + +## Rolling image context window + +Master Goal limits raw images sent with each model request. The default is **1**, so screenshot-heavy sessions do not accumulate every previous screenshot in the provider request. The newest images across the whole outgoing conversation survive, including screenshots returned by tools. Text, code, reasoning, other files and tool-call/result pairs remain. + +Use OpenCode 2.0.22's native plugin options in `opencode.json` or `opencode.jsonc`. Replace the existing Master Goal entry; do not add a second registration. Keep its existing `package` path (the installer normally uses a `file:///.../.mastergoal-runtime/node_modules/@darkmatter2222/opencode-mastergoal` directory URL): + +```json +{ + "plugins": [ + { + "package": "@darkmatter2222/opencode-mastergoal", + "options": { "imageWindow": 1 } + } + ] +} +``` + +| Value | Behavior | +| ------------- | ---------------------------------------------- | +| `1` (default) | Latest image only | +| `2` | Latest two images | +| `4` | Latest four images | +| `0` | No raw images, including newly attached images | + +Explicit `options.imageWindow` takes precedence over `MASTERGOAL_IMAGE_WINDOW`, then the default `1`. Options require a non-negative safe integer JSON number; the environment variable requires decimal digits. Invalid selected configuration rejects plugin startup with a clear error, never disables the ceiling. Restart OpenCode after changing settings. Installer upgrades preserve plugin options. + +Protection applies whenever the plugin is loaded for a location, including sessions without a goal, finite and forever goals, autonomous continuation, compaction, title and session-generation requests. It filters the canonical request before provider serialization and does not delete persisted screenshots. It does not change the model architecture or vLLM's own multimodal limit. Set the window at or below your server's image limit. + +The sidebar and `/goal status` show `Image window: N`. When pruning occurs, debug output contains counts only: `[mastergoal] image window: found=100 retained=1 removed=99 limit=1`. No image payloads or URLs are logged. This is runtime configuration, separate from `goal.md` and its contract hash. + +The guarantee covers canonical image parts in these OpenCode session request hooks. Another plugin that adds images **after** Master Goal's hook can invalidate it; avoid such conflicting transforms. Opaque provider-managed history, images embedded inside PDFs, and model calls made outside OpenCode's session hooks are not inspected or decoded. diff --git a/docs/architecture.md b/docs/architecture.md index 5f3f40e..0b60848 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -66,3 +66,17 @@ The sidebar is read-only. It never alters host todos to create a cosmetic succes `/goal construct` is an explicitly requested preparation turn using the host's current model and tools. It accepts ordinary Markdown without a pre-existing contract. The original document and construction instructions are saved outside the project in session state and reinjected during context creation and compaction. No active, paused, or blocked run may enter construction; stop that run first. Successful start clears the construction context and pins the generated contract through the normal engine. Construction itself cannot mark a goal complete. The model edits draft files through OpenCode's ordinary tools and permission system. This phase is intentionally distinct from the locked verification loop: it may ask questions or need another turn. Generated acceptance criteria require inspection; neither the parser nor a model can establish semantic equivalence between arbitrary prose and executable checks. Optional reviewer scripts still require their own configured service and may only veto deterministic success. + +## Rolling image window (OpenCode 2.0.22) + +Persistent history is translated by `SessionModelRequest.baseTranscript` / `toLLMMessages` into canonical `@opencode/ai` messages. The existing `context` and `compaction` callbacks now apply `applyImageWindow` and then inject goal context. Separate `generate` and `title` hooks filter auxiliary session requests because those flows do not trigger `context`. The host builds its `LLMRequest` from the modified messages before protocol serialization; its later unsupported-media and byte-budget passes cannot add images. + +`src/image-window.ts` walks messages and content backwards, retaining the newest N images globally, then reconstructs changed containers in normal order. Complexity is O(messages + parts), including tool-result content. There is no image decoding or network access. Retained `Media.Asset` instances and large payloads are shared; source arrays/messages/results are never mutated. Image-only messages emptied by filtering are removed. An image-only tool result becomes a text-only removal notice so its matching tool call remains valid. + +The exact image representations are `media` parts whose `Media.Asset.kind` is `image` or `mediaType` starts with `image/`, and `tool-result.result` with `type: content` containing `file` items with an `image/` MIME type. Assets support bytes, base64, URLs and provider references. The host converts user attachments to media assets and tool/MCP screenshot outputs to file content before these hooks. JSON/text that merely mentions an image is not media. PDFs/audio/source files remain untouched. + +Native `plugins: [{ package, options: { imageWindow: 1 } }]` supplies `ctx.options`. The selected value is validated at startup, with explicit options over environment over default. It is not goal state and does not affect locking or lifecycle semantics. RPC appends the effective value for the sidebar. Installer replacement preserves options. + +Source inspection: upstream v2 commit `f74512bfe0ad5264acd58ab6d7548eae427f75f5` (package version 2.0.22), `packages/core/src/session/model-request.ts`, `runner/to-llm-message.ts`, `packages/plugin/src/promise/session.ts`, `packages/schema/src/config/plugin.ts`, and the installed 2.0.22 AI media/tool schemas and protocol lowering. No dependency upgrade was required. + +This is request-context filtering, not persistent-history cleanup. No universal final-hook priority exists in this API: later third-party hooks must not reinsert media. Opaque provider-side history and external model calls lie outside this canonical raw-image boundary. diff --git a/docs/validation.md b/docs/validation.md index 0dc4c82..38109e3 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -35,3 +35,26 @@ The host canary starts an isolated local fake provider and temporary OpenCode st ## Limits of this evidence The six-platform/node CI matrix and real-host job passed on GitHub for the Windows process-cleanup fix (run 37157718683). The current workflow additionally tests packaged installation on Windows with Node 22.18.0 and npm 10.9.3. Windows process-tree cancellation is limited to the direct child. Live cloud-provider reliability, weeks-long runs, genuine coding-task success rate, model-specific compaction quality, malicious same-user shell attacks, and active-active server operation are not certified by these tests. This release is version 0.3.0; report reproducible host/runtime issues with the locked contract and redacted state. + +## Rolling image window — 2026-10-04 UTC + +Pinned `@opencode/plugin@2.0.22` remains unchanged. Validation ran on Node 24.19.0 with the actual OpenCode 2.0.22 Linux x64 binary. + +- `npm run verify`: 117 tests passed; 20/20 deterministic benchmark scenarios passed. +- `npm run test:install`: packaged local/global install, durable runtime and uninstall passed. +- `scripts/host-canary-v2.mjs`: admitted 100 distinct image-bearing user prompts with execution initially suspended, then started a finite goal. Three provider HTTP requests each contained exactly one image with no configuration supplied. All 100 screenshot filenames survived session export; construction, continuation, usage and sidebar RPC also passed. +- The same host canary with `CANARY_IMAGE_WINDOW=4` captured `[4, 4, 4]`; with `CANARY_IMAGE_WINDOW=0`, `[0, 0, 0]`. Both preserved all 100 persisted attachments. +- The canary additionally compares the final retained image URL against a distinct newest PNG. Automatic compaction is disabled in this fixture to prove pruning happens independently of compaction. The fake model does not implement the host's summary template. Compaction/title/generate filtering and existing goal injection are exercised at the official hook boundary by adapter tests. +- Unit tests cover 1,000 growing image-bearing turns, limits 0/1/2/4, ordering within/across messages, native `Media.Asset` sources, tool-result screenshot files, empty-image results, unusual non-image content, frozen source history, strict configuration and upgrade preservation. + +Reproduce after `npm ci`: + +```sh +npm run verify +npm run test:install +node scripts/host-canary-v2.mjs /absolute/path/to/opencode +CANARY_IMAGE_WINDOW=4 node scripts/host-canary-v2.mjs /absolute/path/to/opencode +CANARY_IMAGE_WINDOW=0 node scripts/host-canary-v2.mjs /absolute/path/to/opencode +``` + +The provider capture uses OpenAI-compatible HTTP, not a live vLLM model. Other provider formats are covered by the shared canonical API implementation, not by independent live-provider tests. Later third-party hook rewrites and opaque provider-managed image history are outside this plugin's enforceable boundary; see architecture notes. diff --git a/scripts/host-canary-v2.mjs b/scripts/host-canary-v2.mjs index 7b280e7..0c84146 100644 --- a/scripts/host-canary-v2.mjs +++ b/scripts/host-canary-v2.mjs @@ -26,10 +26,25 @@ const env = { const exec = promisify(execFile); let constructionCalls = 0; let calls = 0; +const imageRequests = []; +const imageLimit = Number(process.env.CANARY_IMAGE_WINDOW ?? 1); +const png = + "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAAFElEQVR4nGPUCKhgwAaYsIoOWgkAwFgBALQ78QgAAAAASUVORK5CYII="; +const imageURI = `data:image/png;base64,${png}`; +const newestURI = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAAFElEQVR4nGOcJinHgA0wYRUdtBIApg4A3ZmXOwIAAAAASUVORK5CYII="; +const newestImages = []; const server = createServer(async (req, res) => { let body = ""; for await (const chunk of req) body += chunk; const data = JSON.parse(body); + if (body.includes("image-history-canary")) { + const images = data.messages + .flatMap((m) => (Array.isArray(m.content) ? m.content : [])) + .filter((p) => p.type === "image_url"); + imageRequests.push(images.length); + newestImages.push(images.at(-1)?.image_url?.url); + } if (body.includes("MASTER GOAL CONSTRUCTION")) constructionCalls++; else calls++; if (calls >= 3) await writeFile(root + "/accepted.txt", "verified"); @@ -104,13 +119,19 @@ const api = async (method, p, data) => { try { const config = { plugins: [ - pathToFileURL( - process.env.MASTERGOAL_PACKAGE_ROOT - ? path.resolve(process.env.MASTERGOAL_PACKAGE_ROOT) - : fileURLToPath(new URL("../", import.meta.url)), - ).href, + { + package: pathToFileURL( + process.env.MASTERGOAL_PACKAGE_ROOT + ? path.resolve(process.env.MASTERGOAL_PACKAGE_ROOT) + : fileURLToPath(new URL("../", import.meta.url)), + ).href, + ...(process.env.CANARY_IMAGE_WINDOW === undefined + ? {} + : { options: { imageWindow: imageLimit } }), + }, ], model: "canary/fake", + compaction: { auto: false }, providers: { canary: { package: "@ai-sdk/openai-compatible", @@ -120,7 +141,11 @@ try { apiKey: "fake", }, models: { - fake: { name: "Fake", limit: { context: 100000, output: 1000 } }, + fake: { + name: "Fake", + capabilities: { input: ["text", "image"] }, + limit: { context: 100000, output: 1000 }, + }, }, }, }, @@ -191,6 +216,15 @@ try { model: { providerID: "canary", id: "fake" }, }); const session = created.data ?? created; + // Admit 100 separate image-bearing prompts without waking the model. + for (let i = 0; i < 100; i++) + await api("POST", `/api/session/${session.id}/prompt`, { + text: `image-history-canary ${i}`, + files: [ + { uri: i === 99 ? newestURI : imageURI, name: `screen-${i}.png` }, + ], + resume: false, + }); await api("POST", `/api/session/${session.id}/command`, { name: "goal", text: "start", @@ -201,12 +235,32 @@ try { while (Date.now() < deadline) { result = await engine.store(session.id).read(); if (result?.status === "completed") break; - if (result?.status === "blocked") throw Error(JSON.stringify(result)); + if (result?.status === "blocked") + throw Error("Host canary goal blocked; inspect isolated host logs"); await new Promise((r) => setTimeout(r, 100)); } assert.equal(result?.status, "completed", JSON.stringify({ result, calls })); assert(calls >= 3); assert(result.usage.output > 0); + assert( + imageRequests.length > 0, + "Image-bearing history reached the provider", + ); + assert( + imageRequests.every((n) => n === imageLimit), + JSON.stringify({ imageRequests, imageLimit }), + ); + assert( + newestImages.every((uri) => + imageLimit === 0 ? uri === undefined : uri === newestURI, + ), + "Newest screenshot survives provider serialization", + ); + const persisted = JSON.stringify( + await api("GET", `/api/experimental/session/${session.id}/export`), + ); + for (let i = 0; i < 100; i++) + assert(persisted.includes(`screen-${i}.png`), `Persisted image ${i}`); const rpc = await api( "POST", "/api/rpc/mastergoal.status/read?location[directory]=" + @@ -225,6 +279,10 @@ try { iterations: result.iterations, outputTokens: result.usage.output, rpc: true, + historicalImages: 100, + providerImageCounts: imageRequests, + persistedImagesPreserved: true, + newestImageVerified: true, }, null, 2, diff --git a/src/commands.ts b/src/commands.ts index e5cbdbc..6879ae2 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -19,7 +19,7 @@ export async function initGoal(root: string, file = "goal.md") { return `Created ${file}. Write your objective in ${file}, then /goal construct ${file}, then /goal start ${file}.`; } export const help = - "/goal init [path] | construct [path] | start [path] | forever [path] | status | inspect | check | pause | resume | stop | end"; + "/goal init [path] | construct [path] | start [path] | forever [path] | status | inspect | check | pause | resume | stop | end\nRaw images per model request: imageWindow defaults to 1 (2 = latest two, 0 = no raw images). Configure plugins[].options.imageWindow in opencode.json[c], or MASTERGOAL_IMAGE_WINDOW; restart OpenCode. Session history is preserved."; export async function command( engine: Engine, session: string, diff --git a/src/image-window.ts b/src/image-window.ts new file mode 100644 index 0000000..0ef75c5 --- /dev/null +++ b/src/image-window.ts @@ -0,0 +1,100 @@ +import type { SessionRequest } from "@opencode/plugin/promise/session"; + +/** Read once at plugin startup. Explicit native plugin options win over env. */ +export function loadMasterGoalConfig( + options: Readonly> = {}, + env: NodeJS.ProcessEnv = process.env, +) { + const explicit = Object.prototype.hasOwnProperty.call(options, "imageWindow"); + const raw = explicit ? options.imageWindow : env.MASTERGOAL_IMAGE_WINDOW; + const value = + !explicit && raw === undefined + ? 1 + : !explicit && typeof raw === "string" && /^\d+$/.test(raw) + ? Number(raw) + : raw; + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) + throw new Error( + "[mastergoal] imageWindow must be a non-negative safe integer (MASTERGOAL_IMAGE_WINDOW must contain decimal digits)", + ); + return { imageWindow: value }; +} + +const record = (value: unknown): Record | undefined => + value !== null && typeof value === "object" + ? (value as Record) + : undefined; +const imageMime = (value: unknown) => + typeof value === "string" && /^image\//i.test(value); +/** Exact 2.0.22 canonical media and Tool.Content file representations. */ +export function isImagePart(value: unknown): boolean { + const part = record(value); + if (part?.type === "media") { + const media = record(part.media); + return media?.kind === "image" || imageMime(media?.mediaType); + } + return part?.type === "file" && imageMime(part.mime); +} + +/** Copy only changed containers; retain Media.Asset instances and payloads. */ +export function applyImageWindow( + messages: SessionRequest["messages"], + limit = 1, +) { + loadMasterGoalConfig({ imageWindow: limit }, {}); + let found = 0; + const keep = (part: unknown) => !isImagePart(part) || ++found <= limit; + const output: SessionRequest["messages"] = []; + for (let m = messages.length - 1; m >= 0; m--) { + const message = messages[m]!; + if (!Array.isArray(message?.content)) { + output.push(message); + continue; + } + const content = []; + let changed = false; + for (let p = message.content.length - 1; p >= 0; p--) { + const part = message.content[p]; + if (!keep(part)) { + changed = true; + continue; + } + if ( + part?.type === "tool-result" && + part.result?.type === "content" && + Array.isArray(part.result.value) + ) { + const value = []; + for (let i = part.result.value.length - 1; i >= 0; i--) + if (keep(part.result.value[i])) value.push(part.result.value[i]); + if (value.length !== part.result.value.length) { + changed = true; + // Keep the call/result pair valid even when the tool returned only images. + content.push({ + ...part, + result: value.length + ? { ...part.result, value: value.reverse() } + : { + type: "text" as const, + value: + "[Master Goal removed an older image from this request.]", + }, + }); + continue; + } + } + content.push(part); + } + if (!changed) output.push(message); + else if (content.length) + output.push({ ...message, content: content.reverse() }); + } + const retained = Math.min(found, limit); + return { + messages: found > limit ? output.reverse() : messages, + found, + retained, + removed: found - retained, + limit, + }; +} diff --git a/src/install.ts b/src/install.ts index bc5546b..c0830f9 100644 --- a/src/install.ts +++ b/src/install.ts @@ -73,14 +73,26 @@ export async function install( if (config[key] !== undefined && !Array.isArray(config[key])) throw new Error(`${key} must be an array in ${file}`); if (name === "opencode" && !remove) { - const entries = [ - ...(config.plugin ?? []), - ...(config.plugins ?? []), - ].filter((x) => !owned(x)); + const previous = [...(config.plugin ?? []), ...(config.plugins ?? [])]; + // Preserve native options when replacing an owned registration during upgrades. + const options = Object.assign( + {}, + ...previous + .filter(owned) + .map((entry) => + typeof entry === "object" && entry !== null + ? (entry.options ?? {}) + : {}, + ), + ); + const entries = previous.filter((x) => !owned(x)); const unique = [ ...new Map(entries.map((x) => [JSON.stringify(x), x])).values(), ]; - set("plugins", [...unique, spec]); + set("plugins", [ + ...unique, + Object.keys(options).length ? { package: spec, options } : spec, + ]); if (config.plugin !== undefined) set("plugin", undefined); } else { for (const key of ["plugin", "plugins"]) { diff --git a/src/server.ts b/src/server.ts index 2459b85..04261cb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,3 +1,8 @@ +import { applyImageWindow, loadMasterGoalConfig } from "./image-window.js"; +import type { + SessionContext, + SessionRequest, +} from "@opencode/plugin/promise/session"; import { constructionContext } from "./construct.js"; import { Plugin } from "@opencode/plugin"; import { realpath } from "node:fs/promises"; @@ -11,6 +16,8 @@ type Sid = Parameters[0]["sessionID"]; export default Plugin.define({ id: "opencode-mastergoal", setup: async (ctx) => { + const config = loadMasterGoalConfig(ctx.options); + const imageStatus = `Image window: ${config.imageWindow}`; const boundRoot = await realpath(ctx.location.directory); const engines = new Map(), schedulers = new Map(), @@ -82,7 +89,9 @@ export default Plugin.define({ ); await ctx.session.synthetic({ sessionID: input.sessionID, - text: result.text, + text: /^(status|help)$/.test(input.prompt.text.trim()) + ? `${result.text}\n${imageStatus}` + : result.text, resume: false, }); if (result.construction) @@ -102,14 +111,22 @@ export default Plugin.define({ read: async (input) => { const { sessionID } = input as { sessionID: string }; const { engine } = await get(sessionID); - return { text: formatStatus(await engine.store(sessionID).read()) }; + return { + text: `${formatStatus(await engine.store(sessionID).read())}\n${imageStatus}`, + }; }, }), ); - const inject = async (e: { - sessionID: Sid; - system: { type: "text"; text: string }[]; - }) => { + const filterImages = async (e: SessionRequest) => { + const filtered = applyImageWindow(e.messages, config.imageWindow); + e.messages = filtered.messages; + if (filtered.removed) + console.debug( + `[mastergoal] image window: found=${filtered.found} retained=${filtered.retained} removed=${filtered.removed} limit=${filtered.limit}`, + ); + }; + const inject = async (e: SessionContext) => { + await filterImages(e); const { engine } = await get(e.sessionID); const text = (await constructionContext(engine, e.sessionID)) || @@ -118,6 +135,8 @@ export default Plugin.define({ }; registrations.push(await ctx.session.hook("context", inject)); registrations.push(await ctx.session.hook("compaction", inject)); + registrations.push(await ctx.session.hook("generate", filterImages)); + registrations.push(await ctx.session.hook("title", filterImages)); registrations.push( await ctx.tool.hook("execute.before", async (e) => { const { engine } = await get(e.sessionID); diff --git a/test/adapters.test.mjs b/test/adapters.test.mjs index 1caa8a5..44d64cf 100644 --- a/test/adapters.test.mjs +++ b/test/adapters.test.mjs @@ -73,7 +73,7 @@ test("V2 native command, context, RPC, event telemetry and disposal", async (t) await cmd.execute({ sessionID: "s", prompt: { text: "construct" } }); assert.match(constructionPrompts[0].text, /MASTER GOAL CONSTRUCTION/); assert.equal(constructionPrompts[0].resume, true); - const draft = { sessionID: "s", system: [] }; + const draft = { sessionID: "s", system: [], messages: [] }; await hooks.compaction(draft); assert.match(draft.system[0].text, /MASTER GOAL CONSTRUCTION/); @@ -83,9 +83,34 @@ test("V2 native command, context, RPC, event telemetry and disposal", async (t) delivery: "queue", }); assert.equal(synthetic[0].resume, false); - const context = { sessionID: "s", system: [] }; + const context = { sessionID: "s", system: [], messages: [] }; await hooks.context(context); assert(context.system[0].text.includes("MASTER GOAL")); + for (const name of ["context", "compaction", "generate", "title"]) { + const messages = Array.from({ length: 100 }, (_, i) => ({ + role: "user", + content: [ + { type: "text", text: String(i) }, + { type: "media", media: { kind: "image", mediaType: "image/png" } }, + ], + })); + const request = { sessionID: "no-goal", system: [], messages }; + await hooks[name](request); + assert.equal( + request.messages + .flatMap((m) => m.content) + .filter((p) => p.type === "media").length, + 1, + ); + assert.equal( + messages.flatMap((m) => m.content).filter((p) => p.type === "media") + .length, + 100, + ); + assert.equal(request.messages.at(-1).content[0].text, "99"); + } + assert.match((await rpc.read({ sessionID: "s" })).text, /Image window: 1/); + assert.match((await rpc.read({ sessionID: "s" })).text, /ACTIVE/); assert.deepEqual( tools.map((x) => x.name), diff --git a/test/commands.test.mjs b/test/commands.test.mjs index d176e86..892aa8d 100644 --- a/test/commands.test.mjs +++ b/test/commands.test.mjs @@ -221,3 +221,30 @@ test("forever command overrides a verified artifact check that passes immediatel await command(f.engine, "s", "stop"); assert.equal((await f.engine.store("s").read()).status, "stopped"); }); + +test("installer upgrades preserve native plugin imageWindow options", async (t) => { + const f = await fixture(t); + await f.write( + "opencode.json", + JSON.stringify({ + plugins: [ + { + package: "@darkmatter2222/opencode-mastergoal@0.3.0", + options: { imageWindow: 4, future: true }, + }, + "other", + ], + }), + ); + await install(f.root); + const once = await readFile(path.join(f.root, "opencode.json"), "utf8"); + await install(f.root); + assert.equal( + await readFile(path.join(f.root, "opencode.json"), "utf8"), + once, + ); + assert.deepEqual(JSON.parse(once).plugins.at(-1).options, { + imageWindow: 4, + future: true, + }); +}); diff --git a/test/image-window.test.mjs b/test/image-window.test.mjs new file mode 100644 index 0000000..1eb12ac --- /dev/null +++ b/test/image-window.test.mjs @@ -0,0 +1,201 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Message, Media } from "@opencode/ai"; +import { + applyImageWindow, + loadMasterGoalConfig, + isImagePart, +} from "../dist/image-window.js"; +const img = (id) => Message.media(Media.base64(id, "image/png")); +const images = (messages) => + messages + .flatMap((m) => + m.content.flatMap((p) => + p?.type === "tool-result" && p.result?.type === "content" + ? p.result.value + : [p], + ), + ) + .filter(isImagePart); +const freeze = (x) => { + if (x && typeof x === "object") { + Object.freeze(x); + Object.values(x).forEach(freeze); + } + return x; +}; + +test("native options, defaults, environment precedence and strict validation", () => { + assert.equal(loadMasterGoalConfig({}, {}).imageWindow, 1); + assert.equal( + loadMasterGoalConfig({}, { MASTERGOAL_IMAGE_WINDOW: "4" }).imageWindow, + 4, + ); + assert.equal( + loadMasterGoalConfig( + { imageWindow: 0 }, + { MASTERGOAL_IMAGE_WINDOW: "garbage" }, + ).imageWindow, + 0, + ); + for (const value of [0, 1, 2, 4, 8]) + assert.equal( + loadMasterGoalConfig({ imageWindow: value }).imageWindow, + value, + ); + for (const value of [ + -1, + 1.5, + "banana", + "2", + NaN, + Infinity, + null, + undefined, + true, + Number.MAX_SAFE_INTEGER + 1, + ]) + assert.throws( + () => loadMasterGoalConfig({ imageWindow: value }, {}), + /imageWindow/, + ); + for (const value of [ + "-1", + "1.5", + "garbage", + "NaN", + "Infinity", + "", + " ", + "0x2", + "1e2", + ]) + assert.throws( + () => loadMasterGoalConfig({}, { MASTERGOAL_IMAGE_WINDOW: value }), + /imageWindow/, + ); +}); + +test("newest global images, mixed messages, stable order and persistence safety", () => { + const input = freeze([ + Message.user([Message.text("first"), img("A")]), + Message.user([img("B"), img("C")]), + Message.assistant("reply"), + Message.user([img("D"), img("E"), img("F")]), + ]); + for (const limit of [0, 1, 2, 4, 8]) { + const output = applyImageWindow(input, limit); + assert.deepEqual( + images(output.messages).map((p) => p.media.source.data), + limit ? ["A", "B", "C", "D", "E", "F"].slice(-limit) : [], + ); + assert.equal(output.found, 6); + assert.equal(output.removed, Math.max(0, 6 - limit)); + assert.equal(output.messages[0].content[0].text, "first"); + assert(output.messages.every((m) => m.content.length)); + assert(output.messages.some((m) => m.content[0].text === "reply")); + } + assert.equal(images(input).length, 6); + assert.equal( + images(applyImageWindow(input).messages)[0].media, + input[3].content[2].media, + ); +}); + +test("text only and a single image are unchanged", () => { + for (const input of [[Message.user("hello")], [Message.user(img("A"))]]) + assert.equal(applyImageWindow(input).messages, input); +}); + +test("canonical media supports bytes, base64, URLs, data URLs and provider refs", () => { + for (const asset of [ + Media.bytes(new Uint8Array([1]), "image/png"), + Media.base64("AA==", "image/png"), + Media.url("https://example.test/a", { mediaType: "image/png" }), + Media.fromDataUrl("data:image/png;base64,AA=="), + Media.ref("openai", "file-1", "image/png"), + ]) { + const input = [Message.user(Message.media(asset))]; + assert.equal(applyImageWindow(input, 0).messages.length, 0); + assert.equal( + applyImageWindow(input, 1).messages[0].content[0].media, + asset, + ); + } +}); + +test("tool screenshot files share the global budget; tool pairs and nonimages survive", () => { + const files = ["A", "B"].map((id) => ({ + type: "file", + mime: "image/png", + uri: `data:image/png;base64,${id}`, + })); + const nonimages = [ + { type: "text", text: "result" }, + { type: "file", mime: "application/pdf", uri: "pdf" }, + { type: "file", mime: "audio/wav", uri: "audio" }, + { type: "file", mime: "text/plain", uri: "source" }, + ]; + const tool = Message.tool({ + id: "call", + name: "browser", + result: { type: "content", value: [...files, ...nonimages] }, + }); + const input = freeze([ + Message.assistant({ + type: "tool-call", + id: "call", + name: "browser", + input: {}, + }), + tool, + Message.user(img("C")), + ]); + const out = applyImageWindow(input, 2).messages; + assert.equal(images(out).length, 2); + assert.equal(out[1].content[0].result.value[0].uri, files[1].uri); + assert.deepEqual(out[1].content[0].result.value.slice(1), nonimages); + const empty = applyImageWindow( + [ + Message.tool({ + id: "x", + name: "screen", + result: { type: "content", value: files }, + }), + ], + 0, + ).messages; + assert.equal(empty[0].content[0].id, "x"); + assert.equal(empty[0].content[0].result.type, "text"); + assert.equal(images(input).length, 3); +}); + +test("unusual unrelated content does not bypass enforcement", () => { + const unusual = [ + null, + { type: "future", payload: { type: "image" } }, + { type: "tool-result", result: null }, + ]; + const result = applyImageWindow([ + { role: "user", content: [img("A"), ...unusual, img("B")] }, + ]); + assert.deepEqual(result.messages[0].content.slice(0, 3), unusual); + assert.equal(images(result.messages).length, 1); +}); + +test("every growing request through 1000 images retains the newest N", () => { + const history = []; + for (let i = 0; i < 1000; i++) { + history.push(Message.user([Message.text(`turn ${i}`), img(String(i))])); + for (const limit of [1, 4]) { + const out = applyImageWindow(history, limit); + assert.deepEqual( + images(out.messages).map((p) => p.media.source.data), + Array.from({ length: Math.min(i + 1, limit) }, (_, n) => + String(i - Math.min(i + 1, limit) + n + 1), + ), + ); + } + } + assert.equal(images(history).length, 1000); +}); From a509b56bdef8f20fbeac16d08f30a9dfbec25662 Mon Sep 17 00:00:00 2001 From: "Ryan V. Susman" Date: Sat, 3 Oct 2026 20:55:57 -0400 Subject: [PATCH 2/2] Prepare v0.4.0 rolling image window release --- package-lock.json | 4 ++-- package.json | 2 +- releases/v0.4.0.md | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 releases/v0.4.0.md diff --git a/package-lock.json b/package-lock.json index fd9c1bc..9f57329 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@darkmatter2222/opencode-mastergoal", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@darkmatter2222/opencode-mastergoal", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@opencode/plugin": "2.0.22", diff --git a/package.json b/package.json index 07f67b2..52c5c5a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@darkmatter2222/opencode-mastergoal", - "version": "0.3.0", + "version": "0.4.0", "description": "OpenCode 2.x goal orchestration with locked deterministic completion contracts", "type": "module", "license": "MIT", diff --git a/releases/v0.4.0.md b/releases/v0.4.0.md new file mode 100644 index 0000000..a71e3df --- /dev/null +++ b/releases/v0.4.0.md @@ -0,0 +1,41 @@ +# Rolling image context window + +Master Goal now defaults to sending only the newest raw image with each model request. Screenshot-heavy sessions can retain their history without sending every previous screenshot back to the model. + +- Set native plugin `options.imageWindow` to 1 (default), 2, 4, or any non-negative safe integer. Zero excludes all raw images. +- Enforced before provider serialization for normal, autonomous, compaction, title and session-generation requests, including sessions without an active goal. +- Handles user attachments and tool/MCP screenshot files. Preserves text, non-image files, tool-call/result pairs and persistent screenshots. +- Native plugin options override `MASTERGOAL_IMAGE_WINDOW`; invalid selected settings reject startup. Installer upgrades preserve options. +- Sidebar/status displays the limit; pruning diagnostics contain counts only. +- OpenCode plugin dependency remains pinned to 2.0.22. Goal completion, forever mode and failure handling are unchanged. + +## Update + +Close OpenCode, then run: + +```powershell +opencode service stop +npx --yes --package=github:darkmatter2222/opencode_mastergoal#v0.4.0 mastergoal install --global +opencode +``` + +Use `--local` instead of `--global` for a project installation. Existing installations need no new setting to get the default limit of one. + +To configure the window, change your existing Master Goal registration in `opencode.json[c]` from its package string to an object, retaining that exact package path: + +```json +{ + "package": "file:///YOUR-EXISTING-PATH/.mastergoal-runtime/node_modules/@darkmatter2222/opencode-mastergoal", + "options": { "imageWindow": 2 } +} +``` + +Restart OpenCode after changing the setting. This does not change your inference server's own image limit. + +## Validation and scope + +Local verification passed 117 tests and 20 deterministic scenarios; packaged local/global install and uninstall passed. Actual OpenCode 2.0.22 captured three provider requests with image counts `[1,1,1]` from 100 stored image-bearing prompts. Configured limits 4 and 0 produced `[4,4,4]` and `[0,0,0]`. All 100 attachments remained in session export, and the default retained PNG was verified as the newest. Unit endurance coverage includes 1,000 growing turns. + +Release publication requires successful cross-platform CI on the exact release commit. The provider canary uses a local OpenAI-compatible fake, not a live vLLM model. Other plugins must not insert images after this filter. Opaque provider-side history and model calls outside OpenCode session hooks are outside its scope. + +The attached tarball contains the compiled runtime, with SHA256SUMS. This is a GitHub release; the package is not published to the npm registry.