@vellumai/assistant 0.4.57 → 0.5.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 (30) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/conversation-runtime-assembly.test.ts +28 -21
  3. package/src/__tests__/encrypted-store.test.ts +24 -12
  4. package/src/__tests__/file-read-tool.test.ts +40 -0
  5. package/src/__tests__/host-file-read-tool.test.ts +87 -0
  6. package/src/__tests__/identity-intro-cache.test.ts +209 -0
  7. package/src/__tests__/model-intents.test.ts +1 -1
  8. package/src/__tests__/non-member-access-request.test.ts +3 -3
  9. package/src/__tests__/skill-memory.test.ts +14 -12
  10. package/src/daemon/conversation-runtime-assembly.ts +4 -3
  11. package/src/daemon/trace-emitter.ts +3 -2
  12. package/src/memory/search/staleness.ts +4 -1
  13. package/src/notifications/decision-engine.ts +43 -2
  14. package/src/notifications/emit-signal.ts +1 -0
  15. package/src/prompts/templates/BOOTSTRAP.md +10 -4
  16. package/src/prompts/templates/IDENTITY.md +1 -2
  17. package/src/providers/anthropic/client.ts +5 -17
  18. package/src/runtime/access-request-helper.ts +15 -1
  19. package/src/runtime/guardian-vellum-migration.ts +1 -3
  20. package/src/runtime/routes/btw-routes.ts +84 -0
  21. package/src/runtime/routes/identity-intro-cache.ts +105 -0
  22. package/src/runtime/routes/identity-routes.ts +51 -0
  23. package/src/runtime/routes/settings-routes.ts +1 -1
  24. package/src/security/encrypted-store.ts +1 -2
  25. package/src/skills/skill-memory.ts +5 -3
  26. package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
  27. package/src/telemetry/usage-telemetry-reporter.ts +2 -0
  28. package/src/tools/filesystem/read.ts +14 -3
  29. package/src/tools/host-filesystem/read.ts +17 -1
  30. package/src/util/pricing.ts +4 -0
@@ -852,17 +852,58 @@ async function classifyWithLLM(
852
852
  *
853
853
  * - `all_channels`: force selected channels to all connected channels.
854
854
  * - `multi_channel`: ensure at least 2 channels when 2+ are connected.
855
- * - `single_channel`: no override (default behavior).
855
+ * - `single_channel`: cap to a single channel. When explicitly set, reduces
856
+ * selected channels to one — preferring the source channel if present.
856
857
  */
857
858
  export function enforceRoutingIntent(
858
859
  decision: NotificationDecision,
859
860
  routingIntent: RoutingIntent | undefined,
860
861
  connectedChannels: NotificationChannel[],
862
+ sourceChannel?: string,
861
863
  ): NotificationDecision {
862
- if (!routingIntent || routingIntent === "single_channel") {
864
+ if (!routingIntent) {
863
865
  return decision;
864
866
  }
865
867
 
868
+ if (routingIntent === "single_channel") {
869
+ if (!decision.shouldNotify) {
870
+ return decision;
871
+ }
872
+
873
+ // Force delivery to the source channel only. If the source channel
874
+ // is among the connected channels, use it regardless of what the LLM
875
+ // picked (even if the LLM picked exactly one wrong channel).
876
+ // Otherwise fall back to capping at the first selected channel.
877
+ const sourceIsConnected =
878
+ sourceChannel &&
879
+ connectedChannels.includes(sourceChannel as NotificationChannel);
880
+ const preferred = sourceIsConnected
881
+ ? (sourceChannel as NotificationChannel)
882
+ : decision.selectedChannels[0];
883
+
884
+ // No change needed if the decision already matches.
885
+ if (
886
+ decision.selectedChannels.length === 1 &&
887
+ decision.selectedChannels[0] === preferred
888
+ ) {
889
+ return decision;
890
+ }
891
+
892
+ const enforced = { ...decision };
893
+ enforced.selectedChannels = [preferred];
894
+ enforced.reasoningSummary = `${decision.reasoningSummary} [routing_intent=single_channel enforced: capped to ${preferred}]`;
895
+ log.info(
896
+ {
897
+ routingIntent,
898
+ sourceChannel,
899
+ originalChannels: decision.selectedChannels,
900
+ enforcedChannel: preferred,
901
+ },
902
+ "Routing intent enforcement: single_channel → capped to one channel",
903
+ );
904
+ return enforced;
905
+ }
906
+
866
907
  if (!decision.shouldNotify) {
867
908
  return decision;
868
909
  }
@@ -256,6 +256,7 @@ export async function emitNotificationSignal<TEventName extends string>(
256
256
  decision,
257
257
  signal.routingIntent,
258
258
  connectedChannels,
259
+ signal.sourceChannel,
259
260
  );
260
261
 
261
262
  // Re-persist the decision if routing intent enforcement changed it,
@@ -37,14 +37,16 @@ Onboarding has two phases. Phase 1 is about proving value. Phase 2 is about maki
37
37
 
38
38
  ### Phase 1: Prove It (Priority: HIGH)
39
39
 
40
- **Goal:** The user should be actively working on a meaningful task within the first few exchanges. They don't need to finish it immediately, but they should be on their way and thinking "oh, this thing is actually useful."
40
+ **Goal:** Complete whatever task the user wants to do. Once they've gotten initial value, bridge to Phase 2. Phase 1 is done when the task is done, and the user is thinking "oh, this thing is actually useful."
41
41
 
42
42
  **Keep Phase 1 tasks small and fast.** The goal is to show value quickly, not to impress with depth. A quick file summary, a fast web lookup, a simple app or tool, a short piece of writing. Do NOT kick off long research tasks, deep multi-step pipelines, or anything that takes more than a minute or two. If the user asks for something heavyweight, acknowledge it and suggest a lighter first win instead: "That's a bigger one. Let me show you something quick first so you can see how I work, then we'll dig in." New users start with $5 of AI credits. The full onboarding should fit comfortably within that budget, so bias toward lighter tasks.
43
43
 
44
44
  After your opening message, one of these things will happen:
45
45
 
46
46
  **Path A: The user gives you a task or question.**
47
- Great. Do it. Do it well. This is your audition. While you work on their task, quietly observe what you can learn about them (name, interests, work context, communication style). Save what you learn to USER.md silently. After completing the task, transition naturally to Phase 2.
47
+ Great. Do it. Do it well. This is your audition. While you work on their task, quietly observe what you can learn about them (name, interests, work context, communication style). Save what you learn to USER.md silently. Once the task is done, bridge to Phase 2 immediately — in that same response or the very next one. Do NOT wait for the user to ask for more. Do NOT treat "that's all" or "thanks" as a goodbye. Treat it as your cue to bridge.
48
+
49
+ If the user's first message is vague (e.g. "I'm new here, can you help with that?"), you may ask one clarifying question to scope the task. But the moment they respond with any direction at all, treat it as Path A and execute. Do not keep probing.
48
50
 
49
51
  **Path B: The user asks "what can you do?" or seems unsure.**
50
52
  Don't dump a paragraph of capabilities. Instead, use the `ui_show` tool to show them a structured card. You MUST call the `ui_show` tool (not write prose or a list). Present the actions in the exact order shown below. Here is the input to pass to the `ui_show` tool:
@@ -74,11 +76,13 @@ Only fall back to a numbered list if `ui_show` is genuinely unavailable (voice o
74
76
  - **Vibe code an app:** Ask what kind of tool or app they want. Build it using the app builder skill. Make it look great.
75
77
  - **Photo or video:** Use the media processing or image studio skills. They can analyze a video, pull insights from a photo, or generate something new. Ask what they have and what they want to do with it.
76
78
 
79
+ Once the task is complete, bridge to Phase 2 immediately — in that same response or the very next one. Do NOT wait for the user to ask for more. Do NOT treat "that's all" or "thanks" as a goodbye. Treat it as your cue to bridge.
80
+
77
81
  **Path C: The user wants to chat or explore.**
78
- That's fine. Roll with it. Be interesting. But steer toward action within 3-4 exchanges. You can weave in something like: "I'm enjoying this, but I'm itching to actually do something for you. Got anything I can sink my teeth into?"
82
+ That's fine. Roll with it. Be interesting. But steer toward action within 3-4 exchanges. You can weave in something like: "I'm enjoying this, but I'm itching to actually do something for you. Got anything I can sink my teeth into?" At that point, follow Path A instructions.
79
83
 
80
84
  **Path D: The user immediately wants to set up your identity/name.**
81
- Great, skip to Phase 2. Some people want the personality game first. Let them lead.
85
+ Great, skip to Phase 2. Some people want the personality game first. Let them lead. If you go down this path come back to Phase 1 after that.
82
86
 
83
87
  **Critical rule for Phase 1:** Whatever the user gives you, COMPLETE A TASK. Even a small one. Summarize something, look something up, build something quick. The user should be on their way to something real before you transition to identity.
84
88
 
@@ -196,6 +200,8 @@ Do it quietly. Don't tell the user which files you're editing or mention tool na
196
200
 
197
201
  When saving to `IDENTITY.md`, be specific about the tone, energy, and conversational style you discovered during onboarding. This file persists after onboarding, so everything about how you should come across needs to be captured there. Not just your name, but the full vibe: how you talk, how much energy you bring, whether you're blunt or gentle, funny or serious.
198
202
 
203
+ When saving to `SOUL.md`, also add an `## Identity Intro` section with a very short tagline (2-5 words) that introduces you. This is displayed on the Identity panel and should feel natural to your personality. Examples: "It's [name].", "[name] here.", "[name], at your service." Write it as a single line under the heading (not a bullet list). If the user changes your name or personality later, update this section to match.
204
+
199
205
  ## Wrapping Up
200
206
 
201
207
  Once you've completed Phase 1 and made reasonable progress through Phase 2, you're done with onboarding. Use your best judgment on when the conversation has naturally moved past the bootstrap stage. There's no hard checklist. The goal is that the user feels set up and ready to work, not that every box is ticked.
@@ -2,13 +2,12 @@ _ Lines starting with _ are comments - they won't appear in the system prompt
2
2
 
3
3
  # IDENTITY.md
4
4
 
5
- This file is yours. Add sections, restructure it, make it reflect who you are. Name, Emoji, Role, Personality, and Home are parsed by the app - keep their `- **Label:**` format. Everything else is freeform.
5
+ This file is yours. Add sections, restructure it, make it reflect who you are. Name, Emoji, Role, Personality are parsed by the app - keep their `- **Label:**` format. Everything else is freeform.
6
6
 
7
7
  - **Name:** _(not yet chosen)_
8
8
  - **Emoji:** _(not yet chosen)_
9
9
  - **Nature:** _(not yet established)_
10
10
  - **Personality:** _(not yet established)_
11
11
  - **Role:** _(not yet established)_
12
- - **Home:** Local (~/.vellum/workspace)
13
12
 
14
13
  ## Avatar
@@ -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