@things-factory/ai-assistant 10.1.106 → 10.1.108

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 (83) hide show
  1. package/client/components/assistant-chat.ts +67 -2
  2. package/client/components/assistant-dock.ts +4 -4
  3. package/client/components/assistant-mode.ts +1 -1
  4. package/client/components/entry-review.ts +23 -3
  5. package/client/pages/assistant-workspace-page.ts +82 -2
  6. package/client/utils/attachment-policy.ts +22 -3
  7. package/client/utils/entry-hash.ts +35 -0
  8. package/client/utils/entry-proposal.ts +10 -3
  9. package/client/utils/entry-workspace.ts +70 -0
  10. package/client/utils/pasted-table.ts +145 -0
  11. package/client/utils/spreadsheet-text.ts +125 -0
  12. package/dist-client/components/assistant-chat.d.ts +2 -0
  13. package/dist-client/components/assistant-chat.js +63 -2
  14. package/dist-client/components/assistant-chat.js.map +1 -1
  15. package/dist-client/components/assistant-dock.js +4 -4
  16. package/dist-client/components/assistant-dock.js.map +1 -1
  17. package/dist-client/components/assistant-mode.d.ts +2 -1
  18. package/dist-client/components/assistant-mode.js.map +1 -1
  19. package/dist-client/components/entry-review.d.ts +2 -0
  20. package/dist-client/components/entry-review.js +30 -3
  21. package/dist-client/components/entry-review.js.map +1 -1
  22. package/dist-client/pages/assistant-workspace-page.d.ts +8 -0
  23. package/dist-client/pages/assistant-workspace-page.js +98 -2
  24. package/dist-client/pages/assistant-workspace-page.js.map +1 -1
  25. package/dist-client/tsconfig.tsbuildinfo +1 -1
  26. package/dist-client/utils/attachment-policy.d.ts +7 -1
  27. package/dist-client/utils/attachment-policy.js +14 -2
  28. package/dist-client/utils/attachment-policy.js.map +1 -1
  29. package/dist-client/utils/entry-hash.d.ts +2 -0
  30. package/dist-client/utils/entry-hash.js +36 -0
  31. package/dist-client/utils/entry-hash.js.map +1 -0
  32. package/dist-client/utils/entry-proposal.d.ts +1 -1
  33. package/dist-client/utils/entry-proposal.js +9 -2
  34. package/dist-client/utils/entry-proposal.js.map +1 -1
  35. package/dist-client/utils/entry-workspace.d.ts +20 -0
  36. package/dist-client/utils/entry-workspace.js +68 -0
  37. package/dist-client/utils/entry-workspace.js.map +1 -0
  38. package/dist-client/utils/pasted-table.d.ts +34 -0
  39. package/dist-client/utils/pasted-table.js +150 -0
  40. package/dist-client/utils/pasted-table.js.map +1 -0
  41. package/dist-client/utils/spreadsheet-text.d.ts +48 -0
  42. package/dist-client/utils/spreadsheet-text.js +110 -0
  43. package/dist-client/utils/spreadsheet-text.js.map +1 -0
  44. package/dist-server/service/assistant-chat-resolver.js +1 -1
  45. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  46. package/dist-server/service/assistant-mode/assistant-mode-resolver.js +1 -1
  47. package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
  48. package/dist-server/service/chat-session/attachment-policy.d.ts +5 -2
  49. package/dist-server/service/chat-session/attachment-policy.js +10 -1
  50. package/dist-server/service/chat-session/attachment-policy.js.map +1 -1
  51. package/dist-server/service/chat-session/attachment-tools.js +3 -1
  52. package/dist-server/service/chat-session/attachment-tools.js.map +1 -1
  53. package/dist-server/service/chat-session/session-attachments.d.ts +3 -2
  54. package/dist-server/service/chat-session/session-attachments.js +5 -4
  55. package/dist-server/service/chat-session/session-attachments.js.map +1 -1
  56. package/dist-server/service/data-entry/entry-apply.d.ts +2 -0
  57. package/dist-server/service/data-entry/entry-apply.js +4 -0
  58. package/dist-server/service/data-entry/entry-apply.js.map +1 -1
  59. package/dist-server/service/data-entry/entry-hash.d.ts +2 -0
  60. package/dist-server/service/data-entry/entry-hash.js +39 -0
  61. package/dist-server/service/data-entry/entry-hash.js.map +1 -0
  62. package/dist-server/service/data-entry/entry-resolver.d.ts +1 -1
  63. package/dist-server/service/data-entry/entry-resolver.js +5 -3
  64. package/dist-server/service/data-entry/entry-resolver.js.map +1 -1
  65. package/dist-server/tsconfig.tsbuildinfo +1 -1
  66. package/package.json +5 -4
  67. package/server/service/assistant-chat-resolver.ts +2 -2
  68. package/server/service/assistant-mode/assistant-mode-resolver.ts +1 -1
  69. package/server/service/chat-session/attachment-policy.ts +18 -2
  70. package/server/service/chat-session/attachment-tools.ts +3 -1
  71. package/server/service/chat-session/session-attachments.ts +5 -4
  72. package/server/service/data-entry/data-entry.test.ts +10 -0
  73. package/server/service/data-entry/entry-apply.ts +5 -0
  74. package/server/service/data-entry/entry-hash.ts +35 -0
  75. package/server/service/data-entry/entry-resolver.ts +4 -2
  76. package/test/entry-workspace.test.ts +89 -0
  77. package/test/pasted-table.test.ts +140 -0
  78. package/test/spreadsheet-text.test.ts +92 -0
  79. package/translations/en.json +12 -1
  80. package/translations/ja.json +12 -1
  81. package/translations/ko.json +12 -1
  82. package/translations/ms.json +12 -1
  83. package/translations/zh.json +12 -1
@@ -1,11 +1,15 @@
1
- export type AttachmentKind = 'image' | 'text';
1
+ export type AttachmentKind = 'image' | 'text' | 'spreadsheet';
2
2
  export interface AttachmentPolicy {
3
3
  kinds: AttachmentKind[];
4
4
  maxBytes?: number;
5
5
  maxFiles?: number;
6
+ /** Per text file, the characters the model reads - a sheet read into text longer than this is refused, not cut. */
7
+ maxChars?: number;
6
8
  }
7
9
  export declare const DEFAULT_MAX_BYTES: number;
8
10
  export declare const DEFAULT_MAX_FILES = 5;
11
+ /** How much of a text file reaches the model - the server's TEXT_CHARS_FOR_MODEL. */
12
+ export declare const TEXT_CHARS_FOR_MODEL = 20000;
9
13
  export declare function attachmentKindOf(file: {
10
14
  type?: string;
11
15
  name?: string;
@@ -24,6 +28,8 @@ export type AttachmentRefusal = {
24
28
  code: 'too-many-attachments';
25
29
  maxFiles: number;
26
30
  };
31
+ /** The characters of a text file the model reads under this policy. */
32
+ export declare function textCharsOf(policy: AttachmentPolicy | null | undefined): number;
27
33
  export declare function refuseAttachments(policy: AttachmentPolicy | null | undefined, files: {
28
34
  name?: string;
29
35
  type?: string;
@@ -7,6 +7,8 @@
7
7
  */
8
8
  export const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
9
9
  export const DEFAULT_MAX_FILES = 5;
10
+ /** How much of a text file reaches the model - the server's TEXT_CHARS_FOR_MODEL. */
11
+ export const TEXT_CHARS_FOR_MODEL = 20_000;
10
12
  const TEXT_TYPES = ['text/plain', 'text/csv', 'text/markdown', 'application/json'];
11
13
  const TEXT_EXTENSIONS = ['.txt', '.csv', '.md', '.json'];
12
14
  export function attachmentKindOf(file) {
@@ -20,6 +22,14 @@ export function attachmentKindOf(file) {
20
22
  return 'text';
21
23
  return null;
22
24
  }
25
+ /** 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
+ }
29
+ /** The characters of a text file the model reads under this policy. */
30
+ export function textCharsOf(policy) {
31
+ return policy?.maxChars ?? TEXT_CHARS_FOR_MODEL;
32
+ }
23
33
  export function refuseAttachments(policy, files) {
24
34
  if (!files.length)
25
35
  return null;
@@ -31,7 +41,7 @@ export function refuseAttachments(policy, files) {
31
41
  const maxBytes = policy.maxBytes ?? DEFAULT_MAX_BYTES;
32
42
  for (const file of files) {
33
43
  const kind = attachmentKindOf(file);
34
- if (!kind || !policy.kinds.includes(kind)) {
44
+ if (!kind || !takes(policy, kind)) {
35
45
  return { code: 'attachment-kind-not-accepted', name: file.name || '', kinds: policy.kinds };
36
46
  }
37
47
  if ((file.size ?? 0) > maxBytes)
@@ -44,8 +54,10 @@ export function acceptOf(policy) {
44
54
  const accept = [];
45
55
  if (policy?.kinds?.includes('image'))
46
56
  accept.push('image/*');
47
- if (policy?.kinds?.includes('text'))
57
+ if (policy?.kinds?.includes('text') || policy?.kinds?.includes('spreadsheet'))
48
58
  accept.push(...TEXT_EXTENSIONS, ...TEXT_TYPES);
59
+ if (policy?.kinds?.includes('spreadsheet'))
60
+ accept.push('.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
49
61
  return accept.join(',');
50
62
  }
51
63
  /** Where a conversation's file opens - the gated route, under the page's domain path. */
@@ -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\nexport type AttachmentKind = 'image' | 'text'\n\nexport interface AttachmentPolicy {\n kinds: AttachmentKind[]\n maxBytes?: number\n maxFiles?: number\n}\n\nexport const DEFAULT_MAX_BYTES = 5 * 1024 * 1024\nexport const DEFAULT_MAX_FILES = 5\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\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 || !policy.kinds.includes(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')) accept.push(...TEXT_EXTENSIONS, ...TEXT_TYPES)\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 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"]}
@@ -0,0 +1,2 @@
1
+ /** FNV-1a over the canonical text - a fingerprint to compare, not a secret. */
2
+ export declare function entryProposalHash(proposal: any): string;
@@ -0,0 +1,36 @@
1
+ /*
2
+ * One 입력안's fingerprint - what the AI 작업판 showed and what the card enters are the same proposal
3
+ * (operato-application/design/ai/workspace.md §6-1). The screen's copy and the server's are one rule;
4
+ * `test/entry-hash-agree.test.ts` runs both over one table.
5
+ *
6
+ * It covers what is entered: the key, the target, create or change, and each row's index, record and values.
7
+ */
8
+ const canonical = (value) => {
9
+ if (value === null || value === undefined)
10
+ return 'null';
11
+ if (Array.isArray(value))
12
+ return `[${value.map(canonical).join(',')}]`;
13
+ if (typeof value === 'object')
14
+ return `{${Object.keys(value)
15
+ .filter(name => value[name] !== undefined)
16
+ .sort()
17
+ .map(name => `${JSON.stringify(name)}:${canonical(value[name])}`)
18
+ .join(',')}}`;
19
+ return JSON.stringify(value);
20
+ };
21
+ /** FNV-1a over the canonical text - a fingerprint to compare, not a secret. */
22
+ export function entryProposalHash(proposal) {
23
+ const text = canonical({
24
+ key: proposal?.key,
25
+ target: proposal?.target,
26
+ op: proposal?.op ?? 'create',
27
+ rows: (proposal?.rows ?? []).map((row) => ({ index: row?.index, id: row?.id, values: row?.values ?? {} }))
28
+ });
29
+ let hash = 0x811c9dc5;
30
+ for (let i = 0; i < text.length; i++) {
31
+ hash ^= text.charCodeAt(i);
32
+ hash = Math.imul(hash, 0x01000193) >>> 0;
33
+ }
34
+ return `${hash.toString(16).padStart(8, '0')}-${text.length.toString(16)}`;
35
+ }
36
+ //# sourceMappingURL=entry-hash.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entry-hash.js","sourceRoot":"","sources":["../../client/utils/entry-hash.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,MAAM,SAAS,GAAG,CAAC,KAAc,EAAU,EAAE;IAC3C,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,MAAM,CAAA;IACxD,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,KAAK,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAA;IACtE,IAAI,OAAO,KAAK,KAAK,QAAQ;QAC3B,OAAO,IAAI,MAAM,CAAC,IAAI,CAAC,KAAe,CAAC;aACpC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAE,KAAa,CAAC,IAAI,CAAC,KAAK,SAAS,CAAC;aAClD,IAAI,EAAE;aACN,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,SAAS,CAAE,KAAa,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC;aACzE,IAAI,CAAC,GAAG,CAAC,GAAG,CAAA;IACjB,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAA;AAC9B,CAAC,CAAA;AAED,+EAA+E;AAC/E,MAAM,UAAU,iBAAiB,CAAC,QAAa;IAC7C,MAAM,IAAI,GAAG,SAAS,CAAC;QACrB,GAAG,EAAE,QAAQ,EAAE,GAAG;QAClB,MAAM,EAAE,QAAQ,EAAE,MAAM;QACxB,EAAE,EAAE,QAAQ,EAAE,EAAE,IAAI,QAAQ;QAC5B,IAAI,EAAE,CAAC,QAAQ,EAAE,IAAI,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,GAAQ,EAAE,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,GAAG,EAAE,KAAK,EAAE,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,IAAI,EAAE,EAAE,CAAC,CAAC;KAChH,CAAC,CAAA;IACF,IAAI,IAAI,GAAG,UAAU,CAAA;IACrB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACrC,IAAI,IAAI,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAA;QAC1B,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,CAAC,KAAK,CAAC,CAAA;IAC1C,CAAC;IACD,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,CAAA;AAC5E,CAAC","sourcesContent":["/*\n * One 입력안's fingerprint - what the AI 작업판 showed and what the card enters are the same proposal\n * (operato-application/design/ai/workspace.md §6-1). The screen's copy and the server's are one rule;\n * `test/entry-hash-agree.test.ts` runs both over one table.\n *\n * It covers what is entered: the key, the target, create or change, and each row's index, record and values.\n */\n\nconst canonical = (value: unknown): string => {\n if (value === null || value === undefined) return 'null'\n if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`\n if (typeof value === 'object')\n return `{${Object.keys(value as object)\n .filter(name => (value as any)[name] !== undefined)\n .sort()\n .map(name => `${JSON.stringify(name)}:${canonical((value as any)[name])}`)\n .join(',')}}`\n return JSON.stringify(value)\n}\n\n/** FNV-1a over the canonical text - a fingerprint to compare, not a secret. */\nexport function entryProposalHash(proposal: any): string {\n const text = canonical({\n key: proposal?.key,\n target: proposal?.target,\n op: proposal?.op ?? 'create',\n rows: (proposal?.rows ?? []).map((row: any) => ({ index: row?.index, id: row?.id, values: row?.values ?? {} }))\n })\n let hash = 0x811c9dc5\n for (let i = 0; i < text.length; i++) {\n hash ^= text.charCodeAt(i)\n hash = Math.imul(hash, 0x01000193) >>> 0\n }\n return `${hash.toString(16).padStart(8, '0')}-${text.length.toString(16)}`\n}\n"]}
@@ -52,7 +52,7 @@ export declare function entryCellIssues(row: EntryRowView, field?: string): Entr
52
52
  /** What stays in the conversation - the model reads it on its next turn, and the person sees what went in. */
53
53
  export declare function entryOutcomeNote(answer: EntryApplyAnswer, t: (key: string, options?: any) => string): string;
54
54
  /** The handler every entry card runs - built with the mutation so the rules are tested without a server. */
55
- export declare function entryProposalHandler(apply: (sessionId: string, proposalKey: string) => Promise<EntryApplyAnswer>, t: (key: string, options?: any) => string): (proposal: any, context: ProposalContext) => Promise<ProposalOutcome>;
55
+ export declare function entryProposalHandler(apply: (sessionId: string, proposalKey: string, reviewedHash?: string) => Promise<EntryApplyAnswer>, t: (key: string, options?: any) => string, reviewedOf?: (key: string) => string | undefined): (proposal: any, context: ProposalContext) => Promise<ProposalOutcome>;
56
56
  /**
57
57
  * The newest card per record, over the conversation's proposals in order - an older one is replaced and
58
58
  * cannot be entered (the server refuses it as `superseded`; the card says so before anyone presses).
@@ -1,3 +1,4 @@
1
+ import { reviewedHashOf, reviewStillHolds } from './entry-workspace.js';
1
2
  export function isEntryProposal(proposal) {
2
3
  return proposal?.kind === 'entry' && Array.isArray(proposal?.rows);
3
4
  }
@@ -37,11 +38,17 @@ export function entryOutcomeNote(answer, t) {
37
38
  return [head, ...lines, ...more].join('\n');
38
39
  }
39
40
  /** The handler every entry card runs - built with the mutation so the rules are tested without a server. */
40
- export function entryProposalHandler(apply, t) {
41
+ export function entryProposalHandler(apply, t, reviewedOf = reviewedHashOf) {
41
42
  return async (proposal, context) => {
42
43
  if (!context.sessionId || !proposal?.key)
43
44
  return { verdict: 'unavailable', errorCode: 'no-conversation' };
44
- const answer = { ...(await apply(context.sessionId, proposal.key)), op: proposal.op };
45
+ /* Reviewed in the 작업판: entered only as it was shown there (design/ai/workspace.md §6-1) - here, and again at the server. */
46
+ const reviewed = reviewedOf(proposal.key);
47
+ if (!reviewStillHolds(proposal, reviewed))
48
+ return { verdict: 'refused', errorCode: 'review-stale', note: t('ai-assistant.entry.review-stale', { defaultValue: 'this proposal changed after it was reviewed in the workspace - review it again before entering.' }) };
49
+ const answer = { ...(await apply(context.sessionId, proposal.key, reviewed)), op: proposal.op };
50
+ if (answer.errorCode === 'review-stale')
51
+ return { verdict: 'refused', errorCode: 'review-stale', applied: 0, missed: 0, note: t('ai-assistant.entry.review-stale', { defaultValue: 'this proposal changed after it was reviewed in the workspace - review it again before entering.' }) };
45
52
  return {
46
53
  verdict: answer.verdict,
47
54
  ...(answer.errorCode ? { errorCode: answer.errorCode } : {}),
@@ -1 +1 @@
1
- {"version":3,"file":"entry-proposal.js","sourceRoot":"","sources":["../../client/utils/entry-proposal.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * The data entry card's rules and its handler (operato-application/design/ai/data-entry.md §5).\n *\n * The card is a review, not an editor: a correction is said in the conversation and the model proposes\n * again - the newer card replaces the older one. Pressing asks the server to enter the stored rows; the\n * screen sends no rows of its own.\n */\nimport type { ProposalContext, ProposalOutcome } from './assistant-proposals.js'\n\nexport interface EntryIssueView {\n field?: string\n code: string\n messageKey?: string\n params?: Record<string, unknown>\n paramKeys?: Record<string, string>\n blocking: boolean\n}\n\nexport interface EntryRowView {\n index: number\n values: Record<string, unknown>\n sources: { attachmentId?: string; where?: string; person?: boolean }[]\n issues: EntryIssueView[]\n /** The server found this row's numbers and dates together on this line of the cited file. */\n evidence?: { attachmentId: string; line: number }\n}\n\nexport interface EntryTotalView {\n field: string\n all: number\n ready: number\n declared?: number\n declaredFound?: boolean\n difference?: number\n declaredBy?: 'model' | 'file'\n}\n\nexport interface EntryRowResult {\n index: number\n ok: boolean\n id?: string\n label?: string\n error?: string\n}\n\nexport interface EntryApplyAnswer extends ProposalOutcome {\n rows?: EntryRowResult[]\n /** Set by the handler from the card - which words the conversation line uses. */\n op?: string\n}\n\nexport function isEntryProposal(proposal: any): boolean {\n return proposal?.kind === 'entry' && Array.isArray(proposal?.rows)\n}\n\nexport function entryReadyCount(proposal: any): number {\n const rows: EntryRowView[] = proposal?.rows ?? []\n const blocked = (row: EntryRowView) => row.issues?.some(issue => issue.blocking)\n if (!proposal?.groupBy) return rows.filter(row => !blocked(row)).length\n /* Lines of one record go in together - a record with a line kept out is kept out whole, and its lines do not count. */\n const keyOf = (row: EntryRowView) => {\n const value: any = row.values?.[proposal.groupBy]\n return value === undefined || value === null || value === '' ? `row-${row.index}` : String(typeof value === 'object' ? value.id ?? value.text : value)\n }\n const out = new Set(rows.filter(blocked).map(keyOf))\n return rows.filter(row => !out.has(keyOf(row))).length\n}\n\n/** The i18n key of an issue - the target's own, or the framework's by code. */\nexport function entryIssueKey(issue: EntryIssueView): string {\n return issue.messageKey || `ai-assistant.entry.issue.${issue.code}`\n}\n\n/** The issues of one cell, and of the row as a whole (no field). */\nexport function entryCellIssues(row: EntryRowView, field?: string): EntryIssueView[] {\n return (row.issues ?? []).filter(issue => (field ? issue.field === field : !issue.field))\n}\n\n/** What stays in the conversation - the model reads it on its next turn, and the person sees what went in. */\nexport function entryOutcomeNote(answer: EntryApplyAnswer, t: (key: string, options?: any) => string): string {\n const refused = (answer.rows ?? []).filter(row => !row.ok)\n const head = answer.op === 'update'\n ? answer.applied\n ? t('ai-assistant.entry.changed', { defaultValue: 'changed {count} records.', count: answer.applied })\n : t('ai-assistant.entry.changed-none', { defaultValue: 'no record was changed.' })\n : answer.applied\n ? t('ai-assistant.entry.entered', { defaultValue: 'entered {count} rows.', count: answer.applied })\n : t('ai-assistant.entry.entered-none', { defaultValue: 'no row was entered.' })\n const lines = refused.slice(0, 20).map(row => t('ai-assistant.entry.row-refused', { defaultValue: 'row {n}: {reason}', n: row.index + 1, reason: row.error || '' }))\n const more = refused.length > 20 ? [t('ai-assistant.entry.more-refused', { defaultValue: '{count} more rows were not entered.', count: refused.length - 20 })] : []\n return [head, ...lines, ...more].join('\\n')\n}\n\n/** The handler every entry card runs - built with the mutation so the rules are tested without a server. */\nexport function entryProposalHandler(\n apply: (sessionId: string, proposalKey: string) => Promise<EntryApplyAnswer>,\n t: (key: string, options?: any) => string\n) {\n return async (proposal: any, context: ProposalContext): Promise<ProposalOutcome> => {\n if (!context.sessionId || !proposal?.key) return { verdict: 'unavailable', errorCode: 'no-conversation' }\n const answer = { ...(await apply(context.sessionId, proposal.key)), op: proposal.op }\n return {\n verdict: answer.verdict,\n ...(answer.errorCode ? { errorCode: answer.errorCode } : {}),\n applied: answer.applied ?? 0,\n missed: answer.missed ?? 0,\n note: entryOutcomeNote(answer, t)\n }\n }\n}\n\n/**\n * The newest card per record, over the conversation's proposals in order - an older one is replaced and\n * cannot be entered (the server refuses it as `superseded`; the card says so before anyone presses).\n */\nexport function latestEntryKeys(proposalsInOrder: any[][]): Map<string, string> {\n const latest = new Map<string, string>()\n for (const list of proposalsInOrder) for (const one of list ?? []) if (isEntryProposal(one) && one.key) latest.set(entryGroupOf(one), one.key)\n return latest\n}\n\n/** A 입력안 and a 수정안 of one record are counted apart - a newer one of the same kind replaces the older. */\nexport function entryGroupOf(proposal: any): string {\n return `${proposal?.target}:${proposal?.op ?? 'create'}`\n}\n\n/**\n * The card's title - the record's name from the target (`부서`, `거래처`) and the kind of card from here, so a\n * 수정안 is never titled as a 입력안.\n */\nexport function entryTitle(proposal: any, t: (key: string, options?: any) => string): string {\n const name = proposal?.labelKey ? t(proposal.labelKey) : proposal?.target ?? ''\n return proposal?.op === 'update'\n ? t('ai-assistant.entry.title-update', { defaultValue: '{name} to change', name })\n : t('ai-assistant.entry.title-create', { defaultValue: '{name} to enter', name })\n}\n\nexport function isChangeProposal(proposal: any): boolean {\n return isEntryProposal(proposal) && proposal.op === 'update'\n}\n"]}
1
+ {"version":3,"file":"entry-proposal.js","sourceRoot":"","sources":["../../client/utils/entry-proposal.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * The data entry card's rules and its handler (operato-application/design/ai/data-entry.md §5).\n *\n * The card is a review, not an editor: a correction is said in the conversation and the model proposes\n * again - the newer card replaces the older one. Pressing asks the server to enter the stored rows; the\n * screen sends no rows of its own.\n */\nimport type { ProposalContext, ProposalOutcome } from './assistant-proposals.js'\nimport { reviewedHashOf, reviewStillHolds } from './entry-workspace.js'\n\nexport interface EntryIssueView {\n field?: string\n code: string\n messageKey?: string\n params?: Record<string, unknown>\n paramKeys?: Record<string, string>\n blocking: boolean\n}\n\nexport interface EntryRowView {\n index: number\n values: Record<string, unknown>\n sources: { attachmentId?: string; where?: string; person?: boolean }[]\n issues: EntryIssueView[]\n /** The server found this row's numbers and dates together on this line of the cited file. */\n evidence?: { attachmentId: string; line: number }\n}\n\nexport interface EntryTotalView {\n field: string\n all: number\n ready: number\n declared?: number\n declaredFound?: boolean\n difference?: number\n declaredBy?: 'model' | 'file'\n}\n\nexport interface EntryRowResult {\n index: number\n ok: boolean\n id?: string\n label?: string\n error?: string\n}\n\nexport interface EntryApplyAnswer extends ProposalOutcome {\n rows?: EntryRowResult[]\n /** Set by the handler from the card - which words the conversation line uses. */\n op?: string\n}\n\nexport function isEntryProposal(proposal: any): boolean {\n return proposal?.kind === 'entry' && Array.isArray(proposal?.rows)\n}\n\nexport function entryReadyCount(proposal: any): number {\n const rows: EntryRowView[] = proposal?.rows ?? []\n const blocked = (row: EntryRowView) => row.issues?.some(issue => issue.blocking)\n if (!proposal?.groupBy) return rows.filter(row => !blocked(row)).length\n /* Lines of one record go in together - a record with a line kept out is kept out whole, and its lines do not count. */\n const keyOf = (row: EntryRowView) => {\n const value: any = row.values?.[proposal.groupBy]\n return value === undefined || value === null || value === '' ? `row-${row.index}` : String(typeof value === 'object' ? value.id ?? value.text : value)\n }\n const out = new Set(rows.filter(blocked).map(keyOf))\n return rows.filter(row => !out.has(keyOf(row))).length\n}\n\n/** The i18n key of an issue - the target's own, or the framework's by code. */\nexport function entryIssueKey(issue: EntryIssueView): string {\n return issue.messageKey || `ai-assistant.entry.issue.${issue.code}`\n}\n\n/** The issues of one cell, and of the row as a whole (no field). */\nexport function entryCellIssues(row: EntryRowView, field?: string): EntryIssueView[] {\n return (row.issues ?? []).filter(issue => (field ? issue.field === field : !issue.field))\n}\n\n/** What stays in the conversation - the model reads it on its next turn, and the person sees what went in. */\nexport function entryOutcomeNote(answer: EntryApplyAnswer, t: (key: string, options?: any) => string): string {\n const refused = (answer.rows ?? []).filter(row => !row.ok)\n const head = answer.op === 'update'\n ? answer.applied\n ? t('ai-assistant.entry.changed', { defaultValue: 'changed {count} records.', count: answer.applied })\n : t('ai-assistant.entry.changed-none', { defaultValue: 'no record was changed.' })\n : answer.applied\n ? t('ai-assistant.entry.entered', { defaultValue: 'entered {count} rows.', count: answer.applied })\n : t('ai-assistant.entry.entered-none', { defaultValue: 'no row was entered.' })\n const lines = refused.slice(0, 20).map(row => t('ai-assistant.entry.row-refused', { defaultValue: 'row {n}: {reason}', n: row.index + 1, reason: row.error || '' }))\n const more = refused.length > 20 ? [t('ai-assistant.entry.more-refused', { defaultValue: '{count} more rows were not entered.', count: refused.length - 20 })] : []\n return [head, ...lines, ...more].join('\\n')\n}\n\n/** The handler every entry card runs - built with the mutation so the rules are tested without a server. */\nexport function entryProposalHandler(\n apply: (sessionId: string, proposalKey: string, reviewedHash?: string) => Promise<EntryApplyAnswer>,\n t: (key: string, options?: any) => string,\n reviewedOf: (key: string) => string | undefined = reviewedHashOf\n) {\n return async (proposal: any, context: ProposalContext): Promise<ProposalOutcome> => {\n if (!context.sessionId || !proposal?.key) return { verdict: 'unavailable', errorCode: 'no-conversation' }\n /* Reviewed in the 작업판: entered only as it was shown there (design/ai/workspace.md §6-1) - here, and again at the server. */\n const reviewed = reviewedOf(proposal.key)\n if (!reviewStillHolds(proposal, reviewed)) return { verdict: 'refused', errorCode: 'review-stale', note: t('ai-assistant.entry.review-stale', { defaultValue: 'this proposal changed after it was reviewed in the workspace - review it again before entering.' }) } as any\n const answer = { ...(await apply(context.sessionId, proposal.key, reviewed)), op: proposal.op }\n if (answer.errorCode === 'review-stale')\n return { verdict: 'refused', errorCode: 'review-stale', applied: 0, missed: 0, note: t('ai-assistant.entry.review-stale', { defaultValue: 'this proposal changed after it was reviewed in the workspace - review it again before entering.' }) }\n return {\n verdict: answer.verdict,\n ...(answer.errorCode ? { errorCode: answer.errorCode } : {}),\n applied: answer.applied ?? 0,\n missed: answer.missed ?? 0,\n note: entryOutcomeNote(answer, t)\n }\n }\n}\n\n/**\n * The newest card per record, over the conversation's proposals in order - an older one is replaced and\n * cannot be entered (the server refuses it as `superseded`; the card says so before anyone presses).\n */\nexport function latestEntryKeys(proposalsInOrder: any[][]): Map<string, string> {\n const latest = new Map<string, string>()\n for (const list of proposalsInOrder) for (const one of list ?? []) if (isEntryProposal(one) && one.key) latest.set(entryGroupOf(one), one.key)\n return latest\n}\n\n/** A 입력안 and a 수정안 of one record are counted apart - a newer one of the same kind replaces the older. */\nexport function entryGroupOf(proposal: any): string {\n return `${proposal?.target}:${proposal?.op ?? 'create'}`\n}\n\n/**\n * The card's title - the record's name from the target (`부서`, `거래처`) and the kind of card from here, so a\n * 수정안 is never titled as a 입력안.\n */\nexport function entryTitle(proposal: any, t: (key: string, options?: any) => string): string {\n const name = proposal?.labelKey ? t(proposal.labelKey) : proposal?.target ?? ''\n return proposal?.op === 'update'\n ? t('ai-assistant.entry.title-update', { defaultValue: '{name} to change', name })\n : t('ai-assistant.entry.title-create', { defaultValue: '{name} to enter', name })\n}\n\nexport function isChangeProposal(proposal: any): boolean {\n return isEntryProposal(proposal) && proposal.op === 'update'\n}\n"]}
@@ -0,0 +1,20 @@
1
+ /** A card wide or long enough not to be read in the dock. */
2
+ export declare const WORKSPACE_ROWS = 20;
3
+ export declare const WORKSPACE_COLUMNS = 6;
4
+ export declare function offersWorkspaceReview(proposal: any): boolean;
5
+ export declare function entryReviewAddress(sessionId: string, proposalKey: string): string;
6
+ /** The proposal of that key among the conversation's messages, and whether a newer card of the same record replaced it. */
7
+ export declare function entryForReview(messages: {
8
+ id: string;
9
+ createdAt?: string;
10
+ proposalsJson?: any[] | null;
11
+ }[], key: string): {
12
+ proposal?: any;
13
+ createdAt?: string;
14
+ superseded: boolean;
15
+ };
16
+ /** Remembers what the page showed for this key. A browser that keeps nothing keeps nothing - the card then sends no fingerprint. */
17
+ export declare function rememberReviewed(proposal: any, store?: Pick<Storage, 'setItem'> | undefined): string;
18
+ export declare function reviewedHashOf(key: string, store?: Pick<Storage, 'getItem'> | undefined): string | undefined;
19
+ /** Whether the card may be pressed: not reviewed, or reviewed as it stands now. */
20
+ export declare function reviewStillHolds(proposal: any, reviewed: string | undefined): boolean;
@@ -0,0 +1,68 @@
1
+ /*
2
+ * Reviewing a 입력안 in the AI 작업판 (operato-application/design/ai/workspace.md §6-1). Pure but for the
3
+ * session store - tested without a browser.
4
+ *
5
+ * The page is read-only: every column and every row, sorted as stored, issues marked. The one place that enters
6
+ * is the dock's card. What the page showed is remembered by its fingerprint, and the card sends it - a proposal
7
+ * that changed in between is refused, and the person reviews it again.
8
+ */
9
+ import { entryProposalHash } from './entry-hash.js';
10
+ /** A card wide or long enough not to be read in the dock. */
11
+ export const WORKSPACE_ROWS = 20;
12
+ export const WORKSPACE_COLUMNS = 6;
13
+ export function offersWorkspaceReview(proposal) {
14
+ return (proposal?.rows?.length ?? 0) > WORKSPACE_ROWS || (proposal?.columns?.length ?? 0) > WORKSPACE_COLUMNS;
15
+ }
16
+ export function entryReviewAddress(sessionId, proposalKey) {
17
+ return `assistant-workspace?${new URLSearchParams({ session: sessionId, entry: proposalKey }).toString()}`;
18
+ }
19
+ /** The proposal of that key among the conversation's messages, and whether a newer card of the same record replaced it. */
20
+ export function entryForReview(messages, key) {
21
+ let at = -1;
22
+ let proposal;
23
+ let createdAt;
24
+ messages.forEach((message, i) => {
25
+ for (const one of message.proposalsJson ?? [])
26
+ if (one?.kind === 'entry' && one?.key === key)
27
+ (at = i), (proposal = one), (createdAt = message.createdAt);
28
+ });
29
+ if (!proposal)
30
+ return { superseded: false };
31
+ const superseded = messages
32
+ .slice(at + 1)
33
+ .some(message => (message.proposalsJson ?? []).some(one => one?.kind === 'entry' && one?.target === proposal.target && (one?.op ?? 'create') === (proposal.op ?? 'create')));
34
+ return { proposal, createdAt, superseded };
35
+ }
36
+ const STORE = 'ai-assistant.entry-reviewed:';
37
+ /** Remembers what the page showed for this key. A browser that keeps nothing keeps nothing - the card then sends no fingerprint. */
38
+ export function rememberReviewed(proposal, store = safeSession()) {
39
+ const hash = entryProposalHash(proposal);
40
+ try {
41
+ store?.setItem(STORE + proposal.key, hash);
42
+ }
43
+ catch {
44
+ /* private window or blocked storage */
45
+ }
46
+ return hash;
47
+ }
48
+ export function reviewedHashOf(key, store = safeSession()) {
49
+ try {
50
+ return store?.getItem(STORE + key) ?? undefined;
51
+ }
52
+ catch {
53
+ return undefined;
54
+ }
55
+ }
56
+ /** Whether the card may be pressed: not reviewed, or reviewed as it stands now. */
57
+ export function reviewStillHolds(proposal, reviewed) {
58
+ return !reviewed || reviewed === entryProposalHash(proposal);
59
+ }
60
+ function safeSession() {
61
+ try {
62
+ return typeof sessionStorage === 'undefined' ? undefined : sessionStorage;
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ }
68
+ //# sourceMappingURL=entry-workspace.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entry-workspace.js","sourceRoot":"","sources":["../../client/utils/entry-workspace.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Reviewing a 입력안 in the AI 작업판 (operato-application/design/ai/workspace.md §6-1). Pure but for the\n * session store - tested without a browser.\n *\n * The page is read-only: every column and every row, sorted as stored, issues marked. The one place that enters\n * is the dock's card. What the page showed is remembered by its fingerprint, and the card sends it - a proposal\n * that changed in between is refused, and the person reviews it again.\n */\nimport { entryProposalHash } from './entry-hash.js'\n\n/** A card wide or long enough not to be read in the dock. */\nexport const WORKSPACE_ROWS = 20\nexport const WORKSPACE_COLUMNS = 6\n\nexport function offersWorkspaceReview(proposal: any): boolean {\n return (proposal?.rows?.length ?? 0) > WORKSPACE_ROWS || (proposal?.columns?.length ?? 0) > WORKSPACE_COLUMNS\n}\n\nexport function entryReviewAddress(sessionId: string, proposalKey: string): string {\n return `assistant-workspace?${new URLSearchParams({ session: sessionId, entry: proposalKey }).toString()}`\n}\n\n/** The proposal of that key among the conversation's messages, and whether a newer card of the same record replaced it. */\nexport function entryForReview(messages: { id: string; createdAt?: string; proposalsJson?: any[] | null }[], key: string): { proposal?: any; createdAt?: string; superseded: boolean } {\n let at = -1\n let proposal: any\n let createdAt: string | undefined\n messages.forEach((message, i) => {\n for (const one of message.proposalsJson ?? []) if (one?.kind === 'entry' && one?.key === key) (at = i), (proposal = one), (createdAt = message.createdAt)\n })\n if (!proposal) return { superseded: false }\n const superseded = messages\n .slice(at + 1)\n .some(message => (message.proposalsJson ?? []).some(one => one?.kind === 'entry' && one?.target === proposal.target && (one?.op ?? 'create') === (proposal.op ?? 'create')))\n return { proposal, createdAt, superseded }\n}\n\nconst STORE = 'ai-assistant.entry-reviewed:'\n\n/** Remembers what the page showed for this key. A browser that keeps nothing keeps nothing - the card then sends no fingerprint. */\nexport function rememberReviewed(proposal: any, store: Pick<Storage, 'setItem'> | undefined = safeSession()): string {\n const hash = entryProposalHash(proposal)\n try {\n store?.setItem(STORE + proposal.key, hash)\n } catch {\n /* private window or blocked storage */\n }\n return hash\n}\n\nexport function reviewedHashOf(key: string, store: Pick<Storage, 'getItem'> | undefined = safeSession()): string | undefined {\n try {\n return store?.getItem(STORE + key) ?? undefined\n } catch {\n return undefined\n }\n}\n\n/** Whether the card may be pressed: not reviewed, or reviewed as it stands now. */\nexport function reviewStillHolds(proposal: any, reviewed: string | undefined): boolean {\n return !reviewed || reviewed === entryProposalHash(proposal)\n}\n\nfunction safeSession(): Storage | undefined {\n try {\n return typeof sessionStorage === 'undefined' ? undefined : sessionStorage\n } catch {\n return undefined\n }\n}\n"]}
@@ -0,0 +1,34 @@
1
+ import { type AttachmentPolicy } from './attachment-policy.js';
2
+ export interface PastedTable {
3
+ rows: string[][];
4
+ columns: number;
5
+ }
6
+ /** The rows of the tables in the clipboard's HTML, or null when it holds none or is mostly not a table. */
7
+ export declare function tableOfHtml(html: string | null | undefined): string[][] | null;
8
+ /** The rows of a spreadsheet's plain text - tab-separated lines, a cell with a break or a tab in quotes. */
9
+ export declare function tableOfText(text: string | null | undefined): string[][] | null;
10
+ /** The table a paste holds, or null for words - at least two rows of at least two columns. */
11
+ export declare function pastedTable(clipboard: {
12
+ html?: string | null;
13
+ text?: string | null;
14
+ }): PastedTable | null;
15
+ /** The table as the text of its file: a line naming it, then every row led by its own number. */
16
+ export declare function pastedTableText(table: PastedTable, name: string): string;
17
+ export type PasteRefusal = {
18
+ code: 'attachment-paste-too-long';
19
+ rows: number;
20
+ maxChars: number;
21
+ };
22
+ /**
23
+ * What a paste becomes in a conversation under this policy: a table file, a refusal, or null - the paste stays words
24
+ * (no table in it, or a conversation that takes no text files).
25
+ */
26
+ export declare function pastedTableFile(clipboard: {
27
+ html?: string | null;
28
+ text?: string | null;
29
+ }, policy: AttachmentPolicy | null | undefined, nameOf: (rows: number) => string): {
30
+ name: string;
31
+ text: string;
32
+ } | {
33
+ refusal: PasteRefusal;
34
+ } | null;
@@ -0,0 +1,150 @@
1
+ /*
2
+ * A table pasted into the composer, carried as a file (design/ai/data-entry.md §4-1, design/ai/shared-dock.md §9-5).
3
+ *
4
+ * Rows copied from a spreadsheet or a web page are what an AI 입력 is made from most often. Pasted as words they
5
+ * reach the model as something the person said, and the entry card cannot check a row against them nor read a
6
+ * printed total. Carried as a CSV file they are a source like an attached sheet: each row is found on its line.
7
+ *
8
+ * - The HTML of the clipboard is read first when it holds a table - a spreadsheet and a browser both put one there,
9
+ * and it keeps a cell that holds a line break or a tab whole. A web page whose words lie mostly outside the table
10
+ * is not a table paste; it is left as words.
11
+ * - Otherwise the plain text is a table when its lines are tab-separated (a spreadsheet's own text).
12
+ * - The file has the shape of a read sheet: a line naming it, then every row led by its own number.
13
+ * - The limit is on the text, as for a sheet - a table over it is refused, never cut.
14
+ *
15
+ * Pure: the screen hands in the clipboard's strings.
16
+ */
17
+ import { textCharsOf } from './attachment-policy.js';
18
+ import { csvField } from './spreadsheet-text.js';
19
+ /** Words outside the table beyond this are a page that holds a table, not a table. */
20
+ const WORDS_OUTSIDE_A_TABLE = 20;
21
+ const ENTITIES = { nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'" };
22
+ function decodeEntities(text) {
23
+ return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+\d*|#39);/gi, (whole, name) => {
24
+ const lower = name.toLowerCase();
25
+ if (lower in ENTITIES)
26
+ return ENTITIES[lower];
27
+ if (lower.startsWith('#x'))
28
+ return String.fromCodePoint(parseInt(lower.slice(2), 16));
29
+ if (lower.startsWith('#'))
30
+ return String.fromCodePoint(parseInt(lower.slice(1), 10));
31
+ return whole;
32
+ });
33
+ }
34
+ /** What a piece of HTML shows as words - a line break and a cell's own breaks become one space. */
35
+ function wordsOf(html) {
36
+ return decodeEntities(html.replace(/<br\s*\/?>/gi, ' ').replace(/<[^>]*>/g, ''))
37
+ .replace(/\s+/g, ' ')
38
+ .trim();
39
+ }
40
+ /** What the clipboard's HTML shows, without what is never shown - comments, styles, scripts, the head. */
41
+ function shownHtml(html) {
42
+ return html
43
+ .replace(/<!--[\s\S]*?-->/g, '')
44
+ .replace(/<(style|script|head)\b[\s\S]*?<\/\1\s*>/gi, '');
45
+ }
46
+ /** The rows of the tables in the clipboard's HTML, or null when it holds none or is mostly not a table. */
47
+ export function tableOfHtml(html) {
48
+ if (!html || !/<table\b/i.test(html))
49
+ return null;
50
+ const shown = shownHtml(html);
51
+ const outside = wordsOf(shown.replace(/<table\b[\s\S]*?<\/table\s*>/gi, ' '));
52
+ if (outside.replace(/\s/g, '').length > WORDS_OUTSIDE_A_TABLE)
53
+ return null;
54
+ const rows = [];
55
+ for (const part of shown.split(/<tr\b[^>]*>/i).slice(1)) {
56
+ const body = part.split(/<\/table\s*>/i)[0];
57
+ const row = [];
58
+ for (const cell of body.matchAll(/<(td|th)\b([^>]*)>([\s\S]*?)(?=<\/?(?:td|th|tr)\b|$)/gi)) {
59
+ row.push(wordsOf(cell[3]));
60
+ const span = Number(/\bcolspan\s*=\s*["']?(\d+)/i.exec(cell[2])?.[1] ?? 1);
61
+ for (let more = 1; more < Math.min(span, 100); more++)
62
+ row.push('');
63
+ }
64
+ if (row.some(text => text !== ''))
65
+ rows.push(row);
66
+ }
67
+ return rows.length ? rows : null;
68
+ }
69
+ /** The rows of a spreadsheet's plain text - tab-separated lines, a cell with a break or a tab in quotes. */
70
+ export function tableOfText(text) {
71
+ if (!text || !text.includes('\t'))
72
+ return null;
73
+ const rows = [];
74
+ let row = [];
75
+ let cell = '';
76
+ let quoted = false;
77
+ for (let at = 0; at < text.length; at++) {
78
+ const c = text[at];
79
+ if (quoted) {
80
+ if (c === '"' && text[at + 1] === '"')
81
+ (cell += '"'), at++;
82
+ else if (c === '"')
83
+ quoted = false;
84
+ else
85
+ cell += c;
86
+ }
87
+ else if (c === '"' && cell === '')
88
+ quoted = true;
89
+ else if (c === '\t')
90
+ row.push(cell), (cell = '');
91
+ else if (c === '\n' || c === '\r') {
92
+ if (c === '\r' && text[at + 1] === '\n')
93
+ at++;
94
+ row.push(cell), rows.push(row), (row = []), (cell = '');
95
+ }
96
+ else
97
+ cell += c;
98
+ }
99
+ if (cell !== '' || row.length)
100
+ row.push(cell), rows.push(row);
101
+ const kept = rows.map(one => one.map(text => text.replace(/\s+/g, ' ').trim())).filter(one => one.some(text => text !== ''));
102
+ /* One tab in a sentence is not a table - most lines must be split into columns. */
103
+ if (kept.filter(one => one.length > 1).length < Math.max(2, Math.ceil(kept.length / 2)))
104
+ return null;
105
+ return kept;
106
+ }
107
+ /** The table a paste holds, or null for words - at least two rows of at least two columns. */
108
+ export function pastedTable(clipboard) {
109
+ const fromHtml = tableOfHtml(clipboard.html);
110
+ /* A page the HTML shows to be mostly words stays words - its plain text would read the table out of it anyway. */
111
+ if (!fromHtml && clipboard.html && /<table\b/i.test(clipboard.html))
112
+ return null;
113
+ for (const rows of [fromHtml, tableOfText(clipboard.text)]) {
114
+ if (!rows)
115
+ continue;
116
+ const trimmed = rows.map(row => {
117
+ let cells = row;
118
+ while (cells.length && cells[cells.length - 1] === '')
119
+ cells = cells.slice(0, -1);
120
+ return cells;
121
+ });
122
+ const columns = Math.max(...trimmed.map(row => row.length));
123
+ if (trimmed.length >= 2 && columns >= 2)
124
+ return { rows: trimmed, columns };
125
+ }
126
+ return null;
127
+ }
128
+ /** The table as the text of its file: a line naming it, then every row led by its own number. */
129
+ export function pastedTableText(table, name) {
130
+ const lines = table.rows.map((row, i) => [String(i + 1), ...row].map(csvField).join(','));
131
+ return [`# ${name} · a table the person pasted · the first column is the row number in the pasted table`, ...lines].join('\n') + '\n';
132
+ }
133
+ /**
134
+ * What a paste becomes in a conversation under this policy: a table file, a refusal, or null - the paste stays words
135
+ * (no table in it, or a conversation that takes no text files).
136
+ */
137
+ export function pastedTableFile(clipboard, policy, nameOf) {
138
+ if (!policy?.kinds?.some(kind => kind === 'text' || kind === 'spreadsheet'))
139
+ return null;
140
+ const table = pastedTable(clipboard);
141
+ if (!table)
142
+ return null;
143
+ const name = nameOf(table.rows.length);
144
+ const text = pastedTableText(table, name);
145
+ const maxChars = textCharsOf(policy);
146
+ if (text.length > maxChars)
147
+ return { refusal: { code: 'attachment-paste-too-long', rows: table.rows.length, maxChars } };
148
+ return { name: `${name.replace(/[\\/:*?"<>|]/g, '_')}.csv`, text };
149
+ }
150
+ //# sourceMappingURL=pasted-table.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pasted-table.js","sourceRoot":"","sources":["../../client/utils/pasted-table.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * A table pasted into the composer, carried as a file (design/ai/data-entry.md §4-1, design/ai/shared-dock.md §9-5).\n *\n * Rows copied from a spreadsheet or a web page are what an AI 입력 is made from most often. Pasted as words they\n * reach the model as something the person said, and the entry card cannot check a row against them nor read a\n * printed total. Carried as a CSV file they are a source like an attached sheet: each row is found on its line.\n *\n * - The HTML of the clipboard is read first when it holds a table - a spreadsheet and a browser both put one there,\n * and it keeps a cell that holds a line break or a tab whole. A web page whose words lie mostly outside the table\n * is not a table paste; it is left as words.\n * - Otherwise the plain text is a table when its lines are tab-separated (a spreadsheet's own text).\n * - The file has the shape of a read sheet: a line naming it, then every row led by its own number.\n * - The limit is on the text, as for a sheet - a table over it is refused, never cut.\n *\n * Pure: the screen hands in the clipboard's strings.\n */\nimport { textCharsOf, type AttachmentPolicy } from './attachment-policy.js'\nimport { csvField } from './spreadsheet-text.js'\n\nexport interface PastedTable {\n rows: string[][]\n columns: number\n}\n\n/** Words outside the table beyond this are a page that holds a table, not a table. */\nconst WORDS_OUTSIDE_A_TABLE = 20\n\nconst ENTITIES: Record<string, string> = { nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '\"', apos: \"'\", '#39': \"'\" }\n\nfunction decodeEntities(text: string): string {\n return text.replace(/&(#x[0-9a-f]+|#\\d+|[a-z]+\\d*|#39);/gi, (whole, name: string) => {\n const lower = name.toLowerCase()\n if (lower in ENTITIES) return ENTITIES[lower]\n if (lower.startsWith('#x')) return String.fromCodePoint(parseInt(lower.slice(2), 16))\n if (lower.startsWith('#')) return String.fromCodePoint(parseInt(lower.slice(1), 10))\n return whole\n })\n}\n\n/** What a piece of HTML shows as words - a line break and a cell's own breaks become one space. */\nfunction wordsOf(html: string): string {\n return decodeEntities(html.replace(/<br\\s*\\/?>/gi, ' ').replace(/<[^>]*>/g, ''))\n .replace(/\\s+/g, ' ')\n .trim()\n}\n\n/** What the clipboard's HTML shows, without what is never shown - comments, styles, scripts, the head. */\nfunction shownHtml(html: string): string {\n return html\n .replace(/<!--[\\s\\S]*?-->/g, '')\n .replace(/<(style|script|head)\\b[\\s\\S]*?<\\/\\1\\s*>/gi, '')\n}\n\n/** The rows of the tables in the clipboard's HTML, or null when it holds none or is mostly not a table. */\nexport function tableOfHtml(html: string | null | undefined): string[][] | null {\n if (!html || !/<table\\b/i.test(html)) return null\n const shown = shownHtml(html)\n const outside = wordsOf(shown.replace(/<table\\b[\\s\\S]*?<\\/table\\s*>/gi, ' '))\n if (outside.replace(/\\s/g, '').length > WORDS_OUTSIDE_A_TABLE) return null\n\n const rows: string[][] = []\n for (const part of shown.split(/<tr\\b[^>]*>/i).slice(1)) {\n const body = part.split(/<\\/table\\s*>/i)[0]\n const row: string[] = []\n for (const cell of body.matchAll(/<(td|th)\\b([^>]*)>([\\s\\S]*?)(?=<\\/?(?:td|th|tr)\\b|$)/gi)) {\n row.push(wordsOf(cell[3]))\n const span = Number(/\\bcolspan\\s*=\\s*[\"']?(\\d+)/i.exec(cell[2])?.[1] ?? 1)\n for (let more = 1; more < Math.min(span, 100); more++) row.push('')\n }\n if (row.some(text => text !== '')) rows.push(row)\n }\n return rows.length ? rows : null\n}\n\n/** The rows of a spreadsheet's plain text - tab-separated lines, a cell with a break or a tab in quotes. */\nexport function tableOfText(text: string | null | undefined): string[][] | null {\n if (!text || !text.includes('\\t')) return null\n const rows: string[][] = []\n let row: string[] = []\n let cell = ''\n let quoted = false\n for (let at = 0; at < text.length; at++) {\n const c = text[at]\n if (quoted) {\n if (c === '\"' && text[at + 1] === '\"') (cell += '\"'), at++\n else if (c === '\"') quoted = false\n else cell += c\n } else if (c === '\"' && cell === '') quoted = true\n else if (c === '\\t') row.push(cell), (cell = '')\n else if (c === '\\n' || c === '\\r') {\n if (c === '\\r' && text[at + 1] === '\\n') at++\n row.push(cell), rows.push(row), (row = []), (cell = '')\n } else cell += c\n }\n if (cell !== '' || row.length) row.push(cell), rows.push(row)\n const kept = rows.map(one => one.map(text => text.replace(/\\s+/g, ' ').trim())).filter(one => one.some(text => text !== ''))\n /* One tab in a sentence is not a table - most lines must be split into columns. */\n if (kept.filter(one => one.length > 1).length < Math.max(2, Math.ceil(kept.length / 2))) return null\n return kept\n}\n\n/** The table a paste holds, or null for words - at least two rows of at least two columns. */\nexport function pastedTable(clipboard: { html?: string | null; text?: string | null }): PastedTable | null {\n const fromHtml = tableOfHtml(clipboard.html)\n /* A page the HTML shows to be mostly words stays words - its plain text would read the table out of it anyway. */\n if (!fromHtml && clipboard.html && /<table\\b/i.test(clipboard.html)) return null\n for (const rows of [fromHtml, tableOfText(clipboard.text)]) {\n if (!rows) continue\n const trimmed = rows.map(row => {\n let cells = row\n while (cells.length && cells[cells.length - 1] === '') cells = cells.slice(0, -1)\n return cells\n })\n const columns = Math.max(...trimmed.map(row => row.length))\n if (trimmed.length >= 2 && columns >= 2) return { rows: trimmed, columns }\n }\n return null\n}\n\n/** The table as the text of its file: a line naming it, then every row led by its own number. */\nexport function pastedTableText(table: PastedTable, name: string): string {\n const lines = table.rows.map((row, i) => [String(i + 1), ...row].map(csvField).join(','))\n return [`# ${name} · a table the person pasted · the first column is the row number in the pasted table`, ...lines].join('\\n') + '\\n'\n}\n\nexport type PasteRefusal = { code: 'attachment-paste-too-long'; rows: number; maxChars: number }\n\n/**\n * What a paste becomes in a conversation under this policy: a table file, a refusal, or null - the paste stays words\n * (no table in it, or a conversation that takes no text files).\n */\nexport function pastedTableFile(\n clipboard: { html?: string | null; text?: string | null },\n policy: AttachmentPolicy | null | undefined,\n nameOf: (rows: number) => string\n): { name: string; text: string } | { refusal: PasteRefusal } | null {\n if (!policy?.kinds?.some(kind => kind === 'text' || kind === 'spreadsheet')) return null\n const table = pastedTable(clipboard)\n if (!table) return null\n const name = nameOf(table.rows.length)\n const text = pastedTableText(table, name)\n const maxChars = textCharsOf(policy)\n if (text.length > maxChars) return { refusal: { code: 'attachment-paste-too-long', rows: table.rows.length, maxChars } }\n return { name: `${name.replace(/[\\\\/:*?\"<>|]/g, '_')}.csv`, text }\n}\n"]}
@@ -0,0 +1,48 @@
1
+ export type SpreadsheetKind = 'xlsx' | 'xls';
2
+ /** A spreadsheet by its type, or by its name when the browser did not know the type. */
3
+ export declare function spreadsheetKindOf(file: {
4
+ type?: string;
5
+ name?: string;
6
+ }): SpreadsheetKind | null;
7
+ /** The picker's `accept` for spreadsheets. */
8
+ export declare const SPREADSHEET_ACCEPT: string[];
9
+ /** One cell as text - what Excel shows as the value, not how it is stored. */
10
+ export declare function cellText(value: unknown): string;
11
+ /** One CSV field - quoted when it holds a comma, a quote or a line break. */
12
+ export declare const csvField: (text: string) => string;
13
+ export interface SheetRow {
14
+ /** The row number in the sheet - 1 for Excel's first row. */
15
+ number: number;
16
+ cells: unknown[];
17
+ }
18
+ /** One sheet as CSV: a line naming the file and sheet, then every non-empty row led by its own row number. */
19
+ export declare function sheetText(fileName: string, sheetName: string, rows: SheetRow[]): string;
20
+ export interface SheetFile {
21
+ name: string;
22
+ text: string;
23
+ }
24
+ export type SpreadsheetRefusal = {
25
+ code: 'attachment-xls-not-read';
26
+ name: string;
27
+ } | {
28
+ code: 'attachment-sheet-too-long';
29
+ name: string;
30
+ maxChars: number;
31
+ } | {
32
+ code: 'attachment-sheet-unreadable';
33
+ name: string;
34
+ } | {
35
+ code: 'attachment-sheet-empty';
36
+ name: string;
37
+ };
38
+ /** The file name a sheet travels under - "거래내역.xlsx" sheet "9월" → "거래내역 - 9월.csv". */
39
+ export declare function sheetFileName(fileName: string, sheetName: string): string;
40
+ /**
41
+ * Every non-empty sheet of an .xlsx as text, or why not. `load` is exceljs's Workbook - passed in so the screen
42
+ * loads the library only when a spreadsheet is attached.
43
+ */
44
+ export declare function readSpreadsheet(data: ArrayBuffer | Uint8Array, fileName: string, maxChars: number, Workbook: new () => any): Promise<{
45
+ files: SheetFile[];
46
+ } | {
47
+ refusal: SpreadsheetRefusal;
48
+ }>;