@tianmucreations/jeeves 0.3.1 → 0.3.2

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 (43) hide show
  1. package/dist/agent/errors.js +1 -1
  2. package/dist/agent/loop.js +34 -1
  3. package/dist/agent/systemPrompt.js +19 -5
  4. package/dist/app.js +23 -1
  5. package/dist/checkpoints/index.js +34 -4
  6. package/dist/commands/clear.js +2 -0
  7. package/dist/commands/help.js +16 -14
  8. package/dist/commands/keys.js +2 -1
  9. package/dist/components/AddressPrompt.js +21 -6
  10. package/dist/components/Footer.js +6 -1
  11. package/dist/components/Header.js +5 -1
  12. package/dist/components/HelpView.js +1 -1
  13. package/dist/components/Input.js +135 -13
  14. package/dist/components/KeysManager.js +33 -18
  15. package/dist/components/ModelPicker.js +27 -8
  16. package/dist/components/OpenRouterConnect.js +114 -0
  17. package/dist/components/ProjectPicker.js +55 -16
  18. package/dist/components/Transcript.js +39 -2
  19. package/dist/components/input-layout.js +129 -26
  20. package/dist/components/markdown.js +185 -0
  21. package/dist/components/transcript-layout.js +50 -2
  22. package/dist/index.js +2 -1
  23. package/dist/ink/mouse.js +39 -6
  24. package/dist/ink/quit.js +21 -0
  25. package/dist/ink/selection.js +128 -0
  26. package/dist/keys/store.js +72 -2
  27. package/dist/platform/address.js +16 -0
  28. package/dist/platform/chat-folder.js +24 -0
  29. package/dist/platform/config.js +3 -0
  30. package/dist/platform/wording.js +10 -0
  31. package/dist/providers/direct.js +8 -2
  32. package/dist/providers/index.js +21 -3
  33. package/dist/providers/ollama.js +8 -2
  34. package/dist/providers/openrouter-signin.js +102 -0
  35. package/dist/providers/openrouter.js +17 -2
  36. package/dist/providers/silence.js +39 -0
  37. package/dist/providers/zai.js +17 -13
  38. package/dist/state/session.js +41 -2
  39. package/dist/tools/index.js +22 -5
  40. package/dist/tools/runBash.js +17 -5
  41. package/dist/tools/web/research.js +119 -21
  42. package/dist/tools/web/zai-search.js +79 -0
  43. package/package.json +2 -1
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { streamText, stepCountIs } from 'ai';
3
3
  import { createOpenRouter } from '@openrouter/ai-sdk-provider';
4
+ import { silenceGuard } from './silence.js';
4
5
  import { prepareStepFor, stepCost } from './step-control.js';
5
6
  // Assumption: the spec's "maxSteps" is called stopWhen/stepCountIs in AI SDK 7 (the installed version); same cap of 25.
6
7
  const MAX_TOOL_STEPS = 25;
@@ -38,6 +39,15 @@ export async function fetchCreditInfo(apiKey) {
38
39
  return null;
39
40
  }
40
41
  }
42
+ // A brand-new OpenRouter account has no credit: it connects, but only the free
43
+ // models answer. Said plainly right after connecting, or '' when there is credit
44
+ // (or the balance can't be read - never a guess).
45
+ export async function noCreditNote(apiKey) {
46
+ const info = await fetchCreditInfo(apiKey);
47
+ if (!info || info.remaining > 0)
48
+ return '';
49
+ return 'Your OpenRouter account has no credit yet. The free models work now - choose one from the Free list. To use the others, add credit at openrouter.ai/credits.';
50
+ }
41
51
  // The key's all-time spend in dollars, from GET /api/v1/key (field "usage",
42
52
  // confirmed against a live response). Used to work out today's spend in local time.
43
53
  export async function fetchKeyUsage(apiKey) {
@@ -67,6 +77,7 @@ export function createOpenRouterProvider(apiKey) {
67
77
  id: 'openrouter',
68
78
  name: 'OpenRouter',
69
79
  async stream({ modelId, messages, tools, instructions, onToken, onReasoning, onToolCall, beforeStep, abortSignal }) {
80
+ const guard = silenceGuard(abortSignal);
70
81
  const result = streamText({
71
82
  instructions,
72
83
  // A stalled request must never wedge the app in the working state forever -
@@ -76,14 +87,16 @@ export function createOpenRouterProvider(apiKey) {
76
87
  // started, and 10 minutes for any single step, which also covers a request that
77
88
  // never starts answering. (A plain number here limits the entire multi-step
78
89
  // job; a 3-minute one killed healthy jobs mid-way in testing.)
79
- timeout: { firstChunkMs: 120_000, chunkMs: 90_000, stepMs: 600_000 },
90
+ // Silence while the model answers is watched by silenceGuard (it pauses while a
91
+ // command runs or waits for the person); the first piece still has 2 minutes.
92
+ timeout: { firstChunkMs: 120_000 },
80
93
  // Usage accounting makes OpenRouter report each step's exact cost.
81
94
  model: openrouter.chat(modelId, { usage: { include: true } }),
82
95
  messages,
83
96
  tools,
84
97
  stopWhen: stepCountIs(MAX_TOOL_STEPS),
85
98
  prepareStep: prepareStepFor(beforeStep, (id) => openrouter.chat(id, { usage: { include: true } })),
86
- abortSignal,
99
+ abortSignal: guard.signal,
87
100
  // The library prints every failure to the screen by default, over Jeeves's
88
101
  // window; the failure still arrives below and is explained in plain English.
89
102
  onError: () => { },
@@ -95,6 +108,7 @@ export function createOpenRouterProvider(apiKey) {
95
108
  });
96
109
  let streamedError = null;
97
110
  for await (const part of result.stream) {
111
+ guard.onPart(part);
98
112
  if (part.type === 'text-delta') {
99
113
  onToken(part.text);
100
114
  }
@@ -108,6 +122,7 @@ export function createOpenRouterProvider(apiKey) {
108
122
  streamedError = part.error;
109
123
  }
110
124
  }
125
+ guard.stop();
111
126
  // The real stream error (a rejected key, a missing model) must win over the
112
127
  // SDK's generic no-output error, which would otherwise mask the cause.
113
128
  if (streamedError !== null) {
@@ -0,0 +1,39 @@
1
+ // A stalled reply must never leave Jeeves "working" forever - but the time a
2
+ // command runs, or the time the person takes to answer "allow?", is not the model
3
+ // going quiet. The AI SDK's timeout.chunkMs counts both (measured 19 Sept: a 2 s tool
4
+ // tripped a 1 s chunkMs), so any command over 90 s cancelled the whole job and
5
+ // blamed the service. This watchdog counts silence only while no tool is running.
6
+ export const SILENCE_MS = 90_000;
7
+ export function silenceGuard(outer, ms = SILENCE_MS) {
8
+ const controller = new AbortController();
9
+ let timer;
10
+ let toolsRunning = 0;
11
+ const arm = () => {
12
+ clearTimeout(timer);
13
+ if (toolsRunning > 0)
14
+ return;
15
+ // Named as the SDK names its own, so the plain-English message stays the same.
16
+ timer = setTimeout(() => controller.abort(new DOMException(`Chunk timeout of ${ms}ms exceeded`, 'TimeoutError')), ms);
17
+ // A finished or stopped reply never keeps Jeeves (or a test run) waiting on this timer.
18
+ timer.unref();
19
+ };
20
+ if (outer?.aborted)
21
+ controller.abort(outer.reason);
22
+ else
23
+ outer?.addEventListener('abort', () => controller.abort(outer.reason), { once: true });
24
+ return {
25
+ signal: controller.signal,
26
+ onPart(part) {
27
+ if (part.type === 'tool-call')
28
+ toolsRunning += 1;
29
+ else if (part.type === 'tool-result' || part.type === 'tool-error' || part.type === 'tool-output-denied')
30
+ toolsRunning = Math.max(0, toolsRunning - 1);
31
+ arm();
32
+ },
33
+ stop() {
34
+ clearTimeout(timer);
35
+ if (controller.signal.aborted && !outer?.aborted)
36
+ throw controller.signal.reason;
37
+ },
38
+ };
39
+ }
@@ -1,11 +1,14 @@
1
1
  import { streamText, stepCountIs } from 'ai';
2
- import { createOpenRouter } from '@openrouter/ai-sdk-provider';
2
+ import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
3
+ import { silenceGuard } from './silence.js';
3
4
  import { prepareStepFor } from './step-control.js';
4
5
  // The GLM Coding Plan endpoint: OpenAI Chat Completions protocol at
5
6
  // https://api.z.ai/api/coding/paas/v4 - NOT the standard /api/paas/v4, which is
6
- // the pay-per-token API. The OpenRouter client speaks the OpenAI protocol, so it
7
- // talks to the coding endpoint directly (the same trick the Ollama adapter uses
8
- // for its OpenAI-compatible endpoint).
7
+ // the pay-per-token API. Connected with @ai-sdk/openai-compatible, as OpenCode does
8
+ // (models.dev "zai-coding-plan"): unlike the OpenRouter client used before, it reads
9
+ // Z.ai's thinking (reasoning_content), so Jeeves can say it is thinking. GLM-5.3
10
+ // thinks for up to half a minute before answering; with the old client that time
11
+ // showed nothing at all (measured 19 Sept: 1,425 thinking tokens, first words at 26 s).
9
12
  export const ZAI_CODING_BASE_URL = 'https://api.z.ai/api/coding/paas/v4';
10
13
  const MAX_TOOL_STEPS = 25;
11
14
  // The GLM Coding Plan is flat-rate, so prices are meaningless per token; the picker
@@ -53,15 +56,12 @@ export const ZAI_MODELS = [
53
56
  },
54
57
  ];
55
58
  export function createZaiProvider(apiKey) {
56
- const client = createOpenRouter({
57
- apiKey,
58
- baseURL: ZAI_CODING_BASE_URL,
59
- compatibility: 'compatible',
60
- });
59
+ const client = createOpenAICompatible({ name: 'zai', apiKey, baseURL: ZAI_CODING_BASE_URL, includeUsage: true });
61
60
  return {
62
61
  id: 'zai',
63
62
  name: 'Z.ai',
64
63
  async stream({ modelId, messages, tools, instructions, onToken, onReasoning, onToolCall, beforeStep, abortSignal }) {
64
+ const guard = silenceGuard(abortSignal);
65
65
  const result = streamText({
66
66
  instructions,
67
67
  // A stalled request must never wedge the app in the working state forever -
@@ -71,19 +71,22 @@ export function createZaiProvider(apiKey) {
71
71
  // started, and 10 minutes for any single step, which also covers a request that
72
72
  // never starts answering. (A plain number here limits the entire multi-step
73
73
  // job; a 3-minute one killed healthy jobs mid-way in testing.)
74
- timeout: { firstChunkMs: 120_000, chunkMs: 90_000, stepMs: 600_000 },
75
- model: client.chat(modelId),
74
+ // Silence while the model answers is watched by silenceGuard (it pauses while a
75
+ // command runs or waits for the person); the first piece still has 2 minutes.
76
+ timeout: { firstChunkMs: 120_000 },
77
+ model: client.chatModel(modelId),
76
78
  messages,
77
79
  tools,
78
80
  stopWhen: stepCountIs(MAX_TOOL_STEPS),
79
- prepareStep: prepareStepFor(beforeStep, (id) => client.chat(id)),
80
- abortSignal,
81
+ prepareStep: prepareStepFor(beforeStep, (id) => client.chatModel(id)),
82
+ abortSignal: guard.signal,
81
83
  // The library prints every failure to the screen by default, over Jeeves's
82
84
  // window; the failure still arrives below and is explained in plain English.
83
85
  onError: () => { },
84
86
  });
85
87
  let streamedError = null;
86
88
  for await (const part of result.stream) {
89
+ guard.onPart(part);
87
90
  if (part.type === 'text-delta') {
88
91
  onToken(part.text);
89
92
  }
@@ -97,6 +100,7 @@ export function createZaiProvider(apiKey) {
97
100
  streamedError = part.error;
98
101
  }
99
102
  }
103
+ guard.stop();
100
104
  // The real stream error (a rejected key, a missing model) must win over the
101
105
  // SDK's generic no-output error, which would otherwise mask the cause.
102
106
  if (streamedError !== null) {
@@ -44,6 +44,9 @@ class SessionStore {
44
44
  tidying = false;
45
45
  // A short note for the info bar while something quick runs, like 'backing up…'.
46
46
  busyNote = null;
47
+ // When the model began thinking privately before writing (ms), or null. Thinking
48
+ // can last half a minute, so the screen says so instead of showing nothing.
49
+ thinkingSince = null;
47
50
  // Something the model must be told with the next message (for example, that /undo ran).
48
51
  pendingContextNote = null;
49
52
  // The daily spending limit in dollars, and any extra allowance granted today.
@@ -58,6 +61,15 @@ class SessionStore {
58
61
  // The furthest the transcript can scroll up (contentHeight - viewportHeight),
59
62
  // reported by the Transcript from its live measurements.
60
63
  transcriptScrollMax = Number.POSITIVE_INFINITY;
64
+ // Text selected with the mouse in the conversation (Claude Code's in-app selection):
65
+ // line and character positions within the drawn lines, so it stays on its words
66
+ // while the view scrolls. null when nothing is selected.
67
+ selection = null;
68
+ // What the conversation area shows, published by the Transcript each time it draws,
69
+ // so a mouse position can be turned into a line and character.
70
+ // Set by the typing box: puts the cursor at a clicked screen position.
71
+ inputClick = null;
72
+ transcriptView = null;
61
73
  // What is being typed in the input box (the window sizes the box to fit it).
62
74
  inputText = '';
63
75
  // Messages sent while Jeeves was busy, in order; each is sent when he finishes.
@@ -222,9 +234,11 @@ class SessionStore {
222
234
  this.wizardActive = true;
223
235
  this.emit();
224
236
  }
225
- endWizard() {
237
+ // skipped: the person chose "not now" - straight to the conversation, not a second
238
+ // list of services (audit 19 Sept).
239
+ endWizard(skipped = false) {
226
240
  this.wizardActive = false;
227
- const shouldOpenModelPicker = this.wizardFromLaunch;
241
+ const shouldOpenModelPicker = this.wizardFromLaunch && !skipped;
228
242
  this.wizardFromLaunch = false;
229
243
  this.emit();
230
244
  if (shouldOpenModelPicker) {
@@ -233,6 +247,19 @@ class SessionStore {
233
247
  }
234
248
  launchComplete() {
235
249
  this.launchStage = 'ready';
250
+ this.switchingFolder = false;
251
+ this.emit();
252
+ }
253
+ // /folder: the folder list again, from inside a conversation (owner, 19 Sept: from
254
+ // "Just chat" there was no way into a folder). Between tasks only, like /model.
255
+ switchingFolder = false;
256
+ openFolderPicker() {
257
+ if (this.status === 'working' || this.approvalPending) {
258
+ this.addNotice('The folder can be changed between tasks.');
259
+ return;
260
+ }
261
+ this.switchingFolder = true;
262
+ this.launchStage = 'project';
236
263
  this.emit();
237
264
  }
238
265
  // The address question runs before the project picker on first launch only;
@@ -293,6 +320,12 @@ class SessionStore {
293
320
  this.transcriptScrollUp = next;
294
321
  this.emit();
295
322
  }
323
+ setSelection(selection) {
324
+ if (selection === null && this.selection === null)
325
+ return;
326
+ this.selection = selection;
327
+ this.emit();
328
+ }
296
329
  setTranscriptScrollMax(max) {
297
330
  this.transcriptScrollMax = Math.max(0, max);
298
331
  if (this.transcriptScrollUp > this.transcriptScrollMax) {
@@ -350,6 +383,12 @@ class SessionStore {
350
383
  this.activeModel = model;
351
384
  this.emit();
352
385
  }
386
+ setThinking(on) {
387
+ if (on === (this.thinkingSince !== null))
388
+ return;
389
+ this.thinkingSince = on ? Date.now() : null;
390
+ this.emit();
391
+ }
353
392
  setBusyNote(note) {
354
393
  if (this.busyNote === note)
355
394
  return;
@@ -5,7 +5,7 @@ import { readFileSchema, runReadFile } from './readFile.js';
5
5
  import { writeFileSchema, runWriteFile } from './writeFile.js';
6
6
  import { listDirSchema, runListDir } from './listDir.js';
7
7
  import { runBashSchema, runRunBash } from './runBash.js';
8
- import { webSearchSchema, runWebSearch, readWebPageSchema, runReadWebPage } from './web/research.js';
8
+ import { webSearchSchema, runWebSearch, readWebPageSchema, runReadWebPage, researchService, borrowedSearchNeedsAsking, borrowedSearchQuestion } from './web/research.js';
9
9
  import { ensureCheckpoint, isOutsideProject, commandMayReachOutside } from '../checkpoints/index.js';
10
10
  import { resolveFromCwd } from '../platform/paths.js';
11
11
  import { isProjectTrusted } from '../agent/trust.js';
@@ -41,6 +41,10 @@ export function plainToolFailure(error) {
41
41
  return 'that folder is not empty';
42
42
  if (text.includes('no space left'))
43
43
  return 'the disk is full';
44
+ // Say how long it was allowed ("didn't finish in 2 minutes"), not just "took too long".
45
+ const limit = /didn't finish in ([0-9]+ (?:minutes?|seconds))/.exec(raw)?.[1];
46
+ if (limit)
47
+ return `still running after ${limit}, so it was stopped`;
44
48
  if (text.includes('timed out') || text.includes('etimedout') || text.includes('stopped after') || text.includes("didn't finish"))
45
49
  return 'took too long';
46
50
  if (text.includes('binary file'))
@@ -49,6 +53,12 @@ export function plainToolFailure(error) {
49
53
  return 'needs an OpenRouter key (type /keys)';
50
54
  if (text.includes('web search is not available'))
51
55
  return 'web search is not available right now';
56
+ if (text.includes("web search isn't available with"))
57
+ return "web search isn't available with this service yet";
58
+ if (text.includes('was not allowed in this conversation'))
59
+ return 'not allowed';
60
+ if (text.includes('needs your z.ai key'))
61
+ return 'needs your Z.ai key (type /keys)';
52
62
  if (text.includes("couldn't open"))
53
63
  return "that website wouldn't open";
54
64
  if (text.includes('not a valid web address') || text.includes('only web pages'))
@@ -59,6 +69,10 @@ export function plainToolFailure(error) {
59
69
  // and explains it in plain English.
60
70
  return 'something unexpected went wrong';
61
71
  }
72
+ function researchLabel() {
73
+ const service = researchService();
74
+ return service === 'zai' ? ' (Z.ai plan)' : service === 'borrowed' ? ' (via OpenRouter)' : '';
75
+ }
62
76
  function clip(text, max) {
63
77
  return text.length > max ? text.slice(0, max - 1) + '…' : text;
64
78
  }
@@ -155,10 +169,13 @@ export const TOOLS = {
155
169
  name: 'webSearch',
156
170
  description: 'Search the web. Returns titles, addresses and short snippets - a list of where to look, not checked facts.',
157
171
  schema: webSearchSchema,
158
- permission: false,
172
+ // On a service with no search of its own, a search borrows OpenRouter (about a cent
173
+ // each) only after a yes, asked once per conversation - never "always allowed".
174
+ permission: () => borrowedSearchNeedsAsking(),
175
+ warning: () => (borrowedSearchNeedsAsking() ? borrowedSearchQuestion() : null),
159
176
  summarize: (input) => clip(input.query, 60),
160
- // On another service, searches still go through OpenRouter (about a cent each), so say so.
161
- label: (input) => `Searched ${clip(input.query, 50)}${session.providerId === 'openrouter' ? '' : ' (via OpenRouter)'}`,
177
+ // Every search line says which service it went through.
178
+ label: (input) => `Searched ${clip(input.query, 50)}${researchLabel()}`,
162
179
  run: runWebSearch,
163
180
  }),
164
181
  readWebPage: defineTool({
@@ -167,7 +184,7 @@ export const TOOLS = {
167
184
  schema: readWebPageSchema,
168
185
  permission: false,
169
186
  summarize: (input) => clip(input.url, 60),
170
- label: (input) => `Read ${clip(input.url.replace(/^https?:\/\//, ''), 50)}${session.providerId === 'openrouter' ? '' : ' (via OpenRouter)'}`,
187
+ label: (input) => `Read ${clip(input.url.replace(/^https?:\/\//, ''), 50)}${researchService() === 'zai' ? ' (Z.ai plan)' : ''}`,
171
188
  run: runReadWebPage,
172
189
  }),
173
190
  runBash: defineTool({
@@ -2,8 +2,17 @@ import { execa } from 'execa';
2
2
  import { z } from 'zod';
3
3
  import { getShell } from '../platform/shell.js';
4
4
  import { stripQuotes } from '../agent/permissions.js';
5
+ // Claude Code's limits (utils/timeouts.ts): 2 minutes unless the model asks for
6
+ // more, 10 minutes at most. Jeeves had 60 seconds, which cut off every large
7
+ // download or install (owner's screenshot, 19 Sept).
8
+ export const DEFAULT_COMMAND_MS = 120_000;
9
+ export const MAX_COMMAND_MS = 600_000;
5
10
  export const runBashSchema = z.object({
6
11
  command: z.string().describe('The shell command to run'),
12
+ timeout: z
13
+ .number()
14
+ .optional()
15
+ .describe(`How long the command may run, in milliseconds - up to ${MAX_COMMAND_MS} (10 minutes). Without it: ${DEFAULT_COMMAND_MS} (2 minutes). Ask for more for downloads, installs and builds.`),
7
16
  });
8
17
  // Every running command is registered so the exit paths (Ctrl+C, /exit, kill
9
18
  // signals) can terminate them immediately - the UI must never be left waiting on
@@ -33,28 +42,31 @@ function interactiveCommandIn(command) {
33
42
  }
34
43
  return null;
35
44
  }
36
- // A hard ceiling per command: anything still running after this is killed and
37
- // reported to the model, which continues the conversation.
38
- const COMMAND_TIMEOUT_MS = 60_000;
45
+ export function describeLimit(ms) {
46
+ return ms % 60_000 === 0 ? `${ms / 60_000} minute${ms === 60_000 ? '' : 's'}` : `${Math.round(ms / 1000)} seconds`;
47
+ }
39
48
  export async function runRunBash(input) {
40
49
  const refusal = interactiveCommandIn(input.command);
41
50
  if (refusal !== null) {
42
51
  throw new Error(`${refusal} needs an interactive terminal, which this tool does not provide - it was not run. Use a non-interactive alternative (for example cat or grep) instead.`);
43
52
  }
53
+ // A hard ceiling per command: anything still running after this is killed and
54
+ // reported to the model, which continues the conversation.
55
+ const limit = Math.min(Math.max(input.timeout ?? DEFAULT_COMMAND_MS, 1_000), MAX_COMMAND_MS);
44
56
  const shell = getShell();
45
57
  const child = execa(shell.program, [shell.flag, input.command], {
46
58
  reject: false,
47
59
  // stdin is /dev/null: a command that reads input gets an immediate end-of-file
48
60
  // instead of sitting forever waiting for keystrokes that will never come.
49
61
  stdin: 'ignore',
50
- timeout: COMMAND_TIMEOUT_MS,
62
+ timeout: limit,
51
63
  forceKillAfterDelay: 2_000,
52
64
  });
53
65
  running.add(child);
54
66
  try {
55
67
  const result = await child;
56
68
  if (result.timedOut === true) {
57
- throw new Error(`that command didn't finish in ${COMMAND_TIMEOUT_MS / 1000} seconds - it may be waiting for input. It was stopped.`);
69
+ throw new Error(`that command didn't finish in ${describeLimit(limit)} - it may be waiting for input, or need longer (up to 10 minutes can be asked for). It was stopped.`);
58
70
  }
59
71
  const parts = [`$ ${input.command}`, `exit code: ${result.exitCode ?? 'unknown'}`];
60
72
  if (result.stdout)
@@ -1,10 +1,21 @@
1
1
  import { z } from 'zod';
2
2
  import { session } from '../../state/session.js';
3
- import { getOpenRouterKey } from '../../providers/index.js';
3
+ import { getOpenRouterKey, getZaiKey, PROVIDER_ROWS } from '../../providers/index.js';
4
+ import { zaiSearch, zaiRead } from './zai-search.js';
5
+ import { expertChat } from '../../agent/expert-chat.js';
6
+ import { isDirectService } from '../../providers/direct-services.js';
4
7
  import { htmlToText } from './htmlToText.js';
5
8
  import { openrouterChat, OpenRouterRequestError } from './openrouterChat.js';
6
- import { workingModelId, workerModel } from '../../agent/auto.js';
9
+ import { workingModelId, workerModel, hasAuto } from '../../agent/auto.js';
7
10
  import { recordPageOpened, recordSearchResults, recordWebUnavailable } from '../../agent/research-gate.js';
11
+ // The service chosen is the service used (owner's rule, 18 Sept): "if someone has a plan
12
+ // and selects plan then the plan should be the thing being used". So:
13
+ // - OpenRouter: search and reading on OpenRouter, as below;
14
+ // - Z.ai's GLM Coding Plan: search with the plan's own Web Search service and reading
15
+ // with GLM-5.3-Flash on the plan - never OpenRouter, even when a key is saved;
16
+ // - any other service: reading with that service; search has no equivalent there yet,
17
+ // so it borrows OpenRouter only after the person says yes (once per conversation),
18
+ // and without an OpenRouter key says plainly that search isn't available.
8
19
  // Web research, built only on OpenRouter's standard (non-beta) features, with
9
20
  // automatic fallbacks so no single service leaving creates a hole:
10
21
  // - search: OpenRouter's web search plugin, rotating Exa -> Parallel -> Perplexity
@@ -29,7 +40,7 @@ export const PROVEN_READING_MODELS = ['deepseek/deepseek-v4-flash-0731', 'openai
29
40
  // Reading models in the order tried: the cheap one first, then Jeeves's own model
30
41
  // when that is also an OpenRouter model, then the proven ones.
31
42
  export function readingModels() {
32
- // Research always runs through OpenRouter, so it uses OpenRouter's Auto worker.
43
+ // Used only for research on OpenRouter, so it is OpenRouter's Auto worker.
33
44
  const models = [workerModel('openrouter')];
34
45
  const current = workingModelId(session.model);
35
46
  if (session.providerId === 'openrouter' && current && !models.includes(current))
@@ -49,7 +60,60 @@ export function reasoningIsMandatory(error) {
49
60
  function isAccountProblem(error) {
50
61
  return error instanceof OpenRouterRequestError && (error.status === 401 || error.status === 402);
51
62
  }
63
+ // Which service web research runs on for the service in use.
64
+ export function researchService(providerId = session.providerId) {
65
+ if (providerId === 'openrouter')
66
+ return 'openrouter';
67
+ if (providerId === 'zai')
68
+ return 'zai';
69
+ return 'borrowed';
70
+ }
71
+ export function serviceLabel(providerId = session.providerId) {
72
+ return PROVIDER_ROWS.find((row) => row.id === providerId)?.label ?? 'this service';
73
+ }
74
+ // Whether the person agreed, in this conversation, to searches using their OpenRouter
75
+ // account while another service is in use. /clear forgets it.
76
+ let borrowAgreed = false;
77
+ export function agreeToBorrowedSearch() {
78
+ borrowAgreed = true;
79
+ }
80
+ export function resetBorrowedSearch() {
81
+ borrowAgreed = false;
82
+ }
83
+ // Asked before a search on OpenRouter while another service is in use.
84
+ export function borrowedSearchNeedsAsking() {
85
+ return researchService() === 'borrowed' && getOpenRouterKey() !== null && !borrowAgreed;
86
+ }
87
+ export function borrowedSearchQuestion() {
88
+ return `Web search isn't available with ${serviceLabel()} yet, so this search would use your OpenRouter account (about a cent each). Allow searches through OpenRouter for this conversation? (y/n)`;
89
+ }
90
+ async function searchOnPlan(query, site) {
91
+ const key = getZaiKey();
92
+ if (!key) {
93
+ recordWebUnavailable();
94
+ throw new Error('Web search on the Z.ai plan needs your Z.ai key - type /keys to add it.');
95
+ }
96
+ try {
97
+ const results = await zaiSearch(key, query, site);
98
+ recordSearchResults(results.map((result) => result.url));
99
+ return results;
100
+ }
101
+ catch (error) {
102
+ recordWebUnavailable();
103
+ throw new Error(`Web search is not available right now on the Z.ai plan (${error instanceof Error ? error.message : String(error)}).`);
104
+ }
105
+ }
52
106
  export async function searchWeb(query, site) {
107
+ const service = researchService();
108
+ if (service === 'zai')
109
+ return searchOnPlan(query, site);
110
+ if (service === 'borrowed' && !getOpenRouterKey()) {
111
+ recordWebUnavailable();
112
+ throw new Error(`Web search isn't available with ${serviceLabel()} yet.`);
113
+ }
114
+ if (service === 'borrowed' && !borrowAgreed) {
115
+ throw new Error('Web search through OpenRouter was not allowed in this conversation.');
116
+ }
53
117
  const key = getOpenRouterKey();
54
118
  if (!key) {
55
119
  recordWebUnavailable();
@@ -97,12 +161,18 @@ export function formatSearchResults(query, results) {
97
161
  const snippet = result.content.replace(/\s+/g, ' ').trim().slice(0, SNIPPET_CHARS);
98
162
  return `${index + 1}. ${result.title || result.url}\n ${result.url}${snippet ? `\n ${snippet}` : ''}`;
99
163
  });
100
- return `${lines.join('\n')}\n\nThese are search snippets, not checked facts. Open the most official page with readWebPage before stating anything as fact.`;
164
+ const planNote = researchService() === 'zai'
165
+ ? ' These results often point to a site\'s front page with a short summary: open the page, and if the exact fact is not there, say plainly that it could not be confirmed.'
166
+ : '';
167
+ return `${lines.join('\n')}\n\nThese are search snippets, not checked facts. Open the most official page with readWebPage before stating anything as fact.${planNote}`;
101
168
  }
102
169
  export const webSearchSchema = z.object({
103
170
  query: z.string().min(1).describe('What to search the web for'),
104
171
  });
105
172
  export async function runWebSearch(input) {
173
+ // Only reached after the person said yes when the search is borrowed from OpenRouter.
174
+ if (researchService() === 'borrowed' && getOpenRouterKey())
175
+ agreeToBorrowedSearch();
106
176
  return formatSearchResults(input.query, await searchWeb(input.query));
107
177
  }
108
178
  export const readWebPageSchema = z.object({
@@ -156,6 +226,7 @@ export async function runReadWebPage(input) {
156
226
  let sourceNote = `Source: ${url.href}`;
157
227
  if (pageText === null) {
158
228
  // The site refused or needs a browser: fall back to search excerpts from that site.
229
+ // (Not tried when the search would be borrowed from OpenRouter without a yes.)
159
230
  const results = await searchWeb(input.question, url.hostname).catch(() => []);
160
231
  if (results.length === 0)
161
232
  throw new Error(`Couldn't open ${url.hostname} - the site refused or needs a browser.`);
@@ -164,25 +235,52 @@ export async function runReadWebPage(input) {
164
235
  sourceNote = `Source: search excerpts from ${url.hostname} (the page itself could not be opened)`;
165
236
  }
166
237
  const page = pageText.slice(0, MAX_PAGE_CHARS);
167
- const key = getOpenRouterKey();
168
- if (key) {
169
- for (const model of readingModels()) {
238
+ const messages = [
239
+ { role: 'system', content: READING_INSTRUCTIONS },
240
+ { role: 'user', content: `Question: ${input.question}\nPage: ${url.href}\n\n<page>\n${page}\n</page>` },
241
+ ];
242
+ const service = researchService();
243
+ if (service === 'zai') {
244
+ // On the plan: GLM-5.3-Flash reads the page, included in the plan.
245
+ const key = getZaiKey();
246
+ if (key) {
170
247
  try {
171
- const reply = await openrouterChat(key, {
172
- model,
173
- messages: [
174
- { role: 'system', content: READING_INSTRUCTIONS },
175
- { role: 'user', content: `Question: ${input.question}\nPage: ${url.href}\n\n<page>\n${page}\n</page>` },
176
- ],
177
- max_tokens: 1500,
178
- reasoning: { effort: 'low' },
179
- });
180
- if (reply.text)
181
- return `${reply.text}\n\n${sourceNote}`;
248
+ const text = await zaiRead(key, messages);
249
+ if (text)
250
+ return `${text}\n\n${sourceNote}`;
182
251
  }
183
- catch (error) {
184
- if (isAccountProblem(error))
185
- throw error;
252
+ catch {
253
+ // Falls through to the page text below.
254
+ }
255
+ }
256
+ }
257
+ else if (service === 'borrowed') {
258
+ // Reading uses the service in use (a direct company); others get the page text.
259
+ if (isDirectService(session.providerId)) {
260
+ try {
261
+ const model = hasAuto(session.providerId) ? workerModel() : workingModelId(session.model);
262
+ const text = await expertChat(model, messages, 1500);
263
+ if (text)
264
+ return `${text}\n\n${sourceNote}`;
265
+ }
266
+ catch {
267
+ // Falls through to the page text below.
268
+ }
269
+ }
270
+ }
271
+ else {
272
+ const key = getOpenRouterKey();
273
+ if (key) {
274
+ for (const model of readingModels()) {
275
+ try {
276
+ const reply = await openrouterChat(key, { model, messages, max_tokens: 1500, reasoning: { effort: 'low' } });
277
+ if (reply.text)
278
+ return `${reply.text}\n\n${sourceNote}`;
279
+ }
280
+ catch (error) {
281
+ if (isAccountProblem(error))
282
+ throw error;
283
+ }
186
284
  }
187
285
  }
188
286
  }