mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-01 22:11:08 +02:00
chore(code-health): prepare v1.6.5
This commit is contained in:
@@ -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 {
|
||||
// ...
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,37 @@ 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
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
# 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
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencode-working-memory",
|
||||
"version": "1.6.4",
|
||||
"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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
import { assessMemoryQuality } from "./memory-quality.ts";
|
||||
import {
|
||||
loadWorkspaceMemory,
|
||||
updateWorkspaceMemory,
|
||||
updateWorkspaceMemoryWithAccounting,
|
||||
accountWorkspaceMemoryRender,
|
||||
accountWorkspaceMemoryCompactionRefs,
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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\./);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
+19
-2
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user