@tianmucreations/jeeves 0.3.0 → 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/LICENSE +37 -17
- package/README.md +5 -2
- package/dist/agent/errors.js +1 -1
- package/dist/agent/loop.js +40 -1
- package/dist/agent/permissions.js +19 -3
- package/dist/agent/systemPrompt.js +19 -5
- package/dist/agent/trust.js +29 -0
- package/dist/app.js +31 -4
- package/dist/checkpoints/index.js +34 -4
- package/dist/commands/clear.js +2 -0
- package/dist/commands/help.js +16 -13
- 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 +181 -24
- 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 +40 -3
- package/dist/components/input-layout.js +161 -0
- package/dist/components/markdown.js +185 -0
- package/dist/components/transcript-layout.js +64 -4
- 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 +11 -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 +61 -2
- package/dist/tools/index.js +27 -6
- 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 +3 -2
|
@@ -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
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ZAI_CODING_BASE_URL } from '../../providers/zai.js';
|
|
2
|
+
// Web search on the GLM Coding Plan: Z.ai's Web Search MCP server, included in every
|
|
3
|
+
// plan (docs.z.ai/devpack/mcp/search-mcp-server). Spoken to directly over MCP's HTTP
|
|
4
|
+
// transport: initialize, then call the one tool it lists, "web_search_prime".
|
|
5
|
+
// Measured 18 Sept 2026 with a real plan key: results come back as a JSON string of
|
|
6
|
+
// [{title, link, content, refer}], where link is often only the site's front page and
|
|
7
|
+
// content a ~150-character summary - weaker than OpenRouter's search, so Jeeves is told
|
|
8
|
+
// to say plainly when a fact can't be confirmed from them.
|
|
9
|
+
export const ZAI_SEARCH_URL = 'https://api.z.ai/api/mcp/web_search_prime/mcp';
|
|
10
|
+
async function rpc(key, body, session, signal) {
|
|
11
|
+
const response = await fetch(ZAI_SEARCH_URL, {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: {
|
|
14
|
+
Authorization: `Bearer ${key}`,
|
|
15
|
+
'Content-Type': 'application/json',
|
|
16
|
+
Accept: 'application/json, text/event-stream',
|
|
17
|
+
...(session ? { 'mcp-session-id': session } : {}),
|
|
18
|
+
},
|
|
19
|
+
body: JSON.stringify(body),
|
|
20
|
+
signal,
|
|
21
|
+
});
|
|
22
|
+
const text = await response.text();
|
|
23
|
+
if (!response.ok)
|
|
24
|
+
throw new Error(`Z.ai web search returned ${response.status}: ${text.slice(0, 200)}`);
|
|
25
|
+
// Replies arrive as server-sent events ("data: {...}") or as plain JSON.
|
|
26
|
+
const line = text.split('\n').find((entry) => entry.startsWith('data:'));
|
|
27
|
+
const parsed = line ? JSON.parse(line.slice(5)) : text ? JSON.parse(text) : {};
|
|
28
|
+
return { session: response.headers.get('mcp-session-id') ?? session, body: parsed };
|
|
29
|
+
}
|
|
30
|
+
// The tool's text is a JSON string of the results (sometimes JSON-encoded twice).
|
|
31
|
+
export function parseZaiResults(text) {
|
|
32
|
+
let value = text;
|
|
33
|
+
for (let i = 0; i < 2 && typeof value === 'string'; i++) {
|
|
34
|
+
try {
|
|
35
|
+
value = JSON.parse(value);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (!Array.isArray(value))
|
|
42
|
+
return [];
|
|
43
|
+
return value
|
|
44
|
+
.map((item) => item)
|
|
45
|
+
.filter((item) => typeof item.link === 'string' && item.link)
|
|
46
|
+
.map((item) => ({ url: String(item.link), title: String(item.title ?? ''), content: String(item.content ?? '') }));
|
|
47
|
+
}
|
|
48
|
+
export async function zaiSearch(key, query, site, timeoutMs = 45_000) {
|
|
49
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
50
|
+
const init = await rpc(key, { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'jeeves', version: '1' } } }, undefined, signal);
|
|
51
|
+
// The server expects to be told initialisation is finished before tool calls.
|
|
52
|
+
await rpc(key, { jsonrpc: '2.0', method: 'notifications/initialized' }, init.session, signal).catch(() => undefined);
|
|
53
|
+
const call = await rpc(key, {
|
|
54
|
+
jsonrpc: '2.0',
|
|
55
|
+
id: 2,
|
|
56
|
+
method: 'tools/call',
|
|
57
|
+
params: { name: 'web_search_prime', arguments: { search_query: query.slice(0, 70), location: 'us', ...(site ? { search_domain_filter: site } : {}) } },
|
|
58
|
+
}, init.session, signal);
|
|
59
|
+
if (call.body.error || call.body.result?.isError) {
|
|
60
|
+
throw new Error(`Z.ai web search: ${call.body.error?.message ?? call.body.result?.content?.[0]?.text ?? 'failed'}`);
|
|
61
|
+
}
|
|
62
|
+
return parseZaiResults(call.body.result?.content?.find((part) => part.type === 'text')?.text ?? '');
|
|
63
|
+
}
|
|
64
|
+
// One reading request on the plan: GLM-5.3-Flash on the coding endpoint, thinking off
|
|
65
|
+
// (reading only quotes the page). Returns the reply text.
|
|
66
|
+
export const ZAI_READING_MODEL = 'glm-5.3-flash';
|
|
67
|
+
export async function zaiRead(key, messages, timeoutMs = 60_000) {
|
|
68
|
+
const response = await fetch(`${ZAI_CODING_BASE_URL}/chat/completions`, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
71
|
+
body: JSON.stringify({ model: ZAI_READING_MODEL, messages, max_tokens: 1500, thinking: { type: 'disabled' } }),
|
|
72
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
73
|
+
});
|
|
74
|
+
const text = await response.text();
|
|
75
|
+
if (!response.ok)
|
|
76
|
+
throw new Error(`Z.ai returned ${response.status}: ${text.slice(0, 200)}`);
|
|
77
|
+
const body = JSON.parse(text);
|
|
78
|
+
return (body.choices?.[0]?.message?.content ?? '').trim();
|
|
79
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tianmucreations/jeeves",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Your personal assistant in the terminal: say what you need in plain English and Jeeves does the work carefully - asks first, can undo, watches your spending. Auto mode picks the right AI model for you.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"bugs": {
|
|
34
34
|
"url": "https://github.com/tianmucreations/Jeeves/issues"
|
|
35
35
|
},
|
|
36
|
-
"license": "
|
|
36
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
37
37
|
"files": [
|
|
38
38
|
"bin/",
|
|
39
39
|
"dist/",
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
"ink": "^7.1.1",
|
|
72
72
|
"ink-spinner": "^5.0.0",
|
|
73
73
|
"keytar": "^7.9.0",
|
|
74
|
+
"marked": "^18.0.13",
|
|
74
75
|
"node-notifier": "^10.0.1",
|
|
75
76
|
"react": "^19.3.0",
|
|
76
77
|
"signal-exit": "^3.0.7",
|