@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.
- package/ARCHITECTURE.md +54 -54
- package/docs/architecture/integrations.md +62 -67
- package/docs/credential-execution-service.md +3 -3
- package/package.json +1 -1
- package/src/__tests__/agent-loop.test.ts +111 -0
- package/src/__tests__/always-loaded-tools-guard.test.ts +3 -4
- package/src/__tests__/app-builder-tool-scripts.test.ts +13 -151
- package/src/__tests__/app-dir-path-guard.test.ts +78 -0
- package/src/__tests__/app-executors.test.ts +1 -291
- package/src/__tests__/app-git-history.test.ts +4 -4
- package/src/__tests__/app-routes-csp.test.ts +1 -0
- package/src/__tests__/app-store-dir-names.test.ts +426 -0
- package/src/__tests__/attachments-store.test.ts +169 -21
- package/src/__tests__/attachments.test.ts +115 -1
- package/src/__tests__/btw-routes.test.ts +1 -0
- package/src/__tests__/canonical-guardian-store.test.ts +38 -0
- package/src/__tests__/channel-reply-delivery.test.ts +55 -0
- package/src/__tests__/checker.test.ts +54 -0
- package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
- package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
- package/src/__tests__/compaction.benchmark.test.ts +2 -1
- package/src/__tests__/config-schema-cmd.test.ts +68 -21
- package/src/__tests__/config-schema.test.ts +1 -1
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +149 -5
- package/src/__tests__/conversation-agent-loop.test.ts +290 -2
- package/src/__tests__/conversation-attachments.test.ts +17 -19
- package/src/__tests__/conversation-disk-view-integration.test.ts +277 -0
- package/src/__tests__/conversation-disk-view.test.ts +810 -0
- package/src/__tests__/conversation-error.test.ts +1 -1
- package/src/__tests__/conversation-fork-crud.test.ts +551 -0
- package/src/__tests__/conversation-fork-route.test.ts +386 -0
- package/src/__tests__/conversation-history-web-search.test.ts +1 -1
- package/src/__tests__/conversation-key-store-disk-view.test.ts +130 -0
- package/src/__tests__/conversation-media-retry.test.ts +8 -2
- package/src/__tests__/conversation-queue.test.ts +36 -1
- package/src/__tests__/conversation-routes-disk-view.test.ts +439 -0
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +2 -2
- package/src/__tests__/conversation-routes-slash-commands.test.ts +2 -7
- package/src/__tests__/conversation-runtime-assembly.test.ts +17 -2
- package/src/__tests__/conversation-skill-tools.test.ts +4 -9
- package/src/__tests__/conversation-slash-commands.test.ts +149 -0
- package/src/__tests__/conversation-store.test.ts +24 -21
- package/src/__tests__/conversation-surfaces-state-update.test.ts +246 -0
- package/src/__tests__/conversation-surfaces-task-progress.test.ts +1 -0
- package/src/__tests__/conversation-title-service.test.ts +137 -0
- package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +25 -315
- package/src/__tests__/conversation-tool-setup-memory-scope.test.ts +1 -0
- package/src/__tests__/conversation-tool-setup-side-effect-flag.test.ts +1 -0
- package/src/__tests__/conversation-workspace-cache-state.test.ts +44 -2
- package/src/__tests__/conversation-workspace-injection.test.ts +11 -0
- package/src/__tests__/credential-security-invariants.test.ts +3 -0
- package/src/__tests__/credential-vault-unit.test.ts +5 -10
- package/src/__tests__/cu-unified-flow.test.ts +1 -0
- package/src/__tests__/db-conversation-fork-lineage-migration.test.ts +241 -0
- package/src/__tests__/db-llm-request-log-provider-migration.test.ts +214 -0
- package/src/__tests__/diagnostics-export.test.ts +70 -1
- package/src/__tests__/first-greeting.test.ts +80 -0
- package/src/__tests__/gateway-only-guard.test.ts +1 -0
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +3 -7
- package/src/__tests__/history-repair.test.ts +32 -10
- package/src/__tests__/http-conversation-lineage.test.ts +251 -0
- package/src/__tests__/image-source-path-reinject.test.ts +136 -0
- package/src/__tests__/llm-context-normalization.test.ts +1116 -0
- package/src/__tests__/llm-context-route-provider.test.ts +217 -0
- package/src/__tests__/llm-request-log-turn-query.test.ts +270 -0
- package/src/__tests__/media-generate-image.test.ts +47 -94
- package/src/__tests__/memory-lifecycle-e2e.test.ts +3 -1
- package/src/__tests__/memory-recall-quality.test.ts +5 -5
- package/src/__tests__/migration-cross-version-compatibility.test.ts +4 -1
- package/src/__tests__/migration-export-http.test.ts +3 -1
- package/src/__tests__/migration-import-commit-http.test.ts +18 -4
- package/src/__tests__/migration-import-preflight-http.test.ts +1 -3
- package/src/__tests__/mime-builder.test.ts +3 -2
- package/src/__tests__/non-member-access-request.test.ts +12 -1
- package/src/__tests__/notification-decision-identity.test.ts +52 -0
- package/src/__tests__/oauth-apps-routes.test.ts +103 -0
- package/src/__tests__/oauth-store.test.ts +115 -0
- package/src/__tests__/provider-error-scenarios.test.ts +1 -3
- package/src/__tests__/provider-failover-actual-provider.test.ts +66 -0
- package/src/__tests__/recording-handler.test.ts +17 -0
- package/src/__tests__/registry.test.ts +3 -8
- package/src/__tests__/relay-server.test.ts +1 -1
- package/src/__tests__/runtime-attachment-metadata.test.ts +7 -3
- package/src/__tests__/schema-transforms.test.ts +165 -5
- package/src/__tests__/server-history-render.test.ts +2 -2
- package/src/__tests__/slack-app-setup-skill-regression.test.ts +3 -1
- package/src/__tests__/slack-inbound-verification.test.ts +2 -2
- package/src/__tests__/starter-task-flow.test.ts +1 -0
- package/src/__tests__/suggestion-routes.test.ts +443 -0
- package/src/__tests__/swarm-conversation-integration.test.ts +1 -0
- package/src/__tests__/swarm-recursion.test.ts +1 -0
- package/src/__tests__/swarm-tool.test.ts +1 -0
- package/src/__tests__/tool-execution-abort-cleanup.test.ts +1 -0
- package/src/__tests__/tool-preview-lifecycle.test.ts +32 -5
- package/src/__tests__/top-level-renderer.test.ts +22 -0
- package/src/__tests__/turn-boundary-resolution.test.ts +243 -0
- package/src/__tests__/web-fetch.test.ts +6 -2
- package/src/__tests__/workspace-migration-006-services-config.test.ts +335 -0
- package/src/__tests__/workspace-migration-007-web-search-provider-rename.test.ts +312 -0
- package/src/__tests__/workspace-migration-009-backfill-conversation-disk-view.test.ts +278 -0
- package/src/__tests__/workspace-migration-010-app-dir-rename.test.ts +275 -0
- package/src/__tests__/workspace-migration-012-rename-conversation-disk-view-dirs.test.ts +77 -0
- package/src/__tests__/workspace-migration-013-repair-conversation-disk-view.test.ts +401 -0
- package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +328 -0
- package/src/__tests__/workspace-migration-seed-device-id.test.ts +6 -10
- package/src/agent/attachments.ts +27 -1
- package/src/agent/loop.ts +29 -1
- package/src/avatar/traits-png-sync.ts +80 -25
- package/src/bundler/app-bundler.ts +4 -4
- package/src/calls/call-domain.ts +1 -0
- package/src/calls/voice-session-bridge.ts +1 -0
- package/src/cli/commands/auth.ts +92 -0
- package/src/cli/commands/avatar.ts +7 -6
- package/src/cli/commands/config.ts +2 -0
- package/src/cli/commands/oauth/providers.ts +29 -0
- package/src/cli/program.ts +12 -0
- package/src/cli.ts +15 -48
- package/src/config/bundled-skills/app-builder/SKILL.md +103 -28
- package/src/config/bundled-skills/app-builder/TOOLS.json +5 -199
- package/src/config/bundled-skills/app-builder/tools/{app-query.ts → app-refresh.ts} +2 -2
- package/src/config/bundled-skills/contacts/tools/google-contacts.ts +2 -3
- package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +6 -9
- package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +4 -6
- package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +2 -3
- package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +2 -3
- package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +2 -3
- package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +2 -3
- package/src/config/bundled-skills/gmail/tools/gmail-label.ts +4 -6
- package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +2 -3
- package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +2 -3
- package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +2 -3
- package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +2 -3
- package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +2 -3
- package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +2 -3
- package/src/config/bundled-skills/google-calendar/tools/shared.ts +1 -1
- package/src/config/bundled-skills/image-studio/SKILL.md +2 -2
- package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
- package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +45 -72
- package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +2 -2
- package/src/config/bundled-skills/messaging/tools/shared.ts +1 -1
- package/src/config/bundled-skills/settings/tools/voice-config-update.ts +19 -3
- package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
- package/src/config/bundled-skills/slack/tools/shared.ts +19 -4
- package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +2 -3
- package/src/config/bundled-skills/transcribe/SKILL.md +1 -1
- package/src/config/bundled-skills/transcribe/TOOLS.json +2 -6
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +19 -83
- package/src/config/bundled-tool-registry.ts +2 -14
- package/src/config/feature-flag-registry.json +8 -0
- package/src/config/loader.ts +64 -0
- package/src/config/raw-config-utils.ts +30 -0
- package/src/config/schema-utils.ts +28 -7
- package/src/config/schema.ts +8 -0
- package/src/config/schemas/elevenlabs.ts +18 -0
- package/src/config/schemas/memory-lifecycle.ts +4 -2
- package/src/config/schemas/memory-storage.ts +1 -1
- package/src/config/schemas/services.ts +8 -6
- package/src/contacts/contact-store.ts +13 -6
- package/src/contacts/contacts-write.ts +0 -1
- package/src/context/window-manager.ts +13 -2
- package/src/daemon/conversation-agent-loop-handlers.ts +48 -7
- package/src/daemon/conversation-agent-loop.ts +56 -19
- package/src/daemon/conversation-attachments.ts +18 -36
- package/src/daemon/conversation-error.ts +2 -1
- package/src/daemon/conversation-history.ts +18 -4
- package/src/daemon/conversation-lifecycle.ts +39 -15
- package/src/daemon/conversation-messaging.ts +70 -26
- package/src/daemon/conversation-process.ts +58 -34
- package/src/daemon/conversation-runtime-assembly.ts +21 -38
- package/src/daemon/conversation-slash.ts +121 -256
- package/src/daemon/conversation-surfaces.ts +143 -20
- package/src/daemon/conversation-tool-setup.ts +0 -6
- package/src/daemon/conversation-workspace.ts +21 -1
- package/src/daemon/conversation.ts +51 -29
- package/src/daemon/first-greeting.ts +35 -0
- package/src/daemon/handlers/config-embeddings.ts +148 -0
- package/src/daemon/handlers/config-model.ts +71 -26
- package/src/daemon/handlers/conversations.ts +0 -23
- package/src/daemon/handlers/recording.ts +26 -21
- package/src/daemon/host-cu-proxy.ts +2 -2
- package/src/daemon/lifecycle.ts +106 -64
- package/src/daemon/message-protocol.ts +3 -0
- package/src/daemon/message-types/conversations.ts +19 -0
- package/src/daemon/message-types/messages.ts +1 -0
- package/src/daemon/message-types/shared.ts +2 -0
- package/src/daemon/message-types/surfaces.ts +2 -0
- package/src/daemon/message-types/upgrades.ts +23 -0
- package/src/daemon/server.ts +83 -12
- package/src/daemon/shutdown-handlers.ts +8 -5
- package/src/daemon/startup-error.ts +9 -0
- package/src/daemon/tool-side-effects.ts +11 -28
- package/src/events/tool-permission-telemetry-listener.ts +1 -3
- package/src/instrument.ts +0 -4
- package/src/media/app-icon-generator.ts +2 -2
- package/src/memory/app-git-service.ts +28 -16
- package/src/memory/app-store.ts +230 -41
- package/src/memory/attachments-store.ts +558 -130
- package/src/memory/conversation-attention-store.ts +70 -0
- package/src/memory/conversation-crud.ts +442 -3
- package/src/memory/conversation-directories.ts +125 -0
- package/src/memory/conversation-disk-view.ts +390 -0
- package/src/memory/conversation-key-store.ts +17 -5
- package/src/memory/conversation-queries.ts +5 -1
- package/src/memory/conversation-title-service.ts +21 -49
- package/src/memory/db-init.ts +28 -0
- package/src/memory/embedding-backend.ts +42 -53
- package/src/memory/embedding-gemini.test.ts +4 -4
- package/src/memory/embedding-local.ts +1 -3
- package/src/memory/embedding-ollama.ts +1 -3
- package/src/memory/embedding-openai.ts +1 -3
- package/src/memory/indexer.ts +9 -7
- package/src/memory/items-extractor.ts +42 -13
- package/src/memory/job-handlers/conversation-starters.ts +6 -1
- package/src/memory/job-handlers/embedding.test.ts +1 -4
- package/src/memory/llm-request-log-store.ts +100 -1
- package/src/memory/migrations/102-alter-table-columns.ts +5 -0
- package/src/memory/migrations/146-schedule-oneshot-routing.ts +3 -3
- package/src/memory/migrations/147-migrate-reminders-to-schedules.ts +66 -70
- package/src/memory/migrations/148-drop-reminders-table.ts +5 -9
- package/src/memory/migrations/160-drop-loopback-port-column.ts +1 -3
- package/src/memory/migrations/174-rename-thread-starters-table.ts +0 -7
- package/src/memory/migrations/178-oauth-providers-managed-service-config-key.ts +15 -0
- package/src/memory/migrations/179-llm-request-log-message-id.ts +16 -0
- package/src/memory/migrations/180-backfill-inline-attachments-to-disk.ts +66 -0
- package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +46 -0
- package/src/memory/migrations/182-oauth-providers-display-metadata.ts +20 -0
- package/src/memory/migrations/183-add-conversation-fork-lineage.ts +22 -0
- package/src/memory/migrations/184-llm-request-log-provider.ts +12 -0
- package/src/memory/migrations/index.ts +7 -0
- package/src/memory/migrations/registry.ts +13 -0
- package/src/memory/retriever.test.ts +601 -2
- package/src/memory/retriever.ts +85 -9
- package/src/memory/schema/conversations.ts +6 -0
- package/src/memory/schema/infrastructure.ts +13 -7
- package/src/memory/schema/oauth.ts +6 -0
- package/src/messaging/providers/gmail/mime-builder.ts +3 -1
- package/src/notifications/copy-composer.ts +26 -0
- package/src/notifications/decision-engine.ts +14 -1
- package/src/notifications/emit-signal.ts +1 -1
- package/src/notifications/signal.ts +36 -0
- package/src/oauth/byo-connection.test.ts +1 -45
- package/src/oauth/byo-connection.ts +2 -8
- package/src/oauth/connect-orchestrator.ts +15 -11
- package/src/oauth/connection-resolver.test.ts +191 -0
- package/src/oauth/connection-resolver.ts +66 -38
- package/src/oauth/connection.ts +0 -1
- package/src/oauth/oauth-store.ts +97 -47
- package/src/oauth/platform-connection.test.ts +0 -1
- package/src/oauth/platform-connection.ts +11 -3
- package/src/oauth/seed-providers.ts +78 -3
- package/src/oauth/token-persistence.ts +16 -10
- package/src/permissions/checker.ts +71 -8
- package/src/prompts/templates/BOOTSTRAP.md +2 -0
- package/src/providers/anthropic/client.ts +8 -1
- package/src/providers/failover.ts +4 -1
- package/src/providers/gemini/client.ts +50 -0
- package/src/providers/model-catalog.ts +92 -0
- package/src/providers/model-intents.ts +29 -20
- package/src/providers/openai/client.ts +49 -0
- package/src/providers/types.ts +2 -0
- package/src/runtime/access-request-helper.ts +16 -7
- package/src/runtime/auth/credential-service.ts +3 -1
- package/src/runtime/auth/route-policy.ts +14 -1
- package/src/runtime/btw-sidechain.ts +101 -0
- package/src/runtime/channel-reply-delivery.ts +17 -1
- package/src/runtime/http-router.ts +3 -1
- package/src/runtime/http-server.ts +196 -141
- package/src/runtime/http-types.ts +1 -0
- package/src/runtime/migrations/vbundle-builder.ts +5 -1
- package/src/runtime/routes/access-request-decision.ts +41 -0
- package/src/runtime/routes/app-management-routes.ts +6 -3
- package/src/runtime/routes/app-routes.ts +7 -3
- package/src/runtime/routes/approval-routes.ts +1 -0
- package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +34 -2
- package/src/runtime/routes/attachment-routes.ts +45 -15
- package/src/runtime/routes/btw-routes.ts +21 -61
- package/src/runtime/routes/conversation-management-routes.ts +68 -0
- package/src/runtime/routes/conversation-query-routes.ts +180 -10
- package/src/runtime/routes/conversation-routes.ts +222 -28
- package/src/runtime/routes/conversation-starter-routes.ts +9 -11
- package/src/runtime/routes/diagnostics-routes.ts +1 -0
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +2 -2
- package/src/runtime/routes/llm-context-normalization.ts +1199 -0
- package/src/runtime/routes/log-export-routes.ts +3 -0
- package/src/runtime/routes/memory-item-routes.test.ts +34 -0
- package/src/runtime/routes/memory-item-routes.ts +4 -0
- package/src/runtime/routes/migration-routes.ts +4 -1
- package/src/runtime/routes/oauth-apps.ts +291 -0
- package/src/runtime/routes/secret-routes.ts +28 -1
- package/src/runtime/routes/settings-routes.ts +14 -0
- package/src/runtime/routes/trace-event-routes.ts +4 -1
- package/src/schedule/schedule-store.ts +9 -21
- package/src/security/secure-keys.ts +21 -0
- package/src/signals/bash.ts +1 -1
- package/src/swarm/backend-claude-code.ts +3 -6
- package/src/telemetry/usage-telemetry-reporter.test.ts +3 -2
- package/src/telemetry/usage-telemetry-reporter.ts +3 -1
- package/src/tools/AGENTS.md +6 -10
- package/src/tools/apps/executors.ts +17 -232
- package/src/tools/claude-code/claude-code.ts +2 -3
- package/src/tools/credentials/vault.ts +7 -12
- package/src/tools/host-filesystem/read.ts +13 -10
- package/src/tools/network/__tests__/web-search.test.ts +4 -2
- package/src/tools/schedule/list.ts +2 -7
- package/src/tools/schema-transforms.ts +5 -0
- package/src/tools/shared/filesystem/format-diff.ts +2 -7
- package/src/tools/skills/execute.ts +1 -1
- package/src/tools/tool-manifest.ts +0 -6
- package/src/tools/ui-surface/definitions.ts +2 -2
- package/src/util/device-id.ts +28 -5
- package/src/util/platform.ts +6 -0
- package/src/util/pricing.ts +1 -0
- package/src/util/retry.ts +1 -3
- package/src/workspace/migrations/002-backfill-installation-id.ts +23 -12
- package/src/workspace/migrations/003-seed-device-id.ts +3 -4
- package/src/workspace/migrations/006-services-config.ts +5 -0
- package/src/workspace/migrations/008-voice-timeout-and-max-steps.ts +12 -0
- package/src/workspace/migrations/009-backfill-conversation-disk-view.ts +10 -0
- package/src/workspace/migrations/010-app-dir-rename.ts +223 -0
- package/src/workspace/migrations/012-rename-conversation-disk-view-dirs.ts +64 -0
- package/src/workspace/migrations/013-repair-conversation-disk-view.ts +11 -0
- package/src/workspace/migrations/rebuild-conversation-disk-view.ts +186 -0
- package/src/workspace/migrations/registry.ts +10 -0
- package/src/workspace/top-level-renderer.ts +12 -0
- package/src/__tests__/asset-materialize-tool.test.ts +0 -523
- package/src/__tests__/asset-search-tool.test.ts +0 -536
- package/src/__tests__/fixtures/media-reuse-fixtures.ts +0 -56
- package/src/__tests__/media-reuse-story.e2e.test.ts +0 -762
- package/src/__tests__/media-visibility-policy.test.ts +0 -190
- package/src/config/bundled-skills/app-builder/tools/app-file-edit.ts +0 -14
- package/src/config/bundled-skills/app-builder/tools/app-file-list.ts +0 -13
- package/src/config/bundled-skills/app-builder/tools/app-file-read.ts +0 -21
- package/src/config/bundled-skills/app-builder/tools/app-file-write.ts +0 -14
- package/src/config/bundled-skills/app-builder/tools/app-list.ts +0 -13
- package/src/config/bundled-skills/app-builder/tools/app-update.ts +0 -23
- package/src/daemon/media-visibility-policy.ts +0 -59
- package/src/tools/assets/materialize.ts +0 -248
- package/src/tools/assets/search.ts +0 -400
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Assistant-owned attachment storage.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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 {
|
|
9
|
-
|
|
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
|
-
*
|
|
59
|
-
*
|
|
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
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
272
|
-
*
|
|
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
|
|
276
|
-
|
|
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?.
|
|
534
|
+
return row?.source_path ?? null;
|
|
280
535
|
}
|
|
281
536
|
|
|
282
537
|
/**
|
|
283
|
-
* Batch-fetch
|
|
284
|
-
* Returns a
|
|
285
|
-
* Uses raw SQL since
|
|
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
|
|
542
|
+
export function getSourcePathsForAttachments(
|
|
288
543
|
attachmentIds: string[],
|
|
289
|
-
):
|
|
290
|
-
if (attachmentIds.length === 0) return new
|
|
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
|
|
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
|
|
551
|
+
return new Map(rows.map((r) => [r.id, r.source_path]));
|
|
297
552
|
}
|
|
298
553
|
|
|
299
554
|
/**
|
|
300
|
-
*
|
|
301
|
-
*
|
|
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
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
-
|
|
334
|
-
filename: string,
|
|
335
|
-
mimeType: string,
|
|
610
|
+
function validateAttachmentPayload(
|
|
336
611
|
dataBase64: string,
|
|
337
|
-
|
|
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
|
|
343
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
440
|
-
//
|
|
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 =
|
|
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
|
|
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
|
-
):
|
|
500
|
-
const
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
|
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
|
|
611
|
-
|
|
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
|