@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
@@ -232,12 +232,6 @@ export const AssistantConfigSchema = z
232
232
  imageGenModel: z
233
233
  .string({ error: "imageGenModel must be a string" })
234
234
  .default("gemini-2.5-flash-image"),
235
- apiKeys: z
236
- .record(
237
- z.string(),
238
- z.string({ error: "Each apiKeys value must be a string" }),
239
- )
240
- .default({} as Record<string, string>),
241
235
  webSearchProvider: z
242
236
  .enum(VALID_WEB_SEARCH_PROVIDERS, {
243
237
  error: `webSearchProvider must be one of: ${VALID_WEB_SEARCH_PROVIDERS.join(
@@ -34,6 +34,7 @@ export const WhatsAppConfigSchema = z.object({
34
34
  });
35
35
 
36
36
  export const TelegramConfigSchema = z.object({
37
+ botId: z.string({ error: "telegram.botId must be a string" }).default(""),
37
38
  botUsername: z
38
39
  .string({ error: "telegram.botUsername must be a string" })
39
40
  .default(""),
@@ -1,9 +1,9 @@
1
1
  import { z } from "zod";
2
2
 
3
- // Default ElevenLabs voice — "Rachel" (calm, warm, conversational).
3
+ // Default ElevenLabs voice — "Amelia" (expressive, enthusiastic, British English).
4
4
  // Used by both in-app TTS and phone calls (via Twilio ConversationRelay).
5
5
  // Mirrored in: clients/macos/.../OpenAIVoiceService.swift (defaultVoiceId)
6
- export const DEFAULT_ELEVENLABS_VOICE_ID = "21m00Tcm4TlvDq8ikWAM";
6
+ export const DEFAULT_ELEVENLABS_VOICE_ID = "ZF6FPAbjXT4488VcRRnw";
7
7
 
8
8
  export const ElevenLabsConfigSchema = z.object({
9
9
  voiceId: z
@@ -34,8 +34,8 @@ function parseContact(row: typeof contacts.$inferSelect): Contact {
34
34
  id: row.id,
35
35
  displayName: row.displayName,
36
36
  notes: row.notes,
37
- lastInteraction: row.lastInteraction,
38
- interactionCount: row.interactionCount,
37
+ lastInteraction: null,
38
+ interactionCount: 0,
39
39
  createdAt: row.createdAt,
40
40
  updatedAt: row.updatedAt,
41
41
  role: row.role as Contact["role"],
@@ -63,6 +63,8 @@ function parseChannel(
63
63
  revokedReason: row.revokedReason,
64
64
  blockedReason: row.blockedReason,
65
65
  lastSeenAt: row.lastSeenAt,
66
+ interactionCount: row.interactionCount,
67
+ lastInteraction: row.lastInteraction,
66
68
  updatedAt: row.updatedAt,
67
69
  createdAt: row.createdAt,
68
70
  };
@@ -80,7 +82,15 @@ function getChannelsForContact(contactId: string): ContactChannel[] {
80
82
  }
81
83
 
82
84
  function withChannels(contact: Contact): ContactWithChannels {
83
- return { ...contact, channels: getChannelsForContact(contact.id) };
85
+ const channels = getChannelsForContact(contact.id);
86
+ const interactionCount = channels.reduce(
87
+ (sum, ch) => sum + ch.interactionCount,
88
+ 0,
89
+ );
90
+ const lastInteraction =
91
+ channels.reduce((max, ch) => Math.max(max, ch.lastInteraction ?? 0), 0) ||
92
+ null;
93
+ return { ...contact, interactionCount, lastInteraction, channels };
84
94
  }
85
95
 
86
96
  // ── Channel data type for syncChannels ───────────────────────────────
@@ -235,8 +245,6 @@ export function upsertContact(params: {
235
245
  id: contactId,
236
246
  displayName: params.displayName,
237
247
  notes: params.notes ?? null,
238
- lastInteraction: null,
239
- interactionCount: 0,
240
248
  role: params.role ?? "contact",
241
249
  contactType: params.contactType ?? "human",
242
250
  principalId: params.principalId ?? null,
@@ -488,7 +496,7 @@ export function searchContacts(params: {
488
496
  .from(contacts)
489
497
  .innerJoin(contactChannels, eq(contacts.id, contactChannels.contactId))
490
498
  .where(whereClause)
491
- .orderBy(desc(contacts.updatedAt), desc(contacts.lastInteraction))
499
+ .orderBy(desc(contacts.updatedAt))
492
500
  .all();
493
501
 
494
502
  const contactIds = [...new Set(rows.map((r) => r.contactId))];
@@ -509,7 +517,7 @@ export function searchContacts(params: {
509
517
  .select()
510
518
  .from(contacts)
511
519
  .where(whereClause)
512
- .orderBy(desc(contacts.updatedAt), desc(contacts.lastInteraction))
520
+ .orderBy(desc(contacts.updatedAt))
513
521
  .limit(limit)
514
522
  .all();
515
523
 
@@ -531,11 +539,7 @@ export function listContacts(
531
539
  .select()
532
540
  .from(contacts)
533
541
  .where(conditions.length === 1 ? conditions[0] : and(...conditions))
534
- .orderBy(
535
- sql`${contacts.role} = 'guardian' DESC`,
536
- desc(contacts.updatedAt),
537
- desc(contacts.lastInteraction),
538
- )
542
+ .orderBy(sql`${contacts.role} = 'guardian' DESC`, desc(contacts.updatedAt))
539
543
  .limit(effectiveLimit)
540
544
  .all();
541
545
  return rows.map((r) => withChannels(parseContact(r)));
@@ -571,16 +575,8 @@ export function mergeContacts(
571
575
  .get();
572
576
  if (!merge) throw new Error(`Contact "${mergeId}" not found`);
573
577
 
574
- // Resolve merged field values — pick the better/more recent value
575
- const mergedInteractionCount =
576
- keep.interactionCount + merge.interactionCount;
577
- const mergedLastInteraction =
578
- Math.max(keep.lastInteraction ?? 0, merge.lastInteraction ?? 0) || null;
579
-
580
578
  tx.update(contacts)
581
579
  .set({
582
- interactionCount: mergedInteractionCount,
583
- lastInteraction: mergedLastInteraction,
584
580
  notes: [keep.notes, merge.notes].filter(Boolean).join("\n") || null,
585
581
  updatedAt: now,
586
582
  })
@@ -934,19 +930,19 @@ export function updateChannelLastSeenById(channelId: string): void {
934
930
  }
935
931
 
936
932
  /**
937
- * Atomically increment interactionCount and set lastInteraction on a contact.
933
+ * Atomically increment interactionCount and set lastInteraction on a contact channel.
938
934
  * Optimized for the hot path — single UPDATE with no prior SELECT.
939
935
  */
940
- export function updateContactInteraction(contactId: string): void {
936
+ export function updateChannelInteraction(channelId: string): void {
941
937
  const db = getDb();
942
938
  const now = Date.now();
943
- db.update(contacts)
939
+ db.update(contactChannels)
944
940
  .set({
945
941
  lastInteraction: now,
946
- interactionCount: sql`${contacts.interactionCount} + 1`,
942
+ interactionCount: sql`${contactChannels.interactionCount} + 1`,
947
943
  updatedAt: now,
948
944
  })
949
- .where(eq(contacts.id, contactId))
945
+ .where(eq(contactChannels.id, channelId))
950
946
  .run();
951
947
  }
952
948
 
@@ -16,9 +16,9 @@ import {
16
16
  findGuardianForChannel,
17
17
  getChannelById,
18
18
  getContactInternal,
19
+ updateChannelInteraction,
19
20
  updateChannelLastSeenById,
20
21
  updateChannelStatus,
21
- updateContactInteraction,
22
22
  upsertContact,
23
23
  } from "./contact-store.js";
24
24
  import type {
@@ -287,13 +287,13 @@ export function touchChannelLastSeen(channelId: string): void {
287
287
  }
288
288
 
289
289
  /**
290
- * Increment the interaction count and update lastInteraction on a contact.
291
- * Expects a plain contact UUID (Contact.id).
290
+ * Track an interaction on the specific channel that received it.
291
+ * Swallows errors to avoid disrupting the inbound message hot path.
292
292
  */
293
- export function touchContactInteraction(contactId: string): void {
293
+ export function touchContactInteraction(channelId: string): void {
294
294
  try {
295
- updateContactInteraction(contactId);
295
+ updateChannelInteraction(channelId);
296
296
  } catch (err) {
297
- log.warn({ err }, "Failed to update contact interaction stats");
297
+ log.warn({ err }, "Failed to update channel interaction stats");
298
298
  }
299
299
  }
@@ -70,6 +70,8 @@ export interface ContactChannel {
70
70
  revokedReason: string | null;
71
71
  blockedReason: string | null;
72
72
  lastSeenAt: number | null;
73
+ interactionCount: number;
74
+ lastInteraction: number | null;
73
75
  updatedAt: number | null;
74
76
  createdAt: number;
75
77
  }
@@ -1,4 +1,8 @@
1
- import type { ContentBlock, Message } from "../providers/types.js";
1
+ import type {
2
+ ContentBlock,
3
+ Message,
4
+ ToolDefinition,
5
+ } from "../providers/types.js";
2
6
  import { parseImageDimensions } from "./image-dimensions.js";
3
7
 
4
8
  const CHARS_PER_TOKEN = 4;
@@ -29,8 +33,17 @@ const ANTHROPIC_IMAGE_MAX_TOKENS = Math.ceil(
29
33
  const ANTHROPIC_PDF_TOKENS_PER_BYTE = 0.016;
30
34
  const ANTHROPIC_PDF_MIN_TOKENS = 1600; // At least one page
31
35
 
36
+ // Anthropic wraps each tool definition in XML internally, adding overhead
37
+ // beyond the raw JSON schema. Empirically measured at ~132 tokens/tool via
38
+ // the countTokens API, but the overhead varies by schema complexity.
39
+ // We use per-tool estimation (JSON schema size) plus a fixed XML-wrapping
40
+ // overhead to approximate the actual cost.
41
+ const TOOL_DEFINITION_OVERHEAD_TOKENS = 28;
42
+
32
43
  export interface TokenEstimatorOptions {
33
44
  providerName?: string;
45
+ /** Pre-computed tool token budget. When provided, added to the prompt total. */
46
+ toolTokenBudget?: number;
34
47
  }
35
48
 
36
49
  export function estimateTextTokens(text: string): number {
@@ -185,6 +198,25 @@ export function estimateMessagesTokens(
185
198
  return total;
186
199
  }
187
200
 
201
+ /** Estimate token cost for a single tool definition. */
202
+ export function estimateToolDefinitionTokens(tool: ToolDefinition): number {
203
+ return (
204
+ TOOL_DEFINITION_OVERHEAD_TOKENS +
205
+ estimateTextTokens(tool.name) +
206
+ estimateTextTokens(tool.description) +
207
+ estimateTextTokens(stableJson(tool.input_schema))
208
+ );
209
+ }
210
+
211
+ /** Estimate total token cost for an array of tool definitions. */
212
+ export function estimateToolsTokens(tools: ToolDefinition[]): number {
213
+ let total = 0;
214
+ for (const tool of tools) {
215
+ total += estimateToolDefinitionTokens(tool);
216
+ }
217
+ return total;
218
+ }
219
+
188
220
  export function estimatePromptTokens(
189
221
  messages: Message[],
190
222
  systemPrompt?: string,
@@ -193,7 +225,8 @@ export function estimatePromptTokens(
193
225
  const systemTokens = systemPrompt
194
226
  ? SYSTEM_PROMPT_OVERHEAD_TOKENS + estimateTextTokens(systemPrompt)
195
227
  : 0;
196
- return systemTokens + estimateMessagesTokens(messages, options);
228
+ const toolTokens = options?.toolTokenBudget ?? 0;
229
+ return systemTokens + toolTokens + estimateMessagesTokens(messages, options);
197
230
  }
198
231
 
199
232
  function stableJson(value: unknown): string {
@@ -85,12 +85,15 @@ export interface ContextWindowManagerOptions {
85
85
  provider: Provider;
86
86
  systemPrompt: string | (() => string);
87
87
  config: ContextWindowConfig;
88
+ /** Pre-computed tool token budget to include in all estimations. */
89
+ toolTokenBudget?: number;
88
90
  }
89
91
 
90
92
  export class ContextWindowManager {
91
93
  private readonly provider: Provider;
92
94
  private readonly _systemPrompt: string | (() => string);
93
95
  private readonly config: ContextWindowConfig;
96
+ private readonly toolTokenBudget: number;
94
97
  /**
95
98
  * Cached resolved system prompt. Lazily populated on first access via the
96
99
  * `systemPrompt` getter and cleared after each compaction pass so the next
@@ -102,6 +105,7 @@ export class ContextWindowManager {
102
105
  this.provider = options.provider;
103
106
  this._systemPrompt = options.systemPrompt;
104
107
  this.config = options.config;
108
+ this.toolTokenBudget = options.toolTokenBudget ?? 0;
105
109
  }
106
110
 
107
111
  /** Lazily resolve and cache the system prompt for the duration of a compaction pass. */
@@ -132,6 +136,7 @@ export class ContextWindowManager {
132
136
  try {
133
137
  const estimated = estimatePromptTokens(messages, this.systemPrompt, {
134
138
  providerName: this.provider.name,
139
+ toolTokenBudget: this.toolTokenBudget,
135
140
  });
136
141
  const threshold = Math.floor(
137
142
  this.config.maxInputTokens * this.config.compactThreshold,
@@ -163,6 +168,7 @@ export class ContextWindowManager {
163
168
  options?.precomputedEstimate ??
164
169
  estimatePromptTokens(messages, this.systemPrompt, {
165
170
  providerName: this.provider.name,
171
+ toolTokenBudget: this.toolTokenBudget,
166
172
  });
167
173
  const thresholdTokens = Math.floor(
168
174
  this.config.maxInputTokens * this.config.compactThreshold,
@@ -245,6 +251,7 @@ export class ContextWindowManager {
245
251
  const estimatedAfterTruncation = didTruncate
246
252
  ? estimatePromptTokens(truncatedMessages, this.systemPrompt, {
247
253
  providerName: this.provider.name,
254
+ toolTokenBudget: this.toolTokenBudget,
248
255
  })
249
256
  : previousEstimatedInputTokens;
250
257
  return {
@@ -303,7 +310,10 @@ export class ContextWindowManager {
303
310
  const projectedInputTokens = estimatePromptTokens(
304
311
  projectedMessages,
305
312
  this.systemPrompt,
306
- { providerName: this.provider.name },
313
+ {
314
+ providerName: this.provider.name,
315
+ toolTokenBudget: this.toolTokenBudget,
316
+ },
307
317
  );
308
318
  const projectedGainTokens = Math.max(
309
319
  0,
@@ -428,7 +438,10 @@ export class ContextWindowManager {
428
438
  const estimatedInputTokens = estimatePromptTokens(
429
439
  compactedMessages,
430
440
  this.systemPrompt,
431
- { providerName: this.provider.name },
441
+ {
442
+ providerName: this.provider.name,
443
+ toolTokenBudget: this.toolTokenBudget,
444
+ },
432
445
  );
433
446
  log.info(
434
447
  {
@@ -502,6 +515,7 @@ export class ContextWindowManager {
502
515
  );
503
516
  return estimatePromptTokens(projectedMessages, this.systemPrompt, {
504
517
  providerName: this.provider.name,
518
+ toolTokenBudget: this.toolTokenBudget,
505
519
  });
506
520
  };
507
521
 
@@ -20,6 +20,7 @@ import {
20
20
  resetAllowlist,
21
21
  validateAllowlistFile,
22
22
  } from "../security/secret-allowlist.js";
23
+ import { handleBashSignal } from "../signals/bash.js";
23
24
  import { handleConfirmationSignal } from "../signals/confirm.js";
24
25
  import { handleMcpReloadSignal } from "../signals/mcp-reload.js";
25
26
  import { DebouncerMap } from "../util/debounce.js";
@@ -224,20 +225,37 @@ export class ConfigWatcher {
224
225
  // If we can't create it, watching will also fail — handled below.
225
226
  }
226
227
 
227
- const signalHandlers: Record<string, () => void> = {
228
+ const exactSignalHandlers: Record<string, () => void> = {
228
229
  "mcp-reload": handleMcpReloadSignal,
229
230
  confirm: handleConfirmationSignal,
230
231
  };
231
232
 
233
+ const prefixSignalHandlers: Record<string, (filename: string) => void> = {
234
+ "bash.": handleBashSignal,
235
+ };
236
+
232
237
  try {
233
238
  const watcher = watch(signalsDir, (_eventType, filename) => {
234
239
  if (!filename) return;
235
240
  const file = String(filename);
236
- if (!signalHandlers[file]) return;
237
- this.debounceTimers.schedule(`signal:${file}`, () => {
238
- log.info({ file }, "Signal file detected");
239
- signalHandlers[file]();
240
- });
241
+
242
+ if (exactSignalHandlers[file]) {
243
+ this.debounceTimers.schedule(`signal:${file}`, () => {
244
+ log.info({ file }, "Signal file detected");
245
+ exactSignalHandlers[file]();
246
+ });
247
+ return;
248
+ }
249
+
250
+ for (const [prefix, handler] of Object.entries(prefixSignalHandlers)) {
251
+ if (file.startsWith(prefix) && !file.endsWith(".result")) {
252
+ this.debounceTimers.schedule(`signal:${file}`, () => {
253
+ log.info({ file }, "Signal file detected");
254
+ handler(file);
255
+ });
256
+ return;
257
+ }
258
+ }
241
259
  });
242
260
  this.watchers.push(watcher);
243
261
  log.info({ dir: signalsDir }, "Watching signals directory");
@@ -88,6 +88,8 @@ export interface ReducerConfig {
88
88
  contextWindow: ContextWindowConfig;
89
89
  /** Target token budget — the reducer tries to get below this. */
90
90
  targetTokens: number;
91
+ /** Pre-computed tool token budget to include in estimations. */
92
+ toolTokenBudget?: number;
91
93
  }
92
94
 
93
95
  /**
@@ -143,6 +145,7 @@ export async function reduceContextOverflow(
143
145
  // All tiers exhausted
144
146
  const estimatedTokens = estimatePromptTokens(messages, config.systemPrompt, {
145
147
  providerName: config.providerName,
148
+ toolTokenBudget: config.toolTokenBudget,
146
149
  });
147
150
  return {
148
151
  messages,
@@ -175,6 +178,7 @@ async function applyForcedCompaction(
175
178
  ? result.estimatedInputTokens
176
179
  : estimatePromptTokens(messages, config.systemPrompt, {
177
180
  providerName: config.providerName,
181
+ toolTokenBudget: config.toolTokenBudget,
178
182
  });
179
183
 
180
184
  const nextApplied: ReducerTier[] = [...applied, "forced_compaction"];
@@ -205,7 +209,10 @@ function applyToolResultTruncation(
205
209
  const estimatedTokens = estimatePromptTokens(
206
210
  nextMessages,
207
211
  config.systemPrompt,
208
- { providerName: config.providerName },
212
+ {
213
+ providerName: config.providerName,
214
+ toolTokenBudget: config.toolTokenBudget,
215
+ },
209
216
  );
210
217
 
211
218
  const nextApplied: ReducerTier[] = [...applied, "tool_result_truncation"];
@@ -242,7 +249,10 @@ function applyMediaStubbing(
242
249
  const estimatedTokens = estimatePromptTokens(
243
250
  nextMessages,
244
251
  config.systemPrompt,
245
- { providerName: config.providerName },
252
+ {
253
+ providerName: config.providerName,
254
+ toolTokenBudget: config.toolTokenBudget,
255
+ },
246
256
  );
247
257
 
248
258
  const nextApplied: ReducerTier[] = [...applied, "media_stubbing"];
@@ -271,6 +281,7 @@ function applyInjectionDowngrade(
271
281
  // mode, which the caller applies via applyRuntimeInjections().
272
282
  const estimatedTokens = estimatePromptTokens(messages, config.systemPrompt, {
273
283
  providerName: config.providerName,
284
+ toolTokenBudget: config.toolTokenBudget,
274
285
  });
275
286
 
276
287
  const nextApplied: ReducerTier[] = [...applied, "injection_downgrade"];
@@ -29,6 +29,29 @@ export function computeGatewayTarget(): string {
29
29
  return getGatewayInternalBaseUrl();
30
30
  }
31
31
 
32
+ /**
33
+ * Read the current ingress config from the raw workspace config file.
34
+ * Extracted so it can be called from both the daemon message handler
35
+ * and the HTTP route handler.
36
+ */
37
+ export function getIngressConfigResult(): {
38
+ enabled: boolean;
39
+ publicBaseUrl: string;
40
+ localGatewayTarget: string;
41
+ success: boolean;
42
+ } {
43
+ const raw = loadRawConfig();
44
+ const ingress = (raw?.ingress ?? {}) as Record<string, unknown>;
45
+ const publicBaseUrl = (ingress.publicBaseUrl as string) ?? "";
46
+ const enabled = (ingress.enabled as boolean | undefined) ?? false;
47
+ return {
48
+ enabled,
49
+ publicBaseUrl,
50
+ localGatewayTarget: computeGatewayTarget(),
51
+ success: true,
52
+ };
53
+ }
54
+
32
55
  /**
33
56
  * Best-effort Twilio webhook sync helper.
34
57
  *
@@ -80,16 +103,10 @@ export async function handleIngressConfig(
80
103
  const localGatewayTarget = computeGatewayTarget();
81
104
  try {
82
105
  if (msg.action === "get") {
83
- const raw = loadRawConfig();
84
- const ingress = (raw?.ingress ?? {}) as Record<string, unknown>;
85
- const publicBaseUrl = (ingress.publicBaseUrl as string) ?? "";
86
- const enabled = (ingress.enabled as boolean | undefined) ?? false;
106
+ const result = getIngressConfigResult();
87
107
  ctx.send({
88
108
  type: "ingress_config_response",
89
- enabled,
90
- publicBaseUrl,
91
- localGatewayTarget,
92
- success: true,
109
+ ...result,
93
110
  });
94
111
  } else if (msg.action === "set") {
95
112
  const value = (msg.publicBaseUrl ?? "").trim().replace(/\/+$/, "");
@@ -1,9 +1,11 @@
1
1
  import {
2
+ API_KEY_PROVIDERS,
2
3
  getConfig,
3
4
  loadRawConfig,
4
5
  saveRawConfig,
5
6
  } from "../../config/loader.js";
6
7
  import { initializeProviders } from "../../providers/registry.js";
8
+ import { getSecureKeyAsync } from "../../security/secure-keys.js";
7
9
  import type {
8
10
  ImageGenModelSetRequest,
9
11
  ModelSetRequest,
@@ -26,11 +28,14 @@ export interface ModelInfo {
26
28
  }
27
29
 
28
30
  /** Return current model configuration. */
29
- export function getModelInfo(): ModelInfo {
31
+ export async function getModelInfo(): Promise<ModelInfo> {
30
32
  const config = getConfig();
31
- const configured = Object.keys(config.apiKeys).filter(
32
- (k) => !!config.apiKeys[k],
33
- );
33
+ const configured: string[] = [];
34
+ for (const p of API_KEY_PROVIDERS) {
35
+ if (await getSecureKeyAsync(p)) {
36
+ configured.push(p);
37
+ }
38
+ }
34
39
  if (!configured.includes("ollama")) configured.push("ollama");
35
40
  return {
36
41
  model: config.model,
@@ -58,7 +63,10 @@ export interface ModelSetContext {
58
63
  * Set the active model. Returns the resulting ModelInfo, or throws on failure.
59
64
  * The caller is responsible for sending the response to the client.
60
65
  */
61
- export function setModel(modelId: string, ctx: ModelSetContext): ModelInfo {
66
+ export async function setModel(
67
+ modelId: string,
68
+ ctx: ModelSetContext,
69
+ ): Promise<ModelInfo> {
62
70
  // If the requested model is already the current model AND the provider
63
71
  // is already aligned with what MODEL_TO_PROVIDER expects, skip expensive
64
72
  // reinitialization but still return model_info so the client confirms.
@@ -68,17 +76,16 @@ export function setModel(modelId: string, ctx: ModelSetContext): ModelInfo {
68
76
  const providerAligned =
69
77
  !expectedProvider || current.provider === expectedProvider;
70
78
  if (modelId === current.model && providerAligned) {
71
- return getModelInfo();
79
+ return await getModelInfo();
72
80
  }
73
81
  }
74
82
 
75
83
  // Validate API key before switching
76
84
  const provider = MODEL_TO_PROVIDER[modelId];
77
85
  if (provider && provider !== "ollama") {
78
- const currentConfig = getConfig();
79
- if (!currentConfig.apiKeys[provider]) {
86
+ if (!(await getSecureKeyAsync(provider))) {
80
87
  // Return current model_info so the client resyncs its optimistic state
81
- return getModelInfo();
88
+ return await getModelInfo();
82
89
  }
83
90
  }
84
91
 
@@ -158,20 +165,20 @@ export function setImageGenModel(modelId: string, ctx: ModelSetContext): void {
158
165
  // HTTP handlers (delegate to shared logic)
159
166
  // ---------------------------------------------------------------------------
160
167
 
161
- export function handleModelGet(ctx: HandlerContext): void {
162
- const info = getModelInfo();
168
+ export async function handleModelGet(ctx: HandlerContext): Promise<void> {
169
+ const info = await getModelInfo();
163
170
  ctx.send({
164
171
  type: "model_info",
165
172
  ...info,
166
173
  });
167
174
  }
168
175
 
169
- export function handleModelSet(
176
+ export async function handleModelSet(
170
177
  msg: ModelSetRequest,
171
178
  ctx: HandlerContext,
172
- ): void {
179
+ ): Promise<void> {
173
180
  try {
174
- const info = setModel(msg.model, ctx);
181
+ const info = await setModel(msg.model, ctx);
175
182
  ctx.send({ type: "model_info", ...info });
176
183
  } catch (err) {
177
184
  const message = err instanceof Error ? err.message : String(err);
@@ -193,4 +200,3 @@ export function handleImageGenModelSet(
193
200
  log.error({ err }, `Failed to set image gen model: ${message}`);
194
201
  }
195
202
  }
196
-