@tianmucreations/jeeves 0.2.0 → 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 +34 -8
- package/dist/components/AlternateScreen.js +0 -74
package/dist/app.js
CHANGED
|
@@ -6,27 +6,35 @@ import { Transcript } from './components/Transcript.js';
|
|
|
6
6
|
import { Input } from './components/Input.js';
|
|
7
7
|
import { Footer } from './components/Footer.js';
|
|
8
8
|
import { session, useSession } from './state/session.js';
|
|
9
|
-
import { initKeys, hasCredentials, refreshCredit } from './providers/index.js';
|
|
10
|
-
import {
|
|
9
|
+
import { initKeys, hasCredentials, refreshCredit, serviceKey } from './providers/index.js';
|
|
10
|
+
import { isDirectService } from './providers/direct-services.js';
|
|
11
|
+
import { loadDirectModels } from './providers/catalogue.js';
|
|
12
|
+
import { getDailyLimit, getFavorites, getRecents, getRecentProjects, getDefaultModel, getDefaultProvider, getVerbosePreference } from './platform/config.js';
|
|
11
13
|
import { loadModels } from './models/registry.js';
|
|
12
14
|
import { ModelPicker } from './components/ModelPicker.js';
|
|
13
15
|
import { KeysManager } from './components/KeysManager.js';
|
|
14
16
|
import { HelpView } from './components/HelpView.js';
|
|
15
17
|
import { ProjectPicker } from './components/ProjectPicker.js';
|
|
16
|
-
|
|
17
|
-
//
|
|
18
|
-
//
|
|
18
|
+
import { AddressPrompt } from './components/AddressPrompt.js';
|
|
19
|
+
// The main window: a rounded box border around the top section only, with the
|
|
20
|
+
// info bar on its own row below the box. Top to bottom: plain top border, header
|
|
21
|
+
// row inside the box (Jeeves left, dot right), transcript (flexGrow), internal
|
|
22
|
+
// separator, input row, plain bottom border closing the box, then the info bar
|
|
23
|
+
// outside the box at the very bottom (model left, metrics right). Budget:
|
|
24
|
+
// border 1 + header 1 + transcript rows-6 + separator 1 + input 1 + border 1 +
|
|
25
|
+
// info bar 1 = exactly the terminal's rows.
|
|
19
26
|
export function App() {
|
|
20
27
|
const s = useSession();
|
|
21
28
|
const { stdout } = useStdout();
|
|
22
29
|
const rows = Math.max(stdout.rows ?? 24, 8);
|
|
23
30
|
const columns = Math.max(stdout.columns ?? 80, 40);
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
31
|
+
const inner = columns - 2;
|
|
32
|
+
const midHeight = Math.max(1, rows - 6);
|
|
33
|
+
const side = '│\n'.repeat(midHeight - 1) + '│';
|
|
34
|
+
const separator = '─'.repeat(inner);
|
|
27
35
|
useEffect(() => {
|
|
28
|
-
session.setHiddenMetrics(getHiddenMetrics());
|
|
29
36
|
session.setFavorites(getFavorites());
|
|
37
|
+
session.setDailyLimit(getDailyLimit());
|
|
30
38
|
session.setRecents(getRecents());
|
|
31
39
|
session.setRecentProjects(getRecentProjects());
|
|
32
40
|
if (getVerbosePreference())
|
|
@@ -41,6 +49,10 @@ export function App() {
|
|
|
41
49
|
// Keys resolve from the Mac keychain first; the first-run wizard now starts
|
|
42
50
|
// after the project is chosen (the project list always shows first).
|
|
43
51
|
void initKeys().then(() => {
|
|
52
|
+
// A direct connection's models (memory sizes, prices) load in the background.
|
|
53
|
+
const directKey = isDirectService(session.providerId) ? serviceKey(session.providerId) : null;
|
|
54
|
+
if (directKey)
|
|
55
|
+
void loadDirectModels(session.providerId, directKey).catch(() => { });
|
|
44
56
|
if (hasCredentials()) {
|
|
45
57
|
void refreshCredit();
|
|
46
58
|
}
|
|
@@ -61,8 +73,11 @@ export function App() {
|
|
|
61
73
|
if (s.helpOpen) {
|
|
62
74
|
return _jsx(HelpView, { rows: rows });
|
|
63
75
|
}
|
|
76
|
+
if (s.addressOpen || s.launchStage === 'address') {
|
|
77
|
+
return _jsx(AddressPrompt, { rows: rows });
|
|
78
|
+
}
|
|
64
79
|
if (s.launchStage === 'project') {
|
|
65
80
|
return _jsx(ProjectPicker, { rows: rows, columns: columns });
|
|
66
81
|
}
|
|
67
|
-
return (_jsxs(Box, { flexDirection: "column",
|
|
82
|
+
return (_jsxs(Box, { flexDirection: "column", height: rows, width: columns, children: [_jsxs(Text, { dimColor: true, children: ["\u256D", separator, "\u256E"] }), _jsxs(Box, { height: 1, children: [_jsx(Text, { dimColor: true, children: "\u2502" }), _jsx(Box, { width: inner, paddingLeft: 1, paddingRight: 1, flexDirection: "column", children: _jsx(Header, {}) }), _jsx(Text, { dimColor: true, children: "\u2502" })] }), _jsxs(Box, { height: midHeight, children: [_jsx(Box, { width: 1, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: side }) }), _jsx(Box, { width: inner, paddingLeft: 1, paddingRight: 1, children: _jsx(Transcript, { width: inner - 2 }) }), _jsx(Box, { width: 1, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: side }) })] }), _jsxs(Text, { dimColor: true, children: ["\u251C", separator, "\u2524"] }), _jsxs(Box, { height: 1, children: [_jsx(Text, { dimColor: true, children: "\u2502 " }), _jsx(Box, { width: inner - 2, children: _jsx(Input, { scrollPage: midHeight, width: inner - 2 }) }), _jsx(Text, { dimColor: true, children: " \u2502" })] }), _jsxs(Text, { dimColor: true, children: ["\u2570", separator, "\u256F"] }), _jsx(Box, { height: 1, flexDirection: "column", children: _jsx(Footer, {}) })] }));
|
|
68
83
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { session } from '../state/session.js';
|
|
3
|
+
import { settingsFolder } from '../platform/config.js';
|
|
4
|
+
import { CheckpointStore } from './store.js';
|
|
5
|
+
// Ties backups to the conversation: one checkpoint per message, taken just before
|
|
6
|
+
// the first thing Jeeves changes in answer to it (after permission, before the change),
|
|
7
|
+
// so /undo puts the folder back to how it was before that message.
|
|
8
|
+
let turnLabel = '';
|
|
9
|
+
let takenThisTurn = false;
|
|
10
|
+
const warnedIncomplete = new Set();
|
|
11
|
+
export function checkpointStoreRoot() {
|
|
12
|
+
return process.env.JEEVES_CHECKPOINTS_DIR || path.join(settingsFolder(), 'checkpoints');
|
|
13
|
+
}
|
|
14
|
+
function store() {
|
|
15
|
+
return new CheckpointStore(checkpointStoreRoot(), process.cwd());
|
|
16
|
+
}
|
|
17
|
+
export function startTurnCheckpoints(label) {
|
|
18
|
+
turnLabel = label;
|
|
19
|
+
takenThisTurn = false;
|
|
20
|
+
}
|
|
21
|
+
// Called before any change. Never throws: a backup failure is reported plainly and
|
|
22
|
+
// the person decides - but the change is not silently made without a backup.
|
|
23
|
+
export async function ensureCheckpoint() {
|
|
24
|
+
if (takenThisTurn)
|
|
25
|
+
return { ok: true };
|
|
26
|
+
session.setBusyNote('backing up…');
|
|
27
|
+
try {
|
|
28
|
+
const checkpoint = await store().create(turnLabel || 'a change');
|
|
29
|
+
takenThisTurn = true;
|
|
30
|
+
const folder = process.cwd();
|
|
31
|
+
if (checkpoint.skipped.length > 0 && !warnedIncomplete.has(folder)) {
|
|
32
|
+
warnedIncomplete.add(folder);
|
|
33
|
+
const shown = checkpoint.skipped.slice(0, 3).join('; ');
|
|
34
|
+
const more = checkpoint.skipped.length > 3 ? `, and ${checkpoint.skipped.length - 3} more` : '';
|
|
35
|
+
session.addNotice(`Backup note: ${shown}${more}. /undo can't bring those back.`);
|
|
36
|
+
}
|
|
37
|
+
return { ok: true };
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
return { ok: false, problem: error instanceof Error ? error.message : String(error) };
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
session.setBusyNote(null);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export async function undoLastChange() {
|
|
47
|
+
const s = store();
|
|
48
|
+
const checkpoint = await s.latestUndoable();
|
|
49
|
+
if (!checkpoint)
|
|
50
|
+
return { message: "There's nothing to undo in this project folder yet.", historyNote: null };
|
|
51
|
+
session.setBusyNote('undoing…');
|
|
52
|
+
try {
|
|
53
|
+
const result = await s.restore(checkpoint);
|
|
54
|
+
const parts = [];
|
|
55
|
+
if (result.restored.length > 0)
|
|
56
|
+
parts.push(`put back ${plural(result.restored.length, 'file')} (${listNames(result.restored)})`);
|
|
57
|
+
if (result.removed.length > 0)
|
|
58
|
+
parts.push(`removed ${plural(result.removed.length, 'file')} added since (${listNames(result.removed)})`);
|
|
59
|
+
const what = parts.length > 0 ? parts.join(', and ') : 'nothing needed changing';
|
|
60
|
+
const label = checkpoint.label.length > 60 ? checkpoint.label.slice(0, 59) + '…' : checkpoint.label;
|
|
61
|
+
let message = `Undone: the folder is back to how it was before "${label}" - ${what}.`;
|
|
62
|
+
if (result.failed.length > 0)
|
|
63
|
+
message += ` ${plural(result.failed.length, 'file')} could not be put back: ${listNames(result.failed)}.`;
|
|
64
|
+
if (checkpoint.skipped.length > 0)
|
|
65
|
+
message += ' Some things were never backed up, so they were left as they are.';
|
|
66
|
+
return {
|
|
67
|
+
message,
|
|
68
|
+
historyNote: `[The person used /undo. The project folder was put back to how it was before their message "${checkpoint.label}". Any changes made after that are gone - check files again before relying on earlier results.]`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
session.setBusyNote(null);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function plural(count, word) {
|
|
76
|
+
return `${count} ${word}${count === 1 ? '' : 's'}`;
|
|
77
|
+
}
|
|
78
|
+
function listNames(files) {
|
|
79
|
+
const shown = files.slice(0, 3).join(', ');
|
|
80
|
+
return files.length > 3 ? `${shown} and ${files.length - 3} more` : shown;
|
|
81
|
+
}
|
|
82
|
+
// Is this location outside the project folder (so /undo cannot reverse a change to it)?
|
|
83
|
+
export function isOutsideProject(target, folder = process.cwd()) {
|
|
84
|
+
const relative = path.relative(path.resolve(folder), path.resolve(folder, target));
|
|
85
|
+
return relative.startsWith('..') || path.isAbsolute(relative);
|
|
86
|
+
}
|
|
87
|
+
// Could this command change things outside the project folder? A careful, simple
|
|
88
|
+
// check: absolute paths elsewhere, the home folder, parent folders, administrator
|
|
89
|
+
// rights, and installing programs for the whole computer.
|
|
90
|
+
export function commandMayReachOutside(command, folder = process.cwd()) {
|
|
91
|
+
if (/(^|[\s;|&])sudo\b|\bbrew\s|\s(-g|--global)\b|\bapt(-get)?\s|\bchoco\s|\bwinget\s/.test(command))
|
|
92
|
+
return true;
|
|
93
|
+
if (/(^|[\s'"=])~(\/|\s|$)|(^|[\s'"=/])\.\.(\/|\\|\s|$)|\$HOME|%USERPROFILE%/.test(command))
|
|
94
|
+
return true;
|
|
95
|
+
for (const match of command.matchAll(/(?:^|[\s'"=])((?:\/|[A-Za-z]:\\)[^\s'"]*)/g)) {
|
|
96
|
+
const candidate = match[1];
|
|
97
|
+
if (/^\/dev\/null$/.test(candidate))
|
|
98
|
+
continue;
|
|
99
|
+
if (isOutsideProject(candidate, folder))
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
// Automatic backups ("checkpoints") of the project folder, so /undo can put things
|
|
5
|
+
// back. Researched first: Claude Code keeps copies of files its own edit tools
|
|
6
|
+
// change, but cannot undo what a shell command did; Cline and Gemini CLI snapshot
|
|
7
|
+
// the whole folder into a hidden git repository, which covers commands but needs git
|
|
8
|
+
// installed - not a given on a non-coder's machine. This takes the whole-folder
|
|
9
|
+
// approach without git: every file is stored once by its content fingerprint
|
|
10
|
+
// (SHA-256), and a checkpoint is a list of which fingerprint each file had. Files
|
|
11
|
+
// that have not changed since the last checkpoint are recognised by size and
|
|
12
|
+
// modified time and are not read again.
|
|
13
|
+
// Folders that are rebuilt automatically or are the person's own version history.
|
|
14
|
+
// Restoring a .git folder from a copy could damage that history, so it is left alone.
|
|
15
|
+
export const SKIPPED_FOLDERS = new Set(['.git', 'node_modules', '.venv', 'venv', '__pycache__', '.DS_Store']);
|
|
16
|
+
// Limits that keep a checkpoint fast. Past them the checkpoint is marked incomplete
|
|
17
|
+
// and the person is told plainly that some things could not be backed up.
|
|
18
|
+
export const MAX_FILE_BYTES = 50 * 1024 * 1024;
|
|
19
|
+
export const MAX_FILES = 20_000;
|
|
20
|
+
export const MAX_TOTAL_BYTES = 1024 * 1024 * 1024;
|
|
21
|
+
export const KEEP_CHECKPOINTS = 50;
|
|
22
|
+
export function projectKey(folder) {
|
|
23
|
+
return createHash('sha256').update(path.resolve(folder)).digest('hex').slice(0, 16);
|
|
24
|
+
}
|
|
25
|
+
export class CheckpointStore {
|
|
26
|
+
folder;
|
|
27
|
+
dir;
|
|
28
|
+
constructor(storeRoot, folder) {
|
|
29
|
+
this.folder = folder;
|
|
30
|
+
this.dir = path.join(storeRoot, projectKey(folder));
|
|
31
|
+
}
|
|
32
|
+
blobPath(hash) {
|
|
33
|
+
return path.join(this.dir, 'blobs', hash.slice(0, 2), hash);
|
|
34
|
+
}
|
|
35
|
+
checkpointsDir() {
|
|
36
|
+
return path.join(this.dir, 'checkpoints');
|
|
37
|
+
}
|
|
38
|
+
async list() {
|
|
39
|
+
let names;
|
|
40
|
+
try {
|
|
41
|
+
names = await fs.readdir(this.checkpointsDir());
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
const checkpoints = [];
|
|
47
|
+
for (const name of names.filter((n) => n.endsWith('.json')).sort()) {
|
|
48
|
+
try {
|
|
49
|
+
checkpoints.push(JSON.parse(await fs.readFile(path.join(this.checkpointsDir(), name), 'utf8')));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// A damaged checkpoint record is ignored rather than breaking undo entirely.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return checkpoints.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
|
|
56
|
+
}
|
|
57
|
+
// Walks the folder and records every file, storing any content not stored before.
|
|
58
|
+
async create(label, now = Date.now(), kind = 'turn') {
|
|
59
|
+
const previous = (await this.list()).at(-1);
|
|
60
|
+
const files = {};
|
|
61
|
+
const folders = [];
|
|
62
|
+
const skipped = [];
|
|
63
|
+
let count = 0;
|
|
64
|
+
let total = 0;
|
|
65
|
+
let overLimit = false;
|
|
66
|
+
const walk = async (relative) => {
|
|
67
|
+
let entries;
|
|
68
|
+
try {
|
|
69
|
+
entries = await fs.readdir(path.join(this.folder, relative), { withFileTypes: true });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
skipped.push(`the folder "${relative || '.'}" could not be read`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
76
|
+
const rel = relative ? path.join(relative, entry.name) : entry.name;
|
|
77
|
+
if (SKIPPED_FOLDERS.has(entry.name))
|
|
78
|
+
continue;
|
|
79
|
+
if (entry.isSymbolicLink()) {
|
|
80
|
+
skipped.push(`"${rel}" is a shortcut (link), which is not backed up`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (entry.isDirectory()) {
|
|
84
|
+
folders.push(rel);
|
|
85
|
+
await walk(rel);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!entry.isFile())
|
|
89
|
+
continue;
|
|
90
|
+
const full = path.join(this.folder, rel);
|
|
91
|
+
let stat;
|
|
92
|
+
try {
|
|
93
|
+
stat = await fs.stat(full);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
skipped.push(`"${rel}" could not be read`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (stat.size > MAX_FILE_BYTES) {
|
|
100
|
+
skipped.push(`"${rel}" is too large to back up`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (count >= MAX_FILES || total + stat.size > MAX_TOTAL_BYTES) {
|
|
104
|
+
overLimit = true;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const known = previous?.files[rel];
|
|
108
|
+
if (known && known.size === stat.size && known.mtimeMs === stat.mtimeMs && (await this.hasBlob(known.hash))) {
|
|
109
|
+
files[rel] = { ...known, mode: stat.mode };
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
try {
|
|
113
|
+
const content = await fs.readFile(full);
|
|
114
|
+
const hash = createHash('sha256').update(content).digest('hex');
|
|
115
|
+
await this.storeBlob(hash, content);
|
|
116
|
+
files[rel] = { hash, size: stat.size, mtimeMs: stat.mtimeMs, mode: stat.mode };
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
skipped.push(`"${rel}" could not be read`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
count += 1;
|
|
124
|
+
total += stat.size;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
await walk('');
|
|
128
|
+
if (overLimit)
|
|
129
|
+
skipped.push('the folder is too big to back up completely');
|
|
130
|
+
const checkpoint = {
|
|
131
|
+
id: `${now}-${Math.random().toString(36).slice(2, 8)}`,
|
|
132
|
+
kind,
|
|
133
|
+
createdAt: now,
|
|
134
|
+
label: label.slice(0, 200),
|
|
135
|
+
folder: path.resolve(this.folder),
|
|
136
|
+
files,
|
|
137
|
+
folders,
|
|
138
|
+
skipped,
|
|
139
|
+
};
|
|
140
|
+
await fs.mkdir(this.checkpointsDir(), { recursive: true });
|
|
141
|
+
await fs.writeFile(path.join(this.checkpointsDir(), `${checkpoint.id}.json`), JSON.stringify(checkpoint));
|
|
142
|
+
await this.prune();
|
|
143
|
+
return checkpoint;
|
|
144
|
+
}
|
|
145
|
+
async hasBlob(hash) {
|
|
146
|
+
try {
|
|
147
|
+
await fs.access(this.blobPath(hash));
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async storeBlob(hash, content) {
|
|
155
|
+
const target = this.blobPath(hash);
|
|
156
|
+
if (await this.hasBlob(hash))
|
|
157
|
+
return;
|
|
158
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
159
|
+
// Written beside the target, then renamed, so a crash never leaves half a copy.
|
|
160
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
161
|
+
await fs.writeFile(temporary, content);
|
|
162
|
+
await fs.rename(temporary, target);
|
|
163
|
+
}
|
|
164
|
+
// Puts the folder back exactly as it was at the checkpoint: changed and deleted
|
|
165
|
+
// files come back, and files created since are removed. A checkpoint of the
|
|
166
|
+
// current state is taken first, so nothing is ever lost by undoing.
|
|
167
|
+
async restore(checkpoint, now = Date.now()) {
|
|
168
|
+
const current = await this.create(`before undoing "${checkpoint.label}"`, now, 'before-undo');
|
|
169
|
+
const restored = [];
|
|
170
|
+
const removed = [];
|
|
171
|
+
const failed = [];
|
|
172
|
+
for (const [rel, record] of Object.entries(checkpoint.files)) {
|
|
173
|
+
if (current.files[rel]?.hash === record.hash)
|
|
174
|
+
continue;
|
|
175
|
+
const full = path.join(this.folder, rel);
|
|
176
|
+
try {
|
|
177
|
+
const content = await fs.readFile(this.blobPath(record.hash));
|
|
178
|
+
await fs.mkdir(path.dirname(full), { recursive: true });
|
|
179
|
+
await fs.writeFile(full, content);
|
|
180
|
+
await fs.chmod(full, record.mode & 0o777);
|
|
181
|
+
restored.push(rel);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
failed.push(rel);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Files created since are removed - but only when both copies are complete. If
|
|
188
|
+
// anything was skipped, a file missing from the checkpoint might simply not have
|
|
189
|
+
// been backed up, so nothing is deleted blind.
|
|
190
|
+
const incomplete = checkpoint.skipped.length > 0 || current.skipped.length > 0;
|
|
191
|
+
for (const rel of Object.keys(current.files)) {
|
|
192
|
+
if (rel in checkpoint.files || incomplete)
|
|
193
|
+
continue;
|
|
194
|
+
try {
|
|
195
|
+
await fs.rm(path.join(this.folder, rel));
|
|
196
|
+
removed.push(rel);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
failed.push(rel);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Folders created since the checkpoint are removed if they are now empty.
|
|
203
|
+
for (const rel of [...current.folders].sort((a, b) => b.length - a.length)) {
|
|
204
|
+
if (checkpoint.folders.includes(rel) || incomplete)
|
|
205
|
+
continue;
|
|
206
|
+
try {
|
|
207
|
+
await fs.rmdir(path.join(this.folder, rel));
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// Not empty (or already gone) - left as it is.
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// The undone checkpoint is used up, so the next /undo goes one step further back.
|
|
214
|
+
await fs.rm(path.join(this.checkpointsDir(), `${checkpoint.id}.json`), { force: true });
|
|
215
|
+
return { checkpoint, restored: restored.sort(), removed: removed.sort(), failed: failed.sort() };
|
|
216
|
+
}
|
|
217
|
+
// The most recent checkpoint that /undo would go back to.
|
|
218
|
+
async latestUndoable() {
|
|
219
|
+
return (await this.list()).filter((checkpoint) => checkpoint.kind !== 'before-undo').at(-1);
|
|
220
|
+
}
|
|
221
|
+
// Keeps the most recent checkpoints and deletes stored content nothing refers to.
|
|
222
|
+
async prune() {
|
|
223
|
+
const all = await this.list();
|
|
224
|
+
const excess = all.slice(0, Math.max(0, all.length - KEEP_CHECKPOINTS));
|
|
225
|
+
if (excess.length === 0)
|
|
226
|
+
return;
|
|
227
|
+
for (const checkpoint of excess) {
|
|
228
|
+
await fs.rm(path.join(this.checkpointsDir(), `${checkpoint.id}.json`), { force: true });
|
|
229
|
+
}
|
|
230
|
+
const referenced = new Set(all.slice(excess.length).flatMap((checkpoint) => Object.values(checkpoint.files).map((f) => f.hash)));
|
|
231
|
+
const blobsRoot = path.join(this.dir, 'blobs');
|
|
232
|
+
for (const prefix of await fs.readdir(blobsRoot).catch(() => [])) {
|
|
233
|
+
for (const hash of await fs.readdir(path.join(blobsRoot, prefix)).catch(() => [])) {
|
|
234
|
+
if (!referenced.has(hash))
|
|
235
|
+
await fs.rm(path.join(blobsRoot, prefix, hash), { force: true });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
package/dist/commands/clear.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { session } from '../state/session.js';
|
|
2
2
|
import { resetStickySession } from '../providers/openrouter.js';
|
|
3
|
+
import { resetResearchGate } from '../agent/research-gate.js';
|
|
3
4
|
// Starts fresh: wipes the screen and the conversation the model remembers.
|
|
4
5
|
export function clearConversation() {
|
|
5
6
|
session.clearTranscript();
|
|
6
7
|
session.setHistory([]);
|
|
7
8
|
session.setLastReasoning('');
|
|
8
9
|
resetStickySession();
|
|
10
|
+
resetResearchGate();
|
|
9
11
|
}
|
package/dist/commands/help.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export const COMMANDS = [
|
|
2
2
|
{ command: '/help', description: 'show this list' },
|
|
3
3
|
{ command: '/model', description: 'pick a different AI model' },
|
|
4
|
-
{ command: '/keys', description: 'add or remove
|
|
5
|
-
{ command: '/
|
|
4
|
+
{ command: '/keys', description: 'add or remove keys' },
|
|
5
|
+
{ command: '/address', description: 'change how Jeeves addresses you' },
|
|
6
|
+
{ command: '/verbose', description: "show the model's thinking and technical details" },
|
|
7
|
+
{ command: '/undo', description: 'put the project folder back to how it was before the last change' },
|
|
6
8
|
{ command: '/clear', description: 'start a fresh conversation and clear the screen' },
|
|
7
9
|
{ command: '/exit', description: 'quit' },
|
|
8
10
|
];
|
|
@@ -10,8 +12,9 @@ export const KEY_BINDINGS = [
|
|
|
10
12
|
{ command: 'up down', description: 'move in lists' },
|
|
11
13
|
{ command: 'Enter', description: 'select' },
|
|
12
14
|
{ command: 'Esc', description: 'go back' },
|
|
13
|
-
{ command: 'Tab', description: 'zoom a footer bar' },
|
|
14
15
|
{ command: 'Ctrl+R', description: "show the model's last thinking" },
|
|
15
16
|
{ command: 'y / n', description: 'allow or deny a permission request' },
|
|
16
|
-
{ command: 'arrows', description: 'scroll the conversation up and down; Page Up / Page Down jump a whole screen' },
|
|
17
|
+
{ command: 'arrows', description: 'scroll the conversation up and down; Page Up / Page Down jump a whole screen; End returns to the newest' },
|
|
18
|
+
{ command: 'wheel', description: 'the trackpad or mouse wheel scrolls the conversation too' },
|
|
19
|
+
{ command: 'copying', description: 'hold Fn and drag to select text (Option in iTerm2, Shift elsewhere)' },
|
|
17
20
|
];
|
package/dist/commands/keys.js
CHANGED
|
@@ -10,6 +10,6 @@ export function describeKeySource(source) {
|
|
|
10
10
|
if (source === 'keychain')
|
|
11
11
|
return 'key stored in your Mac keychain';
|
|
12
12
|
if (source === 'env')
|
|
13
|
-
return 'key in
|
|
13
|
+
return 'key in a local file - add it with /keys to store it safely';
|
|
14
14
|
return 'no key';
|
|
15
15
|
}
|
package/dist/commands/verbose.js
CHANGED
|
@@ -4,5 +4,5 @@ import { setVerbosePreference } from '../platform/config.js';
|
|
|
4
4
|
export function toggleVerbose() {
|
|
5
5
|
session.setVerbose(!session.verbose);
|
|
6
6
|
setVerbosePreference(session.verbose);
|
|
7
|
-
return session.verbose ? '
|
|
7
|
+
return session.verbose ? "Showing the model's thinking and technical details: on" : "Showing the model's thinking and technical details: off";
|
|
8
8
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { session } from '../state/session.js';
|
|
5
|
+
import { getAddress, setAddress } from '../platform/config.js';
|
|
6
|
+
import { isMouseSequence } from '../ink/mouse.js';
|
|
7
|
+
// The first-launch question, in Jeeves' own voice, asked once before the project
|
|
8
|
+
// picker whenever no address is saved; /address reopens the same screen later.
|
|
9
|
+
export function AddressPrompt({ rows }) {
|
|
10
|
+
const [value, setValue] = useState('');
|
|
11
|
+
const firstRun = session.launchStage === 'address';
|
|
12
|
+
useInput((input, key) => {
|
|
13
|
+
if (isMouseSequence(input))
|
|
14
|
+
return;
|
|
15
|
+
if (key.return) {
|
|
16
|
+
const address = value.trim() || getAddress() || 'Sir';
|
|
17
|
+
setAddress(address.slice(0, 30));
|
|
18
|
+
session.addressDone();
|
|
19
|
+
session.addNotice(`Very good - I shall address you as ${address.slice(0, 30)}.`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (key.backspace || key.delete) {
|
|
23
|
+
setValue((v) => v.slice(0, -1));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (!input || key.ctrl || key.meta)
|
|
27
|
+
return;
|
|
28
|
+
setValue((v) => (v.length >= 30 ? v : v + input));
|
|
29
|
+
});
|
|
30
|
+
return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Text, { dimColor: true, children: "Good evening. Before we begin \u2014 how shall I address you? Sir, Madam, or something else?" }), _jsx(Box, { flexGrow: 1, justifyContent: "center", flexDirection: "column", minHeight: 1, children: _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Address: " }), _jsx(Text, { children: value }), _jsx(Text, { inverse: true, children: " " })] }) }), firstRun ? _jsx(Text, { dimColor: true, children: "type your title \u00B7 Enter save" }) : _jsx(Text, { dimColor: true, children: "type a new title \u00B7 Enter save \u00B7 unchanged keeps the current one" })] }));
|
|
31
|
+
}
|