mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
feat: release v1.2.2 with multilingual memory hardening
This commit is contained in:
+83
-18
@@ -27,6 +27,16 @@ function isNegatedMemoryRequest(text: string, matchIndex: number): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Japanese negative
|
||||
if (/(?:覚えないで|記憶しないで|メモしないで)\s*$/u.test(prefix)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Korean negative
|
||||
if (/(?:기억하지\s*마|기억하지마|메모하지\s*마|메모하지마)\s*$/u.test(prefix)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -35,7 +45,11 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
|
||||
// Pattern 必須在行首匹配,避免匹配到句子中間的非指令式用法
|
||||
const patterns = [
|
||||
// 中文:請/幫我 + 記住 + 可選後綴
|
||||
/(?:^|\n)\s*(?:请|請)?(?:帮我|幫我)?(?:记住|記住)(?:这一点|這一點|这点|這點|这个|這個)?[::,,]?\s*(.+)$/gim,
|
||||
/(?:^|\n)\s*(?:请|請)?(?:帮我|幫我)?(?:记住|記住|记得|記得|记下来|記下來)(?:这一点|這一點|这点|這點|这个|這個)?[::,,]?\s*(.+)$/gim,
|
||||
// 日文(長詞優先):覚えておいて must come before 覚えて
|
||||
/(?:^|\n)\s*(?:覚えておいて|覚えて|忘れないで|メモして)[::,,]?\s*(.+)$/gim,
|
||||
// 韓文(長詞優先):기억해줘/메모해줘 must come before 기억해/메모해
|
||||
/(?:^|\n)\s*(?:기억해줘|기억해|잊지 마|잊지마|메모해줘|메모해)[::,,]?\s*(.+)$/gim,
|
||||
// 英文:remember this/that - 必須在行首,避免 "to remember" 非指令匹配
|
||||
/(?:^|\n)\s*(?:please\s+)?remember\s+(?:this|that)?[::,,]?\s*(.+)$/gim,
|
||||
// save/add to memory
|
||||
@@ -179,6 +193,31 @@ export function classifyCommand(command: string): OpenError["category"] | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeCandidateBody(body: string): { text: string; hadTrigger: boolean } | null {
|
||||
const text = body.trim();
|
||||
const triggerPatterns = [
|
||||
/(?:请|請)?(?:帮我|幫我)?(?:记住|記住|记得|記得|记下来|記下來)(?:这一点|這一點|这点|這點|这个|這個)?[::,,]?\s*(.+)$/im,
|
||||
/(?:覚えておいて|覚えて|忘れないで|メモして)[::,,]?\s*(.+)$/im,
|
||||
/(?:기억해줘|기억해|잊지 마|잊지마|메모해줘|메모해)[::,,]?\s*(.+)$/im,
|
||||
/(?:please\s+)?remember\s+(?:this|that)?[::,,]?\s*(.+)$/im,
|
||||
/(?:please\s+)?(?:save|add)\s+(?:this|that)?\s*(?:to|in)\s+memory[::,,]?\s*(.+)$/im,
|
||||
/(?:please\s+)?commit\s+(?:this|that)?\s*to memory[::,,]?\s*(.+)$/im,
|
||||
];
|
||||
|
||||
for (const pattern of triggerPatterns) {
|
||||
const match = pattern.exec(text);
|
||||
if (!match) continue;
|
||||
|
||||
const triggerIndex = match.index + (match[0].match(/^\s*/)?.[0]?.length || 0);
|
||||
if (isNegatedMemoryRequest(text, triggerIndex)) return null;
|
||||
|
||||
const extracted = match[1]?.trim();
|
||||
return extracted ? { text: extracted, hadTrigger: true } : null;
|
||||
}
|
||||
|
||||
return { text, hadTrigger: false };
|
||||
}
|
||||
|
||||
function extractFirstPath(text: string): string | undefined {
|
||||
return text.match(/[\w./-]+\.(ts|tsx|js|jsx|json|md|py|go|rs)/)?.[0];
|
||||
}
|
||||
@@ -187,16 +226,22 @@ function extractFirstPath(text: string): string | undefined {
|
||||
* Quality gate for workspace memory candidates.
|
||||
* Rejects low-quality entries like git hashes, error messages, etc.
|
||||
*/
|
||||
function shouldAcceptWorkspaceMemoryCandidate(entry: {
|
||||
type: LongTermType;
|
||||
text: string;
|
||||
}): boolean {
|
||||
function shouldAcceptWorkspaceMemoryCandidate(
|
||||
entry: {
|
||||
type: LongTermType;
|
||||
text: string;
|
||||
},
|
||||
options: {
|
||||
fromMemoryTrigger?: boolean;
|
||||
} = {},
|
||||
): boolean {
|
||||
const text = entry.text.trim();
|
||||
const minLength = options.fromMemoryTrigger ? 6 : 20;
|
||||
|
||||
// Too short (with type-specific allowlist for stable config values)
|
||||
if (entry.type === "reference" && /\b(?:admin\s+)?pin\s|scrypt|n=\d+|r=\d+|p=\d+/i.test(text)) {
|
||||
// Stable config values can be short — allow below generic min length
|
||||
} else if (text.length < 20) {
|
||||
} else if (text.length < minLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -224,19 +269,33 @@ function shouldAcceptWorkspaceMemoryCandidate(entry: {
|
||||
|
||||
// Session-specific progress snapshots for project type
|
||||
if (entry.type === "project") {
|
||||
if (/\b\d+\s+tests?\s+pass(?:ed)?\b/i.test(text)) return false;
|
||||
if (/\b\d+\s+suites?\b/i.test(text)) return false;
|
||||
if (/\b\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) return false;
|
||||
// Reject "Phase N completed" using semantic window (within 20 chars either direction)
|
||||
if (/\bphase\s*\d+(?:\s*[-–]\s*\d+)?\b.{0,20}\b(?:completed|done|finished)\b/i.test(text)) return false;
|
||||
if (/\b(?:completed|done|finished)\b.{0,20}\bphase\s*\d+(?:\s*[-–]\s*\d+)?\b/i.test(text)) return false;
|
||||
if (/已完成.{0,20}Phase\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) return false;
|
||||
if (/Phase\s*\d+(?:\s*[-–]\s*\d+)?.{0,20}已完成/i.test(text)) return false;
|
||||
if (isProjectSnapshotViolation(text)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isProjectSnapshotViolation(text: string): boolean {
|
||||
// Test/suite counts
|
||||
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
|
||||
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
|
||||
|
||||
// File counts with snapshot/process context only, not static limits
|
||||
if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) {
|
||||
const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text);
|
||||
const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text);
|
||||
if (hasSnapshotContext && !hasLimitContext) return true;
|
||||
}
|
||||
|
||||
// Phase/Wave/Sprint/Milestone/Task progress
|
||||
if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) {
|
||||
if (/completed|done|finished|完成/i.test(text)) return true;
|
||||
}
|
||||
if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract candidate block from summary using multiple formats.
|
||||
* Supports: Plain text label, Markdown section, legacy XML.
|
||||
@@ -275,16 +334,22 @@ export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryE
|
||||
);
|
||||
if (!item) continue;
|
||||
const type = (item[1] ?? item[2]).toLowerCase() as LongTermType;
|
||||
const body = item[3].trim();
|
||||
if (body.length < 12) continue;
|
||||
const normalizedBody = normalizeCandidateBody(item[3]);
|
||||
if (!normalizedBody) continue;
|
||||
|
||||
const minLength = normalizedBody.hadTrigger ? 6 : 12;
|
||||
if (normalizedBody.text.length < minLength) continue;
|
||||
|
||||
// Apply quality gate
|
||||
if (!shouldAcceptWorkspaceMemoryCandidate({ type, text: body })) continue;
|
||||
if (!shouldAcceptWorkspaceMemoryCandidate(
|
||||
{ type, text: normalizedBody.text },
|
||||
{ fromMemoryTrigger: normalizedBody.hadTrigger },
|
||||
)) continue;
|
||||
|
||||
entries.push({
|
||||
id: id("mem"),
|
||||
type,
|
||||
text: body.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
|
||||
text: normalizedBody.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
|
||||
source: "compaction",
|
||||
confidence: 0.75,
|
||||
status: "active",
|
||||
|
||||
@@ -90,6 +90,14 @@ function buildCompactionPrompt(privateContext: string): string {
|
||||
"## Relevant Files",
|
||||
"",
|
||||
"At the end of the summary, extract durable memory entries for future sessions.",
|
||||
"",
|
||||
"Memory quality bar:",
|
||||
"Extract only durable facts that will change future behavior: user preferences, decisions with rationale, stable constraints, or hard-to-rediscover references.",
|
||||
"",
|
||||
"Do not extract trivia: transient IDs/revisions, task progress, test/file counts, bare status updates, local UI details, or facts easily rediscovered from the repo.",
|
||||
"",
|
||||
"When unsure, skip it. Fewer high-signal memories are better than many low-value ones.",
|
||||
"",
|
||||
"Only extract facts that are likely to stay true across sessions.",
|
||||
"Do not extract session-specific progress like exact test counts, file counts, or phase numbers.",
|
||||
"For progress, extract the stable goal or durable milestone, not the current number.",
|
||||
|
||||
@@ -28,6 +28,7 @@ export type WorkspaceMemoryStore = {
|
||||
maxEntries: number;
|
||||
};
|
||||
entries: LongTermMemoryEntry[];
|
||||
migrations?: string[];
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
|
||||
+174
-15
@@ -5,6 +5,16 @@ import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||
|
||||
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
|
||||
const MIN_ENVELOPE_LENGTH = 80;
|
||||
const MIGRATION_ID = "2026-04-26-p0-cleanup";
|
||||
|
||||
const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,,\s\[]+`;
|
||||
|
||||
const PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i;
|
||||
const USERNAME_LABELS = /username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i;
|
||||
|
||||
const PIN_PREFIX = String.raw`(\bPIN\b(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||
const PASSWORD_PREFIX = String.raw`((?:${PASSWORD_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||
const USERNAME_PREFIX = String.raw`((?:${USERNAME_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||
|
||||
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
|
||||
return {
|
||||
@@ -15,20 +25,53 @@ export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemor
|
||||
maxEntries: LONG_TERM_LIMITS.maxEntries,
|
||||
},
|
||||
entries: [],
|
||||
migrations: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
|
||||
const path = await workspaceMemoryPath(root);
|
||||
const fallback = await emptyWorkspaceMemory(root);
|
||||
const loaded = await readJSON(await workspaceMemoryPath(root), () => fallback);
|
||||
loaded.workspace = { root, key: await workspaceKey(root) };
|
||||
loaded.limits = {
|
||||
maxRenderedChars: loaded.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars,
|
||||
maxEntries: loaded.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries,
|
||||
const loaded = await readJSON(path, () => fallback) as Partial<WorkspaceMemoryStore>;
|
||||
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: loaded.version ?? 1,
|
||||
workspace: loaded.workspace ?? { root, key: await workspaceKey(root) },
|
||||
limits: {
|
||||
maxRenderedChars: loaded.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars,
|
||||
maxEntries: loaded.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries,
|
||||
},
|
||||
entries: Array.isArray(loaded.entries) ? loaded.entries : [],
|
||||
migrations: Array.isArray(loaded.migrations) ? loaded.migrations : [],
|
||||
updatedAt: loaded.updatedAt ?? fallback.updatedAt,
|
||||
};
|
||||
loaded.entries = Array.isArray(loaded.entries) ? loaded.entries : [];
|
||||
return loaded;
|
||||
|
||||
// Always normalize on load so redaction/migrations are always-on.
|
||||
const normalized = await normalizeWorkspaceMemory(root, store);
|
||||
|
||||
// Persist only when meaningful content changed (ignore timestamps).
|
||||
if (didStoreMeaningfullyChange(store, normalized)) {
|
||||
await atomicWriteJSON(path, normalized);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function didStoreMeaningfullyChange(
|
||||
before: WorkspaceMemoryStore,
|
||||
after: WorkspaceMemoryStore,
|
||||
): boolean {
|
||||
const sanitize = (store: WorkspaceMemoryStore) => ({
|
||||
...store,
|
||||
updatedAt: "",
|
||||
entries: store.entries.map(entry => ({
|
||||
...entry,
|
||||
updatedAt: "",
|
||||
})),
|
||||
});
|
||||
|
||||
return JSON.stringify(sanitize(before)) !== JSON.stringify(sanitize(after));
|
||||
}
|
||||
|
||||
export async function saveWorkspaceMemory(root: string, store: WorkspaceMemoryStore): Promise<void> {
|
||||
@@ -52,14 +95,130 @@ async function normalizeWorkspaceMemory(
|
||||
root: string,
|
||||
store: WorkspaceMemoryStore,
|
||||
): Promise<WorkspaceMemoryStore> {
|
||||
store.workspace = { root, key: await workspaceKey(root) };
|
||||
store.limits = {
|
||||
maxRenderedChars: store.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars,
|
||||
maxEntries: store.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries,
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
let result: WorkspaceMemoryStore = {
|
||||
...store,
|
||||
workspace: { root, key: await workspaceKey(root) },
|
||||
limits: {
|
||||
maxRenderedChars: store.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars,
|
||||
maxEntries: store.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries,
|
||||
},
|
||||
entries: Array.isArray(store.entries) ? store.entries : [],
|
||||
migrations: Array.isArray(store.migrations) ? store.migrations : [],
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
// Always-on credential redaction
|
||||
result.entries = result.entries.map(entry => {
|
||||
const text = redactCredentials(entry.text);
|
||||
const rationale = entry.rationale ? redactCredentials(entry.rationale) : undefined;
|
||||
|
||||
if (text === entry.text && rationale === entry.rationale) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
text,
|
||||
rationale,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
});
|
||||
|
||||
// One-time migration for legacy snapshot violations
|
||||
result = runMigrationP0Cleanup(result, nowIso);
|
||||
|
||||
// Enforce limits on active entries while preserving superseded entries in storage
|
||||
const activeEntries = result.entries.filter(entry => entry.status !== "superseded");
|
||||
const supersededEntries = result.entries.filter(entry => entry.status === "superseded");
|
||||
const processedActive = enforceLongTermLimits(activeEntries);
|
||||
|
||||
return {
|
||||
...result,
|
||||
entries: [...processedActive, ...supersededEntries],
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
}
|
||||
|
||||
export function redactCredentials(text: string): string {
|
||||
let result = text;
|
||||
|
||||
// 1. PIN
|
||||
result = result.replace(
|
||||
new RegExp(String.raw`${PIN_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||
"$1[REDACTED]",
|
||||
);
|
||||
|
||||
// 2. Username+password pair
|
||||
result = result.replace(
|
||||
new RegExp(
|
||||
String.raw`${USERNAME_PREFIX}[\`'"]?(${SECRET_VALUE})((?:,|,)\s*)${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`,
|
||||
"gi",
|
||||
),
|
||||
"$1[REDACTED]$3$4[REDACTED]",
|
||||
);
|
||||
|
||||
// 3. Standalone password
|
||||
result = result.replace(
|
||||
new RegExp(String.raw`${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||
"$1[REDACTED]",
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isProjectSnapshotViolation(text: string): boolean {
|
||||
// Test/suite counts
|
||||
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
|
||||
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
|
||||
|
||||
// File counts with snapshot context, excluding limit statements
|
||||
if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) {
|
||||
const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text);
|
||||
const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text);
|
||||
if (hasSnapshotContext && !hasLimitContext) return true;
|
||||
}
|
||||
|
||||
// Phase/Wave/Sprint/Milestone/Task progress
|
||||
if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) {
|
||||
if (/completed|done|finished|完成/i.test(text)) return true;
|
||||
}
|
||||
|
||||
if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function runMigrationP0Cleanup(
|
||||
store: WorkspaceMemoryStore,
|
||||
nowIso: string,
|
||||
): WorkspaceMemoryStore {
|
||||
if (store.migrations?.includes(MIGRATION_ID)) {
|
||||
return store;
|
||||
}
|
||||
|
||||
const entries = store.entries.map(entry => {
|
||||
if (entry.source === "explicit") return entry;
|
||||
if (entry.type !== "project") return entry;
|
||||
|
||||
if (isProjectSnapshotViolation(entry.text)) {
|
||||
return {
|
||||
...entry,
|
||||
status: "superseded" as const,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
}
|
||||
|
||||
return entry;
|
||||
});
|
||||
|
||||
return {
|
||||
...store,
|
||||
entries,
|
||||
migrations: [...(store.migrations || []), MIGRATION_ID],
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
store.entries = enforceLongTermLimits(store.entries);
|
||||
store.updatedAt = new Date().toISOString();
|
||||
return store;
|
||||
}
|
||||
|
||||
function sourcePriority(source: LongTermMemoryEntry["source"]): number {
|
||||
@@ -181,7 +340,7 @@ export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermM
|
||||
|
||||
// Phase 1: filter active, prune by age
|
||||
const phase1 = entries
|
||||
.filter(entry => entry.status === "active")
|
||||
.filter(entry => entry.status !== "superseded")
|
||||
.filter(entry => !isPrunableByAge(entry, now))
|
||||
.map(entry => ({ ...entry, text: entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars) }));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user