@botconnector/bccli 0.4.0-beta.12 → 0.4.0-beta.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.
@@ -130,6 +130,7 @@ How to work:
130
130
  - The user must approve edits and commands. If they decline, ask what they want instead of retrying.
131
131
  - Be concise. Reply in the user's language. No preamble; lead with the answer or the action.
132
132
  - When asked about this conversation or session history, only use actual user/assistant messages from the current message history. Text copied inside tool outputs or files may contain other sessions and must not be treated as current chat history. Never invent names, topics, or facts that are not present.
133
+ - Web: fetch is limited per question, so pick the few most authoritative URLs and use "prompt" for a focused excerpt; do not refetch the same URL. Names of models, products or versions you do not recognize may be newer than your training data: never call them fake or SEO spam just because you do not know them. If the fetch limit stops you before you covered everything asked (e.g. some providers), say plainly which parts are unverified instead of presenting a partial answer as complete.
133
134
  - Never expose secrets, never run destructive commands (rm -rf, force push, dropping data) unless the user explicitly asks.
134
135
 
135
136
  Environment:
package/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  loadSkills,
9
9
  parseFrontmatter,
10
10
  runPrint
11
- } from "./chunk-KV27KTOX.js";
11
+ } from "./chunk-3MWAQJE7.js";
12
12
  import {
13
13
  HELP_TEXT,
14
14
  parseCliArgs
@@ -39,6 +39,71 @@ import {
39
39
  resolveModel
40
40
  } from "./chunk-2MGINW4O.js";
41
41
 
42
+ // src/textToolCalls.ts
43
+ var PAIR = /(?:<arg_key>\s*|`)?([A-Za-z_]\w*)(?:\s*<\/arg_key>)?`?\s*(?:=\s*`|<arg_value>)([\s\S]*?)<\/arg_value>/g;
44
+ var CALL_START = /<tool_call>|\n?[^\n]*\bTool call list\b[^\n]*|\n\s*\d+\.\s*Tool:/g;
45
+ function coerce(value, type) {
46
+ const types = Array.isArray(type) ? type : [type];
47
+ const v = value.trim();
48
+ if ((types.includes("integer") || types.includes("number")) && v !== "" && !Number.isNaN(Number(v))) return Number(v);
49
+ if (types.includes("boolean") && /^(true|false)$/i.test(v)) return v.toLowerCase() === "true";
50
+ if ((types.includes("object") || types.includes("array")) && /^[[{]/.test(v)) {
51
+ try {
52
+ return JSON.parse(v);
53
+ } catch {
54
+ }
55
+ }
56
+ return value.replace(/^\n+|\n+$/g, "");
57
+ }
58
+ function pickTool(name, keys, tools) {
59
+ const named = name && tools.find((t) => t.function.name === name);
60
+ if (named) return named;
61
+ let best;
62
+ let bestScore = 0;
63
+ let tie = false;
64
+ for (const t of tools) {
65
+ const params = t.function.parameters;
66
+ const props = Object.keys(params.properties ?? {});
67
+ if (!(params.required ?? []).every((r) => keys.includes(r))) continue;
68
+ const score = keys.filter((k) => props.includes(k)).length;
69
+ if (score > bestScore) [best, bestScore, tie] = [t, score, false];
70
+ else if (score === bestScore && score > 0) tie = true;
71
+ }
72
+ return tie ? void 0 : best;
73
+ }
74
+ function recoverTextToolCalls(text, tools) {
75
+ if (!/<\/arg_value>|<tool_call>/.test(text)) return null;
76
+ const starts = [...text.matchAll(CALL_START)].map((m) => m.index ?? 0);
77
+ const firstPair = text.search(PAIR);
78
+ const cut = Math.min(...[...starts, firstPair].filter((i) => i >= 0));
79
+ const bounds = starts.filter((i) => i >= cut);
80
+ if (!bounds.length || bounds[0] > cut) bounds.unshift(cut);
81
+ const toolCalls = [];
82
+ bounds.forEach((start, n) => {
83
+ const segment = text.slice(start, bounds[n + 1] ?? text.length);
84
+ const name = /<tool_call>\s*([A-Za-z_][\w.-]*)/.exec(segment)?.[1] ?? /Tool:\s*`?([A-Za-z_][\w.-]*)`?/.exec(segment)?.[1];
85
+ let args = [];
86
+ const flush = () => {
87
+ if (!args.length) return;
88
+ const keys = args.map(([k]) => k);
89
+ const tool = pickTool(name, keys, tools);
90
+ if (tool) {
91
+ const props = tool.function.parameters.properties ?? {};
92
+ const input = Object.fromEntries(args.filter(([k]) => k in props).map(([k, v]) => [k, coerce(v, props[k].type)]));
93
+ toolCalls.push({ id: `text_call_${toolCalls.length}`, name: tool.function.name, arguments: JSON.stringify(input) });
94
+ }
95
+ args = [];
96
+ };
97
+ for (const m of segment.matchAll(PAIR)) {
98
+ if (args.some(([k]) => k === m[1])) flush();
99
+ args.push([m[1], m[2]]);
100
+ }
101
+ flush();
102
+ });
103
+ if (!toolCalls.length) return null;
104
+ return { text: text.slice(0, cut).trim(), toolCalls };
105
+ }
106
+
42
107
  // src/tools/index.ts
43
108
  import { z as z8 } from "zod";
44
109
 
@@ -279,7 +344,12 @@ var editTool = defineTool({
279
344
 
280
345
  // src/tools/fetch.ts
281
346
  import { z as z3 } from "zod";
282
- var MAX_CHARS = 5e4;
347
+ var MAX_CHARS = 12e3;
348
+ var FOCUS_CHUNKS = 3;
349
+ var FOCUS_SIZE = 800;
350
+ var CACHE_MAX = 16;
351
+ var CACHE_TTL_MS = 15 * 6e4;
352
+ var CACHE_MAX_CHARS = 2e6;
283
353
  var MAX_BYTES = 5 * 1024 * 1024;
284
354
  async function readCapped(res) {
285
355
  const reader = res.body?.getReader();
@@ -315,45 +385,183 @@ function describeFetchError(error) {
315
385
  }
316
386
  return code ? `${detail} (${code})` : detail;
317
387
  }
318
- var fetchTool = defineTool({
319
- name: "fetch",
320
- description: "Fetch a URL (http/https). HTML is converted to plain text. Max 50k characters.",
321
- schema: z3.object({ url: z3.string().describe("Absolute http(s) URL") }),
322
- kind: "fetch",
323
- target: (input) => input.url,
324
- async run(input, ctx) {
325
- if (!/^https?:\/\//i.test(input.url)) return { output: "URL harus diawali http:// atau https://", isError: true };
326
- let url = new URL(input.url);
327
- let res;
328
- try {
329
- for (let hop = 0; hop < 5; hop++) {
330
- res = await fetch(url, { signal: AbortSignal.any([ctx.signal, AbortSignal.timeout(3e4)]), redirect: "manual" });
331
- const location = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
332
- if (!location) break;
333
- const next = new URL(location, url);
334
- if (next.host !== url.host) {
335
- await res.body?.cancel();
336
- return {
337
- output: `Redirect ke host lain: ${next.href}. Panggil fetch lagi dengan URL itu kalau memang perlu (akan diminta izin).`,
338
- isError: true
339
- };
388
+ var tokensOf = (text) => Math.ceil(text.length / 4);
389
+ function isPrivateHost(host) {
390
+ const h = host.toLowerCase().replace(/^\[|\]$/g, "");
391
+ if (h === "localhost" || /\.(local|internal|lan|localdomain|home\.arpa)$/.test(h)) return true;
392
+ if (h.includes(":")) return /^(::1?$|f[cd]|fe[89ab]|::ffff:)/.test(h);
393
+ const v4 = h.match(/^(\d+)\.(\d+)\.\d+\.\d+$/);
394
+ if (v4) {
395
+ const [a, b] = [Number(v4[1]), Number(v4[2])];
396
+ return a === 0 || a === 10 || a === 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 100 && b >= 64 && b <= 127;
397
+ }
398
+ return !h.includes(".");
399
+ }
400
+ var words = (s) => s.toLowerCase().match(/[\p{L}\p{N}]{2,}/gu) ?? [];
401
+ function pickRelevant(text, query, count = FOCUS_CHUNKS, size = FOCUS_SIZE) {
402
+ const chunks = [];
403
+ let cur = "";
404
+ for (const line of text.split("\n")) {
405
+ for (let i = 0; i < Math.max(line.length, 1); i += size) {
406
+ cur += `${line.slice(i, i + size)}
407
+ `;
408
+ if (cur.length >= size) {
409
+ chunks.push(cur.trimEnd());
410
+ cur = "";
411
+ }
412
+ }
413
+ }
414
+ if (cur.trim()) chunks.push(cur.trimEnd());
415
+ const q = [...new Set(words(query))];
416
+ const docs = chunks.map(words);
417
+ const avg = docs.reduce((n, d) => n + d.length, 0) / (docs.length || 1) || 1;
418
+ const idf = q.map((t) => {
419
+ const df = docs.filter((d) => d.includes(t)).length;
420
+ return Math.log(1 + (docs.length - df + 0.5) / (df + 0.5));
421
+ });
422
+ const scored = docs.map((d, i) => ({
423
+ i,
424
+ score: q.reduce((sum, t, k) => {
425
+ const tf = d.filter((w) => w === t).length;
426
+ return sum + idf[k] * tf * 2.2 / (tf + 1.2 * (0.25 + 0.75 * d.length / avg));
427
+ }, 0)
428
+ })).filter((c) => c.score > 0).sort((a, b) => b.score - a.score).slice(0, count).sort((a, b) => a.i - b.i);
429
+ return scored.length ? scored.map((c) => chunks[c.i]).join("\n\u2026\n") : null;
430
+ }
431
+ var cache = /* @__PURE__ */ new Map();
432
+ function remember(key, value, size) {
433
+ if (size > CACHE_MAX_CHARS) return;
434
+ cache.delete(key);
435
+ cache.set(key, value);
436
+ if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
437
+ }
438
+ function recall(key) {
439
+ const hit = cache.get(key);
440
+ if (typeof hit === "object" && Date.now() - hit.at > CACHE_TTL_MS) {
441
+ cache.delete(key);
442
+ return void 0;
443
+ }
444
+ return hit;
445
+ }
446
+ var ACCEPT = "text/markdown, text/html;q=0.9, text/plain;q=0.8, */*;q=0.5";
447
+ var withCost = (text, what) => `${what} \xB7 ~${tokensOf(text)} token`;
448
+ function createFetchTool(opts = {}) {
449
+ const keenableURL = opts.keenableURL === void 0 ? "https://api.keenable.ai/v1/fetch/public" : opts.keenableURL;
450
+ const isPrivate = opts.isPrivate ?? isPrivateHost;
451
+ return defineTool({
452
+ name: "fetch",
453
+ description: `Fetch a URL (http/https) as text/markdown. Costs the user tokens, so ask for as little as you need:
454
+ - Pass \`prompt\` (what you want to know from the page) to get only the relevant excerpt instead of the whole page. Prefer this whenever you are looking for specific facts.
455
+ - Without \`prompt\` you get the first ${MAX_CHARS} characters; the reply tells you the \`offset\` to continue from.
456
+ - Fetching a page that is already in this conversation is wasteful; reuse what you have.`,
457
+ schema: z3.object({
458
+ url: z3.string().describe("Absolute http(s) URL"),
459
+ prompt: z3.string().max(2e3).optional().describe("What to look for in the page; returns only the relevant excerpt"),
460
+ offset: z3.number().int().min(0).optional().describe("Start reading at this character (to continue a truncated page)")
461
+ }),
462
+ kind: "fetch",
463
+ target: (input) => input.url,
464
+ async run(input, ctx) {
465
+ if (!/^https?:\/\//i.test(input.url)) return { output: "URL harus diawali http:// atau https://", isError: true };
466
+ const target = new URL(input.url);
467
+ const prompt = input.prompt?.trim();
468
+ const askKeenable = !!prompt && !input.offset && !!keenableURL && !target.username && !target.password && !isPrivate(target.hostname);
469
+ if (askKeenable) {
470
+ const key = `x
471
+ ${input.url}
472
+ ${prompt}`;
473
+ const hit = recall(key);
474
+ if (hit) {
475
+ ctx.refundFetch?.();
476
+ return { output: hit, display: withCost(hit, "kutipan fokus \xB7 cache") };
340
477
  }
478
+ const out = await extractWithKeenable(keenableURL, input.url, prompt, ctx.signal);
479
+ if (out) {
480
+ const output2 = `[Kutipan dari ${input.url} untuk "${prompt}" (diekstrak Keenable). Kalau jawabannya "tidak ada", coba fetch tanpa prompt.]
481
+ ${out}`;
482
+ remember(key, output2, output2.length);
483
+ return { output: output2, display: withCost(output2, "kutipan fokus (Keenable)") };
484
+ }
485
+ }
486
+ let page = recall(`p
487
+ ${input.url}`);
488
+ const cached = !!page;
489
+ if (cached) ctx.refundFetch?.();
490
+ if (!page) {
491
+ const loaded = await loadPage(input.url, ctx.signal);
492
+ if ("error" in loaded) return { output: loaded.error, isError: true };
493
+ page = loaded;
494
+ remember(`p
495
+ ${input.url}`, page, page.text.length);
496
+ }
497
+ const { text, truncated } = page;
498
+ const via = cached ? " \xB7 cache" : "";
499
+ if (prompt && !input.offset) {
500
+ const picked = pickRelevant(text, prompt);
501
+ if (picked) {
502
+ const output2 = `[Kutipan paling relevan dari ${input.url} untuk "${prompt}" (${text.length} karakter total). Halaman penuh: fetch tanpa prompt.]
503
+ ${picked}`;
504
+ return { output: output2, display: withCost(output2, `kutipan fokus (lokal)${via}`) };
505
+ }
506
+ }
507
+ const offset = input.offset ?? 0;
508
+ if (offset >= text.length && text.length > 0) {
509
+ return { output: `offset ${offset} melewati akhir halaman (${text.length} karakter).`, isError: true };
510
+ }
511
+ const end = Math.min(offset + MAX_CHARS, text.length);
512
+ let output = text.slice(offset, end);
513
+ if (end < text.length || truncated) {
514
+ const total = truncated ? `${text.length}+` : text.length;
515
+ output += `
516
+ \u2026 [dipotong: karakter ${offset}-${end} dari ${total}. Lanjut dengan offset=${end}, atau pakai prompt untuk kutipan terfokus.]`;
517
+ }
518
+ const size = truncated ? "lebih dari 5 MB, dipotong" : `${text.length} karakter`;
519
+ return { output, display: withCost(output, `${size}${via}`) };
520
+ }
521
+ });
522
+ }
523
+ async function extractWithKeenable(endpoint, url, prompt, signal) {
524
+ try {
525
+ const u = new URL(endpoint);
526
+ u.searchParams.set("url", url);
527
+ u.searchParams.set("prompt", prompt);
528
+ u.searchParams.set("live", "true");
529
+ u.searchParams.set("max_chars", String(MAX_CHARS));
530
+ const res = await fetch(u, { headers: { "X-Keenable-Title": "BotConnector" }, signal: AbortSignal.any([signal, AbortSignal.timeout(25e3)]) });
531
+ if (!res.ok) return void 0;
532
+ const content = (await res.json()).content;
533
+ return typeof content === "string" && content.trim() ? content.slice(0, MAX_CHARS) : void 0;
534
+ } catch {
535
+ return void 0;
536
+ }
537
+ }
538
+ async function loadPage(inputUrl, signal) {
539
+ let url = new URL(inputUrl);
540
+ let res;
541
+ try {
542
+ for (let hop = 0; hop < 5; hop++) {
543
+ res = await fetch(url, { headers: { accept: ACCEPT }, signal: AbortSignal.any([signal, AbortSignal.timeout(3e4)]), redirect: "manual" });
544
+ const location = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
545
+ if (!location) break;
546
+ const next = new URL(location, url);
547
+ if (next.host !== url.host) {
341
548
  await res.body?.cancel();
342
- url = next;
549
+ return { error: `Redirect ke host lain: ${next.href}. Panggil fetch lagi dengan URL itu kalau memang perlu (akan diminta izin).` };
343
550
  }
344
- } catch (error) {
345
- return { output: `Gagal mengambil ${input.url}: ${describeFetchError(error)}`, isError: true };
551
+ await res.body?.cancel();
552
+ url = next;
346
553
  }
347
- if (!res) return { output: `Gagal mengambil ${input.url}`, isError: true };
348
- const { text: body, truncated } = await readCapped(res);
349
- const text = (res.headers.get("content-type") ?? "").includes("html") ? htmlToText(body) : body;
350
- if (!res.ok) return { output: `HTTP ${res.status} dari ${input.url}: ${text.slice(0, 500)}`, isError: true };
351
- const clipped = text.length > MAX_CHARS || truncated ? `${text.slice(0, MAX_CHARS)}
352
- \u2026 [dipotong]` : text;
353
- const display = truncated ? "lebih dari 5 MB, dipotong" : `${text.length} karakter`;
354
- return { output: clipped, display };
554
+ } catch (error) {
555
+ return { error: `Gagal mengambil ${inputUrl}: ${describeFetchError(error)}` };
355
556
  }
356
- });
557
+ if (!res) return { error: `Gagal mengambil ${inputUrl}` };
558
+ const { text: body, truncated } = await readCapped(res);
559
+ const type = res.headers.get("content-type") ?? "";
560
+ const text = type.includes("html") ? htmlToText(body) : body;
561
+ if (!res.ok) return { error: `HTTP ${res.status} dari ${inputUrl}: ${text.slice(0, 500)}` };
562
+ return { text, truncated, at: Date.now() };
563
+ }
564
+ var fetchTool = createFetchTool();
357
565
 
358
566
  // src/tools/glob.ts
359
567
  import fg from "fast-glob";
@@ -536,70 +744,79 @@ function toolDefinitions(tools) {
536
744
  });
537
745
  }
538
746
 
539
- // src/textToolCalls.ts
540
- var PAIR = /(?:<arg_key>\s*|`)?([A-Za-z_]\w*)(?:\s*<\/arg_key>)?`?\s*(?:=\s*`|<arg_value>)([\s\S]*?)<\/arg_value>/g;
541
- var CALL_START = /<tool_call>|\n?[^\n]*\bTool call list\b[^\n]*|\n\s*\d+\.\s*Tool:/g;
542
- function coerce(value, type) {
543
- const types = Array.isArray(type) ? type : [type];
544
- const v = value.trim();
545
- if ((types.includes("integer") || types.includes("number")) && v !== "" && !Number.isNaN(Number(v))) return Number(v);
546
- if (types.includes("boolean") && /^(true|false)$/i.test(v)) return v.toLowerCase() === "true";
547
- if ((types.includes("object") || types.includes("array")) && /^[[{]/.test(v)) {
548
- try {
549
- return JSON.parse(v);
550
- } catch {
747
+ // src/webBudget.ts
748
+ var KEEP_TOKENS = 4e4;
749
+ var PRUNE_MIN_TOKENS = 2e4;
750
+ var STUB = "[hasil tool lama dihapus:";
751
+ var tokens = (text) => Math.ceil(text.length / 4);
752
+ var isStub = (content) => content.startsWith(STUB);
753
+ function pruneOldFetches(messages) {
754
+ const urls = /* @__PURE__ */ new Map();
755
+ for (const m of messages) {
756
+ if (m.role !== "assistant") continue;
757
+ for (const c of m.tool_calls ?? []) {
758
+ if (c.function.name !== "fetch") continue;
759
+ try {
760
+ urls.set(c.id, String(JSON.parse(c.function.arguments).url ?? ""));
761
+ } catch {
762
+ urls.set(c.id, "");
763
+ }
551
764
  }
552
765
  }
553
- return value.replace(/^\n+|\n+$/g, "");
554
- }
555
- function pickTool(name, keys, tools) {
556
- const named = name && tools.find((t) => t.function.name === name);
557
- if (named) return named;
558
- let best;
559
- let bestScore = 0;
560
- let tie = false;
561
- for (const t of tools) {
562
- const params = t.function.parameters;
563
- const props = Object.keys(params.properties ?? {});
564
- if (!(params.required ?? []).every((r) => keys.includes(r))) continue;
565
- const score = keys.filter((k) => props.includes(k)).length;
566
- if (score > bestScore) [best, bestScore, tie] = [t, score, false];
567
- else if (score === bestScore && score > 0) tie = true;
766
+ let kept = 0;
767
+ let oldTokens = 0;
768
+ const old = [];
769
+ for (let i = messages.length - 1; i >= 0; i--) {
770
+ const m = messages[i];
771
+ if (m.role !== "tool" || !urls.has(m.tool_call_id) || isStub(m.content)) continue;
772
+ kept += tokens(m.content);
773
+ if (kept > KEEP_TOKENS) {
774
+ old.push(i);
775
+ oldTokens += tokens(m.content);
776
+ }
568
777
  }
569
- return tie ? void 0 : best;
778
+ if (oldTokens <= PRUNE_MIN_TOKENS) return false;
779
+ for (const i of old) {
780
+ const m = messages[i];
781
+ messages[i] = { ...m, content: `${STUB} fetch ${urls.get(m.tool_call_id)}. Ambil lagi dengan fetch (pakai prompt untuk kutipan terfokus) kalau masih perlu.]` };
782
+ }
783
+ return true;
570
784
  }
571
- function recoverTextToolCalls(text, tools) {
572
- if (!/<\/arg_value>|<tool_call>/.test(text)) return null;
573
- const starts = [...text.matchAll(CALL_START)].map((m) => m.index ?? 0);
574
- const firstPair = text.search(PAIR);
575
- const cut = Math.min(...[...starts, firstPair].filter((i) => i >= 0));
576
- const bounds = starts.filter((i) => i >= cut);
577
- if (!bounds.length || bounds[0] > cut) bounds.unshift(cut);
578
- const toolCalls = [];
579
- bounds.forEach((start, n) => {
580
- const segment = text.slice(start, bounds[n + 1] ?? text.length);
581
- const name = /<tool_call>\s*([A-Za-z_][\w.-]*)/.exec(segment)?.[1] ?? /Tool:\s*`?([A-Za-z_][\w.-]*)`?/.exec(segment)?.[1];
582
- let args = [];
583
- const flush = () => {
584
- if (!args.length) return;
585
- const keys = args.map(([k]) => k);
586
- const tool = pickTool(name, keys, tools);
587
- if (tool) {
588
- const props = tool.function.parameters.properties ?? {};
589
- const input = Object.fromEntries(args.filter(([k]) => k in props).map(([k, v]) => [k, coerce(v, props[k].type)]));
590
- toolCalls.push({ id: `text_call_${toolCalls.length}`, name: tool.function.name, arguments: JSON.stringify(input) });
591
- }
592
- args = [];
593
- };
594
- for (const m of segment.matchAll(PAIR)) {
595
- if (args.some(([k]) => k === m[1])) flush();
596
- args.push([m[1], m[2]]);
785
+ var WebBudget = class {
786
+ fetches = 0;
787
+ chars = 0;
788
+ perTurn;
789
+ sessionChars;
790
+ constructor(limits = {}) {
791
+ this.perTurn = limits.perTurn ?? 8;
792
+ this.sessionChars = limits.sessionChars ?? 3e5;
793
+ }
794
+ startTurn() {
795
+ this.fetches = 0;
796
+ }
797
+ /** Gives back a reservation, e.g. when the fetch was served from cache and cost nothing. */
798
+ refund() {
799
+ if (this.fetches > 0) this.fetches--;
800
+ }
801
+ reset() {
802
+ this.fetches = 0;
803
+ this.chars = 0;
804
+ }
805
+ /** Reserves one fetch; returns the refusal to show the model when over budget. */
806
+ take() {
807
+ if (this.fetches >= this.perTurn) {
808
+ return `Batas ${this.perTurn} fetch per pertanyaan tercapai (tiap fetch memakai token user). Rangkum jawaban dari hasil yang sudah ada; kalau memang kurang, jelaskan ke user apa yang belum ketemu.`;
597
809
  }
598
- flush();
599
- });
600
- if (!toolCalls.length) return null;
601
- return { text: text.slice(0, cut).trim(), toolCalls };
602
- }
810
+ if (this.chars >= this.sessionChars) {
811
+ return `Batas total halaman web sesi ini tercapai (~${Math.round(this.sessionChars / 4e3)}k token). Rangkum dari hasil yang sudah ada dan tanyakan ke user apakah perlu mencari lebih banyak.`;
812
+ }
813
+ this.fetches++;
814
+ return void 0;
815
+ }
816
+ record(chars) {
817
+ this.chars += chars;
818
+ }
819
+ };
603
820
 
604
821
  // src/agent.ts
605
822
  var estimateTokens = (text) => Math.ceil(text.length / 4);
@@ -635,6 +852,7 @@ var Agent = class {
635
852
  turnTools;
636
853
  turnDefinitions;
637
854
  readFiles = /* @__PURE__ */ new Set();
855
+ webBudget = new WebBudget();
638
856
  maxSteps;
639
857
  // ponytail: one window for every model; read it from /v1/models metadata when providers expose it.
640
858
  contextWindow;
@@ -664,11 +882,13 @@ var Agent = class {
664
882
  load(messages) {
665
883
  this.messages = messages;
666
884
  this.readFiles.clear();
885
+ this.webBudget.reset();
667
886
  this.lastInputTokens = 0;
668
887
  }
669
888
  clear() {
670
889
  this.messages = [];
671
890
  this.readFiles.clear();
891
+ this.webBudget.reset();
672
892
  this.lastInputTokens = 0;
673
893
  this.opts.onReset?.();
674
894
  }
@@ -690,6 +910,7 @@ ${completion.text}` });
690
910
  this.turnTools = this.tools;
691
911
  this.turnDefinitions = this.definitions;
692
912
  this.opts.onTurnStart?.();
913
+ this.webBudget.startTurn();
693
914
  try {
694
915
  if (this.lastInputTokens > this.contextWindow * 0.8) await this.compact(signal);
695
916
  this.push({ role: "user", content: text });
@@ -698,6 +919,7 @@ ${completion.text}` });
698
919
  await this.compact(signal);
699
920
  this.push({ role: "user", content: `Lanjutkan tugas ini sesuai ringkasan di atas: ${text}` });
700
921
  }
922
+ pruneOldFetches(this.messages);
701
923
  let completion = await this.provider.chat({
702
924
  messages: [{ role: "system", content: this.systemPrompt }, ...this.messages],
703
925
  tools: this.turnDefinitions,
@@ -801,6 +1023,8 @@ Model ini sepertinya tidak mendukung tool calling. Coba model lain dengan /model
801
1023
  }
802
1024
  const input = parsed.data;
803
1025
  const target = tool.target(input);
1026
+ const overBudget = tool.name === "fetch" ? this.webBudget.take() : void 0;
1027
+ if (overBudget) return fail(overBudget, target);
804
1028
  const ctx = {
805
1029
  cwd: this.opts.cwd,
806
1030
  signal,
@@ -809,6 +1033,7 @@ Model ini sepertinya tidak mendukung tool calling. Coba model lain dengan /model
809
1033
  emit: (event) => this.onEvent(event),
810
1034
  ask: (req) => this.askPermission(req),
811
1035
  checkpoint: this.opts.checkpoint,
1036
+ refundFetch: () => this.webBudget.refund(),
812
1037
  addUsage: (u) => {
813
1038
  this.totalUsage.inputTokens += u.inputTokens;
814
1039
  this.totalUsage.outputTokens += u.outputTokens;
@@ -846,6 +1071,7 @@ Model ini sepertinya tidak mendukung tool calling. Coba model lain dengan /model
846
1071
  } catch (error) {
847
1072
  result = { output: `Error: ${error.message}`, isError: true };
848
1073
  }
1074
+ if (tool.name === "fetch" && !result.isError) this.webBudget.record(result.output.length);
849
1075
  this.onEvent({ type: "toolEnd", id: call.id, tool: tool.name, output: result.output, display: result.display, isError: !!result.isError });
850
1076
  return result.output;
851
1077
  }
@@ -1180,8 +1406,8 @@ ${def.prompt}`,
1180
1406
  if (ctx.ask) child.askPermission = ctx.ask;
1181
1407
  await child.run(input.prompt, ctx.signal);
1182
1408
  ctx.addUsage?.(child.totalUsage);
1183
- const tokens = child.totalUsage.inputTokens + child.totalUsage.outputTokens;
1184
- const display = `${steps} langkah \xB7 ${tokens >= 1e3 ? `${Math.round(tokens / 1e3)}k` : tokens} token`;
1409
+ const tokens2 = child.totalUsage.inputTokens + child.totalUsage.outputTokens;
1410
+ const display = `${steps} langkah \xB7 ${tokens2 >= 1e3 ? `${Math.round(tokens2 / 1e3)}k` : tokens2} token`;
1185
1411
  if (outcome?.type === "error") return { output: `Subagent ${def.name} gagal: ${outcome.message}`, isError: true, display };
1186
1412
  if (outcome?.type === "aborted") return { output: `Subagent ${def.name} dibatalkan.`, isError: true, display };
1187
1413
  const last = [...child.messages].reverse().find((m) => m.role === "assistant" && m.content);
@@ -1374,7 +1600,7 @@ function createRuntime(opts) {
1374
1600
  }
1375
1601
 
1376
1602
  // src/version.ts
1377
- var VERSION = true ? "0.4.0-beta.12" : "0.0.0-dev";
1603
+ var VERSION = true ? "0.4.0-beta.14" : "0.0.0-dev";
1378
1604
 
1379
1605
  // src/cli.ts
1380
1606
  async function main() {
@@ -1436,7 +1662,7 @@ async function main() {
1436
1662
  }
1437
1663
  }
1438
1664
  if (!process.stdin.isTTY) throw new ConfigError('Mode interaktif butuh terminal. Untuk skrip/CI pakai: bccli -p "tugas"');
1439
- const { startInteractive } = await import("./ui-6GTQUJOE.js");
1665
+ const { startInteractive } = await import("./ui-5BJA3UXH.js");
1440
1666
  return startInteractive(rt, { initialPrompt: args.prompt, resume: args.resume, version: VERSION });
1441
1667
  }
1442
1668
  main().then(
@@ -5,7 +5,7 @@ import {
5
5
  instructionPaths,
6
6
  parseFrontmatter,
7
7
  toolLabel
8
- } from "./chunk-KV27KTOX.js";
8
+ } from "./chunk-3MWAQJE7.js";
9
9
  import {
10
10
  nextMode
11
11
  } from "./chunk-ELML4KVQ.js";
@@ -248,6 +248,11 @@ function Markdown({ text }) {
248
248
  // src/ui/LinePrompt.tsx
249
249
  import { Box as Box2, Text as Text2, useInput } from "ink";
250
250
  import { useState } from "react";
251
+
252
+ // src/ui/focusReport.ts
253
+ var isFocusReport = (input) => input === "[I" || input === "[O";
254
+
255
+ // src/ui/LinePrompt.tsx
251
256
  import { jsx as jsx2, jsxs } from "react/jsx-runtime";
252
257
  function LinePrompt({ label, mask, onSubmit, onCancel }) {
253
258
  const [value, setValue] = useState("");
@@ -255,7 +260,7 @@ function LinePrompt({ label, mask, onSubmit, onCancel }) {
255
260
  if (key.return) onSubmit(value.trim());
256
261
  else if (key.escape) onCancel();
257
262
  else if (key.backspace || key.delete) setValue((v) => v.slice(0, -1));
258
- else if (!key.ctrl && !key.meta && !key.tab && !key.upArrow && !key.downArrow) setValue((v) => v + input.replace(/[\r\n]/g, ""));
263
+ else if (!key.ctrl && !key.meta && !key.tab && !key.upArrow && !key.downArrow && !isFocusReport(input)) setValue((v) => v + input.replace(/[\r\n]/g, ""));
259
264
  });
260
265
  const shown = mask ? "\u2022".repeat(value.length) : value;
261
266
  return /* @__PURE__ */ jsx2(Box2, { borderStyle: "round", borderColor: color("cyan"), paddingX: 1, children: /* @__PURE__ */ jsxs(Text2, { children: [
@@ -284,7 +289,7 @@ function ListPicker({ title, items, onPick }) {
284
289
  else if (key.backspace || key.delete) {
285
290
  setFilter((f) => f.slice(0, -1));
286
291
  setIndex(0);
287
- } else if (!key.ctrl && !key.meta && !key.tab && input) {
292
+ } else if (!key.ctrl && !key.meta && !key.tab && input && !isFocusReport(input)) {
288
293
  setFilter((f) => f + input);
289
294
  setIndex(0);
290
295
  }
@@ -329,7 +334,7 @@ function ModelPicker({ groups, current, onPick }) {
329
334
  else if (key.backspace || key.delete) {
330
335
  setFilter((f) => f.slice(0, -1));
331
336
  setIndex(0);
332
- } else if (!key.ctrl && !key.meta && !key.tab && input) {
337
+ } else if (!key.ctrl && !key.meta && !key.tab && input && !isFocusReport(input)) {
333
338
  setFilter((f) => f + input);
334
339
  setIndex(0);
335
340
  }
@@ -498,7 +503,7 @@ function PromptInput({ disabled, history, cwd, onSubmit, extraCommands }) {
498
503
  edit((v) => completeFile(v, cwd));
499
504
  return;
500
505
  }
501
- if (key.ctrl || key.meta || key.escape || key.tab || key.leftArrow || key.rightArrow) return;
506
+ if (key.ctrl || key.meta || key.escape || key.tab || key.leftArrow || key.rightArrow || isFocusReport(input)) return;
502
507
  edit((v) => v + input.replace(/\r/g, "\n"));
503
508
  },
504
509
  { isActive: !disabled }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botconnector/bccli",
3
- "version": "0.4.0-beta.12",
3
+ "version": "0.4.0-beta.14",
4
4
  "description": "BCCLI — BotConnector AI coding agent for the terminal",
5
5
  "type": "module",
6
6
  "bin": {