@vellumai/assistant 0.3.2 → 0.3.4
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/README.md +82 -21
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +16 -0
- package/src/__tests__/app-git-history.test.ts +22 -27
- package/src/__tests__/app-git-service.test.ts +44 -78
- package/src/__tests__/call-orchestrator.test.ts +321 -0
- package/src/__tests__/channel-approval-routes.test.ts +1267 -93
- package/src/__tests__/channel-approval.test.ts +2 -0
- package/src/__tests__/channel-approvals.test.ts +51 -2
- package/src/__tests__/channel-delivery-store.test.ts +130 -1
- package/src/__tests__/channel-guardian.test.ts +371 -1
- package/src/__tests__/config-schema.test.ts +1 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/daemon-lifecycle.test.ts +635 -0
- package/src/__tests__/daemon-server-session-init.test.ts +5 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +106 -21
- package/src/__tests__/handlers-telegram-config.test.ts +82 -0
- package/src/__tests__/handlers-twilio-config.test.ts +738 -5
- package/src/__tests__/ingress-url-consistency.test.ts +64 -0
- package/src/__tests__/ipc-snapshot.test.ts +10 -0
- package/src/__tests__/run-orchestrator.test.ts +1 -1
- package/src/__tests__/secret-scanner.test.ts +223 -0
- package/src/__tests__/session-process-bridge.test.ts +2 -0
- package/src/__tests__/shell-parser-property.test.ts +357 -2
- package/src/__tests__/system-prompt.test.ts +25 -1
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
- package/src/__tests__/tool-permission-simulate-handler.test.ts +2 -2
- package/src/__tests__/user-reference.test.ts +68 -0
- package/src/calls/call-orchestrator.ts +63 -11
- package/src/calls/twilio-config.ts +10 -1
- package/src/calls/twilio-rest.ts +70 -0
- package/src/cli/map.ts +6 -0
- package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
- package/src/commands/cc-command-registry.ts +14 -1
- package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
- package/src/config/bundled-skills/email-setup/SKILL.md +56 -0
- package/src/config/bundled-skills/messaging/SKILL.md +4 -0
- package/src/config/bundled-skills/subagent/SKILL.md +4 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
- package/src/config/defaults.ts +1 -1
- package/src/config/schema.ts +6 -3
- package/src/config/skills.ts +5 -32
- package/src/config/system-prompt.ts +16 -0
- package/src/config/user-reference.ts +29 -0
- package/src/config/vellum-skills/catalog.json +52 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
- package/src/config/vellum-skills/twilio-setup/SKILL.md +49 -4
- package/src/daemon/auth-manager.ts +103 -0
- package/src/daemon/computer-use-session.ts +8 -1
- package/src/daemon/config-watcher.ts +253 -0
- package/src/daemon/handlers/config.ts +193 -17
- package/src/daemon/handlers/sessions.ts +5 -3
- package/src/daemon/handlers/skills.ts +60 -17
- package/src/daemon/ipc-contract-inventory.json +4 -0
- package/src/daemon/ipc-contract.ts +16 -0
- package/src/daemon/ipc-handler.ts +87 -0
- package/src/daemon/lifecycle.ts +16 -4
- package/src/daemon/ride-shotgun-handler.ts +11 -1
- package/src/daemon/server.ts +105 -502
- package/src/daemon/session-agent-loop.ts +9 -14
- package/src/daemon/session-process.ts +20 -3
- package/src/daemon/session-runtime-assembly.ts +60 -44
- package/src/daemon/session-slash.ts +50 -2
- package/src/daemon/session-surfaces.ts +17 -1
- package/src/daemon/session.ts +8 -1
- package/src/inbound/public-ingress-urls.ts +20 -3
- package/src/index.ts +1 -23
- package/src/memory/app-git-service.ts +24 -0
- package/src/memory/app-store.ts +0 -21
- package/src/memory/channel-delivery-store.ts +74 -3
- package/src/memory/channel-guardian-store.ts +54 -26
- package/src/memory/conversation-key-store.ts +20 -0
- package/src/memory/conversation-store.ts +14 -2
- package/src/memory/db-connection.ts +28 -0
- package/src/memory/db-init.ts +1019 -0
- package/src/memory/db.ts +2 -1995
- package/src/memory/embedding-backend.ts +79 -11
- package/src/memory/indexer.ts +2 -0
- package/src/memory/job-utils.ts +64 -4
- package/src/memory/jobs-worker.ts +7 -1
- package/src/memory/recall-cache.ts +107 -0
- package/src/memory/retriever.ts +30 -1
- package/src/memory/schema-migration.ts +984 -0
- package/src/memory/schema.ts +6 -0
- package/src/memory/search/types.ts +2 -0
- package/src/permissions/prompter.ts +14 -3
- package/src/permissions/trust-store.ts +7 -0
- package/src/runtime/channel-approvals.ts +17 -3
- package/src/runtime/gateway-client.ts +2 -1
- package/src/runtime/http-server.ts +28 -9
- package/src/runtime/routes/channel-routes.ts +279 -100
- package/src/runtime/routes/run-routes.ts +7 -1
- package/src/runtime/run-orchestrator.ts +8 -1
- package/src/security/secret-scanner.ts +218 -0
- package/src/skills/clawhub.ts +6 -2
- package/src/skills/frontmatter.ts +63 -0
- package/src/skills/slash-commands.ts +23 -0
- package/src/skills/vellum-catalog-remote.ts +107 -0
- package/src/subagent/manager.ts +4 -1
- package/src/subagent/types.ts +2 -0
- package/src/tools/browser/auto-navigate.ts +132 -24
- package/src/tools/browser/browser-manager.ts +67 -61
- package/src/tools/claude-code/claude-code.ts +55 -3
- package/src/tools/executor.ts +10 -2
- package/src/tools/skills/vellum-catalog.ts +75 -127
- package/src/tools/subagent/spawn.ts +2 -0
- package/src/tools/terminal/parser.ts +21 -5
- package/src/util/platform.ts +8 -1
- package/src/util/retry.ts +4 -4
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import Anthropic from '@anthropic-ai/sdk';
|
|
10
10
|
import { getConfig } from '../config/loader.js';
|
|
11
|
+
import { resolveUserReference } from '../config/user-reference.js';
|
|
11
12
|
import { getLogger } from '../util/logger.js';
|
|
12
13
|
import {
|
|
13
14
|
getCallSession,
|
|
@@ -43,6 +44,8 @@ export class CallOrchestrator {
|
|
|
43
44
|
private task: string | null;
|
|
44
45
|
/** Instructions queued while an LLM turn is in-flight or during waiting_on_user */
|
|
45
46
|
private pendingInstructions: string[] = [];
|
|
47
|
+
/** Monotonic run id used to suppress stale turn side effects after interruption. */
|
|
48
|
+
private llmRunVersion = 0;
|
|
46
49
|
|
|
47
50
|
constructor(callSessionId: string, relay: RelayConnection, task: string | null) {
|
|
48
51
|
this.callSessionId = callSessionId;
|
|
@@ -64,20 +67,30 @@ export class CallOrchestrator {
|
|
|
64
67
|
* Handle a final caller utterance from the ConversationRelay.
|
|
65
68
|
*/
|
|
66
69
|
async handleCallerUtterance(transcript: string, speaker?: PromptSpeakerContext): Promise<void> {
|
|
70
|
+
const interruptedInFlight = this.state === 'processing' || this.state === 'speaking';
|
|
67
71
|
// If we're already processing or speaking, abort the in-flight generation
|
|
68
|
-
if (
|
|
72
|
+
if (interruptedInFlight) {
|
|
69
73
|
this.abortController.abort();
|
|
70
74
|
this.abortController = new AbortController();
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
this.state = 'processing';
|
|
74
78
|
this.resetSilenceTimer();
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
const callerContent = this.formatCallerUtterance(transcript, speaker);
|
|
80
|
+
|
|
81
|
+
// Preserve strict role alternation for Anthropic. If the last message
|
|
82
|
+
// is already user-role (e.g. interrupted run never appended assistant,
|
|
83
|
+
// or a second caller prompt arrives before assistant completion), merge
|
|
84
|
+
// this utterance into that same user turn.
|
|
85
|
+
const lastMessage = this.conversationHistory[this.conversationHistory.length - 1];
|
|
86
|
+
if (lastMessage?.role === 'user') {
|
|
87
|
+
lastMessage.content = `${lastMessage.content}\n${callerContent}`;
|
|
88
|
+
} else {
|
|
89
|
+
this.conversationHistory.push({
|
|
90
|
+
role: 'user',
|
|
91
|
+
content: callerContent,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
81
94
|
|
|
82
95
|
await this.runLlm();
|
|
83
96
|
}
|
|
@@ -168,8 +181,15 @@ export class CallOrchestrator {
|
|
|
168
181
|
* Handle caller interrupting the assistant's speech.
|
|
169
182
|
*/
|
|
170
183
|
handleInterrupt(): void {
|
|
184
|
+
const wasSpeaking = this.state === 'speaking';
|
|
171
185
|
this.abortController.abort();
|
|
172
186
|
this.abortController = new AbortController();
|
|
187
|
+
this.llmRunVersion++;
|
|
188
|
+
// Explicitly terminate the in-progress TTS turn so the relay can
|
|
189
|
+
// immediately hand control back to the caller after barge-in.
|
|
190
|
+
if (wasSpeaking) {
|
|
191
|
+
this.relay.sendTextToken('', true);
|
|
192
|
+
}
|
|
173
193
|
this.state = 'idle';
|
|
174
194
|
}
|
|
175
195
|
|
|
@@ -196,13 +216,14 @@ export class CallOrchestrator {
|
|
|
196
216
|
: '1. Begin the conversation naturally.';
|
|
197
217
|
|
|
198
218
|
return [
|
|
199
|
-
|
|
219
|
+
`You are on a live phone call on behalf of ${resolveUserReference()}.`,
|
|
200
220
|
this.task ? `Task: ${this.task}` : '',
|
|
201
221
|
'',
|
|
202
222
|
'You are speaking directly to the person who answered the phone.',
|
|
203
223
|
'Respond naturally and conversationally — speak as you would in a real phone conversation.',
|
|
204
224
|
'',
|
|
205
225
|
'IMPORTANT RULES:',
|
|
226
|
+
'0. When introducing yourself, refer to yourself as an assistant. Avoid the phrase "AI assistant" unless directly asked.',
|
|
206
227
|
disclosureRule,
|
|
207
228
|
'2. Be concise — phone conversations should be brief and natural.',
|
|
208
229
|
'3. If the callee asks something you don\'t know, include [ASK_USER: your question here] in your response along with a hold message like "Let me check on that for you."',
|
|
@@ -239,6 +260,8 @@ export class CallOrchestrator {
|
|
|
239
260
|
}
|
|
240
261
|
|
|
241
262
|
const client = new Anthropic({ apiKey });
|
|
263
|
+
const runVersion = ++this.llmRunVersion;
|
|
264
|
+
const runSignal = this.abortController.signal;
|
|
242
265
|
|
|
243
266
|
try {
|
|
244
267
|
this.state = 'speaking';
|
|
@@ -255,7 +278,7 @@ export class CallOrchestrator {
|
|
|
255
278
|
content: m.content,
|
|
256
279
|
})),
|
|
257
280
|
},
|
|
258
|
-
{ signal:
|
|
281
|
+
{ signal: runSignal },
|
|
259
282
|
);
|
|
260
283
|
|
|
261
284
|
// Buffer incoming tokens so we can strip control markers ([ASK_USER:...], [END_CALL])
|
|
@@ -264,6 +287,7 @@ export class CallOrchestrator {
|
|
|
264
287
|
let ttsBuffer = '';
|
|
265
288
|
|
|
266
289
|
const flushSafeText = (_force: boolean): void => {
|
|
290
|
+
if (!this.isCurrentRun(runVersion)) return;
|
|
267
291
|
if (ttsBuffer.length === 0) return;
|
|
268
292
|
const bracketIdx = ttsBuffer.indexOf('[');
|
|
269
293
|
if (bracketIdx === -1) {
|
|
@@ -312,6 +336,7 @@ export class CallOrchestrator {
|
|
|
312
336
|
};
|
|
313
337
|
|
|
314
338
|
stream.on('text', (text) => {
|
|
339
|
+
if (!this.isCurrentRun(runVersion)) return;
|
|
315
340
|
ttsBuffer += text;
|
|
316
341
|
|
|
317
342
|
// If the buffer contains a complete control marker, strip it
|
|
@@ -326,6 +351,7 @@ export class CallOrchestrator {
|
|
|
326
351
|
});
|
|
327
352
|
|
|
328
353
|
const finalMessage = await stream.finalMessage();
|
|
354
|
+
if (!this.isCurrentRun(runVersion)) return;
|
|
329
355
|
|
|
330
356
|
// Final sweep: strip any remaining control markers from the buffer
|
|
331
357
|
ttsBuffer = ttsBuffer.replace(ASK_USER_REGEX, '').replace(END_CALL_MARKER, '');
|
|
@@ -412,8 +438,25 @@ export class CallOrchestrator {
|
|
|
412
438
|
this.flushPendingInstructions();
|
|
413
439
|
} catch (err: unknown) {
|
|
414
440
|
// Aborted requests are expected (interruptions, rapid utterances)
|
|
415
|
-
if (err
|
|
416
|
-
log.debug(
|
|
441
|
+
if (this.isExpectedAbortError(err) || runSignal.aborted) {
|
|
442
|
+
log.debug(
|
|
443
|
+
{
|
|
444
|
+
callSessionId: this.callSessionId,
|
|
445
|
+
errName: err instanceof Error ? err.name : typeof err,
|
|
446
|
+
stale: !this.isCurrentRun(runVersion),
|
|
447
|
+
},
|
|
448
|
+
'LLM request aborted',
|
|
449
|
+
);
|
|
450
|
+
if (this.isCurrentRun(runVersion)) {
|
|
451
|
+
this.state = 'idle';
|
|
452
|
+
}
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (!this.isCurrentRun(runVersion)) {
|
|
456
|
+
log.debug(
|
|
457
|
+
{ callSessionId: this.callSessionId, errName: err instanceof Error ? err.name : typeof err },
|
|
458
|
+
'Ignoring stale LLM streaming error from superseded turn',
|
|
459
|
+
);
|
|
417
460
|
return;
|
|
418
461
|
}
|
|
419
462
|
log.error({ err, callSessionId: this.callSessionId }, 'LLM streaming error');
|
|
@@ -423,6 +466,15 @@ export class CallOrchestrator {
|
|
|
423
466
|
}
|
|
424
467
|
}
|
|
425
468
|
|
|
469
|
+
private isExpectedAbortError(err: unknown): boolean {
|
|
470
|
+
if (!(err instanceof Error)) return false;
|
|
471
|
+
return err.name === 'AbortError' || err.name === 'APIUserAbortError';
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private isCurrentRun(runVersion: number): boolean {
|
|
475
|
+
return runVersion === this.llmRunVersion;
|
|
476
|
+
}
|
|
477
|
+
|
|
426
478
|
/**
|
|
427
479
|
* Drain any instructions that were queued while the LLM was active.
|
|
428
480
|
* Each instruction is appended as a user message (now correctly after
|
|
@@ -16,8 +16,17 @@ export interface TwilioConfig {
|
|
|
16
16
|
export function getTwilioConfig(): TwilioConfig {
|
|
17
17
|
const accountSid = getSecureKey('credential:twilio:account_sid');
|
|
18
18
|
const authToken = getSecureKey('credential:twilio:auth_token');
|
|
19
|
-
const phoneNumber = process.env.TWILIO_PHONE_NUMBER || getSecureKey('credential:twilio:phone_number') || '';
|
|
20
19
|
const config = loadConfig();
|
|
20
|
+
|
|
21
|
+
// Phone number resolution priority:
|
|
22
|
+
// 1. TWILIO_PHONE_NUMBER env var (explicit override)
|
|
23
|
+
// 2. config file sms.phoneNumber (primary storage)
|
|
24
|
+
// 3. credential:twilio:phone_number secure key (backward-compat fallback)
|
|
25
|
+
const phoneNumber =
|
|
26
|
+
process.env.TWILIO_PHONE_NUMBER ||
|
|
27
|
+
config.sms?.phoneNumber ||
|
|
28
|
+
getSecureKey('credential:twilio:phone_number') ||
|
|
29
|
+
'';
|
|
21
30
|
const webhookBaseUrl = getPublicBaseUrl(config);
|
|
22
31
|
|
|
23
32
|
// Always use the centralized relay URL derived from the public ingress base URL.
|
package/src/calls/twilio-rest.ts
CHANGED
|
@@ -154,3 +154,73 @@ export async function provisionPhoneNumber(
|
|
|
154
154
|
capabilities: { voice: data.capabilities.voice, sms: data.capabilities.sms },
|
|
155
155
|
};
|
|
156
156
|
}
|
|
157
|
+
|
|
158
|
+
export interface WebhookUrls {
|
|
159
|
+
voiceUrl: string;
|
|
160
|
+
statusCallbackUrl: string;
|
|
161
|
+
smsUrl: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Update the webhook URLs on a Twilio IncomingPhoneNumber.
|
|
166
|
+
*
|
|
167
|
+
* Configures voice webhook, voice status callback, and SMS webhook so
|
|
168
|
+
* that Twilio routes inbound calls and messages to the assistant's
|
|
169
|
+
* gateway endpoints.
|
|
170
|
+
*/
|
|
171
|
+
export async function updatePhoneNumberWebhooks(
|
|
172
|
+
accountSid: string,
|
|
173
|
+
authToken: string,
|
|
174
|
+
phoneNumber: string,
|
|
175
|
+
webhooks: WebhookUrls,
|
|
176
|
+
): Promise<void> {
|
|
177
|
+
// First, find the SID for this phone number
|
|
178
|
+
const listRes = await fetch(
|
|
179
|
+
`${twilioBaseUrl(accountSid)}/IncomingPhoneNumbers.json?PhoneNumber=${encodeURIComponent(phoneNumber)}`,
|
|
180
|
+
{
|
|
181
|
+
method: 'GET',
|
|
182
|
+
headers: { Authorization: twilioAuthHeader(accountSid, authToken) },
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
if (!listRes.ok) {
|
|
187
|
+
const text = await listRes.text();
|
|
188
|
+
throw new Error(`Twilio API error ${listRes.status} looking up phone number: ${text}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const listData = (await listRes.json()) as {
|
|
192
|
+
incoming_phone_numbers: Array<{ sid: string; phone_number: string }>;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const match = listData.incoming_phone_numbers.find((n) => n.phone_number === phoneNumber);
|
|
196
|
+
if (!match) {
|
|
197
|
+
throw new Error(`Phone number ${phoneNumber} not found on Twilio account ${accountSid}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Update the phone number's webhook configuration
|
|
201
|
+
const body = new URLSearchParams({
|
|
202
|
+
VoiceUrl: webhooks.voiceUrl,
|
|
203
|
+
VoiceMethod: 'POST',
|
|
204
|
+
StatusCallback: webhooks.statusCallbackUrl,
|
|
205
|
+
StatusCallbackMethod: 'POST',
|
|
206
|
+
SmsUrl: webhooks.smsUrl,
|
|
207
|
+
SmsMethod: 'POST',
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const updateRes = await fetch(
|
|
211
|
+
`${twilioBaseUrl(accountSid)}/IncomingPhoneNumbers/${match.sid}.json`,
|
|
212
|
+
{
|
|
213
|
+
method: 'POST',
|
|
214
|
+
headers: {
|
|
215
|
+
Authorization: twilioAuthHeader(accountSid, authToken),
|
|
216
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
217
|
+
},
|
|
218
|
+
body: body.toString(),
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
if (!updateRes.ok) {
|
|
223
|
+
const text = await updateRes.text();
|
|
224
|
+
throw new Error(`Twilio API error ${updateRes.status} updating webhooks: ${text}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
package/src/cli/map.ts
CHANGED
|
@@ -166,6 +166,12 @@ async function startLearnSession(
|
|
|
166
166
|
continue;
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
if (m.type === 'ride_shotgun_progress') {
|
|
170
|
+
// Live progress from auto-navigator
|
|
171
|
+
process.stderr.write(` ${m.message}\n`);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
169
175
|
if (m.type === 'ride_shotgun_result') {
|
|
170
176
|
clearTimeout(timeoutHandle);
|
|
171
177
|
socket.destroy();
|
|
@@ -276,6 +276,73 @@ describe('summary extraction', () => {
|
|
|
276
276
|
expect(entry!.summary).toBe('');
|
|
277
277
|
});
|
|
278
278
|
|
|
279
|
+
test('returns empty summary when frontmatter is truncated by partial read', () => {
|
|
280
|
+
// Simulate frontmatter that exceeds SUMMARY_READ_BYTES (1024).
|
|
281
|
+
// The closing --- delimiter will be cut off, causing FRONTMATTER_REGEX to
|
|
282
|
+
// fail. extractSummary should return '' instead of '---'.
|
|
283
|
+
const largeFrontmatter = '---\n' + 'key: ' + 'x'.repeat(1100) + '\n---\n\nActual summary.';
|
|
284
|
+
createCommandsDir(tmpDir, {
|
|
285
|
+
'big-frontmatter.md': largeFrontmatter,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const registry = discoverCCCommands(tmpDir);
|
|
289
|
+
const entry = registry.entries.get('big-frontmatter');
|
|
290
|
+
expect(entry).toBeDefined();
|
|
291
|
+
expect(entry!.summary).toBe('');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('returns empty summary when frontmatter is truncated (CRLF)', () => {
|
|
295
|
+
const largeFrontmatter = '---\r\n' + 'key: ' + 'x'.repeat(1100) + '\r\n---\r\n\r\nActual summary.';
|
|
296
|
+
createCommandsDir(tmpDir, {
|
|
297
|
+
'big-frontmatter-crlf.md': largeFrontmatter,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const registry = discoverCCCommands(tmpDir);
|
|
301
|
+
const entry = registry.entries.get('big-frontmatter-crlf');
|
|
302
|
+
expect(entry).toBeDefined();
|
|
303
|
+
expect(entry!.summary).toBe('');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('returns empty summary when frontmatter is truncated with multibyte UTF-8 characters', () => {
|
|
307
|
+
// When frontmatter contains multibyte UTF-8 characters (e.g., CJK text),
|
|
308
|
+
// the JavaScript string length (UTF-16 code units) is smaller than the
|
|
309
|
+
// byte length. The truncation guard must compare byte length, not
|
|
310
|
+
// string length, against SUMMARY_READ_BYTES (1024).
|
|
311
|
+
//
|
|
312
|
+
// Each CJK character is 3 bytes in UTF-8 but 1 code unit in UTF-16.
|
|
313
|
+
// We need the total byte count to reach 1024 while string length stays
|
|
314
|
+
// well below 1024 to exercise the bug.
|
|
315
|
+
const cjkChars = '\u4e00'.repeat(340); // 340 chars * 3 bytes = 1020 bytes
|
|
316
|
+
// '---\n' is 4 bytes, so total = 4 + 1020 = 1024 bytes, but string
|
|
317
|
+
// length = 4 + 340 = 344 chars — well under 1024.
|
|
318
|
+
const truncatedContent = '---\n' + cjkChars;
|
|
319
|
+
createCommandsDir(tmpDir, {
|
|
320
|
+
'multibyte-frontmatter.md': truncatedContent,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const registry = discoverCCCommands(tmpDir);
|
|
324
|
+
const entry = registry.entries.get('multibyte-frontmatter');
|
|
325
|
+
expect(entry).toBeDefined();
|
|
326
|
+
// Should return '' because the frontmatter opening delimiter is present
|
|
327
|
+
// but the closing delimiter is missing and the byte length reached the
|
|
328
|
+
// read limit — indicating truncation.
|
|
329
|
+
expect(entry!.summary).toBe('');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('returns summary for small file starting with thematic break ---', () => {
|
|
333
|
+
// A small markdown file that starts with "---" as a thematic break (not
|
|
334
|
+
// frontmatter) should still have its first content line extracted as a
|
|
335
|
+
// summary, rather than being treated as truncated frontmatter.
|
|
336
|
+
createCommandsDir(tmpDir, {
|
|
337
|
+
'thematic-break.md': '---\nThis is a valid summary after a thematic break.',
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const registry = discoverCCCommands(tmpDir);
|
|
341
|
+
const entry = registry.entries.get('thematic-break');
|
|
342
|
+
expect(entry).toBeDefined();
|
|
343
|
+
expect(entry!.summary).toBe('This is a valid summary after a thematic break.');
|
|
344
|
+
});
|
|
345
|
+
|
|
279
346
|
test('handles frontmatter with Windows-style line endings', () => {
|
|
280
347
|
createCommandsDir(tmpDir, {
|
|
281
348
|
'crlf.md': '---\r\ntitle: Test\r\n---\r\n\r\nSummary with CRLF.',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync } from 'node:fs';
|
|
2
2
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { FRONTMATTER_REGEX } from '../skills/frontmatter.js';
|
|
3
4
|
import { getLogger } from '../util/logger.js';
|
|
4
5
|
|
|
5
6
|
const log = getLogger('cc-commands');
|
|
@@ -27,7 +28,6 @@ export interface CCCommandRegistry {
|
|
|
27
28
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
28
29
|
|
|
29
30
|
const COMMAND_NAME_REGEX = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
30
|
-
const FRONTMATTER_REGEX = /^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/;
|
|
31
31
|
const DEFAULT_CACHE_TTL_MS = 30_000;
|
|
32
32
|
const MAX_SUMMARY_LENGTH = 100;
|
|
33
33
|
|
|
@@ -80,6 +80,19 @@ function extractSummary(content: string): string {
|
|
|
80
80
|
const fmMatch = content.match(FRONTMATTER_REGEX);
|
|
81
81
|
if (fmMatch) {
|
|
82
82
|
body = content.slice(fmMatch[0].length);
|
|
83
|
+
} else if (/^---\r?\n/.test(content)) {
|
|
84
|
+
if (Buffer.byteLength(content, 'utf-8') >= SUMMARY_READ_BYTES) {
|
|
85
|
+
// Content starts with a frontmatter opening delimiter but the closing
|
|
86
|
+
// delimiter was not found. The content length reached SUMMARY_READ_BYTES,
|
|
87
|
+
// so the read was likely truncated — the missing closing `---` is
|
|
88
|
+
// probably just beyond the read boundary. Return empty rather than
|
|
89
|
+
// surfacing partial frontmatter fields as a summary.
|
|
90
|
+
return '';
|
|
91
|
+
}
|
|
92
|
+
// Small file that starts with `---` (thematic break or unclosed
|
|
93
|
+
// frontmatter opener). Skip the leading `---` line and extract the
|
|
94
|
+
// first real content line from the remainder.
|
|
95
|
+
body = content.replace(/^---\r?\n/, '');
|
|
83
96
|
}
|
|
84
97
|
|
|
85
98
|
// Find first non-empty line
|
|
@@ -11,7 +11,15 @@
|
|
|
11
11
|
"properties": {
|
|
12
12
|
"prompt": {
|
|
13
13
|
"type": "string",
|
|
14
|
-
"description": "The coding task or question for Claude Code to work on"
|
|
14
|
+
"description": "The coding task or question for Claude Code to work on. Use this for free-form tasks. Mutually exclusive with command."
|
|
15
|
+
},
|
|
16
|
+
"command": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"description": "Name of a .claude/commands/*.md command template to execute. The template will be loaded and $ARGUMENTS substituted before execution. Use this instead of prompt when invoking a named CC command."
|
|
19
|
+
},
|
|
20
|
+
"arguments": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "Arguments to substitute into the command template ($ARGUMENTS placeholder). Only used with the command input."
|
|
15
23
|
},
|
|
16
24
|
"working_dir": {
|
|
17
25
|
"type": "string",
|
|
@@ -30,8 +38,7 @@
|
|
|
30
38
|
"enum": ["general", "researcher", "coder", "reviewer"],
|
|
31
39
|
"description": "Worker profile that scopes tool access. Defaults to general (backward compatible)."
|
|
32
40
|
}
|
|
33
|
-
}
|
|
34
|
-
"required": ["prompt"]
|
|
41
|
+
}
|
|
35
42
|
},
|
|
36
43
|
"executor": "tools/claude-code.ts",
|
|
37
44
|
"execution_target": "host"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "Email Setup"
|
|
3
|
+
description: "Create the assistant's own email address via the Vellum hosted API (one-time setup)"
|
|
4
|
+
user-invocable: true
|
|
5
|
+
metadata: {"vellum": {"emoji": "📧"}}
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
You are setting up your own personal email address. This is a one-time operation — once you have an email, you do not need to run this again.
|
|
9
|
+
|
|
10
|
+
## Prerequisites
|
|
11
|
+
|
|
12
|
+
Only proceed if the user explicitly asks you to create or set up your email address. Do NOT proactively run this skill.
|
|
13
|
+
|
|
14
|
+
## Step 1: Check if Email Already Exists
|
|
15
|
+
|
|
16
|
+
Before doing anything, check whether you already have an email address configured:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
vellum email status
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
This will return your email address, the callback URL that inbound email will hit, and the path to the inbox locally. If you already have an email address, tell the user your existing email address and stop — do NOT create another one.
|
|
23
|
+
|
|
24
|
+
## Step 2: Create Your Email
|
|
25
|
+
|
|
26
|
+
Call the Vellum hosted API to provision your email. Use `host_bash` to make the request:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
vellum email create <your-username>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
For `<your-username>`, use your assistant name (lowercased, alphanumeric only). Check your identity from `IDENTITY.md` or `USER.md` to determine your name. If you don't have a name yet, ask the user what username they'd like for your email.
|
|
33
|
+
|
|
34
|
+
## Step 3: Confirm Setup
|
|
35
|
+
|
|
36
|
+
After the inbox is created successfully:
|
|
37
|
+
|
|
38
|
+
1. Parse the command output to extract your new email address.
|
|
39
|
+
2. Tell the user your new email address.
|
|
40
|
+
3. Store a note in your memory or `USER.md` that your email has been provisioned so you remember it in future conversations.
|
|
41
|
+
|
|
42
|
+
## Rules
|
|
43
|
+
|
|
44
|
+
- **One-time only.** If an inbox already exists (Step 1), do not create another. Inform the user of the existing address.
|
|
45
|
+
- **User-initiated only.** Never run this skill unless the user asks you to set up or create an email.
|
|
46
|
+
- **No custom domains.** Use the default provider domain. Do not attempt domain setup.
|
|
47
|
+
- **No API key prompting.** The email API key should already be configured. If the `vellum email` command fails with an API key error, tell the user the email integration is not yet configured and ask them to set it up.
|
|
48
|
+
|
|
49
|
+
## Troubleshooting
|
|
50
|
+
|
|
51
|
+
### API key not configured
|
|
52
|
+
If you get an error about a missing API key, the email provider has not been set up. Tell the user:
|
|
53
|
+
> "Email isn't configured yet. Please set up the email integration first."
|
|
54
|
+
|
|
55
|
+
### Inbox creation failed
|
|
56
|
+
If inbox creation returns an error (e.g. username taken), try a variation of the name (append a number or use a nickname) and retry once. If it still fails, report the error to the user.
|
|
@@ -39,6 +39,8 @@ Telegram uses a bot token (not OAuth). Install and load the **telegram-setup** s
|
|
|
39
39
|
|
|
40
40
|
The telegram-setup skill handles: verifying the bot token from @BotFather, generating a webhook secret, registering bot commands, and storing credentials securely via the secure credential prompt flow. **Never accept a Telegram bot token pasted in plaintext chat — always use the secure prompt.** Webhook registration with Telegram is handled automatically by the gateway on startup and whenever credentials change.
|
|
41
41
|
|
|
42
|
+
The telegram-setup skill also includes **guardian verification**, which links your Telegram account as the trusted guardian for the bot.
|
|
43
|
+
|
|
42
44
|
### SMS (Twilio)
|
|
43
45
|
SMS messaging uses Twilio as the telephony provider. Twilio credentials and phone number configuration are shared with the **phone-calls** skill. Load the **twilio-setup** skill to configure Twilio:
|
|
44
46
|
- Call `vellum_skills_catalog` with `action: "install"` and `skill_id: "twilio-setup"`.
|
|
@@ -47,6 +49,8 @@ SMS messaging uses Twilio as the telephony provider. Twilio credentials and phon
|
|
|
47
49
|
|
|
48
50
|
The twilio-setup skill handles: credential storage (Account SID + Auth Token), phone number provisioning or assignment, and public ingress setup. Once Twilio is configured, SMS is available automatically — no additional feature flag is needed. The assistant's Twilio phone number is used for both outbound SMS and voice calls.
|
|
49
51
|
|
|
52
|
+
The twilio-setup skill also includes optional **guardian verification** for SMS, which links your phone number as the trusted guardian. This is the same guardian concept used by Telegram — it ensures only verified users can approve sensitive operations via SMS.
|
|
53
|
+
|
|
50
54
|
## Platform Selection
|
|
51
55
|
|
|
52
56
|
- If the user specifies a platform (e.g., "check my Slack"), pass it as the `platform` parameter.
|
|
@@ -18,6 +18,10 @@ Subagents follow this status flow: `pending` -> `running` -> `completed` / `fail
|
|
|
18
18
|
|
|
19
19
|
Only the parent session that spawned a subagent can interact with it (check status, send messages, abort, or read output).
|
|
20
20
|
|
|
21
|
+
## Silent Mode
|
|
22
|
+
|
|
23
|
+
Set `send_result_to_user: false` when spawning a subagent whose result is for internal processing only. The parent will still be notified on completion, but the notification will instruct it to read the result without presenting it to the user.
|
|
24
|
+
|
|
21
25
|
## Tips
|
|
22
26
|
|
|
23
27
|
- Do NOT poll `subagent_status` in a loop. You will be notified automatically when a subagent completes.
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
"context": {
|
|
21
21
|
"type": "string",
|
|
22
22
|
"description": "Optional additional context to pass to the subagent"
|
|
23
|
+
},
|
|
24
|
+
"send_result_to_user": {
|
|
25
|
+
"type": "boolean",
|
|
26
|
+
"description": "Whether to present the subagent's result to the user when it completes. Defaults to true. Set to false for internal/silent processing."
|
|
23
27
|
}
|
|
24
28
|
},
|
|
25
29
|
"required": ["label", "objective"]
|
package/src/config/defaults.ts
CHANGED
|
@@ -224,7 +224,7 @@ export const DEFAULT_CONFIG: AssistantConfig = {
|
|
|
224
224
|
userConsultTimeoutSeconds: 120,
|
|
225
225
|
disclosure: {
|
|
226
226
|
enabled: true,
|
|
227
|
-
text: 'At the very beginning of the call,
|
|
227
|
+
text: 'At the very beginning of the call, introduce yourself as an assistant calling on behalf of the user.',
|
|
228
228
|
},
|
|
229
229
|
safety: {
|
|
230
230
|
denyCategories: [],
|
package/src/config/schema.ts
CHANGED
|
@@ -907,7 +907,7 @@ export const CallsDisclosureConfigSchema = z.object({
|
|
|
907
907
|
.default(true),
|
|
908
908
|
text: z
|
|
909
909
|
.string({ error: 'calls.disclosure.text must be a string' })
|
|
910
|
-
.default('At the very beginning of the call,
|
|
910
|
+
.default('At the very beginning of the call, introduce yourself as an assistant calling on behalf of the user.'),
|
|
911
911
|
});
|
|
912
912
|
|
|
913
913
|
export const CallsSafetyConfigSchema = z.object({
|
|
@@ -1017,7 +1017,7 @@ export const CallsConfigSchema = z.object({
|
|
|
1017
1017
|
.default(120),
|
|
1018
1018
|
disclosure: CallsDisclosureConfigSchema.default({
|
|
1019
1019
|
enabled: true,
|
|
1020
|
-
text: 'At the very beginning of the call,
|
|
1020
|
+
text: 'At the very beginning of the call, introduce yourself as an assistant calling on behalf of the user.',
|
|
1021
1021
|
}),
|
|
1022
1022
|
safety: CallsSafetyConfigSchema.default({
|
|
1023
1023
|
denyCategories: [],
|
|
@@ -1066,6 +1066,9 @@ export const SmsConfigSchema = z.object({
|
|
|
1066
1066
|
phoneNumber: z
|
|
1067
1067
|
.string({ error: 'sms.phoneNumber must be a string' })
|
|
1068
1068
|
.default(''),
|
|
1069
|
+
assistantPhoneNumbers: z
|
|
1070
|
+
.record(z.string(), z.string({ error: 'sms.assistantPhoneNumbers values must be strings' }))
|
|
1071
|
+
.optional(),
|
|
1069
1072
|
});
|
|
1070
1073
|
|
|
1071
1074
|
const IngressBaseSchema = z.object({
|
|
@@ -1337,7 +1340,7 @@ export const AssistantConfigSchema = z.object({
|
|
|
1337
1340
|
userConsultTimeoutSeconds: 120,
|
|
1338
1341
|
disclosure: {
|
|
1339
1342
|
enabled: true,
|
|
1340
|
-
text: 'At the very beginning of the call,
|
|
1343
|
+
text: 'At the very beginning of the call, introduce yourself as an assistant calling on behalf of the user.',
|
|
1341
1344
|
},
|
|
1342
1345
|
safety: {
|
|
1343
1346
|
denyCategories: [],
|
package/src/config/skills.ts
CHANGED
|
@@ -5,13 +5,12 @@ import { getConfig } from './loader.js';
|
|
|
5
5
|
import { getWorkspaceSkillsDir } from '../util/platform.js';
|
|
6
6
|
import { getLogger } from '../util/logger.js';
|
|
7
7
|
import { stripCommentLines } from './system-prompt.js';
|
|
8
|
+
import { parseFrontmatterFields } from '../skills/frontmatter.js';
|
|
8
9
|
import { parseToolManifestFile } from '../skills/tool-manifest.js';
|
|
9
10
|
import { computeSkillVersionHash } from '../skills/version-hash.js';
|
|
10
11
|
|
|
11
12
|
const log = getLogger('skills');
|
|
12
13
|
|
|
13
|
-
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
|
14
|
-
|
|
15
14
|
// ─── New interfaces for extended skill metadata ──────────────────────────────
|
|
16
15
|
|
|
17
16
|
export interface VellumMetadata {
|
|
@@ -266,39 +265,13 @@ function parseIncludes(raw: string | undefined, skillFilePath: string): string[]
|
|
|
266
265
|
}
|
|
267
266
|
|
|
268
267
|
function parseFrontmatter(content: string, skillFilePath: string): ParsedFrontmatter | null {
|
|
269
|
-
const
|
|
270
|
-
if (!
|
|
268
|
+
const result = parseFrontmatterFields(content);
|
|
269
|
+
if (!result) {
|
|
271
270
|
log.warn({ skillFilePath }, 'Skipping skill without YAML frontmatter');
|
|
272
271
|
return null;
|
|
273
272
|
}
|
|
274
273
|
|
|
275
|
-
const
|
|
276
|
-
const fields: Record<string, string> = {};
|
|
277
|
-
for (const line of frontmatter.split(/\r?\n/)) {
|
|
278
|
-
const trimmed = line.trim();
|
|
279
|
-
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
280
|
-
const separatorIndex = trimmed.indexOf(':');
|
|
281
|
-
if (separatorIndex === -1) continue;
|
|
282
|
-
|
|
283
|
-
const key = trimmed.slice(0, separatorIndex).trim();
|
|
284
|
-
let value = trimmed.slice(separatorIndex + 1).trim();
|
|
285
|
-
const isDoubleQuoted = value.startsWith('"') && value.endsWith('"');
|
|
286
|
-
const isSingleQuoted = value.startsWith('\'') && value.endsWith('\'');
|
|
287
|
-
if (isDoubleQuoted || isSingleQuoted) {
|
|
288
|
-
value = value.slice(1, -1);
|
|
289
|
-
if (isDoubleQuoted) {
|
|
290
|
-
// Unescape sequences produced by buildSkillMarkdown's esc().
|
|
291
|
-
// Only for double-quoted values — single-quoted YAML treats backslashes literally.
|
|
292
|
-
// Single-pass to avoid misinterpreting \\n (escaped backslash + n) as a newline.
|
|
293
|
-
value = value.replace(/\\(["\\nr])/g, (_, ch) => {
|
|
294
|
-
if (ch === 'n') return '\n';
|
|
295
|
-
if (ch === 'r') return '\r';
|
|
296
|
-
return ch; // handles \\ → \ and \" → "
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
fields[key] = value;
|
|
301
|
-
}
|
|
274
|
+
const { fields, body } = result;
|
|
302
275
|
|
|
303
276
|
const name = fields.name?.trim();
|
|
304
277
|
const description = fields.description?.trim();
|
|
@@ -335,7 +308,7 @@ function parseFrontmatter(content: string, skillFilePath: string): ParsedFrontma
|
|
|
335
308
|
return {
|
|
336
309
|
name,
|
|
337
310
|
description,
|
|
338
|
-
body: stripCommentLines(
|
|
311
|
+
body: stripCommentLines(body),
|
|
339
312
|
homepage,
|
|
340
313
|
userInvocable,
|
|
341
314
|
disableModelInvocation,
|
|
@@ -5,6 +5,7 @@ import { getLogger } from '../util/logger.js';
|
|
|
5
5
|
import { loadSkillCatalog, type SkillSummary } from './skills.js';
|
|
6
6
|
import { getConfig } from './loader.js';
|
|
7
7
|
import { listCredentialMetadata } from '../tools/credentials/metadata-store.js';
|
|
8
|
+
import { resolveUserReference } from './user-reference.js';
|
|
8
9
|
|
|
9
10
|
const log = getLogger('system-prompt');
|
|
10
11
|
|
|
@@ -116,6 +117,7 @@ export function buildSystemPrompt(): string {
|
|
|
116
117
|
parts.push(buildToolPermissionSection());
|
|
117
118
|
parts.push(buildSystemPermissionSection());
|
|
118
119
|
parts.push(buildChannelAwarenessSection());
|
|
120
|
+
parts.push(buildExternalCommsIdentitySection());
|
|
119
121
|
parts.push(buildSwarmGuidanceSection());
|
|
120
122
|
parts.push(buildAccessPreferenceSection());
|
|
121
123
|
parts.push(buildIntegrationSection());
|
|
@@ -378,6 +380,20 @@ export function buildChannelAwarenessSection(): string {
|
|
|
378
380
|
].join('\n');
|
|
379
381
|
}
|
|
380
382
|
|
|
383
|
+
export function buildExternalCommsIdentitySection(): string {
|
|
384
|
+
const userRef = resolveUserReference();
|
|
385
|
+
return [
|
|
386
|
+
'## External Communications Identity',
|
|
387
|
+
'',
|
|
388
|
+
'When communicating with people outside your user\'s household (phone calls, messages to non-guardian contacts, emails to third parties, etc.):',
|
|
389
|
+
'',
|
|
390
|
+
`- Refer to yourself as an **assistant**, not an "AI assistant" or "artificial intelligence". Keep identity framing simple and natural.`,
|
|
391
|
+
`- When introducing who you represent, say you are acting on behalf of **${userRef}**.`,
|
|
392
|
+
'- Do not volunteer that you are an AI unless directly asked. If asked, answer honestly.',
|
|
393
|
+
'- This is guidance for natural, human-like communication — not a hard constraint. Occasional variations are acceptable.',
|
|
394
|
+
].join('\n');
|
|
395
|
+
}
|
|
396
|
+
|
|
381
397
|
export function buildSwarmGuidanceSection(): string {
|
|
382
398
|
return [
|
|
383
399
|
'## Parallel Task Orchestration',
|