feat: release v1.2.2 with multilingual memory hardening

This commit is contained in:
Ralph Chang
2026-04-27 00:21:18 +08:00
parent 6603fe869d
commit f6f35e87c1
10 changed files with 1335 additions and 37 deletions
+83 -18
View File
@@ -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",
+8
View File
@@ -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.",
+1
View File
@@ -28,6 +28,7 @@ export type WorkspaceMemoryStore = {
maxEntries: number;
};
entries: LongTermMemoryEntry[];
migrations?: string[];
updatedAt: string;
};
+174 -15
View File
@@ -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) }));