ags-cli 0.1.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 (156) hide show
  1. ags_cli-0.1.0.dist-info/METADATA +332 -0
  2. ags_cli-0.1.0.dist-info/RECORD +156 -0
  3. ags_cli-0.1.0.dist-info/WHEEL +5 -0
  4. ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. ags_cli-0.1.0.dist-info/top_level.txt +2 -0
  6. cli/__init__.py +0 -0
  7. cli/__main__.py +4 -0
  8. cli/client.py +145 -0
  9. cli/commands/__init__.py +0 -0
  10. cli/commands/adapter.py +33 -0
  11. cli/commands/artifact.py +21 -0
  12. cli/commands/ask.py +212 -0
  13. cli/commands/chat.py +649 -0
  14. cli/commands/diff.py +49 -0
  15. cli/commands/doctor.py +93 -0
  16. cli/commands/gui.py +45 -0
  17. cli/commands/init.py +20 -0
  18. cli/commands/mcp.py +50 -0
  19. cli/commands/memory.py +65 -0
  20. cli/commands/model.py +73 -0
  21. cli/commands/policy.py +52 -0
  22. cli/commands/project.py +122 -0
  23. cli/commands/quota.py +107 -0
  24. cli/commands/run.py +84 -0
  25. cli/commands/serve.py +171 -0
  26. cli/commands/service.py +236 -0
  27. cli/commands/session.py +219 -0
  28. cli/commands/stats.py +96 -0
  29. cli/commands/status.py +36 -0
  30. cli/commands/task.py +37 -0
  31. cli/commands/usage.py +86 -0
  32. cli/local.py +84 -0
  33. cli/main.py +149 -0
  34. harness/__init__.py +4 -0
  35. harness/adapters/__init__.py +26 -0
  36. harness/adapters/antigravity.py +301 -0
  37. harness/adapters/claude_code.py +522 -0
  38. harness/adapters/local_openai.py +534 -0
  39. harness/adapters/mock.py +112 -0
  40. harness/adapters/probe.py +65 -0
  41. harness/adapters/registry.py +56 -0
  42. harness/adapters/warm_pool.py +255 -0
  43. harness/api/__init__.py +0 -0
  44. harness/api/router.py +38 -0
  45. harness/api/v1/__init__.py +0 -0
  46. harness/api/v1/adapters.py +105 -0
  47. harness/api/v1/approvals.py +173 -0
  48. harness/api/v1/artifacts.py +44 -0
  49. harness/api/v1/attachments.py +48 -0
  50. harness/api/v1/doctor.py +22 -0
  51. harness/api/v1/health.py +37 -0
  52. harness/api/v1/mcp.py +131 -0
  53. harness/api/v1/memory.py +80 -0
  54. harness/api/v1/policies.py +49 -0
  55. harness/api/v1/project.py +302 -0
  56. harness/api/v1/runs.py +98 -0
  57. harness/api/v1/sessions.py +248 -0
  58. harness/api/v1/stream.py +110 -0
  59. harness/api/v1/tasks.py +30 -0
  60. harness/api/v1/usage.py +58 -0
  61. harness/bootstrap.py +216 -0
  62. harness/db.py +164 -0
  63. harness/deps.py +58 -0
  64. harness/eventbus/__init__.py +20 -0
  65. harness/eventbus/inprocess_bus.py +85 -0
  66. harness/main.py +144 -0
  67. harness/mcp/__init__.py +22 -0
  68. harness/mcp/client.py +139 -0
  69. harness/mcp/config_gen.py +77 -0
  70. harness/mcp/registry.py +156 -0
  71. harness/mcp/tools.py +81 -0
  72. harness/memory/__init__.py +13 -0
  73. harness/memory/context_budget.py +116 -0
  74. harness/memory/embed.py +217 -0
  75. harness/memory/extract.py +74 -0
  76. harness/memory/ingest.py +106 -0
  77. harness/memory/namespaces.py +57 -0
  78. harness/memory/ranking.py +31 -0
  79. harness/memory/redact.py +37 -0
  80. harness/memory/service.py +320 -0
  81. harness/memory/sqlite_vec_backend.py +266 -0
  82. harness/memory/summarize.py +68 -0
  83. harness/models/__init__.py +35 -0
  84. harness/models/adapter_health.py +27 -0
  85. harness/models/approval.py +37 -0
  86. harness/models/artifact.py +35 -0
  87. harness/models/audit_log.py +33 -0
  88. harness/models/comparison.py +33 -0
  89. harness/models/enums.py +146 -0
  90. harness/models/mcp_server_config.py +49 -0
  91. harness/models/memory.py +76 -0
  92. harness/models/policy.py +29 -0
  93. harness/models/provider_config.py +35 -0
  94. harness/models/run.py +46 -0
  95. harness/models/run_event.py +35 -0
  96. harness/models/session.py +50 -0
  97. harness/models/task.py +30 -0
  98. harness/models/types.py +63 -0
  99. harness/models/workspace.py +27 -0
  100. harness/observability/__init__.py +4 -0
  101. harness/observability/audit.py +88 -0
  102. harness/observability/logging.py +47 -0
  103. harness/observability/metrics.py +43 -0
  104. harness/observability/tracing.py +38 -0
  105. harness/orchestrator/__init__.py +19 -0
  106. harness/orchestrator/engine.py +821 -0
  107. harness/orchestrator/handoff.py +33 -0
  108. harness/orchestrator/lifecycle.py +69 -0
  109. harness/orchestrator/native_resume.py +93 -0
  110. harness/orchestrator/policies.py +101 -0
  111. harness/orchestrator/reconcile.py +39 -0
  112. harness/orchestrator/run_graph.py +62 -0
  113. harness/orchestrator/snapshot.py +49 -0
  114. harness/schemas/__init__.py +1 -0
  115. harness/schemas/adapter.py +26 -0
  116. harness/schemas/approval.py +25 -0
  117. harness/schemas/artifact.py +17 -0
  118. harness/schemas/common.py +20 -0
  119. harness/schemas/memory.py +50 -0
  120. harness/schemas/policy.py +39 -0
  121. harness/schemas/run.py +116 -0
  122. harness/schemas/session.py +93 -0
  123. harness/schemas/task.py +24 -0
  124. harness/security/__init__.py +5 -0
  125. harness/security/approval_policy.py +107 -0
  126. harness/security/auth.py +106 -0
  127. harness/security/permissions.py +59 -0
  128. harness/security/risk.py +59 -0
  129. harness/security/secrets.py +49 -0
  130. harness/services/__init__.py +1 -0
  131. harness/services/adapters_health.py +212 -0
  132. harness/services/approvals.py +84 -0
  133. harness/services/artifacts.py +78 -0
  134. harness/services/attachments.py +228 -0
  135. harness/services/comparison.py +345 -0
  136. harness/services/doctor.py +156 -0
  137. harness/services/files.py +287 -0
  138. harness/services/mcp_servers.py +179 -0
  139. harness/services/project_git.py +101 -0
  140. harness/services/retention.py +97 -0
  141. harness/services/runs.py +155 -0
  142. harness/services/runs_diff.py +201 -0
  143. harness/services/sessions.py +242 -0
  144. harness/services/ssh.py +300 -0
  145. harness/services/stats.py +132 -0
  146. harness/services/tasks.py +41 -0
  147. harness/services/workspaces.py +184 -0
  148. harness/services/worktrees.py +439 -0
  149. harness/settings.py +186 -0
  150. harness/skills/__init__.py +11 -0
  151. harness/skills/manager.py +141 -0
  152. harness/tools/__init__.py +1 -0
  153. harness/tools/fs_tools.py +295 -0
  154. harness/workers/__init__.py +0 -0
  155. harness/workers/dispatch.py +26 -0
  156. harness/workers/inprocess.py +135 -0
@@ -0,0 +1,31 @@
1
+ """Recency + relevance fusion for retrieval ranking.
2
+
3
+ Final score = relevance_weight * cosine_similarity + recency_weight * recency,
4
+ where recency decays exponentially with item age. Weights come from
5
+ ``config.yaml`` (``memory.retrieval``)."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from datetime import UTC, datetime
11
+
12
+
13
+ def recency_score(created_at: datetime, *, half_life_hours: float = 168.0) -> float:
14
+ """Exponential decay; 1.0 now, 0.5 at one half-life (default 7 days)."""
15
+ now = datetime.now(UTC)
16
+ if created_at.tzinfo is None:
17
+ created_at = created_at.replace(tzinfo=UTC)
18
+ age_hours = max((now - created_at).total_seconds() / 3600.0, 0.0)
19
+ return math.pow(0.5, age_hours / half_life_hours)
20
+
21
+
22
+ def fuse(
23
+ similarity: float,
24
+ created_at: datetime,
25
+ *,
26
+ relevance_weight: float = 0.7,
27
+ recency_weight: float = 0.3,
28
+ half_life_hours: float = 168.0,
29
+ ) -> float:
30
+ rec = recency_score(created_at, half_life_hours=half_life_hours)
31
+ return relevance_weight * similarity + recency_weight * rec
@@ -0,0 +1,37 @@
1
+ """Secret redaction. Runs BEFORE storage and BEFORE embedding so raw secrets
2
+ never enter canonical memory or the vector index. Pattern-based and conservative:
3
+ prefers over-redaction to leakage."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+
9
+ _PATTERNS: list[tuple[str, re.Pattern[str]]] = [
10
+ ("aws_access_key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
11
+ ("aws_secret", re.compile(r"\b(?i:aws_secret_access_key)\s*[=:]\s*\S+")),
12
+ ("github_token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b")),
13
+ ("openai_key", re.compile(r"\bsk-[A-Za-z0-9]{20,}\b")),
14
+ ("anthropic_key", re.compile(r"\bsk-ant-[A-Za-z0-9_\-]{20,}\b")),
15
+ ("slack_token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b")),
16
+ ("bearer", re.compile(r"(?i:bearer)\s+[A-Za-z0-9._\-]{20,}")),
17
+ ("jwt", re.compile(r"\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\b")),
18
+ ("private_key", re.compile(
19
+ r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]+?-----END[^-]+-----"
20
+ )),
21
+ ("password_kv", re.compile(r"(?i:password|passwd|secret|api[_-]?key)\s*[=:]\s*\S+")),
22
+ ("email", re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")),
23
+ ]
24
+
25
+
26
+ def redact(text: str) -> tuple[str, bool]:
27
+ """Return (redacted_text, was_redacted)."""
28
+ redacted = text
29
+ hit = False
30
+ for label, pat in _PATTERNS:
31
+ redacted, n = pat.subn(f"[REDACTED:{label}]", redacted)
32
+ hit = hit or n > 0
33
+ return redacted, hit
34
+
35
+
36
+ def contains_secret(text: str) -> bool:
37
+ return redact(text)[1]
@@ -0,0 +1,320 @@
1
+ """Memory retrieval service — the unified, provider-agnostic retrieval API.
2
+
3
+ ``retrieve`` runs a namespace-bounded vector search, re-ranks by recency+relevance
4
+ fusion, drops TTL-expired items, and returns a compact normalized context block any
5
+ adapter can prepend to its prompt. This is the single seam through which shared
6
+ memory reaches every provider.
7
+
8
+ Similarity has two backends behind one query shape: sqlite-vec's ANN KNN search when
9
+ the extension is loaded, and a namespace-scoped brute-force cosine in Python otherwise
10
+ — fine for a single developer's memory, and dependency-free.
11
+
12
+ ``reindex_memory`` iterates workspace items whose embeddings are stale (wrong model),
13
+ deletes them, and re-embeds with the active embedder in batches of 64."""
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import math
19
+ import uuid
20
+ from dataclasses import dataclass
21
+ from datetime import UTC, datetime
22
+
23
+ from sqlalchemy import and_, delete, or_, select
24
+ from sqlalchemy.ext.asyncio import AsyncSession
25
+
26
+ from harness.memory.embed import Embedder, get_embedder
27
+ # _chunk is private to ingest but lives in the same package; imported here to
28
+ # avoid duplicating the chunking logic — same function reused for reindex fidelity.
29
+ from harness.memory.ingest import _chunk
30
+ from harness.memory.namespaces import session_ns, workspace_ns
31
+ from harness.memory.ranking import fuse
32
+ from harness.memory.sqlite_vec_backend import SqliteVecIndex
33
+ from harness.models.enums import MemoryScope
34
+ from harness.models.memory import MemoryEmbedding, MemoryItem
35
+ from harness.observability.metrics import MEMORY_HITS, MEMORY_QUERIES
36
+ from harness.settings import get_settings
37
+
38
+ log = logging.getLogger(__name__)
39
+
40
+ # Upper bound on candidate embeddings scanned in the SQLite brute-force path. A single
41
+ # developer's namespace holds far fewer; the cap is a safety valve, logged if hit.
42
+ _LOCAL_SCAN_LIMIT = 5000
43
+
44
+
45
+ def _cosine_similarity(a: list[float], b: list[float]) -> float:
46
+ dot = sum(x * y for x, y in zip(a, b, strict=False))
47
+ na = math.sqrt(sum(x * x for x in a))
48
+ nb = math.sqrt(sum(y * y for y in b))
49
+ if na == 0.0 or nb == 0.0:
50
+ return 0.0
51
+ return dot / (na * nb)
52
+
53
+
54
+ @dataclass
55
+ class RetrievedItem:
56
+ item_id: str
57
+ namespace: str
58
+ type: str
59
+ content: str
60
+ similarity: float
61
+ score: float
62
+ created_at: datetime
63
+
64
+
65
+ @dataclass
66
+ class RetrievalResult:
67
+ query: str
68
+ items: list[RetrievedItem]
69
+
70
+ def as_context_block(self, max_chars: int = 4000) -> str:
71
+ """Compact, normalized context for prompt injection."""
72
+ if not self.items:
73
+ return ""
74
+ lines = ["# Relevant memory (shared, vendor-neutral)"]
75
+ used = len(lines[0])
76
+ for it in self.items:
77
+ entry = f"- ({it.type}) {it.content.strip()}"
78
+ if used + len(entry) > max_chars:
79
+ break
80
+ lines.append(entry)
81
+ used += len(entry)
82
+ return "\n".join(lines)
83
+
84
+
85
+ class MemoryService:
86
+ def __init__(self, session: AsyncSession, embedder: Embedder | None = None) -> None:
87
+ self._session = session
88
+ self._embedder = embedder or get_embedder()
89
+ cfg = get_settings().config().get("memory", {}).get("retrieval", {})
90
+ self._top_k = int(cfg.get("top_k", 8))
91
+ self._relevance_w = float(cfg.get("relevance_weight", 0.7))
92
+ self._recency_w = float(cfg.get("recency_weight", 0.3))
93
+
94
+ async def retrieve(
95
+ self,
96
+ *,
97
+ workspace_slug: str,
98
+ query: str,
99
+ session_id: str | None = None,
100
+ project: str | None = None,
101
+ top_k: int | None = None,
102
+ ) -> RetrievalResult:
103
+ MEMORY_QUERIES.inc()
104
+ top_k = top_k or self._top_k
105
+ [qvec] = await self._embedder.embed([query])
106
+ now = datetime.now(UTC)
107
+
108
+ # Scope-aware, isolation-preserving filter:
109
+ # - workspace-scoped items anywhere under ws/<slug>[/project] (incl. the
110
+ # provider/* sub-namespaces written by memory extraction);
111
+ # - session-scoped items ONLY for the session being queried.
112
+ ws_prefix = workspace_ns(workspace_slug, project=project)
113
+ ns_filter = and_(
114
+ MemoryItem.scope == MemoryScope.workspace,
115
+ or_(MemoryItem.namespace == ws_prefix,
116
+ MemoryItem.namespace.like(f"{ws_prefix}/%")),
117
+ )
118
+ if session_id:
119
+ sess_prefix = session_ns(workspace_slug, session_id, project=project)
120
+ ns_filter = or_(ns_filter, MemoryItem.namespace == sess_prefix)
121
+ ttl_ok = (MemoryItem.ttl_expires_at.is_(None)) | (MemoryItem.ttl_expires_at > now)
122
+
123
+ # Only consider embeddings produced by the active embedder model to avoid
124
+ # silent corruption from mixed-model vectors in the cosine distance space.
125
+ model_filter = MemoryEmbedding.model == self._embedder.model
126
+
127
+ if await SqliteVecIndex.loaded_for_session(self._session):
128
+ # SQLite + sqlite-vec ANN path: KNN via vec0 virtual table, then
129
+ # join back to MemoryItem for namespace/TTL/model filters.
130
+ # We over-fetch (top_k * 4) candidate item_ids from the vec table
131
+ # (which holds ALL embeddings regardless of namespace/TTL/model),
132
+ # then filter via a normal ORM query so namespace isolation,
133
+ # TTL expiry, and model consistency are strictly enforced.
134
+ # This is correct because vec_memory rows are only written when
135
+ # the active embedder is used (see ingest_memory / reindex_memory),
136
+ # so model drift is handled at write time, not query time.
137
+ await SqliteVecIndex.ensure_table(self._session, self._embedder.dim)
138
+ ann_hits = await SqliteVecIndex.search(
139
+ self._session, qvec, k=top_k * 4
140
+ )
141
+ if not ann_hits:
142
+ rows = []
143
+ else:
144
+ ann_item_ids = [item_id for item_id, _ in ann_hits]
145
+ sim_map = {item_id: sim for item_id, sim in ann_hits}
146
+ # Resolve item_ids to MemoryItem rows with full filters applied.
147
+ # item_id in vec_memory is str(item.id) — cast to UUID for the IN clause.
148
+ try:
149
+ ann_uuids = [uuid.UUID(iid) for iid in ann_item_ids]
150
+ except ValueError:
151
+ ann_uuids = []
152
+ if ann_uuids:
153
+ item_stmt = (
154
+ select(MemoryItem)
155
+ .where(
156
+ MemoryItem.id.in_(ann_uuids),
157
+ ns_filter,
158
+ ttl_ok,
159
+ )
160
+ )
161
+ items_by_id = {
162
+ str(item.id): item
163
+ for item in (await self._session.execute(item_stmt)).scalars().all()
164
+ }
165
+ rows = [
166
+ (items_by_id[iid], sim_map[iid])
167
+ for iid in ann_item_ids
168
+ if iid in items_by_id
169
+ ]
170
+ else:
171
+ rows = []
172
+ else:
173
+ # SQLite (local): embeddings are JSON lists; compute cosine in Python over
174
+ # the namespace-scoped candidate set.
175
+ stmt = (
176
+ select(MemoryItem, MemoryEmbedding.embedding)
177
+ .join(MemoryEmbedding, MemoryEmbedding.memory_item_id == MemoryItem.id)
178
+ .where(ns_filter, ttl_ok, model_filter)
179
+ .limit(_LOCAL_SCAN_LIMIT)
180
+ )
181
+ rows = [
182
+ (item, _cosine_similarity(qvec, emb))
183
+ for item, emb in (await self._session.execute(stmt)).all()
184
+ if emb is not None
185
+ ]
186
+
187
+ best: dict[str, RetrievedItem] = {}
188
+ for item, similarity in rows:
189
+ score = fuse(
190
+ similarity,
191
+ item.created_at,
192
+ relevance_weight=self._relevance_w,
193
+ recency_weight=self._recency_w,
194
+ )
195
+ key = str(item.id)
196
+ if key not in best or score > best[key].score:
197
+ best[key] = RetrievedItem(
198
+ item_id=key,
199
+ namespace=item.namespace,
200
+ type=item.type.value,
201
+ content=item.content,
202
+ similarity=similarity,
203
+ score=score,
204
+ created_at=item.created_at,
205
+ )
206
+
207
+ ranked = sorted(best.values(), key=lambda r: r.score, reverse=True)[:top_k]
208
+ if ranked:
209
+ MEMORY_HITS.inc()
210
+ return RetrievalResult(query=query, items=ranked)
211
+
212
+
213
+ _REINDEX_BATCH = 64
214
+
215
+
216
+ async def reindex_memory(
217
+ db: AsyncSession,
218
+ *,
219
+ workspace_slug: str | None = None,
220
+ embedder: Embedder | None = None,
221
+ ) -> int:
222
+ """Re-embed all items whose embeddings don't match the active model.
223
+
224
+ Iterates MemoryItem rows (optionally namespace-filtered by ``ws/<slug>``
225
+ prefix), deletes stale-model embeddings, re-embeds content in batches of
226
+ 64 with the active embedder, inserts new MemoryEmbedding rows. Items that
227
+ already have an active-model embedding are skipped entirely.
228
+
229
+ Returns the count of items actually re-embedded.
230
+ """
231
+ embedder = embedder or get_embedder()
232
+ active_model = embedder.model
233
+
234
+ # Build namespace filter when a workspace is specified.
235
+ if workspace_slug is not None:
236
+ ns_prefix = workspace_ns(workspace_slug)
237
+ ns_filter = or_(
238
+ MemoryItem.namespace == ns_prefix,
239
+ MemoryItem.namespace.like(f"{ns_prefix}/%"),
240
+ )
241
+ else:
242
+ ns_filter = None # type: ignore[assignment]
243
+
244
+ # Load all candidate items (no TTL filter — reindex is a maintenance op).
245
+ item_stmt = select(MemoryItem)
246
+ if ns_filter is not None:
247
+ item_stmt = item_stmt.where(ns_filter)
248
+ items: list[MemoryItem] = list(
249
+ (await db.execute(item_stmt)).scalars().all()
250
+ )
251
+
252
+ # Determine which items already have an active-model embedding so we can skip them.
253
+ if not items:
254
+ return 0
255
+
256
+ item_ids = [i.id for i in items]
257
+ active_emb_stmt = select(MemoryEmbedding.memory_item_id).where(
258
+ MemoryEmbedding.memory_item_id.in_(item_ids),
259
+ MemoryEmbedding.model == active_model,
260
+ )
261
+ already_current: set[uuid.UUID] = set(
262
+ (await db.execute(active_emb_stmt)).scalars().all()
263
+ )
264
+
265
+ stale_items = [i for i in items if i.id not in already_current]
266
+ if not stale_items:
267
+ return 0
268
+
269
+ # Delete all embeddings for stale items (regardless of model — a clean slate
270
+ # avoids having multiple embedding rows for the same chunk if the batch
271
+ # were ever to be run twice).
272
+ stale_ids = [i.id for i in stale_items]
273
+ await db.execute(
274
+ delete(MemoryEmbedding).where(
275
+ MemoryEmbedding.memory_item_id.in_(stale_ids)
276
+ )
277
+ )
278
+
279
+ # Also purge stale items from the vec0 ANN table so it stays consistent.
280
+ # Drop and recreate the table to handle dim/model transitions: a stale-dim
281
+ # table would error forever on first upsert after an embedder switch.
282
+ vec_loaded = await SqliteVecIndex.loaded_for_session(db)
283
+ if vec_loaded:
284
+ await SqliteVecIndex.drop_table(db)
285
+ await SqliteVecIndex.ensure_table(db, embedder.dim)
286
+
287
+ # Re-embed in batches of up to _REINDEX_BATCH chunks (batched across items).
288
+ # Mirror ingest's _chunk() so long items retain the same chunk granularity
289
+ # they had at ingest time — avoids recall regression and embedder truncation.
290
+ reindexed = 0
291
+ # Build a flat list of (item, chunk_idx, chunk_text) triples to batch efficiently.
292
+ all_chunks: list[tuple[MemoryItem, int, str]] = []
293
+ for item in stale_items:
294
+ for idx, chunk_text in enumerate(_chunk(item.content)):
295
+ all_chunks.append((item, idx, chunk_text))
296
+
297
+ for batch_start in range(0, len(all_chunks), _REINDEX_BATCH):
298
+ batch = all_chunks[batch_start : batch_start + _REINDEX_BATCH]
299
+ texts = [chunk_text for _, _, chunk_text in batch]
300
+ vectors = await embedder.embed(texts)
301
+ for (item, chunk_idx, chunk_text), vec in zip(batch, vectors, strict=True):
302
+ db.add(
303
+ MemoryEmbedding(
304
+ memory_item_id=item.id,
305
+ chunk_idx=chunk_idx,
306
+ model=active_model,
307
+ embedding=vec,
308
+ meta={"chars": len(chunk_text), "reindexed": True},
309
+ )
310
+ )
311
+ if vec_loaded:
312
+ # Mirror re-embedded vector back into the ANN table.
313
+ await SqliteVecIndex.upsert(db, str(item.id), chunk_idx, vec)
314
+ log.debug("reindex_memory: re-embedded %d chunks (batch)", len(batch))
315
+
316
+ reindexed = len(stale_items)
317
+
318
+ await db.flush()
319
+ log.info("reindex_memory: re-embedded %d items total (model=%s)", reindexed, active_model)
320
+ return reindexed
@@ -0,0 +1,266 @@
1
+ """Optional sqlite-vec ANN index backend for memory search.
2
+
3
+ This module provides ``SqliteVecIndex``, an optional accelerator for the SQLite
4
+ retrieval path. When the ``sqlite-vec`` package is installed, the vec0 virtual
5
+ table gives sub-millisecond ANN search even for thousands of memory embeddings
6
+ — a large improvement over the O(N) brute-force cosine in Python that the base
7
+ path uses.
8
+
9
+ DDL that was empirically verified to work with sqlite-vec 0.1.9::
10
+
11
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_memory
12
+ USING vec0(
13
+ embedding float[{dim}] distance_metric=cosine,
14
+ +item_id text,
15
+ +chunk_idx integer
16
+ )
17
+
18
+ The ``+`` prefix marks ``item_id`` and ``chunk_idx`` as *auxiliary* (non-indexed)
19
+ payload columns. Without the prefix vec0 rejects non-embedding columns.
20
+
21
+ KNN query syntax (verified)::
22
+
23
+ SELECT item_id, chunk_idx, distance
24
+ FROM vec_memory
25
+ WHERE embedding MATCH ?
26
+ AND k = ?
27
+ ORDER BY distance
28
+
29
+ Vectors must be passed as ``struct.pack("{dim}f", *vec)`` bytes blobs (float32).
30
+ Distance is cosine distance (0 = identical, 2 = opposite); similarity = 1 - distance.
31
+
32
+ Model-filter design: the vec_memory virtual table intentionally does NOT store
33
+ the embedding model name — vec0 auxiliary columns add overhead and we want the
34
+ table lean. Correctness is maintained by only writing to vec_memory when the
35
+ embedding model matches the active embedder (callers pass vectors already produced
36
+ by the active model), and by the reindex operation truncating + rebuilding all
37
+ rows whenever a model change is detected (see ``reindex_memory`` in service.py).
38
+ The ANN search therefore has an implicit invariant: all rows in vec_memory were
39
+ produced by the currently-active embedder. Divergence can only occur during a
40
+ model transition, which is handled by ``reindex_memory`` clearing and rebuilding
41
+ the vec table as part of the same operation.
42
+
43
+ Public API (async, for production use):
44
+ SqliteVecIndex.available() -> bool
45
+ SqliteVecIndex.ensure_table(db: AsyncSession, dim: int) -> None
46
+ SqliteVecIndex.upsert(db: AsyncSession, item_id: str, chunk_idx: int, vec: list[float]) -> None
47
+ SqliteVecIndex.search(db: AsyncSession, vec: list[float], k: int) -> list[tuple[str, float]]
48
+
49
+ Sync API (for tests and sync contexts):
50
+ SqliteVecIndex.ensure_table_sync(conn, dim)
51
+ SqliteVecIndex.upsert_sync(conn, item_id, chunk_idx, vec)
52
+ SqliteVecIndex.search_sync(conn, vec, k) -> list[tuple[str, float]]
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import struct
58
+ from typing import TYPE_CHECKING
59
+
60
+ from sqlalchemy import text
61
+
62
+ if TYPE_CHECKING:
63
+ from sqlalchemy.ext.asyncio import AsyncSession
64
+
65
+ try:
66
+ import sqlite_vec as _sqlite_vec # type: ignore[import]
67
+ except ImportError:
68
+ _sqlite_vec = None # type: ignore[assignment]
69
+
70
+ # Engine-level cache: maps engine id() → bool so we only probe once per engine.
71
+ _engine_vec_loaded: dict[int, bool] = {}
72
+
73
+ # Module-level memoized result for available() — opening a :memory: connection on
74
+ # every call is unnecessary overhead; the result cannot change within a process.
75
+ _available_cached: bool | None = None
76
+
77
+
78
+ def _pack(vec: list[float]) -> bytes:
79
+ """Pack a Python float list into the float32 blob sqlite-vec expects."""
80
+ return struct.pack(f"{len(vec)}f", *vec)
81
+
82
+
83
+ _CREATE_DDL = """
84
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_memory
85
+ USING vec0(
86
+ embedding float[{dim}] distance_metric=cosine,
87
+ +item_id text,
88
+ +chunk_idx integer
89
+ )
90
+ """
91
+
92
+ _SEARCH_SQL = """
93
+ SELECT item_id, distance
94
+ FROM vec_memory
95
+ WHERE embedding MATCH ?
96
+ AND k = ?
97
+ ORDER BY distance
98
+ """
99
+
100
+
101
+ class SqliteVecIndex:
102
+ """Thin façade over the vec0 virtual table.
103
+
104
+ All public methods are async (for production use with AsyncSession) and
105
+ have ``_sync`` equivalents for use in tests with a raw sqlite3.Connection.
106
+ """
107
+
108
+ @staticmethod
109
+ def available() -> bool:
110
+ """Return True when sqlite-vec is importable AND loadable.
111
+
112
+ The result is memoized at module level — opening a :memory: connection on
113
+ every call is wasteful; the answer cannot change within a process lifetime.
114
+ """
115
+ global _available_cached
116
+ if _available_cached is not None:
117
+ return _available_cached
118
+ if _sqlite_vec is None:
119
+ _available_cached = False
120
+ return False
121
+ try:
122
+ import sqlite3
123
+ conn = sqlite3.connect(":memory:")
124
+ conn.enable_load_extension(True)
125
+ _sqlite_vec.load(conn)
126
+ conn.enable_load_extension(False)
127
+ conn.close()
128
+ _available_cached = True
129
+ return True
130
+ except Exception: # noqa: BLE001
131
+ _available_cached = False
132
+ return False
133
+
134
+ @staticmethod
135
+ async def loaded_for_session(db: AsyncSession) -> bool: # type: ignore[name-defined]
136
+ """Return True if the sqlite-vec extension is loaded on this session's connection.
137
+
138
+ Probes once per engine (cached in ``_engine_vec_loaded``). Uses a
139
+ savepoint so a failed probe does not invalidate the enclosing transaction.
140
+ Cache is consulted before calling available() to avoid the overhead of the
141
+ package-level import check on hot paths.
142
+ """
143
+ # Fast path: per-engine cache is checked first (avoids available() overhead).
144
+ engine_key = id(db.get_bind())
145
+ cached = _engine_vec_loaded.get(engine_key)
146
+ if cached is not None:
147
+ return cached
148
+ # Package-level gate: if sqlite-vec is not importable/loadable, bail out.
149
+ if not SqliteVecIndex.available():
150
+ _engine_vec_loaded[engine_key] = False
151
+ return False
152
+ try:
153
+ async with db.begin_nested():
154
+ await db.execute(text("SELECT vec_version()"))
155
+ _engine_vec_loaded[engine_key] = True
156
+ return True
157
+ except Exception: # noqa: BLE001
158
+ _engine_vec_loaded[engine_key] = False
159
+ return False
160
+
161
+ # ------------------------------------------------------------------
162
+ # Sync helpers — used in tests and by the async wrappers below
163
+ # ------------------------------------------------------------------
164
+
165
+ @staticmethod
166
+ def drop_table_sync(conn) -> None: # noqa: ANN001
167
+ """Drop the vec_memory virtual table (used before a dim/model transition)."""
168
+ conn.execute("DROP TABLE IF EXISTS vec_memory")
169
+ conn.commit()
170
+
171
+ @staticmethod
172
+ def ensure_table_sync(conn, dim: int) -> None: # noqa: ANN001
173
+ """Create the vec_memory virtual table if it doesn't exist."""
174
+ conn.execute(_CREATE_DDL.format(dim=dim))
175
+ conn.commit()
176
+
177
+ @staticmethod
178
+ def upsert_sync(conn, item_id: str, chunk_idx: int, vec: list[float]) -> None: # noqa: ANN001
179
+ """Insert or replace a vector row.
180
+
181
+ vec0 doesn't support ON CONFLICT natively, so we emulate upsert by
182
+ deleting any existing row for (item_id, chunk_idx) then inserting.
183
+ """
184
+ conn.execute(
185
+ "DELETE FROM vec_memory WHERE item_id = ? AND chunk_idx = ?",
186
+ (item_id, chunk_idx),
187
+ )
188
+ # Use MAX(rowid)+1 to assign a fresh rowid
189
+ row = conn.execute("SELECT COALESCE(MAX(rowid), 0) FROM vec_memory").fetchone()
190
+ new_rowid = (row[0] or 0) + 1
191
+ conn.execute(
192
+ "INSERT INTO vec_memory (rowid, embedding, item_id, chunk_idx) VALUES (?, ?, ?, ?)",
193
+ (new_rowid, _pack(vec), item_id, chunk_idx),
194
+ )
195
+ conn.commit()
196
+
197
+ @staticmethod
198
+ def search_sync(conn, vec: list[float], k: int) -> list[tuple[str, float]]: # noqa: ANN001
199
+ """Return up to ``k`` (item_id, similarity) pairs ordered by similarity desc."""
200
+ rows = conn.execute(_SEARCH_SQL, (_pack(vec), k)).fetchall()
201
+ return [(item_id, 1.0 - distance) for item_id, distance in rows]
202
+
203
+ # ------------------------------------------------------------------
204
+ # Async API — uses db.execute(text(...)) for compatibility with
205
+ # aiosqlite + SQLAlchemy AsyncSession (the session's sync_engine
206
+ # connect event loads the sqlite-vec extension on every connection).
207
+ # ------------------------------------------------------------------
208
+
209
+ @staticmethod
210
+ async def drop_table(db: AsyncSession) -> None:
211
+ """Drop the vec_memory virtual table (used before a dim/model transition)."""
212
+ await db.execute(text("DROP TABLE IF EXISTS vec_memory"))
213
+
214
+ @staticmethod
215
+ async def ensure_table(db: AsyncSession, dim: int) -> None:
216
+ """Create the vec_memory virtual table if it doesn't exist (async)."""
217
+ await db.execute(text(_CREATE_DDL.format(dim=dim)))
218
+
219
+ @staticmethod
220
+ async def upsert(
221
+ db: AsyncSession, item_id: str, chunk_idx: int, vec: list[float]
222
+ ) -> None:
223
+ """Insert or replace a vector row (async)."""
224
+ blob = _pack(vec)
225
+ await db.execute(
226
+ text("DELETE FROM vec_memory WHERE item_id = :iid AND chunk_idx = :ci"),
227
+ {"iid": item_id, "ci": chunk_idx},
228
+ )
229
+ row = (
230
+ await db.execute(text("SELECT COALESCE(MAX(rowid), 0) FROM vec_memory"))
231
+ ).fetchone()
232
+ new_rowid = (row[0] if row else 0) + 1
233
+ await db.execute(
234
+ text(
235
+ "INSERT INTO vec_memory (rowid, embedding, item_id, chunk_idx)"
236
+ " VALUES (:r, :e, :iid, :ci)"
237
+ ),
238
+ {"r": new_rowid, "e": blob, "iid": item_id, "ci": chunk_idx},
239
+ )
240
+
241
+ @staticmethod
242
+ async def search(
243
+ db: AsyncSession, vec: list[float], k: int
244
+ ) -> list[tuple[str, float]]:
245
+ """Return up to ``k`` (item_id, similarity) pairs, ordered by similarity desc (async)."""
246
+ blob = _pack(vec)
247
+ rows = (
248
+ await db.execute(
249
+ text(
250
+ "SELECT item_id, distance"
251
+ " FROM vec_memory"
252
+ " WHERE embedding MATCH :q AND k = :k"
253
+ " ORDER BY distance"
254
+ ),
255
+ {"q": blob, "k": k},
256
+ )
257
+ ).fetchall()
258
+ return [(item_id, 1.0 - dist) for item_id, dist in rows]
259
+
260
+ @staticmethod
261
+ async def delete_item(db: AsyncSession, item_id: str) -> None:
262
+ """Delete all vec rows for a given item_id (called from reindex)."""
263
+ await db.execute(
264
+ text("DELETE FROM vec_memory WHERE item_id = :iid"),
265
+ {"iid": item_id},
266
+ )