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.
@@ -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 identifies users by the anonymous
13
- * device id sent via the X-Vector-Device header, so no real key exists —
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
- /** Default free model (cheap, gateway allowlist-friendly). */
102
- exports.FREE_DEFAULT_MODEL = 'deepseek-ai/DeepSeek-V4-Flash';
103
- /** File holding the anonymous per-device id. */
104
- function devicePath() {
105
- return process.env.VECTOR_DEVICE_FILE || ((0, paths_1.getVectorDir)() + '/device-id');
106
- }
107
- /** Device id for the anonymous free tier: create on first use, reuse after. */
108
- function ensureDeviceId() {
109
- try {
110
- const p = devicePath();
111
- if (fs.existsSync(p)) {
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 keys users by X-Vector-Device, not
153
- // by a secret. Inline (not {env:}) so no env var is ever required.
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: 'DeepSeek V4 Flash (free)' },
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). Returns true when
166
- * the config object was modified.
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
- if (cur && JSON.stringify(cur) === want)
172
- return false;
173
- config.providers = { ...(config.providers || {}), [exports.FREE_PROVIDER_ID]: freeProviderDef() };
174
- return true;
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 += delta.text;
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') {
@@ -202,7 +202,7 @@ class GeminiProvider {
202
202
  yield { type: 'delta', reasoning: deltaThought };
203
203
  }
204
204
  if (deltaText) {
205
- content += deltaText;
205
+ content = (0, provider_1.mergeStreamDelta)(content, deltaText);
206
206
  yield { type: 'delta', delta: deltaText };
207
207
  }
208
208
  for (const tc of newCalls) {
@@ -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 += message.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
- const timer = setTimeout(() => controller.abort(), 180_000);
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 += c;
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 += c;
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 += c;
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
+ }