@tianmucreations/jeeves 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/bin/jeeves +2 -0
  4. package/dist/agent/context.js +50 -0
  5. package/dist/agent/errors.js +41 -0
  6. package/dist/agent/loop.js +84 -0
  7. package/dist/agent/permissions.js +27 -0
  8. package/dist/app.js +68 -0
  9. package/dist/commands/clear.js +9 -0
  10. package/dist/commands/help.js +17 -0
  11. package/dist/commands/keys.js +15 -0
  12. package/dist/commands/model.js +4 -0
  13. package/dist/commands/verbose.js +8 -0
  14. package/dist/components/AlternateScreen.js +74 -0
  15. package/dist/components/Footer.js +114 -0
  16. package/dist/components/Header.js +6 -0
  17. package/dist/components/HelpView.js +14 -0
  18. package/dist/components/Input.js +76 -0
  19. package/dist/components/KeysManager.js +281 -0
  20. package/dist/components/ModelPicker.js +457 -0
  21. package/dist/components/ProjectPicker.js +334 -0
  22. package/dist/components/TrafficLight.js +116 -0
  23. package/dist/components/Transcript.js +23 -0
  24. package/dist/components/UsageBar.js +35 -0
  25. package/dist/components/transcript-layout.js +103 -0
  26. package/dist/index.js +53 -0
  27. package/dist/ink/AlternateScreen.js +106 -0
  28. package/dist/keys/store.js +58 -0
  29. package/dist/models/filter.js +4 -0
  30. package/dist/models/registry.js +112 -0
  31. package/dist/platform/config.js +60 -0
  32. package/dist/platform/paths.js +60 -0
  33. package/dist/platform/shell.js +9 -0
  34. package/dist/providers/index.js +165 -0
  35. package/dist/providers/ollama.js +103 -0
  36. package/dist/providers/openrouter.js +109 -0
  37. package/dist/providers/types.js +1 -0
  38. package/dist/providers/zai.js +104 -0
  39. package/dist/state/session.js +315 -0
  40. package/dist/tools/index.js +106 -0
  41. package/dist/tools/listDir.js +55 -0
  42. package/dist/tools/readFile.js +15 -0
  43. package/dist/tools/runBash.js +22 -0
  44. package/dist/tools/writeFile.js +15 -0
  45. package/package.json +62 -0
@@ -0,0 +1,103 @@
1
+ import { streamText, stepCountIs } from 'ai';
2
+ import { createOpenRouter } from '@openrouter/ai-sdk-provider';
3
+ const OLLAMA_BASE_URL = 'http://localhost:11434/v1';
4
+ const MAX_TOOL_STEPS = 25;
5
+ // Ollama exposes an OpenAI-compatible endpoint, so the OpenRouter client speaks to it directly.
6
+ export function createOllamaProvider() {
7
+ const client = createOpenRouter({
8
+ apiKey: 'ollama',
9
+ baseURL: OLLAMA_BASE_URL,
10
+ compatibility: 'compatible',
11
+ });
12
+ return {
13
+ id: 'ollama',
14
+ name: 'Ollama',
15
+ async stream({ modelId, messages, tools, onToken, onReasoning, onToolCall }) {
16
+ const result = streamText({
17
+ model: client.chat(modelId),
18
+ messages,
19
+ tools,
20
+ stopWhen: stepCountIs(MAX_TOOL_STEPS),
21
+ });
22
+ let streamedError = null;
23
+ for await (const part of result.stream) {
24
+ if (part.type === 'text-delta') {
25
+ onToken(part.text);
26
+ }
27
+ else if (part.type === 'reasoning-delta') {
28
+ onReasoning(part.text);
29
+ }
30
+ else if (part.type === 'tool-call') {
31
+ onToolCall({ id: part.toolCallId, name: part.toolName });
32
+ }
33
+ else if (part.type === 'error') {
34
+ streamedError = part.error;
35
+ }
36
+ }
37
+ const text = await result.text;
38
+ if (!text && streamedError !== null) {
39
+ throw streamedError instanceof Error ? streamedError : new Error(String(streamedError));
40
+ }
41
+ const finalStep = await result.finalStep;
42
+ const responseMessages = await result.responseMessages;
43
+ const usage = await result.usage;
44
+ return {
45
+ text,
46
+ reasoning: finalStep.reasoningText ?? '',
47
+ messages: responseMessages,
48
+ usage: {
49
+ input: usage.inputTokens ?? 0,
50
+ output: usage.outputTokens ?? 0,
51
+ total: usage.totalTokens ?? 0,
52
+ cached: 0,
53
+ },
54
+ cost: 0,
55
+ rateLimit: null,
56
+ };
57
+ },
58
+ };
59
+ }
60
+ // Maps the local Ollama model list (/api/tags) into picker rows.
61
+ export function mapOllamaTags(body) {
62
+ if (typeof body !== 'object' || body === null)
63
+ return [];
64
+ const models = body.models;
65
+ if (!Array.isArray(models))
66
+ return [];
67
+ const out = [];
68
+ for (const raw of models) {
69
+ if (typeof raw !== 'object' || raw === null)
70
+ continue;
71
+ const entry = raw;
72
+ const name = typeof entry.name === 'string' ? entry.name : '';
73
+ if (!name)
74
+ continue;
75
+ const info = (typeof entry.model_info === 'object' && entry.model_info !== null ? entry.model_info : {});
76
+ out.push({
77
+ id: name,
78
+ name,
79
+ contextLength: typeof info.context_length === 'number' ? info.context_length : 0,
80
+ promptPrice: 0,
81
+ completionPrice: 0,
82
+ // Assumption: current Ollama models handle tools through the OpenAI-compatible endpoint.
83
+ supportedParameters: ['tools'],
84
+ provider: 'ollama',
85
+ });
86
+ }
87
+ return out;
88
+ }
89
+ export async function listLocalOllamaModels() {
90
+ const response = await fetch('http://localhost:11434/api/tags', { signal: AbortSignal.timeout(1500) });
91
+ if (!response.ok)
92
+ throw new Error('local Ollama did not respond');
93
+ return mapOllamaTags(await response.json());
94
+ }
95
+ export async function isOllamaOnline() {
96
+ try {
97
+ const response = await fetch('http://localhost:11434/api/tags', { signal: AbortSignal.timeout(1200) });
98
+ return response.ok;
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ }
@@ -0,0 +1,109 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { streamText, stepCountIs } from 'ai';
3
+ import { createOpenRouter } from '@openrouter/ai-sdk-provider';
4
+ // Assumption: the spec's "maxSteps" is called stopWhen/stepCountIs in AI SDK 7 (the installed version); same cap of 25.
5
+ const MAX_TOOL_STEPS = 25;
6
+ // Sticky routing: one id per conversation, sent with every request. OpenRouter uses
7
+ // it directly as the routing key, pinning the conversation to one provider endpoint
8
+ // so the repeated context hits that endpoint's prompt cache (cache reads bill at
9
+ // roughly 0.1-0.5x the fresh-input price). /clear rotates it for a fresh conversation.
10
+ let stickySessionId = randomUUID();
11
+ export function resetStickySession() {
12
+ stickySessionId = randomUUID();
13
+ }
14
+ export function getStickySessionId() {
15
+ return stickySessionId;
16
+ }
17
+ // Reads the account's credit position from OpenRouter; returns null when unavailable.
18
+ export async function fetchCreditInfo(apiKey) {
19
+ try {
20
+ const response = await fetch('https://openrouter.ai/api/v1/credits', {
21
+ headers: { Authorization: `Bearer ${apiKey}` },
22
+ });
23
+ if (!response.ok)
24
+ return null;
25
+ const body = (await response.json());
26
+ const data = body.data;
27
+ if (!data || typeof data.total_credits !== 'number' || typeof data.total_usage !== 'number') {
28
+ return null;
29
+ }
30
+ return {
31
+ used: data.total_usage,
32
+ limit: data.total_credits,
33
+ remaining: data.total_credits - data.total_usage,
34
+ };
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ function headerNumber(headers, name) {
41
+ const raw = headers?.[name];
42
+ if (raw === undefined)
43
+ return null;
44
+ const parsed = Number(raw);
45
+ return Number.isFinite(parsed) ? parsed : null;
46
+ }
47
+ export function createOpenRouterProvider(apiKey) {
48
+ const openrouter = createOpenRouter({ apiKey });
49
+ return {
50
+ id: 'openrouter',
51
+ name: 'OpenRouter',
52
+ async stream({ modelId, messages, tools, onToken, onReasoning, onToolCall }) {
53
+ const result = streamText({
54
+ model: openrouter.chat(modelId),
55
+ messages,
56
+ tools,
57
+ stopWhen: stepCountIs(MAX_TOOL_STEPS),
58
+ providerOptions: {
59
+ openrouter: {
60
+ session_id: stickySessionId,
61
+ },
62
+ },
63
+ });
64
+ let streamedError = null;
65
+ for await (const part of result.stream) {
66
+ if (part.type === 'text-delta') {
67
+ onToken(part.text);
68
+ }
69
+ else if (part.type === 'reasoning-delta') {
70
+ onReasoning(part.text);
71
+ }
72
+ else if (part.type === 'tool-call') {
73
+ onToolCall({ id: part.toolCallId, name: part.toolName });
74
+ }
75
+ else if (part.type === 'error') {
76
+ streamedError = part.error;
77
+ }
78
+ }
79
+ const text = await result.text;
80
+ if (!text && streamedError !== null) {
81
+ throw streamedError instanceof Error ? streamedError : new Error(String(streamedError));
82
+ }
83
+ const finalStep = await result.finalStep;
84
+ const reasoning = finalStep.reasoningText ?? '';
85
+ const responseMessages = await result.responseMessages;
86
+ const usage = await result.usage;
87
+ const headers = finalStep.response.headers;
88
+ const limit = headerNumber(headers, 'x-ratelimit-limit');
89
+ const remaining = headerNumber(headers, 'x-ratelimit-remaining');
90
+ const reset = headerNumber(headers, 'x-ratelimit-reset');
91
+ const rateLimit = limit !== null || remaining !== null
92
+ ? { limit: limit ?? 0, remaining: remaining ?? 0, reset: reset ?? 0 }
93
+ : null;
94
+ return {
95
+ text,
96
+ reasoning,
97
+ messages: responseMessages,
98
+ usage: {
99
+ input: usage.inputTokens ?? 0,
100
+ output: usage.outputTokens ?? 0,
101
+ total: usage.totalTokens ?? 0,
102
+ cached: usage.inputTokenDetails?.cacheReadTokens ?? 0,
103
+ },
104
+ cost: 0,
105
+ rateLimit,
106
+ };
107
+ },
108
+ };
109
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,104 @@
1
+ import { streamText, stepCountIs } from 'ai';
2
+ import { createAnthropic } from '@ai-sdk/anthropic';
3
+ // Z.ai's coding endpoint speaks the Anthropic Messages protocol, so the official
4
+ // Anthropic adapter talks to it directly. The base URL must include /v1 because
5
+ // the appends /messages to it.
6
+ const ZAI_BASE_URL = 'https://api.z.ai/api/anthropic/v1';
7
+ const MAX_TOOL_STEPS = 25;
8
+ // The GLM Coding Plan is flat-rate, so prices are meaningless per token; the picker
9
+ // shows "included" instead of a dollar figure (spec: no misleading numbers).
10
+ export const ZAI_MODELS = [
11
+ {
12
+ id: 'glm-5.3',
13
+ name: 'GLM-5.3',
14
+ contextLength: 200_000,
15
+ promptPrice: 0,
16
+ completionPrice: 0,
17
+ supportedParameters: ['tools'],
18
+ provider: 'zai',
19
+ priceLabel: 'included',
20
+ },
21
+ {
22
+ id: 'glm-5.3-flash',
23
+ name: 'GLM-5.3-Flash',
24
+ contextLength: 200_000,
25
+ promptPrice: 0,
26
+ completionPrice: 0,
27
+ supportedParameters: ['tools'],
28
+ provider: 'zai',
29
+ priceLabel: 'included',
30
+ },
31
+ {
32
+ id: 'glm-5.2',
33
+ name: 'GLM-5.2',
34
+ contextLength: 1_000_000,
35
+ promptPrice: 0,
36
+ completionPrice: 0,
37
+ supportedParameters: ['tools'],
38
+ provider: 'zai',
39
+ priceLabel: 'included',
40
+ },
41
+ {
42
+ id: 'glm-4.6',
43
+ name: 'GLM-4.6',
44
+ contextLength: 200_000,
45
+ promptPrice: 0,
46
+ completionPrice: 0,
47
+ supportedParameters: ['tools'],
48
+ provider: 'zai',
49
+ priceLabel: 'included',
50
+ },
51
+ ];
52
+ export function createZaiProvider(apiKey) {
53
+ const client = createAnthropic({
54
+ apiKey,
55
+ baseURL: ZAI_BASE_URL,
56
+ });
57
+ return {
58
+ id: 'zai',
59
+ name: 'Z.ai',
60
+ async stream({ modelId, messages, tools, onToken, onReasoning, onToolCall }) {
61
+ const result = streamText({
62
+ model: client(modelId),
63
+ messages,
64
+ tools,
65
+ stopWhen: stepCountIs(MAX_TOOL_STEPS),
66
+ });
67
+ let streamedError = null;
68
+ for await (const part of result.stream) {
69
+ if (part.type === 'text-delta') {
70
+ onToken(part.text);
71
+ }
72
+ else if (part.type === 'reasoning-delta') {
73
+ onReasoning(part.text);
74
+ }
75
+ else if (part.type === 'tool-call') {
76
+ onToolCall({ id: part.toolCallId, name: part.toolName });
77
+ }
78
+ else if (part.type === 'error') {
79
+ streamedError = part.error;
80
+ }
81
+ }
82
+ const text = await result.text;
83
+ if (!text && streamedError !== null) {
84
+ throw streamedError instanceof Error ? streamedError : new Error(String(streamedError));
85
+ }
86
+ const finalStep = await result.finalStep;
87
+ const responseMessages = await result.responseMessages;
88
+ const usage = await result.usage;
89
+ return {
90
+ text,
91
+ reasoning: finalStep.reasoningText ?? '',
92
+ messages: responseMessages,
93
+ usage: {
94
+ input: usage.inputTokens ?? 0,
95
+ output: usage.outputTokens ?? 0,
96
+ total: usage.totalTokens ?? 0,
97
+ cached: usage.inputTokenDetails?.cacheReadTokens ?? 0,
98
+ },
99
+ cost: 0,
100
+ rateLimit: null,
101
+ };
102
+ },
103
+ };
104
+ }
@@ -0,0 +1,315 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ // Assumption: z-ai/glm-5.3's context length; the Phase 6 model registry replaces this constant.
3
+ export const DEFAULT_CONTEXT_TOKENS = 1_310_720;
4
+ class SessionStore {
5
+ model = 'z-ai/glm-5.3';
6
+ providerId = 'openrouter';
7
+ providerName = 'OpenRouter';
8
+ status = 'idle';
9
+ approvalPending = false;
10
+ verbose = false;
11
+ showLastReasoning = false;
12
+ pickerOpen = false;
13
+ keysOpen = false;
14
+ wizardActive = false;
15
+ helpOpen = false;
16
+ exitRequested = false;
17
+ launchStage = 'project';
18
+ wizardFromLaunch = false;
19
+ recentProjects = [];
20
+ models = [];
21
+ modelsNote = '';
22
+ favorites = [];
23
+ recents = [];
24
+ tokensIn = 0;
25
+ tokensOut = 0;
26
+ tokensCached = 0;
27
+ cost = 0;
28
+ footerExpanded = null;
29
+ hiddenMetrics = [];
30
+ creditUsed = null;
31
+ creditRemaining = null;
32
+ creditLimit = null;
33
+ creditIsAccount = false;
34
+ spend = 0;
35
+ rateLimit = null;
36
+ transcript = [];
37
+ history = [];
38
+ lastReasoning = '';
39
+ // Lines the transcript view is scrolled up from the bottom; 0 means "follow the newest".
40
+ transcriptScrollUp = 0;
41
+ turnEvents = [];
42
+ creditBaselineUsed = null;
43
+ nextId = 1;
44
+ version = 0;
45
+ reasoningEntryId = null;
46
+ listeners = new Set();
47
+ subscribe = (listener) => {
48
+ this.listeners.add(listener);
49
+ return () => this.listeners.delete(listener);
50
+ };
51
+ getSnapshot = () => this.version;
52
+ emit() {
53
+ this.version += 1;
54
+ for (const listener of this.listeners)
55
+ listener();
56
+ }
57
+ setStatus(status) {
58
+ this.status = status;
59
+ this.emit();
60
+ }
61
+ setActiveApproval() {
62
+ this.approvalPending = true;
63
+ this.status = 'awaiting-approval';
64
+ this.emit();
65
+ }
66
+ clearActiveApproval() {
67
+ this.approvalPending = false;
68
+ this.status = 'working';
69
+ this.emit();
70
+ }
71
+ setVerbose(value) {
72
+ this.verbose = value;
73
+ this.emit();
74
+ }
75
+ toggleShowLastReasoning() {
76
+ this.showLastReasoning = !this.showLastReasoning;
77
+ this.emit();
78
+ }
79
+ addUser(text) {
80
+ this.transcript = [...this.transcript, { id: this.nextId++, kind: 'user', text }];
81
+ this.emit();
82
+ }
83
+ addNotice(text) {
84
+ this.transcript = [...this.transcript, { id: this.nextId++, kind: 'notice', text }];
85
+ this.emit();
86
+ }
87
+ addError(text) {
88
+ this.transcript = [...this.transcript, { id: this.nextId++, kind: 'error', text }];
89
+ this.emit();
90
+ }
91
+ startAssistant() {
92
+ const id = this.nextId++;
93
+ this.transcript = [...this.transcript, { id, kind: 'assistant', text: '' }];
94
+ this.emit();
95
+ return id;
96
+ }
97
+ appendToken(id, token) {
98
+ this.transcript = this.transcript.map((entry) => entry.id === id && entry.kind === 'assistant' ? { ...entry, text: entry.text + token } : entry);
99
+ this.emit();
100
+ }
101
+ setAssistantText(id, text) {
102
+ this.transcript = this.transcript.map((entry) => entry.id === id && entry.kind === 'assistant' ? { ...entry, text } : entry);
103
+ this.emit();
104
+ }
105
+ finishAssistant(id) {
106
+ this.transcript = this.transcript.filter((entry) => !(entry.id === id && entry.kind === 'assistant' && entry.text === ''));
107
+ this.emit();
108
+ }
109
+ beginTurn() {
110
+ this.reasoningEntryId = null;
111
+ }
112
+ // Reasoning only renders while verbose is on; each model step gets its own block.
113
+ appendReasoning(delta) {
114
+ if (this.reasoningEntryId === null) {
115
+ const id = this.nextId++;
116
+ this.transcript = [...this.transcript, { id, kind: 'reasoning', text: '' }];
117
+ this.reasoningEntryId = id;
118
+ }
119
+ const target = this.reasoningEntryId;
120
+ this.transcript = this.transcript.map((entry) => entry.id === target && entry.kind === 'reasoning' ? { ...entry, text: entry.text + delta } : entry);
121
+ this.emit();
122
+ }
123
+ closeReasoningEntry() {
124
+ this.reasoningEntryId = null;
125
+ }
126
+ addToolLine(tool, summary, state) {
127
+ const id = this.nextId++;
128
+ this.transcript = [...this.transcript, { id, kind: 'tool', data: { tool, summary, state, label: '' } }];
129
+ this.emit();
130
+ return id;
131
+ }
132
+ updateToolLine(id, patch) {
133
+ this.transcript = this.transcript.map((entry) => entry.kind === 'tool' && entry.id === id ? { ...entry, data: { ...entry.data, ...patch } } : entry);
134
+ this.emit();
135
+ }
136
+ setModel(model) {
137
+ this.model = model;
138
+ this.emit();
139
+ }
140
+ setProvider(providerId) {
141
+ this.providerId = providerId;
142
+ this.emit();
143
+ }
144
+ setModels(models, note) {
145
+ this.models = models;
146
+ this.modelsNote = note;
147
+ this.emit();
148
+ }
149
+ setFavorites(models) {
150
+ this.favorites = models;
151
+ this.emit();
152
+ }
153
+ setRecents(models) {
154
+ this.recents = models.slice(0, 10);
155
+ this.emit();
156
+ }
157
+ openPicker() {
158
+ if (this.status === 'working' || this.approvalPending) {
159
+ this.addNotice('The model picker opens between tasks.');
160
+ return;
161
+ }
162
+ this.pickerOpen = true;
163
+ this.emit();
164
+ }
165
+ closePicker() {
166
+ this.pickerOpen = false;
167
+ this.emit();
168
+ }
169
+ openKeys() {
170
+ if (this.status === 'working' || this.approvalPending) {
171
+ this.addNotice('The key screens open between tasks.');
172
+ return;
173
+ }
174
+ this.keysOpen = true;
175
+ this.emit();
176
+ }
177
+ closeKeys() {
178
+ this.keysOpen = false;
179
+ this.emit();
180
+ }
181
+ startWizard(fromLaunch = false) {
182
+ this.wizardFromLaunch = fromLaunch;
183
+ this.wizardActive = true;
184
+ this.emit();
185
+ }
186
+ endWizard() {
187
+ this.wizardActive = false;
188
+ const shouldOpenModelPicker = this.wizardFromLaunch;
189
+ this.wizardFromLaunch = false;
190
+ this.emit();
191
+ if (shouldOpenModelPicker) {
192
+ this.openPicker();
193
+ }
194
+ }
195
+ launchComplete() {
196
+ this.launchStage = 'ready';
197
+ this.emit();
198
+ }
199
+ setRecentProjects(projects) {
200
+ this.recentProjects = projects.slice(0, 10);
201
+ this.emit();
202
+ }
203
+ openHelp() {
204
+ this.helpOpen = true;
205
+ this.emit();
206
+ }
207
+ closeHelp() {
208
+ this.helpOpen = false;
209
+ this.emit();
210
+ }
211
+ requestExit() {
212
+ this.exitRequested = true;
213
+ this.emit();
214
+ }
215
+ clearTranscript() {
216
+ this.transcript = [];
217
+ this.transcriptScrollUp = 0;
218
+ this.emit();
219
+ }
220
+ // Internal scrolling for the alternate-screen era: the terminal's own scrollback is
221
+ // unavailable there, so the transcript region scrolls itself. Positive deltas go up
222
+ // (older); the count is clamped to zero so the view can never sink past the newest.
223
+ scrollTranscript(delta) {
224
+ if (delta === 0)
225
+ return;
226
+ const next = Math.max(0, this.transcriptScrollUp + delta);
227
+ if (next === this.transcriptScrollUp)
228
+ return;
229
+ this.transcriptScrollUp = next;
230
+ this.emit();
231
+ }
232
+ followTranscript() {
233
+ if (this.transcriptScrollUp === 0)
234
+ return;
235
+ this.transcriptScrollUp = 0;
236
+ this.emit();
237
+ }
238
+ setHistory(messages) {
239
+ this.history = messages;
240
+ }
241
+ setLastReasoning(text) {
242
+ this.lastReasoning = text;
243
+ }
244
+ addUsage(input, output, cost, cached = 0) {
245
+ this.tokensIn += input;
246
+ this.tokensOut += output;
247
+ this.tokensCached += cached;
248
+ this.cost += cost;
249
+ this.turnEvents.push({ t: Date.now(), tokens: input + output });
250
+ const cutoff = Date.now() - 60_000;
251
+ this.turnEvents = this.turnEvents.filter((event) => event.t >= cutoff);
252
+ this.emit();
253
+ }
254
+ // Share of input tokens served from the provider's prompt cache - the at-a-glance
255
+ // "is the money-saving working" number for the footer.
256
+ cacheHitRate() {
257
+ if (this.tokensIn <= 0)
258
+ return null;
259
+ return Math.min(1, this.tokensCached / this.tokensIn);
260
+ }
261
+ tokensPerMinute() {
262
+ const cutoff = Date.now() - 60_000;
263
+ return this.turnEvents.filter((event) => event.t >= cutoff).reduce((sum, event) => sum + event.tokens, 0);
264
+ }
265
+ setHiddenMetrics(metrics) {
266
+ this.hiddenMetrics = metrics;
267
+ this.emit();
268
+ }
269
+ // Tab is an optional zoom-in: it expands one metric into a wide bar, cycling
270
+ // through them and wrapping back to the always-visible compact view.
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();
277
+ 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
+ }
286
+ this.emit();
287
+ }
288
+ escapeFooter() {
289
+ this.footerExpanded = null;
290
+ this.emit();
291
+ }
292
+ setCredit(used, limit, remaining, accountWide) {
293
+ if (this.creditBaselineUsed === null)
294
+ this.creditBaselineUsed = used;
295
+ this.spend = Math.max(0, used - this.creditBaselineUsed);
296
+ this.creditUsed = used;
297
+ this.creditLimit = limit;
298
+ this.creditRemaining = remaining;
299
+ this.creditIsAccount = accountWide;
300
+ this.emit();
301
+ }
302
+ setRateLimit(info) {
303
+ this.rateLimit = info;
304
+ this.emit();
305
+ }
306
+ // Assumption: roughly four characters per token is accurate enough for the context meter.
307
+ estimateContextTokens() {
308
+ return Math.ceil(JSON.stringify(this.history).length / 4);
309
+ }
310
+ }
311
+ export const session = new SessionStore();
312
+ export function useSession() {
313
+ useSyncExternalStore(session.subscribe, session.getSnapshot);
314
+ return session;
315
+ }