@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.
- package/dist/agent/errors.js +1 -1
- package/dist/agent/loop.js +34 -1
- package/dist/agent/systemPrompt.js +19 -5
- package/dist/app.js +23 -1
- package/dist/checkpoints/index.js +34 -4
- package/dist/commands/clear.js +2 -0
- package/dist/commands/help.js +16 -14
- package/dist/commands/keys.js +2 -1
- package/dist/components/AddressPrompt.js +21 -6
- package/dist/components/Footer.js +6 -1
- package/dist/components/Header.js +5 -1
- package/dist/components/HelpView.js +1 -1
- package/dist/components/Input.js +135 -13
- package/dist/components/KeysManager.js +33 -18
- package/dist/components/ModelPicker.js +27 -8
- package/dist/components/OpenRouterConnect.js +114 -0
- package/dist/components/ProjectPicker.js +55 -16
- package/dist/components/Transcript.js +39 -2
- package/dist/components/input-layout.js +129 -26
- package/dist/components/markdown.js +185 -0
- package/dist/components/transcript-layout.js +50 -2
- package/dist/index.js +2 -1
- package/dist/ink/mouse.js +39 -6
- package/dist/ink/quit.js +21 -0
- package/dist/ink/selection.js +128 -0
- package/dist/keys/store.js +72 -2
- package/dist/platform/address.js +16 -0
- package/dist/platform/chat-folder.js +24 -0
- package/dist/platform/config.js +3 -0
- package/dist/platform/wording.js +10 -0
- package/dist/providers/direct.js +8 -2
- package/dist/providers/index.js +21 -3
- package/dist/providers/ollama.js +8 -2
- package/dist/providers/openrouter-signin.js +102 -0
- package/dist/providers/openrouter.js +17 -2
- package/dist/providers/silence.js +39 -0
- package/dist/providers/zai.js +17 -13
- package/dist/state/session.js +41 -2
- package/dist/tools/index.js +22 -5
- package/dist/tools/runBash.js +17 -5
- package/dist/tools/web/research.js +119 -21
- package/dist/tools/web/zai-search.js +79 -0
- 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
|
-
|
|
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
|
+
}
|
package/dist/providers/zai.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { streamText, stepCountIs } from 'ai';
|
|
2
|
-
import {
|
|
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.
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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 =
|
|
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
|
-
|
|
75
|
-
|
|
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.
|
|
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) {
|
package/dist/state/session.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/tools/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
161
|
-
label: (input) => `Searched ${clip(input.query, 50)}${
|
|
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)}${
|
|
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({
|
package/dist/tools/runBash.js
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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:
|
|
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 ${
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
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
|
}
|