@orbit-intelligence/orbit-agent 0.3.14 → 0.3.16
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/dist/prompts/system.js +7 -0
- package/dist/src/cli/orchestrate.js +7 -1
- package/dist/src/cli/run.js +104 -10
- package/dist/src/core/agent/agent-loop.js +34 -7
- package/dist/src/core/context/context-manager.js +40 -6
- package/dist/src/core/llm/endpoints.js +110 -0
- package/dist/src/core/llm/index.js +16 -0
- package/dist/src/core/llm/sanitize.js +47 -0
- package/dist/src/core/llm/secrets.js +61 -3
- package/dist/src/core/project-context.js +62 -2
- package/dist/src/core/tools/registry.js +10 -6
- package/dist/src/tui/InkApp.js +5 -1
- package/dist/src/tui/app.js +312 -15
- package/dist/src/tui/components/Header.js +1 -1
- package/dist/src/tui/components/ModelPicker.js +13 -5
- package/dist/src/tui/picker.js +127 -39
- package/dist/src/tui/store.js +10 -0
- package/dist/src/utils/platform.js +3 -0
- package/package.json +1 -1
package/dist/prompts/system.js
CHANGED
|
@@ -23,6 +23,12 @@ ${extras.conventions}`
|
|
|
23
23
|
When a user request matches one of these skills, load its instructions before starting. To load a skill body your model can call the \`load_project_skill\` tool; use the skill's name exactly as listed.
|
|
24
24
|
${extras.skills.map((s) => `- \`${s.name}\` — ${s.summary}`).join('\n')}`
|
|
25
25
|
: '';
|
|
26
|
+
const memoryBlock = extras?.memory && extras.memory.trim().length > 0
|
|
27
|
+
? `
|
|
28
|
+
# Persistent memory
|
|
29
|
+
Facts the user asked you to remember across sessions (MEMORY.md). Treat these as durable context; update them only via the /remember command or explicit user request.
|
|
30
|
+
${extras.memory.trim()}`
|
|
31
|
+
: '';
|
|
26
32
|
return `You are orbit-agent, an interactive terminal agent for software engineering tasks. You help users safely and efficiently, using the tools below and following these instructions strictly.
|
|
27
33
|
|
|
28
34
|
# Operating environment
|
|
@@ -33,6 +39,7 @@ ${extras.skills.map((s) => `- \`${s.name}\` — ${s.summary}`).join('\n')}`
|
|
|
33
39
|
${termuxBlock}
|
|
34
40
|
${conventionsBlock}
|
|
35
41
|
${skillsBlock}
|
|
42
|
+
${memoryBlock}
|
|
36
43
|
|
|
37
44
|
# Core mandates
|
|
38
45
|
- Conventions: Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
|
|
@@ -37,7 +37,13 @@ export async function runOrchestrateCmd(opts) {
|
|
|
37
37
|
toolTimeoutMs: config.tools?.timeoutMs ?? 30_000,
|
|
38
38
|
streamTimeoutMs: config.runtime?.streamTimeoutMs ?? 120_000,
|
|
39
39
|
};
|
|
40
|
-
const registry = new ToolRegistry({
|
|
40
|
+
const registry = new ToolRegistry({
|
|
41
|
+
cwd,
|
|
42
|
+
canWrite: true,
|
|
43
|
+
shell: config.tools.shell,
|
|
44
|
+
filesystem: config.tools.filesystem,
|
|
45
|
+
search: config.tools.search,
|
|
46
|
+
});
|
|
41
47
|
registerOrchestrationTools(registry);
|
|
42
48
|
const app = new TuiApp({
|
|
43
49
|
config,
|
package/dist/src/cli/run.js
CHANGED
|
@@ -5,7 +5,7 @@ import { VERSION } from '../version.js';
|
|
|
5
5
|
import { touchConfig, saveConfig } from '../config/index.js';
|
|
6
6
|
import { EventBus } from '../core/events.js';
|
|
7
7
|
import { buildProviders, resolveCandidates } from '../core/llm/index.js';
|
|
8
|
-
import {
|
|
8
|
+
import { resolveModelRows, resolveProviderRows } from '../tui/picker.js';
|
|
9
9
|
import { AutoRouter } from '../core/llm/router.js';
|
|
10
10
|
import { ContextManager } from '../core/context/context-manager.js';
|
|
11
11
|
import { ToolRegistry } from '../core/tools/registry.js';
|
|
@@ -17,8 +17,9 @@ import { hasAnySecrets } from '../core/llm/secrets.js';
|
|
|
17
17
|
import { runWizard } from '../setup/wizard.js';
|
|
18
18
|
import { createSession, saveSession, pruneSessions } from '../session/store.js';
|
|
19
19
|
import { createEventLog, appendEvent, readEventLog } from '../session/event-log.js';
|
|
20
|
-
import { loadProjectContext } from '../core/project-context.js';
|
|
20
|
+
import { loadProjectContext, appendProjectMemory } from '../core/project-context.js';
|
|
21
21
|
import { createSkillLoaderTool } from '../core/skill-loader.js';
|
|
22
|
+
import { newMessage } from '../core/types.js';
|
|
22
23
|
const THEME_NAMES = ['tokyonight', 'catppuccin-mocha', 'catppuccin-latte', 'nord', 'gruvbox', 'monokai', 'clean-dark'];
|
|
23
24
|
const PROVIDER_IDS = ['orbitx', 'groq', 'gemini', 'openrouter', 'openai', 'anthropic', 'grok', 'deepseek', 'ollama'];
|
|
24
25
|
export async function main(argv) {
|
|
@@ -135,14 +136,16 @@ export async function main(argv) {
|
|
|
135
136
|
console.error(`⚠ No models available for routing.\n ${tip}\n`);
|
|
136
137
|
return 1;
|
|
137
138
|
}
|
|
138
|
-
// Project context: AGENTS.md conventions + .orbit/skills.
|
|
139
|
-
// system prompt; skills exposed both to the model
|
|
140
|
-
// the /skills command.
|
|
141
|
-
|
|
142
|
-
const
|
|
139
|
+
// Project context: AGENTS.md conventions + MEMORY.md + .orbit/skills.
|
|
140
|
+
// Injected into the system prompt; skills exposed both to the model
|
|
141
|
+
// (load_project_skill) and the /skills command.
|
|
142
|
+
let project = loadProjectContext(cwd);
|
|
143
|
+
const buildPrompt = () => buildSystemPrompt(cfg, cwd, {
|
|
143
144
|
conventions: project.conventions,
|
|
145
|
+
memory: project.memory,
|
|
144
146
|
skills: project.skills.map((s) => ({ name: s.name, summary: s.summary })),
|
|
145
147
|
});
|
|
148
|
+
let systemPrompt = buildPrompt();
|
|
146
149
|
if (args.orchestrate) {
|
|
147
150
|
const { runOrchestrateCmd } = await import('./orchestrate.js');
|
|
148
151
|
return runOrchestrateCmd({
|
|
@@ -154,7 +157,13 @@ export async function main(argv) {
|
|
|
154
157
|
});
|
|
155
158
|
}
|
|
156
159
|
const context = new ContextManager(systemPrompt);
|
|
157
|
-
const registry = new ToolRegistry({
|
|
160
|
+
const registry = new ToolRegistry({
|
|
161
|
+
cwd,
|
|
162
|
+
canWrite: true,
|
|
163
|
+
shell: cfg.tools.shell,
|
|
164
|
+
filesystem: cfg.tools.filesystem,
|
|
165
|
+
search: cfg.tools.search,
|
|
166
|
+
});
|
|
158
167
|
let session = createSession(new Date().toLocaleString(), cwd);
|
|
159
168
|
if (args.session) {
|
|
160
169
|
const { loadSession } = await import('../session/store.js');
|
|
@@ -212,6 +221,25 @@ export async function main(argv) {
|
|
|
212
221
|
cfg.permissions.mode = 'allow';
|
|
213
222
|
saveConfig(cfg);
|
|
214
223
|
return null;
|
|
224
|
+
case 'remember': {
|
|
225
|
+
const note = cmd.value?.trim();
|
|
226
|
+
if (!note)
|
|
227
|
+
return 'Usage: /remember <note>';
|
|
228
|
+
const path = appendProjectMemory(cwd, note);
|
|
229
|
+
if (path === null)
|
|
230
|
+
return 'Could not write MEMORY.md.';
|
|
231
|
+
// Reload memory and refresh the live system prompt without a restart.
|
|
232
|
+
project = loadProjectContext(cwd);
|
|
233
|
+
systemPrompt = buildPrompt();
|
|
234
|
+
context.setSystemPrompt(systemPrompt);
|
|
235
|
+
return `Remembered → ${path}`;
|
|
236
|
+
}
|
|
237
|
+
case 'compact': {
|
|
238
|
+
if (context.history().length === 0)
|
|
239
|
+
return 'Nothing to compact yet.';
|
|
240
|
+
const result = await compactContext(bus, router, context, cfg.agent.contextBudgetTokens);
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
215
243
|
default:
|
|
216
244
|
return null;
|
|
217
245
|
}
|
|
@@ -248,7 +276,7 @@ export async function main(argv) {
|
|
|
248
276
|
onCommand,
|
|
249
277
|
version: VERSION,
|
|
250
278
|
models: router.order(),
|
|
251
|
-
pickRows: () =>
|
|
279
|
+
pickRows: (stage, providerId, liveModels) => stage === 'providers' ? Promise.resolve(resolveProviderRows()) : resolveModelRows(providerId ?? '', liveModels),
|
|
252
280
|
});
|
|
253
281
|
app.store.skills = project.skills.map((s) => ({ name: s.name, summary: s.summary }));
|
|
254
282
|
const resumeMessages = session.messages.filter((m) => m.role !== 'tool');
|
|
@@ -329,10 +357,17 @@ async function runSingleShot(cfg, cwd, prompt, autoAllow) {
|
|
|
329
357
|
const project = loadProjectContext(cwd);
|
|
330
358
|
const systemPrompt = buildSystemPrompt(cfg, cwd, {
|
|
331
359
|
conventions: project.conventions,
|
|
360
|
+
memory: project.memory,
|
|
332
361
|
skills: project.skills.map((s) => ({ name: s.name, summary: s.summary })),
|
|
333
362
|
});
|
|
334
363
|
const context = new ContextManager(systemPrompt);
|
|
335
|
-
const registry = new ToolRegistry({
|
|
364
|
+
const registry = new ToolRegistry({
|
|
365
|
+
cwd,
|
|
366
|
+
canWrite: true,
|
|
367
|
+
shell: cfg.tools.shell,
|
|
368
|
+
filesystem: cfg.tools.filesystem,
|
|
369
|
+
search: cfg.tools.search,
|
|
370
|
+
});
|
|
336
371
|
registry.register(createSkillLoaderTool(project));
|
|
337
372
|
const permissionMode = autoAllow ? 'allow' : cfg.permissions.mode;
|
|
338
373
|
const permissions = new PermissionManager({ ...cfg.permissions, mode: permissionMode }, { ask: (q) => askConsole(q) });
|
|
@@ -399,3 +434,62 @@ function emitRoute(bus, router, cfg) {
|
|
|
399
434
|
bus.emit('onRoute', { ...firstRoute, strategy: cfg.routing.strategy });
|
|
400
435
|
}
|
|
401
436
|
}
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
// /compact — summarize the older turns with the model, keep the current turn.
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
const SUMMARIZE_SYSTEM = `You are a conversation summarizer for a coding-agent session. Produce a terse, structured summary of the conversation transcript you are given: a short Objective, Important Details (exact file paths, command names, and file:line references), Work State (Completed / Active / Blocked), and Next Move. Preserve hard facts verbatim where possible — do not invent state. Keep it under ~400 words. Output only the summary.`;
|
|
441
|
+
/**
|
|
442
|
+
* Compact the conversation: summarize everything before the current turn via
|
|
443
|
+
* the router, replace it with a single summary message, and emit
|
|
444
|
+
* onContextSummary. Falls back to drop-only compaction when the model call
|
|
445
|
+
* fails or there is nothing to summarize.
|
|
446
|
+
*/
|
|
447
|
+
async function compactContext(bus, router, context, budget) {
|
|
448
|
+
const { older, current } = context.splitCurrentTurn();
|
|
449
|
+
if (older.length === 0) {
|
|
450
|
+
// Single-turn context: nothing older to summarize; drop-only path.
|
|
451
|
+
const res = context.compact(budget);
|
|
452
|
+
bus.emit('onContextSummary', res);
|
|
453
|
+
return res.droppedPairs > 0
|
|
454
|
+
? `Compacted (drop-only): dropped ${res.droppedPairs} turns · ${res.tokensBefore} → ${res.tokensAfter} tokens.`
|
|
455
|
+
: 'Context already fits the budget.';
|
|
456
|
+
}
|
|
457
|
+
const tokensBefore = context.estimateTokens();
|
|
458
|
+
try {
|
|
459
|
+
const summary = await summarizeWith(router, older);
|
|
460
|
+
if (summary && summary.trim().length > 0) {
|
|
461
|
+
const summaryMsg = newMessage(`sum_${Date.now()}`, 'user', summary);
|
|
462
|
+
context.replaceHistory([summaryMsg, ...current]);
|
|
463
|
+
const tokensAfter = context.estimateTokens();
|
|
464
|
+
const droppedPairs = older.filter((m) => m.role === 'user').length;
|
|
465
|
+
bus.emit('onContextSummary', { droppedPairs, tokensBefore, tokensAfter });
|
|
466
|
+
return `Compacted with model summary: ${droppedPairs} older turns replaced · ${tokensBefore} → ${tokensAfter} tokens.`;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
// model summarization unavailable — fall through to drop-only
|
|
471
|
+
}
|
|
472
|
+
const res = context.compact(budget);
|
|
473
|
+
bus.emit('onContextSummary', res);
|
|
474
|
+
return res.droppedPairs > 0
|
|
475
|
+
? `Model compact failed; dropped ${res.droppedPairs} turns instead · ${res.tokensBefore} → ${res.tokensAfter} tokens.`
|
|
476
|
+
: 'Context already fits the budget.';
|
|
477
|
+
}
|
|
478
|
+
/** Stream a structured summary of `older` through the router, returning its text. */
|
|
479
|
+
async function summarizeWith(router, older) {
|
|
480
|
+
// Peek at the current route so the router streams on the exact active model.
|
|
481
|
+
const peek = router.peek();
|
|
482
|
+
if (!peek)
|
|
483
|
+
throw new Error('no route');
|
|
484
|
+
const model = `${peek.provider}/${peek.model}`;
|
|
485
|
+
const sub = new ContextManager(SUMMARIZE_SYSTEM);
|
|
486
|
+
for (const m of older)
|
|
487
|
+
sub.add(m);
|
|
488
|
+
sub.addUser('Summarize the transcript above.');
|
|
489
|
+
let text = '';
|
|
490
|
+
for await (const ev of router.stream({ messages: sub.toOutgoing(), model, temperature: 0.2 })) {
|
|
491
|
+
if (ev.type === 'token')
|
|
492
|
+
text += ev.text;
|
|
493
|
+
}
|
|
494
|
+
return text;
|
|
495
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sanitizeToken, sanitizeContent } from '../llm/sanitize.js';
|
|
1
2
|
import { combineSignals } from '../../utils/signals.js';
|
|
2
3
|
import { unifiedDiff, countChanges } from '../../utils/diff.js';
|
|
3
4
|
import { readFile, stat } from 'node:fs/promises';
|
|
@@ -87,13 +88,15 @@ export class AgentLoop {
|
|
|
87
88
|
try {
|
|
88
89
|
for await (const ev of gen) {
|
|
89
90
|
if (ev.type === 'token') {
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
const clean = sanitizeToken(ev.text);
|
|
92
|
+
asstMsg.content += clean;
|
|
93
|
+
bus.emit('onToken', clean);
|
|
92
94
|
await throttle(this.opts.maxTokensPerSecond);
|
|
93
95
|
}
|
|
94
96
|
else if (ev.type === 'reasoning') {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
+
const clean = sanitizeToken(ev.text);
|
|
98
|
+
asstMsg.reasoning = (asstMsg.reasoning ?? '') + clean;
|
|
99
|
+
bus.emit('onThinking', clean);
|
|
97
100
|
}
|
|
98
101
|
else if (ev.type === 'tool_call_start') {
|
|
99
102
|
const call = {
|
|
@@ -153,6 +156,8 @@ export class AgentLoop {
|
|
|
153
156
|
// TUI reveal the whole message (snap the typewriter to the end) so the
|
|
154
157
|
// text sits above the tool rows BEFORE the tools start running — the
|
|
155
158
|
// user should see "intent first, then action".
|
|
159
|
+
asstMsg.content = sanitizeContent(asstMsg.content);
|
|
160
|
+
asstMsg.reasoning = sanitizeContent(asstMsg.reasoning ?? '');
|
|
156
161
|
bus.emit('onAssistantGenerationDone', asstMsg);
|
|
157
162
|
asstMsg.streaming = false;
|
|
158
163
|
asstMsg.reasoningOpen = (asstMsg.reasoning?.length ?? 0) > 0;
|
|
@@ -169,11 +174,17 @@ export class AgentLoop {
|
|
|
169
174
|
// don't pollute history, burn a turn slot, and try the model again.
|
|
170
175
|
continue;
|
|
171
176
|
}
|
|
177
|
+
// Record the assistant message (with its tool_calls) BEFORE executing the
|
|
178
|
+
// tools: toOutgoing() pairs each tool result with the assistant turn that
|
|
179
|
+
// invoked it. Adding it afterwards would walk the results back to the
|
|
180
|
+
// user message instead (nothing precedes a bare tool message in the wire
|
|
181
|
+
// history), so the provider would never deliver the file contents. This
|
|
182
|
+
// ordering bug made read_file/list_dir output invisible to the model.
|
|
183
|
+
this.opts.context.addAssistant(asstMsg.content, { model: asstMsg.model, toolCalls });
|
|
172
184
|
// Execute each tool call, feed results back into context
|
|
173
185
|
for (const call of toolCalls) {
|
|
174
186
|
await this.executeTool(call);
|
|
175
187
|
}
|
|
176
|
-
this.opts.context.addAssistant(asstMsg.content, { model: asstMsg.model, toolCalls });
|
|
177
188
|
bus.emit('onAssistantEnd', asstMsg);
|
|
178
189
|
// Overflow-driven compaction (adapted from opencode's loop protection):
|
|
179
190
|
// after a tool-heavy turn, bring the outgoing context back inside budget
|
|
@@ -201,9 +212,19 @@ export class AgentLoop {
|
|
|
201
212
|
const decision = await this.opts.permissions.checkCommand(parseCommandArg(call.args));
|
|
202
213
|
denied = decision === 'deny';
|
|
203
214
|
}
|
|
204
|
-
else if (call.name === 'write_file') {
|
|
205
|
-
const
|
|
215
|
+
else if (call.name === 'write_file' || call.name === 'edit_file') {
|
|
216
|
+
const writePath = parseWritePath(call.args);
|
|
217
|
+
const decision = await this.opts.permissions.checkPath(writePath, 'write');
|
|
206
218
|
denied = decision === 'deny';
|
|
219
|
+
// Containment: mutating file tools must stay inside the working
|
|
220
|
+
// directory (or an allowPath) so a model can't `../`-escape the project.
|
|
221
|
+
if (!denied && writePath) {
|
|
222
|
+
const resolved = resolveCwdPath(writePath, this.opts.cwd ?? '');
|
|
223
|
+
const cwdRoot = resolve(this.opts.cwd ?? '.');
|
|
224
|
+
const inCwd = pathWithin(resolved, cwdRoot);
|
|
225
|
+
const inAllowed = this.opts.permissions.allowPaths.some((a) => pathWithin(resolved, resolveCwdPath(a, this.opts.cwd ?? '.')));
|
|
226
|
+
denied = !inCwd && !inAllowed;
|
|
227
|
+
}
|
|
207
228
|
}
|
|
208
229
|
else if (MUTATING_TOOLS[call.name]) {
|
|
209
230
|
const decision = await this.opts.permissions.checkCommand(MUTATING_TOOLS[call.name]);
|
|
@@ -363,6 +384,12 @@ function resolveCwdPath(p, cwd) {
|
|
|
363
384
|
return p;
|
|
364
385
|
return resolve(cwd, p);
|
|
365
386
|
}
|
|
387
|
+
/** True when `candidate` is `root` or a descendant of it (path-sealed). */
|
|
388
|
+
export function pathWithin(candidate, root) {
|
|
389
|
+
const c = resolve(candidate);
|
|
390
|
+
const r = resolve(root);
|
|
391
|
+
return c === r || c.startsWith(r.endsWith('/') ? r : `${r}/`);
|
|
392
|
+
}
|
|
366
393
|
async function captureSnapshot(args, cwd, toolName) {
|
|
367
394
|
if (toolName !== 'edit_file' && toolName !== 'write_file')
|
|
368
395
|
return null;
|
|
@@ -59,12 +59,25 @@ export class ContextManager {
|
|
|
59
59
|
}
|
|
60
60
|
if (m.role === 'tool') {
|
|
61
61
|
const tr = m;
|
|
62
|
-
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
62
|
+
// Pair the result with the assistant turn that declared its tool call:
|
|
63
|
+
// prefer the nearest preceding assistant whose toolCalls carries this
|
|
64
|
+
// toolCallId (never a bare user/system message). Falls back to the most
|
|
65
|
+
// recent assistant when an id is missing (legacy/compacted history).
|
|
66
|
+
let last;
|
|
67
|
+
for (let idx = out.length - 1; idx >= 0; idx--) {
|
|
68
|
+
const cand = out[idx];
|
|
69
|
+
if (cand.role !== 'assistant')
|
|
70
|
+
continue;
|
|
71
|
+
last = cand;
|
|
72
|
+
if (tr.toolCallId) {
|
|
73
|
+
const declared = (cand.toolCalls ?? []).some((tc) => tc.id === tr.toolCallId);
|
|
74
|
+
if (declared)
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
68
81
|
if (last && tr.toolCallId) {
|
|
69
82
|
const existing = last.toolResults ?? [];
|
|
70
83
|
last.toolResults = [
|
|
@@ -164,4 +177,25 @@ export class ContextManager {
|
|
|
164
177
|
}
|
|
165
178
|
return Math.ceil(chars / 4) + count;
|
|
166
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Split history into the current (newest) turn — the last user message and
|
|
182
|
+
* everything after it — and the older turns. Used by /compact to summarize
|
|
183
|
+
* the older portion while keeping the active turn verbatim.
|
|
184
|
+
*/
|
|
185
|
+
splitCurrentTurn() {
|
|
186
|
+
let lastUser = -1;
|
|
187
|
+
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
188
|
+
if (this.messages[i]?.role === 'user') {
|
|
189
|
+
lastUser = i;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (lastUser < 0)
|
|
194
|
+
return { older: [], current: this.messages };
|
|
195
|
+
return { older: this.messages.slice(0, lastUser), current: this.messages.slice(lastUser) };
|
|
196
|
+
}
|
|
197
|
+
/** Replace the whole history (used by /compact to inject the summary). */
|
|
198
|
+
replaceHistory(messages) {
|
|
199
|
+
this.messages = messages;
|
|
200
|
+
}
|
|
167
201
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom OpenAI-compatible endpoint registry.
|
|
3
|
+
*
|
|
4
|
+
* Lets users add named endpoints (LM Studio, vLLM, a personal gateway, …)
|
|
5
|
+
* with a base URL and an OPTIONAL key. Persisted to the git-ignored
|
|
6
|
+
* `endpoints.json` (0600) next to keys.json. Base URLs are not secrets, but
|
|
7
|
+
* endpoint keys are — keeping both in one 0600 file matches the keys.json
|
|
8
|
+
* security posture (never config.json).
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync, existsSync, chmodSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { endpointsPath, configDir } from '../../utils/platform.js';
|
|
12
|
+
const CACHE_MS = 2000;
|
|
13
|
+
let cache = null;
|
|
14
|
+
function readFile() {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
if (cache && now - cache.at < CACHE_MS)
|
|
17
|
+
return cache.data;
|
|
18
|
+
const path = endpointsPath();
|
|
19
|
+
if (!existsSync(path)) {
|
|
20
|
+
cache = { data: [], at: now };
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
25
|
+
if (!Array.isArray(raw))
|
|
26
|
+
throw new Error('not an array');
|
|
27
|
+
const cleaned = raw.filter((e) => e && typeof e.name === 'string' && typeof e.baseUrl === 'string');
|
|
28
|
+
cache = { data: cleaned, at: now };
|
|
29
|
+
return cleaned;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
cache = { data: [], at: now };
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function write(list) {
|
|
37
|
+
mkdirSync(configDir(), { recursive: true });
|
|
38
|
+
writeFileSync(endpointsPath(), JSON.stringify(list, null, 2), 'utf8');
|
|
39
|
+
try {
|
|
40
|
+
chmodSync(endpointsPath(), 0o600);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
/* best-effort */
|
|
44
|
+
}
|
|
45
|
+
cache = { data: list, at: Date.now() };
|
|
46
|
+
}
|
|
47
|
+
export function loadEndpoints() {
|
|
48
|
+
return readFile();
|
|
49
|
+
}
|
|
50
|
+
export function findEndpoint(name) {
|
|
51
|
+
return readFile().find((e) => e.name === name) ?? null;
|
|
52
|
+
}
|
|
53
|
+
/** Upsert by name (case-sensitive). Returns an error string, or null on success. */
|
|
54
|
+
export function saveEndpoint(ep) {
|
|
55
|
+
const trimmed = { ...ep, name: ep.name.trim(), baseUrl: ep.baseUrl.trim() };
|
|
56
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,39}$/.test(trimmed.name)) {
|
|
57
|
+
return 'name must be 1-40 chars: lowercase letters, digits, . _ -';
|
|
58
|
+
}
|
|
59
|
+
if (!/^https?:\/\/\S+$/.test(trimmed.baseUrl)) {
|
|
60
|
+
return 'base URL must start with http:// or https://';
|
|
61
|
+
}
|
|
62
|
+
if (!trimmed.baseUrl.endsWith('/v1') && !/\/(v1|v1beta1)\/?$/.test(trimmed.baseUrl)) {
|
|
63
|
+
trimmed.baseUrl = `${trimmed.baseUrl.replace(/\/+$/, '')}/v1`;
|
|
64
|
+
}
|
|
65
|
+
const list = readFile();
|
|
66
|
+
const idx = list.findIndex((e) => e.name === trimmed.name);
|
|
67
|
+
if (idx >= 0)
|
|
68
|
+
list[idx] = trimmed;
|
|
69
|
+
else
|
|
70
|
+
list.push(trimmed);
|
|
71
|
+
write(list);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
export function deleteEndpoint(name) {
|
|
75
|
+
write(readFile().filter((e) => e.name !== name));
|
|
76
|
+
}
|
|
77
|
+
/** Remember a model id the user actually picked for this endpoint. */
|
|
78
|
+
export function rememberEndpointModel(name, model) {
|
|
79
|
+
const list = readFile();
|
|
80
|
+
const ep = list.find((e) => e.name === name);
|
|
81
|
+
if (!ep)
|
|
82
|
+
return;
|
|
83
|
+
const models = ep.models ?? [];
|
|
84
|
+
if (!models.includes(model)) {
|
|
85
|
+
ep.models = [...models, model].slice(-20);
|
|
86
|
+
write(list);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Probe the endpoint's live /v1/models list (fast-fail, short timeout). */
|
|
90
|
+
export async function listEndpointModels(name) {
|
|
91
|
+
const ep = findEndpoint(name);
|
|
92
|
+
if (!ep)
|
|
93
|
+
return [];
|
|
94
|
+
try {
|
|
95
|
+
const ctrl = new AbortController();
|
|
96
|
+
const timer = setTimeout(() => ctrl.abort(new Error('endpoint probe timed out')), 3000);
|
|
97
|
+
const headers = { accept: 'application/json' };
|
|
98
|
+
if (ep.key)
|
|
99
|
+
headers.authorization = `Bearer ${ep.key}`;
|
|
100
|
+
const res = await fetch(`${ep.baseUrl.replace(/\/+$/, '')}/models`, { signal: ctrl.signal, headers });
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
if (!res.ok)
|
|
103
|
+
throw new Error(`HTTP ${res.status}`);
|
|
104
|
+
const json = (await res.json());
|
|
105
|
+
return (json.data ?? []).map((m) => m.id).filter(Boolean);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -3,6 +3,7 @@ import { createOpenAiProvider } from './providers/openai-compat.js';
|
|
|
3
3
|
import { createGeminiProvider } from './providers/gemini.js';
|
|
4
4
|
import { createAnthropicProvider } from './providers/anthropic.js';
|
|
5
5
|
import { createOllamaProvider } from './providers/ollama.js';
|
|
6
|
+
import { loadEndpoints } from './endpoints.js';
|
|
6
7
|
import { PROVIDER_CATALOGS, ORBITX_SERVE, modelsOf } from './models.js';
|
|
7
8
|
export { PROVIDER_CATALOGS as PROVIDER_SPECS, ORBITX_SERVE, modelsOf };
|
|
8
9
|
const ENDPOINTS = {
|
|
@@ -65,6 +66,21 @@ export function buildProviders(config) {
|
|
|
65
66
|
}
|
|
66
67
|
// Local Ollama is always offered: no key required, auto-detects the server.
|
|
67
68
|
providers.ollama = createOllamaProvider();
|
|
69
|
+
// Named custom OpenAI-compatible endpoints (endpoints.json). Each is exposed
|
|
70
|
+
// as a provider keyed by its name so `<name>/<model>` routes to it.
|
|
71
|
+
for (const ep of loadEndpoints()) {
|
|
72
|
+
if (!ep || !ep.name || !ep.baseUrl)
|
|
73
|
+
continue;
|
|
74
|
+
const existing = providers[ep.name];
|
|
75
|
+
if (existing)
|
|
76
|
+
continue; // never shadow a real provider with a same-named endpoint
|
|
77
|
+
providers[ep.name] = createOpenAiProvider({
|
|
78
|
+
id: ep.name,
|
|
79
|
+
baseUrl: ep.baseUrl,
|
|
80
|
+
apiKey: ep.key ?? '',
|
|
81
|
+
models: (ep.models ?? []).filter(Boolean),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
68
84
|
return providers;
|
|
69
85
|
}
|
|
70
86
|
/** Candidate model ids (provider-prefixed) for the router. */
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streamed-content sanitizer.
|
|
3
|
+
*
|
|
4
|
+
* Some gateway models (gpt-oss family, Qwen cloud variants) leak control
|
|
5
|
+
* tokens into the visible content stream: `<|start|>functions.list_dir
|
|
6
|
+
* to=assistant<|message|>{...}` pensieve blocks, dangling `<|...|>` tags,
|
|
7
|
+
* and boundary boilerplate such as a lone `response` line or "Proceed to
|
|
8
|
+
* final answer." Strip those before the TUI renders or history commits them
|
|
9
|
+
* so the user sees the prose, not the plumbing.
|
|
10
|
+
*/
|
|
11
|
+
/** Control-rune tag, e.g. `<|start|>`, `<|message|>`, `<|end|>`. */
|
|
12
|
+
const TAG_RE = /<\|[^|>]*\|>/g;
|
|
13
|
+
/**
|
|
14
|
+
* Qwen/OSS pensieve header: `<|start|>functions.<name> to=assistant
|
|
15
|
+
* <|message|>` followed by a mangled JSON echo that model emits verbatim.
|
|
16
|
+
* Removes the header plus the leading portion of the echoed line up to the
|
|
17
|
+
* first blank line, which is where the real prose begins in practice.
|
|
18
|
+
*/
|
|
19
|
+
const PENSIVE_RE = /<\|start\|>\n?[\s\S]*?<\|message\|>[ \t]*[^\r\n]*\n/g;
|
|
20
|
+
/** Lone boundary markers some cloud models insert before their answer. */
|
|
21
|
+
const BOUNDARY_LINE_RE = /^[ \t]*(?:response|Proceed to final answer\.?)[ \t]*$/gm;
|
|
22
|
+
/** Collapse 3+ consecutive blank lines down to two (post-strip tidy-up). */
|
|
23
|
+
const BLANK_RUN_RE = /\n{3,}/g;
|
|
24
|
+
/**
|
|
25
|
+
* Sanitize a single streamed token. idempotent; cheap enough to run per
|
|
26
|
+
* token. A `<|` opened mid-chunk but closed later is finished by
|
|
27
|
+
* `sanitizeContent` over the assembled text, so partial matches here are
|
|
28
|
+
* fine.
|
|
29
|
+
*/
|
|
30
|
+
export function sanitizeToken(text) {
|
|
31
|
+
let s = text.replace(PENSIVE_RE, '').replace(TAG_RE, '');
|
|
32
|
+
if (s.includes('response') || s.includes('Proceed to final answer')) {
|
|
33
|
+
s = s.replace(BOUNDARY_LINE_RE, '');
|
|
34
|
+
}
|
|
35
|
+
return s;
|
|
36
|
+
}
|
|
37
|
+
/** Sanitize a fully-assembled message/reasoning blob. */
|
|
38
|
+
export function sanitizeContent(text) {
|
|
39
|
+
let s = text
|
|
40
|
+
.replace(PENSIVE_RE, '')
|
|
41
|
+
.replace(TAG_RE, '')
|
|
42
|
+
.replace(BOUNDARY_LINE_RE, '')
|
|
43
|
+
.replace(BLANK_RUN_RE, '\n\n')
|
|
44
|
+
.replace(/^\n+/, '')
|
|
45
|
+
.replace(/\n+$/, '');
|
|
46
|
+
return s;
|
|
47
|
+
}
|
|
@@ -83,8 +83,9 @@ export function getProviderSecrets(provider) {
|
|
|
83
83
|
return { provider, keys: envKeys, source: 'env' };
|
|
84
84
|
const file = readKeysFile();
|
|
85
85
|
const fileKeys = file[provider] ?? [];
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
const flat = fileKeys.map((k) => (typeof k === 'string' ? k : k.key)).filter(Boolean);
|
|
87
|
+
if (flat.length > 0)
|
|
88
|
+
return { provider, keys: flat, source: 'file' };
|
|
88
89
|
return null;
|
|
89
90
|
}
|
|
90
91
|
export function hasAnySecrets() {
|
|
@@ -108,12 +109,30 @@ export function describeProvidersAvailable() {
|
|
|
108
109
|
}
|
|
109
110
|
return out;
|
|
110
111
|
}
|
|
112
|
+
export function describeProviderKey(provider) {
|
|
113
|
+
const s = getProviderSecrets(provider);
|
|
114
|
+
if (!s || !s.keys[0])
|
|
115
|
+
return { provider, available: false };
|
|
116
|
+
const named = readKeysFile()[provider]?.find((k) => typeof k === 'object');
|
|
117
|
+
return {
|
|
118
|
+
provider,
|
|
119
|
+
available: true,
|
|
120
|
+
masked: maskSecret(s.keys[0]),
|
|
121
|
+
source: s.source,
|
|
122
|
+
label: named?.name,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
111
125
|
/**
|
|
112
126
|
* Persist provider keys to the git-ignored keys.json (0600), merging over any
|
|
113
127
|
* existing keys so a re-run of the wizard never wipes other providers.
|
|
114
128
|
*/
|
|
115
129
|
export function writeKeys(provider, keys) {
|
|
116
|
-
const trimmed = keys
|
|
130
|
+
const trimmed = keys
|
|
131
|
+
.map((k) => {
|
|
132
|
+
const key = typeof k === 'string' ? k.trim() : { name: k.name.trim() || 'default', key: k.key.trim() };
|
|
133
|
+
return typeof key === 'string' ? key : { name: key.name, key: key.key };
|
|
134
|
+
})
|
|
135
|
+
.filter((k) => (typeof k === 'string' ? k : k.key) !== '');
|
|
117
136
|
if (trimmed.length === 0)
|
|
118
137
|
return 'no keys provided';
|
|
119
138
|
const existing = readKeysFile();
|
|
@@ -129,3 +148,42 @@ export function writeKeys(provider, keys) {
|
|
|
129
148
|
return err.message;
|
|
130
149
|
}
|
|
131
150
|
}
|
|
151
|
+
/** Save (or update by label) a single named key for a provider. */
|
|
152
|
+
export function saveNamedKey(provider, name, key) {
|
|
153
|
+
const clean = key.trim();
|
|
154
|
+
if (!clean)
|
|
155
|
+
return 'no key provided';
|
|
156
|
+
const label = name.trim() || 'default';
|
|
157
|
+
const existing = readKeysFile();
|
|
158
|
+
const list = existing[provider] ?? [];
|
|
159
|
+
const idx = list.findIndex((k) => typeof k === 'object' && k.name === label);
|
|
160
|
+
if (idx >= 0)
|
|
161
|
+
list[idx] = { name: label, key: clean };
|
|
162
|
+
else
|
|
163
|
+
list.push({ name: label, key: clean });
|
|
164
|
+
existing[provider] = list;
|
|
165
|
+
try {
|
|
166
|
+
mkdirSync(configDir(), { recursive: true });
|
|
167
|
+
writeFileSync(keysPath(), JSON.stringify(existing, null, 2), 'utf8');
|
|
168
|
+
chmodSync(keysPath(), 0o600);
|
|
169
|
+
invalidateKeysCache();
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
return err.message;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** Human "masked · source [label]" description for a provider's configured key. */
|
|
177
|
+
export function describeKey(provider) {
|
|
178
|
+
const envKeys = fromEnv(provider);
|
|
179
|
+
if (envKeys.length > 0 && envKeys[0])
|
|
180
|
+
return { masked: maskSecret(envKeys[0]), source: 'env' };
|
|
181
|
+
const file = readKeysFile();
|
|
182
|
+
const entries = file[provider] ?? [];
|
|
183
|
+
const first = entries[0];
|
|
184
|
+
if (!first)
|
|
185
|
+
return null;
|
|
186
|
+
if (typeof first === 'string')
|
|
187
|
+
return { masked: maskSecret(first), source: 'file' };
|
|
188
|
+
return { masked: maskSecret(first.key), source: 'file', label: first.name };
|
|
189
|
+
}
|