mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
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:
+90
-2
@@ -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,
|
||||
|
||||
@@ -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 = `
|
||||
<workspace_memory_candidates>
|
||||
- [decision] short text
|
||||
</workspace_memory_candidates>
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects git commit hash", () => {
|
||||
const summary = `
|
||||
<workspace_memory_candidates>
|
||||
- [project] abc123def456 is the commit hash
|
||||
</workspace_memory_candidates>
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects raw error", () => {
|
||||
const summary = `
|
||||
<workspace_memory_candidates>
|
||||
- [feedback] TypeError: Cannot read property 'x' of undefined
|
||||
</workspace_memory_candidates>
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects stack trace", () => {
|
||||
const summary = `
|
||||
<workspace_memory_candidates>
|
||||
- [reference] at foo (bar.ts:10:5)
|
||||
</workspace_memory_candidates>
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects commit prefix", () => {
|
||||
const summary = `
|
||||
<workspace_memory_candidates>
|
||||
- [project] fix: add new feature
|
||||
</workspace_memory_candidates>
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects path-heavy facts", () => {
|
||||
const summary = `
|
||||
<workspace_memory_candidates>
|
||||
- [project] files at /src/a.ts /src/b.ts /src/c.ts are important
|
||||
</workspace_memory_candidates>
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates accepts valid decision", () => {
|
||||
const summary = `
|
||||
<workspace_memory_candidates>
|
||||
- [decision] Use pnpm instead of npm for package management
|
||||
</workspace_memory_candidates>
|
||||
`;
|
||||
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 = `
|
||||
<workspace_memory_candidates>
|
||||
- [project] This project uses TypeScript for all source files
|
||||
</workspace_memory_candidates>
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0].type, "project");
|
||||
});
|
||||
Reference in New Issue
Block a user