mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-02 06:19:36 +02:00
fix(memory): make quality cleanup migration conservative
This commit is contained in:
@@ -9,6 +9,21 @@ export type MemoryQualityResult = {
|
|||||||
reasons: string[];
|
reasons: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const HARD_QUALITY_REASONS: ReadonlySet<string> = new Set([
|
||||||
|
"empty",
|
||||||
|
"progress_snapshot",
|
||||||
|
"raw_error",
|
||||||
|
"commit_or_ci_snapshot",
|
||||||
|
"temporary_status",
|
||||||
|
"active_file_snapshot",
|
||||||
|
"code_or_api_signature",
|
||||||
|
"path_heavy",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isHardQualityReason(reason: string): boolean {
|
||||||
|
return HARD_QUALITY_REASONS.has(reason);
|
||||||
|
}
|
||||||
|
|
||||||
export function assessMemoryQuality(entry: MemoryQualityInput): MemoryQualityResult {
|
export function assessMemoryQuality(entry: MemoryQualityInput): MemoryQualityResult {
|
||||||
const reasons: string[] = [];
|
const reasons: string[] = [];
|
||||||
const text = entry.text.trim();
|
const text = entry.text.trim();
|
||||||
|
|||||||
+10
-5
@@ -2,7 +2,7 @@ import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
|
|||||||
import { LONG_TERM_LIMITS } from "./types.ts";
|
import { LONG_TERM_LIMITS } from "./types.ts";
|
||||||
import { workspaceKey, workspaceMemoryPath } from "./paths.ts";
|
import { workspaceKey, workspaceMemoryPath } from "./paths.ts";
|
||||||
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||||
import { assessMemoryQuality, isProgressSnapshotViolation } from "./memory-quality.ts";
|
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts";
|
||||||
|
|
||||||
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
|
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
|
||||||
const MIN_ENVELOPE_LENGTH = 80;
|
const MIN_ENVELOPE_LENGTH = 80;
|
||||||
@@ -188,9 +188,11 @@ export async function normalizeWorkspaceMemoryWithAccounting(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// One-time migration for legacy snapshot violations
|
// One-time migrations for legacy/low-quality snapshot violations.
|
||||||
result = runMigrationP0Cleanup(result, nowIso);
|
// Run quality cleanup first so hard violations receive quality audit tags
|
||||||
|
// before the older P0 project-only cleanup marks progress snapshots.
|
||||||
result = runMigrationQualityCleanup(result, nowIso);
|
result = runMigrationQualityCleanup(result, nowIso);
|
||||||
|
result = runMigrationP0Cleanup(result, nowIso);
|
||||||
|
|
||||||
// P0 accounting only considers active entries. Entries that were already
|
// P0 accounting only considers active entries. Entries that were already
|
||||||
// superseded before this normalization are preserved in storage; entries that
|
// superseded before this normalization are preserved in storage; entries that
|
||||||
@@ -285,7 +287,7 @@ export function runMigrationP0Cleanup(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function runMigrationQualityCleanup(
|
export function runMigrationQualityCleanup(
|
||||||
store: WorkspaceMemoryStore,
|
store: WorkspaceMemoryStore,
|
||||||
nowIso: string,
|
nowIso: string,
|
||||||
): WorkspaceMemoryStore {
|
): WorkspaceMemoryStore {
|
||||||
@@ -301,11 +303,14 @@ function runMigrationQualityCleanup(
|
|||||||
const quality = assessMemoryQuality(entry);
|
const quality = assessMemoryQuality(entry);
|
||||||
if (quality.accepted) return entry;
|
if (quality.accepted) return entry;
|
||||||
|
|
||||||
|
const hardReasons = quality.reasons.filter(isHardQualityReason);
|
||||||
|
if (hardReasons.length === 0) return entry;
|
||||||
|
|
||||||
changed = true;
|
changed = true;
|
||||||
const tags = new Set([
|
const tags = new Set([
|
||||||
...(entry.tags ?? []),
|
...(entry.tags ?? []),
|
||||||
"quality_cleanup",
|
"quality_cleanup",
|
||||||
...quality.reasons.map(reason => `quality:${reason}`),
|
...hardReasons.map(reason => `quality:${reason}`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { extractExplicitMemories, parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
|
import { extractExplicitMemories, parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
|
||||||
import { assessMemoryQuality } from "../src/memory-quality.ts";
|
import { assessMemoryQuality, isHardQualityReason } from "../src/memory-quality.ts";
|
||||||
import { expectedAcceptedFixtureIds, reviewerCurrent28Fixture } from "./fixtures/memory-quality-current-28.ts";
|
import { expectedAcceptedFixtureIds, reviewerCurrent28Fixture } from "./fixtures/memory-quality-current-28.ts";
|
||||||
|
|
||||||
const acceptedCases = [
|
const acceptedCases = [
|
||||||
@@ -159,3 +159,17 @@ test("explicit memories bypass extraction quality gate", () => {
|
|||||||
assert.equal(entries[0].source, "explicit");
|
assert.equal(entries[0].source, "explicit");
|
||||||
assert.match(entries[0].text, /Wave 1 completed/);
|
assert.match(entries[0].text, /Wave 1 completed/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("hard quality reasons exclude soft whitelist failures", () => {
|
||||||
|
assert.equal(isHardQualityReason("progress_snapshot"), true);
|
||||||
|
assert.equal(isHardQualityReason("raw_error"), true);
|
||||||
|
assert.equal(isHardQualityReason("commit_or_ci_snapshot"), true);
|
||||||
|
assert.equal(isHardQualityReason("temporary_status"), true);
|
||||||
|
assert.equal(isHardQualityReason("active_file_snapshot"), true);
|
||||||
|
assert.equal(isHardQualityReason("code_or_api_signature"), true);
|
||||||
|
assert.equal(isHardQualityReason("path_heavy"), true);
|
||||||
|
assert.equal(isHardQualityReason("empty"), true);
|
||||||
|
|
||||||
|
assert.equal(isHardQualityReason("bad_feedback"), false);
|
||||||
|
assert.equal(isHardQualityReason("bad_decision"), false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import {
|
|||||||
saveWorkspaceMemory,
|
saveWorkspaceMemory,
|
||||||
updateWorkspaceMemoryWithAccounting,
|
updateWorkspaceMemoryWithAccounting,
|
||||||
} from "../src/workspace-memory.ts";
|
} from "../src/workspace-memory.ts";
|
||||||
import { isProgressSnapshotViolation } from "../src/memory-quality.ts";
|
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "../src/memory-quality.ts";
|
||||||
import { reviewerCurrent28Fixture, expectedAcceptedFixtureIds } from "./fixtures/memory-quality-current-28.ts";
|
import { reviewerCurrent28Fixture } from "./fixtures/memory-quality-current-28.ts";
|
||||||
|
|
||||||
function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry {
|
function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -954,7 +954,84 @@ test("runMigrationP0Cleanup marks only non-explicit project snapshots and runs o
|
|||||||
assert.equal(twice.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.entries.find(e => e.id === "project-snapshot")?.updatedAt);
|
assert.equal(twice.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.entries.find(e => e.id === "project-snapshot")?.updatedAt);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("quality cleanup migration supersedes low-quality compaction memories from current-28 fixture", async () => {
|
test("quality cleanup migration preserves soft-only feedback and decision violations", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "wm-quality-soft-preserve-"));
|
||||||
|
try {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await saveWorkspaceMemory(root, {
|
||||||
|
version: 1,
|
||||||
|
workspace: { root, key: await workspaceKey(root) },
|
||||||
|
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: "soft_feedback",
|
||||||
|
type: "feedback",
|
||||||
|
text: "UI 要統一風格:兩個表格都要 scrollable,約 20 rows",
|
||||||
|
source: "compaction",
|
||||||
|
confidence: 0.75,
|
||||||
|
status: "active",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "soft_decision",
|
||||||
|
type: "decision",
|
||||||
|
text: "Product branding is \"OpenCode Working Memory\" without \"Plugin\" in the name",
|
||||||
|
source: "compaction",
|
||||||
|
confidence: 0.75,
|
||||||
|
status: "active",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
staleAfterDays: 45,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
migrations: [],
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaded = await loadWorkspaceMemory(root);
|
||||||
|
assert.equal(loaded.entries.find(e => e.id === "soft_feedback")?.status, "active");
|
||||||
|
assert.equal(loaded.entries.find(e => e.id === "soft_decision")?.status, "active");
|
||||||
|
assert.ok(loaded.migrations?.includes("2026-04-28-quality-cleanup"));
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quality cleanup migration supersedes hard quality violations", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "wm-quality-hard-supersede-"));
|
||||||
|
try {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await saveWorkspaceMemory(root, {
|
||||||
|
version: 1,
|
||||||
|
workspace: { root, key: await workspaceKey(root) },
|
||||||
|
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||||
|
entries: [{
|
||||||
|
id: "hard_progress",
|
||||||
|
type: "project",
|
||||||
|
text: "測試套件:1237 tests pass, 226 suites",
|
||||||
|
source: "compaction",
|
||||||
|
confidence: 0.75,
|
||||||
|
status: "active",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
staleAfterDays: 60,
|
||||||
|
}],
|
||||||
|
migrations: [],
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaded = await loadWorkspaceMemory(root);
|
||||||
|
const entry = loaded.entries.find(e => e.id === "hard_progress");
|
||||||
|
assert.equal(entry?.status, "superseded");
|
||||||
|
assert.ok(entry?.tags?.includes("quality_cleanup"));
|
||||||
|
assert.ok(entry?.tags?.includes("quality:progress_snapshot"));
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quality cleanup migration supersedes only hard violations from current fixture", async () => {
|
||||||
const root = await mkdtemp(join(tmpdir(), "wm-quality-cleanup-"));
|
const root = await mkdtemp(join(tmpdir(), "wm-quality-cleanup-"));
|
||||||
try {
|
try {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -972,10 +1049,12 @@ test("quality cleanup migration supersedes low-quality compaction memories from
|
|||||||
const supersededIds = new Set(loaded.entries.filter(entry => entry.status === "superseded").map(entry => entry.id));
|
const supersededIds = new Set(loaded.entries.filter(entry => entry.status === "superseded").map(entry => entry.id));
|
||||||
|
|
||||||
for (const entry of reviewerCurrent28Fixture) {
|
for (const entry of reviewerCurrent28Fixture) {
|
||||||
if (expectedAcceptedFixtureIds.has(entry.id)) {
|
const quality = assessMemoryQuality(entry);
|
||||||
assert.equal(activeIds.has(entry.id), true, `${entry.id} should remain active`);
|
const hasHardReason = quality.reasons.some(isHardQualityReason);
|
||||||
} else {
|
if (entry.source === "compaction" && !quality.accepted && hasHardReason) {
|
||||||
assert.equal(supersededIds.has(entry.id), true, `${entry.id} should be superseded`);
|
assert.equal(supersededIds.has(entry.id), true, `${entry.id} should be superseded`);
|
||||||
|
} else {
|
||||||
|
assert.equal(activeIds.has(entry.id), true, `${entry.id} should remain active`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user