@tianmucreations/jeeves 0.2.1 → 0.3.0

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 (55) hide show
  1. package/README.md +79 -18
  2. package/bin/jeeves +8 -1
  3. package/dist/agent/auto-ids.js +66 -0
  4. package/dist/agent/auto.js +178 -0
  5. package/dist/agent/context.js +55 -13
  6. package/dist/agent/errors.js +83 -22
  7. package/dist/agent/expert-chat.js +33 -0
  8. package/dist/agent/housekeeping.js +55 -0
  9. package/dist/agent/loop.js +168 -12
  10. package/dist/agent/permissions.js +167 -0
  11. package/dist/agent/research-gate.js +267 -0
  12. package/dist/agent/review.js +135 -0
  13. package/dist/agent/spending.js +73 -0
  14. package/dist/agent/systemPrompt.js +112 -0
  15. package/dist/app.js +25 -10
  16. package/dist/checkpoints/index.js +103 -0
  17. package/dist/checkpoints/store.js +239 -0
  18. package/dist/commands/address.js +5 -0
  19. package/dist/commands/clear.js +2 -0
  20. package/dist/commands/help.js +7 -4
  21. package/dist/commands/keys.js +1 -1
  22. package/dist/commands/verbose.js +1 -1
  23. package/dist/components/AddressPrompt.js +31 -0
  24. package/dist/components/Footer.js +74 -102
  25. package/dist/components/Input.js +76 -25
  26. package/dist/components/KeysManager.js +65 -20
  27. package/dist/components/ModelPicker.js +348 -75
  28. package/dist/components/ProjectPicker.js +4 -1
  29. package/dist/components/Transcript.js +29 -14
  30. package/dist/components/input-layout.js +34 -0
  31. package/dist/components/transcript-layout.js +13 -17
  32. package/dist/index.js +25 -7
  33. package/dist/ink/AlternateScreen.js +33 -16
  34. package/dist/ink/cursor.js +18 -0
  35. package/dist/ink/mouse.js +48 -0
  36. package/dist/keys/store.js +2 -1
  37. package/dist/models/registry.js +18 -2
  38. package/dist/platform/config.js +63 -7
  39. package/dist/providers/catalogue.js +293 -0
  40. package/dist/providers/direct-services.js +65 -0
  41. package/dist/providers/direct.js +145 -0
  42. package/dist/providers/index.js +123 -13
  43. package/dist/providers/models-snapshot.js +1037 -0
  44. package/dist/providers/ollama.js +21 -4
  45. package/dist/providers/openrouter.js +39 -4
  46. package/dist/providers/step-control.js +28 -0
  47. package/dist/providers/zai.js +31 -11
  48. package/dist/state/session.js +90 -36
  49. package/dist/state/today-spend.js +26 -0
  50. package/dist/tools/index.js +118 -10
  51. package/dist/tools/runBash.js +58 -11
  52. package/dist/tools/web/htmlToText.js +32 -0
  53. package/dist/tools/web/openrouterChat.js +31 -0
  54. package/dist/tools/web/research.js +191 -0
  55. package/package.json +32 -6
@@ -1,15 +1,25 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo } from 'react';
3
- import { Box, Text } from 'ink';
4
- import { useSession } from '../state/session.js';
5
- import { buildDisplayLines, visibleWindow } from './transcript-layout.js';
6
- // The transcript is clipped to a fixed height so the frame never grows past the
7
- // terminal window - this is what keeps the header and footer permanently in place.
8
- // The terminal's native scrollback is unavailable in alternate-screen mode, so
9
- // the region scrolls itself: arrow and page keys move the view, and 0 offset
10
- // keeps it pinned to the newest line while answers stream in.
11
- export function Transcript({ height, width }) {
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef } from 'react';
3
+ import { Box, Text, useBoxMetrics, useStdout } from 'ink';
4
+ import { session, useSession } from '../state/session.js';
5
+ import { buildDisplayLines } from './transcript-layout.js';
6
+ // Claude Code's ScrollBox pattern (ch13-14-terminal-ui.md): the outer box clips at
7
+ // the viewport with overflow="hidden" and flexGrow={1}, so the transcript fills
8
+ // every row left over by the fixed header, input, and footer slots - no dead space.
9
+ // The content is anchored to the bottom (justifyContent flex-end), so the inner box
10
+ // scrolls with a negative BOTTOM margin, which pushes it down past the bottom edge
11
+ // and brings older lines in at the top. (A negative top margin, as in Claude Code's
12
+ // top-anchored ScrollBox, does nothing to a bottom-anchored box - measured with
13
+ // renderToString: the same last lines showed at every offset.) scrollTop is
14
+ // clamped between 0 and contentHeight - viewportHeight, both measured live. While scrollTop is 0 the newest line sits at the bottom edge
15
+ // (auto-follow): new content arrives and the view stays pinned to it.
16
+ export function Transcript({ width }) {
12
17
  const s = useSession();
18
+ const { stdout } = useStdout();
19
+ const outer = useRef(null);
20
+ const inner = useRef(null);
21
+ const viewport = useBoxMetrics(outer);
22
+ const content = useBoxMetrics(inner);
13
23
  const entries = useMemo(() => {
14
24
  if (s.showLastReasoning && s.lastReasoning) {
15
25
  return [...s.transcript, { id: -1, kind: 'reasoning', text: s.lastReasoning }];
@@ -17,7 +27,12 @@ export function Transcript({ height, width }) {
17
27
  return s.transcript;
18
28
  }, [s.transcript, s.showLastReasoning, s.lastReasoning]);
19
29
  const lines = useMemo(() => buildDisplayLines(entries, width), [entries, width]);
20
- const { visible, linesAbove, linesBelow } = useMemo(() => visibleWindow(lines, height, s.transcriptScrollUp), [lines, height, s.transcriptScrollUp]);
21
- const showPosition = linesAbove > 0 || linesBelow > 0;
22
- return (_jsxs(Box, { flexDirection: "column", justifyContent: "flex-end", height: height, children: [showPosition && (_jsxs(Text, { dimColor: true, children: ["\u2191 ", linesAbove, " line", linesAbove === 1 ? '' : 's', " above \u00B7 newest \u2193", linesBelow > 0 ? ` (+${linesBelow} below)` : ''] })), visible.map((line, index) => (_jsx(Text, { color: line.color, dimColor: line.dim, children: line.text }, index)))] }));
30
+ // Virtual scroll: never above the first line, never below the newest.
31
+ const maxScroll = Math.max(0, content.height - viewport.height);
32
+ const scrollTop = Math.min(s.transcriptScrollUp, maxScroll);
33
+ // The session clamps key and wheel scrolling to this same limit.
34
+ useEffect(() => {
35
+ session.setTranscriptScrollMax(maxScroll);
36
+ }, [maxScroll]);
37
+ return (_jsx(Box, { flexDirection: "column", overflow: "hidden", flexGrow: 1, justifyContent: "flex-end", ref: outer, children: _jsx(Box, { flexDirection: "column", flexShrink: 0, marginBottom: -scrollTop, ref: inner, children: lines.map((line, index) => (_jsx(Text, { color: line.color, dimColor: line.dim, children: line.text }, index))) }) }));
23
38
  }
@@ -0,0 +1,34 @@
1
+ import stringWidth from 'string-width';
2
+ // What the one-row input shows, and where the cursor goes. Two rules keep Ink off
3
+ // its cursor-only update path, which in an exactly-fullscreen frame draws the
4
+ // cursor two rows above the y passed (onto the separator - measured in a pty):
5
+ //
6
+ // 1. Text wider than the row shows only its tail, so the cursor never runs past
7
+ // the row and every keystroke changes what is drawn. (Left to wrap, the extra
8
+ // characters landed on a clipped second line: the frame stayed identical
9
+ // while the cursor moved, and the cursor climbed up through the transcript.)
10
+ // 2. Trailing spaces are returned separately so the caller can style them (dim):
11
+ // a plain trailing space is indistinguishable from the row's padding, so
12
+ // "hello " and "hello" would otherwise be byte-identical frames.
13
+ //
14
+ // maxWidth leaves one column spare so the cursor itself stays inside the row.
15
+ export function inputView(value, rowWidth) {
16
+ const maxWidth = Math.max(1, rowWidth - 1);
17
+ const chars = Array.from(value);
18
+ let width = 0;
19
+ let start = chars.length;
20
+ while (start > 0) {
21
+ const next = stringWidth(chars[start - 1]);
22
+ if (width + next > maxWidth)
23
+ break;
24
+ width += next;
25
+ start -= 1;
26
+ }
27
+ const visible = chars.slice(start).join('');
28
+ const text = visible.replace(/ +$/, '');
29
+ return { text, trailingSpaces: visible.slice(text.length), width };
30
+ }
31
+ // Backspace removes one whole character, never half of a surrogate pair.
32
+ export function dropLastChar(value) {
33
+ return Array.from(value).slice(0, -1).join('');
34
+ }
@@ -39,34 +39,30 @@ function wrapWithPrefix(s, width, prefix, indent) {
39
39
  }
40
40
  return raw.map((line, index) => (index === 0 ? prefix + line : indent + line));
41
41
  }
42
+ // The tools' everyday names on screen; the internal names are for the model only.
43
+ const TOOL_NAMES = { readFile: 'Read', listDir: 'List', writeFile: 'Write', runBash: 'Run', webSearch: 'Search', readWebPage: 'Read', askExpert: 'Expert', noteResearch: 'Research' };
44
+ export function toolName(tool) {
45
+ return TOOL_NAMES[tool] ?? tool;
46
+ }
42
47
  function toolLineText(d) {
43
48
  if (d.state === 'awaiting') {
44
- return { text: `? ${d.tool} ${d.summary} — allow? (y/n)`, color: 'yellow' };
49
+ return { text: `? ${toolName(d.tool)} ${d.summary} — allow? (y/n)`, color: 'yellow' };
45
50
  }
46
51
  if (d.state === 'running') {
47
- return { text: `… ${d.tool} ${d.summary}`, dim: true };
52
+ return { text: `… ${toolName(d.tool)} ${d.summary}`, dim: true };
48
53
  }
49
54
  if (d.state === 'declined') {
50
- return { text: `✗ ${d.tool} declined`, color: 'red' };
55
+ return { text: `✗ ${toolName(d.tool)} ${clipLine(d.summary, 50)} - you said no`, color: 'red' };
56
+ }
57
+ // Held until research is done: not a failure and not a refusal, so neither red nor alarming.
58
+ if (d.state === 'held') {
59
+ return { text: `· ${toolName(d.tool)} ${clipLine(d.summary, 50)} - ${d.label}`, dim: true };
51
60
  }
52
61
  if (d.state === 'failed') {
53
- return { text: `✗ ${d.tool} failed: ${clipLine(d.label, 60)}`, color: 'red' };
62
+ return { text: `✗ ${toolName(d.tool)} failed: ${clipLine(d.label, 60)}`, color: 'red' };
54
63
  }
55
64
  return { text: `✓ ${d.label}` };
56
65
  }
57
- // Picks the visible window of display lines for the transcript region. The
58
- // terminal's own scrollback is off in alternate-screen mode, so this is the
59
- // app's whole scrolling story: scrollUp counts lines above the bottom edge,
60
- // and 0 pins the view to the newest line (follow mode).
61
- export function visibleWindow(lines, height, scrollUp) {
62
- const total = lines.length;
63
- const maxScroll = Math.max(0, total - height);
64
- const offset = Math.min(Math.max(0, scrollUp), maxScroll);
65
- const end = total - offset;
66
- const start = Math.max(0, end - height);
67
- const visible = lines.slice(start, end);
68
- return { visible, linesAbove: start, linesBelow: total - end };
69
- }
70
66
  // Turns transcript entries into physical display lines that fit the given width.
71
67
  export function buildDisplayLines(entries, width) {
72
68
  const lines = [];
package/dist/index.js CHANGED
@@ -1,12 +1,14 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { render } from 'ink';
3
3
  import { Command } from 'commander';
4
- import { existsSync } from 'node:fs';
4
+ import { existsSync, readFileSync } from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { App } from './app.js';
8
8
  import { session } from './state/session.js';
9
9
  import { AlternateScreen, leaveAltScreen } from './ink/AlternateScreen.js';
10
+ import { killAllRunningCommands } from './tools/runBash.js';
11
+ import { getAddress } from './platform/config.js';
10
12
  // Local development bridge: settings such as OPENROUTER_API_KEY are loaded from a gitignored
11
13
  // .env file at the project root. Replaced by the secure OS credential store in Phase 7.
12
14
  const envPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '.env');
@@ -18,19 +20,34 @@ if (existsSync(envPath)) {
18
20
  // A malformed .env is non-fatal; the on-screen notice explains what is missing.
19
21
  }
20
22
  }
21
- // Graceful degradation: without an interactive terminal there is nothing to draw,
22
- // so explain in plain English instead of crashing on raw mode.
23
- if (!process.stdin.isTTY) {
24
- console.error('This app needs an interactive terminal window to run.');
25
- process.exit(1);
23
+ // The version comes from package.json, so it can never drift from the release.
24
+ function packageVersion() {
25
+ try {
26
+ const file = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
27
+ return JSON.parse(readFileSync(file, 'utf8')).version ?? 'unknown';
28
+ }
29
+ catch {
30
+ return 'unknown';
31
+ }
26
32
  }
27
33
  const program = new Command();
28
34
  program
29
35
  .name('jeeves')
30
- .version('0.2.1')
36
+ .version(packageVersion())
31
37
  .description('A plain-English terminal assistant.')
32
38
  .argument('[prompt]', 'optional prompt to start with')
33
39
  .action((prompt) => {
40
+ // Graceful degradation: without an interactive terminal there is nothing to draw,
41
+ // so explain in plain English instead of crashing on raw mode. (Checked here, not
42
+ // earlier, so --version and --help still answer anywhere.)
43
+ if (!process.stdin.isTTY) {
44
+ console.error('This needs to be opened in a Terminal window.');
45
+ process.exit(1);
46
+ }
47
+ // The address question comes before the project picker on first launch only;
48
+ // a saved address skips straight to the picker.
49
+ if (getAddress())
50
+ session.skipAddressStage();
34
51
  // The prompt argument is accepted but not auto-sent yet; a later phase wires it into the loop.
35
52
  // The whole app - project picker, key screens, model picker, main window - runs inside a
36
53
  // single AlternateScreen, so the terminal is taken over exactly once for the whole
@@ -44,6 +61,7 @@ program
44
61
  quitting = true;
45
62
  instance.unmount();
46
63
  void instance.waitUntilExit().then(() => {
64
+ killAllRunningCommands();
47
65
  leaveAltScreen();
48
66
  process.exit(0);
49
67
  });
@@ -28,6 +28,9 @@ import { jsx as _jsx } from "react/jsx-runtime";
28
28
  import { useEffect, useInsertionEffect } from 'react';
29
29
  import { Box, useStdout } from 'ink';
30
30
  import { createRequire } from 'node:module';
31
+ import { DEFAULT_CURSOR } from './cursor.js';
32
+ import { ENABLE_MOUSE_TRACKING, DISABLE_MOUSE_TRACKING } from './mouse.js';
33
+ import { killAllRunningCommands } from '../tools/runBash.js';
31
34
  // signal-exit covers the termination signals beyond exit and SIGINT
32
35
  // (SIGTERM, SIGHUP) with correct exit codes. Already in the tree as Ink's own
33
36
  // dependency; declared ours.
@@ -50,48 +53,59 @@ function write(data) {
50
53
  }
51
54
  }
52
55
  // Claude Code's Ink fork exposes this on the render instance; stock Ink has no
53
- // such method, so the notification lives here: the flag that says the
54
- // alternate screen owns the terminal, consulted by every exit path below.
56
+ // such method, so the notification lives here: the flag that says the alternate
57
+ // screen owns the terminal, consulted by every exit path below. Mouse tracking
58
+ // is now genuinely on (wheel scrolling), so the flag reports it truthfully.
55
59
  export function setAltScreenActive(active, mouseTracking) {
56
60
  altScreenActive = active;
57
- // Mouse tracking is deliberately left off: it would break click-drag text
58
- // selection in Terminal.app, and the app already owns scrolling by key.
59
61
  void mouseTracking;
60
62
  }
61
63
  // Hands the terminal back. Safe to call from anywhere, any number of times.
62
64
  // The scrollback erase follows the switch back because macOS Terminal.app
63
65
  // archives the app's own frames into the scrollback at hand-back (measured),
64
- // and they must not linger; the cursor comes back on for the shell.
66
+ // mouse tracking is switched off so the terminal's own selection works again,
67
+ // and the cursor shape returns to the shell default with the cursor back on.
65
68
  export function leaveAltScreen() {
66
69
  if (!altScreenActive)
67
70
  return;
68
71
  altScreenActive = false;
69
- write(LEAVE_ALT_SCREEN + ERASE_SCROLLBACK + SHOW_CURSOR);
72
+ write(LEAVE_ALT_SCREEN + ERASE_SCROLLBACK + DISABLE_MOUSE_TRACKING + DEFAULT_CURSOR + SHOW_CURSOR);
70
73
  }
71
- // The terminal must always be restored. process handlers per the mechanism,
72
- // plus signal-exit so kill signals re-raise with correct exit codes.
74
+ // The terminal must always be restored, and any command the agent is running
75
+ // must die with the app - the user is leaving, nothing may keep running or block
76
+ // the exit. process handlers per the mechanism, plus signal-exit so kill signals
77
+ // re-raise with correct exit codes.
73
78
  function registerCleanup() {
74
79
  if (cleanupRegistered)
75
80
  return;
76
81
  cleanupRegistered = true;
77
- process.on('exit', () => leaveAltScreen());
82
+ process.on('exit', () => {
83
+ killAllRunningCommands();
84
+ leaveAltScreen();
85
+ });
78
86
  process.on('SIGINT', () => {
87
+ killAllRunningCommands();
79
88
  leaveAltScreen();
80
89
  process.exit(130);
81
90
  });
82
- onSignalExit(() => leaveAltScreen(), { alwaysLast: false });
91
+ onSignalExit(() => {
92
+ killAllRunningCommands();
93
+ leaveAltScreen();
94
+ }, { alwaysLast: false });
83
95
  }
84
96
  export function AlternateScreen({ children }) {
85
97
  const { stdout } = useStdout();
86
98
  // Entered once, before the first frame, in Claude Code's order: take over
87
99
  // the window first, then clear the fresh alternate screen, erase the
88
- // scrollback the switch archived, and home the cursor. Because the main
100
+ // scrollback the switch archived, home the cursor, and enable SGR mouse
101
+ // tracking so wheel events reach the app (the alternate screen has no native
102
+ // scrollback - wheel gestures scroll the transcript instead). Because the main
89
103
  // screen is never wiped, the shell's own screen survives for a perfect
90
104
  // restore on exit. The cursor is hidden for the same reason Ink's own mode
91
105
  // hides it. Empty dependency array: this runs exactly once, on mount.
92
106
  useInsertionEffect(() => {
93
- write(ENTER_ALT_SCREEN + CLEAR_SCREEN + ERASE_SCROLLBACK + HOME_CURSOR + HIDE_CURSOR);
94
- setAltScreenActive(true, false);
107
+ write(ENTER_ALT_SCREEN + CLEAR_SCREEN + ERASE_SCROLLBACK + HOME_CURSOR + HIDE_CURSOR + ENABLE_MOUSE_TRACKING);
108
+ setAltScreenActive(true, true);
95
109
  registerCleanup();
96
110
  }, []);
97
111
  // On unmount the terminal is handed back too (the app unmounts before the
@@ -100,7 +114,10 @@ export function AlternateScreen({ children }) {
100
114
  return () => leaveAltScreen();
101
115
  }, []);
102
116
  const rows = Math.max(stdout.rows ?? 24, 8);
103
- // The alternate screen has no native scrollback, so the app owns its own
104
- // scrolling: everything is constrained to the terminal's row count.
105
- return (_jsx(Box, { height: rows, flexDirection: "column", overflow: "hidden", children: children }));
117
+ // The ceiling: without a height constraint on this box, flexGrow below has no
118
+ // limit - the viewport would size to the content, scrolling would pin at 0, and
119
+ // Ink's screen buffer would size to the full content. This is what makes the
120
+ // slot layout work. The alternate screen has no native scrollback, so the app
121
+ // owns its scrolling within these rows.
122
+ return (_jsx(Box, { height: rows, flexDirection: "column", children: children }));
106
123
  }
@@ -0,0 +1,18 @@
1
+ // DECSCUSR cursor-shape sequences. The app switches the terminal's real cursor to a
2
+ // steady block while it runs (the way Claude Code does) and restores the shell's
3
+ // default shape when it quits. Not reverse-video fakery: the terminal's own cursor.
4
+ export const BLOCK_CURSOR = '\x1b[2 q';
5
+ export const DEFAULT_CURSOR = '\x1b[0 q';
6
+ // The main window's fixed rows (see src/app.tsx): top border 1, header 1,
7
+ // transcript flexGrow (rows-6), separator 1, input 1, bottom border 1, info bar
8
+ // 1 (outside the box). The input text sits on 0-based frame row rows-3. Ink's
9
+ // cursor placement in an exactly-fullscreen frame (outputHeight >= terminal rows)
10
+ // lands the visible cursor one row ABOVE the y passed to setCursorPosition -
11
+ // buildCursorSuffix moves up visibleLineCount - y from the last written line
12
+ // (row visibleLineCount-1), which resolves to y-1 (verified against a real pty
13
+ // capture). So the y passed here is the input row plus one: rows-2, which draws
14
+ // the block cursor on the input text's own row, clear of the separator above and
15
+ // the box's bottom border below.
16
+ export function inputFrameRow(rows) {
17
+ return rows - 2;
18
+ }
@@ -0,0 +1,48 @@
1
+ import { session } from '../state/session.js';
2
+ // SGR mouse tracking (modes 1000 + 1002 + 1006), enabled for the whole session by
3
+ // the AlternateScreen takeover and disabled on every exit path. Claude Code's
4
+ // approach: in the alternate screen the terminal has no scrollback, so wheel
5
+ // 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.
8
+ export const ENABLE_MOUSE_TRACKING = '\x1b[?1000h\x1b[?1002h\x1b[?1006h';
9
+ export const DISABLE_MOUSE_TRACKING = '\x1b[?1006l\x1b[?1002l\x1b[?1000l';
10
+ // Ink's input parser hands over complete CSI sequences but strips the leading ESC
11
+ // from ones it cannot resolve, so both forms are accepted here.
12
+ const MOUSE_RE = /^\x1b?\[<(\d+);(\d+);(\d+)([Mm])$/;
13
+ export function isMouseSequence(input) {
14
+ return MOUSE_RE.test(input);
15
+ }
16
+ // SGR mouse protocol: press ends in M, release ends in m; 64 is wheel up and 65
17
+ // is wheel down, arriving as single events with no press/release distinction.
18
+ // Everything else (clicks, drags) is parsed for recognition but not acted on.
19
+ export function parseMouseSequence(input) {
20
+ const match = MOUSE_RE.exec(input);
21
+ if (!match)
22
+ return null;
23
+ const rawButton = Number(match[1]);
24
+ const col = Number(match[2]);
25
+ const row = Number(match[3]);
26
+ const final = match[4];
27
+ if (final === 'm') {
28
+ if (rawButton > 3)
29
+ return null;
30
+ return { kind: 'release', button: rawButton, col, row };
31
+ }
32
+ if (rawButton === 64 || rawButton === 65) {
33
+ return { kind: 'wheel', button: rawButton - 64, col, row };
34
+ }
35
+ if (rawButton <= 6) {
36
+ return { kind: 'press', button: rawButton, col, row };
37
+ }
38
+ return null;
39
+ }
40
+ // 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.
43
+ export function handleMouseInput(input) {
44
+ const event = parseMouseSequence(input);
45
+ if (event === null || event.kind !== 'wheel')
46
+ return;
47
+ session.scrollTranscript(event.button === 0 ? 3 : -3);
48
+ }
@@ -1,4 +1,5 @@
1
- const SERVICE = 'jeeves';
1
+ // JEEVES_KEYCHAIN_SERVICE lets a test run use its own keychain entries, never the real ones.
2
+ const SERVICE = process.env.JEEVES_KEYCHAIN_SERVICE || 'jeeves';
2
3
  let cached = null;
3
4
  async function load() {
4
5
  // Escape hatch for automated tests so they never touch the real credential store.
@@ -1,3 +1,4 @@
1
+ import { AUTO_MODEL_ID, WORKER_MODELS, firstAvailable } from '../agent/auto-ids.js';
1
2
  import { getModelCache, setModelCache } from '../platform/config.js';
2
3
  const MODELS_URL = 'https://openrouter.ai/api/v1/models';
3
4
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
@@ -54,7 +55,7 @@ export async function loadModels() {
54
55
  if (cache) {
55
56
  return { models: normalizeModels(cache.raw), error: 'Could not refresh the model list - showing the saved copy.' };
56
57
  }
57
- return { models: [], error: `Could not load the model list: ${error instanceof Error ? error.message : String(error)}` };
58
+ return { models: [], error: "Could not load the model list - check the internet connection, then open /model again." };
58
59
  }
59
60
  }
60
61
  export function compactContext(tokens) {
@@ -74,9 +75,24 @@ export const CURATED_MODELS = [
74
75
  { ids: ['google/gemini-3.1-pro-preview', 'google/gemini-3-pro-preview', 'google/gemini-2.5-pro'], blurb: 'huge memory' },
75
76
  { ids: ['qwen/qwen3-coder-plus', 'qwen/qwen3-coder', 'qwen/qwen3-coder-flash'], blurb: 'good for code' },
76
77
  ];
78
+ // A free OpenRouter model: no charge for input or output. (Z.ai's plan models also
79
+ // show 0 but are "included" in a paid plan, not free.)
80
+ export function isFreeModel(model) {
81
+ return model.promptPrice === 0 && model.completionPrice === 0 && model.priceLabel !== 'included' && model.provider !== 'ollama';
82
+ }
83
+ // Auto heads the shortlist whenever its worker model is in the catalogue.
84
+ export function autoPick(models) {
85
+ // Auto stays available as long as any of its worker models is still in the catalogue.
86
+ const workerId = firstAvailable(WORKER_MODELS, models);
87
+ const worker = models.find((model) => model.id === workerId);
88
+ if (!worker)
89
+ return null;
90
+ return { model: { ...worker, id: AUTO_MODEL_ID, name: 'Auto', priceLabel: 'cheap, expert when needed' }, blurb: 'recommended: cheap model, expert on call' };
91
+ }
77
92
  export function resolveCurated(models) {
78
93
  const byId = new Map(models.map((model) => [model.id, model]));
79
- const picks = [];
94
+ const auto = autoPick(models);
95
+ const picks = auto ? [auto] : [];
80
96
  for (const entry of CURATED_MODELS) {
81
97
  for (const id of entry.ids) {
82
98
  const model = byId.get(id);
@@ -1,3 +1,4 @@
1
+ import path from 'node:path';
1
2
  import Conf from 'conf';
2
3
  // Persistent settings. Phase 7 expands this into the full config surface
3
4
  // (default model, favourites, recents, verbose flag). API keys are NEVER stored here (spec 5.3).
@@ -5,14 +6,36 @@ import Conf from 'conf';
5
6
  const config = new Conf({
6
7
  projectName: process.env.NODE_ENV === 'test' ? 'jeeves-tests' : 'jeeves',
7
8
  });
8
- const VALID_METRICS = ['session', 'context', 'cache', 'today', 'credit', 'speed'];
9
- // Metrics a power user has chosen to hide from the footer - hiding is opt-in, never required.
10
- export function getHiddenMetrics() {
11
- const stored = config.get('hiddenMetrics') ?? [];
12
- return stored.filter((metric) => VALID_METRICS.includes(metric));
9
+ // The folder Jeeves keeps its settings in (chosen by the conf library for each
10
+ // operating system); backups live in a "checkpoints" folder beside the settings.
11
+ export function settingsFolder() {
12
+ return path.dirname(config.path);
13
13
  }
14
- export function setHiddenMetrics(metrics) {
15
- config.set('hiddenMetrics', metrics.filter((metric) => VALID_METRICS.includes(metric)));
14
+ // The daily spending limit in dollars (default $3).
15
+ export function getDailyLimit() {
16
+ const stored = config.get('dailyLimit');
17
+ return typeof stored === 'number' && stored > 0 ? stored : 3;
18
+ }
19
+ // Whether a limit has ever been chosen - the first paid model choice asks for one.
20
+ export function hasSavedDailyLimit() {
21
+ return typeof config.get('dailyLimit') === 'number';
22
+ }
23
+ export function setDailyLimit(limit) {
24
+ config.set('dailyLimit', limit);
25
+ }
26
+ // Extra allowance agreed for one day when the limit was reached.
27
+ export function getDailyExtra() {
28
+ return config.get('dailyExtra');
29
+ }
30
+ export function setDailyExtra(extra) {
31
+ config.set('dailyExtra', extra);
32
+ }
33
+ // The last OpenRouter usage reading, kept between launches so "today" survives a restart.
34
+ export function getSpendReading() {
35
+ return config.get('spendReading');
36
+ }
37
+ export function setSpendReading(reading) {
38
+ config.set('spendReading', reading);
16
39
  }
17
40
  export function getFavorites() {
18
41
  return config.get('favorites') ?? [];
@@ -58,3 +81,36 @@ export function getVerbosePreference() {
58
81
  export function setVerbosePreference(value) {
59
82
  config.set('verbose', value);
60
83
  }
84
+ // How Jeeves addresses the person (spec: asked once on first launch, changeable via /address).
85
+ export function getAddress() {
86
+ return config.get('address') ?? null;
87
+ }
88
+ export function setAddress(address) {
89
+ config.set('address', address);
90
+ }
91
+ // The trimmed models.dev catalogue for direct connections (prices and abilities).
92
+ export function getDirectCatalogue() {
93
+ return config.get('directCatalogue') ?? null;
94
+ }
95
+ export function setDirectCatalogue(catalogue, fetchedAt) {
96
+ config.set('directCatalogue', { catalogue, fetchedAt });
97
+ }
98
+ // The address of the "any compatible service" the person added (its key is in the keychain).
99
+ export function getCustomService() {
100
+ return config.get('customService') ?? null;
101
+ }
102
+ export function setCustomService(service) {
103
+ if (service)
104
+ config.set('customService', service);
105
+ else
106
+ config.delete('customService');
107
+ }
108
+ // Today's spending worked out from price lists (direct connections), kept between
109
+ // launches so the daily limit still holds after a restart. OpenRouter's own
110
+ // figures are read from OpenRouter instead.
111
+ export function getEstimatedSpend() {
112
+ return config.get('estimatedSpend');
113
+ }
114
+ export function setEstimatedSpend(spend) {
115
+ config.set('estimatedSpend', spend);
116
+ }