@things-factory/ai-assistant 10.1.178 → 10.1.179

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.
@@ -1,3 +1,4 @@
1
+ import { type FileUploadLimits } from '@operato/utils/file-acceptance.js';
1
2
  export type AttachmentKind = 'image' | 'text' | 'spreadsheet';
2
3
  export interface AttachmentPolicy {
3
4
  kinds: AttachmentKind[];
@@ -10,10 +11,6 @@ export declare const DEFAULT_MAX_BYTES: number;
10
11
  export declare const DEFAULT_MAX_FILES = 5;
11
12
  /** How much of a text file reaches the model - the server's TEXT_CHARS_FOR_MODEL. */
12
13
  export declare const TEXT_CHARS_FOR_MODEL = 20000;
13
- export declare function attachmentKindOf(file: {
14
- type?: string;
15
- name?: string;
16
- }): AttachmentKind | null;
17
14
  export type AttachmentRefusal = {
18
15
  code: 'attachments-not-accepted';
19
16
  } | {
@@ -30,12 +27,19 @@ export type AttachmentRefusal = {
30
27
  };
31
28
  /** The characters of a text file the model reads under this policy. */
32
29
  export declare function textCharsOf(policy: AttachmentPolicy | null | undefined): number;
30
+ /** The mode's size and count as picker limits. */
31
+ export declare function limitsOf(policy: AttachmentPolicy | null | undefined): FileUploadLimits;
32
+ /**
33
+ * Whether these files may go, by the one picking rule. `server` is the server's upload limit (shell
34
+ * `fileUploadLimits`); the tighter of it and the mode's policy applies. The first reason, by name: file by file, its
35
+ * kind, then its size, then whether it is one too many - the door says them in the same order.
36
+ */
33
37
  export declare function refuseAttachments(policy: AttachmentPolicy | null | undefined, files: {
34
38
  name?: string;
35
39
  type?: string;
36
40
  size?: number;
37
- }[]): AttachmentRefusal | null;
38
- /** The file picker's `accept` for a policy. */
41
+ }[], server?: FileUploadLimits): AttachmentRefusal | null;
42
+ /** The file picker's `accept` for a policy - a spreadsheet as the person picks it (.xlsx). */
39
43
  export declare function acceptOf(policy: AttachmentPolicy | null | undefined): string;
40
44
  /** Where a conversation's file opens - the gated route, under the page's domain path. */
41
45
  export declare function chatAttachmentUrl(sessionId: string, attachmentId: string): string;
@@ -1,64 +1,65 @@
1
1
  /*
2
- * What a conversation accepts as attachments - the screen's copy of the server rule
2
+ * What a conversation accepts as attachments - the screen's side of the server rule
3
3
  * (server/service/chat-session/attachment-policy.ts, design/ai/shared-dock.md §9-1).
4
4
  *
5
5
  * The screen checks first so the person hears "no" before anything is sent; the door checks again and is
6
- * the authority. `test/attachment-policy-agree.test.ts` runs both copies over one table so they cannot drift.
6
+ * the authority. `test/attachment-policy-agree.test.ts` runs both over one table so they cannot drift.
7
+ *
8
+ * The screen's check is the one file-picking rule (@operato/utils `sortPickedFiles`, ADR-0061 2026-10-17): the mode's
9
+ * kinds become an `accept` (the table below, the one place), its size and count become limits, narrowed by the
10
+ * server's upload limits. What stays here is the mode's words: kinds, and a spreadsheet read into text.
7
11
  */
12
+ import { narrowLimits, sortPickedFiles } from '@operato/utils/file-acceptance.js';
8
13
  export const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
9
14
  export const DEFAULT_MAX_FILES = 5;
10
15
  /** How much of a text file reaches the model - the server's TEXT_CHARS_FOR_MODEL. */
11
16
  export const TEXT_CHARS_FOR_MODEL = 20_000;
12
17
  const TEXT_TYPES = ['text/plain', 'text/csv', 'text/markdown', 'application/json'];
13
18
  const TEXT_EXTENSIONS = ['.txt', '.csv', '.md', '.json'];
14
- export function attachmentKindOf(file) {
15
- const type = (file.type || '').toLowerCase();
16
- if (type.startsWith('image/'))
17
- return 'image';
18
- if (TEXT_TYPES.includes(type))
19
- return 'text';
20
- const name = (file.name || '').toLowerCase();
21
- if (TEXT_EXTENSIONS.some(ext => name.endsWith(ext)))
22
- return 'text';
23
- return null;
24
- }
19
+ const XLSX_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
20
+ /** Each kind as `accept` patterns - the one table from the mode's kinds to what a picker takes. */
21
+ const KIND_ACCEPT = {
22
+ image: ['image/*'],
23
+ text: [...TEXT_EXTENSIONS, ...TEXT_TYPES],
24
+ spreadsheet: ['.xlsx', XLSX_TYPE]
25
+ };
25
26
  /** A kind the policy takes - text also when it takes spreadsheets, which arrive as the text of their sheets. */
26
- function takes(policy, kind) {
27
- return policy.kinds.includes(kind) || (kind === 'text' && policy.kinds.includes('spreadsheet'));
28
- }
27
+ const acceptFor = (kinds) => [...new Set(kinds.flatMap(kind => KIND_ACCEPT[kind]))].join(',');
29
28
  /** The characters of a text file the model reads under this policy. */
30
29
  export function textCharsOf(policy) {
31
30
  return policy?.maxChars ?? TEXT_CHARS_FOR_MODEL;
32
31
  }
33
- export function refuseAttachments(policy, files) {
32
+ /** The mode's size and count as picker limits. */
33
+ export function limitsOf(policy) {
34
+ return { maxFileSize: policy?.maxBytes ?? DEFAULT_MAX_BYTES, maxFiles: policy?.maxFiles ?? DEFAULT_MAX_FILES };
35
+ }
36
+ /**
37
+ * Whether these files may go, by the one picking rule. `server` is the server's upload limit (shell
38
+ * `fileUploadLimits`); the tighter of it and the mode's policy applies. The first reason, by name: file by file, its
39
+ * kind, then its size, then whether it is one too many - the door says them in the same order.
40
+ */
41
+ export function refuseAttachments(policy, files, server) {
34
42
  if (!files.length)
35
43
  return null;
36
44
  if (!policy || !policy.kinds?.length)
37
45
  return { code: 'attachments-not-accepted' };
38
- const maxFiles = policy.maxFiles ?? DEFAULT_MAX_FILES;
39
- if (files.length > maxFiles)
40
- return { code: 'too-many-attachments', maxFiles };
41
- const maxBytes = policy.maxBytes ?? DEFAULT_MAX_BYTES;
42
- for (const file of files) {
43
- const kind = attachmentKindOf(file);
44
- if (!kind || !takes(policy, kind)) {
45
- return { code: 'attachment-kind-not-accepted', name: file.name || '', kinds: policy.kinds };
46
- }
47
- if ((file.size ?? 0) > maxBytes)
48
- return { code: 'attachment-too-large', name: file.name || '', maxBytes };
49
- }
50
- return null;
46
+ /* What is sent: a spreadsheet arrives as the text of its sheets. */
47
+ const sent = policy.kinds.map(kind => (kind === 'spreadsheet' ? 'text' : kind));
48
+ const limits = narrowLimits(limitsOf(policy), server);
49
+ const { refused } = sortPickedFiles(files, { accept: acceptFor(sent), limits });
50
+ if (!refused.length)
51
+ return null;
52
+ const [first] = refused;
53
+ if (first.reason === 'count')
54
+ return { code: 'too-many-attachments', maxFiles: limits.maxFiles };
55
+ return first.reason === 'type'
56
+ ? { code: 'attachment-kind-not-accepted', name: first.file.name || '', kinds: policy.kinds }
57
+ : { code: 'attachment-too-large', name: first.file.name || '', maxBytes: limits.maxFileSize };
51
58
  }
52
- /** The file picker's `accept` for a policy. */
59
+ /** The file picker's `accept` for a policy - a spreadsheet as the person picks it (.xlsx). */
53
60
  export function acceptOf(policy) {
54
- const accept = [];
55
- if (policy?.kinds?.includes('image'))
56
- accept.push('image/*');
57
- if (policy?.kinds?.includes('text') || policy?.kinds?.includes('spreadsheet'))
58
- accept.push(...TEXT_EXTENSIONS, ...TEXT_TYPES);
59
- if (policy?.kinds?.includes('spreadsheet'))
60
- accept.push('.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
61
- return accept.join(',');
61
+ const kinds = policy?.kinds ?? [];
62
+ return acceptFor(kinds.includes('spreadsheet') ? [...kinds, 'text'] : kinds);
62
63
  }
63
64
  /** Where a conversation's file opens - the gated route, under the page's domain path. */
64
65
  export function chatAttachmentUrl(sessionId, attachmentId) {
@@ -1 +1 @@
1
- {"version":3,"file":"attachment-policy.js","sourceRoot":"","sources":["../../client/utils/attachment-policy.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * What a conversation accepts as attachments - the screen's copy of the server rule\n * (server/service/chat-session/attachment-policy.ts, design/ai/shared-dock.md §9-1).\n *\n * The screen checks first so the person hears \"no\" before anything is sent; the door checks again and is\n * the authority. `test/attachment-policy-agree.test.ts` runs both copies over one table so they cannot drift.\n */\n\n/*\n * 'spreadsheet' is a kind a mode opens, never a file the door receives: the screen reads each .xlsx sheet into CSV\n * text before sending (client/utils/spreadsheet-text.ts), so a mode that takes spreadsheets takes that text.\n */\nexport type AttachmentKind = 'image' | 'text' | 'spreadsheet'\n\nexport interface AttachmentPolicy {\n kinds: AttachmentKind[]\n maxBytes?: number\n maxFiles?: number\n /** Per text file, the characters the model reads - a sheet read into text longer than this is refused, not cut. */\n maxChars?: number\n}\n\nexport const DEFAULT_MAX_BYTES = 5 * 1024 * 1024\nexport const DEFAULT_MAX_FILES = 5\n/** How much of a text file reaches the model - the server's TEXT_CHARS_FOR_MODEL. */\nexport const TEXT_CHARS_FOR_MODEL = 20_000\n\nconst TEXT_TYPES = ['text/plain', 'text/csv', 'text/markdown', 'application/json']\nconst TEXT_EXTENSIONS = ['.txt', '.csv', '.md', '.json']\n\nexport function attachmentKindOf(file: { type?: string; name?: string }): AttachmentKind | null {\n const type = (file.type || '').toLowerCase()\n if (type.startsWith('image/')) return 'image'\n if (TEXT_TYPES.includes(type)) return 'text'\n const name = (file.name || '').toLowerCase()\n if (TEXT_EXTENSIONS.some(ext => name.endsWith(ext))) return 'text'\n return null\n}\n\nexport type AttachmentRefusal =\n | { code: 'attachments-not-accepted' }\n | { code: 'attachment-kind-not-accepted'; name: string; kinds: AttachmentKind[] }\n | { code: 'attachment-too-large'; name: string; maxBytes: number }\n | { code: 'too-many-attachments'; maxFiles: number }\n\n/** A kind the policy takes - text also when it takes spreadsheets, which arrive as the text of their sheets. */\nfunction takes(policy: AttachmentPolicy, kind: AttachmentKind): boolean {\n return policy.kinds.includes(kind) || (kind === 'text' && policy.kinds.includes('spreadsheet'))\n}\n\n/** The characters of a text file the model reads under this policy. */\nexport function textCharsOf(policy: AttachmentPolicy | null | undefined): number {\n return policy?.maxChars ?? TEXT_CHARS_FOR_MODEL\n}\n\nexport function refuseAttachments(\n policy: AttachmentPolicy | null | undefined,\n files: { name?: string; type?: string; size?: number }[]\n): AttachmentRefusal | null {\n if (!files.length) return null\n if (!policy || !policy.kinds?.length) return { code: 'attachments-not-accepted' }\n\n const maxFiles = policy.maxFiles ?? DEFAULT_MAX_FILES\n if (files.length > maxFiles) return { code: 'too-many-attachments', maxFiles }\n\n const maxBytes = policy.maxBytes ?? DEFAULT_MAX_BYTES\n for (const file of files) {\n const kind = attachmentKindOf(file)\n if (!kind || !takes(policy, kind)) {\n return { code: 'attachment-kind-not-accepted', name: file.name || '', kinds: policy.kinds }\n }\n if ((file.size ?? 0) > maxBytes) return { code: 'attachment-too-large', name: file.name || '', maxBytes }\n }\n return null\n}\n\n/** The file picker's `accept` for a policy. */\nexport function acceptOf(policy: AttachmentPolicy | null | undefined): string {\n const accept: string[] = []\n if (policy?.kinds?.includes('image')) accept.push('image/*')\n if (policy?.kinds?.includes('text') || policy?.kinds?.includes('spreadsheet')) accept.push(...TEXT_EXTENSIONS, ...TEXT_TYPES)\n if (policy?.kinds?.includes('spreadsheet')) accept.push('.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')\n return accept.join(',')\n}\n\n/** Where a conversation's file opens - the gated route, under the page's domain path. */\nexport function chatAttachmentUrl(sessionId: string, attachmentId: string): string {\n const domainPath = /^\\/domain\\/[^/]+/.exec(location.pathname)?.[0] ?? ''\n return `${domainPath}/chat-attachments/${encodeURIComponent(sessionId)}/${encodeURIComponent(attachmentId)}`\n}\n"]}
1
+ {"version":3,"file":"attachment-policy.js","sourceRoot":"","sources":["../../client/utils/attachment-policy.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * What a conversation accepts as attachments - the screen's side of the server rule\n * (server/service/chat-session/attachment-policy.ts, design/ai/shared-dock.md §9-1).\n *\n * The screen checks first so the person hears \"no\" before anything is sent; the door checks again and is\n * the authority. `test/attachment-policy-agree.test.ts` runs both over one table so they cannot drift.\n *\n * The screen's check is the one file-picking rule (@operato/utils `sortPickedFiles`, ADR-0061 2026-10-17): the mode's\n * kinds become an `accept` (the table below, the one place), its size and count become limits, narrowed by the\n * server's upload limits. What stays here is the mode's words: kinds, and a spreadsheet read into text.\n */\nimport { narrowLimits, sortPickedFiles, type FileUploadLimits } from '@operato/utils/file-acceptance.js'\n\n/*\n * 'spreadsheet' is a kind a mode opens, never a file the door receives: the screen reads each .xlsx sheet into CSV\n * text before sending (client/utils/spreadsheet-text.ts), so a mode that takes spreadsheets takes that text.\n */\nexport type AttachmentKind = 'image' | 'text' | 'spreadsheet'\n\nexport interface AttachmentPolicy {\n kinds: AttachmentKind[]\n maxBytes?: number\n maxFiles?: number\n /** Per text file, the characters the model reads - a sheet read into text longer than this is refused, not cut. */\n maxChars?: number\n}\n\nexport const DEFAULT_MAX_BYTES = 5 * 1024 * 1024\nexport const DEFAULT_MAX_FILES = 5\n/** How much of a text file reaches the model - the server's TEXT_CHARS_FOR_MODEL. */\nexport const TEXT_CHARS_FOR_MODEL = 20_000\n\nconst TEXT_TYPES = ['text/plain', 'text/csv', 'text/markdown', 'application/json']\nconst TEXT_EXTENSIONS = ['.txt', '.csv', '.md', '.json']\nconst XLSX_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'\n\n/** Each kind as `accept` patterns - the one table from the mode's kinds to what a picker takes. */\nconst KIND_ACCEPT: Record<AttachmentKind, string[]> = {\n image: ['image/*'],\n text: [...TEXT_EXTENSIONS, ...TEXT_TYPES],\n spreadsheet: ['.xlsx', XLSX_TYPE]\n}\n\n/** A kind the policy takes - text also when it takes spreadsheets, which arrive as the text of their sheets. */\nconst acceptFor = (kinds: AttachmentKind[]) => [...new Set(kinds.flatMap(kind => KIND_ACCEPT[kind]))].join(',')\n\nexport type AttachmentRefusal =\n | { code: 'attachments-not-accepted' }\n | { code: 'attachment-kind-not-accepted'; name: string; kinds: AttachmentKind[] }\n | { code: 'attachment-too-large'; name: string; maxBytes: number }\n | { code: 'too-many-attachments'; maxFiles: number }\n\n/** The characters of a text file the model reads under this policy. */\nexport function textCharsOf(policy: AttachmentPolicy | null | undefined): number {\n return policy?.maxChars ?? TEXT_CHARS_FOR_MODEL\n}\n\n/** The mode's size and count as picker limits. */\nexport function limitsOf(policy: AttachmentPolicy | null | undefined): FileUploadLimits {\n return { maxFileSize: policy?.maxBytes ?? DEFAULT_MAX_BYTES, maxFiles: policy?.maxFiles ?? DEFAULT_MAX_FILES }\n}\n\n/**\n * Whether these files may go, by the one picking rule. `server` is the server's upload limit (shell\n * `fileUploadLimits`); the tighter of it and the mode's policy applies. The first reason, by name: file by file, its\n * kind, then its size, then whether it is one too many - the door says them in the same order.\n */\nexport function refuseAttachments(\n policy: AttachmentPolicy | null | undefined,\n files: { name?: string; type?: string; size?: number }[],\n server?: FileUploadLimits\n): AttachmentRefusal | null {\n if (!files.length) return null\n if (!policy || !policy.kinds?.length) return { code: 'attachments-not-accepted' }\n\n /* What is sent: a spreadsheet arrives as the text of its sheets. */\n const sent = policy.kinds.map(kind => (kind === 'spreadsheet' ? 'text' : kind))\n const limits = narrowLimits(limitsOf(policy), server)\n const { refused } = sortPickedFiles(files, { accept: acceptFor(sent), limits })\n if (!refused.length) return null\n\n const [first] = refused\n if (first.reason === 'count') return { code: 'too-many-attachments', maxFiles: limits.maxFiles! }\n return first.reason === 'type'\n ? { code: 'attachment-kind-not-accepted', name: first.file.name || '', kinds: policy.kinds }\n : { code: 'attachment-too-large', name: first.file.name || '', maxBytes: limits.maxFileSize! }\n}\n\n/** The file picker's `accept` for a policy - a spreadsheet as the person picks it (.xlsx). */\nexport function acceptOf(policy: AttachmentPolicy | null | undefined): string {\n const kinds = policy?.kinds ?? []\n return acceptFor(kinds.includes('spreadsheet') ? [...kinds, 'text'] : kinds)\n}\n\n/** Where a conversation's file opens - the gated route, under the page's domain path. */\nexport function chatAttachmentUrl(sessionId: string, attachmentId: string): string {\n const domainPath = /^\\/domain\\/[^/]+/.exec(location.pathname)?.[0] ?? ''\n return `${domainPath}/chat-attachments/${encodeURIComponent(sessionId)}/${encodeURIComponent(attachmentId)}`\n}\n"]}
@@ -44,10 +44,13 @@ function refuseAttachments(policy, files) {
44
44
  return null;
45
45
  if (!policy || !policy.kinds?.length)
46
46
  return { code: 'attachments-not-accepted' };
47
+ /*
48
+ * File by file, in the order sent: its kind, then its size, then whether it is one too many. The screen's picking
49
+ * rule (@operato/utils sortPickedFiles) says them in this order, so the same batch hears the same "no" from both.
50
+ */
47
51
  const maxFiles = policy.maxFiles ?? exports.DEFAULT_MAX_FILES;
48
- if (files.length > maxFiles)
49
- return { code: 'too-many-attachments', maxFiles };
50
52
  const maxBytes = policy.maxBytes ?? exports.DEFAULT_MAX_BYTES;
53
+ let taken = 0;
51
54
  for (const file of files) {
52
55
  const kind = attachmentKindOf(file);
53
56
  if (!kind || !takes(policy, kind)) {
@@ -55,6 +58,9 @@ function refuseAttachments(policy, files) {
55
58
  }
56
59
  if ((file.size ?? 0) > maxBytes)
57
60
  return { code: 'attachment-too-large', name: file.name || '', maxBytes };
61
+ if (taken >= maxFiles)
62
+ return { code: 'too-many-attachments', maxFiles };
63
+ taken++;
58
64
  }
59
65
  return null;
60
66
  }
@@ -1 +1 @@
1
- {"version":3,"file":"attachment-policy.js","sourceRoot":"","sources":["../../../server/service/chat-session/attachment-policy.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * What a conversation accepts as attachments - declared by its mode (design/ai/shared-dock.md §9-1).\n *\n * Pure, so the rule is tested alone and the screen can say \"no\" before anything is sent. A mode that\n * declares nothing accepts nothing: the chat once took .csv and .json, carried them, and no code ever\n * showed them to the model - the person got an answer about a file it had never seen (AI standard §7).\n */\n\n/*\n * 'spreadsheet' is a kind a mode opens, never a file the door receives: the screen reads each .xlsx sheet into CSV\n * text before sending (client/utils/spreadsheet-text.ts), so a mode that takes spreadsheets takes that text.\n */\nexport type AttachmentKind = 'image' | 'text' | 'spreadsheet'\n\nexport interface AttachmentPolicy {\n kinds: AttachmentKind[]\n /** Per file. */\n maxBytes?: number\n /** Per message. */\n maxFiles?: number\n /** Per text file, the characters the model reads - a sheet read into text longer than this is refused, not cut. */\n maxChars?: number\n}\n\nexport const DEFAULT_MAX_BYTES = 5 * 1024 * 1024\nexport const DEFAULT_MAX_FILES = 5\n/** How much of a text file reaches the model in the turn it was sent. */\nexport const TEXT_CHARS_FOR_MODEL = 20_000\n\nconst TEXT_TYPES = ['text/plain', 'text/csv', 'text/markdown', 'application/json']\nconst TEXT_EXTENSIONS = ['.txt', '.csv', '.md', '.json']\n\n/** Which kind a file is, from its type - and its name when the browser did not know the type. */\nexport function attachmentKindOf(file: { type?: string; name?: string }): AttachmentKind | null {\n const type = (file.type || '').toLowerCase()\n if (type.startsWith('image/')) return 'image'\n if (TEXT_TYPES.includes(type)) return 'text'\n const name = (file.name || '').toLowerCase()\n if (TEXT_EXTENSIONS.some(ext => name.endsWith(ext))) return 'text'\n return null\n}\n\nexport type AttachmentRefusal =\n | { code: 'attachments-not-accepted' }\n | { code: 'attachment-kind-not-accepted'; name: string; kinds: AttachmentKind[] }\n | { code: 'attachment-too-large'; name: string; maxBytes: number }\n | { code: 'too-many-attachments'; maxFiles: number }\n\n/** `null` when every file may go; otherwise the first reason, by name - said before sending, never cut silently. */\n/** A kind the policy takes - text also when it takes spreadsheets, which arrive as the text of their sheets. */\nfunction takes(policy: AttachmentPolicy, kind: AttachmentKind): boolean {\n return policy.kinds.includes(kind) || (kind === 'text' && policy.kinds.includes('spreadsheet'))\n}\n\n/** The characters of a text file the model reads under this policy. */\nexport function textCharsOf(policy: AttachmentPolicy | null | undefined): number {\n return policy?.maxChars ?? TEXT_CHARS_FOR_MODEL\n}\n\nexport function refuseAttachments(\n policy: AttachmentPolicy | null | undefined,\n files: { name?: string; type?: string; size?: number }[]\n): AttachmentRefusal | null {\n if (!files.length) return null\n if (!policy || !policy.kinds?.length) return { code: 'attachments-not-accepted' }\n\n const maxFiles = policy.maxFiles ?? DEFAULT_MAX_FILES\n if (files.length > maxFiles) return { code: 'too-many-attachments', maxFiles }\n\n const maxBytes = policy.maxBytes ?? DEFAULT_MAX_BYTES\n for (const file of files) {\n const kind = attachmentKindOf(file)\n if (!kind || !takes(policy, kind)) {\n return { code: 'attachment-kind-not-accepted', name: file.name || '', kinds: policy.kinds }\n }\n if ((file.size ?? 0) > maxBytes) return { code: 'attachment-too-large', name: file.name || '', maxBytes }\n }\n return null\n}\n\n/** The line history keeps for a file once its turn is past - the model reads the file again only by asking. */\nexport function attachmentHistoryLine(file: { id: string; name?: string; mimetype?: string; size?: number | string }): string {\n return `[attachment ${file.id}: ${file.name || 'unnamed'} (${file.mimetype || 'unknown type'}, ${file.size ?? '?'} bytes) - readChatAttachment to open it]`\n}\n"]}
1
+ {"version":3,"file":"attachment-policy.js","sourceRoot":"","sources":["../../../server/service/chat-session/attachment-policy.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * What a conversation accepts as attachments - declared by its mode (design/ai/shared-dock.md §9-1).\n *\n * Pure, so the rule is tested alone and the screen can say \"no\" before anything is sent. A mode that\n * declares nothing accepts nothing: the chat once took .csv and .json, carried them, and no code ever\n * showed them to the model - the person got an answer about a file it had never seen (AI standard §7).\n */\n\n/*\n * 'spreadsheet' is a kind a mode opens, never a file the door receives: the screen reads each .xlsx sheet into CSV\n * text before sending (client/utils/spreadsheet-text.ts), so a mode that takes spreadsheets takes that text.\n */\nexport type AttachmentKind = 'image' | 'text' | 'spreadsheet'\n\nexport interface AttachmentPolicy {\n kinds: AttachmentKind[]\n /** Per file. */\n maxBytes?: number\n /** Per message. */\n maxFiles?: number\n /** Per text file, the characters the model reads - a sheet read into text longer than this is refused, not cut. */\n maxChars?: number\n}\n\nexport const DEFAULT_MAX_BYTES = 5 * 1024 * 1024\nexport const DEFAULT_MAX_FILES = 5\n/** How much of a text file reaches the model in the turn it was sent. */\nexport const TEXT_CHARS_FOR_MODEL = 20_000\n\nconst TEXT_TYPES = ['text/plain', 'text/csv', 'text/markdown', 'application/json']\nconst TEXT_EXTENSIONS = ['.txt', '.csv', '.md', '.json']\n\n/** Which kind a file is, from its type - and its name when the browser did not know the type. */\nexport function attachmentKindOf(file: { type?: string; name?: string }): AttachmentKind | null {\n const type = (file.type || '').toLowerCase()\n if (type.startsWith('image/')) return 'image'\n if (TEXT_TYPES.includes(type)) return 'text'\n const name = (file.name || '').toLowerCase()\n if (TEXT_EXTENSIONS.some(ext => name.endsWith(ext))) return 'text'\n return null\n}\n\nexport type AttachmentRefusal =\n | { code: 'attachments-not-accepted' }\n | { code: 'attachment-kind-not-accepted'; name: string; kinds: AttachmentKind[] }\n | { code: 'attachment-too-large'; name: string; maxBytes: number }\n | { code: 'too-many-attachments'; maxFiles: number }\n\n/** `null` when every file may go; otherwise the first reason, by name - said before sending, never cut silently. */\n/** A kind the policy takes - text also when it takes spreadsheets, which arrive as the text of their sheets. */\nfunction takes(policy: AttachmentPolicy, kind: AttachmentKind): boolean {\n return policy.kinds.includes(kind) || (kind === 'text' && policy.kinds.includes('spreadsheet'))\n}\n\n/** The characters of a text file the model reads under this policy. */\nexport function textCharsOf(policy: AttachmentPolicy | null | undefined): number {\n return policy?.maxChars ?? TEXT_CHARS_FOR_MODEL\n}\n\nexport function refuseAttachments(\n policy: AttachmentPolicy | null | undefined,\n files: { name?: string; type?: string; size?: number }[]\n): AttachmentRefusal | null {\n if (!files.length) return null\n if (!policy || !policy.kinds?.length) return { code: 'attachments-not-accepted' }\n\n /*\n * File by file, in the order sent: its kind, then its size, then whether it is one too many. The screen's picking\n * rule (@operato/utils sortPickedFiles) says them in this order, so the same batch hears the same \"no\" from both.\n */\n const maxFiles = policy.maxFiles ?? DEFAULT_MAX_FILES\n const maxBytes = policy.maxBytes ?? DEFAULT_MAX_BYTES\n let taken = 0\n for (const file of files) {\n const kind = attachmentKindOf(file)\n if (!kind || !takes(policy, kind)) {\n return { code: 'attachment-kind-not-accepted', name: file.name || '', kinds: policy.kinds }\n }\n if ((file.size ?? 0) > maxBytes) return { code: 'attachment-too-large', name: file.name || '', maxBytes }\n if (taken >= maxFiles) return { code: 'too-many-attachments', maxFiles }\n taken++\n }\n return null\n}\n\n/** The line history keeps for a file once its turn is past - the model reads the file again only by asking. */\nexport function attachmentHistoryLine(file: { id: string; name?: string; mimetype?: string; size?: number | string }): string {\n return `[attachment ${file.id}: ${file.name || 'unnamed'} (${file.mimetype || 'unknown type'}, ${file.size ?? '?'} bytes) - readChatAttachment to open it]`\n}\n"]}