@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.
Files changed (47) hide show
  1. package/LICENSE +37 -17
  2. package/README.md +5 -2
  3. package/dist/agent/errors.js +1 -1
  4. package/dist/agent/loop.js +40 -1
  5. package/dist/agent/permissions.js +19 -3
  6. package/dist/agent/systemPrompt.js +19 -5
  7. package/dist/agent/trust.js +29 -0
  8. package/dist/app.js +31 -4
  9. package/dist/checkpoints/index.js +34 -4
  10. package/dist/commands/clear.js +2 -0
  11. package/dist/commands/help.js +16 -13
  12. package/dist/commands/keys.js +2 -1
  13. package/dist/components/AddressPrompt.js +21 -6
  14. package/dist/components/Footer.js +6 -1
  15. package/dist/components/Header.js +5 -1
  16. package/dist/components/HelpView.js +1 -1
  17. package/dist/components/Input.js +181 -24
  18. package/dist/components/KeysManager.js +33 -18
  19. package/dist/components/ModelPicker.js +27 -8
  20. package/dist/components/OpenRouterConnect.js +114 -0
  21. package/dist/components/ProjectPicker.js +55 -16
  22. package/dist/components/Transcript.js +40 -3
  23. package/dist/components/input-layout.js +161 -0
  24. package/dist/components/markdown.js +185 -0
  25. package/dist/components/transcript-layout.js +64 -4
  26. package/dist/index.js +2 -1
  27. package/dist/ink/mouse.js +39 -6
  28. package/dist/ink/quit.js +21 -0
  29. package/dist/ink/selection.js +128 -0
  30. package/dist/keys/store.js +72 -2
  31. package/dist/platform/address.js +16 -0
  32. package/dist/platform/chat-folder.js +24 -0
  33. package/dist/platform/config.js +11 -0
  34. package/dist/platform/wording.js +10 -0
  35. package/dist/providers/direct.js +8 -2
  36. package/dist/providers/index.js +21 -3
  37. package/dist/providers/ollama.js +8 -2
  38. package/dist/providers/openrouter-signin.js +102 -0
  39. package/dist/providers/openrouter.js +17 -2
  40. package/dist/providers/silence.js +39 -0
  41. package/dist/providers/zai.js +17 -13
  42. package/dist/state/session.js +61 -2
  43. package/dist/tools/index.js +27 -6
  44. package/dist/tools/runBash.js +17 -5
  45. package/dist/tools/web/research.js +119 -21
  46. package/dist/tools/web/zai-search.js +79 -0
  47. 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
- // 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
  }
@@ -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.0",
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": "MIT",
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",