@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
package/dist/ink/mouse.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { session } from '../state/session.js';
|
|
2
|
+
import { pointAt, copySelection } from './selection.js';
|
|
2
3
|
// SGR mouse tracking (modes 1000 + 1002 + 1006), enabled for the whole session by
|
|
3
4
|
// the AlternateScreen takeover and disabled on every exit path. Claude Code's
|
|
4
5
|
// approach: in the alternate screen the terminal has no scrollback, so wheel
|
|
5
6
|
// events are captured and translated into transcript scrolling. The trade-off is
|
|
6
|
-
//
|
|
7
|
-
//
|
|
7
|
+
// the terminal's own click-drag selection, so selection is done here instead
|
|
8
|
+
// (selection.ts): drag to select, copied on release. Fn in Terminal.app still
|
|
9
|
+
// bypasses capture for the terminal's own selection.
|
|
8
10
|
export const ENABLE_MOUSE_TRACKING = '\x1b[?1000h\x1b[?1002h\x1b[?1006h';
|
|
9
11
|
export const DISABLE_MOUSE_TRACKING = '\x1b[?1006l\x1b[?1002l\x1b[?1000l';
|
|
10
12
|
// Ink's input parser hands over complete CSI sequences but strips the leading ESC
|
|
@@ -35,14 +37,45 @@ export function parseMouseSequence(input) {
|
|
|
35
37
|
if (rawButton <= 6) {
|
|
36
38
|
return { kind: 'press', button: rawButton, col, row };
|
|
37
39
|
}
|
|
40
|
+
// Mode 1002 reports movement with a button held as the button code plus 32.
|
|
41
|
+
if (rawButton >= 32 && rawButton <= 34) {
|
|
42
|
+
return { kind: 'drag', button: rawButton - 32, col, row };
|
|
43
|
+
}
|
|
38
44
|
return null;
|
|
39
45
|
}
|
|
40
46
|
// Wheel up scrolls the transcript up 3 rows, wheel down 3 rows back; the session
|
|
41
|
-
// clamps at the newest (0) and the Transcript clamps at the oldest.
|
|
42
|
-
//
|
|
47
|
+
// clamps at the newest (0) and the Transcript clamps at the oldest. A left-button
|
|
48
|
+
// press in the conversation starts a selection, dragging extends it, and releasing
|
|
49
|
+
// copies it. Nothing here ever reaches the text being typed.
|
|
43
50
|
export function handleMouseInput(input) {
|
|
44
51
|
const event = parseMouseSequence(input);
|
|
45
|
-
if (event === null
|
|
52
|
+
if (event === null)
|
|
53
|
+
return;
|
|
54
|
+
if (event.kind === 'wheel') {
|
|
55
|
+
session.scrollTranscript(event.button === 0 ? 3 : -3);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (event.button !== 0)
|
|
46
59
|
return;
|
|
47
|
-
|
|
60
|
+
if (event.kind === 'press') {
|
|
61
|
+
const point = pointAt(event.col, event.row);
|
|
62
|
+
session.setSelection(point ? { anchor: point, focus: point } : null);
|
|
63
|
+
// Outside the conversation: a click in the typing box places the cursor.
|
|
64
|
+
if (!point)
|
|
65
|
+
session.inputClick?.(event.col, event.row);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const current = session.selection;
|
|
69
|
+
if (!current)
|
|
70
|
+
return;
|
|
71
|
+
const point = pointAt(event.col, event.row, true);
|
|
72
|
+
if (point)
|
|
73
|
+
session.setSelection({ anchor: current.anchor, focus: point });
|
|
74
|
+
if (event.kind === 'release') {
|
|
75
|
+
const moved = point && (point.line !== current.anchor.line || point.ch !== current.anchor.ch);
|
|
76
|
+
if (moved)
|
|
77
|
+
void copySelection();
|
|
78
|
+
else
|
|
79
|
+
session.setSelection(null);
|
|
80
|
+
}
|
|
48
81
|
}
|
package/dist/ink/quit.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { session } from '../state/session.js';
|
|
2
|
+
// Quitting takes Ctrl+C twice within two seconds (Claude Code's rule), so a stray
|
|
3
|
+
// Ctrl+C - the copy key on Windows and Linux - never closes Jeeves and loses the
|
|
4
|
+
// conversation. The first press says so in the info bar.
|
|
5
|
+
const WINDOW_MS = 2000;
|
|
6
|
+
let firstPress = 0;
|
|
7
|
+
let noteTimer = null;
|
|
8
|
+
export function pressCtrlCToQuit(now = Date.now()) {
|
|
9
|
+
if (now - firstPress <= WINDOW_MS) {
|
|
10
|
+
session.requestExit();
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
firstPress = now;
|
|
14
|
+
session.setBusyNote('press Ctrl+C again to quit');
|
|
15
|
+
if (noteTimer)
|
|
16
|
+
clearTimeout(noteTimer);
|
|
17
|
+
noteTimer = setTimeout(() => {
|
|
18
|
+
if (session.busyNote === 'press Ctrl+C again to quit')
|
|
19
|
+
session.setBusyNote(null);
|
|
20
|
+
}, WINDOW_MS);
|
|
21
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { session } from '../state/session.js';
|
|
3
|
+
// A screen position (1-based column and row, as the mouse reports them) as a line
|
|
4
|
+
// and character of the drawn conversation. Outside the conversation area: null,
|
|
5
|
+
// unless clamp is set, when it is pinned to the nearest edge (for dragging past it).
|
|
6
|
+
export function pointAt(col, row, clamp = false) {
|
|
7
|
+
const view = session.transcriptView;
|
|
8
|
+
if (!view)
|
|
9
|
+
return null;
|
|
10
|
+
let r = row - view.top;
|
|
11
|
+
if (r < 0 || r >= view.height) {
|
|
12
|
+
if (!clamp)
|
|
13
|
+
return null;
|
|
14
|
+
r = Math.max(0, Math.min(view.height - 1, r));
|
|
15
|
+
}
|
|
16
|
+
// The newest line sits on the bottom row; scrolling up moves older lines in.
|
|
17
|
+
const line = view.lines.length - view.scrollTop - view.height + r;
|
|
18
|
+
if (line < 0 || line >= view.lines.length) {
|
|
19
|
+
if (!clamp)
|
|
20
|
+
return null;
|
|
21
|
+
return { line: Math.max(0, Math.min(view.lines.length - 1, line)), ch: line < 0 ? 0 : Number.MAX_SAFE_INTEGER };
|
|
22
|
+
}
|
|
23
|
+
return { line, ch: Math.max(0, col - view.left) };
|
|
24
|
+
}
|
|
25
|
+
// Start and end in reading order.
|
|
26
|
+
export function ordered(a, b) {
|
|
27
|
+
return a.line < b.line || (a.line === b.line && a.ch <= b.ch) ? [a, b] : [b, a];
|
|
28
|
+
}
|
|
29
|
+
// The selected characters of one drawn line, as [from, to), or null.
|
|
30
|
+
export function selectedRange(lineIndex, length) {
|
|
31
|
+
const sel = session.selection;
|
|
32
|
+
if (!sel)
|
|
33
|
+
return null;
|
|
34
|
+
const [start, end] = ordered(sel.anchor, sel.focus);
|
|
35
|
+
if (lineIndex < start.line || lineIndex > end.line)
|
|
36
|
+
return null;
|
|
37
|
+
const from = lineIndex === start.line ? Math.min(start.ch, length) : 0;
|
|
38
|
+
const to = lineIndex === end.line ? Math.min(end.ch + 1, length) : length;
|
|
39
|
+
return to > from ? [from, to] : null;
|
|
40
|
+
}
|
|
41
|
+
// The selected text: each line's selected part without the padding the display adds.
|
|
42
|
+
export function selectedText(lines) {
|
|
43
|
+
const sel = session.selection;
|
|
44
|
+
if (!sel)
|
|
45
|
+
return '';
|
|
46
|
+
const [start, end] = ordered(sel.anchor, sel.focus);
|
|
47
|
+
const out = [];
|
|
48
|
+
for (let i = start.line; i <= end.line && i < lines.length; i++) {
|
|
49
|
+
const range = selectedRange(i, lines[i].length);
|
|
50
|
+
out.push(range ? lines[i].slice(range[0], range[1]).replace(/\s+$/, '') : '');
|
|
51
|
+
}
|
|
52
|
+
return out.join('\n').replace(/^\n+|\n+$/g, '');
|
|
53
|
+
}
|
|
54
|
+
// As Claude Code (ink/termio/osc.ts setClipboard): the terminal's clipboard escape
|
|
55
|
+
// (OSC 52) always, and - on this computer, not over SSH - its own clipboard
|
|
56
|
+
// program too, because OSC 52 depends on terminal settings (several Linux terminals
|
|
57
|
+
// ignore it): pbcopy on a Mac, clip on Windows, and on Linux the first of wl-copy
|
|
58
|
+
// (Wayland), xclip or xsel (X11) that exists, remembered after the first copy.
|
|
59
|
+
let linuxCopy;
|
|
60
|
+
function runCopy(program, args, text) {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
try {
|
|
63
|
+
const child = spawn(program, args, { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
64
|
+
const timer = setTimeout(() => child.kill(), 2000);
|
|
65
|
+
child.on('error', () => {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
resolve(false);
|
|
68
|
+
});
|
|
69
|
+
child.on('close', (code) => {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
resolve(code === 0);
|
|
72
|
+
});
|
|
73
|
+
child.stdin.on('error', () => { });
|
|
74
|
+
child.stdin.end(text);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
resolve(false);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
export async function copyNative(text) {
|
|
82
|
+
if (process.platform === 'darwin')
|
|
83
|
+
return runCopy('pbcopy', [], text);
|
|
84
|
+
if (process.platform === 'win32')
|
|
85
|
+
return runCopy('clip', [], text);
|
|
86
|
+
if (linuxCopy === null)
|
|
87
|
+
return false;
|
|
88
|
+
if (linuxCopy)
|
|
89
|
+
return runCopy(linuxCopy[0], linuxCopy[1], text);
|
|
90
|
+
const candidates = [['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]];
|
|
91
|
+
for (const candidate of candidates) {
|
|
92
|
+
if (await runCopy(candidate[0], candidate[1], text)) {
|
|
93
|
+
linuxCopy = candidate;
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
linuxCopy = null;
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
export async function copyToClipboard(text) {
|
|
101
|
+
let sent = false;
|
|
102
|
+
try {
|
|
103
|
+
process.stdout.write(`\x1b]52;c;${Buffer.from(text, 'utf8').toString('base64')}\x07`);
|
|
104
|
+
sent = true;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// A closed stream: the clipboard program below may still work.
|
|
108
|
+
}
|
|
109
|
+
if (process.env.SSH_CONNECTION)
|
|
110
|
+
return sent;
|
|
111
|
+
return (await copyNative(text)) || sent;
|
|
112
|
+
}
|
|
113
|
+
let noteTimer = null;
|
|
114
|
+
// Copies the selection and says so in the info bar for two seconds.
|
|
115
|
+
export async function copySelection() {
|
|
116
|
+
const view = session.transcriptView;
|
|
117
|
+
const text = view ? selectedText(view.lines) : '';
|
|
118
|
+
if (!text)
|
|
119
|
+
return;
|
|
120
|
+
const copied = await copyToClipboard(text);
|
|
121
|
+
session.setBusyNote(copied ? 'copied' : "couldn't copy");
|
|
122
|
+
if (noteTimer)
|
|
123
|
+
clearTimeout(noteTimer);
|
|
124
|
+
noteTimer = setTimeout(() => {
|
|
125
|
+
if (session.busyNote === 'copied' || session.busyNote === "couldn't copy")
|
|
126
|
+
session.setBusyNote(null);
|
|
127
|
+
}, 2000);
|
|
128
|
+
}
|
package/dist/keys/store.js
CHANGED
|
@@ -1,13 +1,83 @@
|
|
|
1
|
+
// API keys live in the operating system's own credential store (the macOS Keychain),
|
|
2
|
+
// never in plain files. The library loads lazily so a missing native module degrades
|
|
3
|
+
// gracefully instead of crashing the app.
|
|
4
|
+
//
|
|
5
|
+
// On a Mac, Jeeves uses Apple's own `security` program, as Claude Code does
|
|
6
|
+
// (src/utils/secureStorage/macOsKeychainStorage.ts): no add-on to break. keytar is
|
|
7
|
+
// archived (unmaintained since Dec 2022) and inside the desktop app it crashed on
|
|
8
|
+
// quit whenever a keychain read was still running - 10 of 10 times (19 Sept). The
|
|
9
|
+
// Keychain items are the same ones keytar made (service "jeeves", account = the
|
|
10
|
+
// service id), so nobody re-enters a key: checked 19 Sept - an item keytar created
|
|
11
|
+
// was updated, read, listed and deleted by `security` with no permission box.
|
|
12
|
+
// Windows and Linux stay on keytar until the same check is done there.
|
|
13
|
+
import { execFile } from 'node:child_process';
|
|
1
14
|
// JEEVES_KEYCHAIN_SERVICE lets a test run use its own keychain entries, never the real ones.
|
|
2
15
|
const SERVICE = process.env.JEEVES_KEYCHAIN_SERVICE || 'jeeves';
|
|
3
16
|
let cached = null;
|
|
17
|
+
function runSecurity(args, input) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const child = execFile('/usr/bin/security', args, { timeout: 10_000, maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => {
|
|
20
|
+
const code = error ? (typeof error.code === 'number' ? error.code : 1) : 0;
|
|
21
|
+
resolve({ code, stdout: String(stdout) });
|
|
22
|
+
});
|
|
23
|
+
if (input !== undefined)
|
|
24
|
+
child.stdin?.end(input);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
// Quotes a value for one line of `security -i`: the ids are plain words, but never trust that.
|
|
28
|
+
const quoted = (value) => `"${value.replace(/["\\]/g, '')}"`;
|
|
29
|
+
const macKeychain = {
|
|
30
|
+
async setPassword(service, account, password) {
|
|
31
|
+
// The key goes in as hex on standard input, so it never appears in the list of
|
|
32
|
+
// running programs (Claude Code's reason: process monitors see only "security -i").
|
|
33
|
+
const hex = Buffer.from(password, 'utf8').toString('hex');
|
|
34
|
+
const { code } = await runSecurity(['-i'], `add-generic-password -U -s ${quoted(service)} -a ${quoted(account)} -X "${hex}"\n`);
|
|
35
|
+
if (code !== 0)
|
|
36
|
+
throw new Error('The Mac keychain did not save the key.');
|
|
37
|
+
},
|
|
38
|
+
async getPassword(service, account) {
|
|
39
|
+
// Exit 44 is "no such item" - a real "no key". Any other failure (a timeout, a
|
|
40
|
+
// busy keychain) is asked once more rather than taken as "no key" (Claude Code
|
|
41
|
+
// treats a timed-out read as "may have a key" for the same reason).
|
|
42
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
43
|
+
const { code, stdout } = await runSecurity(['find-generic-password', '-s', service, '-a', account, '-w']);
|
|
44
|
+
if (code === 0)
|
|
45
|
+
return stdout.replace(/\n$/, '') || null;
|
|
46
|
+
if (code === 44)
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
},
|
|
51
|
+
async deletePassword(service, account) {
|
|
52
|
+
const { code } = await runSecurity(['delete-generic-password', '-s', service, '-a', account]);
|
|
53
|
+
return code === 0;
|
|
54
|
+
},
|
|
55
|
+
// Names only: dump-keychain without -d lists what is saved, never the keys themselves.
|
|
56
|
+
async findCredentials(service) {
|
|
57
|
+
const { stdout } = await runSecurity(['dump-keychain']);
|
|
58
|
+
const accounts = [];
|
|
59
|
+
for (const block of stdout.split('keychain: ')) {
|
|
60
|
+
if (!block.includes(`"svce"<blob>="${service}"`))
|
|
61
|
+
continue;
|
|
62
|
+
const account = /"acct"<blob>="([^"]*)"/.exec(block)?.[1];
|
|
63
|
+
if (account)
|
|
64
|
+
accounts.push({ account, password: '' });
|
|
65
|
+
}
|
|
66
|
+
return accounts;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
4
69
|
async function load() {
|
|
5
70
|
// Escape hatch for automated tests so they never touch the real credential store.
|
|
6
71
|
if (process.env.JEEVES_SKIP_KEYCHAIN === '1')
|
|
7
72
|
return null;
|
|
8
73
|
if (cached === null) {
|
|
9
|
-
|
|
10
|
-
|
|
74
|
+
if (process.platform === 'darwin') {
|
|
75
|
+
cached = macKeychain;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const mod = await import('keytar');
|
|
79
|
+
cached = (mod.default ?? mod);
|
|
80
|
+
}
|
|
11
81
|
}
|
|
12
82
|
return cached;
|
|
13
83
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// How Jeeves addresses the person. Asked before anything else, in the terminal and
|
|
2
|
+
// the window alike, so Jeeves never has to guess "Sir" (owner's request, 19 Sept).
|
|
3
|
+
export const ADDRESS_MAX = 30;
|
|
4
|
+
// Morning / afternoon / evening by this computer's own clock.
|
|
5
|
+
export function partOfDay(now = new Date()) {
|
|
6
|
+
const hour = now.getHours();
|
|
7
|
+
return hour < 12 ? 'morning' : hour < 18 ? 'afternoon' : 'evening';
|
|
8
|
+
}
|
|
9
|
+
export function timeOfDayGreeting(now = new Date()) {
|
|
10
|
+
return `Good ${partOfDay(now)}`;
|
|
11
|
+
}
|
|
12
|
+
// The address as saved: trimmed and short; null when nothing usable was given.
|
|
13
|
+
export function cleanAddress(raw) {
|
|
14
|
+
const value = raw.replace(/\s+/g, ' ').trim().slice(0, ADDRESS_MAX);
|
|
15
|
+
return value ? value : null;
|
|
16
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
// "Just chat": no project needed. Jeeves still works in a folder of its own, so
|
|
5
|
+
// anything it is asked to save lands somewhere the person can find, /undo still
|
|
6
|
+
// works, and nothing ever runs loose in the whole home folder (agreed 19 Sept).
|
|
7
|
+
export const CHAT_FOLDER_NAME = 'Jeeves Chats';
|
|
8
|
+
export function chatFolder() {
|
|
9
|
+
return path.join(os.homedir(), 'Documents', CHAT_FOLDER_NAME);
|
|
10
|
+
}
|
|
11
|
+
// Creates the folder when needed; the location, or null if it could not be made.
|
|
12
|
+
export function ensureChatFolder() {
|
|
13
|
+
const folder = chatFolder();
|
|
14
|
+
try {
|
|
15
|
+
mkdirSync(folder, { recursive: true });
|
|
16
|
+
return folder;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function chatNotice(address) {
|
|
23
|
+
return `Just chatting, ${address} - anything I save for you goes in Documents/${CHAT_FOLDER_NAME}. Type /folder any time to work in a folder instead.`;
|
|
24
|
+
}
|
package/dist/platform/config.js
CHANGED
|
@@ -88,6 +88,9 @@ export function getAddress() {
|
|
|
88
88
|
export function setAddress(address) {
|
|
89
89
|
config.set('address', address);
|
|
90
90
|
}
|
|
91
|
+
export function clearAddress() {
|
|
92
|
+
config.delete('address');
|
|
93
|
+
}
|
|
91
94
|
// The trimmed models.dev catalogue for direct connections (prices and abilities).
|
|
92
95
|
export function getDirectCatalogue() {
|
|
93
96
|
return config.get('directCatalogue') ?? null;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Words that depend on the computer Jeeves is running on (owner, 19 Sept: "What
|
|
2
|
+
// if it is on a non-Mac?"). Keys are kept by keytar in Windows Credential Manager
|
|
3
|
+
// and in the system password store on Linux; the copy keys are the terminal's own
|
|
4
|
+
// (Windows Terminal's defaults include Ctrl+Shift+C - learn.microsoft.com, Windows
|
|
5
|
+
// Terminal actions; Linux terminals use the same).
|
|
6
|
+
const platform = process.platform;
|
|
7
|
+
export const KEY_STORE = platform === 'darwin' ? 'your Mac keychain' : platform === 'win32' ? 'Windows Credential Manager' : "your computer's password store";
|
|
8
|
+
export const KEY_STORE_SUBJECT = platform === 'darwin' ? 'The Mac keychain' : platform === 'win32' ? 'Windows Credential Manager' : "Your computer's password store";
|
|
9
|
+
export const COPY_KEYS = platform === 'darwin' ? 'Cmd+C' : 'Ctrl+Shift+C';
|
|
10
|
+
export const WORD_JUMP_KEYS = platform === 'darwin' ? 'Option+← →' : 'Ctrl+← →';
|
package/dist/providers/direct.js
CHANGED
|
@@ -6,6 +6,7 @@ import { createXai } from '@ai-sdk/xai';
|
|
|
6
6
|
import { createMistral } from '@ai-sdk/mistral';
|
|
7
7
|
import { createGroq } from '@ai-sdk/groq';
|
|
8
8
|
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
|
9
|
+
import { silenceGuard } from './silence.js';
|
|
9
10
|
import { prepareStepFor } from './step-control.js';
|
|
10
11
|
import { directService, serviceNameFor, CUSTOM_SERVICE_ID } from './direct-services.js';
|
|
11
12
|
import { estimateCost, priceOf } from './catalogue.js';
|
|
@@ -68,10 +69,13 @@ function streamWith(id, name, modelFor, costOf, caching) {
|
|
|
68
69
|
const stepCostOf = (step) => costOf(step.model?.modelId ?? modelId, step.usage ?? {});
|
|
69
70
|
const prepare = prepareStepFor(beforeStep, modelFor, stepCostOf);
|
|
70
71
|
const system = caching && instructions ? { role: 'system', content: instructions, providerOptions: ANTHROPIC_CACHE } : instructions;
|
|
72
|
+
const guard = silenceGuard(abortSignal);
|
|
71
73
|
const result = streamText({
|
|
72
74
|
instructions: system,
|
|
73
75
|
// The same limits on silence as the other services (see openrouter.ts).
|
|
74
|
-
|
|
76
|
+
// Silence while the model answers is watched by silenceGuard (it pauses while a
|
|
77
|
+
// command runs or waits for the person); the first piece still has 2 minutes.
|
|
78
|
+
timeout: { firstChunkMs: 120_000 },
|
|
75
79
|
model: modelFor(modelId),
|
|
76
80
|
messages: caching ? markForCaching(messages) : messages,
|
|
77
81
|
tools,
|
|
@@ -85,13 +89,14 @@ function streamWith(id, name, modelFor, costOf, caching) {
|
|
|
85
89
|
return { ...control, messages: markForCaching(control.messages ?? step.messages) };
|
|
86
90
|
}
|
|
87
91
|
: undefined,
|
|
88
|
-
abortSignal,
|
|
92
|
+
abortSignal: guard.signal,
|
|
89
93
|
// The library prints every failure to the screen by default, over Jeeves's
|
|
90
94
|
// window; the failure still arrives below and is explained in plain English.
|
|
91
95
|
onError: () => { },
|
|
92
96
|
});
|
|
93
97
|
let streamedError = null;
|
|
94
98
|
for await (const part of result.stream) {
|
|
99
|
+
guard.onPart(part);
|
|
95
100
|
if (part.type === 'text-delta') {
|
|
96
101
|
onToken(part.text);
|
|
97
102
|
}
|
|
@@ -105,6 +110,7 @@ function streamWith(id, name, modelFor, costOf, caching) {
|
|
|
105
110
|
streamedError = part.error;
|
|
106
111
|
}
|
|
107
112
|
}
|
|
113
|
+
guard.stop();
|
|
108
114
|
// The real stream error (a rejected key, a missing model) must win over the
|
|
109
115
|
// SDK's generic no-output error, which would otherwise mask the cause.
|
|
110
116
|
if (streamedError !== null) {
|
package/dist/providers/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { getKey, setKey, deleteKey, listProviders } from '../keys/store.js';
|
|
|
13
13
|
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { KEY_STORE, KEY_STORE_SUBJECT } from '../platform/wording.js';
|
|
16
17
|
let active = null;
|
|
17
18
|
let resolvedKey = null;
|
|
18
19
|
let zaiKey = null;
|
|
@@ -23,6 +24,10 @@ const serviceKeys = new Map();
|
|
|
23
24
|
export function getOpenRouterKey() {
|
|
24
25
|
return resolvedKey;
|
|
25
26
|
}
|
|
27
|
+
// The GLM Coding Plan key, for research on the plan (Z.ai's web search and reading).
|
|
28
|
+
export function getZaiKey() {
|
|
29
|
+
return zaiKey;
|
|
30
|
+
}
|
|
26
31
|
// The calm provider list shared by the model picker and the key screens.
|
|
27
32
|
export const PROVIDER_ROWS = [
|
|
28
33
|
{ id: 'openrouter', label: 'OpenRouter', description: 'one key unlocks 400+ models - recommended' },
|
|
@@ -107,7 +112,20 @@ export function hasCredentials() {
|
|
|
107
112
|
// Startup key resolution: the Keychain wins; a .env file is a development fallback
|
|
108
113
|
// that gets migrated into the Keychain on first launch. The first access may pop a
|
|
109
114
|
// macOS permission dialog - that is expected and allowed once.
|
|
110
|
-
|
|
115
|
+
// Reading the keychain takes a moment; anything that decides "is there a key?" waits
|
|
116
|
+
// for it first (Claude Code's keychainPrefetch pattern). Choosing a folder within
|
|
117
|
+
// about 0.8 s of opening used to decide before the read finished and asked for a key
|
|
118
|
+
// the person already had (measured 19 Sept).
|
|
119
|
+
let keysLoading = null;
|
|
120
|
+
export function initKeys() {
|
|
121
|
+
keysLoading ??= readKeys();
|
|
122
|
+
return keysLoading;
|
|
123
|
+
}
|
|
124
|
+
// Resolves once the saved keys have been read (starting the read if needed).
|
|
125
|
+
export function keysRead() {
|
|
126
|
+
return initKeys();
|
|
127
|
+
}
|
|
128
|
+
async function readKeys() {
|
|
111
129
|
// Read together, so startup is not held up by one keychain read after another.
|
|
112
130
|
const ids = [...DIRECT_SERVICES.map((service) => service.id), CUSTOM_SERVICE_ID];
|
|
113
131
|
const serviceStored = await Promise.all(ids.map((id) => getKey(id)));
|
|
@@ -131,10 +149,10 @@ export async function initKeys() {
|
|
|
131
149
|
const moved = await setKey('openrouter', envKey);
|
|
132
150
|
if (moved) {
|
|
133
151
|
removeEnvFile();
|
|
134
|
-
session.addNotice(
|
|
152
|
+
session.addNotice(`Your key was moved from a local file into ${KEY_STORE}, and the file was removed.`);
|
|
135
153
|
}
|
|
136
154
|
else {
|
|
137
|
-
session.addNotice(
|
|
155
|
+
session.addNotice(`${KEY_STORE_SUBJECT} was not reachable, so the key is being read from a local file for now. Type /keys to store it securely.`);
|
|
138
156
|
}
|
|
139
157
|
resolvedKey = envKey;
|
|
140
158
|
keySource = moved ? 'keychain' : 'env';
|
package/dist/providers/ollama.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { streamText, stepCountIs } from 'ai';
|
|
2
2
|
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
|
3
|
+
import { silenceGuard } from './silence.js';
|
|
3
4
|
import { prepareStepFor } from './step-control.js';
|
|
4
5
|
const OLLAMA_BASE_URL = 'http://localhost:11434/v1';
|
|
5
6
|
const MAX_TOOL_STEPS = 25;
|
|
@@ -14,6 +15,7 @@ export function createOllamaProvider() {
|
|
|
14
15
|
id: 'ollama',
|
|
15
16
|
name: 'Ollama',
|
|
16
17
|
async stream({ modelId, messages, tools, instructions, onToken, onReasoning, onToolCall, beforeStep, abortSignal }) {
|
|
18
|
+
const guard = silenceGuard(abortSignal);
|
|
17
19
|
const result = streamText({
|
|
18
20
|
instructions,
|
|
19
21
|
// A stalled request must never wedge the app in the working state forever -
|
|
@@ -23,19 +25,22 @@ export function createOllamaProvider() {
|
|
|
23
25
|
// started, and 10 minutes for any single step, which also covers a request that
|
|
24
26
|
// never starts answering. (A plain number here limits the entire multi-step
|
|
25
27
|
// job; a 3-minute one killed healthy jobs mid-way in testing.)
|
|
26
|
-
|
|
28
|
+
// Silence while the model answers is watched by silenceGuard (it pauses while a
|
|
29
|
+
// command runs or waits for the person); the first piece still has 2 minutes.
|
|
30
|
+
timeout: { firstChunkMs: 120_000 },
|
|
27
31
|
model: client.chat(modelId),
|
|
28
32
|
messages,
|
|
29
33
|
tools,
|
|
30
34
|
stopWhen: stepCountIs(MAX_TOOL_STEPS),
|
|
31
35
|
prepareStep: prepareStepFor(beforeStep, (id) => client.chat(id)),
|
|
32
|
-
abortSignal,
|
|
36
|
+
abortSignal: guard.signal,
|
|
33
37
|
// The library prints every failure to the screen by default, over Jeeves's
|
|
34
38
|
// window; the failure still arrives below and is explained in plain English.
|
|
35
39
|
onError: () => { },
|
|
36
40
|
});
|
|
37
41
|
let streamedError = null;
|
|
38
42
|
for await (const part of result.stream) {
|
|
43
|
+
guard.onPart(part);
|
|
39
44
|
if (part.type === 'text-delta') {
|
|
40
45
|
onToken(part.text);
|
|
41
46
|
}
|
|
@@ -49,6 +54,7 @@ export function createOllamaProvider() {
|
|
|
49
54
|
streamedError = part.error;
|
|
50
55
|
}
|
|
51
56
|
}
|
|
57
|
+
guard.stop();
|
|
52
58
|
// The real stream error (a rejected key, a missing model) must win over the
|
|
53
59
|
// SDK's generic no-output error, which would otherwise mask the cause.
|
|
54
60
|
if (streamedError !== null) {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
// "Sign in with OpenRouter": the person approves Jeeves in their browser and
|
|
5
|
+
// OpenRouter hands back a key on their own account - nothing to copy or paste, the
|
|
6
|
+
// step that stops most non-coders. OpenRouter's OAuth PKCE flow, as documented at
|
|
7
|
+
// openrouter.ai/docs/use-cases/oauth-pkce (checked 18 Sept 2026): open
|
|
8
|
+
// https://openrouter.ai/auth with callback_url, code_challenge (base64url of the
|
|
9
|
+
// SHA-256 of a random verifier) and code_challenge_method=S256; the browser returns
|
|
10
|
+
// to the callback with ?code=; POST {code, code_verifier, code_challenge_method} to
|
|
11
|
+
// /api/v1/auth/keys and read "key". Localhost callbacks on any port are allowed.
|
|
12
|
+
export const AUTH_URL = 'https://openrouter.ai/auth';
|
|
13
|
+
export const EXCHANGE_URL = 'https://openrouter.ai/api/v1/auth/keys';
|
|
14
|
+
export function pkcePair() {
|
|
15
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
16
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
17
|
+
return { verifier, challenge };
|
|
18
|
+
}
|
|
19
|
+
export function authorizeUrl(callbackUrl, challenge) {
|
|
20
|
+
const params = new URLSearchParams({ callback_url: callbackUrl, code_challenge: challenge, code_challenge_method: 'S256', key_label: 'Jeeves' });
|
|
21
|
+
return `${AUTH_URL}?${params.toString()}`;
|
|
22
|
+
}
|
|
23
|
+
// The program and arguments that open a web address on each system.
|
|
24
|
+
export function browserCommand(url, platform = process.platform) {
|
|
25
|
+
// Windows: rundll32, as Claude Code does (utils/browser.ts) - never cmd's "start",
|
|
26
|
+
// which splits a web address at its & signs (audit, 19 Sept).
|
|
27
|
+
return platform === 'darwin' ? ['open', [url]] : platform === 'win32' ? ['rundll32', ['url,OpenURL', url]] : ['xdg-open', [url]];
|
|
28
|
+
}
|
|
29
|
+
// Opens the address in the person's own browser, on every operating system.
|
|
30
|
+
export function openInBrowser(url) {
|
|
31
|
+
const command = browserCommand(url);
|
|
32
|
+
try {
|
|
33
|
+
const child = spawn(command[0], command[1], { stdio: 'ignore', detached: true });
|
|
34
|
+
child.on('error', () => { });
|
|
35
|
+
child.unref();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// The address is also shown on screen, so the person can open it themselves.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const PAGE = (message) => `<!doctype html><meta charset="utf-8"><title>Jeeves</title><body style="font-family:-apple-system,Segoe UI,sans-serif;background:#0b0b0d;color:#e6e6e6;display:grid;place-items:center;height:100vh;margin:0"><div style="text-align:center"><h1 style="font-weight:500">Jeeves</h1><p>${message}</p></div>`;
|
|
42
|
+
export async function signInWithOpenRouter(options) {
|
|
43
|
+
const { verifier, challenge } = pkcePair();
|
|
44
|
+
const exchange = options.exchange ?? ((body) => fetch(EXCHANGE_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }));
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
let settled = false;
|
|
47
|
+
const server = createServer(async (request, response) => {
|
|
48
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
49
|
+
const code = url.searchParams.get('code');
|
|
50
|
+
if (url.pathname !== '/callback' || !code) {
|
|
51
|
+
response.writeHead(404).end();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const reply = await exchange({ code, code_verifier: verifier, code_challenge_method: 'S256' });
|
|
56
|
+
const body = (await reply.json().catch(() => ({})));
|
|
57
|
+
if (!reply.ok || typeof body.key !== 'string')
|
|
58
|
+
throw new Error(`exchange ${reply.status}`);
|
|
59
|
+
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }).end(PAGE('Jeeves is connected to OpenRouter. You can close this tab and go back to Jeeves.'));
|
|
60
|
+
finish({ ok: true, key: body.key });
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }).end(PAGE("OpenRouter didn't complete the sign-in. Go back to Jeeves and try again."));
|
|
64
|
+
finish({ ok: false, reason: 'refused' });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
// Fifteen minutes: long enough to make a new OpenRouter account and confirm an email.
|
|
68
|
+
const timer = setTimeout(() => finish({ ok: false, reason: 'timeout' }), options.timeoutMs ?? 15 * 60_000);
|
|
69
|
+
const onAbort = () => finish({ ok: false, reason: 'cancelled' });
|
|
70
|
+
options.signal?.addEventListener('abort', onAbort);
|
|
71
|
+
if (options.signal?.aborted) {
|
|
72
|
+
finish({ ok: false, reason: 'cancelled' });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
function finish(result) {
|
|
76
|
+
if (settled)
|
|
77
|
+
return;
|
|
78
|
+
settled = true;
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
options.signal?.removeEventListener('abort', onAbort);
|
|
81
|
+
server.close();
|
|
82
|
+
resolve(result);
|
|
83
|
+
}
|
|
84
|
+
// Only this computer can reach the callback.
|
|
85
|
+
server.listen(0, '127.0.0.1', () => {
|
|
86
|
+
const address = server.address();
|
|
87
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
88
|
+
const url = authorizeUrl(`http://localhost:${port}/callback`, challenge);
|
|
89
|
+
options.onUrl?.(url);
|
|
90
|
+
(options.open ?? openInBrowser)(url);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
// Said while the browser is open, in the terminal and the window alike. A person
|
|
95
|
+
// who is not logged in lands on OpenRouter's Sign Up page, which carries a return
|
|
96
|
+
// address to this same approval page (measured 19 Sept with a logged-out visit).
|
|
97
|
+
export const WAITING_STEPS = [
|
|
98
|
+
'Your browser has opened at OpenRouter.',
|
|
99
|
+
'Log in if it asks, then click Authorize.',
|
|
100
|
+
'New to OpenRouter? Make an account on the page that opens - it brings you back to Authorize afterwards. If you end up somewhere else, come back here and choose Sign in again.',
|
|
101
|
+
"I'm waiting here - this moves on by itself once you approve.",
|
|
102
|
+
];
|