@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.
@@ -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({ cwd, canWrite: true, shell: config.tools.shell });
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,
@@ -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 { resolvePickerRows } from '../tui/picker.js';
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. Injected into the
139
- // system prompt; skills exposed both to the model (load_project_skill) and
140
- // the /skills command.
141
- const project = loadProjectContext(cwd);
142
- const systemPrompt = buildSystemPrompt(cfg, cwd, {
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({ cwd, canWrite: true, shell: cfg.tools.shell });
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: () => resolvePickerRows(),
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({ cwd, canWrite: true, shell: cfg.tools.shell });
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
- asstMsg.content += ev.text;
91
- bus.emit('onToken', ev.text);
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
- asstMsg.reasoning = (asstMsg.reasoning ?? '') + ev.text;
96
- bus.emit('onThinking', ev.text);
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 decision = await this.opts.permissions.checkPath(parseWritePath(call.args), 'write');
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
- let last = out[out.length - 1];
63
- // find the preceding assistant turn
64
- let idx = out.length - 1;
65
- while (idx >= 0 && out[idx].role !== 'assistant')
66
- idx--;
67
- last = out[idx] ?? out[out.length - 1];
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
- if (fileKeys.length > 0)
87
- return { provider, keys: fileKeys, source: 'file' };
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.map((k) => k.trim()).filter(Boolean);
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
+ }