@vellumai/assistant 0.5.0 → 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 (347) 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__/assistant-feature-flags-integration.test.ts +7 -9
  14. package/src/__tests__/attachments-store.test.ts +169 -21
  15. package/src/__tests__/attachments.test.ts +115 -1
  16. package/src/__tests__/btw-routes.test.ts +1 -0
  17. package/src/__tests__/canonical-guardian-store.test.ts +38 -0
  18. package/src/__tests__/channel-reply-delivery.test.ts +55 -0
  19. package/src/__tests__/checker.test.ts +54 -0
  20. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  21. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  22. package/src/__tests__/compaction.benchmark.test.ts +2 -1
  23. package/src/__tests__/config-schema-cmd.test.ts +68 -21
  24. package/src/__tests__/config-schema.test.ts +1 -1
  25. package/src/__tests__/conversation-agent-loop-overflow.test.ts +149 -5
  26. package/src/__tests__/conversation-agent-loop.test.ts +290 -2
  27. package/src/__tests__/conversation-attachments.test.ts +17 -19
  28. package/src/__tests__/conversation-disk-view-integration.test.ts +277 -0
  29. package/src/__tests__/conversation-disk-view.test.ts +810 -0
  30. package/src/__tests__/conversation-error.test.ts +1 -1
  31. package/src/__tests__/conversation-fork-crud.test.ts +551 -0
  32. package/src/__tests__/conversation-fork-route.test.ts +386 -0
  33. package/src/__tests__/conversation-history-web-search.test.ts +1 -1
  34. package/src/__tests__/conversation-key-store-disk-view.test.ts +130 -0
  35. package/src/__tests__/conversation-media-retry.test.ts +8 -2
  36. package/src/__tests__/conversation-queue.test.ts +36 -1
  37. package/src/__tests__/conversation-routes-disk-view.test.ts +439 -0
  38. package/src/__tests__/conversation-routes-guardian-reply.test.ts +2 -2
  39. package/src/__tests__/conversation-routes-slash-commands.test.ts +2 -7
  40. package/src/__tests__/conversation-runtime-assembly.test.ts +17 -2
  41. package/src/__tests__/conversation-skill-tools.test.ts +4 -9
  42. package/src/__tests__/conversation-slash-commands.test.ts +149 -0
  43. package/src/__tests__/conversation-store.test.ts +24 -21
  44. package/src/__tests__/conversation-surfaces-state-update.test.ts +246 -0
  45. package/src/__tests__/conversation-surfaces-task-progress.test.ts +1 -0
  46. package/src/__tests__/conversation-title-service.test.ts +137 -0
  47. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +25 -315
  48. package/src/__tests__/conversation-tool-setup-memory-scope.test.ts +1 -0
  49. package/src/__tests__/conversation-tool-setup-side-effect-flag.test.ts +1 -0
  50. package/src/__tests__/conversation-workspace-cache-state.test.ts +44 -2
  51. package/src/__tests__/conversation-workspace-injection.test.ts +11 -0
  52. package/src/__tests__/credential-execution-feature-gates.test.ts +3 -3
  53. package/src/__tests__/credential-security-invariants.test.ts +3 -0
  54. package/src/__tests__/credential-vault-unit.test.ts +5 -10
  55. package/src/__tests__/cu-unified-flow.test.ts +1 -0
  56. package/src/__tests__/db-conversation-fork-lineage-migration.test.ts +241 -0
  57. package/src/__tests__/db-llm-request-log-provider-migration.test.ts +214 -0
  58. package/src/__tests__/diagnostics-export.test.ts +70 -1
  59. package/src/__tests__/filesystem-tools.test.ts +4 -2
  60. package/src/__tests__/first-greeting.test.ts +80 -0
  61. package/src/__tests__/gateway-only-guard.test.ts +1 -0
  62. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +3 -7
  63. package/src/__tests__/history-repair.test.ts +103 -10
  64. package/src/__tests__/http-conversation-lineage.test.ts +251 -0
  65. package/src/__tests__/image-source-path-reinject.test.ts +136 -0
  66. package/src/__tests__/llm-context-normalization.test.ts +1116 -0
  67. package/src/__tests__/llm-context-route-provider.test.ts +217 -0
  68. package/src/__tests__/llm-request-log-turn-query.test.ts +270 -0
  69. package/src/__tests__/media-generate-image.test.ts +47 -94
  70. package/src/__tests__/memory-lifecycle-e2e.test.ts +3 -1
  71. package/src/__tests__/memory-recall-quality.test.ts +5 -5
  72. package/src/__tests__/migration-cross-version-compatibility.test.ts +4 -1
  73. package/src/__tests__/migration-export-http.test.ts +3 -1
  74. package/src/__tests__/migration-import-commit-http.test.ts +18 -4
  75. package/src/__tests__/migration-import-preflight-http.test.ts +1 -3
  76. package/src/__tests__/mime-builder.test.ts +3 -2
  77. package/src/__tests__/non-member-access-request.test.ts +12 -1
  78. package/src/__tests__/notification-decision-identity.test.ts +52 -0
  79. package/src/__tests__/oauth-apps-routes.test.ts +103 -0
  80. package/src/__tests__/oauth-store.test.ts +115 -0
  81. package/src/__tests__/provider-error-scenarios.test.ts +1 -3
  82. package/src/__tests__/provider-failover-actual-provider.test.ts +66 -0
  83. package/src/__tests__/recording-handler.test.ts +17 -0
  84. package/src/__tests__/registry.test.ts +3 -8
  85. package/src/__tests__/relay-server.test.ts +1 -1
  86. package/src/__tests__/runtime-attachment-metadata.test.ts +7 -3
  87. package/src/__tests__/schema-transforms.test.ts +165 -5
  88. package/src/__tests__/server-history-render.test.ts +2 -2
  89. package/src/__tests__/skill-feature-flags-integration.test.ts +18 -17
  90. package/src/__tests__/skill-feature-flags.test.ts +13 -13
  91. package/src/__tests__/skill-load-feature-flag.test.ts +4 -4
  92. package/src/__tests__/slack-app-setup-skill-regression.test.ts +3 -1
  93. package/src/__tests__/slack-inbound-verification.test.ts +2 -2
  94. package/src/__tests__/starter-task-flow.test.ts +1 -0
  95. package/src/__tests__/suggestion-routes.test.ts +443 -0
  96. package/src/__tests__/swarm-conversation-integration.test.ts +1 -0
  97. package/src/__tests__/swarm-recursion.test.ts +1 -0
  98. package/src/__tests__/swarm-tool.test.ts +1 -0
  99. package/src/__tests__/system-prompt.test.ts +8 -0
  100. package/src/__tests__/tool-execution-abort-cleanup.test.ts +1 -0
  101. package/src/__tests__/tool-preview-lifecycle.test.ts +32 -5
  102. package/src/__tests__/top-level-renderer.test.ts +22 -0
  103. package/src/__tests__/turn-boundary-resolution.test.ts +243 -0
  104. package/src/__tests__/web-fetch.test.ts +6 -2
  105. package/src/__tests__/workspace-migration-006-services-config.test.ts +335 -0
  106. package/src/__tests__/workspace-migration-007-web-search-provider-rename.test.ts +312 -0
  107. package/src/__tests__/workspace-migration-009-backfill-conversation-disk-view.test.ts +278 -0
  108. package/src/__tests__/workspace-migration-010-app-dir-rename.test.ts +275 -0
  109. package/src/__tests__/workspace-migration-012-rename-conversation-disk-view-dirs.test.ts +77 -0
  110. package/src/__tests__/workspace-migration-013-repair-conversation-disk-view.test.ts +401 -0
  111. package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +328 -0
  112. package/src/__tests__/workspace-migration-seed-device-id.test.ts +6 -10
  113. package/src/agent/attachments.ts +27 -1
  114. package/src/agent/loop.ts +29 -1
  115. package/src/avatar/traits-png-sync.ts +80 -25
  116. package/src/bundler/app-bundler.ts +4 -4
  117. package/src/calls/call-domain.ts +1 -0
  118. package/src/calls/voice-session-bridge.ts +1 -0
  119. package/src/cli/commands/auth.ts +92 -0
  120. package/src/cli/commands/avatar.ts +7 -6
  121. package/src/cli/commands/config.ts +2 -0
  122. package/src/cli/commands/oauth/providers.ts +29 -0
  123. package/src/cli/program.ts +12 -0
  124. package/src/cli.ts +15 -48
  125. package/src/config/bundled-skills/app-builder/SKILL.md +103 -28
  126. package/src/config/bundled-skills/app-builder/TOOLS.json +5 -199
  127. package/src/config/bundled-skills/app-builder/tools/{app-query.ts → app-refresh.ts} +2 -2
  128. package/src/config/bundled-skills/contacts/tools/google-contacts.ts +2 -3
  129. package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +6 -9
  130. package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +4 -6
  131. package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +2 -3
  132. package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +2 -3
  133. package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +2 -3
  134. package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +2 -3
  135. package/src/config/bundled-skills/gmail/tools/gmail-label.ts +4 -6
  136. package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +2 -3
  137. package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +2 -3
  138. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +2 -3
  139. package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +2 -3
  140. package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +2 -3
  141. package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +2 -3
  142. package/src/config/bundled-skills/google-calendar/tools/shared.ts +1 -1
  143. package/src/config/bundled-skills/image-studio/SKILL.md +2 -2
  144. package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
  145. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +45 -72
  146. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +2 -2
  147. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -1
  148. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +19 -3
  149. package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
  150. package/src/config/bundled-skills/slack/tools/shared.ts +19 -4
  151. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +2 -3
  152. package/src/config/bundled-skills/transcribe/SKILL.md +1 -1
  153. package/src/config/bundled-skills/transcribe/TOOLS.json +2 -6
  154. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +19 -83
  155. package/src/config/bundled-tool-registry.ts +2 -14
  156. package/src/config/feature-flag-registry.json +16 -0
  157. package/src/config/loader.ts +64 -0
  158. package/src/config/raw-config-utils.ts +30 -0
  159. package/src/config/schema-utils.ts +28 -7
  160. package/src/config/schema.ts +8 -0
  161. package/src/config/schemas/elevenlabs.ts +18 -0
  162. package/src/config/schemas/memory-lifecycle.ts +4 -2
  163. package/src/config/schemas/memory-storage.ts +1 -1
  164. package/src/config/schemas/services.ts +8 -6
  165. package/src/contacts/contact-store.ts +13 -6
  166. package/src/contacts/contacts-write.ts +0 -1
  167. package/src/context/window-manager.ts +13 -2
  168. package/src/daemon/conversation-agent-loop-handlers.ts +46 -42
  169. package/src/daemon/conversation-agent-loop.ts +56 -19
  170. package/src/daemon/conversation-attachments.ts +18 -36
  171. package/src/daemon/conversation-error.ts +2 -1
  172. package/src/daemon/conversation-history.ts +18 -4
  173. package/src/daemon/conversation-lifecycle.ts +39 -15
  174. package/src/daemon/conversation-messaging.ts +70 -26
  175. package/src/daemon/conversation-process.ts +58 -34
  176. package/src/daemon/conversation-runtime-assembly.ts +21 -38
  177. package/src/daemon/conversation-slash.ts +121 -256
  178. package/src/daemon/conversation-surfaces.ts +143 -20
  179. package/src/daemon/conversation-tool-setup.ts +0 -6
  180. package/src/daemon/conversation-workspace.ts +21 -1
  181. package/src/daemon/conversation.ts +51 -29
  182. package/src/daemon/first-greeting.ts +35 -0
  183. package/src/daemon/handlers/config-embeddings.ts +148 -0
  184. package/src/daemon/handlers/config-model.ts +71 -26
  185. package/src/daemon/handlers/conversations.ts +0 -23
  186. package/src/daemon/handlers/recording.ts +26 -21
  187. package/src/daemon/history-repair.ts +28 -8
  188. package/src/daemon/host-cu-proxy.ts +2 -2
  189. package/src/daemon/lifecycle.ts +106 -64
  190. package/src/daemon/message-protocol.ts +3 -0
  191. package/src/daemon/message-types/conversations.ts +19 -0
  192. package/src/daemon/message-types/messages.ts +1 -0
  193. package/src/daemon/message-types/shared.ts +2 -0
  194. package/src/daemon/message-types/surfaces.ts +2 -0
  195. package/src/daemon/message-types/upgrades.ts +23 -0
  196. package/src/daemon/server.ts +83 -12
  197. package/src/daemon/shutdown-handlers.ts +8 -5
  198. package/src/daemon/startup-error.ts +9 -0
  199. package/src/daemon/tool-side-effects.ts +11 -28
  200. package/src/events/tool-permission-telemetry-listener.ts +1 -3
  201. package/src/instrument.ts +0 -4
  202. package/src/media/app-icon-generator.ts +2 -2
  203. package/src/memory/app-git-service.ts +28 -16
  204. package/src/memory/app-store.ts +230 -41
  205. package/src/memory/attachments-store.ts +558 -130
  206. package/src/memory/conversation-attention-store.ts +70 -0
  207. package/src/memory/conversation-crud.ts +442 -3
  208. package/src/memory/conversation-directories.ts +125 -0
  209. package/src/memory/conversation-disk-view.ts +390 -0
  210. package/src/memory/conversation-key-store.ts +17 -5
  211. package/src/memory/conversation-queries.ts +5 -1
  212. package/src/memory/conversation-title-service.ts +21 -49
  213. package/src/memory/db-init.ts +28 -0
  214. package/src/memory/embedding-backend.ts +42 -53
  215. package/src/memory/embedding-gemini.test.ts +4 -4
  216. package/src/memory/embedding-local.ts +1 -3
  217. package/src/memory/embedding-ollama.ts +1 -3
  218. package/src/memory/embedding-openai.ts +1 -3
  219. package/src/memory/indexer.ts +9 -7
  220. package/src/memory/items-extractor.ts +42 -13
  221. package/src/memory/job-handlers/conversation-starters.ts +6 -1
  222. package/src/memory/job-handlers/embedding.test.ts +1 -4
  223. package/src/memory/llm-request-log-store.ts +100 -1
  224. package/src/memory/migrations/102-alter-table-columns.ts +5 -0
  225. package/src/memory/migrations/146-schedule-oneshot-routing.ts +3 -3
  226. package/src/memory/migrations/147-migrate-reminders-to-schedules.ts +66 -70
  227. package/src/memory/migrations/148-drop-reminders-table.ts +5 -9
  228. package/src/memory/migrations/160-drop-loopback-port-column.ts +1 -3
  229. package/src/memory/migrations/174-rename-thread-starters-table.ts +0 -7
  230. package/src/memory/migrations/178-oauth-providers-managed-service-config-key.ts +15 -0
  231. package/src/memory/migrations/179-llm-request-log-message-id.ts +16 -0
  232. package/src/memory/migrations/180-backfill-inline-attachments-to-disk.ts +66 -0
  233. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +46 -0
  234. package/src/memory/migrations/182-oauth-providers-display-metadata.ts +20 -0
  235. package/src/memory/migrations/183-add-conversation-fork-lineage.ts +22 -0
  236. package/src/memory/migrations/184-llm-request-log-provider.ts +12 -0
  237. package/src/memory/migrations/index.ts +7 -0
  238. package/src/memory/migrations/registry.ts +13 -0
  239. package/src/memory/retriever.test.ts +601 -2
  240. package/src/memory/retriever.ts +85 -9
  241. package/src/memory/schema/conversations.ts +6 -0
  242. package/src/memory/schema/infrastructure.ts +13 -7
  243. package/src/memory/schema/oauth.ts +6 -0
  244. package/src/messaging/providers/gmail/mime-builder.ts +3 -1
  245. package/src/notifications/copy-composer.ts +26 -0
  246. package/src/notifications/decision-engine.ts +14 -1
  247. package/src/notifications/emit-signal.ts +1 -1
  248. package/src/notifications/signal.ts +36 -0
  249. package/src/oauth/byo-connection.test.ts +1 -45
  250. package/src/oauth/byo-connection.ts +2 -8
  251. package/src/oauth/connect-orchestrator.ts +15 -11
  252. package/src/oauth/connection-resolver.test.ts +191 -0
  253. package/src/oauth/connection-resolver.ts +66 -38
  254. package/src/oauth/connection.ts +0 -1
  255. package/src/oauth/oauth-store.ts +97 -47
  256. package/src/oauth/platform-connection.test.ts +0 -1
  257. package/src/oauth/platform-connection.ts +11 -3
  258. package/src/oauth/seed-providers.ts +78 -3
  259. package/src/oauth/token-persistence.ts +16 -10
  260. package/src/permissions/checker.ts +62 -19
  261. package/src/prompts/system-prompt.ts +2 -0
  262. package/src/prompts/templates/BOOTSTRAP.md +2 -0
  263. package/src/providers/anthropic/client.ts +8 -1
  264. package/src/providers/failover.ts +4 -1
  265. package/src/providers/gemini/client.ts +50 -0
  266. package/src/providers/model-catalog.ts +92 -0
  267. package/src/providers/model-intents.ts +29 -20
  268. package/src/providers/openai/client.ts +49 -0
  269. package/src/providers/types.ts +2 -0
  270. package/src/runtime/access-request-helper.ts +16 -7
  271. package/src/runtime/auth/credential-service.ts +3 -1
  272. package/src/runtime/auth/route-policy.ts +14 -1
  273. package/src/runtime/btw-sidechain.ts +101 -0
  274. package/src/runtime/channel-reply-delivery.ts +17 -1
  275. package/src/runtime/http-router.ts +3 -1
  276. package/src/runtime/http-server.ts +196 -141
  277. package/src/runtime/http-types.ts +1 -0
  278. package/src/runtime/migrations/vbundle-builder.ts +5 -1
  279. package/src/runtime/routes/access-request-decision.ts +41 -0
  280. package/src/runtime/routes/app-management-routes.ts +6 -3
  281. package/src/runtime/routes/app-routes.ts +7 -3
  282. package/src/runtime/routes/approval-routes.ts +1 -0
  283. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +34 -2
  284. package/src/runtime/routes/attachment-routes.ts +45 -15
  285. package/src/runtime/routes/btw-routes.ts +21 -61
  286. package/src/runtime/routes/conversation-management-routes.ts +68 -0
  287. package/src/runtime/routes/conversation-query-routes.ts +180 -10
  288. package/src/runtime/routes/conversation-routes.ts +222 -28
  289. package/src/runtime/routes/conversation-starter-routes.ts +9 -11
  290. package/src/runtime/routes/diagnostics-routes.ts +1 -0
  291. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +2 -2
  292. package/src/runtime/routes/llm-context-normalization.ts +1199 -0
  293. package/src/runtime/routes/log-export-routes.ts +3 -0
  294. package/src/runtime/routes/memory-item-routes.test.ts +34 -0
  295. package/src/runtime/routes/memory-item-routes.ts +4 -0
  296. package/src/runtime/routes/migration-routes.ts +4 -1
  297. package/src/runtime/routes/oauth-apps.ts +291 -0
  298. package/src/runtime/routes/secret-routes.ts +28 -1
  299. package/src/runtime/routes/settings-routes.ts +14 -0
  300. package/src/runtime/routes/trace-event-routes.ts +4 -1
  301. package/src/schedule/schedule-store.ts +9 -21
  302. package/src/security/secure-keys.ts +21 -0
  303. package/src/signals/bash.ts +1 -1
  304. package/src/swarm/backend-claude-code.ts +3 -6
  305. package/src/telemetry/usage-telemetry-reporter.test.ts +3 -2
  306. package/src/telemetry/usage-telemetry-reporter.ts +3 -1
  307. package/src/tools/AGENTS.md +6 -10
  308. package/src/tools/apps/executors.ts +17 -232
  309. package/src/tools/claude-code/claude-code.ts +2 -3
  310. package/src/tools/credentials/vault.ts +7 -12
  311. package/src/tools/host-filesystem/read.ts +13 -10
  312. package/src/tools/network/__tests__/web-search.test.ts +4 -2
  313. package/src/tools/schedule/list.ts +2 -7
  314. package/src/tools/schema-transforms.ts +5 -0
  315. package/src/tools/shared/filesystem/format-diff.ts +4 -21
  316. package/src/tools/skills/execute.ts +1 -1
  317. package/src/tools/tool-manifest.ts +0 -6
  318. package/src/tools/ui-surface/definitions.ts +2 -2
  319. package/src/util/device-id.ts +28 -5
  320. package/src/util/platform.ts +6 -0
  321. package/src/util/pricing.ts +1 -0
  322. package/src/util/retry.ts +1 -3
  323. package/src/workspace/migrations/002-backfill-installation-id.ts +23 -12
  324. package/src/workspace/migrations/003-seed-device-id.ts +3 -4
  325. package/src/workspace/migrations/006-services-config.ts +5 -0
  326. package/src/workspace/migrations/008-voice-timeout-and-max-steps.ts +12 -0
  327. package/src/workspace/migrations/009-backfill-conversation-disk-view.ts +10 -0
  328. package/src/workspace/migrations/010-app-dir-rename.ts +223 -0
  329. package/src/workspace/migrations/012-rename-conversation-disk-view-dirs.ts +64 -0
  330. package/src/workspace/migrations/013-repair-conversation-disk-view.ts +11 -0
  331. package/src/workspace/migrations/rebuild-conversation-disk-view.ts +186 -0
  332. package/src/workspace/migrations/registry.ts +10 -0
  333. package/src/workspace/top-level-renderer.ts +12 -0
  334. package/src/__tests__/asset-materialize-tool.test.ts +0 -523
  335. package/src/__tests__/asset-search-tool.test.ts +0 -536
  336. package/src/__tests__/fixtures/media-reuse-fixtures.ts +0 -56
  337. package/src/__tests__/media-reuse-story.e2e.test.ts +0 -762
  338. package/src/__tests__/media-visibility-policy.test.ts +0 -190
  339. package/src/config/bundled-skills/app-builder/tools/app-file-edit.ts +0 -14
  340. package/src/config/bundled-skills/app-builder/tools/app-file-list.ts +0 -13
  341. package/src/config/bundled-skills/app-builder/tools/app-file-read.ts +0 -21
  342. package/src/config/bundled-skills/app-builder/tools/app-file-write.ts +0 -14
  343. package/src/config/bundled-skills/app-builder/tools/app-list.ts +0 -13
  344. package/src/config/bundled-skills/app-builder/tools/app-update.ts +0 -23
  345. package/src/daemon/media-visibility-policy.ts +0 -59
  346. package/src/tools/assets/materialize.ts +0 -248
  347. package/src/tools/assets/search.ts +0 -400
@@ -1,17 +1,27 @@
1
1
  /**
2
2
  * Assistant-owned attachment storage.
3
3
  *
4
- * Stores attachments in the local SQLite database with base64-encoded
5
- * data. Provides upload, delete, and message-linkage operations.
4
+ * Attachments uploaded ahead of message persistence are staged in the database.
5
+ * Once linked to a message, the canonical file is materialized directly into
6
+ * that conversation's attachments/ directory and the database row points there.
6
7
  */
7
8
 
8
- import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
9
- import { basename, join } from "node:path";
9
+ import {
10
+ copyFileSync,
11
+ existsSync,
12
+ mkdirSync,
13
+ readFileSync,
14
+ unlinkSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { basename, dirname, extname, join } from "node:path";
10
18
 
11
19
  import { eq } from "drizzle-orm";
12
20
  import { v4 as uuid } from "uuid";
13
21
 
22
+ import { getLogger } from "../util/logger.js";
14
23
  import { getWorkspaceDir } from "../util/platform.js";
24
+ import { getConversationAttachmentsDirPath } from "./conversation-directories.js";
15
25
  import { getDb, rawAll, rawGet, rawRun } from "./db.js";
16
26
  import { attachments, messageAttachments } from "./schema.js";
17
27
 
@@ -44,6 +54,242 @@ function formatBytes(bytes: number): string {
44
54
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
45
55
  }
46
56
 
57
+ function resolveUniqueFilename(dir: string, filename: string): string {
58
+ const sanitized = basename(filename);
59
+ const existingPath = join(dir, sanitized);
60
+ if (!existsSync(existingPath)) return sanitized;
61
+
62
+ const ext = extname(sanitized);
63
+ const base = basename(sanitized, ext);
64
+ let counter = 2;
65
+ let candidate = `${base}-${counter}${ext}`;
66
+ while (existsSync(join(dir, candidate))) {
67
+ counter++;
68
+ candidate = `${base}-${counter}${ext}`;
69
+ }
70
+ return candidate;
71
+ }
72
+
73
+ function computeSizeBytesFromBase64(dataBase64: string): number {
74
+ const padding = dataBase64.endsWith("==")
75
+ ? 2
76
+ : dataBase64.endsWith("=")
77
+ ? 1
78
+ : 0;
79
+ return Math.max(0, Math.floor((dataBase64.length * 3) / 4) - padding);
80
+ }
81
+
82
+ interface AttachmentRow {
83
+ id: string;
84
+ originalFilename: string;
85
+ mimeType: string;
86
+ sizeBytes: number;
87
+ kind: string;
88
+ dataBase64: string;
89
+ contentHash: string | null;
90
+ thumbnailBase64: string | null;
91
+ filePath: string | null;
92
+ createdAt: number;
93
+ sourcePath: string | null;
94
+ }
95
+
96
+ function getAttachmentRow(attachmentId: string): AttachmentRow | null {
97
+ return (
98
+ rawGet<AttachmentRow>(
99
+ `SELECT
100
+ id,
101
+ original_filename AS originalFilename,
102
+ mime_type AS mimeType,
103
+ size_bytes AS sizeBytes,
104
+ kind,
105
+ data_base64 AS dataBase64,
106
+ content_hash AS contentHash,
107
+ thumbnail_base64 AS thumbnailBase64,
108
+ file_path AS filePath,
109
+ created_at AS createdAt,
110
+ source_path AS sourcePath
111
+ FROM attachments
112
+ WHERE id = ?`,
113
+ attachmentId,
114
+ ) ?? null
115
+ );
116
+ }
117
+
118
+ function getMessageConversationContext(
119
+ messageId: string,
120
+ ): { conversationId: string; conversationCreatedAt: number } | null {
121
+ return (
122
+ rawGet<{ conversationId: string; conversationCreatedAt: number }>(
123
+ `SELECT
124
+ m.conversation_id AS conversationId,
125
+ c.created_at AS conversationCreatedAt
126
+ FROM messages m
127
+ JOIN conversations c ON c.id = m.conversation_id
128
+ WHERE m.id = ?`,
129
+ messageId,
130
+ ) ?? null
131
+ );
132
+ }
133
+
134
+ function listLinkedConversationIds(attachmentId: string): string[] {
135
+ return rawAll<{ conversationId: string }>(
136
+ `SELECT DISTINCT m.conversation_id AS conversationId
137
+ FROM message_attachments ma
138
+ JOIN messages m ON m.id = ma.message_id
139
+ WHERE ma.attachment_id = ?`,
140
+ attachmentId,
141
+ ).map((row) => row.conversationId);
142
+ }
143
+
144
+ function cloneAttachmentRow(row: AttachmentRow): AttachmentRow {
145
+ const clonedId = uuid();
146
+ const db = getDb();
147
+ const now = Date.now();
148
+
149
+ db.insert(attachments)
150
+ .values({
151
+ id: clonedId,
152
+ originalFilename: row.originalFilename,
153
+ mimeType: row.mimeType,
154
+ sizeBytes: row.sizeBytes,
155
+ kind: row.kind,
156
+ dataBase64: row.dataBase64,
157
+ contentHash: null,
158
+ thumbnailBase64: row.thumbnailBase64,
159
+ filePath: row.filePath,
160
+ createdAt: now,
161
+ })
162
+ .run();
163
+
164
+ if (row.sourcePath) {
165
+ rawRun(
166
+ `UPDATE attachments SET source_path = ? WHERE id = ?`,
167
+ row.sourcePath,
168
+ clonedId,
169
+ );
170
+ }
171
+
172
+ return {
173
+ ...row,
174
+ id: clonedId,
175
+ createdAt: now,
176
+ };
177
+ }
178
+
179
+ function insertMessageAttachmentLink(
180
+ messageId: string,
181
+ attachmentId: string,
182
+ position: number,
183
+ ): void {
184
+ const db = getDb();
185
+ db.insert(messageAttachments)
186
+ .values({
187
+ id: uuid(),
188
+ messageId,
189
+ attachmentId,
190
+ position,
191
+ createdAt: Date.now(),
192
+ })
193
+ .run();
194
+ }
195
+
196
+ function persistAttachmentFilePath(
197
+ attachmentId: string,
198
+ targetPath: string,
199
+ sourcePath?: string | null,
200
+ ): void {
201
+ if (sourcePath) {
202
+ rawRun(
203
+ `UPDATE attachments
204
+ SET file_path = ?, data_base64 = '', source_path = COALESCE(source_path, ?)
205
+ WHERE id = ?`,
206
+ targetPath,
207
+ sourcePath,
208
+ attachmentId,
209
+ );
210
+ return;
211
+ }
212
+
213
+ rawRun(
214
+ `UPDATE attachments SET file_path = ?, data_base64 = '' WHERE id = ?`,
215
+ targetPath,
216
+ attachmentId,
217
+ );
218
+ }
219
+
220
+ function materializeAttachmentIntoConversation(
221
+ row: AttachmentRow,
222
+ conversationId: string,
223
+ conversationCreatedAt: number,
224
+ ): void {
225
+ const attachDir = getConversationAttachmentsDirPath(
226
+ conversationId,
227
+ conversationCreatedAt,
228
+ );
229
+ mkdirSync(attachDir, { recursive: true });
230
+
231
+ if (
232
+ row.filePath &&
233
+ existsSync(row.filePath) &&
234
+ dirname(row.filePath) === attachDir
235
+ ) {
236
+ if (row.dataBase64) {
237
+ rawRun(`UPDATE attachments SET data_base64 = '' WHERE id = ?`, row.id);
238
+ }
239
+ return;
240
+ }
241
+
242
+ const resolvedName = resolveUniqueFilename(attachDir, row.originalFilename);
243
+ const targetPath = join(attachDir, resolvedName);
244
+
245
+ let sourcePath = row.sourcePath;
246
+ if (row.dataBase64) {
247
+ writeFileSync(targetPath, Buffer.from(row.dataBase64, "base64"));
248
+ } else {
249
+ const readablePath = [row.filePath, row.sourcePath].find(
250
+ (path): path is string => !!path && existsSync(path),
251
+ );
252
+ if (!readablePath) return;
253
+
254
+ if (!sourcePath && readablePath !== row.filePath) {
255
+ sourcePath = readablePath;
256
+ } else if (
257
+ !sourcePath &&
258
+ readablePath === row.filePath &&
259
+ dirname(readablePath) !== attachDir
260
+ ) {
261
+ sourcePath = readablePath;
262
+ }
263
+
264
+ copyFileSync(readablePath, targetPath);
265
+ }
266
+
267
+ persistAttachmentFilePath(row.id, targetPath, sourcePath);
268
+ }
269
+
270
+ function scopeAttachmentToConversation(
271
+ attachmentId: string,
272
+ conversationId: string,
273
+ conversationCreatedAt: number,
274
+ ): string {
275
+ let row = getAttachmentRow(attachmentId);
276
+ if (!row) {
277
+ throw new Error(`Attachment not found: ${attachmentId}`);
278
+ }
279
+
280
+ const linkedConversationIds = listLinkedConversationIds(attachmentId);
281
+ if (linkedConversationIds.some((id) => id !== conversationId)) {
282
+ row = cloneAttachmentRow(row);
283
+ }
284
+
285
+ materializeAttachmentIntoConversation(
286
+ row,
287
+ conversationId,
288
+ conversationCreatedAt,
289
+ );
290
+ return row.id;
291
+ }
292
+
47
293
  // ---------------------------------------------------------------------------
48
294
  // Size and encoding limits
49
295
  // ---------------------------------------------------------------------------
@@ -51,12 +297,9 @@ function formatBytes(bytes: number): string {
51
297
  /** Hard ceiling on a single uploaded attachment (100 MB, matching assistant limits). */
52
298
  export const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
53
299
 
54
- /** Attachments larger than this are stored on disk instead of inline in SQLite. */
55
- export const FILE_BACKED_THRESHOLD_BYTES = 5 * 1024 * 1024;
56
-
57
300
  /**
58
- * Write decoded base64 data to disk under the workspace attachments directory.
59
- * Returns the absolute file path of the written file.
301
+ * Legacy helper kept for historical backfills that still need to materialize
302
+ * old attachment rows from inline base64 data.
60
303
  */
61
304
  export function writeAttachmentToDisk(
62
305
  dataBase64: string,
@@ -117,6 +360,8 @@ const ALLOWED_MIME_TYPES = new Set([
117
360
  "video/mpeg",
118
361
  // Documents
119
362
  "application/pdf",
363
+ "text/rtf",
364
+ "application/rtf",
120
365
  "text/plain",
121
366
  "text/csv",
122
367
  "text/markdown",
@@ -211,14 +456,6 @@ export function validateAttachmentUpload(
211
456
  return { ok: true };
212
457
  }
213
458
 
214
- /**
215
- * Compute a content hash for deduplication. Uses Bun.hash (wyhash) for speed,
216
- * encoded as base-36 for compact storage.
217
- */
218
- function computeContentHash(dataBase64: string): string {
219
- return Bun.hash(dataBase64).toString(36);
220
- }
221
-
222
459
  // ---------------------------------------------------------------------------
223
460
  // File-backed attachment storage (avoids reading large files into memory)
224
461
  // ---------------------------------------------------------------------------
@@ -229,8 +466,7 @@ function computeContentHash(dataBase64: string): string {
229
466
  * normal 100 MB upload limit.
230
467
  *
231
468
  * The file stays on disk; the attachment row stores an empty dataBase64 and
232
- * records the on-disk path in a `file_path` column (added via DB migration
233
- * in 102-alter-table-columns.ts since the Drizzle schema doesn't know about it).
469
+ * records the on-disk path in the `file_path` column.
234
470
  */
235
471
  export function uploadFileBackedAttachment(
236
472
  filename: string,
@@ -241,19 +477,22 @@ export function uploadFileBackedAttachment(
241
477
  const now = Date.now();
242
478
  const kind = classifyKind(mimeType);
243
479
  const id = uuid();
480
+ const db = getDb();
244
481
 
245
- // Use raw SQL since the Drizzle schema doesn't know about the file_path column
246
- rawRun(
247
- `INSERT INTO attachments (id, original_filename, mime_type, size_bytes, kind, data_base64, file_path, created_at)
248
- VALUES (?, ?, ?, ?, ?, '', ?, ?)`,
249
- id,
250
- filename,
251
- mimeType,
252
- sizeBytes,
253
- kind,
254
- filePath,
255
- now,
256
- );
482
+ db.insert(attachments)
483
+ .values({
484
+ id,
485
+ originalFilename: filename,
486
+ mimeType,
487
+ sizeBytes,
488
+ kind,
489
+ dataBase64: "",
490
+ filePath,
491
+ createdAt: now,
492
+ })
493
+ .run();
494
+
495
+ rawRun(`UPDATE attachments SET source_path = ? WHERE id = ?`, filePath, id);
257
496
 
258
497
  return {
259
498
  id,
@@ -268,88 +507,116 @@ export function uploadFileBackedAttachment(
268
507
  }
269
508
 
270
509
  /**
271
- * Returns the file_path for a file-backed attachment, or null if not file-backed.
272
- * Uses raw SQL since file_path is added via DB migration and is not in the Drizzle schema.
510
+ * Returns the file_path for an attachment, or null if not set.
511
+ * Now uses Drizzle since filePath is in the schema.
273
512
  */
274
513
  export function getFilePathForAttachment(attachmentId: string): string | null {
275
- const row = rawGet<{ file_path: string | null }>(
276
- "SELECT file_path FROM attachments WHERE id = ?",
514
+ const db = getDb();
515
+ const row = db
516
+ .select({ filePath: attachments.filePath })
517
+ .from(attachments)
518
+ .where(eq(attachments.id, attachmentId))
519
+ .get();
520
+ return row?.filePath ?? null;
521
+ }
522
+
523
+ /**
524
+ * Returns the source_path (original file path on disk) for an attachment, or null if not set.
525
+ * Uses raw SQL since source_path is added via DB migration and is not in the Drizzle schema.
526
+ */
527
+ export function getSourcePathForAttachment(
528
+ attachmentId: string,
529
+ ): string | null {
530
+ const row = rawGet<{ source_path: string | null }>(
531
+ "SELECT source_path FROM attachments WHERE id = ?",
277
532
  attachmentId,
278
533
  );
279
- return row?.file_path ?? null;
534
+ return row?.source_path ?? null;
280
535
  }
281
536
 
282
537
  /**
283
- * Batch-fetch file_path values for multiple attachment IDs in a single query.
284
- * Returns a Set of attachment IDs that are file-backed (have a non-null file_path).
285
- * Uses raw SQL since file_path is added via runtime migration and is not in the Drizzle schema.
538
+ * Batch-fetch source_path values for multiple attachment IDs in a single query.
539
+ * Returns a Map of attachment ID source_path for attachments that have a non-null source_path.
540
+ * Uses raw SQL since source_path is added via runtime migration and is not in the Drizzle schema.
286
541
  */
287
- export function getFileBackedAttachmentIds(
542
+ export function getSourcePathsForAttachments(
288
543
  attachmentIds: string[],
289
- ): Set<string> {
290
- if (attachmentIds.length === 0) return new Set();
544
+ ): Map<string, string> {
545
+ if (attachmentIds.length === 0) return new Map();
291
546
  const placeholders = attachmentIds.map(() => "?").join(", ");
292
- const rows = rawAll<{ id: string }>(
293
- `SELECT id FROM attachments WHERE id IN (${placeholders}) AND file_path IS NOT NULL`,
547
+ const rows = rawAll<{ id: string; source_path: string }>(
548
+ `SELECT id, source_path FROM attachments WHERE id IN (${placeholders}) AND source_path IS NOT NULL`,
294
549
  ...attachmentIds,
295
550
  );
296
- return new Set(rows.map((r) => r.id));
551
+ return new Map(rows.map((r) => [r.id, r.source_path]));
297
552
  }
298
553
 
299
554
  /**
300
- * Return the raw binary content for an attachment, abstracting over inline
301
- * (base64-in-DB) vs file-backed (on-disk) storage.
302
- *
303
- * For file-backed attachments the bytes are read from the on-disk path;
304
- * for inline attachments the base64 payload is decoded from the DB row.
305
- *
306
- * Returns null if the attachment does not exist.
555
+ * Look up the stored file_path for an attachment by its original source_path.
556
+ * Returns the workspace-internal file path if found, or null otherwise.
557
+ * Useful as a fallback when the original source_path is outside the sandbox.
307
558
  */
308
- export function getAttachmentContent(attachmentId: string): Buffer | null {
309
- const filePath = getFilePathForAttachment(attachmentId);
310
- if (filePath) {
311
- try {
312
- return readFileSync(filePath);
313
- } catch (err: unknown) {
314
- if (err instanceof Error && "code" in err && err.code === "ENOENT") {
315
- return null;
316
- }
317
- throw err;
559
+ export function getFilePathBySourcePath(
560
+ sourcePath: string,
561
+ conversationId: string,
562
+ ): string | null {
563
+ try {
564
+ const row = rawGet<{ file_path: string | null }>(
565
+ `SELECT a.file_path FROM attachments a
566
+ JOIN message_attachments ma ON ma.attachment_id = a.id
567
+ JOIN messages m ON m.id = ma.message_id
568
+ WHERE a.source_path = ? AND m.conversation_id = ?
569
+ ORDER BY a.created_at DESC LIMIT 1`,
570
+ sourcePath,
571
+ conversationId,
572
+ );
573
+ return row?.file_path ?? null;
574
+ } catch (err) {
575
+ // Some test contexts exercise the tool wrapper before attachment tables
576
+ // are initialized. In that case, there is no stored fallback path to use.
577
+ if (err instanceof Error && err.message.includes("no such table")) {
578
+ return null;
318
579
  }
580
+ throw err;
319
581
  }
582
+ }
320
583
 
321
- // Fall back to inline base64 stored in the DB
322
- const db = getDb();
323
- const row = db
324
- .select({ dataBase64: attachments.dataBase64 })
325
- .from(attachments)
326
- .where(eq(attachments.id, attachmentId))
327
- .get();
328
-
584
+ /**
585
+ * Return the raw binary content for an attachment by reading from its
586
+ * on-disk file path.
587
+ *
588
+ * Returns null if the attachment does not exist or the file is missing.
589
+ */
590
+ export function getAttachmentContent(attachmentId: string): Buffer | null {
591
+ const row = getAttachmentRow(attachmentId);
329
592
  if (!row) return null;
330
- return Buffer.from(row.dataBase64, "base64");
593
+
594
+ try {
595
+ if (row.filePath) {
596
+ return readFileSync(row.filePath);
597
+ }
598
+ if (row.dataBase64) {
599
+ return Buffer.from(row.dataBase64, "base64");
600
+ }
601
+ return null;
602
+ } catch (err: unknown) {
603
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
604
+ return null;
605
+ }
606
+ throw err;
607
+ }
331
608
  }
332
609
 
333
- export function uploadAttachment(
334
- filename: string,
335
- mimeType: string,
610
+ function validateAttachmentPayload(
336
611
  dataBase64: string,
337
- ): StoredAttachment {
612
+ options?: { skipSizeLimit?: boolean },
613
+ ): number {
338
614
  if (!isValidBase64(dataBase64)) {
339
615
  throw new AttachmentUploadError("Invalid base64 encoding");
340
616
  }
341
617
 
342
- const padding = dataBase64.endsWith("==")
343
- ? 2
344
- : dataBase64.endsWith("=")
345
- ? 1
346
- : 0;
347
- const sizeBytes = Math.max(
348
- 0,
349
- Math.floor((dataBase64.length * 3) / 4) - padding,
350
- );
351
-
352
- if (sizeBytes > MAX_UPLOAD_BYTES) {
618
+ const sizeBytes = computeSizeBytesFromBase64(dataBase64);
619
+ if (!options?.skipSizeLimit && sizeBytes > MAX_UPLOAD_BYTES) {
353
620
  throw new AttachmentUploadError(
354
621
  `Attachment too large: ${formatBytes(sizeBytes)} exceeds ${formatBytes(
355
622
  MAX_UPLOAD_BYTES,
@@ -357,29 +624,18 @@ export function uploadAttachment(
357
624
  );
358
625
  }
359
626
 
360
- const db = getDb();
361
- const contentHash = computeContentHash(dataBase64);
362
-
363
- // Dedup: if an attachment with the same content already exists, return it
364
- // instead of storing a duplicate.
365
- const existing = db
366
- .select({
367
- id: attachments.id,
368
- originalFilename: attachments.originalFilename,
369
- mimeType: attachments.mimeType,
370
- sizeBytes: attachments.sizeBytes,
371
- kind: attachments.kind,
372
- thumbnailBase64: attachments.thumbnailBase64,
373
- createdAt: attachments.createdAt,
374
- })
375
- .from(attachments)
376
- .where(eq(attachments.contentHash, contentHash))
377
- .get();
627
+ return sizeBytes;
628
+ }
378
629
 
379
- if (existing) {
380
- return existing;
381
- }
630
+ export function uploadAttachment(
631
+ filename: string,
632
+ mimeType: string,
633
+ dataBase64: string,
634
+ sourcePath?: string,
635
+ ): StoredAttachment {
636
+ const sizeBytes = validateAttachmentPayload(dataBase64);
382
637
 
638
+ const db = getDb();
383
639
  const now = Date.now();
384
640
  const kind = classifyKind(mimeType);
385
641
 
@@ -390,12 +646,21 @@ export function uploadAttachment(
390
646
  sizeBytes,
391
647
  kind,
392
648
  dataBase64,
393
- contentHash,
649
+ filePath: null,
650
+ contentHash: null,
394
651
  createdAt: now,
395
652
  };
396
653
 
397
654
  db.insert(attachments).values(record).run();
398
655
 
656
+ if (sourcePath) {
657
+ rawRun(
658
+ `UPDATE attachments SET source_path = ? WHERE id = ?`,
659
+ sourcePath,
660
+ record.id,
661
+ );
662
+ }
663
+
399
664
  return {
400
665
  id: record.id,
401
666
  originalFilename: filename,
@@ -407,6 +672,130 @@ export function uploadAttachment(
407
672
  };
408
673
  }
409
674
 
675
+ export function attachInlineAttachmentToMessage(
676
+ messageId: string,
677
+ position: number,
678
+ filename: string,
679
+ mimeType: string,
680
+ dataBase64: string,
681
+ options?: { sourcePath?: string; skipSizeLimit?: boolean },
682
+ ): StoredAttachment {
683
+ const sizeBytes = validateAttachmentPayload(dataBase64, {
684
+ skipSizeLimit: options?.skipSizeLimit,
685
+ });
686
+ const ctx = getMessageConversationContext(messageId);
687
+ if (!ctx) {
688
+ throw new Error(`Message not found: ${messageId}`);
689
+ }
690
+
691
+ const attachDir = getConversationAttachmentsDirPath(
692
+ ctx.conversationId,
693
+ ctx.conversationCreatedAt,
694
+ );
695
+ mkdirSync(attachDir, { recursive: true });
696
+ const resolvedName = resolveUniqueFilename(attachDir, filename);
697
+ const targetPath = join(attachDir, resolvedName);
698
+ writeFileSync(targetPath, Buffer.from(dataBase64, "base64"));
699
+
700
+ const now = Date.now();
701
+ const id = uuid();
702
+ const kind = classifyKind(mimeType);
703
+ const db = getDb();
704
+
705
+ db.insert(attachments)
706
+ .values({
707
+ id,
708
+ originalFilename: filename,
709
+ mimeType,
710
+ sizeBytes,
711
+ kind,
712
+ dataBase64: "",
713
+ filePath: targetPath,
714
+ contentHash: null,
715
+ createdAt: now,
716
+ })
717
+ .run();
718
+
719
+ if (options?.sourcePath) {
720
+ rawRun(
721
+ `UPDATE attachments SET source_path = ? WHERE id = ?`,
722
+ options.sourcePath,
723
+ id,
724
+ );
725
+ }
726
+
727
+ insertMessageAttachmentLink(messageId, id, position);
728
+
729
+ return {
730
+ id,
731
+ originalFilename: filename,
732
+ mimeType,
733
+ sizeBytes,
734
+ kind,
735
+ thumbnailBase64: null,
736
+ createdAt: now,
737
+ };
738
+ }
739
+
740
+ export function attachFileBackedAttachmentToMessage(
741
+ messageId: string,
742
+ position: number,
743
+ filename: string,
744
+ mimeType: string,
745
+ sourceFilePath: string,
746
+ sizeBytes: number,
747
+ ): StoredAttachment & { filePath: string } {
748
+ const ctx = getMessageConversationContext(messageId);
749
+ if (!ctx) {
750
+ throw new Error(`Message not found: ${messageId}`);
751
+ }
752
+
753
+ const attachDir = getConversationAttachmentsDirPath(
754
+ ctx.conversationId,
755
+ ctx.conversationCreatedAt,
756
+ );
757
+ mkdirSync(attachDir, { recursive: true });
758
+ const resolvedName = resolveUniqueFilename(attachDir, filename);
759
+ const targetPath = join(attachDir, resolvedName);
760
+ copyFileSync(sourceFilePath, targetPath);
761
+
762
+ const now = Date.now();
763
+ const id = uuid();
764
+ const kind = classifyKind(mimeType);
765
+ const db = getDb();
766
+
767
+ db.insert(attachments)
768
+ .values({
769
+ id,
770
+ originalFilename: filename,
771
+ mimeType,
772
+ sizeBytes,
773
+ kind,
774
+ dataBase64: "",
775
+ filePath: targetPath,
776
+ createdAt: now,
777
+ })
778
+ .run();
779
+
780
+ rawRun(
781
+ `UPDATE attachments SET source_path = ? WHERE id = ?`,
782
+ sourceFilePath,
783
+ id,
784
+ );
785
+ insertMessageAttachmentLink(messageId, id, position);
786
+
787
+ return {
788
+ id,
789
+ originalFilename: filename,
790
+ mimeType,
791
+ sizeBytes,
792
+ kind,
793
+ thumbnailBase64: null,
794
+ createdAt: now,
795
+ filePath: targetPath,
796
+ };
797
+ }
798
+
410
799
  /**
411
800
  * Update the thumbnail for an existing attachment.
412
801
  */
@@ -429,16 +818,15 @@ export type DeleteAttachmentResult =
429
818
  export function deleteAttachment(attachmentId: string): DeleteAttachmentResult {
430
819
  const db = getDb();
431
820
  const existing = db
432
- .select({ id: attachments.id })
821
+ .select({ id: attachments.id, filePath: attachments.filePath })
433
822
  .from(attachments)
434
823
  .where(eq(attachments.id, attachmentId))
435
824
  .get();
436
825
 
437
826
  if (!existing) return "not_found";
438
827
 
439
- // With content-hash deduplication, multiple messages may reference the same
440
- // attachment row. Only delete the attachment (and cascade its links) when no
441
- // message_attachments rows still point to it.
828
+ // An attachment row can still be shared by multiple messages inside the same
829
+ // conversation. Only delete it when no remaining links point to the row.
442
830
  const refCount = db
443
831
  .select({ id: messageAttachments.id })
444
832
  .from(messageAttachments)
@@ -448,7 +836,7 @@ export function deleteAttachment(attachmentId: string): DeleteAttachmentResult {
448
836
  if (refCount > 0) return "still_referenced";
449
837
 
450
838
  // Collect file path BEFORE deleting the DB row (the row contains the path reference)
451
- const filePath = getFilePathForAttachment(attachmentId);
839
+ const { filePath } = existing;
452
840
 
453
841
  db.delete(attachments).where(eq(attachments.id, attachmentId)).run();
454
842
 
@@ -466,9 +854,11 @@ export function deleteAttachment(attachmentId: string): DeleteAttachmentResult {
466
854
 
467
855
  export function getAttachmentsByIds(
468
856
  ids: string[],
857
+ options?: { hydrateFileData?: boolean },
469
858
  ): Array<StoredAttachment & { dataBase64: string }> {
470
859
  if (ids.length === 0) return [];
471
860
  const db = getDb();
861
+ const hydrateFileData = options?.hydrateFileData ?? false;
472
862
  const results: Array<StoredAttachment & { dataBase64: string }> = [];
473
863
  for (const id of ids) {
474
864
  const row = db
@@ -477,6 +867,21 @@ export function getAttachmentsByIds(
477
867
  .where(eq(attachments.id, id))
478
868
  .get();
479
869
  if (row) {
870
+ // File-backed attachments store data on disk with dataBase64 = "".
871
+ // Only hydrate base64 from disk when callers explicitly opt in,
872
+ // to avoid eagerly reading large files for validation-only paths.
873
+ let dataBase64 = row.dataBase64;
874
+ if (hydrateFileData && !dataBase64 && row.filePath) {
875
+ try {
876
+ dataBase64 = readFileSync(row.filePath).toString("base64");
877
+ } catch (err: unknown) {
878
+ const log = getLogger("attachments-store");
879
+ log.warn(
880
+ `Failed to read file-backed attachment ${id} from ${row.filePath}: ${err instanceof Error ? err.message : String(err)}`,
881
+ );
882
+ dataBase64 = "";
883
+ }
884
+ }
480
885
  results.push({
481
886
  id: row.id,
482
887
  originalFilename: row.originalFilename,
@@ -484,7 +889,7 @@ export function getAttachmentsByIds(
484
889
  sizeBytes: row.sizeBytes,
485
890
  kind: row.kind,
486
891
  thumbnailBase64: row.thumbnailBase64,
487
- dataBase64: row.dataBase64,
892
+ dataBase64,
488
893
  createdAt: row.createdAt,
489
894
  });
490
895
  }
@@ -496,17 +901,19 @@ export function linkAttachmentToMessage(
496
901
  messageId: string,
497
902
  attachmentId: string,
498
903
  position: number,
499
- ): void {
500
- const db = getDb();
501
- db.insert(messageAttachments)
502
- .values({
503
- id: uuid(),
504
- messageId,
505
- attachmentId,
506
- position,
507
- createdAt: Date.now(),
508
- })
509
- .run();
904
+ ): string {
905
+ const ctx = getMessageConversationContext(messageId);
906
+ if (!ctx) {
907
+ throw new Error(`Message not found: ${messageId}`);
908
+ }
909
+
910
+ const scopedAttachmentId = scopeAttachmentToConversation(
911
+ attachmentId,
912
+ ctx.conversationId,
913
+ ctx.conversationCreatedAt,
914
+ );
915
+ insertMessageAttachmentLink(messageId, scopedAttachmentId, position);
916
+ return scopedAttachmentId;
510
917
  }
511
918
 
512
919
  /**
@@ -531,7 +938,7 @@ export function getAttachmentsForMessage(
531
938
  const ids = links
532
939
  .map((l) => l.attachmentId)
533
940
  .filter((id): id is string => id != null);
534
- return getAttachmentsByIds(ids);
941
+ return getAttachmentsByIds(ids, { hydrateFileData: true });
535
942
  }
536
943
 
537
944
  /**
@@ -574,13 +981,28 @@ export function getAttachmentMetadataForMessage(
574
981
  return results;
575
982
  }
576
983
 
984
+ /**
985
+ * Lightweight existence check — queries only the attachment ID column
986
+ * without reading file contents from disk.
987
+ */
988
+ export function attachmentExists(attachmentId: string): boolean {
989
+ const db = getDb();
990
+ const row = db
991
+ .select({ id: attachments.id })
992
+ .from(attachments)
993
+ .where(eq(attachments.id, attachmentId))
994
+ .get();
995
+ return !!row;
996
+ }
997
+
577
998
  /**
578
999
  * Retrieve a single attachment by ID.
579
1000
  */
580
1001
  export function getAttachmentById(
581
1002
  attachmentId: string,
1003
+ options?: { hydrateFileData?: boolean },
582
1004
  ): (StoredAttachment & { dataBase64: string }) | null {
583
- const results = getAttachmentsByIds([attachmentId]);
1005
+ const results = getAttachmentsByIds([attachmentId], options);
584
1006
  return results[0] ?? null;
585
1007
  }
586
1008
 
@@ -595,6 +1017,8 @@ export function getAttachmentById(
595
1017
  export function deleteOrphanAttachments(candidateIds: string[]): number {
596
1018
  if (candidateIds.length === 0) return 0;
597
1019
 
1020
+ const db = getDb();
1021
+
598
1022
  // Identify truly orphaned attachment IDs first (not referenced by any message)
599
1023
  const placeholders = candidateIds.map(() => "?").join(", ");
600
1024
  const orphanIds = rawAll<{ id: string }>(
@@ -604,11 +1028,15 @@ export function deleteOrphanAttachments(candidateIds: string[]): number {
604
1028
 
605
1029
  if (orphanIds.length === 0) return 0;
606
1030
 
607
- // Collect file paths BEFORE deleting the DB rows (the rows contain the path reference)
1031
+ // Collect file paths BEFORE deleting the DB rows via Drizzle
608
1032
  const orphanFilePaths: string[] = [];
609
1033
  for (const id of orphanIds) {
610
- const filePath = getFilePathForAttachment(id);
611
- if (filePath) orphanFilePaths.push(filePath);
1034
+ const row = db
1035
+ .select({ filePath: attachments.filePath })
1036
+ .from(attachments)
1037
+ .where(eq(attachments.id, id))
1038
+ .get();
1039
+ if (row?.filePath) orphanFilePaths.push(row.filePath);
612
1040
  }
613
1041
 
614
1042
  // Delete the orphaned DB rows first — if this fails, the on-disk files