open-context-engine 0.1.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 +177 -0
- package/assets/brand/logo-lockup-dark.svg +14 -0
- package/assets/brand/logo-lockup.svg +14 -0
- package/assets/brand/logo.svg +9 -0
- package/bin/opencontextengine.mjs +64 -0
- package/docs/QUICKSTART.md +192 -0
- package/docs/RERANKER_API.md +32 -0
- package/package.json +81 -0
- package/requirements.txt +2 -0
- package/scripts/mcp-opencontextengine.mjs +37 -0
- package/scripts/retrieval-server.py +135 -0
- package/src/client.mjs +37 -0
- package/src/config.mjs +49 -0
- package/src/environment.mjs +10 -0
- package/src/eval/remote-models.mjs +70 -0
- package/src/mcp.mjs +58 -0
- package/src/retrieval/batched.py +220 -0
- package/src/retrieval/cascade.py +142 -0
- package/src/retrieval/engine.py +195 -0
- package/src/retrieval/entities.py +187 -0
- package/src/retrieval/languages/__init__.py +129 -0
- package/src/retrieval/languages/files.py +90 -0
- package/src/retrieval/languages/go.py +154 -0
- package/src/retrieval/languages/go_ast.go +204 -0
- package/src/retrieval/languages/go_types.go +169 -0
- package/src/retrieval/languages/python.py +113 -0
- package/src/retrieval/languages/schema.py +81 -0
- package/src/retrieval/languages/text.py +39 -0
- package/src/retrieval/languages/typescript.mjs +233 -0
- package/src/retrieval/languages/typescript.py +23 -0
- package/src/retrieval/live.py +273 -0
- package/src/retrieval/reranker.py +83 -0
- package/src/retrieval/routed.py +35 -0
- package/src/runtime.mjs +60 -0
- package/src/service.mjs +77 -0
- package/src/setup.mjs +66 -0
- package/src/workspaces.mjs +59 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Single-workspace index: durable vector reuse and atomic searchable generations."""
|
|
2
|
+
from collections import Counter
|
|
3
|
+
from contextlib import closing
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
import fcntl
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import shutil
|
|
11
|
+
import sqlite3
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from engine import document, post
|
|
19
|
+
from languages import adapter_manifest, source_units
|
|
20
|
+
from languages.files import discover_snapshot
|
|
21
|
+
from routed import RoutedEngine
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def digest(value):
|
|
25
|
+
return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class IndexUnavailable(Exception):
|
|
29
|
+
"""The requested working tree has no complete, current searchable generation."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SourceChanged(Exception):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Generation:
|
|
38
|
+
identity: str
|
|
39
|
+
snapshot: dict
|
|
40
|
+
info: dict
|
|
41
|
+
engine: object
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class LiveIndex:
|
|
45
|
+
def __init__(self, config, *, embed=post, engine_factory=RoutedEngine):
|
|
46
|
+
self.config = config
|
|
47
|
+
self.root = Path(config['root']).resolve()
|
|
48
|
+
self.state = Path(config['state']).resolve()
|
|
49
|
+
if not self.root.is_dir():
|
|
50
|
+
raise ValueError('Repository root must be a directory')
|
|
51
|
+
if self.state.is_relative_to(self.root):
|
|
52
|
+
raise ValueError('Live index state must be outside the repository root')
|
|
53
|
+
self.options = config.get('languageOptions', {})
|
|
54
|
+
self.poll = float(config.get('pollSeconds', 1))
|
|
55
|
+
self.debounce = float(config.get('debounceSeconds', .3))
|
|
56
|
+
if not .05 <= self.poll <= 60 or not 0 <= self.debounce <= 10:
|
|
57
|
+
raise ValueError('Invalid live index polling or debounce interval')
|
|
58
|
+
self.embedding = {'provider': config['embeddingIdentity'],
|
|
59
|
+
'model': config.get('embeddingModel', 'Qwen3-Embedding-4B'),
|
|
60
|
+
'dimensions': config.get('embeddingDimensions', 1024),
|
|
61
|
+
'revision': config.get('embeddingRevision', '1')}
|
|
62
|
+
if not isinstance(self.embedding['model'], str) or not self.embedding['model']:
|
|
63
|
+
raise ValueError('Invalid embedding model')
|
|
64
|
+
self.dimensions = self.embedding['dimensions']
|
|
65
|
+
if type(self.dimensions) is not int or not 1 <= self.dimensions <= 65536:
|
|
66
|
+
raise ValueError('Invalid embedding dimensions')
|
|
67
|
+
self.embed, self.engine_factory = embed, engine_factory
|
|
68
|
+
self.condition = threading.Condition()
|
|
69
|
+
self.stop_event = threading.Event()
|
|
70
|
+
self.generation = None
|
|
71
|
+
self.error = None
|
|
72
|
+
self.phase = 'starting'
|
|
73
|
+
self.parse_cache = {}
|
|
74
|
+
self.state.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
75
|
+
self.lock_file = (self.state/'writer.lock').open('a')
|
|
76
|
+
try:
|
|
77
|
+
fcntl.flock(self.lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
78
|
+
except OSError:
|
|
79
|
+
self.lock_file.close()
|
|
80
|
+
raise ValueError('This index directory already has a running writer') from None
|
|
81
|
+
self.thread = threading.Thread(target=self._run, name='repository-index', daemon=True)
|
|
82
|
+
|
|
83
|
+
def scan(self):
|
|
84
|
+
snapshot = discover_snapshot(self.root)
|
|
85
|
+
# Ignore diagnostic exclusions and mtime: identities bind actual inputs.
|
|
86
|
+
snapshot = {'files': [{'path': f['path'], 'sha256': f['sha256']} for f in snapshot['files']],
|
|
87
|
+
'languageOptions': self.options}
|
|
88
|
+
return snapshot
|
|
89
|
+
|
|
90
|
+
def identity(self, snapshot):
|
|
91
|
+
return digest({'schema': 'live-index-v1', 'root': str(self.root), 'snapshot': snapshot,
|
|
92
|
+
'adapters': adapter_manifest(snapshot['files'], self.options),
|
|
93
|
+
'embedding': self.embedding})
|
|
94
|
+
|
|
95
|
+
def start(self):
|
|
96
|
+
self.thread.start()
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def close(self):
|
|
100
|
+
self.stop_event.set()
|
|
101
|
+
with self.condition:
|
|
102
|
+
self.condition.notify_all()
|
|
103
|
+
if self.thread.is_alive():
|
|
104
|
+
self.thread.join(timeout=5)
|
|
105
|
+
# An in-flight model call may finish later. Keep its writer lock until
|
|
106
|
+
# the worker exits, rather than allowing another process to overlap it.
|
|
107
|
+
if not self.thread.is_alive() and not self.lock_file.closed:
|
|
108
|
+
self.lock_file.close()
|
|
109
|
+
|
|
110
|
+
def status(self):
|
|
111
|
+
with self.condition:
|
|
112
|
+
return {'status': self.phase, 'mode': 'live', 'root': str(self.root),
|
|
113
|
+
'generation': self.generation.info if self.generation else None,
|
|
114
|
+
'error': self.error, 'pollSeconds': self.poll}
|
|
115
|
+
|
|
116
|
+
def current(self, timeout=30):
|
|
117
|
+
deadline = time.monotonic() + timeout
|
|
118
|
+
while not self.stop_event.is_set():
|
|
119
|
+
try:
|
|
120
|
+
target = self.identity(self.scan())
|
|
121
|
+
except (OSError, ValueError) as error:
|
|
122
|
+
raise IndexUnavailable('Cannot read current source: ' + type(error).__name__) from None
|
|
123
|
+
with self.condition:
|
|
124
|
+
if self.generation and self.generation.identity == target:
|
|
125
|
+
return self.generation
|
|
126
|
+
if self.error and self.error['identity'] == target:
|
|
127
|
+
raise IndexUnavailable('Index update failed: ' + self.error['type'])
|
|
128
|
+
remaining = deadline - time.monotonic()
|
|
129
|
+
if remaining <= 0:
|
|
130
|
+
raise IndexUnavailable('Index update pending; retry after synchronization')
|
|
131
|
+
self.condition.wait(min(remaining, self.poll))
|
|
132
|
+
raise IndexUnavailable('Index is stopping')
|
|
133
|
+
|
|
134
|
+
def verify(self, generation):
|
|
135
|
+
try:
|
|
136
|
+
current = self.identity(self.scan()) == generation.identity
|
|
137
|
+
except (OSError, ValueError):
|
|
138
|
+
current = False
|
|
139
|
+
if not current:
|
|
140
|
+
raise IndexUnavailable('Source changed during search; retry for current code')
|
|
141
|
+
|
|
142
|
+
def _engine(self, units, vectors):
|
|
143
|
+
if not units:
|
|
144
|
+
return None
|
|
145
|
+
return self.engine_factory(units, vectors, self.config['embeddingUrl'],
|
|
146
|
+
self.config['reranker'], self.config.get('embeddingKey', 'local-only'),
|
|
147
|
+
embedding_model=self.embedding['model'])
|
|
148
|
+
|
|
149
|
+
def _restore(self, snapshot, identity):
|
|
150
|
+
pointer = self.state/'current.json'
|
|
151
|
+
if not pointer.exists():
|
|
152
|
+
return None
|
|
153
|
+
try:
|
|
154
|
+
saved = json.loads(pointer.read_text())
|
|
155
|
+
name = saved['directory']
|
|
156
|
+
if name != Path(name).name or not name.startswith('generation-'):
|
|
157
|
+
return None
|
|
158
|
+
folder = self.state/name
|
|
159
|
+
info = json.loads((folder/'metadata.json').read_text())
|
|
160
|
+
if info['identity'] != identity:
|
|
161
|
+
return None
|
|
162
|
+
units = json.loads((folder/'units.json').read_text())
|
|
163
|
+
vectors = np.load(folder/'vectors.npy', allow_pickle=False)
|
|
164
|
+
if vectors.shape != (len(units), self.dimensions) or not np.isfinite(vectors).all():
|
|
165
|
+
return None
|
|
166
|
+
return Generation(identity, snapshot, info, self._engine(units, vectors))
|
|
167
|
+
except (OSError, ValueError, KeyError):
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
def _build(self, snapshot, identity):
|
|
171
|
+
start = time.monotonic()
|
|
172
|
+
units = source_units(self.root, snapshot['files'], language_options=self.options, cache=self.parse_cache)
|
|
173
|
+
documents = [document(unit) for unit in units]
|
|
174
|
+
keys = [digest([self.embedding, text]) for text in documents]
|
|
175
|
+
vectors, missing = {}, {}
|
|
176
|
+
with closing(sqlite3.connect(self.state/'embeddings.sqlite')) as db:
|
|
177
|
+
db.execute('CREATE TABLE IF NOT EXISTS vectors (key TEXT PRIMARY KEY, value BLOB NOT NULL)')
|
|
178
|
+
for key, text in zip(keys, documents):
|
|
179
|
+
row = db.execute('SELECT value FROM vectors WHERE key=?', (key,)).fetchone()
|
|
180
|
+
if row:
|
|
181
|
+
value = np.frombuffer(row[0], dtype=np.float32)
|
|
182
|
+
if value.shape == (self.dimensions,) and np.isfinite(value).all():
|
|
183
|
+
vectors[key] = value
|
|
184
|
+
continue
|
|
185
|
+
missing[key] = text
|
|
186
|
+
entries = list(missing.items())
|
|
187
|
+
for offset in range(0, len(entries), 64):
|
|
188
|
+
if self.stop_event.is_set():
|
|
189
|
+
raise SourceChanged()
|
|
190
|
+
batch = entries[offset:offset+64]
|
|
191
|
+
result = self.embed(self.config['embeddingUrl']+'/embeddings',
|
|
192
|
+
{'model': self.embedding['model'], 'input': [text for _, text in batch]},
|
|
193
|
+
self.config.get('embeddingKey', 'local-only'), timeout=60)
|
|
194
|
+
rows = sorted(result['data'], key=lambda row: row['index'])
|
|
195
|
+
if [row['index'] for row in rows] != list(range(len(batch))):
|
|
196
|
+
raise ValueError('Invalid embedding response indices')
|
|
197
|
+
matrix = np.asarray([row['embedding'] for row in rows], dtype=np.float32)
|
|
198
|
+
if matrix.shape != (len(batch), self.dimensions) or not np.isfinite(matrix).all():
|
|
199
|
+
raise ValueError('Invalid embedding vectors')
|
|
200
|
+
for (key, _), vector in zip(batch, matrix):
|
|
201
|
+
vectors[key] = vector
|
|
202
|
+
db.execute('INSERT OR REPLACE INTO vectors VALUES (?, ?)', (key, vector.tobytes()))
|
|
203
|
+
db.commit() # Reuse successful batches even after a concurrent edit.
|
|
204
|
+
matrix = np.asarray([vectors[key] for key in keys], dtype=np.float32).reshape(len(keys), self.dimensions)
|
|
205
|
+
engine = self._engine(units, matrix)
|
|
206
|
+
if self.stop_event.is_set() or self.scan() != snapshot:
|
|
207
|
+
raise SourceChanged()
|
|
208
|
+
previous = {f['path']: f['sha256'] for f in self.generation.snapshot['files']} if self.generation else {}
|
|
209
|
+
now = {f['path']: f['sha256'] for f in snapshot['files']}
|
|
210
|
+
info = {'identity': identity, 'files': len(now), 'units': len(units),
|
|
211
|
+
'embeddedDocuments': len(missing), 'reusedUnits': sum(key not in missing for key in keys),
|
|
212
|
+
'changedFiles': sum(previous.get(path) != sha for path, sha in now.items()),
|
|
213
|
+
'deletedFiles': len(previous.keys() - now.keys()), 'embedding': self.embedding,
|
|
214
|
+
'languageUnits': dict(Counter(unit['language'] for unit in units)),
|
|
215
|
+
'indexingMs': round((time.monotonic()-start)*1000), 'completedAt': time.time()}
|
|
216
|
+
folder = self.state/('generation-'+uuid.uuid4().hex)
|
|
217
|
+
folder.mkdir(mode=0o700)
|
|
218
|
+
try:
|
|
219
|
+
(folder/'units.json').write_text(json.dumps(units))
|
|
220
|
+
np.save(folder/'vectors.npy', matrix)
|
|
221
|
+
(folder/'metadata.json').write_text(json.dumps(info))
|
|
222
|
+
pointer = self.state/'current.tmp'
|
|
223
|
+
pointer.write_text(json.dumps({'directory': folder.name}))
|
|
224
|
+
os.replace(pointer, self.state/'current.json')
|
|
225
|
+
except BaseException:
|
|
226
|
+
shutil.rmtree(folder)
|
|
227
|
+
raise
|
|
228
|
+
# Search retains its own immutable in-memory generation.
|
|
229
|
+
for old in self.state.glob('generation-*'):
|
|
230
|
+
if old != folder and old.is_dir():
|
|
231
|
+
shutil.rmtree(old)
|
|
232
|
+
return Generation(identity, snapshot, info, engine)
|
|
233
|
+
|
|
234
|
+
def _run(self):
|
|
235
|
+
failed_identity, retry_at = None, 0
|
|
236
|
+
try:
|
|
237
|
+
while not self.stop_event.is_set():
|
|
238
|
+
identity = None
|
|
239
|
+
try:
|
|
240
|
+
snapshot = self.scan()
|
|
241
|
+
identity = self.identity(snapshot)
|
|
242
|
+
if self.generation and self.generation.identity == identity:
|
|
243
|
+
with self.condition:
|
|
244
|
+
self.phase, self.error = 'ready', None
|
|
245
|
+
self.stop_event.wait(self.poll)
|
|
246
|
+
continue
|
|
247
|
+
if failed_identity == identity and time.monotonic() < retry_at:
|
|
248
|
+
self.stop_event.wait(self.poll)
|
|
249
|
+
continue
|
|
250
|
+
with self.condition:
|
|
251
|
+
self.phase, self.error = 'updating', None
|
|
252
|
+
if self.stop_event.wait(self.debounce):
|
|
253
|
+
break
|
|
254
|
+
if self.scan() != snapshot:
|
|
255
|
+
continue
|
|
256
|
+
generation = self._restore(snapshot, identity) or self._build(snapshot, identity)
|
|
257
|
+
if self.scan() != snapshot:
|
|
258
|
+
raise SourceChanged()
|
|
259
|
+
with self.condition:
|
|
260
|
+
self.generation, self.phase, self.error = generation, 'ready', None
|
|
261
|
+
failed_identity = None
|
|
262
|
+
self.condition.notify_all()
|
|
263
|
+
except SourceChanged:
|
|
264
|
+
continue
|
|
265
|
+
except Exception as error:
|
|
266
|
+
failed_identity, retry_at = identity, time.monotonic() + max(2, self.poll)
|
|
267
|
+
with self.condition:
|
|
268
|
+
self.phase = 'failed'
|
|
269
|
+
self.error = {'identity': identity, 'type': type(error).__name__}
|
|
270
|
+
self.condition.notify_all()
|
|
271
|
+
self.stop_event.wait(self.poll)
|
|
272
|
+
finally:
|
|
273
|
+
self.lock_file.close()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Normalize ordinary rerank and optional multi-query batch responses.
|
|
2
|
+
|
|
3
|
+
Only requested pairs are sent. Result indices always refer to the submitted
|
|
4
|
+
documents, never their relevance-sorted position in a provider response.
|
|
5
|
+
"""
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
8
|
+
import math
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def bounded_integer(value, name, maximum):
|
|
12
|
+
if type(value) is not int or not 1 <= value <= maximum:
|
|
13
|
+
raise ValueError(f'{name} must be an integer from 1 to {maximum}')
|
|
14
|
+
return value
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def checked_rows(data, count):
|
|
18
|
+
rows = data.get('results') if isinstance(data, dict) else None
|
|
19
|
+
if not isinstance(rows, list) or len(rows) != count:
|
|
20
|
+
raise ValueError('Incomplete rerank response')
|
|
21
|
+
indexed = {}
|
|
22
|
+
for row in rows:
|
|
23
|
+
if not isinstance(row, dict):
|
|
24
|
+
raise ValueError('Invalid rerank result')
|
|
25
|
+
index, score = row.get('index'), row.get('relevance_score')
|
|
26
|
+
if type(index) is not int or not 0 <= index < count or index in indexed:
|
|
27
|
+
raise ValueError('Invalid rerank result index')
|
|
28
|
+
if type(score) not in (int, float) or not math.isfinite(score) or not 0 <= score <= 1:
|
|
29
|
+
raise ValueError('Invalid rerank relevance score')
|
|
30
|
+
indexed[index] = row
|
|
31
|
+
return [indexed[i] for i in range(count)]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def rerank_pairs(config, queries, documents, pairs, post):
|
|
35
|
+
api = config.get('api', 'rerank')
|
|
36
|
+
if api not in ('rerank', 'rerank-batch'):
|
|
37
|
+
raise ValueError('Rerank API must be rerank or rerank-batch')
|
|
38
|
+
concurrency = bounded_integer(config.get('concurrency', 2), 'Rerank concurrency', 8)
|
|
39
|
+
limit = bounded_integer(config.get('maxDocuments', 128), 'Rerank document limit', 1024)
|
|
40
|
+
for q, d in pairs:
|
|
41
|
+
if type(q) is not int or type(d) is not int or not 0 <= q < len(queries) or not 0 <= d < len(documents):
|
|
42
|
+
raise ValueError('Invalid requested rerank pair')
|
|
43
|
+
if not pairs:
|
|
44
|
+
return {'results': [], 'meta': {'request_count': 0}}
|
|
45
|
+
url = config['baseUrl'].rstrip('/') + '/' + api
|
|
46
|
+
if api == 'rerank-batch':
|
|
47
|
+
data = post(url, {'model': config['model'], 'queries': queries,
|
|
48
|
+
'documents': documents, 'pairs': pairs}, config['apiKey'])
|
|
49
|
+
rows = checked_rows(data, len(pairs))
|
|
50
|
+
for row, (q, d) in zip(rows, pairs):
|
|
51
|
+
if (type(row.get('query_index')) is not int or type(row.get('document_index')) is not int
|
|
52
|
+
or (row['query_index'], row['document_index']) != (q, d)):
|
|
53
|
+
raise ValueError('Invalid neural pair mapping')
|
|
54
|
+
return {**data, 'results': rows, 'meta': {**data.get('meta', {}), 'request_count': 1}}
|
|
55
|
+
|
|
56
|
+
grouped = defaultdict(dict)
|
|
57
|
+
for q, d in pairs:
|
|
58
|
+
grouped[q][d] = None
|
|
59
|
+
jobs = [(q, ids[start:start + limit]) for q, group in grouped.items()
|
|
60
|
+
for ids in [list(group)] for start in range(0, len(ids), limit)]
|
|
61
|
+
|
|
62
|
+
def request(job):
|
|
63
|
+
q, ids = job
|
|
64
|
+
data = post(url, {'model': config['model'], 'query': queries[q],
|
|
65
|
+
'documents': [documents[d] for d in ids], 'top_n': len(ids)}, config['apiKey'])
|
|
66
|
+
rows = checked_rows(data, len(ids))
|
|
67
|
+
return {(q, ids[row['index']]): row['relevance_score'] for row in rows}, data
|
|
68
|
+
|
|
69
|
+
# Fail the whole wave on any error; never silently drop a query or switch API.
|
|
70
|
+
if len(jobs) == 1:
|
|
71
|
+
responses = [request(jobs[0])]
|
|
72
|
+
else:
|
|
73
|
+
with ThreadPoolExecutor(max_workers=min(concurrency, len(jobs))) as pool:
|
|
74
|
+
responses = list(pool.map(request, jobs))
|
|
75
|
+
scores = {pair: score for mapping, _ in responses for pair, score in mapping.items()}
|
|
76
|
+
def total(section, key):
|
|
77
|
+
values = [data[section].get(key) if isinstance(data.get(section), dict) else None
|
|
78
|
+
for _, data in responses]
|
|
79
|
+
return sum(values) if all(type(v) in (int, float) and math.isfinite(v) and v >= 0 for v in values) else None
|
|
80
|
+
return {'results': [{'index': i, 'query_index': q, 'document_index': d,
|
|
81
|
+
'relevance_score': scores[q, d]} for i, (q, d) in enumerate(pairs)],
|
|
82
|
+
'usage': {'input_tokens': total('usage', 'input_tokens')},
|
|
83
|
+
'meta': {'request_count': len(jobs), 'elapsed_ms': total('meta', 'elapsed_ms')}}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Shared intent scoring with dense facet affinity and scored graph expansion.
|
|
2
|
+
|
|
3
|
+
Every candidate is scored against the complete question, so fragments containing
|
|
4
|
+
pronouns never lose their context. Facet affinity distributes its packing value.
|
|
5
|
+
"""
|
|
6
|
+
import numpy as np
|
|
7
|
+
from batched import BatchedEngine
|
|
8
|
+
|
|
9
|
+
VERSION = 'shared-intent-v4'
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RoutedEngine(BatchedEngine):
|
|
13
|
+
version = VERSION
|
|
14
|
+
# Shared-intent values are lower than independently maximized facet scores.
|
|
15
|
+
# Only fill the remaining budget after all v2 selections; never displace them.
|
|
16
|
+
min_gain = .005
|
|
17
|
+
|
|
18
|
+
def scoring_policy(self, cache, queries, second_wave):
|
|
19
|
+
confidence = max((value for (query, _), value in cache.items() if query == queries[0]), default=0)
|
|
20
|
+
return {'fullFacets': second_wave and len(set(queries)) > 1 and confidence < .1}
|
|
21
|
+
|
|
22
|
+
def scoring_pairs(self, requested, queries, dense, policy):
|
|
23
|
+
if policy['fullFacets']:
|
|
24
|
+
return requested
|
|
25
|
+
return [(queries[0], uid) for _, uid in requested]
|
|
26
|
+
|
|
27
|
+
def pair_score(self, cache, query, uid, queries, dense, policy):
|
|
28
|
+
if policy['fullFacets']:
|
|
29
|
+
return cache[(query, uid)]
|
|
30
|
+
score = cache[(queries[0], uid)]
|
|
31
|
+
if query == queries[0]:
|
|
32
|
+
return score
|
|
33
|
+
col = queries.index(query)
|
|
34
|
+
affinity = np.exp(min(0, dense[uid, col] - max(dense[uid, 1:])) / .06)
|
|
35
|
+
return score * (.35 + .65 * float(affinity))
|
package/src/runtime.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { readFile, mkdir, mkdtemp, writeFile, rm } from 'node:fs/promises';
|
|
4
|
+
import { join, dirname, relative, isAbsolute } from 'node:path';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { configDirectory, projectRoot } from './config.mjs';
|
|
7
|
+
|
|
8
|
+
const exec = promisify(execFile);
|
|
9
|
+
export async function run(command, args, options = {}) {
|
|
10
|
+
try {return await exec(command,args,{timeout:600000,maxBuffer:4*1024*1024,...options});}
|
|
11
|
+
catch (error) {
|
|
12
|
+
// Do not echo pip output: configured package indexes may contain credentials.
|
|
13
|
+
throw new Error(`${command} failed (${error.code || 'unknown'}). Check the executable, network, and Python venv/pip support.`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const probe = 'import sys, importlib.metadata as m; assert sys.version_info >= (3,10); import numpy,tiktoken; '
|
|
17
|
+
+ 'n=tuple(map(int,m.version("numpy").split(".")[:2])); t=tuple(map(int,m.version("tiktoken").split(".")[:2])); '
|
|
18
|
+
+ 'assert (2,2)<=n<(3,0) and (0,9)<=t<(1,0); tiktoken.get_encoding("cl100k_base"); print(sys.version.split()[0])';
|
|
19
|
+
|
|
20
|
+
export async function verifyPython(python, environment = process.env, execute = run) {
|
|
21
|
+
return (await execute(python,['-c',probe],{env:environment,timeout:120000})).stdout.trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function ensureRuntime(environment, {python = 'python3', execute = run, log = () => {}} = {}) {
|
|
25
|
+
const runtimes = join(configDirectory(environment),'runtimes');
|
|
26
|
+
const requirements = await readFile(join(projectRoot,'requirements.txt'));
|
|
27
|
+
const fingerprint = createHash('sha256').update(requirements).digest('hex');
|
|
28
|
+
const current = environment.OCE_PYTHON;
|
|
29
|
+
const child = current && relative(runtimes,current);
|
|
30
|
+
if (current && child && !child.startsWith('..') && !isAbsolute(child)) {
|
|
31
|
+
try {
|
|
32
|
+
const directory = dirname(dirname(current));
|
|
33
|
+
const manifest = JSON.parse(await readFile(join(directory,'ready.json'),'utf8'));
|
|
34
|
+
if (manifest.requirementsSha256 === fingerprint) {
|
|
35
|
+
const env = {...environment,TIKTOKEN_CACHE_DIR:join(directory,'tokenizer')};
|
|
36
|
+
await verifyPython(current,env,execute);
|
|
37
|
+
log('Reusing the installed Python runtime.');
|
|
38
|
+
return {OCE_PYTHON:current,TIKTOKEN_CACHE_DIR:env.TIKTOKEN_CACHE_DIR};
|
|
39
|
+
}
|
|
40
|
+
} catch { /* Rebuild separately; the existing runtime and config remain intact. */ }
|
|
41
|
+
}
|
|
42
|
+
await execute(python,['-c','import sys; assert sys.version_info >= (3,10), "Python 3.10+ required"'],{timeout:10000});
|
|
43
|
+
await mkdir(runtimes,{recursive:true,mode:0o700});
|
|
44
|
+
// Virtual environments cannot be relocated. Each installation is built in its final directory.
|
|
45
|
+
const directory = await mkdtemp(join(runtimes,'python-'));
|
|
46
|
+
const executable = join(directory,'bin','python');
|
|
47
|
+
const env = {...environment,TIKTOKEN_CACHE_DIR:join(directory,'tokenizer')};
|
|
48
|
+
try {
|
|
49
|
+
log('Creating an isolated Python environment...');
|
|
50
|
+
await execute(python,['-m','venv',directory]);
|
|
51
|
+
log('Installing NumPy and tiktoken (no model weights)...');
|
|
52
|
+
await execute(executable,['-m','pip','install','--disable-pip-version-check','--no-input','-r',join(projectRoot,'requirements.txt')]);
|
|
53
|
+
await verifyPython(executable,env,execute);
|
|
54
|
+
await writeFile(join(directory,'ready.json'),JSON.stringify({requirementsSha256:fingerprint})+'\n',{mode:0o600});
|
|
55
|
+
return {OCE_PYTHON:executable,TIKTOKEN_CACHE_DIR:env.TIKTOKEN_CACHE_DIR};
|
|
56
|
+
} catch (error) {
|
|
57
|
+
await rm(directory,{recursive:true,force:true});
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/service.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { resolve, dirname, delimiter } from 'node:path';
|
|
6
|
+
import { createInterface } from 'node:readline';
|
|
7
|
+
import { projectRoot, loadEnvironment } from './config.mjs';
|
|
8
|
+
import { embeddingTransportConfig, remoteRerankerConfig, rerankerExecutionTransport } from './eval/remote-models.mjs';
|
|
9
|
+
|
|
10
|
+
export function serviceConfig({root, state, port = 0} = {}, environment = process.env) {
|
|
11
|
+
const env = loadEnvironment(environment);
|
|
12
|
+
const embedding = embeddingTransportConfig(env);
|
|
13
|
+
const reranker = remoteRerankerConfig(env), runtime = rerankerExecutionTransport(env);
|
|
14
|
+
const repository = root ? realpathSync(resolve(root)) : undefined;
|
|
15
|
+
const workspaceId = repository && createHash('sha256').update(repository).digest('hex').slice(0, 24);
|
|
16
|
+
const candidates = ['.venv/bin/python', '.pilot-state/baselines/cocoindex-venv/bin/python',
|
|
17
|
+
'.pilot-state/language-adapters-venv/bin/python'].map(path => resolve(projectRoot, path));
|
|
18
|
+
const currentState = repository && resolve(homedir(), '.cache/opencontextengine', workspaceId);
|
|
19
|
+
const previousState = repository && resolve(homedir(), '.cache/reponerve', workspaceId);
|
|
20
|
+
const defaultState = repository && (existsSync(currentState) ? currentState : existsSync(previousState) ? previousState : currentState);
|
|
21
|
+
return {
|
|
22
|
+
python: env.OCE_PYTHON || candidates.find(existsSync) || 'python3',
|
|
23
|
+
workerEnv: {...(env.OCE_GO_BINARY ? {OCE_GO_BINARY:env.OCE_GO_BINARY} : {}),
|
|
24
|
+
...(env.TIKTOKEN_CACHE_DIR ? {TIKTOKEN_CACHE_DIR:env.TIKTOKEN_CACHE_DIR} : {})},
|
|
25
|
+
config: {root: repository, state: state ? resolve(state) : repository
|
|
26
|
+
? defaultState : resolve(projectRoot, '.pilot-state/reponerve/index'),
|
|
27
|
+
serviceKey: env.OCE_API_KEY || randomBytes(32).toString('hex'), port,
|
|
28
|
+
embeddingUrl: embedding.requestBaseUrl, embeddingIdentity: embedding.baseUrl,
|
|
29
|
+
embeddingKey: env.EMBEDDING_API_KEY,
|
|
30
|
+
embeddingModel: env.EMBEDDING_MODEL || 'Qwen3-Embedding-4B',
|
|
31
|
+
embeddingDimensions: Number(env.OCE_EMBEDDING_DIMENSIONS || 1024),
|
|
32
|
+
embeddingRevision: env.OCE_EMBEDDING_REVISION || '1',
|
|
33
|
+
reranker: {...reranker, baseUrl: runtime.requestBaseUrl},
|
|
34
|
+
languageOptions: env.OCE_LANGUAGE_OPTIONS ? JSON.parse(env.OCE_LANGUAGE_OPTIONS) : {},
|
|
35
|
+
pollSeconds: Number(env.OCE_POLL_SECONDS || 1),
|
|
36
|
+
debounceSeconds: Number(env.OCE_DEBOUNCE_SECONDS || .3)},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function startService(settings, {log = line => process.stderr.write(line + '\n')} = {}) {
|
|
41
|
+
const {python, config} = settings;
|
|
42
|
+
const child = spawn(python, [resolve(projectRoot, 'scripts/retrieval-server.py')], {
|
|
43
|
+
cwd: projectRoot, env: {...process.env, ...settings.workerEnv,
|
|
44
|
+
PATH:dirname(process.execPath)+delimiter+(process.env.PATH || ''), OPENBLAS_NUM_THREADS:'2', OMP_NUM_THREADS:'2'},
|
|
45
|
+
stdio:['pipe', 'pipe', 'pipe'],
|
|
46
|
+
});
|
|
47
|
+
const lines = createInterface({input: child.stdout});
|
|
48
|
+
const errors = createInterface({input: child.stderr});
|
|
49
|
+
errors.on('line', log);
|
|
50
|
+
child.stdin.on('error', () => {}); // Spawn/exit handlers report early failures.
|
|
51
|
+
child.stdin.end(JSON.stringify(config) + '\n');
|
|
52
|
+
const ready = new Promise((resolveReady, reject) => {
|
|
53
|
+
const timer = setTimeout(() => {child.kill(); reject(new Error('Retrieval worker startup timed out'));}, 15000);
|
|
54
|
+
child.once('error', error => {clearTimeout(timer); reject(error);});
|
|
55
|
+
child.once('exit', code => {clearTimeout(timer); reject(new Error(`Retrieval worker exited (${code})`));});
|
|
56
|
+
lines.on('line', line => {
|
|
57
|
+
try {
|
|
58
|
+
const value = JSON.parse(line);
|
|
59
|
+
if (value.listening) {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
resolveReady({baseUrl:value.listening, apiKey:config.serviceKey});
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
} catch { /* Non-protocol worker logs belong on stderr. */ }
|
|
65
|
+
log(line);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
async function close() {
|
|
69
|
+
if (!child.pid || child.exitCode !== null || child.signalCode !== null) return;
|
|
70
|
+
await new Promise(resolveClosed => {
|
|
71
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), 5000);
|
|
72
|
+
child.once('exit', () => {clearTimeout(timer); resolveClosed();});
|
|
73
|
+
child.kill('SIGTERM');
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return {child, ready, close};
|
|
77
|
+
}
|
package/src/setup.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { Writable } from 'node:stream';
|
|
3
|
+
import { configDirectory, loadEnvironment, saveUserConfig } from './config.mjs';
|
|
4
|
+
import { remoteRerankerConfig, embeddingTransportConfig, rerankerExecutionTransport } from './eval/remote-models.mjs';
|
|
5
|
+
import { ensureRuntime, run } from './runtime.mjs';
|
|
6
|
+
|
|
7
|
+
export function validateModels(env) {
|
|
8
|
+
embeddingTransportConfig(env);
|
|
9
|
+
remoteRerankerConfig(env);
|
|
10
|
+
rerankerExecutionTransport(env);
|
|
11
|
+
for (const key of ['EMBEDDING_API_KEY','EMBEDDING_MODEL']) {
|
|
12
|
+
if (!env[key]?.trim()) throw new Error(`Set ${key}`);
|
|
13
|
+
}
|
|
14
|
+
const dimensions = Number(env.OCE_EMBEDDING_DIMENSIONS);
|
|
15
|
+
if (!Number.isInteger(dimensions) || dimensions < 1) throw new Error('Embedding dimensions must be a positive integer');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function terminalPrompt(input = process.stdin, output = process.stderr) {
|
|
19
|
+
if (!input.isTTY || !output.isTTY) throw new Error('Run setup in a terminal, or use --non-interactive with model environment variables');
|
|
20
|
+
let muted = false;
|
|
21
|
+
const sink = new Writable({write(chunk,encoding,callback) {if (!muted) output.write(chunk,encoding); callback();}});
|
|
22
|
+
sink.isTTY = true; sink.columns = output.columns;
|
|
23
|
+
const terminal = createInterface({input,output:sink,terminal:true});
|
|
24
|
+
const abort = new AbortController();
|
|
25
|
+
terminal.on('SIGINT',() => abort.abort());
|
|
26
|
+
return {
|
|
27
|
+
async ask(label,current = '',secret = false) {
|
|
28
|
+
const hint = current ? (secret ? ' [saved; Enter to keep]' : ` [${current}]`) : '';
|
|
29
|
+
if (!secret) return (await terminal.question(`${label}${hint}: `,{signal:abort.signal})).trim() || current;
|
|
30
|
+
output.write(`${label}${hint}: `);
|
|
31
|
+
muted = true;
|
|
32
|
+
try {return (await terminal.question('',{signal:abort.signal})).trim() || current;}
|
|
33
|
+
finally {muted = false; output.write('\n');}
|
|
34
|
+
},
|
|
35
|
+
close() {terminal.close();},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function setup({environment = process.env, nonInteractive = false, python,
|
|
40
|
+
prompt, install = ensureRuntime, execute = run, log = message => process.stderr.write(message+'\n')} = {}) {
|
|
41
|
+
if (!['darwin','linux'].includes(process.platform)) throw new Error('OpenContextEngine currently supports macOS and Linux');
|
|
42
|
+
let env = {EMBEDDING_MODEL:'Qwen3-Embedding-4B',RERANK_MODEL:'Qwen3-Reranker-4B',
|
|
43
|
+
OCE_EMBEDDING_DIMENSIONS:'1024',...loadEnvironment(environment)};
|
|
44
|
+
log(`Configuration: ${configDirectory(environment)}`);
|
|
45
|
+
if (!nonInteractive) {
|
|
46
|
+
const questions = prompt || terminalPrompt();
|
|
47
|
+
try {
|
|
48
|
+
for (const [key,label,secret] of [
|
|
49
|
+
['EMBEDDING_BASE_URL','Embedding base URL (including /v1)'],
|
|
50
|
+
['EMBEDDING_API_KEY','Embedding API key',true],['EMBEDDING_MODEL','Embedding model'],
|
|
51
|
+
['OCE_EMBEDDING_DIMENSIONS','Embedding dimensions'],
|
|
52
|
+
['RERANK_BASE_URL','Rerank base URL (before /rerank; include a version prefix if required)'],
|
|
53
|
+
['RERANK_API_KEY','Rerank API key',true],['RERANK_MODEL','Rerank model'],
|
|
54
|
+
]) env[key] = await questions.ask(label,env[key] || '',Boolean(secret));
|
|
55
|
+
} finally {questions.close();}
|
|
56
|
+
}
|
|
57
|
+
validateModels(env);
|
|
58
|
+
await execute('git',['--version'],{timeout:10000});
|
|
59
|
+
if (python) delete env.OCE_PYTHON;
|
|
60
|
+
const runtime = await install(env,{python:python || 'python3',execute,log});
|
|
61
|
+
env = {...env,...runtime};
|
|
62
|
+
const path = saveUserConfig(env,environment);
|
|
63
|
+
log(`Saved configuration to ${path}. API keys are stored with owner-only file permissions.`);
|
|
64
|
+
log('Setup complete. Go projects additionally require Go 1.22+ on PATH or OCE_GO_BINARY.');
|
|
65
|
+
return {path,env};
|
|
66
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, resolve, join } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { serviceConfig, startService } from './service.mjs';
|
|
5
|
+
|
|
6
|
+
function directory(path) {
|
|
7
|
+
const canonical = realpathSync(path);
|
|
8
|
+
if (!statSync(canonical).isDirectory()) throw new Error('directory_path must point to a directory');
|
|
9
|
+
return canonical;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Each canonical repository owns one worker, including while it is starting.
|
|
13
|
+
export function createWorkspaceManager({root, state} = {}, {configure = serviceConfig, start = startService} = {}) {
|
|
14
|
+
const fixedRoot = root ? directory(resolve(root)) : undefined;
|
|
15
|
+
const workers = new Map();
|
|
16
|
+
let closing = false, closed;
|
|
17
|
+
|
|
18
|
+
async function get(directoryPath) {
|
|
19
|
+
if (closing) throw new Error('MCP workspace manager is shutting down');
|
|
20
|
+
if (directoryPath !== undefined && (typeof directoryPath !== 'string' || !isAbsolute(directoryPath))) {
|
|
21
|
+
throw new Error('directory_path must be an absolute project directory');
|
|
22
|
+
}
|
|
23
|
+
if (!directoryPath && !fixedRoot) {
|
|
24
|
+
throw new Error('Pass directory_path with the absolute path of the project to search, or start MCP with --root');
|
|
25
|
+
}
|
|
26
|
+
const repository = directoryPath ? directory(directoryPath) : fixedRoot;
|
|
27
|
+
if (fixedRoot && repository !== fixedRoot) {
|
|
28
|
+
throw new Error('This MCP server is fixed to --root; omit --root at startup to search multiple projects');
|
|
29
|
+
}
|
|
30
|
+
let entry = workers.get(repository);
|
|
31
|
+
if (!entry) {
|
|
32
|
+
const workspaceState = state && (fixedRoot ? state : join(resolve(state),
|
|
33
|
+
createHash('sha256').update(repository).digest('hex').slice(0, 24)));
|
|
34
|
+
const worker = start(configure({root:repository, state:workspaceState}));
|
|
35
|
+
entry = {worker};
|
|
36
|
+
workers.set(repository, entry);
|
|
37
|
+
const remove = () => {if (workers.get(repository) === entry) workers.delete(repository);};
|
|
38
|
+
worker.child.once('exit', remove);
|
|
39
|
+
entry.ready = worker.ready.catch(async error => {
|
|
40
|
+
try {await worker.close();} finally {remove();}
|
|
41
|
+
throw error;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return entry.ready;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function close() {
|
|
48
|
+
if (!closed) {
|
|
49
|
+
closing = true;
|
|
50
|
+
closed = Promise.allSettled([...workers.values()].map(entry => entry.worker.close())).then(results => {
|
|
51
|
+
workers.clear();
|
|
52
|
+
const errors = results.filter(result => result.status === 'rejected').map(result => result.reason);
|
|
53
|
+
if (errors.length) throw new AggregateError(errors, 'Failed to close repository workers');
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return closed;
|
|
57
|
+
}
|
|
58
|
+
return {get, close};
|
|
59
|
+
}
|