mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-02 06:19:36 +02:00
feat(memory-diag): clarify diagnostic provenance
This commit is contained in:
+8
-1
@@ -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> {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user