@vellumai/assistant 0.5.1 → 0.5.2

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 (338) hide show
  1. package/ARCHITECTURE.md +54 -54
  2. package/docs/architecture/integrations.md +62 -67
  3. package/docs/credential-execution-service.md +3 -3
  4. package/package.json +1 -1
  5. package/src/__tests__/agent-loop.test.ts +111 -0
  6. package/src/__tests__/always-loaded-tools-guard.test.ts +3 -4
  7. package/src/__tests__/app-builder-tool-scripts.test.ts +13 -151
  8. package/src/__tests__/app-dir-path-guard.test.ts +78 -0
  9. package/src/__tests__/app-executors.test.ts +1 -291
  10. package/src/__tests__/app-git-history.test.ts +4 -4
  11. package/src/__tests__/app-routes-csp.test.ts +1 -0
  12. package/src/__tests__/app-store-dir-names.test.ts +426 -0
  13. package/src/__tests__/attachments-store.test.ts +169 -21
  14. package/src/__tests__/attachments.test.ts +115 -1
  15. package/src/__tests__/btw-routes.test.ts +1 -0
  16. package/src/__tests__/canonical-guardian-store.test.ts +38 -0
  17. package/src/__tests__/channel-reply-delivery.test.ts +55 -0
  18. package/src/__tests__/checker.test.ts +54 -0
  19. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  20. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  21. package/src/__tests__/compaction.benchmark.test.ts +2 -1
  22. package/src/__tests__/config-schema-cmd.test.ts +68 -21
  23. package/src/__tests__/config-schema.test.ts +1 -1
  24. package/src/__tests__/conversation-agent-loop-overflow.test.ts +149 -5
  25. package/src/__tests__/conversation-agent-loop.test.ts +290 -2
  26. package/src/__tests__/conversation-attachments.test.ts +17 -19
  27. package/src/__tests__/conversation-disk-view-integration.test.ts +277 -0
  28. package/src/__tests__/conversation-disk-view.test.ts +810 -0
  29. package/src/__tests__/conversation-error.test.ts +1 -1
  30. package/src/__tests__/conversation-fork-crud.test.ts +551 -0
  31. package/src/__tests__/conversation-fork-route.test.ts +386 -0
  32. package/src/__tests__/conversation-history-web-search.test.ts +1 -1
  33. package/src/__tests__/conversation-key-store-disk-view.test.ts +130 -0
  34. package/src/__tests__/conversation-media-retry.test.ts +8 -2
  35. package/src/__tests__/conversation-queue.test.ts +36 -1
  36. package/src/__tests__/conversation-routes-disk-view.test.ts +439 -0
  37. package/src/__tests__/conversation-routes-guardian-reply.test.ts +2 -2
  38. package/src/__tests__/conversation-routes-slash-commands.test.ts +2 -7
  39. package/src/__tests__/conversation-runtime-assembly.test.ts +17 -2
  40. package/src/__tests__/conversation-skill-tools.test.ts +4 -9
  41. package/src/__tests__/conversation-slash-commands.test.ts +149 -0
  42. package/src/__tests__/conversation-store.test.ts +24 -21
  43. package/src/__tests__/conversation-surfaces-state-update.test.ts +246 -0
  44. package/src/__tests__/conversation-surfaces-task-progress.test.ts +1 -0
  45. package/src/__tests__/conversation-title-service.test.ts +137 -0
  46. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +25 -315
  47. package/src/__tests__/conversation-tool-setup-memory-scope.test.ts +1 -0
  48. package/src/__tests__/conversation-tool-setup-side-effect-flag.test.ts +1 -0
  49. package/src/__tests__/conversation-workspace-cache-state.test.ts +44 -2
  50. package/src/__tests__/conversation-workspace-injection.test.ts +11 -0
  51. package/src/__tests__/credential-security-invariants.test.ts +3 -0
  52. package/src/__tests__/credential-vault-unit.test.ts +5 -10
  53. package/src/__tests__/cu-unified-flow.test.ts +1 -0
  54. package/src/__tests__/db-conversation-fork-lineage-migration.test.ts +241 -0
  55. package/src/__tests__/db-llm-request-log-provider-migration.test.ts +214 -0
  56. package/src/__tests__/diagnostics-export.test.ts +70 -1
  57. package/src/__tests__/first-greeting.test.ts +80 -0
  58. package/src/__tests__/gateway-only-guard.test.ts +1 -0
  59. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +3 -7
  60. package/src/__tests__/history-repair.test.ts +32 -10
  61. package/src/__tests__/http-conversation-lineage.test.ts +251 -0
  62. package/src/__tests__/image-source-path-reinject.test.ts +136 -0
  63. package/src/__tests__/llm-context-normalization.test.ts +1116 -0
  64. package/src/__tests__/llm-context-route-provider.test.ts +217 -0
  65. package/src/__tests__/llm-request-log-turn-query.test.ts +270 -0
  66. package/src/__tests__/media-generate-image.test.ts +47 -94
  67. package/src/__tests__/memory-lifecycle-e2e.test.ts +3 -1
  68. package/src/__tests__/memory-recall-quality.test.ts +5 -5
  69. package/src/__tests__/migration-cross-version-compatibility.test.ts +4 -1
  70. package/src/__tests__/migration-export-http.test.ts +3 -1
  71. package/src/__tests__/migration-import-commit-http.test.ts +18 -4
  72. package/src/__tests__/migration-import-preflight-http.test.ts +1 -3
  73. package/src/__tests__/mime-builder.test.ts +3 -2
  74. package/src/__tests__/non-member-access-request.test.ts +12 -1
  75. package/src/__tests__/notification-decision-identity.test.ts +52 -0
  76. package/src/__tests__/oauth-apps-routes.test.ts +103 -0
  77. package/src/__tests__/oauth-store.test.ts +115 -0
  78. package/src/__tests__/provider-error-scenarios.test.ts +1 -3
  79. package/src/__tests__/provider-failover-actual-provider.test.ts +66 -0
  80. package/src/__tests__/recording-handler.test.ts +17 -0
  81. package/src/__tests__/registry.test.ts +3 -8
  82. package/src/__tests__/relay-server.test.ts +1 -1
  83. package/src/__tests__/runtime-attachment-metadata.test.ts +7 -3
  84. package/src/__tests__/schema-transforms.test.ts +165 -5
  85. package/src/__tests__/server-history-render.test.ts +2 -2
  86. package/src/__tests__/slack-app-setup-skill-regression.test.ts +3 -1
  87. package/src/__tests__/slack-inbound-verification.test.ts +2 -2
  88. package/src/__tests__/starter-task-flow.test.ts +1 -0
  89. package/src/__tests__/suggestion-routes.test.ts +443 -0
  90. package/src/__tests__/swarm-conversation-integration.test.ts +1 -0
  91. package/src/__tests__/swarm-recursion.test.ts +1 -0
  92. package/src/__tests__/swarm-tool.test.ts +1 -0
  93. package/src/__tests__/tool-execution-abort-cleanup.test.ts +1 -0
  94. package/src/__tests__/tool-preview-lifecycle.test.ts +32 -5
  95. package/src/__tests__/top-level-renderer.test.ts +22 -0
  96. package/src/__tests__/turn-boundary-resolution.test.ts +243 -0
  97. package/src/__tests__/web-fetch.test.ts +6 -2
  98. package/src/__tests__/workspace-migration-006-services-config.test.ts +335 -0
  99. package/src/__tests__/workspace-migration-007-web-search-provider-rename.test.ts +312 -0
  100. package/src/__tests__/workspace-migration-009-backfill-conversation-disk-view.test.ts +278 -0
  101. package/src/__tests__/workspace-migration-010-app-dir-rename.test.ts +275 -0
  102. package/src/__tests__/workspace-migration-012-rename-conversation-disk-view-dirs.test.ts +77 -0
  103. package/src/__tests__/workspace-migration-013-repair-conversation-disk-view.test.ts +401 -0
  104. package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +328 -0
  105. package/src/__tests__/workspace-migration-seed-device-id.test.ts +6 -10
  106. package/src/agent/attachments.ts +27 -1
  107. package/src/agent/loop.ts +29 -1
  108. package/src/avatar/traits-png-sync.ts +80 -25
  109. package/src/bundler/app-bundler.ts +4 -4
  110. package/src/calls/call-domain.ts +1 -0
  111. package/src/calls/voice-session-bridge.ts +1 -0
  112. package/src/cli/commands/auth.ts +92 -0
  113. package/src/cli/commands/avatar.ts +7 -6
  114. package/src/cli/commands/config.ts +2 -0
  115. package/src/cli/commands/oauth/providers.ts +29 -0
  116. package/src/cli/program.ts +12 -0
  117. package/src/cli.ts +15 -48
  118. package/src/config/bundled-skills/app-builder/SKILL.md +103 -28
  119. package/src/config/bundled-skills/app-builder/TOOLS.json +5 -199
  120. package/src/config/bundled-skills/app-builder/tools/{app-query.ts → app-refresh.ts} +2 -2
  121. package/src/config/bundled-skills/contacts/tools/google-contacts.ts +2 -3
  122. package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +6 -9
  123. package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +4 -6
  124. package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +2 -3
  125. package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +2 -3
  126. package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +2 -3
  127. package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +2 -3
  128. package/src/config/bundled-skills/gmail/tools/gmail-label.ts +4 -6
  129. package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +2 -3
  130. package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +2 -3
  131. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +2 -3
  132. package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +2 -3
  133. package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +2 -3
  134. package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +2 -3
  135. package/src/config/bundled-skills/google-calendar/tools/shared.ts +1 -1
  136. package/src/config/bundled-skills/image-studio/SKILL.md +2 -2
  137. package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
  138. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +45 -72
  139. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +2 -2
  140. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -1
  141. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +19 -3
  142. package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
  143. package/src/config/bundled-skills/slack/tools/shared.ts +19 -4
  144. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +2 -3
  145. package/src/config/bundled-skills/transcribe/SKILL.md +1 -1
  146. package/src/config/bundled-skills/transcribe/TOOLS.json +2 -6
  147. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +19 -83
  148. package/src/config/bundled-tool-registry.ts +2 -14
  149. package/src/config/feature-flag-registry.json +8 -0
  150. package/src/config/loader.ts +64 -0
  151. package/src/config/raw-config-utils.ts +30 -0
  152. package/src/config/schema-utils.ts +28 -7
  153. package/src/config/schema.ts +8 -0
  154. package/src/config/schemas/elevenlabs.ts +18 -0
  155. package/src/config/schemas/memory-lifecycle.ts +4 -2
  156. package/src/config/schemas/memory-storage.ts +1 -1
  157. package/src/config/schemas/services.ts +8 -6
  158. package/src/contacts/contact-store.ts +13 -6
  159. package/src/contacts/contacts-write.ts +0 -1
  160. package/src/context/window-manager.ts +13 -2
  161. package/src/daemon/conversation-agent-loop-handlers.ts +48 -7
  162. package/src/daemon/conversation-agent-loop.ts +56 -19
  163. package/src/daemon/conversation-attachments.ts +18 -36
  164. package/src/daemon/conversation-error.ts +2 -1
  165. package/src/daemon/conversation-history.ts +18 -4
  166. package/src/daemon/conversation-lifecycle.ts +39 -15
  167. package/src/daemon/conversation-messaging.ts +70 -26
  168. package/src/daemon/conversation-process.ts +58 -34
  169. package/src/daemon/conversation-runtime-assembly.ts +21 -38
  170. package/src/daemon/conversation-slash.ts +121 -256
  171. package/src/daemon/conversation-surfaces.ts +143 -20
  172. package/src/daemon/conversation-tool-setup.ts +0 -6
  173. package/src/daemon/conversation-workspace.ts +21 -1
  174. package/src/daemon/conversation.ts +51 -29
  175. package/src/daemon/first-greeting.ts +35 -0
  176. package/src/daemon/handlers/config-embeddings.ts +148 -0
  177. package/src/daemon/handlers/config-model.ts +71 -26
  178. package/src/daemon/handlers/conversations.ts +0 -23
  179. package/src/daemon/handlers/recording.ts +26 -21
  180. package/src/daemon/host-cu-proxy.ts +2 -2
  181. package/src/daemon/lifecycle.ts +106 -64
  182. package/src/daemon/message-protocol.ts +3 -0
  183. package/src/daemon/message-types/conversations.ts +19 -0
  184. package/src/daemon/message-types/messages.ts +1 -0
  185. package/src/daemon/message-types/shared.ts +2 -0
  186. package/src/daemon/message-types/surfaces.ts +2 -0
  187. package/src/daemon/message-types/upgrades.ts +23 -0
  188. package/src/daemon/server.ts +83 -12
  189. package/src/daemon/shutdown-handlers.ts +8 -5
  190. package/src/daemon/startup-error.ts +9 -0
  191. package/src/daemon/tool-side-effects.ts +11 -28
  192. package/src/events/tool-permission-telemetry-listener.ts +1 -3
  193. package/src/instrument.ts +0 -4
  194. package/src/media/app-icon-generator.ts +2 -2
  195. package/src/memory/app-git-service.ts +28 -16
  196. package/src/memory/app-store.ts +230 -41
  197. package/src/memory/attachments-store.ts +558 -130
  198. package/src/memory/conversation-attention-store.ts +70 -0
  199. package/src/memory/conversation-crud.ts +442 -3
  200. package/src/memory/conversation-directories.ts +125 -0
  201. package/src/memory/conversation-disk-view.ts +390 -0
  202. package/src/memory/conversation-key-store.ts +17 -5
  203. package/src/memory/conversation-queries.ts +5 -1
  204. package/src/memory/conversation-title-service.ts +21 -49
  205. package/src/memory/db-init.ts +28 -0
  206. package/src/memory/embedding-backend.ts +42 -53
  207. package/src/memory/embedding-gemini.test.ts +4 -4
  208. package/src/memory/embedding-local.ts +1 -3
  209. package/src/memory/embedding-ollama.ts +1 -3
  210. package/src/memory/embedding-openai.ts +1 -3
  211. package/src/memory/indexer.ts +9 -7
  212. package/src/memory/items-extractor.ts +42 -13
  213. package/src/memory/job-handlers/conversation-starters.ts +6 -1
  214. package/src/memory/job-handlers/embedding.test.ts +1 -4
  215. package/src/memory/llm-request-log-store.ts +100 -1
  216. package/src/memory/migrations/102-alter-table-columns.ts +5 -0
  217. package/src/memory/migrations/146-schedule-oneshot-routing.ts +3 -3
  218. package/src/memory/migrations/147-migrate-reminders-to-schedules.ts +66 -70
  219. package/src/memory/migrations/148-drop-reminders-table.ts +5 -9
  220. package/src/memory/migrations/160-drop-loopback-port-column.ts +1 -3
  221. package/src/memory/migrations/174-rename-thread-starters-table.ts +0 -7
  222. package/src/memory/migrations/178-oauth-providers-managed-service-config-key.ts +15 -0
  223. package/src/memory/migrations/179-llm-request-log-message-id.ts +16 -0
  224. package/src/memory/migrations/180-backfill-inline-attachments-to-disk.ts +66 -0
  225. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +46 -0
  226. package/src/memory/migrations/182-oauth-providers-display-metadata.ts +20 -0
  227. package/src/memory/migrations/183-add-conversation-fork-lineage.ts +22 -0
  228. package/src/memory/migrations/184-llm-request-log-provider.ts +12 -0
  229. package/src/memory/migrations/index.ts +7 -0
  230. package/src/memory/migrations/registry.ts +13 -0
  231. package/src/memory/retriever.test.ts +601 -2
  232. package/src/memory/retriever.ts +85 -9
  233. package/src/memory/schema/conversations.ts +6 -0
  234. package/src/memory/schema/infrastructure.ts +13 -7
  235. package/src/memory/schema/oauth.ts +6 -0
  236. package/src/messaging/providers/gmail/mime-builder.ts +3 -1
  237. package/src/notifications/copy-composer.ts +26 -0
  238. package/src/notifications/decision-engine.ts +14 -1
  239. package/src/notifications/emit-signal.ts +1 -1
  240. package/src/notifications/signal.ts +36 -0
  241. package/src/oauth/byo-connection.test.ts +1 -45
  242. package/src/oauth/byo-connection.ts +2 -8
  243. package/src/oauth/connect-orchestrator.ts +15 -11
  244. package/src/oauth/connection-resolver.test.ts +191 -0
  245. package/src/oauth/connection-resolver.ts +66 -38
  246. package/src/oauth/connection.ts +0 -1
  247. package/src/oauth/oauth-store.ts +97 -47
  248. package/src/oauth/platform-connection.test.ts +0 -1
  249. package/src/oauth/platform-connection.ts +11 -3
  250. package/src/oauth/seed-providers.ts +78 -3
  251. package/src/oauth/token-persistence.ts +16 -10
  252. package/src/permissions/checker.ts +71 -8
  253. package/src/prompts/templates/BOOTSTRAP.md +2 -0
  254. package/src/providers/anthropic/client.ts +8 -1
  255. package/src/providers/failover.ts +4 -1
  256. package/src/providers/gemini/client.ts +50 -0
  257. package/src/providers/model-catalog.ts +92 -0
  258. package/src/providers/model-intents.ts +29 -20
  259. package/src/providers/openai/client.ts +49 -0
  260. package/src/providers/types.ts +2 -0
  261. package/src/runtime/access-request-helper.ts +16 -7
  262. package/src/runtime/auth/credential-service.ts +3 -1
  263. package/src/runtime/auth/route-policy.ts +14 -1
  264. package/src/runtime/btw-sidechain.ts +101 -0
  265. package/src/runtime/channel-reply-delivery.ts +17 -1
  266. package/src/runtime/http-router.ts +3 -1
  267. package/src/runtime/http-server.ts +196 -141
  268. package/src/runtime/http-types.ts +1 -0
  269. package/src/runtime/migrations/vbundle-builder.ts +5 -1
  270. package/src/runtime/routes/access-request-decision.ts +41 -0
  271. package/src/runtime/routes/app-management-routes.ts +6 -3
  272. package/src/runtime/routes/app-routes.ts +7 -3
  273. package/src/runtime/routes/approval-routes.ts +1 -0
  274. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +34 -2
  275. package/src/runtime/routes/attachment-routes.ts +45 -15
  276. package/src/runtime/routes/btw-routes.ts +21 -61
  277. package/src/runtime/routes/conversation-management-routes.ts +68 -0
  278. package/src/runtime/routes/conversation-query-routes.ts +180 -10
  279. package/src/runtime/routes/conversation-routes.ts +222 -28
  280. package/src/runtime/routes/conversation-starter-routes.ts +9 -11
  281. package/src/runtime/routes/diagnostics-routes.ts +1 -0
  282. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +2 -2
  283. package/src/runtime/routes/llm-context-normalization.ts +1199 -0
  284. package/src/runtime/routes/log-export-routes.ts +3 -0
  285. package/src/runtime/routes/memory-item-routes.test.ts +34 -0
  286. package/src/runtime/routes/memory-item-routes.ts +4 -0
  287. package/src/runtime/routes/migration-routes.ts +4 -1
  288. package/src/runtime/routes/oauth-apps.ts +291 -0
  289. package/src/runtime/routes/secret-routes.ts +28 -1
  290. package/src/runtime/routes/settings-routes.ts +14 -0
  291. package/src/runtime/routes/trace-event-routes.ts +4 -1
  292. package/src/schedule/schedule-store.ts +9 -21
  293. package/src/security/secure-keys.ts +21 -0
  294. package/src/signals/bash.ts +1 -1
  295. package/src/swarm/backend-claude-code.ts +3 -6
  296. package/src/telemetry/usage-telemetry-reporter.test.ts +3 -2
  297. package/src/telemetry/usage-telemetry-reporter.ts +3 -1
  298. package/src/tools/AGENTS.md +6 -10
  299. package/src/tools/apps/executors.ts +17 -232
  300. package/src/tools/claude-code/claude-code.ts +2 -3
  301. package/src/tools/credentials/vault.ts +7 -12
  302. package/src/tools/host-filesystem/read.ts +13 -10
  303. package/src/tools/network/__tests__/web-search.test.ts +4 -2
  304. package/src/tools/schedule/list.ts +2 -7
  305. package/src/tools/schema-transforms.ts +5 -0
  306. package/src/tools/shared/filesystem/format-diff.ts +2 -7
  307. package/src/tools/skills/execute.ts +1 -1
  308. package/src/tools/tool-manifest.ts +0 -6
  309. package/src/tools/ui-surface/definitions.ts +2 -2
  310. package/src/util/device-id.ts +28 -5
  311. package/src/util/platform.ts +6 -0
  312. package/src/util/pricing.ts +1 -0
  313. package/src/util/retry.ts +1 -3
  314. package/src/workspace/migrations/002-backfill-installation-id.ts +23 -12
  315. package/src/workspace/migrations/003-seed-device-id.ts +3 -4
  316. package/src/workspace/migrations/006-services-config.ts +5 -0
  317. package/src/workspace/migrations/008-voice-timeout-and-max-steps.ts +12 -0
  318. package/src/workspace/migrations/009-backfill-conversation-disk-view.ts +10 -0
  319. package/src/workspace/migrations/010-app-dir-rename.ts +223 -0
  320. package/src/workspace/migrations/012-rename-conversation-disk-view-dirs.ts +64 -0
  321. package/src/workspace/migrations/013-repair-conversation-disk-view.ts +11 -0
  322. package/src/workspace/migrations/rebuild-conversation-disk-view.ts +186 -0
  323. package/src/workspace/migrations/registry.ts +10 -0
  324. package/src/workspace/top-level-renderer.ts +12 -0
  325. package/src/__tests__/asset-materialize-tool.test.ts +0 -523
  326. package/src/__tests__/asset-search-tool.test.ts +0 -536
  327. package/src/__tests__/fixtures/media-reuse-fixtures.ts +0 -56
  328. package/src/__tests__/media-reuse-story.e2e.test.ts +0 -762
  329. package/src/__tests__/media-visibility-policy.test.ts +0 -190
  330. package/src/config/bundled-skills/app-builder/tools/app-file-edit.ts +0 -14
  331. package/src/config/bundled-skills/app-builder/tools/app-file-list.ts +0 -13
  332. package/src/config/bundled-skills/app-builder/tools/app-file-read.ts +0 -21
  333. package/src/config/bundled-skills/app-builder/tools/app-file-write.ts +0 -14
  334. package/src/config/bundled-skills/app-builder/tools/app-list.ts +0 -13
  335. package/src/config/bundled-skills/app-builder/tools/app-update.ts +0 -23
  336. package/src/daemon/media-visibility-policy.ts +0 -59
  337. package/src/tools/assets/materialize.ts +0 -248
  338. package/src/tools/assets/search.ts +0 -400
@@ -0,0 +1,328 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mock state
5
+ // ---------------------------------------------------------------------------
6
+
7
+ const existsSyncFn = mock((_path: string): boolean => false);
8
+ const readFileSyncFn = mock((_path: string, _enc: string): string => "");
9
+ const writeFileSyncFn = mock((_path: string, _data: string): void => undefined);
10
+ const randomUUIDFn = mock((): string => "generated-uuid-1234");
11
+ const getMemoryCheckpointFn = mock((_key: string): string | null => null);
12
+ const deleteMemoryCheckpointFn = mock((_key: string): void => undefined);
13
+ const getExternalAssistantIdFn = mock((): string | undefined => "my-assistant");
14
+ const homedirFn = mock((): string => "/mock-home");
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Mock modules — before importing module under test
18
+ // ---------------------------------------------------------------------------
19
+
20
+ mock.module("node:fs", () => ({
21
+ existsSync: existsSyncFn,
22
+ readFileSync: readFileSyncFn,
23
+ writeFileSync: writeFileSyncFn,
24
+ }));
25
+
26
+ mock.module("node:crypto", () => ({
27
+ randomUUID: randomUUIDFn,
28
+ }));
29
+
30
+ mock.module("node:os", () => ({
31
+ homedir: homedirFn,
32
+ }));
33
+
34
+ mock.module("../memory/checkpoints.js", () => ({
35
+ getMemoryCheckpoint: getMemoryCheckpointFn,
36
+ deleteMemoryCheckpoint: deleteMemoryCheckpointFn,
37
+ }));
38
+
39
+ mock.module("../runtime/auth/external-assistant-id.js", () => ({
40
+ getExternalAssistantId: getExternalAssistantIdFn,
41
+ }));
42
+
43
+ // Import after mocking
44
+ import { backfillInstallationIdMigration } from "../workspace/migrations/002-backfill-installation-id.js";
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Helpers
48
+ // ---------------------------------------------------------------------------
49
+
50
+ const BASE = "/mock-home";
51
+ const LOCK_PATH = `${BASE}/.vellum.lock.json`;
52
+ const LEGACY_LOCK_PATH = `${BASE}/.vellum.lockfile.json`;
53
+ const WORKSPACE_DIR = `${BASE}/.vellum/workspace`;
54
+
55
+ function makeLockfile(assistants: Array<Record<string, unknown>>): string {
56
+ return JSON.stringify({ assistants });
57
+ }
58
+
59
+ function setupFs(fileContents: Record<string, string>): void {
60
+ existsSyncFn.mockImplementation((path: string) => path in fileContents);
61
+ readFileSyncFn.mockImplementation((path: string, _enc: string) => {
62
+ if (path in fileContents) return fileContents[path];
63
+ throw new Error(`ENOENT: ${path}`);
64
+ });
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Tests
69
+ // ---------------------------------------------------------------------------
70
+
71
+ describe("002-backfill-installation-id migration", () => {
72
+ beforeEach(() => {
73
+ existsSyncFn.mockClear();
74
+ readFileSyncFn.mockClear();
75
+ writeFileSyncFn.mockClear();
76
+ randomUUIDFn.mockClear();
77
+ getMemoryCheckpointFn.mockClear();
78
+ deleteMemoryCheckpointFn.mockClear();
79
+ getExternalAssistantIdFn.mockClear();
80
+ homedirFn.mockClear();
81
+
82
+ // Defaults
83
+ homedirFn.mockReturnValue("/mock-home");
84
+ getExternalAssistantIdFn.mockReturnValue("my-assistant");
85
+ getMemoryCheckpointFn.mockReturnValue(null);
86
+ randomUUIDFn.mockReturnValue("generated-uuid-1234");
87
+ delete process.env.BASE_DATA_DIR;
88
+ });
89
+
90
+ test("no-op when no lockfile exists", () => {
91
+ setupFs({});
92
+
93
+ backfillInstallationIdMigration.run(WORKSPACE_DIR);
94
+
95
+ expect(writeFileSyncFn).not.toHaveBeenCalled();
96
+ });
97
+
98
+ test("no-op when lockfile has no assistants array", () => {
99
+ setupFs({
100
+ [LOCK_PATH]: JSON.stringify({ version: 1 }),
101
+ });
102
+
103
+ backfillInstallationIdMigration.run(WORKSPACE_DIR);
104
+
105
+ expect(writeFileSyncFn).not.toHaveBeenCalled();
106
+ });
107
+
108
+ test("no-op when lockfile is malformed JSON", () => {
109
+ setupFs({
110
+ [LOCK_PATH]: "{{not json",
111
+ });
112
+
113
+ backfillInstallationIdMigration.run(WORKSPACE_DIR);
114
+
115
+ expect(writeFileSyncFn).not.toHaveBeenCalled();
116
+ });
117
+
118
+ test("no-op when lockfile is an array", () => {
119
+ setupFs({
120
+ [LOCK_PATH]: JSON.stringify([1, 2, 3]),
121
+ });
122
+
123
+ backfillInstallationIdMigration.run(WORKSPACE_DIR);
124
+
125
+ expect(writeFileSyncFn).not.toHaveBeenCalled();
126
+ });
127
+
128
+ test("no-op when no matching assistant entry found", () => {
129
+ getExternalAssistantIdFn.mockReturnValue("my-assistant");
130
+ setupFs({
131
+ [LOCK_PATH]: makeLockfile([
132
+ { assistantId: "other-assistant", installationId: undefined },
133
+ ]),
134
+ });
135
+
136
+ backfillInstallationIdMigration.run(WORKSPACE_DIR);
137
+
138
+ expect(writeFileSyncFn).not.toHaveBeenCalled();
139
+ });
140
+
141
+ test("backfills installationId from SQLite checkpoint", () => {
142
+ getMemoryCheckpointFn.mockReturnValue("sqlite-install-id");
143
+ setupFs({
144
+ [LOCK_PATH]: makeLockfile([{ assistantId: "my-assistant" }]),
145
+ });
146
+
147
+ backfillInstallationIdMigration.run(WORKSPACE_DIR);
148
+
149
+ expect(writeFileSyncFn).toHaveBeenCalledTimes(1);
150
+ const [path, data] = writeFileSyncFn.mock.calls[0] as [string, string];
151
+ expect(path).toBe(LOCK_PATH);
152
+ const parsed = JSON.parse(data);
153
+ expect(parsed.assistants[0].installationId).toBe("sqlite-install-id");
154
+ });
155
+
156
+ test("generates new UUID when no SQLite checkpoint exists", () => {
157
+ getMemoryCheckpointFn.mockReturnValue(null);
158
+ randomUUIDFn.mockReturnValue("new-uuid-5678");
159
+ setupFs({
160
+ [LOCK_PATH]: makeLockfile([{ assistantId: "my-assistant" }]),
161
+ });
162
+
163
+ backfillInstallationIdMigration.run(WORKSPACE_DIR);
164
+
165
+ expect(writeFileSyncFn).toHaveBeenCalledTimes(1);
166
+ const [, data] = writeFileSyncFn.mock.calls[0] as [string, string];
167
+ const parsed = JSON.parse(data);
168
+ expect(parsed.assistants[0].installationId).toBe("new-uuid-5678");
169
+ });
170
+
171
+ test("generates new UUID when SQLite table does not exist", () => {
172
+ getMemoryCheckpointFn.mockImplementation(() => {
173
+ throw new Error("no such table: memory_checkpoints");
174
+ });
175
+ randomUUIDFn.mockReturnValue("fallback-uuid");
176
+ setupFs({
177
+ [LOCK_PATH]: makeLockfile([{ assistantId: "my-assistant" }]),
178
+ });
179
+
180
+ backfillInstallationIdMigration.run(WORKSPACE_DIR);
181
+
182
+ expect(writeFileSyncFn).toHaveBeenCalledTimes(1);
183
+ const [, data] = writeFileSyncFn.mock.calls[0] as [string, string];
184
+ const parsed = JSON.parse(data);
185
+ expect(parsed.assistants[0].installationId).toBe("fallback-uuid");
186
+ });
187
+
188
+ test("skips lockfile write when entry already has installationId", () => {
189
+ setupFs({
190
+ [LOCK_PATH]: makeLockfile([
191
+ { assistantId: "my-assistant", installationId: "existing-id" },
192
+ ]),
193
+ });
194
+
195
+ backfillInstallationIdMigration.run(WORKSPACE_DIR);
196
+
197
+ expect(writeFileSyncFn).not.toHaveBeenCalled();
198
+ });
199
+
200
+ test("cleans up SQLite checkpoint when entry already has installationId", () => {
201
+ setupFs({
202
+ [LOCK_PATH]: makeLockfile([
203
+ { assistantId: "my-assistant", installationId: "existing-id" },
204
+ ]),
205
+ });
206
+
207
+ backfillInstallationIdMigration.run(WORKSPACE_DIR);
208
+
209
+ expect(deleteMemoryCheckpointFn).toHaveBeenCalledWith(
210
+ "telemetry:installation_id",
211
+ );
212
+ });
213
+
214
+ test("cleans up SQLite checkpoint after writing lockfile", () => {
215
+ getMemoryCheckpointFn.mockReturnValue("sqlite-id");
216
+ setupFs({
217
+ [LOCK_PATH]: makeLockfile([{ assistantId: "my-assistant" }]),
218
+ });
219
+
220
+ backfillInstallationIdMigration.run(WORKSPACE_DIR);
221
+
222
+ expect(writeFileSyncFn).toHaveBeenCalledTimes(1);
223
+ expect(deleteMemoryCheckpointFn).toHaveBeenCalledWith(
224
+ "telemetry:installation_id",
225
+ );
226
+ });
227
+
228
+ test("handles deleteMemoryCheckpoint throwing gracefully", () => {
229
+ deleteMemoryCheckpointFn.mockImplementation(() => {
230
+ throw new Error("no such table");
231
+ });
232
+ setupFs({
233
+ [LOCK_PATH]: makeLockfile([{ assistantId: "my-assistant" }]),
234
+ });
235
+
236
+ // Should not throw
237
+ backfillInstallationIdMigration.run(WORKSPACE_DIR);
238
+
239
+ expect(writeFileSyncFn).toHaveBeenCalledTimes(1);
240
+ });
241
+
242
+ test("reads from legacy .vellum.lockfile.json when primary is absent", () => {
243
+ getMemoryCheckpointFn.mockReturnValue("sqlite-id");
244
+ setupFs({
245
+ [LEGACY_LOCK_PATH]: makeLockfile([{ assistantId: "my-assistant" }]),
246
+ });
247
+
248
+ backfillInstallationIdMigration.run(WORKSPACE_DIR);
249
+
250
+ expect(writeFileSyncFn).toHaveBeenCalledTimes(1);
251
+ const [path, data] = writeFileSyncFn.mock.calls[0] as [string, string];
252
+ expect(path).toBe(LEGACY_LOCK_PATH);
253
+ const parsed = JSON.parse(data);
254
+ expect(parsed.assistants[0].installationId).toBe("sqlite-id");
255
+ });
256
+
257
+ test("prefers primary lockfile over legacy when both exist", () => {
258
+ getMemoryCheckpointFn.mockReturnValue("sqlite-id");
259
+ setupFs({
260
+ [LOCK_PATH]: makeLockfile([{ assistantId: "my-assistant" }]),
261
+ [LEGACY_LOCK_PATH]: makeLockfile([{ assistantId: "my-assistant" }]),
262
+ });
263
+
264
+ backfillInstallationIdMigration.run(WORKSPACE_DIR);
265
+
266
+ expect(writeFileSyncFn).toHaveBeenCalledTimes(1);
267
+ const [path] = writeFileSyncFn.mock.calls[0] as [string, string];
268
+ expect(path).toBe(LOCK_PATH);
269
+ });
270
+
271
+ test("falls through to legacy lockfile when primary is malformed", () => {
272
+ getMemoryCheckpointFn.mockReturnValue("sqlite-id");
273
+ setupFs({
274
+ [LOCK_PATH]: "{{not json",
275
+ [LEGACY_LOCK_PATH]: makeLockfile([{ assistantId: "my-assistant" }]),
276
+ });
277
+
278
+ backfillInstallationIdMigration.run(WORKSPACE_DIR);
279
+
280
+ expect(writeFileSyncFn).toHaveBeenCalledTimes(1);
281
+ const [path, data] = writeFileSyncFn.mock.calls[0] as [string, string];
282
+ expect(path).toBe(LEGACY_LOCK_PATH);
283
+ const parsed = JSON.parse(data);
284
+ expect(parsed.assistants[0].installationId).toBe("sqlite-id");
285
+ });
286
+
287
+ test("respects BASE_DATA_DIR environment variable", () => {
288
+ process.env.BASE_DATA_DIR = "/custom-base";
289
+ getMemoryCheckpointFn.mockReturnValue("sqlite-id");
290
+
291
+ const customLockPath = "/custom-base/.vellum.lock.json";
292
+ setupFs({
293
+ [customLockPath]: makeLockfile([{ assistantId: "my-assistant" }]),
294
+ });
295
+
296
+ backfillInstallationIdMigration.run(WORKSPACE_DIR);
297
+
298
+ expect(writeFileSyncFn).toHaveBeenCalledTimes(1);
299
+ const [path] = writeFileSyncFn.mock.calls[0] as [string, string];
300
+ expect(path).toBe(customLockPath);
301
+ });
302
+
303
+ test("preserves other assistants in lockfile when writing", () => {
304
+ getMemoryCheckpointFn.mockReturnValue("sqlite-id");
305
+ setupFs({
306
+ [LOCK_PATH]: JSON.stringify({
307
+ assistants: [
308
+ { assistantId: "other-assistant", installationId: "other-id" },
309
+ { assistantId: "my-assistant" },
310
+ ],
311
+ }),
312
+ });
313
+
314
+ backfillInstallationIdMigration.run(WORKSPACE_DIR);
315
+
316
+ expect(writeFileSyncFn).toHaveBeenCalledTimes(1);
317
+ const [, data] = writeFileSyncFn.mock.calls[0] as [string, string];
318
+ const parsed = JSON.parse(data);
319
+ expect(parsed.assistants[0].installationId).toBe("other-id");
320
+ expect(parsed.assistants[1].installationId).toBe("sqlite-id");
321
+ });
322
+
323
+ test("has migration id 002-backfill-installation-id", () => {
324
+ expect(backfillInstallationIdMigration.id).toBe(
325
+ "002-backfill-installation-id",
326
+ );
327
+ });
328
+ });
@@ -10,7 +10,7 @@ const writeFileSyncFn = mock(
10
10
  (_path: string, _data: string, _opts?: object) => {},
11
11
  );
12
12
  const mkdirSyncFn = mock((_path: string, _opts?: object) => {});
13
- const getBaseDataDirFn = mock((): string | undefined => undefined);
13
+ const getDeviceIdBaseDirFn = mock((): string => "/mock-home");
14
14
 
15
15
  // ---------------------------------------------------------------------------
16
16
  // Mock modules — before importing module under test
@@ -23,12 +23,8 @@ mock.module("node:fs", () => ({
23
23
  mkdirSync: mkdirSyncFn,
24
24
  }));
25
25
 
26
- mock.module("node:os", () => ({
27
- homedir: () => "/mock-home",
28
- }));
29
-
30
- mock.module("../config/env-registry.js", () => ({
31
- getBaseDataDir: getBaseDataDirFn,
26
+ mock.module("../util/device-id.js", () => ({
27
+ getDeviceIdBaseDir: getDeviceIdBaseDirFn,
32
28
  }));
33
29
 
34
30
  // Import after mocking
@@ -67,7 +63,7 @@ describe("003-seed-device-id migration", () => {
67
63
  readFileSyncFn.mockClear();
68
64
  writeFileSyncFn.mockClear();
69
65
  mkdirSyncFn.mockClear();
70
- getBaseDataDirFn.mockReturnValue(undefined);
66
+ getDeviceIdBaseDirFn.mockReturnValue("/mock-home");
71
67
  });
72
68
 
73
69
  test("no-op when device.json already has a deviceId", () => {
@@ -269,9 +265,9 @@ describe("003-seed-device-id migration", () => {
269
265
  expect(parsed.deviceId).toBe("install-legacy");
270
266
  });
271
267
 
272
- test("respects BASE_DATA_DIR override", () => {
268
+ test("respects BASE_DATA_DIR override via getDeviceIdBaseDir", () => {
273
269
  const customBase = "/custom-base";
274
- getBaseDataDirFn.mockReturnValue(customBase);
270
+ getDeviceIdBaseDirFn.mockReturnValue(customBase);
275
271
 
276
272
  const customLockPath = `${customBase}/.vellum.lock.json`;
277
273
  const customDevicePath = `${customBase}/.vellum/device.json`;
@@ -1,4 +1,4 @@
1
- import type { ContentBlock } from "../providers/types.js";
1
+ import type { ContentBlock, Message } from "../providers/types.js";
2
2
 
3
3
  export interface MessageAttachmentInput {
4
4
  id?: string;
@@ -6,6 +6,7 @@ export interface MessageAttachmentInput {
6
6
  mimeType: string;
7
7
  data: string;
8
8
  extractedText?: string;
9
+ filePath?: string;
9
10
  }
10
11
 
11
12
  export function attachmentsToContentBlocks(
@@ -35,3 +36,28 @@ export function attachmentsToContentBlocks(
35
36
  } as ContentBlock;
36
37
  });
37
38
  }
39
+
40
+ /**
41
+ * Return a copy of the message with text annotations for image source paths.
42
+ * The annotations are appended as a text content block so the LLM knows where
43
+ * the images came from on disk. The caller should persist the ORIGINAL message
44
+ * (without annotations) so the UI stays clean.
45
+ */
46
+ export function enrichMessageWithSourcePaths(
47
+ message: Message,
48
+ attachments: MessageAttachmentInput[],
49
+ ): Message {
50
+ const imageAttachments = attachments.filter(
51
+ (a) => a.mimeType.toLowerCase().startsWith("image/") && a.filePath,
52
+ );
53
+ if (imageAttachments.length === 0) return message;
54
+
55
+ const annotation = imageAttachments
56
+ .map((a) => `[Attached image source: ${a.filePath}]`)
57
+ .join("\n");
58
+
59
+ return {
60
+ ...message,
61
+ content: [...message.content, { type: "text" as const, text: annotation }],
62
+ };
63
+ }
package/src/agent/loop.ts CHANGED
@@ -88,6 +88,7 @@ export type AgentEvent =
88
88
  cacheCreationInputTokens?: number;
89
89
  cacheReadInputTokens?: number;
90
90
  model: string;
91
+ actualProvider?: string;
91
92
  providerDurationMs: number;
92
93
  rawRequest?: unknown;
93
94
  rawResponse?: unknown;
@@ -100,6 +101,7 @@ const DEFAULT_CONFIG: AgentLoopConfig = {
100
101
  };
101
102
 
102
103
  const PROGRESS_CHECK_INTERVAL = 5;
104
+ const MAX_CONSECUTIVE_ERROR_NUDGES = 3;
103
105
  const PROGRESS_CHECK_REMINDER =
104
106
  "You have been using tools for several turns. Check whether you are making meaningful progress toward the user's goal. If you are stuck in a loop or not making progress, summarize what you have tried and ask the user for guidance instead of continuing.";
105
107
 
@@ -200,6 +202,7 @@ export class AgentLoop {
200
202
  const history = [...messages];
201
203
  let toolUseTurns = 0;
202
204
  let nudgedForEmptyResponse = false;
205
+ let consecutiveErrorTurns = 0;
203
206
  let lastLlmCallTime = 0;
204
207
  const rlog = requestId ? log.child({ requestId }) : log;
205
208
 
@@ -350,6 +353,7 @@ export class AgentLoop {
350
353
  cacheCreationInputTokens: response.usage.cacheCreationInputTokens,
351
354
  cacheReadInputTokens: response.usage.cacheReadInputTokens,
352
355
  model: response.model,
356
+ actualProvider: response.actualProvider ?? this.provider.name,
353
357
  providerDurationMs,
354
358
  rawRequest: response.rawRequest,
355
359
  rawResponse: response.rawResponse,
@@ -566,13 +570,37 @@ export class AgentLoop {
566
570
 
567
571
  // Track tool-use turns and inject progress reminder every N turns
568
572
  toolUseTurns++;
569
- if (toolUseTurns % PROGRESS_CHECK_INTERVAL === 0) {
573
+ const isProgressCheckTurn =
574
+ toolUseTurns % PROGRESS_CHECK_INTERVAL === 0;
575
+ if (isProgressCheckTurn) {
570
576
  resultBlocks.push({
571
577
  type: "text",
572
578
  text: `<system_notice>${PROGRESS_CHECK_REMINDER}</system_notice>`,
573
579
  });
574
580
  }
575
581
 
582
+ // When any tool returned an error, nudge the LLM to retry with
583
+ // corrected parameters instead of ending its turn. Skip the nudge
584
+ // when the progress check fires (to avoid contradictory instructions)
585
+ // and after MAX_CONSECUTIVE_ERROR_NUDGES consecutive error turns
586
+ // (the error is likely unrecoverable at that point).
587
+ const hasToolError = toolResults.some(({ result }) => result.isError);
588
+ if (hasToolError) {
589
+ consecutiveErrorTurns++;
590
+ } else {
591
+ consecutiveErrorTurns = 0;
592
+ }
593
+ if (
594
+ hasToolError &&
595
+ !isProgressCheckTurn &&
596
+ consecutiveErrorTurns <= MAX_CONSECUTIVE_ERROR_NUDGES
597
+ ) {
598
+ resultBlocks.push({
599
+ type: "text",
600
+ text: "<system_notice>One or more tool calls returned an error. If the error looks recoverable (e.g. missing or invalid parameters), fix the parameters and retry. If the error is clearly unrecoverable (e.g. a service is down, a resource does not exist, or a permission is permanently denied), report it to the user.</system_notice>",
601
+ });
602
+ }
603
+
576
604
  // Remind the LLM not to repeat text it already streamed
577
605
  if (hasTextBlock) {
578
606
  resultBlocks.push({
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import { getLogger } from "../util/logger.js";
6
6
  import { getWorkspaceDir } from "../util/platform.js";
7
7
  import { renderCharacterAscii } from "./ascii-renderer.js";
8
+ import { getCharacterComponents } from "./character-components.js";
8
9
  import { renderCharacterPng } from "./png-renderer.js";
9
10
 
10
11
  const log = getLogger("traits-png-sync");
@@ -24,37 +25,56 @@ export type TraitsSyncResult =
24
25
  };
25
26
 
26
27
  /**
27
- * Renders avatar-image.png and character-ascii.txt from the given traits,
28
- * writing each file atomically. Does NOT touch character-traits.json.
29
- *
30
- * Returns `true` if the ASCII sidecar was also written successfully.
28
+ * Renders avatar PNG and ASCII art into memory without touching the filesystem.
29
+ * Call this before any disk writes so a render failure leaves all files untouched.
31
30
  */
32
- function renderAndWriteAvatarFiles(
33
- traits: CharacterTraits,
34
- avatarDir: string,
35
- ): boolean {
36
- const pngPath = join(avatarDir, "avatar-image.png");
37
-
38
- // Render PNG first — this validates trait IDs (composeSvg throws on
39
- // unknown components), so we fail before writing anything to disk.
31
+ function renderAvatarBuffers(traits: CharacterTraits): {
32
+ pngBuffer: Buffer;
33
+ asciiArt: string | null;
34
+ } {
40
35
  const pngBuffer = renderCharacterPng(
41
36
  traits.bodyShape,
42
37
  traits.eyeStyle,
43
38
  traits.color,
44
39
  );
45
- const pngTmp = `${pngPath}.${randomUUID()}.tmp`;
46
- writeFileSync(pngTmp, pngBuffer);
47
- renameSync(pngTmp, pngPath);
48
40
 
49
- // Render and write ASCII art — isolated so a failure here doesn't cause
50
- // the primary operation to report failure.
41
+ let asciiArt: string | null = null;
51
42
  try {
52
- const asciiPath = join(avatarDir, "character-ascii.txt");
53
- const asciiArt = renderCharacterAscii(
43
+ asciiArt = renderCharacterAscii(
54
44
  traits.bodyShape,
55
45
  traits.eyeStyle,
56
46
  traits.color,
57
47
  );
48
+ } catch (asciiErr) {
49
+ log.warn(
50
+ { err: asciiErr },
51
+ "Failed to render ASCII art — will still write PNG and traits",
52
+ );
53
+ }
54
+
55
+ return { pngBuffer, asciiArt };
56
+ }
57
+
58
+ /**
59
+ * Writes pre-rendered avatar files (PNG + optional ASCII) to disk atomically.
60
+ * Returns `true` if the ASCII sidecar was also written successfully.
61
+ */
62
+ function writeAvatarFiles(
63
+ avatarDir: string,
64
+ pngBuffer: Buffer,
65
+ asciiArt: string | null,
66
+ ): boolean {
67
+ const pngPath = join(avatarDir, "avatar-image.png");
68
+ const pngTmp = `${pngPath}.${randomUUID()}.tmp`;
69
+ writeFileSync(pngTmp, pngBuffer);
70
+ renameSync(pngTmp, pngPath);
71
+
72
+ if (asciiArt == null) {
73
+ return false;
74
+ }
75
+
76
+ try {
77
+ const asciiPath = join(avatarDir, "character-ascii.txt");
58
78
  const asciiTmp = `${asciiPath}.${randomUUID()}.tmp`;
59
79
  writeFileSync(asciiTmp, asciiArt);
60
80
  renameSync(asciiTmp, asciiPath);
@@ -73,8 +93,10 @@ function renderAndWriteAvatarFiles(
73
93
  * character-ascii.txt in one atomic operation. Accepts the trait values
74
94
  * directly so callers don't need to touch the filesystem first.
75
95
  *
76
- * Renders the PNG before writing the traits file so that if rendering fails
77
- * (e.g. unknown component IDs), neither file is modified.
96
+ * Validates trait IDs against the component set, then renders into memory
97
+ * before any disk writes. Writes the traits file first, then the rendered
98
+ * avatar files, so a render failure leaves all files untouched and a disk
99
+ * failure after traits are written never leaves the PNG ahead of the traits.
78
100
  */
79
101
  export function writeTraitsAndRenderAvatar(
80
102
  traits: CharacterTraits,
@@ -94,22 +116,55 @@ export function writeTraitsAndRenderAvatar(
94
116
  };
95
117
  }
96
118
 
119
+ // Validate trait IDs against the known component set so that unknown values
120
+ // are surfaced as input-validation errors (400) rather than server errors (500).
121
+ const components = getCharacterComponents();
122
+ const validBodyShapes = components.bodyShapes.map((b) => b.id);
123
+ if (!validBodyShapes.includes(traits.bodyShape)) {
124
+ return {
125
+ ok: false,
126
+ reason: "invalid_traits",
127
+ message: `Unknown body shape: "${traits.bodyShape}". Valid IDs: ${validBodyShapes.join(", ")}`,
128
+ };
129
+ }
130
+ const validEyeStyles = components.eyeStyles.map((e) => e.id);
131
+ if (!validEyeStyles.includes(traits.eyeStyle)) {
132
+ return {
133
+ ok: false,
134
+ reason: "invalid_traits",
135
+ message: `Unknown eye style: "${traits.eyeStyle}". Valid IDs: ${validEyeStyles.join(", ")}`,
136
+ };
137
+ }
138
+ const validColors = components.colors.map((c) => c.id);
139
+ if (!validColors.includes(traits.color)) {
140
+ return {
141
+ ok: false,
142
+ reason: "invalid_traits",
143
+ message: `Unknown color: "${traits.color}". Valid IDs: ${validColors.join(", ")}`,
144
+ };
145
+ }
146
+
97
147
  const avatarDir = join(getWorkspaceDir(), "data", "avatar");
98
148
  const traitsPath = join(avatarDir, "character-traits.json");
99
149
 
100
150
  try {
101
151
  mkdirSync(avatarDir, { recursive: true });
102
152
 
103
- // Render avatar files firstvalidates trait IDs before committing
104
- // the traits file, so we never leave traits and PNG out of sync.
105
- const asciiWritten = renderAndWriteAvatarFiles(traits, avatarDir);
153
+ // Phase 1: Render everything into memoryno disk writes yet.
154
+ // If rendering fails, all files remain untouched.
155
+ const { pngBuffer, asciiArt } = renderAvatarBuffers(traits);
106
156
 
107
- // Write traits file atomically (after successful render)
157
+ // Phase 2: Write traits file atomically first.
108
158
  const traitsJson = JSON.stringify(traits, null, 2);
109
159
  const traitsTmp = `${traitsPath}.${randomUUID()}.tmp`;
110
160
  writeFileSync(traitsTmp, traitsJson);
111
161
  renameSync(traitsTmp, traitsPath);
112
162
 
163
+ // Phase 3: Write rendered avatar files to disk.
164
+ // Traits are already committed, so a failure here leaves traits ahead of
165
+ // the PNG — acceptable because the next render call will reconcile them.
166
+ const asciiWritten = writeAvatarFiles(avatarDir, pngBuffer, asciiArt);
167
+
113
168
  log.info(
114
169
  {
115
170
  bodyShape: traits.bodyShape,
@@ -14,7 +14,7 @@ import { join } from "node:path";
14
14
  import archiver from "archiver";
15
15
  import JSZip from "jszip";
16
16
 
17
- import { getApp, getAppsDir, isMultifileApp } from "../memory/app-store.js";
17
+ import { getApp, getAppDirPath, isMultifileApp } from "../memory/app-store.js";
18
18
  import { computeContentId } from "../util/content-id.js";
19
19
  import { getLogger } from "../util/logger.js";
20
20
  import { compileApp } from "./app-compiler.js";
@@ -79,7 +79,7 @@ export async function packageApp(
79
79
  // Compile the app and bundle the output.
80
80
  const compiledFiles: { name: string; data: Buffer }[] = [];
81
81
 
82
- const appDir = join(getAppsDir(), appId);
82
+ const appDir = getAppDirPath(appId);
83
83
 
84
84
  if (multifile) {
85
85
  // Multi-file TSX app: compile src/ -> dist/
@@ -154,7 +154,7 @@ export async function packageApp(
154
154
  }
155
155
 
156
156
  // Include app icon if one was generated
157
- const iconPath = join(getAppsDir(), appId, "icon.png");
157
+ const iconPath = join(getAppDirPath(appId), "icon.png");
158
158
  if (existsSync(iconPath)) {
159
159
  archive.append(readFileSync(iconPath), { name: "icon.png" });
160
160
  }
@@ -202,7 +202,7 @@ export async function packageApp(
202
202
 
203
203
  // Read icon for inclusion in the response
204
204
  let iconImageBase64: string | undefined;
205
- const iconFilePath = join(getAppsDir(), appId, "icon.png");
205
+ const iconFilePath = join(getAppDirPath(appId), "icon.png");
206
206
  if (existsSync(iconFilePath)) {
207
207
  iconImageBase64 = readFileSync(iconFilePath).toString("base64");
208
208
  }
@@ -1088,6 +1088,7 @@ export async function startInviteCall(
1088
1088
  callMode: "invite",
1089
1089
  inviteFriendName: friendName,
1090
1090
  inviteGuardianName: guardianName,
1091
+ initiatedFromConversationId: conversationId,
1091
1092
  });
1092
1093
  sessionId = session.id;
1093
1094