@vellumai/assistant 0.3.3 → 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.
Files changed (75) hide show
  1. package/README.md +8 -16
  2. package/package.json +1 -1
  3. package/src/__tests__/call-orchestrator.test.ts +321 -0
  4. package/src/__tests__/channel-approval-routes.test.ts +382 -124
  5. package/src/__tests__/channel-approvals.test.ts +51 -2
  6. package/src/__tests__/channel-delivery-store.test.ts +30 -4
  7. package/src/__tests__/channel-guardian.test.ts +187 -0
  8. package/src/__tests__/config-schema.test.ts +1 -1
  9. package/src/__tests__/daemon-lifecycle.test.ts +635 -0
  10. package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
  11. package/src/__tests__/handlers-twilio-config.test.ts +73 -0
  12. package/src/__tests__/secret-scanner.test.ts +223 -0
  13. package/src/__tests__/shell-parser-property.test.ts +357 -2
  14. package/src/__tests__/system-prompt.test.ts +25 -1
  15. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  16. package/src/__tests__/user-reference.test.ts +68 -0
  17. package/src/calls/call-orchestrator.ts +63 -11
  18. package/src/cli/map.ts +6 -0
  19. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  20. package/src/commands/cc-command-registry.ts +14 -1
  21. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  22. package/src/config/bundled-skills/messaging/SKILL.md +4 -0
  23. package/src/config/defaults.ts +1 -1
  24. package/src/config/schema.ts +3 -3
  25. package/src/config/skills.ts +5 -32
  26. package/src/config/system-prompt.ts +16 -0
  27. package/src/config/user-reference.ts +29 -0
  28. package/src/config/vellum-skills/catalog.json +52 -0
  29. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  30. package/src/config/vellum-skills/twilio-setup/SKILL.md +38 -0
  31. package/src/daemon/auth-manager.ts +103 -0
  32. package/src/daemon/computer-use-session.ts +8 -1
  33. package/src/daemon/config-watcher.ts +253 -0
  34. package/src/daemon/handlers/config.ts +36 -13
  35. package/src/daemon/handlers/skills.ts +6 -7
  36. package/src/daemon/ipc-contract.ts +6 -0
  37. package/src/daemon/ipc-handler.ts +87 -0
  38. package/src/daemon/lifecycle.ts +16 -4
  39. package/src/daemon/ride-shotgun-handler.ts +11 -1
  40. package/src/daemon/server.ts +105 -502
  41. package/src/daemon/session-agent-loop.ts +5 -14
  42. package/src/daemon/session-runtime-assembly.ts +60 -44
  43. package/src/daemon/session.ts +8 -1
  44. package/src/memory/db-connection.ts +28 -0
  45. package/src/memory/db-init.ts +1019 -0
  46. package/src/memory/db.ts +2 -2007
  47. package/src/memory/embedding-backend.ts +79 -11
  48. package/src/memory/indexer.ts +2 -0
  49. package/src/memory/job-utils.ts +64 -4
  50. package/src/memory/jobs-worker.ts +7 -1
  51. package/src/memory/recall-cache.ts +107 -0
  52. package/src/memory/retriever.ts +30 -1
  53. package/src/memory/schema-migration.ts +984 -0
  54. package/src/memory/schema.ts +1 -0
  55. package/src/memory/search/types.ts +2 -0
  56. package/src/permissions/prompter.ts +14 -3
  57. package/src/permissions/trust-store.ts +7 -0
  58. package/src/runtime/channel-approvals.ts +17 -3
  59. package/src/runtime/gateway-client.ts +2 -1
  60. package/src/runtime/http-server.ts +15 -4
  61. package/src/runtime/routes/channel-routes.ts +172 -84
  62. package/src/runtime/routes/run-routes.ts +7 -1
  63. package/src/runtime/run-orchestrator.ts +8 -1
  64. package/src/security/secret-scanner.ts +218 -0
  65. package/src/skills/frontmatter.ts +63 -0
  66. package/src/skills/slash-commands.ts +23 -0
  67. package/src/skills/vellum-catalog-remote.ts +107 -0
  68. package/src/tools/browser/auto-navigate.ts +132 -24
  69. package/src/tools/browser/browser-manager.ts +67 -61
  70. package/src/tools/claude-code/claude-code.ts +55 -3
  71. package/src/tools/executor.ts +10 -2
  72. package/src/tools/skills/vellum-catalog.ts +61 -156
  73. package/src/tools/terminal/parser.ts +21 -5
  74. package/src/util/platform.ts +8 -1
  75. package/src/util/retry.ts +4 -4
@@ -50,8 +50,12 @@ mock.module('../config/loader.js', () => ({
50
50
  }),
51
51
  }));
52
52
 
53
+ mock.module('../config/user-reference.js', () => ({
54
+ resolveUserReference: () => 'John',
55
+ }));
56
+
53
57
  // Import after mock
54
- const { buildSystemPrompt, ensurePromptFiles, stripCommentLines } = await import('../config/system-prompt.js');
58
+ const { buildSystemPrompt, ensurePromptFiles, stripCommentLines, buildExternalCommsIdentitySection } = await import('../config/system-prompt.js');
55
59
 
56
60
  /** Strip the Configuration and Skills sections so base-prompt tests stay focused. */
57
61
  function basePrompt(result: string): string {
@@ -167,6 +171,26 @@ describe('buildSystemPrompt', () => {
167
171
  expect(result).toContain('Browser automation as last resort');
168
172
  });
169
173
 
174
+ test('includes external comms identity section', () => {
175
+ const result = buildSystemPrompt();
176
+ expect(result).toContain('## External Communications Identity');
177
+ });
178
+
179
+ test('external comms identity section contains assistant guidance and resolved user reference', () => {
180
+ const result = buildSystemPrompt();
181
+ expect(result).toContain('Refer to yourself as an **assistant**');
182
+ expect(result).toContain('on behalf of **John**');
183
+ });
184
+
185
+ test('buildExternalCommsIdentitySection returns section with expected content', () => {
186
+ const section = buildExternalCommsIdentitySection();
187
+ expect(section).toContain('## External Communications Identity');
188
+ expect(section).toContain('assistant');
189
+ expect(section).toContain('John');
190
+ expect(section).toContain('Do not volunteer that you are an AI unless directly asked');
191
+ expect(section).toContain('Occasional variations are acceptable');
192
+ });
193
+
170
194
  test('config section uses workspace directory from platform util', () => {
171
195
  const result = buildSystemPrompt();
172
196
  expect(result).toContain(`Your workspace is mounted at \`/workspace/\` inside the Docker sandbox (host path: \`${TEST_DIR}/\`)`);
@@ -143,7 +143,12 @@ function makeContext(events: ToolLifecycleEvent[]) {
143
143
  };
144
144
  }
145
145
 
146
- function makePrompter(promptImpl?: () => Promise<{ decision: 'allow' | 'always_allow' | 'deny' | 'always_deny' }>) {
146
+ function makePrompter(
147
+ promptImpl?: () => Promise<{
148
+ decision: 'allow' | 'always_allow' | 'deny' | 'always_deny';
149
+ decisionContext?: string;
150
+ }>,
151
+ ) {
147
152
  return {
148
153
  prompt: promptImpl ?? (async () => ({ decision: promptDecision })),
149
154
  resolveConfirmation: () => {},
@@ -225,6 +230,34 @@ describe('ToolExecutor lifecycle events', () => {
225
230
  expect(deniedEvent.reason).toBe('Permission denied by user');
226
231
  });
227
232
 
233
+ test('uses contextual deny messaging when provided by prompter', async () => {
234
+ checkerDecision = 'prompt';
235
+ checkerReason = 'guardrail prompt';
236
+ checkerRisk = 'high';
237
+ sandboxed = true;
238
+
239
+ const events: ToolLifecycleEvent[] = [];
240
+ const executor = new ToolExecutor(
241
+ makePrompter(async () => ({
242
+ decision: 'deny',
243
+ decisionContext:
244
+ 'Permission denied: this action requires guardian setup before retrying. Explain this and provide setup steps.',
245
+ })),
246
+ );
247
+
248
+ const result = await executor.execute('bash', { command: 'echo hi' }, makeContext(events));
249
+
250
+ expect(result.isError).toBe(true);
251
+ expect(result.content).toContain('requires guardian setup');
252
+ expect(result.content).not.toContain('Permission denied by user');
253
+
254
+ const deniedEvent = events.find((event) => event.type === 'permission_denied');
255
+ if (!deniedEvent || deniedEvent.type !== 'permission_denied') {
256
+ throw new Error('Expected permission_denied event');
257
+ }
258
+ expect(deniedEvent.reason).toBe('Permission denied (bash): contextual policy');
259
+ });
260
+
228
261
  test('emits host executionTarget for host tools', async () => {
229
262
  const events: ToolLifecycleEvent[] = [];
230
263
  const executor = new ToolExecutor(makePrompter());
@@ -0,0 +1,68 @@
1
+ import { describe, test, expect, mock, beforeEach } from 'bun:test';
2
+ import { join } from 'node:path';
3
+
4
+ const TEST_DIR = '/tmp/vellum-user-ref-test';
5
+
6
+ mock.module('../util/platform.js', () => ({
7
+ getWorkspacePromptPath: (file: string) => join(TEST_DIR, file),
8
+ }));
9
+
10
+ // Mutable state the tests control
11
+ let mockFileExists = false;
12
+ let mockFileContent = '';
13
+
14
+ mock.module('node:fs', () => ({
15
+ existsSync: (path: string) => {
16
+ if (path === join(TEST_DIR, 'USER.md')) return mockFileExists;
17
+ return false;
18
+ },
19
+ readFileSync: (path: string, _encoding: string) => {
20
+ if (path === join(TEST_DIR, 'USER.md') && mockFileExists) return mockFileContent;
21
+ throw new Error(`ENOENT: no such file: ${path}`);
22
+ },
23
+ }));
24
+
25
+ // Import after mocks are in place
26
+ const { resolveUserReference } = await import('../config/user-reference.js');
27
+
28
+ describe('resolveUserReference', () => {
29
+ beforeEach(() => {
30
+ mockFileExists = false;
31
+ mockFileContent = '';
32
+ });
33
+
34
+ test('returns "my human" when USER.md does not exist', () => {
35
+ mockFileExists = false;
36
+ expect(resolveUserReference()).toBe('my human');
37
+ });
38
+
39
+ test('returns "my human" when preferred name field is empty', () => {
40
+ mockFileExists = true;
41
+ mockFileContent = [
42
+ '## Onboarding Snapshot',
43
+ '',
44
+ '- Preferred name/reference:',
45
+ '- Goals:',
46
+ '- Locale:',
47
+ ].join('\n');
48
+ expect(resolveUserReference()).toBe('my human');
49
+ });
50
+
51
+ test('returns the configured name when it is set', () => {
52
+ mockFileExists = true;
53
+ mockFileContent = [
54
+ '## Onboarding Snapshot',
55
+ '',
56
+ '- Preferred name/reference: John',
57
+ '- Goals: ship fast',
58
+ '- Locale: en-US',
59
+ ].join('\n');
60
+ expect(resolveUserReference()).toBe('John');
61
+ });
62
+
63
+ test('trims whitespace around the configured name', () => {
64
+ mockFileExists = true;
65
+ mockFileContent = '- Preferred name/reference: Alice \n';
66
+ expect(resolveUserReference()).toBe('Alice');
67
+ });
68
+ });
@@ -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 (this.state === 'processing' || this.state === 'speaking') {
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
- // Append caller utterance
77
- this.conversationHistory.push({
78
- role: 'user',
79
- content: this.formatCallerUtterance(transcript, speaker),
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
- 'You are on a live phone call on behalf of your user.',
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: this.abortController.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 instanceof Error && err.name === 'AbortError') {
416
- log.debug({ callSessionId: this.callSessionId }, 'LLM request aborted');
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
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"
@@ -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.
@@ -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, disclose that you are an AI assistant calling on behalf of the user.',
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: [],
@@ -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, disclose that you are an AI assistant calling on behalf of the user.'),
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, disclose that you are an AI assistant calling on behalf of the user.',
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: [],
@@ -1340,7 +1340,7 @@ export const AssistantConfigSchema = z.object({
1340
1340
  userConsultTimeoutSeconds: 120,
1341
1341
  disclosure: {
1342
1342
  enabled: true,
1343
- text: 'At the very beginning of the call, disclose that you are an AI assistant calling on behalf of the user.',
1343
+ text: 'At the very beginning of the call, introduce yourself as an assistant calling on behalf of the user.',
1344
1344
  },
1345
1345
  safety: {
1346
1346
  denyCategories: [],
@@ -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 match = content.match(FRONTMATTER_REGEX);
270
- if (!match) {
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 frontmatter = match[1];
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(content.slice(match[0].length)),
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',