mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-02 06:19:36 +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";
|
||||
|
||||
const plugin: PluginModule = {
|
||||
export default {
|
||||
id: "working-memory",
|
||||
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,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["index.ts"],
|
||||
"include": ["index.ts", "src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user