Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 44 additions & 41 deletions apps/server/src/memory/Services/ProjectMemoryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,46 +56,52 @@ const makeProjectMemoryService = Effect.gen(function* () {
const search: ProjectMemoryServiceShape["search"] = (input) =>
Effect.gen(function* () {
const start = Date.now();
const kindClause = input.kind ? "AND m.kind = ?" : "";
const queryParams: Array<string | number> = input.kind
? [input.query, input.projectId, input.kind, input.limit]
: [input.query, input.projectId, input.limit];
// Use FTS5 for full-text search
const rows = yield* sql.unsafe<{
id: string;
project_id: string;
thread_id: string | null;
kind: string;
title: string;
content: string;
tags: string;
relevance_score: number;
access_count: number;
created_at: string;
updated_at: string;
expires_at: string | null;
rank: number;
}>(
`SELECT m.*, fts.rank
FROM memory_fts fts
JOIN memory_entries m ON m.rowid = fts.rowid
WHERE memory_fts MATCH ?
AND m.project_id = ?
${kindClause}
AND (m.expires_at IS NULL OR m.expires_at > datetime('now'))
ORDER BY fts.rank
LIMIT ?`,
queryParams,
);
const pattern = `%${input.query}%`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Escape LIKE wildcards in memory search query

Building pattern as %${input.query}% means % and _ in user input are treated as SQL wildcards, not literal characters. In this memory feature, queries often include code identifiers (for example user_id or % in snippets), so searches can return unrelated entries and evict relevant matches under LIMIT. Please escape wildcard characters (and use ESCAPE) before binding the pattern so literal queries behave predictably.

Useful? React with 👍 / 👎.

const rows = yield* (input.kind
? sql<{
id: string;
project_id: string;
thread_id: string | null;
kind: string;
title: string;
content: string;
tags: string;
relevance_score: number;
access_count: number;
created_at: string;
updated_at: string;
expires_at: string | null;
}>`SELECT * FROM memory_entries
WHERE project_id = ${input.projectId}
AND kind = ${input.kind}
AND (title LIKE ${pattern} OR content LIKE ${pattern} OR tags LIKE ${pattern})
AND (expires_at IS NULL OR expires_at > datetime('now'))
ORDER BY relevance_score DESC
LIMIT ${input.limit}`
: sql<{
id: string;
project_id: string;
thread_id: string | null;
kind: string;
title: string;
content: string;
tags: string;
relevance_score: number;
access_count: number;
created_at: string;
updated_at: string;
expires_at: string | null;
}>`SELECT * FROM memory_entries
WHERE project_id = ${input.projectId}
AND (title LIKE ${pattern} OR content LIKE ${pattern} OR tags LIKE ${pattern})
AND (expires_at IS NULL OR expires_at > datetime('now'))
ORDER BY relevance_score DESC
LIMIT ${input.limit}`);
Comment on lines +59 to +99
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Escape LIKE wildcards in the user query.

input.query is now fed straight into %...%, so % and _ inside real queries become SQL wildcards. That breaks common searches like snake_case and can turn % into “match everything”.

Suggested fix
-      const pattern = `%${input.query}%`;
+      const escapedQuery = input.query.replace(/[\\%_]/g, "\\$&");
+      const pattern = `%${escapedQuery}%`;
@@
-               AND (title LIKE ${pattern} OR content LIKE ${pattern} OR tags LIKE ${pattern})
+               AND (
+                 title LIKE ${pattern} ESCAPE '\\'
+                 OR content LIKE ${pattern} ESCAPE '\\'
+                 OR tags LIKE ${pattern} ESCAPE '\\'
+               )
@@
-               AND (title LIKE ${pattern} OR content LIKE ${pattern} OR tags LIKE ${pattern})
+               AND (
+                 title LIKE ${pattern} ESCAPE '\\'
+                 OR content LIKE ${pattern} ESCAPE '\\'
+                 OR tags LIKE ${pattern} ESCAPE '\\'
+               )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const pattern = `%${input.query}%`;
const rows = yield* (input.kind
? sql<{
id: string;
project_id: string;
thread_id: string | null;
kind: string;
title: string;
content: string;
tags: string;
relevance_score: number;
access_count: number;
created_at: string;
updated_at: string;
expires_at: string | null;
}>`SELECT * FROM memory_entries
WHERE project_id = ${input.projectId}
AND kind = ${input.kind}
AND (title LIKE ${pattern} OR content LIKE ${pattern} OR tags LIKE ${pattern})
AND (expires_at IS NULL OR expires_at > datetime('now'))
ORDER BY relevance_score DESC
LIMIT ${input.limit}`
: sql<{
id: string;
project_id: string;
thread_id: string | null;
kind: string;
title: string;
content: string;
tags: string;
relevance_score: number;
access_count: number;
created_at: string;
updated_at: string;
expires_at: string | null;
}>`SELECT * FROM memory_entries
WHERE project_id = ${input.projectId}
AND (title LIKE ${pattern} OR content LIKE ${pattern} OR tags LIKE ${pattern})
AND (expires_at IS NULL OR expires_at > datetime('now'))
ORDER BY relevance_score DESC
LIMIT ${input.limit}`);
const escapedQuery = input.query.replace(/[\\%_]/g, "\\$&");
const pattern = `%${escapedQuery}%`;
const rows = yield* (input.kind
? sql<{
id: string;
project_id: string;
thread_id: string | null;
kind: string;
title: string;
content: string;
tags: string;
relevance_score: number;
access_count: number;
created_at: string;
updated_at: string;
expires_at: string | null;
}>`SELECT * FROM memory_entries
WHERE project_id = ${input.projectId}
AND kind = ${input.kind}
AND (
title LIKE ${pattern} ESCAPE '\\'
OR content LIKE ${pattern} ESCAPE '\\'
OR tags LIKE ${pattern} ESCAPE '\\'
)
AND (expires_at IS NULL OR expires_at > datetime('now'))
ORDER BY relevance_score DESC
LIMIT ${input.limit}`
: sql<{
id: string;
project_id: string;
thread_id: string | null;
kind: string;
title: string;
content: string;
tags: string;
relevance_score: number;
access_count: number;
created_at: string;
updated_at: string;
expires_at: string | null;
}>`SELECT * FROM memory_entries
WHERE project_id = ${input.projectId}
AND (
title LIKE ${pattern} ESCAPE '\\'
OR content LIKE ${pattern} ESCAPE '\\'
OR tags LIKE ${pattern} ESCAPE '\\'
)
AND (expires_at IS NULL OR expires_at > datetime('now'))
ORDER BY relevance_score DESC
LIMIT ${input.limit}`);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/memory/Services/ProjectMemoryService.ts` around lines 59 -
99, Escape SQL LIKE wildcards in the user query before building the pattern:
sanitize input.query by first replacing backslashes with double backslashes,
then replacing '%' with '\%' and '_' with '\_' (e.g., const escaped =
input.query.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
const pattern = `%${escaped}%`), and add an explicit ESCAPE '\' clause to both
SQL statements that use pattern (the two sql`... WHERE ... (title LIKE
${pattern} OR content LIKE ${pattern} OR tags LIKE ${pattern}) ...` branches in
ProjectMemoryService.ts) so the backslash escapes are honored.


// Increment access count
if (rows.length > 0) {
const placeholders = rows.map(() => "?").join(",");
const ids = rows.map((r) => r.id);
yield* sql.unsafe(
`UPDATE memory_entries SET access_count = access_count + 1 WHERE id IN (${placeholders})`,
ids,
);
yield* sql`UPDATE memory_entries SET access_count = access_count + 1 WHERE id IN ${sql.in(ids)}`;
}

const results: MemorySearchResult[] = rows.map((r) => ({
Expand All @@ -113,7 +119,7 @@ const makeProjectMemoryService = Effect.gen(function* () {
updatedAt: r.updated_at,
expiresAt: (r.expires_at ?? null) as MemoryEntry["expiresAt"],
} as MemoryEntry,
matchScore: -r.rank, // FTS5 rank is negative; flip for display
matchScore: r.relevance_score,
matchSnippet: null,
}));

Expand Down Expand Up @@ -169,10 +175,7 @@ const makeProjectMemoryService = Effect.gen(function* () {
const index: ProjectMemoryServiceShape["index"] = (input) =>
Effect.gen(function* () {
const start = Date.now();
if (input.forceReindex) {
// Rebuild FTS index
yield* sql.unsafe("INSERT INTO memory_fts(memory_fts) VALUES('rebuild')");
}
// forceReindex is a no-op with LIKE-based search (no external index to rebuild)
const rows = yield* sql<{
count: number;
}>`SELECT COUNT(*) as count FROM memory_entries WHERE project_id = ${input.projectId}`;
Expand Down
29 changes: 0 additions & 29 deletions apps/server/src/persistence/Migrations/020_NewFeatureTables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,35 +222,6 @@ export default Effect.gen(function* () {
CREATE INDEX IF NOT EXISTS idx_memory_entries_kind ON memory_entries(kind)
`;

yield* sql`
CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
title,
content,
tags,
content=memory_entries,
content_rowid=rowid
)
`;

yield* sql.unsafe(`
CREATE TRIGGER IF NOT EXISTS memory_fts_insert AFTER INSERT ON memory_entries BEGIN
INSERT INTO memory_fts(rowid, title, content, tags) VALUES (new.rowid, new.title, new.content, new.tags);
END
`);

yield* sql.unsafe(`
CREATE TRIGGER IF NOT EXISTS memory_fts_delete AFTER DELETE ON memory_entries BEGIN
INSERT INTO memory_fts(memory_fts, rowid, title, content, tags) VALUES ('delete', old.rowid, old.title, old.content, old.tags);
END
`);

yield* sql.unsafe(`
CREATE TRIGGER IF NOT EXISTS memory_fts_update AFTER UPDATE ON memory_entries BEGIN
INSERT INTO memory_fts(memory_fts, rowid, title, content, tags) VALUES ('delete', old.rowid, old.title, old.content, old.tags);
INSERT INTO memory_fts(rowid, title, content, tags) VALUES (new.rowid, new.title, new.content, new.tags);
END
`);

// ── Presence / Session Sharing ─────────────────────────────────────
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add forward migration to remove legacy FTS artifacts

This commit removes FTS DDL by editing migration 020 in place, which only affects fresh databases. Existing databases that already ran the previous 020 will keep memory_fts and its triggers indefinitely, but search no longer uses them; those triggers still execute on every memory_entries write and can preserve FTS5 schema dependencies in upgraded installs. A new migration should explicitly drop memory_fts and its triggers so upgraded environments converge to the new schema.

Useful? React with 👍 / 👎.


yield* sql`
Expand Down