@vellumai/assistant 0.3.28 → 0.4.1

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 (201) hide show
  1. package/ARCHITECTURE.md +33 -3
  2. package/bun.lock +4 -1
  3. package/docs/trusted-contact-access.md +9 -2
  4. package/package.json +6 -3
  5. package/scripts/ipc/generate-swift.ts +3 -3
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  7. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  8. package/src/__tests__/approval-routes-http.test.ts +13 -5
  9. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  10. package/src/__tests__/asset-search-tool.test.ts +2 -0
  11. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  12. package/src/__tests__/attachments-store.test.ts +2 -0
  13. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  14. package/src/__tests__/call-controller.test.ts +30 -29
  15. package/src/__tests__/call-routes-http.test.ts +34 -32
  16. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  17. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  18. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  19. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  20. package/src/__tests__/clarification-resolver.test.ts +2 -0
  21. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  22. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  23. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  24. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  25. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  26. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  27. package/src/__tests__/config-schema.test.ts +5 -5
  28. package/src/__tests__/config-watcher.test.ts +3 -1
  29. package/src/__tests__/connection-policy.test.ts +14 -5
  30. package/src/__tests__/contacts-tools.test.ts +3 -1
  31. package/src/__tests__/contradiction-checker.test.ts +2 -0
  32. package/src/__tests__/conversation-pairing.test.ts +10 -0
  33. package/src/__tests__/conversation-routes.test.ts +1 -1
  34. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  35. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  36. package/src/__tests__/credential-vault.test.ts +5 -4
  37. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  38. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  39. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  40. package/src/__tests__/encrypted-store.test.ts +10 -5
  41. package/src/__tests__/followup-tools.test.ts +3 -1
  42. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  43. package/src/__tests__/gmail-integration.test.ts +0 -1
  44. package/src/__tests__/guardian-control-plane-policy.test.ts +25 -21
  45. package/src/__tests__/guardian-dispatch.test.ts +2 -0
  46. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  47. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  48. package/src/__tests__/guardian-routing-invariants.test.ts +138 -0
  49. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  50. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  51. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  52. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  53. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  54. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  55. package/src/__tests__/heartbeat-service.test.ts +20 -0
  56. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  57. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  58. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  59. package/src/__tests__/intent-routing.test.ts +2 -0
  60. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  61. package/src/__tests__/media-generate-image.test.ts +21 -0
  62. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  63. package/src/__tests__/memory-regressions.test.ts +20 -20
  64. package/src/__tests__/non-member-access-request.test.ts +183 -9
  65. package/src/__tests__/notification-decision-fallback.test.ts +2 -0
  66. package/src/__tests__/notification-decision-strategy.test.ts +61 -0
  67. package/src/__tests__/notification-guardian-path.test.ts +2 -0
  68. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  69. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  70. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  71. package/src/__tests__/pairing-routes.test.ts +171 -0
  72. package/src/__tests__/playbook-execution.test.ts +3 -1
  73. package/src/__tests__/playbook-tools.test.ts +3 -1
  74. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  75. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  76. package/src/__tests__/recording-handler.test.ts +11 -0
  77. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  78. package/src/__tests__/recording-state-machine.test.ts +13 -2
  79. package/src/__tests__/registry.test.ts +7 -3
  80. package/src/__tests__/relay-server.test.ts +148 -28
  81. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  82. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  83. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  84. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  85. package/src/__tests__/schedule-tools.test.ts +3 -1
  86. package/src/__tests__/send-endpoint-busy.test.ts +288 -0
  87. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  88. package/src/__tests__/session-agent-loop.test.ts +16 -0
  89. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  90. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  91. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  92. package/src/__tests__/session-profile-injection.test.ts +21 -0
  93. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  94. package/src/__tests__/session-queue.test.ts +23 -0
  95. package/src/__tests__/session-runtime-assembly.test.ts +50 -12
  96. package/src/__tests__/session-skill-tools.test.ts +27 -5
  97. package/src/__tests__/session-slash-known.test.ts +23 -0
  98. package/src/__tests__/session-slash-queue.test.ts +23 -0
  99. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  100. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  101. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  102. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  103. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  104. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  105. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  106. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  107. package/src/__tests__/skills.test.ts +8 -4
  108. package/src/__tests__/slack-channel-config.test.ts +3 -1
  109. package/src/__tests__/subagent-tools.test.ts +19 -0
  110. package/src/__tests__/swarm-recursion.test.ts +2 -0
  111. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  112. package/src/__tests__/swarm-tool.test.ts +2 -0
  113. package/src/__tests__/system-prompt.test.ts +3 -1
  114. package/src/__tests__/task-compiler.test.ts +3 -1
  115. package/src/__tests__/task-management-tools.test.ts +3 -1
  116. package/src/__tests__/task-tools.test.ts +3 -1
  117. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  118. package/src/__tests__/terminal-tools.test.ts +2 -0
  119. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  120. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  121. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  122. package/src/__tests__/tool-grant-request-escalation.test.ts +7 -7
  123. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  124. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  125. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  126. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  127. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  128. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  129. package/src/__tests__/view-image-tool.test.ts +3 -1
  130. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  131. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  132. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  133. package/src/__tests__/work-item-output.test.ts +3 -1
  134. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  135. package/src/calls/call-controller.ts +26 -23
  136. package/src/calls/guardian-action-sweep.ts +10 -2
  137. package/src/calls/relay-server.ts +216 -27
  138. package/src/calls/types.ts +1 -1
  139. package/src/calls/voice-session-bridge.ts +3 -3
  140. package/src/cli.ts +12 -0
  141. package/src/config/agent-schema.ts +14 -3
  142. package/src/config/calls-schema.ts +6 -6
  143. package/src/config/core-schema.ts +3 -3
  144. package/src/config/feature-flag-registry.json +8 -0
  145. package/src/config/mcp-schema.ts +1 -1
  146. package/src/config/memory-schema.ts +27 -19
  147. package/src/config/schema.ts +21 -21
  148. package/src/config/skills-schema.ts +7 -7
  149. package/src/config/vellum-skills/trusted-contacts/SKILL.md +139 -16
  150. package/src/daemon/handlers/config-inbox.ts +4 -4
  151. package/src/daemon/handlers/sessions.ts +148 -4
  152. package/src/daemon/ipc-contract/messages.ts +16 -0
  153. package/src/daemon/ipc-contract-inventory.json +1 -0
  154. package/src/daemon/lifecycle.ts +19 -0
  155. package/src/daemon/pairing-store.ts +86 -3
  156. package/src/daemon/response-tier.ts +6 -5
  157. package/src/daemon/session-agent-loop.ts +5 -5
  158. package/src/daemon/session-lifecycle.ts +25 -17
  159. package/src/daemon/session-memory.ts +2 -2
  160. package/src/daemon/session-process.ts +1 -20
  161. package/src/daemon/session-runtime-assembly.ts +28 -22
  162. package/src/daemon/session-tool-setup.ts +2 -2
  163. package/src/daemon/session.ts +3 -3
  164. package/src/memory/canonical-guardian-store.ts +63 -1
  165. package/src/memory/channel-guardian-store.ts +1 -0
  166. package/src/memory/conversation-crud.ts +7 -7
  167. package/src/memory/db-init.ts +4 -0
  168. package/src/memory/embedding-local.ts +257 -39
  169. package/src/memory/embedding-runtime-manager.ts +471 -0
  170. package/src/memory/guardian-bindings.ts +25 -1
  171. package/src/memory/indexer.ts +3 -3
  172. package/src/memory/ingress-invite-store.ts +45 -0
  173. package/src/memory/job-handlers/backfill.ts +16 -9
  174. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  175. package/src/memory/migrations/index.ts +1 -0
  176. package/src/memory/qdrant-client.ts +31 -22
  177. package/src/memory/schema.ts +4 -0
  178. package/src/notifications/copy-composer.ts +15 -0
  179. package/src/runtime/access-request-helper.ts +43 -7
  180. package/src/runtime/actor-trust-resolver.ts +46 -50
  181. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  182. package/src/runtime/channel-retry-sweep.ts +18 -6
  183. package/src/runtime/guardian-context-resolver.ts +38 -96
  184. package/src/runtime/guardian-reply-router.ts +31 -1
  185. package/src/runtime/ingress-service.ts +80 -3
  186. package/src/runtime/invite-redemption-service.ts +141 -2
  187. package/src/runtime/routes/channel-route-shared.ts +1 -1
  188. package/src/runtime/routes/channel-routes.ts +1 -1
  189. package/src/runtime/routes/conversation-routes.ts +166 -2
  190. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  191. package/src/runtime/routes/inbound-message-handler.ts +41 -10
  192. package/src/runtime/routes/ingress-routes.ts +52 -4
  193. package/src/runtime/routes/pairing-routes.ts +3 -0
  194. package/src/tools/guardian-control-plane-policy.ts +2 -2
  195. package/src/tools/reminder/reminder-store.ts +10 -14
  196. package/src/tools/tool-approval-handler.ts +11 -11
  197. package/src/tools/types.ts +2 -2
  198. package/src/util/logger.ts +20 -8
  199. package/src/util/platform.ts +10 -0
  200. package/src/util/voice-code.ts +29 -0
  201. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -0,0 +1,471 @@
1
+ /**
2
+ * Downloads and manages the local embedding runtime post-hatch.
3
+ *
4
+ * Instead of shipping heavy native + JS dependencies inside the .app bundle,
5
+ * we download them from npm and run embeddings in a **separate bun process**.
6
+ * The compiled daemon binary cannot resolve bare specifiers in dynamically
7
+ * imported files, so we spawn a standalone bun process that runs an embed
8
+ * worker script communicating via JSON-lines over stdin/stdout.
9
+ *
10
+ * The runtime is stored in ~/.vellum/workspace/embedding-models/ and used
11
+ * by embedding-local.ts on demand.
12
+ *
13
+ * Follows the same download/install pattern as qdrant-manager.ts.
14
+ */
15
+
16
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
17
+ import { arch, platform } from 'node:os';
18
+ import { join } from 'node:path';
19
+
20
+ import { getLogger } from '../util/logger.js';
21
+ import { getEmbeddingModelsDir } from '../util/platform.js';
22
+ import { PromiseGuard } from '../util/promise-guard.js';
23
+
24
+ const log = getLogger('embedding-runtime-manager');
25
+
26
+ // Pinned versions matching assistant/bun.lock
27
+ const ONNXRUNTIME_NODE_VERSION = '1.21.0';
28
+ const ONNXRUNTIME_COMMON_VERSION = '1.21.0';
29
+ const TRANSFORMERS_VERSION = '3.8.1';
30
+
31
+ /** Bun version to download when system bun is not available. */
32
+ const BUN_VERSION = '1.2.0';
33
+
34
+ /** Composite version string for cache invalidation. */
35
+ const RUNTIME_VERSION = `ort-${ONNXRUNTIME_NODE_VERSION}_hf-${TRANSFORMERS_VERSION}`;
36
+
37
+ const WORKER_FILENAME = 'embed-worker.mjs';
38
+
39
+ /** Module-level guard so concurrent in-process calls share one download. */
40
+ const installGuard = new PromiseGuard<void>();
41
+
42
+ interface VersionManifest {
43
+ runtimeVersion: string;
44
+ onnxruntimeNodeVersion: string;
45
+ onnxruntimeCommonVersion: string;
46
+ transformersVersion: string;
47
+ platform: string;
48
+ arch: string;
49
+ installedAt: string;
50
+ }
51
+
52
+ // ── npm tarball helpers ─────────────────────────────────────────────
53
+
54
+ function npmTarballUrl(pkg: string, version: string): string {
55
+ // Scoped packages encode the scope in the URL
56
+ const encoded = pkg.replace('/', '%2f');
57
+ const basename = pkg.startsWith('@') ? pkg.split('/')[1] : pkg;
58
+ return `https://registry.npmjs.org/${encoded}/-/${basename}-${version}.tgz`;
59
+ }
60
+
61
+ async function downloadAndExtract(
62
+ url: string,
63
+ targetDir: string,
64
+ signal?: AbortSignal,
65
+ ): Promise<void> {
66
+ log.info({ url, targetDir }, 'Downloading npm package');
67
+
68
+ const response = await fetch(url, { signal });
69
+ if (!response.ok) {
70
+ throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
71
+ }
72
+
73
+ const tarball = await response.arrayBuffer();
74
+
75
+ // npm tarballs extract to package/, we need to redirect to targetDir
76
+ mkdirSync(targetDir, { recursive: true });
77
+
78
+ const tmpTar = join(targetDir, `download-${Date.now()}.tgz`);
79
+ writeFileSync(tmpTar, Buffer.from(tarball));
80
+
81
+ try {
82
+ // Extract tarball, stripping the leading "package/" directory
83
+ const proc = Bun.spawn({
84
+ cmd: ['tar', 'xzf', tmpTar, '-C', targetDir, '--strip-components=1'],
85
+ stdout: 'ignore',
86
+ stderr: 'pipe',
87
+ });
88
+ await proc.exited;
89
+ if (proc.exitCode !== 0) {
90
+ const stderr = await new Response(proc.stderr).text();
91
+ throw new Error(`Failed to extract ${url}: ${stderr}`);
92
+ }
93
+ } finally {
94
+ try { rmSync(tmpTar); } catch { /* ignore */ }
95
+ }
96
+ }
97
+
98
+ // ── Worker script content ───────────────────────────────────────────
99
+
100
+ function generateWorkerScript(): string {
101
+ // This script is run by a standalone bun process (not the compiled daemon).
102
+ // Because it runs in a real bun runtime, bare specifier resolution works
103
+ // normally — node_modules/ in the same directory is found automatically.
104
+ return `\
105
+ // embed-worker.mjs — Auto-generated by EmbeddingRuntimeManager
106
+ // Runs in a separate bun process, communicates via JSON-lines over stdin/stdout.
107
+ import { pipeline, env } from '@huggingface/transformers';
108
+
109
+ const model = process.argv[2];
110
+ const cacheDir = process.argv[3];
111
+ if (cacheDir && env) env.cacheDir = cacheDir;
112
+
113
+ let extractor;
114
+ try {
115
+ extractor = await pipeline('feature-extraction', model, { dtype: 'fp32' });
116
+ process.stdout.write(JSON.stringify({ type: 'ready' }) + '\\n');
117
+ } catch (err) {
118
+ process.stdout.write(JSON.stringify({ type: 'error', error: err.message || String(err) }) + '\\n');
119
+ process.exit(1);
120
+ }
121
+
122
+ // Sequential request queue to avoid concurrent ONNX inference
123
+ const decoder = new TextDecoder();
124
+ let buffer = '';
125
+ let processing = false;
126
+ const queue = [];
127
+
128
+ process.stdin.on('data', (chunk) => {
129
+ buffer += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
130
+ let idx;
131
+ while ((idx = buffer.indexOf('\\n')) !== -1) {
132
+ const line = buffer.slice(0, idx);
133
+ buffer = buffer.slice(idx + 1);
134
+ if (line.trim()) queue.push(line);
135
+ }
136
+ if (!processing) processQueue();
137
+ });
138
+
139
+ async function processQueue() {
140
+ processing = true;
141
+ while (queue.length > 0) {
142
+ const line = queue.shift();
143
+ let req;
144
+ try { req = JSON.parse(line); } catch { continue; }
145
+ try {
146
+ const output = await extractor(req.texts, { pooling: 'cls', normalize: true });
147
+ const vectors = output.tolist();
148
+ process.stdout.write(JSON.stringify({ id: req.id, vectors }) + '\\n');
149
+ } catch (err) {
150
+ process.stdout.write(JSON.stringify({ id: req.id, error: err.message || String(err) }) + '\\n');
151
+ }
152
+ }
153
+ processing = false;
154
+ }
155
+
156
+ process.stdin.on('end', () => process.exit(0));
157
+ `;
158
+ }
159
+
160
+ // ── Main manager ────────────────────────────────────────────────────
161
+
162
+ export class EmbeddingRuntimeManager {
163
+ private readonly baseDir: string;
164
+
165
+ constructor(baseDir?: string) {
166
+ this.baseDir = baseDir ?? getEmbeddingModelsDir();
167
+ }
168
+
169
+ /** Check if the embedding runtime is installed and up-to-date. */
170
+ isReady(): boolean {
171
+ const manifest = this.readManifest();
172
+ if (!manifest) return false;
173
+ if (manifest.runtimeVersion !== RUNTIME_VERSION) return false;
174
+
175
+ // Verify the worker script exists and a bun binary is available
176
+ return existsSync(this.getWorkerPath()) && this.getBunPath() !== undefined;
177
+ }
178
+
179
+ /** Path to the embed worker script. */
180
+ getWorkerPath(): string {
181
+ return join(this.baseDir, WORKER_FILENAME);
182
+ }
183
+
184
+ /**
185
+ * Find a usable bun binary.
186
+ * Checks: downloaded copy → common install locations.
187
+ */
188
+ getBunPath(): string | undefined {
189
+ // 1. Downloaded bun
190
+ const downloadedBun = join(this.baseDir, 'bin', 'bun');
191
+ if (existsSync(downloadedBun)) return downloadedBun;
192
+
193
+ // 2. Common installation paths — the compiled daemon inherits a
194
+ // restricted PATH, so we check well-known prefixes directly.
195
+ const home = process.env.HOME ?? '';
196
+ for (const p of [
197
+ join(home, '.bun', 'bin', 'bun'),
198
+ '/opt/homebrew/bin/bun',
199
+ '/usr/local/bin/bun',
200
+ ]) {
201
+ if (existsSync(p)) return p;
202
+ }
203
+
204
+ return undefined;
205
+ }
206
+
207
+ /**
208
+ * Download and install the embedding runtime if not already present.
209
+ * Safe to call concurrently — in-process calls share one promise via
210
+ * PromiseGuard, and cross-process calls are serialized via a lock file.
211
+ */
212
+ async ensureInstalled(signal?: AbortSignal): Promise<void> {
213
+ if (this.isReady()) return;
214
+
215
+ // Deduplicate concurrent in-process calls
216
+ await installGuard.run(() => this.acquireLockAndInstall(signal));
217
+
218
+ // If another process was downloading and we skipped, or if the download
219
+ // somehow failed silently, reset the guard so we can retry next time.
220
+ if (!this.isReady()) {
221
+ installGuard.reset();
222
+ }
223
+ }
224
+
225
+ private async acquireLockAndInstall(signal?: AbortSignal): Promise<void> {
226
+ // Re-check after acquiring the in-process guard
227
+ if (this.isReady()) return;
228
+
229
+ // Cross-process lock to prevent duplicate downloads
230
+ const lockPath = join(this.baseDir, '.downloading');
231
+ if (existsSync(lockPath)) {
232
+ try {
233
+ const lockContent = readFileSync(lockPath, 'utf-8').trim();
234
+ const lockPid = parseInt(lockContent, 10);
235
+ if (!isNaN(lockPid) && lockPid !== process.pid) {
236
+ try {
237
+ process.kill(lockPid, 0);
238
+ log.info({ lockPid }, 'Another process is downloading the embedding runtime, skipping');
239
+ return;
240
+ } catch {
241
+ log.info({ lockPid }, 'Cleaning up stale download lock');
242
+ }
243
+ }
244
+ } catch {
245
+ // Can't read lock file, proceed
246
+ }
247
+ }
248
+
249
+ mkdirSync(this.baseDir, { recursive: true });
250
+
251
+ // Write a .gitignore so the workspace git repo ignores this directory
252
+ const gitignorePath = join(this.baseDir, '.gitignore');
253
+ if (!existsSync(gitignorePath)) {
254
+ writeFileSync(gitignorePath, '*\n!.gitignore\n');
255
+ }
256
+
257
+ writeFileSync(lockPath, String(process.pid));
258
+
259
+ try {
260
+ await this.install(signal);
261
+ } finally {
262
+ try { rmSync(lockPath); } catch { /* ignore */ }
263
+ }
264
+ }
265
+
266
+ private async install(signal?: AbortSignal): Promise<void> {
267
+ const os = platform();
268
+ const cpu = arch();
269
+ log.info({ os, cpu, runtimeVersion: RUNTIME_VERSION }, 'Installing embedding runtime');
270
+
271
+ // Work in a temp directory for atomic install
272
+ const tmpDir = join(this.baseDir, `.installing-${Date.now()}`);
273
+ mkdirSync(tmpDir, { recursive: true });
274
+
275
+ try {
276
+ // Step 1: Download npm packages (and bun if needed) in parallel
277
+ const nodeModules = join(tmpDir, 'node_modules');
278
+ const downloads: Promise<void>[] = [
279
+ downloadAndExtract(
280
+ npmTarballUrl('onnxruntime-node', ONNXRUNTIME_NODE_VERSION),
281
+ join(nodeModules, 'onnxruntime-node'),
282
+ signal,
283
+ ),
284
+ downloadAndExtract(
285
+ npmTarballUrl('onnxruntime-common', ONNXRUNTIME_COMMON_VERSION),
286
+ join(nodeModules, 'onnxruntime-common'),
287
+ signal,
288
+ ),
289
+ downloadAndExtract(
290
+ npmTarballUrl('@huggingface/transformers', TRANSFORMERS_VERSION),
291
+ join(nodeModules, '@huggingface', 'transformers'),
292
+ signal,
293
+ ),
294
+ ];
295
+
296
+ // Download bun binary if not already available on the system
297
+ if (!this.getBunPath()) {
298
+ downloads.push(this.downloadBunBinary(tmpDir, signal));
299
+ }
300
+
301
+ await Promise.all(downloads);
302
+
303
+ if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
304
+
305
+ log.info('npm packages downloaded, stripping non-platform binaries');
306
+
307
+ // Step 2: Strip non-platform native binaries
308
+ const onnxBinDir = join(nodeModules, 'onnxruntime-node', 'bin', 'napi-v3');
309
+ if (existsSync(onnxBinDir)) {
310
+ const entries = readdirSync(onnxBinDir);
311
+ for (const entry of entries) {
312
+ // Keep all darwin architectures (arm64 and x86_64) since uname -m
313
+ // is unreliable under Rosetta (returns x86_64 on Apple Silicon)
314
+ if (entry !== os) {
315
+ rmSync(join(onnxBinDir, entry), { recursive: true, force: true });
316
+ }
317
+ }
318
+ }
319
+
320
+ // Strip non-runtime files to reduce disk usage.
321
+ // Keep lib/ directories — they contain JS entry points needed for bare
322
+ // specifier imports in the worker subprocess.
323
+ const onnxNodeDir = join(nodeModules, 'onnxruntime-node');
324
+ rmSync(join(onnxNodeDir, 'script'), { recursive: true, force: true });
325
+ rmSync(join(onnxNodeDir, 'README.md'), { force: true });
326
+ rmSync(join(nodeModules, 'onnxruntime-common', 'README.md'), { force: true });
327
+
328
+ // Step 3: Create a stub "sharp" package so that the pre-built
329
+ // transformers.node.mjs can import it without error. The bundle checks
330
+ // `if (sharp)` at module initialization time — the stub must be truthy.
331
+ // We only use text embeddings, never image processing.
332
+ const sharpDir = join(nodeModules, 'sharp');
333
+ mkdirSync(sharpDir, { recursive: true });
334
+ writeFileSync(join(sharpDir, 'package.json'), '{"name":"sharp","version":"0.0.0","main":"index.js"}\n');
335
+ writeFileSync(join(sharpDir, 'index.js'), [
336
+ '// Stub: only text embeddings are used, no image processing.',
337
+ '// Must be a truthy function so transformers.node.mjs initialization passes.',
338
+ 'function sharp() { throw new Error("sharp stub: image processing not available"); }',
339
+ 'sharp.format = {};',
340
+ 'module.exports = sharp;',
341
+ '',
342
+ ].join('\n'));
343
+
344
+ // Step 4: Write embed worker script
345
+ writeFileSync(join(tmpDir, WORKER_FILENAME), generateWorkerScript());
346
+
347
+ // Step 5: Write version manifest
348
+ const manifest: VersionManifest = {
349
+ runtimeVersion: RUNTIME_VERSION,
350
+ onnxruntimeNodeVersion: ONNXRUNTIME_NODE_VERSION,
351
+ onnxruntimeCommonVersion: ONNXRUNTIME_COMMON_VERSION,
352
+ transformersVersion: TRANSFORMERS_VERSION,
353
+ platform: os,
354
+ arch: cpu,
355
+ installedAt: new Date().toISOString(),
356
+ };
357
+ writeFileSync(join(tmpDir, 'version.json'), JSON.stringify(manifest, null, 2) + '\n');
358
+
359
+ // Step 6: Atomic swap — remove old install and rename temp to final
360
+ // Preserve model-cache/, bin/ (downloaded bun), and .gitignore
361
+ const modelCacheDir = join(this.baseDir, 'model-cache');
362
+ const hadModelCache = existsSync(modelCacheDir);
363
+ let tmpModelCache: string | null = null;
364
+ if (hadModelCache) {
365
+ tmpModelCache = join(this.baseDir, `.model-cache-preserve-${Date.now()}`);
366
+ renameSync(modelCacheDir, tmpModelCache);
367
+ }
368
+
369
+ // Preserve downloaded bun binary if it exists and we didn't just download a new one
370
+ const existingBinDir = join(this.baseDir, 'bin');
371
+ const newBinDir = join(tmpDir, 'bin');
372
+ const hadBinDir = existsSync(existingBinDir) && !existsSync(newBinDir);
373
+ let tmpBinDir: string | null = null;
374
+ if (hadBinDir) {
375
+ tmpBinDir = join(this.baseDir, `.bin-preserve-${Date.now()}`);
376
+ renameSync(existingBinDir, tmpBinDir);
377
+ }
378
+
379
+ // Remove old install (preserving dotfiles like .gitignore, .downloading, temp dirs)
380
+ for (const entry of readdirSync(this.baseDir)) {
381
+ if (entry.startsWith('.') || entry === tmpDir.split('/').pop()) continue;
382
+ rmSync(join(this.baseDir, entry), { recursive: true, force: true });
383
+ }
384
+
385
+ // Move new files into place
386
+ for (const entry of readdirSync(tmpDir)) {
387
+ renameSync(join(tmpDir, entry), join(this.baseDir, entry));
388
+ }
389
+
390
+ // Restore model cache
391
+ if (tmpModelCache && existsSync(tmpModelCache)) {
392
+ renameSync(tmpModelCache, modelCacheDir);
393
+ }
394
+
395
+ // Restore bun binary
396
+ if (tmpBinDir && existsSync(tmpBinDir)) {
397
+ renameSync(tmpBinDir, existingBinDir);
398
+ }
399
+
400
+ log.info({ runtimeVersion: RUNTIME_VERSION }, 'Embedding runtime installed successfully');
401
+ } catch (err) {
402
+ log.error({ err }, 'Failed to install embedding runtime');
403
+ throw err;
404
+ } finally {
405
+ // Clean up temp directory
406
+ rmSync(tmpDir, { recursive: true, force: true });
407
+ }
408
+ }
409
+
410
+ private async downloadBunBinary(installDir: string, signal?: AbortSignal): Promise<void> {
411
+ const os = platform();
412
+ const cpu = arch() === 'arm64' ? 'aarch64' : arch();
413
+ const target = `${os}-${cpu}`;
414
+ const url = `https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-${target}.zip`;
415
+
416
+ log.info({ url, target, bunVersion: BUN_VERSION }, 'Downloading bun binary');
417
+
418
+ const response = await fetch(url, {
419
+ signal,
420
+ redirect: 'follow',
421
+ });
422
+ if (!response.ok) {
423
+ throw new Error(`Failed to download bun: ${response.status} ${response.statusText}`);
424
+ }
425
+
426
+ const zipData = await response.arrayBuffer();
427
+ const binDir = join(installDir, 'bin');
428
+ mkdirSync(binDir, { recursive: true });
429
+
430
+ const tmpZip = join(binDir, `bun-download-${Date.now()}.zip`);
431
+ writeFileSync(tmpZip, Buffer.from(zipData));
432
+
433
+ try {
434
+ // Extract zip
435
+ const proc = Bun.spawn({
436
+ cmd: ['unzip', '-o', tmpZip, '-d', binDir],
437
+ stdout: 'ignore',
438
+ stderr: 'pipe',
439
+ });
440
+ await proc.exited;
441
+ if (proc.exitCode !== 0) {
442
+ const stderr = await new Response(proc.stderr).text();
443
+ throw new Error(`Failed to extract bun zip: ${stderr}`);
444
+ }
445
+
446
+ // Move binary from bun-{target}/bun to bin/bun
447
+ const extractedBun = join(binDir, `bun-${target}`, 'bun');
448
+ if (existsSync(extractedBun)) {
449
+ renameSync(extractedBun, join(binDir, 'bun'));
450
+ rmSync(join(binDir, `bun-${target}`), { recursive: true, force: true });
451
+ }
452
+
453
+ // Make executable
454
+ chmodSync(join(binDir, 'bun'), 0o755);
455
+ } finally {
456
+ try { rmSync(tmpZip); } catch { /* ignore */ }
457
+ }
458
+
459
+ log.info('Bun binary downloaded successfully');
460
+ }
461
+
462
+ private readManifest(): VersionManifest | null {
463
+ const manifestPath = join(this.baseDir, 'version.json');
464
+ if (!existsSync(manifestPath)) return null;
465
+ try {
466
+ return JSON.parse(readFileSync(manifestPath, 'utf-8'));
467
+ } catch {
468
+ return null;
469
+ }
470
+ }
471
+ }
@@ -5,7 +5,7 @@
5
5
  * for a given (assistantId, channel) pair.
6
6
  */
7
7
 
8
- import { and, eq } from 'drizzle-orm';
8
+ import { and, asc, desc, eq } from 'drizzle-orm';
9
9
  import { v4 as uuid } from 'uuid';
10
10
 
11
11
  import { getDb } from './db.js';
@@ -103,6 +103,30 @@ export function getActiveBinding(assistantId: string, channel: string): Guardian
103
103
  return row ? rowToBinding(row) : null;
104
104
  }
105
105
 
106
+ /**
107
+ * List all active guardian bindings for an assistant across all channels.
108
+ * Deterministic ordering: verifiedAt DESC (most recently verified first),
109
+ * then channel ASC (alphabetical tiebreaker).
110
+ */
111
+ export function listActiveBindingsByAssistant(assistantId: string): GuardianBinding[] {
112
+ const db = getDb();
113
+ return db
114
+ .select()
115
+ .from(channelGuardianBindings)
116
+ .where(
117
+ and(
118
+ eq(channelGuardianBindings.assistantId, assistantId),
119
+ eq(channelGuardianBindings.status, 'active'),
120
+ ),
121
+ )
122
+ .orderBy(
123
+ desc(channelGuardianBindings.verifiedAt),
124
+ asc(channelGuardianBindings.channel),
125
+ )
126
+ .all()
127
+ .map(rowToBinding);
128
+ }
129
+
106
130
  export function revokeBinding(assistantId: string, channel: string): boolean {
107
131
  const db = getDb();
108
132
  const now = Date.now();
@@ -23,7 +23,7 @@ export interface IndexMessageInput {
23
23
  createdAt: number;
24
24
  scopeId?: string;
25
25
  // Provenance for trust-aware extraction gating (M3)
26
- provenanceActorRole?: 'guardian' | 'non-guardian' | 'unverified_channel';
26
+ provenanceTrustClass?: 'guardian' | 'trusted_contact' | 'unknown';
27
27
  }
28
28
 
29
29
  export interface IndexMessageResult {
@@ -39,7 +39,7 @@ export function indexMessageNow(
39
39
 
40
40
  // Provenance-based trust gating: only guardian and legacy (undefined) actors
41
41
  // are trusted for extraction and conflict resolution.
42
- const isTrustedActor = input.provenanceActorRole === 'guardian' || input.provenanceActorRole === undefined;
42
+ const isTrustedActor = input.provenanceTrustClass === 'guardian' || input.provenanceTrustClass === undefined;
43
43
 
44
44
  const text = extractTextFromStoredMessageContent(input.content);
45
45
  if (text.length === 0) {
@@ -123,7 +123,7 @@ export function indexMessageNow(
123
123
  }
124
124
 
125
125
  if (!isTrustedActor && (shouldExtract || shouldResolveConflicts)) {
126
- log.info(`Skipping extraction/conflict jobs for untrusted actor (role=${input.provenanceActorRole})`);
126
+ log.info(`Skipping extraction/conflict jobs for untrusted actor (trustClass=${input.provenanceTrustClass})`);
127
127
  }
128
128
 
129
129
  enqueueSummaryRollupJobsIfDue();
@@ -33,6 +33,10 @@ export interface IngressInvite {
33
33
  redeemedByExternalUserId: string | null;
34
34
  redeemedByExternalChatId: string | null;
35
35
  redeemedAt: number | null;
36
+ // Voice invite fields (null for non-voice invites)
37
+ expectedExternalUserId: string | null;
38
+ voiceCodeHash: string | null;
39
+ voiceCodeDigits: number | null;
36
40
  createdAt: number;
37
41
  updatedAt: number;
38
42
  }
@@ -90,6 +94,9 @@ function rowToInvite(row: typeof assistantIngressInvites.$inferSelect): IngressI
90
94
  redeemedByExternalUserId: row.redeemedByExternalUserId,
91
95
  redeemedByExternalChatId: row.redeemedByExternalChatId,
92
96
  redeemedAt: row.redeemedAt,
97
+ expectedExternalUserId: row.expectedExternalUserId,
98
+ voiceCodeHash: row.voiceCodeHash,
99
+ voiceCodeDigits: row.voiceCodeDigits,
93
100
  createdAt: row.createdAt,
94
101
  updatedAt: row.updatedAt,
95
102
  };
@@ -127,6 +134,10 @@ export function createInvite(params: {
127
134
  note?: string;
128
135
  maxUses?: number;
129
136
  expiresInMs?: number;
137
+ // Voice invite metadata (all optional — omitted for non-voice invites)
138
+ expectedExternalUserId?: string;
139
+ voiceCodeHash?: string;
140
+ voiceCodeDigits?: number;
130
141
  }): { invite: IngressInvite; rawToken: string } {
131
142
  const db = getDb();
132
143
  const now = Date.now();
@@ -148,6 +159,9 @@ export function createInvite(params: {
148
159
  redeemedByExternalUserId: null,
149
160
  redeemedByExternalChatId: null,
150
161
  redeemedAt: null,
162
+ expectedExternalUserId: params.expectedExternalUserId ?? null,
163
+ voiceCodeHash: params.voiceCodeHash ?? null,
164
+ voiceCodeDigits: params.voiceCodeDigits ?? null,
151
165
  createdAt: now,
152
166
  updatedAt: now,
153
167
  };
@@ -432,3 +446,34 @@ export function findByTokenHash(tokenHash: string): IngressInvite | null {
432
446
 
433
447
  return row ? rowToInvite(row) : null;
434
448
  }
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // findActiveVoiceInvites
452
+ // ---------------------------------------------------------------------------
453
+
454
+ /**
455
+ * Find all active voice invites bound to a specific caller identity.
456
+ * Used by the voice invite redemption flow to locate candidate invites
457
+ * before code hash matching.
458
+ */
459
+ export function findActiveVoiceInvites(params: {
460
+ assistantId: string;
461
+ expectedExternalUserId: string;
462
+ }): IngressInvite[] {
463
+ const db = getDb();
464
+
465
+ const rows = db
466
+ .select()
467
+ .from(assistantIngressInvites)
468
+ .where(
469
+ and(
470
+ eq(assistantIngressInvites.assistantId, params.assistantId),
471
+ eq(assistantIngressInvites.sourceChannel, 'voice'),
472
+ eq(assistantIngressInvites.status, 'active'),
473
+ eq(assistantIngressInvites.expectedExternalUserId, params.expectedExternalUserId),
474
+ ),
475
+ )
476
+ .all();
477
+
478
+ return rows.map(rowToInvite);
479
+ }
@@ -24,21 +24,28 @@ const BACKFILL_CHECKPOINT_ID_KEY = 'memory:backfill:last_message_id';
24
24
  const RELATION_BACKFILL_CHECKPOINT_KEY = 'memory:relation_backfill:last_created_at';
25
25
  const RELATION_BACKFILL_CHECKPOINT_ID_KEY = 'memory:relation_backfill:last_message_id';
26
26
 
27
- type ProvenanceActorRole = 'guardian' | 'non-guardian' | 'unverified_channel';
27
+ type ProvenanceTrustClass = 'guardian' | 'trusted_contact' | 'unknown';
28
28
 
29
- function parseProvenanceActorRole(rawMetadata: string | null): ProvenanceActorRole | undefined {
29
+ function parseProvenanceTrustClass(rawMetadata: string | null): ProvenanceTrustClass | undefined {
30
30
  if (!rawMetadata) return undefined;
31
31
  try {
32
32
  const parsedJson: unknown = JSON.parse(rawMetadata);
33
33
  const parsed = messageMetadataSchema.safeParse(parsedJson);
34
- return parsed.success ? parsed.data.provenanceActorRole : undefined;
34
+ if (!parsed.success) return undefined;
35
+ if (parsed.data.provenanceTrustClass) return parsed.data.provenanceTrustClass;
36
+ // Legacy fallback for rows written before provenanceTrustClass existed.
37
+ const legacyRole = (parsedJson as { provenanceActorRole?: unknown }).provenanceActorRole;
38
+ if (legacyRole === 'guardian') return 'guardian';
39
+ if (legacyRole === 'non-guardian') return 'trusted_contact';
40
+ if (legacyRole === 'unverified_channel') return 'unknown';
41
+ return undefined;
35
42
  } catch {
36
43
  return undefined;
37
44
  }
38
45
  }
39
46
 
40
- function isTrustedActorRole(actorRole: ProvenanceActorRole | undefined): boolean {
41
- return actorRole === 'guardian' || actorRole === undefined;
47
+ function isTrustedTrustClass(trustClass: ProvenanceTrustClass | undefined): boolean {
48
+ return trustClass === 'guardian' || trustClass === undefined;
42
49
  }
43
50
 
44
51
  export function backfillJob(job: MemoryJob, config: AssistantConfig): void {
@@ -68,7 +75,7 @@ export function backfillJob(job: MemoryJob, config: AssistantConfig): void {
68
75
  scopeId = getConversationMemoryScopeId(message.conversationId);
69
76
  scopeCache.set(message.conversationId, scopeId);
70
77
  }
71
- const provenanceActorRole = parseProvenanceActorRole(message.metadata ?? null);
78
+ const provenanceTrustClass = parseProvenanceTrustClass(message.metadata ?? null);
72
79
  indexMessageNow({
73
80
  messageId: message.id,
74
81
  conversationId: message.conversationId,
@@ -76,7 +83,7 @@ export function backfillJob(job: MemoryJob, config: AssistantConfig): void {
76
83
  content: message.content,
77
84
  createdAt: message.createdAt,
78
85
  scopeId,
79
- provenanceActorRole,
86
+ provenanceTrustClass,
80
87
  }, config.memory);
81
88
  }
82
89
  const lastMessage = batch[batch.length - 1];
@@ -139,8 +146,8 @@ export function backfillEntityRelationsJob(job: MemoryJob, config: AssistantConf
139
146
  let queuedExtractEntityJobs = 0;
140
147
  let skippedUntrusted = 0;
141
148
  for (const message of batch) {
142
- const provenanceActorRole = parseProvenanceActorRole(message.metadata ?? null);
143
- if (!isTrustedActorRole(provenanceActorRole)) {
149
+ const provenanceTrustClass = parseProvenanceTrustClass(message.metadata ?? null);
150
+ if (!isTrustedTrustClass(provenanceTrustClass)) {
144
151
  skippedUntrusted += 1;
145
152
  continue;
146
153
  }
@@ -0,0 +1,16 @@
1
+ import type { DrizzleDb } from '../db-connection.js';
2
+
3
+ /**
4
+ * Add voice invite columns to assistant_ingress_invites for guardian-initiated
5
+ * voice invite codes. All columns are nullable to keep existing invite rows
6
+ * compatible.
7
+ *
8
+ * - expected_external_user_id: E.164 phone number for identity binding
9
+ * - voice_code_hash: SHA-256 hash of the short numeric code
10
+ * - voice_code_digits: configurable digit count (nullable — NULL for non-voice invites)
11
+ */
12
+ export function migrateVoiceInviteColumns(database: DrizzleDb): void {
13
+ try { database.run(/*sql*/ `ALTER TABLE assistant_ingress_invites ADD COLUMN expected_external_user_id TEXT`); } catch { /* already exists */ }
14
+ try { database.run(/*sql*/ `ALTER TABLE assistant_ingress_invites ADD COLUMN voice_code_hash TEXT`); } catch { /* already exists */ }
15
+ try { database.run(/*sql*/ `ALTER TABLE assistant_ingress_invites ADD COLUMN voice_code_digits INTEGER`); } catch { /* already exists */ }
16
+ }