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.
Files changed (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +441 -0
  3. package/README.vi.md +441 -0
  4. package/bin/ctxora.mjs +147 -0
  5. package/package.json +45 -0
  6. package/pyproject.toml +59 -0
  7. package/src/chunking/compressor.py +104 -0
  8. package/src/chunking/treesitter_chunker.py +240 -0
  9. package/src/compact/anthropic.py +98 -0
  10. package/src/compact/gemini.py +88 -0
  11. package/src/compact/handoff.py +179 -0
  12. package/src/compact/openai.py +318 -0
  13. package/src/compact/summarizer.py +186 -0
  14. package/src/context/assembler.py +298 -0
  15. package/src/context/budgeting.py +137 -0
  16. package/src/context/sanitizer.py +23 -0
  17. package/src/evaluation/__init__.py +1 -0
  18. package/src/evaluation/gates.py +172 -0
  19. package/src/evaluation/metrics.py +41 -0
  20. package/src/harness_context/__init__.py +5 -0
  21. package/src/harness_context/adapters/__init__.py +1 -0
  22. package/src/harness_context/adapters/clients/__init__.py +4 -0
  23. package/src/harness_context/adapters/clients/formatters.py +47 -0
  24. package/src/harness_context/adapters/clients/profiles.py +29 -0
  25. package/src/harness_context/adapters/ecc/__init__.py +4 -0
  26. package/src/harness_context/adapters/ecc/detection.py +41 -0
  27. package/src/harness_context/adapters/ecc/mapping.py +32 -0
  28. package/src/harness_context/adapters/ecc/memory_reader.py +162 -0
  29. package/src/harness_context/adapters/ecc/provenance.py +16 -0
  30. package/src/harness_context/api/__init__.py +1 -0
  31. package/src/harness_context/api/v2/__init__.py +12 -0
  32. package/src/harness_context/api/v2/contracts.py +119 -0
  33. package/src/harness_context/api/v2/diagnostics.py +13 -0
  34. package/src/harness_context/api/v2/enums.py +17 -0
  35. package/src/harness_context/api/v2/errors.py +32 -0
  36. package/src/harness_context/api/v2/models.py +4 -0
  37. package/src/harness_context/api/v2/requests.py +17 -0
  38. package/src/harness_context/api/v2/responses.py +22 -0
  39. package/src/harness_context/application/__init__.py +3 -0
  40. package/src/harness_context/application/container.py +31 -0
  41. package/src/harness_context/application/context_service.py +51 -0
  42. package/src/harness_context/application/ecc_service.py +7 -0
  43. package/src/harness_context/application/handoff_service.py +11 -0
  44. package/src/harness_context/application/memory_service.py +9 -0
  45. package/src/harness_context/application/protocols.py +46 -0
  46. package/src/harness_context/application/refresh_service.py +25 -0
  47. package/src/harness_context/application/retrieval_service.py +22 -0
  48. package/src/harness_context/application/services.py +4 -0
  49. package/src/harness_context/application/workspace_service.py +18 -0
  50. package/src/harness_context/bootstrap.py +47 -0
  51. package/src/harness_context/branding.py +16 -0
  52. package/src/harness_context/cli/__init__.py +1 -0
  53. package/src/harness_context/cli/app.py +239 -0
  54. package/src/harness_context/cli/exit_codes.py +25 -0
  55. package/src/harness_context/domain/__init__.py +9 -0
  56. package/src/harness_context/domain/cag.py +18 -0
  57. package/src/harness_context/domain/chunking.py +17 -0
  58. package/src/harness_context/domain/planning.py +30 -0
  59. package/src/harness_context/domain/ports.py +24 -0
  60. package/src/harness_context/domain/retrieval.py +46 -0
  61. package/src/harness_context/engine.py +10 -0
  62. package/src/harness_context/free_tools.py +143 -0
  63. package/src/harness_context/infrastructure/__init__.py +10 -0
  64. package/src/harness_context/infrastructure/graph.py +26 -0
  65. package/src/harness_context/infrastructure/indexes.py +33 -0
  66. package/src/harness_context/infrastructure/local_engine.py +296 -0
  67. package/src/harness_context/infrastructure/parsing.py +38 -0
  68. package/src/harness_context/infrastructure/scanning.py +51 -0
  69. package/src/harness_context/installer/__init__.py +4 -0
  70. package/src/harness_context/installer/models.py +22 -0
  71. package/src/harness_context/installer/service.py +168 -0
  72. package/src/harness_context/mcp/__init__.py +3 -0
  73. package/src/harness_context/mcp/capabilities.py +11 -0
  74. package/src/harness_context/mcp/errors.py +8 -0
  75. package/src/harness_context/mcp/lifecycle.py +72 -0
  76. package/src/harness_context/mcp/middleware.py +57 -0
  77. package/src/harness_context/mcp/server.py +3 -0
  78. package/src/harness_context/mcp/tool_handlers/__init__.py +7 -0
  79. package/src/harness_context/mcp/tool_handlers/context.py +16 -0
  80. package/src/harness_context/mcp/tool_handlers/ecc.py +8 -0
  81. package/src/harness_context/mcp/tool_handlers/handoffs.py +20 -0
  82. package/src/harness_context/mcp/tool_handlers/memory.py +16 -0
  83. package/src/harness_context/mcp/tool_handlers/workspace.py +12 -0
  84. package/src/harness_context/mcp/tools.py +15 -0
  85. package/src/harness_context/observability/__init__.py +6 -0
  86. package/src/harness_context/observability/events.py +25 -0
  87. package/src/harness_context/observability/metrics.py +20 -0
  88. package/src/harness_context/paths.py +35 -0
  89. package/src/harness_context/runtime.py +127 -0
  90. package/src/harness_context/schemas.py +38 -0
  91. package/src/harness_context/security/__init__.py +3 -0
  92. package/src/harness_context/security/secret_patterns.py +15 -0
  93. package/src/harness_context/server.py +1077 -0
  94. package/src/harness_context/storage/__init__.py +6 -0
  95. package/src/harness_context/storage/migrations.py +24 -0
  96. package/src/harness_context/storage/pins.py +10 -0
  97. package/src/harness_context/storage/snapshots.py +149 -0
  98. package/src/harness_context/tokenize.py +12 -0
  99. package/src/harness_context/topology.py +65 -0
  100. package/src/harness_context/watcher/__init__.py +3 -0
  101. package/src/harness_context/watcher/service.py +32 -0
  102. package/src/harness_context/workspace/__init__.py +13 -0
  103. package/src/harness_context/workspace/identity.py +9 -0
  104. package/src/harness_context/workspace/lock.py +24 -0
  105. package/src/harness_context/workspace/policy.py +3 -0
  106. package/src/harness_context/workspace/roots.py +84 -0
  107. package/src/harness_context/workspace/state.py +35 -0
  108. package/src/memory/episodic.py +257 -0
  109. package/src/memory/vector_store.py +104 -0
  110. package/src/retrieval/bm25.py +23 -0
  111. package/src/retrieval/cache.py +76 -0
  112. package/src/retrieval/embeddings.py +75 -0
  113. package/src/retrieval/graph.py +45 -0
  114. package/src/retrieval/reranker.py +78 -0
  115. 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))]