@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.
- package/package.json +1 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +28 -21
- package/src/__tests__/encrypted-store.test.ts +24 -12
- package/src/__tests__/file-read-tool.test.ts +40 -0
- package/src/__tests__/host-file-read-tool.test.ts +87 -0
- package/src/__tests__/identity-intro-cache.test.ts +209 -0
- package/src/__tests__/model-intents.test.ts +1 -1
- package/src/__tests__/non-member-access-request.test.ts +3 -3
- package/src/__tests__/skill-memory.test.ts +14 -12
- package/src/daemon/conversation-runtime-assembly.ts +4 -3
- package/src/daemon/trace-emitter.ts +3 -2
- package/src/memory/search/staleness.ts +4 -1
- package/src/notifications/decision-engine.ts +43 -2
- package/src/notifications/emit-signal.ts +1 -0
- package/src/prompts/templates/BOOTSTRAP.md +10 -4
- package/src/prompts/templates/IDENTITY.md +1 -2
- package/src/providers/anthropic/client.ts +5 -17
- package/src/runtime/access-request-helper.ts +15 -1
- package/src/runtime/guardian-vellum-migration.ts +1 -3
- package/src/runtime/routes/btw-routes.ts +84 -0
- package/src/runtime/routes/identity-intro-cache.ts +105 -0
- package/src/runtime/routes/identity-routes.ts +51 -0
- package/src/runtime/routes/settings-routes.ts +1 -1
- package/src/security/encrypted-store.ts +1 -2
- package/src/skills/skill-memory.ts +5 -3
- package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
- package/src/telemetry/usage-telemetry-reporter.ts +2 -0
- package/src/tools/filesystem/read.ts +14 -3
- package/src/tools/host-filesystem/read.ts +17 -1
- 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`:
|
|
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
|
|
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
|
}
|
|
@@ -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:**
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
803
|
-
|
|
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:
|
|
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
|
}
|
|
@@ -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 (
|
|
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 {
|
|
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
|
-
|
|
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
|
|