@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/providers/ollama.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { streamText, stepCountIs } from 'ai';
|
|
2
2
|
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
|
3
|
+
import { prepareStepFor } from './step-control.js';
|
|
3
4
|
const OLLAMA_BASE_URL = 'http://localhost:11434/v1';
|
|
4
5
|
const MAX_TOOL_STEPS = 25;
|
|
5
6
|
// Ollama exposes an OpenAI-compatible endpoint, so the OpenRouter client speaks to it directly.
|
|
@@ -12,12 +13,26 @@ export function createOllamaProvider() {
|
|
|
12
13
|
return {
|
|
13
14
|
id: 'ollama',
|
|
14
15
|
name: 'Ollama',
|
|
15
|
-
async stream({ modelId, messages, tools, onToken, onReasoning, onToolCall }) {
|
|
16
|
+
async stream({ modelId, messages, tools, instructions, onToken, onReasoning, onToolCall, beforeStep, abortSignal }) {
|
|
16
17
|
const result = streamText({
|
|
18
|
+
instructions,
|
|
19
|
+
// A stalled request must never wedge the app in the working state forever -
|
|
20
|
+
// but a long, healthy job must not be cut off either. So the limits are on
|
|
21
|
+
// silence, not on the whole job: 90 seconds between pieces of a reply (verified
|
|
22
|
+
// to abort a real stream), 2 minutes for the first piece once the reply has
|
|
23
|
+
// started, and 10 minutes for any single step, which also covers a request that
|
|
24
|
+
// never starts answering. (A plain number here limits the entire multi-step
|
|
25
|
+
// job; a 3-minute one killed healthy jobs mid-way in testing.)
|
|
26
|
+
timeout: { firstChunkMs: 120_000, chunkMs: 90_000, stepMs: 600_000 },
|
|
17
27
|
model: client.chat(modelId),
|
|
18
28
|
messages,
|
|
19
29
|
tools,
|
|
20
30
|
stopWhen: stepCountIs(MAX_TOOL_STEPS),
|
|
31
|
+
prepareStep: prepareStepFor(beforeStep, (id) => client.chat(id)),
|
|
32
|
+
abortSignal,
|
|
33
|
+
// The library prints every failure to the screen by default, over Jeeves's
|
|
34
|
+
// window; the failure still arrives below and is explained in plain English.
|
|
35
|
+
onError: () => { },
|
|
21
36
|
});
|
|
22
37
|
let streamedError = null;
|
|
23
38
|
for await (const part of result.stream) {
|
|
@@ -34,10 +49,12 @@ export function createOllamaProvider() {
|
|
|
34
49
|
streamedError = part.error;
|
|
35
50
|
}
|
|
36
51
|
}
|
|
37
|
-
|
|
38
|
-
|
|
52
|
+
// The real stream error (a rejected key, a missing model) must win over the
|
|
53
|
+
// SDK's generic no-output error, which would otherwise mask the cause.
|
|
54
|
+
if (streamedError !== null) {
|
|
39
55
|
throw streamedError instanceof Error ? streamedError : new Error(String(streamedError));
|
|
40
56
|
}
|
|
57
|
+
const text = await result.text;
|
|
41
58
|
const finalStep = await result.finalStep;
|
|
42
59
|
const responseMessages = await result.responseMessages;
|
|
43
60
|
const usage = await result.usage;
|
|
@@ -89,7 +106,7 @@ export function mapOllamaTags(body) {
|
|
|
89
106
|
export async function listLocalOllamaModels() {
|
|
90
107
|
const response = await fetch('http://localhost:11434/api/tags', { signal: AbortSignal.timeout(1500) });
|
|
91
108
|
if (!response.ok)
|
|
92
|
-
throw new Error('
|
|
109
|
+
throw new Error('Ollama on this computer did not respond');
|
|
93
110
|
return mapOllamaTags(await response.json());
|
|
94
111
|
}
|
|
95
112
|
export async function isOllamaOnline() {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { streamText, stepCountIs } from 'ai';
|
|
3
3
|
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
|
4
|
+
import { prepareStepFor, stepCost } from './step-control.js';
|
|
4
5
|
// Assumption: the spec's "maxSteps" is called stopWhen/stepCountIs in AI SDK 7 (the installed version); same cap of 25.
|
|
5
6
|
const MAX_TOOL_STEPS = 25;
|
|
6
7
|
// Sticky routing: one id per conversation, sent with every request. OpenRouter uses
|
|
@@ -37,6 +38,22 @@ export async function fetchCreditInfo(apiKey) {
|
|
|
37
38
|
return null;
|
|
38
39
|
}
|
|
39
40
|
}
|
|
41
|
+
// The key's all-time spend in dollars, from GET /api/v1/key (field "usage",
|
|
42
|
+
// confirmed against a live response). Used to work out today's spend in local time.
|
|
43
|
+
export async function fetchKeyUsage(apiKey) {
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetch('https://openrouter.ai/api/v1/key', {
|
|
46
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
47
|
+
});
|
|
48
|
+
if (!response.ok)
|
|
49
|
+
return null;
|
|
50
|
+
const body = (await response.json());
|
|
51
|
+
return typeof body.data?.usage === 'number' ? body.data.usage : null;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
40
57
|
function headerNumber(headers, name) {
|
|
41
58
|
const raw = headers?.[name];
|
|
42
59
|
if (raw === undefined)
|
|
@@ -49,12 +66,27 @@ export function createOpenRouterProvider(apiKey) {
|
|
|
49
66
|
return {
|
|
50
67
|
id: 'openrouter',
|
|
51
68
|
name: 'OpenRouter',
|
|
52
|
-
async stream({ modelId, messages, tools, onToken, onReasoning, onToolCall }) {
|
|
69
|
+
async stream({ modelId, messages, tools, instructions, onToken, onReasoning, onToolCall, beforeStep, abortSignal }) {
|
|
53
70
|
const result = streamText({
|
|
54
|
-
|
|
71
|
+
instructions,
|
|
72
|
+
// A stalled request must never wedge the app in the working state forever -
|
|
73
|
+
// but a long, healthy job must not be cut off either. So the limits are on
|
|
74
|
+
// silence, not on the whole job: 90 seconds between pieces of a reply (verified
|
|
75
|
+
// to abort a real stream), 2 minutes for the first piece once the reply has
|
|
76
|
+
// started, and 10 minutes for any single step, which also covers a request that
|
|
77
|
+
// never starts answering. (A plain number here limits the entire multi-step
|
|
78
|
+
// job; a 3-minute one killed healthy jobs mid-way in testing.)
|
|
79
|
+
timeout: { firstChunkMs: 120_000, chunkMs: 90_000, stepMs: 600_000 },
|
|
80
|
+
// Usage accounting makes OpenRouter report each step's exact cost.
|
|
81
|
+
model: openrouter.chat(modelId, { usage: { include: true } }),
|
|
55
82
|
messages,
|
|
56
83
|
tools,
|
|
57
84
|
stopWhen: stepCountIs(MAX_TOOL_STEPS),
|
|
85
|
+
prepareStep: prepareStepFor(beforeStep, (id) => openrouter.chat(id, { usage: { include: true } })),
|
|
86
|
+
abortSignal,
|
|
87
|
+
// The library prints every failure to the screen by default, over Jeeves's
|
|
88
|
+
// window; the failure still arrives below and is explained in plain English.
|
|
89
|
+
onError: () => { },
|
|
58
90
|
providerOptions: {
|
|
59
91
|
openrouter: {
|
|
60
92
|
session_id: stickySessionId,
|
|
@@ -76,10 +108,12 @@ export function createOpenRouterProvider(apiKey) {
|
|
|
76
108
|
streamedError = part.error;
|
|
77
109
|
}
|
|
78
110
|
}
|
|
79
|
-
|
|
80
|
-
|
|
111
|
+
// The real stream error (a rejected key, a missing model) must win over the
|
|
112
|
+
// SDK's generic no-output error, which would otherwise mask the cause.
|
|
113
|
+
if (streamedError !== null) {
|
|
81
114
|
throw streamedError instanceof Error ? streamedError : new Error(String(streamedError));
|
|
82
115
|
}
|
|
116
|
+
const text = await result.text;
|
|
83
117
|
const finalStep = await result.finalStep;
|
|
84
118
|
const reasoning = finalStep.reasoningText ?? '';
|
|
85
119
|
const responseMessages = await result.responseMessages;
|
|
@@ -103,6 +137,7 @@ export function createOpenRouterProvider(apiKey) {
|
|
|
103
137
|
},
|
|
104
138
|
cost: 0,
|
|
105
139
|
rateLimit,
|
|
140
|
+
stepCosts: (await result.steps).map(stepCost),
|
|
106
141
|
};
|
|
107
142
|
},
|
|
108
143
|
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Failed tool actions in one step. A person saying no to a permission is not a failure.
|
|
2
|
+
export function countToolFailures(content) {
|
|
3
|
+
// Nor is an action held until research is done (research-gate.ts).
|
|
4
|
+
return content.filter((part) => part.type === 'tool-error' && !String(part.error).includes('Permission denied by the user') && !String(part.error).includes('Held for research:')).length;
|
|
5
|
+
}
|
|
6
|
+
// OpenRouter's own cost figure for one step, from its usage accounting.
|
|
7
|
+
export function stepCost(step) {
|
|
8
|
+
const cost = step.providerMetadata?.openrouter?.usage?.cost;
|
|
9
|
+
return typeof cost === 'number' ? cost : 0;
|
|
10
|
+
}
|
|
11
|
+
// costOf: what a finished step cost - OpenRouter's own figure unless the service
|
|
12
|
+
// works it out from a price list (the direct connections).
|
|
13
|
+
export function prepareStepFor(beforeStep, modelFor, costOf = stepCost) {
|
|
14
|
+
if (!beforeStep)
|
|
15
|
+
return undefined;
|
|
16
|
+
return async ({ stepNumber, steps, messages }) => {
|
|
17
|
+
const control = await beforeStep({
|
|
18
|
+
stepNumber,
|
|
19
|
+
stepFailures: steps.map((step) => countToolFailures(step.content)),
|
|
20
|
+
stepCosts: steps.map(costOf),
|
|
21
|
+
messages,
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
...(control.modelId ? { model: modelFor(control.modelId) } : {}),
|
|
25
|
+
...(control.messages ? { messages: control.messages } : {}),
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
}
|
package/dist/providers/zai.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { streamText, stepCountIs } from 'ai';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
//
|
|
5
|
-
// the
|
|
6
|
-
|
|
2
|
+
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
|
3
|
+
import { prepareStepFor } from './step-control.js';
|
|
4
|
+
// The GLM Coding Plan endpoint: OpenAI Chat Completions protocol at
|
|
5
|
+
// https://api.z.ai/api/coding/paas/v4 - NOT the standard /api/paas/v4, which is
|
|
6
|
+
// the pay-per-token API. The OpenRouter client speaks the OpenAI protocol, so it
|
|
7
|
+
// talks to the coding endpoint directly (the same trick the Ollama adapter uses
|
|
8
|
+
// for its OpenAI-compatible endpoint).
|
|
9
|
+
export const ZAI_CODING_BASE_URL = 'https://api.z.ai/api/coding/paas/v4';
|
|
7
10
|
const MAX_TOOL_STEPS = 25;
|
|
8
11
|
// The GLM Coding Plan is flat-rate, so prices are meaningless per token; the picker
|
|
9
12
|
// shows "included" instead of a dollar figure (spec: no misleading numbers).
|
|
@@ -50,19 +53,34 @@ export const ZAI_MODELS = [
|
|
|
50
53
|
},
|
|
51
54
|
];
|
|
52
55
|
export function createZaiProvider(apiKey) {
|
|
53
|
-
const client =
|
|
56
|
+
const client = createOpenRouter({
|
|
54
57
|
apiKey,
|
|
55
|
-
baseURL:
|
|
58
|
+
baseURL: ZAI_CODING_BASE_URL,
|
|
59
|
+
compatibility: 'compatible',
|
|
56
60
|
});
|
|
57
61
|
return {
|
|
58
62
|
id: 'zai',
|
|
59
63
|
name: 'Z.ai',
|
|
60
|
-
async stream({ modelId, messages, tools, onToken, onReasoning, onToolCall }) {
|
|
64
|
+
async stream({ modelId, messages, tools, instructions, onToken, onReasoning, onToolCall, beforeStep, abortSignal }) {
|
|
61
65
|
const result = streamText({
|
|
62
|
-
|
|
66
|
+
instructions,
|
|
67
|
+
// A stalled request must never wedge the app in the working state forever -
|
|
68
|
+
// but a long, healthy job must not be cut off either. So the limits are on
|
|
69
|
+
// silence, not on the whole job: 90 seconds between pieces of a reply (verified
|
|
70
|
+
// to abort a real stream), 2 minutes for the first piece once the reply has
|
|
71
|
+
// started, and 10 minutes for any single step, which also covers a request that
|
|
72
|
+
// never starts answering. (A plain number here limits the entire multi-step
|
|
73
|
+
// job; a 3-minute one killed healthy jobs mid-way in testing.)
|
|
74
|
+
timeout: { firstChunkMs: 120_000, chunkMs: 90_000, stepMs: 600_000 },
|
|
75
|
+
model: client.chat(modelId),
|
|
63
76
|
messages,
|
|
64
77
|
tools,
|
|
65
78
|
stopWhen: stepCountIs(MAX_TOOL_STEPS),
|
|
79
|
+
prepareStep: prepareStepFor(beforeStep, (id) => client.chat(id)),
|
|
80
|
+
abortSignal,
|
|
81
|
+
// The library prints every failure to the screen by default, over Jeeves's
|
|
82
|
+
// window; the failure still arrives below and is explained in plain English.
|
|
83
|
+
onError: () => { },
|
|
66
84
|
});
|
|
67
85
|
let streamedError = null;
|
|
68
86
|
for await (const part of result.stream) {
|
|
@@ -79,10 +97,12 @@ export function createZaiProvider(apiKey) {
|
|
|
79
97
|
streamedError = part.error;
|
|
80
98
|
}
|
|
81
99
|
}
|
|
82
|
-
|
|
83
|
-
|
|
100
|
+
// The real stream error (a rejected key, a missing model) must win over the
|
|
101
|
+
// SDK's generic no-output error, which would otherwise mask the cause.
|
|
102
|
+
if (streamedError !== null) {
|
|
84
103
|
throw streamedError instanceof Error ? streamedError : new Error(String(streamedError));
|
|
85
104
|
}
|
|
105
|
+
const text = await result.text;
|
|
86
106
|
const finalStep = await result.finalStep;
|
|
87
107
|
const responseMessages = await result.responseMessages;
|
|
88
108
|
const usage = await result.usage;
|
package/dist/state/session.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { useSyncExternalStore } from 'react';
|
|
2
|
+
import { AUTO_MODEL_ID } from '../agent/auto-ids.js';
|
|
2
3
|
// Assumption: z-ai/glm-5.3's context length; the Phase 6 model registry replaces this constant.
|
|
3
4
|
export const DEFAULT_CONTEXT_TOKENS = 1_310_720;
|
|
4
5
|
class SessionStore {
|
|
5
|
-
|
|
6
|
+
// Auto until the person chooses otherwise (decided 18 Sept): someone who
|
|
7
|
+
// leaves the model list without picking still gets the recommended experience.
|
|
8
|
+
model = AUTO_MODEL_ID;
|
|
6
9
|
providerId = 'openrouter';
|
|
7
10
|
providerName = 'OpenRouter';
|
|
8
11
|
status = 'idle';
|
|
@@ -14,7 +17,8 @@ class SessionStore {
|
|
|
14
17
|
wizardActive = false;
|
|
15
18
|
helpOpen = false;
|
|
16
19
|
exitRequested = false;
|
|
17
|
-
launchStage = '
|
|
20
|
+
launchStage = 'address';
|
|
21
|
+
addressOpen = false;
|
|
18
22
|
wizardFromLaunch = false;
|
|
19
23
|
recentProjects = [];
|
|
20
24
|
models = [];
|
|
@@ -25,21 +29,36 @@ class SessionStore {
|
|
|
25
29
|
tokensOut = 0;
|
|
26
30
|
tokensCached = 0;
|
|
27
31
|
cost = 0;
|
|
28
|
-
footerExpanded = null;
|
|
29
|
-
hiddenMetrics = [];
|
|
30
32
|
creditUsed = null;
|
|
31
33
|
creditRemaining = null;
|
|
32
34
|
creditLimit = null;
|
|
33
35
|
creditIsAccount = false;
|
|
34
|
-
|
|
36
|
+
// Spent today on the OpenRouter key (local calendar day); null until first read.
|
|
37
|
+
todaySpend = null;
|
|
38
|
+
// When a flat-rate plan (Z.ai) has used up its allowance: the reset time it gave
|
|
39
|
+
// (HH:MM, or '' if none was given); null while the plan has allowance.
|
|
40
|
+
planResetAt = null;
|
|
41
|
+
// In Auto mode, the model actually working right now (worker or expert).
|
|
42
|
+
activeModel = null;
|
|
43
|
+
// True while quiet housekeeping (a summary) is running - shown in the info bar.
|
|
44
|
+
tidying = false;
|
|
45
|
+
// A short note for the info bar while something quick runs, like 'backing up…'.
|
|
46
|
+
busyNote = null;
|
|
47
|
+
// Something the model must be told with the next message (for example, that /undo ran).
|
|
48
|
+
pendingContextNote = null;
|
|
49
|
+
// The daily spending limit in dollars, and any extra allowance granted today.
|
|
50
|
+
dailyLimit = 3;
|
|
51
|
+
dailyExtra = 0;
|
|
35
52
|
rateLimit = null;
|
|
36
53
|
transcript = [];
|
|
37
54
|
history = [];
|
|
38
55
|
lastReasoning = '';
|
|
39
56
|
// Lines the transcript view is scrolled up from the bottom; 0 means "follow the newest".
|
|
40
57
|
transcriptScrollUp = 0;
|
|
58
|
+
// The furthest the transcript can scroll up (contentHeight - viewportHeight),
|
|
59
|
+
// reported by the Transcript from its live measurements.
|
|
60
|
+
transcriptScrollMax = Number.POSITIVE_INFINITY;
|
|
41
61
|
turnEvents = [];
|
|
42
|
-
creditBaselineUsed = null;
|
|
43
62
|
nextId = 1;
|
|
44
63
|
version = 0;
|
|
45
64
|
reasoningEntryId = null;
|
|
@@ -196,6 +215,29 @@ class SessionStore {
|
|
|
196
215
|
this.launchStage = 'ready';
|
|
197
216
|
this.emit();
|
|
198
217
|
}
|
|
218
|
+
// The address question runs before the project picker on first launch only;
|
|
219
|
+
// index.tsx skips straight to the picker when an address is already saved.
|
|
220
|
+
skipAddressStage() {
|
|
221
|
+
if (this.launchStage === 'address') {
|
|
222
|
+
this.launchStage = 'project';
|
|
223
|
+
this.emit();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
addressDone() {
|
|
227
|
+
this.addressOpen = false;
|
|
228
|
+
if (this.launchStage === 'address') {
|
|
229
|
+
this.launchStage = 'project';
|
|
230
|
+
this.emit();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
openAddress() {
|
|
234
|
+
if (this.status === 'working' || this.approvalPending) {
|
|
235
|
+
this.addNotice('The address change happens between tasks.');
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
this.addressOpen = true;
|
|
239
|
+
this.emit();
|
|
240
|
+
}
|
|
199
241
|
setRecentProjects(projects) {
|
|
200
242
|
this.recentProjects = projects.slice(0, 10);
|
|
201
243
|
this.emit();
|
|
@@ -219,16 +261,25 @@ class SessionStore {
|
|
|
219
261
|
}
|
|
220
262
|
// Internal scrolling for the alternate-screen era: the terminal's own scrollback is
|
|
221
263
|
// unavailable there, so the transcript region scrolls itself. Positive deltas go up
|
|
222
|
-
// (older); the count is clamped
|
|
264
|
+
// (older); the count is clamped between zero (the newest) and the measured maximum
|
|
265
|
+
// (the oldest), so overshooting the top never leaves wheel or arrow presses to
|
|
266
|
+
// unwind before the view moves again.
|
|
223
267
|
scrollTranscript(delta) {
|
|
224
268
|
if (delta === 0)
|
|
225
269
|
return;
|
|
226
|
-
const next = Math.max(0, this.transcriptScrollUp + delta);
|
|
270
|
+
const next = Math.min(this.transcriptScrollMax, Math.max(0, this.transcriptScrollUp + delta));
|
|
227
271
|
if (next === this.transcriptScrollUp)
|
|
228
272
|
return;
|
|
229
273
|
this.transcriptScrollUp = next;
|
|
230
274
|
this.emit();
|
|
231
275
|
}
|
|
276
|
+
setTranscriptScrollMax(max) {
|
|
277
|
+
this.transcriptScrollMax = Math.max(0, max);
|
|
278
|
+
if (this.transcriptScrollUp > this.transcriptScrollMax) {
|
|
279
|
+
this.transcriptScrollUp = this.transcriptScrollMax;
|
|
280
|
+
this.emit();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
232
283
|
followTranscript() {
|
|
233
284
|
if (this.transcriptScrollUp === 0)
|
|
234
285
|
return;
|
|
@@ -262,41 +313,44 @@ class SessionStore {
|
|
|
262
313
|
const cutoff = Date.now() - 60_000;
|
|
263
314
|
return this.turnEvents.filter((event) => event.t >= cutoff).reduce((sum, event) => sum + event.tokens, 0);
|
|
264
315
|
}
|
|
265
|
-
|
|
266
|
-
this.
|
|
316
|
+
setCredit(used, limit, remaining, accountWide) {
|
|
317
|
+
this.creditUsed = used;
|
|
318
|
+
this.creditLimit = limit;
|
|
319
|
+
this.creditRemaining = remaining;
|
|
320
|
+
this.creditIsAccount = accountWide;
|
|
267
321
|
this.emit();
|
|
268
322
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if (
|
|
275
|
-
this.footerExpanded = null;
|
|
276
|
-
this.emit();
|
|
323
|
+
setTodaySpend(amount) {
|
|
324
|
+
this.todaySpend = amount;
|
|
325
|
+
this.emit();
|
|
326
|
+
}
|
|
327
|
+
setActiveModel(model) {
|
|
328
|
+
if (this.activeModel === model)
|
|
277
329
|
return;
|
|
278
|
-
|
|
279
|
-
if (this.footerExpanded === null) {
|
|
280
|
-
this.footerExpanded = visible[0];
|
|
281
|
-
}
|
|
282
|
-
else {
|
|
283
|
-
const index = visible.indexOf(this.footerExpanded);
|
|
284
|
-
this.footerExpanded = visible[index + 1] ?? null;
|
|
285
|
-
}
|
|
330
|
+
this.activeModel = model;
|
|
286
331
|
this.emit();
|
|
287
332
|
}
|
|
288
|
-
|
|
289
|
-
this.
|
|
333
|
+
setBusyNote(note) {
|
|
334
|
+
if (this.busyNote === note)
|
|
335
|
+
return;
|
|
336
|
+
this.busyNote = note;
|
|
290
337
|
this.emit();
|
|
291
338
|
}
|
|
292
|
-
|
|
293
|
-
if (this.
|
|
294
|
-
|
|
295
|
-
this.
|
|
296
|
-
this.
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
this.
|
|
339
|
+
setTidying(value) {
|
|
340
|
+
if (this.tidying === value)
|
|
341
|
+
return;
|
|
342
|
+
this.tidying = value;
|
|
343
|
+
this.emit();
|
|
344
|
+
}
|
|
345
|
+
setDailyLimit(limit, extra = this.dailyExtra) {
|
|
346
|
+
this.dailyLimit = limit;
|
|
347
|
+
this.dailyExtra = extra;
|
|
348
|
+
this.emit();
|
|
349
|
+
}
|
|
350
|
+
setPlanResetAt(value) {
|
|
351
|
+
if (this.planResetAt === value)
|
|
352
|
+
return;
|
|
353
|
+
this.planResetAt = value;
|
|
300
354
|
this.emit();
|
|
301
355
|
}
|
|
302
356
|
setRateLimit(info) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// "today" in the info bar is the user's own calendar day, not OpenRouter's. OpenRouter
|
|
2
|
+
// reports usage_daily for the current UTC day (openrouter.ai/docs, GET /api/v1/key),
|
|
3
|
+
// which for someone at UTC+7 would reset at 7am. Instead the key's all-time usage is
|
|
4
|
+
// read after each turn and today's spend is measured from a baseline for the local
|
|
5
|
+
// date. The zone is never stored: the date comes from the computer's clock each time,
|
|
6
|
+
// so it follows the user when they travel.
|
|
7
|
+
export function localDate(now) {
|
|
8
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
9
|
+
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
10
|
+
}
|
|
11
|
+
// When a new day starts, the last reading from yesterday becomes today's baseline,
|
|
12
|
+
// so anything spent after that reading counts towards today - it may over-count a
|
|
13
|
+
// little, never under-count. A reading older than yesterday is too stale to use.
|
|
14
|
+
export function nextSpendReading(prev, usage, now) {
|
|
15
|
+
const today = localDate(now);
|
|
16
|
+
if (!prev || usage < prev.last)
|
|
17
|
+
return { date: today, baseline: usage, last: usage };
|
|
18
|
+
if (prev.date === today)
|
|
19
|
+
return { ...prev, last: usage };
|
|
20
|
+
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
|
|
21
|
+
const baseline = prev.date === localDate(yesterday) ? prev.last : usage;
|
|
22
|
+
return { date: today, baseline, last: usage };
|
|
23
|
+
}
|
|
24
|
+
export function spentToday(reading) {
|
|
25
|
+
return Math.max(0, reading.last - reading.baseline);
|
|
26
|
+
}
|
package/dist/tools/index.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { tool } from 'ai';
|
|
2
2
|
import { session } from '../state/session.js';
|
|
3
|
-
import { requestApproval } from '../agent/permissions.js';
|
|
3
|
+
import { requestApproval, isReadOnlyBashCommand } from '../agent/permissions.js';
|
|
4
4
|
import { readFileSchema, runReadFile } from './readFile.js';
|
|
5
5
|
import { writeFileSchema, runWriteFile } from './writeFile.js';
|
|
6
6
|
import { listDirSchema, runListDir } from './listDir.js';
|
|
7
7
|
import { runBashSchema, runRunBash } from './runBash.js';
|
|
8
|
+
import { webSearchSchema, runWebSearch, readWebPageSchema, runReadWebPage } from './web/research.js';
|
|
9
|
+
import { ensureCheckpoint, isOutsideProject, commandMayReachOutside } from '../checkpoints/index.js';
|
|
10
|
+
import { resolveFromCwd } from '../platform/paths.js';
|
|
11
|
+
import { holdForWrite, holdForCommand, recordCommandResult, recordNote, noteResearchSchema, HELD_PREFIX, commandEditsFiles, } from '../agent/research-gate.js';
|
|
12
|
+
import { holdUntilReproduced } from '../agent/review.js';
|
|
8
13
|
// Assumption: every tool result is capped to keep huge outputs from flooding the conversation.
|
|
9
14
|
const MAX_RESULT_CHARS = 150_000;
|
|
10
15
|
function truncate(text) {
|
|
@@ -20,16 +25,38 @@ function describeError(error) {
|
|
|
20
25
|
export function plainToolFailure(error) {
|
|
21
26
|
const raw = describeError(error);
|
|
22
27
|
const text = raw.toLowerCase();
|
|
28
|
+
// Codes and meanings per the Node.js docs' "Common system errors" list.
|
|
23
29
|
if (text.includes('enoent'))
|
|
24
|
-
return 'file or folder
|
|
30
|
+
return "couldn't find that file or folder";
|
|
25
31
|
if (text.includes('eacces') || text.includes('eperm'))
|
|
26
|
-
return '
|
|
27
|
-
if (text.includes('
|
|
32
|
+
return "the computer wouldn't allow it";
|
|
33
|
+
if (text.includes('eisdir'))
|
|
34
|
+
return 'that is a folder, not a file';
|
|
35
|
+
if (text.includes('enotdir'))
|
|
36
|
+
return 'part of that location is not a folder';
|
|
37
|
+
if (text.includes('eexist'))
|
|
38
|
+
return 'something with that name already exists';
|
|
39
|
+
if (text.includes('enotempty'))
|
|
40
|
+
return 'that folder is not empty';
|
|
41
|
+
if (text.includes('no space left'))
|
|
42
|
+
return 'the disk is full';
|
|
43
|
+
if (text.includes('timed out') || text.includes('etimedout') || text.includes('stopped after') || text.includes("didn't finish"))
|
|
28
44
|
return 'took too long';
|
|
29
45
|
if (text.includes('binary file'))
|
|
30
46
|
return 'not a text file';
|
|
31
|
-
|
|
32
|
-
|
|
47
|
+
if (text.includes('web search needs an openrouter key'))
|
|
48
|
+
return 'needs an OpenRouter key (type /keys)';
|
|
49
|
+
if (text.includes('web search is not available'))
|
|
50
|
+
return 'web search is not available right now';
|
|
51
|
+
if (text.includes("couldn't open"))
|
|
52
|
+
return "that website wouldn't open";
|
|
53
|
+
if (text.includes('not a valid web address') || text.includes('only web pages'))
|
|
54
|
+
return 'not a web address';
|
|
55
|
+
if (text.includes('needs an interactive terminal'))
|
|
56
|
+
return 'that program needs typing in a window of its own';
|
|
57
|
+
// Anything unrecognised stays off screen; the model receives the full message
|
|
58
|
+
// and explains it in plain English.
|
|
59
|
+
return 'something unexpected went wrong';
|
|
33
60
|
}
|
|
34
61
|
function clip(text, max) {
|
|
35
62
|
return text.length > max ? text.slice(0, max - 1) + '…' : text;
|
|
@@ -42,8 +69,21 @@ function defineTool(config) {
|
|
|
42
69
|
// The SDK validates before execute; parsing again keeps this layer strictly typed.
|
|
43
70
|
const input = config.schema.parse(rawInput);
|
|
44
71
|
const summary = config.summarize(input);
|
|
45
|
-
const
|
|
46
|
-
if (
|
|
72
|
+
const held = (await config.hold?.(input)) ?? null;
|
|
73
|
+
if (held) {
|
|
74
|
+
const heldLine = session.addToolLine(config.name, summary, 'running');
|
|
75
|
+
session.updateToolLine(heldLine, {
|
|
76
|
+
state: 'held',
|
|
77
|
+
label: held.includes('reproduce the reported problem') ? 'waits until the problem is reproduced' : 'waits until the research is done',
|
|
78
|
+
});
|
|
79
|
+
throw new Error(held);
|
|
80
|
+
}
|
|
81
|
+
const needsPermission = typeof config.permission === 'function' ? config.permission(input) : config.permission;
|
|
82
|
+
const warning = config.warning?.(input) ?? null;
|
|
83
|
+
if (warning)
|
|
84
|
+
session.addNotice(warning);
|
|
85
|
+
const lineId = session.addToolLine(config.name, summary, needsPermission ? 'awaiting' : 'running');
|
|
86
|
+
if (needsPermission) {
|
|
47
87
|
const approved = await requestApproval();
|
|
48
88
|
if (!approved) {
|
|
49
89
|
session.updateToolLine(lineId, { state: 'declined' });
|
|
@@ -51,6 +91,16 @@ function defineTool(config) {
|
|
|
51
91
|
}
|
|
52
92
|
session.updateToolLine(lineId, { state: 'running' });
|
|
53
93
|
}
|
|
94
|
+
if (config.changesFiles?.(input)) {
|
|
95
|
+
const backup = await ensureCheckpoint();
|
|
96
|
+
if (!backup.ok) {
|
|
97
|
+
session.addNotice("I couldn't back up the project folder first, so this change couldn't be undone. Go ahead anyway? (y/n)");
|
|
98
|
+
if (!(await requestApproval())) {
|
|
99
|
+
session.updateToolLine(lineId, { state: 'declined' });
|
|
100
|
+
throw new Error(`Not done: the folder could not be backed up first (${backup.problem}), and the person chose not to go ahead.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
54
104
|
try {
|
|
55
105
|
const result = truncate(await config.run(input));
|
|
56
106
|
session.updateToolLine(lineId, { state: 'done', label: config.label(input, result) });
|
|
@@ -72,6 +122,7 @@ export const TOOLS = {
|
|
|
72
122
|
summarize: (input) => input.path,
|
|
73
123
|
label: (input) => `Read ${input.path}`,
|
|
74
124
|
run: runReadFile,
|
|
125
|
+
hold: (input) => holdUntilReproduced('readFile', input.path, false),
|
|
75
126
|
}),
|
|
76
127
|
listDir: defineTool({
|
|
77
128
|
name: 'listDir',
|
|
@@ -81,6 +132,7 @@ export const TOOLS = {
|
|
|
81
132
|
summarize: (input) => input.path,
|
|
82
133
|
label: (input, result) => `Listed ${input.path} (${result.split('\n').length} items)`,
|
|
83
134
|
run: runListDir,
|
|
135
|
+
hold: (input) => holdUntilReproduced('listDir', input.path, false),
|
|
84
136
|
}),
|
|
85
137
|
writeFile: defineTool({
|
|
86
138
|
name: 'writeFile',
|
|
@@ -90,17 +142,73 @@ export const TOOLS = {
|
|
|
90
142
|
summarize: (input) => `${input.path} (${input.content.length} characters)`,
|
|
91
143
|
label: () => 'Wrote 1 file',
|
|
92
144
|
run: runWriteFile,
|
|
145
|
+
hold: async (input) => holdUntilReproduced('writeFile', input.path, true) ?? (await holdForWrite(input.path, resolveFromCwd(input.path), process.cwd())),
|
|
146
|
+
changesFiles: () => true,
|
|
147
|
+
warning: (input) => isOutsideProject(input.path)
|
|
148
|
+
? `Heads up: ${input.path} is outside your project folder, so /undo can't reverse this change.`
|
|
149
|
+
: null,
|
|
150
|
+
}),
|
|
151
|
+
webSearch: defineTool({
|
|
152
|
+
name: 'webSearch',
|
|
153
|
+
description: 'Search the web. Returns titles, addresses and short snippets - a list of where to look, not checked facts.',
|
|
154
|
+
schema: webSearchSchema,
|
|
155
|
+
permission: false,
|
|
156
|
+
summarize: (input) => clip(input.query, 60),
|
|
157
|
+
label: (input) => `Searched ${clip(input.query, 60)}`,
|
|
158
|
+
run: runWebSearch,
|
|
159
|
+
}),
|
|
160
|
+
readWebPage: defineTool({
|
|
161
|
+
name: 'readWebPage',
|
|
162
|
+
description: 'Open a web page and find one fact on it. Returns the answer with the exact quote from the page, or says it is not stated there.',
|
|
163
|
+
schema: readWebPageSchema,
|
|
164
|
+
permission: false,
|
|
165
|
+
summarize: (input) => clip(input.url, 60),
|
|
166
|
+
label: (input) => `Read ${clip(input.url.replace(/^https?:\/\//, ''), 60)}`,
|
|
167
|
+
run: runReadWebPage,
|
|
93
168
|
}),
|
|
94
169
|
runBash: defineTool({
|
|
95
170
|
name: 'runBash',
|
|
96
171
|
description: 'Run a shell command and return its output.',
|
|
97
172
|
schema: runBashSchema,
|
|
98
|
-
|
|
173
|
+
// Read-only commands never ask (the allowlist lives in permissions.ts);
|
|
174
|
+
// everything else - writes, deletes, installs, network - still prompts.
|
|
175
|
+
permission: (input) => !isReadOnlyBashCommand(input.command),
|
|
99
176
|
summarize: (input) => clip(input.command, 60),
|
|
100
177
|
label: (input) => `Ran ${clip(input.command, 60)}`,
|
|
101
|
-
run:
|
|
178
|
+
run: async (input) => {
|
|
179
|
+
const output = await runRunBash(input);
|
|
180
|
+
// Looking around (a search that finds nothing exits 1) is not a problem to research.
|
|
181
|
+
if (isReadOnlyBashCommand(input.command))
|
|
182
|
+
return output;
|
|
183
|
+
const exit = output.match(/^exit code: (\d+|unknown)$/m)?.[1];
|
|
184
|
+
const note = recordCommandResult(input.command, exit === undefined || exit === 'unknown' ? null : Number(exit), output);
|
|
185
|
+
return note ? output + note : output;
|
|
186
|
+
},
|
|
187
|
+
hold: (input) => holdUntilReproduced('runBash', input.command, commandEditsFiles(input.command)) ??
|
|
188
|
+
(isReadOnlyBashCommand(input.command) ? null : holdForCommand(input.command)),
|
|
189
|
+
changesFiles: (input) => !isReadOnlyBashCommand(input.command),
|
|
190
|
+
warning: (input) => !isReadOnlyBashCommand(input.command) && commandMayReachOutside(input.command)
|
|
191
|
+
? "Heads up: this command may change things outside your project folder, which /undo can't reverse."
|
|
192
|
+
: null,
|
|
102
193
|
}),
|
|
103
194
|
};
|
|
195
|
+
// Research before building or patching: the note that releases a hold. Shown to the
|
|
196
|
+
// person in full, because code can make research happen but not make it good.
|
|
197
|
+
TOOLS.noteResearch = defineTool({
|
|
198
|
+
name: 'noteResearch',
|
|
199
|
+
description: 'Record your research before building something new (kind "build") or after the same failure twice (kind "problem"). List only pages you opened with readWebPage in this conversation.',
|
|
200
|
+
schema: noteResearchSchema,
|
|
201
|
+
permission: false,
|
|
202
|
+
summarize: (input) => clip(input.subject, 60),
|
|
203
|
+
label: (_input, result) => (result.startsWith('Recorded') ? 'Research noted' : 'Research note not accepted yet'),
|
|
204
|
+
run: async (input) => {
|
|
205
|
+
const outcome = recordNote(input);
|
|
206
|
+
if (outcome.shown)
|
|
207
|
+
session.addNotice(outcome.shown);
|
|
208
|
+
return outcome.reply;
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
export { HELD_PREFIX };
|
|
104
212
|
export function getTools() {
|
|
105
213
|
return TOOLS;
|
|
106
214
|
}
|