feat(memory-diag): clarify diagnostic provenance

This commit is contained in:
Ralph Chang
2026-05-13 21:18:24 +08:00
parent 5bca3432b0
commit 3c4282b241
16 changed files with 1959 additions and 82 deletions
+8 -1
View File
@@ -4,6 +4,7 @@ import { appendFile, mkdir, readFile, realpath, rename, rm, stat, writeFile } fr
import { dirname, join } from "node:path";
import { dataHome, workspaceEvidenceLogPath, workspaceKey } from "./paths.ts";
import { redactCredentials } from "./redaction.ts";
import { producerFields } from "./instrumentation.ts";
export type EvidenceEventType =
| "extraction_candidate_accepted"
@@ -95,6 +96,9 @@ export type EvidenceEventV1 = {
workspaceRootHash: string;
sessionHash?: string;
messageHash?: string;
producerName?: string;
producerVersion?: string;
instrumentationVersion?: number;
type: EvidenceEventType;
phase: EvidencePhase;
outcome: EvidenceOutcome;
@@ -273,7 +277,10 @@ function buildEvidenceEvent(
if (details) event.details = details;
if (input.textPreview) event.textPreview = evidenceTextPreview(input.textPreview, textPreviewMax);
return event;
return {
...event,
...producerFields(),
};
}
async function safeAppendEvidenceLine(path: string, line: string): Promise<void> {
+9
View File
@@ -7,6 +7,7 @@ import { assessMemoryQuality } from "./memory-quality.ts";
import { extractionRejectionLogPath } from "./paths.ts";
import { redactCredentials } from "./redaction.ts";
import type { EvidenceEventInput } from "./evidence-log.ts";
import { producerFields } from "./instrumentation.ts";
function id(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
@@ -329,6 +330,11 @@ type ExtractionRejectionLogEntry = {
source: "compaction";
workspaceKey?: string;
workspaceRootHash?: string;
producerName?: string;
producerVersion?: string;
instrumentationVersion?: number;
decisionLogicName?: string;
decisionLogicVersion?: number;
};
type WorkspaceMemoryCandidateParseOptions = {
@@ -381,6 +387,9 @@ function evaluateWorkspaceMemoryCandidate(
source: "compaction",
workspaceKey: options.workspaceKey,
workspaceRootHash: options.workspaceRootHash,
...producerFields(),
decisionLogicName: "assessMemoryQuality",
decisionLogicVersion: 1,
});
return { accepted: false, reasons: quality.reasons };
}
+41
View File
@@ -0,0 +1,41 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
let cachedVersion: string | undefined;
const MEMORY_PRODUCER_NAME = "opencode-working-memory";
const MEMORY_INSTRUMENTATION_VERSION = 2;
function producerVersion(): string {
if (cachedVersion) return cachedVersion;
try {
const candidates = [
join(__dirname, "..", "package.json"),
join(__dirname, "..", "..", "package.json"),
// resolve from compiled dist/src/ -> repo root
];
for (const path of candidates) {
try {
const pkg = JSON.parse(readFileSync(path, "utf8"));
cachedVersion = pkg.version as string;
break;
} catch {
// try next
}
}
if (!cachedVersion) cachedVersion = "unknown";
} catch {
cachedVersion = "unknown";
}
return cachedVersion;
}
export function producerFields(): { producerName: string; producerVersion: string; instrumentationVersion: number } {
return {
producerName: MEMORY_PRODUCER_NAME,
producerVersion: producerVersion(),
instrumentationVersion: MEMORY_INSTRUMENTATION_VERSION,
};
}
+20 -5
View File
@@ -44,7 +44,7 @@ import {
workspaceMemoryExactKey,
workspaceMemoryIdentityKey,
} from "./workspace-memory.ts";
import { reinforceMemory } from "./retention.ts";
import { tryReinforceMemory } from "./retention.ts";
import {
appendPendingMemories,
clearPendingMemories,
@@ -429,17 +429,31 @@ export const MemoryV2Plugin: Plugin = async (input) => {
const { refSnapshot, target, targetIndex } = resolution;
if (command.kind === "REINFORCE") {
const reinforced = reinforceMemory(target, sessionID, now);
if (reinforced === target) {
evidence.push(memoryReinforcedEvidence(target, command.ref, "rejected", ["numbered_ref_reinforce", "reinforcement_window_blocked"], {
const decision = tryReinforceMemory(target, sessionID, now);
if (decision.outcome === "blocked") {
evidence.push(memoryReinforcedEvidence(target, command.ref, "rejected", ["numbered_ref_reinforce", "reinforcement_window_blocked", `reinforcement_block_${decision.blockReason}`], {
memoryId: refSnapshot.memoryId,
blockReason: decision.blockReason,
attemptedAtMs: now,
attemptedAtIso: new Date(now).toISOString(),
...(decision.lastReinforcedAt ? {
lastReinforcedAtMs: decision.lastReinforcedAt,
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
} : {}),
reinforcementCount: decision.reinforcementCount,
maxReinforcementCount: decision.maxReinforcementCount,
minIntervalMs: decision.minIntervalMs,
}));
continue;
}
const reinforced = decision.memory;
workspaceMemory.entries[targetIndex] = reinforced;
evidence.push(memoryReinforcedEvidence(reinforced, command.ref, "reinforced", ["numbered_ref_reinforce", "reinforcement_window_allowed"], {
memoryId: refSnapshot.memoryId,
reinforcementOutcome: "reinforced",
previousReinforcementCount: decision.previousReinforcementCount,
newReinforcementCount: decision.newReinforcementCount,
}));
continue;
}
@@ -662,11 +676,12 @@ export const MemoryV2Plugin: Plugin = async (input) => {
const key = memoryKey(memory);
const existing = existingByKey.get(key);
if (existing) {
const reinforced = reinforceMemory(
const decision = tryReinforceMemory(
existing.memory,
sessionID ?? memory.pendingOwnerSessionID ?? "workspace-promotion",
promotedAt,
);
const reinforced = decision.memory;
if (reinforced !== existing.memory) {
workspaceMemory.entries[existing.index] = reinforced;
existingByKey.set(key, { memory: reinforced, index: existing.index });
+45 -13
View File
@@ -1,5 +1,11 @@
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
export type ReinforcementBlockReason = "same_session" | "same_utc_day" | "min_interval" | "max_count";
export type ReinforcementDecision =
| { outcome: "reinforced"; memory: LongTermMemoryEntry; previousReinforcementCount: number; newReinforcementCount: number }
| { outcome: "blocked"; memory: LongTermMemoryEntry; blockReason: ReinforcementBlockReason; lastReinforcedAt?: number; reinforcementCount: number; maxReinforcementCount: number; minIntervalMs: number };
// Retention decay model constants (v1.5)
export const BASE_HALF_LIFE_DAYS = 45;
export const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
@@ -116,33 +122,59 @@ function isSameUTCCalendarDay(ts1: number, ts2: number): boolean {
&& d1.getUTCDate() === d2.getUTCDate();
}
export function reinforceMemory(
export function tryReinforceMemory(
memory: LongTermMemoryEntry,
sessionId: string,
now: number,
): LongTermMemoryEntry {
if (memory.lastReinforcedSessionID === sessionId) {
return memory;
): ReinforcementDecision {
const count = memory.reinforcementCount ?? 0;
const lastAt = memory.lastReinforcedAt ?? 0;
const lastSession = memory.lastReinforcedSessionID;
if (lastSession === sessionId) {
return blockedDecision(memory, "same_session", count, lastAt);
}
// Calendar-day diversity gate (OQ-2): same UTC day = no reinforcement.
if (memory.lastReinforcedAt && isSameUTCCalendarDay(memory.lastReinforcedAt, now)) {
return memory;
if (count >= REINFORCEMENT_MAX_COUNT) {
return blockedDecision(memory, "max_count", count, lastAt);
}
if (memory.lastReinforcedAt && now - memory.lastReinforcedAt < REINFORCEMENT_MIN_INTERVAL_MS) {
return memory;
if (lastAt > 0 && now < lastAt + REINFORCEMENT_MIN_INTERVAL_MS) {
return blockedDecision(memory, "min_interval", count, lastAt);
}
if ((memory.reinforcementCount ?? 0) >= REINFORCEMENT_MAX_COUNT) {
return memory;
if (lastAt > 0 && isSameUTCCalendarDay(lastAt, now)) {
return blockedDecision(memory, "same_utc_day", count, lastAt);
}
return {
const reinforced: LongTermMemoryEntry = {
...memory,
reinforcementCount: (memory.reinforcementCount ?? 0) + 1,
reinforcementCount: count + 1,
lastReinforcedAt: now,
lastReinforcedSessionID: sessionId,
retentionClock: now,
};
return {
outcome: "reinforced",
memory: reinforced,
previousReinforcementCount: count,
newReinforcementCount: count + 1,
};
}
function blockedDecision(
memory: LongTermMemoryEntry,
blockReason: ReinforcementBlockReason,
reinforcementCount: number,
lastReinforcedAt: number,
): ReinforcementDecision {
return {
outcome: "blocked",
memory,
blockReason,
...(lastReinforcedAt > 0 ? { lastReinforcedAt } : {}),
reinforcementCount,
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
minIntervalMs: REINFORCEMENT_MIN_INTERVAL_MS,
};
}
+79 -10
View File
@@ -9,7 +9,8 @@ import { redactCredentials } from "./redaction.ts";
import {
RETENTION_TYPE_MAX,
calculateRetentionStrength,
reinforceMemory,
tryReinforceMemory,
type ReinforcementDecision,
} from "./retention.ts";
import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts";
import { appendEvidenceEvents } from "./evidence-log.ts";
@@ -559,7 +560,13 @@ function consolidationEvent(
function capacityRemovalEvidence(
memory: LongTermMemoryEntry,
reason: "type_cap" | "global_cap" | "capacity",
reason: "type_cap" | "global_cap",
details: {
strengthAtRemoval: number;
rankAtRemoval: number;
typeRankAtRemoval: number;
ageDaysAtRemoval: number;
},
): EvidenceEventInput {
return {
type: "memory_removed_capacity",
@@ -578,6 +585,7 @@ function capacityRemovalEvidence(
...(typeof memory.retentionClock === "number" && Number.isFinite(memory.retentionClock) ? { retentionClock: memory.retentionClock } : {}),
...(memory.createdAt ? { createdAt: memory.createdAt } : {}),
...(memory.source ? { source: memory.source } : {}),
...details,
},
};
}
@@ -664,8 +672,8 @@ export function enforceLongTermLimitsWithAccounting(
const typeCapLosers = sorted.filter(entry => !cappedIds.has(entry.id));
const globalCapLosers = capped.filter(entry => !keptIds.has(entry.id));
const capacityEvidence: EvidenceEventInput[] = [
...typeCapLosers.map(entry => capacityRemovalEvidence(entry, "type_cap")),
...globalCapLosers.map(entry => capacityRemovalEvidence(entry, "global_cap")),
...typeCapLosers.map(entry => capacityRemovalEvidence(entry, "type_cap", capacityRemovalSnapshot(entry, sorted, now, lastActivityAt))),
...globalCapLosers.map(entry => capacityRemovalEvidence(entry, "global_cap", capacityRemovalSnapshot(entry, sorted, now, lastActivityAt))),
];
const capacityDropped = sorted
.filter(entry => !keptIds.has(entry.id))
@@ -680,6 +688,28 @@ export function enforceLongTermLimitsWithAccounting(
};
}
function capacityRemovalSnapshot(
memory: LongTermMemoryEntry,
sorted: LongTermMemoryEntry[],
now: number,
lastActivityAt?: string,
): {
strengthAtRemoval: number;
rankAtRemoval: number;
typeRankAtRemoval: number;
ageDaysAtRemoval: number;
} {
const createdAtMs = new Date(memory.createdAt).getTime();
const rank = sorted.findIndex(entry => entry.id === memory.id);
const typeRank = sorted.filter(entry => entry.type === memory.type).findIndex(entry => entry.id === memory.id);
return {
strengthAtRemoval: calculateRetentionStrength(memory, now, lastActivityAt),
rankAtRemoval: rank >= 0 ? rank + 1 : -1,
typeRankAtRemoval: typeRank >= 0 ? typeRank + 1 : -1,
ageDaysAtRemoval: Number.isFinite(createdAtMs) ? Math.floor(Math.max(0, now - createdAtMs) / 86_400_000) : 0,
};
}
function applyTypeMaxCaps(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
return applyTypeMaxCapsWithOmissions(entries).kept;
}
@@ -732,12 +762,13 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
? "absorbed_exact" as const
: "absorbed_identity" as const;
const reinforced = reinforceMemory(
const decision = tryReinforceMemory(
retained,
reinforcementSessionId(retained, dropped),
now,
);
const reinforcedEvent = reinforcementEvidence(retained, dropped, reinforced, reason);
const reinforced = decision.memory;
const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason, now);
if (reinforcedEvent) evidence.push(reinforcedEvent);
absorbed.push(consolidationEvent(dropped, reason, reinforced));
@@ -760,12 +791,13 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
? "absorbed_exact" as const
: "superseded_existing" as const; // v1.5.4 placeholder: unreachable until numbered refs
const reinforced = reinforceMemory(
const decision = tryReinforceMemory(
retained,
reinforcementSessionId(retained, dropped),
now,
);
const reinforcedEvent = reinforcementEvidence(retained, dropped, reinforced, reason);
const reinforced = decision.memory;
const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason, now);
if (reinforcedEvent) evidence.push(reinforcedEvent);
if (reason === "superseded_existing") {
@@ -807,11 +839,41 @@ function memoryEvidenceRef(memory: LongTermMemoryEntry): MemoryEvidenceRef {
function reinforcementEvidence(
retained: LongTermMemoryEntry,
dropped: LongTermMemoryEntry,
reinforced: LongTermMemoryEntry,
decision: ReinforcementDecision,
reason: "absorbed_exact" | "absorbed_identity" | "superseded_existing",
attemptedAt: number,
): EvidenceEventInput | undefined {
if ((reinforced.reinforcementCount ?? 0) <= (retained.reinforcementCount ?? 0)) return undefined;
const duplicateReason = reason === "absorbed_identity" ? "duplicate_identity" : "duplicate_exact";
if (decision.outcome === "blocked") {
return {
type: "memory_reinforced",
phase: "reinforcement",
outcome: "rejected",
memory: memoryEvidenceRef(retained),
relations: [
{ role: "target", memory: memoryEvidenceRef(retained) },
{ role: "reinforced_by", memory: memoryEvidenceRef(dropped) },
],
reasonCodes: [duplicateReason, "reinforcement_window_blocked", `reinforcement_block_${decision.blockReason}`],
details: {
memoryId: retained.id,
droppedMemoryId: dropped.id,
blockReason: decision.blockReason,
attemptedAtMs: attemptedAt,
attemptedAtIso: new Date(attemptedAt).toISOString(),
...(decision.lastReinforcedAt ? {
lastReinforcedAtMs: decision.lastReinforcedAt,
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
} : {}),
reinforcementCount: decision.reinforcementCount,
maxReinforcementCount: decision.maxReinforcementCount,
minIntervalMs: decision.minIntervalMs,
},
textPreview: retained.text,
};
}
const reinforced = decision.memory;
return {
type: "memory_reinforced",
phase: "reinforcement",
@@ -822,6 +884,13 @@ function reinforcementEvidence(
{ role: "reinforced_by", memory: memoryEvidenceRef(dropped) },
],
reasonCodes: [duplicateReason, "reinforcement_window_allowed"],
details: {
memoryId: reinforced.id,
droppedMemoryId: dropped.id,
reinforcementOutcome: "reinforced",
previousReinforcementCount: decision.previousReinforcementCount,
newReinforcementCount: decision.newReinforcementCount,
},
textPreview: reinforced.text,
};
}