@things-factory/ai-assistant 10.1.38 → 10.1.39

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.
@@ -11,18 +11,18 @@ export interface ChatMessagePayload {
11
11
  * 같은 domain + session 구독자에게만 통과시키는 필터 (도메인 격리 = 보안 핵심).
12
12
  */
13
13
  export declare function chatMessageFilter(domainId: string, sessionId: string): (payload: ChatMessagePayload) => boolean;
14
- /** 방에 대한 두 물음. 저장소는 부르는 쪽이 준다 — 이 파일은 저장소를 모른다. */
14
+ /** The two questions about a room. The caller supplies the storage — this file does not know it. */
15
15
  export interface ChatRoomLookup {
16
- /** 그 세션의 지금 공개 범위. 이 도메인에 그런 세션이 없으면 `undefined`. */
16
+ /** The session's visibility now; `undefined` when this domain has no such session. */
17
17
  visibility(): Promise<string | null | undefined>;
18
- /** 부르는 사람이 지금 그 세션의 참여자인가. */
18
+ /** Whether the caller sits in that session now. */
19
19
  isParticipant(): Promise<boolean>;
20
20
  }
21
21
  export declare function mayHearRoom(room: ChatRoomLookup): Promise<boolean>;
22
22
  /**
23
- * 같은 도메인 + 세션이고 **그 방이 지금 이 사람에게 열려 있을 때만** 통과한다.
23
+ * Passes only the same domain + session, and only while **that room is open to this person**.
24
24
  *
25
- * 방은 메시지마다 다시 묻는다 — 누가 듣고 있는 동안 세션이 비공개로 바뀔 수 있다. 사람의 대화라
26
- * 메시지가 드물고, 공개 방은 조회 하나로 끝난다.
25
+ * The room is asked again for every message — a session can be made private while someone is
26
+ * listening. People write these messages, so they are infrequent, and an open room costs one query.
27
27
  */
28
28
  export declare function chatMessageRoomFilter(domainId: string, sessionId: string, room: ChatRoomLookup): (payload: ChatMessagePayload) => Promise<boolean>;
@@ -15,19 +15,19 @@ function chatMessageFilter(domainId, sessionId) {
15
15
  }
16
16
  async function mayHearRoom(room) {
17
17
  const visibility = await room.visibility();
18
- /* 이 도메인에 없는 세션 — 열림으로 읽지 않는다. */
18
+ /* A session this domain does not have — never read as open. */
19
19
  if (visibility === undefined)
20
20
  return false;
21
- /* 공개 방이면 참여 여부를 묻지 않는다. 방송마다 조회가 하나 준다. */
21
+ /* An open room does not ask about membership — one query fewer per message. */
22
22
  if ((0, session_visibility_js_1.mayReadSession)(visibility, false))
23
23
  return true;
24
24
  return (0, session_visibility_js_1.mayReadSession)(visibility, await room.isParticipant());
25
25
  }
26
26
  /**
27
- * 같은 도메인 + 세션이고 **그 방이 지금 이 사람에게 열려 있을 때만** 통과한다.
27
+ * Passes only the same domain + session, and only while **that room is open to this person**.
28
28
  *
29
- * 방은 메시지마다 다시 묻는다 — 누가 듣고 있는 동안 세션이 비공개로 바뀔 수 있다. 사람의 대화라
30
- * 메시지가 드물고, 공개 방은 조회 하나로 끝난다.
29
+ * The room is asked again for every message — a session can be made private while someone is
30
+ * listening. People write these messages, so they are infrequent, and an open room costs one query.
31
31
  */
32
32
  function chatMessageRoomFilter(domainId, sessionId, room) {
33
33
  const sameSession = chatMessageFilter(domainId, sessionId);
@@ -1 +1 @@
1
- {"version":3,"file":"chat-message-broadcast.js","sourceRoot":"","sources":["../../../server/service/chat-message/chat-message-broadcast.ts"],"names":[],"mappings":";;;AAwBA,8CAGC;AAmBD,kCAUC;AAQD,sDAGC;AA1DD,iFAAsE;AAEtE,8BAA8B;AACjB,QAAA,kBAAkB,GAAG,uBAAuB,CAAA;AASzD;;GAEG;AACH,SAAgB,iBAAiB,CAAC,QAAgB,EAAE,SAAiB;IACnE,OAAO,CAAC,OAA2B,EAAW,EAAE,CAC9C,CAAC,CAAC,OAAO,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ,IAAI,OAAO,CAAC,SAAS,KAAK,SAAS,CAAA;AACjF,CAAC;AAmBM,KAAK,UAAU,WAAW,CAAC,IAAoB;IACpD,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC,UAAU,EAAE,CAAA;IAE1C,iCAAiC;IACjC,IAAI,UAAU,KAAK,SAAS;QAAE,OAAO,KAAK,CAAA;IAE1C,2CAA2C;IAC3C,IAAI,IAAA,sCAAc,EAAC,UAAU,EAAE,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IAElD,OAAO,IAAA,sCAAc,EAAC,UAAU,EAAE,MAAM,IAAI,CAAC,aAAa,EAAE,CAAC,CAAA;AAC/D,CAAC;AAED;;;;;GAKG;AACH,SAAgB,qBAAqB,CAAC,QAAgB,EAAE,SAAiB,EAAE,IAAoB;IAC7F,MAAM,WAAW,GAAG,iBAAiB,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAA;IAC1D,OAAO,KAAK,EAAE,OAA2B,EAAoB,EAAE,CAAC,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,WAAW,CAAC,IAAI,CAAC,CAAC,CAAA;AACnH,CAAC","sourcesContent":["/*\n * chat-message 브로드캐스트 — 순수 로직 (토픽/페이로드/필터). 데코레이터·pubsub import 없음\n * → 단위테스트 가능. 데코레이터 resolver(chat-message-subscription.ts)와 pubsub 의존\n * publishChatMessage 는 별도.\n *\n * patch spine(board-patch-broadcast.ts)과 동일 컨벤션. 다만 채팅 메시지는 board 가 아니라\n * **ChatSession 단위**로 묶이므로 필터 키가 sessionId (+ domain 격리).\n */\nimport type { ChatMessage } from './chat-message.js' // type-only — 런타임 import 아님\nimport { mayReadSession } from '../chat-session/session-visibility.js'\n\n/** chat-message 브로드캐스트 토픽. */\nexport const CHAT_MESSAGE_TOPIC = 'board-ai-chat-message'\n\n/** 방송 페이로드 — 구독 필터(domain+session)용 메타 + ChatMessage 본체. */\nexport interface ChatMessagePayload {\n domainId: string\n sessionId?: string\n message: ChatMessage\n}\n\n/**\n * 같은 domain + session 구독자에게만 통과시키는 필터 (도메인 격리 = 보안 핵심).\n */\nexport function chatMessageFilter(domainId: string, sessionId: string) {\n return (payload: ChatMessagePayload): boolean =>\n !!payload && payload.domainId === domainId && payload.sessionId === sessionId\n}\n\n/*\n * ── 방이 이 사람에게 열려 있나 — ADR-0073 결정 2 ────────────────────────────\n *\n * 공개 대화는 도메인 안 누구나, 비공개 대화는 참여자만. 저장된 길(`chatMessages`)은 그것을 묻고\n * 있었고 방송 길은 묻지 않았다 — 구독 때 도메인 소속만 보고, 필터는 도메인 + 세션만 맞췄다. 그래서\n * 비공개 세션의 id 를 가진 도메인 안 누구나 그 대화를 실시간으로 받았다.\n *\n * 규칙은 한 줄(`mayReadSession`)이고 두 길이 같은 줄을 읽는다. 여기서 다시 적으면 그날부터 두 벌이다.\n */\n/** 방에 대한 두 물음. 저장소는 부르는 쪽이 준다 — 이 파일은 저장소를 모른다. */\nexport interface ChatRoomLookup {\n /** 그 세션의 지금 공개 범위. 이 도메인에 그런 세션이 없으면 `undefined`. */\n visibility(): Promise<string | null | undefined>\n /** 부르는 사람이 지금 그 세션의 참여자인가. */\n isParticipant(): Promise<boolean>\n}\n\nexport async function mayHearRoom(room: ChatRoomLookup): Promise<boolean> {\n const visibility = await room.visibility()\n\n /* 이 도메인에 없는 세션 — 열림으로 읽지 않는다. */\n if (visibility === undefined) return false\n\n /* 공개 방이면 참여 여부를 묻지 않는다. 방송마다 조회가 하나 준다. */\n if (mayReadSession(visibility, false)) return true\n\n return mayReadSession(visibility, await room.isParticipant())\n}\n\n/**\n * 같은 도메인 + 세션이고 **그 방이 지금 이 사람에게 열려 있을 때만** 통과한다.\n *\n * 방은 메시지마다 다시 묻는다 — 누가 듣고 있는 동안 세션이 비공개로 바뀔 수 있다. 사람의 대화라\n * 메시지가 드물고, 공개 방은 조회 하나로 끝난다.\n */\nexport function chatMessageRoomFilter(domainId: string, sessionId: string, room: ChatRoomLookup) {\n const sameSession = chatMessageFilter(domainId, sessionId)\n return async (payload: ChatMessagePayload): Promise<boolean> => sameSession(payload) && (await mayHearRoom(room))\n}\n"]}
1
+ {"version":3,"file":"chat-message-broadcast.js","sourceRoot":"","sources":["../../../server/service/chat-message/chat-message-broadcast.ts"],"names":[],"mappings":";;;AAwBA,8CAGC;AAqBD,kCAUC;AAQD,sDAGC;AA5DD,iFAAsE;AAEtE,8BAA8B;AACjB,QAAA,kBAAkB,GAAG,uBAAuB,CAAA;AASzD;;GAEG;AACH,SAAgB,iBAAiB,CAAC,QAAgB,EAAE,SAAiB;IACnE,OAAO,CAAC,OAA2B,EAAW,EAAE,CAC9C,CAAC,CAAC,OAAO,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ,IAAI,OAAO,CAAC,SAAS,KAAK,SAAS,CAAA;AACjF,CAAC;AAqBM,KAAK,UAAU,WAAW,CAAC,IAAoB;IACpD,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC,UAAU,EAAE,CAAA;IAE1C,+DAA+D;IAC/D,IAAI,UAAU,KAAK,SAAS;QAAE,OAAO,KAAK,CAAA;IAE1C,+EAA+E;IAC/E,IAAI,IAAA,sCAAc,EAAC,UAAU,EAAE,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IAElD,OAAO,IAAA,sCAAc,EAAC,UAAU,EAAE,MAAM,IAAI,CAAC,aAAa,EAAE,CAAC,CAAA;AAC/D,CAAC;AAED;;;;;GAKG;AACH,SAAgB,qBAAqB,CAAC,QAAgB,EAAE,SAAiB,EAAE,IAAoB;IAC7F,MAAM,WAAW,GAAG,iBAAiB,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAA;IAC1D,OAAO,KAAK,EAAE,OAA2B,EAAoB,EAAE,CAAC,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,WAAW,CAAC,IAAI,CAAC,CAAC,CAAA;AACnH,CAAC","sourcesContent":["/*\n * chat-message 브로드캐스트 — 순수 로직 (토픽/페이로드/필터). 데코레이터·pubsub import 없음\n * → 단위테스트 가능. 데코레이터 resolver(chat-message-subscription.ts)와 pubsub 의존\n * publishChatMessage 는 별도.\n *\n * patch spine(board-patch-broadcast.ts)과 동일 컨벤션. 다만 채팅 메시지는 board 가 아니라\n * **ChatSession 단위**로 묶이므로 필터 키가 sessionId (+ domain 격리).\n */\nimport type { ChatMessage } from './chat-message.js' // type-only — 런타임 import 아님\nimport { mayReadSession } from '../chat-session/session-visibility.js'\n\n/** chat-message 브로드캐스트 토픽. */\nexport const CHAT_MESSAGE_TOPIC = 'board-ai-chat-message'\n\n/** 방송 페이로드 — 구독 필터(domain+session)용 메타 + ChatMessage 본체. */\nexport interface ChatMessagePayload {\n domainId: string\n sessionId?: string\n message: ChatMessage\n}\n\n/**\n * 같은 domain + session 구독자에게만 통과시키는 필터 (도메인 격리 = 보안 핵심).\n */\nexport function chatMessageFilter(domainId: string, sessionId: string) {\n return (payload: ChatMessagePayload): boolean =>\n !!payload && payload.domainId === domainId && payload.sessionId === sessionId\n}\n\n/*\n * ── Is the room open to this person — ADR-0073 decision 2 ─────────────────────\n *\n * An open conversation is readable by anyone in the domain, a private one by its participants. The\n * stored path (`chatMessages`) asked that and the broadcast path did not — subscribing checked only\n * domain membership, and the filter matched only domain + session. So anyone in the domain holding a\n * private session's id received that conversation as it was written.\n *\n * The rule is one line (`mayReadSession`) and both paths read that line. Writing it again here would\n * make two copies from that day on.\n */\n/** The two questions about a room. The caller supplies the storage — this file does not know it. */\nexport interface ChatRoomLookup {\n /** The session's visibility now; `undefined` when this domain has no such session. */\n visibility(): Promise<string | null | undefined>\n /** Whether the caller sits in that session now. */\n isParticipant(): Promise<boolean>\n}\n\nexport async function mayHearRoom(room: ChatRoomLookup): Promise<boolean> {\n const visibility = await room.visibility()\n\n /* A session this domain does not have — never read as open. */\n if (visibility === undefined) return false\n\n /* An open room does not ask about membership — one query fewer per message. */\n if (mayReadSession(visibility, false)) return true\n\n return mayReadSession(visibility, await room.isParticipant())\n}\n\n/**\n * Passes only the same domain + session, and only while **that room is open to this person**.\n *\n * The room is asked again for every message — a session can be made private while someone is\n * listening. People write these messages, so they are infrequent, and an open room costs one query.\n */\nexport function chatMessageRoomFilter(domainId: string, sessionId: string, room: ChatRoomLookup) {\n const sameSession = chatMessageFilter(domainId, sessionId)\n return async (payload: ChatMessagePayload): Promise<boolean> => sameSession(payload) && (await mayHearRoom(room))\n}\n"]}
@@ -6,8 +6,8 @@ interface FindOne {
6
6
  }): Promise<any>;
7
7
  }
8
8
  /**
9
- * 방을 묻는 두 조회. **둘 다 부르는 사람의 도메인 안에서** 찾는다 — 저장된 길(`chatMessages`)의 질의와
10
- * 같은 조건이다.
9
+ * The two lookups about a room. **Both search within the caller's domain** — the same condition as
10
+ * the stored path's query (`chatMessages`).
11
11
  */
12
12
  export declare function chatRoomLookup(repos: {
13
13
  sessions: FindOne;
@@ -17,6 +17,6 @@ export declare function chatRoomLookup(repos: {
17
17
  sessionId: string;
18
18
  userId?: string;
19
19
  }): ChatRoomLookup;
20
- /** 구독이 돌려주는 스트림 — 같은 도메인 + 세션이고, 그 방이 메시지마다 이 사람에게 열려 있을 때만. */
20
+ /** The stream the subscription returns — same domain + session, and the room open to this person at every message. */
21
21
  export declare function chatMessageStream(domainId: string, sessionId: string, room: ChatRoomLookup): import("graphql-yoga").Repeater<import("./chat-message-broadcast.js").ChatMessagePayload, void, unknown>;
22
22
  export {};
@@ -3,19 +3,22 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.chatRoomLookup = chatRoomLookup;
4
4
  exports.chatMessageStream = chatMessageStream;
5
5
  /*
6
- * 방송 길의 방 — 구독이 돌려주는 스트림과, 방을 묻는 두 조회.
6
+ * The room on the broadcast path — the stream the subscription returns, and the two lookups that
7
+ * ask about the room.
7
8
  *
8
- * 구독 리졸버는 파라미터 데코레이터(@Root · @Arg) 때문에 jest 가 못 읽는다. 그래서 스트림을 짜는 일을
9
- * 여기로 뺐다 — 시험이 리졸버가 돌려주는 것과 **같은 것**을 돌린다(`chat-message-room.test.ts`).
9
+ * jest cannot read the subscription resolver (parameter decorators: @Root · @Arg), so composing the
10
+ * stream lives here — the test runs **the same thing** the resolver returns
11
+ * (`chat-message-room.test.ts`).
10
12
  *
11
- * 저장소는 부르는 쪽이 넘긴다. 엔티티를 여기서 import 하면 이 파일도 시험에서 못 읽는다.
13
+ * The caller passes the storage in. Importing the entities here would make this file unreadable to
14
+ * the tests too.
12
15
  */
13
16
  const graphql_yoga_1 = require("graphql-yoga");
14
17
  const shell_1 = require("@things-factory/shell");
15
18
  const chat_message_broadcast_js_1 = require("./chat-message-broadcast.js");
16
19
  /**
17
- * 방을 묻는 두 조회. **둘 다 부르는 사람의 도메인 안에서** 찾는다 — 저장된 길(`chatMessages`)의 질의와
18
- * 같은 조건이다.
20
+ * The two lookups about a room. **Both search within the caller's domain** — the same condition as
21
+ * the stored path's query (`chatMessages`).
19
22
  */
20
23
  function chatRoomLookup(repos, who) {
21
24
  return {
@@ -33,7 +36,7 @@ function chatRoomLookup(repos, who) {
33
36
  }
34
37
  };
35
38
  }
36
- /** 구독이 돌려주는 스트림 — 같은 도메인 + 세션이고, 그 방이 메시지마다 이 사람에게 열려 있을 때만. */
39
+ /** The stream the subscription returns — same domain + session, and the room open to this person at every message. */
37
40
  function chatMessageStream(domainId, sessionId, room) {
38
41
  return (0, graphql_yoga_1.pipe)(shell_1.pubsub.subscribe(chat_message_broadcast_js_1.CHAT_MESSAGE_TOPIC), (0, graphql_yoga_1.filter)((0, chat_message_broadcast_js_1.chatMessageRoomFilter)(domainId, sessionId, room)));
39
42
  }
@@ -1 +1 @@
1
- {"version":3,"file":"chat-message-room.js","sourceRoot":"","sources":["../../../server/service/chat-message/chat-message-room.ts"],"names":[],"mappings":";;AAqBA,wCAkBC;AAGD,8CAEC;AA5CD;;;;;;;GAOG;AACH,+CAA2C;AAC3C,iDAA8C;AAE9C,2EAA4G;AAM5G;;;GAGG;AACH,SAAgB,cAAc,CAC5B,KAAmD,EACnD,GAA6D;IAE7D,OAAO;QACL,KAAK,CAAC,UAAU;YACd,MAAM,OAAO,GAAG,MAAM,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,QAAQ,EAAE,EAAE,EAAE,CAAC,CAAA;YAC5G,OAAO,OAAO,CAAC,CAAC,CAAC,CAAE,OAAe,CAAC,UAAU,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;QACpE,CAAC;QAED,KAAK,CAAC,aAAa;YACjB,IAAI,CAAC,GAAG,CAAC,MAAM;gBAAE,OAAO,KAAK,CAAA;YAC7B,MAAM,IAAI,GAAG,MAAM,KAAK,CAAC,YAAY,CAAC,OAAO,CAAC;gBAC5C,KAAK,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,SAAS,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,MAAM,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,QAAQ,EAAE,EAAE;aAClG,CAAC,CAAA;YACF,OAAO,CAAC,CAAC,IAAI,CAAA;QACf,CAAC;KACF,CAAA;AACH,CAAC;AAED,gEAAgE;AAChE,SAAgB,iBAAiB,CAAC,QAAgB,EAAE,SAAiB,EAAE,IAAoB;IACzF,OAAO,IAAA,mBAAI,EAAC,cAAM,CAAC,SAAS,CAAC,8CAAkB,CAAC,EAAE,IAAA,qBAAM,EAAC,IAAA,iDAAqB,EAAC,QAAQ,EAAE,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC,CAAA;AAC7G,CAAC","sourcesContent":["/*\n * 방송 길의 방 — 구독이 돌려주는 스트림과, 방을 묻는 두 조회.\n *\n * 구독 리졸버는 파라미터 데코레이터(@Root · @Arg) 때문에 jest 가 못 읽는다. 그래서 스트림을 짜는 일을\n * 여기로 뺐다 — 시험이 리졸버가 돌려주는 것과 **같은 것**을 돌린다(`chat-message-room.test.ts`).\n *\n * 저장소는 부르는 쪽이 넘긴다. 엔티티를 여기서 import 하면 이 파일도 시험에서 못 읽는다.\n */\nimport { filter, pipe } from 'graphql-yoga'\nimport { pubsub } from '@things-factory/shell'\n\nimport { CHAT_MESSAGE_TOPIC, chatMessageRoomFilter, type ChatRoomLookup } from './chat-message-broadcast.js'\n\ninterface FindOne {\n findOne(options: { where: any; select?: any }): Promise<any>\n}\n\n/**\n * 방을 묻는 두 조회. **둘 다 부르는 사람의 도메인 안에서** 찾는다 — 저장된 길(`chatMessages`)의 질의와\n * 같은 조건이다.\n */\nexport function chatRoomLookup(\n repos: { sessions: FindOne; participants: FindOne },\n who: { domainId: string; sessionId: string; userId?: string }\n): ChatRoomLookup {\n return {\n async visibility() {\n const session = await repos.sessions.findOne({ where: { id: who.sessionId, domain: { id: who.domainId } } })\n return session ? ((session as any).visibility ?? null) : undefined\n },\n\n async isParticipant() {\n if (!who.userId) return false\n const seat = await repos.participants.findOne({\n where: { session: { id: who.sessionId }, user: { id: who.userId }, domain: { id: who.domainId } }\n })\n return !!seat\n }\n }\n}\n\n/** 구독이 돌려주는 스트림 — 같은 도메인 + 세션이고, 그 방이 메시지마다 이 사람에게 열려 있을 때만. */\nexport function chatMessageStream(domainId: string, sessionId: string, room: ChatRoomLookup) {\n return pipe(pubsub.subscribe(CHAT_MESSAGE_TOPIC), filter(chatMessageRoomFilter(domainId, sessionId, room)))\n}\n"]}
1
+ {"version":3,"file":"chat-message-room.js","sourceRoot":"","sources":["../../../server/service/chat-message/chat-message-room.ts"],"names":[],"mappings":";;AAwBA,wCAkBC;AAGD,8CAEC;AA/CD;;;;;;;;;;GAUG;AACH,+CAA2C;AAC3C,iDAA8C;AAE9C,2EAA4G;AAM5G;;;GAGG;AACH,SAAgB,cAAc,CAC5B,KAAmD,EACnD,GAA6D;IAE7D,OAAO;QACL,KAAK,CAAC,UAAU;YACd,MAAM,OAAO,GAAG,MAAM,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,QAAQ,EAAE,EAAE,EAAE,CAAC,CAAA;YAC5G,OAAO,OAAO,CAAC,CAAC,CAAC,CAAE,OAAe,CAAC,UAAU,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;QACpE,CAAC;QAED,KAAK,CAAC,aAAa;YACjB,IAAI,CAAC,GAAG,CAAC,MAAM;gBAAE,OAAO,KAAK,CAAA;YAC7B,MAAM,IAAI,GAAG,MAAM,KAAK,CAAC,YAAY,CAAC,OAAO,CAAC;gBAC5C,KAAK,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,SAAS,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,MAAM,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,QAAQ,EAAE,EAAE;aAClG,CAAC,CAAA;YACF,OAAO,CAAC,CAAC,IAAI,CAAA;QACf,CAAC;KACF,CAAA;AACH,CAAC;AAED,sHAAsH;AACtH,SAAgB,iBAAiB,CAAC,QAAgB,EAAE,SAAiB,EAAE,IAAoB;IACzF,OAAO,IAAA,mBAAI,EAAC,cAAM,CAAC,SAAS,CAAC,8CAAkB,CAAC,EAAE,IAAA,qBAAM,EAAC,IAAA,iDAAqB,EAAC,QAAQ,EAAE,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC,CAAA;AAC7G,CAAC","sourcesContent":["/*\n * The room on the broadcast path — the stream the subscription returns, and the two lookups that\n * ask about the room.\n *\n * jest cannot read the subscription resolver (parameter decorators: @Root · @Arg), so composing the\n * stream lives here — the test runs **the same thing** the resolver returns\n * (`chat-message-room.test.ts`).\n *\n * The caller passes the storage in. Importing the entities here would make this file unreadable to\n * the tests too.\n */\nimport { filter, pipe } from 'graphql-yoga'\nimport { pubsub } from '@things-factory/shell'\n\nimport { CHAT_MESSAGE_TOPIC, chatMessageRoomFilter, type ChatRoomLookup } from './chat-message-broadcast.js'\n\ninterface FindOne {\n findOne(options: { where: any; select?: any }): Promise<any>\n}\n\n/**\n * The two lookups about a room. **Both search within the caller's domain** — the same condition as\n * the stored path's query (`chatMessages`).\n */\nexport function chatRoomLookup(\n repos: { sessions: FindOne; participants: FindOne },\n who: { domainId: string; sessionId: string; userId?: string }\n): ChatRoomLookup {\n return {\n async visibility() {\n const session = await repos.sessions.findOne({ where: { id: who.sessionId, domain: { id: who.domainId } } })\n return session ? ((session as any).visibility ?? null) : undefined\n },\n\n async isParticipant() {\n if (!who.userId) return false\n const seat = await repos.participants.findOne({\n where: { session: { id: who.sessionId }, user: { id: who.userId }, domain: { id: who.domainId } }\n })\n return !!seat\n }\n }\n}\n\n/** The stream the subscription returns — same domain + session, and the room open to this person at every message. */\nexport function chatMessageStream(domainId: string, sessionId: string, room: ChatRoomLookup) {\n return pipe(pubsub.subscribe(CHAT_MESSAGE_TOPIC), filter(chatMessageRoomFilter(domainId, sessionId, room)))\n}\n"]}
@@ -56,12 +56,13 @@ tslib_1.__decorate([
56
56
  const { sessionId } = args;
57
57
  await (0, shell_1.assertDomainSubscribeAllowed)(domain, user, (d, u) => process.superUserGranted(d, u));
58
58
  /*
59
- * **방을 묻는다** — ADR-0073 결정 2. 공개 방은 도메인 안 누구나, 비공개 방은 참여자만. 저장된 길
60
- * (`chatMessages`)은 이것을 물었고 방송 길은 묻지 않아, 비공개 세션의 id 를 가진 도메인 안 누구나
61
- * 그 대화를 실시간으로 받았다.
59
+ * **Ask about the room** — ADR-0073 decision 2. An open room is for anyone in the domain, a private
60
+ * one for its participants. The stored path (`chatMessages`) asked this and the broadcast path did
61
+ * not, so anyone in the domain holding a private session's id received it live.
62
62
  *
63
- * 못 들어오는 방은 빈 스트림을 주지 않고 거절한다. 없는 세션과 같은 말이다 — 비공개 방이 있다는
64
- * 사실도 알려 주지 않는다. 구독이 선 뒤에도 방은 메시지마다 다시 묻는다(`chatMessageRoomFilter`).
63
+ * A room the caller cannot enter is refused rather than handed an empty stream, in the same words
64
+ * as a missing session — so a private room's existence is not confirmed either. Once subscribed,
65
+ * the room is asked again for every message (`chatMessageRoomFilter`).
65
66
  */
66
67
  const room = (0, chat_message_room_js_1.chatRoomLookup)({ sessions: (0, shell_1.getRepository)(chat_session_js_1.ChatSession), participants: (0, shell_1.getRepository)(chat_session_participant_js_1.ChatSessionParticipant) }, { domainId: domain.id, sessionId, userId: user?.id });
67
68
  if (!(await (0, chat_message_broadcast_js_1.mayHearRoom)(room)))
@@ -1 +1 @@
1
- {"version":3,"file":"chat-message-subscription.js","sourceRoot":"","sources":["../../../server/service/chat-message/chat-message-subscription.ts"],"names":[],"mappings":";;;;AAAA;;;;;;;;;;;;;;GAcG;AACH,+CAAqE;AACrE,iDAAmF;AACnF,uDAA+C;AAC/C,qEAA6D;AAC7D,yGAAgG;AAChG,2EAAkF;AAClF,iEAA0E;AAC1E,qDAA6C;AAE7C,8EAA8E;AAC9E,qEAA8D;AAArD,6HAAA,kBAAkB,OAAA;AAC3B,yEAAmF;AAA1E,+HAAA,kBAAkB,OAAA;AAAE,8HAAA,iBAAiB,OAAA;AAIvC,IAAM,uBAAuB,GAA7B,MAAM,uBAAuB;IAqC5B,AAJN;;;OAGG;IACH,KAAK,CAAC,mBAAmB,CACf,OAA2B,EACjB,SAAiB,EAC5B,OAAY;QAEnB,MAAM,CAAC,OAAO,CAAC,GAAG,MAAM,IAAA,2BAAU,EAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAA;QAC9D,OAAO,OAAsB,CAAA;IAC/B,CAAC;CACF,CAAA;AA7CY,0DAAuB;AAqC5B;IApCL,IAAA,2BAAY,EAAC,GAAG,EAAE,CAAC,6BAAW,EAAE;QAC/B;;;;;WAKG;QACH,WAAW,EACT,gGAAgG;QAClG,SAAS,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE;YACrC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;YACtC,MAAM,EAAE,SAAS,EAAE,GAAG,IAAI,CAAA;YAE1B,MAAM,IAAA,oCAA4B,EAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,gBAAgB,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;YAE1F;;;;;;;eAOG;YACH,MAAM,IAAI,GAAG,IAAA,qCAAc,EACzB,EAAE,QAAQ,EAAE,IAAA,qBAAa,EAAC,6BAAW,CAAC,EAAE,YAAY,EAAE,IAAA,qBAAa,EAAC,oDAAsB,CAAC,EAAE,EAC7F,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,EAAE,CACrD,CAAA;YACD,IAAI,CAAC,CAAC,MAAM,IAAA,uCAAW,EAAC,IAAI,CAAC,CAAC;gBAAE,MAAM,IAAI,KAAK,CAAC,wBAAwB,CAAC,CAAA;YAEzE,OAAO,IAAA,wCAAiB,EAAC,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,CAAC,CAAA;QACtD,CAAC;KACF,CAAC;IACF;;;OAGG;;IAEA,mBAAA,IAAA,mBAAI,GAAE,CAAA;IACN,mBAAA,IAAA,kBAAG,EAAC,WAAW,CAAC,CAAA;IAChB,mBAAA,IAAA,kBAAG,GAAE,CAAA;;;;kEAIP;kCA5CU,uBAAuB;IADnC,IAAA,uBAAQ,EAAC,6BAAW,CAAC;GACT,uBAAuB,CA6CnC","sourcesContent":["/*\n * ChatMessageSubscription — 그룹챗 메시지 실시간 방송.\n *\n * 클래스 이름이 `BoardChatMessageSubscription` 이었다. 전선 이름(`chatMessageActivity`)은 처음부터\n * 중립이었고 클래스 이름만 보드였다 — 중립 패키지로 옮기면서 고쳤다(2026-09-10). 전선이 안 바뀌므로\n * 소비처는 그대로다.\n *\n * ChatMessage(사용자 / AI / system) 가 영속될 때마다 같은 세션 구독자에게 방송 →\n * 그룹챗의 척추. 클라이언트(board-ai-chat)가 구독해 원격 메시지를 transcript 에 append.\n * 자기 발신 에코는 클라이언트가 message.id 로 dedup (mutation 응답으로 이미 렌더).\n *\n * patch spine(board-patch-subscription.ts)과 동일 패턴 — PubSub + graphql-ws, domain 격리.\n * 차이: 필터 키가 board 가 아닌 sessionId (채팅은 ChatSession 단위).\n * 순수 로직(토픽/필터)은 chat-message-broadcast.ts (테스트 가능). 여기엔 pubsub 의존만.\n */\nimport { Resolver, Subscription, Root, Arg, Ctx } from 'type-graphql'\nimport { assertDomainSubscribeAllowed, getRepository } from '@things-factory/shell'\nimport { ChatMessage } from './chat-message.js'\nimport { ChatSession } from '../chat-session/chat-session.js'\nimport { ChatSessionParticipant } from '../chat-session-participant/chat-session-participant.js'\nimport { mayHearRoom, type ChatMessagePayload } from './chat-message-broadcast.js'\nimport { chatMessageStream, chatRoomLookup } from './chat-message-room.js'\nimport { readerGate } from './reader-gate.js'\n\n// publishChatMessage 는 데코레이터 없는 chat-message-publish.ts 에 분리 (테스트 import 가능).\nexport { publishChatMessage } from './chat-message-publish.js'\nexport { CHAT_MESSAGE_TOPIC, chatMessageFilter } from './chat-message-broadcast.js'\nexport type { ChatMessagePayload } from './chat-message-broadcast.js'\n\n@Resolver(ChatMessage)\nexport class ChatMessageSubscription {\n @Subscription(() => ChatMessage, {\n /*\n * 돌려주는 형을 **명시한다**. 이 핸들러가 async 가 되면서(받는 사람마다 가리기 — ADR-0073 판정 1)\n * 반환형이 `Promise<ChatMessage>` 가 되었고, type-graphql 의 reflection 은 Promise 안을 못 본다.\n * 빠지면 스키마를 세울 때 NoExplicitTypeError 로 **패키지 적재 자체가 실패한다** — 시험이 아니라\n * 부팅이 죽는다. plant 레인이 설치본에서 이 오류를 잡아 알려 주었다(2026-09-18).\n */\n description:\n 'Subscribes to chat messages (user / assistant / system) of a session for real-time group chat.',\n subscribe: async ({ args, context }) => {\n const { domain, user } = context.state\n const { sessionId } = args\n\n await assertDomainSubscribeAllowed(domain, user, (d, u) => process.superUserGranted(d, u))\n\n /*\n * **방을 묻는다** — ADR-0073 결정 2. 공개 방은 도메인 안 누구나, 비공개 방은 참여자만. 저장된 길\n * (`chatMessages`)은 이것을 물었고 방송 길은 묻지 않아, 비공개 세션의 id 를 가진 도메인 안 누구나\n * 그 대화를 실시간으로 받았다.\n *\n * 못 들어오는 방은 빈 스트림을 주지 않고 거절한다. 없는 세션과 같은 말이다 — 비공개 방이 있다는\n * 사실도 알려 주지 않는다. 구독이 선 뒤에도 방은 메시지마다 다시 묻는다(`chatMessageRoomFilter`).\n */\n const room = chatRoomLookup(\n { sessions: getRepository(ChatSession), participants: getRepository(ChatSessionParticipant) },\n { domainId: domain.id, sessionId, userId: user?.id }\n )\n if (!(await mayHearRoom(room))) throw new Error('chat session not found')\n\n return chatMessageStream(domain.id, sessionId, room)\n }\n })\n /*\n * 방송도 **받는 사람 기준으로** 가린다 — ADR-0073 판정 1. 저장해 놓고 읽을 때만 가리면, 그 자리에\n * 앉아 있던 사람은 방송으로 원문을 받는다. 같은 값이 두 길로 나가고 한쪽만 문을 본다.\n */\n async chatMessageActivity(\n @Root() payload: ChatMessagePayload,\n @Arg('sessionId') sessionId: string,\n @Ctx() context: any\n ): Promise<ChatMessage> {\n const [message] = await readerGate(context)([payload.message])\n return message as ChatMessage\n }\n}\n"]}
1
+ {"version":3,"file":"chat-message-subscription.js","sourceRoot":"","sources":["../../../server/service/chat-message/chat-message-subscription.ts"],"names":[],"mappings":";;;;AAAA;;;;;;;;;;;;;;GAcG;AACH,+CAAqE;AACrE,iDAAmF;AACnF,uDAA+C;AAC/C,qEAA6D;AAC7D,yGAAgG;AAChG,2EAAkF;AAClF,iEAA0E;AAC1E,qDAA6C;AAE7C,8EAA8E;AAC9E,qEAA8D;AAArD,6HAAA,kBAAkB,OAAA;AAC3B,yEAAmF;AAA1E,+HAAA,kBAAkB,OAAA;AAAE,8HAAA,iBAAiB,OAAA;AAIvC,IAAM,uBAAuB,GAA7B,MAAM,uBAAuB;IAsC5B,AAJN;;;OAGG;IACH,KAAK,CAAC,mBAAmB,CACf,OAA2B,EACjB,SAAiB,EAC5B,OAAY;QAEnB,MAAM,CAAC,OAAO,CAAC,GAAG,MAAM,IAAA,2BAAU,EAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAA;QAC9D,OAAO,OAAsB,CAAA;IAC/B,CAAC;CACF,CAAA;AA9CY,0DAAuB;AAsC5B;IArCL,IAAA,2BAAY,EAAC,GAAG,EAAE,CAAC,6BAAW,EAAE;QAC/B;;;;;WAKG;QACH,WAAW,EACT,gGAAgG;QAClG,SAAS,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE;YACrC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;YACtC,MAAM,EAAE,SAAS,EAAE,GAAG,IAAI,CAAA;YAE1B,MAAM,IAAA,oCAA4B,EAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,gBAAgB,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;YAE1F;;;;;;;;eAQG;YACH,MAAM,IAAI,GAAG,IAAA,qCAAc,EACzB,EAAE,QAAQ,EAAE,IAAA,qBAAa,EAAC,6BAAW,CAAC,EAAE,YAAY,EAAE,IAAA,qBAAa,EAAC,oDAAsB,CAAC,EAAE,EAC7F,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,EAAE,CACrD,CAAA;YACD,IAAI,CAAC,CAAC,MAAM,IAAA,uCAAW,EAAC,IAAI,CAAC,CAAC;gBAAE,MAAM,IAAI,KAAK,CAAC,wBAAwB,CAAC,CAAA;YAEzE,OAAO,IAAA,wCAAiB,EAAC,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,CAAC,CAAA;QACtD,CAAC;KACF,CAAC;IACF;;;OAGG;;IAEA,mBAAA,IAAA,mBAAI,GAAE,CAAA;IACN,mBAAA,IAAA,kBAAG,EAAC,WAAW,CAAC,CAAA;IAChB,mBAAA,IAAA,kBAAG,GAAE,CAAA;;;;kEAIP;kCA7CU,uBAAuB;IADnC,IAAA,uBAAQ,EAAC,6BAAW,CAAC;GACT,uBAAuB,CA8CnC","sourcesContent":["/*\n * ChatMessageSubscription — 그룹챗 메시지 실시간 방송.\n *\n * 클래스 이름이 `BoardChatMessageSubscription` 이었다. 전선 이름(`chatMessageActivity`)은 처음부터\n * 중립이었고 클래스 이름만 보드였다 — 중립 패키지로 옮기면서 고쳤다(2026-09-10). 전선이 안 바뀌므로\n * 소비처는 그대로다.\n *\n * ChatMessage(사용자 / AI / system) 가 영속될 때마다 같은 세션 구독자에게 방송 →\n * 그룹챗의 척추. 클라이언트(board-ai-chat)가 구독해 원격 메시지를 transcript 에 append.\n * 자기 발신 에코는 클라이언트가 message.id 로 dedup (mutation 응답으로 이미 렌더).\n *\n * patch spine(board-patch-subscription.ts)과 동일 패턴 — PubSub + graphql-ws, domain 격리.\n * 차이: 필터 키가 board 가 아닌 sessionId (채팅은 ChatSession 단위).\n * 순수 로직(토픽/필터)은 chat-message-broadcast.ts (테스트 가능). 여기엔 pubsub 의존만.\n */\nimport { Resolver, Subscription, Root, Arg, Ctx } from 'type-graphql'\nimport { assertDomainSubscribeAllowed, getRepository } from '@things-factory/shell'\nimport { ChatMessage } from './chat-message.js'\nimport { ChatSession } from '../chat-session/chat-session.js'\nimport { ChatSessionParticipant } from '../chat-session-participant/chat-session-participant.js'\nimport { mayHearRoom, type ChatMessagePayload } from './chat-message-broadcast.js'\nimport { chatMessageStream, chatRoomLookup } from './chat-message-room.js'\nimport { readerGate } from './reader-gate.js'\n\n// publishChatMessage 는 데코레이터 없는 chat-message-publish.ts 에 분리 (테스트 import 가능).\nexport { publishChatMessage } from './chat-message-publish.js'\nexport { CHAT_MESSAGE_TOPIC, chatMessageFilter } from './chat-message-broadcast.js'\nexport type { ChatMessagePayload } from './chat-message-broadcast.js'\n\n@Resolver(ChatMessage)\nexport class ChatMessageSubscription {\n @Subscription(() => ChatMessage, {\n /*\n * 돌려주는 형을 **명시한다**. 이 핸들러가 async 가 되면서(받는 사람마다 가리기 — ADR-0073 판정 1)\n * 반환형이 `Promise<ChatMessage>` 가 되었고, type-graphql 의 reflection 은 Promise 안을 못 본다.\n * 빠지면 스키마를 세울 때 NoExplicitTypeError 로 **패키지 적재 자체가 실패한다** — 시험이 아니라\n * 부팅이 죽는다. plant 레인이 설치본에서 이 오류를 잡아 알려 주었다(2026-09-18).\n */\n description:\n 'Subscribes to chat messages (user / assistant / system) of a session for real-time group chat.',\n subscribe: async ({ args, context }) => {\n const { domain, user } = context.state\n const { sessionId } = args\n\n await assertDomainSubscribeAllowed(domain, user, (d, u) => process.superUserGranted(d, u))\n\n /*\n * **Ask about the room** — ADR-0073 decision 2. An open room is for anyone in the domain, a private\n * one for its participants. The stored path (`chatMessages`) asked this and the broadcast path did\n * not, so anyone in the domain holding a private session's id received it live.\n *\n * A room the caller cannot enter is refused rather than handed an empty stream, in the same words\n * as a missing session — so a private room's existence is not confirmed either. Once subscribed,\n * the room is asked again for every message (`chatMessageRoomFilter`).\n */\n const room = chatRoomLookup(\n { sessions: getRepository(ChatSession), participants: getRepository(ChatSessionParticipant) },\n { domainId: domain.id, sessionId, userId: user?.id }\n )\n if (!(await mayHearRoom(room))) throw new Error('chat session not found')\n\n return chatMessageStream(domain.id, sessionId, room)\n }\n })\n /*\n * 방송도 **받는 사람 기준으로** 가린다 — ADR-0073 판정 1. 저장해 놓고 읽을 때만 가리면, 그 자리에\n * 앉아 있던 사람은 방송으로 원문을 받는다. 같은 값이 두 길로 나가고 한쪽만 문을 본다.\n */\n async chatMessageActivity(\n @Root() payload: ChatMessagePayload,\n @Arg('sessionId') sessionId: string,\n @Ctx() context: any\n ): Promise<ChatMessage> {\n const [message] = await readerGate(context)([payload.message])\n return message as ChatMessage\n }\n}\n"]}
@@ -44,11 +44,12 @@ async function runRegisteredMode(kind, options = {}) {
44
44
  maxTokens: settings.maxTokens,
45
45
  hostContext: options.hostContext,
46
46
  /*
47
- * 이 하네스가 묻는 것은 **모드**다 — 「이 모드가 무엇을 여는가」이지 「이 사람이 문을 지나는가」가
48
- * 아니다. 관문이 `doors` 로 옮겨간 뒤(ADR-0071 결정 2) 검사를 안 넘기면 문을 적은 도구가 전부
49
- * 닫히고, 그러면 앱의 적합성 시험에서 **실제 자료를 읽는 도구만** 조용히 사라진다. 그래서 기본은
50
- * 모든 문을 지나는 사람이고, 다른 쪽을 보고 싶은 앱은 `toolAccess` 를 적어 그 경우를 쓴다.
51
- * 권한 축은 자기 시험이 따로 본다(`tool-access.test.ts` · `tool-scope.test.ts`).
47
+ * This harness asks about **the mode** — "what does this mode open", not "does this person pass the
48
+ * doors". Since the gate moved onto `doors` (ADR-0071 decision 2), passing no check closes every tool
49
+ * that names a door, and an app's conformance test would quietly lose **exactly the tools that read
50
+ * real data**. So the default is someone who passes every door; an app that wants the other case
51
+ * writes `toolAccess`. The permission axis has its own tests (`tool-access.test.ts` ·
52
+ * `tool-scope.test.ts`).
52
53
  */
53
54
  toolAccess: options.toolAccess ?? (async () => true),
54
55
  state: options.state ?? { domain: { id: 'dom-1' }, user: { id: 'u-1' } }
@@ -1 +1 @@
1
- {"version":3,"file":"mode-conformance.js","sourceRoot":"","sources":["../../../server/service/testing/mode-conformance.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 등록된 모드가 **선언한 대로 도는가** — 글자가 아니라 문을 지나서 본다.\n *\n * 모드 선언은 소스에 적힌 값이고, 사람이 만나는 것은 그 값이 문을 지나 만들어 낸 것이다. 그 둘이\n * 어긋나는 자리가 오늘 하루에만 여럿 나왔다: 범주에 없는 도구를 프롬프트가 부르라고 했고, 키는 있는데\n * 번역이 없었고, 부품에 규칙이 있는데 부르는 쪽이 없었다. 전부 「선언은 맞는데 돌지 않는」 모양이다.\n *\n * 그래서 앱의 시험이 이것으로 **자기 모드를 실제로 돌려 본다**:\n * · 그 이름으로 모드가 서는가\n * · 그 모드가 여는 도구가 무엇인가 — 모델에게 실제로 건네지는 목록\n * · 첫 턴 도구 호출을 강제하는가\n * · 화면에 낼 말이 **키가 아니라 문장**으로 나오는가\n * · 슬래시가 가리키는 도구가 그 모드 안에 있는가\n */\nimport { registryAssistantChat } from '@things-factory/ai-client-base'\n\nimport { getAssistantMode, getAssistantModeWarnings } from '../assistant-mode/registry.js'\nimport { assistantModeView, resolveModeSettings, type Translate } from '../assistant-mode/resolve.js'\n\nexport interface ModeRunResult {\n /** 이 모드가 모델에게 실제로 건넨 도구 이름. */\n offeredTools: string[]\n /** 모델이 받은 시스템 프롬프트 — 서버의 것인지 확인할 수 있다. */\n systemPrompt: string\n /** 첫 턴에 도구를 강제했는가. */\n requiredToolOnFirstTurn: boolean\n reply: string\n}\n\n/** 시험용 번역기 — 키를 그대로 돌려준다. 문장이 나와야 할 자리에 키가 나오면 눈에 띈다. */\nexport const keyEchoTranslate: Translate = key => key\n\n/**\n * 등록된 모드로 한 턴을 돌린다 — 프롬프트도 도구도 **등록에서** 나온다.\n *\n * 시험이 자기 값을 넘기면 그 시험은 선언을 안 본 것이다. 그래서 여기서는 `kind` 만 받는다.\n */\nexport async function runRegisteredMode(\n kind: string,\n options: { client: any; hostContext?: any; state?: any; toolAccess?: any; reply?: string } = {} as any\n): Promise<ModeRunResult> {\n const settings = resolveModeSettings({ kind }, keyEchoTranslate)\n let requiredToolOnFirstTurn = false\n\n const result = await registryAssistantChat({\n client: options.client,\n messages: [{ role: 'user', content: '한 턴' }] as any,\n systemPrompt: settings.systemPrompt,\n toolCategories: settings.toolCategories,\n requireToolOnFirstTurn: (requiredToolOnFirstTurn = Boolean(settings.requireToolOnFirstTurn)),\n model: settings.model,\n temperature: settings.temperature,\n maxTokens: settings.maxTokens,\n hostContext: options.hostContext,\n /*\n * 이 하네스가 묻는 것은 **모드**다 — 「이 모드가 무엇을 여는가」이지 「이 사람이 문을 지나는가」가\n * 아니다. 관문이 `doors` 로 옮겨간 뒤(ADR-0071 결정 2) 검사를 안 넘기면 문을 적은 도구가 전부\n * 닫히고, 그러면 앱의 적합성 시험에서 **실제 자료를 읽는 도구만** 조용히 사라진다. 그래서 기본은\n * 모든 문을 지나는 사람이고, 다른 쪽을 보고 싶은 앱은 `toolAccess` 를 적어 그 경우를 쓴다.\n * 권한 축은 자기 시험이 따로 본다(`tool-access.test.ts` · `tool-scope.test.ts`).\n */\n toolAccess: options.toolAccess ?? (async () => true),\n state: options.state ?? { domain: { id: 'dom-1' }, user: { id: 'u-1' } }\n })\n\n return {\n offeredTools: [...result.offeredTools],\n systemPrompt: settings.systemPrompt,\n requiredToolOnFirstTurn,\n reply: result.reply\n }\n}\n\nexport interface ModeWording {\n placeholder?: string\n /** 빈 대화의 제목과 한 줄 — 없으면 공용 대화창이 보드 제품의 글을 낸다. */\n intro?: { title: string; subtitle: string }\n /** 빈 대화의 예시 — 이 대화가 실제로 할 수 있는 말. */\n examples?: Array<{ label: string; items: string[] }>\n slash: { name: string; description: string; template: string }[]\n footerNotice?: string\n stagedNotice?: string\n}\n\n/** 화면에 낼 말 — 주어진 사전으로 옮긴 결과. 키가 그대로 남으면 사전에 없는 것이다. */\nexport function modeWording(kind: string, dictionary: Record<string, string>): ModeWording {\n const view = assistantModeView(kind, key => dictionary[key] ?? key)\n return {\n placeholder: view.placeholder,\n intro: view.intro,\n examples: view.examples,\n slash: view.slashTemplates,\n footerNotice: view.footerNotice,\n stagedNotice: view.stagedNotice\n }\n}\n\n/** 사전에 없어서 키가 그대로 화면에 나갈 말들. 비어 있어야 한다. */\nexport function missingWording(kind: string, dictionary: Record<string, string>): string[] {\n const missing: string[] = []\n const spec = getAssistantMode(kind)\n if (!spec) return [`mode:${kind}`]\n\n for (const key of [spec.placeholderKey, spec.footerNoticeKey, spec.stagedNoticeKey]) {\n if (key && !(key in dictionary)) missing.push(key)\n }\n /* 빈 대화의 말도 사전에 있어야 한다 — 없으면 화면에 키가 그대로 찍힌다. */\n for (const key of [spec.introKeys?.title, spec.introKeys?.subtitle]) {\n if (key && !(key in dictionary)) missing.push(key)\n }\n for (const group of spec.exampleKeys ?? []) {\n for (const key of [group.labelKey, ...group.itemKeys]) {\n if (!(key in dictionary)) missing.push(key)\n }\n }\n for (const slash of spec.slashTemplates ?? []) {\n for (const key of [slash.descriptionKey, slash.templateKey]) {\n if (!(key in dictionary)) missing.push(key)\n }\n }\n return missing\n}\n\n/** 이 모드가 자기 도구로 못 하는 일을 제안하는가 — 부팅 보고와 같은 답. */\nexport function modePromisesItCannotKeep(kind: string): string[] {\n return getAssistantModeWarnings()\n .filter(warning => warning.params.kind === kind)\n .map(warning => `${warning.code}:${warning.params.slash}→${warning.params.tool}`)\n}\n"]}
1
+ {"version":3,"file":"mode-conformance.js","sourceRoot":"","sources":["../../../server/service/testing/mode-conformance.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 등록된 모드가 **선언한 대로 도는가** — 글자가 아니라 문을 지나서 본다.\n *\n * 모드 선언은 소스에 적힌 값이고, 사람이 만나는 것은 그 값이 문을 지나 만들어 낸 것이다. 그 둘이\n * 어긋나는 자리가 오늘 하루에만 여럿 나왔다: 범주에 없는 도구를 프롬프트가 부르라고 했고, 키는 있는데\n * 번역이 없었고, 부품에 규칙이 있는데 부르는 쪽이 없었다. 전부 「선언은 맞는데 돌지 않는」 모양이다.\n *\n * 그래서 앱의 시험이 이것으로 **자기 모드를 실제로 돌려 본다**:\n * · 그 이름으로 모드가 서는가\n * · 그 모드가 여는 도구가 무엇인가 — 모델에게 실제로 건네지는 목록\n * · 첫 턴 도구 호출을 강제하는가\n * · 화면에 낼 말이 **키가 아니라 문장**으로 나오는가\n * · 슬래시가 가리키는 도구가 그 모드 안에 있는가\n */\nimport { registryAssistantChat } from '@things-factory/ai-client-base'\n\nimport { getAssistantMode, getAssistantModeWarnings } from '../assistant-mode/registry.js'\nimport { assistantModeView, resolveModeSettings, type Translate } from '../assistant-mode/resolve.js'\n\nexport interface ModeRunResult {\n /** 이 모드가 모델에게 실제로 건넨 도구 이름. */\n offeredTools: string[]\n /** 모델이 받은 시스템 프롬프트 — 서버의 것인지 확인할 수 있다. */\n systemPrompt: string\n /** 첫 턴에 도구를 강제했는가. */\n requiredToolOnFirstTurn: boolean\n reply: string\n}\n\n/** 시험용 번역기 — 키를 그대로 돌려준다. 문장이 나와야 할 자리에 키가 나오면 눈에 띈다. */\nexport const keyEchoTranslate: Translate = key => key\n\n/**\n * 등록된 모드로 한 턴을 돌린다 — 프롬프트도 도구도 **등록에서** 나온다.\n *\n * 시험이 자기 값을 넘기면 그 시험은 선언을 안 본 것이다. 그래서 여기서는 `kind` 만 받는다.\n */\nexport async function runRegisteredMode(\n kind: string,\n options: { client: any; hostContext?: any; state?: any; toolAccess?: any; reply?: string } = {} as any\n): Promise<ModeRunResult> {\n const settings = resolveModeSettings({ kind }, keyEchoTranslate)\n let requiredToolOnFirstTurn = false\n\n const result = await registryAssistantChat({\n client: options.client,\n messages: [{ role: 'user', content: '한 턴' }] as any,\n systemPrompt: settings.systemPrompt,\n toolCategories: settings.toolCategories,\n requireToolOnFirstTurn: (requiredToolOnFirstTurn = Boolean(settings.requireToolOnFirstTurn)),\n model: settings.model,\n temperature: settings.temperature,\n maxTokens: settings.maxTokens,\n hostContext: options.hostContext,\n /*\n * This harness asks about **the mode** — \"what does this mode open\", not \"does this person pass the\n * doors\". Since the gate moved onto `doors` (ADR-0071 decision 2), passing no check closes every tool\n * that names a door, and an app's conformance test would quietly lose **exactly the tools that read\n * real data**. So the default is someone who passes every door; an app that wants the other case\n * writes `toolAccess`. The permission axis has its own tests (`tool-access.test.ts` ·\n * `tool-scope.test.ts`).\n */\n toolAccess: options.toolAccess ?? (async () => true),\n state: options.state ?? { domain: { id: 'dom-1' }, user: { id: 'u-1' } }\n })\n\n return {\n offeredTools: [...result.offeredTools],\n systemPrompt: settings.systemPrompt,\n requiredToolOnFirstTurn,\n reply: result.reply\n }\n}\n\nexport interface ModeWording {\n placeholder?: string\n /** 빈 대화의 제목과 한 줄 — 없으면 공용 대화창이 보드 제품의 글을 낸다. */\n intro?: { title: string; subtitle: string }\n /** 빈 대화의 예시 — 이 대화가 실제로 할 수 있는 말. */\n examples?: Array<{ label: string; items: string[] }>\n slash: { name: string; description: string; template: string }[]\n footerNotice?: string\n stagedNotice?: string\n}\n\n/** 화면에 낼 말 — 주어진 사전으로 옮긴 결과. 키가 그대로 남으면 사전에 없는 것이다. */\nexport function modeWording(kind: string, dictionary: Record<string, string>): ModeWording {\n const view = assistantModeView(kind, key => dictionary[key] ?? key)\n return {\n placeholder: view.placeholder,\n intro: view.intro,\n examples: view.examples,\n slash: view.slashTemplates,\n footerNotice: view.footerNotice,\n stagedNotice: view.stagedNotice\n }\n}\n\n/** 사전에 없어서 키가 그대로 화면에 나갈 말들. 비어 있어야 한다. */\nexport function missingWording(kind: string, dictionary: Record<string, string>): string[] {\n const missing: string[] = []\n const spec = getAssistantMode(kind)\n if (!spec) return [`mode:${kind}`]\n\n for (const key of [spec.placeholderKey, spec.footerNoticeKey, spec.stagedNoticeKey]) {\n if (key && !(key in dictionary)) missing.push(key)\n }\n /* 빈 대화의 말도 사전에 있어야 한다 — 없으면 화면에 키가 그대로 찍힌다. */\n for (const key of [spec.introKeys?.title, spec.introKeys?.subtitle]) {\n if (key && !(key in dictionary)) missing.push(key)\n }\n for (const group of spec.exampleKeys ?? []) {\n for (const key of [group.labelKey, ...group.itemKeys]) {\n if (!(key in dictionary)) missing.push(key)\n }\n }\n for (const slash of spec.slashTemplates ?? []) {\n for (const key of [slash.descriptionKey, slash.templateKey]) {\n if (!(key in dictionary)) missing.push(key)\n }\n }\n return missing\n}\n\n/** 이 모드가 자기 도구로 못 하는 일을 제안하는가 — 부팅 보고와 같은 답. */\nexport function modePromisesItCannotKeep(kind: string): string[] {\n return getAssistantModeWarnings()\n .filter(warning => warning.params.kind === kind)\n .map(warning => `${warning.code}:${warning.params.slash}→${warning.params.tool}`)\n}\n"]}