From 09880c1840dfe57496f53ee502ce4d444f4be0a1 Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Fri, 8 May 2026 12:18:39 +0800 Subject: [PATCH] feat(memory): add numbered compaction refs --- CHANGELOG.md | 33 + README.md | 25 +- RELEASE_NOTES.md | 112 +++ package.json | 2 +- scripts/memory-diag/cli.ts | 19 +- scripts/memory-diag/command-metadata.ts | 2 +- scripts/memory-diag/command-registry.ts | 4 + scripts/memory-diag/commands/commands.ts | 187 ++++ scripts/memory-diag/commands/revert.ts | 193 +++++ scripts/memory-diag/types.ts | 2 + src/evidence-log.ts | 4 + src/extractors.ts | 113 ++- src/memory-quality.ts | 70 +- src/plugin.ts | 362 +++++++- src/retention.ts | 2 +- src/session-state.ts | 51 +- src/types.ts | 13 + src/workspace-memory.ts | 84 +- tests/evidence-log.test.ts | 33 + tests/extractors.test.ts | 59 ++ tests/memory-diag-cli.test.ts | 37 + tests/memory-diag.test.ts | 296 ++++++- tests/memory-quality-eval.test.ts | 95 ++ tests/plugin.test.ts | 1010 +++++++++++++++++++++- tests/session-state.test.ts | 159 +++- tests/workspace-memory.test.ts | 169 +++- 26 files changed, 3030 insertions(+), 106 deletions(-) create mode 100644 scripts/memory-diag/commands/commands.ts create mode 100644 scripts/memory-diag/commands/revert.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cfa860f..3a25789 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ 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.0] - 2026-05-08 + +### Added + +- Numbered compaction memory references (`[M1]`, `[M2]`, ...) for existing rendered workspace memories. +- Compaction memory commands: `REINFORCE [M#]` for retention reinforcement and `REPLACE [M#] [type] text` for protected replacement. +- `CompactionMemoryRef` session-state snapshots with optional compaction IDs for overlap detection. +- Evidence events for numbered memory command outcomes: `memory_reinforced`, `memory_replaced_numbered_ref`, and `memory_reverted_numbered_ref`. +- Public `memory-diag commands` report for command counts, outcomes, rejection reasons, protected replacement blocks, malformed commands, and latest command events. +- Public dry-run-first `memory-diag revert` command for manually reverting successful numbered replacements by replacement memory ID or evidence event ID. +- Hard quality rejection reasons for unresolved questions, transient bug/debug state, and deployment snapshots. +- Soft `terse_label` diagnostic for very short label-like candidates. +- Regression tests for command parsing, REINFORCE, protected REPLACE, revert behavior, compaction ref validation, overlap protection, and fallback behavior when the model omits the compaction snapshot ID. + +### Changed + +- Compaction prompts now include numbered memory refs and concise memory-operation rules instead of asking the model to reuse existing wording exactly. +- Compaction no longer duplicates hot session state inside the compaction prompt; hot state remains available in normal prompt context. +- Duplicate maintenance now prefers explicit REINFORCE or protected REPLACE evidence over silent duplicate restatement. +- Rendered decision memory cap increased from 10 to 12 while keeping the global rendered cap at 28. +- Rejected memory command evidence now uses neutral `target` relations instead of lifecycle-mutating `reinforced` or `superseded` relation roles. +- `memory-diag` public command metadata now includes `commands` and `revert` alongside `status`, `rejected`, `missing`, and `explain`. + +### Fixed + +- Overlapping same-session compactions can no longer silently apply numbered commands against the wrong snapshot when the snapshot ID is present. +- Numbered command resolution now rejects stale refs whose memory ID, status, or exact key no longer match the current workspace memory entry. +- Protected replacements are surfaced as first-class diagnostics instead of being buried in generic rejection counts. + +### Recovery note + +Successful numbered replacements supersede the original memory and add a replacement. To inspect or recover one, run `memory-diag commands --verbose`, then dry-run `memory-diag revert --memory ` or `memory-diag revert --event ` before adding `--apply`. + ## [1.5.5] - 2026-05-05 ### Added diff --git a/README.md b/README.md index 560f1e4..ffdc837 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Use it when you want your agent to remember things like: - **Hot session state** — active files, open errors, and current working context for the current session. - **Explicit memory triggers** — users can say “remember this”, “記住”, “覚えて”, or “기억해” to save durable facts. - **Compaction-based extraction** — memory extraction piggybacks on OpenCode’s existing compaction flow. +- **Numbered memory refs** — compaction can `REINFORCE [M#]` useful memories or safely `REPLACE [M#]` obsolete compaction memories. - **No manual tools** — memory is injected automatically into the system prompt. - **Quality guards** — filters noisy memories, temporary progress snapshots, stack traces, raw errors, and credentials. - **Retention decay** — keeps the strongest memories in prompt context while older or weaker memories fade out naturally; important and reinforced memories decay more slowly. @@ -202,6 +203,22 @@ The goal is to remember durable facts, not every detail. Historical cleanup is intentionally conservative: extraction-time filtering may reject more aggressively, but one-time migration cleanup only supersedes high-confidence garbage patterns. This protects existing durable memories written in declarative style, such as "API endpoint is X" or "Product branding is Y". +### Numbered Memory Refs + +During compaction, existing workspace memories may be shown as numbered refs such as `[M1]` or `[M2]`. The model can use these refs to maintain memory without duplicating it: + +```md +REINFORCE [M1] +REPLACE [M2] project Updated durable project fact. +``` + +- `REINFORCE [M#]` strengthens an existing memory's retention signal without changing its text. +- `REPLACE [M#] [type] text` supersedes a safe compaction-sourced memory and adds a replacement. +- Manual, explicit, and already-reinforced memories are protected from automatic replacement. +- Stale or mismatched numbered refs are rejected instead of mutating the wrong memory. + +Use `memory-diag commands` to inspect command outcomes and `memory-diag revert` to dry-run and apply manual recovery for successful numbered replacements. + ### Memory Diagnostics CLI Use the read-only diagnostics CLI when you want to understand what memory is doing for the current workspace. @@ -212,6 +229,8 @@ Use the read-only diagnostics CLI when you want to understand what memory is doi | Why was something rejected? | `npx --package opencode-working-memory memory-diag rejected` | | Where did my memory go? | `npx --package opencode-working-memory memory-diag missing` | | Why is this memory shown or hidden? | `npx --package opencode-working-memory memory-diag explain ` | +| How are numbered memory commands behaving? | `npx --package opencode-working-memory memory-diag commands` | +| Revert a numbered replacement? | `npx --package opencode-working-memory memory-diag revert --memory ` | Global options: @@ -226,8 +245,12 @@ npx --package opencode-working-memory memory-diag status npx --package opencode-working-memory memory-diag rejected --verbose npx --package opencode-working-memory memory-diag missing --workspace /path/to/project npx --package opencode-working-memory memory-diag status --json +npx --package opencode-working-memory memory-diag commands --verbose +npx --package opencode-working-memory memory-diag revert --memory ``` +`memory-diag revert` is dry-run by default. Add `--apply` only after reviewing the planned original/replacement status changes. + The npm package is opencode-working-memory; the installed bin is memory-diag, so package-qualified npx avoids resolving a different package named memory-diag. Maintainer-only diagnostics and cleanup commands are intentionally not documented here. Future work: move those internal commands to `docs/development.md`. @@ -251,7 +274,7 @@ See [Configuration](docs/configuration.md) for customization options. Current focus: - Add explicit delete tombstones so removed memories do not get re-extracted. -- Enforce explicit `supersedes` chains for safer replacement of obsolete memories. +- Monitor numbered refs and protected replacements with `memory-diag commands` before tightening automatic replacement policy further. - Explore tiered hot/warm/cold storage after the retention model has more real-world data. ## Documentation diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 633f67a..25b21b5 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,117 @@ # Release Notes +## 1.6.0 (2026-05-08) + +### Numbered Memory Refs + +This release turns compaction from a one-way memory extractor into a memory maintenance loop. The model now sees numbered references for existing workspace memories and can explicitly reinforce a still-useful memory or propose a protected replacement when compaction reveals that old memory is obsolete. + +The goal is not to make memory more aggressive. It is to make memory more accountable: old facts should be strengthened when they keep proving useful, replaced only when the target is safe, and diagnosable when the model tries something risky. + +> **Good memory is selective memory.** +> v1.6 lets memory say “this still matters” without copying it again — and lets obsolete compaction memories fade behind a safer replacement trail. + +```text + compaction summary + │ + ▼ + Memory candidates: + Memory ref snapshot id: + [M1] decision · reinforced=2 · source=explicit + [M2] project · reinforced=0 · source=compaction + │ + ├─ REINFORCE [M1] + │ ↑ slows decay, no text mutation + │ + └─ REPLACE [M2] project Updated durable fact + ↑ only allowed for safe compaction targets +``` + +### What Changed + +- **Numbered memory refs**: compaction prompts now render existing workspace memories as `[M1]`, `[M2]`, ... references so the model can target a known memory instead of restating it as a duplicate candidate. +- **REINFORCE commands**: `REINFORCE [M#]` increments the target memory's reinforcement count and updates its retention clock without changing its text. +- **Protected REPLACE commands**: `REPLACE [M#] [type] text` supersedes the old memory and appends a replacement only when the target is safe: active, compaction-sourced, and not already reinforced. +- **Reinforce + append workflow**: when a memory is mostly right but needs more context, compaction can reinforce the old memory and emit a new candidate for the new durable fact instead of mutating history. +- **Compaction prompt restructure**: verbose type definitions and the old “reuse existing wording exactly” instruction were replaced with shorter command rules, categorization guidance, and concrete memory-operation examples. +- **Hot state removed from compaction context**: active files, current errors, and pending session state remain in normal prompt context but are no longer duplicated inside the compaction prompt, saving budget and reducing accidental promotion of transient state. +- **New hard quality gates**: unresolved questions, transient bug/debug state, and deployment snapshots are rejected as durable memory candidates. +- **Soft terse-label diagnostic**: very short label-like candidates are reported for tuning without being hard-rejected in v1.6. +- **Decision cap raised**: rendered decision memories now have a per-type cap of 12 instead of 10, while the global rendered cap remains 28. +- **Overlap guard for compaction refs**: memory ref snapshots are tagged with a compaction ID when available, so overlapping same-session compactions cannot silently apply commands against the wrong numbered snapshot. +- **Safer evidence semantics**: rejected memory command events use a neutral `target` relation role instead of lifecycle roles such as `reinforced` or `superseded`. + +### Why This Helps + +- Useful memories can become stronger through real reuse instead of duplicate extraction. +- Obsolete compaction-sourced memories can be replaced with an explicit evidence trail rather than left to drift. +- Manual, explicit, and already-reinforced memories are protected from automatic replacement. +- Compaction prompt budget is spent on durable memory maintenance, not on duplicated hot session state. +- Command outcomes are visible enough to tune the feature after release instead of guessing from reinforcement counts alone. + +### Diagnostics + +Inspect command behavior with: + +```bash +npx --package opencode-working-memory memory-diag commands +npx --package opencode-working-memory memory-diag commands --verbose +npx --package opencode-working-memory memory-diag commands --json +``` + +The command report includes: + +- compactions with command evidence +- REINFORCE and REPLACE counts +- reinforced, superseded, rejected, and blocked outcomes +- invalid or malformed command counts +- same-type vs cross-type replacements +- protected REPLACE blocks, split by reinforced target and protected source +- latest command events in verbose mode + +If a numbered replacement needs manual recovery, use the dry-run-first revert command: + +```bash +npx --package opencode-working-memory memory-diag revert --memory +npx --package opencode-working-memory memory-diag revert --memory --apply +``` + +You can also target a replacement evidence event directly: + +```bash +npx --package opencode-working-memory memory-diag revert --event +``` + +### Safety Model + +- REINFORCE never edits memory text. +- REPLACE is rejected for manual or explicit memories. +- REPLACE is rejected for already reinforced targets. +- REPLACE is rejected if the numbered ref no longer matches the current memory ID, status, and exact key. +- If a compaction snapshot ID is present and mismatched, all numbered commands from that summary are rejected with `missing_memory_ref_snapshot`. +- If the model omits the snapshot ID, v1.6 falls back to exact memory ref validation for compatibility and command effectiveness. + +### Not Included Yet + +- Semantic duplicate detection is still out of scope; numbered refs solve explicit compaction-targeted maintenance, not fuzzy topic matching. +- Hot state evidence emission remains deferred to v2 until there is a `memory-diag` consumer for omitted hot-state items. +- Automatic cleanup of old low-quality memories is not expanded in this release; new quality gates apply to new candidates. + +### Upgrade Notes + +- No configuration changes required. +- Existing workspace memory files remain compatible. +- Existing session state remains compatible; old sessions without compaction ref snapshots fall back safely. +- Existing evidence logs remain compatible; new command events are appended only after v1.6 runs. +- `memory-diag` now exposes two additional public commands: `commands` and `revert`. + +### Validation + +- `npm run typecheck` — `TYPECHECK_PASS` +- `npm test` — 405 tests passing, `TEST_PASS` + +--- + ## 1.5.5 (2026-05-05) ### Hot State Rendering Health diff --git a/package.json b/package.json index da94d70..7ed6f3d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-working-memory", - "version": "1.5.5", + "version": "1.6.0", "description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state", "type": "module", "main": "index.ts", diff --git a/scripts/memory-diag/cli.ts b/scripts/memory-diag/cli.ts index dbe79f2..66fa70c 100644 --- a/scripts/memory-diag/cli.ts +++ b/scripts/memory-diag/cli.ts @@ -9,6 +9,8 @@ export function usage(): string { memory-diag rejected [--workspace ] [--verbose] [--json] memory-diag missing [--workspace ] [--verbose] [--json] memory-diag explain [memory-id] [--workspace ] [--raw] + memory-diag commands [--workspace ] [--verbose] [--json] + memory-diag revert (--memory | --event ) [--workspace ] [--apply] Global options: --workspace Workspace path (default: current directory) @@ -58,6 +60,7 @@ export function parseArgs(argv: string[]): ParsedArgs { else if (arg === "--trigger-only") options.triggerOnly = true; else if (arg === "--include-historical") options.includeHistorical = true; else if (arg === "--explain") options.explain = true; + else if (arg === "--apply") options.apply = true; else if (arg === "--workspace") { const value = rest[++i]; if (!value) return error("--workspace requires a path"); @@ -78,6 +81,10 @@ export function parseArgs(argv: string[]): ParsedArgs { const value = rest[++i]; if (!value) return error("--memory requires an id"); options.memory = value; + } else if (arg === "--event") { + const value = rest[++i]; + if (!value) return error("--event requires an id"); + options.event = value; } else if (!arg.startsWith("--") && command === "explain") { options.positional?.push(arg); } else { @@ -96,12 +103,12 @@ export function parseArgs(argv: string[]): ParsedArgs { if (command === "status") { if (options.all) return error(`${command} does not accept --all`); - } else if (command === "rejected" || command === "missing" || command === "coverage" || command === "explain") { + } else if (command === "rejected" || command === "missing" || command === "coverage" || command === "explain" || command === "commands" || command === "revert") { if (options.all) return error(`${command} does not accept --all`); } else { if (options.all || options.workspace) return error(`${command} does not accept --all or --workspace`); } - if (options.json && command !== "status" && command !== "rejected" && command !== "missing" && command !== "coverage") { + if (options.json && command !== "status" && command !== "rejected" && command !== "missing" && command !== "coverage" && command !== "commands") { return error(`${command} does not accept --json`); } if (command !== "rejected" && (options.softOnly || options.triggerOnly || options.since)) { @@ -113,9 +120,15 @@ export function parseArgs(argv: string[]): ParsedArgs { if (command !== "audit" && options.migration) { return error(`${command} does not accept --migration`); } - if (command !== "explain" && options.memory) { + if (command !== "explain" && command !== "revert" && options.memory) { return error(`${command} does not accept --memory`); } + if (command !== "revert" && options.event) return error(`${command} does not accept --event`); + if (command !== "revert" && options.apply) return error(`${command} does not accept --apply`); + if (command === "revert") { + if (!options.memory && !options.event) return error("revert requires --memory or --event"); + if (options.memory && options.event) return error("Use either --memory or --event, not both"); + } if (command === "rejected" && options.since && !isValidSince(options.since)) { return error(`Invalid --since value: ${options.since}`); } diff --git a/scripts/memory-diag/command-metadata.ts b/scripts/memory-diag/command-metadata.ts index 33a4234..a785c6e 100644 --- a/scripts/memory-diag/command-metadata.ts +++ b/scripts/memory-diag/command-metadata.ts @@ -1,4 +1,4 @@ -export const VISIBLE_COMMANDS = ["status", "rejected", "missing", "explain"] as const; +export const VISIBLE_COMMANDS = ["status", "rejected", "missing", "explain", "commands", "revert"] as const; export const HIDDEN_COMMANDS = ["coverage", "audit"] as const; export type VisibleCommand = typeof VISIBLE_COMMANDS[number]; diff --git a/scripts/memory-diag/command-registry.ts b/scripts/memory-diag/command-registry.ts index d7f9888..a44841a 100644 --- a/scripts/memory-diag/command-registry.ts +++ b/scripts/memory-diag/command-registry.ts @@ -1,8 +1,10 @@ import { runAudit } from "./commands/audit.ts"; +import { runCommands } from "./commands/commands.ts"; import { runCoverage } from "./commands/coverage.ts"; import { runExplain } from "./commands/explain.ts"; import { runMissing } from "./commands/missing.ts"; import { runRejected } from "./commands/rejected.ts"; +import { runRevert } from "./commands/revert.ts"; import { runStatus } from "./commands/status.ts"; import type { CliOptions, Command, CommandResult } from "./types.ts"; @@ -14,5 +16,7 @@ export async function dispatch(command: Command, options: CliOptions): Promise; + outcomes: Record<"reinforced" | "superseded" | "rejected" | "blocked", number>; + invalidMalformedCommands: number; + replacements: { + sameType: number; + crossType: number; + }; + protectedReplacements: { + total: number; + protectedReinforcedTarget: number; + protectedMemorySource: number; + }; + rejectionReasons: Record; + latestEvents: Array<{ + eventId: string; + createdAt: string; + type: string; + outcome: EvidenceOutcome; + ref?: string; + memoryId?: string; + reasonCodes: string[]; + textPreview?: string; + }>; +}; + +const INVALID_COMMAND_REASONS = new Set([ + "invalid_memory_command", + "invalid_memory_ref", + "invalid_memory_type", + "empty_replacement_text", +]); + +function hasReason(event: EvidenceEventV1, reason: string): boolean { + return event.reasonCodes.includes(reason); +} + +function isInvalidMalformedCommandEvent(event: EvidenceEventV1): boolean { + return event.type === "extraction_candidate_rejected" + && event.reasonCodes.some(reason => INVALID_COMMAND_REASONS.has(reason)); +} + +function isParsedCommandEvent(event: EvidenceEventV1): boolean { + return event.type === "memory_reinforced" || event.type === "memory_replaced_numbered_ref"; +} + +function isManualRevertEvent(event: EvidenceEventV1): boolean { + return event.type === "memory_reverted_numbered_ref"; +} + +function isCommandEvidenceEvent(event: EvidenceEventV1): boolean { + return isParsedCommandEvent(event) || isInvalidMalformedCommandEvent(event) || isManualRevertEvent(event); +} + +function refFromEvent(event: EvidenceEventV1): string | undefined { + const ref = event.details?.ref; + return typeof ref === "string" ? ref : undefined; +} + +function latestEventJSON(event: EvidenceEventV1): MemoryCommandSummary["latestEvents"][number] { + return { + eventId: event.eventId, + createdAt: event.createdAt, + type: event.type, + outcome: event.outcome, + ref: refFromEvent(event), + memoryId: event.memory?.memoryId, + reasonCodes: event.reasonCodes, + textPreview: event.textPreview, + }; +} + +export function buildMemoryCommandSummary(events: EvidenceEventV1[], generatedAt = new Date().toISOString()): MemoryCommandSummary { + const commandEvents = events.filter(isCommandEvidenceEvent); + const compactionCommandEvents = commandEvents.filter(event => !isManualRevertEvent(event)); + const parsedEvents = compactionCommandEvents.filter(isParsedCommandEvent); + const invalidEvents = compactionCommandEvents.filter(isInvalidMalformedCommandEvent); + const sessions = new Set(compactionCommandEvents.map(event => event.sessionHash).filter((value): value is string => typeof value === "string" && value.length > 0)); + const replacementSuccesses = parsedEvents.filter(event => event.type === "memory_replaced_numbered_ref" && event.outcome === "superseded"); + const rejectedCommandEvents = commandEvents.filter(event => event.outcome === "rejected"); + const rejectionReasonCounts = new Map(); + + for (const event of rejectedCommandEvents) { + for (const reason of event.reasonCodes) { + rejectionReasonCounts.set(reason, (rejectionReasonCounts.get(reason) ?? 0) + 1); + } + } + + const protectedReinforcedTarget = parsedEvents.filter(event => event.type === "memory_replaced_numbered_ref" && hasReason(event, "protected_reinforced_target")).length; + const protectedMemorySource = parsedEvents.filter(event => event.type === "memory_replaced_numbered_ref" && hasReason(event, "protected_memory_source")).length; + const parsedRejected = parsedEvents.filter(event => event.outcome === "rejected").length; + + return { + version: 1, + generatedAt, + compactionsWithCommandEvidence: sessions.size > 0 ? sessions.size : compactionCommandEvents.length > 0 ? 1 : 0, + commands: { + reinforce: parsedEvents.filter(event => event.type === "memory_reinforced").length, + replace: parsedEvents.filter(event => event.type === "memory_replaced_numbered_ref").length, + }, + outcomes: { + reinforced: parsedEvents.filter(event => event.outcome === "reinforced").length, + superseded: parsedEvents.filter(event => event.outcome === "superseded").length, + rejected: parsedRejected, + blocked: parsedRejected, + }, + invalidMalformedCommands: invalidEvents.length, + replacements: { + sameType: replacementSuccesses.filter(event => hasReason(event, "same_type_replace")).length, + crossType: replacementSuccesses.filter(event => hasReason(event, "cross_type_replace")).length, + }, + protectedReplacements: { + total: parsedEvents.filter(event => event.type === "memory_replaced_numbered_ref" && (hasReason(event, "protected_reinforced_target") || hasReason(event, "protected_memory_source"))).length, + protectedReinforcedTarget, + protectedMemorySource, + }, + rejectionReasons: objectFromCounts(rejectionReasonCounts), + latestEvents: commandEvents.slice(-10).reverse().map(latestEventJSON), + }; +} + +function formatReasonCounts(rejectionReasons: Record): string[] { + const counts = new Map(Object.entries(rejectionReasons)); + const rows = sortedCounts(counts); + if (rows.length === 0) return [" (none)"]; + return rows.map(([reason, count]) => ` - ${reason}: ${count}`); +} + +function formatLatestEvents(events: MemoryCommandSummary["latestEvents"]): string[] { + if (events.length === 0) return [" (none)"]; + return events.map(event => { + const ref = event.ref ? ` ref=${event.ref}` : ""; + const memoryId = event.memoryId ? ` memory=${event.memoryId}` : ""; + const textPreview = event.textPreview ? ` text=${JSON.stringify(event.textPreview)}` : ""; + return ` - ${event.createdAt} ${event.type} ${event.outcome}${ref}${memoryId} reasons=${event.reasonCodes.join(",") || "none"}${textPreview}`; + }); +} + +export function formatMemoryCommandSummary(summary: MemoryCommandSummary, options: Pick = {}): string { + const warning = options.noEmoji ? "!" : "⚠"; + const lines = [ + "Memory command diagnostics", + "", + "Key metrics:", + ` - compactions with command evidence: ${summary.compactionsWithCommandEvidence}`, + ` - reinforce: ${summary.commands.reinforce}`, + ` - replace: ${summary.commands.replace}`, + ` - reinforced: ${summary.outcomes.reinforced}`, + ` - superseded: ${summary.outcomes.superseded}`, + ` - rejected: ${summary.outcomes.rejected}`, + ` - blocked: ${summary.outcomes.blocked}`, + ` - invalid/malformed commands: ${summary.invalidMalformedCommands}`, + ` - same-type replacements: ${summary.replacements.sameType}`, + ` - cross-type replacements: ${summary.replacements.crossType}`, + ` - ${warning} Protected REPLACE blocked: ${summary.protectedReplacements.total} (reinforced: ${summary.protectedReplacements.protectedReinforcedTarget}, source: ${summary.protectedReplacements.protectedMemorySource})`, + "", + "Rejection reasons:", + ...formatReasonCounts(summary.rejectionReasons), + ]; + + if (options.verbose) { + lines.push("", "Latest command events:", ...formatLatestEvents(summary.latestEvents)); + } + + return lines.join("\n"); +} + +export async function runCommands(options: CliOptions): Promise { + const root = options.workspace ?? process.cwd(); + const events = await queryEvidenceEvents(root); + const summary = buildMemoryCommandSummary(events); + + if (options.json) { + return { stdout: JSON.stringify(summary, null, 2) }; + } + + return { stdout: formatMemoryCommandSummary(summary, options) }; +} diff --git a/scripts/memory-diag/commands/revert.ts b/scripts/memory-diag/commands/revert.ts new file mode 100644 index 0000000..3bf5e90 --- /dev/null +++ b/scripts/memory-diag/commands/revert.ts @@ -0,0 +1,193 @@ +import { appendEvidenceEvents, queryEvidenceEvents, type EvidenceEventInput, type EvidenceEventV1, type MemoryEvidenceRef } from "../../../src/evidence-log.ts"; +import { workspaceMemoryPath } from "../../../src/paths.ts"; +import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../../../src/types.ts"; +import { updateWorkspaceMemoryWithAccounting } from "../../../src/workspace-memory.ts"; +import { readJSONFile } from "../io.ts"; +import { cleanText, truncate } from "../text.ts"; +import { CliInputError, type CliOptions, type CommandResult } from "../types.ts"; + +type ReplacementLink = { + event: EvidenceEventV1; + originalId: string; + replacementId: string; +}; + +type RevertPlan = ReplacementLink & { + original: LongTermMemoryEntry; + replacement: LongTermMemoryEntry; +}; + +function reject(message: string): never { + throw new CliInputError(`revert rejected: ${message}`); +} + +function memoryRef(memory: LongTermMemoryEntry, status: LongTermMemoryEntry["status"] = memory.status): MemoryEvidenceRef { + return { + memoryId: memory.id, + type: memory.type, + source: memory.source, + status, + }; +} + +function replacementIdFromEvent(event: EvidenceEventV1): string | undefined { + return event.relations?.find(relation => relation.role === "superseded_by")?.memory?.memoryId; +} + +function originalIdFromEvent(event: EvidenceEventV1): string | undefined { + return event.memory?.memoryId + ?? event.relations?.find(relation => relation.role === "superseded")?.memory?.memoryId; +} + +function replacementLinkFromEvent(event: EvidenceEventV1): ReplacementLink { + if (event.type !== "memory_replaced_numbered_ref") { + reject(`event ${event.eventId} is not a memory_replaced_numbered_ref event`); + } + if (event.outcome !== "superseded") { + reject(`event ${event.eventId} is not a successful numbered replacement`); + } + if (!event.reasonCodes.includes("numbered_ref_replace")) { + reject(`event ${event.eventId} is not a numbered replacement`); + } + + const originalId = originalIdFromEvent(event); + const replacementId = replacementIdFromEvent(event); + if (!originalId || !replacementId) { + reject(`event ${event.eventId} does not identify original and replacement memories`); + } + + return { event, originalId, replacementId }; +} + +function selectReplacementLink(events: EvidenceEventV1[], options: CliOptions): ReplacementLink { + if (options.event) { + const event = events.find(item => item.eventId === options.event); + if (!event) reject(`event ${options.event} was not found`); + return replacementLinkFromEvent(event); + } + + const memoryId = options.memory; + if (!memoryId) reject("missing --memory or --event selector"); + const matches = events + .filter(event => event.type === "memory_replaced_numbered_ref") + .filter(event => replacementIdFromEvent(event) === memoryId); + + if (matches.length === 0) { + reject(`replacement memory ${memoryId} was not created by memory_replaced_numbered_ref`); + } + if (matches.length > 1) { + reject(`replacement memory ${memoryId} has ${matches.length} replacement events; use --event`); + } + + return replacementLinkFromEvent(matches[0]); +} + +function validatePlan(link: ReplacementLink, store: WorkspaceMemoryStore): RevertPlan { + const byId = new Map(store.entries.map(entry => [entry.id, entry])); + const original = byId.get(link.originalId); + const replacement = byId.get(link.replacementId); + + if (!original) reject(`original memory ${link.originalId} is missing`); + if (!replacement) reject(`replacement memory ${link.replacementId} is missing`); + if (original.status !== "superseded") reject(`original memory ${original.id} is not superseded`); + if (replacement.status !== "active") reject(`replacement memory ${replacement.id} is not active`); + + const laterSuperseder = store.entries.find(entry => + entry.status === "active" + && entry.id !== original.id + && entry.id !== replacement.id + && (entry.supersedes ?? []).includes(replacement.id) + ); + if (laterSuperseder) { + reject(`replacement memory ${replacement.id} is superseded by active memory ${laterSuperseder.id}`); + } + + return { ...link, original, replacement }; +} + +async function dryRunPlan(root: string, link: ReplacementLink): Promise { + const rawStore = await readJSONFile(await workspaceMemoryPath(root)); + const store: WorkspaceMemoryStore = rawStore ?? { + version: 1, + workspace: { root, key: "" }, + limits: { maxRenderedChars: 0, maxEntries: 0 }, + entries: [], + migrations: [], + updatedAt: new Date(0).toISOString(), + }; + return validatePlan(link, store); +} + +function revertEvidence(plan: RevertPlan): EvidenceEventInput { + const replacement = { ...plan.replacement, status: "superseded" as const }; + const original = { ...plan.original, status: "active" as const }; + return { + type: "memory_reverted_numbered_ref", + phase: "storage", + outcome: "recovered", + memory: memoryRef(replacement, "superseded"), + relations: [ + { role: "superseded", memory: memoryRef(replacement, "superseded") }, + { role: "recovered", memory: memoryRef(original, "active") }, + ], + reasonCodes: ["manual_revert_numbered_ref"], + details: { + replacementEventId: plan.event.eventId, + replacementMemoryId: plan.replacementId, + restoredMemoryId: plan.originalId, + }, + textPreview: original.text, + }; +} + +async function applyPlan(root: string, link: ReplacementLink): Promise { + let applied: RevertPlan | undefined; + const updateResult = await updateWorkspaceMemoryWithAccounting(root, store => { + const plan = validatePlan(link, store); + const nowIso = new Date().toISOString(); + applied = { + ...plan, + original: { ...plan.original, status: "active", updatedAt: nowIso }, + replacement: { ...plan.replacement, status: "superseded", updatedAt: nowIso }, + }; + + return { + ...store, + entries: store.entries.map(entry => { + if (entry.id === plan.originalId) return applied!.original; + if (entry.id === plan.replacementId) return applied!.replacement; + return entry; + }), + updatedAt: nowIso, + lastActivityAt: nowIso, + }; + }); + + if (!applied) reject("unable to apply revert"); + await appendEvidenceEvents(root, [...updateResult.evidence, revertEvidence(applied)]); + return applied; +} + +function formatPlan(plan: RevertPlan, applied: boolean): string { + const heading = applied ? "Memory revert applied" : "Memory revert dry run"; + const nextStep = applied ? "Changes applied." : "No changes applied. Re-run with --apply to mutate workspace memory."; + return [ + heading, + "", + "Planned changes:", + ` - replacement: ${plan.replacementId} active -> superseded`, + ` - original: ${plan.originalId} superseded -> active`, + ` - replacement event: ${plan.event.eventId}`, + ` - restored text: ${truncate(cleanText(plan.original.text, false), 100)}`, + "", + nextStep, + ].join("\n"); +} + +export async function runRevert(options: CliOptions): Promise { + const root = options.workspace ?? process.cwd(); + const events = await queryEvidenceEvents(root); + const link = selectReplacementLink(events, options); + const plan = options.apply ? await applyPlan(root, link) : await dryRunPlan(root, link); + return { stdout: formatPlan(plan, options.apply === true) }; +} diff --git a/scripts/memory-diag/types.ts b/scripts/memory-diag/types.ts index d2a6dfd..8678cfd 100644 --- a/scripts/memory-diag/types.ts +++ b/scripts/memory-diag/types.ts @@ -67,6 +67,8 @@ export type CliOptions = { since?: string; migration?: string; memory?: string; + event?: string; + apply?: boolean; positional?: string[]; auditMode?: "coverage" | "migrations"; }; diff --git a/src/evidence-log.ts b/src/evidence-log.ts index 78fb4c3..38e5b21 100644 --- a/src/evidence-log.ts +++ b/src/evidence-log.ts @@ -20,6 +20,8 @@ export type EvidenceEventType = | "promotion_retry_scheduled" | "promotion_retry_exhausted" | "memory_reinforced" + | "memory_replaced_numbered_ref" + | "memory_reverted_numbered_ref" | "memory_migration_superseded" | "render_selected" | "render_omitted" @@ -71,10 +73,12 @@ export type EvidenceRelation = { | "promoted" | "retained" | "absorbed" + | "target" | "superseded" | "superseded_by" | "reinforced" | "reinforced_by" + | "recovered" | "rendered" | "omitted" | "removed"; diff --git a/src/extractors.ts b/src/extractors.ts index 0d24e5e..247c733 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -52,9 +52,14 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] { export type WorkspaceMemoryParseResult = { entries: LongTermMemoryEntry[]; + commands: WorkspaceMemoryCommand[]; evidence: EvidenceEventInput[]; }; +export type WorkspaceMemoryCommand = + | { kind: "REINFORCE"; ref: string } + | { kind: "REPLACE"; ref: string; type: LongTermType; text: string }; + function evidenceTextPreview(text: string, maxChars = 120): string { return redactCredentials(text).replace(/\s+/g, " ").trim().slice(0, maxChars); } @@ -191,7 +196,7 @@ export function extractExplicitMemoriesWithEvidence(text: string): WorkspaceMemo } } - return { entries, evidence }; + return { entries, commands: [], evidence }; } function classifyExplicitMemory(text: string): LongTermType { @@ -395,6 +400,76 @@ function shouldAcceptWorkspaceMemoryCandidate( return evaluateWorkspaceMemoryCandidate(entry, options).accepted; } +function commandAttemptReason(line: string): string { + const normalized = line.replace(/^\s*-\s*/, "").trim(); + const reinforceMatch = normalized.match(/^REINFORCE\s+(.+)$/i); + if (reinforceMatch) { + return /^\[M[1-9]\d*\]$/i.test(reinforceMatch[1]?.trim() ?? "") + ? "invalid_memory_command" + : "invalid_memory_ref"; + } + + const replaceMatch = normalized.match(/^REPLACE\s+(.*)$/i); + if (!replaceMatch) return "invalid_memory_command"; + + const rest = replaceMatch[1]?.trim() ?? ""; + const refMatch = rest.match(/^(\[[^\]]+\]|\S+)(?:\s+(.*))?$/); + const ref = refMatch?.[1] ?? ""; + if (!/^\[M[1-9]\d*\]$/i.test(ref)) return "invalid_memory_ref"; + + const afterRef = refMatch?.[2]?.trim() ?? ""; + const typeMatch = afterRef.match(/^(\[[^\]]+\]|\S+)(?:\s+(.*))?$/); + const typeToken = typeMatch?.[1] ?? ""; + if (!/^\[(feedback|project|decision|reference)\]$/i.test(typeToken)) { + return "invalid_memory_type"; + } + + const replacementText = typeMatch?.[2]?.trim() ?? ""; + return replacementText ? "invalid_memory_command" : "empty_replacement_text"; +} + +function isCommandAttempt(line: string): boolean { + const normalized = line.replace(/^\s*-\s*/, "").trim(); + return /^(REINFORCE|REPLACE)\b/i.test(normalized) + || /\b(REINFORCE|REPLACE)\b.*\[?\w+\]?/i.test(normalized); +} + +function parseWorkspaceMemoryCommand(line: string): WorkspaceMemoryCommand | null { + const normalized = line.replace(/^\s*-\s*/, "").trim(); + const reinforce = normalized.match(/^REINFORCE\s+\[(M[1-9]\d*)\]\s*$/i); + if (reinforce) { + return { kind: "REINFORCE", ref: reinforce[1].toUpperCase() }; + } + + const replace = normalized.match(/^REPLACE\s+\[(M[1-9]\d*)\]\s+\[(feedback|project|decision|reference)\]\s+(.+)$/i); + if (replace) { + const text = replace[3].trim(); + if (!text) return null; + return { + kind: "REPLACE", + ref: replace[1].toUpperCase(), + type: replace[2].toLowerCase() as LongTermType, + text, + }; + } + + return null; +} + +function parseCandidateLine(line: string): { type: LongTermType; body: string } | null { + const bracketed = line.trim().match(/^\s*-?\s*\[(feedback|project|decision|reference)\]\s+(.+)$/i); + if (bracketed) { + return { type: bracketed[1].toLowerCase() as LongTermType, body: bracketed[2] }; + } + + const bracketless = line.trim().match(/^-\s*(feedback|project|decision|reference)\b\s+(.+)$/i); + if (bracketless) { + return { type: bracketless[1].toLowerCase() as LongTermType, body: bracketless[2] }; + } + + return null; +} + /** * Extract candidate block from summary using multiple formats. * Supports: Plain text label, Markdown section, legacy XML. @@ -431,21 +506,39 @@ export function parseWorkspaceMemoryCandidatesWithEvidence( options: WorkspaceMemoryCandidateParseOptions = {}, ): WorkspaceMemoryParseResult { const block = extractCandidateBlock(summary); - if (!block) return { entries: [], evidence: [] }; + if (!block) return { entries: [], commands: [], evidence: [] }; const nowMs = Date.now(); const now = new Date(nowMs).toISOString(); const entries: LongTermMemoryEntry[] = []; + const commands: WorkspaceMemoryCommand[] = []; const evidence: EvidenceEventInput[] = []; for (const line of block.split("\n")) { + if (!line.trim() || /^\s*\(?none\)?\s*$/i.test(line)) continue; + + const command = parseWorkspaceMemoryCommand(line); + if (command) { + commands.push(command); + continue; + } + // Accept both "- [type] text" (bracketed) and "- type text" (bracketless) - const item = line.trim().match( - /^-\s*(?:\[(feedback|project|decision|reference)\]|(feedback|project|decision|reference)\b)\s+(.+)$/i, - ); - if (!item) continue; - const type = (item[1] ?? item[2]).toLowerCase() as LongTermType; - const normalizedBody = normalizeCandidateBody(item[3]); + const item = parseCandidateLine(line); + if (!item) { + if (isCommandAttempt(line)) { + evidence.push(extractionEvidence({ + type: "extraction_candidate_rejected", + phase: "extraction", + outcome: "rejected", + reasonCodes: [commandAttemptReason(line)], + textPreview: evidenceTextPreview(line, 80), + })); + } + continue; + } + const type = item.type; + const normalizedBody = normalizeCandidateBody(item.body); if (!normalizedBody) { evidence.push(extractionEvidence({ type: "extraction_candidate_rejected", @@ -453,7 +546,7 @@ export function parseWorkspaceMemoryCandidatesWithEvidence( outcome: "rejected", reasonCodes: ["negated_request"], memory: { type, source: "compaction" }, - textPreview: evidenceTextPreview(item[3], 80), + textPreview: evidenceTextPreview(item.body, 80), })); continue; } @@ -515,5 +608,5 @@ export function parseWorkspaceMemoryCandidatesWithEvidence( })); } - return { entries, evidence }; + return { entries, commands, evidence }; } diff --git a/src/memory-quality.ts b/src/memory-quality.ts index 45a61ce..95dac82 100644 --- a/src/memory-quality.ts +++ b/src/memory-quality.ts @@ -7,6 +7,7 @@ export type MemoryQualityInput = Pick & { export type MemoryQualityResult = { accepted: boolean; reasons: string[]; + diagnostics?: string[]; }; export const HARD_QUALITY_REASONS: ReadonlySet = new Set([ @@ -18,6 +19,9 @@ export const HARD_QUALITY_REASONS: ReadonlySet = new Set([ "active_file_snapshot", "code_or_api_signature", "path_heavy", + "unresolved_question", + "transient_bug_state", + "deployment_snapshot", ]); export function isHardQualityReason(reason: string): boolean { @@ -36,10 +40,18 @@ export function assessMemoryQuality(entry: MemoryQualityInput): MemoryQualityRes if (isTemporaryStatusViolation(text)) reasons.push("temporary_status"); if (isActiveFileSnapshotViolation(text)) reasons.push("active_file_snapshot"); if (isCodeOrApiSignatureViolation(text)) reasons.push("code_or_api_signature"); + if (isUnresolvedQuestionViolation(text)) reasons.push("unresolved_question"); + if (isTransientBugStateViolation(text)) reasons.push("transient_bug_state"); + if (isDeploymentSnapshotViolation(text)) reasons.push("deployment_snapshot"); if (entry.type === "feedback" && isFeedbackQualityViolation(text)) reasons.push("bad_feedback"); if (entry.type === "decision" && isDecisionQualityViolation(text)) reasons.push("bad_decision"); - return { accepted: reasons.length === 0, reasons }; + const diagnostics = isTerseLabelDiagnostic(text) ? ["terse_label"] : []; + return { + accepted: reasons.length === 0, + reasons, + ...(diagnostics.length > 0 ? { diagnostics } : {}), + }; } export function isProgressSnapshotViolation(text: string): boolean { @@ -83,6 +95,34 @@ export function hasFutureRule(text: string): boolean { || /(?:使用|保持|避免|不要|必須|必须|應該|应该|選擇|选择)/u.test(text); } +function textWithoutUrls(text: string): string { + return text.replace(/https?:\/\/[^\s`"'<>]+/gi, ""); +} + +function hasDurableRuleMarker(text: string): boolean { + return /\b(?:must|always|never|use|do\s+not|don't)\b/i.test(text) + || /\bshould\b(?!\s+we\b)/i.test(text) + || /(?:必須|必须|應該|应该|不要|使用|保持)/u.test(text); +} + +function isUnresolvedQuestionViolation(text: string): boolean { + if (hasDurableRuleMarker(text)) return false; + + const withoutUrls = textWithoutUrls(text).trim(); + const startsUnresolved = /^(?:question:|open question\b|unresolved\b|pending question\b|todo:\s*decide\b|TBD\b|TODO\b|待確認|未決|待決定)/iu.test(withoutUrls); + if (startsUnresolved) return true; + + if (/\b(?:need to decide|needs decision|not decided|whether to|should we|do we need)\b/i.test(withoutUrls)) return true; + if (/(?:尚未決定|需要決定|是否要|要不要)/u.test(withoutUrls)) return true; + + if (/[??]\s*$/.test(withoutUrls)) return true; + + const hasQuestion = /[??]/.test(withoutUrls); + const hasPlanningPhrase = /\b(?:we need|need to|next|later|follow up)\b/i.test(withoutUrls) + || /(?:確認|决定|決定)/u.test(withoutUrls); + return hasQuestion && hasPlanningPhrase; +} + export function isArchitectureLikeDecision(text: string): boolean { if (/\b(?:[A-Z][A-Z0-9]*_[A-Z0-9_]*|[A-Z][A-Z0-9]{3,})\b/.test(text)) return true; if (/\b(?:schema|model|scoring|retention|cap|evidence|normalization|root cause|architecture(?!\s+keywords?)|boundary|rule|memory system)\b/i.test(text)) return true; @@ -137,6 +177,34 @@ function isTemporaryStatusViolation(text: string): boolean { return false; } +function isTransientBugStateViolation(text: string): boolean { + return /\b(?:currently debugging|still failing|unresolved bug|temporary workaround|next step is to fix|tests are failing)\b/i.test(text) + || /(?:待修|暫時|暂时|目前正在)/u.test(text); +} + +function isDeploymentSnapshotViolation(text: string): boolean { + const hasDeploymentContext = /\b(?:deployed|current|latest|active|revision|build|release)\b/i.test(text) + || /(?:部署|版本|修訂|修订)/u.test(text); + if (!hasDeploymentContext) return false; + + const highEntropyId = /\b(?:rev|build|release|revision)[-_]?[A-Za-z0-9]{10,}\b/i.test(text) + || /\b[A-Za-z0-9]*[A-Z][A-Za-z0-9]*\d[A-Za-z0-9]*[A-Za-z0-9_-]{8,}\b/.test(text) + || /\b[A-Za-z0-9]*\d[A-Za-z0-9]*[A-Z][A-Za-z0-9]*[A-Za-z0-9_-]{8,}\b/.test(text); + return highEntropyId; +} + +function isTerseLabelDiagnostic(text: string): boolean { + if (/[::]/u.test(text)) return false; + + const codePoints = [...text].length; + const tokens = text.split(/\s+/u).filter(Boolean); + if (codePoints >= 18 && tokens.length >= 4) return false; + + const hasMarker = /\b(?:is|are|was|were|has|have|uses?|keeps?|requires?|prefers?|wants?|supports?|must|should|always|never|do\s+not|don't|because|for|with|when|after|before)\b/i.test(text) + || /(?:使用|保持|避免|不要|必須|必须|應該|应该|偏好|要求|支援|支持|因為|因为)/u.test(text); + return !hasMarker; +} + function isActiveFileSnapshotViolation(text: string): boolean { return /^(modified|created|deleted|renamed)\s+\S+\.\S+$/i.test(text); } diff --git a/src/plugin.ts b/src/plugin.ts index 412b3fa..275fc28 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -19,11 +19,11 @@ * - Processes explicit memory from latest user text once per message id * - Injects frozen workspace memory and dynamic hot session state into system prompt * - Updates session state after tool execution - * - Augments compaction context with memory, hot state, todos, and instruction + * - Augments compaction context with numbered memory refs, todos, and instruction * - Parses compaction summaries for memory candidates and merges them */ -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { realpath, rm } from "fs/promises"; import type { Plugin } from "@opencode-ai/plugin"; import { @@ -31,12 +31,17 @@ import { extractActiveFiles, extractErrorsFromBash, parseWorkspaceMemoryCandidatesWithEvidence, + staleAfterDaysFor, + type WorkspaceMemoryCommand, } from "./extractors.ts"; +import { assessMemoryQuality } from "./memory-quality.ts"; import { loadWorkspaceMemory, updateWorkspaceMemory, updateWorkspaceMemoryWithAccounting, accountWorkspaceMemoryRender, + accountWorkspaceMemoryCompactionRefs, + workspaceMemoryExactKey, workspaceMemoryIdentityKey, } from "./workspace-memory.ts"; import { reinforceMemory } from "./retention.ts"; @@ -66,7 +71,7 @@ import { } from "./opencode.ts"; import { accountPendingPromotions, promotionAccountingEvidenceEvents } from "./promotion-accounting.ts"; import { appendEvidenceEvent, appendEvidenceEvents, type EvidenceEventInput, type MemoryEvidenceRef } from "./evidence-log.ts"; -import { type LongTermMemoryEntry, WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts"; +import { type CompactionMemoryRef, type LongTermMemoryEntry, LONG_TERM_LIMITS, WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts"; /** * Build the complete compaction prompt. @@ -76,11 +81,14 @@ import { type LongTermMemoryEntry, WORKSPACE_MEMORY_CACHE_LIMITS } from "./types * Our template uses only ## Markdown headings and explicitly forbids YAML frontmatter, * horizontal rules, and delimiter lines. * - * @param privateContext - Background context (workspace memory, hot session state, + * @param privateContext - Background context (numbered workspace memory refs, * pending todos) from our plugin and any other plugins. Shown to the model to * inform the summary but not copied verbatim. */ -function buildCompactionPrompt(privateContext: string): string { +function buildCompactionPrompt(privateContext: string, compactionId?: string): string { + const snapshotInstruction = compactionId + ? `- If you emit any REINFORCE or REPLACE command, include \`Memory ref snapshot id: ${compactionId}\` as the first line under \"Memory candidates:\" so numbered refs match the correct compaction snapshot.` + : ""; return [ "Provide a detailed summary for continuing our conversation above.", "Focus on information that would help another agent continue the work: the goal, user instructions, completed work, current state, decisions, relevant files, and next steps.", @@ -95,6 +103,7 @@ function buildCompactionPrompt(privateContext: string): string { "- Do not output horizontal rules.", "- Do not wrap the summary in delimiter lines such as ---.", "- Do not use code fences around the summary.", + ...(snapshotInstruction ? [snapshotInstruction] : []), "", "Use this structure:", "", @@ -116,19 +125,19 @@ function buildCompactionPrompt(privateContext: string): string { "", "CRITICAL MEMORY RULES:", "- Most compactions should produce ZERO memories. Empty is correct when nothing durable changed.", - "- Existing workspace memory may already contain durable facts. If a fact is already present and still accurate, do not create a rephrased duplicate.", - "- If the same durable fact truly needs to be emitted again, reuse the existing memory wording exactly whenever possible.", - "- Only emit a new memory when the fact is new, materially corrected, or materially more specific than the existing memory.", + "- Existing memories are numbered [M#]. If an existing memory is still accurate, emit at most 3 lines like `REINFORCE [M#]`; do not rephrase it.", + "- Use `REPLACE [M#] [type] text` only for eligible unreinforced compaction-sourced memories where the old text itself needs correction; this is rarely the right choice.", + "- To supplement or correct a memory, REINFORCE the existing [M#] if it is still accurate, and also emit a new complete [type] candidate with the addition or correction. Do not use REPLACE for additions; do not reinforce a memory that is now inaccurate.", "- NO completion or progress statements: do not extract completed work, passing tests, commits, PR status, wave/task/phase completion, or current state.", "- NO session-internal implementation notes: do not extract what files were edited, what bug was just fixed, what command just ran, or what the assistant reviewed.", - "- feedback ONLY means stable user preferences or user instructions, written in imperative/future-facing form.", - "- decision ONLY means rules that apply to FUTURE work, not decisions already implemented in this session.", - "- project/reference ONLY when the fact is stable across sessions and hard to rediscover from the repository.", + "- decision = future rule/architecture choice; reference = stable lookup fact; project = stable project fact; feedback = stable user preference.", + "- Do not use decision for service names, IDs, URLs, file paths, or one-off session status; use reference/project or skip.", "- If unsure, skip it.", "", "Good memory examples:", + "- REINFORCE [M1]", "- [feedback] User prefers architecture reviews in Traditional Chinese.", - "- [decision] Do not add semantic merge to memory dedupe.", + "- [decision] Keep memory dedupe exact-only for decisions.", "- [project] This repository is an OpenCode plugin using local JSON stores.", "- [reference] Workspace memory is rendered as frozen system[1]; pending memories remain in hot state until compaction.", "", @@ -139,11 +148,13 @@ function buildCompactionPrompt(privateContext: string): string { "- The assistant reviewed code reviewer feedback and updated the plan.", "- Commit a762e86 contains the owner scope fix.", "", - "Format when there ARE durable memories:", + "Format when there ARE REINFORCE/REPLACE commands or durable new candidates:", "Memory candidates:", - "- [feedback|decision|project|reference] future-facing durable fact", + "REINFORCE [M#]", + "REPLACE [M#] [feedback|decision|project|reference] corrected durable fact", + "- [feedback|decision|project|reference] new future-facing durable fact", "", - "Format when there are NO durable memories:", + "Format when there are NO REINFORCE/REPLACE commands or durable candidates:", "Memory candidates:", "(none)", "", @@ -173,6 +184,20 @@ function safeErrorMessage(error: unknown): string { return message.replace(/\s+/g, " ").slice(0, 240); } +type CompactionRefResolution = + | { + ok: true; + refSnapshot: CompactionMemoryRef; + target: LongTermMemoryEntry; + targetIndex: number; + } + | { + ok: false; + reason: "missing_memory_ref_snapshot" | "invalid_memory_ref" | "memory_ref_target_unavailable" | "memory_ref_target_changed"; + refSnapshot?: CompactionMemoryRef; + target?: LongTermMemoryEntry; + }; + async function warnMemoryHook(scope: string, error: unknown, root?: string): Promise { const message = safeErrorMessage(error); console.error(`[memory] ${scope} failed: ${message}`); @@ -200,6 +225,10 @@ async function workspaceIdentity(root: string): Promise<{ workspaceKey: string; return { workspaceKey: workspaceKeyValue, workspaceRootHash: workspaceRootHashValue }; } +function compactionIdFromSummary(summary: string): string | undefined { + return summary.match(/Memory ref snapshot id:\s*([a-zA-Z0-9_-]+)/i)?.[1]; +} + export const MemoryV2Plugin: Plugin = async (input) => { const { directory, client } = input; @@ -259,6 +288,227 @@ export const MemoryV2Plugin: Plugin = async (input) => { }; } + function memoryReinforcedEvidence( + memory: LongTermMemoryEntry | undefined, + ref: string, + outcome: "reinforced" | "rejected", + reasonCodes: string[], + details: EvidenceEventInput["details"] = {}, + ): EvidenceEventInput { + const relationRole = outcome === "rejected" ? "target" : "reinforced"; + return { + type: "memory_reinforced", + phase: "reinforcement", + outcome, + memory: memory ? memoryEvidenceRef(memory) : undefined, + relations: memory ? [{ role: relationRole, memory: memoryEvidenceRef(memory) }] : undefined, + reasonCodes, + details: { + ref, + ...details, + }, + textPreview: memory?.text, + }; + } + + function replacementMemoryId(): string { + return `mem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + } + + function memoryReplacedEvidence( + oldMemory: LongTermMemoryEntry | undefined, + newMemory: LongTermMemoryEntry | undefined, + ref: string, + outcome: "superseded" | "rejected", + reasonCodes: string[], + details: EvidenceEventInput["details"] = {}, + ): EvidenceEventInput { + const relations = outcome === "rejected" + ? [ + ...(oldMemory ? [{ role: "target" as const, memory: memoryEvidenceRef(oldMemory) }] : []), + ] + : [ + ...(oldMemory ? [{ role: "superseded" as const, memory: memoryEvidenceRef(oldMemory) }] : []), + ...(newMemory ? [{ role: "superseded_by" as const, memory: memoryEvidenceRef(newMemory) }] : []), + ]; + return { + type: "memory_replaced_numbered_ref", + phase: "storage", + outcome, + memory: oldMemory ? memoryEvidenceRef(oldMemory) : undefined, + relations: relations.length > 0 ? relations : undefined, + reasonCodes, + details: { + ref, + ...details, + }, + textPreview: newMemory?.text ?? oldMemory?.text, + }; + } + + function compactionRefByLabel(refs: CompactionMemoryRef[]): Map { + return new Map(refs.map(ref => [ref.ref.toUpperCase(), ref])); + } + + function compactionSnapshotStatus( + refs: CompactionMemoryRef[], + expectedCompactionId: string | undefined, + ): { ok: true } | { ok: false; storedCompactionId: string } { + if (refs.length === 0) return { ok: false, storedCompactionId: "none" }; + + const ids = new Set(refs.map(ref => ref.compactionId).filter((id): id is string => typeof id === "string" && id.length > 0)); + if (!expectedCompactionId) return { ok: true }; + if (ids.size === 1 && ids.has(expectedCompactionId)) return { ok: true }; + if (ids.size === 0) return { ok: false, storedCompactionId: "none" }; + if (ids.size === 1) return { ok: false, storedCompactionId: [...ids][0] }; + return { ok: false, storedCompactionId: "mixed" }; + } + + function resolveCompactionMemoryRef( + refs: CompactionMemoryRef[], + refsByLabel: Map, + entries: LongTermMemoryEntry[], + ref: string, + ): CompactionRefResolution { + if (refs.length === 0) { + return { ok: false, reason: "missing_memory_ref_snapshot" }; + } + + const refSnapshot = refsByLabel.get(ref.toUpperCase()); + if (!refSnapshot) { + return { ok: false, reason: "invalid_memory_ref" }; + } + + const targetIndex = entries.findIndex(entry => entry.id === refSnapshot.memoryId); + const target = targetIndex >= 0 ? entries[targetIndex] : undefined; + if (!target || target.status !== "active") { + return { ok: false, reason: "memory_ref_target_unavailable", refSnapshot, target }; + } + + if (workspaceMemoryExactKey(target) !== refSnapshot.exactKey) { + return { ok: false, reason: "memory_ref_target_changed", refSnapshot, target }; + } + + return { ok: true, refSnapshot, target, targetIndex }; + } + + async function applyCompactionMemoryCommands( + sessionID: string, + commands: WorkspaceMemoryCommand[], + compactionId: string | undefined, + ): Promise { + if (commands.length === 0) return; + + const sessionState = await loadSessionState(directory, sessionID); + const snapshotStatus = compactionSnapshotStatus(sessionState.compactionMemoryRefs, compactionId); + const refs = snapshotStatus.ok ? sessionState.compactionMemoryRefs : []; + const refsByLabel = compactionRefByLabel(refs); + const evidence: EvidenceEventInput[] = []; + const now = Date.now(); + let snapshotMismatchDetails: EvidenceEventInput["details"] = {}; + if ("storedCompactionId" in snapshotStatus) { + snapshotMismatchDetails = { + ...(compactionId ? { compactionId } : {}), + storedCompactionId: snapshotStatus.storedCompactionId, + }; + } + + const updateResult = await updateWorkspaceMemoryWithAccounting(directory, workspaceMemory => { + for (const command of commands) { + const resolution = resolveCompactionMemoryRef(refs, refsByLabel, workspaceMemory.entries, command.ref); + if (resolution.ok === false) { + const memoryId = resolution.refSnapshot?.memoryId; + const details = memoryId ? { ...snapshotMismatchDetails, memoryId } : snapshotMismatchDetails; + if (command.kind === "REINFORCE") { + evidence.push(memoryReinforcedEvidence(resolution.target, command.ref, "rejected", [resolution.reason], details)); + } else { + evidence.push(memoryReplacedEvidence(resolution.target, undefined, command.ref, "rejected", [resolution.reason], details)); + } + continue; + } + + const { refSnapshot, target, targetIndex } = resolution; + if (command.kind === "REINFORCE") { + const reinforced = reinforceMemory(target, sessionID, now); + if (reinforced === target) { + evidence.push(memoryReinforcedEvidence(target, command.ref, "rejected", ["numbered_ref_reinforce", "reinforcement_window_blocked"], { + memoryId: refSnapshot.memoryId, + })); + continue; + } + + workspaceMemory.entries[targetIndex] = reinforced; + evidence.push(memoryReinforcedEvidence(reinforced, command.ref, "reinforced", ["numbered_ref_reinforce", "reinforcement_window_allowed"], { + memoryId: refSnapshot.memoryId, + })); + continue; + } + + if (target.source !== "compaction") { + evidence.push(memoryReplacedEvidence(target, undefined, command.ref, "rejected", ["protected_memory_source"], { + memoryId: refSnapshot.memoryId, + source: target.source, + })); + continue; + } + + if ((target.reinforcementCount ?? 0) > 0) { + evidence.push(memoryReplacedEvidence(target, undefined, command.ref, "rejected", ["protected_reinforced_target"], { + memoryId: refSnapshot.memoryId, + reinforcementCount: target.reinforcementCount ?? 0, + })); + continue; + } + + const quality = assessMemoryQuality({ type: command.type, text: command.text, source: "compaction" }); + if (!quality.accepted) { + evidence.push(memoryReplacedEvidence(target, undefined, command.ref, "rejected", quality.reasons, { + memoryId: refSnapshot.memoryId, + })); + continue; + } + + const supersededTarget: LongTermMemoryEntry = { + ...target, + status: "superseded", + updatedAt: new Date(now).toISOString(), + }; + const replacement: LongTermMemoryEntry = { + id: replacementMemoryId(), + type: command.type, + text: command.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars), + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: new Date(now).toISOString(), + updatedAt: new Date(now).toISOString(), + retentionClock: now, + staleAfterDays: staleAfterDaysFor(command.type), + supersedes: [target.id], + }; + + workspaceMemory.entries[targetIndex] = supersededTarget; + workspaceMemory.entries.push(replacement); + evidence.push(memoryReplacedEvidence(supersededTarget, replacement, command.ref, "superseded", [ + "numbered_ref_replace", + command.type === target.type ? "same_type_replace" : "cross_type_replace", + ], { + oldMemoryId: target.id, + newMemoryId: replacement.id, + oldType: target.type, + newType: command.type, + })); + } + + return workspaceMemory; + }); + + await appendEvidenceEvents(directory, [...updateResult.evidence, ...evidence].map(event => ({ + ...event, + sessionHash: sessionID, + }))); + } + function pruneFrozenWorkspaceMemoryCache(now = Date.now()): void { for (const [sessionID, cached] of frozenWorkspaceMemoryCache) { if (now - cached.loadedAt > WORKSPACE_MEMORY_CACHE_LIMITS.frozenTtlMs) { @@ -564,6 +814,13 @@ export const MemoryV2Plugin: Plugin = async (input) => { return props?.sessionID ?? props?.info?.id; } + async function clearCompactionMemoryRefs(sessionID: string): Promise { + await updateSessionState(directory, sessionID, state => { + state.compactionMemoryRefs = []; + return state; + }); + } + return { // Inject workspace memory and hot session state into system prompt "experimental.chat.system.transform": async (hookInput, output) => { @@ -692,23 +949,27 @@ export const MemoryV2Plugin: Plugin = async (input) => { // so we must explicitly carry forward any existing output.context. const otherContext = output.context.filter(Boolean).join("\n\n"); - // Build our private context (workspace memory, hot state, todos) + // Build our private context (numbered workspace memory refs, todos) const contextParts: string[] = []; - // 1. Frozen workspace memory snapshot - const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID); - if (workspaceSnapshot.renderedPrompt) { - contextParts.push(workspaceSnapshot.renderedPrompt); + // 1. Compaction-only numbered workspace memory snapshot + const compactionId = randomUUID(); + const workspaceStore = await loadWorkspaceMemory(directory); + const compactionRefs = accountWorkspaceMemoryCompactionRefs(workspaceStore); + const refsWithCompactionId = compactionRefs.refs.map(ref => ({ ...ref, compactionId })); + await updateSessionState(directory, sessionID, state => { + state.compactionMemoryRefs = refsWithCompactionId; + return state; + }); + await appendEvidenceEvents(directory, compactionRefs.evidence.map(event => ({ + ...event, + sessionHash: sessionID, + }))); + if (compactionRefs.prompt) { + contextParts.push(compactionRefs.prompt); } - // 2. Hot session state - const sessionState = await loadSessionState(directory, sessionID); - const hotPrompt = renderHotSessionState(sessionState, directory); - if (hotPrompt) { - contextParts.push(hotPrompt); - } - - // 3. Pending todos from OpenCode + // 2. Pending todos from OpenCode const todos = await pendingTodos(client, sessionID); const todosPrompt = renderTodosForCompaction(todos); if (todosPrompt) { @@ -721,7 +982,7 @@ export const MemoryV2Plugin: Plugin = async (input) => { .join("\n\n"); // Replace the default prompt entirely with our ---free template - output.prompt = buildCompactionPrompt(privateContext); + output.prompt = buildCompactionPrompt(privateContext, compactionId); // Clear context array since we consumed it into output.prompt. // Subsequent plugins that set output.prompt will also need to check @@ -735,33 +996,40 @@ export const MemoryV2Plugin: Plugin = async (input) => { // Handle session events event: async ({ event }) => { if (event.type === "session.compacted") { + let sessionID: string | undefined; try { - const sessionID = sessionIDFromEventProperties(event.properties); + sessionID = sessionIDFromEventProperties(event.properties); if (!sessionID) return; // Sub-agents don't need post-compaction processing if (await isSubAgent(sessionID)) return; - // Parse latest compaction summary for memory candidates, stage them into - // durable pending journal, then promote pending memories. - const summary = await latestCompactionSummary(client, sessionID); - const parseResult = summary - ? parseWorkspaceMemoryCandidatesWithEvidence(summary, await workspaceIdentity(directory)) - : { entries: [], evidence: [] }; - await appendEvidenceEvents(directory, parseResult.evidence.map(event => ({ - ...event, - sessionHash: sessionID, - }))); - const candidates = parseResult.entries; - if (candidates.length > 0) { - await appendPendingMemories(directory, candidates); - await appendEvidenceEvents(directory, candidates.map(memory => ({ - ...pendingAppendedEvidence(memory), + try { + // Parse latest compaction summary for memory candidates, stage them into + // durable pending journal, then promote pending memories. + const summary = await latestCompactionSummary(client, sessionID); + const compactionId = summary ? compactionIdFromSummary(summary) : undefined; + const parseResult = summary + ? parseWorkspaceMemoryCandidatesWithEvidence(summary, await workspaceIdentity(directory)) + : { entries: [], commands: [], evidence: [] }; + await appendEvidenceEvents(directory, parseResult.evidence.map(event => ({ + ...event, sessionHash: sessionID, }))); - } + await applyCompactionMemoryCommands(sessionID, parseResult.commands, compactionId); + const candidates = parseResult.entries; + if (candidates.length > 0) { + await appendPendingMemories(directory, candidates); + await appendEvidenceEvents(directory, candidates.map(memory => ({ + ...pendingAppendedEvidence(memory), + sessionHash: sessionID, + }))); + } - await promotePendingMemories(sessionID, { includeUnownedJournal: true }); + await promotePendingMemories(sessionID, { includeUnownedJournal: true }); + } finally { + await clearCompactionMemoryRefs(sessionID); + } } catch (error) { // Keep pending memories in session/journal for retry on next event/session. await warnMemoryHook("event.session.compacted", error, directory); diff --git a/src/retention.ts b/src/retention.ts index 4f618ab..b975a0f 100644 --- a/src/retention.ts +++ b/src/retention.ts @@ -30,7 +30,7 @@ export const USER_IMPORTANCE_FACTOR = { export const RETENTION_TYPE_MAX = { feedback: 10, - decision: 10, + decision: 12, project: 8, reference: 6, } as const; diff --git a/src/session-state.ts b/src/session-state.ts index 5a8f867..5a4a496 100644 --- a/src/session-state.ts +++ b/src/session-state.ts @@ -1,10 +1,14 @@ import { relative } from "path"; import { sessionStatePath } from "./paths.ts"; import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts"; -import type { ActiveFile, LongTermMemoryEntry, OpenError, SessionDecision, SessionState } from "./types.ts"; -import { HOT_STATE_LIMITS } from "./types.ts"; +import type { ActiveFile, CompactionMemoryRef, LongTermMemoryEntry, OpenError, SessionDecision, SessionState } from "./types.ts"; +import { HOT_STATE_LIMITS, LONG_TERM_LIMITS } from "./types.ts"; import { memoryKey } from "./pending-journal.ts"; +type SessionStateInput = Omit & { + compactionMemoryRefs?: unknown; +}; + const ACTION_WEIGHT: Record = { edit: 50, write: 45, @@ -22,6 +26,7 @@ export function createEmptySessionState(sessionID: string): SessionState { openErrors: [], recentDecisions: [], pendingMemories: [], + compactionMemoryRefs: [], }; } @@ -33,10 +38,11 @@ export async function loadSessionState(root: string, sessionID: string): Promise loaded.openErrors = Array.isArray(loaded.openErrors) ? loaded.openErrors : []; loaded.recentDecisions = Array.isArray(loaded.recentDecisions) ? loaded.recentDecisions : []; loaded.pendingMemories = Array.isArray(loaded.pendingMemories) ? loaded.pendingMemories : []; + loaded.compactionMemoryRefs = normalizeCompactionMemoryRefs((loaded as SessionStateInput).compactionMemoryRefs); return loaded; } -export async function saveSessionState(root: string, state: SessionState): Promise { +export async function saveSessionState(root: string, state: SessionState | SessionStateInput): Promise { await atomicWriteJSON(await sessionStatePath(root, state.sessionID), normalizeSessionState(state)); } @@ -52,18 +58,53 @@ export async function updateSessionState( current.openErrors = Array.isArray(current.openErrors) ? current.openErrors : []; current.recentDecisions = Array.isArray(current.recentDecisions) ? current.recentDecisions : []; current.pendingMemories = Array.isArray(current.pendingMemories) ? current.pendingMemories : []; + current.compactionMemoryRefs = normalizeCompactionMemoryRefs((current as SessionStateInput).compactionMemoryRefs); return normalizeSessionState(await updater(current)); }); } -function normalizeSessionState(state: SessionState): SessionState { +function normalizeSessionState(state: SessionState | SessionStateInput): SessionState { state.updatedAt = new Date().toISOString(); state.activeFiles = state.activeFiles.slice(0, HOT_STATE_LIMITS.maxActiveFilesStored); state.openErrors = state.openErrors.slice(0, HOT_STATE_LIMITS.maxOpenErrorsStored); state.recentDecisions = state.recentDecisions.slice(0, HOT_STATE_LIMITS.maxRecentDecisionsStored); state.pendingMemories = dedupePendingMemories(Array.isArray(state.pendingMemories) ? state.pendingMemories : []) .slice(-HOT_STATE_LIMITS.maxPendingMemoriesStored); - return state; + return { + ...state, + compactionMemoryRefs: normalizeCompactionMemoryRefs(state.compactionMemoryRefs), + }; +} + +function normalizeCompactionMemoryRefs(value: unknown): CompactionMemoryRef[] { + if (!Array.isArray(value)) return []; + if (value.some(item => !isCompactionMemoryRef(item))) return []; + return value.slice(0, LONG_TERM_LIMITS.maxEntries); +} + +function isCompactionMemoryRef(value: unknown): value is CompactionMemoryRef { + if (!isRecord(value)) return false; + if (typeof value.ref !== "string" || !/^M[1-9]\d*$/.test(value.ref)) return false; + if (typeof value.memoryId !== "string" || value.memoryId.trim() === "") return false; + if (value.compactionId !== undefined && typeof value.compactionId !== "string") return false; + if (!isLongTermType(value.type)) return false; + if (!isLongTermSource(value.source)) return false; + if (typeof value.exactKey !== "string" || value.exactKey.trim() === "") return false; + if (typeof value.identityKey !== "string" || value.identityKey.trim() === "") return false; + if (typeof value.textPreview !== "string") return false; + return typeof value.capturedAt === "number" && Number.isFinite(value.capturedAt); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isLongTermType(value: unknown): value is CompactionMemoryRef["type"] { + return value === "feedback" || value === "project" || value === "decision" || value === "reference"; +} + +function isLongTermSource(value: unknown): value is CompactionMemoryRef["source"] { + return value === "explicit" || value === "compaction" || value === "manual"; } function dedupePendingMemories(memories: LongTermMemoryEntry[]): LongTermMemoryEntry[] { diff --git a/src/types.ts b/src/types.ts index f24cc65..4fbf807 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,6 +28,18 @@ export type LongTermMemoryEntry = { safetyCritical?: boolean; }; +export type CompactionMemoryRef = { + ref: string; + compactionId?: string; + memoryId: string; + type: LongTermType; + source: LongTermSource; + exactKey: string; + identityKey: string; + textPreview: string; + capturedAt: number; +}; + export type WorkspaceMemoryStore = { version: 1; workspace: { @@ -92,6 +104,7 @@ export type SessionState = { openErrors: OpenError[]; recentDecisions: SessionDecision[]; pendingMemories: LongTermMemoryEntry[]; + compactionMemoryRefs: CompactionMemoryRef[]; }; export const LONG_TERM_LIMITS = { diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index c5c18e4..e1d0e8c 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -1,6 +1,6 @@ import { appendFile, mkdir } from "node:fs/promises"; import { dirname } from "node:path"; -import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts"; +import type { CompactionMemoryRef, LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts"; import { LONG_TERM_LIMITS } from "./types.ts"; import { migrationLogPath, workspaceKey, workspaceMemoryPath } from "./paths.ts"; import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts"; @@ -59,6 +59,16 @@ export type WorkspaceMemoryRenderAccounting = { prompt: string; }; +export type WorkspaceMemoryCompactionRefsAccounting = WorkspaceMemoryRenderAccounting & { + refs: CompactionMemoryRef[]; +}; + +type WorkspaceMemoryRenderSelection = { + active: LongTermMemoryEntry[]; + omitted: WorkspaceMemoryRenderAccounting["omitted"]; + maxChars: number; +}; + export type QualityCleanupMigrationLogEntry = { migrationId: string; timestamp: string; @@ -850,14 +860,13 @@ export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string { return accountWorkspaceMemoryRender(store).prompt; } -export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): WorkspaceMemoryRenderAccounting { +function selectWorkspaceMemoryForRender(store: WorkspaceMemoryStore): WorkspaceMemoryRenderSelection { const now = Date.now(); const maxChars = Math.min( store.limits.maxRenderedChars, LONG_TERM_LIMITS.maxRenderedChars ); const omitted: WorkspaceMemoryRenderAccounting["omitted"] = []; - const evidence: EvidenceEventInput[] = []; for (const entry of store.entries) { if (entry.status === "superseded") { @@ -874,6 +883,13 @@ export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): Works const active = typeCapResult.kept.slice(0, LONG_TERM_LIMITS.maxEntries); for (const memory of typeCapResult.kept.slice(LONG_TERM_LIMITS.maxEntries)) omitted.push({ memory, reason: "global_cap" }); + return { active, omitted, maxChars }; +} + +export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): WorkspaceMemoryRenderAccounting { + const { active, omitted, maxChars } = selectWorkspaceMemoryForRender(store); + const evidence: EvidenceEventInput[] = []; + if (active.length === 0) { for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason)); return { rendered: [], omitted, evidence, prompt: "" }; @@ -918,6 +934,68 @@ export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): Works return { rendered, omitted, evidence, prompt: lines.join("\n") }; } +export function accountWorkspaceMemoryCompactionRefs(store: WorkspaceMemoryStore): WorkspaceMemoryCompactionRefsAccounting { + const { active, omitted, maxChars } = selectWorkspaceMemoryForRender(store); + const evidence: EvidenceEventInput[] = []; + const originalById = new Map(store.entries.map(entry => [entry.id, entry])); + + if (active.length === 0) { + for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason)); + return { rendered: [], omitted, evidence, prompt: "", refs: [] }; + } + + const lines: string[] = [ + "Existing workspace memories available for consolidation:", + ]; + const rendered: LongTermMemoryEntry[] = []; + const refs: CompactionMemoryRef[] = []; + const capturedAt = Date.now(); + + for (const type of ["feedback", "project", "decision", "reference"] as const) { + const items = active.filter(entry => entry.type === type); + if (items.length === 0) continue; + + const sectionLines: string[] = [`${type}:`]; + + for (const item of items) { + const ref = `M${refs.length + 1}`; + const line = `[${ref}] ${item.text}`; + if ([...lines, ...sectionLines, line].join("\n").length <= maxChars) { + const original = originalById.get(item.id) ?? item; + sectionLines.push(line); + rendered.push(item); + refs.push({ + ref, + memoryId: item.id, + type: original.type, + source: original.source, + exactKey: workspaceMemoryExactKey(original), + identityKey: workspaceMemoryIdentityKey(original), + textPreview: item.text, + capturedAt, + }); + } else { + omitted.push({ memory: item, reason: "char_budget" }); + } + } + + if (sectionLines.length > 1) { + lines.push(...sectionLines); + } + } + + for (const memory of rendered) evidence.push(renderEvidence(memory, "rendered")); + for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason)); + + return { + rendered, + omitted, + evidence, + prompt: rendered.length > 0 ? lines.join("\n") : "", + refs, + }; +} + function renderEvidence( memory: LongTermMemoryEntry, outcome: "rendered" | "omitted", diff --git a/tests/evidence-log.test.ts b/tests/evidence-log.test.ts index f2edeb5..188c7b7 100644 --- a/tests/evidence-log.test.ts +++ b/tests/evidence-log.test.ts @@ -209,6 +209,39 @@ test("memory_migration_superseded event round-trips through append and query", a } }); +test("memory_reverted_numbered_ref event round-trips through append and query", async () => { + const root = await tempRoot(); + try { + await appendEvidenceEvent(root, eventInput({ + type: "memory_reverted_numbered_ref", + phase: "storage", + outcome: "recovered", + reasonCodes: ["manual_revert_numbered_ref"], + memory: { memoryId: "replacement-memory", type: "decision", source: "compaction", status: "superseded" }, + relations: [ + { role: "superseded", memory: { memoryId: "replacement-memory", type: "decision", source: "compaction", status: "superseded" } }, + { role: "recovered", memory: { memoryId: "restored-memory", type: "decision", source: "compaction", status: "active" } }, + ], + })); + + const result = await queryEvidenceEvents(root, { + types: ["memory_reverted_numbered_ref"], + phases: ["storage"], + outcomes: ["recovered"], + memoryId: "restored-memory", + }); + + assert.equal(result.length, 1); + assert.equal(result[0].type, "memory_reverted_numbered_ref"); + assert.equal(result[0].phase, "storage"); + assert.equal(result[0].outcome, "recovered"); + assert.ok(result[0].reasonCodes.includes("manual_revert_numbered_ref")); + assert.ok(result[0].relations?.some(relation => relation.role === "recovered" && relation.memory?.memoryId === "restored-memory")); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("queryEvidenceEvents supports newestFirst and limit", async () => { const root = await tempRoot(); try { diff --git a/tests/extractors.test.ts b/tests/extractors.test.ts index 083ddc0..f112d87 100644 --- a/tests/extractors.test.ts +++ b/tests/extractors.test.ts @@ -212,6 +212,65 @@ Memory candidates: assert.match(result.evidence[0].textPreview ?? "", /accounting evidence events/); }); +test("parseWorkspaceMemoryCandidatesWithEvidence returns mixed valid commands and entries", () => { + const summary = ` +Memory candidates: +REINFORCE [M3] +REPLACE [M4] [decision] Use numbered refs for safe memory consolidation. +- [feedback] User prefers concise implementation handoffs. +`; + + const result = parseWorkspaceMemoryCandidatesWithEvidence(summary); + + assert.deepEqual(result.commands, [ + { kind: "REINFORCE", ref: "M3" }, + { kind: "REPLACE", ref: "M4", type: "decision", text: "Use numbered refs for safe memory consolidation." }, + ]); + assert.equal(result.entries.length, 1); + assert.equal(result.entries[0].type, "feedback"); + assert.equal(result.entries[0].text, "User prefers concise implementation handoffs."); +}); + +test("parseWorkspaceMemoryCandidatesWithEvidence rejects malformed command attempts", () => { + const summary = ` +Memory candidates: +REINFORCE M3 +REINFORCE [X3] +REPLACE [M3] text without type +REPLACE [M3] [invalid] text +REPLACE [M3] [decision] +`; + + const result = parseWorkspaceMemoryCandidatesWithEvidence(summary); + const rejections = result.evidence.filter(event => event.type === "extraction_candidate_rejected"); + + assert.equal(result.entries.length, 0); + assert.deepEqual(result.commands, []); + assert.equal(rejections.length, 5); + assert.ok(rejections.every(event => event.phase === "extraction")); + assert.ok(rejections.every(event => event.outcome === "rejected")); + assert.deepEqual(rejections.map(event => event.reasonCodes[0]), [ + "invalid_memory_ref", + "invalid_memory_ref", + "invalid_memory_type", + "invalid_memory_type", + "empty_replacement_text", + ]); +}); + +test("parseWorkspaceMemoryCandidates accepts bracketed candidates without bullets", () => { + const summary = ` +Memory candidates: +[project] This repository uses local JSON stores for workspace memory. +`; + + const items = parseWorkspaceMemoryCandidates(summary); + + assert.equal(items.length, 1); + assert.equal(items[0].type, "project"); + assert.equal(items[0].text, "This repository uses local JSON stores for workspace memory."); +}); + test("compaction rejected candidate returns rejection evidence without secrets", () => { const summary = ` Memory candidates: diff --git a/tests/memory-diag-cli.test.ts b/tests/memory-diag-cli.test.ts index 9850a4b..9556035 100644 --- a/tests/memory-diag-cli.test.ts +++ b/tests/memory-diag-cli.test.ts @@ -9,6 +9,8 @@ test("help returns usage without exposing hidden or removed commands", () => { assert.equal("help" in parsed && parsed.help, true); assert.match(parsed.usage, /Usage:/); assert.match(parsed.usage, /memory-diag \[status\]/); + assert.match(parsed.usage, /memory-diag commands/); + assert.match(parsed.usage, /memory-diag revert/); for (const command of ["health", "quality", "rejections", "disappearances", "trace", "coverage", "audit"]) { assert.doesNotMatch(parsed.usage, new RegExp(command)); } @@ -78,6 +80,41 @@ test("current command flag validation messages are preserved", () => { } }); +test("commands accepts workspace json and verbose flags", () => { + const parsed = parseArgs(["commands", "--workspace", "/tmp/workspace", "--json", "--verbose"]); + + assert.equal(parsed.ok, true); + assert.equal("command" in parsed && parsed.command, "commands"); + assert.equal("options" in parsed && parsed.options.workspace, "/tmp/workspace"); + assert.equal("options" in parsed && parsed.options.json, true); + assert.equal("options" in parsed && parsed.options.verbose, true); +}); + +test("revert accepts memory or event selectors and apply flag", () => { + const byMemory = parseArgs(["revert", "--memory", "mem-new", "--workspace", "/tmp/workspace", "--apply"]); + assert.equal(byMemory.ok, true); + assert.equal("command" in byMemory && byMemory.command, "revert"); + assert.equal("options" in byMemory && byMemory.options.memory, "mem-new"); + assert.equal("options" in byMemory && byMemory.options.workspace, "/tmp/workspace"); + assert.equal("options" in byMemory && byMemory.options.apply, true); + + const byEvent = parseArgs(["revert", "--event", "evt_1"]); + assert.equal(byEvent.ok, true); + assert.equal("command" in byEvent && byEvent.command, "revert"); + assert.equal("options" in byEvent && byEvent.options.event, "evt_1"); + assert.equal("options" in byEvent && byEvent.options.apply, undefined); +}); + +test("revert requires exactly one selector", () => { + const missing = parseArgs(["revert"]); + assert.equal(missing.ok, false); + if (!missing.ok) assert.equal(missing.message, "revert requires --memory or --event"); + + const both = parseArgs(["revert", "--memory", "mem-new", "--event", "evt_1"]); + assert.equal(both.ok, false); + if (!both.ok) assert.equal(both.message, "Use either --memory or --event, not both"); +}); + test("rejected invalid since value returns current error", () => { const parsed = parseArgs(["rejected", "--since", "forever"]); diff --git a/tests/memory-diag.test.ts b/tests/memory-diag.test.ts index 8bccf0a..f406ca8 100644 --- a/tests/memory-diag.test.ts +++ b/tests/memory-diag.test.ts @@ -7,7 +7,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; -import { appendEvidenceEvents, type EvidenceEventInput } from "../src/evidence-log.ts"; +import { appendEvidenceEvents, queryEvidenceEvents, type EvidenceEventInput } from "../src/evidence-log.ts"; import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts"; import { LONG_TERM_LIMITS, PROMOTION_RETRY_LIMITS, type PendingMemoryJournalStore } from "../src/types.ts"; import { extractionRejectionLogPath, workspaceEvidenceLogPath, workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts"; @@ -96,6 +96,33 @@ function evidence(overrides: Partial): EvidenceEventInput { }; } +function replacementFixture(): { original: LongTermMemoryEntry; replacement: LongTermMemoryEntry } { + const original = { + ...entry("mem-original", "Original compaction memory to restore", "decision"), + status: "superseded" as const, + }; + const replacement = { + ...entry("mem-replacement", "Replacement compaction memory to revert", "decision"), + supersedes: [original.id], + }; + return { original, replacement }; +} + +function replacementEvidence(original: LongTermMemoryEntry, replacement: LongTermMemoryEntry): EvidenceEventInput { + return evidence({ + type: "memory_replaced_numbered_ref", + phase: "storage", + outcome: "superseded", + memory: { memoryId: original.id, type: original.type, source: original.source, status: "superseded" }, + relations: [ + { role: "superseded", memory: { memoryId: original.id, type: original.type, source: original.source, status: "superseded" } }, + { role: "superseded_by", memory: { memoryId: replacement.id, type: replacement.type, source: replacement.source, status: "active" } }, + ], + reasonCodes: ["numbered_ref_replace", "same_type_replace"], + details: { ref: "M1", oldMemoryId: original.id, newMemoryId: replacement.id }, + }); +} + test("status handles missing workspace store as empty", async () => { const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-missing-health-")); try { @@ -217,7 +244,7 @@ test("memory health reports stored vs rendered retention counts", async () => { assert.match(stdout, /Memory status inspection/); assert.match(stdout, /active: 28 \/ 28/); - assert.match(stdout, /rendered: 20/); + assert.match(stdout, /rendered: 21/); assert.match(stdout, /feedback: 17 \/ 10 FULL/); assert.match(stdout, /Top rendered candidates:\n\s+- strength=/); } finally { @@ -225,6 +252,269 @@ test("memory health reports stored vs rendered retention counts", async () => { } }); +test("memory-diag commands reports zero counts for empty evidence log", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-commands-empty-")); + try { + const stdout = await runMemoryDiag(["commands", "--workspace", root]); + + assert.match(stdout, /Memory command diagnostics/); + assert.match(stdout, /compactions with command evidence: 0/); + assert.match(stdout, /reinforce: 0/); + assert.match(stdout, /replace: 0/); + assert.match(stdout, /invalid\/malformed commands: 0/); + assert.match(stdout, /Protected REPLACE blocked: 0 \(reinforced: 0, source: 0\)/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("memory-diag commands summarizes seeded command evidence", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-commands-")); + try { + await appendEvidenceEvents(root, [ + evidence({ + type: "memory_reinforced", + phase: "reinforcement", + outcome: "reinforced", + memory: { memoryId: "reinforced-1", type: "decision", source: "compaction", status: "active" }, + reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_allowed"], + sessionHash: "command-session-a", + }), + evidence({ + type: "memory_reinforced", + phase: "reinforcement", + outcome: "rejected", + memory: { memoryId: "reinforced-blocked", type: "feedback", source: "compaction", status: "active" }, + reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_blocked"], + sessionHash: "command-session-a", + }), + evidence({ + type: "memory_replaced_numbered_ref", + phase: "storage", + outcome: "superseded", + memory: { memoryId: "replace-old-same", type: "decision", source: "compaction", status: "superseded" }, + relations: [ + { role: "superseded", memory: { memoryId: "replace-old-same", type: "decision", source: "compaction", status: "superseded" } }, + { role: "superseded_by", memory: { memoryId: "replace-new-same", type: "decision", source: "compaction", status: "active" } }, + ], + reasonCodes: ["numbered_ref_replace", "same_type_replace"], + sessionHash: "command-session-b", + }), + evidence({ + type: "memory_replaced_numbered_ref", + phase: "storage", + outcome: "superseded", + memory: { memoryId: "replace-old-cross", type: "project", source: "compaction", status: "superseded" }, + relations: [ + { role: "superseded", memory: { memoryId: "replace-old-cross", type: "project", source: "compaction", status: "superseded" } }, + { role: "superseded_by", memory: { memoryId: "replace-new-cross", type: "decision", source: "compaction", status: "active" } }, + ], + reasonCodes: ["numbered_ref_replace", "cross_type_replace"], + sessionHash: "command-session-b", + }), + evidence({ + type: "memory_replaced_numbered_ref", + phase: "storage", + outcome: "rejected", + memory: { memoryId: "replace-protected-reinforced", type: "decision", source: "compaction", status: "active" }, + reasonCodes: ["protected_reinforced_target"], + sessionHash: "command-session-c", + }), + evidence({ + type: "memory_replaced_numbered_ref", + phase: "storage", + outcome: "rejected", + memory: { memoryId: "replace-protected-source", type: "feedback", source: "explicit", status: "active" }, + reasonCodes: ["protected_memory_source"], + sessionHash: "command-session-c", + }), + evidence({ + type: "extraction_candidate_rejected", + phase: "extraction", + outcome: "rejected", + memory: { type: "decision", source: "compaction" }, + reasonCodes: ["invalid_memory_ref"], + textPreview: "REINFORCE [X3]", + sessionHash: "command-session-c", + }), + ]); + + const stdout = await runMemoryDiag(["commands", "--workspace", root, "--verbose"]); + + assert.match(stdout, /compactions with command evidence: 3/); + assert.match(stdout, /reinforce: 2/); + assert.match(stdout, /replace: 4/); + assert.match(stdout, /reinforced: 1/); + assert.match(stdout, /superseded: 2/); + assert.match(stdout, /rejected: 3/); + assert.match(stdout, /blocked: 3/); + assert.match(stdout, /invalid\/malformed commands: 1/); + assert.match(stdout, /same-type replacements: 1/); + assert.match(stdout, /cross-type replacements: 1/); + assert.match(stdout, /Protected REPLACE blocked: 2 \(reinforced: 1, source: 1\)/); + assert.match(stdout, /protected_reinforced_target: 1/); + assert.match(stdout, /protected_memory_source: 1/); + assert.match(stdout, /Latest command events/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("memory-diag commands json exposes protected replacement counts", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-commands-json-")); + try { + await appendEvidenceEvents(root, [ + evidence({ + type: "memory_replaced_numbered_ref", + phase: "storage", + outcome: "rejected", + memory: { memoryId: "replace-protected-reinforced-json", type: "decision", source: "compaction", status: "active" }, + reasonCodes: ["protected_reinforced_target"], + sessionHash: "json-command-session", + }), + evidence({ + type: "memory_replaced_numbered_ref", + phase: "storage", + outcome: "rejected", + memory: { memoryId: "replace-protected-source-json", type: "feedback", source: "manual", status: "active" }, + reasonCodes: ["protected_memory_source"], + sessionHash: "json-command-session", + }), + ]); + + const stdout = await runMemoryDiag(["commands", "--workspace", root, "--json"]); + const parsed = JSON.parse(stdout) as { + protectedReplacements: { total: number; protectedReinforcedTarget: number; protectedMemorySource: number }; + commands: { reinforce: number; replace: number }; + outcomes: { rejected: number }; + }; + + assert.deepEqual(parsed.protectedReplacements, { + total: 2, + protectedReinforcedTarget: 1, + protectedMemorySource: 1, + }); + assert.equal(parsed.commands.replace, 2); + assert.equal(parsed.commands.reinforce, 0); + assert.equal(parsed.outcomes.rejected, 2); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("memory-diag revert dry-run plans changes without mutating", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-revert-dry-run-")); + try { + const { original, replacement } = replacementFixture(); + await writeWorkspaceStore(root, [original, replacement]); + await appendEvidenceEvents(root, [replacementEvidence(original, replacement)]); + const path = await workspaceMemoryPath(root); + const before = await readFile(path, "utf8"); + + const stdout = await runMemoryDiag(["revert", "--workspace", root, "--memory", replacement.id]); + const after = await readFile(path, "utf8"); + const reverts = await queryEvidenceEvents(root, { types: ["memory_reverted_numbered_ref"] }); + + assert.match(stdout, /Memory revert dry run/); + assert.match(stdout, /replacement: mem-replacement/); + assert.match(stdout, /original: mem-original/); + assert.match(stdout, /No changes applied/); + assert.equal(after, before); + assert.equal(reverts.length, 0); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("memory-diag revert apply restores original and emits evidence", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-revert-apply-")); + try { + const { original, replacement } = replacementFixture(); + await writeWorkspaceStore(root, [original, replacement]); + const [replaceEvent] = await appendEvidenceEvents(root, [replacementEvidence(original, replacement)]); + + const stdout = await runMemoryDiag(["revert", "--workspace", root, "--event", replaceEvent.eventId, "--apply"]); + const stored = JSON.parse(await readFile(await workspaceMemoryPath(root), "utf8")) as WorkspaceMemoryStore; + const restored = stored.entries.find(item => item.id === original.id); + const supersededReplacement = stored.entries.find(item => item.id === replacement.id); + const reverts = await queryEvidenceEvents(root, { types: ["memory_reverted_numbered_ref"], outcomes: ["recovered"] }); + const commandsStdout = await runMemoryDiag(["commands", "--workspace", root, "--verbose"]); + + assert.match(stdout, /Memory revert applied/); + assert.equal(restored?.status, "active"); + assert.equal(supersededReplacement?.status, "superseded"); + assert.ok(restored?.updatedAt && restored.updatedAt !== original.updatedAt); + assert.ok(supersededReplacement?.updatedAt && supersededReplacement.updatedAt !== replacement.updatedAt); + assert.equal(reverts.length, 1); + assert.equal(reverts[0].type, "memory_reverted_numbered_ref"); + assert.equal(reverts[0].outcome, "recovered"); + assert.ok(reverts[0].reasonCodes.includes("manual_revert_numbered_ref")); + assert.ok(reverts[0].relations?.some(relation => relation.role === "recovered" && relation.memory?.memoryId === original.id)); + assert.match(commandsStdout, /memory_reverted_numbered_ref/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("memory-diag revert rejects unsafe requests", async () => { + const missingOriginalRoot = await mkdtemp(join(tmpdir(), "opencode-memory-diag-revert-missing-original-")); + const replacementSupersededRoot = await mkdtemp(join(tmpdir(), "opencode-memory-diag-revert-replacement-superseded-")); + const laterSupersessionRoot = await mkdtemp(join(tmpdir(), "opencode-memory-diag-revert-later-supersession-")); + const wrongEventRoot = await mkdtemp(join(tmpdir(), "opencode-memory-diag-revert-wrong-event-")); + try { + const { original, replacement } = replacementFixture(); + + await writeWorkspaceStore(missingOriginalRoot, [replacement]); + await appendEvidenceEvents(missingOriginalRoot, [replacementEvidence(original, replacement)]); + await assert.rejects( + runMemoryDiagResult(["revert", "--workspace", missingOriginalRoot, "--memory", replacement.id]), + (error: unknown) => { + const err = error as { stderr?: string }; + assert.match(err.stderr ?? "", /revert rejected: original memory mem-original is missing/); + return true; + }, + ); + + await writeWorkspaceStore(replacementSupersededRoot, [original, { ...replacement, status: "superseded" }]); + await appendEvidenceEvents(replacementSupersededRoot, [replacementEvidence(original, replacement)]); + await assert.rejects( + runMemoryDiagResult(["revert", "--workspace", replacementSupersededRoot, "--memory", replacement.id]), + (error: unknown) => { + const err = error as { stderr?: string }; + assert.match(err.stderr ?? "", /revert rejected: replacement memory mem-replacement is not active/); + return true; + }, + ); + + const later = { ...entry("mem-later", "Later active supersession", "decision"), supersedes: [replacement.id] }; + await writeWorkspaceStore(laterSupersessionRoot, [original, replacement, later]); + await appendEvidenceEvents(laterSupersessionRoot, [replacementEvidence(original, replacement)]); + await assert.rejects( + runMemoryDiagResult(["revert", "--workspace", laterSupersessionRoot, "--memory", replacement.id]), + (error: unknown) => { + const err = error as { stderr?: string }; + assert.match(err.stderr ?? "", /revert rejected: replacement memory mem-replacement is superseded by active memory mem-later/); + return true; + }, + ); + + const [wrongEvent] = await appendEvidenceEvents(wrongEventRoot, [evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted" })]); + await assert.rejects( + runMemoryDiagResult(["revert", "--workspace", wrongEventRoot, "--event", wrongEvent.eventId]), + (error: unknown) => { + const err = error as { stderr?: string }; + assert.match(err.stderr ?? "", /revert rejected: event .* is not a memory_replaced_numbered_ref event/); + return true; + }, + ); + } finally { + await rm(missingOriginalRoot, { recursive: true, force: true }); + await rm(replacementSupersededRoot, { recursive: true, force: true }); + await rm(laterSupersessionRoot, { recursive: true, force: true }); + await rm(wrongEventRoot, { recursive: true, force: true }); + } +}); + test("memory health reports dormancy and retention monitoring deprecations", async () => { const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-")); try { @@ -471,7 +761,7 @@ test("status verbose output includes summary and aggregate inspection counts", a assert.match(stdout, /Memory status inspection/); assert.match(stdout, /Summary: Workspace memory quality is degraded:/); assert.match(stdout, /Caps:\n\s+active: 28 \/ 28/); - assert.match(stdout, /decision: 14 \/ 10 FULL/); + assert.match(stdout, /decision: 14 \/ 12 FULL/); assert.match(stdout, /feedback: 14 \/ 10 FULL/); assert.match(stdout, /Retention clocks:\n\s+present: 27\n\s+missing: 0\n\s+invalid: 1/); assert.match(stdout, /Evidence:\n\s+current with evidence: 1\n\s+current without evidence: 27/); diff --git a/tests/memory-quality-eval.test.ts b/tests/memory-quality-eval.test.ts index 70f4697..b37f8a5 100644 --- a/tests/memory-quality-eval.test.ts +++ b/tests/memory-quality-eval.test.ts @@ -35,6 +35,18 @@ const acceptedCases = [ expectedType: "reference", expectedText: /bracketless/, }, + { + name: "stable URL query reference", + line: "- [reference] Memory diagnostics dashboard URL is https://example.test/search?q=memory&view=summary", + expectedType: "reference", + expectedText: /search\?q=memory/, + }, + { + name: "bilingual stable rule", + line: "- [decision] 使用 durable evidence records 保持 memory command auditability", + expectedType: "decision", + expectedText: /保持 memory command auditability/, + }, ] as const; const rejectedCases = [ @@ -78,6 +90,34 @@ const rejectedCases = [ name: "session internal review note", line: "- [feedback] The assistant reviewed the code reviewer feedback and updated the plan", }, + { + name: "unresolved question suffix", + line: "- [project] Should we add semantic merge to workspace memory?", + }, + { + name: "unresolved question prefix", + line: "- [reference] TODO: decide whether to keep this migration path", + }, + { + name: "unresolved Chinese question", + line: "- [decision] 需要決定是否要增加新的記憶壓縮策略", + }, + { + name: "transient bug state", + line: "- [project] Tests are failing and the next step is to fix the retry path", + }, + { + name: "Chinese transient bug state", + line: "- [reference] 目前正在 debug storage lock failure,暫時 workaround 待修", + }, + { + name: "deployment snapshot", + line: "- [reference] Latest deployed revision is rev-a8F3kL9pQ2xZ7bN4", + }, + { + name: "Chinese deployment snapshot", + line: "- [project] 目前部署版本 build-9f8A7c6D5e4F3g2H 是 active release", + }, ] as const; for (const item of acceptedCases) { @@ -125,6 +165,57 @@ test("progress snapshot rejection is type independent", () => { } }); +test("new v1.6 hard quality reasons are emitted by concrete heuristics", () => { + const cases = [ + { + reason: "unresolved_question", + entry: { type: "reference" as const, text: "Open question: whether to keep legacy prompt rendering", source: "compaction" as const }, + }, + { + reason: "unresolved_question", + entry: { type: "project" as const, text: "We need to decide storage migration order?", source: "compaction" as const }, + }, + { + reason: "transient_bug_state", + entry: { type: "project" as const, text: "Currently debugging memory replacement and tests are failing", source: "compaction" as const }, + }, + { + reason: "deployment_snapshot", + entry: { type: "reference" as const, text: "Current active release build is build-X9kLmN42pQ7rT6z", source: "compaction" as const }, + }, + ]; + + for (const { reason, entry } of cases) { + const result = assessMemoryQuality(entry); + assert.equal(result.accepted, false, `${entry.text} should reject`); + assert.ok(result.reasons.includes(reason), `${entry.text} -> ${result.reasons.join(",")}`); + } +}); + +test("unresolved question guardrails preserve stable URL queries and durable rules", () => { + const urlResult = assessMemoryQuality({ + type: "reference", + text: "Memory dashboard URL is https://example.test/search?q=memory&view=summary", + source: "compaction", + }); + assert.equal(urlResult.reasons.includes("unresolved_question"), false, urlResult.reasons.join(",")); + assert.equal(urlResult.accepted, true); + + const durableRule = assessMemoryQuality({ + type: "decision", + text: "Use verifier questions only when acceptance evidence is missing?", + source: "compaction", + }); + assert.equal(durableRule.reasons.includes("unresolved_question"), false, durableRule.reasons.join(",")); +}); + +test("terse_label is diagnostic only and does not block quality acceptance", () => { + const result = assessMemoryQuality({ type: "reference", text: "Cache key", source: "compaction" }); + assert.equal(result.accepted, true); + assert.deepEqual(result.reasons, []); + assert.ok(result.diagnostics?.includes("terse_label")); +}); + test("feedback must be stable user preference or instruction", () => { assert.equal(assessMemoryQuality({ type: "feedback", text: "User prefers concise architecture reviews", source: "compaction" }).accepted, true); assert.equal(assessMemoryQuality({ type: "feedback", text: "Implemented owner-aware cleanup in plugin.ts", source: "compaction" }).accepted, false); @@ -196,7 +287,11 @@ test("hard quality reasons exclude soft whitelist failures", () => { assert.equal(isHardQualityReason("code_or_api_signature"), true); assert.equal(isHardQualityReason("path_heavy"), true); assert.equal(isHardQualityReason("empty"), true); + assert.equal(isHardQualityReason("unresolved_question"), true); + assert.equal(isHardQualityReason("transient_bug_state"), true); + assert.equal(isHardQualityReason("deployment_snapshot"), true); assert.equal(isHardQualityReason("bad_feedback"), false); assert.equal(isHardQualityReason("bad_decision"), false); + assert.equal(isHardQualityReason("terse_label"), false); }); diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index ad9a1f1..d8f39fa 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -6,11 +6,11 @@ import { dirname, join } from "node:path"; import { MemoryV2Plugin } from "../src/plugin.ts"; import { loadSessionState, saveSessionState } from "../src/session-state.ts"; import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts"; -import type { OpenError } from "../src/types.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 { loadPendingJournal, savePendingJournal, memoryKey } from "../src/pending-journal.ts"; -import { loadWorkspaceMemory, updateWorkspaceMemory } from "../src/workspace-memory.ts"; +import { loadWorkspaceMemory, updateWorkspaceMemory, workspaceMemoryExactKey, workspaceMemoryIdentityKey } from "../src/workspace-memory.ts"; import { queryEvidenceEvents } from "../src/evidence-log.ts"; // Mock client for root session (not a sub-agent) @@ -58,6 +58,34 @@ function mockClientWithCompactionSummary(summary: string) { }; } +function mockClientWithMutableCompactionSummary(summary: () => string) { + return { + session: { + get: async () => ({ data: { parentID: null } }), + messages: async () => ({ + data: summary() + ? [{ info: { role: "assistant", summary: true }, parts: [{ type: "text", text: summary() }] }] + : [], + }), + todo: async () => ({ data: [] }), + }, + }; +} + +function compactionRefFor(memory: LongTermMemoryEntry, ref = "M1", overrides: Partial = {}): CompactionMemoryRef { + return { + ref, + memoryId: memory.id, + type: memory.type, + source: memory.source, + exactKey: workspaceMemoryExactKey(memory), + identityKey: workspaceMemoryIdentityKey(memory), + textPreview: memory.text, + capturedAt: Date.now(), + ...overrides, + }; +} + // Helper: create session state with pre-populated open error function createSessionWithError(sessionID: string, error: OpenError) { return { @@ -244,6 +272,21 @@ test("compaction hook sets output.prompt with ---free template", async () => { const client = mockRootClient(); const plugin = await MemoryV2Plugin({ directory: tmpDir, client }); + await updateWorkspaceMemory(tmpDir, store => { + const now = new Date().toISOString(); + store.entries.push({ + id: "compaction-numbered-memory", + type: "decision", + text: "Decision dedupe stays exact-only.", + source: "compaction", + confidence: 0.9, + status: "active", + createdAt: now, + updatedAt: now, + }); + return store; + }); + // Create a session state with some data await saveSessionState(tmpDir, { version: 1, @@ -303,9 +346,25 @@ test("compaction hook sets output.prompt with ---free template", async () => { assert.equal(prompt!.includes("Commit a762e86"), true, "Prompt should explicitly reject commit-hash snapshots"); - // Should contain our context data (hot session state) - assert.equal(prompt!.includes("Hot session state"), true, - "Prompt should include hot session state context"); + // Should contain numbered workspace memory refs but not normal hot state or unnumbered workspace memory. + assert.equal(prompt!.includes("Existing workspace memories available for consolidation:"), true, + "Prompt should include numbered compaction memory context"); + assert.equal(prompt!.includes("[M1] Decision dedupe stays exact-only."), true, + "Prompt should include numbered workspace memory line"); + assert.equal(prompt!.includes("Workspace memory (cross-session"), false, + "Prompt should not include normal unnumbered workspace memory context"); + assert.equal(prompt!.includes("- Decision dedupe stays exact-only."), false, + "Prompt should not duplicate numbered memory as unnumbered memory"); + assert.equal(prompt!.includes("Hot session state"), false, + "Prompt should not include hot session state context"); + assert.equal(prompt!.includes("active_files:"), false, + "Prompt should not include active_files in compaction context"); + assert.equal(prompt!.includes("Test decision"), false, + "Prompt should not include recent_decisions in compaction context"); + + const state = await loadSessionState(tmpDir, "test-session-compaction"); + assert.deepEqual(state.compactionMemoryRefs.map(ref => ref.ref), ["M1"]); + assert.equal(state.compactionMemoryRefs[0]?.memoryId, "compaction-numbered-memory"); // Verify: prompt starts with plain text, not a markup delimiter assert.equal(prompt!.startsWith("---"), false, @@ -332,14 +391,15 @@ test("compaction prompt forbids progress and session-internal memory candidates" assert.match(output.prompt, /CRITICAL MEMORY RULES/); assert.match(output.prompt, /NO completion or progress statements/i); assert.match(output.prompt, /NO session-internal implementation notes/i); - assert.match(output.prompt, /feedback ONLY/i); + assert.match(output.prompt, /decision = future rule\/architecture choice/i); + assert.match(output.prompt, /Do not use decision for service names, IDs, URLs, file paths/i); assert.match(output.prompt, /Most compactions should produce ZERO memories/i); } finally { await rm(tmpDir, { recursive: true, force: true }); } }); -test("compaction prompt includes existing-memory wording reuse guidance", async () => { +test("compaction prompt includes numbered reinforce and replace guidance", async () => { const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-prompt-")); try { const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() }); @@ -350,16 +410,20 @@ test("compaction prompt includes existing-memory wording reuse guidance", async output, ); - assert.match(output.prompt, /Existing workspace memory may already contain durable facts/); - assert.match(output.prompt, /do not create a rephrased duplicate/); - assert.match(output.prompt, /reuse the existing memory wording exactly whenever possible/); - assert.match(output.prompt, /new, materially corrected, or materially more specific/); + assert.match(output.prompt, /Existing memories are numbered \[M#\]/); + assert.match(output.prompt, /emit at most 3 lines like `REINFORCE \[M#\]`/); + assert.match(output.prompt, /Use `REPLACE \[M#\] \[type\] text` only for eligible unreinforced compaction-sourced memories/); + assert.match(output.prompt, /REINFORCE the existing \[M#\].*also emit a new complete \[type\] candidate/s); + assert.doesNotMatch(output.prompt, /reuse the existing memory wording exactly whenever possible/); + assert.doesNotMatch(output.prompt, /feedback ONLY means/); + assert.doesNotMatch(output.prompt, /decision ONLY means/); + assert.doesNotMatch(output.prompt, /project\/reference ONLY when/); } finally { await rm(tmpDir, { recursive: true, force: true }); } }); -test("compaction prompt does not introduce CRUD memory directives", async () => { +test("compaction prompt includes only supported command directives", async () => { const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-prompt-")); try { const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() }); @@ -370,9 +434,12 @@ test("compaction prompt does not introduce CRUD memory directives", async () => output, ); - assert.doesNotMatch(output.prompt, /\bREPLACE\b/); assert.doesNotMatch(output.prompt, /\bDROP\b/); - assert.doesNotMatch(output.prompt, /\bREINFORCE\s+\[M/); + assert.doesNotMatch(output.prompt, /fuzzy merge/i); + assert.doesNotMatch(output.prompt, /semantic merge/i); + assert.doesNotMatch(output.prompt, /embedding/i); + assert.match(output.prompt, /\bREPLACE \[M#\]/); + assert.match(output.prompt, /\bREINFORCE\s+\[M#\]/); } finally { await rm(tmpDir, { recursive: true, force: true }); } @@ -391,12 +458,13 @@ test("compaction prompt preserves Memory candidates output format", async () => assert.match( output.prompt, - /Format when there ARE durable memories:\nMemory candidates:\n- \[feedback\|decision\|project\|reference\] future-facing durable fact/, + /Format when there ARE REINFORCE\/REPLACE commands or durable new candidates:\nMemory candidates:\nREINFORCE \[M#\]\nREPLACE \[M#\] \[feedback\|decision\|project\|reference\] corrected durable fact\n- \[feedback\|decision\|project\|reference\] new future-facing durable fact/, ); assert.match( output.prompt, - /Format when there are NO durable memories:\nMemory candidates:\n\(none\)/, + /Format when there are NO REINFORCE\/REPLACE commands or durable candidates:\nMemory candidates:\n\(none\)/, ); + assert.match(output.prompt, /Good memory examples:[\s\S]*REINFORCE \[M\d+\]/); } finally { await rm(tmpDir, { recursive: true, force: true }); } @@ -960,6 +1028,916 @@ test("session.compacted reinforces existing exact workspace memory", async () => } }); +test("session.compacted applies numbered REINFORCE command to referenced memory", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); + + try { + const now = new Date().toISOString(); + const oldRetentionClock = Date.now() - 10 * 24 * 60 * 60 * 1000; + const existing: LongTermMemoryEntry = { + id: "numbered-reinforce-memory", + type: "decision", + text: "Use numbered refs for explicit memory reinforcement.", + source: "compaction", + confidence: 0.9, + status: "active", + createdAt: now, + updatedAt: now, + retentionClock: oldRetentionClock, + }; + await updateWorkspaceMemory(tmpDir, store => { + store.entries.push(existing); + return store; + }); + await saveSessionState(tmpDir, { + version: 1, + sessionID: "numbered-reinforce-session", + turn: 0, + updatedAt: now, + activeFiles: [], + openErrors: [], + recentDecisions: [], + pendingMemories: [], + compactionMemoryRefs: [compactionRefFor(existing)], + }); + + const plugin = await MemoryV2Plugin({ + directory: tmpDir, + client: mockClientWithCompactionSummary("Memory candidates:\nREINFORCE [M1]"), + }); + await (plugin as Record)["event"]({ + event: { type: "session.compacted", properties: { sessionID: "numbered-reinforce-session" } }, + }); + + const workspace = await loadWorkspaceMemory(tmpDir); + const reinforced = workspace.entries.find(entry => entry.id === existing.id); + assert.equal(reinforced?.reinforcementCount, 1); + assert.equal(reinforced?.lastReinforcedSessionID, "numbered-reinforce-session"); + assert.ok((reinforced?.retentionClock ?? 0) > oldRetentionClock); + + const events = await queryEvidenceEvents(tmpDir, { types: ["memory_reinforced"] }); + assert.ok(events.some(event => + event.outcome === "reinforced" && + event.reasonCodes.includes("numbered_ref_reinforce") && + event.reasonCodes.includes("reinforcement_window_allowed") && + event.memory?.memoryId === existing.id + )); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("session.compacted rejects invalid unavailable and stale REINFORCE refs without mutation", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); + + try { + const now = new Date().toISOString(); + const current: LongTermMemoryEntry = { + id: "stale-numbered-target", + type: "decision", + text: "Current durable memory text should not reinforce from a stale snapshot.", + source: "compaction", + confidence: 0.9, + status: "active", + createdAt: now, + updatedAt: now, + retentionClock: Date.now() - 5 * 24 * 60 * 60 * 1000, + }; + const oldSnapshot = { + ...current, + text: "Old durable memory text from the compaction snapshot.", + }; + await updateWorkspaceMemory(tmpDir, store => { + store.entries.push(current); + return store; + }); + await saveSessionState(tmpDir, { + version: 1, + sessionID: "numbered-reinforce-reject-session", + turn: 0, + updatedAt: now, + activeFiles: [], + openErrors: [], + recentDecisions: [], + pendingMemories: [], + compactionMemoryRefs: [ + compactionRefFor(current, "M1", { + exactKey: workspaceMemoryExactKey(oldSnapshot), + identityKey: workspaceMemoryIdentityKey(oldSnapshot), + textPreview: oldSnapshot.text, + }), + compactionRefFor({ ...current, id: "missing-numbered-target" }, "M2"), + ], + }); + + const plugin = await MemoryV2Plugin({ + directory: tmpDir, + client: mockClientWithCompactionSummary([ + "Memory candidates:", + "REINFORCE [M1]", + "REINFORCE [M2]", + "REINFORCE [M9]", + ].join("\n")), + }); + await (plugin as Record)["event"]({ + event: { type: "session.compacted", properties: { sessionID: "numbered-reinforce-reject-session" } }, + }); + + const workspace = await loadWorkspaceMemory(tmpDir); + const target = workspace.entries.find(entry => entry.id === current.id); + assert.equal(target?.reinforcementCount ?? 0, 0); + + const events = await queryEvidenceEvents(tmpDir, { types: ["memory_reinforced"] }); + const rejectedReasons = events + .filter(event => event.outcome === "rejected") + .flatMap(event => event.reasonCodes); + assert.ok(rejectedReasons.includes("memory_ref_target_changed")); + assert.ok(rejectedReasons.includes("memory_ref_target_unavailable")); + assert.ok(rejectedReasons.includes("invalid_memory_ref")); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("session.compacted emits rejected evidence when numbered REINFORCE is blocked by retention gates", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); + + try { + const nowMs = Date.now(); + const now = new Date(nowMs).toISOString(); + const existing: LongTermMemoryEntry = { + id: "blocked-numbered-reinforce", + type: "feedback", + text: "User prefers deterministic numbered-ref reinforcement evidence.", + source: "compaction", + confidence: 0.9, + status: "active", + createdAt: now, + updatedAt: now, + retentionClock: nowMs - 10 * 24 * 60 * 60 * 1000, + reinforcementCount: 1, + lastReinforcedAt: nowMs - 2 * 60 * 60 * 1000, + lastReinforcedSessionID: "blocked-reinforce-session", + }; + await updateWorkspaceMemory(tmpDir, store => { + store.entries.push(existing); + return store; + }); + await saveSessionState(tmpDir, { + version: 1, + sessionID: "blocked-reinforce-session", + turn: 0, + updatedAt: now, + activeFiles: [], + openErrors: [], + recentDecisions: [], + pendingMemories: [], + compactionMemoryRefs: [compactionRefFor(existing)], + }); + + const plugin = await MemoryV2Plugin({ + directory: tmpDir, + client: mockClientWithCompactionSummary("Memory candidates:\nREINFORCE [M1]"), + }); + await (plugin as Record)["event"]({ + event: { type: "session.compacted", properties: { sessionID: "blocked-reinforce-session" } }, + }); + + const workspace = await loadWorkspaceMemory(tmpDir); + assert.equal(workspace.entries.filter(entry => entry.id === existing.id).length, 1); + const blocked = workspace.entries.find(entry => entry.id === existing.id); + assert.equal(blocked?.reinforcementCount, 1); + + const events = await queryEvidenceEvents(tmpDir, { types: ["memory_reinforced"] }); + assert.ok(events.some(event => + event.outcome === "rejected" && + event.reasonCodes.includes("reinforcement_window_blocked") && + event.memory?.memoryId === existing.id && + event.relations?.some(relation => relation.role === "target" && relation.memory?.memoryId === existing.id) && + !event.relations?.some(relation => relation.role === "reinforced") + )); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("session.compacted applies REINFORCE plus new candidate append through normal promotion", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); + + try { + const now = new Date().toISOString(); + const existing: LongTermMemoryEntry = { + id: "reinforce-plus-append-existing", + type: "decision", + text: "Use numbered refs for durable memory consolidation.", + source: "compaction", + confidence: 0.9, + status: "active", + createdAt: now, + updatedAt: now, + }; + await updateWorkspaceMemory(tmpDir, store => { + store.entries.push(existing); + return store; + }); + await saveSessionState(tmpDir, { + version: 1, + sessionID: "reinforce-plus-append-session", + turn: 0, + updatedAt: now, + activeFiles: [], + openErrors: [], + recentDecisions: [], + pendingMemories: [], + compactionMemoryRefs: [compactionRefFor(existing)], + }); + + const newCandidateText = "Use numbered refs plus audit evidence for durable consolidation."; + const plugin = await MemoryV2Plugin({ + directory: tmpDir, + client: mockClientWithCompactionSummary([ + "Memory candidates:", + "REINFORCE [M1]", + `- [decision] ${newCandidateText}`, + ].join("\n")), + }); + await (plugin as Record)["event"]({ + event: { type: "session.compacted", properties: { sessionID: "reinforce-plus-append-session" } }, + }); + + const workspace = await loadWorkspaceMemory(tmpDir); + const reinforced = workspace.entries.find(entry => entry.id === existing.id); + assert.equal(reinforced?.reinforcementCount, 1); + assert.ok(workspace.entries.some(entry => entry.text === newCandidateText && entry.status === "active")); + + const events = await queryEvidenceEvents(tmpDir, { newestFirst: false }); + assert.ok(events.some(event => event.type === "memory_reinforced" && event.outcome === "reinforced")); + assert.ok(events.some(event => event.type === "promotion_promoted" && event.textPreview === newCandidateText)); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("session.compacted replaces unreinforced compaction memory with same-type numbered command", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); + + try { + const now = new Date().toISOString(); + const existing: LongTermMemoryEntry = { + id: "replace-same-type-target", + type: "decision", + text: "Use old replacement wording for numbered ref consolidation.", + source: "compaction", + confidence: 0.9, + status: "active", + createdAt: now, + updatedAt: now, + }; + await updateWorkspaceMemory(tmpDir, store => { + store.entries.push(existing); + return store; + }); + await saveSessionState(tmpDir, { + version: 1, + sessionID: "replace-same-type-session", + turn: 0, + updatedAt: now, + activeFiles: [], + openErrors: [], + recentDecisions: [], + pendingMemories: [], + compactionMemoryRefs: [compactionRefFor(existing)], + }); + + const replacementText = "Use corrected replacement wording for numbered ref consolidation."; + const plugin = await MemoryV2Plugin({ + directory: tmpDir, + client: mockClientWithCompactionSummary(`Memory candidates:\nREPLACE [M1] [decision] ${replacementText}`), + }); + await (plugin as Record)["event"]({ + event: { type: "session.compacted", properties: { sessionID: "replace-same-type-session" } }, + }); + + const workspace = await loadWorkspaceMemory(tmpDir); + const oldTarget = workspace.entries.find(entry => entry.id === existing.id); + const replacement = workspace.entries.find(entry => entry.text === replacementText); + assert.equal(oldTarget?.status, "superseded"); + assert.equal(replacement?.status, "active"); + assert.equal(replacement?.type, "decision"); + assert.equal(replacement?.source, "compaction"); + assert.deepEqual(replacement?.supersedes, [existing.id]); + + const events = await queryEvidenceEvents(tmpDir, { types: ["memory_replaced_numbered_ref"] }); + assert.ok(events.some(event => + event.outcome === "superseded" && + event.reasonCodes.includes("numbered_ref_replace") && + event.reasonCodes.includes("same_type_replace") && + event.relations?.some(relation => relation.role === "superseded" && relation.memory?.memoryId === existing.id) && + event.relations?.some(relation => relation.role === "superseded_by" && relation.memory?.memoryId === replacement?.id) + )); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("session.compacted applies cross-type numbered REPLACE for eligible compaction target", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); + + try { + const now = new Date().toISOString(); + const existing: LongTermMemoryEntry = { + id: "replace-cross-type-target", + type: "project", + text: "This repository uses an old replacement categorization.", + source: "compaction", + confidence: 0.9, + status: "active", + createdAt: now, + updatedAt: now, + }; + await updateWorkspaceMemory(tmpDir, store => { + store.entries.push(existing); + return store; + }); + await saveSessionState(tmpDir, { + version: 1, + sessionID: "replace-cross-type-session", + turn: 0, + updatedAt: now, + activeFiles: [], + openErrors: [], + recentDecisions: [], + pendingMemories: [], + compactionMemoryRefs: [compactionRefFor(existing)], + }); + + const replacementText = "Use numbered REPLACE only for correcting eligible compaction memory records."; + const plugin = await MemoryV2Plugin({ + directory: tmpDir, + client: mockClientWithCompactionSummary(`Memory candidates:\nREPLACE [M1] [decision] ${replacementText}`), + }); + await (plugin as Record)["event"]({ + event: { type: "session.compacted", properties: { sessionID: "replace-cross-type-session" } }, + }); + + const workspace = await loadWorkspaceMemory(tmpDir); + const oldTarget = workspace.entries.find(entry => entry.id === existing.id); + const replacement = workspace.entries.find(entry => entry.text === replacementText); + assert.equal(oldTarget?.status, "superseded"); + assert.equal(replacement?.type, "decision"); + assert.deepEqual(replacement?.supersedes, [existing.id]); + + const events = await queryEvidenceEvents(tmpDir, { types: ["memory_replaced_numbered_ref"] }); + assert.ok(events.some(event => + event.outcome === "superseded" && + event.reasonCodes.includes("numbered_ref_replace") && + event.reasonCodes.includes("cross_type_replace") + )); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("session.compacted rejects numbered REPLACE for reinforced compaction target", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); + + try { + const now = new Date().toISOString(); + const existing: LongTermMemoryEntry = { + id: "replace-reinforced-target", + type: "decision", + text: "Keep protected replacement targets when they have reinforcement history.", + source: "compaction", + confidence: 0.9, + status: "active", + createdAt: now, + updatedAt: now, + reinforcementCount: 1, + }; + await updateWorkspaceMemory(tmpDir, store => { + store.entries.push(existing); + return store; + }); + await saveSessionState(tmpDir, { + version: 1, + sessionID: "replace-reinforced-session", + turn: 0, + updatedAt: now, + activeFiles: [], + openErrors: [], + recentDecisions: [], + pendingMemories: [], + compactionMemoryRefs: [compactionRefFor(existing)], + }); + + const replacementText = "Use replacement text that should not be applied to reinforced memory."; + const plugin = await MemoryV2Plugin({ + directory: tmpDir, + client: mockClientWithCompactionSummary(`Memory candidates:\nREPLACE [M1] [decision] ${replacementText}`), + }); + await (plugin as Record)["event"]({ + event: { type: "session.compacted", properties: { sessionID: "replace-reinforced-session" } }, + }); + + const workspace = await loadWorkspaceMemory(tmpDir); + assert.equal(workspace.entries.find(entry => entry.id === existing.id)?.status, "active"); + assert.equal(workspace.entries.some(entry => entry.text === replacementText), false); + + const events = await queryEvidenceEvents(tmpDir, { types: ["memory_replaced_numbered_ref"] }); + assert.ok(events.some(event => + event.outcome === "rejected" && + event.reasonCodes.includes("protected_reinforced_target") && + event.memory?.memoryId === existing.id && + event.relations?.some(relation => relation.role === "target" && relation.memory?.memoryId === existing.id) && + !event.relations?.some(relation => relation.role === "superseded" || relation.role === "superseded_by") + )); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("session.compacted rejects numbered REPLACE for explicit or manual targets", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); + + try { + const now = new Date().toISOString(); + const explicitTarget: LongTermMemoryEntry = { + id: "replace-explicit-target", + type: "feedback", + text: "User prefers protected explicit memory records.", + source: "explicit", + confidence: 1, + status: "active", + createdAt: now, + updatedAt: now, + }; + const manualTarget: LongTermMemoryEntry = { + id: "replace-manual-target", + type: "project", + text: "This workspace has protected manual memory records.", + source: "manual", + confidence: 1, + status: "active", + createdAt: now, + updatedAt: now, + }; + await updateWorkspaceMemory(tmpDir, store => { + store.entries.push(explicitTarget, manualTarget); + return store; + }); + await saveSessionState(tmpDir, { + version: 1, + sessionID: "replace-protected-source-session", + turn: 0, + updatedAt: now, + activeFiles: [], + openErrors: [], + recentDecisions: [], + pendingMemories: [], + compactionMemoryRefs: [compactionRefFor(explicitTarget), compactionRefFor(manualTarget, "M2")], + }); + + const plugin = await MemoryV2Plugin({ + directory: tmpDir, + client: mockClientWithCompactionSummary([ + "Memory candidates:", + "REPLACE [M1] [feedback] User prefers unprotected explicit replacement records.", + "REPLACE [M2] [project] This workspace has unprotected manual replacement records.", + ].join("\n")), + }); + await (plugin as Record)["event"]({ + event: { type: "session.compacted", properties: { sessionID: "replace-protected-source-session" } }, + }); + + const workspace = await loadWorkspaceMemory(tmpDir); + assert.equal(workspace.entries.find(entry => entry.id === explicitTarget.id)?.status, "active"); + assert.equal(workspace.entries.find(entry => entry.id === manualTarget.id)?.status, "active"); + assert.equal(workspace.entries.length, 2); + + const events = await queryEvidenceEvents(tmpDir, { types: ["memory_replaced_numbered_ref"] }); + const protectedEvents = events.filter(event => event.reasonCodes.includes("protected_memory_source")); + assert.equal(protectedEvents.length, 2); + assert.ok(protectedEvents.some(event => event.memory?.memoryId === explicitTarget.id)); + assert.ok(protectedEvents.some(event => event.memory?.memoryId === manualTarget.id)); + assert.ok(protectedEvents.every(event => event.relations?.some(relation => relation.role === "target"))); + assert.ok(protectedEvents.every(event => !event.relations?.some(relation => relation.role === "superseded" || relation.role === "superseded_by"))); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("session.compacted rejects low-quality numbered REPLACE and leaves target active", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); + + try { + const now = new Date().toISOString(); + const existing: LongTermMemoryEntry = { + id: "replace-quality-target", + type: "decision", + text: "Use quality gates before applying numbered replacement commands.", + source: "compaction", + confidence: 0.9, + status: "active", + createdAt: now, + updatedAt: now, + }; + await updateWorkspaceMemory(tmpDir, store => { + store.entries.push(existing); + return store; + }); + await saveSessionState(tmpDir, { + version: 1, + sessionID: "replace-quality-session", + turn: 0, + updatedAt: now, + activeFiles: [], + openErrors: [], + recentDecisions: [], + pendingMemories: [], + compactionMemoryRefs: [compactionRefFor(existing)], + }); + + const badReplacement = "Task A.5 completed successfully."; + const plugin = await MemoryV2Plugin({ + directory: tmpDir, + client: mockClientWithCompactionSummary(`Memory candidates:\nREPLACE [M1] [decision] ${badReplacement}`), + }); + await (plugin as Record)["event"]({ + event: { type: "session.compacted", properties: { sessionID: "replace-quality-session" } }, + }); + + const workspace = await loadWorkspaceMemory(tmpDir); + assert.equal(workspace.entries.find(entry => entry.id === existing.id)?.status, "active"); + assert.equal(workspace.entries.some(entry => entry.text === badReplacement), false); + + const events = await queryEvidenceEvents(tmpDir, { types: ["memory_replaced_numbered_ref"] }); + assert.ok(events.some(event => + event.outcome === "rejected" && + event.reasonCodes.includes("progress_snapshot") && + event.memory?.memoryId === existing.id && + event.relations?.some(relation => relation.role === "target" && relation.memory?.memoryId === existing.id) && + !event.relations?.some(relation => relation.role === "superseded" || relation.role === "superseded_by") + )); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("e2e compaction numbered refs reinforce and promote appended candidate then clear refs", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-e2e-")); + + try { + const now = new Date().toISOString(); + const oldRetentionClock = Date.now() - 9 * 24 * 60 * 60 * 1000; + const existing: LongTermMemoryEntry = { + id: "e2e-reinforce-existing", + type: "decision", + text: "Use numbered refs for compaction consolidation decisions.", + source: "compaction", + confidence: 0.9, + status: "active", + createdAt: now, + updatedAt: now, + retentionClock: oldRetentionClock, + }; + await updateWorkspaceMemory(tmpDir, store => { + store.entries.push(existing); + return store; + }); + + const additionText = "Use audit-safe addition memory when numbered refs reinforce existing consolidation facts."; + let summary = ""; + const plugin = await MemoryV2Plugin({ + directory: tmpDir, + client: mockClientWithMutableCompactionSummary(() => summary), + }); + + const compactingOutput = { context: [], prompt: "" }; + await (plugin as Record)["experimental.session.compacting"]( + { sessionID: "e2e-reinforce-append-session" }, + compactingOutput, + ); + + assert.match(compactingOutput.prompt, /Existing workspace memories available for consolidation:/); + assert.match(compactingOutput.prompt, /\[M1\] Use numbered refs for compaction consolidation decisions\./); + assert.deepEqual( + (await loadSessionState(tmpDir, "e2e-reinforce-append-session")).compactionMemoryRefs.map(ref => ref.memoryId), + [existing.id], + ); + const compactionId = (await loadSessionState(tmpDir, "e2e-reinforce-append-session")).compactionMemoryRefs[0]?.compactionId; + assert.ok(compactionId); + summary = [ + "## Goal", + "Continue the task.", + "", + "Memory candidates:", + `Memory ref snapshot id: ${compactionId}`, + "REINFORCE [M1]", + `- [decision] ${additionText}`, + ].join("\n"); + + await (plugin as Record)["event"]({ + event: { type: "session.compacted", properties: { sessionID: "e2e-reinforce-append-session" } }, + }); + + const workspace = await loadWorkspaceMemory(tmpDir); + const reinforced = workspace.entries.find(entry => entry.id === existing.id); + const addition = workspace.entries.find(entry => entry.text === additionText); + assert.equal(reinforced?.reinforcementCount, 1); + assert.equal(reinforced?.lastReinforcedSessionID, "e2e-reinforce-append-session"); + assert.ok((reinforced?.retentionClock ?? 0) > oldRetentionClock); + assert.equal(addition?.status, "active"); + assert.equal(addition?.source, "compaction"); + + const stateAfter = await loadSessionState(tmpDir, "e2e-reinforce-append-session"); + assert.deepEqual(stateAfter.compactionMemoryRefs, []); + assert.equal(stateAfter.pendingMemories.length, 0); + assert.equal((await loadPendingJournal(tmpDir)).entries.length, 0); + + const events = await queryEvidenceEvents(tmpDir, { newestFirst: false }); + assert.ok(events.some(event => event.type === "render_selected" && event.memory?.memoryId === existing.id)); + assert.ok(events.some(event => event.type === "memory_reinforced" && event.outcome === "reinforced" && event.memory?.memoryId === existing.id)); + assert.ok(events.some(event => event.type === "promotion_promoted" && event.textPreview === additionText)); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("e2e compaction numbered refs apply cross-type REPLACE and clear refs", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-e2e-")); + + try { + const now = new Date().toISOString(); + const existing: LongTermMemoryEntry = { + id: "e2e-replace-existing", + type: "project", + text: "This repository stores replacement policy as a project fact.", + source: "compaction", + confidence: 0.9, + status: "active", + createdAt: now, + updatedAt: now, + }; + await updateWorkspaceMemory(tmpDir, store => { + store.entries.push(existing); + return store; + }); + + const replacementText = "Use numbered REPLACE only for eligible unreinforced compaction memory corrections."; + let summary = ""; + const plugin = await MemoryV2Plugin({ + directory: tmpDir, + client: mockClientWithMutableCompactionSummary(() => summary), + }); + + const compactingOutput = { context: [], prompt: "" }; + await (plugin as Record)["experimental.session.compacting"]( + { sessionID: "e2e-replace-session" }, + compactingOutput, + ); + assert.match(compactingOutput.prompt, /\[M1\] This repository stores replacement policy as a project fact\./); + const persistedRef = (await loadSessionState(tmpDir, "e2e-replace-session")).compactionMemoryRefs[0]; + assert.equal(persistedRef?.memoryId, existing.id); + assert.ok(persistedRef?.compactionId); + summary = [ + "Memory candidates:", + `Memory ref snapshot id: ${persistedRef.compactionId}`, + `REPLACE [M1] [decision] ${replacementText}`, + ].join("\n"); + + await (plugin as Record)["event"]({ + event: { type: "session.compacted", properties: { sessionID: "e2e-replace-session" } }, + }); + + const workspace = await loadWorkspaceMemory(tmpDir); + const oldTarget = workspace.entries.find(entry => entry.id === existing.id); + const replacement = workspace.entries.find(entry => entry.text === replacementText); + assert.equal(oldTarget?.status, "superseded"); + assert.equal(replacement?.status, "active"); + assert.equal(replacement?.type, "decision"); + assert.deepEqual(replacement?.supersedes, [existing.id]); + assert.deepEqual((await loadSessionState(tmpDir, "e2e-replace-session")).compactionMemoryRefs, []); + + const events = await queryEvidenceEvents(tmpDir, { types: ["memory_replaced_numbered_ref"] }); + assert.ok(events.some(event => + event.outcome === "superseded" && + event.reasonCodes.includes("numbered_ref_replace") && + event.reasonCodes.includes("cross_type_replace") && + event.relations?.some(relation => relation.role === "superseded" && relation.memory?.memoryId === existing.id) && + event.relations?.some(relation => relation.role === "superseded_by" && relation.memory?.memoryId === replacement?.id) + )); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("e2e compaction stale numbered ref rejects after concurrent workspace mutation and clears refs", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-e2e-")); + + try { + const now = new Date().toISOString(); + const existing: LongTermMemoryEntry = { + id: "e2e-stale-existing", + type: "decision", + text: "Use original stale ref text before concurrent mutation.", + source: "compaction", + confidence: 0.9, + status: "active", + createdAt: now, + updatedAt: now, + }; + await updateWorkspaceMemory(tmpDir, store => { + store.entries.push(existing); + return store; + }); + + const replacementText = "Use replacement text that must not apply to stale refs."; + let summary = ""; + const plugin = await MemoryV2Plugin({ + directory: tmpDir, + client: mockClientWithMutableCompactionSummary(() => summary), + }); + + const compactingOutput = { context: [], prompt: "" }; + await (plugin as Record)["experimental.session.compacting"]( + { sessionID: "e2e-stale-session" }, + compactingOutput, + ); + assert.match(compactingOutput.prompt, /\[M1\] Use original stale ref text before concurrent mutation\./); + const persistedRef = (await loadSessionState(tmpDir, "e2e-stale-session")).compactionMemoryRefs[0]; + assert.equal(persistedRef?.exactKey, workspaceMemoryExactKey(existing)); + assert.ok(persistedRef?.compactionId); + summary = [ + "Memory candidates:", + `Memory ref snapshot id: ${persistedRef.compactionId}`, + `REPLACE [M1] [decision] ${replacementText}`, + ].join("\n"); + + await updateWorkspaceMemory(tmpDir, store => { + const target = store.entries.find(entry => entry.id === existing.id); + assert.ok(target); + target.text = "Use concurrently changed stale ref text before compacted handling."; + target.updatedAt = new Date().toISOString(); + return store; + }); + + await (plugin as Record)["event"]({ + event: { type: "session.compacted", properties: { sessionID: "e2e-stale-session" } }, + }); + + const workspace = await loadWorkspaceMemory(tmpDir); + const targetAfter = workspace.entries.find(entry => entry.id === existing.id); + assert.equal(targetAfter?.status, "active"); + assert.equal(targetAfter?.text, "Use concurrently changed stale ref text before compacted handling."); + assert.equal(workspace.entries.some(entry => entry.text === replacementText), false); + assert.deepEqual((await loadSessionState(tmpDir, "e2e-stale-session")).compactionMemoryRefs, []); + + const events = await queryEvidenceEvents(tmpDir, { types: ["memory_replaced_numbered_ref"] }); + assert.ok(events.some(event => + event.outcome === "rejected" && + event.reasonCodes.includes("memory_ref_target_changed") && + event.memory?.memoryId === existing.id + )); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("overlapping compactions reject numbered commands from stale ref snapshot", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-e2e-overlap-")); + + try { + const now = new Date().toISOString(); + const existing: LongTermMemoryEntry = { + id: "overlap-numbered-target", + type: "decision", + text: "Use compaction snapshot ids to guard numbered memory refs.", + source: "compaction", + confidence: 0.9, + status: "active", + createdAt: now, + updatedAt: now, + retentionClock: Date.now() - 9 * 24 * 60 * 60 * 1000, + }; + await updateWorkspaceMemory(tmpDir, store => { + store.entries.push(existing); + return store; + }); + + let summary = ""; + const client = { + session: { + get: async () => ({ data: { parentID: null } }), + messages: async () => ({ + data: summary + ? [{ info: { role: "assistant", summary: true }, parts: [{ type: "text", text: summary }] }] + : [], + }), + todo: async () => ({ data: [] }), + }, + }; + const plugin = await MemoryV2Plugin({ directory: tmpDir, client }); + + const firstOutput = { prompt: "", context: [] as string[] }; + await (plugin as Record)["experimental.session.compacting"]( + { sessionID: "overlap-compaction-session" }, + firstOutput, + ); + const firstId = (await loadSessionState(tmpDir, "overlap-compaction-session")).compactionMemoryRefs[0]?.compactionId; + assert.ok(firstId); + assert.match(firstOutput.prompt, new RegExp(`Memory ref snapshot id: ${firstId}`)); + + await (plugin as Record)["experimental.session.compacting"]( + { sessionID: "overlap-compaction-session" }, + { prompt: "", context: [] as string[] }, + ); + const secondId = (await loadSessionState(tmpDir, "overlap-compaction-session")).compactionMemoryRefs[0]?.compactionId; + assert.ok(secondId); + assert.notEqual(secondId, firstId); + + summary = [ + "Memory candidates:", + `Memory ref snapshot id: ${firstId}`, + "REINFORCE [M1]", + ].join("\n"); + await (plugin as Record)["event"]({ + event: { type: "session.compacted", properties: { sessionID: "overlap-compaction-session" } }, + }); + + const workspace = await loadWorkspaceMemory(tmpDir); + const target = workspace.entries.find(entry => entry.id === existing.id); + assert.equal(target?.reinforcementCount ?? 0, 0); + assert.deepEqual((await loadSessionState(tmpDir, "overlap-compaction-session")).compactionMemoryRefs, []); + + const events = await queryEvidenceEvents(tmpDir, { types: ["memory_reinforced"] }); + assert.ok(events.some(event => + event.outcome === "rejected" && + event.reasonCodes.includes("missing_memory_ref_snapshot") && + event.details?.compactionId === firstId && + event.details?.storedCompactionId === secondId + )); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("compaction id omission falls back to exact ref validation", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-e2e-compaction-id-fallback-")); + + try { + const now = new Date().toISOString(); + const existing: LongTermMemoryEntry = { + id: "fallback-numbered-target", + type: "decision", + text: "Use exact ref validation when compaction summaries omit snapshot ids.", + source: "compaction", + confidence: 0.9, + status: "active", + createdAt: now, + updatedAt: now, + retentionClock: Date.now() - 9 * 24 * 60 * 60 * 1000, + }; + await updateWorkspaceMemory(tmpDir, store => { + store.entries.push(existing); + return store; + }); + + const plugin = await MemoryV2Plugin({ + directory: tmpDir, + client: mockClientWithCompactionSummary([ + "Memory candidates:", + "REINFORCE [M1]", + ].join("\n")), + }); + + await (plugin as Record)["experimental.session.compacting"]( + { sessionID: "compaction-id-fallback-session" }, + { prompt: "", context: [] as string[] }, + ); + const persistedRef = (await loadSessionState(tmpDir, "compaction-id-fallback-session")).compactionMemoryRefs[0]; + assert.equal(persistedRef?.memoryId, existing.id); + assert.ok(persistedRef?.compactionId); + + await (plugin as Record)["event"]({ + event: { type: "session.compacted", properties: { sessionID: "compaction-id-fallback-session" } }, + }); + + const workspace = await loadWorkspaceMemory(tmpDir); + const target = workspace.entries.find(entry => entry.id === existing.id); + assert.equal(target?.reinforcementCount, 1); + assert.equal(target?.lastReinforcedSessionID, "compaction-id-fallback-session"); + + const events = await queryEvidenceEvents(tmpDir, { types: ["memory_reinforced"] }); + assert.ok(events.some(event => + event.outcome === "reinforced" && + event.memory?.memoryId === existing.id + )); + assert.equal(events.some(event => event.reasonCodes.includes("missing_memory_ref_snapshot")), false); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + test("integration: explicit memory flows from user message through pending journal into workspace", async () => { const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); diff --git a/tests/session-state.test.ts b/tests/session-state.test.ts index f6f07a0..5996cb4 100644 --- a/tests/session-state.test.ts +++ b/tests/session-state.test.ts @@ -1,9 +1,14 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { MemoryV2Plugin } from "../src/plugin.ts"; import * as sessionStateModule from "../src/session-state.ts"; import type { HotSessionStateRenderAccounting } from "../src/session-state.ts"; -import type { ActiveFile, LongTermMemoryEntry, OpenError, SessionDecision, SessionState } from "../src/types.ts"; -import { HOT_STATE_LIMITS } from "../src/types.ts"; +import { sessionStatePath, workspaceMemoryPath } from "../src/paths.ts"; +import type { ActiveFile, CompactionMemoryRef, LongTermMemoryEntry, OpenError, SessionDecision, SessionState } from "../src/types.ts"; +import { HOT_STATE_LIMITS, LONG_TERM_LIMITS } from "../src/types.ts"; type AccountHotSessionStateRender = (state: SessionState, workspaceRoot: string) => HotSessionStateRenderAccounting; @@ -11,7 +16,7 @@ const accountHotSessionStateRender = ( sessionStateModule as typeof sessionStateModule & { accountHotSessionStateRender: AccountHotSessionStateRender } ).accountHotSessionStateRender; -const { createEmptySessionState, renderHotSessionState } = sessionStateModule; +const { createEmptySessionState, loadSessionState, renderHotSessionState, saveSessionState } = sessionStateModule; const root = "/repo"; @@ -25,10 +30,39 @@ function state(overrides: Partial = {}): SessionState { openErrors: [], recentDecisions: [], pendingMemories: [], + compactionMemoryRefs: [], ...overrides, }; } +function compactionRef(index: number, overrides: Partial = {}): CompactionMemoryRef { + return { + ref: `M${index}`, + memoryId: `memory-${index}`, + type: "decision", + source: "compaction", + exactKey: `decision:durable fact ${index}`, + identityKey: `decision:durable fact ${index}`, + textPreview: `Durable fact ${index}`, + capturedAt: 1_777_000_000_000 + index, + ...overrides, + }; +} + +function mockRootClient(summary = "") { + return { + session: { + get: async () => ({ data: { parentID: null } }), + messages: async () => ({ + data: summary + ? [{ info: { role: "assistant", summary: true }, parts: [{ type: "text", text: summary }] }] + : [], + }), + todo: async () => ({ data: [] }), + }, + }; +} + function activeFile(path: string, action: ActiveFile["action"], count: number, lastSeen: number): ActiveFile { return { path, action, count, lastSeen }; } @@ -208,3 +242,122 @@ test("accountHotSessionStateRender counts newline separators in the 700-char bud assert.equal(overAccounting.omitted.length, 1); assert.equal(overAccounting.omitted[0]?.reason, "char_budget"); }); + +test("compaction memory refs round-trip through session state and are capped", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-session-state-")); + + try { + const refs = Array.from({ length: LONG_TERM_LIMITS.maxEntries + 2 }, (_, index) => compactionRef( + index + 1, + index === 0 ? { compactionId: "compaction-snapshot-1" } : {}, + )); + await saveSessionState(tmpDir, state({ + sessionID: "compaction-ref-roundtrip", + compactionMemoryRefs: refs, + })); + + const loaded = await loadSessionState(tmpDir, "compaction-ref-roundtrip"); + assert.equal(loaded.compactionMemoryRefs.length, LONG_TERM_LIMITS.maxEntries); + assert.deepEqual(loaded.compactionMemoryRefs, refs.slice(0, LONG_TERM_LIMITS.maxEntries)); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("invalid stored compaction memory refs normalize to empty without crashing", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-session-state-")); + + try { + const path = await sessionStatePath(tmpDir, "invalid-compaction-ref-session"); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify({ + version: 1, + sessionID: "stale-id", + turn: 0, + updatedAt: "2026-05-05T00:00:00.000Z", + activeFiles: [], + openErrors: [], + recentDecisions: [], + pendingMemories: [], + compactionMemoryRefs: [ + compactionRef(1), + { ...compactionRef(2), ref: "not-a-numbered-ref" }, + ], + }), "utf8"); + + const loaded = await loadSessionState(tmpDir, "invalid-compaction-ref-session"); + assert.equal(loaded.sessionID, "invalid-compaction-ref-session"); + assert.deepEqual(loaded.compactionMemoryRefs, []); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("compaction memory refs never render in hot session state", () => { + const rendered = renderHotSessionState(state({ + activeFiles: [activeFile("/repo/src/a.ts", "read", 1, 1)], + compactionMemoryRefs: [compactionRef(1, { + memoryId: "secret-memory-id", + exactKey: "decision:secret exact key", + identityKey: "decision:secret identity key", + textPreview: "Secret compaction ref preview must not render", + })], + }), root); + + assert.match(rendered, /active_files:/); + assert.doesNotMatch(rendered, /Secret compaction ref preview/); + assert.doesNotMatch(rendered, /secret-memory-id/); + assert.doesNotMatch(rendered, /secret exact key/); + assert.doesNotMatch(rendered, /secret identity key/); + assert.doesNotMatch(rendered, /\bM1\b/); +}); + +test("session.compacted clears compaction memory refs after processing", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-session-state-")); + + try { + await saveSessionState(tmpDir, state({ + sessionID: "clear-compaction-refs-session", + compactionMemoryRefs: [compactionRef(1)], + })); + + const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() }); + await (plugin as Record)["event"]({ + event: { type: "session.compacted", properties: { sessionID: "clear-compaction-refs-session" } }, + }); + + const loaded = await loadSessionState(tmpDir, "clear-compaction-refs-session"); + assert.deepEqual(loaded.compactionMemoryRefs, []); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("session.compacted clears compaction memory refs even when promotion fails", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-session-state-")); + + try { + await saveSessionState(tmpDir, state({ + sessionID: "clear-compaction-refs-failure-session", + pendingMemories: [memory("mem-pending-failure", "Keep pending memory when promotion fails")], + compactionMemoryRefs: [compactionRef(1)], + })); + + const workspacePath = await workspaceMemoryPath(tmpDir); + await rm(workspacePath, { force: true }).catch(() => undefined); + await mkdir(workspacePath, { recursive: true }); + + const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() }); + await (plugin as Record)["event"]({ + event: { type: "session.compacted", properties: { sessionID: "clear-compaction-refs-failure-session" } }, + }); + + const loaded = await loadSessionState(tmpDir, "clear-compaction-refs-failure-session"); + assert.deepEqual(loaded.compactionMemoryRefs, []); + assert.equal(loaded.pendingMemories.length, 1, + "unrelated retryable pending memory should remain on promotion failure"); + assert.equal(loaded.pendingMemories[0].id, "mem-pending-failure"); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index d34030c..905a35c 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -10,6 +10,7 @@ import { queryEvidenceEvents } from "../src/evidence-log.ts"; import { renderWorkspaceMemory, accountWorkspaceMemoryRender, + accountWorkspaceMemoryCompactionRefs, enforceLongTermLimits, dedupeLongTermEntriesWithAccounting, enforceLongTermLimitsWithAccounting, @@ -44,6 +45,15 @@ test("default prompt budgets use calibrated conservative character caps", () => assert.equal(HOT_STATE_LIMITS.maxRenderedChars, 700); }); +test("retention type caps use v1.6 decision headroom without changing other caps", () => { + assert.equal(RETENTION_TYPE_MAX.feedback, 10); + assert.equal(RETENTION_TYPE_MAX.decision, 12); + assert.equal(RETENTION_TYPE_MAX.project, 8); + assert.equal(RETENTION_TYPE_MAX.reference, 6); + assert.equal(LONG_TERM_LIMITS.maxEntries, 28); + assert.equal(LONG_TERM_LIMITS.maxRenderedChars, 3600); +}); + function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry { const now = new Date().toISOString(); return { @@ -159,6 +169,92 @@ test("renderWorkspaceMemory returns empty for no entries", () => { assert.equal(rendered, ""); }); +test("accountWorkspaceMemoryCompactionRefs returns empty prompt and refs for no entries", () => { + const store: WorkspaceMemoryStore = { + version: 1, + workspace: { root: "/repo", key: "abc" }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: [], + updatedAt: new Date().toISOString(), + }; + + const accounting = accountWorkspaceMemoryCompactionRefs(store); + + assert.equal(accounting.prompt, ""); + assert.deepEqual(accounting.refs, []); +}); + +test("accountWorkspaceMemoryCompactionRefs renders numbered refs by type with snapshot keys", () => { + const originalNow = Date.now; + const capturedAt = Date.UTC(2026, 4, 7, 12, 0, 0); + Date.now = () => capturedAt; + + try { + const createdAt = new Date(capturedAt).toISOString(); + const entries: LongTermMemoryEntry[] = [ + { ...entry("mem-feedback", "User requires verifier confirmation after each wave.", "feedback"), createdAt, updatedAt: createdAt, source: "explicit" }, + { ...entry("mem-project", "This repository is an OpenCode plugin using local JSON stores.", "project"), createdAt, updatedAt: createdAt }, + { ...entry("mem-decision", "Decision dedupe stays exact-only.", "decision"), createdAt, updatedAt: createdAt }, + { ...entry("mem-reference", "Workspace memory is rendered as frozen system[1] during normal chat turns.", "reference"), createdAt, updatedAt: createdAt }, + { ...entry("mem-superseded", "Superseded memory should not get a ref.", "decision"), status: "superseded" as const }, + ]; + const store: WorkspaceMemoryStore = { + version: 1, + workspace: { root: "/repo", key: "abc" }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries, + updatedAt: createdAt, + lastActivityAt: createdAt, + }; + + const accounting = accountWorkspaceMemoryCompactionRefs(store); + + assert.equal(accounting.prompt, [ + "Existing workspace memories available for consolidation:", + "feedback:", + "[M1] User requires verifier confirmation after each wave.", + "project:", + "[M2] This repository is an OpenCode plugin using local JSON stores.", + "decision:", + "[M3] Decision dedupe stays exact-only.", + "reference:", + "[M4] Workspace memory is rendered as frozen system[1] during normal chat turns.", + ].join("\n")); + assert.deepEqual(accounting.refs.map(ref => ref.ref), ["M1", "M2", "M3", "M4"]); + assert.deepEqual(accounting.refs.map(ref => ref.memoryId), ["mem-feedback", "mem-project", "mem-decision", "mem-reference"]); + assert.deepEqual(accounting.refs.map(ref => ref.exactKey), accounting.rendered.map(workspaceMemoryExactKey)); + assert.deepEqual(accounting.refs.map(ref => ref.identityKey), accounting.rendered.map(workspaceMemoryIdentityKey)); + assert.ok(accounting.refs.every(ref => ref.capturedAt === capturedAt)); + assert.equal(accounting.refs.some(ref => ref.memoryId === "mem-superseded"), false); + } finally { + Date.now = originalNow; + } +}); + +test("accountWorkspaceMemoryCompactionRefs is bounded by long-term caps", () => { + const now = new Date().toISOString(); + const entries: LongTermMemoryEntry[] = [ + ...Array.from({ length: 10 }, (_, i) => entry(`compaction-feedback-${i}`, `Compaction feedback ${i}`, "feedback")), + ...Array.from({ length: 10 }, (_, i) => entry(`compaction-decision-${i}`, `Compaction decision ${i}`, "decision")), + ...Array.from({ length: 8 }, (_, i) => entry(`compaction-project-${i}`, `Compaction project ${i}`, "project")), + ...Array.from({ length: 6 }, (_, i) => entry(`compaction-reference-${i}`, `Compaction reference ${i}`, "reference")), + ].map(memory => ({ ...memory, createdAt: now, updatedAt: now })); + const store: WorkspaceMemoryStore = { + version: 1, + workspace: { root: "/repo", key: "abc" }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries, + updatedAt: now, + lastActivityAt: now, + }; + + const accounting = accountWorkspaceMemoryCompactionRefs(store); + + assert.equal(accounting.refs.length, LONG_TERM_LIMITS.maxEntries); + assert.equal(accounting.prompt.includes("[M28]"), true); + assert.equal(accounting.prompt.includes("[M29]"), false); +}); + test("accountWorkspaceMemoryRender reports rendered and omitted reasons", () => { const store: WorkspaceMemoryStore = { version: 1, @@ -853,15 +949,15 @@ test("mixed retention scenario applies caps and reinforcement ordering", () => { test("type max sum above global cap still respects maxEntries", () => { const entries: LongTermMemoryEntry[] = [ - ...Array.from({ length: 10 }, (_, i) => entry(`feedback-${i}`, `Unique feedback preference ${i}`, "feedback")), - ...Array.from({ length: 10 }, (_, i) => entry(`decision-${i}`, `Unique durable decision ${i}`, "decision")), - ...Array.from({ length: 8 }, (_, i) => entry(`project-${i}`, `Unique project fact ${i}`, "project")), - ...Array.from({ length: 6 }, (_, i) => entry(`reference-${i}`, `Unique reference fact ${i}`, "reference")), + ...Array.from({ length: RETENTION_TYPE_MAX.feedback }, (_, i) => entry(`feedback-${i}`, `Unique feedback preference ${i}`, "feedback")), + ...Array.from({ length: RETENTION_TYPE_MAX.decision }, (_, i) => entry(`decision-${i}`, `Unique durable decision ${i}`, "decision")), + ...Array.from({ length: RETENTION_TYPE_MAX.project }, (_, i) => entry(`project-${i}`, `Unique project fact ${i}`, "project")), + ...Array.from({ length: RETENTION_TYPE_MAX.reference }, (_, i) => entry(`reference-${i}`, `Unique reference fact ${i}`, "reference")), ]; const kept = enforceLongTermLimits(entries); - assert.equal(entries.length, 34); + assert.equal(entries.length, Object.values(RETENTION_TYPE_MAX).reduce((sum, count) => sum + count, 0)); assert.equal(kept.length, LONG_TERM_LIMITS.maxEntries); }); @@ -2509,7 +2605,7 @@ function decisionEntry(id: string, text: string, timestampMs: number): LongTermM }; } -test("enforceLongTermLimitsWithAccounting capacity drops return 3 lower-ranked decisions in dropped", () => { +test("enforceLongTermLimitsWithAccounting keeps 11th and 12th decisions and type-caps the 13th", () => { const now = Date.UTC(2026, 4, 1, 6, 24, 0); const thirtyDaysAgo = now - 30 * DAY_MS; const existingDecisions = Array.from({ length: 10 }, (_, i) => @@ -2532,8 +2628,12 @@ test("enforceLongTermLimitsWithAccounting capacity drops return 3 lower-ranked d const droppedIds = new Set(capacityDrops.map(event => event.memory.id)); const capacityEvidence = result.evidence.filter(event => event.type === "memory_removed_capacity"); - assert.equal(capacityDrops.length, 3); - assert.equal(capacityEvidence.length, 3); + assert.equal(result.kept.filter(memory => memory.type === "decision").length, RETENTION_TYPE_MAX.decision); + assert.equal(result.kept.some(memory => memory.id === "new-decision-0"), true); + assert.equal(result.kept.some(memory => memory.id === "new-decision-1"), true); + assert.equal(result.kept.some(memory => memory.id === "new-decision-2"), true); + assert.equal(capacityDrops.length, 1); + assert.equal(capacityEvidence.length, 1); for (const event of capacityEvidence) { assert.equal(event.phase, "storage"); assert.equal(event.outcome, "removed"); @@ -2568,20 +2668,20 @@ test("enforceLongTermLimitsWithAccounting emits global_cap evidence for global c const capacityEvidence = result.evidence.filter(event => event.type === "memory_removed_capacity"); const globalCapEvidence = capacityEvidence.filter(event => event.reasonCodes.includes("global_cap")); - assert.equal(entries.length, 34); + assert.equal(entries.length, Object.values(RETENTION_TYPE_MAX).reduce((sum, count) => sum + count, 0)); assert.equal(result.kept.length, LONG_TERM_LIMITS.maxEntries); assert.equal(globalCapEvidence.length, entries.length - LONG_TERM_LIMITS.maxEntries); assert.equal(capacityEvidence.some(event => event.reasonCodes.includes("type_cap")), false); assert.ok(globalCapEvidence.every(event => event.phase === "storage" && event.outcome === "removed")); }); -test("accountWorkspaceMemoryRender emits render_omitted for type_cap with 11 decisions", () => { +test("accountWorkspaceMemoryRender emits render_omitted for type_cap with 13 decisions", () => { const now = Date.UTC(2026, 4, 1, 6, 24, 0); const store: WorkspaceMemoryStore = { version: 1, workspace: { root: "/repo", key: "abc" }, limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, - entries: Array.from({ length: 11 }, (_, i) => + entries: Array.from({ length: 13 }, (_, i) => decisionEntry(`render-decision-${i}`, `Render durable decision ${i}`, now) ), updatedAt: new Date(now).toISOString(), @@ -2598,3 +2698,50 @@ test("accountWorkspaceMemoryRender emits render_omitted for type_cap with 11 dec assert.equal(typeCapOmissions.length, 1); assert.equal(typeCapEvidence.length, 1); }); + +test("accountWorkspaceMemoryRender records char-budget omissions when 12 decisions crowd other types", () => { + const now = Date.UTC(2026, 4, 1, 6, 24, 0); + const longText = (prefix: string, i: number) => `${prefix} ${i}: ${"durable architecture context ".repeat(8)}`; + const entries: LongTermMemoryEntry[] = [ + ...Array.from({ length: 2 }, (_, i) => ({ + ...decisionEntry(`crowd-feedback-${i}`, longText("User feedback preference", i), now), + type: "feedback" as const, + source: "explicit" as const, + })), + ...Array.from({ length: 2 }, (_, i) => ({ + ...decisionEntry(`crowd-project-${i}`, longText("Project fact", i), now), + type: "project" as const, + })), + ...Array.from({ length: RETENTION_TYPE_MAX.decision }, (_, i) => + decisionEntry(`crowd-decision-${i}`, longText("Decision rule", i), now) + ), + ...Array.from({ length: 2 }, (_, i) => ({ + ...decisionEntry(`crowd-reference-${i}`, longText("Reference fact", i), now), + type: "reference" as const, + })), + ]; + const store: WorkspaceMemoryStore = { + version: 1, + workspace: { root: "/repo", key: "abc" }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries, + updatedAt: new Date(now).toISOString(), + lastActivityAt: new Date(now).toISOString(), + }; + + const accounting = accountWorkspaceMemoryRender(store); + const charBudgetOmissions = accounting.omitted.filter(item => item.reason === "char_budget"); + const charBudgetEvidence = accounting.evidence.filter(event => + event.type === "render_omitted" && event.reasonCodes.includes("char_budget") + ); + const omittedIds = new Set(charBudgetOmissions.map(item => item.memory.id)); + const evidenceIds = new Set(charBudgetEvidence.map(event => event.memory?.memoryId).filter(Boolean)); + + assert.ok(accounting.prompt.length <= LONG_TERM_LIMITS.maxRenderedChars); + assert.equal(accounting.omitted.some(item => item.reason === "type_cap"), false); + assert.equal(accounting.omitted.some(item => item.reason === "global_cap"), false); + assert.ok(charBudgetOmissions.length > 0, "crowded render should omit by char budget"); + assert.ok(charBudgetOmissions.some(item => item.memory.type === "reference"), "later non-decision types should be accounted if crowded out"); + assert.equal(charBudgetEvidence.length, charBudgetOmissions.length); + assert.deepEqual(evidenceIds, omittedIds); +});