@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.
- package/README.md +79 -18
- package/bin/jeeves +8 -1
- package/dist/agent/auto-ids.js +66 -0
- package/dist/agent/auto.js +178 -0
- package/dist/agent/context.js +55 -13
- package/dist/agent/errors.js +83 -22
- package/dist/agent/expert-chat.js +33 -0
- package/dist/agent/housekeeping.js +55 -0
- package/dist/agent/loop.js +168 -12
- package/dist/agent/permissions.js +167 -0
- package/dist/agent/research-gate.js +267 -0
- package/dist/agent/review.js +135 -0
- package/dist/agent/spending.js +73 -0
- package/dist/agent/systemPrompt.js +112 -0
- package/dist/app.js +25 -10
- package/dist/checkpoints/index.js +103 -0
- package/dist/checkpoints/store.js +239 -0
- package/dist/commands/address.js +5 -0
- package/dist/commands/clear.js +2 -0
- package/dist/commands/help.js +7 -4
- package/dist/commands/keys.js +1 -1
- package/dist/commands/verbose.js +1 -1
- package/dist/components/AddressPrompt.js +31 -0
- package/dist/components/Footer.js +74 -102
- package/dist/components/Input.js +76 -25
- package/dist/components/KeysManager.js +65 -20
- package/dist/components/ModelPicker.js +348 -75
- package/dist/components/ProjectPicker.js +4 -1
- package/dist/components/Transcript.js +29 -14
- package/dist/components/input-layout.js +34 -0
- package/dist/components/transcript-layout.js +13 -17
- package/dist/index.js +25 -7
- package/dist/ink/AlternateScreen.js +33 -16
- package/dist/ink/cursor.js +18 -0
- package/dist/ink/mouse.js +48 -0
- package/dist/keys/store.js +2 -1
- package/dist/models/registry.js +18 -2
- package/dist/platform/config.js +63 -7
- package/dist/providers/catalogue.js +293 -0
- package/dist/providers/direct-services.js +65 -0
- package/dist/providers/direct.js +145 -0
- package/dist/providers/index.js +123 -13
- package/dist/providers/models-snapshot.js +1037 -0
- package/dist/providers/ollama.js +21 -4
- package/dist/providers/openrouter.js +39 -4
- package/dist/providers/step-control.js +28 -0
- package/dist/providers/zai.js +31 -11
- package/dist/state/session.js +90 -36
- package/dist/state/today-spend.js +26 -0
- package/dist/tools/index.js +118 -10
- package/dist/tools/runBash.js +58 -11
- package/dist/tools/web/htmlToText.js +32 -0
- package/dist/tools/web/openrouterChat.js +31 -0
- package/dist/tools/web/research.js +191 -0
- package/package.json +32 -6
|
@@ -1,15 +1,25 @@
|
|
|
1
|
-
import { jsx as _jsx
|
|
2
|
-
import { useMemo } from 'react';
|
|
3
|
-
import { Box, Text } from 'ink';
|
|
4
|
-
import { useSession } from '../state/session.js';
|
|
5
|
-
import { buildDisplayLines
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
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
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
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}
|
|
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
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
72
|
-
//
|
|
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', () =>
|
|
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(() =>
|
|
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,
|
|
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,
|
|
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
|
|
104
|
-
//
|
|
105
|
-
|
|
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
|
+
}
|
package/dist/keys/store.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
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.
|
package/dist/models/registry.js
CHANGED
|
@@ -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:
|
|
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
|
|
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);
|
package/dist/platform/config.js
CHANGED
|
@@ -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
|
-
|
|
9
|
-
//
|
|
10
|
-
export function
|
|
11
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
+
}
|