hermes-memory-pgvector 0.3.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.
pgvector/store.py ADDED
@@ -0,0 +1,507 @@
1
+ """store.py — Postgres ops for the pgvector memory plugin.
2
+
3
+ Wraps psycopg3 + psycopg_pool. Mirrors hermes-agent's native built-in
4
+ memory model (`memory` tool's add/replace/remove on targets 'memory' /
5
+ 'user') into a single Postgres table with embeddings.
6
+
7
+ Uses a small ConnectionPool because the plugin is touched from two
8
+ threads at runtime: the agent thread (for prefetch / recall_memory /
9
+ ensure_schema / health) and the async-writer drain thread (for the
10
+ mirrored INSERTs / UPDATEs / DELETEs). Pooling beats short-lived
11
+ connections under that two-thread pattern without adding much
12
+ complexity.
13
+
14
+ No SQLAlchemy, no LLM-mediated workers, no deriver loops.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ import threading
22
+ from pathlib import Path
23
+ from typing import Any, Dict, List, Optional
24
+
25
+ import psycopg
26
+ from psycopg.rows import dict_row
27
+ from psycopg_pool import ConnectionPool
28
+
29
+ from .embed import to_pgvector_literal
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ class MemoryStore:
35
+ """Postgres-backed mirror of hermes-agent's built-in memory entries."""
36
+
37
+ def __init__(
38
+ self,
39
+ dsn: str,
40
+ *,
41
+ min_size: int = 1,
42
+ max_size: int = 4,
43
+ timeout: float = 5.0,
44
+ ):
45
+ """Open a lazily-initialized ConnectionPool.
46
+
47
+ min_size=1 keeps a single warm connection — enough for the agent
48
+ thread under low traffic. max_size=4 lets the writer thread + a
49
+ burst of recall calls overlap. Pool is opened on first use (or
50
+ explicit `open()`) and closed via `close()`.
51
+ """
52
+ self._dsn = dsn
53
+ self._lock = threading.Lock()
54
+ self._pool: Optional[ConnectionPool] = None
55
+ self._min_size = min_size
56
+ self._max_size = max_size
57
+ self._timeout = timeout
58
+
59
+ # -- Pool lifecycle ------------------------------------------------------
60
+
61
+ def _get_pool(self) -> ConnectionPool:
62
+ """Return the live pool, constructing it on first call. Thread-safe."""
63
+ if self._pool is not None:
64
+ return self._pool
65
+ with self._lock:
66
+ if self._pool is None:
67
+ self._pool = ConnectionPool(
68
+ conninfo=self._dsn,
69
+ min_size=self._min_size,
70
+ max_size=self._max_size,
71
+ timeout=self._timeout,
72
+ open=True,
73
+ name="pgvector-memory",
74
+ )
75
+ return self._pool
76
+
77
+ def close(self) -> None:
78
+ """Close the connection pool. Idempotent."""
79
+ with self._lock:
80
+ if self._pool is not None:
81
+ try:
82
+ self._pool.close()
83
+ except Exception as exc: # noqa: BLE001
84
+ logger.debug("pgvector pool close: %s", exc)
85
+ finally:
86
+ self._pool = None
87
+
88
+ # -- Schema --------------------------------------------------------------
89
+
90
+ class SchemaNotApplied(RuntimeError):
91
+ """Raised when memory_entries does not exist in the target DB."""
92
+
93
+ def ensure_schema(self) -> None:
94
+ """Verify the schema is in place. Does NOT run DDL.
95
+
96
+ The migration (migrations/001_schema.sql) is admin-only — it
97
+ runs `CREATE EXTENSION vector` which requires superuser, and
98
+ creates the table + indexes which then end up owned by the
99
+ admin role. The plugin's runtime user (hermes) only has
100
+ SELECT/INSERT/UPDATE/DELETE on the existing schema, and that's
101
+ the right separation: DDL at install time, DML at run time.
102
+
103
+ Operators apply the migration once via:
104
+ sudo -u postgres psql -d hermes_memory -f migrations/001_schema.sql
105
+ """
106
+ with self._get_pool().connection() as conn:
107
+ with conn.cursor() as cur:
108
+ cur.execute("SELECT to_regclass('memory_entries')")
109
+ if cur.fetchone()[0] is None:
110
+ raise self.SchemaNotApplied(
111
+ "memory_entries table missing. Apply the migration as DB admin: "
112
+ "psql -d <dbname> -f plugins/memory/pgvector/migrations/001_schema.sql"
113
+ )
114
+
115
+ def apply_migration_as_admin(self, *, admin_dsn: str) -> None:
116
+ """One-shot admin path: run the full migration with privileged creds.
117
+
118
+ Bypasses the runtime pool — opens a fresh autocommit connection
119
+ with admin_dsn (typically `user=postgres host=/var/run/postgresql`)
120
+ so CREATE EXTENSION + CREATE TABLE + CREATE INDEX all succeed.
121
+ Idempotent: re-running on an already-migrated DB is a no-op.
122
+ """
123
+ sql_path = Path(__file__).parent / "migrations" / "001_schema.sql"
124
+ sql = sql_path.read_text(encoding="utf-8")
125
+ with psycopg.connect(admin_dsn, autocommit=True) as conn:
126
+ with conn.cursor() as cur:
127
+ cur.execute(sql)
128
+
129
+ # -- Built-in memory mirror (called by on_memory_write) ------------------
130
+
131
+ def add(
132
+ self,
133
+ *,
134
+ agent_identity: str,
135
+ target: str,
136
+ content: str,
137
+ embedding: Optional[List[float]] = None,
138
+ metadata: Optional[Dict[str, Any]] = None,
139
+ ) -> Optional[int]:
140
+ """Insert a memory entry. Returns row id, or None if duplicate (no-op).
141
+
142
+ Matches the built-in tool's "reject exact duplicate" semantics via
143
+ the (agent_identity, target, content) unique constraint + ON CONFLICT.
144
+ """
145
+ meta_json = json.dumps(metadata or {})
146
+ vec_literal = to_pgvector_literal(embedding) if embedding is not None else None
147
+
148
+ with self._get_pool().connection() as conn:
149
+ with conn.cursor() as cur:
150
+ cur.execute(
151
+ """
152
+ INSERT INTO memory_entries
153
+ (agent_identity, target, content, embedding, metadata)
154
+ VALUES (%s, %s, %s, %s::vector, %s::jsonb)
155
+ ON CONFLICT (agent_identity, target, content) DO NOTHING
156
+ RETURNING id
157
+ """,
158
+ (agent_identity, target, content, vec_literal, meta_json),
159
+ )
160
+ row = cur.fetchone()
161
+ conn.commit()
162
+ return int(row[0]) if row else None
163
+
164
+ def replace(
165
+ self,
166
+ *,
167
+ agent_identity: str,
168
+ target: str,
169
+ old_text: str,
170
+ new_content: str,
171
+ new_embedding: Optional[List[float]] = None,
172
+ ) -> int:
173
+ """Update entries in (agent_identity, target) where content contains old_text.
174
+
175
+ Matches built-in semantics — old_text is a substring match. Returns
176
+ the number of rows updated (built-in updates the FIRST match; we
177
+ update all matches in the same scope for safety).
178
+ """
179
+ vec_literal = (
180
+ to_pgvector_literal(new_embedding) if new_embedding is not None else None
181
+ )
182
+ with self._get_pool().connection() as conn:
183
+ with conn.cursor() as cur:
184
+ cur.execute(
185
+ """
186
+ UPDATE memory_entries
187
+ SET content = %s,
188
+ embedding = %s::vector,
189
+ updated_at = now()
190
+ WHERE agent_identity = %s
191
+ AND target = %s
192
+ AND content LIKE %s
193
+ """,
194
+ (new_content, vec_literal, agent_identity, target, f"%{old_text}%"),
195
+ )
196
+ updated = cur.rowcount
197
+ conn.commit()
198
+ return int(updated)
199
+
200
+ def remove(
201
+ self,
202
+ *,
203
+ agent_identity: str,
204
+ target: str,
205
+ old_text: str,
206
+ ) -> int:
207
+ """Delete entries in (agent_identity, target) matching old_text substring.
208
+
209
+ Returns the number of rows deleted.
210
+ """
211
+ with self._get_pool().connection() as conn:
212
+ with conn.cursor() as cur:
213
+ cur.execute(
214
+ """
215
+ DELETE FROM memory_entries
216
+ WHERE agent_identity = %s
217
+ AND target = %s
218
+ AND content LIKE %s
219
+ """,
220
+ (agent_identity, target, f"%{old_text}%"),
221
+ )
222
+ deleted = cur.rowcount
223
+ conn.commit()
224
+ return int(deleted)
225
+
226
+ # -- Reads ---------------------------------------------------------------
227
+
228
+ def list_entries(
229
+ self,
230
+ *,
231
+ agent_identity: str,
232
+ target: Optional[str] = None,
233
+ limit: int = 100,
234
+ ) -> List[Dict[str, Any]]:
235
+ """List entries in an agent's scope. If target is None, both stores."""
236
+ params: List[Any] = [agent_identity]
237
+ target_clause = ""
238
+ if target:
239
+ target_clause = "AND target = %s"
240
+ params.append(target)
241
+ params.append(limit)
242
+
243
+ with self._get_pool().connection() as conn:
244
+ with conn.cursor(row_factory=dict_row) as cur:
245
+ cur.execute(
246
+ f"""
247
+ SELECT id, agent_identity, target, content, created_at, updated_at, metadata
248
+ FROM memory_entries
249
+ WHERE agent_identity = %s
250
+ {target_clause}
251
+ ORDER BY updated_at DESC
252
+ LIMIT %s
253
+ """,
254
+ params,
255
+ )
256
+ return list(cur.fetchall())
257
+
258
+ def search(
259
+ self,
260
+ *,
261
+ query_embedding: List[float],
262
+ agent_identity: Optional[str] = None,
263
+ target: Optional[str] = None,
264
+ limit: int = 5,
265
+ min_similarity: float = 0.0,
266
+ ) -> List[Dict[str, Any]]:
267
+ """Semantic recall via cosine distance.
268
+
269
+ agent_identity=None → search across ALL agents (cross-theme recall).
270
+ target=None → search both 'memory' and 'user'.
271
+ Returns rows with `score` = 1 - cosine_distance ∈ [0, 1].
272
+ """
273
+ vec_literal = to_pgvector_literal(query_embedding)
274
+ clauses: List[str] = []
275
+ params: List[Any] = []
276
+ if agent_identity:
277
+ clauses.append("agent_identity = %s")
278
+ params.append(agent_identity)
279
+ if target:
280
+ clauses.append("target = %s")
281
+ params.append(target)
282
+ where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
283
+
284
+ with self._get_pool().connection() as conn:
285
+ with conn.cursor(row_factory=dict_row) as cur:
286
+ cur.execute(
287
+ f"""
288
+ SELECT id, agent_identity, target, content, created_at,
289
+ updated_at, metadata,
290
+ 1 - (embedding <=> %s::vector) AS score
291
+ FROM memory_entries
292
+ {where}
293
+ ORDER BY embedding <=> %s::vector
294
+ LIMIT %s
295
+ """,
296
+ [vec_literal, *params, vec_literal, limit],
297
+ )
298
+ rows = list(cur.fetchall())
299
+
300
+ if min_similarity > 0:
301
+ rows = [r for r in rows if (r.get("score") or 0) >= min_similarity]
302
+ return rows
303
+
304
+ # -- Bulk import from MEMORY.md / USER.md (v0.1.1) ----------------------
305
+
306
+ # Matches tools/memory_tool.py:ENTRY_DELIMITER. Keep in sync if upstream
307
+ # ever changes it (currently stable; been "\n§\n" since the tool shipped).
308
+ ENTRY_DELIMITER = "\n§\n"
309
+
310
+ def bulk_upsert_md(
311
+ self,
312
+ *,
313
+ agent_identity: str,
314
+ target: str,
315
+ file_path: "Path | str",
316
+ embed_fn,
317
+ ) -> Dict[str, int]:
318
+ """Parse a MEMORY.md / USER.md file and upsert each entry.
319
+
320
+ Idempotent + cheap on re-run: we SELECT the existing content set
321
+ for (agent_identity, target) once, then only embed + INSERT new
322
+ entries. So initial install embeds everything; subsequent inits
323
+ with no MD changes do zero embed calls.
324
+
325
+ embed_fn is a callable taking a string and returning a 768-dim
326
+ list (or raising — we catch and store text-only). Wired by the
327
+ caller so the plugin can pass its `embed()` with the configured
328
+ base_url + model.
329
+
330
+ Returns: {'parsed': N, 'inserted': M, 'skipped': K} where N=M+K.
331
+ """
332
+ from pathlib import Path as _Path
333
+ p = _Path(file_path)
334
+ if not p.exists():
335
+ return {"parsed": 0, "inserted": 0, "skipped": 0}
336
+
337
+ raw = p.read_text(encoding="utf-8", errors="replace")
338
+ entries = [e.strip() for e in raw.split(self.ENTRY_DELIMITER) if e.strip()]
339
+ if not entries:
340
+ return {"parsed": 0, "inserted": 0, "skipped": 0}
341
+
342
+ # Single bulk SELECT of existing content for this scope. Beats N+1
343
+ # by a wide margin and keeps re-init nearly free.
344
+ with self._get_pool().connection() as conn:
345
+ with conn.cursor() as cur:
346
+ cur.execute(
347
+ "SELECT content FROM memory_entries WHERE agent_identity = %s AND target = %s",
348
+ (agent_identity, target),
349
+ )
350
+ existing = {row[0] for row in cur.fetchall()}
351
+
352
+ inserted = 0
353
+ skipped = 0
354
+ for entry in entries:
355
+ if entry in existing:
356
+ skipped += 1
357
+ continue
358
+ vec = None
359
+ try:
360
+ vec = embed_fn(entry) if embed_fn else None
361
+ except Exception: # noqa: BLE001 — fail-soft on bulk embed
362
+ vec = None
363
+ row_id = self.add(
364
+ agent_identity=agent_identity,
365
+ target=target,
366
+ content=entry,
367
+ embedding=vec,
368
+ metadata={"source": "bulk_import", "file": str(p)},
369
+ )
370
+ if row_id is not None:
371
+ inserted += 1
372
+ else:
373
+ # Lost a race with another writer that inserted the same row.
374
+ skipped += 1
375
+ return {"parsed": len(entries), "inserted": inserted, "skipped": skipped}
376
+
377
+ # -- Conversation turns (v0.2) ------------------------------------------
378
+
379
+ def append_turn(
380
+ self,
381
+ *,
382
+ session_id: str,
383
+ agent_identity: str,
384
+ role: str,
385
+ content: str,
386
+ embedding: Optional[List[float]] = None,
387
+ metadata: Optional[Dict[str, Any]] = None,
388
+ ) -> int:
389
+ """Insert one chat turn. Returns row id.
390
+
391
+ No dedup (turns are inherently time-ordered events — same content
392
+ twice is two distinct turns, even verbatim).
393
+ """
394
+ meta_json = json.dumps(metadata or {})
395
+ vec_literal = to_pgvector_literal(embedding) if embedding is not None else None
396
+
397
+ with self._get_pool().connection() as conn:
398
+ with conn.cursor() as cur:
399
+ cur.execute(
400
+ """
401
+ INSERT INTO conversations
402
+ (session_id, agent_identity, role, content, embedding, metadata)
403
+ VALUES (%s, %s, %s, %s, %s::vector, %s::jsonb)
404
+ RETURNING id
405
+ """,
406
+ (session_id, agent_identity, role, content, vec_literal, meta_json),
407
+ )
408
+ row = cur.fetchone()
409
+ conn.commit()
410
+ return int(row[0])
411
+
412
+ def search_turns(
413
+ self,
414
+ *,
415
+ query_embedding: List[float],
416
+ agent_identity: Optional[str] = None,
417
+ session_id: Optional[str] = None,
418
+ limit: int = 5,
419
+ min_similarity: float = 0.0,
420
+ ) -> List[Dict[str, Any]]:
421
+ """Semantic recall over conversation turns. Same shape as `search()`."""
422
+ vec_literal = to_pgvector_literal(query_embedding)
423
+ clauses: List[str] = []
424
+ params: List[Any] = []
425
+ if agent_identity:
426
+ clauses.append("agent_identity = %s")
427
+ params.append(agent_identity)
428
+ if session_id:
429
+ clauses.append("session_id = %s")
430
+ params.append(session_id)
431
+ where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
432
+
433
+ with self._get_pool().connection() as conn:
434
+ with conn.cursor(row_factory=dict_row) as cur:
435
+ cur.execute(
436
+ f"""
437
+ SELECT id, session_id, agent_identity, role, content, ts, metadata,
438
+ 1 - (embedding <=> %s::vector) AS score
439
+ FROM conversations
440
+ {where}
441
+ ORDER BY embedding <=> %s::vector
442
+ LIMIT %s
443
+ """,
444
+ [vec_literal, *params, vec_literal, limit],
445
+ )
446
+ rows = list(cur.fetchall())
447
+
448
+ if min_similarity > 0:
449
+ rows = [r for r in rows if (r.get("score") or 0) >= min_similarity]
450
+ return rows
451
+
452
+ # -- Maintenance ---------------------------------------------------------
453
+
454
+ def count_turns(
455
+ self,
456
+ *,
457
+ agent_identity: Optional[str] = None,
458
+ session_id: Optional[str] = None,
459
+ ) -> int:
460
+ clauses: List[str] = []
461
+ params: List[Any] = []
462
+ if agent_identity:
463
+ clauses.append("agent_identity = %s")
464
+ params.append(agent_identity)
465
+ if session_id:
466
+ clauses.append("session_id = %s")
467
+ params.append(session_id)
468
+ where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
469
+ with self._get_pool().connection() as conn:
470
+ with conn.cursor() as cur:
471
+ cur.execute(f"SELECT COUNT(*) FROM conversations {where}", params)
472
+ return int(cur.fetchone()[0])
473
+
474
+ def count(
475
+ self,
476
+ *,
477
+ agent_identity: Optional[str] = None,
478
+ target: Optional[str] = None,
479
+ ) -> int:
480
+ clauses: List[str] = []
481
+ params: List[Any] = []
482
+ if agent_identity:
483
+ clauses.append("agent_identity = %s")
484
+ params.append(agent_identity)
485
+ if target:
486
+ clauses.append("target = %s")
487
+ params.append(target)
488
+ where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
489
+
490
+ with self._get_pool().connection() as conn:
491
+ with conn.cursor() as cur:
492
+ cur.execute(f"SELECT COUNT(*) FROM memory_entries {where}", params)
493
+ return int(cur.fetchone()[0])
494
+
495
+ def health(self) -> Dict[str, Any]:
496
+ """Liveness probe — pool reachable + table exists. Never raises."""
497
+ try:
498
+ with self._get_pool().connection(timeout=3.0) as conn:
499
+ with conn.cursor() as cur:
500
+ cur.execute("SELECT to_regclass('memory_entries') IS NOT NULL")
501
+ has_table = bool(cur.fetchone()[0])
502
+ if not has_table:
503
+ return {"ok": False, "error": "memory_entries table missing", "row_count": 0}
504
+ cur.execute("SELECT COUNT(*) FROM memory_entries")
505
+ return {"ok": True, "error": "", "row_count": int(cur.fetchone()[0])}
506
+ except Exception as exc: # noqa: BLE001
507
+ return {"ok": False, "error": str(exc)[:200], "row_count": 0}
pgvector/writer.py ADDED
@@ -0,0 +1,170 @@
1
+ """writer.py — async write queue for the pgvector memory plugin.
2
+
3
+ Decouples `on_memory_write` from the (potentially slow) embed + INSERT
4
+ path. Built-in memory tool fires the hook → we enqueue → background
5
+ thread drains, embeds, writes. The hook returns in microseconds, never
6
+ blocks the agent loop on a slow embed endpoint.
7
+
8
+ Failure modes we deliberately swallow:
9
+ • queue full → drop write, log once, set a tripwire flag
10
+ • embed endpoint slow/down → write goes through with embedding=NULL,
11
+ row is still recoverable, recall just
12
+ misses it until a future backfill
13
+ • DB temporarily down → drop write, log once
14
+ • background thread crash → log + auto-restart on next enqueue
15
+
16
+ The whole point is to avoid the Honcho-style retry-storm meltdown.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import queue
23
+ import threading
24
+ from typing import Any, Callable, Dict, Optional
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ # Pending-write payload — kept small so the queue stays bounded.
30
+ class _PendingWrite:
31
+ __slots__ = ("action", "agent_identity", "target", "content", "extra", "metadata")
32
+
33
+ def __init__(
34
+ self,
35
+ *,
36
+ action: str,
37
+ agent_identity: str,
38
+ target: str,
39
+ content: str,
40
+ extra: Optional[Dict[str, Any]] = None,
41
+ metadata: Optional[Dict[str, Any]] = None,
42
+ ):
43
+ self.action = action
44
+ self.agent_identity = agent_identity
45
+ self.target = target
46
+ self.content = content
47
+ self.extra = extra or {}
48
+ self.metadata = metadata or {}
49
+
50
+
51
+ class AsyncWriter:
52
+ """Bounded write queue with a background drain thread.
53
+
54
+ Caller responsibilities:
55
+ - Construct with `worker_fn`, a callable that takes a _PendingWrite
56
+ and performs the actual embed + DB write. worker_fn MUST NOT raise
57
+ — it should swallow + log its own failures.
58
+ - Call `enqueue(...)` from the agent thread; returns instantly.
59
+ - Call `shutdown(timeout=5)` on plugin teardown to drain gracefully.
60
+ """
61
+
62
+ def __init__(self, worker_fn: Callable[[_PendingWrite], None], *, maxsize: int = 256):
63
+ self._worker_fn = worker_fn
64
+ self._queue: "queue.Queue[Optional[_PendingWrite]]" = queue.Queue(maxsize=maxsize)
65
+ self._thread: Optional[threading.Thread] = None
66
+ self._stop = threading.Event()
67
+ self._dropped = 0
68
+ self._dropped_warned = False
69
+ self._lock = threading.Lock()
70
+
71
+ # -- Public API ----------------------------------------------------------
72
+
73
+ def enqueue(
74
+ self,
75
+ *,
76
+ action: str,
77
+ agent_identity: str,
78
+ target: str,
79
+ content: str,
80
+ extra: Optional[Dict[str, Any]] = None,
81
+ metadata: Optional[Dict[str, Any]] = None,
82
+ ) -> bool:
83
+ """Enqueue a write. Returns True on accept, False on drop (queue full).
84
+
85
+ Lazily starts the drain thread on first enqueue. Restarts the
86
+ thread if it died (e.g., from an unforeseen exception in worker_fn).
87
+ """
88
+ self._ensure_thread()
89
+ item = _PendingWrite(
90
+ action=action,
91
+ agent_identity=agent_identity,
92
+ target=target,
93
+ content=content,
94
+ extra=extra,
95
+ metadata=metadata,
96
+ )
97
+ try:
98
+ self._queue.put_nowait(item)
99
+ return True
100
+ except queue.Full:
101
+ with self._lock:
102
+ self._dropped += 1
103
+ if not self._dropped_warned:
104
+ logger.warning(
105
+ "pgvector writer queue full (maxsize=%d); dropping writes",
106
+ self._queue.maxsize,
107
+ )
108
+ self._dropped_warned = True
109
+ return False
110
+
111
+ def shutdown(self, timeout: float = 5.0) -> None:
112
+ """Signal stop + wait for the queue to drain (up to `timeout` seconds)."""
113
+ if not self._thread or not self._thread.is_alive():
114
+ return
115
+ # Sentinel wakes the thread even if the queue is empty.
116
+ try:
117
+ self._queue.put_nowait(None)
118
+ except queue.Full:
119
+ self._stop.set()
120
+ self._thread.join(timeout=timeout)
121
+ if self._thread.is_alive():
122
+ logger.warning("pgvector writer thread did not drain within %.1fs", timeout)
123
+
124
+ def stats(self) -> Dict[str, Any]:
125
+ return {
126
+ "queue_size": self._queue.qsize(),
127
+ "queue_max": self._queue.maxsize,
128
+ "dropped_total": self._dropped,
129
+ "thread_alive": bool(self._thread and self._thread.is_alive()),
130
+ }
131
+
132
+ # -- Internals -----------------------------------------------------------
133
+
134
+ def _ensure_thread(self) -> None:
135
+ with self._lock:
136
+ if self._thread and self._thread.is_alive():
137
+ return
138
+ self._stop.clear()
139
+ self._thread = threading.Thread(
140
+ target=self._run,
141
+ name="pgvector-writer",
142
+ daemon=True,
143
+ )
144
+ self._thread.start()
145
+
146
+ def _run(self) -> None:
147
+ while not self._stop.is_set():
148
+ try:
149
+ item = self._queue.get(timeout=0.5)
150
+ except queue.Empty:
151
+ continue
152
+ if item is None: # shutdown sentinel
153
+ self._queue.task_done()
154
+ break
155
+ try:
156
+ self._worker_fn(item)
157
+ except Exception as exc: # noqa: BLE001
158
+ # worker_fn is supposed to swallow its own errors. If
159
+ # something slipped through, log and keep going — never
160
+ # let the drain thread die over one bad write.
161
+ logger.warning(
162
+ "pgvector writer worker raised %s on (%s/%s/%s): %s",
163
+ type(exc).__name__,
164
+ item.action,
165
+ item.agent_identity,
166
+ item.target,
167
+ str(exc)[:200],
168
+ )
169
+ finally:
170
+ self._queue.task_done()