@tangle-network/agent-app 0.44.28 → 0.44.30

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 (50) hide show
  1. package/dist/assistant/index.d.ts +8 -6
  2. package/dist/assistant/index.js +6 -3
  3. package/dist/assistant/index.js.map +1 -1
  4. package/dist/{attachment-validation-Dvc_Livy.d.ts → attachment-validation-CNkH91Gs.d.ts} +1 -1
  5. package/dist/chat-react/index.d.ts +192 -0
  6. package/dist/chat-react/index.js +179 -0
  7. package/dist/chat-react/index.js.map +1 -0
  8. package/dist/chat-routes/index.d.ts +7 -6
  9. package/dist/chat-routes/index.js +13 -9
  10. package/dist/chat-routes/index.js.map +1 -1
  11. package/dist/chat-store/index.d.ts +6 -5
  12. package/dist/chat-store/index.js +2 -1
  13. package/dist/chat-store/index.js.map +1 -1
  14. package/dist/chunk-4OO7P3ZF.js +292 -0
  15. package/dist/chunk-4OO7P3ZF.js.map +1 -0
  16. package/dist/chunk-4PUMUTLU.js +158 -0
  17. package/dist/chunk-4PUMUTLU.js.map +1 -0
  18. package/dist/{chunk-7775L5NN.js → chunk-BAC2B2KI.js} +170 -7
  19. package/dist/chunk-BAC2B2KI.js.map +1 -0
  20. package/dist/{chunk-PRKSYTMQ.js → chunk-GCH3BUAZ.js} +31 -317
  21. package/dist/chunk-GCH3BUAZ.js.map +1 -0
  22. package/dist/{chunk-BI6NKSO4.js → chunk-IYLJS6VW.js} +3 -84
  23. package/dist/chunk-IYLJS6VW.js.map +1 -0
  24. package/dist/chunk-QY4BRKRJ.js +84 -0
  25. package/dist/chunk-QY4BRKRJ.js.map +1 -0
  26. package/dist/{chunk-C3SRFZGL.js → chunk-QYAQGCHF.js} +2 -154
  27. package/dist/chunk-QYAQGCHF.js.map +1 -0
  28. package/dist/{contract-CEewO6DI.d.ts → contract-CQNvv5th.d.ts} +1 -1
  29. package/dist/interactions/index.d.ts +2 -2
  30. package/dist/{parts-fyPPdDdK.d.ts → parts-7fbe2rj8.d.ts} +4 -274
  31. package/dist/{queue-vRI0Qx3X.d.ts → queue-VTBA5ONX.d.ts} +1 -1
  32. package/dist/sandbox/index.d.ts +253 -1
  33. package/dist/sandbox/index.js +9 -1
  34. package/dist/stream/index.d.ts +3 -3
  35. package/dist/{stream-normalizer-DnuqkZvw.d.ts → stream-normalizer-CnPnMaTp.d.ts} +1 -1
  36. package/dist/teams/index.js +5 -5
  37. package/dist/teams/invitations-api.js +4 -4
  38. package/dist/teams-react/index.js +3 -3
  39. package/dist/{types-DB82fktc.d.ts → types-CCeYywdS.d.ts} +1 -1
  40. package/dist/use-file-mentions-E6a7_cbH.d.ts +98 -0
  41. package/dist/web-react/index.d.ts +13 -104
  42. package/dist/web-react/index.js +17 -12
  43. package/dist/wire-DSp4LzEE.d.ts +272 -0
  44. package/dist/work-product/index.d.ts +3 -3
  45. package/dist/work-product-react/index.d.ts +1 -1
  46. package/package.json +6 -1
  47. package/dist/chunk-7775L5NN.js.map +0 -1
  48. package/dist/chunk-BI6NKSO4.js.map +0 -1
  49. package/dist/chunk-C3SRFZGL.js.map +0 -1
  50. package/dist/chunk-PRKSYTMQ.js.map +0 -1
@@ -1,13 +1,14 @@
1
- import { C as ChatMessagePart } from '../parts-fyPPdDdK.js';
2
- export { a as ChatAttachmentKind, b as ChatAttachmentPart, c as ChatFilePart, d as ChatImagePart, e as ChatInteractionPart, f as ChatMentionKind, g as ChatMentionPart, h as ChatNoticePart, i as ChatPartTime, j as ChatPlanPart, k as ChatReasoningPart, l as ChatStepFinishPart, m as ChatStepStartPart, n as ChatSubtaskPart, o as ChatTextPart, p as ChatToolPart, q as ChatToolState, r as ChatToolStatus, s as ChatUsageTokens, t as ChatWorkProductPart, D as DEFAULT_ATTACHMENT_PROMPT_HEADER, S as StorableHarnessPartKind, u as attachmentInputToPart, v as attachmentKindForMime, w as attachmentPartsFromMessageParts, x as buildAttachmentPromptBlock, y as historyContentWithAttachments, z as isChatAttachmentPart, A as isChatInteractionPart, B as isChatMentionPart, E as isChatPlanPart, F as isChatStepFinishPart, G as isChatTextPart, H as isChatToolPart, I as isChatWorkProductPart, J as mentionInputToPart, K as mentionPartsFromMessageParts, L as toChatMessageParts } from '../parts-fyPPdDdK.js';
1
+ import { C as ChatMessagePart } from '../parts-7fbe2rj8.js';
2
+ export { a as ChatAttachmentPart, b as ChatFilePart, c as ChatImagePart, d as ChatInteractionPart, e as ChatMentionPart, f as ChatNoticePart, g as ChatPartTime, h as ChatPlanPart, i as ChatReasoningPart, j as ChatStepFinishPart, k as ChatStepStartPart, l as ChatSubtaskPart, m as ChatTextPart, n as ChatToolPart, o as ChatToolState, p as ChatToolStatus, q as ChatUsageTokens, r as ChatWorkProductPart, D as DEFAULT_ATTACHMENT_PROMPT_HEADER, S as StorableHarnessPartKind, s as attachmentInputToPart, t as attachmentKindForMime, u as attachmentPartsFromMessageParts, v as buildAttachmentPromptBlock, w as historyContentWithAttachments, x as isChatAttachmentPart, y as isChatInteractionPart, z as isChatMentionPart, A as isChatPlanPart, B as isChatStepFinishPart, E as isChatTextPart, F as isChatToolPart, G as isChatWorkProductPart, H as mentionInputToPart, I as mentionPartsFromMessageParts, J as toChatMessageParts } from '../parts-7fbe2rj8.js';
3
3
  import * as drizzle_orm from 'drizzle-orm';
4
4
  import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core';
5
5
  import { SQLiteColumnBuilderBase, AnySQLiteTable, AnySQLiteColumn, BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core';
6
6
  import { SqliteBatchDatabase } from '../store/index.js';
7
- export { a as attachmentPartKey } from '../stream-normalizer-DnuqkZvw.js';
7
+ export { a as ChatAttachmentKind, C as ChatMentionKind } from '../wire-DSp4LzEE.js';
8
+ export { a as attachmentPartKey } from '../stream-normalizer-CnPnMaTp.js';
8
9
  import '@tangle-network/agent-interface';
9
- import '../contract-CEewO6DI.js';
10
- import '../types-DB82fktc.js';
10
+ import '../contract-CQNvv5th.js';
11
+ import '../types-CCeYywdS.js';
11
12
  import '../plans/index.js';
12
13
 
13
14
  /**
@@ -19,8 +19,9 @@ import {
19
19
  mentionInputToPart,
20
20
  mentionPartsFromMessageParts,
21
21
  toChatMessageParts
22
- } from "../chunk-C3SRFZGL.js";
22
+ } from "../chunk-4PUMUTLU.js";
23
23
  import "../chunk-ZVEEWGDK.js";
24
+ import "../chunk-QYAQGCHF.js";
24
25
  import {
25
26
  attachmentPartKey
26
27
  } from "../chunk-5EPIPT4V.js";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/chat-store/core.ts","../../src/chat-store/schema.ts","../../src/chat-store/store.ts"],"sourcesContent":["/**\n * Pure (drizzle-free) pieces of the chat store: thread-title derivation, the\n * bulk-delete bound, and the typed input error. Split from `./schema`/`./store`\n * so the root barrel can re-export them without dragging the optional\n * drizzle-orm peer into every root-entry consumer.\n */\n\n/** Bounds a single bulk-delete request's write set; product surfaces cap\n * thread lists at far fewer, so a larger batch is a malformed or hostile\n * request. (Lifted from legal's api.threads.bulk-delete route.) */\nexport const BULK_DELETE_MAX_THREADS = 200\n\n/** Invalid caller input (missing/oversized ids, empty title). Products map it\n * to a 400; anything else out of the store is a real failure. */\nexport class ChatStoreInputError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'ChatStoreInputError'\n }\n}\n\n/** Thread titles come from the first message — keep the list scannable by\n * storing only its first non-empty line, capped at 80 chars, never the whole\n * multi-page prompt. (Lifted verbatim from legal's chat.new route.) */\nexport function threadTitleFromMessage(message: string): string {\n const firstLine = message.split('\\n').find((l) => l.trim().length > 0)?.trim() ?? ''\n if (!firstLine) return 'New Thread'\n return firstLine.length > 80 ? `${firstLine.slice(0, 79)}…` : firstLine\n}\n","/**\n * Drizzle schema factory for the chat thread/message tables — the same\n * injection pattern as `createTeamTables`: the product owns the workspace\n * table; the factory wires the thread FK into it so the whole graph lives in\n * one drizzle schema with real cascade semantics. Column names, types,\n * defaults, enums, and indexes mirror legal's and gtm's hand-rolled `thread`/\n * `message` tables so a product with those tables adopts the factory without\n * rewriting rows; `tablePrefix` covers products that namespace (tax's\n * `chat_messages` style).\n *\n * The core is the superset the three products agree on. Divergences dropped,\n * and why:\n * - `thread.status` ('active'|'archived', legal+gtm) — archive semantics\n * diverge (tax uses `archivedAt`); product-domain lifecycle → extra column.\n * - `thread.scopeKind`/`scopeKey`/`harness` (gtm) — artifact anchoring and\n * harness pinning are product-domain → extra columns.\n * - tax's `tax_sessions` session columns (`taxYear`, `projectRef`,\n * `agentSessionId`, `agentRuntime`, `agentHarness`, `profile`, `error`,\n * `userId`) — sandbox-session state, not chat state → extra columns.\n * - `message.toolInput`/`toolOutput` (legal+gtm) — duplicate of the tool\n * part's `state.input`/`state.output` inside `parts` (the shape `/stream`'s\n * `normalizePersistedPart` owns); keeping both invites drift.\n * - `message.vaultFiles` (legal+gtm) — vault is product-domain → extra column.\n * - tax's re-declared `turn_events`/`turn_status` DDL — deliberately NOT here;\n * `/stream`'s turn-buffer owns that DDL (`TURN_BUFFER_D1_SCHEMA_SQL`).\n *\n * Kept beyond the intersection: tax's per-message `model`/`inputTokens`/\n * `outputTokens`, extended to the full usage receipt the harness actually\n * reports in `step-finish` parts (`tokens {input, output, reasoning,\n * cache{read, write}}` + `cost`) — see `./parts`.\n *\n * `threadExtraColumns`/`messageExtraColumns` merge product columns into the\n * table definitions (the `/missions` opaque-extras pattern: the store writes\n * `extras` values verbatim in the SAME insert statement and never reads,\n * validates, or defaults them).\n *\n * SERVER-side module (D1/libsql/better-sqlite3 behind a worker or server\n * route) — but free of `node:` builtins on purpose: D1 workers have none.\n */\n\nimport { sql } from 'drizzle-orm'\nimport { index, integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'\nimport type { AnySQLiteColumn, AnySQLiteTable, SQLiteColumnBuilderBase } from 'drizzle-orm/sqlite-core'\nimport type { ChatMessagePart } from './parts'\n\n/** A product table referenced by FK — only the `id` column is touched. */\nexport type ChatParentTable = AnySQLiteTable & { id: AnySQLiteColumn }\n\n/** Define options to customize chat thread and message table creation including workspace and naming prefixes */\nexport interface CreateChatTablesOptions<\n TThreadExtras extends Record<string, SQLiteColumnBuilderBase> = {},\n TMessageExtras extends Record<string, SQLiteColumnBuilderBase> = {},\n> {\n /** The product's workspace table — threads reference `workspaceTable.id`\n * with cascade. Omitted: `workspace_id` stays a plain indexed text column\n * (products whose tenant table lives in another database). */\n workspaceTable?: ChatParentTable\n /** Prefixes table AND index names (`'chat_'` → `chat_thread`,\n * `idx_chat_thread_workspace`) for products that namespace chat tables in a\n * shared database. Default: unprefixed `thread`/`message` (legal/gtm row\n * compatibility). */\n tablePrefix?: string\n /** Product columns merged into the thread table (the `/missions` extras\n * pattern) — e.g. a `status` lifecycle enum or gtm's scope columns. */\n threadExtraColumns?: TThreadExtras\n /** Product columns merged into the message table — e.g. legal's\n * `vault_files`. */\n messageExtraColumns?: TMessageExtras\n}\n\nconst hexId = () => text('id').primaryKey().default(sql`(lower(hex(randomblob(16))))`)\n\nconst createdAt = () => integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`)\n\nconst updatedAt = () => integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`)\n\n/** Build chat-related SQLite tables with customizable thread and message columns */\nexport function createChatTables<\n TThreadExtras extends Record<string, SQLiteColumnBuilderBase> = {},\n TMessageExtras extends Record<string, SQLiteColumnBuilderBase> = {},\n>(options: CreateChatTablesOptions<TThreadExtras, TMessageExtras> = {}) {\n const { workspaceTable, tablePrefix = '' } = options\n const threadExtras = options.threadExtraColumns ?? ({} as TThreadExtras)\n const messageExtras = options.messageExtraColumns ?? ({} as TMessageExtras)\n\n const threads = sqliteTable(`${tablePrefix}thread`, {\n id: hexId(),\n workspaceId: workspaceTable\n ? text('workspace_id').notNull().references(() => workspaceTable.id, { onDelete: 'cascade' })\n : text('workspace_id').notNull(),\n title: text('title').notNull(),\n category: text('category'),\n isPinned: integer('is_pinned', { mode: 'boolean' }).notNull().default(false),\n createdAt: createdAt(),\n updatedAt: updatedAt(),\n ...threadExtras,\n }, (table) => [\n index(`idx_${tablePrefix}thread_workspace`).on(table.workspaceId),\n // Supports the store's list ordering (updatedAt desc within a workspace).\n index(`idx_${tablePrefix}thread_workspace_updated`).on(table.workspaceId, table.updatedAt),\n ])\n\n const messages = sqliteTable(`${tablePrefix}message`, {\n id: hexId(),\n threadId: text('thread_id').notNull().references(() => threads.id, { onDelete: 'cascade' }),\n role: text('role', { enum: ['user', 'assistant', 'system', 'tool'] }).notNull(),\n content: text('content').notNull(),\n parts: text('parts', { mode: 'json' }).$type<ChatMessagePart[]>().default([]),\n toolName: text('tool_name'),\n model: text('model'),\n // Usage receipt, flattened from the harness's `step-finish` shape\n // (`tokens {input, output, reasoning, cache{read, write}}` + `cost`).\n inputTokens: integer('input_tokens'),\n outputTokens: integer('output_tokens'),\n reasoningTokens: integer('reasoning_tokens'),\n cacheReadTokens: integer('cache_read_tokens'),\n cacheWriteTokens: integer('cache_write_tokens'),\n costUsd: real('cost_usd'),\n createdAt: createdAt(),\n ...messageExtras,\n }, (table) => [\n index(`idx_${tablePrefix}message_thread`).on(table.threadId),\n index(`idx_${tablePrefix}message_thread_created`).on(table.threadId, table.createdAt),\n ])\n\n return { threads, messages }\n}\n\n/**\n * The base (no-extras) table pair, pinned via an instantiation expression:\n * `ReturnType<typeof createChatTables>` on the bare generic substitutes the\n * extras params with their CONSTRAINT (`Record<string,\n * SQLiteColumnBuilderBase>`), stamping an index signature into the column map\n * that widens every concrete column to `unknown`/`notNull: false` — concrete\n * factory results then fail `extends ChatTables`. (`teams`' `createTeamTables`\n * is non-generic, so its plain `ReturnType` never hits this.)\n */\nexport type ChatTables = ReturnType<typeof createChatTables<{}, {}>>\n\n/** Resolve the selected fields of a chat thread row from the chat threads table */\nexport type ChatThreadRow = ChatTables['threads']['$inferSelect']\n/** Resolve the selected structure of a chat message row from the messages table */\nexport type ChatMessageRow = ChatTables['messages']['$inferSelect']\n/** Resolve the type for inserting a new chat thread row into the threads table */\nexport type NewChatThreadRow = ChatTables['threads']['$inferInsert']\n/** Resolve the type for inserting a new chat message row into the messages table */\nexport type NewChatMessageRow = ChatTables['messages']['$inferInsert']\n","/**\n * Typed CRUD over the tables from `createChatTables`. Works against any\n * SQLite drizzle driver (D1, libsql, better-sqlite3) — builders are awaited,\n * never `.run()`/`.all()`, so sync and async drivers behave identically.\n *\n * Access control is an injected seam, never an import: single-thread routes\n * check workspace access themselves (they know the thread), while\n * `bulkDeleteThreads` REQUIRES an `assertAccess` callback because one request\n * can span workspaces — it is called once per distinct workspace and any\n * throw rejects the whole request before a single delete runs (fail-closed;\n * legal's bulk-delete semantics).\n *\n * Deletes run messages-first in ONE `db.batch` round trip when the driver has\n * one (D1, libsql), so a partial failure never leaves orphaned rows behind a\n * deleted thread; drivers without `batch` (better-sqlite3) fall back to\n * sequential awaits in the same order.\n */\n\nimport { asc, desc, eq, inArray, sql } from 'drizzle-orm'\nimport type { BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core'\nimport {\n runSqliteStatements,\n type SqliteBatchDatabase,\n} from '../store'\nimport { BULK_DELETE_MAX_THREADS, ChatStoreInputError, threadTitleFromMessage } from './core'\nimport type { ChatMessagePart } from './parts'\nimport type { ChatMessageRow, ChatTables, ChatThreadRow, NewChatMessageRow, NewChatThreadRow } from './schema'\n\n/** Any SQLite drizzle database — `any` erases the driver-specific run-result\n * and schema generics so better-sqlite3, D1, and libsql handles all fit.\n * `batch` is structural: present on D1/libsql drizzle instances. */\nexport type ChatDatabase = BaseSQLiteDatabase<'sync' | 'async', any, any> &\n SqliteBatchDatabase\n\n/** Product-injected access check. Throw to deny; the store never interprets\n * users or roles itself. */\nexport type WorkspaceAccessCheck = (workspaceId: string) => void | Promise<void>\n\n/** Define input parameters for listing threads within a workspace with pagination options */\nexport interface ListThreadsInput {\n workspaceId: string\n /** Clamped to 1..200; default 50 (legal's list route semantics). */\n limit?: number\n /** Clamped to >= 0; default 0. */\n offset?: number\n}\n\n/** Represent a paginated collection of chat threads with total count and pagination details */\nexport interface ListThreadsResult<TThread = ChatThreadRow> {\n threads: TThread[]\n total: number\n limit: number\n offset: number\n}\n\n/** Define input parameters required to create a new thread in a workspace */\nexport interface CreateThreadInput {\n workspaceId: string\n /** Title source when `title` is absent: first non-empty line, 80-char cap\n * (`threadTitleFromMessage`). */\n firstMessage?: string\n /** Explicit title; still normalized through `threadTitleFromMessage` so a\n * multi-page paste never becomes a sidebar entry. */\n title?: string\n category?: string | null\n isPinned?: boolean\n /** Opaque product-column values written verbatim in the SAME insert (the\n * `/missions` extras pattern). Never read, validated, or defaulted here. */\n extras?: Record<string, unknown>\n}\n\n/** Define input parameters for appending a message to a chat thread with optional metadata */\nexport interface AppendMessageInput {\n /** Caller-assigned primary key. Omitted, the column default assigns a random\n * hex id (today's behavior). Incremental assistant persistence passes a\n * DETERMINISTIC id derived from the turn's own identity, so a re-entered\n * turn (crashed worker, durable-driver retry) finds and updates the row a\n * previous attempt started instead of inserting a second one. */\n id?: string\n threadId: string\n role: 'user' | 'assistant' | 'system' | 'tool'\n content: string\n parts?: ChatMessagePart[]\n toolName?: string | null\n model?: string | null\n inputTokens?: number | null\n outputTokens?: number | null\n reasoningTokens?: number | null\n cacheReadTokens?: number | null\n cacheWriteTokens?: number | null\n costUsd?: number | null\n /** Opaque product-column values written verbatim in the SAME insert. */\n extras?: Record<string, unknown>\n}\n\n/** Fields an existing message row may be patched with. Every field is\n * optional and only DEFINED fields are written, so a partial patch never\n * clears a column it does not mention. `threadId` and `role` are absent on\n * purpose: a message never moves thread or changes speaker.\n *\n * Exists for incremental assistant persistence — the streaming turn writes\n * the row once and then patches it as content accumulates, so the durable\n * transcript is at most one cadence interval behind the live stream. */\nexport interface UpdateMessageInput {\n content?: string\n parts?: ChatMessagePart[]\n toolName?: string | null\n model?: string | null\n inputTokens?: number | null\n outputTokens?: number | null\n reasoningTokens?: number | null\n cacheReadTokens?: number | null\n cacheWriteTokens?: number | null\n costUsd?: number | null\n /** Opaque product-column values written verbatim in the SAME update. */\n extras?: Record<string, unknown>\n}\n\n/** Define options to configure message listing with optional limit and offset parameters */\nexport interface ListMessagesOptions {\n limit?: number\n offset?: number\n}\n\n/** Define input for bulk deleting threads with access checks per workspace */\nexport interface BulkDeleteThreadsInput {\n ids: string[]\n /** Called once per distinct workspace the ids touch, before ANY delete. */\n assertAccess: WorkspaceAccessCheck\n}\n\n/** Manage chat threads and messages with operations for listing, creating, updating, and deleting data */\nexport interface ChatStore<TThread = ChatThreadRow, TMessage = ChatMessageRow> {\n listThreads(input: ListThreadsInput): Promise<ListThreadsResult<TThread>>\n getThread(threadId: string): Promise<TThread | null>\n createThread(input: CreateThreadInput): Promise<TThread>\n renameThread(threadId: string, title: string): Promise<TThread | null>\n pinThread(threadId: string, isPinned: boolean): Promise<TThread | null>\n /** Messages + thread in one batch. Resolves false when the thread does not\n * exist. `assertAccess` (optional) receives the thread's workspaceId before\n * the delete — single-thread callers usually check access themselves. */\n deleteThread(threadId: string, options?: { assertAccess?: WorkspaceAccessCheck }): Promise<boolean>\n bulkDeleteThreads(input: BulkDeleteThreadsInput): Promise<{ deleted: number }>\n /** Ordered oldest-first: `created_at`, then rowid (insertion order within a\n * same-second burst — a user+assistant pair lands in one epoch second). */\n listMessages(threadId: string, options?: ListMessagesOptions): Promise<TMessage[]>\n /** Inserts the message and bumps the thread's `updatedAt` in one batch so\n * workspace recency sorts stay truthful. */\n appendMessage(input: AppendMessageInput): Promise<TMessage>\n /** Patches an existing message and bumps its thread's `updatedAt` in the\n * same batch. Resolves `null` when the id does not exist. Only defined\n * patch fields are written. */\n updateMessage(id: string, patch: UpdateMessageInput): Promise<TMessage | null>\n /** Removes one message. Resolves false when the id does not exist. Used by\n * incremental persistence to retract a draft row for a turn that ended\n * producing nothing, so an empty assistant row is never left behind. */\n deleteMessage(id: string): Promise<boolean>\n}\n\nfunction clampLimit(limit: number | undefined, fallback: number, max: number): number {\n const value = Number.isFinite(limit) ? Math.trunc(limit as number) : fallback\n return Math.min(Math.max(value, 1), max)\n}\n\nfunction clampOffset(offset: number | undefined): number {\n const value = Number.isFinite(offset) ? Math.trunc(offset as number) : 0\n return Math.max(value, 0)\n}\n\n/** Create a chat store managing threads and messages based on the provided database and tables */\nexport function createChatStore<TTables extends ChatTables>(\n db: ChatDatabase,\n tables: TTables,\n): ChatStore<TTables['threads']['$inferSelect'], TTables['messages']['$inferSelect']> {\n type TThread = TTables['threads']['$inferSelect']\n type TMessage = TTables['messages']['$inferSelect']\n const threads = tables.threads as ChatTables['threads']\n const messages = tables.messages as ChatTables['messages']\n\n return {\n async listThreads(input) {\n const limit = clampLimit(input.limit, 50, 200)\n const offset = clampOffset(input.offset)\n const scope = eq(threads.workspaceId, input.workspaceId)\n const [list, [countRow]] = await Promise.all([\n db.select().from(threads).where(scope)\n // `id` tiebreak keeps pagination stable across same-second updates.\n .orderBy(desc(threads.updatedAt), asc(threads.id))\n .limit(limit)\n .offset(offset),\n db.select({ total: sql<number>`count(*)` }).from(threads).where(scope),\n ])\n return { threads: list as TThread[], total: countRow?.total ?? 0, limit, offset }\n },\n\n async getThread(threadId) {\n const [row] = await db.select().from(threads).where(eq(threads.id, threadId)).limit(1)\n return (row as TThread | undefined) ?? null\n },\n\n async createThread(input) {\n const title = threadTitleFromMessage(input.title ?? input.firstMessage ?? '')\n const values = {\n workspaceId: input.workspaceId,\n title,\n ...(input.category !== undefined ? { category: input.category } : {}),\n ...(input.isPinned !== undefined ? { isPinned: input.isPinned } : {}),\n ...(input.extras ?? {}),\n } as NewChatThreadRow\n const [row] = await db.insert(threads).values(values).returning()\n if (!row) throw new Error('thread insert returned no row')\n return row as TThread\n },\n\n async renameThread(threadId, title) {\n const trimmed = title.trim()\n if (!trimmed) throw new ChatStoreInputError('Missing title')\n const [row] = await db.update(threads)\n .set({ title: trimmed, updatedAt: new Date() })\n .where(eq(threads.id, threadId))\n .returning()\n return (row as TThread | undefined) ?? null\n },\n\n async pinThread(threadId, isPinned) {\n const [row] = await db.update(threads)\n .set({ isPinned, updatedAt: new Date() })\n .where(eq(threads.id, threadId))\n .returning()\n return (row as TThread | undefined) ?? null\n },\n\n async deleteThread(threadId, options) {\n const [existing] = await db.select({ id: threads.id, workspaceId: threads.workspaceId })\n .from(threads)\n .where(eq(threads.id, threadId))\n .limit(1)\n if (!existing) return false\n if (options?.assertAccess) await options.assertAccess(existing.workspaceId)\n // Messages first so a partial failure never leaves orphaned rows behind\n // a deleted thread.\n await runSqliteStatements(db, [\n db.delete(messages).where(eq(messages.threadId, threadId)),\n db.delete(threads).where(eq(threads.id, threadId)),\n ])\n return true\n },\n\n async bulkDeleteThreads(input) {\n const { ids, assertAccess } = input\n if (typeof assertAccess !== 'function') throw new ChatStoreInputError('Missing assertAccess')\n if (!Array.isArray(ids) || ids.length === 0 || !ids.every((id) => typeof id === 'string' && id.length > 0)) {\n throw new ChatStoreInputError('Missing ids')\n }\n if (ids.length > BULK_DELETE_MAX_THREADS) {\n throw new ChatStoreInputError(`Too many ids (max ${BULK_DELETE_MAX_THREADS})`)\n }\n\n const rows = await db.select({ id: threads.id, workspaceId: threads.workspaceId })\n .from(threads)\n .where(inArray(threads.id, ids))\n if (rows.length === 0) return { deleted: 0 }\n\n // Access is verified once per workspace the ids touch. Fail-closed: one\n // inaccessible workspace rejects the whole request before any delete.\n // Sorted so the check order (and therefore which denial surfaces) is\n // deterministic — row order follows random hex ids and varies per run.\n const workspaceIds = [...new Set(rows.map((row) => row.workspaceId))].sort()\n for (const workspaceId of workspaceIds) {\n await assertAccess(workspaceId)\n }\n\n const foundIds = rows.map((row) => row.id)\n await runSqliteStatements(db, [\n db.delete(messages).where(inArray(messages.threadId, foundIds)),\n db.delete(threads).where(inArray(threads.id, foundIds)),\n ])\n return { deleted: foundIds.length }\n },\n\n async listMessages(threadId, options) {\n const query = db.select().from(messages)\n .where(eq(messages.threadId, threadId))\n .orderBy(asc(messages.createdAt), sql`rowid`)\n .$dynamic()\n if (options?.limit !== undefined) query.limit(clampLimit(options.limit, 1, 1000))\n if (options?.offset !== undefined) query.offset(clampOffset(options.offset))\n return await query as TMessage[]\n },\n\n async appendMessage(input) {\n const values = {\n ...(input.id !== undefined ? { id: input.id } : {}),\n threadId: input.threadId,\n role: input.role,\n content: input.content,\n ...(input.parts !== undefined ? { parts: input.parts } : {}),\n ...(input.toolName !== undefined ? { toolName: input.toolName } : {}),\n ...(input.model !== undefined ? { model: input.model } : {}),\n ...(input.inputTokens !== undefined ? { inputTokens: input.inputTokens } : {}),\n ...(input.outputTokens !== undefined ? { outputTokens: input.outputTokens } : {}),\n ...(input.reasoningTokens !== undefined ? { reasoningTokens: input.reasoningTokens } : {}),\n ...(input.cacheReadTokens !== undefined ? { cacheReadTokens: input.cacheReadTokens } : {}),\n ...(input.cacheWriteTokens !== undefined ? { cacheWriteTokens: input.cacheWriteTokens } : {}),\n ...(input.costUsd !== undefined ? { costUsd: input.costUsd } : {}),\n ...(input.extras ?? {}),\n } as NewChatMessageRow\n const [insertResult] = await runSqliteStatements(db, [\n db.insert(messages).values(values).returning(),\n db.update(threads).set({ updatedAt: new Date() }).where(eq(threads.id, input.threadId)),\n ])\n const row = (insertResult as TMessage[] | undefined)?.[0]\n if (!row) throw new Error('message insert returned no row')\n return row\n },\n\n async updateMessage(id, patch) {\n const values = {\n ...(patch.content !== undefined ? { content: patch.content } : {}),\n ...(patch.parts !== undefined ? { parts: patch.parts } : {}),\n ...(patch.toolName !== undefined ? { toolName: patch.toolName } : {}),\n ...(patch.model !== undefined ? { model: patch.model } : {}),\n ...(patch.inputTokens !== undefined ? { inputTokens: patch.inputTokens } : {}),\n ...(patch.outputTokens !== undefined ? { outputTokens: patch.outputTokens } : {}),\n ...(patch.reasoningTokens !== undefined ? { reasoningTokens: patch.reasoningTokens } : {}),\n ...(patch.cacheReadTokens !== undefined ? { cacheReadTokens: patch.cacheReadTokens } : {}),\n ...(patch.cacheWriteTokens !== undefined ? { cacheWriteTokens: patch.cacheWriteTokens } : {}),\n ...(patch.costUsd !== undefined ? { costUsd: patch.costUsd } : {}),\n ...(patch.extras ?? {}),\n }\n if (Object.keys(values).length === 0) {\n const [current] = await db.select().from(messages).where(eq(messages.id, id))\n return (current as TMessage | undefined) ?? null\n }\n // The thread bump reads the row's own `thread_id` rather than trusting a\n // caller-supplied one: a message never moves thread, so the subquery is\n // the authoritative source and one fewer parameter to get wrong.\n const [updateResult] = await runSqliteStatements(db, [\n db.update(messages).set(values).where(eq(messages.id, id)).returning(),\n db.update(threads).set({ updatedAt: new Date() })\n .where(eq(threads.id, sql`(select ${messages.threadId} from ${messages} where ${messages.id} = ${id})`)),\n ])\n return (updateResult as TMessage[] | undefined)?.[0] ?? null\n },\n\n async deleteMessage(id) {\n const deleted = await db.delete(messages).where(eq(messages.id, id)).returning()\n return (deleted as unknown[]).length > 0\n },\n }\n}\n"],"mappings":"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","names":["sql","sql"]}
1
+ {"version":3,"sources":["../../src/chat-store/core.ts","../../src/chat-store/schema.ts","../../src/chat-store/store.ts"],"sourcesContent":["/**\n * Pure (drizzle-free) pieces of the chat store: thread-title derivation, the\n * bulk-delete bound, and the typed input error. Split from `./schema`/`./store`\n * so the root barrel can re-export them without dragging the optional\n * drizzle-orm peer into every root-entry consumer.\n */\n\n/** Bounds a single bulk-delete request's write set; product surfaces cap\n * thread lists at far fewer, so a larger batch is a malformed or hostile\n * request. (Lifted from legal's api.threads.bulk-delete route.) */\nexport const BULK_DELETE_MAX_THREADS = 200\n\n/** Invalid caller input (missing/oversized ids, empty title). Products map it\n * to a 400; anything else out of the store is a real failure. */\nexport class ChatStoreInputError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'ChatStoreInputError'\n }\n}\n\n/** Thread titles come from the first message — keep the list scannable by\n * storing only its first non-empty line, capped at 80 chars, never the whole\n * multi-page prompt. (Lifted verbatim from legal's chat.new route.) */\nexport function threadTitleFromMessage(message: string): string {\n const firstLine = message.split('\\n').find((l) => l.trim().length > 0)?.trim() ?? ''\n if (!firstLine) return 'New Thread'\n return firstLine.length > 80 ? `${firstLine.slice(0, 79)}…` : firstLine\n}\n","/**\n * Drizzle schema factory for the chat thread/message tables — the same\n * injection pattern as `createTeamTables`: the product owns the workspace\n * table; the factory wires the thread FK into it so the whole graph lives in\n * one drizzle schema with real cascade semantics. Column names, types,\n * defaults, enums, and indexes mirror legal's and gtm's hand-rolled `thread`/\n * `message` tables so a product with those tables adopts the factory without\n * rewriting rows; `tablePrefix` covers products that namespace (tax's\n * `chat_messages` style).\n *\n * The core is the superset the three products agree on. Divergences dropped,\n * and why:\n * - `thread.status` ('active'|'archived', legal+gtm) — archive semantics\n * diverge (tax uses `archivedAt`); product-domain lifecycle → extra column.\n * - `thread.scopeKind`/`scopeKey`/`harness` (gtm) — artifact anchoring and\n * harness pinning are product-domain → extra columns.\n * - tax's `tax_sessions` session columns (`taxYear`, `projectRef`,\n * `agentSessionId`, `agentRuntime`, `agentHarness`, `profile`, `error`,\n * `userId`) — sandbox-session state, not chat state → extra columns.\n * - `message.toolInput`/`toolOutput` (legal+gtm) — duplicate of the tool\n * part's `state.input`/`state.output` inside `parts` (the shape `/stream`'s\n * `normalizePersistedPart` owns); keeping both invites drift.\n * - `message.vaultFiles` (legal+gtm) — vault is product-domain → extra column.\n * - tax's re-declared `turn_events`/`turn_status` DDL — deliberately NOT here;\n * `/stream`'s turn-buffer owns that DDL (`TURN_BUFFER_D1_SCHEMA_SQL`).\n *\n * Kept beyond the intersection: tax's per-message `model`/`inputTokens`/\n * `outputTokens`, extended to the full usage receipt the harness actually\n * reports in `step-finish` parts (`tokens {input, output, reasoning,\n * cache{read, write}}` + `cost`) — see `./parts`.\n *\n * `threadExtraColumns`/`messageExtraColumns` merge product columns into the\n * table definitions (the `/missions` opaque-extras pattern: the store writes\n * `extras` values verbatim in the SAME insert statement and never reads,\n * validates, or defaults them).\n *\n * SERVER-side module (D1/libsql/better-sqlite3 behind a worker or server\n * route) — but free of `node:` builtins on purpose: D1 workers have none.\n */\n\nimport { sql } from 'drizzle-orm'\nimport { index, integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'\nimport type { AnySQLiteColumn, AnySQLiteTable, SQLiteColumnBuilderBase } from 'drizzle-orm/sqlite-core'\nimport type { ChatMessagePart } from './parts'\n\n/** A product table referenced by FK — only the `id` column is touched. */\nexport type ChatParentTable = AnySQLiteTable & { id: AnySQLiteColumn }\n\n/** Define options to customize chat thread and message table creation including workspace and naming prefixes */\nexport interface CreateChatTablesOptions<\n TThreadExtras extends Record<string, SQLiteColumnBuilderBase> = {},\n TMessageExtras extends Record<string, SQLiteColumnBuilderBase> = {},\n> {\n /** The product's workspace table — threads reference `workspaceTable.id`\n * with cascade. Omitted: `workspace_id` stays a plain indexed text column\n * (products whose tenant table lives in another database). */\n workspaceTable?: ChatParentTable\n /** Prefixes table AND index names (`'chat_'` → `chat_thread`,\n * `idx_chat_thread_workspace`) for products that namespace chat tables in a\n * shared database. Default: unprefixed `thread`/`message` (legal/gtm row\n * compatibility). */\n tablePrefix?: string\n /** Product columns merged into the thread table (the `/missions` extras\n * pattern) — e.g. a `status` lifecycle enum or gtm's scope columns. */\n threadExtraColumns?: TThreadExtras\n /** Product columns merged into the message table — e.g. legal's\n * `vault_files`. */\n messageExtraColumns?: TMessageExtras\n}\n\nconst hexId = () => text('id').primaryKey().default(sql`(lower(hex(randomblob(16))))`)\n\nconst createdAt = () => integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`)\n\nconst updatedAt = () => integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`)\n\n/** Build chat-related SQLite tables with customizable thread and message columns */\nexport function createChatTables<\n TThreadExtras extends Record<string, SQLiteColumnBuilderBase> = {},\n TMessageExtras extends Record<string, SQLiteColumnBuilderBase> = {},\n>(options: CreateChatTablesOptions<TThreadExtras, TMessageExtras> = {}) {\n const { workspaceTable, tablePrefix = '' } = options\n const threadExtras = options.threadExtraColumns ?? ({} as TThreadExtras)\n const messageExtras = options.messageExtraColumns ?? ({} as TMessageExtras)\n\n const threads = sqliteTable(`${tablePrefix}thread`, {\n id: hexId(),\n workspaceId: workspaceTable\n ? text('workspace_id').notNull().references(() => workspaceTable.id, { onDelete: 'cascade' })\n : text('workspace_id').notNull(),\n title: text('title').notNull(),\n category: text('category'),\n isPinned: integer('is_pinned', { mode: 'boolean' }).notNull().default(false),\n createdAt: createdAt(),\n updatedAt: updatedAt(),\n ...threadExtras,\n }, (table) => [\n index(`idx_${tablePrefix}thread_workspace`).on(table.workspaceId),\n // Supports the store's list ordering (updatedAt desc within a workspace).\n index(`idx_${tablePrefix}thread_workspace_updated`).on(table.workspaceId, table.updatedAt),\n ])\n\n const messages = sqliteTable(`${tablePrefix}message`, {\n id: hexId(),\n threadId: text('thread_id').notNull().references(() => threads.id, { onDelete: 'cascade' }),\n role: text('role', { enum: ['user', 'assistant', 'system', 'tool'] }).notNull(),\n content: text('content').notNull(),\n parts: text('parts', { mode: 'json' }).$type<ChatMessagePart[]>().default([]),\n toolName: text('tool_name'),\n model: text('model'),\n // Usage receipt, flattened from the harness's `step-finish` shape\n // (`tokens {input, output, reasoning, cache{read, write}}` + `cost`).\n inputTokens: integer('input_tokens'),\n outputTokens: integer('output_tokens'),\n reasoningTokens: integer('reasoning_tokens'),\n cacheReadTokens: integer('cache_read_tokens'),\n cacheWriteTokens: integer('cache_write_tokens'),\n costUsd: real('cost_usd'),\n createdAt: createdAt(),\n ...messageExtras,\n }, (table) => [\n index(`idx_${tablePrefix}message_thread`).on(table.threadId),\n index(`idx_${tablePrefix}message_thread_created`).on(table.threadId, table.createdAt),\n ])\n\n return { threads, messages }\n}\n\n/**\n * The base (no-extras) table pair, pinned via an instantiation expression:\n * `ReturnType<typeof createChatTables>` on the bare generic substitutes the\n * extras params with their CONSTRAINT (`Record<string,\n * SQLiteColumnBuilderBase>`), stamping an index signature into the column map\n * that widens every concrete column to `unknown`/`notNull: false` — concrete\n * factory results then fail `extends ChatTables`. (`teams`' `createTeamTables`\n * is non-generic, so its plain `ReturnType` never hits this.)\n */\nexport type ChatTables = ReturnType<typeof createChatTables<{}, {}>>\n\n/** Resolve the selected fields of a chat thread row from the chat threads table */\nexport type ChatThreadRow = ChatTables['threads']['$inferSelect']\n/** Resolve the selected structure of a chat message row from the messages table */\nexport type ChatMessageRow = ChatTables['messages']['$inferSelect']\n/** Resolve the type for inserting a new chat thread row into the threads table */\nexport type NewChatThreadRow = ChatTables['threads']['$inferInsert']\n/** Resolve the type for inserting a new chat message row into the messages table */\nexport type NewChatMessageRow = ChatTables['messages']['$inferInsert']\n","/**\n * Typed CRUD over the tables from `createChatTables`. Works against any\n * SQLite drizzle driver (D1, libsql, better-sqlite3) — builders are awaited,\n * never `.run()`/`.all()`, so sync and async drivers behave identically.\n *\n * Access control is an injected seam, never an import: single-thread routes\n * check workspace access themselves (they know the thread), while\n * `bulkDeleteThreads` REQUIRES an `assertAccess` callback because one request\n * can span workspaces — it is called once per distinct workspace and any\n * throw rejects the whole request before a single delete runs (fail-closed;\n * legal's bulk-delete semantics).\n *\n * Deletes run messages-first in ONE `db.batch` round trip when the driver has\n * one (D1, libsql), so a partial failure never leaves orphaned rows behind a\n * deleted thread; drivers without `batch` (better-sqlite3) fall back to\n * sequential awaits in the same order.\n */\n\nimport { asc, desc, eq, inArray, sql } from 'drizzle-orm'\nimport type { BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core'\nimport {\n runSqliteStatements,\n type SqliteBatchDatabase,\n} from '../store'\nimport { BULK_DELETE_MAX_THREADS, ChatStoreInputError, threadTitleFromMessage } from './core'\nimport type { ChatMessagePart } from './parts'\nimport type { ChatMessageRow, ChatTables, ChatThreadRow, NewChatMessageRow, NewChatThreadRow } from './schema'\n\n/** Any SQLite drizzle database — `any` erases the driver-specific run-result\n * and schema generics so better-sqlite3, D1, and libsql handles all fit.\n * `batch` is structural: present on D1/libsql drizzle instances. */\nexport type ChatDatabase = BaseSQLiteDatabase<'sync' | 'async', any, any> &\n SqliteBatchDatabase\n\n/** Product-injected access check. Throw to deny; the store never interprets\n * users or roles itself. */\nexport type WorkspaceAccessCheck = (workspaceId: string) => void | Promise<void>\n\n/** Define input parameters for listing threads within a workspace with pagination options */\nexport interface ListThreadsInput {\n workspaceId: string\n /** Clamped to 1..200; default 50 (legal's list route semantics). */\n limit?: number\n /** Clamped to >= 0; default 0. */\n offset?: number\n}\n\n/** Represent a paginated collection of chat threads with total count and pagination details */\nexport interface ListThreadsResult<TThread = ChatThreadRow> {\n threads: TThread[]\n total: number\n limit: number\n offset: number\n}\n\n/** Define input parameters required to create a new thread in a workspace */\nexport interface CreateThreadInput {\n workspaceId: string\n /** Title source when `title` is absent: first non-empty line, 80-char cap\n * (`threadTitleFromMessage`). */\n firstMessage?: string\n /** Explicit title; still normalized through `threadTitleFromMessage` so a\n * multi-page paste never becomes a sidebar entry. */\n title?: string\n category?: string | null\n isPinned?: boolean\n /** Opaque product-column values written verbatim in the SAME insert (the\n * `/missions` extras pattern). Never read, validated, or defaulted here. */\n extras?: Record<string, unknown>\n}\n\n/** Define input parameters for appending a message to a chat thread with optional metadata */\nexport interface AppendMessageInput {\n /** Caller-assigned primary key. Omitted, the column default assigns a random\n * hex id (today's behavior). Incremental assistant persistence passes a\n * DETERMINISTIC id derived from the turn's own identity, so a re-entered\n * turn (crashed worker, durable-driver retry) finds and updates the row a\n * previous attempt started instead of inserting a second one. */\n id?: string\n threadId: string\n role: 'user' | 'assistant' | 'system' | 'tool'\n content: string\n parts?: ChatMessagePart[]\n toolName?: string | null\n model?: string | null\n inputTokens?: number | null\n outputTokens?: number | null\n reasoningTokens?: number | null\n cacheReadTokens?: number | null\n cacheWriteTokens?: number | null\n costUsd?: number | null\n /** Opaque product-column values written verbatim in the SAME insert. */\n extras?: Record<string, unknown>\n}\n\n/** Fields an existing message row may be patched with. Every field is\n * optional and only DEFINED fields are written, so a partial patch never\n * clears a column it does not mention. `threadId` and `role` are absent on\n * purpose: a message never moves thread or changes speaker.\n *\n * Exists for incremental assistant persistence — the streaming turn writes\n * the row once and then patches it as content accumulates, so the durable\n * transcript is at most one cadence interval behind the live stream. */\nexport interface UpdateMessageInput {\n content?: string\n parts?: ChatMessagePart[]\n toolName?: string | null\n model?: string | null\n inputTokens?: number | null\n outputTokens?: number | null\n reasoningTokens?: number | null\n cacheReadTokens?: number | null\n cacheWriteTokens?: number | null\n costUsd?: number | null\n /** Opaque product-column values written verbatim in the SAME update. */\n extras?: Record<string, unknown>\n}\n\n/** Define options to configure message listing with optional limit and offset parameters */\nexport interface ListMessagesOptions {\n limit?: number\n offset?: number\n}\n\n/** Define input for bulk deleting threads with access checks per workspace */\nexport interface BulkDeleteThreadsInput {\n ids: string[]\n /** Called once per distinct workspace the ids touch, before ANY delete. */\n assertAccess: WorkspaceAccessCheck\n}\n\n/** Manage chat threads and messages with operations for listing, creating, updating, and deleting data */\nexport interface ChatStore<TThread = ChatThreadRow, TMessage = ChatMessageRow> {\n listThreads(input: ListThreadsInput): Promise<ListThreadsResult<TThread>>\n getThread(threadId: string): Promise<TThread | null>\n createThread(input: CreateThreadInput): Promise<TThread>\n renameThread(threadId: string, title: string): Promise<TThread | null>\n pinThread(threadId: string, isPinned: boolean): Promise<TThread | null>\n /** Messages + thread in one batch. Resolves false when the thread does not\n * exist. `assertAccess` (optional) receives the thread's workspaceId before\n * the delete — single-thread callers usually check access themselves. */\n deleteThread(threadId: string, options?: { assertAccess?: WorkspaceAccessCheck }): Promise<boolean>\n bulkDeleteThreads(input: BulkDeleteThreadsInput): Promise<{ deleted: number }>\n /** Ordered oldest-first: `created_at`, then rowid (insertion order within a\n * same-second burst — a user+assistant pair lands in one epoch second). */\n listMessages(threadId: string, options?: ListMessagesOptions): Promise<TMessage[]>\n /** Inserts the message and bumps the thread's `updatedAt` in one batch so\n * workspace recency sorts stay truthful. */\n appendMessage(input: AppendMessageInput): Promise<TMessage>\n /** Patches an existing message and bumps its thread's `updatedAt` in the\n * same batch. Resolves `null` when the id does not exist. Only defined\n * patch fields are written. */\n updateMessage(id: string, patch: UpdateMessageInput): Promise<TMessage | null>\n /** Removes one message. Resolves false when the id does not exist. Used by\n * incremental persistence to retract a draft row for a turn that ended\n * producing nothing, so an empty assistant row is never left behind. */\n deleteMessage(id: string): Promise<boolean>\n}\n\nfunction clampLimit(limit: number | undefined, fallback: number, max: number): number {\n const value = Number.isFinite(limit) ? Math.trunc(limit as number) : fallback\n return Math.min(Math.max(value, 1), max)\n}\n\nfunction clampOffset(offset: number | undefined): number {\n const value = Number.isFinite(offset) ? Math.trunc(offset as number) : 0\n return Math.max(value, 0)\n}\n\n/** Create a chat store managing threads and messages based on the provided database and tables */\nexport function createChatStore<TTables extends ChatTables>(\n db: ChatDatabase,\n tables: TTables,\n): ChatStore<TTables['threads']['$inferSelect'], TTables['messages']['$inferSelect']> {\n type TThread = TTables['threads']['$inferSelect']\n type TMessage = TTables['messages']['$inferSelect']\n const threads = tables.threads as ChatTables['threads']\n const messages = tables.messages as ChatTables['messages']\n\n return {\n async listThreads(input) {\n const limit = clampLimit(input.limit, 50, 200)\n const offset = clampOffset(input.offset)\n const scope = eq(threads.workspaceId, input.workspaceId)\n const [list, [countRow]] = await Promise.all([\n db.select().from(threads).where(scope)\n // `id` tiebreak keeps pagination stable across same-second updates.\n .orderBy(desc(threads.updatedAt), asc(threads.id))\n .limit(limit)\n .offset(offset),\n db.select({ total: sql<number>`count(*)` }).from(threads).where(scope),\n ])\n return { threads: list as TThread[], total: countRow?.total ?? 0, limit, offset }\n },\n\n async getThread(threadId) {\n const [row] = await db.select().from(threads).where(eq(threads.id, threadId)).limit(1)\n return (row as TThread | undefined) ?? null\n },\n\n async createThread(input) {\n const title = threadTitleFromMessage(input.title ?? input.firstMessage ?? '')\n const values = {\n workspaceId: input.workspaceId,\n title,\n ...(input.category !== undefined ? { category: input.category } : {}),\n ...(input.isPinned !== undefined ? { isPinned: input.isPinned } : {}),\n ...(input.extras ?? {}),\n } as NewChatThreadRow\n const [row] = await db.insert(threads).values(values).returning()\n if (!row) throw new Error('thread insert returned no row')\n return row as TThread\n },\n\n async renameThread(threadId, title) {\n const trimmed = title.trim()\n if (!trimmed) throw new ChatStoreInputError('Missing title')\n const [row] = await db.update(threads)\n .set({ title: trimmed, updatedAt: new Date() })\n .where(eq(threads.id, threadId))\n .returning()\n return (row as TThread | undefined) ?? null\n },\n\n async pinThread(threadId, isPinned) {\n const [row] = await db.update(threads)\n .set({ isPinned, updatedAt: new Date() })\n .where(eq(threads.id, threadId))\n .returning()\n return (row as TThread | undefined) ?? null\n },\n\n async deleteThread(threadId, options) {\n const [existing] = await db.select({ id: threads.id, workspaceId: threads.workspaceId })\n .from(threads)\n .where(eq(threads.id, threadId))\n .limit(1)\n if (!existing) return false\n if (options?.assertAccess) await options.assertAccess(existing.workspaceId)\n // Messages first so a partial failure never leaves orphaned rows behind\n // a deleted thread.\n await runSqliteStatements(db, [\n db.delete(messages).where(eq(messages.threadId, threadId)),\n db.delete(threads).where(eq(threads.id, threadId)),\n ])\n return true\n },\n\n async bulkDeleteThreads(input) {\n const { ids, assertAccess } = input\n if (typeof assertAccess !== 'function') throw new ChatStoreInputError('Missing assertAccess')\n if (!Array.isArray(ids) || ids.length === 0 || !ids.every((id) => typeof id === 'string' && id.length > 0)) {\n throw new ChatStoreInputError('Missing ids')\n }\n if (ids.length > BULK_DELETE_MAX_THREADS) {\n throw new ChatStoreInputError(`Too many ids (max ${BULK_DELETE_MAX_THREADS})`)\n }\n\n const rows = await db.select({ id: threads.id, workspaceId: threads.workspaceId })\n .from(threads)\n .where(inArray(threads.id, ids))\n if (rows.length === 0) return { deleted: 0 }\n\n // Access is verified once per workspace the ids touch. Fail-closed: one\n // inaccessible workspace rejects the whole request before any delete.\n // Sorted so the check order (and therefore which denial surfaces) is\n // deterministic — row order follows random hex ids and varies per run.\n const workspaceIds = [...new Set(rows.map((row) => row.workspaceId))].sort()\n for (const workspaceId of workspaceIds) {\n await assertAccess(workspaceId)\n }\n\n const foundIds = rows.map((row) => row.id)\n await runSqliteStatements(db, [\n db.delete(messages).where(inArray(messages.threadId, foundIds)),\n db.delete(threads).where(inArray(threads.id, foundIds)),\n ])\n return { deleted: foundIds.length }\n },\n\n async listMessages(threadId, options) {\n const query = db.select().from(messages)\n .where(eq(messages.threadId, threadId))\n .orderBy(asc(messages.createdAt), sql`rowid`)\n .$dynamic()\n if (options?.limit !== undefined) query.limit(clampLimit(options.limit, 1, 1000))\n if (options?.offset !== undefined) query.offset(clampOffset(options.offset))\n return await query as TMessage[]\n },\n\n async appendMessage(input) {\n const values = {\n ...(input.id !== undefined ? { id: input.id } : {}),\n threadId: input.threadId,\n role: input.role,\n content: input.content,\n ...(input.parts !== undefined ? { parts: input.parts } : {}),\n ...(input.toolName !== undefined ? { toolName: input.toolName } : {}),\n ...(input.model !== undefined ? { model: input.model } : {}),\n ...(input.inputTokens !== undefined ? { inputTokens: input.inputTokens } : {}),\n ...(input.outputTokens !== undefined ? { outputTokens: input.outputTokens } : {}),\n ...(input.reasoningTokens !== undefined ? { reasoningTokens: input.reasoningTokens } : {}),\n ...(input.cacheReadTokens !== undefined ? { cacheReadTokens: input.cacheReadTokens } : {}),\n ...(input.cacheWriteTokens !== undefined ? { cacheWriteTokens: input.cacheWriteTokens } : {}),\n ...(input.costUsd !== undefined ? { costUsd: input.costUsd } : {}),\n ...(input.extras ?? {}),\n } as NewChatMessageRow\n const [insertResult] = await runSqliteStatements(db, [\n db.insert(messages).values(values).returning(),\n db.update(threads).set({ updatedAt: new Date() }).where(eq(threads.id, input.threadId)),\n ])\n const row = (insertResult as TMessage[] | undefined)?.[0]\n if (!row) throw new Error('message insert returned no row')\n return row\n },\n\n async updateMessage(id, patch) {\n const values = {\n ...(patch.content !== undefined ? { content: patch.content } : {}),\n ...(patch.parts !== undefined ? { parts: patch.parts } : {}),\n ...(patch.toolName !== undefined ? { toolName: patch.toolName } : {}),\n ...(patch.model !== undefined ? { model: patch.model } : {}),\n ...(patch.inputTokens !== undefined ? { inputTokens: patch.inputTokens } : {}),\n ...(patch.outputTokens !== undefined ? { outputTokens: patch.outputTokens } : {}),\n ...(patch.reasoningTokens !== undefined ? { reasoningTokens: patch.reasoningTokens } : {}),\n ...(patch.cacheReadTokens !== undefined ? { cacheReadTokens: patch.cacheReadTokens } : {}),\n ...(patch.cacheWriteTokens !== undefined ? { cacheWriteTokens: patch.cacheWriteTokens } : {}),\n ...(patch.costUsd !== undefined ? { costUsd: patch.costUsd } : {}),\n ...(patch.extras ?? {}),\n }\n if (Object.keys(values).length === 0) {\n const [current] = await db.select().from(messages).where(eq(messages.id, id))\n return (current as TMessage | undefined) ?? null\n }\n // The thread bump reads the row's own `thread_id` rather than trusting a\n // caller-supplied one: a message never moves thread, so the subquery is\n // the authoritative source and one fewer parameter to get wrong.\n const [updateResult] = await runSqliteStatements(db, [\n db.update(messages).set(values).where(eq(messages.id, id)).returning(),\n db.update(threads).set({ updatedAt: new Date() })\n .where(eq(threads.id, sql`(select ${messages.threadId} from ${messages} where ${messages.id} = ${id})`)),\n ])\n return (updateResult as TMessage[] | undefined)?.[0] ?? null\n },\n\n async deleteMessage(id) {\n const deleted = await db.delete(messages).where(eq(messages.id, id)).returning()\n return (deleted as unknown[]).length > 0\n },\n }\n}\n"],"mappings":"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","names":["sql","sql"]}
@@ -0,0 +1,292 @@
1
+ import {
2
+ ATTACHMENT_ACCEPT,
3
+ ATTACHMENT_MAX_COUNT,
4
+ MAX_ATTACHMENT_TOTAL_BYTES,
5
+ MAX_BINARY_ATTACHMENT_BYTES,
6
+ MAX_TEXT_ATTACHMENT_BYTES,
7
+ attachmentSizeErrorMessage,
8
+ attachmentTotalSizeErrorMessage,
9
+ checkAttachmentType,
10
+ sanitizeAttachmentFileName,
11
+ sniffBinary
12
+ } from "./chunk-IYLJS6VW.js";
13
+
14
+ // src/web-react/use-composer-attachments.ts
15
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
16
+ function newId() {
17
+ const cryptoObject = globalThis.crypto;
18
+ if (typeof cryptoObject?.randomUUID === "function") return cryptoObject.randomUUID();
19
+ return `att-${Date.now()}-${Math.random().toString(36).slice(2)}`;
20
+ }
21
+ function dedupeName(name, taken) {
22
+ if (!taken.has(name)) return name;
23
+ const dot = name.lastIndexOf(".");
24
+ const base = dot > 0 ? name.slice(0, dot) : name;
25
+ const ext = dot > 0 ? name.slice(dot) : "";
26
+ let n = 2;
27
+ let candidate = `${base}-${n}${ext}`;
28
+ while (taken.has(candidate)) {
29
+ n += 1;
30
+ candidate = `${base}-${n}${ext}`;
31
+ }
32
+ return candidate;
33
+ }
34
+ function kindForMime(mime) {
35
+ return mime.startsWith("image/") ? "image" : "file";
36
+ }
37
+ function isAcceptedFileType(file, accept) {
38
+ const patterns = accept.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
39
+ if (patterns.length === 0) return true;
40
+ const name = file.name.toLowerCase();
41
+ const type = (file.type || "").toLowerCase();
42
+ return patterns.some((pattern) => {
43
+ const lower = pattern.toLowerCase();
44
+ if (lower.startsWith(".")) return name.endsWith(lower);
45
+ if (lower.endsWith("/*")) return type.startsWith(lower.slice(0, -1));
46
+ return type === lower;
47
+ });
48
+ }
49
+ async function parseUploadError(res) {
50
+ const detail = await res.json().catch(() => null);
51
+ if (detail && typeof detail === "object" && "error" in detail) {
52
+ const error = detail.error;
53
+ if (typeof error === "string" && error) return error;
54
+ if (error && typeof error === "object" && "message" in error) {
55
+ const message = error.message;
56
+ if (typeof message === "string" && message) return message;
57
+ }
58
+ }
59
+ return `Upload failed (${res.status})`;
60
+ }
61
+ var NO_UPLOAD_TARGET_MESSAGE = "No upload destination configured (pass uploadUrl or buildUploadRequest)";
62
+ function useComposerAttachments(options) {
63
+ const optionsRef = useRef(options);
64
+ optionsRef.current = options;
65
+ const [staged, setStagedState] = useState([]);
66
+ const stagedRef = useRef([]);
67
+ const controllersRef = useRef(/* @__PURE__ */ new Map());
68
+ const setStaged = useCallback(
69
+ (updater) => {
70
+ const next = typeof updater === "function" ? updater(stagedRef.current) : updater;
71
+ stagedRef.current = next;
72
+ setStagedState(next);
73
+ },
74
+ []
75
+ );
76
+ const upload = useCallback(
77
+ async (id, file, name) => {
78
+ const opts = optionsRef.current;
79
+ setStaged(
80
+ (prev) => prev.map((s) => s.id === id ? { ...s, status: "uploading", errorMessage: void 0 } : s)
81
+ );
82
+ const controller = new AbortController();
83
+ controllersRef.current.set(id, controller);
84
+ const form = new FormData();
85
+ form.append("file", file, name);
86
+ const request = opts.buildUploadRequest ? opts.buildUploadRequest({ file, name, form }) : opts.uploadUrl ? { url: opts.uploadUrl } : null;
87
+ if (!request) {
88
+ setStaged(
89
+ (prev) => prev.map(
90
+ (s) => s.id === id ? { ...s, status: "error", errorMessage: NO_UPLOAD_TARGET_MESSAGE } : s
91
+ )
92
+ );
93
+ opts.onError?.(NO_UPLOAD_TARGET_MESSAGE);
94
+ controllersRef.current.delete(id);
95
+ return;
96
+ }
97
+ try {
98
+ const res = await fetch(request.url, {
99
+ method: "POST",
100
+ credentials: "same-origin",
101
+ ...request.init,
102
+ body: form,
103
+ signal: controller.signal
104
+ });
105
+ if (!res.ok) {
106
+ const message = await parseUploadError(res);
107
+ setStaged(
108
+ (prev) => prev.map((s) => s.id === id ? { ...s, status: "error", errorMessage: message } : s)
109
+ );
110
+ opts.onError?.(message);
111
+ return;
112
+ }
113
+ const data = await res.json();
114
+ const uploaded = data.files?.[0];
115
+ if (!uploaded) {
116
+ const message = "Upload returned no file";
117
+ setStaged(
118
+ (prev) => prev.map((s) => s.id === id ? { ...s, status: "error", errorMessage: message } : s)
119
+ );
120
+ opts.onError?.(message);
121
+ return;
122
+ }
123
+ setStaged(
124
+ (prev) => prev.map((s) => s.id === id ? { ...s, status: "ready", reference: uploaded } : s)
125
+ );
126
+ } catch (err) {
127
+ if (err.name === "AbortError") return;
128
+ const message = err instanceof Error && err.message ? err.message : "Upload failed \u2014 check your connection";
129
+ setStaged(
130
+ (prev) => prev.map((s) => s.id === id ? { ...s, status: "error", errorMessage: message } : s)
131
+ );
132
+ opts.onError?.(message);
133
+ } finally {
134
+ controllersRef.current.delete(id);
135
+ }
136
+ },
137
+ [setStaged]
138
+ );
139
+ const addFiles = useCallback(
140
+ async (files) => {
141
+ const opts = optionsRef.current;
142
+ const enabled2 = opts.enabled ?? true;
143
+ if (!enabled2) {
144
+ opts.onReject?.("Attachments are disabled");
145
+ return;
146
+ }
147
+ const accept = opts.accept ?? ATTACHMENT_ACCEPT;
148
+ const maxCount = opts.limits?.maxCount ?? ATTACHMENT_MAX_COUNT;
149
+ const maxBinaryBytes = opts.limits?.maxBinaryBytes ?? MAX_BINARY_ATTACHMENT_BYTES;
150
+ const maxTextBytes = opts.limits?.maxTextBytes ?? MAX_TEXT_ATTACHMENT_BYTES;
151
+ const maxTotalBytes = opts.limits?.maxTotalBytes ?? MAX_ATTACHMENT_TOTAL_BYTES;
152
+ const allowedKinds = opts.allowedKinds ?? ["image", "file"];
153
+ const list = Array.isArray(files) ? files : Array.from(files);
154
+ const currentCount = stagedRef.current.length;
155
+ const countAccepted = [];
156
+ for (const file of list) {
157
+ if (!isAcceptedFileType(file, accept)) {
158
+ opts.onReject?.(`"${file.name}" is not an accepted file type (${accept}).`, file);
159
+ continue;
160
+ }
161
+ if (currentCount + countAccepted.length >= maxCount) {
162
+ opts.onReject?.(`"${file.name}" was not added \u2014 the ${maxCount}-file limit is already reached.`, file);
163
+ continue;
164
+ }
165
+ countAccepted.push(file);
166
+ }
167
+ const sizeAccepted = [];
168
+ for (const file of countAccepted) {
169
+ const bytes = new Uint8Array(await file.arrayBuffer());
170
+ const sniff = sniffBinary(bytes);
171
+ const typeCheck = checkAttachmentType(file.name, sniff);
172
+ if (!typeCheck.succeeded) {
173
+ opts.onReject?.(typeCheck.message, file);
174
+ continue;
175
+ }
176
+ const limit = sniff.binary ? maxBinaryBytes : maxTextBytes;
177
+ if (file.size > limit) {
178
+ opts.onReject?.(attachmentSizeErrorMessage(file.name, file.size, limit), file);
179
+ continue;
180
+ }
181
+ const mediaType = sniff.mime ?? file.type ?? "";
182
+ const kind = kindForMime(mediaType);
183
+ if (!allowedKinds.includes(kind)) {
184
+ opts.onReject?.(`"${file.name}" is a ${kind} attachment, which isn't accepted here`, file);
185
+ continue;
186
+ }
187
+ sizeAccepted.push(file);
188
+ }
189
+ const accepted = [];
190
+ let totalBytes = stagedRef.current.reduce((total, s) => total + s.size, 0);
191
+ for (const file of sizeAccepted) {
192
+ const nextTotalBytes = totalBytes + file.size;
193
+ if (nextTotalBytes > maxTotalBytes) {
194
+ opts.onReject?.(attachmentTotalSizeErrorMessage(nextTotalBytes, maxTotalBytes), file);
195
+ continue;
196
+ }
197
+ accepted.push(file);
198
+ totalBytes = nextTotalBytes;
199
+ }
200
+ if (accepted.length === 0) return;
201
+ const taken = new Set(stagedRef.current.map((s) => s.name));
202
+ const entries = accepted.map((file) => {
203
+ const name = dedupeName(sanitizeAttachmentFileName(file.name), taken);
204
+ taken.add(name);
205
+ return {
206
+ id: newId(),
207
+ file,
208
+ name,
209
+ size: file.size,
210
+ status: "pending",
211
+ previewUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : void 0
212
+ };
213
+ });
214
+ setStaged((prev) => [...prev, ...entries]);
215
+ for (const entry of entries) void upload(entry.id, entry.file, entry.name);
216
+ },
217
+ [setStaged, upload]
218
+ );
219
+ const retry = useCallback(
220
+ (id) => {
221
+ const entry = stagedRef.current.find((s) => s.id === id);
222
+ if (!entry) return;
223
+ void upload(entry.id, entry.file, entry.name);
224
+ },
225
+ [upload]
226
+ );
227
+ const removeAttachment = useCallback(
228
+ (id) => {
229
+ controllersRef.current.get(id)?.abort();
230
+ controllersRef.current.delete(id);
231
+ const entry = stagedRef.current.find((s) => s.id === id);
232
+ if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl);
233
+ setStaged((prev) => prev.filter((s) => s.id !== id));
234
+ },
235
+ [setStaged]
236
+ );
237
+ const clear = useCallback(() => {
238
+ for (const controller of controllersRef.current.values()) controller.abort();
239
+ controllersRef.current.clear();
240
+ for (const entry of stagedRef.current) {
241
+ if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
242
+ }
243
+ setStaged([]);
244
+ }, [setStaged]);
245
+ useEffect(
246
+ () => () => {
247
+ for (const controller of controllersRef.current.values()) controller.abort();
248
+ controllersRef.current.clear();
249
+ for (const entry of stagedRef.current) {
250
+ if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
251
+ }
252
+ },
253
+ []
254
+ );
255
+ const composerFiles = useMemo(
256
+ () => staged.map((s) => ({
257
+ id: s.id,
258
+ name: s.name,
259
+ size: s.size,
260
+ kind: "file",
261
+ status: s.status
262
+ })),
263
+ [staged]
264
+ );
265
+ const references = useMemo(
266
+ () => staged.filter((s) => s.status === "ready" && !!s.reference).map((s) => s.reference),
267
+ [staged]
268
+ );
269
+ const hasPending = useMemo(
270
+ () => staged.some((s) => s.status === "pending" || s.status === "uploading"),
271
+ [staged]
272
+ );
273
+ const hasError = useMemo(() => staged.some((s) => s.status === "error"), [staged]);
274
+ const enabled = options.enabled ?? true;
275
+ const blockReason = !enabled ? "Attachments are disabled" : hasPending ? "Attachments are still uploading" : hasError ? "Remove failed attachments to send" : null;
276
+ return {
277
+ composerFiles,
278
+ references,
279
+ addFiles,
280
+ retry,
281
+ removeAttachment,
282
+ clear,
283
+ hasPending,
284
+ hasError,
285
+ blockReason
286
+ };
287
+ }
288
+
289
+ export {
290
+ useComposerAttachments
291
+ };
292
+ //# sourceMappingURL=chunk-4OO7P3ZF.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/web-react/use-composer-attachments.ts"],"sourcesContent":["/**\n * `useComposerAttachments` — the composer's staged-upload lifecycle: validate\n * selected/dropped/pasted files against the shared limits (the SAME\n * `sniffBinary`/`checkAttachmentType`/size-cap vocabulary the store-backed\n * upload route enforces server-side, `../chat-routes/attachment-validation`\n * + `../chat-routes/binary-sniff`), upload each accepted file with one POST\n * request per file (so a single failure never poisons the batch), and track\n * every file's status so a host composer can render chips and gate sending.\n *\n * Ported from gtm-agent's `src/components/composer-attachments.tsx`\n * (gtm#584/#592/#593 hardened the sniff gate and batch semantics this leans\n * on), de-gtm-ified:\n * - the hardcoded `/api/vault/upload?workspaceId=` URL becomes\n * `uploadUrl`/`buildUploadRequest` (the latter wins — it hands back both\n * the URL and a `RequestInit` override, e.g. an auth header);\n * - `sonner` toasts become `onReject` (client pre-validation, never hits the\n * network) and `onError` (a request that reached the server and failed);\n * - the sandbox-ui `validateComposerFiles` import becomes a small\n * accept-list matcher re-implemented locally (`isAcceptedFileType`,\n * mirroring its `accept`-string matching byte-for-byte) — this module\n * stays free of the sandbox-ui peer;\n * - the response is expected to be `{ files: ChatAttachmentInput[] }` (full\n * server-authoritative descriptors — size/mediaType/kind — not gtm's\n * `{path, name}`), so `references` is a verbatim pass-through with no\n * client recompute;\n * - `workspaceId`'s truthiness gate becomes `enabled` (default `true`).\n *\n * Import-free beyond React + the browser-safe `/chat-routes` validation core:\n * this module ships through `/web-react` into client bundles\n * (`tests/browser-safe-subpaths.test.ts` walks the graph), so nothing here\n * may reach a Node builtin, `sandbox-ui`, or an engine package.\n */\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport type { ChatAttachmentInput, ChatAttachmentKind } from './chat-stream'\nimport type { ComposerFile } from './chat-composer'\nimport {\n ATTACHMENT_ACCEPT,\n ATTACHMENT_MAX_COUNT,\n MAX_ATTACHMENT_TOTAL_BYTES,\n MAX_BINARY_ATTACHMENT_BYTES,\n MAX_TEXT_ATTACHMENT_BYTES,\n attachmentSizeErrorMessage,\n attachmentTotalSizeErrorMessage,\n checkAttachmentType,\n sanitizeAttachmentFileName,\n} from '../chat-routes/attachment-validation'\nimport { sniffBinary } from '../chat-routes/binary-sniff'\n\nexport { ATTACHMENT_ACCEPT } from '../chat-routes/attachment-validation'\n\n/** One staged file and its upload lifecycle. `file` is retained so a failed\n * upload can be retried without re-selecting; `previewUrl` is an object URL\n * for image thumbnails and must be revoked when the entry leaves the queue.\n * `reference` is the server's authoritative descriptor once the upload\n * lands — stored verbatim, never recomputed client-side. */\ninterface StagedAttachment {\n id: string\n file: File\n name: string\n size: number\n status: 'pending' | 'uploading' | 'ready' | 'error'\n reference?: ChatAttachmentInput\n previewUrl?: string\n errorMessage?: string\n}\n\n/** Define options for configuring file upload behavior and handling in a composer component */\nexport interface UseComposerAttachmentsOptions {\n /** Simple upload target: every file POSTs here. Ignored when\n * `buildUploadRequest` is provided. */\n uploadUrl?: string\n /** Full request-building seam (auth headers, per-file routing, …) — wins\n * over `uploadUrl` when both are set. */\n buildUploadRequest?: (args: { file: File; name: string; form: FormData }) => {\n url: string\n init?: Omit<RequestInit, 'body' | 'signal'>\n }\n /** Client pre-validation rejections — a file that never reaches the\n * network (bad type, over a size cap, over count, disallowed kind). */\n onReject?: (reason: string, file?: File) => void\n /** A file that reached the upload endpoint and failed (HTTP error,\n * transport error, malformed response). */\n onError?: (reason: string) => void\n limits?: {\n maxCount?: number\n maxBinaryBytes?: number\n maxTextBytes?: number\n maxTotalBytes?: number\n }\n /** Attachment kinds accepted, checked against the sniffed content's\n * mime. Default: both (`['image', 'file']` — i.e. no restriction). */\n allowedKinds?: ChatAttachmentKind[]\n /** `<input accept>`-style gate for the file picker/drop/paste path.\n * Default {@link ATTACHMENT_ACCEPT}. */\n accept?: string\n /** When `false`, `addFiles` rejects every call via `onReject` (and\n * `blockReason` explains why) instead of staging anything — the\n * replacement for gtm's `workspaceId`-truthiness gate (e.g. no workspace\n * loaded yet). Default `true`. */\n enabled?: boolean\n}\n\n/** Provide staged file chips, ready attachments, and methods to add, retry, or drop composer files */\nexport interface UseComposerAttachmentsResult {\n /** Chip models for `ChatComposer`'s `pendingFiles` prop, one per staged\n * file — `kind` is always `'file'` (agent-app's `ComposerFile.kind`\n * discriminates file-vs-folder chips, not attachment media type). */\n composerFiles: ComposerFile[]\n /** Ready-to-send attachment descriptors — only files whose upload\n * succeeded, straight from the server's response (no recompute). Feed\n * this into `ChatTurnRequestPayload.attachments`. */\n references: ChatAttachmentInput[]\n /** Validate + stage + upload the given files, one request per file. */\n addFiles: (files: File[] | FileList) => Promise<void>\n /** Re-upload a failed entry using its retained `File`. */\n retry: (id: string) => void\n /** Drop one staged entry, aborting its upload and revoking its preview. */\n removeAttachment: (id: string) => void\n /** Forget every staged entry (call after a successful send). */\n clear: () => void\n /** True while any file is still pending or uploading. */\n hasPending: boolean\n /** True while any file failed to upload. */\n hasError: boolean\n /** Why a send is blocked, or `null` when the queue is clean. */\n blockReason: string | null\n}\n\nfunction newId(): string {\n const cryptoObject = globalThis.crypto\n if (typeof cryptoObject?.randomUUID === 'function') return cryptoObject.randomUUID()\n return `att-${Date.now()}-${Math.random().toString(36).slice(2)}`\n}\n\n/** Suffix a name (`report.pdf` → `report-2.pdf`) until it's unused. The\n * server writes to a name-derived store path, so identical names would\n * overwrite. The suffix stays inside the store-path charset (see\n * `sanitizeAttachmentFileName`). Ported byte-for-byte from gtm's\n * `dedupeName`. */\nfunction dedupeName(name: string, taken: Set<string>): string {\n if (!taken.has(name)) return name\n const dot = name.lastIndexOf('.')\n const base = dot > 0 ? name.slice(0, dot) : name\n const ext = dot > 0 ? name.slice(dot) : ''\n let n = 2\n let candidate = `${base}-${n}${ext}`\n while (taken.has(candidate)) {\n n += 1\n candidate = `${base}-${n}${ext}`\n }\n return candidate\n}\n\n/** `image/*` → `'image'`, everything else → `'file'`. Deliberately\n * reimplemented here (not imported from `../chat-store/parts`, which pulls\n * the drizzle-adjacent `/chat-store` barrel): this module must stay reachable\n * from a browser bundle with only the `/chat-routes` validation core as a\n * dependency. */\nfunction kindForMime(mime: string): ChatAttachmentKind {\n return mime.startsWith('image/') ? 'image' : 'file'\n}\n\n/** `<input accept>`-style matcher: extension (`.pdf`), wildcard mime\n * (`image/*`), or exact mime. Reimplemented locally (NOT imported from\n * `@tangle-network/sandbox-ui`'s `validateComposerFiles`/`isAcceptedType`) so\n * this module has no sandbox-ui dependency; the matching semantics are kept\n * identical so a rejection reads the same either side of the fence. */\nfunction isAcceptedFileType(file: File, accept: string): boolean {\n const patterns = accept.split(',').map((p) => p.trim()).filter((p) => p.length > 0)\n if (patterns.length === 0) return true\n const name = file.name.toLowerCase()\n const type = (file.type || '').toLowerCase()\n return patterns.some((pattern) => {\n const lower = pattern.toLowerCase()\n if (lower.startsWith('.')) return name.endsWith(lower)\n if (lower.endsWith('/*')) return type.startsWith(lower.slice(0, -1))\n return type === lower\n })\n}\n\n/** Pull a human-readable message out of the upload endpoint's error body.\n * Ported from gtm's `parseUploadError`: handles both `{ error: string }`\n * (size/count/access errors) and `{ error: { message } }` (the\n * `createAttachmentUploadRoute` envelope, `{error:{code,message,path?}}`). */\nasync function parseUploadError(res: Response): Promise<string> {\n const detail = await res.json().catch(() => null)\n if (detail && typeof detail === 'object' && 'error' in detail) {\n const error = (detail as { error: unknown }).error\n if (typeof error === 'string' && error) return error\n if (error && typeof error === 'object' && 'message' in error) {\n const message = (error as { message: unknown }).message\n if (typeof message === 'string' && message) return message\n }\n }\n return `Upload failed (${res.status})`\n}\n\n/** Shown when neither `uploadUrl` nor `buildUploadRequest` is configured\n * while `enabled` — a product wiring bug, not a user-facing rejection, so it\n * lands each affected entry in `error` (with `onError`) rather than blocking\n * `addFiles` outright via `onReject`: the files still stage and can be\n * retried once the product fixes its config, instead of silently vanishing. */\nconst NO_UPLOAD_TARGET_MESSAGE = 'No upload destination configured (pass uploadUrl or buildUploadRequest)'\n\n/**\n * Owns the composer's attachment lifecycle: validate selected/dropped/pasted\n * files against the shared limits, upload each accepted file to the\n * product's store (one request per file), and track every file's status so\n * the composer can render chips and gate sending.\n *\n * Failures surface loud — a rejected file calls `onReject` and is never\n * uploaded; a failed upload calls `onError` and leaves an error chip the user\n * can retry or remove. `references` only ever contains files whose upload the\n * server actually confirmed.\n */\nexport function useComposerAttachments(\n options: UseComposerAttachmentsOptions,\n): UseComposerAttachmentsResult {\n // Latest options, read from inside stable callbacks — avoids re-creating\n // `addFiles`/`upload` (and therefore breaking referential stability for\n // effects a host might hang off them) every time a caller passes a fresh\n // options object literal.\n const optionsRef = useRef(options)\n optionsRef.current = options\n\n const [staged, setStagedState] = useState<StagedAttachment[]>([])\n // Mirror of `staged` kept in lockstep so dedupe/aggregate-cap/abort read\n // current values synchronously (setState callbacks alone can't answer\n // \"what's staged right now\" mid-validation).\n const stagedRef = useRef<StagedAttachment[]>([])\n const controllersRef = useRef<Map<string, AbortController>>(new Map())\n\n // Post-unmount calls reduce to a React no-op setState; the refs they touch\n // die with the instance.\n const setStaged = useCallback(\n (updater: StagedAttachment[] | ((prev: StagedAttachment[]) => StagedAttachment[])) => {\n const next =\n typeof updater === 'function'\n ? (updater as (prev: StagedAttachment[]) => StagedAttachment[])(stagedRef.current)\n : updater\n stagedRef.current = next\n setStagedState(next)\n },\n [],\n )\n\n const upload = useCallback(\n async (id: string, file: File, name: string) => {\n const opts = optionsRef.current\n setStaged((prev) =>\n prev.map((s) => (s.id === id ? { ...s, status: 'uploading', errorMessage: undefined } : s)),\n )\n const controller = new AbortController()\n controllersRef.current.set(id, controller)\n const form = new FormData()\n form.append('file', file, name)\n\n const request = opts.buildUploadRequest\n ? opts.buildUploadRequest({ file, name, form })\n : opts.uploadUrl\n ? { url: opts.uploadUrl }\n : null\n\n if (!request) {\n setStaged((prev) =>\n prev.map((s) =>\n s.id === id ? { ...s, status: 'error', errorMessage: NO_UPLOAD_TARGET_MESSAGE } : s,\n ),\n )\n opts.onError?.(NO_UPLOAD_TARGET_MESSAGE)\n controllersRef.current.delete(id)\n return\n }\n\n try {\n const res = await fetch(request.url, {\n method: 'POST',\n credentials: 'same-origin',\n ...request.init,\n body: form,\n signal: controller.signal,\n })\n if (!res.ok) {\n const message = await parseUploadError(res)\n setStaged((prev) =>\n prev.map((s) => (s.id === id ? { ...s, status: 'error', errorMessage: message } : s)),\n )\n opts.onError?.(message)\n return\n }\n const data = (await res.json()) as { files?: ChatAttachmentInput[] }\n const uploaded = data.files?.[0]\n if (!uploaded) {\n const message = 'Upload returned no file'\n setStaged((prev) =>\n prev.map((s) => (s.id === id ? { ...s, status: 'error', errorMessage: message } : s)),\n )\n opts.onError?.(message)\n return\n }\n setStaged((prev) =>\n prev.map((s) => (s.id === id ? { ...s, status: 'ready', reference: uploaded } : s)),\n )\n } catch (err) {\n if ((err as Error).name === 'AbortError') return // silent removal — see removeAttachment/clear\n const message =\n err instanceof Error && err.message ? err.message : 'Upload failed — check your connection'\n setStaged((prev) =>\n prev.map((s) => (s.id === id ? { ...s, status: 'error', errorMessage: message } : s)),\n )\n opts.onError?.(message)\n } finally {\n controllersRef.current.delete(id)\n }\n },\n [setStaged],\n )\n\n const addFiles = useCallback(\n async (files: File[] | FileList) => {\n const opts = optionsRef.current\n const enabled = opts.enabled ?? true\n if (!enabled) {\n opts.onReject?.('Attachments are disabled')\n return\n }\n\n const accept = opts.accept ?? ATTACHMENT_ACCEPT\n const maxCount = opts.limits?.maxCount ?? ATTACHMENT_MAX_COUNT\n const maxBinaryBytes = opts.limits?.maxBinaryBytes ?? MAX_BINARY_ATTACHMENT_BYTES\n const maxTextBytes = opts.limits?.maxTextBytes ?? MAX_TEXT_ATTACHMENT_BYTES\n const maxTotalBytes = opts.limits?.maxTotalBytes ?? MAX_ATTACHMENT_TOTAL_BYTES\n const allowedKinds = opts.allowedKinds ?? (['image', 'file'] as ChatAttachmentKind[])\n\n const list = Array.isArray(files) ? files : Array.from(files)\n\n // Pass 1: accept-list + count cap, mirroring sandbox-ui's\n // `validateComposerFiles` semantics (accept checked before count, count\n // checked against currently-staged + already-accepted-this-batch).\n const currentCount = stagedRef.current.length\n const countAccepted: File[] = []\n for (const file of list) {\n if (!isAcceptedFileType(file, accept)) {\n opts.onReject?.(`\"${file.name}\" is not an accepted file type (${accept}).`, file)\n continue\n }\n if (currentCount + countAccepted.length >= maxCount) {\n opts.onReject?.(`\"${file.name}\" was not added — the ${maxCount}-file limit is already reached.`, file)\n continue\n }\n countAccepted.push(file)\n }\n\n // Pass 2: real content sniff + type gate + per-kind size cap +\n // allowed-kinds gate — the SAME checks the server enforces, so a\n // rejection never differs depending on which side classified the bytes\n // first. Nothing here ever reaches the network.\n const sizeAccepted: File[] = []\n for (const file of countAccepted) {\n const bytes = new Uint8Array(await file.arrayBuffer())\n const sniff = sniffBinary(bytes)\n const typeCheck = checkAttachmentType(file.name, sniff)\n if (!typeCheck.succeeded) {\n opts.onReject?.(typeCheck.message, file)\n continue\n }\n const limit = sniff.binary ? maxBinaryBytes : maxTextBytes\n if (file.size > limit) {\n opts.onReject?.(attachmentSizeErrorMessage(file.name, file.size, limit), file)\n continue\n }\n const mediaType = sniff.mime ?? file.type ?? ''\n const kind = kindForMime(mediaType)\n if (!allowedKinds.includes(kind)) {\n opts.onReject?.(`\"${file.name}\" is a ${kind} attachment, which isn't accepted here`, file)\n continue\n }\n sizeAccepted.push(file)\n }\n\n // Pass 3: running aggregate cap across this batch + everything already\n // staged (any status) — a partial batch can still land.\n const accepted: File[] = []\n let totalBytes = stagedRef.current.reduce((total, s) => total + s.size, 0)\n for (const file of sizeAccepted) {\n const nextTotalBytes = totalBytes + file.size\n if (nextTotalBytes > maxTotalBytes) {\n opts.onReject?.(attachmentTotalSizeErrorMessage(nextTotalBytes, maxTotalBytes), file)\n continue\n }\n accepted.push(file)\n totalBytes = nextTotalBytes\n }\n if (accepted.length === 0) return\n\n // Stage under the name the server will actually store, so the chip and\n // the message's attachment references never diverge.\n const taken = new Set(stagedRef.current.map((s) => s.name))\n const entries: StagedAttachment[] = accepted.map((file) => {\n const name = dedupeName(sanitizeAttachmentFileName(file.name), taken)\n taken.add(name)\n return {\n id: newId(),\n file,\n name,\n size: file.size,\n status: 'pending',\n previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined,\n }\n })\n setStaged((prev) => [...prev, ...entries])\n for (const entry of entries) void upload(entry.id, entry.file, entry.name)\n },\n [setStaged, upload],\n )\n\n const retry = useCallback(\n (id: string) => {\n const entry = stagedRef.current.find((s) => s.id === id)\n if (!entry) return\n void upload(entry.id, entry.file, entry.name)\n },\n [upload],\n )\n\n const removeAttachment = useCallback(\n (id: string) => {\n controllersRef.current.get(id)?.abort()\n controllersRef.current.delete(id)\n const entry = stagedRef.current.find((s) => s.id === id)\n if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl)\n setStaged((prev) => prev.filter((s) => s.id !== id))\n },\n [setStaged],\n )\n\n const clear = useCallback(() => {\n for (const controller of controllersRef.current.values()) controller.abort()\n controllersRef.current.clear()\n for (const entry of stagedRef.current) {\n if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl)\n }\n setStaged([])\n }, [setStaged])\n\n useEffect(\n () => () => {\n for (const controller of controllersRef.current.values()) controller.abort()\n controllersRef.current.clear()\n for (const entry of stagedRef.current) {\n if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl)\n }\n },\n [],\n )\n\n const composerFiles = useMemo<ComposerFile[]>(\n () =>\n staged.map((s) => ({\n id: s.id,\n name: s.name,\n size: s.size,\n kind: 'file' as const,\n status: s.status,\n })),\n [staged],\n )\n\n const references = useMemo<ChatAttachmentInput[]>(\n () =>\n staged\n .filter((s): s is StagedAttachment & { reference: ChatAttachmentInput } => s.status === 'ready' && !!s.reference)\n .map((s) => s.reference),\n [staged],\n )\n\n const hasPending = useMemo(\n () => staged.some((s) => s.status === 'pending' || s.status === 'uploading'),\n [staged],\n )\n const hasError = useMemo(() => staged.some((s) => s.status === 'error'), [staged])\n const enabled = options.enabled ?? true\n const blockReason = !enabled\n ? 'Attachments are disabled'\n : hasPending\n ? 'Attachments are still uploading'\n : hasError\n ? 'Remove failed attachments to send'\n : null\n\n return {\n composerFiles,\n references,\n addFiles,\n retry,\n removeAttachment,\n clear,\n hasPending,\n hasError,\n blockReason,\n }\n}\n"],"mappings":"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","names":["enabled"]}