@saadnvd1/agent-os 0.3.672 → 0.3.673

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 (44) hide show
  1. package/app/api/files/upload-temp/route.test.ts +144 -0
  2. package/app/api/files/upload-temp/route.ts +52 -21
  3. package/app/api/sessions/[id]/route.ts +2 -0
  4. package/app/api/sessions/route.ts +6 -2
  5. package/components/Chat/ChatPanel.tsx +9 -2
  6. package/components/Chat/FileChip.tsx +67 -0
  7. package/components/Chat/Messages.tsx +8 -0
  8. package/components/Chat/Queue.tsx +6 -0
  9. package/components/Chat/composer/Attachments.tsx +16 -2
  10. package/components/Chat/composer/ComposerBody.tsx +35 -14
  11. package/components/Chat/composer/Toolbar.tsx +11 -8
  12. package/components/Chat/composer/editor.test.ts +1 -1
  13. package/components/Chat/composer/paste.ts +5 -4
  14. package/components/Chat/composer/useAttachFiles.ts +142 -0
  15. package/components/Chat/composer/useComposerEditor.ts +3 -3
  16. package/components/Chat/useDraft.ts +9 -2
  17. package/components/Draft/DraftPanel.tsx +7 -4
  18. package/components/Terminal/index.tsx +6 -5
  19. package/data/chat/useChat.ts +10 -3
  20. package/data/client-config/queries.ts +18 -0
  21. package/data/sessions/queries.ts +2 -1
  22. package/lib/attachments/check.ts +50 -0
  23. package/lib/attachments/message.ts +49 -0
  24. package/lib/attachments/policy.ts +116 -0
  25. package/lib/attachments/store.ts +92 -0
  26. package/lib/attachments/types.ts +8 -0
  27. package/lib/chat/client-send.ts +6 -2
  28. package/lib/chat/driver.ts +4 -1
  29. package/lib/chat/drivers/claude.ts +2 -0
  30. package/lib/chat/events.ts +13 -1
  31. package/lib/chat/pending.ts +2 -1
  32. package/lib/chat/queued.ts +24 -7
  33. package/lib/chat/runner.ts +11 -2
  34. package/lib/chat/worker/host.ts +7 -3
  35. package/lib/chat/worker/protocol.ts +2 -0
  36. package/lib/config/client-config-types.ts +14 -3
  37. package/lib/config/client-config.ts +1 -1
  38. package/lib/db/migrations/20261019T1200_chat_queue_files.ts +14 -0
  39. package/lib/file-upload.ts +61 -27
  40. package/lib/intake/intake.ts +2 -17
  41. package/lib/intake/sniff.ts +1 -1
  42. package/lib/sessions/launch.ts +12 -6
  43. package/package.json +1 -1
  44. package/components/Chat/composer/useImageDrop.ts +0 -52
@@ -8,7 +8,8 @@ export interface PasteHandlers {
8
8
  plain: boolean;
9
9
  // Cmd/Ctrl+Shift+V was pressed just before this paste.
10
10
  asText: boolean;
11
- onImages: (files: File[]) => void;
11
+ // Files pasted: images, documents, anything the composer may attach.
12
+ onFiles: (files: File[]) => void;
12
13
  onLongPaste: (text: string, language?: string) => void;
13
14
  }
14
15
 
@@ -58,9 +59,9 @@ export function handlePaste(
58
59
  ): boolean {
59
60
  const data = event.clipboardData;
60
61
  if (!data) return false;
61
- const images = [...data.files].filter(isImage);
62
- if (images.length) {
63
- h.onImages(images);
62
+ const files = [...data.files];
63
+ if (files.length) {
64
+ h.onFiles(files);
64
65
  return true;
65
66
  }
66
67
  const text = data.getData("text/plain").replace(/\r\n?/g, "\n");
@@ -0,0 +1,142 @@
1
+ "use client";
2
+
3
+ import { useRef, useState } from "react";
4
+ import { useQueryClient } from "@tanstack/react-query";
5
+ import { toast } from "sonner";
6
+ import {
7
+ clientConfigQuery,
8
+ useClientConfig,
9
+ } from "@/data/client-config/queries";
10
+ import {
11
+ acceptAttr,
12
+ tooBigMessage,
13
+ typeFor,
14
+ unsupportedMessage,
15
+ } from "@/lib/attachments/policy";
16
+ import type { ChatFile, ChatImage } from "@/lib/chat/events";
17
+ import { uploadFile } from "@/lib/file-upload";
18
+ import { isImage } from "./paste";
19
+
20
+ export interface Upload {
21
+ key: string;
22
+ file: ChatFile;
23
+ uploading?: boolean;
24
+ }
25
+
26
+ function readImage(file: File): Promise<ChatImage> {
27
+ return new Promise((resolve, reject) => {
28
+ const reader = new FileReader();
29
+ reader.onload = () => {
30
+ const [meta, data] = String(reader.result).split(",");
31
+ resolve({ mediaType: meta.slice(5, meta.indexOf(";")), data });
32
+ };
33
+ reader.onerror = reject;
34
+ reader.readAsDataURL(file);
35
+ });
36
+ }
37
+
38
+ const carriesFiles = (e: React.DragEvent) =>
39
+ [...e.dataTransfer.types].includes("Files");
40
+
41
+ let seq = 0;
42
+
43
+ // Files picked, pasted or dropped onto the composer, and the outline shown
44
+ // while something is dragged over it. Images are read in and sent with the
45
+ // message; any other file is uploaded to the session's attachments folder
46
+ // at once, so a wrong type or a too-big file fails here, not at send. The
47
+ // types and sizes come from the server (/api/client-config).
48
+ export function useAttachFiles({
49
+ scope,
50
+ imageCount,
51
+ onImages,
52
+ }: {
53
+ // The session's id, or "draft" for a chat that hasn't started.
54
+ scope: string;
55
+ imageCount: number;
56
+ onImages: (images: ChatImage[]) => void;
57
+ }) {
58
+ const queryClient = useQueryClient();
59
+ const types = useClientConfig().data?.attachments.types;
60
+ const [dragging, setDragging] = useState(false);
61
+ const [uploads, setUploads] = useState<Upload[]>([]);
62
+ const held = useRef(0);
63
+ held.current = imageCount + uploads.length;
64
+
65
+ const addFiles = async (picked: Iterable<File>) => {
66
+ // Copied first: a file input's live list is emptied right after the pick.
67
+ const list = [...picked];
68
+ let limits;
69
+ try {
70
+ limits = (await queryClient.fetchQuery(clientConfigQuery)).attachments;
71
+ } catch (e) {
72
+ toast.error(e instanceof Error ? e.message : "Couldn't attach");
73
+ return;
74
+ }
75
+ const images: File[] = [];
76
+ const others: File[] = [];
77
+ for (const f of list) {
78
+ let error: string | null = null;
79
+ if (held.current + images.length + others.length >= limits.max)
80
+ error = `You can attach up to ${limits.max} files to one message.`;
81
+ else if (isImage(f)) {
82
+ if (f.size > limits.maxImageBytes)
83
+ error = tooBigMessage(f.name, f.size, limits.maxImageBytes);
84
+ else images.push(f);
85
+ } else if (!typeFor(f.name, limits.types))
86
+ error = unsupportedMessage(f.name);
87
+ else if (f.size > limits.maxBytes)
88
+ error = tooBigMessage(f.name, f.size, limits.maxBytes);
89
+ else others.push(f);
90
+ if (error) toast.error(error);
91
+ }
92
+ if (images.length) onImages(await Promise.all(images.map(readImage)));
93
+ for (const f of others) {
94
+ const key = `up-${++seq}`;
95
+ const file = { path: "", name: f.name, size: f.size, mediaType: f.type };
96
+ setUploads((prev) => [...prev, { key, file, uploading: true }]);
97
+ const done = await uploadFile(f, scope);
98
+ if (done.ok)
99
+ setUploads((prev) =>
100
+ prev.map((u) => (u.key === key ? { key, file: done.file } : u))
101
+ );
102
+ else {
103
+ setUploads((prev) => prev.filter((u) => u.key !== key));
104
+ toast.error(done.error);
105
+ }
106
+ }
107
+ };
108
+
109
+ const dropProps = {
110
+ onDragOver: (e: React.DragEvent) => {
111
+ if (!carriesFiles(e)) return;
112
+ e.preventDefault();
113
+ e.dataTransfer.dropEffect = "copy";
114
+ setDragging(true);
115
+ },
116
+ onDragLeave: (e: React.DragEvent) => {
117
+ if (!e.currentTarget.contains(e.relatedTarget as Node | null))
118
+ setDragging(false);
119
+ },
120
+ onDrop: (e: React.DragEvent) => {
121
+ if (!carriesFiles(e)) return;
122
+ e.preventDefault();
123
+ setDragging(false);
124
+ void addFiles(e.dataTransfer.files);
125
+ },
126
+ };
127
+
128
+ return {
129
+ dragging,
130
+ dropProps,
131
+ addFiles,
132
+ uploads,
133
+ // Until the limits are read, the picker offers images only.
134
+ accept: types ? `image/*,${acceptAttr(types)}` : "image/*",
135
+ uploading: uploads.some((u) => u.uploading),
136
+ remove: (key: string) =>
137
+ setUploads((prev) => prev.filter((u) => u.key !== key)),
138
+ clear: () => setUploads([]),
139
+ restore: (files: ChatFile[]) =>
140
+ setUploads(files.map((file) => ({ key: `up-${++seq}`, file }))),
141
+ };
142
+ }
@@ -13,7 +13,7 @@ import { docToMarkdown } from "@/lib/chat/markdown/serialize";
13
13
  import { plainExtensions, richExtensions } from "./extensions";
14
14
  import { loadMarkdown } from "./load";
15
15
  import { handleKeyDown, type KeyHandlers } from "./keys";
16
- import { handlePaste, isImage, type PasteHandlers } from "./paste";
16
+ import { handlePaste, type PasteHandlers } from "./paste";
17
17
  import { usePlainMode } from "./usePlainMode";
18
18
 
19
19
  export type ComposerHandlers = Omit<
@@ -120,9 +120,9 @@ export function useComposerEditor({
120
120
  asText,
121
121
  });
122
122
  },
123
- // Dropped images are the composer's to take, not the document's.
123
+ // Dropped files are the composer's to take, not the document's.
124
124
  handleDrop: (view, event) =>
125
- [...(event.dataTransfer?.files ?? [])].some(isImage),
125
+ (event.dataTransfer?.files.length ?? 0) > 0,
126
126
  },
127
127
  // Only edits change the text: not loading a draft, not setEditable.
128
128
  onUpdate: ({ editor: ed, transaction }) => {
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useRef } from "react";
4
- import type { ChatImage } from "@/lib/chat/events";
4
+ import type { ChatFile, ChatImage } from "@/lib/chat/events";
5
5
  import type { TextAttachment } from "@/lib/chat/paste";
6
6
 
7
7
  // `text` is the composer's markdown, so a reload restores its formatting.
@@ -9,6 +9,8 @@ interface Draft {
9
9
  text: string;
10
10
  images: ChatImage[];
11
11
  files?: TextAttachment[];
12
+ // Attached files, saved on the server: references only.
13
+ uploads?: ChatFile[];
12
14
  }
13
15
 
14
16
  const keyFor = (id: string) => `agentos:draft:${id}`;
@@ -39,7 +41,12 @@ export function useSaveDraft(id: string | undefined, draft: Draft): void {
39
41
  clearTimeout(timer.current);
40
42
  timer.current = setTimeout(() => {
41
43
  const key = keyFor(id);
42
- if (!draft.text.trim() && !draft.images.length && !draft.files?.length) {
44
+ if (
45
+ !draft.text.trim() &&
46
+ !draft.images.length &&
47
+ !draft.files?.length &&
48
+ !draft.uploads?.length
49
+ ) {
43
50
  localStorage.removeItem(key);
44
51
  return;
45
52
  }
@@ -5,7 +5,7 @@ import { useSnapshot } from "valtio";
5
5
  import { toast } from "sonner";
6
6
  import { Composer } from "@/components/Chat/Composer";
7
7
  import { Button } from "@/components/ui/button";
8
- import type { ChatImage } from "@/lib/chat/events";
8
+ import type { ChatFile, ChatImage } from "@/lib/chat/events";
9
9
  import type { Project } from "@/lib/db";
10
10
  import { draftComposerKey, type Draft } from "@/lib/drafts";
11
11
  import { getModelOptions } from "@/lib/model-catalog";
@@ -57,11 +57,12 @@ export function DraftPanel({
57
57
  </div>
58
58
  );
59
59
 
60
- const send = async (text: string, images: ChatImage[]) => {
60
+ const send = async (text: string, images: ChatImage[], files: ChatFile[]) => {
61
61
  try {
62
62
  if (draft.openPr) {
63
63
  if (!project) throw new Error("Pick a project for a task");
64
- if (images.length) throw new Error("A task takes text only");
64
+ if (images.length || files.length)
65
+ throw new Error("A task takes text only");
65
66
  const { session, queued } = await createTask.mutateAsync({
66
67
  projectId: project.id,
67
68
  prompt: text,
@@ -96,6 +97,7 @@ export function DraftPanel({
96
97
  baseBranch: draft.baseBranch,
97
98
  prompt: text,
98
99
  images: images.length ? images : undefined,
100
+ files: files.length ? files : undefined,
99
101
  });
100
102
  // A resend whose first answer was lost: the terminal still needs its
101
103
  // first prompt, and this is the text that was sent.
@@ -134,6 +136,7 @@ export function DraftPanel({
134
136
  />
135
137
  <Composer
136
138
  draftKey={draftComposerKey(draft.id)}
139
+ attachScope="draft"
137
140
  running={false}
138
141
  disabled={pending}
139
142
  placeholder={
@@ -143,7 +146,7 @@ export function DraftPanel({
143
146
  ? "Describe the task"
144
147
  : `Message ${project?.name ?? "a new chat"}`
145
148
  }
146
- onSend={(text, images) => void send(text, images)}
149
+ onSend={(text, images, files) => void send(text, images, files)}
147
150
  onStop={() => {}}
148
151
  models={getModelOptions(draft.agentType)}
149
152
  model={draft.model}
@@ -19,7 +19,8 @@ import { useTerminalConnection, useTerminalSearch } from "./hooks";
19
19
  import type { TerminalScrollState } from "./hooks";
20
20
  import { useViewport } from "@/hooks/useViewport";
21
21
  import { useFileDrop } from "@/hooks/useFileDrop";
22
- import { uploadFileToTemp } from "@/lib/file-upload";
22
+ import { toast } from "sonner";
23
+ import { uploadFile } from "@/lib/file-upload";
23
24
  import { FilePicker } from "@/components/FilePicker";
24
25
  import type { AttachSpec } from "@/lib/hosts/attach";
25
26
 
@@ -121,11 +122,11 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(
121
122
  async (file: File) => {
122
123
  setIsUploading(true);
123
124
  try {
124
- const path = await uploadFileToTemp(file);
125
- if (path) {
126
- sendInput(path);
125
+ const done = await uploadFile(file);
126
+ if (done.ok) {
127
+ sendInput(done.file.path);
127
128
  focus();
128
- }
129
+ } else toast.error(done.error);
129
130
  } catch (err) {
130
131
  console.error("Failed to upload file:", err);
131
132
  } finally {
@@ -9,6 +9,7 @@ import type {
9
9
  ChatAccess,
10
10
  ChatCommand,
11
11
  ChatContext,
12
+ ChatFile,
12
13
  ChatImage,
13
14
  ChatItem,
14
15
  ChatModel,
@@ -34,12 +35,13 @@ import { usePending } from "./usePending";
34
35
 
35
36
  // A pending send as the socket's `send`, under its own id.
36
37
  const sendFrame = (
37
- p: Pick<PendingSend, "id" | "text" | "images" | "compact">
38
+ p: Pick<PendingSend, "id" | "text" | "images" | "files" | "compact">
38
39
  ) =>
39
40
  JSON.stringify({
40
41
  type: "send",
41
42
  text: p.text,
42
43
  images: p.images,
44
+ files: p.files,
43
45
  clientId: p.id,
44
46
  ...(p.compact && { compact: true }),
45
47
  });
@@ -400,8 +402,13 @@ export function useChat(
400
402
  const send = useCallback(
401
403
  // `compact`: the reader let a stale chat compact first (lib/chat/stale).
402
404
  // It shows as a pending bubble at once, until it's in the chat.
403
- (text: string, images?: ChatImage[], compact?: boolean) => {
404
- const p = { id: clientId(), text, images, compact };
405
+ (
406
+ text: string,
407
+ images?: ChatImage[],
408
+ compact?: boolean,
409
+ files?: ChatFile[]
410
+ ) => {
411
+ const p = { id: clientId(), text, images, files, compact };
405
412
  pend({ type: "add", send: p, now: Date.now() });
406
413
  const ws = wsRef.current;
407
414
  if (ws?.readyState !== WebSocket.OPEN) {
@@ -0,0 +1,18 @@
1
+ import { queryOptions, useQuery } from "@tanstack/react-query";
2
+ import type { ClientConfig } from "@/lib/config/client-config-types";
3
+
4
+ export const clientConfigKeys = { all: ["client-config"] as const };
5
+
6
+ // What the server says clients render with and are limited by; it only
7
+ // changes with a server release, so one read per page load.
8
+ export const clientConfigQuery = queryOptions({
9
+ queryKey: clientConfigKeys.all,
10
+ queryFn: async (): Promise<ClientConfig> => {
11
+ const res = await fetch("/api/client-config");
12
+ if (!res.ok) throw new Error("Couldn't read the server's limits");
13
+ return res.json();
14
+ },
15
+ staleTime: Infinity,
16
+ });
17
+
18
+ export const useClientConfig = () => useQuery(clientConfigQuery);
@@ -1,7 +1,7 @@
1
1
  import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
2
2
  import type { Session, Group } from "@/lib/db";
3
3
  import type { AgentType } from "@/lib/providers";
4
- import type { ChatAccess, ChatImage } from "@/lib/chat/events";
4
+ import type { ChatAccess, ChatFile, ChatImage } from "@/lib/chat/events";
5
5
  import { sessionKeys } from "./keys";
6
6
  import { usePollWhenOffline, usePushConnected } from "../push/connection";
7
7
 
@@ -192,6 +192,7 @@ export interface LaunchSessionInput {
192
192
  baseBranch: string | null;
193
193
  prompt: string;
194
194
  images?: ChatImage[];
195
+ files?: ChatFile[];
195
196
  }
196
197
 
197
198
  interface LaunchSessionResponse {
@@ -0,0 +1,50 @@
1
+ import { isText, sniff } from "../intake/sniff";
2
+ import {
3
+ ATTACHMENT_LIMITS,
4
+ tooBigMessage,
5
+ typeFor,
6
+ unsupportedMessage,
7
+ type AttachmentType,
8
+ } from "./policy";
9
+
10
+ export type UploadCheck =
11
+ | { ok: true; type: AttachmentType }
12
+ | { ok: false; status: 400 | 413 | 415; error: string };
13
+
14
+ const ZIP = [0x50, 0x4b, 0x03, 0x04];
15
+ const OLE = [0xd0, 0xcf, 0x11, 0xe0];
16
+ const OOXML_ZIP = new Set(["xlsx", "docx", "pptx", "ods", "odt"]);
17
+ const OLE_EXTS = new Set(["xls", "doc"]);
18
+
19
+ const starts = (b: Buffer, sig: number[]) => sig.every((v, i) => b[i] === v);
20
+
21
+ const bad = (name: string): UploadCheck => ({
22
+ ok: false,
23
+ status: 400,
24
+ error: `${name} doesn't look like a real .${name.split(".").pop()?.toLowerCase()} file.`,
25
+ });
26
+
27
+ /** Whether a file's name, size and bytes are something a chat accepts. */
28
+ export function checkUpload(name: string, data: Buffer): UploadCheck {
29
+ const type = typeFor(name);
30
+ if (!type) return { ok: false, status: 415, error: unsupportedMessage(name) };
31
+ const limit =
32
+ type.kind === "image"
33
+ ? ATTACHMENT_LIMITS.maxImageBytes
34
+ : ATTACHMENT_LIMITS.maxBytes;
35
+ if (data.length > limit)
36
+ return {
37
+ ok: false,
38
+ status: 413,
39
+ error: tooBigMessage(name, data.length, limit),
40
+ };
41
+ if (!data.length)
42
+ return { ok: false, status: 400, error: `${name} is empty.` };
43
+ let real: boolean;
44
+ if (type.kind === "image" || type.kind === "pdf")
45
+ real = sniff(data, name)?.mediaType === type.mediaType;
46
+ else if (OOXML_ZIP.has(type.ext)) real = starts(data, ZIP);
47
+ else if (OLE_EXTS.has(type.ext)) real = starts(data, OLE);
48
+ else real = isText(data);
49
+ return real ? { ok: true, type } : bad(name);
50
+ }
@@ -0,0 +1,49 @@
1
+ import fs from "fs";
2
+ import { ATTACHMENT_LIMITS, formatBytes } from "./policy";
3
+ import type { ChatFile } from "./types";
4
+
5
+ /**
6
+ * What the agent reads: the message plus where each file is. Every driver
7
+ * gets this, so any agent can open the files with its own tools (a
8
+ * spreadsheet through a script, a docx through pandoc). The chat keeps the
9
+ * plain text and shows the files as chips.
10
+ */
11
+ export function agentText(text: string, files?: ChatFile[]): string {
12
+ if (!files?.length) return text;
13
+ const list = files
14
+ .map((f) => `- ${f.path} (${f.mediaType}, ${formatBytes(f.size)})`)
15
+ .join("\n");
16
+ return [
17
+ text,
18
+ `Attached files, saved on this machine. Open them with your own tools:\n${list}`,
19
+ ]
20
+ .filter(Boolean)
21
+ .join("\n\n");
22
+ }
23
+
24
+ /** Claude reads a PDF itself, as a document block, when it's small enough. */
25
+ export function documentBlocks(files?: ChatFile[]) {
26
+ const out: {
27
+ type: "document";
28
+ title: string;
29
+ source: { type: "base64"; media_type: "application/pdf"; data: string };
30
+ }[] = [];
31
+ for (const f of files ?? []) {
32
+ if (f.mediaType !== "application/pdf") continue;
33
+ if (f.size > ATTACHMENT_LIMITS.pdfInlineBytes) continue;
34
+ try {
35
+ out.push({
36
+ type: "document",
37
+ title: f.name,
38
+ source: {
39
+ type: "base64",
40
+ media_type: "application/pdf",
41
+ data: fs.readFileSync(f.path).toString("base64"),
42
+ },
43
+ });
44
+ } catch {
45
+ // Unreadable: the agent still has the path.
46
+ }
47
+ }
48
+ return out;
49
+ }
@@ -0,0 +1,116 @@
1
+ // What a chat may carry besides typed text, and how big. The server owns it:
2
+ // the upload route enforces it and /api/client-config hands it to clients, so
3
+ // no client keeps its own copy. No Node imports: the browser reads this too.
4
+
5
+ export type AttachmentKind = "image" | "pdf" | "sheet" | "doc" | "text";
6
+
7
+ export interface AttachmentType {
8
+ ext: string;
9
+ mediaType: string;
10
+ kind: AttachmentKind;
11
+ }
12
+
13
+ const t = (
14
+ kind: AttachmentKind,
15
+ mediaType: string,
16
+ ...exts: string[]
17
+ ): AttachmentType[] => exts.map((ext) => ({ ext, mediaType, kind }));
18
+
19
+ const OOXML = "application/vnd.openxmlformats-officedocument";
20
+
21
+ export const ATTACHMENT_TYPES: AttachmentType[] = [
22
+ ...t("image", "image/png", "png"),
23
+ ...t("image", "image/jpeg", "jpg", "jpeg"),
24
+ ...t("image", "image/gif", "gif"),
25
+ ...t("image", "image/webp", "webp"),
26
+ ...t("pdf", "application/pdf", "pdf"),
27
+ ...t("sheet", `${OOXML}.spreadsheetml.sheet`, "xlsx"),
28
+ ...t("sheet", "application/vnd.ms-excel", "xls"),
29
+ ...t("sheet", "text/csv", "csv"),
30
+ ...t("sheet", "text/tab-separated-values", "tsv"),
31
+ ...t("sheet", "application/vnd.oasis.opendocument.spreadsheet", "ods"),
32
+ ...t("doc", `${OOXML}.wordprocessingml.document`, "docx"),
33
+ ...t("doc", "application/msword", "doc"),
34
+ ...t("doc", `${OOXML}.presentationml.presentation`, "pptx"),
35
+ ...t("doc", "application/vnd.oasis.opendocument.text", "odt"),
36
+ ...t("doc", "application/rtf", "rtf"),
37
+ ...t("text", "text/plain", "txt", "log", "env"),
38
+ ...t("text", "text/markdown", "md", "markdown"),
39
+ ...t("text", "application/json", "json", "jsonl", "ipynb"),
40
+ ...t(
41
+ "text",
42
+ "text/plain",
43
+ "yaml",
44
+ "yml",
45
+ "toml",
46
+ "xml",
47
+ "html",
48
+ "css",
49
+ "sql",
50
+ "ts",
51
+ "tsx",
52
+ "js",
53
+ "jsx",
54
+ "mjs",
55
+ "cjs",
56
+ "py",
57
+ "rb",
58
+ "go",
59
+ "rs",
60
+ "java",
61
+ "kt",
62
+ "swift",
63
+ "c",
64
+ "h",
65
+ "cpp",
66
+ "hpp",
67
+ "cs",
68
+ "php",
69
+ "diff",
70
+ "patch"
71
+ ),
72
+ ];
73
+
74
+ export const ATTACHMENT_LIMITS = {
75
+ // Files and images on one message.
76
+ max: 8,
77
+ // One non-image file.
78
+ maxBytes: 25_000_000,
79
+ // One image, under the 5 MB a model accepts once base64'd.
80
+ maxImageBytes: 3_750_000,
81
+ // A PDF up to this goes to Claude as a document block; bigger ones, and
82
+ // every other file, are opened by the agent from their path.
83
+ pdfInlineBytes: 10_000_000,
84
+ types: ATTACHMENT_TYPES,
85
+ };
86
+
87
+ export type AttachmentLimits = typeof ATTACHMENT_LIMITS;
88
+
89
+ const BY_EXT = new Map(ATTACHMENT_TYPES.map((x) => [x.ext, x]));
90
+
91
+ export const extOf = (name: string): string =>
92
+ /\.([A-Za-z0-9]+)$/.exec(name)?.[1].toLowerCase() ?? "";
93
+
94
+ export const typeFor = (
95
+ name: string,
96
+ types: AttachmentType[] = ATTACHMENT_TYPES
97
+ ): AttachmentType | null =>
98
+ types === ATTACHMENT_TYPES
99
+ ? (BY_EXT.get(extOf(name)) ?? null)
100
+ : (types.find((x) => x.ext === extOf(name)) ?? null);
101
+
102
+ // For <input accept>: every extension, so the picker greys out the rest.
103
+ export const acceptAttr = (types: AttachmentType[] = ATTACHMENT_TYPES) =>
104
+ types.map((x) => `.${x.ext}`).join(",");
105
+
106
+ export const formatBytes = (n: number): string =>
107
+ n >= 1_000_000
108
+ ? `${(n / 1_000_000).toFixed(1)} MB`
109
+ : `${Math.max(1, Math.round(n / 1000))} KB`;
110
+
111
+ // What a person is told when a file can't be attached.
112
+ export const unsupportedMessage = (name: string): string =>
113
+ `${name}: this file type can't be attached. Supported: PDF, spreadsheets (xlsx, xls, csv), Word (docx), text, markdown, JSON, code and images.`;
114
+
115
+ export const tooBigMessage = (name: string, size: number, limit: number) =>
116
+ `${name} is ${formatBytes(size)}; the limit is ${formatBytes(limit)}.`;
@@ -0,0 +1,92 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { dataRoot } from "../db/path";
4
+ import { ATTACHMENT_LIMITS, typeFor } from "./policy";
5
+ import type { ChatFile } from "./types";
6
+
7
+ // Outside every repo and worktree, so an agent's `git add -A` never picks a
8
+ // file up; a folder per session, `new` for a draft that has no session yet.
9
+ export const attachmentsRoot = () => path.join(dataRoot(), "attachments");
10
+
11
+ const segment = (s: string) => s.replace(/[^\w-]/g, "_");
12
+
13
+ /** A client's filename as one safe path segment, unique among `taken`. */
14
+ export function safeName(name: string, taken: Set<string>, n: number): string {
15
+ let base = path.basename(name.replace(/\\/g, "/"));
16
+ base = base
17
+ .replace(/[^a-zA-Z0-9.-]/g, "_")
18
+ .replace(/^\.+/, "")
19
+ .slice(0, 80);
20
+ if (!base) base = `file-${n}`;
21
+ let out = base;
22
+ for (let i = 2; taken.has(out); i++) {
23
+ const dot = base.lastIndexOf(".");
24
+ out =
25
+ dot > 0 ? `${base.slice(0, dot)}-${i}${base.slice(dot)}` : `${base}-${i}`;
26
+ }
27
+ taken.add(out);
28
+ return out;
29
+ }
30
+
31
+ export function scopeDir(scope: string): string {
32
+ return path.join(attachmentsRoot(), segment(scope) || "new");
33
+ }
34
+
35
+ export function saveAttachment(
36
+ scope: string,
37
+ name: string,
38
+ data: Buffer,
39
+ mediaType: string
40
+ ): ChatFile {
41
+ const dir = scopeDir(scope);
42
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
43
+ const taken = new Set(fs.readdirSync(dir));
44
+ const file = safeName(name, taken, taken.size + 1);
45
+ const full = path.join(dir, file);
46
+ fs.writeFileSync(full, data, { mode: 0o600 });
47
+ return { path: full, name: file, size: data.length, mediaType };
48
+ }
49
+
50
+ function inside(root: string, p: string): boolean {
51
+ const rel = path.relative(root, p);
52
+ return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
53
+ }
54
+
55
+ /**
56
+ * The files a client says it attached, kept only where they really are a
57
+ * file inside the attachments folder (symlinks followed): a client can name
58
+ * any path, and the agent is told to open what's named here. Size and type
59
+ * come from the disk, not the client.
60
+ */
61
+ export function resolveFiles(raw: unknown): ChatFile[] | undefined {
62
+ if (!Array.isArray(raw)) return undefined;
63
+ let root: string;
64
+ try {
65
+ root = fs.realpathSync(attachmentsRoot());
66
+ } catch {
67
+ return undefined;
68
+ }
69
+ const out: ChatFile[] = [];
70
+ for (const f of raw.slice(0, ATTACHMENT_LIMITS.max)) {
71
+ const p = (f as { path?: unknown } | null)?.path;
72
+ if (typeof p !== "string") continue;
73
+ try {
74
+ const real = fs.realpathSync(p);
75
+ const stat = fs.statSync(real);
76
+ if (!inside(root, real) || !stat.isFile()) continue;
77
+ out.push({
78
+ path: real,
79
+ name: path.basename(real),
80
+ size: stat.size,
81
+ mediaType: typeFor(real)?.mediaType ?? "application/octet-stream",
82
+ });
83
+ } catch {
84
+ // Gone or unreadable: dropped.
85
+ }
86
+ }
87
+ return out.length ? out : undefined;
88
+ }
89
+
90
+ export function removeSessionAttachments(sessionId: string): Promise<void> {
91
+ return fs.promises.rm(scopeDir(sessionId), { recursive: true, force: true });
92
+ }