Skip to content
Open
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
31 changes: 31 additions & 0 deletions db/migrations/083_fts_trigger_scope.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
-- Migration 083: scope memories_fts update triggers (issue #152)
--
-- The unscoped AFTER UPDATE triggers fired on every metadata write, so each
-- memory_search (via _retrieval_practice_boost) eroded the external-content
-- FTS5 index. Scope them to the indexed columns. The one-time rebuild
-- repairs indexes already corrupted in the field.
--
-- Rollback:
-- restore the unscoped triggers from migration 052
-- DELETE FROM schema_version WHERE version = 83;
--
-- IDEMPOTENT.

DROP TRIGGER IF EXISTS memories_fts_update_delete;
DROP TRIGGER IF EXISTS memories_fts_update_insert;

CREATE TRIGGER memories_fts_update_delete AFTER UPDATE OF content, category, tags, indexed, retired_at ON memories WHEN old.indexed = 1 BEGIN
INSERT INTO memories_fts(memories_fts, rowid, content, category, tags)
VALUES ('delete', old.id, old.content, old.category, old.tags);
END;

CREATE TRIGGER memories_fts_update_insert AFTER UPDATE OF content, category, tags, indexed, retired_at ON memories WHEN new.indexed = 1 AND new.retired_at IS NULL BEGIN
INSERT INTO memories_fts(rowid, content, category, tags)
VALUES (new.id, new.content, new.category, new.tags);
END;

INSERT INTO memories_fts(memories_fts) VALUES('rebuild');

INSERT OR IGNORE INTO schema_version (version, description, applied_at)
VALUES (83, 'scope memories_fts update triggers (issue #152)',
strftime('%Y-%m-%dT%H:%M:%S', 'now'));
14 changes: 5 additions & 9 deletions src/agentmemory/db/init_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -132,19 +132,15 @@ CREATE TRIGGER memories_fts_insert AFTER INSERT ON memories WHEN new.indexed = 1
INSERT INTO memories_fts(rowid, content, category, tags) VALUES (new.id, new.content, new.category, new.tags);
END;

-- Split into two triggers so 0→1 promotion correctly adds to FTS without double-delete.
-- Added `NEW.retired_at IS NULL` guard on the INSERT leg so retire UPDATEs
-- (retired_at NULL → non-NULL) do not re-insert the row. The companion
-- trg_memories_fts_purge_on_retire trigger near the end of this file does
-- the actual DELETE at the retire transition; without this guard, the
-- 'delete' command issued there is silently no-op'd by FTS5 statement-level
-- batching against the pending INSERT.
CREATE TRIGGER memories_fts_update_delete AFTER UPDATE ON memories WHEN old.indexed = 1 BEGIN
-- Update triggers scoped to FTS columns (plus indexed, retired_at) so
-- metadata-only updates don't churn the index (issue #152). The
-- retired_at IS NULL guard drops the re-insert at the retire transition.
CREATE TRIGGER memories_fts_update_delete AFTER UPDATE OF content, category, tags, indexed, retired_at ON memories WHEN old.indexed = 1 BEGIN
INSERT INTO memories_fts(memories_fts, rowid, content, category, tags)
VALUES ('delete', old.id, old.content, old.category, old.tags);
END;

CREATE TRIGGER memories_fts_update_insert AFTER UPDATE ON memories WHEN new.indexed = 1 AND new.retired_at IS NULL BEGIN
CREATE TRIGGER memories_fts_update_insert AFTER UPDATE OF content, category, tags, indexed, retired_at ON memories WHEN new.indexed = 1 AND new.retired_at IS NULL BEGIN
INSERT INTO memories_fts(rowid, content, category, tags)
VALUES (new.id, new.content, new.category, new.tags);
END;
Expand Down
51 changes: 50 additions & 1 deletion src/agentmemory/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,10 +346,44 @@ def _ensure_fts_index_consistent(conn) -> bool:
return False
except sqlite3.Error:
return False

return False


_SCOPED_FTS_UPDATE_TRIGGERS = """
DROP TRIGGER IF EXISTS memories_fts_update_delete;
DROP TRIGGER IF EXISTS memories_fts_update_insert;
CREATE TRIGGER memories_fts_update_delete AFTER UPDATE OF content, category, tags, indexed, retired_at ON memories WHEN old.indexed = 1 BEGIN
INSERT INTO memories_fts(memories_fts, rowid, content, category, tags)
VALUES ('delete', old.id, old.content, old.category, old.tags);
END;
CREATE TRIGGER memories_fts_update_insert AFTER UPDATE OF content, category, tags, indexed, retired_at ON memories WHEN new.indexed = 1 AND new.retired_at IS NULL BEGIN
INSERT INTO memories_fts(rowid, content, category, tags)
VALUES (new.id, new.content, new.category, new.tags);
END;
"""


def _ensure_fts_triggers_scoped(conn) -> bool:
"""Replace legacy unscoped memories_fts update triggers with column-scoped
ones (issue #152). Idempotent; returns True only if it rewrote them."""
try:
rows = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='trigger' "
"AND name IN ('memories_fts_update_delete', 'memories_fts_update_insert')"
).fetchall()
except sqlite3.Error:
return False
if not rows or all(r[0] and "AFTER UPDATE OF" in r[0].upper() for r in rows):
return False
try:
conn.executescript(_SCOPED_FTS_UPDATE_TRIGGERS)
conn.execute("INSERT INTO memories_fts(memories_fts) VALUES('rebuild')")
conn.commit()
return True
except sqlite3.Error:
return False


def ensure_agent(conn, agent_id: str) -> None:
if not agent_id:
return
Expand Down Expand Up @@ -3640,6 +3674,21 @@ def _ensure_db_initialized() -> None:
file=sys.stderr,
)

# Heal legacy unscoped memories_fts update triggers (issue #152) on
# installs that predate migration 083 or never run migrations.
try:
conn = sqlite3.connect(str(db_path), timeout=10)
try:
if _ensure_fts_triggers_scoped(conn):
print(
f"brainctl-mcp: rescoped memories_fts update triggers for {db_path}",
file=sys.stderr,
)
finally:
conn.close()
except sqlite3.Error:
pass


def run():
"""Synchronous entry point for pyproject.toml console_scripts."""
Expand Down
70 changes: 35 additions & 35 deletions tests/bench/baselines/search_quality.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,27 @@
"by_category": {
"ambiguous": {
"count": 1,
"mrr": 0.0,
"ndcg_at_5": 0.0,
"p_at_1": 0.0,
"p_at_5": 0.0,
"recall_at_5": 0.0
"mrr": 1.0,
"ndcg_at_5": 1.0,
"p_at_1": 1.0,
"p_at_5": 0.6,
"recall_at_5": 1.0
},
"decision": {
"count": 3,
"mrr": 0.3333,
"ndcg_at_5": 0.3186,
"p_at_1": 0.3333,
"p_at_5": 0.1333,
"recall_at_5": 0.3333
"mrr": 1.0,
"ndcg_at_5": 1.0,
"p_at_1": 1.0,
"p_at_5": 0.3333,
"recall_at_5": 1.0
},
"entity": {
"count": 7,
"mrr": 0.7143,
"ndcg_at_5": 0.6099,
"p_at_1": 0.7143,
"p_at_5": 0.2,
"recall_at_5": 0.4524
"mrr": 1.0,
"ndcg_at_5": 0.8474,
"p_at_1": 1.0,
"p_at_5": 0.2857,
"recall_at_5": 0.6429
},
"negative": {
"count": 1,
Expand All @@ -35,37 +35,37 @@
"procedural": {
"count": 4,
"mrr": 0.875,
"ndcg_at_5": 0.6333,
"ndcg_at_5": 0.8032,
"p_at_1": 0.75,
"p_at_5": 0.25,
"recall_at_5": 0.75
"p_at_5": 0.3,
"recall_at_5": 0.875
},
"temporal": {
"count": 2,
"mrr": 1.0,
"ndcg_at_5": 0.8936,
"p_at_1": 1.0,
"p_at_5": 0.3,
"recall_at_5": 0.75
"mrr": 0.4167,
"ndcg_at_5": 0.5335,
"p_at_1": 0.0,
"p_at_5": 0.4,
"recall_at_5": 1.0
},
"troubleshooting": {
"count": 2,
"mrr": 0.5,
"ndcg_at_5": 0.3066,
"p_at_1": 0.5,
"p_at_5": 0.1,
"recall_at_5": 0.25
"mrr": 1.0,
"ndcg_at_5": 1.0,
"p_at_1": 1.0,
"p_at_5": 0.3,
"recall_at_5": 1.0
}
},
"k": 10,
"overall": {
"mrr": 0.625,
"mrr": 0.8667,
"n_queries": 20,
"ndcg_at_10": 0.5579,
"ndcg_at_5": 0.5579,
"p_at_1": 0.6,
"p_at_5": 0.18,
"recall_at_10": 0.5083,
"recall_at_5": 0.5083
"ndcg_at_10": 0.8606,
"ndcg_at_5": 0.8606,
"p_at_1": 0.8,
"p_at_5": 0.31,
"recall_at_10": 0.85,
"recall_at_5": 0.85
}
}
Loading