agen-vektor 0.3.13 → 0.3.15
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 +21 -3
- package/dist/agent/agent.js +10 -50
- package/dist/agent/loop.js +140 -13
- package/dist/agent/prompts.js +11 -1
- package/dist/agent/skills.js +5 -1
- package/dist/cli/index.js +44 -23
- package/dist/cli/keyboard.js +7 -0
- package/dist/config/config.js +36 -3
- package/dist/config/free-tier.js +41 -88
- package/dist/providers/anthropic.js +1 -1
- package/dist/providers/gemini.js +1 -1
- package/dist/providers/ollama.js +1 -1
- package/dist/providers/openai-compat.js +41 -4
- package/dist/providers/provider.js +78 -2
- package/dist/tools/design-lab.js +247 -0
- package/dist/tools/extras.js +2 -2
- package/dist/tools/filesystem.js +2 -2
- package/dist/tools/git.js +83 -1
- package/dist/tools/shell.js +1 -1
- package/dist/tools/web.js +44 -3
- package/dist/tui/app.js +268 -143
- package/dist/tui/chat.js +206 -82
- package/dist/tui/commands.js +8 -6
- package/dist/tui/components.js +107 -51
- package/dist/tui/input.js +84 -32
- package/dist/tui/statusbar.js +58 -57
- package/dist/tui/suggest.js +10 -3
- package/dist/utils/terminal.js +161 -25
- package/package.json +1 -1
package/dist/config/free-tier.js
CHANGED
|
@@ -9,65 +9,25 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Design:
|
|
11
11
|
* - `vectorhead-free` is an OpenCode-style entry in config.providers with an
|
|
12
|
-
* INLINE placeholder key. The gateway
|
|
13
|
-
*
|
|
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 —
|
|
14
16
|
* the inline placeholder simply satisfies "custom providers need a key"
|
|
15
17
|
* checks without storing anything secret on the user's disk.
|
|
16
|
-
* - The device id lives in ~/.vector/device-id (NOT config.json, so config
|
|
17
|
-
* stays copyable between machines; a copied device id would merge two
|
|
18
|
-
* users into one quota bucket). Header templating "{env:VECTOR_DEVICE_ID}"
|
|
19
|
-
* resolves at request time via providerHeaders().
|
|
20
18
|
* - First run (no config.json): the agent starts on this provider so a new
|
|
21
19
|
* user can chat immediately — pick BYOK later via /connect. An EXISTING
|
|
22
20
|
* config is never touched except to (re)define the free provider entry.
|
|
23
21
|
*/
|
|
24
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
25
|
-
if (k2 === undefined) k2 = k;
|
|
26
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
27
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
28
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
29
|
-
}
|
|
30
|
-
Object.defineProperty(o, k2, desc);
|
|
31
|
-
}) : (function(o, m, k, k2) {
|
|
32
|
-
if (k2 === undefined) k2 = k;
|
|
33
|
-
o[k2] = m[k];
|
|
34
|
-
}));
|
|
35
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
36
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
37
|
-
}) : function(o, v) {
|
|
38
|
-
o["default"] = v;
|
|
39
|
-
});
|
|
40
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
41
|
-
var ownKeys = function(o) {
|
|
42
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
43
|
-
var ar = [];
|
|
44
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
45
|
-
return ar;
|
|
46
|
-
};
|
|
47
|
-
return ownKeys(o);
|
|
48
|
-
};
|
|
49
|
-
return function (mod) {
|
|
50
|
-
if (mod && mod.__esModule) return mod;
|
|
51
|
-
var result = {};
|
|
52
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
53
|
-
__setModuleDefault(result, mod);
|
|
54
|
-
return result;
|
|
55
|
-
};
|
|
56
|
-
})();
|
|
57
22
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
58
|
-
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;
|
|
23
|
+
exports.FREE_LEGACY_MODELS = 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;
|
|
59
24
|
exports.resetFreeCatalogCache = resetFreeCatalogCache;
|
|
60
25
|
exports.freeTierEnabled = freeTierEnabled;
|
|
61
|
-
exports.ensureDeviceId = ensureDeviceId;
|
|
62
|
-
exports.exportDeviceIdEnv = exportDeviceIdEnv;
|
|
63
26
|
exports.freeProviderDef = freeProviderDef;
|
|
64
27
|
exports.ensureFreeProvider = ensureFreeProvider;
|
|
65
28
|
exports.isFirstRun = isFirstRun;
|
|
66
29
|
exports.fetchFreeCatalog = fetchFreeCatalog;
|
|
67
30
|
exports.applyFreeCatalog = applyFreeCatalog;
|
|
68
|
-
const fs = __importStar(require("node:fs"));
|
|
69
|
-
const crypto = __importStar(require("node:crypto"));
|
|
70
|
-
const paths_1 = require("../utils/paths");
|
|
71
31
|
/** Public gateway base URL (custom Cloudflare domain — stable, no ports). */
|
|
72
32
|
exports.FREE_GATEWAY_URL = 'https://vector-tui.methatech.eu.org/v1';
|
|
73
33
|
/**
|
|
@@ -98,39 +58,17 @@ function freeTierEnabled() {
|
|
|
98
58
|
}
|
|
99
59
|
/** Provider id used in config.provider / providers map. */
|
|
100
60
|
exports.FREE_PROVIDER_ID = 'vectorhead-free';
|
|
101
|
-
/**
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const existing = fs.readFileSync(p, 'utf8').trim();
|
|
113
|
-
if (/^[a-f0-9][a-f0-9-]{7,63}$/.test(existing))
|
|
114
|
-
return existing;
|
|
115
|
-
}
|
|
116
|
-
const fresh = crypto.randomUUID();
|
|
117
|
-
fs.writeFileSync(p, fresh + '\n', { mode: 0o600 });
|
|
118
|
-
return fresh;
|
|
119
|
-
}
|
|
120
|
-
catch {
|
|
121
|
-
// Read-only fs etc. — fall back to an ephemeral id for this session.
|
|
122
|
-
return crypto.randomUUID();
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
/** Export the current device id into the env for {env:} header templates. */
|
|
126
|
-
function exportDeviceIdEnv() {
|
|
127
|
-
try {
|
|
128
|
-
process.env.VECTOR_DEVICE_ID = ensureDeviceId();
|
|
129
|
-
}
|
|
130
|
-
catch {
|
|
131
|
-
/* never block startup on this */
|
|
132
|
-
}
|
|
133
|
-
}
|
|
61
|
+
/**
|
|
62
|
+
* Default free model (cheap, gateway allowlist-friendly). 2026-09-07: BACK
|
|
63
|
+
* to `glm-5.3-flash` — the gateway allowlist flipped back from the 7-model
|
|
64
|
+
* Claude window to the api.b.ai set: live GET /v1/models now serves hy3,
|
|
65
|
+
* glm-5.3-flash, qwen3.8-max, mimo-v2.5, so `claude-sonnet-4-6` (the
|
|
66
|
+
* 2026-09-06-malam default) is the id that would 404 on fresh installs.
|
|
67
|
+
* glm-5.3-flash verified live end-to-end via the relay (chat 200). History
|
|
68
|
+
* of the same trap: `deepseek-ai/DeepSeek-V4-Flash` (first default), then
|
|
69
|
+
* claude-sonnet-4-6, now glm-5.3-flash again.
|
|
70
|
+
*/
|
|
71
|
+
exports.FREE_DEFAULT_MODEL = 'glm-5.3-flash';
|
|
134
72
|
/** The providers-map entry for the free tier (idempotent — same shape always). */
|
|
135
73
|
function freeProviderDef() {
|
|
136
74
|
// When a catalog has been fetched in this process, the gateway URL and
|
|
@@ -149,29 +87,44 @@ function freeProviderDef() {
|
|
|
149
87
|
name: 'VectorHead Free',
|
|
150
88
|
options: {
|
|
151
89
|
baseURL: (cat && typeof cat.gateway === 'string' && cat.gateway ? cat.gateway : exports.FREE_GATEWAY_URL),
|
|
152
|
-
// Placeholder ONLY — the gateway
|
|
153
|
-
//
|
|
90
|
+
// Placeholder ONLY — the gateway holds the real provider key as a
|
|
91
|
+
// server-side secret and accepts anonymous requests. Inline (not
|
|
92
|
+
// {env:}) so no env var is ever required.
|
|
154
93
|
apiKey: 'vectorhead-free-anonymous',
|
|
155
|
-
headers: { 'X-Vector-Device': '{env:VECTOR_DEVICE_ID}' },
|
|
156
94
|
},
|
|
157
95
|
model: exports.FREE_DEFAULT_MODEL,
|
|
158
96
|
models: Object.keys(models).length > 0 ? models : {
|
|
159
|
-
[exports.FREE_DEFAULT_MODEL]: { name: '
|
|
97
|
+
[exports.FREE_DEFAULT_MODEL]: { name: 'GLM 5.3 Flash (free)' },
|
|
160
98
|
},
|
|
161
99
|
};
|
|
162
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Stale first-run defaults — migrated to FREE_DEFAULT_MODEL once on
|
|
103
|
+
* ensureFreeProvider (only while the free provider is active). glm-5.3-flash
|
|
104
|
+
* itself was legacy during the 2026-09-06 Claude window; since 2026-09-07 it
|
|
105
|
+
* is the default again, so the stale set is the original deepseek default
|
|
106
|
+
* plus that window's claude default (no longer in the gateway allowlist).
|
|
107
|
+
*/
|
|
108
|
+
exports.FREE_LEGACY_MODELS = ['deepseek-ai/DeepSeek-V4-Flash', 'claude-sonnet-4-6'];
|
|
163
109
|
/**
|
|
164
110
|
* Ensure the free provider entry exists in the config's providers map
|
|
165
|
-
* (create or repair — never touches unrelated providers).
|
|
166
|
-
* the
|
|
111
|
+
* (create or repair — never touches unrelated providers). Also migrates an
|
|
112
|
+
* active free-tier model that still points at the STALE legacy default.
|
|
113
|
+
* Returns true when the config object was modified.
|
|
167
114
|
*/
|
|
168
115
|
function ensureFreeProvider(config) {
|
|
169
116
|
const cur = config.providers?.[exports.FREE_PROVIDER_ID];
|
|
170
117
|
const want = JSON.stringify(freeProviderDef());
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
118
|
+
let changed = false;
|
|
119
|
+
if (!cur || JSON.stringify(cur) !== want) {
|
|
120
|
+
config.providers = { ...(config.providers || {}), [exports.FREE_PROVIDER_ID]: freeProviderDef() };
|
|
121
|
+
changed = true;
|
|
122
|
+
}
|
|
123
|
+
if (config.provider === exports.FREE_PROVIDER_ID && exports.FREE_LEGACY_MODELS.includes(config.model || '')) {
|
|
124
|
+
config.model = exports.FREE_DEFAULT_MODEL;
|
|
125
|
+
changed = true;
|
|
126
|
+
}
|
|
127
|
+
return changed;
|
|
175
128
|
}
|
|
176
129
|
/**
|
|
177
130
|
* True when this machine has never run VectorHead before (no config.json).
|
|
@@ -234,7 +234,7 @@ class AnthropicProvider {
|
|
|
234
234
|
else if (evtName === 'content_block_delta') {
|
|
235
235
|
const delta = json.delta;
|
|
236
236
|
if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
|
|
237
|
-
content
|
|
237
|
+
content = (0, provider_1.mergeStreamDelta)(content, delta.text);
|
|
238
238
|
yield { type: 'delta', delta: delta.text };
|
|
239
239
|
}
|
|
240
240
|
else if (delta?.type === 'thinking_delta' && typeof delta.thinking === 'string') {
|
package/dist/providers/gemini.js
CHANGED
|
@@ -202,7 +202,7 @@ class GeminiProvider {
|
|
|
202
202
|
yield { type: 'delta', reasoning: deltaThought };
|
|
203
203
|
}
|
|
204
204
|
if (deltaText) {
|
|
205
|
-
content
|
|
205
|
+
content = (0, provider_1.mergeStreamDelta)(content, deltaText);
|
|
206
206
|
yield { type: 'delta', delta: deltaText };
|
|
207
207
|
}
|
|
208
208
|
for (const tc of newCalls) {
|
package/dist/providers/ollama.js
CHANGED
|
@@ -159,7 +159,7 @@ class OllamaProvider {
|
|
|
159
159
|
yield { type: 'delta', reasoning: message.thinking };
|
|
160
160
|
}
|
|
161
161
|
if (typeof message.content === 'string' && message.content) {
|
|
162
|
-
content
|
|
162
|
+
content = (0, provider_1.mergeStreamDelta)(content, message.content);
|
|
163
163
|
yield { type: 'delta', delta: message.content };
|
|
164
164
|
}
|
|
165
165
|
const tcs = message.tool_calls || [];
|
|
@@ -338,7 +338,22 @@ class OpenAICompatProvider {
|
|
|
338
338
|
}
|
|
339
339
|
async *stream(params) {
|
|
340
340
|
const controller = new AbortController();
|
|
341
|
-
|
|
341
|
+
// 180s TOTAL deadline is wrong for a streaming response: a long agentic
|
|
342
|
+
// turn (big diff, slow reasoning model, cold gateway) can legitimately
|
|
343
|
+
// run for minutes — as long as bytes keep arriving the stream is ALIVE.
|
|
344
|
+
// Instead: IDLE timeout — the clock resets on every chunk, so it only
|
|
345
|
+
// fires when the connection truly stalls (the "stuck mid-task" report).
|
|
346
|
+
// A hard cap remains (IDLE_MAX_TOTAL_MS) for pathological never-ending
|
|
347
|
+
// streams.
|
|
348
|
+
const IDLE_TIMEOUT_MS = 120_000;
|
|
349
|
+
const IDLE_MAX_TOTAL_MS = 600_000;
|
|
350
|
+
let idleTimer = null;
|
|
351
|
+
const armIdle = () => {
|
|
352
|
+
if (idleTimer)
|
|
353
|
+
clearTimeout(idleTimer);
|
|
354
|
+
idleTimer = setTimeout(() => controller.abort(), IDLE_TIMEOUT_MS);
|
|
355
|
+
};
|
|
356
|
+
const timer = setTimeout(() => controller.abort(), IDLE_MAX_TOTAL_MS);
|
|
342
357
|
const onOuterAbort = () => controller.abort();
|
|
343
358
|
if (params.signal) {
|
|
344
359
|
if (params.signal.aborted)
|
|
@@ -347,6 +362,10 @@ class OpenAICompatProvider {
|
|
|
347
362
|
params.signal.addEventListener('abort', onOuterAbort);
|
|
348
363
|
}
|
|
349
364
|
try {
|
|
365
|
+
// Arm the idle clock immediately: connect + time-to-first-byte must
|
|
366
|
+
// arrive within IDLE_TIMEOUT_MS too (previously this window had NO
|
|
367
|
+
// timer at all — a dead upstream sat forever on the spinner).
|
|
368
|
+
armIdle();
|
|
350
369
|
const res = await fetch(this.endpoint('/chat/completions'), {
|
|
351
370
|
method: 'POST',
|
|
352
371
|
headers: this.headers(),
|
|
@@ -376,6 +395,8 @@ class OpenAICompatProvider {
|
|
|
376
395
|
const { done, value } = await reader.read();
|
|
377
396
|
if (done)
|
|
378
397
|
break;
|
|
398
|
+
// Any chunk = the stream is alive: reset the idle clock.
|
|
399
|
+
armIdle();
|
|
379
400
|
buffer += decoder.decode(value, { stream: true });
|
|
380
401
|
const lines = buffer.split('\n');
|
|
381
402
|
buffer = lines.pop() || '';
|
|
@@ -390,7 +411,7 @@ class OpenAICompatProvider {
|
|
|
390
411
|
for (const r of tail.reasoning)
|
|
391
412
|
reasoning += r;
|
|
392
413
|
for (const c of tail.content)
|
|
393
|
-
content
|
|
414
|
+
content = (0, provider_1.mergeStreamDelta)(content, c);
|
|
394
415
|
const calls = toolCollector.finished();
|
|
395
416
|
yield {
|
|
396
417
|
type: 'done',
|
|
@@ -428,7 +449,7 @@ class OpenAICompatProvider {
|
|
|
428
449
|
yield { type: 'delta', reasoning: r };
|
|
429
450
|
}
|
|
430
451
|
for (const c of pieces.content) {
|
|
431
|
-
content
|
|
452
|
+
content = (0, provider_1.mergeStreamDelta)(content, c);
|
|
432
453
|
yield { type: 'delta', delta: c };
|
|
433
454
|
}
|
|
434
455
|
}
|
|
@@ -451,7 +472,7 @@ class OpenAICompatProvider {
|
|
|
451
472
|
for (const r of tail.reasoning)
|
|
452
473
|
reasoning += r;
|
|
453
474
|
for (const c of tail.content)
|
|
454
|
-
content
|
|
475
|
+
content = (0, provider_1.mergeStreamDelta)(content, c);
|
|
455
476
|
yield {
|
|
456
477
|
type: 'done',
|
|
457
478
|
result: {
|
|
@@ -463,8 +484,24 @@ class OpenAICompatProvider {
|
|
|
463
484
|
},
|
|
464
485
|
};
|
|
465
486
|
}
|
|
487
|
+
catch (err) {
|
|
488
|
+
// Distinguish a USER interrupt (outer signal aborted → keep the
|
|
489
|
+
// AbortError; the loop marks the run 'Stopped') from OUR deadline
|
|
490
|
+
// (idle/total timer fired → transport failure). The latter used to
|
|
491
|
+
// surface as a bare AbortError that withRetry refuses to retry, so
|
|
492
|
+
// one stalled stream killed the whole run mid-task; make it a
|
|
493
|
+
// retryable ProviderError instead.
|
|
494
|
+
if (params.signal?.aborted)
|
|
495
|
+
throw err;
|
|
496
|
+
if (err.name === 'AbortError') {
|
|
497
|
+
throw new provider_1.ProviderError('Upstream stream stalled (no data) — connection aborted by timeout.', 0, true);
|
|
498
|
+
}
|
|
499
|
+
throw err;
|
|
500
|
+
}
|
|
466
501
|
finally {
|
|
467
502
|
clearTimeout(timer);
|
|
503
|
+
if (idleTimer)
|
|
504
|
+
clearTimeout(idleTimer);
|
|
468
505
|
if (params.signal)
|
|
469
506
|
params.signal.removeEventListener('abort', onOuterAbort);
|
|
470
507
|
}
|
|
@@ -1,21 +1,96 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ProviderError = void 0;
|
|
4
|
+
exports.mergeStreamDelta = mergeStreamDelta;
|
|
5
|
+
exports.friendlyApiError = friendlyApiError;
|
|
4
6
|
exports.withRetry = withRetry;
|
|
7
|
+
/**
|
|
8
|
+
* Idempotent merge of a streamed delta into the reply content so far.
|
|
9
|
+
* Some OpenAI-compatible servers (e.g. BitDeer) resend the FULL text so far
|
|
10
|
+
* — or re-emit an identical chunk — on every stream frame instead of truly
|
|
11
|
+
* incremental deltas; a naive concatenation then renders the reply TWICE
|
|
12
|
+
* ("double chat", reported 2026-09-08: the final summary appeared twice —
|
|
13
|
+
* the TUI guarded its own display but the loop/provider accumulators did
|
|
14
|
+
* not, so done.result.content carried the doubled text). The guard is exact
|
|
15
|
+
* for all three shapes:
|
|
16
|
+
* - trailing chunk resend → keep (skip);
|
|
17
|
+
* - full-text-so-far resend → replace;
|
|
18
|
+
* - genuine incremental chunk → append.
|
|
19
|
+
*/
|
|
20
|
+
function mergeStreamDelta(existing, delta) {
|
|
21
|
+
if (!delta)
|
|
22
|
+
return existing;
|
|
23
|
+
if (existing.endsWith(delta))
|
|
24
|
+
return existing; // repeat of the trailing chunk
|
|
25
|
+
if (existing.startsWith(delta))
|
|
26
|
+
return existing; // earlier chunk re-emitted
|
|
27
|
+
if (delta.startsWith(existing))
|
|
28
|
+
return delta; // full text so far, resent
|
|
29
|
+
return existing + delta; // incremental chunk
|
|
30
|
+
}
|
|
5
31
|
/** Error thrown by providers for user-facing issues. */
|
|
6
32
|
class ProviderError extends Error {
|
|
7
33
|
status;
|
|
8
34
|
retryable;
|
|
9
35
|
constructor(message, status, retryable = false) {
|
|
10
|
-
super(message
|
|
36
|
+
super(message
|
|
37
|
+
// Upstream error bodies are often PRETTY-PRINTED JSON — the raw
|
|
38
|
+
// text carries real newlines (e.g. `HTTP 429: {\n "error": …}`).
|
|
39
|
+
// Every UI surface that prints an error is a SINGLE frame row (the
|
|
40
|
+
// bottom status bar, the Working line); a newline inside the text
|
|
41
|
+
// wraps the terminal there and the row "tembus ke bawah" — it
|
|
42
|
+
// overflows past the frame and pushes/prints over the rows below.
|
|
43
|
+
// Flatten any newline run to one space at the single chokepoint so
|
|
44
|
+
// every provider error is one-line by construction.
|
|
45
|
+
.replace(/\s*[\r\n]+\s*/g, ' ')
|
|
46
|
+
.trim());
|
|
11
47
|
this.status = status;
|
|
12
48
|
this.retryable = retryable;
|
|
13
49
|
this.name = 'ProviderError';
|
|
14
50
|
}
|
|
15
51
|
}
|
|
16
52
|
exports.ProviderError = ProviderError;
|
|
53
|
+
/**
|
|
54
|
+
* Friendly one-line text for UI surfaces (status bar, chat error line).
|
|
55
|
+
* Raw provider bodies are NOT user-facing: a gateway 429 body like
|
|
56
|
+
* `HTTP 429: { "error": "provider key cooling down…" }` leaked internals
|
|
57
|
+
* into the status bar and ate the right side (timer/YOLO truncated).
|
|
58
|
+
* Mapping: known statuses get short human labels; JSON bodies are cut at
|
|
59
|
+
* the first `{`; leftover `HTTP <code>:` prefixes are dropped; the result
|
|
60
|
+
* is capped so the row always fits.
|
|
61
|
+
*/
|
|
62
|
+
function friendlyApiError(err) {
|
|
63
|
+
const status = err instanceof ProviderError ? err.status : undefined;
|
|
64
|
+
const raw = (err instanceof Error ? err.message : String(err)) || 'unknown error';
|
|
65
|
+
const labelByStatus = {
|
|
66
|
+
400: 'bad request',
|
|
67
|
+
401: 'auth failed — check API key',
|
|
68
|
+
402: 'quota/billing',
|
|
69
|
+
403: 'access denied',
|
|
70
|
+
404: 'model not found',
|
|
71
|
+
408: 'timeout',
|
|
72
|
+
429: 'provider busy (rate limit)',
|
|
73
|
+
500: 'provider error',
|
|
74
|
+
502: 'provider error',
|
|
75
|
+
503: 'provider unavailable',
|
|
76
|
+
504: 'timeout',
|
|
77
|
+
};
|
|
78
|
+
if (status !== undefined && labelByStatus[status])
|
|
79
|
+
return labelByStatus[status];
|
|
80
|
+
// Cut at the first `{` — everything from there on is a body dump.
|
|
81
|
+
const noJson = raw.split('{')[0].trim();
|
|
82
|
+
// Drop redundant `HTTP <code>:` / `<Name> HTTP <code>:` prefixes — the
|
|
83
|
+
// caller already shows the context ("Retrying x/y", "! Error:").
|
|
84
|
+
const cleaned = noJson.replace(/^(?:\w+ )?HTTP \d+(?: from \/\w+)?[—:-]\s*/i, '').trim();
|
|
85
|
+
const text = cleaned || noJson || raw;
|
|
86
|
+
return text.length > 64 ? `${text.slice(0, 63)}…` : text;
|
|
87
|
+
}
|
|
17
88
|
/** Simple retry wrapper for provider calls. */
|
|
18
|
-
async function withRetry(fn, { retries = 3, delayMs = 1000, signal
|
|
89
|
+
async function withRetry(fn, { retries = 3, delayMs = 1000, signal,
|
|
90
|
+
/** Called before each wait so the UI can show "retrying…" instead of a
|
|
91
|
+
* silent frozen spinner (retry waits are 1s/2s/4s — long enough to look
|
|
92
|
+
* like a hang when the stream just died mid-response). */
|
|
93
|
+
onRetry, } = {}) {
|
|
19
94
|
let attempt = 0;
|
|
20
95
|
for (;;) {
|
|
21
96
|
try {
|
|
@@ -31,6 +106,7 @@ async function withRetry(fn, { retries = 3, delayMs = 1000, signal } = {}) {
|
|
|
31
106
|
if (!retryable || attempt > retries)
|
|
32
107
|
throw err;
|
|
33
108
|
const wait = delayMs * Math.pow(2, attempt - 1);
|
|
109
|
+
onRetry?.(attempt, err, wait);
|
|
34
110
|
await new Promise((r) => setTimeout(r, wait));
|
|
35
111
|
if (signal?.aborted)
|
|
36
112
|
throw new Error('aborted');
|
|
@@ -0,0 +1,247 @@
|
|
|
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
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.createDesignLabTools = createDesignLabTools;
|
|
37
|
+
const free_tier_1 = require("../config/free-tier");
|
|
38
|
+
const BASE = free_tier_1.FREE_GATEWAY_URL.replace(/\/v1$/, '');
|
|
39
|
+
const RUN_URL = BASE + '/v1/design/run';
|
|
40
|
+
const STATUS_URL = (job) => BASE + '/v1/design/status/' + encodeURIComponent(job);
|
|
41
|
+
const RESULT_URL = (job, json) => BASE + '/v1/design/result/' + encodeURIComponent(job) + (json ? '?format=json' : '');
|
|
42
|
+
/** Polling status: tiap 20s, maks 8 menit (estimasi pipeline 4-6 menit + buffer).
|
|
43
|
+
* Interval/timeout bisa dipendekkan via env (dipakai test): VECTOR_DESIGN_POLL_MS,
|
|
44
|
+
* VECTOR_DESIGN_POLL_TIMEOUT_MS. */
|
|
45
|
+
const POLL_INTERVAL_MS = Math.max(250, Number(process.env.VECTOR_DESIGN_POLL_MS) || 20_000);
|
|
46
|
+
const POLL_TIMEOUT_MS = Math.max(1_000, Number(process.env.VECTOR_DESIGN_POLL_TIMEOUT_MS) || 8 * 60 * 1000);
|
|
47
|
+
const BRIEF_MIN = 10;
|
|
48
|
+
const BRIEF_MAX = 4000;
|
|
49
|
+
const TERMINAL = new Set(['done', 'failed', 'expired', 'lost']);
|
|
50
|
+
function sleep(ms) {
|
|
51
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
52
|
+
}
|
|
53
|
+
async function fetchJson(url, init) {
|
|
54
|
+
const res = await fetch(url, {
|
|
55
|
+
...init,
|
|
56
|
+
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
|
|
57
|
+
signal: AbortSignal.timeout(30_000),
|
|
58
|
+
});
|
|
59
|
+
let data = null;
|
|
60
|
+
try {
|
|
61
|
+
data = await res.json();
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
/* body non-JSON dibiarkan null */
|
|
65
|
+
}
|
|
66
|
+
return { status: res.status, data };
|
|
67
|
+
}
|
|
68
|
+
async function runAction(args) {
|
|
69
|
+
const brief = String(args.brief ?? '').trim();
|
|
70
|
+
if (brief.length < BRIEF_MIN) {
|
|
71
|
+
return { output: `ERROR: brief terlalu pendek (min ${BRIEF_MIN} karakter).` };
|
|
72
|
+
}
|
|
73
|
+
if (brief.length > BRIEF_MAX) {
|
|
74
|
+
return { output: `ERROR: brief terlalu panjang (maks ${BRIEF_MAX} karakter).` };
|
|
75
|
+
}
|
|
76
|
+
let r;
|
|
77
|
+
try {
|
|
78
|
+
r = await fetchJson(RUN_URL, { method: 'POST', body: JSON.stringify({ brief }) });
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
return { output: `ERROR: gateway tidak terjangkau: ${e.message}` };
|
|
82
|
+
}
|
|
83
|
+
if (r.status === 429) {
|
|
84
|
+
const busy = r.data && r.data.busy ? ' job lain masih berjalan' : '';
|
|
85
|
+
return {
|
|
86
|
+
output: `ERROR: design-lab menolak (429)${busy}. ` +
|
|
87
|
+
`Coba lagi nanti — batas ${r.data && r.data.error && /rate limit/.test(String(r.data.error)) ? '5 job/jam' : '1 job aktif'}.` +
|
|
88
|
+
(r.data && Array.isArray(r.data.jobs) && r.data.jobs.length
|
|
89
|
+
? ` Job aktif: ${r.data.jobs.join(', ')} (cek dengan aksi "status").`
|
|
90
|
+
: ''),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (r.status !== 200) {
|
|
94
|
+
const detail = r.data && r.data.error ? r.data.error : `HTTP ${r.status}`;
|
|
95
|
+
return { output: `ERROR: /v1/design/run gagal: ${detail}` };
|
|
96
|
+
}
|
|
97
|
+
const job = r.data && r.data.job;
|
|
98
|
+
if (!job)
|
|
99
|
+
return { output: `ERROR: respons gateway tanpa job id: ${JSON.stringify(r.data).slice(0, 200)}` };
|
|
100
|
+
return {
|
|
101
|
+
output: `design-lab job ${job} dibuat (codespace: ${r.data.codespace || '?'}). ` +
|
|
102
|
+
`Pipeline berjalan ±4-6 menit. Pantau dengan aksi "status" job=${job}.`,
|
|
103
|
+
summary: `design job ${job} dibuat`,
|
|
104
|
+
data: { job, codespace: r.data.codespace, statusUrl: r.data.statusUrl, resultUrl: r.data.resultUrl },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
async function statusAction(args) {
|
|
108
|
+
const job = String(args.job ?? '').trim();
|
|
109
|
+
if (!/^[a-z0-9-]{4,64}$/.test(job))
|
|
110
|
+
return { output: 'ERROR: job id tidak valid.' };
|
|
111
|
+
const wait = args.wait === true;
|
|
112
|
+
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
113
|
+
let last = null;
|
|
114
|
+
for (;;) {
|
|
115
|
+
try {
|
|
116
|
+
last = await fetchJson(STATUS_URL(job));
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
return { output: `ERROR: gateway tidak terjangkau: ${e.message}` };
|
|
120
|
+
}
|
|
121
|
+
if (last.status === 404) {
|
|
122
|
+
return { output: `ERROR: job ${job} tidak ditemukan / kedaluwarsa di gateway.` };
|
|
123
|
+
}
|
|
124
|
+
const state = last.data && last.data.state;
|
|
125
|
+
if (TERMINAL.has(state) || !wait || Date.now() >= deadline)
|
|
126
|
+
break;
|
|
127
|
+
await sleep(POLL_INTERVAL_MS);
|
|
128
|
+
}
|
|
129
|
+
if (!last)
|
|
130
|
+
return { output: 'ERROR: tidak ada respons dari gateway.' };
|
|
131
|
+
const d = last.data || {};
|
|
132
|
+
if (d.state === 'running') {
|
|
133
|
+
return {
|
|
134
|
+
output: `job ${job} masih running (codespace: ${d.codespaceState || '?'}). Panggil lagi nanti.`,
|
|
135
|
+
summary: `design job ${job}: running`,
|
|
136
|
+
data: d,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (d.state === 'done') {
|
|
140
|
+
return {
|
|
141
|
+
output: `job ${job} SELESAI. Model: ${d.result?.model || '?'}, bytes: ${d.result?.bytes || '?'}. ` +
|
|
142
|
+
`Ambil hasil dengan aksi "result" job=${job}.`,
|
|
143
|
+
summary: `design job ${job}: done ✓`,
|
|
144
|
+
data: d,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// failed / expired / lost
|
|
148
|
+
const why = d.error || d.result?.error || '(tanpa detail)';
|
|
149
|
+
const hint = d.state === 'expired'
|
|
150
|
+
? ' Melewati TTL 30 menit — hasil bisa saja tetap ter-push ke branch GitHub; cek repo skill.'
|
|
151
|
+
: d.state === 'lost'
|
|
152
|
+
? ' Codespace hilang tanpa hasil.'
|
|
153
|
+
: '';
|
|
154
|
+
return {
|
|
155
|
+
output: `job ${job} berakhir dengan state=${d.state}. ${why}${hint}`,
|
|
156
|
+
summary: `design job ${job}: ${d.state}`,
|
|
157
|
+
data: d,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
async function resultAction(args, ctx) {
|
|
161
|
+
const job = String(args.job ?? '').trim();
|
|
162
|
+
if (!/^[a-z0-9-]{4,64}$/.test(job))
|
|
163
|
+
return { output: 'ERROR: job id tidak valid.' };
|
|
164
|
+
const saveTo = String(args.save_to ?? '').trim();
|
|
165
|
+
let res;
|
|
166
|
+
try {
|
|
167
|
+
// Selalu format=json agar struktur respons seragam (indexHtml + result).
|
|
168
|
+
res = await fetch(RESULT_URL(job, true), { signal: AbortSignal.timeout(60_000) });
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
return { output: `ERROR: gateway tidak terjangkau: ${e.message}` };
|
|
172
|
+
}
|
|
173
|
+
if (res.status === 404) {
|
|
174
|
+
return { output: `ERROR: hasil job ${job} tidak ditemukan (belum done / kedaluwarsa).` };
|
|
175
|
+
}
|
|
176
|
+
if (!res.ok)
|
|
177
|
+
return { output: `ERROR: /v1/design/result HTTP ${res.status}` };
|
|
178
|
+
const jj = await res.json();
|
|
179
|
+
const html = String(jj.indexHtml || '');
|
|
180
|
+
if (!html)
|
|
181
|
+
return { output: `ERROR: hasil job ${job} kosong.` };
|
|
182
|
+
if (saveTo) {
|
|
183
|
+
// Simpan hanya di dalam project root ctx.cwd (anti path-traversal) —
|
|
184
|
+
// konsisten dgn tool file lain (bukan process.cwd() proses CLI).
|
|
185
|
+
const path = await Promise.resolve().then(() => __importStar(require('node:path')));
|
|
186
|
+
const fs = await Promise.resolve().then(() => __importStar(require('node:fs/promises')));
|
|
187
|
+
const root = ctx ? ctx.cwd : process.cwd();
|
|
188
|
+
const target = path.resolve(root, saveTo);
|
|
189
|
+
if (!target.startsWith(root + path.sep) && target !== root) {
|
|
190
|
+
return { output: `ERROR: save_to harus di dalam project (diterima: ${saveTo}).` };
|
|
191
|
+
}
|
|
192
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
193
|
+
await fs.writeFile(target, html, 'utf8');
|
|
194
|
+
return {
|
|
195
|
+
output: `hasil job ${job} (${html.length} byte) disimpan ke ${saveTo}` +
|
|
196
|
+
(/<\/html>\s*$/i.test(html) ? '' : ' — PERINGATAN: HTML tidak berakhir </html> (kemungkinan terpotong).'),
|
|
197
|
+
summary: `design job ${job}: disimpan ${saveTo}`,
|
|
198
|
+
data: { job, savedTo: saveTo, bytes: html.length, complete: /<\/html>\s*$/i.test(html) },
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
const head = html.slice(0, 1500);
|
|
202
|
+
return {
|
|
203
|
+
output: `hasil job ${job} (${html.length} byte):\n` +
|
|
204
|
+
(/<\/html>\s*$/i.test(html) ? '(HTML tampak lengkap)\n' : 'PERINGATAN: HTML TIDAK berakhir </html> — kemungkinan terpotong!\n') +
|
|
205
|
+
head +
|
|
206
|
+
(html.length > 1500 ? '\n…[dipotong — gunakan save_to untuk menyimpan file utuh]' : ''),
|
|
207
|
+
summary: `design job ${job}: ${html.length} byte`,
|
|
208
|
+
data: { job, bytes: html.length, complete: /<\/html>\s*$/i.test(html), indexHtml: html },
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function createDesignLabTools() {
|
|
212
|
+
return [
|
|
213
|
+
{
|
|
214
|
+
definition: {
|
|
215
|
+
name: 'design_lab',
|
|
216
|
+
description: 'Run the remote design-lab pipeline (GitHub Codespace + ui-ux-pro-max skill) via the VectorHead gateway. ' +
|
|
217
|
+
'Actions: "run" {brief} starts a one-file HTML design job (4-6 min, 1 concurrent job, 5 jobs/hour); ' +
|
|
218
|
+
'"status" {job, wait?} polls until done/failed/expired/lost (wait=true blocks up to 8 min); ' +
|
|
219
|
+
'"result" {job, save_to?} fetches the finished index.html (save_to writes it into the project). ' +
|
|
220
|
+
'Use when the user asks for a landing page / one-file HTML design produced by the design-lab pipeline.',
|
|
221
|
+
parameters: {
|
|
222
|
+
type: 'object',
|
|
223
|
+
properties: {
|
|
224
|
+
action: { type: 'string', enum: ['run', 'status', 'result'], description: 'Which design-lab step to perform' },
|
|
225
|
+
brief: { type: 'string', description: 'Design brief (action "run", 10-4000 chars)' },
|
|
226
|
+
job: { type: 'string', description: 'Job id from a previous "run" (actions "status"/"result")' },
|
|
227
|
+
wait: { type: 'boolean', description: 'action "status": block and poll until terminal state (default false)' },
|
|
228
|
+
save_to: { type: 'string', description: 'action "result": project-relative path to save index.html (e.g. "output/index.html")' },
|
|
229
|
+
},
|
|
230
|
+
required: ['action'],
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
async execute(args, ctx) {
|
|
234
|
+
switch (args.action) {
|
|
235
|
+
case 'run':
|
|
236
|
+
return runAction(args);
|
|
237
|
+
case 'status':
|
|
238
|
+
return statusAction(args);
|
|
239
|
+
case 'result':
|
|
240
|
+
return resultAction(args, ctx);
|
|
241
|
+
default:
|
|
242
|
+
return { output: 'ERROR: action harus salah satu dari "run" | "status" | "result".' };
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
];
|
|
247
|
+
}
|