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.
- ags_cli-0.1.0.dist-info/METADATA +332 -0
- ags_cli-0.1.0.dist-info/RECORD +156 -0
- ags_cli-0.1.0.dist-info/WHEEL +5 -0
- ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ags_cli-0.1.0.dist-info/top_level.txt +2 -0
- cli/__init__.py +0 -0
- cli/__main__.py +4 -0
- cli/client.py +145 -0
- cli/commands/__init__.py +0 -0
- cli/commands/adapter.py +33 -0
- cli/commands/artifact.py +21 -0
- cli/commands/ask.py +212 -0
- cli/commands/chat.py +649 -0
- cli/commands/diff.py +49 -0
- cli/commands/doctor.py +93 -0
- cli/commands/gui.py +45 -0
- cli/commands/init.py +20 -0
- cli/commands/mcp.py +50 -0
- cli/commands/memory.py +65 -0
- cli/commands/model.py +73 -0
- cli/commands/policy.py +52 -0
- cli/commands/project.py +122 -0
- cli/commands/quota.py +107 -0
- cli/commands/run.py +84 -0
- cli/commands/serve.py +171 -0
- cli/commands/service.py +236 -0
- cli/commands/session.py +219 -0
- cli/commands/stats.py +96 -0
- cli/commands/status.py +36 -0
- cli/commands/task.py +37 -0
- cli/commands/usage.py +86 -0
- cli/local.py +84 -0
- cli/main.py +149 -0
- harness/__init__.py +4 -0
- harness/adapters/__init__.py +26 -0
- harness/adapters/antigravity.py +301 -0
- harness/adapters/claude_code.py +522 -0
- harness/adapters/local_openai.py +534 -0
- harness/adapters/mock.py +112 -0
- harness/adapters/probe.py +65 -0
- harness/adapters/registry.py +56 -0
- harness/adapters/warm_pool.py +255 -0
- harness/api/__init__.py +0 -0
- harness/api/router.py +38 -0
- harness/api/v1/__init__.py +0 -0
- harness/api/v1/adapters.py +105 -0
- harness/api/v1/approvals.py +173 -0
- harness/api/v1/artifacts.py +44 -0
- harness/api/v1/attachments.py +48 -0
- harness/api/v1/doctor.py +22 -0
- harness/api/v1/health.py +37 -0
- harness/api/v1/mcp.py +131 -0
- harness/api/v1/memory.py +80 -0
- harness/api/v1/policies.py +49 -0
- harness/api/v1/project.py +302 -0
- harness/api/v1/runs.py +98 -0
- harness/api/v1/sessions.py +248 -0
- harness/api/v1/stream.py +110 -0
- harness/api/v1/tasks.py +30 -0
- harness/api/v1/usage.py +58 -0
- harness/bootstrap.py +216 -0
- harness/db.py +164 -0
- harness/deps.py +58 -0
- harness/eventbus/__init__.py +20 -0
- harness/eventbus/inprocess_bus.py +85 -0
- harness/main.py +144 -0
- harness/mcp/__init__.py +22 -0
- harness/mcp/client.py +139 -0
- harness/mcp/config_gen.py +77 -0
- harness/mcp/registry.py +156 -0
- harness/mcp/tools.py +81 -0
- harness/memory/__init__.py +13 -0
- harness/memory/context_budget.py +116 -0
- harness/memory/embed.py +217 -0
- harness/memory/extract.py +74 -0
- harness/memory/ingest.py +106 -0
- harness/memory/namespaces.py +57 -0
- harness/memory/ranking.py +31 -0
- harness/memory/redact.py +37 -0
- harness/memory/service.py +320 -0
- harness/memory/sqlite_vec_backend.py +266 -0
- harness/memory/summarize.py +68 -0
- harness/models/__init__.py +35 -0
- harness/models/adapter_health.py +27 -0
- harness/models/approval.py +37 -0
- harness/models/artifact.py +35 -0
- harness/models/audit_log.py +33 -0
- harness/models/comparison.py +33 -0
- harness/models/enums.py +146 -0
- harness/models/mcp_server_config.py +49 -0
- harness/models/memory.py +76 -0
- harness/models/policy.py +29 -0
- harness/models/provider_config.py +35 -0
- harness/models/run.py +46 -0
- harness/models/run_event.py +35 -0
- harness/models/session.py +50 -0
- harness/models/task.py +30 -0
- harness/models/types.py +63 -0
- harness/models/workspace.py +27 -0
- harness/observability/__init__.py +4 -0
- harness/observability/audit.py +88 -0
- harness/observability/logging.py +47 -0
- harness/observability/metrics.py +43 -0
- harness/observability/tracing.py +38 -0
- harness/orchestrator/__init__.py +19 -0
- harness/orchestrator/engine.py +821 -0
- harness/orchestrator/handoff.py +33 -0
- harness/orchestrator/lifecycle.py +69 -0
- harness/orchestrator/native_resume.py +93 -0
- harness/orchestrator/policies.py +101 -0
- harness/orchestrator/reconcile.py +39 -0
- harness/orchestrator/run_graph.py +62 -0
- harness/orchestrator/snapshot.py +49 -0
- harness/schemas/__init__.py +1 -0
- harness/schemas/adapter.py +26 -0
- harness/schemas/approval.py +25 -0
- harness/schemas/artifact.py +17 -0
- harness/schemas/common.py +20 -0
- harness/schemas/memory.py +50 -0
- harness/schemas/policy.py +39 -0
- harness/schemas/run.py +116 -0
- harness/schemas/session.py +93 -0
- harness/schemas/task.py +24 -0
- harness/security/__init__.py +5 -0
- harness/security/approval_policy.py +107 -0
- harness/security/auth.py +106 -0
- harness/security/permissions.py +59 -0
- harness/security/risk.py +59 -0
- harness/security/secrets.py +49 -0
- harness/services/__init__.py +1 -0
- harness/services/adapters_health.py +212 -0
- harness/services/approvals.py +84 -0
- harness/services/artifacts.py +78 -0
- harness/services/attachments.py +228 -0
- harness/services/comparison.py +345 -0
- harness/services/doctor.py +156 -0
- harness/services/files.py +287 -0
- harness/services/mcp_servers.py +179 -0
- harness/services/project_git.py +101 -0
- harness/services/retention.py +97 -0
- harness/services/runs.py +155 -0
- harness/services/runs_diff.py +201 -0
- harness/services/sessions.py +242 -0
- harness/services/ssh.py +300 -0
- harness/services/stats.py +132 -0
- harness/services/tasks.py +41 -0
- harness/services/workspaces.py +184 -0
- harness/services/worktrees.py +439 -0
- harness/settings.py +186 -0
- harness/skills/__init__.py +11 -0
- harness/skills/manager.py +141 -0
- harness/tools/__init__.py +1 -0
- harness/tools/fs_tools.py +295 -0
- harness/workers/__init__.py +0 -0
- harness/workers/dispatch.py +26 -0
- 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
|
harness/memory/redact.py
ADDED
|
@@ -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
|
+
)
|