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 +18 -1
- package/dist/agent/agent.js +60 -1
- package/dist/agent/loop.js +5 -0
- package/dist/agent/prompts.js +1 -1
- package/dist/cli/index.js +24 -20
- package/dist/cli/keyboard.js +56 -3
- package/dist/config/free-tier.js +159 -0
- package/dist/providers/openai-compat.js +70 -28
- package/dist/tools/search.js +62 -12
- package/dist/tui/app.js +301 -75
- package/dist/tui/commands.js +1 -0
- package/dist/tui/components.js +143 -68
- package/dist/tui/input.js +24 -0
- package/dist/tui/suggest.js +27 -9
- package/dist/utils/terminal.js +6 -0
- package/package.json +4 -1
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
|
```
|
package/dist/agent/agent.js
CHANGED
|
@@ -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
|
-
|
|
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).
|
package/dist/agent/loop.js
CHANGED
|
@@ -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({
|
package/dist/agent/prompts.js
CHANGED
|
@@ -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 —
|
|
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).
|
|
316
|
-
//
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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();
|
package/dist/cli/keyboard.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
398
|
-
|
|
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:
|
|
460
|
+
toolCalls: toolCollector.finished(),
|
|
419
461
|
stopReason: 'stop',
|
|
420
462
|
model: params.model,
|
|
421
463
|
},
|