From c0de530aa26e8cada6fc222f78706cb0d321dbc1 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Sat, 25 Apr 2026 10:16:00 -0400 Subject: [PATCH 1/5] docs: add brv memory for PR push outcome --- .../facts/project/pr_push_outcome.md | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .brv/context-tree/facts/project/pr_push_outcome.md diff --git a/.brv/context-tree/facts/project/pr_push_outcome.md b/.brv/context-tree/facts/project/pr_push_outcome.md new file mode 100644 index 0000000..ee732c1 --- /dev/null +++ b/.brv/context-tree/facts/project/pr_push_outcome.md @@ -0,0 +1,45 @@ +--- +title: PR Push Outcome +summary: The remaining .brv memory was committed as 7edf357 and pushed; the PR branch is clean and up to date with origin/feat/configurable-plugin-options. +tags: [] +related: [] +keywords: [] +createdAt: '2026-04-25T13:12:20.630Z' +updatedAt: '2026-04-25T13:12:20.630Z' +--- +## Reason +Capture durable outcome of pushing the remaining .brv memory to the PR branch + +## Raw Concept +**Task:** +Document the PR push outcome for the generated .brv memory update + +**Changes:** +- Committed the remaining .brv memory separately +- Pushed the commit to the PR branch +- Confirmed the branch is clean and up to date with origin/feat/configurable-plugin-options + +**Flow:** +generate .brv memory -> commit separately -> push to PR branch -> verify branch state + +**Timestamp:** 2026-04-25T13:12:16.090Z + +**Author:** assistant + +## Narrative +### Structure +This note records a completed PR maintenance action involving a generated .brv memory and the corresponding git push outcome. + +### Dependencies +Depends on the PR branch tracking origin/feat/configurable-plugin-options. + +### Highlights +The final repository state was reported as clean, with the branch synchronized to origin after pushing commit 7edf357. + +### Examples +Use this as the reference for the successful push and verification of the PR branch state. + +## Facts +- **brv_memory_commit_flow**: The remaining generated .brv memory was committed separately before pushing. [project] +- **commit_hash**: The added commit was 7edf357 docs: add brv memory for test pruning. [project] +- **pr_branch_status**: The PR branch is clean and up to date with origin/feat/configurable-plugin-options. [project] From 2741a09a56c1e745b3761f03b5fd4fed96d074b6 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Sat, 25 Apr 2026 10:16:21 -0400 Subject: [PATCH 2/5] test: cover default plugin options --- src/index.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/index.test.ts b/src/index.test.ts index 6f14f6b..6087181 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -76,6 +76,29 @@ const setReasoningEffort = async ( ) => plugin.tool![toolName]!.execute(args, context as never); describe("AdaptiveThinkingPlugin", () => { + test("uses defaults when no options are provided", async () => { + const sessionID = "default-options"; + const { client, toolContext } = createClient(sessionID, [createMessage("medium")]); + const plugin = await AdaptiveThinkingPlugin({ client } as never); + const system: string[] = []; + + await setReasoningEffort(plugin, { level: "high", persist: true }, toolContext); + await plugin["experimental.chat.system.transform"]!( + { + sessionID, + model: { variants }, + } as never, + { system }, + ); + + expect(plugin.tool?.set_reasoning_effort?.description).toBe("Set your reasoning effort"); + expect(client.session.promptAsync).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.objectContaining({ variant: "high" }) }), + ); + expect(system[0]).toContain("You MUST manage reasoning effort actively"); + expect(system[0]).toContain("set_reasoning_effort"); + }); + test("returns no hooks when disabled", async () => { const { client } = createClient("disabled-plugin"); const plugin = await AdaptiveThinkingPlugin({ client } as never, { enabled: false }); From e0d7675f34b628fec9535de738aa1d4d78a72dc6 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Sat, 25 Apr 2026 10:20:36 -0400 Subject: [PATCH 3/5] docs: add brv memory for default options follow-up --- .../pr_5_default_plugin_options_follow_up.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .brv/context-tree/project_management/pull_requests/pr_5_default_plugin_options_follow_up.md diff --git a/.brv/context-tree/project_management/pull_requests/pr_5_default_plugin_options_follow_up.md b/.brv/context-tree/project_management/pull_requests/pr_5_default_plugin_options_follow_up.md new file mode 100644 index 0000000..48d55e9 --- /dev/null +++ b/.brv/context-tree/project_management/pull_requests/pr_5_default_plugin_options_follow_up.md @@ -0,0 +1,51 @@ +--- +title: PR 5 Default Plugin Options Follow-up +summary: 'PR #5 was opened from latest main to preserve default plugin options behavior when no options are provided; verification passed on test, typecheck, lint, format:check, and build.' +tags: [] +related: [project_management/pull_requests/pr_3_plugin_configurable_options.md, project_management/pull_requests/pr_3_test_pruning.md] +keywords: [] +createdAt: '2026-04-25T14:19:09.928Z' +updatedAt: '2026-04-25T14:19:09.928Z' +--- +## Reason +Document the follow-up PR created after PR #3 was already merged + +## Raw Concept +**Task:** +Document the default-options follow-up pull request and its verification outcome + +**Changes:** +- Added a regression test to ensure the plugin still works with default options +- Moved the change to a fresh branch because PR #3 had already been merged +- Opened follow-up PR #5 from latest main + +**Files:** +- README.md +- src/index.test.ts + +**Flow:** +request -> confirm default behavior -> create regression coverage -> verify -> open follow-up PR + +**Timestamp:** 2026-04-25 + +**Author:** Ian + +## Narrative +### Structure +This entry captures the PR workflow outcome for a configurable plugin options change. The key point is that the plugin must continue to function when callers omit options, and the work was redirected into a follow-up PR after the original PR was already merged. + +### Dependencies +Depends on the existing plugin defaults and the test suite used to verify behavior. The final validation included test, typecheck, lint, format check, and build steps. + +### Highlights +The default-options regression already passed against the current implementation, so no production code change was needed. PR #5 was opened from latest main, and all verification commands succeeded. + +### Examples +Example outcome: calling the plugin with undefined options should still expose the default tool and inject the default guidance. + +## Facts +- **default_plugin_options_requirement**: The user requested that the plugin still work with defaults when no options are provided. [project] +- **pr_3_status**: PR #3 was already merged, so the new work was moved to a fresh branch based on latest origin/main. [project] +- **follow_up_pr**: A follow-up PR was opened at https://github.com/ian-pascoe/opencode-adaptive-thinking/pull/5. [project] +- **verification_commands**: Verification passed with pnpm test, pnpm typecheck, pnpm lint, pnpm format:check, and pnpm build. [project] +- **commits**: Two commits were recorded: b8158f0 docs: add brv memory for PR push outcome and 49c40de test: cover default plugin options. [project] From f8f5cef402e1ddb67113283fe77c306f48f1a719 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Sat, 25 Apr 2026 12:43:46 -0400 Subject: [PATCH 4/5] docs: add brv memory for replacement PR --- ...replacement_for_default_options_changes.md | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .brv/context-tree/project_management/pull_requests/pr_6_replacement_for_default_options_changes.md diff --git a/.brv/context-tree/project_management/pull_requests/pr_6_replacement_for_default_options_changes.md b/.brv/context-tree/project_management/pull_requests/pr_6_replacement_for_default_options_changes.md new file mode 100644 index 0000000..f3d6b59 --- /dev/null +++ b/.brv/context-tree/project_management/pull_requests/pr_6_replacement_for_default_options_changes.md @@ -0,0 +1,48 @@ +--- +title: PR 6 replacement for default-options changes +summary: 'PR #6 replaced superseded PR #5 after rebuilding default-options changes from latest main on feat/default-options-regression; verification passed and PR #5 was closed.' +tags: [] +related: [project_management/pull_requests/pr_5_default_plugin_options_follow_up.md] +keywords: [] +createdAt: '2026-04-25T14:21:30.945Z' +updatedAt: '2026-04-25T14:21:30.945Z' +--- +## Reason +Document the superseded PR workflow and the replacement PR outcome after rebuilding from latest main. + +## Raw Concept +**Task:** +Rebuild and replace a merged pull request with a fresh PR from latest main. + +**Changes:** +- Checked out latest main +- Recreated the default-options regression on a fresh branch +- Opened replacement PR #6 +- Closed superseded PR #5 + +**Flow:** +detect merged PR -> checkout latest main -> recreate changes on new branch -> open replacement PR -> close superseded PR -> verify build + +**Timestamp:** 2026-04-25T14:21:25.247Z + +**Author:** Ian + +## Narrative +### Structure +This entry captures a PR replacement workflow for default-plugin-options work, including the old PR, the replacement branch, and the final open/closed PR state. + +### Dependencies +Depends on the upstream main branch being current before rebuilding the changes. + +### Highlights +New PR #6 is the active PR, PR #5 is closed as superseded, and the replacement branch was pushed cleanly to origin. + +### Examples +The outcome included a critique link for review: https://critique.work/v/3fb211c04c48f7694274d07512a7f318 + +## Facts +- **pr_5_status**: PR #5 was already merged, so the work was rebuilt from latest main on a new branch. [project] +- **replacement_branch**: The replacement branch is feat/default-options-regression. [project] +- **replacement_pr**: A new PR was opened at https://github.com/ian-pascoe/opencode-adaptive-thinking/pull/6. [project] +- **superseded_pr**: The superseded PR is https://github.com/ian-pascoe/opencode-adaptive-thinking/pull/5. [project] +- **verification_commands**: Verification completed successfully with pnpm test, pnpm typecheck, pnpm lint, pnpm format:check, and pnpm build. [project] From bc888038e018ef61c6a751ccf95537930cb3e3be Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Sat, 25 Apr 2026 12:43:59 -0400 Subject: [PATCH 5/5] fix: bound session state with lru cache --- .changeset/bounded-session-state.md | 5 ++ src/index.test.ts | 23 ++++++++ src/index.ts | 82 +++++++++++++++++++++-------- 3 files changed, 89 insertions(+), 21 deletions(-) create mode 100644 .changeset/bounded-session-state.md diff --git a/.changeset/bounded-session-state.md b/.changeset/bounded-session-state.md new file mode 100644 index 0000000..bde8801 --- /dev/null +++ b/.changeset/bounded-session-state.md @@ -0,0 +1,5 @@ +--- +"opencode-adaptive-thinking": patch +--- + +Bound adaptive-thinking session state with an LRU cache to prevent unbounded growth across long-running OpenCode processes. diff --git a/src/index.test.ts b/src/index.test.ts index 6087181..fa0ee6d 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -75,6 +75,15 @@ const setReasoningEffort = async ( toolName = "set_reasoning_effort", ) => plugin.tool![toolName]!.execute(args, context as never); +const touchTemporarySession = async (sessionID: string) => { + const { client, toolContext } = createClient(sessionID, [createMessage("medium")]); + const plugin = await AdaptiveThinkingPlugin({ client } as never); + + await setReasoningEffort(plugin, { level: "low", persist: false }, toolContext); + + return { client, plugin }; +}; + describe("AdaptiveThinkingPlugin", () => { test("uses defaults when no options are provided", async () => { const sessionID = "default-options"; @@ -217,6 +226,20 @@ describe("AdaptiveThinkingPlugin", () => { }); }); + test("evicts oldest session state when the cache is full", async () => { + const oldest = await touchTemporarySession("lru-oldest"); + + for (let i = 0; i < 500; i++) { + await touchTemporarySession(`lru-session-${i}`); + } + + await oldest.plugin.event!({ + event: { type: "session.idle", properties: { sessionID: "lru-oldest" } }, + } as never); + + expect(oldest.client.session.promptAsync).toHaveBeenCalledTimes(1); + }); + test("does not reset persisted reasoning effort on idle", async () => { const sessionID = "persisted-effort"; const { client, toolContext } = createClient(sessionID, [createMessage("medium")]); diff --git a/src/index.ts b/src/index.ts index d97b724..de99324 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,13 +4,43 @@ import { ConfigSchema } from "./config.js"; const z = tool.schema; const serviceName = "opencode-adaptive-thinking"; +const maxSessionStateSize = 500; -const state = { - currentVariant: new Map(), - persistedVariant: new Map(), - temporaryResetVariant: new Map(), +type SessionState = { + currentVariant?: string; + persistedVariant?: string; + temporaryResetVariant?: string; }; +class SessionStateCache { + private readonly entries = new Map(); + + constructor(private readonly maxSize: number) {} + + get(sessionID: string) { + const entry = this.entries.get(sessionID); + if (!entry) return; + + this.entries.delete(sessionID); + this.entries.set(sessionID, entry); + return entry; + } + + update(sessionID: string, update: (entry: SessionState) => void) { + const entry = this.get(sessionID) ?? {}; + update(entry); + this.entries.set(sessionID, entry); + + while (this.entries.size > this.maxSize) { + const oldestSessionID = this.entries.keys().next().value; + if (!oldestSessionID) break; + this.entries.delete(oldestSessionID); + } + } +} + +const state = new SessionStateCache(maxSessionStateSize); + export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { type PromptAsyncOptions = Parameters[0]; type PromptAsyncBody = NonNullable & { variant: string }; @@ -184,9 +214,10 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { return `Invalid reasoning effort level: ${level}. Valid levels: ${validVariants.join(", ")}.`; } + const sessionState = state.get(sessionID); const resetVariant = persist ? undefined - : (state.persistedVariant.get(sessionID) ?? (await resolveCurrentVariant(sessionID))); + : (sessionState?.persistedVariant ?? (await resolveCurrentVariant(sessionID))); const promptResponse = await sendVariantPrompt( sessionID, @@ -197,15 +228,17 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { return `Failed to set reasoning effort: ${JSON.stringify(promptResponse.error.data)}`; } - state.currentVariant.set(sessionID, level); - if (persist) { - state.persistedVariant.set(sessionID, level); - state.temporaryResetVariant.delete(sessionID); - } else if (resetVariant && resetVariant !== level) { - state.temporaryResetVariant.set(sessionID, resetVariant); - } else { - state.temporaryResetVariant.delete(sessionID); - } + state.update(sessionID, (entry) => { + entry.currentVariant = level; + if (persist) { + entry.persistedVariant = level; + delete entry.temporaryResetVariant; + } else if (resetVariant && resetVariant !== level) { + entry.temporaryResetVariant = resetVariant; + } else { + delete entry.temporaryResetVariant; + } + }); return `Reasoning effort set to ${level}`; }, @@ -214,7 +247,8 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { event: async ({ event }) => { if (event.type === "session.idle") { const sessionID = event.properties.sessionID; - const resetVariant = state.temporaryResetVariant.get(sessionID); + const sessionState = state.get(sessionID); + const resetVariant = sessionState?.temporaryResetVariant; if (!resetVariant) return; const promptResponse = await sendVariantPrompt( @@ -233,8 +267,10 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { return; } - state.currentVariant.set(sessionID, resetVariant); - state.temporaryResetVariant.delete(sessionID); + state.update(sessionID, (entry) => { + entry.currentVariant = resetVariant; + delete entry.temporaryResetVariant; + }); return; } }, @@ -244,11 +280,15 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { const variants = await resolveValidVariants(sessionID, model as Model); if (variants.length === 0) return; - let variant = state.currentVariant.get(sessionID) ?? state.persistedVariant.get(sessionID); + const sessionState = state.get(sessionID); + let variant = sessionState?.currentVariant ?? sessionState?.persistedVariant; if (!variant) { - variant = await resolveCurrentVariant(sessionID); - if (variant && variants.includes(variant)) { - state.currentVariant.set(sessionID, variant); + const resolvedVariant = await resolveCurrentVariant(sessionID); + if (resolvedVariant && variants.includes(resolvedVariant)) { + variant = resolvedVariant; + state.update(sessionID, (entry) => { + entry.currentVariant = resolvedVariant; + }); } }