@things-factory/board-ai 10.1.4 → 10.1.6

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 (199) hide show
  1. package/client/components/default-catalog-entries.ts +1 -1
  2. package/client/components/default-slash-templates.ts +1 -1
  3. package/client/index.ts +27 -6
  4. package/dist-client/client/components/default-catalog-entries.d.ts +1 -1
  5. package/dist-client/client/components/default-catalog-entries.js.map +1 -1
  6. package/dist-client/client/components/default-slash-templates.d.ts +1 -1
  7. package/dist-client/client/components/default-slash-templates.js.map +1 -1
  8. package/dist-client/client/index.d.ts +3 -11
  9. package/dist-client/client/index.js +25 -6
  10. package/dist-client/client/index.js.map +1 -1
  11. package/dist-client/server/service/assistant.d.ts +22 -22
  12. package/dist-client/server/service/assistant.js +32 -70
  13. package/dist-client/server/service/assistant.js.map +1 -1
  14. package/dist-client/server/service/grounding.d.ts +2 -12
  15. package/dist-client/server/service/grounding.js +15 -23
  16. package/dist-client/server/service/grounding.js.map +1 -1
  17. package/dist-client/server/service/types.d.ts +2 -4
  18. package/dist-client/server/service/types.js.map +1 -1
  19. package/dist-client/server/service/validation/board-model-schema.d.ts +270 -270
  20. package/dist-client/tsconfig.tsbuildinfo +1 -1
  21. package/dist-server/service/assistant.d.ts +22 -22
  22. package/dist-server/service/assistant.js +25 -76
  23. package/dist-server/service/assistant.js.map +1 -1
  24. package/dist-server/service/board-ai-resolver.js +41 -30
  25. package/dist-server/service/board-ai-resolver.js.map +1 -1
  26. package/dist-server/service/board-session-resolver.d.ts +12 -0
  27. package/dist-server/service/board-session-resolver.js +203 -0
  28. package/dist-server/service/board-session-resolver.js.map +1 -0
  29. package/dist-server/service/board-tool-rights.d.ts +7 -0
  30. package/dist-server/service/board-tool-rights.js +62 -0
  31. package/dist-server/service/board-tool-rights.js.map +1 -0
  32. package/dist-server/service/grounding.d.ts +2 -12
  33. package/dist-server/service/grounding.js +16 -24
  34. package/dist-server/service/grounding.js.map +1 -1
  35. package/dist-server/service/index.d.ts +4 -10
  36. package/dist-server/service/index.js +16 -23
  37. package/dist-server/service/index.js.map +1 -1
  38. package/dist-server/service/patch-entry/board-patch-subscription.js +2 -2
  39. package/dist-server/service/patch-entry/board-patch-subscription.js.map +1 -1
  40. package/dist-server/service/patch-entry/patch-entry.d.ts +1 -1
  41. package/dist-server/service/patch-entry/patch-entry.js +3 -3
  42. package/dist-server/service/patch-entry/patch-entry.js.map +1 -1
  43. package/dist-server/service/types.d.ts +2 -4
  44. package/dist-server/service/types.js.map +1 -1
  45. package/dist-server/tsconfig.tsbuildinfo +1 -1
  46. package/package.json +6 -5
  47. package/server/service/apply-patch-drift.test.ts +3 -1
  48. package/server/service/assistant.ts +47 -73
  49. package/server/service/board-ai-resolver.ts +22 -12
  50. package/server/service/board-session-resolver.ts +183 -0
  51. package/server/service/board-tool-rights.test.ts +96 -0
  52. package/server/service/board-tool-rights.ts +73 -0
  53. package/server/service/dock-contract.test.ts +3 -0
  54. package/server/service/grounding.test.ts +3 -3
  55. package/server/service/grounding.ts +15 -34
  56. package/server/service/index.ts +16 -22
  57. package/server/service/patch-entry/board-patch-subscription.ts +1 -1
  58. package/server/service/patch-entry/patch-entry.ts +3 -2
  59. package/server/service/registry-tool-loop.test.ts +1 -0
  60. package/server/service/tool-exposure.test.ts +2 -0
  61. package/server/service/types.ts +3 -4
  62. package/client/components/board-ai-chat.test.ts +0 -149
  63. package/client/components/board-ai-chat.ts +0 -3184
  64. package/client/components/chat-echo-dedup.test.ts +0 -230
  65. package/client/components/chat-echo-dedup.ts +0 -155
  66. package/client/components/chat-input-builder.ts +0 -69
  67. package/client/components/markdown.test.ts +0 -518
  68. package/client/components/markdown.ts +0 -508
  69. package/client/components/mention-popup-helpers.ts +0 -433
  70. package/client/components/mention-popup.test.ts +0 -588
  71. package/client/components/mention-popup.ts +0 -238
  72. package/client/utils/board-edit-patch.ts +0 -374
  73. package/dist-client/client/components/board-ai-chat.d.ts +0 -384
  74. package/dist-client/client/components/board-ai-chat.js +0 -3008
  75. package/dist-client/client/components/board-ai-chat.js.map +0 -1
  76. package/dist-client/client/components/board-ai-chat.test.d.ts +0 -1
  77. package/dist-client/client/components/board-ai-chat.test.js +0 -138
  78. package/dist-client/client/components/board-ai-chat.test.js.map +0 -1
  79. package/dist-client/client/components/chat-echo-dedup.d.ts +0 -54
  80. package/dist-client/client/components/chat-echo-dedup.js +0 -115
  81. package/dist-client/client/components/chat-echo-dedup.js.map +0 -1
  82. package/dist-client/client/components/chat-echo-dedup.test.d.ts +0 -1
  83. package/dist-client/client/components/chat-echo-dedup.test.js +0 -203
  84. package/dist-client/client/components/chat-echo-dedup.test.js.map +0 -1
  85. package/dist-client/client/components/chat-input-builder.d.ts +0 -55
  86. package/dist-client/client/components/chat-input-builder.js +0 -32
  87. package/dist-client/client/components/chat-input-builder.js.map +0 -1
  88. package/dist-client/client/components/markdown.d.ts +0 -60
  89. package/dist-client/client/components/markdown.js +0 -422
  90. package/dist-client/client/components/markdown.js.map +0 -1
  91. package/dist-client/client/components/markdown.test.d.ts +0 -1
  92. package/dist-client/client/components/markdown.test.js +0 -418
  93. package/dist-client/client/components/markdown.test.js.map +0 -1
  94. package/dist-client/client/components/mention-popup-helpers.d.ts +0 -165
  95. package/dist-client/client/components/mention-popup-helpers.js +0 -330
  96. package/dist-client/client/components/mention-popup-helpers.js.map +0 -1
  97. package/dist-client/client/components/mention-popup.d.ts +0 -35
  98. package/dist-client/client/components/mention-popup.js +0 -223
  99. package/dist-client/client/components/mention-popup.js.map +0 -1
  100. package/dist-client/client/components/mention-popup.test.d.ts +0 -1
  101. package/dist-client/client/components/mention-popup.test.js +0 -474
  102. package/dist-client/client/components/mention-popup.test.js.map +0 -1
  103. package/dist-client/client/utils/board-edit-patch.d.ts +0 -157
  104. package/dist-client/client/utils/board-edit-patch.js +0 -284
  105. package/dist-client/client/utils/board-edit-patch.js.map +0 -1
  106. package/dist-server/service/chat-message/chat-message-broadcast.d.ts +0 -13
  107. package/dist-server/service/chat-message/chat-message-broadcast.js +0 -13
  108. package/dist-server/service/chat-message/chat-message-broadcast.js.map +0 -1
  109. package/dist-server/service/chat-message/chat-message-publish.d.ts +0 -6
  110. package/dist-server/service/chat-message/chat-message-publish.js +0 -27
  111. package/dist-server/service/chat-message/chat-message-publish.js.map +0 -1
  112. package/dist-server/service/chat-message/chat-message-subscription.d.ts +0 -8
  113. package/dist-server/service/chat-message/chat-message-subscription.js +0 -53
  114. package/dist-server/service/chat-message/chat-message-subscription.js.map +0 -1
  115. package/dist-server/service/chat-message/chat-message.d.ts +0 -68
  116. package/dist-server/service/chat-message/chat-message.js +0 -172
  117. package/dist-server/service/chat-message/chat-message.js.map +0 -1
  118. package/dist-server/service/chat-message/fold-history.d.ts +0 -43
  119. package/dist-server/service/chat-message/fold-history.js +0 -33
  120. package/dist-server/service/chat-message/fold-history.js.map +0 -1
  121. package/dist-server/service/chat-message/history-summary.d.ts +0 -43
  122. package/dist-server/service/chat-message/history-summary.js +0 -77
  123. package/dist-server/service/chat-message/history-summary.js.map +0 -1
  124. package/dist-server/service/chat-message/index.d.ts +0 -5
  125. package/dist-server/service/chat-message/index.js +0 -12
  126. package/dist-server/service/chat-message/index.js.map +0 -1
  127. package/dist-server/service/chat-message/llm-history.d.ts +0 -46
  128. package/dist-server/service/chat-message/llm-history.js +0 -87
  129. package/dist-server/service/chat-message/llm-history.js.map +0 -1
  130. package/dist-server/service/chat-session/chat-session.d.ts +0 -62
  131. package/dist-server/service/chat-session/chat-session.js +0 -164
  132. package/dist-server/service/chat-session/chat-session.js.map +0 -1
  133. package/dist-server/service/chat-session/index.d.ts +0 -3
  134. package/dist-server/service/chat-session/index.js +0 -7
  135. package/dist-server/service/chat-session/index.js.map +0 -1
  136. package/dist-server/service/chat-session/session-activity-broadcast.d.ts +0 -30
  137. package/dist-server/service/chat-session/session-activity-broadcast.js +0 -34
  138. package/dist-server/service/chat-session/session-activity-broadcast.js.map +0 -1
  139. package/dist-server/service/chat-session/session-activity-publish.d.ts +0 -12
  140. package/dist-server/service/chat-session/session-activity-publish.js +0 -37
  141. package/dist-server/service/chat-session/session-activity-publish.js.map +0 -1
  142. package/dist-server/service/chat-session/session-activity-subscription.d.ts +0 -14
  143. package/dist-server/service/chat-session/session-activity-subscription.js +0 -87
  144. package/dist-server/service/chat-session/session-activity-subscription.js.map +0 -1
  145. package/dist-server/service/chat-session/session-anchor.d.ts +0 -12
  146. package/dist-server/service/chat-session/session-anchor.js +0 -20
  147. package/dist-server/service/chat-session/session-anchor.js.map +0 -1
  148. package/dist-server/service/chat-session/session-inbox.d.ts +0 -80
  149. package/dist-server/service/chat-session/session-inbox.js +0 -133
  150. package/dist-server/service/chat-session/session-inbox.js.map +0 -1
  151. package/dist-server/service/chat-session-participant/chat-session-participant.d.ts +0 -49
  152. package/dist-server/service/chat-session-participant/chat-session-participant.js +0 -141
  153. package/dist-server/service/chat-session-participant/chat-session-participant.js.map +0 -1
  154. package/dist-server/service/chat-session-participant/index.d.ts +0 -4
  155. package/dist-server/service/chat-session-participant/index.js +0 -10
  156. package/dist-server/service/chat-session-participant/index.js.map +0 -1
  157. package/dist-server/service/chat-session-participant/invite-notification.d.ts +0 -27
  158. package/dist-server/service/chat-session-participant/invite-notification.js +0 -30
  159. package/dist-server/service/chat-session-participant/invite-notification.js.map +0 -1
  160. package/dist-server/service/chat-session-participant/presence.d.ts +0 -4
  161. package/dist-server/service/chat-session-participant/presence.js +0 -22
  162. package/dist-server/service/chat-session-participant/presence.js.map +0 -1
  163. package/dist-server/service/chat-session-resolver.d.ts +0 -102
  164. package/dist-server/service/chat-session-resolver.js +0 -856
  165. package/dist-server/service/chat-session-resolver.js.map +0 -1
  166. package/dist-server/service/subscribe-auth.d.ts +0 -14
  167. package/dist-server/service/subscribe-auth.js +0 -33
  168. package/dist-server/service/subscribe-auth.js.map +0 -1
  169. package/server/service/chat-message/chat-message-broadcast.integration.test.ts +0 -73
  170. package/server/service/chat-message/chat-message-broadcast.test.ts +0 -40
  171. package/server/service/chat-message/chat-message-broadcast.ts +0 -27
  172. package/server/service/chat-message/chat-message-publish.ts +0 -22
  173. package/server/service/chat-message/chat-message-subscription.ts +0 -41
  174. package/server/service/chat-message/chat-message.ts +0 -195
  175. package/server/service/chat-message/fold-history.test.ts +0 -98
  176. package/server/service/chat-message/fold-history.ts +0 -77
  177. package/server/service/chat-message/history-summary.test.ts +0 -127
  178. package/server/service/chat-message/history-summary.ts +0 -100
  179. package/server/service/chat-message/index.ts +0 -12
  180. package/server/service/chat-message/llm-history.test.ts +0 -194
  181. package/server/service/chat-message/llm-history.ts +0 -163
  182. package/server/service/chat-session/chat-session.ts +0 -182
  183. package/server/service/chat-session/index.ts +0 -5
  184. package/server/service/chat-session/session-activity-broadcast.ts +0 -57
  185. package/server/service/chat-session/session-activity-publish.ts +0 -44
  186. package/server/service/chat-session/session-activity-subscription.ts +0 -67
  187. package/server/service/chat-session/session-anchor.ts +0 -35
  188. package/server/service/chat-session/session-inbox.test.ts +0 -185
  189. package/server/service/chat-session/session-inbox.ts +0 -161
  190. package/server/service/chat-session-participant/chat-session-participant.ts +0 -135
  191. package/server/service/chat-session-participant/index.ts +0 -6
  192. package/server/service/chat-session-participant/invite-notification.test.ts +0 -30
  193. package/server/service/chat-session-participant/invite-notification.ts +0 -48
  194. package/server/service/chat-session-participant/presence.test.ts +0 -39
  195. package/server/service/chat-session-participant/presence.ts +0 -21
  196. package/server/service/chat-session-resolver.ts +0 -779
  197. package/server/service/session-anchor.test.ts +0 -65
  198. package/server/service/subscribe-auth.test.ts +0 -57
  199. package/server/service/subscribe-auth.ts +0 -34
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/board-ai",
3
- "version": "10.1.4",
3
+ "version": "10.1.6",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/client/index.js",
6
6
  "things-factory": true,
@@ -31,9 +31,10 @@
31
31
  "@operato/graphql": "^10.0.0",
32
32
  "@operato/i18n": "^10.0.0",
33
33
  "@operato/styles": "^10.0.0",
34
- "@things-factory/ai-client-base": "^10.1.3",
35
- "@things-factory/auth-base": "^10.1.4",
36
- "@things-factory/board-import": "^10.1.4",
34
+ "@things-factory/ai-assistant": "^10.1.6",
35
+ "@things-factory/ai-client-base": "^10.1.6",
36
+ "@things-factory/auth-base": "^10.1.6",
37
+ "@things-factory/board-import": "^10.1.6",
37
38
  "@things-factory/env": "^10.1.3",
38
39
  "@things-factory/shell": "^10.1.4",
39
40
  "dompurify": "^3.0.0",
@@ -45,5 +46,5 @@
45
46
  "copyfiles": "^2.4.1",
46
47
  "rimraf": "^5.0.0"
47
48
  },
48
- "gitHead": "c653a2967daa76b3828f1eb7f4eb18934263d4b8"
49
+ "gitHead": "963aeb9ac470be5aea05bb5842a0a92afcab6036"
49
50
  }
@@ -14,7 +14,9 @@
14
14
  import * as serverModule from './apply-patch'
15
15
  // 테스트 환경은 jest node — client 측 module 도 plain TS 라서 직접 import 가능
16
16
  // (Lit / @material/web 같은 ESM 의존 없음)
17
- import * as clientModule from '../../client/utils/board-edit-patch'
17
+ /* 클라이언트 쪽 구현은 로 옮겼다(2026-09-10). 이 시험은 두 구현이
18
+ * 어긋나는 것을 잡는 것이므로, 옮긴 자리를 그대로 가리킨다. */
19
+ import * as clientModule from '../../../ai-assistant/client/utils/board-edit-patch'
18
20
 
19
21
  describe('apply-patch drift 자동 검증 — server / client 사본 동기 보장', () => {
20
22
  test('주요 함수 export 모두 양쪽에 존재', () => {
@@ -40,7 +40,15 @@ import { buildComponentStyleSummary } from './styling/read-tools'
40
40
  *
41
41
  * board 의 것(문서·선택 refid·편집 op)은 타입 매개변수로 넘긴다 — 루프는 그 모양을 모른다.
42
42
  */
43
- import { runAgenticLoop, type AgenticLoopResult } from '@things-factory/ai-client-base'
43
+ import {
44
+ runAgenticLoop,
45
+ formatAbortNotice,
46
+ summarizeToolResult,
47
+ registryToolGate,
48
+ extendToolGate,
49
+ type ToolGate as NeutralToolGate,
50
+ type AgenticLoopResult
51
+ } from '@things-factory/ai-client-base'
44
52
  import type { BoardActionOp } from './types.js'
45
53
 
46
54
  /** 이 대화면의 도구가 알아야 하는 것 — 루프는 나르기만 한다. */
@@ -296,7 +304,8 @@ export class DefaultBoardAIAssistant implements BoardAIAssistant {
296
304
  * 근거집합의 정의와 사정거리는 ./grounding 머리말 참조(식별자 없는 창작 서술은 잡지 못한다). */
297
305
  const warnings = checkReplyGrounding(baseReply, {
298
306
  systemPrompt,
299
- boardContext,
307
+ /* 근거집합의 칸 이름은 공용 패키지의 것이다 — 이 값이 보드 문맥이라는 것은 이 파일만 안다. */
308
+ hostContext: boardContext,
300
309
  messages: messages.map(m => (typeof m.content === 'string' ? m.content : '')),
301
310
  toolResults: loopResult.groundedTexts
302
311
  })
@@ -1990,64 +1999,20 @@ export function listLayoutTemplateNames(): string[] {
1990
1999
  return Object.keys(LAYOUT_TEMPLATES.templates)
1991
2000
  }
1992
2001
 
1993
- /**
1994
- * agentic loop 의 비정상 종료 사유 → 사용자 메시지에 부기할 한국어 한 줄.
2002
+ /*
2003
+ * 중단 통지 · 도구 결과 요약 — **공용 패키지로 옮겼다** (2026-09-10).
1995
2004
  *
1996
- * 정상 종료 (abortReason undefined) 면 빈 문자열 — concat 해도 무해.
1997
- * 사용자에게 "왜 중간에 멈췄는지" 단서 제공이 목적 — 침묵 종료 금지.
1998
- */
1999
- export function formatAbortNotice(
2000
- abortReason: AgenticLoopResult['abortReason']
2001
- ): string {
2002
- if (!abortReason) return ''
2003
- switch (abortReason.type) {
2004
- case 'aborted':
2005
- return '\n\n_(요청이 중단되어 일부만 처리됐어요.)_'
2006
- case 'provider_error':
2007
- /* 남의 말(SDK 원문)을 사용자에게 보여주지 않는다 — "Error fetching from undefined: [503 …]" 은
2008
- * 사용자가 할 수 있는 일을 알려주지 않는다. 일시 장애는 이미 몇 번 다시 시도한 뒤이므로,
2009
- * 지금 필요한 안내는 "잠시 후 다시" 하나다. 원문은 서버 로그에 남는다. */
2010
- return isTransientAIError({ message: abortReason.message })
2011
- ? '\n\n_(AI 서비스가 일시적으로 응답하지 않았어요. 잠시 후 다시 시도해 주세요.)_'
2012
- : '\n\n_(처리 중 오류가 발생했어요. 같은 요청이 계속 실패하면 관리자에게 알려 주세요.)_'
2013
- case 'max_iterations':
2014
- return `\n\n_(작업이 너무 길어 ${abortReason.iter}회에서 중단했어요. 더 작게 나눠 요청해보세요.)_`
2015
- case 'repeated_validation_failure':
2016
- return `\n\n_(\`${abortReason.toolName}\` 호출이 ${abortReason.consecutive}회 연속 실패해 중단했어요. 요청을 다시 표현해보세요.)_`
2017
- }
2018
- }
2019
-
2020
- /**
2021
- * tool 결과를 UI fold-able 박스에 보여줄 만한 크기로 요약.
2005
+ * 둘 다 보드를 모르는 함수였다. 옮긴 이유는 중복이 아니라 **한쪽만 갖고 있었던 것**이다:
2006
+ * `twin-ai` 는 `summarizeToolResult: v => v` 로 요약을 아예 안 하고 있었다. 한 제품은 압축해서
2007
+ * 영속하고 다른 제품은 원본을 영속했고, 그렇게 갈린 이유가 어디에도 없었다.
2008
+ *
2009
+ * 중단 통지는 더 무겁다 — 안 붙이면 **잘린 답이 완결된 답과 똑같이 보인다.** 그 보장을 제품마다
2010
+ * 기억해야 하는 것으로 두면 언젠가 한 제품이 잊는다.
2022
2011
  *
2023
- * 원본 그대로면 findComponents 50개 / getAtmosphereGuide 같은 큰 객체가 ChatMessage
2024
- * 영속까지 부담 — 핵심 모양만 유지하고 나머지는 marker.
2012
+ * 여기 남은 것은 재수출이다. 이 패키지를 지나는 소비처가 이름을 그대로 쓰게 한다.
2025
2013
  */
2026
- export function summarizeToolResult(value: any, depth = 0): any {
2027
- if (value === null || value === undefined) return value
2028
- if (typeof value === 'string') {
2029
- return value.length > 200 ? `${value.slice(0, 200)}…[+${value.length - 200} chars]` : value
2030
- }
2031
- if (typeof value === 'number' || typeof value === 'boolean') return value
2032
- if (Array.isArray(value)) {
2033
- if (depth >= 2) return `[Array(${value.length})]`
2034
- const out = value.slice(0, 5).map(v => summarizeToolResult(v, depth + 1))
2035
- if (value.length > 5) out.push(`…[+${value.length - 5} items]`)
2036
- return out
2037
- }
2038
- if (typeof value === 'object') {
2039
- if (depth >= 3) {
2040
- const keys = Object.keys(value).slice(0, 6).join(',')
2041
- return `{${keys}${Object.keys(value).length > 6 ? ',…' : ''}}`
2042
- }
2043
- const out: any = {}
2044
- for (const key of Object.keys(value)) {
2045
- out[key] = summarizeToolResult(value[key], depth + 1)
2046
- }
2047
- return out
2048
- }
2049
- return undefined
2050
- }
2014
+ export { formatAbortNotice, summarizeToolResult }
2015
+
2051
2016
 
2052
2017
  export function isWriteTool(name: string): boolean {
2053
2018
  if (WRITE_TOOL_NAMES.has(name)) return true
@@ -2079,27 +2044,36 @@ export interface ToolExposure {
2079
2044
  toolCategories?: ToolCategoryFilter
2080
2045
  }
2081
2046
 
2082
- export interface ToolGate {
2047
+ /**
2048
+ * 보드의 게이트 — 공용 게이트 + 이 패키지가 가진 코어 도구 이름집합.
2049
+ *
2050
+ * `coreOn` 이 여기 있는 이유: 「코어 보드 편집 도구」라는 것이 board-ai 에만 있는 개념이다.
2051
+ * 공용 게이트는 레지스트리만 안다.
2052
+ */
2053
+ export interface ToolGate extends NeutralToolGate {
2083
2054
  coreOn: boolean
2084
- filter: ToolCategoryFilter
2085
- isRead(name: string): boolean
2086
- isWrite(name: string): boolean
2087
- isAction(name: string): boolean
2088
- spec(name: string): ToolSpec | undefined
2089
2055
  }
2090
2056
 
2057
+ /**
2058
+ * ── 레지스트리 판정을 공용으로 옮겼다 (2026-09-10) ─────────────────────────
2059
+ * 이 함수가 두 출처에 답하고 있었다 — **도구 레지스트리**(등록된 카테고리의 kind·spec)와
2060
+ * **이 패키지의 코어 이름집합**. 앞엣것은 중립이다: 「이 이름이 어느 종류이고 이 대화가 볼 수
2061
+ * 있나」는 호스트가 누구든 같은 답이다.
2062
+ *
2063
+ * 그래서 앞엣것은 `registryToolGate` 로 내려갔고, 여기서 뒤엣것을 **더한다**. 이 갈이가 없으면
2064
+ * 보드 없는 제품이 같은 dispatch 규율을 받으려고 자기 게이트를 써야 하고, 그러면 접지 가드가
2065
+ * 갈렸던 것과 같은 일이 게이트에서 난다.
2066
+ *
2067
+ * 더하는 것이지 덮는 것이 아니다 — 덮으면 코어 이름집합에 없는 등록 도구가 조용히 잠긴다.
2068
+ */
2091
2069
  export function buildToolGate(exposure: ToolExposure = {}): ToolGate {
2092
2070
  const coreOn = exposure.boardTools !== false
2093
- const filter = exposure.toolCategories
2094
- const kind = (name: string) => getToolKind(name, filter)
2095
- return {
2096
- coreOn,
2097
- filter,
2098
- isRead: name => (coreOn && READ_TOOL_NAMES.has(name)) || kind(name) === 'read' || kind(name) === 'external',
2099
- isWrite: name => (coreOn && WRITE_TOOL_NAMES.has(name)) || kind(name) === 'write',
2100
- isAction: name => (coreOn && ACTION_TOOL_NAMES.has(name)) || kind(name) === 'action',
2101
- spec: name => findToolSpec(name, filter)
2102
- }
2071
+ const gate = extendToolGate(registryToolGate(exposure.toolCategories), {
2072
+ isRead: name => coreOn && READ_TOOL_NAMES.has(name),
2073
+ isWrite: name => coreOn && WRITE_TOOL_NAMES.has(name),
2074
+ isAction: name => coreOn && ACTION_TOOL_NAMES.has(name)
2075
+ })
2076
+ return { ...gate, coreOn }
2103
2077
  }
2104
2078
 
2105
2079
  /**
@@ -15,12 +15,13 @@ import { createAIClient, getDefaultAIClient, type AIClient } from '@things-facto
15
15
  import type { ComponentCategory } from '@things-factory/board-import'
16
16
 
17
17
  import { DefaultBoardAIAssistant } from './assistant.js'
18
+ import { effectiveBoardTools } from './board-tool-rights.js'
18
19
  import type { LLMMessage } from './types.js'
19
- import { ChatSession } from './chat-session/chat-session.js'
20
- import { ChatMessage } from './chat-message/chat-message.js'
21
- import { buildLlmHistory } from './chat-message/llm-history.js'
22
- import { splitByCap, summaryInstruction } from './chat-message/history-summary.js'
23
- import { foldHistorySummary } from './chat-message/fold-history.js'
20
+ import { ChatSession } from '@things-factory/ai-assistant'
21
+ import { ChatMessage } from '@things-factory/ai-assistant'
22
+ import { buildLlmHistory } from '@things-factory/ai-assistant'
23
+ import { splitByCap, summaryInstruction } from '@things-factory/ai-assistant'
24
+ import { foldHistorySummary } from '@things-factory/ai-assistant'
24
25
 
25
26
  /**
26
27
  * 모델에 넘길 최근 메시지 수 상한.
@@ -38,13 +39,13 @@ const LLM_HISTORY_MAX_TURNS = 40
38
39
  * 상한을 넘긴 뒤 매 턴 요약을 만들면 대화마다 LLM 호출이 두 번씩 붙는다. 10 개씩 모아 접는다.
39
40
  */
40
41
  const LLM_SUMMARY_THRESHOLD = 10
41
- import { activityPatch, autoSessionName } from './chat-session/session-inbox.js'
42
- import { publishSessionActivity } from './chat-session/session-activity-publish.js'
43
- import { publishChatMessage } from './chat-message/chat-message-publish.js'
42
+ import { activityPatch, autoSessionName } from '@things-factory/ai-assistant'
43
+ import { publishSessionActivity } from '@things-factory/ai-assistant'
44
+ import { publishChatMessage } from '@things-factory/ai-assistant'
44
45
  import { PatchEntry } from './patch-entry/patch-entry.js'
45
46
  import { publishBoardPatch } from './patch-entry/board-patch-subscription.js'
46
- import { ChatSessionParticipant } from './chat-session-participant/chat-session-participant.js'
47
- import { buildInviteNotification } from './chat-session-participant/invite-notification.js'
47
+ import { ChatSessionParticipant } from '@things-factory/ai-assistant'
48
+ import { buildInviteNotification } from '@things-factory/ai-assistant'
48
49
  import {
49
50
  injectMentionRefids,
50
51
  injectAiMentionRefids,
@@ -258,7 +259,16 @@ export class BoardAIChatResolver {
258
259
  @Mutation(() => BoardAIChatOutput, {
259
260
  description: 'AI 주도 보드 모델링 — 자연어 채팅으로 보드 생성·구조편집·스타일링. sessionId 로 영속 컨텍스트.'
260
261
  })
261
- @Directive('@privilege(category: "board-ai", privilege: "mutation")')
262
+ /*
263
+ * ── 이 문은 「묻는 것」이지 「보드를 고치는 것」이 아니다 (2026-09-10) ──────────
264
+ * 공장 현황을 묻는 화면이 **보드 편집 권한**을 요구하던 것이 이 분리의 시작이었다. 대화는
265
+ * `ai-assistant:mutation`, 보드 편집은 `board-ai:mutation` — 둘은 다른 능력이다.
266
+ *
267
+ * ⚠ 문만 바꾸면 **거꾸로 넓어진다.** 코어 보드 편집 도구가 기본으로 켜져 있으므로, 대화 권한만
268
+ * 가진 사람이 이 문으로 보드를 고치게 된다. 그래서 아래에서 **보드 편집 권한을 따로 보고**
269
+ * 없으면 그 도구를 끈다(§`effectiveBoardTools`).
270
+ */
271
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", legacy: "board-ai")')
262
272
  async boardAIChat(
263
273
  @Arg('input') input: BoardAIChatInput,
264
274
  @Ctx() context: ResolverContext
@@ -401,7 +411,7 @@ export class BoardAIChatResolver {
401
411
  hostContext: input.hostContext ?? undefined,
402
412
  /* 대화면별 도구 노출 — 미지정이면 현행(전부 허용) 그대로. */
403
413
  toolCategories: input.toolCategories ?? undefined,
404
- boardTools: input.boardTools ?? undefined,
414
+ boardTools: await effectiveBoardTools(input.boardTools, context),
405
415
  requireGroundingTools: input.requireGroundingTools ?? undefined,
406
416
  knownTypes: input.knownTypes,
407
417
  categories: input.categories as ComponentCategory[] | undefined,
@@ -0,0 +1,183 @@
1
+ /*
2
+ * 보드에 매인 대화 op — **보드 편집기의 것이라 여기 남는다.**
3
+ *
4
+ * ── 왜 갈랐나 (2026-09-10) ──────────────────────────────────────────────────
5
+ * `chat-session-resolver.ts` 에 스물넷이 함께 있었다. 그 중 열여덟은 대화 자체의 일(세션·참가자·
6
+ * 인박스·메시지)이라 보드가 없는 제품에도 뜻이 있고, 여섯은 보드의 일이다. 한 파일에 있는 동안은
7
+ * 앞엣것을 중립 패키지로 옮길 수 없다 — 옮기면 `ai-assistant → board-ai` 의존이 생긴다.
8
+ *
9
+ * 그래서 **여섯을 이쪽으로 뗐다.** 남은 열여덟이 옮겨질 수 있는 상태가 되는 것이 이 갈이의 목적이다.
10
+ *
11
+ * ── 왜 여섯인가 ─────────────────────────────────────────────────────────────
12
+ * chatSessionByBoard · chatSessionsByBoard 보드 id 로 찾는다 — 보드를 아는 질의
13
+ * startBoardAISession 보드 앵커로 시작하는 얇은 래퍼
14
+ * chatPatches · recordDirectPatch · revertPatch PatchEntry — 보드 편집 이력
15
+ *
16
+ * ── 위임을 `this` 로 하지 않는다 ────────────────────────────────────────────
17
+ * 앞의 셋은 원래 `this.startAISession(...)` 처럼 같은 클래스의 중립 메서드를 불렀다. 이제 클래스가
18
+ * 둘이므로 중립 리졸버 인스턴스에 위임한다. 리졸버는 상태가 없다(전부 `context.state` 에서 읽는다).
19
+ *
20
+ * **로직을 베끼지 않는다** — 같은 글이 두 곳에 있으면 한쪽만 고쳐진다.
21
+ */
22
+ import { Resolver, Query, Mutation, Arg, Ctx, Directive, Int } from 'type-graphql'
23
+ import { GraphQLJSON } from 'graphql-scalars'
24
+
25
+ import { getRepository } from '@things-factory/shell'
26
+
27
+ import { ChatSession } from '@things-factory/ai-assistant'
28
+ import { activityPatch } from '@things-factory/ai-assistant'
29
+ import { publishSessionActivity } from '@things-factory/ai-assistant'
30
+ import { ChatMessage } from '@things-factory/ai-assistant'
31
+ import { publishChatMessage } from '@things-factory/ai-assistant'
32
+ import { ChatSessionResolver } from '@things-factory/ai-assistant'
33
+ import { PatchEntry } from './patch-entry/patch-entry.js'
34
+ import { publishBoardPatch } from './patch-entry/board-patch-subscription.js'
35
+
36
+ /** 중립 op 로의 위임 대상. 리졸버는 상태가 없으므로 하나면 된다. */
37
+ const sessions = new ChatSessionResolver()
38
+
39
+ @Resolver()
40
+ export class BoardSessionResolver {
41
+ @Query(() => ChatSession, { nullable: true, description: 'Get AI chat session by board id (returns first match — backward compat single-session lookup).' })
42
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", legacy: "board-ai")')
43
+ async chatSessionByBoard(
44
+ @Arg('boardId') boardId: string,
45
+ @Ctx() context: ResolverContext
46
+ ): Promise<ChatSession | null> {
47
+ const { domain } = context.state
48
+ return (
49
+ (await getRepository(ChatSession).findOneBy({ boardId, domain: { id: domain.id } })) ?? null
50
+ )
51
+ }
52
+
53
+ /** 보드 전용 진입점 — 앵커 조회의 얇은 래퍼(기존 소비자 무변경). */
54
+ @Query(() => [ChatSession], {
55
+ description: 'List all AI chat sessions for a board (oldest first). Thin wrapper over chatSessionsByAnchor(board).'
56
+ })
57
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", legacy: "board-ai")')
58
+ async chatSessionsByBoard(
59
+ @Arg('boardId') boardId: string,
60
+ @Ctx() context: ResolverContext
61
+ ): Promise<ChatSession[]> {
62
+ return await sessions.chatSessionsByAnchor('board', boardId, context)
63
+ }
64
+
65
+ @Query(() => [PatchEntry], { description: 'List patch entries of a session, newest first.' })
66
+ /*
67
+ * ⚠ 이 셋은 **보드 편집기의 것**이라 `board-ai` 에 남는다 (2026-09-10).
68
+ *
69
+ * 나머지 스물둘을 `ai-assistant` 로 옮길 때 이것들도 함께 갔다가, 도는 스키마를 확인하면서
70
+ * 되돌렸다 — `revertPatch` 가 `ai-assistant:mutation` 으로 떠 있었다. 즉 **대화 권한만 가진
71
+ * 사람이 보드 패치를 되돌릴 수 있었다.** 좁히려던 변경이 넓히는 변경이 된 자리다.
72
+ *
73
+ * 가르는 물음: **이것이 보드를 바꾸나.** 패치는 바꾼다. 세션 조회는 안 바꾼다.
74
+ */
75
+ @Directive('@privilege(category: "board-ai", privilege: "query")')
76
+ async chatPatches(
77
+ @Arg('sessionId') sessionId: string,
78
+ @Arg('limit', () => Int, { nullable: true, defaultValue: 100 }) limit: number,
79
+ @Ctx() context: ResolverContext
80
+ ): Promise<PatchEntry[]> {
81
+ const { domain } = context.state
82
+ const session = await getRepository(ChatSession).findOneBy({
83
+ id: sessionId,
84
+ domain: { id: domain.id }
85
+ })
86
+ if (!session) return []
87
+ return await getRepository(PatchEntry).find({
88
+ where: { session: { id: sessionId } as any },
89
+ order: { createdAt: 'DESC' },
90
+ take: limit
91
+ })
92
+ }
93
+
94
+ /** 보드 전용 진입점 — 앵커 시작의 얇은 래퍼(기존 소비자 무변경). */
95
+ @Mutation(() => ChatSession, {
96
+ description: 'Start (or get existing) AI chat session for a board. Thin wrapper over startAISession(board).'
97
+ })
98
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", legacy: "board-ai")')
99
+ async startBoardAISession(
100
+ @Arg('boardId') boardId: string,
101
+ @Ctx() context: ResolverContext
102
+ ): Promise<ChatSession> {
103
+ return await sessions.startAISession('board', boardId, context)
104
+ }
105
+
106
+ @Directive('@transaction')
107
+ @Mutation(() => PatchEntry, {
108
+ description: 'Record a patch from user direct edit. Adds a system message so AI sees the change next turn.'
109
+ })
110
+ @Directive('@privilege(category: "board-ai", privilege: "mutation")')
111
+ async recordDirectPatch(
112
+ @Arg('sessionId') sessionId: string,
113
+ @Arg('ops', () => GraphQLJSON) ops: any[],
114
+ @Arg('summary', { nullable: true }) summary: string | undefined,
115
+ @Ctx() context: ResolverContext
116
+ ): Promise<PatchEntry> {
117
+ const { domain, tx, user } = context.state
118
+ const session = await getRepository(ChatSession, tx).findOneBy({
119
+ id: sessionId,
120
+ domain: { id: domain.id }
121
+ })
122
+ if (!session) throw new Error(`ChatSession ${sessionId} not found`)
123
+
124
+ const entry = await getRepository(PatchEntry, tx).save({
125
+ session: { id: session.id } as any,
126
+ source: 'user-direct',
127
+ ops: JSON.stringify(ops || []),
128
+ summary,
129
+ reverted: false
130
+ } as any)
131
+
132
+ // AI 컨텍스트 흐름 — system 메시지 자동 추가. creator = 직접 편집한 사용자.
133
+ // parentMessage = 직전 in-session 메시지 (timeline 연속성 유지 — system 도 chain 에 합류).
134
+ const previousLast = await getRepository(ChatMessage, tx).findOne({
135
+ where: { session: { id: session.id } as any },
136
+ order: { createdAt: 'DESC' }
137
+ })
138
+ const sysMsg = await getRepository(ChatMessage, tx).save({
139
+ session: { id: session.id } as any,
140
+ role: 'system',
141
+ content: `User directly edited the board: ${summary ?? `${(ops || []).length} op(s)`}`,
142
+ relatedPatchId: entry.id,
143
+ creator: user,
144
+ updater: user,
145
+ parentMessage: previousLast ? ({ id: previousLast.id } as any) : undefined
146
+ } as any)
147
+
148
+ /* 세션 활동 갱신 — 직접 편집도 다른 참여자에게는 새 소식이다(목록 정렬·안 읽음 판정). */
149
+ try {
150
+ await getRepository(ChatSession, tx).update(session.id!, activityPatch(sysMsg.content) as any)
151
+ } catch {
152
+ /* best-effort — 편집 기록을 막지 않는다 */
153
+ }
154
+ publishSessionActivity({
155
+ domainId: domain.id,
156
+ sessionId: session.id!,
157
+ kind: 'message',
158
+ anchorType: (session as any).anchorType,
159
+ anchorId: (session as any).anchorId,
160
+ preview: sysMsg.content,
161
+ actorId: user?.id
162
+ })
163
+
164
+ // C1: user-direct 편집도 같은 board 구독자에게 방송 (AI·타참여자 실시간 반영). best-effort.
165
+ publishBoardPatch({ domainId: domain.id, boardId: session.boardId, patch: entry })
166
+ // C4: 직접편집 system 메시지를 같은 세션 구독자에게 방송 (transcript 실시간 일관성). best-effort.
167
+ publishChatMessage({ domainId: domain.id, sessionId: session.id, message: sysMsg })
168
+
169
+ return entry
170
+ }
171
+
172
+ @Directive('@transaction')
173
+ @Mutation(() => Boolean, { description: 'Mark a patch as reverted (does not undo, only flags).' })
174
+ @Directive('@privilege(category: "board-ai", privilege: "mutation")')
175
+ async revertPatch(
176
+ @Arg('patchId') patchId: string,
177
+ @Ctx() context: ResolverContext
178
+ ): Promise<boolean> {
179
+ const { tx } = context.state
180
+ await getRepository(PatchEntry, tx).update(patchId, { reverted: true })
181
+ return true
182
+ }
183
+ }
@@ -0,0 +1,96 @@
1
+ /*
2
+ * 무엇을 지키는가: **권한을 좁히려던 변경이 권한을 넓히지 않는 것.**
3
+ *
4
+ * `boardAIChat` 의 문이 `board-ai:mutation` 에서 `ai-assistant:mutation` 으로 옮겨졌다. 코어 보드
5
+ * 편집 도구는 기본이 켜짐이므로, 그 한 줄만 바꾸면 **대화 권한만 가진 사람이 같은 문으로 보드를
6
+ * 고치게 된다.** 시작한 문제(공장 질문이 보드 편집 권한을 요구한다)를 뒤집어 놓는 것이다.
7
+ *
8
+ * 여기 있는 시험들은 그 뒤집힘이 다시 생기면 실패한다.
9
+ */
10
+ const mockCheckPermission = jest.fn()
11
+ const mockWarn = jest.fn()
12
+
13
+ jest.mock('@things-factory/auth-base', () => ({ checkPermission: (...a: any[]) => mockCheckPermission(...a) }))
14
+ jest.mock('@things-factory/env', () => ({ logger: { warn: (...a: any[]) => mockWarn(...a) } }))
15
+
16
+ import { effectiveBoardTools } from './board-tool-rights'
17
+
18
+ const context = (over: any = {}) => ({
19
+ state: {
20
+ domain: { id: 'dom-1', subdomain: 'miratek-gunsan' },
21
+ user: { id: 'u-1', email: 'a@b.c' },
22
+ unsafeIP: false,
23
+ prohibitedPrivileges: [],
24
+ ...over
25
+ }
26
+ })
27
+
28
+ beforeEach(() => {
29
+ mockCheckPermission.mockReset()
30
+ mockWarn.mockReset()
31
+ })
32
+
33
+ describe('보드 편집 권한이 없으면 편집 도구가 켜지지 않는다', () => {
34
+ it('★ 권한이 없으면 기본값(미지정)이 켜짐으로 남지 않는다', async () => {
35
+ mockCheckPermission.mockResolvedValue(false)
36
+ expect(await effectiveBoardTools(undefined, context())).toBe(false)
37
+ })
38
+
39
+ it('권한이 있으면 미지정은 미지정 그대로다 — 기본 거동을 이 파일이 바꾸지 않는다', async () => {
40
+ mockCheckPermission.mockResolvedValue(true)
41
+ expect(await effectiveBoardTools(undefined, context())).toBeUndefined()
42
+ })
43
+
44
+ it('권한이 있고 켜 달라고 하면 켠다', async () => {
45
+ mockCheckPermission.mockResolvedValue(true)
46
+ expect(await effectiveBoardTools(true, context())).toBe(true)
47
+ })
48
+
49
+ it('권한이 없는데 켜 달라고 하면 끄고, 그 사실을 남긴다', async () => {
50
+ mockCheckPermission.mockResolvedValue(false)
51
+ expect(await effectiveBoardTools(true, context())).toBe(false)
52
+ expect(mockWarn).toHaveBeenCalledTimes(1)
53
+ expect(String(mockWarn.mock.calls[0][0])).toMatch(/board-ai:mutation/)
54
+ })
55
+
56
+ it('안 물어본 좁힘은 로그를 남기지 않는다 — 매 질문마다 한 줄이면 로그가 소음이 된다', async () => {
57
+ mockCheckPermission.mockResolvedValue(false)
58
+ await effectiveBoardTools(undefined, context())
59
+ expect(mockWarn).not.toHaveBeenCalled()
60
+ })
61
+ })
62
+
63
+ describe('끄겠다고 한 것을 되살리지 않는다', () => {
64
+ it('false 는 권한이 있어도 false 다 — 보드 없는 대화면이 이 값을 준다', async () => {
65
+ mockCheckPermission.mockResolvedValue(true)
66
+ expect(await effectiveBoardTools(false, context())).toBe(false)
67
+ })
68
+
69
+ it('false 면 권한을 묻지도 않는다 — 물을 이유가 없다', async () => {
70
+ await effectiveBoardTools(false, context())
71
+ expect(mockCheckPermission).not.toHaveBeenCalled()
72
+ })
73
+ })
74
+
75
+ describe('묻는 방식', () => {
76
+ it('board-ai:mutation 을 묻는다', async () => {
77
+ mockCheckPermission.mockResolvedValue(true)
78
+ await effectiveBoardTools(undefined, context())
79
+ expect(mockCheckPermission.mock.calls[0][0]).toEqual({ category: 'board-ai', privilege: 'mutation' })
80
+ })
81
+
82
+ it('★ 소유자·슈퍼유저 우회를 새로 주지 않는다 — 좁히는 변경이 우회를 늘리면 안 된다', async () => {
83
+ mockCheckPermission.mockResolvedValue(true)
84
+ await effectiveBoardTools(undefined, context())
85
+ const asked = mockCheckPermission.mock.calls[0][0]
86
+ expect(asked.owner).toBeUndefined()
87
+ expect(asked.super).toBeUndefined()
88
+ })
89
+
90
+ it('금지 목록을 그대로 넘긴다 — 여기서 관리자의 금지를 무력화하지 않는다', async () => {
91
+ mockCheckPermission.mockResolvedValue(true)
92
+ const prohibited = [{ category: 'board-ai', privilege: 'mutation' }]
93
+ await effectiveBoardTools(undefined, context({ prohibitedPrivileges: prohibited }))
94
+ expect(mockCheckPermission.mock.calls[0][4]).toBe(prohibited)
95
+ })
96
+ })
@@ -0,0 +1,73 @@
1
+ /*
2
+ * 이 대화가 보드를 고칠 수 있나 — **문을 지난 것과 별개로** 묻는다.
3
+ *
4
+ * ── 왜 나뉘어야 하나 (2026-09-10) ───────────────────────────────────────────
5
+ * `boardAIChat` 의 문이 `board-ai:mutation` 이었다. 그래서 공장 현황을 묻는 화면이 보드 편집
6
+ * 권한을 요구했고, 그것이 이 분리의 시작이었다.
7
+ *
8
+ * 문만 `ai-assistant:mutation` 으로 바꾸면 **거꾸로 넓어진다.** 코어 보드 편집 도구는 기본으로
9
+ * 켜져 있으므로, 대화 권한만 가진 사람이 같은 문으로 보드를 고치게 된다. 권한을 좁히려던 변경이
10
+ * 권한을 넓히는 변경이 되는 것이다.
11
+ *
12
+ * 그래서 둘로 본다.
13
+ *
14
+ * 묻는다 ai-assistant:mutation 문이 본다
15
+ * 보드를 고친다 board-ai:mutation 이 파일이 본다
16
+ *
17
+ * ── 왜 게이트가 아니라 능력으로 두나 ────────────────────────────────────────
18
+ * 권한이 없다고 요청을 거절하면, 보드 편집 권한이 없는 사람은 **보드가 열린 화면에서 아무것도
19
+ * 물을 수 없게** 된다. 그건 과하다 — 읽고 묻는 것은 편집이 아니다. 그래서 거절하지 않고 **편집
20
+ * 도구만 끈다.** 모델은 그 도구를 아예 못 보고, 고쳐 달라는 말에는 할 수 없다고 답한다.
21
+ *
22
+ * 도구를 숨기는 것만으로 충분하지 않다는 것은 이미 판정돼 있다 — 정의를 숨겨도 이름을 짐작하거나
23
+ * 옛 대화가 재생되면 호출된다. 그래서 이 값은 `buildToolGate` 로 내려가 **정의 조립과 dispatch
24
+ * 판정 양쪽**에 쓰인다(`assistant.ts`).
25
+ */
26
+ import { checkPermission } from '@things-factory/auth-base'
27
+ import { logger } from '@things-factory/env'
28
+
29
+ /**
30
+ * 이 요청에 코어 보드 편집 도구를 켤 것인가.
31
+ *
32
+ * @param requested 대화면이 보낸 값. `false` 는 「끄고 싶다」는 뜻이고 그대로 존중한다.
33
+ * `undefined` 는 「기본대로」이고, 기본은 켜짐이었다 — 이제 권한이 그것을 정한다.
34
+ */
35
+ export async function effectiveBoardTools(
36
+ requested: boolean | undefined,
37
+ context: any
38
+ ): Promise<boolean | undefined> {
39
+ /* 끄겠다고 한 것을 권한으로 되살리지 않는다. plant 처럼 애초에 보드가 없는 대화면이 이 값을 준다. */
40
+ if (requested === false) return false
41
+
42
+ const { domain, user, unsafeIP, prohibitedPrivileges } = context?.state ?? {}
43
+
44
+ /*
45
+ * 소유자·슈퍼유저 우회를 주지 않는다 — 옮기기 전 이 리졸버의 지시자에도 없었다. 여기서 새로
46
+ * 주면 권한을 좁히는 변경이 우회를 하나 늘리는 변경이 된다.
47
+ */
48
+ const mayEdit = await checkPermission(
49
+ { category: 'board-ai', privilege: 'mutation' },
50
+ user,
51
+ domain,
52
+ unsafeIP,
53
+ prohibitedPrivileges
54
+ )
55
+
56
+ if (mayEdit) return requested
57
+
58
+ /*
59
+ * **달라고 했는데 못 준 경우에만 남긴다.**
60
+ *
61
+ * 안 물어본 좁힘(=`undefined`)까지 매번 적으면, 보드가 없는 대화면이 질문을 던질 때마다 한 줄씩
62
+ * 쌓여 로그가 소음이 된다. 소음이 되면 사람이 로그를 안 본다. 반대로 **명시로 켜 달라고 한 것을
63
+ * 조용히 끄면** 대화면 쪽에서 원인을 찾을 길이 없다 — 그 경우만 적는다.
64
+ */
65
+ if (requested === true) {
66
+ logger.warn(
67
+ `[board-ai] board edit tools were requested but withheld — ` +
68
+ `${user?.email || user?.id} has no board-ai:mutation in ${domain?.subdomain || domain?.id}`
69
+ )
70
+ }
71
+
72
+ return false
73
+ }
@@ -44,6 +44,8 @@ function registerFakeTwinTools(seen: { host?: any; args?: any }) {
44
44
  registerToolCategory({
45
45
  name: 'twin-ops-fake',
46
46
  description: 'fake ops tools for contract test',
47
+ /* 지침 — 지침 없는 카테고리는 등록이 거절된다(2026-09-10). */
48
+ guidance: '- fixture guidance for twin-ops-fake',
47
49
  specs: [
48
50
  {
49
51
  kind: 'read',
@@ -101,6 +103,7 @@ describe('도크 계약 — 대화면 문맥(hostContext)', () => {
101
103
  registerToolCategory({
102
104
  name: 'twin-ops-fake',
103
105
  description: 'x',
106
+ guidance: '- fixture guidance for twin-ops-fake',
104
107
  specs: [
105
108
  {
106
109
  kind: 'read',
@@ -8,10 +8,10 @@
8
8
  import { buildGroundedCorpus, checkReplyGrounding } from './grounding'
9
9
 
10
10
  describe('buildGroundedCorpus', () => {
11
- it('네 갈래를 모두 담는다 — 프롬프트·보드 문맥·이력·도구 결과', () => {
11
+ it('네 갈래를 모두 담는다 — 프롬프트·호스트 문맥·이력·도구 결과', () => {
12
12
  const corpus = buildGroundedCorpus({
13
13
  systemPrompt: 'types: dock-door',
14
- boardContext: '{"components":[{"id":"gauge-1"}]}',
14
+ hostContext: '{"components":[{"id":"gauge-1"}]}',
15
15
  messages: ['aisle-2 상태 알려줘'],
16
16
  toolResults: ['{"nodes":[{"id":"rack-9"}]}']
17
17
  })
@@ -28,7 +28,7 @@ describe('buildGroundedCorpus', () => {
28
28
  describe('checkReplyGrounding', () => {
29
29
  const sources = {
30
30
  systemPrompt: 'creatable types: dock-door, twin-node',
31
- boardContext: '{"components":[{"id":"dock-1","name":"입고 도크"}]}',
31
+ hostContext: '{"components":[{"id":"dock-1","name":"입고 도크"}]}',
32
32
  messages: ['지금 상태 어때?'],
33
33
  toolResults: ['{"instances":[{"instanceId":"hatiolab-wms"}],"nodes":[{"id":"dock-1"}]}']
34
34
  }