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,37 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { createMcpServer } from '../src/mcp.mjs';
4
+ import { clientConfig } from '../src/client.mjs';
5
+ import { createWorkspaceManager } from '../src/workspaces.mjs';
6
+
7
+ const {values} = parseArgs({options:{root:{type:'string'},state:{type:'string'},connect:{type:'boolean'}}});
8
+ if (values.connect && (values.root !== undefined || values.state !== undefined) || values.root === '' || values.state === '') {
9
+ throw new Error('Usage: node scripts/mcp-opencontextengine.mjs [--root /repository] [--state /outside/index] OR --connect');
10
+ }
11
+ let workspaces, server, stopping = false;
12
+ async function shutdown(code = 0) {
13
+ if (stopping) return;
14
+ stopping = true;
15
+ try {
16
+ await server?.close();
17
+ } finally {
18
+ await workspaces?.close();
19
+ process.exit(code);
20
+ }
21
+ }
22
+ process.once('SIGINT', () => {void shutdown();});
23
+ process.once('SIGTERM', () => {void shutdown();});
24
+ process.stdin.once('end', () => {void shutdown();});
25
+ try {
26
+ if (values.connect) {
27
+ server = createMcpServer(clientConfig());
28
+ } else {
29
+ workspaces = createWorkspaceManager(values);
30
+ if (values.root) await workspaces.get();
31
+ if (!stopping) server = createMcpServer(undefined, {resolveConfig:workspaces.get, automatic:!values.root});
32
+ }
33
+ if (!stopping) await server.connect(new StdioServerTransport());
34
+ } catch (error) {
35
+ process.stderr.write(`OpenContextEngine MCP startup failed: ${error.message}\n`);
36
+ await shutdown(1);
37
+ }
@@ -0,0 +1,135 @@
1
+ """Authenticated, persistent retrieval worker; model inference stays remote."""
2
+ import hashlib
3
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
4
+ import json
5
+ from pathlib import Path
6
+ import re
7
+ import secrets
8
+ import sys
9
+ import threading
10
+ import time
11
+ from urllib.request import urlopen
12
+ import numpy as np
13
+
14
+ ROOT = Path(__file__).resolve().parents[1]
15
+ sys.path.insert(0, str(ROOT / 'src' / 'retrieval'))
16
+ from routed import RoutedEngine, VERSION
17
+ from live import LiveIndex, IndexUnavailable
18
+
19
+
20
+ def plan_query(query):
21
+ parts = [part.strip() for part in re.split(r'[::;;,]|,\s+(?=how|which|why|where|what)|\s+and\s+(?=how|which|why|where|what)', query, flags=re.I) if len(part.strip()) > 7]
22
+ facets = parts if 1 < len(parts) <= 4 else [query]
23
+ return {'intent':query,'facets':[{'question':part,'terms':re.findall(r'[A-Za-z][A-Za-z0-9_]*',part)} for part in facets]}
24
+
25
+
26
+ def serve(config):
27
+ initialized = time.monotonic()
28
+ live = LiveIndex(config).start() if config.get('root') else None
29
+ state = Path(config['state'])
30
+ if live:
31
+ index, retrieval = None, None
32
+ else:
33
+ units = json.loads((state / 'units.json').read_text())
34
+ index = json.loads((state / 'metadata.json').read_text())
35
+ retrieval = RoutedEngine(units, np.load(state/'vectors.npy'), config['embeddingUrl'], config['reranker'], config['embeddingKey'])
36
+ health = {'status':'ready','engine':VERSION,'index':index,'queryCache':False,
37
+ 'initializationMs':round((time.monotonic()-initialized)*1000),
38
+ 'sourceSha256':{name:hashlib.sha256((ROOT/name).read_bytes()).hexdigest() for name in
39
+ ['src/retrieval/engine.py','src/retrieval/batched.py','src/retrieval/routed.py','src/retrieval/reranker.py','scripts/retrieval-server.py',
40
+ 'src/retrieval/languages/__init__.py','src/retrieval/languages/schema.py',
41
+ 'src/retrieval/languages/text.py','src/retrieval/languages/files.py',
42
+ 'src/retrieval/languages/python.py','src/retrieval/languages/go.py','src/retrieval/languages/go_ast.go','src/retrieval/languages/go_types.go',
43
+ 'src/retrieval/languages/typescript.py',
44
+ 'src/retrieval/languages/typescript.mjs','package.json','package-lock.json']
45
+ if name != 'package-lock.json' or (ROOT/name).is_file()}}
46
+ model_base = config['reranker']['baseUrl'].removesuffix('/v1')
47
+ if not live:
48
+ with urlopen(model_base+'/healthz',timeout=10) as response:
49
+ health['reranker'] = json.load(response)
50
+ lock = threading.Lock()
51
+
52
+ class Handler(BaseHTTPRequestHandler):
53
+ def log_message(self, *args):
54
+ pass
55
+
56
+ def reply(self, status, data):
57
+ body = json.dumps(data,ensure_ascii=False).encode()
58
+ self.send_response(status)
59
+ self.send_header('Content-Type','application/json; charset=utf-8')
60
+ self.send_header('Content-Length',str(len(body)))
61
+ self.end_headers()
62
+ self.wfile.write(body)
63
+
64
+ def do_GET(self):
65
+ if self.path == '/healthz':
66
+ return self.reply(200, {'status':'running','engine':VERSION,'mode':'live'} if live else health)
67
+ if self.path == '/status':
68
+ if not secrets.compare_digest(self.headers.get('Authorization',''), 'Bearer '+config['serviceKey']):
69
+ return self.reply(401, {'error':'Unauthorized'})
70
+ return self.reply(200, live.status() if live else {'status':'ready','mode':'frozen','generation':index})
71
+ self.reply(404,{'error':'Not found'})
72
+
73
+ def do_POST(self):
74
+ if self.path!='/search':
75
+ return self.reply(404,{'error':'Not found'})
76
+ if not secrets.compare_digest(self.headers.get('Authorization',''), 'Bearer '+config['serviceKey']):
77
+ return self.reply(401,{'error':'Unauthorized'})
78
+ try:
79
+ length = int(self.headers.get('Content-Length','0'))
80
+ if not 1<=length<=32768:
81
+ raise ValueError('Invalid body size')
82
+ body = json.loads(self.rfile.read(length))
83
+ if not isinstance(body,dict) or set(body)-{'query','budget','trace','freshnessWaitMs'}:
84
+ raise ValueError('Unknown fields')
85
+ query,budget = body.get('query'),body.get('budget',4000)
86
+ wait_ms = body.get('freshnessWaitMs', 30000)
87
+ if type(wait_ms) is not int or not 0 <= wait_ms <= 120000:
88
+ raise ValueError('Invalid freshness wait')
89
+ if not isinstance(query,str) or not query.strip() or len(query)>8192 or type(budget) is not int or not 256<=budget<=8000 or type(body.get('trace',False)) is not bool:
90
+ raise ValueError('Invalid search input')
91
+ except (ValueError,TypeError):
92
+ return self.reply(422,{'error':'Expected query text and a token budget between 256 and 8000'})
93
+ start = time.monotonic()
94
+ if not lock.acquire(timeout=5):
95
+ return self.reply(429,{'error':'Retrieval worker busy'})
96
+ try:
97
+ queued = round((time.monotonic()-start)*1000)
98
+ generation = live.current(wait_ms/1000) if live else None
99
+ engine = generation.engine if live else retrieval
100
+ if engine is None:
101
+ raw,debug = '', {'tokens':0,'elapsedMs':0}
102
+ else:
103
+ raw,debug = engine.search(plan_query(query),budget=budget)
104
+ if live:
105
+ live.verify(generation)
106
+ response = {'context':raw,'tokens':debug['tokens'],'engine':VERSION,
107
+ 'retrievalMs':debug['elapsedMs'],'queueMs':queued,
108
+ 'serverElapsedMs':round((time.monotonic()-start)*1000),'queryCache':False,
109
+ 'index': {'mode':'live','identity':generation.identity,'freshness':'verified-after-search',
110
+ 'completedAt':generation.info['completedAt']} if live else {'mode':'frozen'}}
111
+ if body.get('trace'):
112
+ response['diagnostics'] = debug
113
+ self.reply(200,response)
114
+ except IndexUnavailable as error:
115
+ self.reply(503,{'error':str(error),'index':live.status()})
116
+ except Exception as error:
117
+ print(json.dumps({'event':'search-failed','type':type(error).__name__}),flush=True)
118
+ self.reply(502,{'error':'Retrieval or model request failed'})
119
+ finally:
120
+ lock.release()
121
+
122
+ server = ThreadingHTTPServer(('127.0.0.1',config.get('port',23505)),Handler)
123
+ server.daemon_threads = True
124
+ print(json.dumps({'listening':f'http://127.0.0.1:{server.server_port}',
125
+ 'health':{'status':'running','mode':'live'} if live else health}),flush=True)
126
+ try:
127
+ server.serve_forever()
128
+ finally:
129
+ server.server_close()
130
+ if live:
131
+ live.close()
132
+
133
+
134
+ if __name__ == '__main__':
135
+ serve(json.loads(sys.stdin.readline()))
package/src/client.mjs ADDED
@@ -0,0 +1,37 @@
1
+ import { loadEnvironment } from './config.mjs';
2
+
3
+ export function clientConfig(environment=process.env) {
4
+ const env=loadEnvironment(environment);
5
+ const url=new URL(env.OCE_BASE_URL || 'http://127.0.0.1:45005');
6
+ if (url.username || url.password || url.search || url.hash ||
7
+ !(url.protocol==='https:' || url.protocol==='http:' && url.hostname==='127.0.0.1')) throw new Error('Use HTTPS or an explicit loopback SSH forward');
8
+ const apiKey=env.OCE_API_KEY || env.RERANK_API_KEY;
9
+ if (!apiKey) throw new Error('Set OCE_API_KEY or the existing project reranker key');
10
+ return {baseUrl:url.href.replace(/\/$/,''),apiKey};
11
+ }
12
+
13
+ export async function search(query,{budget=4000,trace=false,freshnessWaitMs=30000,config=clientConfig(),signal}={}) {
14
+ const started=performance.now();
15
+ const timeout = AbortSignal.timeout(freshnessWaitMs + 30000);
16
+ const response=await fetch(`${config.baseUrl}/search`,{method:'POST',redirect:'error',signal:signal ? AbortSignal.any([signal,timeout]) : timeout,
17
+ headers:{'content-type':'application/json',authorization:`Bearer ${config.apiKey}`},body:JSON.stringify({query,budget,trace,freshnessWaitMs})});
18
+ if (!response.ok) {
19
+ if (response.status === 503) {
20
+ const body = await response.json();
21
+ throw new Error(`Index unavailable: ${body.error || 'update pending'}`);
22
+ }
23
+ throw new Error(`Retrieval HTTP ${response.status}`);
24
+ }
25
+ const result=await response.json();
26
+ if (typeof result.context!=='string' || !Number.isFinite(result.retrievalMs)) throw new Error('Invalid retrieval response');
27
+ return {...result,clientElapsedMs:Math.round(performance.now()-started)};
28
+ }
29
+
30
+ export async function indexStatus({config=clientConfig(),signal}={}) {
31
+ const timeout = AbortSignal.timeout(10000);
32
+ const response = await fetch(`${config.baseUrl}/status`, {redirect:'error',
33
+ signal:signal ? AbortSignal.any([signal,timeout]) : timeout,
34
+ headers:{authorization:`Bearer ${config.apiKey}`}});
35
+ if (!response.ok) throw new Error(`Retrieval HTTP ${response.status}`);
36
+ return response.json();
37
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,49 @@
1
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, rmSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { resolve, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { parseEnv } from 'node:util';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { normalizeEnvironment } from './environment.mjs';
8
+
9
+ export const projectRoot = fileURLToPath(new URL('../', import.meta.url));
10
+ export const configKeys = new Set([
11
+ 'EMBEDDING_BASE_URL','EMBEDDING_API_KEY','EMBEDDING_MODEL','EMBEDDING_SSH_TUNNEL_URL','EMBEDDING_SSH_REMOTE',
12
+ 'RERANK_BASE_URL','RERANK_API_KEY','RERANK_MODEL','RERANK_REMOTE_RUNTIME_URL','RERANK_REMOTE_HOST',
13
+ 'RERANK_SSH_TUNNEL_URL','RERANK_SSH_REMOTE',
14
+ 'OCE_ALLOW_HTTP','OCE_RERANK_API','OCE_RERANK_CONCURRENCY','OCE_RERANK_MAX_DOCUMENTS','OCE_EMBEDDING_DIMENSIONS',
15
+ 'OCE_EMBEDDING_REVISION','OCE_PYTHON','OCE_GO_BINARY','OCE_LANGUAGE_OPTIONS','OCE_POLL_SECONDS',
16
+ 'OCE_DEBOUNCE_SECONDS','OCE_API_KEY','OCE_BASE_URL','TIKTOKEN_CACHE_DIR',
17
+ ]);
18
+ export function configDirectory(environment = process.env) {
19
+ return resolve(environment.OCE_CONFIG_HOME || join(homedir(), '.config', 'opencontextengine'));
20
+ }
21
+ export function readUserConfig(environment = process.env) {
22
+ const path = join(configDirectory(environment), 'config.json');
23
+ if (!existsSync(path)) return {};
24
+ let data;
25
+ try {data = JSON.parse(readFileSync(path, 'utf8'));} catch {throw new Error(`Invalid OpenContextEngine configuration: ${path}`);}
26
+ if (data?.schemaVersion !== 1 || !data.env || typeof data.env !== 'object' || Array.isArray(data.env)
27
+ || Object.entries(data.env).some(([key,value]) => !configKeys.has(key) || typeof value !== 'string')) {
28
+ throw new Error(`Invalid OpenContextEngine configuration: ${path}`);
29
+ }
30
+ return data.env;
31
+ }
32
+ export function loadEnvironment(environment = process.env, checkout = projectRoot) {
33
+ const file = join(checkout, '.env');
34
+ return {...normalizeEnvironment(existsSync(file) ? parseEnv(readFileSync(file,'utf8')) : {}),
35
+ ...readUserConfig(environment),
36
+ ...normalizeEnvironment(environment)};
37
+ }
38
+ export function saveUserConfig(values, environment = process.env) {
39
+ const directory = configDirectory(environment), path = join(directory,'config.json');
40
+ const env = Object.fromEntries(Object.entries(values).filter(([key]) => configKeys.has(key)));
41
+ if (Object.values(env).some(value => typeof value !== 'string')) throw new Error('Configuration values must be strings');
42
+ mkdirSync(directory, {recursive:true,mode:0o700});
43
+ const temporary = join(directory, `.config-${randomUUID()}.tmp`);
44
+ try {
45
+ writeFileSync(temporary, JSON.stringify({schemaVersion:1,env},null,2)+'\n', {flag:'wx',mode:0o600});
46
+ renameSync(temporary,path);
47
+ } finally {rmSync(temporary,{force:true});}
48
+ return path;
49
+ }
@@ -0,0 +1,10 @@
1
+ // Keep existing deployments working while publishing only the OCE_* interface.
2
+ export function normalizeEnvironment(environment) {
3
+ const result = {...environment};
4
+ for (const [key,value] of Object.entries(environment)) {
5
+ if (!key.startsWith('REPONERVE_')) continue;
6
+ const current = 'OCE_' + key.slice('REPONERVE_'.length);
7
+ if (!Object.hasOwn(environment,current)) result[current] = value;
8
+ }
9
+ return result;
10
+ }
@@ -0,0 +1,70 @@
1
+ import os from 'node:os';
2
+
3
+ // User requirement: model inference must run on a separately authorized server.
4
+ export function remoteModelUrl(value, {allowHttp = false} = {}) {
5
+ const url = new URL(value);
6
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
7
+ const local = new Set(['localhost', 'localhost.localdomain', '0.0.0.0', '::', '::1', os.hostname().toLowerCase()]);
8
+ for (const addresses of Object.values(os.networkInterfaces())) {
9
+ for (const address of addresses ?? []) local.add(address.address.toLowerCase());
10
+ }
11
+ if (!(url.protocol === 'https:' || (allowHttp && url.protocol === 'http:')) || url.username || url.password || url.search || url.hash ||
12
+ local.has(host) || /^127\./.test(host) || host.endsWith('.localhost') || host.endsWith('.local')) {
13
+ throw new Error('Model endpoint must be remote HTTPS (or explicitly enable OCE_ALLOW_HTTP=1); local model inference is prohibited');
14
+ }
15
+ return url.href.replace(/\/$/, '');
16
+ }
17
+
18
+ export function remoteRerankerConfig(env) {
19
+ if (!env.RERANK_BASE_URL || !env.RERANK_MODEL || !env.RERANK_API_KEY) {
20
+ throw new Error('A user-approved remote reranker is required. No local model will be installed or started.');
21
+ }
22
+ const api = env.OCE_RERANK_API ?? 'rerank';
23
+ if (!['rerank', 'rerank-batch'].includes(api)) throw new Error('OCE_RERANK_API must be rerank or rerank-batch');
24
+ const integer = (name, fallback, maximum) => {
25
+ const value = Number(env[name] ?? fallback);
26
+ if (!Number.isInteger(value) || value < 1 || value > maximum) throw new Error(`${name} must be an integer from 1 to ${maximum}`);
27
+ return value;
28
+ };
29
+ return { baseUrl: remoteModelUrl(env.RERANK_BASE_URL, {allowHttp:env.OCE_ALLOW_HTTP === '1'}), model: env.RERANK_MODEL, apiKey: env.RERANK_API_KEY,
30
+ api, concurrency: integer('OCE_RERANK_CONCURRENCY', 2, 8),
31
+ maxDocuments: integer('OCE_RERANK_MAX_DOCUMENTS', 128, 1024) };
32
+ }
33
+
34
+ // An explicitly named authorized Linux worker can reach its own remote GPU
35
+ // service without a round trip through the public HTTPS proxy. This does not
36
+ // permit launching a model process, nor accepting a loopback model on the Mac.
37
+ export function rerankerExecutionTransport(env, host = { platform: process.platform, hostname: os.hostname() }) {
38
+ const baseUrl = remoteModelUrl(env.RERANK_BASE_URL, {allowHttp:env.OCE_ALLOW_HTTP === '1'});
39
+ if (env.RERANK_SSH_TUNNEL_URL) {
40
+ if (env.RERANK_REMOTE_RUNTIME_URL) throw new Error('Choose either SSH forwarding or same-host reranker transport');
41
+ return sshForward(baseUrl, env.RERANK_SSH_TUNNEL_URL, env.RERANK_SSH_REMOTE, new URL(baseUrl).pathname);
42
+ }
43
+ if (!env.RERANK_REMOTE_RUNTIME_URL) return { baseUrl, requestBaseUrl: baseUrl, transport: new URL(baseUrl).protocol.slice(0,-1) };
44
+ const url = new URL(env.RERANK_REMOTE_RUNTIME_URL);
45
+ if (host.platform !== 'linux' || env.RERANK_REMOTE_HOST !== host.hostname ||
46
+ url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port ||
47
+ url.pathname !== '/v1' || url.username || url.password || url.search || url.hash) {
48
+ throw new Error('Direct GPU transport requires the explicitly named remote Linux execution host');
49
+ }
50
+ return { baseUrl, requestBaseUrl: url.href, transport: 'authorized-remote-host-loopback', executionHost: host.hostname };
51
+ }
52
+
53
+ // Explicit SSH forwarding reaches a remote service; no local model is loaded.
54
+ // Cache identity remains the logical provider URL when transport changes.
55
+ export function embeddingTransportConfig(env) {
56
+ const baseUrl = remoteModelUrl(env.EMBEDDING_BASE_URL, {allowHttp:env.OCE_ALLOW_HTTP === '1'});
57
+ if (!env.EMBEDDING_SSH_TUNNEL_URL) return { baseUrl, requestBaseUrl: baseUrl, transport: new URL(baseUrl).protocol.slice(0,-1) };
58
+ return sshForward(baseUrl, env.EMBEDDING_SSH_TUNNEL_URL, env.EMBEDDING_SSH_REMOTE, '/v1');
59
+ }
60
+
61
+ function sshForward(baseUrl, value, remote, path) {
62
+ const tunnel = new URL(value);
63
+ if (tunnel.protocol !== 'http:' || tunnel.hostname !== '127.0.0.1' || !tunnel.port ||
64
+ tunnel.username || tunnel.password || tunnel.search || tunnel.hash ||
65
+ tunnel.pathname.replace(/\/$/, '') !== path.replace(/\/$/, '') ||
66
+ !remote || !/^[a-zA-Z0-9_.-]+@[a-zA-Z0-9.-]+:[0-9]+$/.test(remote)) {
67
+ throw new Error('SSH model transport requires an explicit loopback forward and remote SSH authority');
68
+ }
69
+ return { baseUrl, requestBaseUrl: tunnel.href.replace(/\/$/, ''), transport: 'ssh-tunnel', remote };
70
+ }
package/src/mcp.mjs ADDED
@@ -0,0 +1,58 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import { search, indexStatus } from './client.mjs';
4
+
5
+ export function createMcpServer(config, {resolveConfig, automatic = false} = {}) {
6
+ const workspaceInstructions = automatic
7
+ ? 'Pass directory_path as the absolute directory of the project the user is working on with every tool call. '
8
+ + 'Use the workspace path supplied by your host or inspect the current project directory; do not guess it. '
9
+ + 'A new project is indexed on first access. The same server can search multiple projects independently. '
10
+ : 'This server searches one configured repository. ';
11
+ async function selectConfig(directoryPath) {
12
+ if (resolveConfig) return resolveConfig(directoryPath);
13
+ if (directoryPath !== undefined) throw new Error('Connected-service mode uses its configured repository; omit directory_path');
14
+ return config;
15
+ }
16
+ const pathSchema = z.string().min(1).describe('Absolute path to the project directory. Required in automatic workspace mode.');
17
+ const directoryPath = automatic ? pathSchema : pathSchema.optional();
18
+ const server = new McpServer({name:'open-context-engine',version:'0.1.0'}, {
19
+ instructions:workspaceInstructions + 'Search for source evidence. Results include source paths and line numbers. '
20
+ + 'Search waits for saved file changes to be indexed. If an update is pending or fails, inspect index_status and retry after it completes. '
21
+ + 'Read target files again before editing, because code may change after a search.',
22
+ });
23
+ const annotations = {readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false};
24
+ server.registerTool('search_code', {
25
+ title:'Search repository source',
26
+ description:'Find code implementing a task or behavior in a repository. Returns source evidence under a token budget. '
27
+ + workspaceInstructions
28
+ + 'Uses saved working-tree content, including uncommitted changes; rejects stale results when synchronization fails.',
29
+ inputSchema:{directory_path:directoryPath,query:z.string().trim().min(1).max(8192),budget:z.number().int().min(256).max(8000).default(4000),
30
+ freshnessWaitMs:z.number().int().min(0).max(120000).default(30000)},
31
+ annotations,
32
+ }, async ({directory_path,query,budget,freshnessWaitMs}, extra) => {
33
+ try {
34
+ const selected = await selectConfig(directory_path);
35
+ const result = await search(query,{budget,freshnessWaitMs,config:selected,signal:extra.signal});
36
+ if (result.index?.mode !== 'live') throw new Error('This service uses a frozen index; connect to a service started with --root');
37
+ return {content:[{type:'text',text:result.context || 'No matching source context.'}],structuredContent:result};
38
+ } catch (error) {
39
+ return {isError:true,content:[{type:'text',text:error.message}]};
40
+ }
41
+ });
42
+ server.registerTool('index_status', {
43
+ title:'Repository index status',
44
+ description:'Inspect repository synchronization, current generation, embedding reuse and last update error. '
45
+ + workspaceInstructions
46
+ + 'Status is the latest background observation; search_code actively checks source freshness.',
47
+ inputSchema:{directory_path:directoryPath},annotations,
48
+ }, async ({directory_path},extra) => {
49
+ try {
50
+ const selected = await selectConfig(directory_path);
51
+ const result = await indexStatus({config:selected,signal:extra.signal});
52
+ return {content:[{type:'text',text:JSON.stringify(result)}],structuredContent:result};
53
+ } catch (error) {
54
+ return {isError:true,content:[{type:'text',text:error.message}]};
55
+ }
56
+ });
57
+ return server;
58
+ }
@@ -0,0 +1,220 @@
1
+ """Compile the reference retrieval DAG into two batched neural scoring waves.
2
+
3
+ Candidate retention reserves distinct structural neighbors before neural scoring.
4
+ Scoring uses ordinary rerank calls or optional multi-query batches. Duplicate
5
+ pairs are reused only within one request. No response or cross-query cache.
6
+ """
7
+ from collections import defaultdict
8
+ import time
9
+ import numpy as np
10
+
11
+ from engine import Engine, document, post
12
+ from reranker import rerank_pairs
13
+
14
+ VERSION = 'batched-dag-v6'
15
+
16
+
17
+ class BatchedEngine(Engine):
18
+ def __init__(self, *args, **kwargs):
19
+ super().__init__(*args, **kwargs)
20
+ self.structural_neighbors = [dict() for _ in self.units]
21
+ for unit in self.units:
22
+ for relation in unit.get('relations', []):
23
+ target = relation['target']
24
+ kind = relation['kind']
25
+ # Follow execution and snippet completeness, not broad type/import hubs.
26
+ supported = kind in {'calls', 'same_symbol'} or (
27
+ kind == 'member_of' and unit['kind'] in {'function', 'method'}
28
+ and self.units[target]['kind'] in {'function', 'method'})
29
+ if supported:
30
+ for source, neighbor in ((unit['id'], target), (target, unit['id'])):
31
+ edges = self.structural_neighbors[source]
32
+ edges[neighbor] = max(edges.get(neighbor, 0), relation['confidence'])
33
+ self.context_bundles = self._context_bundles()
34
+
35
+ def _context_bundles(self):
36
+ """Keep small callable bodies and their bounded caller context together."""
37
+ by_symbol = defaultdict(list)
38
+ for unit in self.units:
39
+ by_symbol[unit['symbol']].append(unit['id'])
40
+ owners = []
41
+ for unit in self.units:
42
+ symbol, parent, seen = unit['symbol'], unit.get('owner'), set()
43
+ while parent in by_symbol and parent not in seen:
44
+ seen.add(parent)
45
+ ancestor = self.units[by_symbol[parent][0]]
46
+ if ancestor['kind'] not in {'function', 'method'}:
47
+ break
48
+ symbol, parent = parent, ancestor.get('owner')
49
+ owners.append(symbol)
50
+ families = defaultdict(list)
51
+ for uid, symbol in enumerate(owners):
52
+ families[symbol].append(uid)
53
+ small = {symbol: ids for symbol, ids in families.items()
54
+ if self.units[by_symbol[symbol][0]]['kind'] in {'function', 'method'}
55
+ and sum(self.costs[uid] for uid in ids) <= 256}
56
+ callers = defaultdict(set)
57
+ for unit in self.units:
58
+ for relation in unit.get('relations', []):
59
+ source, target = owners[unit['id']], owners[relation['target']]
60
+ if relation['kind'] == 'calls' and source != target:
61
+ callers[target].add(source)
62
+ bundles = []
63
+ for unit in self.units:
64
+ root = owners[unit['id']]
65
+ if root not in small:
66
+ bundles.append([unit['id']]); continue
67
+ included, frontier = {root}, [root]
68
+ total = sum(self.costs[uid] for uid in small[root])
69
+ # Bounded source-only context, never a substitute for task labels.
70
+ for _ in range(2):
71
+ following = []
72
+ for symbol in frontier:
73
+ if len(callers[symbol]) > 4:
74
+ continue
75
+ for caller in sorted(callers[symbol], key=lambda s: families[s][0]):
76
+ if caller in included or caller not in small:
77
+ continue
78
+ cost = sum(self.costs[uid] for uid in small[caller])
79
+ if total + cost <= 768:
80
+ total += cost; included.add(caller); following.append(caller)
81
+ frontier = following
82
+ bundles.append(sorted(uid for symbol in included for uid in small[symbol]))
83
+ return bundles
84
+
85
+ def retain_candidates(self, ranked, expanded, facet_scores, fused, limit=80, graph_slots=16):
86
+ core = ranked[:limit - graph_slots]
87
+ core_set = set(core)
88
+ support = {}
89
+ # An unscored neighbor inherits evidence from its scored anchor. Requiring
90
+ # the neighbor's own first-wave score would discard the point of expansion.
91
+ for anchor in core:
92
+ score = max(scores.get(anchor, 0) for scores in facet_scores)
93
+ neighbors = {uid: confidence for uid, confidence in self.structural_neighbors[anchor].items()
94
+ if uid not in core_set}
95
+ for uid, confidence in neighbors.items():
96
+ support[uid] = max(support.get(uid, 0), score * confidence / len(neighbors))
97
+ novel = (set(expanded) | set(support)) - core_set
98
+ graph = sorted(novel, key=lambda uid: (-support.get(uid, 0), -fused[uid], uid))[:graph_slots]
99
+ retained = list(dict.fromkeys(core + graph + ranked))[:limit]
100
+ return retained, {'core': core, 'graph': graph,
101
+ 'novelGraphCandidates': len(novel),
102
+ 'support': {uid: support.get(uid, 0) for uid in graph}}
103
+
104
+ def scoring_policy(self, cache, queries, second_wave):
105
+ return {}
106
+
107
+ def scoring_pairs(self, requested, queries, dense, policy):
108
+ return requested
109
+
110
+ def pair_score(self, cache, query, uid, queries, dense, policy):
111
+ return cache[(query, uid)]
112
+
113
+ def search(self, plan, budget=4000):
114
+ start = time.monotonic()
115
+ facets = plan['facets']
116
+ queries = [plan['intent']] + [f['question'] for f in facets]
117
+ unique = list(dict.fromkeys(queries))
118
+ embedded = post(self.embed_url+'/embeddings',{'model':self.embedding_model,
119
+ 'input':['Instruct: Retrieve source code implementing the requested behavior.\nQuery: '+q for q in unique]},self.embedding_key)
120
+ vectors = [r['embedding'] for r in sorted(embedded['data'],key=lambda r:r['index'])]
121
+ embedding_at = time.monotonic()
122
+ dense = self.vectors @ np.asarray([vectors[unique.index(q)] for q in queries],dtype=np.float32).T
123
+ pools, fused = [], defaultdict(float)
124
+ for col, query in enumerate(queries):
125
+ lexical = self.lexical(query + (' ' + ' '.join(facets[col-1]['terms']) if col else ''))
126
+ local = defaultdict(float)
127
+ for scores in (dense[:, col], lexical):
128
+ for rank, uid in enumerate(np.argsort(-scores)[:40]):
129
+ if scores[uid] > 0:
130
+ local[int(uid)] += 1 / (30 + rank)
131
+ pools.append(sorted(local,key=local.get,reverse=True)[:28])
132
+ for uid,score in local.items():
133
+ fused[uid] += score
134
+ recalled_at = time.monotonic()
135
+ pair_cache, waves = {}, []
136
+
137
+ def rank_wave(jobs):
138
+ requested = [(query,uid) for query,ids in jobs for uid in ids]
139
+ policy = self.scoring_policy(pair_cache, queries, bool(waves))
140
+ scoring = self.scoring_pairs(requested, queries, dense, policy)
141
+ needed = list(dict.fromkeys(pair for pair in scoring if pair not in pair_cache))
142
+ if needed:
143
+ query_list = list(dict.fromkeys(q for q,uid in needed))
144
+ ids = list(dict.fromkeys(uid for q,uid in needed))
145
+ qmap = {q:i for i,q in enumerate(query_list)}
146
+ imap = {uid:i for i,uid in enumerate(ids)}
147
+ pairs = [(qmap[q],imap[uid]) for q,uid in needed]
148
+ before = time.monotonic()
149
+ data = rerank_pairs(self.reranker, query_list,
150
+ [document(self.units[uid],5000) for uid in ids], pairs, post)
151
+ rows = data['results']
152
+ for row in rows:
153
+ value = row['relevance_score']
154
+ pair_cache[needed[row['index']]] = value
155
+ waves.append({'elapsedMs':round((time.monotonic()-before)*1000),
156
+ 'requests':data['meta']['request_count'],
157
+ 'modelMs':data.get('meta',{}).get('elapsed_ms'),'pairs':len(needed),
158
+ 'reusedPairs':len(requested)-len(needed),'inputTokens':data.get('usage',{}).get('input_tokens'),
159
+ 'maxBatchSize':data.get('meta',{}).get('max_batch_size'),
160
+ 'batchTokenBudget':data.get('meta',{}).get('batch_token_budget'), 'scoringPolicy': policy})
161
+ return [{uid:self.pair_score(pair_cache, query, uid, queries, dense, policy) for uid in ids} for query,ids in jobs]
162
+
163
+ facet_scores = rank_wave(list(zip(queries,pools)))
164
+ seeds = set()
165
+ for scores in facet_scores:
166
+ seeds.update(sorted(scores,key=scores.get,reverse=True)[:3])
167
+ candidates = set().union(*(set(s) for s in facet_scores))
168
+ expanded = set()
169
+ for uid in seeds:
170
+ neighbors = self.units[uid]['edges'] + self.incoming[uid]
171
+ expanded.update(sorted(set(neighbors),key=lambda x:fused[x],reverse=True)[:10])
172
+ candidates.update(expanded)
173
+ ranked = sorted(candidates,key=lambda uid:max(s.get(uid,0) for s in facet_scores)+min(.15,fused[uid]),reverse=True)
174
+ retained, retention = self.retain_candidates(ranked, expanded, facet_scores, fused)
175
+ expanded.update(retention['graph'])
176
+ candidates.update(retention['graph'])
177
+ jobs = [(plan['intent'],retained)] + [(facet['question'],retained) for facet in facets]
178
+ outputs = rank_wave(jobs)
179
+ overall = outputs[0]
180
+ for col,scores in enumerate(outputs[1:],1):
181
+ facet_scores[col].update(scores)
182
+ ranked_at = time.monotonic()
183
+ covered = np.zeros(len(facets))
184
+ selected, selected_set, spent, trace = [], set(), 0, []
185
+ available = set(retained)
186
+ while available:
187
+ choices = []
188
+ for uid in available:
189
+ bundle = [item for item in self.context_bundles[uid] if item not in selected_set]
190
+ cost = sum(self.costs[item] for item in bundle)
191
+ if spent+cost>budget:
192
+ continue
193
+ values = np.asarray([s.get(uid,0) for s in facet_scores[1:]])
194
+ gain = float(np.sum(values/(1+covered)))/len(facets)
195
+ gain = .7*gain+.3*overall.get(uid,0)
196
+ gain /= (max(120,cost)/300)**.35
197
+ choices.append((gain,uid,values,bundle,cost))
198
+ if not choices:
199
+ break
200
+ gain,uid,values,bundle,cost = max(choices,key=lambda item:(item[0],-item[1]))
201
+ if gain<getattr(self, 'min_gain', .015):
202
+ break
203
+ available.difference_update(bundle);selected.extend(bundle);selected_set.update(bundle)
204
+ spent+=cost;covered+=values
205
+ for item in bundle:
206
+ trace.append({'id':item,'overall':overall.get(item,0),
207
+ 'facets':[s.get(item,0) for s in facet_scores[1:]],
208
+ 'tokens':self.costs[item],'gain':gain,'graphExpanded':item in expanded,
209
+ 'selectionAnchor':uid,'contextOnly':item not in retained})
210
+ raw = '\n'.join(self.render(self.units[uid]) for uid in selected)
211
+ end = time.monotonic()
212
+ return raw,{'version':getattr(self, 'version', VERSION),'elapsedMs':round((end-start)*1000),'tokens':len(self.encoding.encode(raw)),
213
+ 'candidateCount':len(candidates),'rerankedCount':len(retained),'expandedCount':len(expanded),
214
+ 'retention':retention,
215
+ 'modelRequests':{'embedding':1,'rerank':sum(w['requests'] for w in waves)},
216
+ 'rerankApi':self.reranker.get('api','rerank'),'queryCache':False,'pairCacheScope':'one-search-only',
217
+ 'waves':waves,'timingMs':{'embedding':round((embedding_at-start)*1000),'recall':round((recalled_at-embedding_at)*1000),
218
+ 'rerank':sum(w['elapsedMs'] for w in waves),'rerankModel':sum(w['modelMs'] or 0 for w in waves),
219
+ 'graphAndBookkeeping':round((ranked_at-recalled_at)*1000)-sum(w['elapsedMs'] for w in waves),
220
+ 'selection':round((end-ranked_at)*1000)},'selected':trace,'plan':plan}