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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -0
  3. package/assets/brand/logo-lockup-dark.svg +14 -0
  4. package/assets/brand/logo-lockup.svg +14 -0
  5. package/assets/brand/logo.svg +9 -0
  6. package/bin/opencontextengine.mjs +64 -0
  7. package/docs/QUICKSTART.md +192 -0
  8. package/docs/RERANKER_API.md +32 -0
  9. package/package.json +81 -0
  10. package/requirements.txt +2 -0
  11. package/scripts/mcp-opencontextengine.mjs +37 -0
  12. package/scripts/retrieval-server.py +135 -0
  13. package/src/client.mjs +37 -0
  14. package/src/config.mjs +49 -0
  15. package/src/environment.mjs +10 -0
  16. package/src/eval/remote-models.mjs +70 -0
  17. package/src/mcp.mjs +58 -0
  18. package/src/retrieval/batched.py +220 -0
  19. package/src/retrieval/cascade.py +142 -0
  20. package/src/retrieval/engine.py +195 -0
  21. package/src/retrieval/entities.py +187 -0
  22. package/src/retrieval/languages/__init__.py +129 -0
  23. package/src/retrieval/languages/files.py +90 -0
  24. package/src/retrieval/languages/go.py +154 -0
  25. package/src/retrieval/languages/go_ast.go +204 -0
  26. package/src/retrieval/languages/go_types.go +169 -0
  27. package/src/retrieval/languages/python.py +113 -0
  28. package/src/retrieval/languages/schema.py +81 -0
  29. package/src/retrieval/languages/text.py +39 -0
  30. package/src/retrieval/languages/typescript.mjs +233 -0
  31. package/src/retrieval/languages/typescript.py +23 -0
  32. package/src/retrieval/live.py +273 -0
  33. package/src/retrieval/reranker.py +83 -0
  34. package/src/retrieval/routed.py +35 -0
  35. package/src/runtime.mjs +60 -0
  36. package/src/service.mjs +77 -0
  37. package/src/setup.mjs +66 -0
  38. 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))
@@ -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
+ }
@@ -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
+ }