agentgraph-server 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. agentgraph/__init__.py +1 -0
  2. agentgraph/auth/__init__.py +0 -0
  3. agentgraph/auth/credentials.py +224 -0
  4. agentgraph/backends/__init__.py +50 -0
  5. agentgraph/backends/sqlite/__init__.py +1 -0
  6. agentgraph/backends/sqlite/backend.py +1471 -0
  7. agentgraph/backends/sqlite/vector.py +142 -0
  8. agentgraph/cli.py +721 -0
  9. agentgraph/cli_query.py +519 -0
  10. agentgraph/config.py +90 -0
  11. agentgraph/connectors/__init__.py +0 -0
  12. agentgraph/connectors/base.py +455 -0
  13. agentgraph/connectors/registry.py +78 -0
  14. agentgraph/connectors/status.py +244 -0
  15. agentgraph/core/__init__.py +0 -0
  16. agentgraph/core/context.py +26 -0
  17. agentgraph/core/runtime.py +36 -0
  18. agentgraph/core/storage.py +240 -0
  19. agentgraph/graph/__init__.py +1 -0
  20. agentgraph/graph/bookmark.py +87 -0
  21. agentgraph/graph/delete.py +17 -0
  22. agentgraph/graph/download.py +35 -0
  23. agentgraph/graph/embeddings.py +58 -0
  24. agentgraph/graph/fetch.py +53 -0
  25. agentgraph/graph/gc.py +26 -0
  26. agentgraph/graph/link.py +63 -0
  27. agentgraph/graph/person.py +40 -0
  28. agentgraph/graph/query.py +244 -0
  29. agentgraph/graph/upsert.py +49 -0
  30. agentgraph/logging.py +78 -0
  31. agentgraph/mcp/__init__.py +0 -0
  32. agentgraph/mcp/server.py +811 -0
  33. agentgraph/perf.py +43 -0
  34. agentgraph/server/__init__.py +0 -0
  35. agentgraph/server/app.py +133 -0
  36. agentgraph/server/cli_api.py +708 -0
  37. agentgraph/server/dwell.py +79 -0
  38. agentgraph/server/graph_api.py +46 -0
  39. agentgraph/server/router.py +47 -0
  40. agentgraph/server/sync.py +247 -0
  41. agentgraph/skills.py +93 -0
  42. agentgraph_server-0.5.0.data/data/.agents/skills/graph/SKILL.md +159 -0
  43. agentgraph_server-0.5.0.data/data/.agents/skills/slack-auth/SKILL.md +92 -0
  44. agentgraph_server-0.5.0.dist-info/METADATA +286 -0
  45. agentgraph_server-0.5.0.dist-info/RECORD +49 -0
  46. agentgraph_server-0.5.0.dist-info/WHEEL +5 -0
  47. agentgraph_server-0.5.0.dist-info/entry_points.txt +2 -0
  48. agentgraph_server-0.5.0.dist-info/licenses/LICENSE +21 -0
  49. agentgraph_server-0.5.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1471 @@
1
+ """SQLite + FTS5 storage backend."""
2
+
3
+ # pyright: reportUnknownMemberType=false, reportUnknownVariableType=false
4
+ # pyright: reportUnknownArgumentType=false
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import json
10
+ import logging
11
+ import re
12
+ import sqlite3
13
+ import uuid
14
+ from datetime import UTC, datetime
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import aiosqlite
19
+
20
+ from agentgraph.backends.sqlite.vector import (
21
+ load_sqlite_vec,
22
+ pack_embedding,
23
+ vector_ranked,
24
+ )
25
+ from agentgraph.connectors.base import EntityBatch, EntityRecord, PersonRecord
26
+ from agentgraph.core.storage import EdgeResult, EntityResult, StorageBackend
27
+ from agentgraph.perf import timed
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ _SCHEMA_SQL = (Path(__file__).parent / "schema.sql").read_text()
32
+
33
+ _FTS5_SPECIAL = re.compile(r"[^\w\s]", re.UNICODE)
34
+
35
+
36
+ def _fts5_query(text: str) -> str:
37
+ """Strip FTS5 syntax characters so arbitrary user text doesn't cause parse errors."""
38
+ return _FTS5_SPECIAL.sub(" ", text).strip()
39
+
40
+
41
+ _VALID_ORDER_BY = {"created_at", "updated_at", "last_accessed", "synced_at"}
42
+ _LIST_PAGE_ORDER_BY = {
43
+ **{field: field for field in _VALID_ORDER_BY},
44
+ "display_name": """
45
+ COALESCE(
46
+ NULLIF(TRIM(title), ''),
47
+ NULLIF(TRIM(json_extract(metadata, '$.display_name')), ''),
48
+ NULLIF(TRIM(json_extract(metadata, '$.canonical_email')), ''),
49
+ NULLIF(TRIM(content), ''),
50
+ platform_entity_id,
51
+ id
52
+ ) COLLATE NOCASE
53
+ """,
54
+ "entity_type": "entity_type COLLATE NOCASE",
55
+ "platform": "platform COLLATE NOCASE",
56
+ }
57
+ _COLUMN_FILTERS = {"platform", "platform_entity_id", "entity_type"}
58
+ _FTS_DELETE_CHUNK_SIZE = 500
59
+
60
+
61
+ def _now() -> str:
62
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
63
+
64
+
65
+ def _new_id() -> str:
66
+ return str(uuid.uuid4())
67
+
68
+
69
+ def _append_merged_people(
70
+ metadata: dict[str, Any],
71
+ merged_people: list[dict[str, str]],
72
+ merged_person_ids: set[str],
73
+ ) -> None:
74
+ """Append valid, previously merged Person summaries without duplication."""
75
+ existing_merged_people = metadata.get("merged_people", [])
76
+ if not isinstance(existing_merged_people, list):
77
+ return
78
+ for existing_person in existing_merged_people:
79
+ if not isinstance(existing_person, dict):
80
+ continue
81
+ existing_id = existing_person.get("id")
82
+ existing_title = existing_person.get("title")
83
+ existing_ref = existing_person.get("platform_entity_id")
84
+ if (
85
+ not isinstance(existing_id, str)
86
+ or not isinstance(existing_title, str)
87
+ or not isinstance(existing_ref, str)
88
+ or existing_id in merged_person_ids
89
+ ):
90
+ continue
91
+ merged_people.append(
92
+ {
93
+ "id": existing_id,
94
+ "title": existing_title,
95
+ "platform_entity_id": existing_ref,
96
+ }
97
+ )
98
+ merged_person_ids.add(existing_id)
99
+
100
+
101
+ class SQLiteBackend(StorageBackend):
102
+ def __init__(
103
+ self, db_path: str = "~/.agentgraph/agentgraph.db", vector_mode: str = "sqlite-vec"
104
+ ) -> None:
105
+ self._db_path = str(Path(db_path).expanduser()) if db_path != ":memory:" else db_path
106
+ self._vector_mode = vector_mode
107
+ self._conn: aiosqlite.Connection | None = None
108
+ self._read_conn: aiosqlite.Connection | None = None
109
+ self._vec_loaded = False
110
+ # Serialises concurrent write transactions — SQLite only supports one writer at a time
111
+ # and we use explicit BEGIN/COMMIT, so concurrent polls would deadlock without this.
112
+ self._write_lock: asyncio.Lock | None = None
113
+
114
+ async def initialize(self) -> None:
115
+ self._write_lock = asyncio.Lock()
116
+
117
+ if self._db_path != ":memory:":
118
+ Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
119
+
120
+ self._conn = await aiosqlite.connect(self._db_path, isolation_level=None)
121
+ self._conn.row_factory = sqlite3.Row
122
+ if self._db_path == ":memory:":
123
+ self._read_conn = self._conn
124
+
125
+ if self._db_path != ":memory:":
126
+ await self._conn.execute("PRAGMA journal_mode=WAL")
127
+ await self._conn.execute("PRAGMA foreign_keys=ON")
128
+ await self._conn.executescript(_SCHEMA_SQL)
129
+
130
+ await self._run_migrations()
131
+
132
+ if self._db_path != ":memory:":
133
+ self._read_conn = await aiosqlite.connect(self._db_path, isolation_level=None)
134
+ self._read_conn.row_factory = sqlite3.Row
135
+ await self._read_conn.execute("PRAGMA foreign_keys=ON")
136
+
137
+ if self._vector_mode == "sqlite-vec":
138
+ assert self._read_conn is not None
139
+ self._vec_loaded = await load_sqlite_vec(self._read_conn)
140
+ if self._vec_loaded:
141
+ logger.info("sqlite-vec extension loaded")
142
+ else:
143
+ logger.info("sqlite-vec not available, falling back to numpy")
144
+
145
+ async def close(self) -> None:
146
+ if self._read_conn is not None and self._read_conn is not self._conn:
147
+ await self._read_conn.close()
148
+ self._read_conn = None
149
+ if self._conn is not None:
150
+ await self._conn.close()
151
+ self._conn = None
152
+ self._read_conn = None
153
+
154
+ def _conn_or_raise(self) -> aiosqlite.Connection:
155
+ if self._conn is None:
156
+ raise RuntimeError("SQLiteBackend not initialized — call initialize() first")
157
+ return self._conn
158
+
159
+ def _read_conn_or_raise(self) -> aiosqlite.Connection:
160
+ if self._read_conn is None:
161
+ raise RuntimeError("SQLiteBackend not initialized — call initialize() first")
162
+ return self._read_conn
163
+
164
+ # --- Internal helpers ---
165
+
166
+ async def _run_migrations(self) -> None:
167
+ conn = self._conn_or_raise()
168
+ cursor = await conn.execute("PRAGMA table_info(entities)")
169
+ columns = {row["name"] for row in await cursor.fetchall()}
170
+ if "cumulative_dwell_ms" not in columns:
171
+ await conn.execute(
172
+ "ALTER TABLE entities ADD COLUMN cumulative_dwell_ms INTEGER NOT NULL DEFAULT 0"
173
+ )
174
+ if "bookmarked" not in columns:
175
+ await conn.execute(
176
+ "ALTER TABLE entities ADD COLUMN bookmarked INTEGER NOT NULL DEFAULT 0"
177
+ )
178
+ if "observed_at" not in columns:
179
+ await conn.execute("ALTER TABLE entities ADD COLUMN observed_at TEXT")
180
+ await conn.execute(
181
+ "CREATE INDEX IF NOT EXISTS idx_entities_bookmarked ON entities(bookmarked)"
182
+ )
183
+ await conn.execute(
184
+ "CREATE INDEX IF NOT EXISTS idx_entities_platform_synced_at ON entities(platform, synced_at)"
185
+ )
186
+ await conn.execute(
187
+ "CREATE INDEX IF NOT EXISTS idx_entities_last_accessed_id ON entities(last_accessed DESC, id ASC)"
188
+ )
189
+ await conn.execute(
190
+ "CREATE INDEX IF NOT EXISTS idx_entities_type_last_accessed ON entities(entity_type, last_accessed DESC)"
191
+ )
192
+ await conn.execute(
193
+ "CREATE INDEX IF NOT EXISTS idx_entities_type_last_accessed_id ON entities(entity_type, last_accessed DESC, id ASC)"
194
+ )
195
+ await conn.execute(
196
+ "CREATE INDEX IF NOT EXISTS idx_entities_type_created_at ON entities(entity_type, created_at DESC)"
197
+ )
198
+ await conn.execute(
199
+ "CREATE INDEX IF NOT EXISTS idx_entities_type_updated_at ON entities(entity_type, updated_at DESC)"
200
+ )
201
+ await conn.execute(
202
+ "CREATE INDEX IF NOT EXISTS idx_entities_platform_last_accessed_id ON entities(platform, last_accessed DESC, id ASC)"
203
+ )
204
+ await conn.execute(
205
+ "CREATE INDEX IF NOT EXISTS idx_entities_platform_type_last_accessed ON entities(platform, entity_type, last_accessed DESC)"
206
+ )
207
+
208
+ async def _fetchall(self, sql: str, params: list[Any] | None = None) -> list[Any]:
209
+ conn = self._read_conn_or_raise()
210
+ with timed("sqlite.fetchall"):
211
+ cursor = await conn.execute(sql, params or [])
212
+ return list(await cursor.fetchall())
213
+
214
+ async def _fetchone(self, sql: str, params: list[Any] | None = None) -> Any:
215
+ conn = self._read_conn_or_raise()
216
+ with timed("sqlite.fetchone"):
217
+ cursor = await conn.execute(sql, params or [])
218
+ return await cursor.fetchone()
219
+
220
+ async def _fetchval(self, sql: str, params: list[Any] | None = None) -> Any:
221
+ row = await self._fetchone(sql, params)
222
+ return row[0] if row else None
223
+
224
+ async def _execute(self, sql: str, params: list[Any] | None = None) -> None:
225
+ conn = self._conn_or_raise()
226
+ with timed("sqlite.execute"):
227
+ await conn.execute(sql, params or [])
228
+
229
+ async def _resolve_existing_entity_id(
230
+ self,
231
+ conn: aiosqlite.Connection,
232
+ platform: str,
233
+ platform_entity_id: str,
234
+ ) -> str | None:
235
+ cursor = await conn.execute(
236
+ "SELECT id FROM entities WHERE platform = ? AND platform_entity_id = ?",
237
+ [platform, platform_entity_id],
238
+ )
239
+ row = await cursor.fetchone()
240
+ return str(row[0]) if row else None
241
+
242
+ async def _resolve_existing_person_id(
243
+ self,
244
+ conn: aiosqlite.Connection,
245
+ platform: str,
246
+ platform_user_id: str,
247
+ ) -> str | None:
248
+ cursor = await conn.execute(
249
+ """
250
+ SELECT id
251
+ FROM entities
252
+ WHERE entity_type = 'Person'
253
+ AND platform = 'canonical'
254
+ AND json_extract(metadata, ?) = ?
255
+ """,
256
+ [f"$.{platform}_user_id", platform_user_id],
257
+ )
258
+ row = await cursor.fetchone()
259
+ return str(row[0]) if row else None
260
+
261
+ # --- Write ---
262
+
263
+ async def upsert_batch(
264
+ self,
265
+ batch: EntityBatch,
266
+ person_embeddings: dict[str, list[float] | None],
267
+ entity_embeddings: dict[str, list[float] | None],
268
+ ) -> None:
269
+ assert self._write_lock is not None
270
+ async with self._write_lock:
271
+ conn = self._conn_or_raise()
272
+ with timed(
273
+ "sqlite.upsert_batch",
274
+ entities=len(batch.entities),
275
+ persons=len(batch.persons),
276
+ edges=len(batch.edges),
277
+ ):
278
+ await conn.execute("BEGIN")
279
+ try:
280
+ person_id_map = await self._upsert_persons(
281
+ conn, batch.persons, person_embeddings
282
+ )
283
+ entity_id_map = await self._upsert_entities(
284
+ conn, batch.entities, entity_embeddings
285
+ )
286
+ await self._upsert_edges(conn, batch, person_id_map, entity_id_map)
287
+ await conn.execute("COMMIT")
288
+ except Exception:
289
+ await conn.execute("ROLLBACK")
290
+ raise
291
+
292
+ async def _upsert_persons(
293
+ self,
294
+ conn: aiosqlite.Connection,
295
+ persons: list[PersonRecord],
296
+ embeddings: dict[str, list[float] | None],
297
+ ) -> dict[str, str]:
298
+ id_map: dict[str, str] = {}
299
+ for p in persons:
300
+ canonical_key = p.canonical_email or f"{p.platform}:{p.platform_user_id}"
301
+ meta: dict[str, str] = {}
302
+ if p.canonical_email:
303
+ meta["canonical_email"] = p.canonical_email
304
+ meta[f"{p.platform}_user_id"] = p.platform_user_id
305
+ if p.platform_username:
306
+ meta[f"{p.platform}_username"] = p.platform_username
307
+
308
+ embedding = embeddings.get(canonical_key)
309
+ emb_blob = pack_embedding(embedding) if embedding else None
310
+ now = _now()
311
+
312
+ existing_id = await self._resolve_person_for_upsert(
313
+ conn, canonical_key, p.platform, p.platform_user_id
314
+ )
315
+ if existing_id is not None:
316
+ existing_cursor = await conn.execute(
317
+ "SELECT title, content FROM entities WHERE id = ?", [existing_id]
318
+ )
319
+ existing_row = await existing_cursor.fetchone()
320
+ existing_title = str(existing_row[0]) if existing_row and existing_row[0] else ""
321
+ existing_content = str(existing_row[1]) if existing_row and existing_row[1] else ""
322
+ fts_title = p.display_name if p.display_name is not None else existing_title
323
+ fts_content = (
324
+ p.canonical_email if p.canonical_email is not None else existing_content
325
+ )
326
+ rewrite_fts = fts_title != existing_title or fts_content != existing_content
327
+ await conn.execute(
328
+ """
329
+ UPDATE entities
330
+ SET title = COALESCE(?, title),
331
+ content = COALESCE(?, content),
332
+ content_embedding = COALESCE(?, content_embedding),
333
+ metadata = json_patch(metadata, ?),
334
+ last_accessed = ?
335
+ WHERE id = ?
336
+ """,
337
+ [
338
+ p.display_name,
339
+ p.canonical_email,
340
+ emb_blob,
341
+ json.dumps(meta),
342
+ now,
343
+ existing_id,
344
+ ],
345
+ )
346
+ entity_id = existing_id
347
+ else:
348
+ cursor = await conn.execute(
349
+ """
350
+ INSERT INTO entities
351
+ (id, entity_type, platform, platform_entity_id, title, content,
352
+ content_embedding, metadata, last_accessed)
353
+ VALUES (?, 'Person', 'canonical', ?, ?, ?, ?, ?, ?)
354
+ RETURNING id
355
+ """,
356
+ [
357
+ _new_id(),
358
+ canonical_key,
359
+ p.display_name,
360
+ p.canonical_email,
361
+ emb_blob,
362
+ json.dumps(meta),
363
+ now,
364
+ ],
365
+ )
366
+ row = await cursor.fetchone()
367
+ if row is None:
368
+ raise RuntimeError("Failed to upsert person entity")
369
+ entity_id = str(row[0])
370
+ fts_title = p.display_name or ""
371
+ fts_content = p.canonical_email or ""
372
+ rewrite_fts = bool(fts_title or fts_content)
373
+
374
+ if rewrite_fts:
375
+ if existing_id is not None:
376
+ await conn.execute("DELETE FROM entities_fts WHERE id = ?", [entity_id])
377
+ await conn.execute(
378
+ "INSERT INTO entities_fts (id, title, content) VALUES (?, ?, ?)",
379
+ [entity_id, fts_title, fts_content],
380
+ )
381
+
382
+ id_map[p.platform_user_id] = entity_id
383
+ if p.canonical_email:
384
+ id_map[p.canonical_email] = entity_id
385
+ return id_map
386
+
387
+ async def _resolve_person_for_upsert(
388
+ self,
389
+ conn: aiosqlite.Connection,
390
+ canonical_key: str,
391
+ platform: str,
392
+ platform_user_id: str,
393
+ ) -> str | None:
394
+ cursor = await conn.execute(
395
+ """
396
+ SELECT id
397
+ FROM entities
398
+ WHERE entity_type = 'Person'
399
+ AND platform = 'canonical'
400
+ AND platform_entity_id = ?
401
+ """,
402
+ [canonical_key],
403
+ )
404
+ row = await cursor.fetchone()
405
+ if row:
406
+ return str(row[0])
407
+ return await self._resolve_existing_person_id(conn, platform, platform_user_id)
408
+
409
+ async def _upsert_entities(
410
+ self,
411
+ conn: aiosqlite.Connection,
412
+ entities: list[EntityRecord],
413
+ embeddings: dict[str, list[float] | None],
414
+ ) -> dict[str, str]:
415
+ id_map: dict[str, str] = {}
416
+ # FTS maintenance is independent of edge resolution. Deferring it avoids
417
+ # two SQLite round trips for every entity in a connector-sized batch.
418
+ fts_delete_ids: list[str] = []
419
+ fts_entries: dict[str, tuple[str, str]] = {}
420
+ now = _now()
421
+ for e in entities:
422
+ if e.is_stub:
423
+ cursor = await conn.execute(
424
+ """
425
+ INSERT INTO entities (id, entity_type, platform, platform_entity_id, last_accessed)
426
+ VALUES (?, ?, ?, ?, ?)
427
+ ON CONFLICT (platform, platform_entity_id) DO UPDATE SET
428
+ last_accessed = EXCLUDED.last_accessed
429
+ RETURNING id
430
+ """,
431
+ [_new_id(), e.entity_type, e.platform, e.platform_entity_id, now],
432
+ )
433
+ else:
434
+ existing_cursor = await conn.execute(
435
+ """
436
+ SELECT id, title, content FROM entities
437
+ WHERE platform = ? AND platform_entity_id = ?
438
+ """,
439
+ [e.platform, e.platform_entity_id],
440
+ )
441
+ existing_row = await existing_cursor.fetchone()
442
+ existing_title = str(existing_row[1]) if existing_row and existing_row[1] else ""
443
+ existing_content = str(existing_row[2]) if existing_row and existing_row[2] else ""
444
+ fts_title = e.title if e.title is not None else existing_title
445
+ fts_content = e.content if e.content is not None else existing_content
446
+ rewrite_fts = (
447
+ existing_row is None
448
+ or fts_title != existing_title
449
+ or fts_content != existing_content
450
+ )
451
+ embedding = embeddings.get(e.platform_entity_id)
452
+ emb_blob = pack_embedding(embedding) if embedding else None
453
+ created = e.created_at.strftime("%Y-%m-%dT%H:%M:%SZ") if e.created_at else None
454
+ updated = e.updated_at.strftime("%Y-%m-%dT%H:%M:%SZ") if e.updated_at else None
455
+ cursor = await conn.execute(
456
+ """
457
+ INSERT INTO entities
458
+ (id, entity_type, platform, platform_entity_id, title, content,
459
+ content_embedding, metadata, created_at, updated_at, synced_at, last_accessed)
460
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
461
+ ON CONFLICT (platform, platform_entity_id) DO UPDATE SET
462
+ entity_type = CASE WHEN entities.entity_type = 'Document' THEN EXCLUDED.entity_type ELSE entities.entity_type END,
463
+ title = COALESCE(EXCLUDED.title, entities.title),
464
+ content = COALESCE(EXCLUDED.content, entities.content),
465
+ content_embedding = COALESCE(EXCLUDED.content_embedding, entities.content_embedding),
466
+ metadata = EXCLUDED.metadata,
467
+ updated_at = COALESCE(EXCLUDED.updated_at, entities.updated_at),
468
+ synced_at = EXCLUDED.last_accessed,
469
+ last_accessed = EXCLUDED.last_accessed
470
+ RETURNING id
471
+ """,
472
+ [
473
+ _new_id(),
474
+ e.entity_type,
475
+ e.platform,
476
+ e.platform_entity_id,
477
+ e.title,
478
+ e.content,
479
+ emb_blob,
480
+ json.dumps(dict(e.metadata)),
481
+ created,
482
+ updated,
483
+ now,
484
+ now,
485
+ ],
486
+ )
487
+
488
+ row = await cursor.fetchone()
489
+ if row is None:
490
+ raise RuntimeError(
491
+ f"Failed to upsert entity {e.platform}:{e.platform_entity_id}"
492
+ )
493
+ entity_id: str = row[0]
494
+ if rewrite_fts:
495
+ if existing_row is not None:
496
+ fts_delete_ids.append(entity_id)
497
+ if fts_title or fts_content:
498
+ fts_entries[entity_id] = (fts_title, fts_content)
499
+ id_map[e.platform_entity_id] = entity_id
500
+ continue
501
+
502
+ row = await cursor.fetchone()
503
+ if row is None:
504
+ raise RuntimeError(
505
+ f"Failed to upsert stub entity {e.platform}:{e.platform_entity_id}"
506
+ )
507
+ id_map[e.platform_entity_id] = row[0]
508
+
509
+ for start in range(0, len(fts_delete_ids), _FTS_DELETE_CHUNK_SIZE):
510
+ ids = fts_delete_ids[start : start + _FTS_DELETE_CHUNK_SIZE]
511
+ placeholders = ",".join("?" * len(ids))
512
+ await conn.execute(f"DELETE FROM entities_fts WHERE id IN ({placeholders})", ids)
513
+ inserts = [
514
+ [entity_id, title, content]
515
+ for entity_id, entry in fts_entries.items()
516
+ for title, content in [entry]
517
+ ]
518
+ if inserts:
519
+ await conn.executemany(
520
+ "INSERT INTO entities_fts (id, title, content) VALUES (?, ?, ?)", inserts
521
+ )
522
+ return id_map
523
+
524
+ async def _upsert_edges(
525
+ self,
526
+ conn: aiosqlite.Connection,
527
+ batch: EntityBatch,
528
+ person_id_map: dict[str, str],
529
+ entity_id_map: dict[str, str],
530
+ ) -> None:
531
+ now = _now()
532
+ for edge in batch.edges:
533
+ source_id: str | None = (
534
+ entity_id_map.get(edge.source_platform_entity_id)
535
+ if edge.source_platform_entity_id
536
+ else person_id_map.get(edge.source_platform_user_id or "")
537
+ if edge.source_platform_user_id
538
+ else None
539
+ )
540
+ if not source_id:
541
+ if edge.source_platform_entity_id:
542
+ source_id = await self._resolve_existing_entity_id(
543
+ conn, edge.platform, edge.source_platform_entity_id
544
+ )
545
+ elif edge.source_platform_user_id:
546
+ source_id = await self._resolve_existing_person_id(
547
+ conn, edge.platform, edge.source_platform_user_id
548
+ )
549
+ target_id: str | None = (
550
+ entity_id_map.get(edge.target_platform_entity_id)
551
+ if edge.target_platform_entity_id
552
+ else person_id_map.get(edge.target_platform_user_id or "")
553
+ if edge.target_platform_user_id
554
+ else None
555
+ )
556
+ if not target_id:
557
+ if edge.target_platform_entity_id:
558
+ target_id = await self._resolve_existing_entity_id(
559
+ conn, edge.platform, edge.target_platform_entity_id
560
+ )
561
+ elif edge.target_platform_user_id:
562
+ target_id = await self._resolve_existing_person_id(
563
+ conn, edge.platform, edge.target_platform_user_id
564
+ )
565
+ if not source_id:
566
+ logger.warning("Skipping edge %s — source not resolved", edge.edge_type)
567
+ continue
568
+ if not target_id:
569
+ logger.warning("Skipping edge %s — target not resolved", edge.edge_type)
570
+ continue
571
+ await conn.execute(
572
+ """
573
+ INSERT INTO edges (id, edge_type, source_entity_id, target_entity_id, platform, properties, created_at)
574
+ VALUES (?, ?, ?, ?, ?, ?, ?)
575
+ ON CONFLICT (edge_type, source_entity_id, target_entity_id) DO NOTHING
576
+ """,
577
+ [
578
+ _new_id(),
579
+ edge.edge_type,
580
+ source_id,
581
+ target_id,
582
+ edge.platform,
583
+ json.dumps(dict(edge.properties)),
584
+ now,
585
+ ],
586
+ )
587
+
588
+ async def merge_person_entities(
589
+ self,
590
+ primary_entity_id: str,
591
+ duplicate_entity_ids: list[str],
592
+ ) -> EntityResult:
593
+ duplicate_ids = [eid for eid in duplicate_entity_ids if eid != primary_entity_id]
594
+ if not duplicate_ids:
595
+ entity = await self.get_entity_by_id(primary_entity_id)
596
+ if entity is None:
597
+ raise ValueError(f"Entity {primary_entity_id!r} not found")
598
+ if entity["entity_type"] != "Person":
599
+ raise ValueError(f"Entity {primary_entity_id!r} is not a Person")
600
+ return entity
601
+
602
+ assert self._write_lock is not None
603
+ async with self._write_lock:
604
+ conn = self._conn_or_raise()
605
+ await conn.execute("BEGIN")
606
+ try:
607
+ all_ids = [primary_entity_id, *duplicate_ids]
608
+ placeholders = ",".join("?" * len(all_ids))
609
+ cursor = await conn.execute(
610
+ f"""
611
+ SELECT id, entity_type, platform, platform_entity_id,
612
+ title, content, metadata, last_accessed
613
+ FROM entities
614
+ WHERE id IN ({placeholders})
615
+ """,
616
+ all_ids,
617
+ )
618
+ rows = await cursor.fetchall()
619
+ by_id = {str(row["id"]): row for row in rows}
620
+ missing = [eid for eid in all_ids if eid not in by_id]
621
+ if missing:
622
+ raise ValueError(f"Person entity not found: {missing[0]}")
623
+ for eid, row in by_id.items():
624
+ if row["entity_type"] != "Person":
625
+ raise ValueError(f"Entity {eid!r} is not a Person")
626
+
627
+ primary = by_id[primary_entity_id]
628
+ primary_metadata = json.loads(primary["metadata"] or "{}")
629
+ merged_people: list[dict[str, str]] = []
630
+ merged_person_ids: set[str] = set()
631
+ _append_merged_people(
632
+ primary_metadata,
633
+ merged_people,
634
+ merged_person_ids,
635
+ )
636
+ for eid in duplicate_ids:
637
+ duplicate = by_id[eid]
638
+ duplicate_metadata = json.loads(duplicate["metadata"] or "{}")
639
+ _append_merged_people(
640
+ duplicate_metadata,
641
+ merged_people,
642
+ merged_person_ids,
643
+ )
644
+
645
+ merged_people.append(
646
+ {
647
+ "id": eid,
648
+ "title": str(duplicate["title"] or duplicate["platform_entity_id"]),
649
+ "platform_entity_id": str(duplicate["platform_entity_id"]),
650
+ }
651
+ )
652
+ merged_person_ids.add(eid)
653
+
654
+ merged_metadata: dict[str, Any] = {}
655
+ for eid in duplicate_ids:
656
+ merged_metadata.update(json.loads(by_id[eid]["metadata"] or "{}"))
657
+ merged_metadata.update(primary_metadata)
658
+ merged_metadata["merged_people"] = merged_people
659
+
660
+ title = primary["title"] or next(
661
+ (by_id[eid]["title"] for eid in duplicate_ids if by_id[eid]["title"]),
662
+ None,
663
+ )
664
+ content = primary["content"] or next(
665
+ (by_id[eid]["content"] for eid in duplicate_ids if by_id[eid]["content"]),
666
+ None,
667
+ )
668
+ last_accessed_values = [
669
+ value for value in (by_id[eid]["last_accessed"] for eid in all_ids) if value
670
+ ]
671
+ last_accessed = max(last_accessed_values) if last_accessed_values else _now()
672
+
673
+ await conn.execute(
674
+ """
675
+ UPDATE entities
676
+ SET title = ?, content = ?, metadata = ?, last_accessed = ?
677
+ WHERE id = ?
678
+ """,
679
+ [title, content, json.dumps(merged_metadata), last_accessed, primary_entity_id],
680
+ )
681
+
682
+ dup_placeholders = ",".join("?" * len(duplicate_ids))
683
+ await conn.execute(
684
+ f"""
685
+ UPDATE OR IGNORE edges
686
+ SET source_entity_id = ?
687
+ WHERE source_entity_id IN ({dup_placeholders})
688
+ """,
689
+ [primary_entity_id, *duplicate_ids],
690
+ )
691
+ await conn.execute(
692
+ f"DELETE FROM edges WHERE source_entity_id IN ({dup_placeholders})",
693
+ duplicate_ids,
694
+ )
695
+ await conn.execute(
696
+ f"""
697
+ UPDATE OR IGNORE edges
698
+ SET target_entity_id = ?
699
+ WHERE target_entity_id IN ({dup_placeholders})
700
+ """,
701
+ [primary_entity_id, *duplicate_ids],
702
+ )
703
+ await conn.execute(
704
+ f"DELETE FROM edges WHERE target_entity_id IN ({dup_placeholders})",
705
+ duplicate_ids,
706
+ )
707
+ await conn.execute(
708
+ "DELETE FROM edges WHERE source_entity_id = ? AND target_entity_id = ?",
709
+ [primary_entity_id, primary_entity_id],
710
+ )
711
+ await conn.execute(
712
+ f"DELETE FROM entities_fts WHERE id IN ({','.join('?' * len(all_ids))})",
713
+ all_ids,
714
+ )
715
+ await conn.execute(
716
+ f"DELETE FROM entities WHERE id IN ({dup_placeholders})",
717
+ duplicate_ids,
718
+ )
719
+ if title or content:
720
+ await conn.execute(
721
+ "INSERT INTO entities_fts (id, title, content) VALUES (?, ?, ?)",
722
+ [primary_entity_id, title or "", content or ""],
723
+ )
724
+ await conn.execute("COMMIT")
725
+ except Exception:
726
+ await conn.execute("ROLLBACK")
727
+ raise
728
+
729
+ entity = await self.get_entity_by_id(primary_entity_id)
730
+ if entity is None:
731
+ raise RuntimeError("Merged primary person disappeared")
732
+ return entity
733
+
734
+ async def set_entity_bookmarked(
735
+ self,
736
+ entity_id: str,
737
+ bookmarked: bool,
738
+ ) -> EntityResult:
739
+ now = _now()
740
+ cursor = await self._conn_or_raise().execute(
741
+ """
742
+ UPDATE entities
743
+ SET bookmarked = ?, last_accessed = ?
744
+ WHERE id = ?
745
+ RETURNING id, entity_type, platform, platform_entity_id,
746
+ title, content, metadata, created_at, updated_at, synced_at,
747
+ observed_at, last_accessed, cumulative_dwell_ms, bookmarked
748
+ """,
749
+ [1 if bookmarked else 0, now, entity_id],
750
+ )
751
+ row = await cursor.fetchone()
752
+ if row is None:
753
+ raise ValueError(f"Entity {entity_id!r} not found")
754
+ return _row_to_entity(row)
755
+
756
+ async def delete_entity(self, entity_id: str) -> EntityResult:
757
+ """Delete one entity by internal ID. Edges cascade; FTS rows are removed explicitly."""
758
+ assert self._write_lock is not None
759
+ async with self._write_lock:
760
+ conn = self._conn_or_raise()
761
+ await conn.execute("BEGIN")
762
+ try:
763
+ cursor = await conn.execute(
764
+ """
765
+ DELETE FROM entities
766
+ WHERE id = ?
767
+ RETURNING id, entity_type, platform, platform_entity_id,
768
+ title, content, metadata, created_at, updated_at, synced_at,
769
+ observed_at, last_accessed, cumulative_dwell_ms, bookmarked
770
+ """,
771
+ [entity_id],
772
+ )
773
+ row = await cursor.fetchone()
774
+ if row is None:
775
+ raise ValueError(f"Entity {entity_id!r} not found")
776
+ await conn.execute("DELETE FROM entities_fts WHERE id = ?", [entity_id])
777
+ await conn.execute("COMMIT")
778
+ except Exception:
779
+ await conn.execute("ROLLBACK")
780
+ raise
781
+ return _row_to_entity(row)
782
+
783
+ # --- Read: entities ---
784
+
785
+ async def search_entities(
786
+ self,
787
+ query_vec: list[float],
788
+ query_text: str,
789
+ entity_types: list[str] | None,
790
+ limit: int,
791
+ min_score: float,
792
+ platform: str | None = None,
793
+ ) -> list[EntityResult]:
794
+ conn = self._read_conn_or_raise()
795
+
796
+ with timed("sqlite.search_entities", limit=limit, platform=platform):
797
+ initial_candidate_limit = limit * 2
798
+ max_candidate_limit = limit * 5
799
+
800
+ # BM25 via FTS5
801
+ fts_ids: list[tuple[str, int]] = []
802
+ with timed("sqlite.search.fts", limit=initial_candidate_limit, platform=platform):
803
+ try:
804
+ extra_clause = ""
805
+ fts_extra_params: list[Any] = []
806
+ if entity_types:
807
+ placeholders = ",".join("?" * len(entity_types))
808
+ extra_clause += f" AND e.entity_type IN ({placeholders})"
809
+ fts_extra_params.extend(entity_types)
810
+ if platform:
811
+ extra_clause += " AND e.platform = ?"
812
+ fts_extra_params.append(platform)
813
+
814
+ cursor = await conn.execute(
815
+ f"""
816
+ SELECT e.id
817
+ FROM entities_fts f
818
+ JOIN entities e ON e.id = f.id
819
+ WHERE entities_fts MATCH ? {extra_clause}
820
+ ORDER BY f.rank
821
+ LIMIT ?
822
+ """,
823
+ [_fts5_query(query_text), *fts_extra_params, initial_candidate_limit],
824
+ )
825
+ rows = await cursor.fetchall()
826
+ fts_ids = [(row[0], i + 1) for i, row in enumerate(rows)]
827
+ except Exception:
828
+ pass
829
+
830
+ # Vector search is the expensive leg. A saturated initial FTS
831
+ # window already has enough lexical candidates for the requested
832
+ # result count, so avoid the O(n) vector scan in that common case.
833
+ # Sparse FTS queries use the larger window to preserve the existing
834
+ # hybrid-search recall.
835
+ if len(fts_ids) >= initial_candidate_limit:
836
+ vec_ids: list[tuple[str, int]] = []
837
+ logger.debug(
838
+ "search skipped vector scan because FTS returned %d candidates",
839
+ len(fts_ids),
840
+ )
841
+ else:
842
+ vec_ids = await vector_ranked(
843
+ conn,
844
+ query_vec,
845
+ entity_types,
846
+ limit,
847
+ self._vector_mode,
848
+ self._vec_loaded,
849
+ platform=platform,
850
+ candidate_limit=max_candidate_limit,
851
+ )
852
+
853
+ # RRF fusion (k=60, fulltext weight=2x)
854
+ # Rule: if BM25 found anything, include only results that BM25 also found.
855
+ with timed("sqlite.search.fusion", limit=limit):
856
+ fts_set = {eid for eid, _ in fts_ids}
857
+ vec_rank: dict[str, int] = {eid: rank for eid, rank in vec_ids}
858
+ fts_rank: dict[str, int] = {eid: rank for eid, rank in fts_ids}
859
+
860
+ candidates = fts_set if fts_set else set(vec_rank)
861
+ if not candidates:
862
+ return []
863
+
864
+ scored: list[tuple[str, float]] = []
865
+ for eid in candidates:
866
+ score = 0.0
867
+ if eid in vec_rank:
868
+ score += 1.0 / (60 + vec_rank[eid])
869
+ if eid in fts_rank:
870
+ score += 2.0 / (60 + fts_rank[eid])
871
+ scored.append((eid, score))
872
+
873
+ scored.sort(key=lambda x: x[1], reverse=True)
874
+ top = scored[:limit]
875
+
876
+ if not top:
877
+ return []
878
+
879
+ import math
880
+
881
+ id_list = [eid for eid, _ in top]
882
+ score_map = {eid: sc for eid, sc in top}
883
+ placeholders = ",".join("?" * len(id_list))
884
+ with timed("sqlite.search.hydrate", count=len(id_list)):
885
+ cursor = await conn.execute(
886
+ f"""
887
+ SELECT id, entity_type, platform, platform_entity_id,
888
+ title, content, metadata, created_at, updated_at, synced_at,
889
+ observed_at, last_accessed, cumulative_dwell_ms, bookmarked
890
+ FROM entities WHERE id IN ({placeholders})
891
+ """,
892
+ id_list,
893
+ )
894
+ rows = await cursor.fetchall()
895
+ results: list[dict[str, Any]] = []
896
+ for row in rows:
897
+ r = _row_to_entity(row)
898
+ base_score = score_map.get(r["id"], 0.0)
899
+ dwell_ms = r.get("cumulative_dwell_ms", 0)
900
+ dwell_boost = 0.1 * math.log10(1 + (dwell_ms / 1000.0))
901
+ r["score"] = base_score + dwell_boost
902
+
903
+ if (r["score"] or 0) >= min_score:
904
+ results.append(r)
905
+
906
+ def _score(result: dict[str, Any]) -> float:
907
+ raw_score = result.get("score")
908
+ return float(raw_score) if isinstance(raw_score, int | float) else 0.0
909
+
910
+ results.sort(key=_score, reverse=True)
911
+ return results
912
+
913
+ async def get_entity_by_id(self, entity_id: str) -> EntityResult | None:
914
+ row = await self._fetchone(
915
+ """
916
+ SELECT id, entity_type, platform, platform_entity_id,
917
+ title, content, metadata, created_at, updated_at, synced_at,
918
+ observed_at, last_accessed, cumulative_dwell_ms, bookmarked
919
+ FROM entities WHERE id = ?
920
+ """,
921
+ [entity_id],
922
+ )
923
+ return _row_to_entity(row) if row else None
924
+
925
+ async def get_entities_by_ids(self, entity_ids: list[str]) -> list[EntityResult]:
926
+ if not entity_ids:
927
+ return []
928
+ placeholders = ",".join("?" * len(entity_ids))
929
+ rows = await self._fetchall(
930
+ f"""
931
+ SELECT id, entity_type, platform, platform_entity_id,
932
+ title, content, metadata, created_at, updated_at, synced_at,
933
+ observed_at, last_accessed, cumulative_dwell_ms, bookmarked
934
+ FROM entities WHERE id IN ({placeholders})
935
+ """,
936
+ entity_ids,
937
+ )
938
+ return [_row_to_entity(r) for r in rows]
939
+
940
+ async def get_entities_by_id_prefix(self, prefix: str) -> list[EntityResult]:
941
+ rows = await self._fetchall(
942
+ """
943
+ SELECT id, entity_type, platform, platform_entity_id,
944
+ title, content, metadata, created_at, updated_at, synced_at,
945
+ observed_at, last_accessed, cumulative_dwell_ms, bookmarked
946
+ FROM entities WHERE id LIKE ?
947
+ """,
948
+ [f"{prefix}%"],
949
+ )
950
+ return [_row_to_entity(row) for row in rows]
951
+
952
+ async def get_entity_by_platform(
953
+ self, platform: str, platform_entity_id: str
954
+ ) -> EntityResult | None:
955
+ row = await self._fetchone(
956
+ """
957
+ SELECT id, entity_type, platform, platform_entity_id,
958
+ title, content, metadata, created_at, updated_at, synced_at,
959
+ observed_at, last_accessed, cumulative_dwell_ms, bookmarked
960
+ FROM entities WHERE platform = ? AND platform_entity_id = ?
961
+ """,
962
+ [platform, platform_entity_id],
963
+ )
964
+ return _row_to_entity(row) if row else None
965
+
966
+ async def list_entities(
967
+ self,
968
+ entity_types: list[str] | None,
969
+ platform: str | None,
970
+ since: datetime | None,
971
+ limit: int,
972
+ ) -> list[EntityResult]:
973
+ clauses: list[str] = []
974
+ params: list[Any] = []
975
+ if entity_types:
976
+ placeholders = ",".join("?" * len(entity_types))
977
+ clauses.append(f"entity_type IN ({placeholders})")
978
+ params.extend(entity_types)
979
+ if platform:
980
+ clauses.append("platform = ?")
981
+ params.append(platform)
982
+ if since:
983
+ clauses.append("updated_at >= ?")
984
+ params.append(since.strftime("%Y-%m-%dT%H:%M:%SZ"))
985
+ where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
986
+ params.append(limit)
987
+ rows = await self._fetchall(
988
+ f"""
989
+ SELECT id, entity_type, platform, platform_entity_id,
990
+ title, content, metadata, created_at, updated_at, synced_at,
991
+ observed_at, last_accessed, cumulative_dwell_ms, bookmarked
992
+ FROM entities
993
+ {where}
994
+ ORDER BY last_accessed DESC
995
+ LIMIT ?
996
+ """,
997
+ params,
998
+ )
999
+ return [_row_to_entity(row) for row in rows]
1000
+
1001
+ async def list_entities_page(
1002
+ self,
1003
+ entity_types: list[str] | None,
1004
+ platform: str | None,
1005
+ since: datetime | None,
1006
+ limit: int,
1007
+ offset: int,
1008
+ order_by: str | None,
1009
+ order_dir: str,
1010
+ ) -> tuple[list[EntityResult], int]:
1011
+ order_by_sql = (
1012
+ _LIST_PAGE_ORDER_BY.get(order_by, "last_accessed") if order_by is not None else None
1013
+ )
1014
+ if order_dir.upper() not in {"ASC", "DESC"}:
1015
+ order_dir = "DESC"
1016
+ order_clause = (
1017
+ f"ORDER BY {order_by_sql} {order_dir}, id ASC" if order_by_sql is not None else ""
1018
+ )
1019
+
1020
+ clauses: list[str] = []
1021
+ params: list[Any] = []
1022
+ if entity_types:
1023
+ placeholders = ",".join("?" * len(entity_types))
1024
+ clauses.append(f"entity_type IN ({placeholders})")
1025
+ params.extend(entity_types)
1026
+ if platform:
1027
+ clauses.append("platform = ?")
1028
+ params.append(platform)
1029
+ if since:
1030
+ clauses.append("updated_at >= ?")
1031
+ params.append(since.strftime("%Y-%m-%dT%H:%M:%SZ"))
1032
+ where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
1033
+
1034
+ count_row = await self._fetchone(f"SELECT COUNT(*) AS count FROM entities {where}", params)
1035
+ total = int(count_row["count"]) if count_row else 0
1036
+ rows = await self._fetchall(
1037
+ f"""
1038
+ SELECT id, entity_type, platform, platform_entity_id,
1039
+ title, content, metadata, created_at, updated_at, synced_at,
1040
+ observed_at, last_accessed, cumulative_dwell_ms, bookmarked
1041
+ FROM entities
1042
+ {where}
1043
+ {order_clause}
1044
+ LIMIT ? OFFSET ?
1045
+ """,
1046
+ [*params, limit, offset],
1047
+ )
1048
+ return [_row_to_entity(row) for row in rows], total
1049
+
1050
+ async def query_by_filter(
1051
+ self,
1052
+ entity_type: str,
1053
+ filters: dict[str, str],
1054
+ limit: int,
1055
+ order_by: str,
1056
+ since: datetime | None,
1057
+ authored_by: list[str] | None,
1058
+ has_attachments: bool = False,
1059
+ ) -> list[EntityResult]:
1060
+ if order_by not in _VALID_ORDER_BY:
1061
+ order_by = "last_accessed"
1062
+
1063
+ params: list[Any] = [entity_type]
1064
+ extra_clauses: list[str] = []
1065
+ for k, v in filters.items():
1066
+ if k in _COLUMN_FILTERS:
1067
+ extra_clauses.append(f"e.{k} = ?")
1068
+ else:
1069
+ extra_clauses.append(f"json_extract(e.metadata, '$.{k}') = ?")
1070
+ params.append(v)
1071
+ if since:
1072
+ extra_clauses.append("e.updated_at >= ?")
1073
+ params.append(since.strftime("%Y-%m-%dT%H:%M:%SZ"))
1074
+ if has_attachments:
1075
+ extra_clauses.append(
1076
+ "json_extract(e.metadata, '$.attachments') IS NOT NULL"
1077
+ " AND json_extract(e.metadata, '$.attachments') != '[]'"
1078
+ )
1079
+
1080
+ authored_join = ""
1081
+ authored_params: list[Any] = []
1082
+ if authored_by:
1083
+ placeholders = ", ".join("?" for _ in authored_by)
1084
+ metadata_placeholders = ", ".join("?" for _ in authored_by)
1085
+ authored_join = f"""
1086
+ JOIN edges _auth ON _auth.edge_type = 'authored' AND _auth.target_entity_id = e.id
1087
+ JOIN entities _p ON _p.id = _auth.source_entity_id AND _p.entity_type = 'Person'
1088
+ AND (
1089
+ _p.platform_entity_id IN ({placeholders})
1090
+ OR EXISTS (
1091
+ SELECT 1 FROM json_each(_p.metadata)
1092
+ WHERE json_each.value IN ({metadata_placeholders})
1093
+ )
1094
+ )
1095
+ """
1096
+ authored_params.extend([*authored_by, *authored_by])
1097
+
1098
+ where_extra = ("AND " + " AND ".join(extra_clauses)) if extra_clauses else ""
1099
+ params.append(limit)
1100
+ with timed(
1101
+ "sqlite.query_by_filter", entity_type=entity_type, order_by=order_by, limit=limit
1102
+ ):
1103
+ rows = await self._fetchall(
1104
+ f"""
1105
+ SELECT e.id, e.entity_type, e.platform, e.platform_entity_id,
1106
+ e.title, e.content, e.metadata, e.created_at, e.updated_at,
1107
+ e.synced_at, e.observed_at, e.last_accessed,
1108
+ e.cumulative_dwell_ms, e.bookmarked
1109
+ FROM entities e
1110
+ {authored_join}
1111
+ WHERE e.entity_type = ? {where_extra}
1112
+ ORDER BY e.{order_by} DESC
1113
+ LIMIT ?
1114
+ """,
1115
+ [*authored_params, *params],
1116
+ )
1117
+ return [_row_to_entity(row) for row in rows]
1118
+
1119
+ # --- Read: edges ---
1120
+
1121
+ async def get_edges(
1122
+ self,
1123
+ entity_id: str,
1124
+ edge_type: str | None,
1125
+ direction: str,
1126
+ ) -> list[EdgeResult]:
1127
+ conditions: list[str] = []
1128
+ params: list[Any] = []
1129
+ if direction in ("out", "both"):
1130
+ conditions.append("e.source_entity_id = ?")
1131
+ params.append(entity_id)
1132
+ if direction in ("in", "both"):
1133
+ conditions.append("e.target_entity_id = ?")
1134
+ params.append(entity_id)
1135
+ if not conditions:
1136
+ return []
1137
+
1138
+ type_clause = ""
1139
+ if edge_type:
1140
+ type_clause = "AND e.edge_type = ?"
1141
+ params.append(edge_type)
1142
+
1143
+ where = " OR ".join(f"({c})" for c in conditions)
1144
+ rows = await self._fetchall(
1145
+ f"""
1146
+ SELECT e.id, e.edge_type, e.platform, e.properties,
1147
+ e.source_entity_id, e.target_entity_id,
1148
+ se.platform_entity_id AS source_ref,
1149
+ te.platform_entity_id AS target_ref
1150
+ FROM edges e
1151
+ LEFT JOIN entities se ON se.id = e.source_entity_id
1152
+ LEFT JOIN entities te ON te.id = e.target_entity_id
1153
+ WHERE ({where}) {type_clause}
1154
+ ORDER BY e.created_at DESC
1155
+ """,
1156
+ params,
1157
+ )
1158
+ return [_row_to_edge(row) for row in rows]
1159
+
1160
+ async def get_edges_for_entities(self, entity_ids: list[str]) -> list[EdgeResult]:
1161
+ if not entity_ids:
1162
+ return []
1163
+ placeholders = ",".join("?" * len(entity_ids))
1164
+ rows = await self._fetchall(
1165
+ f"""
1166
+ SELECT e.id, e.edge_type, e.platform, e.properties,
1167
+ e.source_entity_id, e.target_entity_id,
1168
+ se.platform_entity_id AS source_ref,
1169
+ te.platform_entity_id AS target_ref
1170
+ FROM edges e
1171
+ LEFT JOIN entities se ON se.id = e.source_entity_id
1172
+ LEFT JOIN entities te ON te.id = e.target_entity_id
1173
+ WHERE e.source_entity_id IN ({placeholders})
1174
+ OR e.target_entity_id IN ({placeholders})
1175
+ """,
1176
+ entity_ids + entity_ids,
1177
+ )
1178
+ return [_row_to_edge(row) for row in rows]
1179
+
1180
+ async def traverse_graph(self, entity_id: str, max_depth: int) -> dict[str, Any]:
1181
+ conn = self._read_conn_or_raise()
1182
+ visited: set[str] = set()
1183
+ frontier: list[str] = [entity_id]
1184
+ all_nodes: list[EntityResult] = []
1185
+ all_edges: list[EdgeResult] = []
1186
+ seen_edge_ids: set[str] = set()
1187
+
1188
+ for _ in range(max_depth):
1189
+ if not frontier:
1190
+ break
1191
+ placeholders = ",".join("?" * len(frontier))
1192
+ cursor = await conn.execute(
1193
+ f"""
1194
+ SELECT id, entity_type, platform, platform_entity_id,
1195
+ title, content, metadata, created_at, updated_at, synced_at,
1196
+ observed_at, last_accessed, cumulative_dwell_ms, bookmarked
1197
+ FROM entities WHERE id IN ({placeholders})
1198
+ """,
1199
+ frontier,
1200
+ )
1201
+ for row in await cursor.fetchall():
1202
+ eid = row["id"]
1203
+ if eid not in visited:
1204
+ visited.add(eid)
1205
+ all_nodes.append(_row_to_entity(row))
1206
+
1207
+ cursor = await conn.execute(
1208
+ f"""
1209
+ SELECT e.id, e.edge_type, e.platform, e.properties,
1210
+ e.source_entity_id, e.target_entity_id,
1211
+ se.platform_entity_id AS source_ref,
1212
+ te.platform_entity_id AS target_ref
1213
+ FROM edges e
1214
+ LEFT JOIN entities se ON se.id = e.source_entity_id
1215
+ LEFT JOIN entities te ON te.id = e.target_entity_id
1216
+ WHERE e.source_entity_id IN ({placeholders})
1217
+ OR e.target_entity_id IN ({placeholders})
1218
+ """,
1219
+ frontier + frontier,
1220
+ )
1221
+ next_frontier: list[str] = []
1222
+ for row in await cursor.fetchall():
1223
+ edge_id = str(row["id"])
1224
+ if edge_id not in seen_edge_ids:
1225
+ seen_edge_ids.add(edge_id)
1226
+ all_edges.append(_row_to_edge(row))
1227
+ for key in ("source_entity_id", "target_entity_id"):
1228
+ val: str | None = row[key]
1229
+ if val and val not in visited:
1230
+ next_frontier.append(val)
1231
+ frontier = list(set(next_frontier))
1232
+
1233
+ # Load final frontier
1234
+ unvisited = [eid for eid in frontier if eid not in visited]
1235
+ if unvisited:
1236
+ placeholders = ",".join("?" * len(unvisited))
1237
+ cursor = await conn.execute(
1238
+ f"""
1239
+ SELECT id, entity_type, platform, platform_entity_id,
1240
+ title, content, metadata, created_at, updated_at, synced_at,
1241
+ observed_at, last_accessed, cumulative_dwell_ms, bookmarked
1242
+ FROM entities WHERE id IN ({placeholders})
1243
+ """,
1244
+ unvisited,
1245
+ )
1246
+ for row in await cursor.fetchall():
1247
+ all_nodes.append(_row_to_entity(row))
1248
+
1249
+ return {"nodes": all_nodes, "edges": all_edges}
1250
+
1251
+ # --- Linking ---
1252
+
1253
+ async def find_entity_id(self, platform: str, platform_entity_id: str) -> str | None:
1254
+ return await self._fetchval(
1255
+ "SELECT id FROM entities WHERE platform = ? AND platform_entity_id = ?",
1256
+ [platform, platform_entity_id],
1257
+ )
1258
+
1259
+ async def upsert_stub_entity(
1260
+ self, entity_type: str, platform: str, platform_entity_id: str
1261
+ ) -> str:
1262
+ cursor = await self._conn_or_raise().execute(
1263
+ """
1264
+ INSERT INTO entities (id, entity_type, platform, platform_entity_id, last_accessed)
1265
+ VALUES (?, ?, ?, ?, ?)
1266
+ ON CONFLICT (platform, platform_entity_id) DO UPDATE SET
1267
+ last_accessed = EXCLUDED.last_accessed
1268
+ RETURNING id
1269
+ """,
1270
+ [_new_id(), entity_type, platform, platform_entity_id, _now()],
1271
+ )
1272
+ row = await cursor.fetchone()
1273
+ if row is None:
1274
+ raise RuntimeError(f"Failed to upsert stub entity {platform}:{platform_entity_id}")
1275
+ return row[0]
1276
+
1277
+ async def insert_references_edge(self, source_id: str, target_id: str) -> None:
1278
+ await self._execute(
1279
+ """
1280
+ INSERT INTO edges (id, edge_type, source_entity_id, target_entity_id, platform, properties)
1281
+ VALUES (?, 'references', ?, ?, 'cross', '{}')
1282
+ ON CONFLICT (edge_type, source_entity_id, target_entity_id) DO NOTHING
1283
+ """,
1284
+ [_new_id(), source_id, target_id],
1285
+ )
1286
+
1287
+ # --- GC ---
1288
+
1289
+ async def gc_entities(self, retention_days: int) -> int:
1290
+ cutoff = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
1291
+ # SQLite doesn't have interval arithmetic; compute cutoff in Python
1292
+ from datetime import timedelta
1293
+
1294
+ cutoff_dt = datetime.now(UTC) - timedelta(days=retention_days)
1295
+ cutoff = cutoff_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
1296
+
1297
+ assert self._write_lock is not None
1298
+ async with self._write_lock:
1299
+ conn = self._conn_or_raise()
1300
+ await conn.execute("BEGIN")
1301
+ try:
1302
+ cursor = await conn.execute(
1303
+ "SELECT id FROM entities WHERE last_accessed < ? AND bookmarked = 0",
1304
+ [cutoff],
1305
+ )
1306
+ to_delete = [row[0] for row in await cursor.fetchall()]
1307
+ if to_delete:
1308
+ placeholders = ",".join("?" * len(to_delete))
1309
+ await conn.execute(
1310
+ f"DELETE FROM entities WHERE id IN ({placeholders})", to_delete
1311
+ )
1312
+ await conn.execute(
1313
+ f"DELETE FROM entities_fts WHERE id IN ({placeholders})", to_delete
1314
+ )
1315
+ await conn.execute("COMMIT")
1316
+ except Exception:
1317
+ await conn.execute("ROLLBACK")
1318
+ raise
1319
+ return len(to_delete)
1320
+
1321
+ # --- Observations ---
1322
+
1323
+ # --- Sync state ---
1324
+
1325
+ async def load_cursor(self, source: str) -> dict[str, Any]:
1326
+ val = await self._fetchval("SELECT cursor FROM sync_state WHERE source = ?", [source])
1327
+ return json.loads(val) if val else {}
1328
+
1329
+ async def save_cursor(self, source: str, cursor: dict[str, Any]) -> None:
1330
+ await self._execute(
1331
+ """
1332
+ INSERT INTO sync_state (source, cursor, updated_at)
1333
+ VALUES (?, ?, ?)
1334
+ ON CONFLICT (source) DO UPDATE SET
1335
+ cursor = EXCLUDED.cursor,
1336
+ updated_at = EXCLUDED.updated_at
1337
+ """,
1338
+ [source, json.dumps(cursor), _now()],
1339
+ )
1340
+
1341
+ # --- Connector support ---
1342
+
1343
+ async def increment_dwell_time(
1344
+ self, platform: str, platform_entity_id: str, dwell_ms: int
1345
+ ) -> None:
1346
+ now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
1347
+ await self._execute(
1348
+ """
1349
+ UPDATE entities
1350
+ SET cumulative_dwell_ms = cumulative_dwell_ms + ?, observed_at = ?
1351
+ WHERE platform = ? AND platform_entity_id = ?
1352
+ """,
1353
+ [dwell_ms, now, platform, platform_entity_id],
1354
+ )
1355
+
1356
+ async def get_last_synced_at(self, platform: str, platform_entity_id: str) -> datetime | None:
1357
+ val = await self._fetchval(
1358
+ """
1359
+ SELECT max(synced_at) FROM entities
1360
+ WHERE platform = ? AND platform_entity_id = ?
1361
+ """,
1362
+ [platform, platform_entity_id],
1363
+ )
1364
+ return datetime.fromisoformat(val) if val else None
1365
+
1366
+ async def get_platform_last_synced_at(self, platform: str) -> datetime | None:
1367
+ val = await self._fetchval(
1368
+ """
1369
+ SELECT max(synced_at) FROM entities
1370
+ WHERE platform = ?
1371
+ """,
1372
+ [platform],
1373
+ )
1374
+ return datetime.fromisoformat(val) if val else None
1375
+
1376
+ async def get_platforms_last_synced_at(
1377
+ self,
1378
+ platforms: list[str],
1379
+ ) -> dict[str, datetime | None]:
1380
+ if not platforms:
1381
+ return {}
1382
+ placeholders = ",".join("?" for _ in platforms)
1383
+ rows = await self._fetchall(
1384
+ f"""
1385
+ SELECT platform, max(synced_at) AS last_synced_at
1386
+ FROM entities
1387
+ WHERE platform IN ({placeholders})
1388
+ GROUP BY platform
1389
+ """,
1390
+ platforms,
1391
+ )
1392
+ result: dict[str, datetime | None] = dict.fromkeys(platforms, None)
1393
+ for row in rows:
1394
+ val = row["last_synced_at"]
1395
+ result[row["platform"]] = datetime.fromisoformat(val) if val else None
1396
+ return result
1397
+
1398
+ async def reset_synced_at(self, platform: str, platform_entity_id: str) -> None:
1399
+ await self._execute(
1400
+ "UPDATE entities SET synced_at = NULL WHERE platform = ? AND platform_entity_id = ?",
1401
+ [platform, platform_entity_id],
1402
+ )
1403
+
1404
+ async def touch_last_accessed(self, platform: str, platform_entity_id: str) -> None:
1405
+ now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
1406
+ await self._execute(
1407
+ "UPDATE entities SET last_accessed = ? WHERE platform = ? AND platform_entity_id = ?",
1408
+ [now, platform, platform_entity_id],
1409
+ )
1410
+
1411
+ async def touch_last_accessed_by_ids(self, entity_ids: list[str]) -> None:
1412
+ if not entity_ids:
1413
+ return
1414
+ now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
1415
+ placeholders = ",".join("?" * len(entity_ids))
1416
+ await self._execute(
1417
+ f"UPDATE entities SET last_accessed = ? WHERE id IN ({placeholders})",
1418
+ [now, *entity_ids],
1419
+ )
1420
+
1421
+ async def get_entity_type(self, platform: str, platform_entity_id: str) -> str | None:
1422
+ return await self._fetchval(
1423
+ "SELECT entity_type FROM entities WHERE platform = ? AND platform_entity_id = ?",
1424
+ [platform, platform_entity_id],
1425
+ )
1426
+
1427
+ async def get_entity_platform_ref(self, entity_id: str) -> tuple[str, str] | None:
1428
+ row = await self._fetchone(
1429
+ "SELECT platform, platform_entity_id FROM entities WHERE id = ?",
1430
+ [entity_id],
1431
+ )
1432
+ if row is None:
1433
+ return None
1434
+ return (row["platform"], row["platform_entity_id"])
1435
+
1436
+
1437
+ # --- Row serialization helpers ---
1438
+
1439
+
1440
+ def _row_to_entity(row: Any) -> EntityResult:
1441
+ keys = row.keys() if hasattr(row, "keys") else []
1442
+ return {
1443
+ "id": row["id"],
1444
+ "entity_type": row["entity_type"],
1445
+ "platform": row["platform"],
1446
+ "platform_entity_id": row["platform_entity_id"],
1447
+ "title": row["title"],
1448
+ "content": row["content"],
1449
+ "metadata": json.loads(row["metadata"]) if row["metadata"] else {},
1450
+ "created_at": row["created_at"],
1451
+ "updated_at": row["updated_at"],
1452
+ "synced_at": row["synced_at"] if "synced_at" in keys else None,
1453
+ "observed_at": row["observed_at"] if "observed_at" in keys else None,
1454
+ "last_accessed": row["last_accessed"] if "last_accessed" in keys else None,
1455
+ "cumulative_dwell_ms": row["cumulative_dwell_ms"] if "cumulative_dwell_ms" in keys else 0,
1456
+ "bookmarked": bool(row["bookmarked"]) if "bookmarked" in keys else False,
1457
+ "score": row["score"] if "score" in keys else None,
1458
+ }
1459
+
1460
+
1461
+ def _row_to_edge(row: Any) -> EdgeResult:
1462
+ return {
1463
+ "id": row["id"],
1464
+ "edge_type": row["edge_type"],
1465
+ "platform": row["platform"],
1466
+ "properties": json.loads(row["properties"]) if row["properties"] else {},
1467
+ "source_entity_id": row["source_entity_id"],
1468
+ "target_entity_id": row["target_entity_id"],
1469
+ "source_ref": row["source_ref"],
1470
+ "target_ref": row["target_ref"],
1471
+ }