@things-factory/ai-assistant 10.1.46 → 10.1.50

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 (174) hide show
  1. package/client/components/assistant-chat.ts +100 -20
  2. package/client/components/assistant-dock.ts +617 -0
  3. package/client/components/assistant-mode.ts +29 -0
  4. package/client/components/assistant-session-manager.ts +607 -0
  5. package/client/components/chat-echo-dedup.ts +4 -0
  6. package/client/components/chat-input-builder.ts +12 -2
  7. package/client/components/panel-aria.test.ts +13 -8
  8. package/client/index.ts +10 -0
  9. package/client/setup-assistant-dock.ts +109 -0
  10. package/client/utils/assistant-activity.ts +122 -0
  11. package/client/utils/assistant-ask.ts +44 -0
  12. package/client/utils/assistant-capability-read.ts +24 -0
  13. package/client/utils/assistant-capability.test.ts +17 -0
  14. package/client/utils/assistant-capability.ts +19 -0
  15. package/client/utils/assistant-proposals.ts +50 -0
  16. package/client/utils/assistant-session-actions.ts +18 -0
  17. package/client/utils/assistant-session-admin.ts +167 -0
  18. package/client/utils/assistant-session-controller.ts +2 -0
  19. package/client/utils/assistant-session-transport.ts +6 -2
  20. package/client/utils/assistant-surface.ts +157 -0
  21. package/client/utils/attachment-policy.ts +71 -0
  22. package/dist-client/components/assistant-chat.d.ts +6 -0
  23. package/dist-client/components/assistant-chat.js +101 -21
  24. package/dist-client/components/assistant-chat.js.map +1 -1
  25. package/dist-client/components/assistant-dock.d.ts +74 -0
  26. package/dist-client/components/assistant-dock.js +641 -0
  27. package/dist-client/components/assistant-dock.js.map +1 -0
  28. package/dist-client/components/assistant-mode.d.ts +6 -0
  29. package/dist-client/components/assistant-mode.js +24 -0
  30. package/dist-client/components/assistant-mode.js.map +1 -1
  31. package/dist-client/components/assistant-session-manager.d.ts +77 -0
  32. package/dist-client/components/assistant-session-manager.js +652 -0
  33. package/dist-client/components/assistant-session-manager.js.map +1 -0
  34. package/dist-client/components/chat-echo-dedup.js +4 -0
  35. package/dist-client/components/chat-echo-dedup.js.map +1 -1
  36. package/dist-client/components/chat-input-builder.d.ts +11 -0
  37. package/dist-client/components/chat-input-builder.js +4 -2
  38. package/dist-client/components/chat-input-builder.js.map +1 -1
  39. package/dist-client/components/panel-aria.test.js +12 -8
  40. package/dist-client/components/panel-aria.test.js.map +1 -1
  41. package/dist-client/index.d.ts +10 -0
  42. package/dist-client/index.js +10 -0
  43. package/dist-client/index.js.map +1 -1
  44. package/dist-client/setup-assistant-dock.d.ts +15 -0
  45. package/dist-client/setup-assistant-dock.js +110 -0
  46. package/dist-client/setup-assistant-dock.js.map +1 -0
  47. package/dist-client/tsconfig.tsbuildinfo +1 -1
  48. package/dist-client/utils/assistant-activity.d.ts +22 -0
  49. package/dist-client/utils/assistant-activity.js +104 -0
  50. package/dist-client/utils/assistant-activity.js.map +1 -0
  51. package/dist-client/utils/assistant-ask.d.ts +23 -0
  52. package/dist-client/utils/assistant-ask.js +18 -0
  53. package/dist-client/utils/assistant-ask.js.map +1 -0
  54. package/dist-client/utils/assistant-capability-read.d.ts +6 -0
  55. package/dist-client/utils/assistant-capability-read.js +23 -0
  56. package/dist-client/utils/assistant-capability-read.js.map +1 -0
  57. package/dist-client/utils/assistant-capability.d.ts +4 -0
  58. package/dist-client/utils/assistant-capability.js +17 -0
  59. package/dist-client/utils/assistant-capability.js.map +1 -0
  60. package/dist-client/utils/assistant-capability.test.d.ts +1 -0
  61. package/dist-client/utils/assistant-capability.test.js +16 -0
  62. package/dist-client/utils/assistant-capability.test.js.map +1 -0
  63. package/dist-client/utils/assistant-proposals.d.ts +29 -0
  64. package/dist-client/utils/assistant-proposals.js +22 -0
  65. package/dist-client/utils/assistant-proposals.js.map +1 -0
  66. package/dist-client/utils/assistant-session-actions.d.ts +12 -0
  67. package/dist-client/utils/assistant-session-actions.js +19 -0
  68. package/dist-client/utils/assistant-session-actions.js.map +1 -0
  69. package/dist-client/utils/assistant-session-admin.d.ts +72 -0
  70. package/dist-client/utils/assistant-session-admin.js +81 -0
  71. package/dist-client/utils/assistant-session-admin.js.map +1 -0
  72. package/dist-client/utils/assistant-session-controller.d.ts +2 -0
  73. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  74. package/dist-client/utils/assistant-session-transport.js +6 -2
  75. package/dist-client/utils/assistant-session-transport.js.map +1 -1
  76. package/dist-client/utils/assistant-surface.d.ts +72 -0
  77. package/dist-client/utils/assistant-surface.js +99 -0
  78. package/dist-client/utils/assistant-surface.js.map +1 -0
  79. package/dist-client/utils/attachment-policy.d.ts +35 -0
  80. package/dist-client/utils/attachment-policy.js +56 -0
  81. package/dist-client/utils/attachment-policy.js.map +1 -0
  82. package/dist-server/service/assistant-chat-resolver.d.ts +1 -0
  83. package/dist-server/service/assistant-chat-resolver.js +72 -4
  84. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  85. package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +1 -0
  86. package/dist-server/service/assistant-mode/assistant-mode-resolver.js +7 -0
  87. package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
  88. package/dist-server/service/assistant-mode/resolve.d.ts +3 -0
  89. package/dist-server/service/assistant-mode/resolve.js +6 -2
  90. package/dist-server/service/assistant-mode/resolve.js.map +1 -1
  91. package/dist-server/service/assistant-mode/types.d.ts +6 -0
  92. package/dist-server/service/assistant-mode/types.js +0 -9
  93. package/dist-server/service/assistant-mode/types.js.map +1 -1
  94. package/dist-server/service/chat-message/chat-message.d.ts +6 -0
  95. package/dist-server/service/chat-message/chat-message.js +19 -0
  96. package/dist-server/service/chat-message/chat-message.js.map +1 -1
  97. package/dist-server/service/chat-session/attachment-policy.d.ts +44 -0
  98. package/dist-server/service/chat-session/attachment-policy.js +56 -0
  99. package/dist-server/service/chat-session/attachment-policy.js.map +1 -0
  100. package/dist-server/service/chat-session/attachment-route.d.ts +3 -0
  101. package/dist-server/service/chat-session/attachment-route.js +44 -0
  102. package/dist-server/service/chat-session/attachment-route.js.map +1 -0
  103. package/dist-server/service/chat-session/attachment-tools.d.ts +3 -0
  104. package/dist-server/service/chat-session/attachment-tools.js +59 -0
  105. package/dist-server/service/chat-session/attachment-tools.js.map +1 -0
  106. package/dist-server/service/chat-session/session-attachments.d.ts +36 -0
  107. package/dist-server/service/chat-session/session-attachments.js +84 -0
  108. package/dist-server/service/chat-session/session-attachments.js.map +1 -0
  109. package/dist-server/service/chat-session/session-inbox.d.ts +29 -0
  110. package/dist-server/service/chat-session/session-inbox.js +32 -8
  111. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  112. package/dist-server/service/chat-session/session-purge.d.ts +14 -0
  113. package/dist-server/service/chat-session/session-purge.js +77 -0
  114. package/dist-server/service/chat-session/session-purge.js.map +1 -0
  115. package/dist-server/service/chat-session/session-retention.d.ts +6 -0
  116. package/dist-server/service/chat-session/session-retention.js +20 -0
  117. package/dist-server/service/chat-session/session-retention.js.map +1 -0
  118. package/dist-server/service/chat-session/session-visibility.d.ts +8 -0
  119. package/dist-server/service/chat-session/session-visibility.js +11 -0
  120. package/dist-server/service/chat-session/session-visibility.js.map +1 -1
  121. package/dist-server/service/chat-session/session-write-gate.d.ts +11 -0
  122. package/dist-server/service/chat-session/session-write-gate.js +24 -0
  123. package/dist-server/service/chat-session/session-write-gate.js.map +1 -0
  124. package/dist-server/service/chat-session-participant/invite-notification.d.ts +1 -1
  125. package/dist-server/service/chat-session-participant/invite-notification.js +21 -6
  126. package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -1
  127. package/dist-server/service/chat-session-participant/participant-lookup-roles.d.ts +3 -0
  128. package/dist-server/service/chat-session-participant/participant-lookup-roles.js +10 -0
  129. package/dist-server/service/chat-session-participant/participant-lookup-roles.js.map +1 -0
  130. package/dist-server/service/chat-session-participant/participant-lookup.d.ts +2 -0
  131. package/dist-server/service/chat-session-participant/participant-lookup.js +26 -0
  132. package/dist-server/service/chat-session-participant/participant-lookup.js.map +1 -0
  133. package/dist-server/service/chat-session-resolver.d.ts +17 -2
  134. package/dist-server/service/chat-session-resolver.js +135 -15
  135. package/dist-server/service/chat-session-resolver.js.map +1 -1
  136. package/dist-server/service/index.d.ts +6 -0
  137. package/dist-server/service/index.js +8 -1
  138. package/dist-server/service/index.js.map +1 -1
  139. package/dist-server/tsconfig.tsbuildinfo +1 -1
  140. package/package.json +11 -4
  141. package/server/service/assistant-chat-resolver.ts +72 -5
  142. package/server/service/assistant-mode/assistant-mode-resolver.ts +6 -0
  143. package/server/service/assistant-mode/resolve.ts +9 -2
  144. package/server/service/assistant-mode/types.ts +6 -0
  145. package/server/service/chat-message/chat-message.ts +17 -0
  146. package/server/service/chat-message/fold-session-history.test.ts +18 -0
  147. package/server/service/chat-session/attachment-policy.test.ts +42 -0
  148. package/server/service/chat-session/attachment-policy.ts +68 -0
  149. package/server/service/chat-session/attachment-route.ts +45 -0
  150. package/server/service/chat-session/attachment-tools.ts +63 -0
  151. package/server/service/chat-session/session-attachments.ts +89 -0
  152. package/server/service/chat-session/session-inbox.test.ts +71 -1
  153. package/server/service/chat-session/session-inbox.ts +61 -11
  154. package/server/service/chat-session/session-purge.test.ts +24 -0
  155. package/server/service/chat-session/session-purge.ts +81 -0
  156. package/server/service/chat-session/session-retention.ts +17 -0
  157. package/server/service/chat-session/session-visibility.ts +11 -0
  158. package/server/service/chat-session/session-write-gate.test.ts +53 -0
  159. package/server/service/chat-session/session-write-gate.ts +31 -0
  160. package/server/service/chat-session-participant/invite-notification.test.ts +15 -0
  161. package/server/service/chat-session-participant/invite-notification.ts +24 -6
  162. package/server/service/chat-session-participant/participant-lookup-roles.ts +6 -0
  163. package/server/service/chat-session-participant/participant-lookup.ts +22 -0
  164. package/server/service/chat-session-resolver.ts +130 -13
  165. package/server/service/index.ts +6 -0
  166. package/test/assistant-dock.test.ts +118 -0
  167. package/test/assistant-session-manager.test.ts +126 -0
  168. package/test/attachment-policy-agree.test.ts +32 -0
  169. package/test/open-doors.test.ts +5 -2
  170. package/translations/en.json +61 -1
  171. package/translations/ja.json +61 -1
  172. package/translations/ko.json +61 -1
  173. package/translations/ms.json +61 -1
  174. package/translations/zh.json +61 -1
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ /*
3
+ * What a conversation accepts as attachments - declared by its mode (design/ai/shared-dock.md §9-1).
4
+ *
5
+ * Pure, so the rule is tested alone and the screen can say "no" before anything is sent. A mode that
6
+ * declares nothing accepts nothing: the chat once took .csv and .json, carried them, and no code ever
7
+ * showed them to the model - the person got an answer about a file it had never seen (AI standard §7).
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.TEXT_CHARS_FOR_MODEL = exports.DEFAULT_MAX_FILES = exports.DEFAULT_MAX_BYTES = void 0;
11
+ exports.attachmentKindOf = attachmentKindOf;
12
+ exports.refuseAttachments = refuseAttachments;
13
+ exports.attachmentHistoryLine = attachmentHistoryLine;
14
+ exports.DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
15
+ exports.DEFAULT_MAX_FILES = 5;
16
+ /** How much of a text file reaches the model in the turn it was sent. */
17
+ exports.TEXT_CHARS_FOR_MODEL = 20_000;
18
+ const TEXT_TYPES = ['text/plain', 'text/csv', 'text/markdown', 'application/json'];
19
+ const TEXT_EXTENSIONS = ['.txt', '.csv', '.md', '.json'];
20
+ /** Which kind a file is, from its type - and its name when the browser did not know the type. */
21
+ function attachmentKindOf(file) {
22
+ const type = (file.type || '').toLowerCase();
23
+ if (type.startsWith('image/'))
24
+ return 'image';
25
+ if (TEXT_TYPES.includes(type))
26
+ return 'text';
27
+ const name = (file.name || '').toLowerCase();
28
+ if (TEXT_EXTENSIONS.some(ext => name.endsWith(ext)))
29
+ return 'text';
30
+ return null;
31
+ }
32
+ /** `null` when every file may go; otherwise the first reason, by name - said before sending, never cut silently. */
33
+ function refuseAttachments(policy, files) {
34
+ if (!files.length)
35
+ return null;
36
+ if (!policy || !policy.kinds?.length)
37
+ return { code: 'attachments-not-accepted' };
38
+ const maxFiles = policy.maxFiles ?? exports.DEFAULT_MAX_FILES;
39
+ if (files.length > maxFiles)
40
+ return { code: 'too-many-attachments', maxFiles };
41
+ const maxBytes = policy.maxBytes ?? exports.DEFAULT_MAX_BYTES;
42
+ for (const file of files) {
43
+ const kind = attachmentKindOf(file);
44
+ if (!kind || !policy.kinds.includes(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;
51
+ }
52
+ /** The line history keeps for a file once its turn is past - the model reads the file again only by asking. */
53
+ function attachmentHistoryLine(file) {
54
+ return `[attachment ${file.id}: ${file.name || 'unnamed'} (${file.mimetype || 'unknown type'}, ${file.size ?? '?'} bytes) - readChatAttachment to open it]`;
55
+ }
56
+ //# sourceMappingURL=attachment-policy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachment-policy.js","sourceRoot":"","sources":["../../../server/service/chat-session/attachment-policy.ts"],"names":[],"mappings":";AAAA;;;;;;GAMG;;;AAqBH,4CAOC;AASD,8CAmBC;AAGD,sDAEC;AAjDY,QAAA,iBAAiB,GAAG,CAAC,GAAG,IAAI,GAAG,IAAI,CAAA;AACnC,QAAA,iBAAiB,GAAG,CAAC,CAAA;AAClC,yEAAyE;AAC5D,QAAA,oBAAoB,GAAG,MAAM,CAAA;AAE1C,MAAM,UAAU,GAAG,CAAC,YAAY,EAAE,UAAU,EAAE,eAAe,EAAE,kBAAkB,CAAC,CAAA;AAClF,MAAM,eAAe,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,CAAC,CAAA;AAExD,iGAAiG;AACjG,SAAgB,gBAAgB,CAAC,IAAsC;IACrE,MAAM,IAAI,GAAG,CAAC,IAAI,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,WAAW,EAAE,CAAA;IAC5C,IAAI,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC;QAAE,OAAO,OAAO,CAAA;IAC7C,IAAI,UAAU,CAAC,QAAQ,CAAC,IAAI,CAAC;QAAE,OAAO,MAAM,CAAA;IAC5C,MAAM,IAAI,GAAG,CAAC,IAAI,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,WAAW,EAAE,CAAA;IAC5C,IAAI,eAAe,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;QAAE,OAAO,MAAM,CAAA;IAClE,OAAO,IAAI,CAAA;AACb,CAAC;AAQD,oHAAoH;AACpH,SAAgB,iBAAiB,CAC/B,MAA2C,EAC3C,KAAwD;IAExD,IAAI,CAAC,KAAK,CAAC,MAAM;QAAE,OAAO,IAAI,CAAA;IAC9B,IAAI,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM;QAAE,OAAO,EAAE,IAAI,EAAE,0BAA0B,EAAE,CAAA;IAEjF,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,IAAI,yBAAiB,CAAA;IACrD,IAAI,KAAK,CAAC,MAAM,GAAG,QAAQ;QAAE,OAAO,EAAE,IAAI,EAAE,sBAAsB,EAAE,QAAQ,EAAE,CAAA;IAE9E,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,IAAI,yBAAiB,CAAA;IACrD,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,IAAI,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAA;QACnC,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;YAC1C,OAAO,EAAE,IAAI,EAAE,8BAA8B,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,IAAI,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,CAAA;QAC7F,CAAC;QACD,IAAI,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,CAAC,GAAG,QAAQ;YAAE,OAAO,EAAE,IAAI,EAAE,sBAAsB,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,IAAI,EAAE,EAAE,QAAQ,EAAE,CAAA;IAC3G,CAAC;IACD,OAAO,IAAI,CAAA;AACb,CAAC;AAED,+GAA+G;AAC/G,SAAgB,qBAAqB,CAAC,IAA8E;IAClH,OAAO,eAAe,IAAI,CAAC,EAAE,KAAK,IAAI,CAAC,IAAI,IAAI,SAAS,KAAK,IAAI,CAAC,QAAQ,IAAI,cAAc,KAAK,IAAI,CAAC,IAAI,IAAI,GAAG,0CAA0C,CAAA;AAC7J,CAAC","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\nexport type AttachmentKind = 'image' | 'text'\n\nexport interface AttachmentPolicy {\n kinds: AttachmentKind[]\n /** Per file. */\n maxBytes?: number\n /** Per message. */\n maxFiles?: 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. */\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 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"]}
@@ -0,0 +1,3 @@
1
+ export declare const CHAT_ATTACHMENT_ROUTE = "chat-attachments";
2
+ /** The address a screen uses for one file of a conversation. */
3
+ export declare function chatAttachmentUrl(sessionId: string, attachmentId: string): string;
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CHAT_ATTACHMENT_ROUTE = void 0;
4
+ exports.chatAttachmentUrl = chatAttachmentUrl;
5
+ /*
6
+ * Where a person opens a conversation's file - GET /chat-attachments/:sessionId/:attachmentId.
7
+ *
8
+ * Domain-private (sign-in and tenancy required), and the conversation's own read gate on top: a private
9
+ * conversation's file opens for its participants only, a deleted conversation's for nobody (design/ai/
10
+ * shared-dock.md §9-3). attachment-base's own route is public and unauthenticated, so chat files are
11
+ * never linked through it. `private` caching - a shared cache must not keep a private conversation's file.
12
+ */
13
+ const attachment_base_1 = require("@things-factory/attachment-base");
14
+ const shell_1 = require("@things-factory/shell");
15
+ const chat_session_js_1 = require("./chat-session.js");
16
+ const session_visibility_js_1 = require("./session-visibility.js");
17
+ const session_attachments_js_1 = require("./session-attachments.js");
18
+ const participant_lookup_js_1 = require("../chat-session-participant/participant-lookup.js");
19
+ exports.CHAT_ATTACHMENT_ROUTE = 'chat-attachments';
20
+ /** The address a screen uses for one file of a conversation. */
21
+ function chatAttachmentUrl(sessionId, attachmentId) {
22
+ return `/${exports.CHAT_ATTACHMENT_ROUTE}/${encodeURIComponent(sessionId)}/${encodeURIComponent(attachmentId)}`;
23
+ }
24
+ process.on('bootstrap-module-domain-private-route', (_app, routes) => {
25
+ routes.get(`/${exports.CHAT_ATTACHMENT_ROUTE}/:sessionId/:attachmentId`, async (context, next) => {
26
+ const { domain, user } = context.state;
27
+ const { sessionId, attachmentId } = context.params;
28
+ /* A soft-deleted conversation is not found here - its files close with it (§9-5). */
29
+ const session = await (0, shell_1.getRepository)(chat_session_js_1.ChatSession).findOne({ where: { id: sessionId, domain: { id: domain.id } } });
30
+ const readable = !!session && (0, session_visibility_js_1.mayReadSession)(session.visibility, await (0, participant_lookup_js_1.isSessionParticipant)(domain.id, sessionId, user?.id));
31
+ const row = readable ? await (0, session_attachments_js_1.findChatAttachment)(domain.id, sessionId, attachmentId) : undefined;
32
+ if (!row) {
33
+ /* One answer for "none", "not yours" and "deleted" - telling them apart would say what exists. */
34
+ context.status = 404;
35
+ return;
36
+ }
37
+ context.set('Cache-Control', 'private, max-age=3600');
38
+ context.set('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(row.name)}`);
39
+ if (row.mimetype)
40
+ context.type = row.mimetype;
41
+ await attachment_base_1.STORAGE.sendFile(context, row.path, next);
42
+ });
43
+ });
44
+ //# sourceMappingURL=attachment-route.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachment-route.js","sourceRoot":"","sources":["../../../server/service/chat-session/attachment-route.ts"],"names":[],"mappings":";;;AAmBA,8CAEC;AArBD;;;;;;;GAOG;AACH,qEAAyD;AACzD,iDAAqD;AAErD,uDAA+C;AAC/C,mEAAwD;AACxD,qEAA6D;AAC7D,6FAAwF;AAE3E,QAAA,qBAAqB,GAAG,kBAAkB,CAAA;AAEvD,gEAAgE;AAChE,SAAgB,iBAAiB,CAAC,SAAiB,EAAE,YAAoB;IACvE,OAAO,IAAI,6BAAqB,IAAI,kBAAkB,CAAC,SAAS,CAAC,IAAI,kBAAkB,CAAC,YAAY,CAAC,EAAE,CAAA;AACzG,CAAC;AAED,OAAO,CAAC,EAAE,CAAC,uCAA8C,EAAE,CAAC,IAAS,EAAE,MAAW,EAAE,EAAE;IACpF,MAAM,CAAC,GAAG,CAAC,IAAI,6BAAqB,2BAA2B,EAAE,KAAK,EAAE,OAAY,EAAE,IAAS,EAAE,EAAE;QACjG,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;QACtC,MAAM,EAAE,SAAS,EAAE,YAAY,EAAE,GAAG,OAAO,CAAC,MAAM,CAAA;QAElD,qFAAqF;QACrF,MAAM,OAAO,GAAQ,MAAM,IAAA,qBAAa,EAAC,6BAAW,CAAC,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAS,EAAE,CAAC,CAAA;QAC7H,MAAM,QAAQ,GAAG,CAAC,CAAC,OAAO,IAAI,IAAA,sCAAc,EAAC,OAAO,CAAC,UAAU,EAAE,MAAM,IAAA,4CAAoB,EAAC,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC,CAAA;QAC5H,MAAM,GAAG,GAAQ,QAAQ,CAAC,CAAC,CAAC,MAAM,IAAA,2CAAkB,EAAC,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,YAAY,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;QAEpG,IAAI,CAAC,GAAG,EAAE,CAAC;YACT,kGAAkG;YAClG,OAAO,CAAC,MAAM,GAAG,GAAG,CAAA;YACpB,OAAM;QACR,CAAC;QAED,OAAO,CAAC,GAAG,CAAC,eAAe,EAAE,uBAAuB,CAAC,CAAA;QACrD,OAAO,CAAC,GAAG,CAAC,qBAAqB,EAAE,4BAA4B,kBAAkB,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;QAC9F,IAAI,GAAG,CAAC,QAAQ;YAAE,OAAO,CAAC,IAAI,GAAG,GAAG,CAAC,QAAQ,CAAA;QAC7C,MAAM,yBAAO,CAAC,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,CAAA;IACjD,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA","sourcesContent":["/*\n * Where a person opens a conversation's file - GET /chat-attachments/:sessionId/:attachmentId.\n *\n * Domain-private (sign-in and tenancy required), and the conversation's own read gate on top: a private\n * conversation's file opens for its participants only, a deleted conversation's for nobody (design/ai/\n * shared-dock.md §9-3). attachment-base's own route is public and unauthenticated, so chat files are\n * never linked through it. `private` caching - a shared cache must not keep a private conversation's file.\n */\nimport { STORAGE } from '@things-factory/attachment-base'\nimport { getRepository } from '@things-factory/shell'\n\nimport { ChatSession } from './chat-session.js'\nimport { mayReadSession } from './session-visibility.js'\nimport { findChatAttachment } from './session-attachments.js'\nimport { isSessionParticipant } from '../chat-session-participant/participant-lookup.js'\n\nexport const CHAT_ATTACHMENT_ROUTE = 'chat-attachments'\n\n/** The address a screen uses for one file of a conversation. */\nexport function chatAttachmentUrl(sessionId: string, attachmentId: string): string {\n return `/${CHAT_ATTACHMENT_ROUTE}/${encodeURIComponent(sessionId)}/${encodeURIComponent(attachmentId)}`\n}\n\nprocess.on('bootstrap-module-domain-private-route' as any, (_app: any, routes: any) => {\n routes.get(`/${CHAT_ATTACHMENT_ROUTE}/:sessionId/:attachmentId`, async (context: any, next: any) => {\n const { domain, user } = context.state\n const { sessionId, attachmentId } = context.params\n\n /* A soft-deleted conversation is not found here - its files close with it (§9-5). */\n const session: any = await getRepository(ChatSession).findOne({ where: { id: sessionId, domain: { id: domain.id } } as any })\n const readable = !!session && mayReadSession(session.visibility, await isSessionParticipant(domain.id, sessionId, user?.id))\n const row: any = readable ? await findChatAttachment(domain.id, sessionId, attachmentId) : undefined\n\n if (!row) {\n /* One answer for \"none\", \"not yours\" and \"deleted\" - telling them apart would say what exists. */\n context.status = 404\n return\n }\n\n context.set('Cache-Control', 'private, max-age=3600')\n context.set('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(row.name)}`)\n if (row.mimetype) context.type = row.mimetype\n await STORAGE.sendFile(context, row.path, next)\n })\n})\n"]}
@@ -0,0 +1,3 @@
1
+ import { type ToolCategory } from '@things-factory/ai-client-base';
2
+ export declare const CHAT_ATTACHMENT_CATEGORY = "chat-attachments";
3
+ export declare const chatAttachmentCategory: ToolCategory;
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.chatAttachmentCategory = exports.CHAT_ATTACHMENT_CATEGORY = void 0;
4
+ /*
5
+ * readChatAttachment - the model opens a file sent in an earlier turn (design/ai/shared-dock.md §9-4).
6
+ *
7
+ * A file reaches the model in full only in the turn it was sent; later turns carry one line naming it.
8
+ * When the model needs it again it asks here. The caller must be able to read the conversation the file
9
+ * belongs to - the same gate as reading its messages (ADR-0073 decision 1) - so a file id from elsewhere
10
+ * opens nothing.
11
+ *
12
+ * The category is opened for every mode that declares an attachment policy (resolve.ts).
13
+ */
14
+ const ai_client_base_1 = require("@things-factory/ai-client-base");
15
+ const attachment_base_1 = require("@things-factory/attachment-base");
16
+ const shell_1 = require("@things-factory/shell");
17
+ const chat_session_js_1 = require("./chat-session.js");
18
+ const session_visibility_js_1 = require("./session-visibility.js");
19
+ const participant_lookup_js_1 = require("../chat-session-participant/participant-lookup.js");
20
+ const session_attachments_js_1 = require("./session-attachments.js");
21
+ exports.CHAT_ATTACHMENT_CATEGORY = 'chat-attachments';
22
+ const readChatAttachment = {
23
+ kind: 'read',
24
+ name: 'readChatAttachment',
25
+ doors: ['Query.chatMessages'],
26
+ description: 'Open a file that was sent earlier in this conversation. History names such files as "[attachment <id>: <name> ...]"; ' +
27
+ 'pass that id. An image comes back as a description request, a text file as its text (cut when long - the answer says so).',
28
+ schema: {
29
+ type: 'object',
30
+ properties: { attachmentId: { type: 'string', description: 'The id from the "[attachment <id>: ...]" line.' } },
31
+ required: ['attachmentId']
32
+ },
33
+ builder: async (args, context) => {
34
+ const { domain, user } = context?.state ?? {};
35
+ const id = typeof args?.attachmentId === 'string' ? args.attachmentId.trim() : '';
36
+ if (!domain?.id || !id)
37
+ return { absent: 'no-attachment', message: 'no attachment id was given.' };
38
+ const row = await (0, shell_1.getRepository)(attachment_base_1.Attachment).findOne({ where: { id, domain: { id: domain.id }, refType: session_attachments_js_1.CHAT_ATTACHMENT_REF } });
39
+ const session = row && (await (0, shell_1.getRepository)(chat_session_js_1.ChatSession).findOne({ where: { id: row.refBy, domain: { id: domain.id } } }));
40
+ if (!row || !session)
41
+ return { absent: 'not-found', attachmentId: id, message: 'there is no such attachment in a conversation here.' };
42
+ const participant = await (0, participant_lookup_js_1.isSessionParticipant)(domain.id, session.id, user?.id);
43
+ if (!(0, session_visibility_js_1.mayReadSession)(session.visibility, participant)) {
44
+ /* Same answer as absent - saying "it exists but is private" would itself leak. */
45
+ return { absent: 'not-found', attachmentId: id, message: 'there is no such attachment in a conversation here.' };
46
+ }
47
+ const file = await (0, session_attachments_js_1.attachmentForModel)(row);
48
+ return file.type === 'image'
49
+ ? { attachmentId: id, name: file.name, type: 'image', note: 'images are shown to you only in the turn they were sent; ask the person to send it again to look at it.' }
50
+ : { attachmentId: id, name: file.name, type: 'text', text: file.text, ...(file.truncated ? { note: 'the file is longer; this is its beginning.' } : {}) };
51
+ }
52
+ };
53
+ exports.chatAttachmentCategory = {
54
+ name: exports.CHAT_ATTACHMENT_CATEGORY,
55
+ guidance: '- A line "[attachment <id>: <name> ...]" names a file sent in an earlier turn. Call readChatAttachment with that id before answering about its contents; never guess what a file says.',
56
+ specs: [readChatAttachment]
57
+ };
58
+ (0, ai_client_base_1.registerToolCategory)(exports.chatAttachmentCategory);
59
+ //# sourceMappingURL=attachment-tools.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachment-tools.js","sourceRoot":"","sources":["../../../server/service/chat-session/attachment-tools.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * readChatAttachment - the model opens a file sent in an earlier turn (design/ai/shared-dock.md §9-4).\n *\n * A file reaches the model in full only in the turn it was sent; later turns carry one line naming it.\n * When the model needs it again it asks here. The caller must be able to read the conversation the file\n * belongs to - the same gate as reading its messages (ADR-0073 decision 1) - so a file id from elsewhere\n * opens nothing.\n *\n * The category is opened for every mode that declares an attachment policy (resolve.ts).\n */\nimport { registerToolCategory, type ToolCategory, type ToolSpec } from '@things-factory/ai-client-base'\nimport { Attachment } from '@things-factory/attachment-base'\nimport { getRepository } from '@things-factory/shell'\n\nimport { ChatSession } from './chat-session.js'\nimport { mayReadSession } from './session-visibility.js'\nimport { isSessionParticipant } from '../chat-session-participant/participant-lookup.js'\nimport { attachmentForModel, CHAT_ATTACHMENT_REF } from './session-attachments.js'\n\nexport const CHAT_ATTACHMENT_CATEGORY = 'chat-attachments'\n\nconst readChatAttachment: ToolSpec = {\n kind: 'read',\n name: 'readChatAttachment',\n doors: ['Query.chatMessages'],\n description:\n 'Open a file that was sent earlier in this conversation. History names such files as \"[attachment <id>: <name> ...]\"; ' +\n 'pass that id. An image comes back as a description request, a text file as its text (cut when long - the answer says so).',\n schema: {\n type: 'object',\n properties: { attachmentId: { type: 'string', description: 'The id from the \"[attachment <id>: ...]\" line.' } },\n required: ['attachmentId']\n },\n builder: async (args: any, context: any) => {\n const { domain, user } = context?.state ?? {}\n const id = typeof args?.attachmentId === 'string' ? args.attachmentId.trim() : ''\n if (!domain?.id || !id) return { absent: 'no-attachment', message: 'no attachment id was given.' }\n\n const row: any = await getRepository(Attachment).findOne({ where: { id, domain: { id: domain.id }, refType: CHAT_ATTACHMENT_REF } as any })\n const session: any = row && (await getRepository(ChatSession).findOne({ where: { id: row.refBy, domain: { id: domain.id } } as any }))\n if (!row || !session) return { absent: 'not-found', attachmentId: id, message: 'there is no such attachment in a conversation here.' }\n\n const participant = await isSessionParticipant(domain.id, session.id, user?.id)\n if (!mayReadSession(session.visibility, participant)) {\n /* Same answer as absent - saying \"it exists but is private\" would itself leak. */\n return { absent: 'not-found', attachmentId: id, message: 'there is no such attachment in a conversation here.' }\n }\n\n const file = await attachmentForModel(row)\n return file.type === 'image'\n ? { attachmentId: id, name: file.name, type: 'image', note: 'images are shown to you only in the turn they were sent; ask the person to send it again to look at it.' }\n : { attachmentId: id, name: file.name, type: 'text', text: file.text, ...(file.truncated ? { note: 'the file is longer; this is its beginning.' } : {}) }\n }\n} as ToolSpec\n\nexport const chatAttachmentCategory: ToolCategory = {\n name: CHAT_ATTACHMENT_CATEGORY,\n guidance:\n '- A line \"[attachment <id>: <name> ...]\" names a file sent in an earlier turn. Call readChatAttachment with that id before answering about its contents; never guess what a file says.',\n specs: [readChatAttachment]\n}\n\nregisterToolCategory(chatAttachmentCategory)\n"]}
@@ -0,0 +1,36 @@
1
+ export declare const CHAT_ATTACHMENT_REF = "chat-session";
2
+ export interface StoredChatAttachment {
3
+ id: string;
4
+ name: string;
5
+ mimetype: string;
6
+ size: number;
7
+ }
8
+ export declare function describeAttachment(row: any): StoredChatAttachment;
9
+ /** Stores uploaded files under the conversation. The caller has passed the write gate and the mode's policy. */
10
+ export declare function saveChatAttachments(sessionId: string, files: any[], context: any): Promise<StoredChatAttachment[]>;
11
+ /** One file of the conversation - `undefined` when it is not this conversation's. */
12
+ export declare function findChatAttachment(domainId: string, sessionId: string, attachmentId: string): Promise<any | undefined>;
13
+ /**
14
+ * What the model gets for a file: an image as bytes, a text file as its text (cut at TEXT_CHARS_FOR_MODEL,
15
+ * and the cut is said), anything else as a line that names it.
16
+ */
17
+ export declare function attachmentForModel(row: any): Promise<{
18
+ type: 'image';
19
+ data: Buffer;
20
+ mediaType: string;
21
+ name: string;
22
+ } | {
23
+ type: 'text';
24
+ text: string;
25
+ name: string;
26
+ truncated: boolean;
27
+ }>;
28
+ /** Removes these files from storage and their rows. */
29
+ export declare function removeAttachmentRows(ids: string[]): Promise<void>;
30
+ /** Removes a conversation's files from storage and their rows - the purge's part (§9-5). */
31
+ export declare function removeSessionFiles(session: {
32
+ id?: string;
33
+ domain?: {
34
+ id?: string;
35
+ };
36
+ }): Promise<void>;
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CHAT_ATTACHMENT_REF = void 0;
4
+ exports.describeAttachment = describeAttachment;
5
+ exports.saveChatAttachments = saveChatAttachments;
6
+ exports.findChatAttachment = findChatAttachment;
7
+ exports.attachmentForModel = attachmentForModel;
8
+ exports.removeAttachmentRows = removeAttachmentRows;
9
+ exports.removeSessionFiles = removeSessionFiles;
10
+ /*
11
+ * A conversation's files - stored in attachment-base, opened only through the conversation.
12
+ *
13
+ * Files are kept with `refType: 'chat-session'`, `refBy: <session id>` (design/ai/shared-dock.md §9-2).
14
+ * They are never handed out through attachment-base's own route: that route is public and unauthenticated
15
+ * (routes.ts, "TODO make this secure"), so a private conversation's file would leave the conversation.
16
+ * Every read here passes the conversation's gate first (§9-3); this module only does the storage part.
17
+ */
18
+ const attachment_base_1 = require("@things-factory/attachment-base");
19
+ const shell_1 = require("@things-factory/shell");
20
+ const attachment_policy_js_1 = require("./attachment-policy.js");
21
+ exports.CHAT_ATTACHMENT_REF = 'chat-session';
22
+ function describeAttachment(row) {
23
+ return { id: row.id, name: row.name, mimetype: row.mimetype, size: Number(row.size) || 0 };
24
+ }
25
+ /** Stores uploaded files under the conversation. The caller has passed the write gate and the mode's policy. */
26
+ async function saveChatAttachments(sessionId, files, context) {
27
+ const saved = [];
28
+ for (const file of files) {
29
+ const row = await (0, attachment_base_1.createAttachment)(null, { attachment: { file, category: 'chat', refType: exports.CHAT_ATTACHMENT_REF, refBy: sessionId } }, context);
30
+ saved.push(describeAttachment(row));
31
+ }
32
+ return saved;
33
+ }
34
+ /** One file of the conversation - `undefined` when it is not this conversation's. */
35
+ async function findChatAttachment(domainId, sessionId, attachmentId) {
36
+ return ((await (0, shell_1.getRepository)(attachment_base_1.Attachment).findOne({
37
+ where: { id: attachmentId, domain: { id: domainId }, refType: exports.CHAT_ATTACHMENT_REF, refBy: sessionId }
38
+ })) ?? undefined);
39
+ }
40
+ /**
41
+ * What the model gets for a file: an image as bytes, a text file as its text (cut at TEXT_CHARS_FOR_MODEL,
42
+ * and the cut is said), anything else as a line that names it.
43
+ */
44
+ async function attachmentForModel(row) {
45
+ const kind = (0, attachment_policy_js_1.attachmentKindOf)({ type: row.mimetype, name: row.name });
46
+ if (kind === 'image') {
47
+ const data = await attachment_base_1.STORAGE.readFile(row.path);
48
+ return { type: 'image', data: Buffer.isBuffer(data) ? data : Buffer.from(data), mediaType: row.mimetype, name: row.name };
49
+ }
50
+ const content = String(await attachment_base_1.STORAGE.readFile(row.path, 'utf8'));
51
+ const truncated = content.length > attachment_policy_js_1.TEXT_CHARS_FOR_MODEL;
52
+ return { type: 'text', text: truncated ? content.slice(0, attachment_policy_js_1.TEXT_CHARS_FOR_MODEL) : content, name: row.name, truncated };
53
+ }
54
+ /** Removes these files from storage and their rows. */
55
+ async function removeAttachmentRows(ids) {
56
+ if (!ids.length)
57
+ return;
58
+ const rows = await (0, shell_1.getRepository)(attachment_base_1.Attachment).find({ where: ids.map(id => ({ id, refType: exports.CHAT_ATTACHMENT_REF })) });
59
+ for (const row of rows) {
60
+ try {
61
+ await attachment_base_1.STORAGE.deleteFile(row.path);
62
+ }
63
+ catch {
64
+ /* already gone from storage */
65
+ }
66
+ }
67
+ if (rows.length)
68
+ await (0, shell_1.getRepository)(attachment_base_1.Attachment).delete(rows.map(row => row.id));
69
+ }
70
+ /** Removes a conversation's files from storage and their rows - the purge's part (§9-5). */
71
+ async function removeSessionFiles(session) {
72
+ const rows = await (0, shell_1.getRepository)(attachment_base_1.Attachment).find({ where: { refType: exports.CHAT_ATTACHMENT_REF, refBy: session.id } });
73
+ for (const row of rows) {
74
+ try {
75
+ await attachment_base_1.STORAGE.deleteFile(row.path);
76
+ }
77
+ catch {
78
+ /* A file already gone from storage must not keep its row alive. */
79
+ }
80
+ }
81
+ if (rows.length)
82
+ await (0, shell_1.getRepository)(attachment_base_1.Attachment).delete(rows.map(row => row.id));
83
+ }
84
+ //# sourceMappingURL=session-attachments.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-attachments.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-attachments.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * A conversation's files - stored in attachment-base, opened only through the conversation.\n *\n * Files are kept with `refType: 'chat-session'`, `refBy: <session id>` (design/ai/shared-dock.md §9-2).\n * They are never handed out through attachment-base's own route: that route is public and unauthenticated\n * (routes.ts, \"TODO make this secure\"), so a private conversation's file would leave the conversation.\n * Every read here passes the conversation's gate first (§9-3); this module only does the storage part.\n */\nimport { Attachment, STORAGE, createAttachment } from '@things-factory/attachment-base'\nimport { getRepository } from '@things-factory/shell'\n\nimport { attachmentKindOf, TEXT_CHARS_FOR_MODEL } from './attachment-policy.js'\n\nexport const CHAT_ATTACHMENT_REF = 'chat-session'\n\nexport interface StoredChatAttachment {\n id: string\n name: string\n mimetype: string\n size: number\n}\n\nexport function describeAttachment(row: any): StoredChatAttachment {\n return { id: row.id, name: row.name, mimetype: row.mimetype, size: Number(row.size) || 0 }\n}\n\n/** Stores uploaded files under the conversation. The caller has passed the write gate and the mode's policy. */\nexport async function saveChatAttachments(sessionId: string, files: any[], context: any): Promise<StoredChatAttachment[]> {\n const saved: StoredChatAttachment[] = []\n for (const file of files) {\n const row = await createAttachment(null, { attachment: { file, category: 'chat', refType: CHAT_ATTACHMENT_REF, refBy: sessionId } }, context)\n saved.push(describeAttachment(row))\n }\n return saved\n}\n\n/** One file of the conversation - `undefined` when it is not this conversation's. */\nexport async function findChatAttachment(domainId: string, sessionId: string, attachmentId: string): Promise<any | undefined> {\n return (\n (await getRepository(Attachment).findOne({\n where: { id: attachmentId, domain: { id: domainId }, refType: CHAT_ATTACHMENT_REF, refBy: sessionId } as any\n })) ?? undefined\n )\n}\n\n/**\n * What the model gets for a file: an image as bytes, a text file as its text (cut at TEXT_CHARS_FOR_MODEL,\n * and the cut is said), anything else as a line that names it.\n */\nexport async function attachmentForModel(row: any): Promise<\n | { type: 'image'; data: Buffer; mediaType: string; name: string }\n | { type: 'text'; text: string; name: string; truncated: boolean }\n> {\n const kind = attachmentKindOf({ type: row.mimetype, name: row.name })\n if (kind === 'image') {\n const data = await STORAGE.readFile(row.path)\n return { type: 'image', data: Buffer.isBuffer(data) ? data : Buffer.from(data), mediaType: row.mimetype, name: row.name }\n }\n const content = String(await STORAGE.readFile(row.path, 'utf8'))\n const truncated = content.length > TEXT_CHARS_FOR_MODEL\n return { type: 'text', text: truncated ? content.slice(0, TEXT_CHARS_FOR_MODEL) : content, name: row.name, truncated }\n}\n\n/** Removes these files from storage and their rows. */\nexport async function removeAttachmentRows(ids: string[]): Promise<void> {\n if (!ids.length) return\n const rows = await getRepository(Attachment).find({ where: ids.map(id => ({ id, refType: CHAT_ATTACHMENT_REF })) as any })\n for (const row of rows) {\n try {\n await STORAGE.deleteFile((row as any).path)\n } catch {\n /* already gone from storage */\n }\n }\n if (rows.length) await getRepository(Attachment).delete(rows.map(row => (row as any).id))\n}\n\n/** Removes a conversation's files from storage and their rows - the purge's part (§9-5). */\nexport async function removeSessionFiles(session: { id?: string; domain?: { id?: string } }): Promise<void> {\n const rows = await getRepository(Attachment).find({ where: { refType: CHAT_ATTACHMENT_REF, refBy: session.id } as any })\n for (const row of rows) {\n try {\n await STORAGE.deleteFile((row as any).path)\n } catch {\n /* A file already gone from storage must not keep its row alive. */\n }\n }\n if (rows.length) await getRepository(Attachment).delete(rows.map(row => (row as any).id))\n}\n"]}
@@ -12,10 +12,14 @@ export interface InboxSessionRow {
12
12
  /** 그 미리보기가 지나온 문 — 목록도 같은 관문을 지난다. */
13
13
  lastMessageDoors?: string[] | null;
14
14
  createdAt?: Date | string | null;
15
+ /** 도메인 안 누구나 읽나(`open`) · 참여자만(`private`) — ADR-0073 판정 2. 값이 없으면 open(옛 행). */
16
+ visibility?: string | null;
15
17
  }
16
18
  /** 내 참여 기록(읽은 지점). */
17
19
  export interface MyParticipation {
18
20
  sessionId?: string;
21
+ /** 이 대화에서 나의 자리 — `owner` 만 공개 범위를 바꾼다. */
22
+ role?: string | null;
19
23
  lastReadAt?: Date | string | null;
20
24
  /** 내 목록에서 숨긴 시각 — 이후 새 활동이 생기면 다시 보인다. */
21
25
  hiddenAt?: Date | string | null;
@@ -27,6 +31,10 @@ export interface InboxEntry extends InboxSessionRow {
27
31
  participant: boolean;
28
32
  /** 내가 숨긴 대화인가 — 목록에서 빠지지만 필터로 볼 수 있다(되돌리기 가능). */
29
33
  hidden?: boolean;
34
+ /** `open` · `private` — 목록이 자물쇠를 그리는 근거. */
35
+ visibility: string;
36
+ /** 내가 이 대화의 owner 인가 — 공개 범위를 바꿀 수 있는 사람. UI 힌트이고 판정은 문이 다시 한다. */
37
+ owner: boolean;
30
38
  }
31
39
  /**
32
40
  * 세션 + 내 참여 기록 → 목록 항목. **최근 활동순**(활동 없으면 생성순) 내림차순.
@@ -59,6 +67,27 @@ export declare function applyHidden(entries: InboxEntry[], hiddenAt: Map<string,
59
67
  * **상한을 두고** 가져오고(아래 INBOX_SCAN_CAP) 걸러내기·정렬·페이지는 여기서 한다.
60
68
  * 상한을 넘으면 잘렸다는 사실을 그대로 알린다(조용히 일부만 보여주지 않는다).
61
69
  */
70
+ /** What the conversation list is narrowed by - the manager's filters (design/ai/shared-dock.md §2-6). */
71
+ export interface InboxFilter {
72
+ /** Only this conversation kind (mode). Absent: every kind on the anchor. */
73
+ kind?: string | null;
74
+ /** Only the ones I started. */
75
+ mineOnly?: boolean | null;
76
+ /** Only the ones I am in (owner or member). */
77
+ participantOnly?: boolean | null;
78
+ /** Only 'open' or only 'private'. */
79
+ visibility?: string | null;
80
+ /** Only the ones with activity I have not read. */
81
+ unreadOnly?: boolean | null;
82
+ }
83
+ /**
84
+ * Narrow the list. Pure - the resolver builds the entries (with the reader's gate already applied) and
85
+ * this decides only what the person asked to see. A session from before the kind column has no kind:
86
+ * it is shown under every kind, so it is never lost from the list it was started in.
87
+ */
88
+ export declare function filterInbox<T extends InboxEntry & {
89
+ mine?: boolean;
90
+ }>(entries: T[], filter?: InboxFilter): T[];
62
91
  export declare function searchInbox(entries: InboxEntry[], search?: string): InboxEntry[];
63
92
  /** 한 번에 훑는 세션 상한 — 이보다 많으면 잘렸다고 알린다(정직). */
64
93
  export declare const INBOX_SCAN_CAP = 500;
@@ -19,10 +19,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
19
19
  exports.INBOX_SCAN_CAP = void 0;
20
20
  exports.buildInbox = buildInbox;
21
21
  exports.applyHidden = applyHidden;
22
+ exports.filterInbox = filterInbox;
22
23
  exports.searchInbox = searchInbox;
23
24
  exports.countUnreadSessions = countUnreadSessions;
24
25
  exports.activityPatch = activityPatch;
25
26
  exports.autoSessionName = autoSessionName;
27
+ const session_visibility_js_1 = require("./session-visibility.js");
26
28
  function time(v) {
27
29
  if (!v)
28
30
  return 0;
@@ -43,13 +45,22 @@ function time(v) {
43
45
  function buildInbox(sessions, mine) {
44
46
  const readBy = new Map();
45
47
  const isParticipant = new Set();
48
+ const owns = new Set();
46
49
  for (const p of mine) {
47
50
  if (!p.sessionId)
48
51
  continue;
49
52
  isParticipant.add(p.sessionId);
50
53
  readBy.set(p.sessionId, time(p.lastReadAt));
54
+ if (p.role === 'owner')
55
+ owns.add(p.sessionId);
51
56
  }
52
- const entries = sessions.map(session => {
57
+ const entries = sessions
58
+ /*
59
+ * 남의 비공개 대화는 목록에도 서지 않는다 — ADR-0073 판정 1 의 「읽는 길은 같은 관문을 지난다」.
60
+ * 메시지 문(`chatMessages`)만 막고 목록이 이름을 내보내면, 대화의 주제가 다른 길로 새어 나간다.
61
+ */
62
+ .filter(session => (0, session_visibility_js_1.mayReadSession)(session.visibility, isParticipant.has(session.id ?? '')))
63
+ .map(session => {
53
64
  const id = session.id ?? '';
54
65
  const activity = time(session.lastMessageAt);
55
66
  const participant = isParticipant.has(id);
@@ -57,6 +68,8 @@ function buildInbox(sessions, mine) {
57
68
  return {
58
69
  ...session,
59
70
  participant,
71
+ visibility: session.visibility ?? 'open',
72
+ owner: owns.has(id),
60
73
  unread: participant && activity > 0 && activity > read
61
74
  };
62
75
  });
@@ -84,14 +97,25 @@ function applyHidden(entries, hiddenAt, showHidden = false) {
84
97
  return out;
85
98
  }
86
99
  /**
87
- * 검색 — 이름과 마지막 미리보기에 대한 대소문자 무시 부분일치.
88
- *
89
- * **왜 메모리에서 하는가**: 이 프레임워크는 5개 DB 드라이버를 지원한다. 대소문자 무시 검색은
90
- * 드라이버마다 표기가 갈리고(ILIKE=pg 전용, LOWER()+LIKE 는 되지만 정렬·NULL 취급이 또 갈린다),
91
- * 목록 경로에 드라이버별 SQL 을 두는 것은 이 프로젝트의 규율에 반한다. 그래서 조회는 앵커로
92
- * **상한을 두고** 가져오고(아래 INBOX_SCAN_CAP) 걸러내기·정렬·페이지는 여기서 한다.
93
- * 상한을 넘으면 잘렸다는 사실을 그대로 알린다(조용히 일부만 보여주지 않는다).
100
+ * Narrow the list. Pure - the resolver builds the entries (with the reader's gate already applied) and
101
+ * this decides only what the person asked to see. A session from before the kind column has no kind:
102
+ * it is shown under every kind, so it is never lost from the list it was started in.
94
103
  */
104
+ function filterInbox(entries, filter = {}) {
105
+ return entries.filter(entry => {
106
+ if (filter.kind && entry.kind && entry.kind !== filter.kind)
107
+ return false;
108
+ if (filter.mineOnly && !entry.mine)
109
+ return false;
110
+ if (filter.participantOnly && !entry.participant)
111
+ return false;
112
+ if (filter.visibility && (entry.visibility ?? 'open') !== filter.visibility)
113
+ return false;
114
+ if (filter.unreadOnly && !entry.unread)
115
+ return false;
116
+ return true;
117
+ });
118
+ }
95
119
  function searchInbox(entries, search) {
96
120
  const q = (search ?? '').trim().toLowerCase();
97
121
  if (!q)
@@ -1 +1 @@
1
- {"version":3,"file":"session-inbox.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-inbox.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 세션 목록(받은편지함) 순수 로직 — 리졸버와 분리해 단위 테스트 가능.\n *\n * ── 왜 필요한가 ─────────────────────────────────────────────────────────────\n * 대화가 늘면(가벼운 질문을 승격할 때마다 새 세션) \"어디로 갈지\"를 목록에서 골라야 하고, 다른 참여자가\n * 남긴 새 메시지를 알아야 한다. 세션 목록은 **최근 활동순**이어야 하고 **안 읽음**이 보여야 한다.\n *\n * ── 집계 쿼리를 쓰지 않는다 ─────────────────────────────────────────────────\n * 세션마다 메시지를 집계하면(MAX/COUNT + group by) 목록 한 번에 집계가 붙고, 이 프레임워크는 5개 DB\n * 드라이버를 지원해야 한다. 대신 `ChatSession.lastMessageAt` 을 비정규화해 두고(메시지 저장 시 갱신),\n * 참여자의 `lastReadAt` 과 **값 비교**로 안 읽음을 판정한다 — 조회 두 번(세션·내 참여자)으로 끝난다.\n *\n * ── v1 은 개수가 아니라 \"안 읽음 여부\" ──────────────────────────────────────\n * 정확한 미읽음 **개수**는 세션별 count 가 필요하다(목록 N개면 N번). 목록에서는 점 하나로 충분하고,\n * 숫자가 필요해지면 화면에 보이는 세션에 한해 따로 세면 된다. 처음부터 N번 세지 않는다.\n */\n\n/** 목록에 필요한 세션 필드(엔티티·typeorm 비의존). */\nexport interface InboxSessionRow {\n id?: string\n name?: string\n /** 어느 대화인가 — 목록이 대화를 가리지 않으므로, 고른 뒤 화면이 따라갈 수 있게 함께 낸다. */\n kind?: string\n anchorType?: string\n anchorId?: string\n boardId?: string\n lastMessageAt?: Date | string | null\n lastMessagePreview?: string | null\n /** 그 미리보기가 지나온 문 — 목록도 같은 관문을 지난다. */\n lastMessageDoors?: string[] | null\n createdAt?: Date | string | null\n}\n\n/** 내 참여 기록(읽은 지점). */\nexport interface MyParticipation {\n sessionId?: string\n lastReadAt?: Date | string | null\n /** 내 목록에서 숨긴 시각 — 이후 새 활동이 생기면 다시 보인다. */\n hiddenAt?: Date | string | null\n}\n\nexport interface InboxEntry extends InboxSessionRow {\n /** 안 읽은 활동이 있는가 — 목록의 점 표시. */\n unread: boolean\n /** 내가 참여자인가 — 아니면 열어볼 수는 있어도 읽음 상태를 갖지 않는다. */\n participant: boolean\n /** 내가 숨긴 대화인가 — 목록에서 빠지지만 필터로 볼 수 있다(되돌리기 가능). */\n hidden?: boolean\n}\n\nfunction time(v: Date | string | null | undefined): number {\n if (!v) return 0\n const t = v instanceof Date ? v.getTime() : Date.parse(v)\n return Number.isFinite(t) ? t : 0\n}\n\n/**\n * 세션 + 내 참여 기록 → 목록 항목. **최근 활동순**(활동 없으면 생성순) 내림차순.\n *\n * 안 읽음 판정: 세션의 마지막 활동이 내가 읽은 지점보다 **뒤**면 안 읽음.\n * · 참여자가 아니면 안 읽음으로 표시하지 않는다(내 것이 아닌 대화에 배지를 띄우지 않는다).\n * · 참여자인데 한 번도 읽지 않았고(lastReadAt 없음) 활동이 있으면 안 읽음이다.\n * · 활동이 없는 세션(빈 대화)은 안 읽음이 아니다.\n *\n * 같은 시각 비교는 **엄격 초과**만 안 읽음으로 본다 — 읽은 직후 같은 밀리초에 저장된 활동을 안 읽음으로\n * 잡으면 방금 읽은 화면에 배지가 남는다.\n */\nexport function buildInbox(sessions: InboxSessionRow[], mine: MyParticipation[]): InboxEntry[] {\n const readBy = new Map<string, number>()\n const isParticipant = new Set<string>()\n for (const p of mine) {\n if (!p.sessionId) continue\n isParticipant.add(p.sessionId)\n readBy.set(p.sessionId, time(p.lastReadAt))\n }\n\n const entries = sessions.map(session => {\n const id = session.id ?? ''\n const activity = time(session.lastMessageAt)\n const participant = isParticipant.has(id)\n const read = readBy.get(id) ?? 0\n return {\n ...session,\n participant,\n unread: participant && activity > 0 && activity > read\n }\n })\n\n return entries.sort(\n (a, b) => (time(b.lastMessageAt) || time(b.createdAt)) - (time(a.lastMessageAt) || time(a.createdAt))\n )\n}\n\n/**\n * 숨김 표시·필터.\n *\n * 숨김은 **나에게만** 적용되고 되돌릴 수 있다. 숨긴 **뒤에 새 활동이 생기면 다시 보인다** —\n * 아직 참여 중인 대화에서 조용히 잘려 나가는 것이 목록 한 줄보다 나쁘다.\n *\n * `showHidden` 이면 숨긴 것도 함께 돌려주되 표시(hidden)를 남긴다 — 필터를 켠 사용자가 무엇이\n * 숨겨진 것인지 알아야 되돌릴 수 있다.\n */\nexport function applyHidden(entries: InboxEntry[], hiddenAt: Map<string, Date | string | null | undefined>, showHidden = false): InboxEntry[] {\n const out: InboxEntry[] = []\n for (const entry of entries) {\n const at = hiddenAt.get(entry.id ?? '')\n /* 숨긴 시각보다 **뒤에** 활동이 있으면 숨김은 풀린 것으로 본다. */\n const hidden = !!at && time(entry.lastMessageAt) <= time(at)\n if (hidden && !showHidden) continue\n out.push(hidden ? { ...entry, hidden: true } : entry)\n }\n return out\n}\n\n/**\n * 검색 — 이름과 마지막 미리보기에 대한 대소문자 무시 부분일치.\n *\n * **왜 메모리에서 하는가**: 이 프레임워크는 5개 DB 드라이버를 지원한다. 대소문자 무시 검색은\n * 드라이버마다 표기가 갈리고(ILIKE=pg 전용, LOWER()+LIKE 는 되지만 정렬·NULL 취급이 또 갈린다),\n * 목록 경로에 드라이버별 SQL 을 두는 것은 이 프로젝트의 규율에 반한다. 그래서 조회는 앵커로\n * **상한을 두고** 가져오고(아래 INBOX_SCAN_CAP) 걸러내기·정렬·페이지는 여기서 한다.\n * 상한을 넘으면 잘렸다는 사실을 그대로 알린다(조용히 일부만 보여주지 않는다).\n */\nexport function searchInbox(entries: InboxEntry[], search?: string): InboxEntry[] {\n const q = (search ?? '').trim().toLowerCase()\n if (!q) return entries\n return entries.filter(e =>\n `${e.name ?? ''}\\n${e.lastMessagePreview ?? ''}`.toLowerCase().includes(q)\n )\n}\n\n/** 한 번에 훑는 세션 상한 — 이보다 많으면 잘렸다고 알린다(정직). */\nexport const INBOX_SCAN_CAP = 500\n\n/** 안 읽은 세션 수 — 도크 버튼 배지(개수가 아니라 **세션 수**). */\nexport function countUnreadSessions(entries: InboxEntry[]): number {\n return entries.filter(e => e.unread).length\n}\n\n/**\n * 메시지 저장 시 세션에 반영할 활동 값. 미리보기는 짧게 자르고 개행을 없앤다\n * (목록 한 줄에 들어가야 하고, 컬럼 길이 제한이 있다).\n */\nexport function activityPatch(\n content: string,\n at: Date = new Date(),\n doors: string[] = []\n): { lastMessageAt: Date; lastMessagePreview: string; lastMessageDoors: string[] } {\n const flat = (content ?? '').replace(/\\s+/g, ' ').trim()\n /* 미리보기가 어느 문을 지나온 값인지 함께 적는다 — 목록도 같은 관문을 지난다(ADR-0073 판정 1). */\n return { lastMessageAt: at, lastMessagePreview: flat.slice(0, 200), lastMessageDoors: doors }\n}\n\n/**\n * 첫 발화로 세션 이름을 만든다.\n *\n * `세션 2` 같은 순번은 목록에서 아무것도 알려주지 않는다 — \"A-DC 포화 원인\" 이어야 돌아갈 대화를 찾는다.\n * 사용자가 직접 지은 이름은 이 함수로 덮지 않는다(호출부가 이름 없을 때만 부른다).\n *\n * 규칙: 한 줄로 눌러 첫 문장 경계(., ?, !, 개행)까지, 최대 40자. 문장 부호로 끝나면 떼어 낸다\n * (목록 제목에 물음표가 붙으면 길이만 먹는다).\n */\nexport function autoSessionName(content: string, max = 40): string {\n const flat = (content ?? '').replace(/\\s+/g, ' ').trim()\n if (!flat) return ''\n const cut = flat.search(/[.?!\\n]/)\n const head = (cut > 0 ? flat.slice(0, cut) : flat).trim()\n const name = head.length > max ? head.slice(0, max).trim() + '…' : head\n return name\n}\n"]}
1
+ {"version":3,"file":"session-inbox.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-inbox.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 세션 목록(받은편지함) 순수 로직 — 리졸버와 분리해 단위 테스트 가능.\n *\n * ── 왜 필요한가 ─────────────────────────────────────────────────────────────\n * 대화가 늘면(가벼운 질문을 승격할 때마다 새 세션) \"어디로 갈지\"를 목록에서 골라야 하고, 다른 참여자가\n * 남긴 새 메시지를 알아야 한다. 세션 목록은 **최근 활동순**이어야 하고 **안 읽음**이 보여야 한다.\n *\n * ── 집계 쿼리를 쓰지 않는다 ─────────────────────────────────────────────────\n * 세션마다 메시지를 집계하면(MAX/COUNT + group by) 목록 한 번에 집계가 붙고, 이 프레임워크는 5개 DB\n * 드라이버를 지원해야 한다. 대신 `ChatSession.lastMessageAt` 을 비정규화해 두고(메시지 저장 시 갱신),\n * 참여자의 `lastReadAt` 과 **값 비교**로 안 읽음을 판정한다 — 조회 두 번(세션·내 참여자)으로 끝난다.\n *\n * ── v1 은 개수가 아니라 \"안 읽음 여부\" ──────────────────────────────────────\n * 정확한 미읽음 **개수**는 세션별 count 가 필요하다(목록 N개면 N번). 목록에서는 점 하나로 충분하고,\n * 숫자가 필요해지면 화면에 보이는 세션에 한해 따로 세면 된다. 처음부터 N번 세지 않는다.\n */\n\nimport { mayReadSession } from './session-visibility.js'\n\n/** 목록에 필요한 세션 필드(엔티티·typeorm 비의존). */\nexport interface InboxSessionRow {\n id?: string\n name?: string\n /** 어느 대화인가 — 목록이 대화를 가리지 않으므로, 고른 뒤 화면이 따라갈 수 있게 함께 낸다. */\n kind?: string\n anchorType?: string\n anchorId?: string\n boardId?: string\n lastMessageAt?: Date | string | null\n lastMessagePreview?: string | null\n /** 그 미리보기가 지나온 문 — 목록도 같은 관문을 지난다. */\n lastMessageDoors?: string[] | null\n createdAt?: Date | string | null\n /** 도메인 안 누구나 읽나(`open`) · 참여자만(`private`) — ADR-0073 판정 2. 값이 없으면 open(옛 행). */\n visibility?: string | null\n}\n\n/** 내 참여 기록(읽은 지점). */\nexport interface MyParticipation {\n sessionId?: string\n /** 이 대화에서 나의 자리 — `owner` 만 공개 범위를 바꾼다. */\n role?: string | null\n lastReadAt?: Date | string | null\n /** 내 목록에서 숨긴 시각 — 이후 새 활동이 생기면 다시 보인다. */\n hiddenAt?: Date | string | null\n}\n\nexport interface InboxEntry extends InboxSessionRow {\n /** 안 읽은 활동이 있는가 — 목록의 점 표시. */\n unread: boolean\n /** 내가 참여자인가 — 아니면 열어볼 수는 있어도 읽음 상태를 갖지 않는다. */\n participant: boolean\n /** 내가 숨긴 대화인가 — 목록에서 빠지지만 필터로 볼 수 있다(되돌리기 가능). */\n hidden?: boolean\n /** `open` · `private` — 목록이 자물쇠를 그리는 근거. */\n visibility: string\n /** 내가 이 대화의 owner 인가 — 공개 범위를 바꿀 수 있는 사람. UI 힌트이고 판정은 문이 다시 한다. */\n owner: boolean\n}\n\nfunction time(v: Date | string | null | undefined): number {\n if (!v) return 0\n const t = v instanceof Date ? v.getTime() : Date.parse(v)\n return Number.isFinite(t) ? t : 0\n}\n\n/**\n * 세션 + 내 참여 기록 → 목록 항목. **최근 활동순**(활동 없으면 생성순) 내림차순.\n *\n * 안 읽음 판정: 세션의 마지막 활동이 내가 읽은 지점보다 **뒤**면 안 읽음.\n * · 참여자가 아니면 안 읽음으로 표시하지 않는다(내 것이 아닌 대화에 배지를 띄우지 않는다).\n * · 참여자인데 한 번도 읽지 않았고(lastReadAt 없음) 활동이 있으면 안 읽음이다.\n * · 활동이 없는 세션(빈 대화)은 안 읽음이 아니다.\n *\n * 같은 시각 비교는 **엄격 초과**만 안 읽음으로 본다 — 읽은 직후 같은 밀리초에 저장된 활동을 안 읽음으로\n * 잡으면 방금 읽은 화면에 배지가 남는다.\n */\nexport function buildInbox(sessions: InboxSessionRow[], mine: MyParticipation[]): InboxEntry[] {\n const readBy = new Map<string, number>()\n const isParticipant = new Set<string>()\n const owns = new Set<string>()\n for (const p of mine) {\n if (!p.sessionId) continue\n isParticipant.add(p.sessionId)\n readBy.set(p.sessionId, time(p.lastReadAt))\n if (p.role === 'owner') owns.add(p.sessionId)\n }\n\n const entries = sessions\n /*\n * 남의 비공개 대화는 목록에도 서지 않는다 — ADR-0073 판정 1 의 「읽는 길은 같은 관문을 지난다」.\n * 메시지 문(`chatMessages`)만 막고 목록이 이름을 내보내면, 대화의 주제가 다른 길로 새어 나간다.\n */\n .filter(session => mayReadSession(session.visibility, isParticipant.has(session.id ?? '')))\n .map(session => {\n const id = session.id ?? ''\n const activity = time(session.lastMessageAt)\n const participant = isParticipant.has(id)\n const read = readBy.get(id) ?? 0\n return {\n ...session,\n participant,\n visibility: session.visibility ?? 'open',\n owner: owns.has(id),\n unread: participant && activity > 0 && activity > read\n }\n })\n\n return entries.sort(\n (a, b) => (time(b.lastMessageAt) || time(b.createdAt)) - (time(a.lastMessageAt) || time(a.createdAt))\n )\n}\n\n/**\n * 숨김 표시·필터.\n *\n * 숨김은 **나에게만** 적용되고 되돌릴 수 있다. 숨긴 **뒤에 새 활동이 생기면 다시 보인다** —\n * 아직 참여 중인 대화에서 조용히 잘려 나가는 것이 목록 한 줄보다 나쁘다.\n *\n * `showHidden` 이면 숨긴 것도 함께 돌려주되 표시(hidden)를 남긴다 — 필터를 켠 사용자가 무엇이\n * 숨겨진 것인지 알아야 되돌릴 수 있다.\n */\nexport function applyHidden(entries: InboxEntry[], hiddenAt: Map<string, Date | string | null | undefined>, showHidden = false): InboxEntry[] {\n const out: InboxEntry[] = []\n for (const entry of entries) {\n const at = hiddenAt.get(entry.id ?? '')\n /* 숨긴 시각보다 **뒤에** 활동이 있으면 숨김은 풀린 것으로 본다. */\n const hidden = !!at && time(entry.lastMessageAt) <= time(at)\n if (hidden && !showHidden) continue\n out.push(hidden ? { ...entry, hidden: true } : entry)\n }\n return out\n}\n\n/**\n * 검색 — 이름과 마지막 미리보기에 대한 대소문자 무시 부분일치.\n *\n * **왜 메모리에서 하는가**: 이 프레임워크는 5개 DB 드라이버를 지원한다. 대소문자 무시 검색은\n * 드라이버마다 표기가 갈리고(ILIKE=pg 전용, LOWER()+LIKE 는 되지만 정렬·NULL 취급이 또 갈린다),\n * 목록 경로에 드라이버별 SQL 을 두는 것은 이 프로젝트의 규율에 반한다. 그래서 조회는 앵커로\n * **상한을 두고** 가져오고(아래 INBOX_SCAN_CAP) 걸러내기·정렬·페이지는 여기서 한다.\n * 상한을 넘으면 잘렸다는 사실을 그대로 알린다(조용히 일부만 보여주지 않는다).\n */\n/** What the conversation list is narrowed by - the manager's filters (design/ai/shared-dock.md §2-6). */\nexport interface InboxFilter {\n /** Only this conversation kind (mode). Absent: every kind on the anchor. */\n kind?: string | null\n /** Only the ones I started. */\n mineOnly?: boolean | null\n /** Only the ones I am in (owner or member). */\n participantOnly?: boolean | null\n /** Only 'open' or only 'private'. */\n visibility?: string | null\n /** Only the ones with activity I have not read. */\n unreadOnly?: boolean | null\n}\n\n/**\n * Narrow the list. Pure - the resolver builds the entries (with the reader's gate already applied) and\n * this decides only what the person asked to see. A session from before the kind column has no kind:\n * it is shown under every kind, so it is never lost from the list it was started in.\n */\nexport function filterInbox<T extends InboxEntry & { mine?: boolean }>(entries: T[], filter: InboxFilter = {}): T[] {\n return entries.filter(entry => {\n if (filter.kind && entry.kind && entry.kind !== filter.kind) return false\n if (filter.mineOnly && !entry.mine) return false\n if (filter.participantOnly && !entry.participant) return false\n if (filter.visibility && (entry.visibility ?? 'open') !== filter.visibility) return false\n if (filter.unreadOnly && !entry.unread) return false\n return true\n })\n}\n\nexport function searchInbox(entries: InboxEntry[], search?: string): InboxEntry[] {\n const q = (search ?? '').trim().toLowerCase()\n if (!q) return entries\n return entries.filter(e =>\n `${e.name ?? ''}\\n${e.lastMessagePreview ?? ''}`.toLowerCase().includes(q)\n )\n}\n\n/** 한 번에 훑는 세션 상한 — 이보다 많으면 잘렸다고 알린다(정직). */\nexport const INBOX_SCAN_CAP = 500\n\n/** 안 읽은 세션 수 — 도크 버튼 배지(개수가 아니라 **세션 수**). */\nexport function countUnreadSessions(entries: InboxEntry[]): number {\n return entries.filter(e => e.unread).length\n}\n\n/**\n * 메시지 저장 시 세션에 반영할 활동 값. 미리보기는 짧게 자르고 개행을 없앤다\n * (목록 한 줄에 들어가야 하고, 컬럼 길이 제한이 있다).\n */\nexport function activityPatch(\n content: string,\n at: Date = new Date(),\n doors: string[] = []\n): { lastMessageAt: Date; lastMessagePreview: string; lastMessageDoors: string[] } {\n const flat = (content ?? '').replace(/\\s+/g, ' ').trim()\n /* 미리보기가 어느 문을 지나온 값인지 함께 적는다 — 목록도 같은 관문을 지난다(ADR-0073 판정 1). */\n return { lastMessageAt: at, lastMessagePreview: flat.slice(0, 200), lastMessageDoors: doors }\n}\n\n/**\n * 첫 발화로 세션 이름을 만든다.\n *\n * `세션 2` 같은 순번은 목록에서 아무것도 알려주지 않는다 — \"A-DC 포화 원인\" 이어야 돌아갈 대화를 찾는다.\n * 사용자가 직접 지은 이름은 이 함수로 덮지 않는다(호출부가 이름 없을 때만 부른다).\n *\n * 규칙: 한 줄로 눌러 첫 문장 경계(., ?, !, 개행)까지, 최대 40자. 문장 부호로 끝나면 떼어 낸다\n * (목록 제목에 물음표가 붙으면 길이만 먹는다).\n */\nexport function autoSessionName(content: string, max = 40): string {\n const flat = (content ?? '').replace(/\\s+/g, ' ').trim()\n if (!flat) return ''\n const cut = flat.search(/[.?!\\n]/)\n const head = (cut > 0 ? flat.slice(0, cut) : flat).trim()\n const name = head.length > max ? head.slice(0, max).trim() + '…' : head\n return name\n}\n"]}
@@ -0,0 +1,14 @@
1
+ export { DEFAULT_RETENTION_DAYS, purgeCutoff, retentionDaysOf } from './session-retention.js';
2
+ /** How many conversations one run empties - a large backlog is worked off over several runs. */
3
+ export declare const PURGE_BATCH = 100;
4
+ export interface PurgeResult {
5
+ purged: number;
6
+ failed: {
7
+ sessionId: string;
8
+ message: string;
9
+ }[];
10
+ }
11
+ /** Empties the conversations whose deletion is older than the retention period. */
12
+ export declare function purgeDeletedSessions(now?: Date, retentionDays?: number): Promise<PurgeResult>;
13
+ /** Runs the job once a little after boot, then daily. The timer never keeps the process alive. */
14
+ export declare function schedulePurge(): void;