oioxo-mcp 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/README.md +57 -0
- package/dist/cli/agents.d.ts +9 -0
- package/dist/cli/agents.js +155 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +86 -0
- package/dist/cli/login.d.ts +1 -0
- package/dist/cli/login.js +125 -0
- package/dist/core/bm25.d.ts +31 -0
- package/dist/core/bm25.js +103 -0
- package/dist/core/capsule.d.ts +34 -0
- package/dist/core/capsule.js +50 -0
- package/dist/core/code-graph.d.ts +29 -0
- package/dist/core/code-graph.js +105 -0
- package/dist/core/files.d.ts +17 -0
- package/dist/core/files.js +88 -0
- package/dist/core/memory.d.ts +12 -0
- package/dist/core/memory.js +55 -0
- package/dist/core/skeleton.d.ts +21 -0
- package/dist/core/skeleton.js +106 -0
- package/dist/gate/account.d.ts +32 -0
- package/dist/gate/account.js +97 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +122 -0
- package/dist/test/core.test.d.ts +1 -0
- package/dist/test/core.test.js +109 -0
- package/dist/test/e2e.test.d.ts +1 -0
- package/dist/test/e2e.test.js +100 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# OIOXO — context engine for AI coding agents
|
|
2
|
+
|
|
3
|
+
OIOXO feeds Claude Code, GitHub Copilot, Cursor and any MCP-capable agent the **minimal relevant slice** of your codebase — the exact code in play plus the API surface of its real dependencies — instead of letting the agent read whole files. Typical result: the same answer for **10-20× fewer tokens**, so your API keys and subscriptions go much further.
|
|
4
|
+
|
|
5
|
+
Everything runs **100% on your device**. Your code is never uploaded — only the *count* of tokens you saved is reported to your OIOXO account for metering.
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g oioxo-mcp
|
|
11
|
+
|
|
12
|
+
oioxo-mcp login # connect your OIOXO account (opens the browser)
|
|
13
|
+
oioxo-mcp init # wire OIOXO into the agents in this project
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Restart your agent. From the next prompt on, it calls OIOXO before reading files.
|
|
17
|
+
|
|
18
|
+
`init` detects Claude Code (`.mcp.json`), VS Code / Copilot (`.vscode/mcp.json`) and Cursor (`.cursor/mcp.json`), and **merges** into existing configs — your other MCP servers are untouched.
|
|
19
|
+
|
|
20
|
+
## What the agent gets
|
|
21
|
+
|
|
22
|
+
| Tool | What it does |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `get_context` | The main event: BM25 retrieval + import-graph expansion → anchors in full, dependencies as signatures only. |
|
|
25
|
+
| `get_skeleton` | One file's API surface without implementation bodies. |
|
|
26
|
+
| `get_impact` | Blast radius: what a file imports, and everything that imports it. |
|
|
27
|
+
| `remember` / `recall` | Durable project memory in `.oioxo/` — shared with the OIOXO IDEs. |
|
|
28
|
+
|
|
29
|
+
## Plans
|
|
30
|
+
|
|
31
|
+
The context engine is part of your OIOXO account:
|
|
32
|
+
|
|
33
|
+
- **OIOXO Free** — a monthly saved-tokens allowance to feel the difference.
|
|
34
|
+
- **OIOXO Pro** — unlimited, across every agent and every project → [oioxo.com](https://oioxo.com/?upgrade=pro)
|
|
35
|
+
|
|
36
|
+
Plan checks are server-side; `oioxo-mcp status` shows your remaining allowance, plan and index stats.
|
|
37
|
+
|
|
38
|
+
## How it works
|
|
39
|
+
|
|
40
|
+
1. **Index** — your project is chunked and BM25-indexed in memory (~1500 files max, deps/build output excluded). No model, no GPU, instant and offline.
|
|
41
|
+
2. **Anchor** — a task query retrieves the highest-confidence chunks.
|
|
42
|
+
3. **Expand** — the import graph pulls in the files the anchors *actually depend on* (and their callers), rejecting files that merely share words.
|
|
43
|
+
4. **Compress** — anchors ship as full code, neighbors ship as signatures. That asymmetry is the saving.
|
|
44
|
+
|
|
45
|
+
## Commands
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
oioxo-mcp login connect your OIOXO account
|
|
49
|
+
oioxo-mcp init [--all] write agent MCP configs ( --all = every supported agent )
|
|
50
|
+
oioxo-mcp serve run the MCP server (agent configs call this)
|
|
51
|
+
oioxo-mcp status plan, savings and index stats
|
|
52
|
+
oioxo-mcp logout remove the stored credential
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
© OIOXO · [oioxo.com](https://oioxo.com)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface InitOptions {
|
|
2
|
+
/** Force-write the per-PROJECT files even when not detected. */
|
|
3
|
+
all?: boolean;
|
|
4
|
+
/** Custom server command (default: npx -y oioxo-mcp). */
|
|
5
|
+
command?: string;
|
|
6
|
+
/** Home dir override (tests). */
|
|
7
|
+
home?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function initAgents(root: string, opts?: InitOptions): Promise<void>;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `oioxo-mcp init` — agent auto-configuration. Detects which AI coding agents
|
|
3
|
+
* touch this project / machine and writes each one's MCP config so the OIOXO
|
|
4
|
+
* context engine is available the next time the agent starts. One install,
|
|
5
|
+
* OIOXO everywhere.
|
|
6
|
+
*
|
|
7
|
+
* Per-PROJECT files (the user chooses whether to commit them):
|
|
8
|
+
* Claude Code → .mcp.json { mcpServers }
|
|
9
|
+
* VS Code/Copilot → .vscode/mcp.json { servers } (VS Code's own key)
|
|
10
|
+
* Cursor → .cursor/mcp.json { mcpServers }
|
|
11
|
+
* Per-MACHINE files (written only when the tool is actually installed):
|
|
12
|
+
* Windsurf → ~/.codeium/windsurf/mcp_config.json { mcpServers }
|
|
13
|
+
* Gemini CLI → ~/.gemini/settings.json { mcpServers }
|
|
14
|
+
* Codex CLI → ~/.codex/config.toml [mcp_servers.oioxo]
|
|
15
|
+
* Existing files are MERGED, never clobbered — we only add/replace the "oioxo" entry.
|
|
16
|
+
*/
|
|
17
|
+
import { promises as fs } from 'node:fs';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import * as os from 'node:os';
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
const onPath = (cmd) => {
|
|
22
|
+
try {
|
|
23
|
+
const probe = process.platform === 'win32' ? 'where' : 'which';
|
|
24
|
+
return spawnSync(probe, [cmd], { stdio: 'ignore', timeout: 4000 }).status === 0;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const dirExists = async (p) => {
|
|
31
|
+
try {
|
|
32
|
+
return (await fs.stat(p)).isDirectory();
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const TARGETS = [
|
|
39
|
+
{
|
|
40
|
+
id: 'claude-code',
|
|
41
|
+
display: 'Claude Code',
|
|
42
|
+
kind: 'json',
|
|
43
|
+
key: 'mcpServers',
|
|
44
|
+
file: (root) => path.join(root, '.mcp.json'),
|
|
45
|
+
detect: async () => onPath('claude'),
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'vscode-copilot',
|
|
49
|
+
display: 'VS Code / GitHub Copilot',
|
|
50
|
+
kind: 'json',
|
|
51
|
+
key: 'servers',
|
|
52
|
+
file: (root) => path.join(root, '.vscode', 'mcp.json'),
|
|
53
|
+
detect: async (root) => (await dirExists(path.join(root, '.vscode'))) || onPath('code'),
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'cursor',
|
|
57
|
+
display: 'Cursor',
|
|
58
|
+
kind: 'json',
|
|
59
|
+
key: 'mcpServers',
|
|
60
|
+
file: (root) => path.join(root, '.cursor', 'mcp.json'),
|
|
61
|
+
detect: async (root) => (await dirExists(path.join(root, '.cursor'))) || onPath('cursor'),
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'windsurf',
|
|
65
|
+
display: 'Windsurf',
|
|
66
|
+
kind: 'json',
|
|
67
|
+
key: 'mcpServers',
|
|
68
|
+
global: true,
|
|
69
|
+
file: (_root, home) => path.join(home, '.codeium', 'windsurf', 'mcp_config.json'),
|
|
70
|
+
detect: async (_root, home) => dirExists(path.join(home, '.codeium', 'windsurf')),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 'gemini-cli',
|
|
74
|
+
display: 'Gemini CLI',
|
|
75
|
+
kind: 'json',
|
|
76
|
+
key: 'mcpServers',
|
|
77
|
+
global: true,
|
|
78
|
+
file: (_root, home) => path.join(home, '.gemini', 'settings.json'),
|
|
79
|
+
detect: async (_root, home) => dirExists(path.join(home, '.gemini')),
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: 'codex',
|
|
83
|
+
display: 'Codex CLI',
|
|
84
|
+
kind: 'toml',
|
|
85
|
+
global: true,
|
|
86
|
+
file: (_root, home) => path.join(home, '.codex', 'config.toml'),
|
|
87
|
+
detect: async (_root, home) => dirExists(path.join(home, '.codex')),
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
/** The server entry every host gets. `npx -y` keeps it version-current. */
|
|
91
|
+
function serverEntry(command) {
|
|
92
|
+
if (command) {
|
|
93
|
+
const [cmd, ...args] = command.split(' ');
|
|
94
|
+
return { command: cmd, args: [...args, 'serve'] };
|
|
95
|
+
}
|
|
96
|
+
return { command: 'npx', args: ['-y', 'oioxo-mcp', 'serve'] };
|
|
97
|
+
}
|
|
98
|
+
async function mergeWriteJson(file, key, entry) {
|
|
99
|
+
let existing = {};
|
|
100
|
+
try {
|
|
101
|
+
existing = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
102
|
+
}
|
|
103
|
+
catch { /* fresh file */ }
|
|
104
|
+
const section = existing[key] ?? {};
|
|
105
|
+
section['oioxo'] = entry;
|
|
106
|
+
existing[key] = section;
|
|
107
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
108
|
+
await fs.writeFile(file, JSON.stringify(existing, null, 2) + '\n', 'utf8');
|
|
109
|
+
}
|
|
110
|
+
/** Codex config is TOML — append our section if absent (never rewrite the file). */
|
|
111
|
+
async function appendToml(file, entry) {
|
|
112
|
+
let existing = '';
|
|
113
|
+
try {
|
|
114
|
+
existing = await fs.readFile(file, 'utf8');
|
|
115
|
+
}
|
|
116
|
+
catch { /* fresh file */ }
|
|
117
|
+
if (/^\s*\[mcp_servers\.oioxo\]/m.test(existing))
|
|
118
|
+
return false; // already wired — leave the user's edits alone
|
|
119
|
+
const block = `\n[mcp_servers.oioxo]\n` +
|
|
120
|
+
`command = "${entry.command}"\n` +
|
|
121
|
+
`args = [${entry.args.map((a) => `"${a}"`).join(', ')}]\n`;
|
|
122
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
123
|
+
await fs.writeFile(file, existing + block, 'utf8');
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
export async function initAgents(root, opts = {}) {
|
|
127
|
+
const home = opts.home ?? os.homedir();
|
|
128
|
+
const entry = serverEntry(opts.command);
|
|
129
|
+
let wrote = 0;
|
|
130
|
+
console.log('\nOIOXO — wiring the context engine into your agents');
|
|
131
|
+
for (const t of TARGETS) {
|
|
132
|
+
// --all forces project files; machine-level configs only when the tool exists.
|
|
133
|
+
const want = t.global ? await t.detect(root, home) : opts.all || (await t.detect(root, home));
|
|
134
|
+
if (!want) {
|
|
135
|
+
console.log(` · ${t.display}: not detected${t.global ? '' : ' (use --all to force)'}`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const file = t.file(root, home);
|
|
139
|
+
if (t.kind === 'toml') {
|
|
140
|
+
const added = await appendToml(file, entry);
|
|
141
|
+
console.log(added ? ` ✓ ${t.display}: ${file}` : ` ✓ ${t.display}: already configured (${file})`);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
await mergeWriteJson(file, t.key, entry);
|
|
145
|
+
console.log(` ✓ ${t.display}: ${t.global ? file : path.relative(root, file)}`);
|
|
146
|
+
}
|
|
147
|
+
wrote++;
|
|
148
|
+
}
|
|
149
|
+
if (!wrote) {
|
|
150
|
+
console.log(' No agents detected. Run with --all to write the project configs anyway.');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
console.log('\nRestart your agent (or reload the window) and it will call OIOXO before reading files.');
|
|
154
|
+
console.log('Not connected yet? Run `oioxo-mcp login`.');
|
|
155
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* OIOXO MCP CLI — the context engine for AI coding agents.
|
|
4
|
+
*
|
|
5
|
+
* oioxo-mcp login connect your OIOXO account (browser handoff)
|
|
6
|
+
* oioxo-mcp init [--all] wire OIOXO into the agents in this project
|
|
7
|
+
* oioxo-mcp serve run the MCP server (what agent configs invoke)
|
|
8
|
+
* oioxo-mcp status index + account + savings at a glance
|
|
9
|
+
* oioxo-mcp logout forget the stored credential
|
|
10
|
+
*/
|
|
11
|
+
import { login } from './login.js';
|
|
12
|
+
import { initAgents } from './agents.js';
|
|
13
|
+
import { startServer } from '../mcp/server.js';
|
|
14
|
+
import { loadProject } from '../core/files.js';
|
|
15
|
+
import { Bm25Index } from '../core/bm25.js';
|
|
16
|
+
import { readCredentials, clearCredentials, saveMeter, CONTROL_PLANE } from '../gate/account.js';
|
|
17
|
+
const HELP = `OIOXO — context engine for AI coding agents (https://oioxo.com)
|
|
18
|
+
|
|
19
|
+
Usage: oioxo-mcp <command>
|
|
20
|
+
|
|
21
|
+
Commands:
|
|
22
|
+
login Connect your OIOXO account (opens the browser)
|
|
23
|
+
init [--all] Write MCP configs for the agents in this project
|
|
24
|
+
(--all: write every supported agent; --command "<cmd>": custom server command)
|
|
25
|
+
serve Run the MCP server over stdio (agents call this; you rarely will)
|
|
26
|
+
status Show account tier, monthly savings and index stats
|
|
27
|
+
logout Remove the stored credential from this machine
|
|
28
|
+
`;
|
|
29
|
+
async function status(root) {
|
|
30
|
+
console.log('\nOIOXO status');
|
|
31
|
+
console.log(` Control plane : ${CONTROL_PLANE}`);
|
|
32
|
+
const creds = await readCredentials();
|
|
33
|
+
if (!creds || creds.expiresAt <= Date.now()) {
|
|
34
|
+
console.log(' Account : not connected — run `oioxo-mcp login`');
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
const days = Math.max(0, Math.round((creds.expiresAt - Date.now()) / 86_400_000));
|
|
38
|
+
console.log(` Account : connected (credential valid ~${days}d)`);
|
|
39
|
+
const s = await saveMeter('check');
|
|
40
|
+
if (s?.tier === 'pro')
|
|
41
|
+
console.log(' Plan : OIOXO Pro — unlimited context engine');
|
|
42
|
+
else if (s?.tier === 'free')
|
|
43
|
+
console.log(` Plan : OIOXO Free — ${s.remainingTokens?.toLocaleString() ?? 0} saved-tokens left this month (resets ${new Date(s.resetsAt).toUTCString()})`);
|
|
44
|
+
else if (s)
|
|
45
|
+
console.log(` Plan : ${s.tier}`);
|
|
46
|
+
else
|
|
47
|
+
console.log(' Plan : could not reach oioxo.com');
|
|
48
|
+
}
|
|
49
|
+
const files = await loadProject(root);
|
|
50
|
+
const idx = new Bm25Index();
|
|
51
|
+
const st = idx.build(files);
|
|
52
|
+
console.log(` Index : ${st.files} files, ${st.chunks} chunks (in-memory, on-device)`);
|
|
53
|
+
}
|
|
54
|
+
async function main() {
|
|
55
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
56
|
+
const root = process.cwd();
|
|
57
|
+
switch (cmd) {
|
|
58
|
+
case 'login':
|
|
59
|
+
await login();
|
|
60
|
+
return;
|
|
61
|
+
case 'logout':
|
|
62
|
+
await clearCredentials();
|
|
63
|
+
console.log('OIOXO credential removed from this machine.');
|
|
64
|
+
return;
|
|
65
|
+
case 'init': {
|
|
66
|
+
const all = rest.includes('--all');
|
|
67
|
+
const ci = rest.indexOf('--command');
|
|
68
|
+
const command = ci >= 0 ? rest[ci + 1] : undefined;
|
|
69
|
+
await initAgents(root, { all, command });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
case 'serve':
|
|
73
|
+
// stdio belongs to the MCP transport from here on — no console output.
|
|
74
|
+
await startServer(root);
|
|
75
|
+
return;
|
|
76
|
+
case 'status':
|
|
77
|
+
await status(root);
|
|
78
|
+
return;
|
|
79
|
+
default:
|
|
80
|
+
console.log(HELP);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
main().catch((err) => {
|
|
84
|
+
console.error(`oioxo-mcp: ${err instanceof Error ? err.message : String(err)}`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function login(): Promise<void>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `oioxo-mcp login` — loopback browser handoff (the `gh auth login` pattern).
|
|
3
|
+
*
|
|
4
|
+
* 1. Start a one-shot HTTP listener on 127.0.0.1:<random port>.
|
|
5
|
+
* 2. Open https://oioxo.com/cli-auth?port=<port>&state=<nonce> in the browser.
|
|
6
|
+
* 3. The signed-in user clicks Authorize there; the page mints a 30-day device
|
|
7
|
+
* bearer via /api/auth/device-token and POSTs it to the listener with the
|
|
8
|
+
* state nonce echoed. Wrong/missing nonce → rejected.
|
|
9
|
+
* 4. Store the bearer in ~/.oioxo/credentials.json (0600).
|
|
10
|
+
*
|
|
11
|
+
* The token only ever travels oioxo.com → this machine's loopback. CORS is
|
|
12
|
+
* scoped to the OIOXO origin so no other site can talk to the listener.
|
|
13
|
+
*/
|
|
14
|
+
import * as http from 'node:http';
|
|
15
|
+
import { randomBytes } from 'node:crypto';
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
import { CONTROL_PLANE, writeCredentials, deviceId, saveMeter } from '../gate/account.js';
|
|
18
|
+
const LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
19
|
+
// Served by the loopback listener after a successful handoff — the tab the
|
|
20
|
+
// user lands on. Same restrained OIOXO look: white, one gold accent.
|
|
21
|
+
const SUCCESS_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>OIOXO CLI connected</title></head>
|
|
22
|
+
<body style="margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#fafafa;font-family:ui-sans-serif,system-ui,Segoe UI,sans-serif">
|
|
23
|
+
<div style="background:#fff;border-radius:16px;box-shadow:0 10px 30px rgba(0,0,0,.08);padding:28px;max-width:380px">
|
|
24
|
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:18px">
|
|
25
|
+
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#E2B24A"></span>
|
|
26
|
+
<span style="font-family:ui-monospace,monospace;font-size:13px;font-weight:500;color:#232327">OIOXO</span></div>
|
|
27
|
+
<h1 style="font-size:17px;margin:0 0 8px;color:#232327">OIOXO CLI connected</h1>
|
|
28
|
+
<p style="font-size:13.5px;line-height:1.6;color:#71717a;margin:0">You can close this tab and return to your terminal.</p>
|
|
29
|
+
</div></body></html>`;
|
|
30
|
+
function openBrowser(url) {
|
|
31
|
+
const platform = process.platform;
|
|
32
|
+
// `start` must go through cmd on Windows; '' is the window-title slot.
|
|
33
|
+
if (platform === 'win32')
|
|
34
|
+
spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' }).unref();
|
|
35
|
+
else if (platform === 'darwin')
|
|
36
|
+
spawn('open', [url], { detached: true, stdio: 'ignore' }).unref();
|
|
37
|
+
else
|
|
38
|
+
spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();
|
|
39
|
+
}
|
|
40
|
+
export async function login() {
|
|
41
|
+
const state = randomBytes(24).toString('base64url');
|
|
42
|
+
const device = await deviceId();
|
|
43
|
+
const tokenPromise = new Promise((resolve, reject) => {
|
|
44
|
+
const server = http.createServer((req, res) => {
|
|
45
|
+
const allowOrigin = new URL(CONTROL_PLANE).origin;
|
|
46
|
+
res.setHeader('Access-Control-Allow-Origin', allowOrigin);
|
|
47
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
48
|
+
res.setHeader('Access-Control-Allow-Headers', 'content-type');
|
|
49
|
+
// Chrome Private Network Access: a public https page fetching loopback
|
|
50
|
+
// must see this on the preflight, or the request is blocked.
|
|
51
|
+
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
52
|
+
if (req.method === 'OPTIONS') {
|
|
53
|
+
res.writeHead(204).end();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (req.method !== 'POST' || req.url !== '/oioxo/token') {
|
|
57
|
+
res.writeHead(404).end();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
let body = '';
|
|
61
|
+
req.on('data', (c) => { body += c; if (body.length > 64_000)
|
|
62
|
+
req.destroy(); });
|
|
63
|
+
req.on('end', () => {
|
|
64
|
+
try {
|
|
65
|
+
// The page delivers via a top-level form POST (urlencoded) — fetch() to
|
|
66
|
+
// loopback is blocked by Chrome's Local Network Access. JSON kept for
|
|
67
|
+
// forward-compat with clients that may hold the LNA permission.
|
|
68
|
+
const ct = req.headers['content-type'] ?? '';
|
|
69
|
+
let j;
|
|
70
|
+
if (ct.includes('application/json')) {
|
|
71
|
+
j = JSON.parse(body);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
const p = new URLSearchParams(body);
|
|
75
|
+
j = {
|
|
76
|
+
state: p.get('state') ?? undefined,
|
|
77
|
+
token: p.get('token') ?? undefined,
|
|
78
|
+
expiresInSeconds: p.get('expiresInSeconds') ? Number(p.get('expiresInSeconds')) : undefined,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (j.state !== state || !j.token) {
|
|
82
|
+
res.writeHead(400, { 'content-type': 'text/html' }).end('<p>OIOXO: invalid or expired login request. Run <code>oioxo-mcp login</code> again.</p>');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
res.writeHead(200, { 'content-type': 'text/html' }).end(SUCCESS_HTML);
|
|
86
|
+
server.close();
|
|
87
|
+
resolve({ token: j.token, expiresInSeconds: j.expiresInSeconds });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
res.writeHead(400).end();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
server.on('error', reject);
|
|
95
|
+
server.listen(0, '127.0.0.1', () => {
|
|
96
|
+
const addr = server.address();
|
|
97
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
98
|
+
const url = `${CONTROL_PLANE}/cli-auth?port=${port}&state=${state}`;
|
|
99
|
+
console.log('\nOIOXO — connect your account');
|
|
100
|
+
console.log(`Opening ${url}`);
|
|
101
|
+
console.log('If the browser does not open, paste the URL yourself.\n');
|
|
102
|
+
// OIOXO_NO_BROWSER=1: print the URL only (CI / e2e / remote shells).
|
|
103
|
+
if (process.env.OIOXO_NO_BROWSER !== '1')
|
|
104
|
+
openBrowser(url);
|
|
105
|
+
});
|
|
106
|
+
setTimeout(() => { server.close(); reject(new Error('timeout')); }, LOGIN_TIMEOUT_MS).unref();
|
|
107
|
+
});
|
|
108
|
+
const { token, expiresInSeconds } = await tokenPromise;
|
|
109
|
+
await writeCredentials({
|
|
110
|
+
token,
|
|
111
|
+
expiresAt: Date.now() + (expiresInSeconds ?? 30 * 24 * 3600) * 1000,
|
|
112
|
+
device,
|
|
113
|
+
});
|
|
114
|
+
const stateNow = await saveMeter('check');
|
|
115
|
+
if (stateNow?.tier === 'pro') {
|
|
116
|
+
console.log('Connected — OIOXO Pro: unlimited context engine. ✓');
|
|
117
|
+
}
|
|
118
|
+
else if (stateNow?.tier === 'free') {
|
|
119
|
+
const rem = stateNow.remainingTokens?.toLocaleString() ?? '—';
|
|
120
|
+
console.log(`Connected — OIOXO Free: ${rem} saved-tokens remaining this month. Pro is unlimited → https://oioxo.com/?upgrade=pro ✓`);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
console.log('Connected. ✓');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIOXO BM25 lexical index — ported from the desktop IDE's on-device @codebase
|
|
3
|
+
* engine (oioxoCodebaseIndexService). BM25, not embeddings, is the deliberate
|
|
4
|
+
* choice: no model, no GPU, instant + offline, and for code (identifiers,
|
|
5
|
+
* symbols, error strings) lexical match is strong.
|
|
6
|
+
*
|
|
7
|
+
* Chunking: ~60-line windows with 12-line overlap so a symbol near a boundary
|
|
8
|
+
* still appears whole in at least one chunk.
|
|
9
|
+
*/
|
|
10
|
+
import type { ProjectFile } from './files.js';
|
|
11
|
+
export interface RetrievedChunk {
|
|
12
|
+
path: string;
|
|
13
|
+
startLine: number;
|
|
14
|
+
endLine: number;
|
|
15
|
+
text: string;
|
|
16
|
+
score: number;
|
|
17
|
+
}
|
|
18
|
+
export interface IndexStatus {
|
|
19
|
+
files: number;
|
|
20
|
+
chunks: number;
|
|
21
|
+
}
|
|
22
|
+
export declare function tokenize(s: string): string[];
|
|
23
|
+
export declare class Bm25Index {
|
|
24
|
+
private chunks;
|
|
25
|
+
private df;
|
|
26
|
+
private avgLen;
|
|
27
|
+
private _status;
|
|
28
|
+
status(): IndexStatus;
|
|
29
|
+
build(files: ProjectFile[]): IndexStatus;
|
|
30
|
+
retrieve(query: string, k?: number): RetrievedChunk[];
|
|
31
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const CHUNK_LINES = 60;
|
|
2
|
+
const CHUNK_OVERLAP = 12;
|
|
3
|
+
const MAX_CHUNK_CHARS = 4000;
|
|
4
|
+
export function tokenize(s) {
|
|
5
|
+
// Split identifiers too: camelCase / snake_case / kebab → sub-tokens, so a
|
|
6
|
+
// query "user auth" matches "getUserAuthToken".
|
|
7
|
+
const raw = s.toLowerCase().match(/[a-z0-9_]+/g) ?? [];
|
|
8
|
+
const out = [];
|
|
9
|
+
for (const t of raw) {
|
|
10
|
+
out.push(t);
|
|
11
|
+
for (const part of t.split('_'))
|
|
12
|
+
if (part && part !== t)
|
|
13
|
+
out.push(part);
|
|
14
|
+
}
|
|
15
|
+
for (const m of s.match(/[A-Za-z][a-z0-9]+|[A-Z]+(?![a-z])/g) ?? []) {
|
|
16
|
+
const low = m.toLowerCase();
|
|
17
|
+
if (low.length > 1)
|
|
18
|
+
out.push(low);
|
|
19
|
+
}
|
|
20
|
+
return out.filter((t) => t.length > 1 && t.length < 40);
|
|
21
|
+
}
|
|
22
|
+
export class Bm25Index {
|
|
23
|
+
chunks = [];
|
|
24
|
+
df = new Map();
|
|
25
|
+
avgLen = 0;
|
|
26
|
+
_status = { files: 0, chunks: 0 };
|
|
27
|
+
status() {
|
|
28
|
+
return this._status;
|
|
29
|
+
}
|
|
30
|
+
build(files) {
|
|
31
|
+
const chunks = [];
|
|
32
|
+
for (const f of files) {
|
|
33
|
+
const lines = f.content.split('\n');
|
|
34
|
+
for (let start = 0; start < lines.length; start += CHUNK_LINES - CHUNK_OVERLAP) {
|
|
35
|
+
const slice = lines.slice(start, start + CHUNK_LINES);
|
|
36
|
+
const chunkText = slice.join('\n').slice(0, MAX_CHUNK_CHARS);
|
|
37
|
+
if (chunkText.trim()) {
|
|
38
|
+
const tokens = tokenize(chunkText);
|
|
39
|
+
if (tokens.length) {
|
|
40
|
+
const tf = new Map();
|
|
41
|
+
for (const t of tokens)
|
|
42
|
+
tf.set(t, (tf.get(t) ?? 0) + 1);
|
|
43
|
+
chunks.push({
|
|
44
|
+
path: f.path,
|
|
45
|
+
startLine: start + 1,
|
|
46
|
+
endLine: Math.min(start + CHUNK_LINES, lines.length),
|
|
47
|
+
text: chunkText,
|
|
48
|
+
tf,
|
|
49
|
+
len: tokens.length,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (start + CHUNK_LINES >= lines.length)
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const df = new Map();
|
|
58
|
+
let totalLen = 0;
|
|
59
|
+
for (const c of chunks) {
|
|
60
|
+
totalLen += c.len;
|
|
61
|
+
for (const term of c.tf.keys())
|
|
62
|
+
df.set(term, (df.get(term) ?? 0) + 1);
|
|
63
|
+
}
|
|
64
|
+
this.chunks = chunks;
|
|
65
|
+
this.df = df;
|
|
66
|
+
this.avgLen = chunks.length ? totalLen / chunks.length : 0;
|
|
67
|
+
this._status = { files: files.length, chunks: chunks.length };
|
|
68
|
+
return this._status;
|
|
69
|
+
}
|
|
70
|
+
retrieve(query, k = 6) {
|
|
71
|
+
if (!this.chunks.length)
|
|
72
|
+
return [];
|
|
73
|
+
const qTerms = tokenize(query);
|
|
74
|
+
if (!qTerms.length)
|
|
75
|
+
return [];
|
|
76
|
+
const N = this.chunks.length;
|
|
77
|
+
const k1 = 1.5;
|
|
78
|
+
const b = 0.75;
|
|
79
|
+
const scored = this.chunks
|
|
80
|
+
.map((c) => {
|
|
81
|
+
let score = 0;
|
|
82
|
+
for (const term of qTerms) {
|
|
83
|
+
const f = c.tf.get(term);
|
|
84
|
+
if (!f)
|
|
85
|
+
continue;
|
|
86
|
+
const n = this.df.get(term) ?? 0;
|
|
87
|
+
const idf = Math.log(1 + (N - n + 0.5) / (n + 0.5));
|
|
88
|
+
const denom = f + k1 * (1 - b + b * (c.len / (this.avgLen || 1)));
|
|
89
|
+
score += idf * ((f * (k1 + 1)) / denom);
|
|
90
|
+
}
|
|
91
|
+
return { c, score };
|
|
92
|
+
})
|
|
93
|
+
.filter((s) => s.score > 0);
|
|
94
|
+
scored.sort((a, b2) => b2.score - a.score);
|
|
95
|
+
return scored.slice(0, k).map(({ c, score }) => ({
|
|
96
|
+
path: c.path,
|
|
97
|
+
startLine: c.startLine,
|
|
98
|
+
endLine: c.endLine,
|
|
99
|
+
text: c.text,
|
|
100
|
+
score,
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIOXO context capsule — the engine's main product. For a task/query it
|
|
3
|
+
* returns the MINIMAL relevant slice of the codebase:
|
|
4
|
+
*
|
|
5
|
+
* 1. BM25 retrieval picks high-confidence ANCHOR chunks (lexical, on-device);
|
|
6
|
+
* 2. the import graph EXPANDS anchors to the files they actually depend on
|
|
7
|
+
* (the Context Compiler pattern — rejects same-words-only decoys);
|
|
8
|
+
* 3. anchors ship as full excerpts, neighbors ship as SKELETONS (signatures,
|
|
9
|
+
* no bodies) — that asymmetry is where the token saving lives.
|
|
10
|
+
*
|
|
11
|
+
* `savedTokens` is the honest estimate of what the agent did NOT have to read:
|
|
12
|
+
* (full size of every file represented in the capsule − capsule size) / 4.
|
|
13
|
+
*/
|
|
14
|
+
import type { ProjectFile } from './files.js';
|
|
15
|
+
import { Bm25Index, type RetrievedChunk } from './bm25.js';
|
|
16
|
+
export interface Capsule {
|
|
17
|
+
/** Ready-to-inject context block. */
|
|
18
|
+
text: string;
|
|
19
|
+
/** Files represented (anchors first). */
|
|
20
|
+
files: string[];
|
|
21
|
+
/** Estimated tokens the agent avoided reading (capsule vs full files). */
|
|
22
|
+
savedTokens: number;
|
|
23
|
+
/** Raw anchor chunks (for callers that want structure). */
|
|
24
|
+
chunks: RetrievedChunk[];
|
|
25
|
+
}
|
|
26
|
+
export interface CapsuleOptions {
|
|
27
|
+
/** Max anchor chunks from BM25. */
|
|
28
|
+
topChunks?: number;
|
|
29
|
+
/** Max files in the expanded slice. */
|
|
30
|
+
maxFiles?: number;
|
|
31
|
+
/** Cap on the capsule text size (chars). */
|
|
32
|
+
maxChars?: number;
|
|
33
|
+
}
|
|
34
|
+
export declare function buildCapsule(query: string, files: ProjectFile[], index: Bm25Index, opts?: CapsuleOptions): Capsule;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { buildGraph, expandContext } from './code-graph.js';
|
|
2
|
+
import { fileSkeleton } from './skeleton.js';
|
|
3
|
+
const APPROX_CHARS_PER_TOKEN = 4;
|
|
4
|
+
export function buildCapsule(query, files, index, opts = {}) {
|
|
5
|
+
const topChunks = opts.topChunks ?? 6;
|
|
6
|
+
const maxFiles = opts.maxFiles ?? 10;
|
|
7
|
+
const maxChars = opts.maxChars ?? 24_000;
|
|
8
|
+
const byPath = new Map(files.map((f) => [f.path, f]));
|
|
9
|
+
const chunks = index.retrieve(query, topChunks);
|
|
10
|
+
// Anchors: distinct files behind the top chunks (order of first appearance).
|
|
11
|
+
const anchors = [];
|
|
12
|
+
for (const c of chunks)
|
|
13
|
+
if (!anchors.includes(c.path))
|
|
14
|
+
anchors.push(c.path);
|
|
15
|
+
// Graph expansion: the files the anchors actually import (+1 hop of callers).
|
|
16
|
+
let slice = anchors.slice(0, maxFiles);
|
|
17
|
+
try {
|
|
18
|
+
const graph = buildGraph(files);
|
|
19
|
+
slice = expandContext(graph, anchors.slice(0, 3), { hops: 2, includeDependents: true, max: maxFiles });
|
|
20
|
+
for (const a of anchors)
|
|
21
|
+
if (!slice.includes(a) && slice.length < maxFiles)
|
|
22
|
+
slice.push(a);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// graph is an optimization — never break retrieval
|
|
26
|
+
}
|
|
27
|
+
const parts = [];
|
|
28
|
+
const anchorSet = new Set(anchors);
|
|
29
|
+
// 1) Anchor chunks: the exact code in play, full text, with line refs.
|
|
30
|
+
for (const c of chunks) {
|
|
31
|
+
parts.push(`// ${c.path}:${c.startLine}-${c.endLine}\n${c.text}`);
|
|
32
|
+
}
|
|
33
|
+
// 2) Neighbor files: skeletons only.
|
|
34
|
+
for (const p of slice) {
|
|
35
|
+
if (anchorSet.has(p))
|
|
36
|
+
continue;
|
|
37
|
+
const f = byPath.get(p);
|
|
38
|
+
if (f)
|
|
39
|
+
parts.push(fileSkeleton(f.path, f.content));
|
|
40
|
+
}
|
|
41
|
+
let text = parts.join('\n\n');
|
|
42
|
+
if (text.length > maxChars)
|
|
43
|
+
text = text.slice(0, maxChars) + '\n// … capsule truncated';
|
|
44
|
+
// Savings estimate: full files the capsule REPRESENTS vs what it actually shipped.
|
|
45
|
+
let fullChars = 0;
|
|
46
|
+
for (const p of slice.length ? slice : anchors)
|
|
47
|
+
fullChars += byPath.get(p)?.content.length ?? 0;
|
|
48
|
+
const savedTokens = Math.max(0, Math.round((fullChars - text.length) / APPROX_CHARS_PER_TOKEN));
|
|
49
|
+
return { text, files: slice.length ? slice : anchors, savedTokens, chunks };
|
|
50
|
+
}
|