surf-cli 2.11.0 → 2.13.0

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.
@@ -0,0 +1,912 @@
1
+ /**
2
+ * Kimi Web Client for surf-cli
3
+ *
4
+ * Browser-session client for kimi.com (Moonshot AI - Kimi K-series models).
5
+ * Drives the real kimi.com web UI through generic surf browser primitives
6
+ * (NEW_TAB + EXECUTE_JAVASCRIPT + CLOSE_TAB), so no extension changes or
7
+ * provider-specific CDP types are needed.
8
+ *
9
+ * Kimi's composer is a Lexical editor (`div[contenteditable][role="textbox"]`),
10
+ * which rejects plain CDP Input.insertText events. We type via
11
+ * document.execCommand("insertText") instead (proven to work with Lexical).
12
+ */
13
+
14
+ const { abortableDelay, raceAbort, throwIfAborted } = require("./abort.cjs");
15
+
16
+ const KIMI_URL = "https://www.kimi.com/";
17
+ const DEFAULT_MODEL = "instant";
18
+
19
+ // Default models (best-effort labels; kimi.com UI may differ by plan/region)
20
+ const DEFAULT_KIMI_MODELS = {
21
+ "instant": { id: "instant", name: "Instant", desc: "Fast responses" },
22
+ "thinking": { id: "thinking", name: "Thinking", desc: "Deep reasoning (may need paid plan)" },
23
+ "high": { id: "high", name: "High", desc: "Higher reasoning effort" },
24
+ };
25
+
26
+ function normalizeLabel(text) {
27
+ return String(text || "").toLowerCase().replace(/[^a-z0-9]/g, "");
28
+ }
29
+
30
+ function getMatchLabels(desiredModel) {
31
+ const labels = new Set([desiredModel]);
32
+ for (const m of Object.values(DEFAULT_KIMI_MODELS)) {
33
+ if (m.id === desiredModel) labels.add(m.name);
34
+ }
35
+ return Array.from(new Set(Array.from(labels).filter(Boolean).map(normalizeLabel)));
36
+ }
37
+
38
+ // ============================================================================
39
+ // Helpers
40
+ // ============================================================================
41
+
42
+ function delay(ms, signal) {
43
+ return abortableDelay(ms, signal);
44
+ }
45
+
46
+ // In-page click dispatcher (pointerdown/mousedown/pointerup/click) - needed
47
+ // because plain el.click() is ignored by React/Lexical synthetic event systems.
48
+ function buildClickDispatcher() {
49
+ return `function dispatchClickSequence(target) {
50
+ if (!target || !(target instanceof EventTarget)) return false;
51
+ const types = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'];
52
+ for (const type of types) {
53
+ const common = { bubbles: true, cancelable: true, view: window };
54
+ let event;
55
+ if (type.startsWith('pointer') && 'PointerEvent' in window) {
56
+ event = new PointerEvent(type, { ...common, pointerId: 1, pointerType: 'mouse' });
57
+ } else {
58
+ event = new MouseEvent(type, common);
59
+ }
60
+ target.dispatchEvent(event);
61
+ }
62
+ return true;
63
+ }`;
64
+ }
65
+
66
+ // The chat composer input (Lexical editor) - prefer the specific class first to
67
+ // avoid matching sidebar search boxes
68
+ const INPUT_SELECTOR =
69
+ "div.chat-input-editor, div[contenteditable=\"true\"][role=\"textbox\"], div[contenteditable=\"true\"]";
70
+
71
+ // Find the chat composer specifically: chat-input-editor class, or a textbox
72
+ // whose ancestors include the composer area
73
+ const FIND_INPUT_JS = `(() => {
74
+ const direct = document.querySelector('div.chat-input-editor');
75
+ if (direct) return direct;
76
+ const boxes = Array.from(document.querySelectorAll('[contenteditable="true"][role="textbox"], [contenteditable="true"]'));
77
+ for (const el of boxes) {
78
+ const p = el.parentElement;
79
+ if (!p) continue;
80
+ const cls = ((p.className || '') + ' ' + ((p.parentElement && p.parentElement.className) || '')).toString();
81
+ if (/chat-input|chat-editor|composer|publisher/.test(cls)) return el;
82
+ }
83
+ return boxes[0] || null;
84
+ })()`;
85
+
86
+ async function evaluate(jsEval, expression, signal) {
87
+ throwIfAborted(signal);
88
+ // IMPORTANT: always emit `return (expr);` so the eval works on BOTH the
89
+ // current installed extension (wraps code as an async arrow body directly,
90
+ // so a bare expression's value is discarded -> undefined) and a rebuilt one
91
+ // (wraps with `return (code);` -> falls back to the raw code on syntax error).
92
+ const code = /^\s*return\b/.test(expression) ? expression : `return (${expression});`;
93
+ const result = await jsEval(code);
94
+ throwIfAborted(signal);
95
+ if (result && result.error) throw new Error(result.error);
96
+ if (result && result.output !== undefined) {
97
+ const output = result.output;
98
+ if (output === "undefined") return undefined;
99
+ try {
100
+ return JSON.parse(output);
101
+ } catch (e) {
102
+ return output;
103
+ }
104
+ }
105
+ return result;
106
+ }
107
+
108
+ // ============================================================================
109
+ // Page State
110
+ // ============================================================================
111
+
112
+ async function waitForPageLoad(jsEval, signal, timeoutMs = 30000) {
113
+ const deadline = Date.now() + timeoutMs;
114
+ while (Date.now() < deadline) {
115
+ try {
116
+ const ready = await evaluate(jsEval, "document.readyState", signal);
117
+ if (ready === "complete" || ready === "interactive") {
118
+ await delay(1200, signal);
119
+ return;
120
+ }
121
+ } catch (e) {
122
+ // Page may not be reachable yet (mid-navigation); keep waiting
123
+ }
124
+ await delay(150, signal);
125
+ }
126
+ throw new Error("Page did not load in time");
127
+ }
128
+
129
+ async function checkLoginStatus(jsEval, signal) {
130
+ const result = await evaluate(
131
+ jsEval,
132
+ `(() => {
133
+ const body = (document.body.innerText || '').toLowerCase();
134
+ const hasLogin = !!document.querySelector(
135
+ 'a[href*="/login"], a[href*="sign-in"], button[data-testid*="login"], a[href*="signup"]'
136
+ );
137
+ const loginWallWords = ['log in', 'sign in', 'sign up', '登录', '注册'];
138
+ const looksLoggedOut = loginWallWords.some((w) => body.includes(w)) && !body.includes('ask anything');
139
+ return {
140
+ loggedIn: !hasLogin && !looksLoggedOut,
141
+ url: location.href,
142
+ bodyLen: body.length
143
+ };
144
+ })()`,
145
+ signal
146
+ );
147
+ return result || { loggedIn: false, url: "", bodyLen: 0 };
148
+ }
149
+
150
+ async function waitForKimiReady(jsEval, signal, timeoutMs = 25000) {
151
+ const deadline = Date.now() + timeoutMs;
152
+ let lastState = null;
153
+ while (Date.now() < deadline) {
154
+ let state = null;
155
+ try {
156
+ state = await evaluate(
157
+ jsEval,
158
+ `(() => {
159
+ const input = ${FIND_INPUT_JS};
160
+ const visible = !!input && input.offsetParent !== null;
161
+ const body = (document.body.innerText || '');
162
+ return { ready: visible, hasInput: visible, bodyLen: body.length, url: location.href };
163
+ })()`,
164
+ signal
165
+ );
166
+ } catch (e) {
167
+ if (signal?.aborted) throw e;
168
+ // Transient jsEval failure mid-SPA-load - keep waiting
169
+ }
170
+ lastState = state;
171
+ if (state && state.ready) return state;
172
+ await delay(250, signal);
173
+ }
174
+ if (lastState) {
175
+ throw new Error(`Kimi chat UI not detected (current: ${lastState.url}) - may need to log in to kimi.com`);
176
+ }
177
+ throw new Error("Timed out waiting for Kimi chat UI");
178
+ }
179
+
180
+ // ============================================================================
181
+ // Model Selection (best-effort)
182
+ // ============================================================================
183
+
184
+ async function selectModel(jsEval, signal, desiredModel, timeoutMs = 8000) {
185
+ const requestedLabels = getMatchLabels(desiredModel);
186
+ if (requestedLabels.length === 0) return desiredModel;
187
+
188
+ // The model picker (.current-model) renders late - wait for it (up to 6s)
189
+ let modelClicked = null;
190
+ const clickDeadline = Date.now() + 6000;
191
+ while (Date.now() < clickDeadline && !(modelClicked && modelClicked.success)) {
192
+ modelClicked = await evaluate(
193
+ jsEval,
194
+ `(() => {
195
+ ${buildClickDispatcher()}
196
+ // Kimi renders the model picker as div.current-model / div.model-name
197
+ const modelDiv = document.querySelector('.current-model, .model-name, [class*="model-picker"], [class*="model-select"]');
198
+ if (modelDiv && modelDiv.offsetParent !== null) {
199
+ dispatchClickSequence(modelDiv);
200
+ return { success: true };
201
+ }
202
+ const input = ${FIND_INPUT_JS};
203
+ const scope = input
204
+ ? (input.closest('[class*="composer"], [class*="input"], [role="form"], form') || input.parentElement)
205
+ : document;
206
+ const buttons = Array.from((scope || document).querySelectorAll('button'));
207
+ const modelBtn = buttons.find((b) => {
208
+ const text = (b.textContent || '').toLowerCase();
209
+ const label = (b.getAttribute('aria-label') || '').toLowerCase();
210
+ return /k2|k3|instant|thinking|deep|model/i.test(text) || label.includes('model');
211
+ });
212
+ if (modelBtn) { dispatchClickSequence(modelBtn); return { success: true }; }
213
+ return { success: false };
214
+ })()`,
215
+ signal
216
+ );
217
+ if (!(modelClicked && modelClicked.success)) await delay(500, signal);
218
+ }
219
+
220
+ if (!modelClicked || !modelClicked.success) return desiredModel;
221
+ await delay(900, signal);
222
+
223
+ const deadline = Date.now() + timeoutMs;
224
+ while (Date.now() < deadline) {
225
+ const result = await evaluate(
226
+ jsEval,
227
+ `(() => {
228
+ ${buildClickDispatcher()}
229
+ const requestedLabels = ${JSON.stringify(requestedLabels)};
230
+ const norm = (t) => (t || '').toLowerCase().replace(/[^a-z0-9]/g, '');
231
+ // Kimi model popover: .models-popover with plain div rows (no role attrs)
232
+ const popover = document.querySelector('.models-popover') ||
233
+ Array.from(document.querySelectorAll('.n-popover__content, .n-popover')).find((el) => el.offsetParent !== null && (el.textContent || '').length > 0);
234
+ const candidates = popover
235
+ ? Array.from(popover.querySelectorAll('[role="menuitemradio"], [role="menuitem"], [role="option"], [class*="item"], div'))
236
+ : Array.from(document.querySelectorAll('[role="menuitemradio"], [role="menuitem"], [role="option"]'));
237
+ if (candidates.length === 0) return { waiting: true };
238
+ let best = null, bestScore = 0;
239
+ const seen = new Set();
240
+ for (const item of candidates) {
241
+ if (seen.has(item)) continue;
242
+ seen.add(item);
243
+ if (item.offsetParent === null) continue;
244
+ const text = norm(item.textContent || '');
245
+ if (text.length < 2) continue;
246
+ let score = 0;
247
+ for (const label of requestedLabels) {
248
+ if (!label) continue;
249
+ if (text === label || text.startsWith(label)) score = Math.max(score, 100);
250
+ else if (text.includes(label)) score = Math.max(score, 90);
251
+ }
252
+ if (score > bestScore) { bestScore = score; best = item; }
253
+ }
254
+ if (!best) return { found: true, success: false };
255
+ dispatchClickSequence(best);
256
+ return { found: true, success: true, model: (best.textContent || '').trim().split('\\n')[0] };
257
+ })()`,
258
+ signal
259
+ );
260
+ if (result && result.found) {
261
+ if (result.success) { await delay(200, signal); return result.model; }
262
+ await evaluate(jsEval, "document.body.click()", signal);
263
+ throw new Error(`No matching model in menu for "${desiredModel}"`);
264
+ }
265
+ await delay(120, signal);
266
+ }
267
+ await evaluate(jsEval, "document.body.click()", signal);
268
+ throw new Error(`Timed out waiting for model menu to show "${desiredModel}"`);
269
+ }
270
+
271
+ // Start a fresh chat - kimi.com restores the last session on home
272
+ async function startNewChat(jsEval, signal) {
273
+ const clicked = await evaluate(
274
+ jsEval,
275
+ `(() => {
276
+ ${buildClickDispatcher()}
277
+ const btn = document.querySelector('a[aria-label="New Chat"], a.new-chat-btn, .sidebar-new-chat, a.logo');
278
+ if (!btn || btn.offsetParent === null) return { success: false };
279
+ dispatchClickSequence(btn);
280
+ return { success: true };
281
+ })()`,
282
+ signal
283
+ );
284
+ if (clicked && clicked.success) {
285
+ await delay(1800, signal);
286
+ }
287
+ return (clicked && clicked.success) || false;
288
+ }
289
+
290
+ // ============================================================================
291
+ // Input + Submission (Lexical-safe)
292
+ // ============================================================================
293
+
294
+ async function typePrompt(jsEval, signal, prompt) {
295
+ const promptTail = normalizeLabel(prompt).slice(-30);
296
+ const insertCode = (mode) => `(() => {
297
+ const input = ${FIND_INPUT_JS};
298
+ if (!input) return { success: false, error: 'input not found' };
299
+ input.focus();
300
+ const prompt = ${JSON.stringify(prompt)};
301
+ let ok = false;
302
+ if (${mode === 'fallback' ? 'true' : 'false'}) {
303
+ const sel = window.getSelection();
304
+ const range = document.createRange();
305
+ range.selectNodeContents(input);
306
+ range.collapse(false);
307
+ sel.removeAllRanges();
308
+ sel.addRange(range);
309
+ input.dispatchEvent(new InputEvent('beforeinput', {
310
+ bubbles: true, cancelable: true, inputType: 'insertText', data: prompt
311
+ }));
312
+ }
313
+ try {
314
+ ok = document.execCommand('insertText', false, prompt);
315
+ } catch (e) {
316
+ ok = false;
317
+ }
318
+ return { ok, len: (input.textContent || '').length, cls: (input.className || '').toString().slice(0, 60) };
319
+ })()`;
320
+ const verifyCode = `(() => {
321
+ const input = ${FIND_INPUT_JS};
322
+ const text = (input ? input.textContent : '') || '';
323
+ const tail = ${JSON.stringify(promptTail)};
324
+ const plen = ${JSON.stringify(prompt.length)};
325
+ const norm = text.toLowerCase().replace(/[^a-z0-9]/g, '');
326
+ return { len: text.length, matched: tail ? norm.includes(tail) : text.length > 0, full: text.length >= plen };
327
+ })()`;
328
+
329
+ // 1. Focus the composer
330
+ const focused = await evaluate(
331
+ jsEval,
332
+ `(() => {
333
+ ${buildClickDispatcher()}
334
+ const input = ${FIND_INPUT_JS};
335
+ if (!input || input.offsetParent === null) return { success: false, error: 'input not visible' };
336
+ input.scrollIntoView({ block: 'center' });
337
+ dispatchClickSequence(input);
338
+ input.focus();
339
+ return { success: true };
340
+ })()`,
341
+ signal
342
+ );
343
+ if (!focused || !focused.success) {
344
+ throw new Error(`Could not focus Kimi input: ${focused?.error || 'unknown'}`);
345
+ }
346
+ await delay(400, signal);
347
+
348
+ // 2. Insert + poll for the async Lexical commit (up to 10s per attempt)
349
+ const waitForText = async (attempt) => {
350
+ const insert = await evaluate(jsEval, insertCode(attempt), signal);
351
+ const deadline = Date.now() + 10000;
352
+ let last = null;
353
+ while (Date.now() < deadline) {
354
+ last = await evaluate(jsEval, verifyCode, signal);
355
+ if (last && last.matched && last.full && last.len > 0) return last;
356
+ await delay(400, signal);
357
+ }
358
+ return last;
359
+ };
360
+
361
+ const result = await waitForText('primary');
362
+ if (result && result.matched && result.full && result.len > 0) return;
363
+
364
+ // 3. Fallback: clear composer, then selection + beforeinput + execCommand.
365
+ // Clearing first prevents duplicated text when the primary insert partially
366
+ // committed into the Lexical editor.
367
+ const cleared = await evaluate(
368
+ jsEval,
369
+ `(() => {
370
+ const input = ${FIND_INPUT_JS};
371
+ if (!input) return { ok: false };
372
+ input.focus();
373
+ try {
374
+ document.execCommand('selectAll', false, null);
375
+ document.execCommand('delete', false, null);
376
+ } catch (e) { /* some engines ignore */ }
377
+ return { ok: true };
378
+ })()`,
379
+ signal
380
+ );
381
+ void cleared;
382
+ await delay(300, signal);
383
+ const result2 = await waitForText('fallback');
384
+ if (!result2 || !result2.matched || !result2.full || !result2.len) {
385
+ const detail = result2 ? `(len=${result2.len}, full=${result2.full})` : '';
386
+ throw new Error(`Kimi composer did not accept typed text ${detail}`);
387
+ }
388
+ }
389
+
390
+ async function submitPrompt(jsEval, signal) {
391
+ const clicked = await evaluate(
392
+ jsEval,
393
+ `(() => {
394
+ ${buildClickDispatcher()}
395
+ const input = ${FIND_INPUT_JS};
396
+ const isVisible = (el) => el && el.offsetParent !== null;
397
+ const isEnabled = (el) => el && !el.disabled && el.getAttribute('aria-disabled') !== 'true';
398
+ // 1. Kimi's send control: a div.send-button-container (not a <button>)
399
+ const container = document.querySelector('.send-button-container');
400
+ if (container && isVisible(container) && isEnabled(container)) {
401
+ dispatchClickSequence(container);
402
+ return { success: true, method: 'send-button-container' };
403
+ }
404
+ const scope = input
405
+ ? (input.closest('[class*="composer"], [class*="input"], [role="form"], form') || input.parentElement)
406
+ : document;
407
+ const buttons = Array.from((scope || document).querySelectorAll('button'));
408
+ // 2. aria-label / text match
409
+ const labeled = buttons.find((b) => {
410
+ if (!isVisible(b) || !isEnabled(b)) return false;
411
+ const label = (b.getAttribute('aria-label') || '').toLowerCase();
412
+ const txt = (b.textContent || '').trim().toLowerCase();
413
+ return label === 'send' || label === 'submit' || label.startsWith('send ') ||
414
+ label.includes('发送') || txt === 'send' || txt.includes('发送');
415
+ });
416
+ if (labeled) { dispatchClickSequence(labeled); return { success: true, method: 'label' }; }
417
+ // 3. Last enabled visible button in the composer
418
+ const candidates = buttons.filter((b) => isVisible(b) && isEnabled(b));
419
+ if (candidates.length > 0) {
420
+ dispatchClickSequence(candidates[candidates.length - 1]);
421
+ return { success: true, method: 'last' };
422
+ }
423
+ return { success: false };
424
+ })()`,
425
+ signal
426
+ );
427
+ if (!clicked || !clicked.success) {
428
+ throw new Error("Kimi send button not found");
429
+ }
430
+ await delay(600, signal);
431
+ }
432
+
433
+ // ============================================================================
434
+ // Response Handling
435
+ // ============================================================================
436
+
437
+ // Extract the assistant answer from full body innerText. Fresh tab per query
438
+ // means exactly one turn: user prompt line -> assistant reply -> input chrome.
439
+ function extractKimiResponse(bodyText, userPrompt = "") {
440
+ if (!bodyText) return null;
441
+ const lines = bodyText.split("\n").map((l) => l.trim()).filter((l) => l);
442
+ if (lines.length === 0) return null;
443
+
444
+ // UI chrome lines to drop (kimi sidebar + composer + message actions)
445
+ const uiSet = new Set([
446
+ "edit", "copy", "share", "regenerate", "like", "dislike", "stop", "stop generating",
447
+ "new chat", "plugins", "scheduled tasks", "swarm", "slides", "deep research",
448
+ "websites", "docs", "sheets", "design", "kimi work", "kimi code", "kimi claw",
449
+ "projects", "new project", "chats", "all chats", "upgrade", "explore inspiration",
450
+ "chat with kimi", "loading", "thinking...", "instant", "thinking", "high",
451
+ "type \"/\" to invoke plugins and skills", "select project", "invite to earn",
452
+ "up to 1-year k3 credits", "audiolibro vs libro", "kimi swarm definition",
453
+ "kimi cline reddit views", "kimi api plugin", "hermes models add section",
454
+ "hermes agent section update",
455
+ ]);
456
+ const isUI = (l) => {
457
+ const key = l.toLowerCase().replace(/[.?!]+$/, "");
458
+ if (uiSet.has(key) || uiSet.has(l.toLowerCase())) return true;
459
+ // kimi footer / chrome prefix patterns
460
+ const lower = l.toLowerCase();
461
+ return (
462
+ lower.startsWith("ask anything") ||
463
+ lower.startsWith("high demand") ||
464
+ lower.startsWith("upgrade to use") ||
465
+ lower.startsWith("switched to k") ||
466
+ lower.startsWith("kimi can make mistakes") ||
467
+ lower.startsWith("kimi ai may") ||
468
+ lower.startsWith("explore inspiration") ||
469
+ lower.startsWith("new chat") ||
470
+ lower.startsWith("projects") ||
471
+ lower.startsWith("chats") ||
472
+ lower.startsWith("plugins") ||
473
+ lower.startsWith("live reminders") ||
474
+ lower.startsWith("mark all as read") ||
475
+ lower.startsWith("scroll to explore") ||
476
+ lower.startsWith("too many people are chatting") ||
477
+ lower.startsWith("subscribe to enter a dedicated priority queue") ||
478
+ lower.startsWith("got it") ||
479
+ lower === "completed" ||
480
+ lower === "tips"
481
+ );
482
+ };
483
+
484
+ // Locate the LAST occurrence of the user prompt (most recent turn).
485
+ // Prefer an exact normalized line match: replies that echo the prompt
486
+ // word ("hi", "ok") would otherwise match inside the reply and shift the
487
+ // slice past the answer.
488
+ const promptNorm = normalizeLabel(userPrompt).slice(0, 30);
489
+ const promptLineNorms = String(userPrompt || "").split(/\r?\n/)
490
+ .map((line) => line.trim())
491
+ .filter((line) => line.length > 0)
492
+ .map(normalizeLabel);
493
+ const findPromptSequence = () => {
494
+ if (promptLineNorms.length < 2) return -1;
495
+ for (let i = lines.length - promptLineNorms.length; i >= 0; i--) {
496
+ let matches = true;
497
+ for (let j = 0; j < promptLineNorms.length; j++) {
498
+ if (normalizeLabel(lines[i + j]) !== promptLineNorms[j]) {
499
+ matches = false;
500
+ break;
501
+ }
502
+ }
503
+ if (matches) return i + promptLineNorms.length - 1;
504
+ }
505
+ return -1;
506
+ };
507
+ const findPrompt = (exactOnly) => {
508
+ if (!promptNorm) return -1;
509
+ for (let i = lines.length - 1; i >= 0; i--) {
510
+ const n = normalizeLabel(lines[i]);
511
+ if (exactOnly ? n === promptNorm : n.includes(promptNorm)) return i;
512
+ }
513
+ return -1;
514
+ };
515
+ let lastIdx = findPromptSequence();
516
+ if (lastIdx < 0) lastIdx = findPrompt(true);
517
+ if (lastIdx < 0) lastIdx = findPrompt(false);
518
+ let start = lastIdx >= 0 ? lastIdx + 1 : 0;
519
+
520
+ // If everything after the prompt marker is UI chrome (e.g. a reply that
521
+ // echoed the prompt and consumed the match), re-slice from the previous
522
+ // occurrence of the prompt instead of returning chrome as the answer.
523
+ const isChromeLine = (l) =>
524
+ isUI(l) || (l.length <= 1 && !/^[\d.,%$€£+\-—]+$/.test(l));
525
+ const sliceIsChrome = (from) =>
526
+ lines.slice(from).every((l) => isChromeLine(l));
527
+ if (sliceIsChrome(start) && lastIdx >= 0) {
528
+ for (let i = lastIdx - 1; i >= 0; i--) {
529
+ const n = normalizeLabel(lines[i]);
530
+ if (n.includes(promptNorm)) { start = i + 1; break; }
531
+ }
532
+ }
533
+
534
+ const out = [];
535
+ for (let i = start; i < lines.length; i++) {
536
+ const line = lines[i];
537
+ if (!line) continue;
538
+ if (isUI(line)) continue;
539
+ if (line.length <= 1 && !/^[\d.,%$€£+\-—]+$/.test(line)) continue;
540
+ out.push(line);
541
+ }
542
+
543
+ if (out.length > 0) return out.join("\n").trim();
544
+ // Fallback: everything after the prompt marker, but only if it contains
545
+ // substantive (non-chrome) content - otherwise the reply hasn't started yet
546
+ const rest = lines
547
+ .slice(start)
548
+ .filter((l) => !isUI(l) && !(l.length <= 1 && !/^[\d.,%$€£+\-—]+$/.test(l)))
549
+ .join("\n")
550
+ .trim();
551
+ return rest || null;
552
+ }
553
+
554
+ async function waitForResponse(jsEval, signal, timeoutMs = 300000, userPrompt = "") {
555
+ const deadline = Date.now() + timeoutMs;
556
+ let lastText = "";
557
+ let stableCycles = 0;
558
+ let lastChangeAt = Date.now();
559
+ let lastResponse = "";
560
+
561
+ while (Date.now() < deadline) {
562
+ const snapshot = await evaluate(
563
+ jsEval,
564
+ `(() => {
565
+ const bodyText = document.body.innerText || '';
566
+ // Stop control while generating
567
+ const stopBtn = Array.from(document.querySelectorAll('button')).find((b) => {
568
+ const label = (b.getAttribute('aria-label') || '').toLowerCase();
569
+ const txt = (b.textContent || '').toLowerCase().trim();
570
+ return (label.includes('stop') || txt === 'stop' || txt.includes('stop generating') || label.includes('停止') || txt.includes('停止'));
571
+ });
572
+ const input = ${FIND_INPUT_JS};
573
+ return {
574
+ bodyText,
575
+ hasStop: !!stopBtn && stopBtn.offsetParent !== null,
576
+ inputReady: !!input && input.offsetParent !== null,
577
+ url: location.href,
578
+ };
579
+ })()`,
580
+ signal
581
+ );
582
+ if (!snapshot || !snapshot.bodyText) {
583
+ await delay(350, signal);
584
+ continue;
585
+ }
586
+
587
+ const response = extractKimiResponse(snapshot.bodyText, userPrompt);
588
+ const current = response || "";
589
+
590
+ // Free accounts see a priority-queue notice when Kimi is at capacity.
591
+ // Fail fast with a clear error instead of a 300s silent timeout.
592
+ if (!current && !snapshot.hasStop &&
593
+ /too many people are chatting with kimi|subscribe to enter a dedicated priority queue/i.test(snapshot.bodyText)) {
594
+ throw new Error(
595
+ "Kimi is at capacity (priority-queue notice shown when no paid subscription is active); retry later"
596
+ );
597
+ }
598
+
599
+ if (current !== lastResponse) {
600
+ lastResponse = current;
601
+ stableCycles = 0;
602
+ lastChangeAt = Date.now();
603
+ } else if (current.length > 0) {
604
+ stableCycles++;
605
+ }
606
+ if (current !== lastText) lastText = current;
607
+
608
+ const stableMs = Date.now() - lastChangeAt;
609
+ const done = current.length > 0 && !snapshot.hasStop && snapshot.inputReady &&
610
+ (stableCycles >= 3 || stableMs >= 2000);
611
+
612
+ if (done) {
613
+ return { text: current, url: snapshot.url, partial: false };
614
+ }
615
+ await delay(400, signal);
616
+ }
617
+
618
+ // Timeout: return whatever we have
619
+ const finalText = extractKimiResponse(lastText || "", userPrompt);
620
+ if (finalText && finalText.trim().length > 0) {
621
+ return { text: finalText, url: undefined, partial: true };
622
+ }
623
+ throw new Error("Response timeout - Kimi did not complete in time");
624
+ }
625
+
626
+ // ============================================================================
627
+ // Main Query
628
+ // ============================================================================
629
+
630
+ async function query(options) {
631
+ const {
632
+ prompt,
633
+ extractionPrompt = prompt,
634
+ model,
635
+ timeout = 300000,
636
+ createTab,
637
+ closeTab,
638
+ jsEval,
639
+ log = () => {},
640
+ signal,
641
+ } = options;
642
+ throwIfAborted(signal);
643
+
644
+ const startTime = Date.now();
645
+ log("Starting Kimi query");
646
+
647
+ const tabInfo = await raceAbort(createTab, signal);
648
+ const tabId = tabInfo && (tabInfo.tabId ?? (tabInfo.tabs && tabInfo.tabs[0] && tabInfo.tabs[0].tabId));
649
+ if (!tabId) {
650
+ throw new Error(`Failed to create Kimi tab: ${JSON.stringify(tabInfo)}`);
651
+ }
652
+ log(`Created tab ${tabId}`);
653
+
654
+ const evalPage = (expression) => raceAbort(() => jsEval(tabId, expression), signal);
655
+
656
+ try {
657
+ await waitForPageLoad(evalPage, signal);
658
+ log("Page loaded");
659
+
660
+ const loginStatus = await checkLoginStatus(evalPage, signal);
661
+ if (!loginStatus.loggedIn) {
662
+ throw new Error("kimi.com login required - log in to kimi.com in your browser first");
663
+ }
664
+ log(`Login: yes (${loginStatus.url})`);
665
+
666
+ await waitForKimiReady(evalPage, signal);
667
+ log("Kimi ready");
668
+
669
+ // Fresh chat (kimi.com may restore the previous session)
670
+ try {
671
+ await startNewChat(evalPage, signal);
672
+ log("Started new chat");
673
+ } catch (e) {
674
+ log(`New chat reset failed (continuing): ${e.message}`);
675
+ }
676
+
677
+ const warnings = [];
678
+ const targetModel = model || DEFAULT_MODEL;
679
+ let selectedModel = targetModel;
680
+ let modelSelectionFailed = false;
681
+ try {
682
+ selectedModel = await selectModel(evalPage, signal, targetModel);
683
+ log(`Model: ${selectedModel}`);
684
+ // K3 / K3 Swarm / agent rows switch kimi.com into the /agent or
685
+ // /projects workspace, which has a different composer this client does
686
+ // not drive. Navigation can lag the click, so poll the URL briefly.
687
+ let urlState = null;
688
+ const urlDeadline = Date.now() + 3500;
689
+ while (Date.now() < urlDeadline) {
690
+ urlState = await evaluate(
691
+ evalPage,
692
+ `(() => ({ url: location.href }))()`,
693
+ signal
694
+ );
695
+ if (urlState && /\/agent|\/swarm|\/projects/.test(urlState.url)) break;
696
+ await delay(250, signal);
697
+ }
698
+ if (urlState && /\/agent|\/swarm|\/projects/.test(urlState.url)) {
699
+ modelSelectionFailed = true;
700
+ warnings.push(
701
+ `Model "${selectedModel}" switches Kimi into its Agent workspace (${urlState.url}); ` +
702
+ `reverting to the default chat model. Use --model instant (default), thinking, or high for the chat UI.`
703
+ );
704
+ selectedModel = DEFAULT_MODEL;
705
+ await evaluate(evalPage, `(() => { location.href = 'https://www.kimi.com/'; return true; })()`, signal).catch(() => {});
706
+ await delay(3500, signal);
707
+ await waitForKimiReady(evalPage, signal);
708
+ }
709
+ } catch (e) {
710
+ if (signal?.aborted) throw e;
711
+ modelSelectionFailed = true;
712
+ warnings.push(`Model selection failed: ${e.message}. Kimi may auto-select its default model.`);
713
+ log(`Model selection failed: ${e.message}`);
714
+ }
715
+
716
+ await typePrompt(evalPage, signal, prompt);
717
+ log("Prompt typed");
718
+
719
+ await submitPrompt(evalPage, signal);
720
+ log("Submitted, waiting for response...");
721
+
722
+ const response = await waitForResponse(evalPage, signal, timeout, extractionPrompt);
723
+ log(`Response: ${response.text.length} chars${response.partial ? ' (partial)' : ''}`);
724
+
725
+ return {
726
+ response: response.text,
727
+ model: selectedModel,
728
+ requestedModel: targetModel,
729
+ modelSelectionFailed,
730
+ url: response.url,
731
+ partial: response.partial || false,
732
+ warnings: warnings.length > 0 ? warnings : undefined,
733
+ tookMs: Date.now() - startTime,
734
+ };
735
+ } finally {
736
+ try {
737
+ await closeTab(tabId);
738
+ } catch (error) {
739
+ log(`Failed to close Kimi tab ${tabId}: ${error?.message || error}`);
740
+ }
741
+ }
742
+ }
743
+
744
+ // ============================================================================
745
+ // Validate - check UI structure and scrape available models
746
+ // ============================================================================
747
+
748
+ async function validate(options) {
749
+ const { createTab, closeTab, jsEval, log = () => {}, signal } = options;
750
+ throwIfAborted(signal);
751
+ const startTime = Date.now();
752
+ log("Starting Kimi validation");
753
+
754
+ const result = {
755
+ kimiValidate: true,
756
+ authenticated: false,
757
+ models: [],
758
+ expectedModels: Object.values(DEFAULT_KIMI_MODELS).map((m) => m.name),
759
+ inputFound: false,
760
+ sendButtonFound: false,
761
+ errors: [],
762
+ };
763
+
764
+ let tabId;
765
+ try {
766
+ const tabInfo = await raceAbort(createTab, signal);
767
+ tabId = tabInfo && (tabInfo.tabId ?? (tabInfo.tabs && tabInfo.tabs[0] && tabInfo.tabs[0].tabId));
768
+ if (!tabId) {
769
+ result.errors.push(`Failed to create tab: ${JSON.stringify(tabInfo)}`);
770
+ return { ...result, tookMs: Date.now() - startTime };
771
+ }
772
+ } catch (e) {
773
+ if (signal?.aborted) throw e;
774
+ result.errors.push(`Tab creation failed: ${e.message}`);
775
+ return { ...result, tookMs: Date.now() - startTime };
776
+ }
777
+
778
+ const evalPage = (expression) => raceAbort(() => jsEval(tabId, expression), signal);
779
+
780
+ try {
781
+ await raceAbort(waitForPageLoad(evalPage, signal), signal);
782
+ const loginStatus = await checkLoginStatus(evalPage, signal);
783
+ result.authenticated = loginStatus.loggedIn;
784
+ if (!loginStatus.loggedIn) {
785
+ result.errors.push("kimi.com shows logged-out state - log in first");
786
+ return { ...result, tookMs: Date.now() - startTime };
787
+ }
788
+
789
+ await raceAbort(waitForKimiReady(evalPage, signal), signal);
790
+ log("Kimi ready");
791
+
792
+ const inputCheck = await evaluate(
793
+ evalPage,
794
+ `(() => {
795
+ const input = ${FIND_INPUT_JS};
796
+ return { found: !!input && input.offsetParent !== null };
797
+ })()`,
798
+ signal
799
+ );
800
+ result.inputFound = inputCheck?.found || false;
801
+
802
+ const sendCheck = await evaluate(
803
+ evalPage,
804
+ `(() => {
805
+ const container = document.querySelector('.send-button-container');
806
+ if (container && container.offsetParent !== null) return { found: true };
807
+ const input = ${FIND_INPUT_JS};
808
+ const scope = input ? (input.parentElement || document) : document;
809
+ const btn = Array.from(scope.querySelectorAll('button')).find((b) => {
810
+ const label = (b.getAttribute('aria-label') || '').toLowerCase();
811
+ const txt = (b.textContent || '').trim().toLowerCase();
812
+ return label === 'send' || label === 'submit' || label.includes('发送') || txt === 'send';
813
+ });
814
+ return { found: !!btn && btn.offsetParent !== null };
815
+ })()`,
816
+ signal
817
+ );
818
+ result.sendButtonFound = sendCheck?.found || false;
819
+
820
+ // Try scraping the model menu
821
+ // Kimi renders .current-model/.model-name late - wait for it (up to 6s)
822
+ let modelReady = false;
823
+ const readyDeadline = Date.now() + 6000;
824
+ while (Date.now() < readyDeadline && !modelReady) {
825
+ const chk = await evaluate(
826
+ evalPage,
827
+ `(() => {
828
+ const el = document.querySelector('.current-model, .model-name');
829
+ return { ready: !!el && el.offsetParent !== null };
830
+ })()`,
831
+ signal
832
+ );
833
+ modelReady = chk && chk.ready;
834
+ if (!modelReady) await abortableDelay(400, signal);
835
+ }
836
+
837
+ const modelClicked = await evaluate(
838
+ evalPage,
839
+ `(() => {
840
+ ${buildClickDispatcher()}
841
+ const modelDiv = document.querySelector('.current-model, .model-name, [class*="model-picker"], [class*="model-select"]');
842
+ if (modelDiv && modelDiv.offsetParent !== null) {
843
+ dispatchClickSequence(modelDiv);
844
+ return { success: true };
845
+ }
846
+ const input = ${FIND_INPUT_JS};
847
+ const scope = input ? (input.parentElement || document) : document;
848
+ const btn = Array.from(scope.querySelectorAll('button')).find((b) => {
849
+ const text = (b.textContent || '').toLowerCase();
850
+ return /k2|k3|instant|thinking|model/i.test(text);
851
+ });
852
+ if (btn) { dispatchClickSequence(btn); return { success: true }; }
853
+ return { success: false };
854
+ })()`,
855
+ signal
856
+ );
857
+
858
+ if (modelClicked?.success) {
859
+ // Popover may take a moment - retry the scrape for up to ~4s
860
+ let scrapeResult = null;
861
+ const scrapeDeadline = Date.now() + 4000;
862
+ while (Date.now() < scrapeDeadline && !(scrapeResult && scrapeResult.models && scrapeResult.models.length > 0)) {
863
+ await abortableDelay(600, signal);
864
+ scrapeResult = await evaluate(
865
+ evalPage,
866
+ `(() => {
867
+ const popover = document.querySelector('.models-popover') ||
868
+ Array.from(document.querySelectorAll('.n-popover__content, .n-popover')).find((el) => el.offsetParent !== null && (el.textContent || '').length > 0);
869
+ if (!popover) return { models: [] };
870
+ const names = [];
871
+ const seen = new Set();
872
+ for (const el of popover.querySelectorAll('[class*="item"], div')) {
873
+ const first = (el.textContent || '').trim().split('\\n')[0].trim();
874
+ if (!first || first.length < 1 || first.length > 40 || seen.has(first)) continue;
875
+ seen.add(first);
876
+ names.push(first);
877
+ }
878
+ return { models: names.slice(0, 12) };
879
+ })()`,
880
+ signal
881
+ ).catch(() => ({ models: [] }));
882
+ }
883
+ result.models = scrapeResult?.models || [];
884
+ log(`Found models: ${result.models.join(', ')}`);
885
+ await evaluate(evalPage, "document.body.click()", signal);
886
+ }
887
+ } catch (e) {
888
+ if (signal?.aborted) throw e;
889
+ result.errors.push(`Validation error: ${e.message}`);
890
+ } finally {
891
+ try {
892
+ await closeTab(tabId);
893
+ } catch (error) {
894
+ log(`Failed to close Kimi validation tab ${tabId}: ${error?.message || error}`);
895
+ }
896
+ }
897
+
898
+ result.tookMs = Date.now() - startTime;
899
+ return result;
900
+ }
901
+
902
+ module.exports = {
903
+ query,
904
+ validate,
905
+ extractKimiResponse,
906
+ normalizeLabel,
907
+ getMatchLabels,
908
+ waitForResponse,
909
+ KIMI_URL,
910
+ DEFAULT_KIMI_MODELS,
911
+ DEFAULT_MODEL,
912
+ };