Compare commits

...

2 Commits

Author SHA1 Message Date
Ralph Chang 01bda7c134 fix(memory): freeze hot session prompt epoch 2026-05-20 12:38:16 +08:00
Ralph Chang 041115c173 chore(code-health): prepare v1.6.5 2026-05-19 15:05:48 +08:00
29 changed files with 1162 additions and 341 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.6] - 2026-05-20
### Changed
- Froze hot session state with the existing prompt-epoch model to reduce pre-history prompt churn for better prefix KV-cache reuse.
- Switched frozen prompt cache pressure eviction to recency-aware tracking.
- Updated README and architecture docs for the new frozen hot snapshot behavior.
### Fixed
- Fixed KV prefix-cache instability caused by per-turn hot session prompt changes.
### Thanks
- Thanks to @nilo85 for opening PR #5 and surfacing the local-LLM KV cache hit-rate issue that led to this release.
## [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
+5 -5
View File
@@ -132,22 +132,22 @@ OpenCode Working Memory adds durable memory without making extra LLM/API calls.
┌──────────────────────────────────────┐
│ ⚡ Prompt Context │
│ system[1]*: frozen workspace memory │
│ system[2+]*: hot session state
│ system[2+]*: frozen hot snapshot
└──────────────────────────────────────┘
```
\* Conceptually, workspace memory is pushed first when it is non-empty, and hot session state is pushed after workspace memory. If workspace memory is empty, hot state may be the first plugin-added system message. Actual `system[]` indices also depend on OpenCode and other plugins, so `system[1]` / `system[2+]` is a simplified model.
\* Conceptually, frozen workspace memory is pushed first when it is non-empty, and the frozen hot snapshot is pushed after workspace memory. If workspace memory is empty, the hot snapshot may be the first plugin-added system message. Actual `system[]` indices also depend on OpenCode and other plugins, so `system[1]` / `system[2+]` is a simplified model.
**Zero extra API calls:** OpenCode Working Memory does not call the model on its own. Memory extraction is folded into OpenCode's built-in compaction request.
**Cache-friendly layout:** durable workspace memory is rendered as a stable frozen snapshot for the session, while fast-changing hot session state is appended separately. Compaction starts a new cache epoch, refreshing the workspace snapshot after pending memories are promoted.
**Cache-friendly layout:** durable workspace memory and hot session state are rendered as separate frozen prompts that share the same epoch lifecycle. Hot state is an epoch-start snapshot: active files and open errors can change after it is created, and the conversation/tool transcript is the source of truth for newer events. The plugin intentionally does not invalidate the hot snapshot on active-file, open-error, recent-decision, or pending-memory changes because doing so would defeat prefix KV-cache reuse. Explicit pending memories remain durable and promote safely at compaction, but after the current epoch caches exist they do not force a prompt refresh.
The runtime context has three layers:
| Layer | Purpose | Lifetime |
|---|---|---|
| Workspace Memory | Durable decisions, preferences, project facts, references | Cross-session |
| Hot Session State | Active files, open errors, recent context | Current session |
| Hot Session State | Active files, open errors, recent context, pending memories | Current session storage; frozen prompt refreshes at epoch boundaries |
| Native OpenCode State | Todos and built-in state | OpenCode-managed |
## Workspace Memory
@@ -261,7 +261,7 @@ Default behavior:
- Workspace memory budget: 3600 characters (~900 tokens)
- Workspace memory limit: 28 entries
- Hot session state budget: 700 characters (~175 tokens)
- Hot session state budget: 700 characters (~175 tokens) per frozen hot snapshot
- Active files shown: 8
- Open errors shown: 3
+66
View File
@@ -1,5 +1,71 @@
# Release Notes
## 1.6.6 (2026-05-20)
### KV Cache Stability
This patch release reduces pre-history prompt churn by freezing hot session state with the existing prompt-epoch model, improving prefix KV-cache reuse for local LLMs.
Thanks to @nilo85 for opening PR #5 and surfacing the cache hit-rate issue.
### What Changed
- Hot session state now uses a frozen epoch snapshot instead of changing on every normal turn.
- Frozen prompt caches use recency-aware cache pressure eviction.
- The hot-state prompt now labels itself as an epoch snapshot so conversation/tool history remains the source of truth for newer events.
### Upgrade Notes
- No configuration changes are required.
- Existing workspace memory files, session state files, and evidence logs remain compatible.
### Validation
- `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/session-state.test.ts` — 14 tests passing
- `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/plugin.test.ts` — 67 tests passing
- `npm run typecheck``TYPECHECK_PASS`
- `npm test` — 509 tests passing, `TEST_PASS`
---
## 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
+17 -7
View File
@@ -18,7 +18,8 @@ OpenCode Working Memory implements a **three-layer memory architecture** designe
│ LAYER 2: HOT SESSION STATE (Short-term, per-session) │
│ • Session-scoped tracking: active files, open errors │
│ • Storage: sessions/{sessionID}.json │
│ • Auto-extracted from tool usage patterns
│ • Frozen prompt snapshot shares the workspace epoch
│ • Auto-extracted from tool usage and explicit remembers │
│ • Cleared: on new session start │
└─────────────────────────────────────────────────────────────┘
@@ -182,6 +183,9 @@ Track current session context automatically:
- What files are you working on?
- What errors are currently open?
- What decisions were made recently?
- Which explicit memories are pending promotion?
Hot session state is stored continuously during a session, but it is not rendered as a per-turn dynamic prompt. The prompt layer uses a frozen hot snapshot created or refreshed at the same epoch boundary as frozen workspace memory. Active files and open errors are current at epoch boundaries, not on every normal turn. After epoch start, the conversation/tool transcript is the source of truth for newer events.
### Storage
@@ -195,7 +199,8 @@ Track current session context automatically:
updatedAt: string,
activeFiles: ActiveFile[],
openErrors: OpenError[],
recentDecisions: SessionDecision[]
recentDecisions: SessionDecision[],
pendingMemories: LongTermMemoryEntry[]
}
```
@@ -242,12 +247,17 @@ Short-term decisions made this session. Candidates for promotion to workspace me
### System Prompt Injection
Hot session state is injected after workspace memory:
Workspace memory and hot session state are separate cached prompt layers that share a prompt epoch lifecycle:
```text
system[1]*: frozen workspace memory
system[2+]*: frozen hot snapshot
```
The hot state example below is included in a frozen hot snapshot when the epoch is created or refreshed, not rendered again on every normal turn. Active files and open errors are current at epoch boundaries, not on every normal turn; the plugin intentionally does not invalidate the hot snapshot on active-file or open-error changes because doing so would defeat prefix KV-cache reuse. Explicit pending memories persist in session state and the pending journal, then promote safely at compaction; once the current epoch caches exist, new pending memories do not force pre-history prompt refresh. After epoch start, the conversation/tool transcript is the source of truth for newer events.
```
---
Hot session state (current session):
Hot session state snapshot (epoch start; conversation history may be newer):
active_files:
- src/plugin.ts (edit, 18x)
@@ -280,7 +290,7 @@ OpenCode Working Memory hooks into OpenCode lifecycle events:
### `experimental.chat.system.transform`
Injects workspace memory and hot session state into system prompt.
Injects cached frozen workspace memory and cached frozen hot snapshot prompts into the system prompt. Normal tool/user churn updates storage but does not mutate these pre-history prompts until a new epoch starts.
### `tool.execute.after`
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "opencode-working-memory",
"version": "1.6.4",
"version": "1.6.6",
"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();
}
+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";
+32 -231
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",
@@ -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);
+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> {
+110 -43
View File
@@ -3,21 +3,24 @@
*
* Architecture:
* - Layer 1: Stable Workspace Memory (frozen per session cache epoch, refreshed at compaction)
* - Layer 2: Hot Session State (active files, open errors, recent decisions, pending memories)
* - Layer 2: Frozen Hot Session State (active files, open errors, recent decisions, pending memories)
* - Layer 3: Native OpenCode State (todos owned by OpenCode, read during compaction)
*
* Cache Epoch Model:
* - Each session creates a frozen workspace memory snapshot on first transform.
* - Normal turns reuse the exact rendered string (system[1] remains stable).
* - Compaction starts a new cache epoch: pending memories are promoted, the cache is cleared,
* and the next transform re-renders workspace memory.
* - Each session creates frozen workspace memory and hot session snapshots on first transform.
* - Normal turns reuse the exact rendered strings (pre-history system prompts remain stable).
* - Normal tool/user churn updates session storage but does not mutate pre-history prompts
* until compaction, session restart, or process restart starts a new epoch; conversation
* and tool history are the source of truth for newer events after epoch start.
* - Compaction starts a new cache epoch: pending memories are promoted, caches are cleared,
* and the next transform re-renders workspace memory and hot session state.
* - Explicit memory ("remember X") goes to SessionState.pendingMemories + durable journal,
* visible in ephemeral system[2+] for the current epoch, promoted to system[1] after compaction.
* visible in the hot snapshot only if processed before epoch creation, promoted after compaction.
*
* This plugin:
* - Caches frozen workspace memory per sessionID
* - Caches frozen workspace memory and hot session state per sessionID epoch
* - Processes explicit memory from latest user text once per message id
* - Injects frozen workspace memory and dynamic hot session state into system prompt
* - Injects frozen workspace memory and frozen hot session state into system prompt
* - Updates session state after tool execution
* - Augments compaction context with numbered memory refs, todos, and instruction
* - Parses compaction summaries for memory candidates and merges them
@@ -37,7 +40,6 @@ import {
import { assessMemoryQuality } from "./memory-quality.ts";
import {
loadWorkspaceMemory,
updateWorkspaceMemory,
updateWorkspaceMemoryWithAccounting,
accountWorkspaceMemoryRender,
accountWorkspaceMemoryCompactionRefs,
@@ -259,6 +261,18 @@ export const MemoryV2Plugin: Plugin = async (input) => {
store: Awaited<ReturnType<typeof loadWorkspaceMemory>>;
renderedPrompt: string;
loadedAt: number;
lastAccessedAt: number;
}
>();
// Cache for frozen hot session state per session epoch.
// Lifecycle is unified with frozenWorkspaceMemoryCache; do not clear independently.
const frozenHotSessionStateCache = new Map<
string,
{
renderedPrompt: string;
loadedAt: number;
lastAccessedAt: number;
}
>();
@@ -540,18 +554,39 @@ export const MemoryV2Plugin: Plugin = async (input) => {
})));
}
function pruneFrozenWorkspaceMemoryCache(now = Date.now()): void {
function clearFrozenPromptEpoch(sessionID: string): void {
frozenWorkspaceMemoryCache.delete(sessionID);
frozenHotSessionStateCache.delete(sessionID);
}
function pruneFrozenPromptEpochCaches(): void {
const lastAccessedAtBySession = new Map<string, number>();
for (const [sessionID, cached] of frozenWorkspaceMemoryCache) {
if (now - cached.loadedAt > WORKSPACE_MEMORY_CACHE_LIMITS.frozenTtlMs) {
frozenWorkspaceMemoryCache.delete(sessionID);
}
lastAccessedAtBySession.set(
sessionID,
Math.max(lastAccessedAtBySession.get(sessionID) ?? cached.lastAccessedAt, cached.lastAccessedAt),
);
}
for (const [sessionID, cached] of frozenHotSessionStateCache) {
lastAccessedAtBySession.set(
sessionID,
Math.max(lastAccessedAtBySession.get(sessionID) ?? cached.lastAccessedAt, cached.lastAccessedAt),
);
}
while (frozenWorkspaceMemoryCache.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxFrozenSessions) {
const oldest = [...frozenWorkspaceMemoryCache.entries()]
.sort((a, b) => a[1].loadedAt - b[1].loadedAt)[0]?.[0];
if (!oldest) break;
frozenWorkspaceMemoryCache.delete(oldest);
const sorted = [...lastAccessedAtBySession.entries()].sort((a, b) => a[1] - b[1]);
while (lastAccessedAtBySession.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxFrozenSessions) {
const [oldestSessionID] = sorted.shift() ?? [];
if (!oldestSessionID) break;
lastAccessedAtBySession.delete(oldestSessionID);
clearFrozenPromptEpoch(oldestSessionID);
}
for (const sessionID of frozenWorkspaceMemoryCache.keys()) {
if (!lastAccessedAtBySession.has(sessionID)) frozenWorkspaceMemoryCache.delete(sessionID);
}
for (const sessionID of frozenHotSessionStateCache.keys()) {
if (!lastAccessedAtBySession.has(sessionID)) frozenHotSessionStateCache.delete(sessionID);
}
}
@@ -767,7 +802,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
});
return state;
});
clearFrozenWorkspaceMemoryCache(sessionID);
clearFrozenPromptEpoch(sessionID);
}
if (accounting.clearableKeys.size > 0) {
@@ -812,13 +847,13 @@ export const MemoryV2Plugin: Plugin = async (input) => {
renderedPrompt: string;
}> {
const now = Date.now();
pruneFrozenWorkspaceMemoryCache(now);
const cached = frozenWorkspaceMemoryCache.get(sessionID);
// Cache is valid for the current session cache epoch.
// It is intentionally invalidated after compaction so promoted memories
// become visible in the next compacted context (new epoch starts).
if (cached) {
cached.lastAccessedAt = now;
return { store: cached.store, renderedPrompt: cached.renderedPrompt };
}
@@ -829,16 +864,42 @@ export const MemoryV2Plugin: Plugin = async (input) => {
...event,
sessionHash: sessionID,
})));
frozenWorkspaceMemoryCache.set(sessionID, { store, renderedPrompt, loadedAt: now });
pruneFrozenWorkspaceMemoryCache(now);
frozenWorkspaceMemoryCache.set(sessionID, { store, renderedPrompt, loadedAt: now, lastAccessedAt: now });
pruneFrozenPromptEpochCaches();
return { store, renderedPrompt };
}
/**
* Clear frozen workspace memory cache (e.g., after compaction).
* Get frozen hot session state snapshot for a session.
* Loads and renders from disk once per prompt epoch, then reuses the exact rendered string.
*/
function clearFrozenWorkspaceMemoryCache(sessionID: string): void {
frozenWorkspaceMemoryCache.delete(sessionID);
async function getFrozenHotSessionStateSnapshot(
root: string,
sessionID: string,
): Promise<{ renderedPrompt: string }> {
const now = Date.now();
const cached = frozenHotSessionStateCache.get(sessionID);
if (cached) {
cached.lastAccessedAt = now;
return { renderedPrompt: cached.renderedPrompt };
}
const sessionState = await loadSessionState(root, sessionID);
const renderedPrompt = renderHotSessionState(sessionState, root);
frozenHotSessionStateCache.set(sessionID, { renderedPrompt, loadedAt: now, lastAccessedAt: now });
pruneFrozenPromptEpochCaches();
return { renderedPrompt };
}
async function promoteUnownedBacklogForEpochSnapshot(sessionID: string): Promise<void> {
if (frozenWorkspaceMemoryCache.has(sessionID) || frozenHotSessionStateCache.has(sessionID)) return;
if (!await hasPendingJournalEntries(directory)) return;
try {
await promotePendingMemories(undefined, { includeUnownedJournal: true, includeOwnedJournal: false });
} catch (error) {
await warnMemoryHook("chat.system.transform.promote_unowned", error, directory);
}
}
function sessionIDFromEventProperties(properties: unknown): string | undefined {
@@ -854,13 +915,13 @@ export const MemoryV2Plugin: Plugin = async (input) => {
}
return {
// Inject workspace memory and hot session state into system prompt
// Inject frozen workspace memory and frozen hot session state into system prompt
"experimental.chat.system.transform": async (hookInput, output) => {
const { sessionID } = hookInput;
if (!sessionID) return;
try {
pruneFrozenWorkspaceMemoryCache();
pruneFrozenPromptEpochCaches();
pruneProcessedUserMessagesCache();
// Sub-agents are short-lived - skip memory system
@@ -870,28 +931,33 @@ export const MemoryV2Plugin: Plugin = async (input) => {
// sub-agent guard so child sessions never append to the parent journal.
await processLatestUserMessage(sessionID);
// Before first snapshot in this session, promote durable unowned backlog from
// prior sessions. Current-turn owned explicit memory remains pending and only
// appears in hot state for this transform.
if (!frozenWorkspaceMemoryCache.has(sessionID) && await hasPendingJournalEntries(directory)) {
await promotePendingMemories(undefined, { includeUnownedJournal: true, includeOwnedJournal: false });
// Before first snapshot in this session, best-effort promote durable
// unowned backlog from prior sessions. Current-turn owned explicit memory
// remains pending and appears in hot state only if the epoch snapshot is new.
await promoteUnownedBacklogForEpochSnapshot(sessionID);
let workspaceSnapshot: Awaited<ReturnType<typeof getFrozenWorkspaceMemorySnapshot>> | undefined;
try {
workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
} catch (error) {
await warnMemoryHook("chat.system.transform.workspace_snapshot", error, directory);
}
// Get frozen workspace memory snapshot (loaded and rendered once per session)
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
// Get current hot session state
const sessionState = await loadSessionState(directory, sessionID);
let hotSnapshot: Awaited<ReturnType<typeof getFrozenHotSessionStateSnapshot>> | undefined;
try {
hotSnapshot = await getFrozenHotSessionStateSnapshot(directory, sessionID);
} catch (error) {
await warnMemoryHook("chat.system.transform.hot_snapshot", error, directory);
}
// Inject frozen workspace memory snapshot
if (workspaceSnapshot.renderedPrompt) {
if (workspaceSnapshot?.renderedPrompt) {
output.system.push(workspaceSnapshot.renderedPrompt);
}
// Render and inject hot session state
const hotPrompt = renderHotSessionState(sessionState, directory);
if (hotPrompt) {
output.system.push(hotPrompt);
// Inject frozen hot session state snapshot
if (hotSnapshot?.renderedPrompt) {
output.system.push(hotSnapshot.renderedPrompt);
}
} catch (error) {
await warnMemoryHook("chat.system.transform", error, directory);
@@ -1061,6 +1127,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
await promotePendingMemories(sessionID, { includeUnownedJournal: true });
} finally {
await clearCompactionMemoryRefs(sessionID);
clearFrozenPromptEpoch(sessionID);
}
} catch (error) {
// Keep pending memories in session/journal for retry on next event/session.
@@ -1078,7 +1145,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
await promotePendingMemories(sessionID, { includeOwnedJournal: true, includeUnownedJournal: false });
promoted = true;
if (promoted) {
frozenWorkspaceMemoryCache.delete(sessionID);
clearFrozenPromptEpoch(sessionID);
processedUserMessages.delete(sessionID);
sessionParentCache.delete(sessionID);
}
+2 -1
View File
@@ -44,7 +44,8 @@ export const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
export const REINFORCEMENT_MAX_COUNT = 6;
export const DAY_MS = 24 * 60 * 60 * 1000;
export const REINFORCEMENT_MIN_ELAPSED_MS = 7 * DAY_MS;
export const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // Deprecated compatibility constant; new policy uses REINFORCEMENT_MIN_ELAPSED_MS.
/** @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;
+1 -1
View File
@@ -258,7 +258,7 @@ type HotStateRenderSection = {
items: HotStateRenderItem[];
};
const HOT_STATE_PREFIX = "Hot session state (current session):";
const HOT_STATE_PREFIX = "Hot session state snapshot (epoch start; conversation history may be newer):";
export function accountHotSessionStateRender(state: SessionState, workspaceRoot: string): HotSessionStateRenderAccounting {
const maxRenderedChars = HOT_STATE_LIMITS.maxRenderedChars;
+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;
+3 -11
View File
@@ -15,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;
@@ -933,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;
}
@@ -993,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;
@@ -1037,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 {
+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",
);
});
+383 -15
View File
@@ -8,7 +8,7 @@ import { loadSessionState, saveSessionState } from "../src/session-state.ts";
import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
import type { CompactionMemoryRef, LongTermMemoryEntry, OpenError } from "../src/types.ts";
import { PROMOTION_RETRY_LIMITS, WORKSPACE_MEMORY_CACHE_LIMITS } from "../src/types.ts";
import { sessionStatePath, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
import { sessionStatePath, workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
import { loadPendingJournal, savePendingJournal, memoryKey } from "../src/pending-journal.ts";
import { loadWorkspaceMemory, updateWorkspaceMemory, workspaceMemoryExactKey, workspaceMemoryIdentityKey } from "../src/workspace-memory.ts";
import { queryEvidenceEvents } from "../src/evidence-log.ts";
@@ -569,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
@@ -756,6 +758,46 @@ test("explicit memory appended from user message is owned by session and not pro
}
});
test("explicit memory before first cache miss appears in frozen hot snapshot", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
let latestMessages: Array<Record<string, unknown>> = [{
info: { role: "user", id: "msg-before-cache-1" },
parts: [{ type: "text", text: "remember this: First epoch captures pending memory." }],
}];
const client = {
session: {
get: async () => ({ data: { parentID: null } }),
messages: async () => ({ data: latestMessages }),
todo: async () => ({ data: [] }),
},
};
const plugin = await MemoryV2Plugin({ directory: tmpDir, client });
const output = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "first-cache-explicit-session", model: {} },
output,
);
const joined = output.system.join("\n");
assert.match(joined, /Hot session state/);
assert.match(joined, /pending_memories:/);
assert.match(joined, /First epoch captures pending memory/);
const state = await loadSessionState(tmpDir, "first-cache-explicit-session");
assert.ok(
state.pendingMemories.some(memory => /First epoch captures pending memory/.test(memory.text)),
"first-turn explicit memory should remain durable in session pending state",
);
latestMessages = [];
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("session promotion does not clear another session's same-key pending journal entry", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
@@ -878,12 +920,21 @@ test("session.deleted clears caches even when session state file is already gone
});
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "deleted-missing-state-session",
args: { filePath: join(tmpDir, "src/delete-before.ts") },
},
{ output: "", exitCode: 0 },
);
const beforeOutput = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "deleted-missing-state-session", model: {} },
beforeOutput,
);
assert.match(beforeOutput.system.join("\n"), /Workspace memory before delete cleanup/);
assert.match(beforeOutput.system.join("\n"), /src\/delete-before\.ts/);
const ownedPending = {
id: "mem_delete_owned_journal",
@@ -915,6 +966,15 @@ test("session.deleted clears caches even when session state file is already gone
assert.equal(pendingAfter.entries.length, 0,
"clearable owned journal entry should be removed even when session state file is absent");
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "deleted-missing-state-session",
args: { filePath: join(tmpDir, "src/delete-after.ts") },
},
{ output: "", exitCode: 0 },
);
const afterOutput = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "deleted-missing-state-session", model: {} },
@@ -923,6 +983,11 @@ test("session.deleted clears caches even when session state file is already gone
const workspacePrompt = afterOutput.system.find((part: string) => part.startsWith("Workspace memory"));
assert.match(workspacePrompt ?? "", /Owned journal memory promotes during delete cleanup/,
"session.deleted should clear frozen cache after successful promotion");
const afterJoined = afterOutput.system.join("\n");
assert.match(afterJoined, /src\/delete-after\.ts/,
"session.deleted should clear the paired frozen hot cache after successful promotion");
assert.equal(afterJoined.includes("src/delete-before.ts"), false,
"new epoch after session deletion should not reuse the deleted session's hot snapshot");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
@@ -2350,7 +2415,64 @@ test("integration: next session promotes prior unowned journal and leaves journa
}
});
test("same-session explicit memory does not mutate frozen system[1]", async () => {
test("unowned pending promotion failure does not block epoch snapshot creation", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
let lockPath: string | undefined;
try {
const now = new Date().toISOString();
await updateWorkspaceMemory(tmpDir, store => {
store.entries.push({
id: "mem_existing_promotion_failure",
type: "project",
text: "Existing stable workspace memory survives promotion failure.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: now,
updatedAt: now,
});
return store;
});
await savePendingJournal(tmpDir, {
version: 1,
workspace: { root: tmpDir, key: await workspaceKey(tmpDir) },
updatedAt: now,
entries: [{
id: "mem_unowned_lock_failure",
type: "feedback",
text: "unowned pending memory text should not be treated as promoted after lock failure.",
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
}],
});
const workspacePath = await workspaceMemoryPath(tmpDir);
lockPath = `${workspacePath}.lock`;
await mkdir(dirname(workspacePath), { recursive: true });
await writeFile(lockPath, `${process.pid}\n${Date.now()}\n`);
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
const output = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "promotion-failure-session", model: {} },
output,
);
const joined = output.system.join("\n");
assert.match(joined, /Existing stable workspace memory survives promotion failure|Hot session state/);
assert.equal(joined.includes("unowned pending memory text"), false,
"failed unowned backlog promotion should not be silently treated as promoted");
} finally {
if (lockPath) await rm(lockPath, { force: true });
await rm(tmpDir, { recursive: true, force: true });
}
});
test("same-session explicit memory after epoch creation persists without refreshing frozen prompts", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
@@ -2392,10 +2514,10 @@ test("same-session explicit memory does not mutate frozen system[1]", async () =
assert.match(firstSystem1 ?? "", /Existing stable workspace memory/,
"first transform should create a frozen workspace memory system[1]");
// 3. User says "remember X" in the same session.
// 3. User says "remember X" in the same session after the epoch exists.
latestMessages = [{
info: { role: "user", id: "msg-explicit-1" },
parts: [{ type: "text", text: "remember this: Same-session memory stays ephemeral." }],
parts: [{ type: "text", text: "remember this: Same-session memory stays durable but frozen prompt does not refresh." }],
}];
const output2 = { system: ["base header"] };
@@ -2404,25 +2526,28 @@ test("same-session explicit memory does not mutate frozen system[1]", async () =
output2,
);
// 4. Assert: workspace system[1] unchanged (frozen snapshot).
// 4. Assert: all plugin-added pre-history prompts are unchanged.
const secondSystem1 = output2.system.find((part: string) => part.startsWith("Workspace memory"));
assert.equal(secondSystem1, firstSystem1,
"frozen system[1] must not change after explicit memory in same session");
"frozen workspace prompt should remain unchanged after explicit memory in same session");
assert.deepEqual(output2.system, output1.system,
"same-session explicit memory after epoch creation must not mutate pre-history prompts");
// 5. Assert: hot state (system[2+]) contains the pending memory.
const hotState = output2.system.find((part: string) => part.includes("Hot session state"));
assert.ok(hotState, "hot session state should be rendered");
assert.match(hotState, /pending_memories:/,
"hot state should contain pending_memories section");
assert.match(hotState, /Same-session memory stays ephemeral/,
"hot state should contain the explicit memory text");
// 5. Assert: explicit memory remains durable without forcing prompt refresh.
const state = await loadSessionState(tmpDir, "frozen-cache-session");
assert.ok(
state.pendingMemories.some(memory => /Same-session memory stays durable/.test(memory.text)),
"explicit memory should remain durable in session pending state",
);
assert.equal(output2.system.join("\n").includes("Same-session memory stays durable"), false,
"new pending memory is already in conversation history and should not force system prompt refresh");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("chat system transform reloads frozen workspace snapshot after cache TTL expires", async () => {
test("chat system transform does not reload frozen epoch snapshots after TTL time passes", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
const originalNow = Date.now;
let now = originalNow();
@@ -2445,12 +2570,21 @@ test("chat system transform reloads frozen workspace snapshot after cache TTL ex
});
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "ttl-session",
args: { filePath: join(tmpDir, "src/before-ttl.ts") },
},
{ output: "", exitCode: 0 },
);
const output1 = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "ttl-session", model: {} },
output1,
);
assert.match(output1.system.join("\n"), /Workspace memory before TTL expiry/);
assert.match(output1.system.join("\n"), /src\/before-ttl\.ts/);
await updateWorkspaceMemory(tmpDir, store => {
store.entries.push({
@@ -2468,13 +2602,228 @@ test("chat system transform reloads frozen workspace snapshot after cache TTL ex
now += WORKSPACE_MEMORY_CACHE_LIMITS.frozenTtlMs + 1;
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "ttl-session",
args: { filePath: join(tmpDir, "src/after-ttl.ts") },
},
{ output: "", exitCode: 0 },
);
const output2 = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "ttl-session", model: {} },
output2,
);
assert.match(output2.system.join("\n"), /Workspace memory after TTL expiry/);
const joined = output2.system.join("\n");
assert.match(joined, /Workspace memory before TTL expiry/);
assert.equal(joined.includes("Workspace memory after TTL expiry"), false);
assert.deepEqual(output2.system, output1.system,
"TTL time passage and hot-state churn must not refresh active frozen epoch prompts");
} finally {
Date.now = originalNow;
await rm(tmpDir, { recursive: true, force: true });
}
});
test("chat system transform keeps frozen prompts stable across active file churn", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "active-file-churn-session",
args: { filePath: join(tmpDir, "src/test.ts") },
},
{ output: "", exitCode: 0 },
);
const output1 = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "active-file-churn-session", model: {} },
output1,
);
assert.match(output1.system.join("\n"), /src\/test\.ts \(edit, 1x\)/);
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "active-file-churn-session",
args: { filePath: join(tmpDir, "src/test.ts") },
},
{ output: "", exitCode: 0 },
);
const output2 = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "active-file-churn-session", model: {} },
output2,
);
assert.deepEqual(output2.system, output1.system,
"active-file churn must not mutate pre-history prompts during the active epoch");
assert.equal(output2.system.join("\n").includes("Hot state deltas"), false);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("session compaction starts a new frozen prompt epoch including refreshed hot state", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "epoch-refresh-session",
args: { filePath: join(tmpDir, "src/before.ts") },
},
{ output: "", exitCode: 0 },
);
const output1 = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "epoch-refresh-session", model: {} },
output1,
);
assert.match(output1.system.join("\n"), /src\/before\.ts/);
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "epoch-refresh-session",
args: { filePath: join(tmpDir, "src/after.ts") },
},
{ output: "", exitCode: 0 },
);
const output2 = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "epoch-refresh-session", model: {} },
output2,
);
assert.equal(output2.system.join("\n").includes("src/after.ts"), false,
"normal turns should keep using the frozen hot prompt before compaction");
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: "epoch-refresh-session" } },
});
const output3 = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "epoch-refresh-session", model: {} },
output3,
);
assert.match(output3.system.join("\n"), /src\/after\.ts/,
"compaction should clear the frozen hot cache so the next epoch includes refreshed hot state");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("chat system transform keeps recently accessed frozen epoch under cache pressure", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
const originalNow = Date.now;
let now = originalNow();
Date.now = () => now;
try {
const timestamp = new Date(now).toISOString();
await updateWorkspaceMemory(tmpDir, store => {
store.entries.push({
id: "mem_recency_cache_before",
type: "project",
text: "Workspace memory before recency cache pressure.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: timestamp,
updatedAt: timestamp,
});
return store;
});
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "active-session-0",
args: { filePath: join(tmpDir, "src/recency-before.ts") },
},
{ output: "", exitCode: 0 },
);
let activeOutput = { system: ["base header"] };
for (let i = 0; i < WORKSPACE_MEMORY_CACHE_LIMITS.maxFrozenSessions; i += 1) {
const sessionID = i === 0 ? "active-session-0" : `inactive-session-${i}`;
now += 1;
const output = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID, model: {} },
output,
);
if (sessionID === "active-session-0") activeOutput = output;
}
assert.match(activeOutput.system.join("\n"), /Workspace memory before recency cache pressure/);
assert.match(activeOutput.system.join("\n"), /src\/recency-before\.ts/);
now += 1;
const activeHitOutput = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "active-session-0", model: {} },
activeHitOutput,
);
assert.deepEqual(activeHitOutput.system, activeOutput.system,
"cache hit should update access recency without changing frozen prompt text");
now += 1;
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "pressure-session-extra", model: {} },
{ system: ["base header"] },
);
await updateWorkspaceMemory(tmpDir, store => {
store.entries.push({
id: "mem_recency_cache_after",
type: "project",
text: "Workspace memory after recency cache pressure.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: timestamp,
updatedAt: timestamp,
});
return store;
});
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "active-session-0",
args: { filePath: join(tmpDir, "src/recency-after.ts") },
},
{ output: "", exitCode: 0 },
);
now += 1;
const outputAfterPressure = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "active-session-0", model: {} },
outputAfterPressure,
);
const joined = outputAfterPressure.system.join("\n");
assert.deepEqual(outputAfterPressure.system, activeOutput.system,
"recently accessed active session should keep its original frozen workspace and hot prompts under cache pressure");
assert.match(joined, /Workspace memory before recency cache pressure/);
assert.equal(joined.includes("Workspace memory after recency cache pressure"), false);
assert.match(joined, /src\/recency-before\.ts/);
assert.equal(joined.includes("src/recency-after.ts"), false);
} finally {
Date.now = originalNow;
await rm(tmpDir, { recursive: true, force: true });
@@ -2501,6 +2850,14 @@ test("chat system transform evicts oldest frozen snapshots when cache exceeds se
});
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "cache-size-session-0",
args: { filePath: join(tmpDir, "src/cache-before.ts") },
},
{ output: "", exitCode: 0 },
);
for (let i = 0; i <= WORKSPACE_MEMORY_CACHE_LIMITS.maxFrozenSessions; i += 1) {
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: `cache-size-session-${i}`, model: {} },
@@ -2522,6 +2879,15 @@ test("chat system transform evicts oldest frozen snapshots when cache exceeds se
return store;
});
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "cache-size-session-0",
args: { filePath: join(tmpDir, "src/cache-after.ts") },
},
{ output: "", exitCode: 0 },
);
const output = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "cache-size-session-0", model: {} },
@@ -2529,6 +2895,8 @@ test("chat system transform evicts oldest frozen snapshots when cache exceeds se
);
assert.match(output.system.join("\n"), /Workspace memory after cache pressure/);
assert.match(output.system.join("\n"), /src\/cache-after\.ts/,
"cache pressure should evict the paired frozen hot snapshot for the oldest session");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
+5 -4
View File
@@ -19,6 +19,7 @@ const accountHotSessionStateRender = (
const { createEmptySessionState, loadSessionState, renderHotSessionState, saveSessionState } = sessionStateModule;
const root = "/repo";
const HOT_STATE_PREFIX = "Hot session state snapshot (epoch start; conversation history may be newer):";
function state(overrides: Partial<SessionState> = {}): SessionState {
return {
@@ -113,7 +114,7 @@ test("accountHotSessionStateRender renders hot-state sections in stable order",
pendingMemories: [memory("mem-1", "Promote useful fact")],
}), root);
assert.ok(accounting.prompt.startsWith("Hot session state (current session):"));
assert.ok(accounting.prompt.startsWith(HOT_STATE_PREFIX));
assert.ok(accounting.prompt.indexOf("active_files:") < accounting.prompt.indexOf("open_errors:"));
assert.ok(accounting.prompt.indexOf("open_errors:") < accounting.prompt.indexOf("recent_decisions:"));
assert.ok(accounting.prompt.indexOf("recent_decisions:") < accounting.prompt.indexOf("pending_memories:"));
@@ -165,7 +166,7 @@ test("accountHotSessionStateRender omits over-budget entries without cutting ren
}), root);
assert.equal(accounting.prompt, [
"Hot session state (current session):",
HOT_STATE_PREFIX,
"active_files:",
"- src/short.ts (read, 1x)",
].join("\n"));
@@ -177,7 +178,7 @@ test("accountHotSessionStateRender omits over-budget entries without cutting ren
test("accountHotSessionStateRender includes exact 700-char prompt but omits one additional character", () => {
const fixedPrompt = [
"Hot session state (current session):",
HOT_STATE_PREFIX,
"pending_memories:",
"- [decision] ",
].join("\n");
@@ -223,7 +224,7 @@ test("renderHotSessionState delegates to accounted renderer prompt for empty and
test("accountHotSessionStateRender counts newline separators in the 700-char budget", () => {
const fixedPrompt = [
"Hot session state (current session):",
HOT_STATE_PREFIX,
"recent_decisions:",
"- ",
].join("\n");
+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 {
+37
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);
@@ -179,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,
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noUnusedLocals": true,
"noUnusedParameters": true
}
}