@tianmucreations/jeeves 0.2.1 → 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.
Files changed (55) hide show
  1. package/README.md +79 -18
  2. package/bin/jeeves +8 -1
  3. package/dist/agent/auto-ids.js +66 -0
  4. package/dist/agent/auto.js +178 -0
  5. package/dist/agent/context.js +55 -13
  6. package/dist/agent/errors.js +83 -22
  7. package/dist/agent/expert-chat.js +33 -0
  8. package/dist/agent/housekeeping.js +55 -0
  9. package/dist/agent/loop.js +168 -12
  10. package/dist/agent/permissions.js +167 -0
  11. package/dist/agent/research-gate.js +267 -0
  12. package/dist/agent/review.js +135 -0
  13. package/dist/agent/spending.js +73 -0
  14. package/dist/agent/systemPrompt.js +112 -0
  15. package/dist/app.js +25 -10
  16. package/dist/checkpoints/index.js +103 -0
  17. package/dist/checkpoints/store.js +239 -0
  18. package/dist/commands/address.js +5 -0
  19. package/dist/commands/clear.js +2 -0
  20. package/dist/commands/help.js +7 -4
  21. package/dist/commands/keys.js +1 -1
  22. package/dist/commands/verbose.js +1 -1
  23. package/dist/components/AddressPrompt.js +31 -0
  24. package/dist/components/Footer.js +74 -102
  25. package/dist/components/Input.js +76 -25
  26. package/dist/components/KeysManager.js +65 -20
  27. package/dist/components/ModelPicker.js +348 -75
  28. package/dist/components/ProjectPicker.js +4 -1
  29. package/dist/components/Transcript.js +29 -14
  30. package/dist/components/input-layout.js +34 -0
  31. package/dist/components/transcript-layout.js +13 -17
  32. package/dist/index.js +25 -7
  33. package/dist/ink/AlternateScreen.js +33 -16
  34. package/dist/ink/cursor.js +18 -0
  35. package/dist/ink/mouse.js +48 -0
  36. package/dist/keys/store.js +2 -1
  37. package/dist/models/registry.js +18 -2
  38. package/dist/platform/config.js +63 -7
  39. package/dist/providers/catalogue.js +293 -0
  40. package/dist/providers/direct-services.js +65 -0
  41. package/dist/providers/direct.js +145 -0
  42. package/dist/providers/index.js +123 -13
  43. package/dist/providers/models-snapshot.js +1037 -0
  44. package/dist/providers/ollama.js +21 -4
  45. package/dist/providers/openrouter.js +39 -4
  46. package/dist/providers/step-control.js +28 -0
  47. package/dist/providers/zai.js +31 -11
  48. package/dist/state/session.js +90 -36
  49. package/dist/state/today-spend.js +26 -0
  50. package/dist/tools/index.js +118 -10
  51. package/dist/tools/runBash.js +58 -11
  52. package/dist/tools/web/htmlToText.js +32 -0
  53. package/dist/tools/web/openrouterChat.js +31 -0
  54. package/dist/tools/web/research.js +191 -0
  55. package/package.json +32 -6
@@ -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,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
- 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;
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 to zero so the view can never sink past the newest.
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
- setHiddenMetrics(metrics) {
266
- this.hiddenMetrics = metrics;
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
- // 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();
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
- escapeFooter() {
289
- this.footerExpanded = null;
333
+ setBusyNote(note) {
334
+ if (this.busyNote === note)
335
+ return;
336
+ this.busyNote = note;
290
337
  this.emit();
291
338
  }
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;
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
+ }
@@ -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 not found';
30
+ return "couldn't find that file or folder";
25
31
  if (text.includes('eacces') || text.includes('eperm'))
26
- return 'permission denied';
27
- if (text.includes('timed out') || text.includes('etimedout') || text.includes('stopped after'))
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
- const firstLine = raw.split('\n')[0].trim();
32
- return firstLine.length > 0 ? firstLine : 'failed';
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 lineId = session.addToolLine(config.name, summary, config.permission ? 'awaiting' : 'running');
46
- if (config.permission) {
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
- permission: true,
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: runRunBash,
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
  }