mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a480b734b2 |
@@ -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/),
|
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).
|
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
|
## [1.6.3] - 2026-05-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,5 +1,39 @@
|
|||||||
# Release Notes
|
# 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)
|
## 1.6.3 (2026-05-14)
|
||||||
|
|
||||||
### Diagnostic Quality Review Board
|
### Diagnostic Quality Review Board
|
||||||
|
|||||||
@@ -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
|
### 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.
|
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
|
### Safety-Critical Deprecation
|
||||||
|
|
||||||
|
|||||||
+6
-4
@@ -38,8 +38,8 @@ Every diagnostic section must document:
|
|||||||
For `memory-diag quality`:
|
For `memory-diag quality`:
|
||||||
- `reinforcementRules`: `inventory_only` (cannot distinguish spam from legitimate blocks)
|
- `reinforcementRules`: `inventory_only` (cannot distinguish spam from legitimate blocks)
|
||||||
- `evictionAndCaps`: `inventory_only` (cannot distinguish healthy turnover from premature eviction)
|
- `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.
|
- 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 UTC-day grouping; they still require human content judgment.
|
- 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.
|
- 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
|
## Examples
|
||||||
@@ -78,9 +78,11 @@ npx --package opencode-working-memory memory-diag commands --verbose
|
|||||||
npx --package opencode-working-memory memory-diag commands --memory <memory-id>
|
npx --package opencode-working-memory memory-diag commands --memory <memory-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
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 <memory-id>` 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 <memory-id>` 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
|
## Dry-run Recovery
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "opencode-working-memory",
|
"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",
|
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ type MemoryCommandDetail = {
|
|||||||
reasonCodes: string[];
|
reasonCodes: string[];
|
||||||
attemptedAtIso?: string;
|
attemptedAtIso?: string;
|
||||||
lastReinforcedAtIso?: string;
|
lastReinforcedAtIso?: string;
|
||||||
|
elapsedMs?: number;
|
||||||
|
requiredElapsedMs?: number;
|
||||||
|
sameSession?: boolean;
|
||||||
|
legacyMissingTimestamp?: boolean;
|
||||||
|
reinforcementMode?: string;
|
||||||
crossUtcDay?: boolean | "unknown";
|
crossUtcDay?: boolean | "unknown";
|
||||||
producerVersion?: string;
|
producerVersion?: string;
|
||||||
instrumentationVersion?: number;
|
instrumentationVersion?: number;
|
||||||
@@ -116,6 +121,16 @@ function stringDetail(event: EvidenceEventV1, key: string): string | undefined {
|
|||||||
return typeof value === "string" && value.length > 0 ? value : 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 {
|
function isRejectedOrBlocked(event: EvidenceEventV1): boolean {
|
||||||
return event.outcome === "rejected" || hasReason(event, "reinforcement_window_blocked");
|
return event.outcome === "rejected" || hasReason(event, "reinforcement_window_blocked");
|
||||||
}
|
}
|
||||||
@@ -175,6 +190,11 @@ function detailEventJSON(event: EvidenceEventV1): MemoryCommandDetail["events"][
|
|||||||
reasonCodes: event.reasonCodes,
|
reasonCodes: event.reasonCodes,
|
||||||
attemptedAtIso,
|
attemptedAtIso,
|
||||||
lastReinforcedAtIso,
|
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,
|
crossUtcDay: blocked ? isCrossUtcDay(attemptedAtIso, lastReinforcedAtIso) : undefined,
|
||||||
producerVersion: event.producerVersion,
|
producerVersion: event.producerVersion,
|
||||||
instrumentationVersion: event.instrumentationVersion,
|
instrumentationVersion: event.instrumentationVersion,
|
||||||
@@ -315,15 +335,24 @@ function formatCrossUtcDay(value: boolean | "unknown" | undefined): string {
|
|||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBoolean(value: boolean): string {
|
||||||
|
return value ? "yes" : "no";
|
||||||
|
}
|
||||||
|
|
||||||
function formatMemoryCommandDetailEvents(events: MemoryCommandDetail["events"]): string[] {
|
function formatMemoryCommandDetailEvents(events: MemoryCommandDetail["events"]): string[] {
|
||||||
if (events.length === 0) return [" (none)"];
|
if (events.length === 0) return [" (none)"];
|
||||||
return events.map(event => {
|
return events.map(event => {
|
||||||
const ref = event.ref ? ` ref=${event.ref}` : "";
|
const ref = event.ref ? ` ref=${event.ref}` : "";
|
||||||
const blockReason = event.blockReason ? ` blockReason=${event.blockReason}` : "";
|
const blockReason = event.blockReason ? ` blockReason=${event.blockReason}` : "";
|
||||||
|
const reinforcementMode = event.reinforcementMode ? ` reinforcementMode=${event.reinforcementMode}` : "";
|
||||||
const attemptedAt = event.attemptedAtIso ? ` attemptedAt=${event.attemptedAtIso}` : "";
|
const attemptedAt = event.attemptedAtIso ? ` attemptedAt=${event.attemptedAtIso}` : "";
|
||||||
const lastReinforcedAt = event.lastReinforcedAtIso ? ` lastReinforcedAt=${event.lastReinforcedAtIso}` : "";
|
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)}` : "";
|
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"}`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -571,9 +571,9 @@ function applyInstrumentedAnswerability(
|
|||||||
report.reinforcementRules.currentSignals = uniqueStrings([
|
report.reinforcementRules.currentSignals = uniqueStrings([
|
||||||
...report.reinforcementRules.currentSignals,
|
...report.reinforcementRules.currentSignals,
|
||||||
"exact block reasons",
|
"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
|
const hasCapacitySnapshots = evictionFacts.recentCapacityRemovalsWithSnapshot > 0
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|||||||
let cachedVersion: string | undefined;
|
let cachedVersion: string | undefined;
|
||||||
|
|
||||||
const MEMORY_PRODUCER_NAME = "opencode-working-memory";
|
const MEMORY_PRODUCER_NAME = "opencode-working-memory";
|
||||||
const MEMORY_INSTRUMENTATION_VERSION = 2;
|
const MEMORY_INSTRUMENTATION_VERSION = 3;
|
||||||
|
|
||||||
function producerVersion(): string {
|
function producerVersion(): string {
|
||||||
if (cachedVersion) return cachedVersion;
|
if (cachedVersion) return cachedVersion;
|
||||||
|
|||||||
+27
-10
@@ -44,7 +44,7 @@ import {
|
|||||||
workspaceMemoryExactKey,
|
workspaceMemoryExactKey,
|
||||||
workspaceMemoryIdentityKey,
|
workspaceMemoryIdentityKey,
|
||||||
} from "./workspace-memory.ts";
|
} from "./workspace-memory.ts";
|
||||||
import { tryReinforceMemory } from "./retention.ts";
|
import { REINFORCEMENT_MAX_COUNT, tryReinforceMemory, type ReinforcementDecision } from "./retention.ts";
|
||||||
import {
|
import {
|
||||||
appendPendingMemories,
|
appendPendingMemories,
|
||||||
clearPendingMemories,
|
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 {
|
function replacementMemoryId(): string {
|
||||||
return `mem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
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}`], {
|
evidence.push(memoryReinforcedEvidence(target, command.ref, "rejected", ["numbered_ref_reinforce", "reinforcement_window_blocked", `reinforcement_block_${decision.blockReason}`], {
|
||||||
memoryId: refSnapshot.memoryId,
|
memoryId: refSnapshot.memoryId,
|
||||||
blockReason: decision.blockReason,
|
blockReason: decision.blockReason,
|
||||||
attemptedAtMs: now,
|
...reinforcementDecisionTimingDetails(decision),
|
||||||
attemptedAtIso: new Date(now).toISOString(),
|
|
||||||
...(decision.lastReinforcedAt ? {
|
|
||||||
lastReinforcedAtMs: decision.lastReinforcedAt,
|
|
||||||
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
|
|
||||||
} : {}),
|
|
||||||
reinforcementCount: decision.reinforcementCount,
|
reinforcementCount: decision.reinforcementCount,
|
||||||
maxReinforcementCount: decision.maxReinforcementCount,
|
maxReinforcementCount: decision.maxReinforcementCount,
|
||||||
minIntervalMs: decision.minIntervalMs,
|
|
||||||
}));
|
}));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reinforced = decision.memory;
|
const reinforced = decision.memory;
|
||||||
workspaceMemory.entries[targetIndex] = reinforced;
|
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,
|
memoryId: refSnapshot.memoryId,
|
||||||
reinforcementOutcome: "reinforced",
|
reinforcementOutcome: decision.reinforcementMode === "refresh_only" ? "refreshed" : "reinforced",
|
||||||
|
reinforcementMode: decision.reinforcementMode,
|
||||||
|
...reinforcementDecisionTimingDetails(decision),
|
||||||
previousReinforcementCount: decision.previousReinforcementCount,
|
previousReinforcementCount: decision.previousReinforcementCount,
|
||||||
newReinforcementCount: decision.newReinforcementCount,
|
newReinforcementCount: decision.newReinforcementCount,
|
||||||
|
reinforcementCount: decision.newReinforcementCount,
|
||||||
|
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
|
||||||
}));
|
}));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-33
@@ -1,19 +1,52 @@
|
|||||||
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
|
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 =
|
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)
|
// Retention decay model constants (v1.5)
|
||||||
export const BASE_HALF_LIFE_DAYS = 45;
|
export const BASE_HALF_LIFE_DAYS = 45;
|
||||||
export const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
|
export const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
|
||||||
export const REINFORCEMENT_MAX_COUNT = 6;
|
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 WORKSPACE_DORMANT_AFTER_DAYS = 14;
|
||||||
export const DORMANT_DECAY_MULTIPLIER = 0.25;
|
export const DORMANT_DECAY_MULTIPLIER = 0.25;
|
||||||
export const DAY_MS = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
export const TYPE_FACTOR = {
|
export const TYPE_FACTOR = {
|
||||||
reference: 1.0,
|
reference: 1.0,
|
||||||
@@ -114,42 +147,38 @@ export function calculateEffectiveAgeDays(
|
|||||||
return activeDays + dormantOverlapDays * DORMANT_DECAY_MULTIPLIER;
|
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(
|
export function tryReinforceMemory(
|
||||||
memory: LongTermMemoryEntry,
|
memory: LongTermMemoryEntry,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
now: number,
|
now: number,
|
||||||
): ReinforcementDecision {
|
): ReinforcementDecision {
|
||||||
const count = memory.reinforcementCount ?? 0;
|
const count = memory.reinforcementCount ?? 0;
|
||||||
const lastAt = memory.lastReinforcedAt ?? 0;
|
const lastAt = validLastReinforcedAt(memory.lastReinforcedAt);
|
||||||
const lastSession = memory.lastReinforcedSessionID;
|
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) {
|
if (lastAt !== undefined && now - lastAt < REINFORCEMENT_MIN_ELAPSED_MS) {
|
||||||
return blockedDecision(memory, "same_session", count, lastAt);
|
return blockedDecision(memory, "min_elapsed_window", count, metadata);
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reinforcementMode: ReinforcementMode = count >= REINFORCEMENT_MAX_COUNT
|
||||||
|
? "refresh_only"
|
||||||
|
: "increment";
|
||||||
|
const newReinforcementCount = reinforcementMode === "refresh_only" ? count : count + 1;
|
||||||
const reinforced: LongTermMemoryEntry = {
|
const reinforced: LongTermMemoryEntry = {
|
||||||
...memory,
|
...memory,
|
||||||
reinforcementCount: count + 1,
|
reinforcementCount: newReinforcementCount,
|
||||||
lastReinforcedAt: now,
|
lastReinforcedAt: now,
|
||||||
lastReinforcedSessionID: sessionId,
|
lastReinforcedSessionID: sessionId,
|
||||||
retentionClock: now,
|
retentionClock: now,
|
||||||
@@ -158,23 +187,29 @@ export function tryReinforceMemory(
|
|||||||
outcome: "reinforced",
|
outcome: "reinforced",
|
||||||
memory: reinforced,
|
memory: reinforced,
|
||||||
previousReinforcementCount: count,
|
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(
|
function blockedDecision(
|
||||||
memory: LongTermMemoryEntry,
|
memory: LongTermMemoryEntry,
|
||||||
blockReason: ReinforcementBlockReason,
|
blockReason: ReinforcementBlockReason,
|
||||||
reinforcementCount: number,
|
reinforcementCount: number,
|
||||||
lastReinforcedAt: number,
|
metadata: ReinforcementDecisionMetadata,
|
||||||
): ReinforcementDecision {
|
): ReinforcementDecision {
|
||||||
return {
|
return {
|
||||||
outcome: "blocked",
|
outcome: "blocked",
|
||||||
memory,
|
memory,
|
||||||
blockReason,
|
blockReason,
|
||||||
...(lastReinforcedAt > 0 ? { lastReinforcedAt } : {}),
|
|
||||||
reinforcementCount,
|
reinforcementCount,
|
||||||
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
|
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 { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts";
|
||||||
import { redactCredentials } from "./redaction.ts";
|
import { redactCredentials } from "./redaction.ts";
|
||||||
import {
|
import {
|
||||||
|
REINFORCEMENT_MAX_COUNT,
|
||||||
RETENTION_TYPE_MAX,
|
RETENTION_TYPE_MAX,
|
||||||
calculateRetentionStrength,
|
calculateRetentionStrength,
|
||||||
tryReinforceMemory,
|
tryReinforceMemory,
|
||||||
@@ -768,7 +769,7 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
|
|||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
const reinforced = decision.memory;
|
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 (reinforcedEvent) evidence.push(reinforcedEvent);
|
||||||
|
|
||||||
absorbed.push(consolidationEvent(dropped, reason, reinforced));
|
absorbed.push(consolidationEvent(dropped, reason, reinforced));
|
||||||
@@ -797,7 +798,7 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
|
|||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
const reinforced = decision.memory;
|
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 (reinforcedEvent) evidence.push(reinforcedEvent);
|
||||||
|
|
||||||
if (reason === "superseded_existing") {
|
if (reason === "superseded_existing") {
|
||||||
@@ -841,7 +842,6 @@ function reinforcementEvidence(
|
|||||||
dropped: LongTermMemoryEntry,
|
dropped: LongTermMemoryEntry,
|
||||||
decision: ReinforcementDecision,
|
decision: ReinforcementDecision,
|
||||||
reason: "absorbed_exact" | "absorbed_identity" | "superseded_existing",
|
reason: "absorbed_exact" | "absorbed_identity" | "superseded_existing",
|
||||||
attemptedAt: number,
|
|
||||||
): EvidenceEventInput | undefined {
|
): EvidenceEventInput | undefined {
|
||||||
const duplicateReason = reason === "absorbed_identity" ? "duplicate_identity" : "duplicate_exact";
|
const duplicateReason = reason === "absorbed_identity" ? "duplicate_identity" : "duplicate_exact";
|
||||||
if (decision.outcome === "blocked") {
|
if (decision.outcome === "blocked") {
|
||||||
@@ -859,21 +859,19 @@ function reinforcementEvidence(
|
|||||||
memoryId: retained.id,
|
memoryId: retained.id,
|
||||||
droppedMemoryId: dropped.id,
|
droppedMemoryId: dropped.id,
|
||||||
blockReason: decision.blockReason,
|
blockReason: decision.blockReason,
|
||||||
attemptedAtMs: attemptedAt,
|
...reinforcementDecisionTimingDetails(decision),
|
||||||
attemptedAtIso: new Date(attemptedAt).toISOString(),
|
|
||||||
...(decision.lastReinforcedAt ? {
|
|
||||||
lastReinforcedAtMs: decision.lastReinforcedAt,
|
|
||||||
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
|
|
||||||
} : {}),
|
|
||||||
reinforcementCount: decision.reinforcementCount,
|
reinforcementCount: decision.reinforcementCount,
|
||||||
maxReinforcementCount: decision.maxReinforcementCount,
|
maxReinforcementCount: decision.maxReinforcementCount,
|
||||||
minIntervalMs: decision.minIntervalMs,
|
|
||||||
},
|
},
|
||||||
textPreview: retained.text,
|
textPreview: retained.text,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const reinforced = decision.memory;
|
const reinforced = decision.memory;
|
||||||
|
const reasonCodes = [duplicateReason, "reinforcement_window_allowed"];
|
||||||
|
if (decision.reinforcementMode === "refresh_only") {
|
||||||
|
reasonCodes.push("reinforcement_saturation_refresh");
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
type: "memory_reinforced",
|
type: "memory_reinforced",
|
||||||
phase: "reinforcement",
|
phase: "reinforcement",
|
||||||
@@ -883,18 +881,37 @@ function reinforcementEvidence(
|
|||||||
{ role: "reinforced", memory: memoryEvidenceRef(reinforced) },
|
{ role: "reinforced", memory: memoryEvidenceRef(reinforced) },
|
||||||
{ role: "reinforced_by", memory: memoryEvidenceRef(dropped) },
|
{ role: "reinforced_by", memory: memoryEvidenceRef(dropped) },
|
||||||
],
|
],
|
||||||
reasonCodes: [duplicateReason, "reinforcement_window_allowed"],
|
reasonCodes,
|
||||||
details: {
|
details: {
|
||||||
memoryId: reinforced.id,
|
memoryId: reinforced.id,
|
||||||
droppedMemoryId: dropped.id,
|
droppedMemoryId: dropped.id,
|
||||||
reinforcementOutcome: "reinforced",
|
reinforcementOutcome: decision.reinforcementMode === "refresh_only" ? "refreshed" : "reinforced",
|
||||||
|
reinforcementMode: decision.reinforcementMode,
|
||||||
|
...reinforcementDecisionTimingDetails(decision),
|
||||||
previousReinforcementCount: decision.previousReinforcementCount,
|
previousReinforcementCount: decision.previousReinforcementCount,
|
||||||
newReinforcementCount: decision.newReinforcementCount,
|
newReinforcementCount: decision.newReinforcementCount,
|
||||||
|
reinforcementCount: decision.newReinforcementCount,
|
||||||
|
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
|
||||||
},
|
},
|
||||||
textPreview: reinforced.text,
|
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 {
|
function reinforcementSessionId(retained: LongTermMemoryEntry, dropped: LongTermMemoryEntry): string {
|
||||||
return dropped.pendingOwnerSessionID ?? retained.pendingOwnerSessionID ?? "workspace-dedupe";
|
return dropped.pendingOwnerSessionID ?? retained.pendingOwnerSessionID ?? "workspace-dedupe";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.producerName, "opencode-working-memory");
|
||||||
assert.equal(stored.producerVersion, packageJson.version);
|
assert.equal(stored.producerVersion, packageJson.version);
|
||||||
assert.equal(stored.instrumentationVersion, 2);
|
assert.equal(stored.instrumentationVersion, 3);
|
||||||
|
|
||||||
const historical = event("evt-historical-no-producer", {
|
const historical = event("evt-historical-no-producer", {
|
||||||
type: "render_selected",
|
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);
|
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", () => {
|
test("versioned quality warns when current reinforcement sample is small", () => {
|
||||||
const events = [
|
const events = [
|
||||||
reinforcementAttempt("evt-prev-small-block", "1.6.0", true),
|
reinforcementAttempt("evt-prev-small-block", "1.6.0", true),
|
||||||
|
|||||||
+118
-9
@@ -181,6 +181,85 @@ async function setupMemoryCommandDetailFixture(root: string): Promise<void> {
|
|||||||
sessionHash: "command-session-two",
|
sessionHash: "command-session-two",
|
||||||
messageHash: "command-message-three",
|
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({
|
evidence({
|
||||||
type: "render_selected",
|
type: "render_selected",
|
||||||
phase: "render",
|
phase: "render",
|
||||||
@@ -482,16 +561,26 @@ test("memory-diag commands memory selector prints reinforcement detail", async (
|
|||||||
assert.match(stdout, /status: active/);
|
assert.match(stdout, /status: active/);
|
||||||
assert.match(stdout, /render: rendered/);
|
assert.match(stdout, /render: rendered/);
|
||||||
assert.match(stdout, /Reinforcement summary:/);
|
assert.match(stdout, /Reinforcement summary:/);
|
||||||
assert.match(stdout, /attempts: 3/);
|
assert.match(stdout, /attempts: 7/);
|
||||||
assert.match(stdout, /reinforced: 1/);
|
assert.match(stdout, /reinforced: 4/);
|
||||||
assert.match(stdout, /rejected\/blocked: 2/);
|
assert.match(stdout, /rejected\/blocked: 3/);
|
||||||
assert.match(stdout, /window blocked: 2/);
|
assert.match(stdout, /window blocked: 3/);
|
||||||
assert.match(stdout, /block reasons: same_session=1, unknown=1/);
|
assert.match(stdout, /block reasons: min_elapsed_window=1, same_session=1, unknown=1/);
|
||||||
assert.match(stdout, /block details missing: 1/);
|
assert.match(stdout, /block details missing: 1/);
|
||||||
assert.match(stdout, /same-session cross UTC day blocks: 1/);
|
assert.match(stdout, /same-session cross UTC day blocks: 1/);
|
||||||
assert.match(stdout, /refs: M3/);
|
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.match(stdout, /crossUtcDay=yes/);
|
||||||
assert.doesNotMatch(stdout, /render_selected/);
|
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);
|
assertNoAttributionSafetyTerms(stdout);
|
||||||
} finally {
|
} finally {
|
||||||
await rm(root, { recursive: true, force: true });
|
await rm(root, { recursive: true, force: true });
|
||||||
@@ -516,7 +605,18 @@ test("memory-diag commands memory selector emits stable JSON", async () => {
|
|||||||
blockDetailsMissing: number;
|
blockDetailsMissing: number;
|
||||||
sameSessionCrossUtcDayBlocks: 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);
|
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.present, true);
|
||||||
assert.equal(parsed.current.status, "active");
|
assert.equal(parsed.current.status, "active");
|
||||||
assert.equal(parsed.current.renderStatus, "rendered");
|
assert.equal(parsed.current.renderStatus, "rendered");
|
||||||
assert.equal(parsed.summary.attempts, 3);
|
assert.equal(parsed.summary.attempts, 7);
|
||||||
assert.equal(parsed.summary.reinforced, 1);
|
assert.equal(parsed.summary.reinforced, 4);
|
||||||
assert.equal(parsed.summary.rejectedOrBlocked, 2);
|
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.same_session, 1);
|
||||||
assert.equal(parsed.summary.blocksByReason.unknown, 1);
|
assert.equal(parsed.summary.blocksByReason.unknown, 1);
|
||||||
assert.equal(parsed.summary.blockDetailsMissing, 1);
|
assert.equal(parsed.summary.blockDetailsMissing, 1);
|
||||||
assert.equal(parsed.summary.sameSessionCrossUtcDayBlocks, 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 === "same_session" && event.crossUtcDay === true), true);
|
||||||
assert.equal(parsed.events.some(event => event.blockReason === "unknown" && event.crossUtcDay === "unknown"), 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-session"), false);
|
||||||
assert.equal(JSON.stringify(parsed).includes("command-message"), 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("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);
|
assertNoAttributionSafetyTerms(stdout);
|
||||||
} finally {
|
} finally {
|
||||||
await rm(root, { recursive: true, force: true });
|
await rm(root, { recursive: true, force: true });
|
||||||
|
|||||||
+240
-15
@@ -100,6 +100,60 @@ function createSessionWithError(sessionID: string, error: OpenError) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function withMockedDateNow<T>(now: number, fn: () => Promise<T>): Promise<T> {
|
||||||
|
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<ReturnType<typeof loadWorkspaceMemory>>;
|
||||||
|
events: Awaited<ReturnType<typeof queryEvidenceEvents>>;
|
||||||
|
}) => Promise<void> | void,
|
||||||
|
): Promise<void> {
|
||||||
|
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<string, Function>)["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 () => {
|
test("tool.execute.after: undefined exitCode does NOT create open error", async () => {
|
||||||
// 1. Temp directory for isolated file I/O
|
// 1. Temp directory for isolated file I/O
|
||||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
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-"));
|
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const now = new Date().toISOString();
|
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
|
||||||
const oldRetentionClock = Date.now() - 10 * 24 * 60 * 60 * 1000;
|
const now = new Date(nowMs).toISOString();
|
||||||
|
const lastReinforcedAt = nowMs - 8 * 24 * 60 * 60 * 1000;
|
||||||
const existing: LongTermMemoryEntry = {
|
const existing: LongTermMemoryEntry = {
|
||||||
id: "numbered-reinforce-memory",
|
id: "numbered-reinforce-memory",
|
||||||
type: "decision",
|
type: "decision",
|
||||||
@@ -1043,7 +1098,10 @@ test("session.compacted applies numbered REINFORCE command to referenced memory"
|
|||||||
status: "active",
|
status: "active",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
retentionClock: oldRetentionClock,
|
retentionClock: lastReinforcedAt,
|
||||||
|
reinforcementCount: 1,
|
||||||
|
lastReinforcedAt,
|
||||||
|
lastReinforcedSessionID: "numbered-reinforce-session",
|
||||||
};
|
};
|
||||||
await updateWorkspaceMemory(tmpDir, store => {
|
await updateWorkspaceMemory(tmpDir, store => {
|
||||||
store.entries.push(existing);
|
store.entries.push(existing);
|
||||||
@@ -1065,28 +1123,174 @@ test("session.compacted applies numbered REINFORCE command to referenced memory"
|
|||||||
directory: tmpDir,
|
directory: tmpDir,
|
||||||
client: mockClientWithCompactionSummary("Memory candidates:\nREINFORCE [M1]"),
|
client: mockClientWithCompactionSummary("Memory candidates:\nREINFORCE [M1]"),
|
||||||
});
|
});
|
||||||
await (plugin as Record<string, Function>)["event"]({
|
await withMockedDateNow(nowMs, async () => {
|
||||||
event: { type: "session.compacted", properties: { sessionID: "numbered-reinforce-session" } },
|
await (plugin as Record<string, Function>)["event"]({
|
||||||
|
event: { type: "session.compacted", properties: { sessionID: "numbered-reinforce-session" } },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const workspace = await loadWorkspaceMemory(tmpDir);
|
const workspace = await loadWorkspaceMemory(tmpDir);
|
||||||
const reinforced = workspace.entries.find(entry => entry.id === existing.id);
|
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.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"] });
|
const events = await queryEvidenceEvents(tmpDir, { types: ["memory_reinforced"] });
|
||||||
assert.ok(events.some(event =>
|
const event = events.find(event =>
|
||||||
event.outcome === "reinforced" &&
|
event.outcome === "reinforced" &&
|
||||||
event.reasonCodes.includes("numbered_ref_reinforce") &&
|
event.reasonCodes.includes("numbered_ref_reinforce") &&
|
||||||
event.reasonCodes.includes("reinforcement_window_allowed") &&
|
event.reasonCodes.includes("reinforcement_window_allowed") &&
|
||||||
event.memory?.memoryId === existing.id
|
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 {
|
} finally {
|
||||||
await rm(tmpDir, { recursive: true, force: true });
|
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 () => {
|
test("session.compacted rejects invalid unavailable and stale REINFORCE refs without mutation", async () => {
|
||||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
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-"));
|
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
|
||||||
const now = new Date(nowMs).toISOString();
|
const now = new Date(nowMs).toISOString();
|
||||||
|
const lastReinforcedAt = nowMs - (7 * 24 * 60 * 60 * 1000 - 60 * 60 * 1000);
|
||||||
const existing: LongTermMemoryEntry = {
|
const existing: LongTermMemoryEntry = {
|
||||||
id: "blocked-numbered-reinforce",
|
id: "blocked-numbered-reinforce",
|
||||||
type: "feedback",
|
type: "feedback",
|
||||||
@@ -1176,7 +1381,7 @@ test("session.compacted emits rejected evidence when numbered REINFORCE is block
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
retentionClock: nowMs - 10 * 24 * 60 * 60 * 1000,
|
retentionClock: nowMs - 10 * 24 * 60 * 60 * 1000,
|
||||||
reinforcementCount: 1,
|
reinforcementCount: 1,
|
||||||
lastReinforcedAt: nowMs - 2 * 60 * 60 * 1000,
|
lastReinforcedAt,
|
||||||
lastReinforcedSessionID: "blocked-reinforce-session",
|
lastReinforcedSessionID: "blocked-reinforce-session",
|
||||||
};
|
};
|
||||||
await updateWorkspaceMemory(tmpDir, store => {
|
await updateWorkspaceMemory(tmpDir, store => {
|
||||||
@@ -1199,8 +1404,10 @@ test("session.compacted emits rejected evidence when numbered REINFORCE is block
|
|||||||
directory: tmpDir,
|
directory: tmpDir,
|
||||||
client: mockClientWithCompactionSummary("Memory candidates:\nREINFORCE [M1]"),
|
client: mockClientWithCompactionSummary("Memory candidates:\nREINFORCE [M1]"),
|
||||||
});
|
});
|
||||||
await (plugin as Record<string, Function>)["event"]({
|
await withMockedDateNow(nowMs, async () => {
|
||||||
event: { type: "session.compacted", properties: { sessionID: "blocked-reinforce-session" } },
|
await (plugin as Record<string, Function>)["event"]({
|
||||||
|
event: { type: "session.compacted", properties: { sessionID: "blocked-reinforce-session" } },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const workspace = await loadWorkspaceMemory(tmpDir);
|
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);
|
assert.equal(blocked?.reinforcementCount, 1);
|
||||||
|
|
||||||
const events = await queryEvidenceEvents(tmpDir, { types: ["memory_reinforced"] });
|
const events = await queryEvidenceEvents(tmpDir, { types: ["memory_reinforced"] });
|
||||||
assert.ok(events.some(event =>
|
const event = events.find(event =>
|
||||||
event.outcome === "rejected" &&
|
event.outcome === "rejected" &&
|
||||||
event.reasonCodes.includes("reinforcement_window_blocked") &&
|
event.reasonCodes.includes("reinforcement_window_blocked") &&
|
||||||
|
event.reasonCodes.includes("reinforcement_block_min_elapsed_window") &&
|
||||||
event.memory?.memoryId === existing.id &&
|
event.memory?.memoryId === existing.id &&
|
||||||
event.relations?.some(relation => relation.role === "target" && relation.memory?.memoryId === existing.id) &&
|
event.relations?.some(relation => relation.role === "target" && relation.memory?.memoryId === existing.id) &&
|
||||||
!event.relations?.some(relation => relation.role === "reinforced")
|
!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 {
|
} finally {
|
||||||
await rm(tmpDir, { recursive: true, force: true });
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
+145
-37
@@ -1,12 +1,15 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import {
|
import {
|
||||||
|
BASE_HALF_LIFE_DAYS,
|
||||||
REINFORCEMENT_MAX_COUNT,
|
REINFORCEMENT_MAX_COUNT,
|
||||||
REINFORCEMENT_MIN_INTERVAL_MS,
|
|
||||||
tryReinforceMemory,
|
tryReinforceMemory,
|
||||||
} from "../src/retention.ts";
|
} from "../src/retention.ts";
|
||||||
import type { LongTermMemoryEntry } from "../src/types.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> = {}): LongTermMemoryEntry => ({
|
const baseMemory = (overrides: Partial<LongTermMemoryEntry> = {}): LongTermMemoryEntry => ({
|
||||||
id: "mem-retention",
|
id: "mem-retention",
|
||||||
type: "decision",
|
type: "decision",
|
||||||
@@ -19,40 +22,77 @@ const baseMemory = (overrides: Partial<LongTermMemoryEntry> = {}): LongTermMemor
|
|||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
test("tryReinforceMemory blocks same session with exact reason", () => {
|
test("tryReinforceMemory allows same session after rolling 8 days", () => {
|
||||||
const now = Date.UTC(2026, 4, 12, 12, 0, 0);
|
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
|
||||||
|
const now = lastAt + 8 * DAY_MS;
|
||||||
const memory = baseMemory({
|
const memory = baseMemory({
|
||||||
reinforcementCount: 1,
|
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",
|
lastReinforcedSessionID: "session-a",
|
||||||
});
|
});
|
||||||
|
|
||||||
const decision = tryReinforceMemory(memory, "session-a", now);
|
const decision = tryReinforceMemory(memory, "session-a", now);
|
||||||
|
|
||||||
assert.equal(decision.outcome, "blocked");
|
assert.equal(decision.outcome, "blocked");
|
||||||
assert.equal(decision.blockReason, "same_session");
|
assert.equal(decision.blockReason, "min_elapsed_window");
|
||||||
assert.equal(decision.memory, memory);
|
assert.equal(decision.memory, memory);
|
||||||
});
|
assert.equal(decision.sameSession, true);
|
||||||
|
|
||||||
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.lastReinforcedAt, lastAt);
|
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", () => {
|
test("tryReinforceMemory blocks different session below rolling window", () => {
|
||||||
const lastAt = Date.UTC(2026, 4, 12, 23, 45, 0);
|
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
|
||||||
const now = Date.UTC(2026, 4, 13, 0, 15, 0);
|
const now = lastAt + 3 * DAY_MS;
|
||||||
const memory = baseMemory({
|
const memory = baseMemory({
|
||||||
reinforcementCount: 1,
|
reinforcementCount: 1,
|
||||||
lastReinforcedAt: lastAt,
|
lastReinforcedAt: lastAt,
|
||||||
@@ -62,42 +102,110 @@ test("tryReinforceMemory blocks min interval across UTC day boundary", () => {
|
|||||||
const decision = tryReinforceMemory(memory, "session-b", now);
|
const decision = tryReinforceMemory(memory, "session-b", now);
|
||||||
|
|
||||||
assert.equal(decision.outcome, "blocked");
|
assert.equal(decision.outcome, "blocked");
|
||||||
assert.equal(decision.blockReason, "min_interval");
|
assert.equal(decision.blockReason, "min_elapsed_window");
|
||||||
assert.equal(decision.minIntervalMs, REINFORCEMENT_MIN_INTERVAL_MS);
|
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", () => {
|
test("tryReinforceMemory blocks UTC midnight crossing below rolling window", () => {
|
||||||
const now = Date.UTC(2026, 4, 12, 12, 0, 0);
|
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({
|
const memory = baseMemory({
|
||||||
reinforcementCount: REINFORCEMENT_MAX_COUNT,
|
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",
|
lastReinforcedSessionID: "session-a",
|
||||||
});
|
});
|
||||||
|
|
||||||
const decision = tryReinforceMemory(memory, "session-b", now);
|
const decision = tryReinforceMemory(memory, "session-b", now);
|
||||||
|
|
||||||
assert.equal(decision.outcome, "blocked");
|
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.reinforcementCount, REINFORCEMENT_MAX_COUNT);
|
||||||
assert.equal(decision.maxReinforcementCount, 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 now = Date.UTC(2026, 4, 12, 12, 0, 0);
|
||||||
const memory = baseMemory({
|
const memory = baseMemory({
|
||||||
reinforcementCount: 1,
|
reinforcementCount: 2,
|
||||||
lastReinforcedAt: Date.UTC(2026, 4, 10, 12, 0, 0),
|
|
||||||
lastReinforcedSessionID: "session-a",
|
lastReinforcedSessionID: "session-a",
|
||||||
});
|
});
|
||||||
|
|
||||||
const decision = tryReinforceMemory(memory, "session-b", now);
|
const decision = tryReinforceMemory(memory, "session-b", now);
|
||||||
|
|
||||||
assert.equal(decision.outcome, "reinforced");
|
assert.equal(decision.outcome, "reinforced");
|
||||||
assert.equal(decision.previousReinforcementCount, 1);
|
assert.equal(decision.reinforcementMode, "increment");
|
||||||
assert.equal(decision.newReinforcementCount, 2);
|
assert.equal(decision.legacyMissingTimestamp, true);
|
||||||
assert.notEqual(decision.memory, memory);
|
assert.equal(decision.memory.reinforcementCount, 3);
|
||||||
assert.equal(decision.memory.reinforcementCount, 2);
|
|
||||||
assert.equal(decision.memory.lastReinforcedAt, now);
|
assert.equal(decision.memory.lastReinforcedAt, now);
|
||||||
assert.equal(decision.memory.lastReinforcedSessionID, "session-b");
|
|
||||||
assert.equal(decision.memory.retentionClock, now);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
+149
-41
@@ -72,6 +72,16 @@ function sleep(ms: number): Promise<void> {
|
|||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withMockedDateNow<T>(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) */
|
/** Create an entry with a createdAt offset from now (negative = in the past) */
|
||||||
function agedEntry(
|
function agedEntry(
|
||||||
id: string,
|
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"]);
|
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 now = Date.UTC(2026, 3, 29);
|
||||||
const base = entry("reinforce", "Durable memory should reinforce only when gated");
|
const base = entry("reinforce", "Durable memory should reinforce only when gated");
|
||||||
const reinforced = tryReinforceMemory(base, "session-a", now).memory;
|
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-a", now + 2 * 60 * 60 * 1000).memory, reinforced);
|
||||||
assert.equal(tryReinforceMemory(reinforced, "session-b", now + 30 * 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 = {
|
const atMax: LongTermMemoryEntry = {
|
||||||
...base,
|
...base,
|
||||||
reinforcementCount: 6,
|
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 firstReinforcedAt = Date.UTC(2026, 3, 29, 0, 15);
|
||||||
const sameUtcDayMuchLater = Date.UTC(2026, 3, 29, 23, 30);
|
const sameUtcDayMuchLater = Date.UTC(2026, 3, 29, 23, 30);
|
||||||
const nextUtcDayAfterInterval = Date.UTC(2026, 3, 30, 1, 30);
|
const nextUtcDayAfterInterval = Date.UTC(2026, 3, 30, 1, 30);
|
||||||
|
const afterWindow = firstReinforcedAt + 7 * DAY_MS;
|
||||||
const base: LongTermMemoryEntry = {
|
const base: LongTermMemoryEntry = {
|
||||||
...entry("calendar-day-gated", "Reinforcement requires distinct UTC calendar days", "decision"),
|
...entry("calendar-day-gated", "Reinforcement requires distinct UTC calendar days", "decision"),
|
||||||
reinforcementCount: 1,
|
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", sameUtcDayMuchLater).memory, base);
|
||||||
|
assert.equal(tryReinforceMemory(base, "session-b", nextUtcDayAfterInterval).memory, base);
|
||||||
|
|
||||||
const reinforcedNextDay = tryReinforceMemory(base, "session-b", nextUtcDayAfterInterval).memory;
|
const reinforcedAfterWindow = tryReinforceMemory(base, "session-b", afterWindow).memory;
|
||||||
assert.notEqual(reinforcedNextDay, base);
|
assert.notEqual(reinforcedAfterWindow, base);
|
||||||
assert.equal(reinforcedNextDay.reinforcementCount, 2);
|
assert.equal(reinforcedAfterWindow.reinforcementCount, 2);
|
||||||
assert.equal(reinforcedNextDay.lastReinforcedAt, nextUtcDayAfterInterval);
|
assert.equal(reinforcedAfterWindow.lastReinforcedAt, afterWindow);
|
||||||
assert.equal(reinforcedNextDay.lastReinforcedSessionID, "session-b");
|
assert.equal(reinforcedAfterWindow.lastReinforcedSessionID, "session-b");
|
||||||
assert.equal(reinforcedNextDay.retentionClock, nextUtcDayAfterInterval);
|
assert.equal(reinforcedAfterWindow.retentionClock, afterWindow);
|
||||||
|
|
||||||
const atMax: LongTermMemoryEntry = {
|
const atMax: LongTermMemoryEntry = {
|
||||||
...base,
|
...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"]);
|
assert.deepEqual(kept.map(memory => memory.id), ["reinforced", "unreinforced"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("dedupe reinforcement does not increment for same session", () => {
|
test("dedupe reinforcement allows same session after rolling 8 days with increment evidence", () => {
|
||||||
const now = Date.now();
|
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
|
||||||
const existing: LongTermMemoryEntry = {
|
const existing: LongTermMemoryEntry = {
|
||||||
...entry("existing", "Use pnpm for package management", "decision"),
|
...entry("existing", "Use pnpm for package management", "decision"),
|
||||||
source: "manual",
|
source: "manual",
|
||||||
pendingOwnerSessionID: "same-session",
|
pendingOwnerSessionID: "same-session",
|
||||||
reinforcementCount: 1,
|
reinforcementCount: 1,
|
||||||
lastReinforcedAt: now - 2 * 60 * 60 * 1000,
|
lastReinforcedAt: now - 8 * DAY_MS,
|
||||||
lastReinforcedSessionID: "same-session",
|
lastReinforcedSessionID: "same-session",
|
||||||
|
retentionClock: now - 8 * DAY_MS,
|
||||||
};
|
};
|
||||||
const duplicate: LongTermMemoryEntry = {
|
const duplicate: LongTermMemoryEntry = {
|
||||||
...entry("duplicate", "use pnpm for package management!!!", "decision"),
|
...entry("duplicate", "use pnpm for package management!!!", "decision"),
|
||||||
pendingOwnerSessionID: "same-session",
|
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 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.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(retained.lastReinforcedSessionID, "same-session");
|
||||||
assert.equal(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "reinforced"), false);
|
assert.equal(retained.retentionClock, now);
|
||||||
assert.ok(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "rejected" && event.details?.blockReason === "same_session"));
|
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", () => {
|
test("dedupe reinforcement allows exactly 7 rolling days", () => {
|
||||||
const now = Date.now();
|
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
|
||||||
const existing: LongTermMemoryEntry = {
|
const existing: LongTermMemoryEntry = {
|
||||||
...entry("existing-blocked", "Prefer deterministic consolidation accounting", "feedback"),
|
...entry("existing-exact-window", "Prefer deterministic consolidation accounting", "feedback"),
|
||||||
source: "manual",
|
source: "manual",
|
||||||
reinforcementCount: 1,
|
reinforcementCount: 5,
|
||||||
lastReinforcedAt: now - 30 * 60 * 1000,
|
lastReinforcedAt: now - 7 * DAY_MS,
|
||||||
lastReinforcedSessionID: "old-session",
|
lastReinforcedSessionID: "old-session",
|
||||||
};
|
};
|
||||||
const duplicate: LongTermMemoryEntry = {
|
const duplicate: LongTermMemoryEntry = {
|
||||||
...entry("duplicate-blocked", "prefer deterministic consolidation accounting!!!", "feedback"),
|
...entry("duplicate-exact-window", "prefer deterministic consolidation accounting!!!", "feedback"),
|
||||||
pendingOwnerSessionID: "new-session",
|
pendingOwnerSessionID: "new-session",
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
|
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
|
||||||
const blocked = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "rejected");
|
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.equal(retained?.reinforcementCount, 6);
|
||||||
assert.ok(blocked.reasonCodes.includes("reinforcement_window_blocked"));
|
assert.ok(reinforced, "exact 7-day window should emit reinforced evidence");
|
||||||
assert.ok(blocked.reasonCodes.includes("reinforcement_block_min_interval"));
|
assert.equal(reinforced.details?.reinforcementMode, "increment");
|
||||||
assert.equal(blocked.details?.blockReason, "min_interval");
|
assert.equal(reinforced.details?.elapsedMs, 7 * DAY_MS);
|
||||||
assert.equal(blocked.details?.reinforcementCount, 1);
|
assert.equal(reinforced.details?.requiredElapsedMs, 7 * DAY_MS);
|
||||||
assert.equal(blocked.details?.maxReinforcementCount, 6);
|
|
||||||
assert.equal(blocked.details?.minIntervalMs, 60 * 60 * 1000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("dedupe reinforcement does not increment under one hour", () => {
|
test("dedupe reinforcement blocks 7 rolling days minus 1ms with elapsed details", () => {
|
||||||
const now = Date.now();
|
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
|
||||||
const existing: LongTermMemoryEntry = {
|
const existing: LongTermMemoryEntry = {
|
||||||
...entry("existing", "Prefer deterministic consolidation accounting", "feedback"),
|
...entry("existing", "Prefer deterministic consolidation accounting", "feedback"),
|
||||||
source: "manual",
|
source: "manual",
|
||||||
reinforcementCount: 1,
|
reinforcementCount: 1,
|
||||||
lastReinforcedAt: now - 30 * 60 * 1000,
|
lastReinforcedAt: now - 7 * DAY_MS + 1,
|
||||||
lastReinforcedSessionID: "old-session",
|
lastReinforcedSessionID: "old-session",
|
||||||
};
|
};
|
||||||
const duplicate: LongTermMemoryEntry = {
|
const duplicate: LongTermMemoryEntry = {
|
||||||
@@ -781,31 +811,109 @@ test("dedupe reinforcement does not increment under one hour", () => {
|
|||||||
pendingOwnerSessionID: "new-session",
|
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 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.ok(retained, "existing manual memory should be retained");
|
||||||
assert.equal(retained.reinforcementCount, 1);
|
assert.equal(retained.reinforcementCount, 1);
|
||||||
assert.equal(retained.lastReinforcedSessionID, "old-session");
|
assert.equal(retained.lastReinforcedSessionID, "old-session");
|
||||||
assert.equal(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "reinforced"), false);
|
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 = {
|
const existing: LongTermMemoryEntry = {
|
||||||
...entry("existing-max", "Prefer deterministic consolidation accounting", "feedback"),
|
...entry("existing-max", "Prefer deterministic consolidation accounting", "feedback"),
|
||||||
source: "manual",
|
source: "manual",
|
||||||
reinforcementCount: 6,
|
reinforcementCount: 6,
|
||||||
|
lastReinforcedAt: now - 7 * DAY_MS,
|
||||||
|
lastReinforcedSessionID: "old-session",
|
||||||
|
retentionClock: oldRetentionClock,
|
||||||
};
|
};
|
||||||
const duplicate: LongTermMemoryEntry = {
|
const duplicate: LongTermMemoryEntry = {
|
||||||
...entry("duplicate-max", "prefer deterministic consolidation accounting!!!", "feedback"),
|
...entry("duplicate-max", "prefer deterministic consolidation accounting!!!", "feedback"),
|
||||||
pendingOwnerSessionID: "new-session",
|
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.equal(retained?.reinforcementCount, 6);
|
||||||
assert.ok(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "rejected" && event.details?.blockReason === "max_count"));
|
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", () => {
|
test("enforceLongTermLimits orders entries by retention strength", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user