Compare commits

..

2 Commits

Author SHA1 Message Date
Ralph Chang 041115c173 chore(code-health): prepare v1.6.5 2026-05-19 15:05:48 +08:00
Ralph Chang a480b734b2 feat(memory): add rolling reinforcement window 2026-05-15 11:16:34 +08:00
32 changed files with 1508 additions and 434 deletions
+2 -2
View File
@@ -208,8 +208,8 @@ const typedData = data as WorkspaceMemoryStore; // Explicit cast after validati
// ============================================================================
// ✅ REQUIRED: Block comments for complex logic
// Quality gate: Reject candidates that are git hashes, errors, or path-heavy
function shouldAcceptWorkspaceMemoryCandidate(candidate: string): boolean {
// Quality gate: return accepted/reasons so rejection evidence stays explainable
function evaluateWorkspaceMemoryCandidate(candidate: WorkspaceMemoryCandidate): CandidateEvaluation {
// ...
}
+47
View File
@@ -5,6 +5,53 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.6.5] - 2026-05-19
### Added
- Added `check:package-integrity` to verify `package.json` and on-disk `package-lock.json` root versions stay aligned even though the lockfile remains ignored by git.
- Added `tsconfig.unused.json` as a strict unused-symbol audit gate for development and release checks.
- Added package-integrity tests covering matching versions, mismatch reporting, and missing-lockfile guidance.
- Added storage/evidence contract tests for full-state JSON overwrites and concurrent evidence JSONL appends.
- Added workspace-memory render-order characterization and memory-visibility order coverage for the shared memory type order.
### Changed
- Centralized the current memory type ordering (`feedback`, `project`, `decision`, `reference`) in a narrow `memory-kind-policy` seam used by workspace rendering, TUI grouping, and memory visibility.
- Extracted diagnostics producer-version grouping and inference helpers from `memory-diag quality` into a pure diagnostics-only module while preserving the existing JSON and human output contracts.
- Documented storage write-path contracts in code: `updateJSON` is the locked read-modify-write path, `atomicWriteJSON` is the full-state overwrite primitive, and evidence logs remain append-only JSONL with bounded pruning.
- Marked legacy parser fixtures and retention caps as intentional compatibility/policy-contract test coverage.
- Updated developer docs to reference `evaluateWorkspaceMemoryCandidate` instead of the removed private acceptance wrapper.
### Deprecated
- Marked `REINFORCEMENT_MIN_INTERVAL_MS` with JSDoc `@deprecated`; the rolling reinforcement policy uses `REINFORCEMENT_MIN_ELAPSED_MS`.
### Removed
- Removed unused imports and private unused helpers discovered by the new unused-symbol audit, including the private `shouldAcceptWorkspaceMemoryCandidate` wrapper.
### Fixed
- Fixed release hygiene drift detection for the ignored lockfile by adding an explicit package integrity check.
- Reduced future diagnostics and memory-kind change risk by extracting small behavior-preserving seams without changing runtime memory behavior.
## [1.6.4] - 2026-05-15
### Changed
- Replaced same-session reinforcement blocking with a rolling 7-day elapsed reinforcement window so long-lived OpenCode sessions can reinforce durable memories after meaningful weekly recurrence.
- Kept the 45-day base half-life while changing the max reinforcement count to a growth saturation point: memories at count 6 can refresh retention timestamps weekly without increasing count or effective half-life.
- Bumped memory evidence instrumentation to version 3 for the new elapsed-window and refresh-only reinforcement semantics.
- Updated `memory-diag commands --memory` to show elapsed-window details, `sameSession` evidence, `reinforcementMode`, and legacy missing timestamp markers without exposing raw session IDs.
- Updated `memory-diag quality` to keep historical `same_session` block accounting while preventing new `sameSession` evidence from triggering old same-session diagnostic questions.
### Fixed
- Prevented long-lived sessions from indefinitely blocking reinforcement solely because the session ID stayed the same across days.
- Prevented saturated memories from growing stronger beyond the max reinforcement count while still allowing continued weekly use to keep them fresh.
- Preserved historical block reason compatibility for `same_session`, `same_utc_day`, `min_interval`, and `max_count` without producing those reasons from the new policy path.
## [1.6.3] - 2026-05-14
### Added
+72
View File
@@ -1,5 +1,77 @@
# Release Notes
## 1.6.5 (2026-05-19)
### Code Health and Release Hygiene
This patch release is an internal health release before the next feature wave. It does not change memory extraction, reinforcement policy, TUI behavior, or the `memory-diag` CLI contract. Instead, it makes the codebase easier to audit and safer to modify.
The release adds package-version integrity checks, a clean unused-symbol audit, focused characterization tests, storage/evidence contract coverage, a narrow shared memory-type ordering seam, and a small diagnostics versioning extraction.
### What Changed
- **Package integrity check**: added `npm run check:package-integrity` to verify `package.json` and the on-disk `package-lock.json` root versions match, with a clear `run npm install first` message when the ignored lockfile is missing.
- **Unused-symbol audit**: added `tsconfig.unused.json` and cleaned the existing unused imports/private helpers so the audit now passes cleanly.
- **Memory type order seam**: centralized the current order (`feedback`, `project`, `decision`, `reference`) for workspace rendering, memory visibility, and TUI grouping without creating a broader policy registry.
- **Storage/evidence contracts**: documented write-path semantics and added tests for full-state JSON overwrite behavior and concurrent evidence JSONL appends.
- **Diagnostics containment**: extracted producer-version grouping and inference helpers from `memory-diag quality` into a pure diagnostics-only module while preserving existing output shape and wording.
- **Characterization coverage**: added render-order coverage and labeled compatibility/policy-contract tests so future refactors can distinguish intentional legacy behavior from brittle fixtures.
### Upgrade Notes
- No configuration changes are required.
- Existing workspace memory files and evidence logs remain compatible.
- The `memory-diag` CLI JSON shape and human output wording are intended to be unchanged.
- `package-lock.json` remains ignored by git in this repository; run `npm install` before `npm run check:package-integrity` if the lockfile is missing locally.
- `REINFORCEMENT_MIN_INTERVAL_MS` remains exported for compatibility but is now marked `@deprecated`; use `REINFORCEMENT_MIN_ELAPSED_MS` for the rolling reinforcement policy.
### Validation
- `npm run check:package-integrity` — `PACKAGE_INTEGRITY_PASS version=1.6.5`
- `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/package-integrity.test.ts` — 3 tests passing
- `./node_modules/.bin/tsc -p tsconfig.unused.json` — no unused-symbol errors
- `node --test --experimental-strip-types tests/memory-diag-quality.test.ts tests/memory-diag.test.ts` — 93 tests passing
- `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/storage.test.ts tests/evidence-log.test.ts` — 22 tests passing
- `npm run typecheck` — `TYPECHECK_PASS`
- `npm test` — 504 tests passing, `TEST_PASS`
- `npm run build` — `BUILD_PASS`
---
## 1.6.4 (2026-05-15)
### Rolling Weekly Reinforcement
This patch release fixes the reinforcement policy for users who work in long-lived OpenCode sessions. Reinforcement no longer treats `same_session` as a hard block. Instead, each memory uses a rolling 7-day elapsed window, so recurring preferences can be reinforced after meaningful weekly use even when the session stays open.
The base retention half-life remains 45 days. The max reinforcement count remains 6, but it now acts as a growth saturation point rather than a lifetime hard stop.
### What Changed
- **7-day rolling window**: repeated reinforcement is allowed once 7 rolling days have elapsed since the memory's last reinforcement; 7 days minus 1ms still blocks.
- **Same-session as evidence**: `sameSession` is recorded for diagnostics but no longer blocks reinforcement by itself.
- **Refresh-only saturation**: memories at reinforcement count 6 can refresh `retentionClock`, `lastReinforcedAt`, and session evidence after the weekly window without increasing count or effective half-life.
- **Instrumentation v3**: new reinforcement evidence records elapsed-window fields, `sameSession`, `reinforcementMode`, and legacy missing timestamp markers.
- **Diagnostics updated**: `memory-diag commands --memory` exposes the new fields, while `memory-diag quality` keeps historical `same_session` block analysis separate from new same-session evidence.
### Upgrade Notes
- No configuration changes are required.
- Existing workspace memory files and evidence logs remain compatible.
- Historical diagnostics may still show older block reasons such as `same_session`, `same_utc_day`, `min_interval`, or `max_count`; new instrumentation-version-3 events use the rolling elapsed-window semantics.
- Consumers of `memory-diag commands --memory --json` should use `reinforcementMode` to distinguish count-increment reinforcement from refresh-only saturation.
### Validation
- `node --test --experimental-strip-types tests/retention.test.ts` — 10 tests passing
- `node --test --experimental-strip-types tests/workspace-memory.test.ts tests/plugin.test.ts` — 181 tests passing
- `node --test --experimental-strip-types tests/memory-diag.test.ts tests/memory-diag-quality.test.ts` — 93 tests passing
- `npm run typecheck` — `TYPECHECK_PASS`
- `npm test` — 498 tests passing, `TEST_PASS`
- `npm run build` — `BUILD_PASS`
---
## 1.6.3 (2026-05-14)
### Diagnostic Quality Review Board
+2 -2
View File
@@ -77,7 +77,7 @@ Workspace diagnostics also read the append-only evidence log for the current wor
}
```
Instrumentation version 2 adds optional causal details for future diagnostics without backfilling old JSONL records. Reinforcement-block events may include `details.blockReason` (for example `same_session` or `same_utc_day`). Capacity-removal events may include `details.strengthAtRemoval`, `details.rankAtRemoval`, `details.typeRankAtRemoval`, and `details.ageDaysAtRemoval`. `memory-diag quality` treats missing producer/instrumentation fields as historical or ambiguous rather than proof of current behavior.
Instrumentation version 2 added optional causal block details for diagnostics without backfilling old JSONL records. Instrumentation version 3 adds elapsed-window reinforcement details such as `details.elapsedMs`, `details.requiredElapsedMs`, `details.sameSession`, `details.reinforcementMode`, and `details.legacyMissingTimestamp`. Historical reinforcement-block events may still include older `details.blockReason` values such as `same_session`, `same_utc_day`, `min_interval`, `max_count`, or may have missing block details. Capacity-removal events may include `details.strengthAtRemoval`, `details.rankAtRemoval`, `details.typeRankAtRemoval`, and `details.ageDaysAtRemoval`. `memory-diag quality` treats missing producer/instrumentation fields as historical or ambiguous rather than proof of current behavior.
### Entry Types
@@ -154,7 +154,7 @@ Default type caps:
The type-cap total is 34, intentionally above the global 28-entry cap. These are maximums, not quotas.
Dormant workspaces age more slowly: after 14 inactive days, additional dormant time counts at 0.25x for retention decay. Repeated duplicate memories reinforce the surviving entry and slow future decay, but same-session and under-one-hour repeats do not stack reinforcement.
Dormant workspaces age more slowly: after 14 inactive days, additional dormant time counts at 0.25x for retention decay. Repeated duplicate memories reinforce the surviving entry only after a rolling 7-day elapsed window. Below reinforcement count 6, an allowed recurrence increments the reinforcement count and refreshes retention timestamps; at count 6 or higher, an allowed recurrence refreshes retention timestamps without increasing the count. Same-session status is recorded as diagnostic evidence, not as a new-policy block reason.
### Safety-Critical Deprecation
+6 -4
View File
@@ -38,8 +38,8 @@ Every diagnostic section must document:
For `memory-diag quality`:
- `reinforcementRules`: `inventory_only` (cannot distinguish spam from legitimate blocks)
- `evictionAndCaps`: `inventory_only` (cannot distinguish healthy turnover from premature eviction)
- Old evidence remains ambiguous. Answerability improves only for events produced after instrumentation version 2. Mixed old/new logs will show a mix of `inventory_only` and `partial` sections.
- Producer-instrumented reinforcement blocks can upgrade `reinforcementRules` to `partial` by showing exact block reasons and UTC-day grouping; they still require human content judgment.
- Old evidence remains ambiguous. Answerability improves for producer-instrumented events, including instrumentation version 2 block details and instrumentation version 3 elapsed-window details. Mixed old/new logs will show a mix of `inventory_only` and `partial` sections.
- Producer-instrumented reinforcement blocks can upgrade `reinforcementRules` to `partial` by showing exact block reasons and, when available, rolling elapsed-window fields; they still require human content judgment.
- Producer-instrumented capacity removals with rank/strength snapshots can upgrade `evictionAndCaps` to `partial`; fullness alone remains occupancy inventory, not proof of a capacity problem.
## Examples
@@ -78,9 +78,11 @@ npx --package opencode-working-memory memory-diag commands --verbose
npx --package opencode-working-memory memory-diag commands --memory <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
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "opencode-working-memory",
"version": "1.6.3",
"version": "1.6.5",
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
"type": "module",
"main": "index.ts",
@@ -29,6 +29,7 @@
"prepack": "npm run build",
"diag": "npm run --silent build:memory-diag && node ./scripts/memory-diag-bin.cjs",
"test:pack:memory-diag": "node --test --experimental-strip-types tests/smoke/memory-diag-packaging.test.ts",
"check:package-integrity": "node --experimental-strip-types scripts/dev/check-package-integrity.ts",
"typecheck": "tsc --noEmit && node -e \"console.log('TYPECHECK_PASS')\"",
"test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts && node -e \"console.log('TEST_PASS')\"",
"check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test"
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
import { readFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
type PackageManifest = {
version?: unknown;
};
type PackageLock = {
version?: unknown;
packages?: Record<string, { version?: unknown } | undefined>;
};
export type PackageVersionMismatch = {
field: "package-lock.json version" | "package-lock.json packages[\"\"].version";
expected: string;
actual: unknown;
};
export function packageVersionMismatches(
packageJson: PackageManifest,
packageLock: PackageLock,
): PackageVersionMismatch[] {
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
throw new Error("package.json version must be a non-empty string");
}
const expected = packageJson.version;
const rootLockVersion = packageLock.version;
const rootPackageVersion = packageLock.packages?.[""]?.version;
const candidates = [
{ field: "package-lock.json version" as const, actual: rootLockVersion },
{ field: "package-lock.json packages[\"\"].version" as const, actual: rootPackageVersion },
];
return candidates
.filter(candidate => candidate.actual !== expected)
.map(candidate => ({ ...candidate, expected }));
}
export function formatPackageVersionMismatch(mismatch: PackageVersionMismatch): string {
return `${mismatch.field} (${String(mismatch.actual)}) does not match package.json version (${mismatch.expected})`;
}
export function packageLockReadErrorMessage(error: unknown): string {
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
if (code === "ENOENT") return "package-lock.json not found; run npm install first";
const message = error instanceof Error ? error.message : String(error);
return `Unable to read package-lock.json; run npm install first. ${message}`;
}
async function readJsonFile<T>(path: string): Promise<T> {
return JSON.parse(await readFile(path, "utf8")) as T;
}
async function main(): Promise<void> {
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
const packageJson = await readJsonFile<PackageManifest>(join(repoRoot, "package.json"));
let packageLock: PackageLock;
try {
packageLock = await readJsonFile<PackageLock>(join(repoRoot, "package-lock.json"));
} catch (error) {
console.error(packageLockReadErrorMessage(error));
process.exit(1);
}
const mismatches = packageVersionMismatches(packageJson, packageLock);
if (mismatches.length > 0) {
console.error("Package integrity check failed:");
for (const mismatch of mismatches) {
console.error(`- ${formatPackageVersionMismatch(mismatch)}`);
}
process.exit(1);
}
console.log(`PACKAGE_INTEGRITY_PASS version=${packageJson.version}`);
}
function isMainModule(): boolean {
const invokedPath = process.argv[1];
return invokedPath ? import.meta.url === pathToFileURL(resolve(invokedPath)).href : false;
}
if (isMainModule()) {
await main();
}
+30 -1
View File
@@ -68,6 +68,11 @@ type MemoryCommandDetail = {
reasonCodes: string[];
attemptedAtIso?: string;
lastReinforcedAtIso?: string;
elapsedMs?: number;
requiredElapsedMs?: number;
sameSession?: boolean;
legacyMissingTimestamp?: boolean;
reinforcementMode?: string;
crossUtcDay?: boolean | "unknown";
producerVersion?: string;
instrumentationVersion?: number;
@@ -116,6 +121,16 @@ function stringDetail(event: EvidenceEventV1, key: string): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function numberDetail(event: EvidenceEventV1, key: string): number | undefined {
const value = event.details?.[key];
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function booleanDetail(event: EvidenceEventV1, key: string): boolean | undefined {
const value = event.details?.[key];
return typeof value === "boolean" ? value : undefined;
}
function isRejectedOrBlocked(event: EvidenceEventV1): boolean {
return event.outcome === "rejected" || hasReason(event, "reinforcement_window_blocked");
}
@@ -175,6 +190,11 @@ function detailEventJSON(event: EvidenceEventV1): MemoryCommandDetail["events"][
reasonCodes: event.reasonCodes,
attemptedAtIso,
lastReinforcedAtIso,
elapsedMs: numberDetail(event, "elapsedMs"),
requiredElapsedMs: numberDetail(event, "requiredElapsedMs"),
sameSession: booleanDetail(event, "sameSession"),
legacyMissingTimestamp: booleanDetail(event, "legacyMissingTimestamp") === true ? true : undefined,
reinforcementMode: stringDetail(event, "reinforcementMode"),
crossUtcDay: blocked ? isCrossUtcDay(attemptedAtIso, lastReinforcedAtIso) : undefined,
producerVersion: event.producerVersion,
instrumentationVersion: event.instrumentationVersion,
@@ -315,15 +335,24 @@ function formatCrossUtcDay(value: boolean | "unknown" | undefined): string {
return "unknown";
}
function formatBoolean(value: boolean): string {
return value ? "yes" : "no";
}
function formatMemoryCommandDetailEvents(events: MemoryCommandDetail["events"]): string[] {
if (events.length === 0) return [" (none)"];
return events.map(event => {
const ref = event.ref ? ` ref=${event.ref}` : "";
const blockReason = event.blockReason ? ` blockReason=${event.blockReason}` : "";
const reinforcementMode = event.reinforcementMode ? ` reinforcementMode=${event.reinforcementMode}` : "";
const attemptedAt = event.attemptedAtIso ? ` attemptedAt=${event.attemptedAtIso}` : "";
const lastReinforcedAt = event.lastReinforcedAtIso ? ` lastReinforcedAt=${event.lastReinforcedAtIso}` : "";
const elapsedMs = event.elapsedMs !== undefined ? ` elapsedMs=${event.elapsedMs}` : "";
const requiredElapsedMs = event.requiredElapsedMs !== undefined ? ` requiredElapsedMs=${event.requiredElapsedMs}` : "";
const sameSession = event.sameSession !== undefined ? ` sameSession=${formatBoolean(event.sameSession)}` : "";
const legacyMissingTimestamp = event.legacyMissingTimestamp === true ? " legacyMissingTimestamp=yes" : "";
const crossUtcDay = event.crossUtcDay !== undefined ? ` crossUtcDay=${formatCrossUtcDay(event.crossUtcDay)}` : "";
return ` - ${event.createdAt} outcome=${event.outcome}${ref}${blockReason}${attemptedAt}${lastReinforcedAt}${crossUtcDay} reasons=${event.reasonCodes.join(",") || "none"}`;
return ` - ${event.createdAt} outcome=${event.outcome}${ref}${blockReason}${reinforcementMode}${attemptedAt}${lastReinforcedAt}${elapsedMs}${requiredElapsedMs}${sameSession}${legacyMissingTimestamp}${crossUtcDay} reasons=${event.reasonCodes.join(",") || "none"}`;
});
}
+1 -1
View File
@@ -3,7 +3,7 @@ import {
RETENTION_TYPE_MAX,
} from "../../../src/retention.ts";
import { TYPES } from "../constants.ts";
import { daysSinceIso, formatStrength } from "../retention-model.ts";
import { formatStrength } from "../retention-model.ts";
import { cleanText, truncate } from "../text.ts";
import type { MemoryInspectionReadModel, RetentionDiagItem } from "../types.ts";
+1 -1
View File
@@ -1,6 +1,6 @@
import type { EvidenceEventV1 } from "../../src/evidence-log.ts";
import type { LongTermType } from "../../src/types.ts";
import { countBy, objectFromCounts, uniqueStrings } from "./text.ts";
import { countBy, objectFromCounts } from "./text.ts";
import { groupEvidenceByMemoryId } from "./evidence-model.ts";
import { loadRejectionRecords } from "./rejections-model.ts";
import { snapshotForOptions } from "./workspace-snapshot.ts";
+34 -233
View File
@@ -5,75 +5,42 @@ import { RETENTION_TYPE_MAX } from "../../src/retention.ts";
import type { LongTermMemoryEntry, LongTermType } from "../../src/types.ts";
import { TYPES } from "./constants.ts";
import { disappearanceRows } from "./inspection-model.ts";
import {
VERSION_ANALYSIS_SAMPLE_THRESHOLD,
VERSION_GROUPS,
buildVersionBuckets,
buildVersionCoverage,
computeVersionedInference,
hasKnownProducerVersion,
hasProducerFields,
producerVersionGroupFor,
} from "./quality-versioning.ts";
import type {
AnswerabilityLevel,
ProducerBearingRecord,
ProducerVersionGroup,
VersionCoverage,
VersionedMechanismDiagnosticQuestion,
VersionedMechanismFacts,
} from "./quality-versioning.ts";
import { hasWorkspaceScope, rejectionQualitySummary, uniqueByCanonicalText } from "./rejections-model.ts";
import { canonicalMemoryText, cleanText, countBy, objectFromCounts, truncate, uniqueStrings, workspaceRootHash } from "./text.ts";
import type { MemoryInspectionReadModel, NormalizedRejection } from "./types.ts";
export type AnswerabilityLevel = "supported" | "partial" | "inventory_only" | "not_instrumented";
export type {
AnswerabilityLevel,
ProducerBearingRecord,
ProducerVersionGroup,
VersionAvailability,
VersionBucketFacts,
VersionCoverage,
VersionedMechanismDiagnosticQuestion,
VersionedMechanismFacts,
VersionedMechanismInference,
VersionSampleAssessment,
} from "./quality-versioning.ts";
export type ProducerVersionGroup = "current" | "previous" | "unknown_unversioned";
export type VersionSampleAssessment =
| "observed"
| "not_observed_but_sample_small"
| "not_observed_with_sufficient_sample"
| "no_current_version_opportunities";
export type VersionAvailability = {
noProducerFields: number;
unknownProducerVersion: number;
emptyProducerVersion: number;
knownProducerVersion: number;
};
export type VersionCoverage = {
totalEvents: number;
currentVersionEvents: number;
previousVersionEvents: number;
unknownVersionEvents: number;
coveragePercent: number;
isTransitional: boolean;
};
export type VersionedMechanismInference = {
status:
| "current_recurrence_detected"
| "pattern_persists_across_versions"
| "no_current_evidence_observed"
| "no_current_evidence_sample_small"
| "no_current_version_opportunities"
| "no_previous_pattern_observed";
message: string;
caveat: "Version grouping is based only on producerVersion strings in evidence";
};
export type VersionBucketFacts<TFacts> = {
group: ProducerVersionGroup;
label: string;
opportunityCount: number;
observedPatternCount: number;
producerVersions: Record<string, number>;
versionAvailability: VersionAvailability;
answerabilityLevel: AnswerabilityLevel;
sampleAssessment: VersionSampleAssessment;
facts: TFacts;
};
export type VersionedMechanismDiagnosticQuestion = {
mechanism: "reinforcement_rule";
group: ProducerVersionGroup;
question: string;
evidence: string[];
};
export type VersionedMechanismFacts<TFacts> = {
currentPackageVersion: string;
opportunityName: string;
sampleThreshold: number;
buckets: Record<ProducerVersionGroup, VersionBucketFacts<TFacts>>;
inference: VersionedMechanismInference;
diagnosticQuestions?: VersionedMechanismDiagnosticQuestion[];
};
export { hasKnownProducerVersion, producerVersionGroupFor } from "./quality-versioning.ts";
export type RejectionVersionFacts = {
totalRecords: number;
@@ -314,9 +281,6 @@ export type HeuristicFlag = {
const ACTIVE_MEMORY_FULL_TEXT_THRESHOLD = 40;
const REPRESENTATIVE_CANDIDATE_LIMIT = 10;
const RECENT_EVICTION_DAYS = 7;
const VERSION_ANALYSIS_SAMPLE_THRESHOLD = 5;
const VERSION_GROUPS: ProducerVersionGroup[] = ["current", "previous", "unknown_unversioned"];
const VERSION_GROUPING_CAVEAT = "Version grouping is based only on producerVersion strings in evidence" as const;
const KNOWN_MIGRATION_IDS = [
"2026-04-26-p0-cleanup",
@@ -571,9 +535,9 @@ function applyInstrumentedAnswerability(
report.reinforcementRules.currentSignals = uniqueStrings([
...report.reinforcementRules.currentSignals,
"exact block reasons",
"UTC day grouping",
"elapsed-window details when present",
]);
report.reinforcementRules.outputPermission = "Show exact block reasons and day grouping; causal fields exist but human content judgment is still required.";
report.reinforcementRules.outputPermission = "Show exact block reasons and elapsed-window details when present; causal fields exist but human content judgment is still required.";
}
const hasCapacitySnapshots = evictionFacts.recentCapacityRemovalsWithSnapshot > 0
@@ -617,7 +581,7 @@ function buildVersionedSystemMechanismFacts(
currentPackageVersion: string,
generatedAt: string,
): VersionedSystemMechanismFacts {
const versionCoverage = buildVersionCoverage(events, rejections, currentPackageVersion);
const versionCoverage = buildVersionCoverage([...events, ...rejections], currentPackageVersion);
return {
currentPackageVersion,
versionCoverage,
@@ -761,30 +725,6 @@ function buildVersionedEvictionFacts(
};
}
function buildVersionBuckets<TRecord extends ProducerBearingRecord, TFacts>(
records: TRecord[],
currentPackageVersion: string,
summarize: (records: TRecord[]) => { facts: TFacts; opportunityCount: number; observedPatternCount: number },
): Record<ProducerVersionGroup, VersionBucketFacts<TFacts>> {
const grouped = Object.fromEntries(VERSION_GROUPS.map(group => [group, []])) as Record<ProducerVersionGroup, TRecord[]>;
for (const record of records) grouped[producerVersionGroupFor(record, currentPackageVersion)].push(record);
return Object.fromEntries(VERSION_GROUPS.map(group => {
const bucketRecords = grouped[group];
const summary = summarize(bucketRecords);
return [group, {
group,
label: versionGroupLabel(group, currentPackageVersion),
opportunityCount: summary.opportunityCount,
observedPatternCount: summary.observedPatternCount,
producerVersions: producerVersionCounts(bucketRecords),
versionAvailability: buildVersionAvailability(bucketRecords),
answerabilityLevel: group === "current" && summary.opportunityCount > 0 ? "partial" : "inventory_only",
sampleAssessment: sampleAssessmentFor(group, summary.opportunityCount, summary.observedPatternCount, currentPackageVersion),
facts: summary.facts,
} satisfies VersionBucketFacts<TFacts>];
})) as Record<ProducerVersionGroup, VersionBucketFacts<TFacts>>;
}
function buildEvictionVersionFacts(capacityEvents: EvidenceEventV1[], generatedAt: string): EvictionVersionFacts {
const recentCapacityEvents = capacityEvents.filter(event => isWithinDaysOf(event.createdAt, generatedAt, RECENT_EVICTION_DAYS));
const capacityEventsWithSnapshot = capacityEvents.filter(hasCapacitySnapshot);
@@ -808,145 +748,6 @@ function isReviewableRejectionCandidate(record: NormalizedRejection): boolean {
return label === "architecture_like_rejected_candidate" || label === "ambiguous_rejected_candidate";
}
function producerVersionCounts(records: ProducerBearingRecord[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const record of records) {
if (!hasKnownProducerVersion(record)) continue;
const version = String(record.producerVersion).trim();
counts[version] = (counts[version] ?? 0) + 1;
}
return counts;
}
function versionGroupLabel(group: ProducerVersionGroup, currentPackageVersion: string): string {
if (group === "current") return `current version ${currentPackageVersion}`;
if (group === "previous") return "previous versions";
return "unknown/unversioned";
}
function sampleAssessmentFor(
group: ProducerVersionGroup,
opportunityCount: number,
observedPatternCount: number,
currentPackageVersion: string,
): VersionSampleAssessment {
if (observedPatternCount > 0) return "observed";
if (group === "current" && (!isAssessableCurrentPackageVersion(currentPackageVersion) || opportunityCount === 0)) return "no_current_version_opportunities";
if (opportunityCount < VERSION_ANALYSIS_SAMPLE_THRESHOLD) return "not_observed_but_sample_small";
return "not_observed_with_sufficient_sample";
}
function isAssessableCurrentPackageVersion(currentPackageVersion: string): boolean {
const trimmed = currentPackageVersion.trim();
return trimmed.length > 0 && trimmed !== "unknown";
}
function computeVersionedInference<TFacts>(
mechanism: Omit<VersionedMechanismFacts<TFacts>, "inference">,
text: { observedPattern: string; patternName: string },
): VersionedMechanismInference {
const current = mechanism.buckets.current;
const previous = mechanism.buckets.previous;
const currentFact = `Current version: ${current.observedPatternCount} ${text.observedPattern} in ${current.opportunityCount} ${mechanism.opportunityName}.`;
const previousFact = `Previous versions: ${previous.observedPatternCount} ${text.observedPattern} in ${previous.opportunityCount} ${mechanism.opportunityName}.`;
const unknownUnversioned = mechanism.buckets.unknown_unversioned;
if (!isAssessableCurrentPackageVersion(mechanism.currentPackageVersion) || current.opportunityCount === 0) {
return inference("no_current_version_opportunities", "Current package version is unknown or has no events; cannot assess recurrence.");
}
if (current.observedPatternCount > 0 && previous.observedPatternCount === 0 && unknownUnversioned.observedPatternCount === 0) {
return inference("no_previous_pattern_observed", `${currentFact} No previous pattern observed — this is a new pattern, not a recurrence.`);
}
if (current.observedPatternCount > 0) {
if (previous.observedPatternCount > 0) {
return inference("pattern_persists_across_versions", `${currentFact} ${previousFact} Current recurrence detected — ${text.patternName} observed in current version. Pattern persists across versions.`);
}
// Current has signal, previous has none, but unknown/unversioned has signal
return inference("current_recurrence_detected", `${currentFact} No known previous-version pattern observed, but unknown/unversioned evidence shows ${unknownUnversioned.observedPatternCount} ${text.observedPattern}. Pattern may persist — version grouping cannot confirm or deny.`);
}
if (current.opportunityCount < mechanism.sampleThreshold) {
return inference("no_current_evidence_sample_small", `${currentFact} ${previousFact} No current evidence observed, but current-version opportunity count is ${current.opportunityCount} (<${mechanism.sampleThreshold}); do not infer absence.`);
}
return inference("no_current_evidence_observed", `${currentFact} ${previousFact} No recurrence observed with sufficient current-version sample.`);
}
function inference(status: VersionedMechanismInference["status"], message: string): VersionedMechanismInference {
return { status, message, caveat: VERSION_GROUPING_CAVEAT };
}
function hasProducerFields(record: Pick<EvidenceEventV1, "producerName" | "producerVersion" | "instrumentationVersion"> | Pick<NormalizedRejection, "producerName" | "producerVersion" | "instrumentationVersion">): boolean {
return typeof record.producerName === "string"
&& record.producerName.length > 0
&& typeof record.producerVersion === "string"
&& record.producerVersion.length > 0
&& typeof record.instrumentationVersion === "number";
}
type ProducerBearingRecord = Pick<EvidenceEventV1 | NormalizedRejection, "producerName" | "producerVersion" | "instrumentationVersion">;
export function hasKnownProducerVersion(record: ProducerBearingRecord): boolean {
if (typeof record.producerVersion !== "string") return false;
const producerVersion = record.producerVersion.trim();
return producerVersion.length > 0 && producerVersion !== "unknown";
}
export function producerVersionGroupFor(record: ProducerBearingRecord, currentPackageVersion: string): ProducerVersionGroup {
if (!hasKnownProducerVersion(record)) return "unknown_unversioned";
const producerVersion = String(record.producerVersion).trim();
const currentVersion = currentPackageVersion.trim();
if (currentVersion.length > 0 && currentVersion !== "unknown" && producerVersion === currentVersion) return "current";
return "previous";
}
function buildVersionAvailability(records: ProducerBearingRecord[]): VersionAvailability {
const availability: VersionAvailability = {
noProducerFields: 0,
unknownProducerVersion: 0,
emptyProducerVersion: 0,
knownProducerVersion: 0,
};
for (const record of records) {
const hasAnyProducerField = typeof record.producerName === "string"
|| typeof record.producerVersion === "string"
|| typeof record.instrumentationVersion === "number";
if (!hasAnyProducerField) {
availability.noProducerFields += 1;
continue;
}
if (typeof record.producerVersion !== "string" || record.producerVersion.trim().length === 0) {
availability.emptyProducerVersion += 1;
continue;
}
if (record.producerVersion.trim() === "unknown") {
availability.unknownProducerVersion += 1;
continue;
}
availability.knownProducerVersion += 1;
}
return availability;
}
function buildVersionCoverage(events: EvidenceEventV1[], rejections: NormalizedRejection[], currentPackageVersion: string): VersionCoverage {
const coverage: VersionCoverage = {
totalEvents: events.length + rejections.length,
currentVersionEvents: 0,
previousVersionEvents: 0,
unknownVersionEvents: 0,
coveragePercent: 0,
isTransitional: true,
};
for (const record of [...events, ...rejections]) {
const group = producerVersionGroupFor(record, currentPackageVersion);
if (group === "current") coverage.currentVersionEvents += 1;
if (group === "previous") coverage.previousVersionEvents += 1;
if (group === "unknown_unversioned") coverage.unknownVersionEvents += 1;
}
coverage.coveragePercent = coverage.totalEvents === 0
? 0
: Math.round(((coverage.currentVersionEvents + coverage.previousVersionEvents) / coverage.totalEvents) * 1000) / 10;
coverage.isTransitional = coverage.coveragePercent < 50;
return coverage;
}
function typeCountsFor(entries: LongTermMemoryEntry[]): Record<string, number> {
return Object.fromEntries(TYPES.map(type => [type, entries.filter(entry => entry.type === type).length]));
}
+236
View File
@@ -0,0 +1,236 @@
export type AnswerabilityLevel = "supported" | "partial" | "inventory_only" | "not_instrumented";
export type ProducerVersionGroup = "current" | "previous" | "unknown_unversioned";
export type ProducerBearingRecord = {
producerName?: string;
producerVersion?: string;
instrumentationVersion?: number;
};
export type VersionSampleAssessment =
| "observed"
| "not_observed_but_sample_small"
| "not_observed_with_sufficient_sample"
| "no_current_version_opportunities";
export type VersionAvailability = {
noProducerFields: number;
unknownProducerVersion: number;
emptyProducerVersion: number;
knownProducerVersion: number;
};
export type VersionCoverage = {
totalEvents: number;
currentVersionEvents: number;
previousVersionEvents: number;
unknownVersionEvents: number;
coveragePercent: number;
isTransitional: boolean;
};
export const VERSION_ANALYSIS_SAMPLE_THRESHOLD = 5;
export const VERSION_GROUPS: ProducerVersionGroup[] = ["current", "previous", "unknown_unversioned"];
export const VERSION_GROUPING_CAVEAT = "Version grouping is based only on producerVersion strings in evidence" as const;
export type VersionedMechanismInference = {
status:
| "current_recurrence_detected"
| "pattern_persists_across_versions"
| "no_current_evidence_observed"
| "no_current_evidence_sample_small"
| "no_current_version_opportunities"
| "no_previous_pattern_observed";
message: string;
caveat: typeof VERSION_GROUPING_CAVEAT;
};
export type VersionBucketFacts<TFacts> = {
group: ProducerVersionGroup;
label: string;
opportunityCount: number;
observedPatternCount: number;
producerVersions: Record<string, number>;
versionAvailability: VersionAvailability;
answerabilityLevel: AnswerabilityLevel;
sampleAssessment: VersionSampleAssessment;
facts: TFacts;
};
export type VersionedMechanismDiagnosticQuestion = {
mechanism: "reinforcement_rule";
group: ProducerVersionGroup;
question: string;
evidence: string[];
};
export type VersionedMechanismFacts<TFacts> = {
currentPackageVersion: string;
opportunityName: string;
sampleThreshold: number;
buckets: Record<ProducerVersionGroup, VersionBucketFacts<TFacts>>;
inference: VersionedMechanismInference;
diagnosticQuestions?: VersionedMechanismDiagnosticQuestion[];
};
export function buildVersionBuckets<TRecord extends ProducerBearingRecord, TFacts>(
records: TRecord[],
currentPackageVersion: string,
summarize: (records: TRecord[]) => { facts: TFacts; opportunityCount: number; observedPatternCount: number },
): Record<ProducerVersionGroup, VersionBucketFacts<TFacts>> {
const grouped = Object.fromEntries(VERSION_GROUPS.map(group => [group, []])) as Record<ProducerVersionGroup, TRecord[]>;
for (const record of records) grouped[producerVersionGroupFor(record, currentPackageVersion)].push(record);
return Object.fromEntries(VERSION_GROUPS.map(group => {
const bucketRecords = grouped[group];
const summary = summarize(bucketRecords);
return [group, {
group,
label: versionGroupLabel(group, currentPackageVersion),
opportunityCount: summary.opportunityCount,
observedPatternCount: summary.observedPatternCount,
producerVersions: producerVersionCounts(bucketRecords),
versionAvailability: buildVersionAvailability(bucketRecords),
answerabilityLevel: group === "current" && summary.opportunityCount > 0 ? "partial" : "inventory_only",
sampleAssessment: sampleAssessmentFor(group, summary.opportunityCount, summary.observedPatternCount, currentPackageVersion),
facts: summary.facts,
} satisfies VersionBucketFacts<TFacts>];
})) as Record<ProducerVersionGroup, VersionBucketFacts<TFacts>>;
}
export function computeVersionedInference<TFacts>(
mechanism: Omit<VersionedMechanismFacts<TFacts>, "inference">,
text: { observedPattern: string; patternName: string },
): VersionedMechanismInference {
const current = mechanism.buckets.current;
const previous = mechanism.buckets.previous;
const currentFact = `Current version: ${current.observedPatternCount} ${text.observedPattern} in ${current.opportunityCount} ${mechanism.opportunityName}.`;
const previousFact = `Previous versions: ${previous.observedPatternCount} ${text.observedPattern} in ${previous.opportunityCount} ${mechanism.opportunityName}.`;
const unknownUnversioned = mechanism.buckets.unknown_unversioned;
if (!isAssessableCurrentPackageVersion(mechanism.currentPackageVersion) || current.opportunityCount === 0) {
return inference("no_current_version_opportunities", "Current package version is unknown or has no events; cannot assess recurrence.");
}
if (current.observedPatternCount > 0 && previous.observedPatternCount === 0 && unknownUnversioned.observedPatternCount === 0) {
return inference("no_previous_pattern_observed", `${currentFact} No previous pattern observed — this is a new pattern, not a recurrence.`);
}
if (current.observedPatternCount > 0) {
if (previous.observedPatternCount > 0) {
return inference("pattern_persists_across_versions", `${currentFact} ${previousFact} Current recurrence detected — ${text.patternName} observed in current version. Pattern persists across versions.`);
}
// Current has signal, previous has none, but unknown/unversioned has signal
return inference("current_recurrence_detected", `${currentFact} No known previous-version pattern observed, but unknown/unversioned evidence shows ${unknownUnversioned.observedPatternCount} ${text.observedPattern}. Pattern may persist — version grouping cannot confirm or deny.`);
}
if (current.opportunityCount < mechanism.sampleThreshold) {
return inference("no_current_evidence_sample_small", `${currentFact} ${previousFact} No current evidence observed, but current-version opportunity count is ${current.opportunityCount} (<${mechanism.sampleThreshold}); do not infer absence.`);
}
return inference("no_current_evidence_observed", `${currentFact} ${previousFact} No recurrence observed with sufficient current-version sample.`);
}
export function hasProducerFields(record: ProducerBearingRecord): boolean {
return typeof record.producerName === "string"
&& record.producerName.length > 0
&& typeof record.producerVersion === "string"
&& record.producerVersion.length > 0
&& typeof record.instrumentationVersion === "number";
}
export function hasKnownProducerVersion(record: ProducerBearingRecord): boolean {
if (typeof record.producerVersion !== "string") return false;
const producerVersion = record.producerVersion.trim();
return producerVersion.length > 0 && producerVersion !== "unknown";
}
export function producerVersionGroupFor(record: ProducerBearingRecord, currentPackageVersion: string): ProducerVersionGroup {
if (!hasKnownProducerVersion(record)) return "unknown_unversioned";
const producerVersion = String(record.producerVersion).trim();
const currentVersion = currentPackageVersion.trim();
if (currentVersion.length > 0 && currentVersion !== "unknown" && producerVersion === currentVersion) return "current";
return "previous";
}
export function buildVersionCoverage(records: ProducerBearingRecord[], currentPackageVersion: string): VersionCoverage {
const coverage: VersionCoverage = {
totalEvents: records.length,
currentVersionEvents: 0,
previousVersionEvents: 0,
unknownVersionEvents: 0,
coveragePercent: 0,
isTransitional: true,
};
for (const record of records) {
const group = producerVersionGroupFor(record, currentPackageVersion);
if (group === "current") coverage.currentVersionEvents += 1;
if (group === "previous") coverage.previousVersionEvents += 1;
if (group === "unknown_unversioned") coverage.unknownVersionEvents += 1;
}
coverage.coveragePercent = coverage.totalEvents === 0
? 0
: Math.round(((coverage.currentVersionEvents + coverage.previousVersionEvents) / coverage.totalEvents) * 1000) / 10;
coverage.isTransitional = coverage.coveragePercent < 50;
return coverage;
}
function producerVersionCounts(records: ProducerBearingRecord[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const record of records) {
if (!hasKnownProducerVersion(record)) continue;
const version = String(record.producerVersion).trim();
counts[version] = (counts[version] ?? 0) + 1;
}
return counts;
}
function versionGroupLabel(group: ProducerVersionGroup, currentPackageVersion: string): string {
if (group === "current") return `current version ${currentPackageVersion}`;
if (group === "previous") return "previous versions";
return "unknown/unversioned";
}
function sampleAssessmentFor(
group: ProducerVersionGroup,
opportunityCount: number,
observedPatternCount: number,
currentPackageVersion: string,
): VersionSampleAssessment {
if (observedPatternCount > 0) return "observed";
if (group === "current" && (!isAssessableCurrentPackageVersion(currentPackageVersion) || opportunityCount === 0)) return "no_current_version_opportunities";
if (opportunityCount < VERSION_ANALYSIS_SAMPLE_THRESHOLD) return "not_observed_but_sample_small";
return "not_observed_with_sufficient_sample";
}
function isAssessableCurrentPackageVersion(currentPackageVersion: string): boolean {
const trimmed = currentPackageVersion.trim();
return trimmed.length > 0 && trimmed !== "unknown";
}
function inference(status: VersionedMechanismInference["status"], message: string): VersionedMechanismInference {
return { status, message, caveat: VERSION_GROUPING_CAVEAT };
}
function buildVersionAvailability(records: ProducerBearingRecord[]): VersionAvailability {
const availability: VersionAvailability = {
noProducerFields: 0,
unknownProducerVersion: 0,
emptyProducerVersion: 0,
knownProducerVersion: 0,
};
for (const record of records) {
const hasAnyProducerField = typeof record.producerName === "string"
|| typeof record.producerVersion === "string"
|| typeof record.instrumentationVersion === "number";
if (!hasAnyProducerField) {
availability.noProducerFields += 1;
continue;
}
if (typeof record.producerVersion !== "string" || record.producerVersion.trim().length === 0) {
availability.emptyProducerVersion += 1;
continue;
}
if (record.producerVersion.trim() === "unknown") {
availability.unknownProducerVersion += 1;
continue;
}
availability.knownProducerVersion += 1;
}
return availability;
}
+6
View File
@@ -284,6 +284,9 @@ function buildEvidenceEvent(
}
async function safeAppendEvidenceLine(path: string, line: string): Promise<void> {
// Evidence logs are JSONL append streams, not JSON store read-modify-write
// documents. Appends intentionally use appendFile so independent evidence
// writers do not need to share the JSON store lock path.
try {
await mkdir(dirname(path), { recursive: true });
await appendFile(path, `${line}\n`, "utf8");
@@ -294,6 +297,9 @@ async function safeAppendEvidenceLine(path: string, line: string): Promise<void>
}
async function maybePruneEvidenceLog(path: string): Promise<void> {
// Bounded pruning is a separate best-effort compaction of the append-only log.
// It rewrites the JSONL file only at configured append intervals and never
// routes through updateJSON because evidence is not a single JSON document.
const nextCount = (appendCounts.get(path) ?? 0) + 1;
appendCounts.set(path, nextCount);
if (nextCount % EVIDENCE_LOG_LIMITS.pruneEveryAppendCount !== 0) return;
-12
View File
@@ -397,18 +397,6 @@ function evaluateWorkspaceMemoryCandidate(
return { accepted: true, reasons: ["quality_gate_passed"] };
}
function shouldAcceptWorkspaceMemoryCandidate(
entry: {
type: LongTermType;
text: string;
},
options: {
fromMemoryTrigger?: boolean;
} & WorkspaceMemoryCandidateParseOptions = {},
): boolean {
return evaluateWorkspaceMemoryCandidate(entry, options).accepted;
}
function commandAttemptReason(line: string): string {
const normalized = line.replace(/^\s*-\s*/, "").trim();
const reinforceMatch = normalized.match(/^REINFORCE\s+(.+)$/i);
+1 -1
View File
@@ -6,7 +6,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
let cachedVersion: string | undefined;
const MEMORY_PRODUCER_NAME = "opencode-working-memory";
const MEMORY_INSTRUMENTATION_VERSION = 2;
const MEMORY_INSTRUMENTATION_VERSION = 3;
function producerVersion(): string {
if (cachedVersion) return cachedVersion;
+14
View File
@@ -0,0 +1,14 @@
import type { LongTermType } from "./types.ts";
// Current workspace-memory display/render order. This is intentionally a narrow
// shared constant, not a broader memory-kind policy registry.
export const MEMORY_TYPE_ORDER = ["feedback", "project", "decision", "reference"] as const satisfies readonly LongTermType[];
export function emptyMemoryTypeGroups<T>(): Record<LongTermType, T[]> {
return {
feedback: [],
project: [],
decision: [],
reference: [],
};
}
+2 -2
View File
@@ -6,6 +6,7 @@ import { redactCredentials } from "./redaction.ts";
import type { LongTermMemoryEntry, PendingMemoryJournalStore, SessionState, WorkspaceMemoryStore } from "./types.ts";
import { LONG_TERM_LIMITS } from "./types.ts";
import { accountWorkspaceMemoryCompactionRefs, accountWorkspaceMemoryRender } from "./workspace-memory.ts";
import { MEMORY_TYPE_ORDER, emptyMemoryTypeGroups } from "./memory-kind-policy.ts";
export type MemoryVisibilityCommand = "status" | "list" | "help";
@@ -33,7 +34,6 @@ export type MemoryListModel = {
};
const MAX_PREVIEW_CHARS = 120;
const MEMORY_TYPE_ORDER = ["feedback", "project", "decision", "reference"] as const satisfies readonly LongTermMemoryEntry["type"][];
function safePreview(text: string | undefined, maxChars = MAX_PREVIEW_CHARS): string {
const clean = redactCredentials(text ?? "").replace(/\s+/g, " ").trim();
@@ -211,7 +211,7 @@ export function formatMemoryStatus(model: MemoryStatusModel): string {
}
function emptyMemoryListGroups(): MemoryListModel["groups"] {
return { feedback: [], project: [], decision: [], reference: [] };
return emptyMemoryTypeGroups<MemoryListItem>();
}
export async function getMemoryList(root: string): Promise<MemoryListModel> {
+27 -11
View File
@@ -37,14 +37,13 @@ import {
import { assessMemoryQuality } from "./memory-quality.ts";
import {
loadWorkspaceMemory,
updateWorkspaceMemory,
updateWorkspaceMemoryWithAccounting,
accountWorkspaceMemoryRender,
accountWorkspaceMemoryCompactionRefs,
workspaceMemoryExactKey,
workspaceMemoryIdentityKey,
} from "./workspace-memory.ts";
import { tryReinforceMemory } from "./retention.ts";
import { REINFORCEMENT_MAX_COUNT, tryReinforceMemory, type ReinforcementDecision } from "./retention.ts";
import {
appendPendingMemories,
clearPendingMemories,
@@ -311,6 +310,21 @@ export const MemoryV2Plugin: Plugin = async (input) => {
};
}
function reinforcementDecisionTimingDetails(decision: ReinforcementDecision): EvidenceEventInput["details"] {
return {
attemptedAtMs: decision.attemptedAt,
attemptedAtIso: new Date(decision.attemptedAt).toISOString(),
...(decision.lastReinforcedAt !== undefined ? {
lastReinforcedAtMs: decision.lastReinforcedAt,
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
} : {}),
...(decision.elapsedMs !== undefined ? { elapsedMs: decision.elapsedMs } : {}),
requiredElapsedMs: decision.requiredElapsedMs,
sameSession: decision.sameSession,
...(decision.legacyMissingTimestamp ? { legacyMissingTimestamp: true } : {}),
};
}
function replacementMemoryId(): string {
return `mem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
@@ -434,26 +448,28 @@ export const MemoryV2Plugin: Plugin = async (input) => {
evidence.push(memoryReinforcedEvidence(target, command.ref, "rejected", ["numbered_ref_reinforce", "reinforcement_window_blocked", `reinforcement_block_${decision.blockReason}`], {
memoryId: refSnapshot.memoryId,
blockReason: decision.blockReason,
attemptedAtMs: now,
attemptedAtIso: new Date(now).toISOString(),
...(decision.lastReinforcedAt ? {
lastReinforcedAtMs: decision.lastReinforcedAt,
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
} : {}),
...reinforcementDecisionTimingDetails(decision),
reinforcementCount: decision.reinforcementCount,
maxReinforcementCount: decision.maxReinforcementCount,
minIntervalMs: decision.minIntervalMs,
}));
continue;
}
const reinforced = decision.memory;
workspaceMemory.entries[targetIndex] = reinforced;
evidence.push(memoryReinforcedEvidence(reinforced, command.ref, "reinforced", ["numbered_ref_reinforce", "reinforcement_window_allowed"], {
const reasonCodes = ["numbered_ref_reinforce", "reinforcement_window_allowed"];
if (decision.reinforcementMode === "refresh_only") {
reasonCodes.push("reinforcement_saturation_refresh");
}
evidence.push(memoryReinforcedEvidence(reinforced, command.ref, "reinforced", reasonCodes, {
memoryId: refSnapshot.memoryId,
reinforcementOutcome: "reinforced",
reinforcementOutcome: decision.reinforcementMode === "refresh_only" ? "refreshed" : "reinforced",
reinforcementMode: decision.reinforcementMode,
...reinforcementDecisionTimingDetails(decision),
previousReinforcementCount: decision.previousReinforcementCount,
newReinforcementCount: decision.newReinforcementCount,
reinforcementCount: decision.newReinforcementCount,
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
}));
continue;
}
+69 -33
View File
@@ -1,19 +1,53 @@
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
export type ReinforcementBlockReason = "same_session" | "same_utc_day" | "min_interval" | "max_count";
export type ReinforcementBlockReason =
| "min_elapsed_window"
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
| "same_session"
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
| "same_utc_day"
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
| "min_interval"
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
| "max_count";
export type ReinforcementMode = "increment" | "refresh_only";
type ReinforcementDecisionMetadata = {
attemptedAt: number;
lastReinforcedAt?: number;
elapsedMs?: number;
requiredElapsedMs: number;
sameSession: boolean;
legacyMissingTimestamp?: boolean;
};
export type ReinforcementDecision =
| { outcome: "reinforced"; memory: LongTermMemoryEntry; previousReinforcementCount: number; newReinforcementCount: number }
| { outcome: "blocked"; memory: LongTermMemoryEntry; blockReason: ReinforcementBlockReason; lastReinforcedAt?: number; reinforcementCount: number; maxReinforcementCount: number; minIntervalMs: number };
| ({
outcome: "reinforced";
memory: LongTermMemoryEntry;
previousReinforcementCount: number;
newReinforcementCount: number;
reinforcementMode: ReinforcementMode;
} & ReinforcementDecisionMetadata)
| ({
outcome: "blocked";
memory: LongTermMemoryEntry;
blockReason: ReinforcementBlockReason;
reinforcementCount: number;
maxReinforcementCount: number;
} & ReinforcementDecisionMetadata);
// Retention decay model constants (v1.5)
export const BASE_HALF_LIFE_DAYS = 45;
export const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
export const REINFORCEMENT_MAX_COUNT = 6;
export const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
export const DAY_MS = 24 * 60 * 60 * 1000;
export const REINFORCEMENT_MIN_ELAPSED_MS = 7 * DAY_MS;
/** @deprecated Compatibility constant; new policy uses REINFORCEMENT_MIN_ELAPSED_MS. */
export const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000;
export const WORKSPACE_DORMANT_AFTER_DAYS = 14;
export const DORMANT_DECAY_MULTIPLIER = 0.25;
export const DAY_MS = 24 * 60 * 60 * 1000;
export const TYPE_FACTOR = {
reference: 1.0,
@@ -114,42 +148,38 @@ export function calculateEffectiveAgeDays(
return activeDays + dormantOverlapDays * DORMANT_DECAY_MULTIPLIER;
}
function isSameUTCCalendarDay(ts1: number, ts2: number): boolean {
const d1 = new Date(ts1);
const d2 = new Date(ts2);
return d1.getUTCFullYear() === d2.getUTCFullYear()
&& d1.getUTCMonth() === d2.getUTCMonth()
&& d1.getUTCDate() === d2.getUTCDate();
}
export function tryReinforceMemory(
memory: LongTermMemoryEntry,
sessionId: string,
now: number,
): ReinforcementDecision {
const count = memory.reinforcementCount ?? 0;
const lastAt = memory.lastReinforcedAt ?? 0;
const lastAt = validLastReinforcedAt(memory.lastReinforcedAt);
const lastSession = memory.lastReinforcedSessionID;
const sameSession = lastSession === sessionId;
const legacyMissingTimestamp = count > 0 && lastAt === undefined;
const metadata: ReinforcementDecisionMetadata = {
attemptedAt: now,
...(lastAt !== undefined ? {
lastReinforcedAt: lastAt,
elapsedMs: now - lastAt,
} : {}),
requiredElapsedMs: REINFORCEMENT_MIN_ELAPSED_MS,
sameSession,
...(legacyMissingTimestamp ? { legacyMissingTimestamp: true } : {}),
};
if (lastSession === sessionId) {
return blockedDecision(memory, "same_session", count, lastAt);
}
if (count >= REINFORCEMENT_MAX_COUNT) {
return blockedDecision(memory, "max_count", count, lastAt);
}
if (lastAt > 0 && now < lastAt + REINFORCEMENT_MIN_INTERVAL_MS) {
return blockedDecision(memory, "min_interval", count, lastAt);
}
if (lastAt > 0 && isSameUTCCalendarDay(lastAt, now)) {
return blockedDecision(memory, "same_utc_day", count, lastAt);
if (lastAt !== undefined && now - lastAt < REINFORCEMENT_MIN_ELAPSED_MS) {
return blockedDecision(memory, "min_elapsed_window", count, metadata);
}
const reinforcementMode: ReinforcementMode = count >= REINFORCEMENT_MAX_COUNT
? "refresh_only"
: "increment";
const newReinforcementCount = reinforcementMode === "refresh_only" ? count : count + 1;
const reinforced: LongTermMemoryEntry = {
...memory,
reinforcementCount: count + 1,
reinforcementCount: newReinforcementCount,
lastReinforcedAt: now,
lastReinforcedSessionID: sessionId,
retentionClock: now,
@@ -158,23 +188,29 @@ export function tryReinforceMemory(
outcome: "reinforced",
memory: reinforced,
previousReinforcementCount: count,
newReinforcementCount: count + 1,
newReinforcementCount,
reinforcementMode,
...metadata,
};
}
function validLastReinforcedAt(value: unknown): number | undefined {
if (typeof value !== "number") return undefined;
return Number.isFinite(value) && value > 0 ? value : undefined;
}
function blockedDecision(
memory: LongTermMemoryEntry,
blockReason: ReinforcementBlockReason,
reinforcementCount: number,
lastReinforcedAt: number,
metadata: ReinforcementDecisionMetadata,
): ReinforcementDecision {
return {
outcome: "blocked",
memory,
blockReason,
...(lastReinforcedAt > 0 ? { lastReinforcedAt } : {}),
reinforcementCount,
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
minIntervalMs: REINFORCEMENT_MIN_INTERVAL_MS,
...metadata,
};
}
+6
View File
@@ -164,6 +164,9 @@ async function withFileLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
}
export async function atomicWriteJSON(path: string, data: unknown): Promise<void> {
// Full-state overwrite primitive: callers must already own the complete next
// JSON document. Do not use this for read-modify-write updates that must
// preserve concurrent changes; use updateJSON for that contract instead.
await mkdir(dirname(path), { recursive: true });
const tmp = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
await writeFile(tmp, JSON.stringify(data, null, 2), { encoding: "utf8", mode: 0o600 });
@@ -175,6 +178,9 @@ export async function updateJSON<T>(
fallback: () => T,
updater: (current: T) => T | Promise<T>,
): Promise<T> {
// Locked read-modify-write path: serializes in-process callers and uses a
// filesystem lock for cross-process callers before reading, updating, and
// atomically replacing the JSON document.
const previous = fileLocks.get(path) ?? Promise.resolve();
let release: () => void = () => {};
const currentLock = new Promise<void>(resolve => {
+1 -2
View File
@@ -5,6 +5,7 @@ import {
renderMemoryCommand,
type MemoryVisibilityCommand,
} from "./memory-visibility.ts";
import { MEMORY_TYPE_ORDER } from "./memory-kind-policy.ts";
type DialogContext = {
clear?: () => void;
@@ -245,8 +246,6 @@ function showMemoryHelp(api: TuiPluginApi): void {
showAlertFromMarkdown(api, formatMemoryHelp(), "Memory help", "medium");
}
const MEMORY_TYPE_ORDER = ["feedback", "project", "decision", "reference"] as const;
async function showMemoryList(api: TuiPluginApi): Promise<void> {
const dialogApi = getDialogApi(api);
if (!dialogApi) return;
+32 -23
View File
@@ -7,6 +7,7 @@ import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts";
import { redactCredentials } from "./redaction.ts";
import {
REINFORCEMENT_MAX_COUNT,
RETENTION_TYPE_MAX,
calculateRetentionStrength,
tryReinforceMemory,
@@ -14,6 +15,7 @@ import {
} from "./retention.ts";
import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts";
import { appendEvidenceEvents } from "./evidence-log.ts";
import { MEMORY_TYPE_ORDER } from "./memory-kind-policy.ts";
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
const MIN_ENVELOPE_LENGTH = 80;
@@ -768,7 +770,7 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
now,
);
const reinforced = decision.memory;
const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason, now);
const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason);
if (reinforcedEvent) evidence.push(reinforcedEvent);
absorbed.push(consolidationEvent(dropped, reason, reinforced));
@@ -797,7 +799,7 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
now,
);
const reinforced = decision.memory;
const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason, now);
const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason);
if (reinforcedEvent) evidence.push(reinforcedEvent);
if (reason === "superseded_existing") {
@@ -841,7 +843,6 @@ function reinforcementEvidence(
dropped: LongTermMemoryEntry,
decision: ReinforcementDecision,
reason: "absorbed_exact" | "absorbed_identity" | "superseded_existing",
attemptedAt: number,
): EvidenceEventInput | undefined {
const duplicateReason = reason === "absorbed_identity" ? "duplicate_identity" : "duplicate_exact";
if (decision.outcome === "blocked") {
@@ -859,21 +860,19 @@ function reinforcementEvidence(
memoryId: retained.id,
droppedMemoryId: dropped.id,
blockReason: decision.blockReason,
attemptedAtMs: attemptedAt,
attemptedAtIso: new Date(attemptedAt).toISOString(),
...(decision.lastReinforcedAt ? {
lastReinforcedAtMs: decision.lastReinforcedAt,
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
} : {}),
...reinforcementDecisionTimingDetails(decision),
reinforcementCount: decision.reinforcementCount,
maxReinforcementCount: decision.maxReinforcementCount,
minIntervalMs: decision.minIntervalMs,
},
textPreview: retained.text,
};
}
const reinforced = decision.memory;
const reasonCodes = [duplicateReason, "reinforcement_window_allowed"];
if (decision.reinforcementMode === "refresh_only") {
reasonCodes.push("reinforcement_saturation_refresh");
}
return {
type: "memory_reinforced",
phase: "reinforcement",
@@ -883,18 +882,37 @@ function reinforcementEvidence(
{ role: "reinforced", memory: memoryEvidenceRef(reinforced) },
{ role: "reinforced_by", memory: memoryEvidenceRef(dropped) },
],
reasonCodes: [duplicateReason, "reinforcement_window_allowed"],
reasonCodes,
details: {
memoryId: reinforced.id,
droppedMemoryId: dropped.id,
reinforcementOutcome: "reinforced",
reinforcementOutcome: decision.reinforcementMode === "refresh_only" ? "refreshed" : "reinforced",
reinforcementMode: decision.reinforcementMode,
...reinforcementDecisionTimingDetails(decision),
previousReinforcementCount: decision.previousReinforcementCount,
newReinforcementCount: decision.newReinforcementCount,
reinforcementCount: decision.newReinforcementCount,
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
},
textPreview: reinforced.text,
};
}
function reinforcementDecisionTimingDetails(decision: ReinforcementDecision): EvidenceEventInput["details"] {
return {
attemptedAtMs: decision.attemptedAt,
attemptedAtIso: new Date(decision.attemptedAt).toISOString(),
...(decision.lastReinforcedAt !== undefined ? {
lastReinforcedAtMs: decision.lastReinforcedAt,
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
} : {}),
...(decision.elapsedMs !== undefined ? { elapsedMs: decision.elapsedMs } : {}),
requiredElapsedMs: decision.requiredElapsedMs,
sameSession: decision.sameSession,
...(decision.legacyMissingTimestamp ? { legacyMissingTimestamp: true } : {}),
};
}
function reinforcementSessionId(retained: LongTermMemoryEntry, dropped: LongTermMemoryEntry): string {
return dropped.pendingOwnerSessionID ?? retained.pendingOwnerSessionID ?? "workspace-dedupe";
}
@@ -916,15 +934,6 @@ function compareLongTermMemoryForRetention(
return a.id.localeCompare(b.id);
}
function wouldFit(
lines: string[],
nextLine: string,
closingLine: string,
maxChars: number
): boolean {
return [...lines, nextLine, closingLine].join("\n").length <= maxChars;
}
export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string {
return accountWorkspaceMemoryRender(store).prompt;
}
@@ -976,7 +985,7 @@ export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): Works
];
const rendered: LongTermMemoryEntry[] = [];
for (const type of ["feedback", "project", "decision", "reference"] as const) {
for (const type of MEMORY_TYPE_ORDER) {
const items = active.filter(entry => entry.type === type);
if (items.length === 0) continue;
@@ -1020,7 +1029,7 @@ export function accountWorkspaceMemoryCompactionRefs(store: WorkspaceMemoryStore
const refs: CompactionMemoryRef[] = [];
const capturedAt = Date.now();
for (const type of ["feedback", "project", "decision", "reference"] as const) {
for (const type of MEMORY_TYPE_ORDER) {
const items = active.filter(entry => entry.type === type);
if (items.length === 0) continue;
+24
View File
@@ -109,6 +109,30 @@ test("appendEvidenceEvent redacts text previews before writing", async () => {
}
});
test("concurrent evidence appends preserve independent JSONL records", async () => {
const root = await tempRoot();
try {
const count = 40;
await Promise.all(Array.from({ length: count }, (_, index) =>
appendEvidenceEvent(root, eventInput({ memory: { memoryId: `concurrent-${index}` } }))
));
const raw = await readLog(root);
const lines = raw.trim().split("\n");
const events = await queryEvidenceEvents(root);
const memoryIds = new Set(events.map(event => event.memory?.memoryId));
assert.equal(lines.length, count);
assert.equal(events.length, count);
for (let index = 0; index < count; index += 1) {
assert.equal(memoryIds.has(`concurrent-${index}`), true);
}
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("queryEvidenceEvents filters by type outcome and memory id", async () => {
const root = await tempRoot();
try {
+43 -1
View File
@@ -373,7 +373,7 @@ test("new evidence events include producer metadata and historical events remain
assert.equal(stored.producerName, "opencode-working-memory");
assert.equal(stored.producerVersion, packageJson.version);
assert.equal(stored.instrumentationVersion, 2);
assert.equal(stored.instrumentationVersion, 3);
const historical = event("evt-historical-no-producer", {
type: "render_selected",
@@ -747,6 +747,48 @@ test("quality surfaces same-session cross-day reinforcement as design diagnostic
assert.doesNotMatch(output, /definitely a bug/i);
});
test("quality treats elapsed-window and refresh-only reinforcement as new evidence without old same-session question", () => {
const report = buildQualityReviewBoard(inspectionModel([], [
reinforcementAttempt("evt-elapsed-window-block", packageJson.version, true, {
instrumentationVersion: 3,
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_blocked", "reinforcement_block_min_elapsed_window"],
details: {
blockReason: "min_elapsed_window",
elapsedMs: 601_200_000,
requiredElapsedMs: 604_800_000,
sameSession: true,
attemptedAtIso: "2026-05-13T07:48:21.361Z",
lastReinforcedAtIso: "2026-05-06T08:48:21.361Z",
},
}),
reinforcementAttempt("evt-refresh-only", packageJson.version, false, {
instrumentationVersion: 3,
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_allowed", "reinforcement_saturation_refresh"],
details: {
reinforcementMode: "refresh_only",
elapsedMs: 604_800_000,
requiredElapsedMs: 604_800_000,
sameSession: true,
},
}),
...Array.from({ length: 4 }, (_, index) => reinforcementAttempt(`evt-elapsed-ok-${index}`, packageJson.version, false, { instrumentationVersion: 3 })),
]), {}, generatedAt);
const facts = report.facts.systemMechanisms.reinforcementRules;
const versionedFacts = report.facts.systemMechanisms.versionedFacts?.reinforcementRules.buckets.current.facts;
const output = formatQualityReviewBoard(report, {});
assert.equal(facts.blocksByExactReason.min_elapsed_window, 1);
assert.equal(facts.blocksByExactReason.same_session, undefined);
assert.equal(facts.reinforcedEvents, 5);
assert.equal(facts.rejectedOrBlockedEvents, 1);
assert.equal(versionedFacts?.blocksByExactReason.min_elapsed_window, 1);
assert.equal(versionedFacts?.reinforcedEvents, 5);
assert.equal(versionedFacts?.rejectedOrBlockedEvents, 1);
assert.equal(report.facts.systemMechanisms.versionedFacts?.reinforcementRules.diagnosticQuestions, undefined);
assert.doesNotMatch(output, /Should same_session reinforcement blocking apply across UTC days/i);
assert.doesNotMatch(output, /same_session=1/);
});
test("versioned quality warns when current reinforcement sample is small", () => {
const events = [
reinforcementAttempt("evt-prev-small-block", "1.6.0", true),
+118 -9
View File
@@ -181,6 +181,85 @@ async function setupMemoryCommandDetailFixture(root: string): Promise<void> {
sessionHash: "command-session-two",
messageHash: "command-message-three",
}),
evidence({
type: "memory_reinforced",
phase: "reinforcement",
outcome: "rejected",
memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" },
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_blocked", "reinforcement_block_min_elapsed_window"],
details: {
ref: "M3",
blockReason: "min_elapsed_window",
attemptedAtIso: "2026-05-20T00:05:00.000Z",
lastReinforcedAtIso: "2026-05-13T00:05:00.001Z",
elapsedMs: 604_799_999,
requiredElapsedMs: 604_800_000,
sameSession: true,
sessionID: "raw-session-secret",
lastReinforcedSessionID: "raw-last-session-secret",
},
sessionHash: "command-session-three",
messageHash: "command-message-four",
}),
evidence({
type: "memory_reinforced",
phase: "reinforcement",
outcome: "reinforced",
memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" },
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_allowed"],
details: {
ref: "M3",
reinforcementOutcome: "reinforced",
reinforcementMode: "increment",
attemptedAtIso: "2026-05-20T00:05:00.001Z",
lastReinforcedAtIso: "2026-05-13T00:05:00.001Z",
elapsedMs: 604_800_000,
requiredElapsedMs: 604_800_000,
sameSession: false,
sessionID: "raw-increment-session-secret",
},
sessionHash: "command-session-four",
messageHash: "command-message-five",
}),
evidence({
type: "memory_reinforced",
phase: "reinforcement",
outcome: "reinforced",
memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" },
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_allowed", "reinforcement_saturation_refresh"],
details: {
ref: "M3",
reinforcementOutcome: "refreshed",
reinforcementMode: "refresh_only",
attemptedAtIso: "2026-05-27T00:05:00.001Z",
lastReinforcedAtIso: "2026-05-20T00:05:00.001Z",
elapsedMs: 604_800_000,
requiredElapsedMs: 604_800_000,
sameSession: true,
lastReinforcedSessionID: "raw-refresh-last-session-secret",
},
sessionHash: "command-session-five",
messageHash: "command-message-six",
}),
evidence({
type: "memory_reinforced",
phase: "reinforcement",
outcome: "reinforced",
memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" },
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_allowed"],
details: {
ref: "M3",
reinforcementOutcome: "reinforced",
reinforcementMode: "increment",
attemptedAtIso: "2026-05-28T00:05:00.001Z",
requiredElapsedMs: 604_800_000,
sameSession: false,
legacyMissingTimestamp: true,
sessionID: "raw-legacy-session-secret",
},
sessionHash: "command-session-six",
messageHash: "command-message-seven",
}),
evidence({
type: "render_selected",
phase: "render",
@@ -482,16 +561,26 @@ test("memory-diag commands memory selector prints reinforcement detail", async (
assert.match(stdout, /status: active/);
assert.match(stdout, /render: rendered/);
assert.match(stdout, /Reinforcement summary:/);
assert.match(stdout, /attempts: 3/);
assert.match(stdout, /reinforced: 1/);
assert.match(stdout, /rejected\/blocked: 2/);
assert.match(stdout, /window blocked: 2/);
assert.match(stdout, /block reasons: same_session=1, unknown=1/);
assert.match(stdout, /attempts: 7/);
assert.match(stdout, /reinforced: 4/);
assert.match(stdout, /rejected\/blocked: 3/);
assert.match(stdout, /window blocked: 3/);
assert.match(stdout, /block reasons: min_elapsed_window=1, same_session=1, unknown=1/);
assert.match(stdout, /block details missing: 1/);
assert.match(stdout, /same-session cross UTC day blocks: 1/);
assert.match(stdout, /refs: M3/);
assert.match(stdout, /blockReason=min_elapsed_window/);
assert.match(stdout, /elapsedMs=604799999/);
assert.match(stdout, /requiredElapsedMs=604800000/);
assert.match(stdout, /sameSession=yes/);
assert.match(stdout, /sameSession=no/);
assert.match(stdout, /reinforcementMode=refresh_only/);
assert.match(stdout, /legacyMissingTimestamp=yes/);
assert.match(stdout, /crossUtcDay=yes/);
assert.doesNotMatch(stdout, /render_selected/);
assert.doesNotMatch(stdout, /raw-session-secret/);
assert.doesNotMatch(stdout, /raw-last-session-secret/);
assert.doesNotMatch(stdout, /raw-refresh-last-session-secret/);
assertNoAttributionSafetyTerms(stdout);
} finally {
await rm(root, { recursive: true, force: true });
@@ -516,7 +605,18 @@ test("memory-diag commands memory selector emits stable JSON", async () => {
blockDetailsMissing: number;
sameSessionCrossUtcDayBlocks: number;
};
events: Array<{ eventId: string; outcome: string; blockReason?: string; crossUtcDay?: boolean | "unknown" }>;
events: Array<{
eventId: string;
outcome: string;
blockReason?: string;
crossUtcDay?: boolean | "unknown";
elapsedMs?: number;
requiredElapsedMs?: number;
sameSession?: boolean;
legacyMissingTimestamp?: boolean;
reinforcementMode?: string;
instrumentationVersion?: number;
}>;
};
assert.equal(parsed.version, 1);
@@ -524,18 +624,27 @@ test("memory-diag commands memory selector emits stable JSON", async () => {
assert.equal(parsed.current.present, true);
assert.equal(parsed.current.status, "active");
assert.equal(parsed.current.renderStatus, "rendered");
assert.equal(parsed.summary.attempts, 3);
assert.equal(parsed.summary.reinforced, 1);
assert.equal(parsed.summary.rejectedOrBlocked, 2);
assert.equal(parsed.summary.attempts, 7);
assert.equal(parsed.summary.reinforced, 4);
assert.equal(parsed.summary.rejectedOrBlocked, 3);
assert.equal(parsed.summary.blocksByReason.min_elapsed_window, 1);
assert.equal(parsed.summary.blocksByReason.same_session, 1);
assert.equal(parsed.summary.blocksByReason.unknown, 1);
assert.equal(parsed.summary.blockDetailsMissing, 1);
assert.equal(parsed.summary.sameSessionCrossUtcDayBlocks, 1);
assert.equal(parsed.events.some(event => event.blockReason === "min_elapsed_window" && event.elapsedMs === 604_799_999 && event.requiredElapsedMs === 604_800_000 && event.sameSession === true), true);
assert.equal(parsed.events.some(event => event.reinforcementMode === "increment" && event.elapsedMs === 604_800_000 && event.sameSession === false), true);
assert.equal(parsed.events.some(event => event.reinforcementMode === "refresh_only" && event.elapsedMs === 604_800_000 && event.sameSession === true), true);
assert.equal(parsed.events.some(event => event.legacyMissingTimestamp === true), true);
assert.equal(parsed.events.every(event => event.instrumentationVersion === 3), true);
assert.equal(parsed.events.some(event => event.blockReason === "same_session" && event.crossUtcDay === true), true);
assert.equal(parsed.events.some(event => event.blockReason === "unknown" && event.crossUtcDay === "unknown"), true);
assert.equal(JSON.stringify(parsed).includes("command-session"), false);
assert.equal(JSON.stringify(parsed).includes("command-message"), false);
assert.equal(JSON.stringify(parsed).includes("Detail drill-down memory remains current"), false);
assert.equal(JSON.stringify(parsed).includes("raw-session-secret"), false);
assert.equal(JSON.stringify(parsed).includes("raw-last-session-secret"), false);
assert.equal(JSON.stringify(parsed).includes("raw-refresh-last-session-secret"), false);
assertNoAttributionSafetyTerms(stdout);
} finally {
await rm(root, { recursive: true, force: true });
+4
View File
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { appendPendingMemories } from "../src/pending-journal.ts";
import { saveSessionState } from "../src/session-state.ts";
import { MEMORY_TYPE_ORDER } from "../src/memory-kind-policy.ts";
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
import { workspaceMemoryPath } from "../src/paths.ts";
import { saveWorkspaceMemory } from "../src/workspace-memory.ts";
@@ -154,6 +155,9 @@ test("formats current workspace memories grouped by type with display-local refs
assert.match(output, /project:\n- \[M\d+\]/);
assert.match(output, /decision:\n- \[M\d+\]/);
assert.match(output, /reference:\n- \[M\d+\]/);
const groupIndexes = MEMORY_TYPE_ORDER.map(type => output.indexOf(`${type}:`));
assert.equal(groupIndexes.every(index => index >= 0), true, "all memory type groups should render");
assert.deepEqual(groupIndexes, [...groupIndexes].sort((a, b) => a - b), "memory list groups should follow shared memory type order");
assert.match(output, /Shown: \d+ of \d+ active memories\./);
assert.match(output, /Shown: 4 of 4 active memories\./);
assert.match(output, /Omitted active memories: 0\./);
+38
View File
@@ -0,0 +1,38 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
formatPackageVersionMismatch,
packageLockReadErrorMessage,
packageVersionMismatches,
} from "../scripts/dev/check-package-integrity.ts";
test("package integrity accepts matching package and lockfile versions", () => {
const mismatches = packageVersionMismatches(
{ version: "1.6.4" },
{ version: "1.6.4", packages: { "": { version: "1.6.4" } } },
);
assert.deepEqual(mismatches, []);
});
test("package integrity reports both lockfile version mismatches", () => {
const mismatches = packageVersionMismatches(
{ version: "1.6.4" },
{ version: "1.6.3", packages: { "": { version: "1.6.2" } } },
);
assert.deepEqual(
mismatches.map(formatPackageVersionMismatch),
[
"package-lock.json version (1.6.3) does not match package.json version (1.6.4)",
"package-lock.json packages[\"\"].version (1.6.2) does not match package.json version (1.6.4)",
],
);
});
test("package integrity explains missing package-lock.json", () => {
assert.equal(
packageLockReadErrorMessage(Object.assign(new Error("missing"), { code: "ENOENT" })),
"package-lock.json not found; run npm install first",
);
});
+242 -15
View File
@@ -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 () => {
// 1. Temp directory for isolated file I/O
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
@@ -515,6 +569,8 @@ Next steps: continue development.
assert.equal(candidates[1].type, "project");
});
// Compatibility-contract characterization: legacy compaction parser formats are
// still supported intentionally and should not be removed as brittle fixtures.
test("parseWorkspaceMemoryCandidates accepts legacy Workspace Memory Candidates section", async () => {
const summary = `
## Summary
@@ -1032,8 +1088,9 @@ test("session.compacted applies numbered REINFORCE command to referenced memory"
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const now = new Date().toISOString();
const oldRetentionClock = Date.now() - 10 * 24 * 60 * 60 * 1000;
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
const now = new Date(nowMs).toISOString();
const lastReinforcedAt = nowMs - 8 * 24 * 60 * 60 * 1000;
const existing: LongTermMemoryEntry = {
id: "numbered-reinforce-memory",
type: "decision",
@@ -1043,7 +1100,10 @@ test("session.compacted applies numbered REINFORCE command to referenced memory"
status: "active",
createdAt: now,
updatedAt: now,
retentionClock: oldRetentionClock,
retentionClock: lastReinforcedAt,
reinforcementCount: 1,
lastReinforcedAt,
lastReinforcedSessionID: "numbered-reinforce-session",
};
await updateWorkspaceMemory(tmpDir, store => {
store.entries.push(existing);
@@ -1065,28 +1125,174 @@ test("session.compacted applies numbered REINFORCE command to referenced memory"
directory: tmpDir,
client: mockClientWithCompactionSummary("Memory candidates:\nREINFORCE [M1]"),
});
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: "numbered-reinforce-session" } },
await withMockedDateNow(nowMs, async () => {
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: "numbered-reinforce-session" } },
});
});
const workspace = await loadWorkspaceMemory(tmpDir);
const reinforced = workspace.entries.find(entry => entry.id === existing.id);
assert.equal(reinforced?.reinforcementCount, 1);
assert.equal(reinforced?.reinforcementCount, 2);
assert.equal(reinforced?.lastReinforcedSessionID, "numbered-reinforce-session");
assert.ok((reinforced?.retentionClock ?? 0) > oldRetentionClock);
assert.equal(reinforced?.lastReinforcedAt, nowMs);
assert.equal(reinforced?.retentionClock, nowMs);
const events = await queryEvidenceEvents(tmpDir, { types: ["memory_reinforced"] });
assert.ok(events.some(event =>
const event = events.find(event =>
event.outcome === "reinforced" &&
event.reasonCodes.includes("numbered_ref_reinforce") &&
event.reasonCodes.includes("reinforcement_window_allowed") &&
event.memory?.memoryId === existing.id
));
);
assert.ok(event, "numbered REINFORCE should emit allowed evidence");
assert.equal(event.instrumentationVersion, 3);
assert.equal(event.details?.reinforcementOutcome, "reinforced");
assert.equal(event.details?.reinforcementMode, "increment");
assert.equal(event.details?.sameSession, true);
assert.equal(event.details?.elapsedMs, 8 * 24 * 60 * 60 * 1000);
assert.equal(event.details?.requiredElapsedMs, 7 * 24 * 60 * 60 * 1000);
assert.equal(event.details?.attemptedAtMs, nowMs);
assert.equal(event.details?.lastReinforcedAtMs, lastReinforcedAt);
assert.equal(event.details?.previousReinforcementCount, 1);
assert.equal(event.details?.newReinforcementCount, 2);
assert.equal(JSON.stringify(event.details).includes("numbered-reinforce-session"), false);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("session.compacted allows numbered REINFORCE at exact 7-day boundary", async () => {
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
const lastReinforcedAt = nowMs - 7 * 24 * 60 * 60 * 1000;
const existing: LongTermMemoryEntry = {
id: "numbered-reinforce-exact-window",
type: "decision",
text: "Use exact rolling windows for memory reinforcement.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: new Date(nowMs).toISOString(),
updatedAt: new Date(nowMs).toISOString(),
retentionClock: lastReinforcedAt,
reinforcementCount: 5,
lastReinforcedAt,
lastReinforcedSessionID: "previous-session",
};
await withNumberedReinforceScenario({ sessionID: "exact-window-session", nowMs, existing }, ({ workspace, events }) => {
const reinforced = workspace.entries.find(entry => entry.id === existing.id);
const event = events.find(event => event.outcome === "reinforced" && event.memory?.memoryId === existing.id);
assert.equal(reinforced?.reinforcementCount, 6);
assert.equal(reinforced?.retentionClock, nowMs);
assert.ok(event, "exact 7-day boundary should emit reinforced evidence");
assert.equal(event.details?.reinforcementMode, "increment");
assert.equal(event.details?.elapsedMs, 7 * 24 * 60 * 60 * 1000);
assert.equal(event.details?.requiredElapsedMs, 7 * 24 * 60 * 60 * 1000);
assert.equal(event.instrumentationVersion, 3);
});
});
test("session.compacted blocks different-session numbered REINFORCE below 7-day window", async () => {
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
const lastReinforcedAt = nowMs - (7 * 24 * 60 * 60 * 1000 - 1);
const existing: LongTermMemoryEntry = {
id: "numbered-reinforce-below-window",
type: "feedback",
text: "User prefers below-window reinforcement blocks.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: new Date(nowMs).toISOString(),
updatedAt: new Date(nowMs).toISOString(),
retentionClock: nowMs - 10 * 24 * 60 * 60 * 1000,
reinforcementCount: 1,
lastReinforcedAt,
lastReinforcedSessionID: "previous-session",
};
await withNumberedReinforceScenario({ sessionID: "different-session", nowMs, existing }, ({ workspace, events }) => {
const blocked = workspace.entries.find(entry => entry.id === existing.id);
const event = events.find(event => event.outcome === "rejected" && event.memory?.memoryId === existing.id);
assert.equal(blocked?.reinforcementCount, 1);
assert.ok(event, "below-window different-session attempt should emit rejected evidence");
assert.ok(event.reasonCodes.includes("reinforcement_block_min_elapsed_window"));
assert.equal(event.details?.blockReason, "min_elapsed_window");
assert.equal(event.details?.sameSession, false);
assert.equal(event.details?.elapsedMs, 7 * 24 * 60 * 60 * 1000 - 1);
assert.equal(event.details?.requiredElapsedMs, 7 * 24 * 60 * 60 * 1000);
assert.equal(event.instrumentationVersion, 3);
});
});
test("session.compacted refreshes saturated numbered REINFORCE after rolling window", async () => {
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
const lastReinforcedAt = nowMs - 7 * 24 * 60 * 60 * 1000;
const existing: LongTermMemoryEntry = {
id: "numbered-reinforce-refresh-only",
type: "decision",
text: "Use refresh-only mode for saturated reinforcement.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: new Date(nowMs).toISOString(),
updatedAt: new Date(nowMs).toISOString(),
retentionClock: nowMs - 30 * 24 * 60 * 60 * 1000,
reinforcementCount: 6,
lastReinforcedAt,
lastReinforcedSessionID: "previous-session",
};
await withNumberedReinforceScenario({ sessionID: "refresh-only-session", nowMs, existing }, ({ workspace, events }) => {
const refreshed = workspace.entries.find(entry => entry.id === existing.id);
const event = events.find(event => event.outcome === "reinforced" && event.memory?.memoryId === existing.id);
assert.equal(refreshed?.reinforcementCount, 6);
assert.equal(refreshed?.retentionClock, nowMs);
assert.equal(refreshed?.lastReinforcedAt, nowMs);
assert.equal(refreshed?.lastReinforcedSessionID, "refresh-only-session");
assert.ok(event, "saturated after-window attempt should emit refreshed evidence");
assert.equal(event.details?.reinforcementOutcome, "refreshed");
assert.equal(event.details?.reinforcementMode, "refresh_only");
assert.equal(event.details?.previousReinforcementCount, 6);
assert.equal(event.details?.newReinforcementCount, 6);
assert.ok(event.reasonCodes.includes("reinforcement_saturation_refresh"));
assert.equal(event.reasonCodes.includes("reinforcement_block_max_count"), false);
assert.equal(event.instrumentationVersion, 3);
});
});
test("session.compacted emits legacy missing timestamp details for numbered REINFORCE", async () => {
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
const existing: LongTermMemoryEntry = {
id: "numbered-reinforce-legacy-missing",
type: "feedback",
text: "User prefers legacy timestamp anomalies to be visible.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: new Date(nowMs).toISOString(),
updatedAt: new Date(nowMs).toISOString(),
retentionClock: nowMs - 30 * 24 * 60 * 60 * 1000,
reinforcementCount: 2,
lastReinforcedSessionID: "previous-session",
};
await withNumberedReinforceScenario({ sessionID: "legacy-missing-session", nowMs, existing }, ({ workspace, events }) => {
const reinforced = workspace.entries.find(entry => entry.id === existing.id);
const event = events.find(event => event.outcome === "reinforced" && event.memory?.memoryId === existing.id);
assert.equal(reinforced?.reinforcementCount, 3);
assert.equal(reinforced?.lastReinforcedAt, nowMs);
assert.equal(reinforced?.retentionClock, nowMs);
assert.equal(event?.details?.legacyMissingTimestamp, true);
assert.equal(event?.details?.reinforcementMode, "increment");
assert.equal(event?.instrumentationVersion, 3);
});
});
test("session.compacted rejects invalid unavailable and stale REINFORCE refs without mutation", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
@@ -1163,8 +1369,9 @@ test("session.compacted emits rejected evidence when numbered REINFORCE is block
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const nowMs = Date.now();
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
const now = new Date(nowMs).toISOString();
const lastReinforcedAt = nowMs - (7 * 24 * 60 * 60 * 1000 - 60 * 60 * 1000);
const existing: LongTermMemoryEntry = {
id: "blocked-numbered-reinforce",
type: "feedback",
@@ -1176,7 +1383,7 @@ test("session.compacted emits rejected evidence when numbered REINFORCE is block
updatedAt: now,
retentionClock: nowMs - 10 * 24 * 60 * 60 * 1000,
reinforcementCount: 1,
lastReinforcedAt: nowMs - 2 * 60 * 60 * 1000,
lastReinforcedAt,
lastReinforcedSessionID: "blocked-reinforce-session",
};
await updateWorkspaceMemory(tmpDir, store => {
@@ -1199,8 +1406,10 @@ test("session.compacted emits rejected evidence when numbered REINFORCE is block
directory: tmpDir,
client: mockClientWithCompactionSummary("Memory candidates:\nREINFORCE [M1]"),
});
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: "blocked-reinforce-session" } },
await withMockedDateNow(nowMs, async () => {
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: "blocked-reinforce-session" } },
});
});
const workspace = await loadWorkspaceMemory(tmpDir);
@@ -1209,13 +1418,31 @@ test("session.compacted emits rejected evidence when numbered REINFORCE is block
assert.equal(blocked?.reinforcementCount, 1);
const events = await queryEvidenceEvents(tmpDir, { types: ["memory_reinforced"] });
assert.ok(events.some(event =>
const event = events.find(event =>
event.outcome === "rejected" &&
event.reasonCodes.includes("reinforcement_window_blocked") &&
event.reasonCodes.includes("reinforcement_block_min_elapsed_window") &&
event.memory?.memoryId === existing.id &&
event.relations?.some(relation => relation.role === "target" && relation.memory?.memoryId === existing.id) &&
!event.relations?.some(relation => relation.role === "reinforced")
));
);
assert.ok(event, "blocked numbered REINFORCE should emit elapsed-window evidence");
assert.equal(event.instrumentationVersion, 3);
assert.equal(event.reasonCodes.includes("reinforcement_block_same_session"), false);
assert.equal(event.reasonCodes.includes("reinforcement_block_same_utc_day"), false);
assert.equal(event.reasonCodes.includes("reinforcement_block_min_interval"), false);
assert.equal(event.reasonCodes.includes("reinforcement_block_max_count"), false);
assert.equal(event.details?.blockReason, "min_elapsed_window");
assert.equal(event.details?.elapsedMs, 7 * 24 * 60 * 60 * 1000 - 60 * 60 * 1000);
assert.equal(event.details?.requiredElapsedMs, 7 * 24 * 60 * 60 * 1000);
assert.equal(event.details?.sameSession, true);
assert.equal(event.details?.attemptedAtMs, nowMs);
assert.equal(event.details?.lastReinforcedAtMs, lastReinforcedAt);
assert.equal(event.details?.reinforcementCount, 1);
assert.equal(event.details?.maxReinforcementCount, 6);
assert.equal("minIntervalMs" in (event.details ?? {}), false);
assert.equal("reinforcementMode" in (event.details ?? {}), false);
assert.equal(JSON.stringify(event.details).includes("blocked-reinforce-session"), false);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
+145 -37
View File
@@ -1,12 +1,15 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
BASE_HALF_LIFE_DAYS,
REINFORCEMENT_MAX_COUNT,
REINFORCEMENT_MIN_INTERVAL_MS,
tryReinforceMemory,
} from "../src/retention.ts";
import type { LongTermMemoryEntry } from "../src/types.ts";
const DAY_MS = 24 * 60 * 60 * 1000;
const ROLLING_WINDOW_MS = 7 * DAY_MS;
const baseMemory = (overrides: Partial<LongTermMemoryEntry> = {}): LongTermMemoryEntry => ({
id: "mem-retention",
type: "decision",
@@ -19,40 +22,77 @@ const baseMemory = (overrides: Partial<LongTermMemoryEntry> = {}): LongTermMemor
...overrides,
});
test("tryReinforceMemory blocks same session with exact reason", () => {
const now = Date.UTC(2026, 4, 12, 12, 0, 0);
test("tryReinforceMemory allows same session after rolling 8 days", () => {
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
const now = lastAt + 8 * DAY_MS;
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: now - 2 * REINFORCEMENT_MIN_INTERVAL_MS,
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
retentionClock: lastAt,
});
const decision = tryReinforceMemory(memory, "session-a", now);
assert.equal(decision.outcome, "reinforced");
assert.equal(decision.reinforcementMode, "increment");
assert.equal(decision.previousReinforcementCount, 1);
assert.equal(decision.newReinforcementCount, 2);
assert.equal(decision.memory.reinforcementCount, 2);
assert.equal(decision.memory.retentionClock, now);
assert.equal(decision.memory.lastReinforcedAt, now);
assert.equal(decision.memory.lastReinforcedSessionID, "session-a");
assert.equal(decision.sameSession, true);
assert.equal(decision.elapsedMs, 8 * DAY_MS);
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
});
test("tryReinforceMemory allows exactly 7 rolling days after last reinforcement", () => {
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
const now = lastAt + ROLLING_WINDOW_MS;
const memory = baseMemory({
reinforcementCount: 5,
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
retentionClock: lastAt,
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "reinforced");
assert.equal(decision.reinforcementMode, "increment");
assert.equal(decision.memory.reinforcementCount, 6);
assert.equal(decision.memory.retentionClock, now);
assert.equal(decision.memory.lastReinforcedAt, now);
assert.equal(decision.memory.lastReinforcedSessionID, "session-b");
assert.equal(decision.sameSession, false);
assert.equal(decision.elapsedMs, ROLLING_WINDOW_MS);
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
});
test("tryReinforceMemory blocks 7 rolling days minus 1ms as min_elapsed_window", () => {
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
const now = lastAt + ROLLING_WINDOW_MS - 1;
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-a", now);
assert.equal(decision.outcome, "blocked");
assert.equal(decision.blockReason, "same_session");
assert.equal(decision.blockReason, "min_elapsed_window");
assert.equal(decision.memory, memory);
});
test("tryReinforceMemory blocks different session on same UTC day", () => {
const lastAt = Date.UTC(2026, 4, 12, 0, 15, 0);
const now = Date.UTC(2026, 4, 12, 23, 30, 0);
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "blocked");
assert.equal(decision.blockReason, "same_utc_day");
assert.equal(decision.sameSession, true);
assert.equal(decision.lastReinforcedAt, lastAt);
assert.equal(decision.elapsedMs, ROLLING_WINDOW_MS - 1);
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
});
test("tryReinforceMemory blocks min interval across UTC day boundary", () => {
const lastAt = Date.UTC(2026, 4, 12, 23, 45, 0);
const now = Date.UTC(2026, 4, 13, 0, 15, 0);
test("tryReinforceMemory blocks different session below rolling window", () => {
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
const now = lastAt + 3 * DAY_MS;
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: lastAt,
@@ -62,42 +102,110 @@ test("tryReinforceMemory blocks min interval across UTC day boundary", () => {
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "blocked");
assert.equal(decision.blockReason, "min_interval");
assert.equal(decision.minIntervalMs, REINFORCEMENT_MIN_INTERVAL_MS);
assert.equal(decision.blockReason, "min_elapsed_window");
assert.equal(decision.sameSession, false);
assert.equal(decision.elapsedMs, 3 * DAY_MS);
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
});
test("tryReinforceMemory blocks max count with exact reason", () => {
const now = Date.UTC(2026, 4, 12, 12, 0, 0);
test("tryReinforceMemory blocks UTC midnight crossing below rolling window", () => {
const lastAt = Date.UTC(2026, 4, 12, 23, 45, 0);
const now = Date.UTC(2026, 4, 13, 2, 15, 0);
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "blocked");
assert.equal(decision.blockReason, "min_elapsed_window");
assert.notEqual(decision.blockReason, "same_utc_day");
assert.equal(decision.elapsedMs, now - lastAt);
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
});
test("tryReinforceMemory refreshes saturated count after rolling window", () => {
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
const now = lastAt + ROLLING_WINDOW_MS;
const memory = baseMemory({
reinforcementCount: REINFORCEMENT_MAX_COUNT,
lastReinforcedAt: Date.UTC(2026, 4, 10, 12, 0, 0),
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
retentionClock: lastAt,
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "reinforced");
assert.equal(decision.reinforcementMode, "refresh_only");
assert.equal(decision.previousReinforcementCount, REINFORCEMENT_MAX_COUNT);
assert.equal(decision.newReinforcementCount, REINFORCEMENT_MAX_COUNT);
assert.notEqual(decision.memory, memory);
assert.equal(decision.memory.reinforcementCount, REINFORCEMENT_MAX_COUNT);
assert.equal(decision.memory.lastReinforcedAt, now);
assert.equal(decision.memory.lastReinforcedSessionID, "session-b");
assert.equal(decision.memory.retentionClock, now);
assert.equal(decision.elapsedMs, ROLLING_WINDOW_MS);
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
});
test("tryReinforceMemory blocks saturated count below rolling window as min_elapsed_window", () => {
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
const now = lastAt + ROLLING_WINDOW_MS - 1;
const memory = baseMemory({
reinforcementCount: REINFORCEMENT_MAX_COUNT,
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "blocked");
assert.equal(decision.blockReason, "max_count");
assert.equal(decision.blockReason, "min_elapsed_window");
assert.notEqual(decision.blockReason, "max_count");
assert.equal(decision.reinforcementCount, REINFORCEMENT_MAX_COUNT);
assert.equal(decision.maxReinforcementCount, REINFORCEMENT_MAX_COUNT);
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
});
test("tryReinforceMemory reinforces allowed memory and wrapper returns memory only", () => {
test("tryReinforceMemory normalizes missing legacy timestamp while incrementing", () => {
const now = Date.UTC(2026, 4, 12, 12, 0, 0);
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: Date.UTC(2026, 4, 10, 12, 0, 0),
reinforcementCount: 2,
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "reinforced");
assert.equal(decision.previousReinforcementCount, 1);
assert.equal(decision.newReinforcementCount, 2);
assert.notEqual(decision.memory, memory);
assert.equal(decision.memory.reinforcementCount, 2);
assert.equal(decision.reinforcementMode, "increment");
assert.equal(decision.legacyMissingTimestamp, true);
assert.equal(decision.memory.reinforcementCount, 3);
assert.equal(decision.memory.lastReinforcedAt, now);
assert.equal(decision.memory.lastReinforcedSessionID, "session-b");
assert.equal(decision.memory.retentionClock, now);
});
test("tryReinforceMemory normalizes invalid legacy timestamp while refresh-only saturated", () => {
const now = Date.UTC(2026, 4, 12, 12, 0, 0);
const memory = baseMemory({
reinforcementCount: REINFORCEMENT_MAX_COUNT,
lastReinforcedAt: Number.NaN,
lastReinforcedSessionID: "session-a",
retentionClock: Date.UTC(2026, 4, 8, 12, 0, 0),
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "reinforced");
assert.equal(decision.reinforcementMode, "refresh_only");
assert.equal(decision.legacyMissingTimestamp, true);
assert.equal(decision.memory.reinforcementCount, REINFORCEMENT_MAX_COUNT);
assert.equal(decision.memory.lastReinforcedAt, now);
assert.equal(decision.memory.retentionClock, now);
});
test("BASE_HALF_LIFE_DAYS remains 45", () => {
assert.equal(BASE_HALF_LIFE_DAYS, 45);
});
+19 -2
View File
@@ -1,11 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { spawn } from "node:child_process";
import { readJSON, updateJSON } from "../src/storage.ts";
import { atomicWriteJSON, readJSON, updateJSON } from "../src/storage.ts";
import { queryEvidenceEvents } from "../src/evidence-log.ts";
import { workspaceMemoryPath } from "../src/paths.ts";
@@ -24,6 +24,23 @@ test("updateJSON serializes concurrent increments", async () => {
}
});
test("atomicWriteJSON is a full-state overwrite primitive", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-storage-atomic-overwrite-"));
try {
const path = join(root, "store.json");
await atomicWriteJSON(path, { retained: true, removed: true });
await atomicWriteJSON(path, { retained: true });
const raw = await readFile(path, "utf8");
assert.deepEqual(JSON.parse(raw), { retained: true });
assert.equal(raw.includes("removed"), false, "atomic overwrite should not merge with previous state");
assert.equal(existsSync(`${path}.lock`), false, "atomic overwrite should not create the RMW lock file");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("updateJSON does not replace corrupt JSON with fallback", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-storage-corrupt-"));
try {
+186 -41
View File
@@ -46,6 +46,8 @@ test("default prompt budgets use calibrated conservative character caps", () =>
});
test("retention type caps use v1.6 decision headroom without changing other caps", () => {
// Policy-contract characterization: these caps are intentionally brittle so
// retention policy changes must update the expected values deliberately.
assert.equal(RETENTION_TYPE_MAX.feedback, 10);
assert.equal(RETENTION_TYPE_MAX.decision, 12);
assert.equal(RETENTION_TYPE_MAX.project, 8);
@@ -72,6 +74,16 @@ function sleep(ms: number): Promise<void> {
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) */
function agedEntry(
id: string,
@@ -169,6 +181,41 @@ test("renderWorkspaceMemory returns empty for no entries", () => {
assert.equal(rendered, "");
});
test("renderWorkspaceMemory groups active entries in current prompt order", () => {
// Wave 2 characterization: lock the externally visible prompt grouping before
// any future memory-kind policy extraction or render-order refactor.
const now = "2026-05-15T12:00:00.000Z";
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root: "/repo", key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [
{ ...entry("mem-reference", "Docs live under docs/.", "reference"), createdAt: now, updatedAt: now },
{ ...entry("mem-decision", "Keep health waves behavior-preserving.", "decision"), createdAt: now, updatedAt: now },
{ ...entry("mem-project", "This repository uses Node's built-in test runner.", "project"), createdAt: now, updatedAt: now },
{ ...entry("mem-feedback", "Prefer concise verification summaries.", "feedback"), createdAt: now, updatedAt: now },
{ ...entry("mem-superseded", "Superseded entries stay out of prompts.", "feedback"), createdAt: now, updatedAt: now, status: "superseded" as const },
],
updatedAt: now,
lastActivityAt: now,
};
const rendered = renderWorkspaceMemory(store);
assert.equal(rendered, [
"Workspace memory (cross-session, verify if stale):",
"feedback:",
"- Prefer concise verification summaries.",
"project:",
"- This repository uses Node's built-in test runner.",
"decision:",
"- Keep health waves behavior-preserving.",
"reference:",
"- Docs live under docs/.",
].join("\n"));
assert.equal(rendered.includes("Superseded entries stay out of prompts."), false);
});
test("accountWorkspaceMemoryCompactionRefs returns empty prompt and refs for no entries", () => {
const store: WorkspaceMemoryStore = {
version: 1,
@@ -580,7 +627,7 @@ test("normalizeWorkspaceMemoryWithAccounting uses dormant workspace days for str
assert.deepEqual(result.kept.map(memory => memory.id), ["reinforced-old", "fresh"]);
});
test("reinforceMemory enforces session interval and max guards", () => {
test("reinforceMemory enforces rolling elapsed window and saturation refresh", () => {
const now = Date.UTC(2026, 3, 29);
const base = entry("reinforce", "Durable memory should reinforce only when gated");
const reinforced = tryReinforceMemory(base, "session-a", now).memory;
@@ -594,18 +641,26 @@ test("reinforceMemory enforces session interval and max guards", () => {
assert.equal(tryReinforceMemory(reinforced, "session-a", now + 2 * 60 * 60 * 1000).memory, reinforced);
assert.equal(tryReinforceMemory(reinforced, "session-b", now + 30 * 60 * 1000).memory, reinforced);
const reinforcedAfterWindow = tryReinforceMemory(reinforced, "session-a", now + 7 * DAY_MS).memory;
assert.notEqual(reinforcedAfterWindow, reinforced);
assert.equal(reinforcedAfterWindow.reinforcementCount, 2);
const atMax: LongTermMemoryEntry = {
...base,
reinforcementCount: 6,
lastReinforcedAt: now - 2 * 60 * 60 * 1000,
lastReinforcedAt: now - 7 * DAY_MS,
};
assert.equal(tryReinforceMemory(atMax, "session-c", now).memory, atMax);
const refreshedAtMax = tryReinforceMemory(atMax, "session-c", now).memory;
assert.notEqual(refreshedAtMax, atMax);
assert.equal(refreshedAtMax.reinforcementCount, 6);
assert.equal(refreshedAtMax.retentionClock, now);
});
test("reinforceMemory requires distinct UTC calendar days between reinforcements", () => {
test("reinforceMemory uses rolling elapsed window instead of UTC calendar days", () => {
const firstReinforcedAt = Date.UTC(2026, 3, 29, 0, 15);
const sameUtcDayMuchLater = Date.UTC(2026, 3, 29, 23, 30);
const nextUtcDayAfterInterval = Date.UTC(2026, 3, 30, 1, 30);
const afterWindow = firstReinforcedAt + 7 * DAY_MS;
const base: LongTermMemoryEntry = {
...entry("calendar-day-gated", "Reinforcement requires distinct UTC calendar days", "decision"),
reinforcementCount: 1,
@@ -614,13 +669,14 @@ test("reinforceMemory requires distinct UTC calendar days between reinforcements
};
assert.equal(tryReinforceMemory(base, "session-b", sameUtcDayMuchLater).memory, base);
assert.equal(tryReinforceMemory(base, "session-b", nextUtcDayAfterInterval).memory, base);
const reinforcedNextDay = tryReinforceMemory(base, "session-b", nextUtcDayAfterInterval).memory;
assert.notEqual(reinforcedNextDay, base);
assert.equal(reinforcedNextDay.reinforcementCount, 2);
assert.equal(reinforcedNextDay.lastReinforcedAt, nextUtcDayAfterInterval);
assert.equal(reinforcedNextDay.lastReinforcedSessionID, "session-b");
assert.equal(reinforcedNextDay.retentionClock, nextUtcDayAfterInterval);
const reinforcedAfterWindow = tryReinforceMemory(base, "session-b", afterWindow).memory;
assert.notEqual(reinforcedAfterWindow, base);
assert.equal(reinforcedAfterWindow.reinforcementCount, 2);
assert.equal(reinforcedAfterWindow.lastReinforcedAt, afterWindow);
assert.equal(reinforcedAfterWindow.lastReinforcedSessionID, "session-b");
assert.equal(reinforcedAfterWindow.retentionClock, afterWindow);
const atMax: LongTermMemoryEntry = {
...base,
@@ -716,64 +772,75 @@ test("reinforced memory with same initial strength and age ranks above unreinfor
assert.deepEqual(kept.map(memory => memory.id), ["reinforced", "unreinforced"]);
});
test("dedupe reinforcement does not increment for same session", () => {
const now = Date.now();
test("dedupe reinforcement allows same session after rolling 8 days with increment evidence", () => {
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
const existing: LongTermMemoryEntry = {
...entry("existing", "Use pnpm for package management", "decision"),
source: "manual",
pendingOwnerSessionID: "same-session",
reinforcementCount: 1,
lastReinforcedAt: now - 2 * 60 * 60 * 1000,
lastReinforcedAt: now - 8 * DAY_MS,
lastReinforcedSessionID: "same-session",
retentionClock: now - 8 * DAY_MS,
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate", "use pnpm for package management!!!", "decision"),
pendingOwnerSessionID: "same-session",
};
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
const retained = result.kept.find(memory => memory.id === "existing");
const reinforced = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "reinforced");
assert.ok(retained, "existing manual memory should be retained");
assert.equal(retained.reinforcementCount, 1);
assert.equal(retained.reinforcementCount, 2);
assert.equal(retained.lastReinforcedSessionID, "same-session");
assert.equal(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "reinforced"), false);
assert.ok(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "rejected" && event.details?.blockReason === "same_session"));
assert.equal(retained.retentionClock, now);
assert.ok(reinforced, "same-session after window should emit reinforced evidence");
assert.equal(reinforced.details?.reinforcementOutcome, "reinforced");
assert.equal(reinforced.details?.reinforcementMode, "increment");
assert.equal(reinforced.details?.sameSession, true);
assert.equal(reinforced.details?.elapsedMs, 8 * DAY_MS);
assert.equal(reinforced.details?.requiredElapsedMs, 7 * DAY_MS);
assert.equal(reinforced.details?.attemptedAtMs, now);
assert.equal(reinforced.details?.lastReinforcedAtMs, now - 8 * DAY_MS);
assert.equal(reinforced.details?.previousReinforcementCount, 1);
assert.equal(reinforced.details?.newReinforcementCount, 2);
assert.equal(JSON.stringify(reinforced.details).includes("same-session"), false);
});
test("dedupe blocked reinforcement emits exact block reason details", () => {
const now = Date.now();
test("dedupe reinforcement allows exactly 7 rolling days", () => {
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
const existing: LongTermMemoryEntry = {
...entry("existing-blocked", "Prefer deterministic consolidation accounting", "feedback"),
...entry("existing-exact-window", "Prefer deterministic consolidation accounting", "feedback"),
source: "manual",
reinforcementCount: 1,
lastReinforcedAt: now - 30 * 60 * 1000,
reinforcementCount: 5,
lastReinforcedAt: now - 7 * DAY_MS,
lastReinforcedSessionID: "old-session",
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate-blocked", "prefer deterministic consolidation accounting!!!", "feedback"),
...entry("duplicate-exact-window", "prefer deterministic consolidation accounting!!!", "feedback"),
pendingOwnerSessionID: "new-session",
};
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
const blocked = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "rejected");
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
const retained = result.kept.find(memory => memory.id === "existing-exact-window");
const reinforced = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "reinforced");
assert.ok(blocked, "blocked duplicate reinforcement should emit diagnostic evidence");
assert.ok(blocked.reasonCodes.includes("reinforcement_window_blocked"));
assert.ok(blocked.reasonCodes.includes("reinforcement_block_min_interval"));
assert.equal(blocked.details?.blockReason, "min_interval");
assert.equal(blocked.details?.reinforcementCount, 1);
assert.equal(blocked.details?.maxReinforcementCount, 6);
assert.equal(blocked.details?.minIntervalMs, 60 * 60 * 1000);
assert.equal(retained?.reinforcementCount, 6);
assert.ok(reinforced, "exact 7-day window should emit reinforced evidence");
assert.equal(reinforced.details?.reinforcementMode, "increment");
assert.equal(reinforced.details?.elapsedMs, 7 * DAY_MS);
assert.equal(reinforced.details?.requiredElapsedMs, 7 * DAY_MS);
});
test("dedupe reinforcement does not increment under one hour", () => {
const now = Date.now();
test("dedupe reinforcement blocks 7 rolling days minus 1ms with elapsed details", () => {
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
const existing: LongTermMemoryEntry = {
...entry("existing", "Prefer deterministic consolidation accounting", "feedback"),
source: "manual",
reinforcementCount: 1,
lastReinforcedAt: now - 30 * 60 * 1000,
lastReinforcedAt: now - 7 * DAY_MS + 1,
lastReinforcedSessionID: "old-session",
};
const duplicate: LongTermMemoryEntry = {
@@ -781,31 +848,109 @@ test("dedupe reinforcement does not increment under one hour", () => {
pendingOwnerSessionID: "new-session",
};
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
const retained = result.kept.find(memory => memory.id === "existing");
const blocked = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "rejected");
assert.ok(retained, "existing manual memory should be retained");
assert.equal(retained.reinforcementCount, 1);
assert.equal(retained.lastReinforcedSessionID, "old-session");
assert.equal(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "reinforced"), false);
assert.ok(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "rejected" && event.details?.blockReason === "min_interval"));
assert.ok(blocked, "below-window duplicate reinforcement should emit rejected evidence");
assert.ok(blocked.reasonCodes.includes("reinforcement_window_blocked"));
assert.ok(blocked.reasonCodes.includes("reinforcement_block_min_elapsed_window"));
assert.equal(blocked.reasonCodes.includes("reinforcement_block_min_interval"), false);
assert.equal(blocked.details?.blockReason, "min_elapsed_window");
assert.equal(blocked.details?.sameSession, false);
assert.equal(blocked.details?.elapsedMs, 7 * DAY_MS - 1);
assert.equal(blocked.details?.requiredElapsedMs, 7 * DAY_MS);
assert.equal(blocked.details?.reinforcementCount, 1);
assert.equal(blocked.details?.maxReinforcementCount, 6);
assert.equal("minIntervalMs" in (blocked.details ?? {}), false);
assert.equal("reinforcementMode" in (blocked.details ?? {}), false);
});
test("dedupe reinforcement does not emit evidence at max reinforcement count", () => {
test("dedupe reinforcement blocks same session at 6d23h as min elapsed window", () => {
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
const existing: LongTermMemoryEntry = {
...entry("existing-same-session-window", "Prefer deterministic consolidation accounting", "feedback"),
source: "manual",
pendingOwnerSessionID: "same-session-window",
reinforcementCount: 1,
lastReinforcedAt: now - (7 * DAY_MS - 60 * 60 * 1000),
lastReinforcedSessionID: "same-session-window",
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate-same-session-window", "prefer deterministic consolidation accounting!!!", "feedback"),
pendingOwnerSessionID: "same-session-window",
};
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
const blocked = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "rejected");
assert.ok(blocked, "6d23h same-session attempt should be rejected");
assert.equal(blocked.details?.blockReason, "min_elapsed_window");
assert.equal(blocked.details?.elapsedMs, 7 * DAY_MS - 60 * 60 * 1000);
assert.equal(blocked.details?.requiredElapsedMs, 7 * DAY_MS);
assert.equal(blocked.details?.sameSession, true);
assert.equal(blocked.reasonCodes.includes("reinforcement_block_same_session"), false);
});
test("dedupe reinforcement refreshes saturated count after rolling window", () => {
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
const oldRetentionClock = now - 30 * DAY_MS;
const existing: LongTermMemoryEntry = {
...entry("existing-max", "Prefer deterministic consolidation accounting", "feedback"),
source: "manual",
reinforcementCount: 6,
lastReinforcedAt: now - 7 * DAY_MS,
lastReinforcedSessionID: "old-session",
retentionClock: oldRetentionClock,
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate-max", "prefer deterministic consolidation accounting!!!", "feedback"),
pendingOwnerSessionID: "new-session",
};
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
const retained = result.kept.find(memory => memory.id === "existing-max");
const refreshed = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "reinforced");
assert.equal(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "reinforced"), false);
assert.ok(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "rejected" && event.details?.blockReason === "max_count"));
assert.equal(retained?.reinforcementCount, 6);
assert.equal(retained?.retentionClock, now);
assert.equal(retained?.lastReinforcedAt, now);
assert.equal(retained?.lastReinforcedSessionID, "new-session");
assert.ok(refreshed, "saturated after-window attempt should emit refreshed evidence");
assert.equal(refreshed.details?.reinforcementOutcome, "refreshed");
assert.equal(refreshed.details?.reinforcementMode, "refresh_only");
assert.equal(refreshed.details?.previousReinforcementCount, 6);
assert.equal(refreshed.details?.newReinforcementCount, 6);
assert.ok(refreshed.reasonCodes.includes("reinforcement_saturation_refresh"));
assert.equal(refreshed.reasonCodes.includes("reinforcement_block_max_count"), false);
});
test("dedupe reinforcement reports legacy missing timestamp and normalizes it", () => {
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
const existing: LongTermMemoryEntry = {
...entry("existing-legacy-missing", "Prefer deterministic consolidation accounting", "feedback"),
source: "manual",
reinforcementCount: 2,
lastReinforcedSessionID: "old-session",
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate-legacy-missing", "prefer deterministic consolidation accounting!!!", "feedback"),
pendingOwnerSessionID: "new-session",
};
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
const retained = result.kept.find(memory => memory.id === "existing-legacy-missing");
const reinforced = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "reinforced");
assert.equal(retained?.reinforcementCount, 3);
assert.equal(retained?.lastReinforcedAt, now);
assert.equal(retained?.retentionClock, now);
assert.equal(reinforced?.details?.legacyMissingTimestamp, true);
assert.equal(reinforced?.details?.reinforcementMode, "increment");
});
test("enforceLongTermLimits orders entries by retention strength", () => {
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noUnusedLocals": true,
"noUnusedParameters": true
}
}