mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-02 06:19:36 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b46150fab | |||
| 79320cb21d | |||
| 09880c1840 |
@@ -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 <replacement-memory-id>` or `memory-diag revert --event <event-id>` before adding `--apply`.
|
||||
|
||||
## [1.5.5] - 2026-05-05
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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 <memory-id>` |
|
||||
| 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 <replacement-memory-id>` |
|
||||
|
||||
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 <replacement-memory-id>
|
||||
```
|
||||
|
||||
`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
|
||||
|
||||
@@ -1,5 +1,111 @@
|
||||
# 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: <uuid>
|
||||
[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 <replacement-memory-id>
|
||||
npx --package opencode-working-memory memory-diag revert --memory <replacement-memory-id> --apply
|
||||
```
|
||||
|
||||
You can also target a replacement evidence event directly:
|
||||
|
||||
```bash
|
||||
npx --package opencode-working-memory memory-diag revert --event <event-id>
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
### 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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -9,6 +9,8 @@ export function usage(): string {
|
||||
memory-diag rejected [--workspace <path>] [--verbose] [--json]
|
||||
memory-diag missing [--workspace <path>] [--verbose] [--json]
|
||||
memory-diag explain [memory-id] [--workspace <path>] [--raw]
|
||||
memory-diag commands [--workspace <path>] [--verbose] [--json]
|
||||
memory-diag revert (--memory <replacement-id> | --event <event-id>) [--workspace <path>] [--apply]
|
||||
|
||||
Global options:
|
||||
--workspace <path> 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}`);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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<C
|
||||
case "coverage": return runCoverage(options);
|
||||
case "audit": return runAudit(options);
|
||||
case "explain": return runExplain(options);
|
||||
case "commands": return runCommands(options);
|
||||
case "revert": return runRevert(options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { queryEvidenceEvents, type EvidenceEventV1, type EvidenceOutcome } from "../../../src/evidence-log.ts";
|
||||
import { objectFromCounts, sortedCounts } from "../text.ts";
|
||||
import type { CliOptions, CommandResult } from "../types.ts";
|
||||
|
||||
type CommandKind = "reinforce" | "replace";
|
||||
|
||||
type MemoryCommandSummary = {
|
||||
version: 1;
|
||||
generatedAt: string;
|
||||
compactionsWithCommandEvidence: number;
|
||||
commands: Record<CommandKind, number>;
|
||||
outcomes: Record<"reinforced" | "superseded" | "rejected" | "blocked", number>;
|
||||
invalidMalformedCommands: number;
|
||||
replacements: {
|
||||
sameType: number;
|
||||
crossType: number;
|
||||
};
|
||||
protectedReplacements: {
|
||||
total: number;
|
||||
protectedReinforcedTarget: number;
|
||||
protectedMemorySource: number;
|
||||
};
|
||||
rejectionReasons: Record<string, number>;
|
||||
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<string, number>();
|
||||
|
||||
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, number>): 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<CliOptions, "verbose" | "noEmoji"> = {}): 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<CommandResult> {
|
||||
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) };
|
||||
}
|
||||
@@ -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<RevertPlan> {
|
||||
const rawStore = await readJSONFile<WorkspaceMemoryStore>(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<RevertPlan> {
|
||||
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<CommandResult> {
|
||||
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) };
|
||||
}
|
||||
@@ -67,6 +67,8 @@ export type CliOptions = {
|
||||
since?: string;
|
||||
migration?: string;
|
||||
memory?: string;
|
||||
event?: string;
|
||||
apply?: boolean;
|
||||
positional?: string[];
|
||||
auditMode?: "coverage" | "migrations";
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
+103
-10
@@ -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 };
|
||||
}
|
||||
|
||||
+69
-1
@@ -7,6 +7,7 @@ export type MemoryQualityInput = Pick<LongTermMemoryEntry, "type" | "text"> & {
|
||||
export type MemoryQualityResult = {
|
||||
accepted: boolean;
|
||||
reasons: string[];
|
||||
diagnostics?: string[];
|
||||
};
|
||||
|
||||
export const HARD_QUALITY_REASONS: ReadonlySet<string> = new Set([
|
||||
@@ -18,6 +19,9 @@ export const HARD_QUALITY_REASONS: ReadonlySet<string> = 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);
|
||||
}
|
||||
|
||||
+315
-47
@@ -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<void> {
|
||||
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<string, CompactionMemoryRef> {
|
||||
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<string, CompactionMemoryRef>,
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+46
-5
@@ -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<SessionState, "compactionMemoryRefs"> & {
|
||||
compactionMemoryRefs?: unknown;
|
||||
};
|
||||
|
||||
const ACTION_WEIGHT: Record<ActiveFile["action"], number> = {
|
||||
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<void> {
|
||||
export async function saveSessionState(root: string, state: SessionState | SessionStateInput): Promise<void> {
|
||||
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<string, unknown> {
|
||||
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[] {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+81
-3
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
|
||||
+293
-3
@@ -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>): 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/);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
+994
-16
File diff suppressed because it is too large
Load Diff
+156
-3
@@ -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> = {}): SessionState {
|
||||
openErrors: [],
|
||||
recentDecisions: [],
|
||||
pendingMemories: [],
|
||||
compactionMemoryRefs: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function compactionRef(index: number, overrides: Partial<CompactionMemoryRef> = {}): 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<string, Function>)["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<string, Function>)["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 });
|
||||
}
|
||||
});
|
||||
|
||||
+158
-11
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user