mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
feat(memory): add rolling reinforcement window
This commit is contained in:
@@ -6,7 +6,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
let cachedVersion: string | undefined;
|
||||
|
||||
const MEMORY_PRODUCER_NAME = "opencode-working-memory";
|
||||
const MEMORY_INSTRUMENTATION_VERSION = 2;
|
||||
const MEMORY_INSTRUMENTATION_VERSION = 3;
|
||||
|
||||
function producerVersion(): string {
|
||||
if (cachedVersion) return cachedVersion;
|
||||
|
||||
+27
-10
@@ -44,7 +44,7 @@ import {
|
||||
workspaceMemoryExactKey,
|
||||
workspaceMemoryIdentityKey,
|
||||
} from "./workspace-memory.ts";
|
||||
import { tryReinforceMemory } from "./retention.ts";
|
||||
import { REINFORCEMENT_MAX_COUNT, tryReinforceMemory, type ReinforcementDecision } from "./retention.ts";
|
||||
import {
|
||||
appendPendingMemories,
|
||||
clearPendingMemories,
|
||||
@@ -311,6 +311,21 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
};
|
||||
}
|
||||
|
||||
function reinforcementDecisionTimingDetails(decision: ReinforcementDecision): EvidenceEventInput["details"] {
|
||||
return {
|
||||
attemptedAtMs: decision.attemptedAt,
|
||||
attemptedAtIso: new Date(decision.attemptedAt).toISOString(),
|
||||
...(decision.lastReinforcedAt !== undefined ? {
|
||||
lastReinforcedAtMs: decision.lastReinforcedAt,
|
||||
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
|
||||
} : {}),
|
||||
...(decision.elapsedMs !== undefined ? { elapsedMs: decision.elapsedMs } : {}),
|
||||
requiredElapsedMs: decision.requiredElapsedMs,
|
||||
sameSession: decision.sameSession,
|
||||
...(decision.legacyMissingTimestamp ? { legacyMissingTimestamp: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function replacementMemoryId(): string {
|
||||
return `mem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
@@ -434,26 +449,28 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
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(),
|
||||
} : {}),
|
||||
...reinforcementDecisionTimingDetails(decision),
|
||||
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"], {
|
||||
const reasonCodes = ["numbered_ref_reinforce", "reinforcement_window_allowed"];
|
||||
if (decision.reinforcementMode === "refresh_only") {
|
||||
reasonCodes.push("reinforcement_saturation_refresh");
|
||||
}
|
||||
evidence.push(memoryReinforcedEvidence(reinforced, command.ref, "reinforced", reasonCodes, {
|
||||
memoryId: refSnapshot.memoryId,
|
||||
reinforcementOutcome: "reinforced",
|
||||
reinforcementOutcome: decision.reinforcementMode === "refresh_only" ? "refreshed" : "reinforced",
|
||||
reinforcementMode: decision.reinforcementMode,
|
||||
...reinforcementDecisionTimingDetails(decision),
|
||||
previousReinforcementCount: decision.previousReinforcementCount,
|
||||
newReinforcementCount: decision.newReinforcementCount,
|
||||
reinforcementCount: decision.newReinforcementCount,
|
||||
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
+68
-33
@@ -1,19 +1,52 @@
|
||||
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
|
||||
|
||||
export type ReinforcementBlockReason = "same_session" | "same_utc_day" | "min_interval" | "max_count";
|
||||
export type ReinforcementBlockReason =
|
||||
| "min_elapsed_window"
|
||||
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
|
||||
| "same_session"
|
||||
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
|
||||
| "same_utc_day"
|
||||
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
|
||||
| "min_interval"
|
||||
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
|
||||
| "max_count";
|
||||
|
||||
export type ReinforcementMode = "increment" | "refresh_only";
|
||||
|
||||
type ReinforcementDecisionMetadata = {
|
||||
attemptedAt: number;
|
||||
lastReinforcedAt?: number;
|
||||
elapsedMs?: number;
|
||||
requiredElapsedMs: number;
|
||||
sameSession: boolean;
|
||||
legacyMissingTimestamp?: boolean;
|
||||
};
|
||||
|
||||
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 };
|
||||
| ({
|
||||
outcome: "reinforced";
|
||||
memory: LongTermMemoryEntry;
|
||||
previousReinforcementCount: number;
|
||||
newReinforcementCount: number;
|
||||
reinforcementMode: ReinforcementMode;
|
||||
} & ReinforcementDecisionMetadata)
|
||||
| ({
|
||||
outcome: "blocked";
|
||||
memory: LongTermMemoryEntry;
|
||||
blockReason: ReinforcementBlockReason;
|
||||
reinforcementCount: number;
|
||||
maxReinforcementCount: number;
|
||||
} & ReinforcementDecisionMetadata);
|
||||
|
||||
// Retention decay model constants (v1.5)
|
||||
export const BASE_HALF_LIFE_DAYS = 45;
|
||||
export const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
|
||||
export const REINFORCEMENT_MAX_COUNT = 6;
|
||||
export const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
export const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
export const REINFORCEMENT_MIN_ELAPSED_MS = 7 * DAY_MS;
|
||||
export const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // Deprecated compatibility constant; new policy uses REINFORCEMENT_MIN_ELAPSED_MS.
|
||||
export const WORKSPACE_DORMANT_AFTER_DAYS = 14;
|
||||
export const DORMANT_DECAY_MULTIPLIER = 0.25;
|
||||
export const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export const TYPE_FACTOR = {
|
||||
reference: 1.0,
|
||||
@@ -114,42 +147,38 @@ export function calculateEffectiveAgeDays(
|
||||
return activeDays + dormantOverlapDays * DORMANT_DECAY_MULTIPLIER;
|
||||
}
|
||||
|
||||
function isSameUTCCalendarDay(ts1: number, ts2: number): boolean {
|
||||
const d1 = new Date(ts1);
|
||||
const d2 = new Date(ts2);
|
||||
return d1.getUTCFullYear() === d2.getUTCFullYear()
|
||||
&& d1.getUTCMonth() === d2.getUTCMonth()
|
||||
&& d1.getUTCDate() === d2.getUTCDate();
|
||||
}
|
||||
|
||||
export function tryReinforceMemory(
|
||||
memory: LongTermMemoryEntry,
|
||||
sessionId: string,
|
||||
now: number,
|
||||
): ReinforcementDecision {
|
||||
const count = memory.reinforcementCount ?? 0;
|
||||
const lastAt = memory.lastReinforcedAt ?? 0;
|
||||
const lastAt = validLastReinforcedAt(memory.lastReinforcedAt);
|
||||
const lastSession = memory.lastReinforcedSessionID;
|
||||
const sameSession = lastSession === sessionId;
|
||||
const legacyMissingTimestamp = count > 0 && lastAt === undefined;
|
||||
const metadata: ReinforcementDecisionMetadata = {
|
||||
attemptedAt: now,
|
||||
...(lastAt !== undefined ? {
|
||||
lastReinforcedAt: lastAt,
|
||||
elapsedMs: now - lastAt,
|
||||
} : {}),
|
||||
requiredElapsedMs: REINFORCEMENT_MIN_ELAPSED_MS,
|
||||
sameSession,
|
||||
...(legacyMissingTimestamp ? { legacyMissingTimestamp: true } : {}),
|
||||
};
|
||||
|
||||
if (lastSession === sessionId) {
|
||||
return blockedDecision(memory, "same_session", count, lastAt);
|
||||
}
|
||||
|
||||
if (count >= REINFORCEMENT_MAX_COUNT) {
|
||||
return blockedDecision(memory, "max_count", count, lastAt);
|
||||
}
|
||||
|
||||
if (lastAt > 0 && now < lastAt + REINFORCEMENT_MIN_INTERVAL_MS) {
|
||||
return blockedDecision(memory, "min_interval", count, lastAt);
|
||||
}
|
||||
|
||||
if (lastAt > 0 && isSameUTCCalendarDay(lastAt, now)) {
|
||||
return blockedDecision(memory, "same_utc_day", count, lastAt);
|
||||
if (lastAt !== undefined && now - lastAt < REINFORCEMENT_MIN_ELAPSED_MS) {
|
||||
return blockedDecision(memory, "min_elapsed_window", count, metadata);
|
||||
}
|
||||
|
||||
const reinforcementMode: ReinforcementMode = count >= REINFORCEMENT_MAX_COUNT
|
||||
? "refresh_only"
|
||||
: "increment";
|
||||
const newReinforcementCount = reinforcementMode === "refresh_only" ? count : count + 1;
|
||||
const reinforced: LongTermMemoryEntry = {
|
||||
...memory,
|
||||
reinforcementCount: count + 1,
|
||||
reinforcementCount: newReinforcementCount,
|
||||
lastReinforcedAt: now,
|
||||
lastReinforcedSessionID: sessionId,
|
||||
retentionClock: now,
|
||||
@@ -158,23 +187,29 @@ export function tryReinforceMemory(
|
||||
outcome: "reinforced",
|
||||
memory: reinforced,
|
||||
previousReinforcementCount: count,
|
||||
newReinforcementCount: count + 1,
|
||||
newReinforcementCount,
|
||||
reinforcementMode,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
function validLastReinforcedAt(value: unknown): number | undefined {
|
||||
if (typeof value !== "number") return undefined;
|
||||
return Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function blockedDecision(
|
||||
memory: LongTermMemoryEntry,
|
||||
blockReason: ReinforcementBlockReason,
|
||||
reinforcementCount: number,
|
||||
lastReinforcedAt: number,
|
||||
metadata: ReinforcementDecisionMetadata,
|
||||
): ReinforcementDecision {
|
||||
return {
|
||||
outcome: "blocked",
|
||||
memory,
|
||||
blockReason,
|
||||
...(lastReinforcedAt > 0 ? { lastReinforcedAt } : {}),
|
||||
reinforcementCount,
|
||||
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
|
||||
minIntervalMs: REINFORCEMENT_MIN_INTERVAL_MS,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
+29
-12
@@ -7,6 +7,7 @@ import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts";
|
||||
import { redactCredentials } from "./redaction.ts";
|
||||
import {
|
||||
REINFORCEMENT_MAX_COUNT,
|
||||
RETENTION_TYPE_MAX,
|
||||
calculateRetentionStrength,
|
||||
tryReinforceMemory,
|
||||
@@ -768,7 +769,7 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
|
||||
now,
|
||||
);
|
||||
const reinforced = decision.memory;
|
||||
const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason, now);
|
||||
const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason);
|
||||
if (reinforcedEvent) evidence.push(reinforcedEvent);
|
||||
|
||||
absorbed.push(consolidationEvent(dropped, reason, reinforced));
|
||||
@@ -797,7 +798,7 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
|
||||
now,
|
||||
);
|
||||
const reinforced = decision.memory;
|
||||
const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason, now);
|
||||
const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason);
|
||||
if (reinforcedEvent) evidence.push(reinforcedEvent);
|
||||
|
||||
if (reason === "superseded_existing") {
|
||||
@@ -841,7 +842,6 @@ function reinforcementEvidence(
|
||||
dropped: LongTermMemoryEntry,
|
||||
decision: ReinforcementDecision,
|
||||
reason: "absorbed_exact" | "absorbed_identity" | "superseded_existing",
|
||||
attemptedAt: number,
|
||||
): EvidenceEventInput | undefined {
|
||||
const duplicateReason = reason === "absorbed_identity" ? "duplicate_identity" : "duplicate_exact";
|
||||
if (decision.outcome === "blocked") {
|
||||
@@ -859,21 +859,19 @@ function reinforcementEvidence(
|
||||
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(),
|
||||
} : {}),
|
||||
...reinforcementDecisionTimingDetails(decision),
|
||||
reinforcementCount: decision.reinforcementCount,
|
||||
maxReinforcementCount: decision.maxReinforcementCount,
|
||||
minIntervalMs: decision.minIntervalMs,
|
||||
},
|
||||
textPreview: retained.text,
|
||||
};
|
||||
}
|
||||
|
||||
const reinforced = decision.memory;
|
||||
const reasonCodes = [duplicateReason, "reinforcement_window_allowed"];
|
||||
if (decision.reinforcementMode === "refresh_only") {
|
||||
reasonCodes.push("reinforcement_saturation_refresh");
|
||||
}
|
||||
return {
|
||||
type: "memory_reinforced",
|
||||
phase: "reinforcement",
|
||||
@@ -883,18 +881,37 @@ function reinforcementEvidence(
|
||||
{ role: "reinforced", memory: memoryEvidenceRef(reinforced) },
|
||||
{ role: "reinforced_by", memory: memoryEvidenceRef(dropped) },
|
||||
],
|
||||
reasonCodes: [duplicateReason, "reinforcement_window_allowed"],
|
||||
reasonCodes,
|
||||
details: {
|
||||
memoryId: reinforced.id,
|
||||
droppedMemoryId: dropped.id,
|
||||
reinforcementOutcome: "reinforced",
|
||||
reinforcementOutcome: decision.reinforcementMode === "refresh_only" ? "refreshed" : "reinforced",
|
||||
reinforcementMode: decision.reinforcementMode,
|
||||
...reinforcementDecisionTimingDetails(decision),
|
||||
previousReinforcementCount: decision.previousReinforcementCount,
|
||||
newReinforcementCount: decision.newReinforcementCount,
|
||||
reinforcementCount: decision.newReinforcementCount,
|
||||
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
|
||||
},
|
||||
textPreview: reinforced.text,
|
||||
};
|
||||
}
|
||||
|
||||
function reinforcementDecisionTimingDetails(decision: ReinforcementDecision): EvidenceEventInput["details"] {
|
||||
return {
|
||||
attemptedAtMs: decision.attemptedAt,
|
||||
attemptedAtIso: new Date(decision.attemptedAt).toISOString(),
|
||||
...(decision.lastReinforcedAt !== undefined ? {
|
||||
lastReinforcedAtMs: decision.lastReinforcedAt,
|
||||
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
|
||||
} : {}),
|
||||
...(decision.elapsedMs !== undefined ? { elapsedMs: decision.elapsedMs } : {}),
|
||||
requiredElapsedMs: decision.requiredElapsedMs,
|
||||
sameSession: decision.sameSession,
|
||||
...(decision.legacyMissingTimestamp ? { legacyMissingTimestamp: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function reinforcementSessionId(retained: LongTermMemoryEntry, dropped: LongTermMemoryEntry): string {
|
||||
return dropped.pendingOwnerSessionID ?? retained.pendingOwnerSessionID ?? "workspace-dedupe";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user