From 1bba0511bb6f14312f190012b8008a54d35449d4 Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Sun, 26 Apr 2026 12:52:21 +0800 Subject: [PATCH] fix: PR-1 memory plugin quality fixes ## Task 1: Fix exitCode undefined false positive - Add `typeof exitCode !== "number"` check in plugin.ts - Only extract errors when exitCode is explicitly non-zero - Prevent git-log/cat with "errors" text from creating false positives ## Task 2: Fix workspace memory XML truncation - Budget-aware line-by-line rendering - Always include closing tag - Return empty string when budget too small - Bonus: canonical exact deduplication with source priority ## Task 3: Remove "always" as trigger - Replace "always" with "going forward" in patterns - Add word boundary via `g` flag and matchAll loop - "from now on" still works as expected ## Task 4: Verification - 22 tests passing - typecheck passing Tests cover: - git log/cat with loose "errors" ignored - TS2345/TypeError strong signals captured - undefined exitCode: no create, no clear - exitCode 0: clears errors - exitCode non-zero: creates error - XML never truncated mid-tag - "always" not a trigger --- index.ts | 7 +- src/extractors.ts | 167 +++++++++++++++ src/opencode.ts | 177 ++++++++++++++++ src/paths.ts | 26 +++ src/plugin.ts | 374 +++++++++++++++++++++++++++++++++ src/session-state.ts | 240 +++++++++++++++++++++ src/storage.ts | 49 +++++ src/types.ts | 88 ++++++++ src/workspace-memory.ts | 174 +++++++++++++++ tests/extractors.test.ts | 87 ++++++++ tests/plugin.test.ts | 195 +++++++++++++++++ tests/workspace-memory.test.ts | 210 ++++++++++++++++++ tsconfig.json | 6 +- 13 files changed, 1793 insertions(+), 7 deletions(-) create mode 100644 src/extractors.ts create mode 100644 src/opencode.ts create mode 100644 src/paths.ts create mode 100644 src/plugin.ts create mode 100644 src/session-state.ts create mode 100644 src/storage.ts create mode 100644 src/types.ts create mode 100644 src/workspace-memory.ts create mode 100644 tests/extractors.test.ts create mode 100644 tests/plugin.test.ts create mode 100644 tests/workspace-memory.test.ts diff --git a/index.ts b/index.ts index 78edf0e..cac6641 100644 --- a/index.ts +++ b/index.ts @@ -1,9 +1,6 @@ -import type { PluginModule } from "@opencode-ai/plugin"; import { MemoryV2Plugin } from "./src/plugin.ts"; -const plugin: PluginModule = { +export default { id: "working-memory", server: MemoryV2Plugin, -}; - -export default plugin; \ No newline at end of file +}; \ No newline at end of file diff --git a/src/extractors.ts b/src/extractors.ts new file mode 100644 index 0000000..66fedfb --- /dev/null +++ b/src/extractors.ts @@ -0,0 +1,167 @@ +import { createHash } from "crypto"; +import type { ActiveFile, LongTermMemoryEntry, LongTermType, OpenError } from "./types.ts"; +import { LONG_TERM_LIMITS } from "./types.ts"; + +function id(prefix: string): string { + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} + +function hash(value: string): string { + return createHash("sha1").update(value).digest("hex").slice(0, 12); +} + +export function extractExplicitMemories(text: string): LongTermMemoryEntry[] { + const patterns = [ + // 注意:所有pattern必須有 g flag,因為使用 matchAll() + /(?:请记住|記住|记住这一点|remember this|commit to memory)[::]?\s*(.+)$/gim, + /(?:从现在开始|從現在開始|从今以后|從今以後|from now on|going forward)[::,,]?\s*(.+)$/gim, + ]; + + const now = new Date().toISOString(); + const entries: LongTermMemoryEntry[] = []; + + for (const pattern of patterns) { + for (const match of text.matchAll(pattern)) { + const body = match[1]?.trim(); + if (!body || body.length < 8) continue; + if (/^(再说|再說|later|next time)$/i.test(body)) continue; + + const type = classifyExplicitMemory(body); + entries.push({ + id: id("mem"), + type, + text: body.slice(0, LONG_TERM_LIMITS.maxEntryTextChars), + source: "explicit", + confidence: 1, + status: "active", + createdAt: now, + updatedAt: now, + staleAfterDays: staleAfterDaysFor(type), + }); + } + } + + return entries; +} + +function classifyExplicitMemory(text: string): LongTermType { + const lower = text.toLowerCase(); + if (/https?:\/\/|linear|slack|notion|dashboard|grafana/.test(lower)) return "reference"; + if (/decide|decision|choose|chosen|决定|決定|选择|選擇/.test(lower)) return "decision"; + if (/project|repo|项目|專案/.test(lower)) return "project"; + return "feedback"; +} + +export function staleAfterDaysFor(type: LongTermType): number | undefined { + if (type === "feedback") return undefined; + if (type === "decision") return 45; + if (type === "project") return 60; + return 90; +} + +export function extractActiveFiles( + toolName: string, + args: Record, + output: string, +): Array<{ path: string; action: ActiveFile["action"] }> { + if (toolName === "read" && typeof args.filePath === "string") return [{ path: args.filePath, action: "read" }]; + if (toolName === "edit" && typeof args.filePath === "string") return [{ path: args.filePath, action: "edit" }]; + if (toolName === "write" && typeof args.filePath === "string") return [{ path: args.filePath, action: "write" }]; + if (toolName === "grep") return extractGrepPaths(output).map(path => ({ path, action: "grep" as const })); + return []; +} + +function extractGrepPaths(output: string): string[] { + const matches = output.match(/^(\/[^\n]+\.(ts|tsx|js|jsx|json|md|py|go|rs|toml|yml|yaml)):/gm) ?? []; + return [...new Set(matches.map(match => match.replace(/:$/, "")))].slice(0, 10); +} + +function isErrorLine(line: string, knownValidationCommand: boolean): boolean { + // 無條件捕捉的強訊號 + if (/TS\d{4}|ERR!|Traceback \(most recent call last\):|panic:/i.test(line)) return true; + + // Error 類型前綴(獨立行) + if (/^\s*(Error|TypeError|ReferenceError|SyntaxError|Exception):/i.test(line)) { + return true; + } + + // 已知 validation command 才用寬鬆匹配 + if (knownValidationCommand) { + return /\b(error|failed|failure|exception)\b/i.test(line); + } + + return false; +} + +export function extractErrorsFromBash(command: string, output: string): OpenError[] { + const classifiedCategory = classifyCommand(command); + const knownValidationCommand = classifiedCategory !== null; + + const lines = output + .split("\n") + .filter(line => isErrorLine(line, knownValidationCommand)) + .slice(0, 5); + if (lines.length === 0) return []; + + const category = classifiedCategory ?? "runtime"; + const summary = lines.join(" ").slice(0, 280); + const fingerprint = hash(`${category}:${summary.toLowerCase().replace(/\s+/g, " ")}`); + const now = Date.now(); + + return [ + { + id: `err_${fingerprint}`, + category, + summary, + command, + file: extractFirstPath(summary), + fingerprint, + status: "open", + firstSeen: now, + lastSeen: now, + seenCount: 1, + }, + ]; +} + +export function classifyCommand(command: string): OpenError["category"] | null { + const c = command.toLowerCase(); + if (/\b(tsc|typecheck)\b/.test(c)) return "typecheck"; + if (/\b(test|vitest|jest|mocha|pytest|go test|cargo test)\b/.test(c)) return "test"; + if (/\b(lint|eslint|biome)\b/.test(c)) return "lint"; + if (/\b(build|vite build|webpack|tsup)\b/.test(c)) return "build"; + return null; +} + +function extractFirstPath(text: string): string | undefined { + return text.match(/[\w./-]+\.(ts|tsx|js|jsx|json|md|py|go|rs)/)?.[0]; +} + +export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryEntry[] { + const match = summary.match(/([\s\S]*?)<\/workspace_memory_candidates>/i); + if (!match) return []; + + const now = new Date().toISOString(); + const entries: LongTermMemoryEntry[] = []; + + for (const line of match[1].split("\n")) { + const item = line.trim().match(/^-\s*\[(feedback|project|decision|reference)\]\s*(.+)$/i); + if (!item) continue; + const type = item[1].toLowerCase() as LongTermType; + const body = item[2].trim(); + if (body.length < 12) continue; + entries.push({ + id: id("mem"), + type, + text: body.slice(0, LONG_TERM_LIMITS.maxEntryTextChars), + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: now, + updatedAt: now, + staleAfterDays: staleAfterDaysFor(type), + }); + } + + return entries; +} diff --git a/src/opencode.ts b/src/opencode.ts new file mode 100644 index 0000000..a772d2f --- /dev/null +++ b/src/opencode.ts @@ -0,0 +1,177 @@ +/** + * OpenCode SDK helper functions for memory plugin. + * + * These functions wrap OpenCode client API calls to extract: + * - Latest user message text (for explicit memory extraction) + * - Latest compaction summary (for memory candidate parsing) + * - Pending todos (for compaction context) + */ + +/** + * Extract the latest user message text from a session. + * Returns { id, text } or null if no user message found. + */ +export async function latestUserText( + client: unknown, + sessionID: string +): Promise<{ id: string; text: string } | null> { + try { + // Cast client to access session.messages API + const api = client as { + session: { + messages: (params: { path: { id: string } }) => Promise<{ + data?: Array<{ + info?: { + role?: string; + id?: string; + }; + parts?: Array<{ + type?: string; + text?: string; + }>; + }>; + }>; + }; + }; + + const result = await api.session.messages({ path: { id: sessionID } }); + const messages = result.data ?? []; + + // Scan backwards from most recent messages to find the latest user message + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.info?.role !== "user") continue; + + // Concatenate all text parts + const text = (msg.parts ?? []) + .filter((p: { type?: string }) => p.type === "text") + .map((p: { text?: string }) => p.text ?? "") + .join("\n"); + + if (text.trim()) { + return { + id: msg.info?.id ?? "", + text: text.trim(), + }; + } + } + + return null; + } catch { + return null; + } +} + +/** + * Extract the latest compaction summary from a session. + * Compaction summaries are assistant messages marked with summary=true. + */ +export async function latestCompactionSummary( + client: unknown, + sessionID: string +): Promise { + try { + const api = client as { + session: { + messages: (params: { path: { id: string } }) => Promise<{ + data?: Array<{ + info?: { + role?: string; + summary?: boolean; + }; + parts?: Array<{ + type?: string; + text?: string; + }>; + }>; + }>; + }; + }; + + const result = await api.session.messages({ path: { id: sessionID } }); + const messages = result.data ?? []; + + // Scan backwards to find the most recent summary (compaction) + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.info?.role !== "assistant" || msg.info?.summary !== true) continue; + + const text = (msg.parts ?? []) + .filter((p: { type?: string }) => p.type === "text") + .map((p: { text?: string }) => p.text ?? "") + .join("\n"); + + if (text.trim()) { + return text.trim(); + } + } + + return null; + } catch { + return null; + } +} + +/** + * Fetch pending todos from a session. + * Returns todos that are not marked as completed. + */ +export async function pendingTodos( + client: unknown, + sessionID: string +): Promise> { + try { + const api = client as { + session: { + todo: (params: { path: { id: string } }) => Promise<{ + data?: Array<{ + content?: string; + status?: string; + priority?: string; + }>; + }>; + }; + }; + + const result = await api.session.todo({ path: { id: sessionID } }); + const todos = result.data ?? []; + + // Filter out completed todos + return todos + .filter((todo: { status?: string }) => todo.status !== "completed") + .map((todo: { content?: string; status?: string; priority?: string }) => ({ + content: todo.content ?? "", + status: todo.status ?? "pending", + priority: todo.priority, + })); + } catch { + return []; + } +} + +/** + * Check if a session is a sub-agent (has a parent session). + * Sub-agents are short-lived and should not have their own memory tracking. + */ +export async function isSubAgent( + client: unknown, + sessionID: string +): Promise { + try { + const api = client as { + session: { + get: (params: { path: { id: string } }) => Promise<{ + data?: { + parentID?: string | null; + }; + }>; + }; + }; + + const result = await api.session.get({ path: { id: sessionID } }); + return result.data?.parentID != null; + } catch { + // If we can't determine, assume it's NOT a sub-agent (safe default) + return false; + } +} \ No newline at end of file diff --git a/src/paths.ts b/src/paths.ts new file mode 100644 index 0000000..f9433a4 --- /dev/null +++ b/src/paths.ts @@ -0,0 +1,26 @@ +import { createHash } from "crypto"; +import { homedir } from "os"; +import { join } from "path"; +import { realpath } from "fs/promises"; + +export function dataHome(): string { + return process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"); +} + +export async function workspaceKey(root: string): Promise { + const resolved = await realpath(root).catch(() => root); + return createHash("sha256").update(resolved).digest("hex").slice(0, 16); +} + +export async function memoryRoot(root: string): Promise { + return join(dataHome(), "opencode-working-memory", "workspaces", await workspaceKey(root)); +} + +export async function workspaceMemoryPath(root: string): Promise { + return join(await memoryRoot(root), "workspace-memory.json"); +} + +export async function sessionStatePath(root: string, sessionID: string): Promise { + const safeSessionID = createHash("sha256").update(sessionID).digest("hex").slice(0, 32); + return join(await memoryRoot(root), "sessions", `${safeSessionID}.json`); +} diff --git a/src/plugin.ts b/src/plugin.ts new file mode 100644 index 0000000..3f2c9d3 --- /dev/null +++ b/src/plugin.ts @@ -0,0 +1,374 @@ +/** + * Memory V2 Plugin for OpenCode + * + * Architecture: + * - Layer 1: Stable Workspace Memory (frozen per session, refreshed at compaction) + * - Layer 2: Hot Session State (active files, open errors, recent decisions) + * - Layer 3: Native OpenCode State (todos owned by OpenCode, read during compaction) + * + * This plugin: + * - Caches frozen workspace memory per sessionID + * - Processes explicit memory from latest user text once per message id + * - Injects frozen workspace memory and dynamic hot session state into system prompt + * - Updates session state after tool execution + * - Augments compaction context with memory, hot state, todos, and instruction + * - Parses compaction summaries for memory candidates and merges them + */ + +import { rm } from "fs/promises"; +import type { Plugin } from "@opencode-ai/plugin"; +import { + extractExplicitMemories, + extractActiveFiles, + extractErrorsFromBash, + parseWorkspaceMemoryCandidates, +} from "./extractors.ts"; +import { + loadWorkspaceMemory, + updateWorkspaceMemory, + renderWorkspaceMemory, +} from "./workspace-memory.ts"; +import { + loadSessionState, + updateSessionState, + touchActiveFile, + upsertOpenError, + clearErrorsForSuccessfulCommand, + markErrorsMaybeFixedForFile, + addRecentDecision, + renderHotSessionState, +} from "./session-state.ts"; +import { sessionStatePath } from "./paths.ts"; +import { + latestUserText, + latestCompactionSummary, + pendingTodos, +} from "./opencode.ts"; + +/** + * Generate the memory candidate instruction to include in compaction context. + */ +function memoryCandidateInstruction(): string { + return ` +At the end of the compaction summary, include: + + +- [feedback] ... +- [project] ... +- [decision] ... +- [reference] ... + + +Only include durable information useful across future sessions in this exact workspace. +Do NOT include active file lists, raw errors, temporary progress, stack traces, code signatures, API docs, git history, or facts easily rediscovered from the repository. +For decisions, include rationale in one sentence. +If nothing qualifies, output an empty block. +`.trim(); +} + +/** + * Render todos for compaction context. + */ +function renderTodos(todos: Array<{ content: string; status: string; priority?: string }>): string { + if (todos.length === 0) return ""; + + const lines = [""]; + for (const todo of todos) { + const priority = todo.priority ? ` [${todo.priority}]` : ""; + lines.push(`- ${todo.content}${priority}`); + } + lines.push(""); + return lines.join("\n"); +} + +export const MemoryV2Plugin: Plugin = async (input) => { + const { directory, client } = input; + + // Cache for sub-agent detection — avoids repeated API calls per session. + // Maps sessionID → parentID (string) or null (root session). + const sessionParentCache = new Map(); + + async function isSubAgent(sessionID: string): Promise { + if (sessionParentCache.has(sessionID)) { + return sessionParentCache.get(sessionID) !== null; + } + try { + const result = await client.session.get({ path: { id: sessionID } }); + const parentID = result.data?.parentID ?? null; + sessionParentCache.set(sessionID, parentID); + return parentID !== null; + } catch { + // If we can't determine, assume it's NOT a sub-agent (safe default). + sessionParentCache.set(sessionID, null); + return false; + } + } + + // Cache for frozen workspace memory per session + const frozenWorkspaceMemoryCache = new Map< + string, + { + store: Awaited>; + loadedAt: number; + } + >(); + + // Cache for processed user message IDs (to avoid duplicate processing) + const processedUserMessages = new Map>(); + + async function processLatestUserMessage(sessionID: string): Promise { + const processedForSession = processedUserMessages.get(sessionID) ?? new Set(); + const latestMessage = await latestUserText(client, sessionID); + + if (!latestMessage?.id || processedForSession.has(latestMessage.id)) return; + + const memories = extractExplicitMemories(latestMessage.text); + const decisions = memories.filter(memory => memory.type === "decision"); + let workspaceMemory: Awaited> | undefined; + + if (memories.length > 0) { + workspaceMemory = await updateWorkspaceMemory(directory, store => { + store.entries.push(...memories); + return store; + }); + + // Update frozen cache + const cached = frozenWorkspaceMemoryCache.get(sessionID); + if (cached) { + cached.store = workspaceMemory; + } + } + + if (decisions.length > 0) { + await updateSessionState(directory, sessionID, state => { + for (const decision of decisions) { + addRecentDecision(state, { + text: decision.text, + rationale: decision.rationale, + source: "user", + }); + } + return state; + }); + } + + processedForSession.add(latestMessage.id); + processedUserMessages.set(sessionID, processedForSession); + } + + function bashExitCode(hookOutput: unknown): number | undefined { + const output = hookOutput as { + exitCode?: unknown; + metadata?: Record; + output?: string; + }; + const candidates = [ + output.exitCode, + output.metadata?.exitCode, + output.metadata?.exit_code, + output.metadata?.code, + output.metadata?.status, + ]; + for (const candidate of candidates) { + if (typeof candidate === "number") return candidate; + if (typeof candidate === "string" && /^-?\d+$/.test(candidate)) return Number(candidate); + } + const text = output.output ?? ""; + const match = text.match(/(?:exit\s*code|exitCode|status)[:=]\s*(-?\d+)/i); + return match ? Number(match[1]) : undefined; + } + + /** + * Get frozen workspace memory for a session. + * Loads from disk once per session, then caches in memory. + */ + async function getFrozenWorkspaceMemory( + root: string, + sessionID: string + ): Promise>> { + const now = Date.now(); + const cached = frozenWorkspaceMemoryCache.get(sessionID); + + // Cache is valid for the session lifetime + if (cached) { + return cached.store; + } + + const store = await loadWorkspaceMemory(root); + frozenWorkspaceMemoryCache.set(sessionID, { store, loadedAt: now }); + return store; + } + + /** + * Clear frozen workspace memory cache (e.g., after compaction). + */ + function clearFrozenWorkspaceMemoryCache(sessionID: string): void { + frozenWorkspaceMemoryCache.delete(sessionID); + } + + return { + // Inject workspace memory and hot session state into system prompt + "experimental.chat.system.transform": async (hookInput, output) => { + const { sessionID } = hookInput; + if (!sessionID) return; + + // Sub-agents are short-lived - skip memory system + if (await isSubAgent(sessionID)) return; + + // Process explicit user memory even on no-tool turns. + await processLatestUserMessage(sessionID); + + // Get frozen workspace memory (loaded once per session) + const workspaceMemory = await getFrozenWorkspaceMemory(directory, sessionID); + + // Get current hot session state + const sessionState = await loadSessionState(directory, sessionID); + + // Render and inject workspace memory + const workspacePrompt = renderWorkspaceMemory(workspaceMemory); + if (workspacePrompt) { + output.system.push(workspacePrompt); + } + + // Render and inject hot session state + const hotPrompt = renderHotSessionState(sessionState, directory); + if (hotPrompt) { + output.system.push(hotPrompt); + } + }, + + // Track tool usage and update session state + "tool.execute.after": async (hookInput, hookOutput) => { + const { sessionID, tool: toolName, args } = hookInput; + const { output: toolOutput } = hookOutput; + if (!sessionID) return; + + // Sub-agents don't need memory tracking + if (await isSubAgent(sessionID)) return; + + await updateSessionState(directory, sessionID, state => { + // Track active files from tool usage + if (toolName === "read" || toolName === "edit" || toolName === "write" || toolName === "grep") { + const files = extractActiveFiles( + toolName, + args as Record, + toolOutput ?? "" + ); + for (const { path, action } of files) { + touchActiveFile(state, path, action); + if (action === "edit" || action === "write") { + markErrorsMaybeFixedForFile(state, path, directory); + } + } + } + + // Track errors from failed bash commands + if (toolName === "bash") { + const argsRecord = args as Record; + const command: string = typeof argsRecord?.command === "string" + ? argsRecord.command + : ""; + const outputText: string = toolOutput ?? ""; + + // Check if command succeeded - clear errors for that category + const exitCode = bashExitCode(hookOutput); + if (typeof exitCode !== "number") { + // Unknown exit status: do not extract and do not clear + } else if (exitCode === 0 && command) { + clearErrorsForSuccessfulCommand(state, command); + } else if (command) { + // Only extract errors for commands with explicit non-zero exit + const errors = extractErrorsFromBash(command, outputText); + for (const error of errors) { + upsertOpenError(state, error); + } + } + } + return state; + }); + + // Process explicit memory from latest user message + // Only process once per message ID + await processLatestUserMessage(sessionID); + }, + + // Add compaction context before summarization + "experimental.session.compacting": async (hookInput, output) => { + const { sessionID } = hookInput; + if (!sessionID) return; + + // Sub-agents don't need compaction support + if (await isSubAgent(sessionID)) return; + + // Add compaction context with memory, hot state, todos, and instruction + const contextParts: string[] = []; + + // 1. Frozen workspace memory + const workspaceMemory = await getFrozenWorkspaceMemory(directory, sessionID); + const workspacePrompt = renderWorkspaceMemory(workspaceMemory); + if (workspacePrompt) { + contextParts.push(workspacePrompt); + } + + // 2. Hot session state + const sessionState = await loadSessionState(directory, sessionID); + const hotPrompt = renderHotSessionState(sessionState, directory); + if (hotPrompt) { + contextParts.push(hotPrompt); + } + + // 3. Pending todos from OpenCode + const todos = await pendingTodos(client, sessionID); + const todosPrompt = renderTodos(todos); + if (todosPrompt) { + contextParts.push(todosPrompt); + } + + // 4. Memory candidate instruction + contextParts.push(memoryCandidateInstruction()); + + // Add to compaction context (output.context is an array) + for (const part of contextParts) { + output.context.push(part); + } + }, + + // Handle session events + event: async ({ event }) => { + if (event.type === "session.compacted") { + const sessionID = (event.properties as { sessionID?: string; info?: { id?: string } })?.sessionID + ?? (event.properties as { info?: { id?: string } })?.info?.id; + if (!sessionID) return; + + // Sub-agents don't need post-compaction processing + if (await isSubAgent(sessionID)) return; + + // Parse latest compaction summary for memory candidates + const summary = await latestCompactionSummary(client, sessionID); + if (summary) { + const candidates = parseWorkspaceMemoryCandidates(summary); + if (candidates.length > 0) { + await updateWorkspaceMemory(directory, workspaceMemory => { + workspaceMemory.entries.push(...candidates); + return workspaceMemory; + }); + + // Clear frozen cache so next session reloads with new memories + clearFrozenWorkspaceMemoryCache(sessionID); + } + } + } + + if (event.type === "session.deleted") { + const sessionID = (event.properties as { info?: { id?: string } })?.info?.id; + if (sessionID) { + // Clean up caches + frozenWorkspaceMemoryCache.delete(sessionID); + processedUserMessages.delete(sessionID); + sessionParentCache.delete(sessionID); + await rm(await sessionStatePath(directory, sessionID), { force: true }); + } + } + }, + }; +} diff --git a/src/session-state.ts b/src/session-state.ts new file mode 100644 index 0000000..4cc29b2 --- /dev/null +++ b/src/session-state.ts @@ -0,0 +1,240 @@ +import { relative } from "path"; +import { sessionStatePath } from "./paths.ts"; +import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts"; +import type { ActiveFile, OpenError, SessionDecision, SessionState } from "./types.ts"; +import { HOT_STATE_LIMITS } from "./types.ts"; + +const ACTION_WEIGHT: Record = { + edit: 50, + write: 45, + grep: 30, + read: 20, +}; + +export function createEmptySessionState(sessionID: string): SessionState { + return { + version: 1, + sessionID, + turn: 0, + updatedAt: new Date().toISOString(), + activeFiles: [], + openErrors: [], + recentDecisions: [], + }; +} + +export async function loadSessionState(root: string, sessionID: string): Promise { + const fallback = createEmptySessionState(sessionID); + const loaded = await readJSON(await sessionStatePath(root, sessionID), () => fallback); + loaded.sessionID = sessionID; + loaded.activeFiles = Array.isArray(loaded.activeFiles) ? loaded.activeFiles : []; + loaded.openErrors = Array.isArray(loaded.openErrors) ? loaded.openErrors : []; + loaded.recentDecisions = Array.isArray(loaded.recentDecisions) ? loaded.recentDecisions : []; + return loaded; +} + +export async function saveSessionState(root: string, state: SessionState): Promise { + await atomicWriteJSON(await sessionStatePath(root, state.sessionID), normalizeSessionState(state)); +} + +export async function updateSessionState( + root: string, + sessionID: string, + updater: (state: SessionState) => SessionState | Promise, +): Promise { + const path = await sessionStatePath(root, sessionID); + return updateJSON(path, () => createEmptySessionState(sessionID), async current => { + current.sessionID = sessionID; + current.activeFiles = Array.isArray(current.activeFiles) ? current.activeFiles : []; + current.openErrors = Array.isArray(current.openErrors) ? current.openErrors : []; + current.recentDecisions = Array.isArray(current.recentDecisions) ? current.recentDecisions : []; + return normalizeSessionState(await updater(current)); + }); +} + +function normalizeSessionState(state: SessionState): SessionState { + state.updatedAt = new Date().toISOString(); + state.activeFiles = state.activeFiles.slice(0, HOT_STATE_LIMITS.maxActiveFilesStored); + state.openErrors = state.openErrors.slice(0, HOT_STATE_LIMITS.maxOpenErrorsStored); + state.recentDecisions = state.recentDecisions.slice(0, HOT_STATE_LIMITS.maxRecentDecisionsStored); + return state; +} + +export function touchActiveFile(state: SessionState, filePath: string, action: ActiveFile["action"]): void { + const now = Date.now(); + const existing = state.activeFiles.find(item => item.path === filePath); + + if (existing) { + existing.count += 1; + existing.lastSeen = now; + if (ACTION_WEIGHT[action] >= ACTION_WEIGHT[existing.action]) { + existing.action = action; + } + } else { + state.activeFiles.push({ + path: filePath, + action, + count: 1, + lastSeen: now, + }); + } + + state.activeFiles = rankActiveFiles(state.activeFiles).slice(0, HOT_STATE_LIMITS.maxActiveFilesStored); + state.updatedAt = new Date().toISOString(); +} + +export function upsertOpenError(state: SessionState, error: OpenError): void { + const now = Date.now(); + const existing = state.openErrors.find(item => item.fingerprint === error.fingerprint); + + if (existing) { + existing.summary = error.summary; + existing.command = error.command ?? existing.command; + existing.file = error.file ?? existing.file; + existing.lastSeen = now; + existing.status = "open"; + existing.seenCount += 1; + } else { + state.openErrors.unshift({ + ...error, + firstSeen: error.firstSeen ?? now, + lastSeen: now, + seenCount: Math.max(error.seenCount ?? 1, 1), + status: "open", + }); + } + + state.openErrors.sort((a, b) => b.lastSeen - a.lastSeen); + state.openErrors = state.openErrors.slice(0, HOT_STATE_LIMITS.maxOpenErrorsStored); + state.updatedAt = new Date().toISOString(); +} + +export function markErrorsMaybeFixedForFile( + state: SessionState, + filePath: string, + workspaceRoot = "", +): void { + const candidates = new Set([filePath]); + if (workspaceRoot && filePath.startsWith(workspaceRoot)) { + candidates.add(relative(workspaceRoot, filePath)); + } + + let changed = false; + for (const error of state.openErrors) { + if (error.status !== "open") continue; + if (!error.file) continue; + for (const candidate of candidates) { + if (pathsMatch(error.file, candidate)) { + error.status = "maybe_fixed"; + error.lastSeen = Date.now(); + changed = true; + break; + } + } + } + + if (changed) state.updatedAt = new Date().toISOString(); +} + +export function addRecentDecision( + state: SessionState, + decision: Pick, +): void { + const normalized = decision.text.toLowerCase().replace(/\s+/g, " ").trim(); + const existing = state.recentDecisions.find(item => ( + item.text.toLowerCase().replace(/\s+/g, " ").trim() === normalized + )); + const now = Date.now(); + + if (existing) { + existing.createdAt = now; + existing.rationale = decision.rationale ?? existing.rationale; + existing.source = decision.source; + } else { + state.recentDecisions.push({ + id: `decision_${now}_${Math.random().toString(36).slice(2, 8)}`, + text: decision.text, + rationale: decision.rationale, + source: decision.source, + createdAt: now, + }); + } + + state.recentDecisions = state.recentDecisions.slice(-HOT_STATE_LIMITS.maxRecentDecisionsStored); + state.updatedAt = new Date().toISOString(); +} + +export function clearErrorsForSuccessfulCommand(state: SessionState, command: string): void { + const category = classifyCommand(command); + if (!category) return; + state.openErrors = state.openErrors.filter(error => error.category !== category); + state.updatedAt = new Date().toISOString(); +} + +export function renderHotSessionState(state: SessionState, workspaceRoot: string): string { + const activeFiles = rankActiveFiles(state.activeFiles).slice(0, HOT_STATE_LIMITS.maxActiveFilesRendered); + 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); + + if (activeFiles.length === 0 && openErrors.length === 0 && decisions.length === 0) return ""; + + const lines: string[] = [""]; + + 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)`); + } + } + + if (openErrors.length > 0) { + lines.push("open_errors:"); + for (const err of openErrors) { + lines.push(`- [${err.category}] ${err.summary}`); + } + } + + if (decisions.length > 0) { + lines.push("recent_decisions:"); + for (const decision of decisions) { + lines.push(`- ${decision.text}`); + } + } + + lines.push(""); + return lines.join("\n").slice(0, HOT_STATE_LIMITS.maxRenderedChars); +} + +function rankActiveFiles(activeFiles: ActiveFile[]): ActiveFile[] { + return [...activeFiles].sort((a, b) => { + const scoreA = ACTION_WEIGHT[a.action] + a.count * 3; + const scoreB = ACTION_WEIGHT[b.action] + b.count * 3; + if (scoreA !== scoreB) return scoreB - scoreA; + return b.lastSeen - a.lastSeen; + }); +} + +function displayPath(workspaceRoot: string, filePath: string): string { + if (!workspaceRoot || !filePath.startsWith(workspaceRoot)) return filePath; + return relative(workspaceRoot, filePath) || "."; +} + +function pathsMatch(errorFile: string, touchedFile: string): boolean { + const normalizedError = errorFile.replace(/\\/g, "/").replace(/^\.\//, ""); + const normalizedTouched = touchedFile.replace(/\\/g, "/").replace(/^\.\//, ""); + return normalizedError === normalizedTouched + || normalizedTouched.endsWith(`/${normalizedError}`) + || normalizedError.endsWith(`/${normalizedTouched}`); +} + +function classifyCommand(command: string): OpenError["category"] | null { + const c = command.toLowerCase(); + if (/\b(tsc|typecheck)\b/.test(c)) return "typecheck"; + if (/\b(test|vitest|jest|mocha|pytest|go test|cargo test)\b/.test(c)) return "test"; + if (/\b(lint|eslint|biome)\b/.test(c)) return "lint"; + if (/\b(build|vite build|webpack|tsup)\b/.test(c)) return "build"; + return null; +} diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..e75396b --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,49 @@ +import { existsSync } from "fs"; +import { randomUUID } from "crypto"; +import { mkdir, readFile, rename, writeFile } from "fs/promises"; +import { dirname } from "path"; + +const fileLocks = new Map>(); + +export async function readJSON(path: string, fallback: () => T): Promise { + if (!existsSync(path)) return fallback(); + try { + return JSON.parse(await readFile(path, "utf8")) as T; + } catch { + return fallback(); + } +} + +export async function atomicWriteJSON(path: string, data: unknown): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`; + await writeFile(tmp, JSON.stringify(data, null, 2), { encoding: "utf8", mode: 0o600 }); + await rename(tmp, path); +} + +export async function updateJSON( + path: string, + fallback: () => T, + updater: (current: T) => T | Promise, +): Promise { + const previous = fileLocks.get(path) ?? Promise.resolve(); + let release: () => void = () => {}; + const currentLock = new Promise(resolve => { + release = resolve; + }); + const queued = previous.then(() => currentLock, () => currentLock); + fileLocks.set(path, queued); + + try { + await previous.catch(() => undefined); + const current = await readJSON(path, fallback); + const updated = await updater(current); + await atomicWriteJSON(path, updated); + return updated; + } finally { + release(); + if (fileLocks.get(path) === queued) { + fileLocks.delete(path); + } + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..ed97498 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,88 @@ +export type LongTermType = "feedback" | "project" | "decision" | "reference"; + +export type LongTermSource = "explicit" | "compaction" | "manual"; + +export type LongTermMemoryEntry = { + id: string; + type: LongTermType; + text: string; + rationale?: string; + source: LongTermSource; + confidence: number; + status: "active" | "superseded"; + createdAt: string; + updatedAt: string; + staleAfterDays?: number; + supersedes?: string[]; + tags?: string[]; +}; + +export type WorkspaceMemoryStore = { + version: 1; + workspace: { + root: string; + key: string; + }; + limits: { + maxRenderedChars: number; + maxEntries: number; + }; + entries: LongTermMemoryEntry[]; + updatedAt: string; +}; + +export type ActiveFile = { + path: string; + action: "read" | "grep" | "edit" | "write"; + count: number; + lastSeen: number; +}; + +export type OpenError = { + id: string; + category: "typecheck" | "test" | "lint" | "build" | "runtime" | "tool"; + summary: string; + command?: string; + file?: string; + fingerprint: string; + status: "open" | "maybe_fixed"; + firstSeen: number; + lastSeen: number; + seenCount: number; +}; + +export type SessionDecision = { + id: string; + text: string; + rationale?: string; + source: "assistant" | "user" | "compaction"; + createdAt: number; + promotedToLongTerm?: boolean; +}; + +export type SessionState = { + version: 1; + sessionID: string; + turn: number; + updatedAt: string; + activeFiles: ActiveFile[]; + openErrors: OpenError[]; + recentDecisions: SessionDecision[]; +}; + +export const LONG_TERM_LIMITS = { + maxRenderedChars: 5200, + targetRenderedChars: 4200, + maxEntries: 28, + maxEntryTextChars: 260, + maxRationaleChars: 180, +} as const; + +export const HOT_STATE_LIMITS = { + maxRenderedChars: 1200, + maxActiveFilesStored: 20, + maxActiveFilesRendered: 8, + maxOpenErrorsStored: 5, + maxOpenErrorsRendered: 3, + maxRecentDecisionsStored: 8, +} as const; diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts new file mode 100644 index 0000000..3a4c5e6 --- /dev/null +++ b/src/workspace-memory.ts @@ -0,0 +1,174 @@ +import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts"; +import { LONG_TERM_LIMITS } from "./types.ts"; +import { workspaceKey, workspaceMemoryPath } from "./paths.ts"; +import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts"; + +// Minimum length for workspace_memory envelope: \n...\n +const MIN_ENVELOPE_LENGTH = 80; + +export async function emptyWorkspaceMemory(root: string): Promise { + return { + version: 1, + workspace: { root, key: await workspaceKey(root) }, + limits: { + maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, + maxEntries: LONG_TERM_LIMITS.maxEntries, + }, + entries: [], + updatedAt: new Date().toISOString(), + }; +} + +export async function loadWorkspaceMemory(root: string): Promise { + const fallback = await emptyWorkspaceMemory(root); + const loaded = await readJSON(await workspaceMemoryPath(root), () => fallback); + loaded.workspace = { root, key: await workspaceKey(root) }; + loaded.limits = { + maxRenderedChars: loaded.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars, + maxEntries: loaded.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries, + }; + loaded.entries = Array.isArray(loaded.entries) ? loaded.entries : []; + return loaded; +} + +export async function saveWorkspaceMemory(root: string, store: WorkspaceMemoryStore): Promise { + const normalized = await normalizeWorkspaceMemory(root, store); + await atomicWriteJSON(await workspaceMemoryPath(root), normalized); +} + +export async function updateWorkspaceMemory( + root: string, + updater: (store: WorkspaceMemoryStore) => WorkspaceMemoryStore | Promise, +): Promise { + const path = await workspaceMemoryPath(root); + const fallback = await emptyWorkspaceMemory(root); + return updateJSON(path, () => fallback, async current => { + const normalized = await normalizeWorkspaceMemory(root, current); + return normalizeWorkspaceMemory(root, await updater(normalized)); + }); +} + +async function normalizeWorkspaceMemory( + root: string, + store: WorkspaceMemoryStore, +): Promise { + store.workspace = { root, key: await workspaceKey(root) }; + store.limits = { + maxRenderedChars: store.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars, + maxEntries: store.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries, + }; + store.entries = enforceLongTermLimits(store.entries); + store.updatedAt = new Date().toISOString(); + return store; +} + +function sourcePriority(source: LongTermMemoryEntry["source"]): number { + if (source === "explicit") return 3; + if (source === "manual") return 2; + return 1; +} + +function canonicalMemoryText(text: string): string { + return text + .normalize("NFKC") + .toLowerCase() + .replace(/[\s\p{P}]+/gu, " ") + .trim(); +} + +export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] { + const byKey = new Map(); + + for (const entry of entries.filter(entry => entry.status === "active")) { + const text = entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars); + const key = `${entry.type}:${canonicalMemoryText(text)}`; + + const existing = byKey.get(key); + + // Source priority: explicit > manual > compaction + // Same source: higher confidence wins + if (!existing) { + byKey.set(key, { ...entry, text }); + } else if (sourcePriority(entry.source) > sourcePriority(existing.source)) { + byKey.set(key, { ...entry, text }); + } else if (sourcePriority(entry.source) === sourcePriority(existing.source)) { + if (entry.confidence > existing.confidence) { + byKey.set(key, { ...entry, text }); + } + } + } + + return [...byKey.values()] + .sort((a, b) => priority(b) - priority(a)) + .slice(0, LONG_TERM_LIMITS.maxEntries); +} + +function priority(entry: LongTermMemoryEntry): number { + const typeWeight = { + feedback: 400, + decision: 300, + project: 200, + reference: 100, + }[entry.type]; + + const sourceWeight = entry.source === "explicit" ? 1000 : 0; + return sourceWeight + typeWeight + entry.confidence * 10; +} + +function wouldFit( + lines: string[], + nextLine: string, + closingLine: string, + maxChars: number +): boolean { + return [...lines, nextLine, closingLine].join("\n").length <= maxChars; +} + +export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string { + const active = enforceLongTermLimits(store.entries); + if (active.length === 0) return ""; + + const maxChars = Math.min( + store.limits.maxRenderedChars, + LONG_TERM_LIMITS.maxRenderedChars + ); + + // If maxChars smaller than minimum envelope, return empty string + if (maxChars < MIN_ENVELOPE_LENGTH) return ""; + + const closing = ""; + const lines: string[] = [ + "", + "Persistent workspace memory. Use as background; verify stale or code-related claims.", + ]; + + for (const type of ["feedback", "project", "decision", "reference"] as const) { + const items = active.filter(entry => entry.type === type); + if (items.length === 0) continue; + + const sectionLines: string[] = [`${type}:`]; + + for (const item of items) { + const line = `- ${renderEntry(item)}`; + if (wouldFit([...lines, ...sectionLines], line, closing, maxChars)) { + sectionLines.push(line); + } + } + + if (sectionLines.length > 1 && wouldFit(lines, sectionLines[0], closing, maxChars)) { + lines.push(...sectionLines); + } + } + + lines.push(closing); + return lines.join("\n"); +} + +function renderEntry(entry: LongTermMemoryEntry): string { + const ageDays = Math.floor((Date.now() - new Date(entry.createdAt).getTime()) / 86_400_000); + const stale = entry.staleAfterDays && ageDays > entry.staleAfterDays ? ` [${ageDays}d old, verify]` : ""; + const rationale = entry.rationale + ? ` Why: ${entry.rationale.slice(0, LONG_TERM_LIMITS.maxRationaleChars)}` + : ""; + return `${entry.text}${rationale}${stale}`; +} diff --git a/tests/extractors.test.ts b/tests/extractors.test.ts new file mode 100644 index 0000000..6d7a8fe --- /dev/null +++ b/tests/extractors.test.ts @@ -0,0 +1,87 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { extractErrorsFromBash, extractExplicitMemories } from "../src/extractors.ts"; + +// ============================================ +// Task 1: extractErrorsFromBash tests +// ============================================ + +test("git log output mentioning errors is ignored", () => { + const errors = extractErrorsFromBash( + "cd /repo && rtk git log --oneline -5", + "4832b38 fix: silence memory load errors in working-memory" + ); + assert.equal(errors.length, 0); +}); + +test("cat session json with openErrors is ignored", () => { + const errors = extractErrorsFromBash( + "rtk cat ~/.local/share/opencode-working-memory/session.json", + '"openErrors": []' + ); + assert.equal(errors.length, 0); +}); + +test("typecheck failure is captured", () => { + const errors = extractErrorsFromBash( + "npm run typecheck", + "src/index.ts(10,3): error TS2345: bad type" + ); + assert.equal(errors.length, 1); + assert.equal(errors[0].category, "typecheck"); +}); + +test("runtime Error prefix is captured for failed unknown command", () => { + const errors = extractErrorsFromBash( + "node script.js", + "Error: Cannot find module './missing'" + ); + assert.equal(errors.length, 1); + assert.equal(errors[0].category, "runtime"); +}); + +test("unknown command with loose error words is ignored", () => { + const errors = extractErrorsFromBash( + "some-unknown-command", + "this output has errors in it but no clear signal" + ); + assert.equal(errors.length, 0); +}); + +test("TypeError prefix is captured", () => { + const errors = extractErrorsFromBash( + "node script.js", + "TypeError: Cannot read property 'x' of undefined" + ); + assert.equal(errors.length, 1); +}); + +test("TS error pattern is always captured", () => { + const errors = extractErrorsFromBash( + "cat some-file.txt", // unknown command, but TS error is strong signal + "src/index.ts(10,3): error TS2345: Argument of type 'string' is not assignable" + ); + assert.equal(errors.length, 1); + assert.equal(errors[0].category, "runtime"); +}); + +// ============================================ +// Task 3: extractExplicitMemories tests +// ============================================ + +test("extractExplicitMemories does not treat always as memory trigger", () => { + const items = extractExplicitMemories("tests always fail on CI when cache is stale"); + assert.equal(items.length, 0); +}); + +test("extractExplicitMemories still captures going forward", () => { + const items = extractExplicitMemories("going forward: use pnpm instead of npm"); + assert.equal(items.length, 1); + assert.match(items[0].text, /pnpm/); +}); + +test("extractExplicitMemories captures from now on", () => { + const items = extractExplicitMemories("from now on: reply in Traditional Chinese"); + assert.equal(items.length, 1); + assert.match(items[0].text, /Traditional Chinese/); +}); \ No newline at end of file diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts new file mode 100644 index 0000000..b81b30d --- /dev/null +++ b/tests/plugin.test.ts @@ -0,0 +1,195 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MemoryV2Plugin } from "../src/plugin.ts"; +import { loadSessionState, saveSessionState } from "../src/session-state.ts"; +import type { OpenError } from "../src/types.ts"; + +// Mock client for root session (not a sub-agent) +function mockRootClient() { + return { + session: { + get: async () => ({ data: { parentID: null } }), + }, + }; +} + +// Helper: create session state with pre-populated open error +function createSessionWithError(sessionID: string, error: OpenError) { + return { + version: 1 as const, + sessionID, + turn: 0, + updatedAt: new Date().toISOString(), + activeFiles: [], + openErrors: [error], + recentDecisions: [], + }; +} + +test("tool.execute.after: undefined exitCode does NOT create open error", async () => { + // 1. Temp directory for isolated file I/O + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); + + try { + // 2. Mock client — root session, no user messages + const client = mockRootClient(); + + // 3. Instantiate plugin + const plugin = await MemoryV2Plugin({ directory: tmpDir, client }); + + // 4. Simulate bash output with NO exitCode, but output contains TS error + // This would create an open error if exitCode was non-zero + // Using STRONG error signal (TS2345) to catch the bug where undefined !== 0 + await (plugin as Record)["tool.execute.after"]( + { + tool: "bash", + sessionID: "test-session-1", + args: { command: "npm run typecheck" }, + }, + { + // exitCode deliberately absent (undefined !== 0 is the bug we're testing) + output: "src/index.ts(10,3): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'", + } + ); + + // 5. Assert: session state has ZERO open errors + const state = await loadSessionState(tmpDir, "test-session-1"); + assert.equal(state.openErrors.length, 0, + "exitCode === undefined must not create open errors even with strong error signal"); + + } finally { + // Cleanup + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("tool.execute.after: undefined exitCode does NOT clear existing open error", async () => { + // 1. Temp directory + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); + + try { + // 2. Pre-populate session state with a real open error + const preExistingError: OpenError = { + id: "err_critical_abc", + category: "typecheck", + summary: "TS2345: Argument of type 'string' is not assignable to parameter of type 'number'", + command: "npm run typecheck", + fingerprint: "ee7b3f9a1c2d", + status: "open", + firstSeen: Date.now() - 3600000, + lastSeen: Date.now() - 3600000, + seenCount: 3, + }; + + await saveSessionState(tmpDir, createSessionWithError("test-session-2", preExistingError)); + + // 3. Mock client + const client = mockRootClient(); + + // 4. Instantiate plugin + const plugin = await MemoryV2Plugin({ directory: tmpDir, client }); + + // 5. Simulate bash output with NO exitCode (inspection command) + // Using STRONG error signal (TS error) to verify undefined exitCode doesn't clear + await (plugin as Record)["tool.execute.after"]( + { + tool: "bash", + sessionID: "test-session-2", + args: { command: "rtk cat ~/.local/share/opencode-working-memory/session.json" }, + }, + { + // exitCode deliberately absent (undefined) + // Even with TS error in output, should NOT clear existing error + output: "src/other.ts(5,10): error TS2794: Expected 0 arguments, but got 1", + } + ); + + // 6. Assert: pre-existing open error is PRESERVED + const state = await loadSessionState(tmpDir, "test-session-2"); + assert.equal(state.openErrors.length, 1, + "exitCode === undefined must not clear pre-existing open errors"); + assert.equal(state.openErrors[0].fingerprint, "ee7b3f9a1c2d", + "The original open error must remain intact"); + + } finally { + // Cleanup + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("tool.execute.after: exitCode 0 clears errors for same category", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); + + try { + // Pre-populate session with a typecheck error + const preExistingError: OpenError = { + id: "err_test", + category: "typecheck", + summary: "TS2345: some error", + command: "npm run typecheck", + fingerprint: "abc123", + status: "open", + firstSeen: Date.now() - 3600000, + lastSeen: Date.now() - 3600000, + seenCount: 1, + }; + + await saveSessionState(tmpDir, createSessionWithError("test-session-3", preExistingError)); + + const client = mockRootClient(); + const plugin = await MemoryV2Plugin({ directory: tmpDir, client }); + + // Simulate successful typecheck (exitCode 0) + await (plugin as Record)["tool.execute.after"]( + { + tool: "bash", + sessionID: "test-session-3", + args: { command: "npm run typecheck" }, + }, + { + exitCode: 0, + output: "", + } + ); + + const state = await loadSessionState(tmpDir, "test-session-3"); + assert.equal(state.openErrors.length, 0, + "exitCode 0 should clear typecheck errors"); + + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("tool.execute.after: exitCode non-zero creates open error", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); + + try { + const client = mockRootClient(); + const plugin = await MemoryV2Plugin({ directory: tmpDir, client }); + + // Simulate failed typecheck (exitCode 1) + await (plugin as Record)["tool.execute.after"]( + { + tool: "bash", + sessionID: "test-session-4", + args: { command: "npm run typecheck" }, + }, + { + exitCode: 1, + output: "src/index.ts(10,3): error TS2345: Argument of type 'string' is not assignable", + } + ); + + const state = await loadSessionState(tmpDir, "test-session-4"); + assert.equal(state.openErrors.length, 1, + "exitCode non-zero should create open error"); + assert.equal(state.openErrors[0].category, "typecheck"); + + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); \ No newline at end of file diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts new file mode 100644 index 0000000..095491e --- /dev/null +++ b/tests/workspace-memory.test.ts @@ -0,0 +1,210 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts"; +import { renderWorkspaceMemory, enforceLongTermLimits } from "../src/workspace-memory.ts"; + +function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry { + const now = new Date().toISOString(); + return { + id, + type, + text, + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: now, + updatedAt: now, + }; +} + +// ============================================ +// Task 2: renderWorkspaceMemory tests +// ============================================ + +test("renderWorkspaceMemory never truncates closing XML tag", () => { + const entries = Array.from({ length: 28 }, (_, i) => + entry(`mem_${i}`, `Long durable memory entry ${i} `.repeat(20)) + ); + + const store: WorkspaceMemoryStore = { + version: 1, + workspace: { root: "/repo", key: "abc" }, + limits: { maxRenderedChars: 700, maxEntries: 28 }, + entries, + updatedAt: new Date().toISOString(), + }; + + const rendered = renderWorkspaceMemory(store); + + assert.ok(rendered.endsWith(""), + `Rendered memory must end with closing tag. Got: ...${rendered.slice(-50)}`); + assert.ok(rendered.length <= 700, + `Rendered memory must not exceed maxChars. Got: ${rendered.length}`); +}); + +test("renderWorkspaceMemory returns empty string when maxChars too small", () => { + const store: WorkspaceMemoryStore = { + version: 1, + workspace: { root: "/repo", key: "abc" }, + limits: { maxRenderedChars: 50, maxEntries: 28 }, + entries: [entry("test", "test memory")], + updatedAt: new Date().toISOString(), + }; + + const rendered = renderWorkspaceMemory(store); + assert.equal(rendered, "", + "When maxChars too small for even minimal envelope, return empty string"); +}); + +test("renderWorkspaceMemory respects budget and fits entries", () => { + // Create entries that would overflow a small budget + const entries = [ + entry("a", "First memory entry that is reasonably long"), + entry("b", "Second memory entry that is also reasonably long"), + entry("c", "Third memory entry that is also reasonably long"), + ]; + + const store: WorkspaceMemoryStore = { + version: 1, + workspace: { root: "/repo", key: "abc" }, + limits: { maxRenderedChars: 200, maxEntries: 28 }, + entries, + updatedAt: new Date().toISOString(), + }; + + const rendered = renderWorkspaceMemory(store); + + assert.ok(rendered.endsWith(""), + "Must end with closing tag even when truncating entries"); + assert.ok(rendered.length <= 200, + `Must respect maxChars limit. Got: ${rendered.length}`); +}); + +test("renderWorkspaceMemory returns empty for no entries", () => { + const store: WorkspaceMemoryStore = { + version: 1, + workspace: { root: "/repo", key: "abc" }, + limits: { maxRenderedChars: 5200, maxEntries: 28 }, + entries: [], + updatedAt: new Date().toISOString(), + }; + + const rendered = renderWorkspaceMemory(store); + assert.equal(rendered, ""); +}); + +// ============================================ +// PR-2 Task 5 tests (for enforceLongTermLimits) +// ============================================ + +test("enforceLongTermLimits dedupes with canonical text", () => { + const now = new Date().toISOString(); + + const a: LongTermMemoryEntry = { + id: "a", + type: "decision", + text: "OpenCode uses NPM CACHE for plugin loading", + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: now, + updatedAt: now, + }; + + const b: LongTermMemoryEntry = { + id: "b", + type: "decision", + text: "opencode uses npm cache for plugin loading!!!", + source: "compaction", + confidence: 0.8, + status: "active", + createdAt: now, + updatedAt: now, + }; + + const kept = enforceLongTermLimits([a, b]); + + assert.equal(kept.length, 1, "Should dedupe similar texts"); + assert.equal(kept[0].confidence, 0.8, "Higher confidence should win for same source"); +}); + +test("enforceLongTermLimits preserves explicit over compaction", () => { + const now = new Date().toISOString(); + + const explicit: LongTermMemoryEntry = { + id: "explicit", + type: "decision", + text: "Use pnpm for this project", + source: "explicit", + confidence: 0.5, + status: "active", + createdAt: now, + updatedAt: now, + }; + + const compaction: LongTermMemoryEntry = { + id: "compaction", + type: "decision", + text: "Use pnpm for this project", + source: "compaction", + confidence: 0.9, + status: "active", + createdAt: now, + updatedAt: now, + }; + + const kept = enforceLongTermLimits([explicit, compaction]); + + assert.equal(kept.length, 1); + assert.equal(kept[0].source, "explicit", + "Explicit source should win over compaction even with lower confidence"); + assert.equal(kept[0].confidence, 0.5, "Original explicit confidence preserved"); +}); + +test("enforceLongTermLimits same source higher confidence wins", () => { + const now = new Date().toISOString(); + + const a: LongTermMemoryEntry = { + id: "a", + type: "decision", + text: "Project uses TypeScript", + source: "compaction", + confidence: 0.7, + status: "active", + createdAt: now, + updatedAt: now, + }; + + const b: LongTermMemoryEntry = { + id: "b", + type: "decision", + text: "Project uses TypeScript", + source: "compaction", + confidence: 0.9, + status: "active", + createdAt: now, + updatedAt: now, + }; + + const kept = enforceLongTermLimits([a, b]); + + assert.equal(kept.length, 1); + assert.equal(kept[0].confidence, 0.9, "Higher confidence wins for same source"); +}); + +test("enforceLongTermLimits respects maxEntries limit", () => { + const now = new Date().toISOString(); + const entries = Array.from({ length: 50 }, (_, i) => ({ + id: `mem_${i}`, + type: "decision" as const, + text: `Unique memory entry number ${i}`, + source: "compaction" as const, + confidence: 0.75, + status: "active" as const, + createdAt: now, + updatedAt: now, + })); + + const kept = enforceLongTermLimits(entries); + assert.ok(kept.length <= 28, `Should respect maxEntries. Got: ${kept.length}`); +}); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index db5f715..e92ae18 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,8 +21,10 @@ "noFallthroughCasesInSwitch": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "allowImportingTsExtensions": true, + "noEmit": true }, - "include": ["index.ts"], + "include": ["index.ts", "src/**/*.ts"], "exclude": ["node_modules", "dist"] }