ctxora 6.2.0
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.
- package/LICENSE +21 -0
- package/README.md +441 -0
- package/README.vi.md +441 -0
- package/bin/ctxora.mjs +147 -0
- package/package.json +45 -0
- package/pyproject.toml +59 -0
- package/src/chunking/compressor.py +104 -0
- package/src/chunking/treesitter_chunker.py +240 -0
- package/src/compact/anthropic.py +98 -0
- package/src/compact/gemini.py +88 -0
- package/src/compact/handoff.py +179 -0
- package/src/compact/openai.py +318 -0
- package/src/compact/summarizer.py +186 -0
- package/src/context/assembler.py +298 -0
- package/src/context/budgeting.py +137 -0
- package/src/context/sanitizer.py +23 -0
- package/src/evaluation/__init__.py +1 -0
- package/src/evaluation/gates.py +172 -0
- package/src/evaluation/metrics.py +41 -0
- package/src/harness_context/__init__.py +5 -0
- package/src/harness_context/adapters/__init__.py +1 -0
- package/src/harness_context/adapters/clients/__init__.py +4 -0
- package/src/harness_context/adapters/clients/formatters.py +47 -0
- package/src/harness_context/adapters/clients/profiles.py +29 -0
- package/src/harness_context/adapters/ecc/__init__.py +4 -0
- package/src/harness_context/adapters/ecc/detection.py +41 -0
- package/src/harness_context/adapters/ecc/mapping.py +32 -0
- package/src/harness_context/adapters/ecc/memory_reader.py +162 -0
- package/src/harness_context/adapters/ecc/provenance.py +16 -0
- package/src/harness_context/api/__init__.py +1 -0
- package/src/harness_context/api/v2/__init__.py +12 -0
- package/src/harness_context/api/v2/contracts.py +119 -0
- package/src/harness_context/api/v2/diagnostics.py +13 -0
- package/src/harness_context/api/v2/enums.py +17 -0
- package/src/harness_context/api/v2/errors.py +32 -0
- package/src/harness_context/api/v2/models.py +4 -0
- package/src/harness_context/api/v2/requests.py +17 -0
- package/src/harness_context/api/v2/responses.py +22 -0
- package/src/harness_context/application/__init__.py +3 -0
- package/src/harness_context/application/container.py +31 -0
- package/src/harness_context/application/context_service.py +51 -0
- package/src/harness_context/application/ecc_service.py +7 -0
- package/src/harness_context/application/handoff_service.py +11 -0
- package/src/harness_context/application/memory_service.py +9 -0
- package/src/harness_context/application/protocols.py +46 -0
- package/src/harness_context/application/refresh_service.py +25 -0
- package/src/harness_context/application/retrieval_service.py +22 -0
- package/src/harness_context/application/services.py +4 -0
- package/src/harness_context/application/workspace_service.py +18 -0
- package/src/harness_context/bootstrap.py +47 -0
- package/src/harness_context/branding.py +16 -0
- package/src/harness_context/cli/__init__.py +1 -0
- package/src/harness_context/cli/app.py +239 -0
- package/src/harness_context/cli/exit_codes.py +25 -0
- package/src/harness_context/domain/__init__.py +9 -0
- package/src/harness_context/domain/cag.py +18 -0
- package/src/harness_context/domain/chunking.py +17 -0
- package/src/harness_context/domain/planning.py +30 -0
- package/src/harness_context/domain/ports.py +24 -0
- package/src/harness_context/domain/retrieval.py +46 -0
- package/src/harness_context/engine.py +10 -0
- package/src/harness_context/free_tools.py +143 -0
- package/src/harness_context/infrastructure/__init__.py +10 -0
- package/src/harness_context/infrastructure/graph.py +26 -0
- package/src/harness_context/infrastructure/indexes.py +33 -0
- package/src/harness_context/infrastructure/local_engine.py +296 -0
- package/src/harness_context/infrastructure/parsing.py +38 -0
- package/src/harness_context/infrastructure/scanning.py +51 -0
- package/src/harness_context/installer/__init__.py +4 -0
- package/src/harness_context/installer/models.py +22 -0
- package/src/harness_context/installer/service.py +168 -0
- package/src/harness_context/mcp/__init__.py +3 -0
- package/src/harness_context/mcp/capabilities.py +11 -0
- package/src/harness_context/mcp/errors.py +8 -0
- package/src/harness_context/mcp/lifecycle.py +72 -0
- package/src/harness_context/mcp/middleware.py +57 -0
- package/src/harness_context/mcp/server.py +3 -0
- package/src/harness_context/mcp/tool_handlers/__init__.py +7 -0
- package/src/harness_context/mcp/tool_handlers/context.py +16 -0
- package/src/harness_context/mcp/tool_handlers/ecc.py +8 -0
- package/src/harness_context/mcp/tool_handlers/handoffs.py +20 -0
- package/src/harness_context/mcp/tool_handlers/memory.py +16 -0
- package/src/harness_context/mcp/tool_handlers/workspace.py +12 -0
- package/src/harness_context/mcp/tools.py +15 -0
- package/src/harness_context/observability/__init__.py +6 -0
- package/src/harness_context/observability/events.py +25 -0
- package/src/harness_context/observability/metrics.py +20 -0
- package/src/harness_context/paths.py +35 -0
- package/src/harness_context/runtime.py +127 -0
- package/src/harness_context/schemas.py +38 -0
- package/src/harness_context/security/__init__.py +3 -0
- package/src/harness_context/security/secret_patterns.py +15 -0
- package/src/harness_context/server.py +1077 -0
- package/src/harness_context/storage/__init__.py +6 -0
- package/src/harness_context/storage/migrations.py +24 -0
- package/src/harness_context/storage/pins.py +10 -0
- package/src/harness_context/storage/snapshots.py +149 -0
- package/src/harness_context/tokenize.py +12 -0
- package/src/harness_context/topology.py +65 -0
- package/src/harness_context/watcher/__init__.py +3 -0
- package/src/harness_context/watcher/service.py +32 -0
- package/src/harness_context/workspace/__init__.py +13 -0
- package/src/harness_context/workspace/identity.py +9 -0
- package/src/harness_context/workspace/lock.py +24 -0
- package/src/harness_context/workspace/policy.py +3 -0
- package/src/harness_context/workspace/roots.py +84 -0
- package/src/harness_context/workspace/state.py +35 -0
- package/src/memory/episodic.py +257 -0
- package/src/memory/vector_store.py +104 -0
- package/src/retrieval/bm25.py +23 -0
- package/src/retrieval/cache.py +76 -0
- package/src/retrieval/embeddings.py +75 -0
- package/src/retrieval/graph.py +45 -0
- package/src/retrieval/reranker.py +78 -0
- package/src/retrieval/tokenize.py +11 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Persistent, locally ranked memory tiers for the MCP harness."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sqlite3
|
|
9
|
+
import time
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from enum import Enum
|
|
12
|
+
|
|
13
|
+
from retrieval.embeddings import EmbeddingEngine
|
|
14
|
+
|
|
15
|
+
_TOKEN_RE = re.compile(r"\w+", re.UNICODE)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MemoryType(Enum):
|
|
19
|
+
EPISODIC = "episodic"
|
|
20
|
+
SEMANTIC = "semantic"
|
|
21
|
+
PROCEDURAL = "procedural"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MemoryStore:
|
|
25
|
+
_instance = None
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def instance(cls):
|
|
29
|
+
if cls._instance is None:
|
|
30
|
+
cls._instance = cls()
|
|
31
|
+
return cls._instance
|
|
32
|
+
|
|
33
|
+
def __init__(self, db_path: str | None = None):
|
|
34
|
+
self.db_path = (
|
|
35
|
+
db_path
|
|
36
|
+
or os.environ.get("CTXORA_MEMORY_DB")
|
|
37
|
+
or os.environ.get("MCP_HARNESS_MEMORY_DB")
|
|
38
|
+
or os.path.expanduser("~/.ctxora/memory.sqlite3")
|
|
39
|
+
)
|
|
40
|
+
if self.db_path != ":memory:":
|
|
41
|
+
directory = os.path.dirname(os.path.abspath(self.db_path))
|
|
42
|
+
os.makedirs(directory, exist_ok=True)
|
|
43
|
+
self._conn = sqlite3.connect(self.db_path, timeout=5)
|
|
44
|
+
self._conn.row_factory = sqlite3.Row
|
|
45
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
46
|
+
self._conn.execute("PRAGMA busy_timeout=5000")
|
|
47
|
+
self._conn.execute("""
|
|
48
|
+
CREATE TABLE IF NOT EXISTS memories_v2 (
|
|
49
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
50
|
+
workspace_id TEXT NOT NULL DEFAULT 'legacy_global',
|
|
51
|
+
type TEXT NOT NULL,
|
|
52
|
+
key TEXT NOT NULL,
|
|
53
|
+
value TEXT NOT NULL,
|
|
54
|
+
tags TEXT NOT NULL DEFAULT '',
|
|
55
|
+
scope TEXT NOT NULL DEFAULT 'workspace',
|
|
56
|
+
source TEXT NOT NULL DEFAULT 'user',
|
|
57
|
+
confidence REAL NOT NULL DEFAULT 1.0,
|
|
58
|
+
created_by TEXT NOT NULL DEFAULT 'user',
|
|
59
|
+
expires_at REAL,
|
|
60
|
+
supersedes TEXT NOT NULL DEFAULT '',
|
|
61
|
+
content_hash TEXT NOT NULL DEFAULT '',
|
|
62
|
+
created_at REAL NOT NULL,
|
|
63
|
+
updated_at REAL NOT NULL,
|
|
64
|
+
last_access REAL NOT NULL,
|
|
65
|
+
hits INTEGER NOT NULL DEFAULT 0,
|
|
66
|
+
UNIQUE(workspace_id, type, key)
|
|
67
|
+
)
|
|
68
|
+
""")
|
|
69
|
+
if self._table_exists("memories"):
|
|
70
|
+
self._conn.execute("""
|
|
71
|
+
INSERT OR IGNORE INTO memories_v2 (
|
|
72
|
+
workspace_id, type, key, value, tags, created_at,
|
|
73
|
+
updated_at, last_access, hits
|
|
74
|
+
)
|
|
75
|
+
SELECT 'legacy_global', type, key, value, tags, created_at,
|
|
76
|
+
updated_at, last_access, hits
|
|
77
|
+
FROM memories
|
|
78
|
+
""")
|
|
79
|
+
self._conn.commit()
|
|
80
|
+
|
|
81
|
+
def _table_exists(self, name: str) -> bool:
|
|
82
|
+
return self._conn.execute(
|
|
83
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", (name,)
|
|
84
|
+
).fetchone() is not None
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def _document(row: sqlite3.Row) -> str:
|
|
88
|
+
return f"{row['key']} {row['tags']} {row['value']}"
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _lexical_score(query: str, document: str) -> float:
|
|
92
|
+
query_counts = Counter(_TOKEN_RE.findall(query.lower()))
|
|
93
|
+
document_counts = Counter(_TOKEN_RE.findall(document.lower()))
|
|
94
|
+
if not query_counts or not document_counts:
|
|
95
|
+
return 0.0
|
|
96
|
+
numerator = sum(
|
|
97
|
+
query_counts[token] * document_counts[token]
|
|
98
|
+
for token in query_counts.keys() & document_counts.keys()
|
|
99
|
+
)
|
|
100
|
+
query_norm = math.sqrt(sum(count * count for count in query_counts.values()))
|
|
101
|
+
document_norm = math.sqrt(
|
|
102
|
+
sum(count * count for count in document_counts.values())
|
|
103
|
+
)
|
|
104
|
+
return numerator / (query_norm * document_norm)
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _semantic_scores(query: str, documents: list[str]) -> list[float]:
|
|
108
|
+
if not documents:
|
|
109
|
+
return []
|
|
110
|
+
engine = EmbeddingEngine()
|
|
111
|
+
vectors = engine.rebuild(documents)
|
|
112
|
+
query_vector = engine.embed_text(query)
|
|
113
|
+
return [
|
|
114
|
+
max(0.0, sum(value * query_value for value, query_value in zip(vector, query_vector)))
|
|
115
|
+
for vector in vectors
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _entry(row: sqlite3.Row) -> dict:
|
|
120
|
+
return {
|
|
121
|
+
"workspace_id": row["workspace_id"],
|
|
122
|
+
"type": row["type"],
|
|
123
|
+
"key": row["key"],
|
|
124
|
+
"value": row["value"],
|
|
125
|
+
"tags": row["tags"],
|
|
126
|
+
"time": row["created_at"],
|
|
127
|
+
"scope": row["scope"],
|
|
128
|
+
"source": row["source"],
|
|
129
|
+
"confidence": row["confidence"],
|
|
130
|
+
"expires_at": row["expires_at"],
|
|
131
|
+
"content_hash": row["content_hash"],
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
def search(
|
|
135
|
+
self,
|
|
136
|
+
query: str,
|
|
137
|
+
mtype: MemoryType | None = None,
|
|
138
|
+
top_k: int = 5,
|
|
139
|
+
min_sim: float = 0.12,
|
|
140
|
+
workspace_id: str = "legacy_global",
|
|
141
|
+
) -> list[dict]:
|
|
142
|
+
if top_k <= 0:
|
|
143
|
+
return []
|
|
144
|
+
sql = "SELECT * FROM memories_v2 WHERE (workspace_id = ? OR scope = 'global') AND (expires_at IS NULL OR expires_at > ?)"
|
|
145
|
+
params: tuple = (workspace_id, time.time())
|
|
146
|
+
if mtype is not None:
|
|
147
|
+
sql += " AND type = ?"
|
|
148
|
+
params += (mtype.value,)
|
|
149
|
+
rows = self._conn.execute(sql, params).fetchall()
|
|
150
|
+
documents = [self._document(row) for row in rows]
|
|
151
|
+
semantic_scores = self._semantic_scores(query, documents)
|
|
152
|
+
ranked = []
|
|
153
|
+
for row, document, semantic_score in zip(rows, documents, semantic_scores):
|
|
154
|
+
score = 0.8 * self._lexical_score(query, document) + 0.2 * semantic_score
|
|
155
|
+
if score >= min_sim:
|
|
156
|
+
ranked.append((score, row))
|
|
157
|
+
ranked.sort(key=lambda item: item[0], reverse=True)
|
|
158
|
+
selected = ranked[:top_k]
|
|
159
|
+
if selected:
|
|
160
|
+
now = time.time()
|
|
161
|
+
self._conn.executemany(
|
|
162
|
+
"UPDATE memories_v2 SET hits = hits + 1, last_access = ? WHERE id = ?",
|
|
163
|
+
[(now, row["id"]) for _, row in selected],
|
|
164
|
+
)
|
|
165
|
+
self._conn.commit()
|
|
166
|
+
return [self._entry(row) for _, row in selected]
|
|
167
|
+
|
|
168
|
+
def save(self, mtype: MemoryType, key: str, value: str, tags: str, workspace_id: str = "legacy_global", scope: str = "workspace", source: str = "user", confidence: float = 1.0, created_by: str = "user", expires_at: float | None = None, supersedes: str = "") -> dict:
|
|
169
|
+
now = time.time()
|
|
170
|
+
import hashlib
|
|
171
|
+
content_hash = hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
172
|
+
self._conn.execute("""
|
|
173
|
+
INSERT INTO memories_v2 (workspace_id, type, key, value, tags, scope, source, confidence, created_by, expires_at, supersedes, content_hash, created_at, updated_at, last_access)
|
|
174
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
175
|
+
ON CONFLICT(workspace_id, type, key) DO UPDATE SET
|
|
176
|
+
value = excluded.value,
|
|
177
|
+
tags = excluded.tags,
|
|
178
|
+
scope = excluded.scope,
|
|
179
|
+
source = excluded.source,
|
|
180
|
+
confidence = excluded.confidence,
|
|
181
|
+
expires_at = excluded.expires_at,
|
|
182
|
+
supersedes = excluded.supersedes,
|
|
183
|
+
content_hash = excluded.content_hash,
|
|
184
|
+
updated_at = excluded.updated_at,
|
|
185
|
+
last_access = excluded.last_access
|
|
186
|
+
""", (workspace_id, mtype.value, key, value, tags, scope, source, confidence, created_by, expires_at, supersedes, content_hash, now, now, now))
|
|
187
|
+
self._conn.commit()
|
|
188
|
+
row = self._conn.execute(
|
|
189
|
+
"SELECT * FROM memories_v2 WHERE workspace_id = ? AND type = ? AND key = ?",
|
|
190
|
+
(workspace_id, mtype.value, key),
|
|
191
|
+
).fetchone()
|
|
192
|
+
return self._entry(row)
|
|
193
|
+
|
|
194
|
+
def delete(self, mtype: MemoryType, key: str, workspace_id: str = "legacy_global") -> bool:
|
|
195
|
+
cursor = self._conn.execute(
|
|
196
|
+
"DELETE FROM memories_v2 WHERE workspace_id = ? AND type = ? AND key = ?", (workspace_id, mtype.value, key)
|
|
197
|
+
)
|
|
198
|
+
self._conn.commit()
|
|
199
|
+
return cursor.rowcount > 0
|
|
200
|
+
|
|
201
|
+
def list_keys(self, mtype: MemoryType | None = None, limit: int = 30, workspace_id: str = "legacy_global") -> list[str]:
|
|
202
|
+
sql = "SELECT key FROM memories_v2 WHERE workspace_id = ?"
|
|
203
|
+
params: tuple = (workspace_id,)
|
|
204
|
+
if mtype is not None:
|
|
205
|
+
sql += " AND type = ?"
|
|
206
|
+
params += (mtype.value,)
|
|
207
|
+
sql += " ORDER BY last_access DESC LIMIT ?"
|
|
208
|
+
return [row["key"] for row in self._conn.execute(sql, (*params, limit))]
|
|
209
|
+
|
|
210
|
+
def evict_lru(self, keep_top: int) -> int:
|
|
211
|
+
keep_top = max(keep_top, 0)
|
|
212
|
+
before = self._conn.execute("SELECT COUNT(*) FROM memories_v2").fetchone()[0]
|
|
213
|
+
self._conn.execute("""
|
|
214
|
+
DELETE FROM memories_v2
|
|
215
|
+
WHERE id NOT IN (
|
|
216
|
+
SELECT id FROM memories_v2 ORDER BY last_access DESC, id DESC LIMIT ?
|
|
217
|
+
)
|
|
218
|
+
""", (keep_top,))
|
|
219
|
+
self._conn.commit()
|
|
220
|
+
return before - self._conn.execute("SELECT COUNT(*) FROM memories_v2").fetchone()[0]
|
|
221
|
+
|
|
222
|
+
def stats(self) -> dict:
|
|
223
|
+
summary = {"total": 0}
|
|
224
|
+
rows = self._conn.execute("""
|
|
225
|
+
SELECT type, COUNT(*) AS count, COALESCE(SUM(hits), 0) AS hits,
|
|
226
|
+
COALESCE(SUM((LENGTH(key) + LENGTH(value) + LENGTH(tags)) / 4), 0) AS tokens
|
|
227
|
+
FROM memories_v2 GROUP BY type
|
|
228
|
+
""").fetchall()
|
|
229
|
+
for row in rows:
|
|
230
|
+
summary[row["type"]] = {
|
|
231
|
+
"count": row["count"],
|
|
232
|
+
"tokens": row["tokens"],
|
|
233
|
+
"hits": row["hits"],
|
|
234
|
+
}
|
|
235
|
+
summary["total"] += row["count"]
|
|
236
|
+
return summary
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class EpisodicMemory:
|
|
240
|
+
def __init__(self, store): self.store = store
|
|
241
|
+
|
|
242
|
+
def save(self, key, value, tags):
|
|
243
|
+
return self.store.save(MemoryType.EPISODIC, key, value, tags)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class SemanticMemory:
|
|
247
|
+
def __init__(self, store): self.store = store
|
|
248
|
+
|
|
249
|
+
def save(self, key, value, tags):
|
|
250
|
+
return self.store.save(MemoryType.SEMANTIC, key, value, tags)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class ProceduralMemory:
|
|
254
|
+
def __init__(self, store): self.store = store
|
|
255
|
+
|
|
256
|
+
def save(self, key, value, tags):
|
|
257
|
+
return self.store.save(MemoryType.PROCEDURAL, key, value, tags)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
vector_store.py — In-memory numpy vector store
|
|
3
|
+
===============================================
|
|
4
|
+
No LanceDB, no PyArrow, no fixed dimension constraint.
|
|
5
|
+
Stores chunk embeddings in RAM and performs brute-force cosine search.
|
|
6
|
+
Fast enough for typical workspaces (<10k chunks).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class VectorStore:
|
|
15
|
+
"""
|
|
16
|
+
In-memory cosine-similarity vector store.
|
|
17
|
+
Replaces lancedb to remove the fixed 384-dim Hugging Face constraint.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._ids: list[str] = []
|
|
22
|
+
self._paths: list[str] = []
|
|
23
|
+
self._matrix: np.ndarray | None = None # shape (N, D)
|
|
24
|
+
|
|
25
|
+
def clear(self) -> None:
|
|
26
|
+
self._ids.clear()
|
|
27
|
+
self._paths.clear()
|
|
28
|
+
self._matrix = None
|
|
29
|
+
|
|
30
|
+
def rebuild(self, chunks: list) -> None:
|
|
31
|
+
"""Replace the full index so every vector shares one embedding basis."""
|
|
32
|
+
self.clear()
|
|
33
|
+
self.upsert(chunks)
|
|
34
|
+
|
|
35
|
+
# ── Helpers ──────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
def _index_of(self, id_: str) -> int | None:
|
|
38
|
+
try:
|
|
39
|
+
return self._ids.index(id_)
|
|
40
|
+
except ValueError:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
# ── Public API ───────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
def upsert(self, chunks: list) -> None:
|
|
46
|
+
"""Insert or overwrite chunk embeddings."""
|
|
47
|
+
for c in chunks:
|
|
48
|
+
if not c.embedding:
|
|
49
|
+
continue
|
|
50
|
+
vec = np.array(c.embedding, dtype=np.float32)
|
|
51
|
+
norm = np.linalg.norm(vec)
|
|
52
|
+
if norm > 0:
|
|
53
|
+
vec /= norm # pre-normalise for cosine via dot
|
|
54
|
+
|
|
55
|
+
idx = self._index_of(c.id)
|
|
56
|
+
if idx is not None:
|
|
57
|
+
# Update in-place
|
|
58
|
+
self._matrix[idx] = vec # type: ignore[index]
|
|
59
|
+
self._paths[idx] = c.path
|
|
60
|
+
else:
|
|
61
|
+
# Append
|
|
62
|
+
self._ids.append(c.id)
|
|
63
|
+
self._paths.append(c.path)
|
|
64
|
+
if self._matrix is None:
|
|
65
|
+
self._matrix = vec.reshape(1, -1)
|
|
66
|
+
else:
|
|
67
|
+
# Pad/trim to current dim if needed
|
|
68
|
+
d = self._matrix.shape[1]
|
|
69
|
+
if vec.shape[0] < d:
|
|
70
|
+
vec = np.pad(vec, (0, d - vec.shape[0]))
|
|
71
|
+
elif vec.shape[0] > d:
|
|
72
|
+
vec = vec[:d]
|
|
73
|
+
self._matrix = np.vstack([self._matrix, vec.reshape(1, -1)])
|
|
74
|
+
|
|
75
|
+
def search(self, query_vec: list[float], top_k: int = 50) -> list[dict]:
|
|
76
|
+
"""Return top-k chunks by cosine similarity."""
|
|
77
|
+
if self._matrix is None or len(self._ids) == 0:
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
q = np.array(query_vec, dtype=np.float32)
|
|
81
|
+
# Match dimension
|
|
82
|
+
d = self._matrix.shape[1]
|
|
83
|
+
if q.shape[0] < d:
|
|
84
|
+
q = np.pad(q, (0, d - q.shape[0]))
|
|
85
|
+
elif q.shape[0] > d:
|
|
86
|
+
q = q[:d]
|
|
87
|
+
|
|
88
|
+
norm = np.linalg.norm(q)
|
|
89
|
+
if norm > 0:
|
|
90
|
+
q /= norm
|
|
91
|
+
|
|
92
|
+
# Brute-force cosine (dot of normalised vectors)
|
|
93
|
+
scores = self._matrix @ q # shape (N,)
|
|
94
|
+
n = min(top_k, len(self._ids))
|
|
95
|
+
top_indices = np.argpartition(scores, -n)[-n:]
|
|
96
|
+
top_indices = top_indices[np.argsort(scores[top_indices])[::-1]]
|
|
97
|
+
|
|
98
|
+
return [
|
|
99
|
+
{"id": self._ids[i], "score": float(scores[i])}
|
|
100
|
+
for i in top_indices
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
def count(self) -> int:
|
|
104
|
+
return len(self._ids)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from rank_bm25 import BM25Okapi
|
|
2
|
+
|
|
3
|
+
from retrieval.tokenize import tokenize
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BM25Index:
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self.bm25 = None
|
|
9
|
+
self.chunks = []
|
|
10
|
+
|
|
11
|
+
def build(self, chunks: list):
|
|
12
|
+
self.chunks = chunks
|
|
13
|
+
if not chunks:
|
|
14
|
+
self.bm25 = None
|
|
15
|
+
return
|
|
16
|
+
tokenized_corpus = [tokenize(c.content) for c in chunks]
|
|
17
|
+
self.bm25 = BM25Okapi(tokenized_corpus)
|
|
18
|
+
|
|
19
|
+
def score(self, query: str) -> list[float]:
|
|
20
|
+
if not self.bm25:
|
|
21
|
+
return []
|
|
22
|
+
tokenized_query = tokenize(query)
|
|
23
|
+
return self.bm25.get_scores(tokenized_query).tolist()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""TTL cache for immutable retrieval selections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from copy import deepcopy
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RetrievalCache:
|
|
13
|
+
def __init__(self, ttl: int = 600):
|
|
14
|
+
self.ttl = ttl
|
|
15
|
+
self.cache: dict[str, tuple[list, float]] = {}
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def path_fingerprint(path: str) -> str:
|
|
19
|
+
"""Fingerprint a requested source path so changed files cannot hit cache."""
|
|
20
|
+
source = Path(path)
|
|
21
|
+
digest = hashlib.sha256()
|
|
22
|
+
|
|
23
|
+
if not source.exists():
|
|
24
|
+
return "missing"
|
|
25
|
+
|
|
26
|
+
files = [source] if source.is_file() else sorted(
|
|
27
|
+
candidate for candidate in source.rglob("*") if candidate.is_file()
|
|
28
|
+
)
|
|
29
|
+
for file_path in files:
|
|
30
|
+
stat = file_path.stat()
|
|
31
|
+
digest.update(str(file_path.resolve()).encode())
|
|
32
|
+
digest.update(f"{stat.st_mtime_ns}:{stat.st_size}".encode())
|
|
33
|
+
return digest.hexdigest()
|
|
34
|
+
|
|
35
|
+
def _hash(
|
|
36
|
+
self,
|
|
37
|
+
query: str,
|
|
38
|
+
paths: list[str],
|
|
39
|
+
options: dict | None = None,
|
|
40
|
+
) -> str:
|
|
41
|
+
payload = {
|
|
42
|
+
"query": query,
|
|
43
|
+
"paths": sorted((path, self.path_fingerprint(path)) for path in paths),
|
|
44
|
+
"options": options or {},
|
|
45
|
+
}
|
|
46
|
+
encoded = json.dumps(payload, sort_keys=True, ensure_ascii=False)
|
|
47
|
+
return hashlib.sha256(encoded.encode()).hexdigest()
|
|
48
|
+
|
|
49
|
+
def get(
|
|
50
|
+
self,
|
|
51
|
+
query: str,
|
|
52
|
+
paths: list[str],
|
|
53
|
+
options: dict | None = None,
|
|
54
|
+
) -> list | None:
|
|
55
|
+
key = self._hash(query, paths, options)
|
|
56
|
+
entry = self.cache.get(key)
|
|
57
|
+
if entry is None:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
data, timestamp = entry
|
|
61
|
+
if time.time() - timestamp >= self.ttl:
|
|
62
|
+
del self.cache[key]
|
|
63
|
+
return None
|
|
64
|
+
return deepcopy(data)
|
|
65
|
+
|
|
66
|
+
def set(
|
|
67
|
+
self,
|
|
68
|
+
query: str,
|
|
69
|
+
paths: list[str],
|
|
70
|
+
chunks: list,
|
|
71
|
+
options: dict | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
self.cache[self._hash(query, paths, options)] = (deepcopy(chunks), time.time())
|
|
74
|
+
|
|
75
|
+
def invalidate_all(self) -> None:
|
|
76
|
+
self.cache.clear()
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Local TF-IDF + LSA embedding engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from sklearn.decomposition import TruncatedSVD
|
|
7
|
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
|
8
|
+
from sklearn.pipeline import Pipeline
|
|
9
|
+
from sklearn.preprocessing import Normalizer
|
|
10
|
+
|
|
11
|
+
_DIM = 128
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EmbeddingEngine:
|
|
15
|
+
"""Local LSA-based embedding engine (TF-IDF → SVD → L2-normalise)."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, dim: int = _DIM):
|
|
18
|
+
self.dim = dim
|
|
19
|
+
self._corpus: list[str] = []
|
|
20
|
+
self._pipe: Pipeline | None = None
|
|
21
|
+
|
|
22
|
+
def _fit(self, texts: list[str]) -> None:
|
|
23
|
+
n_components = min(self.dim, len(texts) - 1) if len(texts) > 1 else 1
|
|
24
|
+
self._pipe = Pipeline([
|
|
25
|
+
("tfidf", TfidfVectorizer(
|
|
26
|
+
analyzer="word",
|
|
27
|
+
ngram_range=(1, 2),
|
|
28
|
+
max_features=20_000,
|
|
29
|
+
sublinear_tf=True,
|
|
30
|
+
)),
|
|
31
|
+
("svd", TruncatedSVD(n_components=n_components, random_state=42)),
|
|
32
|
+
("norm", Normalizer(copy=False)),
|
|
33
|
+
])
|
|
34
|
+
self._pipe.fit(texts)
|
|
35
|
+
|
|
36
|
+
def _transform(self, texts: list[str]) -> np.ndarray:
|
|
37
|
+
if self._pipe is None:
|
|
38
|
+
return np.zeros((len(texts), self.dim), dtype=np.float32)
|
|
39
|
+
|
|
40
|
+
vecs = self._pipe.transform(texts).astype(np.float32)
|
|
41
|
+
if vecs.shape[1] < self.dim:
|
|
42
|
+
pad = np.zeros(
|
|
43
|
+
(vecs.shape[0], self.dim - vecs.shape[1]), dtype=np.float32)
|
|
44
|
+
vecs = np.hstack([vecs, pad])
|
|
45
|
+
return vecs[:, : self.dim]
|
|
46
|
+
|
|
47
|
+
def rebuild(self, texts: list[str]) -> list[list[float]]:
|
|
48
|
+
"""Fit once against the complete corpus and return aligned vectors.
|
|
49
|
+
|
|
50
|
+
LSA components change whenever the corpus changes. Rebuilding all vectors
|
|
51
|
+
together prevents the vector store from comparing embeddings from two
|
|
52
|
+
incompatible bases.
|
|
53
|
+
"""
|
|
54
|
+
self._corpus = list(dict.fromkeys(texts))
|
|
55
|
+
self._pipe = None
|
|
56
|
+
if len(self._corpus) >= 2:
|
|
57
|
+
self._fit(self._corpus)
|
|
58
|
+
return self._transform(texts).tolist()
|
|
59
|
+
|
|
60
|
+
def embed_text(self, text: str) -> list[float]:
|
|
61
|
+
"""Embed a query with the existing corpus model without refitting it."""
|
|
62
|
+
return self._transform([text])[0].tolist()
|
|
63
|
+
|
|
64
|
+
def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
|
65
|
+
"""Compatibility wrapper for callers indexing a complete corpus."""
|
|
66
|
+
if not texts:
|
|
67
|
+
return []
|
|
68
|
+
return self.rebuild(texts)
|
|
69
|
+
|
|
70
|
+
def similarity(self, a: str, b: str) -> float:
|
|
71
|
+
if self._pipe is None:
|
|
72
|
+
self.rebuild([a, b])
|
|
73
|
+
vecs = self._transform([a, b])
|
|
74
|
+
dot = float(np.dot(vecs[0], vecs[1]))
|
|
75
|
+
return max(-1.0, min(1.0, dot))
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import networkx as nx
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DependencyGraph:
|
|
5
|
+
def __init__(self):
|
|
6
|
+
self.graph = nx.DiGraph()
|
|
7
|
+
|
|
8
|
+
def build(self, chunks: list):
|
|
9
|
+
self.graph.clear()
|
|
10
|
+
for c in chunks:
|
|
11
|
+
self.graph.add_node(c.id, chunk=c)
|
|
12
|
+
|
|
13
|
+
# Basic heuristic to build graph: look for symbol usage
|
|
14
|
+
# In a real app, you would use tree-sitter references
|
|
15
|
+
symbols = {c.symbol: c.id for c in chunks if c.symbol != "module"}
|
|
16
|
+
|
|
17
|
+
for c in chunks:
|
|
18
|
+
for sym, t_id in symbols.items():
|
|
19
|
+
if sym in c.content and t_id != c.id:
|
|
20
|
+
self.graph.add_edge(c.id, t_id) # c depends on sym
|
|
21
|
+
|
|
22
|
+
def symbol_score(self, query: str, chunk) -> float:
|
|
23
|
+
score = 0.0
|
|
24
|
+
if chunk.symbol and chunk.symbol.lower() in query.lower():
|
|
25
|
+
score += 0.5
|
|
26
|
+
return score
|
|
27
|
+
|
|
28
|
+
def expand(self, chunks: list, max_extra: int = 4) -> list:
|
|
29
|
+
expanded = set()
|
|
30
|
+
for c in chunks:
|
|
31
|
+
if c.id in self.graph:
|
|
32
|
+
neighbors = list(self.graph.successors(c.id)) + \
|
|
33
|
+
list(self.graph.predecessors(c.id))
|
|
34
|
+
for n in neighbors:
|
|
35
|
+
expanded.add(n)
|
|
36
|
+
|
|
37
|
+
new_chunks = []
|
|
38
|
+
for n_id in expanded:
|
|
39
|
+
if len(new_chunks) >= max_extra:
|
|
40
|
+
break
|
|
41
|
+
chunk_data = self.graph.nodes[n_id].get("chunk")
|
|
42
|
+
if chunk_data and chunk_data not in chunks:
|
|
43
|
+
new_chunks.append(chunk_data)
|
|
44
|
+
|
|
45
|
+
return new_chunks
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
reranker.py — Local cross-score reranker
|
|
3
|
+
=========================================
|
|
4
|
+
No Hugging Face, no model downloads.
|
|
5
|
+
|
|
6
|
+
Score = BM25 term-overlap + keyword exact-match bonus + length-penalty.
|
|
7
|
+
This approximates cross-encoder relevance for code/documentation retrieval
|
|
8
|
+
without any network dependency.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
from collections import Counter
|
|
15
|
+
|
|
16
|
+
from retrieval.tokenize import tokenize
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _tokenize(text: str) -> list[str]:
|
|
20
|
+
"""Lower-case word tokeniser (strips punctuation)."""
|
|
21
|
+
return tokenize(text)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _bm25_score(
|
|
25
|
+
query_tokens: list[str],
|
|
26
|
+
doc_tokens: list[str],
|
|
27
|
+
avg_dl: float,
|
|
28
|
+
k1: float = 1.5,
|
|
29
|
+
b: float = 0.75,
|
|
30
|
+
) -> float:
|
|
31
|
+
"""Single-document BM25 score against a query."""
|
|
32
|
+
doc_freq = Counter(doc_tokens)
|
|
33
|
+
dl = len(doc_tokens)
|
|
34
|
+
score = 0.0
|
|
35
|
+
for term in set(query_tokens):
|
|
36
|
+
tf = doc_freq.get(term, 0)
|
|
37
|
+
if tf == 0:
|
|
38
|
+
continue
|
|
39
|
+
idf = math.log(1 + 1) # single-doc approx; always 1 query doc
|
|
40
|
+
tf_norm = (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * dl / max(avg_dl, 1)))
|
|
41
|
+
score += idf * tf_norm
|
|
42
|
+
return score
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _keyword_bonus(query_tokens: set[str], doc_tokens: list[str]) -> float:
|
|
46
|
+
"""Fraction of distinct query tokens found in doc (0–1)."""
|
|
47
|
+
if not query_tokens:
|
|
48
|
+
return 0.0
|
|
49
|
+
found = sum(1 for t in query_tokens if t in doc_tokens)
|
|
50
|
+
return found / len(query_tokens)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Reranker:
|
|
54
|
+
"""
|
|
55
|
+
Local reranker: BM25 term-overlap + keyword coverage bonus.
|
|
56
|
+
Replaces sentence-transformers CrossEncoder with a zero-download solution.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def rerank(self, query: str, chunks: list, top_k: int = 12) -> list:
|
|
60
|
+
if not chunks:
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
q_tokens = _tokenize(query)
|
|
64
|
+
q_set = set(q_tokens)
|
|
65
|
+
|
|
66
|
+
# Average document length for BM25 normalisation
|
|
67
|
+
doc_token_lists = [_tokenize(c.content) for c in chunks]
|
|
68
|
+
avg_dl = sum(len(d) for d in doc_token_lists) / max(len(doc_token_lists), 1)
|
|
69
|
+
|
|
70
|
+
for chunk, doc_tokens in zip(chunks, doc_token_lists):
|
|
71
|
+
bm25 = _bm25_score(q_tokens, doc_tokens, avg_dl)
|
|
72
|
+
bonus = _keyword_bonus(q_set, doc_tokens)
|
|
73
|
+
# Blend: BM25 dominates, keyword coverage as tiebreaker
|
|
74
|
+
prior = float(getattr(chunk, "_score", 0.0))
|
|
75
|
+
chunk._score = prior * 0.6 + bm25 * 0.3 + bonus * 0.1 # type: ignore[attr-defined]
|
|
76
|
+
|
|
77
|
+
chunks.sort(key=lambda x: getattr(x, "_score", 0.0), reverse=True)
|
|
78
|
+
return chunks[:top_k]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import unicodedata
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def tokenize(text: str) -> list[str]:
|
|
8
|
+
text = unicodedata.normalize("NFC", text)
|
|
9
|
+
text = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", text).replace("_", " ")
|
|
10
|
+
words = re.findall(r"[^\W_]+", text.casefold(), flags=re.UNICODE)
|
|
11
|
+
return words + [word[index:index + 3] for word in words for index in range(max(0, len(word) - 2))]
|