agen-vektor 0.3.12 → 0.3.14

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 CHANGED
@@ -50,7 +50,7 @@ incrementally with no flicker.
50
50
  ## Contents
51
51
 
52
52
  - [Features](#features) · [Installation](#installation) · [Quick start](#quick-start) · [Command line](#command-line) · [TUI commands](#tui-commands)
53
- - [Provider configuration](#provider-configuration) · [Themes](#themes) · [Security model](#security-model) · [Architecture](#architecture) · [Development](#development)
53
+ - [Provider configuration](#provider-configuration) · [Themes](#themes) · [Security model](#security-model) · [Privacy](#privacy) · [Architecture](#architecture) · [Development](#development)
54
54
  - [Testing](#testing) · [Troubleshooting](#troubleshooting) · [Roadmap](#roadmap) · [License](#license)
55
55
 
56
56
  ## Features
@@ -307,6 +307,23 @@ Every shell command is classified:
307
307
 
308
308
  `--yolo` auto-approves ASK-level actions but still prompts for DANGEROUS and refuses BLOCKED ones. API keys are never printed to the terminal, logs, diffs, or session history.
309
309
 
310
+ ## Privacy
311
+
312
+ **VectorHead collects nothing.** No telemetry, no analytics, no crash reporting, no usage tracking — there is no code in the app that could phone home. Every network destination in the source is auditable:
313
+
314
+ | Destination | When |
315
+ |---|---|
316
+ | Your configured AI provider (OpenAI, Anthropic, Gemini, OpenRouter, Ollama, custom) | Only when you chat — with **your own** API key |
317
+ | `vector-tui.methatech.eu.org` (VectorHead Free gateway) | Only if you pick the free tier; the shared provider key stays server-side, users never see it |
318
+ | Brave / Exa / Serper | Only when you use the web-search tool with your own key |
319
+ | Raw model catalog (public JSON) | To populate the free-tier model list |
320
+
321
+ **Stored locally** (all in `~/.vector/`): `config.json` (preferences, no secrets), `credentials.json` (only keys *you* enter, file mode 0600), sessions, memory, logs. Nothing else is written anywhere.
322
+
323
+ **No device identity.** The app does not generate a device id or send any tracking header (the old anonymous `device-id` + `X-Vector-Device` pair was fully removed in 0.3.14).
324
+
325
+ **The agent guards itself against secrets** — its own file tools refuse to read or write `.env`, private keys, `credentials.json`, kubeconfig, and similar files (override only via explicit `VECTOR_ALLOW_SENSITIVE=1`), so a prompt cannot accidentally exfiltrate them.
326
+
310
327
  ## Architecture
311
328
 
312
329
  ```
@@ -1,6 +1,44 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.Agent = void 0;
37
+ /**
38
+ * Agent — high-level facade bundling provider, tools, permissions
39
+ * and the loop into a single object the TUI/CLI can drive.
40
+ */
41
+ const fs = __importStar(require("node:fs"));
4
42
  const factory_1 = require("../providers/factory");
5
43
  const registry_1 = require("../tools/registry");
6
44
  const filesystem_1 = require("../tools/filesystem");
@@ -12,6 +50,8 @@ const extras_1 = require("../tools/extras");
12
50
  const apply_patch_1 = require("../tools/apply-patch");
13
51
  const permissions_1 = require("../security/permissions");
14
52
  const config_1 = require("../config/config");
53
+ const paths_1 = require("../utils/paths");
54
+ const free_tier_1 = require("../config/free-tier");
15
55
  const loop_1 = require("./loop");
16
56
  const rules_1 = require("./rules");
17
57
  const skills_1 = require("./skills");
@@ -24,7 +64,26 @@ class Agent {
24
64
  permissions;
25
65
  cwd;
26
66
  constructor(opts) {
27
- this.config = (0, config_1.effectiveConfig)(opts.config);
67
+ // Free tier (vectorhead-free): define the provider entry and default a
68
+ // FIRST RUN (no config.json yet) to the free tier so onboarding is
69
+ // "install & pick a model" with zero keys. Existing configs keep their
70
+ // active provider; only the free ENTRY is (re)defined. No per-device
71
+ // identity: the gateway requires none (X-Vector-Device removed 2026-09-05).
72
+ const firstRun = !opts.config && (0, free_tier_1.isFirstRun)(fs.existsSync((0, paths_1.getConfigPath)()));
73
+ const seeded = (0, config_1.effectiveConfig)(opts.config);
74
+ if ((0, free_tier_1.ensureFreeProvider)(seeded)) {
75
+ try {
76
+ (0, config_1.saveConfig)(seeded);
77
+ }
78
+ catch {
79
+ /* read-only home — the in-memory def still works this session */
80
+ }
81
+ }
82
+ if (firstRun) {
83
+ seeded.provider = free_tier_1.FREE_PROVIDER_ID;
84
+ seeded.model = free_tier_1.FREE_DEFAULT_MODEL;
85
+ }
86
+ this.config = seeded;
28
87
  this.cwd = opts.cwd || process.cwd();
29
88
  // Seed Hermes-style SOUL.md/MEMORY.md/USER.md/SKILL.md on first run
30
89
  // (never overwrites existing user files — see src/agent/rules.ts).
@@ -182,6 +182,11 @@ async function runAgentLoop(userRequest, opts, callbacks = {}) {
182
182
  finalContent = `⚠️ Error: ${err.message}`;
183
183
  break;
184
184
  }
185
+ // Defense in depth: a misbehaving gateway can yield a sparse toolCalls
186
+ // array (null holes — e.g. non-0-based stream indices from Anthropic-
187
+ // backed endpoints). Drop holes here so tool execution and the follow-up
188
+ // request history stay well-formed even if a provider regresses.
189
+ result.toolCalls = result.toolCalls.filter((tc) => !!tc && typeof tc.name === 'string' && tc.name !== '');
185
190
  const hasToolCalls = result.toolCalls.length > 0;
186
191
  // Streaming-free path: collect the assistant message and execute tools.
187
192
  messages.push({
@@ -66,7 +66,7 @@ Rules:
66
66
  */
67
67
  function identityPrompt(model, provider) {
68
68
  return `You are currently running as the model "${model}" via the "${provider}" provider (OpenAI-compatible API unless stated otherwise).
69
- If the user asks which model you are or which provider backs you, answer with exactly this — e.g. "Saya dijalankan oleh model deepseek-ai/DeepSeek-V4-Flash lewat provider custom". Do not invent a different model name.`;
69
+ If the user asks which model you are or which provider backs you, answer with exactly this sentence — copy it verbatim, do not substitute any other name: "Saya dijalankan oleh model ${model} lewat provider ${provider}". Do not invent a different model name.`;
70
70
  }
71
71
  function taskPrompt(userRequest, cwd) {
72
72
  return `Working directory: ${cwd}
package/dist/cli/index.js CHANGED
@@ -312,23 +312,16 @@ async function runTui(opts) {
312
312
  // Incremental renderer: keep the previous frame and only rewrite the rows
313
313
  // that actually changed. Full-screen redraws every tick caused constant
314
314
  // flicker, especially while the agent is running (spinner + timer animate
315
- // every frame). Modal overlays are absolutely positioned, so they force a
316
- // full repaint.
315
+ // every frame).
316
+ // OpenCode single-buffer parity: modal overlays are composed INTO the
317
+ // frame rows by App.computeFrame, so a selector navigation is diffed per
318
+ // ROW like everything else. The old modal branch forced a full-screen
319
+ // repaint per keystroke (lastFrameRows = []) — the ↑/↓ "berkedip" in
320
+ // /model. The cursor is HIDDEN while a modal is open (menus don't show a
321
+ // text cursor) and restored at the input row when it closes.
317
322
  let lastFrameRows = [];
318
323
  let lastCursorPos = '';
319
324
  const doRender = () => {
320
- if (app.modalActive()) {
321
- // A modal (e.g. a permission prompt) is static while open, so only
322
- // repaint when something actually changed (open/close/keystroke).
323
- // Full-screen redraws every tick while waiting for permission caused
324
- // the chat to flicker during multi-tool turns.
325
- if (app.isDirty()) {
326
- lastFrameRows = [];
327
- process.stdout.write(terminal_1.ANSI.hideCursor + DECAWM_OFF + (0, terminal_1.toAsciiSafe)(app.render()) + DECAWM_ON);
328
- app.consumeRender();
329
- }
330
- return;
331
- }
332
325
  const snap = app.frameSnapshot();
333
326
  const rows = snap.rows;
334
327
  const out = [];
@@ -338,17 +331,23 @@ async function runTui(opts) {
338
331
  }
339
332
  }
340
333
  lastFrameRows = rows;
341
- const cursor = (0, terminal_1.cursorTo)(snap.inputRow, snap.cursorCol);
342
- if (cursor !== lastCursorPos)
343
- out.push(cursor);
344
- lastCursorPos = cursor;
345
- out.push(terminal_1.ANSI.showCursor);
334
+ if (app.modalActive()) {
335
+ out.push(terminal_1.ANSI.hideCursor);
336
+ lastCursorPos = '';
337
+ }
338
+ else {
339
+ const cursor = (0, terminal_1.cursorTo)(snap.inputRow, snap.cursorCol);
340
+ if (cursor !== lastCursorPos)
341
+ out.push(cursor);
342
+ lastCursorPos = cursor;
343
+ out.push(terminal_1.ANSI.showCursor);
344
+ }
346
345
  // DECAWM guard: even one exactly-`cols`-wide row (input-box borders,
347
346
  // header at the old width) leaves the cursor in the pending-wrap state;
348
347
  // the NEXT repaint — e.g. the ~480ms blink dot — then auto-wraps first
349
348
  // and the whole screen scrolls up one line. With auto-wrap disabled for
350
349
  // the duration of the paint, that phantom newline is impossible.
351
- process.stdout.write(terminal_1.ANSI.hideCursor + DECAWM_OFF + (0, terminal_1.toAsciiSafe)(out.join('')) + DECAWM_ON);
350
+ process.stdout.write(terminal_1.ANSI.hideCursor + DECAWM_OFF + (0, terminal_1.toAsciiSafe)(out.filter(Boolean).join('')) + DECAWM_ON);
352
351
  };
353
352
  const onResize = () => {
354
353
  app.markDirty();
@@ -382,6 +381,10 @@ async function runTui(opts) {
382
381
  // terminals never send \x1b[<...> sequences, so wheel/touch scrolling of
383
382
  // the chat is dead even though the parser supports it.
384
383
  process.stdout.write(terminal_1.ANSI.enableMouse);
384
+ // Bracketed paste (Freebuff MultilineInput onPaste): the terminal then
385
+ // wraps clipboard text in ESC[200~ … ESC[201~ so a paste of thousands of
386
+ // characters reaches the input as ONE event instead of a keystroke burst.
387
+ process.stdout.write(terminal_1.ANSI.enablePaste);
385
388
  (0, keyboard_1.keyStream)(keyHandler);
386
389
  app.start();
387
390
  doRender();
@@ -401,6 +404,7 @@ async function runTui(opts) {
401
404
  finally {
402
405
  process.stdout.write(terminal_1.ANSI.disableAltScreen);
403
406
  process.stdout.write(terminal_1.ANSI.disableMouse);
407
+ process.stdout.write(terminal_1.ANSI.disablePaste);
404
408
  process.stdout.write(terminal_1.ANSI.showCursor);
405
409
  process.stdout.removeListener('resize', onResize);
406
410
  cleanup();
@@ -96,6 +96,22 @@ function parseKey(data) {
96
96
  continue;
97
97
  }
98
98
  }
99
+ // Bracketed paste: ESC[200~ … ESC[201~ wraps the WHOLE clipboard text
100
+ // (Freebuff onPaste). Captured as ONE 'paste' event so multi-line and
101
+ // multi-thousand-char pastes never burst into per-key events (a raw \n
102
+ // inside the payload would otherwise submit the half-typed prompt).
103
+ if (ch === '\x1b' && s.startsWith('\x1b[200~', i)) {
104
+ const end = s.indexOf('\x1b[201~', i + 6);
105
+ if (end !== -1) {
106
+ events.push({ name: 'paste', text: s.slice(i + 6, end) });
107
+ i = end + 6;
108
+ continue;
109
+ }
110
+ // Unterminated paste (cut off read): take the rest as paste text.
111
+ events.push({ name: 'paste', text: s.slice(i + 6) });
112
+ i = s.length;
113
+ continue;
114
+ }
99
115
  // CSI sequence
100
116
  if (ch === '\x1b' && i + 1 < s.length && s[i + 1] === '[') {
101
117
  // Find the terminating char
@@ -281,13 +297,50 @@ function enableRawMode() {
281
297
  process.stdin.pause();
282
298
  };
283
299
  }
284
- /** Create a key stream (async iterable of KeyEvent). */
300
+ /**
301
+ * Create a key stream. Bracketed pastes are BUFFERED across stdin chunks:
302
+ * a multi-thousand-char paste often arrives split over several 'data'
303
+ * events, so the stream waits for the closing ESC[201~ before emitting ONE
304
+ * 'paste' event (Freebuff onPaste). Everything outside a paste is parsed
305
+ * and emitted immediately, exactly like before.
306
+ */
285
307
  function keyStream(onKey) {
308
+ let pending = '';
286
309
  process.stdin.on('data', (data) => {
287
- for (const ev of parseKey(data))
288
- onKey(ev);
310
+ pending += data.toString('utf8');
311
+ for (;;) {
312
+ const start = pending.indexOf('\x1b[200~');
313
+ if (start === -1) {
314
+ const evs = parseKeyStr(pending);
315
+ pending = '';
316
+ for (const ev of evs)
317
+ onKey(ev);
318
+ return;
319
+ }
320
+ if (start > 0) {
321
+ for (const ev of parseKeyStr(pending.slice(0, start)))
322
+ onKey(ev);
323
+ pending = pending.slice(start);
324
+ }
325
+ const end = pending.indexOf('\x1b[201~', 6);
326
+ if (end === -1) {
327
+ // Safety valve: a runaway opener without terminator must not buffer
328
+ // input forever (e.g. a terminal that only half-implements 2004).
329
+ if (pending.length > 1_000_000) {
330
+ onKey({ name: 'paste', text: pending.slice(6) });
331
+ pending = '';
332
+ }
333
+ return; // wait for the rest of the paste
334
+ }
335
+ onKey({ name: 'paste', text: pending.slice(6, end) });
336
+ pending = pending.slice(end + 6);
337
+ }
289
338
  });
290
339
  }
340
+ /** parseKey over a string (keyStream works on accumulated text). */
341
+ function parseKeyStr(s) {
342
+ return parseKey(Buffer.from(s, 'utf8'));
343
+ }
291
344
  function setupReadline(stream) {
292
345
  return readline.createInterface({ input: stream, terminal: false });
293
346
  }
@@ -0,0 +1,159 @@
1
+ "use strict";
2
+ /**
3
+ * Free-tier provider (`vectorhead-free`) — the "install & just pick a model"
4
+ * onboarding tier backed by the VectorHead gateway (Cloudflare Worker):
5
+ *
6
+ * https://vector-tui.methatech.eu.org (see konsep.md and vector-gateway/
7
+ * — the worker holds the real BitDeer tokens as server-side secrets and
8
+ * enforces the per-device quota; users never see an API key).
9
+ *
10
+ * Design:
11
+ * - `vectorhead-free` is an OpenCode-style entry in config.providers with an
12
+ * INLINE placeholder key. The gateway holds the real provider key as a
13
+ * server-side secret and does NOT require any per-device or per-user
14
+ * identity from the client (per-device quota was removed 2026-09-05 —
15
+ * X-Vector-Device is no longer sent), so no real key exists client-side —
16
+ * the inline placeholder simply satisfies "custom providers need a key"
17
+ * checks without storing anything secret on the user's disk.
18
+ * - First run (no config.json): the agent starts on this provider so a new
19
+ * user can chat immediately — pick BYOK later via /connect. An EXISTING
20
+ * config is never touched except to (re)define the free provider entry.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.FREE_DEFAULT_MODEL = exports.FREE_PROVIDER_ID = exports.CATALOG_TTL_MS = exports.FREE_CATALOG_URL = exports.FREE_GATEWAY_CATALOG_URL = exports.FREE_GATEWAY_URL = void 0;
24
+ exports.resetFreeCatalogCache = resetFreeCatalogCache;
25
+ exports.freeTierEnabled = freeTierEnabled;
26
+ exports.freeProviderDef = freeProviderDef;
27
+ exports.ensureFreeProvider = ensureFreeProvider;
28
+ exports.isFirstRun = isFirstRun;
29
+ exports.fetchFreeCatalog = fetchFreeCatalog;
30
+ exports.applyFreeCatalog = applyFreeCatalog;
31
+ /** Public gateway base URL (custom Cloudflare domain — stable, no ports). */
32
+ exports.FREE_GATEWAY_URL = 'https://vector-tui.methatech.eu.org/v1';
33
+ /**
34
+ * Live catalog served BY the gateway itself (admin-editable via the /admin
35
+ * dashboard — upstream, model list and limits can change without touching
36
+ * this repo). Kept in sync with FREE_GATEWAY_URL's /v1 root.
37
+ */
38
+ exports.FREE_GATEWAY_CATALOG_URL = exports.FREE_GATEWAY_URL.replace(/\/v1$/, '') + '/v1/catalog';
39
+ /**
40
+ * Public catalog (konsep.md tahap 2): JSON WITHOUT secrets committed to the
41
+ * repo and fetched via the stable RAW URL — models, gateway URL, limits and
42
+ * the remote kill switch, all updatable without an app release.
43
+ * FALLBACK only: the gateway's own /v1/catalog (admin-editable) is tried
44
+ * first; this static file covers the gateway being down / not yet deployed.
45
+ */
46
+ exports.FREE_CATALOG_URL = 'https://raw.githubusercontent.com/clickmamaheti-prog/vector-agent/main/free-models.json';
47
+ /** Catalog cache lifetime (konsep.md: "Agent fetch saat start, cache 1 jam"). */
48
+ exports.CATALOG_TTL_MS = 60 * 60 * 1000;
49
+ /** In-process catalog cache — once loaded, freeProviderDef() follows it. */
50
+ let catalogCache = null;
51
+ /** Test/edge hook: forget the cached catalog (next fetch starts cold). */
52
+ function resetFreeCatalogCache() {
53
+ catalogCache = null;
54
+ }
55
+ /** Remote kill switch ("enabled": false in free-models.json) — default true. */
56
+ function freeTierEnabled() {
57
+ return catalogCache?.data.enabled !== false;
58
+ }
59
+ /** Provider id used in config.provider / providers map. */
60
+ exports.FREE_PROVIDER_ID = 'vectorhead-free';
61
+ /** Default free model (cheap, gateway allowlist-friendly). */
62
+ exports.FREE_DEFAULT_MODEL = 'deepseek-ai/DeepSeek-V4-Flash';
63
+ /** The providers-map entry for the free tier (idempotent — same shape always). */
64
+ function freeProviderDef() {
65
+ // When a catalog has been fetched in this process, the gateway URL and
66
+ // model list follow it (konsep.md: katalog bisa diubah remote tanpa rilis
67
+ // app). No cache → the compile-time defaults.
68
+ const cat = catalogCache?.data;
69
+ const models = {};
70
+ if (cat && Array.isArray(cat.models) && cat.models.length > 0) {
71
+ for (const m of cat.models) {
72
+ if (m && typeof m.id === 'string' && m.id.trim() !== '') {
73
+ models[m.id] = { name: m.label || m.id };
74
+ }
75
+ }
76
+ }
77
+ return {
78
+ name: 'VectorHead Free',
79
+ options: {
80
+ baseURL: (cat && typeof cat.gateway === 'string' && cat.gateway ? cat.gateway : exports.FREE_GATEWAY_URL),
81
+ // Placeholder ONLY — the gateway holds the real provider key as a
82
+ // server-side secret and accepts anonymous requests. Inline (not
83
+ // {env:}) so no env var is ever required.
84
+ apiKey: 'vectorhead-free-anonymous',
85
+ },
86
+ model: exports.FREE_DEFAULT_MODEL,
87
+ models: Object.keys(models).length > 0 ? models : {
88
+ [exports.FREE_DEFAULT_MODEL]: { name: 'DeepSeek V4 Flash (free)' },
89
+ },
90
+ };
91
+ }
92
+ /**
93
+ * Ensure the free provider entry exists in the config's providers map
94
+ * (create or repair — never touches unrelated providers). Returns true when
95
+ * the config object was modified.
96
+ */
97
+ function ensureFreeProvider(config) {
98
+ const cur = config.providers?.[exports.FREE_PROVIDER_ID];
99
+ const want = JSON.stringify(freeProviderDef());
100
+ if (cur && JSON.stringify(cur) === want)
101
+ return false;
102
+ config.providers = { ...(config.providers || {}), [exports.FREE_PROVIDER_ID]: freeProviderDef() };
103
+ return true;
104
+ }
105
+ /**
106
+ * True when this machine has never run VectorHead before (no config.json).
107
+ * First-run seeds default to the free tier so onboarding is zero-config.
108
+ */
109
+ function isFirstRun(configDirHasConfig) {
110
+ return !configDirHasConfig;
111
+ }
112
+ function isValidCatalog(data) {
113
+ if (!data || typeof data !== 'object')
114
+ return false;
115
+ const c = data;
116
+ return typeof c.gateway === 'string' && /^https:\/\//.test(c.gateway)
117
+ && Array.isArray(c.models);
118
+ }
119
+ /**
120
+ * Fetch the catalog. Sources, in order:
121
+ * 1. explicit override (opts.url or VECTOR_FREE_CATALOG_URL env) — ONLY source
122
+ * 2. gateway /v1/catalog (live, admin-editable via the /admin dashboard)
123
+ * 3. GitHub RAW free-models.json (static fallback when the gateway is down)
124
+ * Cached for 1 hour — subsequent calls within the TTL are served from memory
125
+ * without network. Returns null when every source fails / is malformed (never
126
+ * throws) so callers can stay best-effort.
127
+ */
128
+ async function fetchFreeCatalog(opts) {
129
+ if (!opts?.force && catalogCache && Date.now() - catalogCache.fetchedAt < exports.CATALOG_TTL_MS) {
130
+ return catalogCache.data;
131
+ }
132
+ const override = opts?.url || process.env.VECTOR_FREE_CATALOG_URL;
133
+ const candidates = override ? [override] : [exports.FREE_GATEWAY_CATALOG_URL, exports.FREE_CATALOG_URL];
134
+ for (const url of candidates) {
135
+ try {
136
+ const res = await fetch(url, { signal: AbortSignal.timeout(opts?.timeoutMs ?? 8_000) });
137
+ if (!res.ok)
138
+ continue;
139
+ const data = await res.json();
140
+ if (!isValidCatalog(data))
141
+ continue;
142
+ catalogCache = { data, fetchedAt: Date.now() };
143
+ return data;
144
+ }
145
+ catch {
146
+ /* try the next source */
147
+ }
148
+ }
149
+ return null;
150
+ }
151
+ /**
152
+ * Adopt a fetched catalog: prime the cache (freeProviderDef follows it) and
153
+ * refresh the free provider entry in the config. Returns true when the
154
+ * config object was modified (caller decides whether to persist).
155
+ */
156
+ function applyFreeCatalog(data, config) {
157
+ catalogCache = { data, fetchedAt: Date.now() };
158
+ return ensureFreeProvider(config);
159
+ }
@@ -9,6 +9,62 @@ exports.createThinkTagStreamSplitter = createThinkTagStreamSplitter;
9
9
  */
10
10
  const provider_1 = require("./provider");
11
11
  const credentials_1 = require("../config/credentials");
12
+ /**
13
+ * Stream delta-collector for tool calls.
14
+ *
15
+ * WHY A MAP, NOT AN ARRAY INDEXED BY `part.index`:
16
+ * OpenAI spec says tool_calls deltas carry a 0-based `index` per tool call,
17
+ * and arguments arrive in fragments. Most gateways comply. But some gateways
18
+ * proxy Anthropic models and leak the UPSTREAM content-block positions as
19
+ * `index` (thinking=0, text=1, tool_use=2, ...) — with one tool call the
20
+ * chunks arrive with `index:2` (observed live on api.justwoker.icu, 2026-09-04).
21
+ * Indexing a plain array by that value produces a SPARSE array
22
+ * ([<hole>, <hole>, call]) which silently breaks `for..of` iteration in the
23
+ * agent loop (holes are skipped) and corrupts the follow-up request history.
24
+ * Collect per index in a Map, emit a DENSE array in arrival order, deduped
25
+ * by id (Anthropic ids are unique per call; OpenAI reuses one id across
26
+ * argument fragments).
27
+ */
28
+ class StreamToolCallCollector {
29
+ byIndex = new Map();
30
+ /** Feed one delta.tool_calls part; returns the latest complete call, if any. */
31
+ add(part) {
32
+ const idx = Number(part.index || 0);
33
+ const fn = (part.function || {});
34
+ const existing = this.byIndex.get(idx);
35
+ if (!existing) {
36
+ this.byIndex.set(idx, {
37
+ id: String(part.id || `call_${idx}_${Math.random().toString(36).slice(2)}`),
38
+ name: String(fn.name || ''),
39
+ arguments: String(fn.arguments || ''),
40
+ });
41
+ }
42
+ else {
43
+ if (part.id)
44
+ existing.id = String(part.id);
45
+ if (fn.name)
46
+ existing.name = String(fn.name);
47
+ if (fn.arguments)
48
+ existing.arguments += String(fn.arguments);
49
+ }
50
+ const finished = this.finished();
51
+ return finished.length > 0 ? finished[finished.length - 1] : null;
52
+ }
53
+ /** All complete calls so far, in arrival order, DENSE (no holes/nulls). */
54
+ finished() {
55
+ const seen = new Set();
56
+ const out = [];
57
+ for (const call of this.byIndex.values()) {
58
+ if (!call.name)
59
+ continue; // incomplete: no function name yet
60
+ if (seen.has(call.id))
61
+ continue; // fragment of an already-collected call
62
+ seen.add(call.id);
63
+ out.push({ id: call.id, name: call.name, arguments: call.arguments || '{}' });
64
+ }
65
+ return out;
66
+ }
67
+ }
12
68
  function toOpenAIMessages(messages) {
13
69
  const out = [];
14
70
  for (const m of messages) {
@@ -308,7 +364,10 @@ class OpenAICompatProvider {
308
364
  let buffer = '';
309
365
  let content = '';
310
366
  let reasoning = '';
311
- const toolCalls = [];
367
+ // Dense collector — see StreamToolCallCollector: some gateways send
368
+ // non-0-based tool_calls indices (Anthropic block positions), which
369
+ // used to produce a sparse toolCalls array the agent loop skipped.
370
+ const toolCollector = new StreamToolCallCollector();
312
371
  // Reasoning models behind OpenAI-compat gateways may inline thinking
313
372
  // into delta.content as <think>…</think> (DeepSeek-R1/QwQ/Qwen3).
314
373
  // Split it out so the • Thinking card works on EVERY model.
@@ -332,13 +391,14 @@ class OpenAICompatProvider {
332
391
  reasoning += r;
333
392
  for (const c of tail.content)
334
393
  content += c;
394
+ const calls = toolCollector.finished();
335
395
  yield {
336
396
  type: 'done',
337
397
  result: {
338
398
  content,
339
399
  reasoning: reasoning || undefined,
340
- toolCalls,
341
- stopReason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
400
+ toolCalls: calls,
401
+ stopReason: calls.length > 0 ? 'tool_calls' : 'stop',
342
402
  model: params.model,
343
403
  },
344
404
  };
@@ -374,32 +434,14 @@ class OpenAICompatProvider {
374
434
  }
375
435
  const tc = delta.tool_calls;
376
436
  if (Array.isArray(tc)) {
437
+ let latest = null;
377
438
  for (const part of tc) {
378
- const idx = Number(part.index || 0);
379
- const fn = (part.function || {});
380
- const existing = toolCalls[idx];
381
- if (!existing) {
382
- toolCalls[idx] = {
383
- id: String(part.id || `call_${idx}_${Math.random().toString(36).slice(2)}`),
384
- name: String(fn.name || ''),
385
- arguments: String(fn.arguments || ''),
386
- };
387
- }
388
- else {
389
- if (part.id)
390
- existing.id = String(part.id);
391
- if (fn.name)
392
- existing.name = String(fn.name);
393
- if (fn.arguments)
394
- existing.arguments += String(fn.arguments);
395
- }
439
+ const call = toolCollector.add(part);
440
+ if (call)
441
+ latest = call;
396
442
  }
397
- const finished = toolCalls.filter((t) => t.name).map((t) => ({
398
- ...t,
399
- arguments: t.arguments || '{}',
400
- }));
401
- if (finished.length > 0) {
402
- yield { type: 'tool_call', toolCall: finished[finished.length - 1] };
443
+ if (latest) {
444
+ yield { type: 'tool_call', toolCall: latest };
403
445
  }
404
446
  }
405
447
  }
@@ -415,7 +457,7 @@ class OpenAICompatProvider {
415
457
  result: {
416
458
  content,
417
459
  reasoning: reasoning || undefined,
418
- toolCalls: toolCalls.filter((t) => t.name),
460
+ toolCalls: toolCollector.finished(),
419
461
  stopReason: 'stop',
420
462
  model: params.model,
421
463
  },