@vellumai/assistant 0.4.57 → 0.5.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 (44) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/assistant-feature-flags-integration.test.ts +7 -9
  3. package/src/__tests__/conversation-runtime-assembly.test.ts +28 -21
  4. package/src/__tests__/credential-execution-feature-gates.test.ts +3 -3
  5. package/src/__tests__/encrypted-store.test.ts +24 -12
  6. package/src/__tests__/file-read-tool.test.ts +40 -0
  7. package/src/__tests__/filesystem-tools.test.ts +4 -2
  8. package/src/__tests__/history-repair.test.ts +71 -0
  9. package/src/__tests__/host-file-read-tool.test.ts +87 -0
  10. package/src/__tests__/identity-intro-cache.test.ts +209 -0
  11. package/src/__tests__/model-intents.test.ts +1 -1
  12. package/src/__tests__/non-member-access-request.test.ts +3 -3
  13. package/src/__tests__/skill-feature-flags-integration.test.ts +18 -17
  14. package/src/__tests__/skill-feature-flags.test.ts +13 -13
  15. package/src/__tests__/skill-load-feature-flag.test.ts +4 -4
  16. package/src/__tests__/skill-memory.test.ts +14 -12
  17. package/src/__tests__/system-prompt.test.ts +8 -0
  18. package/src/config/feature-flag-registry.json +9 -1
  19. package/src/daemon/conversation-agent-loop-handlers.ts +2 -39
  20. package/src/daemon/conversation-runtime-assembly.ts +4 -3
  21. package/src/daemon/history-repair.ts +28 -8
  22. package/src/daemon/trace-emitter.ts +3 -2
  23. package/src/memory/search/staleness.ts +4 -1
  24. package/src/notifications/decision-engine.ts +43 -2
  25. package/src/notifications/emit-signal.ts +1 -0
  26. package/src/permissions/checker.ts +0 -20
  27. package/src/prompts/system-prompt.ts +2 -0
  28. package/src/prompts/templates/BOOTSTRAP.md +10 -4
  29. package/src/prompts/templates/IDENTITY.md +1 -2
  30. package/src/providers/anthropic/client.ts +5 -17
  31. package/src/runtime/access-request-helper.ts +15 -1
  32. package/src/runtime/guardian-vellum-migration.ts +1 -3
  33. package/src/runtime/routes/btw-routes.ts +84 -0
  34. package/src/runtime/routes/identity-intro-cache.ts +105 -0
  35. package/src/runtime/routes/identity-routes.ts +51 -0
  36. package/src/runtime/routes/settings-routes.ts +1 -1
  37. package/src/security/encrypted-store.ts +1 -2
  38. package/src/skills/skill-memory.ts +5 -3
  39. package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
  40. package/src/telemetry/usage-telemetry-reporter.ts +2 -0
  41. package/src/tools/filesystem/read.ts +14 -3
  42. package/src/tools/host-filesystem/read.ts +17 -1
  43. package/src/tools/shared/filesystem/format-diff.ts +4 -16
  44. package/src/util/pricing.ts +4 -0
@@ -530,7 +530,6 @@ export class AnthropicProvider implements Provider {
530
530
  private model: string;
531
531
  private useNativeWebSearch: boolean;
532
532
  private streamTimeoutMs: number;
533
- private fastMode: boolean;
534
533
 
535
534
  constructor(
536
535
  apiKey: string,
@@ -542,9 +541,7 @@ export class AnthropicProvider implements Provider {
542
541
  } = {},
543
542
  ) {
544
543
  this.client = new Anthropic({ apiKey, baseURL: options.baseURL });
545
- // Models ending in "-fast" use the beta fast-mode API
546
- this.fastMode = model.endsWith("-fast");
547
- this.model = this.fastMode ? model.slice(0, -"-fast".length) : model;
544
+ this.model = model;
548
545
  this.useNativeWebSearch = options.useNativeWebSearch ?? false;
549
546
  this.streamTimeoutMs = options.streamTimeoutMs ?? 300_000;
550
547
  }
@@ -799,18 +796,9 @@ export class AnthropicProvider implements Provider {
799
796
 
800
797
  let response: Anthropic.Message;
801
798
  try {
802
- const stream: UnifiedStream = this.fastMode
803
- ? (this.client.beta.messages.stream(
804
- {
805
- ...params,
806
- betas: ["fast-mode-2026-02-01"],
807
- speed: "fast",
808
- } as Parameters<typeof this.client.beta.messages.stream>[0],
809
- { signal: timeoutSignal },
810
- ) as unknown as UnifiedStream)
811
- : (this.client.messages.stream(params, {
812
- signal: timeoutSignal,
813
- }) as unknown as UnifiedStream);
799
+ const stream: UnifiedStream = this.client.messages.stream(params, {
800
+ signal: timeoutSignal,
801
+ }) as unknown as UnifiedStream;
814
802
 
815
803
  // Track whether we've seen a text content block so we can insert a
816
804
  // separator between consecutive text blocks in the same response.
@@ -961,7 +949,7 @@ export class AnthropicProvider implements Provider {
961
949
  content: response.content.map((block) =>
962
950
  this.fromAnthropicBlock(block),
963
951
  ),
964
- model: this.fastMode ? `${response.model}-fast` : response.model,
952
+ model: response.model,
965
953
  usage: {
966
954
  inputTokens:
967
955
  response.usage.input_tokens +
@@ -200,10 +200,24 @@ export function notifyGuardianOfAccessRequest(
200
200
  });
201
201
 
202
202
  let vellumDeliveryId: string | null = null;
203
+ // When the access request originates from a text channel with
204
+ // notification delivery support (Slack, Telegram), route the guardian
205
+ // notification to that same channel only. Delivering on the macOS
206
+ // client as well is noisy and approving from there doesn't work
207
+ // because the desktop path lacks the channel delivery context needed
208
+ // to deliver the verification code. Phone is excluded because it is
209
+ // not a deliverable notification channel.
210
+ const TEXT_CHANNELS_WITH_DELIVERY: ReadonlySet<string> = new Set([
211
+ "slack",
212
+ "telegram",
213
+ ]);
214
+ const sameChannelOnly = TEXT_CHANNELS_WITH_DELIVERY.has(sourceChannel);
215
+
203
216
  void emitNotificationSignal({
204
217
  sourceEventName: "ingress.access_request",
205
218
  sourceChannel: sourceChannel as NotificationSourceChannel,
206
219
  sourceContextId: `access-req-${sourceChannel}-${actorExternalId}`,
220
+ ...(sameChannelOnly ? { routingIntent: "single_channel" as const } : {}),
207
221
  attentionHints: {
208
222
  requiresAction: true,
209
223
  urgency: "high",
@@ -258,7 +272,7 @@ export function notifyGuardianOfAccessRequest(
258
272
  applyDeliveryStatus(delivery.id, result);
259
273
  }
260
274
 
261
- if (!vellumDeliveryId) {
275
+ if (!vellumDeliveryId && !sameChannelOnly) {
262
276
  const fallback = createCanonicalGuardianDelivery({
263
277
  requestId: canonicalRequest.id,
264
278
  destinationChannel: "vellum",
@@ -91,9 +91,7 @@ export function ensureVellumGuardianBinding(
91
91
  *
92
92
  * Returns true if healing occurred, false otherwise.
93
93
  */
94
- export function healGuardianBindingDrift(
95
- incomingPrincipalId: string,
96
- ): boolean {
94
+ export function healGuardianBindingDrift(incomingPrincipalId: string): boolean {
97
95
  if (!incomingPrincipalId.startsWith("vellum-principal-")) {
98
96
  return false;
99
97
  }
@@ -12,6 +12,8 @@
12
12
  * No messages are persisted. `conversation.processing` is never set or checked.
13
13
  */
14
14
 
15
+ import { existsSync, readFileSync } from "node:fs";
16
+
15
17
  import { buildToolDefinitions } from "../../daemon/conversation-tool-setup.js";
16
18
  import { getConversationByKey } from "../../memory/conversation-key-store.js";
17
19
  import { buildSystemPrompt } from "../../prompts/system-prompt.js";
@@ -21,13 +23,45 @@ import {
21
23
  } from "../../providers/provider-send-message.js";
22
24
  import { checkIngressForSecrets } from "../../security/secret-ingress.js";
23
25
  import { getLogger } from "../../util/logger.js";
26
+ import { getWorkspacePromptPath } from "../../util/platform.js";
24
27
  import type { AuthContext } from "../auth/types.js";
25
28
  import { httpError } from "../http-errors.js";
26
29
  import type { RouteDefinition } from "../http-router.js";
27
30
  import type { SendMessageDeps } from "../http-types.js";
31
+ import { getCachedIntro, setCachedIntro } from "./identity-intro-cache.js";
28
32
 
29
33
  const log = getLogger("btw-routes");
30
34
 
35
+ /** Conversation key used by the client for identity intro generation. */
36
+ const IDENTITY_INTRO_KEY = "identity-intro";
37
+
38
+ /**
39
+ * Parse the `## Identity Intro` section from SOUL.md.
40
+ * Returns the first non-empty line under that heading, or null.
41
+ */
42
+ function readSoulIdentityIntro(): string | null {
43
+ try {
44
+ const soulPath = getWorkspacePromptPath("SOUL.md");
45
+ if (!existsSync(soulPath)) return null;
46
+ const content = readFileSync(soulPath, "utf-8");
47
+
48
+ let inSection = false;
49
+ for (const line of content.split("\n")) {
50
+ const trimmed = line.trim();
51
+ if (/^#+\s/.test(trimmed)) {
52
+ inSection = trimmed.toLowerCase().includes("identity intro");
53
+ continue;
54
+ }
55
+ if (inSection && trimmed.length > 0) {
56
+ return trimmed;
57
+ }
58
+ }
59
+ } catch {
60
+ // Fall through — no SOUL.md intro available
61
+ }
62
+ return null;
63
+ }
64
+
31
65
  // ---------------------------------------------------------------------------
32
66
  // Handler
33
67
  // ---------------------------------------------------------------------------
@@ -77,6 +111,43 @@ async function handleBtw(
77
111
  );
78
112
  }
79
113
 
114
+ // ----- Identity intro fast-path -----
115
+ // When the client requests the identity intro, check SOUL.md first (persisted
116
+ // during onboarding), then the LLM-generated cache. Only fall through to a
117
+ // live LLM call when neither source has a value.
118
+ if (conversationKey === IDENTITY_INTRO_KEY) {
119
+ const soulIntro = readSoulIdentityIntro();
120
+ const fastText = soulIntro ?? getCachedIntro()?.text;
121
+ if (fastText) {
122
+ log.debug(
123
+ soulIntro
124
+ ? "Returning SOUL.md identity intro"
125
+ : "Returning cached identity intro",
126
+ );
127
+ const encoder = new TextEncoder();
128
+ const stream = new ReadableStream({
129
+ start(controller) {
130
+ controller.enqueue(
131
+ encoder.encode(
132
+ `event: btw_text_delta\ndata: ${JSON.stringify({ text: fastText })}\n\n`,
133
+ ),
134
+ );
135
+ controller.enqueue(
136
+ encoder.encode(`event: btw_complete\ndata: {}\n\n`),
137
+ );
138
+ controller.close();
139
+ },
140
+ });
141
+ return new Response(stream, {
142
+ headers: {
143
+ "Content-Type": "text/event-stream",
144
+ "Cache-Control": "no-cache",
145
+ Connection: "keep-alive",
146
+ },
147
+ });
148
+ }
149
+ }
150
+
80
151
  // Look up an existing conversation — never create one. BTW is ephemeral
81
152
  // (the file header promises "No messages are persisted"), so we must not
82
153
  // call getOrCreateConversation which would insert a DB row. When no
@@ -116,7 +187,9 @@ async function handleBtw(
116
187
  ? conversation.systemPrompt
117
188
  : buildSystemPrompt({ excludeBootstrap: true });
118
189
 
190
+ const isIntroRequest = conversationKey === IDENTITY_INTRO_KEY;
119
191
  let textDeltaCount = 0;
192
+ let collectedText = "";
120
193
  await conversation.provider.sendMessage(
121
194
  messages,
122
195
  tools,
@@ -130,6 +203,7 @@ async function handleBtw(
130
203
  onEvent: (event) => {
131
204
  if (event.type === "text_delta") {
132
205
  textDeltaCount++;
206
+ if (isIntroRequest) collectedText += event.text;
133
207
  controller.enqueue(
134
208
  encoder.encode(
135
209
  `event: btw_text_delta\ndata: ${JSON.stringify({ text: event.text })}\n\n`,
@@ -148,6 +222,16 @@ async function handleBtw(
148
222
  );
149
223
  }
150
224
 
225
+ // Cache the generated identity intro for subsequent requests.
226
+ if (isIntroRequest && collectedText.trim()) {
227
+ try {
228
+ setCachedIntro(collectedText.trim());
229
+ log.debug("Cached identity intro text");
230
+ } catch {
231
+ // Non-fatal — next request will regenerate.
232
+ }
233
+ }
234
+
151
235
  controller.enqueue(
152
236
  encoder.encode(`event: btw_complete\ndata: {}\n\n`),
153
237
  );
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Caching layer for the LLM-generated identity intro text.
3
+ *
4
+ * The intro (a short identity tagline) is generated via the
5
+ * /v1/btw endpoint and displayed on the Identity panel. To avoid redundant LLM
6
+ * calls, we cache the result for 4 hours with content-hash-based invalidation:
7
+ * when USER.md, IDENTITY.md, or SOUL.md change, the cache is busted.
8
+ *
9
+ * Storage uses the existing `memory_checkpoints` table (simple key-value store).
10
+ */
11
+
12
+ import { createHash } from "node:crypto";
13
+ import { existsSync, readFileSync } from "node:fs";
14
+
15
+ import {
16
+ getMemoryCheckpoint,
17
+ setMemoryCheckpoint,
18
+ } from "../../memory/checkpoints.js";
19
+ import { getWorkspacePromptPath } from "../../util/platform.js";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Constants
23
+ // ---------------------------------------------------------------------------
24
+
25
+ const CACHE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
26
+
27
+ const CHECKPOINT_KEY_TEXT = "identity:intro:text";
28
+ const CHECKPOINT_KEY_HASH = "identity:intro:content_hash";
29
+ const CHECKPOINT_KEY_TIMESTAMP = "identity:intro:cached_at";
30
+
31
+ /** Workspace files whose content influences the identity intro. */
32
+ const IDENTITY_FILES = ["USER.md", "IDENTITY.md", "SOUL.md"] as const;
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Helpers
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /** Read a workspace prompt file, returning empty string if missing. */
39
+ function readWorkspaceFile(name: string): string {
40
+ try {
41
+ const path = getWorkspacePromptPath(name);
42
+ if (!existsSync(path)) return "";
43
+ return readFileSync(path, "utf-8");
44
+ } catch {
45
+ return "";
46
+ }
47
+ }
48
+
49
+ /** Compute a SHA-256 hex hash of the concatenated identity file contents. */
50
+ export function computeIdentityContentHash(): string {
51
+ const combined = IDENTITY_FILES.map(readWorkspaceFile).join("\n---\n");
52
+ return createHash("sha256").update(combined).digest("hex");
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Public API
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export interface CachedIntro {
60
+ text: string;
61
+ }
62
+
63
+ /**
64
+ * Retrieve the cached identity intro if it exists, is within the TTL window,
65
+ * and the identity files have not changed since it was generated.
66
+ *
67
+ * Returns `null` when the cache is missing, expired, or invalidated.
68
+ */
69
+ export function getCachedIntro(): CachedIntro | null {
70
+ try {
71
+ const text = getMemoryCheckpoint(CHECKPOINT_KEY_TEXT);
72
+ const hash = getMemoryCheckpoint(CHECKPOINT_KEY_HASH);
73
+ const timestampStr = getMemoryCheckpoint(CHECKPOINT_KEY_TIMESTAMP);
74
+
75
+ if (!text || !hash || !timestampStr) return null;
76
+
77
+ // TTL check
78
+ const cachedAt = Number(timestampStr);
79
+ if (isNaN(cachedAt) || Date.now() - cachedAt > CACHE_TTL_MS) return null;
80
+
81
+ // Content-hash check — bust cache when identity files change
82
+ const currentHash = computeIdentityContentHash();
83
+ if (currentHash !== hash) return null;
84
+
85
+ return { text };
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Store the generated identity intro text in the cache along with
93
+ * the current content hash and timestamp.
94
+ */
95
+ export function setCachedIntro(text: string): void {
96
+ try {
97
+ const hash = computeIdentityContentHash();
98
+ const now = String(Date.now());
99
+ setMemoryCheckpoint(CHECKPOINT_KEY_TEXT, text);
100
+ setMemoryCheckpoint(CHECKPOINT_KEY_HASH, hash);
101
+ setMemoryCheckpoint(CHECKPOINT_KEY_TIMESTAMP, now);
102
+ } catch {
103
+ // Cache write failure is non-fatal — next request will regenerate.
104
+ }
105
+ }
@@ -11,6 +11,7 @@ import { getBaseDataDir } from "../../config/env-registry.js";
11
11
  import { getWorkspacePromptPath, readLockfile } from "../../util/platform.js";
12
12
  import { httpError } from "../http-errors.js";
13
13
  import type { RouteDefinition } from "../http-router.js";
14
+ import { getCachedIntro } from "./identity-intro-cache.js";
14
15
 
15
16
  interface DiskSpaceInfo {
16
17
  path: string;
@@ -233,6 +234,51 @@ export function handleGetIdentity(): Response {
233
234
  });
234
235
  }
235
236
 
237
+ // ---------------------------------------------------------------------------
238
+ // Identity intro cache
239
+ // ---------------------------------------------------------------------------
240
+
241
+ /**
242
+ * Parse the `## Identity Intro` section from SOUL.md.
243
+ * Returns the first non-empty line under that heading, or null.
244
+ */
245
+ function readSoulIdentityIntro(): string | null {
246
+ try {
247
+ const soulPath = getWorkspacePromptPath("SOUL.md");
248
+ if (!existsSync(soulPath)) return null;
249
+ const content = readFileSync(soulPath, "utf-8");
250
+
251
+ let inSection = false;
252
+ for (const line of content.split("\n")) {
253
+ const trimmed = line.trim();
254
+ if (/^#+\s/.test(trimmed)) {
255
+ inSection = trimmed.toLowerCase().includes("identity intro");
256
+ continue;
257
+ }
258
+ if (inSection && trimmed.length > 0) {
259
+ return trimmed;
260
+ }
261
+ }
262
+ } catch {
263
+ // Fall through to cache/fallback
264
+ }
265
+ return null;
266
+ }
267
+
268
+ export function handleGetIdentityIntro(): Response {
269
+ // Prefer SOUL.md persisted intro over LLM-generated cache
270
+ const soulIntro = readSoulIdentityIntro();
271
+ if (soulIntro) {
272
+ return Response.json({ text: soulIntro });
273
+ }
274
+
275
+ const cached = getCachedIntro();
276
+ if (!cached) {
277
+ return httpError("NOT_FOUND", "No cached identity intro available", 404);
278
+ }
279
+ return Response.json({ text: cached.text });
280
+ }
281
+
236
282
  // ---------------------------------------------------------------------------
237
283
  // Route definitions
238
284
  // ---------------------------------------------------------------------------
@@ -249,5 +295,10 @@ export function identityRouteDefinitions(): RouteDefinition[] {
249
295
  method: "GET",
250
296
  handler: () => handleGetIdentity(),
251
297
  },
298
+ {
299
+ endpoint: "identity/intro",
300
+ method: "GET",
301
+ handler: () => handleGetIdentityIntro(),
302
+ },
252
303
  ];
253
304
  }
@@ -522,7 +522,7 @@ async function handleToolPermissionSimulate(body: {
522
522
  }
523
523
 
524
524
  return Response.json({
525
- ok: true,
525
+ success: true,
526
526
  decision: result.decision,
527
527
  riskLevel,
528
528
  reason: result.reason,
@@ -104,8 +104,7 @@ export function _setStoreKeyPath(path: string | null): void {
104
104
 
105
105
  function getStoreKeyPath(): string {
106
106
  return (
107
- storeKeyPathOverride ??
108
- join(dirname(getStorePath()), STORE_KEY_FILENAME)
107
+ storeKeyPathOverride ?? join(dirname(getStorePath()), STORE_KEY_FILENAME)
109
108
  );
110
109
  }
111
110
 
@@ -17,8 +17,7 @@ const log = getLogger("skill-memory");
17
17
  * Truncated to 500 chars max (matching the limit used by memory item extraction).
18
18
  */
19
19
  export function buildCapabilityStatement(entry: CatalogSkill): string {
20
- const displayName =
21
- entry.metadata?.vellum?.["display-name"] ?? entry.name;
20
+ const displayName = entry.metadata?.vellum?.["display-name"] ?? entry.name;
22
21
  const activationHints = entry.metadata?.vellum?.["activation-hints"];
23
22
 
24
23
  let statement = `The "${displayName}" skill (${entry.id}) is available. ${entry.description}.`;
@@ -71,7 +70,10 @@ export function upsertSkillCapabilityMemory(
71
70
  .get();
72
71
 
73
72
  if (existing) {
74
- if (existing.status === "active" && existing.fingerprint === fingerprint) {
73
+ if (
74
+ existing.status === "active" &&
75
+ existing.fingerprint === fingerprint
76
+ ) {
75
77
  // Same content — just touch lastSeenAt
76
78
  db.update(memoryItems)
77
79
  .set({ lastSeenAt: now })
@@ -89,6 +89,10 @@ mock.module("../util/logger.js", () => ({
89
89
  }),
90
90
  }));
91
91
 
92
+ mock.module("../version.js", () => ({
93
+ APP_VERSION: "1.2.3-test",
94
+ }));
95
+
92
96
  // ---------------------------------------------------------------------------
93
97
  // Production import (after mocks)
94
98
  // ---------------------------------------------------------------------------
@@ -370,8 +374,9 @@ describe("UsageTelemetryReporter", () => {
370
374
  (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
371
375
  );
372
376
 
373
- // Top-level: installation_id and events array (no turn_events key)
377
+ // Top-level: installation_id, app_version, and events array (no turn_events key)
374
378
  expect(body.installation_id).toBe("test-device-id");
379
+ expect(body.app_version).toBe("1.2.3-test");
375
380
  expect(Array.isArray(body.events)).toBe(true);
376
381
  expect(body.events.length).toBe(1);
377
382
  expect(body.turn_events).toBeUndefined();
@@ -26,6 +26,7 @@ import { resolveManagedProxyContext } from "../providers/managed-proxy/context.j
26
26
  import { getExternalAssistantId } from "../runtime/auth/external-assistant-id.js";
27
27
  import { getDeviceId } from "../util/device-id.js";
28
28
  import { getLogger } from "../util/logger.js";
29
+ import { APP_VERSION } from "../version.js";
29
30
  import type { TelemetryEvent } from "./types.js";
30
31
 
31
32
  const log = getLogger("usage-telemetry");
@@ -192,6 +193,7 @@ export class UsageTelemetryReporter {
192
193
  const payload = {
193
194
  installation_id: getDeviceId(),
194
195
  assistant_id: assistantId,
196
+ app_version: APP_VERSION,
195
197
  ...(organizationId ? { organization_id: organizationId } : {}),
196
198
  ...(userId ? { user_id: userId } : {}),
197
199
  events: typedEvents,
@@ -61,7 +61,10 @@ class FileReadTool implements Tool {
61
61
  if (IMAGE_EXTENSIONS.has(ext)) {
62
62
  const pathCheck = sandboxPolicy(rawPath, context.workingDir);
63
63
  if (!pathCheck.ok) {
64
- return { content: `Error: ${pathCheck.error}`, isError: true };
64
+ return {
65
+ content: `Error: ${pathCheck.error}. To read files outside the workspace, use the host_file_read tool instead.`,
66
+ isError: true,
67
+ };
65
68
  }
66
69
  return readImageFile(pathCheck.resolved);
67
70
  }
@@ -89,8 +92,16 @@ class FileReadTool implements Tool {
89
92
  content: `Error reading file "${rawPath}": ${error.message}`,
90
93
  isError: true,
91
94
  };
92
- default:
93
- return { content: `Error: ${error.message}`, isError: true };
95
+ default: {
96
+ const hint =
97
+ error.code === "PATH_OUT_OF_BOUNDS"
98
+ ? ". To read files outside the workspace, use the host_file_read tool instead."
99
+ : "";
100
+ return {
101
+ content: `Error: ${error.message}${hint}`,
102
+ isError: true,
103
+ };
104
+ }
94
105
  }
95
106
  }
96
107
 
@@ -1,13 +1,19 @@
1
+ import { extname } from "node:path";
2
+
1
3
  import { RiskLevel } from "../../permissions/types.js";
2
4
  import type { ToolDefinition } from "../../providers/types.js";
3
5
  import { FileSystemOps } from "../shared/filesystem/file-ops-service.js";
6
+ import {
7
+ IMAGE_EXTENSIONS,
8
+ readImageFile,
9
+ } from "../shared/filesystem/image-read.js";
4
10
  import { hostPolicy } from "../shared/filesystem/path-policy.js";
5
11
  import type { Tool, ToolContext, ToolExecutionResult } from "../types.js";
6
12
 
7
13
  class HostFileReadTool implements Tool {
8
14
  name = "host_file_read";
9
15
  description =
10
- "Read the contents of a file on the host filesystem. Not for workspace files under .vellum (use file_read instead).";
16
+ "Read the contents of a file on the host filesystem, including images (JPEG, PNG, GIF, WebP). Not for workspace files under .vellum (use file_read instead).";
11
17
  category = "host-filesystem";
12
18
  defaultRiskLevel = RiskLevel.Medium;
13
19
 
@@ -63,6 +69,16 @@ class HostFileReadTool implements Tool {
63
69
  );
64
70
  }
65
71
 
72
+ // For image files, delegate to the shared image reader.
73
+ const ext = extname(rawPath).toLowerCase();
74
+ if (IMAGE_EXTENSIONS.has(ext)) {
75
+ const pathCheck = hostPolicy(rawPath);
76
+ if (!pathCheck.ok) {
77
+ return { content: `Error: ${pathCheck.error}`, isError: true };
78
+ }
79
+ return readImageFile(pathCheck.resolved);
80
+ }
81
+
66
82
  const ops = new FileSystemOps(hostPolicy);
67
83
 
68
84
  const result = ops.readFileSafe({
@@ -1,21 +1,15 @@
1
- const MAX_DIFF_LINES = 8;
2
-
3
1
  /**
4
- * Build a compact inline diff from an old→new string replacement.
5
- * Lines are prefixed with - / + and truncated if the change is large.
2
+ * Build an inline diff from an old→new string replacement.
3
+ * Lines are prefixed with - / +.
6
4
  */
7
5
  export function formatEditDiff(oldString: string, newString: string): string {
8
6
  const removed =
9
7
  oldString.length > 0
10
- ? truncateLines(oldString.split("\n"), MAX_DIFF_LINES).map(
11
- (l) => `- ${l}`,
12
- )
8
+ ? oldString.split("\n").map((l) => `- ${l}`)
13
9
  : [];
14
10
  const added =
15
11
  newString.length > 0
16
- ? truncateLines(newString.split("\n"), MAX_DIFF_LINES).map(
17
- (l) => `+ ${l}`,
18
- )
12
+ ? newString.split("\n").map((l) => `+ ${l}`)
19
13
  : [];
20
14
 
21
15
  return [...removed, ...added].join("\n");
@@ -37,9 +31,3 @@ export function formatWriteSummary(
37
31
  return `(${oldLineCount} → ${newLineCount} lines)`;
38
32
  }
39
33
 
40
- function truncateLines(lines: string[], max: number): string[] {
41
- if (lines.length <= max) return lines;
42
- const kept = lines.slice(0, max);
43
- kept.push(`... (${lines.length - max} more lines)`);
44
- return kept;
45
- }
@@ -24,6 +24,9 @@ const PROVIDER_PRICING: Record<string, Record<string, ModelPricing>> = {
24
24
  "claude-haiku-4": { inputPer1M: 0.8, outputPer1M: 4 },
25
25
  },
26
26
  openai: {
27
+ "gpt-5.4": { inputPer1M: 2.5, outputPer1M: 15 },
28
+ "gpt-5.4-nano": { inputPer1M: 0.2, outputPer1M: 1.25 },
29
+ "gpt-5.2": { inputPer1M: 1.75, outputPer1M: 14 },
27
30
  "gpt-4o": { inputPer1M: 2.5, outputPer1M: 10 },
28
31
  "gpt-4o-mini": { inputPer1M: 0.15, outputPer1M: 0.6 },
29
32
  "gpt-4.1": { inputPer1M: 2.0, outputPer1M: 8.0 },
@@ -35,6 +38,7 @@ const PROVIDER_PRICING: Record<string, Record<string, ModelPricing>> = {
35
38
  "o4-mini": { inputPer1M: 1.1, outputPer1M: 4.4 },
36
39
  },
37
40
  gemini: {
41
+ "gemini-3-flash": { inputPer1M: 0.5, outputPer1M: 3 },
38
42
  "gemini-2.5-pro": { inputPer1M: 1.25, outputPer1M: 10 },
39
43
  "gemini-2.5-flash": { inputPer1M: 0.15, outputPer1M: 0.6 },
40
44
  "gemini-2.0-flash": { inputPer1M: 0.1, outputPer1M: 0.4 },