mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
feat(evidence): wire evidence events into extraction, promotion, reinforcement, render, storage, and hook lifecycle
Phase 3 Tasks 3.2-3.6: - Extraction evidence: accepted/rejected/explicit_detected/explicit_ignored - Promotion evidence with relation edges (superseded/superseded_by, absorbed/retained) - Reinforcement evidence with reinforced/reinforced_by relations - Render accounting helper with render_selected/render_omitted evidence - Storage evidence: corrupt_json_quarantined, stale_lock_recovered, lock_timeout - Hook failure evidence in plugin - All evidence failures swallowed, never throw into memory behavior - Privacy-safe textPreview (redacted + truncated) - 266 tests pass, typecheck pass
This commit is contained in:
+13
-2
@@ -1,8 +1,8 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { appendFile, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { workspaceEvidenceLogPath, workspaceKey } from "./paths.ts";
|
||||
import { dirname, join } from "node:path";
|
||||
import { dataHome, workspaceEvidenceLogPath, workspaceKey } from "./paths.ts";
|
||||
import { redactCredentials } from "./redaction.ts";
|
||||
|
||||
export type EvidenceEventType =
|
||||
@@ -311,6 +311,17 @@ export async function appendEvidenceEvents(root: string, events: EvidenceEventIn
|
||||
return records;
|
||||
}
|
||||
|
||||
export async function appendEvidenceEventForWorkspaceKey(
|
||||
workspaceKeyValue: string,
|
||||
event: EvidenceEventInput,
|
||||
): Promise<EvidenceEventV1> {
|
||||
const path = join(dataHome(), "opencode-working-memory", "workspaces", workspaceKeyValue, "evidence", "events.jsonl");
|
||||
const record = buildEvidenceEvent(event, workspaceKeyValue, workspaceKeyValue);
|
||||
await safeAppendEvidenceLine(path, JSON.stringify(record));
|
||||
await maybePruneEvidenceLog(path);
|
||||
return record;
|
||||
}
|
||||
|
||||
type ParsedEvidenceLine = {
|
||||
event: EvidenceEventV1;
|
||||
index: number;
|
||||
|
||||
+164
-24
@@ -6,6 +6,7 @@ import { LONG_TERM_LIMITS } from "./types.ts";
|
||||
import { assessMemoryQuality } from "./memory-quality.ts";
|
||||
import { extractionRejectionLogPath } from "./paths.ts";
|
||||
import { redactCredentials } from "./redaction.ts";
|
||||
import type { EvidenceEventInput } from "./evidence-log.ts";
|
||||
|
||||
function id(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -46,6 +47,34 @@ function isNegatedMemoryRequest(text: string, matchIndex: number): boolean {
|
||||
}
|
||||
|
||||
export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
|
||||
return extractExplicitMemoriesWithEvidence(text).entries;
|
||||
}
|
||||
|
||||
export type WorkspaceMemoryParseResult = {
|
||||
entries: LongTermMemoryEntry[];
|
||||
evidence: EvidenceEventInput[];
|
||||
};
|
||||
|
||||
function evidenceTextPreview(text: string, maxChars = 120): string {
|
||||
return redactCredentials(text).replace(/\s+/g, " ").trim().slice(0, maxChars);
|
||||
}
|
||||
|
||||
function memoryEvidence(memory: LongTermMemoryEntry): EvidenceEventInput["memory"] {
|
||||
return {
|
||||
memoryId: memory.id,
|
||||
type: memory.type,
|
||||
source: memory.source,
|
||||
status: memory.status,
|
||||
};
|
||||
}
|
||||
|
||||
function extractionEvidence(
|
||||
input: Pick<EvidenceEventInput, "type" | "phase" | "outcome" | "reasonCodes" | "textPreview" | "memory" | "details">,
|
||||
): EvidenceEventInput {
|
||||
return input;
|
||||
}
|
||||
|
||||
export function extractExplicitMemoriesWithEvidence(text: string): WorkspaceMemoryParseResult {
|
||||
// 注意:所有pattern必須有 g flag,因為使用 matchAll()
|
||||
// Pattern 必須在行首匹配,避免匹配到句子中間的非指令式用法
|
||||
const patterns = [
|
||||
@@ -71,29 +100,74 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
|
||||
const nowMs = Date.now();
|
||||
const now = new Date(nowMs).toISOString();
|
||||
const entries: LongTermMemoryEntry[] = [];
|
||||
const evidence: EvidenceEventInput[] = [];
|
||||
const seen = new Set<string>();
|
||||
const negatedLinePattern = /(?:^|\n)\s*(?:(?:please\s+)?(?:do\s+not|don't|dont|never)\s+remember(?:\s+(?:this|that))?|不要\s*(?:記住|记住)|別\s*(?:記住|记住)|别\s*(?:記住|记住))[::,,]?\s*(.+)$/gim;
|
||||
for (const match of text.matchAll(negatedLinePattern)) {
|
||||
evidence.push(extractionEvidence({
|
||||
type: "explicit_memory_ignored",
|
||||
phase: "explicit",
|
||||
outcome: "rejected",
|
||||
reasonCodes: ["negated_request"],
|
||||
textPreview: evidenceTextPreview(match[1] ?? match[0], 80),
|
||||
}));
|
||||
}
|
||||
|
||||
for (const pattern of patterns) {
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const body = match[1]?.trim();
|
||||
if (!body || body.length < 8) continue;
|
||||
if (body && /^(再说|再說|later|next time)$/i.test(body)) {
|
||||
evidence.push(extractionEvidence({
|
||||
type: "explicit_memory_ignored",
|
||||
phase: "explicit",
|
||||
outcome: "rejected",
|
||||
reasonCodes: ["deferral"],
|
||||
textPreview: evidenceTextPreview(body, 80),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
if (!body || body.length < 8) {
|
||||
evidence.push(extractionEvidence({
|
||||
type: "explicit_memory_ignored",
|
||||
phase: "explicit",
|
||||
outcome: "rejected",
|
||||
reasonCodes: ["too_short"],
|
||||
textPreview: evidenceTextPreview(body ?? match[0], 80),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate actual trigger position (after possible newline)
|
||||
const triggerIndex = match.index! + (match[0].match(/^[\s\n]*/)?.[0]?.length || 0);
|
||||
|
||||
// Check if this is a negated request (e.g., "不要記住")
|
||||
if (isNegatedMemoryRequest(text, triggerIndex)) continue;
|
||||
if (isNegatedMemoryRequest(text, triggerIndex)) {
|
||||
evidence.push(extractionEvidence({
|
||||
type: "explicit_memory_ignored",
|
||||
phase: "explicit",
|
||||
outcome: "rejected",
|
||||
reasonCodes: ["negated_request"],
|
||||
textPreview: evidenceTextPreview(body, 80),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's a deferral (e.g., "later", "next time")
|
||||
if (/^(再说|再說|later|next time)$/i.test(body)) continue;
|
||||
|
||||
// Dedupe by canonical body
|
||||
const key = body.toLowerCase().replace(/\s+/g, " ").trim();
|
||||
if (seen.has(key)) continue;
|
||||
if (seen.has(key)) {
|
||||
evidence.push(extractionEvidence({
|
||||
type: "explicit_memory_ignored",
|
||||
phase: "explicit",
|
||||
outcome: "rejected",
|
||||
reasonCodes: ["duplicate_in_message"],
|
||||
textPreview: evidenceTextPreview(body, 80),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
|
||||
const type = classifyExplicitMemory(body);
|
||||
entries.push({
|
||||
const memory: LongTermMemoryEntry = {
|
||||
id: id("mem"),
|
||||
type,
|
||||
text: body.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
|
||||
@@ -104,11 +178,20 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
|
||||
updatedAt: now,
|
||||
retentionClock: nowMs,
|
||||
staleAfterDays: staleAfterDaysFor(type),
|
||||
});
|
||||
};
|
||||
entries.push(memory);
|
||||
evidence.push(extractionEvidence({
|
||||
type: "explicit_memory_detected",
|
||||
phase: "explicit",
|
||||
outcome: "accepted",
|
||||
reasonCodes: ["explicit_trigger_matched"],
|
||||
memory: memoryEvidence(memory),
|
||||
textPreview: evidenceTextPreview(memory.text),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
return { entries, evidence };
|
||||
}
|
||||
|
||||
function classifyExplicitMemory(text: string): LongTermType {
|
||||
@@ -251,7 +334,7 @@ async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promi
|
||||
}
|
||||
}
|
||||
|
||||
function shouldAcceptWorkspaceMemoryCandidate(
|
||||
function evaluateWorkspaceMemoryCandidate(
|
||||
entry: {
|
||||
type: LongTermType;
|
||||
text: string;
|
||||
@@ -259,7 +342,7 @@ function shouldAcceptWorkspaceMemoryCandidate(
|
||||
options: {
|
||||
fromMemoryTrigger?: boolean;
|
||||
} = {},
|
||||
): boolean {
|
||||
): { accepted: boolean; reasons: string[] } {
|
||||
const text = entry.text.trim();
|
||||
const minLength = options.fromMemoryTrigger ? 6 : 20;
|
||||
|
||||
@@ -267,14 +350,14 @@ function shouldAcceptWorkspaceMemoryCandidate(
|
||||
if (entry.type === "reference" && /\b(?:admin\s+)?pin\s|scrypt|n=\d+|r=\d+|p=\d+/i.test(text)) {
|
||||
// Stable config values can be short — allow below generic min length
|
||||
} else if (text.length < minLength) {
|
||||
return false;
|
||||
return { accepted: false, reasons: ["too_short"] };
|
||||
}
|
||||
|
||||
// Indirect Prompt Injection / Adversarial Instructions
|
||||
// Rejects attempts to overwrite system behavior or "ignore" rules.
|
||||
// comparative "instead of" is allowed.
|
||||
if (/\b(ignore\s+all|ignore\s+previous|ignore\s+instruction|overwrite\s+system|overwrite\s+rules|forget\s+all|delete\s+root)\b/i.test(text)) return false;
|
||||
if (/\b(ignore|instruction|overwrite)\b/i.test(text) && /\b(previous|all|rules|behavior|prompt|system)\b/i.test(text)) return false;
|
||||
if (/\b(ignore\s+all|ignore\s+previous|ignore\s+instruction|overwrite\s+system|overwrite\s+rules|forget\s+all|delete\s+root)\b/i.test(text)) return { accepted: false, reasons: ["prompt_injection"] };
|
||||
if (/\b(ignore|instruction|overwrite)\b/i.test(text) && /\b(previous|all|rules|behavior|prompt|system)\b/i.test(text)) return { accepted: false, reasons: ["prompt_injection"] };
|
||||
|
||||
const quality = assessMemoryQuality({ type: entry.type, text, source: "compaction" });
|
||||
if (!quality.accepted) {
|
||||
@@ -285,10 +368,22 @@ function shouldAcceptWorkspaceMemoryCandidate(
|
||||
reasons: quality.reasons,
|
||||
source: "compaction",
|
||||
});
|
||||
return false;
|
||||
return { accepted: false, reasons: quality.reasons };
|
||||
}
|
||||
|
||||
return true;
|
||||
return { accepted: true, reasons: ["quality_gate_passed"] };
|
||||
}
|
||||
|
||||
function shouldAcceptWorkspaceMemoryCandidate(
|
||||
entry: {
|
||||
type: LongTermType;
|
||||
text: string;
|
||||
},
|
||||
options: {
|
||||
fromMemoryTrigger?: boolean;
|
||||
} = {},
|
||||
): boolean {
|
||||
return evaluateWorkspaceMemoryCandidate(entry, options).accepted;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -316,12 +411,17 @@ function extractCandidateBlock(summary: string): string | null {
|
||||
}
|
||||
|
||||
export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryEntry[] {
|
||||
return parseWorkspaceMemoryCandidatesWithEvidence(summary).entries;
|
||||
}
|
||||
|
||||
export function parseWorkspaceMemoryCandidatesWithEvidence(summary: string): WorkspaceMemoryParseResult {
|
||||
const block = extractCandidateBlock(summary);
|
||||
if (!block) return [];
|
||||
if (!block) return { entries: [], evidence: [] };
|
||||
|
||||
const nowMs = Date.now();
|
||||
const now = new Date(nowMs).toISOString();
|
||||
const entries: LongTermMemoryEntry[] = [];
|
||||
const evidence: EvidenceEventInput[] = [];
|
||||
|
||||
for (const line of block.split("\n")) {
|
||||
// Accept both "- [type] text" (bracketed) and "- type text" (bracketless)
|
||||
@@ -331,18 +431,49 @@ export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryE
|
||||
if (!item) continue;
|
||||
const type = (item[1] ?? item[2]).toLowerCase() as LongTermType;
|
||||
const normalizedBody = normalizeCandidateBody(item[3]);
|
||||
if (!normalizedBody) continue;
|
||||
if (!normalizedBody) {
|
||||
evidence.push(extractionEvidence({
|
||||
type: "extraction_candidate_rejected",
|
||||
phase: "extraction",
|
||||
outcome: "rejected",
|
||||
reasonCodes: ["negated_request"],
|
||||
memory: { type, source: "compaction" },
|
||||
textPreview: evidenceTextPreview(item[3], 80),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
const minLength = normalizedBody.hadTrigger ? 6 : 12;
|
||||
if (normalizedBody.text.length < minLength) continue;
|
||||
if (normalizedBody.text.length < minLength) {
|
||||
evidence.push(extractionEvidence({
|
||||
type: "extraction_candidate_rejected",
|
||||
phase: "extraction",
|
||||
outcome: "rejected",
|
||||
reasonCodes: ["too_short"],
|
||||
memory: { type, source: "compaction" },
|
||||
textPreview: evidenceTextPreview(normalizedBody.text, 80),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply quality gate
|
||||
if (!shouldAcceptWorkspaceMemoryCandidate(
|
||||
const quality = evaluateWorkspaceMemoryCandidate(
|
||||
{ type, text: normalizedBody.text },
|
||||
{ fromMemoryTrigger: normalizedBody.hadTrigger },
|
||||
)) continue;
|
||||
);
|
||||
if (!quality.accepted) {
|
||||
evidence.push(extractionEvidence({
|
||||
type: "extraction_candidate_rejected",
|
||||
phase: "extraction",
|
||||
outcome: "rejected",
|
||||
reasonCodes: quality.reasons,
|
||||
memory: { type, source: "compaction" },
|
||||
textPreview: evidenceTextPreview(normalizedBody.text, 80),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push({
|
||||
const memory: LongTermMemoryEntry = {
|
||||
id: id("mem"),
|
||||
type,
|
||||
text: normalizedBody.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
|
||||
@@ -353,8 +484,17 @@ export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryE
|
||||
updatedAt: now,
|
||||
retentionClock: nowMs,
|
||||
staleAfterDays: staleAfterDaysFor(type),
|
||||
});
|
||||
};
|
||||
entries.push(memory);
|
||||
evidence.push(extractionEvidence({
|
||||
type: "extraction_candidate_accepted",
|
||||
phase: "extraction",
|
||||
outcome: "accepted",
|
||||
reasonCodes: ["quality_gate_passed", "valid_candidate_format"],
|
||||
memory: memoryEvidence(memory),
|
||||
textPreview: evidenceTextPreview(memory.text),
|
||||
}));
|
||||
}
|
||||
|
||||
return entries;
|
||||
return { entries, evidence };
|
||||
}
|
||||
|
||||
+92
-13
@@ -26,16 +26,17 @@
|
||||
import { rm } from "fs/promises";
|
||||
import type { Plugin } from "@opencode-ai/plugin";
|
||||
import {
|
||||
extractExplicitMemories,
|
||||
extractExplicitMemoriesWithEvidence,
|
||||
extractActiveFiles,
|
||||
extractErrorsFromBash,
|
||||
parseWorkspaceMemoryCandidates,
|
||||
parseWorkspaceMemoryCandidatesWithEvidence,
|
||||
} from "./extractors.ts";
|
||||
import {
|
||||
loadWorkspaceMemory,
|
||||
updateWorkspaceMemory,
|
||||
updateWorkspaceMemoryWithAccounting,
|
||||
renderWorkspaceMemory,
|
||||
accountWorkspaceMemoryRender,
|
||||
workspaceMemoryIdentityKey,
|
||||
} from "./workspace-memory.ts";
|
||||
import { reinforceMemory } from "./retention.ts";
|
||||
import {
|
||||
@@ -62,7 +63,8 @@ import {
|
||||
latestCompactionSummary,
|
||||
pendingTodos,
|
||||
} from "./opencode.ts";
|
||||
import { accountPendingPromotions } from "./promotion-accounting.ts";
|
||||
import { accountPendingPromotions, promotionAccountingEvidenceEvents } from "./promotion-accounting.ts";
|
||||
import { appendEvidenceEvent, appendEvidenceEvents, type EvidenceEventInput, type MemoryEvidenceRef } from "./evidence-log.ts";
|
||||
import { type LongTermMemoryEntry, WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts";
|
||||
|
||||
/**
|
||||
@@ -162,9 +164,23 @@ function renderTodosForCompaction(todos: Array<{ content: string; status: string
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function warnMemoryHook(scope: string, error: unknown): void {
|
||||
function safeErrorMessage(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.replace(/\s+/g, " ").slice(0, 240);
|
||||
}
|
||||
|
||||
async function warnMemoryHook(scope: string, error: unknown, root?: string): Promise<void> {
|
||||
const message = safeErrorMessage(error);
|
||||
console.error(`[memory] ${scope} failed: ${message}`);
|
||||
if (root) {
|
||||
await appendEvidenceEvent(root, {
|
||||
type: "hook_failed",
|
||||
phase: "hook",
|
||||
outcome: "failed",
|
||||
reasonCodes: [scope],
|
||||
details: { message },
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
@@ -203,6 +219,29 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
// Cache for processed user message IDs (to avoid duplicate processing)
|
||||
const processedUserMessages = new Map<string, Set<string>>();
|
||||
|
||||
function memoryEvidenceRef(memory: LongTermMemoryEntry): MemoryEvidenceRef {
|
||||
return {
|
||||
memoryId: memory.id,
|
||||
memoryKeyHash: memoryKey(memory),
|
||||
identityKeyHash: workspaceMemoryIdentityKey(memory),
|
||||
type: memory.type,
|
||||
source: memory.source,
|
||||
status: memory.status,
|
||||
};
|
||||
}
|
||||
|
||||
function pendingAppendedEvidence(memory: LongTermMemoryEntry): EvidenceEventInput {
|
||||
return {
|
||||
type: "pending_memory_appended",
|
||||
phase: "pending_journal",
|
||||
outcome: "accepted",
|
||||
memory: memoryEvidenceRef(memory),
|
||||
relations: [{ role: "pending", memory: memoryEvidenceRef(memory) }],
|
||||
reasonCodes: ["pending_journal_append"],
|
||||
textPreview: memory.text,
|
||||
};
|
||||
}
|
||||
|
||||
function pruneFrozenWorkspaceMemoryCache(now = Date.now()): void {
|
||||
for (const [sessionID, cached] of frozenWorkspaceMemoryCache) {
|
||||
if (now - cached.loadedAt > WORKSPACE_MEMORY_CACHE_LIMITS.frozenTtlMs) {
|
||||
@@ -259,7 +298,14 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
|
||||
if (!latestMessage?.id || processedForSession.has(latestMessage.id)) return;
|
||||
|
||||
const memories = extractExplicitMemories(latestMessage.text).map(memory => ({
|
||||
const extraction = extractExplicitMemoriesWithEvidence(latestMessage.text);
|
||||
await appendEvidenceEvents(directory, extraction.evidence.map(event => ({
|
||||
...event,
|
||||
sessionHash: sessionID,
|
||||
messageHash: latestMessage.id,
|
||||
})));
|
||||
|
||||
const memories = extraction.entries.map(memory => ({
|
||||
...memory,
|
||||
pendingOwnerSessionID: sessionID,
|
||||
pendingMessageID: latestMessage.id,
|
||||
@@ -272,6 +318,11 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
return state;
|
||||
});
|
||||
await appendPendingMemories(directory, memories);
|
||||
await appendEvidenceEvents(directory, memories.map(memory => ({
|
||||
...pendingAppendedEvidence(memory),
|
||||
sessionHash: sessionID,
|
||||
messageHash: latestMessage.id,
|
||||
})));
|
||||
}
|
||||
|
||||
if (decisions.length > 0) {
|
||||
@@ -385,6 +436,20 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
},
|
||||
);
|
||||
|
||||
await appendEvidenceEvents(directory, [
|
||||
...updateResult.evidence,
|
||||
...promotionAccountingEvidenceEvents({
|
||||
pending,
|
||||
after: updateResult.store.entries,
|
||||
events: updateResult.events,
|
||||
accounting,
|
||||
exhaustedRejectedKeys,
|
||||
}),
|
||||
].map(event => ({
|
||||
...event,
|
||||
sessionHash: sessionID,
|
||||
})));
|
||||
|
||||
const sessionRemovalKeys = new Set([
|
||||
...accounting.clearableKeys,
|
||||
...exhaustedRejectedKeys,
|
||||
@@ -459,7 +524,12 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
}
|
||||
|
||||
const store = await loadWorkspaceMemory(root);
|
||||
const renderedPrompt = renderWorkspaceMemory(store);
|
||||
const renderAccounting = accountWorkspaceMemoryRender(store);
|
||||
const renderedPrompt = renderAccounting.prompt;
|
||||
await appendEvidenceEvents(root, renderAccounting.evidence.map(event => ({
|
||||
...event,
|
||||
sessionHash: sessionID,
|
||||
})));
|
||||
frozenWorkspaceMemoryCache.set(sessionID, { store, renderedPrompt, loadedAt: now });
|
||||
pruneFrozenWorkspaceMemoryCache(now);
|
||||
return { store, renderedPrompt };
|
||||
@@ -518,7 +588,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
output.system.push(hotPrompt);
|
||||
}
|
||||
} catch (error) {
|
||||
warnMemoryHook("chat.system.transform", error);
|
||||
await warnMemoryHook("chat.system.transform", error, directory);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -577,7 +647,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
// Only process once per message ID
|
||||
await processLatestUserMessage(sessionID);
|
||||
} catch (error) {
|
||||
warnMemoryHook("tool.execute.after", error);
|
||||
await warnMemoryHook("tool.execute.after", error, directory);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -641,7 +711,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
// output.context if they want to preserve other plugin contributions.
|
||||
output.context.length = 0;
|
||||
} catch (error) {
|
||||
warnMemoryHook("session.compacting", error);
|
||||
await warnMemoryHook("session.compacting", error, directory);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -658,15 +728,24 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
// Parse latest compaction summary for memory candidates, stage them into
|
||||
// durable pending journal, then promote pending memories.
|
||||
const summary = await latestCompactionSummary(client, sessionID);
|
||||
const candidates = summary ? parseWorkspaceMemoryCandidates(summary) : [];
|
||||
const parseResult = summary ? parseWorkspaceMemoryCandidatesWithEvidence(summary) : { entries: [], evidence: [] };
|
||||
await appendEvidenceEvents(directory, parseResult.evidence.map(event => ({
|
||||
...event,
|
||||
sessionHash: sessionID,
|
||||
})));
|
||||
const candidates = parseResult.entries;
|
||||
if (candidates.length > 0) {
|
||||
await appendPendingMemories(directory, candidates);
|
||||
await appendEvidenceEvents(directory, candidates.map(memory => ({
|
||||
...pendingAppendedEvidence(memory),
|
||||
sessionHash: sessionID,
|
||||
})));
|
||||
}
|
||||
|
||||
await promotePendingMemories(sessionID, { includeUnownedJournal: true });
|
||||
} catch (error) {
|
||||
// Keep pending memories in session/journal for retry on next event/session.
|
||||
warnMemoryHook("event.session.compacted", error);
|
||||
await warnMemoryHook("event.session.compacted", error, directory);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -688,7 +767,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
await rm(await sessionStatePath(directory, sessionID), { force: true });
|
||||
}
|
||||
} catch (error) {
|
||||
warnMemoryHook("event.session.deleted", error);
|
||||
await warnMemoryHook("event.session.deleted", error, directory);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { LongTermMemoryEntry } from "./types.ts";
|
||||
import { memoryKey } from "./pending-journal.ts";
|
||||
import type { MemoryConsolidationEvent } from "./workspace-memory.ts";
|
||||
import { workspaceMemoryIdentityKey } from "./workspace-memory.ts";
|
||||
import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts";
|
||||
|
||||
export type PendingPromotionAccounting = {
|
||||
promotedKeys: Set<string>;
|
||||
@@ -105,3 +106,118 @@ export function accountPendingPromotions(input: {
|
||||
clearableKeys,
|
||||
};
|
||||
}
|
||||
|
||||
function memoryRef(memory: LongTermMemoryEntry | undefined): MemoryEvidenceRef | undefined {
|
||||
if (!memory) return undefined;
|
||||
return {
|
||||
memoryId: memory.id,
|
||||
memoryKeyHash: memoryKey(memory),
|
||||
identityKeyHash: workspaceMemoryIdentityKey(memory),
|
||||
type: memory.type,
|
||||
source: memory.source,
|
||||
status: memory.status,
|
||||
};
|
||||
}
|
||||
|
||||
function retainedMemoryFor(
|
||||
pending: LongTermMemoryEntry,
|
||||
event: MemoryConsolidationEvent | undefined,
|
||||
after: LongTermMemoryEntry[],
|
||||
): LongTermMemoryEntry | undefined {
|
||||
if (event?.retainedId) {
|
||||
const byId = after.find(memory => memory.id === event.retainedId);
|
||||
if (byId) return byId;
|
||||
}
|
||||
|
||||
const exactKey = memoryKey(pending);
|
||||
const identityKey = workspaceMemoryIdentityKey(pending);
|
||||
return after.find(memory => memory.status !== "superseded" && (
|
||||
memoryKey(memory) === exactKey || workspaceMemoryIdentityKey(memory) === identityKey
|
||||
));
|
||||
}
|
||||
|
||||
function promotionEventBase(
|
||||
type: EvidenceEventInput["type"],
|
||||
outcome: EvidenceEventInput["outcome"],
|
||||
memory: LongTermMemoryEntry,
|
||||
reasonCodes: string[],
|
||||
): EvidenceEventInput {
|
||||
return {
|
||||
type,
|
||||
phase: "promotion",
|
||||
outcome,
|
||||
memory: memoryRef(memory),
|
||||
reasonCodes,
|
||||
textPreview: memory.text,
|
||||
};
|
||||
}
|
||||
|
||||
export function promotionAccountingEvidenceEvents(input: {
|
||||
pending: LongTermMemoryEntry[];
|
||||
after: LongTermMemoryEntry[];
|
||||
events?: MemoryConsolidationEvent[];
|
||||
accounting: PendingPromotionAccounting;
|
||||
exhaustedRejectedKeys?: Set<string>;
|
||||
}): EvidenceEventInput[] {
|
||||
const terminalByKey = new Map((input.events ?? []).map(event => [event.memoryKey, event]));
|
||||
const exhaustedRejectedKeys = input.exhaustedRejectedKeys ?? new Set<string>();
|
||||
const evidence: EvidenceEventInput[] = [];
|
||||
|
||||
for (const pending of input.pending) {
|
||||
const key = memoryKey(pending);
|
||||
const terminal = terminalByKey.get(key);
|
||||
const retained = retainedMemoryFor(pending, terminal, input.after);
|
||||
|
||||
if (input.accounting.promotedKeys.has(key)) {
|
||||
evidence.push({
|
||||
...promotionEventBase("promotion_promoted", "promoted", pending, ["new_workspace_entry"]),
|
||||
relations: [
|
||||
{ role: "promoted", memory: memoryRef(retained ?? pending) },
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (input.accounting.absorbedKeys.has(key)) {
|
||||
const exact = terminal?.reason !== "absorbed_identity";
|
||||
evidence.push({
|
||||
...promotionEventBase(
|
||||
exact ? "promotion_absorbed_exact" : "promotion_absorbed_identity",
|
||||
"absorbed",
|
||||
pending,
|
||||
[exact ? "same_exact_key" : "same_identity_key"],
|
||||
),
|
||||
relations: [
|
||||
{ role: "absorbed" as const, memory: memoryRef(pending) },
|
||||
{ role: "retained" as const, memory: memoryRef(retained) },
|
||||
].filter(relation => relation.memory),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (input.accounting.supersededKeys.has(key)) {
|
||||
evidence.push({
|
||||
...promotionEventBase("promotion_superseded", "superseded", pending, ["superseded_existing"]),
|
||||
relations: [
|
||||
{ role: "superseded" as const, memory: memoryRef(pending) },
|
||||
{ role: "superseded_by" as const, memory: memoryRef(retained) },
|
||||
].filter(relation => relation.memory),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (input.accounting.rejectedKeys.has(key)) {
|
||||
evidence.push(promotionEventBase("promotion_rejected_capacity", "rejected", pending, ["capacity_rejected"]));
|
||||
if (input.accounting.retryableRejectedKeys.has(key)) {
|
||||
evidence.push(promotionEventBase(
|
||||
exhaustedRejectedKeys.has(key) ? "promotion_retry_exhausted" : "promotion_retry_scheduled",
|
||||
exhaustedRejectedKeys.has(key) ? "exhausted" : "retried",
|
||||
pending,
|
||||
[exhaustedRejectedKeys.has(key) ? "max_attempts_reached" : "retryable_capacity_rejection"],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return evidence;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,30 @@ import { randomUUID } from "crypto";
|
||||
import { mkdir, open, readFile, rename, rm, stat, writeFile } from "fs/promises";
|
||||
import type { FileHandle } from "fs/promises";
|
||||
import { dirname } from "path";
|
||||
import { appendEvidenceEventForWorkspaceKey } from "./evidence-log.ts";
|
||||
|
||||
const fileLocks = new Map<string, Promise<unknown>>();
|
||||
const LOCK_WAIT_TIMEOUT_MS = 5000;
|
||||
const LOCK_STALE_MS = 30_000;
|
||||
const LOCK_HEARTBEAT_MS = 1_000;
|
||||
|
||||
function workspaceKeyFromStorePath(path: string): string | undefined {
|
||||
return path.match(/[\\/]opencode-working-memory[\\/]workspaces[\\/]([a-f0-9]{16})[\\/]/i)?.[1];
|
||||
}
|
||||
|
||||
function storeKindFromPath(path: string): string {
|
||||
if (path.endsWith("workspace-memory.json")) return "workspace_memory";
|
||||
if (path.endsWith("workspace-pending-journal.json")) return "pending_journal";
|
||||
if (path.includes(`${"/"}sessions${"/"}`) || path.includes(`${"\\"}sessions${"\\"}`)) return "session_state";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
async function emitStorageEvidence(path: string, event: Parameters<typeof appendEvidenceEventForWorkspaceKey>[1]): Promise<void> {
|
||||
const key = workspaceKeyFromStorePath(path);
|
||||
if (!key) return;
|
||||
await appendEvidenceEventForWorkspaceKey(key, event).catch(() => undefined);
|
||||
}
|
||||
|
||||
async function quarantineCorruptJSON(path: string): Promise<string | null> {
|
||||
const quarantinePath = `${path}.corrupt-${Date.now()}-${process.pid}-${randomUUID()}`;
|
||||
|
||||
@@ -31,6 +49,16 @@ export async function readJSON<T>(path: string, fallback: () => T): Promise<T> {
|
||||
|
||||
if (quarantinePath) {
|
||||
console.error(`[memory] invalid JSON in ${path}; quarantined to ${quarantinePath}: ${message}`);
|
||||
await emitStorageEvidence(path, {
|
||||
type: "storage_corrupt_json_quarantined",
|
||||
phase: "storage",
|
||||
outcome: "quarantined",
|
||||
reasonCodes: ["invalid_json"],
|
||||
details: {
|
||||
storeKind: storeKindFromPath(path),
|
||||
quarantined: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.error(`[memory] invalid JSON in ${path}; using fallback without quarantine: ${message}`);
|
||||
}
|
||||
@@ -103,10 +131,30 @@ async function withFileLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
|
||||
|
||||
if (await isLockStale(lockPath)) {
|
||||
await rm(lockPath, { force: true });
|
||||
await emitStorageEvidence(path, {
|
||||
type: "storage_stale_lock_recovered",
|
||||
phase: "storage",
|
||||
outcome: "recovered",
|
||||
reasonCodes: ["stale_lock"],
|
||||
details: {
|
||||
storeKind: storeKindFromPath(path),
|
||||
waitMs: Date.now() - started,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Date.now() - started > LOCK_WAIT_TIMEOUT_MS) {
|
||||
await emitStorageEvidence(path, {
|
||||
type: "storage_lock_timeout",
|
||||
phase: "storage",
|
||||
outcome: "failed",
|
||||
reasonCodes: ["lock_wait_timeout"],
|
||||
details: {
|
||||
storeKind: storeKindFromPath(path),
|
||||
waitMs: LOCK_WAIT_TIMEOUT_MS,
|
||||
},
|
||||
});
|
||||
throw new Error(`Timed out waiting for lock ${lockPath}`);
|
||||
}
|
||||
|
||||
|
||||
+119
-6
@@ -11,6 +11,7 @@ import {
|
||||
calculateRetentionStrength,
|
||||
reinforceMemory,
|
||||
} from "./retention.ts";
|
||||
import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts";
|
||||
|
||||
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
|
||||
const MIN_ENVELOPE_LENGTH = 80;
|
||||
@@ -38,6 +39,7 @@ export type LongTermLimitResult = {
|
||||
dropped: MemoryConsolidationEvent[];
|
||||
absorbed: MemoryConsolidationEvent[];
|
||||
superseded: MemoryConsolidationEvent[];
|
||||
evidence: EvidenceEventInput[];
|
||||
};
|
||||
|
||||
export type WorkspaceMemoryNormalizationResult = LongTermLimitResult & {
|
||||
@@ -45,6 +47,16 @@ export type WorkspaceMemoryNormalizationResult = LongTermLimitResult & {
|
||||
events: MemoryConsolidationEvent[];
|
||||
};
|
||||
|
||||
export type WorkspaceMemoryRenderAccounting = {
|
||||
rendered: LongTermMemoryEntry[];
|
||||
omitted: Array<{
|
||||
memory: LongTermMemoryEntry;
|
||||
reason: "superseded" | "type_cap" | "global_cap" | "char_budget" | "empty_render_budget";
|
||||
}>;
|
||||
evidence: EvidenceEventInput[];
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export type QualityCleanupMigrationLogEntry = {
|
||||
migrationId: string;
|
||||
timestamp: string;
|
||||
@@ -156,6 +168,7 @@ export async function updateWorkspaceMemoryWithAccounting(
|
||||
dropped: [],
|
||||
absorbed: [],
|
||||
superseded: [],
|
||||
evidence: [],
|
||||
events: [],
|
||||
};
|
||||
}
|
||||
@@ -244,6 +257,7 @@ export async function normalizeWorkspaceMemoryWithAccounting(
|
||||
dropped: accounting.dropped,
|
||||
absorbed: accounting.absorbed,
|
||||
superseded: accounting.superseded,
|
||||
evidence: accounting.evidence,
|
||||
events: [...accounting.dropped, ...accounting.absorbed, ...accounting.superseded],
|
||||
};
|
||||
}
|
||||
@@ -520,29 +534,39 @@ export function enforceLongTermLimitsWithAccounting(
|
||||
dropped: [...dedupeResult.dropped, ...capacityDropped],
|
||||
absorbed: dedupeResult.absorbed,
|
||||
superseded: dedupeResult.superseded,
|
||||
evidence: dedupeResult.evidence,
|
||||
};
|
||||
}
|
||||
|
||||
function applyTypeMaxCaps(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
|
||||
return applyTypeMaxCapsWithOmissions(entries).kept;
|
||||
}
|
||||
|
||||
function applyTypeMaxCapsWithOmissions(entries: LongTermMemoryEntry[]): { kept: LongTermMemoryEntry[]; omitted: LongTermMemoryEntry[] } {
|
||||
const capped: LongTermMemoryEntry[] = [];
|
||||
const omitted: LongTermMemoryEntry[] = [];
|
||||
const typeCounts: Partial<Record<LongTermMemoryEntry["type"], number>> = {};
|
||||
|
||||
for (const entry of entries) {
|
||||
const count = typeCounts[entry.type] ?? 0;
|
||||
const max = RETENTION_TYPE_MAX[entry.type] ?? Infinity;
|
||||
if (count >= max) continue;
|
||||
if (count >= max) {
|
||||
omitted.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
capped.push(entry);
|
||||
typeCounts[entry.type] = count + 1;
|
||||
}
|
||||
|
||||
return capped;
|
||||
return { kept: capped, omitted };
|
||||
}
|
||||
|
||||
export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult {
|
||||
const now = Date.now();
|
||||
const absorbed: MemoryConsolidationEvent[] = [];
|
||||
const superseded: MemoryConsolidationEvent[] = [];
|
||||
const evidence: EvidenceEventInput[] = [];
|
||||
|
||||
// For project/reference/feedback: dedupe by concrete identity or exact canonical text.
|
||||
const projectRefEntries = entries.filter(e => e.type === "project" || e.type === "reference" || e.type === "feedback");
|
||||
@@ -566,6 +590,8 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
|
||||
reinforcementSessionId(retained, dropped),
|
||||
now,
|
||||
);
|
||||
const reinforcedEvent = reinforcementEvidence(retained, dropped, reinforced, reason);
|
||||
if (reinforcedEvent) evidence.push(reinforcedEvent);
|
||||
|
||||
absorbed.push(consolidationEvent(dropped, reason, reinforced));
|
||||
entityDeduped.set(key, reinforced);
|
||||
@@ -592,6 +618,8 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
|
||||
reinforcementSessionId(retained, dropped),
|
||||
now,
|
||||
);
|
||||
const reinforcedEvent = reinforcementEvidence(retained, dropped, reinforced, reason);
|
||||
if (reinforcedEvent) evidence.push(reinforcedEvent);
|
||||
|
||||
if (reason === "superseded_existing") {
|
||||
superseded.push(consolidationEvent(dropped, reason, reinforced));
|
||||
@@ -614,6 +642,40 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
|
||||
dropped: [],
|
||||
absorbed,
|
||||
superseded,
|
||||
evidence,
|
||||
};
|
||||
}
|
||||
|
||||
function memoryEvidenceRef(memory: LongTermMemoryEntry): MemoryEvidenceRef {
|
||||
return {
|
||||
memoryId: memory.id,
|
||||
memoryKeyHash: workspaceMemoryExactKey(memory),
|
||||
identityKeyHash: workspaceMemoryIdentityKey(memory),
|
||||
type: memory.type,
|
||||
source: memory.source,
|
||||
status: memory.status,
|
||||
};
|
||||
}
|
||||
|
||||
function reinforcementEvidence(
|
||||
retained: LongTermMemoryEntry,
|
||||
dropped: LongTermMemoryEntry,
|
||||
reinforced: LongTermMemoryEntry,
|
||||
reason: "absorbed_exact" | "absorbed_identity" | "superseded_existing",
|
||||
): EvidenceEventInput | undefined {
|
||||
if ((reinforced.reinforcementCount ?? 0) <= (retained.reinforcementCount ?? 0)) return undefined;
|
||||
const duplicateReason = reason === "absorbed_identity" ? "duplicate_identity" : "duplicate_exact";
|
||||
return {
|
||||
type: "memory_reinforced",
|
||||
phase: "reinforcement",
|
||||
outcome: "reinforced",
|
||||
memory: memoryEvidenceRef(reinforced),
|
||||
relations: [
|
||||
{ role: "reinforced", memory: memoryEvidenceRef(reinforced) },
|
||||
{ role: "reinforced_by", memory: memoryEvidenceRef(dropped) },
|
||||
],
|
||||
reasonCodes: [duplicateReason, "reinforcement_window_allowed"],
|
||||
textPreview: reinforced.text,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -648,20 +710,49 @@ function wouldFit(
|
||||
}
|
||||
|
||||
export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string {
|
||||
const active = enforceLongTermLimitsWithAccounting(store.entries, store).kept;
|
||||
if (active.length === 0) return "";
|
||||
return accountWorkspaceMemoryRender(store).prompt;
|
||||
}
|
||||
|
||||
export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): WorkspaceMemoryRenderAccounting {
|
||||
const now = Date.now();
|
||||
const maxChars = Math.min(
|
||||
store.limits.maxRenderedChars,
|
||||
LONG_TERM_LIMITS.maxRenderedChars
|
||||
);
|
||||
const omitted: WorkspaceMemoryRenderAccounting["omitted"] = [];
|
||||
const evidence: EvidenceEventInput[] = [];
|
||||
|
||||
for (const entry of store.entries) {
|
||||
if (entry.status === "superseded") {
|
||||
omitted.push({ memory: entry, reason: "superseded" });
|
||||
}
|
||||
}
|
||||
|
||||
const activeEntries = store.entries.filter(entry => entry.status !== "superseded");
|
||||
const phase1 = activeEntries.map(entry => ({ ...entry, text: entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars) }));
|
||||
const dedupeResult = dedupeLongTermEntriesWithAccounting(phase1);
|
||||
const sorted = [...dedupeResult.kept].sort((a, b) => compareLongTermMemoryForRetention(a, b, now, store.lastActivityAt));
|
||||
const typeCapResult = applyTypeMaxCapsWithOmissions(sorted);
|
||||
for (const memory of typeCapResult.omitted) omitted.push({ memory, reason: "type_cap" });
|
||||
const active = typeCapResult.kept.slice(0, LONG_TERM_LIMITS.maxEntries);
|
||||
for (const memory of typeCapResult.kept.slice(LONG_TERM_LIMITS.maxEntries)) omitted.push({ memory, reason: "global_cap" });
|
||||
|
||||
if (active.length === 0) {
|
||||
for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason));
|
||||
return { rendered: [], omitted, evidence, prompt: "" };
|
||||
}
|
||||
|
||||
// If maxChars smaller than minimum envelope, return empty string
|
||||
if (maxChars < MIN_ENVELOPE_LENGTH) return "";
|
||||
if (maxChars < MIN_ENVELOPE_LENGTH) {
|
||||
for (const memory of active) omitted.push({ memory, reason: "empty_render_budget" });
|
||||
for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason));
|
||||
return { rendered: [], omitted, evidence, prompt: "" };
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
"Workspace memory (cross-session, verify if stale):",
|
||||
];
|
||||
const rendered: LongTermMemoryEntry[] = [];
|
||||
|
||||
for (const type of ["feedback", "project", "decision", "reference"] as const) {
|
||||
const items = active.filter(entry => entry.type === type);
|
||||
@@ -673,6 +764,9 @@ export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string {
|
||||
const line = `- ${renderEntry(item)}`;
|
||||
if ([...lines, ...sectionLines, line].join("\n").length <= maxChars) {
|
||||
sectionLines.push(line);
|
||||
rendered.push(item);
|
||||
} else {
|
||||
omitted.push({ memory: item, reason: "char_budget" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,7 +775,26 @@ export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string {
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
for (const memory of rendered) evidence.push(renderEvidence(memory, "rendered"));
|
||||
for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason));
|
||||
|
||||
return { rendered, omitted, evidence, prompt: lines.join("\n") };
|
||||
}
|
||||
|
||||
function renderEvidence(
|
||||
memory: LongTermMemoryEntry,
|
||||
outcome: "rendered" | "omitted",
|
||||
reason?: WorkspaceMemoryRenderAccounting["omitted"][number]["reason"],
|
||||
): EvidenceEventInput {
|
||||
return {
|
||||
type: outcome === "rendered" ? "render_selected" : "render_omitted",
|
||||
phase: "render",
|
||||
outcome,
|
||||
memory: memoryEvidenceRef(memory),
|
||||
relations: [{ role: outcome === "rendered" ? "rendered" : "omitted", memory: memoryEvidenceRef(memory) }],
|
||||
reasonCodes: outcome === "rendered" ? ["within_caps", "within_char_budget"] : [reason ?? "char_budget"],
|
||||
textPreview: memory.text,
|
||||
};
|
||||
}
|
||||
|
||||
function renderEntry(entry: LongTermMemoryEntry): string {
|
||||
|
||||
@@ -3,7 +3,13 @@ import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { extractErrorsFromBash, extractExplicitMemories, parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
|
||||
import {
|
||||
extractErrorsFromBash,
|
||||
extractExplicitMemories,
|
||||
extractExplicitMemoriesWithEvidence,
|
||||
parseWorkspaceMemoryCandidates,
|
||||
parseWorkspaceMemoryCandidatesWithEvidence,
|
||||
} from "../src/extractors.ts";
|
||||
|
||||
async function waitForFile(path: string, attempts = 20): Promise<string> {
|
||||
let lastError: unknown;
|
||||
@@ -145,6 +151,20 @@ test("extractExplicitMemories captures multiple memories in same message", () =>
|
||||
assert.equal(items.length, 2);
|
||||
});
|
||||
|
||||
test("explicit memory extraction returns detected and ignored evidence", () => {
|
||||
const result = extractExplicitMemoriesWithEvidence([
|
||||
"remember this: Prefer deterministic tests.",
|
||||
"don't remember this: temporary password: sushi",
|
||||
"remember this: later",
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(result.entries.length, 1);
|
||||
assert.ok(result.evidence.some(event => event.type === "explicit_memory_detected"));
|
||||
assert.ok(result.evidence.some(event => event.type === "explicit_memory_ignored" && event.reasonCodes.includes("negated_request")));
|
||||
assert.ok(result.evidence.some(event => event.type === "explicit_memory_ignored" && event.reasonCodes.includes("deferral")));
|
||||
assert.equal(JSON.stringify(result.evidence).includes("sushi"), false);
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Task 7: Compaction quality gate tests
|
||||
// ============================================
|
||||
@@ -176,6 +196,41 @@ test("parseWorkspaceMemoryCandidates rejects raw error", () => {
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("compaction accepted candidate returns privacy-safe extraction evidence", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- [decision] Use accounting evidence events to explain promoted memories in diagnostics.
|
||||
`;
|
||||
|
||||
const result = parseWorkspaceMemoryCandidatesWithEvidence(summary);
|
||||
|
||||
assert.equal(result.entries.length, 1);
|
||||
assert.equal(result.evidence.length, 1);
|
||||
assert.equal(result.evidence[0].type, "extraction_candidate_accepted");
|
||||
assert.ok(result.evidence[0].reasonCodes.includes("quality_gate_passed"));
|
||||
assert.ok(result.evidence[0].reasonCodes.includes("valid_candidate_format"));
|
||||
assert.match(result.evidence[0].textPreview ?? "", /accounting evidence events/);
|
||||
});
|
||||
|
||||
test("compaction rejected candidate returns rejection evidence without secrets", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- [feedback] password: sushi Admin PIN 是 456123 Bearer abc.def.ghi TypeError: Cannot read property x
|
||||
`;
|
||||
|
||||
const result = parseWorkspaceMemoryCandidatesWithEvidence(summary);
|
||||
const raw = JSON.stringify(result.evidence);
|
||||
|
||||
assert.equal(result.entries.length, 0);
|
||||
assert.equal(result.evidence.length, 1);
|
||||
assert.equal(result.evidence[0].type, "extraction_candidate_rejected");
|
||||
assert.ok(result.evidence[0].reasonCodes.length > 0);
|
||||
assert.equal(raw.includes("sushi"), false);
|
||||
assert.equal(raw.includes("456123"), false);
|
||||
assert.equal(raw.includes("abc.def.ghi"), false);
|
||||
assert.ok((result.evidence[0].textPreview?.length ?? 0) <= 80);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects stack trace", () => {
|
||||
const summary = `
|
||||
## Memory Candidates
|
||||
|
||||
+69
-1
@@ -8,9 +8,10 @@ import { loadSessionState, saveSessionState } from "../src/session-state.ts";
|
||||
import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
|
||||
import type { OpenError } from "../src/types.ts";
|
||||
import { PROMOTION_RETRY_LIMITS, WORKSPACE_MEMORY_CACHE_LIMITS } from "../src/types.ts";
|
||||
import { workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
|
||||
import { sessionStatePath, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
|
||||
import { loadPendingJournal, savePendingJournal, memoryKey } from "../src/pending-journal.ts";
|
||||
import { loadWorkspaceMemory, updateWorkspaceMemory } from "../src/workspace-memory.ts";
|
||||
import { queryEvidenceEvents } from "../src/evidence-log.ts";
|
||||
|
||||
// Mock client for root session (not a sub-agent)
|
||||
function mockRootClient() {
|
||||
@@ -477,6 +478,35 @@ test("chat system transform degrades gracefully when workspace memory JSON is co
|
||||
}
|
||||
});
|
||||
|
||||
test("hook failure emits hook_failed evidence without raw tool output", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
try {
|
||||
const statePath = await sessionStatePath(tmpDir, "hook-failure-session");
|
||||
await mkdir(dirname(statePath), { recursive: true });
|
||||
await mkdir(statePath, { recursive: true });
|
||||
|
||||
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
|
||||
await (plugin as Record<string, Function>)["tool.execute.after"](
|
||||
{
|
||||
tool: "bash",
|
||||
sessionID: "hook-failure-session",
|
||||
args: { command: "cat secret-output.txt" },
|
||||
},
|
||||
{ output: "raw tool output password: sushi should not be in evidence", exitCode: 1 },
|
||||
);
|
||||
|
||||
const events = await queryEvidenceEvents(tmpDir, { types: ["hook_failed"] });
|
||||
const raw = JSON.stringify(events);
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].reasonCodes.includes("tool.execute.after"), true);
|
||||
assert.equal(raw.includes("raw tool output"), false);
|
||||
assert.equal(raw.includes("sushi"), false);
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("no compaction: owned explicit memory is not promoted by unrelated next session start", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
@@ -903,6 +933,44 @@ test("integration: explicit memory flows from user message through pending journ
|
||||
}
|
||||
});
|
||||
|
||||
test("explicit memory lifecycle emits detected appended and promoted evidence", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
try {
|
||||
const plugin = await MemoryV2Plugin({
|
||||
directory: tmpDir,
|
||||
client: mockClientWithLatestUser("remember this: Prefer evidence-backed lifecycle tests.", "msg-evidence-life"),
|
||||
});
|
||||
|
||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID: "evidence-life-session", model: {} },
|
||||
{ system: ["base header"] },
|
||||
);
|
||||
|
||||
await (plugin as Record<string, Function>)["event"]({
|
||||
event: { type: "session.compacted", properties: { sessionID: "evidence-life-session" } },
|
||||
});
|
||||
|
||||
const events = await queryEvidenceEvents(tmpDir, { newestFirst: false });
|
||||
const eventTypes = events.map(event => event.type);
|
||||
|
||||
assert.ok(eventTypes.includes("explicit_memory_detected"));
|
||||
assert.ok(eventTypes.includes("pending_memory_appended"));
|
||||
assert.ok(eventTypes.includes("promotion_promoted"));
|
||||
|
||||
const output = { system: ["base header"] };
|
||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID: "evidence-life-session", model: {} },
|
||||
output,
|
||||
);
|
||||
|
||||
const finalEvents = await queryEvidenceEvents(tmpDir, { newestFirst: false });
|
||||
assert.ok(finalEvents.map(event => event.type).includes("render_selected"));
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("session.compacted promotes first-time explicit memory without self-reinforcement", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { LongTermMemoryEntry } from "../src/types.ts";
|
||||
import { accountPendingPromotions } from "../src/promotion-accounting.ts";
|
||||
import { accountPendingPromotions, promotionAccountingEvidenceEvents } from "../src/promotion-accounting.ts";
|
||||
import { memoryKey } from "../src/pending-journal.ts";
|
||||
import type { MemoryConsolidationEvent } from "../src/workspace-memory.ts";
|
||||
import { workspaceMemoryExactKey, workspaceMemoryIdentityKey } from "../src/workspace-memory.ts";
|
||||
@@ -229,3 +229,57 @@ test("accountPendingPromotions marks manual capacity rejection as retryable", ()
|
||||
assert.equal(result.clearableKeys.size, 0);
|
||||
assert.deepEqual([...result.retryableRejectedKeys], [memoryKey(pending[0])]);
|
||||
});
|
||||
|
||||
test("promotionAccountingEvidenceEvents maps every promotion outcome with relations", () => {
|
||||
const promoted = mem("promoted", "Promoted memory should produce evidence.", { source: "explicit" });
|
||||
const absorbed = mem("absorbed", "Absorbed memory should produce evidence.", { source: "explicit" });
|
||||
const retained = mem("retained", "absorbed memory should produce evidence.", { source: "explicit" });
|
||||
const identityAbsorbed = mem("identity-absorbed", "Project config lives in `src/config.ts`", { type: "reference" });
|
||||
const identityRetained = mem("identity-retained", "Project config lives in `./src/config.ts`", { type: "reference" });
|
||||
const superseded = mem("superseded", "Parser supports 3 formats.", { source: "compaction" });
|
||||
const replacement = mem("replacement", "Parser supports 4 formats.", { source: "compaction" });
|
||||
const capacity = mem("capacity", "Capacity rejected explicit memory should retry.", { source: "explicit", type: "reference" });
|
||||
const exhausted = mem("exhausted", "Exhausted explicit memory should stop retrying.", { source: "explicit", type: "reference" });
|
||||
const pending = [promoted, absorbed, identityAbsorbed, superseded, capacity, exhausted];
|
||||
const accounting = {
|
||||
promotedKeys: new Set([memoryKey(promoted)]),
|
||||
absorbedKeys: new Set([memoryKey(absorbed), memoryKey(identityAbsorbed)]),
|
||||
supersededKeys: new Set([memoryKey(superseded)]),
|
||||
rejectedKeys: new Set([memoryKey(capacity), memoryKey(exhausted)]),
|
||||
retryableRejectedKeys: new Set([memoryKey(capacity), memoryKey(exhausted)]),
|
||||
clearableKeys: new Set([memoryKey(promoted), memoryKey(absorbed), memoryKey(identityAbsorbed), memoryKey(superseded), memoryKey(exhausted)]),
|
||||
};
|
||||
const events = [
|
||||
{ ...event(absorbed, "absorbed_exact"), retainedId: retained.id },
|
||||
{ ...event(identityAbsorbed, "absorbed_identity"), retainedId: identityRetained.id },
|
||||
{ ...event(superseded, "superseded_existing"), retainedId: replacement.id, supersededId: superseded.id },
|
||||
event(capacity, "rejected_capacity"),
|
||||
event(exhausted, "rejected_capacity"),
|
||||
];
|
||||
|
||||
const evidence = promotionAccountingEvidenceEvents({
|
||||
pending,
|
||||
after: [promoted, retained, identityRetained, replacement],
|
||||
events,
|
||||
accounting,
|
||||
exhaustedRejectedKeys: new Set([memoryKey(exhausted)]),
|
||||
});
|
||||
|
||||
const expectedPromotionEventTypes = new Set([
|
||||
"promotion_promoted",
|
||||
"promotion_absorbed_exact",
|
||||
"promotion_absorbed_identity",
|
||||
"promotion_superseded",
|
||||
"promotion_rejected_capacity",
|
||||
"promotion_retry_scheduled",
|
||||
"promotion_retry_exhausted",
|
||||
]);
|
||||
|
||||
assert.deepEqual(new Set(evidence.map(event => event.type)), expectedPromotionEventTypes);
|
||||
const absorbedEvent = evidence.find(event => event.type === "promotion_absorbed_exact");
|
||||
assert.ok(absorbedEvent?.relations?.some(relation => relation.role === "absorbed" && relation.memory?.memoryId === absorbed.id));
|
||||
assert.ok(absorbedEvent?.relations?.some(relation => relation.role === "retained" && relation.memory?.memoryId === retained.id));
|
||||
const supersededEvent = evidence.find(event => event.type === "promotion_superseded");
|
||||
assert.ok(supersededEvent?.relations?.some(relation => relation.role === "superseded" && relation.memory?.memoryId === superseded.id));
|
||||
assert.ok(supersededEvent?.relations?.some(relation => relation.role === "superseded_by" && relation.memory?.memoryId === replacement.id));
|
||||
});
|
||||
|
||||
+64
-2
@@ -1,11 +1,13 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { spawn } from "node:child_process";
|
||||
import { readJSON, updateJSON } from "../src/storage.ts";
|
||||
import { queryEvidenceEvents } from "../src/evidence-log.ts";
|
||||
import { workspaceMemoryPath } from "../src/paths.ts";
|
||||
|
||||
test("updateJSON serializes concurrent increments", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wm-storage-"));
|
||||
@@ -56,6 +58,25 @@ test("readJSON quarantines corrupt JSON and returns fallback", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("readJSON emits corrupt JSON quarantine evidence for workspace stores", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wm-storage-evidence-corrupt-"));
|
||||
try {
|
||||
const path = await workspaceMemoryPath(root);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, "{ invalid json", "utf8");
|
||||
|
||||
const loaded = await readJSON(path, () => ({ ok: true }));
|
||||
const events = await queryEvidenceEvents(root, { types: ["storage_corrupt_json_quarantined"] });
|
||||
|
||||
assert.deepEqual(loaded, { ok: true });
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].reasonCodes.includes("invalid_json"), true);
|
||||
assert.equal(JSON.stringify(events).includes("invalid json"), false);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("updateJSON recovers stale lock files left by crashed process", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wm-storage-stale-lock-"));
|
||||
try {
|
||||
@@ -71,6 +92,47 @@ test("updateJSON recovers stale lock files left by crashed process", async () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("updateJSON emits stale lock recovery evidence for workspace stores", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wm-storage-evidence-stale-lock-"));
|
||||
try {
|
||||
const path = await workspaceMemoryPath(root);
|
||||
const lockPath = `${path}.lock`;
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(lockPath, `999999\n0\n`, "utf8");
|
||||
|
||||
await updateJSON(path, () => ({ count: 0 }), current => ({ count: current.count + 1 }));
|
||||
const events = await queryEvidenceEvents(root, { types: ["storage_stale_lock_recovered"] });
|
||||
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].reasonCodes.includes("stale_lock"), true);
|
||||
assert.equal(JSON.stringify(events).includes("999999"), false);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("updateJSON emits lock timeout evidence and still throws", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wm-storage-evidence-timeout-"));
|
||||
try {
|
||||
const path = await workspaceMemoryPath(root);
|
||||
const lockPath = `${path}.lock`;
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(lockPath, `${process.pid}\n${Date.now()}\n`, "utf8");
|
||||
|
||||
await assert.rejects(
|
||||
updateJSON(path, () => ({ count: 0 }), current => current),
|
||||
/Timed out waiting for lock/,
|
||||
);
|
||||
const events = await queryEvidenceEvents(root, { types: ["storage_lock_timeout"] });
|
||||
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].reasonCodes.includes("lock_wait_timeout"), true);
|
||||
assert.equal(JSON.stringify(events).includes(String(process.pid)), false);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("updateJSON serializes writes across separate node processes", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wm-storage-xproc-"));
|
||||
try {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { HOT_STATE_LIMITS, LONG_TERM_LIMITS } from "../src/types.ts";
|
||||
import { workspaceKey, workspaceMemoryPath } from "../src/paths.ts";
|
||||
import {
|
||||
renderWorkspaceMemory,
|
||||
accountWorkspaceMemoryRender,
|
||||
enforceLongTermLimits,
|
||||
dedupeLongTermEntriesWithAccounting,
|
||||
enforceLongTermLimitsWithAccounting,
|
||||
@@ -157,6 +158,47 @@ test("renderWorkspaceMemory returns empty for no entries", () => {
|
||||
assert.equal(rendered, "");
|
||||
});
|
||||
|
||||
test("accountWorkspaceMemoryRender reports rendered and omitted reasons", () => {
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: 1,
|
||||
workspace: { root: "/repo", key: "abc" },
|
||||
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||
entries: [
|
||||
...Array.from({ length: 12 }, (_, i) => entry(`feedback-render-${i}`, `Unique rendered feedback preference ${i}`, "feedback")),
|
||||
{ ...entry("superseded-render", "Old superseded memory", "decision"), status: "superseded" as const },
|
||||
],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const accounting = accountWorkspaceMemoryRender(store);
|
||||
|
||||
assert.equal(accounting.rendered.length, 10);
|
||||
assert.ok(accounting.omitted.some(item => item.reason === "type_cap"));
|
||||
assert.ok(accounting.omitted.some(item => item.reason === "superseded"));
|
||||
assert.ok(accounting.evidence.some(event => event.type === "render_selected"));
|
||||
assert.ok(accounting.evidence.some(event => event.type === "render_omitted" && event.reasonCodes.includes("type_cap")));
|
||||
});
|
||||
|
||||
test("accountWorkspaceMemoryRender reports char budget and empty budget omissions", () => {
|
||||
const charBudgetStore: WorkspaceMemoryStore = {
|
||||
version: 1,
|
||||
workspace: { root: "/repo", key: "abc" },
|
||||
limits: { maxRenderedChars: 180, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||
entries: Array.from({ length: 3 }, (_, i) => entry(`char-budget-${i}`, `Long rendered memory ${i} `.repeat(20), "decision")),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const emptyBudgetStore: WorkspaceMemoryStore = {
|
||||
...charBudgetStore,
|
||||
limits: { maxRenderedChars: 10, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||
};
|
||||
|
||||
const charBudget = accountWorkspaceMemoryRender(charBudgetStore);
|
||||
const emptyBudget = accountWorkspaceMemoryRender(emptyBudgetStore);
|
||||
|
||||
assert.ok(charBudget.omitted.some(item => item.reason === "char_budget"));
|
||||
assert.ok(emptyBudget.omitted.some(item => item.reason === "empty_render_budget"));
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// PR-2 Task 5 tests (for enforceLongTermLimits)
|
||||
// ============================================
|
||||
@@ -485,6 +527,30 @@ test("dedupeLongTermEntriesWithAccounting reinforces absorbed exact duplicates",
|
||||
assert.equal(result.kept[0].reinforcementCount, 1);
|
||||
assert.equal(result.kept[0].lastReinforcedSessionID, "reinforce-session");
|
||||
assert.ok(typeof result.kept[0].retentionClock === "number");
|
||||
assert.ok(result.evidence.some(event =>
|
||||
event.type === "memory_reinforced" &&
|
||||
event.reasonCodes.includes("duplicate_exact") &&
|
||||
event.relations?.some(relation => relation.role === "reinforced" && relation.memory?.memoryId === "duplicate") &&
|
||||
event.relations?.some(relation => relation.role === "reinforced_by" && relation.memory?.memoryId === "retained")
|
||||
));
|
||||
});
|
||||
|
||||
test("dedupeLongTermEntriesWithAccounting emits identity reinforcement evidence", () => {
|
||||
const now = Date.now();
|
||||
const retained: LongTermMemoryEntry = {
|
||||
...entry("retained-identity", "OpenCode plugin config location: `.opencode-agenthub/current/xdg/opencode/opencode.json` in workspace", "reference"),
|
||||
retentionClock: now - 10 * DAY_MS,
|
||||
};
|
||||
const duplicate: LongTermMemoryEntry = {
|
||||
...entry("duplicate-identity", "OpenCode plugin config: .opencode-agenthub/current/xdg/opencode/opencode.json in workspace", "reference"),
|
||||
pendingOwnerSessionID: "identity-session",
|
||||
};
|
||||
|
||||
const result = dedupeLongTermEntriesWithAccounting([retained, duplicate]);
|
||||
|
||||
assert.ok(result.evidence.some(event =>
|
||||
event.type === "memory_reinforced" && event.reasonCodes.includes("duplicate_identity")
|
||||
));
|
||||
});
|
||||
|
||||
test("reinforced memory with same initial strength and age ranks above unreinforced memory", () => {
|
||||
@@ -530,6 +596,7 @@ test("dedupe reinforcement does not increment for same session", () => {
|
||||
assert.ok(retained, "existing manual memory should be retained");
|
||||
assert.equal(retained.reinforcementCount, 1);
|
||||
assert.equal(retained.lastReinforcedSessionID, "same-session");
|
||||
assert.equal(result.evidence.some(event => event.type === "memory_reinforced"), false);
|
||||
});
|
||||
|
||||
test("dedupe reinforcement does not increment under one hour", () => {
|
||||
@@ -552,6 +619,23 @@ test("dedupe reinforcement does not increment under one hour", () => {
|
||||
assert.ok(retained, "existing manual memory should be retained");
|
||||
assert.equal(retained.reinforcementCount, 1);
|
||||
assert.equal(retained.lastReinforcedSessionID, "old-session");
|
||||
assert.equal(result.evidence.some(event => event.type === "memory_reinforced"), false);
|
||||
});
|
||||
|
||||
test("dedupe reinforcement does not emit evidence at max reinforcement count", () => {
|
||||
const existing: LongTermMemoryEntry = {
|
||||
...entry("existing-max", "Prefer deterministic consolidation accounting", "feedback"),
|
||||
source: "manual",
|
||||
reinforcementCount: 6,
|
||||
};
|
||||
const duplicate: LongTermMemoryEntry = {
|
||||
...entry("duplicate-max", "prefer deterministic consolidation accounting!!!", "feedback"),
|
||||
pendingOwnerSessionID: "new-session",
|
||||
};
|
||||
|
||||
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
|
||||
|
||||
assert.equal(result.evidence.some(event => event.type === "memory_reinforced"), false);
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits orders entries by retention strength", () => {
|
||||
|
||||
Reference in New Issue
Block a user