@tianmucreations/jeeves 0.2.1 → 0.3.1
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/LICENSE +37 -17
- package/README.md +82 -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 +174 -12
- package/dist/agent/permissions.js +186 -3
- 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/agent/trust.js +29 -0
- package/dist/app.js +31 -11
- 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 +8 -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 +115 -29
- 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 +92 -0
- package/dist/components/transcript-layout.js +27 -19
- 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 +71 -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 +110 -36
- package/dist/state/today-spend.js +26 -0
- package/dist/tools/index.js +123 -11
- 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 +33 -7
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,40 @@ 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;
|
|
61
|
+
// What is being typed in the input box (the window sizes the box to fit it).
|
|
62
|
+
inputText = '';
|
|
63
|
+
// Messages sent while Jeeves was busy, in order; each is sent when he finishes.
|
|
64
|
+
queued = [];
|
|
41
65
|
turnEvents = [];
|
|
42
|
-
creditBaselineUsed = null;
|
|
43
66
|
nextId = 1;
|
|
44
67
|
version = 0;
|
|
45
68
|
reasoningEntryId = null;
|
|
@@ -54,6 +77,22 @@ class SessionStore {
|
|
|
54
77
|
for (const listener of this.listeners)
|
|
55
78
|
listener();
|
|
56
79
|
}
|
|
80
|
+
setInputText(text) {
|
|
81
|
+
if (text === this.inputText)
|
|
82
|
+
return;
|
|
83
|
+
this.inputText = text;
|
|
84
|
+
this.emit();
|
|
85
|
+
}
|
|
86
|
+
queueMessage(text) {
|
|
87
|
+
this.queued = [...this.queued, text];
|
|
88
|
+
this.emit();
|
|
89
|
+
}
|
|
90
|
+
takeQueued() {
|
|
91
|
+
const [next, ...rest] = this.queued;
|
|
92
|
+
this.queued = rest;
|
|
93
|
+
this.emit();
|
|
94
|
+
return next;
|
|
95
|
+
}
|
|
57
96
|
setStatus(status) {
|
|
58
97
|
this.status = status;
|
|
59
98
|
this.emit();
|
|
@@ -196,6 +235,29 @@ class SessionStore {
|
|
|
196
235
|
this.launchStage = 'ready';
|
|
197
236
|
this.emit();
|
|
198
237
|
}
|
|
238
|
+
// The address question runs before the project picker on first launch only;
|
|
239
|
+
// index.tsx skips straight to the picker when an address is already saved.
|
|
240
|
+
skipAddressStage() {
|
|
241
|
+
if (this.launchStage === 'address') {
|
|
242
|
+
this.launchStage = 'project';
|
|
243
|
+
this.emit();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
addressDone() {
|
|
247
|
+
this.addressOpen = false;
|
|
248
|
+
if (this.launchStage === 'address') {
|
|
249
|
+
this.launchStage = 'project';
|
|
250
|
+
this.emit();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
openAddress() {
|
|
254
|
+
if (this.status === 'working' || this.approvalPending) {
|
|
255
|
+
this.addNotice('The address change happens between tasks.');
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
this.addressOpen = true;
|
|
259
|
+
this.emit();
|
|
260
|
+
}
|
|
199
261
|
setRecentProjects(projects) {
|
|
200
262
|
this.recentProjects = projects.slice(0, 10);
|
|
201
263
|
this.emit();
|
|
@@ -219,16 +281,25 @@ class SessionStore {
|
|
|
219
281
|
}
|
|
220
282
|
// Internal scrolling for the alternate-screen era: the terminal's own scrollback is
|
|
221
283
|
// unavailable there, so the transcript region scrolls itself. Positive deltas go up
|
|
222
|
-
// (older); the count is clamped
|
|
284
|
+
// (older); the count is clamped between zero (the newest) and the measured maximum
|
|
285
|
+
// (the oldest), so overshooting the top never leaves wheel or arrow presses to
|
|
286
|
+
// unwind before the view moves again.
|
|
223
287
|
scrollTranscript(delta) {
|
|
224
288
|
if (delta === 0)
|
|
225
289
|
return;
|
|
226
|
-
const next = Math.max(0, this.transcriptScrollUp + delta);
|
|
290
|
+
const next = Math.min(this.transcriptScrollMax, Math.max(0, this.transcriptScrollUp + delta));
|
|
227
291
|
if (next === this.transcriptScrollUp)
|
|
228
292
|
return;
|
|
229
293
|
this.transcriptScrollUp = next;
|
|
230
294
|
this.emit();
|
|
231
295
|
}
|
|
296
|
+
setTranscriptScrollMax(max) {
|
|
297
|
+
this.transcriptScrollMax = Math.max(0, max);
|
|
298
|
+
if (this.transcriptScrollUp > this.transcriptScrollMax) {
|
|
299
|
+
this.transcriptScrollUp = this.transcriptScrollMax;
|
|
300
|
+
this.emit();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
232
303
|
followTranscript() {
|
|
233
304
|
if (this.transcriptScrollUp === 0)
|
|
234
305
|
return;
|
|
@@ -262,41 +333,44 @@ class SessionStore {
|
|
|
262
333
|
const cutoff = Date.now() - 60_000;
|
|
263
334
|
return this.turnEvents.filter((event) => event.t >= cutoff).reduce((sum, event) => sum + event.tokens, 0);
|
|
264
335
|
}
|
|
265
|
-
|
|
266
|
-
this.
|
|
336
|
+
setCredit(used, limit, remaining, accountWide) {
|
|
337
|
+
this.creditUsed = used;
|
|
338
|
+
this.creditLimit = limit;
|
|
339
|
+
this.creditRemaining = remaining;
|
|
340
|
+
this.creditIsAccount = accountWide;
|
|
341
|
+
this.emit();
|
|
342
|
+
}
|
|
343
|
+
setTodaySpend(amount) {
|
|
344
|
+
this.todaySpend = amount;
|
|
267
345
|
this.emit();
|
|
268
346
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
tabFooter() {
|
|
272
|
-
const all = ['session', 'context', 'cache', 'today', 'credit', 'speed'];
|
|
273
|
-
const visible = all.filter((metric) => !this.hiddenMetrics.includes(metric));
|
|
274
|
-
if (visible.length === 0) {
|
|
275
|
-
this.footerExpanded = null;
|
|
276
|
-
this.emit();
|
|
347
|
+
setActiveModel(model) {
|
|
348
|
+
if (this.activeModel === model)
|
|
277
349
|
return;
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
350
|
+
this.activeModel = model;
|
|
351
|
+
this.emit();
|
|
352
|
+
}
|
|
353
|
+
setBusyNote(note) {
|
|
354
|
+
if (this.busyNote === note)
|
|
355
|
+
return;
|
|
356
|
+
this.busyNote = note;
|
|
286
357
|
this.emit();
|
|
287
358
|
}
|
|
288
|
-
|
|
289
|
-
this.
|
|
359
|
+
setTidying(value) {
|
|
360
|
+
if (this.tidying === value)
|
|
361
|
+
return;
|
|
362
|
+
this.tidying = value;
|
|
290
363
|
this.emit();
|
|
291
364
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
this.
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
this.
|
|
299
|
-
|
|
365
|
+
setDailyLimit(limit, extra = this.dailyExtra) {
|
|
366
|
+
this.dailyLimit = limit;
|
|
367
|
+
this.dailyExtra = extra;
|
|
368
|
+
this.emit();
|
|
369
|
+
}
|
|
370
|
+
setPlanResetAt(value) {
|
|
371
|
+
if (this.planResetAt === value)
|
|
372
|
+
return;
|
|
373
|
+
this.planResetAt = value;
|
|
300
374
|
this.emit();
|
|
301
375
|
}
|
|
302
376
|
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
|
+
}
|