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.
- hermes_memory_pgvector-0.3.0.dist-info/METADATA +260 -0
- hermes_memory_pgvector-0.3.0.dist-info/RECORD +11 -0
- hermes_memory_pgvector-0.3.0.dist-info/WHEEL +5 -0
- hermes_memory_pgvector-0.3.0.dist-info/licenses/LICENSE +28 -0
- hermes_memory_pgvector-0.3.0.dist-info/top_level.txt +1 -0
- pgvector/__init__.py +806 -0
- pgvector/embed.py +103 -0
- pgvector/migrations/001_schema.sql +95 -0
- pgvector/plugin.yaml +9 -0
- pgvector/store.py +507 -0
- pgvector/writer.py +170 -0
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()
|