clixad 0.0.1-beta.10 → 0.0.1-beta.12

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.
Files changed (2) hide show
  1. package/dist/clixad.mjs +259 -25
  2. package/package.json +1 -1
package/dist/clixad.mjs CHANGED
@@ -14,7 +14,83 @@ var __export = (target, all) => {
14
14
  __defProp(target, name, { get: all[name], enumerable: true });
15
15
  };
16
16
 
17
+ // src/edge.ts
18
+ function detectEdgeBlock(status, headers, body) {
19
+ if (status !== 403) return void 0;
20
+ if (isGatewayError(body)) return void 0;
21
+ const via = headers.get("server") ?? void 0;
22
+ const ray = rayId(headers, body);
23
+ return { status, ...ray ? { rayId: ray } : {}, ...via ? { via } : {} };
24
+ }
25
+ function isGatewayError(body) {
26
+ try {
27
+ const parsed = JSON.parse(body);
28
+ return typeof parsed?.error === "string";
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+ function rayId(headers, body) {
34
+ const header = headers.get("cf-ray");
35
+ if (header) return header.split("-")[0];
36
+ const text = body.replace(/<[^>]*>/g, " ");
37
+ return /ray\s*id:?\s*([0-9a-f]{16})/i.exec(text)?.[1];
38
+ }
39
+ function edgeBlockSummary(block) {
40
+ const parts = [`${block.status} from ${block.via ?? "an intermediary"}`];
41
+ if (block.rayId) parts.push(`ray ${block.rayId}`);
42
+ return parts.join(", ");
43
+ }
44
+ function edgeBlockMessage(block, outcome) {
45
+ const head = `A security filter in front of the gateway rejected this request (${block.status}), so it
46
+ never reached Clixad. Nothing was sent to a model and nothing was charged.
47
+ Filters like that match on request contents, and a conversation about path
48
+ traversal, SQL or shell commands looks like an attack payload to one.`;
49
+ const tail = outcome === "retried" ? `
50
+ The compressed body this CLI falls back to was blocked as well.
51
+ Every turn resends the whole conversation, so the next one will be blocked
52
+ too \u2014 use /clear, or start a new session, to get past it.` : `
53
+ This gateway is too old to accept the compressed body that works around it.
54
+ Update the gateway, or use /clear to drop the history that trips the filter.`;
55
+ const id = block.rayId ? `
56
+ Request id (for support): ${block.rayId}` : "";
57
+ return head + tail + id;
58
+ }
59
+ function safeDetail(body, limit = DETAIL_LIMIT) {
60
+ if (!body) return "";
61
+ const title = /<title[^>]*>([^<]*)<\/title>/i.exec(body)?.[1];
62
+ const source = title ?? body;
63
+ const text = source.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)?/g, " ").replace(/\u001b\[[0-9;?]*[ -\/]*[@-~]/g, " ").replace(/<[^>]*>/g, " ").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim();
64
+ if (!text) return "";
65
+ return text.length > limit ? `${text.slice(0, limit - 1)}\u2026` : text;
66
+ }
67
+ async function peek(res) {
68
+ const body = await res.text().catch(() => "");
69
+ return { body, res: new Response(body, { status: res.status, headers: res.headers }) };
70
+ }
71
+ var EdgeBlockedError, DETAIL_LIMIT;
72
+ var init_edge = __esm({
73
+ "src/edge.ts"() {
74
+ "use strict";
75
+ EdgeBlockedError = class extends Error {
76
+ constructor(block, outcome) {
77
+ super(edgeBlockMessage(block, outcome));
78
+ this.block = block;
79
+ this.outcome = outcome;
80
+ this.name = "EdgeBlockedError";
81
+ }
82
+ block;
83
+ outcome;
84
+ };
85
+ DETAIL_LIMIT = 200;
86
+ }
87
+ });
88
+
17
89
  // src/client.ts
90
+ import { gzipSync } from "node:zlib";
91
+ function encodeChatBody(json) {
92
+ return gzipSync(Buffer.from(json, "utf8"));
93
+ }
18
94
  function adsPerTaskLabel(estAdsPerTask) {
19
95
  const effort = estAdsPerTask >= 1 ? `~${estAdsPerTask} ads/task` : "<1 ad/task";
20
96
  return `${effort} on completion`;
@@ -76,6 +152,15 @@ async function authFailure(res, what) {
76
152
  `${what}: the gateway returned 401 without a Clixad error body. The gateway URL may be pointing at something else \u2014 a login/SSO page, a proxy, or a stale deployment. Check it, or set CLIXAD_GATEWAY_URL.`
77
153
  );
78
154
  }
155
+ async function triage(res) {
156
+ const peeked = await peek(res);
157
+ const block = detectEdgeBlock(res.status, res.headers, peeked.body);
158
+ return { ...block ? { block } : {}, res: peeked.res };
159
+ }
160
+ async function httpFailure(res, what) {
161
+ const detail = safeDetail(await res.text().catch(() => ""));
162
+ return detail ? `${what} failed: ${res.status} \u2014 ${detail}` : `${what} failed: ${res.status}`;
163
+ }
79
164
  function accumulateToolCall(calls, part, fallbackIndex) {
80
165
  const index = part.index ?? fallbackIndex;
81
166
  const current = calls.get(index) ?? { id: "", type: "function", function: { name: "", arguments: "" } };
@@ -102,14 +187,17 @@ async function* parseSSE(body) {
102
187
  }
103
188
  }
104
189
  }
105
- var LEDGER_REASONS, SignupClosedError, PaywallError, AuthError, GatewayClient;
190
+ var CHAT_ENCODED_CONTENT_TYPE, LEDGER_REASONS, SignupClosedError, PaywallError, AuthError, GatewayClient;
106
191
  var init_client = __esm({
107
192
  "src/client.ts"() {
108
193
  "use strict";
194
+ init_edge();
195
+ CHAT_ENCODED_CONTENT_TYPE = "application/vnd.clixad.chat+gzip";
109
196
  LEDGER_REASONS = {
110
197
  signup_bonus: "signup bonus",
111
198
  ad_reward: "offer reward",
112
199
  ad_screenout_bonus: "screenout bonus (didn't qualify)",
200
+ ad_sponsor_impression: "sponsored line shown",
113
201
  ad_reversal: "offer reward reversed by the provider",
114
202
  purchase: "credits purchased",
115
203
  usage: "model usage",
@@ -148,6 +236,25 @@ var init_client = __esm({
148
236
  this.config = config;
149
237
  }
150
238
  config;
239
+ /**
240
+ * Told when the client had to change how it talks to the gateway.
241
+ *
242
+ * Set by the REPL, unset everywhere else. A transport that silently changes
243
+ * itself is a thing the user should be able to see — it explains the pause,
244
+ * and it is the difference between "the tool is being slow" and "something in
245
+ * front of the gateway rejected that request". Left unset for `clixad -p` and
246
+ * the one-shot commands, whose stdout exists to be piped.
247
+ */
248
+ onNotice;
249
+ /**
250
+ * How chat bodies are being sent, for the rest of this process.
251
+ *
252
+ * Sticky once it moves. The conversation is resent in full every turn, so the
253
+ * string that got the first request blocked is in every request after it —
254
+ * going back to plain JSON would mean paying a blocked round-trip per turn to
255
+ * rediscover the same thing.
256
+ */
257
+ chatTransport = "json";
151
258
  headers(auth = true) {
152
259
  const h = { "Content-Type": "application/json" };
153
260
  if (auth) {
@@ -179,7 +286,7 @@ var init_client = __esm({
179
286
  headers: this.headers(false)
180
287
  });
181
288
  if (res.status === 501) return null;
182
- if (!res.ok) throw new Error(`device start failed: ${res.status} ${await res.text()}`);
289
+ if (!res.ok) throw new Error(await httpFailure(res, "device start"));
183
290
  return res.json();
184
291
  }
185
292
  /** Poll once for device-flow completion. `invite` unlocks a gated signup. */
@@ -229,7 +336,7 @@ var init_client = __esm({
229
336
  });
230
337
  if (res.status === 401) throw await authFailure(res, "checkout");
231
338
  if (res.status === 501) throw new Error("Buying credits isn't enabled (Stripe not configured on the gateway).");
232
- if (!res.ok) throw new Error(`checkout failed: ${res.status} ${await res.text()}`);
339
+ if (!res.ok) throw new Error(await httpFailure(res, "checkout"));
233
340
  return res.json();
234
341
  }
235
342
  // Both take an optional signal for the REPL's sake: they are awaited while the
@@ -263,6 +370,40 @@ var init_client = __esm({
263
370
  if (!res.ok) throw new Error(`wallet failed: ${res.status}`);
264
371
  return res.json();
265
372
  }
373
+ /**
374
+ * Ask the gateway for a sponsored line, or `null` if it has none.
375
+ *
376
+ * **This never throws and never reports a failure to anybody.** Every caller is
377
+ * painting a row of dim chrome that already has a house line in it, so there is
378
+ * no state a user could act on and nothing a message would improve — a 404 from
379
+ * a gateway older than the route, a 401, a cold start, a dropped connection and
380
+ * an empty inventory are all the same event here, which is "keep showing ours".
381
+ * That is the opposite of every other method on this client, and it is why the
382
+ * catch is this broad rather than a list of statuses.
383
+ *
384
+ * The serve itself happens on the gateway, deliberately: an impression is worth
385
+ * credits, and this program is installed by strangers from npm. All the CLI
386
+ * gets to decide is when to ask.
387
+ */
388
+ async sponsorLine(signal) {
389
+ try {
390
+ const res = await fetch(`${this.config.gatewayUrl}/v1/sponsor/line`, {
391
+ headers: this.headers(),
392
+ signal
393
+ });
394
+ if (!res.ok) return null;
395
+ const body = await res.json();
396
+ if (typeof body.text !== "string" || !body.text.trim()) return null;
397
+ return {
398
+ provider: "adtention",
399
+ id: typeof body.id === "string" && body.id ? body.id : "adtention",
400
+ text: body.text.trim(),
401
+ credits: typeof body.credits === "number" ? body.credits : 0
402
+ };
403
+ } catch {
404
+ return null;
405
+ }
406
+ }
266
407
  /**
267
408
  * Search the web, through the gateway.
268
409
  *
@@ -292,7 +433,14 @@ var init_client = __esm({
292
433
  throw new Error(body.message ?? "Too many searches \u2014 wait a moment and try again.");
293
434
  }
294
435
  if (!res.ok) {
295
- const body = await res.json().catch(() => ({}));
436
+ const peeked = await peek(res);
437
+ const block = detectEdgeBlock(res.status, res.headers, peeked.body);
438
+ if (block) {
439
+ throw new Error(
440
+ `The search was rejected by a security filter in front of the gateway (${edgeBlockSummary(block)}); the query reads as an attack payload to it. Rephrase it without the literal payload, or use web_fetch on a URL you know.`
441
+ );
442
+ }
443
+ const body = await peeked.res.json().catch(() => ({}));
296
444
  throw new Error(body.message ?? `web search failed: ${res.status}`);
297
445
  }
298
446
  const data = await res.json();
@@ -306,19 +454,70 @@ var init_client = __esm({
306
454
  // wall, and the reward arrives as a signed postback from the network to the
307
455
  // gateway. Nothing comes back to the CLI; a rising balance is the only signal,
308
456
  // which is why both callers poll `wallet()`.
309
- /** Non-streaming chat. Kept for scripted/one-shot paths; the REPL streams. */
310
- async chat(messages, model, tools, signal) {
311
- const res = await fetch(`${this.config.gatewayUrl}/v1/chat/completions`, {
457
+ /** One POST to the chat route, in whichever transport is currently in use. */
458
+ sendChat(payload, encoded, signal) {
459
+ const json = JSON.stringify(payload);
460
+ return fetch(`${this.config.gatewayUrl}/v1/chat/completions`, {
312
461
  method: "POST",
313
- headers: this.headers(),
314
- body: JSON.stringify({ model, messages, stream: false, ...tools ? { tools } : {} }),
462
+ headers: encoded ? { ...this.headers(), "Content-Type": CHAT_ENCODED_CONTENT_TYPE } : this.headers(),
463
+ body: encoded ? encodeChatBody(json) : json,
315
464
  signal
316
465
  });
466
+ }
467
+ /**
468
+ * POST a chat request, and get past the edge if the edge is in the way.
469
+ *
470
+ * Plain JSON first, always, on every fresh process. That is the OpenAI-compatible
471
+ * contract this route serves — Kimi CLI's kosong client speaks it, and so does
472
+ * anything else somebody points at `CLIXAD_GATEWAY_URL` — and a gateway that
473
+ * has never heard of the encoded form must keep working. The compressed body
474
+ * is a workaround for one specific failure, so it engages only once that
475
+ * failure has actually happened.
476
+ *
477
+ * The retry is not a general "try again": a 403 from a WAF is deterministic,
478
+ * and resending the identical bytes would be blocked identically. It is the
479
+ * *same* request in a form the filter has nothing to match on, which is why it
480
+ * is worth exactly one attempt and no more.
481
+ *
482
+ * Three outcomes, and each has to be distinguishable or the user is left
483
+ * guessing: the retry works (stick with it, say so once), the gateway answers
484
+ * 415 because it predates this (say that — the fix is a deploy, not a
485
+ * `/clear`), or it is blocked again (say that too, and say that clearing the
486
+ * history is the way out, because the history is what keeps triggering it).
487
+ */
488
+ async postChat(payload, signal) {
489
+ const already = this.chatTransport === "encoded";
490
+ const first = await this.sendChat(payload, already, signal);
491
+ if (first.status !== 403) return first;
492
+ const { block, res } = await triage(first);
493
+ if (!block) return res;
494
+ if (already) throw new EdgeBlockedError(block, "retried");
495
+ const retry = await this.sendChat(payload, true, signal);
496
+ if (retry.status === 415) throw new EdgeBlockedError(block, "unsupported");
497
+ if (retry.status === 403) {
498
+ const second = await triage(retry);
499
+ if (second.block) throw new EdgeBlockedError(second.block, "retried");
500
+ return second.res;
501
+ }
502
+ this.chatTransport = "encoded";
503
+ this.onNotice?.(
504
+ ` A security filter in front of the gateway blocked that request (${edgeBlockSummary(block)}).
505
+ Resent with a compressed body, which got through. Nothing was lost, and the
506
+ rest of this session will use the same encoding.`
507
+ );
508
+ return retry;
509
+ }
510
+ /** Non-streaming chat. Kept for scripted/one-shot paths; the REPL streams. */
511
+ async chat(messages, model, tools, signal) {
512
+ const res = await this.postChat(
513
+ { model, messages, stream: false, ...tools ? { tools } : {} },
514
+ signal
515
+ );
317
516
  if (res.status === 401) throw await authFailure(res, "chat");
318
517
  if (res.status === 402) {
319
518
  throw paywallError(await res.json());
320
519
  }
321
- if (!res.ok) throw new Error(`chat failed: ${res.status} ${await res.text()}`);
520
+ if (!res.ok) throw new Error(await httpFailure(res, "chat"));
322
521
  const data = await res.json();
323
522
  const choice = data.choices[0];
324
523
  if (!choice) throw new Error("chat: no choices in response");
@@ -336,17 +535,15 @@ var init_client = __esm({
336
535
  * the agent loop can stream *and* use tools in the same request.
337
536
  */
338
537
  async chatStream(messages, model, opts = {}) {
339
- const res = await fetch(`${this.config.gatewayUrl}/v1/chat/completions`, {
340
- method: "POST",
341
- headers: this.headers(),
342
- body: JSON.stringify({ model, messages, stream: true, ...opts.tools ? { tools: opts.tools } : {} }),
343
- signal: opts.signal
344
- });
538
+ const res = await this.postChat(
539
+ { model, messages, stream: true, ...opts.tools ? { tools: opts.tools } : {} },
540
+ opts.signal
541
+ );
345
542
  if (res.status === 401) throw await authFailure(res, "chat");
346
543
  if (res.status === 402) {
347
544
  throw paywallError(await res.json());
348
545
  }
349
- if (!res.ok || !res.body) throw new Error(`chat failed: ${res.status} ${await res.text()}`);
546
+ if (!res.ok || !res.body) throw new Error(await httpFailure(res, "chat"));
350
547
  let content = "";
351
548
  let finishReason = "stop";
352
549
  let creditsCharged = 0;
@@ -3840,6 +4037,23 @@ function sponsorSource(provider = "house") {
3840
4037
  return houseSource();
3841
4038
  }
3842
4039
  }
4040
+ function adtentionSource(fetchLine) {
4041
+ return {
4042
+ provider: "adtention",
4043
+ async fetch(signal) {
4044
+ let line2;
4045
+ try {
4046
+ line2 = await fetchLine(signal);
4047
+ } catch {
4048
+ return null;
4049
+ }
4050
+ if (!line2) return null;
4051
+ const text = line2.text.trim();
4052
+ if (!text) return null;
4053
+ return { id: line2.id, text };
4054
+ }
4055
+ };
4056
+ }
3843
4057
  var init_provider = __esm({
3844
4058
  "src/sponsor/provider.ts"() {
3845
4059
  "use strict";
@@ -4279,6 +4493,13 @@ function headRows(text, cols, max) {
4279
4493
  }
4280
4494
  return kept.join("\n");
4281
4495
  }
4496
+ function errorNotice(message, rows = ERROR_NOTICE_ROWS) {
4497
+ const clean = message.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)?/g, " ").replace(/\u001b\[[0-9;?]*[ -\/]*[@-~]/g, " ").replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/g, " ");
4498
+ const lines = clean.split("\n");
4499
+ const kept = lines.slice(0, rows).map((l) => l.length > ERROR_NOTICE_COLS ? `${l.slice(0, ERROR_NOTICE_COLS - 1)}\u2026` : l);
4500
+ if (lines.length > rows) kept.push(` \u2026 +${lines.length - rows} more lines`);
4501
+ return kept.join("\n");
4502
+ }
4282
4503
  function entryHeight(entry, cols) {
4283
4504
  const margin = entry.kind === "banner" ? 0 : 1;
4284
4505
  switch (entry.kind) {
@@ -4325,7 +4546,7 @@ function EntryView({ entry }) {
4325
4546
  ) });
4326
4547
  }
4327
4548
  }
4328
- var MANGO3, MANGO_BRIGHT, SLATE, MODE_STYLE;
4549
+ var MANGO3, MANGO_BRIGHT, SLATE, MODE_STYLE, ERROR_NOTICE_ROWS, ERROR_NOTICE_COLS;
4329
4550
  var init_views = __esm({
4330
4551
  "src/tui/views.tsx"() {
4331
4552
  "use strict";
@@ -4339,6 +4560,8 @@ var init_views = __esm({
4339
4560
  acceptEdits: { color: "#7ee787", glyph: "\u23F5\u23F5" },
4340
4561
  plan: { color: "#79c0ff", glyph: "\u23F8" }
4341
4562
  };
4563
+ ERROR_NOTICE_ROWS = 12;
4564
+ ERROR_NOTICE_COLS = 200;
4342
4565
  }
4343
4566
  });
4344
4567
 
@@ -4434,6 +4657,8 @@ function App({ client, config, wallet, session, initialTask }) {
4434
4657
  const sponsorRef = useRef(sponsorSource());
4435
4658
  const sponsorIdxRef = useRef(0);
4436
4659
  const sponsorPrevRef = useRef(void 0);
4660
+ const sponsorCallRef = useRef(0);
4661
+ const remoteSponsorRef = useRef(adtentionSource((signal) => client.sponsorLine(signal)));
4437
4662
  const tallyRef = useRef(createTally());
4438
4663
  const heightCacheRef = useRef(/* @__PURE__ */ new Map());
4439
4664
  const push = useCallback((e) => {
@@ -4473,6 +4698,7 @@ function App({ client, config, wallet, session, initialTask }) {
4473
4698
  }, []);
4474
4699
  useEffect(() => dropDelta, [dropDelta]);
4475
4700
  useEffect(() => {
4701
+ client.onNotice = (text2) => push({ kind: "notice", tone: "warn", text: text2 });
4476
4702
  const files = contextRef.current.files;
4477
4703
  if (files.length) push({ kind: "notice", text: ` context: ${files.join(", ")}` });
4478
4704
  if (session?.messages.length) {
@@ -4520,6 +4746,7 @@ function App({ client, config, wallet, session, initialTask }) {
4520
4746
  );
4521
4747
  const nextSponsor = useCallback(
4522
4748
  (modelId) => {
4749
+ const call = ++sponsorCallRef.current;
4523
4750
  if (!sponsorEnabled({
4524
4751
  isTTY: process.stdout.isTTY,
4525
4752
  env: process.env,
@@ -4534,13 +4761,19 @@ function App({ client, config, wallet, session, initialTask }) {
4534
4761
  adsPerTask: est === void 0 ? void 0 : adsPerTaskLabel(est)
4535
4762
  };
4536
4763
  const picked = sponsorRef.current.pick(sponsorIdxRef.current++, sponsorPrevRef.current, facts);
4537
- if (!picked) {
4764
+ if (picked) {
4765
+ sponsorPrevRef.current = picked.id;
4766
+ recordImpression(tallyRef.current, picked.id);
4767
+ setSponsor(picked.text);
4768
+ } else {
4538
4769
  setSponsor(null);
4539
- return;
4540
4770
  }
4541
- sponsorPrevRef.current = picked.id;
4542
- recordImpression(tallyRef.current, picked.id);
4543
- setSponsor(picked.text);
4771
+ void remoteSponsorRef.current.fetch().then((line2) => {
4772
+ if (!line2 || call !== sponsorCallRef.current) return;
4773
+ sponsorPrevRef.current = line2.id;
4774
+ recordImpression(tallyRef.current, line2.id);
4775
+ setSponsor(line2.text);
4776
+ });
4544
4777
  },
4545
4778
  [config.sponsor, wallet]
4546
4779
  );
@@ -4760,13 +4993,14 @@ ${NO_SEARCH_INSTRUCTION}`),
4760
4993
  } else if (err instanceof AuthError) {
4761
4994
  push({ kind: "notice", tone: "error", text: NOT_SIGNED_IN });
4762
4995
  } else {
4763
- push({ kind: "notice", tone: "error", text: ` error: ${err.message}` });
4996
+ push({ kind: "notice", tone: "error", text: ` error: ${errorNotice(err.message)}` });
4764
4997
  }
4765
4998
  } finally {
4766
4999
  abortRef.current = null;
4767
5000
  dropDelta();
4768
5001
  setLive(null);
4769
5002
  setBusy(false);
5003
+ sponsorCallRef.current++;
4770
5004
  setSponsor(null);
4771
5005
  }
4772
5006
  },
@@ -4801,7 +5035,7 @@ ${NO_SEARCH_INSTRUCTION}`),
4801
5035
  } catch (err) {
4802
5036
  if (ac.signal.aborted) push({ kind: "notice", tone: "warn", text: " (stopped)" });
4803
5037
  else if (err instanceof AuthError) push({ kind: "notice", tone: "error", text: NOT_SIGNED_IN });
4804
- else push({ kind: "notice", tone: "error", text: ` ${err.message}` });
5038
+ else push({ kind: "notice", tone: "error", text: ` ${errorNotice(err.message)}` });
4805
5039
  } finally {
4806
5040
  busyAbortRef.current = null;
4807
5041
  setBusyLabel(WORKING);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clixad",
3
- "version": "0.0.1-beta.10",
3
+ "version": "0.0.1-beta.12",
4
4
  "description": "Free AI coding agent in your terminal, funded by rewarded ads.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",