@vellumai/assistant 0.5.2 → 0.5.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 (144) hide show
  1. package/ARCHITECTURE.md +109 -0
  2. package/docs/architecture/memory.md +105 -0
  3. package/docs/skills.md +100 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/archive-recall.test.ts +560 -0
  6. package/src/__tests__/conversation-agent-loop-overflow.test.ts +7 -0
  7. package/src/__tests__/conversation-agent-loop.test.ts +7 -0
  8. package/src/__tests__/conversation-clear-safety.test.ts +259 -0
  9. package/src/__tests__/conversation-memory-dirty-tail.test.ts +150 -0
  10. package/src/__tests__/conversation-provider-retry-repair.test.ts +7 -0
  11. package/src/__tests__/conversation-switch-memory-reduction.test.ts +474 -0
  12. package/src/__tests__/conversation-wipe.test.ts +226 -0
  13. package/src/__tests__/db-memory-archive-migration.test.ts +372 -0
  14. package/src/__tests__/db-memory-brief-state-migration.test.ts +213 -0
  15. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +273 -0
  16. package/src/__tests__/db-schedule-syntax-migration.test.ts +3 -0
  17. package/src/__tests__/inline-command-runner.test.ts +311 -0
  18. package/src/__tests__/inline-skill-authoring-guard.test.ts +220 -0
  19. package/src/__tests__/inline-skill-load-permissions.test.ts +435 -0
  20. package/src/__tests__/list-messages-attachments.test.ts +96 -0
  21. package/src/__tests__/memory-brief-open-loops.test.ts +530 -0
  22. package/src/__tests__/memory-brief-time.test.ts +285 -0
  23. package/src/__tests__/memory-brief-wrapper.test.ts +311 -0
  24. package/src/__tests__/memory-chunk-archive.test.ts +400 -0
  25. package/src/__tests__/memory-chunk-dual-write.test.ts +453 -0
  26. package/src/__tests__/memory-episode-archive.test.ts +370 -0
  27. package/src/__tests__/memory-episode-dual-write.test.ts +626 -0
  28. package/src/__tests__/memory-observation-archive.test.ts +375 -0
  29. package/src/__tests__/memory-observation-dual-write.test.ts +318 -0
  30. package/src/__tests__/memory-recall-quality.test.ts +2 -2
  31. package/src/__tests__/memory-reducer-job.test.ts +538 -0
  32. package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
  33. package/src/__tests__/memory-reducer-store.test.ts +728 -0
  34. package/src/__tests__/memory-reducer-types.test.ts +707 -0
  35. package/src/__tests__/memory-reducer.test.ts +704 -0
  36. package/src/__tests__/memory-regressions.test.ts +30 -8
  37. package/src/__tests__/memory-simplified-config.test.ts +281 -0
  38. package/src/__tests__/parse-identity-fields.test.ts +129 -0
  39. package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
  40. package/src/__tests__/simplified-memory-runtime.test.ts +616 -0
  41. package/src/__tests__/skill-load-inline-command.test.ts +598 -0
  42. package/src/__tests__/skill-load-inline-includes.test.ts +644 -0
  43. package/src/__tests__/skills-inline-command-expansions.test.ts +301 -0
  44. package/src/__tests__/skills-transitive-hash.test.ts +333 -0
  45. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +320 -0
  46. package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +4 -4
  47. package/src/cli/commands/conversations.ts +18 -0
  48. package/src/config/bundled-skills/app-builder/SKILL.md +8 -8
  49. package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
  50. package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
  51. package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
  52. package/src/config/feature-flag-registry.json +16 -0
  53. package/src/config/raw-config-utils.ts +28 -0
  54. package/src/config/schema.ts +12 -0
  55. package/src/config/schemas/memory-simplified.ts +101 -0
  56. package/src/config/schemas/memory.ts +4 -0
  57. package/src/config/skills.ts +50 -4
  58. package/src/daemon/conversation-agent-loop-handlers.ts +8 -3
  59. package/src/daemon/conversation-agent-loop.ts +71 -1
  60. package/src/daemon/conversation-lifecycle.ts +11 -1
  61. package/src/daemon/conversation-memory.ts +117 -0
  62. package/src/daemon/conversation-runtime-assembly.ts +3 -1
  63. package/src/daemon/conversation-surfaces.ts +31 -8
  64. package/src/daemon/conversation.ts +40 -23
  65. package/src/daemon/handlers/config-embeddings.ts +10 -2
  66. package/src/daemon/handlers/config-model.ts +0 -9
  67. package/src/daemon/handlers/conversations.ts +11 -0
  68. package/src/daemon/handlers/identity.ts +12 -1
  69. package/src/daemon/lifecycle.ts +52 -1
  70. package/src/daemon/message-types/conversations.ts +0 -1
  71. package/src/daemon/server.ts +1 -1
  72. package/src/followups/followup-store.ts +47 -1
  73. package/src/memory/archive-recall.ts +516 -0
  74. package/src/memory/archive-store.ts +400 -0
  75. package/src/memory/brief-formatting.ts +33 -0
  76. package/src/memory/brief-open-loops.ts +266 -0
  77. package/src/memory/brief-time.ts +162 -0
  78. package/src/memory/brief.ts +75 -0
  79. package/src/memory/conversation-crud.ts +455 -101
  80. package/src/memory/conversation-key-store.ts +33 -4
  81. package/src/memory/db-init.ts +16 -0
  82. package/src/memory/indexer.ts +106 -15
  83. package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
  84. package/src/memory/job-handlers/conversation-starters.ts +9 -3
  85. package/src/memory/job-handlers/embedding.test.ts +1 -0
  86. package/src/memory/job-handlers/embedding.ts +83 -0
  87. package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
  88. package/src/memory/job-utils.ts +1 -1
  89. package/src/memory/jobs-store.ts +8 -0
  90. package/src/memory/jobs-worker.ts +20 -0
  91. package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
  92. package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
  93. package/src/memory/migrations/141-rename-verification-table.ts +8 -0
  94. package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
  95. package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -0
  96. package/src/memory/migrations/185-memory-brief-state.ts +52 -0
  97. package/src/memory/migrations/186-memory-archive.ts +109 -0
  98. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +19 -0
  99. package/src/memory/migrations/188-schedule-quiet-flag.ts +13 -0
  100. package/src/memory/migrations/index.ts +4 -0
  101. package/src/memory/qdrant-client.ts +23 -4
  102. package/src/memory/reducer-scheduler.ts +242 -0
  103. package/src/memory/reducer-store.ts +271 -0
  104. package/src/memory/reducer-types.ts +106 -0
  105. package/src/memory/reducer.ts +467 -0
  106. package/src/memory/schema/conversations.ts +3 -0
  107. package/src/memory/schema/index.ts +2 -0
  108. package/src/memory/schema/infrastructure.ts +1 -0
  109. package/src/memory/schema/memory-archive.ts +121 -0
  110. package/src/memory/schema/memory-brief.ts +55 -0
  111. package/src/memory/search/semantic.ts +17 -4
  112. package/src/oauth/oauth-store.ts +3 -1
  113. package/src/permissions/checker.ts +89 -6
  114. package/src/permissions/defaults.ts +14 -0
  115. package/src/runtime/auth/route-policy.ts +10 -1
  116. package/src/runtime/routes/conversation-management-routes.ts +94 -2
  117. package/src/runtime/routes/conversation-query-routes.ts +7 -0
  118. package/src/runtime/routes/conversation-routes.ts +52 -5
  119. package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
  120. package/src/runtime/routes/identity-routes.ts +2 -35
  121. package/src/runtime/routes/llm-context-normalization.ts +14 -1
  122. package/src/runtime/routes/memory-item-routes.ts +90 -5
  123. package/src/runtime/routes/secret-routes.ts +3 -0
  124. package/src/runtime/routes/surface-action-routes.ts +68 -1
  125. package/src/schedule/schedule-store.ts +28 -0
  126. package/src/schedule/scheduler.ts +6 -2
  127. package/src/skills/inline-command-expansions.ts +204 -0
  128. package/src/skills/inline-command-render.ts +127 -0
  129. package/src/skills/inline-command-runner.ts +242 -0
  130. package/src/skills/transitive-version-hash.ts +88 -0
  131. package/src/tasks/task-store.ts +43 -1
  132. package/src/telemetry/usage-telemetry-reporter.ts +1 -1
  133. package/src/tools/filesystem/edit.ts +6 -1
  134. package/src/tools/filesystem/read.ts +6 -1
  135. package/src/tools/filesystem/write.ts +6 -1
  136. package/src/tools/memory/handlers.ts +129 -1
  137. package/src/tools/permission-checker.ts +8 -1
  138. package/src/tools/schedule/create.ts +3 -0
  139. package/src/tools/schedule/list.ts +5 -1
  140. package/src/tools/schedule/update.ts +6 -0
  141. package/src/tools/skills/load.ts +140 -6
  142. package/src/util/platform.ts +18 -0
  143. package/src/workspace/migrations/{002-backfill-installation-id.ts → 011-backfill-installation-id.ts} +1 -1
  144. package/src/workspace/migrations/registry.ts +1 -1
@@ -1,4 +1,4 @@
1
- import { desc, eq, inArray } from "drizzle-orm";
1
+ import { asc, desc, eq, inArray, or } from "drizzle-orm";
2
2
 
3
3
  import { getDb } from "../memory/db.js";
4
4
  import { taskRuns, tasks, workItems } from "../memory/schema.js";
@@ -139,3 +139,45 @@ export function getTaskRun(id: string): TaskRun | undefined {
139
139
  const db = getDb();
140
140
  return db.select().from(taskRuns).where(eq(taskRuns.id, id)).get();
141
141
  }
142
+
143
+ // ── Brief Helpers ─────────────────────────────────────────────────────
144
+
145
+ /**
146
+ * Lightweight read-only projection of a work item used by the brief compiler.
147
+ * Avoids pulling the full WorkItem type with all its tool/approval fields.
148
+ */
149
+ export interface ActionableWorkItem {
150
+ id: string;
151
+ taskId: string;
152
+ title: string;
153
+ status: string;
154
+ priorityTier: number;
155
+ updatedAt: number;
156
+ }
157
+
158
+ /**
159
+ * Return actionable work items — those that are queued, running, or
160
+ * awaiting review. Ordered by priority (high first) then most-recently-updated.
161
+ */
162
+ export function getActionableWorkItems(): ActionableWorkItem[] {
163
+ const db = getDb();
164
+ return db
165
+ .select({
166
+ id: workItems.id,
167
+ taskId: workItems.taskId,
168
+ title: workItems.title,
169
+ status: workItems.status,
170
+ priorityTier: workItems.priorityTier,
171
+ updatedAt: workItems.updatedAt,
172
+ })
173
+ .from(workItems)
174
+ .where(
175
+ or(
176
+ eq(workItems.status, "queued"),
177
+ eq(workItems.status, "running"),
178
+ eq(workItems.status, "awaiting_review"),
179
+ ),
180
+ )
181
+ .orderBy(asc(workItems.priorityTier), desc(workItems.updatedAt))
182
+ .all();
183
+ }
@@ -193,7 +193,7 @@ export class UsageTelemetryReporter {
193
193
  const organizationId = getPlatformOrganizationId() || undefined;
194
194
  const userId = getPlatformUserId() || undefined;
195
195
  const payload = {
196
- installation_id: getDeviceId(),
196
+ device_id: getDeviceId(),
197
197
  assistant_id: assistantId,
198
198
  app_version: APP_VERSION,
199
199
  ...(organizationId ? { organization_id: organizationId } : {}),
@@ -38,8 +38,13 @@ class FileEditTool implements Tool {
38
38
  description:
39
39
  "Replace all occurrences of old_string instead of requiring a unique match (default: false)",
40
40
  },
41
+ activity: {
42
+ type: "string",
43
+ description:
44
+ "Brief non-technical explanation of what you are doing and why, shown to the user as a status update.",
45
+ },
41
46
  },
42
- required: ["path", "old_string", "new_string"],
47
+ required: ["path", "old_string", "new_string", "activity"],
43
48
  },
44
49
  };
45
50
  }
@@ -38,8 +38,13 @@ class FileReadTool implements Tool {
38
38
  type: "number",
39
39
  description: "Maximum number of lines to read",
40
40
  },
41
+ activity: {
42
+ type: "string",
43
+ description:
44
+ "Brief non-technical explanation of what you are doing and why, shown to the user as a status update.",
45
+ },
41
46
  },
42
- required: ["path"],
47
+ required: ["path", "activity"],
43
48
  },
44
49
  };
45
50
  }
@@ -28,8 +28,13 @@ class FileWriteTool implements Tool {
28
28
  type: "string",
29
29
  description: "The content to write to the file",
30
30
  },
31
+ activity: {
32
+ type: "string",
33
+ description:
34
+ "Brief non-technical explanation of what you are doing and why, shown to the user as a status update.",
35
+ },
31
36
  },
32
- required: ["path", "content"],
37
+ required: ["path", "content", "activity"],
33
38
  },
34
39
  };
35
40
  }
@@ -2,6 +2,8 @@ import { and, eq, ne } from "drizzle-orm";
2
2
  import { v4 as uuid } from "uuid";
3
3
 
4
4
  import type { AssistantConfig } from "../../config/types.js";
5
+ import { buildArchiveRecall } from "../../memory/archive-recall.js";
6
+ import { insertObservation } from "../../memory/archive-store.js";
5
7
  import { getDb } from "../../memory/db.js";
6
8
  import { computeMemoryFingerprint } from "../../memory/fingerprint.js";
7
9
  import { enqueueMemoryJob } from "../../memory/jobs-store.js";
@@ -18,7 +20,7 @@ const log = getLogger("memory-tools");
18
20
 
19
21
  export async function handleMemorySave(
20
22
  args: Record<string, unknown>,
21
- _config: AssistantConfig,
23
+ config: AssistantConfig,
22
24
  conversationId: string,
23
25
  messageId: string | undefined,
24
26
  scopeId: string = "default",
@@ -63,6 +65,19 @@ export async function handleMemorySave(
63
65
  ? truncate(args.subject.trim(), 80, "")
64
66
  : inferSubjectFromStatement(statement.trim());
65
67
 
68
+ // When simplified memory is enabled, save directly to the simplified
69
+ // observation/chunk tables instead of the legacy memory_items table.
70
+ if (config.memory.simplified.enabled) {
71
+ return handleSimplifiedMemorySave(
72
+ kind,
73
+ subject,
74
+ statement.trim(),
75
+ conversationId,
76
+ messageId,
77
+ scopeId,
78
+ );
79
+ }
80
+
66
81
  try {
67
82
  const db = getDb();
68
83
  const id = uuid();
@@ -275,6 +290,12 @@ export async function handleMemoryRecall(
275
290
  ? args.scope.trim()
276
291
  : "default";
277
292
 
293
+ // When simplified memory is enabled, use the archive recall path
294
+ // instead of the legacy hybrid retriever.
295
+ if (config.memory.simplified.enabled) {
296
+ return handleSimplifiedMemoryRecall(query.trim(), scopeId ?? "default");
297
+ }
298
+
278
299
  // Scope policy: "conversation" means strict (only that scope),
279
300
  // anything else allows fallback to the default scope.
280
301
  const scopePolicyOverride: ScopePolicyOverride | undefined = scopeId
@@ -411,6 +432,113 @@ export async function handleMemoryDelete(
411
432
  }
412
433
  }
413
434
 
435
+ // ── Simplified memory helpers ────────────────────────────────────────
436
+
437
+ /**
438
+ * Save a memory item as an observation + chunk in the simplified system.
439
+ * This is used when simplified memory is enabled instead of writing to
440
+ * the legacy memory_items table.
441
+ */
442
+ function handleSimplifiedMemorySave(
443
+ kind: string,
444
+ subject: string,
445
+ statement: string,
446
+ conversationId: string,
447
+ messageId: string | undefined,
448
+ scopeId: string,
449
+ ): ToolExecutionResult {
450
+ try {
451
+ const trimmedStatement = truncate(statement, 500, "");
452
+ const content = `[${kind}] ${subject}: ${trimmedStatement}`;
453
+
454
+ const result = insertObservation({
455
+ conversationId,
456
+ messageId: messageId ?? null,
457
+ role: "user",
458
+ content,
459
+ scopeId,
460
+ modality: "text",
461
+ source: "tool:memory_save",
462
+ });
463
+
464
+ log.debug(
465
+ {
466
+ observationId: result.observationId,
467
+ chunkId: result.chunkId,
468
+ kind,
469
+ subject,
470
+ conversationId,
471
+ messageId,
472
+ },
473
+ "Memory saved via simplified system",
474
+ );
475
+
476
+ return {
477
+ content: `Saved to memory (ID: ${result.observationId}).\nKind: ${kind}\nSubject: ${subject}\nStatement: ${trimmedStatement}`,
478
+ isError: false,
479
+ };
480
+ } catch (err) {
481
+ const msg = err instanceof Error ? err.message : String(err);
482
+ log.error({ err }, "simplified memory_save failed");
483
+ return { content: `Error: Failed to save memory: ${msg}`, isError: true };
484
+ }
485
+ }
486
+
487
+ /**
488
+ * Recall memories using the simplified archive recall path instead of
489
+ * the legacy hybrid retriever.
490
+ */
491
+ function handleSimplifiedMemoryRecall(
492
+ query: string,
493
+ scopeId: string,
494
+ ): ToolExecutionResult {
495
+ try {
496
+ const recallResult = buildArchiveRecall(scopeId, query);
497
+
498
+ if (recallResult.bullets.length === 0) {
499
+ return {
500
+ content: JSON.stringify({
501
+ text: "No matching memories found.",
502
+ resultCount: 0,
503
+ degraded: false,
504
+ items: [],
505
+ sources: { semantic: 0, recency: 0 },
506
+ }),
507
+ isError: false,
508
+ };
509
+ }
510
+
511
+ const items = recallResult.bullets.map((b) => ({
512
+ id: b.sourceId,
513
+ type: b.source,
514
+ kind: b.source,
515
+ }));
516
+
517
+ const result = {
518
+ text: recallResult.text,
519
+ resultCount: recallResult.bullets.length,
520
+ degraded: false,
521
+ items,
522
+ sources: {
523
+ semantic: recallResult.prefetchHitCount,
524
+ recency: 0,
525
+ },
526
+ };
527
+
528
+ return {
529
+ content: JSON.stringify(result),
530
+ isError: false,
531
+ };
532
+ } catch (err) {
533
+ const msg = err instanceof Error ? err.message : String(err);
534
+ log.error({ err, query }, "simplified memory_recall failed");
535
+ return {
536
+ content: `Error: Memory recall failed: ${msg}`,
537
+ isError: true,
538
+ };
539
+ }
540
+ }
541
+
414
542
  // ── Helpers ──────────────────────────────────────────────────────────
415
543
 
416
544
  function inferSubjectFromStatement(statement: string): string {
@@ -142,10 +142,17 @@ export class PermissionChecker {
142
142
  // is the owner - prompting makes no sense when there is no client.
143
143
  // Exception: requireFreshApproval tools cannot be auto-approved -
144
144
  // without a human present, bundle installation must be denied.
145
+ // Exception: inline-command skill loads (skill_load_dynamic:*) must
146
+ // never be silently auto-approved — they execute embedded commands
147
+ // and require explicit human review or a pinned trust rule.
148
+ const isDynamicSkillLoad =
149
+ result.matchedRule?.pattern.startsWith("skill_load_dynamic:") ===
150
+ true;
145
151
  if (
146
152
  context.isInteractive === false &&
147
153
  context.trustClass === "guardian" &&
148
- !context.requireFreshApproval
154
+ !context.requireFreshApproval &&
155
+ !isDynamicSkillLoad
149
156
  ) {
150
157
  log.info(
151
158
  { toolName: name, riskLevel },
@@ -41,6 +41,7 @@ export async function executeScheduleCreate(
41
41
  const routingHints = input.routing_hints as
42
42
  | Record<string, unknown>
43
43
  | undefined;
44
+ const quiet = (input.quiet as boolean) ?? false;
44
45
 
45
46
  if (!name || typeof name !== "string") {
46
47
  return {
@@ -112,6 +113,7 @@ export async function executeScheduleCreate(
112
113
  mode,
113
114
  routingIntent: routingIntent as RoutingIntent | undefined,
114
115
  routingHints,
116
+ quiet,
115
117
  });
116
118
 
117
119
  const fireDate = formatLocalDate(job.nextRunAt);
@@ -187,6 +189,7 @@ export async function executeScheduleCreate(
187
189
  mode,
188
190
  routingIntent: routingIntent as RoutingIntent | undefined,
189
191
  routingHints,
192
+ quiet,
190
193
  });
191
194
 
192
195
  const scheduleDescription =
@@ -62,7 +62,11 @@ export async function executeScheduleList(
62
62
  );
63
63
  }
64
64
 
65
- lines.push(` Enabled: ${job.enabled}`, ` Message: ${job.message}`);
65
+ lines.push(
66
+ ` Enabled: ${job.enabled}`,
67
+ ` Quiet: ${job.quiet}`,
68
+ ` Message: ${job.message}`,
69
+ );
66
70
 
67
71
  if (!oneShot) {
68
72
  lines.push(` Next run: ${formatLocalDate(job.nextRunAt)}`);
@@ -97,6 +97,11 @@ export async function executeScheduleUpdate(
97
97
  updates.routingHints = input.routing_hints;
98
98
  }
99
99
 
100
+ // Quiet mode
101
+ if (input.quiet !== undefined) {
102
+ updates.quiet = input.quiet;
103
+ }
104
+
100
105
  // Auto-detect syntax when expression changes without explicit syntax
101
106
  if (input.expression !== undefined || input.syntax !== undefined) {
102
107
  const resolved = normalizeScheduleSyntax({
@@ -159,6 +164,7 @@ export async function executeScheduleUpdate(
159
164
  mode?: ScheduleMode;
160
165
  routingIntent?: RoutingIntent;
161
166
  routingHints?: Record<string, unknown>;
167
+ quiet?: boolean;
162
168
  },
163
169
  );
164
170
 
@@ -21,12 +21,24 @@ import {
21
21
  indexCatalogById,
22
22
  validateIncludes,
23
23
  } from "../../skills/include-graph.js";
24
+ import { renderInlineCommands } from "../../skills/inline-command-render.js";
24
25
  import { parseToolManifestFile } from "../../skills/tool-manifest.js";
25
26
  import { computeSkillVersionHash } from "../../skills/version-hash.js";
26
27
  import { getLogger } from "../../util/logger.js";
28
+ import { getWorkspaceDirDisplay } from "../../util/platform.js";
27
29
  import { registerTool } from "../registry.js";
28
30
  import type { Tool, ToolContext, ToolExecutionResult } from "../types.js";
29
31
 
32
+ /** Canonical feature flag key for inline skill command expansion. */
33
+ const INLINE_COMMANDS_FLAG_KEY = "feature_flags.inline-skill-commands.enabled";
34
+
35
+ /** Skill sources eligible for inline command expansion in v1. */
36
+ const INLINE_COMMAND_ELIGIBLE_SOURCES = new Set([
37
+ "bundled",
38
+ "managed",
39
+ "workspace",
40
+ ]);
41
+
30
42
  const log = getLogger("skill-load");
31
43
 
32
44
  /**
@@ -76,7 +88,9 @@ function formatToolSchemas(
76
88
 
77
89
  for (const tool of manifest.tools) {
78
90
  lines.push(`${toolHeadingLevel} ${tool.name}`);
79
- lines.push(tool.description);
91
+ lines.push(
92
+ tool.description.replaceAll("{workspaceDir}", getWorkspaceDirDisplay()),
93
+ );
80
94
 
81
95
  const schema = tool.input_schema;
82
96
  const properties = schema.properties as
@@ -96,7 +110,7 @@ function formatToolSchemas(
96
110
  : "optional";
97
111
  const descPart =
98
112
  typeof paramDef.description === "string"
99
- ? `: ${paramDef.description}`
113
+ ? `: ${paramDef.description.replaceAll("{workspaceDir}", getWorkspaceDirDisplay())}`
100
114
  : "";
101
115
  lines.push(
102
116
  `- ${paramName} (${paramType}, ${requiredLabel})${descPart}`,
@@ -113,7 +127,7 @@ function formatToolSchemas(
113
127
  export class SkillLoadTool implements Tool {
114
128
  name = "skill_load";
115
129
  description =
116
- "Load full instructions for a skill. Works for both bundled skills (listed in the catalog) and workspace skills in ~/.vellum/workspace/skills.";
130
+ "Load full instructions for a skill. Works for both bundled skills (listed in the catalog) and custom workspace skills.";
117
131
  category = "skills";
118
132
  defaultRiskLevel = RiskLevel.Low;
119
133
 
@@ -136,7 +150,7 @@ export class SkillLoadTool implements Tool {
136
150
 
137
151
  async execute(
138
152
  input: Record<string, unknown>,
139
- _context: ToolContext,
153
+ context: ToolContext,
140
154
  ): Promise<ToolExecutionResult> {
141
155
  const selector = input.skill;
142
156
  if (typeof selector !== "string" || selector.trim().length === 0) {
@@ -279,7 +293,63 @@ export class SkillLoadTool implements Tool {
279
293
  }
280
294
  }
281
295
 
282
- const body = skill.body.length > 0 ? skill.body : "(No body content)";
296
+ let body = skill.body.length > 0 ? skill.body : "(No body content)";
297
+
298
+ // ── Inline command expansion ──────────────────────────────────────────
299
+ const hasInlineCommands =
300
+ skill.inlineCommandExpansions && skill.inlineCommandExpansions.length > 0;
301
+
302
+ if (hasInlineCommands) {
303
+ const inlineFlagEnabled = isAssistantFeatureFlagEnabled(
304
+ INLINE_COMMANDS_FLAG_KEY,
305
+ config,
306
+ );
307
+
308
+ if (!inlineFlagEnabled) {
309
+ // Feature flag is off: fail closed instead of leaving live tokens in
310
+ // the prompt that the LLM might try to interpret.
311
+ return {
312
+ content: `Error: skill "${skill.id}" contains inline command expansions but the inline-skill-commands feature flag is disabled. Enable the flag to use this skill.`,
313
+ isError: true,
314
+ };
315
+ }
316
+
317
+ if (skill.source === "extra") {
318
+ // Third-party extra roots are out of scope for inline command
319
+ // expansion in v1. Reject explicitly so the failure is clear.
320
+ return {
321
+ content: `Error: skill "${skill.id}" contains inline command expansions but inline commands are not supported for third-party (extra) skill sources.`,
322
+ isError: true,
323
+ };
324
+ }
325
+
326
+ if (!INLINE_COMMAND_ELIGIBLE_SOURCES.has(skill.source)) {
327
+ // Defensive: reject any other unknown sources that somehow have
328
+ // inline commands. Should not happen with current SkillSource values,
329
+ // but fail closed if a new source type is added without updating this.
330
+ return {
331
+ content: `Error: skill "${skill.id}" contains inline command expansions but source "${skill.source}" is not eligible for inline command expansion.`,
332
+ isError: true,
333
+ };
334
+ }
335
+
336
+ // Render inline commands by executing each through the sandbox runner
337
+ const renderResult = await renderInlineCommands(
338
+ body,
339
+ skill.inlineCommandExpansions!,
340
+ context.workingDir,
341
+ );
342
+ body = renderResult.renderedBody;
343
+
344
+ log.info(
345
+ {
346
+ skillId: skill.id,
347
+ expandedCount: renderResult.expandedCount,
348
+ failedCount: renderResult.failedCount,
349
+ },
350
+ "Rendered inline command expansions",
351
+ );
352
+ }
283
353
 
284
354
  // Build reference file listing (if any)
285
355
  const referenceListing = listReferenceFiles(skill.directoryPath);
@@ -313,8 +383,72 @@ export class SkillLoadTool implements Tool {
313
383
  // Load the included skill's body content
314
384
  const childLoaded = loadSkillBySelector(childId);
315
385
  if (childLoaded.skill && childLoaded.skill.body.length > 0) {
386
+ let childBody = childLoaded.skill.body;
387
+
388
+ // ── Inline command expansion for included child skill ─────────
389
+ const childHasInlineCommands =
390
+ childLoaded.skill.inlineCommandExpansions &&
391
+ childLoaded.skill.inlineCommandExpansions.length > 0;
392
+
393
+ if (childHasInlineCommands) {
394
+ const childInlineFlagEnabled = isAssistantFeatureFlagEnabled(
395
+ INLINE_COMMANDS_FLAG_KEY,
396
+ config,
397
+ );
398
+
399
+ // Fail closed: if the flag is off, reject the entire skill_load
400
+ // just like we do for root skills. Leaving raw !`...` tokens in
401
+ // the prompt would violate the documented fail-closed contract.
402
+ if (!childInlineFlagEnabled) {
403
+ return {
404
+ content: `Error: included skill "${childId}" contains inline command expansions but the inline-skill-commands feature flag is disabled. Enable the flag to use skill "${skill.id}".`,
405
+ isError: true,
406
+ };
407
+ }
408
+
409
+ if (childLoaded.skill.source === "extra") {
410
+ return {
411
+ content: `Error: included skill "${childId}" contains inline command expansions but inline commands are not supported for third-party (extra) skill sources.`,
412
+ isError: true,
413
+ };
414
+ }
415
+
416
+ if (
417
+ !INLINE_COMMAND_ELIGIBLE_SOURCES.has(childLoaded.skill.source)
418
+ ) {
419
+ return {
420
+ content: `Error: included skill "${childId}" contains inline command expansions but source "${childLoaded.skill.source}" is not eligible for inline command expansion.`,
421
+ isError: true,
422
+ };
423
+ }
424
+
425
+ try {
426
+ const childRenderResult = await renderInlineCommands(
427
+ childBody,
428
+ childLoaded.skill.inlineCommandExpansions!,
429
+ context.workingDir,
430
+ );
431
+ childBody = childRenderResult.renderedBody;
432
+
433
+ log.info(
434
+ {
435
+ skillId: childId,
436
+ parentSkillId: skill.id,
437
+ expandedCount: childRenderResult.expandedCount,
438
+ failedCount: childRenderResult.failedCount,
439
+ },
440
+ "Rendered inline command expansions for included skill",
441
+ );
442
+ } catch (err) {
443
+ log.warn(
444
+ { err, skillId: childId, parentSkillId: skill.id },
445
+ "Failed to render inline commands for included skill, using raw body",
446
+ );
447
+ }
448
+ }
449
+
316
450
  includedBodies.push(
317
- `--- Included Skill: ${childLoaded.skill.displayName} (${childId}) ---\n${childLoaded.skill.body}`,
451
+ `--- Included Skill: ${childLoaded.skill.displayName} (${childId}) ---\n${childBody}`,
318
452
  );
319
453
 
320
454
  // List reference files for the included skill
@@ -366,6 +366,24 @@ export function getWorkspaceDir(): string {
366
366
  return join(getRootDir(), "workspace");
367
367
  }
368
368
 
369
+ /**
370
+ * Returns a display-friendly workspace path for embedding in agent-facing text
371
+ * (skill bodies, tool descriptions). Replaces the home directory prefix with `~`
372
+ * so paths stay concise and portable across machines.
373
+ *
374
+ * Examples:
375
+ * /Users/sidd/.vellum/workspace → ~/.vellum/workspace
376
+ * /data/.vellum/workspace → /data/.vellum/workspace
377
+ */
378
+ export function getWorkspaceDirDisplay(): string {
379
+ const abs = getWorkspaceDir();
380
+ const home = homedir();
381
+ if (abs.startsWith(home + "/") || abs === home) {
382
+ return "~" + abs.slice(home.length);
383
+ }
384
+ return abs;
385
+ }
386
+
369
387
  /** Returns ~/.vellum/workspace/config.json */
370
388
  export function getWorkspaceConfigPath(): string {
371
389
  return join(getWorkspaceDir(), "config.json");
@@ -11,7 +11,7 @@ import { getExternalAssistantId } from "../../runtime/auth/external-assistant-id
11
11
  import type { WorkspaceMigration } from "./types.js";
12
12
 
13
13
  export const backfillInstallationIdMigration: WorkspaceMigration = {
14
- id: "002-backfill-installation-id",
14
+ id: "011-backfill-installation-id",
15
15
  description:
16
16
  "Backfill installationId into lockfile from SQLite checkpoint and clean up stale row",
17
17
  run(_workspaceDir: string): void {
@@ -1,5 +1,4 @@
1
1
  import { avatarRenameMigration } from "./001-avatar-rename.js";
2
- import { backfillInstallationIdMigration } from "./002-backfill-installation-id.js";
3
2
  import { seedDeviceIdMigration } from "./003-seed-device-id.js";
4
3
  import { extractCollectUsageDataMigration } from "./004-extract-collect-usage-data.js";
5
4
  import { addSendDiagnosticsMigration } from "./005-add-send-diagnostics.js";
@@ -8,6 +7,7 @@ import { webSearchProviderRenameMigration } from "./007-web-search-provider-rena
8
7
  import { voiceTimeoutAndMaxStepsMigration } from "./008-voice-timeout-and-max-steps.js";
9
8
  import { backfillConversationDiskViewMigration } from "./009-backfill-conversation-disk-view.js";
10
9
  import { appDirRenameMigration } from "./010-app-dir-rename.js";
10
+ import { backfillInstallationIdMigration } from "./011-backfill-installation-id.js";
11
11
  import { renameConversationDiskViewDirsMigration } from "./012-rename-conversation-disk-view-dirs.js";
12
12
  import { repairConversationDiskViewMigration } from "./013-repair-conversation-disk-view.js";
13
13
  import type { WorkspaceMigration } from "./types.js";