chore(release): prepare v1.5.2

This commit is contained in:
Ralph Chang
2026-05-01 16:00:44 +08:00
parent 36593b512e
commit f19614565a
14 changed files with 1175 additions and 25 deletions
+4 -1
View File
@@ -22,6 +22,7 @@ export type EvidenceEventType =
| "memory_reinforced"
| "render_selected"
| "render_omitted"
| "memory_removed_capacity"
| "storage_corrupt_json_quarantined"
| "storage_stale_lock_recovered"
| "storage_lock_timeout"
@@ -45,6 +46,7 @@ export type EvidenceOutcome =
| "superseded"
| "rendered"
| "omitted"
| "removed"
| "retried"
| "exhausted"
| "reinforced"
@@ -73,7 +75,8 @@ export type EvidenceRelation = {
| "reinforced"
| "reinforced_by"
| "rendered"
| "omitted";
| "omitted"
| "removed";
memory?: MemoryEvidenceRef;
};
+25 -6
View File
@@ -322,6 +322,13 @@ type ExtractionRejectionLogEntry = {
text: string;
reasons: string[];
source: "compaction";
workspaceKey?: string;
workspaceRootHash?: string;
};
type WorkspaceMemoryCandidateParseOptions = {
workspaceKey?: string;
workspaceRootHash?: string;
};
async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promise<void> {
@@ -341,7 +348,7 @@ function evaluateWorkspaceMemoryCandidate(
},
options: {
fromMemoryTrigger?: boolean;
} = {},
} & WorkspaceMemoryCandidateParseOptions = {},
): { accepted: boolean; reasons: string[] } {
const text = entry.text.trim();
const minLength = options.fromMemoryTrigger ? 6 : 20;
@@ -367,6 +374,8 @@ function evaluateWorkspaceMemoryCandidate(
text: redactCredentials(text),
reasons: quality.reasons,
source: "compaction",
workspaceKey: options.workspaceKey,
workspaceRootHash: options.workspaceRootHash,
});
return { accepted: false, reasons: quality.reasons };
}
@@ -381,7 +390,7 @@ function shouldAcceptWorkspaceMemoryCandidate(
},
options: {
fromMemoryTrigger?: boolean;
} = {},
} & WorkspaceMemoryCandidateParseOptions = {},
): boolean {
return evaluateWorkspaceMemoryCandidate(entry, options).accepted;
}
@@ -410,11 +419,17 @@ function extractCandidateBlock(summary: string): string | null {
return null;
}
export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryEntry[] {
return parseWorkspaceMemoryCandidatesWithEvidence(summary).entries;
export function parseWorkspaceMemoryCandidates(
summary: string,
options?: WorkspaceMemoryCandidateParseOptions,
): LongTermMemoryEntry[] {
return parseWorkspaceMemoryCandidatesWithEvidence(summary, options).entries;
}
export function parseWorkspaceMemoryCandidatesWithEvidence(summary: string): WorkspaceMemoryParseResult {
export function parseWorkspaceMemoryCandidatesWithEvidence(
summary: string,
options: WorkspaceMemoryCandidateParseOptions = {},
): WorkspaceMemoryParseResult {
const block = extractCandidateBlock(summary);
if (!block) return { entries: [], evidence: [] };
@@ -459,7 +474,11 @@ export function parseWorkspaceMemoryCandidatesWithEvidence(summary: string): Wor
// Apply quality gate
const quality = evaluateWorkspaceMemoryCandidate(
{ type, text: normalizedBody.text },
{ fromMemoryTrigger: normalizedBody.hadTrigger },
{
fromMemoryTrigger: normalizedBody.hadTrigger,
workspaceKey: options.workspaceKey,
workspaceRootHash: options.workspaceRootHash,
},
);
if (!quality.accepted) {
evidence.push(extractionEvidence({
+27 -3
View File
@@ -78,15 +78,39 @@ export function isFeedbackQualityViolation(text: string): boolean {
return true;
}
export function isDecisionQualityViolation(text: string): boolean {
const futureRule = /\b(?:use|keep|prefer|avoid|do not|don't|must|should|never|always|require|choose|reject)\b/i.test(text)
export function hasFutureRule(text: string): boolean {
return /\b(?:use|keep|prefer|avoid|do not|don't|must|should|never|always|require|choose|reject)\b/i.test(text)
|| /(?:使用|保持|避免|不要|必須|必须|應該|应该|選擇|选择)/u.test(text);
if (!futureRule) return true;
}
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;
if (/(?:模型|架構|架构|证据|證據|規則|规则|邊界|边界|記憶系統|记忆系统|原因|採用|采用)/u.test(text)) return true;
return false;
}
export function isImplementationStatusDecision(text: string): boolean {
if (/\b(?:implemented|added|updated|fixed|completed|reviewed)\b/i.test(text)) return true;
if (/\b(?:was|were|has been|had been)\b/i.test(text) && /\b(?:previous|last|latest|this session|this wave|already)\b/i.test(text)) return true;
return false;
}
export function isDecisionQualityViolation(text: string): boolean {
if (hasFutureRule(text)) {
if (isImplementationStatusDecision(text)) return true;
return false;
}
if (isArchitectureLikeDecision(text)) {
if (isImplementationStatusDecision(text)) return true;
if (/\b(?:session|wave|task|test|CI|compatibility|commit)\b/i.test(text)) return true;
return false;
}
return true;
}
function isRawErrorViolation(text: string): boolean {
if (/^\s*(Error|TypeError|ReferenceError|SyntaxError|Exception):/i.test(text)) return true;
if (/at \S+ \([^)]+:\d+:\d+\)/.test(text)) return true;
+19 -3
View File
@@ -23,7 +23,8 @@
* - Parses compaction summaries for memory candidates and merges them
*/
import { rm } from "fs/promises";
import { createHash } from "node:crypto";
import { realpath, rm } from "fs/promises";
import type { Plugin } from "@opencode-ai/plugin";
import {
extractExplicitMemoriesWithEvidence,
@@ -57,7 +58,7 @@ import {
addRecentDecision,
renderHotSessionState,
} from "./session-state.ts";
import { sessionStatePath } from "./paths.ts";
import { sessionStatePath, workspaceKey } from "./paths.ts";
import {
latestUserText,
latestCompactionSummary,
@@ -183,6 +184,19 @@ async function warnMemoryHook(scope: string, error: unknown, root?: string): Pro
}
}
async function workspaceRootHash(root: string): Promise<string> {
const resolved = await realpath(root).catch(() => root);
return createHash("sha256").update(resolved).digest("hex").slice(0, 16);
}
async function workspaceIdentity(root: string): Promise<{ workspaceKey: string; workspaceRootHash: string }> {
const [workspaceKeyValue, workspaceRootHashValue] = await Promise.all([
workspaceKey(root),
workspaceRootHash(root),
]);
return { workspaceKey: workspaceKeyValue, workspaceRootHash: workspaceRootHashValue };
}
export const MemoryV2Plugin: Plugin = async (input) => {
const { directory, client } = input;
@@ -728,7 +742,9 @@ export const MemoryV2Plugin: Plugin = async (input) => {
// 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) : { entries: [], evidence: [] };
const parseResult = summary
? parseWorkspaceMemoryCandidatesWithEvidence(summary, await workspaceIdentity(directory))
: { entries: [], evidence: [] };
await appendEvidenceEvents(directory, parseResult.evidence.map(event => ({
...event,
sessionHash: sessionID,
+64 -2
View File
@@ -17,6 +17,7 @@ import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts";
const MIN_ENVELOPE_LENGTH = 80;
const MIGRATION_ID = "2026-04-26-p0-cleanup";
const QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup";
const RETENTION_CLOCK_BACKFILL_MIGRATION_ID = "2026-05-01-retention-clock-backfill";
export type MemoryConsolidationReason =
| "promoted"
@@ -129,6 +130,7 @@ function hasSecurityOrMigrationChange(
if (beforeEntry.text !== afterEntry.text) return true;
if ((beforeEntry.rationale ?? "") !== (afterEntry.rationale ?? "")) return true;
if (beforeEntry.status !== afterEntry.status) return true;
if ((beforeEntry.retentionClock ?? null) !== (afterEntry.retentionClock ?? null)) return true;
}
const beforeMigrations = JSON.stringify(before.migrations ?? []);
@@ -184,7 +186,8 @@ export async function normalizeWorkspaceMemoryWithAccounting(
root: string,
store: WorkspaceMemoryStore,
): Promise<WorkspaceMemoryNormalizationResult> {
const nowIso = new Date().toISOString();
const nowMs = Date.now();
const nowIso = new Date(nowMs).toISOString();
let result: WorkspaceMemoryStore = {
...store,
@@ -236,6 +239,15 @@ export async function normalizeWorkspaceMemoryWithAccounting(
result = runMigrationP0Cleanup(result, nowIso);
}
result.entries = result.entries.map(entry => backfillRetentionClock(entry, nowMs));
if (!result.migrations.includes(RETENTION_CLOCK_BACKFILL_MIGRATION_ID)) {
result = {
...result,
migrations: [...result.migrations, RETENTION_CLOCK_BACKFILL_MIGRATION_ID],
updatedAt: nowIso,
};
}
// P0 accounting only considers active entries. Entries that were already
// superseded before this normalization are preserved in storage; entries that
// lose during this enforcement are reported via accounting events but are not
@@ -262,6 +274,24 @@ export async function normalizeWorkspaceMemoryWithAccounting(
};
}
function backfillRetentionClock(entry: LongTermMemoryEntry, nowMs: number): LongTermMemoryEntry {
if (Number.isFinite(entry.retentionClock)) {
return entry;
}
const createdAtMs = new Date(entry.createdAt).getTime();
if (Number.isFinite(createdAtMs)) {
return { ...entry, retentionClock: createdAtMs };
}
const updatedAtMs = new Date(entry.updatedAt).getTime();
if (Number.isFinite(updatedAtMs)) {
return { ...entry, retentionClock: updatedAtMs };
}
return { ...entry, retentionClock: nowMs };
}
export function runMigrationP0Cleanup(
store: WorkspaceMemoryStore,
nowIso: string,
@@ -470,6 +500,31 @@ function consolidationEvent(
};
}
function capacityRemovalEvidence(
memory: LongTermMemoryEntry,
reason: "type_cap" | "global_cap" | "capacity",
): EvidenceEventInput {
return {
type: "memory_removed_capacity",
phase: "storage",
outcome: "removed",
reasonCodes: [reason],
memory: memoryEvidenceRef(memory),
relations: [{
role: "removed",
memory: memoryEvidenceRef(memory),
}],
details: {
type: memory.type,
globalCap: LONG_TERM_LIMITS.maxEntries,
...(reason === "type_cap" ? { typeCap: RETENTION_TYPE_MAX[memory.type] } : {}),
...(typeof memory.retentionClock === "number" && Number.isFinite(memory.retentionClock) ? { retentionClock: memory.retentionClock } : {}),
...(memory.createdAt ? { createdAt: memory.createdAt } : {}),
...(memory.source ? { source: memory.source } : {}),
},
};
}
/** Choose better memory when identity/topic keys conflict */
function chooseBetterMemory(
a: LongTermMemoryEntry,
@@ -525,6 +580,13 @@ export function enforceLongTermLimitsWithAccounting(
const capped = applyTypeMaxCaps(sorted);
const kept = capped.slice(0, LONG_TERM_LIMITS.maxEntries);
const keptIds = new Set(kept.map(entry => entry.id));
const cappedIds = new Set(capped.map(entry => entry.id));
const typeCapLosers = sorted.filter(entry => !cappedIds.has(entry.id));
const globalCapLosers = capped.filter(entry => !keptIds.has(entry.id));
const capacityEvidence: EvidenceEventInput[] = [
...typeCapLosers.map(entry => capacityRemovalEvidence(entry, "type_cap")),
...globalCapLosers.map(entry => capacityRemovalEvidence(entry, "global_cap")),
];
const capacityDropped = sorted
.filter(entry => !keptIds.has(entry.id))
.map(entry => consolidationEvent(entry, "rejected_capacity"));
@@ -534,7 +596,7 @@ export function enforceLongTermLimitsWithAccounting(
dropped: [...dedupeResult.dropped, ...capacityDropped],
absorbed: dedupeResult.absorbed,
superseded: dedupeResult.superseded,
evidence: dedupeResult.evidence,
evidence: [...dedupeResult.evidence, ...capacityEvidence],
};
}