From a480b734b23b2efb45a46649e6308b16f14664f0 Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Fri, 15 May 2026 11:16:34 +0800 Subject: [PATCH] feat(memory): add rolling reinforcement window --- CHANGELOG.md | 16 ++ RELEASE_NOTES.md | 34 +++ docs/architecture.md | 4 +- docs/diagnostics.md | 10 +- package.json | 2 +- scripts/memory-diag/commands/commands.ts | 31 ++- scripts/memory-diag/quality-review-model.ts | 4 +- src/instrumentation.ts | 2 +- src/plugin.ts | 37 ++- src/retention.ts | 101 +++++--- src/workspace-memory.ts | 41 +++- tests/memory-diag-quality.test.ts | 44 +++- tests/memory-diag.test.ts | 127 +++++++++- tests/plugin.test.ts | 255 ++++++++++++++++++-- tests/retention.test.ts | 182 +++++++++++--- tests/workspace-memory.test.ts | 190 +++++++++++---- 16 files changed, 911 insertions(+), 169 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac17de2..d7f19c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.6.4] - 2026-05-15 + +### Changed + +- Replaced same-session reinforcement blocking with a rolling 7-day elapsed reinforcement window so long-lived OpenCode sessions can reinforce durable memories after meaningful weekly recurrence. +- Kept the 45-day base half-life while changing the max reinforcement count to a growth saturation point: memories at count 6 can refresh retention timestamps weekly without increasing count or effective half-life. +- Bumped memory evidence instrumentation to version 3 for the new elapsed-window and refresh-only reinforcement semantics. +- Updated `memory-diag commands --memory` to show elapsed-window details, `sameSession` evidence, `reinforcementMode`, and legacy missing timestamp markers without exposing raw session IDs. +- Updated `memory-diag quality` to keep historical `same_session` block accounting while preventing new `sameSession` evidence from triggering old same-session diagnostic questions. + +### Fixed + +- Prevented long-lived sessions from indefinitely blocking reinforcement solely because the session ID stayed the same across days. +- Prevented saturated memories from growing stronger beyond the max reinforcement count while still allowing continued weekly use to keep them fresh. +- Preserved historical block reason compatibility for `same_session`, `same_utc_day`, `min_interval`, and `max_count` without producing those reasons from the new policy path. + ## [1.6.3] - 2026-05-14 ### Added diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ede427c..d0efd71 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,39 @@ # Release Notes +## 1.6.4 (2026-05-15) + +### Rolling Weekly Reinforcement + +This patch release fixes the reinforcement policy for users who work in long-lived OpenCode sessions. Reinforcement no longer treats `same_session` as a hard block. Instead, each memory uses a rolling 7-day elapsed window, so recurring preferences can be reinforced after meaningful weekly use even when the session stays open. + +The base retention half-life remains 45 days. The max reinforcement count remains 6, but it now acts as a growth saturation point rather than a lifetime hard stop. + +### What Changed + +- **7-day rolling window**: repeated reinforcement is allowed once 7 rolling days have elapsed since the memory's last reinforcement; 7 days minus 1ms still blocks. +- **Same-session as evidence**: `sameSession` is recorded for diagnostics but no longer blocks reinforcement by itself. +- **Refresh-only saturation**: memories at reinforcement count 6 can refresh `retentionClock`, `lastReinforcedAt`, and session evidence after the weekly window without increasing count or effective half-life. +- **Instrumentation v3**: new reinforcement evidence records elapsed-window fields, `sameSession`, `reinforcementMode`, and legacy missing timestamp markers. +- **Diagnostics updated**: `memory-diag commands --memory` exposes the new fields, while `memory-diag quality` keeps historical `same_session` block analysis separate from new same-session evidence. + +### Upgrade Notes + +- No configuration changes are required. +- Existing workspace memory files and evidence logs remain compatible. +- Historical diagnostics may still show older block reasons such as `same_session`, `same_utc_day`, `min_interval`, or `max_count`; new instrumentation-version-3 events use the rolling elapsed-window semantics. +- Consumers of `memory-diag commands --memory --json` should use `reinforcementMode` to distinguish count-increment reinforcement from refresh-only saturation. + +### Validation + +- `node --test --experimental-strip-types tests/retention.test.ts` — 10 tests passing +- `node --test --experimental-strip-types tests/workspace-memory.test.ts tests/plugin.test.ts` — 181 tests passing +- `node --test --experimental-strip-types tests/memory-diag.test.ts tests/memory-diag-quality.test.ts` — 93 tests passing +- `npm run typecheck` — `TYPECHECK_PASS` +- `npm test` — 498 tests passing, `TEST_PASS` +- `npm run build` — `BUILD_PASS` + +--- + ## 1.6.3 (2026-05-14) ### Diagnostic Quality Review Board diff --git a/docs/architecture.md b/docs/architecture.md index 76ad6d6..bc10312 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -77,7 +77,7 @@ Workspace diagnostics also read the append-only evidence log for the current wor } ``` -Instrumentation version 2 adds optional causal details for future diagnostics without backfilling old JSONL records. Reinforcement-block events may include `details.blockReason` (for example `same_session` or `same_utc_day`). Capacity-removal events may include `details.strengthAtRemoval`, `details.rankAtRemoval`, `details.typeRankAtRemoval`, and `details.ageDaysAtRemoval`. `memory-diag quality` treats missing producer/instrumentation fields as historical or ambiguous rather than proof of current behavior. +Instrumentation version 2 added optional causal block details for diagnostics without backfilling old JSONL records. Instrumentation version 3 adds elapsed-window reinforcement details such as `details.elapsedMs`, `details.requiredElapsedMs`, `details.sameSession`, `details.reinforcementMode`, and `details.legacyMissingTimestamp`. Historical reinforcement-block events may still include older `details.blockReason` values such as `same_session`, `same_utc_day`, `min_interval`, `max_count`, or may have missing block details. Capacity-removal events may include `details.strengthAtRemoval`, `details.rankAtRemoval`, `details.typeRankAtRemoval`, and `details.ageDaysAtRemoval`. `memory-diag quality` treats missing producer/instrumentation fields as historical or ambiguous rather than proof of current behavior. ### Entry Types @@ -154,7 +154,7 @@ Default type caps: The type-cap total is 34, intentionally above the global 28-entry cap. These are maximums, not quotas. -Dormant workspaces age more slowly: after 14 inactive days, additional dormant time counts at 0.25x for retention decay. Repeated duplicate memories reinforce the surviving entry and slow future decay, but same-session and under-one-hour repeats do not stack reinforcement. +Dormant workspaces age more slowly: after 14 inactive days, additional dormant time counts at 0.25x for retention decay. Repeated duplicate memories reinforce the surviving entry only after a rolling 7-day elapsed window. Below reinforcement count 6, an allowed recurrence increments the reinforcement count and refreshes retention timestamps; at count 6 or higher, an allowed recurrence refreshes retention timestamps without increasing the count. Same-session status is recorded as diagnostic evidence, not as a new-policy block reason. ### Safety-Critical Deprecation diff --git a/docs/diagnostics.md b/docs/diagnostics.md index d85315f..374d705 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -38,8 +38,8 @@ Every diagnostic section must document: For `memory-diag quality`: - `reinforcementRules`: `inventory_only` (cannot distinguish spam from legitimate blocks) - `evictionAndCaps`: `inventory_only` (cannot distinguish healthy turnover from premature eviction) -- Old evidence remains ambiguous. Answerability improves only for events produced after instrumentation version 2. Mixed old/new logs will show a mix of `inventory_only` and `partial` sections. -- Producer-instrumented reinforcement blocks can upgrade `reinforcementRules` to `partial` by showing exact block reasons and UTC-day grouping; they still require human content judgment. +- Old evidence remains ambiguous. Answerability improves for producer-instrumented events, including instrumentation version 2 block details and instrumentation version 3 elapsed-window details. Mixed old/new logs will show a mix of `inventory_only` and `partial` sections. +- Producer-instrumented reinforcement blocks can upgrade `reinforcementRules` to `partial` by showing exact block reasons and, when available, rolling elapsed-window fields; they still require human content judgment. - Producer-instrumented capacity removals with rank/strength snapshots can upgrade `evictionAndCaps` to `partial`; fullness alone remains occupancy inventory, not proof of a capacity problem. ## Examples @@ -78,9 +78,11 @@ npx --package opencode-working-memory memory-diag commands --verbose npx --package opencode-working-memory memory-diag commands --memory ``` -The report includes successful reinforcements, successful replacements, malformed commands, stale refs, protected replacement blocks, and latest command events in verbose mode. +The report includes successful reinforcements, refresh-only reinforcements, successful replacements, malformed commands, stale refs, protected replacement blocks, and latest command events in verbose mode. -Use `commands --memory ` when you need a focused, evidence-only reinforcement view for one memory. It reports current memory status separately from recorded reinforcement attempts, block reasons, missing block details, and UTC-day evidence without judging whether the policy is correct. +Use `commands --memory ` when you need a focused, evidence-only reinforcement view for one memory. It reports current memory status separately from recorded reinforcement attempts, block reasons, missing block details, elapsed-window fields (`elapsedMs`, `requiredElapsedMs`), `sameSession` evidence, `reinforcementMode` (`increment` or `refresh_only`), `legacyMissingTimestamp` when true, and historical UTC-day evidence without judging whether the policy is correct. + +Current reinforcement policy uses a rolling 7-day elapsed window. Below reinforcement count 6, allowed attempts increment the count and refresh retention timestamps; at count 6 or higher, allowed attempts refresh retention timestamps without increasing the count. Historical evidence can still show older block reasons such as `same_session`, `same_utc_day`, `min_interval`, `max_count`, or missing block details because evidence logs are append-only and are not backfilled. ## Dry-run Recovery diff --git a/package.json b/package.json index ffc3677..c4c1044 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-working-memory", - "version": "1.6.3", + "version": "1.6.4", "description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state", "type": "module", "main": "index.ts", diff --git a/scripts/memory-diag/commands/commands.ts b/scripts/memory-diag/commands/commands.ts index 4266586..44c07e9 100644 --- a/scripts/memory-diag/commands/commands.ts +++ b/scripts/memory-diag/commands/commands.ts @@ -68,6 +68,11 @@ type MemoryCommandDetail = { reasonCodes: string[]; attemptedAtIso?: string; lastReinforcedAtIso?: string; + elapsedMs?: number; + requiredElapsedMs?: number; + sameSession?: boolean; + legacyMissingTimestamp?: boolean; + reinforcementMode?: string; crossUtcDay?: boolean | "unknown"; producerVersion?: string; instrumentationVersion?: number; @@ -116,6 +121,16 @@ function stringDetail(event: EvidenceEventV1, key: string): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } +function numberDetail(event: EvidenceEventV1, key: string): number | undefined { + const value = event.details?.[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function booleanDetail(event: EvidenceEventV1, key: string): boolean | undefined { + const value = event.details?.[key]; + return typeof value === "boolean" ? value : undefined; +} + function isRejectedOrBlocked(event: EvidenceEventV1): boolean { return event.outcome === "rejected" || hasReason(event, "reinforcement_window_blocked"); } @@ -175,6 +190,11 @@ function detailEventJSON(event: EvidenceEventV1): MemoryCommandDetail["events"][ reasonCodes: event.reasonCodes, attemptedAtIso, lastReinforcedAtIso, + elapsedMs: numberDetail(event, "elapsedMs"), + requiredElapsedMs: numberDetail(event, "requiredElapsedMs"), + sameSession: booleanDetail(event, "sameSession"), + legacyMissingTimestamp: booleanDetail(event, "legacyMissingTimestamp") === true ? true : undefined, + reinforcementMode: stringDetail(event, "reinforcementMode"), crossUtcDay: blocked ? isCrossUtcDay(attemptedAtIso, lastReinforcedAtIso) : undefined, producerVersion: event.producerVersion, instrumentationVersion: event.instrumentationVersion, @@ -315,15 +335,24 @@ function formatCrossUtcDay(value: boolean | "unknown" | undefined): string { return "unknown"; } +function formatBoolean(value: boolean): string { + return value ? "yes" : "no"; +} + function formatMemoryCommandDetailEvents(events: MemoryCommandDetail["events"]): string[] { if (events.length === 0) return [" (none)"]; return events.map(event => { const ref = event.ref ? ` ref=${event.ref}` : ""; const blockReason = event.blockReason ? ` blockReason=${event.blockReason}` : ""; + const reinforcementMode = event.reinforcementMode ? ` reinforcementMode=${event.reinforcementMode}` : ""; const attemptedAt = event.attemptedAtIso ? ` attemptedAt=${event.attemptedAtIso}` : ""; const lastReinforcedAt = event.lastReinforcedAtIso ? ` lastReinforcedAt=${event.lastReinforcedAtIso}` : ""; + const elapsedMs = event.elapsedMs !== undefined ? ` elapsedMs=${event.elapsedMs}` : ""; + const requiredElapsedMs = event.requiredElapsedMs !== undefined ? ` requiredElapsedMs=${event.requiredElapsedMs}` : ""; + const sameSession = event.sameSession !== undefined ? ` sameSession=${formatBoolean(event.sameSession)}` : ""; + const legacyMissingTimestamp = event.legacyMissingTimestamp === true ? " legacyMissingTimestamp=yes" : ""; const crossUtcDay = event.crossUtcDay !== undefined ? ` crossUtcDay=${formatCrossUtcDay(event.crossUtcDay)}` : ""; - return ` - ${event.createdAt} outcome=${event.outcome}${ref}${blockReason}${attemptedAt}${lastReinforcedAt}${crossUtcDay} reasons=${event.reasonCodes.join(",") || "none"}`; + return ` - ${event.createdAt} outcome=${event.outcome}${ref}${blockReason}${reinforcementMode}${attemptedAt}${lastReinforcedAt}${elapsedMs}${requiredElapsedMs}${sameSession}${legacyMissingTimestamp}${crossUtcDay} reasons=${event.reasonCodes.join(",") || "none"}`; }); } diff --git a/scripts/memory-diag/quality-review-model.ts b/scripts/memory-diag/quality-review-model.ts index c57772c..6abad27 100644 --- a/scripts/memory-diag/quality-review-model.ts +++ b/scripts/memory-diag/quality-review-model.ts @@ -571,9 +571,9 @@ function applyInstrumentedAnswerability( report.reinforcementRules.currentSignals = uniqueStrings([ ...report.reinforcementRules.currentSignals, "exact block reasons", - "UTC day grouping", + "elapsed-window details when present", ]); - report.reinforcementRules.outputPermission = "Show exact block reasons and day grouping; causal fields exist but human content judgment is still required."; + report.reinforcementRules.outputPermission = "Show exact block reasons and elapsed-window details when present; causal fields exist but human content judgment is still required."; } const hasCapacitySnapshots = evictionFacts.recentCapacityRemovalsWithSnapshot > 0 diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 22268e0..eab00ae 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -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; diff --git a/src/plugin.ts b/src/plugin.ts index f4d7dae..2c7d836 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -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; } diff --git a/src/retention.ts b/src/retention.ts index 40bca10..e373c8d 100644 --- a/src/retention.ts +++ b/src/retention.ts @@ -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, }; } diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index 3be5953..ca5139e 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -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"; } diff --git a/tests/memory-diag-quality.test.ts b/tests/memory-diag-quality.test.ts index 301b6bf..ff02042 100644 --- a/tests/memory-diag-quality.test.ts +++ b/tests/memory-diag-quality.test.ts @@ -373,7 +373,7 @@ test("new evidence events include producer metadata and historical events remain assert.equal(stored.producerName, "opencode-working-memory"); assert.equal(stored.producerVersion, packageJson.version); - assert.equal(stored.instrumentationVersion, 2); + assert.equal(stored.instrumentationVersion, 3); const historical = event("evt-historical-no-producer", { type: "render_selected", @@ -747,6 +747,48 @@ test("quality surfaces same-session cross-day reinforcement as design diagnostic assert.doesNotMatch(output, /definitely a bug/i); }); +test("quality treats elapsed-window and refresh-only reinforcement as new evidence without old same-session question", () => { + const report = buildQualityReviewBoard(inspectionModel([], [ + reinforcementAttempt("evt-elapsed-window-block", packageJson.version, true, { + instrumentationVersion: 3, + reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_blocked", "reinforcement_block_min_elapsed_window"], + details: { + blockReason: "min_elapsed_window", + elapsedMs: 601_200_000, + requiredElapsedMs: 604_800_000, + sameSession: true, + attemptedAtIso: "2026-05-13T07:48:21.361Z", + lastReinforcedAtIso: "2026-05-06T08:48:21.361Z", + }, + }), + reinforcementAttempt("evt-refresh-only", packageJson.version, false, { + instrumentationVersion: 3, + reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_allowed", "reinforcement_saturation_refresh"], + details: { + reinforcementMode: "refresh_only", + elapsedMs: 604_800_000, + requiredElapsedMs: 604_800_000, + sameSession: true, + }, + }), + ...Array.from({ length: 4 }, (_, index) => reinforcementAttempt(`evt-elapsed-ok-${index}`, packageJson.version, false, { instrumentationVersion: 3 })), + ]), {}, generatedAt); + const facts = report.facts.systemMechanisms.reinforcementRules; + const versionedFacts = report.facts.systemMechanisms.versionedFacts?.reinforcementRules.buckets.current.facts; + const output = formatQualityReviewBoard(report, {}); + + assert.equal(facts.blocksByExactReason.min_elapsed_window, 1); + assert.equal(facts.blocksByExactReason.same_session, undefined); + assert.equal(facts.reinforcedEvents, 5); + assert.equal(facts.rejectedOrBlockedEvents, 1); + assert.equal(versionedFacts?.blocksByExactReason.min_elapsed_window, 1); + assert.equal(versionedFacts?.reinforcedEvents, 5); + assert.equal(versionedFacts?.rejectedOrBlockedEvents, 1); + assert.equal(report.facts.systemMechanisms.versionedFacts?.reinforcementRules.diagnosticQuestions, undefined); + assert.doesNotMatch(output, /Should same_session reinforcement blocking apply across UTC days/i); + assert.doesNotMatch(output, /same_session=1/); +}); + test("versioned quality warns when current reinforcement sample is small", () => { const events = [ reinforcementAttempt("evt-prev-small-block", "1.6.0", true), diff --git a/tests/memory-diag.test.ts b/tests/memory-diag.test.ts index 9e5060e..9852a01 100644 --- a/tests/memory-diag.test.ts +++ b/tests/memory-diag.test.ts @@ -181,6 +181,85 @@ async function setupMemoryCommandDetailFixture(root: string): Promise { sessionHash: "command-session-two", messageHash: "command-message-three", }), + evidence({ + type: "memory_reinforced", + phase: "reinforcement", + outcome: "rejected", + memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" }, + reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_blocked", "reinforcement_block_min_elapsed_window"], + details: { + ref: "M3", + blockReason: "min_elapsed_window", + attemptedAtIso: "2026-05-20T00:05:00.000Z", + lastReinforcedAtIso: "2026-05-13T00:05:00.001Z", + elapsedMs: 604_799_999, + requiredElapsedMs: 604_800_000, + sameSession: true, + sessionID: "raw-session-secret", + lastReinforcedSessionID: "raw-last-session-secret", + }, + sessionHash: "command-session-three", + messageHash: "command-message-four", + }), + evidence({ + type: "memory_reinforced", + phase: "reinforcement", + outcome: "reinforced", + memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" }, + reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_allowed"], + details: { + ref: "M3", + reinforcementOutcome: "reinforced", + reinforcementMode: "increment", + attemptedAtIso: "2026-05-20T00:05:00.001Z", + lastReinforcedAtIso: "2026-05-13T00:05:00.001Z", + elapsedMs: 604_800_000, + requiredElapsedMs: 604_800_000, + sameSession: false, + sessionID: "raw-increment-session-secret", + }, + sessionHash: "command-session-four", + messageHash: "command-message-five", + }), + evidence({ + type: "memory_reinforced", + phase: "reinforcement", + outcome: "reinforced", + memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" }, + reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_allowed", "reinforcement_saturation_refresh"], + details: { + ref: "M3", + reinforcementOutcome: "refreshed", + reinforcementMode: "refresh_only", + attemptedAtIso: "2026-05-27T00:05:00.001Z", + lastReinforcedAtIso: "2026-05-20T00:05:00.001Z", + elapsedMs: 604_800_000, + requiredElapsedMs: 604_800_000, + sameSession: true, + lastReinforcedSessionID: "raw-refresh-last-session-secret", + }, + sessionHash: "command-session-five", + messageHash: "command-message-six", + }), + evidence({ + type: "memory_reinforced", + phase: "reinforcement", + outcome: "reinforced", + memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" }, + reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_allowed"], + details: { + ref: "M3", + reinforcementOutcome: "reinforced", + reinforcementMode: "increment", + attemptedAtIso: "2026-05-28T00:05:00.001Z", + requiredElapsedMs: 604_800_000, + sameSession: false, + legacyMissingTimestamp: true, + sessionID: "raw-legacy-session-secret", + }, + sessionHash: "command-session-six", + messageHash: "command-message-seven", + }), evidence({ type: "render_selected", phase: "render", @@ -482,16 +561,26 @@ test("memory-diag commands memory selector prints reinforcement detail", async ( assert.match(stdout, /status: active/); assert.match(stdout, /render: rendered/); assert.match(stdout, /Reinforcement summary:/); - assert.match(stdout, /attempts: 3/); - assert.match(stdout, /reinforced: 1/); - assert.match(stdout, /rejected\/blocked: 2/); - assert.match(stdout, /window blocked: 2/); - assert.match(stdout, /block reasons: same_session=1, unknown=1/); + assert.match(stdout, /attempts: 7/); + assert.match(stdout, /reinforced: 4/); + assert.match(stdout, /rejected\/blocked: 3/); + assert.match(stdout, /window blocked: 3/); + assert.match(stdout, /block reasons: min_elapsed_window=1, same_session=1, unknown=1/); assert.match(stdout, /block details missing: 1/); assert.match(stdout, /same-session cross UTC day blocks: 1/); assert.match(stdout, /refs: M3/); + assert.match(stdout, /blockReason=min_elapsed_window/); + assert.match(stdout, /elapsedMs=604799999/); + assert.match(stdout, /requiredElapsedMs=604800000/); + assert.match(stdout, /sameSession=yes/); + assert.match(stdout, /sameSession=no/); + assert.match(stdout, /reinforcementMode=refresh_only/); + assert.match(stdout, /legacyMissingTimestamp=yes/); assert.match(stdout, /crossUtcDay=yes/); assert.doesNotMatch(stdout, /render_selected/); + assert.doesNotMatch(stdout, /raw-session-secret/); + assert.doesNotMatch(stdout, /raw-last-session-secret/); + assert.doesNotMatch(stdout, /raw-refresh-last-session-secret/); assertNoAttributionSafetyTerms(stdout); } finally { await rm(root, { recursive: true, force: true }); @@ -516,7 +605,18 @@ test("memory-diag commands memory selector emits stable JSON", async () => { blockDetailsMissing: number; sameSessionCrossUtcDayBlocks: number; }; - events: Array<{ eventId: string; outcome: string; blockReason?: string; crossUtcDay?: boolean | "unknown" }>; + events: Array<{ + eventId: string; + outcome: string; + blockReason?: string; + crossUtcDay?: boolean | "unknown"; + elapsedMs?: number; + requiredElapsedMs?: number; + sameSession?: boolean; + legacyMissingTimestamp?: boolean; + reinforcementMode?: string; + instrumentationVersion?: number; + }>; }; assert.equal(parsed.version, 1); @@ -524,18 +624,27 @@ test("memory-diag commands memory selector emits stable JSON", async () => { assert.equal(parsed.current.present, true); assert.equal(parsed.current.status, "active"); assert.equal(parsed.current.renderStatus, "rendered"); - assert.equal(parsed.summary.attempts, 3); - assert.equal(parsed.summary.reinforced, 1); - assert.equal(parsed.summary.rejectedOrBlocked, 2); + assert.equal(parsed.summary.attempts, 7); + assert.equal(parsed.summary.reinforced, 4); + assert.equal(parsed.summary.rejectedOrBlocked, 3); + assert.equal(parsed.summary.blocksByReason.min_elapsed_window, 1); assert.equal(parsed.summary.blocksByReason.same_session, 1); assert.equal(parsed.summary.blocksByReason.unknown, 1); assert.equal(parsed.summary.blockDetailsMissing, 1); assert.equal(parsed.summary.sameSessionCrossUtcDayBlocks, 1); + assert.equal(parsed.events.some(event => event.blockReason === "min_elapsed_window" && event.elapsedMs === 604_799_999 && event.requiredElapsedMs === 604_800_000 && event.sameSession === true), true); + assert.equal(parsed.events.some(event => event.reinforcementMode === "increment" && event.elapsedMs === 604_800_000 && event.sameSession === false), true); + assert.equal(parsed.events.some(event => event.reinforcementMode === "refresh_only" && event.elapsedMs === 604_800_000 && event.sameSession === true), true); + assert.equal(parsed.events.some(event => event.legacyMissingTimestamp === true), true); + assert.equal(parsed.events.every(event => event.instrumentationVersion === 3), true); assert.equal(parsed.events.some(event => event.blockReason === "same_session" && event.crossUtcDay === true), true); assert.equal(parsed.events.some(event => event.blockReason === "unknown" && event.crossUtcDay === "unknown"), true); assert.equal(JSON.stringify(parsed).includes("command-session"), false); assert.equal(JSON.stringify(parsed).includes("command-message"), false); assert.equal(JSON.stringify(parsed).includes("Detail drill-down memory remains current"), false); + assert.equal(JSON.stringify(parsed).includes("raw-session-secret"), false); + assert.equal(JSON.stringify(parsed).includes("raw-last-session-secret"), false); + assert.equal(JSON.stringify(parsed).includes("raw-refresh-last-session-secret"), false); assertNoAttributionSafetyTerms(stdout); } finally { await rm(root, { recursive: true, force: true }); diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index d8f39fa..190247d 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -100,6 +100,60 @@ function createSessionWithError(sessionID: string, error: OpenError) { }; } +async function withMockedDateNow(now: number, fn: () => Promise): Promise { + const originalDateNow = Date.now; + Date.now = () => now; + try { + return await fn(); + } finally { + Date.now = originalDateNow; + } +} + +async function withNumberedReinforceScenario( + options: { sessionID: string; nowMs: number; existing: LongTermMemoryEntry; summary?: string }, + assertions: (context: { + workspace: Awaited>; + events: Awaited>; + }) => Promise | void, +): Promise { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); + try { + await updateWorkspaceMemory(tmpDir, store => { + store.entries.push(options.existing); + return store; + }); + await saveSessionState(tmpDir, { + version: 1, + sessionID: options.sessionID, + turn: 0, + updatedAt: new Date(options.nowMs).toISOString(), + activeFiles: [], + openErrors: [], + recentDecisions: [], + pendingMemories: [], + compactionMemoryRefs: [compactionRefFor(options.existing)], + }); + + const plugin = await MemoryV2Plugin({ + directory: tmpDir, + client: mockClientWithCompactionSummary(options.summary ?? "Memory candidates:\nREINFORCE [M1]"), + }); + await withMockedDateNow(options.nowMs, async () => { + await (plugin as Record)["event"]({ + event: { type: "session.compacted", properties: { sessionID: options.sessionID } }, + }); + }); + + await assertions({ + workspace: await loadWorkspaceMemory(tmpDir), + events: await queryEvidenceEvents(tmpDir, { types: ["memory_reinforced"] }), + }); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +} + test("tool.execute.after: undefined exitCode does NOT create open error", async () => { // 1. Temp directory for isolated file I/O const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); @@ -1032,8 +1086,9 @@ test("session.compacted applies numbered REINFORCE command to referenced memory" const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); try { - const now = new Date().toISOString(); - const oldRetentionClock = Date.now() - 10 * 24 * 60 * 60 * 1000; + const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0); + const now = new Date(nowMs).toISOString(); + const lastReinforcedAt = nowMs - 8 * 24 * 60 * 60 * 1000; const existing: LongTermMemoryEntry = { id: "numbered-reinforce-memory", type: "decision", @@ -1043,7 +1098,10 @@ test("session.compacted applies numbered REINFORCE command to referenced memory" status: "active", createdAt: now, updatedAt: now, - retentionClock: oldRetentionClock, + retentionClock: lastReinforcedAt, + reinforcementCount: 1, + lastReinforcedAt, + lastReinforcedSessionID: "numbered-reinforce-session", }; await updateWorkspaceMemory(tmpDir, store => { store.entries.push(existing); @@ -1065,28 +1123,174 @@ test("session.compacted applies numbered REINFORCE command to referenced memory" directory: tmpDir, client: mockClientWithCompactionSummary("Memory candidates:\nREINFORCE [M1]"), }); - await (plugin as Record)["event"]({ - event: { type: "session.compacted", properties: { sessionID: "numbered-reinforce-session" } }, + await withMockedDateNow(nowMs, async () => { + await (plugin as Record)["event"]({ + event: { type: "session.compacted", properties: { sessionID: "numbered-reinforce-session" } }, + }); }); const workspace = await loadWorkspaceMemory(tmpDir); const reinforced = workspace.entries.find(entry => entry.id === existing.id); - assert.equal(reinforced?.reinforcementCount, 1); + assert.equal(reinforced?.reinforcementCount, 2); assert.equal(reinforced?.lastReinforcedSessionID, "numbered-reinforce-session"); - assert.ok((reinforced?.retentionClock ?? 0) > oldRetentionClock); + assert.equal(reinforced?.lastReinforcedAt, nowMs); + assert.equal(reinforced?.retentionClock, nowMs); const events = await queryEvidenceEvents(tmpDir, { types: ["memory_reinforced"] }); - assert.ok(events.some(event => + const event = events.find(event => event.outcome === "reinforced" && event.reasonCodes.includes("numbered_ref_reinforce") && event.reasonCodes.includes("reinforcement_window_allowed") && event.memory?.memoryId === existing.id - )); + ); + assert.ok(event, "numbered REINFORCE should emit allowed evidence"); + assert.equal(event.instrumentationVersion, 3); + assert.equal(event.details?.reinforcementOutcome, "reinforced"); + assert.equal(event.details?.reinforcementMode, "increment"); + assert.equal(event.details?.sameSession, true); + assert.equal(event.details?.elapsedMs, 8 * 24 * 60 * 60 * 1000); + assert.equal(event.details?.requiredElapsedMs, 7 * 24 * 60 * 60 * 1000); + assert.equal(event.details?.attemptedAtMs, nowMs); + assert.equal(event.details?.lastReinforcedAtMs, lastReinforcedAt); + assert.equal(event.details?.previousReinforcementCount, 1); + assert.equal(event.details?.newReinforcementCount, 2); + assert.equal(JSON.stringify(event.details).includes("numbered-reinforce-session"), false); } finally { await rm(tmpDir, { recursive: true, force: true }); } }); +test("session.compacted allows numbered REINFORCE at exact 7-day boundary", async () => { + const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0); + const lastReinforcedAt = nowMs - 7 * 24 * 60 * 60 * 1000; + const existing: LongTermMemoryEntry = { + id: "numbered-reinforce-exact-window", + type: "decision", + text: "Use exact rolling windows for memory reinforcement.", + source: "compaction", + confidence: 0.9, + status: "active", + createdAt: new Date(nowMs).toISOString(), + updatedAt: new Date(nowMs).toISOString(), + retentionClock: lastReinforcedAt, + reinforcementCount: 5, + lastReinforcedAt, + lastReinforcedSessionID: "previous-session", + }; + + await withNumberedReinforceScenario({ sessionID: "exact-window-session", nowMs, existing }, ({ workspace, events }) => { + const reinforced = workspace.entries.find(entry => entry.id === existing.id); + const event = events.find(event => event.outcome === "reinforced" && event.memory?.memoryId === existing.id); + + assert.equal(reinforced?.reinforcementCount, 6); + assert.equal(reinforced?.retentionClock, nowMs); + assert.ok(event, "exact 7-day boundary should emit reinforced evidence"); + assert.equal(event.details?.reinforcementMode, "increment"); + assert.equal(event.details?.elapsedMs, 7 * 24 * 60 * 60 * 1000); + assert.equal(event.details?.requiredElapsedMs, 7 * 24 * 60 * 60 * 1000); + assert.equal(event.instrumentationVersion, 3); + }); +}); + +test("session.compacted blocks different-session numbered REINFORCE below 7-day window", async () => { + const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0); + const lastReinforcedAt = nowMs - (7 * 24 * 60 * 60 * 1000 - 1); + const existing: LongTermMemoryEntry = { + id: "numbered-reinforce-below-window", + type: "feedback", + text: "User prefers below-window reinforcement blocks.", + source: "compaction", + confidence: 0.9, + status: "active", + createdAt: new Date(nowMs).toISOString(), + updatedAt: new Date(nowMs).toISOString(), + retentionClock: nowMs - 10 * 24 * 60 * 60 * 1000, + reinforcementCount: 1, + lastReinforcedAt, + lastReinforcedSessionID: "previous-session", + }; + + await withNumberedReinforceScenario({ sessionID: "different-session", nowMs, existing }, ({ workspace, events }) => { + const blocked = workspace.entries.find(entry => entry.id === existing.id); + const event = events.find(event => event.outcome === "rejected" && event.memory?.memoryId === existing.id); + + assert.equal(blocked?.reinforcementCount, 1); + assert.ok(event, "below-window different-session attempt should emit rejected evidence"); + assert.ok(event.reasonCodes.includes("reinforcement_block_min_elapsed_window")); + assert.equal(event.details?.blockReason, "min_elapsed_window"); + assert.equal(event.details?.sameSession, false); + assert.equal(event.details?.elapsedMs, 7 * 24 * 60 * 60 * 1000 - 1); + assert.equal(event.details?.requiredElapsedMs, 7 * 24 * 60 * 60 * 1000); + assert.equal(event.instrumentationVersion, 3); + }); +}); + +test("session.compacted refreshes saturated numbered REINFORCE after rolling window", async () => { + const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0); + const lastReinforcedAt = nowMs - 7 * 24 * 60 * 60 * 1000; + const existing: LongTermMemoryEntry = { + id: "numbered-reinforce-refresh-only", + type: "decision", + text: "Use refresh-only mode for saturated reinforcement.", + source: "compaction", + confidence: 0.9, + status: "active", + createdAt: new Date(nowMs).toISOString(), + updatedAt: new Date(nowMs).toISOString(), + retentionClock: nowMs - 30 * 24 * 60 * 60 * 1000, + reinforcementCount: 6, + lastReinforcedAt, + lastReinforcedSessionID: "previous-session", + }; + + await withNumberedReinforceScenario({ sessionID: "refresh-only-session", nowMs, existing }, ({ workspace, events }) => { + const refreshed = workspace.entries.find(entry => entry.id === existing.id); + const event = events.find(event => event.outcome === "reinforced" && event.memory?.memoryId === existing.id); + + assert.equal(refreshed?.reinforcementCount, 6); + assert.equal(refreshed?.retentionClock, nowMs); + assert.equal(refreshed?.lastReinforcedAt, nowMs); + assert.equal(refreshed?.lastReinforcedSessionID, "refresh-only-session"); + assert.ok(event, "saturated after-window attempt should emit refreshed evidence"); + assert.equal(event.details?.reinforcementOutcome, "refreshed"); + assert.equal(event.details?.reinforcementMode, "refresh_only"); + assert.equal(event.details?.previousReinforcementCount, 6); + assert.equal(event.details?.newReinforcementCount, 6); + assert.ok(event.reasonCodes.includes("reinforcement_saturation_refresh")); + assert.equal(event.reasonCodes.includes("reinforcement_block_max_count"), false); + assert.equal(event.instrumentationVersion, 3); + }); +}); + +test("session.compacted emits legacy missing timestamp details for numbered REINFORCE", async () => { + const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0); + const existing: LongTermMemoryEntry = { + id: "numbered-reinforce-legacy-missing", + type: "feedback", + text: "User prefers legacy timestamp anomalies to be visible.", + source: "compaction", + confidence: 0.9, + status: "active", + createdAt: new Date(nowMs).toISOString(), + updatedAt: new Date(nowMs).toISOString(), + retentionClock: nowMs - 30 * 24 * 60 * 60 * 1000, + reinforcementCount: 2, + lastReinforcedSessionID: "previous-session", + }; + + await withNumberedReinforceScenario({ sessionID: "legacy-missing-session", nowMs, existing }, ({ workspace, events }) => { + const reinforced = workspace.entries.find(entry => entry.id === existing.id); + const event = events.find(event => event.outcome === "reinforced" && event.memory?.memoryId === existing.id); + + assert.equal(reinforced?.reinforcementCount, 3); + assert.equal(reinforced?.lastReinforcedAt, nowMs); + assert.equal(reinforced?.retentionClock, nowMs); + assert.equal(event?.details?.legacyMissingTimestamp, true); + assert.equal(event?.details?.reinforcementMode, "increment"); + assert.equal(event?.instrumentationVersion, 3); + }); +}); + test("session.compacted rejects invalid unavailable and stale REINFORCE refs without mutation", async () => { const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); @@ -1163,8 +1367,9 @@ test("session.compacted emits rejected evidence when numbered REINFORCE is block const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); try { - const nowMs = Date.now(); + const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0); const now = new Date(nowMs).toISOString(); + const lastReinforcedAt = nowMs - (7 * 24 * 60 * 60 * 1000 - 60 * 60 * 1000); const existing: LongTermMemoryEntry = { id: "blocked-numbered-reinforce", type: "feedback", @@ -1176,7 +1381,7 @@ test("session.compacted emits rejected evidence when numbered REINFORCE is block updatedAt: now, retentionClock: nowMs - 10 * 24 * 60 * 60 * 1000, reinforcementCount: 1, - lastReinforcedAt: nowMs - 2 * 60 * 60 * 1000, + lastReinforcedAt, lastReinforcedSessionID: "blocked-reinforce-session", }; await updateWorkspaceMemory(tmpDir, store => { @@ -1199,8 +1404,10 @@ test("session.compacted emits rejected evidence when numbered REINFORCE is block directory: tmpDir, client: mockClientWithCompactionSummary("Memory candidates:\nREINFORCE [M1]"), }); - await (plugin as Record)["event"]({ - event: { type: "session.compacted", properties: { sessionID: "blocked-reinforce-session" } }, + await withMockedDateNow(nowMs, async () => { + await (plugin as Record)["event"]({ + event: { type: "session.compacted", properties: { sessionID: "blocked-reinforce-session" } }, + }); }); const workspace = await loadWorkspaceMemory(tmpDir); @@ -1209,13 +1416,31 @@ test("session.compacted emits rejected evidence when numbered REINFORCE is block assert.equal(blocked?.reinforcementCount, 1); const events = await queryEvidenceEvents(tmpDir, { types: ["memory_reinforced"] }); - assert.ok(events.some(event => + const event = events.find(event => event.outcome === "rejected" && event.reasonCodes.includes("reinforcement_window_blocked") && + event.reasonCodes.includes("reinforcement_block_min_elapsed_window") && event.memory?.memoryId === existing.id && event.relations?.some(relation => relation.role === "target" && relation.memory?.memoryId === existing.id) && !event.relations?.some(relation => relation.role === "reinforced") - )); + ); + assert.ok(event, "blocked numbered REINFORCE should emit elapsed-window evidence"); + assert.equal(event.instrumentationVersion, 3); + assert.equal(event.reasonCodes.includes("reinforcement_block_same_session"), false); + assert.equal(event.reasonCodes.includes("reinforcement_block_same_utc_day"), false); + assert.equal(event.reasonCodes.includes("reinforcement_block_min_interval"), false); + assert.equal(event.reasonCodes.includes("reinforcement_block_max_count"), false); + assert.equal(event.details?.blockReason, "min_elapsed_window"); + assert.equal(event.details?.elapsedMs, 7 * 24 * 60 * 60 * 1000 - 60 * 60 * 1000); + assert.equal(event.details?.requiredElapsedMs, 7 * 24 * 60 * 60 * 1000); + assert.equal(event.details?.sameSession, true); + assert.equal(event.details?.attemptedAtMs, nowMs); + assert.equal(event.details?.lastReinforcedAtMs, lastReinforcedAt); + assert.equal(event.details?.reinforcementCount, 1); + assert.equal(event.details?.maxReinforcementCount, 6); + assert.equal("minIntervalMs" in (event.details ?? {}), false); + assert.equal("reinforcementMode" in (event.details ?? {}), false); + assert.equal(JSON.stringify(event.details).includes("blocked-reinforce-session"), false); } finally { await rm(tmpDir, { recursive: true, force: true }); } diff --git a/tests/retention.test.ts b/tests/retention.test.ts index 915aded..cf9f8db 100644 --- a/tests/retention.test.ts +++ b/tests/retention.test.ts @@ -1,12 +1,15 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + BASE_HALF_LIFE_DAYS, REINFORCEMENT_MAX_COUNT, - REINFORCEMENT_MIN_INTERVAL_MS, tryReinforceMemory, } from "../src/retention.ts"; import type { LongTermMemoryEntry } from "../src/types.ts"; +const DAY_MS = 24 * 60 * 60 * 1000; +const ROLLING_WINDOW_MS = 7 * DAY_MS; + const baseMemory = (overrides: Partial = {}): LongTermMemoryEntry => ({ id: "mem-retention", type: "decision", @@ -19,40 +22,77 @@ const baseMemory = (overrides: Partial = {}): LongTermMemor ...overrides, }); -test("tryReinforceMemory blocks same session with exact reason", () => { - const now = Date.UTC(2026, 4, 12, 12, 0, 0); +test("tryReinforceMemory allows same session after rolling 8 days", () => { + const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0); + const now = lastAt + 8 * DAY_MS; const memory = baseMemory({ reinforcementCount: 1, - lastReinforcedAt: now - 2 * REINFORCEMENT_MIN_INTERVAL_MS, + lastReinforcedAt: lastAt, + lastReinforcedSessionID: "session-a", + retentionClock: lastAt, + }); + + const decision = tryReinforceMemory(memory, "session-a", now); + + assert.equal(decision.outcome, "reinforced"); + assert.equal(decision.reinforcementMode, "increment"); + assert.equal(decision.previousReinforcementCount, 1); + assert.equal(decision.newReinforcementCount, 2); + assert.equal(decision.memory.reinforcementCount, 2); + assert.equal(decision.memory.retentionClock, now); + assert.equal(decision.memory.lastReinforcedAt, now); + assert.equal(decision.memory.lastReinforcedSessionID, "session-a"); + assert.equal(decision.sameSession, true); + assert.equal(decision.elapsedMs, 8 * DAY_MS); + assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS); +}); + +test("tryReinforceMemory allows exactly 7 rolling days after last reinforcement", () => { + const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0); + const now = lastAt + ROLLING_WINDOW_MS; + const memory = baseMemory({ + reinforcementCount: 5, + lastReinforcedAt: lastAt, + lastReinforcedSessionID: "session-a", + retentionClock: lastAt, + }); + + const decision = tryReinforceMemory(memory, "session-b", now); + + assert.equal(decision.outcome, "reinforced"); + assert.equal(decision.reinforcementMode, "increment"); + assert.equal(decision.memory.reinforcementCount, 6); + assert.equal(decision.memory.retentionClock, now); + assert.equal(decision.memory.lastReinforcedAt, now); + assert.equal(decision.memory.lastReinforcedSessionID, "session-b"); + assert.equal(decision.sameSession, false); + assert.equal(decision.elapsedMs, ROLLING_WINDOW_MS); + assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS); +}); + +test("tryReinforceMemory blocks 7 rolling days minus 1ms as min_elapsed_window", () => { + const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0); + const now = lastAt + ROLLING_WINDOW_MS - 1; + const memory = baseMemory({ + reinforcementCount: 1, + lastReinforcedAt: lastAt, lastReinforcedSessionID: "session-a", }); const decision = tryReinforceMemory(memory, "session-a", now); assert.equal(decision.outcome, "blocked"); - assert.equal(decision.blockReason, "same_session"); + assert.equal(decision.blockReason, "min_elapsed_window"); assert.equal(decision.memory, memory); -}); - -test("tryReinforceMemory blocks different session on same UTC day", () => { - const lastAt = Date.UTC(2026, 4, 12, 0, 15, 0); - const now = Date.UTC(2026, 4, 12, 23, 30, 0); - const memory = baseMemory({ - reinforcementCount: 1, - lastReinforcedAt: lastAt, - lastReinforcedSessionID: "session-a", - }); - - const decision = tryReinforceMemory(memory, "session-b", now); - - assert.equal(decision.outcome, "blocked"); - assert.equal(decision.blockReason, "same_utc_day"); + assert.equal(decision.sameSession, true); assert.equal(decision.lastReinforcedAt, lastAt); + assert.equal(decision.elapsedMs, ROLLING_WINDOW_MS - 1); + assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS); }); -test("tryReinforceMemory blocks min interval across UTC day boundary", () => { - const lastAt = Date.UTC(2026, 4, 12, 23, 45, 0); - const now = Date.UTC(2026, 4, 13, 0, 15, 0); +test("tryReinforceMemory blocks different session below rolling window", () => { + const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0); + const now = lastAt + 3 * DAY_MS; const memory = baseMemory({ reinforcementCount: 1, lastReinforcedAt: lastAt, @@ -62,42 +102,110 @@ test("tryReinforceMemory blocks min interval across UTC day boundary", () => { const decision = tryReinforceMemory(memory, "session-b", now); assert.equal(decision.outcome, "blocked"); - assert.equal(decision.blockReason, "min_interval"); - assert.equal(decision.minIntervalMs, REINFORCEMENT_MIN_INTERVAL_MS); + assert.equal(decision.blockReason, "min_elapsed_window"); + assert.equal(decision.sameSession, false); + assert.equal(decision.elapsedMs, 3 * DAY_MS); + assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS); }); -test("tryReinforceMemory blocks max count with exact reason", () => { - const now = Date.UTC(2026, 4, 12, 12, 0, 0); +test("tryReinforceMemory blocks UTC midnight crossing below rolling window", () => { + const lastAt = Date.UTC(2026, 4, 12, 23, 45, 0); + const now = Date.UTC(2026, 4, 13, 2, 15, 0); + const memory = baseMemory({ + reinforcementCount: 1, + lastReinforcedAt: lastAt, + lastReinforcedSessionID: "session-a", + }); + + const decision = tryReinforceMemory(memory, "session-b", now); + + assert.equal(decision.outcome, "blocked"); + assert.equal(decision.blockReason, "min_elapsed_window"); + assert.notEqual(decision.blockReason, "same_utc_day"); + assert.equal(decision.elapsedMs, now - lastAt); + assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS); +}); + +test("tryReinforceMemory refreshes saturated count after rolling window", () => { + const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0); + const now = lastAt + ROLLING_WINDOW_MS; const memory = baseMemory({ reinforcementCount: REINFORCEMENT_MAX_COUNT, - lastReinforcedAt: Date.UTC(2026, 4, 10, 12, 0, 0), + lastReinforcedAt: lastAt, + lastReinforcedSessionID: "session-a", + retentionClock: lastAt, + }); + + const decision = tryReinforceMemory(memory, "session-b", now); + + assert.equal(decision.outcome, "reinforced"); + assert.equal(decision.reinforcementMode, "refresh_only"); + assert.equal(decision.previousReinforcementCount, REINFORCEMENT_MAX_COUNT); + assert.equal(decision.newReinforcementCount, REINFORCEMENT_MAX_COUNT); + assert.notEqual(decision.memory, memory); + assert.equal(decision.memory.reinforcementCount, REINFORCEMENT_MAX_COUNT); + assert.equal(decision.memory.lastReinforcedAt, now); + assert.equal(decision.memory.lastReinforcedSessionID, "session-b"); + assert.equal(decision.memory.retentionClock, now); + assert.equal(decision.elapsedMs, ROLLING_WINDOW_MS); + assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS); +}); + +test("tryReinforceMemory blocks saturated count below rolling window as min_elapsed_window", () => { + const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0); + const now = lastAt + ROLLING_WINDOW_MS - 1; + const memory = baseMemory({ + reinforcementCount: REINFORCEMENT_MAX_COUNT, + lastReinforcedAt: lastAt, lastReinforcedSessionID: "session-a", }); const decision = tryReinforceMemory(memory, "session-b", now); assert.equal(decision.outcome, "blocked"); - assert.equal(decision.blockReason, "max_count"); + assert.equal(decision.blockReason, "min_elapsed_window"); + assert.notEqual(decision.blockReason, "max_count"); assert.equal(decision.reinforcementCount, REINFORCEMENT_MAX_COUNT); assert.equal(decision.maxReinforcementCount, REINFORCEMENT_MAX_COUNT); + assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS); }); -test("tryReinforceMemory reinforces allowed memory and wrapper returns memory only", () => { +test("tryReinforceMemory normalizes missing legacy timestamp while incrementing", () => { const now = Date.UTC(2026, 4, 12, 12, 0, 0); const memory = baseMemory({ - reinforcementCount: 1, - lastReinforcedAt: Date.UTC(2026, 4, 10, 12, 0, 0), + reinforcementCount: 2, lastReinforcedSessionID: "session-a", }); const decision = tryReinforceMemory(memory, "session-b", now); assert.equal(decision.outcome, "reinforced"); - assert.equal(decision.previousReinforcementCount, 1); - assert.equal(decision.newReinforcementCount, 2); - assert.notEqual(decision.memory, memory); - assert.equal(decision.memory.reinforcementCount, 2); + assert.equal(decision.reinforcementMode, "increment"); + assert.equal(decision.legacyMissingTimestamp, true); + assert.equal(decision.memory.reinforcementCount, 3); assert.equal(decision.memory.lastReinforcedAt, now); - assert.equal(decision.memory.lastReinforcedSessionID, "session-b"); assert.equal(decision.memory.retentionClock, now); }); + +test("tryReinforceMemory normalizes invalid legacy timestamp while refresh-only saturated", () => { + const now = Date.UTC(2026, 4, 12, 12, 0, 0); + const memory = baseMemory({ + reinforcementCount: REINFORCEMENT_MAX_COUNT, + lastReinforcedAt: Number.NaN, + lastReinforcedSessionID: "session-a", + retentionClock: Date.UTC(2026, 4, 8, 12, 0, 0), + }); + + const decision = tryReinforceMemory(memory, "session-b", now); + + assert.equal(decision.outcome, "reinforced"); + assert.equal(decision.reinforcementMode, "refresh_only"); + assert.equal(decision.legacyMissingTimestamp, true); + assert.equal(decision.memory.reinforcementCount, REINFORCEMENT_MAX_COUNT); + assert.equal(decision.memory.lastReinforcedAt, now); + assert.equal(decision.memory.retentionClock, now); +}); + +test("BASE_HALF_LIFE_DAYS remains 45", () => { + assert.equal(BASE_HALF_LIFE_DAYS, 45); +}); diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index 6801b3e..c4bd86e 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -72,6 +72,16 @@ function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } +function withMockedDateNow(now: number, fn: () => T): T { + const originalDateNow = Date.now; + Date.now = () => now; + try { + return fn(); + } finally { + Date.now = originalDateNow; + } +} + /** Create an entry with a createdAt offset from now (negative = in the past) */ function agedEntry( id: string, @@ -580,7 +590,7 @@ test("normalizeWorkspaceMemoryWithAccounting uses dormant workspace days for str assert.deepEqual(result.kept.map(memory => memory.id), ["reinforced-old", "fresh"]); }); -test("reinforceMemory enforces session interval and max guards", () => { +test("reinforceMemory enforces rolling elapsed window and saturation refresh", () => { const now = Date.UTC(2026, 3, 29); const base = entry("reinforce", "Durable memory should reinforce only when gated"); const reinforced = tryReinforceMemory(base, "session-a", now).memory; @@ -594,18 +604,26 @@ test("reinforceMemory enforces session interval and max guards", () => { assert.equal(tryReinforceMemory(reinforced, "session-a", now + 2 * 60 * 60 * 1000).memory, reinforced); assert.equal(tryReinforceMemory(reinforced, "session-b", now + 30 * 60 * 1000).memory, reinforced); + const reinforcedAfterWindow = tryReinforceMemory(reinforced, "session-a", now + 7 * DAY_MS).memory; + assert.notEqual(reinforcedAfterWindow, reinforced); + assert.equal(reinforcedAfterWindow.reinforcementCount, 2); + const atMax: LongTermMemoryEntry = { ...base, reinforcementCount: 6, - lastReinforcedAt: now - 2 * 60 * 60 * 1000, + lastReinforcedAt: now - 7 * DAY_MS, }; - assert.equal(tryReinforceMemory(atMax, "session-c", now).memory, atMax); + const refreshedAtMax = tryReinforceMemory(atMax, "session-c", now).memory; + assert.notEqual(refreshedAtMax, atMax); + assert.equal(refreshedAtMax.reinforcementCount, 6); + assert.equal(refreshedAtMax.retentionClock, now); }); -test("reinforceMemory requires distinct UTC calendar days between reinforcements", () => { +test("reinforceMemory uses rolling elapsed window instead of UTC calendar days", () => { const firstReinforcedAt = Date.UTC(2026, 3, 29, 0, 15); const sameUtcDayMuchLater = Date.UTC(2026, 3, 29, 23, 30); const nextUtcDayAfterInterval = Date.UTC(2026, 3, 30, 1, 30); + const afterWindow = firstReinforcedAt + 7 * DAY_MS; const base: LongTermMemoryEntry = { ...entry("calendar-day-gated", "Reinforcement requires distinct UTC calendar days", "decision"), reinforcementCount: 1, @@ -614,13 +632,14 @@ test("reinforceMemory requires distinct UTC calendar days between reinforcements }; assert.equal(tryReinforceMemory(base, "session-b", sameUtcDayMuchLater).memory, base); + assert.equal(tryReinforceMemory(base, "session-b", nextUtcDayAfterInterval).memory, base); - const reinforcedNextDay = tryReinforceMemory(base, "session-b", nextUtcDayAfterInterval).memory; - assert.notEqual(reinforcedNextDay, base); - assert.equal(reinforcedNextDay.reinforcementCount, 2); - assert.equal(reinforcedNextDay.lastReinforcedAt, nextUtcDayAfterInterval); - assert.equal(reinforcedNextDay.lastReinforcedSessionID, "session-b"); - assert.equal(reinforcedNextDay.retentionClock, nextUtcDayAfterInterval); + const reinforcedAfterWindow = tryReinforceMemory(base, "session-b", afterWindow).memory; + assert.notEqual(reinforcedAfterWindow, base); + assert.equal(reinforcedAfterWindow.reinforcementCount, 2); + assert.equal(reinforcedAfterWindow.lastReinforcedAt, afterWindow); + assert.equal(reinforcedAfterWindow.lastReinforcedSessionID, "session-b"); + assert.equal(reinforcedAfterWindow.retentionClock, afterWindow); const atMax: LongTermMemoryEntry = { ...base, @@ -716,64 +735,75 @@ test("reinforced memory with same initial strength and age ranks above unreinfor assert.deepEqual(kept.map(memory => memory.id), ["reinforced", "unreinforced"]); }); -test("dedupe reinforcement does not increment for same session", () => { - const now = Date.now(); +test("dedupe reinforcement allows same session after rolling 8 days with increment evidence", () => { + const now = Date.UTC(2026, 4, 15, 12, 0, 0); const existing: LongTermMemoryEntry = { ...entry("existing", "Use pnpm for package management", "decision"), source: "manual", pendingOwnerSessionID: "same-session", reinforcementCount: 1, - lastReinforcedAt: now - 2 * 60 * 60 * 1000, + lastReinforcedAt: now - 8 * DAY_MS, lastReinforcedSessionID: "same-session", + retentionClock: now - 8 * DAY_MS, }; const duplicate: LongTermMemoryEntry = { ...entry("duplicate", "use pnpm for package management!!!", "decision"), pendingOwnerSessionID: "same-session", }; - const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]); + const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate])); const retained = result.kept.find(memory => memory.id === "existing"); + const reinforced = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "reinforced"); assert.ok(retained, "existing manual memory should be retained"); - assert.equal(retained.reinforcementCount, 1); + assert.equal(retained.reinforcementCount, 2); assert.equal(retained.lastReinforcedSessionID, "same-session"); - assert.equal(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "reinforced"), false); - assert.ok(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "rejected" && event.details?.blockReason === "same_session")); + assert.equal(retained.retentionClock, now); + assert.ok(reinforced, "same-session after window should emit reinforced evidence"); + assert.equal(reinforced.details?.reinforcementOutcome, "reinforced"); + assert.equal(reinforced.details?.reinforcementMode, "increment"); + assert.equal(reinforced.details?.sameSession, true); + assert.equal(reinforced.details?.elapsedMs, 8 * DAY_MS); + assert.equal(reinforced.details?.requiredElapsedMs, 7 * DAY_MS); + assert.equal(reinforced.details?.attemptedAtMs, now); + assert.equal(reinforced.details?.lastReinforcedAtMs, now - 8 * DAY_MS); + assert.equal(reinforced.details?.previousReinforcementCount, 1); + assert.equal(reinforced.details?.newReinforcementCount, 2); + assert.equal(JSON.stringify(reinforced.details).includes("same-session"), false); }); -test("dedupe blocked reinforcement emits exact block reason details", () => { - const now = Date.now(); +test("dedupe reinforcement allows exactly 7 rolling days", () => { + const now = Date.UTC(2026, 4, 15, 12, 0, 0); const existing: LongTermMemoryEntry = { - ...entry("existing-blocked", "Prefer deterministic consolidation accounting", "feedback"), + ...entry("existing-exact-window", "Prefer deterministic consolidation accounting", "feedback"), source: "manual", - reinforcementCount: 1, - lastReinforcedAt: now - 30 * 60 * 1000, + reinforcementCount: 5, + lastReinforcedAt: now - 7 * DAY_MS, lastReinforcedSessionID: "old-session", }; const duplicate: LongTermMemoryEntry = { - ...entry("duplicate-blocked", "prefer deterministic consolidation accounting!!!", "feedback"), + ...entry("duplicate-exact-window", "prefer deterministic consolidation accounting!!!", "feedback"), pendingOwnerSessionID: "new-session", }; - const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]); - const blocked = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "rejected"); + const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate])); + const retained = result.kept.find(memory => memory.id === "existing-exact-window"); + const reinforced = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "reinforced"); - assert.ok(blocked, "blocked duplicate reinforcement should emit diagnostic evidence"); - assert.ok(blocked.reasonCodes.includes("reinforcement_window_blocked")); - assert.ok(blocked.reasonCodes.includes("reinforcement_block_min_interval")); - assert.equal(blocked.details?.blockReason, "min_interval"); - assert.equal(blocked.details?.reinforcementCount, 1); - assert.equal(blocked.details?.maxReinforcementCount, 6); - assert.equal(blocked.details?.minIntervalMs, 60 * 60 * 1000); + assert.equal(retained?.reinforcementCount, 6); + assert.ok(reinforced, "exact 7-day window should emit reinforced evidence"); + assert.equal(reinforced.details?.reinforcementMode, "increment"); + assert.equal(reinforced.details?.elapsedMs, 7 * DAY_MS); + assert.equal(reinforced.details?.requiredElapsedMs, 7 * DAY_MS); }); -test("dedupe reinforcement does not increment under one hour", () => { - const now = Date.now(); +test("dedupe reinforcement blocks 7 rolling days minus 1ms with elapsed details", () => { + const now = Date.UTC(2026, 4, 15, 12, 0, 0); const existing: LongTermMemoryEntry = { ...entry("existing", "Prefer deterministic consolidation accounting", "feedback"), source: "manual", reinforcementCount: 1, - lastReinforcedAt: now - 30 * 60 * 1000, + lastReinforcedAt: now - 7 * DAY_MS + 1, lastReinforcedSessionID: "old-session", }; const duplicate: LongTermMemoryEntry = { @@ -781,31 +811,109 @@ test("dedupe reinforcement does not increment under one hour", () => { pendingOwnerSessionID: "new-session", }; - const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]); + const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate])); const retained = result.kept.find(memory => memory.id === "existing"); + const blocked = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "rejected"); 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" && event.outcome === "reinforced"), false); - assert.ok(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "rejected" && event.details?.blockReason === "min_interval")); + assert.ok(blocked, "below-window duplicate reinforcement should emit rejected evidence"); + assert.ok(blocked.reasonCodes.includes("reinforcement_window_blocked")); + assert.ok(blocked.reasonCodes.includes("reinforcement_block_min_elapsed_window")); + assert.equal(blocked.reasonCodes.includes("reinforcement_block_min_interval"), false); + assert.equal(blocked.details?.blockReason, "min_elapsed_window"); + assert.equal(blocked.details?.sameSession, false); + assert.equal(blocked.details?.elapsedMs, 7 * DAY_MS - 1); + assert.equal(blocked.details?.requiredElapsedMs, 7 * DAY_MS); + assert.equal(blocked.details?.reinforcementCount, 1); + assert.equal(blocked.details?.maxReinforcementCount, 6); + assert.equal("minIntervalMs" in (blocked.details ?? {}), false); + assert.equal("reinforcementMode" in (blocked.details ?? {}), false); }); -test("dedupe reinforcement does not emit evidence at max reinforcement count", () => { +test("dedupe reinforcement blocks same session at 6d23h as min elapsed window", () => { + const now = Date.UTC(2026, 4, 15, 12, 0, 0); + const existing: LongTermMemoryEntry = { + ...entry("existing-same-session-window", "Prefer deterministic consolidation accounting", "feedback"), + source: "manual", + pendingOwnerSessionID: "same-session-window", + reinforcementCount: 1, + lastReinforcedAt: now - (7 * DAY_MS - 60 * 60 * 1000), + lastReinforcedSessionID: "same-session-window", + }; + const duplicate: LongTermMemoryEntry = { + ...entry("duplicate-same-session-window", "prefer deterministic consolidation accounting!!!", "feedback"), + pendingOwnerSessionID: "same-session-window", + }; + + const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate])); + const blocked = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "rejected"); + + assert.ok(blocked, "6d23h same-session attempt should be rejected"); + assert.equal(blocked.details?.blockReason, "min_elapsed_window"); + assert.equal(blocked.details?.elapsedMs, 7 * DAY_MS - 60 * 60 * 1000); + assert.equal(blocked.details?.requiredElapsedMs, 7 * DAY_MS); + assert.equal(blocked.details?.sameSession, true); + assert.equal(blocked.reasonCodes.includes("reinforcement_block_same_session"), false); +}); + +test("dedupe reinforcement refreshes saturated count after rolling window", () => { + const now = Date.UTC(2026, 4, 15, 12, 0, 0); + const oldRetentionClock = now - 30 * DAY_MS; const existing: LongTermMemoryEntry = { ...entry("existing-max", "Prefer deterministic consolidation accounting", "feedback"), source: "manual", reinforcementCount: 6, + lastReinforcedAt: now - 7 * DAY_MS, + lastReinforcedSessionID: "old-session", + retentionClock: oldRetentionClock, }; const duplicate: LongTermMemoryEntry = { ...entry("duplicate-max", "prefer deterministic consolidation accounting!!!", "feedback"), pendingOwnerSessionID: "new-session", }; - const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]); + const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate])); + const retained = result.kept.find(memory => memory.id === "existing-max"); + const refreshed = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "reinforced"); - assert.equal(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "reinforced"), false); - assert.ok(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "rejected" && event.details?.blockReason === "max_count")); + assert.equal(retained?.reinforcementCount, 6); + assert.equal(retained?.retentionClock, now); + assert.equal(retained?.lastReinforcedAt, now); + assert.equal(retained?.lastReinforcedSessionID, "new-session"); + assert.ok(refreshed, "saturated after-window attempt should emit refreshed evidence"); + assert.equal(refreshed.details?.reinforcementOutcome, "refreshed"); + assert.equal(refreshed.details?.reinforcementMode, "refresh_only"); + assert.equal(refreshed.details?.previousReinforcementCount, 6); + assert.equal(refreshed.details?.newReinforcementCount, 6); + assert.ok(refreshed.reasonCodes.includes("reinforcement_saturation_refresh")); + assert.equal(refreshed.reasonCodes.includes("reinforcement_block_max_count"), false); +}); + +test("dedupe reinforcement reports legacy missing timestamp and normalizes it", () => { + const now = Date.UTC(2026, 4, 15, 12, 0, 0); + const existing: LongTermMemoryEntry = { + ...entry("existing-legacy-missing", "Prefer deterministic consolidation accounting", "feedback"), + source: "manual", + reinforcementCount: 2, + lastReinforcedSessionID: "old-session", + }; + const duplicate: LongTermMemoryEntry = { + ...entry("duplicate-legacy-missing", "prefer deterministic consolidation accounting!!!", "feedback"), + pendingOwnerSessionID: "new-session", + }; + + const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate])); + const retained = result.kept.find(memory => memory.id === "existing-legacy-missing"); + const reinforced = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "reinforced"); + + assert.equal(retained?.reinforcementCount, 3); + assert.equal(retained?.lastReinforcedAt, now); + assert.equal(retained?.retentionClock, now); + assert.equal(reinforced?.details?.legacyMissingTimestamp, true); + assert.equal(reinforced?.details?.reinforcementMode, "increment"); }); test("enforceLongTermLimits orders entries by retention strength", () => {