@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,3 +1,47 @@
1
+ import stringWidth from 'string-width';
2
+ import { renderMarkdown } from './markdown.js';
3
+ // Wraps formatted text at word boundaries, carrying each style range onto the
4
+ // lines it lands on. Every line break in the text starts a new line.
5
+ export function wrapStyled(text, spans, width) {
6
+ const out = [];
7
+ let offset = 0;
8
+ for (const paragraph of text.split('\n')) {
9
+ // A list line's wrapped lines start under its words, not under the "- ".
10
+ const hang = /^(\s*(?:[-•]|\d+\.)\s)/.exec(paragraph)?.[1].length ?? 0;
11
+ const pieces = [];
12
+ let start = 0;
13
+ do {
14
+ const max = Math.max(1, width - (start > 0 ? hang : 0));
15
+ if (paragraph.length - start <= max) {
16
+ pieces.push([start, paragraph.length]);
17
+ break;
18
+ }
19
+ let cut = paragraph.lastIndexOf(' ', start + max);
20
+ if (cut <= start)
21
+ cut = start + max;
22
+ pieces.push([start, cut]);
23
+ start = paragraph[cut] === ' ' ? cut + 1 : cut;
24
+ } while (start < paragraph.length);
25
+ pieces.forEach(([from, to], index) => {
26
+ const line = paragraph.slice(from, to).replace(/\s+$/, '');
27
+ const absFrom = offset + from;
28
+ const absTo = absFrom + line.length;
29
+ const pad = index > 0 ? hang : 0;
30
+ const lineSpans = spans
31
+ .filter((span) => span.to > absFrom && span.from < absTo)
32
+ .map((span) => ({ ...span, from: Math.max(span.from, absFrom) - absFrom + pad, to: Math.min(span.to, absTo) - absFrom + pad }));
33
+ // A single space: an empty line would be drawn with no height at all.
34
+ out.push({ text: ' '.repeat(pad) + line || ' ', spans: lineSpans });
35
+ });
36
+ offset += paragraph.length + 1;
37
+ }
38
+ return out;
39
+ }
40
+ // Claude Code marks the person's messages the same way: a blank line above and a
41
+ // grey band behind (its dark theme's userMessageBackground, rgb(55, 55, 55)), with
42
+ // white text so the band reads on light and dark terminals alike.
43
+ export const OWN_MESSAGE_BACKGROUND = '#373737';
44
+ export const OWN_MESSAGE_TEXT = '#ffffff';
1
45
  // Greedy word-wrap for plain text. Long unbreakable words are hard-broken at the width.
2
46
  export function wrapParagraph(s, max) {
3
47
  if (max <= 0)
@@ -44,7 +88,7 @@ const TOOL_NAMES = { readFile: 'Read', listDir: 'List', writeFile: 'Write', runB
44
88
  export function toolName(tool) {
45
89
  return TOOL_NAMES[tool] ?? tool;
46
90
  }
47
- function toolLineText(d) {
91
+ export function toolLineText(d) {
48
92
  if (d.state === 'awaiting') {
49
93
  return { text: `? ${toolName(d.tool)} ${d.summary} — allow? (y/n)`, color: 'yellow' };
50
94
  }
@@ -73,11 +117,27 @@ export function buildDisplayLines(entries, width) {
73
117
  };
74
118
  for (const entry of entries) {
75
119
  switch (entry.kind) {
76
- case 'user':
77
- pushWrapped(entry.text, '> ', ' ');
120
+ case 'user': {
121
+ // A single space: an empty line would be drawn with no height at all.
122
+ if (lines.length > 0)
123
+ lines.push({ text: ' ' });
124
+ for (const line of wrapWithPrefix(entry.text, width, '> ', ' ')) {
125
+ lines.push({ text: line + ' '.repeat(Math.max(0, width - stringWidth(line))), own: true });
126
+ }
78
127
  break;
128
+ }
79
129
  case 'assistant':
80
- pushWrapped(entry.text, '', '');
130
+ // Always a gap above Jeeves's answer, so it never runs straight on from the
131
+ // actions above it (the owner's request, 18 Sept) or from your message.
132
+ if (lines.length > 0 && lines[lines.length - 1].text.trim() !== '')
133
+ lines.push({ text: ' ' });
134
+ {
135
+ // Markdown drawn as formatting, never as stray ** and ## marks.
136
+ const styled = renderMarkdown(entry.text);
137
+ for (const line of wrapStyled(styled.text, styled.spans, width)) {
138
+ lines.push({ text: line.text, spans: line.spans.length ? line.spans : undefined });
139
+ }
140
+ }
81
141
  break;
82
142
  case 'reasoning':
83
143
  pushWrapped(entry.text, '· ', ' ', undefined, true);
package/dist/index.js CHANGED
@@ -52,7 +52,8 @@ program
52
52
  // The whole app - project picker, key screens, model picker, main window - runs inside a
53
53
  // single AlternateScreen, so the terminal is taken over exactly once for the whole
54
54
  // process and handed back only when Jeeves quits (Claude Code's mechanism).
55
- const instance = render(_jsx(AlternateScreen, { children: _jsx(App, {}) }));
55
+ // Ctrl+C is Jeeves's own (src/ink/quit.ts): twice to quit, never at once.
56
+ const instance = render(_jsx(AlternateScreen, { children: _jsx(App, {}) }), { exitOnCtrlC: false });
56
57
  let quitting = false;
57
58
  // /exit asks for a clean shutdown: let Ink finish its frame teardown, hand the
58
59
  // terminal back, then leave.
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
- // native click-drag selection; Shift (or Option in Terminal.app) bypasses mouse
7
- // capture for copying - the help screen says so.
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. Non-wheel
42
- // events are consumed silently - clicks must never leak into text inputs.
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 || event.kind !== 'wheel')
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
- session.scrollTranscript(event.button === 0 ? 3 : -3);
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
  }
@@ -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
+ }
@@ -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
- const mod = await import('keytar');
10
- cached = (mod.default ?? mod);
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
+ }
@@ -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;
@@ -114,3 +117,11 @@ export function getEstimatedSpend() {
114
117
  export function setEstimatedSpend(spend) {
115
118
  config.set('estimatedSpend', spend);
116
119
  }
120
+ // Project folders where the person chose "always allow": changes inside them need no
121
+ // yes/no (they are still backed up, so /undo works). /ask removes a folder again.
122
+ export function getTrustedProjects() {
123
+ return config.get('trustedProjects') ?? [];
124
+ }
125
+ export function setTrustedProjects(folders) {
126
+ config.set('trustedProjects', folders);
127
+ }
@@ -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+← →';
@@ -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
- timeout: { firstChunkMs: 120_000, chunkMs: 90_000, stepMs: 600_000 },
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) {
@@ -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
- export async function initKeys() {
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('Your key was moved from a local file into your Mac keychain, and the file was removed.');
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('The Mac keychain was not reachable, so the key is being read from a local file for now. Type /keys to store it securely.');
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';
@@ -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
- timeout: { firstChunkMs: 120_000, chunkMs: 90_000, stepMs: 600_000 },
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) {