diff --git a/src/extractors.ts b/src/extractors.ts index 66fedfb..5b13a12 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -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(); 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(/([\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, diff --git a/tests/extractors.test.ts b/tests/extractors.test.ts index 6d7a8fe..21426c1 100644 --- a/tests/extractors.test.ts +++ b/tests/extractors.test.ts @@ -84,4 +84,132 @@ test("extractExplicitMemories captures from now on", () => { const items = extractExplicitMemories("from now on: reply in Traditional Chinese"); assert.equal(items.length, 1); assert.match(items[0].text, /Traditional Chinese/); +}); + +// ============================================ +// Task 6: Negative memory request tests +// ============================================ + +test("extractExplicitMemories ignores Chinese negative request", () => { + const items = extractExplicitMemories("不要記住:這個 repo 使用 npm cache"); + assert.equal(items.length, 0); +}); + +test("extractExplicitMemories ignores English negative request", () => { + const items = extractExplicitMemories("please don't remember this: use npm cache"); + assert.equal(items.length, 0); +}); + +test("extractExplicitMemories does not false positive on 'not forget'", () => { + // "remember this" in middle of sentence should NOT match (not a directive) + const items = extractExplicitMemories("I will not forget to remember this: use pnpm"); + assert.equal(items.length, 0); +}); + +test("extractExplicitMemories captures remember at line start", () => { + // "remember this" at line start IS a directive + const items = extractExplicitMemories("remember this: use pnpm for all packages"); + assert.equal(items.length, 1); + assert.match(items[0].text, /pnpm/); +}); + +test("extractExplicitMemories still captures positive request after negative", () => { + // Ensure negative guard doesn't block positive requests + const items = extractExplicitMemories("記住:使用 pnpm 來管理套件"); + assert.equal(items.length, 1); + assert.match(items[0].text, /pnpm/); +}); + +test("extractExplicitMemories captures multiple memories in same message", () => { + const items = extractExplicitMemories("請記住:使用 pnpm 管理套件\n記住這點:用 TypeScript 撰寫程式碼"); + assert.equal(items.length, 2); +}); + +// ============================================ +// Task 7: Compaction quality gate tests +// ============================================ + +import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts"; + +test("parseWorkspaceMemoryCandidates rejects short text", () => { + const summary = ` + +- [decision] short text + +`; + const items = parseWorkspaceMemoryCandidates(summary); + assert.equal(items.length, 0); +}); + +test("parseWorkspaceMemoryCandidates rejects git commit hash", () => { + const summary = ` + +- [project] abc123def456 is the commit hash + +`; + const items = parseWorkspaceMemoryCandidates(summary); + assert.equal(items.length, 0); +}); + +test("parseWorkspaceMemoryCandidates rejects raw error", () => { + const summary = ` + +- [feedback] TypeError: Cannot read property 'x' of undefined + +`; + const items = parseWorkspaceMemoryCandidates(summary); + assert.equal(items.length, 0); +}); + +test("parseWorkspaceMemoryCandidates rejects stack trace", () => { + const summary = ` + +- [reference] at foo (bar.ts:10:5) + +`; + const items = parseWorkspaceMemoryCandidates(summary); + assert.equal(items.length, 0); +}); + +test("parseWorkspaceMemoryCandidates rejects commit prefix", () => { + const summary = ` + +- [project] fix: add new feature + +`; + const items = parseWorkspaceMemoryCandidates(summary); + assert.equal(items.length, 0); +}); + +test("parseWorkspaceMemoryCandidates rejects path-heavy facts", () => { + const summary = ` + +- [project] files at /src/a.ts /src/b.ts /src/c.ts are important + +`; + const items = parseWorkspaceMemoryCandidates(summary); + assert.equal(items.length, 0); +}); + +test("parseWorkspaceMemoryCandidates accepts valid decision", () => { + const summary = ` + +- [decision] Use pnpm instead of npm for package management + +`; + const items = parseWorkspaceMemoryCandidates(summary); + assert.equal(items.length, 1); + assert.equal(items[0].type, "decision"); + assert.match(items[0].text, /pnpm/); +}); + +test("parseWorkspaceMemoryCandidates accepts valid project info", () => { + const summary = ` + +- [project] This project uses TypeScript for all source files + +`; + const items = parseWorkspaceMemoryCandidates(summary); + assert.equal(items.length, 1); + assert.equal(items[0].type, "project"); }); \ No newline at end of file