From b394e9e86e15fad87b82c0462d87e375ae2d77ca Mon Sep 17 00:00:00 2001 From: DevMello Date: Tue, 4 Aug 2026 15:52:21 -0700 Subject: [PATCH] fix(memory): keep short pattern terms out of raw JSON metadata columns Two-char tokens like "ts" skip FTS and hit the pattern channel, which LIKE-matched them against raw properties_json/info_json. Every trace carries the JSON key "ts", so unrelated recent memories outranked real hits. Short ASCII terms now only match id, key, value and tags; longer terms and CJK bigrams keep their metadata reach. --- Memory/src/storage/repositories.ts | 43 +++++++++++------ .../repository/memory-retrieval-index.test.ts | 48 +++++++++++++++++++ 2 files changed, 76 insertions(+), 15 deletions(-) diff --git a/Memory/src/storage/repositories.ts b/Memory/src/storage/repositories.ts index b2ffbcc6..be090e42 100644 --- a/Memory/src/storage/repositories.ts +++ b/Memory/src/storage/repositories.ts @@ -980,22 +980,11 @@ export class MemoryRepository { ...filter, status: filter.status ?? ["activated", "resolving"] }); - const clauses = normalized.map(() => { - const columns = [ - "lower(memories.id) LIKE ? ESCAPE '\\'", - "lower(COALESCE(memories.memory_key, '')) LIKE ? ESCAPE '\\'", - "lower(memories.memory_value) LIKE ? ESCAPE '\\'", - "lower(memories.properties_json) LIKE ? ESCAPE '\\'", - "lower(memories.info_json) LIKE ? ESCAPE '\\'" - ]; - if (includeTags) columns.push("lower(memories.tags_json) LIKE ? ESCAPE '\\'"); - return `(${columns.join(" OR ")})`; - }); - const params = normalized.flatMap((term) => { + const termColumns = normalized.map((term) => likeColumnsForTerm(term, includeTags)); + const clauses = termColumns.map((columns) => `(${columns.join(" OR ")})`); + const params = normalized.flatMap((term, index) => { const pattern = `%${escapeLikePattern(term)}%`; - return includeTags - ? [pattern, pattern, pattern, pattern, pattern, pattern] - : [pattern, pattern, pattern, pattern, pattern]; + return termColumns[index]!.map(() => pattern); }); const rows = this.db .prepare( @@ -3855,6 +3844,30 @@ function escapeLikePattern(value: string): string { return value.replace(/[\\%_]/g, (match) => `\\${match}`); } +function likeColumnsForTerm(term: string, includeTags: boolean): string[] { + const columns = [ + "lower(memories.id) LIKE ? ESCAPE '\\'", + "lower(COALESCE(memories.memory_key, '')) LIKE ? ESCAPE '\\'", + "lower(memories.memory_value) LIKE ? ESCAPE '\\'" + ]; + // Short ASCII terms ("ts", "id", ...) are substrings of JSON keys present in + // every row's metadata blobs, so matching them there ranks unrelated recent + // memories above real hits. Longer terms and CJK bigrams cannot collide with + // JSON structure and keep their reach into metadata values. + if (!isShortAsciiTerm(term)) { + columns.push( + "lower(memories.properties_json) LIKE ? ESCAPE '\\'", + "lower(memories.info_json) LIKE ? ESCAPE '\\'" + ); + } + if (includeTags) columns.push("lower(memories.tags_json) LIKE ? ESCAPE '\\'"); + return columns; +} + +function isShortAsciiTerm(term: string): boolean { + return /^[\x20-\x7e]{1,2}$/.test(term); +} + function normalizeAgentIdKey(value: string): string { return value.trim().toLowerCase().replace(/[\s-]+/gu, "_"); } diff --git a/Memory/tests/repository/memory-retrieval-index.test.ts b/Memory/tests/repository/memory-retrieval-index.test.ts index 212655eb..e46d0c0b 100644 --- a/Memory/tests/repository/memory-retrieval-index.test.ts +++ b/Memory/tests/repository/memory-retrieval-index.test.ts @@ -102,6 +102,54 @@ describe("memory retrieval indexes", () => { } }); + it("keeps short ascii pattern terms out of raw JSON metadata columns", () => { + const root = mkdtempSync(join(tmpdir(), "mindock-memory-pattern-json-noise-")); + try { + const db = new MemoryDb({ path: join(root, "memory.sqlite") }); + const repos = new Repositories(db.db); + repos.memories.insert({ + ...traceMemory("trace-birthday-party"), + memoryValue: [ + "Summary: planned a birthday party", + "User:", + "help me plan a birthday party", + "Agent:", + "balloons and cake are ready." + ].join("\n") + }); + repos.memories.insert({ + ...traceMemory("trace-build-error"), + memoryValue: [ + "Summary: ts build error in web", + "User:", + "fix my ts build", + "Agent:", + "done, the build is green." + ].join("\n") + }); + + // Every trace row carries the JSON key "ts" inside properties_json, so a + // two-character query token must only match real content columns. + const hits = repos.memories.searchPatternIds(["ts"], { memoryLayer: "L1" }, 5) + .map((hit) => hit.id); + expect(hits).toContain("trace-build-error"); + expect(hits).not.toContain("trace-birthday-party"); + + // CJK bigrams cannot collide with ASCII JSON keys and keep their reach + // into metadata values. + repos.memories.insert({ + ...traceMemory("trace-cjk-metadata-only"), + memoryValue: "Summary: metadata-only cjk row" + }); + expect(repos.memories.searchPatternIds(["科幻"], { memoryLayer: "L1" }, 5) + .map((hit) => hit.id)).toContain("trace-cjk-metadata-only"); + + db.close(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it("filters memories by an inclusive start and exclusive end creation time", () => { const root = mkdtempSync(join(tmpdir(), "mindock-memory-time-filter-")); try {