fix: PR-2 memory plugin behavior improvements

## Task 5: Canonical exact dedupe
- Already implemented in PR-1 with enforceLongTermLimits()
- Source priority: explicit > manual > compaction
- Same source: higher confidence wins

## Task 6: Structured negative guard
- Add isNegatedMemoryRequest() for adjacency detection
- "不要記住" / "don't remember" are now properly ignored
- "not forget to remember" no longer false positive (not a directive)
- Restrict patterns to line-start (^|\n) to avoid mid-sentence matches

## Task 7: Compaction quality gate
- Add shouldAcceptWorkspaceMemoryCandidate() predicate
- Reject low-quality candidates: git hashes, errors, stack traces
- Reject temporary progress, code signatures, path-heavy facts
- Only accept entries with >= 20 chars

## Pattern improvements
- All patterns use matchAll() with proper g flag
- Dedupe by canonical text in extractExplicitMemories()
- Line-start anchor prevents "to remember" mid-sentence matches
- Add more trigger patterns: save/add to memory, commit to memory

Tests: 36 passing
This commit is contained in:
Ralph Chang
2026-04-26 13:06:36 +08:00
parent 1bba0511bb
commit ff4639d153
2 changed files with 218 additions and 2 deletions
+90 -2
View File
@@ -10,22 +10,68 @@ function hash(value: string): string {
return createHash("sha1").update(value).digest("hex").slice(0, 12);
}
/**
* Check if a memory request is negated (e.g., "不要記住", "don't remember").
* Uses structured adjacency detection to avoid false positives.
*/
function isNegatedMemoryRequest(text: string, matchIndex: number): boolean {
const prefix = text.slice(Math.max(0, matchIndex - 30), matchIndex);
// Chinese negative: 不要/別/不用 + optional 幫我, must be adjacent to trigger
if (/(?:|||||)\s*(?:|)?\s*$/u.test(prefix)) {
return true;
}
// English negative: do not / don't / never / not + optional please, must be adjacent to trigger
if (/(?:do\s+not|don't|dont|never|not)\s+(?:please\s+)?$/i.test(prefix)) {
return true;
}
return false;
}
export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
// 注意:所有pattern必須有 g flag,因為使用 matchAll()
// Pattern 必須在行首匹配,避免匹配到句子中間的非指令式用法
const patterns = [
// 注意:所有pattern必須有 g flag,因為使用 matchAll()
/(?:请记住|記住|记住这一点|remember this|commit to memory)[:]?\s*(.+)$/gim,
// 中文:請/幫我 + 記住 + 可選後綴
/(?:^|\n)\s*(?:请|請)?(?:帮我|幫我)?(?:记住|記住)(?:这一点|這一點|这点|這點|这个|這個)?[:,]?\s*(.+)$/gim,
// 英文:remember this/that - 必須在行首,避免 "to remember" 非指令匹配
/(?:^|\n)\s*(?:please\s+)?remember\s+(?:this|that)?[:,]?\s*(.+)$/gim,
// save/add to memory
/(?:^|\n)\s*(?:please\s+)?(?:save|add)\s+(?:this|that)?\s*(?:to|in)\s+memory[:,]?\s*(.+)$/gim,
// commit to memory
/(?:^|\n)\s*(?:please\s+)?commit\s+(?:this|that)?\s*to memory[:,]?\s*(.+)$/gim,
// going forward / from now on
/(?:从现在开始|從現在開始|从今以后|從今以後|from now on|going forward)[:,]?\s*(.+)$/gim,
// 偏好
/(?:我的偏好是|我偏好|以后请|以後請|以后都|以後都)[:,]?\s*(.+)$/gim,
/(?:^|\n)\s*(?:my preference is|i prefer)[:,]?\s*(.+)$/gim,
];
const now = new Date().toISOString();
const entries: LongTermMemoryEntry[] = [];
const seen = new Set<string>();
for (const pattern of patterns) {
for (const match of text.matchAll(pattern)) {
const body = match[1]?.trim();
if (!body || body.length < 8) continue;
// Calculate actual trigger position (after possible newline)
const triggerIndex = match.index! + (match[0].match(/^[\s\n]*/)?.[0]?.length || 0);
// Check if this is a negated request (e.g., "不要記住")
if (isNegatedMemoryRequest(text, triggerIndex)) continue;
// Check if it's a deferral (e.g., "later", "next time")
if (/^(再说|再說|later|next time)$/i.test(body)) continue;
// Dedupe by canonical body
const key = body.toLowerCase().replace(/\s+/g, " ").trim();
if (seen.has(key)) continue;
seen.add(key);
const type = classifyExplicitMemory(body);
entries.push({
id: id("mem"),
@@ -137,6 +183,44 @@ function extractFirstPath(text: string): string | undefined {
return text.match(/[\w./-]+\.(ts|tsx|js|jsx|json|md|py|go|rs)/)?.[0];
}
/**
* Quality gate for workspace memory candidates.
* Rejects low-quality entries like git hashes, error messages, etc.
*/
function shouldAcceptWorkspaceMemoryCandidate(entry: {
type: LongTermType;
text: string;
}): boolean {
const text = entry.text.trim();
// Too short
if (text.length < 20) return false;
// Git history / commit hash
if (/\b[0-9a-f]{7,40}\b/.test(text)) return false;
if (/^(fix|feat|chore|docs|refactor|test):/i.test(text)) return false;
// Raw error / stack trace
if (/^\s*(Error|TypeError|ReferenceError|SyntaxError):/i.test(text)) return false;
if (/at \S+ \([^)]+:\d+:\d+\)/.test(text)) return false;
// Active file list
if (/^(modified|created|deleted|renamed)\s+\S+\.\S+$/i.test(text)) return false;
// Temporary progress
if (/^(currently|now|pending|in progress|todo|wip):/i.test(text)) return false;
// Code signature / API doc
if (/^(function|class|interface|type|const|let|var)\s+\w+/.test(text)) return false;
if (/^(GET|POST|PUT|DELETE|PATCH)\s+\//.test(text)) return false;
// Path-heavy facts (rediscoverable from repo)
const pathCount = (text.match(/\/[\w.-]+(\/[\w.-]+)+/g) || []).length;
if (pathCount > 2) return false;
return true;
}
export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryEntry[] {
const match = summary.match(/<workspace_memory_candidates>([\s\S]*?)<\/workspace_memory_candidates>/i);
if (!match) return [];
@@ -150,6 +234,10 @@ export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryE
const type = item[1].toLowerCase() as LongTermType;
const body = item[2].trim();
if (body.length < 12) continue;
// Apply quality gate
if (!shouldAcceptWorkspaceMemoryCandidate({ type, text: body })) continue;
entries.push({
id: id("mem"),
type,