mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-01 22:11:08 +02:00
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:
@@ -1,9 +1,6 @@
|
|||||||
import type { PluginModule } from "@opencode-ai/plugin";
|
|
||||||
import { MemoryV2Plugin } from "./src/plugin.ts";
|
import { MemoryV2Plugin } from "./src/plugin.ts";
|
||||||
|
|
||||||
const plugin: PluginModule = {
|
export default {
|
||||||
id: "working-memory",
|
id: "working-memory",
|
||||||
server: MemoryV2Plugin,
|
server: MemoryV2Plugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default plugin;
|
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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<string, Function>)["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<string, Function>)["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<string, Function>)["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<string, Function>)["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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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("</workspace_memory>"),
|
||||||
|
`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("</workspace_memory>"),
|
||||||
|
"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}`);
|
||||||
|
});
|
||||||
+4
-2
@@ -21,8 +21,10 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["index.ts"],
|
"include": ["index.ts", "src/**/*.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user