@vellumai/assistant 0.6.1 → 0.6.2

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 (115) hide show
  1. package/docker-entrypoint.sh +12 -2
  2. package/node_modules/@vellumai/ces-contracts/src/handles.ts +7 -9
  3. package/openapi.yaml +1 -1
  4. package/package.json +1 -1
  5. package/src/__tests__/assistant-event-hub.test.ts +30 -0
  6. package/src/__tests__/checker.test.ts +104 -170
  7. package/src/__tests__/cli-command-risk-guard.test.ts +1 -1
  8. package/src/__tests__/context-overflow-approval.test.ts +5 -5
  9. package/src/__tests__/conversation-analysis-routes.test.ts +169 -0
  10. package/src/__tests__/conversation-directories-parse.test.ts +105 -0
  11. package/src/__tests__/credential-execution-approval-bridge.test.ts +0 -2
  12. package/src/__tests__/init-feature-flag-overrides.test.ts +167 -0
  13. package/src/__tests__/inline-command-runner.test.ts +7 -5
  14. package/src/__tests__/log-export-workspace.test.ts +190 -0
  15. package/src/__tests__/managed-credential-catalog-cli.test.ts +12 -14
  16. package/src/__tests__/navigate-settings-tab.test.ts +14 -1
  17. package/src/__tests__/notification-broadcaster.test.ts +65 -0
  18. package/src/__tests__/onboarding-template-contract.test.ts +5 -4
  19. package/src/__tests__/pkb-autoinject.test.ts +96 -0
  20. package/src/__tests__/require-fresh-approval.test.ts +0 -2
  21. package/src/__tests__/sandbox-diagnostics.test.ts +1 -32
  22. package/src/__tests__/terminal-sandbox.test.ts +1 -1
  23. package/src/__tests__/terminal-tools.test.ts +2 -5
  24. package/src/__tests__/test-preload.ts +14 -0
  25. package/src/__tests__/tool-domain-event-publisher.test.ts +0 -1
  26. package/src/__tests__/tool-executor-lifecycle-events.test.ts +1 -8
  27. package/src/__tests__/tool-executor.test.ts +0 -1
  28. package/src/__tests__/transport-hints-queue.test.ts +77 -0
  29. package/src/__tests__/trust-store.test.ts +4 -4
  30. package/src/__tests__/workspace-migration-030-seed-pkb-autoinject.test.ts +168 -0
  31. package/src/__tests__/workspace-policy.test.ts +2 -7
  32. package/src/agent/loop.ts +0 -29
  33. package/src/channels/types.ts +5 -0
  34. package/src/cli/__tests__/run-assistant-command.ts +34 -7
  35. package/src/cli/__tests__/unknown-command.test.ts +33 -0
  36. package/src/cli/commands/default-action.ts +68 -1
  37. package/src/cli/commands/oauth/__tests__/connect.test.ts +27 -0
  38. package/src/cli/commands/oauth/connect.ts +11 -0
  39. package/src/cli/commands/platform/__tests__/connect.test.ts +1 -1
  40. package/src/cli/commands/platform/__tests__/disconnect.test.ts +1 -1
  41. package/src/cli/commands/platform/__tests__/status.test.ts +1 -1
  42. package/src/cli/program.ts +9 -2
  43. package/src/config/assistant-feature-flags.ts +59 -55
  44. package/src/config/bundled-skills/app-builder/SKILL.md +87 -4
  45. package/src/config/bundled-skills/gmail/SKILL.md +11 -6
  46. package/src/config/bundled-skills/gmail/TOOLS.json +1 -1
  47. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +2 -1
  48. package/src/config/bundled-skills/settings/TOOLS.json +1 -1
  49. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +8 -3
  50. package/src/config/feature-flag-registry.json +2 -2
  51. package/src/config/schemas/services.ts +8 -0
  52. package/src/credential-execution/approval-bridge.ts +0 -1
  53. package/src/credential-execution/managed-catalog.ts +3 -7
  54. package/src/daemon/config-watcher.ts +6 -2
  55. package/src/daemon/context-overflow-approval.ts +0 -1
  56. package/src/daemon/conversation-agent-loop.ts +33 -12
  57. package/src/daemon/conversation-attachments.ts +0 -1
  58. package/src/daemon/conversation-messaging.ts +3 -0
  59. package/src/daemon/conversation-process.ts +18 -2
  60. package/src/daemon/conversation-queue-manager.ts +8 -0
  61. package/src/daemon/conversation-runtime-assembly.ts +64 -7
  62. package/src/daemon/conversation-surfaces.ts +65 -0
  63. package/src/daemon/conversation-tool-setup.ts +0 -3
  64. package/src/daemon/conversation.ts +3 -5
  65. package/src/daemon/handlers/conversations.ts +2 -1
  66. package/src/daemon/handlers/shared.ts +7 -0
  67. package/src/daemon/lifecycle.ts +21 -1
  68. package/src/daemon/message-types/conversations.ts +4 -0
  69. package/src/daemon/message-types/messages.ts +0 -1
  70. package/src/daemon/message-types/notifications.ts +12 -0
  71. package/src/daemon/message-types/settings.ts +12 -0
  72. package/src/daemon/server.ts +21 -24
  73. package/src/daemon/transport-hints.ts +33 -0
  74. package/src/index.ts +1 -1
  75. package/src/memory/conversation-crud.ts +15 -10
  76. package/src/memory/conversation-directories.ts +39 -0
  77. package/src/memory/conversation-group-migration.ts +65 -5
  78. package/src/memory/embedding-local.ts +1 -1
  79. package/src/memory/graph/capability-seed.ts +3 -5
  80. package/src/memory/group-crud.ts +25 -9
  81. package/src/messaging/provider.ts +1 -1
  82. package/src/notifications/broadcaster.ts +6 -0
  83. package/src/notifications/conversation-pairing.ts +12 -4
  84. package/src/notifications/emit-signal.ts +14 -0
  85. package/src/notifications/signal.ts +11 -0
  86. package/src/oauth/platform-connection.test.ts +2 -2
  87. package/src/oauth/seed-providers.ts +1 -0
  88. package/src/permissions/checker.ts +3 -3
  89. package/src/permissions/defaults.ts +7 -8
  90. package/src/permissions/prompter.ts +0 -2
  91. package/src/platform/client.ts +1 -1
  92. package/src/prompts/templates/BOOTSTRAP.md +14 -5
  93. package/src/prompts/templates/SOUL.md +11 -11
  94. package/src/runtime/assistant-event-hub.ts +22 -0
  95. package/src/runtime/auth/token-service.ts +8 -0
  96. package/src/runtime/routes/conversation-analysis-routes.ts +18 -6
  97. package/src/runtime/routes/conversation-routes.ts +9 -3
  98. package/src/runtime/routes/group-routes.ts +22 -8
  99. package/src/runtime/routes/log-export/AGENTS.md +104 -0
  100. package/src/runtime/routes/log-export/__tests__/workspace-allowlist-error-contract.test.ts +103 -0
  101. package/src/runtime/routes/log-export/__tests__/workspace-allowlist.test.ts +716 -0
  102. package/src/runtime/routes/log-export/workspace-allowlist.ts +458 -0
  103. package/src/runtime/routes/log-export-routes.ts +18 -3
  104. package/src/skills/inline-command-runner.ts +12 -14
  105. package/src/tools/permission-checker.ts +0 -18
  106. package/src/tools/secret-detection-handler.ts +0 -1
  107. package/src/tools/skills/sandbox-runner.ts +3 -6
  108. package/src/tools/terminal/sandbox-diagnostics.ts +4 -4
  109. package/src/tools/terminal/sandbox.ts +4 -1
  110. package/src/tools/terminal/shell.ts +3 -5
  111. package/src/tools/types.ts +0 -3
  112. package/src/watcher/provider-types.ts +1 -1
  113. package/src/workspace/migrations/029-seed-pkb.ts +1 -0
  114. package/src/workspace/migrations/030-seed-pkb-autoinject.ts +73 -0
  115. package/src/workspace/migrations/registry.ts +2 -0
@@ -0,0 +1,33 @@
1
+ import { parseInterfaceId } from "../channels/types.js";
2
+ import type { ConversationTransportMetadata } from "./message-types/conversations.js";
3
+
4
+ /**
5
+ * Build enriched transport hints from conversation transport metadata.
6
+ *
7
+ * Interface ID first, then host environment (macOS only), then any
8
+ * client-provided hints. Shared between the conversation creation path
9
+ * (server.ts) and the queue drain path (conversation-process.ts).
10
+ */
11
+ export function buildTransportHints(
12
+ transport: ConversationTransportMetadata,
13
+ ): string[] {
14
+ const hints: string[] = [];
15
+
16
+ const interfaceLabel = parseInterfaceId(transport.interfaceId) ?? "vellum";
17
+ hints.push(`User is messaging from interface: ${interfaceLabel}`);
18
+
19
+ if (transport.interfaceId === "macos") {
20
+ if (transport.hostHomeDir) {
21
+ hints.push(`Host home directory: ${transport.hostHomeDir}`);
22
+ }
23
+ if (transport.hostUsername) {
24
+ hints.push(`Host username: ${transport.hostUsername}`);
25
+ }
26
+ }
27
+
28
+ if (transport.hints) {
29
+ hints.push(...transport.hints);
30
+ }
31
+
32
+ return hints;
33
+ }
package/src/index.ts CHANGED
@@ -2,4 +2,4 @@
2
2
 
3
3
  import { buildCliProgram } from "./cli/program.js";
4
4
 
5
- buildCliProgram().parse();
5
+ (await buildCliProgram()).parse();
@@ -314,12 +314,15 @@ export function createConversation(
314
314
 
315
315
  // group_id is NOT in the Drizzle schema (raw-query-only pattern).
316
316
  // Set via raw SQL after the INSERT succeeds.
317
- if (groupId) {
317
+ // Always set group_id — default to "system:all" when none provided.
318
+ {
319
+ const effectiveGroupId = groupId ?? "system:all";
318
320
  for (let attempt = 0; ; attempt++) {
319
321
  try {
320
322
  rawRun(
321
- "UPDATE conversations SET group_id = ? WHERE id = ?",
322
- groupId,
323
+ "UPDATE conversations SET group_id = ?, is_pinned = ? WHERE id = ?",
324
+ effectiveGroupId,
325
+ effectiveGroupId === "system:pinned" ? 1 : 0,
323
326
  id,
324
327
  );
325
328
  break;
@@ -469,7 +472,7 @@ export function forkConversation(params: {
469
472
  const fc = createConversation({
470
473
  title: forkTitle,
471
474
  conversationType: "standard",
472
- groupId: parentGroupId ?? undefined,
475
+ groupId: parentGroupId ?? "system:all",
473
476
  });
474
477
 
475
478
  db.update(conversations)
@@ -1547,17 +1550,19 @@ export function batchSetDisplayOrders(
1547
1550
  if (update.groupId !== undefined) {
1548
1551
  // New client: groupId is authoritative.
1549
1552
  // Derive is_pinned from groupId.
1550
- // Sanitize: if groupId references a deleted/unknown group, fall back
1551
- // to NULL to avoid FK violation that would roll back the entire batch.
1553
+ // Sanitize: if groupId is null or references a deleted/unknown group,
1554
+ // fall back to "system:all" to avoid FK violation that would roll back
1555
+ // the entire batch.
1552
1556
  let safeGroupId = update.groupId;
1553
- if (
1554
- safeGroupId !== null &&
1557
+ if (safeGroupId === null) {
1558
+ safeGroupId = "system:all";
1559
+ } else if (
1555
1560
  !rawGet<{ id: string }>(
1556
1561
  "SELECT id FROM conversation_groups WHERE id = ?",
1557
1562
  safeGroupId,
1558
1563
  )
1559
1564
  ) {
1560
- safeGroupId = null;
1565
+ safeGroupId = "system:all";
1561
1566
  }
1562
1567
  rawRun(
1563
1568
  "UPDATE conversations SET display_order = ?, is_pinned = ?, group_id = ? WHERE id = ?",
@@ -1587,7 +1592,7 @@ export function batchSetDisplayOrders(
1587
1592
  WHEN source IN ('schedule', 'reminder') THEN 'system:scheduled'
1588
1593
  WHEN source IN ('heartbeat', 'task') THEN 'system:background'
1589
1594
  WHEN conversation_type = 'background' AND COALESCE(source, '') != 'notification' THEN 'system:background'
1590
- ELSE NULL
1595
+ ELSE 'system:all'
1591
1596
  END
1592
1597
  ELSE group_id END
1593
1598
  WHERE id = ?`,
@@ -26,6 +26,45 @@ export function getConversationDirName(
26
26
  return `${getConversationDirTimestamp(createdAtMs)}_${id}`;
27
27
  }
28
28
 
29
+ /**
30
+ * Parse a canonical conversation directory name (`<ISO-with-dashes>_<id>`)
31
+ * back into its components. Returns null for names that don't match the
32
+ * canonical format — callers should treat null as "skip this entry"
33
+ * rather than as an error.
34
+ *
35
+ * Inverse of {@link getConversationDirName}. Does NOT parse the legacy
36
+ * `<id>_<ISO-with-dashes>` format.
37
+ */
38
+ export function parseConversationDirName(
39
+ name: string,
40
+ ): { conversationId: string; createdAtMs: number } | null {
41
+ // Canonical format: YYYY-MM-DDTHH-MM-SS.sssZ_<uuid>
42
+ // The timestamp portion has 4 hyphens (date + time-component separators)
43
+ // and ends in `Z`. Anchor the regex to enforce that.
44
+ const match = name.match(
45
+ /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2}\.\d{1,9}Z)_(.+)$/,
46
+ );
47
+ if (!match) return null;
48
+ const [, datePart, hh, mm, ssAndMs, conversationId] = match;
49
+ const iso = `${datePart}T${hh}:${mm}:${ssAndMs}`;
50
+ const ms = Date.parse(iso);
51
+ if (Number.isNaN(ms)) return null;
52
+ if (!conversationId) return null;
53
+ // Reject conversation IDs that are path-traversal-shaped or contain
54
+ // path separators. These are never valid conversation IDs and would
55
+ // be a defense-in-depth concern if parsed.conversationId is later used
56
+ // to construct filesystem paths.
57
+ if (
58
+ conversationId === "." ||
59
+ conversationId === ".." ||
60
+ conversationId.includes("/") ||
61
+ conversationId.includes("\\")
62
+ ) {
63
+ return null;
64
+ }
65
+ return { conversationId, createdAtMs: ms };
66
+ }
67
+
29
68
  /**
30
69
  * Return the absolute path to a conversation's timestamp-first disk-view
31
70
  * directory.
@@ -57,15 +57,45 @@ export function ensureGroupMigration(): void {
57
57
  }
58
58
  }
59
59
 
60
- // 3. Seed system groups (three: pinned, scheduled, background)
60
+ // 3. Seed system groups (four: pinned, scheduled, background, all)
61
+ const now = Math.floor(Date.now() / 1000);
61
62
  rawExec(`
62
- INSERT OR IGNORE INTO conversation_groups (id, name, sort_position, is_system_group)
63
+ INSERT OR IGNORE INTO conversation_groups (id, name, sort_position, is_system_group, created_at, updated_at)
63
64
  VALUES
64
- ('system:pinned', 'Pinned', 0, TRUE),
65
- ('system:scheduled', 'Scheduled', 1, TRUE),
66
- ('system:background', 'Background', 2, TRUE)
65
+ ('system:pinned', 'Pinned', 0, TRUE, ${now}, ${now}),
66
+ ('system:scheduled', 'Scheduled', 1, TRUE, ${now}, ${now}),
67
+ ('system:background', 'Background', 2, TRUE, ${now}, ${now}),
68
+ ('system:all', 'Recents', 3, TRUE, ${now}, ${now})
67
69
  `);
68
70
 
71
+ // One-time migration: move system:all to sortPosition 3 (from 999999).
72
+ // Bump custom groups at position 3+ up by 1 to make room. Wrapped in a
73
+ // transaction so a crash between the shift and the sentinel can't cause
74
+ // repeated drift on restart.
75
+ const sortShiftDone = rawGet<{ id: string }>(
76
+ "SELECT id FROM conversation_groups WHERE id = '_sort_shift_complete'",
77
+ );
78
+ if (!sortShiftDone) {
79
+ try {
80
+ rawExec("BEGIN");
81
+ rawRun(
82
+ "UPDATE conversation_groups SET sort_position = sort_position + 1 WHERE is_system_group = 0 AND sort_position >= 3",
83
+ );
84
+ rawRun(
85
+ "UPDATE conversation_groups SET sort_position = 3 WHERE id = 'system:all' AND sort_position != 3",
86
+ );
87
+ rawRun(
88
+ `INSERT OR IGNORE INTO conversation_groups (id, name, sort_position, is_system_group, created_at, updated_at)
89
+ VALUES ('_sort_shift_complete', '_sort_shift_complete', -1, TRUE, ${now}, ${now})`,
90
+ );
91
+ rawExec("COMMIT");
92
+ } catch (err) {
93
+ rawExec("ROLLBACK");
94
+ log.error({ err }, "Sort-position shift transaction failed, rolled back");
95
+ throw err;
96
+ }
97
+ }
98
+
69
99
  // 4. One-time backfill (guard: persistent marker prevents re-running on restart)
70
100
  //
71
101
  // The backfill sets group_id on existing conversations based on their attributes.
@@ -153,5 +183,35 @@ export function ensureGroupMigration(): void {
153
183
  }
154
184
  }
155
185
 
186
+ // 5. One-time backfill: assign all ungrouped conversations to system:all
187
+ //
188
+ // Separate from the initial backfill above because system:all is added later.
189
+ // Uses its own sentinel so it runs exactly once, even on existing installations
190
+ // where the original backfill already completed.
191
+ const allBackfillDone = rawGet<{ id: string }>(
192
+ "SELECT id FROM conversation_groups WHERE id = '_backfill_all_complete'",
193
+ );
194
+
195
+ if (!allBackfillDone) {
196
+ try {
197
+ rawExec("BEGIN");
198
+
199
+ rawExec(`
200
+ UPDATE conversations SET group_id = 'system:all' WHERE group_id IS NULL
201
+ `);
202
+
203
+ rawExec(`
204
+ INSERT OR IGNORE INTO conversation_groups (id, name, sort_position, is_system_group)
205
+ VALUES ('_backfill_all_complete', '_backfill_all_complete', -1, TRUE)
206
+ `);
207
+
208
+ rawExec("COMMIT");
209
+ } catch (err) {
210
+ rawExec("ROLLBACK");
211
+ log.error({ err }, "system:all backfill transaction failed, rolled back");
212
+ throw err;
213
+ }
214
+ }
215
+
156
216
  migrated = true;
157
217
  }
@@ -205,7 +205,7 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
205
205
  );
206
206
 
207
207
  const proc = Bun.spawn({
208
- cmd: [bunPath, workerPath, this.model, modelCacheDir],
208
+ cmd: [bunPath, "--smol", workerPath, this.model, modelCacheDir],
209
209
  stdin: "pipe",
210
210
  stdout: "pipe",
211
211
  stderr: "pipe",
@@ -127,9 +127,7 @@ export function seedSkillGraphNodes(): void {
127
127
  // skip catalog-based pruning to avoid incorrectly marking valid
128
128
  // uninstalled catalog nodes as gone. But still prune locally disabled
129
129
  // skills so stale capability nodes don't linger after cold start.
130
- log.info(
131
- "Catalog cache is cold — pruning only locally disabled skills",
132
- );
130
+ log.info("Catalog cache is cold — pruning only locally disabled skills");
133
131
  const disabled = resolved.filter((r) => r.state !== "enabled");
134
132
  for (const { summary } of disabled) {
135
133
  deleteSkillCapabilityNode(summary.id);
@@ -158,9 +156,9 @@ export function seedSkillGraphNodes(): void {
158
156
  * Seed graph nodes for all CLI commands.
159
157
  * Prunes stale nodes whose commands are no longer registered.
160
158
  */
161
- export function seedCliGraphNodes(): void {
159
+ export async function seedCliGraphNodes(): Promise<void> {
162
160
  try {
163
- const program = buildCliProgram();
161
+ const program = await buildCliProgram();
164
162
 
165
163
  const seenKeys = new Set<string>();
166
164
  for (const cmd of program.commands) {
@@ -33,7 +33,7 @@ export function listGroups(): ConversationGroupRow[] {
33
33
  created_at: number;
34
34
  updated_at: number;
35
35
  }>(
36
- "SELECT id, name, sort_position, is_system_group, created_at, updated_at FROM conversation_groups WHERE id != '_backfill_complete' ORDER BY sort_position ASC",
36
+ "SELECT id, name, sort_position, is_system_group, created_at, updated_at FROM conversation_groups WHERE id NOT IN ('_backfill_complete', '_backfill_all_complete', '_sort_shift_complete') ORDER BY sort_position ASC",
37
37
  );
38
38
  return rows.map((r) => ({
39
39
  id: r.id,
@@ -79,8 +79,8 @@ export function getGroup(groupId: string): ConversationGroupRow | null {
79
79
 
80
80
  /**
81
81
  * Create a custom group. Server assigns sort_position as max(custom) + 1.
82
- * System groups occupy positions 0 (pinned), 1 (scheduled), 2 (background).
83
- * First custom group gets position 3. Fallback ?? 2 ensures 2 + 1 = 3 when
82
+ * System groups occupy positions 0–3 (pinned, scheduled, background, all).
83
+ * First custom group gets position 4. Fallback ?? 3 ensures 3 + 1 = 4 when
84
84
  * no custom groups exist.
85
85
  */
86
86
  export function createGroup(name: string): ConversationGroupRow {
@@ -88,7 +88,7 @@ export function createGroup(name: string): ConversationGroupRow {
88
88
  const maxPos =
89
89
  rawGet<{ max: number | null }>(
90
90
  "SELECT MAX(sort_position) as max FROM conversation_groups WHERE is_system_group = 0",
91
- )?.max ?? 2;
91
+ )?.max ?? 3;
92
92
  const sortPosition = maxPos + 1;
93
93
  const id = uuid();
94
94
  const now = Math.floor(Date.now() / 1000);
@@ -153,13 +153,16 @@ export function updateGroup(
153
153
  // Delete
154
154
  // ---------------------------------------------------------------------------
155
155
 
156
- // Relies on PRAGMA foreign_keys = ON (set at connection time) so that
157
- // ON DELETE SET NULL fires and clears group_id on conversations when
158
- // a group is deleted. If FK enforcement is ever disabled, orphaned
159
- // group_id values would persist and conversations would appear in a
160
- // non-existent group.
156
+ // Reassign conversations to system:all before deleting the group so they
157
+ // don't end up with NULL group_id (which would violate the system:all
158
+ // invariant). The FK ON DELETE SET NULL would otherwise leave NULLs that
159
+ // the one-time backfill won't re-fix.
161
160
  export function deleteGroup(groupId: string): boolean {
162
161
  ensureGroupMigration();
162
+ rawRun(
163
+ "UPDATE conversations SET group_id = 'system:all' WHERE group_id = ?",
164
+ groupId,
165
+ );
163
166
  rawRun("DELETE FROM conversation_groups WHERE id = ?", groupId);
164
167
  return true;
165
168
  }
@@ -176,6 +179,19 @@ export function reorderGroups(
176
179
  rawExec("BEGIN");
177
180
  try {
178
181
  for (const update of updates) {
182
+ // Look up the group first — skip unknown/stale IDs and system groups
183
+ const group = rawGet<{ id: string; is_system_group: number }>(
184
+ "SELECT id, is_system_group FROM conversation_groups WHERE id = ?",
185
+ update.groupId,
186
+ );
187
+ if (!group) continue;
188
+ if (group.is_system_group === 1) continue;
189
+
190
+ if (update.sortPosition < 4) {
191
+ throw new Error(
192
+ `Custom group sort_position must be >= 4 (got ${update.sortPosition} for ${update.groupId})`,
193
+ );
194
+ }
179
195
  rawRun(
180
196
  "UPDATE conversation_groups SET sort_position = ?, updated_at = ? WHERE id = ?",
181
197
  update.sortPosition,
@@ -25,7 +25,7 @@ export interface MessagingProvider {
25
25
  id: string;
26
26
  /** Human-readable name (e.g. 'Slack', 'Gmail'). */
27
27
  displayName: string;
28
- /** Credential service name for token-manager (e.g. 'integration:slack'). */
28
+ /** Credential service name for token-manager (e.g. 'slack'). */
29
29
  credentialService: string;
30
30
 
31
31
  // ── Universal operations (every platform must implement) ──────────
@@ -41,6 +41,10 @@ export interface ConversationCreatedInfo {
41
41
  sourceEventName: string;
42
42
  /** Present when the conversation is for a guardian-sensitive notification. */
43
43
  targetGuardianPrincipalId?: string;
44
+ /** Conversation group identifier from the signal producer (e.g. "system:scheduled"). */
45
+ groupId?: string;
46
+ /** Semantic source from the signal producer (e.g. "schedule", "reminder"). */
47
+ source?: string;
44
48
  }
45
49
  export type OnConversationCreatedFn = (info: ConversationCreatedInfo) => void;
46
50
  export interface BroadcastDecisionOptions {
@@ -238,6 +242,8 @@ export class NotificationBroadcaster {
238
242
  title: conversationTitle,
239
243
  sourceEventName: signal.sourceEventName,
240
244
  targetGuardianPrincipalId,
245
+ groupId: signal.conversationMetadata?.groupId,
246
+ source: signal.conversationMetadata?.source,
241
247
  };
242
248
 
243
249
  // The per-dispatch onConversationCreated callback fires whenever a vellum
@@ -130,7 +130,9 @@ export async function pairDeliveryWithConversation(
130
130
  const targetId = conversationAction.conversationId;
131
131
  const existing = getConversation(targetId);
132
132
 
133
- if (existing && existing.source === "notification") {
133
+ const effectiveSource =
134
+ signal.conversationMetadata?.source ?? "notification";
135
+ if (existing && existing.source === effectiveSource) {
134
136
  // Append the seed message to the existing conversation
135
137
  const message = await addMessage(
136
138
  existing.id,
@@ -186,7 +188,9 @@ export async function pairDeliveryWithConversation(
186
188
  const conversation = createConversation({
187
189
  title,
188
190
  conversationType,
189
- source: "notification",
191
+ source: signal.conversationMetadata?.source ?? "notification",
192
+ groupId: signal.conversationMetadata?.groupId,
193
+ scheduleJobId: signal.conversationMetadata?.scheduleJobId,
190
194
  });
191
195
 
192
196
  const message = await addMessage(
@@ -241,7 +245,9 @@ export async function pairDeliveryWithConversation(
241
245
  existingBinding.conversationId,
242
246
  );
243
247
 
244
- if (boundConversation && boundConversation.source === "notification") {
248
+ const effectiveSource =
249
+ signal.conversationMetadata?.source ?? "notification";
250
+ if (boundConversation && boundConversation.source === effectiveSource) {
245
251
  const message = await addMessage(
246
252
  boundConversation.id,
247
253
  "assistant",
@@ -299,7 +305,9 @@ export async function pairDeliveryWithConversation(
299
305
  const conversation = createConversation({
300
306
  title,
301
307
  conversationType,
302
- source: "notification",
308
+ source: signal.conversationMetadata?.source ?? "notification",
309
+ groupId: signal.conversationMetadata?.groupId,
310
+ scheduleJobId: signal.conversationMetadata?.scheduleJobId,
303
311
  });
304
312
 
305
313
  // Skip memory indexing — notification audit messages are not conversational
@@ -79,6 +79,8 @@ function getBroadcaster(): NotificationBroadcaster {
79
79
  title: info.title,
80
80
  sourceEventName: info.sourceEventName,
81
81
  targetGuardianPrincipalId: info.targetGuardianPrincipalId,
82
+ groupId: info.groupId,
83
+ source: info.source,
82
84
  });
83
85
  log.info(
84
86
  {
@@ -176,6 +178,17 @@ export interface EmitSignalParams<TEventName extends string = string> {
176
178
  * Useful for direct user-invoked actions that must fail closed.
177
179
  */
178
180
  throwOnError?: boolean;
181
+ /**
182
+ * Optional metadata propagated to the conversation created by the notification
183
+ * pipeline. Allows signal producers (e.g. the scheduler) to set groupId,
184
+ * scheduleJobId, or override the default "notification" source on the
185
+ * resulting conversation so it appears in the correct folder on clients.
186
+ */
187
+ conversationMetadata?: {
188
+ groupId?: string;
189
+ scheduleJobId?: string;
190
+ source?: string;
191
+ };
179
192
  }
180
193
 
181
194
  export interface EmitSignalResult {
@@ -210,6 +223,7 @@ export async function emitNotificationSignal<TEventName extends string>(
210
223
  routingIntent: params.routingIntent,
211
224
  routingHints: params.routingHints,
212
225
  conversationAffinityHint: params.conversationAffinityHint,
226
+ conversationMetadata: params.conversationMetadata,
213
227
  };
214
228
 
215
229
  try {
@@ -200,4 +200,15 @@ export interface NotificationSignal<TEventName extends string = string> {
200
200
  * affinity within a call session.
201
201
  */
202
202
  conversationAffinityHint?: Partial<Record<string, string>>;
203
+ /**
204
+ * Optional metadata propagated to the conversation created by the notification
205
+ * pipeline. Allows signal producers (e.g. the scheduler) to set groupId,
206
+ * scheduleJobId, or override the default "notification" source on the
207
+ * resulting conversation so it appears in the correct folder on clients.
208
+ */
209
+ conversationMetadata?: {
210
+ groupId?: string;
211
+ scheduleJobId?: string;
212
+ source?: string;
213
+ };
203
214
  }
@@ -27,7 +27,7 @@ function makeMockClient(
27
27
  fetch: mock(async (path: string, init?: RequestInit) => {
28
28
  const url = `https://platform.example.com${path}`;
29
29
  const headers = new Headers(init?.headers);
30
- headers.set("Authorization", "Api-Key test-api-key");
30
+ headers.set("Authorization", "Bearer test-api-key");
31
31
  return mockFetchFn(url, { ...init, headers });
32
32
  }),
33
33
  } as unknown as VellumPlatformClient;
@@ -53,7 +53,7 @@ describe("PlatformOAuthConnection", () => {
53
53
  );
54
54
  expect(init?.method).toBe("POST");
55
55
  const headers = new Headers(init?.headers);
56
- expect(headers.get("Authorization")).toBe("Api-Key test-api-key");
56
+ expect(headers.get("Authorization")).toBe("Bearer test-api-key");
57
57
  expect(headers.get("Content-Type")).toBe("application/json");
58
58
 
59
59
  const parsed = JSON.parse(init?.body as string);
@@ -295,6 +295,7 @@ const PROVIDER_SEED_DATA: Record<
295
295
  },
296
296
  extraParams: { prompt: "consent" },
297
297
  loopbackPort: 17324,
298
+ managedServiceConfigKey: "linear-oauth",
298
299
  injectionTemplates: [
299
300
  {
300
301
  hostPattern: "api.linear.app",
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
 
5
5
  import { isAssistantFeatureFlagEnabled } from "../config/assistant-feature-flags.js";
6
+ import { getIsContainerized } from "../config/env-registry.js";
6
7
  import { getConfig } from "../config/loader.js";
7
8
  import { loadSkillCatalog, resolveSkillSelector } from "../config/skills.js";
8
9
  import { indexCatalogById } from "../skills/include-graph.js";
@@ -1097,9 +1098,8 @@ export async function check(
1097
1098
  !matchedRule &&
1098
1099
  risk === RiskLevel.Low
1099
1100
  ) {
1100
- // When sandbox is disabled, bash runs on the host — don't auto-allow
1101
- const sandboxEnabled = getConfig().sandbox.enabled;
1102
- if (toolName === "bash" && !sandboxEnabled) {
1101
+ // Outside a container, bash runs on the host — don't auto-allow
1102
+ if (toolName === "bash" && !getIsContainerized()) {
1103
1103
  // Fall through to risk-based policy below
1104
1104
  } else if (isWorkspaceScopedInvocation(toolName, input, workingDir)) {
1105
1105
  return {
@@ -1,5 +1,6 @@
1
1
  import { join } from "node:path";
2
2
 
3
+ import { getIsContainerized } from "../config/env-registry.js";
3
4
  import { getConfig } from "../config/loader.js";
4
5
  import { getBundledSkillsDir } from "../config/skills.js";
5
6
  import { getWorkspaceDir } from "../util/platform.js";
@@ -42,7 +43,6 @@ export function getDefaultRuleTemplates(): DefaultRuleTemplate[] {
42
43
  // Some test suites mock getConfig() with partial objects; treat missing
43
44
  // branches as defaults so rule generation remains deterministic.
44
45
  const config = getConfig() as {
45
- sandbox?: { enabled?: boolean };
46
46
  skills?: { load?: { extraDirs?: unknown } };
47
47
  };
48
48
 
@@ -67,12 +67,11 @@ export function getDefaultRuleTemplates(): DefaultRuleTemplate[] {
67
67
  priority: 50,
68
68
  };
69
69
 
70
- // Sandboxed bash commands run in an isolated container auto-allow all of
71
- // them (including high-risk) so the user is never prompted for sandbox work.
72
- // Only emit this rule when the sandbox is actually enabled; otherwise bash
73
- // commands execute on the host and must go through normal permission checks.
74
- const sandboxEnabled = config.sandbox?.enabled !== false;
75
- const sandboxShellRule: DefaultRuleTemplate | null = sandboxEnabled
70
+ // When running inside a container (IS_CONTAINERIZED=true), bash commands
71
+ // execute in an isolated environment auto-allow all of them (including
72
+ // high-risk) so the user is never prompted. Outside a container, bash
73
+ // commands run on the host and go through normal permission checks.
74
+ const bashShellRule: DefaultRuleTemplate | null = getIsContainerized()
76
75
  ? {
77
76
  id: "default:allow-bash-global",
78
77
  tool: "bash",
@@ -300,7 +299,7 @@ export function getDefaultRuleTemplates(): DefaultRuleTemplate[] {
300
299
  return [
301
300
  ...hostFileRules,
302
301
  hostShellRule,
303
- ...(sandboxShellRule ? [sandboxShellRule] : []),
302
+ ...(bashShellRule ? [bashShellRule] : []),
304
303
  ...computerUseRules,
305
304
  ...managedSkillRules,
306
305
  ...workspacePromptRules,
@@ -58,7 +58,6 @@ export class PermissionPrompter {
58
58
  newContent: string;
59
59
  isNewFile: boolean;
60
60
  },
61
- sandboxed?: boolean,
62
61
  conversationId?: string,
63
62
  executionTarget?: ExecutionTarget,
64
63
  persistentDecisionsAllowed?: boolean,
@@ -119,7 +118,6 @@ export class PermissionPrompter {
119
118
  scope: o.scope,
120
119
  })),
121
120
  diff,
122
- sandboxed,
123
121
  conversationId,
124
122
  executionTarget,
125
123
  persistentDecisionsAllowed: persistentDecisionsAllowed ?? true,
@@ -2,7 +2,7 @@
2
2
  * Centralized platform API client.
3
3
  *
4
4
  * Owns managed proxy context resolution, prerequisite validation, and
5
- * authenticated fetch for all platform API calls that use Api-Key auth.
5
+ * authenticated fetch for all platform API calls.
6
6
  */
7
7
 
8
8
  import { getPlatformAssistantId } from "../config/env.js";
@@ -86,14 +86,21 @@ Read BOOTSTRAP-REFERENCE.md for the exact `ui_show` card payload.
86
86
 
87
87
  **Pacing rule:** Don't ask more than 2 questions in a row without doing something. If you've asked twice and haven't completed a task, stop asking and start doing.
88
88
 
89
- ### Step 5: Getting Set Up
89
+ ### Step 5: Keep the Momentum
90
90
 
91
- After the task is done, suggest two things to get the most out of the assistant. These are natural next steps, not a formal menu.
91
+ After the task is done, don't pivot to setup. Build on what just happened.
92
92
 
93
- 1. **Daily briefing:** "I can send you a morning briefing with weather, calendar, and news. Want me to set that up?"
94
- 2. **Meet you where you are:** "I'm here on your desktop, but I can also meet you on Slack or Telegram if you want to reach me from your phone. Want to set one up?"
93
+ **First choice: chain off the task.** Suggest one natural follow-up that extends the work they just did. Examples:
94
+ - Built a deck "Want to send this to someone or refine a specific slide?"
95
+ - Connected email → "Want me to summarize what needs your attention today?"
96
+ - Researched a topic → "Want me to go deeper on one of those points, or turn this into something shareable?"
97
+ - Built an app → Proactively suggest a specific improvement to what they built (a missing feature, a UI polish, better error handling). Show you have taste.
95
98
 
96
- If the user declines or ignores these, that's fine. Move on.
99
+ The follow-up should feel like a coworker saying "while we're at it..." — not a product tour.
100
+
101
+ **Fallback: plant a hook for next time.** If the task was a dead-end (photo edit, one-off question), reach back to Step 3. Pick up something from their "what's on your mind" answer and offer to work on it: "You mentioned [X] earlier — I can dig into that and have something ready next time you open this."
102
+
103
+ If they engage, do it. If they decline or wrap up, move on. One offer, no pressure.
97
104
 
98
105
  ### Step 6: Before You Go
99
106
 
@@ -119,6 +126,8 @@ Mark declined fields so you don't re-ask later (e.g., `Work role: declined_by_us
119
126
 
120
127
  **Call `file_edit` immediately whenever you learn something, in the same turn.** Don't batch saves. The moment the user gives you a name, save it. The moment you infer their style, save it.
121
128
 
129
+ **After tool calls, do not repeat yourself.** Your text before tool calls is already visible to the user. When tool results return and you continue, pick up where you left off — don't re-confirm, re-greet, or re-ask the same question. If you already asked something and are waiting for the user's answer, just stop.
130
+
122
131
  **The contents of IDENTITY.md, SOUL.md, and USER.md are already in your system prompt.** Use the exact text you see there for `old_string` in `file_edit`. Do not guess or invent content.
123
132
 
124
133
  Update `IDENTITY.md` (name, nature, personality, style) and `USER.md` (their name, pronouns, goals, locale, work role, hobbies, daily tools). Save behavioral guidelines to `SOUL.md`.