diff --git a/README.md b/README.md index 087a926..560f1e4 100644 --- a/README.md +++ b/README.md @@ -85,11 +85,13 @@ OpenCode Working Memory adds durable memory without making extra LLM/API calls. ▼ ┌──────────────────────────────────────┐ │ ⚡ Prompt Context │ -│ system[1]: frozen workspace memory │ -│ system[2+]: hot session state │ +│ system[1]*: frozen workspace memory │ +│ system[2+]*: hot session state │ └──────────────────────────────────────┘ ``` +\* Conceptually, workspace memory is pushed first when it is non-empty, and hot session state is pushed after workspace memory. If workspace memory is empty, hot state may be the first plugin-added system message. Actual `system[]` indices also depend on OpenCode and other plugins, so `system[1]` / `system[2+]` is a simplified model. + **Zero extra API calls:** OpenCode Working Memory does not call the model on its own. Memory extraction is folded into OpenCode's built-in compaction request. **Cache-friendly layout:** durable workspace memory is rendered as a stable frozen snapshot for the session, while fast-changing hot session state is appended separately. Compaction starts a new cache epoch, refreshing the workspace snapshot after pending memories are promoted. diff --git a/docs/configuration.md b/docs/configuration.md index f77ca61..9610ad2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -87,16 +87,33 @@ const HOT_STATE_LIMITS = { ## Active File Scoring -Files are ranked by action type: +Files are ranked by action type plus repeated access count: | Action | Weight | Description | |--------|--------|-------------| -| `write` | 4 | File created/overwritten | -| `edit` | 3 | File modified | -| `read` | 2 | File read | -| `grep` | 1 | Grep searched in file | +| `edit` | 50 | File modified | +| `write` | 45 | File created/overwritten | +| `grep` | 30 | Grep searched in file | +| `read` | 20 | File read | -Score formula: `count * action_weight * recency_decay` +Score formula: `ACTION_WEIGHT[action] + count * 3` + +When scores tie, the most recent `lastSeen` timestamp sorts first. There is no recency decay factor in the score itself. + +## Hot Session State Truncation + +Hot state rendering applies section caps before the character budget: + +| Section | Rendered cap | +|---------|--------------| +| `active_files` | 8 | +| `open_errors` | 3 | +| `recent_decisions` | 8 | +| `pending_memories` | 6 | + +After section caps, the renderer applies the 700-character budget with whole-line inclusion only. The prefix line is counted against the budget, and a section heading is emitted only when at least one entry from that section fits; header-only sections are suppressed. + +`accountHotSessionStateRender()` returns prompt and omission accounting for future diagnostics. Evidence event integration for hot-state render omissions is planned for v2. ## Error Categories diff --git a/src/session-state.ts b/src/session-state.ts index c168459..5a8f867 100644 --- a/src/session-state.ts +++ b/src/session-state.ts @@ -189,49 +189,150 @@ export function clearErrorsForSuccessfulCommand(state: SessionState, command: st state.updatedAt = new Date().toISOString(); } +export type HotStateSection = "active_files" | "open_errors" | "recent_decisions" | "pending_memories"; + +export type HotStateOmissionReason = "section_cap" | "char_budget"; + +export type HotStateOmittedItem = { + section: HotStateSection; + reason: HotStateOmissionReason; + text: string; + memoryId?: string; +}; + +export type HotSessionStateRenderAccounting = { + prompt: string; + omitted: HotStateOmittedItem[]; + maxRenderedChars: number; +}; + +type HotStateRenderItem = { + section: HotStateSection; + line: string; + memoryId?: string; +}; + +type HotStateRenderSection = { + heading: `${HotStateSection}:`; + items: HotStateRenderItem[]; +}; + +const HOT_STATE_PREFIX = "Hot session state (current session):"; + +export function accountHotSessionStateRender(state: SessionState, workspaceRoot: string): HotSessionStateRenderAccounting { + const maxRenderedChars = HOT_STATE_LIMITS.maxRenderedChars; + const omitted: HotStateOmittedItem[] = []; + const sections = buildHotStateRenderSections(state, workspaceRoot, omitted); + + if (sections.every(section => section.items.length === 0)) { + return { prompt: "", omitted, maxRenderedChars }; + } + + const lines: string[] = [HOT_STATE_PREFIX]; + let renderedEntryCount = 0; + + for (const section of sections) { + let headingRendered = false; + + for (const item of section.items) { + if (headingRendered) { + if (wouldFit(lines, item.line, maxRenderedChars)) { + lines.push(item.line); + renderedEntryCount += 1; + } else { + omitted.push(omitHotStateItem(item, "char_budget")); + } + continue; + } + + if (wouldFit(lines, section.heading, maxRenderedChars) + && wouldFit([...lines, section.heading], item.line, maxRenderedChars)) { + lines.push(section.heading, item.line); + headingRendered = true; + renderedEntryCount += 1; + } else { + omitted.push(omitHotStateItem(item, "char_budget")); + } + } + } + + if (renderedEntryCount === 0) return { prompt: "", omitted, maxRenderedChars }; + + return { prompt: lines.join("\n"), omitted, maxRenderedChars }; +} + export function renderHotSessionState(state: SessionState, workspaceRoot: string): string { - const activeFiles = rankActiveFiles(state.activeFiles).slice(0, HOT_STATE_LIMITS.maxActiveFilesRendered); + return accountHotSessionStateRender(state, workspaceRoot).prompt; +} + +function buildHotStateRenderSections( + state: SessionState, + workspaceRoot: string, + omitted: HotStateOmittedItem[], +): HotStateRenderSection[] { + const activeFiles = rankActiveFiles(state.activeFiles).map(item => ({ + section: "active_files" as const, + line: `- ${displayPath(workspaceRoot, item.path)} (${item.action}, ${item.count}x)`, + })); const openErrors = [...state.openErrors] .sort((a, b) => b.lastSeen - a.lastSeen) - .slice(0, HOT_STATE_LIMITS.maxOpenErrorsRendered); - const decisions = state.recentDecisions.slice(-HOT_STATE_LIMITS.maxRecentDecisionsStored); - const pendingMemories = dedupePendingMemories(state.pendingMemories) - .slice(-HOT_STATE_LIMITS.maxPendingMemoriesRendered); + .map(err => ({ + section: "open_errors" as const, + line: `- [${err.category}] ${err.summary}`, + })); + const decisions = state.recentDecisions.map(item => ({ + section: "recent_decisions" as const, + line: `- ${item.text}`, + })); + const pendingMemories = dedupePendingMemories(state.pendingMemories).map(item => ({ + section: "pending_memories" as const, + line: `- [${item.type}] ${item.text}`, + memoryId: item.id, + })); - if (activeFiles.length === 0 && openErrors.length === 0 && decisions.length === 0 && pendingMemories.length === 0) return ""; + const renderedActiveFiles = capHotStateItems(activeFiles, HOT_STATE_LIMITS.maxActiveFilesRendered, "start", omitted); + const renderedOpenErrors = capHotStateItems(openErrors, HOT_STATE_LIMITS.maxOpenErrorsRendered, "start", omitted); + const renderedDecisions = capHotStateItems(decisions, HOT_STATE_LIMITS.maxRecentDecisionsStored, "end", omitted); + const renderedPendingMemories = capHotStateItems( + pendingMemories, + HOT_STATE_LIMITS.maxPendingMemoriesRendered, + "end", + omitted, + ); - const lines: string[] = ["Hot session state (current session):"]; + return [ + { heading: "active_files:", items: renderedActiveFiles }, + { heading: "open_errors:", items: renderedOpenErrors }, + { heading: "recent_decisions:", items: renderedDecisions }, + { heading: "pending_memories:", items: renderedPendingMemories }, + ]; +} - if (activeFiles.length > 0) { - lines.push("active_files:"); - for (const item of activeFiles) { - const viewPath = displayPath(workspaceRoot, item.path); - lines.push(`- ${viewPath} (${item.action}, ${item.count}x)`); - } - } +function capHotStateItems( + items: HotStateRenderItem[], + cap: number, + keep: "start" | "end", + omitted: HotStateOmittedItem[], +): HotStateRenderItem[] { + if (items.length <= cap) return items; - if (openErrors.length > 0) { - lines.push("open_errors:"); - for (const err of openErrors) { - lines.push(`- [${err.category}] ${err.summary}`); - } - } + const renderedItems = keep === "start" ? items.slice(0, cap) : items.slice(-cap); + const omittedItems = keep === "start" ? items.slice(cap) : items.slice(0, items.length - cap); + omitted.push(...omittedItems.map(item => omitHotStateItem(item, "section_cap"))); + return renderedItems; +} - if (decisions.length > 0) { - lines.push("recent_decisions:"); - for (const decision of decisions) { - lines.push(`- ${decision.text}`); - } - } +function omitHotStateItem(item: HotStateRenderItem, reason: HotStateOmissionReason): HotStateOmittedItem { + return { + section: item.section, + reason, + text: item.line, + ...(item.memoryId ? { memoryId: item.memoryId } : {}), + }; +} - if (pendingMemories.length > 0) { - lines.push("pending_memories:"); - for (const memory of pendingMemories) { - lines.push(`- [${memory.type}] ${memory.text}`); - } - } - - return lines.join("\n").slice(0, HOT_STATE_LIMITS.maxRenderedChars); +function wouldFit(lines: string[], nextLine: string, maxRenderedChars: number): boolean { + return [...lines, nextLine].join("\n").length <= maxRenderedChars; } function rankActiveFiles(activeFiles: ActiveFile[]): ActiveFile[] { diff --git a/tests/session-state.test.ts b/tests/session-state.test.ts new file mode 100644 index 0000000..f6f07a0 --- /dev/null +++ b/tests/session-state.test.ts @@ -0,0 +1,210 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as sessionStateModule from "../src/session-state.ts"; +import type { HotSessionStateRenderAccounting } from "../src/session-state.ts"; +import type { ActiveFile, LongTermMemoryEntry, OpenError, SessionDecision, SessionState } from "../src/types.ts"; +import { HOT_STATE_LIMITS } from "../src/types.ts"; + +type AccountHotSessionStateRender = (state: SessionState, workspaceRoot: string) => HotSessionStateRenderAccounting; + +const accountHotSessionStateRender = ( + sessionStateModule as typeof sessionStateModule & { accountHotSessionStateRender: AccountHotSessionStateRender } +).accountHotSessionStateRender; + +const { createEmptySessionState, renderHotSessionState } = sessionStateModule; + +const root = "/repo"; + +function state(overrides: Partial = {}): SessionState { + return { + version: 1, + sessionID: "session-state-test", + turn: 0, + updatedAt: "2026-05-05T00:00:00.000Z", + activeFiles: [], + openErrors: [], + recentDecisions: [], + pendingMemories: [], + ...overrides, + }; +} + +function activeFile(path: string, action: ActiveFile["action"], count: number, lastSeen: number): ActiveFile { + return { path, action, count, lastSeen }; +} + +function openError(id: string, summary: string, lastSeen: number): OpenError { + return { + id, + category: "test", + summary, + fingerprint: `fingerprint-${id}`, + status: "open", + firstSeen: lastSeen - 1, + lastSeen, + seenCount: 1, + }; +} + +function decision(id: string, text: string, createdAt: number): SessionDecision { + return { id, text, source: "assistant", createdAt }; +} + +function memory(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry { + return { + id, + type, + text, + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: "2026-05-05T00:00:00.000Z", + updatedAt: "2026-05-05T00:00:00.000Z", + }; +} + +test("accountHotSessionStateRender returns empty prompt and no omissions for empty state", () => { + const accounting = accountHotSessionStateRender(createEmptySessionState("empty-session"), root); + + assert.equal(accounting.prompt, ""); + assert.deepEqual(accounting.omitted, []); + assert.equal(accounting.maxRenderedChars, HOT_STATE_LIMITS.maxRenderedChars); +}); + +test("accountHotSessionStateRender renders hot-state sections in stable order", () => { + const accounting = accountHotSessionStateRender(state({ + activeFiles: [activeFile("/repo/src/a.ts", "read", 1, 1)], + openErrors: [openError("err-1", "one failing test", 2)], + recentDecisions: [decision("dec-1", "Keep renderer simple", 3)], + pendingMemories: [memory("mem-1", "Promote useful fact")], + }), root); + + assert.ok(accounting.prompt.startsWith("Hot session state (current session):")); + assert.ok(accounting.prompt.indexOf("active_files:") < accounting.prompt.indexOf("open_errors:")); + assert.ok(accounting.prompt.indexOf("open_errors:") < accounting.prompt.indexOf("recent_decisions:")); + assert.ok(accounting.prompt.indexOf("recent_decisions:") < accounting.prompt.indexOf("pending_memories:")); +}); + +test("accountHotSessionStateRender ranks active files by score then lastSeen descending", () => { + const accounting = accountHotSessionStateRender(state({ + activeFiles: [ + activeFile("/repo/src/edit.ts", "edit", 1, 400), + activeFile("/repo/src/write.ts", "write", 5, 200), + activeFile("/repo/src/grep.ts", "grep", 10, 300), + activeFile("/repo/src/read.ts", "read", 12, 100), + ], + }), root); + + const lines = accounting.prompt.split("\n"); + const activeLines = lines.filter(line => line.startsWith("- src/")); + assert.deepEqual(activeLines, [ + "- src/grep.ts (grep, 10x)", + "- src/write.ts (write, 5x)", + "- src/read.ts (read, 12x)", + "- src/edit.ts (edit, 1x)", + ]); +}); + +test("accountHotSessionStateRender reports section-cap omissions for every capped section", () => { + const accounting = accountHotSessionStateRender(state({ + activeFiles: Array.from({ length: 9 }, (_, index) => activeFile(`/repo/a${index}.ts`, "read", 1, 100 - index)), + openErrors: Array.from({ length: 4 }, (_, index) => openError(`err-${index}`, `e${index}`, 100 - index)), + recentDecisions: Array.from({ length: 9 }, (_, index) => decision(`dec-${index}`, `d${index}`, index)), + pendingMemories: Array.from({ length: 7 }, (_, index) => memory(`mem-${index}`, `m${index}`)), + }), root); + + const sectionCapOmissions = accounting.omitted.filter(item => item.reason === "section_cap"); + assert.deepEqual( + sectionCapOmissions.map(item => item.section).sort(), + ["active_files", "open_errors", "pending_memories", "recent_decisions"].sort(), + ); + assert.equal(sectionCapOmissions.find(item => item.section === "pending_memories")?.memoryId, "mem-0"); +}); + +test("accountHotSessionStateRender omits over-budget entries without cutting rendered lines", () => { + const longPath = `/repo/${"x".repeat(650)}.ts`; + const accounting = accountHotSessionStateRender(state({ + activeFiles: [ + activeFile("/repo/src/short.ts", "read", 1, 20), + activeFile(longPath, "read", 1, 10), + ], + }), root); + + assert.equal(accounting.prompt, [ + "Hot session state (current session):", + "active_files:", + "- src/short.ts (read, 1x)", + ].join("\n")); + assert.equal(accounting.omitted.length, 1); + assert.equal(accounting.omitted[0]?.reason, "char_budget"); + assert.equal(accounting.omitted[0]?.section, "active_files"); + assert.ok(!accounting.prompt.includes("x".repeat(20))); +}); + +test("accountHotSessionStateRender includes exact 700-char prompt but omits one additional character", () => { + const fixedPrompt = [ + "Hot session state (current session):", + "pending_memories:", + "- [decision] ", + ].join("\n"); + const exactText = "x".repeat(HOT_STATE_LIMITS.maxRenderedChars - fixedPrompt.length); + const exactAccounting = accountHotSessionStateRender(state({ + pendingMemories: [memory("mem-exact", exactText)], + }), root); + + assert.equal(exactAccounting.prompt.length, HOT_STATE_LIMITS.maxRenderedChars); + assert.equal(exactAccounting.omitted.length, 0); + + const overAccounting = accountHotSessionStateRender(state({ + pendingMemories: [memory("mem-over", `${exactText}y`)], + }), root); + + assert.equal(overAccounting.prompt, ""); + assert.equal(overAccounting.omitted.length, 1); + assert.equal(overAccounting.omitted[0]?.reason, "char_budget"); + assert.equal(overAccounting.omitted[0]?.memoryId, "mem-over"); +}); + +test("accountHotSessionStateRender suppresses header-only sections when no entries fit", () => { + const accounting = accountHotSessionStateRender(state({ + activeFiles: [activeFile(`/repo/${"z".repeat(720)}.ts`, "read", 1, 1)], + }), root); + + assert.equal(accounting.prompt, ""); + assert.equal(accounting.omitted.length, 1); + assert.equal(accounting.omitted[0]?.reason, "char_budget"); + assert.ok(!accounting.prompt.includes("active_files:")); +}); + +test("renderHotSessionState delegates to accounted renderer prompt for empty and seeded states", () => { + const empty = createEmptySessionState("compat-empty"); + const seeded = state({ + activeFiles: [activeFile("/repo/src/a.ts", "edit", 2, 1)], + pendingMemories: [memory("mem-compat", "Compatibility prompt")], + }); + + assert.equal(renderHotSessionState(empty, root), accountHotSessionStateRender(empty, root).prompt); + assert.equal(renderHotSessionState(seeded, root), accountHotSessionStateRender(seeded, root).prompt); +}); + +test("accountHotSessionStateRender counts newline separators in the 700-char budget", () => { + const fixedPrompt = [ + "Hot session state (current session):", + "recent_decisions:", + "- ", + ].join("\n"); + const exactText = "n".repeat(HOT_STATE_LIMITS.maxRenderedChars - fixedPrompt.length); + + const exactAccounting = accountHotSessionStateRender(state({ + recentDecisions: [decision("dec-exact", exactText, 1)], + }), root); + assert.equal(exactAccounting.prompt.length, HOT_STATE_LIMITS.maxRenderedChars); + assert.equal(exactAccounting.omitted.length, 0); + + const overAccounting = accountHotSessionStateRender(state({ + recentDecisions: [decision("dec-over", `${exactText}!`, 1)], + }), root); + assert.equal(overAccounting.prompt, ""); + assert.equal(overAccounting.omitted.length, 1); + assert.equal(overAccounting.omitted[0]?.reason, "char_budget"); +});