@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.
Files changed (57) hide show
  1. package/LICENSE +37 -17
  2. package/README.md +82 -18
  3. package/bin/jeeves +8 -1
  4. package/dist/agent/auto-ids.js +66 -0
  5. package/dist/agent/auto.js +178 -0
  6. package/dist/agent/context.js +55 -13
  7. package/dist/agent/errors.js +83 -22
  8. package/dist/agent/expert-chat.js +33 -0
  9. package/dist/agent/housekeeping.js +55 -0
  10. package/dist/agent/loop.js +174 -12
  11. package/dist/agent/permissions.js +186 -3
  12. package/dist/agent/research-gate.js +267 -0
  13. package/dist/agent/review.js +135 -0
  14. package/dist/agent/spending.js +73 -0
  15. package/dist/agent/systemPrompt.js +112 -0
  16. package/dist/agent/trust.js +29 -0
  17. package/dist/app.js +31 -11
  18. package/dist/checkpoints/index.js +103 -0
  19. package/dist/checkpoints/store.js +239 -0
  20. package/dist/commands/address.js +5 -0
  21. package/dist/commands/clear.js +2 -0
  22. package/dist/commands/help.js +8 -4
  23. package/dist/commands/keys.js +1 -1
  24. package/dist/commands/verbose.js +1 -1
  25. package/dist/components/AddressPrompt.js +31 -0
  26. package/dist/components/Footer.js +74 -102
  27. package/dist/components/Input.js +115 -29
  28. package/dist/components/KeysManager.js +65 -20
  29. package/dist/components/ModelPicker.js +348 -75
  30. package/dist/components/ProjectPicker.js +4 -1
  31. package/dist/components/Transcript.js +29 -14
  32. package/dist/components/input-layout.js +92 -0
  33. package/dist/components/transcript-layout.js +27 -19
  34. package/dist/index.js +25 -7
  35. package/dist/ink/AlternateScreen.js +33 -16
  36. package/dist/ink/cursor.js +18 -0
  37. package/dist/ink/mouse.js +48 -0
  38. package/dist/keys/store.js +2 -1
  39. package/dist/models/registry.js +18 -2
  40. package/dist/platform/config.js +71 -7
  41. package/dist/providers/catalogue.js +293 -0
  42. package/dist/providers/direct-services.js +65 -0
  43. package/dist/providers/direct.js +145 -0
  44. package/dist/providers/index.js +123 -13
  45. package/dist/providers/models-snapshot.js +1037 -0
  46. package/dist/providers/ollama.js +21 -4
  47. package/dist/providers/openrouter.js +39 -4
  48. package/dist/providers/step-control.js +28 -0
  49. package/dist/providers/zai.js +31 -11
  50. package/dist/state/session.js +110 -36
  51. package/dist/state/today-spend.js +26 -0
  52. package/dist/tools/index.js +123 -11
  53. package/dist/tools/runBash.js +58 -11
  54. package/dist/tools/web/htmlToText.js +32 -0
  55. package/dist/tools/web/openrouterChat.js +31 -0
  56. package/dist/tools/web/research.js +191 -0
  57. package/package.json +33 -7
@@ -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
- const text = await result.text;
38
- if (!text && streamedError !== null) {
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('local Ollama did not respond');
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
- model: openrouter.chat(modelId),
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
- const text = await result.text;
80
- if (!text && streamedError !== null) {
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
+ }
@@ -1,9 +1,12 @@
1
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';
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 = createAnthropic({
56
+ const client = createOpenRouter({
54
57
  apiKey,
55
- baseURL: ZAI_BASE_URL,
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
- model: client(modelId),
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
- const text = await result.text;
83
- if (!text && streamedError !== null) {
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;
@@ -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
- model = 'z-ai/glm-5.3';
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 = 'project';
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
- spend = 0;
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 to zero so the view can never sink past the newest.
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
- setHiddenMetrics(metrics) {
266
- this.hiddenMetrics = metrics;
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
- // 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();
347
+ setActiveModel(model) {
348
+ if (this.activeModel === model)
277
349
  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
- }
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
- escapeFooter() {
289
- this.footerExpanded = null;
359
+ setTidying(value) {
360
+ if (this.tidying === value)
361
+ return;
362
+ this.tidying = value;
290
363
  this.emit();
291
364
  }
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;
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
+ }