@things-factory/ai-assistant 10.1.27 → 10.1.29

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 (210) hide show
  1. package/client/components/assistant-chat.ts +701 -63
  2. package/client/components/assistant-mode.test.ts +105 -55
  3. package/client/components/assistant-mode.ts +144 -82
  4. package/client/components/chat-echo-dedup.test.ts +23 -0
  5. package/client/components/chat-echo-dedup.ts +29 -3
  6. package/client/components/chat-input-builder.test.ts +20 -20
  7. package/client/components/chat-input-builder.ts +16 -14
  8. package/client/components/chat-proposal-survival.test.ts +73 -0
  9. package/client/components/mention-popup.ts +31 -8
  10. package/client/components/panel-aria.test.ts +54 -0
  11. package/client/components/proposal-outcome-line.ts +33 -0
  12. package/client/utils/assistant-session-controller.test.ts +75 -0
  13. package/client/utils/assistant-session-controller.ts +37 -1
  14. package/client/utils/assistant-session-transport.ts +13 -5
  15. package/dist-client/components/assistant-chat.d.ts +140 -13
  16. package/dist-client/components/assistant-chat.js +659 -71
  17. package/dist-client/components/assistant-chat.js.map +1 -1
  18. package/dist-client/components/assistant-mode.d.ts +102 -68
  19. package/dist-client/components/assistant-mode.js +79 -19
  20. package/dist-client/components/assistant-mode.js.map +1 -1
  21. package/dist-client/components/assistant-mode.test.js +90 -53
  22. package/dist-client/components/assistant-mode.test.js.map +1 -1
  23. package/dist-client/components/chat-echo-dedup.d.ts +2 -0
  24. package/dist-client/components/chat-echo-dedup.js +27 -4
  25. package/dist-client/components/chat-echo-dedup.js.map +1 -1
  26. package/dist-client/components/chat-echo-dedup.test.js +20 -0
  27. package/dist-client/components/chat-echo-dedup.test.js.map +1 -1
  28. package/dist-client/components/chat-input-builder.d.ts +12 -7
  29. package/dist-client/components/chat-input-builder.js +8 -10
  30. package/dist-client/components/chat-input-builder.js.map +1 -1
  31. package/dist-client/components/chat-input-builder.test.js +17 -18
  32. package/dist-client/components/chat-input-builder.test.js.map +1 -1
  33. package/dist-client/components/chat-proposal-survival.test.d.ts +1 -0
  34. package/dist-client/components/chat-proposal-survival.test.js +61 -0
  35. package/dist-client/components/chat-proposal-survival.test.js.map +1 -0
  36. package/dist-client/components/mention-popup.js +28 -8
  37. package/dist-client/components/mention-popup.js.map +1 -1
  38. package/dist-client/components/panel-aria.test.d.ts +1 -0
  39. package/dist-client/components/panel-aria.test.js +49 -0
  40. package/dist-client/components/panel-aria.test.js.map +1 -0
  41. package/dist-client/components/proposal-outcome-line.d.ts +9 -0
  42. package/dist-client/components/proposal-outcome-line.js +15 -0
  43. package/dist-client/components/proposal-outcome-line.js.map +1 -0
  44. package/dist-client/tsconfig.tsbuildinfo +1 -1
  45. package/dist-client/utils/assistant-session-controller.d.ts +20 -0
  46. package/dist-client/utils/assistant-session-controller.js +16 -0
  47. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  48. package/dist-client/utils/assistant-session-controller.test.d.ts +1 -0
  49. package/dist-client/utils/assistant-session-controller.test.js +56 -0
  50. package/dist-client/utils/assistant-session-controller.test.js.map +1 -0
  51. package/dist-client/utils/assistant-session-transport.js +13 -5
  52. package/dist-client/utils/assistant-session-transport.js.map +1 -1
  53. package/dist-server/door-declaration-report.d.ts +14 -0
  54. package/dist-server/door-declaration-report.js +54 -0
  55. package/dist-server/door-declaration-report.js.map +1 -0
  56. package/dist-server/service/assistant-capability-resolver.d.ts +17 -0
  57. package/dist-server/service/assistant-capability-resolver.js +88 -0
  58. package/dist-server/service/assistant-capability-resolver.js.map +1 -0
  59. package/dist-server/service/assistant-chat-resolver.d.ts +6 -6
  60. package/dist-server/service/assistant-chat-resolver.js +185 -44
  61. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  62. package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +33 -0
  63. package/dist-server/service/assistant-mode/assistant-mode-resolver.js +148 -0
  64. package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -0
  65. package/dist-server/service/assistant-mode/index.d.ts +9 -0
  66. package/dist-server/service/assistant-mode/index.js +19 -0
  67. package/dist-server/service/assistant-mode/index.js.map +1 -0
  68. package/dist-server/service/assistant-mode/registry.d.ts +18 -0
  69. package/dist-server/service/assistant-mode/registry.js +136 -0
  70. package/dist-server/service/assistant-mode/registry.js.map +1 -0
  71. package/dist-server/service/assistant-mode/resolve.d.ts +43 -0
  72. package/dist-server/service/assistant-mode/resolve.js +65 -0
  73. package/dist-server/service/assistant-mode/resolve.js.map +1 -0
  74. package/dist-server/service/assistant-mode/types.d.ts +120 -0
  75. package/dist-server/service/assistant-mode/types.js +12 -0
  76. package/dist-server/service/assistant-mode/types.js.map +1 -0
  77. package/dist-server/service/chat-message/carried-turns.d.ts +48 -0
  78. package/dist-server/service/chat-message/carried-turns.js +92 -0
  79. package/dist-server/service/chat-message/carried-turns.js.map +1 -0
  80. package/dist-server/service/chat-message/chat-message-subscription.d.ts +1 -1
  81. package/dist-server/service/chat-message/chat-message-subscription.js +24 -6
  82. package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -1
  83. package/dist-server/service/chat-message/chat-message.d.ts +32 -0
  84. package/dist-server/service/chat-message/chat-message.js +17 -0
  85. package/dist-server/service/chat-message/chat-message.js.map +1 -1
  86. package/dist-server/service/chat-message/door-access.d.ts +27 -0
  87. package/dist-server/service/chat-message/door-access.js +34 -0
  88. package/dist-server/service/chat-message/door-access.js.map +1 -0
  89. package/dist-server/service/chat-message/fold-session-history.d.ts +30 -0
  90. package/dist-server/service/chat-message/fold-session-history.js +133 -0
  91. package/dist-server/service/chat-message/fold-session-history.js.map +1 -0
  92. package/dist-server/service/chat-message/llm-history.d.ts +7 -0
  93. package/dist-server/service/chat-message/llm-history.js +23 -11
  94. package/dist-server/service/chat-message/llm-history.js.map +1 -1
  95. package/dist-server/service/chat-message/message-doors.d.ts +29 -0
  96. package/dist-server/service/chat-message/message-doors.js +107 -0
  97. package/dist-server/service/chat-message/message-doors.js.map +1 -0
  98. package/dist-server/service/chat-message/proposal-outcome.d.ts +18 -0
  99. package/dist-server/service/chat-message/proposal-outcome.js +31 -0
  100. package/dist-server/service/chat-message/proposal-outcome.js.map +1 -0
  101. package/dist-server/service/chat-message/reader-gate.d.ts +3 -0
  102. package/dist-server/service/chat-message/reader-gate.js +39 -0
  103. package/dist-server/service/chat-message/reader-gate.js.map +1 -0
  104. package/dist-server/service/chat-message/room-message.d.ts +4 -0
  105. package/dist-server/service/chat-message/room-message.js +25 -0
  106. package/dist-server/service/chat-message/room-message.js.map +1 -0
  107. package/dist-server/service/chat-session/chat-session.d.ts +36 -0
  108. package/dist-server/service/chat-session/chat-session.js +27 -2
  109. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  110. package/dist-server/service/chat-session/session-anchor.d.ts +10 -1
  111. package/dist-server/service/chat-session/session-anchor.js +12 -4
  112. package/dist-server/service/chat-session/session-anchor.js.map +1 -1
  113. package/dist-server/service/chat-session/session-inbox.d.ts +6 -1
  114. package/dist-server/service/chat-session/session-inbox.js +3 -2
  115. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  116. package/dist-server/service/chat-session/session-kind.d.ts +14 -0
  117. package/dist-server/service/chat-session/session-kind.js +30 -0
  118. package/dist-server/service/chat-session/session-kind.js.map +1 -0
  119. package/dist-server/service/chat-session/session-visibility.d.ts +15 -0
  120. package/dist-server/service/chat-session/session-visibility.js +35 -0
  121. package/dist-server/service/chat-session/session-visibility.js.map +1 -0
  122. package/dist-server/service/chat-session/station-session-guard.d.ts +1 -5
  123. package/dist-server/service/chat-session/station-session-guard.js +7 -2
  124. package/dist-server/service/chat-session/station-session-guard.js.map +1 -1
  125. package/dist-server/service/chat-session-participant/mention-invite.d.ts +29 -0
  126. package/dist-server/service/chat-session-participant/mention-invite.js +103 -0
  127. package/dist-server/service/chat-session-participant/mention-invite.js.map +1 -0
  128. package/dist-server/service/chat-session-resolver.d.ts +17 -0
  129. package/dist-server/service/chat-session-resolver.js +271 -15
  130. package/dist-server/service/chat-session-resolver.js.map +1 -1
  131. package/dist-server/service/grounding-sources.d.ts +14 -0
  132. package/dist-server/service/grounding-sources.js +26 -0
  133. package/dist-server/service/grounding-sources.js.map +1 -0
  134. package/dist-server/service/index.d.ts +9 -1
  135. package/dist-server/service/index.js +21 -0
  136. package/dist-server/service/index.js.map +1 -1
  137. package/dist-server/service/request-translator.d.ts +2 -0
  138. package/dist-server/service/request-translator.js +20 -0
  139. package/dist-server/service/request-translator.js.map +1 -0
  140. package/dist-server/service/testing/conversation-script.d.ts +123 -0
  141. package/dist-server/service/testing/conversation-script.js +202 -0
  142. package/dist-server/service/testing/conversation-script.js.map +1 -0
  143. package/dist-server/service/testing/mode-conformance.d.ts +50 -0
  144. package/dist-server/service/testing/mode-conformance.js +103 -0
  145. package/dist-server/service/testing/mode-conformance.js.map +1 -0
  146. package/dist-server/tool-registry-boot-report.d.ts +3 -0
  147. package/dist-server/tool-registry-boot-report.js +32 -2
  148. package/dist-server/tool-registry-boot-report.js.map +1 -1
  149. package/dist-server/tsconfig.tsbuildinfo +1 -1
  150. package/package.json +5 -5
  151. package/server/door-declaration-report.test.ts +53 -0
  152. package/server/door-declaration-report.ts +61 -0
  153. package/server/explicit-graphql-types.test.ts +56 -0
  154. package/server/service/assistant-capability-resolver.ts +66 -0
  155. package/server/service/assistant-chat-resolver.ts +205 -42
  156. package/server/service/assistant-mode/assistant-mode-resolver.ts +108 -0
  157. package/server/service/assistant-mode/index.ts +16 -0
  158. package/server/service/assistant-mode/registry.test.ts +105 -0
  159. package/server/service/assistant-mode/registry.ts +147 -0
  160. package/server/service/assistant-mode/resolve.test.ts +120 -0
  161. package/server/service/assistant-mode/resolve.ts +105 -0
  162. package/server/service/assistant-mode/subject-defaults.test.ts +86 -0
  163. package/server/service/assistant-mode/types.ts +121 -0
  164. package/server/service/chat-message/carried-turns.test.ts +80 -0
  165. package/server/service/chat-message/carried-turns.ts +112 -0
  166. package/server/service/chat-message/chat-message-subscription.ts +20 -4
  167. package/server/service/chat-message/chat-message.ts +44 -0
  168. package/server/service/chat-message/door-access.test.ts +72 -0
  169. package/server/service/chat-message/door-access.ts +65 -0
  170. package/server/service/chat-message/fold-history-window.test.ts +184 -0
  171. package/server/service/chat-message/fold-session-history.test.ts +98 -0
  172. package/server/service/chat-message/fold-session-history.ts +115 -0
  173. package/server/service/chat-message/llm-history.test.ts +58 -7
  174. package/server/service/chat-message/llm-history.ts +30 -11
  175. package/server/service/chat-message/message-doors.test.ts +87 -0
  176. package/server/service/chat-message/message-doors.ts +117 -0
  177. package/server/service/chat-message/proposal-outcome.test.ts +48 -0
  178. package/server/service/chat-message/proposal-outcome.ts +42 -0
  179. package/server/service/chat-message/reader-gate.ts +44 -0
  180. package/server/service/chat-message/room-message.test.ts +21 -0
  181. package/server/service/chat-message/room-message.ts +22 -0
  182. package/server/service/chat-session/chat-session.ts +46 -1
  183. package/server/service/chat-session/session-anchor.ts +12 -3
  184. package/server/service/chat-session/session-inbox.test.ts +18 -0
  185. package/server/service/chat-session/session-inbox.ts +11 -2
  186. package/server/service/chat-session/session-kind.test.ts +24 -0
  187. package/server/service/chat-session/session-kind.ts +32 -0
  188. package/server/service/chat-session/session-visibility.test.ts +52 -0
  189. package/server/service/chat-session/session-visibility.ts +34 -0
  190. package/server/service/chat-session/station-session-guard.ts +10 -2
  191. package/server/service/chat-session-participant/mention-invite.test.ts +125 -0
  192. package/server/service/chat-session-participant/mention-invite.ts +121 -0
  193. package/server/service/chat-session-resolver.ts +276 -15
  194. package/server/service/grounding-sources.test.ts +55 -0
  195. package/server/service/grounding-sources.ts +46 -0
  196. package/server/service/index.ts +21 -0
  197. package/server/service/request-translator.test.ts +45 -0
  198. package/server/service/request-translator.ts +33 -0
  199. package/server/service/station-session-guard.test.ts +12 -5
  200. package/server/service/testing/conversation-scenario.test.ts +163 -0
  201. package/server/service/testing/conversation-script.test.ts +131 -0
  202. package/server/service/testing/conversation-script.ts +288 -0
  203. package/server/service/testing/mode-conformance.ts +122 -0
  204. package/server/tool-registry-boot-report.ts +31 -2
  205. package/test/assistant-chat-call-sites.test.ts +82 -0
  206. package/translations/en.json +46 -3
  207. package/translations/ja.json +46 -3
  208. package/translations/ko.json +46 -3
  209. package/translations/ms.json +46 -3
  210. package/translations/zh.json +46 -3
@@ -0,0 +1,117 @@
1
+ /*
2
+ * 답에 실린 문 — ADR-0073 판정 1.
3
+ *
4
+ * ── 무엇을 막는가 ─────────────────────────────────────────────────────────
5
+ * 대화방은 도메인 안에서 공개다(ADR-0073 판정 2). 그런데 대화 안에는 어시스턴트가 도구 결과로 근거를
6
+ * 대고 쓴 답이 들어 있고, 그 근거는 **부른 사람의 문**을 지나 얻은 값이다. 도구는 문을 지켰는데
7
+ * 어시스턴트의 문장이 그 값을 이미 평문으로 적어 놓았다. 나중에 읽는 사람은 자기가 못 여는 문 너머의
8
+ * 값을 어시스턴트의 말로 읽는다 — 도구 권한을 다 지켜도 **글로 새는 길**이다.
9
+ *
10
+ * 그래서 답 행에 그 답을 만든 도구들이 지난 문 목록을 적어 두고(사람의 말은 `[]`), 읽는 사람이 그 문을
11
+ * 전부 지나는지 볼 때마다 확인한다. 못 지나면 본문 대신 한 줄이 나간다 — **행이 있다는 사실은 보이고
12
+ * 값만 안 보인다.** 방에 무슨 일이 있었는지는 알 수 있고, 값은 자기 권한만큼만 읽는다.
13
+ *
14
+ * ── 시점은 뜻이 없다 ──────────────────────────────────────────────────────
15
+ * 언제 들어왔는지로 가르지 않는다. 오전에 있었든 방금 들어왔든, 볼 수 있는 것은 본다. 권한이 나중에
16
+ * 붙으면 그때부터 보이고, 떨어지면 그때부터 안 보인다. 저장된 행은 그대로 있고 **보는 일만** 달라진다.
17
+ *
18
+ * ── 모델이 읽는 이력도 같다 ───────────────────────────────────────────────
19
+ * 부른 사람이 못 여는 문의 답은 모델에게도 가리고 보낸다. 가리지 않으면 모델이 그 값을 다음 답에 옮겨
20
+ * 적어서, 문을 한 번도 안 지난 사람이 값을 갖게 된다. 가린 자리를 지우지 않고 한 줄로 남기는 이유도
21
+ * 같다 — 모델이 "그런 말 한 적 없다"고 답하면 그것은 거짓말이다.
22
+ *
23
+ * 이 파일은 저장소·엔티티를 모르는 순수 판정이다.
24
+ */
25
+
26
+ /** 문 이름으로 보이지 않는 값 — 등록되지 않은 도구가 답에 끼었을 때. */
27
+ const UNRESOLVED = 'unresolved:'
28
+
29
+ /**
30
+ * 이 답이 지난 문들 — 부른 도구들의 문을 합친다(중복 없이, 정렬).
31
+ *
32
+ * 등록기에 없는 도구가 답에 끼면 그 도구가 무엇을 읽었는지 알 길이 없다. 그때 `[]`(누구나 읽는 답)로
33
+ * 적으면 모르는 값이 공개된다. 아무도 못 지나는 이름을 적어 **가려지는 쪽**으로 넘어뜨리고, 이름에
34
+ * 원인을 실어 둔다.
35
+ */
36
+ export function doorsForAnswer(toolNames: readonly string[], doorsOf: (name: string) => string[] | undefined): string[] {
37
+ const doors = new Set<string>()
38
+
39
+ for (const name of toolNames) {
40
+ const declared = doorsOf(name)
41
+ if (declared === undefined) doors.add(`${UNRESOLVED}${name}`)
42
+ else for (const door of declared) doors.add(door)
43
+ }
44
+
45
+ return [...doors].sort()
46
+ }
47
+
48
+ /**
49
+ * 한 요청 안에서 (사람, 문) 별로 한 번만 묻는 관문.
50
+ *
51
+ * 한 화면이 백 줄을 읽고 그 줄들이 같은 문 서넛을 가리킨다. 줄마다 물으면 권한 조회가 백 번 간다.
52
+ * 같은 요청 안에서 답이 바뀔 일은 없으므로 문 하나에 한 번 묻고 그 답을 쓴다. 관문은 요청마다 새로
53
+ * 만든다 — 프로세스가 들고 있으면 권한이 바뀌어도 낡은 답을 준다.
54
+ */
55
+ export function doorGate(check: (door: string) => Promise<boolean>): (door: string) => Promise<boolean> {
56
+ const asked = new Map<string, Promise<boolean>>()
57
+
58
+ return (door: string) => {
59
+ /* 등록되지 않은 도구의 자리는 물을 것도 없다 — 지나는 사람이 없다. */
60
+ if (door.startsWith(UNRESOLVED)) return Promise.resolve(false)
61
+
62
+ let answer = asked.get(door)
63
+ if (!answer) {
64
+ answer = check(door)
65
+ asked.set(door, answer)
66
+ }
67
+ return answer
68
+ }
69
+ }
70
+
71
+ /** 이 사람이 이 행을 열 수 있나 — 적힌 문을 **전부** 지나야 한다(AND). */
72
+ export async function passesRow(doors: readonly string[] | null | undefined, gate: (door: string) => Promise<boolean>): Promise<boolean> {
73
+ for (const door of doors ?? []) {
74
+ if (!(await gate(door))) return false
75
+ }
76
+ return true
77
+ }
78
+
79
+ /** 문 판정에 필요한 만큼만 — 엔티티를 이 자리로 끌고 오지 않는다. */
80
+ export interface DoorReadableRow {
81
+ doors?: string[] | null
82
+ content?: string | null
83
+ }
84
+
85
+ /** 가린 행에서 값이 실려 있는 칸들 — 본문만 지우고 두면 그리로 새어 나간다. */
86
+ const VALUE_CARRYING = ['extra', 'proposals', 'grounding', 'patchEntryId', 'importSessionId'] as const
87
+
88
+ /**
89
+ * 못 지나는 행의 값을 가린다 — 행은 남기고 본문을 한 줄로 바꾼다.
90
+ *
91
+ * `notice` 는 부르는 쪽이 번역해 넘긴다(서버 번역, 화면 말은 서버가 만든다 — ADR-0061 결정 7).
92
+ */
93
+ export async function redactByDoors<T extends DoorReadableRow>(
94
+ rows: readonly T[],
95
+ gate: (door: string) => Promise<boolean>,
96
+ notice: (doors: string[]) => string
97
+ ): Promise<T[]> {
98
+ const out: T[] = []
99
+
100
+ for (const row of rows) {
101
+ const doors = row.doors ?? []
102
+ if (doors.length === 0 || (await passesRow(doors, gate))) {
103
+ out.push(row)
104
+ continue
105
+ }
106
+
107
+ const hidden: any = { ...row, content: notice(doors) }
108
+ for (const field of VALUE_CARRYING) {
109
+ if (field in hidden) hidden[field] = null
110
+ }
111
+ /* 가려졌다는 것 자체는 사실이다. 화면이 자물쇠를 그릴 수 있게 남긴다. */
112
+ hidden.redacted = true
113
+ out.push(hidden)
114
+ }
115
+
116
+ return out
117
+ }
@@ -0,0 +1,48 @@
1
+ /*
2
+ * What this guards: the line the server writes is the line the panel reads.
3
+ *
4
+ * Two files hold the shape — one writes it into the conversation, one reads it back for the card.
5
+ * They are tested together here, so a change to either breaks a test instead of quietly leaving
6
+ * cards in "sending" forever.
7
+ *
8
+ * The test lives on the server side on purpose: the client build compiles everything under
9
+ * `client/`, so a client test importing the server would pull the server into the browser build's
10
+ * root and move every emitted path (`dist-client/client/…`). The server build excludes tests, so
11
+ * reaching the other way costs nothing.
12
+ */
13
+ import { formatProposalOutcomeLine, PROPOSAL_VERDICTS } from './proposal-outcome'
14
+ import { parseProposalOutcomeLine } from '../../../client/components/proposal-outcome-line'
15
+
16
+ const key = 'proposeSceneEdit|{"kind":"scene-edit"}'
17
+
18
+ it('★ every verdict survives the round trip', () => {
19
+ for (const verdict of PROPOSAL_VERDICTS) {
20
+ const line = formatProposalOutcomeLine(key, { verdict })
21
+ expect(parseProposalOutcomeLine(line)).toEqual({ key, verdict, errorCode: undefined, applied: undefined, total: undefined })
22
+ }
23
+ })
24
+
25
+ it('★ the reason code comes back exactly as the host gave it', () => {
26
+ const line = formatProposalOutcomeLine(key, { verdict: 'refused', errorCode: 'unknown-tag' })
27
+
28
+ expect(parseProposalOutcomeLine(line)?.errorCode).toBe('unknown-tag')
29
+ })
30
+
31
+ it('★ counts come back as "how many of how many"', () => {
32
+ const line = formatProposalOutcomeLine(key, { verdict: 'accepted', applied: 2, missed: 1 })
33
+
34
+ expect(parseProposalOutcomeLine(line)).toMatchObject({ verdict: 'accepted', applied: 2, total: 3 })
35
+ })
36
+
37
+ it('★ another system row is not read as an outcome — no guessing', () => {
38
+ expect(parseProposalOutcomeLine('User directly edited the board: moved EQ-1')).toBeUndefined()
39
+ expect(parseProposalOutcomeLine('proposal something without a verdict')).toBeUndefined()
40
+ expect(parseProposalOutcomeLine('')).toBeUndefined()
41
+ })
42
+
43
+ it('a key containing colons and spaces still parses', () => {
44
+ const awkward = 'proposeAction|stop|EQ-1|{"note":"a: b"}'
45
+ const line = formatProposalOutcomeLine(awkward, { verdict: 'accepted' })
46
+
47
+ expect(parseProposalOutcomeLine(line)?.key).toBe(awkward)
48
+ })
@@ -0,0 +1,42 @@
1
+ /*
2
+ * What the host did with a proposal, written into the conversation (ADR-0070 A12-a).
3
+ *
4
+ * The card used to change to "executed" the moment it was pressed, which is the one thing it knew
5
+ * and the one thing that is not the answer. The host's answer comes back through a server door and
6
+ * is stored as a `system` row, so three readers get it from the same place: the card that is open,
7
+ * the card after a refresh, and the model on its next turn — a model that is not told its proposal
8
+ * was refused goes on as though it had been applied.
9
+ *
10
+ * The line is the row's content. It is parsed back by the panel, so the shape is fixed here and the
11
+ * panel's parser is tested against this formatter.
12
+ */
13
+
14
+ /** ADR-0042's verdicts, plus the one for a person who backed out at the confirm step. */
15
+ export type ProposalVerdict = 'accepted' | 'refused' | 'unavailable' | 'declined'
16
+
17
+ export interface ProposalOutcome {
18
+ verdict: ProposalVerdict
19
+ /** The host's own reason code, carried through untouched — no mapping table (ADR-0042). */
20
+ errorCode?: string
21
+ /** For a proposal made of several edits: how many went in, and how many did not. */
22
+ applied?: number
23
+ missed?: number
24
+ }
25
+
26
+ export const PROPOSAL_OUTCOME_PREFIX = 'proposal '
27
+
28
+ export const PROPOSAL_VERDICTS: ProposalVerdict[] = ['accepted', 'refused', 'unavailable', 'declined']
29
+
30
+ /**
31
+ * `proposal <key>: <verdict> (<errorCode>) 2/3`
32
+ *
33
+ * English, like the other lines the model reads. The panel turns it into the reader's language.
34
+ */
35
+ export function formatProposalOutcomeLine(key: string, outcome: ProposalOutcome): string {
36
+ const reason = outcome.errorCode ? ` (${outcome.errorCode})` : ''
37
+ const counted =
38
+ typeof outcome.applied === 'number' || typeof outcome.missed === 'number'
39
+ ? ` ${outcome.applied ?? 0}/${(outcome.applied ?? 0) + (outcome.missed ?? 0)}`
40
+ : ''
41
+ return `${PROPOSAL_OUTCOME_PREFIX}${key}: ${outcome.verdict}${reason}${counted}`
42
+ }
@@ -0,0 +1,44 @@
1
+ /*
2
+ * 읽는 사람의 관문 — ADR-0073 판정 1·2 를 요청 하나에 세운다.
3
+ *
4
+ * 리졸버는 이것을 한 번 만들고 행 목록을 통과시킨다. 문 판정은 (사람, 문) 별로 한 번만 일어나고,
5
+ * 세션이 private 인지도 여기서 함께 답한다. 리졸버마다 같은 판정을 다시 적으면 한 곳이 빠진 날
6
+ * 그 문으로 샌다.
7
+ */
8
+ import { checkPermission, privilegeOfField } from '@things-factory/auth-base'
9
+
10
+ import { doorCheck } from './door-access.js'
11
+ import { doorGate, redactByDoors, type DoorReadableRow } from './message-doors.js'
12
+ import { requestTranslator } from '../request-translator.js'
13
+
14
+ /** 이 요청에서 행을 가려 내보내는 함수. 같은 요청 안에서 계속 쓴다. */
15
+ export function readerGate(context: any): <T extends DoorReadableRow>(rows: readonly T[]) => Promise<T[]> {
16
+ const { domain, user } = context.state
17
+ const t = requestTranslator(context)
18
+
19
+ const { unsafeIP, prohibitedPrivileges } = context.state
20
+
21
+ const gate = doorGate(
22
+ /* 문에서 쓰는 판정을 그대로 쓴다 — 같은 인자, 같은 함수(ADR-0072 결정 5). */
23
+ doorCheck({
24
+ lookup: privilegeOfField,
25
+ permits: fieldGate => checkPermission(fieldGate as any, user, domain, unsafeIP, prohibitedPrivileges)
26
+ })
27
+ )
28
+
29
+ /*
30
+ * 문 이름(`Query.productionReports`)은 이 문장에 넣지 않는다.
31
+ *
32
+ * 현장에서 읽는 사람에게 GraphQL 이름은 아무 말도 아니다. 문 이름을 사람의 말로 바꿀 표는 AUTH 의
33
+ * 필드→권한 표와 같은 자리에서 나와야 하고, 그것은 아직 없다(ADR-0072 결정 2 (b)). 없는 표를 흉내
34
+ * 내느니 문장은 사실만 말하고, 목록은 `ChatMessage.doors` 로 따로 나간다 — 관리자에게 무엇을
35
+ * 청할지 알아야 하는 화면은 거기서 읽는다. 문구는 UX 레인이 정한다.
36
+ */
37
+ return rows =>
38
+ redactByDoors(rows, gate, () =>
39
+ t('ai-assistant.text.answer-needs-permission', {
40
+ /* 구독에는 요청 번역기가 없다 — `request-translator.ts` 참조. */
41
+ defaultValue: 'you do not have permission to see this answer. ask an administrator for access.'
42
+ })
43
+ )
44
+ }
@@ -0,0 +1,21 @@
1
+ /*
2
+ * What this guards: the people's door keeps what was said, and refuses what was not.
3
+ *
4
+ * An empty turn stored as a message is a blank line in a shared conversation and a blank line in
5
+ * what the model reads — both mean "someone said something" when nobody did.
6
+ */
7
+ import { ROOM_MESSAGE_LIMIT, roomMessageBody } from './room-message'
8
+
9
+ it('keeps what was said, without the whitespace around it', () => {
10
+ expect(roomMessageBody(' 3 라인이 멈춘 것 같아요 ')).toBe('3 라인이 멈춘 것 같아요')
11
+ })
12
+
13
+ it('★ an empty turn is nothing to store', () => {
14
+ expect(roomMessageBody(' ')).toBeUndefined()
15
+ expect(roomMessageBody('')).toBeUndefined()
16
+ expect(roomMessageBody(undefined)).toBeUndefined()
17
+ })
18
+
19
+ it('a very long turn is capped — this is a conversation, not an archive', () => {
20
+ expect(roomMessageBody('가'.repeat(ROOM_MESSAGE_LIMIT + 100))?.length).toBe(ROOM_MESSAGE_LIMIT)
21
+ })
@@ -0,0 +1,22 @@
1
+ /*
2
+ * A person saying something to the room (ADR-0070 A15).
3
+ *
4
+ * The addressee column has two values and, until this door, a screen could only produce one of
5
+ * them: every turn a person typed went through `assistantChat`, which means every turn was for the
6
+ * assistant. `room` existed only on turns carried in from somewhere else. So the half of the design
7
+ * that says "people also talk to each other here, and the assistant reads it as context" had no way
8
+ * to happen.
9
+ *
10
+ * The rule the door follows is the same one: the addressee comes from **which door was called**,
11
+ * never from an argument. This one is the people's door — it stores the turn and does not call the
12
+ * model. Nobody is answered, and that silence is the point (A15 decision ⑦).
13
+ */
14
+
15
+ /** How much of one turn is kept — the same cap the carried turns use. */
16
+ export const ROOM_MESSAGE_LIMIT = 4000
17
+
18
+ /** Trimmed and capped, or nothing when there is no message. */
19
+ export function roomMessageBody(content: string | undefined | null): string | undefined {
20
+ const body = (content ?? '').trim().slice(0, ROOM_MESSAGE_LIMIT)
21
+ return body || undefined
22
+ }
@@ -43,7 +43,7 @@ const DATABASE_TYPE = ORMCONFIG.type
43
43
  // 앵커 조회용 — chatSessionsByAnchor 가 [domain, anchorType, anchorId] 로 질의.
44
44
  @Index('ix_chat_session_2', (session: ChatSession) => [session.domain, session.anchorType, session.anchorId])
45
45
  @ObjectType({
46
- description: 'AI 협력 세션 — Board 와 결합. 한 보드에 여러 세션 가능 (thread / 사용자별 / 컨텍스트별 등 미래 확장).'
46
+ description: 'One AI conversation, anchored to what it is about. An anchor may have several — threads, one per person, one per context.'
47
47
  })
48
48
  export class ChatSession {
49
49
  @PrimaryGeneratedColumn('uuid')
@@ -125,11 +125,56 @@ export class ChatSession {
125
125
  @Field({ nullable: true, description: 'Timestamp of the last message in this session (denormalized for list ordering and unread comparison — avoids per-session aggregation across the supported DB drivers).' })
126
126
  lastMessageAt?: Date
127
127
 
128
+ /**
129
+ * 어느 대화인가 — `twin.board` · `figure.catalog` 같은 모드의 kind (ADR-0072 결정 3).
130
+ *
131
+ * **서버가 적는다.** 클라이언트가 보내는 값이 아니다 — 등록된 모드에서 나온 것만 적힌다(`addressee`
132
+ * 와 같은 결). 한 대상 위에 대화가 둘 설 수 있게 되면서(A17), 세션이 자기가 어느 대화인지 알아야
133
+ * 한다. 몰랐을 때 화면에서 이렇게 나왔다: 모드를 바꿔도 같은 세션이 그대로 남아, 「도형 찾기」로
134
+ * 바꾼 뒤 보낸 말이 앞의 고치기 대화에 붙었다(2026-09-18, Figure 레인 :3300).
135
+ *
136
+ * 값이 없는 세션은 이 칸이 서기 전에 난 것이다. 다음 턴에 요청한 kind 로 한 번 찍고, 찍힌 뒤에는
137
+ * 「요청한 모드와 다르면 거절」 하나만 남는다. 마이그레이션은 만들지 않는다.
138
+ */
139
+ @Column({ type: 'varchar', length: 64, nullable: true })
140
+ @Field({ nullable: true, description: "Which conversation this session is — the registered mode kind. Written by the server, never sent by the client." })
141
+ kind?: string
142
+
143
+ /**
144
+ * 이 대화를 도메인 안 누구나 읽는가 — ADR-0073 판정 2.
145
+ *
146
+ * `open` 도메인 안이면 누구나 들어와 읽고 말한다. 초대 절차는 없다.
147
+ * `private` 참여자만 읽는다.
148
+ *
149
+ * 앵커가 있는 세션(space · board · station)은 공유된 무엇을 놓고 하는 이야기이므로 `open` 으로
150
+ * 시작한다. 앵커가 없는 세션과 **옮겨 적어 만든 세션**은 `private` 으로 시작한다 — 가벼운 질문을
151
+ * 승격하면서 앞선 대화가 새 세션에 옮겨 적히는데(`appendSessionTurns`), 그것이 공개로 태어나면
152
+ * 사적으로 오간 원문이 소급해서 공개된다.
153
+ *
154
+ * owner 가 양방향으로 바꿀 수 있다. 공개로 바꾸기 전에 화면이 한 줄을 먼저 보인다.
155
+ *
156
+ * open 이어도 답 행의 문 판정(ADR-0073 판정 1)은 그대로다 — 방에 들어온 것과 값을 읽는 것은 다르다.
157
+ */
158
+ @Column({ type: 'varchar', length: 16, default: 'open' })
159
+ @Field({ description: "Who may read this conversation: 'open' (anyone in the domain) or 'private' (participants only)." })
160
+ visibility!: string
161
+
128
162
  /** 목록에 보여줄 마지막 메시지 미리보기 — 목록에서 메시지 테이블을 다시 읽지 않기 위해 짧게 저장. */
129
163
  @Column({ nullable: true, length: 200 })
130
164
  @Field({ nullable: true, description: 'Short preview of the last message, stored so the session list does not need to query messages.' })
131
165
  lastMessagePreview?: string
132
166
 
167
+ /**
168
+ * 그 미리보기가 지나온 문 — ADR-0073 판정 1.
169
+ *
170
+ * 목록은 답을 **다시 읽는 두 번째 길**이다. 메시지 화면에서만 가리면 같은 문장이 목록에 그대로 뜬다.
171
+ * 문이 둘이고 한쪽이 느슨하면 느슨한 쪽이 이긴다(ADR-0061 결정 1). 미리보기를 저장할 때 그 행의 문을
172
+ * 함께 옮겨 적고, 목록도 같은 관문을 지난다.
173
+ */
174
+ @Column({ type: 'simple-array', nullable: true })
175
+ @Field(() => [String], { nullable: true, description: 'Doors of the message the preview came from — the session list passes the same gate as the conversation.' })
176
+ lastMessageDoors?: string[]
177
+
133
178
  @Column({
134
179
  nullable: true,
135
180
  type:
@@ -101,8 +101,17 @@ export function anchorColumns(
101
101
  * 여기서도 앵커 이름을 검사한다 — 읽기 경로에서 틀린 이름은 오류가 아니라 **0 행**으로 나오고,
102
102
  * 화면은 「대화가 없다」로 그린다. 그 증상으로는 원인을 못 찾는다.
103
103
  */
104
- export function anchorWhere(domainId: string, anchorType: string, anchorId: string): any {
104
+ /**
105
+ * 이 앵커의 세션을 찾는 조건 — `kind` 를 주면 **그 대화의** 세션만 본다(ADR-0072 결정 3).
106
+ *
107
+ * 한 대상 위에 대화가 둘 설 수 있게 되면서(A17), 앵커만으로 찾으면 저작 대화를 찾는 자리에서 운영
108
+ * 대화가 잡힌다. figure 에서 그것이 화면으로 나왔다 — 모드를 바꿨는데 같은 세션이 그대로 남아 새 말이
109
+ * 앞 대화에 붙었다(2026-09-18, Figure 레인 :3300).
110
+ *
111
+ * `kind` 를 안 주면 앵커의 대화를 모두 본다 — 목록처럼 대화를 가리지 않고 훑는 자리다.
112
+ */
113
+ export function anchorWhere(domainId: string, anchorType: string, anchorId: string, kind?: string): any {
105
114
  const type = assertAnchorType(anchorType, 'anchorWhere')
106
- if (type === 'board') return { boardId: anchorId, domain: { id: domainId } }
107
- return { anchorType: type, anchorId, domain: { id: domainId } }
115
+ const where = type === 'board' ? { boardId: anchorId, domain: { id: domainId } } : { anchorType: type, anchorId, domain: { id: domainId } }
116
+ return kind ? { ...where, kind } : where
108
117
  }
@@ -183,3 +183,21 @@ describe('searchInbox — 대량 목록의 풀텍스트 필터', () => {
183
183
  expect(searchInbox(rows, 'zzz')).toEqual([])
184
184
  })
185
185
  })
186
+
187
+ /*
188
+ * ADR-0073 판정 1 — 목록 미리보기도 같은 관문을 지나야 한다.
189
+ *
190
+ * 대화 화면에서만 가리면 같은 문장이 목록에 그대로 뜬다. 문이 둘이고 한쪽이 느슨하면 느슨한 쪽이 이긴다.
191
+ */
192
+ describe('preview carries its doors', () => {
193
+ it('★ 미리보기를 적을 때 그 답이 지나온 문도 함께 적는다', () => {
194
+ const patch = activityPatch('오늘 양품 1,204', new Date('2026-09-18T00:00:00Z'), ['Query.productionReports'])
195
+
196
+ expect(patch.lastMessageDoors).toEqual(['Query.productionReports'])
197
+ expect(patch.lastMessagePreview).toBe('오늘 양품 1,204')
198
+ })
199
+
200
+ it('사람의 말에는 문이 없다 — 빈 목록이면 누구나 본다', () => {
201
+ expect(activityPatch('이것 좀 봐줘').lastMessageDoors).toEqual([])
202
+ })
203
+ })
@@ -19,11 +19,15 @@
19
19
  export interface InboxSessionRow {
20
20
  id?: string
21
21
  name?: string
22
+ /** 어느 대화인가 — 목록이 대화를 가리지 않으므로, 고른 뒤 화면이 따라갈 수 있게 함께 낸다. */
23
+ kind?: string
22
24
  anchorType?: string
23
25
  anchorId?: string
24
26
  boardId?: string
25
27
  lastMessageAt?: Date | string | null
26
28
  lastMessagePreview?: string | null
29
+ /** 그 미리보기가 지나온 문 — 목록도 같은 관문을 지난다. */
30
+ lastMessageDoors?: string[] | null
27
31
  createdAt?: Date | string | null
28
32
  }
29
33
 
@@ -137,9 +141,14 @@ export function countUnreadSessions(entries: InboxEntry[]): number {
137
141
  * 메시지 저장 시 세션에 반영할 활동 값. 미리보기는 짧게 자르고 개행을 없앤다
138
142
  * (목록 한 줄에 들어가야 하고, 컬럼 길이 제한이 있다).
139
143
  */
140
- export function activityPatch(content: string, at: Date = new Date()): { lastMessageAt: Date; lastMessagePreview: string } {
144
+ export function activityPatch(
145
+ content: string,
146
+ at: Date = new Date(),
147
+ doors: string[] = []
148
+ ): { lastMessageAt: Date; lastMessagePreview: string; lastMessageDoors: string[] } {
141
149
  const flat = (content ?? '').replace(/\s+/g, ' ').trim()
142
- return { lastMessageAt: at, lastMessagePreview: flat.slice(0, 200) }
150
+ /* 미리보기가 어느 문을 지나온 값인지 함께 적는다 — 목록도 같은 관문을 지난다(ADR-0073 판정 1). */
151
+ return { lastMessageAt: at, lastMessagePreview: flat.slice(0, 200), lastMessageDoors: doors }
143
152
  }
144
153
 
145
154
  /**
@@ -0,0 +1,24 @@
1
+ /*
2
+ * ADR-0072 결정 3 — 세션이 자기 대화를 알고, 다른 대화의 말을 받지 않는다.
3
+ */
4
+ import { judgeSessionKind } from './session-kind'
5
+
6
+ describe('session kind', () => {
7
+ it('같은 대화면 그대로 간다', () => {
8
+ expect(judgeSessionKind('figure.modeller', 'figure.modeller')).toEqual({ action: 'proceed' })
9
+ })
10
+
11
+ it('★ 칸이 서기 전 세션은 요청한 대화로 한 번 찍는다 — 마이그레이션을 만들지 않는다', () => {
12
+ expect(judgeSessionKind(null, 'figure.catalog')).toEqual({ action: 'stamp', kind: 'figure.catalog' })
13
+ expect(judgeSessionKind('', 'figure.catalog')).toEqual({ action: 'stamp', kind: 'figure.catalog' })
14
+ expect(judgeSessionKind(' ', 'figure.catalog')).toEqual({ action: 'stamp', kind: 'figure.catalog' })
15
+ })
16
+
17
+ it('★ 다른 대화의 세션은 거절한다 — 조용히 섞으면 그 이력이 남의 프롬프트로 들어간다', () => {
18
+ expect(judgeSessionKind('figure.modeller', 'figure.catalog')).toEqual({
19
+ action: 'refuse',
20
+ errorCode: 'session-kind-mismatch',
21
+ params: { sessionKind: 'figure.modeller', requested: 'figure.catalog' }
22
+ })
23
+ })
24
+ })
@@ -0,0 +1,32 @@
1
+ /*
2
+ * 이 세션이 어느 대화인가 — ADR-0072 결정 3.
3
+ *
4
+ * 한 대상 위에 대화가 둘 설 수 있게 되면서(A17), 세션이 자기가 어느 대화인지 알아야 한다. 모르면 모드를
5
+ * 바꿔도 같은 세션이 그대로 남고, 새 말이 앞 대화에 붙는다 — 화면에서는 줄이 사라져 새 대화처럼 보이니
6
+ * 보낸 사람은 그것을 알 수 없다. 새로 고쳐야 드러난다(2026-09-18, Figure 레인 :3300).
7
+ *
8
+ * 답은 셋이다.
9
+ * `proceed` 세션의 대화와 요청한 대화가 같다.
10
+ * `stamp` 칸이 서기 전에 난 세션이다 — 요청한 대화로 한 번 찍고 진행한다. 찍힌 뒤에는 아래 하나만
11
+ * 남는다. 마이그레이션은 만들지 않는다.
12
+ * `refuse` 다른 대화의 세션이다. **조용히 섞지 않는다** — 섞으면 그 대화의 이력이 다른 대화의
13
+ * 프롬프트로 들어가고, 읽는 사람은 자기가 무엇에 답하고 있는지 모른다.
14
+ */
15
+
16
+ export type SessionKindVerdict =
17
+ | { action: 'proceed' }
18
+ | { action: 'stamp'; kind: string }
19
+ | { action: 'refuse'; errorCode: 'session-kind-mismatch'; params: { sessionKind: string; requested: string } }
20
+
21
+ export function judgeSessionKind(sessionKind: string | null | undefined, requested: string): SessionKindVerdict {
22
+ const stored = (sessionKind ?? '').trim()
23
+
24
+ if (!stored) return { action: 'stamp', kind: requested }
25
+ if (stored === requested) return { action: 'proceed' }
26
+
27
+ return {
28
+ action: 'refuse',
29
+ errorCode: 'session-kind-mismatch',
30
+ params: { sessionKind: stored, requested }
31
+ }
32
+ }
@@ -0,0 +1,52 @@
1
+ /*
2
+ * ADR-0073 판정 2 — 새 대화가 공개로 태어나는가, 비공개는 누가 읽는가.
3
+ *
4
+ * 옮겨 적어 만든 세션이 공개로 태어나면 사적으로 오간 원문이 소급해서 공개된다. 그 한 줄을 여기서 문다.
5
+ */
6
+ import { initialVisibility, mayReadSession } from './session-visibility'
7
+
8
+ describe('session visibility', () => {
9
+ it('앵커가 있는 대화는 공개로 시작한다 — 공유된 대상을 놓고 하는 이야기다', () => {
10
+ expect(initialVisibility({ anchorType: 'space' })).toBe('open')
11
+ expect(initialVisibility({ anchorType: 'board' })).toBe('open')
12
+ expect(initialVisibility({ anchorType: 'station' })).toBe('open')
13
+ })
14
+
15
+ it('★ 옮겨 적어 만든 대화는 비공개로 시작한다 — 원문이 소급 공개되지 않는다', () => {
16
+ expect(initialVisibility({ anchorType: 'board', copyOf: 'session-1' })).toBe('private')
17
+ })
18
+
19
+ it('앵커 없는 대화는 비공개로 시작한다', () => {
20
+ expect(initialVisibility({})).toBe('private')
21
+ expect(initialVisibility({ anchorType: null })).toBe('private')
22
+ })
23
+
24
+ it('★ 비공개 대화는 참여자만 읽는다', () => {
25
+ expect(mayReadSession('private', false)).toBe(false)
26
+ expect(mayReadSession('private', true)).toBe(true)
27
+ })
28
+
29
+ it('공개 대화는 참여자가 아니어도 읽는다 — 초대 절차가 없다', () => {
30
+ expect(mayReadSession('open', false)).toBe(true)
31
+ })
32
+
33
+ it('값이 없는 옛 행은 공개로 읽는다 — 오늘까지 도메인만 보고 있었다', () => {
34
+ expect(mayReadSession(undefined, false)).toBe(true)
35
+ })
36
+ })
37
+
38
+ /*
39
+ * ADR-0073 결정 2 — 대상 없는 개인 질문은 공개 방으로 태어나지 않는다.
40
+ *
41
+ * `'none'` 은 저장되는 앵커 종류 목록에 든 글자다(대상이 없다는 것도 행에 적어야 하므로). 「값이 있으면
42
+ * 앵커가 있다」로 읽으면 그 글자가 앵커 노릇을 하고, `twin.none` 대화가 도메인 안 누구에게나 열린다.
43
+ */
44
+ describe("'none' is not an anchor", () => {
45
+ it('★ 대상 없는 대화는 비공개로 시작한다 — 앵커 종류 목록에 그 글자가 있어도', () => {
46
+ expect(initialVisibility({ anchorType: 'none' })).toBe('private')
47
+ })
48
+
49
+ it('대상이 있는 대화는 그대로 공개다', () => {
50
+ expect(initialVisibility({ anchorType: 'space' })).toBe('open')
51
+ })
52
+ })
@@ -0,0 +1,34 @@
1
+ /*
2
+ * 새 대화가 공개로 태어나는가 — ADR-0073 판정 2.
3
+ *
4
+ * 판정을 세션을 만드는 자리마다 적으면 한 곳이 빠진 날 그 세션만 다른 규칙으로 산다. 한 줄로 모은다.
5
+ */
6
+
7
+ export type SessionVisibility = 'open' | 'private'
8
+
9
+ export const SESSION_VISIBILITIES: readonly SessionVisibility[] = ['open', 'private']
10
+
11
+ /**
12
+ * 이렇게 만들어진 세션은 무엇으로 시작하나.
13
+ *
14
+ * 앵커가 있으면 공유된 대상을 놓고 하는 이야기다 — 공개. 앵커가 없거나 앞선 대화를 옮겨 적어 만든
15
+ * 것이면 비공개. `copyOf` 가 있다는 것은 다른 대화의 원문이 이 안에 들어왔다는 뜻이고, 그 원문은
16
+ * 자기가 있던 자리의 규칙으로 쓰였다.
17
+ */
18
+ export function initialVisibility(session: { anchorType?: string | null; copyOf?: string | null }): SessionVisibility {
19
+ if (session.copyOf) return 'private'
20
+
21
+ /*
22
+ * `'none'` 은 **앵커가 아니다.** 저장되는 앵커 종류 목록에 그 글자가 들어 있어서(`session-anchor.ts`
23
+ * — 대상이 없다는 것도 행에 적어야 하므로), 「값이 있으면 앵커가 있다」로 읽으면 대상 없는 개인 질문이
24
+ * 공개 방으로 태어난다. ADR-0073 결정 2 가 그것을 `private` 로 못박았다 — 「앵커 없는 세션(`twin.none`
25
+ * 같은 개인 질문)」.
26
+ */
27
+ const anchored = !!session.anchorType && session.anchorType !== 'none'
28
+ return anchored ? 'open' : 'private'
29
+ }
30
+
31
+ /** 이 사람이 이 대화를 읽을 수 있나. open 이면 도메인 안 누구나, private 이면 참여자만. */
32
+ export function mayReadSession(visibility: string | null | undefined, isParticipant: boolean): boolean {
33
+ return (visibility ?? 'open') === 'open' || isParticipant
34
+ }
@@ -2,9 +2,13 @@
2
2
  * This is a consistency check, not authorization; tenant/privilege checks stay in the resolver.
3
3
  * Older Board sessions have only boardId, so preserve that supported read shape.
4
4
  */
5
+ const STATION_MISMATCH = 'this conversation belongs to another station screen. open the conversation of the screen you are on.'
6
+
5
7
  export function assertStationSession(
6
8
  session: {anchorType?:string|null;anchorId?:string|null;boardId?:string|null}|undefined,
7
- host: {surface?:string;boardId?:unknown}|undefined
9
+ host: {surface?:string;boardId?:unknown}|undefined,
10
+ /* The reader's sentence comes from the request (ADR-0054); without a translator it falls back to English. */
11
+ t?: (key: string, params?: Record<string, any>) => string
8
12
  ) {
9
13
  if(host?.surface!=='station-screen-modeller')return
10
14
  const boardId=host.boardId
@@ -13,5 +17,9 @@ export function assertStationSession(
13
17
  (session.anchorType==='board'||legacy)&&
14
18
  (session.anchorId||session.boardId)===boardId&&
15
19
  (!session.boardId||session.boardId===boardId)
16
- if(!valid)throw new Error('현재 Station Screen과 대화 이력이 일치하지 않습니다. 해당 화면의 대화를 다시 여세요.')
20
+ if(!valid)throw new Error(
21
+ t
22
+ ? t('ai-assistant.text.other-station-history', { defaultValue: STATION_MISMATCH })
23
+ : STATION_MISMATCH
24
+ )
17
25
  }