vellum 0.2.2 → 0.2.8

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 (60) hide show
  1. package/bun.lock +68 -100
  2. package/package.json +3 -3
  3. package/src/__tests__/asset-materialize-tool.test.ts +2 -2
  4. package/src/__tests__/checker.test.ts +104 -0
  5. package/src/__tests__/config-schema.test.ts +6 -0
  6. package/src/__tests__/gateway-only-enforcement.test.ts +458 -0
  7. package/src/__tests__/handlers-twilio-config.test.ts +221 -0
  8. package/src/__tests__/ipc-snapshot.test.ts +20 -0
  9. package/src/__tests__/memory-regressions.test.ts +100 -2
  10. package/src/__tests__/oauth-callback-registry.test.ts +85 -0
  11. package/src/__tests__/oauth2-gateway-transport.test.ts +298 -0
  12. package/src/__tests__/provider-commit-message-generator.test.ts +342 -0
  13. package/src/__tests__/public-ingress-urls.test.ts +206 -0
  14. package/src/__tests__/session-conflict-gate.test.ts +28 -25
  15. package/src/__tests__/tool-executor.test.ts +88 -0
  16. package/src/__tests__/turn-commit.test.ts +64 -0
  17. package/src/calls/__tests__/twilio-webhook-urls.test.ts +162 -0
  18. package/src/calls/call-domain.ts +3 -3
  19. package/src/calls/twilio-config.ts +25 -9
  20. package/src/calls/twilio-provider.ts +4 -4
  21. package/src/calls/twilio-routes.ts +10 -2
  22. package/src/calls/twilio-webhook-urls.ts +47 -0
  23. package/src/cli/map.ts +30 -6
  24. package/src/config/defaults.ts +5 -0
  25. package/src/config/schema.ts +34 -2
  26. package/src/config/system-prompt.ts +1 -1
  27. package/src/config/types.ts +1 -0
  28. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -5
  29. package/src/daemon/computer-use-session.ts +2 -1
  30. package/src/daemon/handlers/config.ts +95 -4
  31. package/src/daemon/handlers/sessions.ts +2 -2
  32. package/src/daemon/handlers/work-items.ts +1 -1
  33. package/src/daemon/ipc-contract-inventory.json +8 -0
  34. package/src/daemon/ipc-contract.ts +39 -1
  35. package/src/daemon/ride-shotgun-handler.ts +2 -1
  36. package/src/daemon/session-agent-loop.ts +37 -2
  37. package/src/daemon/session-conflict-gate.ts +18 -109
  38. package/src/daemon/session-tool-setup.ts +7 -0
  39. package/src/inbound/public-ingress-urls.ts +106 -0
  40. package/src/memory/attachments-store.ts +0 -1
  41. package/src/memory/channel-delivery-store.ts +0 -1
  42. package/src/memory/conflict-intent.ts +114 -0
  43. package/src/memory/conversation-key-store.ts +0 -1
  44. package/src/memory/db.ts +346 -149
  45. package/src/memory/job-handlers/conflict.ts +23 -1
  46. package/src/memory/runs-store.ts +0 -3
  47. package/src/memory/schema.ts +0 -4
  48. package/src/runtime/gateway-client.ts +36 -0
  49. package/src/runtime/http-server.ts +140 -2
  50. package/src/runtime/routes/channel-routes.ts +121 -79
  51. package/src/security/oauth-callback-registry.ts +56 -0
  52. package/src/security/oauth2.ts +174 -58
  53. package/src/swarm/backend-claude-code.ts +1 -1
  54. package/src/tools/assets/search.ts +1 -36
  55. package/src/tools/browser/api-map.ts +123 -50
  56. package/src/tools/claude-code/claude-code.ts +131 -1
  57. package/src/tools/tasks/work-item-list.ts +16 -2
  58. package/src/workspace/commit-message-enrichment-service.ts +3 -3
  59. package/src/workspace/provider-commit-message-generator.ts +57 -14
  60. package/src/workspace/turn-commit.ts +6 -2
@@ -212,6 +212,8 @@ export interface RideShotgunStart {
212
212
  intervalSeconds: number;
213
213
  mode?: 'observe' | 'learn';
214
214
  targetDomain?: string;
215
+ /** Domain to auto-navigate (may differ from targetDomain, e.g. open.spotify.com vs spotify.com). */
216
+ navigateDomain?: string;
215
217
  autoNavigate?: boolean;
216
218
  }
217
219
 
@@ -470,6 +472,18 @@ export interface SlackWebhookConfigRequest {
470
472
  webhookUrl?: string;
471
473
  }
472
474
 
475
+ export interface TwilioWebhookConfigRequest {
476
+ type: 'twilio_webhook_config';
477
+ action: 'get' | 'set';
478
+ webhookBaseUrl?: string;
479
+ }
480
+
481
+ export interface IngressConfigRequest {
482
+ type: 'ingress_config';
483
+ action: 'get' | 'set';
484
+ publicBaseUrl?: string;
485
+ }
486
+
473
487
  export interface VercelApiConfigRequest {
474
488
  type: 'vercel_api_config';
475
489
  action: 'get' | 'set' | 'delete';
@@ -933,6 +947,8 @@ export type ClientMessage =
933
947
  | ShareAppCloudRequest
934
948
  | ShareToSlackRequest
935
949
  | SlackWebhookConfigRequest
950
+ | TwilioWebhookConfigRequest
951
+ | IngressConfigRequest
936
952
  | VercelApiConfigRequest
937
953
  | TwitterIntegrationConfigRequest
938
954
  | TwitterAuthStartRequest
@@ -1041,6 +1057,12 @@ export interface ToolUseStart {
1041
1057
  export interface ToolOutputChunk {
1042
1058
  type: 'tool_output_chunk';
1043
1059
  chunk: string;
1060
+ sessionId?: string;
1061
+ subType?: 'tool_start' | 'tool_complete' | 'status';
1062
+ subToolName?: string;
1063
+ subToolInput?: string;
1064
+ subToolIsError?: boolean;
1065
+ subToolId?: string;
1044
1066
  }
1045
1067
 
1046
1068
  export interface ToolInputDelta {
@@ -1675,6 +1697,20 @@ export interface SlackWebhookConfigResponse {
1675
1697
  error?: string;
1676
1698
  }
1677
1699
 
1700
+ export interface TwilioWebhookConfigResponse {
1701
+ type: 'twilio_webhook_config_response';
1702
+ webhookBaseUrl: string;
1703
+ success: boolean;
1704
+ error?: string;
1705
+ }
1706
+
1707
+ export interface IngressConfigResponse {
1708
+ type: 'ingress_config_response';
1709
+ publicBaseUrl: string;
1710
+ success: boolean;
1711
+ error?: string;
1712
+ }
1713
+
1678
1714
  export interface OpenUrl {
1679
1715
  type: 'open_url';
1680
1716
  url: string;
@@ -1993,7 +2029,7 @@ export interface WorkItemDeleteResponse {
1993
2029
  success: boolean;
1994
2030
  }
1995
2031
 
1996
- export type WorkItemRunTaskErrorCode = 'not_found' | 'already_running' | 'invalid_status' | 'no_task';
2032
+ export type WorkItemRunTaskErrorCode = 'not_found' | 'already_running' | 'invalid_status' | 'no_task' | 'permission_required';
1997
2033
 
1998
2034
  export interface WorkItemRunTaskResponse {
1999
2035
  type: 'work_item_run_task_response';
@@ -2158,6 +2194,8 @@ export type ServerMessage =
2158
2194
  | GalleryInstallResponse
2159
2195
  | ShareToSlackResponse
2160
2196
  | SlackWebhookConfigResponse
2197
+ | TwilioWebhookConfigResponse
2198
+ | IngressConfigResponse
2161
2199
  | VercelApiConfigResponse
2162
2200
  | TwitterIntegrationConfigResponse
2163
2201
  | TwitterAuthResult
@@ -161,6 +161,7 @@ export async function handleRideShotgunStart(
161
161
  }
162
162
  });
163
163
  } else if (msg.autoNavigate && targetDomain) {
164
+ const navDomain = msg.navigateDomain ?? targetDomain;
164
165
  const abortSignal = { aborted: false };
165
166
  const checkInterval = setInterval(() => {
166
167
  if (session.status !== 'active') {
@@ -168,7 +169,7 @@ export async function handleRideShotgunStart(
168
169
  clearInterval(checkInterval);
169
170
  }
170
171
  }, 1000);
171
- autoNavigate(targetDomain, abortSignal).then(visited => {
172
+ autoNavigate(navDomain, abortSignal).then(visited => {
172
173
  clearInterval(checkInterval);
173
174
  log.info({ watchId, visitedPages: visited.length }, 'Generic auto-navigation finished');
174
175
  if (session.status === 'active') {
@@ -350,9 +350,44 @@ export async function runAgentLoopImpl(
350
350
  currentTurnToolNames.push(event.name);
351
351
  onEvent({ type: 'tool_use_start', toolName: event.name, input: event.input, sessionId: ctx.conversationId });
352
352
  break;
353
- case 'tool_output_chunk':
354
- onEvent({ type: 'tool_output_chunk', chunk: event.chunk });
353
+ case 'tool_output_chunk': {
354
+ // Try to parse structured progress fields from the chunk.
355
+ // Cheap pre-check: only attempt JSON.parse when the chunk looks like an object.
356
+ let structured: { subType?: 'tool_start' | 'tool_complete' | 'status'; subToolName?: string; subToolInput?: string; subToolIsError?: boolean; subToolId?: string } | undefined;
357
+ const trimmed = event.chunk.trimStart();
358
+ if (trimmed.length > 0 && trimmed.length < 4096 && trimmed[0] === '{') {
359
+ try {
360
+ const parsed = JSON.parse(event.chunk);
361
+ const VALID_SUB_TYPES = new Set(['tool_start', 'tool_complete', 'status']);
362
+ if (parsed && typeof parsed === 'object' && typeof parsed.subType === 'string' && VALID_SUB_TYPES.has(parsed.subType)) {
363
+ structured = {
364
+ subType: parsed.subType as 'tool_start' | 'tool_complete' | 'status',
365
+ subToolName: typeof parsed.subToolName === 'string' ? parsed.subToolName : undefined,
366
+ subToolInput: typeof parsed.subToolInput === 'string' ? parsed.subToolInput : undefined,
367
+ subToolIsError: typeof parsed.subToolIsError === 'boolean' ? parsed.subToolIsError : undefined,
368
+ subToolId: typeof parsed.subToolId === 'string' ? parsed.subToolId : undefined,
369
+ };
370
+ }
371
+ } catch {
372
+ // Not valid JSON — pass through as plain chunk
373
+ }
374
+ }
375
+ if (structured) {
376
+ onEvent({
377
+ type: 'tool_output_chunk',
378
+ chunk: event.chunk,
379
+ sessionId: ctx.conversationId,
380
+ subType: structured.subType,
381
+ subToolName: structured.subToolName,
382
+ subToolInput: structured.subToolInput,
383
+ subToolIsError: structured.subToolIsError,
384
+ subToolId: structured.subToolId,
385
+ });
386
+ } else {
387
+ onEvent({ type: 'tool_output_chunk', chunk: event.chunk, sessionId: ctx.conversationId });
388
+ }
355
389
  break;
390
+ }
356
391
  case 'input_json_delta':
357
392
  onEvent({ type: 'tool_input_delta', toolName: event.toolName, content: event.accumulatedJson, sessionId: ctx.conversationId });
358
393
  break;
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Conflict-gate logic extracted from Session.
3
3
  *
4
- * Decides whether to ask the user about a pending memory conflict (relevant gate),
5
- * inject a soft instruction (irrelevant gate), or skip entirely.
4
+ * Decides whether to ask the user about a pending memory conflict (relevant gate)
5
+ * or skip entirely.
6
6
  */
7
7
 
8
8
  import {
@@ -12,6 +12,11 @@ import {
12
12
  } from '../memory/conflict-store.js';
13
13
  import type { PendingConflictDetail } from '../memory/conflict-store.js';
14
14
  import { resolveConflictClarification } from '../memory/clarification-resolver.js';
15
+ import {
16
+ computeConflictRelevance,
17
+ looksLikeClarificationReply,
18
+ shouldAttemptConflictResolution,
19
+ } from '../memory/conflict-intent.js';
15
20
 
16
21
  export interface ConflictGateDecision {
17
22
  question: string;
@@ -39,14 +44,14 @@ export class ConflictGate {
39
44
  const threshold = conflictConfig.relevanceThreshold;
40
45
  const cooldownTurns = Math.max(1, conflictConfig.reaskCooldownTurns);
41
46
  const pendingBeforeResolve = listPendingConflictDetails(scopeId, 50);
47
+ const clarificationReply = looksLikeClarificationReply(userMessage);
42
48
  const candidatesBeforeResolve = pendingBeforeResolve.filter((conflict) => {
43
49
  const relevance = computeConflictRelevance(userMessage, conflict);
44
- if (relevance >= threshold) return true;
45
- // Only try to resolve recently-asked conflicts when the user message
46
- // looks like a short clarification reply (e.g. "keep the new one"),
47
- // not a full unrelated question that happens to contain cue words.
48
- return this.wasRecentlyAsked(conflict.id, cooldownTurns)
49
- && looksLikeClarificationReply(userMessage);
50
+ return shouldAttemptConflictResolution({
51
+ clarificationReply,
52
+ relevance,
53
+ wasRecentlyAsked: this.wasRecentlyAsked(conflict.id, cooldownTurns),
54
+ });
50
55
  });
51
56
  await this.resolvePendingConflicts(
52
57
  userMessage,
@@ -61,18 +66,16 @@ export class ConflictGate {
61
66
  conflict,
62
67
  relevance: computeConflictRelevance(userMessage, conflict),
63
68
  }));
64
- const relevant = scored.filter((entry) => entry.relevance >= threshold);
65
- const irrelevant = scored.filter((entry) => entry.relevance < threshold);
66
- const ordered = [...relevant, ...irrelevant];
67
-
68
- const askable = ordered.find((entry) => this.shouldAsk(entry.conflict.id, cooldownTurns));
69
+ const askable = scored
70
+ .filter((entry) => entry.relevance >= threshold)
71
+ .find((entry) => this.shouldAsk(entry.conflict.id, cooldownTurns));
69
72
  if (!askable) return null;
70
73
 
71
74
  this.lastAskedTurn.set(askable.conflict.id, this.turnCounter);
72
75
  markConflictAsked(askable.conflict.id);
73
76
  return {
74
77
  question: askable.conflict.clarificationQuestion ?? buildFallbackConflictQuestion(askable.conflict),
75
- relevant: askable.relevance >= threshold,
78
+ relevant: true,
76
79
  };
77
80
  }
78
81
 
@@ -122,98 +125,4 @@ export function buildFallbackConflictQuestion(conflict: PendingConflictDetail):
122
125
  'Which one should I keep?',
123
126
  ].join('\n');
124
127
  }
125
-
126
- export function computeConflictRelevance(
127
- userMessage: string,
128
- conflict: Pick<PendingConflictDetail, 'existingStatement' | 'candidateStatement'>,
129
- ): number {
130
- const queryTokens = tokenizeForConflictRelevance(userMessage);
131
- if (queryTokens.size === 0) return 0;
132
- const existingTokens = tokenizeForConflictRelevance(conflict.existingStatement);
133
- const candidateTokens = tokenizeForConflictRelevance(conflict.candidateStatement);
134
- return Math.max(
135
- overlapRatio(queryTokens, existingTokens),
136
- overlapRatio(queryTokens, candidateTokens),
137
- );
138
- }
139
-
140
- function tokenizeForConflictRelevance(input: string): Set<string> {
141
- const tokens = input
142
- .toLowerCase()
143
- .split(/[^a-z0-9]+/g)
144
- .map((token) => token.trim())
145
- .filter((token) => token.length >= 4);
146
- return new Set(tokens);
147
- }
148
-
149
- function overlapRatio(left: Set<string>, right: Set<string>): number {
150
- if (left.size === 0 || right.size === 0) return 0;
151
- let overlap = 0;
152
- for (const token of left) {
153
- if (right.has(token)) overlap += 1;
154
- }
155
- return overlap / Math.max(left.size, right.size);
156
- }
157
-
158
- // Action verbs that signal the user is making a deliberate choice.
159
- const ACTION_CUES = new Set([
160
- 'keep', 'use', 'prefer', 'go', 'pick', 'choose', 'take', 'want', 'select',
161
- ]);
162
-
163
- // Directional/merge cue words mirrored from clarification-resolver.ts heuristics.
164
- const DIRECTIONAL_CUES = new Set([
165
- 'existing', 'old', 'previous', 'first', 'earlier', 'original',
166
- 'candidate', 'new', 'latest', 'second', 'updated', 'instead', 'replace',
167
- 'both', 'merge', 'combine', 'together', 'either', 'mix',
168
- 'option', 'former', 'latter',
169
- ]);
170
-
171
- const MAX_REPLY_WORD_COUNT = 12;
172
-
173
- // Direction-only matches (no action verb) must be very short to avoid
174
- // false positives from unrelated statements that happen to contain
175
- // common words like "new", "old", "option", etc.
176
- const MAX_DIRECTION_ONLY_WORD_COUNT = 4;
177
-
178
- // Messages starting with a question word are unlikely to be clarification
179
- // replies even when they lack a trailing question mark.
180
- const QUESTION_WORD_PREFIXES = new Set([
181
- 'what', 'how', 'why', 'where', 'when', 'which', 'who', 'whom', 'whose',
182
- ]);
183
-
184
- /**
185
- * Determines whether a user message looks like a deliberate reply to a
186
- * recently asked conflict clarification (e.g. "keep the new one", "both",
187
- * "option B", "new one"). Requires at least one action or directional cue,
188
- * and the message must be short. Questions and longer statements are excluded
189
- * to prevent accidental resolution from unrelated messages.
190
- *
191
- * When only directional cues are present (no action verb), the message must
192
- * be very short (<= 4 words) to avoid false positives from unrelated messages
193
- * like "What's new in Bun" or "try the old approach".
194
- */
195
- export function looksLikeClarificationReply(userMessage: string): boolean {
196
- const trimmed = userMessage.trim();
197
- if (trimmed.endsWith('?')) return false;
198
-
199
- const words = trimmed.toLowerCase().split(/\s+/).filter(Boolean);
200
- if (words.length === 0 || words.length > MAX_REPLY_WORD_COUNT) return false;
201
-
202
- const normalized = words.map((w) => w.replace(/[^a-z]/g, ''));
203
-
204
- // Reject messages that start with a question word (even without '?').
205
- // Match exact question words or contractions (e.g. "what's", "where's"),
206
- // but not words that merely share a prefix (e.g. "whichever", "however").
207
- const firstWord = words[0];
208
- const firstNorm = normalized[0];
209
- for (const qw of QUESTION_WORD_PREFIXES) {
210
- if (firstNorm === qw || (firstWord.startsWith(qw) && "'\u2018\u2019".includes(firstWord[qw.length]))) return false;
211
- }
212
-
213
- const hasAction = normalized.some((w) => ACTION_CUES.has(w));
214
- const hasDirection = normalized.some((w) => DIRECTIONAL_CUES.has(w));
215
-
216
- if (hasAction) return true;
217
- if (hasDirection) return words.length <= MAX_DIRECTION_ONLY_WORD_COUNT;
218
- return false;
219
- }
128
+ export { computeConflictRelevance, looksLikeClarificationReply };
@@ -14,6 +14,9 @@ import type { PermissionPrompter } from '../permissions/prompter.js';
14
14
  import type { SecretPrompter } from '../permissions/secret-prompter.js';
15
15
  import { addRule, findHighestPriorityRule } from '../permissions/trust-store.js';
16
16
  import { generateAllowlistOptions, generateScopeOptions, normalizeWebFetchUrl } from '../permissions/checker.js';
17
+ import { getLogger } from '../util/logger.js';
18
+
19
+ const log = getLogger('session-tool-setup');
17
20
  import { getAllToolDefinitions } from '../tools/registry.js';
18
21
  import { allUiSurfaceTools } from '../tools/ui-surface/definitions.js';
19
22
  import { coreAppProxyTools } from '../tools/apps/definitions.js';
@@ -248,10 +251,12 @@ export function createToolExecutor(
248
251
  req.principal,
249
252
  );
250
253
  if ((response.decision === 'always_allow' || response.decision === 'always_allow_high_risk') && response.selectedPattern && response.selectedScope) {
254
+ log.info({ toolName: 'cc:' + req.toolName, pattern: response.selectedPattern, scope: response.selectedScope, highRisk: response.decision === 'always_allow_high_risk' }, 'Persisting always-allow trust rule');
251
255
  addRule('cc:' + req.toolName, response.selectedPattern, response.selectedScope, 'allow', 100,
252
256
  response.decision === 'always_allow_high_risk' ? { allowHighRisk: true } : undefined);
253
257
  }
254
258
  if (response.decision === 'always_deny' && response.selectedPattern && response.selectedScope) {
259
+ log.info({ toolName: 'cc:' + req.toolName, pattern: response.selectedPattern, scope: response.selectedScope }, 'Persisting always-deny trust rule');
255
260
  addRule('cc:' + req.toolName, response.selectedPattern, response.selectedScope, 'deny');
256
261
  }
257
262
  return {
@@ -440,10 +445,12 @@ export function createProxyApprovalCallback(
440
445
 
441
446
  // Persist trust rule if the user chose "always allow" or "always deny"
442
447
  if ((response.decision === 'always_allow' || response.decision === 'always_allow_high_risk') && response.selectedPattern && response.selectedScope) {
448
+ log.info({ toolName, pattern: response.selectedPattern, scope: response.selectedScope, highRisk: response.decision === 'always_allow_high_risk' }, 'Persisting always-allow trust rule (proxy)');
443
449
  addRule(toolName, response.selectedPattern, response.selectedScope, 'allow', 100,
444
450
  response.decision === 'always_allow_high_risk' ? { allowHighRisk: true } : undefined);
445
451
  }
446
452
  if (response.decision === 'always_deny' && response.selectedPattern && response.selectedScope) {
453
+ log.info({ toolName, pattern: response.selectedPattern, scope: response.selectedScope }, 'Persisting always-deny trust rule (proxy)');
447
454
  addRule(toolName, response.selectedPattern, response.selectedScope, 'deny');
448
455
  }
449
456
 
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Centralized URL builders for all public-facing ingress endpoints.
3
+ *
4
+ * Resolves the canonical public base URL via a fallback chain:
5
+ * ingress.publicBaseUrl → calls.webhookBaseUrl → env TWILIO_WEBHOOK_BASE_URL
6
+ *
7
+ * Supersedes the per-domain URL helpers in calls/twilio-webhook-urls.ts.
8
+ */
9
+
10
+ import { getLogger } from '../util/logger.js';
11
+
12
+ const log = getLogger('public-ingress-urls');
13
+
14
+ export interface IngressConfig {
15
+ ingress?: { publicBaseUrl?: string };
16
+ calls?: { webhookBaseUrl?: string };
17
+ }
18
+
19
+ /**
20
+ * Trim whitespace and strip trailing slashes from a URL string.
21
+ */
22
+ function normalizeUrl(url: string): string {
23
+ return url.trim().replace(/\/+$/, '');
24
+ }
25
+
26
+ /**
27
+ * Resolve the canonical public base URL with a three-level fallback chain:
28
+ * 1. ingress.publicBaseUrl (preferred)
29
+ * 2. calls.webhookBaseUrl (backward compat)
30
+ * 3. TWILIO_WEBHOOK_BASE_URL env var (legacy, deprecated)
31
+ *
32
+ * Throws if no source provides a non-empty value.
33
+ */
34
+ export function getPublicBaseUrl(config: IngressConfig): string {
35
+ const ingressValue = config.ingress?.publicBaseUrl;
36
+ if (ingressValue) {
37
+ const normalized = normalizeUrl(ingressValue);
38
+ if (normalized) return normalized;
39
+ }
40
+
41
+ const callsValue = config.calls?.webhookBaseUrl;
42
+ if (callsValue) {
43
+ const normalized = normalizeUrl(callsValue);
44
+ if (normalized) {
45
+ log.warn(
46
+ 'Using calls.webhookBaseUrl as public base URL — set ingress.publicBaseUrl instead.',
47
+ );
48
+ return normalized;
49
+ }
50
+ }
51
+
52
+ const envValue = process.env.TWILIO_WEBHOOK_BASE_URL;
53
+ if (envValue) {
54
+ log.warn(
55
+ 'TWILIO_WEBHOOK_BASE_URL env var is deprecated — set ingress.publicBaseUrl in config instead.',
56
+ );
57
+ const normalized = normalizeUrl(envValue);
58
+ if (normalized) return normalized;
59
+ }
60
+
61
+ throw new Error(
62
+ 'No public base URL configured. Set ingress.publicBaseUrl in config, calls.webhookBaseUrl, or TWILIO_WEBHOOK_BASE_URL env var.',
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Build the Twilio voice webhook URL for a given call session.
68
+ */
69
+ export function getTwilioVoiceWebhookUrl(config: IngressConfig, callSessionId: string): string {
70
+ const base = getPublicBaseUrl(config);
71
+ return `${base}/webhooks/twilio/voice?callSessionId=${callSessionId}`;
72
+ }
73
+
74
+ /**
75
+ * Build the Twilio status callback URL.
76
+ */
77
+ export function getTwilioStatusCallbackUrl(config: IngressConfig): string {
78
+ const base = getPublicBaseUrl(config);
79
+ return `${base}/webhooks/twilio/status`;
80
+ }
81
+
82
+ /**
83
+ * Build the Twilio connect-action callback URL.
84
+ */
85
+ export function getTwilioConnectActionUrl(config: IngressConfig): string {
86
+ const base = getPublicBaseUrl(config);
87
+ return `${base}/webhooks/twilio/connect-action`;
88
+ }
89
+
90
+ /**
91
+ * Build the Twilio ConversationRelay WebSocket URL.
92
+ * Converts http:// → ws:// and https:// → wss://.
93
+ */
94
+ export function getTwilioRelayUrl(config: IngressConfig): string {
95
+ const base = getPublicBaseUrl(config);
96
+ const wsBase = base.replace(/^http(s?)/, 'ws$1');
97
+ return `${wsBase}/webhooks/twilio/relay`;
98
+ }
99
+
100
+ /**
101
+ * Build the OAuth callback URL.
102
+ */
103
+ export function getOAuthCallbackUrl(config: IngressConfig): string {
104
+ const base = getPublicBaseUrl(config);
105
+ return `${base}/webhooks/oauth/callback`;
106
+ }
@@ -197,7 +197,6 @@ export function uploadAttachment(
197
197
 
198
198
  const record = {
199
199
  id: uuid(),
200
- assistantId: 'self',
201
200
  originalFilename: filename,
202
201
  mimeType,
203
202
  sizeBytes,
@@ -82,7 +82,6 @@ export function recordInbound(
82
82
  tx.insert(channelInboundEvents)
83
83
  .values({
84
84
  id: eventId,
85
- assistantId: 'self',
86
85
  sourceChannel,
87
86
  externalChatId,
88
87
  externalMessageId,
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Shared conflict intent helpers used by both interactive conflict gating and
3
+ * background conflict resolution jobs.
4
+ */
5
+
6
+ export interface ConflictStatementPair {
7
+ existingStatement: string;
8
+ candidateStatement: string;
9
+ }
10
+
11
+ export function computeConflictRelevance(
12
+ userMessage: string,
13
+ conflict: ConflictStatementPair,
14
+ ): number {
15
+ const queryTokens = tokenizeForConflictRelevance(userMessage);
16
+ if (queryTokens.size === 0) return 0;
17
+ const existingTokens = tokenizeForConflictRelevance(conflict.existingStatement);
18
+ const candidateTokens = tokenizeForConflictRelevance(conflict.candidateStatement);
19
+ return Math.max(
20
+ overlapRatio(queryTokens, existingTokens),
21
+ overlapRatio(queryTokens, candidateTokens),
22
+ );
23
+ }
24
+
25
+ function tokenizeForConflictRelevance(input: string): Set<string> {
26
+ const tokens = input
27
+ .toLowerCase()
28
+ .split(/[^a-z0-9]+/g)
29
+ .map((token) => token.trim())
30
+ .filter((token) => token.length >= 4);
31
+ return new Set(tokens);
32
+ }
33
+
34
+ function overlapRatio(left: Set<string>, right: Set<string>): number {
35
+ if (left.size === 0 || right.size === 0) return 0;
36
+ let overlap = 0;
37
+ for (const token of left) {
38
+ if (right.has(token)) overlap += 1;
39
+ }
40
+ return overlap / Math.max(left.size, right.size);
41
+ }
42
+
43
+ // Action verbs that signal the user is making a deliberate choice.
44
+ const ACTION_CUES = new Set([
45
+ 'keep', 'use', 'prefer', 'go', 'pick', 'choose', 'take', 'want', 'select',
46
+ ]);
47
+
48
+ // Directional/merge cue words mirrored from clarification-resolver.ts heuristics.
49
+ const DIRECTIONAL_CUES = new Set([
50
+ 'existing', 'old', 'previous', 'first', 'earlier', 'original',
51
+ 'candidate', 'new', 'latest', 'second', 'updated', 'instead', 'replace',
52
+ 'both', 'merge', 'combine', 'together', 'either', 'mix',
53
+ 'option', 'former', 'latter',
54
+ ]);
55
+
56
+ const MAX_REPLY_WORD_COUNT = 12;
57
+
58
+ // Direction-only matches (no action verb) must be very short to avoid
59
+ // false positives from unrelated statements that happen to contain
60
+ // common words like "new", "old", "option", etc.
61
+ const MAX_DIRECTION_ONLY_WORD_COUNT = 4;
62
+
63
+ // Messages starting with a question word are unlikely to be clarification
64
+ // replies even when they lack a trailing question mark.
65
+ const QUESTION_WORD_PREFIXES = new Set([
66
+ 'what', 'how', 'why', 'where', 'when', 'which', 'who', 'whom', 'whose',
67
+ ]);
68
+
69
+ /**
70
+ * Determines whether a user message looks like a deliberate clarification
71
+ * reply (e.g. "keep the new one", "both", "option B").
72
+ */
73
+ export function looksLikeClarificationReply(userMessage: string): boolean {
74
+ const trimmed = userMessage.trim();
75
+ if (trimmed.endsWith('?')) return false;
76
+
77
+ const words = trimmed.toLowerCase().split(/\s+/).filter(Boolean);
78
+ if (words.length === 0 || words.length > MAX_REPLY_WORD_COUNT) return false;
79
+
80
+ const normalized = words.map((w) => w.replace(/[^a-z]/g, ''));
81
+
82
+ // Reject messages that start with a question word (even without '?').
83
+ // Match exact question words or contractions (e.g. "what's", "where's"),
84
+ // but not words that merely share a prefix (e.g. "whichever", "however").
85
+ const firstWord = words[0];
86
+ const firstNorm = normalized[0];
87
+ for (const qw of QUESTION_WORD_PREFIXES) {
88
+ if (firstNorm === qw || (firstWord.startsWith(qw) && "'\u2018\u2019".includes(firstWord[qw.length]))) return false;
89
+ }
90
+
91
+ const hasAction = normalized.some((w) => ACTION_CUES.has(w));
92
+ const hasDirection = normalized.some((w) => DIRECTIONAL_CUES.has(w));
93
+
94
+ if (hasAction) return true;
95
+ if (hasDirection) return words.length <= MAX_DIRECTION_ONLY_WORD_COUNT;
96
+ return false;
97
+ }
98
+
99
+ /**
100
+ * Conflict resolution should require explicit clarification intent and either:
101
+ * - non-zero topical overlap with the conflict statements, or
102
+ * - a very recent explicit ask from the assistant.
103
+ */
104
+ export function shouldAttemptConflictResolution(
105
+ input: {
106
+ clarificationReply: boolean;
107
+ relevance: number;
108
+ wasRecentlyAsked: boolean;
109
+ },
110
+ ): boolean {
111
+ if (!input.clarificationReply) return false;
112
+ if (input.relevance > 0) return true;
113
+ return input.wasRecentlyAsked;
114
+ }
@@ -96,7 +96,6 @@ export function getOrCreateConversation(
96
96
  tx.insert(conversationKeys)
97
97
  .values({
98
98
  id: uuid(),
99
- assistantId: 'self',
100
99
  conversationKey,
101
100
  conversationId,
102
101
  createdAt: now,