From d4dd0349fe9f8ee6df011840c483eab1ef48f45d Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 24 Aug 2026 10:05:54 -0400 Subject: [PATCH 1/2] feat(session-recap): configurable window via setting + env var Adds amicode.sessionRecapWindowDays to VS Code settings (default 7, minimum 1). The extension injects it as AMICODE_SESSION_RECAP_WINDOW_DAYS into the spawned server process. The plugin's resolveWindowDays() reads the env var and falls back to the default. Invalid values (<=0, NaN, Infinity, empty) are silently ignored. The markdown heading reflects the actual window used. Changes: - package.json: new setting near sessionDatabase - extension.ts: spawnEnv closure pipes the setting into the env - session_recap.ts: resolveWindowDays() + dynamic heading - session_recap.test.ts: 9 new test cases --- .../opencode-plugin/session_recap.ts | 20 ++++-- packages/extension/package.json | 6 ++ packages/extension/src/extension.ts | 2 + packages/extension/test/session_recap.test.ts | 72 ++++++++++++++++++- 4 files changed, 95 insertions(+), 5 deletions(-) diff --git a/packages/extension/opencode-plugin/session_recap.ts b/packages/extension/opencode-plugin/session_recap.ts index 762f8fca..6b0992f1 100644 --- a/packages/extension/opencode-plugin/session_recap.ts +++ b/packages/extension/opencode-plugin/session_recap.ts @@ -46,6 +46,16 @@ export const RECAP_WINDOW_DAYS = 7; export const MAX_RECAPS = 10; export const MIN_ASSISTANT_MESSAGES = 2; +/** Resolve the effective recap window in days. Reads AMICODE_SESSION_RECAP_WINDOW_DAYS + * from the environment; falls back to RECAP_WINDOW_DAYS if unset or invalid. */ +export function resolveWindowDays(): number { + const raw = process.env.AMICODE_SESSION_RECAP_WINDOW_DAYS; + if (raw == null || raw.trim() === "") return RECAP_WINDOW_DAYS; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) return RECAP_WINDOW_DAYS; + return parsed; +} + // Titles that indicate noise sessions (internal housekeeping) export const NOISE_TITLE_PREFIXES = ["Compaction", "compaction"]; @@ -168,8 +178,9 @@ export function composeRecapText(userTexts: string[], outcomes: string[]): strin // ── Markdown composition (pure, testable) ──────────────────────────────────── -export function composeMarkdown(recaps: SessionRecap[]): string { - const lines = ["## Recent sessions (last 7 days)", ""]; +export function composeMarkdown(recaps: SessionRecap[], windowDays?: number): string { + const days = windowDays ?? resolveWindowDays(); + const lines = [`## Recent sessions (last ${days} days)`, ""]; for (const r of recaps) { const date = new Date(r.created); @@ -263,7 +274,8 @@ export function buildRecentSessionsBlock(currentSessionId?: string): string | nu } try { - const cutoff = Date.now() - RECAP_WINDOW_DAYS * 24 * 60 * 60 * 1000; + const windowDays = resolveWindowDays(); + const cutoff = Date.now() - windowDays * 24 * 60 * 60 * 1000; // Query recent sessions: non-subagent, non-archived, within window const sessions = db.prepare(` @@ -320,7 +332,7 @@ export function buildRecentSessionsBlock(currentSessionId?: string): string | nu if (recaps.length === 0) return null; - return composeMarkdown(recaps); + return composeMarkdown(recaps, windowDays); } catch (e) { console.error(`[session-recap] failed: ${e instanceof Error ? e.message : String(e)}`); return null; diff --git a/packages/extension/package.json b/packages/extension/package.json index 9581e360..2dd7d9ff 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -251,6 +251,12 @@ "default": "", "description": "Override the session database path. The value is injected as OPENCODE_DB into the spawned server process. Empty = opencode uses its XDG default (~/.local/share/opencode/opencode.db)." }, + "amicode.sessionRecapWindowDays": { + "type": "number", + "default": 7, + "minimum": 1, + "description": "How many days of recent sessions to show in the context prompt. Injected as AMICODE_SESSION_RECAP_WINDOW_DAYS." + }, "amicode.configDir": { "type": "string", "default": "", diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index f9cd058c..ff643e66 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -316,8 +316,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const cfg = vscode.workspace.getConfiguration("amicode"); const sessionDb = cfg.get("sessionDatabase", ""); const configDirOverride = cfg.get("configDir", ""); + const recapWindow = cfg.get("sessionRecapWindowDays", 0); if (sessionDb) env.OPENCODE_DB = sessionDb; if (configDirOverride) env.OPENCODE_CONFIG_DIR = configDirOverride; + if (recapWindow > 0) env.AMICODE_SESSION_RECAP_WINDOW_DAYS = String(recapWindow); return (currentSpawnEnv = env); }; diff --git a/packages/extension/test/session_recap.test.ts b/packages/extension/test/session_recap.test.ts index f312d383..a1d0ba06 100644 --- a/packages/extension/test/session_recap.test.ts +++ b/packages/extension/test/session_recap.test.ts @@ -19,6 +19,8 @@ import { readCachedRecap, writeCachedRecap, buildRecentSessionsBlock, + resolveWindowDays, + RECAP_WINDOW_DAYS, NOISE_TITLE_PREFIXES, MIN_ASSISTANT_MESSAGES, MAX_RECAPS, @@ -167,7 +169,7 @@ describe("composeRecapText — recap string composition", () => { // ── composeMarkdown ────────────────────────────────────────────────────────── describe("composeMarkdown — final prompt section composition", () => { - it("starts with the heading", () => { + it("starts with the heading (default window)", () => { const recaps: SessionRecap[] = [{ session_id: "ses_1", title: "Test", @@ -179,6 +181,18 @@ describe("composeMarkdown — final prompt section composition", () => { expect(md.startsWith("## Recent sessions (last 7 days)")).toBe(true); }); + it("heading reflects custom windowDays parameter", () => { + const recaps: SessionRecap[] = [{ + session_id: "ses_1", + title: "Test", + created: "2026-08-23T10:30:00.000Z", + recap: "Did some stuff", + summarized_at: "2026-08-23T14:00:00.000Z", + }]; + const md = composeMarkdown(recaps, 14); + expect(md.startsWith("## Recent sessions (last 14 days)")).toBe(true); + }); + it("renders date and time for each entry", () => { const recaps: SessionRecap[] = [{ session_id: "ses_1", @@ -278,6 +292,62 @@ describe("cache — read/write SessionRecap to disk", () => { }); }); +// ── resolveWindowDays — env-configurable window ────────────────────────────── + +describe("resolveWindowDays — environment override of RECAP_WINDOW_DAYS", () => { + const origEnv = process.env.AMICODE_SESSION_RECAP_WINDOW_DAYS; + + afterEach(() => { + if (origEnv === undefined) delete process.env.AMICODE_SESSION_RECAP_WINDOW_DAYS; + else process.env.AMICODE_SESSION_RECAP_WINDOW_DAYS = origEnv; + }); + + it("returns default (7) when env is unset", () => { + delete process.env.AMICODE_SESSION_RECAP_WINDOW_DAYS; + expect(resolveWindowDays()).toBe(RECAP_WINDOW_DAYS); + }); + + it("returns default when env is empty string", () => { + process.env.AMICODE_SESSION_RECAP_WINDOW_DAYS = ""; + expect(resolveWindowDays()).toBe(RECAP_WINDOW_DAYS); + }); + + it("returns default when env is whitespace", () => { + process.env.AMICODE_SESSION_RECAP_WINDOW_DAYS = " "; + expect(resolveWindowDays()).toBe(RECAP_WINDOW_DAYS); + }); + + it("parses a valid integer", () => { + process.env.AMICODE_SESSION_RECAP_WINDOW_DAYS = "14"; + expect(resolveWindowDays()).toBe(14); + }); + + it("parses a valid float (fractional days)", () => { + process.env.AMICODE_SESSION_RECAP_WINDOW_DAYS = "3.5"; + expect(resolveWindowDays()).toBe(3.5); + }); + + it("returns default for zero", () => { + process.env.AMICODE_SESSION_RECAP_WINDOW_DAYS = "0"; + expect(resolveWindowDays()).toBe(RECAP_WINDOW_DAYS); + }); + + it("returns default for negative values", () => { + process.env.AMICODE_SESSION_RECAP_WINDOW_DAYS = "-5"; + expect(resolveWindowDays()).toBe(RECAP_WINDOW_DAYS); + }); + + it("returns default for NaN strings", () => { + process.env.AMICODE_SESSION_RECAP_WINDOW_DAYS = "abc"; + expect(resolveWindowDays()).toBe(RECAP_WINDOW_DAYS); + }); + + it("returns default for Infinity", () => { + process.env.AMICODE_SESSION_RECAP_WINDOW_DAYS = "Infinity"; + expect(resolveWindowDays()).toBe(RECAP_WINDOW_DAYS); + }); +}); + // ── buildRecentSessionsBlock graceful degradation ──────────────────────────── describe("buildRecentSessionsBlock — graceful degradation under Node (no bun:sqlite)", () => { From d5f1ae51b6b7e2d9046f3e6f04b95b66d2f69e4b Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 24 Aug 2026 15:15:17 -0400 Subject: [PATCH 2/2] feat(settings): surface session recap window in Data & Storage panel Adds a 'Session recap window' number input to the settings dialog's Data & Storage section, alongside Session database and Config directory. - settings.tsx: adds recapWindowDays to the storage type + accessor - data-storage-controller.ts: pipes the value in query/update messages - data-storage.tsx: renders a number input row (min 1) - chat_bridge.ts: sends default (7) on query, writes VS Code setting on update - en.ts: title + description strings --- .../settings-v2/data-storage-controller.ts | 8 +++++++ .../components/settings-v2/data-storage.tsx | 21 +++++++++++++++++++ .../packages/app/src/context/settings.tsx | 6 ++++++ .../overlay/packages/app/src/i18n/en.ts | 3 +++ packages/extension/src/chat_bridge.ts | 10 +++++++++ 5 files changed, 48 insertions(+) diff --git a/packages/app-bundle/overlay/packages/app/src/components/settings-v2/data-storage-controller.ts b/packages/app-bundle/overlay/packages/app/src/components/settings-v2/data-storage-controller.ts index f23ff832..f090147d 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/settings-v2/data-storage-controller.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/settings-v2/data-storage-controller.ts @@ -13,6 +13,7 @@ export interface DataStorageStatus { export interface DataStorageDefaults { databasePath: string configDir: string + recapWindowDays: number } export function createDataStorageController() { @@ -43,6 +44,7 @@ export function createDataStorageController() { setDefaults({ databasePath: typeof d.databasePath === "string" ? d.databasePath : "", configDir: typeof d.configDir === "string" ? d.configDir : "", + recapWindowDays: typeof d.recapWindowDays === "number" ? d.recapWindowDays : 7, }) } @@ -74,6 +76,7 @@ export function createDataStorageController() { kind: "data-storage-update", databasePath: settings.storage.databasePath(), configDir: settings.storage.configDir(), + recapWindowDays: settings.storage.recapWindowDays(), }, "*", ) @@ -88,9 +91,14 @@ export function createDataStorageController() { setConfigDir: (value: string) => { settings.storage.setConfigDir(value) }, + recapWindowDays: settings.storage.recapWindowDays, + setRecapWindowDays: (value: number) => { + settings.storage.setRecapWindowDays(value) + }, /** Trigger validation + apply on blur */ commitDatabasePath: () => sendUpdate(), commitConfigDir: () => sendUpdate(), + commitRecapWindowDays: () => sendUpdate(), status, defaults, pending, diff --git a/packages/app-bundle/overlay/packages/app/src/components/settings-v2/data-storage.tsx b/packages/app-bundle/overlay/packages/app/src/components/settings-v2/data-storage.tsx index 430e3a3d..63d7ceb4 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/settings-v2/data-storage.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/settings-v2/data-storage.tsx @@ -79,6 +79,27 @@ const DataStorageContent: Component<{ controller: DataStorageController }> = (pr /> + + +
+ { + const v = Number(event.currentTarget.value) + if (Number.isFinite(v) && v >= 1) props.controller.setRecapWindowDays(v) + }} + onBlur={() => props.controller.commitRecapWindowDays()} + placeholder={String(props.controller.defaults()?.recapWindowDays ?? 7)} + aria-label={language.t("settings.general.row.dataStorage.recapWindow.title")} + /> +
+
) } diff --git a/packages/app-bundle/overlay/packages/app/src/context/settings.tsx b/packages/app-bundle/overlay/packages/app/src/context/settings.tsx index 299cbc27..f9899484 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/settings.tsx +++ b/packages/app-bundle/overlay/packages/app/src/context/settings.tsx @@ -49,6 +49,7 @@ export interface Settings { storage: { databasePath: string configDir: string + recapWindowDays: number } developer: { enabled: boolean @@ -214,6 +215,7 @@ const defaultSettings: Settings = { storage: { databasePath: "", configDir: "", + recapWindowDays: 7, }, developer: { enabled: false, @@ -560,6 +562,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setConfigDir(value: string) { setStore("storage", "configDir", value) }, + recapWindowDays: withFallback(() => store.storage?.recapWindowDays, defaultSettings.storage.recapWindowDays), + setRecapWindowDays(value: number) { + setStore("storage", "recapWindowDays", value) + }, }, developer: { enabled: withFallback(() => store.developer?.enabled, defaultSettings.developer.enabled), diff --git a/packages/app-bundle/overlay/packages/app/src/i18n/en.ts b/packages/app-bundle/overlay/packages/app/src/i18n/en.ts index a12f75f8..897b66bd 100644 --- a/packages/app-bundle/overlay/packages/app/src/i18n/en.ts +++ b/packages/app-bundle/overlay/packages/app/src/i18n/en.ts @@ -1046,6 +1046,9 @@ export const dict = { "settings.general.row.dataStorage.configDir.description": "Override the opencode configuration directory. Leave empty to use the default location.", "settings.general.row.dataStorage.configDir.error.invalidPath": "Directory does not exist", + "settings.general.row.dataStorage.recapWindow.title": "Session recap window", + "settings.general.row.dataStorage.recapWindow.description": + "How many days of recent sessions to include in the context prompt. Requires a server restart to take effect.", "settings.general.row.releaseNotes.title": "Release notes", "settings.general.row.releaseNotes.description": "Show What's New popups after updates", diff --git a/packages/extension/src/chat_bridge.ts b/packages/extension/src/chat_bridge.ts index d225030b..d6b3411b 100644 --- a/packages/extension/src/chat_bridge.ts +++ b/packages/extension/src/chat_bridge.ts @@ -727,6 +727,7 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean kind: "data-storage-defaults", databasePath: shorten(defaultDbPath), configDir: shorten(defaultConfigDir), + recapWindowDays: 7, tab: msg.tab, }); return true; @@ -739,6 +740,9 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean const configDir = typeof (msg as { configDir?: unknown }).configDir === "string" ? (msg as unknown as { configDir: string }).configDir.trim().replace(/^~/, os.homedir()) : ""; + const recapWindowDays = typeof (msg as { recapWindowDays?: unknown }).recapWindowDays === "number" + ? (msg as unknown as { recapWindowDays: number }).recapWindowDays + : 7; const reply: { source: "amicode"; kind: "data-storage-status"; tab?: string; @@ -804,6 +808,12 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean "configDir", configDir, vscode.ConfigurationTarget.Global, ); } + // Recap window: always valid (clamped to >= 1 on the client side) + if (recapWindowDays >= 1) { + void vscode.workspace.getConfiguration("amicode").update( + "sessionRecapWindowDays", recapWindowDays, vscode.ConfigurationTarget.Global, + ); + } // Restart the server so it picks up the new env vars if (reply.databaseValid && reply.configValid) {