vellum 0.2.13 → 0.2.14

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 (207) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +113 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +137 -18
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +62 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +27 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +93 -7
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +8 -0
  94. package/src/calls/elevenlabs-config.ts +7 -5
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +32 -37
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +29 -7
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +10 -4
  116. package/src/config/schema.ts +80 -21
  117. package/src/config/types.ts +1 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/prompter.ts +0 -4
  160. package/src/permissions/shell-identity.ts +227 -0
  161. package/src/permissions/trust-store.ts +76 -53
  162. package/src/permissions/types.ts +0 -19
  163. package/src/permissions/workspace-policy.ts +114 -0
  164. package/src/providers/retry.ts +12 -37
  165. package/src/runtime/assistant-event-hub.ts +41 -4
  166. package/src/runtime/channel-approval-parser.ts +60 -0
  167. package/src/runtime/channel-approval-types.ts +71 -0
  168. package/src/runtime/channel-approvals.ts +145 -0
  169. package/src/runtime/gateway-client.ts +16 -0
  170. package/src/runtime/http-server.ts +29 -9
  171. package/src/runtime/routes/call-routes.ts +52 -2
  172. package/src/runtime/routes/channel-routes.ts +296 -16
  173. package/src/runtime/routes/events-routes.ts +97 -28
  174. package/src/runtime/routes/run-routes.ts +2 -7
  175. package/src/runtime/run-orchestrator.ts +0 -3
  176. package/src/schedule/recurrence-engine.ts +26 -2
  177. package/src/schedule/recurrence-types.ts +1 -1
  178. package/src/schedule/schedule-store.ts +12 -3
  179. package/src/security/secret-scanner.ts +7 -0
  180. package/src/tasks/ephemeral-permissions.ts +0 -2
  181. package/src/tasks/task-scheduler.ts +2 -1
  182. package/src/tools/calls/call-start.ts +8 -0
  183. package/src/tools/execution-target.ts +21 -0
  184. package/src/tools/execution-timeout.ts +49 -0
  185. package/src/tools/executor.ts +6 -135
  186. package/src/tools/network/web-search.ts +9 -32
  187. package/src/tools/policy-context.ts +29 -0
  188. package/src/tools/schedule/update.ts +8 -1
  189. package/src/tools/terminal/parser.ts +16 -18
  190. package/src/tools/types.ts +4 -11
  191. package/src/twitter/oauth-client.ts +102 -0
  192. package/src/twitter/router.ts +101 -0
  193. package/src/util/debounce.ts +88 -0
  194. package/src/util/network-info.ts +47 -0
  195. package/src/util/platform.ts +29 -4
  196. package/src/util/promise-guard.ts +37 -0
  197. package/src/util/retry.ts +98 -0
  198. package/src/util/truncate.ts +1 -1
  199. package/src/workspace/git-service.ts +129 -112
  200. package/src/tools/contacts/contact-merge.ts +0 -55
  201. package/src/tools/contacts/contact-search.ts +0 -58
  202. package/src/tools/contacts/contact-upsert.ts +0 -64
  203. package/src/tools/playbooks/index.ts +0 -4
  204. package/src/tools/playbooks/playbook-create.ts +0 -96
  205. package/src/tools/playbooks/playbook-delete.ts +0 -52
  206. package/src/tools/playbooks/playbook-list.ts +0 -74
  207. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -0,0 +1,114 @@
1
+ import { resolve, normalize, dirname, basename } from 'node:path';
2
+ import { realpathSync } from 'node:fs';
3
+
4
+ /**
5
+ * Resolve a path to its canonical form. When the target itself doesn't
6
+ * exist (e.g. a new file being written), walk up to the nearest existing
7
+ * ancestor and append the remaining segments so that symlinks in parent
8
+ * directories (like macOS `/var` -> `/private/var`) are still resolved.
9
+ */
10
+ function canonicalize(p: string): string {
11
+ const abs = resolve(p);
12
+ try {
13
+ return realpathSync(abs);
14
+ } catch {
15
+ // Walk upward until we find an existing ancestor.
16
+ const name = basename(abs);
17
+ const parent = dirname(abs);
18
+ if (parent === abs) {
19
+ // Reached filesystem root — nothing left to resolve.
20
+ return normalize(abs);
21
+ }
22
+ return `${canonicalize(parent)}/${name}`;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Resolve a file path to its canonical form (resolving symlinks and
28
+ * normalizing segments like `.` and `..`), then check whether it falls
29
+ * within the given workspace root.
30
+ */
31
+ export function isPathWithinWorkspaceRoot(filePath: string, workspaceRoot: string): boolean {
32
+ if (!filePath || !workspaceRoot) return false;
33
+
34
+ const canonicalPath = canonicalize(filePath);
35
+ const canonicalRoot = canonicalize(workspaceRoot);
36
+
37
+ // Ensure the root ends with a separator so `/workspace-extra` doesn't
38
+ // match `/workspace`.
39
+ const rootPrefix = canonicalRoot.endsWith('/') ? canonicalRoot : `${canonicalRoot}/`;
40
+
41
+ return canonicalPath === canonicalRoot || canonicalPath.startsWith(rootPrefix);
42
+ }
43
+
44
+ // ── Tool-name sets for invocation classification ──────────────────────
45
+
46
+ /** File-path tools whose workspace-scoped-ness depends on the file_path input. */
47
+ const PATH_SCOPED_TOOLS = new Set([
48
+ 'file_read',
49
+ 'file_write',
50
+ 'file_edit',
51
+ ]);
52
+
53
+ /** Network-accessing tools — never workspace-scoped. */
54
+ const NETWORK_TOOLS = new Set([
55
+ 'web_search',
56
+ 'web_fetch',
57
+ 'browser_navigate',
58
+ 'browser_click',
59
+ 'browser_type',
60
+ 'browser_scroll',
61
+ 'browser_screenshot',
62
+ 'browser_close',
63
+ 'network_request',
64
+ ]);
65
+
66
+ /** Host-level tools — operate outside the sandbox, never workspace-scoped. */
67
+ const HOST_TOOLS = new Set([
68
+ 'host_file_read',
69
+ 'host_file_write',
70
+ 'host_file_edit',
71
+ 'host_bash',
72
+ ]);
73
+
74
+ /** Safe local-only tools that are always workspace-scoped. */
75
+ const ALWAYS_SCOPED_TOOLS = new Set([
76
+ 'skill_load',
77
+ 'view_image',
78
+ 'memory_search',
79
+ 'ui_update',
80
+ 'ui_dismiss',
81
+ ]);
82
+
83
+ /**
84
+ * Determine whether a tool invocation only affects resources within the
85
+ * workspace root. This is a conservative classification — unknown tools
86
+ * default to NOT workspace-scoped.
87
+ */
88
+ export function isWorkspaceScopedInvocation(
89
+ toolName: string,
90
+ toolInput: Record<string, unknown>,
91
+ workspaceRoot: string,
92
+ ): boolean {
93
+ if (ALWAYS_SCOPED_TOOLS.has(toolName)) return true;
94
+ if (NETWORK_TOOLS.has(toolName)) return false;
95
+ if (HOST_TOOLS.has(toolName)) return false;
96
+
97
+ if (PATH_SCOPED_TOOLS.has(toolName)) {
98
+ const rawPath = typeof toolInput.file_path === 'string'
99
+ ? toolInput.file_path
100
+ : typeof toolInput.path === 'string'
101
+ ? toolInput.path
102
+ : '';
103
+ // Resolve relative paths against workspaceRoot (not process.cwd())
104
+ const filePath = rawPath !== '' && !rawPath.startsWith('/') ? resolve(workspaceRoot, rawPath) : rawPath;
105
+ return filePath !== '' && isPathWithinWorkspaceRoot(filePath, workspaceRoot);
106
+ }
107
+
108
+ // Bash is generally workspace-scoped when sandbox isolation is active —
109
+ // the caller handles network mode checks separately.
110
+ if (toolName === 'bash') return true;
111
+
112
+ // Unknown tool — conservative default.
113
+ return false;
114
+ }
@@ -1,46 +1,21 @@
1
1
  import type { Provider, ProviderResponse, SendMessageOptions, Message, ToolDefinition } from './types.js';
2
2
  import { ProviderError } from '../util/errors.js';
3
3
  import { getLogger, isDebug } from '../util/logger.js';
4
+ import {
5
+ computeRetryDelay,
6
+ isRetryableNetworkError,
7
+ sleep,
8
+ DEFAULT_MAX_RETRIES,
9
+ DEFAULT_BASE_DELAY_MS,
10
+ } from '../util/retry.js';
4
11
 
5
12
  const log = getLogger('retry');
6
13
 
7
- const MAX_RETRIES = 3;
8
- const BASE_DELAY_MS = 1000;
9
-
10
14
  function isRetryableError(error: unknown): boolean {
11
- // Check ProviderError.statusCode for retryable HTTP status codes
12
15
  if (error instanceof ProviderError && error.statusCode !== undefined) {
13
16
  if (error.statusCode === 429 || error.statusCode >= 500) return true;
14
17
  }
15
-
16
- // Check for network errors (direct or wrapped in cause chain)
17
- if (error instanceof Error) {
18
- const code = (error as NodeJS.ErrnoException).code;
19
- if (code === 'ECONNRESET' || code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'EPIPE') {
20
- return true;
21
- }
22
-
23
- if (error.cause instanceof Error) {
24
- const causeCode = (error.cause as NodeJS.ErrnoException).code;
25
- if (causeCode === 'ECONNRESET' || causeCode === 'ECONNREFUSED' || causeCode === 'ETIMEDOUT' || causeCode === 'EPIPE') {
26
- return true;
27
- }
28
- }
29
- }
30
-
31
- return false;
32
- }
33
-
34
- function getRetryDelay(attempt: number): number {
35
- // Equal jitter: guaranteed floor of cap/2 plus random in [0, cap/2].
36
- // Prevents retry storms while ensuring retries never collapse to 0ms.
37
- const cap = BASE_DELAY_MS * Math.pow(2, attempt);
38
- const half = cap / 2;
39
- return half + Math.random() * half;
40
- }
41
-
42
- function sleep(ms: number): Promise<void> {
43
- return new Promise((resolve) => setTimeout(resolve, ms));
18
+ return isRetryableNetworkError(error);
44
19
  }
45
20
 
46
21
  export class RetryProvider implements Provider {
@@ -67,7 +42,7 @@ export class RetryProvider implements Provider {
67
42
  }, 'Provider sendMessage start');
68
43
  }
69
44
 
70
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
45
+ for (let attempt = 0; attempt <= DEFAULT_MAX_RETRIES; attempt++) {
71
46
  try {
72
47
  const start = Date.now();
73
48
  const result = await this.inner.sendMessage(messages, tools, systemPrompt, options);
@@ -85,14 +60,14 @@ export class RetryProvider implements Provider {
85
60
  } catch (error) {
86
61
  lastError = error;
87
62
 
88
- if (attempt < MAX_RETRIES && isRetryableError(error)) {
89
- const delay = getRetryDelay(attempt);
63
+ if (attempt < DEFAULT_MAX_RETRIES && isRetryableError(error)) {
64
+ const delay = computeRetryDelay(attempt, DEFAULT_BASE_DELAY_MS);
90
65
  const errorType = error instanceof ProviderError && error.statusCode === 429
91
66
  ? 'rate_limit'
92
67
  : error instanceof ProviderError && error.statusCode !== undefined && error.statusCode >= 500
93
68
  ? `server_error_${error.statusCode}`
94
69
  : 'network_error';
95
- log.warn({ attempt: attempt + 1, maxRetries: MAX_RETRIES, delay, errorType, provider: this.name }, 'Retrying after transient error');
70
+ log.warn({ attempt: attempt + 1, maxRetries: DEFAULT_MAX_RETRIES, delay, errorType, provider: this.name }, 'Retrying after transient error');
96
71
  await sleep(delay);
97
72
  continue;
98
73
  }
@@ -33,6 +33,8 @@ interface SubscriberEntry {
33
33
  filter: AssistantEventFilter;
34
34
  callback: AssistantEventCallback;
35
35
  active: boolean;
36
+ /** Called by the hub when this entry is evicted to make room for a new subscriber. */
37
+ onEvict?: () => void;
36
38
  }
37
39
 
38
40
  /**
@@ -42,18 +44,48 @@ interface SubscriberEntry {
42
44
  * events that match their `assistantId` (and optionally `sessionId`).
43
45
  *
44
46
  * The hub is intentionally simple: synchronous fanout, no buffering, no
45
- * backpressure. Slow-consumer protection is added in PR 7.
47
+ * backpressure. Slow-consumer protection lives in the SSE route (PR 7).
46
48
  */
47
49
  export class AssistantEventHub {
48
50
  private readonly subscribers = new Set<SubscriberEntry>();
51
+ private readonly maxSubscribers: number;
52
+
53
+ constructor(options?: { maxSubscribers?: number }) {
54
+ this.maxSubscribers = options?.maxSubscribers ?? Infinity;
55
+ }
49
56
 
50
57
  /**
51
58
  * Register a subscriber that will be called for each matching event.
52
59
  *
60
+ * When the subscriber cap (`maxSubscribers`) has been reached, the **oldest**
61
+ * subscriber is evicted to make room: its `onEvict` callback is invoked (so
62
+ * it can close its SSE stream) and its entry is removed from the hub.
63
+ *
64
+ * The only case that throws is when `maxSubscribers` is 0 — there is nothing
65
+ * to evict and no room to add.
66
+ *
67
+ * @param options.onEvict Called if this subscriber is later evicted by a newer one.
53
68
  * @returns A subscription handle. Call `dispose()` to unsubscribe.
54
69
  */
55
- subscribe(filter: AssistantEventFilter, callback: AssistantEventCallback): AssistantEventSubscription {
56
- const entry: SubscriberEntry = { filter, callback, active: true };
70
+ subscribe(
71
+ filter: AssistantEventFilter,
72
+ callback: AssistantEventCallback,
73
+ options?: { onEvict?: () => void },
74
+ ): AssistantEventSubscription {
75
+ if (this.subscribers.size >= this.maxSubscribers) {
76
+ // Evict the oldest subscriber (Sets maintain insertion order).
77
+ const [oldest] = this.subscribers;
78
+ if (!oldest) {
79
+ // maxSubscribers is 0 — nothing to evict, nothing to add.
80
+ throw new RangeError(
81
+ `AssistantEventHub: subscriber cap reached (${this.maxSubscribers})`,
82
+ );
83
+ }
84
+ oldest.active = false;
85
+ this.subscribers.delete(oldest);
86
+ try { oldest.onEvict?.(); } catch { /* ignore eviction callback errors */ }
87
+ }
88
+ const entry: SubscriberEntry = { filter, callback, active: true, onEvict: options?.onEvict };
57
89
  this.subscribers.add(entry);
58
90
 
59
91
  return {
@@ -108,6 +140,11 @@ export class AssistantEventHub {
108
140
  subscriberCount(): number {
109
141
  return this.subscribers.size;
110
142
  }
143
+
144
+ /** Returns true if the hub can accept a subscriber without evicting anyone. */
145
+ hasCapacity(): boolean {
146
+ return this.subscribers.size < this.maxSubscribers;
147
+ }
111
148
  }
112
149
 
113
150
  // ── Process-level singleton ───────────────────────────────────────────────────
@@ -117,4 +154,4 @@ export class AssistantEventHub {
117
154
  *
118
155
  * Import and use this in daemon send paths (PR 3) and the SSE route (PR 5).
119
156
  */
120
- export const assistantEventHub = new AssistantEventHub();
157
+ export const assistantEventHub = new AssistantEventHub({ maxSubscribers: 100 });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Channel-agnostic plain-text approval decision parser.
3
+ *
4
+ * Parses inbound user text to determine whether it matches an approval,
5
+ * rejection, or "approve always" intent. This module is transport-agnostic
6
+ * and can be used by any channel adapter (Telegram, SMS, etc.).
7
+ */
8
+
9
+ import type { ApprovalAction, ApprovalDecisionResult } from './channel-approval-types.js';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Phrase → action mapping
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const APPROVE_ONCE_PHRASES = ['yes', 'approve', 'allow', 'go ahead'];
16
+ const APPROVE_ALWAYS_PHRASES = ['always', 'approve always', 'allow always'];
17
+ const REJECT_PHRASES = ['no', 'reject', 'deny', 'cancel'];
18
+
19
+ /**
20
+ * Build a Map from lowercased phrase to action. "Approve always" phrases
21
+ * are checked first (longest-match-wins) because "approve" is a prefix
22
+ * of "approve always".
23
+ */
24
+ function buildPhraseMap(): Map<string, ApprovalAction> {
25
+ const map = new Map<string, ApprovalAction>();
26
+
27
+ // Insert longer phrases first so iteration order does not matter —
28
+ // we match on exact equality after normalising, not prefix matching.
29
+ for (const phrase of APPROVE_ALWAYS_PHRASES) {
30
+ map.set(phrase, 'approve_always');
31
+ }
32
+ for (const phrase of APPROVE_ONCE_PHRASES) {
33
+ map.set(phrase, 'approve_once');
34
+ }
35
+ for (const phrase of REJECT_PHRASES) {
36
+ map.set(phrase, 'reject');
37
+ }
38
+ return map;
39
+ }
40
+
41
+ const PHRASE_MAP = buildPhraseMap();
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Public API
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Parse a plain-text message into an approval decision.
49
+ *
50
+ * Returns a structured `ApprovalDecisionResult` if the text matches one
51
+ * of the known intent phrases, or `null` if it does not match.
52
+ *
53
+ * Matching is case-insensitive with leading/trailing whitespace trimmed.
54
+ */
55
+ export function parseApprovalDecision(text: string): ApprovalDecisionResult | null {
56
+ const normalised = text.trim().toLowerCase();
57
+ const action = PHRASE_MAP.get(normalised);
58
+ if (!action) return null;
59
+ return { action, source: 'plain_text' };
60
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Channel-agnostic approval flow types.
3
+ *
4
+ * These types model the approval prompt/decision lifecycle for tool-use
5
+ * confirmations surfaced through external channels (Telegram, SMS, etc.).
6
+ * They are intentionally decoupled from any specific channel so that the
7
+ * same approval flow can be reused across transports.
8
+ */
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Approval actions
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /** The set of actions a user can take on an approval prompt. */
15
+ export type ApprovalAction = 'approve_once' | 'approve_always' | 'reject';
16
+
17
+ /** An action presented to the user as a tappable button or text option. */
18
+ export interface ApprovalActionOption {
19
+ id: ApprovalAction;
20
+ label: string;
21
+ }
22
+
23
+ /** Default action options presented to users across all channels. */
24
+ export const DEFAULT_APPROVAL_ACTIONS: readonly ApprovalActionOption[] = [
25
+ { id: 'approve_once', label: 'Approve once' },
26
+ { id: 'approve_always', label: 'Approve always' },
27
+ { id: 'reject', label: 'Reject' },
28
+ ] as const;
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Approval prompt
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /** The approval prompt model sent to users via a channel. */
35
+ export interface ChannelApprovalPrompt {
36
+ /** Human-readable description of what is being approved. */
37
+ promptText: string;
38
+ /** Available actions the user can take. */
39
+ actions: ApprovalActionOption[];
40
+ /** Instruction text for channels that only support plain text (no buttons). */
41
+ plainTextFallback: string;
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Approval UI metadata (gateway callback payload)
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Metadata attached to gateway callback payloads so the channel adapter
50
+ * can render approval UI and route the user's decision back to the
51
+ * correct pending run.
52
+ */
53
+ export interface ApprovalUIMetadata {
54
+ runId: string;
55
+ requestId: string;
56
+ actions: ApprovalActionOption[];
57
+ plainTextFallback: string;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Decision result
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /** How the user communicated their decision. */
65
+ export type ApprovalDecisionSource = 'telegram_button' | 'plain_text';
66
+
67
+ /** The structured result of a user's approval decision. */
68
+ export interface ApprovalDecisionResult {
69
+ action: ApprovalAction;
70
+ source: ApprovalDecisionSource;
71
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Channel-agnostic approval orchestration module.
3
+ *
4
+ * Bridges the gap between external channel adapters (Telegram, SMS, etc.)
5
+ * and the internal run orchestrator / permission system:
6
+ *
7
+ * 1. Detect pending confirmations for a conversation
8
+ * 2. Build human-readable approval prompts with action buttons
9
+ * 3. Consume user decisions and apply them to the underlying run
10
+ * 4. Build reminder prompts when non-decision messages arrive
11
+ */
12
+
13
+ import { getPendingConfirmationsByConversation, getRun } from '../memory/runs-store.js';
14
+ import type { PendingRunInfo } from '../memory/runs-store.js';
15
+ import { addRule } from '../permissions/trust-store.js';
16
+ import type { RunOrchestrator } from './run-orchestrator.js';
17
+ import { DEFAULT_APPROVAL_ACTIONS } from './channel-approval-types.js';
18
+ import type {
19
+ ChannelApprovalPrompt,
20
+ ApprovalUIMetadata,
21
+ ApprovalDecisionResult,
22
+ } from './channel-approval-types.js';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // 1. Detect pending confirmations and build prompt
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /**
29
+ * Check whether a conversation has a pending tool-use confirmation and,
30
+ * if so, build a human-readable approval prompt.
31
+ *
32
+ * Returns `null` when there is nothing waiting for approval.
33
+ */
34
+ export function getChannelApprovalPrompt(
35
+ conversationId: string,
36
+ ): ChannelApprovalPrompt | null {
37
+ const pending = getPendingConfirmationsByConversation(conversationId);
38
+ if (pending.length === 0) return null;
39
+
40
+ // Use the first pending run — channel UIs show one prompt at a time.
41
+ const info = pending[0];
42
+ return buildPromptFromRunInfo(info);
43
+ }
44
+
45
+ /**
46
+ * Internal helper: turn a PendingRunInfo into a ChannelApprovalPrompt.
47
+ */
48
+ function buildPromptFromRunInfo(info: PendingRunInfo): ChannelApprovalPrompt {
49
+ const promptText = `The assistant wants to use the tool "${info.toolName}". Do you want to allow this?`;
50
+ const actions = [...DEFAULT_APPROVAL_ACTIONS];
51
+ const plainTextFallback =
52
+ `${promptText}\n\nReply "yes" to approve once, "always" to approve always, or "no" to reject.`;
53
+
54
+ return { promptText, actions, plainTextFallback };
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // 2. Build gateway-facing UI metadata
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * Convert a prompt + run info into the `ApprovalUIMetadata` payload that
63
+ * gateway adapters use to render buttons and route decisions back.
64
+ */
65
+ export function buildApprovalUIMetadata(
66
+ prompt: ChannelApprovalPrompt,
67
+ runInfo: PendingRunInfo,
68
+ ): ApprovalUIMetadata {
69
+ return {
70
+ runId: runInfo.runId,
71
+ requestId: runInfo.requestId,
72
+ actions: prompt.actions,
73
+ plainTextFallback: prompt.plainTextFallback,
74
+ };
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // 3. Consume a user decision and apply it to the run
79
+ // ---------------------------------------------------------------------------
80
+
81
+ export interface HandleDecisionResult {
82
+ applied: boolean;
83
+ runId?: string;
84
+ }
85
+
86
+ /**
87
+ * Find the pending run for a conversation, map the user's decision to the
88
+ * permission system's vocabulary, and apply it.
89
+ *
90
+ * For `approve_always`, a trust rule is persisted using the first allowlist
91
+ * option and first scope option from the pending confirmation (narrow
92
+ * default). The current invocation is also approved.
93
+ */
94
+ export function handleChannelDecision(
95
+ conversationId: string,
96
+ decision: ApprovalDecisionResult,
97
+ orchestrator: RunOrchestrator,
98
+ ): HandleDecisionResult {
99
+ const pending = getPendingConfirmationsByConversation(conversationId);
100
+ if (pending.length === 0) return { applied: false };
101
+
102
+ const info = pending[0];
103
+
104
+ if (decision.action === 'approve_always') {
105
+ // Persist a trust rule so future invocations of this tool are auto-approved.
106
+ const run = getRun(info.runId);
107
+ const confirmation = run?.pendingConfirmation;
108
+ if (confirmation) {
109
+ const pattern = confirmation.allowlistOptions?.[0]?.pattern ?? '**';
110
+ const scope = confirmation.scopeOptions?.[0]?.scope ?? 'everywhere';
111
+ addRule(confirmation.toolName, pattern, scope, 'allow', 100, {
112
+ executionTarget: confirmation.executionTarget,
113
+ });
114
+ }
115
+ }
116
+
117
+ // Map channel-level action to the permission system's UserDecision type.
118
+ const userDecision = decision.action === 'reject' ? 'deny' as const : 'allow' as const;
119
+ const result = orchestrator.submitDecision(info.runId, userDecision);
120
+
121
+ return {
122
+ applied: result === 'applied',
123
+ runId: info.runId,
124
+ };
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // 4. Reminder prompt for non-decision messages
129
+ // ---------------------------------------------------------------------------
130
+
131
+ /**
132
+ * Build a reminder prompt when the user sends a non-decision message while
133
+ * an approval is pending. Reuses the original actions and fallback text
134
+ * but prefixes the prompt text with a reminder.
135
+ */
136
+ export function buildReminderPrompt(
137
+ pendingPrompt: ChannelApprovalPrompt,
138
+ ): ChannelApprovalPrompt {
139
+ const reminderPrefix = "I'm still waiting for your decision on the previous request.";
140
+ return {
141
+ promptText: `${reminderPrefix}\n\n${pendingPrompt.promptText}`,
142
+ actions: pendingPrompt.actions,
143
+ plainTextFallback: `${reminderPrefix}\n\n${pendingPrompt.plainTextFallback}`,
144
+ };
145
+ }
@@ -1,5 +1,6 @@
1
1
  import { getLogger } from '../util/logger.js';
2
2
  import type { RuntimeAttachmentMetadata } from './http-types.js';
3
+ import type { ApprovalUIMetadata } from './channel-approval-types.js';
3
4
 
4
5
  const log = getLogger('gateway-client');
5
6
 
@@ -10,6 +11,7 @@ export interface ChannelReplyPayload {
10
11
  text?: string;
11
12
  assistantId?: string;
12
13
  attachments?: RuntimeAttachmentMetadata[];
14
+ approval?: ApprovalUIMetadata;
13
15
  }
14
16
 
15
17
  export async function deliverChannelReply(
@@ -40,3 +42,17 @@ export async function deliverChannelReply(
40
42
 
41
43
  log.info({ chatId: payload.chatId, callbackUrl }, 'Channel reply delivered');
42
44
  }
45
+
46
+ /**
47
+ * Deliver an approval prompt (text + inline keyboard metadata) to the
48
+ * gateway so it can render the approval UI in the channel.
49
+ */
50
+ export async function deliverApprovalPrompt(
51
+ callbackUrl: string,
52
+ chatId: string,
53
+ text: string,
54
+ approval: ApprovalUIMetadata,
55
+ bearerToken?: string,
56
+ ): Promise<void> {
57
+ await deliverChannelReply(callbackUrl, { chatId, text, approval }, bearerToken);
58
+ }
@@ -44,6 +44,7 @@ import {
44
44
  } from './routes/channel-routes.js';
45
45
  import * as channelDeliveryStore from '../memory/channel-delivery-store.js';
46
46
  import * as conversationStore from '../memory/conversation-store.js';
47
+ import * as externalConversationStore from '../memory/external-conversation-store.js';
47
48
  import * as attachmentsStore from '../memory/attachments-store.js';
48
49
  import { renderHistoryContent } from '../daemon/handlers.js';
49
50
  import { deliverChannelReply } from './gateway-client.js';
@@ -60,6 +61,7 @@ import {
60
61
  handleGetCallStatus,
61
62
  handleCancelCall,
62
63
  handleAnswerCall,
64
+ handleInstructionCall,
63
65
  } from './routes/call-routes.js';
64
66
  import {
65
67
  handleVoiceWebhook,
@@ -616,13 +618,28 @@ export class RuntimeHttpServer {
616
618
  if (endpoint === 'conversations' && req.method === 'GET') {
617
619
  const limit = Number(url.searchParams.get('limit') ?? 50);
618
620
  const conversations = conversationStore.listConversations(limit);
621
+ const bindings = externalConversationStore.getBindingsForConversations(
622
+ conversations.map((c) => c.id),
623
+ );
619
624
  return Response.json({
620
- sessions: conversations.map((c) => ({
621
- id: c.id,
622
- title: c.title ?? 'Untitled',
623
- updatedAt: c.updatedAt,
624
- threadType: c.threadType === 'private' ? 'private' : 'standard',
625
- })),
625
+ sessions: conversations.map((c) => {
626
+ const binding = bindings.get(c.id);
627
+ return {
628
+ id: c.id,
629
+ title: c.title ?? 'Untitled',
630
+ updatedAt: c.updatedAt,
631
+ threadType: c.threadType === 'private' ? 'private' : 'standard',
632
+ ...(binding ? {
633
+ channelBinding: {
634
+ sourceChannel: binding.sourceChannel,
635
+ externalChatId: binding.externalChatId,
636
+ externalUserId: binding.externalUserId,
637
+ displayName: binding.displayName,
638
+ username: binding.username,
639
+ },
640
+ } : {}),
641
+ };
642
+ }),
626
643
  });
627
644
  }
628
645
 
@@ -700,7 +717,7 @@ export class RuntimeHttpServer {
700
717
  }
701
718
 
702
719
  if (endpoint === 'channels/inbound' && req.method === 'POST') {
703
- return await handleChannelInbound(req, this.processMessage, this.bearerToken);
720
+ return await handleChannelInbound(req, this.processMessage, this.bearerToken, this.runOrchestrator);
704
721
  }
705
722
 
706
723
  if (endpoint === 'channels/delivery-ack' && req.method === 'POST') {
@@ -720,8 +737,8 @@ export class RuntimeHttpServer {
720
737
  return await handleStartCall(req);
721
738
  }
722
739
 
723
- // Match calls/:callSessionId and calls/:callSessionId/cancel, calls/:callSessionId/answer
724
- const callsMatch = endpoint.match(/^calls\/([^/]+?)(\/cancel|\/answer)?$/);
740
+ // Match calls/:callSessionId and calls/:callSessionId/cancel, calls/:callSessionId/answer, calls/:callSessionId/instruction
741
+ const callsMatch = endpoint.match(/^calls\/([^/]+?)(\/cancel|\/answer|\/instruction)?$/);
725
742
  if (callsMatch) {
726
743
  const callSessionId = callsMatch[1];
727
744
  // Skip known sub-paths that are handled elsewhere (twilio, relay)
@@ -732,6 +749,9 @@ export class RuntimeHttpServer {
732
749
  if (callsMatch[2] === '/answer' && req.method === 'POST') {
733
750
  return await handleAnswerCall(req, callSessionId);
734
751
  }
752
+ if (callsMatch[2] === '/instruction' && req.method === 'POST') {
753
+ return await handleInstructionCall(req, callSessionId);
754
+ }
735
755
  if (!callsMatch[2] && req.method === 'GET') {
736
756
  return handleGetCallStatus(callSessionId);
737
757
  }