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 </workspace_memory> 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
This commit is contained in:
Ralph Chang
2026-04-26 12:52:21 +08:00
parent 2d7cb6cdf4
commit 1bba0511bb
13 changed files with 1793 additions and 7 deletions
+167
View File
@@ -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<string, unknown>,
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(/<workspace_memory_candidates>([\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;
}
+177
View File
@@ -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<string | null> {
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<Array<{ content: string; status: string; priority?: string }>> {
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<boolean> {
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;
}
}
+26
View File
@@ -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<string> {
const resolved = await realpath(root).catch(() => root);
return createHash("sha256").update(resolved).digest("hex").slice(0, 16);
}
export async function memoryRoot(root: string): Promise<string> {
return join(dataHome(), "opencode-working-memory", "workspaces", await workspaceKey(root));
}
export async function workspaceMemoryPath(root: string): Promise<string> {
return join(await memoryRoot(root), "workspace-memory.json");
}
export async function sessionStatePath(root: string, sessionID: string): Promise<string> {
const safeSessionID = createHash("sha256").update(sessionID).digest("hex").slice(0, 32);
return join(await memoryRoot(root), "sessions", `${safeSessionID}.json`);
}
+374
View File
@@ -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:
<workspace_memory_candidates>
- [feedback] ...
- [project] ...
- [decision] ...
- [reference] ...
</workspace_memory_candidates>
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 = ["<pending_todos>"];
for (const todo of todos) {
const priority = todo.priority ? ` [${todo.priority}]` : "";
lines.push(`- ${todo.content}${priority}`);
}
lines.push("</pending_todos>");
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<string, string | null>();
async function isSubAgent(sessionID: string): Promise<boolean> {
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<ReturnType<typeof loadWorkspaceMemory>>;
loadedAt: number;
}
>();
// Cache for processed user message IDs (to avoid duplicate processing)
const processedUserMessages = new Map<string, Set<string>>();
async function processLatestUserMessage(sessionID: string): Promise<void> {
const processedForSession = processedUserMessages.get(sessionID) ?? new Set<string>();
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<ReturnType<typeof loadWorkspaceMemory>> | 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<string, unknown>;
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<Awaited<ReturnType<typeof loadWorkspaceMemory>>> {
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<string, unknown>,
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<string, unknown>;
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 });
}
}
},
};
}
+240
View File
@@ -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<ActiveFile["action"], number> = {
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<SessionState> {
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<void> {
await atomicWriteJSON(await sessionStatePath(root, state.sessionID), normalizeSessionState(state));
}
export async function updateSessionState(
root: string,
sessionID: string,
updater: (state: SessionState) => SessionState | Promise<SessionState>,
): Promise<SessionState> {
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<string>([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<SessionDecision, "text" | "source" | "rationale">,
): 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[] = ["<hot_session_state>"];
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("</hot_session_state>");
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;
}
+49
View File
@@ -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<string, Promise<unknown>>();
export async function readJSON<T>(path: string, fallback: () => T): Promise<T> {
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<void> {
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<T>(
path: string,
fallback: () => T,
updater: (current: T) => T | Promise<T>,
): Promise<T> {
const previous = fileLocks.get(path) ?? Promise.resolve();
let release: () => void = () => {};
const currentLock = new Promise<void>(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);
}
}
}
+88
View File
@@ -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;
+174
View File
@@ -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: <workspace_memory>\n...\n</workspace_memory>
const MIN_ENVELOPE_LENGTH = 80;
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
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<WorkspaceMemoryStore> {
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<void> {
const normalized = await normalizeWorkspaceMemory(root, store);
await atomicWriteJSON(await workspaceMemoryPath(root), normalized);
}
export async function updateWorkspaceMemory(
root: string,
updater: (store: WorkspaceMemoryStore) => WorkspaceMemoryStore | Promise<WorkspaceMemoryStore>,
): Promise<WorkspaceMemoryStore> {
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<WorkspaceMemoryStore> {
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<string, LongTermMemoryEntry>();
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 = "</workspace_memory>";
const lines: string[] = [
"<workspace_memory>",
"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}`;
}