@vellumai/assistant 0.4.51 → 0.4.53

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 (220) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/docs/architecture/keychain-broker.md +19 -6
  3. package/docs/architecture/memory.md +3 -3
  4. package/package.json +1 -1
  5. package/src/__tests__/approval-cascade.test.ts +3 -1
  6. package/src/__tests__/approval-routes-http.test.ts +0 -1
  7. package/src/__tests__/asset-materialize-tool.test.ts +0 -1
  8. package/src/__tests__/asset-search-tool.test.ts +0 -1
  9. package/src/__tests__/assistant-events-sse-hardening.test.ts +0 -1
  10. package/src/__tests__/attachments-store.test.ts +0 -1
  11. package/src/__tests__/avatar-e2e.test.ts +6 -1
  12. package/src/__tests__/browser-fill-credential.test.ts +3 -0
  13. package/src/__tests__/btw-routes.test.ts +39 -0
  14. package/src/__tests__/call-controller.test.ts +0 -1
  15. package/src/__tests__/call-domain.test.ts +1 -0
  16. package/src/__tests__/call-routes-http.test.ts +1 -2
  17. package/src/__tests__/canonical-guardian-store.test.ts +33 -2
  18. package/src/__tests__/channel-readiness-routes.test.ts +1 -0
  19. package/src/__tests__/channel-readiness-service.test.ts +1 -0
  20. package/src/__tests__/claude-code-skill-regression.test.ts +6 -2
  21. package/src/__tests__/claude-code-tool-profiles.test.ts +7 -2
  22. package/src/__tests__/config-loader-backfill.test.ts +1 -2
  23. package/src/__tests__/config-schema.test.ts +6 -37
  24. package/src/__tests__/conversation-routes-slash-commands.test.ts +0 -1
  25. package/src/__tests__/credential-broker-server-use.test.ts +16 -16
  26. package/src/__tests__/credential-security-invariants.test.ts +14 -0
  27. package/src/__tests__/credential-vault-unit.test.ts +4 -4
  28. package/src/__tests__/error-handler-friendly-messages.test.ts +4 -5
  29. package/src/__tests__/gateway-only-enforcement.test.ts +0 -2
  30. package/src/__tests__/host-shell-tool.test.ts +0 -1
  31. package/src/__tests__/http-user-message-parity.test.ts +19 -0
  32. package/src/__tests__/list-messages-attachments.test.ts +0 -1
  33. package/src/__tests__/log-export-workspace.test.ts +233 -0
  34. package/src/__tests__/managed-proxy-context.test.ts +1 -1
  35. package/src/__tests__/managed-skill-lifecycle.test.ts +0 -1
  36. package/src/__tests__/media-generate-image.test.ts +7 -2
  37. package/src/__tests__/media-reuse-story.e2e.test.ts +1 -1
  38. package/src/__tests__/memory-regressions.test.ts +0 -1
  39. package/src/__tests__/migration-cross-version-compatibility.test.ts +0 -1
  40. package/src/__tests__/migration-export-http.test.ts +0 -1
  41. package/src/__tests__/migration-import-commit-http.test.ts +0 -1
  42. package/src/__tests__/migration-import-preflight-http.test.ts +0 -1
  43. package/src/__tests__/migration-validate-http.test.ts +0 -1
  44. package/src/__tests__/notification-schedule-dedup.test.ts +237 -0
  45. package/src/__tests__/oauth-cli.test.ts +1 -10
  46. package/src/__tests__/oauth-store.test.ts +3 -5
  47. package/src/__tests__/oauth2-gateway-transport.test.ts +5 -4
  48. package/src/__tests__/onboarding-starter-tasks.test.ts +1 -1
  49. package/src/__tests__/onboarding-template-contract.test.ts +1 -2
  50. package/src/__tests__/pricing.test.ts +0 -11
  51. package/src/__tests__/provider-commit-message-generator.test.ts +21 -14
  52. package/src/__tests__/provider-fail-open-selection.test.ts +9 -8
  53. package/src/__tests__/provider-managed-proxy-integration.test.ts +27 -24
  54. package/src/__tests__/provider-registry-ollama.test.ts +8 -2
  55. package/src/__tests__/recording-handler.test.ts +0 -1
  56. package/src/__tests__/relay-server.test.ts +0 -1
  57. package/src/__tests__/runtime-attachment-metadata.test.ts +0 -1
  58. package/src/__tests__/runtime-events-sse-parity.test.ts +0 -1
  59. package/src/__tests__/runtime-events-sse.test.ts +0 -1
  60. package/src/__tests__/script-proxy-injection-runtime.test.ts +4 -0
  61. package/src/__tests__/secret-routes-managed-proxy.test.ts +0 -1
  62. package/src/__tests__/secret-scanner-executor.test.ts +0 -1
  63. package/src/__tests__/send-endpoint-busy.test.ts +0 -1
  64. package/src/__tests__/session-abort-tool-results.test.ts +3 -1
  65. package/src/__tests__/session-agent-loop-overflow.test.ts +1012 -838
  66. package/src/__tests__/session-agent-loop.test.ts +2 -2
  67. package/src/__tests__/session-confirmation-signals.test.ts +3 -1
  68. package/src/__tests__/session-error.test.ts +5 -4
  69. package/src/__tests__/session-history-web-search.test.ts +34 -9
  70. package/src/__tests__/session-pre-run-repair.test.ts +3 -1
  71. package/src/__tests__/session-provider-retry-repair.test.ts +31 -26
  72. package/src/__tests__/session-queue.test.ts +3 -1
  73. package/src/__tests__/session-runtime-assembly.test.ts +118 -0
  74. package/src/__tests__/session-slash-known.test.ts +31 -13
  75. package/src/__tests__/session-slash-queue.test.ts +3 -1
  76. package/src/__tests__/session-slash-unknown.test.ts +3 -1
  77. package/src/__tests__/session-workspace-cache-state.test.ts +3 -1
  78. package/src/__tests__/session-workspace-injection.test.ts +3 -1
  79. package/src/__tests__/session-workspace-tool-tracking.test.ts +3 -1
  80. package/src/__tests__/shell-tool-proxy-mode.test.ts +0 -1
  81. package/src/__tests__/skill-script-runner-sandbox.test.ts +0 -1
  82. package/src/__tests__/skillssh-registry.test.ts +21 -0
  83. package/src/__tests__/slack-share-routes.test.ts +1 -1
  84. package/src/__tests__/swarm-recursion.test.ts +5 -1
  85. package/src/__tests__/swarm-session-integration.test.ts +25 -14
  86. package/src/__tests__/swarm-tool.test.ts +5 -2
  87. package/src/__tests__/telegram-bot-username-resolution.test.ts +2 -4
  88. package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +1521 -0
  89. package/src/__tests__/tool-execution-abort-cleanup.test.ts +0 -1
  90. package/src/__tests__/tool-executor-lifecycle-events.test.ts +0 -1
  91. package/src/__tests__/tool-executor-shell-integration.test.ts +0 -1
  92. package/src/__tests__/tool-executor.test.ts +0 -1
  93. package/src/__tests__/trust-store.test.ts +5 -1
  94. package/src/__tests__/twilio-routes.test.ts +2 -2
  95. package/src/__tests__/verification-control-plane-policy.test.ts +0 -1
  96. package/src/__tests__/voice-quality.test.ts +2 -1
  97. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
  98. package/src/__tests__/web-search.test.ts +1 -1
  99. package/src/agent/loop.ts +17 -1
  100. package/src/bundler/app-bundler.ts +40 -24
  101. package/src/calls/call-controller.ts +16 -0
  102. package/src/calls/relay-server.ts +29 -13
  103. package/src/calls/voice-control-protocol.ts +1 -0
  104. package/src/calls/voice-quality.ts +1 -1
  105. package/src/calls/voice-session-bridge.ts +9 -3
  106. package/src/channels/types.ts +16 -0
  107. package/src/cli/commands/bash.ts +173 -0
  108. package/src/cli/commands/doctor.ts +5 -23
  109. package/src/cli/commands/oauth/connections.ts +4 -2
  110. package/src/cli/commands/oauth/providers.ts +1 -13
  111. package/src/cli/program.ts +2 -0
  112. package/src/cli/reference.ts +1 -0
  113. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +2 -1
  114. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +3 -5
  115. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +2 -3
  116. package/src/config/bundled-skills/messaging/TOOLS.json +41 -1
  117. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -1
  118. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +2 -1
  119. package/src/config/bundled-skills/messaging/tools/messaging-auth-test.ts +2 -1
  120. package/src/config/bundled-skills/messaging/tools/messaging-list-conversations.ts +2 -1
  121. package/src/config/bundled-skills/messaging/tools/messaging-mark-read.ts +2 -1
  122. package/src/config/bundled-skills/messaging/tools/messaging-read.ts +2 -1
  123. package/src/config/bundled-skills/messaging/tools/messaging-search.ts +2 -1
  124. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +2 -1
  125. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +2 -1
  126. package/src/config/bundled-skills/messaging/tools/shared.ts +2 -1
  127. package/src/config/bundled-skills/phone-calls/references/CONFIG.md +1 -1
  128. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +5 -6
  129. package/src/config/feature-flag-registry.json +8 -0
  130. package/src/config/loader.ts +7 -135
  131. package/src/config/schema.ts +0 -6
  132. package/src/config/schemas/channels.ts +1 -0
  133. package/src/config/schemas/elevenlabs.ts +2 -2
  134. package/src/contacts/contact-store.ts +21 -25
  135. package/src/contacts/contacts-write.ts +6 -6
  136. package/src/contacts/types.ts +2 -0
  137. package/src/context/token-estimator.ts +35 -2
  138. package/src/context/window-manager.ts +16 -2
  139. package/src/daemon/config-watcher.ts +24 -6
  140. package/src/daemon/context-overflow-reducer.ts +13 -2
  141. package/src/daemon/handlers/config-ingress.ts +25 -8
  142. package/src/daemon/handlers/config-model.ts +21 -15
  143. package/src/daemon/handlers/config-telegram.ts +18 -6
  144. package/src/daemon/handlers/dictation.ts +0 -429
  145. package/src/daemon/handlers/skills.ts +1 -200
  146. package/src/daemon/lifecycle.ts +8 -5
  147. package/src/daemon/message-types/contacts.ts +2 -0
  148. package/src/daemon/message-types/integrations.ts +1 -0
  149. package/src/daemon/message-types/sessions.ts +2 -0
  150. package/src/daemon/parse-actual-tokens-from-error.test.ts +75 -0
  151. package/src/daemon/server.ts +23 -2
  152. package/src/daemon/session-agent-loop-handlers.ts +1 -1
  153. package/src/daemon/session-agent-loop.ts +27 -79
  154. package/src/daemon/session-error.ts +5 -4
  155. package/src/daemon/session-process.ts +17 -10
  156. package/src/daemon/session-runtime-assembly.ts +50 -0
  157. package/src/daemon/session-slash.ts +32 -20
  158. package/src/daemon/session.ts +1 -0
  159. package/src/events/domain-events.ts +1 -0
  160. package/src/media/app-icon-generator.ts +2 -1
  161. package/src/media/avatar-router.ts +3 -2
  162. package/src/memory/canonical-guardian-store.ts +25 -3
  163. package/src/memory/db-init.ts +12 -0
  164. package/src/memory/embedding-backend.ts +25 -16
  165. package/src/memory/migrations/158-channel-interaction-columns.ts +18 -0
  166. package/src/memory/migrations/159-drop-contact-interaction-columns.ts +16 -0
  167. package/src/memory/migrations/160-drop-loopback-port-column.ts +13 -0
  168. package/src/memory/migrations/index.ts +3 -0
  169. package/src/memory/retriever.test.ts +19 -12
  170. package/src/memory/schema/contacts.ts +2 -2
  171. package/src/memory/schema/oauth.ts +0 -1
  172. package/src/oauth/byo-connection.ts +55 -49
  173. package/src/oauth/connect-orchestrator.ts +5 -3
  174. package/src/oauth/connect-types.ts +9 -2
  175. package/src/oauth/manual-token-connection.ts +9 -7
  176. package/src/oauth/oauth-store.ts +2 -8
  177. package/src/oauth/provider-behaviors.ts +10 -0
  178. package/src/oauth/seed-providers.ts +13 -5
  179. package/src/permissions/checker.ts +20 -1
  180. package/src/prompts/__tests__/build-cli-reference-section.test.ts +1 -1
  181. package/src/prompts/system-prompt.ts +2 -11
  182. package/src/prompts/templates/BOOTSTRAP.md +1 -3
  183. package/src/providers/anthropic/client.ts +16 -8
  184. package/src/providers/managed-proxy/constants.ts +1 -1
  185. package/src/providers/registry.ts +21 -15
  186. package/src/providers/types.ts +1 -1
  187. package/src/runtime/auth/route-policy.ts +4 -0
  188. package/src/runtime/channel-invite-transports/telegram.ts +12 -6
  189. package/src/runtime/channel-retry-sweep.ts +6 -0
  190. package/src/runtime/http-types.ts +1 -0
  191. package/src/runtime/middleware/error-handler.ts +1 -2
  192. package/src/runtime/routes/app-management-routes.ts +1 -0
  193. package/src/runtime/routes/btw-routes.ts +20 -1
  194. package/src/runtime/routes/conversation-routes.ts +32 -13
  195. package/src/runtime/routes/inbound-message-handler.ts +10 -2
  196. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
  197. package/src/runtime/routes/inbound-stages/edit-intercept.ts +5 -5
  198. package/src/runtime/routes/integrations/slack/share.ts +5 -5
  199. package/src/runtime/routes/log-export-routes.ts +122 -10
  200. package/src/runtime/routes/session-query-routes.ts +3 -3
  201. package/src/runtime/routes/settings-routes.ts +53 -0
  202. package/src/runtime/routes/workspace-routes.ts +3 -0
  203. package/src/runtime/verification-templates.ts +1 -1
  204. package/src/security/oauth2.ts +4 -4
  205. package/src/security/secure-keys.ts +24 -3
  206. package/src/security/token-manager.ts +7 -8
  207. package/src/signals/bash.ts +157 -0
  208. package/src/skills/skillssh-registry.ts +6 -1
  209. package/src/swarm/backend-claude-code.ts +6 -6
  210. package/src/swarm/worker-backend.ts +1 -1
  211. package/src/swarm/worker-runner.ts +1 -1
  212. package/src/telegram/bot-username.ts +11 -0
  213. package/src/tools/claude-code/claude-code.ts +4 -4
  214. package/src/tools/credentials/broker.ts +7 -5
  215. package/src/tools/credentials/vault.ts +3 -2
  216. package/src/tools/network/__tests__/web-search.test.ts +18 -86
  217. package/src/tools/network/web-search.ts +9 -15
  218. package/src/util/platform.ts +7 -1
  219. package/src/util/pricing.ts +0 -1
  220. package/src/workspace/provider-commit-message-generator.ts +10 -6
@@ -15,7 +15,7 @@ import type {
15
15
  TurnInterfaceContext,
16
16
  } from "../channels/types.js";
17
17
  import { parseChannelId, parseInterfaceId } from "../channels/types.js";
18
- import { getConfig } from "../config/loader.js";
18
+ import { API_KEY_PROVIDERS, getConfig } from "../config/loader.js";
19
19
  import { listPendingRequestsByConversationScope } from "../memory/canonical-guardian-store.js";
20
20
  import {
21
21
  addMessage,
@@ -27,6 +27,7 @@ import { extractPreferences } from "../notifications/preference-extractor.js";
27
27
  import { createPreference } from "../notifications/preferences-store.js";
28
28
  import type { Message } from "../providers/types.js";
29
29
  import { routeGuardianReply } from "../runtime/guardian-reply-router.js";
30
+ import { getSecureKeyAsync } from "../security/secure-keys.js";
30
31
  import { getLogger } from "../util/logger.js";
31
32
  import type {
32
33
  ServerMessage,
@@ -47,12 +48,15 @@ import { resolveVerificationSessionIntent } from "./verification-session-intent.
47
48
  const log = getLogger("session-process");
48
49
 
49
50
  /** Build a model_info event with fresh config data. */
50
- export function buildModelInfoEvent(): ServerMessage {
51
+ export async function buildModelInfoEvent(): Promise<ServerMessage> {
51
52
  const config = getConfig();
52
- const configured = Object.keys(config.apiKeys).filter(
53
- (k) => !!config.apiKeys[k],
54
- );
55
- if (!configured.includes("ollama")) configured.push("ollama");
53
+ const configured: string[] = ["ollama"];
54
+ for (const p of API_KEY_PROVIDERS) {
55
+ if (p === "ollama") continue;
56
+ if (await getSecureKeyAsync(p)) {
57
+ configured.push(p);
58
+ }
59
+ }
56
60
  return {
57
61
  type: "model_info",
58
62
  model: config.model,
@@ -296,7 +300,10 @@ export async function drainQueue(
296
300
  }
297
301
 
298
302
  // Resolve slash commands for queued messages
299
- const slashResult = resolveSlash(next.content, buildSlashContext(session));
303
+ const slashResult = await resolveSlash(
304
+ next.content,
305
+ buildSlashContext(session),
306
+ );
300
307
 
301
308
  // Unknown slash — persist the exchange and continue draining.
302
309
  // Persist each message before pushing to session.messages so that a
@@ -365,7 +372,7 @@ export async function drainQueue(
365
372
  isModelSlashCommand(next.content) ||
366
373
  isProviderShortcut(next.content)
367
374
  ) {
368
- next.onEvent(buildModelInfoEvent());
375
+ next.onEvent(await buildModelInfoEvent());
369
376
  }
370
377
  next.onEvent({ type: "assistant_text_delta", text: slashResult.message });
371
378
  session.traceEmitter.emit(
@@ -651,7 +658,7 @@ export async function processMessage(
651
658
  }
652
659
 
653
660
  // Resolve slash commands before persistence
654
- const slashResult = resolveSlash(content, buildSlashContext(session));
661
+ const slashResult = await resolveSlash(content, buildSlashContext(session));
655
662
 
656
663
  // Unknown slash command — persist the exchange (user + assistant) so the
657
664
  // messageId is real. Persist each message before pushing to session.messages
@@ -715,7 +722,7 @@ export async function processMessage(
715
722
  // Emit fresh model info before the text delta so the client has
716
723
  // up-to-date configuredProviders when rendering /model or /models UI.
717
724
  if (isModelSlashCommand(content) || isProviderShortcut(content)) {
718
- onEvent(buildModelInfoEvent());
725
+ onEvent(await buildModelInfoEvent());
719
726
  }
720
727
  onEvent({ type: "assistant_text_delta", text: slashResult.message });
721
728
  session.traceEmitter.emit(
@@ -37,6 +37,8 @@ export interface ChannelCapabilities {
37
37
  pttActivationKey?: string;
38
38
  /** Whether the client has been granted microphone permission by the OS. */
39
39
  microphonePermissionGranted?: boolean;
40
+ /** Chat type from the gateway (e.g. "private", "group", "supergroup", "channel", "im", "mpim"). */
41
+ chatType?: string;
40
42
  }
41
43
 
42
44
  /**
@@ -296,6 +298,7 @@ export function resolveChannelCapabilities(
296
298
  sourceChannel?: string | null,
297
299
  sourceInterface?: string | null,
298
300
  pttMetadata?: PttMetadata | null,
301
+ chatType?: string | null,
299
302
  ): ChannelCapabilities {
300
303
  // Normalise legacy pseudo-channel IDs to canonical ChannelId values.
301
304
  let channel: string;
@@ -330,6 +333,8 @@ export function resolveChannelCapabilities(
330
333
  }
331
334
  }
332
335
 
336
+ const resolvedChatType = chatType ?? undefined;
337
+
333
338
  switch (channel) {
334
339
  case "vellum": {
335
340
  const supportsDesktopUi = iface === "macos";
@@ -342,6 +347,7 @@ export function resolveChannelCapabilities(
342
347
  pttMetadata?.pttActivationKey,
343
348
  ),
344
349
  microphonePermissionGranted: pttMetadata?.microphonePermissionGranted,
350
+ chatType: resolvedChatType,
345
351
  };
346
352
  }
347
353
  case "telegram":
@@ -354,6 +360,7 @@ export function resolveChannelCapabilities(
354
360
  dashboardCapable: false,
355
361
  supportsDynamicUi: false,
356
362
  supportsVoiceInput: false,
363
+ chatType: resolvedChatType,
357
364
  };
358
365
  default:
359
366
  return {
@@ -361,10 +368,28 @@ export function resolveChannelCapabilities(
361
368
  dashboardCapable: false,
362
369
  supportsDynamicUi: false,
363
370
  supportsVoiceInput: false,
371
+ chatType: resolvedChatType,
364
372
  };
365
373
  }
366
374
  }
367
375
 
376
+ /**
377
+ * Returns true when the chat type indicates a group/multi-party conversation
378
+ * (Telegram group/supergroup, Slack channel/group/mpim, etc.).
379
+ */
380
+ export function isGroupChatType(chatType?: string): boolean {
381
+ if (!chatType) return false;
382
+ switch (chatType) {
383
+ case "group":
384
+ case "supergroup":
385
+ case "channel":
386
+ case "mpim":
387
+ return true;
388
+ default:
389
+ return false;
390
+ }
391
+ }
392
+
368
393
  /** Context about the active workspace surface, passed to applyRuntimeInjections. */
369
394
  export interface ActiveSurfaceContext {
370
395
  surfaceId: string;
@@ -632,6 +657,31 @@ export function injectChannelCapabilityContext(
632
657
  }
633
658
  }
634
659
 
660
+ // Inject group chat etiquette only when the chat type indicates a multi-party
661
+ // conversation, avoiding misconditioned "stay silent" guidance in 1:1 DMs.
662
+ if (isGroupChatType(caps.chatType)) {
663
+ lines.push(`chat_type: ${caps.chatType}`);
664
+ lines.push("");
665
+ lines.push("GROUP CHAT ETIQUETTE:");
666
+ lines.push(
667
+ "- You are a **participant**, not the user's proxy. Think before you speak.",
668
+ );
669
+ lines.push(
670
+ "- **Respond when:** directly mentioned, you can add genuine value, something witty fits naturally, or correcting important misinformation.",
671
+ );
672
+ lines.push(
673
+ '- **Stay silent when:** casual banter between humans, someone already answered, your response would just be "yeah" or "nice", or the conversation flows fine without you.',
674
+ );
675
+ lines.push(
676
+ "- **The human rule:** humans don't respond to every message in a group chat. Neither should you. Quality over quantity.",
677
+ );
678
+ if (caps.channel === "slack") {
679
+ lines.push(
680
+ "- Use emoji reactions naturally to acknowledge without cluttering.",
681
+ );
682
+ }
683
+ }
684
+
635
685
  lines.push("</channel_capabilities>");
636
686
 
637
687
  const block = lines.join("\n");
@@ -5,10 +5,16 @@ import { join } from "node:path";
5
5
  import QRCode from "qrcode";
6
6
 
7
7
  import { getGatewayPort, getIngressPublicBaseUrl } from "../config/env.js";
8
- import { getConfig, loadRawConfig, saveRawConfig } from "../config/loader.js";
8
+ import {
9
+ API_KEY_PROVIDERS,
10
+ getConfig,
11
+ loadRawConfig,
12
+ saveRawConfig,
13
+ } from "../config/loader.js";
9
14
  import { resolveSkillStates } from "../config/skill-state.js";
10
15
  import { loadSkillCatalog } from "../config/skills.js";
11
16
  import { initializeProviders } from "../providers/registry.js";
17
+ import { getSecureKeyAsync } from "../security/secure-keys.js";
12
18
  import {
13
19
  buildInvocableSlashCatalog,
14
20
  resolveSlashSkillCommand,
@@ -53,14 +59,12 @@ export interface SlashContext {
53
59
 
54
60
  const AVAILABLE_MODELS = [
55
61
  "claude-opus-4-6",
56
- "claude-opus-4-6-fast",
57
62
  "claude-sonnet-4-6",
58
63
  "claude-haiku-4-5-20251001",
59
64
  ] as const;
60
65
 
61
66
  const MODEL_DISPLAY_NAMES: Record<string, string> = {
62
67
  "claude-opus-4-6": "Claude Opus 4.6",
63
- "claude-opus-4-6-fast": "Claude Opus 4.6 Fast",
64
68
  "claude-sonnet-4-6": "Claude Sonnet 4.6",
65
69
  "claude-haiku-4-5-20251001": "Claude Haiku 4.5",
66
70
  };
@@ -75,11 +79,6 @@ const PROVIDER_MODEL_SHORTCUTS: Record<
75
79
  model: "claude-opus-4-6",
76
80
  displayName: "Claude Opus 4.6",
77
81
  },
78
- "opus-fast": {
79
- provider: "anthropic",
80
- model: "claude-opus-4-6-fast",
81
- displayName: "Claude Opus 4.6 Fast",
82
- },
83
82
  sonnet: {
84
83
  provider: "anthropic",
85
84
  model: "claude-sonnet-4-6",
@@ -146,7 +145,9 @@ function matchModel(input: string): string | undefined {
146
145
  return AVAILABLE_MODELS.find((m) => m.includes(lower));
147
146
  }
148
147
 
149
- function resolveProviderModelCommand(content: string): SlashResolution | null {
148
+ async function resolveProviderModelCommand(
149
+ content: string,
150
+ ): Promise<SlashResolution | null> {
150
151
  const trimmed = content.trim();
151
152
  if (!trimmed.startsWith("/")) return null;
152
153
 
@@ -163,7 +164,7 @@ function resolveProviderModelCommand(content: string): SlashResolution | null {
163
164
  const name = getAssistantName();
164
165
 
165
166
  // Check if API key exists for this provider (Ollama doesn't require an API key)
166
- if (provider !== "ollama" && !config.apiKeys[provider]) {
167
+ if (provider !== "ollama" && !(await getSecureKeyAsync(provider))) {
167
168
  return {
168
169
  kind: "unknown",
169
170
  message: `Cannot switch to ${displayName}. No API key configured for ${provider}.\n\nSet it with: \`keys set ${provider} <your-key>\``,
@@ -201,14 +202,23 @@ function resolveProviderModelCommand(content: string): SlashResolution | null {
201
202
  };
202
203
  }
203
204
 
204
- function resolveModelList(): SlashResolution {
205
+ async function resolveModelList(): Promise<SlashResolution> {
205
206
  const config = getConfig();
207
+
208
+ // Build a set of providers that have a configured API key.
209
+ const configuredProviders = new Set<string>(["ollama"]);
210
+ for (const p of API_KEY_PROVIDERS) {
211
+ if (await getSecureKeyAsync(p)) {
212
+ configuredProviders.add(p);
213
+ }
214
+ }
215
+
206
216
  const lines = ["Available models:\n"];
207
217
 
208
218
  for (const [cmd, { provider, model, displayName }] of Object.entries(
209
219
  PROVIDER_MODEL_SHORTCUTS,
210
220
  )) {
211
- const hasKey = provider === "ollama" || !!config.apiKeys[provider];
221
+ const hasKey = configuredProviders.has(provider);
212
222
  const isCurrent = config.provider === provider && config.model === model;
213
223
  const status = hasKey ? "✓" : "✗";
214
224
  const current = isCurrent ? " **[current]**" : "";
@@ -224,11 +234,13 @@ function resolveModelList(): SlashResolution {
224
234
  };
225
235
  }
226
236
 
227
- function resolveModelCommand(content: string): SlashResolution | null {
237
+ async function resolveModelCommand(
238
+ content: string,
239
+ ): Promise<SlashResolution | null> {
228
240
  const trimmed = content.trim();
229
241
  // Match /models → route to list
230
242
  if (trimmed === "/models") {
231
- return resolveModelList();
243
+ return await resolveModelList();
232
244
  }
233
245
 
234
246
  if (!trimmed.startsWith("/model")) return null;
@@ -251,7 +263,7 @@ function resolveModelCommand(content: string): SlashResolution | null {
251
263
 
252
264
  // Handle /model list
253
265
  if (args === "list") {
254
- return resolveModelList();
266
+ return await resolveModelList();
255
267
  }
256
268
 
257
269
  // Try to match the model name
@@ -280,7 +292,7 @@ function resolveModelCommand(content: string): SlashResolution | null {
280
292
  }
281
293
 
282
294
  // Validate that Anthropic provider is available
283
- if (!currentConfig.apiKeys.anthropic) {
295
+ if (!(await getSecureKeyAsync("anthropic"))) {
284
296
  const displayName = MODEL_DISPLAY_NAMES[matched] ?? matched;
285
297
  return {
286
298
  kind: "unknown",
@@ -343,16 +355,16 @@ function resolveStatusCommand(context: SlashContext): SlashResolution {
343
355
  * Resolve slash commands against the current skill catalog.
344
356
  * Returns `unknown` with a deterministic message, or the (possibly rewritten) content.
345
357
  */
346
- export function resolveSlash(
358
+ export async function resolveSlash(
347
359
  content: string,
348
360
  context?: SlashContext,
349
- ): SlashResolution {
361
+ ): Promise<SlashResolution> {
350
362
  // Check provider shortcuts first (/gpt4, /opus, etc.)
351
- const providerResult = resolveProviderModelCommand(content);
363
+ const providerResult = await resolveProviderModelCommand(content);
352
364
  if (providerResult) return providerResult;
353
365
 
354
366
  // Handle /model command
355
- const modelResult = resolveModelCommand(content);
367
+ const modelResult = await resolveModelCommand(content);
356
368
  if (modelResult) return modelResult;
357
369
 
358
370
  // Handle /pair command
@@ -348,6 +348,7 @@ export class Session {
348
348
  provider,
349
349
  systemPrompt: () => resolveSystemPromptCallback([]).systemPrompt,
350
350
  config: config.contextWindow,
351
+ toolTokenBudget: this.agentLoop.getToolTokenBudget(),
351
352
  });
352
353
 
353
354
  void getHookManager().trigger("session-start", {
@@ -25,6 +25,7 @@ export interface ToolDomainEvents {
25
25
  | "allow_10m"
26
26
  | "allow_thread"
27
27
  | "always_allow"
28
+ | "always_allow_high_risk"
28
29
  | "deny"
29
30
  | "always_deny"
30
31
  | "temporary_override";
@@ -15,6 +15,7 @@ import {
15
15
  buildManagedBaseUrl,
16
16
  resolveManagedProxyContext,
17
17
  } from "../providers/managed-proxy/context.js";
18
+ import { getSecureKeyAsync } from "../security/secure-keys.js";
18
19
  import { getLogger } from "../util/logger.js";
19
20
  import {
20
21
  generateImage,
@@ -36,7 +37,7 @@ export async function generateAppIcon(
36
37
  appDescription?: string,
37
38
  ): Promise<void> {
38
39
  const config = getConfig();
39
- const apiKey = config.apiKeys.gemini ?? process.env.GEMINI_API_KEY;
40
+ const apiKey = await getSecureKeyAsync("gemini");
40
41
 
41
42
  let credentials: ImageGenCredentials | undefined;
42
43
  if (apiKey) {
@@ -3,6 +3,7 @@ import {
3
3
  buildManagedBaseUrl,
4
4
  resolveManagedProxyContext,
5
5
  } from "../providers/managed-proxy/context.js";
6
+ import { getSecureKeyAsync } from "../security/secure-keys.js";
6
7
  import { ConfigError, ProviderError } from "../util/errors.js";
7
8
  import {
8
9
  generateImage,
@@ -13,7 +14,7 @@ export async function generateAvatar(
13
14
  prompt: string,
14
15
  ): Promise<{ imageBase64: string; mimeType: string }> {
15
16
  const config = getConfig();
16
- const geminiKey = config.apiKeys.gemini ?? process.env.GEMINI_API_KEY;
17
+ const geminiKey = await getSecureKeyAsync("gemini");
17
18
 
18
19
  let credentials: ImageGenCredentials | undefined;
19
20
  if (geminiKey) {
@@ -32,7 +33,7 @@ export async function generateAvatar(
32
33
 
33
34
  if (!credentials) {
34
35
  throw new ConfigError(
35
- "Gemini API key is not configured. Set it via `keys set gemini <key>` or the GEMINI_API_KEY environment variable.",
36
+ "Gemini API key is not configured. Set it via `keys set gemini <key>`.",
36
37
  );
37
38
  }
38
39
 
@@ -7,7 +7,7 @@
7
7
  * request from the expected status wins.
8
8
  */
9
9
 
10
- import { and, desc, eq } from "drizzle-orm";
10
+ import { and, desc, eq, inArray } from "drizzle-orm";
11
11
  import { v4 as uuid } from "uuid";
12
12
 
13
13
  import { IntegrityError } from "../util/errors.js";
@@ -460,12 +460,29 @@ export function resolveCanonicalGuardianRequest(
460
460
  }
461
461
 
462
462
  /**
463
- * Expire all pending canonical guardian requests in a single bulk update.
463
+ * Request kinds whose resolution depends on the in-memory
464
+ * `pendingInteractions` Map. These kinds become unresolvable after a daemon
465
+ * restart because the Map is wiped, so they should be expired on startup.
466
+ *
467
+ * Persistent kinds (`access_request`, `tool_grant_request`) resolve without
468
+ * pending interactions and remain valid across restarts — they must NOT be
469
+ * expired here.
470
+ */
471
+ const INTERACTION_BOUND_KINDS = ["tool_approval", "pending_question"];
472
+
473
+ /**
474
+ * Expire pending interaction-bound canonical guardian requests in a single
475
+ * bulk update.
464
476
  *
465
477
  * Called at daemon startup to clean up requests that can never be completed
466
478
  * because the in-memory pending-interactions Map (which holds session
467
479
  * references needed by resolvers) was wiped on restart.
468
480
  *
481
+ * Only expires request kinds that depend on in-memory pending interactions
482
+ * (`tool_approval`, `pending_question`). Persistent kinds like
483
+ * `access_request` and `tool_grant_request` resolve without pending
484
+ * interactions and remain valid across restarts.
485
+ *
469
486
  * Returns the number of requests transitioned from pending → expired.
470
487
  */
471
488
  export function expireAllPendingCanonicalRequests(): number {
@@ -474,7 +491,12 @@ export function expireAllPendingCanonicalRequests(): number {
474
491
 
475
492
  db.update(canonicalGuardianRequests)
476
493
  .set({ status: "expired", updatedAt: now })
477
- .where(eq(canonicalGuardianRequests.status, "pending"))
494
+ .where(
495
+ and(
496
+ eq(canonicalGuardianRequests.status, "pending"),
497
+ inArray(canonicalGuardianRequests.kind, INTERACTION_BOUND_KINDS),
498
+ ),
499
+ )
478
500
  .run();
479
501
 
480
502
  return rawChanges();
@@ -42,6 +42,7 @@ import {
42
42
  migrateCanonicalGuardianDeliveriesDestinationIndex,
43
43
  migrateCanonicalGuardianRequesterChatId,
44
44
  migrateChannelInboundDeliveredSegments,
45
+ migrateChannelInteractionColumns,
45
46
  migrateContactChannelsAccessFields,
46
47
  migrateContactChannelsTypeChatIdIndex,
47
48
  migrateContactsAssistantId,
@@ -51,8 +52,10 @@ import {
51
52
  migrateDropAccountsTable,
52
53
  migrateDropAssistantIdColumns,
53
54
  migrateDropConflicts,
55
+ migrateDropContactInteractionColumns,
54
56
  migrateDropEntityTables,
55
57
  migrateDropLegacyMemberGuardianTables,
58
+ migrateDropLoopbackPortColumn,
56
59
  migrateDropMemorySegmentFts,
57
60
  migrateDropRemindersTable,
58
61
  migrateDropUsageCompositeIndexes,
@@ -376,6 +379,15 @@ export function initializeDb(): void {
376
379
  // 60. Add required contact_id to assistant_ingress_invites and clean up legacy rows
377
380
  migrateInviteContactId(database);
378
381
 
382
+ // 61. Add interaction_count and last_interaction columns to contact_channels
383
+ migrateChannelInteractionColumns(database);
384
+
385
+ // 62. Drop interaction_count and last_interaction columns from contacts (now derived from channels)
386
+ migrateDropContactInteractionColumns(database);
387
+
388
+ // 63. Drop loopback_port column from oauth_providers (moved to code-side behavior registry)
389
+ migrateDropLoopbackPortColumn(database);
390
+
379
391
  validateMigrationState(database);
380
392
 
381
393
  if (process.env.BUN_TEST === "1") {
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
 
3
3
  import { getOllamaBaseUrlEnv } from "../config/env.js";
4
4
  import type { AssistantConfig } from "../config/types.js";
5
+ import { getSecureKey } from "../security/secure-keys.js";
5
6
  import { getLogger } from "../util/logger.js";
6
7
  import { GeminiEmbeddingBackend } from "./embedding-gemini.js";
7
8
  import { OllamaEmbeddingBackend } from "./embedding-ollama.js";
@@ -255,7 +256,7 @@ export function selectEmbeddingBackend(
255
256
  config.memory.embeddings.ollamaModel,
256
257
  () =>
257
258
  new OllamaEmbeddingBackend(config.memory.embeddings.ollamaModel, {
258
- apiKey: config.apiKeys.ollama,
259
+ apiKey: getSecureKey("ollama") ?? undefined,
259
260
  }),
260
261
  ),
261
262
  reason: null,
@@ -283,29 +284,32 @@ export function selectEmbeddingBackend(
283
284
  ),
284
285
  reason: null,
285
286
  };
286
- case "openai":
287
- if (!config.apiKeys.openai) continue;
287
+ case "openai": {
288
+ const openaiKey = getSecureKey("openai");
289
+ if (!openaiKey) continue;
288
290
  return {
289
291
  backend: getCachedOrCreate(
290
292
  "openai",
291
293
  config.memory.embeddings.openaiModel,
292
294
  () =>
293
295
  new OpenAIEmbeddingBackend(
294
- config.apiKeys.openai,
296
+ openaiKey,
295
297
  config.memory.embeddings.openaiModel,
296
298
  ),
297
299
  ),
298
300
  reason: null,
299
301
  };
300
- case "gemini":
301
- if (!config.apiKeys.gemini) continue;
302
+ }
303
+ case "gemini": {
304
+ const geminiKey = getSecureKey("gemini");
305
+ if (!geminiKey) continue;
302
306
  return {
303
307
  backend: getCachedOrCreate(
304
308
  "gemini",
305
309
  config.memory.embeddings.geminiModel,
306
310
  () =>
307
311
  new GeminiEmbeddingBackend(
308
- config.apiKeys.gemini,
312
+ geminiKey,
309
313
  config.memory.embeddings.geminiModel,
310
314
  {
311
315
  taskType: config.memory.embeddings.geminiTaskType,
@@ -316,6 +320,7 @@ export function selectEmbeddingBackend(
316
320
  ),
317
321
  reason: null,
318
322
  };
323
+ }
319
324
  case "ollama":
320
325
  if (!isOllamaConfigured(config)) continue;
321
326
  return {
@@ -324,7 +329,7 @@ export function selectEmbeddingBackend(
324
329
  config.memory.embeddings.ollamaModel,
325
330
  () =>
326
331
  new OllamaEmbeddingBackend(config.memory.embeddings.ollamaModel, {
327
- apiKey: config.apiKeys.ollama,
332
+ apiKey: getSecureKey("ollama") ?? undefined,
328
333
  }),
329
334
  ),
330
335
  reason: null,
@@ -530,30 +535,33 @@ function selectFallbackBackends(
530
535
  for (const provider of order) {
531
536
  if (provider === exclude) continue;
532
537
  switch (provider) {
533
- case "openai":
534
- if (config.apiKeys.openai) {
538
+ case "openai": {
539
+ const openaiKey = getSecureKey("openai");
540
+ if (openaiKey) {
535
541
  backends.push(
536
542
  getCachedOrCreate(
537
543
  "openai",
538
544
  config.memory.embeddings.openaiModel,
539
545
  () =>
540
546
  new OpenAIEmbeddingBackend(
541
- config.apiKeys.openai,
547
+ openaiKey,
542
548
  config.memory.embeddings.openaiModel,
543
549
  ),
544
550
  ),
545
551
  );
546
552
  }
547
553
  break;
548
- case "gemini":
549
- if (config.apiKeys.gemini) {
554
+ }
555
+ case "gemini": {
556
+ const geminiKey = getSecureKey("gemini");
557
+ if (geminiKey) {
550
558
  backends.push(
551
559
  getCachedOrCreate(
552
560
  "gemini",
553
561
  config.memory.embeddings.geminiModel,
554
562
  () =>
555
563
  new GeminiEmbeddingBackend(
556
- config.apiKeys.gemini,
564
+ geminiKey,
557
565
  config.memory.embeddings.geminiModel,
558
566
  {
559
567
  taskType: config.memory.embeddings.geminiTaskType,
@@ -565,6 +573,7 @@ function selectFallbackBackends(
565
573
  );
566
574
  }
567
575
  break;
576
+ }
568
577
  case "ollama":
569
578
  if (isOllamaConfigured(config)) {
570
579
  backends.push(
@@ -575,7 +584,7 @@ function selectFallbackBackends(
575
584
  new OllamaEmbeddingBackend(
576
585
  config.memory.embeddings.ollamaModel,
577
586
  {
578
- apiKey: config.apiKeys.ollama,
587
+ apiKey: getSecureKey("ollama") ?? undefined,
579
588
  },
580
589
  ),
581
590
  ),
@@ -614,7 +623,7 @@ export function selectedBackendSupportsMultimodal(
614
623
  function isOllamaConfigured(config: AssistantConfig): boolean {
615
624
  return (
616
625
  config.provider === "ollama" ||
617
- Boolean(config.apiKeys.ollama) ||
626
+ Boolean(getSecureKey("ollama")) ||
618
627
  Boolean(getOllamaBaseUrlEnv())
619
628
  );
620
629
  }
@@ -0,0 +1,18 @@
1
+ import type { DrizzleDb } from "../db-connection.js";
2
+
3
+ export function migrateChannelInteractionColumns(database: DrizzleDb): void {
4
+ try {
5
+ database.run(
6
+ /*sql*/ `ALTER TABLE contact_channels ADD COLUMN interaction_count INTEGER NOT NULL DEFAULT 0`,
7
+ );
8
+ } catch {
9
+ /* already exists */
10
+ }
11
+ try {
12
+ database.run(
13
+ /*sql*/ `ALTER TABLE contact_channels ADD COLUMN last_interaction INTEGER`,
14
+ );
15
+ } catch {
16
+ /* already exists */
17
+ }
18
+ }
@@ -0,0 +1,16 @@
1
+ import type { DrizzleDb } from "../db-connection.js";
2
+
3
+ export function migrateDropContactInteractionColumns(
4
+ database: DrizzleDb,
5
+ ): void {
6
+ try {
7
+ database.run(/*sql*/ `ALTER TABLE contacts DROP COLUMN interaction_count`);
8
+ } catch {
9
+ /* already dropped or doesn't exist */
10
+ }
11
+ try {
12
+ database.run(/*sql*/ `ALTER TABLE contacts DROP COLUMN last_interaction`);
13
+ } catch {
14
+ /* already dropped or doesn't exist */
15
+ }
16
+ }
@@ -0,0 +1,13 @@
1
+ import type { DrizzleDb } from "../db-connection.js";
2
+ import { getSqliteFrom } from "../db-connection.js";
3
+
4
+ export function migrateDropLoopbackPortColumn(database: DrizzleDb): void {
5
+ const raw = getSqliteFrom(database);
6
+ try {
7
+ raw.exec(
8
+ /*sql*/ `ALTER TABLE oauth_providers DROP COLUMN loopback_port`,
9
+ );
10
+ } catch {
11
+ // Column already dropped or doesn't exist — nothing to do.
12
+ }
13
+ }
@@ -99,6 +99,9 @@ export { migrateDropMemorySegmentFts } from "./154-drop-fts.js";
99
99
  export { migrateDropConflicts } from "./155-drop-conflicts.js";
100
100
  export { migrateCallSessionInviteMetadata } from "./156-call-session-invite-metadata.js";
101
101
  export { migrateInviteContactId } from "./157-invite-contact-id.js";
102
+ export { migrateChannelInteractionColumns } from "./158-channel-interaction-columns.js";
103
+ export { migrateDropContactInteractionColumns } from "./159-drop-contact-interaction-columns.js";
104
+ export { migrateDropLoopbackPortColumn } from "./160-drop-loopback-port-column.js";
102
105
  export {
103
106
  MIGRATION_REGISTRY,
104
107
  type MigrationRegistryEntry,