@things-factory/ai-assistant 10.1.47 → 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 (160) hide show
  1. package/client/components/assistant-chat.ts +97 -19
  2. package/client/components/assistant-dock.ts +617 -0
  3. package/client/components/assistant-mode.ts +2 -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 +4 -1
  7. package/client/components/panel-aria.test.ts +13 -8
  8. package/client/index.ts +8 -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-proposals.ts +50 -0
  13. package/client/utils/assistant-session-actions.ts +18 -0
  14. package/client/utils/assistant-session-admin.ts +167 -0
  15. package/client/utils/assistant-session-controller.ts +2 -0
  16. package/client/utils/assistant-session-transport.ts +6 -2
  17. package/client/utils/assistant-surface.ts +157 -0
  18. package/client/utils/attachment-policy.ts +71 -0
  19. package/dist-client/components/assistant-chat.d.ts +6 -0
  20. package/dist-client/components/assistant-chat.js +98 -20
  21. package/dist-client/components/assistant-chat.js.map +1 -1
  22. package/dist-client/components/assistant-dock.d.ts +74 -0
  23. package/dist-client/components/assistant-dock.js +641 -0
  24. package/dist-client/components/assistant-dock.js.map +1 -0
  25. package/dist-client/components/assistant-mode.d.ts +6 -0
  26. package/dist-client/components/assistant-mode.js.map +1 -1
  27. package/dist-client/components/assistant-session-manager.d.ts +77 -0
  28. package/dist-client/components/assistant-session-manager.js +652 -0
  29. package/dist-client/components/assistant-session-manager.js.map +1 -0
  30. package/dist-client/components/chat-echo-dedup.js +4 -0
  31. package/dist-client/components/chat-echo-dedup.js.map +1 -1
  32. package/dist-client/components/chat-input-builder.d.ts +2 -0
  33. package/dist-client/components/chat-input-builder.js +2 -1
  34. package/dist-client/components/chat-input-builder.js.map +1 -1
  35. package/dist-client/components/panel-aria.test.js +12 -8
  36. package/dist-client/components/panel-aria.test.js.map +1 -1
  37. package/dist-client/index.d.ts +8 -0
  38. package/dist-client/index.js +8 -0
  39. package/dist-client/index.js.map +1 -1
  40. package/dist-client/setup-assistant-dock.d.ts +15 -0
  41. package/dist-client/setup-assistant-dock.js +110 -0
  42. package/dist-client/setup-assistant-dock.js.map +1 -0
  43. package/dist-client/tsconfig.tsbuildinfo +1 -1
  44. package/dist-client/utils/assistant-activity.d.ts +22 -0
  45. package/dist-client/utils/assistant-activity.js +104 -0
  46. package/dist-client/utils/assistant-activity.js.map +1 -0
  47. package/dist-client/utils/assistant-ask.d.ts +23 -0
  48. package/dist-client/utils/assistant-ask.js +18 -0
  49. package/dist-client/utils/assistant-ask.js.map +1 -0
  50. package/dist-client/utils/assistant-proposals.d.ts +29 -0
  51. package/dist-client/utils/assistant-proposals.js +22 -0
  52. package/dist-client/utils/assistant-proposals.js.map +1 -0
  53. package/dist-client/utils/assistant-session-actions.d.ts +12 -0
  54. package/dist-client/utils/assistant-session-actions.js +19 -0
  55. package/dist-client/utils/assistant-session-actions.js.map +1 -0
  56. package/dist-client/utils/assistant-session-admin.d.ts +72 -0
  57. package/dist-client/utils/assistant-session-admin.js +81 -0
  58. package/dist-client/utils/assistant-session-admin.js.map +1 -0
  59. package/dist-client/utils/assistant-session-controller.d.ts +2 -0
  60. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  61. package/dist-client/utils/assistant-session-transport.js +6 -2
  62. package/dist-client/utils/assistant-session-transport.js.map +1 -1
  63. package/dist-client/utils/assistant-surface.d.ts +72 -0
  64. package/dist-client/utils/assistant-surface.js +99 -0
  65. package/dist-client/utils/assistant-surface.js.map +1 -0
  66. package/dist-client/utils/attachment-policy.d.ts +35 -0
  67. package/dist-client/utils/attachment-policy.js +56 -0
  68. package/dist-client/utils/attachment-policy.js.map +1 -0
  69. package/dist-server/service/assistant-chat-resolver.d.ts +1 -0
  70. package/dist-server/service/assistant-chat-resolver.js +71 -3
  71. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  72. package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +1 -0
  73. package/dist-server/service/assistant-mode/assistant-mode-resolver.js +7 -0
  74. package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
  75. package/dist-server/service/assistant-mode/resolve.d.ts +3 -0
  76. package/dist-server/service/assistant-mode/resolve.js +6 -2
  77. package/dist-server/service/assistant-mode/resolve.js.map +1 -1
  78. package/dist-server/service/assistant-mode/types.d.ts +6 -0
  79. package/dist-server/service/assistant-mode/types.js +0 -9
  80. package/dist-server/service/assistant-mode/types.js.map +1 -1
  81. package/dist-server/service/chat-message/chat-message.d.ts +6 -0
  82. package/dist-server/service/chat-message/chat-message.js +19 -0
  83. package/dist-server/service/chat-message/chat-message.js.map +1 -1
  84. package/dist-server/service/chat-session/attachment-policy.d.ts +44 -0
  85. package/dist-server/service/chat-session/attachment-policy.js +56 -0
  86. package/dist-server/service/chat-session/attachment-policy.js.map +1 -0
  87. package/dist-server/service/chat-session/attachment-route.d.ts +3 -0
  88. package/dist-server/service/chat-session/attachment-route.js +44 -0
  89. package/dist-server/service/chat-session/attachment-route.js.map +1 -0
  90. package/dist-server/service/chat-session/attachment-tools.d.ts +3 -0
  91. package/dist-server/service/chat-session/attachment-tools.js +59 -0
  92. package/dist-server/service/chat-session/attachment-tools.js.map +1 -0
  93. package/dist-server/service/chat-session/session-attachments.d.ts +36 -0
  94. package/dist-server/service/chat-session/session-attachments.js +84 -0
  95. package/dist-server/service/chat-session/session-attachments.js.map +1 -0
  96. package/dist-server/service/chat-session/session-inbox.d.ts +29 -0
  97. package/dist-server/service/chat-session/session-inbox.js +32 -8
  98. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  99. package/dist-server/service/chat-session/session-purge.d.ts +14 -0
  100. package/dist-server/service/chat-session/session-purge.js +77 -0
  101. package/dist-server/service/chat-session/session-purge.js.map +1 -0
  102. package/dist-server/service/chat-session/session-retention.d.ts +6 -0
  103. package/dist-server/service/chat-session/session-retention.js +20 -0
  104. package/dist-server/service/chat-session/session-retention.js.map +1 -0
  105. package/dist-server/service/chat-session/session-visibility.d.ts +8 -0
  106. package/dist-server/service/chat-session/session-visibility.js +11 -0
  107. package/dist-server/service/chat-session/session-visibility.js.map +1 -1
  108. package/dist-server/service/chat-session/session-write-gate.d.ts +11 -0
  109. package/dist-server/service/chat-session/session-write-gate.js +24 -0
  110. package/dist-server/service/chat-session/session-write-gate.js.map +1 -0
  111. package/dist-server/service/chat-session-participant/invite-notification.d.ts +1 -1
  112. package/dist-server/service/chat-session-participant/invite-notification.js +21 -6
  113. package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -1
  114. package/dist-server/service/chat-session-participant/participant-lookup-roles.d.ts +3 -0
  115. package/dist-server/service/chat-session-participant/participant-lookup-roles.js +10 -0
  116. package/dist-server/service/chat-session-participant/participant-lookup-roles.js.map +1 -0
  117. package/dist-server/service/chat-session-participant/participant-lookup.d.ts +2 -0
  118. package/dist-server/service/chat-session-participant/participant-lookup.js +26 -0
  119. package/dist-server/service/chat-session-participant/participant-lookup.js.map +1 -0
  120. package/dist-server/service/chat-session-resolver.d.ts +17 -2
  121. package/dist-server/service/chat-session-resolver.js +135 -15
  122. package/dist-server/service/chat-session-resolver.js.map +1 -1
  123. package/dist-server/service/index.d.ts +5 -0
  124. package/dist-server/service/index.js +5 -0
  125. package/dist-server/service/index.js.map +1 -1
  126. package/dist-server/tsconfig.tsbuildinfo +1 -1
  127. package/package.json +9 -2
  128. package/server/service/assistant-chat-resolver.ts +71 -4
  129. package/server/service/assistant-mode/assistant-mode-resolver.ts +6 -0
  130. package/server/service/assistant-mode/resolve.ts +9 -2
  131. package/server/service/assistant-mode/types.ts +6 -0
  132. package/server/service/chat-message/chat-message.ts +17 -0
  133. package/server/service/chat-session/attachment-policy.test.ts +42 -0
  134. package/server/service/chat-session/attachment-policy.ts +68 -0
  135. package/server/service/chat-session/attachment-route.ts +45 -0
  136. package/server/service/chat-session/attachment-tools.ts +63 -0
  137. package/server/service/chat-session/session-attachments.ts +89 -0
  138. package/server/service/chat-session/session-inbox.test.ts +71 -1
  139. package/server/service/chat-session/session-inbox.ts +61 -11
  140. package/server/service/chat-session/session-purge.test.ts +24 -0
  141. package/server/service/chat-session/session-purge.ts +81 -0
  142. package/server/service/chat-session/session-retention.ts +17 -0
  143. package/server/service/chat-session/session-visibility.ts +11 -0
  144. package/server/service/chat-session/session-write-gate.test.ts +53 -0
  145. package/server/service/chat-session/session-write-gate.ts +31 -0
  146. package/server/service/chat-session-participant/invite-notification.test.ts +15 -0
  147. package/server/service/chat-session-participant/invite-notification.ts +24 -6
  148. package/server/service/chat-session-participant/participant-lookup-roles.ts +6 -0
  149. package/server/service/chat-session-participant/participant-lookup.ts +22 -0
  150. package/server/service/chat-session-resolver.ts +130 -13
  151. package/server/service/index.ts +5 -0
  152. package/test/assistant-dock.test.ts +118 -0
  153. package/test/assistant-session-manager.test.ts +126 -0
  154. package/test/attachment-policy-agree.test.ts +32 -0
  155. package/test/open-doors.test.ts +5 -2
  156. package/translations/en.json +61 -1
  157. package/translations/ja.json +61 -1
  158. package/translations/ko.json +61 -1
  159. package/translations/ms.json +61 -1
  160. package/translations/zh.json +61 -1
@@ -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":";AAAA;;;;;;;;;;;;;;;GAeG;;;AA8DH,gCAkCC;AAWD,kCAUC;AA8BD,kCASC;AAED,kCAMC;AAMD,kDAEC;AAMD,sCAQC;AAWD,0CAOC;AA1MD,mEAAwD;AA2CxD,SAAS,IAAI,CAAC,CAAmC;IAC/C,IAAI,CAAC,CAAC;QAAE,OAAO,CAAC,CAAA;IAChB,MAAM,CAAC,GAAG,CAAC,YAAY,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;IACzD,OAAO,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;AACnC,CAAC;AAED;;;;;;;;;;GAUG;AACH,SAAgB,UAAU,CAAC,QAA2B,EAAE,IAAuB;IAC7E,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAA;IACxC,MAAM,aAAa,GAAG,IAAI,GAAG,EAAU,CAAA;IACvC,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAA;IAC9B,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;QACrB,IAAI,CAAC,CAAC,CAAC,SAAS;YAAE,SAAQ;QAC1B,aAAa,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC,CAAA;QAC9B,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAA;QAC3C,IAAI,CAAC,CAAC,IAAI,KAAK,OAAO;YAAE,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC,CAAA;IAC/C,CAAC;IAED,MAAM,OAAO,GAAG,QAAQ;QACtB;;;WAGG;SACF,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC,IAAA,sCAAc,EAAC,OAAO,CAAC,UAAU,EAAE,aAAa,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;SAC1F,GAAG,CAAC,OAAO,CAAC,EAAE;QACb,MAAM,EAAE,GAAG,OAAO,CAAC,EAAE,IAAI,EAAE,CAAA;QAC3B,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,CAAA;QAC5C,MAAM,WAAW,GAAG,aAAa,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QACzC,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,CAAA;QAChC,OAAO;YACL,GAAG,OAAO;YACV,WAAW;YACX,UAAU,EAAE,OAAO,CAAC,UAAU,IAAI,MAAM;YACxC,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;YACnB,MAAM,EAAE,WAAW,IAAI,QAAQ,GAAG,CAAC,IAAI,QAAQ,GAAG,IAAI;SACvD,CAAA;IACH,CAAC,CAAC,CAAA;IAEJ,OAAO,OAAO,CAAC,IAAI,CACjB,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,aAAa,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,aAAa,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CACtG,CAAA;AACH,CAAC;AAED;;;;;;;;GAQG;AACH,SAAgB,WAAW,CAAC,OAAqB,EAAE,QAAuD,EAAE,UAAU,GAAG,KAAK;IAC5H,MAAM,GAAG,GAAiB,EAAE,CAAA;IAC5B,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;QAC5B,MAAM,EAAE,GAAG,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC,CAAA;QACvC,2CAA2C;QAC3C,MAAM,MAAM,GAAG,CAAC,CAAC,EAAE,IAAI,IAAI,CAAC,KAAK,CAAC,aAAa,CAAC,IAAI,IAAI,CAAC,EAAE,CAAC,CAAA;QAC5D,IAAI,MAAM,IAAI,CAAC,UAAU;YAAE,SAAQ;QACnC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,GAAG,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAA;IACvD,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAyBD;;;;GAIG;AACH,SAAgB,WAAW,CAA4C,OAAY,EAAE,SAAsB,EAAE;IAC3G,OAAO,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE;QAC5B,IAAI,MAAM,CAAC,IAAI,IAAI,KAAK,CAAC,IAAI,IAAI,KAAK,CAAC,IAAI,KAAK,MAAM,CAAC,IAAI;YAAE,OAAO,KAAK,CAAA;QACzE,IAAI,MAAM,CAAC,QAAQ,IAAI,CAAC,KAAK,CAAC,IAAI;YAAE,OAAO,KAAK,CAAA;QAChD,IAAI,MAAM,CAAC,eAAe,IAAI,CAAC,KAAK,CAAC,WAAW;YAAE,OAAO,KAAK,CAAA;QAC9D,IAAI,MAAM,CAAC,UAAU,IAAI,CAAC,KAAK,CAAC,UAAU,IAAI,MAAM,CAAC,KAAK,MAAM,CAAC,UAAU;YAAE,OAAO,KAAK,CAAA;QACzF,IAAI,MAAM,CAAC,UAAU,IAAI,CAAC,KAAK,CAAC,MAAM;YAAE,OAAO,KAAK,CAAA;QACpD,OAAO,IAAI,CAAA;IACb,CAAC,CAAC,CAAA;AACJ,CAAC;AAED,SAAgB,WAAW,CAAC,OAAqB,EAAE,MAAe;IAChE,MAAM,CAAC,GAAG,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAA;IAC7C,IAAI,CAAC,CAAC;QAAE,OAAO,OAAO,CAAA;IACtB,OAAO,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CACxB,GAAG,CAAC,CAAC,IAAI,IAAI,EAAE,KAAK,CAAC,CAAC,kBAAkB,IAAI,EAAE,EAAE,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,CAC3E,CAAA;AACH,CAAC;AAED,4CAA4C;AAC/B,QAAA,cAAc,GAAG,GAAG,CAAA;AAEjC,8CAA8C;AAC9C,SAAgB,mBAAmB,CAAC,OAAqB;IACvD,OAAO,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,CAAA;AAC7C,CAAC;AAED;;;GAGG;AACH,SAAgB,aAAa,CAC3B,OAAe,EACf,KAAW,IAAI,IAAI,EAAE,EACrB,QAAkB,EAAE;IAEpB,MAAM,IAAI,GAAG,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,CAAA;IACxD,iEAAiE;IACjE,OAAO,EAAE,aAAa,EAAE,EAAE,EAAE,kBAAkB,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,gBAAgB,EAAE,KAAK,EAAE,CAAA;AAC/F,CAAC;AAED;;;;;;;;GAQG;AACH,SAAgB,eAAe,CAAC,OAAe,EAAE,GAAG,GAAG,EAAE;IACvD,MAAM,IAAI,GAAG,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,CAAA;IACxD,IAAI,CAAC,IAAI;QAAE,OAAO,EAAE,CAAA;IACpB,MAAM,GAAG,GAAG,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAA;IAClC,MAAM,IAAI,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,CAAA;IACzD,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAA;IACvE,OAAO,IAAI,CAAA;AACb,CAAC","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;
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PURGE_BATCH = exports.retentionDaysOf = exports.purgeCutoff = exports.DEFAULT_RETENTION_DAYS = void 0;
4
+ exports.purgeDeletedSessions = purgeDeletedSessions;
5
+ exports.schedulePurge = schedulePurge;
6
+ /*
7
+ * Emptying deleted conversations after they have been kept long enough - design/ai/shared-dock.md §9-5.
8
+ *
9
+ * `deleteAISession` is a soft delete: from that moment nobody can open the conversation or its files
10
+ * (every door checks the session), and within the retention period it can still be brought back whole.
11
+ * Nothing ever emptied them afterwards, so a deleted conversation's messages - and now its files - stayed
12
+ * forever (2026-09-25). This job removes a conversation, its messages, its participants and its attached
13
+ * files together once the retention period has passed.
14
+ *
15
+ * Retention: config `ai-assistant.sessionRetentionDays`, default 30. `0` or `false` turns the job off.
16
+ */
17
+ const env_1 = require("@things-factory/env");
18
+ const shell_1 = require("@things-factory/shell");
19
+ const typeorm_1 = require("typeorm");
20
+ const chat_message_js_1 = require("../chat-message/chat-message.js");
21
+ const chat_session_participant_js_1 = require("../chat-session-participant/chat-session-participant.js");
22
+ const chat_session_js_1 = require("./chat-session.js");
23
+ const session_attachments_js_1 = require("./session-attachments.js");
24
+ const session_retention_js_1 = require("./session-retention.js");
25
+ var session_retention_js_2 = require("./session-retention.js");
26
+ Object.defineProperty(exports, "DEFAULT_RETENTION_DAYS", { enumerable: true, get: function () { return session_retention_js_2.DEFAULT_RETENTION_DAYS; } });
27
+ Object.defineProperty(exports, "purgeCutoff", { enumerable: true, get: function () { return session_retention_js_2.purgeCutoff; } });
28
+ Object.defineProperty(exports, "retentionDaysOf", { enumerable: true, get: function () { return session_retention_js_2.retentionDaysOf; } });
29
+ /** How many conversations one run empties - a large backlog is worked off over several runs. */
30
+ exports.PURGE_BATCH = 100;
31
+ /** Empties the conversations whose deletion is older than the retention period. */
32
+ async function purgeDeletedSessions(now = new Date(), retentionDays = (0, session_retention_js_1.retentionDaysOf)(env_1.config.get('ai-assistant', {})?.sessionRetentionDays)) {
33
+ const result = { purged: 0, failed: [] };
34
+ if (retentionDays === null)
35
+ return result;
36
+ const sessions = await (0, shell_1.getRepository)(chat_session_js_1.ChatSession).find({
37
+ where: { deletedAt: (0, typeorm_1.LessThan)((0, session_retention_js_1.purgeCutoff)(now, retentionDays)) },
38
+ withDeleted: true,
39
+ relations: ['domain'],
40
+ take: exports.PURGE_BATCH
41
+ });
42
+ for (const session of sessions) {
43
+ try {
44
+ await (0, session_attachments_js_1.removeSessionFiles)(session);
45
+ /* Messages point at each other (parentMessage) - drop the links before the rows. */
46
+ await (0, shell_1.getRepository)(chat_message_js_1.ChatMessage).update({ session: { id: session.id }, parentMessage: (0, typeorm_1.Not)((0, typeorm_1.IsNull)()) }, { parentMessage: null });
47
+ await (0, shell_1.getRepository)(chat_message_js_1.ChatMessage).delete({ session: { id: session.id } });
48
+ await (0, shell_1.getRepository)(chat_session_participant_js_1.ChatSessionParticipant).delete({ session: { id: session.id } });
49
+ /* Board patches and anything else hanging on the session go with it (ON DELETE CASCADE). */
50
+ await (0, shell_1.getRepository)(chat_session_js_1.ChatSession).delete({ id: (0, typeorm_1.In)([session.id]) });
51
+ result.purged++;
52
+ }
53
+ catch (error) {
54
+ /* One conversation that cannot be emptied must not stop the rest; it is tried again next run. */
55
+ result.failed.push({ sessionId: session.id, message: error.message });
56
+ }
57
+ }
58
+ return result;
59
+ }
60
+ let timer;
61
+ /** Runs the job once a little after boot, then daily. The timer never keeps the process alive. */
62
+ function schedulePurge() {
63
+ if (timer)
64
+ return;
65
+ const run = () => purgeDeletedSessions()
66
+ .then(({ purged, failed }) => {
67
+ if (purged || failed.length)
68
+ console.info(`[ai-assistant] purged ${purged} deleted conversation(s)`, failed.length ? { failed } : '');
69
+ })
70
+ .catch(error => console.warn('[ai-assistant] purging deleted conversations failed:', error.message));
71
+ const first = setTimeout(run, 60 * 1000);
72
+ first.unref?.();
73
+ timer = setInterval(run, session_retention_js_1.DAY_MS);
74
+ timer.unref?.();
75
+ }
76
+ process.on('bootstrap-module-start', () => schedulePurge());
77
+ //# sourceMappingURL=session-purge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-purge.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-purge.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Emptying deleted conversations after they have been kept long enough - design/ai/shared-dock.md §9-5.\n *\n * `deleteAISession` is a soft delete: from that moment nobody can open the conversation or its files\n * (every door checks the session), and within the retention period it can still be brought back whole.\n * Nothing ever emptied them afterwards, so a deleted conversation's messages - and now its files - stayed\n * forever (2026-09-25). This job removes a conversation, its messages, its participants and its attached\n * files together once the retention period has passed.\n *\n * Retention: config `ai-assistant.sessionRetentionDays`, default 30. `0` or `false` turns the job off.\n */\nimport { config } from '@things-factory/env'\nimport { getRepository } from '@things-factory/shell'\nimport { In, IsNull, LessThan, Not } from 'typeorm'\n\nimport { ChatMessage } from '../chat-message/chat-message.js'\nimport { ChatSessionParticipant } from '../chat-session-participant/chat-session-participant.js'\nimport { ChatSession } from './chat-session.js'\nimport { removeSessionFiles } from './session-attachments.js'\n\nimport { DAY_MS, purgeCutoff, retentionDaysOf } from './session-retention.js'\n\nexport { DEFAULT_RETENTION_DAYS, purgeCutoff, retentionDaysOf } from './session-retention.js'\n/** How many conversations one run empties - a large backlog is worked off over several runs. */\nexport const PURGE_BATCH = 100\n\nexport interface PurgeResult {\n purged: number\n failed: { sessionId: string; message: string }[]\n}\n\n/** Empties the conversations whose deletion is older than the retention period. */\nexport async function purgeDeletedSessions(now = new Date(), retentionDays = retentionDaysOf(config.get('ai-assistant', {})?.sessionRetentionDays)): Promise<PurgeResult> {\n const result: PurgeResult = { purged: 0, failed: [] }\n if (retentionDays === null) return result\n\n const sessions = await getRepository(ChatSession).find({\n where: { deletedAt: LessThan(purgeCutoff(now, retentionDays)) } as any,\n withDeleted: true,\n relations: ['domain'],\n take: PURGE_BATCH\n })\n\n for (const session of sessions) {\n try {\n await removeSessionFiles(session as any)\n /* Messages point at each other (parentMessage) - drop the links before the rows. */\n await getRepository(ChatMessage).update({ session: { id: session.id }, parentMessage: Not(IsNull()) } as any, { parentMessage: null } as any)\n await getRepository(ChatMessage).delete({ session: { id: session.id } } as any)\n await getRepository(ChatSessionParticipant).delete({ session: { id: session.id } } as any)\n /* Board patches and anything else hanging on the session go with it (ON DELETE CASCADE). */\n await getRepository(ChatSession).delete({ id: In([session.id]) } as any)\n result.purged++\n } catch (error) {\n /* One conversation that cannot be emptied must not stop the rest; it is tried again next run. */\n result.failed.push({ sessionId: session.id!, message: (error as Error).message })\n }\n }\n\n return result\n}\n\nlet timer: ReturnType<typeof setInterval> | undefined\n\n/** Runs the job once a little after boot, then daily. The timer never keeps the process alive. */\nexport function schedulePurge(): void {\n if (timer) return\n const run = () =>\n purgeDeletedSessions()\n .then(({ purged, failed }) => {\n if (purged || failed.length) console.info(`[ai-assistant] purged ${purged} deleted conversation(s)`, failed.length ? { failed } : '')\n })\n .catch(error => console.warn('[ai-assistant] purging deleted conversations failed:', (error as Error).message))\n\n const first = setTimeout(run, 60 * 1000)\n first.unref?.()\n timer = setInterval(run, DAY_MS)\n timer.unref?.()\n}\n\nprocess.on('bootstrap-module-start' as any, () => schedulePurge())\n"]}
@@ -0,0 +1,6 @@
1
+ export declare const DAY_MS: number;
2
+ export declare const DEFAULT_RETENTION_DAYS = 30;
3
+ /** The retention the config asks for: a positive number of days, `null` when the job is off, the default when unreadable. */
4
+ export declare function retentionDaysOf(value: unknown): number | null;
5
+ /** Conversations deleted before this moment are due. */
6
+ export declare function purgeCutoff(now: Date, retentionDays: number): Date;
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ /* How long a deleted conversation is kept before it is emptied - pure, tested alone (shared-dock.md §9-5). */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.DEFAULT_RETENTION_DAYS = exports.DAY_MS = void 0;
5
+ exports.retentionDaysOf = retentionDaysOf;
6
+ exports.purgeCutoff = purgeCutoff;
7
+ exports.DAY_MS = 24 * 60 * 60 * 1000;
8
+ exports.DEFAULT_RETENTION_DAYS = 30;
9
+ /** The retention the config asks for: a positive number of days, `null` when the job is off, the default when unreadable. */
10
+ function retentionDaysOf(value) {
11
+ if (value === false || value === 0 || value === '0')
12
+ return null;
13
+ const days = Number(value);
14
+ return Number.isFinite(days) && days > 0 ? days : exports.DEFAULT_RETENTION_DAYS;
15
+ }
16
+ /** Conversations deleted before this moment are due. */
17
+ function purgeCutoff(now, retentionDays) {
18
+ return new Date(now.getTime() - retentionDays * exports.DAY_MS);
19
+ }
20
+ //# sourceMappingURL=session-retention.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-retention.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-retention.ts"],"names":[],"mappings":";AAAA,8GAA8G;;;AAM9G,0CAIC;AAGD,kCAEC;AAbY,QAAA,MAAM,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;AAC5B,QAAA,sBAAsB,GAAG,EAAE,CAAA;AAExC,6HAA6H;AAC7H,SAAgB,eAAe,CAAC,KAAc;IAC5C,IAAI,KAAK,KAAK,KAAK,IAAI,KAAK,KAAK,CAAC,IAAI,KAAK,KAAK,GAAG;QAAE,OAAO,IAAI,CAAA;IAChE,MAAM,IAAI,GAAG,MAAM,CAAC,KAAK,CAAC,CAAA;IAC1B,OAAO,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,8BAAsB,CAAA;AAC1E,CAAC;AAED,wDAAwD;AACxD,SAAgB,WAAW,CAAC,GAAS,EAAE,aAAqB;IAC1D,OAAO,IAAI,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,aAAa,GAAG,cAAM,CAAC,CAAA;AACzD,CAAC","sourcesContent":["/* How long a deleted conversation is kept before it is emptied - pure, tested alone (shared-dock.md §9-5). */\n\nexport const DAY_MS = 24 * 60 * 60 * 1000\nexport const DEFAULT_RETENTION_DAYS = 30\n\n/** The retention the config asks for: a positive number of days, `null` when the job is off, the default when unreadable. */\nexport function retentionDaysOf(value: unknown): number | null {\n if (value === false || value === 0 || value === '0') return null\n const days = Number(value)\n return Number.isFinite(days) && days > 0 ? days : DEFAULT_RETENTION_DAYS\n}\n\n/** Conversations deleted before this moment are due. */\nexport function purgeCutoff(now: Date, retentionDays: number): Date {\n return new Date(now.getTime() - retentionDays * DAY_MS)\n}\n\n"]}
@@ -13,3 +13,11 @@ export declare function initialVisibility(session: {
13
13
  }): SessionVisibility;
14
14
  /** 이 사람이 이 대화를 읽을 수 있나. open 이면 도메인 안 누구나, private 이면 참여자만. */
15
15
  export declare function mayReadSession(visibility: string | null | undefined, isParticipant: boolean): boolean;
16
+ /**
17
+ * 이 사람이 이 대화에 **쓸** 수 있나 — 읽을 수 있는 사람만 쓴다.
18
+ *
19
+ * 읽는 문(`chatMessages`)과 목록만 막고 쓰는 문을 열어 두면, 비공개 대화의 id 를 가진 사람이 그 안에 말을
20
+ * 넣을 수 있었다. `assistantChat` 에서는 더 나쁘다 — 답이 그 대화의 이력 위에서 만들어지므로, 읽을 수 없는
21
+ * 내용이 답으로 새어 나간다(2026-09-24, operato-ecs 두 사람 시험 7번). 쓰는 문도 같은 관문을 지난다.
22
+ */
23
+ export declare function mayWriteSession(visibility: string | null | undefined, isParticipant: boolean): boolean;
@@ -8,6 +8,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
8
8
  exports.SESSION_VISIBILITIES = void 0;
9
9
  exports.initialVisibility = initialVisibility;
10
10
  exports.mayReadSession = mayReadSession;
11
+ exports.mayWriteSession = mayWriteSession;
11
12
  exports.SESSION_VISIBILITIES = ['open', 'private'];
12
13
  /**
13
14
  * 이렇게 만들어진 세션은 무엇으로 시작하나.
@@ -32,4 +33,14 @@ function initialVisibility(session) {
32
33
  function mayReadSession(visibility, isParticipant) {
33
34
  return (visibility ?? 'open') === 'open' || isParticipant;
34
35
  }
36
+ /**
37
+ * 이 사람이 이 대화에 **쓸** 수 있나 — 읽을 수 있는 사람만 쓴다.
38
+ *
39
+ * 읽는 문(`chatMessages`)과 목록만 막고 쓰는 문을 열어 두면, 비공개 대화의 id 를 가진 사람이 그 안에 말을
40
+ * 넣을 수 있었다. `assistantChat` 에서는 더 나쁘다 — 답이 그 대화의 이력 위에서 만들어지므로, 읽을 수 없는
41
+ * 내용이 답으로 새어 나간다(2026-09-24, operato-ecs 두 사람 시험 7번). 쓰는 문도 같은 관문을 지난다.
42
+ */
43
+ function mayWriteSession(visibility, isParticipant) {
44
+ return mayReadSession(visibility, isParticipant);
45
+ }
35
46
  //# sourceMappingURL=session-visibility.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"session-visibility.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-visibility.ts"],"names":[],"mappings":";AAAA;;;;GAIG;;;AAaH,8CAWC;AAGD,wCAEC;AAzBY,QAAA,oBAAoB,GAAiC,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;AAErF;;;;;;GAMG;AACH,SAAgB,iBAAiB,CAAC,OAA+D;IAC/F,IAAI,OAAO,CAAC,MAAM;QAAE,OAAO,SAAS,CAAA;IAEpC;;;;;OAKG;IACH,MAAM,QAAQ,GAAG,CAAC,CAAC,OAAO,CAAC,UAAU,IAAI,OAAO,CAAC,UAAU,KAAK,MAAM,CAAA;IACtE,OAAO,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAA;AACtC,CAAC;AAED,+DAA+D;AAC/D,SAAgB,cAAc,CAAC,UAAqC,EAAE,aAAsB;IAC1F,OAAO,CAAC,UAAU,IAAI,MAAM,CAAC,KAAK,MAAM,IAAI,aAAa,CAAA;AAC3D,CAAC","sourcesContent":["/*\n * 새 대화가 공개로 태어나는가 — ADR-0073 판정 2.\n *\n * 판정을 세션을 만드는 자리마다 적으면 한 곳이 빠진 날 그 세션만 다른 규칙으로 산다. 한 줄로 모은다.\n */\n\nexport type SessionVisibility = 'open' | 'private'\n\nexport const SESSION_VISIBILITIES: readonly SessionVisibility[] = ['open', 'private']\n\n/**\n * 이렇게 만들어진 세션은 무엇으로 시작하나.\n *\n * 앵커가 있으면 공유된 대상을 놓고 하는 이야기다 — 공개. 앵커가 없거나 앞선 대화를 옮겨 적어 만든\n * 것이면 비공개. `copyOf` 가 있다는 것은 다른 대화의 원문이 이 안에 들어왔다는 뜻이고, 그 원문은\n * 자기가 있던 자리의 규칙으로 쓰였다.\n */\nexport function initialVisibility(session: { anchorType?: string | null; copyOf?: string | null }): SessionVisibility {\n if (session.copyOf) return 'private'\n\n /*\n * `'none'` 은 **앵커가 아니다.** 저장되는 앵커 종류 목록에 그 글자가 들어 있어서(`session-anchor.ts`\n * — 대상이 없다는 것도 행에 적어야 하므로), 「값이 있으면 앵커가 있다」로 읽으면 대상 없는 개인 질문이\n * 공개 방으로 태어난다. ADR-0073 결정 2 가 그것을 `private` 로 못박았다 — 「앵커 없는 세션(`twin.none`\n * 같은 개인 질문)」.\n */\n const anchored = !!session.anchorType && session.anchorType !== 'none'\n return anchored ? 'open' : 'private'\n}\n\n/** 이 사람이 이 대화를 읽을 수 있나. open 이면 도메인 안 누구나, private 이면 참여자만. */\nexport function mayReadSession(visibility: string | null | undefined, isParticipant: boolean): boolean {\n return (visibility ?? 'open') === 'open' || isParticipant\n}\n"]}
1
+ {"version":3,"file":"session-visibility.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-visibility.ts"],"names":[],"mappings":";AAAA;;;;GAIG;;;AAaH,8CAWC;AAGD,wCAEC;AASD,0CAEC;AApCY,QAAA,oBAAoB,GAAiC,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;AAErF;;;;;;GAMG;AACH,SAAgB,iBAAiB,CAAC,OAA+D;IAC/F,IAAI,OAAO,CAAC,MAAM;QAAE,OAAO,SAAS,CAAA;IAEpC;;;;;OAKG;IACH,MAAM,QAAQ,GAAG,CAAC,CAAC,OAAO,CAAC,UAAU,IAAI,OAAO,CAAC,UAAU,KAAK,MAAM,CAAA;IACtE,OAAO,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAA;AACtC,CAAC;AAED,+DAA+D;AAC/D,SAAgB,cAAc,CAAC,UAAqC,EAAE,aAAsB;IAC1F,OAAO,CAAC,UAAU,IAAI,MAAM,CAAC,KAAK,MAAM,IAAI,aAAa,CAAA;AAC3D,CAAC;AAED;;;;;;GAMG;AACH,SAAgB,eAAe,CAAC,UAAqC,EAAE,aAAsB;IAC3F,OAAO,cAAc,CAAC,UAAU,EAAE,aAAa,CAAC,CAAA;AAClD,CAAC","sourcesContent":["/*\n * 새 대화가 공개로 태어나는가 — ADR-0073 판정 2.\n *\n * 판정을 세션을 만드는 자리마다 적으면 한 곳이 빠진 날 그 세션만 다른 규칙으로 산다. 한 줄로 모은다.\n */\n\nexport type SessionVisibility = 'open' | 'private'\n\nexport const SESSION_VISIBILITIES: readonly SessionVisibility[] = ['open', 'private']\n\n/**\n * 이렇게 만들어진 세션은 무엇으로 시작하나.\n *\n * 앵커가 있으면 공유된 대상을 놓고 하는 이야기다 — 공개. 앵커가 없거나 앞선 대화를 옮겨 적어 만든\n * 것이면 비공개. `copyOf` 가 있다는 것은 다른 대화의 원문이 이 안에 들어왔다는 뜻이고, 그 원문은\n * 자기가 있던 자리의 규칙으로 쓰였다.\n */\nexport function initialVisibility(session: { anchorType?: string | null; copyOf?: string | null }): SessionVisibility {\n if (session.copyOf) return 'private'\n\n /*\n * `'none'` 은 **앵커가 아니다.** 저장되는 앵커 종류 목록에 그 글자가 들어 있어서(`session-anchor.ts`\n * — 대상이 없다는 것도 행에 적어야 하므로), 「값이 있으면 앵커가 있다」로 읽으면 대상 없는 개인 질문이\n * 공개 방으로 태어난다. ADR-0073 결정 2 가 그것을 `private` 로 못박았다 — 「앵커 없는 세션(`twin.none`\n * 같은 개인 질문)」.\n */\n const anchored = !!session.anchorType && session.anchorType !== 'none'\n return anchored ? 'open' : 'private'\n}\n\n/** 이 사람이 이 대화를 읽을 수 있나. open 이면 도메인 안 누구나, private 이면 참여자만. */\nexport function mayReadSession(visibility: string | null | undefined, isParticipant: boolean): boolean {\n return (visibility ?? 'open') === 'open' || isParticipant\n}\n\n/**\n * 이 사람이 이 대화에 **쓸** 수 있나 — 읽을 수 있는 사람만 쓴다.\n *\n * 읽는 문(`chatMessages`)과 목록만 막고 쓰는 문을 열어 두면, 비공개 대화의 id 를 가진 사람이 그 안에 말을\n * 넣을 수 있었다. `assistantChat` 에서는 더 나쁘다 — 답이 그 대화의 이력 위에서 만들어지므로, 읽을 수 없는\n * 내용이 답으로 새어 나간다(2026-09-24, operato-ecs 두 사람 시험 7번). 쓰는 문도 같은 관문을 지난다.\n */\nexport function mayWriteSession(visibility: string | null | undefined, isParticipant: boolean): boolean {\n return mayReadSession(visibility, isParticipant)\n}\n"]}
@@ -0,0 +1,11 @@
1
+ export interface WriteGateInput {
2
+ session: {
3
+ id?: string;
4
+ visibility?: string | null;
5
+ };
6
+ userId?: string;
7
+ /** Is this user a participant of the session (owner or member)? Asked only for a private one. */
8
+ isParticipant: () => Promise<boolean>;
9
+ t: (key: string, options?: Record<string, unknown>) => string;
10
+ }
11
+ export declare function assertMayWriteSession({ session, userId, isParticipant, t }: WriteGateInput): Promise<void>;
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.assertMayWriteSession = assertMayWriteSession;
4
+ /*
5
+ * The gate every door that writes into a conversation passes - ADR-0073 decision 1, applied to writing.
6
+ *
7
+ * `mayWriteSession` is the rule; this looks up only what the rule needs (whether the writer is in the
8
+ * room, and only for a private conversation) and refuses by a named message. Kept apart from the resolvers
9
+ * so the five write doors call one line and cannot drift.
10
+ */
11
+ const session_visibility_js_1 = require("./session-visibility.js");
12
+ async function assertMayWriteSession({ session, userId, isParticipant, t }) {
13
+ const visibility = session.visibility ?? 'open';
14
+ /* A public conversation is open to everyone in the domain - no lookup. */
15
+ if (visibility === 'open')
16
+ return;
17
+ const participant = !!userId && (await isParticipant());
18
+ if (!(0, session_visibility_js_1.mayWriteSession)(visibility, participant)) {
19
+ throw new Error(t('ai-assistant.text.private-session-refused', {
20
+ defaultValue: 'this conversation is private - only the people in it can write here. ask one of them to bring you in.'
21
+ }));
22
+ }
23
+ }
24
+ //# sourceMappingURL=session-write-gate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-write-gate.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-write-gate.ts"],"names":[],"mappings":";;AAiBA,sDAaC;AA9BD;;;;;;GAMG;AACH,mEAAyD;AAUlD,KAAK,UAAU,qBAAqB,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,CAAC,EAAkB;IAC/F,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,MAAM,CAAA;IAC/C,0EAA0E;IAC1E,IAAI,UAAU,KAAK,MAAM;QAAE,OAAM;IAEjC,MAAM,WAAW,GAAG,CAAC,CAAC,MAAM,IAAI,CAAC,MAAM,aAAa,EAAE,CAAC,CAAA;IACvD,IAAI,CAAC,IAAA,uCAAe,EAAC,UAAU,EAAE,WAAW,CAAC,EAAE,CAAC;QAC9C,MAAM,IAAI,KAAK,CACb,CAAC,CAAC,2CAA2C,EAAE;YAC7C,YAAY,EAAE,uGAAuG;SACtH,CAAC,CACH,CAAA;IACH,CAAC;AACH,CAAC","sourcesContent":["/*\n * The gate every door that writes into a conversation passes - ADR-0073 decision 1, applied to writing.\n *\n * `mayWriteSession` is the rule; this looks up only what the rule needs (whether the writer is in the\n * room, and only for a private conversation) and refuses by a named message. Kept apart from the resolvers\n * so the five write doors call one line and cannot drift.\n */\nimport { mayWriteSession } from './session-visibility.js'\n\nexport interface WriteGateInput {\n session: { id?: string; visibility?: string | null }\n userId?: string\n /** Is this user a participant of the session (owner or member)? Asked only for a private one. */\n isParticipant: () => Promise<boolean>\n t: (key: string, options?: Record<string, unknown>) => string\n}\n\nexport async function assertMayWriteSession({ session, userId, isParticipant, t }: WriteGateInput): Promise<void> {\n const visibility = session.visibility ?? 'open'\n /* A public conversation is open to everyone in the domain - no lookup. */\n if (visibility === 'open') return\n\n const participant = !!userId && (await isParticipant())\n if (!mayWriteSession(visibility, participant)) {\n throw new Error(\n t('ai-assistant.text.private-session-refused', {\n defaultValue: 'this conversation is private - only the people in it can write here. ask one of them to bring you in.'\n })\n )\n }\n}\n"]}
@@ -24,4 +24,4 @@ export interface Inviter {
24
24
  * 초대된 사용자들에게 보낼 협업 초대 알림 payload 를 만든다.
25
25
  * 받는 사람이 없으면 null (= 보낼 것 없음).
26
26
  */
27
- export declare function buildInviteNotification(userIds: string[], boardId: string | undefined, inviter: Inviter | undefined | null): InviteNotification | null;
27
+ export declare function buildInviteNotification(userIds: string[], boardId: string | undefined, inviter: Inviter | undefined | null, sessionName?: string | null): InviteNotification | null;