mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
chore(release): prepare v1.5.2
This commit is contained in:
+4
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user