@saadnvd1/agent-os 0.3.671 → 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 (75) 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/projects/[id]/route.ts +26 -0
  4. package/app/api/sessions/[id]/route.ts +2 -0
  5. package/app/api/sessions/route.ts +6 -2
  6. package/components/Chat/ChatPanel.tsx +9 -2
  7. package/components/Chat/FileChip.tsx +67 -0
  8. package/components/Chat/Messages.tsx +8 -0
  9. package/components/Chat/Queue.tsx +6 -0
  10. package/components/Chat/composer/Attachments.tsx +16 -2
  11. package/components/Chat/composer/ComposerBody.tsx +35 -14
  12. package/components/Chat/composer/Toolbar.tsx +11 -8
  13. package/components/Chat/composer/editor.test.ts +1 -1
  14. package/components/Chat/composer/paste.ts +5 -4
  15. package/components/Chat/composer/useAttachFiles.ts +142 -0
  16. package/components/Chat/composer/useComposerEditor.ts +3 -3
  17. package/components/Chat/useDraft.ts +9 -2
  18. package/components/Draft/DraftPanel.tsx +7 -4
  19. package/components/Projects/ProjectSettingsDialog.tsx +7 -0
  20. package/components/Projects/ProjectTierField.tsx +39 -0
  21. package/components/Terminal/index.tsx +6 -5
  22. package/components/Workspaces/ProjectTiersForm.tsx +33 -0
  23. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  24. package/data/chat/useChat.ts +10 -3
  25. package/data/client-config/queries.ts +18 -0
  26. package/data/projects/queries.ts +4 -0
  27. package/data/sessions/queries.ts +2 -1
  28. package/lib/attachments/check.ts +50 -0
  29. package/lib/attachments/message.ts +49 -0
  30. package/lib/attachments/policy.ts +116 -0
  31. package/lib/attachments/store.ts +92 -0
  32. package/lib/attachments/types.ts +8 -0
  33. package/lib/chat/client-send.ts +6 -2
  34. package/lib/chat/driver.ts +4 -1
  35. package/lib/chat/drivers/claude.ts +2 -0
  36. package/lib/chat/events.ts +13 -1
  37. package/lib/chat/pending.ts +2 -1
  38. package/lib/chat/queued.ts +24 -7
  39. package/lib/chat/runner.ts +11 -2
  40. package/lib/chat/worker/host.ts +7 -3
  41. package/lib/chat/worker/protocol.ts +2 -0
  42. package/lib/config/client-config-types.ts +14 -3
  43. package/lib/config/client-config.ts +1 -1
  44. package/lib/db/migrations/20261018T2130_project_tier.ts +38 -0
  45. package/lib/db/migrations/20261019T1200_chat_queue_files.ts +14 -0
  46. package/lib/db/types.ts +4 -0
  47. package/lib/file-upload.ts +61 -27
  48. package/lib/intake/intake.ts +2 -17
  49. package/lib/intake/sniff.ts +1 -1
  50. package/lib/orchestrator/armed.test-helpers.ts +4 -0
  51. package/lib/orchestrator/armed.ts +14 -1
  52. package/lib/orchestrator/brief.ts +9 -2
  53. package/lib/orchestrator/fast-lane-state.ts +69 -0
  54. package/lib/orchestrator/fast-lane.ts +96 -0
  55. package/lib/orchestrator/gates.ts +8 -2
  56. package/lib/orchestrator/project-tier-tool.ts +33 -0
  57. package/lib/orchestrator/review.ts +3 -0
  58. package/lib/orchestrator/serve.ts +6 -1
  59. package/lib/orchestrator/signoff.test-helpers.ts +3 -0
  60. package/lib/orchestrator/signoff.ts +36 -19
  61. package/lib/orchestrator/tool-names.ts +1 -0
  62. package/lib/orchestrator/tool-schemas.ts +8 -1
  63. package/lib/orchestrator/tools.ts +2 -0
  64. package/lib/orchestrator/watcher.ts +5 -0
  65. package/lib/project-tier/getProjectTier.ts +18 -0
  66. package/lib/project-tier/index.ts +3 -0
  67. package/lib/project-tier/setProjectTier.ts +14 -0
  68. package/lib/project-tier/types.ts +3 -0
  69. package/lib/sessions/launch.ts +12 -6
  70. package/lib/start-options.ts +8 -1
  71. package/lib/tasks/finish.ts +2 -1
  72. package/lib/tasks/index.ts +10 -5
  73. package/lib/tasks/state.ts +7 -1
  74. package/package.json +1 -1
  75. package/components/Chat/composer/useImageDrop.ts +0 -52
@@ -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
+ }
@@ -0,0 +1,8 @@
1
+ // A file saved on this machine and attached to a message by reference: the
2
+ // message carries these, never the bytes.
3
+ export interface ChatFile {
4
+ path: string;
5
+ name: string;
6
+ size: number;
7
+ mediaType: string;
8
+ }
@@ -1,25 +1,29 @@
1
- import type { ChatImage } from "./events";
1
+ import { resolveFiles } from "../attachments/store";
2
+ import type { ChatFile, ChatImage } from "./events";
2
3
  import type { CompactFirst } from "./stale";
3
4
 
4
- // What a browser's send may carry: its text and images, and whether the
5
+ // What a browser's send may carry: its text, images and files, and whether the
5
6
  // reader chose to compact a stale chat first. Who a message is from (`from`,
6
7
  // `peer`, `origin`) is set by the server alone, never by a client. Its own
7
8
  // id for the message is kept only in a shape no server id takes.
8
9
  export function clientSend(msg: unknown): {
9
10
  text: string;
10
11
  images?: ChatImage[];
12
+ files?: ChatFile[];
11
13
  compact?: CompactFirst;
12
14
  id?: string;
13
15
  } {
14
16
  const m = (msg ?? {}) as {
15
17
  text?: unknown;
16
18
  images?: unknown;
19
+ files?: unknown;
17
20
  compact?: unknown;
18
21
  clientId?: unknown;
19
22
  };
20
23
  return {
21
24
  text: typeof m.text === "string" ? m.text : "",
22
25
  images: Array.isArray(m.images) ? (m.images as ChatImage[]) : undefined,
26
+ files: resolveFiles(m.files),
23
27
  ...(m.compact === true && { compact: "yes" as const }),
24
28
  ...(isClientId(m.clientId) && { id: m.clientId }),
25
29
  };
@@ -6,6 +6,7 @@ import type {
6
6
  ApprovalDecision,
7
7
  ChatAccess,
8
8
  ChatCommand,
9
+ ChatFile,
9
10
  ChatImage,
10
11
  ChatItem,
11
12
  ChatModel,
@@ -88,7 +89,9 @@ export interface ChatConversation {
88
89
  send(
89
90
  text: string,
90
91
  images?: ChatImage[],
91
- options?: { now?: boolean }
92
+ // `files`: saved attachments; their paths are already in `text`, and a
93
+ // driver may also hand some to the model directly.
94
+ options?: { now?: boolean; files?: ChatFile[] }
92
95
  ): string | undefined;
93
96
  // A command the driver answers itself, without starting a turn (/mcp):
94
97
  // the items to show, or null to send the text as a message.
@@ -1,3 +1,4 @@
1
+ import { documentBlocks } from "../../attachments/message";
1
2
  import { sessionBaseEnv } from "../../agents/server-env";
2
3
  import { randomUUID } from "crypto";
3
4
  import {
@@ -320,6 +321,7 @@ export const claudeDriver: ChatDriver = {
320
321
  data: img.data,
321
322
  },
322
323
  })),
324
+ ...documentBlocks(sendOptions?.files),
323
325
  { type: "text" as const, text },
324
326
  ];
325
327
  const checkpoint = randomUUID();
@@ -1,5 +1,8 @@
1
1
  import type { CallUsage } from "../usage/types";
2
2
  import type { SearchResult } from "./find";
3
+ import type { ChatFile } from "../attachments/types";
4
+
5
+ export type { ChatFile };
3
6
 
4
7
  /**
5
8
  * The provider-neutral shape of a chat conversation. Every driver (Claude
@@ -152,6 +155,8 @@ export type ChatItem =
152
155
  kind: "user";
153
156
  text: string;
154
157
  images?: ChatImage[];
158
+ // Saved in the session's attachments folder, by reference.
159
+ files?: ChatFile[];
155
160
  // Who sent it through the agent bus, when not typed here.
156
161
  from?: string;
157
162
  // Sent by another agent session: shown as its body, while the agent
@@ -282,6 +287,7 @@ export interface QueuedMessage {
282
287
  id: string;
283
288
  text: string;
284
289
  imageCount?: number;
290
+ fileCount?: number;
285
291
  createdAt: number;
286
292
  }
287
293
 
@@ -413,7 +419,13 @@ export type ChatServerMessage =
413
419
  // What a browser sends.
414
420
  export type ChatClientMessage =
415
421
  // `clientId` (`user-c-…`) becomes the message's id: a resend is a no-op.
416
- | { type: "send"; text: string; images?: ChatImage[]; clientId?: string }
422
+ | {
423
+ type: "send";
424
+ text: string;
425
+ images?: ChatImage[];
426
+ files?: ChatFile[];
427
+ clientId?: string;
428
+ }
417
429
  | { type: "queue_edit"; id: string; text: string }
418
430
  | { type: "queue_move"; id: string; by: -1 | 1 }
419
431
  | { type: "queue_delete"; id: string }
@@ -1,4 +1,4 @@
1
- import type { ChatImage, ChatState } from "./events";
1
+ import type { ChatFile, ChatImage, ChatState } from "./events";
2
2
 
3
3
  // A message the composer sent that isn't in the chat or its queue yet. It
4
4
  // shows as a bubble at once, and stays (as failed, with Retry) if it never
@@ -7,6 +7,7 @@ export interface PendingSend {
7
7
  id: string;
8
8
  text: string;
9
9
  images?: ChatImage[];
10
+ files?: ChatFile[];
10
11
  compact?: boolean;
11
12
  // "sending": not taken in yet. "accepted": the server has it and is
12
13
  // starting the agent.
@@ -1,6 +1,6 @@
1
1
  import { db } from "../db";
2
2
  import { HELD_SQL } from "./hold";
3
- import type { ChatImage, QueuedMessage, SentBy } from "./events";
3
+ import type { ChatFile, ChatImage, QueuedMessage, SentBy } from "./events";
4
4
 
5
5
  // Messages written while a turn runs, kept in SQLite so a reload or a
6
6
  // restart never loses them. The worker sends them in order when the turn
@@ -15,6 +15,7 @@ interface Row {
15
15
  id: string;
16
16
  text: string;
17
17
  images: string | null;
18
+ files: string | null;
18
19
  sent_by: string | null;
19
20
  created_at: number;
20
21
  }
@@ -25,19 +26,21 @@ export function listQueue(sessionId: string): QueuedMessage[] {
25
26
  return (
26
27
  db
27
28
  .prepare(
28
- `SELECT id, text, image_count, created_at
29
+ `SELECT id, text, image_count, files, created_at
29
30
  FROM chat_queue WHERE session_id = ? ORDER BY position, created_at`
30
31
  )
31
32
  .all(sessionId) as {
32
33
  id: string;
33
34
  text: string;
34
35
  image_count: number;
36
+ files: string | null;
35
37
  created_at: number;
36
38
  }[]
37
39
  ).map((r) => ({
38
40
  id: r.id,
39
41
  text: r.text,
40
42
  imageCount: r.image_count || undefined,
43
+ fileCount: r.files ? (JSON.parse(r.files) as unknown[]).length : undefined,
41
44
  createdAt: r.created_at,
42
45
  }));
43
46
  }
@@ -45,7 +48,12 @@ export function listQueue(sessionId: string): QueuedMessage[] {
45
48
  // At the back. A retried send with the same id is only queued once.
46
49
  export function enqueue(
47
50
  sessionId: string,
48
- m: { id: string; text: string; images?: ChatImage[] } & SentBy
51
+ m: {
52
+ id: string;
53
+ text: string;
54
+ images?: ChatImage[];
55
+ files?: ChatFile[];
56
+ } & SentBy
49
57
  ): void {
50
58
  const images = m.images?.length ? JSON.stringify(m.images) : null;
51
59
  const sentBy: SentBy = { from: m.from, peer: m.peer, origin: m.origin };
@@ -59,8 +67,8 @@ export function enqueue(
59
67
  if (n >= MAX_QUEUED)
60
68
  throw new Error(`The queue is full (${MAX_QUEUED} messages)`);
61
69
  db.prepare(
62
- `INSERT OR IGNORE INTO chat_queue (id, session_id, position, text, images, image_count, sent_by, created_at)
63
- VALUES (?, ?, (SELECT COALESCE(MAX(position), 0) + 1 FROM chat_queue WHERE session_id = ?), ?, ?, ?, ?, ?)`
70
+ `INSERT OR IGNORE INTO chat_queue (id, session_id, position, text, images, image_count, files, sent_by, created_at)
71
+ VALUES (?, ?, (SELECT COALESCE(MAX(position), 0) + 1 FROM chat_queue WHERE session_id = ?), ?, ?, ?, ?, ?, ?)`
64
72
  ).run(
65
73
  m.id,
66
74
  sessionId,
@@ -68,6 +76,7 @@ export function enqueue(
68
76
  m.text,
69
77
  images,
70
78
  m.images?.length ?? 0,
79
+ m.files?.length ? JSON.stringify(m.files) : null,
71
80
  m.from || m.peer || m.origin ? JSON.stringify(sentBy) : null,
72
81
  Date.now()
73
82
  );
@@ -80,13 +89,20 @@ export function claimNext(
80
89
  sessionId: string,
81
90
  // That message only, if it's still there.
82
91
  id?: string
83
- ): ({ id: string; text: string; images?: ChatImage[] } & SentBy) | null {
92
+ ):
93
+ | ({
94
+ id: string;
95
+ text: string;
96
+ images?: ChatImage[];
97
+ files?: ChatFile[];
98
+ } & SentBy)
99
+ | null {
84
100
  const row = db
85
101
  .prepare(
86
102
  `DELETE FROM chat_queue WHERE session_id = @sid AND NOT ${HELD_SQL} AND id = (
87
103
  SELECT id FROM chat_queue WHERE session_id = @sid AND (@id IS NULL OR id = @id)
88
104
  ORDER BY position, created_at LIMIT 1
89
- ) RETURNING id, text, images, sent_by, created_at`
105
+ ) RETURNING id, text, images, files, sent_by, created_at`
90
106
  )
91
107
  .get({ sid: sessionId, id: id ?? null }) as Row | undefined;
92
108
  if (!row) return null;
@@ -94,6 +110,7 @@ export function claimNext(
94
110
  id: row.id,
95
111
  text: row.text,
96
112
  images: row.images ? (JSON.parse(row.images) as ChatImage[]) : undefined,
113
+ files: row.files ? (JSON.parse(row.files) as ChatFile[]) : undefined,
97
114
  ...(row.sent_by ? (JSON.parse(row.sent_by) as SentBy) : {}),
98
115
  };
99
116
  }
@@ -19,6 +19,7 @@ import type { CompactFirst } from "./stale";
19
19
  import type {
20
20
  ApprovalDecision,
21
21
  ChatContext,
22
+ ChatFile,
22
23
  ChatImage,
23
24
  ChatServerMessage,
24
25
  ChatState,
@@ -405,7 +406,12 @@ function refuseAway(sessionId: string): void {
405
406
  // started): the message waits in the queue instead of starting a turn.
406
407
  function queuedIfHeld(
407
408
  sessionId: string,
408
- m: { id: string; text: string; images?: ChatImage[] } & SentBy
409
+ m: {
410
+ id: string;
411
+ text: string;
412
+ images?: ChatImage[];
413
+ files?: ChatFile[];
414
+ } & SentBy
409
415
  ): boolean {
410
416
  refuseAway(sessionId);
411
417
  if (!chatHold(sessionId)) return false;
@@ -429,6 +435,7 @@ export async function sendChat(
429
435
  input: {
430
436
  text: string;
431
437
  images?: ChatImage[];
438
+ files?: ChatFile[];
432
439
  // Typed in the composer: waits in the queue while a turn runs.
433
440
  queue?: boolean;
434
441
  // Compact a stale chat first (lib/chat/stale).
@@ -438,7 +445,7 @@ export async function sendChat(
438
445
  } & SentBy
439
446
  ): Promise<void> {
440
447
  const text = input.text.trim();
441
- if (!text && !input.images?.length) return;
448
+ if (!text && !input.images?.length && !input.files?.length) return;
442
449
  if (input.id && taken(sessionId, input.id)) return;
443
450
  refuseAway(sessionId);
444
451
  const id = input.id ?? `user-${Date.now()}-${randomUUID().slice(0, 5)}`;
@@ -449,6 +456,7 @@ export async function sendChat(
449
456
  id,
450
457
  text,
451
458
  images: input.images,
459
+ files: input.files,
452
460
  ...sentBy(input),
453
461
  });
454
462
  emitQueue(sessionId);
@@ -463,6 +471,7 @@ export async function sendChat(
463
471
  id,
464
472
  text,
465
473
  images: input.images,
474
+ files: input.files,
466
475
  ...sentBy(input),
467
476
  queue: input.queue,
468
477
  compact: input.compact,
@@ -10,10 +10,12 @@ import { agentEnv } from "../../agents/launch";
10
10
  import { sessionRunningBrief } from "../../project-config/session";
11
11
  import { BUS_BRIEF } from "../../agents/brief";
12
12
  import { resolveModelForAgent } from "../../model-catalog";
13
+ import { agentText } from "../../attachments/message";
13
14
  import { chatDriverFor } from "../drivers";
14
15
  import type { ChatConversation } from "../driver";
15
16
  import type {
16
17
  ChatContext,
18
+ ChatFile,
17
19
  ChatImage,
18
20
  ChatItem,
19
21
  ChatState,
@@ -560,6 +562,7 @@ export class ChatHost {
560
562
  id: string;
561
563
  text: string;
562
564
  images?: ChatImage[];
565
+ files?: ChatFile[];
563
566
  } & SentBy,
564
567
  now = false
565
568
  ): void {
@@ -577,7 +580,7 @@ export class ChatHost {
577
580
  if (now) this.nowPending++;
578
581
  this.stopping = null;
579
582
  const note = this.refreshedNote ?? (this.restartedNote && RESTARTED_NOTE);
580
- const text = note ? `${note}\n\n${m.text}` : m.text;
583
+ const text = agentText(note ? `${note}\n\n${m.text}` : m.text, m.files);
581
584
  if (this.refreshedNote) {
582
585
  this.refreshedNote = null;
583
586
  db.prepare(`UPDATE sessions SET chat_refreshed = 0 WHERE id = ?`).run(
@@ -592,13 +595,14 @@ export class ChatHost {
592
595
  }
593
596
  if (/^\/compact\b/.test(m.text.trimStart())) this.setCompacting(true);
594
597
  const checkpoint = now
595
- ? this.conversation.send(text, m.images, { now })
596
- : this.conversation.send(text, m.images);
598
+ ? this.conversation.send(text, m.images, { now, files: m.files })
599
+ : this.conversation.send(text, m.images, { files: m.files });
597
600
  const user: ChatItem = {
598
601
  id: m.id,
599
602
  kind: "user",
600
603
  text: m.text,
601
604
  images: m.images,
605
+ files: m.files,
602
606
  from: m.from,
603
607
  peer: m.peer,
604
608
  origin: m.origin,
@@ -9,6 +9,7 @@ import path from "path";
9
9
  import type {
10
10
  ApprovalDecision,
11
11
  ChatAccess,
12
+ ChatFile,
12
13
  ChatImage,
13
14
  ChatItem,
14
15
  ChatState,
@@ -32,6 +33,7 @@ export type WorkerCommand =
32
33
  id: string;
33
34
  text: string;
34
35
  images?: ChatImage[];
36
+ files?: ChatFile[];
35
37
  from?: string;
36
38
  peer?: PeerMessage;
37
39
  origin?: ChatOrigin;
@@ -28,10 +28,23 @@ export interface ClientConfig {
28
28
  runTriggers: Record<string, string>;
29
29
  features: ClientFeatures;
30
30
  intake: IntakeLimits;
31
- attachments: { max: number };
31
+ attachments: AttachmentConfig;
32
32
  voice: VoiceLimits;
33
33
  }
34
34
 
35
+ // What a chat accepts, served from lib/attachments/policy.ts. Sizes in bytes.
36
+ export interface AttachmentConfig {
37
+ max: number;
38
+ maxBytes: number;
39
+ maxImageBytes: number;
40
+ pdfInlineBytes: number;
41
+ types: {
42
+ ext: string;
43
+ mediaType: string;
44
+ kind: "image" | "pdf" | "sheet" | "doc" | "text";
45
+ }[];
46
+ }
47
+
35
48
  // Server kill switches for the phone's new sends.
36
49
  export interface ClientFeatures {
37
50
  shakeToReport: boolean;
@@ -66,8 +79,6 @@ export const INTAKE_LIMITS: IntakeLimits = {
66
79
  logMaxLines: 200,
67
80
  };
68
81
 
69
- export const ATTACHMENT_LIMITS = { max: 8 };
70
-
71
82
  export const VOICE_LIMITS: VoiceLimits = {
72
83
  maxPhrases: 100,
73
84
  holdMinMs: 250,
@@ -1,3 +1,4 @@
1
+ import { ATTACHMENT_LIMITS } from "../attachments/policy";
1
2
  import { ACCESS_LEVELS, DEFAULT_NEW_CHAT_ACCESS } from "../chat/access-levels";
2
3
  import { CHAT_ACCESS } from "../chat/events";
3
4
  import {
@@ -7,7 +8,6 @@ import {
7
8
  import { TASK_STATE_LABELS } from "../tasks/state-labels";
8
9
  import { cachedCheck } from "../updates/check";
9
10
  import {
10
- ATTACHMENT_LIMITS,
11
11
  CLIENT_CONFIG_VERSION,
12
12
  INTAKE_LIMITS,
13
13
  VOICE_LIMITS,