diff --git a/src/app/api/v1/quotas/route.ts b/src/app/api/v1/quotas/route.ts index e8d8a0d44c..72e31812b7 100644 --- a/src/app/api/v1/quotas/route.ts +++ b/src/app/api/v1/quotas/route.ts @@ -47,7 +47,11 @@ export async function GET(request: Request) { ? conn.providerId : ""; const connectionId = - typeof conn.connectionId === "string" ? conn.connectionId : ""; + typeof conn.id === "string" + ? conn.id + : typeof conn.connectionId === "string" + ? conn.connectionId + : ""; if (!provider || !connectionId) continue; let saturation = 0; @@ -69,7 +73,6 @@ export async function GET(request: Request) { : typeof conn.name === "string" && conn.name.trim() ? conn.name : connectionId; - connections.push({ provider, connectionId, diff --git a/src/lib/memory/retrieval.ts b/src/lib/memory/retrieval.ts index b5cf1292c4..bbb5e7c3da 100644 --- a/src/lib/memory/retrieval.ts +++ b/src/lib/memory/retrieval.ts @@ -78,8 +78,21 @@ function fetchMemoriesByIds(ids: string[]): Memory[] { return ids.map((id) => byId.get(id)).filter((m): m is Memory => m !== undefined); } -interface FtsColConfig { - apiKeyCol: string; +/** + * Sanitize a free-text query into an FTS5 MATCH expression. + * Strips FTS5 syntax characters (?, !, ", *, :, parentheses, etc.) and quotes + * each whitespace-separated term so natural-language queries with punctuation + * don't raise "fts5: syntax error". + */ +export function toFts5MatchQuery(query: string): string { + const terms = query + .split(/\s+/) + .map((term) => term.replace(/["*():\[\]{}!?\^~+-]/g, "")) + .filter((term) => term.length > 0); + return terms.map((term) => `"${term}"`).join(" AND "); +} + +interface FtsColConfig { apiKeyCol: string; expiresCol: string; createdCol: string; sessionCol: string; @@ -122,7 +135,7 @@ function buildFtsRows(apiKeyId: string, config: FtsColConfig): MemoryRow[] { } ftsQueryStr += ` ORDER BY f.rank LIMIT 100`; - const ftsParams: unknown[] = [q, apiKeyId]; + const ftsParams: unknown[] = [toFts5MatchQuery(q), apiKeyId]; if (scope === "session" && sessionId) ftsParams.push(sessionId); if (retentionDays && retentionDays > 0) { const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString(); @@ -918,7 +931,9 @@ export async function retrievePreview( const ftsQueryStr = apiKeyId ? `SELECT m.* FROM ${tableName} m JOIN memory_fts f ON m.memory_id = f.rowid WHERE f.memory_fts MATCH ? AND m.${apiKeyCol} = ? ORDER BY f.rank LIMIT ?` : `SELECT m.* FROM ${tableName} m JOIN memory_fts f ON m.memory_id = f.rowid WHERE f.memory_fts MATCH ? ORDER BY f.rank LIMIT ?`; - const ftsP: unknown[] = apiKeyId ? [query, apiKeyId, limit] : [query, limit]; + const ftsP: unknown[] = apiKeyId + ? [toFts5MatchQuery(query), apiKeyId, limit] + : [toFts5MatchQuery(query), limit]; try { ftsRows = db.prepare(ftsQueryStr).all(...ftsP) as MemoryRow[]; } catch { diff --git a/tests/unit/memory/fts5-query-sanitize.test.ts b/tests/unit/memory/fts5-query-sanitize.test.ts new file mode 100644 index 0000000000..ca8ee46a46 --- /dev/null +++ b/tests/unit/memory/fts5-query-sanitize.test.ts @@ -0,0 +1,31 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { toFts5MatchQuery } from "../../../src/lib/memory/retrieval"; + +describe("memory retrieval toFts5MatchQuery", () => { + it("keeps plain terms and joins with AND", () => { + assert.strictEqual(toFts5MatchQuery("hello world"), '"hello" AND "world"'); + }); + + it("strips FTS5 syntax characters", () => { + assert.strictEqual( + toFts5MatchQuery("Чем занимается пользователь?"), + '"Чем" AND "занимается" AND "пользователь"' + ); + }); + + it("strips quotes, colons, parentheses and dashes", () => { + assert.strictEqual( + toFts5MatchQuery('model "gpt-4o": (fast) OR NOT [x]'), + '"model" AND "gpt4o" AND "fast" AND "OR" AND "NOT" AND "x"' + ); + }); + + it("keeps punctuation-only input as a single quoted term", () => { + assert.strictEqual(toFts5MatchQuery("???! ..."), '"..."'); + }); + + it("returns empty string for empty input", () => { + assert.strictEqual(toFts5MatchQuery(""), ""); + }); +});